Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93e654578a | |||
| 50ff372ca3 | |||
| b362e9bb40 | |||
| d6c83dd23d | |||
| 9952dbf4a7 | |||
| 6830bc6801 | |||
| 8456aa7f2d | |||
| 54b8f66b2f | |||
| db4c7af3e7 | |||
| 5ee4a3bb7c | |||
| 1d866f7a51 | |||
| a8eaa4c367 | |||
| 32b6af62e2 | |||
| af2dc9c38f | |||
| 7a04780b52 | |||
| 467651fc47 | |||
| 69c2da6808 | |||
| f94cf2c500 | |||
| c4ad2b968a | |||
| a8ddcc6296 | |||
| 2356bd6247 | |||
| 889e0ea8db | |||
| e1fb458eff | |||
| 8de69c34a1 | |||
| d6b7bd6209 | |||
| 8a749a3687 | |||
| 08fe190b3a | |||
| f7aeef87a8 | |||
| 2b7c1f92e7 | |||
| 1ed36ecdd8 | |||
| 7f84225ba8 | |||
| ba9f1b25e8 | |||
| 0d606c2ba2 | |||
| 06c050e446 | |||
| 286581bcba | |||
| 4dadfb954b | |||
| 83c69b48c2 | |||
| 4e594ddb09 | |||
| ef65681230 | |||
| a609d521a0 | |||
| 41ff4a61a9 | |||
| 4107da4c22 | |||
| 5d206bcb73 | |||
| 05634f5472 | |||
| ce9d0129b9 | |||
| 9210373fb2 | |||
| 44975620a7 | |||
| 0d92e5f998 | |||
| fc7c66d30e | |||
| b7cf448a4d | |||
| 1bec806c6e |
+50
-1
@@ -50,12 +50,61 @@ Thumbs.db
|
|||||||
|
|
||||||
# Runtime and calibration scratch files
|
# Runtime and calibration scratch files
|
||||||
/logs/
|
/logs/
|
||||||
|
# The camera SDK writes logs relative to the launch working directory.
|
||||||
|
MvSdkLog/
|
||||||
*.tmp
|
*.tmp
|
||||||
*.log
|
*.log
|
||||||
|
*.bak
|
||||||
|
*.orig
|
||||||
|
*.rej
|
||||||
|
|
||||||
|
# Operator/device-specific calibration artifacts
|
||||||
|
# Reproducible seed profiles remain under
|
||||||
|
# src/linkerhand_retarget/resource/linkerforce_v2/profiles/.
|
||||||
|
/profiles/
|
||||||
|
# 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
|
# ROS bag / MCAP recordings and CAN captures
|
||||||
rosbag2_*/
|
rosbag2_*/
|
||||||
|
/bags/
|
||||||
|
/recordings/
|
||||||
|
/captures/
|
||||||
|
/sessions/
|
||||||
|
/reports/
|
||||||
*.db3
|
*.db3
|
||||||
*.mcap
|
*.mcap
|
||||||
candump-*
|
candump-*
|
||||||
l10_*_state_*/
|
*_state_*/
|
||||||
|
|
||||||
|
# Local Codex/agent workspace metadata
|
||||||
|
/.agents/
|
||||||
|
/.codex/
|
||||||
|
/.codebuddy/
|
||||||
|
/.zcode/
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,8 @@
|
|||||||
# 1. LinkerFFG手套
|
# 1. LinkerFFG手套
|
||||||
|
|
||||||
|
> 当前FFG多手势标定、G20/O6 profile映射及实机操作请优先参考:
|
||||||
|
> [FFG多手势标定映射与遥操操作说明](docs/FFG多手势标定映射与遥操操作说明.md)。
|
||||||
|
|
||||||
## 1.1 产品介绍
|
## 1.1 产品介绍
|
||||||
本产品的具体介绍参考,内含标定示例说明
|
本产品的具体介绍参考,内含标定示例说明
|
||||||
附件1、Linker FFG(FFG01)产品说明手册
|
附件1、Linker FFG(FFG01)产品说明手册
|
||||||
@@ -416,4 +419,4 @@ if self.calibrationoriginal is not None and self.calibrationfistpose is not None
|
|||||||
|
|
||||||
改写成如上图的示例,即可启用右机械手的校准,启动后就会让机械手按照映射的角度固定在当前角度
|
改写成如上图的示例,即可启用右机械手的校准,启动后就会让机械手按照映射的角度固定在当前角度
|
||||||
|
|
||||||
当左右两手都达到期望的对指位置后,就可以恢复原状,按正常顺序使用遥操系统
|
当左右两手都达到期望的对指位置后,就可以恢复原状,按正常顺序使用遥操系统
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,985 @@
|
|||||||
|
# FFG多手势标定映射与遥操技术实现
|
||||||
|
|
||||||
|
## 1. 文档定位
|
||||||
|
|
||||||
|
本文面向 `linkerforce_v2` 的开发、联调和维护人员,说明新版FFG手套映射遥操链路的
|
||||||
|
软件架构、标定拟合方法、实时映射算法、ROS 2接口、profile约束和安全门控。
|
||||||
|
|
||||||
|
实机标定与启动步骤见
|
||||||
|
[《FFG多手势标定映射与遥操操作说明》](./FFG多手势标定映射与遥操操作说明.md)。
|
||||||
|
本文不重复完整操作流程,而是回答以下实现问题:
|
||||||
|
|
||||||
|
- 21维FFG数据如何变成模型无关的手部语义;
|
||||||
|
- 张手、桌面、钩拳、握拳四个锚点如何解耦根部和末端屈伸;
|
||||||
|
- G20和O6如何共用手套语义、同时保持各自的机械执行标尺;
|
||||||
|
- 捏合与握持为什么不会把整只手锁定到离散模板;
|
||||||
|
- `cmd_u8`、`actuation_target`、`q_nominal`三类目标有什么区别;
|
||||||
|
- profile如何生成、校验、配对和追踪;
|
||||||
|
- 节点在什么条件下允许或撤销实机控制。
|
||||||
|
|
||||||
|
当前实现基于:
|
||||||
|
|
||||||
|
- profile schema:`schema_version=1`;
|
||||||
|
- 手套侧:单只左手FFG,21维输入;
|
||||||
|
- 机械手侧:左手G20和左手O6;
|
||||||
|
- 映射模式:`factorized_paired_v2`;
|
||||||
|
- 机械手profile策略:`paired_continuous_v1`;
|
||||||
|
- 仿真策略:`semantic_urdf_v1`;
|
||||||
|
- 标定等级:`provisional`,没有真实关节角GT。
|
||||||
|
|
||||||
|
## 2. 代码组织
|
||||||
|
|
||||||
|
核心实现位于
|
||||||
|
[`linkerforce_v2`](../src/linkerhand_retarget/linkerhand_retarget/motion/linkerforce_v2/):
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `constants.py` | FFG关节名、手部语义名、静态/动态手势集合和默认参数 |
|
||||||
|
| `calibrate_glove.py` | 订阅FFG原始话题,交互采集完整标定和快速佩戴检查 |
|
||||||
|
| `calibrate_robot.py` | 从GUI命令、快照和SDK状态生成G20/O6实机profile |
|
||||||
|
| `calibration.py` | 鲁棒统计、FFG特征拟合、机械手通道权重和分段曲线拟合 |
|
||||||
|
| `profiles.py` | profile加载、严格校验、规范化哈希和原子保存 |
|
||||||
|
| `mapping.py` | 人手语义提取、连续配对映射、捏合/握持修正和命令滤波 |
|
||||||
|
| `node.py` | ROS 2实时节点、话题、服务、定时循环和安全门控 |
|
||||||
|
| `safety.py` | 与ROS无关的超时撤权判定 |
|
||||||
|
| `simulation.py` | 按关节名重排仿真目标并执行限位检查 |
|
||||||
|
| `quality.py` | 静态、捏合、动态轨迹的离线回放质量检查 |
|
||||||
|
| `verify_robot_profile.py` | 低速回放机械手标定姿势并生成独立人工复核报告 |
|
||||||
|
| `session_manifest.py` | 生成provisional数采清单并绑定profile、URDF和设备信息 |
|
||||||
|
|
||||||
|
ROS 2入口在
|
||||||
|
[`setup.py`](../src/linkerhand_retarget/setup.py),双手机型启动文件为
|
||||||
|
[`ffg_dual_g20_o6.launch.py`](../src/linkerhand_retarget/launch/ffg_dual_g20_o6.launch.py),
|
||||||
|
可提交的机械手种子profile位于
|
||||||
|
[`resource/linkerforce_v2/profiles`](../src/linkerhand_retarget/resource/linkerforce_v2/profiles/)。
|
||||||
|
|
||||||
|
## 3. 总体架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
FFG串口 / ROS JointState│ 21维左手套原始弧度 raw[21] │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
│ 可选逐维Kalman
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ HandIntentExtractor │
|
||||||
|
│ 21维 → 22维0~1人手语义 │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
│ 同一hand_intent
|
||||||
|
┌───────────────────┴───────────────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────────┐ ┌───────────────────┐
|
||||||
|
│ G20 RobotMapper │ │ O6 RobotMapper │
|
||||||
|
│ 16个主动执行语义 │ │ 6个主动执行语义 │
|
||||||
|
└─────────┬─────────┘ └─────────┬─────────┘
|
||||||
|
│ │
|
||||||
|
┌─────────┴─────────┐ ┌─────────┴─────────┐
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
20维cmd_u8 16维q_nominal 6维cmd_u8 6维q_nominal
|
||||||
|
G20实机电机空间 G20仿真弧度目标 O6实机电机空间 O6仿真弧度目标
|
||||||
|
```
|
||||||
|
|
||||||
|
架构的关键是分成三层:
|
||||||
|
|
||||||
|
1. **传感器层**:FFG原始值和佩戴差异由手套profile吸收;
|
||||||
|
2. **解剖语义层**:`hand_intent`只描述人的手部动作,不依赖G20或O6;
|
||||||
|
3. **执行器层**:每个机械手profile独立定义语义到本机电机命令和URDF目标的映射。
|
||||||
|
|
||||||
|
因此,换机械手通常不需要改手套语义提取器;换手套或操作者也不需要改G20/O6的
|
||||||
|
通道定义,只需重新建立对应profile并在运行时配对。
|
||||||
|
|
||||||
|
## 4. 数据契约
|
||||||
|
|
||||||
|
### 4.1 FFG 21维原始数据
|
||||||
|
|
||||||
|
原始输入使用 `sensor_msgs/msg/JointState`。名称和规范顺序为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
thumb_0 ... thumb_4
|
||||||
|
index_0 ... index_3
|
||||||
|
middle_0 ... middle_3
|
||||||
|
ring_0 ... ring_3
|
||||||
|
pinky_0 ... pinky_3
|
||||||
|
```
|
||||||
|
|
||||||
|
串口解析器将设备角度转换为弧度。实时节点只接受长度为21、全部有限的数据。
|
||||||
|
|
||||||
|
- 串口模式直接使用上述顺序;
|
||||||
|
- topic模式允许输入名称顺序不同,但要求名称集合完整且唯一,节点按规范顺序重排;
|
||||||
|
- 标定工具要求输入消息已经使用规范顺序;
|
||||||
|
- 不支持右手FFG,也不会因右手套缺失而退出。
|
||||||
|
|
||||||
|
普通四指每指4个原始量:第0维主要用于侧摆,第1~3维共同参与根部和末端屈伸拟合。
|
||||||
|
拇指5个原始量由不同组合共同拟合旋转、外展、对掌、根部屈伸和末端屈伸。
|
||||||
|
|
||||||
|
### 4.2 人手语义
|
||||||
|
|
||||||
|
`hand_intent`共22维,所有值限制在 `[0, 1]`:
|
||||||
|
|
||||||
|
| 类别 | 名称 |
|
||||||
|
|---|---|
|
||||||
|
| 拇指 | `thumb_rotate`、`thumb_abduction`、`thumb_opposition`、`thumb_root`、`thumb_tip` |
|
||||||
|
| 四指屈伸 | 每指的 `<finger>_root`、`<finger>_tip` |
|
||||||
|
| 四指侧摆 | 每指的 `<finger>_splay` |
|
||||||
|
| 捏合证据 | `pinch_index`、`pinch_middle`、`pinch_ring`、`pinch_pinky` |
|
||||||
|
| 整体握持 | `power_grasp` |
|
||||||
|
|
||||||
|
这里的0和1是由个人手套标定定义的语义端点,不是机械手角度,也不代表统一的物理角度。
|
||||||
|
|
||||||
|
### 4.3 G20命令空间
|
||||||
|
|
||||||
|
G20输出完整20维 `cmd_u8`:
|
||||||
|
|
||||||
|
| 下标 | 通道 |
|
||||||
|
|---:|---|
|
||||||
|
| 0 | `thumb_cmc_pitch` |
|
||||||
|
| 1~4 | `index/middle/ring/pinky_mcp_pitch` |
|
||||||
|
| 5 | `thumb_cmc_roll` |
|
||||||
|
| 6~9 | `index/middle/ring/pinky_mcp_roll` |
|
||||||
|
| 10 | `thumb_cmc_yaw` |
|
||||||
|
| 11~14 | `reserved_11`~`reserved_14`,固定为255 |
|
||||||
|
| 15 | `thumb_mcp` |
|
||||||
|
| 16~19 | `index/middle/ring/pinky_pip` |
|
||||||
|
|
||||||
|
其中16个通道是主动映射通道,4个保留通道不参与映射。新版种子profile中
|
||||||
|
`thumb_cmc_yaw`使用完整的 `[0, 255]` 命令范围,不再继承旧版的80下限。
|
||||||
|
|
||||||
|
### 4.4 O6命令空间
|
||||||
|
|
||||||
|
O6输出6维 `cmd_u8`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
thumb_cmc_pitch
|
||||||
|
thumb_cmc_yaw
|
||||||
|
index_mcp_pitch
|
||||||
|
middle_mcp_pitch
|
||||||
|
ring_mcp_pitch
|
||||||
|
pinky_mcp_pitch
|
||||||
|
```
|
||||||
|
|
||||||
|
O6没有独立的四指PIP和侧摆执行通道,因此每个普通手指的单一屈伸通道由
|
||||||
|
`root`和`tip`语义融合得到。
|
||||||
|
|
||||||
|
### 4.5 三种输出标尺
|
||||||
|
|
||||||
|
| 输出 | 范围/单位 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| `actuation_target` | `[0,1]` | 当前型号各主动通道的归一化语义激活量 |
|
||||||
|
| `cmd_u8_preview` / 实机命令 | `[0,255]` | 设备电机命令空间,包含机械耦合和本机标定 |
|
||||||
|
| `joint_target_nominal` | rad | 根据语义激活量和URDF名义端点生成的仿真目标 |
|
||||||
|
|
||||||
|
`state_u8`是SDK返回的设备状态,仍属于设备空间。它既不是编码器关节角,也不能作为
|
||||||
|
`q_nominal`或真实物理关节角的GT。
|
||||||
|
|
||||||
|
## 5. FFG手套profile的生成
|
||||||
|
|
||||||
|
### 5.1 鲁棒采样统计
|
||||||
|
|
||||||
|
每次采集保留:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sample_count
|
||||||
|
median[21]
|
||||||
|
mad[21]
|
||||||
|
raw_frames[N][21]
|
||||||
|
```
|
||||||
|
|
||||||
|
对第 `j` 维:
|
||||||
|
|
||||||
|
```text
|
||||||
|
median_j = median(raw[:, j])
|
||||||
|
MAD_j = median(abs(raw[:, j] - median_j))
|
||||||
|
```
|
||||||
|
|
||||||
|
每个静态姿势和动态轨迹还保存3次独立重复的上述统计。`approved_for_runtime=true`
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 11个静态姿势全部存在;
|
||||||
|
- 7个动态轨迹全部存在;
|
||||||
|
- 每项恰好3次重复;
|
||||||
|
- 每次至少50帧;
|
||||||
|
- 汇总帧数等于3次重复的帧数之和。
|
||||||
|
|
||||||
|
因此,CLI虽然允许修改静态 `--repeats`,但不是3次时生成的profile只能用于预览。
|
||||||
|
|
||||||
|
### 5.2 基础语义特征拟合
|
||||||
|
|
||||||
|
每个语义特征定义一组原始下标和带标签的标定姿势。以某个特征为例:
|
||||||
|
|
||||||
|
1. 从语义标签为0的姿势求原始端点 `low`;
|
||||||
|
2. 从语义标签为1的姿势求原始端点 `high`;
|
||||||
|
3. 将各标定姿势归一化为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
n_j = clip((raw[index_j] - low_j) / (high_j - low_j), 0, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 用最小二乘拟合各原始维度对语义标签的贡献;
|
||||||
|
5. 将负权重截为0,再归一化为权重和1;
|
||||||
|
6. 运行时计算:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feature = clip(sum(weight_j * n_j), 0, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
无有效跨度的维度不参与归一化。若拟合后所有权重都接近0,则回退为等权。
|
||||||
|
|
||||||
|
普通四指的 `root`和`tip`故意使用同一组3个屈伸传感器,但使用不同姿势标签:
|
||||||
|
|
||||||
|
| 姿势 | root目标 | tip目标 |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 张手/并拢 | 0 | 0 |
|
||||||
|
| 桌面 | 1 | 0 |
|
||||||
|
| 钩拳 | 0 | 1 |
|
||||||
|
| 握拳 | 1 | 1 |
|
||||||
|
|
||||||
|
这一设计先得到两个可能仍有耦合的初始特征,再由下一步二维标定面解耦。
|
||||||
|
|
||||||
|
### 5.3 根部—末端双线性解耦
|
||||||
|
|
||||||
|
对每个普通手指,在初始 `(root_feature, tip_feature)` 平面中取得四个锚点:
|
||||||
|
|
||||||
|
```text
|
||||||
|
p00 = 张手
|
||||||
|
p10 = 桌面
|
||||||
|
p01 = 钩拳
|
||||||
|
p11 = 握拳
|
||||||
|
```
|
||||||
|
|
||||||
|
建立双线性标定面:
|
||||||
|
|
||||||
|
```text
|
||||||
|
p(u, v) = p00
|
||||||
|
+ (p10 - p00) * u
|
||||||
|
+ (p01 - p00) * v
|
||||||
|
+ (p11 - p10 - p01 + p00) * u * v
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 `u`是解耦后的根部屈伸,`v`是解耦后的末端屈伸。运行时先用线性最小二乘得到
|
||||||
|
初值,再执行最多5次Newton迭代反解 `(u, v)`,最后限制到 `[0,1]`。
|
||||||
|
|
||||||
|
只有标定四边形在四角的Jacobian行列式符号一致,且最小绝对值不小于 `1e-3` 时才启用
|
||||||
|
该解码器。退化或发生折叠的标定面不会用于反解,此时保留基础特征结果。
|
||||||
|
|
||||||
|
### 5.4 动态屈伸对侧摆的串扰补偿
|
||||||
|
|
||||||
|
每个普通手指的独立屈伸往返轨迹假设该手指侧摆应基本不变。对每次重复:
|
||||||
|
|
||||||
|
```text
|
||||||
|
x = 0.5 * (root + tip)
|
||||||
|
y = splay
|
||||||
|
x, y分别减去各自中位数
|
||||||
|
coefficient = dot(x, y) / (dot(x, x) + ridge)
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 `ridge = 1e-3 * max(dot(x,x), 1e-6)`。
|
||||||
|
|
||||||
|
以下情况拒绝学习该次轨迹:
|
||||||
|
|
||||||
|
- 少于10帧、长度错误或存在非有限值;
|
||||||
|
- 屈伸变化范围小于0.05;
|
||||||
|
- 侧摆几乎没有变化,无法估计;
|
||||||
|
- 补偿后残差方差仍大于原方差的80%;
|
||||||
|
- 三次重复的有效系数方向互相矛盾。
|
||||||
|
|
||||||
|
最终系数取各有效重复的中位数并限制到 `[-1,1]`,运行时执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
splay_corrected = clip(
|
||||||
|
splay - coefficient * 0.5 * root
|
||||||
|
- coefficient * 0.5 * tip,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
补偿只发生在人手语义层,不直接学习或修改任何G20/O6电机系数。
|
||||||
|
|
||||||
|
### 5.5 捏合证据
|
||||||
|
|
||||||
|
每种捏合只使用拇指5维和目标手指4维。标定时保存:
|
||||||
|
|
||||||
|
```text
|
||||||
|
center = 目标捏合姿势中位数
|
||||||
|
scale = max(abs(center - open), 6 * pinch_pose_MAD, 0.02)
|
||||||
|
```
|
||||||
|
|
||||||
|
并计算张手到捏合中心的归一化距离 `open_distance`。运行时:
|
||||||
|
|
||||||
|
```text
|
||||||
|
distance = RMS((raw_selected - center) / scale)
|
||||||
|
pinch_strength = clip(1 - distance / open_distance, 0, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
这4个值是候选证据,不直接等于4个离散状态;最终是否施加捏合修正还要经过竞争门控。
|
||||||
|
|
||||||
|
### 5.6 快速佩戴检查
|
||||||
|
|
||||||
|
快速检查重新采集张手、握拳和食指捏合。每个姿势计算:
|
||||||
|
|
||||||
|
```text
|
||||||
|
normalized_error =
|
||||||
|
RMS((observed_median - reference_median) / max(6 * MAD, 0.05))
|
||||||
|
```
|
||||||
|
|
||||||
|
默认要求每个误差不大于4.0。凭据保存当前手套profile的规范化SHA-256、检查时间、
|
||||||
|
阈值、各姿势误差和通过状态。
|
||||||
|
|
||||||
|
实时节点只在加载profile时检查凭据:
|
||||||
|
|
||||||
|
- `kind=ffg_wear_check`;
|
||||||
|
- `passed=true`;
|
||||||
|
- 绑定哈希等于当前手套profile哈希;
|
||||||
|
- 凭据年龄在配置范围内,默认12小时。
|
||||||
|
|
||||||
|
节点不会在长时间运行期间周期性重新读取凭据或重新计算年龄。需要跨时段运行时,应按
|
||||||
|
作业流程主动重启节点并重新执行佩戴检查。
|
||||||
|
|
||||||
|
## 6. 机械手profile的生成
|
||||||
|
|
||||||
|
### 6.1 捕获数据
|
||||||
|
|
||||||
|
`hand_pose_capture`同时监听:
|
||||||
|
|
||||||
|
- GUI连续命令 `/<model>/cb_left_hand_control_cmd`;
|
||||||
|
- SDK状态 `/<model>/cb_left_hand_state`;
|
||||||
|
- GUI保存快照 `/<model>/calibration_pose_snapshot`。
|
||||||
|
|
||||||
|
消息名称允许任意顺序,但必须与seed中的 `command_names`集合完全一致;保存前统一重排为
|
||||||
|
profile顺序。每个姿势保存:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cmd_u8
|
||||||
|
command_names
|
||||||
|
state_u8
|
||||||
|
state_names
|
||||||
|
status = exact | approximate | unsupported
|
||||||
|
confirmed
|
||||||
|
captured_at
|
||||||
|
```
|
||||||
|
|
||||||
|
每次人工确认后立即原子写入checkpoint。恢复时会核对seed哈希、型号、输出路径、
|
||||||
|
SN、固件、CAN、操作者、命令名和姿势列表;身份不一致时拒绝续标。若旧checkpoint中
|
||||||
|
某个命令超出新的安全范围,只删除该姿势并要求重拍。
|
||||||
|
|
||||||
|
最终 `approved_for_control=true` 同时要求:
|
||||||
|
|
||||||
|
- 用户在最后明确批准;
|
||||||
|
- SN、CAN和操作者非空;
|
||||||
|
- 所有必需姿势均已确认;
|
||||||
|
- 命令与状态名称完整;
|
||||||
|
- 所有命令位于profile安全范围。
|
||||||
|
|
||||||
|
`unsupported`表示该姿势不参与对应运行时约束,但该姿势记录本身仍需人工确认并保存。
|
||||||
|
|
||||||
|
### 6.2 多源执行通道权重
|
||||||
|
|
||||||
|
一个机械手主动通道可以融合多个人手语义。对seed中列出的 `fit_sources`,使用
|
||||||
|
张手、桌面、钩拳和握拳的实机命令拟合。
|
||||||
|
|
||||||
|
先以张手和握拳命令归一化该通道:
|
||||||
|
|
||||||
|
```text
|
||||||
|
y_pose = (cmd_pose - cmd_open) / (cmd_fist - cmd_open)
|
||||||
|
```
|
||||||
|
|
||||||
|
设计矩阵来自各姿势的规范语义目标,然后执行最小二乘;负权重截为0并归一化。
|
||||||
|
|
||||||
|
- G20大部分主动通道只有一个语义源;
|
||||||
|
- O6普通手指通道同时使用对应的 `root`和`tip`,权重由实机捕获结果拟合;
|
||||||
|
- 若张手和握拳命令跨度退化,回退为等权。
|
||||||
|
|
||||||
|
### 6.3 分段曲线与单调约束
|
||||||
|
|
||||||
|
profile生成时,根据通道语义激活量和各标定姿势的 `cmd_u8`产生曲线点。
|
||||||
|
|
||||||
|
- `piecewise`:同一激活量的命令取中位数,然后按激活量排序;
|
||||||
|
- `monotonic_piecewise`:在上述基础上使用相邻违例合并算法执行等距单调回归;
|
||||||
|
- 曲线至少需要两个不同的激活量;
|
||||||
|
- 命令点必须位于 `[0,255]`和该通道 `command_bounds`内。
|
||||||
|
|
||||||
|
该profile曲线是一条可独立验证和追踪的型号级基线,也是在运行时手套锚点退化时的
|
||||||
|
单通道回退曲线。
|
||||||
|
|
||||||
|
## 7. 运行时分解式配对映射
|
||||||
|
|
||||||
|
### 7.1 配对曲线构造
|
||||||
|
|
||||||
|
`RobotMapper`同时收到手套profile和机械手profile时,不直接使用抽象规范姿势坐标,
|
||||||
|
而是:
|
||||||
|
|
||||||
|
1. 用当前手套profile的静态姿势中位数重新计算真实 `hand_intent`;
|
||||||
|
2. 找出手套和机械手共有且未标为 `unsupported` 的姿势;
|
||||||
|
3. 对每个机械手主动通道,选择真正定义该解剖通道的姿势;
|
||||||
|
4. 以当前手套语义激活量为横轴、当前实机profile命令为纵轴重建分段曲线;
|
||||||
|
5. 对单调通道再次执行单调回归。
|
||||||
|
|
||||||
|
姿势选择规则为:
|
||||||
|
|
||||||
|
| 通道 | 使用的基础姿势 |
|
||||||
|
|---|---|
|
||||||
|
| 普通四指屈伸 | 张手、并拢、桌面、钩拳、握拳中双方共有的姿势 |
|
||||||
|
| G20普通四指侧摆 | 并拢、张手 |
|
||||||
|
| 拇指基础通道 | 最大外展、张手、横跨掌心 |
|
||||||
|
|
||||||
|
捏合姿势不进入普通通道曲线,握拳也不直接进入拇指基础曲线;它们分别由局部残差分支
|
||||||
|
处理。这样,某个捏合捕获中的非目标手指残留命令不会污染普通手指曲线。
|
||||||
|
|
||||||
|
如果某个通道的实际手套锚点退化为少于两个不同激活量,该通道使用机械手profile中
|
||||||
|
已经校验的曲线;其他通道仍可保持配对曲线。
|
||||||
|
|
||||||
|
完成构造后,`mapping_mode`为 `factorized_paired_v2`。
|
||||||
|
|
||||||
|
### 7.2 基础通道映射
|
||||||
|
|
||||||
|
对第 `k` 个主动通道,其来源权重为 `w_ki`,当前人手语义为 `h_i`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
a_k = clip(sum(w_ki * h_i) / sum(w_ki), 0, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
`a_k`组成 `actuation_target`。基础电机命令由该通道配对曲线分段线性插值得到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cmd_base[index_k] = piecewise_linear(a_k, paired_points_k)
|
||||||
|
```
|
||||||
|
|
||||||
|
完整命令向量先以张手命令初始化,主动通道逐个覆盖;未映射的保留通道之后强制写回
|
||||||
|
固定值。
|
||||||
|
|
||||||
|
### 7.3 竞争式局部捏合修正
|
||||||
|
|
||||||
|
#### 7.3.1 标定自适应阈值
|
||||||
|
|
||||||
|
映射器先对手套profile中的所有静态姿势计算4种捏合分数。对每个真实捏合姿势记录:
|
||||||
|
|
||||||
|
- 目标分数;
|
||||||
|
- 目标分数相对其他3种分数的领先量。
|
||||||
|
|
||||||
|
对所有非捏合姿势记录:
|
||||||
|
|
||||||
|
- 最大误触分数;
|
||||||
|
- 第一名相对第二名的误触领先量。
|
||||||
|
|
||||||
|
满激活阈值取4个目标姿势中的最弱值,起始阈值位于最大负样本与满激活阈值之间的20%:
|
||||||
|
|
||||||
|
```text
|
||||||
|
score_onset = negative_score + 0.2 * (score_full - negative_score)
|
||||||
|
margin_onset = negative_margin + 0.2 * (margin_full - negative_margin)
|
||||||
|
```
|
||||||
|
|
||||||
|
若当前手套profile无法在正负样本间形成有效分数或领先量间隔,所有捏合门均保持0。
|
||||||
|
|
||||||
|
#### 7.3.2 单赢家连续门控
|
||||||
|
|
||||||
|
运行时仅选择当前分数最高的候选,并计算:
|
||||||
|
|
||||||
|
```text
|
||||||
|
score_gate = smoothstep((top_score - score_onset) / score_span)
|
||||||
|
margin_gate = smoothstep((top_score - second_score - margin_onset) / margin_span)
|
||||||
|
pinch_gate = score_gate * margin_gate
|
||||||
|
```
|
||||||
|
|
||||||
|
`smoothstep(x)=x²(3-2x)`,输入先限制到 `[0,1]`。其他3种捏合门为0。
|
||||||
|
|
||||||
|
因此:
|
||||||
|
|
||||||
|
- 证据不足时保持普通连续映射;
|
||||||
|
- 两种捏合证据接近时,领先量门控将修正降到0;
|
||||||
|
- 不存在确认帧数、进入/退出滞回或历史姿势锁存;
|
||||||
|
- 捏合切换只依赖当前帧,且权重连续变化。
|
||||||
|
|
||||||
|
#### 7.3.3 局部残差
|
||||||
|
|
||||||
|
对每种捏合,在该手套捏合中位数处先计算基础命令,再与机械手目标捏合命令做差:
|
||||||
|
|
||||||
|
```text
|
||||||
|
residual = robot_pinch_target - base_command_at_glove_pinch
|
||||||
|
```
|
||||||
|
|
||||||
|
运行时只把 `pinch_gate * residual`加到:
|
||||||
|
|
||||||
|
- 所有拇指主动通道;
|
||||||
|
- 当前目标手指的主动通道。
|
||||||
|
|
||||||
|
其他3根手指不参与该分支。G20的保留通道也不参与。
|
||||||
|
|
||||||
|
### 7.4 握持时的拇指协调
|
||||||
|
|
||||||
|
`power_grasp`是8个普通四指 `root/tip`语义的平均值。握持分数进一步要求拇指主动折叠:
|
||||||
|
|
||||||
|
```text
|
||||||
|
grasp_score = min(
|
||||||
|
power_grasp,
|
||||||
|
thumb_opposition,
|
||||||
|
thumb_root,
|
||||||
|
thumb_tip
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
满分取手套握拳姿势,负样本取其他静态姿势的最高分,门控同样使用从负样本到握拳分数
|
||||||
|
20%处开始的 `smoothstep`。
|
||||||
|
|
||||||
|
握持残差是机械手握拳目标与握拳处基础命令的差,但只施加到拇指主动通道。四指仍由
|
||||||
|
各自连续屈伸曲线决定,普通拇指动作也不会仅因四指弯曲而被强制成握拳拇指。
|
||||||
|
|
||||||
|
### 7.5 安全范围与保留通道
|
||||||
|
|
||||||
|
局部修正完成后依次执行:
|
||||||
|
|
||||||
|
1. 写回保留通道固定值;
|
||||||
|
2. 按每通道 `command_bounds`裁剪;
|
||||||
|
3. 执行可选命令滤波;
|
||||||
|
4. 再次裁剪并再次写回保留通道;
|
||||||
|
5. 最终四舍五入为整数命令。
|
||||||
|
|
||||||
|
`raw_command`保留滤波前浮点目标,当前ROS节点不发布该字段;`cmd_u8_preview`发布滤波后
|
||||||
|
并取整的最终目标。
|
||||||
|
|
||||||
|
## 8. 滤波与实时执行
|
||||||
|
|
||||||
|
### 8.1 输入Kalman
|
||||||
|
|
||||||
|
输入滤波是21个互相独立的一维Kalman滤波器,共享参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
P_pred = P + process_variance
|
||||||
|
K = P_pred / (P_pred + measurement_variance)
|
||||||
|
x = x + K * (z - x)
|
||||||
|
P = (1 - K) * P_pred
|
||||||
|
```
|
||||||
|
|
||||||
|
首帧、时间倒退或帧间隔超过 `input_filter_reset_gap` 时直接重置到当前测量,避免断流后
|
||||||
|
从旧状态缓慢追赶。默认关闭。
|
||||||
|
|
||||||
|
### 8.2 输出命令滤波
|
||||||
|
|
||||||
|
`CommandFilter`支持:
|
||||||
|
|
||||||
|
| 模式 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| `passthrough` | 直接使用本帧目标,仅应用deadband |
|
||||||
|
| `ema` | `step=clip(alpha*(target-last), ±max_step)` |
|
||||||
|
| `acceleration_limited` | 同时限制速度、帧间加速度,并根据剩余距离提前制动 |
|
||||||
|
|
||||||
|
默认参数匹配旧版左手G20的有效执行路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
input_filter_enabled=false
|
||||||
|
command_filter_mode=passthrough
|
||||||
|
command_filter_ema_alpha=1.0
|
||||||
|
command_filter_max_step_u8=255
|
||||||
|
command_filter_deadband_u8=0
|
||||||
|
```
|
||||||
|
|
||||||
|
实时节点没有单独暴露 `command_filter_max_acceleration_u8_per_frame2` 参数;
|
||||||
|
`acceleration_limited`模式下它使用与 `command_filter_max_step_u8`相同的值。
|
||||||
|
|
||||||
|
### 8.3 30 Hz最新帧策略
|
||||||
|
|
||||||
|
实时节点的处理定时器默认30 Hz:
|
||||||
|
|
||||||
|
1. 串口模式从线程安全快照取得最新序列号、数据和接收时刻;
|
||||||
|
2. 只有出现新FFG序列时,才发布/更新raw、filtered、intent和frame metadata;
|
||||||
|
3. 每个定时周期都用最近一次有效intent重新计算两个型号目标;
|
||||||
|
4. 预览始终发布,只有已使能型号才发布到SDK命令话题;
|
||||||
|
5. 硬件命令QoS为 `RELIABLE + KEEP_LAST(depth=1)`。
|
||||||
|
|
||||||
|
固定控制心跳不会排队重放旧手套帧。FFG停止更新时,节点可在超时窗口内短暂复用最后
|
||||||
|
intent,随后watchdog撤销使能。
|
||||||
|
|
||||||
|
使能某型号时,命令滤波器会重置到该型号最新有效SDK状态,而不是张手或上一次内部
|
||||||
|
目标,从而降低重新使能的第一帧跳变。
|
||||||
|
|
||||||
|
## 9. 独立仿真目标
|
||||||
|
|
||||||
|
仿真目标不从 `cmd_u8`反解。对主动通道激活量 `a_k`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
q_nominal_k = clip(
|
||||||
|
q_open_k + a_k * (q_closed_k - q_open_k),
|
||||||
|
q_lower_k,
|
||||||
|
q_upper_k
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
其输入是基础解剖通道激活量,不使用电机命令曲线,也不直接使用捏合或握持的电机残差。
|
||||||
|
因此实机姿势捕获中的机械耦合、偶然残留值和保留通道不会污染仿真弧度目标。
|
||||||
|
|
||||||
|
仿真消费者必须按 `JointState.name`建立映射。`simulation.py`提供:
|
||||||
|
|
||||||
|
- `build_name_mapping()`:检查空名、重名、缺名和多余名称;
|
||||||
|
- `reorder_named_target()`:按仿真模型顺序重排,检查有限值并应用仿真限位。
|
||||||
|
|
||||||
|
名称合同不满足时抛出 `JointNameMismatch`,不得按裸下标猜测。
|
||||||
|
|
||||||
|
profile中的 `urdf_sha256`用于追踪生成名义端点时对应的URDF版本,但实时映射节点本身
|
||||||
|
不读取或重新计算URDF文件哈希;数采manifest工具会执行文件哈希核对。
|
||||||
|
|
||||||
|
## 10. ROS 2实时节点
|
||||||
|
|
||||||
|
### 10.1 输入与输出话题
|
||||||
|
|
||||||
|
| 话题 | 类型 | 维度 | 发布条件 |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `/ffg/left/raw_joint_state` | `JointState` | 21 | 串口模式收到新帧;topic模式直接使用上游话题 |
|
||||||
|
| `/ffg/left/filtered_joint_state` | `JointState` | 21 | 有有效手套profile和新帧 |
|
||||||
|
| `/retarget/left/hand_intent` | `JointState` | 22 | 有有效手套profile和新帧 |
|
||||||
|
| `/retarget/left/frame_meta` | `String(JSON)` | - | 每个新映射手套帧 |
|
||||||
|
| `/retarget/g20/left/actuation_target` | `JointState` | 16 | G20 mapper有效 |
|
||||||
|
| `/retarget/o6/left/actuation_target` | `JointState` | 6 | O6 mapper有效 |
|
||||||
|
| `/retarget/g20/left/joint_target_nominal` | `JointState` | 16 | G20 mapper有效 |
|
||||||
|
| `/retarget/o6/left/joint_target_nominal` | `JointState` | 6 | O6 mapper有效 |
|
||||||
|
| `/retarget/g20/left/cmd_u8_preview` | `JointState` | 20 | G20 mapper有效 |
|
||||||
|
| `/retarget/o6/left/cmd_u8_preview` | `JointState` | 6 | O6 mapper有效 |
|
||||||
|
| `/g20/cb_left_hand_control_cmd` | `JointState` | 20 | G20已使能 |
|
||||||
|
| `/o6/cb_left_hand_control_cmd` | `JointState` | 6 | O6已使能 |
|
||||||
|
| `/ffg_dual_retarget/status` | `String(JSON)` | - | 1 Hz |
|
||||||
|
|
||||||
|
节点订阅:
|
||||||
|
|
||||||
|
| 话题 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `raw_input_topic` | `input_mode=topic`时的FFG输入,默认 `/ffg/left/raw_joint_state` |
|
||||||
|
| `/g20/cb_left_hand_state` | G20驱动状态心跳 |
|
||||||
|
| `/o6/cb_left_hand_state` | O6驱动状态心跳 |
|
||||||
|
|
||||||
|
topic输入模式不会再次向raw话题发布收到的消息,避免默认同名话题形成反馈。
|
||||||
|
|
||||||
|
驱动状态的名称必须已经按profile `command_names`规范顺序排列;这里与FFG topic输入不同,
|
||||||
|
不会对驱动状态按集合重排。
|
||||||
|
|
||||||
|
### 10.2 帧元数据
|
||||||
|
|
||||||
|
`frame_meta`包含:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp_ns": 0,
|
||||||
|
"sequence": 0,
|
||||||
|
"calibration": "provisional",
|
||||||
|
"q_gt": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
一个新手套帧产生的filtered、intent、frame_meta及该次定时周期的型号目标共用ROS时间戳。
|
||||||
|
定时器复用旧intent时,型号目标使用新的当前时间戳,但不会重复发布intent和frame_meta。
|
||||||
|
|
||||||
|
### 10.3 状态诊断
|
||||||
|
|
||||||
|
1 Hz状态JSON包含:
|
||||||
|
|
||||||
|
- 当前输入模式、FFG帧序号和数据年龄;
|
||||||
|
- G20/O6分别是否使能;
|
||||||
|
- glove、G20、O6的批准状态和SHA-256;
|
||||||
|
- `mapping_mode`、`simulation_mapping_mode`;
|
||||||
|
- 输入和命令滤波配置;
|
||||||
|
- 当前捏合/握持局部分支权重 `anchor_weights`;
|
||||||
|
- wear-check有效性;
|
||||||
|
- 驱动状态年龄;
|
||||||
|
- profile和URDF哈希;
|
||||||
|
- profile加载错误、最近故障和p95调度延迟。
|
||||||
|
|
||||||
|
`latency_p95_ms`以本机接收手套数据的单调时钟为起点,表示接收至映射调度的延迟,
|
||||||
|
不是基于设备硬件时间戳的端到端链路延迟。
|
||||||
|
|
||||||
|
### 10.4 服务与状态转换
|
||||||
|
|
||||||
|
| 服务 | 类型 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| `~/enable_g20` | `SetBool` | 单独申请/撤销G20实机控制 |
|
||||||
|
| `~/enable_o6` | `SetBool` | 单独申请/撤销O6实机控制 |
|
||||||
|
| `~/enable_all` | `SetBool` | 原子检查两台后同时使能,或同时撤销 |
|
||||||
|
| `~/emergency_stop` | `Trigger` | 立即撤销两个型号的命令发布权限 |
|
||||||
|
|
||||||
|
```text
|
||||||
|
显式SetBool(true)且全部检查通过
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ ▼
|
||||||
|
PREVIEW / DISABLED ENABLED(model)
|
||||||
|
▲ │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
SetBool(false)、超时、映射异常或软件急停
|
||||||
|
```
|
||||||
|
|
||||||
|
撤销使能的含义是停止向SDK命令话题发布新命令,不会主动发送张手、零位或其他安全姿势。
|
||||||
|
驱动/固件将保持最后命令相关行为;物理急停仍应由系统级安全链路负责。
|
||||||
|
|
||||||
|
## 11. 实机使能门控
|
||||||
|
|
||||||
|
某个型号从PREVIEW进入ENABLED前依次检查:
|
||||||
|
|
||||||
|
1. 手套profile已加载且 `approved_for_runtime=true`;
|
||||||
|
2. wear-check已通过启动时校验;
|
||||||
|
3. 机械手profile已加载且 `approved_for_control=true`;
|
||||||
|
4. 启动参数中的期望SN非空,并与profile SN完全一致;
|
||||||
|
5. profile CAN接口与启动配置一致;
|
||||||
|
6. mapper为 `factorized_paired_v2`;
|
||||||
|
7. FFG最近一帧未超过 `glove_timeout`,默认0.35秒;
|
||||||
|
8. 对应SDK状态名称、长度、数值和范围有效,且未超过 `driver_timeout`,默认1秒。
|
||||||
|
|
||||||
|
运行时身份门控比较SN和CAN接口,不比较profile中的固件版本。固件兼容性目前依赖操作
|
||||||
|
流程和数采manifest的可选校验;若固件变更会改变电机响应,应重新标定机械手profile。
|
||||||
|
|
||||||
|
`enable_all`先检查G20和O6两者,任意一个失败都不会使能任何一个。单型号服务互相独立。
|
||||||
|
|
||||||
|
## 12. Watchdog与故障策略
|
||||||
|
|
||||||
|
watchdog周期为50 ms:
|
||||||
|
|
||||||
|
| 故障 | 动作 |
|
||||||
|
|---|---|
|
||||||
|
| 任意型号已使能且FFG超时 | 同时撤销G20和O6 |
|
||||||
|
| 某型号SDK状态无效或超时 | 只撤销该型号,另一型号保持 |
|
||||||
|
| 映射计算出现数值/形状错误 | 同时撤销G20和O6 |
|
||||||
|
| 软件急停 | 同时撤销G20和O6 |
|
||||||
|
| profile加载失败 | 启动时降级;不创建对应mapper或只保留raw |
|
||||||
|
|
||||||
|
故障恢复不会自动重新使能。排除原因后必须再次调用对应使能服务。
|
||||||
|
|
||||||
|
节点启动和profile加载采用fail-closed策略:
|
||||||
|
|
||||||
|
- 无手套profile:只发布原始FFG;
|
||||||
|
- 手套profile有效但未获运行批准:允许完整预览,拒绝实机;
|
||||||
|
- seed机械手profile `approved_for_control=false`:允许预览,拒绝实机;
|
||||||
|
- 单个型号profile无效:另一个有效型号仍可生成目标和独立使能。
|
||||||
|
|
||||||
|
## 13. 主要ROS参数
|
||||||
|
|
||||||
|
### 13.1 FFG输入
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `input_mode` | `serial` | `serial`或`topic` |
|
||||||
|
| `raw_input_topic` | `/ffg/left/raw_joint_state` | topic模式输入 |
|
||||||
|
| `serial_port` | 空 | 指定串口;为空时可自动扫描 |
|
||||||
|
| `baudrate` | `0` | 大于0时优先尝试该波特率 |
|
||||||
|
| `baudrates` | `[2000000,460800,1000000,921600]` | 探测候选 |
|
||||||
|
| `auto_scan` | `true` | 指定端口失败或为空时扫描 |
|
||||||
|
| `serial_debug` | `false` | 串口调试日志 |
|
||||||
|
|
||||||
|
### 13.2 Profile与身份
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `glove_profile` | 空 | FFG完整标定profile |
|
||||||
|
| `wear_check` | 空 | 快速佩戴检查凭据 |
|
||||||
|
| `wear_check_max_age_hours` | `12.0` | 启动加载时允许的最大年龄 |
|
||||||
|
| `g20_profile` / `o6_profile` | 空 | 单机机械手profile |
|
||||||
|
| `g20_serial_number` / `o6_serial_number` | 空 | 运行期望SN |
|
||||||
|
| `g20_can_interface` | `can0` | G20身份核对 |
|
||||||
|
| `o6_can_interface` | `can1` | O6身份核对 |
|
||||||
|
|
||||||
|
### 13.3 时序和滤波
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `publish_rate` | `30.0` | 固定映射/命令心跳Hz |
|
||||||
|
| `glove_timeout` | `0.35` | FFG超时秒数 |
|
||||||
|
| `driver_timeout` | `1.0` | SDK状态超时秒数 |
|
||||||
|
| `input_filter_enabled` | `false` | 是否启用逐维Kalman |
|
||||||
|
| `input_filter_process_variance` | `1e-5` | Kalman过程噪声 |
|
||||||
|
| `input_filter_measurement_variance` | `5e-4` | Kalman测量噪声 |
|
||||||
|
| `input_filter_reset_gap` | `0.35` | 断流重置阈值 |
|
||||||
|
| `command_filter_mode` | `passthrough` | 输出滤波模式 |
|
||||||
|
| `command_filter_ema_alpha` | `1.0` | EMA/限加速度目标增益 |
|
||||||
|
| `command_filter_max_step_u8` | `255.0` | 每帧最大速度尺度 |
|
||||||
|
| `command_filter_deadband_u8` | `0.0` | 小于该差值时保持上一目标 |
|
||||||
|
|
||||||
|
启动文件还负责创建两个SDK节点,并设置启动速度、力矩、状态轮询和G20控制期间延迟状态
|
||||||
|
读取等驱动参数;这些不是 `ffg_dual_retarget`自身参数。
|
||||||
|
|
||||||
|
## 14. Profile校验与可追踪性
|
||||||
|
|
||||||
|
### 14.1 严格加载
|
||||||
|
|
||||||
|
`profiles.py`在构造mapper之前检查:
|
||||||
|
|
||||||
|
- schema、profile类型、型号和左手侧;
|
||||||
|
- 固定的FFG关节名或机械手命令名;
|
||||||
|
- 所有数组长度、有限值和范围;
|
||||||
|
- 特征权重非负且和为1;
|
||||||
|
- 分段曲线激活量、命令范围和单调性;
|
||||||
|
- 主动通道与保留通道完整覆盖命令向量;
|
||||||
|
- 仿真名称顺序、端点、限位和URDF哈希格式;
|
||||||
|
- 已批准profile的设备身份、人工确认、状态和名称完整性。
|
||||||
|
|
||||||
|
profile错误不会被静默修正为另一种型号或旧映射策略。
|
||||||
|
|
||||||
|
### 14.2 规范化哈希
|
||||||
|
|
||||||
|
profile哈希不是原文件字节哈希,而是:
|
||||||
|
|
||||||
|
1. 排除加载器添加的 `_profile_path`和 `_profile_sha256`;
|
||||||
|
2. JSON key排序;
|
||||||
|
3. 使用紧凑分隔符和UTF-8;
|
||||||
|
4. 计算SHA-256。
|
||||||
|
|
||||||
|
因此仅缩进或JSON键顺序变化不会改变profile身份,持久字段变化会改变哈希。
|
||||||
|
|
||||||
|
保存使用同目录临时文件加原子替换,避免中途退出留下半个JSON。
|
||||||
|
|
||||||
|
### 14.3 相关运行文件
|
||||||
|
|
||||||
|
| 文件 | 技术作用 |
|
||||||
|
|---|---|
|
||||||
|
| glove profile | 原始帧、鲁棒统计、特征参数和捏合锚点 |
|
||||||
|
| wear-check | 绑定glove profile哈希的短期佩戴凭据 |
|
||||||
|
| robot profile | 设备身份、姿势、通道曲线、安全范围和仿真端点 |
|
||||||
|
| checkpoint | 绑定seed和设备元数据的可恢复捕获进度 |
|
||||||
|
| verification | 绑定robot profile哈希的独立人工复核结果 |
|
||||||
|
| session manifest | 绑定profile、wear-check、URDF、设备和rosbag话题 |
|
||||||
|
|
||||||
|
`session_manifest.py`当前要求G20和O6都已批准,并按 `can0/can1`核对;它适用于标准双手
|
||||||
|
型号数采拓扑,不是任意单型号或任意CAN配置的通用manifest生成器。
|
||||||
|
|
||||||
|
## 15. 离线质量检查
|
||||||
|
|
||||||
|
`retarget_profile_check`不启动ROS、不连接机械手,直接回放profile中的原始帧和姿势。
|
||||||
|
|
||||||
|
### 15.1 静态复现
|
||||||
|
|
||||||
|
对每个共有姿势,只比较该姿势真正定义的相关通道:
|
||||||
|
|
||||||
|
- 捏合:拇指和目标手指;
|
||||||
|
- 拇指姿势:拇指通道;
|
||||||
|
- 并拢:侧摆通道;
|
||||||
|
- 桌面/钩拳:普通四指屈伸通道;
|
||||||
|
- 握拳:除普通侧摆外的通道。
|
||||||
|
|
||||||
|
任一相关通道最大误差大于5个u8单位,记为hard failure。
|
||||||
|
|
||||||
|
### 15.2 捏合混淆
|
||||||
|
|
||||||
|
四个捏合中位数必须:
|
||||||
|
|
||||||
|
- 竞争winner等于目标手指;
|
||||||
|
- 目标门控不小于0.95。
|
||||||
|
|
||||||
|
否则记为hard failure。
|
||||||
|
|
||||||
|
### 15.3 动态连续性和局部性
|
||||||
|
|
||||||
|
每组动态重复记录:
|
||||||
|
|
||||||
|
- 目标通道跨度;
|
||||||
|
- 非目标通道跨度;
|
||||||
|
- 原始浮点命令帧间步长p95和最大值;
|
||||||
|
- 取整后整帧不变比例;
|
||||||
|
- 应用profile执行滤波后的同类指标。
|
||||||
|
|
||||||
|
普通手指屈伸轨迹中,若非目标通道跨度中位数大于
|
||||||
|
`max(15, 0.2 * target_span)`,生成warning。四指开合轨迹中若任一屈伸语义范围中位数
|
||||||
|
大于0.5,也生成warning。
|
||||||
|
|
||||||
|
动态步长目前只报告统计量,没有统一hard-failure阈值;应结合采样率、动作速度和设备
|
||||||
|
允许步长分析。
|
||||||
|
|
||||||
|
离线通过只证明profile内部复现和分解逻辑满足这些判据,不证明实机物理角度精度。
|
||||||
|
|
||||||
|
## 16. 实机姿势复核实现
|
||||||
|
|
||||||
|
`hand_pose_verify`加载已批准机械手profile后:
|
||||||
|
|
||||||
|
1. 查询命令话题是否已有其他发布者,有则拒绝开始;
|
||||||
|
2. 要求显式输入安全确认;
|
||||||
|
3. 从最新SDK状态而不是上一次目标开始;
|
||||||
|
4. 将目标分成每通道步长不超过 `max_step_u8` 的线性序列;
|
||||||
|
5. 默认30 Hz发送,运动中周期检查新竞争发布者;
|
||||||
|
6. 稳定后读取命名状态并计算设备空间绝对误差;
|
||||||
|
7. 保存人工通过/失败、备注、目标、状态和误差摘要。
|
||||||
|
|
||||||
|
默认 `max_step_u8=4`,CLI硬限制不超过8。复核报告明确记录
|
||||||
|
`state_is_angle_ground_truth=false`,并且不修改原机械手profile。
|
||||||
|
|
||||||
|
## 17. 扩展和维护约束
|
||||||
|
|
||||||
|
### 17.1 增加新的机械手型号
|
||||||
|
|
||||||
|
至少需要:
|
||||||
|
|
||||||
|
1. 在 `MODEL_COMMAND_LENGTHS`登记型号和命令长度;
|
||||||
|
2. 定义唯一、稳定的 `command_names`;
|
||||||
|
3. 创建seed profile,包括姿势、安全范围、主动通道、保留通道和仿真端点;
|
||||||
|
4. 明确每个执行通道的解剖语义源;
|
||||||
|
5. 扩展profile校验器的必需姿势集合;
|
||||||
|
6. 扩展节点的话题、身份参数、状态和服务;
|
||||||
|
7. 增加静态复现、局部性、限位和名称合同测试。
|
||||||
|
|
||||||
|
不要通过复制G20的裸下标映射来接入新型号;名称、主动通道和保留通道必须显式定义。
|
||||||
|
|
||||||
|
### 17.2 增加新的手套语义
|
||||||
|
|
||||||
|
需要同步更新:
|
||||||
|
|
||||||
|
- `BASE_INTENT_NAMES`或派生语义列表;
|
||||||
|
- 标定姿势标签和原始下标;
|
||||||
|
- glove profile生成及严格校验;
|
||||||
|
- `HandIntentExtractor.extract()`输出顺序;
|
||||||
|
- 使用该语义的机械手seed和测试;
|
||||||
|
- rosbag/下游消费者的数据合同。
|
||||||
|
|
||||||
|
修改名称或顺序会影响profile兼容性,应升级schema而不是让旧profile静默通过。
|
||||||
|
|
||||||
|
### 17.3 修改手势或阈值
|
||||||
|
|
||||||
|
捏合和握持阈值由当前手套profile自动推导。优先修复标定数据或距离定义,不要增加隐藏
|
||||||
|
的全局常量绕过竞争判据。若确需改变门控公式,应同时更新:
|
||||||
|
|
||||||
|
- 正/负样本定义;
|
||||||
|
- 连续性和混淆测试;
|
||||||
|
- 离线质量报告;
|
||||||
|
- `mapping_mode`或schema版本,以便数据可追踪。
|
||||||
|
|
||||||
|
### 17.4 线程与实时性
|
||||||
|
|
||||||
|
- 串口读取在线程中更新带锁快照;
|
||||||
|
- ROS节点定时器只消费最新快照,不阻塞等待串口;
|
||||||
|
- 运行时没有无界命令队列;
|
||||||
|
- 标定和复核CLI可使用后台executor线程,因为它们包含交互式终端等待;
|
||||||
|
- 映射主要是小向量NumPy运算,不包含在线优化或模型推理。
|
||||||
|
|
||||||
|
## 18. 测试与验收建议
|
||||||
|
|
||||||
|
核心单元/集成测试集中在
|
||||||
|
[`test_linkerforce_v2.py`](../src/linkerhand_retarget/test/test_linkerforce_v2.py),覆盖:
|
||||||
|
|
||||||
|
- 鲁棒统计和profile完整性;
|
||||||
|
- 根部/末端解耦和曲线内部无平台;
|
||||||
|
- 捏合局部性、连续切换和无历史锁存;
|
||||||
|
- 普通手指对拇指动作的独立性;
|
||||||
|
- 动态侧摆串扰补偿;
|
||||||
|
- 仿真目标与电机残差隔离;
|
||||||
|
- 滤波步长、加速度、重置和默认直通行为;
|
||||||
|
- profile身份、checkpoint、安全范围和命名合同;
|
||||||
|
- 重使能重基准和超时撤权;
|
||||||
|
- G20/O6输出长度、名称和限位。
|
||||||
|
|
||||||
|
修改核心算法后至少执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
python3 -m pytest -q \
|
||||||
|
src/linkerhand_retarget/test/test_linkerforce_v2.py
|
||||||
|
```
|
||||||
|
|
||||||
|
完成profile标定后再分别执行G20和O6离线质量回放。涉及ROS接口、SDK命名或launch参数的
|
||||||
|
修改,还应在PREVIEW状态检查实际话题长度、名称、频率和status JSON,再进入低速实机
|
||||||
|
验收。
|
||||||
|
|
||||||
|
## 19. 已知边界
|
||||||
|
|
||||||
|
- 当前只支持左手FFG到左手G20/O6;
|
||||||
|
- `provisional`不提供真实物理关节角精度声明;
|
||||||
|
- `cmd_u8`和SDK `state_u8`不能转换成可靠的真实关节弧度;
|
||||||
|
- 仿真目标只代表语义—URDF名义映射,不是实机测量;
|
||||||
|
- 捏合竞争无时间滞回,连续性依赖当前帧证据质量和可选输入滤波;
|
||||||
|
- wear-check只在节点加载时验证,不在长时间运行中自动过期撤权;
|
||||||
|
- 运行时身份门控不核对固件版本;
|
||||||
|
- 软件急停只撤销发布权限,不替代硬件急停或独立安全控制器;
|
||||||
|
- 默认双型号launch会同时创建两个SDK驱动;单型号系统可直接启动所需驱动和
|
||||||
|
`ffg_dual_retarget`节点。
|
||||||
|
|
||||||
|
这些边界应保留在数据报告、实验结论和对外精度声明中。
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
# FFG多手势标定映射与遥操操作说明
|
||||||
|
|
||||||
|
## 1. 文档目的
|
||||||
|
|
||||||
|
本文档说明当前 `linkerforce_v2` 无Marker方案的工作原理、标定流程和实机操作方法。
|
||||||
|
当前主要使用场景是一只左手FFG控制左手G20,也支持在配置对应profile后同时生成O6目标。
|
||||||
|
|
||||||
|
当前方案属于 `provisional` 阶段:
|
||||||
|
|
||||||
|
- 可以验证手套语义、机械手通道、方向、动作范围和连续性;
|
||||||
|
- 可以用于演示和临时数采;
|
||||||
|
- 不能把G20/O6的 `0~255` 电机命令当作真实关节角;
|
||||||
|
- 没有Marker、编码器或独立角度传感器时,不能给出实机与仿真的真实角度误差。
|
||||||
|
|
||||||
|
旧入口 `handretarget` 仍然保留;本文档只描述新入口 `ffg_dual_retarget`。
|
||||||
|
|
||||||
|
## 2. 当前映射架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
FFG左手套21维原始数据
|
||||||
|
↓
|
||||||
|
hand_intent:模型无关的人手语义(0~1)
|
||||||
|
↓
|
||||||
|
├─→ G20 actuation_target
|
||||||
|
│ ├─→ G20单机profile → 20维cmd_u8 → G20实机
|
||||||
|
│ └─→ G20名义URDF范围 → q_nominal → 仿真
|
||||||
|
│
|
||||||
|
└─→ O6 actuation_target
|
||||||
|
├─→ O6单机profile → 6维cmd_u8 → O6实机
|
||||||
|
└─→ O6名义URDF范围 → q_nominal → 仿真
|
||||||
|
```
|
||||||
|
|
||||||
|
实机命令和仿真目标是两条独立标尺:
|
||||||
|
|
||||||
|
- `cmd_u8`:设备电机空间命令,范围为0~255;
|
||||||
|
- `q_nominal`:根据URDF名义限位生成的仿真弧度目标;
|
||||||
|
- `state_u8`:SDK返回的设备状态,只用于运行诊断,不是真实关节角GT。
|
||||||
|
|
||||||
|
仿真不应直接把 `cmd_u8` 当作真实角度。需要接近实机外观时,可以使用同一
|
||||||
|
`actuation_target`,再通过实测角度标定完善仿真标尺。
|
||||||
|
|
||||||
|
## 3. 多手势标定解决什么问题
|
||||||
|
|
||||||
|
### 3.1 FFG静态姿势
|
||||||
|
|
||||||
|
完整手套标定采集11个静态姿势:
|
||||||
|
|
||||||
|
1. 五指自然张开、自然分开;
|
||||||
|
2. 五指伸直并拢;
|
||||||
|
3. 桌面手势:四指根部弯曲、末端伸直;
|
||||||
|
4. 钩拳:四指根部伸直、末端弯曲;
|
||||||
|
5. 自然握拳;
|
||||||
|
6. 拇指最大外展;
|
||||||
|
7. 拇指横跨掌心;
|
||||||
|
8. 拇指—食指捏合;
|
||||||
|
9. 拇指—中指捏合;
|
||||||
|
10. 拇指—无名指捏合;
|
||||||
|
11. 拇指—小指捏合。
|
||||||
|
|
||||||
|
每个静态姿势默认采集2秒、重复3次,每次至少50帧。profile保留全部原始帧、
|
||||||
|
每次中位数、MAD和有效帧数,而不是只保存一个平均值。
|
||||||
|
|
||||||
|
### 3.2 FFG动态轨迹
|
||||||
|
|
||||||
|
完整标定还采集7组短时往返轨迹:
|
||||||
|
|
||||||
|
- 食指独立弯曲往返;
|
||||||
|
- 中指独立弯曲往返;
|
||||||
|
- 无名指独立弯曲往返;
|
||||||
|
- 小指独立弯曲往返;
|
||||||
|
- 拇指弯曲往返;
|
||||||
|
- 拇指对掌往返;
|
||||||
|
- 四指开合往返。
|
||||||
|
|
||||||
|
动态轨迹主要用于发现和补偿同一手指屈伸对侧摆语义的传感器串扰,并检查非目标
|
||||||
|
手指是否跟随运动。它们不是额外的离散手势模板。
|
||||||
|
|
||||||
|
### 3.3 连续映射原则
|
||||||
|
|
||||||
|
当前运行时不会把整只手吸附到“最相似的标定手势”:
|
||||||
|
|
||||||
|
- 每根普通手指只读取自身的根部、末端和侧摆语义;
|
||||||
|
- 张手、桌面、钩拳和握拳构成四指根部—末端标定面,连续解耦传感器串扰;
|
||||||
|
- 普通屈伸映射保持连续,不在曲线内部加入停止平台;
|
||||||
|
- 四种捏合分别进行竞争判断;
|
||||||
|
- 捏合只局部修正拇指和目标手指,不替换整只手命令;
|
||||||
|
- 捏合证据不明确时,连续退回普通逐关节映射;
|
||||||
|
- 握拳只增加必要的拇指协调,不把相似动作强制变成握拳模板。
|
||||||
|
|
||||||
|
### 3.4 当前实时执行策略
|
||||||
|
|
||||||
|
当前默认执行节奏与旧版左手G20的有效路径一致:
|
||||||
|
|
||||||
|
```text
|
||||||
|
publish_rate=30Hz
|
||||||
|
input_filter_enabled=false
|
||||||
|
command_filter_mode=passthrough
|
||||||
|
command_filter_ema_alpha=1.0
|
||||||
|
command_filter_max_step_u8=255
|
||||||
|
command_filter_deadband_u8=0
|
||||||
|
repeat_position_commands=true
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是直接使用最新手套帧,将连续电机目标交给G20固件插值,并在每个30Hz控制心跳
|
||||||
|
重复发送最新目标。待发送队列深度为1,来不及发送时只保留最新目标,不重放旧命令。
|
||||||
|
|
||||||
|
Kalman和EMA仍然可以显式启用,但不要同时启用两层滤波。两层滤波叠加后再进行整数
|
||||||
|
取整,容易表现为小幅运动停顿、累计后跳变。
|
||||||
|
|
||||||
|
## 4. profile与运行文件
|
||||||
|
|
||||||
|
| 文件 | 内容 | 是否提交Git |
|
||||||
|
|---|---|---|
|
||||||
|
| `glove_<ID>_left_<operator>.json` | 个人佩戴下的FFG完整标定 | 否 |
|
||||||
|
| `*.wear_check.json` | 绑定手套profile哈希的快速佩戴检查 | 否 |
|
||||||
|
| `hand_G20_left_<SN>_provisional.json` | 指定G20实机的姿势命令profile | 否 |
|
||||||
|
| `*.checkpoint.json` | 实机姿势标定中断续标检查点 | 否 |
|
||||||
|
| `*.verification.json` | 实机姿势人工复核报告 | 否 |
|
||||||
|
| `g20_seed_profile.json` | G20 GUI安全初值和标定结构 | 是 |
|
||||||
|
| `o6_seed_profile.json` | O6 GUI安全初值和标定结构 | 是 |
|
||||||
|
|
||||||
|
根目录 `profiles/` 已加入 `.gitignore`。可复现的种子profile位于:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/linkerhand_retarget/resource/linkerforce_v2/profiles/
|
||||||
|
```
|
||||||
|
|
||||||
|
手套profile与操作者、手套和佩戴方式相关;机械手profile与型号、序列号、固件和CAN
|
||||||
|
接口相关。换手套、换操作者、明显改变佩戴位置时,应重新完整标定FFG。换机械手本体
|
||||||
|
或固件导致电机响应变化时,应重新标定机械手profile。
|
||||||
|
|
||||||
|
## 5. 构建与环境准备
|
||||||
|
|
||||||
|
在每个新终端中都要加载ROS和工作空间:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
```
|
||||||
|
|
||||||
|
源码修改后重新构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
|
||||||
|
colcon build --symlink-install --packages-select \
|
||||||
|
linker_hand_ros2_sdk gui_control linkerhand_retarget
|
||||||
|
|
||||||
|
source install/setup.bash
|
||||||
|
```
|
||||||
|
|
||||||
|
如果出现 `Package 'linkerhand_retarget' not found`,通常是当前终端没有执行上述两个
|
||||||
|
`source`,或者源码修改后还没有构建。
|
||||||
|
|
||||||
|
## 6. 完整标定FFG左手套
|
||||||
|
|
||||||
|
### 6.1 启动FFG原始数据发布
|
||||||
|
|
||||||
|
终端A启动只读FFG节点。只连接左手套即可,不要求右手套存在:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run linkerhand_retarget ffg_dual_retarget --ros-args \
|
||||||
|
-p serial_port:=/dev/ttyUSB0 \
|
||||||
|
-p baudrate:=2000000 \
|
||||||
|
-p auto_scan:=true
|
||||||
|
```
|
||||||
|
|
||||||
|
确认原始话题有数据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic hz /ffg/left/raw_joint_state
|
||||||
|
```
|
||||||
|
|
||||||
|
如果标定提示“0个有效帧”,不要继续重复按Enter。先确认:
|
||||||
|
|
||||||
|
- 终端A仍在运行;
|
||||||
|
- 日志显示左手FFG已连接;
|
||||||
|
- `/dev/ttyUSB0`没有被另一个FFG进程占用;
|
||||||
|
- `/ffg/left/raw_joint_state`有稳定数据。
|
||||||
|
|
||||||
|
### 6.2 执行完整手套标定
|
||||||
|
|
||||||
|
终端B执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run linkerhand_retarget ffg_calibrate -- \
|
||||||
|
--glove-id FFG_LEFT_SN \
|
||||||
|
--operator lxp \
|
||||||
|
--firmware 2.1.4 \
|
||||||
|
--output /home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp.json
|
||||||
|
```
|
||||||
|
|
||||||
|
静态姿势的正确操作:
|
||||||
|
|
||||||
|
1. 先摆好终端提示的固定姿势;
|
||||||
|
2. 姿势稳定后按Enter;
|
||||||
|
3. 按Enter后继续保持不动约2秒;
|
||||||
|
4. 终端显示保存帧数后再放松;
|
||||||
|
5. 同一姿势按相同方法重复3次。
|
||||||
|
|
||||||
|
动态往返轨迹的正确操作:
|
||||||
|
|
||||||
|
1. 先回到该动作的自然起始位置;
|
||||||
|
2. 按Enter后立即开始连续、缓慢地往返运动;
|
||||||
|
3. 在默认3秒采集窗口内完成若干次完整往返;
|
||||||
|
4. 非目标手指尽量保持稳定;
|
||||||
|
5. 不要先弯好后全程静止,否则采不到动态关系。
|
||||||
|
|
||||||
|
标定成功应显示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
approved_for_runtime=True
|
||||||
|
sha256=<手套profile哈希>
|
||||||
|
```
|
||||||
|
|
||||||
|
不要使用 `--skip-dynamic` 生成正式运行profile。该参数只适合调试。
|
||||||
|
|
||||||
|
## 7. 快速佩戴检查
|
||||||
|
|
||||||
|
每次正式实机启动前,对当前准备使用的手套profile执行快速检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_retarget ffg_calibrate -- \
|
||||||
|
--quick-check /home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp.json \
|
||||||
|
--quick-output /home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp.wear_check.json
|
||||||
|
```
|
||||||
|
|
||||||
|
依次检查张手、握拳和食指捏合。每个姿势也是“先摆好,再按Enter,然后保持2秒”。
|
||||||
|
|
||||||
|
快速检查凭据:
|
||||||
|
|
||||||
|
- 默认12小时有效;
|
||||||
|
- 必须显示 `passed=true`;
|
||||||
|
- 必须与启动时使用的手套profile SHA-256完全一致;
|
||||||
|
- 切换v4、v5等手套profile时,必须同时切换到对应的wear-check文件。
|
||||||
|
|
||||||
|
快速检查失败时,先重新调整手套佩戴位置并重试。如果多次失败,说明当前佩戴与完整
|
||||||
|
标定差异过大,应重新完整标定,不要通过增大阈值静默放行实机。
|
||||||
|
|
||||||
|
## 8. 标定G20实机姿势
|
||||||
|
|
||||||
|
### 8.1 安全要求
|
||||||
|
|
||||||
|
- 标定时使用低速、低力矩;
|
||||||
|
- 配备软件急停,并保证机械手周围无障碍物;
|
||||||
|
- 不得使用旧手套映射把机械手带到标定姿势;
|
||||||
|
- 不得在带电状态强行手掰;
|
||||||
|
- 使用GUI逐通道调整,并确认通道方向正确。
|
||||||
|
|
||||||
|
### 8.2 启动G20 SDK
|
||||||
|
|
||||||
|
终端A:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run linker_hand_ros2_sdk linker_hand_sdk --ros-args \
|
||||||
|
-p hand_type:=left \
|
||||||
|
-p hand_joint:=G20 \
|
||||||
|
-p is_touch:=false \
|
||||||
|
-p can:=can0 \
|
||||||
|
-p modbus:=None \
|
||||||
|
-p topic_prefix:=/g20 \
|
||||||
|
-p startup_speed:=30 \
|
||||||
|
-p startup_torque:=80 \
|
||||||
|
-p move_on_startup:=false \
|
||||||
|
-p state_poll_rate:=10.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 启动G20标定GUI
|
||||||
|
|
||||||
|
终端B:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run gui_control gui_control --ros-args \
|
||||||
|
-r __node:=g20_calibration_gui \
|
||||||
|
-p hand_type:=left \
|
||||||
|
-p hand_joint:=G20 \
|
||||||
|
-p topic_prefix:=/g20
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 捕获11个G20姿势
|
||||||
|
|
||||||
|
终端C:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run linkerhand_retarget hand_pose_capture -- \
|
||||||
|
--model G20 \
|
||||||
|
--seed /home/lxp/projects/linkerhand_retarget_ros2/install/linkerhand_retarget/share/linkerhand_retarget/linkerforce_v2/profiles/g20_seed_profile.json \
|
||||||
|
--serial-number G20_LEFT_001 \
|
||||||
|
--operator lxp \
|
||||||
|
--firmware unknown \
|
||||||
|
--can-interface can0 \
|
||||||
|
--output /home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json
|
||||||
|
```
|
||||||
|
|
||||||
|
每个姿势的操作:
|
||||||
|
|
||||||
|
1. 用GUI低速调整机械手;
|
||||||
|
2. 目视确认目标手指、通道方向和最终姿势;
|
||||||
|
3. 等待实机稳定;
|
||||||
|
4. 点击GUI“保存当前标定姿势”;
|
||||||
|
5. CLI收到快照后选择姿势状态。
|
||||||
|
|
||||||
|
状态含义:
|
||||||
|
|
||||||
|
- `exact`:机械手能够准确实现该姿势;
|
||||||
|
- `approximate`:受机构自由度限制,只能实现最佳近似;
|
||||||
|
- `unsupported`:该型号不能可靠实现,不用于对应姿势约束;
|
||||||
|
- 输入 `r`:放弃刚才的快照,重新调整和保存。
|
||||||
|
|
||||||
|
CLI每完成一个姿势都会立即写入 `*.checkpoint.json`。程序中断后,使用完全相同的
|
||||||
|
命令会自动恢复并跳过已保存姿势,不需要从头开始。
|
||||||
|
|
||||||
|
只有确实要放弃原进度时才增加:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--fresh
|
||||||
|
```
|
||||||
|
|
||||||
|
全部姿势完成后,只有输入 `y` 批准,最终profile才会包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
approved_for_control=True
|
||||||
|
```
|
||||||
|
|
||||||
|
O6操作相同,但使用 `--model O6`、O6 seed、`can1`和 `/o6` 命名空间。O6自由度较少,
|
||||||
|
桌面、钩拳及部分捏合通常应标为 `approximate`。
|
||||||
|
|
||||||
|
## 9. 复核机械手profile
|
||||||
|
|
||||||
|
姿势复核可以避免手工把JSON中的20维命令复制到GUI。
|
||||||
|
|
||||||
|
复核前:
|
||||||
|
|
||||||
|
- 停止 `ffg_dual_retarget`;
|
||||||
|
- 停止GUI,避免命令话题存在其他发布者;
|
||||||
|
- 只保留低速、低力矩的G20 SDK。
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_retarget hand_pose_verify -- \
|
||||||
|
--profile /home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json \
|
||||||
|
--operator lxp \
|
||||||
|
--topic-prefix /g20
|
||||||
|
```
|
||||||
|
|
||||||
|
只复核单个姿势:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_retarget hand_pose_verify -- \
|
||||||
|
--profile /home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json \
|
||||||
|
--operator lxp \
|
||||||
|
--topic-prefix /g20 \
|
||||||
|
--pose pinch_index
|
||||||
|
```
|
||||||
|
|
||||||
|
按照提示输入 `VERIFY`、`MOVE`,再选择:
|
||||||
|
|
||||||
|
- `p`:目视通过;
|
||||||
|
- `f`:目视未通过;
|
||||||
|
- `r`:重放;
|
||||||
|
- `s`:跳过。
|
||||||
|
|
||||||
|
工具会低速平滑过渡,并保存独立的 `*.verification.json`,不会修改原始机械手profile。
|
||||||
|
|
||||||
|
## 10. 离线检查映射质量
|
||||||
|
|
||||||
|
不连接实机即可回放profile中的静态与动态数据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_retarget retarget_profile_check -- \
|
||||||
|
--glove /home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp.json \
|
||||||
|
--robot /home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json \
|
||||||
|
--model G20 \
|
||||||
|
--output /home/lxp/projects/linkerhand_retarget_ros2/profiles/g20_mapping_quality.json
|
||||||
|
```
|
||||||
|
|
||||||
|
重点查看:
|
||||||
|
|
||||||
|
- `passed`和`hard_failures`;
|
||||||
|
- 静态有效通道复现误差;
|
||||||
|
- 四种捏合的winner和gate;
|
||||||
|
- 四指动态轨迹的非目标通道跨度;
|
||||||
|
- 小指、侧摆等动作是否有明显串扰;
|
||||||
|
- 帧间命令变化是否存在异常突跳。
|
||||||
|
|
||||||
|
离线检查通过不等于实机角度准确,只表示profile内部逻辑一致。
|
||||||
|
|
||||||
|
## 11. 启动G20正式遥操
|
||||||
|
|
||||||
|
正式启动前,停止旧SDK、标定GUI、姿势捕获工具和占用FFG串口的只读节点。每种节点
|
||||||
|
只保留一个实例。
|
||||||
|
|
||||||
|
### 11.1 终端A:启动G20驱动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run linker_hand_ros2_sdk linker_hand_sdk --ros-args \
|
||||||
|
-p hand_type:=left \
|
||||||
|
-p hand_joint:=G20 \
|
||||||
|
-p is_touch:=false \
|
||||||
|
-p can:=can0 \
|
||||||
|
-p modbus:=None \
|
||||||
|
-p topic_prefix:=/g20 \
|
||||||
|
-p startup_speed:=255 \
|
||||||
|
-p startup_torque:=80 \
|
||||||
|
-p move_on_startup:=false \
|
||||||
|
-p state_poll_rate:=10.0 \
|
||||||
|
-p velocity_poll_rate:=10.0 \
|
||||||
|
-p defer_state_reads_while_commanding:=true \
|
||||||
|
-p repeat_position_commands:=true
|
||||||
|
```
|
||||||
|
|
||||||
|
`startup_speed`控制电机最大运动速度;`startup_torque`控制最大输出力矩。提高力矩不会
|
||||||
|
解决映射卡顿。建议先使用80,在确有负载需要并完成安全评估后再提高。
|
||||||
|
|
||||||
|
确认状态话题已有发布者:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic info /g20/cb_left_hand_state
|
||||||
|
```
|
||||||
|
|
||||||
|
应至少显示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Publisher count: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 终端B:启动FFG映射节点
|
||||||
|
|
||||||
|
以下示例使用当前v4手套profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run linkerhand_retarget ffg_dual_retarget --ros-args \
|
||||||
|
-p serial_port:=/dev/ttyUSB0 \
|
||||||
|
-p baudrate:=2000000 \
|
||||||
|
-p auto_scan:=true \
|
||||||
|
-p publish_rate:=30.0 \
|
||||||
|
-p input_filter_enabled:=false \
|
||||||
|
-p command_filter_mode:=passthrough \
|
||||||
|
-p command_filter_ema_alpha:=1.0 \
|
||||||
|
-p command_filter_max_step_u8:=255.0 \
|
||||||
|
-p command_filter_deadband_u8:=0.0 \
|
||||||
|
-p glove_profile:=/home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp_v4.json \
|
||||||
|
-p wear_check:=/home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp_v4.wear_check.json \
|
||||||
|
-p g20_profile:=/home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json \
|
||||||
|
-p g20_serial_number:=G20_LEFT_001 \
|
||||||
|
-p g20_can_interface:=can0
|
||||||
|
```
|
||||||
|
|
||||||
|
正常日志应包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FFG profile已加载
|
||||||
|
三姿势快速佩戴检查有效
|
||||||
|
G20 profile已加载(可申请实机使能)
|
||||||
|
G20映射=factorized_paired_v2
|
||||||
|
执行滤波=passthrough alpha=1.0, max_step=255.0
|
||||||
|
左手FFG已连接
|
||||||
|
```
|
||||||
|
|
||||||
|
节点启动后默认处于PREVIEW,不会立即控制实机。
|
||||||
|
|
||||||
|
### 11.3 PREVIEW检查
|
||||||
|
|
||||||
|
在使能前观察:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ffg/left/raw_joint_state
|
||||||
|
/ffg/left/filtered_joint_state
|
||||||
|
/retarget/left/hand_intent
|
||||||
|
/retarget/g20/left/actuation_target
|
||||||
|
/retarget/g20/left/joint_target_nominal
|
||||||
|
/retarget/g20/left/cmd_u8_preview
|
||||||
|
```
|
||||||
|
|
||||||
|
检查要求:
|
||||||
|
|
||||||
|
- 张手、半握、握拳过程中命令连续;
|
||||||
|
- 弯曲一根手指时,主要变化的是对应手指通道;
|
||||||
|
- 食指捏合主要影响拇指与食指;
|
||||||
|
- 不应锁定在某个历史捏合模板;
|
||||||
|
- 不应出现明显越限或突跳。
|
||||||
|
|
||||||
|
### 11.4 使能G20
|
||||||
|
|
||||||
|
终端C:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 service call /ffg_dual_retarget/enable_g20 \
|
||||||
|
std_srvs/srv/SetBool "{data: true}"
|
||||||
|
```
|
||||||
|
|
||||||
|
成功返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
success=True
|
||||||
|
message='G20已使能'
|
||||||
|
```
|
||||||
|
|
||||||
|
停用G20:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 service call /ffg_dual_retarget/enable_g20 \
|
||||||
|
std_srvs/srv/SetBool "{data: false}"
|
||||||
|
```
|
||||||
|
|
||||||
|
软件急停:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 service call /ffg_dual_retarget/emergency_stop \
|
||||||
|
std_srvs/srv/Trigger "{}"
|
||||||
|
```
|
||||||
|
|
||||||
|
急停、FFG断开、驱动状态超时或profile错误后,都需要排除问题并重新显式使能。
|
||||||
|
|
||||||
|
## 12. ROS话题说明
|
||||||
|
|
||||||
|
| 话题 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `/ffg/left/raw_joint_state` | 21维FFG原始数据 |
|
||||||
|
| `/ffg/left/filtered_joint_state` | 实际送入语义提取器的数据;默认与raw相同 |
|
||||||
|
| `/retarget/left/hand_intent` | 模型无关的0~1人手语义 |
|
||||||
|
| `/retarget/g20/left/actuation_target` | G20归一化目标 |
|
||||||
|
| `/retarget/g20/left/joint_target_nominal` | G20仿真名义弧度目标 |
|
||||||
|
| `/retarget/g20/left/cmd_u8_preview` | 未使能时也持续发布的20维预览命令 |
|
||||||
|
| `/g20/cb_left_hand_control_cmd` | 使能后发送给G20 SDK的20维命令 |
|
||||||
|
| `/g20/cb_left_hand_state` | G20 SDK状态心跳 |
|
||||||
|
| `/ffg_dual_retarget/status` | profile、使能、超时、滤波和延迟诊断 |
|
||||||
|
|
||||||
|
所有向量都带名称。仿真桥和其他消费者必须按 `JointState.name` 匹配,不得依赖裸下标。
|
||||||
|
|
||||||
|
## 13. 安全与自动停用
|
||||||
|
|
||||||
|
- 默认PREVIEW,必须通过服务显式使能;
|
||||||
|
- FFG超过0.35秒没有新帧:撤销全部实机使能;
|
||||||
|
- 对应驱动状态超过1秒未更新:只撤销该型号;
|
||||||
|
- profile缺失、未批准、SN/CAN不匹配:拒绝实机使能;
|
||||||
|
- wear-check缺失、失败、过期或哈希不匹配:拒绝实机使能;
|
||||||
|
- 所有命令检查长度、名称、有限值和0~255范围;
|
||||||
|
- G20四个保留通道保持安全固定值;
|
||||||
|
- 带电机械手不得强行手掰。
|
||||||
|
|
||||||
|
## 14. 常见问题排查
|
||||||
|
|
||||||
|
### 14.1 `Package 'linkerhand_retarget' not found`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source /home/lxp/projects/linkerhand_retarget_ros2/install/setup.bash
|
||||||
|
```
|
||||||
|
|
||||||
|
如果仍然找不到,重新执行第5节的构建命令。
|
||||||
|
|
||||||
|
### 14.2 手套标定只有0个有效帧
|
||||||
|
|
||||||
|
原因通常是没有单独启动FFG原始数据发布节点。检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic info /ffg/left/raw_joint_state
|
||||||
|
ros2 topic hz /ffg/left/raw_joint_state
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.3 快速佩戴检查失败
|
||||||
|
|
||||||
|
- 确认使用的是正确版本profile;
|
||||||
|
- 调整手套位置、腕带和手指传感器;
|
||||||
|
- 每个姿势先摆好再按Enter;
|
||||||
|
- 按Enter后保持不动2秒;
|
||||||
|
- 多次失败则重新完整标定。
|
||||||
|
|
||||||
|
### 14.4 `三姿势快速佩戴检查缺失、失败或过期`
|
||||||
|
|
||||||
|
重新对启动时使用的同一个手套profile执行第7节命令。不能拿v5的wear-check启动v4。
|
||||||
|
|
||||||
|
### 14.5 `G20驱动状态无效或已超时`
|
||||||
|
|
||||||
|
先执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic info /g20/cb_left_hand_state
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `Publisher count: 0`,说明G20 SDK未启动或没有使用 `/g20` 命名空间。按第11.1节
|
||||||
|
启动驱动。如果有发布者,再检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic echo /g20/cb_left_hand_state --once
|
||||||
|
```
|
||||||
|
|
||||||
|
状态必须是20维、名称与G20 profile一致、数值有限且位于0~255。
|
||||||
|
|
||||||
|
### 14.6 服务一直显示 `waiting for service`
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 node list
|
||||||
|
ros2 service list | grep ffg_dual_retarget
|
||||||
|
```
|
||||||
|
|
||||||
|
常见原因是 `ffg_dual_retarget`没有启动、当前终端未source,或者服务名称中多写了反斜杠。
|
||||||
|
|
||||||
|
### 14.7 服务成功但机械手不动
|
||||||
|
|
||||||
|
检查命令话题:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic info /g20/cb_left_hand_control_cmd
|
||||||
|
ros2 topic hz /g20/cb_left_hand_control_cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
使能后应同时存在发布者和订阅者,并接近30Hz。还要检查SDK终端是否报告CAN错误。
|
||||||
|
|
||||||
|
### 14.8 机械手运动卡顿
|
||||||
|
|
||||||
|
确认运行参数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 param get /ffg_dual_retarget input_filter_enabled
|
||||||
|
ros2 param get /ffg_dual_retarget command_filter_mode
|
||||||
|
ros2 param get /linker_hand_sdk repeat_position_commands
|
||||||
|
```
|
||||||
|
|
||||||
|
当前推荐结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
False
|
||||||
|
passthrough
|
||||||
|
True
|
||||||
|
```
|
||||||
|
|
||||||
|
同时检查:
|
||||||
|
|
||||||
|
- 只运行一个G20 SDK和一个映射节点;
|
||||||
|
- 命令话题稳定接近30Hz;
|
||||||
|
- CAN状态查询在遥操期间已延后;
|
||||||
|
- 不要用提高力矩解决卡顿;
|
||||||
|
- 如果只有某一根手指异常,运行离线profile质量检查,重点看该手指动态轨迹。
|
||||||
|
|
||||||
|
### 14.9 某根手指张手不到位或发生串指
|
||||||
|
|
||||||
|
依次比较:
|
||||||
|
|
||||||
|
```text
|
||||||
|
raw_joint_state
|
||||||
|
→ hand_intent
|
||||||
|
→ cmd_u8_preview
|
||||||
|
→ cb_left_hand_state
|
||||||
|
```
|
||||||
|
|
||||||
|
- raw异常:佩戴或FFG采集问题;
|
||||||
|
- hand_intent异常:手套标定/语义解耦问题;
|
||||||
|
- intent正确但preview异常:映射/profile问题;
|
||||||
|
- preview正确但实机异常:机械手profile、驱动、固件或机构问题。
|
||||||
|
|
||||||
|
不要直接通过修改某个写死系数掩盖问题。
|
||||||
|
|
||||||
|
### 14.10 实机姿势标定中断
|
||||||
|
|
||||||
|
使用完全相同的 `hand_pose_capture` 命令重新启动,会自动读取检查点并跳过已保存姿势。
|
||||||
|
不要增加 `--fresh`,除非明确要删除当前标定进度并从头开始。
|
||||||
|
|
||||||
|
## 15. 当前精度边界与后续优化
|
||||||
|
|
||||||
|
当前多手势方案可以继续优化:
|
||||||
|
|
||||||
|
- 重采质量较差的小指、侧摆或拇指动态轨迹;
|
||||||
|
- 改善21维传感器到人体语义的连续解耦;
|
||||||
|
- 完善拇指对掌和四种捏合的局部连续映射;
|
||||||
|
- 为每台机械手建立方向相关、非线性的电机命令曲线;
|
||||||
|
- 记录输入、映射、发布、CAN和状态时间戳,量化延迟与丢帧。
|
||||||
|
|
||||||
|
要得到可量化的真实角度精度,仍需增加Marker、编码器或独立角度传感器,建立:
|
||||||
|
|
||||||
|
```text
|
||||||
|
hand_intent
|
||||||
|
→ 实机真实关节角q_target
|
||||||
|
→ 单机关节角—cmd_u8标定
|
||||||
|
```
|
||||||
|
|
||||||
|
在此之前,验收结论只能是动作语义、通道、连续性和外观接近程度,不能声明实机与仿真
|
||||||
|
达到某个真实关节角误差。
|
||||||
|
|
||||||
|
## 16. 正式运行前检查清单
|
||||||
|
|
||||||
|
- [ ] ROS与工作空间已source;
|
||||||
|
- [ ] 当前只有一个FFG读取进程;
|
||||||
|
- [ ] 当前只有一个G20 SDK,使用`can0`和`/g20`;
|
||||||
|
- [ ] FFG profile显示`approved_for_runtime=True`;
|
||||||
|
- [ ] wear-check通过、未过期且哈希匹配;
|
||||||
|
- [ ] G20 profile显示`approved_for_control=True`;
|
||||||
|
- [ ] profile中的SN和CAN与启动参数一致;
|
||||||
|
- [ ] `/g20/cb_left_hand_state`有有效发布者;
|
||||||
|
- [ ] PREVIEW下逐指、握拳和四种捏合动作正确;
|
||||||
|
- [ ] 默认实时参数为关闭输入滤波、直通命令、30Hz重复目标;
|
||||||
|
- [ ] 周围安全、急停可用;
|
||||||
|
- [ ] 最后才调用`enable_g20`。
|
||||||
Submodule
+1
Submodule src/agillink_omnihand_sdk added at 026740d9fd
@@ -9,6 +9,20 @@ class HandConfig:
|
|||||||
joint_names_en: Optional[List[str]] = None
|
joint_names_en: Optional[List[str]] = None
|
||||||
init_pos: List[int] = field(default_factory=list)
|
init_pos: List[int] = field(default_factory=list)
|
||||||
preset_actions: Optional[Dict[str, List[int]]] = None
|
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],
|
"点赞": [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],
|
"握拳": [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],
|
"张开": [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],
|
"OK": [0, 0, 255, 255, 255, 138, 147, 148, 105, 42, 109, 255, 255, 255, 255, 255, 211, 255, 255, 255],
|
||||||
"拇指对中指": [191, 255, 55, 255, 255, 96, 95, 100, 114, 127, 105, 255, 255, 255, 255, 94, 255, 108, 255, 255],
|
"拇指对中指": [0, 255, 0, 255, 255, 107, 149, 148, 105, 42, 109, 255, 255, 255, 255, 255, 225, 202, 255, 255],
|
||||||
"拇指对无名指": [191, 255, 255, 72, 255, 115, 95, 100, 114, 127, 60, 255, 255, 255, 255, 94, 255, 255, 97, 255],
|
"拇指对无名指": [0, 255, 255, 0, 255, 88, 171, 148, 105, 42, 59, 255, 255, 255, 255, 255, 255, 255, 206, 254],
|
||||||
"拇指对小指": [191, 255, 255, 255, 55, 0, 95, 100, 114, 121, 70, 255, 255, 255, 255, 94, 255, 255, 255, 100],
|
"拇指对小指": [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],
|
"准备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, 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],
|
"贰": [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],
|
"动作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],
|
"根部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],
|
"根部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],
|
"末端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],
|
"末端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],
|
"末端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, 0],
|
||||||
"肆": [92, 87, 255, 255, 255, 255],
|
"肆": [92, 87, 255, 255, 255, 255],
|
||||||
"伍": [255, 255, 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],
|
"点赞": [250, 79, 0, 0, 0, 0],
|
||||||
"握拳": [102, 18, 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(
|
"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=["大拇指弯曲", "大拇指横摆", "食指弯曲", "中指弯曲", "无名指弯曲", "小拇指弯曲"],
|
joint_names=["大拇指弯曲", "大拇指横摆", "食指弯曲", "中指弯曲", "无名指弯曲", "小拇指弯曲"],
|
||||||
init_pos=[250] * 6,
|
init_pos=[250] * 6,
|
||||||
preset_actions={
|
preset_actions={
|
||||||
@@ -240,7 +259,7 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
|
|||||||
"叁": [0, 39, 255, 255, 255, 0],
|
"叁": [0, 39, 255, 255, 255, 0],
|
||||||
"肆": [0, 0, 255, 255, 255, 255],
|
"肆": [0, 0, 255, 255, 255, 255],
|
||||||
"伍": [255, 255, 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],
|
"点赞": [255, 255, 0, 0, 0, 0],
|
||||||
"握拳": [79, 11, 0, 0, 0, 0],
|
"握拳": [79, 11, 0, 0, 0, 0],
|
||||||
"序列动作1": [250, 250, 250, 250, 250, 250],
|
"序列动作1": [250, 250, 250, 250, 250, 250],
|
||||||
@@ -256,7 +275,56 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
|
|||||||
"拇指压感准备1": [139, 18, 130, 0, 0, 0],
|
"拇指压感准备1": [139, 18, 130, 0, 0, 0],
|
||||||
"拇指压感测试": [39, 30, 122, 250, 250, 250],
|
"拇指压感测试": [39, 30, 122, 250, 250, 250],
|
||||||
"拇指压感准备2": [139, 18, 130, 0, 0, 0]
|
"拇指压感准备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)
|
HAND_CONFIGS = MappingProxyType(_HAND_CONFIGS)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import sys
|
import sys
|
||||||
import time, json
|
import time, json
|
||||||
|
import math
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List, Dict
|
from typing import List, Dict
|
||||||
@@ -19,14 +20,72 @@ from .utils.mapping import *
|
|||||||
|
|
||||||
from .config.constants import _HAND_CONFIGS
|
from .config.constants import _HAND_CONFIGS
|
||||||
LOOP_TIME = 1000 # 循环动作间隔时间 毫秒
|
LOOP_TIME = 1000 # 循环动作间隔时间 毫秒
|
||||||
|
|
||||||
|
_CANONICAL_COMMAND_NAMES = {
|
||||||
|
"G20": [
|
||||||
|
"thumb_cmc_pitch", "index_mcp_pitch", "middle_mcp_pitch",
|
||||||
|
"ring_mcp_pitch", "pinky_mcp_pitch", "thumb_cmc_roll",
|
||||||
|
"index_mcp_roll", "middle_mcp_roll", "ring_mcp_roll",
|
||||||
|
"pinky_mcp_roll", "thumb_cmc_yaw", "reserved_11",
|
||||||
|
"reserved_12", "reserved_13", "reserved_14", "thumb_mcp",
|
||||||
|
"index_pip", "middle_pip", "ring_pip", "pinky_pip",
|
||||||
|
],
|
||||||
|
"O6": [
|
||||||
|
"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 = {
|
||||||
|
"G20": [
|
||||||
|
*[(0, 255)] * 10,
|
||||||
|
(0, 255),
|
||||||
|
*[(255, 255)] * 4,
|
||||||
|
*[(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):
|
class ROS2NodeManager(QObject):
|
||||||
"""ROS2节点管理器,处理ROS通信"""
|
"""ROS2节点管理器,处理ROS通信"""
|
||||||
status_updated = pyqtSignal(str, str) # 状态类型, 消息内容
|
status_updated = pyqtSignal(str, str) # 状态类型, 消息内容
|
||||||
|
feedback_updated = pyqtSignal(object)
|
||||||
|
|
||||||
def __init__(self, node_name: str = "hand_control_node"):
|
def __init__(self, node_name: str = "hand_control_node"):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.node = None
|
self.node = None
|
||||||
self.publisher = 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 = JointState()
|
||||||
self.joint_state.header = Header()
|
self.joint_state.header = Header()
|
||||||
|
|
||||||
@@ -45,28 +104,56 @@ class ROS2NodeManager(QObject):
|
|||||||
self.node.declare_parameter('hand_joint', 'L10')
|
self.node.declare_parameter('hand_joint', 'L10')
|
||||||
self.node.declare_parameter('topic_hz', 30)
|
self.node.declare_parameter('topic_hz', 30)
|
||||||
self.node.declare_parameter('is_arc', False)
|
self.node.declare_parameter('is_arc', False)
|
||||||
|
self.node.declare_parameter('topic_prefix', '')
|
||||||
|
|
||||||
# 获取参数
|
# 获取参数
|
||||||
self.hand_type = self.node.get_parameter('hand_type').value
|
self.hand_type = self.node.get_parameter('hand_type').value
|
||||||
self.hand_joint = self.node.get_parameter('hand_joint').value
|
self.hand_joint = self.node.get_parameter('hand_joint').value
|
||||||
self.hz = self.node.get_parameter('topic_hz').value
|
self.hz = self.node.get_parameter('topic_hz').value
|
||||||
self.is_arc = self.node.get_parameter('is_arc').value
|
self.is_arc = self.node.get_parameter('is_arc').value
|
||||||
|
self.topic_prefix = self.normalize_topic_prefix(
|
||||||
|
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(
|
self.publisher_arc = self.node.create_publisher(
|
||||||
JointState, f'/cb_{self.hand_type}_hand_control_cmd_arc', 10
|
JointState,
|
||||||
|
self.topic(f'/cb_{self.hand_type}_hand_control_cmd_arc'),
|
||||||
|
10
|
||||||
)
|
)
|
||||||
# 创建发布者
|
# 创建旧型号发布者
|
||||||
self.publisher = self.node.create_publisher(
|
if self.hand_joint != "O12":
|
||||||
JointState, f'/cb_{self.hand_type}_hand_control_cmd', 10
|
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 发布者
|
# 新增 speed / torque 发布者
|
||||||
self.speed_pub = self.node.create_publisher(
|
if self.hand_joint != "O12":
|
||||||
String, f'/cb_hand_setting_cmd', 10)
|
self.speed_pub = self.node.create_publisher(
|
||||||
self.torque_pub = self.node.create_publisher(
|
String, self.topic('/cb_hand_setting_cmd'), 10)
|
||||||
String, f'/cb_hand_setting_cmd', 10)
|
self.torque_pub = self.node.create_publisher(
|
||||||
self.status_updated.emit("info", f"ROS2节点初始化成功: {self.hand_type} {self.hand_joint}")
|
String, self.topic('/cb_hand_setting_cmd'), 10)
|
||||||
|
self.status_updated.emit(
|
||||||
|
"info",
|
||||||
|
f"ROS2节点初始化成功: {self.topic_prefix or '/'} "
|
||||||
|
f"{self.hand_type} {self.hand_joint}"
|
||||||
|
)
|
||||||
|
|
||||||
# 启动ROS2自旋线程
|
# 启动ROS2自旋线程
|
||||||
self.spin_thread = threading.Thread(target=self.spin_node, daemon=True)
|
self.spin_thread = threading.Thread(target=self.spin_node, daemon=True)
|
||||||
@@ -75,11 +162,55 @@ class ROS2NodeManager(QObject):
|
|||||||
self.status_updated.emit("error", f"ROS2初始化失败: {str(e)}")
|
self.status_updated.emit("error", f"ROS2初始化失败: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_topic_prefix(prefix: str) -> str:
|
||||||
|
prefix = str(prefix).strip()
|
||||||
|
if not prefix or prefix == '/':
|
||||||
|
return ''
|
||||||
|
if not prefix.startswith('/'):
|
||||||
|
prefix = '/' + prefix
|
||||||
|
return prefix.rstrip('/')
|
||||||
|
|
||||||
|
def topic(self, absolute_topic: str) -> str:
|
||||||
|
if not absolute_topic.startswith('/'):
|
||||||
|
raise ValueError('base topic must be absolute')
|
||||||
|
return self.topic_prefix + absolute_topic
|
||||||
|
|
||||||
def spin_node(self):
|
def spin_node(self):
|
||||||
"""运行ROS2节点自旋循环"""
|
"""运行ROS2节点自旋循环"""
|
||||||
while rclpy.ok() and self.node:
|
while rclpy.ok() and self.node:
|
||||||
rclpy.spin_once(self.node, timeout_sec=0.1)
|
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 = 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]):
|
def publish_joint_state(self, positions: List[int]):
|
||||||
"""发布关节状态消息"""
|
"""发布关节状态消息"""
|
||||||
if not self.publisher or not self.node:
|
if not self.publisher or not self.node:
|
||||||
@@ -87,21 +218,31 @@ class ROS2NodeManager(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
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.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.velocity = [0.1] * len(positions)
|
||||||
# self.joint_state.effort = [0.01] * len(positions)
|
# self.joint_state.effort = [0.01] * len(positions)
|
||||||
# 如果有关节名称,添加到消息中
|
# 如果有关节名称,添加到消息中
|
||||||
#hand_config = HandConfig.from_hand_type(self.hand_joint)
|
#hand_config = HandConfig.from_hand_type(self.hand_joint)
|
||||||
hand_config = _HAND_CONFIGS[self.hand_joint]
|
hand_config = _HAND_CONFIGS[self.hand_joint]
|
||||||
if len(hand_config.joint_names) == len(positions):
|
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
|
||||||
|
# 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:
|
if hand_config.joint_names_en != None:
|
||||||
self.joint_state.name = hand_config.joint_names_en
|
self.joint_state.name = hand_config.joint_names_en
|
||||||
else:
|
else:
|
||||||
self.joint_state.name = hand_config.joint_names
|
self.joint_state.name = hand_config.joint_names
|
||||||
|
|
||||||
self.publisher.publish(self.joint_state)
|
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_joint == "O6":
|
||||||
if self.hand_type == "left":
|
if self.hand_type == "left":
|
||||||
pose = range_to_arc_left(positions,self.hand_joint)
|
pose = range_to_arc_left(positions,self.hand_joint)
|
||||||
@@ -131,9 +272,27 @@ class ROS2NodeManager(QObject):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status_updated.emit("error", f"发布失败: {str(e)}")
|
self.status_updated.emit("error", f"发布失败: {str(e)}")
|
||||||
|
|
||||||
|
def publish_pose_snapshot(self, positions: List[int]):
|
||||||
|
"""发布带名称的当前标定姿势快照。"""
|
||||||
|
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 = self.wire_positions(positions)
|
||||||
|
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
|
||||||
|
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):
|
def publish_speed(self, val: int):
|
||||||
joint_len = 0
|
if self.hand_joint == "O12":
|
||||||
if (self.hand_joint.upper() == "O6" or self.hand_joint.upper() == "L6"):
|
self.status_updated.emit(
|
||||||
|
"warning", "O12不使用旧版u8速度接口;请通过角度轨迹限制速度"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if self.hand_joint.upper() in ("O6", "L6"):
|
||||||
joint_len = 6
|
joint_len = 6
|
||||||
elif self.hand_joint == "L7":
|
elif self.hand_joint == "L7":
|
||||||
joint_len = 7
|
joint_len = 7
|
||||||
@@ -152,8 +311,12 @@ class ROS2NodeManager(QObject):
|
|||||||
self.speed_pub.publish(msg)
|
self.speed_pub.publish(msg)
|
||||||
|
|
||||||
def publish_torque(self, val: int):
|
def publish_torque(self, val: int):
|
||||||
joint_len = 0
|
if self.hand_joint == "O12":
|
||||||
if (self.hand_joint.upper() == "O6" or self.hand_joint.upper() == "L6"):
|
self.status_updated.emit(
|
||||||
|
"warning", "O12不使用旧版u8扭矩接口;位置GUI已禁用该设置"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if self.hand_joint.upper() in ("O6", "L6"):
|
||||||
joint_len = 6
|
joint_len = 6
|
||||||
elif self.hand_joint == "L7":
|
elif self.hand_joint == "L7":
|
||||||
joint_len = 7
|
joint_len = 7
|
||||||
@@ -194,11 +357,17 @@ class HandControlGUI(QWidget):
|
|||||||
# 设置ROS管理器
|
# 设置ROS管理器
|
||||||
self.ros_manager = ros_manager
|
self.ros_manager = ros_manager
|
||||||
self.ros_manager.status_updated.connect(self.update_status)
|
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_joint = self.ros_manager.hand_joint
|
||||||
self.hand_type = self.ros_manager.hand_type
|
self.hand_type = self.ros_manager.hand_type
|
||||||
self.hand_config = _HAND_CONFIGS[self.hand_joint]
|
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
|
# 初始化UI
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
@@ -209,10 +378,27 @@ class HandControlGUI(QWidget):
|
|||||||
self.publish_timer.timeout.connect(self.publish_joint_state)
|
self.publish_timer.timeout.connect(self.publish_joint_state)
|
||||||
self.publish_timer.start()
|
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):
|
def init_ui(self):
|
||||||
"""初始化用户界面"""
|
"""初始化用户界面"""
|
||||||
# 设置窗口属性
|
# 设置窗口属性
|
||||||
self.setWindowTitle(f'灵巧手控制界面 - {self.hand_type} {self.hand_joint}')
|
self.setWindowTitle(
|
||||||
|
f'灵巧手控制界面 - {self.ros_manager.topic_prefix or "/"} '
|
||||||
|
f'{self.hand_type} {self.hand_joint}'
|
||||||
|
)
|
||||||
self.setMinimumSize(1200, 900)
|
self.setMinimumSize(1200, 900)
|
||||||
|
|
||||||
# 设置样式
|
# 设置样式
|
||||||
@@ -383,13 +569,16 @@ class HandControlGUI(QWidget):
|
|||||||
for i, (name, value) in enumerate(zip(
|
for i, (name, value) in enumerate(zip(
|
||||||
self.hand_config.joint_names, self.hand_config.init_pos
|
self.hand_config.joint_names, self.hand_config.init_pos
|
||||||
)):
|
)):
|
||||||
|
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)
|
label.setMinimumWidth(120)
|
||||||
|
|
||||||
# 创建滑动条
|
# 创建滑动条
|
||||||
slider = QSlider(Qt.Horizontal)
|
slider = QSlider(Qt.Horizontal)
|
||||||
slider.setRange(0, 255)
|
slider.setRange(minimum, maximum)
|
||||||
slider.setValue(value)
|
slider.setValue(value)
|
||||||
slider.valueChanged.connect(
|
slider.valueChanged.connect(
|
||||||
lambda val, idx=i: self.on_slider_value_changed(idx, val)
|
lambda val, idx=i: self.on_slider_value_changed(idx, val)
|
||||||
@@ -435,6 +624,11 @@ class HandControlGUI(QWidget):
|
|||||||
self.stop_button.setProperty("category", "danger")
|
self.stop_button.setProperty("category", "danger")
|
||||||
self.stop_button.clicked.connect(self.on_stop_clicked)
|
self.stop_button.clicked.connect(self.on_stop_clicked)
|
||||||
actions_layout.addWidget(self.stop_button)
|
actions_layout.addWidget(self.stop_button)
|
||||||
|
|
||||||
|
self.save_pose_button = QPushButton("保存当前标定姿势")
|
||||||
|
self.save_pose_button.setProperty("category", "action")
|
||||||
|
self.save_pose_button.clicked.connect(self.on_save_pose_clicked)
|
||||||
|
actions_layout.addWidget(self.save_pose_button)
|
||||||
|
|
||||||
layout.addLayout(actions_layout)
|
layout.addLayout(actions_layout)
|
||||||
|
|
||||||
@@ -443,9 +637,9 @@ class HandControlGUI(QWidget):
|
|||||||
def create_system_preset_buttons(self, parent_layout):
|
def create_system_preset_buttons(self, parent_layout):
|
||||||
"""创建系统预设动作按钮"""
|
"""创建系统预设动作按钮"""
|
||||||
self.preset_buttons = [] # 清空按钮列表
|
self.preset_buttons = [] # 清空按钮列表
|
||||||
if self.hand_config.preset_actions:
|
if self.preset_actions:
|
||||||
buttons = []
|
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 = QPushButton(name)
|
||||||
button.setProperty("category", "preset")
|
button.setProperty("category", "preset")
|
||||||
button.clicked.connect(
|
button.clicked.connect(
|
||||||
@@ -472,6 +666,9 @@ class HandControlGUI(QWidget):
|
|||||||
|
|
||||||
# —— 2. 新增:速度与扭矩设置(每行一个)——
|
# —— 2. 新增:速度与扭矩设置(每行一个)——
|
||||||
quick_set_gb = QGroupBox("快速设置")
|
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)
|
qv_layout = QVBoxLayout(quick_set_gb)
|
||||||
|
|
||||||
# 速度行
|
# 速度行
|
||||||
@@ -600,7 +797,10 @@ class HandControlGUI(QWidget):
|
|||||||
"""滑动条值改变事件处理"""
|
"""滑动条值改变事件处理"""
|
||||||
if 0 <= index < len(self.slider_labels):
|
if 0 <= index < len(self.slider_labels):
|
||||||
joint_name = self.hand_config.joint_names[index]
|
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()
|
self.update_value_display()
|
||||||
@@ -608,10 +808,23 @@ class HandControlGUI(QWidget):
|
|||||||
def update_value_display(self):
|
def update_value_display(self):
|
||||||
"""更新数值显示面板内容"""
|
"""更新数值显示面板内容"""
|
||||||
# 获取所有滑动条的当前值
|
# 获取所有滑动条的当前值
|
||||||
values = [slider.value() for slider in self.sliders]
|
command = self.command_positions()
|
||||||
|
command_text = ", ".join(f"{value:.3f}" for value in command)
|
||||||
# 格式化显示为列表形式
|
lines = [f"目标({self.hand_config.position_unit}): [{command_text}]"]
|
||||||
self.value_display.setText(f"{values}")
|
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]):
|
def on_preset_action_clicked(self, positions: List[int]):
|
||||||
"""预设动作按钮点击事件处理"""
|
"""预设动作按钮点击事件处理"""
|
||||||
@@ -650,11 +863,34 @@ class HandControlGUI(QWidget):
|
|||||||
self.cycle_button.setText("循环运行预设动作")
|
self.cycle_button.setText("循环运行预设动作")
|
||||||
self.reset_preset_buttons_color()
|
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。"""
|
||||||
|
positions = [slider.value() for slider in self.sliders]
|
||||||
|
self.ros_manager.publish_pose_snapshot(positions)
|
||||||
|
|
||||||
def on_cycle_clicked(self):
|
def on_cycle_clicked(self):
|
||||||
"""循环运行预设动作按钮点击事件处理"""
|
"""循环运行预设动作按钮点击事件处理"""
|
||||||
if not self.hand_config.preset_actions:
|
if not self.preset_actions:
|
||||||
QMessageBox.warning(self, "无预设动作", "当前手部型号没有预设动作可循环运行")
|
QMessageBox.warning(self, "无预设动作", "当前手部型号没有预设动作可循环运行")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -677,19 +913,19 @@ class HandControlGUI(QWidget):
|
|||||||
|
|
||||||
def run_next_action(self):
|
def run_next_action(self):
|
||||||
"""运行下一个预设动作"""
|
"""运行下一个预设动作"""
|
||||||
if not self.hand_config.preset_actions:
|
if not self.preset_actions:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 重置所有按钮颜色
|
# 重置所有按钮颜色
|
||||||
self.reset_preset_buttons_color()
|
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_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)
|
self.on_preset_action_clicked(action_positions)
|
||||||
@@ -714,6 +950,7 @@ class HandControlGUI(QWidget):
|
|||||||
"""关节类型改变事件处理"""
|
"""关节类型改变事件处理"""
|
||||||
self.hand_joint = joint_type
|
self.hand_joint = joint_type
|
||||||
self.hand_config = _HAND_CONFIGS[self.hand_joint]
|
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}
|
info_text = f"""手部类型: {self.hand_type}
|
||||||
@@ -732,8 +969,11 @@ class HandControlGUI(QWidget):
|
|||||||
|
|
||||||
def publish_joint_state(self):
|
def publish_joint_state(self):
|
||||||
"""发布当前关节状态"""
|
"""发布当前关节状态"""
|
||||||
|
if self.hand_joint == "O12" and not self.command_dirty:
|
||||||
|
return
|
||||||
positions = [slider.value() for slider in self.sliders]
|
positions = [slider.value() for slider in self.sliders]
|
||||||
self.ros_manager.publish_joint_state(positions)
|
self.ros_manager.publish_joint_state(positions)
|
||||||
|
self.command_dirty = False
|
||||||
|
|
||||||
def update_status(self, status_type: str, message: str):
|
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]),
|
||||||
|
])
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<test_depend>python3-pytest</test_depend>
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
|
||||||
<exec_depend>linker_hand_ros2_sdk</exec_depend>
|
<exec_depend>linker_hand_ros2_sdk</exec_depend>
|
||||||
|
<exec_depend>omnihand_node</exec_depend>
|
||||||
|
|
||||||
<export>
|
<export>
|
||||||
<build_type>ament_python</build_type>
|
<build_type>ament_python</build_type>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
]
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from launch import LaunchDescription
|
|
||||||
from launch_ros.actions import Node
|
|
||||||
|
|
||||||
def generate_launch_description():
|
|
||||||
return LaunchDescription([
|
|
||||||
Node(
|
|
||||||
package='linker_hand_ros2_sdk',
|
|
||||||
executable='linker_hand_sdk',
|
|
||||||
name='linker_hand_sdk_left',
|
|
||||||
output='screen',
|
|
||||||
parameters=[{
|
|
||||||
'hand_type': 'left',
|
|
||||||
'hand_joint': "L10", # 这里需要修改为实际Linker Hand的型号 L7、L10、L20、L21、L25
|
|
||||||
'is_touch': True, # 是否带有压力传感器
|
|
||||||
'can': 'can0', # 这里需要修改为实际的CAN总线名称
|
|
||||||
}],
|
|
||||||
),
|
|
||||||
|
|
||||||
Node(
|
|
||||||
package='linker_hand_ros2_sdk',
|
|
||||||
executable='linker_hand_sdk',
|
|
||||||
name='linker_hand_sdk_right',
|
|
||||||
output='screen',
|
|
||||||
parameters=[{
|
|
||||||
'hand_type': 'right',
|
|
||||||
'hand_joint': "L10", # 这里需要修改为实际Linker Hand的型号 L7、L10、L20、L21、L25
|
|
||||||
'is_touch': True, # 是否带有压力传感器
|
|
||||||
'can': 'can0', # 这里需要修改为实际的CAN总线名称
|
|
||||||
}],
|
|
||||||
),
|
|
||||||
])
|
|
||||||
+42
-1
@@ -7,6 +7,7 @@ from enum import Enum
|
|||||||
from utils.open_can import OpenCan
|
from utils.open_can import OpenCan
|
||||||
from can.exceptions import CanError
|
from can.exceptions import CanError
|
||||||
from utils.color_msg import ColorMsg
|
from utils.color_msg import ColorMsg
|
||||||
|
from utils.feedback import ReceivedPositionFrames
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
target_dir = os.path.abspath(os.path.join(current_dir, ".."))
|
target_dir = os.path.abspath(os.path.join(current_dir, ".."))
|
||||||
sys.path.append(target_dir)
|
sys.path.append(target_dir)
|
||||||
@@ -178,6 +179,10 @@ class LinkerHandG20Can:
|
|||||||
|
|
||||||
# 串联控制数据存储
|
# 串联控制数据存储
|
||||||
self.x41, self.x42, self.x43, self.x44, self.x45 = [], [], [], [], []
|
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.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.x51, self.x52, self.x53, self.x54, self.x55 = [], [], [], [], []
|
||||||
self.x59, self.x5A, self.x5B, self.x5C, self.x5D = [], [], [], [], []
|
self.x59, self.x5A, self.x5B, self.x5C, self.x5D = [], [], [], [], []
|
||||||
@@ -354,6 +359,8 @@ class LinkerHandG20Can:
|
|||||||
response_data = msg.data[1:]
|
response_data = msg.data[1:]
|
||||||
if len(list(response_data)) == 0:
|
if len(list(response_data)) == 0:
|
||||||
return
|
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)
|
if frame_type == 0x01: self.x01 = list(response_data)
|
||||||
elif frame_type == 0x02: self.x02 = list(response_data)
|
elif frame_type == 0x02: self.x02 = list(response_data)
|
||||||
@@ -1030,6 +1037,19 @@ class LinkerHandG20Can:
|
|||||||
cmd_state = self.joint_state_to_cmd_state(state=s)
|
cmd_state = self.joint_state_to_cmd_state(state=s)
|
||||||
return cmd_state
|
return cmd_state
|
||||||
|
|
||||||
|
def get_cached_current_status(self):
|
||||||
|
"""Return the latest received five-finger state without CAN queries."""
|
||||||
|
state = [self.x41, self.x42, self.x43, self.x44, self.x45]
|
||||||
|
if not all(
|
||||||
|
isinstance(finger, (list, tuple)) and len(finger) == 6
|
||||||
|
for finger in state
|
||||||
|
):
|
||||||
|
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):
|
def get_current_pub_status(self):
|
||||||
"""API接口:获取手指当前状态"""
|
"""API接口:获取手指当前状态"""
|
||||||
self.get_current_status()
|
self.get_current_status()
|
||||||
@@ -1266,4 +1286,25 @@ class LinkerHandG20Can:
|
|||||||
except:
|
except:
|
||||||
return "-1"
|
return "-1"
|
||||||
def get_finger_order(self):
|
def get_finger_order(self):
|
||||||
return ["Thumb Base", "Index Finger Base", "Middle Finger Base", "Ring Finger Base", "Pinky Finger Base", "Thumb Abduction", "Index Finger Abduction", "Middle Finger Abduction", "Ring Finger Abduction", "Pinky Finger Abduction", "Thumb Horizontal Abduction", "Reserved", "Reserved", "Reserved", "Reserved", "Thumb Tip", "Index Finger Tip", "Middle Finger Tip", "Ring Finger Tip", "Pinky Finger Tip"]
|
return [
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"index_mcp_pitch",
|
||||||
|
"middle_mcp_pitch",
|
||||||
|
"ring_mcp_pitch",
|
||||||
|
"pinky_mcp_pitch",
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"index_mcp_roll",
|
||||||
|
"middle_mcp_roll",
|
||||||
|
"ring_mcp_roll",
|
||||||
|
"pinky_mcp_roll",
|
||||||
|
"thumb_cmc_yaw",
|
||||||
|
"reserved_11",
|
||||||
|
"reserved_12",
|
||||||
|
"reserved_13",
|
||||||
|
"reserved_14",
|
||||||
|
"thumb_mcp",
|
||||||
|
"index_pip",
|
||||||
|
"middle_pip",
|
||||||
|
"ring_pip",
|
||||||
|
"pinky_pip",
|
||||||
|
]
|
||||||
|
|||||||
+42
-2
@@ -1,9 +1,12 @@
|
|||||||
|
from collections import deque
|
||||||
|
|
||||||
import can
|
import can
|
||||||
import time, sys
|
import time, sys
|
||||||
import threading
|
import threading
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from utils.open_can import OpenCan
|
from utils.open_can import OpenCan
|
||||||
from utils.color_msg import ColorMsg
|
from utils.color_msg import ColorMsg
|
||||||
|
from utils.feedback import ReceivedPositionFrames
|
||||||
from can.exceptions import CanError
|
from can.exceptions import CanError
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ class LinkerHandL6Can:
|
|||||||
self.open_can = OpenCan(load_yaml=yaml)
|
self.open_can = OpenCan(load_yaml=yaml)
|
||||||
|
|
||||||
self.x01 = [0] * 6 # 关节位置
|
self.x01 = [0] * 6 # 关节位置
|
||||||
|
self.position_feedback = ReceivedPositionFrames({0x01: 6}, [(0x01, i) for i in range(6)])
|
||||||
self.x02 = [-1] * 6 # 转矩限制
|
self.x02 = [-1] * 6 # 转矩限制
|
||||||
self.x05 = [0] * 6 # 速度
|
self.x05 = [0] * 6 # 速度
|
||||||
self.x07 = [-1] * 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.normal_force, self.tangential_force, self.tangential_force_dir, self.approach_inc = [[-1] * 6 for _ in range(4)]
|
||||||
self.is_lock = False
|
self.is_lock = False
|
||||||
self.version = None
|
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
|
# Start the receiving thread
|
||||||
self.running = True
|
self.running = True
|
||||||
self.receive_thread = threading.Thread(target=self.receive_response)
|
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
|
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]
|
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)
|
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:
|
try:
|
||||||
self.bus.send(msg)
|
self.bus.send(msg)
|
||||||
except can.CanError as e:
|
except can.CanError as e:
|
||||||
@@ -201,7 +218,24 @@ class LinkerHandL6Can:
|
|||||||
except:
|
except:
|
||||||
return
|
return
|
||||||
if frame_type == 0x01: # 0x01
|
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
|
elif frame_type == 0x02: # 0x02
|
||||||
self.x02 = list(response_data)
|
self.x02 = list(response_data)
|
||||||
elif frame_type == 0x05: # Set speed
|
elif frame_type == 0x05: # Set speed
|
||||||
@@ -295,6 +329,9 @@ class LinkerHandL6Can:
|
|||||||
def get_current_pub_status(self):
|
def get_current_pub_status(self):
|
||||||
return self.x01
|
return self.x01
|
||||||
|
|
||||||
|
def get_feedback_snapshot(self):
|
||||||
|
return self.position_feedback.snapshot()
|
||||||
|
|
||||||
def get_speed(self):
|
def get_speed(self):
|
||||||
#self.send_frame(0x05, [],sleep=0.003)
|
#self.send_frame(0x05, [],sleep=0.003)
|
||||||
#print("L6暂不支持读取实时速度")
|
#print("L6暂不支持读取实时速度")
|
||||||
@@ -391,7 +428,10 @@ class LinkerHandL6Can:
|
|||||||
return self.x35
|
return self.x35
|
||||||
|
|
||||||
def get_finger_order(self):
|
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):
|
def show_fun_table(self):
|
||||||
pass
|
pass
|
||||||
|
|||||||
+6
@@ -4,6 +4,7 @@ import threading
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from utils.open_can import OpenCan
|
from utils.open_can import OpenCan
|
||||||
from utils.color_msg import ColorMsg
|
from utils.color_msg import ColorMsg
|
||||||
|
from utils.feedback import ReceivedPositionFrames
|
||||||
from can.exceptions import CanError
|
from can.exceptions import CanError
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ class LinkerHandO6Can:
|
|||||||
self.open_can = OpenCan(load_yaml=yaml)
|
self.open_can = OpenCan(load_yaml=yaml)
|
||||||
|
|
||||||
self.x01 = [0] * 6 # 关节位置
|
self.x01 = [0] * 6 # 关节位置
|
||||||
|
self.position_feedback = ReceivedPositionFrames({0x01: 6}, [(0x01, i) for i in range(6)])
|
||||||
self.x02 = [-1] * 6 # 转矩限制
|
self.x02 = [-1] * 6 # 转矩限制
|
||||||
self.x05 = [0] * 6 # 速度
|
self.x05 = [0] * 6 # 速度
|
||||||
self.x07 = [-1] * 6 # 加速度
|
self.x07 = [-1] * 6 # 加速度
|
||||||
@@ -224,6 +226,7 @@ class LinkerHandO6Can:
|
|||||||
return
|
return
|
||||||
if frame_type == 0x01: # 0x01
|
if frame_type == 0x01: # 0x01
|
||||||
self.x01 = list(response_data)
|
self.x01 = list(response_data)
|
||||||
|
self.position_feedback.accept(frame_type, response_data, msg.timestamp)
|
||||||
elif frame_type == 0x02: # 0x02
|
elif frame_type == 0x02: # 0x02
|
||||||
self.x02 = list(response_data)
|
self.x02 = list(response_data)
|
||||||
elif frame_type == 0x05: # Set speed
|
elif frame_type == 0x05: # Set speed
|
||||||
@@ -317,6 +320,9 @@ class LinkerHandO6Can:
|
|||||||
def get_current_pub_status(self):
|
def get_current_pub_status(self):
|
||||||
return self.x01
|
return self.x01
|
||||||
|
|
||||||
|
def get_feedback_snapshot(self):
|
||||||
|
return self.position_feedback.snapshot()
|
||||||
|
|
||||||
def get_speed(self):
|
def get_speed(self):
|
||||||
self.send_frame(0x05, [],sleep=0.002)
|
self.send_frame(0x05, [],sleep=0.002)
|
||||||
#print("L6暂不支持读取实时速度")
|
#print("L6暂不支持读取实时速度")
|
||||||
|
|||||||
+1
-1
@@ -372,7 +372,7 @@ class LinkerHandL6RS485:
|
|||||||
return [0] * 6
|
return [0] * 6
|
||||||
|
|
||||||
def get_finger_order(self):
|
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"]
|
||||||
|
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
# 便捷方法
|
# 便捷方法
|
||||||
|
|||||||
-444
@@ -1,444 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from pymodbus.client import ModbusSerialClient
|
|
||||||
from typing import List, Dict
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
_INTERVAL = 0.006 # 8 ms
|
|
||||||
|
|
||||||
class LinkerHandL6RS485:
|
|
||||||
"""L6机械手 Modbus-RTU 控制类"""
|
|
||||||
|
|
||||||
# 6个关节名称
|
|
||||||
JOINT_NAMES = ["thumb_pitch", "thumb_yaw", "index_pitch",
|
|
||||||
"middle_pitch", "ring_pitch", "little_pitch"]
|
|
||||||
|
|
||||||
# 手指名称
|
|
||||||
FINGER_NAMES = ["thumb", "index", "middle", "ring", "little"]
|
|
||||||
|
|
||||||
def __init__(self, hand_id=0x27, modbus_port="/dev/ttyUSB0", baudrate=115200):
|
|
||||||
"""
|
|
||||||
初始化L6机械手
|
|
||||||
hand_id: 右手0x27(39), 左手0x28(40)
|
|
||||||
modbus_port: 串口设备路径
|
|
||||||
baudrate: 波特率,固定115200
|
|
||||||
"""
|
|
||||||
self.slave = hand_id
|
|
||||||
self.cli = ModbusSerialClient(
|
|
||||||
port=modbus_port,
|
|
||||||
baudrate=baudrate,
|
|
||||||
bytesize=8,
|
|
||||||
parity="N",
|
|
||||||
stopbits=1,
|
|
||||||
timeout=0.05
|
|
||||||
)
|
|
||||||
# pymodbus 3.5.1 需要显式连接
|
|
||||||
self.connected = self.cli.connect()
|
|
||||||
if not self.connected:
|
|
||||||
raise ConnectionError(f"RS485连接失败,端口: {modbus_port}")
|
|
||||||
|
|
||||||
def _read_input_registers(self, address: int, count: int) -> List[int]:
|
|
||||||
"""读取输入寄存器"""
|
|
||||||
time.sleep(_INTERVAL)
|
|
||||||
result = self.cli.read_input_registers(address=address, count=count, slave=self.slave)
|
|
||||||
if result.isError():
|
|
||||||
raise RuntimeError(f"读取输入寄存器失败: address={address}, count={count}")
|
|
||||||
return result.registers
|
|
||||||
|
|
||||||
def _write_register(self, address: int, value: int):
|
|
||||||
"""写入单个寄存器"""
|
|
||||||
time.sleep(_INTERVAL)
|
|
||||||
result = self.cli.write_register(address=address, value=value, slave=self.slave)
|
|
||||||
if result.isError():
|
|
||||||
raise RuntimeError(f"写入寄存器失败: address={address}, value={value}")
|
|
||||||
|
|
||||||
def _write_registers(self, address: int, values: List[int]):
|
|
||||||
"""写入多个寄存器"""
|
|
||||||
time.sleep(_INTERVAL)
|
|
||||||
result = self.cli.write_registers(address=address, values=values, slave=self.slave)
|
|
||||||
if result.isError():
|
|
||||||
raise RuntimeError(f"写入多个寄存器失败: address={address}, values={values}")
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# 基础读取接口
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
def read_angles(self) -> List[int]:
|
|
||||||
"""读取6个关节角度 (输入寄存器 0-5)"""
|
|
||||||
return self._read_input_registers(0, 6)
|
|
||||||
|
|
||||||
def read_torques(self) -> List[int]:
|
|
||||||
"""读取6个关节转矩 (输入寄存器 6-11)"""
|
|
||||||
return self._read_input_registers(6, 6)
|
|
||||||
|
|
||||||
def read_speeds(self) -> List[int]:
|
|
||||||
"""读取6个关节速度 (输入寄存器 12-17)"""
|
|
||||||
return self._read_input_registers(12, 6)
|
|
||||||
|
|
||||||
def read_temperatures(self) -> List[int]:
|
|
||||||
"""读取6个关节温度 (输入寄存器 18-23)"""
|
|
||||||
return self._read_input_registers(18, 6)
|
|
||||||
|
|
||||||
def read_error_codes(self) -> List[int]:
|
|
||||||
"""读取6个关节错误码 (输入寄存器 24-29)"""
|
|
||||||
return self._read_input_registers(24, 6)
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# 压力传感器接口
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
# def _pressure(self, finger: int) -> List[int]:
|
|
||||||
# """内部:选手指 → 读压力数据"""
|
|
||||||
# # 选择手指 (保持寄存器 36)
|
|
||||||
# self._write_register(36, finger)
|
|
||||||
# time.sleep(_INTERVAL)
|
|
||||||
# # 读取压力数据 (输入寄存器 52-122)
|
|
||||||
# return np.array(self._read_input_registers(52, 71))
|
|
||||||
def _pressure(self, finger: int) -> np.ndarray:
|
|
||||||
"""
|
|
||||||
6x12 (72点) 矩阵尺寸。
|
|
||||||
Modbus 地址 60/62。
|
|
||||||
"""
|
|
||||||
rows = 12 # 12 行
|
|
||||||
cols = 6 # 6 列
|
|
||||||
finger_size = rows * cols # 72 个数据点
|
|
||||||
|
|
||||||
# modbus 地址和计数
|
|
||||||
write_address = 60 # 写入手指选择
|
|
||||||
read_address = 62 # 读取压力数据
|
|
||||||
read_count = 96 # 读取 96 个寄存器
|
|
||||||
skip_count = 10 # 跳过前 10 个校验点
|
|
||||||
|
|
||||||
# 0. 参数校验和手指写入值确定
|
|
||||||
if finger < 1 or finger > 5:
|
|
||||||
raise ValueError(f"无效的手指编号: {finger}。手指编号应在 1 到 5 之间。")
|
|
||||||
|
|
||||||
finger_write_value = finger
|
|
||||||
|
|
||||||
# 1. 写入手指选择寄存器 (地址 60)
|
|
||||||
time.sleep(0.008)
|
|
||||||
wrsp = self.cli.write_register(address=write_address, value=finger_write_value, slave=self.slave)
|
|
||||||
if wrsp.isError():
|
|
||||||
raise RuntimeError(f"写入手指选择 {finger} 到地址 {write_address} 失败: {wrsp}")
|
|
||||||
|
|
||||||
# 写入后等待片刻
|
|
||||||
time.sleep(0.008)
|
|
||||||
|
|
||||||
# 2. 读取地址 62 的数据
|
|
||||||
rrsp = self.cli.read_input_registers(address=read_address, count=read_count, slave=self.slave)
|
|
||||||
|
|
||||||
if rrsp.isError():
|
|
||||||
raise RuntimeError(f"读取地址 {read_address} 压力数据失败: {rrsp}")
|
|
||||||
|
|
||||||
registers_16bit: List[int] = rrsp.registers
|
|
||||||
|
|
||||||
# 3. 核心数据处理
|
|
||||||
# a. 提取低 8 位数据 (得到 96 个 8 位数据点)
|
|
||||||
final_data_96 = [reg_value & 255 for reg_value in registers_16bit]
|
|
||||||
|
|
||||||
# b. 跳过前 10 个校验/头部数据点 (得到 86 个有效数据点)
|
|
||||||
effective_data = np.array(final_data_96[skip_count:], dtype=np.uint8)
|
|
||||||
# c. 截取当前手指的矩阵数据 (从 86 个有效点中截取 72 个点)
|
|
||||||
start_idx = 0
|
|
||||||
end_idx = finger_size # 72
|
|
||||||
|
|
||||||
finger_data_flat = effective_data[start_idx:end_idx]
|
|
||||||
|
|
||||||
# d. 验证数据长度
|
|
||||||
if finger_data_flat.size != finger_size:
|
|
||||||
raise ValueError(
|
|
||||||
f"数据提取失败。期望 {finger_size} 点 ({rows}x{cols}),"
|
|
||||||
f"但仅截取到 {finger_data_flat.size} 点。请检查协议,确认地址 62 是否一次性返回了所有手指数据。"
|
|
||||||
)
|
|
||||||
|
|
||||||
# e. 重塑为二维矩阵 (12 行 6 列)
|
|
||||||
finger_matrix = finger_data_flat.reshape((rows, cols))
|
|
||||||
|
|
||||||
return finger_matrix
|
|
||||||
|
|
||||||
def read_pressure_thumb(self) -> np.ndarray:
|
|
||||||
"""读取大拇指压力数据"""
|
|
||||||
return np.array(self._pressure(1), dtype=np.uint8)
|
|
||||||
|
|
||||||
def read_pressure_index(self) -> np.ndarray:
|
|
||||||
"""读取食指压力数据"""
|
|
||||||
return np.array(self._pressure(2), dtype=np.uint8)
|
|
||||||
|
|
||||||
def read_pressure_middle(self) -> np.ndarray:
|
|
||||||
"""读取中指压力数据"""
|
|
||||||
return np.array(self._pressure(3), dtype=np.uint8)
|
|
||||||
|
|
||||||
def read_pressure_ring(self) -> np.ndarray:
|
|
||||||
"""读取无名指压力数据"""
|
|
||||||
return np.array(self._pressure(4), dtype=np.uint8)
|
|
||||||
|
|
||||||
def read_pressure_little(self) -> np.ndarray:
|
|
||||||
"""读取小拇指压力数据"""
|
|
||||||
return np.array(self._pressure(5), dtype=np.uint8)
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# 版本信息接口
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
def read_versions(self) -> Dict[str, int]:
|
|
||||||
"""读取版本信息 (输入寄存器 148-155)"""
|
|
||||||
result = self._read_input_registers(148, 8)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"hand_freedom": result[0],
|
|
||||||
"hand_version": result[1],
|
|
||||||
"hand_number": result[2],
|
|
||||||
"hand_direction": result[3],
|
|
||||||
"software_version_major": result[4],
|
|
||||||
"software_version_minor": result[5] if len(result) > 5 else 0,
|
|
||||||
"software_version_revision": result[6] if len(result) > 6 else 0,
|
|
||||||
"hardware_version": result[7] if len(result) > 7 else 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# 写入接口
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
def write_angles(self, vals: List[int]):
|
|
||||||
"""设置6个关节角度 (保持寄存器 0-5)"""
|
|
||||||
vals = [int(x) for x in vals]
|
|
||||||
if not self.is_valid_6xuint8(vals):
|
|
||||||
raise ValueError("需要6个0-255的整数")
|
|
||||||
self._write_registers(0, vals)
|
|
||||||
|
|
||||||
def write_torques(self, vals: List[int]):
|
|
||||||
"""设置6个关节转矩 (保持寄存器 6-11)"""
|
|
||||||
vals = [int(x) for x in vals]
|
|
||||||
if not self.is_valid_6xuint8(vals):
|
|
||||||
raise ValueError("需要6个0-255的整数")
|
|
||||||
self._write_registers(6, vals)
|
|
||||||
|
|
||||||
def write_speeds(self, vals: List[int]):
|
|
||||||
"""设置6个关节速度 (保持寄存器 12-17)"""
|
|
||||||
vals = [int(x) for x in vals]
|
|
||||||
if not self.is_valid_6xuint8(vals):
|
|
||||||
raise ValueError("需要6个0-255的整数")
|
|
||||||
self._write_registers(12, vals)
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# 上下文管理
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
"""关闭连接"""
|
|
||||||
if self.connected:
|
|
||||||
self.cli.close()
|
|
||||||
self.connected = False
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# API固定接口函数
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
def is_valid_6xuint8(self, lst) -> bool:
|
|
||||||
"""验证6个0-255的整数列表"""
|
|
||||||
if len(lst) != 6:
|
|
||||||
return False
|
|
||||||
return all(isinstance(x, int) and 0 <= x <= 255 for x in lst)
|
|
||||||
|
|
||||||
def set_joint_positions(self, joint_angles=None):
|
|
||||||
"""设置关节位置"""
|
|
||||||
joint_angles = joint_angles or [0] * 6
|
|
||||||
self.write_angles(joint_angles)
|
|
||||||
|
|
||||||
def set_speed(self, speed=None):
|
|
||||||
"""设置速度"""
|
|
||||||
speed = speed or [200] * 6
|
|
||||||
self.write_speeds(speed)
|
|
||||||
|
|
||||||
def set_torque(self, torque=None):
|
|
||||||
"""设置扭矩"""
|
|
||||||
torque = torque or [200] * 6
|
|
||||||
self.write_torques(torque)
|
|
||||||
|
|
||||||
def set_current(self, current=None):
|
|
||||||
"""设置电流 (L6不支持)"""
|
|
||||||
print("当前L6不支持设置电流", flush=True)
|
|
||||||
|
|
||||||
def get_version(self) -> list:
|
|
||||||
"""获取版本信息"""
|
|
||||||
versions = self.read_versions()
|
|
||||||
return [
|
|
||||||
versions.get("hand_freedom", 0),
|
|
||||||
versions.get("hand_version", 0),
|
|
||||||
versions.get("hand_number", 0),
|
|
||||||
versions.get("hand_direction", 0),
|
|
||||||
versions.get("software_version_major", 0),
|
|
||||||
versions.get("hardware_version", 0)
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_current(self):
|
|
||||||
"""获取电流 (L6不支持)"""
|
|
||||||
print("当前L6不支持获取电流", flush=True)
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_state(self) -> list:
|
|
||||||
"""获取关节状态"""
|
|
||||||
return self.read_angles()
|
|
||||||
|
|
||||||
def get_state_for_pub(self) -> list:
|
|
||||||
return self.get_state()
|
|
||||||
|
|
||||||
def get_current_status(self) -> list:
|
|
||||||
return self.get_state()
|
|
||||||
|
|
||||||
def get_speed(self) -> list:
|
|
||||||
"""获取当前速度"""
|
|
||||||
return self.read_speeds()
|
|
||||||
|
|
||||||
def get_joint_speed(self) -> list:
|
|
||||||
return self.get_speed()
|
|
||||||
|
|
||||||
def get_touch_type(self) -> int:
|
|
||||||
"""获取压感类型 (2=矩阵式)"""
|
|
||||||
return 2
|
|
||||||
|
|
||||||
def get_normal_force(self) -> list:
|
|
||||||
"""获取压感数据:点式"""
|
|
||||||
return [-1] * 5
|
|
||||||
|
|
||||||
def get_tangential_force(self) -> list:
|
|
||||||
"""获取压感数据:点式"""
|
|
||||||
return [-1] * 5
|
|
||||||
|
|
||||||
def get_approach_inc(self) -> list:
|
|
||||||
"""获取压感数据:点式"""
|
|
||||||
return [-1] * 5
|
|
||||||
|
|
||||||
def get_touch(self) -> list:
|
|
||||||
return [-1] * 5
|
|
||||||
|
|
||||||
def get_thumb_matrix_touch(self,sleep_time=0):
|
|
||||||
return self._pressure(1)
|
|
||||||
|
|
||||||
def get_index_matrix_touch(self,sleep_time=0):
|
|
||||||
return self._pressure(2)
|
|
||||||
|
|
||||||
def get_middle_matrix_touch(self,sleep_time=0):
|
|
||||||
return self._pressure(3)
|
|
||||||
|
|
||||||
def get_ring_matrix_touch(self,sleep_time=0):
|
|
||||||
return self._pressure(4)
|
|
||||||
|
|
||||||
def get_little_matrix_touch(self,sleep_time=0):
|
|
||||||
return self._pressure(5)
|
|
||||||
|
|
||||||
def get_matrix_touch(self) -> list:
|
|
||||||
"""获取压感数据:矩阵式"""
|
|
||||||
return [self._pressure(1), self._pressure(2), self._pressure(3),
|
|
||||||
self._pressure(4), self._pressure(5)]
|
|
||||||
|
|
||||||
def get_matrix_touch_v2(self) -> list:
|
|
||||||
"""获取压感数据:矩阵式"""
|
|
||||||
return self.get_matrix_touch()
|
|
||||||
|
|
||||||
def get_torque(self) -> list:
|
|
||||||
"""获取当前扭矩"""
|
|
||||||
return self.read_torques()
|
|
||||||
|
|
||||||
def get_temperature(self) -> list:
|
|
||||||
"""获取当前电机温度"""
|
|
||||||
return self.read_temperatures()
|
|
||||||
|
|
||||||
def get_fault(self) -> list:
|
|
||||||
"""获取当前电机故障码"""
|
|
||||||
return self.read_error_codes()
|
|
||||||
|
|
||||||
def get_serial_number(self):
|
|
||||||
return [0] * 6
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# 便捷方法
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
def relax(self):
|
|
||||||
"""所有手指伸直"""
|
|
||||||
self.set_joint_positions([255] * 6)
|
|
||||||
|
|
||||||
def fist(self):
|
|
||||||
"""所有手指握拳"""
|
|
||||||
self.set_joint_positions([0] * 6)
|
|
||||||
|
|
||||||
def dump_status(self):
|
|
||||||
"""打印状态信息"""
|
|
||||||
print("=" * 50)
|
|
||||||
print("L6机械手状态信息")
|
|
||||||
print("=" * 50)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 关节状态
|
|
||||||
angles = self.read_angles()
|
|
||||||
torques = self.read_torques()
|
|
||||||
speeds = self.read_speeds()
|
|
||||||
temps = self.read_temperatures()
|
|
||||||
errors = self.read_error_codes()
|
|
||||||
|
|
||||||
print("关节状态:")
|
|
||||||
for i, name in enumerate(self.JOINT_NAMES):
|
|
||||||
print(f" {name:15s}: 角度={angles[i]:3d}, 扭矩={torques[i]:3d}, "
|
|
||||||
f"速度={speeds[i]:3d}, 温度={temps[i]:2d}℃, 错误={errors[i]:2d}")
|
|
||||||
|
|
||||||
# 版本信息
|
|
||||||
versions = self.read_versions()
|
|
||||||
print("\n版本信息:")
|
|
||||||
for key, value in versions.items():
|
|
||||||
print(f" {key:20s}: {value}")
|
|
||||||
|
|
||||||
# 压力传感器测试
|
|
||||||
print("\n压力传感器测试:")
|
|
||||||
thumb_pressure = self.read_pressure_thumb()
|
|
||||||
print(f"大拇指压力数据长度: {len(thumb_pressure)}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"读取状态时出错: {e}")
|
|
||||||
|
|
||||||
print("=" * 50)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------- 演示程序 -------------------
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# 使用示例
|
|
||||||
try:
|
|
||||||
with LinkerHandL6RS485(hand_id=0x27, modbus_port="/dev/ttyUSB0", baudrate=115200) as hand:
|
|
||||||
print("连接成功!")
|
|
||||||
|
|
||||||
# 打印状态信息
|
|
||||||
hand.dump_status()
|
|
||||||
|
|
||||||
# 测试基本控制
|
|
||||||
print("\n测试控制功能...")
|
|
||||||
print("伸直手指...")
|
|
||||||
hand.relax()
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
print("握拳...")
|
|
||||||
hand.fist()
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
print("恢复伸直...")
|
|
||||||
hand.relax()
|
|
||||||
|
|
||||||
# 测试压力传感器
|
|
||||||
print("\n测试压力传感器...")
|
|
||||||
thumb_matrix = hand.get_thumb_matrix_touch()
|
|
||||||
print(f"大拇指压力数据: {len(thumb_matrix)}个点")
|
|
||||||
|
|
||||||
# 获取所有手指压力数据
|
|
||||||
all_matrices = hand.get_matrix_touch()
|
|
||||||
for i, name in enumerate(hand.FINGER_NAMES):
|
|
||||||
matrix = all_matrices[i]
|
|
||||||
print(f"{name}手指压力数据长度: {len(matrix)}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"错误: {e}")
|
|
||||||
-1157
File diff suppressed because it is too large
Load Diff
@@ -202,6 +202,19 @@ class LinkerHandApi:
|
|||||||
'''Get current joint state'''
|
'''Get current joint state'''
|
||||||
return self.hand.get_current_status()
|
return self.hand.get_current_status()
|
||||||
|
|
||||||
|
def get_state_cached(self):
|
||||||
|
"""Get the latest received state without transmitting new queries."""
|
||||||
|
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):
|
def get_state_for_pub(self):
|
||||||
return self.hand.get_current_pub_status()
|
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))
|
||||||
@@ -37,11 +37,30 @@ def command_changed(previous, current):
|
|||||||
return any(float(old) != float(new) for old, new in zip(previous, values))
|
return any(float(old) != float(new) for old, new in zip(previous, values))
|
||||||
|
|
||||||
|
|
||||||
|
def position_command_should_queue(previous, current, repeat=True):
|
||||||
|
"""Whether the newest position target should be written on this heartbeat."""
|
||||||
|
values = list(current)
|
||||||
|
return bool(values) and (
|
||||||
|
bool(repeat) or command_changed(previous, values)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def state_poll_due(last_poll_time, now, poll_period):
|
def state_poll_due(last_poll_time, now, poll_period):
|
||||||
"""Keep slow CAN state reads off the latency-sensitive command path."""
|
"""Keep slow CAN state reads off the latency-sensitive command path."""
|
||||||
return last_poll_time is None or now >= last_poll_time + poll_period
|
return last_poll_time is None or now >= last_poll_time + poll_period
|
||||||
|
|
||||||
|
|
||||||
|
def state_reads_deferred(
|
||||||
|
last_command_time, now, quiet_period, enabled=True
|
||||||
|
):
|
||||||
|
"""Return whether blocking state reads must yield to active commands."""
|
||||||
|
return (
|
||||||
|
enabled
|
||||||
|
and last_command_time is not None
|
||||||
|
and now < last_command_time + quiet_period
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LinkerHand(Node):
|
class LinkerHand(Node):
|
||||||
def __init__(self, name):
|
def __init__(self, name):
|
||||||
super().__init__(name)
|
super().__init__(name)
|
||||||
@@ -54,6 +73,12 @@ class LinkerHand(Node):
|
|||||||
# -1 keeps the model's original startup speed. Camera teleoperation can
|
# -1 keeps the model's original startup speed. Camera teleoperation can
|
||||||
# set this to a conservative value before the startup pose is sent.
|
# set this to a conservative value before the startup pose is sent.
|
||||||
self.declare_parameter('startup_speed', -1)
|
self.declare_parameter('startup_speed', -1)
|
||||||
|
# -1 keeps the model's original startup torque. The retarget v2
|
||||||
|
# launch uses a conservative value for calibration and preview.
|
||||||
|
self.declare_parameter('startup_torque', -1)
|
||||||
|
# Preserve the legacy behaviour by default. Safety-critical launch
|
||||||
|
# files can configure limits without moving to a startup pose.
|
||||||
|
self.declare_parameter('move_on_startup', True)
|
||||||
# Empty keeps the legacy absolute topics/startup pose. A prefix lets
|
# Empty keeps the legacy absolute topics/startup pose. A prefix lets
|
||||||
# two same-side hands coexist without receiving each other's commands.
|
# two same-side hands coexist without receiving each other's commands.
|
||||||
self.declare_parameter('topic_prefix', '')
|
self.declare_parameter('topic_prefix', '')
|
||||||
@@ -63,6 +88,17 @@ class LinkerHand(Node):
|
|||||||
# incoming position commands.
|
# incoming position commands.
|
||||||
self.declare_parameter('state_poll_rate', 60.0)
|
self.declare_parameter('state_poll_rate', 60.0)
|
||||||
self.declare_parameter('velocity_poll_rate', 60.0)
|
self.declare_parameter('velocity_poll_rate', 60.0)
|
||||||
|
# G20 state and velocity reads each transmit five synchronous CAN
|
||||||
|
# queries. Defer them while teleoperation commands are arriving.
|
||||||
|
self.declare_parameter('defer_state_reads_while_commanding', True)
|
||||||
|
self.declare_parameter('command_quiet_period', 0.2)
|
||||||
|
# Legacy teleoperation sent the latest target on every 30 Hz callback,
|
||||||
|
# including an unchanged target. Some firmware revisions track that
|
||||||
|
# cadence more smoothly than sparse change-only updates.
|
||||||
|
self.declare_parameter('repeat_position_commands', True)
|
||||||
|
# Faults stay manually clearable through cb_hand_setting_cmd. Repeated
|
||||||
|
# automatic clears add periodic CAN traffic to the command stream.
|
||||||
|
self.declare_parameter('auto_clear_faults', False)
|
||||||
|
|
||||||
# ros时间获取
|
# ros时间获取
|
||||||
self.stamp_clock = Clock()
|
self.stamp_clock = Clock()
|
||||||
@@ -75,6 +111,12 @@ class LinkerHand(Node):
|
|||||||
self.startup_speed = int(self.get_parameter('startup_speed').value)
|
self.startup_speed = int(self.get_parameter('startup_speed').value)
|
||||||
if self.startup_speed < -1 or self.startup_speed > 255:
|
if self.startup_speed < -1 or self.startup_speed > 255:
|
||||||
raise ValueError('startup_speed must be -1 or in the range [0, 255]')
|
raise ValueError('startup_speed must be -1 or in the range [0, 255]')
|
||||||
|
self.startup_torque = int(self.get_parameter('startup_torque').value)
|
||||||
|
if self.startup_torque < -1 or self.startup_torque > 255:
|
||||||
|
raise ValueError('startup_torque must be -1 or in the range [0, 255]')
|
||||||
|
self.move_on_startup = bool(
|
||||||
|
self.get_parameter('move_on_startup').value
|
||||||
|
)
|
||||||
self.topic_prefix = self.normalize_topic_prefix(
|
self.topic_prefix = self.normalize_topic_prefix(
|
||||||
self.get_parameter('topic_prefix').value
|
self.get_parameter('topic_prefix').value
|
||||||
)
|
)
|
||||||
@@ -92,6 +134,22 @@ class LinkerHand(Node):
|
|||||||
raise ValueError('velocity_poll_rate must be greater than zero')
|
raise ValueError('velocity_poll_rate must be greater than zero')
|
||||||
self.velocity_poll_period = 1.0 / self.velocity_poll_rate
|
self.velocity_poll_period = 1.0 / self.velocity_poll_rate
|
||||||
self.last_velocity_poll_time = None
|
self.last_velocity_poll_time = None
|
||||||
|
self.defer_state_reads_while_commanding = bool(
|
||||||
|
self.get_parameter(
|
||||||
|
'defer_state_reads_while_commanding'
|
||||||
|
).value
|
||||||
|
)
|
||||||
|
self.command_quiet_period = float(
|
||||||
|
self.get_parameter('command_quiet_period').value
|
||||||
|
)
|
||||||
|
if self.command_quiet_period < 0.0:
|
||||||
|
raise ValueError('command_quiet_period must not be negative')
|
||||||
|
self.auto_clear_faults = bool(
|
||||||
|
self.get_parameter('auto_clear_faults').value
|
||||||
|
)
|
||||||
|
self.repeat_position_commands = bool(
|
||||||
|
self.get_parameter('repeat_position_commands').value
|
||||||
|
)
|
||||||
configured_startup_pose = self.get_parameter_or(
|
configured_startup_pose = self.get_parameter_or(
|
||||||
'startup_pose',
|
'startup_pose',
|
||||||
Parameter('startup_pose', Parameter.Type.INTEGER_ARRAY, []),
|
Parameter('startup_pose', Parameter.Type.INTEGER_ARRAY, []),
|
||||||
@@ -107,8 +165,11 @@ class LinkerHand(Node):
|
|||||||
self.last_hand_eff_cmd = None # 最新手指力矩命令
|
self.last_hand_eff_cmd = None # 最新手指力矩命令
|
||||||
self.applied_hand_post_cmd = None
|
self.applied_hand_post_cmd = None
|
||||||
self.applied_hand_vel_cmd = None
|
self.applied_hand_vel_cmd = None
|
||||||
|
self.last_position_command_time = None
|
||||||
|
|
||||||
self.last_hand_state = [-1] * 10
|
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.last_hand_vel = [-1] * 10
|
||||||
self.force = [[-1] * 5] * 4
|
self.force = [[-1] * 5] * 4
|
||||||
self.matrix_dic = {
|
self.matrix_dic = {
|
||||||
@@ -186,6 +247,7 @@ class LinkerHand(Node):
|
|||||||
COMMAND_QOS,
|
COMMAND_QOS,
|
||||||
)
|
)
|
||||||
self.hand_state_pub = self.create_publisher(JointState, self.topic(f'/cb_{self.hand_type}_hand_state'),10)
|
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)
|
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.is_touch == True:
|
||||||
if self.modbus != "None":
|
if self.modbus != "None":
|
||||||
@@ -244,26 +306,38 @@ class LinkerHand(Node):
|
|||||||
pose = list(self.startup_pose)
|
pose = list(self.startup_pose)
|
||||||
if self.startup_speed >= 0:
|
if self.startup_speed >= 0:
|
||||||
speed = [self.startup_speed] * len(speed)
|
speed = [self.startup_speed] * len(speed)
|
||||||
|
if self.startup_torque >= 0:
|
||||||
|
torque = [self.startup_torque] * len(torque)
|
||||||
if pose is not None:
|
if pose is not None:
|
||||||
for i in range(1):
|
for i in range(1):
|
||||||
self.api.set_speed(speed=speed)
|
self.api.set_speed(speed=speed)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
self.api.set_torque(torque=torque)
|
self.api.set_torque(torque=torque)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
self.api.finger_move(pose=pose)
|
if self.move_on_startup:
|
||||||
time.sleep(0.1)
|
self.api.finger_move(pose=pose)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
def hand_control_cb(self, msg):
|
def hand_control_cb(self, msg):
|
||||||
# The hardware can be slower than the camera. Always replace a
|
# The hardware can be slower than the publisher, so a pending target is
|
||||||
# pending command with the newest sample and never replay an already
|
# always replaced by the newest sample. By default the newest target
|
||||||
# applied sample; this prevents latency from accumulating in software.
|
# is also resent at the publisher cadence, matching the legacy driver.
|
||||||
position = list(msg.position)
|
position = list(msg.position)
|
||||||
if position:
|
if position:
|
||||||
self.last_hand_post_cmd = (
|
# Treat every valid sample as an active teleoperation heartbeat,
|
||||||
position
|
# even if integer quantisation made it identical to the previous
|
||||||
if command_changed(self.applied_hand_post_cmd, position)
|
# target. This keeps all synchronous CAN diagnostics off the bus
|
||||||
else None
|
# for the entire control session, matching the legacy execution
|
||||||
)
|
# path that had no state subscriber.
|
||||||
|
self.last_position_command_time = time.monotonic()
|
||||||
|
if position_command_should_queue(
|
||||||
|
self.applied_hand_post_cmd,
|
||||||
|
position,
|
||||||
|
self.repeat_position_commands,
|
||||||
|
):
|
||||||
|
self.last_hand_post_cmd = position
|
||||||
|
else:
|
||||||
|
self.last_hand_post_cmd = None
|
||||||
|
|
||||||
velocity = list(msg.velocity)
|
velocity = list(msg.velocity)
|
||||||
if velocity:
|
if velocity:
|
||||||
@@ -286,6 +360,9 @@ class LinkerHand(Node):
|
|||||||
self.api.finger_move(pose=pose)
|
self.api.finger_move(pose=pose)
|
||||||
self.applied_hand_post_cmd = pose
|
self.applied_hand_post_cmd = pose
|
||||||
self.last_hand_post_cmd = None
|
self.last_hand_post_cmd = None
|
||||||
|
cached_state = self.api.get_state_cached()
|
||||||
|
if cached_state is not None:
|
||||||
|
self.last_hand_state = cached_state
|
||||||
|
|
||||||
if self.last_hand_vel_cmd is not None:
|
if self.last_hand_vel_cmd is not None:
|
||||||
vel = list(self.last_hand_vel_cmd)
|
vel = list(self.last_hand_vel_cmd)
|
||||||
@@ -317,9 +394,17 @@ class LinkerHand(Node):
|
|||||||
self.last_hand_vel_cmd = None
|
self.last_hand_vel_cmd = None
|
||||||
|
|
||||||
def _poll_state_if_due(self):
|
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
|
return
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
if state_reads_deferred(
|
||||||
|
self.last_position_command_time,
|
||||||
|
now,
|
||||||
|
self.command_quiet_period,
|
||||||
|
self.defer_state_reads_while_commanding,
|
||||||
|
):
|
||||||
|
# A repeated heartbeat retains its original measurement time.
|
||||||
|
return
|
||||||
if not state_poll_due(
|
if not state_poll_due(
|
||||||
self.last_state_poll_time, now, self.state_poll_period
|
self.last_state_poll_time, now, self.state_poll_period
|
||||||
):
|
):
|
||||||
@@ -328,6 +413,7 @@ class LinkerHand(Node):
|
|||||||
# another read on the following timer callback.
|
# another read on the following timer callback.
|
||||||
self.last_state_poll_time = now
|
self.last_state_poll_time = now
|
||||||
self.last_hand_state = self.api.get_state()
|
self.last_hand_state = self.api.get_state()
|
||||||
|
self.last_hand_state_stamp_ns = self.get_clock().now().nanoseconds
|
||||||
time.sleep(0.003)
|
time.sleep(0.003)
|
||||||
if state_poll_due(
|
if state_poll_due(
|
||||||
self.last_velocity_poll_time, now, self.velocity_poll_period
|
self.last_velocity_poll_time, now, self.velocity_poll_period
|
||||||
@@ -342,6 +428,12 @@ class LinkerHand(Node):
|
|||||||
# Position commands have priority over synchronous state reads.
|
# Position commands have priority over synchronous state reads.
|
||||||
self._apply_pending_commands()
|
self._apply_pending_commands()
|
||||||
self._poll_state_if_due()
|
self._poll_state_if_due()
|
||||||
|
diagnostics_deferred = state_reads_deferred(
|
||||||
|
self.last_position_command_time,
|
||||||
|
time.monotonic(),
|
||||||
|
self.command_quiet_period,
|
||||||
|
self.defer_state_reads_while_commanding,
|
||||||
|
)
|
||||||
if self.cmd_lock == False:
|
if self.cmd_lock == False:
|
||||||
time.sleep(0.003)
|
time.sleep(0.003)
|
||||||
if self.run_count == 3 and self.is_touch == True and self.touch_type == 1 and self.modbus == "None" and self.touch_pub.get_subscription_count() > 0:
|
if self.run_count == 3 and self.is_touch == True and self.touch_type == 1 and self.modbus == "None" and self.touch_pub.get_subscription_count() > 0:
|
||||||
@@ -360,7 +452,11 @@ class LinkerHand(Node):
|
|||||||
if self.run_count == 7:
|
if self.run_count == 7:
|
||||||
self.matrix_dic["little_matrix"] = self.api.get_little_matrix_touch(sleep_time=self.sleep_time).tolist()
|
self.matrix_dic["little_matrix"] = self.api.get_little_matrix_touch(sleep_time=self.sleep_time).tolist()
|
||||||
time.sleep(0.005)
|
time.sleep(0.005)
|
||||||
if self.run_count == 8 and self.hand_info_pub.get_subscription_count() > 0:
|
if (
|
||||||
|
self.run_count == 8
|
||||||
|
and self.hand_info_pub.get_subscription_count() > 0
|
||||||
|
and not diagnostics_deferred
|
||||||
|
):
|
||||||
"""手部信息"""
|
"""手部信息"""
|
||||||
self.last_hand_info = {
|
self.last_hand_info = {
|
||||||
"version": self.embedded_version, # Dexterous hand version number
|
"version": self.embedded_version, # Dexterous hand version number
|
||||||
@@ -375,8 +471,9 @@ class LinkerHand(Node):
|
|||||||
"finger_order": self.api.get_finger_order() # Finger motor order
|
"finger_order": self.api.get_finger_order() # Finger motor order
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.run_count == 9:
|
if self.run_count == 9 and self.auto_clear_faults:
|
||||||
self.api.clear_faults() # 自动清除错误编码
|
self.api.clear_faults() # 自动清除错误编码
|
||||||
|
if self.run_count == 9:
|
||||||
self.run_count = 0
|
self.run_count = 0
|
||||||
self.run_count += 1
|
self.run_count += 1
|
||||||
time.sleep(0.003)
|
time.sleep(0.003)
|
||||||
@@ -384,9 +481,7 @@ class LinkerHand(Node):
|
|||||||
|
|
||||||
def pub_state(self):
|
def pub_state(self):
|
||||||
while True:
|
while True:
|
||||||
if self.hand_state_pub.get_subscription_count() > 0:
|
self._publish_feedback()
|
||||||
msg = self.joint_state_msg(self.last_hand_state, self.last_hand_vel)
|
|
||||||
self.hand_state_pub.publish(msg)
|
|
||||||
if self.is_touch == True and self.touch_type == 1 and self.modbus == "None" and self.touch_pub.get_subscription_count() > 0:
|
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 = Float32MultiArray()
|
||||||
msg.data = [float(val) for sublist in self.force for val in sublist]
|
msg.data = [float(val) for sublist in self.force for val in sublist]
|
||||||
@@ -404,6 +499,26 @@ class LinkerHand(Node):
|
|||||||
self.hand_info_pub.publish(msg)
|
self.hand_info_pub.publish(msg)
|
||||||
time.sleep(self.hz)
|
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):
|
def pub_matrix_mass(self, dic):
|
||||||
"""发布矩阵数据合值 单位g 克 JSON格式"""
|
"""发布矩阵数据合值 单位g 克 JSON格式"""
|
||||||
msg = String()
|
msg = String()
|
||||||
@@ -462,10 +577,12 @@ class LinkerHand(Node):
|
|||||||
msg.data = json.dumps(self.matrix_dic)
|
msg.data = json.dumps(self.matrix_dic)
|
||||||
self.matrix_touch_pub.publish(msg)
|
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 = JointState()
|
||||||
joint_state.header = Header()
|
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.name = self.api.get_finger_order()
|
||||||
joint_state.position = [float(x) for x in pose]
|
joint_state.position = [float(x) for x in pose]
|
||||||
if len(vel) > 1:
|
if len(vel) > 1:
|
||||||
|
|||||||
@@ -1,414 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
'''
|
|
||||||
编译: colcon build --symlink-install
|
|
||||||
启动命令:ros2 run linker_hand_ros2_sdk linker_hand_sdk
|
|
||||||
'''
|
|
||||||
from re import A
|
|
||||||
import rclpy,sys # ROS2 Python接口库
|
|
||||||
import time
|
|
||||||
import numpy as np
|
|
||||||
from rclpy.node import Node # ROS2 节点类
|
|
||||||
from rclpy.clock import Clock
|
|
||||||
from std_msgs.msg import String, Header, Float32MultiArray
|
|
||||||
from sensor_msgs.msg import JointState, PointCloud2, PointField
|
|
||||||
import time, json, threading
|
|
||||||
from linker_hand_ros2_sdk.LinkerHand.linker_hand_api import LinkerHandApi
|
|
||||||
from linker_hand_ros2_sdk.LinkerHand.utils.color_msg import ColorMsg
|
|
||||||
from linker_hand_ros2_sdk.LinkerHand.utils.open_can import OpenCan
|
|
||||||
|
|
||||||
|
|
||||||
class LinkerHand(Node):
|
|
||||||
def __init__(self, name):
|
|
||||||
super().__init__(name)
|
|
||||||
# 声明参数(带默认值)
|
|
||||||
self.declare_parameter('hand_type', 'left')
|
|
||||||
self.declare_parameter('hand_joint', 'L6')
|
|
||||||
self.declare_parameter('is_touch', False)
|
|
||||||
self.declare_parameter('can', 'can0')
|
|
||||||
self.declare_parameter('modbus', "None")
|
|
||||||
|
|
||||||
# ros时间获取
|
|
||||||
self.stamp_clock = Clock()
|
|
||||||
# 获取参数值
|
|
||||||
self.hand_type = self.get_parameter('hand_type').value
|
|
||||||
self.hand_joint = self.get_parameter('hand_joint').value
|
|
||||||
self.is_touch = self.get_parameter('is_touch').value
|
|
||||||
self.can = self.get_parameter('can').value
|
|
||||||
self.modbus = self.get_parameter('modbus').value
|
|
||||||
self.sdk_v = 2
|
|
||||||
self.sleep_time = 0.005
|
|
||||||
self.cmd_lock = False
|
|
||||||
self.last_hand_post_cmd = None # 最新手指位置命令
|
|
||||||
self.last_hand_vel_cmd = None # 最新手指速度命令
|
|
||||||
self.last_hand_eff_cmd = None # 最新手指力矩命令
|
|
||||||
|
|
||||||
self.last_hand_state = [-1] * 10
|
|
||||||
self.last_hand_vel = [-1] * 10
|
|
||||||
self.force = [[-1] * 5] * 4
|
|
||||||
self.matrix_dic = {
|
|
||||||
"stamp":{
|
|
||||||
"sec": 0,
|
|
||||||
"nanosec": 0,
|
|
||||||
},
|
|
||||||
"thumb_matrix":[[-1] * 6 for _ in range(12)],
|
|
||||||
"index_matrix":[[-1] * 6 for _ in range(12)],
|
|
||||||
"middle_matrix":[[-1] * 6 for _ in range(12)],
|
|
||||||
"ring_matrix":[[-1] * 6 for _ in range(12)],
|
|
||||||
"little_matrix":[[-1] * 6 for _ in range(12)]
|
|
||||||
}
|
|
||||||
# 压感矩阵合值,单位g 克
|
|
||||||
self.matrix_mass_dic = {
|
|
||||||
"stamp":{
|
|
||||||
"secs": 0,
|
|
||||||
"nsecs": 0,
|
|
||||||
},
|
|
||||||
"thumb_mass":[-1],
|
|
||||||
"index_mass":[-1],
|
|
||||||
"middle_mass":[-1],
|
|
||||||
"ring_mass":[-1],
|
|
||||||
"little_mass":[-1]
|
|
||||||
}
|
|
||||||
self.last_hand_info = {
|
|
||||||
"version": [-1], # Dexterous hand version number
|
|
||||||
"hand_joint": self.hand_joint, # Dexterous hand joint type
|
|
||||||
"speed": [-1] * 10, # Current speed threshold of the dexterous hand
|
|
||||||
"current": [-1] * 10, # Current of the dexterous hand
|
|
||||||
"fault": [-1] * 10, # Current fault of the dexterous hand
|
|
||||||
"motor_temperature": [-1] * 10, # Current motor temperature of the dexterous hand
|
|
||||||
"torque": [-1] * 10, # Current torque of the dexterous hand
|
|
||||||
"is_touch":self.is_touch,
|
|
||||||
"touch_type": -1,
|
|
||||||
"finger_order": None # Finger motor order
|
|
||||||
}
|
|
||||||
self.version = []
|
|
||||||
self.touch_type = -1
|
|
||||||
self.hz = 1.0/60.0
|
|
||||||
|
|
||||||
self.hand_setting_sub = self.create_subscription(String,'/cb_hand_setting_cmd', self.hand_setting_cb, 10)
|
|
||||||
self._init_hand()
|
|
||||||
time.sleep(1)
|
|
||||||
self.run_count = 0 # 计数器,用于记录运行次数
|
|
||||||
self.timer = self.create_timer(0.01, self.run) # 100 Hz
|
|
||||||
self.thread_pub_state = threading.Thread(target=self.pub_state)
|
|
||||||
self.thread_pub_state.daemon = True
|
|
||||||
self.thread_pub_state.start()
|
|
||||||
|
|
||||||
def _init_hand(self):
|
|
||||||
self.api = LinkerHandApi(hand_type=self.hand_type, hand_joint=self.hand_joint,modbus=self.modbus,can=self.can)
|
|
||||||
time.sleep(0.1)
|
|
||||||
self.touch_type = self.api.get_touch_type()
|
|
||||||
self.hand_cmd_sub = self.create_subscription(JointState, f'/cb_{self.hand_type}_hand_control_cmd', self.hand_control_cb,10)
|
|
||||||
self.hand_state_pub = self.create_publisher(JointState, f'/cb_{self.hand_type}_hand_state',10)
|
|
||||||
self.hand_info_pub = self.create_publisher(String, f'/cb_{self.hand_type}_hand_info', 10)
|
|
||||||
if self.is_touch == True:
|
|
||||||
if self.touch_type > 1:
|
|
||||||
ColorMsg(msg=f"{self.hand_type} {self.hand_joint} Equipped with matrix pressure sensing", color='green')
|
|
||||||
self.matrix_touch_pub = self.create_publisher(String, f'/cb_{self.hand_type}_hand_matrix_touch', 10)
|
|
||||||
self.matrix_touch_pub_pc = self.create_publisher(PointCloud2, f'/cb_{self.hand_type}_hand_matrix_touch_pc', 10)
|
|
||||||
self.matrix_touch_mass_pub = self.create_publisher(String, f'/cb_{self.hand_type}_hand_matrix_touch_mass', 10)
|
|
||||||
elif self.touch_type != -1:
|
|
||||||
ColorMsg(msg=f"{self.hand_type} {self.hand_joint} Equipped with pressure sensor", color="green")
|
|
||||||
self.touch_pub = self.create_publisher(Float32MultiArray, f'/cb_{self.hand_type}_hand_force', 10)
|
|
||||||
else:
|
|
||||||
ColorMsg(msg=f"{self.hand_type} {self.hand_joint} Not equipped with any pressure sensors", color="red")
|
|
||||||
self.is_touch = False
|
|
||||||
self.embedded_version = self.api.get_embedded_version()
|
|
||||||
pose = None
|
|
||||||
torque = [200, 200, 200, 200, 200]
|
|
||||||
speed = [200, 250, 250, 250, 250]
|
|
||||||
if self.hand_joint.upper() == "O6" or self.hand_joint.upper() == "L6" or self.hand_joint.upper() == "L6P":
|
|
||||||
pose = [200, 255, 255, 255, 255, 180]
|
|
||||||
torque = [250, 250, 250, 250, 250, 250]
|
|
||||||
# O6 最大速度阈值
|
|
||||||
speed = [200, 250, 250, 250, 250, 250]
|
|
||||||
elif self.hand_joint == "L7":
|
|
||||||
# The data length of L7 is 7, reinitialize here
|
|
||||||
pose = [255, 200, 255, 255, 255, 255, 180]
|
|
||||||
torque = [250, 250, 250, 250, 250, 250, 250]
|
|
||||||
speed = [120, 250, 250, 250, 250, 250, 250]
|
|
||||||
elif self.hand_joint == "L10":
|
|
||||||
torque = [255] * 10
|
|
||||||
pose = [255, 200, 255, 255, 255, 255, 180, 180, 180, 41]
|
|
||||||
speed = [200, 250, 250, 250, 250, 250, 250, 250, 250, 250]
|
|
||||||
elif self.hand_joint == "L20":
|
|
||||||
pose = [255,255,255,255,255,255,10,100,180,240,245,255,255,255,255,255,255,255,255,255]
|
|
||||||
elif self.hand_joint == "L21":
|
|
||||||
pose = [75, 255, 255, 255, 255, 176, 97, 81, 114, 147, 202, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
|
|
||||||
elif self.hand_joint == "L25":
|
|
||||||
pose = [75, 255, 255, 255, 255, 176, 97, 81, 114, 147, 202, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
|
|
||||||
if pose is not None:
|
|
||||||
for i in range(1):
|
|
||||||
self.api.set_speed(speed=speed)
|
|
||||||
time.sleep(0.1)
|
|
||||||
self.api.set_torque(torque=torque)
|
|
||||||
time.sleep(0.1)
|
|
||||||
self.api.finger_move(pose=pose)
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
def list_check(self,pose):
|
|
||||||
if isinstance(pose, list) == False:
|
|
||||||
return False
|
|
||||||
if len(self.last_hand_post_cmd) != len(pose):
|
|
||||||
return False
|
|
||||||
return any(abs(self.last_hand_post_cmd - pose) >= 3 for self.last_hand_post_cmd, pose in zip(self.last_hand_post_cmd, pose))
|
|
||||||
|
|
||||||
def hand_control_cb(self, msg):
|
|
||||||
if self.last_hand_post_cmd == None or self.list_check(msg.position) == True:
|
|
||||||
self.last_hand_post_cmd = msg.position
|
|
||||||
if self.last_hand_vel_cmd == None or self.list_check(msg.velocity) == True:
|
|
||||||
self.last_hand_vel_cmd = msg.velocity
|
|
||||||
if self.last_hand_eff_cmd == None or self.list_check(msg.effort) == True:
|
|
||||||
self.last_hand_eff_cmd = msg.effort
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
if self.sdk_v == 1:
|
|
||||||
self.sleep_time = 0.009
|
|
||||||
if self.hand_state_pub.get_subscription_count() > 0:
|
|
||||||
# 优先获取手指状态并且发布
|
|
||||||
self.last_hand_state = self.api.get_state()
|
|
||||||
time.sleep(0.003)
|
|
||||||
self.last_hand_vel = self.api.get_joint_speed()
|
|
||||||
time.sleep(0.002)
|
|
||||||
if self.cmd_lock == False:
|
|
||||||
if self.last_hand_post_cmd != None:
|
|
||||||
self.api.finger_move(pose=self.last_hand_post_cmd)
|
|
||||||
self.last_hand_post_cmd = None
|
|
||||||
if self.last_hand_vel_cmd != None:
|
|
||||||
vel = list(self.last_hand_vel_cmd)
|
|
||||||
if all(x == 0 for x in vel):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if (str(self.hand_joint).upper() == "O6" or str(self.hand_joint).upper() == "L6" or str(self.hand_joint).upper() == "L6P") and len(vel) == 6:
|
|
||||||
speed = vel
|
|
||||||
self.api.set_joint_speed(speed=speed)
|
|
||||||
elif self.hand_joint == "L7" and len(vel) == 7:
|
|
||||||
speed = vel
|
|
||||||
self.api.set_joint_speed(speed=speed)
|
|
||||||
elif self.hand_joint == "L10" and len(vel) == 10:
|
|
||||||
speed = [vel[0],vel[2],vel[3],vel[4],vel[5]]
|
|
||||||
self.api.set_joint_speed(speed=speed)
|
|
||||||
elif self.hand_joint == "L20" and len(vel) == 20:
|
|
||||||
speed = [vel[10],vel[1],vel[2],vel[3],vel[4]]
|
|
||||||
self.api.set_joint_speed(speed=speed)
|
|
||||||
elif self.hand_joint == "L21" and len(vel) == 25:
|
|
||||||
speed = vel
|
|
||||||
self.api.set_joint_speed(speed=speed)
|
|
||||||
elif self.hand_joint == "L25" and len(vel) == 25:
|
|
||||||
speed = vel
|
|
||||||
self.api.set_joint_speed(speed=speed)
|
|
||||||
self.last_hand_vel_cmd = None
|
|
||||||
time.sleep(0.003)
|
|
||||||
if self.run_count == 3 and self.is_touch == True and self.touch_type == 1 and self.touch_pub.get_subscription_count() > 0:
|
|
||||||
"""单点式压力传感器"""
|
|
||||||
self.force = self.api.get_force()
|
|
||||||
if self.is_touch == True and self.touch_type > 1 and (self.matrix_touch_pub.get_subscription_count() > 0 or self.matrix_touch_mass_pub.get_subscription_count() > 0 or self.matrix_touch_pub_pc.get_subscription_count() > 0):
|
|
||||||
"""矩阵式压力传感器"""
|
|
||||||
if self.run_count == 3:
|
|
||||||
self.matrix_dic["thumb_matrix"] = self.api.get_thumb_matrix_touch(sleep_time=self.sleep_time).tolist()
|
|
||||||
if self.run_count == 4:
|
|
||||||
self.matrix_dic["index_matrix"] = self.api.get_index_matrix_touch(sleep_time=self.sleep_time).tolist()
|
|
||||||
if self.run_count == 5:
|
|
||||||
self.matrix_dic["middle_matrix"] = self.api.get_middle_matrix_touch(sleep_time=self.sleep_time).tolist()
|
|
||||||
if self.run_count == 6:
|
|
||||||
self.matrix_dic["ring_matrix"] = self.api.get_ring_matrix_touch(sleep_time=self.sleep_time).tolist()
|
|
||||||
if self.run_count == 7:
|
|
||||||
self.matrix_dic["little_matrix"] = self.api.get_little_matrix_touch(sleep_time=self.sleep_time).tolist()
|
|
||||||
time.sleep(0.005)
|
|
||||||
if self.run_count == 8 and self.hand_info_pub.get_subscription_count() > 0:
|
|
||||||
"""手部信息"""
|
|
||||||
self.last_hand_info = {
|
|
||||||
"version": self.embedded_version, # Dexterous hand version number
|
|
||||||
"hand_joint": self.hand_joint, # Dexterous hand joint type
|
|
||||||
"speed": self.api.get_speed(), # Current speed threshold of the dexterous hand
|
|
||||||
"current": self.api.get_current(), # Current of the dexterous hand
|
|
||||||
"fault": self.api.get_fault(), # Current fault of the dexterous hand
|
|
||||||
"motor_temperature": self.api.get_temperature(), # Current motor temperature of the dexterous hand
|
|
||||||
"torque": self.api.get_torque(), # Current torque of the dexterous hand
|
|
||||||
"is_touch":self.is_touch,
|
|
||||||
"touch_type": self.touch_type,
|
|
||||||
"finger_order": self.api.get_finger_order() # Finger motor order
|
|
||||||
}
|
|
||||||
if self.run_count == 9:
|
|
||||||
self.run_count = 0
|
|
||||||
self.run_count += 1
|
|
||||||
time.sleep(0.003)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
if self.is_touch == True and self.touch_type == 1 and self.touch_pub.get_subscription_count() > 0:
|
|
||||||
msg = Float32MultiArray()
|
|
||||||
msg.data = [float(val) for sublist in self.force for val in sublist]
|
|
||||||
self.touch_pub.publish(msg)
|
|
||||||
if self.is_touch == True and self.touch_type > 1 and (self.matrix_touch_pub.get_subscription_count() > 0 or self.matrix_touch_mass_pub.get_subscription_count() > 0 or self.matrix_touch_pub_pc.get_subscription_count() > 0):
|
|
||||||
# 发布矩阵压感数据JSON格式
|
|
||||||
self.pub_matrix_dic()
|
|
||||||
# 发布矩阵压感和值JSON格式
|
|
||||||
self.pub_matrix_mass(dic=self.matrix_dic)
|
|
||||||
# 发布矩阵压感点云格式
|
|
||||||
self.pub_matrix_point_cloud()
|
|
||||||
if self.hand_info_pub.get_subscription_count() > 0:
|
|
||||||
msg = String()
|
|
||||||
msg.data = json.dumps(self.last_hand_info)
|
|
||||||
self.hand_info_pub.publish(msg)
|
|
||||||
time.sleep(self.hz)
|
|
||||||
|
|
||||||
def pub_matrix_mass(self, dic):
|
|
||||||
"""发布矩阵数据合值 单位g 克 JSON格式"""
|
|
||||||
msg = String()
|
|
||||||
# 获取当前的 ROS 时间
|
|
||||||
current_time = self.stamp_clock.now()
|
|
||||||
# 提取 secs 和 nsecs
|
|
||||||
t_secs = current_time.to_msg().sec
|
|
||||||
t_nsecs = current_time.to_msg().nanosec
|
|
||||||
self.matrix_mass_dic["stamp"]["secs"] = t_secs
|
|
||||||
self.matrix_mass_dic["stamp"]["nsecs"] = t_nsecs
|
|
||||||
self.matrix_mass_dic["unit"] = "g"
|
|
||||||
self.matrix_mass_dic["thumb_mass"] = sum(sum(row) for row in dic["thumb_matrix"])
|
|
||||||
self.matrix_mass_dic["index_mass"] = sum(sum(row) for row in dic["index_matrix"])
|
|
||||||
self.matrix_mass_dic["middle_mass"] = sum(sum(row) for row in dic["middle_matrix"])
|
|
||||||
self.matrix_mass_dic["ring_mass"] = sum(sum(row) for row in dic["ring_matrix"])
|
|
||||||
self.matrix_mass_dic["little_mass"] = sum(sum(row) for row in dic["little_matrix"])
|
|
||||||
msg.data = json.dumps(self.matrix_mass_dic)
|
|
||||||
self.matrix_touch_mass_pub.publish(msg)
|
|
||||||
|
|
||||||
def pub_matrix_point_cloud(self):
|
|
||||||
"""发布矩阵数据点云格式"""
|
|
||||||
tmp_dic = self.matrix_dic.copy()
|
|
||||||
del tmp_dic['stamp'] # 去掉时间戳字段
|
|
||||||
all_matrices = list(tmp_dic.values()) # 5 帧,每帧 6×12=72 个数
|
|
||||||
# 摊平到一维:360 个 float
|
|
||||||
flat_list = [v for frame in all_matrices for v in frame] # 360
|
|
||||||
flat = np.concatenate([np.asarray(np.clip(c, 0, 255), dtype=np.uint8) for c in flat_list])
|
|
||||||
fields = [PointField(
|
|
||||||
name='val',
|
|
||||||
offset=0,
|
|
||||||
datatype=PointField.UINT8,
|
|
||||||
count=1
|
|
||||||
)]
|
|
||||||
pc = PointCloud2()
|
|
||||||
pc.header.stamp = self.stamp_clock.now().to_msg()
|
|
||||||
pc.header.frame_id = ''
|
|
||||||
pc.height = 1
|
|
||||||
pc.width = flat.size # 360
|
|
||||||
pc.fields = fields
|
|
||||||
pc.is_bigendian = False
|
|
||||||
pc.point_step = 1 # 1 个 float32
|
|
||||||
pc.row_step = pc.point_step * pc.width
|
|
||||||
pc.data = flat.tobytes() # 1440 字节
|
|
||||||
self.matrix_touch_pub_pc.publish(pc)
|
|
||||||
|
|
||||||
def pub_matrix_dic(self):
|
|
||||||
"""发布矩阵数据JSON格式"""
|
|
||||||
msg = String()
|
|
||||||
# 获取当前的 ROS 时间
|
|
||||||
current_time = self.stamp_clock.now()
|
|
||||||
# 提取 secs 和 nsecs
|
|
||||||
t_secs = current_time.to_msg().sec
|
|
||||||
t_nsecs = current_time.to_msg().nanosec
|
|
||||||
self.matrix_dic["stamp"]["secs"] = t_secs
|
|
||||||
self.matrix_dic["stamp"]["nsecs"] = t_nsecs
|
|
||||||
msg.data = json.dumps(self.matrix_dic)
|
|
||||||
self.matrix_touch_pub.publish(msg)
|
|
||||||
|
|
||||||
def joint_state_msg(self, pose,vel=[]):
|
|
||||||
joint_state = JointState()
|
|
||||||
joint_state.header = Header()
|
|
||||||
joint_state.header.stamp = self.get_clock().now().to_msg()
|
|
||||||
joint_state.name = self.api.get_finger_order()
|
|
||||||
joint_state.position = [float(x) for x in pose]
|
|
||||||
if len(vel) > 1:
|
|
||||||
joint_state.velocity = [float(x) for x in vel]
|
|
||||||
else:
|
|
||||||
joint_state.velocity = [0.0] * len(pose)
|
|
||||||
joint_state.effort = [0.0] * len(pose)
|
|
||||||
return joint_state
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def hand_setting_cb(self,msg):
|
|
||||||
'''控制命令回调'''
|
|
||||||
data = json.loads(msg.data)
|
|
||||||
print(f"Received setting command: {data['setting_cmd']}",flush=True)
|
|
||||||
try:
|
|
||||||
if data["params"]["hand_type"] == "left":
|
|
||||||
hand = self.api
|
|
||||||
hand_left = True
|
|
||||||
elif data["params"]["hand_type"] == "right":
|
|
||||||
hand = self.api
|
|
||||||
hand_right = True
|
|
||||||
else:
|
|
||||||
print("Please specify the hand part to be set",flush=True)
|
|
||||||
return
|
|
||||||
self.cmd_lock = True
|
|
||||||
# Set maximum torque
|
|
||||||
if data["setting_cmd"] == "set_max_torque_limits": # Set maximum torque
|
|
||||||
torque = list(data["params"]["torque"])
|
|
||||||
hand.set_torque(torque=torque)
|
|
||||||
|
|
||||||
if data["setting_cmd"] == "set_speed": # Set speed
|
|
||||||
if isinstance(data["params"]["speed"], list) == True:
|
|
||||||
speed = data["params"]["speed"]
|
|
||||||
hand.set_speed(speed=speed)
|
|
||||||
else:
|
|
||||||
ColorMsg(msg=f"Speed parameter error, speed must be a list", color="red")
|
|
||||||
if data["setting_cmd"] == "clear_faults": # Clear faults
|
|
||||||
if hand_left == True and self.hand_joint == "L10" :
|
|
||||||
ColorMsg(msg=f"L10 left hand cannot clear faults")
|
|
||||||
elif hand_right == True and self.hand_joint == "L10" :
|
|
||||||
ColorMsg(msg=f"L10 right hand cannot clear faults")
|
|
||||||
else:
|
|
||||||
hand.clear_faults()
|
|
||||||
if data["setting_cmd"] == "get_faults": # Get faults
|
|
||||||
f = hand.get_fault()
|
|
||||||
ColorMsg(msg=f"Get faults: {f}")
|
|
||||||
if data["setting_cmd"] == "electric_current": # Get current
|
|
||||||
ColorMsg(msg=f"Get current: {hand.get_current()}")
|
|
||||||
if data["setting_cmd"] == "set_electric_current": # Set current
|
|
||||||
if isinstance(data["params"]["current"], list) == True:
|
|
||||||
hand.set_current(data["params"]["current"])
|
|
||||||
if data["setting_cmd"] == "show_fun_table": # Get faults
|
|
||||||
f = hand.show_fun_table()
|
|
||||||
except:
|
|
||||||
print("命令参数错误")
|
|
||||||
self.cmd_lock = False
|
|
||||||
finally:
|
|
||||||
self.cmd_lock = False
|
|
||||||
|
|
||||||
|
|
||||||
def close_can(self):
|
|
||||||
self.api.open_can.close_can(can=self.can)
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def main(args=None):
|
|
||||||
try:
|
|
||||||
rclpy.init(args=args)
|
|
||||||
node = LinkerHand("linker_hand_sdk")
|
|
||||||
embedded_version = node.embedded_version
|
|
||||||
if len(embedded_version) == 3 or node.hand_joint.upper() == "O6" or node.hand_joint.upper() == "L6" or node.hand_joint.upper() == "G20":
|
|
||||||
ColorMsg(msg=f"New Matrix Touch For SDK V2", color="green")
|
|
||||||
node.sdk_v = 2
|
|
||||||
elif len(embedded_version) == 6 and node.hand_joint == "L10":
|
|
||||||
ColorMsg(msg=f"New Matrix Touch For SDK V2", color="green")
|
|
||||||
node.sdk_v = 2
|
|
||||||
elif len(embedded_version) > 4 and ((embedded_version[0]==10 and embedded_version[4]>35) or (embedded_version[0]==7 and embedded_version[4]>50) or (embedded_version[0] == 6)):
|
|
||||||
ColorMsg(msg=f"New Matrix Touch For SDK V2", color="green")
|
|
||||||
node.sdk_v = 2
|
|
||||||
else:
|
|
||||||
ColorMsg(msg=f"SDK V1", color="green")
|
|
||||||
node.sdk_v = 1
|
|
||||||
rclpy.spin(node) # 主循环,监听 ROS 回调
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("收到 Ctrl+C,准备退出...")
|
|
||||||
finally:
|
|
||||||
# node.close_can() # 关闭 CAN 或其他硬件资源
|
|
||||||
# node.destroy_node() # 销毁 ROS 节点
|
|
||||||
# rclpy.shutdown() # 关闭 ROS
|
|
||||||
print("程序已退出。")
|
|
||||||
@@ -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
|
||||||
@@ -4,7 +4,9 @@ from linker_hand_ros2_sdk.linker_hand import (
|
|||||||
COMMAND_QOS,
|
COMMAND_QOS,
|
||||||
LinkerHand,
|
LinkerHand,
|
||||||
command_changed,
|
command_changed,
|
||||||
|
position_command_should_queue,
|
||||||
state_poll_due,
|
state_poll_due,
|
||||||
|
state_reads_deferred,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +32,25 @@ def test_identical_commands_are_not_reapplied():
|
|||||||
assert not command_changed([60, 60], [])
|
assert not command_changed([60, 60], [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_heartbeat_resends_unchanged_position_target():
|
||||||
|
assert position_command_should_queue([60, 60], [60, 60], repeat=True)
|
||||||
|
assert not position_command_should_queue(
|
||||||
|
[60, 60], [60, 60], repeat=False
|
||||||
|
)
|
||||||
|
assert position_command_should_queue(
|
||||||
|
[60, 60], [60, 61], repeat=False
|
||||||
|
)
|
||||||
|
assert not position_command_should_queue([60, 60], [], repeat=True)
|
||||||
|
|
||||||
|
|
||||||
def test_state_polling_is_throttled_without_missing_deadline():
|
def test_state_polling_is_throttled_without_missing_deadline():
|
||||||
assert state_poll_due(None, 10.0, 0.1)
|
assert state_poll_due(None, 10.0, 0.1)
|
||||||
assert not state_poll_due(10.0, 10.09, 0.1)
|
assert not state_poll_due(10.0, 10.09, 0.1)
|
||||||
assert state_poll_due(10.0, 10.1, 0.1)
|
assert state_poll_due(10.0, 10.1, 0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_reads_yield_to_recent_motion_then_resume():
|
||||||
|
assert state_reads_deferred(10.0, 10.1, 0.2)
|
||||||
|
assert not state_reads_deferred(10.0, 10.2, 0.2)
|
||||||
|
assert not state_reads_deferred(None, 10.1, 0.2)
|
||||||
|
assert not state_reads_deferred(10.0, 10.1, 0.2, enabled=False)
|
||||||
|
|||||||
Submodule
+1
Submodule src/linkerhand-l30-sdk added at 0103fc55f9
@@ -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
@@ -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%
|
||||||
|
才考虑切换默认。大日志存储/全量解码重构未与本次采集规则一起进行。
|
||||||
@@ -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`。
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
g20_thumb_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
|
||||||
|
camera_info_topic: /camera/camera/color/camera_info
|
||||||
|
image_topic: /camera/camera/color/image_rect
|
||||||
|
detections_topic: /apriltag/detections
|
||||||
|
tf_topic: /tf
|
||||||
|
# Use PnP translations as 3-D Tag centres and fit the directly observable
|
||||||
|
# root/MCP circles. The passive IP output follows the G20 URDF mimic
|
||||||
|
# relation below; its small residual T5 circle is diagnostic only. PnP
|
||||||
|
# orientations remain auxiliary quality checks. Command 255 is zero.
|
||||||
|
angle_estimation_mode: trajectory_center_3d
|
||||||
|
passive_ip_multiplier: 1.02
|
||||||
|
publish_debug_image: false
|
||||||
|
debug_max_rate_hz: 10.0
|
||||||
|
debug_scale: 0.5
|
||||||
|
|
||||||
|
# Default: send one end-to-end command per direction and pair every valid
|
||||||
|
# AprilTag frame with the timestamp-interpolated actual G20 state.
|
||||||
|
scan_mode: continuous
|
||||||
|
continuous_motion_mode: endpoint
|
||||||
|
repetitions: 1
|
||||||
|
# Used only by point-mode fallback and validation approach offsets.
|
||||||
|
command_step: 8
|
||||||
|
auto_start_tip: true
|
||||||
|
maximum_state_image_skew_ms: 150.0
|
||||||
|
continuous_endpoint_tolerance_u8: 2.0
|
||||||
|
continuous_endpoint_hold_seconds: 1.0
|
||||||
|
continuous_timeout_seconds: 90.0
|
||||||
|
continuous_invalid_timeout_seconds: 3.0
|
||||||
|
continuous_minimum_valid_frames: 40
|
||||||
|
continuous_minimum_state_span_u8: 240.0
|
||||||
|
continuous_minimum_bins: 32
|
||||||
|
continuous_maximum_bin_gap: 16
|
||||||
|
# Keep the responsive firmware speed, but pace it through the same
|
||||||
|
# 8-unit grid without waiting for static image captures at each point.
|
||||||
|
continuous_segment_minimum_seconds: 0.1
|
||||||
|
continuous_segment_timeout_seconds: 5.0
|
||||||
|
continuous_prepare_timeout_seconds: 30.0
|
||||||
|
preflight_frames: 150
|
||||||
|
minimum_detection_rate: 0.95
|
||||||
|
minimum_detection_hz: 15.0
|
||||||
|
maximum_hamming: 0
|
||||||
|
minimum_decision_margin: 30.0
|
||||||
|
# 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.
|
||||||
|
# Keep a small practical margin here; final random validation stays at
|
||||||
|
# MAE <= 2 deg and P95 <= 3 deg.
|
||||||
|
# Match the preflight noise gate to the 3 deg robust capture gate below.
|
||||||
|
# The final calibration is still accepted only by the independent
|
||||||
|
# validation MAE/P95 limits, not by this readiness check.
|
||||||
|
maximum_static_std_deg: 3.0
|
||||||
|
pose_outlier_threshold_deg: 5.0
|
||||||
|
minimum_pose_inlier_rate: 0.90
|
||||||
|
pnp_minimum_valid_rate: 0.95
|
||||||
|
# 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
|
||||||
|
# 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
|
||||||
|
# Preserve the branch through short detector gaps. A continuous sweep
|
||||||
|
# already pauses after 3 s without valid synchronised observations.
|
||||||
|
pnp_tracker_reset_seconds: 5.0
|
||||||
|
# Select all four IPPE branches as one kinematic chain. This prevents T4
|
||||||
|
# and T5 from independently changing mirror branches at the turnaround or
|
||||||
|
# during validation while still allowing real joint motion frame-to-frame.
|
||||||
|
pnp_group_relative_rotation_scale_deg: 5.0
|
||||||
|
pnp_group_relative_translation_scale_m: 0.01
|
||||||
|
# Reprojection remains a tie-breaker; temporal joint-chain continuity is
|
||||||
|
# deliberately dominant for the current 30-38 px planar tags.
|
||||||
|
pnp_group_reprojection_weight: 0.05
|
||||||
|
# Whole-sweep branch review. During a root sweep T3/T4/T5 should retain
|
||||||
|
# rigid relative poses; during a tip sweep T0/T3 should remain fixed.
|
||||||
|
pnp_trajectory_reprojection_scale_px: 0.1
|
||||||
|
pnp_rigid_rotation_scale_deg: 5.0
|
||||||
|
pnp_rigid_translation_scale_m: 0.01
|
||||||
|
# Judge the complete rigid trajectory against a robust sweep reference.
|
||||||
|
# Reject persistent drift at P95; keep a looser hard maximum so one noisy
|
||||||
|
# 30 px endpoint frame does not discard an otherwise sound sweep.
|
||||||
|
pnp_rigid_p95_accepted_drift_deg: 8.0
|
||||||
|
pnp_rigid_maximum_accepted_drift_deg: 15.0
|
||||||
|
# Centre-trajectory mode judges branch consistency by the Euclidean
|
||||||
|
# distance between rigid Tag centres. This is deliberately independent
|
||||||
|
# of the noisy planar-Tag orientation returned by PnP.
|
||||||
|
pnp_rigid_p95_accepted_distance_drift_m: 0.003
|
||||||
|
pnp_rigid_maximum_accepted_distance_drift_m: 0.006
|
||||||
|
|
||||||
|
# Three-dimensional centre-trajectory geometry gates. T0 stays on the
|
||||||
|
# palm as the translation anchor; T3/T4/T5 are the moving thumb points.
|
||||||
|
trajectory_maximum_plane_rms_m: 0.004
|
||||||
|
trajectory_maximum_radial_rms_m: 0.004
|
||||||
|
trajectory_minimum_radius_m: 0.005
|
||||||
|
trajectory_minimum_arc_deg: 15.0
|
||||||
|
trajectory_maximum_root_role_disagreement_deg: 5.0
|
||||||
|
trajectory_maximum_anchor_drift_m: 0.005
|
||||||
|
trajectory_static_translation_outlier_m: 0.005
|
||||||
|
trajectory_maximum_static_translation_rms_m: 0.002
|
||||||
|
|
||||||
|
# Static captures are now used only for sweep preparation and validation.
|
||||||
|
stable_frames: 5
|
||||||
|
capture_frames: 8
|
||||||
|
minimum_settle_seconds: 0.4
|
||||||
|
# This only confirms that the hand has stopped before an 8-frame robust
|
||||||
|
# median capture. The passive T4->T5 pair currently has about 2.3 deg
|
||||||
|
# peak spread over five 30 px PnP frames, while its two IPPE branches are
|
||||||
|
# separated by about 5.5 deg. A 3 deg gate accepts measurement jitter but
|
||||||
|
# still rejects a branch change. Final MAE/P95 limits remain unchanged.
|
||||||
|
maximum_stable_spread_deg: 3.0
|
||||||
|
# In centre mode the stationary capture gate is expressed in metres.
|
||||||
|
maximum_stable_translation_spread_m: 0.003
|
||||||
|
settle_timeout_seconds: 10.0
|
||||||
|
capture_timeout_seconds: 10.0
|
||||||
|
|
||||||
|
validation_command_count: 5
|
||||||
|
# The backlash approach point only waits for feedback to reach the target;
|
||||||
|
# it no longer performs an unnecessary image capture.
|
||||||
|
validation_approach_minimum_seconds: 0.2
|
||||||
|
validation_approach_timeout_seconds: 10.0
|
||||||
|
validation_position_tolerance_u8: 2.0
|
||||||
|
validation_seed: 20260727
|
||||||
|
maximum_validation_mae_deg: 2.0
|
||||||
|
maximum_validation_p95_deg: 3.0
|
||||||
|
maximum_coupling_drift_deg: 2.0
|
||||||
|
minimum_ip_coupling_r_squared: 0.98
|
||||||
|
maximum_monotonic_correction_deg: 2.0
|
||||||
|
maximum_hysteresis_deg: 5.0
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
g20_thumb_cmc_pitch_zero:
|
||||||
|
ros__parameters:
|
||||||
|
t0_id: 0
|
||||||
|
t3_id: 1
|
||||||
|
joint_name: thumb_cmc_pitch
|
||||||
|
motor_index: 0
|
||||||
|
zero_command_u8: 255
|
||||||
|
measure_travel: false
|
||||||
|
baseline_command_u8:
|
||||||
|
[255, 255, 255, 255, 255, 255, 193, 148, 105, 42,
|
||||||
|
245, 255, 255, 255, 255, 255, 255, 255, 255, 255]
|
||||||
|
repetitions: 3
|
||||||
|
zero_capture_frames: 30
|
||||||
|
# Each round sends one 255->64 endpoint command and one 64->255 return
|
||||||
|
# command. All valid T3-minus-T0 centres observed during both motions are
|
||||||
|
# state-binned and fitted to one image-plane circle.
|
||||||
|
trajectory_command_u8: 64
|
||||||
|
trajectory_bin_size_u8: 8.0
|
||||||
|
trajectory_minimum_frames: 45
|
||||||
|
trajectory_minimum_bins: 18
|
||||||
|
trajectory_minimum_state_span_u8: 160.0
|
||||||
|
trajectory_minimum_radius_px: 20.0
|
||||||
|
trajectory_minimum_arc_deg: 20.0
|
||||||
|
trajectory_maximum_radial_rms_px: 2.0
|
||||||
|
trajectory_maximum_p95_radial_error_px: 3.5
|
||||||
|
trajectory_endpoint_settle_seconds: 0.3
|
||||||
|
trajectory_timeout_seconds: 30.0
|
||||||
|
settle_seconds: 0.5
|
||||||
|
move_timeout_seconds: 20.0
|
||||||
|
capture_timeout_seconds: 15.0
|
||||||
|
state_tolerance_u8: 2.0
|
||||||
|
preflight_frames: 60
|
||||||
|
minimum_detection_rate: 0.95
|
||||||
|
maximum_hamming: 0
|
||||||
|
minimum_decision_margin: 30.0
|
||||||
|
minimum_edge_pixels: 40.0
|
||||||
|
maximum_static_position_rms_px: 1.5
|
||||||
|
maximum_round_difference_deg: 1.0
|
||||||
|
maximum_return_error_deg: 1.0
|
||||||
|
# Zero direction is always T3 centre -> fitted circle centre. T3's printed
|
||||||
|
# orientation and corner +x direction are deliberately not used.
|
||||||
|
maximum_zero_radial_error_px: 4.0
|
||||||
|
# Detect a long physical table/reference edge in the lower image. The red
|
||||||
|
# target and blue detected line are display-only aids for manual alignment;
|
||||||
|
# they never block preflight or the start service.
|
||||||
|
camera_alignment_enabled: true
|
||||||
|
camera_alignment_reference_y_ratio: 0.90
|
||||||
|
camera_alignment_roi_y_min_ratio: 0.55
|
||||||
|
camera_alignment_roi_y_max_ratio: 0.98
|
||||||
|
camera_alignment_minimum_line_length_ratio: 0.30
|
||||||
|
camera_alignment_max_candidate_angle_deg: 15.0
|
||||||
|
camera_alignment_max_angle_deg: 0.5
|
||||||
|
camera_alignment_max_vertical_offset_px: 12.0
|
||||||
|
camera_alignment_required_frames: 10
|
||||||
|
camera_alignment_minimum_detection_rate: 0.8
|
||||||
|
camera_alignment_max_age_seconds: 1.0
|
||||||
|
publish_debug_image: true
|
||||||
|
debug_max_rate_hz: 10.0
|
||||||
|
debug_scale: 0.75
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
g20_thumb_cmc_roll_calibration:
|
||||||
|
ros__parameters:
|
||||||
|
t0_id: 0
|
||||||
|
t3_id: 1
|
||||||
|
joint_name: thumb_cmc_roll
|
||||||
|
motor_index: 5
|
||||||
|
zero_command_u8: 255
|
||||||
|
measure_travel: true
|
||||||
|
baseline_command_u8:
|
||||||
|
[255, 255, 255, 255, 255, 255, 193, 148, 105, 42,
|
||||||
|
245, 255, 255, 255, 255, 255, 255, 255, 255, 255]
|
||||||
|
repetitions: 3
|
||||||
|
zero_capture_frames: 30
|
||||||
|
# Measure the complete motor-5 range. Each round captures both static
|
||||||
|
# endpoints around one 255->0->255 circle trajectory.
|
||||||
|
trajectory_command_u8: 0
|
||||||
|
trajectory_bin_size_u8: 8.0
|
||||||
|
trajectory_minimum_frames: 65
|
||||||
|
trajectory_minimum_bins: 30
|
||||||
|
trajectory_minimum_state_span_u8: 240.0
|
||||||
|
trajectory_minimum_radius_px: 20.0
|
||||||
|
trajectory_minimum_arc_deg: 20.0
|
||||||
|
trajectory_maximum_radial_rms_px: 2.0
|
||||||
|
trajectory_maximum_p95_radial_error_px: 3.5
|
||||||
|
trajectory_endpoint_settle_seconds: 0.3
|
||||||
|
trajectory_timeout_seconds: 35.0
|
||||||
|
settle_seconds: 0.5
|
||||||
|
move_timeout_seconds: 25.0
|
||||||
|
capture_timeout_seconds: 15.0
|
||||||
|
state_tolerance_u8: 2.0
|
||||||
|
preflight_frames: 60
|
||||||
|
minimum_detection_rate: 0.95
|
||||||
|
maximum_hamming: 0
|
||||||
|
minimum_decision_margin: 30.0
|
||||||
|
minimum_edge_pixels: 40.0
|
||||||
|
maximum_static_position_rms_px: 1.5
|
||||||
|
maximum_round_difference_deg: 1.0
|
||||||
|
maximum_travel_difference_deg: 1.0
|
||||||
|
minimum_travel_deg: 20.0
|
||||||
|
maximum_return_error_deg: 1.0
|
||||||
|
# Zero direction is always T3 centre -> fitted circle centre. T3's printed
|
||||||
|
# orientation and corner +x direction are deliberately not used.
|
||||||
|
maximum_zero_radial_error_px: 4.0
|
||||||
|
# Detect a long physical table/reference edge in the lower image. The red
|
||||||
|
# target and blue detected line are display-only aids for manual alignment;
|
||||||
|
# they never block preflight or the start service.
|
||||||
|
camera_alignment_enabled: true
|
||||||
|
camera_alignment_reference_y_ratio: 0.90
|
||||||
|
camera_alignment_roi_y_min_ratio: 0.55
|
||||||
|
camera_alignment_roi_y_max_ratio: 0.98
|
||||||
|
camera_alignment_minimum_line_length_ratio: 0.30
|
||||||
|
camera_alignment_max_candidate_angle_deg: 15.0
|
||||||
|
camera_alignment_max_angle_deg: 0.5
|
||||||
|
camera_alignment_max_vertical_offset_px: 12.0
|
||||||
|
camera_alignment_required_frames: 10
|
||||||
|
camera_alignment_minimum_detection_rate: 0.8
|
||||||
|
camera_alignment_max_age_seconds: 1.0
|
||||||
|
publish_debug_image: true
|
||||||
|
debug_max_rate_hz: 10.0
|
||||||
|
debug_scale: 0.75
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<dds>
|
||||||
|
<profiles xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">
|
||||||
|
<transport_descriptors>
|
||||||
|
<transport_descriptor>
|
||||||
|
<transport_id>g20_udp_transport</transport_id>
|
||||||
|
<type>UDPv4</type>
|
||||||
|
<sendBufferSize>10485760</sendBufferSize>
|
||||||
|
<receiveBufferSize>10485760</receiveBufferSize>
|
||||||
|
</transport_descriptor>
|
||||||
|
<transport_descriptor>
|
||||||
|
<transport_id>g20_shm_transport</transport_id>
|
||||||
|
<type>SHM</type>
|
||||||
|
<segment_size>67108864</segment_size>
|
||||||
|
<port_queue_capacity>512</port_queue_capacity>
|
||||||
|
<healthy_check_timeout_ms>1000</healthy_check_timeout_ms>
|
||||||
|
</transport_descriptor>
|
||||||
|
</transport_descriptors>
|
||||||
|
|
||||||
|
<participant
|
||||||
|
profile_name="g20_large_image_participant"
|
||||||
|
is_default_profile="true">
|
||||||
|
<rtps>
|
||||||
|
<userTransports>
|
||||||
|
<transport_id>g20_udp_transport</transport_id>
|
||||||
|
<transport_id>g20_shm_transport</transport_id>
|
||||||
|
</userTransports>
|
||||||
|
<useBuiltinTransports>false</useBuiltinTransports>
|
||||||
|
</rtps>
|
||||||
|
</participant>
|
||||||
|
</profiles>
|
||||||
|
</dds>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
# Live calibration needs the newest frame, not lossless delivery of stale
|
||||||
|
# frames. BEST_EFFORT prevents a slow full-resolution detection callback
|
||||||
|
# from back-pressuring image_proc's reliable image publisher.
|
||||||
|
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]
|
||||||
|
frames: [tag_t0, tag_t3, tag_t4, tag_t5]
|
||||||
|
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.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
|
||||||
@@ -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]
|
||||||
|
frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, index_roll]
|
||||||
|
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.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, 7]
|
||||||
|
frames: [side_base, index_mcp, index_pip, index_dip]
|
||||||
|
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.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]
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/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
|
||||||
|
# Distal Tags use the same measured 16 mm black-code edge as all others.
|
||||||
|
decimate: 1.0
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [4, 5, 6, 7, 14, 15, 16, 17, 18]
|
||||||
|
frames: [side_base, ring_pip, pinky_pip, pinky_dip, 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]
|
||||||
|
|
||||||
|
/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]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""One-release compatibility surface for the former Python package name.
|
||||||
|
|
||||||
|
New code must import :mod:`linkerhand_calibration`. Only the documented
|
||||||
|
configuration loader is re-exported here; calibration algorithms continue to
|
||||||
|
have a single implementation in the renamed package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"g20_thumb_apriltag_calibration is deprecated; "
|
||||||
|
"import linkerhand_calibration instead",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
from linkerhand_calibration.product import ProductConfig, load_product_config
|
||||||
|
|
||||||
|
__all__ = ["ProductConfig", "load_product_config"]
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
"""Deprecated forwarding entry point for the runtime joint-state bridge."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.calibrated_joint_state_bridge import main
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Deprecated forwarding entry point for offline replay."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.offline_replay import main
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Deprecated forwarding entry point for the former Python package."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.one_command import main
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Publish profile-calibrated URDF angles from raw command/feedback u8 values."""
|
||||||
|
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import DeclareLaunchArgument
|
||||||
|
from launch.substitutions import LaunchConfiguration
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description() -> LaunchDescription:
|
||||||
|
return LaunchDescription(
|
||||||
|
[
|
||||||
|
DeclareLaunchArgument("hand_type", default_value="right"),
|
||||||
|
DeclareLaunchArgument("calibration_file"),
|
||||||
|
DeclareLaunchArgument("input_topic", default_value=""),
|
||||||
|
DeclareLaunchArgument("output_topic", default_value=""),
|
||||||
|
Node(
|
||||||
|
package="linkerhand_calibration",
|
||||||
|
executable="calibrated_joint_state_bridge",
|
||||||
|
name=[
|
||||||
|
"calibrated_joint_state_bridge_",
|
||||||
|
LaunchConfiguration("hand_type"),
|
||||||
|
],
|
||||||
|
output="screen",
|
||||||
|
emulate_tty=True,
|
||||||
|
parameters=[
|
||||||
|
{
|
||||||
|
"hand_type": LaunchConfiguration("hand_type"),
|
||||||
|
"calibration_file": LaunchConfiguration(
|
||||||
|
"calibration_file"
|
||||||
|
),
|
||||||
|
"input_topic": LaunchConfiguration("input_topic"),
|
||||||
|
"output_topic": LaunchConfiguration("output_topic"),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
"""Launch Profile-declared Hikrobot views and one calibration owner."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
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):
|
||||||
|
from linkerhand_calibration.product import (
|
||||||
|
ProductCalibrationContract,
|
||||||
|
)
|
||||||
|
from linkerhand_calibration.profiles import load_hand_profile
|
||||||
|
from linkerhand_calibration.runtime.adapters.ros_topics import sdk_topics
|
||||||
|
from linkerhand_calibration.runtime.diagnostic_capture import resolve_diagnostic_capture
|
||||||
|
from linkerhand_calibration.runtime.observation_scope import required_observation_views
|
||||||
|
|
||||||
|
model = LaunchConfiguration("model").perform(context).strip().upper()
|
||||||
|
hand_type = LaunchConfiguration("hand_type").perform(context).lower()
|
||||||
|
if hand_type not in {"left", "right"}:
|
||||||
|
raise RuntimeError("hand_type must be left or right")
|
||||||
|
tag_layout = LaunchConfiguration("tag_layout").perform(context).lower()
|
||||||
|
try:
|
||||||
|
profile_path = LaunchConfiguration("profile_config").perform(context).strip()
|
||||||
|
if not profile_path:
|
||||||
|
raise ValueError("online calibration requires a protected YAML Profile; use calibrate_hand --config")
|
||||||
|
expected = LaunchConfiguration("profile_config_expected_sha256").perform(context).strip()
|
||||||
|
if not expected or hashlib.sha256(Path(profile_path).read_bytes()).hexdigest() != expected:
|
||||||
|
raise ValueError("Profile changed between product validation and launch")
|
||||||
|
contract = ProductCalibrationContract(declarative=load_hand_profile(profile_path))
|
||||||
|
key = contract.typed_profile.key
|
||||||
|
if (key.model, key.side, key.layout) != (model, hand_type, tag_layout):
|
||||||
|
raise ValueError("launch identity differs from the protected Profile")
|
||||||
|
except ValueError as error:
|
||||||
|
raise RuntimeError(str(error)) from error
|
||||||
|
diagnostic = resolve_diagnostic_capture(
|
||||||
|
contract.typed_profile,
|
||||||
|
LaunchConfiguration("diagnostic_capture").perform(context).strip(),
|
||||||
|
)
|
||||||
|
views = required_observation_views(contract.typed_profile, diagnostic)
|
||||||
|
requested_tag_config = LaunchConfiguration("tag_config").perform(context)
|
||||||
|
if not requested_tag_config:
|
||||||
|
raise RuntimeError("tag_config is required; use calibrate_hand --config")
|
||||||
|
tag_config = Path(requested_tag_config).expanduser().resolve()
|
||||||
|
if not tag_config.is_file():
|
||||||
|
raise RuntimeError(f"tag config does not exist: {tag_config}")
|
||||||
|
topic_prefix = f"/{model.lower()}"
|
||||||
|
uses_hcan = contract.typed_profile.sdk_adapter == "o12_hcan_sdk"
|
||||||
|
if contract.typed_profile.sdk_adapter not in {"legacy_byte_sdk", "o12_hcan_sdk", "o30_ros"}:
|
||||||
|
raise RuntimeError("SDK adapter has no ROS launch binding")
|
||||||
|
topics = sdk_topics(contract.typed_profile)
|
||||||
|
command_topic, state_topic = topics.command, topics.feedback
|
||||||
|
requested_source = LaunchConfiguration("source_urdf_path").perform(context)
|
||||||
|
if not requested_source:
|
||||||
|
raise RuntimeError("source_urdf_path is required; use calibrate_hand --config")
|
||||||
|
source_urdf = Path(requested_source).expanduser().resolve()
|
||||||
|
if not source_urdf.is_file():
|
||||||
|
raise RuntimeError(f"source URDF does not exist: {source_urdf}")
|
||||||
|
expected_source_hash = LaunchConfiguration(
|
||||||
|
"source_urdf_expected_sha256"
|
||||||
|
).perform(context).strip().lower()
|
||||||
|
if contract.typed_profile.artifacts.publish_corrected_urdf:
|
||||||
|
if re.fullmatch(r"[0-9a-f]{64}", expected_source_hash) is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"this profile requires source_urdf_expected_sha256 confirmed "
|
||||||
|
"by the CAD/hardware owner"
|
||||||
|
)
|
||||||
|
actual_source_hash = hashlib.sha256(source_urdf.read_bytes()).hexdigest()
|
||||||
|
if actual_source_hash != expected_source_hash:
|
||||||
|
raise RuntimeError(
|
||||||
|
"source_urdf_expected_sha256 does not match source_urdf_path"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 required observation camera serial numbers are required")
|
||||||
|
if len(set(camera_serials.values())) != len(views):
|
||||||
|
raise RuntimeError("required observation camera serial numbers must be unique")
|
||||||
|
|
||||||
|
cameras = []
|
||||||
|
components = []
|
||||||
|
raw_topics = []
|
||||||
|
info_topics = []
|
||||||
|
detection_topics = []
|
||||||
|
calibration_namespace = contract.typed_profile.namespace
|
||||||
|
for view in views:
|
||||||
|
namespace = f"{calibration_namespace}/{view}/camera"
|
||||||
|
raw_topic = f"{namespace}/image_raw"
|
||||||
|
info_topic = f"{namespace}/camera_info"
|
||||||
|
rect_topic = f"{namespace}/image_rect"
|
||||||
|
detector_namespace = f"{calibration_namespace}/{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="linkerhand_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"{model.lower()}_calibration_{view}_optical_frame"
|
||||||
|
),
|
||||||
|
"image_width": 1624,
|
||||||
|
"image_height": 1240,
|
||||||
|
"timestamp_journal_path": str(Path(LaunchConfiguration("session_dir").perform(context))
|
||||||
|
/ f"camera_timing_{view}.jsonl"),
|
||||||
|
"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,
|
||||||
|
# Detector settings belong to the protected model YAML.
|
||||||
|
# A generic launch default must not silently replace its
|
||||||
|
# full-resolution setting for the small calibration Tags.
|
||||||
|
parameters=[str(tag_config)],
|
||||||
|
remappings=[
|
||||||
|
("image_rect", rect_topic),
|
||||||
|
("camera_info", info_topic),
|
||||||
|
],
|
||||||
|
extra_arguments=[{"use_intra_process_comms": True}],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
vision = ComposableNodeContainer(
|
||||||
|
name=f"{model.lower()}_calibration_vision",
|
||||||
|
namespace="/",
|
||||||
|
package="rclcpp_components",
|
||||||
|
executable="component_container_mt",
|
||||||
|
composable_node_descriptions=components,
|
||||||
|
output="screen",
|
||||||
|
emulate_tty=True,
|
||||||
|
)
|
||||||
|
sdk = (
|
||||||
|
Node(
|
||||||
|
package="linkerhand_calibration",
|
||||||
|
executable="o12_sdk_bridge",
|
||||||
|
name="o12_sdk_bridge",
|
||||||
|
output="screen",
|
||||||
|
condition=IfCondition(LaunchConfiguration("start_sdk")),
|
||||||
|
parameters=[{
|
||||||
|
"vendor_config": LaunchConfiguration("vendor_sdk_config"),
|
||||||
|
"vendor_config_sha256": LaunchConfiguration("sdk_config_expected_sha256"),
|
||||||
|
"vendor_python_package": LaunchConfiguration("vendor_sdk_python_package"),
|
||||||
|
"vendor_package_sha256": LaunchConfiguration("sdk_package_expected_sha256"),
|
||||||
|
"hand_type": hand_type,
|
||||||
|
"topic_prefix": f"/{model.lower()}/{hand_type}",
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
if uses_hcan
|
||||||
|
else Node(
|
||||||
|
package="linker_hand_ros2_sdk",
|
||||||
|
executable="linker_hand_sdk",
|
||||||
|
name="linker_hand_sdk",
|
||||||
|
output="screen",
|
||||||
|
condition=IfCondition(LaunchConfiguration("start_sdk")),
|
||||||
|
parameters=[{
|
||||||
|
"hand_type": hand_type,
|
||||||
|
"hand_joint": model,
|
||||||
|
"can": LaunchConfiguration("can_interface"),
|
||||||
|
"modbus": "None",
|
||||||
|
"topic_prefix": topic_prefix,
|
||||||
|
"move_on_startup": False,
|
||||||
|
"startup_speed": ParameterValue(
|
||||||
|
LaunchConfiguration("calibration_speed"), value_type=int
|
||||||
|
),
|
||||||
|
"startup_torque": 80,
|
||||||
|
# Match 30 Hz cameras so state/image p95 skew stays below 50 ms.
|
||||||
|
"state_poll_rate": 30.0,
|
||||||
|
# Calibration does not consume measured joint velocity. A
|
||||||
|
# G20 velocity read sends another five synchronous CAN
|
||||||
|
# queries, so keep it off the trajectory-critical path.
|
||||||
|
"velocity_poll_rate": 1.0,
|
||||||
|
# G20 sends an endpoint and L6 streams a bounded trajectory.
|
||||||
|
# Keep polling the real motor state during either command path;
|
||||||
|
# otherwise the SDK republishes stale state and creates large
|
||||||
|
# command-unit holes in the trajectory bins.
|
||||||
|
"defer_state_reads_while_commanding": False,
|
||||||
|
"repeat_position_commands": False,
|
||||||
|
"is_touch": False,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if contract.typed_profile.sdk_adapter == "o30_ros":
|
||||||
|
from linkerhand_calibration.runtime.adapters.o30_ros import launch_parameters
|
||||||
|
sdk = Node(package="linker_hand_o30_ros2_sdk", executable="linker_hand_o30_ros2_sdk",
|
||||||
|
name="linker_hand_o30_sdk", output="screen",
|
||||||
|
condition=IfCondition(LaunchConfiguration("start_sdk")),
|
||||||
|
parameters=[launch_parameters(side=hand_type)])
|
||||||
|
calibration = Node(
|
||||||
|
package="linkerhand_calibration",
|
||||||
|
executable="three_camera_calibration_node",
|
||||||
|
name=f"{model.lower()}_calibration",
|
||||||
|
output="screen",
|
||||||
|
emulate_tty=True,
|
||||||
|
arguments=[
|
||||||
|
"--profile-id",
|
||||||
|
contract.typed_profile.key.profile_id,
|
||||||
|
"--profile-config", profile_path, "--profile-sha256", expected,
|
||||||
|
],
|
||||||
|
parameters=[
|
||||||
|
LaunchConfiguration("calibration_config"),
|
||||||
|
{
|
||||||
|
"serial_number": hand_serial,
|
||||||
|
"session_dir": str(session_dir),
|
||||||
|
"resume_raw_samples_path": LaunchConfiguration(
|
||||||
|
"resume_raw_samples_path"
|
||||||
|
),
|
||||||
|
"resume_mode": LaunchConfiguration("resume_mode"),
|
||||||
|
"training_policy": LaunchConfiguration("training_policy"),
|
||||||
|
"preparation_witnesses_json": ParameterValue(
|
||||||
|
LaunchConfiguration("preparation_witnesses_json"), value_type=str),
|
||||||
|
"capture_plan_expected_sha256": ParameterValue(
|
||||||
|
LaunchConfiguration("capture_plan_expected_sha256"), value_type=str),
|
||||||
|
"initial_command_file": LaunchConfiguration("initial_command_file"),
|
||||||
|
"reuse_camera_calibration": ParameterValue(LaunchConfiguration("reuse_camera_calibration"), value_type=bool),
|
||||||
|
"camera_optical_observations_file": LaunchConfiguration("camera_optical_observations_file"),
|
||||||
|
"command_topic": command_topic,
|
||||||
|
"state_topic": state_topic,
|
||||||
|
**({"setting_topic": topics.setting} if topics.setting else {}),
|
||||||
|
"camera_extrinsics_file": LaunchConfiguration(
|
||||||
|
"camera_extrinsics_file"
|
||||||
|
),
|
||||||
|
"source_urdf_path": str(source_urdf),
|
||||||
|
"source_urdf_expected_sha256": LaunchConfiguration(
|
||||||
|
"source_urdf_expected_sha256"
|
||||||
|
),
|
||||||
|
"camera_extrinsics_expected_sha256": LaunchConfiguration(
|
||||||
|
"camera_extrinsics_expected_sha256"
|
||||||
|
),
|
||||||
|
"calibration_config_expected_sha256": LaunchConfiguration(
|
||||||
|
"calibration_config_expected_sha256"
|
||||||
|
),
|
||||||
|
"tag_config_expected_sha256": LaunchConfiguration(
|
||||||
|
"tag_config_expected_sha256"
|
||||||
|
),
|
||||||
|
"sdk_config_expected_sha256": LaunchConfiguration(
|
||||||
|
"sdk_config_expected_sha256"
|
||||||
|
),
|
||||||
|
"sdk_package_expected_sha256": LaunchConfiguration("sdk_package_expected_sha256"),
|
||||||
|
"profile_config_expected_sha256": LaunchConfiguration(
|
||||||
|
"profile_config_expected_sha256"
|
||||||
|
),
|
||||||
|
"commands_enabled": ParameterValue(
|
||||||
|
LaunchConfiguration("commands_enabled"), value_type=bool
|
||||||
|
),
|
||||||
|
"diagnostic_capture": ParameterValue(
|
||||||
|
LaunchConfiguration("diagnostic_capture"), value_type=str
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
command_topic,
|
||||||
|
state_topic,
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
f"/{model.lower()}/{hand_type}/calibration_health",
|
||||||
|
]
|
||||||
|
if uses_hcan else []
|
||||||
|
),
|
||||||
|
f"{calibration_namespace}/status",
|
||||||
|
],
|
||||||
|
output="screen",
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
LogInfo(
|
||||||
|
msg=(
|
||||||
|
f"{model} {hand_type} {tag_layout} calibration session: {session_dir}; "
|
||||||
|
f"source_urdf={source_urdf}"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
LogInfo(
|
||||||
|
msg=(
|
||||||
|
"Camera mapping: " + " ".join(f"{view}={serial}" for view, serial in camera_serials.items())
|
||||||
|
)
|
||||||
|
),
|
||||||
|
*cameras,
|
||||||
|
vision,
|
||||||
|
Node(package="linkerhand_calibration", executable="tag_border_filter_node",
|
||||||
|
name="tag_border_filter", output="screen",
|
||||||
|
arguments=["--profile-config", profile_path, "--profile-sha256", expected,
|
||||||
|
"--views", *views]),
|
||||||
|
sdk,
|
||||||
|
calibration,
|
||||||
|
bag,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description() -> LaunchDescription:
|
||||||
|
package_share = Path(
|
||||||
|
get_package_share_directory("linkerhand_calibration")
|
||||||
|
)
|
||||||
|
return LaunchDescription(
|
||||||
|
[
|
||||||
|
# Camera processes publish ~2 MB frames across DDS. Force the
|
||||||
|
# matching RMW and provide both current and legacy profile names
|
||||||
|
# so the configured 64 MB shared-memory segment is actually used.
|
||||||
|
SetEnvironmentVariable(
|
||||||
|
name="RMW_IMPLEMENTATION",
|
||||||
|
value="rmw_fastrtps_cpp",
|
||||||
|
),
|
||||||
|
SetEnvironmentVariable(
|
||||||
|
name="FASTDDS_DEFAULT_PROFILES_FILE",
|
||||||
|
value=str(package_share / "config" / "fastdds_large_images.xml"),
|
||||||
|
),
|
||||||
|
SetEnvironmentVariable(
|
||||||
|
name="FASTRTPS_DEFAULT_PROFILES_FILE",
|
||||||
|
value=str(package_share / "config" / "fastdds_large_images.xml"),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("model", default_value=""),
|
||||||
|
DeclareLaunchArgument("hand_type", default_value=""),
|
||||||
|
DeclareLaunchArgument("tag_layout", default_value=""),
|
||||||
|
DeclareLaunchArgument("serial_number", default_value="UNSET"),
|
||||||
|
DeclareLaunchArgument("camera_model", default_value="MV-CS020-10U"),
|
||||||
|
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("can_interface", default_value="can0"),
|
||||||
|
DeclareLaunchArgument("calibration_speed", default_value="15"),
|
||||||
|
DeclareLaunchArgument("camera_extrinsics_file", default_value=""),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"source_urdf_path", default_value=""
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"source_urdf_expected_sha256", default_value=""
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_extrinsics_expected_sha256", default_value=""
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"calibration_config_expected_sha256", default_value=""
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"tag_config_expected_sha256", default_value=""
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("vendor_sdk_config", default_value=""),
|
||||||
|
DeclareLaunchArgument("vendor_sdk_python_package", default_value=""),
|
||||||
|
DeclareLaunchArgument("sdk_package_expected_sha256", default_value=""),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"sdk_config_expected_sha256", default_value=""
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"profile_config_expected_sha256", default_value=""
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("profile_config", default_value=""),
|
||||||
|
DeclareLaunchArgument("commands_enabled", default_value="true"),
|
||||||
|
DeclareLaunchArgument("diagnostic_capture", default_value=""),
|
||||||
|
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("resume_raw_samples_path", default_value=""),
|
||||||
|
DeclareLaunchArgument("resume_mode", default_value="verify"),
|
||||||
|
DeclareLaunchArgument("training_policy", default_value="fixed"),
|
||||||
|
DeclareLaunchArgument("preparation_witnesses_json", default_value=""),
|
||||||
|
DeclareLaunchArgument("capture_plan_expected_sha256", default_value=""),
|
||||||
|
DeclareLaunchArgument("initial_command_file", default_value=""),
|
||||||
|
DeclareLaunchArgument("reuse_camera_calibration", default_value="false"),
|
||||||
|
DeclareLaunchArgument("camera_optical_observations_file", default_value=""),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"calibration_config",
|
||||||
|
default_value="",
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"tag_config",
|
||||||
|
default_value="",
|
||||||
|
),
|
||||||
|
OpaqueFunction(function=_launch_stack),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Launch three Hikrobot cameras for one-time checkerboard extrinsics."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import DeclareLaunchArgument, OpaqueFunction, SetEnvironmentVariable
|
||||||
|
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(context):
|
||||||
|
cameras = []
|
||||||
|
rectifiers = []
|
||||||
|
serials = {}
|
||||||
|
for view in VIEWS:
|
||||||
|
serial = LaunchConfiguration(f"{view}_camera_serial").perform(context)
|
||||||
|
if not serial:
|
||||||
|
raise RuntimeError(f"{view}_camera_serial is required")
|
||||||
|
serials[view] = serial
|
||||||
|
namespace = f"/g20_extrinsics/{view}/camera"
|
||||||
|
cameras.append(
|
||||||
|
Node(
|
||||||
|
package="linkerhand_calibration",
|
||||||
|
executable="hikrobot_camera_node",
|
||||||
|
name="hikrobot_camera",
|
||||||
|
namespace=namespace,
|
||||||
|
output="screen",
|
||||||
|
emulate_tty=True,
|
||||||
|
parameters=[
|
||||||
|
{
|
||||||
|
"serial_number": serial,
|
||||||
|
"expected_model": LaunchConfiguration("camera_model"),
|
||||||
|
"camera_name": LaunchConfiguration(
|
||||||
|
f"{view}_camera_name"
|
||||||
|
),
|
||||||
|
"frame_id": f"g20_extrinsics_{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": False,
|
||||||
|
"camera_info_url": LaunchConfiguration(
|
||||||
|
f"{view}_camera_info_url"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rectifiers.append(
|
||||||
|
ComposableNode(
|
||||||
|
package="image_proc",
|
||||||
|
plugin="image_proc::RectifyNode",
|
||||||
|
name=f"rectify_{view}",
|
||||||
|
namespace=namespace,
|
||||||
|
remappings=[
|
||||||
|
("image", f"{namespace}/image_raw"),
|
||||||
|
("camera_info", f"{namespace}/camera_info"),
|
||||||
|
("image_rect", f"{namespace}/image_rect"),
|
||||||
|
],
|
||||||
|
parameters=[{"queue_size": 1}],
|
||||||
|
extra_arguments=[{"use_intra_process_comms": True}],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
container = ComposableNodeContainer(
|
||||||
|
name="g20_extrinsics_vision",
|
||||||
|
namespace="/",
|
||||||
|
package="rclcpp_components",
|
||||||
|
executable="component_container_mt",
|
||||||
|
composable_node_descriptions=rectifiers,
|
||||||
|
output="screen",
|
||||||
|
)
|
||||||
|
solver = Node(
|
||||||
|
package="linkerhand_calibration",
|
||||||
|
executable="three_camera_extrinsics_node",
|
||||||
|
name="g20_camera_extrinsics",
|
||||||
|
output="screen",
|
||||||
|
emulate_tty=True,
|
||||||
|
parameters=[
|
||||||
|
{
|
||||||
|
"output_file": LaunchConfiguration("output_file"),
|
||||||
|
"verification_extrinsics_file": LaunchConfiguration("verification_extrinsics_file"),
|
||||||
|
"checkerboard_columns": ParameterValue(
|
||||||
|
LaunchConfiguration("checkerboard_columns"), value_type=int
|
||||||
|
),
|
||||||
|
"checkerboard_rows": ParameterValue(
|
||||||
|
LaunchConfiguration("checkerboard_rows"), value_type=int
|
||||||
|
),
|
||||||
|
"square_size_m": ParameterValue(
|
||||||
|
LaunchConfiguration("square_size_m"), value_type=float
|
||||||
|
),
|
||||||
|
"enable_gui": ParameterValue(
|
||||||
|
LaunchConfiguration("enable_gui"), value_type=bool
|
||||||
|
),
|
||||||
|
"gui_refresh_hz": ParameterValue(
|
||||||
|
LaunchConfiguration("gui_refresh_hz"), value_type=float
|
||||||
|
),
|
||||||
|
"maximum_reprojection_rms_px": ParameterValue(
|
||||||
|
LaunchConfiguration("maximum_reprojection_rms_px"),
|
||||||
|
value_type=float,
|
||||||
|
),
|
||||||
|
"maximum_candidate_pair_reprojection_rms_px": ParameterValue(
|
||||||
|
LaunchConfiguration(
|
||||||
|
"maximum_candidate_pair_reprojection_rms_px"
|
||||||
|
),
|
||||||
|
value_type=float,
|
||||||
|
),
|
||||||
|
"maximum_single_camera_reprojection_rms_px": ParameterValue(
|
||||||
|
LaunchConfiguration(
|
||||||
|
"maximum_single_camera_reprojection_rms_px"
|
||||||
|
),
|
||||||
|
value_type=float,
|
||||||
|
),
|
||||||
|
"auto_capture_default": ParameterValue(
|
||||||
|
LaunchConfiguration("auto_capture_default"),
|
||||||
|
value_type=bool,
|
||||||
|
),
|
||||||
|
"auto_capture_stable_seconds": ParameterValue(
|
||||||
|
LaunchConfiguration("auto_capture_stable_seconds"),
|
||||||
|
value_type=float,
|
||||||
|
),
|
||||||
|
**{
|
||||||
|
f"{view}_camera_serial": serials[view]
|
||||||
|
for view in VIEWS
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return [*cameras, container, solver]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description() -> LaunchDescription:
|
||||||
|
package_share = Path(
|
||||||
|
get_package_share_directory("linkerhand_calibration")
|
||||||
|
)
|
||||||
|
camera_info = Path.home() / ".ros" / "camera_info"
|
||||||
|
return LaunchDescription(
|
||||||
|
[
|
||||||
|
# Keep the large-image transport deterministic even when the
|
||||||
|
# calling shell selected another ROS 2 RMW implementation.
|
||||||
|
SetEnvironmentVariable(
|
||||||
|
name="RMW_IMPLEMENTATION",
|
||||||
|
value="rmw_fastrtps_cpp",
|
||||||
|
),
|
||||||
|
SetEnvironmentVariable(
|
||||||
|
name="FASTDDS_DEFAULT_PROFILES_FILE",
|
||||||
|
value=str(
|
||||||
|
package_share / "config" / "fastdds_large_images.xml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SetEnvironmentVariable(
|
||||||
|
name="FASTRTPS_DEFAULT_PROFILES_FILE",
|
||||||
|
value=str(
|
||||||
|
package_share / "config" / "fastdds_large_images.xml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(camera_info / "hikrobot_DB2163742.yaml")),
|
||||||
|
DeclareLaunchArgument("side_camera_info_url", default_value=str(camera_info / "hikrobot_DB2163749.yaml")),
|
||||||
|
DeclareLaunchArgument("top_camera_info_url", default_value=str(camera_info / "hikrobot_DB2163739.yaml")),
|
||||||
|
DeclareLaunchArgument("camera_frame_rate", default_value="15.0"),
|
||||||
|
DeclareLaunchArgument("exposure_time_us", default_value="5000.0"),
|
||||||
|
DeclareLaunchArgument("gain_db", default_value="0.0"),
|
||||||
|
DeclareLaunchArgument("checkerboard_columns", default_value="8"),
|
||||||
|
DeclareLaunchArgument("checkerboard_rows", default_value="5"),
|
||||||
|
DeclareLaunchArgument("square_size_m", default_value="0.027"),
|
||||||
|
DeclareLaunchArgument("enable_gui", default_value="true"),
|
||||||
|
DeclareLaunchArgument("gui_refresh_hz", default_value="2.0"),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"maximum_reprojection_rms_px", default_value="1.2"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"maximum_candidate_pair_reprojection_rms_px",
|
||||||
|
default_value="1.5",
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"maximum_single_camera_reprojection_rms_px",
|
||||||
|
default_value="1.5",
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("auto_capture_default", default_value="false"),
|
||||||
|
DeclareLaunchArgument("verification_extrinsics_file", default_value=""),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"auto_capture_stable_seconds", default_value="1.0"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"output_file",
|
||||||
|
default_value=str(Path.cwd() / "config" / "g20_three_camera_extrinsics.yaml"),
|
||||||
|
),
|
||||||
|
OpaqueFunction(function=_launch),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Stable launch name for the profile-driven calibration stack."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
implementation = Path(__file__).with_name("three_camera_calibration.launch.py")
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"linkerhand_unified_calibration_launch", implementation
|
||||||
|
)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError("unified calibration launch implementation is missing")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module.generate_launch_description()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Profile-driven LinkerHand calibration and validated URDF correction."""
|
||||||
|
|
||||||
|
from .core import CalibrationProfile, ProfileKey
|
||||||
|
|
||||||
|
__all__ = ["CalibrationProfile", "ProfileKey"]
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
"""Hardware-independent point acquisition state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from bisect import bisect_left
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .core import robust_rotation_summary
|
||||||
|
from .pnp import SquareTagPose
|
||||||
|
|
||||||
|
|
||||||
|
TAG_PAIR_ROLES: dict[str, tuple[str, str]] = {
|
||||||
|
"t0_t3": ("t0", "t3"),
|
||||||
|
"t3_t4": ("t3", "t4"),
|
||||||
|
"t4_t5": ("t4", "t5"),
|
||||||
|
}
|
||||||
|
PAIR_NAMES: tuple[str, ...] = tuple(TAG_PAIR_ROLES)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TagQuality:
|
||||||
|
hamming: int
|
||||||
|
decision_margin: float
|
||||||
|
edge_pixels: float
|
||||||
|
reprojection_error_px: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def tag_quality_is_valid(
|
||||||
|
quality: TagQuality,
|
||||||
|
*,
|
||||||
|
maximum_hamming: int,
|
||||||
|
minimum_decision_margin: float,
|
||||||
|
minimum_edge_pixels: float,
|
||||||
|
maximum_reprojection_error_px: float | None = None,
|
||||||
|
) -> bool:
|
||||||
|
detection_valid = (
|
||||||
|
quality.hamming <= maximum_hamming
|
||||||
|
and quality.decision_margin >= minimum_decision_margin
|
||||||
|
and quality.edge_pixels >= minimum_edge_pixels
|
||||||
|
)
|
||||||
|
if not detection_valid:
|
||||||
|
return False
|
||||||
|
if maximum_reprojection_error_px is None:
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
quality.reprojection_error_px is not None
|
||||||
|
and quality.reprojection_error_px <= maximum_reprojection_error_px
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_pnp_reset_watchdog(
|
||||||
|
*,
|
||||||
|
detection_good: bool,
|
||||||
|
pnp_valid: bool,
|
||||||
|
now: float,
|
||||||
|
invalid_since: float | None,
|
||||||
|
reset_after_seconds: float,
|
||||||
|
) -> tuple[float | None, bool]:
|
||||||
|
"""Track continuous PnP-only failures and request a throttled reset."""
|
||||||
|
reset_after = float(reset_after_seconds)
|
||||||
|
if reset_after <= 0.0:
|
||||||
|
raise ValueError("reset_after_seconds must be positive")
|
||||||
|
if not detection_good or pnp_valid:
|
||||||
|
return None, False
|
||||||
|
since = float(now) if invalid_since is None else float(invalid_since)
|
||||||
|
if float(now) - since >= reset_after:
|
||||||
|
# Start a new interval so a permanently bad view is not reset on every
|
||||||
|
# frame. The next valid frame clears the interval.
|
||||||
|
return float(now), True
|
||||||
|
return since, False
|
||||||
|
|
||||||
|
|
||||||
|
def required_resume_views(active_view: str | None) -> tuple[str, ...]:
|
||||||
|
"""Require only the active view on resume; start still checks all views."""
|
||||||
|
all_views = ("front", "side", "top")
|
||||||
|
if active_view is None:
|
||||||
|
return all_views
|
||||||
|
view = str(active_view)
|
||||||
|
if view not in all_views:
|
||||||
|
raise ValueError(f"unknown calibration view: {view}")
|
||||||
|
return (view,)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Observation:
|
||||||
|
stamp_ns: int
|
||||||
|
received_at: float
|
||||||
|
relative_quaternion_xyzw: Mapping[str, tuple[float, float, float, float]]
|
||||||
|
tag_quality: Mapping[str, TagQuality]
|
||||||
|
state_u8: tuple[float, ...] = ()
|
||||||
|
state_stamp_ns: int | None = None
|
||||||
|
state_sync_error_ns: int | None = None
|
||||||
|
tag_quaternion_xyzw: Mapping[
|
||||||
|
str, tuple[float, float, float, float]
|
||||||
|
] = field(default_factory=dict)
|
||||||
|
tag_translation_xyz_m: Mapping[
|
||||||
|
str, tuple[float, float, float]
|
||||||
|
] = field(default_factory=dict)
|
||||||
|
tag_pose_candidates: Mapping[
|
||||||
|
str, tuple[SquareTagPose, ...]
|
||||||
|
] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StateSample:
|
||||||
|
stamp_ns: int
|
||||||
|
position_u8: tuple[float, ...]
|
||||||
|
channel_stamps_ns: tuple[int, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def interpolate_state_u8(
|
||||||
|
samples: Sequence[StateSample],
|
||||||
|
stamp_ns: int,
|
||||||
|
*,
|
||||||
|
maximum_skew_ns: int,
|
||||||
|
) -> tuple[tuple[float, ...], int] | None:
|
||||||
|
"""Interpolate a profile-sized hand state at an image timestamp.
|
||||||
|
|
||||||
|
The SDK publishes state independently from the camera. Continuous
|
||||||
|
calibration must therefore use the image timestamp instead of whichever
|
||||||
|
state happened to arrive most recently in the ROS callback thread.
|
||||||
|
"""
|
||||||
|
if maximum_skew_ns < 0:
|
||||||
|
raise ValueError("maximum_skew_ns must be non-negative")
|
||||||
|
if not samples:
|
||||||
|
return None
|
||||||
|
if any(sample.channel_stamps_ns for sample in samples):
|
||||||
|
# A multi-frame SDK does not observe all motors at the same instant.
|
||||||
|
# De-duplicate each channel by its CAN receipt, then interpolate it
|
||||||
|
# independently. Repeated publication never creates extra support.
|
||||||
|
samples = tuple(sample for sample in samples if sample.stamp_ns >= stamp_ns-maximum_skew_ns
|
||||||
|
and sample.channel_stamps_ns and min(sample.channel_stamps_ns) <= stamp_ns+maximum_skew_ns)
|
||||||
|
if not samples:
|
||||||
|
return None
|
||||||
|
count = len(samples[0].position_u8)
|
||||||
|
channels = [dict() for _ in range(count)]
|
||||||
|
for sample in samples:
|
||||||
|
if len(sample.position_u8) != count or len(sample.channel_stamps_ns) != count:
|
||||||
|
return None
|
||||||
|
for index, (stamp, value) in enumerate(zip(sample.channel_stamps_ns, sample.position_u8)):
|
||||||
|
if stamp in channels[index] and channels[index][stamp] != value:
|
||||||
|
return None
|
||||||
|
channels[index][stamp] = value
|
||||||
|
matches = [interpolate_state_u8(tuple(StateSample(stamp, (value,))
|
||||||
|
for stamp, value in sorted(channel.items())), stamp_ns, maximum_skew_ns=maximum_skew_ns)
|
||||||
|
for channel in channels]
|
||||||
|
if any(match is None for match in matches):
|
||||||
|
return None
|
||||||
|
return tuple(match[0][0] for match in matches), max(match[1] for match in matches)
|
||||||
|
stamps = [int(sample.stamp_ns) for sample in samples]
|
||||||
|
index = bisect_left(stamps, int(stamp_ns))
|
||||||
|
|
||||||
|
if index < len(samples) and stamps[index] == int(stamp_ns):
|
||||||
|
state = samples[index].position_u8
|
||||||
|
return (tuple(float(value) for value in state), 0)
|
||||||
|
|
||||||
|
before = samples[index - 1] if index > 0 else None
|
||||||
|
after = samples[index] if index < len(samples) else None
|
||||||
|
if before is not None and after is not None:
|
||||||
|
before_gap = int(stamp_ns) - int(before.stamp_ns)
|
||||||
|
after_gap = int(after.stamp_ns) - int(stamp_ns)
|
||||||
|
nearest_gap = min(before_gap, after_gap)
|
||||||
|
if nearest_gap > maximum_skew_ns:
|
||||||
|
return None
|
||||||
|
denominator = int(after.stamp_ns) - int(before.stamp_ns)
|
||||||
|
if denominator <= 0:
|
||||||
|
return (
|
||||||
|
tuple(float(value) for value in before.position_u8),
|
||||||
|
nearest_gap,
|
||||||
|
)
|
||||||
|
fraction = before_gap / denominator
|
||||||
|
before_values = np.asarray(before.position_u8, dtype=float)
|
||||||
|
after_values = np.asarray(after.position_u8, dtype=float)
|
||||||
|
if (
|
||||||
|
before_values.ndim != 1
|
||||||
|
or before_values.size == 0
|
||||||
|
or after_values.shape != before_values.shape
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
interpolated = before_values + fraction * (after_values - before_values)
|
||||||
|
return (
|
||||||
|
tuple(float(value) for value in interpolated),
|
||||||
|
nearest_gap,
|
||||||
|
)
|
||||||
|
|
||||||
|
nearest = before if before is not None else after
|
||||||
|
if nearest is None:
|
||||||
|
return None
|
||||||
|
gap = abs(int(stamp_ns) - int(nearest.stamp_ns))
|
||||||
|
if gap > maximum_skew_ns or not nearest.position_u8:
|
||||||
|
return None
|
||||||
|
return (tuple(float(value) for value in nearest.position_u8), gap)
|
||||||
|
|
||||||
|
|
||||||
|
# Physical-angle profiles use the same timestamp interpolation. Keep the old
|
||||||
|
# public name for compatibility and offer a unit-neutral spelling to new code.
|
||||||
|
interpolate_state = interpolate_state_u8
|
||||||
|
|
||||||
|
|
||||||
|
class ContinuousSweepCollector:
|
||||||
|
"""Collect timestamp-synchronised observations during one end-to-end move."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
endpoint_tolerance_u8: float = 2.0,
|
||||||
|
endpoint_hold_seconds: float = 1.0,
|
||||||
|
timeout_seconds: float = 90.0,
|
||||||
|
invalid_timeout_seconds: float = 2.0,
|
||||||
|
minimum_valid_frames: int = 40,
|
||||||
|
minimum_state_span_u8: float = 240.0,
|
||||||
|
) -> None:
|
||||||
|
if endpoint_tolerance_u8 < 0.0:
|
||||||
|
raise ValueError("endpoint_tolerance_u8 must be non-negative")
|
||||||
|
if endpoint_hold_seconds <= 0.0:
|
||||||
|
raise ValueError("endpoint_hold_seconds must be positive")
|
||||||
|
if timeout_seconds <= 0.0 or invalid_timeout_seconds <= 0.0:
|
||||||
|
raise ValueError("sweep timeouts must be positive")
|
||||||
|
if minimum_valid_frames < 3:
|
||||||
|
raise ValueError("minimum_valid_frames must be at least 3")
|
||||||
|
if minimum_state_span_u8 <= 0.0:
|
||||||
|
raise ValueError("minimum_state_span_u8 must be positive")
|
||||||
|
self.endpoint_tolerance_u8 = float(endpoint_tolerance_u8)
|
||||||
|
self.endpoint_hold_seconds = float(endpoint_hold_seconds)
|
||||||
|
self.timeout_seconds = float(timeout_seconds)
|
||||||
|
self.invalid_timeout_seconds = float(invalid_timeout_seconds)
|
||||||
|
self.minimum_valid_frames = int(minimum_valid_frames)
|
||||||
|
self.minimum_state_span_u8 = float(minimum_state_span_u8)
|
||||||
|
self.observations: list[Observation] = []
|
||||||
|
self.motor_index = 0
|
||||||
|
self.start_u8 = 255.0
|
||||||
|
self.target_u8 = 0.0
|
||||||
|
self.started_at: float | None = None
|
||||||
|
self.last_valid_at: float | None = None
|
||||||
|
self.endpoint_since: float | None = None
|
||||||
|
self.state = "idle"
|
||||||
|
self.reason = ""
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
now: float,
|
||||||
|
*,
|
||||||
|
motor_index: int,
|
||||||
|
start_u8: int,
|
||||||
|
target_u8: int,
|
||||||
|
) -> None:
|
||||||
|
if motor_index not in (0, 15):
|
||||||
|
raise ValueError("continuous thumb sweep only permits motor 0 or 15")
|
||||||
|
if {int(start_u8), int(target_u8)} != {0, 255}:
|
||||||
|
raise ValueError("continuous sweep endpoints must be 0 and 255")
|
||||||
|
self.observations.clear()
|
||||||
|
self.motor_index = int(motor_index)
|
||||||
|
self.start_u8 = float(start_u8)
|
||||||
|
self.target_u8 = float(target_u8)
|
||||||
|
self.started_at = float(now)
|
||||||
|
self.last_valid_at = float(now)
|
||||||
|
self.endpoint_since = None
|
||||||
|
self.state = "collecting"
|
||||||
|
self.reason = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> bool:
|
||||||
|
return self.state == "collecting"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def valid_frames_seen(self) -> int:
|
||||||
|
return len(self.observations)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_span_u8(self) -> float:
|
||||||
|
if not self.observations:
|
||||||
|
return 0.0
|
||||||
|
values = [
|
||||||
|
float(observation.state_u8[self.motor_index])
|
||||||
|
for observation in self.observations
|
||||||
|
]
|
||||||
|
return float(max(values) - min(values))
|
||||||
|
|
||||||
|
def add(
|
||||||
|
self, observation: Observation, now: float
|
||||||
|
) -> list[Observation] | None:
|
||||||
|
if not self.active:
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
len(observation.state_u8) != 20
|
||||||
|
or observation.state_sync_error_ns is None
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
value = float(observation.state_u8[self.motor_index])
|
||||||
|
if not np.isfinite(value) or not -3.0 <= value <= 258.0:
|
||||||
|
return None
|
||||||
|
now = float(now)
|
||||||
|
self.observations.append(observation)
|
||||||
|
self.last_valid_at = now
|
||||||
|
|
||||||
|
if abs(value - self.target_u8) <= self.endpoint_tolerance_u8:
|
||||||
|
if self.endpoint_since is None:
|
||||||
|
self.endpoint_since = now
|
||||||
|
else:
|
||||||
|
self.endpoint_since = None
|
||||||
|
|
||||||
|
enough_endpoint_hold = (
|
||||||
|
self.endpoint_since is not None
|
||||||
|
and now - self.endpoint_since >= self.endpoint_hold_seconds
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
enough_endpoint_hold
|
||||||
|
and len(self.observations) >= self.minimum_valid_frames
|
||||||
|
and self.state_span_u8 >= self.minimum_state_span_u8
|
||||||
|
):
|
||||||
|
self.state = "complete"
|
||||||
|
return list(self.observations)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def poll(self, now: float) -> None:
|
||||||
|
if not self.active:
|
||||||
|
return
|
||||||
|
now = float(now)
|
||||||
|
if now - float(self.started_at) > self.timeout_seconds:
|
||||||
|
self.state = "failed"
|
||||||
|
self.reason = "sweep_timeout"
|
||||||
|
elif now - float(self.last_valid_at) > self.invalid_timeout_seconds:
|
||||||
|
self.state = "failed"
|
||||||
|
self.reason = "synchronised_tag_state_timeout"
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_sweep_observations(
|
||||||
|
observations: Sequence[Observation],
|
||||||
|
*,
|
||||||
|
motor_index: int,
|
||||||
|
start_u8: int,
|
||||||
|
target_u8: int,
|
||||||
|
endpoint_tolerance_u8: float,
|
||||||
|
) -> dict[int, dict[str, Any]]:
|
||||||
|
"""Robustly aggregate continuous observations into integer motor bins."""
|
||||||
|
if not observations:
|
||||||
|
raise ValueError("cannot aggregate an empty continuous sweep")
|
||||||
|
bins: dict[int, list[Observation]] = {}
|
||||||
|
for observation in observations:
|
||||||
|
if len(observation.state_u8) != 20:
|
||||||
|
continue
|
||||||
|
value = float(observation.state_u8[motor_index])
|
||||||
|
if abs(value - float(start_u8)) <= endpoint_tolerance_u8:
|
||||||
|
command = int(start_u8)
|
||||||
|
elif abs(value - float(target_u8)) <= endpoint_tolerance_u8:
|
||||||
|
command = int(target_u8)
|
||||||
|
else:
|
||||||
|
command = int(np.clip(np.rint(value), 0, 255))
|
||||||
|
bins.setdefault(command, []).append(observation)
|
||||||
|
return {
|
||||||
|
command: aggregate_observations(values)
|
||||||
|
for command, values in sorted(bins.items())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PointCollector:
|
||||||
|
"""Wait for a stable pose, then aggregate a fixed number of frames."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
stable_frames: int = 15,
|
||||||
|
capture_frames: int = 30,
|
||||||
|
minimum_settle_seconds: float = 0.4,
|
||||||
|
maximum_stable_spread_rad: float = np.deg2rad(0.3),
|
||||||
|
stability_mode: str = "rotation",
|
||||||
|
maximum_stable_translation_spread_m: float = 0.003,
|
||||||
|
settle_timeout_seconds: float = 5.0,
|
||||||
|
capture_timeout_seconds: float = 5.0,
|
||||||
|
) -> None:
|
||||||
|
if stable_frames < 3 or capture_frames < 3:
|
||||||
|
raise ValueError("stable_frames and capture_frames must be at least 3")
|
||||||
|
self.stable_frames = int(stable_frames)
|
||||||
|
self.capture_frames = int(capture_frames)
|
||||||
|
self.minimum_settle_seconds = float(minimum_settle_seconds)
|
||||||
|
self.maximum_stable_spread_rad = float(maximum_stable_spread_rad)
|
||||||
|
self.stability_mode = str(stability_mode)
|
||||||
|
self.maximum_stable_translation_spread_m = float(
|
||||||
|
maximum_stable_translation_spread_m
|
||||||
|
)
|
||||||
|
if self.stability_mode not in {"rotation", "translation"}:
|
||||||
|
raise ValueError(
|
||||||
|
"stability_mode must be rotation or translation"
|
||||||
|
)
|
||||||
|
if self.maximum_stable_translation_spread_m <= 0.0:
|
||||||
|
raise ValueError(
|
||||||
|
"maximum_stable_translation_spread_m must be positive"
|
||||||
|
)
|
||||||
|
self.settle_timeout_seconds = float(settle_timeout_seconds)
|
||||||
|
self.capture_timeout_seconds = float(capture_timeout_seconds)
|
||||||
|
self._stable: deque[Observation] = deque(maxlen=self.stable_frames)
|
||||||
|
self._captured: list[Observation] = []
|
||||||
|
self._consecutive_invalid_frames = 0
|
||||||
|
self.started_at: float | None = None
|
||||||
|
self.capture_started_at: float | None = None
|
||||||
|
self.state = "idle"
|
||||||
|
self.reason = ""
|
||||||
|
self.stable_spread_rad: dict[str, float] = {}
|
||||||
|
self.stable_spread_m: dict[str, float] = {}
|
||||||
|
self.required_state_index: int | None = None
|
||||||
|
self.required_state_u8: float | None = None
|
||||||
|
self.maximum_state_error_u8: float | None = None
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
now: float,
|
||||||
|
*,
|
||||||
|
required_state_index: int | None = None,
|
||||||
|
required_state_u8: float | None = None,
|
||||||
|
maximum_state_error_u8: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
state_constraints = (
|
||||||
|
required_state_index,
|
||||||
|
required_state_u8,
|
||||||
|
maximum_state_error_u8,
|
||||||
|
)
|
||||||
|
if any(value is not None for value in state_constraints) and not all(
|
||||||
|
value is not None for value in state_constraints
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"point state constraint parameters must be provided together"
|
||||||
|
)
|
||||||
|
if required_state_index is not None:
|
||||||
|
if not 0 <= int(required_state_index) < 20:
|
||||||
|
raise ValueError("required_state_index must be in [0, 19]")
|
||||||
|
if not np.isfinite(float(required_state_u8)):
|
||||||
|
raise ValueError("required_state_u8 must be finite")
|
||||||
|
if float(maximum_state_error_u8) < 0.0:
|
||||||
|
raise ValueError(
|
||||||
|
"maximum_state_error_u8 must be non-negative"
|
||||||
|
)
|
||||||
|
self._stable.clear()
|
||||||
|
self._captured.clear()
|
||||||
|
self._consecutive_invalid_frames = 0
|
||||||
|
self.started_at = float(now)
|
||||||
|
self.capture_started_at = None
|
||||||
|
self.state = "settling"
|
||||||
|
self.reason = ""
|
||||||
|
self.stable_spread_rad = {}
|
||||||
|
self.stable_spread_m = {}
|
||||||
|
self.required_state_index = (
|
||||||
|
None
|
||||||
|
if required_state_index is None
|
||||||
|
else int(required_state_index)
|
||||||
|
)
|
||||||
|
self.required_state_u8 = (
|
||||||
|
None if required_state_u8 is None else float(required_state_u8)
|
||||||
|
)
|
||||||
|
self.maximum_state_error_u8 = (
|
||||||
|
None
|
||||||
|
if maximum_state_error_u8 is None
|
||||||
|
else float(maximum_state_error_u8)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> bool:
|
||||||
|
return self.state in {"settling", "capturing"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stable_frames_seen(self) -> int:
|
||||||
|
return len(self._stable)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capture_frames_seen(self) -> int:
|
||||||
|
return len(self._captured)
|
||||||
|
|
||||||
|
def _window_is_stable(self) -> bool:
|
||||||
|
if len(self._stable) < self.stable_frames:
|
||||||
|
return False
|
||||||
|
if self.stability_mode == "translation":
|
||||||
|
spreads: dict[str, float] = {}
|
||||||
|
for pair, (parent, child) in TAG_PAIR_ROLES.items():
|
||||||
|
if any(
|
||||||
|
parent not in observation.tag_translation_xyz_m
|
||||||
|
or child not in observation.tag_translation_xyz_m
|
||||||
|
for observation in self._stable
|
||||||
|
):
|
||||||
|
self.reason = f"{pair}_translation_missing"
|
||||||
|
return False
|
||||||
|
vectors = np.asarray(
|
||||||
|
[
|
||||||
|
np.asarray(
|
||||||
|
observation.tag_translation_xyz_m[child],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
- np.asarray(
|
||||||
|
observation.tag_translation_xyz_m[parent],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
for observation in self._stable
|
||||||
|
],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
reference = np.median(vectors, axis=0)
|
||||||
|
spreads[pair] = float(
|
||||||
|
np.max(np.linalg.norm(vectors - reference, axis=1))
|
||||||
|
)
|
||||||
|
self.stable_spread_m = spreads
|
||||||
|
for pair, spread in spreads.items():
|
||||||
|
if spread > self.maximum_stable_translation_spread_m:
|
||||||
|
self.reason = f"{pair}_not_stable"
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
spreads: dict[str, float] = {}
|
||||||
|
for pair in PAIR_NAMES:
|
||||||
|
quaternions = [
|
||||||
|
observation.relative_quaternion_xyzw[pair]
|
||||||
|
for observation in self._stable
|
||||||
|
]
|
||||||
|
_, spread = robust_rotation_summary(quaternions)
|
||||||
|
spreads[pair] = float(spread)
|
||||||
|
self.stable_spread_rad = spreads
|
||||||
|
for pair, spread in spreads.items():
|
||||||
|
if spread > self.maximum_stable_spread_rad:
|
||||||
|
self.reason = f"{pair}_not_stable"
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _state_is_acceptable(self, observation: Observation) -> bool:
|
||||||
|
if self.required_state_index is None:
|
||||||
|
return True
|
||||||
|
if (
|
||||||
|
len(observation.state_u8) != 20
|
||||||
|
or observation.state_sync_error_ns is None
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
value = float(observation.state_u8[self.required_state_index])
|
||||||
|
return bool(
|
||||||
|
np.isfinite(value)
|
||||||
|
and abs(value - float(self.required_state_u8))
|
||||||
|
<= float(self.maximum_state_error_u8)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _return_to_settling(self, reason: str) -> None:
|
||||||
|
self._stable.clear()
|
||||||
|
self._captured.clear()
|
||||||
|
self._consecutive_invalid_frames = 0
|
||||||
|
self.capture_started_at = None
|
||||||
|
self.state = "settling"
|
||||||
|
self.reason = str(reason)
|
||||||
|
self.stable_spread_rad = {}
|
||||||
|
self.stable_spread_m = {}
|
||||||
|
|
||||||
|
def add(
|
||||||
|
self, observation: Observation, now: float
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if not self.active:
|
||||||
|
return None
|
||||||
|
self._consecutive_invalid_frames = 0
|
||||||
|
now = float(now)
|
||||||
|
if not self._state_is_acceptable(observation):
|
||||||
|
self._return_to_settling("motor_position_out_of_tolerance")
|
||||||
|
return None
|
||||||
|
if self.state == "settling":
|
||||||
|
self._stable.append(observation)
|
||||||
|
elapsed = now - float(self.started_at)
|
||||||
|
if elapsed >= self.minimum_settle_seconds and self._window_is_stable():
|
||||||
|
self.state = "capturing"
|
||||||
|
self.capture_started_at = now
|
||||||
|
self._captured.clear()
|
||||||
|
self.reason = ""
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._captured.append(observation)
|
||||||
|
if len(self._captured) < self.capture_frames:
|
||||||
|
return None
|
||||||
|
# Validate continuity across the boundary as well as inside the
|
||||||
|
# capture block. A planar branch can switch immediately after the
|
||||||
|
# stable window and then look perfectly stable for every capture
|
||||||
|
# frame; checking only the captured frames would accept that jump.
|
||||||
|
stability_aggregate = aggregate_observations(
|
||||||
|
[*self._stable, *self._captured]
|
||||||
|
)
|
||||||
|
if self.stability_mode == "translation":
|
||||||
|
unstable_pairs = [
|
||||||
|
pair
|
||||||
|
for pair, spread in stability_aggregate[
|
||||||
|
"maximum_translation_spread_m"
|
||||||
|
].items()
|
||||||
|
if float(spread)
|
||||||
|
> self.maximum_stable_translation_spread_m
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
unstable_pairs = [
|
||||||
|
pair
|
||||||
|
for pair, spread in stability_aggregate[
|
||||||
|
"maximum_spread_rad"
|
||||||
|
].items()
|
||||||
|
if float(spread) > self.maximum_stable_spread_rad
|
||||||
|
]
|
||||||
|
if unstable_pairs:
|
||||||
|
self._return_to_settling(
|
||||||
|
f"{unstable_pairs[0]}_capture_not_stable"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
self.state = "complete"
|
||||||
|
return aggregate_observations(self._captured)
|
||||||
|
|
||||||
|
def poll(self, now: float) -> None:
|
||||||
|
if not self.active:
|
||||||
|
return
|
||||||
|
now = float(now)
|
||||||
|
if self.state == "settling":
|
||||||
|
if now - float(self.started_at) > self.settle_timeout_seconds:
|
||||||
|
self.state = "failed"
|
||||||
|
self.reason = self.reason or "settle_timeout"
|
||||||
|
elif self.state == "capturing":
|
||||||
|
if now - float(self.capture_started_at) > self.capture_timeout_seconds:
|
||||||
|
self.state = "failed"
|
||||||
|
self.reason = "capture_timeout"
|
||||||
|
|
||||||
|
def mark_invalid_frame(self) -> None:
|
||||||
|
"""Skip one invalid frame while retaining the recent valid window."""
|
||||||
|
if self.state == "capturing":
|
||||||
|
self._return_to_settling("invalid_tag_frame")
|
||||||
|
return
|
||||||
|
if self.state == "settling":
|
||||||
|
self._consecutive_invalid_frames += 1
|
||||||
|
if self._consecutive_invalid_frames >= 3:
|
||||||
|
self._stable.clear()
|
||||||
|
self.reason = "invalid_tag_frame"
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_observations(
|
||||||
|
observations: Sequence[Observation],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not observations:
|
||||||
|
raise ValueError("cannot aggregate an empty observation sequence")
|
||||||
|
relative: dict[str, list[float]] = {}
|
||||||
|
spread: dict[str, float] = {}
|
||||||
|
for pair in PAIR_NAMES:
|
||||||
|
quaternion, maximum = robust_rotation_summary(
|
||||||
|
[
|
||||||
|
observation.relative_quaternion_xyzw[pair]
|
||||||
|
for observation in observations
|
||||||
|
]
|
||||||
|
)
|
||||||
|
relative[pair] = [float(value) for value in quaternion]
|
||||||
|
spread[pair] = float(maximum)
|
||||||
|
|
||||||
|
quality: dict[str, dict[str, float]] = {}
|
||||||
|
tag_names = sorted(observations[0].tag_quality)
|
||||||
|
for tag_name in tag_names:
|
||||||
|
values = [
|
||||||
|
observation.tag_quality[tag_name] for observation in observations
|
||||||
|
]
|
||||||
|
quality[tag_name] = {
|
||||||
|
"minimum_decision_margin": float(
|
||||||
|
min(value.decision_margin for value in values)
|
||||||
|
),
|
||||||
|
"minimum_edge_pixels": float(min(value.edge_pixels for value in values)),
|
||||||
|
"maximum_hamming": int(max(value.hamming for value in values)),
|
||||||
|
}
|
||||||
|
reprojection_errors = [
|
||||||
|
float(value.reprojection_error_px)
|
||||||
|
for value in values
|
||||||
|
if value.reprojection_error_px is not None
|
||||||
|
]
|
||||||
|
if reprojection_errors:
|
||||||
|
quality[tag_name]["maximum_reprojection_error_px"] = float(
|
||||||
|
max(reprojection_errors)
|
||||||
|
)
|
||||||
|
|
||||||
|
states = [
|
||||||
|
observation.state_u8
|
||||||
|
for observation in observations
|
||||||
|
if len(observation.state_u8) == 20
|
||||||
|
]
|
||||||
|
state_median: list[float] = []
|
||||||
|
if states:
|
||||||
|
state_median = [
|
||||||
|
float(value)
|
||||||
|
for value in np.median(np.asarray(states, dtype=float), axis=0)
|
||||||
|
]
|
||||||
|
sync_errors = [
|
||||||
|
int(observation.state_sync_error_ns)
|
||||||
|
for observation in observations
|
||||||
|
if observation.state_sync_error_ns is not None
|
||||||
|
]
|
||||||
|
tag_translations: dict[str, list[float]] = {}
|
||||||
|
translation_spread: dict[str, float] = {}
|
||||||
|
translation_roles = sorted(
|
||||||
|
set.intersection(
|
||||||
|
*(
|
||||||
|
set(observation.tag_translation_xyz_m)
|
||||||
|
for observation in observations
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if observations
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
for role in translation_roles:
|
||||||
|
values = np.asarray(
|
||||||
|
[
|
||||||
|
observation.tag_translation_xyz_m[role]
|
||||||
|
for observation in observations
|
||||||
|
],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
if values.shape == (len(observations), 3) and np.all(
|
||||||
|
np.isfinite(values)
|
||||||
|
):
|
||||||
|
tag_translations[role] = [
|
||||||
|
float(value)
|
||||||
|
for value in np.median(values, axis=0)
|
||||||
|
]
|
||||||
|
for pair, (parent, child) in TAG_PAIR_ROLES.items():
|
||||||
|
if parent not in translation_roles or child not in translation_roles:
|
||||||
|
continue
|
||||||
|
vectors = np.asarray(
|
||||||
|
[
|
||||||
|
np.asarray(
|
||||||
|
observation.tag_translation_xyz_m[child],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
- np.asarray(
|
||||||
|
observation.tag_translation_xyz_m[parent],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
for observation in observations
|
||||||
|
],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
reference = np.median(vectors, axis=0)
|
||||||
|
translation_spread[pair] = float(
|
||||||
|
np.max(np.linalg.norm(vectors - reference, axis=1))
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"stamp_start_ns": int(observations[0].stamp_ns),
|
||||||
|
"stamp_end_ns": int(observations[-1].stamp_ns),
|
||||||
|
"valid_frames": len(observations),
|
||||||
|
"relative_quaternion_xyzw": relative,
|
||||||
|
"maximum_spread_rad": spread,
|
||||||
|
"tag_quality": quality,
|
||||||
|
"state_u8_median": state_median,
|
||||||
|
"tag_translation_xyz_m": tag_translations,
|
||||||
|
"maximum_translation_spread_m": translation_spread,
|
||||||
|
"maximum_state_sync_error_ms": (
|
||||||
|
None
|
||||||
|
if not sync_errors
|
||||||
|
else float(max(sync_errors)) / 1_000_000.0
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
"""Visual roll-alignment aid for one G20 calibration camera."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
from apriltag_msgs.msg import AprilTagDetectionArray
|
||||||
|
import cv2
|
||||||
|
from cv_bridge import CvBridge
|
||||||
|
import numpy as np
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from rclpy.qos import qos_profile_sensor_data
|
||||||
|
from sensor_msgs.msg import Image
|
||||||
|
|
||||||
|
from .full_hand import VIEW_TAGS
|
||||||
|
from .hikrobot_camera import configure_fastdds_large_image_transport
|
||||||
|
from .zero_calibration import detect_reference_alignment_line
|
||||||
|
|
||||||
|
|
||||||
|
VIEWS = ("front", "side", "top")
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_alignment_measurements(
|
||||||
|
measurements: Sequence[Mapping[str, Any] | None],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Return a median-smoothed physical reference-line measurement."""
|
||||||
|
valid = [measurement for measurement in measurements if measurement]
|
||||||
|
if not valid:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"line_xyxy_px": np.median(
|
||||||
|
np.asarray(
|
||||||
|
[measurement["line_xyxy_px"] for measurement in valid],
|
||||||
|
dtype=float,
|
||||||
|
),
|
||||||
|
axis=0,
|
||||||
|
).tolist(),
|
||||||
|
"angle_rad": float(
|
||||||
|
np.median(
|
||||||
|
[float(measurement["angle_rad"]) for measurement in valid]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"vertical_offset_px": float(
|
||||||
|
np.median(
|
||||||
|
[
|
||||||
|
float(measurement["vertical_offset_px"])
|
||||||
|
for measurement in valid
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"detected_frames": len(valid),
|
||||||
|
"window_frames": len(measurements),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class G20CameraAlignmentView(Node):
|
||||||
|
"""Publish a red/blue roll aid based on a physical scene edge."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Configure one view without taking ownership of hand commands."""
|
||||||
|
super().__init__("g20_camera_alignment_view")
|
||||||
|
self.declare_parameter("view", "front")
|
||||||
|
view = str(self.get_parameter("view").value).strip().lower()
|
||||||
|
if view not in VIEWS:
|
||||||
|
raise ValueError(f"view must be one of {VIEWS}")
|
||||||
|
self.view = view
|
||||||
|
|
||||||
|
namespace = f"/g20_calibration/{view}"
|
||||||
|
self.required_tag_ids = {
|
||||||
|
int(value) for value in VIEW_TAGS[view].values()
|
||||||
|
}
|
||||||
|
self.declare_parameter("image_topic", f"{namespace}/camera/image_rect")
|
||||||
|
self.declare_parameter(
|
||||||
|
"detections_topic", f"{namespace}/apriltag/detections"
|
||||||
|
)
|
||||||
|
self.declare_parameter("reference_y_ratio", 0.90)
|
||||||
|
self.declare_parameter("roi_y_min_ratio", 0.55)
|
||||||
|
self.declare_parameter("roi_y_max_ratio", 0.98)
|
||||||
|
self.declare_parameter("minimum_line_length_ratio", 0.30)
|
||||||
|
self.declare_parameter("maximum_candidate_angle_deg", 15.0)
|
||||||
|
self.declare_parameter("maximum_alignment_error_deg", 0.5)
|
||||||
|
self.declare_parameter("maximum_vertical_offset_px", 12.0)
|
||||||
|
self.declare_parameter("maximum_hamming", 0)
|
||||||
|
self.declare_parameter("minimum_decision_margin", 20.0)
|
||||||
|
self.declare_parameter("minimum_edge_pixels", 20.0)
|
||||||
|
self.declare_parameter("smoothing_frames", 10)
|
||||||
|
self.declare_parameter("maximum_line_age_seconds", 1.0)
|
||||||
|
self.declare_parameter("maximum_tag_age_seconds", 1.0)
|
||||||
|
self.declare_parameter("maximum_publish_rate_hz", 10.0)
|
||||||
|
self.declare_parameter("output_scale", 0.75)
|
||||||
|
|
||||||
|
def value(name: str) -> Any:
|
||||||
|
return self.get_parameter(name).value
|
||||||
|
|
||||||
|
self.image_topic = str(value("image_topic"))
|
||||||
|
self.detections_topic = str(value("detections_topic"))
|
||||||
|
self.reference_y_ratio = float(value("reference_y_ratio"))
|
||||||
|
self.roi_y_min_ratio = float(value("roi_y_min_ratio"))
|
||||||
|
self.roi_y_max_ratio = float(value("roi_y_max_ratio"))
|
||||||
|
self.minimum_line_length_ratio = float(
|
||||||
|
value("minimum_line_length_ratio")
|
||||||
|
)
|
||||||
|
self.maximum_candidate_angle_rad = math.radians(
|
||||||
|
float(value("maximum_candidate_angle_deg"))
|
||||||
|
)
|
||||||
|
self.maximum_alignment_error_rad = math.radians(
|
||||||
|
float(value("maximum_alignment_error_deg"))
|
||||||
|
)
|
||||||
|
self.maximum_vertical_offset_px = float(
|
||||||
|
value("maximum_vertical_offset_px")
|
||||||
|
)
|
||||||
|
self.maximum_hamming = int(value("maximum_hamming"))
|
||||||
|
self.minimum_decision_margin = float(value("minimum_decision_margin"))
|
||||||
|
self.minimum_edge_pixels = float(value("minimum_edge_pixels"))
|
||||||
|
self.maximum_line_age_seconds = float(
|
||||||
|
value("maximum_line_age_seconds")
|
||||||
|
)
|
||||||
|
self.maximum_tag_age_seconds = float(value("maximum_tag_age_seconds"))
|
||||||
|
self.maximum_publish_rate_hz = float(value("maximum_publish_rate_hz"))
|
||||||
|
self.output_scale = float(value("output_scale"))
|
||||||
|
smoothing_frames = int(value("smoothing_frames"))
|
||||||
|
|
||||||
|
if not (
|
||||||
|
0.0
|
||||||
|
<= self.roi_y_min_ratio
|
||||||
|
< self.reference_y_ratio
|
||||||
|
< self.roi_y_max_ratio
|
||||||
|
<= 1.0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"ratios must satisfy 0 <= roi_min < reference < roi_max <= 1"
|
||||||
|
)
|
||||||
|
if not 0.0 < self.minimum_line_length_ratio <= 1.0:
|
||||||
|
raise ValueError("minimum_line_length_ratio must be in (0, 1]")
|
||||||
|
if not (
|
||||||
|
0.0
|
||||||
|
< self.maximum_alignment_error_rad
|
||||||
|
< self.maximum_candidate_angle_rad
|
||||||
|
< math.pi / 2.0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"angle limits must satisfy 0 < alignment < candidate < 90"
|
||||||
|
)
|
||||||
|
if self.maximum_vertical_offset_px <= 0.0:
|
||||||
|
raise ValueError("maximum_vertical_offset_px must be positive")
|
||||||
|
if smoothing_frames < 1:
|
||||||
|
raise ValueError("smoothing_frames must be positive")
|
||||||
|
if self.maximum_line_age_seconds <= 0.0:
|
||||||
|
raise ValueError("maximum_line_age_seconds must be positive")
|
||||||
|
if self.maximum_tag_age_seconds <= 0.0:
|
||||||
|
raise ValueError("maximum_tag_age_seconds must be positive")
|
||||||
|
if self.maximum_publish_rate_hz <= 0.0:
|
||||||
|
raise ValueError("maximum_publish_rate_hz must be positive")
|
||||||
|
if not 0.1 <= self.output_scale <= 1.0:
|
||||||
|
raise ValueError("output_scale must be in [0.1, 1.0]")
|
||||||
|
|
||||||
|
self.bridge = CvBridge()
|
||||||
|
self.line_history: deque[dict[str, Any] | None] = deque(
|
||||||
|
maxlen=smoothing_frames
|
||||||
|
)
|
||||||
|
self.last_line_at = 0.0
|
||||||
|
self.latest_tag_corners: dict[int, np.ndarray] = {}
|
||||||
|
self.latest_tag_at: dict[int, float] = {}
|
||||||
|
self.last_publish_at = 0.0
|
||||||
|
self.publisher = self.create_publisher(
|
||||||
|
Image, "~/image", qos_profile_sensor_data
|
||||||
|
)
|
||||||
|
self.create_subscription(
|
||||||
|
AprilTagDetectionArray,
|
||||||
|
self.detections_topic,
|
||||||
|
self._detections_callback,
|
||||||
|
qos_profile_sensor_data,
|
||||||
|
)
|
||||||
|
self.create_subscription(
|
||||||
|
Image,
|
||||||
|
self.image_topic,
|
||||||
|
self._image_callback,
|
||||||
|
qos_profile_sensor_data,
|
||||||
|
)
|
||||||
|
self.get_logger().info(
|
||||||
|
f"{view} alignment view uses physical long-edge detection; "
|
||||||
|
f"Tag orientation is ignored; input={self.image_topic}; "
|
||||||
|
f"output={self.get_name()}/image"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _detections_callback(self, message: AprilTagDetectionArray) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
for detection in message.detections:
|
||||||
|
tag_id = int(detection.id)
|
||||||
|
if tag_id not in self.required_tag_ids:
|
||||||
|
continue
|
||||||
|
corners = np.asarray(
|
||||||
|
[
|
||||||
|
[float(point.x), float(point.y)]
|
||||||
|
for point in detection.corners
|
||||||
|
],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
if corners.shape != (4, 2) or not np.all(np.isfinite(corners)):
|
||||||
|
continue
|
||||||
|
edges = np.linalg.norm(
|
||||||
|
corners - np.roll(corners, -1, axis=0), axis=1
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
int(detection.hamming) > self.maximum_hamming
|
||||||
|
or float(detection.decision_margin)
|
||||||
|
< self.minimum_decision_margin
|
||||||
|
or float(np.mean(edges)) < self.minimum_edge_pixels
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
self.latest_tag_corners[tag_id] = corners
|
||||||
|
self.latest_tag_at[tag_id] = now
|
||||||
|
|
||||||
|
def _draw_tags(self, image: np.ndarray, now: float) -> None:
|
||||||
|
for tag_id in sorted(self.required_tag_ids):
|
||||||
|
corners = self.latest_tag_corners.get(tag_id)
|
||||||
|
detected_at = self.latest_tag_at.get(tag_id, 0.0)
|
||||||
|
if (
|
||||||
|
corners is None
|
||||||
|
or now - detected_at > self.maximum_tag_age_seconds
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
points = np.rint(corners * self.output_scale).astype(np.int32)
|
||||||
|
cv2.polylines(image, [points], True, (0, 220, 0), 2)
|
||||||
|
centre = np.rint(np.mean(points, axis=0)).astype(int)
|
||||||
|
cv2.putText(
|
||||||
|
image,
|
||||||
|
f"ID {tag_id}",
|
||||||
|
(int(centre[0]) + 5, int(centre[1]) - 7),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.55,
|
||||||
|
(0, 220, 0),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _image_callback(self, message: Image) -> None:
|
||||||
|
# Avoid conversion and Hough work until an image viewer subscribes.
|
||||||
|
if self.publisher.get_subscription_count() < 1:
|
||||||
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self.last_publish_at < 1.0 / self.maximum_publish_rate_hz:
|
||||||
|
return
|
||||||
|
self.last_publish_at = now
|
||||||
|
try:
|
||||||
|
image = self.bridge.imgmsg_to_cv2(message, desired_encoding="bgr8")
|
||||||
|
except Exception as error:
|
||||||
|
self.get_logger().warning(
|
||||||
|
f"alignment image conversion failed: {error}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if self.output_scale != 1.0:
|
||||||
|
image = cv2.resize(
|
||||||
|
image,
|
||||||
|
None,
|
||||||
|
fx=self.output_scale,
|
||||||
|
fy=self.output_scale,
|
||||||
|
interpolation=cv2.INTER_AREA,
|
||||||
|
)
|
||||||
|
|
||||||
|
height, width = image.shape[:2]
|
||||||
|
reference_y = self.reference_y_ratio * float(height - 1)
|
||||||
|
detected = detect_reference_alignment_line(
|
||||||
|
image,
|
||||||
|
reference_y_px=reference_y,
|
||||||
|
roi_y_min_ratio=self.roi_y_min_ratio,
|
||||||
|
roi_y_max_ratio=self.roi_y_max_ratio,
|
||||||
|
minimum_length_ratio=self.minimum_line_length_ratio,
|
||||||
|
maximum_candidate_angle_rad=self.maximum_candidate_angle_rad,
|
||||||
|
)
|
||||||
|
self.line_history.append(detected)
|
||||||
|
if detected is not None:
|
||||||
|
self.last_line_at = now
|
||||||
|
measurement = summarize_alignment_measurements(self.line_history)
|
||||||
|
if now - self.last_line_at > self.maximum_line_age_seconds:
|
||||||
|
measurement = None
|
||||||
|
|
||||||
|
red_y = int(round(reference_y))
|
||||||
|
cv2.line(
|
||||||
|
image,
|
||||||
|
(15, red_y),
|
||||||
|
(max(15, width - 15), red_y),
|
||||||
|
(0, 0, 255),
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
if measurement is not None:
|
||||||
|
line = np.rint(measurement["line_xyxy_px"]).astype(int)
|
||||||
|
blue_ok, blue_start, blue_end = cv2.clipLine(
|
||||||
|
(0, 0, width, height),
|
||||||
|
(int(line[0]), int(line[1])),
|
||||||
|
(int(line[2]), int(line[3])),
|
||||||
|
)
|
||||||
|
if blue_ok:
|
||||||
|
cv2.line(image, blue_start, blue_end, (255, 0, 0), 3)
|
||||||
|
angle_rad = float(measurement["angle_rad"])
|
||||||
|
offset_px = float(measurement["vertical_offset_px"])
|
||||||
|
aligned = bool(
|
||||||
|
abs(angle_rad) <= self.maximum_alignment_error_rad
|
||||||
|
and abs(offset_px) <= self.maximum_vertical_offset_px
|
||||||
|
)
|
||||||
|
status = "ALIGNED" if aligned else "ADJUST CAMERA"
|
||||||
|
status_text = (
|
||||||
|
f"{self.view.upper()} red-blue "
|
||||||
|
f"{math.degrees(angle_rad):+.2f} deg "
|
||||||
|
f"dy {offset_px:+.1f}px {status}"
|
||||||
|
)
|
||||||
|
status_color = (0, 220, 0) if aligned else (0, 165, 255)
|
||||||
|
else:
|
||||||
|
status_text = (
|
||||||
|
f"{self.view.upper()} PHYSICAL REFERENCE LINE NOT DETECTED"
|
||||||
|
)
|
||||||
|
status_color = (0, 165, 255)
|
||||||
|
cv2.putText(
|
||||||
|
image,
|
||||||
|
status_text,
|
||||||
|
(20, 34),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.72,
|
||||||
|
status_color,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
self._draw_tags(image, now)
|
||||||
|
cv2.putText(
|
||||||
|
image,
|
||||||
|
"RED=target BLUE=physical edge GREEN=Tags (angle ignored)",
|
||||||
|
(20, max(64, height - 24)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
|
0.60,
|
||||||
|
(255, 255, 255),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
output = self.bridge.cv2_to_imgmsg(image, encoding="bgr8")
|
||||||
|
output.header = message.header
|
||||||
|
self.publisher.publish(output)
|
||||||
|
|
||||||
|
|
||||||
|
def main(args: list[str] | None = None) -> None:
|
||||||
|
"""Run the single-view alignment helper."""
|
||||||
|
configure_fastdds_large_image_transport()
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node: G20CameraAlignmentView | None = None
|
||||||
|
try:
|
||||||
|
node = G20CameraAlignmentView()
|
||||||
|
rclpy.spin(node)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if node is not None:
|
||||||
|
node.destroy_node()
|
||||||
|
if rclpy.ok():
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"""Map explicit SDK commands or feedback to corrected URDF joint coordinates.
|
||||||
|
|
||||||
|
Unified artifacts load their certified manifest and standard URDF. The old
|
||||||
|
readers below are used only when a historical payload is explicitly supplied.
|
||||||
|
|
||||||
|
The static encoder-zero corrections in ``zero_angles`` are already baked into
|
||||||
|
the corrected URDF joint origins. This bridge therefore publishes only the
|
||||||
|
dynamic ``angle_rad`` values and never adds the static offsets a second time.
|
||||||
|
|
||||||
|
Schema-v5 trajectories are fitted against timestamp-synchronised hardware
|
||||||
|
feedback, not controller set-points. They must therefore be queried with the
|
||||||
|
SDK ``hand_state`` topic. The retained schema-v4 path is command-indexed for
|
||||||
|
backwards compatibility only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from sensor_msgs.msg import JointState
|
||||||
|
|
||||||
|
G20_COMMAND_NAMES: tuple[str, ...] = (
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"index_mcp_pitch",
|
||||||
|
"middle_mcp_pitch",
|
||||||
|
"ring_mcp_pitch",
|
||||||
|
"pinky_mcp_pitch",
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"index_mcp_roll",
|
||||||
|
"middle_mcp_roll",
|
||||||
|
"ring_mcp_roll",
|
||||||
|
"pinky_mcp_roll",
|
||||||
|
"thumb_cmc_yaw",
|
||||||
|
"reserved_11",
|
||||||
|
"reserved_12",
|
||||||
|
"reserved_13",
|
||||||
|
"reserved_14",
|
||||||
|
"thumb_mcp",
|
||||||
|
"index_pip",
|
||||||
|
"middle_pip",
|
||||||
|
"ring_pip",
|
||||||
|
"pinky_pip",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Match the stable ordering used by the existing MuJoCo bridge. JointState
|
||||||
|
# consumers must use names, but retaining the ordering also keeps logs and
|
||||||
|
# direct comparisons deterministic.
|
||||||
|
G20_URDF_JOINT_NAMES: tuple[str, ...] = (
|
||||||
|
"index_dip",
|
||||||
|
"index_mcp_pitch",
|
||||||
|
"index_mcp_roll",
|
||||||
|
"index_pip",
|
||||||
|
"middle_dip",
|
||||||
|
"middle_mcp_pitch",
|
||||||
|
"middle_mcp_roll",
|
||||||
|
"middle_pip",
|
||||||
|
"pinky_dip",
|
||||||
|
"pinky_mcp_pitch",
|
||||||
|
"pinky_mcp_roll",
|
||||||
|
"pinky_pip",
|
||||||
|
"ring_dip",
|
||||||
|
"ring_mcp_pitch",
|
||||||
|
"ring_mcp_roll",
|
||||||
|
"ring_pip",
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"thumb_cmc_yaw",
|
||||||
|
"thumb_ip",
|
||||||
|
"thumb_mcp",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CalibratedCommandMapper:
|
||||||
|
"""Validated, profile-specific lookup from SDK u8 values to URDF radians."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, payload: Mapping[str, Any], *, expected_side: str | None = None
|
||||||
|
) -> None:
|
||||||
|
from .full_hand import get_hand_calibration_profile, infer_compact_payload_layout, validate_compact_payload
|
||||||
|
from .compat.legacy_diagnostic_tools.models import get_default_registry, validate_schema_v6_runtime_payload
|
||||||
|
from .compat.legacy_diagnostic_tools.models.o12.artifacts import validate_o12_runtime_payload
|
||||||
|
from .core import ProfileKey
|
||||||
|
schema_version = int(payload["schema_version"])
|
||||||
|
if schema_version == 7:
|
||||||
|
validate_o12_runtime_payload(payload)
|
||||||
|
elif schema_version == 6:
|
||||||
|
validate_schema_v6_runtime_payload(payload)
|
||||||
|
else:
|
||||||
|
validate_compact_payload(payload)
|
||||||
|
side = str(payload["side"]).lower()
|
||||||
|
if expected_side is not None and side != str(expected_side).lower():
|
||||||
|
raise ValueError(
|
||||||
|
f"calibration side {side!r} does not match requested side "
|
||||||
|
f"{str(expected_side).lower()!r}"
|
||||||
|
)
|
||||||
|
quality = payload["quality"]
|
||||||
|
if quality.get("passed") is not True:
|
||||||
|
raise ValueError("calibration quality.passed must be true")
|
||||||
|
layout_id = (
|
||||||
|
str(payload["layout_id"])
|
||||||
|
if schema_version in {6, 7}
|
||||||
|
else infer_compact_payload_layout(payload)
|
||||||
|
)
|
||||||
|
self.side = side
|
||||||
|
self.layout_id = layout_id
|
||||||
|
self.model = str(payload["model"]).upper()
|
||||||
|
self.profile_id = str(
|
||||||
|
payload.get("profile_id", f"G20/{side}/{layout_id}/v1")
|
||||||
|
)
|
||||||
|
self.serial_number = str(payload["serial_number"])
|
||||||
|
self.input_domain = str(
|
||||||
|
payload.get(
|
||||||
|
"curve_input_domain",
|
||||||
|
"command_u8" if schema_version == 4 else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if self.input_domain not in {"command_u8", "feedback_u8", "feedback_rad"}:
|
||||||
|
raise ValueError("calibration curve_input_domain is invalid")
|
||||||
|
if schema_version in {6, 7}:
|
||||||
|
self.command_names = tuple(str(value) for value in payload["command_names"])
|
||||||
|
self.urdf_joint_names = tuple(str(name) for name in payload["joints"])
|
||||||
|
self._motor_by_joint = {
|
||||||
|
name: int(payload["joints"][name]["motor_index"])
|
||||||
|
for name in self.urdf_joint_names
|
||||||
|
}
|
||||||
|
registered = get_default_registry().get(
|
||||||
|
ProfileKey.parse(self.profile_id)
|
||||||
|
)
|
||||||
|
self.feedback_name_aliases = dict(
|
||||||
|
registered.profile.command.feedback_name_aliases
|
||||||
|
)
|
||||||
|
self.feedback_by_index = bool(
|
||||||
|
registered.profile.command.feedback_by_index
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
profile = get_hand_calibration_profile(side, layout_id)
|
||||||
|
self.command_names = G20_COMMAND_NAMES
|
||||||
|
self.urdf_joint_names = G20_URDF_JOINT_NAMES
|
||||||
|
self._motor_by_joint = {
|
||||||
|
name: int(profile.joint_specs[name].motor_index)
|
||||||
|
for name in self.urdf_joint_names
|
||||||
|
}
|
||||||
|
self.feedback_name_aliases = {}
|
||||||
|
self.feedback_by_index = False
|
||||||
|
self._curves = {
|
||||||
|
name: tuple(
|
||||||
|
float(value)
|
||||||
|
for value in payload["joints"][name]["angle_rad"]
|
||||||
|
)
|
||||||
|
for name in self.urdf_joint_names
|
||||||
|
}
|
||||||
|
self._decreasing_curves = {
|
||||||
|
name: tuple(
|
||||||
|
float(value)
|
||||||
|
for value in payload["joints"][name].get(
|
||||||
|
"decreasing_rad", payload["joints"][name]["angle_rad"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name in self.urdf_joint_names
|
||||||
|
}
|
||||||
|
self._increasing_curves = {
|
||||||
|
name: tuple(
|
||||||
|
float(value)
|
||||||
|
for value in payload["joints"][name].get(
|
||||||
|
"increasing_rad", payload["joints"][name]["angle_rad"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name in self.urdf_joint_names
|
||||||
|
}
|
||||||
|
self._previous_by_motor: dict[int, float] = {}
|
||||||
|
self._direction_by_motor: dict[int, str] = {}
|
||||||
|
self.direction_deadband_u8 = 0.002 if schema_version == 7 else 0.5
|
||||||
|
self._knots = {
|
||||||
|
name: tuple(float(value) for value in payload["joints"][name].get(
|
||||||
|
"curve_input_knots_rad", ()
|
||||||
|
))
|
||||||
|
for name in self.urdf_joint_names
|
||||||
|
}
|
||||||
|
self._raw_increasing_branch = {
|
||||||
|
name: str(payload["joints"][name].get(
|
||||||
|
"raw_increasing_curve_branch", "increasing"
|
||||||
|
))
|
||||||
|
for name in self.urdf_joint_names
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _command_index(value: float) -> int:
|
||||||
|
command = float(value)
|
||||||
|
if not math.isfinite(command):
|
||||||
|
raise ValueError("calibrated command positions must be finite")
|
||||||
|
return max(0, min(255, int(math.floor(command + 0.5))))
|
||||||
|
|
||||||
|
def map_positions(
|
||||||
|
self, positions: Sequence[float], names: Sequence[str] = ()
|
||||||
|
) -> tuple[float, ...]:
|
||||||
|
values = tuple(float(value) for value in positions)
|
||||||
|
if names and not self.feedback_by_index:
|
||||||
|
if len(names) != len(values):
|
||||||
|
raise ValueError(
|
||||||
|
"JointState names and positions must have equal length"
|
||||||
|
)
|
||||||
|
if len(set(names)) != len(names):
|
||||||
|
raise ValueError("JointState names must be unique")
|
||||||
|
by_name = dict(zip((str(name) for name in names), values))
|
||||||
|
for alias, canonical in self.feedback_name_aliases.items():
|
||||||
|
if alias in by_name and canonical not in by_name:
|
||||||
|
by_name[canonical] = by_name[alias]
|
||||||
|
missing = [name for name in self.command_names if name not in by_name]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"{self.model} feedback is missing named channels: "
|
||||||
|
+ ",".join(missing)
|
||||||
|
)
|
||||||
|
command = tuple(by_name[name] for name in self.command_names)
|
||||||
|
else:
|
||||||
|
if len(values) != len(self.command_names):
|
||||||
|
raise ValueError(
|
||||||
|
f"unnamed {self.model} feedback must contain exactly "
|
||||||
|
f"{len(self.command_names)} positions"
|
||||||
|
)
|
||||||
|
command = values
|
||||||
|
indices = (
|
||||||
|
() if self.input_domain == "feedback_rad"
|
||||||
|
else tuple(self._command_index(value) for value in command)
|
||||||
|
)
|
||||||
|
direction_by_motor: dict[int, str | None] = {}
|
||||||
|
for motor, value in enumerate(command):
|
||||||
|
previous = self._previous_by_motor.get(motor)
|
||||||
|
direction = self._direction_by_motor.get(motor)
|
||||||
|
if previous is not None:
|
||||||
|
if value > previous + self.direction_deadband_u8:
|
||||||
|
direction = "increasing"
|
||||||
|
elif value < previous - self.direction_deadband_u8:
|
||||||
|
direction = "decreasing"
|
||||||
|
direction_by_motor[motor] = direction
|
||||||
|
result: list[float] = []
|
||||||
|
for name in self.urdf_joint_names:
|
||||||
|
motor = self._motor_by_joint[name]
|
||||||
|
direction = direction_by_motor[motor]
|
||||||
|
if self.input_domain == "feedback_rad" and direction is not None:
|
||||||
|
raw_increasing = self._raw_increasing_branch[name]
|
||||||
|
direction = (
|
||||||
|
raw_increasing
|
||||||
|
if direction == "increasing"
|
||||||
|
else "increasing" if raw_increasing == "decreasing" else "decreasing"
|
||||||
|
)
|
||||||
|
curves = (
|
||||||
|
self._increasing_curves
|
||||||
|
if direction == "increasing"
|
||||||
|
else self._decreasing_curves
|
||||||
|
if direction == "decreasing"
|
||||||
|
else self._curves
|
||||||
|
)
|
||||||
|
if self.input_domain == "feedback_rad":
|
||||||
|
result.append(float(np.interp(command[motor], self._knots[name], curves[name])))
|
||||||
|
else:
|
||||||
|
result.append(curves[name][indices[motor]])
|
||||||
|
for motor, value in enumerate(command):
|
||||||
|
self._previous_by_motor[motor] = value
|
||||||
|
direction = direction_by_motor[motor]
|
||||||
|
if direction is not None:
|
||||||
|
self._direction_by_motor[motor] = direction
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def load_calibrated_command_mapper(
|
||||||
|
calibration_file: str | Path, *, expected_side: str | None = None, input_kind="command"
|
||||||
|
) -> CalibratedCommandMapper:
|
||||||
|
path = Path(calibration_file).expanduser().resolve()
|
||||||
|
if not path.is_file():
|
||||||
|
raise ValueError(f"calibration JSON does not exist: {path}")
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if path.name == "release_manifest.json" or payload.get("format") in {"unified_calibration_v1", "unified_calibration_v2", "unified_calibration_v3"}:
|
||||||
|
from .runtime.artifacts.reader import load_unified_mapper
|
||||||
|
return load_unified_mapper(path, expected_side=expected_side, input_kind=input_kind)
|
||||||
|
return CalibratedCommandMapper(payload, expected_side=expected_side)
|
||||||
|
|
||||||
|
|
||||||
|
def default_input_topic(
|
||||||
|
hand_type: str, input_domain: str, model: str = "G20"
|
||||||
|
) -> str:
|
||||||
|
side = str(hand_type).lower()
|
||||||
|
if side not in {"left", "right"}:
|
||||||
|
raise ValueError("hand_type must be left or right")
|
||||||
|
if input_domain == "feedback_u8":
|
||||||
|
return f"/{str(model).lower()}/cb_{side}_hand_state"
|
||||||
|
if input_domain == "command_u8":
|
||||||
|
return f"/{str(model).lower()}/cb_{side}_hand_control_cmd"
|
||||||
|
if input_domain == "feedback_rad":
|
||||||
|
return f"/{str(model).lower()}/{side}/joint_states"
|
||||||
|
if input_domain == "command_rad":
|
||||||
|
return f"/{str(model).lower()}/{side}/joint_cmd"
|
||||||
|
raise ValueError("calibration curve_input_domain is invalid")
|
||||||
|
|
||||||
|
|
||||||
|
class CalibratedJointStateBridge(Node):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("calibrated_joint_state_bridge")
|
||||||
|
self.declare_parameter("hand_type", "right")
|
||||||
|
self.declare_parameter("calibration_file", "")
|
||||||
|
self.declare_parameter("input_topic", "")
|
||||||
|
self.declare_parameter("output_topic", "")
|
||||||
|
self.declare_parameter("input_kind", "command")
|
||||||
|
|
||||||
|
hand_type = str(self.get_parameter("hand_type").value).lower()
|
||||||
|
if hand_type not in {"left", "right"}:
|
||||||
|
raise ValueError("hand_type must be left or right")
|
||||||
|
calibration_file = str(self.get_parameter("calibration_file").value)
|
||||||
|
if not calibration_file:
|
||||||
|
raise ValueError("calibration_file is required")
|
||||||
|
self.mapper = load_calibrated_command_mapper(
|
||||||
|
calibration_file, expected_side=hand_type, input_kind=str(self.get_parameter("input_kind").value)
|
||||||
|
)
|
||||||
|
input_topic = str(self.get_parameter("input_topic").value).strip()
|
||||||
|
output_topic = str(self.get_parameter("output_topic").value).strip()
|
||||||
|
self.input_topic = input_topic or default_input_topic(
|
||||||
|
hand_type, self.mapper.input_domain, self.mapper.model
|
||||||
|
)
|
||||||
|
self.output_topic = (
|
||||||
|
output_topic
|
||||||
|
or f"/sim/mujoco/{self.mapper.model.lower()}/{hand_type}/joint_state"
|
||||||
|
)
|
||||||
|
if self.output_topic in {self.input_topic, default_input_topic(hand_type,
|
||||||
|
"feedback_rad" if self.mapper.input_domain.endswith("rad") else "feedback_u8", self.mapper.model)}:
|
||||||
|
raise ValueError("mapped output must not overwrite the real SDK command/feedback topic")
|
||||||
|
self.publisher = self.create_publisher(JointState, self.output_topic, 10)
|
||||||
|
self.subscription = self.create_subscription(
|
||||||
|
JointState, self.input_topic, self._command_callback, 10
|
||||||
|
)
|
||||||
|
self._last_error = ""
|
||||||
|
self.get_logger().info(
|
||||||
|
f"loaded {self.mapper.profile_id} calibration for "
|
||||||
|
f"{self.mapper.serial_number}: "
|
||||||
|
f"{self.input_topic} ({self.mapper.input_domain}) -> "
|
||||||
|
f"{self.output_topic}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _command_callback(self, command: JointState) -> None:
|
||||||
|
try:
|
||||||
|
positions = self.mapper.map_positions(command.position, command.name)
|
||||||
|
except ValueError as error:
|
||||||
|
message = str(error)
|
||||||
|
if message != self._last_error:
|
||||||
|
self.get_logger().error(message)
|
||||||
|
self._last_error = message
|
||||||
|
return
|
||||||
|
self._last_error = ""
|
||||||
|
result = JointState()
|
||||||
|
result.header = command.header
|
||||||
|
result.name = list(self.mapper.urdf_joint_names)
|
||||||
|
result.position = list(positions)
|
||||||
|
self.publisher.publish(result)
|
||||||
|
|
||||||
|
|
||||||
|
def main(args: Sequence[str] | None = None) -> None:
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node: CalibratedJointStateBridge | None = None
|
||||||
|
try:
|
||||||
|
node = CalibratedJointStateBridge()
|
||||||
|
rclpy.spin(node)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if node is not None:
|
||||||
|
node.destroy_node()
|
||||||
|
if rclpy.ok():
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""Map camera frame clocks to host time before pairing images with feedback.
|
||||||
|
|
||||||
|
The MVS frame counter timestamps exposure start. USB delivery/publication is
|
||||||
|
later. Clock latches bracket the device/host correspondence independently of
|
||||||
|
hand motion, images, or fitted calibration curves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
CAMERA_TIMING_POLICY = "mvs_latched_exposure_time_v1"
|
||||||
|
CLOCK_DRIFT_TOLERANCE_NS = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ClockLatch:
|
||||||
|
device_tick: int
|
||||||
|
before_ns: int
|
||||||
|
after_ns: int
|
||||||
|
raw_before_ns: int | None = None
|
||||||
|
raw_after_ns: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def midpoint_ns(self):
|
||||||
|
return (self.before_ns+self.after_ns)//2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def half_width_ns(self):
|
||||||
|
return (self.after_ns-self.before_ns)/2
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceFrameClock:
|
||||||
|
"""A nominal device clock with short, measured host-time brackets.
|
||||||
|
|
||||||
|
A new latch is checked against the previous one before it can change the
|
||||||
|
mapping. This rejects wrong counter units, resets and host clock jumps.
|
||||||
|
Periodic updates bound accumulated drift; none depend on image delivery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ticks_per_second):
|
||||||
|
if not math.isfinite(ticks_per_second) or ticks_per_second <= 0:
|
||||||
|
raise ValueError("invalid camera clock frequency")
|
||||||
|
self.ticks_per_second = ticks_per_second
|
||||||
|
self.anchor = None
|
||||||
|
self.last_frame_tick = None
|
||||||
|
self.last_stamp_ns = None
|
||||||
|
self.refresh_interval_seconds = .5
|
||||||
|
|
||||||
|
def elapsed_ns(self, ticks):
|
||||||
|
return round(ticks*1_000_000_000/self.ticks_per_second)
|
||||||
|
|
||||||
|
def update(self, latches):
|
||||||
|
if not latches or any(x.device_tick <= 0 or x.before_ns <= 0
|
||||||
|
or not 0 <= x.after_ns-x.before_ns <= 2_000_000 for x in latches):
|
||||||
|
raise ValueError("camera clock latch is invalid or too uncertain")
|
||||||
|
previous = self.anchor or latches[0]
|
||||||
|
if self.anchor is None and latches[-1].before_ns-latches[0].after_ns < 50_000_000:
|
||||||
|
raise ValueError("camera clock units need an independent elapsed-time check")
|
||||||
|
for current in latches:
|
||||||
|
elapsed = self.elapsed_ns(current.device_tick-previous.device_tick)
|
||||||
|
host_elapsed = current.midpoint_ns-previous.midpoint_ns
|
||||||
|
tolerance = current.half_width_ns+previous.half_width_ns+CLOCK_DRIFT_TOLERANCE_NS
|
||||||
|
if elapsed < 0 or abs(elapsed-host_elapsed) > tolerance:
|
||||||
|
raise ValueError("camera/host clock discontinuity or counter unit mismatch")
|
||||||
|
# Refresh before measured phase drift spends the existing 1 ms
|
||||||
|
# continuity allowance. A fixed 0.5 s period repeatedly invalidates a
|
||||||
|
# smooth slewing host clock. Scheduling changes; admission never does.
|
||||||
|
current = max(latches, key=lambda x: x.midpoint_ns)
|
||||||
|
host_elapsed = current.midpoint_ns-previous.midpoint_ns
|
||||||
|
if host_elapsed >= 50_000_000:
|
||||||
|
drift = abs(host_elapsed-self.elapsed_ns(current.device_tick-previous.device_tick))
|
||||||
|
rate = drift/host_elapsed
|
||||||
|
self.refresh_interval_seconds = max(.02, min(.5,
|
||||||
|
.25*CLOCK_DRIFT_TOLERANCE_NS/1e9/max(rate, 1e-12)))
|
||||||
|
self.anchor = min(latches, key=lambda x: x.half_width_ns)
|
||||||
|
|
||||||
|
def timestamp(self, tick, *, received_ns, exposure_us):
|
||||||
|
if (self.anchor is None or not 0 <= received_ns-self.anchor.midpoint_ns <= 2_000_000_000
|
||||||
|
or not math.isfinite(exposure_us) or exposure_us < 0):
|
||||||
|
raise ValueError("camera frame has no fresh clock or valid exposure")
|
||||||
|
if self.last_frame_tick is not None and tick <= self.last_frame_tick:
|
||||||
|
raise ValueError("camera frame counter reset or duplicate frame")
|
||||||
|
stamp = self.anchor.midpoint_ns+self.elapsed_ns(tick-self.anchor.device_tick)+round(exposure_us*500)
|
||||||
|
if stamp > received_ns+2_000_000 or (self.last_stamp_ns is not None and stamp <= self.last_stamp_ns):
|
||||||
|
raise ValueError("camera image time is future or out of order")
|
||||||
|
self.last_frame_tick, self.last_stamp_ns = tick, stamp
|
||||||
|
return stamp
|
||||||
|
|
||||||
|
|
||||||
|
class MvsCameraTiming:
|
||||||
|
"""The camera-specific clock latch and its optional append-only journal."""
|
||||||
|
|
||||||
|
def __init__(self, camera, mvs, clock_ns, *, exposure_us, journal_path="", raw_clock_ns=None):
|
||||||
|
self.camera, self.mvs, self.clock_ns = camera, mvs, clock_ns
|
||||||
|
# CLOCK_MONOTONIC_RAW is independent of wall-clock synchronization.
|
||||||
|
# It is diagnostic evidence only, never a replacement ROS timestamp.
|
||||||
|
self.raw_clock_ns = raw_clock_ns or (lambda: time.clock_gettime_ns(time.CLOCK_MONOTONIC_RAW))
|
||||||
|
self.exposure_us = exposure_us
|
||||||
|
self.frame_clock = None
|
||||||
|
self.last_stamp_ns = None
|
||||||
|
self.next_refresh = 0.
|
||||||
|
self.journal = None
|
||||||
|
if journal_path:
|
||||||
|
path = Path(journal_path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.journal = path.open("a", encoding="utf-8")
|
||||||
|
|
||||||
|
def _integer(self, name):
|
||||||
|
value = self.mvs.MVCC_INTVALUE_EX()
|
||||||
|
status = self.camera.MV_CC_GetIntValueEx(name, value)
|
||||||
|
if status != 0:
|
||||||
|
raise ValueError(f"camera clock cannot read {name}:0x{status:08x}")
|
||||||
|
return int(value.nCurValue)
|
||||||
|
|
||||||
|
def _latch(self):
|
||||||
|
raw_before = self.raw_clock_ns()
|
||||||
|
before = self.clock_ns()
|
||||||
|
status = self.camera.MV_CC_SetCommandValue("DeviceTimestampLatch")
|
||||||
|
if status != 0:
|
||||||
|
raise ValueError(f"camera clock latch failed:0x{status:08x}")
|
||||||
|
tick = self._integer("DeviceTimestamp")
|
||||||
|
after = self.clock_ns()
|
||||||
|
return ClockLatch(tick, before, after, raw_before, self.raw_clock_ns())
|
||||||
|
|
||||||
|
def _write(self, row):
|
||||||
|
if self.journal is not None:
|
||||||
|
self.journal.write(json.dumps(row, separators=(",", ":"))+"\n")
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
# The installed USB camera firmware returns ticks/second for this
|
||||||
|
# feature (despite its XML unit label). Verify that interpretation
|
||||||
|
# against elapsed host time; do not assume it for another firmware.
|
||||||
|
self.frame_clock = DeviceFrameClock(self._integer("DeviceTimestampIncrement"))
|
||||||
|
latches = []
|
||||||
|
for index in range(40):
|
||||||
|
if index:
|
||||||
|
time.sleep(.02)
|
||||||
|
latches.append(self._latch())
|
||||||
|
valid = [x for x in latches if 0 <= x.after_ns-x.before_ns <= 2_000_000]
|
||||||
|
if len(valid) >= 8 and valid[-1].before_ns-valid[0].after_ns >= 50_000_000:
|
||||||
|
self._update(latches)
|
||||||
|
return
|
||||||
|
raise ValueError("camera clock has no bounded-latency startup latches")
|
||||||
|
|
||||||
|
def _update(self, latches):
|
||||||
|
valid = [x for x in latches if 0 <= x.after_ns-x.before_ns <= 2_000_000]
|
||||||
|
if not valid:
|
||||||
|
# A delayed USB control transaction provides no new clock evidence.
|
||||||
|
# Keep the original two-second freshness bound while trying again.
|
||||||
|
self._write(dict(kind="camera_clock_latch_rejected", latches=[asdict(x) for x in latches]))
|
||||||
|
self.next_refresh = time.monotonic()+.02
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.frame_clock.update(valid)
|
||||||
|
except ValueError as error:
|
||||||
|
# Invalidation alone loses the transaction that failed. Preserve
|
||||||
|
# both clocks and the old anchor so drift, jumps and bad device
|
||||||
|
# counters can be distinguished without weakening the check.
|
||||||
|
self._write(dict(kind="camera_clock_sync_rejected", policy=CAMERA_TIMING_POLICY,
|
||||||
|
reason=str(error), ticks_per_second=self.frame_clock.ticks_per_second,
|
||||||
|
previous_anchor=asdict(self.frame_clock.anchor) if self.frame_clock.anchor else None,
|
||||||
|
latches=[asdict(x) for x in latches]))
|
||||||
|
if self.journal is not None:
|
||||||
|
self.journal.flush()
|
||||||
|
raise
|
||||||
|
self._write(dict(kind="camera_clock_sync", policy=CAMERA_TIMING_POLICY,
|
||||||
|
ticks_per_second=self.frame_clock.ticks_per_second,
|
||||||
|
refresh_interval_seconds=self.frame_clock.refresh_interval_seconds,
|
||||||
|
latches=[asdict(x) for x in latches], anchor=asdict(self.frame_clock.anchor)))
|
||||||
|
if self.journal is not None:
|
||||||
|
self.journal.flush()
|
||||||
|
# Startup may select a precise latch near the beginning of its window.
|
||||||
|
# That anchor has already spent part of the refresh budget.
|
||||||
|
anchor_age = (max(x.after_ns for x in latches)-self.frame_clock.anchor.midpoint_ns)/1e9
|
||||||
|
self.next_refresh = time.monotonic()+max(0., self.frame_clock.refresh_interval_seconds-anchor_age)
|
||||||
|
|
||||||
|
def refresh_if_due(self):
|
||||||
|
if self.frame_clock is None:
|
||||||
|
self.start()
|
||||||
|
elif time.monotonic() >= self.next_refresh:
|
||||||
|
self._update([self._latch() for _ in range(3)])
|
||||||
|
|
||||||
|
def invalidate(self, reason):
|
||||||
|
"""Stop using an uncertain mapping until a new startup check succeeds."""
|
||||||
|
self.frame_clock = None
|
||||||
|
self.next_refresh = 0.
|
||||||
|
self._write(dict(kind="camera_clock_invalidated", reason=str(reason),
|
||||||
|
last_stamp_ns=self.last_stamp_ns))
|
||||||
|
if self.journal is not None:
|
||||||
|
self.journal.flush()
|
||||||
|
|
||||||
|
def timestamp(self, info, received_ns):
|
||||||
|
if self.frame_clock is None:
|
||||||
|
raise ValueError("camera frame has no synchronized clock")
|
||||||
|
tick = (int(info.nDevTimeStampHigh)<<32)|int(info.nDevTimeStampLow)
|
||||||
|
# Auto-exposure preview uses the exact exposure-start timestamp when
|
||||||
|
# the SDK supplies no per-frame duration. Calibration fixes exposure.
|
||||||
|
measured_exposure = float(info.fExposureTime)
|
||||||
|
exposure = measured_exposure if measured_exposure > 0 else self.exposure_us
|
||||||
|
stamp = self.frame_clock.timestamp(tick, received_ns=received_ns, exposure_us=exposure)
|
||||||
|
if self.last_stamp_ns is not None and stamp <= self.last_stamp_ns:
|
||||||
|
raise ValueError("camera exposure time is not increasing after clock synchronization")
|
||||||
|
self.last_stamp_ns = stamp
|
||||||
|
self._write(dict(kind="camera_frame_time", frame_number=int(info.nFrameNum),
|
||||||
|
device_tick=tick, sdk_host_stamp_ms=int(info.nHostTimeStamp),
|
||||||
|
received_ns=received_ns, stamp_ns=stamp, exposure_us=exposure,
|
||||||
|
timestamp_reference="exposure_midpoint" if exposure else "exposure_start"))
|
||||||
|
return stamp
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.journal is not None:
|
||||||
|
self.journal.close()
|
||||||
|
self.journal = None
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Compatibility adapters for one-release calibration migrations."""
|
||||||
|
|
||||||
|
from .config_v1 import (
|
||||||
|
legacy_default_profile_key,
|
||||||
|
product_profile_key,
|
||||||
|
resolve_legacy_profile_alias,
|
||||||
|
)
|
||||||
|
from .defaults import (
|
||||||
|
default_product_config_path,
|
||||||
|
default_three_camera_config_path,
|
||||||
|
)
|
||||||
|
from .paths import resolve_renamed_package_path
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"default_product_config_path",
|
||||||
|
"default_three_camera_config_path",
|
||||||
|
"legacy_default_profile_key",
|
||||||
|
"product_profile_key",
|
||||||
|
"resolve_legacy_profile_alias",
|
||||||
|
"resolve_renamed_package_path",
|
||||||
|
]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Identity migration for deployed product configuration schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ..core import ProfileKey
|
||||||
|
|
||||||
|
|
||||||
|
def product_profile_key(raw: Mapping[str, Any]) -> ProfileKey:
|
||||||
|
version = int(raw.get("schema_version", -1))
|
||||||
|
if version in {2, 3}:
|
||||||
|
key = ProfileKey.parse(str(raw.get("profile_id", "")))
|
||||||
|
for field, actual in (
|
||||||
|
("model", key.model),
|
||||||
|
("side", key.side),
|
||||||
|
("tag_layout", key.layout),
|
||||||
|
):
|
||||||
|
configured = str(raw.get(field, "")).strip()
|
||||||
|
if configured and configured.lower() != actual.lower():
|
||||||
|
raise ValueError(f"{field} differs from profile_id")
|
||||||
|
return key
|
||||||
|
if version != 1:
|
||||||
|
raise ValueError("product config schema_version must be 1, 2 or 3")
|
||||||
|
model = str(raw.get("model", "")).strip().upper()
|
||||||
|
side = str(raw.get("side", "")).strip().lower()
|
||||||
|
layout = str(raw.get("tag_layout", "")).strip().lower()
|
||||||
|
if not layout and (model, side) == ("G20", "right"):
|
||||||
|
layout = "g20_right_19"
|
||||||
|
return ProfileKey(model, side, layout, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_default_profile_key() -> ProfileKey:
|
||||||
|
"""Preserve the former no-argument executable for one release."""
|
||||||
|
return ProfileKey("G20", "right", "g20_right_19", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_legacy_profile_alias(key: ProfileKey) -> ProfileKey:
|
||||||
|
"""Map retired layout identifiers to their reviewed physical profile."""
|
||||||
|
if (
|
||||||
|
key.model == "G20"
|
||||||
|
and key.side == "right"
|
||||||
|
and key.layout == "g20_right_15"
|
||||||
|
and key.revision == 1
|
||||||
|
):
|
||||||
|
return ProfileKey("G20", "right", "g20_right_19", 1)
|
||||||
|
return key
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""One-release default selection for invocations without ``--config``."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
|
||||||
|
|
||||||
|
def default_product_config_path() -> Path:
|
||||||
|
share = Path(get_package_share_directory("linkerhand_calibration"))
|
||||||
|
return share / "config/g20_right_product.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def default_three_camera_config_path() -> Path:
|
||||||
|
"""Resolve the installed calibration defaults through the ROS index."""
|
||||||
|
share = Path(get_package_share_directory("linkerhand_calibration"))
|
||||||
|
return share / "config/three_camera_calibration.yaml"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Legacy single-camera algorithms retained for one compatibility release."""
|
||||||
|
from .session_v1 import uses_coupled_full_hand_zero_solver
|
||||||
|
|
||||||
|
__all__ = ["uses_coupled_full_hand_zero_solver"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Version selection for replaying durable pre-v3 hardware sessions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def uses_coupled_full_hand_zero_solver(
|
||||||
|
session_start: Mapping[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""Return the solver contract recorded by the legacy session header.
|
||||||
|
|
||||||
|
Capabilities are not consulted by the live runtime. This adapter reads
|
||||||
|
the durable v1 header only so offline replay can reproduce an artifact
|
||||||
|
created before the independent thumb solver was introduced.
|
||||||
|
"""
|
||||||
|
capabilities = {
|
||||||
|
str(value) for value in session_start.get("capabilities", ())
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
int(session_start.get("sample_schema_version", 1)) == 1
|
||||||
|
and "palm_axis_side_channel_v2" in capabilities
|
||||||
|
and "palm_axis_relative_motion_v3" not in capabilities
|
||||||
|
)
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
"""Pure calibration math and command helpers.
|
||||||
|
|
||||||
|
This module deliberately has no ROS imports so the geometry, fitting, and
|
||||||
|
output schema can be tested without a camera or a connected hand.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import math
|
||||||
|
from typing import Any, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.spatial.transform import Rotation
|
||||||
|
|
||||||
|
|
||||||
|
COMMAND_NAMES: tuple[str, ...] = (
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"index_mcp_pitch",
|
||||||
|
"middle_mcp_pitch",
|
||||||
|
"ring_mcp_pitch",
|
||||||
|
"pinky_mcp_pitch",
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"index_mcp_roll",
|
||||||
|
"middle_mcp_roll",
|
||||||
|
"ring_mcp_roll",
|
||||||
|
"pinky_mcp_roll",
|
||||||
|
"thumb_cmc_yaw",
|
||||||
|
"reserved_11",
|
||||||
|
"reserved_12",
|
||||||
|
"reserved_13",
|
||||||
|
"reserved_14",
|
||||||
|
"thumb_mcp",
|
||||||
|
"index_pip",
|
||||||
|
"middle_pip",
|
||||||
|
"ring_pip",
|
||||||
|
"pinky_pip",
|
||||||
|
)
|
||||||
|
|
||||||
|
BASELINE_COMMAND: tuple[int, ...] = (
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
193,
|
||||||
|
148,
|
||||||
|
105,
|
||||||
|
42,
|
||||||
|
245,
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
)
|
||||||
|
|
||||||
|
PAIR_ROOT = "t0_t3"
|
||||||
|
PAIR_MCP = "t3_t4"
|
||||||
|
PAIR_IP = "t4_t5"
|
||||||
|
PAIR_NAMES: tuple[str, ...] = (PAIR_ROOT, PAIR_MCP, PAIR_IP)
|
||||||
|
|
||||||
|
DIRECTION_DECREASING = "decreasing"
|
||||||
|
DIRECTION_INCREASING = "increasing"
|
||||||
|
DIRECTIONS: tuple[str, ...] = (
|
||||||
|
DIRECTION_DECREASING,
|
||||||
|
DIRECTION_INCREASING,
|
||||||
|
)
|
||||||
|
|
||||||
|
PHASE_ROOT = "root"
|
||||||
|
PHASE_TIP = "tip"
|
||||||
|
|
||||||
|
JOINT_SPECS: dict[str, tuple[str, str, int]] = {
|
||||||
|
"thumb_cmc_pitch": (PHASE_ROOT, PAIR_ROOT, 0),
|
||||||
|
"thumb_mcp": (PHASE_TIP, PAIR_MCP, 15),
|
||||||
|
"thumb_ip": (PHASE_TIP, PAIR_IP, 15),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_command(
|
||||||
|
motor_index: int,
|
||||||
|
command_u8: int,
|
||||||
|
baseline: Sequence[int] = BASELINE_COMMAND,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Return one full G20 command with exactly one replaced motor slot."""
|
||||||
|
if len(baseline) != 20:
|
||||||
|
raise ValueError("baseline must contain exactly 20 values")
|
||||||
|
values = [int(value) for value in baseline]
|
||||||
|
if any(value < 0 or value > 255 for value in values):
|
||||||
|
raise ValueError("baseline values must be in [0, 255]")
|
||||||
|
if motor_index not in (0, 5, 15):
|
||||||
|
raise ValueError(
|
||||||
|
"front thumb calibration only permits motor 0, 5 or 15"
|
||||||
|
)
|
||||||
|
command_u8 = int(command_u8)
|
||||||
|
if command_u8 < 0 or command_u8 > 255:
|
||||||
|
raise ValueError("command_u8 must be in [0, 255]")
|
||||||
|
values[motor_index] = command_u8
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def scan_targets(
|
||||||
|
repetitions: int = 3,
|
||||||
|
command_step: int = 1,
|
||||||
|
) -> list[tuple[int, str, int]]:
|
||||||
|
"""Build repeated 255->0->255 scan targets on a bounded command grid."""
|
||||||
|
if repetitions < 1:
|
||||||
|
raise ValueError("repetitions must be positive")
|
||||||
|
if command_step < 1 or command_step > 255:
|
||||||
|
raise ValueError("command_step must be in [1, 255]")
|
||||||
|
increasing = list(range(0, 256, command_step))
|
||||||
|
if increasing[-1] != 255:
|
||||||
|
increasing.append(255)
|
||||||
|
decreasing = list(reversed(increasing))
|
||||||
|
targets: list[tuple[int, str, int]] = []
|
||||||
|
for cycle in range(repetitions):
|
||||||
|
targets.extend(
|
||||||
|
(cycle, DIRECTION_DECREASING, command)
|
||||||
|
for command in decreasing
|
||||||
|
)
|
||||||
|
targets.extend(
|
||||||
|
(cycle, DIRECTION_INCREASING, command)
|
||||||
|
for command in increasing
|
||||||
|
)
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_quaternion_xyzw(values: Sequence[float]) -> np.ndarray:
|
||||||
|
quaternion = np.asarray(values, dtype=float)
|
||||||
|
if quaternion.shape != (4,) or not np.all(np.isfinite(quaternion)):
|
||||||
|
raise ValueError("quaternion must contain four finite xyzw values")
|
||||||
|
norm = float(np.linalg.norm(quaternion))
|
||||||
|
if norm < 1e-12:
|
||||||
|
raise ValueError("quaternion norm is zero")
|
||||||
|
return quaternion / norm
|
||||||
|
|
||||||
|
|
||||||
|
def relative_quaternion_xyzw(
|
||||||
|
parent_camera_quaternion: Sequence[float],
|
||||||
|
child_camera_quaternion: Sequence[float],
|
||||||
|
) -> tuple[float, float, float, float]:
|
||||||
|
"""Compute parent->child orientation from two camera->tag rotations."""
|
||||||
|
parent = Rotation.from_quat(normalize_quaternion_xyzw(parent_camera_quaternion))
|
||||||
|
child = Rotation.from_quat(normalize_quaternion_xyzw(child_camera_quaternion))
|
||||||
|
quaternion = (parent.inv() * child).as_quat()
|
||||||
|
return tuple(float(value) for value in quaternion)
|
||||||
|
|
||||||
|
|
||||||
|
def image_plane_tag_quaternion_xyzw(
|
||||||
|
corners_xy: Sequence[Sequence[float]],
|
||||||
|
) -> tuple[float, float, float, float]:
|
||||||
|
"""Estimate tag orientation about the optical axis from ordered corners."""
|
||||||
|
corners = np.asarray(corners_xy, dtype=float)
|
||||||
|
if corners.shape != (4, 2) or not np.all(np.isfinite(corners)):
|
||||||
|
raise ValueError("corners_xy must contain four finite xy points")
|
||||||
|
# AprilTag corners 0->1 and 3->2 both follow the tag-local x axis.
|
||||||
|
# Average the two edges to reduce sub-pixel corner noise and perspective
|
||||||
|
# asymmetry. Image y points down, hence the minus sign for a right-handed
|
||||||
|
# camera-frame z rotation.
|
||||||
|
x_axis = (corners[1] - corners[0]) + (corners[2] - corners[3])
|
||||||
|
if float(np.linalg.norm(x_axis)) < 1e-9:
|
||||||
|
raise ValueError("tag x-axis is degenerate")
|
||||||
|
angle = -math.atan2(float(x_axis[1]), float(x_axis[0]))
|
||||||
|
quaternion = Rotation.from_rotvec([0.0, 0.0, angle]).as_quat()
|
||||||
|
return tuple(float(value) for value in quaternion)
|
||||||
|
|
||||||
|
|
||||||
|
def robust_rotation_summary(
|
||||||
|
quaternions_xyzw: Sequence[Sequence[float]],
|
||||||
|
) -> tuple[tuple[float, float, float, float], float]:
|
||||||
|
"""Return a robust orientation and maximum angular residual in radians."""
|
||||||
|
if not quaternions_xyzw:
|
||||||
|
raise ValueError("at least one quaternion is required")
|
||||||
|
rotations = Rotation.from_quat(
|
||||||
|
np.asarray(
|
||||||
|
[normalize_quaternion_xyzw(value) for value in quaternions_xyzw],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reference = rotations[0]
|
||||||
|
delta_vectors = (reference.inv() * rotations).as_rotvec()
|
||||||
|
median_delta = np.median(delta_vectors, axis=0)
|
||||||
|
robust = reference * Rotation.from_rotvec(median_delta)
|
||||||
|
residuals = (robust.inv() * rotations).magnitude()
|
||||||
|
maximum = float(np.max(residuals)) if residuals.size else 0.0
|
||||||
|
return (
|
||||||
|
tuple(float(value) for value in robust.as_quat()),
|
||||||
|
maximum,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rotation_spread_rad(
|
||||||
|
quaternions_xyzw: Sequence[Sequence[float]],
|
||||||
|
) -> float:
|
||||||
|
"""Return the maximum geodesic residual around a robust orientation."""
|
||||||
|
_, spread = robust_rotation_summary(quaternions_xyzw)
|
||||||
|
return spread
|
||||||
|
|
||||||
|
|
||||||
|
def rotation_rms_rad(
|
||||||
|
quaternions_xyzw: Sequence[Sequence[float]],
|
||||||
|
*,
|
||||||
|
outlier_threshold_rad: float | None = None,
|
||||||
|
) -> float:
|
||||||
|
"""Return RMS geodesic noise around a robust orientation."""
|
||||||
|
robust, _ = robust_rotation_summary(quaternions_xyzw)
|
||||||
|
reference = Rotation.from_quat(robust)
|
||||||
|
rotations = Rotation.from_quat(
|
||||||
|
np.asarray(
|
||||||
|
[normalize_quaternion_xyzw(value) for value in quaternions_xyzw],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
residuals = (reference.inv() * rotations).magnitude()
|
||||||
|
if outlier_threshold_rad is not None:
|
||||||
|
threshold = float(outlier_threshold_rad)
|
||||||
|
if threshold <= 0.0:
|
||||||
|
raise ValueError("outlier_threshold_rad must be positive")
|
||||||
|
residuals = residuals[residuals <= threshold]
|
||||||
|
if residuals.size == 0:
|
||||||
|
return float("inf")
|
||||||
|
return float(np.sqrt(np.mean(np.square(residuals))))
|
||||||
|
|
||||||
|
|
||||||
|
def rotation_inlier_fraction(
|
||||||
|
quaternions_xyzw: Sequence[Sequence[float]],
|
||||||
|
*,
|
||||||
|
outlier_threshold_rad: float,
|
||||||
|
) -> float:
|
||||||
|
"""Return the fraction close to the robust orientation."""
|
||||||
|
threshold = float(outlier_threshold_rad)
|
||||||
|
if threshold <= 0.0:
|
||||||
|
raise ValueError("outlier_threshold_rad must be positive")
|
||||||
|
robust, _ = robust_rotation_summary(quaternions_xyzw)
|
||||||
|
reference = Rotation.from_quat(robust)
|
||||||
|
rotations = Rotation.from_quat(
|
||||||
|
np.asarray(
|
||||||
|
[normalize_quaternion_xyzw(value) for value in quaternions_xyzw],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
residuals = (reference.inv() * rotations).magnitude()
|
||||||
|
return float(np.mean(residuals <= threshold))
|
||||||
|
|
||||||
|
|
||||||
|
def delta_rotation_vector(
|
||||||
|
reference_xyzw: Sequence[float],
|
||||||
|
observed_xyzw: Sequence[float],
|
||||||
|
) -> np.ndarray:
|
||||||
|
reference = Rotation.from_quat(normalize_quaternion_xyzw(reference_xyzw))
|
||||||
|
observed = Rotation.from_quat(normalize_quaternion_xyzw(observed_xyzw))
|
||||||
|
return (reference.inv() * observed).as_rotvec()
|
||||||
|
|
||||||
|
|
||||||
|
def fit_rotation_axis(
|
||||||
|
vectors: Sequence[Sequence[float]],
|
||||||
|
commands: Sequence[int],
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Fit and orient the single rotational axis used by one motor sweep."""
|
||||||
|
matrix = np.asarray(vectors, dtype=float)
|
||||||
|
command_values = np.asarray(commands, dtype=int)
|
||||||
|
if matrix.ndim != 2 or matrix.shape[1] != 3:
|
||||||
|
raise ValueError("vectors must have shape (N, 3)")
|
||||||
|
if command_values.shape != (matrix.shape[0],):
|
||||||
|
raise ValueError("commands must match vectors")
|
||||||
|
useful = np.linalg.norm(matrix, axis=1) > 1e-6
|
||||||
|
if int(np.count_nonzero(useful)) < 3:
|
||||||
|
raise ValueError("insufficient non-zero rotations to fit an axis")
|
||||||
|
_, _, vh = np.linalg.svd(matrix[useful], full_matrices=False)
|
||||||
|
axis = vh[0]
|
||||||
|
projections = matrix @ axis
|
||||||
|
low = projections[command_values <= 16]
|
||||||
|
high = projections[command_values >= 239]
|
||||||
|
if low.size and high.size and float(np.median(low)) < float(np.median(high)):
|
||||||
|
axis = -axis
|
||||||
|
return axis / np.linalg.norm(axis)
|
||||||
|
|
||||||
|
|
||||||
|
def isotonic_nonincreasing(values: Sequence[float]) -> np.ndarray:
|
||||||
|
"""Unweighted PAVA projection onto non-increasing values."""
|
||||||
|
original = np.asarray(values, dtype=float)
|
||||||
|
if original.ndim != 1 or not np.all(np.isfinite(original)):
|
||||||
|
raise ValueError("values must be a finite vector")
|
||||||
|
negated = -original
|
||||||
|
levels: list[float] = []
|
||||||
|
weights: list[int] = []
|
||||||
|
starts: list[int] = []
|
||||||
|
for index, value in enumerate(negated):
|
||||||
|
levels.append(float(value))
|
||||||
|
weights.append(1)
|
||||||
|
starts.append(index)
|
||||||
|
while len(levels) >= 2 and levels[-2] > levels[-1]:
|
||||||
|
total_weight = weights[-2] + weights[-1]
|
||||||
|
merged = (
|
||||||
|
levels[-2] * weights[-2] + levels[-1] * weights[-1]
|
||||||
|
) / total_weight
|
||||||
|
levels[-2:] = [merged]
|
||||||
|
weights[-2:] = [total_weight]
|
||||||
|
starts.pop()
|
||||||
|
projected = np.empty_like(original)
|
||||||
|
for block_index, (level, start) in enumerate(zip(levels, starts)):
|
||||||
|
end = starts[block_index + 1] if block_index + 1 < len(starts) else len(original)
|
||||||
|
projected[start:end] = -level
|
||||||
|
return projected
|
||||||
|
|
||||||
|
|
||||||
|
def _record_rotation(record: Mapping[str, Any], pair: str) -> tuple[float, ...]:
|
||||||
|
rotations = record.get("relative_quaternion_xyzw", {})
|
||||||
|
value = rotations.get(pair)
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(f"sample record is missing {pair}")
|
||||||
|
return tuple(float(component) for component in value)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FitResult:
|
||||||
|
joints: dict[str, dict[str, Any]]
|
||||||
|
axes: dict[str, tuple[float, float, float]]
|
||||||
|
references: dict[str, tuple[float, float, float, float]]
|
||||||
|
ip_coupling: dict[str, float]
|
||||||
|
max_monotonic_correction_rad: float
|
||||||
|
max_hysteresis_rad: float
|
||||||
|
measurement_mode: str = "rotation"
|
||||||
|
trajectory_models: dict[str, Any] = field(default_factory=dict)
|
||||||
|
trajectory_quality: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def measure_from_reference(
|
||||||
|
self,
|
||||||
|
joint_name: str,
|
||||||
|
observed_quaternion_xyzw: Sequence[float],
|
||||||
|
reference_quaternion_xyzw: Sequence[float] | None = None,
|
||||||
|
) -> float:
|
||||||
|
reference = (
|
||||||
|
reference_quaternion_xyzw
|
||||||
|
if reference_quaternion_xyzw is not None
|
||||||
|
else self.references[joint_name]
|
||||||
|
)
|
||||||
|
vector = delta_rotation_vector(reference, observed_quaternion_xyzw)
|
||||||
|
axis = np.asarray(self.axes[joint_name], dtype=float)
|
||||||
|
return float(vector @ axis)
|
||||||
|
|
||||||
|
|
||||||
|
def fit_calibration_curves(records: Iterable[Mapping[str, Any]]) -> FitResult:
|
||||||
|
"""Fit six complete 256-entry curves from dense or sparse scan records."""
|
||||||
|
samples = [
|
||||||
|
dict(record)
|
||||||
|
for record in records
|
||||||
|
if record.get("kind", "sample") == "sample"
|
||||||
|
]
|
||||||
|
if not samples:
|
||||||
|
raise ValueError("no scan records were provided")
|
||||||
|
|
||||||
|
joint_results: dict[str, dict[str, Any]] = {}
|
||||||
|
axes: dict[str, tuple[float, float, float]] = {}
|
||||||
|
references: dict[str, tuple[float, float, float, float]] = {}
|
||||||
|
maximum_correction = 0.0
|
||||||
|
maximum_hysteresis = 0.0
|
||||||
|
|
||||||
|
for joint_name, (phase, pair, motor_index) in JOINT_SPECS.items():
|
||||||
|
phase_records = [record for record in samples if record.get("phase") == phase]
|
||||||
|
if not phase_records:
|
||||||
|
raise ValueError(f"no records for phase {phase}")
|
||||||
|
|
||||||
|
cycle_references: dict[int, tuple[float, ...]] = {}
|
||||||
|
for record in phase_records:
|
||||||
|
if (
|
||||||
|
record.get("direction") == DIRECTION_DECREASING
|
||||||
|
and int(record.get("command_u8", -1)) == 255
|
||||||
|
):
|
||||||
|
cycle_references.setdefault(
|
||||||
|
int(record["cycle"]),
|
||||||
|
_record_rotation(record, pair),
|
||||||
|
)
|
||||||
|
cycles = sorted({int(record["cycle"]) for record in phase_records})
|
||||||
|
if any(cycle not in cycle_references for cycle in cycles):
|
||||||
|
raise ValueError(f"{joint_name} is missing a command-255 cycle reference")
|
||||||
|
|
||||||
|
vectors: list[np.ndarray] = []
|
||||||
|
commands: list[int] = []
|
||||||
|
indexed: list[tuple[Mapping[str, Any], np.ndarray]] = []
|
||||||
|
for record in phase_records:
|
||||||
|
cycle = int(record["cycle"])
|
||||||
|
vector = delta_rotation_vector(
|
||||||
|
cycle_references[cycle],
|
||||||
|
_record_rotation(record, pair),
|
||||||
|
)
|
||||||
|
vectors.append(vector)
|
||||||
|
commands.append(int(record["command_u8"]))
|
||||||
|
indexed.append((record, vector))
|
||||||
|
axis = fit_rotation_axis(vectors, commands)
|
||||||
|
axes[joint_name] = tuple(float(value) for value in axis)
|
||||||
|
references[joint_name] = robust_rotation_summary(
|
||||||
|
list(cycle_references.values())
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
branch_values: dict[str, list[list[float]]] = {
|
||||||
|
direction: [[] for _ in range(256)] for direction in DIRECTIONS
|
||||||
|
}
|
||||||
|
for record, vector in indexed:
|
||||||
|
direction = str(record["direction"])
|
||||||
|
command = int(record["command_u8"])
|
||||||
|
branch_values[direction][command].append(float(vector @ axis))
|
||||||
|
|
||||||
|
fitted_branches: dict[str, list[float]] = {}
|
||||||
|
for direction in DIRECTIONS:
|
||||||
|
sample_commands = np.asarray(
|
||||||
|
[
|
||||||
|
command
|
||||||
|
for command, values in enumerate(branch_values[direction])
|
||||||
|
if values
|
||||||
|
],
|
||||||
|
dtype=int,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
sample_commands.size < 3
|
||||||
|
or int(sample_commands[0]) != 0
|
||||||
|
or int(sample_commands[-1]) != 255
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"{joint_name}.{direction} requires at least three samples "
|
||||||
|
"including commands 0 and 255"
|
||||||
|
)
|
||||||
|
raw = np.asarray(
|
||||||
|
[
|
||||||
|
float(np.median(branch_values[direction][command]))
|
||||||
|
for command in sample_commands
|
||||||
|
],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
raw -= raw[-1]
|
||||||
|
projected_samples = isotonic_nonincreasing(raw)
|
||||||
|
projected_samples -= projected_samples[-1]
|
||||||
|
correction = float(np.max(np.abs(projected_samples - raw)))
|
||||||
|
maximum_correction = max(maximum_correction, correction)
|
||||||
|
projected = np.interp(
|
||||||
|
np.arange(256, dtype=float),
|
||||||
|
sample_commands.astype(float),
|
||||||
|
projected_samples,
|
||||||
|
)
|
||||||
|
projected -= projected[255]
|
||||||
|
fitted_branches[direction] = [
|
||||||
|
round(float(value), 8) for value in projected
|
||||||
|
]
|
||||||
|
|
||||||
|
hysteresis = float(
|
||||||
|
np.max(
|
||||||
|
np.abs(
|
||||||
|
np.asarray(fitted_branches[DIRECTION_DECREASING])
|
||||||
|
- np.asarray(fitted_branches[DIRECTION_INCREASING])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
maximum_hysteresis = max(maximum_hysteresis, hysteresis)
|
||||||
|
combined_curve = 0.5 * (
|
||||||
|
np.asarray(
|
||||||
|
fitted_branches[DIRECTION_DECREASING], dtype=float
|
||||||
|
)
|
||||||
|
+ np.asarray(
|
||||||
|
fitted_branches[DIRECTION_INCREASING], dtype=float
|
||||||
|
)
|
||||||
|
)
|
||||||
|
combined_curve -= combined_curve[255]
|
||||||
|
joint_result: dict[str, Any] = {
|
||||||
|
"motor_index": motor_index,
|
||||||
|
"angle_rad": [
|
||||||
|
round(float(value), 8) for value in combined_curve
|
||||||
|
],
|
||||||
|
"decreasing_rad": fitted_branches[DIRECTION_DECREASING],
|
||||||
|
"increasing_rad": fitted_branches[DIRECTION_INCREASING],
|
||||||
|
}
|
||||||
|
if joint_name == "thumb_ip":
|
||||||
|
joint_result["passive"] = True
|
||||||
|
joint_results[joint_name] = joint_result
|
||||||
|
|
||||||
|
mcp = joint_results["thumb_mcp"]
|
||||||
|
ip = joint_results["thumb_ip"]
|
||||||
|
x = np.asarray(mcp["angle_rad"], dtype=float)
|
||||||
|
y = np.asarray(ip["angle_rad"], dtype=float)
|
||||||
|
design = np.column_stack((x, np.ones_like(x)))
|
||||||
|
multiplier, offset = np.linalg.lstsq(design, y, rcond=None)[0]
|
||||||
|
predicted = multiplier * x + offset
|
||||||
|
residual_sum = float(np.sum((y - predicted) ** 2))
|
||||||
|
total_sum = float(np.sum((y - np.mean(y)) ** 2))
|
||||||
|
r_squared = 1.0 if total_sum < 1e-12 else 1.0 - residual_sum / total_sum
|
||||||
|
|
||||||
|
return FitResult(
|
||||||
|
joints=joint_results,
|
||||||
|
axes=axes,
|
||||||
|
references=references,
|
||||||
|
ip_coupling={
|
||||||
|
"multiplier": round(float(multiplier), 8),
|
||||||
|
"offset_rad": round(float(offset), 8),
|
||||||
|
"r_squared": round(float(r_squared), 8),
|
||||||
|
},
|
||||||
|
max_monotonic_correction_rad=maximum_correction,
|
||||||
|
max_hysteresis_rad=maximum_hysteresis,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_final_payload(
|
||||||
|
*,
|
||||||
|
serial_number: str,
|
||||||
|
fit: FitResult,
|
||||||
|
validation_errors_rad: Sequence[float],
|
||||||
|
passed: bool,
|
||||||
|
baseline: Sequence[int] = BASELINE_COMMAND,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
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)) if errors.size else float("nan")
|
||||||
|
runtime_joints: dict[str, dict[str, Any]] = {}
|
||||||
|
for joint_name, joint in fit.joints.items():
|
||||||
|
runtime_joint: dict[str, Any] = {
|
||||||
|
"motor_index": int(joint["motor_index"]),
|
||||||
|
"angle_rad": [
|
||||||
|
round(float(value), 8) for value in joint["angle_rad"]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if joint_name == "thumb_ip":
|
||||||
|
runtime_joint["passive"] = True
|
||||||
|
runtime_joints[joint_name] = runtime_joint
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"schema_version": 2,
|
||||||
|
"model": "G20",
|
||||||
|
"side": "left",
|
||||||
|
"serial_number": str(serial_number),
|
||||||
|
"angle_unit": "rad",
|
||||||
|
"command_range": [0, 255],
|
||||||
|
"zero_command_u8": 255,
|
||||||
|
"baseline_command_u8": [int(value) for value in baseline],
|
||||||
|
"joints": runtime_joints,
|
||||||
|
"ip_coupling": {
|
||||||
|
"multiplier": fit.ip_coupling["multiplier"],
|
||||||
|
"offset_rad": fit.ip_coupling["offset_rad"],
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"passed": bool(passed),
|
||||||
|
"validation_mae_rad": None if not np.isfinite(mae) else round(mae, 8),
|
||||||
|
"validation_p95_rad": None if not np.isfinite(p95) else round(p95, 8),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
validate_final_payload(payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def maximum_non_target_drift_rad(
|
||||||
|
records: Iterable[Mapping[str, Any]],
|
||||||
|
fit: FitResult,
|
||||||
|
) -> float:
|
||||||
|
"""Measure unintended active-joint motion during the two isolated scans."""
|
||||||
|
samples = [
|
||||||
|
dict(record)
|
||||||
|
for record in records
|
||||||
|
if record.get("kind", "sample") == "sample"
|
||||||
|
]
|
||||||
|
maximum = 0.0
|
||||||
|
checks = (
|
||||||
|
(PHASE_ROOT, "thumb_mcp", PAIR_MCP),
|
||||||
|
(PHASE_ROOT, "thumb_ip", PAIR_IP),
|
||||||
|
(PHASE_TIP, "thumb_cmc_pitch", PAIR_ROOT),
|
||||||
|
)
|
||||||
|
for phase, joint_name, pair in checks:
|
||||||
|
phase_records = [record for record in samples if record.get("phase") == phase]
|
||||||
|
for cycle in sorted({int(record["cycle"]) for record in phase_records}):
|
||||||
|
cycle_records = [
|
||||||
|
record for record in phase_records if int(record["cycle"]) == cycle
|
||||||
|
]
|
||||||
|
reference_record = next(
|
||||||
|
(
|
||||||
|
record
|
||||||
|
for record in cycle_records
|
||||||
|
if record.get("direction") == DIRECTION_DECREASING
|
||||||
|
and int(record.get("command_u8", -1)) == 255
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if reference_record is None:
|
||||||
|
continue
|
||||||
|
reference = _record_rotation(reference_record, pair)
|
||||||
|
axis = np.asarray(fit.axes[joint_name], dtype=float)
|
||||||
|
for record in cycle_records:
|
||||||
|
drift = abs(
|
||||||
|
float(
|
||||||
|
delta_rotation_vector(
|
||||||
|
reference,
|
||||||
|
_record_rotation(record, pair),
|
||||||
|
)
|
||||||
|
@ axis
|
||||||
|
)
|
||||||
|
)
|
||||||
|
maximum = max(maximum, drift)
|
||||||
|
return maximum
|
||||||
|
|
||||||
|
|
||||||
|
def validate_final_payload(payload: Mapping[str, Any]) -> None:
|
||||||
|
"""Validate the deliberately small runtime JSON schema."""
|
||||||
|
if payload.get("schema_version") != 2:
|
||||||
|
raise ValueError("schema_version must be 2")
|
||||||
|
if payload.get("model") != "G20" or payload.get("side") != "left":
|
||||||
|
raise ValueError("payload must describe a left G20")
|
||||||
|
if payload.get("angle_unit") != "rad":
|
||||||
|
raise ValueError("angle_unit must be rad")
|
||||||
|
baseline = payload.get("baseline_command_u8")
|
||||||
|
if not isinstance(baseline, list) or len(baseline) != 20:
|
||||||
|
raise ValueError("baseline_command_u8 must contain 20 values")
|
||||||
|
joints = payload.get("joints")
|
||||||
|
if not isinstance(joints, Mapping) or set(joints) != set(JOINT_SPECS):
|
||||||
|
raise ValueError("payload must contain exactly the three thumb joints")
|
||||||
|
for joint_name, joint in joints.items():
|
||||||
|
expected_motor = JOINT_SPECS[joint_name][2]
|
||||||
|
if int(joint.get("motor_index", -1)) != expected_motor:
|
||||||
|
raise ValueError(f"{joint_name} has the wrong motor index")
|
||||||
|
curve = joint.get("angle_rad")
|
||||||
|
if not isinstance(curve, list) or len(curve) != 256:
|
||||||
|
raise ValueError(
|
||||||
|
f"{joint_name}.angle_rad must contain 256 values"
|
||||||
|
)
|
||||||
|
values = np.asarray(curve, dtype=float)
|
||||||
|
if not np.all(np.isfinite(values)):
|
||||||
|
raise ValueError(
|
||||||
|
f"{joint_name}.angle_rad contains non-finite values"
|
||||||
|
)
|
||||||
|
if np.any(np.diff(values) > 1e-7):
|
||||||
|
raise ValueError(
|
||||||
|
f"{joint_name}.angle_rad must be non-increasing"
|
||||||
|
)
|
||||||
|
if abs(float(values[255])) > 1e-6:
|
||||||
|
raise ValueError(
|
||||||
|
f"{joint_name}.angle_rad[255] must be zero"
|
||||||
|
)
|
||||||
|
if joints["thumb_ip"].get("passive") is not True:
|
||||||
|
raise ValueError("thumb_ip must be marked passive")
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# 历史兼容区
|
||||||
|
|
||||||
|
此目录保留旧格式读写、旧布局、历史数据诊断及必要的离线工具,不参与正式在线调度。
|
||||||
|
四型号的 runner/node/pipeline 已由 `runtime/runner.py`、`runtime/session.py` 和
|
||||||
|
`runtime/artifacts/finalization.py` 替代。只保留仍被历史工具调用的入口;
|
||||||
|
L6/O6 无调用的 pipeline 包装已删除。
|
||||||
|
|
||||||
|
旧独立断点实现、低速预检和在线节点已移除。旧发布函数已拒绝更新正式发布指针。
|
||||||
|
这里生成的离线诊断文件不能作为标准 URDF 已验收的证据;正式回放使用
|
||||||
|
`calibrate_hand --config <product.yaml> --offline-raw <raw_samples.jsonl>`。
|
||||||
|
|
||||||
|
不要在此目录增加新型号。新型号提供 Profile、产品 YAML、原始 CAD/mesh;仅新 SDK 协议增加 Adapter。
|
||||||
|
需要恢复已删除的历史实现时,使用工作区
|
||||||
|
`calibration_output/refactor_backup.4WRWNn/` 中的归档,不要重新接入生产入口。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user