Compare commits
26 Commits
bd
...
f94cf2c500
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+15
-1
@@ -50,6 +50,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Runtime and calibration scratch files
|
# Runtime and calibration scratch files
|
||||||
/logs/
|
/logs/
|
||||||
|
/MvSdkLog/
|
||||||
*.tmp
|
*.tmp
|
||||||
*.log
|
*.log
|
||||||
*.bak
|
*.bak
|
||||||
@@ -61,13 +62,24 @@ Thumbs.db
|
|||||||
# src/linkerhand_retarget/resource/linkerforce_v2/profiles/.
|
# src/linkerhand_retarget/resource/linkerforce_v2/profiles/.
|
||||||
/profiles/
|
/profiles/
|
||||||
/calibration_output/
|
/calibration_output/
|
||||||
|
/config/*_three_camera_extrinsics.yaml
|
||||||
*.wear_check.json
|
*.wear_check.json
|
||||||
*.checkpoint.json
|
*.checkpoint.json
|
||||||
*.verification.json
|
*.verification.json
|
||||||
*_mapping_quality.json
|
*_mapping_quality.json
|
||||||
|
|
||||||
# Device-specific robot descriptions derived from local CMC pitch calibration
|
# 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
|
||||||
/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_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_*/
|
||||||
@@ -84,3 +96,5 @@ candump-*
|
|||||||
# Local Codex/agent workspace metadata
|
# Local Codex/agent workspace metadata
|
||||||
/.agents/
|
/.agents/
|
||||||
/.codex/
|
/.codex/
|
||||||
|
/.codebuddy/
|
||||||
|
/.zcode/
|
||||||
|
|||||||
Submodule
+1
Submodule src/agillink_omnihand_sdk added at 026740d9fd
@@ -1,398 +0,0 @@
|
|||||||
# G20 左手拇指正面 AprilTag 标定
|
|
||||||
|
|
||||||
该包启动 RealSense、彩色图像校正、`apriltag_ros`、Linker Hand SDK 和标定状态机,
|
|
||||||
只扫描 G20 左手命令下标 `0`、`15`。默认使用单终点连续模式:每个方向只发送一次
|
|
||||||
终点命令,速度保持在固件能稳定响应的 `15`。SDK 以独立时间戳反馈实际 20 维位置,
|
|
||||||
程序把每帧 AprilTag 三维中心与同一时刻的实际电机位置插值配对并按整数位置分箱。
|
|
||||||
完成 `255→0→255` 后分别拟合正反方向并检查回差,最终运行时 JSON 将两条曲线逐点
|
|
||||||
平均,只为每个关节保存一个 256 项 `angle_rad`。最后用 5 个随机静态命令复测精度。
|
|
||||||
|
|
||||||
当前默认使用 `trajectory_center_3d`。节点由四个亚像素角点和 `CameraInfo.P`
|
|
||||||
计算每张 Tag 的三维中心,但不把小尺寸平面 Tag 的 PnP 朝向直接当作关节角:
|
|
||||||
|
|
||||||
- 根部扫描先减去掌心 T0 的位置,再用 T3/T4/T5 三条圆轨迹共同拟合 CMC 旋转轴;
|
|
||||||
每帧三个角度取中位数。
|
|
||||||
- 尖部扫描用 T4 相对 T3 的圆轨迹直接拟合 MCP。G20 只有电机 15 这一个尖部输入,
|
|
||||||
URDF 将被动 IP 定义为 `thumb_ip = 1.02 × thumb_mcp`,因此运行时 IP 曲线严格按
|
|
||||||
这个机械耦合生成。这样不会把不同相机角度下 T5 的平面 PnP 深度偏差误认为 IP
|
|
||||||
真实运动。
|
|
||||||
- 程序仍会按 MCP 角将 T5 反向旋转并拟合剩余小圆,但该结果只用于
|
|
||||||
`trajectory_center_quality.tip` 中的观测一致性诊断,不参与最终 IP 数组。
|
|
||||||
- 每条曲线都减去命令 255 的测量角,所以最终文件严格满足
|
|
||||||
`angle_rad[255] == 0.0`;`angle_rad[0]` 是该关节相对零位的最大角度。
|
|
||||||
|
|
||||||
这种方法对固定的相机摆放角度、Tag 在同一刚性连杆上的固定位置和贴纸朝向更不敏感。
|
|
||||||
但相机或贴纸在一次扫描过程中移动、Tag 翘起、角点严重抖动仍会破坏圆轨迹。程序会
|
|
||||||
检查平面残差、圆残差、轨迹半径、实际弧长和根部三个轨迹点的角度一致性。
|
|
||||||
|
|
||||||
PnP 双分支跟踪和整段刚体复核仍保留,用于选出稳定的三维中心及辅助质量检查,不再
|
|
||||||
直接生成运行时角度。根部扫描用固定的 T3–T4、T4–T5 中心间距共同选择分支;
|
|
||||||
尖部扫描用固定的 T0–T3 中心间距约束非目标部分。中心间距漂移超过阈值仍会暂停,
|
|
||||||
避免错误中心进入圆拟合,但 Tag 的 PnP 朝向抖动不会触发该门限。
|
|
||||||
|
|
||||||
## 1. 标记和安全检查
|
|
||||||
|
|
||||||
- `T0` 必须保留并固定在掌壳,作为整体平移参考;`T3` 固定在拇指根部运动连杆,`T4` 固定在 MCP 后的连杆,
|
|
||||||
`T5` 固定在最末节。四张 Tag 必须与所在刚性件完全固定,不能跨关节或贴在软胶上。
|
|
||||||
- 当前实物使用 `tag36h11` 的 ID `0/1/2/3`,依次对应 T0/T3/T4/T5。如果实物 ID 改变,同时修改
|
|
||||||
`config/front_tags.yaml` 里检测节点和标定节点的两组数组。
|
|
||||||
- `tag.sizes`/`tag_sizes_m` 必须填写每张 Tag 的实测有效边长(米),当前配置为 `0.010`。
|
|
||||||
测量检测角点所围成的正方形边长,不包含外围白色留边。
|
|
||||||
- 当前试标定允许四张 Tag 的有效边长至少 30 px(实测静态约 32~38 px),最终仍由
|
|
||||||
静止角度 RMS 和随机复测误差决定是否合格。四张 Tag 必须在全行程内均可见。需要短时检查标记时,
|
|
||||||
启动参数增加 `publish_debug_image:=true`,再订阅
|
|
||||||
`/g20_thumb_calibration/debug_image`;正式长时间扫描建议保持默认关闭。
|
|
||||||
- 执行全行程前清空拇指周围空间并准备断开电机电源。确认这只手的下标 0 和 15
|
|
||||||
均可安全走完整 `255→0→255`。标定节点发现命令话题上另有发布者时不会解锁扫描。
|
|
||||||
|
|
||||||
## 2. 安装与构建
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y \
|
|
||||||
ros-jazzy-realsense2-camera \
|
|
||||||
ros-jazzy-realsense2-description \
|
|
||||||
ros-jazzy-image-pipeline \
|
|
||||||
ros-jazzy-apriltag-ros \
|
|
||||||
ros-jazzy-apriltag-msgs
|
|
||||||
|
|
||||||
cd /home/lxp/projects/linkerhand_retarget_ros2
|
|
||||||
source /opt/ros/jazzy/setup.bash
|
|
||||||
colcon build --symlink-install \
|
|
||||||
--packages-select linker_hand_ros2_sdk g20_thumb_apriltag_calibration
|
|
||||||
source install/setup.bash
|
|
||||||
```
|
|
||||||
|
|
||||||
连接 CAN 后先确认 `can0` 已启动。不要同时运行其他会发布
|
|
||||||
`/g20/cb_left_hand_control_cmd` 的程序。
|
|
||||||
|
|
||||||
## 3. 启动和操作
|
|
||||||
|
|
||||||
首次使用时可先用 `commands_enabled:=false` 做预检;SDK 仍会设置速度/扭矩并读取状态,
|
|
||||||
但标定节点不会发送位置运动命令,也不会允许解锁全行程扫描:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
|
|
||||||
serial_number:=G20_LEFT_001 \
|
|
||||||
commands_enabled:=false
|
|
||||||
```
|
|
||||||
|
|
||||||
确认 T0、T3、T4、T5 在根部和尖部全行程中不会被遮挡,且拇指运动不会碰撞后,
|
|
||||||
停止预检并启动一个新的正式会话。默认使用 AprilTag 内部 `decimate=1.5` 提升检测
|
|
||||||
速度,并使用单终点连续运动:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
|
|
||||||
serial_number:=G20_LEFT_001 \
|
|
||||||
can_interface:=can0 \
|
|
||||||
calibration_speed:=15 \
|
|
||||||
continuous_motion_mode:=endpoint \
|
|
||||||
angle_estimation_mode:=trajectory_center_3d \
|
|
||||||
apriltag_decimate:=1.5 \
|
|
||||||
use_roi:=false
|
|
||||||
```
|
|
||||||
|
|
||||||
默认关闭 ROI,AprilTag 使用完整的 1280×720 校正画面。查看实际送入 AprilTag
|
|
||||||
的完整画面:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 run image_view image_view --ros-args \
|
|
||||||
--remap image:=/camera/camera/color/image_rect
|
|
||||||
```
|
|
||||||
|
|
||||||
图像检测链路使用 `sensor_data`(BEST_EFFORT)QoS,只保留最新帧,避免完整分辨率
|
|
||||||
下可靠队列积压反压相机;这不会裁剪图像,也不会降低相机分辨率。
|
|
||||||
|
|
||||||
若以后需要以帧率优先,可传入 `use_roi:=true`;默认 ROI 是原图中的
|
|
||||||
`x=128, y=192, width=1024, height=528`,也可用 `roi_x`、`roi_y`、
|
|
||||||
`roi_width`、`roi_height` 覆盖。
|
|
||||||
|
|
||||||
监控状态:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 topic echo /g20_thumb_calibration/status
|
|
||||||
```
|
|
||||||
|
|
||||||
预检通过后状态为 `WAIT_ROOT_CONFIRM`,`reason` 为 `call_start`。只需调用一次:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 service call /g20_thumb_calibration/start std_srvs/srv/Trigger {}
|
|
||||||
```
|
|
||||||
|
|
||||||
节点随后自动完成下标 0 的 `255→0→255`、下标 15 的 `255→0→255` 和 5 点随机复测,
|
|
||||||
正常结束状态为 `COMPLETE`,无需在根部和尖部之间再次确认。为安全起见,调用 `start`
|
|
||||||
前必须一次性确认两个关节的完整行程都已清空。原来的
|
|
||||||
`confirm_root_full_range`、`confirm_tip_full_range` 服务仍保留用于兼容。
|
|
||||||
|
|
||||||
暂停、恢复和终止:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 service call /g20_thumb_calibration/pause std_srvs/srv/Trigger {}
|
|
||||||
ros2 service call /g20_thumb_calibration/resume std_srvs/srv/Trigger {}
|
|
||||||
ros2 service call /g20_thumb_calibration/abort std_srvs/srv/Trigger {}
|
|
||||||
```
|
|
||||||
|
|
||||||
预检要求四 Tag 有效帧率至少 95%,且检测消息频率至少 15 Hz。PnP 有效率也必须
|
|
||||||
至少 95%,每个候选解的重投影 RMS 不超过 1.5 px。中心轨迹模式以三组相对中心
|
|
||||||
的静止 RMS 不超过 2 mm、5 mm 范围内位置内点不少于 90% 为硬判据;PnP 朝向抖动
|
|
||||||
只作为诊断,不会阻止静态捕获。
|
|
||||||
状态中的
|
|
||||||
`pnp_rejections` 会指出当前是哪张 Tag 因丢失、重投影/倾角超限或姿态跳变而被拒绝,
|
|
||||||
`pnp_reprojection_error_px` 显示四张 Tag 最近一次有效解的误差。连续扫描要求
|
|
||||||
图像与状态的时间差不超过 150 ms、全行程至少得到 40 个有效帧、
|
|
||||||
至少覆盖 32 个整数位置且相邻实测位置间隔不超过 16。Tag 或同步状态持续丢失 3 秒、
|
|
||||||
90 秒内未到达终点,或覆盖不足时,节点保持当前命令并进入 `PAUSED`。恢复时会先回到
|
|
||||||
该方向的起点,再完整重扫这个方向,避免把半程数据混入结果。`abort` 也只停止队列,
|
|
||||||
不会主动移动机械手。正常扫描和随机复测最后一项均为命令 255。
|
|
||||||
|
|
||||||
PnP 跟踪在整个会话中对四张 Tag 都优先保持同一个 IPPE 平面分支;最多 5 秒的短暂检测
|
|
||||||
间隔不会重新初始化分支。随机复测只有在同步电机反馈与目标相差不超过 2、且稳定
|
|
||||||
窗口与捕获窗口内三个相对中心的最大偏差都不超过 3 mm 时才会写入,否则继续等待并最终暂停,
|
|
||||||
不会再生成明知不可靠但字段完整的结果。
|
|
||||||
|
|
||||||
单终点连续模式共有 4 个端到端命令:根部和尖部各一个往返。每个方向运动前会先用
|
|
||||||
实际电机反馈确认已经到达起点,再做一次短暂静态确认;随机验证的“接近位置”只等待
|
|
||||||
电机反馈到位,不再重复采图。若实际 AprilTag 检测仍低于 15 Hz,先优化检测链路,
|
|
||||||
不要降低到固件低速区。必须临时回退时可启动
|
|
||||||
`continuous_motion_mode:=paced`,该模式按步长 8 到位即发下一段。
|
|
||||||
|
|
||||||
连续扫描中的主要状态字段:
|
|
||||||
|
|
||||||
- `state_zh`/`reason_zh`/`action_zh`:当前阶段、失败原因和下一步操作的中文说明;
|
|
||||||
原有 `state`/`reason` 英文机器码继续保留。
|
|
||||||
- `tag_quality`:逐张显示 T0/T3/T4/T5 的边长、hamming、识别置信度、重投影误差、
|
|
||||||
是否有效和具体中文问题,不再需要手工解析 `/apriltag/detections`。
|
|
||||||
- `/g20_thumb_calibration/status_text`:适合终端直接查看的多行中文状态。使用
|
|
||||||
`ros2 topic echo --once /g20_thumb_calibration/status_text --field data`
|
|
||||||
即可看到原因、建议及四张标签的质量。
|
|
||||||
- `scan_progress`:4 个方向的完成比例,依次约为 0、0.25、0.5、0.75、1.0。
|
|
||||||
- `sweep_valid_frames_seen`:当前连续方向已收到的同步有效帧数。
|
|
||||||
- `sweep_state_span_u8`:当前方向实际覆盖的电机范围,接近 255 才算完整。
|
|
||||||
- `active_phase`/`active_direction`:当前是根部或尖部、下降或上升方向。
|
|
||||||
- `pnp_branch_corrections`:四张 Tag 联合跟踪为维持相邻关节姿态连续,而没有选择
|
|
||||||
单张 Tag 最小重投影分支的累计次数。
|
|
||||||
- `pnp_trajectory_quality`:最近一个完整方向的整段分支修正帧数,以及相对整段稳健
|
|
||||||
参考的旋转、相对平移和中心间距漂移。中心轨迹模式只按欧氏中心间距判断:
|
|
||||||
P95 超过 3 mm 或单帧最大值超过 6 mm 时暂停;旋转及随 Tag 坐标轴表达的相对平移
|
|
||||||
只保留为诊断。
|
|
||||||
- `trajectory_center_quality`:四个方向完成并拟合后,显示三维平面/圆残差、拟合半径、
|
|
||||||
实际弧长、T0/T3 锚点漂移和根部三个轨迹点的角度一致性。其中
|
|
||||||
`tip.ip_observed_vs_constrained_*` 显示T5残余小圆与URDF被动耦合之间的差异;
|
|
||||||
它用于发现T5识别误差、标签松动或机构异常,但不会改变最终IP曲线。
|
|
||||||
|
|
||||||
根部扫描中 T3/T4/T5 作为完整刚性组共同选择 IPPE 分支,不再把 T3 固定为在线解;
|
|
||||||
尖部扫描仍固定 T3,只用静止的 T0/T3 约束修正非目标根部姿态。
|
|
||||||
|
|
||||||
## 4. 中断恢复和输出
|
|
||||||
|
|
||||||
默认会话目录是启动命令当前目录下:
|
|
||||||
|
|
||||||
```text
|
|
||||||
calibration_output/<序列号>/<时间戳>/
|
|
||||||
```
|
|
||||||
|
|
||||||
恢复时必须显式复用原目录,否则会创建新会话:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
|
|
||||||
serial_number:=G20_LEFT_001 \
|
|
||||||
session_dir:=/绝对路径/calibration_output/G20_LEFT_001/20260727_120000
|
|
||||||
```
|
|
||||||
|
|
||||||
恢复会校验序列号、Tag 配置、基准命令、扫描模式、采集参数和代码哈希;
|
|
||||||
任一项变化都会拒绝混用旧样本,
|
|
||||||
此时应新建会话。
|
|
||||||
|
|
||||||
目录内文件:
|
|
||||||
|
|
||||||
- `raw_samples.jsonl`:连续帧按实际整数电机位置分箱后的 Tag 三维中心、姿态辅助统计及复测点;每完成一个
|
|
||||||
扫描方向后落盘。
|
|
||||||
- `checkpoint.json`:当前状态和进度。
|
|
||||||
- `session_manifest.json`:Tag、相机内参、SDK、代码哈希和会话信息。
|
|
||||||
- `validation.json`:随机复测及全部质量判据。
|
|
||||||
- `rosbag/`:仅在 `record_bag:=true` 时生成,用于保存相机、检测、命令和状态等诊断数据。
|
|
||||||
- `g20_left_<序列号>_thumb_angle.json`:精简后的运行时标定文件。
|
|
||||||
|
|
||||||
最终文件使用 `schema_version: 2`。每个关节只包含:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"motor_index": 0,
|
|
||||||
"angle_rad": ["按命令0~255索引的256个弧度值"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`thumb_ip.angle_rad` 由 `thumb_mcp.angle_rad` 乘
|
|
||||||
`ip_coupling.multiplier`(默认 `1.02`)得到,二者在命令255处都严格为零。
|
|
||||||
|
|
||||||
`thumb_ip` 另外包含 `"passive": true`。正反方向原始曲线不进入最终 JSON,但仍保留
|
|
||||||
在 `raw_samples.jsonl` 中,并用于最大回差和质量判定。
|
|
||||||
|
|
||||||
零位和最大角度可直接读取:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
data = json.loads(Path("g20_left_G20_LEFT_001_thumb_angle.json").read_text())
|
|
||||||
for name, joint in data["joints"].items():
|
|
||||||
print(name, "zero(rad)=", joint["angle_rad"][255],
|
|
||||||
"max(rad)=", joint["angle_rad"][0])
|
|
||||||
```
|
|
||||||
|
|
||||||
如果相机或 SDK 已由外部进程启动,可传
|
|
||||||
`start_camera:=false` 或 `start_sdk:=false`。用 `camera_serial_number:=<序列号>`
|
|
||||||
可绑定指定 RealSense。
|
|
||||||
|
|
||||||
D405 的彩色流来自 `depth_module`,启动文件会同时设置
|
|
||||||
`depth_module.color_profile` 和 `rgb_camera.color_profile`,默认均为
|
|
||||||
`1280x720x30`。连续模式默认关闭深度和 rosbag,以减少 USB、CPU 和磁盘负担;
|
|
||||||
它们都不参与角度计算。需要完整诊断留档时可增加
|
|
||||||
`enable_depth:=true record_bag:=true`。
|
|
||||||
|
|
||||||
默认对完整 1280×720 原图进行畸变校正和 AprilTag 检测。三维中心 PnP 必须使用
|
|
||||||
`image_rect` 的角点及同一条处理链对应的 `CameraInfo`,启动文件已自动保证二者配对。
|
|
||||||
校正和 AprilTag 组件运行
|
|
||||||
在同一个多线程容器内并启用进程内传输,避免在处理链路中重复序列化、复制大图像。
|
|
||||||
可选 ROI 模式会额外在同一容器内加入裁剪组件并同步修正 `CameraInfo`。标定节点默认
|
|
||||||
不订阅整幅图像,只订阅检测结果和 TF。
|
|
||||||
若启用调试图,预览会缩放到 50%、限速 10 Hz 并使用最新帧优先的传输方式,
|
|
||||||
不影响 AprilTag 的 ROI 输入。
|
|
||||||
|
|
||||||
静态预检先在单 Tag 层拒绝高重投影误差,再检查三组相对中心的位置内点率和毫米级 RMS。
|
|
||||||
当前 30~38 px 的 10 mm Tag 属于试标定尺寸,如果中心位置 RMS 持续不合格,应优先增加照明、缩短
|
|
||||||
相机距离或提高 Tag 有效像素,而不是放宽最终随机复测精度。
|
|
||||||
|
|
||||||
启用 rosbag 后保存裁剪后的原始图像和配套 `CameraInfo`,避免新增一个全分辨率图像
|
|
||||||
订阅者;同时使用 MCAP `zstd_fast` 压缩并按 10 GiB 分卷。快速标定通常不需要录制;
|
|
||||||
若用于正式可追溯验收,再启用并检查磁盘空间。
|
|
||||||
|
|
||||||
## 5. CMC Pitch 零位角测量
|
|
||||||
|
|
||||||
只测量命令 255 时 `thumb_cmc_pitch` 的画面水平投影零位角时,使用独立启动文件。
|
|
||||||
它只拟合 CMC 的二维零位轨迹圆,不运行完整 0~255 角度映射,也不会生成或修改
|
|
||||||
URDF:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 launch g20_thumb_apriltag_calibration \
|
|
||||||
front_cmc_pitch_zero.launch.py \
|
|
||||||
serial_number:=G20_LEFT_001
|
|
||||||
```
|
|
||||||
|
|
||||||
该流程只要求 T0(ID 0)和 T3(ID 1)有效。T4/T5 可以留在手上,但丢失不会阻塞。
|
|
||||||
预检完成后查看中文状态:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 topic echo --once --full-length \
|
|
||||||
/g20_thumb_cmc_pitch_zero/status_text \
|
|
||||||
--field data
|
|
||||||
```
|
|
||||||
|
|
||||||
状态显示“等待开始”后启动三轮测量:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 service call \
|
|
||||||
/g20_thumb_cmc_pitch_zero/start \
|
|
||||||
std_srvs/srv/Trigger {}
|
|
||||||
```
|
|
||||||
|
|
||||||
查看带红色画面水平线、T0/T3标签中心、青色轨迹点、紫色拟合圆心和径向零位线
|
|
||||||
的调试画面:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 run image_view image_view --ros-args \
|
|
||||||
--remap image:=/g20_thumb_cmc_pitch_zero/debug_image
|
|
||||||
```
|
|
||||||
|
|
||||||
画面底部红线是固定的相机水平与构图目标。程序会在画面下部自动寻找一条足够长、
|
|
||||||
接近水平的物理桌边或高对比参考直线,并画成蓝线。开始前调整相机,使蓝线与红线
|
|
||||||
重合;画面和 `status_text` 会实时显示红蓝线夹角及垂直偏差。`±0.5°` 和
|
|
||||||
`±12 px` 只用于显示 `ALIGNED/ADJUST`,完全不参与预检或 `start` 服务判断。
|
|
||||||
由操作人员确认相机位置后手动开始标定。参考直线应清晰、连续并尽量横跨画面;
|
|
||||||
该检测不使用 T0/T3 标签朝向。
|
|
||||||
|
|
||||||
一条二维直线只能确认相机滚转角和上下构图位置,不能单独证明相机的距离、俯仰、
|
|
||||||
偏航或完整三维位置。若需要严格复现这些量,还应使用固定相机支架或专用标定板。
|
|
||||||
|
|
||||||
每轮只控制电机 0 执行一次 `255→64` 端点运动和一次 `64→255` 返回运动。运动期间
|
|
||||||
连续采集 `T3中心−T0中心`,按机械手状态分箱后拟合图像平面圆;返回 255 后使用
|
|
||||||
“T3零位中心→拟合圆心”的固定内向径向矢量计算角度。运动前和返回后各采集30帧静态零位,
|
|
||||||
三轮轨迹合并后得到最终圆心。其他 19 个命令保持固定基准。任何其他节点同时发布
|
|
||||||
`/g20/cb_left_hand_control_cmd` 时,`start` 服务会拒绝启动。
|
|
||||||
|
|
||||||
T0中心用于消除相机或整只手的平移抖动。T0和T3标签自身的朝向与角点 `+x`
|
|
||||||
都不参与零位或行程计算;标签可以任意平面内旋转或反贴180°,只需标签平整、
|
|
||||||
固定且中心始终可见。若轨迹跨度、圆弧、半径、径向RMS/P95或回零误差不合格,
|
|
||||||
节点暂停或写出 `quality.passed=false`。
|
|
||||||
|
|
||||||
完成后只生成:
|
|
||||||
|
|
||||||
```text
|
|
||||||
calibration_output/G20_LEFT_001/<时间戳>/
|
|
||||||
g20_left_G20_LEFT_001_thumb_cmc_pitch_zero.json
|
|
||||||
```
|
|
||||||
|
|
||||||
核心字段是:
|
|
||||||
|
|
||||||
```text
|
|
||||||
zero_angles.table_projected_zero_rad
|
|
||||||
```
|
|
||||||
|
|
||||||
该值是内向径向零位矢量相对相机画面水平向右方向的角度。它不使用 T0 的方向,
|
|
||||||
但会用 T0 中心抵消平移;它仍不是真实三维桌面检测,因此会随相机滚转和机械手
|
|
||||||
摆放改变。
|
|
||||||
|
|
||||||
## 6. CMC Roll 零位与行程标定
|
|
||||||
|
|
||||||
`thumb_cmc_roll` 复用上节的 T0 平移补偿、T3 中心轨迹分箱和稳健圆拟合,
|
|
||||||
但控制的是电机 5。每轮执行 `255→0→255`:在 255 零位、0 行程端点和返回
|
|
||||||
255 后各静态采集 30 帧,因此可以同时测量零位角和完整 `0~255` 实际角行程。
|
|
||||||
命令 0 是完整行程端点,开始前必须确认拇指没有机械碰撞或硬限位顶死风险。
|
|
||||||
Roll同样固定使用“T3中心→拟合圆心”的内向径向矢量,不读取T3标签朝向。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 launch g20_thumb_apriltag_calibration \
|
|
||||||
front_cmc_roll_calibration.launch.py \
|
|
||||||
serial_number:=G20_LEFT_001
|
|
||||||
```
|
|
||||||
|
|
||||||
预检通过后启动三轮标定:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 topic echo --once --full-length \
|
|
||||||
/g20_thumb_cmc_roll_calibration/status_text \
|
|
||||||
--field data
|
|
||||||
|
|
||||||
ros2 service call \
|
|
||||||
/g20_thumb_cmc_roll_calibration/start \
|
|
||||||
std_srvs/srv/Trigger {}
|
|
||||||
```
|
|
||||||
|
|
||||||
调试画面:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ros2 run image_view image_view --ros-args \
|
|
||||||
--remap image:=/g20_thumb_cmc_roll_calibration/debug_image
|
|
||||||
```
|
|
||||||
|
|
||||||
Roll 使用与 Pitch 相同的红蓝参考线显示,但是否对齐由操作人员确认,程序不会用
|
|
||||||
蓝线状态阻止 `start` 进入电机运动。
|
|
||||||
|
|
||||||
完成后生成:
|
|
||||||
|
|
||||||
```text
|
|
||||||
calibration_output/G20_LEFT_001/<时间戳>/
|
|
||||||
g20_left_G20_LEFT_001_thumb_cmc_roll_zero_travel.json
|
|
||||||
```
|
|
||||||
|
|
||||||
核心输出字段:
|
|
||||||
|
|
||||||
```text
|
|
||||||
zero_angles.table_projected_zero_rad
|
|
||||||
travel.signed_rad
|
|
||||||
travel.range_rad
|
|
||||||
```
|
|
||||||
|
|
||||||
`travel.signed_rad` 是从命令 255 到 0 的有符号转角,`travel.range_rad` 是三轮
|
|
||||||
行程大小的中值。只有轨迹圆质量、T0/T3 检出率、三轮零位/行程一致性、端点径向
|
|
||||||
误差和回零误差全部通过时,`quality.passed` 才为 `true`。
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# This is a flat parameter mapping consumed by realsense2_camera/rs_launch.py.
|
|
||||||
# Keep image and CameraInfo durability identical so image_transport can
|
|
||||||
# synchronize them for image_proc and apriltag_ros.
|
|
||||||
color_qos: DEFAULT
|
|
||||||
color_info_qos: DEFAULT
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Front-camera AprilTag calibration for the left LinkerHand G20 thumb."""
|
|
||||||
|
|
||||||
from .core import BASELINE_COMMAND, COMMAND_NAMES
|
|
||||||
|
|
||||||
__all__ = ["BASELINE_COMMAND", "COMMAND_NAMES"]
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,979 +0,0 @@
|
|||||||
"""Square AprilTag pose estimation with planar ambiguity tracking.
|
|
||||||
|
|
||||||
The AprilTag detections contain accurately refined image corners. This module
|
|
||||||
uses OpenCV's IPPE square solver directly so the calibration node can inspect
|
|
||||||
both planar PnP solutions instead of accepting an occasionally flipped TF
|
|
||||||
pose.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, replace
|
|
||||||
from itertools import product
|
|
||||||
import math
|
|
||||||
from typing import Mapping, Sequence
|
|
||||||
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
from scipy.spatial.transform import Rotation
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SquareTagPose:
|
|
||||||
"""One tag-to-camera pose candidate returned by IPPE."""
|
|
||||||
|
|
||||||
quaternion_xyzw: tuple[float, float, float, float]
|
|
||||||
translation_xyz_m: tuple[float, float, float]
|
|
||||||
reprojection_error_px: float
|
|
||||||
|
|
||||||
|
|
||||||
def _relative_pose(
|
|
||||||
parent: SquareTagPose,
|
|
||||||
child: SquareTagPose,
|
|
||||||
) -> tuple[Rotation, np.ndarray]:
|
|
||||||
parent_rotation = Rotation.from_quat(parent.quaternion_xyzw)
|
|
||||||
child_rotation = Rotation.from_quat(child.quaternion_xyzw)
|
|
||||||
relative_rotation = parent_rotation.inv() * child_rotation
|
|
||||||
relative_translation = parent_rotation.inv().apply(
|
|
||||||
np.asarray(child.translation_xyz_m, dtype=float)
|
|
||||||
- np.asarray(parent.translation_xyz_m, dtype=float)
|
|
||||||
)
|
|
||||||
return relative_rotation, relative_translation
|
|
||||||
|
|
||||||
|
|
||||||
def select_rigid_group_trajectory(
|
|
||||||
frames: Sequence[Mapping[str, Sequence[SquareTagPose]]],
|
|
||||||
*,
|
|
||||||
roles: Sequence[str],
|
|
||||||
fixed_pairs: Sequence[tuple[str, str]],
|
|
||||||
reprojection_scale_px: float,
|
|
||||||
rotation_scale_rad: float,
|
|
||||||
translation_scale_m: float,
|
|
||||||
pair_geometry: str = "pose",
|
|
||||||
) -> tuple[
|
|
||||||
list[dict[str, SquareTagPose]],
|
|
||||||
dict[str, float | str],
|
|
||||||
]:
|
|
||||||
"""Resolve planar branches using geometry that should stay rigid.
|
|
||||||
|
|
||||||
Every possible branch combination in the first frame is treated as a
|
|
||||||
candidate rigid reference. For each such reference, every later frame
|
|
||||||
independently chooses the combination with the lowest reprojection plus
|
|
||||||
geometric-drift cost. ``pose`` compares relative rotation and translation;
|
|
||||||
``distance`` compares only Euclidean centre distances and therefore does
|
|
||||||
not allow planar-PnP orientation jitter into centre-trajectory angles.
|
|
||||||
The globally cheapest reference and path win.
|
|
||||||
"""
|
|
||||||
role_names = tuple(str(role) for role in roles)
|
|
||||||
pair_names = tuple((str(parent), str(child)) for parent, child in fixed_pairs)
|
|
||||||
if not frames:
|
|
||||||
raise ValueError("at least one PnP frame is required")
|
|
||||||
if len(set(role_names)) != len(role_names) or not role_names:
|
|
||||||
raise ValueError("roles must be non-empty and unique")
|
|
||||||
if any(
|
|
||||||
parent not in role_names or child not in role_names
|
|
||||||
for parent, child in pair_names
|
|
||||||
):
|
|
||||||
raise ValueError("fixed_pairs must reference roles")
|
|
||||||
reprojection_scale = float(reprojection_scale_px)
|
|
||||||
rotation_scale = float(rotation_scale_rad)
|
|
||||||
translation_scale = float(translation_scale_m)
|
|
||||||
geometry_mode = str(pair_geometry)
|
|
||||||
if min(reprojection_scale, rotation_scale, translation_scale) <= 0.0:
|
|
||||||
raise ValueError("trajectory selection scales must be positive")
|
|
||||||
if geometry_mode not in {"pose", "distance"}:
|
|
||||||
raise ValueError("pair_geometry must be pose or distance")
|
|
||||||
|
|
||||||
combinations_by_frame: list[list[dict[str, SquareTagPose]]] = []
|
|
||||||
for frame in frames:
|
|
||||||
candidate_lists = [tuple(frame.get(role, ())) for role in role_names]
|
|
||||||
if any(not candidates for candidates in candidate_lists):
|
|
||||||
raise ValueError("every frame must contain every requested role")
|
|
||||||
combinations_by_frame.append(
|
|
||||||
[
|
|
||||||
dict(zip(role_names, combination))
|
|
||||||
for combination in product(*candidate_lists)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
best_total = float("inf")
|
|
||||||
best_path: list[dict[str, SquareTagPose]] | None = None
|
|
||||||
|
|
||||||
def emission(
|
|
||||||
combination: Mapping[str, SquareTagPose],
|
|
||||||
reference_pairs: Mapping[
|
|
||||||
tuple[str, str], tuple[Rotation, np.ndarray]
|
|
||||||
],
|
|
||||||
reference_distances: Mapping[tuple[str, str], float],
|
|
||||||
) -> tuple[float, float, float]:
|
|
||||||
reprojection_cost = sum(
|
|
||||||
pose.reprojection_error_px
|
|
||||||
for pose in combination.values()
|
|
||||||
) / reprojection_scale
|
|
||||||
rotation_drifts: list[float] = []
|
|
||||||
translation_drifts: list[float] = []
|
|
||||||
distance_drifts: list[float] = []
|
|
||||||
for pair, (
|
|
||||||
reference_rotation,
|
|
||||||
reference_translation,
|
|
||||||
) in reference_pairs.items():
|
|
||||||
rotation, translation = _relative_pose(
|
|
||||||
combination[pair[0]],
|
|
||||||
combination[pair[1]],
|
|
||||||
)
|
|
||||||
rotation_drifts.append(
|
|
||||||
float(
|
|
||||||
(reference_rotation.inv() * rotation).magnitude()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
translation_drifts.append(
|
|
||||||
float(
|
|
||||||
np.linalg.norm(
|
|
||||||
translation - reference_translation
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
current_distance = float(
|
|
||||||
np.linalg.norm(
|
|
||||||
np.asarray(
|
|
||||||
combination[pair[1]].translation_xyz_m,
|
|
||||||
dtype=float,
|
|
||||||
)
|
|
||||||
- np.asarray(
|
|
||||||
combination[pair[0]].translation_xyz_m,
|
|
||||||
dtype=float,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
distance_drifts.append(
|
|
||||||
abs(current_distance - reference_distances[pair])
|
|
||||||
)
|
|
||||||
if geometry_mode == "distance":
|
|
||||||
geometry_cost = sum(distance_drifts) / translation_scale
|
|
||||||
else:
|
|
||||||
geometry_cost = (
|
|
||||||
sum(rotation_drifts) / rotation_scale
|
|
||||||
+ sum(translation_drifts) / translation_scale
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
reprojection_cost + geometry_cost,
|
|
||||||
max(rotation_drifts, default=0.0),
|
|
||||||
(
|
|
||||||
max(distance_drifts, default=0.0)
|
|
||||||
if geometry_mode == "distance"
|
|
||||||
else max(translation_drifts, default=0.0)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def transition_cost(
|
|
||||||
previous: Mapping[str, SquareTagPose],
|
|
||||||
current: Mapping[str, SquareTagPose],
|
|
||||||
) -> float:
|
|
||||||
rotation_motion = sum(
|
|
||||||
rotation_distance_rad(
|
|
||||||
previous[role].quaternion_xyzw,
|
|
||||||
current[role].quaternion_xyzw,
|
|
||||||
)
|
|
||||||
for role in role_names
|
|
||||||
)
|
|
||||||
translation_motion = sum(
|
|
||||||
float(
|
|
||||||
np.linalg.norm(
|
|
||||||
np.asarray(current[role].translation_xyz_m)
|
|
||||||
- np.asarray(previous[role].translation_xyz_m)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for role in role_names
|
|
||||||
)
|
|
||||||
if geometry_mode == "distance":
|
|
||||||
return translation_motion / translation_scale
|
|
||||||
return (
|
|
||||||
rotation_motion / rotation_scale
|
|
||||||
+ translation_motion / translation_scale
|
|
||||||
)
|
|
||||||
|
|
||||||
for reference_index, reference_combination in enumerate(
|
|
||||||
combinations_by_frame[0]
|
|
||||||
):
|
|
||||||
reference_pairs = {
|
|
||||||
pair: _relative_pose(
|
|
||||||
reference_combination[pair[0]],
|
|
||||||
reference_combination[pair[1]],
|
|
||||||
)
|
|
||||||
for pair in pair_names
|
|
||||||
}
|
|
||||||
reference_distances = {
|
|
||||||
pair: float(
|
|
||||||
np.linalg.norm(
|
|
||||||
np.asarray(
|
|
||||||
reference_combination[pair[1]].translation_xyz_m,
|
|
||||||
dtype=float,
|
|
||||||
)
|
|
||||||
- np.asarray(
|
|
||||||
reference_combination[pair[0]].translation_xyz_m,
|
|
||||||
dtype=float,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for pair in pair_names
|
|
||||||
}
|
|
||||||
first_emission = emission(
|
|
||||||
reference_combination,
|
|
||||||
reference_pairs,
|
|
||||||
reference_distances,
|
|
||||||
)
|
|
||||||
previous_costs = np.full(
|
|
||||||
len(combinations_by_frame[0]),
|
|
||||||
np.inf,
|
|
||||||
dtype=float,
|
|
||||||
)
|
|
||||||
previous_costs[reference_index] = first_emission[0]
|
|
||||||
back_pointers: list[list[int]] = []
|
|
||||||
for frame_index in range(1, len(combinations_by_frame)):
|
|
||||||
previous_combinations = combinations_by_frame[frame_index - 1]
|
|
||||||
combinations = combinations_by_frame[frame_index]
|
|
||||||
frame_emissions = [
|
|
||||||
emission(
|
|
||||||
combination,
|
|
||||||
reference_pairs,
|
|
||||||
reference_distances,
|
|
||||||
)
|
|
||||||
for combination in combinations
|
|
||||||
]
|
|
||||||
current_costs = np.full(len(combinations), np.inf, dtype=float)
|
|
||||||
frame_back_pointers: list[int] = []
|
|
||||||
for current_index, combination in enumerate(combinations):
|
|
||||||
transition_costs = [
|
|
||||||
previous_costs[previous_index]
|
|
||||||
+ transition_cost(
|
|
||||||
previous_combination,
|
|
||||||
combination,
|
|
||||||
)
|
|
||||||
for previous_index, previous_combination in enumerate(
|
|
||||||
previous_combinations
|
|
||||||
)
|
|
||||||
]
|
|
||||||
best_previous = int(np.argmin(transition_costs))
|
|
||||||
frame_back_pointers.append(best_previous)
|
|
||||||
current_costs[current_index] = (
|
|
||||||
transition_costs[best_previous]
|
|
||||||
+ frame_emissions[current_index][0]
|
|
||||||
)
|
|
||||||
back_pointers.append(frame_back_pointers)
|
|
||||||
previous_costs = current_costs
|
|
||||||
|
|
||||||
final_index = int(np.argmin(previous_costs))
|
|
||||||
total = float(previous_costs[final_index])
|
|
||||||
path_indices = [final_index]
|
|
||||||
for frame_back_pointers in reversed(back_pointers):
|
|
||||||
path_indices.append(
|
|
||||||
frame_back_pointers[path_indices[-1]]
|
|
||||||
)
|
|
||||||
path_indices.reverse()
|
|
||||||
path = [
|
|
||||||
combinations[index]
|
|
||||||
for combinations, index in zip(
|
|
||||||
combinations_by_frame,
|
|
||||||
path_indices,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
if total < best_total:
|
|
||||||
best_total = total
|
|
||||||
best_path = path
|
|
||||||
|
|
||||||
if best_path is None:
|
|
||||||
raise RuntimeError("trajectory branch selection produced no path")
|
|
||||||
|
|
||||||
# The marker-to-marker mounting transforms are unknown, so the rigid
|
|
||||||
# reference must be estimated from the complete sweep. Using frame zero
|
|
||||||
# as both the optimisation seed and the reported quality reference made
|
|
||||||
# one noisy endpoint frame look like drift in every other frame. A
|
|
||||||
# rotation medoid and component-wise translation median are insensitive
|
|
||||||
# to that endpoint noise while still exposing a persistent mirror branch.
|
|
||||||
robust_reference_pairs: dict[
|
|
||||||
tuple[str, str], tuple[Rotation, np.ndarray]
|
|
||||||
] = {}
|
|
||||||
for pair in pair_names:
|
|
||||||
pair_poses = [
|
|
||||||
_relative_pose(frame[pair[0]], frame[pair[1]])
|
|
||||||
for frame in best_path
|
|
||||||
]
|
|
||||||
pair_rotations = [pose[0] for pose in pair_poses]
|
|
||||||
angular_costs = np.asarray(
|
|
||||||
[
|
|
||||||
sum(
|
|
||||||
float((candidate.inv() * other).magnitude())
|
|
||||||
for other in pair_rotations
|
|
||||||
)
|
|
||||||
for candidate in pair_rotations
|
|
||||||
],
|
|
||||||
dtype=float,
|
|
||||||
)
|
|
||||||
rotation_medoid = pair_rotations[int(np.argmin(angular_costs))]
|
|
||||||
translation_median = np.median(
|
|
||||||
np.asarray([pose[1] for pose in pair_poses], dtype=float),
|
|
||||||
axis=0,
|
|
||||||
)
|
|
||||||
robust_reference_pairs[pair] = (
|
|
||||||
rotation_medoid,
|
|
||||||
translation_median,
|
|
||||||
)
|
|
||||||
|
|
||||||
rotation_drifts_by_frame: list[float] = []
|
|
||||||
translation_drifts_by_frame: list[float] = []
|
|
||||||
pair_distances_by_pair = {
|
|
||||||
pair: np.asarray(
|
|
||||||
[
|
|
||||||
np.linalg.norm(
|
|
||||||
np.asarray(frame[pair[1]].translation_xyz_m, dtype=float)
|
|
||||||
- np.asarray(
|
|
||||||
frame[pair[0]].translation_xyz_m, dtype=float
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for frame in best_path
|
|
||||||
],
|
|
||||||
dtype=float,
|
|
||||||
)
|
|
||||||
for pair in pair_names
|
|
||||||
}
|
|
||||||
robust_pair_distances = {
|
|
||||||
pair: float(np.median(distances))
|
|
||||||
for pair, distances in pair_distances_by_pair.items()
|
|
||||||
}
|
|
||||||
distance_drifts_by_frame: list[float] = []
|
|
||||||
for frame in best_path:
|
|
||||||
frame_rotation_drifts: list[float] = []
|
|
||||||
frame_translation_drifts: list[float] = []
|
|
||||||
frame_distance_drifts: list[float] = []
|
|
||||||
for pair, (
|
|
||||||
reference_rotation,
|
|
||||||
reference_translation,
|
|
||||||
) in robust_reference_pairs.items():
|
|
||||||
rotation, translation = _relative_pose(
|
|
||||||
frame[pair[0]], frame[pair[1]]
|
|
||||||
)
|
|
||||||
frame_rotation_drifts.append(
|
|
||||||
float((reference_rotation.inv() * rotation).magnitude())
|
|
||||||
)
|
|
||||||
frame_translation_drifts.append(
|
|
||||||
float(np.linalg.norm(translation - reference_translation))
|
|
||||||
)
|
|
||||||
distance = float(
|
|
||||||
np.linalg.norm(
|
|
||||||
np.asarray(frame[pair[1]].translation_xyz_m, dtype=float)
|
|
||||||
- np.asarray(
|
|
||||||
frame[pair[0]].translation_xyz_m, dtype=float
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
frame_distance_drifts.append(
|
|
||||||
abs(distance - robust_pair_distances[pair])
|
|
||||||
)
|
|
||||||
rotation_drifts_by_frame.append(
|
|
||||||
max(frame_rotation_drifts, default=0.0)
|
|
||||||
)
|
|
||||||
translation_drifts_by_frame.append(
|
|
||||||
max(frame_translation_drifts, default=0.0)
|
|
||||||
)
|
|
||||||
distance_drifts_by_frame.append(
|
|
||||||
max(frame_distance_drifts, default=0.0)
|
|
||||||
)
|
|
||||||
|
|
||||||
rotation_drifts = np.asarray(rotation_drifts_by_frame, dtype=float)
|
|
||||||
translation_drifts = np.asarray(
|
|
||||||
translation_drifts_by_frame, dtype=float
|
|
||||||
)
|
|
||||||
distance_drifts = np.asarray(distance_drifts_by_frame, dtype=float)
|
|
||||||
return best_path, {
|
|
||||||
"total_cost": float(best_total),
|
|
||||||
"pair_geometry": geometry_mode,
|
|
||||||
"maximum_pair_rotation_drift_rad": float(
|
|
||||||
np.max(rotation_drifts, initial=0.0)
|
|
||||||
),
|
|
||||||
"p95_pair_rotation_drift_rad": float(
|
|
||||||
np.percentile(rotation_drifts, 95.0)
|
|
||||||
),
|
|
||||||
"median_pair_rotation_drift_rad": float(
|
|
||||||
np.median(rotation_drifts)
|
|
||||||
),
|
|
||||||
"maximum_pair_translation_drift_m": float(
|
|
||||||
np.max(translation_drifts, initial=0.0)
|
|
||||||
),
|
|
||||||
"p95_pair_translation_drift_m": float(
|
|
||||||
np.percentile(translation_drifts, 95.0)
|
|
||||||
),
|
|
||||||
"maximum_pair_distance_drift_m": float(
|
|
||||||
np.max(distance_drifts, initial=0.0)
|
|
||||||
),
|
|
||||||
"p95_pair_distance_drift_m": float(
|
|
||||||
np.percentile(distance_drifts, 95.0)
|
|
||||||
),
|
|
||||||
"median_pair_distance_drift_m": float(
|
|
||||||
np.median(distance_drifts)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _as_camera_matrix(camera_matrix: Sequence[Sequence[float]]) -> np.ndarray:
|
|
||||||
matrix = np.asarray(camera_matrix, dtype=np.float64)
|
|
||||||
if matrix.shape != (3, 3):
|
|
||||||
raise ValueError("camera_matrix must have shape (3, 3)")
|
|
||||||
if not np.all(np.isfinite(matrix)):
|
|
||||||
raise ValueError("camera_matrix must be finite")
|
|
||||||
if matrix[0, 0] <= 0.0 or matrix[1, 1] <= 0.0:
|
|
||||||
raise ValueError("camera focal lengths must be positive")
|
|
||||||
return matrix
|
|
||||||
|
|
||||||
|
|
||||||
def square_object_points(tag_size_m: float) -> np.ndarray:
|
|
||||||
"""Return IPPE-square points matching apriltag_msgs corner order.
|
|
||||||
|
|
||||||
``apriltag_ros`` reports bottom-left, bottom-right, top-right, top-left.
|
|
||||||
OpenCV's ``SOLVEPNP_IPPE_SQUARE`` requires the same physical corners in
|
|
||||||
the order below.
|
|
||||||
"""
|
|
||||||
size = float(tag_size_m)
|
|
||||||
if not math.isfinite(size) or size <= 0.0:
|
|
||||||
raise ValueError("tag_size_m must be finite and positive")
|
|
||||||
half = size / 2.0
|
|
||||||
return np.asarray(
|
|
||||||
[
|
|
||||||
[-half, half, 0.0],
|
|
||||||
[half, half, 0.0],
|
|
||||||
[half, -half, 0.0],
|
|
||||||
[-half, -half, 0.0],
|
|
||||||
],
|
|
||||||
dtype=np.float64,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def solve_square_tag_ippe(
|
|
||||||
corners_xy: Sequence[Sequence[float]],
|
|
||||||
*,
|
|
||||||
tag_size_m: float,
|
|
||||||
camera_matrix: Sequence[Sequence[float]],
|
|
||||||
) -> list[SquareTagPose]:
|
|
||||||
"""Return every finite, positive-depth IPPE pose for one square tag."""
|
|
||||||
image_points = np.asarray(corners_xy, dtype=np.float64)
|
|
||||||
if image_points.shape != (4, 2):
|
|
||||||
raise ValueError("corners_xy must have shape (4, 2)")
|
|
||||||
if not np.all(np.isfinite(image_points)):
|
|
||||||
raise ValueError("corners_xy must be finite")
|
|
||||||
intrinsic = _as_camera_matrix(camera_matrix)
|
|
||||||
object_points = square_object_points(tag_size_m)
|
|
||||||
distortion = np.zeros((4, 1), dtype=np.float64)
|
|
||||||
|
|
||||||
solved, rotation_vectors, translations, _ = cv2.solvePnPGeneric(
|
|
||||||
object_points,
|
|
||||||
image_points,
|
|
||||||
intrinsic,
|
|
||||||
distortion,
|
|
||||||
flags=cv2.SOLVEPNP_IPPE_SQUARE,
|
|
||||||
)
|
|
||||||
if not solved:
|
|
||||||
return []
|
|
||||||
|
|
||||||
candidates: list[SquareTagPose] = []
|
|
||||||
for rotation_vector, translation in zip(rotation_vectors, translations):
|
|
||||||
rotation_matrix, _ = cv2.Rodrigues(rotation_vector)
|
|
||||||
translation_vector = np.asarray(translation, dtype=float).reshape(3)
|
|
||||||
camera_points = (
|
|
||||||
rotation_matrix @ object_points.T
|
|
||||||
+ translation_vector.reshape(3, 1)
|
|
||||||
).T
|
|
||||||
if np.min(camera_points[:, 2]) <= 0.0:
|
|
||||||
continue
|
|
||||||
projected, _ = cv2.projectPoints(
|
|
||||||
object_points,
|
|
||||||
rotation_vector,
|
|
||||||
translation_vector,
|
|
||||||
intrinsic,
|
|
||||||
distortion,
|
|
||||||
)
|
|
||||||
residual = projected.reshape(4, 2) - image_points
|
|
||||||
reprojection_error = float(
|
|
||||||
np.sqrt(np.mean(np.sum(residual * residual, axis=1)))
|
|
||||||
)
|
|
||||||
quaternion = Rotation.from_matrix(rotation_matrix).as_quat()
|
|
||||||
if not (
|
|
||||||
np.all(np.isfinite(quaternion))
|
|
||||||
and np.all(np.isfinite(translation_vector))
|
|
||||||
and math.isfinite(reprojection_error)
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
candidates.append(
|
|
||||||
SquareTagPose(
|
|
||||||
quaternion_xyzw=tuple(float(value) for value in quaternion),
|
|
||||||
translation_xyz_m=tuple(
|
|
||||||
float(value) for value in translation_vector
|
|
||||||
),
|
|
||||||
reprojection_error_px=reprojection_error,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
|
|
||||||
def rotation_distance_rad(
|
|
||||||
first_xyzw: Sequence[float],
|
|
||||||
second_xyzw: Sequence[float],
|
|
||||||
) -> float:
|
|
||||||
first = Rotation.from_quat(np.asarray(first_xyzw, dtype=float))
|
|
||||||
second = Rotation.from_quat(np.asarray(second_xyzw, dtype=float))
|
|
||||||
return float((first.inv() * second).magnitude())
|
|
||||||
|
|
||||||
|
|
||||||
def select_continuous_pose(
|
|
||||||
candidates: Sequence[SquareTagPose],
|
|
||||||
*,
|
|
||||||
previous: SquareTagPose | None,
|
|
||||||
maximum_reprojection_error_px: float,
|
|
||||||
reprojection_tie_px: float,
|
|
||||||
maximum_pose_jump_rad: float,
|
|
||||||
maximum_translation_jump_m: float,
|
|
||||||
maximum_tag_tilt_rad: float,
|
|
||||||
) -> tuple[SquareTagPose | None, str]:
|
|
||||||
"""Select the best IPPE branch using image fit and temporal continuity."""
|
|
||||||
maximum_error = float(maximum_reprojection_error_px)
|
|
||||||
tie_error = float(reprojection_tie_px)
|
|
||||||
maximum_rotation = float(maximum_pose_jump_rad)
|
|
||||||
maximum_translation = float(maximum_translation_jump_m)
|
|
||||||
maximum_tilt = float(maximum_tag_tilt_rad)
|
|
||||||
if min(
|
|
||||||
maximum_error,
|
|
||||||
maximum_rotation,
|
|
||||||
maximum_translation,
|
|
||||||
maximum_tilt,
|
|
||||||
) <= 0.0:
|
|
||||||
raise ValueError("PnP selection thresholds must be positive")
|
|
||||||
if tie_error < 0.0:
|
|
||||||
raise ValueError("reprojection_tie_px must be non-negative")
|
|
||||||
|
|
||||||
eligible: list[SquareTagPose] = []
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.reprojection_error_px > maximum_error:
|
|
||||||
continue
|
|
||||||
normal = Rotation.from_quat(candidate.quaternion_xyzw).as_matrix()[:, 2]
|
|
||||||
tilt = math.acos(float(np.clip(abs(normal[2]), 0.0, 1.0)))
|
|
||||||
if tilt > maximum_tilt:
|
|
||||||
continue
|
|
||||||
eligible.append(candidate)
|
|
||||||
if not eligible:
|
|
||||||
return None, "no_pose_within_reprojection_or_tilt_limit"
|
|
||||||
|
|
||||||
eligible.sort(key=lambda item: item.reprojection_error_px)
|
|
||||||
best = eligible[0]
|
|
||||||
if previous is None:
|
|
||||||
return best, ""
|
|
||||||
|
|
||||||
# Temporal continuity must only break a genuine planar-PnP tie. The old
|
|
||||||
# implementation normalised reprojection error by the permissive 1.5 px
|
|
||||||
# rejection limit, which allowed a stale mirror branch at 0.25 px to beat
|
|
||||||
# the true branch at e.g. 0.05 px merely because it was closer to the
|
|
||||||
# preceding (already wrong) pose. Once one IPPE solution has a meaningful
|
|
||||||
# image-fit advantage, trust it and allow the tracker to leave the stale
|
|
||||||
# branch even if that correction is a large pose jump.
|
|
||||||
competitive = [
|
|
||||||
candidate
|
|
||||||
for candidate in eligible
|
|
||||||
if candidate.reprojection_error_px
|
|
||||||
<= best.reprojection_error_px + tie_error
|
|
||||||
]
|
|
||||||
if len(competitive) == 1:
|
|
||||||
return best, ""
|
|
||||||
|
|
||||||
previous_translation = np.asarray(previous.translation_xyz_m, dtype=float)
|
|
||||||
scored: list[tuple[float, SquareTagPose]] = []
|
|
||||||
for candidate in competitive:
|
|
||||||
rotation_jump = rotation_distance_rad(
|
|
||||||
previous.quaternion_xyzw,
|
|
||||||
candidate.quaternion_xyzw,
|
|
||||||
)
|
|
||||||
translation_jump = float(
|
|
||||||
np.linalg.norm(
|
|
||||||
np.asarray(candidate.translation_xyz_m, dtype=float)
|
|
||||||
- previous_translation
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
rotation_jump > maximum_rotation
|
|
||||||
or translation_jump > maximum_translation
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
score = (
|
|
||||||
(
|
|
||||||
candidate.reprojection_error_px
|
|
||||||
- best.reprojection_error_px
|
|
||||||
)
|
|
||||||
/ max(tie_error, np.finfo(float).eps)
|
|
||||||
+ rotation_jump / maximum_rotation
|
|
||||||
+ translation_jump / maximum_translation
|
|
||||||
)
|
|
||||||
scored.append((float(score), candidate))
|
|
||||||
if not scored:
|
|
||||||
return None, "pose_jump"
|
|
||||||
|
|
||||||
selected = min(scored, key=lambda item: item[0])[1]
|
|
||||||
previous_quaternion = np.asarray(previous.quaternion_xyzw, dtype=float)
|
|
||||||
selected_quaternion = np.asarray(selected.quaternion_xyzw, dtype=float)
|
|
||||||
if float(np.dot(previous_quaternion, selected_quaternion)) < 0.0:
|
|
||||||
selected = replace(
|
|
||||||
selected,
|
|
||||||
quaternion_xyzw=tuple(
|
|
||||||
float(value) for value in -selected_quaternion
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return selected, ""
|
|
||||||
|
|
||||||
|
|
||||||
class SquareTagPoseTracker:
|
|
||||||
"""Maintain the selected planar-PnP branch independently for each tag."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
maximum_reprojection_error_px: float,
|
|
||||||
reprojection_tie_px: float,
|
|
||||||
maximum_pose_jump_rad: float,
|
|
||||||
maximum_translation_jump_m: float,
|
|
||||||
maximum_tag_tilt_rad: float,
|
|
||||||
reset_after_seconds: float,
|
|
||||||
) -> None:
|
|
||||||
self.maximum_reprojection_error_px = float(
|
|
||||||
maximum_reprojection_error_px
|
|
||||||
)
|
|
||||||
self.reprojection_tie_px = float(reprojection_tie_px)
|
|
||||||
self.maximum_pose_jump_rad = float(maximum_pose_jump_rad)
|
|
||||||
self.maximum_translation_jump_m = float(maximum_translation_jump_m)
|
|
||||||
self.maximum_tag_tilt_rad = float(maximum_tag_tilt_rad)
|
|
||||||
self.reset_after_ns = int(float(reset_after_seconds) * 1_000_000_000)
|
|
||||||
if self.reset_after_ns <= 0:
|
|
||||||
raise ValueError("reset_after_seconds must be positive")
|
|
||||||
self._previous: dict[str, tuple[int, SquareTagPose]] = {}
|
|
||||||
self.last_candidates_by_role: dict[
|
|
||||||
str, tuple[SquareTagPose, ...]
|
|
||||||
] = {}
|
|
||||||
self.branch_correction_counts: dict[str, int] = {}
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
self._previous.clear()
|
|
||||||
self.last_candidates_by_role.clear()
|
|
||||||
self.branch_correction_counts.clear()
|
|
||||||
|
|
||||||
def estimate(
|
|
||||||
self,
|
|
||||||
role: str,
|
|
||||||
corners_xy: Sequence[Sequence[float]],
|
|
||||||
*,
|
|
||||||
tag_size_m: float,
|
|
||||||
camera_matrix: Sequence[Sequence[float]],
|
|
||||||
stamp_ns: int,
|
|
||||||
reprojection_tie_px: float | None = None,
|
|
||||||
) -> tuple[SquareTagPose | None, str]:
|
|
||||||
try:
|
|
||||||
candidates = solve_square_tag_ippe(
|
|
||||||
corners_xy,
|
|
||||||
tag_size_m=tag_size_m,
|
|
||||||
camera_matrix=camera_matrix,
|
|
||||||
)
|
|
||||||
except (ValueError, cv2.error):
|
|
||||||
self.last_candidates_by_role[str(role)] = ()
|
|
||||||
return None, "pnp_solve_failed"
|
|
||||||
if not candidates:
|
|
||||||
self.last_candidates_by_role[str(role)] = ()
|
|
||||||
return None, "pnp_solve_failed"
|
|
||||||
usable_candidates: list[SquareTagPose] = []
|
|
||||||
for candidate in candidates:
|
|
||||||
normal = Rotation.from_quat(
|
|
||||||
candidate.quaternion_xyzw
|
|
||||||
).as_matrix()[:, 2]
|
|
||||||
tilt = math.acos(
|
|
||||||
float(np.clip(abs(normal[2]), 0.0, 1.0))
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
candidate.reprojection_error_px
|
|
||||||
<= self.maximum_reprojection_error_px
|
|
||||||
and tilt <= self.maximum_tag_tilt_rad
|
|
||||||
):
|
|
||||||
usable_candidates.append(candidate)
|
|
||||||
self.last_candidates_by_role[str(role)] = tuple(
|
|
||||||
usable_candidates
|
|
||||||
)
|
|
||||||
if not usable_candidates:
|
|
||||||
return None, "no_pose_within_reprojection_or_tilt_limit"
|
|
||||||
|
|
||||||
previous_record = self._previous.get(str(role))
|
|
||||||
previous: SquareTagPose | None = None
|
|
||||||
if previous_record is not None:
|
|
||||||
previous_stamp, previous_pose = previous_record
|
|
||||||
elapsed = int(stamp_ns) - previous_stamp
|
|
||||||
if 0 <= elapsed <= self.reset_after_ns:
|
|
||||||
previous = previous_pose
|
|
||||||
|
|
||||||
selected, reason = select_continuous_pose(
|
|
||||||
usable_candidates,
|
|
||||||
previous=previous,
|
|
||||||
maximum_reprojection_error_px=(
|
|
||||||
self.maximum_reprojection_error_px
|
|
||||||
),
|
|
||||||
reprojection_tie_px=(
|
|
||||||
self.reprojection_tie_px
|
|
||||||
if reprojection_tie_px is None
|
|
||||||
else float(reprojection_tie_px)
|
|
||||||
),
|
|
||||||
maximum_pose_jump_rad=self.maximum_pose_jump_rad,
|
|
||||||
maximum_translation_jump_m=self.maximum_translation_jump_m,
|
|
||||||
maximum_tag_tilt_rad=self.maximum_tag_tilt_rad,
|
|
||||||
)
|
|
||||||
if selected is not None:
|
|
||||||
if previous is not None:
|
|
||||||
rotation_jump = rotation_distance_rad(
|
|
||||||
previous.quaternion_xyzw,
|
|
||||||
selected.quaternion_xyzw,
|
|
||||||
)
|
|
||||||
translation_jump = float(
|
|
||||||
np.linalg.norm(
|
|
||||||
np.asarray(selected.translation_xyz_m, dtype=float)
|
|
||||||
- np.asarray(
|
|
||||||
previous.translation_xyz_m,
|
|
||||||
dtype=float,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
rotation_jump > self.maximum_pose_jump_rad
|
|
||||||
or translation_jump
|
|
||||||
> self.maximum_translation_jump_m
|
|
||||||
):
|
|
||||||
key = str(role)
|
|
||||||
self.branch_correction_counts[key] = (
|
|
||||||
self.branch_correction_counts.get(key, 0) + 1
|
|
||||||
)
|
|
||||||
self._previous[str(role)] = (int(stamp_ns), selected)
|
|
||||||
return selected, reason
|
|
||||||
|
|
||||||
|
|
||||||
class SquareTagGroupPoseTracker:
|
|
||||||
"""Choose all tag branches together using thumb-chain continuity.
|
|
||||||
|
|
||||||
A 30 px planar tag has two IPPE solutions whose reprojection errors can
|
|
||||||
exchange order from one frame to the next. Tracking each tag
|
|
||||||
independently can therefore choose an incompatible pair for a relative
|
|
||||||
joint such as T4->T5. This tracker enumerates the small Cartesian product
|
|
||||||
(at most 2**4 combinations) and favours the combination that keeps both
|
|
||||||
the camera poses and all adjacent relative poses continuous.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
roles: Sequence[str],
|
|
||||||
adjacent_pairs: Sequence[tuple[str, str]],
|
|
||||||
maximum_pose_jump_rad: float,
|
|
||||||
maximum_translation_jump_m: float,
|
|
||||||
relative_rotation_scale_rad: float,
|
|
||||||
relative_translation_scale_m: float,
|
|
||||||
reprojection_scale_px: float,
|
|
||||||
reprojection_weight: float,
|
|
||||||
reset_after_seconds: float,
|
|
||||||
) -> None:
|
|
||||||
self.roles = tuple(str(role) for role in roles)
|
|
||||||
self.adjacent_pairs = tuple(
|
|
||||||
(str(parent), str(child))
|
|
||||||
for parent, child in adjacent_pairs
|
|
||||||
)
|
|
||||||
if not self.roles or len(set(self.roles)) != len(self.roles):
|
|
||||||
raise ValueError("roles must be non-empty and unique")
|
|
||||||
if any(
|
|
||||||
parent not in self.roles or child not in self.roles
|
|
||||||
for parent, child in self.adjacent_pairs
|
|
||||||
):
|
|
||||||
raise ValueError("adjacent_pairs must reference roles")
|
|
||||||
self.maximum_pose_jump_rad = float(maximum_pose_jump_rad)
|
|
||||||
self.maximum_translation_jump_m = float(
|
|
||||||
maximum_translation_jump_m
|
|
||||||
)
|
|
||||||
self.relative_rotation_scale_rad = float(
|
|
||||||
relative_rotation_scale_rad
|
|
||||||
)
|
|
||||||
self.relative_translation_scale_m = float(
|
|
||||||
relative_translation_scale_m
|
|
||||||
)
|
|
||||||
self.reprojection_scale_px = float(reprojection_scale_px)
|
|
||||||
self.reprojection_weight = float(reprojection_weight)
|
|
||||||
reset_seconds = float(reset_after_seconds)
|
|
||||||
if min(
|
|
||||||
self.maximum_pose_jump_rad,
|
|
||||||
self.maximum_translation_jump_m,
|
|
||||||
self.relative_rotation_scale_rad,
|
|
||||||
self.relative_translation_scale_m,
|
|
||||||
self.reprojection_scale_px,
|
|
||||||
reset_seconds,
|
|
||||||
) <= 0.0:
|
|
||||||
raise ValueError("group tracking scales must be positive")
|
|
||||||
if self.reprojection_weight < 0.0:
|
|
||||||
raise ValueError("reprojection_weight must be non-negative")
|
|
||||||
self.reset_after_ns = int(reset_seconds * 1_000_000_000)
|
|
||||||
self._previous: dict[str, SquareTagPose] = {}
|
|
||||||
self._previous_stamp_ns: int | None = None
|
|
||||||
self.branch_correction_counts: dict[str, int] = {}
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
self._previous.clear()
|
|
||||||
self._previous_stamp_ns = None
|
|
||||||
self.branch_correction_counts.clear()
|
|
||||||
|
|
||||||
def select(
|
|
||||||
self,
|
|
||||||
candidates_by_role: Mapping[str, Sequence[SquareTagPose]],
|
|
||||||
*,
|
|
||||||
stamp_ns: int,
|
|
||||||
) -> tuple[dict[str, SquareTagPose] | None, str]:
|
|
||||||
"""Return one mutually consistent pose for every configured role."""
|
|
||||||
candidate_lists = [
|
|
||||||
tuple(candidates_by_role.get(role, ()))
|
|
||||||
for role in self.roles
|
|
||||||
]
|
|
||||||
if any(not candidates for candidates in candidate_lists):
|
|
||||||
return None, "group_missing_pose_candidates"
|
|
||||||
|
|
||||||
combinations = [
|
|
||||||
dict(zip(self.roles, combination))
|
|
||||||
for combination in product(*candidate_lists)
|
|
||||||
]
|
|
||||||
minimum_errors = {
|
|
||||||
role: min(
|
|
||||||
candidate.reprojection_error_px
|
|
||||||
for candidate in candidates
|
|
||||||
)
|
|
||||||
for role, candidates in zip(self.roles, candidate_lists)
|
|
||||||
}
|
|
||||||
stamp = int(stamp_ns)
|
|
||||||
previous_is_fresh = (
|
|
||||||
self._previous_stamp_ns is not None
|
|
||||||
and 0 <= stamp - self._previous_stamp_ns
|
|
||||||
<= self.reset_after_ns
|
|
||||||
and set(self._previous) == set(self.roles)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not previous_is_fresh:
|
|
||||||
selected = min(
|
|
||||||
combinations,
|
|
||||||
key=lambda combination: sum(
|
|
||||||
pose.reprojection_error_px
|
|
||||||
for pose in combination.values()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
previous_relative = {
|
|
||||||
pair: _relative_pose(
|
|
||||||
self._previous[pair[0]],
|
|
||||||
self._previous[pair[1]],
|
|
||||||
)
|
|
||||||
for pair in self.adjacent_pairs
|
|
||||||
}
|
|
||||||
scored: list[tuple[float, dict[str, SquareTagPose]]] = []
|
|
||||||
for combination in combinations:
|
|
||||||
absolute_rotation_motion = 0.0
|
|
||||||
absolute_translation_motion = 0.0
|
|
||||||
rejected = False
|
|
||||||
for role in self.roles:
|
|
||||||
rotation_motion = rotation_distance_rad(
|
|
||||||
self._previous[role].quaternion_xyzw,
|
|
||||||
combination[role].quaternion_xyzw,
|
|
||||||
)
|
|
||||||
translation_motion = float(
|
|
||||||
np.linalg.norm(
|
|
||||||
np.asarray(
|
|
||||||
combination[role].translation_xyz_m,
|
|
||||||
dtype=float,
|
|
||||||
)
|
|
||||||
- np.asarray(
|
|
||||||
self._previous[role].translation_xyz_m,
|
|
||||||
dtype=float,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
rotation_motion > self.maximum_pose_jump_rad
|
|
||||||
or translation_motion
|
|
||||||
> self.maximum_translation_jump_m
|
|
||||||
):
|
|
||||||
rejected = True
|
|
||||||
break
|
|
||||||
absolute_rotation_motion += rotation_motion
|
|
||||||
absolute_translation_motion += translation_motion
|
|
||||||
if rejected:
|
|
||||||
continue
|
|
||||||
|
|
||||||
relative_rotation_motion = 0.0
|
|
||||||
relative_translation_motion = 0.0
|
|
||||||
for pair in self.adjacent_pairs:
|
|
||||||
rotation, translation = _relative_pose(
|
|
||||||
combination[pair[0]],
|
|
||||||
combination[pair[1]],
|
|
||||||
)
|
|
||||||
old_rotation, old_translation = previous_relative[pair]
|
|
||||||
relative_rotation_motion += float(
|
|
||||||
(old_rotation.inv() * rotation).magnitude()
|
|
||||||
)
|
|
||||||
relative_translation_motion += float(
|
|
||||||
np.linalg.norm(translation - old_translation)
|
|
||||||
)
|
|
||||||
|
|
||||||
reprojection_penalty = sum(
|
|
||||||
max(
|
|
||||||
0.0,
|
|
||||||
combination[role].reprojection_error_px
|
|
||||||
- minimum_errors[role],
|
|
||||||
)
|
|
||||||
for role in self.roles
|
|
||||||
) / self.reprojection_scale_px
|
|
||||||
score = (
|
|
||||||
absolute_rotation_motion
|
|
||||||
/ self.maximum_pose_jump_rad
|
|
||||||
+ absolute_translation_motion
|
|
||||||
/ self.maximum_translation_jump_m
|
|
||||||
+ relative_rotation_motion
|
|
||||||
/ self.relative_rotation_scale_rad
|
|
||||||
+ relative_translation_motion
|
|
||||||
/ self.relative_translation_scale_m
|
|
||||||
+ self.reprojection_weight * reprojection_penalty
|
|
||||||
)
|
|
||||||
scored.append((float(score), combination))
|
|
||||||
|
|
||||||
if not scored:
|
|
||||||
return None, "group_pose_jump"
|
|
||||||
selected = min(scored, key=lambda item: item[0])[1]
|
|
||||||
|
|
||||||
aligned: dict[str, SquareTagPose] = {}
|
|
||||||
for role in self.roles:
|
|
||||||
pose = selected[role]
|
|
||||||
if previous_is_fresh:
|
|
||||||
old_quaternion = np.asarray(
|
|
||||||
self._previous[role].quaternion_xyzw,
|
|
||||||
dtype=float,
|
|
||||||
)
|
|
||||||
quaternion = np.asarray(
|
|
||||||
pose.quaternion_xyzw,
|
|
||||||
dtype=float,
|
|
||||||
)
|
|
||||||
if float(np.dot(old_quaternion, quaternion)) < 0.0:
|
|
||||||
pose = replace(
|
|
||||||
pose,
|
|
||||||
quaternion_xyzw=tuple(
|
|
||||||
float(value) for value in -quaternion
|
|
||||||
),
|
|
||||||
)
|
|
||||||
best_reprojection = min(
|
|
||||||
candidate_lists[self.roles.index(role)],
|
|
||||||
key=lambda candidate: candidate.reprojection_error_px,
|
|
||||||
)
|
|
||||||
if pose != best_reprojection:
|
|
||||||
self.branch_correction_counts[role] = (
|
|
||||||
self.branch_correction_counts.get(role, 0) + 1
|
|
||||||
)
|
|
||||||
aligned[role] = pose
|
|
||||||
|
|
||||||
self._previous = aligned
|
|
||||||
self._previous_stamp_ns = stamp
|
|
||||||
return dict(aligned), ""
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,238 +0,0 @@
|
|||||||
"""Launch front-camera trajectory-circle CMC pitch zero measurement."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
from ament_index_python.packages import get_package_share_directory
|
|
||||||
from launch import LaunchDescription
|
|
||||||
from launch.actions import (
|
|
||||||
DeclareLaunchArgument,
|
|
||||||
IncludeLaunchDescription,
|
|
||||||
LogInfo,
|
|
||||||
OpaqueFunction,
|
|
||||||
)
|
|
||||||
from launch.conditions import IfCondition
|
|
||||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
|
||||||
from launch.substitutions import LaunchConfiguration
|
|
||||||
from launch_ros.actions import ComposableNodeContainer, Node
|
|
||||||
from launch_ros.descriptions import ComposableNode
|
|
||||||
from launch_ros.parameter_descriptions import ParameterValue
|
|
||||||
|
|
||||||
|
|
||||||
def _launch_stack(context):
|
|
||||||
serial_number = LaunchConfiguration("serial_number").perform(context)
|
|
||||||
if (
|
|
||||||
not serial_number
|
|
||||||
or serial_number == "UNSET"
|
|
||||||
or re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None
|
|
||||||
or serial_number in {".", ".."}
|
|
||||||
):
|
|
||||||
raise RuntimeError(
|
|
||||||
"serial_number is required and may contain only letters, "
|
|
||||||
"digits, dot, underscore and dash"
|
|
||||||
)
|
|
||||||
|
|
||||||
requested_session = LaunchConfiguration("session_dir").perform(context)
|
|
||||||
output_root = Path(
|
|
||||||
LaunchConfiguration("output_root").perform(context)
|
|
||||||
).expanduser().resolve()
|
|
||||||
if requested_session:
|
|
||||||
session_dir = Path(requested_session).expanduser().resolve()
|
|
||||||
else:
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
session_dir = output_root / serial_number / timestamp
|
|
||||||
session_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
tag_config = LaunchConfiguration("tag_config").perform(context)
|
|
||||||
zero_config = LaunchConfiguration("zero_config").perform(context)
|
|
||||||
realsense_config = LaunchConfiguration("realsense_config").perform(context)
|
|
||||||
realsense_launch = (
|
|
||||||
Path(get_package_share_directory("realsense2_camera"))
|
|
||||||
/ "launch"
|
|
||||||
/ "rs_launch.py"
|
|
||||||
)
|
|
||||||
|
|
||||||
camera = IncludeLaunchDescription(
|
|
||||||
PythonLaunchDescriptionSource(str(realsense_launch)),
|
|
||||||
condition=IfCondition(LaunchConfiguration("start_camera")),
|
|
||||||
launch_arguments={
|
|
||||||
"camera_name": "camera",
|
|
||||||
"camera_namespace": "camera",
|
|
||||||
"serial_no": LaunchConfiguration("camera_serial_number"),
|
|
||||||
"config_file": realsense_config,
|
|
||||||
"enable_color": "true",
|
|
||||||
"rgb_camera.color_profile": LaunchConfiguration("color_profile"),
|
|
||||||
"depth_module.color_profile": LaunchConfiguration("color_profile"),
|
|
||||||
"enable_depth": "false",
|
|
||||||
"enable_sync": "false",
|
|
||||||
"align_depth.enable": "false",
|
|
||||||
"diagnostics_period": "1.0",
|
|
||||||
}.items(),
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_topic = "/camera/camera/color/image_raw"
|
|
||||||
camera_info_topic = "/camera/camera/color/camera_info"
|
|
||||||
rect_topic = "/camera/camera/color/image_rect"
|
|
||||||
vision_container = ComposableNodeContainer(
|
|
||||||
name="g20_thumb_zero_vision_container",
|
|
||||||
namespace="/",
|
|
||||||
package="rclcpp_components",
|
|
||||||
executable="component_container_mt",
|
|
||||||
composable_node_descriptions=[
|
|
||||||
ComposableNode(
|
|
||||||
package="image_proc",
|
|
||||||
plugin="image_proc::RectifyNode",
|
|
||||||
name="rectify_color",
|
|
||||||
namespace="/camera/camera/color",
|
|
||||||
remappings=[
|
|
||||||
("image", raw_topic),
|
|
||||||
("camera_info", camera_info_topic),
|
|
||||||
("image_rect", rect_topic),
|
|
||||||
],
|
|
||||||
parameters=[{"queue_size": 1}],
|
|
||||||
extra_arguments=[{"use_intra_process_comms": True}],
|
|
||||||
),
|
|
||||||
ComposableNode(
|
|
||||||
package="apriltag_ros",
|
|
||||||
plugin="AprilTagNode",
|
|
||||||
name="apriltag",
|
|
||||||
namespace="/apriltag",
|
|
||||||
parameters=[
|
|
||||||
tag_config,
|
|
||||||
{
|
|
||||||
"detector.decimate": ParameterValue(
|
|
||||||
LaunchConfiguration("apriltag_decimate"),
|
|
||||||
value_type=float,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
],
|
|
||||||
remappings=[
|
|
||||||
("image_rect", rect_topic),
|
|
||||||
("camera_info", camera_info_topic),
|
|
||||||
],
|
|
||||||
extra_arguments=[{"use_intra_process_comms": True}],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
output="screen",
|
|
||||||
emulate_tty=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
sdk = Node(
|
|
||||||
package="linker_hand_ros2_sdk",
|
|
||||||
executable="linker_hand_sdk",
|
|
||||||
name="linker_hand_sdk",
|
|
||||||
output="screen",
|
|
||||||
condition=IfCondition(LaunchConfiguration("start_sdk")),
|
|
||||||
parameters=[
|
|
||||||
{
|
|
||||||
"hand_type": "left",
|
|
||||||
"hand_joint": "G20",
|
|
||||||
"can": LaunchConfiguration("can_interface"),
|
|
||||||
"modbus": "None",
|
|
||||||
"topic_prefix": "/g20",
|
|
||||||
"move_on_startup": False,
|
|
||||||
"startup_speed": ParameterValue(
|
|
||||||
LaunchConfiguration("calibration_speed"),
|
|
||||||
value_type=int,
|
|
||||||
),
|
|
||||||
"startup_torque": 80,
|
|
||||||
"state_poll_rate": 10.0,
|
|
||||||
"repeat_position_commands": False,
|
|
||||||
"is_touch": False,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
zero_node = Node(
|
|
||||||
package="g20_thumb_apriltag_calibration",
|
|
||||||
executable="cmc_pitch_zero_node",
|
|
||||||
name="g20_thumb_cmc_pitch_zero",
|
|
||||||
output="screen",
|
|
||||||
parameters=[
|
|
||||||
zero_config,
|
|
||||||
{
|
|
||||||
"serial_number": serial_number,
|
|
||||||
"session_dir": str(session_dir),
|
|
||||||
"commands_enabled": ParameterValue(
|
|
||||||
LaunchConfiguration("commands_enabled"),
|
|
||||||
value_type=bool,
|
|
||||||
),
|
|
||||||
"image_topic": rect_topic,
|
|
||||||
"publish_debug_image": ParameterValue(
|
|
||||||
LaunchConfiguration("publish_debug_image"),
|
|
||||||
value_type=bool,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
return [
|
|
||||||
LogInfo(msg=f"G20 CMC pitch zero session: {session_dir}"),
|
|
||||||
LogInfo(
|
|
||||||
msg=(
|
|
||||||
"Only T0(ID 0) and T3(ID 1) are required; "
|
|
||||||
"T4/T5 detections are ignored"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
LogInfo(
|
|
||||||
msg=(
|
|
||||||
"Motor 0 performs three 255->64->255 sweeps; "
|
|
||||||
"zero angles come from the fitted T3-centre trajectory radius"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
camera,
|
|
||||||
vision_container,
|
|
||||||
sdk,
|
|
||||||
zero_node,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def generate_launch_description() -> LaunchDescription:
|
|
||||||
package_share = Path(
|
|
||||||
get_package_share_directory("g20_thumb_apriltag_calibration")
|
|
||||||
)
|
|
||||||
return LaunchDescription(
|
|
||||||
[
|
|
||||||
DeclareLaunchArgument("serial_number", default_value="UNSET"),
|
|
||||||
DeclareLaunchArgument("camera_serial_number", default_value=""),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"color_profile", default_value="1280x720x30"
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument("can_interface", default_value="can0"),
|
|
||||||
DeclareLaunchArgument("calibration_speed", default_value="15"),
|
|
||||||
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
|
|
||||||
DeclareLaunchArgument("commands_enabled", default_value="true"),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"publish_debug_image", default_value="true"
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument("start_camera", default_value="true"),
|
|
||||||
DeclareLaunchArgument("start_sdk", default_value="true"),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"output_root",
|
|
||||||
default_value=str(Path.cwd() / "calibration_output"),
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument("session_dir", default_value=""),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"zero_config",
|
|
||||||
default_value=str(
|
|
||||||
package_share / "config" / "cmc_pitch_zero.yaml"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"realsense_config",
|
|
||||||
default_value=str(
|
|
||||||
package_share / "config" / "realsense_color_qos.yaml"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"tag_config",
|
|
||||||
default_value=str(
|
|
||||||
package_share / "config" / "front_tags.yaml"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
OpaqueFunction(function=_launch_stack),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
"""Launch front-camera trajectory-circle CMC roll zero/travel calibration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
from ament_index_python.packages import get_package_share_directory
|
|
||||||
from launch import LaunchDescription
|
|
||||||
from launch.actions import (
|
|
||||||
DeclareLaunchArgument,
|
|
||||||
IncludeLaunchDescription,
|
|
||||||
LogInfo,
|
|
||||||
OpaqueFunction,
|
|
||||||
)
|
|
||||||
from launch.conditions import IfCondition
|
|
||||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
|
||||||
from launch.substitutions import LaunchConfiguration
|
|
||||||
from launch_ros.actions import ComposableNodeContainer, Node
|
|
||||||
from launch_ros.descriptions import ComposableNode
|
|
||||||
from launch_ros.parameter_descriptions import ParameterValue
|
|
||||||
|
|
||||||
|
|
||||||
def _launch_stack(context):
|
|
||||||
serial_number = LaunchConfiguration("serial_number").perform(context)
|
|
||||||
if (
|
|
||||||
not serial_number
|
|
||||||
or serial_number == "UNSET"
|
|
||||||
or re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None
|
|
||||||
or serial_number in {".", ".."}
|
|
||||||
):
|
|
||||||
raise RuntimeError(
|
|
||||||
"serial_number is required and may contain only letters, "
|
|
||||||
"digits, dot, underscore and dash"
|
|
||||||
)
|
|
||||||
|
|
||||||
requested_session = LaunchConfiguration("session_dir").perform(context)
|
|
||||||
output_root = Path(
|
|
||||||
LaunchConfiguration("output_root").perform(context)
|
|
||||||
).expanduser().resolve()
|
|
||||||
if requested_session:
|
|
||||||
session_dir = Path(requested_session).expanduser().resolve()
|
|
||||||
else:
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
session_dir = output_root / serial_number / timestamp
|
|
||||||
session_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
tag_config = LaunchConfiguration("tag_config").perform(context)
|
|
||||||
calibration_config = LaunchConfiguration(
|
|
||||||
"calibration_config"
|
|
||||||
).perform(context)
|
|
||||||
realsense_config = LaunchConfiguration("realsense_config").perform(context)
|
|
||||||
realsense_launch = (
|
|
||||||
Path(get_package_share_directory("realsense2_camera"))
|
|
||||||
/ "launch"
|
|
||||||
/ "rs_launch.py"
|
|
||||||
)
|
|
||||||
|
|
||||||
camera = IncludeLaunchDescription(
|
|
||||||
PythonLaunchDescriptionSource(str(realsense_launch)),
|
|
||||||
condition=IfCondition(LaunchConfiguration("start_camera")),
|
|
||||||
launch_arguments={
|
|
||||||
"camera_name": "camera",
|
|
||||||
"camera_namespace": "camera",
|
|
||||||
"serial_no": LaunchConfiguration("camera_serial_number"),
|
|
||||||
"config_file": realsense_config,
|
|
||||||
"enable_color": "true",
|
|
||||||
"rgb_camera.color_profile": LaunchConfiguration("color_profile"),
|
|
||||||
"depth_module.color_profile": LaunchConfiguration("color_profile"),
|
|
||||||
"enable_depth": "false",
|
|
||||||
"enable_sync": "false",
|
|
||||||
"align_depth.enable": "false",
|
|
||||||
"diagnostics_period": "1.0",
|
|
||||||
}.items(),
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_topic = "/camera/camera/color/image_raw"
|
|
||||||
camera_info_topic = "/camera/camera/color/camera_info"
|
|
||||||
rect_topic = "/camera/camera/color/image_rect"
|
|
||||||
vision_container = ComposableNodeContainer(
|
|
||||||
name="g20_thumb_roll_vision_container",
|
|
||||||
namespace="/",
|
|
||||||
package="rclcpp_components",
|
|
||||||
executable="component_container_mt",
|
|
||||||
composable_node_descriptions=[
|
|
||||||
ComposableNode(
|
|
||||||
package="image_proc",
|
|
||||||
plugin="image_proc::RectifyNode",
|
|
||||||
name="rectify_color",
|
|
||||||
namespace="/camera/camera/color",
|
|
||||||
remappings=[
|
|
||||||
("image", raw_topic),
|
|
||||||
("camera_info", camera_info_topic),
|
|
||||||
("image_rect", rect_topic),
|
|
||||||
],
|
|
||||||
parameters=[{"queue_size": 1}],
|
|
||||||
extra_arguments=[{"use_intra_process_comms": True}],
|
|
||||||
),
|
|
||||||
ComposableNode(
|
|
||||||
package="apriltag_ros",
|
|
||||||
plugin="AprilTagNode",
|
|
||||||
name="apriltag",
|
|
||||||
namespace="/apriltag",
|
|
||||||
parameters=[
|
|
||||||
tag_config,
|
|
||||||
{
|
|
||||||
"detector.decimate": ParameterValue(
|
|
||||||
LaunchConfiguration("apriltag_decimate"),
|
|
||||||
value_type=float,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
],
|
|
||||||
remappings=[
|
|
||||||
("image_rect", rect_topic),
|
|
||||||
("camera_info", camera_info_topic),
|
|
||||||
],
|
|
||||||
extra_arguments=[{"use_intra_process_comms": True}],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
output="screen",
|
|
||||||
emulate_tty=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
sdk = Node(
|
|
||||||
package="linker_hand_ros2_sdk",
|
|
||||||
executable="linker_hand_sdk",
|
|
||||||
name="linker_hand_sdk",
|
|
||||||
output="screen",
|
|
||||||
condition=IfCondition(LaunchConfiguration("start_sdk")),
|
|
||||||
parameters=[
|
|
||||||
{
|
|
||||||
"hand_type": "left",
|
|
||||||
"hand_joint": "G20",
|
|
||||||
"can": LaunchConfiguration("can_interface"),
|
|
||||||
"modbus": "None",
|
|
||||||
"topic_prefix": "/g20",
|
|
||||||
"move_on_startup": False,
|
|
||||||
"startup_speed": ParameterValue(
|
|
||||||
LaunchConfiguration("calibration_speed"),
|
|
||||||
value_type=int,
|
|
||||||
),
|
|
||||||
"startup_torque": 80,
|
|
||||||
"state_poll_rate": 10.0,
|
|
||||||
"repeat_position_commands": False,
|
|
||||||
"is_touch": False,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
calibration_node = Node(
|
|
||||||
package="g20_thumb_apriltag_calibration",
|
|
||||||
executable="cmc_roll_calibration_node",
|
|
||||||
name="g20_thumb_cmc_roll_calibration",
|
|
||||||
output="screen",
|
|
||||||
parameters=[
|
|
||||||
calibration_config,
|
|
||||||
{
|
|
||||||
"serial_number": serial_number,
|
|
||||||
"session_dir": str(session_dir),
|
|
||||||
"commands_enabled": ParameterValue(
|
|
||||||
LaunchConfiguration("commands_enabled"),
|
|
||||||
value_type=bool,
|
|
||||||
),
|
|
||||||
"image_topic": rect_topic,
|
|
||||||
"publish_debug_image": ParameterValue(
|
|
||||||
LaunchConfiguration("publish_debug_image"),
|
|
||||||
value_type=bool,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
return [
|
|
||||||
LogInfo(msg=f"G20 CMC roll calibration session: {session_dir}"),
|
|
||||||
LogInfo(
|
|
||||||
msg=(
|
|
||||||
"Only T0(ID 0) and T3(ID 1) are required; "
|
|
||||||
"T4/T5 detections are ignored"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
LogInfo(
|
|
||||||
msg=(
|
|
||||||
"Motor 5 performs three 255->0->255 sweeps; "
|
|
||||||
"static captures measure both zero and angular travel"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
camera,
|
|
||||||
vision_container,
|
|
||||||
sdk,
|
|
||||||
calibration_node,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def generate_launch_description() -> LaunchDescription:
|
|
||||||
package_share = Path(
|
|
||||||
get_package_share_directory("g20_thumb_apriltag_calibration")
|
|
||||||
)
|
|
||||||
return LaunchDescription(
|
|
||||||
[
|
|
||||||
DeclareLaunchArgument("serial_number", default_value="UNSET"),
|
|
||||||
DeclareLaunchArgument("camera_serial_number", default_value=""),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"color_profile", default_value="1280x720x30"
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument("can_interface", default_value="can0"),
|
|
||||||
DeclareLaunchArgument("calibration_speed", default_value="15"),
|
|
||||||
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
|
|
||||||
DeclareLaunchArgument("commands_enabled", default_value="true"),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"publish_debug_image", default_value="true"
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument("start_camera", default_value="true"),
|
|
||||||
DeclareLaunchArgument("start_sdk", default_value="true"),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"output_root",
|
|
||||||
default_value=str(Path.cwd() / "calibration_output"),
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument("session_dir", default_value=""),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"calibration_config",
|
|
||||||
default_value=str(
|
|
||||||
package_share
|
|
||||||
/ "config"
|
|
||||||
/ "cmc_roll_zero_travel.yaml"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"realsense_config",
|
|
||||||
default_value=str(
|
|
||||||
package_share / "config" / "realsense_color_qos.yaml"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"tag_config",
|
|
||||||
default_value=str(
|
|
||||||
package_share / "config" / "front_tags.yaml"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
OpaqueFunction(function=_launch_stack),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
"""Launch the complete front-camera G20 thumb calibration stack."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
from ament_index_python.packages import get_package_share_directory
|
|
||||||
from launch import LaunchDescription
|
|
||||||
from launch.actions import (
|
|
||||||
DeclareLaunchArgument,
|
|
||||||
ExecuteProcess,
|
|
||||||
IncludeLaunchDescription,
|
|
||||||
LogInfo,
|
|
||||||
OpaqueFunction,
|
|
||||||
)
|
|
||||||
from launch.conditions import IfCondition
|
|
||||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
|
||||||
from launch.substitutions import LaunchConfiguration
|
|
||||||
from launch_ros.actions import ComposableNodeContainer, Node
|
|
||||||
from launch_ros.descriptions import ComposableNode
|
|
||||||
from launch_ros.parameter_descriptions import ParameterValue
|
|
||||||
|
|
||||||
|
|
||||||
def _launch_stack(context):
|
|
||||||
serial_number = LaunchConfiguration("serial_number").perform(context)
|
|
||||||
if not serial_number or serial_number == "UNSET":
|
|
||||||
raise RuntimeError(
|
|
||||||
"serial_number is required, for example serial_number:=G20_LEFT_001"
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None
|
|
||||||
or serial_number in {".", ".."}
|
|
||||||
):
|
|
||||||
raise RuntimeError(
|
|
||||||
"serial_number may contain only letters, digits, dot, underscore and dash"
|
|
||||||
)
|
|
||||||
requested_session = LaunchConfiguration("session_dir").perform(context)
|
|
||||||
output_root = Path(LaunchConfiguration("output_root").perform(context)).resolve()
|
|
||||||
if requested_session:
|
|
||||||
session_dir = Path(requested_session).expanduser().resolve()
|
|
||||||
else:
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
session_dir = output_root / serial_number / timestamp
|
|
||||||
session_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
bag_path = session_dir / "rosbag"
|
|
||||||
|
|
||||||
calibration_config = LaunchConfiguration("calibration_config").perform(context)
|
|
||||||
tag_config = LaunchConfiguration("tag_config").perform(context)
|
|
||||||
realsense_config = LaunchConfiguration("realsense_config").perform(context)
|
|
||||||
use_roi_text = LaunchConfiguration("use_roi").perform(context).strip().lower()
|
|
||||||
if use_roi_text not in {"true", "false"}:
|
|
||||||
raise RuntimeError("use_roi must be true or false")
|
|
||||||
use_roi = use_roi_text == "true"
|
|
||||||
|
|
||||||
roi_values = {}
|
|
||||||
for name in ("roi_x", "roi_y", "roi_width", "roi_height"):
|
|
||||||
text = LaunchConfiguration(name).perform(context)
|
|
||||||
try:
|
|
||||||
roi_values[name] = int(text)
|
|
||||||
except ValueError as error:
|
|
||||||
raise RuntimeError(f"{name} must be an integer") from error
|
|
||||||
if roi_values["roi_x"] < 0 or roi_values["roi_y"] < 0:
|
|
||||||
raise RuntimeError("roi_x and roi_y must be non-negative")
|
|
||||||
if roi_values["roi_width"] <= 0 or roi_values["roi_height"] <= 0:
|
|
||||||
raise RuntimeError("roi_width and roi_height must be positive")
|
|
||||||
|
|
||||||
color_profile = LaunchConfiguration("color_profile").perform(context)
|
|
||||||
profile_match = re.fullmatch(r"(\d+)x(\d+)x(\d+)", color_profile)
|
|
||||||
if use_roi and profile_match is not None:
|
|
||||||
image_width = int(profile_match.group(1))
|
|
||||||
image_height = int(profile_match.group(2))
|
|
||||||
if (
|
|
||||||
roi_values["roi_x"] + roi_values["roi_width"] > image_width
|
|
||||||
or roi_values["roi_y"] + roi_values["roi_height"] > image_height
|
|
||||||
):
|
|
||||||
raise RuntimeError(
|
|
||||||
"ROI lies outside color_profile "
|
|
||||||
f"{image_width}x{image_height}"
|
|
||||||
)
|
|
||||||
|
|
||||||
realsense_launch = Path(
|
|
||||||
get_package_share_directory("realsense2_camera")
|
|
||||||
) / "launch" / "rs_launch.py"
|
|
||||||
|
|
||||||
camera = IncludeLaunchDescription(
|
|
||||||
PythonLaunchDescriptionSource(str(realsense_launch)),
|
|
||||||
condition=IfCondition(LaunchConfiguration("start_camera")),
|
|
||||||
launch_arguments={
|
|
||||||
"camera_name": "camera",
|
|
||||||
"camera_namespace": "camera",
|
|
||||||
"serial_no": LaunchConfiguration("camera_serial_number"),
|
|
||||||
"config_file": realsense_config,
|
|
||||||
"enable_color": "true",
|
|
||||||
# D405 exposes color from the stereo/depth module. Keep the RGB
|
|
||||||
# camera argument as well so the same launch file also works with
|
|
||||||
# D435/D455-class devices.
|
|
||||||
"rgb_camera.color_profile": LaunchConfiguration("color_profile"),
|
|
||||||
"depth_module.color_profile": LaunchConfiguration("color_profile"),
|
|
||||||
"enable_depth": LaunchConfiguration("enable_depth"),
|
|
||||||
"depth_module.depth_profile": LaunchConfiguration("depth_profile"),
|
|
||||||
# Depth is archival/diagnostic only. Synchronising and aligning it
|
|
||||||
# adds significant D405 processing latency without affecting the
|
|
||||||
# relative AprilTag rotation calculation.
|
|
||||||
"enable_sync": "false",
|
|
||||||
"align_depth.enable": "false",
|
|
||||||
"diagnostics_period": "1.0",
|
|
||||||
}.items(),
|
|
||||||
)
|
|
||||||
|
|
||||||
vision_components = []
|
|
||||||
if use_roi:
|
|
||||||
processed_image_raw_topic = "/g20_thumb_roi/image_raw"
|
|
||||||
processed_camera_info_topic = "/g20_thumb_roi/camera_info"
|
|
||||||
processed_image_rect_topic = "/g20_thumb_roi/image_rect"
|
|
||||||
vision_components.append(
|
|
||||||
ComposableNode(
|
|
||||||
package="image_proc",
|
|
||||||
plugin="image_proc::CropDecimateNode",
|
|
||||||
name="crop_color_roi",
|
|
||||||
namespace="/g20_thumb_roi",
|
|
||||||
remappings=[
|
|
||||||
("in/image_raw", "/camera/camera/color/image_raw"),
|
|
||||||
("in/camera_info", "/camera/camera/color/camera_info"),
|
|
||||||
("out/image_raw", processed_image_raw_topic),
|
|
||||||
("out/camera_info", processed_camera_info_topic),
|
|
||||||
],
|
|
||||||
parameters=[
|
|
||||||
{
|
|
||||||
"queue_size": 5,
|
|
||||||
"decimation_x": 1,
|
|
||||||
"decimation_y": 1,
|
|
||||||
"offset_x": roi_values["roi_x"],
|
|
||||||
"offset_y": roi_values["roi_y"],
|
|
||||||
"width": roi_values["roi_width"],
|
|
||||||
"height": roi_values["roi_height"],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
extra_arguments=[{"use_intra_process_comms": True}],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
rectifier_namespace = "/g20_thumb_roi"
|
|
||||||
rectifier_name = "rectify_color_roi"
|
|
||||||
else:
|
|
||||||
processed_image_raw_topic = "/camera/camera/color/image_raw"
|
|
||||||
processed_camera_info_topic = "/camera/camera/color/camera_info"
|
|
||||||
processed_image_rect_topic = "/camera/camera/color/image_rect"
|
|
||||||
rectifier_namespace = "/camera/camera/color"
|
|
||||||
rectifier_name = "rectify_color"
|
|
||||||
|
|
||||||
vision_components.append(
|
|
||||||
ComposableNode(
|
|
||||||
package="image_proc",
|
|
||||||
plugin="image_proc::RectifyNode",
|
|
||||||
name=rectifier_name,
|
|
||||||
namespace=rectifier_namespace,
|
|
||||||
remappings=[
|
|
||||||
("image", processed_image_raw_topic),
|
|
||||||
("camera_info", processed_camera_info_topic),
|
|
||||||
("image_rect", processed_image_rect_topic),
|
|
||||||
],
|
|
||||||
parameters=[{"queue_size": 1}],
|
|
||||||
extra_arguments=[{"use_intra_process_comms": True}],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
vision_components.append(
|
|
||||||
ComposableNode(
|
|
||||||
package="apriltag_ros",
|
|
||||||
plugin="AprilTagNode",
|
|
||||||
name="apriltag",
|
|
||||||
namespace="/apriltag",
|
|
||||||
parameters=[
|
|
||||||
tag_config,
|
|
||||||
{
|
|
||||||
"detector.decimate": ParameterValue(
|
|
||||||
LaunchConfiguration("apriltag_decimate"),
|
|
||||||
value_type=float,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
],
|
|
||||||
remappings=[
|
|
||||||
("image_rect", processed_image_rect_topic),
|
|
||||||
("camera_info", processed_camera_info_topic),
|
|
||||||
],
|
|
||||||
extra_arguments=[{"use_intra_process_comms": True}],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
vision_container = ComposableNodeContainer(
|
|
||||||
name="g20_thumb_vision_container",
|
|
||||||
namespace="/",
|
|
||||||
package="rclcpp_components",
|
|
||||||
executable="component_container_mt",
|
|
||||||
composable_node_descriptions=vision_components,
|
|
||||||
output="screen",
|
|
||||||
emulate_tty=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
sdk = Node(
|
|
||||||
package="linker_hand_ros2_sdk",
|
|
||||||
executable="linker_hand_sdk",
|
|
||||||
name="linker_hand_sdk",
|
|
||||||
output="screen",
|
|
||||||
condition=IfCondition(LaunchConfiguration("start_sdk")),
|
|
||||||
parameters=[
|
|
||||||
{
|
|
||||||
"hand_type": "left",
|
|
||||||
"hand_joint": "G20",
|
|
||||||
"can": LaunchConfiguration("can_interface"),
|
|
||||||
"modbus": "None",
|
|
||||||
"topic_prefix": "/g20",
|
|
||||||
"move_on_startup": False,
|
|
||||||
"startup_speed": ParameterValue(
|
|
||||||
LaunchConfiguration("calibration_speed"),
|
|
||||||
value_type=int,
|
|
||||||
),
|
|
||||||
"startup_torque": 80,
|
|
||||||
"state_poll_rate": 10.0,
|
|
||||||
"repeat_position_commands": False,
|
|
||||||
"is_touch": False,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
calibration = Node(
|
|
||||||
package="g20_thumb_apriltag_calibration",
|
|
||||||
executable="calibration_node",
|
|
||||||
name="g20_thumb_calibration",
|
|
||||||
output="screen",
|
|
||||||
parameters=[
|
|
||||||
calibration_config,
|
|
||||||
tag_config,
|
|
||||||
{
|
|
||||||
"serial_number": serial_number,
|
|
||||||
"session_dir": str(session_dir),
|
|
||||||
"commands_enabled": LaunchConfiguration("commands_enabled"),
|
|
||||||
"calibration_speed": ParameterValue(
|
|
||||||
LaunchConfiguration("calibration_speed"),
|
|
||||||
value_type=int,
|
|
||||||
),
|
|
||||||
"continuous_motion_mode": ParameterValue(
|
|
||||||
LaunchConfiguration("continuous_motion_mode"),
|
|
||||||
value_type=str,
|
|
||||||
),
|
|
||||||
"angle_estimation_mode": ParameterValue(
|
|
||||||
LaunchConfiguration("angle_estimation_mode"),
|
|
||||||
value_type=str,
|
|
||||||
),
|
|
||||||
"camera_serial_number": LaunchConfiguration("camera_serial_number"),
|
|
||||||
"rosbag_path": str(bag_path),
|
|
||||||
"camera_info_topic": processed_camera_info_topic,
|
|
||||||
"image_topic": processed_image_rect_topic,
|
|
||||||
"publish_debug_image": LaunchConfiguration(
|
|
||||||
"publish_debug_image"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
bag = ExecuteProcess(
|
|
||||||
condition=IfCondition(LaunchConfiguration("record_bag")),
|
|
||||||
cmd=[
|
|
||||||
"ros2",
|
|
||||||
"bag",
|
|
||||||
"record",
|
|
||||||
"--storage",
|
|
||||||
"mcap",
|
|
||||||
"--storage-preset-profile",
|
|
||||||
"zstd_fast",
|
|
||||||
"--max-bag-size",
|
|
||||||
"10737418240",
|
|
||||||
"--output",
|
|
||||||
str(bag_path),
|
|
||||||
processed_image_raw_topic,
|
|
||||||
processed_camera_info_topic,
|
|
||||||
"/camera/camera/depth/image_rect_raw",
|
|
||||||
"/apriltag/detections",
|
|
||||||
"/tf",
|
|
||||||
"/g20/cb_left_hand_control_cmd",
|
|
||||||
"/g20/cb_left_hand_state",
|
|
||||||
"/g20/cb_left_hand_info",
|
|
||||||
"/g20_thumb_calibration/status",
|
|
||||||
],
|
|
||||||
output="screen",
|
|
||||||
)
|
|
||||||
|
|
||||||
actions = [
|
|
||||||
LogInfo(msg=f"G20 thumb calibration session: {session_dir}"),
|
|
||||||
LogInfo(
|
|
||||||
msg=(
|
|
||||||
"G20 thumb image ROI: "
|
|
||||||
f"x={roi_values['roi_x']}, y={roi_values['roi_y']}, "
|
|
||||||
f"width={roi_values['roi_width']}, "
|
|
||||||
f"height={roi_values['roi_height']}"
|
|
||||||
if use_roi
|
|
||||||
else "G20 thumb image ROI: disabled"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
camera,
|
|
||||||
vision_container,
|
|
||||||
]
|
|
||||||
actions.extend([sdk, calibration, bag])
|
|
||||||
return actions
|
|
||||||
|
|
||||||
|
|
||||||
def generate_launch_description() -> LaunchDescription:
|
|
||||||
package_share = Path(
|
|
||||||
get_package_share_directory("g20_thumb_apriltag_calibration")
|
|
||||||
)
|
|
||||||
default_output = str(Path.cwd() / "calibration_output")
|
|
||||||
return LaunchDescription(
|
|
||||||
[
|
|
||||||
DeclareLaunchArgument("serial_number", default_value="UNSET"),
|
|
||||||
DeclareLaunchArgument("camera_serial_number", default_value=""),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"color_profile", default_value="1280x720x30"
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"depth_profile", default_value="640x480x30"
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument("enable_depth", default_value="false"),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"publish_debug_image", default_value="false"
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument("use_roi", default_value="false"),
|
|
||||||
DeclareLaunchArgument("roi_x", default_value="128"),
|
|
||||||
DeclareLaunchArgument("roi_y", default_value="192"),
|
|
||||||
DeclareLaunchArgument("roi_width", default_value="1024"),
|
|
||||||
DeclareLaunchArgument("roi_height", default_value="528"),
|
|
||||||
DeclareLaunchArgument("can_interface", default_value="can0"),
|
|
||||||
DeclareLaunchArgument("calibration_speed", default_value="15"),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"continuous_motion_mode", default_value="endpoint"
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"angle_estimation_mode",
|
|
||||||
default_value="trajectory_center_3d",
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
|
|
||||||
DeclareLaunchArgument("commands_enabled", default_value="true"),
|
|
||||||
DeclareLaunchArgument("start_camera", default_value="true"),
|
|
||||||
DeclareLaunchArgument("start_sdk", default_value="true"),
|
|
||||||
DeclareLaunchArgument("record_bag", default_value="false"),
|
|
||||||
DeclareLaunchArgument("output_root", default_value=default_output),
|
|
||||||
DeclareLaunchArgument("session_dir", default_value=""),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"calibration_config",
|
|
||||||
default_value=str(package_share / "config" / "calibration.yaml"),
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"realsense_config",
|
|
||||||
default_value=str(
|
|
||||||
package_share / "config" / "realsense_color_qos.yaml"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
DeclareLaunchArgument(
|
|
||||||
"tag_config",
|
|
||||||
default_value=str(package_share / "config" / "front_tags.yaml"),
|
|
||||||
),
|
|
||||||
OpaqueFunction(function=_launch_stack),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
[develop]
|
|
||||||
script_dir=$base/lib/g20_thumb_apriltag_calibration
|
|
||||||
[install]
|
|
||||||
install_scripts=$base/lib/g20_thumb_apriltag_calibration
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
from glob import glob
|
|
||||||
|
|
||||||
from setuptools import find_packages, setup
|
|
||||||
|
|
||||||
|
|
||||||
package_name = "g20_thumb_apriltag_calibration"
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name=package_name,
|
|
||||||
version="0.1.0",
|
|
||||||
packages=find_packages(),
|
|
||||||
data_files=[
|
|
||||||
(
|
|
||||||
"share/ament_index/resource_index/packages",
|
|
||||||
["resource/" + package_name],
|
|
||||||
),
|
|
||||||
("share/" + package_name, ["package.xml", "README.md"]),
|
|
||||||
("share/" + package_name + "/config", glob("config/*.yaml")),
|
|
||||||
("share/" + package_name + "/launch", glob("launch/*.launch.py")),
|
|
||||||
],
|
|
||||||
install_requires=["setuptools", "numpy", "scipy"],
|
|
||||||
tests_require=["pytest"],
|
|
||||||
zip_safe=True,
|
|
||||||
maintainer="lxp",
|
|
||||||
maintainer_email="support@linker-robotics.com",
|
|
||||||
description="Front RealSense AprilTag calibration for the left G20 thumb",
|
|
||||||
license="MIT",
|
|
||||||
entry_points={
|
|
||||||
"console_scripts": [
|
|
||||||
"calibration_node = g20_thumb_apriltag_calibration.node:main",
|
|
||||||
(
|
|
||||||
"cmc_pitch_zero_node = "
|
|
||||||
"g20_thumb_apriltag_calibration.zero_node:main"
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"cmc_roll_calibration_node = "
|
|
||||||
"g20_thumb_apriltag_calibration.zero_node:main"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
|
|
||||||
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_front_tag_parameters_match_namespaced_detector() -> None:
|
|
||||||
config = yaml.safe_load(
|
|
||||||
(PACKAGE_ROOT / "config" / "front_tags.yaml").read_text()
|
|
||||||
)
|
|
||||||
|
|
||||||
detector = config["/apriltag/apriltag"]["ros__parameters"]
|
|
||||||
calibration = config["g20_thumb_calibration"]["ros__parameters"]
|
|
||||||
|
|
||||||
assert detector["tag"]["ids"] == calibration["tag_ids"]
|
|
||||||
assert detector["tag"]["frames"] == calibration["tag_frames"]
|
|
||||||
assert detector["tag"]["sizes"] == calibration["tag_sizes_m"]
|
|
||||||
assert detector["tag"]["ids"] == [0, 1, 2, 3]
|
|
||||||
assert detector["qos_profile"] == "sensor_data"
|
|
||||||
assert detector["detector"]["decimate"] == 1.5
|
|
||||||
assert detector["detector"]["refine"] is True
|
|
||||||
assert detector["detector"]["debug"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_trial_uses_centre_trajectory_and_thirty_pixel_tags() -> None:
|
|
||||||
config = yaml.safe_load(
|
|
||||||
(PACKAGE_ROOT / "config" / "calibration.yaml").read_text()
|
|
||||||
)
|
|
||||||
parameters = config["g20_thumb_calibration"]["ros__parameters"]
|
|
||||||
|
|
||||||
assert parameters["angle_estimation_mode"] == "trajectory_center_3d"
|
|
||||||
assert parameters["passive_ip_multiplier"] == 1.02
|
|
||||||
assert parameters["pnp_minimum_valid_rate"] == 0.95
|
|
||||||
assert parameters["pnp_maximum_reprojection_error_px"] <= 1.5
|
|
||||||
assert parameters["pnp_reprojection_tie_px"] == 1.5
|
|
||||||
assert parameters["pnp_tracker_reset_seconds"] == 5.0
|
|
||||||
assert parameters["pnp_group_relative_rotation_scale_deg"] == 5.0
|
|
||||||
assert parameters["pnp_group_relative_translation_scale_m"] == 0.01
|
|
||||||
assert parameters["pnp_group_reprojection_weight"] == 0.05
|
|
||||||
assert parameters["pnp_trajectory_reprojection_scale_px"] == 0.1
|
|
||||||
assert parameters["pnp_rigid_rotation_scale_deg"] == 5.0
|
|
||||||
assert parameters["pnp_rigid_p95_accepted_drift_deg"] == 8.0
|
|
||||||
assert parameters["pnp_rigid_maximum_accepted_drift_deg"] == 15.0
|
|
||||||
assert (
|
|
||||||
parameters["pnp_rigid_p95_accepted_distance_drift_m"]
|
|
||||||
<= 0.003
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
parameters["pnp_rigid_maximum_accepted_distance_drift_m"]
|
|
||||||
<= 0.006
|
|
||||||
)
|
|
||||||
assert parameters["pnp_maximum_pose_jump_deg"] <= 35.0
|
|
||||||
assert parameters["trajectory_maximum_plane_rms_m"] <= 0.004
|
|
||||||
assert parameters["trajectory_maximum_radial_rms_m"] <= 0.004
|
|
||||||
assert parameters["trajectory_minimum_radius_m"] >= 0.005
|
|
||||||
assert parameters["trajectory_minimum_arc_deg"] >= 15.0
|
|
||||||
assert (
|
|
||||||
parameters["trajectory_maximum_root_role_disagreement_deg"]
|
|
||||||
<= 5.0
|
|
||||||
)
|
|
||||||
assert parameters["trajectory_maximum_anchor_drift_m"] <= 0.005
|
|
||||||
assert parameters["trajectory_static_translation_outlier_m"] <= 0.005
|
|
||||||
assert (
|
|
||||||
parameters["trajectory_maximum_static_translation_rms_m"]
|
|
||||||
<= 0.002
|
|
||||||
)
|
|
||||||
assert parameters["minimum_edge_pixels"] == 30.0
|
|
||||||
assert parameters["maximum_static_std_deg"] == 3.0
|
|
||||||
assert parameters["minimum_pose_inlier_rate"] == 0.90
|
|
||||||
assert parameters["repetitions"] == 1
|
|
||||||
assert parameters["command_step"] == 8
|
|
||||||
assert parameters["scan_mode"] == "continuous"
|
|
||||||
assert parameters["continuous_motion_mode"] == "endpoint"
|
|
||||||
assert parameters["auto_start_tip"] is True
|
|
||||||
assert parameters["minimum_detection_hz"] == 15.0
|
|
||||||
assert parameters["stable_frames"] == 5
|
|
||||||
assert parameters["capture_frames"] == 8
|
|
||||||
assert parameters["maximum_stable_spread_deg"] == 3.0
|
|
||||||
assert parameters["maximum_stable_translation_spread_m"] <= 0.003
|
|
||||||
assert parameters["validation_command_count"] == 5
|
|
||||||
assert parameters["continuous_minimum_bins"] >= 32
|
|
||||||
assert parameters["continuous_maximum_bin_gap"] <= 16
|
|
||||||
assert parameters["continuous_segment_minimum_seconds"] >= 0.1
|
|
||||||
assert parameters["continuous_segment_timeout_seconds"] >= 5.0
|
|
||||||
assert parameters["continuous_prepare_timeout_seconds"] >= 30.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_realsense_color_and_camera_info_use_matching_qos() -> None:
|
|
||||||
config = yaml.safe_load(
|
|
||||||
(PACKAGE_ROOT / "config" / "realsense_color_qos.yaml").read_text()
|
|
||||||
)
|
|
||||||
|
|
||||||
assert config["color_qos"] == "DEFAULT"
|
|
||||||
assert config["color_info_qos"] == "DEFAULT"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cmc_pitch_zero_config_uses_three_trajectory_circle_rounds() -> None:
|
|
||||||
config = yaml.safe_load(
|
|
||||||
(PACKAGE_ROOT / "config" / "cmc_pitch_zero.yaml").read_text()
|
|
||||||
)
|
|
||||||
parameters = config["g20_thumb_cmc_pitch_zero"]["ros__parameters"]
|
|
||||||
|
|
||||||
assert parameters["t0_id"] == 0
|
|
||||||
assert parameters["t3_id"] == 1
|
|
||||||
assert parameters["joint_name"] == "thumb_cmc_pitch"
|
|
||||||
assert parameters["motor_index"] == 0
|
|
||||||
assert parameters["zero_command_u8"] == 255
|
|
||||||
assert parameters["measure_travel"] is False
|
|
||||||
assert parameters["baseline_command_u8"] == [
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
193,
|
|
||||||
148,
|
|
||||||
105,
|
|
||||||
42,
|
|
||||||
245,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
]
|
|
||||||
assert parameters["repetitions"] == 3
|
|
||||||
assert parameters["zero_capture_frames"] == 30
|
|
||||||
assert parameters["trajectory_command_u8"] <= 64
|
|
||||||
assert parameters["trajectory_minimum_state_span_u8"] >= 160.0
|
|
||||||
assert parameters["trajectory_minimum_bins"] >= 18
|
|
||||||
assert parameters["trajectory_minimum_arc_deg"] >= 20.0
|
|
||||||
assert parameters["trajectory_maximum_radial_rms_px"] <= 2.0
|
|
||||||
assert parameters["trajectory_maximum_p95_radial_error_px"] <= 3.5
|
|
||||||
assert parameters["minimum_detection_rate"] == 0.95
|
|
||||||
assert parameters["minimum_edge_pixels"] == 40.0
|
|
||||||
assert parameters["maximum_static_position_rms_px"] <= 1.5
|
|
||||||
assert "maximum_axis_alignment_deg" not in parameters
|
|
||||||
assert "maximum_static_std_deg" not in parameters
|
|
||||||
assert "maximum_anchor_drift_deg" not in parameters
|
|
||||||
assert parameters["camera_alignment_enabled"] is True
|
|
||||||
assert parameters["camera_alignment_max_angle_deg"] <= 0.5
|
|
||||||
assert parameters["camera_alignment_max_vertical_offset_px"] <= 12.0
|
|
||||||
assert parameters["camera_alignment_required_frames"] >= 10
|
|
||||||
assert parameters["camera_alignment_minimum_detection_rate"] <= 0.8
|
|
||||||
|
|
||||||
|
|
||||||
def test_cmc_roll_config_measures_full_endpoint_travel() -> None:
|
|
||||||
config = yaml.safe_load(
|
|
||||||
(PACKAGE_ROOT / "config" / "cmc_roll_zero_travel.yaml").read_text()
|
|
||||||
)
|
|
||||||
parameters = config[
|
|
||||||
"g20_thumb_cmc_roll_calibration"
|
|
||||||
]["ros__parameters"]
|
|
||||||
|
|
||||||
assert parameters["t0_id"] == 0
|
|
||||||
assert parameters["t3_id"] == 1
|
|
||||||
assert parameters["joint_name"] == "thumb_cmc_roll"
|
|
||||||
assert parameters["motor_index"] == 5
|
|
||||||
assert parameters["zero_command_u8"] == 255
|
|
||||||
assert parameters["trajectory_command_u8"] == 0
|
|
||||||
assert parameters["measure_travel"] is True
|
|
||||||
assert parameters["baseline_command_u8"][5] == 255
|
|
||||||
assert parameters["repetitions"] == 3
|
|
||||||
assert parameters["zero_capture_frames"] == 30
|
|
||||||
assert parameters["trajectory_minimum_state_span_u8"] >= 240.0
|
|
||||||
assert parameters["trajectory_minimum_bins"] >= 30
|
|
||||||
assert parameters["minimum_travel_deg"] >= 20.0
|
|
||||||
assert parameters["maximum_travel_difference_deg"] <= 1.0
|
|
||||||
assert parameters["maximum_round_difference_deg"] == 1.0
|
|
||||||
assert "maximum_axis_alignment_deg" not in parameters
|
|
||||||
assert "maximum_static_std_deg" not in parameters
|
|
||||||
assert "maximum_anchor_drift_deg" not in parameters
|
|
||||||
assert parameters["camera_alignment_enabled"] is True
|
|
||||||
assert parameters["camera_alignment_max_angle_deg"] <= 0.5
|
|
||||||
assert parameters["camera_alignment_max_vertical_offset_px"] <= 12.0
|
|
||||||
assert parameters["camera_alignment_required_frames"] >= 10
|
|
||||||
assert parameters["camera_alignment_minimum_detection_rate"] <= 0.8
|
|
||||||
@@ -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
|
||||||
@@ -33,6 +34,16 @@ _CANONICAL_COMMAND_NAMES = {
|
|||||||
"thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch",
|
"thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch",
|
||||||
"middle_mcp_pitch", "ring_mcp_pitch", "pinky_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 = {
|
_CANONICAL_COMMAND_BOUNDS = {
|
||||||
@@ -43,17 +54,38 @@ _CANONICAL_COMMAND_BOUNDS = {
|
|||||||
*[(0, 255)] * 5,
|
*[(0, 255)] * 5,
|
||||||
],
|
],
|
||||||
"O6": [(0, 255)] * 6,
|
"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()
|
||||||
|
|
||||||
@@ -83,27 +115,40 @@ class ROS2NodeManager(QObject):
|
|||||||
self.node.get_parameter('topic_prefix').value
|
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,
|
JointState,
|
||||||
self.topic(f'/cb_{self.hand_type}_hand_control_cmd_arc'),
|
self.topic(f'/cb_{self.hand_type}_hand_control_cmd_arc'),
|
||||||
10
|
10
|
||||||
)
|
)
|
||||||
# 创建发布者
|
# 创建旧型号发布者
|
||||||
self.publisher = self.node.create_publisher(
|
if self.hand_joint != "O12":
|
||||||
JointState,
|
self.publisher = self.node.create_publisher(
|
||||||
self.topic(f'/cb_{self.hand_type}_hand_control_cmd'),
|
JointState,
|
||||||
10
|
self.topic(f'/cb_{self.hand_type}_hand_control_cmd'),
|
||||||
)
|
10
|
||||||
|
)
|
||||||
self.snapshot_publisher = self.node.create_publisher(
|
self.snapshot_publisher = self.node.create_publisher(
|
||||||
JointState, self.topic('/calibration_pose_snapshot'), 10
|
JointState, self.topic('/calibration_pose_snapshot'), 10
|
||||||
)
|
)
|
||||||
# 新增 speed / torque 发布者
|
# 新增 speed / torque 发布者
|
||||||
self.speed_pub = self.node.create_publisher(
|
if self.hand_joint != "O12":
|
||||||
String, self.topic('/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, self.topic('/cb_hand_setting_cmd'), 10)
|
self.torque_pub = self.node.create_publisher(
|
||||||
|
String, self.topic('/cb_hand_setting_cmd'), 10)
|
||||||
self.status_updated.emit(
|
self.status_updated.emit(
|
||||||
"info",
|
"info",
|
||||||
f"ROS2节点初始化成功: {self.topic_prefix or '/'} "
|
f"ROS2节点初始化成功: {self.topic_prefix or '/'} "
|
||||||
@@ -136,15 +181,36 @@ class ROS2NodeManager(QObject):
|
|||||||
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]:
|
def bound_positions(self, positions: List[int]) -> List[int]:
|
||||||
bounds = _CANONICAL_COMMAND_BOUNDS.get(self.hand_joint)
|
bounds = self.command_bounds(len(positions))
|
||||||
if not bounds or len(bounds) != len(positions):
|
|
||||||
return list(positions)
|
|
||||||
return [
|
return [
|
||||||
max(minimum, min(maximum, int(value)))
|
max(minimum, min(maximum, int(value)))
|
||||||
for value, (minimum, maximum) in zip(positions, bounds)
|
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:
|
||||||
@@ -153,15 +219,21 @@ class ROS2NodeManager(QObject):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
positions = self.bound_positions(positions)
|
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]
|
||||||
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
|
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
|
||||||
if canonical_names and len(canonical_names) == len(positions):
|
# O12 SDK 1.1.8 may expose names in physical motor order while the
|
||||||
|
# position array uses active-angle order. An empty name array makes
|
||||||
|
# the documented position order unambiguous to the driver.
|
||||||
|
if self.hand_joint == "O12":
|
||||||
|
self.joint_state.name = []
|
||||||
|
elif canonical_names and len(canonical_names) == len(positions):
|
||||||
self.joint_state.name = canonical_names
|
self.joint_state.name = canonical_names
|
||||||
elif len(hand_config.joint_names) == len(positions):
|
elif len(hand_config.joint_names) == len(positions):
|
||||||
if hand_config.joint_names_en != None:
|
if hand_config.joint_names_en != None:
|
||||||
@@ -170,7 +242,7 @@ class ROS2NodeManager(QObject):
|
|||||||
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)
|
||||||
@@ -205,14 +277,21 @@ class ROS2NodeManager(QObject):
|
|||||||
positions = self.bound_positions(positions)
|
positions = self.bound_positions(positions)
|
||||||
self.publish_joint_state(positions)
|
self.publish_joint_state(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 = self.wire_positions(positions)
|
||||||
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
|
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
|
||||||
if canonical_names:
|
if canonical_names and self.hand_joint != "O12":
|
||||||
self.joint_state.name = canonical_names
|
self.joint_state.name = canonical_names
|
||||||
|
elif self.hand_joint == "O12":
|
||||||
|
self.joint_state.name = []
|
||||||
self.snapshot_publisher.publish(self.joint_state)
|
self.snapshot_publisher.publish(self.joint_state)
|
||||||
self.status_updated.emit("info", "当前标定姿势快照已发布")
|
self.status_updated.emit("info", "当前标定姿势快照已发布")
|
||||||
|
|
||||||
def publish_speed(self, val: int):
|
def publish_speed(self, val: int):
|
||||||
|
if self.hand_joint == "O12":
|
||||||
|
self.status_updated.emit(
|
||||||
|
"warning", "O12不使用旧版u8速度接口;请通过角度轨迹限制速度"
|
||||||
|
)
|
||||||
|
return
|
||||||
if self.hand_joint.upper() in ("O6", "L6"):
|
if self.hand_joint.upper() in ("O6", "L6"):
|
||||||
joint_len = 6
|
joint_len = 6
|
||||||
elif self.hand_joint == "L7":
|
elif self.hand_joint == "L7":
|
||||||
@@ -232,6 +311,11 @@ 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):
|
||||||
|
if self.hand_joint == "O12":
|
||||||
|
self.status_updated.emit(
|
||||||
|
"warning", "O12不使用旧版u8扭矩接口;位置GUI已禁用该设置"
|
||||||
|
)
|
||||||
|
return
|
||||||
if self.hand_joint.upper() in ("O6", "L6"):
|
if self.hand_joint.upper() in ("O6", "L6"):
|
||||||
joint_len = 6
|
joint_len = 6
|
||||||
elif self.hand_joint == "L7":
|
elif self.hand_joint == "L7":
|
||||||
@@ -273,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()
|
||||||
@@ -288,6 +378,20 @@ 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):
|
||||||
"""初始化用户界面"""
|
"""初始化用户界面"""
|
||||||
# 设置窗口属性
|
# 设置窗口属性
|
||||||
@@ -465,13 +569,11 @@ 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 = _CANONICAL_COMMAND_BOUNDS.get(self.hand_joint)
|
bounds = self.ros_manager.command_bounds(len(self.hand_config.init_pos))
|
||||||
minimum, maximum = (
|
minimum, maximum = bounds[i]
|
||||||
bounds[i] if bounds and i < len(bounds) else (0, 255)
|
|
||||||
)
|
|
||||||
value = max(minimum, min(maximum, int(value)))
|
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)
|
||||||
|
|
||||||
# 创建滑动条
|
# 创建滑动条
|
||||||
@@ -535,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(
|
||||||
@@ -564,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)
|
||||||
|
|
||||||
# 速度行
|
# 速度行
|
||||||
@@ -692,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()
|
||||||
@@ -700,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]):
|
||||||
"""预设动作按钮点击事件处理"""
|
"""预设动作按钮点击事件处理"""
|
||||||
@@ -742,7 +863,25 @@ 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):
|
def on_save_pose_clicked(self):
|
||||||
"""发布当前滑块姿势,供hand_pose_capture写入profile。"""
|
"""发布当前滑块姿势,供hand_pose_capture写入profile。"""
|
||||||
@@ -751,7 +890,7 @@ class HandControlGUI(QWidget):
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -774,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)
|
||||||
@@ -811,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}
|
||||||
@@ -829,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总线名称
|
|
||||||
}],
|
|
||||||
),
|
|
||||||
])
|
|
||||||
+36
-2
@@ -1,3 +1,5 @@
|
|||||||
|
from collections import deque
|
||||||
|
|
||||||
import can
|
import can
|
||||||
import time, sys
|
import time, sys
|
||||||
import threading
|
import threading
|
||||||
@@ -57,6 +59,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 +121,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 +216,23 @@ 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)
|
||||||
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
|
||||||
@@ -391,7 +422,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
|
||||||
|
|||||||
+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
@@ -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,72 @@
|
|||||||
|
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
|
||||||
|
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_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)))
|
||||||
|
)
|
||||||
|
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)))
|
||||||
|
)
|
||||||
|
assert hand.x01 == list(measured)
|
||||||
Submodule
+1
Submodule src/linkerhand-l30-sdk added at 0103fc55f9
@@ -0,0 +1,169 @@
|
|||||||
|
# 统一标定需求代码审查(2026-09-10)
|
||||||
|
|
||||||
|
## 结论与审查范围
|
||||||
|
|
||||||
|
审查当前工作区(包含未提交的统一重构、精简 JSON v2 和本会话启动参数修复),
|
||||||
|
不是只审查 Git HEAD,也没有把历史 PASS 当作当前实机验收。
|
||||||
|
通用采集、拟合、URDF 修正、最终文件验收和发布架构已经存在,但仍有迁移残留和精度验证缺口。
|
||||||
|
本轮修正可由代码及隔离测试确认的问题,没有改变原始 CAD、机械限位、SDK、贴 Tag 配置或相机外参。
|
||||||
|
没有启动 SDK、相机或发送机械手运动指令。
|
||||||
|
|
||||||
|
## 已修复的问题
|
||||||
|
|
||||||
|
| 问题 | 影响 | 修改 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `vendor_sdk_config:=` 等空 launch 实参 | O6/L6/G20 可能在节点启动前退出 | runner 省略空参数,使用 ROS CLI 的实际解析器覆盖四型号 |
|
||||||
|
| 在线 IO 使用固定 front/side/top 外参加载器 | 新 Profile 即使静态校验通过,在线仍可能不能使用其他机位 | 直接用通用外参加载器,视图和参考机位来自 Profile |
|
||||||
|
| launch 固定三机位、型号文件名回退和过期参数 | 新型号需修改启动代码;部分开关没有实际作用 | 按 Profile 创建机位;要求受保护 Profile/原始 URDF/Tag 路径;移除旧诊断、速度、scope 和输出目录覆盖参数 |
|
||||||
|
| MVS 无图像时只显示“等待设备” | 操作者无法区分 SDK、相机或检测链路故障 | 统一显示每机位有效内参/检测消息等待项、SDK 条件和内参不匹配原因 |
|
||||||
|
| READY 后设备断流仍保留可开始状态 | 启动请求可能使用已失效的就绪条件 | 开始前可撤销 READY;Start 再次检查完整设备条件 |
|
||||||
|
| 实时 CameraInfo 只比较 P 的部分字段 | K、D、R 改变但 P 未变时,整流图可能与原外参不一致 | 实时校验宽高及 K/D/R/P 指纹;开始后不匹配按原有坐标变化故障停止继续采集 |
|
||||||
|
| 每个稳态采样点清空整方向统计 | 显示有效帧和覆盖率归零,误导进度判断 | 分离方向数据与当前稳态点数据,重扫/换方向才重置方向统计 |
|
||||||
|
| 标题只显示序列号 | 序列号不含型号时无法确认当前型号 | 所有型号统一显示 model、side、serial_number |
|
||||||
|
| 正式 finalizer 仍允许旧格式绕过指令拟合 | 新型号配置旧版本可能没有所需的 SDK 指令→rad 产物 | 正式 Profile 与 finalizer 均只接受统一输出版本 2,取消旧输出分派 |
|
||||||
|
| 完成回调读取精简 JSON 已删除的 `quality` | 文件可能已经发布,但节点在结束时抛 KeyError | 删除没有消费者的 `final_quality` 赋值,增加无 quality 字段的完成回调回归 |
|
||||||
|
| 视觉暂停与控制定时器之间的状态竞争 | 定时器等待锁期间已暂停,取得锁后仍可能继续动作 | 在控制锁内再次检查终止状态 |
|
||||||
|
|
||||||
|
开始前等待相机消息的 2 秒窗口不用于扫描中的实时暂停;空检测数组算视觉链路存活。
|
||||||
|
短时 Tag 丢失、低检测率等仍按方向结束的数据质量策略处理,同速重扫一次后仍不足才暂停。
|
||||||
|
|
||||||
|
清理的在线残留包括无人读取的回调计数、命令频率缓存/方法、型号质量标签、旧 tracker reset hook,
|
||||||
|
以及每次反馈回调不必要构造的备用 Adapter。没有删除 SDK 协议接口的抽象方法。
|
||||||
|
|
||||||
|
五个过渡期写出器已移出生产包并保留原文件:
|
||||||
|
`calibration_output/code_review_retired_serializers_umhxlepj/`。
|
||||||
|
包括 `select.py`、`generic_v1.py`、`legacy_v4.py`、`legacy_v6.py`、`native_v7.py`。
|
||||||
|
历史读取器和历史诊断工具仍在;此归档不包含或改写用户的历史标定产物。
|
||||||
|
|
||||||
|
## 视频复核后的补充修复:共用姿态分支筛选
|
||||||
|
|
||||||
|
视频复核发现旧配置把 `pnp_reprojection_tie_px` 和姿态拒绝阈值均设为 `1.5 px`,
|
||||||
|
导致通过质量门槛的 IPPE 候选都参加时间连续性比较,旧镜像分支可能压过明确更好的图像拟合。
|
||||||
|
用现有候选姿态复现时,旧分支 `0.24 px` 会压过另一分支 `0.05 px`。
|
||||||
|
|
||||||
|
公共模块统一默认近似同误差阈值为 `0.03 px`;ROS 与离线采集继承该值。
|
||||||
|
五份旧 YAML 的重复覆盖已移除,四产品的 `calibration_config_sha256` 已同步更新。
|
||||||
|
新增校验拒绝非有限、负值或不小于图像拒绝阈值的分支容差,防止旧的 `1.5/1.5` 配置再次生效。
|
||||||
|
保持实际近似同误差时的连续性选择、原有倾角/图像质量限制和方向结束重扫策略。
|
||||||
|
因此前文“配置保持不变”仅描述首轮结构审查;这次补充修复修改了上述算法配置及其哈希。
|
||||||
|
|
||||||
|
覆盖五份配置和四型号默认采集的九个复现用例,在修复前全部失败,修复后全部通过。
|
||||||
|
第一组姿态、配置、采集与加载实际参数文件的四型号隔离 ROS host 检查共 51 项通过。
|
||||||
|
第二组公共采集/拟合与发布、O12 姿态解析、来源保护、四型号配置及启动检查共 141 项通过,
|
||||||
|
耗时 114.53 秒。两组为本次修改的定向回归,没有宣称本次又执行过全包测试。
|
||||||
|
本次 `colcon build --packages-select linkerhand_calibration --symlink-install` 通过;
|
||||||
|
安装环境读取四产品配置、校验受保护哈希及公共默认值均通过,`git diff --check` 通过。
|
||||||
|
这不代表所有平面双解已消除,也不替代下述相机时序和实机重复性验证。
|
||||||
|
|
||||||
|
## 仍不能由本轮软件审查保证的事项
|
||||||
|
|
||||||
|
### P1:真实采集时间与传输积压
|
||||||
|
|
||||||
|
`hikrobot_camera.py::_publish_frame` 使用主机取到帧时的 ROS 时间戳,未将设备曝光时间映射到统一时钟。
|
||||||
|
SDK 帧结构有设备时间戳和帧号,但设备时钟单位、偏移、漂移与主机时钟同步还没有在当前硬件验证。
|
||||||
|
`runtime/ros/io.py::_state_callback` 对无时间戳反馈也采用接收时间。
|
||||||
|
因此,USB/驱动缓存可能把旧图像与新反馈配对;50 ms 的消息时间戳检查和“只取最新帧”不能排除该问题。
|
||||||
|
本会话已经实测到三台相机共享 480 Mbps Hub 上行,且正面/上方取帧超时。
|
||||||
|
|
||||||
|
后续应在恢复 USB 3.x 链路后,核验设备时间戳、帧号、曝光与反馈时序,并用独立运动数据检查配对误差。
|
||||||
|
不能猜测设备时钟单位、直接给时间戳减一个常数,或放宽质量阈值作为修复。
|
||||||
|
|
||||||
|
### P1:重复性和实机/仿真验收尚未完成
|
||||||
|
|
||||||
|
前三轮训练、第四轮独立验证、冻结 Tag 安装和双文件重读校验已接入。
|
||||||
|
但同手多次独立重采、重新摆放和重新贴 Tag、不同保持姿态/多关节组合动作的实际验收仍缺证据。
|
||||||
|
L6/O6 部分关节由小指参数迁移,不属于所有关节独立实测。
|
||||||
|
发布报告仍应如实保留 `arbitrary_multiaxis_validated: false`。
|
||||||
|
|
||||||
|
外部仿真要加载本次修正 URDF,用同一 SDK 指令话题及配套 JSON 驱动;用实机反馈直接驱动仿真,
|
||||||
|
只能显示反馈对应姿态,不能证明指令映射正确。桥本身是消息转换器,不是独立精度测量工具。
|
||||||
|
|
||||||
|
### P2:数学模块和旧配置仍有维护负担
|
||||||
|
|
||||||
|
`core/fitting/spatial.py` 约 3,656 行,包含轴线拟合、基座位姿、零位优化、可观测性和验证,
|
||||||
|
其中 `solve_urdf_zero_offsets` 从约第 1,921 行开始,嵌套函数及共享局部状态较多。
|
||||||
|
它是当前生产算法,不是可直接删除的废代码。后续拆分应按这些数学职责进行,
|
||||||
|
用固定输入的参数、残差、失败原因和产物回归验证等价性,不应复制成各型号独立算法。
|
||||||
|
|
||||||
|
当前约 58 个兼容模块仍用于旧导入路径、历史回放/迁移工具和旧回归;不能仅因目录名称或静态引用少就删除。
|
||||||
|
几行的公开兼容导出不是第二套拟合算法。旧型号诊断、旧 URDF plan 和部分旧 YAML 参数仍需明确退休范围后继续收缩。
|
||||||
|
受保护的历史配置在本轮保持不变;旧 YAML 中一些已不被在线节点读取的参数,不应当作当前算法的生效开关。
|
||||||
|
|
||||||
|
原始 G20/O12 的 mimic/限位冲突和 CAD 保留策略仍需机械资料确认;当前通用 URDF 修正
|
||||||
|
主要处理零位、限位和线性 mimic,不估计连杆长度、轴位置、mesh 或惯量。
|
||||||
|
|
||||||
|
## 按需求核对
|
||||||
|
|
||||||
|
| 需求 | 当前判断 |
|
||||||
|
| --- | --- |
|
||||||
|
| 多型号共用拟合和 URDF 修正 | 已实现统一生产链;SDK 新协议仍需 Adapter 和 ROS 启动绑定 |
|
||||||
|
| 新型号只关心 SDK、贴 Tag、避让 | 基本成立,但必须声明关节绑定、可观测性、迁移/保留策略和可修改字段,不能从未知安装的单轴 Tag 自动猜出 CAD 零位 |
|
||||||
|
| 统一 SDK 指令→rad JSON 与修正 URDF | 正式仅生成统一 v2 指令表;u8 为 256 项,rad 为显式节点;被动表由标准 mimic 推导并验收,详细证据保留在报告 |
|
||||||
|
| 开始前可移动、开始后固定 | 每次 Start 丢弃预览参考,正式锁定;可见固定基准有漂移监测;遮挡对象是否移动不能实时保证 |
|
||||||
|
| 正确、可重复 | 有独立验证和来源保护,但仍受采集时序与未完成实机验收限制 |
|
||||||
|
| 少暂停且原因明确 | 统一策略保留;开始前明确缺失设备,不用短时视觉丢失实时打断扫描 |
|
||||||
|
| 统一进度且明确型号 | 已修正型号、设备等待原因及方向统计显示 |
|
||||||
|
| 同源指令实机/仿真验证 | 有统一转换桥;实际动态/组合动作对比尚未完成 |
|
||||||
|
|
||||||
|
## 本轮验证
|
||||||
|
|
||||||
|
- 审查中完整标定包回归:**544 通过、1 跳过,383.25 秒**。跳过项要求显式提供
|
||||||
|
`O12_REPLAY_RAW` / `O12_REPLAY_REFERENCE`,没有用合成数据替代历史实测。
|
||||||
|
- 随后补充的启动清理、单一正式输出及归档改动:启动/runner/正式 Profile 拟合/精简产物定向组
|
||||||
|
**74 通过,108.78 秒**,包含四型号与虚拟型号的实际公共拟合和发布路径。
|
||||||
|
- 最后完成回调、暂停竞争、ROS host、等待诊断、实时内参保护:**25 通过,0.98 秒**。
|
||||||
|
与上一组有重叠,不相加冒充一次全量结果;没有宣称最后所有变更又跑过一次全量。
|
||||||
|
- `colcon build --packages-select linkerhand_calibration --symlink-install` 通过。
|
||||||
|
安装入口读取四产品的受保护配置、ROS launch 实参解析均通过;167 个生产/兼容 Python 文件 AST 通过;
|
||||||
|
`git diff --check` 通过。
|
||||||
|
- 新 launch 测试实际构造四型号及改名机位的 launch actions,但不执行这些动作。
|
||||||
|
隔离 ROS 节点/标准加载器测试不启动 SDK 或相机,不代表实机验收。
|
||||||
|
|
||||||
|
## 数学核心与 ROS 组合重构的验收记录
|
||||||
|
|
||||||
|
本轮以包含公共 `0.03 px` 姿态分支修复的工作区为基线,只拆分职责、明确数据和并发边界。
|
||||||
|
没有删除基线中仍存在的历史入口,没有修改配置、拟合公式、优化初值/顺序、残差权重或验收阈值。
|
||||||
|
|
||||||
|
- `core/fitting/spatial.py` 保留 45 个原定义符号的显式导出;实际计算在 `spatial_solver/`。
|
||||||
|
`solve.py` 的入口顺序为输入准备、训练求解、统计、独立验证及结果装配。
|
||||||
|
数据通过 `ZeroProblem`、`TrainingProblem`、`TrainingFit` 和各验收结果类型传递。
|
||||||
|
训练接口只接收训练观测,训练几何中的掌部姿态按训练轮筛选。
|
||||||
|
- `core/geometry/pnp.py` 保留 14 个原定义符号;实现拆为 IPPE、单 Tag 跟踪、刚性组和轨迹选择。
|
||||||
|
跟踪默认值集中在 `tag_pose/parameters.py`,ROS 参数只负责加载和 deg/rad 转换。
|
||||||
|
- `UnifiedCalibrationNode` 直接继承 ROS `Node`,组合消息 IO 与 `CalibrationCoordinator`。
|
||||||
|
协调器不加载 ROS 库,复用 `SessionExecution`、采集、运动、固定基准和断点组件。
|
||||||
|
运行参数、观测输入和快照使用明确类型;阶段只有 `CalibrationSession.phase` 一个来源。
|
||||||
|
- `FinalizationController` 组合原 worker 与发布器,协调器接收阶段事件并授权提交。
|
||||||
|
状态锁先于 worker 锁;PnP 在状态锁外计算,提交时检查会话版本、采集/运动对象及稳态边界。
|
||||||
|
暂停、中止与提交互斥;失败不触发自动恢复运动,成功只提交一次。
|
||||||
|
SDK 绑定显式接收反馈新鲜度、时钟、健康订阅和发布接口。
|
||||||
|
|
||||||
|
固定输入记录在本次工作区快照 `/tmp/calibration_structure_baseline_30qf3ozo/`。
|
||||||
|
持久化的对比报告及压缩前后输出位于 `calibration_output/structure_refactor_20260910/`,
|
||||||
|
其中 `comparison.json` 记录输入摘要、差异、产物摘要和拒绝原因,`protected_inputs.json` 保存输入哈希。
|
||||||
|
这些记录是合成数据的结构等价证据,不是实机精度验收。
|
||||||
|
|
||||||
|
| 固定输入 | 比较浮点值数量 | 最大绝对差异 | JSON/拟合结果/阶段事件及 URDF 文本 |
|
||||||
|
| --- | ---: | ---: | --- |
|
||||||
|
| G20 | 46,046 | 0 | 一致 |
|
||||||
|
| L6 | 15,304 | 0 | 一致 |
|
||||||
|
| O6 | 15,304 | 0 | 一致 |
|
||||||
|
| O12 | 14,494 | 0 | 一致 |
|
||||||
|
| 虚拟型号(任务重排) | 15,304 | 0 | 一致 |
|
||||||
|
|
||||||
|
对五组输入分别移除第四轮或注入第四轮 Tag 姿态滑移,10 个拒绝原因和阶段事件与基线一致。
|
||||||
|
167 个配置、Profile、原始 URDF/资源文件的哈希不变。
|
||||||
|
另一个虚拟型号同时重排 SDK 通道、机位名称和扫描任务,经过真实公共采集、finalizer、
|
||||||
|
最终 JSON/URDF 验收和发布,再检查同一 SDK 指令经过模拟传输、查表和标准 mimic 图的一致性。
|
||||||
|
|
||||||
|
验证结果:
|
||||||
|
|
||||||
|
- 整个标定包:581 passed,1 skipped,337.43 s。
|
||||||
|
- SDK 绑定时钟补充后,协调器、四型号 ROS 构造和安全策略回归:27 passed。
|
||||||
|
- 跳过项是 `test_o12_recorded_replay.py`:未提供 `O12_REPLAY_RAW` 与独立参考模型;不计作实机通过。
|
||||||
|
- 改动模块通过 Pyflakes,`git diff --check` 通过。
|
||||||
|
- `colcon build --packages-select linkerhand_calibration --symlink-install` 成功。
|
||||||
|
- 安装后的 12 个 console entry 可导入;两个产品 CLI 的 `--help` 正常。
|
||||||
|
两个 ROS 节点入口均通过四型号受保护 Profile 的转交检查;安装环境下四型号 ROS 构造/Start 回归 4 passed。
|
||||||
|
|
||||||
|
本轮不处理相机采集时刻同步,也不替代实机多次标定和同话题实机/仿真运动对照。
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# LinkerHand 多型号统一标定
|
||||||
|
|
||||||
|
G20、L6、O6、O12 使用同一个产品启动器、在线状态机、采集器、拟合器、标准 URDF
|
||||||
|
验收和发布器。型号差异来自 `config/profiles/*.yaml` 和 SDK Adapter,不再调用型号节点。
|
||||||
|
|
||||||
|
本包校准关节零位、有效行程及标准线性 mimic;保留 CAD 连杆尺寸、轴位置、mesh、惯量。
|
||||||
|
JSON 描述 SDK 输入与修正 URDF 角度的转换,不能补偿错误的 URDF。
|
||||||
|
软件仿真通过不等于实机精度通过;当前原始 G20/O12 存在需要确认的 mimic/限位冲突。
|
||||||
|
详见 [执行与验收记录](CALIBRATION_REFACTOR_PROGRESS.md)。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
```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`。
|
||||||
|
`calibrate_g20_right` 是同一 runner 的旧命令别名。不要同时运行 GUI、单独 SDK 或其他控制器。
|
||||||
|
`--commands-disabled` 只预览,不发送运动;`--no-resume` 强制新采集。Ctrl+C 中止,不自动快速张手。
|
||||||
|
重新开始时先安全回基准,仍必须确保现场没有障碍物。
|
||||||
|
|
||||||
|
| Profile | SDK 位置通道 | 扫描任务 | Tag | 输出格式 |
|
||||||
|
| --- | ---: | ---: | ---: | --- |
|
||||||
|
| G20/right/g20_right_19/v1 | 20 | 16 | 19 | unified v2,256 项指令查表 |
|
||||||
|
| L6/right/l6_right_8/v1 | 6 | 3 | 8 | unified v2,256 项指令查表 |
|
||||||
|
| O6/right/o6_right_8/v1 | 6 | 3 | 8 | unified v2,256 项指令查表 |
|
||||||
|
| O12/right/o12_right_16/v1 | 12 | 11 | 16 | unified v2,rad 输入节点查表 |
|
||||||
|
|
||||||
|
任务数、主动关节数和实测关节数不必相等。一条运动可观测多个主动/被动关节。
|
||||||
|
L6/O6 未贴 Tag 的同机构关节使用显式迁移,不能把迁移称为独立实测。
|
||||||
|
每任务四个往返周期:前三轮训练,第四轮独立验证。
|
||||||
|
每个方向先连续扫描,再单通道访问稳态点。训练每方向默认 9 点,第四轮为交错点加端点;
|
||||||
|
每点至少 3 个稳定同步图像,轨迹结束后最多等待 2 秒,不能用精确指令/反馈相等判断稳定。
|
||||||
|
稳态点缺失与连续扫描不足统一在方向结束处理,最多同速重扫一次。
|
||||||
|
字节拟合不再改写原始 command/feedback,也不将接近端点的读数伪装成 0/255。
|
||||||
|
|
||||||
|
## 基准、避让与恢复
|
||||||
|
|
||||||
|
- 开始前可以调整手掌、相机和 Tag;预览数据不会成为正式参考。
|
||||||
|
- 开始后清空预览缓存,先安全回基准,再为每机位固定 Tag 收集至少 10 个有效帧。
|
||||||
|
缺少基准时保持并显示缺少的 ID,不因为等待时间长暂停。
|
||||||
|
- 锁定后禁止人工移动手掌、相机、支架、Tag 粘贴位置;程序驱动关节和避让仍是允许的。
|
||||||
|
- 相机相对位置改变时须重新确认/标定外参;仅重新锁定掌心 Tag 不能替代外参标定。
|
||||||
|
- 固定 Tag 被避让遮住时可用已锁定参考继续采集;软件不能保证检测完全不可见对象的移动。
|
||||||
|
运动 Tag 的刚性和几何一致性仍须通过最终独立验证。
|
||||||
|
- 断点版本为 `unified_engine_v4_dual_mapping`,不复用缺少稳态证据的旧 v1/v2/v3。
|
||||||
|
新断点检查配置哈希、基准及所复用 Tag 安装关系(5 px、2°、5 mm)。缺失或不一致即放弃复用,
|
||||||
|
自动完整重采,旧会话不修改。恢复时重新执行准备与避让,不直接跳到旧姿态。
|
||||||
|
|
||||||
|
O12 避让:小指和无名指到 Profile 的最大弯曲指令;标定食指前,中指 MCP/PIP 到最大弯曲指令,
|
||||||
|
中指侧摆为 0 rad。到位采用完成轨迹、正确方向、至少 80% 请求位移和短时稳定,
|
||||||
|
不要求反馈数值精确等于命令。SDK 指令最大值与实测 URDF 角度不是同一个量。
|
||||||
|
|
||||||
|
O12 使用 HCAN device 0/channel 0 的已锁定 vendor Python wheel,不检查 `can0`。
|
||||||
|
只读反馈桥可在第一次运动命令之前取得状态,不会发送零位命令来“激活回读”。
|
||||||
|
POSITION 和活动故障由 SDK 确认;无法单独回读温度时使用 SDK 错误位过热保护,不伪造温度。
|
||||||
|
字节 SDK 当前没有独立硬件故障遥测,状态会明确注明这一能力边界。
|
||||||
|
|
||||||
|
## 哪些情况暂停
|
||||||
|
|
||||||
|
实时仅因人工中止、竞争控制器、活动硬件故障/模式错误、真实失联、反馈超过 1 秒未更新、
|
||||||
|
物理越限、明显运动要求下连续 2 秒无推进、已锁固定基准连续 10 帧漂移超过 5 px 而暂停。
|
||||||
|
开始后相机投影参数变化也会拒绝继续使用混合坐标数据。
|
||||||
|
|
||||||
|
短时 Tag 丢失、PnP/同步失败、低检测率/低反馈 Hz、正常跟随滞后、固有耦合和非目标小幅运动
|
||||||
|
不会实时中断轨迹。无效视觉帧直接丢弃。
|
||||||
|
|
||||||
|
所有型号的独立 Tag 姿态筛选共用 `core/geometry/pnp.py` 中的近似同误差阈值,默认 `0.03 px`。
|
||||||
|
ROS 和离线采集使用相同默认值,型号 YAML 不重复声明该值。`1.5 px` 是图像质量拒绝阈值,
|
||||||
|
不能用它判断两种姿态是否同样可信;仅在误差差值不超过近似同误差阈值时使用时间连续性。
|
||||||
|
当另一分支的图像拟合明确更好时允许纠正旧分支。覆盖参数必须有限、非负且小于图像质量拒绝阈值。
|
||||||
|
这项修复不保证解决所有平面 Tag 双解,真实角点噪声、采集时序和最终运动精度仍需实测验证。
|
||||||
|
|
||||||
|
方向结束才检查 ≥40 个有效同步样本、≥32 分箱、内部空白 ≤行程的 1/16、规定的有效行程覆盖,
|
||||||
|
以及 Profile 声明的端点/双视角观测。数据不足只同速重扫一次;第二次失败暂停。
|
||||||
|
拟合或最终文件验收失败不重新自动运动、不更新发布指针。
|
||||||
|
|
||||||
|
所有型号显示相同进度:阶段、任务/轮次/方向、分机位 Tag ID、命令/反馈/单位/速度、
|
||||||
|
有效数据、基准、断点来源,以及中文原因、建议和原始诊断。耗时拟合在后台执行,不阻塞反馈与状态。
|
||||||
|
标题明确显示型号、左右手和序列号。开始前等待状态列出缺少或过期的相机内参、检测消息和 SDK 条件;
|
||||||
|
空 Tag 检测消息可以证明视觉链路工作,不要求开始前已经识别全部 Tag。
|
||||||
|
收到开始请求时重新检查设备状态;相机有效内参必须与受保护外参中的指纹一致。
|
||||||
|
开始前相机消息的新鲜度窗口为 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/session.py 唯一在线状态机
|
||||||
|
├── runtime/execution.py Profile 任务与运动效果调度
|
||||||
|
├── runtime/motion_execution.py 平滑轨迹与位移到位判定
|
||||||
|
├── runtime/capture.py 多机位视觉/反馈采集
|
||||||
|
├── runtime/scan_quality.py 唯一方向数据门
|
||||||
|
├── runtime/reference_lock.py 开始后固定基准
|
||||||
|
├── runtime/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`,重投影质量、倾角、过滤原因和连续跟踪恢复策略保持一致。
|
||||||
|
|
||||||
|
`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;必要时指定不可见 Tag 的恢复验证姿态。
|
||||||
|
4. 哪些零位可观测、哪些固定保留 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: 2` 选择统一指令查表格式;产物自身为
|
||||||
|
`format: unified_calibration_v2, schema_version: 2`。详细双映射保留在独立标定报告中。
|
||||||
|
旧 generic v1、v4/v6/v7 只保留历史兼容工具,不把反馈表改名为指令表来伪造兼容。
|
||||||
|
正式 Profile 校验及 finalizer 只接受 `output_schema_version: 2`,不能用旧格式跳过指令映射拟合。
|
||||||
|
配置错字、错误绑定、未知修正字段在启动前失败;修改受保护文件后应审查并更新对应 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` 对应 `angle_rad[v]`,只能在训练指令实际覆盖 0–255 时导出。
|
||||||
|
输出均是修正 URDF 的 q_output,不再加零偏。顶层保留 `format/schema_version/profile_id/model/side/serial_number/input_unit`。
|
||||||
|
被动关节的查表由导出 URDF 的标准 mimic 递归生成,绑定同一驱动通道,必须与 URDF 完全一致。
|
||||||
|
单张查表取训练正反向指令曲线的平均值,再用实际导出表重放第四轮视觉数据;回差等因素导致超差时禁止发布,
|
||||||
|
不通过隐藏方向分支、外推、放宽精度或 JSON 非线性被动联动制造通过结果。
|
||||||
|
|
||||||
|
`calibration_report.json` 保存完整的 `command_to_rad` / `feedback_to_rad`、输入域、方向分支、
|
||||||
|
零位、来源、迁移和训练/holdout 指标。报告中的 `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`,桥读取配套报告中的反馈映射。
|
||||||
|
桥检查三文件哈希、拒绝越域输入,
|
||||||
|
被动角由实际导出 URDF 计算,不加第二次零偏。仿真输出不能与实机反馈话题同名。
|
||||||
|
MuJoCo 是用户后续使用的外部验收项目,本轮不改动它、不增加 MuJoCo 依赖,自动标定不要求打开 GUI。
|
||||||
|
外部对比须确认加载的模型来自这份 URDF,指令转换使用配套 JSON;不能用实机反馈直接驱动仿真来证明指令映射。
|
||||||
|
|
||||||
|
```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` 必须显式给出,且不会绕过验证。
|
||||||
|
算法和坐标规则见 [CALIBRATION_FLOW.md](CALIBRATION_FLOW.md),批准的原始方案保持在
|
||||||
|
[CALIBRATION_REFACTOR_PLAN.md](CALIBRATION_REFACTOR_PLAN.md)。
|
||||||
|
|
||||||
|
当前代码审查、已修复问题与未完成的精度验收见
|
||||||
|
[2026-09-10 需求审查](CALIBRATION_CODE_REVIEW_20260910.md)。相机图像目前采用主机取帧时间;
|
||||||
|
USB/驱动积压情况下,图像与反馈的时间戳接近不能证明曝光时刻同步,不能据此宣称实机精度通过。
|
||||||
+3
-6
@@ -44,7 +44,7 @@ g20_thumb_calibration:
|
|||||||
minimum_detection_hz: 15.0
|
minimum_detection_hz: 15.0
|
||||||
maximum_hamming: 0
|
maximum_hamming: 0
|
||||||
minimum_decision_margin: 30.0
|
minimum_decision_margin: 30.0
|
||||||
# Trial threshold for the current 10 mm tags (observed at 32-38 px).
|
# Trial threshold for small/far tags (historically observed at 32-38 px).
|
||||||
# Final acceptance is still guarded by static RMS and random validation.
|
# Final acceptance is still guarded by static RMS and random validation.
|
||||||
minimum_edge_pixels: 30.0
|
minimum_edge_pixels: 30.0
|
||||||
# Current 30 px tags measure about 0.50-0.53 deg RMS while stationary.
|
# Current 30 px tags measure about 0.50-0.53 deg RMS while stationary.
|
||||||
@@ -60,11 +60,8 @@ g20_thumb_calibration:
|
|||||||
# 30-38 px tags are usable, but only if IPPE gives a tight image fit and
|
# 30-38 px tags are usable, but only if IPPE gives a tight image fit and
|
||||||
# a pose continuous with the preceding frame.
|
# a pose continuous with the preceding frame.
|
||||||
pnp_maximum_reprojection_error_px: 1.5
|
pnp_maximum_reprojection_error_px: 1.5
|
||||||
# All four tags keep a temporally continuous IPPE solution throughout the
|
# Independent IPPE selection inherits the shared near-tie tolerance from
|
||||||
# complete session. With 30 px planar tags, tiny reprojection differences
|
# core/geometry/pnp.py; continuity cannot override a clear image-fit lead.
|
||||||
# do not reliably identify the physical branch and previously caused
|
|
||||||
# stationary T0 to flip by about 25 deg between scan and validation.
|
|
||||||
pnp_reprojection_tie_px: 1.5
|
|
||||||
pnp_maximum_pose_jump_deg: 35.0
|
pnp_maximum_pose_jump_deg: 35.0
|
||||||
pnp_maximum_translation_jump_m: 0.04
|
pnp_maximum_translation_jump_m: 0.04
|
||||||
pnp_maximum_tag_tilt_deg: 75.0
|
pnp_maximum_tag_tilt_deg: 75.0
|
||||||
@@ -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>
|
||||||
+3
-3
@@ -6,7 +6,7 @@
|
|||||||
# from back-pressuring image_proc's reliable image publisher.
|
# from back-pressuring image_proc's reliable image publisher.
|
||||||
qos_profile: sensor_data
|
qos_profile: sensor_data
|
||||||
family: 36h11
|
family: 36h11
|
||||||
size: 0.01
|
size: 0.016
|
||||||
profile: false
|
profile: false
|
||||||
max_hamming: 0
|
max_hamming: 0
|
||||||
detector:
|
detector:
|
||||||
@@ -20,11 +20,11 @@
|
|||||||
tag:
|
tag:
|
||||||
ids: [0, 1, 2, 3]
|
ids: [0, 1, 2, 3]
|
||||||
frames: [tag_t0, tag_t3, tag_t4, tag_t5]
|
frames: [tag_t0, tag_t3, tag_t4, tag_t5]
|
||||||
sizes: [0.010, 0.010, 0.010, 0.010]
|
sizes: [0.016, 0.016, 0.016, 0.016]
|
||||||
|
|
||||||
g20_thumb_calibration:
|
g20_thumb_calibration:
|
||||||
ros__parameters:
|
ros__parameters:
|
||||||
tag_roles: [t0, t3, t4, t5]
|
tag_roles: [t0, t3, t4, t5]
|
||||||
tag_ids: [0, 1, 2, 3]
|
tag_ids: [0, 1, 2, 3]
|
||||||
tag_frames: [tag_t0, tag_t3, tag_t4, tag_t5]
|
tag_frames: [tag_t0, tag_t3, tag_t4, tag_t5]
|
||||||
tag_sizes_m: [0.010, 0.010, 0.010, 0.010]
|
tag_sizes_m: [0.016, 0.016, 0.016, 0.016]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
schema_version: 3
|
||||||
|
profile_id: G20/right/g20_right_19/v1
|
||||||
|
profile_config: package://linkerhand_calibration/config/profiles/g20_right_19.yaml
|
||||||
|
profile_config_sha256: 35cad4432a01b3e1af157f1e914c2054e71522a44c6786db2519e4c2a0966a46
|
||||||
|
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: b9654ce998b56c10d7094364e8b715663163d2101e1f70e077cb0723fc47f02e
|
||||||
|
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: 0b5cd27168bc863e5c1aed5382c08e33ab4c02a23359a325dc9925fd30599e5f
|
||||||
|
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: src/agillink_omnihand_sdk/linux/x64/ros2/jazzy/share/omnihand_node/config/omnihand_pro_2025_node.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,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,59 @@
|
|||||||
|
/o6_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_ip]
|
||||||
|
sizes: [0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
/o6_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]
|
||||||
|
|
||||||
|
/o6_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_yaw]
|
||||||
|
sizes: [0.016, 0.016]
|
||||||
@@ -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: ab1d86cb0c3e514de5322a44c1444e99d9b1f1ce43f8a2451cd032ed9c31e4ff
|
||||||
|
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: ~/.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/o6_right/linkerhand_o6_right.urdf
|
||||||
|
source_urdf_sha256: 8f184faad699fbf771e388f109a4e8793b5cb190c33a87b2eba8491a3a37dd62
|
||||||
|
camera_extrinsics: config/o6_three_camera_extrinsics.yaml
|
||||||
|
camera_extrinsics_sha256: 29af61f7bf1bad6718cbbaa54b0536f0a471c83f5bb3554f264ab9d292e56ca4
|
||||||
|
calibration_config: package://linkerhand_calibration/config/o6_three_camera_calibration.yaml
|
||||||
|
calibration_config_sha256: 15adaec462d0895dc68d19760e8f333c62e57786d124f22cfc481007a3d976a4
|
||||||
|
tag_config: package://linkerhand_calibration/config/o6_right_8_tags.yaml
|
||||||
|
tag_config_sha256: 16abe7119b4764f86333dae8264247571d1e0bca45af959d558bef4fb5485f5e
|
||||||
|
|
||||||
|
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.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
|
||||||
|
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,372 @@
|
|||||||
|
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
|
||||||
|
measurement:
|
||||||
|
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:
|
||||||
|
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: 2
|
||||||
|
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:
|
||||||
|
policy_version: unified_engine_v4_dual_mapping
|
||||||
|
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
|
||||||
@@ -0,0 +1,926 @@
|
|||||||
|
schema_version: 1
|
||||||
|
profile_id: O12/right/o12_right_16/v1
|
||||||
|
namespace: /o12_calibration
|
||||||
|
sdk_adapter: o12_hcan_sdk
|
||||||
|
command:
|
||||||
|
unit: rad
|
||||||
|
feedback_by_index: true
|
||||||
|
sdk_to_joint_direction:
|
||||||
|
- 1
|
||||||
|
- -1
|
||||||
|
- -1
|
||||||
|
- -1
|
||||||
|
- -1
|
||||||
|
- 1
|
||||||
|
- 1
|
||||||
|
- -1
|
||||||
|
- 1
|
||||||
|
- 1
|
||||||
|
- 1
|
||||||
|
- 1
|
||||||
|
names:
|
||||||
|
- thumb_roll
|
||||||
|
- thumb_abad
|
||||||
|
- thumb_mcp
|
||||||
|
- thumb_pip
|
||||||
|
- index_abad
|
||||||
|
- index_mcp
|
||||||
|
- index_pip
|
||||||
|
- middle_abad
|
||||||
|
- middle_mcp
|
||||||
|
- middle_pip
|
||||||
|
- ring_mcp
|
||||||
|
- pinky_mcp
|
||||||
|
baseline_u8: []
|
||||||
|
baseline:
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
lower_bounds:
|
||||||
|
- 0
|
||||||
|
- -1.387536755335492
|
||||||
|
- -0.8272860654453121
|
||||||
|
- -1.2915436464758039
|
||||||
|
- -0.2617993877991494
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- -0.2617993877991494
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
upper_bounds:
|
||||||
|
- 0.9424777960769379
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0
|
||||||
|
- 0.2617993877991494
|
||||||
|
- 1.3526301702956054
|
||||||
|
- 1.530653753999027
|
||||||
|
- 0.2617993877991494
|
||||||
|
- 1.3578661580515883
|
||||||
|
- 1.8151424220741028
|
||||||
|
- 1.53588974175501
|
||||||
|
- 1.53588974175501
|
||||||
|
feedback_lower_bounds:
|
||||||
|
- -0.03490658503988659
|
||||||
|
- -1.4224433403753787
|
||||||
|
- -0.8621926504851987
|
||||||
|
- -1.3264502315156905
|
||||||
|
- -0.296705972839036
|
||||||
|
- -0.03490658503988659
|
||||||
|
- -0.03490658503988659
|
||||||
|
- -0.296705972839036
|
||||||
|
- -0.03490658503988659
|
||||||
|
- -0.03490658503988659
|
||||||
|
- -0.03490658503988659
|
||||||
|
- -0.03490658503988659
|
||||||
|
feedback_upper_bounds:
|
||||||
|
- 0.9773843811168246
|
||||||
|
- 0.03490658503988659
|
||||||
|
- 0.03490658503988659
|
||||||
|
- 0.03490658503988659
|
||||||
|
- 0.296705972839036
|
||||||
|
- 1.387536755335492
|
||||||
|
- 1.5655603390389137
|
||||||
|
- 0.296705972839036
|
||||||
|
- 1.392772743091475
|
||||||
|
- 1.8500490071139892
|
||||||
|
- 1.5707963267948966
|
||||||
|
- 1.5707963267948966
|
||||||
|
command_index_by_joint:
|
||||||
|
thumb_cmc_roll: 0
|
||||||
|
thumb_cmc_yaw: 1
|
||||||
|
thumb_cmc_pitch: 2
|
||||||
|
thumb_mcp: 3
|
||||||
|
index_mcp_roll: 4
|
||||||
|
index_mcp_pitch: 5
|
||||||
|
index_pip: 6
|
||||||
|
middle_mcp_roll: 7
|
||||||
|
middle_mcp_pitch: 8
|
||||||
|
middle_pip: 9
|
||||||
|
ring_mcp_pitch: 10
|
||||||
|
pinky_mcp_pitch: 11
|
||||||
|
urdf_joint_by_joint:
|
||||||
|
thumb_cmc_roll: thumb_cmc_roll
|
||||||
|
thumb_cmc_yaw: thumb_cmc_yaw
|
||||||
|
thumb_cmc_pitch: thumb_cmc_pitch
|
||||||
|
thumb_mcp: thumb_mcp
|
||||||
|
index_mcp_roll: index_mcp_roll
|
||||||
|
index_mcp_pitch: index_mcp_pitch
|
||||||
|
index_pip: index_pip
|
||||||
|
middle_mcp_roll: middle_mcp_roll
|
||||||
|
middle_mcp_pitch: middle_mcp_pitch
|
||||||
|
middle_pip: middle_pip
|
||||||
|
ring_mcp_pitch: ring_mcp_pitch
|
||||||
|
pinky_mcp_pitch: pinky_mcp_pitch
|
||||||
|
maximum_velocity:
|
||||||
|
- 0.16
|
||||||
|
- 0.16
|
||||||
|
- 0.1
|
||||||
|
- 0.32
|
||||||
|
- 0.12
|
||||||
|
- 0.32
|
||||||
|
- 0.32
|
||||||
|
- 0.12
|
||||||
|
- 0.32
|
||||||
|
- 0.32
|
||||||
|
- 0.32
|
||||||
|
- 0.32
|
||||||
|
vision:
|
||||||
|
common_frame: calibration_common
|
||||||
|
extrinsic_reference_view: front
|
||||||
|
extrinsics_quality_limits:
|
||||||
|
reprojection_rms_px: 2.0
|
||||||
|
maximum_rotation_repeatability_deg: 0.3
|
||||||
|
maximum_translation_repeatability_m: 0.0015
|
||||||
|
minimum_capture_counts:
|
||||||
|
front_side_captures: 15
|
||||||
|
front_top_captures: 15
|
||||||
|
views:
|
||||||
|
- name: front
|
||||||
|
tags:
|
||||||
|
- role: front_base
|
||||||
|
id: 0
|
||||||
|
fixed_reference: true
|
||||||
|
- role: thumb_cmc
|
||||||
|
id: 1
|
||||||
|
- role: thumb_mcp
|
||||||
|
id: 2
|
||||||
|
- role: thumb_dip
|
||||||
|
id: 3
|
||||||
|
- role: middle_roll
|
||||||
|
id: 12
|
||||||
|
- role: index_roll
|
||||||
|
id: 13
|
||||||
|
- name: side
|
||||||
|
tags:
|
||||||
|
- role: side_base
|
||||||
|
id: 4
|
||||||
|
fixed_reference: true
|
||||||
|
- role: pinky_mcp
|
||||||
|
id: 5
|
||||||
|
- role: pinky_pip
|
||||||
|
id: 6
|
||||||
|
- role: pinky_dip
|
||||||
|
id: 7
|
||||||
|
- role: middle_pip
|
||||||
|
id: 8
|
||||||
|
- role: middle_dip
|
||||||
|
id: 9
|
||||||
|
- role: index_pip
|
||||||
|
id: 10
|
||||||
|
- role: index_dip
|
||||||
|
id: 11
|
||||||
|
- name: top
|
||||||
|
tags:
|
||||||
|
- role: top_base
|
||||||
|
id: 14
|
||||||
|
fixed_reference: true
|
||||||
|
- role: thumb_yaw
|
||||||
|
id: 15
|
||||||
|
motion:
|
||||||
|
speed_parameters:
|
||||||
|
command_rate_hz: 50
|
||||||
|
clearance_flex_rad_s: 0.1
|
||||||
|
clearance_splay_rad_s: 0.04
|
||||||
|
probe_travel_rad: 0.05235987755982989
|
||||||
|
probe_speed_rad_s: 0.02
|
||||||
|
endpoint_hold_seconds: 1.0
|
||||||
|
stall_timeout_seconds: 2.0
|
||||||
|
baseline_rad_s: 0.2
|
||||||
|
trajectory_ramp_seconds: 0.4
|
||||||
|
tasks:
|
||||||
|
- key: thumb_pitch_front
|
||||||
|
view: front
|
||||||
|
command_index: 2
|
||||||
|
joints:
|
||||||
|
- thumb_cmc_pitch
|
||||||
|
start: 0
|
||||||
|
end: -0.8272860654453121
|
||||||
|
preflight_speed: 0.06
|
||||||
|
formal_speed: 0.1
|
||||||
|
preparation_groups:
|
||||||
|
- - 0
|
||||||
|
- 1
|
||||||
|
- 3
|
||||||
|
- - 4
|
||||||
|
- 7
|
||||||
|
- - 5
|
||||||
|
- 6
|
||||||
|
- 8
|
||||||
|
- 9
|
||||||
|
- - 10
|
||||||
|
- 11
|
||||||
|
- - 2
|
||||||
|
- key: thumb_roll_front
|
||||||
|
view: front
|
||||||
|
command_index: 0
|
||||||
|
joints:
|
||||||
|
- thumb_cmc_roll
|
||||||
|
start: 0
|
||||||
|
end: 0.9424777960769379
|
||||||
|
preflight_speed: 0.06
|
||||||
|
formal_speed: 0.16
|
||||||
|
preparation_groups:
|
||||||
|
- - 1
|
||||||
|
- 2
|
||||||
|
- 3
|
||||||
|
- - 4
|
||||||
|
- 7
|
||||||
|
- - 5
|
||||||
|
- 6
|
||||||
|
- 8
|
||||||
|
- 9
|
||||||
|
- - 10
|
||||||
|
- 11
|
||||||
|
- - 0
|
||||||
|
- key: thumb_mcp_dip_front
|
||||||
|
view: front
|
||||||
|
command_index: 3
|
||||||
|
joints:
|
||||||
|
- thumb_mcp
|
||||||
|
- thumb_dip
|
||||||
|
start: 0
|
||||||
|
end: -1.2915436464758039
|
||||||
|
preflight_speed: 0.06
|
||||||
|
formal_speed: 0.32
|
||||||
|
preparation_groups:
|
||||||
|
- - 0
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
- - 4
|
||||||
|
- 7
|
||||||
|
- - 5
|
||||||
|
- 6
|
||||||
|
- 8
|
||||||
|
- 9
|
||||||
|
- - 10
|
||||||
|
- 11
|
||||||
|
- - 3
|
||||||
|
- key: thumb_yaw_top
|
||||||
|
view: top
|
||||||
|
command_index: 1
|
||||||
|
joints:
|
||||||
|
- thumb_cmc_yaw
|
||||||
|
start: 0
|
||||||
|
end: -1.387536755335492
|
||||||
|
preflight_speed: 0.06
|
||||||
|
formal_speed: 0.16
|
||||||
|
preparation_groups:
|
||||||
|
- - 0
|
||||||
|
- 2
|
||||||
|
- 3
|
||||||
|
- - 4
|
||||||
|
- 7
|
||||||
|
- - 5
|
||||||
|
- 6
|
||||||
|
- 8
|
||||||
|
- 9
|
||||||
|
- - 10
|
||||||
|
- 11
|
||||||
|
- - 1
|
||||||
|
- key: pinky_chain_side
|
||||||
|
view: side
|
||||||
|
command_index: 11
|
||||||
|
joints:
|
||||||
|
- pinky_mcp_pitch
|
||||||
|
- pinky_pip
|
||||||
|
- pinky_dip
|
||||||
|
start: 0
|
||||||
|
end: 1.53588974175501
|
||||||
|
preflight_speed: 0.06
|
||||||
|
formal_speed: 0.32
|
||||||
|
preparation_groups:
|
||||||
|
- - 0
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
- 3
|
||||||
|
- - 4
|
||||||
|
- 7
|
||||||
|
- - 5
|
||||||
|
- 6
|
||||||
|
- 8
|
||||||
|
- 9
|
||||||
|
- - 10
|
||||||
|
- - 11
|
||||||
|
- key: middle_roll_front
|
||||||
|
view: front
|
||||||
|
command_index: 7
|
||||||
|
joints:
|
||||||
|
- middle_mcp_roll
|
||||||
|
auxiliary_commands:
|
||||||
|
- - 4
|
||||||
|
- -0.17453292519943295
|
||||||
|
- - 5
|
||||||
|
- 0.11
|
||||||
|
- - 10
|
||||||
|
- 1.53588974175501
|
||||||
|
- - 11
|
||||||
|
- 1.53588974175501
|
||||||
|
- - 8
|
||||||
|
- 0.17
|
||||||
|
start: 0.2617993877991494
|
||||||
|
end: -0.2617993877991494
|
||||||
|
preflight_speed: 0.06
|
||||||
|
formal_speed: 0.12
|
||||||
|
preparation_groups:
|
||||||
|
- - 10
|
||||||
|
- 11
|
||||||
|
- - 5
|
||||||
|
- 6
|
||||||
|
- - 4
|
||||||
|
- - 0
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
- 3
|
||||||
|
- - 8
|
||||||
|
- 9
|
||||||
|
- - 7
|
||||||
|
- key: middle_mcp_side
|
||||||
|
view: side
|
||||||
|
command_index: 8
|
||||||
|
joints:
|
||||||
|
- middle_mcp_pitch
|
||||||
|
auxiliary_commands:
|
||||||
|
- - 4
|
||||||
|
- -0.17453292519943295
|
||||||
|
- - 5
|
||||||
|
- 0.11
|
||||||
|
- - 10
|
||||||
|
- 1.53588974175501
|
||||||
|
- - 11
|
||||||
|
- 1.53588974175501
|
||||||
|
start: 0
|
||||||
|
end: 1.3578661580515883
|
||||||
|
preflight_speed: 0.06
|
||||||
|
formal_speed: 0.32
|
||||||
|
preparation_groups:
|
||||||
|
- - 10
|
||||||
|
- 11
|
||||||
|
- - 5
|
||||||
|
- 6
|
||||||
|
- - 4
|
||||||
|
- - 0
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
- 3
|
||||||
|
- - 7
|
||||||
|
- 9
|
||||||
|
- - 8
|
||||||
|
- key: middle_pip_dip_side
|
||||||
|
view: side
|
||||||
|
command_index: 9
|
||||||
|
joints:
|
||||||
|
- middle_pip
|
||||||
|
- middle_dip
|
||||||
|
auxiliary_commands:
|
||||||
|
- - 4
|
||||||
|
- -0.17453292519943295
|
||||||
|
- - 5
|
||||||
|
- 0.11
|
||||||
|
- - 10
|
||||||
|
- 1.53588974175501
|
||||||
|
- - 11
|
||||||
|
- 1.53588974175501
|
||||||
|
start: 0
|
||||||
|
end: 1.8151424220741028
|
||||||
|
preflight_speed: 0.06
|
||||||
|
formal_speed: 0.32
|
||||||
|
preparation_groups:
|
||||||
|
- - 10
|
||||||
|
- 11
|
||||||
|
- - 5
|
||||||
|
- 6
|
||||||
|
- - 4
|
||||||
|
- - 0
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
- 3
|
||||||
|
- - 7
|
||||||
|
- 8
|
||||||
|
- - 9
|
||||||
|
- key: index_roll_front
|
||||||
|
view: front
|
||||||
|
command_index: 4
|
||||||
|
joints:
|
||||||
|
- index_mcp_roll
|
||||||
|
auxiliary_commands:
|
||||||
|
- - 7
|
||||||
|
- 0
|
||||||
|
- - 8
|
||||||
|
- 1.3578661580515883
|
||||||
|
- - 9
|
||||||
|
- 1.8151424220741028
|
||||||
|
- - 10
|
||||||
|
- 1.53588974175501
|
||||||
|
- - 11
|
||||||
|
- 1.53588974175501
|
||||||
|
- - 5
|
||||||
|
- 0.17
|
||||||
|
start: 0.2617993877991494
|
||||||
|
end: -0.2617993877991494
|
||||||
|
preflight_speed: 0.06
|
||||||
|
formal_speed: 0.12
|
||||||
|
entry_waypoints:
|
||||||
|
- - - 10
|
||||||
|
- 1.53588974175501
|
||||||
|
- - 11
|
||||||
|
- 1.53588974175501
|
||||||
|
- - - 4
|
||||||
|
- 0
|
||||||
|
- - 7
|
||||||
|
- 0
|
||||||
|
- - - 8
|
||||||
|
- 1.3578661580515883
|
||||||
|
- - 9
|
||||||
|
- 1.8151424220741028
|
||||||
|
preparation_groups:
|
||||||
|
- - 10
|
||||||
|
- 11
|
||||||
|
- - 7
|
||||||
|
- - 8
|
||||||
|
- 9
|
||||||
|
- - 0
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
- 3
|
||||||
|
- - 5
|
||||||
|
- 6
|
||||||
|
- - 4
|
||||||
|
- key: index_mcp_side
|
||||||
|
view: side
|
||||||
|
command_index: 5
|
||||||
|
joints:
|
||||||
|
- index_mcp_pitch
|
||||||
|
auxiliary_commands:
|
||||||
|
- - 7
|
||||||
|
- 0
|
||||||
|
- - 8
|
||||||
|
- 1.3578661580515883
|
||||||
|
- - 9
|
||||||
|
- 1.8151424220741028
|
||||||
|
- - 10
|
||||||
|
- 1.53588974175501
|
||||||
|
- - 11
|
||||||
|
- 1.53588974175501
|
||||||
|
start: 0
|
||||||
|
end: 1.3526301702956054
|
||||||
|
preflight_speed: 0.06
|
||||||
|
formal_speed: 0.32
|
||||||
|
entry_waypoints:
|
||||||
|
- - - 10
|
||||||
|
- 1.53588974175501
|
||||||
|
- - 11
|
||||||
|
- 1.53588974175501
|
||||||
|
- - - 4
|
||||||
|
- 0
|
||||||
|
- - 7
|
||||||
|
- 0
|
||||||
|
- - - 8
|
||||||
|
- 1.3578661580515883
|
||||||
|
- - 9
|
||||||
|
- 1.8151424220741028
|
||||||
|
preparation_groups:
|
||||||
|
- - 10
|
||||||
|
- 11
|
||||||
|
- - 7
|
||||||
|
- - 8
|
||||||
|
- 9
|
||||||
|
- - 0
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
- 3
|
||||||
|
- - 4
|
||||||
|
- 6
|
||||||
|
- - 5
|
||||||
|
- key: index_pip_dip_side
|
||||||
|
view: side
|
||||||
|
command_index: 6
|
||||||
|
joints:
|
||||||
|
- index_pip
|
||||||
|
- index_dip
|
||||||
|
auxiliary_commands:
|
||||||
|
- - 7
|
||||||
|
- 0
|
||||||
|
- - 8
|
||||||
|
- 1.3578661580515883
|
||||||
|
- - 9
|
||||||
|
- 1.8151424220741028
|
||||||
|
- - 10
|
||||||
|
- 1.53588974175501
|
||||||
|
- - 11
|
||||||
|
- 1.53588974175501
|
||||||
|
start: 0
|
||||||
|
end: 1.530653753999027
|
||||||
|
preflight_speed: 0.06
|
||||||
|
formal_speed: 0.32
|
||||||
|
entry_waypoints:
|
||||||
|
- - - 10
|
||||||
|
- 1.53588974175501
|
||||||
|
- - 11
|
||||||
|
- 1.53588974175501
|
||||||
|
- - - 4
|
||||||
|
- 0
|
||||||
|
- - 7
|
||||||
|
- 0
|
||||||
|
- - - 8
|
||||||
|
- 1.3578661580515883
|
||||||
|
- - 9
|
||||||
|
- 1.8151424220741028
|
||||||
|
preparation_groups:
|
||||||
|
- - 10
|
||||||
|
- 11
|
||||||
|
- - 7
|
||||||
|
- - 8
|
||||||
|
- 9
|
||||||
|
- - 0
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
- 3
|
||||||
|
- - 4
|
||||||
|
- 5
|
||||||
|
- - 6
|
||||||
|
return_groups:
|
||||||
|
- - 4
|
||||||
|
- 7
|
||||||
|
- - 8
|
||||||
|
- 9
|
||||||
|
- - 10
|
||||||
|
- 11
|
||||||
|
- - 0
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
- 3
|
||||||
|
- 5
|
||||||
|
- 6
|
||||||
|
measurement:
|
||||||
|
candidate_selection_tasks:
|
||||||
|
- thumb_mcp_dip_front
|
||||||
|
directional_zero: true
|
||||||
|
stable_cross_view_cone_bias: true
|
||||||
|
cross_view_sources:
|
||||||
|
middle_mcp_roll: middle_mcp_pitch
|
||||||
|
index_mcp_roll: index_mcp_pitch
|
||||||
|
measurements:
|
||||||
|
thumb_cmc_pitch:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: front
|
||||||
|
parent_role: front_base
|
||||||
|
child_role: thumb_cmc
|
||||||
|
thumb_cmc_roll:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: front
|
||||||
|
parent_role: front_base
|
||||||
|
child_role: thumb_cmc
|
||||||
|
thumb_mcp:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: front
|
||||||
|
parent_role: thumb_cmc
|
||||||
|
child_role: thumb_mcp
|
||||||
|
thumb_dip:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: front
|
||||||
|
parent_role: thumb_mcp
|
||||||
|
child_role: thumb_dip
|
||||||
|
pose_axis_line_required: false
|
||||||
|
thumb_cmc_yaw:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: top
|
||||||
|
parent_role: top_base
|
||||||
|
child_role: thumb_yaw
|
||||||
|
pinky_mcp_pitch:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: side
|
||||||
|
parent_role: side_base
|
||||||
|
child_role: pinky_mcp
|
||||||
|
pinky_pip:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: side
|
||||||
|
parent_role: pinky_mcp
|
||||||
|
child_role: pinky_pip
|
||||||
|
pose_axis_line_required: false
|
||||||
|
pinky_dip:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: side
|
||||||
|
parent_role: pinky_pip
|
||||||
|
child_role: pinky_dip
|
||||||
|
pose_axis_line_required: false
|
||||||
|
middle_mcp_roll:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: front
|
||||||
|
parent_role: front_base
|
||||||
|
child_role: middle_roll
|
||||||
|
middle_mcp_pitch:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: side
|
||||||
|
parent_role: side_base
|
||||||
|
child_role: middle_pip
|
||||||
|
middle_pip:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: side
|
||||||
|
parent_role: side_base
|
||||||
|
child_role: middle_pip
|
||||||
|
middle_dip:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: side
|
||||||
|
parent_role: middle_pip
|
||||||
|
child_role: middle_dip
|
||||||
|
pose_axis_line_required: false
|
||||||
|
index_mcp_roll:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: front
|
||||||
|
parent_role: front_base
|
||||||
|
child_role: index_roll
|
||||||
|
index_mcp_pitch:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: side
|
||||||
|
parent_role: side_base
|
||||||
|
child_role: index_pip
|
||||||
|
index_pip:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: side
|
||||||
|
parent_role: side_base
|
||||||
|
child_role: index_pip
|
||||||
|
index_dip:
|
||||||
|
kind: relative_rotation
|
||||||
|
view: side
|
||||||
|
parent_role: index_pip
|
||||||
|
child_role: index_dip
|
||||||
|
pose_axis_line_required: false
|
||||||
|
zero:
|
||||||
|
active_joints:
|
||||||
|
- thumb_cmc_roll
|
||||||
|
- thumb_cmc_yaw
|
||||||
|
- thumb_cmc_pitch
|
||||||
|
- thumb_mcp
|
||||||
|
- index_mcp_roll
|
||||||
|
- index_mcp_pitch
|
||||||
|
- index_pip
|
||||||
|
- middle_mcp_roll
|
||||||
|
- middle_mcp_pitch
|
||||||
|
- middle_pip
|
||||||
|
- ring_mcp_pitch
|
||||||
|
- pinky_mcp_pitch
|
||||||
|
passive_joints:
|
||||||
|
- thumb_dip
|
||||||
|
- index_dip
|
||||||
|
- middle_dip
|
||||||
|
- ring_pip
|
||||||
|
- ring_dip
|
||||||
|
- pinky_pip
|
||||||
|
- pinky_dip
|
||||||
|
direct_zero_joints:
|
||||||
|
- thumb_cmc_roll
|
||||||
|
- thumb_cmc_yaw
|
||||||
|
- thumb_cmc_pitch
|
||||||
|
- index_mcp_roll
|
||||||
|
- index_mcp_pitch
|
||||||
|
- index_pip
|
||||||
|
- middle_mcp_roll
|
||||||
|
- middle_mcp_pitch
|
||||||
|
- middle_pip
|
||||||
|
- pinky_mcp_pitch
|
||||||
|
axis_joints:
|
||||||
|
- thumb_cmc_roll
|
||||||
|
- thumb_cmc_yaw
|
||||||
|
- thumb_cmc_pitch
|
||||||
|
- thumb_mcp
|
||||||
|
- index_mcp_roll
|
||||||
|
- index_mcp_pitch
|
||||||
|
- index_pip
|
||||||
|
- middle_mcp_roll
|
||||||
|
- middle_mcp_pitch
|
||||||
|
- middle_pip
|
||||||
|
- pinky_mcp_pitch
|
||||||
|
- index_dip
|
||||||
|
- middle_dip
|
||||||
|
- pinky_pip
|
||||||
|
mechanical_endpoint_joints: []
|
||||||
|
post_solve_endpoint_joints: []
|
||||||
|
mimic_source_by_joint:
|
||||||
|
thumb_dip: thumb_mcp
|
||||||
|
index_dip: index_pip
|
||||||
|
middle_dip: middle_pip
|
||||||
|
ring_pip: ring_mcp_pitch
|
||||||
|
ring_dip: ring_pip
|
||||||
|
pinky_pip: pinky_mcp_pitch
|
||||||
|
pinky_dip: pinky_pip
|
||||||
|
cad_frozen_joints:
|
||||||
|
- thumb_mcp
|
||||||
|
- thumb_dip
|
||||||
|
- index_dip
|
||||||
|
- middle_dip
|
||||||
|
- ring_pip
|
||||||
|
- ring_dip
|
||||||
|
- pinky_pip
|
||||||
|
- pinky_dip
|
||||||
|
fitted_mimic_joints:
|
||||||
|
- thumb_dip
|
||||||
|
- index_dip
|
||||||
|
- middle_dip
|
||||||
|
- pinky_pip
|
||||||
|
- pinky_dip
|
||||||
|
coupling_model_by_joint:
|
||||||
|
thumb_dip: linear_mimic
|
||||||
|
index_dip: linear_mimic
|
||||||
|
middle_dip: linear_mimic
|
||||||
|
pinky_pip: linear_mimic
|
||||||
|
pinky_dip: linear_mimic
|
||||||
|
transferred_zero_sources:
|
||||||
|
ring_mcp_pitch: pinky_mcp_pitch
|
||||||
|
spatial:
|
||||||
|
base_pose_strategy: thumb_serial
|
||||||
|
root_anchor_joints:
|
||||||
|
- thumb_cmc_roll
|
||||||
|
orientation_anchor_joint: pinky_mcp_pitch
|
||||||
|
directed_base_axis_joints:
|
||||||
|
- thumb_cmc_roll
|
||||||
|
- pinky_mcp_pitch
|
||||||
|
depth_free_axis_projection: true
|
||||||
|
accept_validated_zero_in_confidence_interval: true
|
||||||
|
project_axis_gauge_before_image: true
|
||||||
|
axis_order:
|
||||||
|
- thumb_cmc_roll
|
||||||
|
- thumb_cmc_yaw
|
||||||
|
- thumb_cmc_pitch
|
||||||
|
- thumb_mcp
|
||||||
|
- pinky_mcp_pitch
|
||||||
|
- pinky_pip
|
||||||
|
- middle_mcp_roll
|
||||||
|
- middle_mcp_pitch
|
||||||
|
- middle_pip
|
||||||
|
- middle_dip
|
||||||
|
- index_mcp_roll
|
||||||
|
- index_mcp_pitch
|
||||||
|
- index_pip
|
||||||
|
- index_dip
|
||||||
|
axis_parent_joint:
|
||||||
|
thumb_cmc_yaw: thumb_cmc_roll
|
||||||
|
thumb_cmc_pitch: thumb_cmc_yaw
|
||||||
|
index_mcp_pitch: index_mcp_roll
|
||||||
|
middle_mcp_pitch: middle_mcp_roll
|
||||||
|
phase_parent_joint:
|
||||||
|
thumb_mcp: thumb_cmc_pitch
|
||||||
|
index_pip: index_mcp_pitch
|
||||||
|
index_dip: index_pip
|
||||||
|
middle_pip: middle_mcp_pitch
|
||||||
|
middle_dip: middle_pip
|
||||||
|
pinky_pip: pinky_mcp_pitch
|
||||||
|
offset_observer_joint:
|
||||||
|
thumb_cmc_roll: thumb_cmc_yaw
|
||||||
|
thumb_cmc_yaw: thumb_cmc_pitch
|
||||||
|
thumb_cmc_pitch: thumb_mcp
|
||||||
|
index_mcp_roll: index_mcp_pitch
|
||||||
|
index_mcp_pitch: index_pip
|
||||||
|
index_pip: index_dip
|
||||||
|
middle_mcp_roll: middle_mcp_pitch
|
||||||
|
middle_mcp_pitch: middle_pip
|
||||||
|
middle_pip: middle_dip
|
||||||
|
pinky_mcp_pitch: pinky_pip
|
||||||
|
quality:
|
||||||
|
training_cycles:
|
||||||
|
- 0
|
||||||
|
- 1
|
||||||
|
- 2
|
||||||
|
holdout_cycle: 3
|
||||||
|
hard_threshold_keys:
|
||||||
|
- holdout
|
||||||
|
isolated_holdout: true
|
||||||
|
scope:
|
||||||
|
default_scope: full
|
||||||
|
calibrate_joints:
|
||||||
|
full:
|
||||||
|
- thumb_cmc_roll
|
||||||
|
- thumb_cmc_yaw
|
||||||
|
- thumb_cmc_pitch
|
||||||
|
- thumb_mcp
|
||||||
|
- index_mcp_roll
|
||||||
|
- index_mcp_pitch
|
||||||
|
- index_pip
|
||||||
|
- middle_mcp_roll
|
||||||
|
- middle_mcp_pitch
|
||||||
|
- middle_pip
|
||||||
|
- ring_mcp_pitch
|
||||||
|
- pinky_mcp_pitch
|
||||||
|
frozen_joints:
|
||||||
|
full: []
|
||||||
|
artifacts:
|
||||||
|
output_schema_version: 2
|
||||||
|
calibration_filename: o12_right_{serial_number}_calibration.json
|
||||||
|
corrected_urdf_filename: linkerhand_o12_right_{serial_number}_zero_calibrated.urdf
|
||||||
|
protected_input_fields:
|
||||||
|
- source_urdf_sha256
|
||||||
|
- camera_extrinsics_sha256
|
||||||
|
- calibration_config_sha256
|
||||||
|
- tag_config_sha256
|
||||||
|
- sdk_config_sha256
|
||||||
|
- sdk_package_sha256
|
||||||
|
- profile_config_sha256
|
||||||
|
publication_pointer: latest_passed
|
||||||
|
session_compatibility_tokens:
|
||||||
|
- o12_right_16_v1
|
||||||
|
- feedback_rad_v1
|
||||||
|
- full_sdk_range_v2
|
||||||
|
publish_corrected_urdf: true
|
||||||
|
acquisition:
|
||||||
|
policy_version: unified_engine_v4_dual_mapping
|
||||||
|
mapping_probe_maximum_rad: 0.05235987755982989
|
||||||
|
urdf:
|
||||||
|
authorized_fields:
|
||||||
|
thumb_cmc_roll:
|
||||||
|
- origin.rpy
|
||||||
|
- limit.lower
|
||||||
|
- limit.upper
|
||||||
|
thumb_cmc_yaw:
|
||||||
|
- origin.rpy
|
||||||
|
- limit.lower
|
||||||
|
- limit.upper
|
||||||
|
thumb_cmc_pitch:
|
||||||
|
- origin.rpy
|
||||||
|
- limit.lower
|
||||||
|
- limit.upper
|
||||||
|
thumb_mcp:
|
||||||
|
- origin.rpy
|
||||||
|
- limit.lower
|
||||||
|
- limit.upper
|
||||||
|
index_mcp_roll:
|
||||||
|
- origin.rpy
|
||||||
|
- limit.lower
|
||||||
|
- limit.upper
|
||||||
|
index_mcp_pitch:
|
||||||
|
- origin.rpy
|
||||||
|
- limit.lower
|
||||||
|
- limit.upper
|
||||||
|
index_pip:
|
||||||
|
- origin.rpy
|
||||||
|
- limit.lower
|
||||||
|
- limit.upper
|
||||||
|
middle_mcp_roll:
|
||||||
|
- origin.rpy
|
||||||
|
- limit.lower
|
||||||
|
- limit.upper
|
||||||
|
middle_mcp_pitch:
|
||||||
|
- origin.rpy
|
||||||
|
- limit.lower
|
||||||
|
- limit.upper
|
||||||
|
middle_pip:
|
||||||
|
- origin.rpy
|
||||||
|
- limit.lower
|
||||||
|
- limit.upper
|
||||||
|
ring_mcp_pitch:
|
||||||
|
- origin.rpy
|
||||||
|
- limit.lower
|
||||||
|
- limit.upper
|
||||||
|
pinky_mcp_pitch:
|
||||||
|
- origin.rpy
|
||||||
|
- limit.lower
|
||||||
|
- limit.upper
|
||||||
|
thumb_dip:
|
||||||
|
- mimic.multiplier
|
||||||
|
- mimic.offset
|
||||||
|
index_dip:
|
||||||
|
- mimic.multiplier
|
||||||
|
- mimic.offset
|
||||||
|
middle_dip:
|
||||||
|
- mimic.multiplier
|
||||||
|
- mimic.offset
|
||||||
|
pinky_pip:
|
||||||
|
- mimic.multiplier
|
||||||
|
- mimic.offset
|
||||||
|
pinky_dip:
|
||||||
|
- mimic.multiplier
|
||||||
|
- mimic.offset
|
||||||
|
ring_pip:
|
||||||
|
- mimic.offset
|
||||||
|
ring_dip:
|
||||||
|
- mimic.offset
|
||||||
|
joint_coverage:
|
||||||
|
thumb_cmc_roll: measured_static_dynamic
|
||||||
|
thumb_cmc_yaw: measured_static_dynamic
|
||||||
|
thumb_cmc_pitch: measured_static_dynamic
|
||||||
|
thumb_mcp: measured_dynamic_cad_static
|
||||||
|
thumb_dip: measured_dynamic_cad_static
|
||||||
|
index_mcp_roll: measured_static_dynamic
|
||||||
|
index_mcp_pitch: measured_static_dynamic
|
||||||
|
index_pip: measured_static_dynamic
|
||||||
|
index_dip: measured_dynamic_cad_static
|
||||||
|
middle_mcp_roll: measured_static_dynamic
|
||||||
|
middle_mcp_pitch: measured_static_dynamic
|
||||||
|
middle_pip: measured_static_dynamic
|
||||||
|
middle_dip: measured_dynamic_cad_static
|
||||||
|
ring_mcp_pitch: transferred_static_dynamic
|
||||||
|
ring_pip: mimic_nominal
|
||||||
|
ring_dip: mimic_nominal
|
||||||
|
pinky_mcp_pitch: measured_static_dynamic
|
||||||
|
pinky_pip: measured_dynamic_cad_static
|
||||||
|
pinky_dip: measured_dynamic_cad_static
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
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
|
||||||
|
- role: thumb_pitch
|
||||||
|
fixed_reference: false
|
||||||
|
id: 1
|
||||||
|
- role: thumb_ip
|
||||||
|
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_yaw
|
||||||
|
fixed_reference: false
|
||||||
|
id: 7
|
||||||
|
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
|
||||||
|
measurement:
|
||||||
|
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:
|
||||||
|
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: 2
|
||||||
|
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:
|
||||||
|
policy_version: unified_engine_v4_dual_mapping
|
||||||
|
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_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
|
||||||
|
rh_index_dip:
|
||||||
|
- mimic.multiplier
|
||||||
|
- mimic.offset
|
||||||
|
rh_pinky_dip:
|
||||||
|
- mimic.multiplier
|
||||||
|
- mimic.offset
|
||||||
|
rh_thumb_ip:
|
||||||
|
- mimic.multiplier
|
||||||
|
- mimic.offset
|
||||||
|
rh_middle_dip:
|
||||||
|
- mimic.multiplier
|
||||||
|
- mimic.offset
|
||||||
|
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,452 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
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
|
||||||
|
views = contract.typed_profile.vision.view_names
|
||||||
|
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"}:
|
||||||
|
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 Profile camera serial numbers are required")
|
||||||
|
if len(set(camera_serials.values())) != len(views):
|
||||||
|
raise RuntimeError("Profile 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,
|
||||||
|
"frame_rate": ParameterValue(
|
||||||
|
LaunchConfiguration("camera_frame_rate"),
|
||||||
|
value_type=float,
|
||||||
|
),
|
||||||
|
"exposure_time_us": ParameterValue(
|
||||||
|
LaunchConfiguration("exposure_time_us"),
|
||||||
|
value_type=float,
|
||||||
|
),
|
||||||
|
"gain_db": ParameterValue(
|
||||||
|
LaunchConfiguration("gain_db"), value_type=float
|
||||||
|
),
|
||||||
|
"auto_exposure": ParameterValue(
|
||||||
|
LaunchConfiguration("auto_exposure"), value_type=bool
|
||||||
|
),
|
||||||
|
"camera_info_url": LaunchConfiguration(
|
||||||
|
f"{view}_camera_info_url"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
components.extend(
|
||||||
|
[
|
||||||
|
ComposableNode(
|
||||||
|
package="image_proc",
|
||||||
|
plugin="image_proc::RectifyNode",
|
||||||
|
name=f"rectify_{view}",
|
||||||
|
namespace=namespace,
|
||||||
|
remappings=[
|
||||||
|
("image", raw_topic),
|
||||||
|
("camera_info", info_topic),
|
||||||
|
("image_rect", rect_topic),
|
||||||
|
],
|
||||||
|
parameters=[{"queue_size": 1}],
|
||||||
|
extra_arguments=[{"use_intra_process_comms": True}],
|
||||||
|
),
|
||||||
|
ComposableNode(
|
||||||
|
package="apriltag_ros",
|
||||||
|
plugin="AprilTagNode",
|
||||||
|
name="apriltag",
|
||||||
|
namespace=detector_namespace,
|
||||||
|
parameters=[
|
||||||
|
str(tag_config),
|
||||||
|
{
|
||||||
|
"detector.decimate": ParameterValue(
|
||||||
|
LaunchConfiguration("apriltag_decimate"),
|
||||||
|
value_type=float,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
remappings=[
|
||||||
|
("image_rect", rect_topic),
|
||||||
|
("camera_info", info_topic),
|
||||||
|
],
|
||||||
|
extra_arguments=[{"use_intra_process_comms": True}],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
vision = ComposableNodeContainer(
|
||||||
|
name=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,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
"command_topic": command_topic,
|
||||||
|
"state_topic": state_topic,
|
||||||
|
"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
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
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("apriltag_decimate", default_value="1.5"),
|
||||||
|
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("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(
|
||||||
|
"calibration_config",
|
||||||
|
default_value="",
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"tag_config",
|
||||||
|
default_value="",
|
||||||
|
),
|
||||||
|
OpaqueFunction(function=_launch_stack),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""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"),
|
||||||
|
"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(
|
||||||
|
"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"]
|
||||||
+47
-4
@@ -9,7 +9,7 @@ from typing import Any, Mapping, Sequence
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from .core import PAIR_NAMES, robust_rotation_summary
|
from .core import robust_rotation_summary
|
||||||
from .pnp import SquareTagPose
|
from .pnp import SquareTagPose
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ TAG_PAIR_ROLES: dict[str, tuple[str, str]] = {
|
|||||||
"t3_t4": ("t3", "t4"),
|
"t3_t4": ("t3", "t4"),
|
||||||
"t4_t5": ("t4", "t5"),
|
"t4_t5": ("t4", "t5"),
|
||||||
}
|
}
|
||||||
|
PAIR_NAMES: tuple[str, ...] = tuple(TAG_PAIR_ROLES)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -51,6 +52,39 @@ def tag_quality_is_valid(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
@dataclass(frozen=True)
|
||||||
class Observation:
|
class Observation:
|
||||||
stamp_ns: int
|
stamp_ns: int
|
||||||
@@ -83,7 +117,7 @@ def interpolate_state_u8(
|
|||||||
*,
|
*,
|
||||||
maximum_skew_ns: int,
|
maximum_skew_ns: int,
|
||||||
) -> tuple[tuple[float, ...], int] | None:
|
) -> tuple[tuple[float, ...], int] | None:
|
||||||
"""Interpolate the 20-D hand state at an image timestamp.
|
"""Interpolate a profile-sized hand state at an image timestamp.
|
||||||
|
|
||||||
The SDK publishes state independently from the camera. Continuous
|
The SDK publishes state independently from the camera. Continuous
|
||||||
calibration must therefore use the image timestamp instead of whichever
|
calibration must therefore use the image timestamp instead of whichever
|
||||||
@@ -117,7 +151,11 @@ def interpolate_state_u8(
|
|||||||
fraction = before_gap / denominator
|
fraction = before_gap / denominator
|
||||||
before_values = np.asarray(before.position_u8, dtype=float)
|
before_values = np.asarray(before.position_u8, dtype=float)
|
||||||
after_values = np.asarray(after.position_u8, dtype=float)
|
after_values = np.asarray(after.position_u8, dtype=float)
|
||||||
if before_values.shape != (20,) or after_values.shape != (20,):
|
if (
|
||||||
|
before_values.ndim != 1
|
||||||
|
or before_values.size == 0
|
||||||
|
or after_values.shape != before_values.shape
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
interpolated = before_values + fraction * (after_values - before_values)
|
interpolated = before_values + fraction * (after_values - before_values)
|
||||||
return (
|
return (
|
||||||
@@ -129,11 +167,16 @@ def interpolate_state_u8(
|
|||||||
if nearest is None:
|
if nearest is None:
|
||||||
return None
|
return None
|
||||||
gap = abs(int(stamp_ns) - int(nearest.stamp_ns))
|
gap = abs(int(stamp_ns) - int(nearest.stamp_ns))
|
||||||
if gap > maximum_skew_ns or len(nearest.position_u8) != 20:
|
if gap > maximum_skew_ns or not nearest.position_u8:
|
||||||
return None
|
return None
|
||||||
return (tuple(float(value) for value in nearest.position_u8), gap)
|
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:
|
class ContinuousSweepCollector:
|
||||||
"""Collect timestamp-synchronised observations during one end-to-end move."""
|
"""Collect timestamp-synchronised observations during one end-to-end move."""
|
||||||
|
|
||||||
@@ -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"}:
|
||||||
|
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,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
|
||||||
|
)
|
||||||
+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/` 中的归档,不要重新接入生产入口。
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
"""Read-only compatibility for archived sessions and diagnostic tools."""
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
"""Model- and side-specific calibration policies."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import (
|
||||||
|
EngineBindings,
|
||||||
|
ProfileRegistry,
|
||||||
|
RegisteredProfile,
|
||||||
|
get_default_registry,
|
||||||
|
)
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.runtime_schema import validate_schema_v6_runtime_payload
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EngineBindings",
|
||||||
|
"ProfileRegistry",
|
||||||
|
"RegisteredProfile",
|
||||||
|
"get_default_registry",
|
||||||
|
"validate_schema_v6_runtime_payload",
|
||||||
|
]
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
"""Registered profiles for this hand family."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import ProfileRegistry
|
||||||
|
|
||||||
|
|
||||||
|
def register_profiles(registry: ProfileRegistry) -> None:
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.legacy_11 import build_left_profile, build_right_profile
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.right_19 import build_profile
|
||||||
|
|
||||||
|
registry.register(build_profile())
|
||||||
|
registry.register(build_left_profile())
|
||||||
|
registry.register(build_right_profile())
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["register_profiles"]
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
"""Adapt reviewed family profiles to the shared typed contract."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from linkerhand_calibration.core import (
|
||||||
|
CalibrationProfile,
|
||||||
|
CommandLayout,
|
||||||
|
MeasurementPolicy,
|
||||||
|
MeasurementSpec,
|
||||||
|
MotionPolicy,
|
||||||
|
ProfileKey,
|
||||||
|
QualityPolicy,
|
||||||
|
ScopePolicy,
|
||||||
|
TagSpec,
|
||||||
|
TaskSpec,
|
||||||
|
ViewSpec,
|
||||||
|
VisionRigSpec,
|
||||||
|
ZeroSolvePolicy,
|
||||||
|
)
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import MIMIC_DERIVED_FINGER_DIPS
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import EngineBindings, RegisteredProfile
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.artifacts import build_artifact_policy
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.motion import (
|
||||||
|
build_calibration_motion_command,
|
||||||
|
build_calibration_preparation_waypoints,
|
||||||
|
build_calibration_return_waypoints,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_HARD_THRESHOLD_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"minimum_detection_rate",
|
||||||
|
"maximum_reprojection_error_px",
|
||||||
|
"maximum_axis_cycle_difference_rad",
|
||||||
|
"maximum_pose_line_rms_m",
|
||||||
|
"maximum_validation_error_rad",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cli(args: list[str] | None = None) -> None:
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.runner import main
|
||||||
|
|
||||||
|
main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_node(args: list[str] | None = None) -> None:
|
||||||
|
from linkerhand_calibration.runtime.ros.entrypoint import main
|
||||||
|
|
||||||
|
main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_typed_profile(
|
||||||
|
*,
|
||||||
|
key: ProfileKey,
|
||||||
|
namespace: str,
|
||||||
|
hand_profile,
|
||||||
|
zero_profile,
|
||||||
|
mechanical_endpoint_joints: frozenset[str] = frozenset(),
|
||||||
|
post_solve_endpoint_joints: frozenset[str] = frozenset(),
|
||||||
|
) -> CalibrationProfile:
|
||||||
|
fixed_by_view = {
|
||||||
|
view: frozenset(roles)
|
||||||
|
for view, roles in hand_profile.preflight_view_roles.items()
|
||||||
|
}
|
||||||
|
views = tuple(
|
||||||
|
ViewSpec(
|
||||||
|
name=view,
|
||||||
|
tags=tuple(
|
||||||
|
TagSpec(
|
||||||
|
role=role,
|
||||||
|
tag_id=int(tag_id),
|
||||||
|
fixed_reference=role in fixed_by_view.get(view, frozenset()),
|
||||||
|
)
|
||||||
|
for role, tag_id in roles.items()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for view, roles in hand_profile.view_tags.items()
|
||||||
|
)
|
||||||
|
record_specs = hand_profile.record_specs
|
||||||
|
command_index_by_joint = {
|
||||||
|
name: int(spec.motor_index) for name, spec in record_specs.items()
|
||||||
|
}
|
||||||
|
command_index_by_joint.update(
|
||||||
|
{
|
||||||
|
name: int(spec.motor_index)
|
||||||
|
for name, spec in hand_profile.joint_specs.items()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
measurements = {
|
||||||
|
name: MeasurementSpec(
|
||||||
|
joint=name,
|
||||||
|
kind=str(spec.zero_kind or "curve"),
|
||||||
|
view=spec.view,
|
||||||
|
parent_role=spec.parent_role,
|
||||||
|
child_role=spec.child_role,
|
||||||
|
validation_source=(hand_profile.axis_validation_sources or {}).get(
|
||||||
|
name
|
||||||
|
),
|
||||||
|
pose_axis_line_required=bool(
|
||||||
|
spec.pose_axis_line_required
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name, spec in record_specs.items()
|
||||||
|
}
|
||||||
|
tasks = tuple(
|
||||||
|
TaskSpec(
|
||||||
|
key=spec.key,
|
||||||
|
view=spec.view,
|
||||||
|
command_index=int(spec.motor_index),
|
||||||
|
joints=tuple(spec.joints),
|
||||||
|
auxiliary_commands=tuple(spec.auxiliary_commands),
|
||||||
|
validation_only=bool(spec.validation_only),
|
||||||
|
)
|
||||||
|
for spec in hand_profile.sweep_specs
|
||||||
|
)
|
||||||
|
active = frozenset(hand_profile.active_joints)
|
||||||
|
passive = frozenset(hand_profile.passive_joints)
|
||||||
|
thumb = frozenset(name for name in active if name.startswith("thumb_"))
|
||||||
|
fingers = active - thumb
|
||||||
|
typed = CalibrationProfile(
|
||||||
|
key=key,
|
||||||
|
namespace=namespace,
|
||||||
|
command=CommandLayout(
|
||||||
|
names=tuple(hand_profile.command_names),
|
||||||
|
baseline_u8=tuple(int(value) for value in hand_profile.baseline_command),
|
||||||
|
command_index_by_joint=command_index_by_joint,
|
||||||
|
disabled_indices=frozenset(
|
||||||
|
index
|
||||||
|
for index, name in enumerate(hand_profile.command_names)
|
||||||
|
if name.startswith("reserved_")
|
||||||
|
),
|
||||||
|
),
|
||||||
|
vision=VisionRigSpec(
|
||||||
|
views=views,
|
||||||
|
common_frame="calibration_common",
|
||||||
|
extrinsic_reference_view=views[0].name,
|
||||||
|
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=MotionPolicy(
|
||||||
|
tasks=tasks,
|
||||||
|
precheck_sweeps=bool(hand_profile.precheck_sweeps),
|
||||||
|
steady_command_checkpoints=bool(
|
||||||
|
hand_profile.steady_command_checkpoints
|
||||||
|
),
|
||||||
|
),
|
||||||
|
measurement=MeasurementPolicy(
|
||||||
|
measurements=measurements,
|
||||||
|
cross_view_sources=dict(
|
||||||
|
hand_profile.axis_validation_sources or {}
|
||||||
|
),
|
||||||
|
image_curve_joints=frozenset(
|
||||||
|
hand_profile.image_trajectory_joints
|
||||||
|
),
|
||||||
|
directional_zero=bool(hand_profile.directional_zero),
|
||||||
|
cross_view_roll_curve=bool(hand_profile.cross_view_roll_curve),
|
||||||
|
stable_cross_view_cone_bias=bool(
|
||||||
|
hand_profile.stable_cross_view_cone_bias
|
||||||
|
),
|
||||||
|
),
|
||||||
|
zero=ZeroSolvePolicy(
|
||||||
|
active_joints=active,
|
||||||
|
passive_joints=passive,
|
||||||
|
direct_zero_joints=tuple(zero_profile.direct_zero_joints),
|
||||||
|
axis_joints=tuple(zero_profile.axis_joints),
|
||||||
|
mechanical_endpoint_joints=mechanical_endpoint_joints,
|
||||||
|
post_solve_endpoint_joints=post_solve_endpoint_joints,
|
||||||
|
mimic_source_by_joint={
|
||||||
|
target: source
|
||||||
|
for target, source in MIMIC_DERIVED_FINGER_DIPS.items()
|
||||||
|
if target in passive and source in active
|
||||||
|
},
|
||||||
|
cad_frozen_joints=frozenset(
|
||||||
|
passive - set(zero_profile.static_output_zero_offsets_rad)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
quality=QualityPolicy(
|
||||||
|
training_cycles=(0, 1, 2),
|
||||||
|
holdout_cycle=3 if key.layout != "legacy_11" else None,
|
||||||
|
hard_threshold_keys=_HARD_THRESHOLD_KEYS,
|
||||||
|
isolated_holdout=bool(hand_profile.isolated_holdout),
|
||||||
|
),
|
||||||
|
scope=ScopePolicy(
|
||||||
|
calibrate_joints={
|
||||||
|
"full": active,
|
||||||
|
"thumb": thumb,
|
||||||
|
"fingers": fingers,
|
||||||
|
},
|
||||||
|
frozen_joints={
|
||||||
|
"full": frozenset(),
|
||||||
|
"thumb": fingers,
|
||||||
|
"fingers": thumb,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
artifacts=build_artifact_policy(
|
||||||
|
frozenset(hand_profile.capabilities)
|
||||||
|
),
|
||||||
|
urdf_authorized_fields={
|
||||||
|
name: frozenset({"origin.rpy", "limit.upper"})
|
||||||
|
for name in active
|
||||||
|
} | {
|
||||||
|
name: frozenset({"mimic.offset"})
|
||||||
|
for name in passive
|
||||||
|
if name in zero_profile.static_output_zero_offsets_rad
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return typed
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_profile(
|
||||||
|
*, key: ProfileKey, namespace: str, hand_profile, zero_profile,
|
||||||
|
mechanical_endpoint_joints: frozenset[str] = frozenset(),
|
||||||
|
post_solve_endpoint_joints: frozenset[str] = frozenset(),
|
||||||
|
typed_profile: CalibrationProfile | None = None,
|
||||||
|
) -> RegisteredProfile:
|
||||||
|
# Current products supply their authoritative YAML; only the archived
|
||||||
|
# 11-Tag compatibility contract is still compiled from its old definition.
|
||||||
|
typed = typed_profile if typed_profile is not None else _legacy_typed_profile(
|
||||||
|
key=key, namespace=namespace, hand_profile=hand_profile,
|
||||||
|
zero_profile=zero_profile, mechanical_endpoint_joints=mechanical_endpoint_joints,
|
||||||
|
post_solve_endpoint_joints=post_solve_endpoint_joints,
|
||||||
|
)
|
||||||
|
if typed.key != key or typed.namespace != namespace:
|
||||||
|
raise ValueError("declarative profile identity differs from product binding")
|
||||||
|
return RegisteredProfile(
|
||||||
|
profile=typed,
|
||||||
|
engine=EngineBindings(
|
||||||
|
hand_profile=hand_profile,
|
||||||
|
zero_profile=zero_profile,
|
||||||
|
motion_command=build_calibration_motion_command,
|
||||||
|
preparation_waypoints=build_calibration_preparation_waypoints,
|
||||||
|
return_waypoints=build_calibration_return_waypoints,
|
||||||
|
cli_main=_run_cli,
|
||||||
|
node_main=_run_node,
|
||||||
|
),
|
||||||
|
)
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
"""Runtime JSON and corrected-URDF naming policy."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.core import ArtifactPolicy
|
||||||
|
|
||||||
|
|
||||||
|
def build_artifact_policy(
|
||||||
|
compatibility_tokens: frozenset[str],
|
||||||
|
) -> ArtifactPolicy:
|
||||||
|
return ArtifactPolicy(
|
||||||
|
output_schema_version=4,
|
||||||
|
calibration_filename="g20_{side}_{serial_number}_calibration.json",
|
||||||
|
corrected_urdf_filename=(
|
||||||
|
"linkerhand_g20_{side}_{serial_number}_zero_calibrated.urdf"
|
||||||
|
),
|
||||||
|
protected_input_fields=frozenset(
|
||||||
|
{
|
||||||
|
"source_urdf_sha256",
|
||||||
|
"camera_extrinsics_sha256",
|
||||||
|
"calibration_config_sha256",
|
||||||
|
"tag_config_sha256",
|
||||||
|
"profile_config_sha256",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
session_compatibility_tokens=frozenset(compatibility_tokens),
|
||||||
|
publish_corrected_urdf=(
|
||||||
|
"urdf_zero_publication" in compatibility_tokens
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["build_artifact_policy"]
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
"""Reviewed command-channel layouts for this hand family."""
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["G20_COMMAND_NAMES"]
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
"""Read-only regression checks for the reviewed hardware sessions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_PASS_SESSIONS = {
|
||||||
|
"20260830_181154": "full",
|
||||||
|
"20260831_141123": "thumb",
|
||||||
|
"20260831_163843": "thumb",
|
||||||
|
}
|
||||||
|
_FAIL_SESSIONS = {
|
||||||
|
"20260831_111837": ("FIT-MODEL-401", "joint_fit_check_failed"),
|
||||||
|
"20260831_142322": ("VAL-QUALITY-501", "zero_model_validation_failed"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(session: Path) -> dict[str, Any]:
|
||||||
|
value = json.loads(
|
||||||
|
(session / "calibration_summary_zh.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(f"invalid golden summary: {session}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_golden_sessions(
|
||||||
|
serial_root: str | Path,
|
||||||
|
*,
|
||||||
|
replay_full_session: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Prove PASS/FAIL, hashes, partial freezes, and failure non-publication."""
|
||||||
|
root = Path(serial_root).expanduser().resolve()
|
||||||
|
results: dict[str, Any] = {}
|
||||||
|
for session_id, scope in _PASS_SESSIONS.items():
|
||||||
|
session = root / session_id
|
||||||
|
summary = _summary(session)
|
||||||
|
if summary.get("result") != "PASS":
|
||||||
|
raise ValueError(f"golden PASS changed: {session_id}")
|
||||||
|
if summary.get("calibration_scope") != scope:
|
||||||
|
raise ValueError(f"golden scope changed: {session_id}")
|
||||||
|
artifacts = summary["artifacts"]
|
||||||
|
hashes = summary["hashes"]
|
||||||
|
calibration_json = session / artifacts["json"]
|
||||||
|
corrected_urdf = session / artifacts["urdf"]
|
||||||
|
actual_json_hash = _sha256_file(calibration_json)
|
||||||
|
actual_urdf_hash = _sha256_file(corrected_urdf)
|
||||||
|
if actual_json_hash != hashes["calibration_json_sha256"]:
|
||||||
|
raise ValueError(f"golden JSON hash changed: {session_id}")
|
||||||
|
if actual_urdf_hash != hashes["corrected_urdf_sha256"]:
|
||||||
|
raise ValueError(f"golden URDF hash changed: {session_id}")
|
||||||
|
if session_id == "20260831_141123" and len(
|
||||||
|
summary.get("preserved_certified_zero_joints", ())
|
||||||
|
) != 12:
|
||||||
|
raise ValueError("merged thumb no longer freezes all finger zeros")
|
||||||
|
if session_id == "20260831_163843":
|
||||||
|
payload = json.loads(calibration_json.read_text(encoding="utf-8"))
|
||||||
|
if payload.get("artifact_type") != (
|
||||||
|
"g20_right_standalone_thumb_calibration"
|
||||||
|
):
|
||||||
|
raise ValueError("standalone thumb artifact type changed")
|
||||||
|
results[session_id] = {
|
||||||
|
"result": "PASS",
|
||||||
|
"json_sha256": actual_json_hash,
|
||||||
|
"urdf_sha256": actual_urdf_hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
for session_id, (error_code, reason) in _FAIL_SESSIONS.items():
|
||||||
|
session = root / session_id
|
||||||
|
summary = _summary(session)
|
||||||
|
if (
|
||||||
|
summary.get("result") != "FAIL"
|
||||||
|
or summary.get("error_code") != error_code
|
||||||
|
or summary.get("reason") != reason
|
||||||
|
):
|
||||||
|
raise ValueError(f"golden failure decision changed: {session_id}")
|
||||||
|
results[session_id] = {
|
||||||
|
"result": "FAIL",
|
||||||
|
"error_code": error_code,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
failure_ids = set(_FAIL_SESSIONS)
|
||||||
|
for pointer_name in ("latest_passed", "latest_thumb_passed"):
|
||||||
|
pointer = root / pointer_name
|
||||||
|
if pointer.exists() and pointer.resolve().name in failure_ids:
|
||||||
|
raise ValueError(f"failure session was published through {pointer_name}")
|
||||||
|
|
||||||
|
if replay_full_session:
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.offline_replay import replay_session
|
||||||
|
|
||||||
|
session_id = "20260830_181154"
|
||||||
|
replay = replay_session(root / session_id, write_outputs=False)
|
||||||
|
expected = results[session_id]
|
||||||
|
if replay["computed_final_json_sha256"] != expected["json_sha256"]:
|
||||||
|
raise ValueError(
|
||||||
|
"full-session replay JSON is not byte-identical: "
|
||||||
|
f"computed={replay['computed_final_json_sha256']} "
|
||||||
|
f"expected={expected['json_sha256']}"
|
||||||
|
)
|
||||||
|
if replay["corrected_urdf_sha256"] != expected["urdf_sha256"]:
|
||||||
|
raise ValueError(
|
||||||
|
"full-session replay URDF is not byte-identical: "
|
||||||
|
f"computed={replay['corrected_urdf_sha256']} "
|
||||||
|
f"expected={expected['urdf_sha256']}"
|
||||||
|
)
|
||||||
|
results[session_id]["offline_replay"] = "byte_identical"
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def main(args: list[str] | None = None) -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Validate the five reviewed calibration sessions"
|
||||||
|
)
|
||||||
|
parser.add_argument("serial_root")
|
||||||
|
parser.add_argument("--no-replay", action="store_true")
|
||||||
|
selected = parser.parse_args(args)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
validate_golden_sessions(
|
||||||
|
selected.serial_root,
|
||||||
|
replay_full_session=not selected.no_replay,
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
"""One-release typed wrappers for the legacy 11-Tag layouts."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.core import ProfileKey
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import get_hand_calibration_profile
|
||||||
|
from linkerhand_calibration.urdf_zero import get_zero_calibration_profile
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import RegisteredProfile
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20._adapter import adapt_profile
|
||||||
|
|
||||||
|
|
||||||
|
LEFT_KEY = ProfileKey("G20", "left", "legacy_11", 1)
|
||||||
|
RIGHT_KEY = ProfileKey("G20", "right", "legacy_11", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def build_left_profile() -> RegisteredProfile:
|
||||||
|
hand = get_hand_calibration_profile(LEFT_KEY.side, LEFT_KEY.layout)
|
||||||
|
return adapt_profile(
|
||||||
|
key=LEFT_KEY,
|
||||||
|
namespace="/g20_calibration",
|
||||||
|
hand_profile=hand,
|
||||||
|
zero_profile=get_zero_calibration_profile(
|
||||||
|
LEFT_KEY.side, LEFT_KEY.layout
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_right_profile() -> RegisteredProfile:
|
||||||
|
hand = get_hand_calibration_profile(RIGHT_KEY.side, RIGHT_KEY.layout)
|
||||||
|
return adapt_profile(
|
||||||
|
key=RIGHT_KEY,
|
||||||
|
namespace="/g20_calibration",
|
||||||
|
hand_profile=hand,
|
||||||
|
zero_profile=get_zero_calibration_profile(
|
||||||
|
RIGHT_KEY.side, RIGHT_KEY.layout
|
||||||
|
),
|
||||||
|
)
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
"""Reviewed motion and safe-waypoint strategy exports."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import (
|
||||||
|
build_calibration_motion_command,
|
||||||
|
build_calibration_preparation_waypoints,
|
||||||
|
build_calibration_return_waypoints,
|
||||||
|
build_calibration_speed_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_calibration_motion_command",
|
||||||
|
"build_calibration_preparation_waypoints",
|
||||||
|
"build_calibration_return_waypoints",
|
||||||
|
"build_calibration_speed_profile",
|
||||||
|
]
|
||||||
+2068
File diff suppressed because it is too large
Load Diff
+2195
File diff suppressed because it is too large
Load Diff
+687
@@ -0,0 +1,687 @@
|
|||||||
|
"""Validate and atomically publish one inseparable G20 JSON/URDF session."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.spatial.transform import Rotation
|
||||||
|
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import (
|
||||||
|
G20_COMBINATION_REQUIRED_TARGET_KEYS,
|
||||||
|
G20_RIGHT_19_LAYOUT,
|
||||||
|
get_hand_calibration_profile,
|
||||||
|
validate_compact_payload,
|
||||||
|
)
|
||||||
|
from linkerhand_calibration.product import ProductConfig, sha256_file
|
||||||
|
from linkerhand_calibration.storage import atomic_write_json
|
||||||
|
from linkerhand_calibration.core.urdf import UrdfCorrectionPlan, build_correction_plan
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_solver import (
|
||||||
|
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
|
||||||
|
get_zero_calibration_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PASSIVE_JOINTS = frozenset(
|
||||||
|
{"thumb_ip", "index_dip", "middle_dip", "ring_dip", "pinky_dip"}
|
||||||
|
)
|
||||||
|
VISUALLY_MEASURED_FINGER_DIPS = frozenset(
|
||||||
|
{"index_dip", "middle_dip", "ring_dip", "pinky_dip"}
|
||||||
|
)
|
||||||
|
ACTIVE_ZERO_JOINTS = frozenset(
|
||||||
|
get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT).direct_zero_joints
|
||||||
|
)
|
||||||
|
RETAINED_ACTIVE_ZERO_JOINTS = frozenset(
|
||||||
|
get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT).active_joints
|
||||||
|
) - ACTIVE_ZERO_JOINTS
|
||||||
|
SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD = 5.1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path) -> dict[str, Any]:
|
||||||
|
with path.open("r", encoding="utf-8") as stream:
|
||||||
|
payload = json.load(stream)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError(f"{path} must contain a JSON object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _joint_blocks(text: str) -> dict[str, str]:
|
||||||
|
pattern = re.compile(
|
||||||
|
r"<joint\b[^>]*\bname\s*=\s*([\"'])(?P<name>[^\"']+)\1[^>]*>.*?</joint>",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
return {match.group("name"): match.group(0) for match in pattern.finditer(text)}
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_origin_rpy(block: str) -> str:
|
||||||
|
return re.sub(
|
||||||
|
r"(<origin\b[^>]*\brpy\s*=\s*)([\"'])[^\"']*\2",
|
||||||
|
r"\1\2__CALIBRATED_RPY__\2",
|
||||||
|
block,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_active_origin_rpy_fields(text: str) -> str:
|
||||||
|
pattern = re.compile(
|
||||||
|
r"<joint\b[^>]*\bname\s*=\s*([\"'])(?P<name>[^\"']+)\1[^>]*>.*?</joint>",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
block = match.group(0)
|
||||||
|
if match.group("name") not in ACTIVE_ZERO_JOINTS:
|
||||||
|
return block
|
||||||
|
masked = _mask_origin_rpy(block)
|
||||||
|
if masked == block:
|
||||||
|
raise ValueError(
|
||||||
|
f"active joint {match.group('name')} has no origin.rpy"
|
||||||
|
)
|
||||||
|
return masked
|
||||||
|
|
||||||
|
return pattern.sub(replace, text)
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_endpoint_coordinate_fields(text: str) -> str:
|
||||||
|
"""Mask only limit/mimic fields induced by endpoint zero coordinates."""
|
||||||
|
pattern = re.compile(
|
||||||
|
r"<joint\b[^>]*\bname\s*=\s*([\"'])(?P<name>[^\"']+)\1[^>]*>.*?</joint>",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
block = match.group(0)
|
||||||
|
name = match.group("name")
|
||||||
|
if name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS:
|
||||||
|
return re.sub(
|
||||||
|
r"(<limit\b[^>]*\bupper\s*=\s*)([\"'])[^\"']*\2",
|
||||||
|
r"\1\2__CALIBRATED_UPPER__\2",
|
||||||
|
block,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
mimic = re.search(
|
||||||
|
r"<mimic\b[^>]*\bjoint\s*=\s*([\"'])(?P<source>[^\"']+)\1[^>]*>",
|
||||||
|
block,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
mimic is not None
|
||||||
|
and mimic.group("source") in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
|
||||||
|
):
|
||||||
|
return re.sub(
|
||||||
|
r"(<mimic\b[^>]*\boffset\s*=\s*)([\"'])[^\"']*\2",
|
||||||
|
r"\1\2__CALIBRATED_MIMIC_OFFSET__\2",
|
||||||
|
block,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
return block
|
||||||
|
|
||||||
|
return pattern.sub(replace, text)
|
||||||
|
|
||||||
|
|
||||||
|
def _triplet(value: str) -> np.ndarray:
|
||||||
|
result = np.asarray([float(item) for item in value.split()], dtype=float)
|
||||||
|
if result.shape != (3,) or not np.all(np.isfinite(result)):
|
||||||
|
raise ValueError(f"invalid URDF triplet: {value}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _joint_elements(path: str | Path) -> dict[str, ET.Element]:
|
||||||
|
return {
|
||||||
|
str(joint.get("name")): joint
|
||||||
|
for joint in ET.parse(Path(path)).getroot().findall("joint")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_expected_origin_offsets(
|
||||||
|
source: str | Path,
|
||||||
|
corrected: str | Path,
|
||||||
|
expected_offsets_rad: Mapping[str, float],
|
||||||
|
) -> None:
|
||||||
|
offsets = {str(name): float(value) for name, value in expected_offsets_rad.items()}
|
||||||
|
if set(offsets) != ACTIVE_ZERO_JOINTS or any(
|
||||||
|
not math.isfinite(value) for value in offsets.values()
|
||||||
|
):
|
||||||
|
raise ValueError("expected offsets must contain all finite active static-zero values")
|
||||||
|
before = _joint_elements(source)
|
||||||
|
after = _joint_elements(corrected)
|
||||||
|
maximum_rotation_error = 0.0
|
||||||
|
for name, original_joint in before.items():
|
||||||
|
corrected_joint = after[name]
|
||||||
|
original_origin = original_joint.find("origin")
|
||||||
|
corrected_origin = corrected_joint.find("origin")
|
||||||
|
if original_origin is None or corrected_origin is None:
|
||||||
|
if original_origin is not corrected_origin:
|
||||||
|
raise ValueError(f"corrected URDF changed origin presence for {name}")
|
||||||
|
continue
|
||||||
|
original_xyz = _triplet(original_origin.get("xyz", "0 0 0"))
|
||||||
|
corrected_xyz = _triplet(corrected_origin.get("xyz", "0 0 0"))
|
||||||
|
if not np.allclose(original_xyz, corrected_xyz, atol=1.0e-12, rtol=0.0):
|
||||||
|
raise ValueError(f"corrected URDF changed origin.xyz for {name}")
|
||||||
|
original_rotation = Rotation.from_euler(
|
||||||
|
"xyz", _triplet(original_origin.get("rpy", "0 0 0"))
|
||||||
|
)
|
||||||
|
corrected_rotation = Rotation.from_euler(
|
||||||
|
"xyz", _triplet(corrected_origin.get("rpy", "0 0 0"))
|
||||||
|
)
|
||||||
|
expected_rotation = original_rotation
|
||||||
|
if name in offsets:
|
||||||
|
axis_node = original_joint.find("axis")
|
||||||
|
axis = _triplet(
|
||||||
|
"1 0 0" if axis_node is None else axis_node.get("xyz", "1 0 0")
|
||||||
|
)
|
||||||
|
norm = float(np.linalg.norm(axis))
|
||||||
|
if norm <= 1.0e-12:
|
||||||
|
raise ValueError(f"source URDF joint {name} has a degenerate axis")
|
||||||
|
expected_rotation = expected_rotation * Rotation.from_rotvec(
|
||||||
|
axis / norm * offsets[name]
|
||||||
|
)
|
||||||
|
error = float((expected_rotation.inv() * corrected_rotation).magnitude())
|
||||||
|
maximum_rotation_error = max(maximum_rotation_error, error)
|
||||||
|
# Schema v4 intentionally stores zero offsets at eight decimal places.
|
||||||
|
# Older sessions wrote the corrected URDF from the full-precision solve,
|
||||||
|
# so comparing that file with the published JSON necessarily permits half
|
||||||
|
# of one last-place unit. This is about 2.9e-7 degrees and is far below
|
||||||
|
# any calibration or URDF numerical significance.
|
||||||
|
if maximum_rotation_error > SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD:
|
||||||
|
raise ValueError(
|
||||||
|
"corrected URDF origin.rpy does not match the published zero offsets"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_corrected_urdf(
|
||||||
|
source: str | Path,
|
||||||
|
corrected: str | Path,
|
||||||
|
*,
|
||||||
|
expected_offsets_rad: Mapping[str, float] | None = None,
|
||||||
|
endpoint_anchored_offsets_rad: Mapping[str, float] | None = None,
|
||||||
|
correction_plan: UrdfCorrectionPlan | None = None,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""Prove that only active-joint origin.rpy attributes changed.
|
||||||
|
|
||||||
|
Passive joint blocks, including their mimic elements, are compared as raw
|
||||||
|
UTF-8 text so formatting and numeric spelling are protected as well.
|
||||||
|
"""
|
||||||
|
source_text = Path(source).read_text(encoding="utf-8")
|
||||||
|
corrected_text = Path(corrected).read_text(encoding="utf-8")
|
||||||
|
if correction_plan is not None:
|
||||||
|
correction_plan.verify_source(source)
|
||||||
|
if expected_offsets_rad is None:
|
||||||
|
raise ValueError(
|
||||||
|
"correction-plan validation requires expected offsets"
|
||||||
|
)
|
||||||
|
correction_plan.authorize_offsets(expected_offsets_rad)
|
||||||
|
before = _joint_blocks(source_text)
|
||||||
|
after = _joint_blocks(corrected_text)
|
||||||
|
if set(before) != set(after):
|
||||||
|
raise ValueError("corrected URDF changed the joint set")
|
||||||
|
# Text patchers may normalize only the final newline. It has no URDF/XML
|
||||||
|
# semantics; every joint block and every non-rpy byte remains guarded
|
||||||
|
# below.
|
||||||
|
masked_source = _mask_endpoint_coordinate_fields(
|
||||||
|
_mask_active_origin_rpy_fields(source_text)
|
||||||
|
).rstrip("\r\n")
|
||||||
|
masked_corrected = _mask_endpoint_coordinate_fields(
|
||||||
|
_mask_active_origin_rpy_fields(corrected_text)
|
||||||
|
).rstrip("\r\n")
|
||||||
|
if masked_source != masked_corrected:
|
||||||
|
raise ValueError(
|
||||||
|
"corrected URDF changed content outside active origin.rpy or "
|
||||||
|
"endpoint-calibrated fields"
|
||||||
|
)
|
||||||
|
changed: list[str] = []
|
||||||
|
for name in before:
|
||||||
|
if before[name] == after[name]:
|
||||||
|
continue
|
||||||
|
if name not in ACTIVE_ZERO_JOINTS and name not in PASSIVE_JOINTS:
|
||||||
|
raise ValueError(f"corrected URDF changed protected joint {name}")
|
||||||
|
if name in ACTIVE_ZERO_JOINTS:
|
||||||
|
changed.append(name)
|
||||||
|
if expected_offsets_rad is not None:
|
||||||
|
_verify_expected_origin_offsets(source, corrected, expected_offsets_rad)
|
||||||
|
endpoint_offsets = {
|
||||||
|
str(name): float(value)
|
||||||
|
for name, value in dict(endpoint_anchored_offsets_rad or {}).items()
|
||||||
|
}
|
||||||
|
if endpoint_offsets:
|
||||||
|
if correction_plan is not None and not set(endpoint_offsets).issubset(
|
||||||
|
correction_plan.endpoint_limit_joints
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"endpoint offsets are not authorized by the correction plan"
|
||||||
|
)
|
||||||
|
source_joints = _joint_elements(source)
|
||||||
|
corrected_joints = _joint_elements(corrected)
|
||||||
|
for name, offset in endpoint_offsets.items():
|
||||||
|
original_limit = source_joints[name].find("limit")
|
||||||
|
corrected_limit = corrected_joints[name].find("limit")
|
||||||
|
expected_upper = float(original_limit.get("upper")) - offset
|
||||||
|
# Schema v4 stores the corresponding zero at eight decimal
|
||||||
|
# places, while the URDF is written from the full-precision solve.
|
||||||
|
# Match the half-last-place tolerance used for origin rotations.
|
||||||
|
if (
|
||||||
|
abs(float(corrected_limit.get("upper")) - expected_upper)
|
||||||
|
> SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
|
||||||
|
):
|
||||||
|
raise ValueError(f"corrected URDF has invalid {name} upper limit")
|
||||||
|
for name in PASSIVE_JOINTS:
|
||||||
|
original_mimic = source_joints[name].find("mimic")
|
||||||
|
corrected_mimic = corrected_joints[name].find("mimic")
|
||||||
|
if original_mimic is None or corrected_mimic is None:
|
||||||
|
continue
|
||||||
|
source_name = str(original_mimic.get("joint"))
|
||||||
|
multiplier = float(original_mimic.get("multiplier", "1"))
|
||||||
|
expected = float(original_mimic.get("offset", "0"))
|
||||||
|
expected += multiplier * endpoint_offsets.get(source_name, 0.0)
|
||||||
|
# The active endpoint offset comes from schema-v4 JSON rounded to
|
||||||
|
# eight decimal places, while the URDF mimic was written from the
|
||||||
|
# full-precision solve. Propagate exactly the same accepted
|
||||||
|
# quantization through the mimic multiplier; retain a much
|
||||||
|
# smaller allowance for XML decimal formatting itself.
|
||||||
|
tolerance = (
|
||||||
|
abs(multiplier)
|
||||||
|
* SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
|
||||||
|
+ 1.0e-12
|
||||||
|
)
|
||||||
|
if abs(float(corrected_mimic.get("offset", "0")) - expected) > tolerance:
|
||||||
|
raise ValueError(f"corrected URDF has invalid {name} mimic offset")
|
||||||
|
return tuple(sorted(changed))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_urdf_mesh_resources(urdf: str | Path) -> dict[str, Path]:
|
||||||
|
"""Return every session-local mesh after proving it resolves safely."""
|
||||||
|
path = Path(urdf).expanduser().resolve()
|
||||||
|
root = ET.parse(path).getroot()
|
||||||
|
resources: dict[str, Path] = {}
|
||||||
|
for mesh in root.findall(".//mesh"):
|
||||||
|
filename = str(mesh.get("filename", "")).strip()
|
||||||
|
if not filename or "://" in filename or filename.startswith("package:"):
|
||||||
|
continue
|
||||||
|
relative = Path(filename)
|
||||||
|
if relative.is_absolute() or ".." in relative.parts:
|
||||||
|
raise ValueError(f"URDF has an unsafe local mesh path: {filename}")
|
||||||
|
resolved = (path.parent / relative).resolve()
|
||||||
|
try:
|
||||||
|
resolved.relative_to(path.parent)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(f"URDF mesh escapes the session: {filename}") from error
|
||||||
|
if not resolved.is_file():
|
||||||
|
raise ValueError(f"URDF mesh resource is missing: {filename}")
|
||||||
|
resources[relative.as_posix()] = resolved
|
||||||
|
return dict(sorted(resources.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_runtime_curves_against_urdf_limits(
|
||||||
|
payload: Mapping[str, Any], runtime_urdf: str | Path
|
||||||
|
) -> None:
|
||||||
|
"""Reject a curve whose q leaves its runtime URDF coordinate limits.
|
||||||
|
|
||||||
|
Curves in schema v4 are expressed in the corrected URDF joint coordinate,
|
||||||
|
not in the source-CAD coordinate. Endpoint zero calibration can therefore
|
||||||
|
move a corrected coordinate limit while preserving the same physical CAD
|
||||||
|
endpoint; callers publishing a calibrated pair must pass that corrected
|
||||||
|
URDF here.
|
||||||
|
"""
|
||||||
|
validate_compact_payload(payload)
|
||||||
|
runtime_joints = _joint_elements(runtime_urdf)
|
||||||
|
for name, calibration in payload["joints"].items():
|
||||||
|
joint = runtime_joints.get(str(name))
|
||||||
|
if joint is None:
|
||||||
|
raise ValueError(f"runtime URDF is missing joint {name}")
|
||||||
|
limit = joint.find("limit")
|
||||||
|
if limit is None or limit.get("lower") is None or limit.get("upper") is None:
|
||||||
|
raise ValueError(f"runtime URDF joint {name} has no finite position limit")
|
||||||
|
lower = float(limit.get("lower"))
|
||||||
|
upper = float(limit.get("upper"))
|
||||||
|
if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
|
||||||
|
raise ValueError(f"runtime URDF joint {name} has invalid position limits")
|
||||||
|
curve = np.asarray(calibration["angle_rad"], dtype=float)
|
||||||
|
minimum = float(np.min(curve))
|
||||||
|
maximum = float(np.max(curve))
|
||||||
|
tolerance = 1.0e-7
|
||||||
|
if minimum < lower - tolerance or maximum > upper + tolerance:
|
||||||
|
raise ValueError(
|
||||||
|
f"runtime curve exceeds runtime URDF limit for {name}: "
|
||||||
|
f"[{minimum:.9g}, {maximum:.9g}] not within "
|
||||||
|
f"[{lower:.9g}, {upper:.9g}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_compact_payload_to_urdf_limits(
|
||||||
|
payload: Mapping[str, Any], runtime_urdf: str | Path
|
||||||
|
) -> tuple[dict[str, Any], dict[str, int]]:
|
||||||
|
"""Return a schema-preserving payload bounded in its runtime coordinates."""
|
||||||
|
result = copy.deepcopy(dict(payload))
|
||||||
|
validate_compact_payload(result)
|
||||||
|
runtime_joints = _joint_elements(runtime_urdf)
|
||||||
|
clipped_by_joint: dict[str, int] = {}
|
||||||
|
for name, calibration in result["joints"].items():
|
||||||
|
joint = runtime_joints.get(str(name))
|
||||||
|
limit = None if joint is None else joint.find("limit")
|
||||||
|
if (
|
||||||
|
limit is None
|
||||||
|
or limit.get("lower") is None
|
||||||
|
or limit.get("upper") is None
|
||||||
|
):
|
||||||
|
raise ValueError(f"runtime URDF joint {name} has no finite position limit")
|
||||||
|
lower = float(limit.get("lower"))
|
||||||
|
upper = float(limit.get("upper"))
|
||||||
|
if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
|
||||||
|
raise ValueError(f"runtime URDF joint {name} has invalid position limits")
|
||||||
|
source = np.asarray(calibration["angle_rad"], dtype=float)
|
||||||
|
bounded = np.clip(source, lower, upper)
|
||||||
|
count = int(np.count_nonzero(bounded != source))
|
||||||
|
if count:
|
||||||
|
clipped_by_joint[str(name)] = count
|
||||||
|
calibration["angle_rad"] = [
|
||||||
|
round(float(value), 8) for value in bounded
|
||||||
|
]
|
||||||
|
validate_compact_payload(result)
|
||||||
|
return result, clipped_by_joint
|
||||||
|
|
||||||
|
|
||||||
|
def build_mujoco_validation_commands(
|
||||||
|
baseline: Sequence[int],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if len(baseline) != 20:
|
||||||
|
raise ValueError("baseline must contain exactly 20 commands")
|
||||||
|
base = [int(value) for value in baseline]
|
||||||
|
if any(not 0 <= value <= 255 for value in base):
|
||||||
|
raise ValueError("baseline commands must be in [0, 255]")
|
||||||
|
|
||||||
|
def pose(name: str, label: str, changes: Mapping[int, int]) -> dict[str, Any]:
|
||||||
|
values = list(base)
|
||||||
|
for index, value in changes.items():
|
||||||
|
values[int(index)] = int(value)
|
||||||
|
return {"name": name, "label_zh": label, "command_u8": values}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"topic": "/g20/cb_right_hand_control_cmd",
|
||||||
|
"serial_number": "G20_RIGHT_001",
|
||||||
|
"poses": [
|
||||||
|
pose("all_open", "全开", {}),
|
||||||
|
pose("thumb_middle", "拇指中位", {0: 160, 5: 160, 10: 160, 15: 160}),
|
||||||
|
pose("index_middle", "食指中位", {1: 160, 6: 127, 16: 160}),
|
||||||
|
pose("middle_middle", "中指中位", {2: 160, 7: 127, 17: 160}),
|
||||||
|
pose("ring_middle", "无名指中位", {3: 160, 8: 127, 18: 160}),
|
||||||
|
pose("pinky_middle", "小指中位", {4: 160, 9: 127, 19: 160}),
|
||||||
|
pose(
|
||||||
|
"half_grip",
|
||||||
|
"四指半握",
|
||||||
|
{1: 160, 2: 160, 3: 160, 4: 160, 16: 160, 17: 160, 18: 160, 19: 160},
|
||||||
|
),
|
||||||
|
pose(
|
||||||
|
"light_pinch",
|
||||||
|
"轻捏",
|
||||||
|
{0: 176, 5: 176, 10: 176, 15: 176, 1: 176, 6: 127, 16: 176},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_session_pointer(root: str | Path, name: str, session: str | Path) -> Path:
|
||||||
|
if "passed" in name:
|
||||||
|
raise ValueError("release pointers require the common ArtifactPublisher")
|
||||||
|
parent = Path(root).resolve()
|
||||||
|
target = Path(session).resolve()
|
||||||
|
if target.parent != parent:
|
||||||
|
raise ValueError("session pointer target must be a direct child of the serial root")
|
||||||
|
if not target.is_dir():
|
||||||
|
raise ValueError(f"session directory does not exist: {target}")
|
||||||
|
if name not in {"latest_attempt", "latest_passed", "latest_thumb_passed"}:
|
||||||
|
raise ValueError("unsupported session pointer name")
|
||||||
|
parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
destination = parent / name
|
||||||
|
temporary = parent / f".{name}.{os.getpid()}.tmp"
|
||||||
|
if temporary.is_symlink():
|
||||||
|
temporary.unlink()
|
||||||
|
elif temporary.exists():
|
||||||
|
raise ValueError(f"temporary pointer path is occupied: {temporary}")
|
||||||
|
os.symlink(target.name, temporary, target_is_directory=True)
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
def active_offsets(payload: Mapping[str, Any]) -> dict[str, float]:
|
||||||
|
validate_compact_payload(payload)
|
||||||
|
result = {
|
||||||
|
name: float(payload["joints"][name]["zero_angles"]["urdf_zero_offset_rad"])
|
||||||
|
for name in ACTIVE_ZERO_JOINTS
|
||||||
|
}
|
||||||
|
if set(result) != ACTIVE_ZERO_JOINTS or any(
|
||||||
|
not math.isfinite(value) for value in result.values()
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"payload does not contain all finite observable active zero offsets"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def standalone_thumb_offsets(payload: Mapping[str, Any]) -> dict[str, float]:
|
||||||
|
"""Validate a standalone thumb artifact and expand CAD finger zeros."""
|
||||||
|
if (
|
||||||
|
payload.get("schema_version") != 1
|
||||||
|
or payload.get("artifact_type")
|
||||||
|
!= "g20_right_standalone_thumb_calibration"
|
||||||
|
or payload.get("side") != "right"
|
||||||
|
or payload.get("non_thumb_zero_policy") != "source_cad_unchanged"
|
||||||
|
or not bool(payload.get("quality", {}).get("passed"))
|
||||||
|
):
|
||||||
|
raise ValueError("standalone thumb JSON contract is invalid")
|
||||||
|
joints = payload.get("joints")
|
||||||
|
expected_joint_names = {
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"thumb_cmc_yaw",
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"thumb_mcp",
|
||||||
|
"thumb_ip",
|
||||||
|
}
|
||||||
|
if not isinstance(joints, Mapping) or set(joints) != expected_joint_names:
|
||||||
|
raise ValueError("standalone thumb JSON has the wrong joint set")
|
||||||
|
thumb_names = expected_joint_names - {"thumb_ip"}
|
||||||
|
result = {name: 0.0 for name in ACTIVE_ZERO_JOINTS}
|
||||||
|
for name in thumb_names:
|
||||||
|
try:
|
||||||
|
value = float(joints[name]["zero_angles"]["urdf_zero_offset_rad"])
|
||||||
|
except (KeyError, TypeError, ValueError) as error:
|
||||||
|
raise ValueError(
|
||||||
|
f"standalone thumb JSON is missing zero for {name}"
|
||||||
|
) from error
|
||||||
|
curve = np.asarray(joints[name].get("angle_rad", ()), dtype=float)
|
||||||
|
if curve.shape != (256,) or not np.all(np.isfinite(curve)):
|
||||||
|
raise ValueError(f"standalone thumb JSON curve is invalid: {name}")
|
||||||
|
result[name] = value
|
||||||
|
passive_curve = np.asarray(
|
||||||
|
joints["thumb_ip"].get("angle_rad", ()), dtype=float
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
passive_curve.shape != (256,)
|
||||||
|
or not np.all(np.isfinite(passive_curve))
|
||||||
|
or not bool(joints["thumb_ip"].get("passive"))
|
||||||
|
or any(not math.isfinite(value) for value in result.values())
|
||||||
|
):
|
||||||
|
raise ValueError("standalone thumb JSON passive curve is invalid")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def verify_partial_scope_preserves_certified_zeros(
|
||||||
|
*,
|
||||||
|
scope: str,
|
||||||
|
source_session: str | Path,
|
||||||
|
serial_root: str | Path | None = None,
|
||||||
|
serial_number: str,
|
||||||
|
current_offsets: Mapping[str, float],
|
||||||
|
) -> None:
|
||||||
|
"""Reject a partial artifact that changes any certified non-target zero."""
|
||||||
|
selected = str(scope).strip().lower()
|
||||||
|
if selected not in {"thumb", "fingers"}:
|
||||||
|
return
|
||||||
|
source = Path(source_session).expanduser()
|
||||||
|
if not source.is_absolute():
|
||||||
|
if serial_root is None:
|
||||||
|
raise ValueError(
|
||||||
|
"relative partial base session requires its serial root"
|
||||||
|
)
|
||||||
|
source = Path(serial_root).expanduser() / source
|
||||||
|
source = source.resolve()
|
||||||
|
base_payload = _load_json(
|
||||||
|
source / f"g20_right_{serial_number}_calibration.json"
|
||||||
|
)
|
||||||
|
base_offsets = (
|
||||||
|
standalone_thumb_offsets(base_payload)
|
||||||
|
if base_payload.get("artifact_type")
|
||||||
|
== "g20_right_standalone_thumb_calibration"
|
||||||
|
else active_offsets(base_payload)
|
||||||
|
)
|
||||||
|
preserve_thumb = selected == "fingers"
|
||||||
|
changed = {
|
||||||
|
name: abs(float(current_offsets[name]) - float(base_offsets[name]))
|
||||||
|
for name in ACTIVE_ZERO_JOINTS
|
||||||
|
if name.startswith("thumb_") == preserve_thumb
|
||||||
|
and abs(float(current_offsets[name]) - float(base_offsets[name]))
|
||||||
|
> SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
|
||||||
|
}
|
||||||
|
if changed:
|
||||||
|
raise ValueError(
|
||||||
|
f"partial {selected} calibration changed certified non-target "
|
||||||
|
"zeros: "
|
||||||
|
+ ",".join(sorted(changed))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compare_session_offsets(
|
||||||
|
first: Mapping[str, Any],
|
||||||
|
second: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
maximum_difference_rad: float,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
first_standalone = (
|
||||||
|
first.get("artifact_type")
|
||||||
|
== "g20_right_standalone_thumb_calibration"
|
||||||
|
)
|
||||||
|
second_standalone = (
|
||||||
|
second.get("artifact_type")
|
||||||
|
== "g20_right_standalone_thumb_calibration"
|
||||||
|
)
|
||||||
|
if first_standalone != second_standalone:
|
||||||
|
raise ValueError("cannot compare standalone thumb and full-hand artifacts")
|
||||||
|
left = (
|
||||||
|
standalone_thumb_offsets(first)
|
||||||
|
if first_standalone
|
||||||
|
else active_offsets(first)
|
||||||
|
)
|
||||||
|
right = (
|
||||||
|
standalone_thumb_offsets(second)
|
||||||
|
if second_standalone
|
||||||
|
else active_offsets(second)
|
||||||
|
)
|
||||||
|
differences = {
|
||||||
|
name: abs(left[name] - right[name])
|
||||||
|
for name in sorted(left)
|
||||||
|
}
|
||||||
|
failed = {name: value for name, value in differences.items() if value > maximum_difference_rad}
|
||||||
|
if failed:
|
||||||
|
details = ", ".join(
|
||||||
|
f"{name}={math.degrees(value):.3f}deg" for name, value in failed.items()
|
||||||
|
)
|
||||||
|
raise ValueError(f"independent-session static-zero mismatch: {details}")
|
||||||
|
return differences
|
||||||
|
|
||||||
|
|
||||||
|
def session_artifact_paths(session: str | Path, serial_number: str) -> dict[str, Path]:
|
||||||
|
directory = Path(session).resolve()
|
||||||
|
urdfs = sorted(directory.glob(f"*zero_calibrated_{serial_number}_*.urdf"))
|
||||||
|
if len(urdfs) != 1:
|
||||||
|
raise ValueError("session must contain exactly one corrected URDF")
|
||||||
|
return {
|
||||||
|
"json": directory / f"g20_right_{serial_number}_calibration.json",
|
||||||
|
"urdf": urdfs[0],
|
||||||
|
"summary": directory / "calibration_summary_zh.json",
|
||||||
|
"commands": directory / "mujoco_validation_commands.json",
|
||||||
|
"raw": directory / "raw_samples.jsonl",
|
||||||
|
"log": directory / "calibration.log",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_compatible_prior_session(
|
||||||
|
config: ProductConfig,
|
||||||
|
current: str | Path,
|
||||||
|
current_payload: Mapping[str, Any],
|
||||||
|
) -> tuple[Path | None, dict[str, float]]:
|
||||||
|
current_path = Path(current).resolve()
|
||||||
|
for candidate in sorted(config.session_root.glob("20??????_??????"), reverse=True):
|
||||||
|
if candidate.resolve() == current_path:
|
||||||
|
continue
|
||||||
|
json_path = candidate / f"g20_right_{config.serial_number}_calibration.json"
|
||||||
|
summary_path = candidate / "calibration_summary_zh.json"
|
||||||
|
if not json_path.is_file() or not summary_path.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
summary = _load_json(summary_path)
|
||||||
|
if not bool(summary.get("quality", {}).get("passed")):
|
||||||
|
continue
|
||||||
|
payload = _load_json(json_path)
|
||||||
|
differences = compare_session_offsets(
|
||||||
|
payload,
|
||||||
|
current_payload,
|
||||||
|
maximum_difference_rad=config.static_repeatability_rad,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
return candidate, differences
|
||||||
|
return None, {}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_combination_validation(combination: Mapping[str, Any]) -> None:
|
||||||
|
# The formal product uses the independently held-out fourth sweep cycle.
|
||||||
|
# The optional eight-pose check remains a developer diagnostic because the
|
||||||
|
# axis-line zero solve does not establish an absolute Cartesian hand-base
|
||||||
|
# transform. When explicitly enabled, retain its strict coverage and
|
||||||
|
# error checks.
|
||||||
|
if not bool(combination.get("enabled")):
|
||||||
|
return
|
||||||
|
position_p95 = float(combination.get("position_p95_m", float("inf")))
|
||||||
|
orientation_p95 = float(
|
||||||
|
combination.get("orientation_p95_rad", float("inf"))
|
||||||
|
)
|
||||||
|
required = {str(value) for value in combination.get("required_targets", ())}
|
||||||
|
expected = set(G20_COMBINATION_REQUIRED_TARGET_KEYS)
|
||||||
|
observations = combination.get("observation_counts")
|
||||||
|
validations = combination.get("validation_counts")
|
||||||
|
if required != expected:
|
||||||
|
raise ValueError("combination validation has the wrong required-target set")
|
||||||
|
if not isinstance(observations, Mapping) or not isinstance(validations, Mapping):
|
||||||
|
raise ValueError("combination validation is missing per-target coverage")
|
||||||
|
missing_observations = sorted(
|
||||||
|
key for key in expected if int(observations.get(key, 0)) < 2
|
||||||
|
)
|
||||||
|
missing_validations = sorted(
|
||||||
|
key for key in expected if int(validations.get(key, 0)) < 1
|
||||||
|
)
|
||||||
|
if missing_observations or missing_validations:
|
||||||
|
raise ValueError(
|
||||||
|
"combination validation target coverage is incomplete: "
|
||||||
|
f"observations={','.join(missing_observations) or '-'}; "
|
||||||
|
f"validations={','.join(missing_validations) or '-'}"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not bool(combination.get("completed"))
|
||||||
|
or int(combination.get("completed_poses", 0)) != 8
|
||||||
|
or position_p95 > 0.003
|
||||||
|
or orientation_p95 > math.radians(2.0)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"eight-pose four-finger combination validation is incomplete or failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_session_artifacts(*args, **kwargs):
|
||||||
|
raise ValueError("legacy publication is retired; use calibrate_hand --offline-raw with the common finalizer")
|
||||||
+893
@@ -0,0 +1,893 @@
|
|||||||
|
"""Chinese, operator-facing diagnostics for three-camera calibration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
STATE_NAMES_ZH = {
|
||||||
|
"PREFLIGHT": "设备和标签预检",
|
||||||
|
"WAIT_START": "等待开始标定",
|
||||||
|
"IMPORTING_BASE": "正在读取基础标定会话",
|
||||||
|
"REVALIDATING_INHERITED": "正在复核继承的四指数据",
|
||||||
|
"RETURN_BASELINE": "正在恢复目标姿态",
|
||||||
|
"PREPARE_SWEEP": "正在到达扫描起点",
|
||||||
|
"SWEEP": "正在采集轨迹",
|
||||||
|
"FITTING": "正在拟合轨迹和零位",
|
||||||
|
"VALIDATION_MOVE": "正在移动到随机复测位置",
|
||||||
|
"VALIDATION_CAPTURE": "正在采集随机复测数据",
|
||||||
|
"PAUSED": "标定已暂停",
|
||||||
|
"ABORTED": "标定已终止",
|
||||||
|
"COMPLETE": "标定已完成",
|
||||||
|
}
|
||||||
|
|
||||||
|
VIEW_NAMES_ZH = {
|
||||||
|
"front": "正面",
|
||||||
|
"side": "侧面",
|
||||||
|
"top": "上面",
|
||||||
|
}
|
||||||
|
|
||||||
|
JOINT_NAMES_ZH = {
|
||||||
|
"thumb_cmc_pitch": "拇指CMC俯仰",
|
||||||
|
"thumb_cmc_roll": "拇指CMC滚转",
|
||||||
|
"thumb_mcp": "拇指MCP",
|
||||||
|
"thumb_ip": "拇指IP(被动)",
|
||||||
|
"index_mcp_roll": "食指MCP侧摆",
|
||||||
|
"index_mcp_pitch": "食指MCP屈伸",
|
||||||
|
"index_pip": "食指PIP",
|
||||||
|
"index_dip": "食指DIP(被动)",
|
||||||
|
"middle_mcp_roll": "中指MCP侧摆",
|
||||||
|
"middle_mcp_pitch": "中指MCP屈伸",
|
||||||
|
"middle_pip": "中指PIP",
|
||||||
|
"middle_dip": "中指DIP(被动)",
|
||||||
|
"ring_mcp_roll": "无名指MCP侧摆",
|
||||||
|
"ring_mcp_pitch": "无名指MCP屈伸",
|
||||||
|
"ring_pip": "无名指PIP",
|
||||||
|
"ring_dip": "无名指DIP(被动)",
|
||||||
|
"pinky_mcp_roll": "小指MCP侧摆",
|
||||||
|
"pinky_mcp_pitch": "小指MCP屈伸",
|
||||||
|
"pinky_pip": "小指PIP",
|
||||||
|
"pinky_dip": "小指DIP(被动)",
|
||||||
|
"thumb_cmc_yaw": "拇指CMC侧摆",
|
||||||
|
"index_mcp_roll_side": "食指MCP侧摆(侧面校验)",
|
||||||
|
"middle_mcp_roll_side": "中指MCP侧摆(侧面校验)",
|
||||||
|
"ring_mcp_roll_side": "无名指MCP侧摆(侧面校验)",
|
||||||
|
"pinky_mcp_roll_side": "小指MCP侧摆(侧面校验)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_u8(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "尚无反馈"
|
||||||
|
return f"{float(value):.1f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _task_text(active: Mapping[str, Any]) -> str:
|
||||||
|
if not active:
|
||||||
|
return "尚无活动任务"
|
||||||
|
view = VIEW_NAMES_ZH.get(str(active.get("view", "")), str(active.get("view", "")))
|
||||||
|
if active.get("kind") == "fit_failure":
|
||||||
|
joints = active.get("joints", [])
|
||||||
|
joint_text = "/".join(
|
||||||
|
JOINT_NAMES_ZH.get(str(joint), str(joint)) for joint in joints
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"{view}机位,{joint_text}拟合检查失败,"
|
||||||
|
f"电机{active.get('motor_index')},"
|
||||||
|
f"第{active.get('attempt', 1)}次尝试"
|
||||||
|
)
|
||||||
|
if active.get("kind") == "zero_model_failure":
|
||||||
|
joints = active.get("joints", [])
|
||||||
|
joint_text = "/".join(
|
||||||
|
JOINT_NAMES_ZH.get(str(joint), str(joint)) for joint in joints
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"{view}机位,{joint_text}零位/URDF验证失败,"
|
||||||
|
f"电机{active.get('motor_index')},不会自动重扫"
|
||||||
|
)
|
||||||
|
if active.get("kind") == "motion_stall":
|
||||||
|
return (
|
||||||
|
f"电机{active.get('motor_index', '?')}运动停滞,目标"
|
||||||
|
f"{_format_u8(active.get('target_u8'))}、实际"
|
||||||
|
f"{_format_u8(active.get('actual_u8'))}"
|
||||||
|
)
|
||||||
|
if active.get("kind") == "cross_view_roll_diagnostic":
|
||||||
|
return (
|
||||||
|
f"{active.get('finger', '?')}侧摆跨机位诊断完成:"
|
||||||
|
f"正面最大{float(active.get('front_maximum_deg', 0.0)):.2f}°,"
|
||||||
|
f"侧面最大{float(active.get('side_maximum_deg', 0.0)):.2f}°"
|
||||||
|
)
|
||||||
|
if active.get("kind") == "validation":
|
||||||
|
return (
|
||||||
|
f"{view}机位,随机复测,电机{active.get('motor_index')},"
|
||||||
|
f"目标命令{active.get('command_u8')}"
|
||||||
|
)
|
||||||
|
joints = active.get("joints", [])
|
||||||
|
joint_text = "/".join(
|
||||||
|
JOINT_NAMES_ZH.get(str(joint), str(joint)) for joint in joints
|
||||||
|
)
|
||||||
|
start = active.get("start_u8")
|
||||||
|
target = active.get("target_u8")
|
||||||
|
cycle = active.get("cycle", "?")
|
||||||
|
repetitions = active.get("repetitions", "?")
|
||||||
|
task = (
|
||||||
|
f"{view}机位,{joint_text},电机{active.get('motor_index')},"
|
||||||
|
f"第{cycle}/{repetitions}轮,{start}→{target}"
|
||||||
|
)
|
||||||
|
fit_attempt = int(active.get("fit_attempt", 1))
|
||||||
|
if fit_attempt > 1:
|
||||||
|
retry_cycles = active.get("fit_retry_cycles", [])
|
||||||
|
if retry_cycles:
|
||||||
|
task += "(补采异常轮" + "/".join(
|
||||||
|
str(cycle) for cycle in retry_cycles
|
||||||
|
) + ")"
|
||||||
|
else:
|
||||||
|
task += f"(拟合补采第{fit_attempt}次)"
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def three_camera_reason_zh(
|
||||||
|
state: str,
|
||||||
|
reason: str,
|
||||||
|
active: Mapping[str, Any],
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Translate a reason code and provide one concrete operator action."""
|
||||||
|
reason = str(reason)
|
||||||
|
sample = active.get("sample", {}) if active else {}
|
||||||
|
missing = [int(value) for value in sample.get("missing_endpoint_u8", [])]
|
||||||
|
sample_range = (
|
||||||
|
f"{_format_u8(sample.get('minimum_u8'))}~"
|
||||||
|
f"{_format_u8(sample.get('maximum_u8'))}"
|
||||||
|
)
|
||||||
|
tolerance = sample.get("endpoint_tolerance_u8", "?")
|
||||||
|
|
||||||
|
if reason.startswith("motor_state_stalled:"):
|
||||||
|
fields = reason.split(":")
|
||||||
|
context = fields[1] if len(fields) > 1 else "unknown"
|
||||||
|
error_match = re.search(r"error_u8=([0-9.]+)", reason)
|
||||||
|
error = error_match.group(1) if error_match else "未知"
|
||||||
|
timeout_match = re.search(r"timeout_seconds=([0-9.]+)", reason)
|
||||||
|
timeout_value = active.get("timeout_seconds")
|
||||||
|
if timeout_value is None and timeout_match is not None:
|
||||||
|
timeout_value = float(timeout_match.group(1))
|
||||||
|
duration = (
|
||||||
|
f"连续{float(timeout_value):g}秒"
|
||||||
|
if timeout_value is not None
|
||||||
|
else "在规定时间内"
|
||||||
|
)
|
||||||
|
motor = active.get("motor_index")
|
||||||
|
if motor is not None:
|
||||||
|
return (
|
||||||
|
f"电机{motor}反馈{duration}没有向目标推进;目标"
|
||||||
|
f"{_format_u8(active.get('target_u8'))}、实际"
|
||||||
|
f"{_format_u8(active.get('actual_u8'))}、误差{error} u8,"
|
||||||
|
f"允许容差±{_format_u8(active.get('tolerance_u8'))} u8"
|
||||||
|
f"(阶段={context})。程序已保持当前位置。",
|
||||||
|
"若实际反馈是稳定的固件端点,应只配置该电机该端点的专用容差后"
|
||||||
|
"重启;若仍在变化或有摩擦,则先排查机械问题,不要反复resume强推。",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"电机反馈{duration}没有向目标推进;停止位置距目标{error}个u8"
|
||||||
|
f"(阶段={context})。程序已保持当前位置,防止机械碰撞或摩擦加重。",
|
||||||
|
"检查该电机是否在机械端点稳定饱和或存在碰撞。若实际反馈已是该型号的"
|
||||||
|
"正常端点,应配置该电机专用端点容差后重启标定;不要反复调用resume强推。",
|
||||||
|
)
|
||||||
|
|
||||||
|
base_reason, separator, reason_detail = reason.partition(":")
|
||||||
|
if base_reason in {
|
||||||
|
"sweep_missing_endpoint_bin",
|
||||||
|
"sweep_bins_too_few",
|
||||||
|
"sweep_bin_gap_too_large",
|
||||||
|
"task_precheck_missing_command_127",
|
||||||
|
"task_precheck_detection_rate_too_low",
|
||||||
|
"synchronised_tag_state_timeout",
|
||||||
|
}:
|
||||||
|
reason = base_reason
|
||||||
|
detail_label = (
|
||||||
|
JOINT_NAMES_ZH.get(reason_detail, reason_detail)
|
||||||
|
if separator and reason_detail
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
detail_prefix = f"{detail_label}:" if detail_label else ""
|
||||||
|
|
||||||
|
if "URDF zero offset reached the configured" in reason:
|
||||||
|
bound_match = re.search(
|
||||||
|
r"configured\s+([0-9.]+)\s+degree bound", reason
|
||||||
|
)
|
||||||
|
bound = bound_match.group(1) if bound_match else "配置的"
|
||||||
|
hit_text = ""
|
||||||
|
if "bound:" in reason:
|
||||||
|
hit_text = reason.split("bound:", 1)[1].split(
|
||||||
|
"; all_offsets:", 1
|
||||||
|
)[0]
|
||||||
|
for name, label in JOINT_NAMES_ZH.items():
|
||||||
|
hit_text = hit_text.replace(name, label)
|
||||||
|
hit_suffix = f";触边关节:{hit_text}" if hit_text else ""
|
||||||
|
return (
|
||||||
|
f"联合URDF零位求解触及±{bound}°安全边界{hit_suffix}。这不是可靠的"
|
||||||
|
"零位结果,而是三机位米制位姿或固定关节轴链无法由纯零位旋转共同解释。",
|
||||||
|
"不要调用resume,也不要增大零位边界。先确认Tag有效黑框边长、三相机"
|
||||||
|
"内外参和原始CAD URDF;Tag尺寸修正后必须调用start重新采集,旧尺度"
|
||||||
|
"产生的轨迹不能直接生成修正URDF。",
|
||||||
|
)
|
||||||
|
|
||||||
|
if reason == "sweep_missing_endpoint_bin":
|
||||||
|
missing_text = "、".join(str(value) for value in missing) or "0或255"
|
||||||
|
return (
|
||||||
|
f"{detail_prefix}本方向已有{active.get('valid_frames', 0)}帧同步有效数据,但缺少"
|
||||||
|
f"电机端点{missing_text}附近的有效分箱;采样到的实际电机范围为"
|
||||||
|
f"{sample_range},端点容差为±{tolerance}。这通常表示电机虽然运动到"
|
||||||
|
"端点,但该时刻没有同时取得有效Tag图像和电机状态。",
|
||||||
|
"确认当前机位所需Tag在整个行程(尤其缺失端点)均可见,然后调用"
|
||||||
|
"/g20_calibration/resume;程序会重新扫描当前方向,不要调用start。",
|
||||||
|
)
|
||||||
|
if reason == "sweep_bins_too_few":
|
||||||
|
return (
|
||||||
|
f"{detail_prefix}有效电机分箱只有{sample.get('bin_count', 0)}个,要求至少"
|
||||||
|
f"{sample.get('minimum_bin_count', '?')}个;当前采样范围{sample_range}。",
|
||||||
|
"检查Tag连续识别和电机状态频率,修正后调用resume重新扫描当前方向。",
|
||||||
|
)
|
||||||
|
if reason == "sweep_bin_gap_too_large":
|
||||||
|
gap_start = sample.get("maximum_bin_gap_start_u8")
|
||||||
|
gap_end = sample.get("maximum_bin_gap_end_u8")
|
||||||
|
gap_range = (
|
||||||
|
""
|
||||||
|
if gap_start is None or gap_end is None
|
||||||
|
else f"({gap_start}→{gap_end})"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"{detail_prefix}轨迹相邻有效电机分箱的最大空缺为{sample.get('maximum_bin_gap', '?')},"
|
||||||
|
f"{gap_range}允许值不超过"
|
||||||
|
f"{sample.get('allowed_maximum_bin_gap', '?')}。",
|
||||||
|
"检查运动中Tag是否间歇丢失;修正遮挡、反光或对焦后调用resume。",
|
||||||
|
)
|
||||||
|
if reason == "synchronised_tag_state_timeout":
|
||||||
|
group_reasons = active.get("group_pnp_reasons", {})
|
||||||
|
if isinstance(group_reasons, Mapping) and group_reasons:
|
||||||
|
tag_rejections = active.get("pnp_rejection_counts", {})
|
||||||
|
group_rejections = active.get(
|
||||||
|
"group_pnp_rejection_counts", {}
|
||||||
|
)
|
||||||
|
missing_roles = active.get(
|
||||||
|
"group_missing_candidate_roles", {}
|
||||||
|
)
|
||||||
|
candidate_diagnostics = active.get(
|
||||||
|
"pnp_candidate_diagnostics", {}
|
||||||
|
)
|
||||||
|
view_details: list[str] = []
|
||||||
|
for view, value in group_reasons.items():
|
||||||
|
view_name = str(view)
|
||||||
|
parts = [str(value)]
|
||||||
|
missing = (
|
||||||
|
missing_roles.get(view_name, ())
|
||||||
|
if isinstance(missing_roles, Mapping)
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
if isinstance(missing, Sequence) and not isinstance(
|
||||||
|
missing, (str, bytes)
|
||||||
|
) and missing:
|
||||||
|
parts.append(
|
||||||
|
"缺候选=" + ",".join(str(role) for role in missing)
|
||||||
|
)
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for source in (tag_rejections, group_rejections):
|
||||||
|
values = (
|
||||||
|
source.get(view_name)
|
||||||
|
if isinstance(source, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if isinstance(values, Mapping):
|
||||||
|
for name, count in values.items():
|
||||||
|
counts[str(name)] = counts.get(str(name), 0) + int(
|
||||||
|
count
|
||||||
|
)
|
||||||
|
if counts:
|
||||||
|
common = sorted(
|
||||||
|
counts.items(), key=lambda pair: (-pair[1], pair[0])
|
||||||
|
)[:3]
|
||||||
|
parts.append(
|
||||||
|
"累计拒绝="
|
||||||
|
+ ",".join(
|
||||||
|
f"{name}×{count}" for name, count in common
|
||||||
|
)
|
||||||
|
)
|
||||||
|
view_candidates = (
|
||||||
|
candidate_diagnostics.get(view_name, {})
|
||||||
|
if isinstance(candidate_diagnostics, Mapping)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
if isinstance(view_candidates, Mapping) and missing:
|
||||||
|
summaries: list[str] = []
|
||||||
|
for role in missing:
|
||||||
|
diagnostic = view_candidates.get(str(role), {})
|
||||||
|
if not isinstance(diagnostic, Mapping):
|
||||||
|
continue
|
||||||
|
summaries.append(
|
||||||
|
f"{role}(solve="
|
||||||
|
f"{int(diagnostic.get('solved_candidate_count', 0))},"
|
||||||
|
"reproj="
|
||||||
|
f"{int(diagnostic.get('reprojection_candidate_count', 0))},"
|
||||||
|
"tilt="
|
||||||
|
f"{int(diagnostic.get('independent_tilt_candidate_count', 0))})"
|
||||||
|
)
|
||||||
|
if summaries:
|
||||||
|
parts.append("候选统计=" + ",".join(summaries))
|
||||||
|
view_details.append(
|
||||||
|
f"{VIEW_NAMES_ZH.get(view_name, view_name)}="
|
||||||
|
+ ";".join(parts)
|
||||||
|
)
|
||||||
|
reason_text = "、".join(view_details)
|
||||||
|
return (
|
||||||
|
f"{detail_prefix}已经取得部分有效轨迹,但Tag仍可见且反馈正常时,"
|
||||||
|
"后续连续图像帧"
|
||||||
|
"被整组PnP几何检查拒绝"
|
||||||
|
f"({reason_text}),因此无法与电机状态形成有效轨迹帧。",
|
||||||
|
"不要调整或反复粘贴Tag;保留当前会话中的"
|
||||||
|
"group_pnp_candidate_event,"
|
||||||
|
"按缺失角色的候选统计检查PnP分支逻辑。",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"{detail_prefix}运动过程中连续超过允许时间没有取得“所需Tag全部有效且能与电机状态"
|
||||||
|
"按时间戳配对”的图像帧。",
|
||||||
|
"查看下面活动机位的缺失Tag,确认状态话题仍在更新;修正后调用resume,"
|
||||||
|
"程序会重扫当前方向。",
|
||||||
|
)
|
||||||
|
if reason == "task_precheck_missing_command_127":
|
||||||
|
return (
|
||||||
|
f"{detail_prefix}低速预检没有取得反馈127附近的同步Tag样本。",
|
||||||
|
"检查中位姿态的Tag遮挡和反光;程序只会重扫当前物理任务。",
|
||||||
|
)
|
||||||
|
if reason == "task_precheck_detection_rate_too_low":
|
||||||
|
return (
|
||||||
|
f"{detail_prefix}低速预检的有效Tag识别率低于门限。",
|
||||||
|
"检查该机位当前任务Tag的遮挡、反光和对焦;程序只会重扫当前物理任务。",
|
||||||
|
)
|
||||||
|
if reason == "sweep_start_position_timeout":
|
||||||
|
return (
|
||||||
|
f"电机{active.get('motor_index')}未在规定时间到达扫描起点"
|
||||||
|
f"{active.get('start_u8')},当前实际值{_format_u8(active.get('actual_u8'))}。",
|
||||||
|
"检查CAN、机械手使能和是否存在机械卡阻,确认安全后调用resume。",
|
||||||
|
)
|
||||||
|
if reason == "sweep_start_tag_timeout":
|
||||||
|
group_reasons = active.get("group_pnp_reasons", {})
|
||||||
|
progress_by_view = active.get("pnp_initialization_progress", {})
|
||||||
|
tag_rejections = active.get("pnp_rejection_counts", {})
|
||||||
|
group_rejections = active.get("group_pnp_rejection_counts", {})
|
||||||
|
if any(
|
||||||
|
isinstance(value, Mapping) and bool(value)
|
||||||
|
for value in (
|
||||||
|
group_reasons,
|
||||||
|
progress_by_view,
|
||||||
|
tag_rejections,
|
||||||
|
group_rejections,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
details: list[str] = []
|
||||||
|
views = set()
|
||||||
|
for value in (
|
||||||
|
group_reasons,
|
||||||
|
progress_by_view,
|
||||||
|
tag_rejections,
|
||||||
|
group_rejections,
|
||||||
|
):
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
views.update(str(view) for view in value)
|
||||||
|
for view in sorted(views):
|
||||||
|
parts: list[str] = []
|
||||||
|
progress = (
|
||||||
|
progress_by_view.get(view)
|
||||||
|
if isinstance(progress_by_view, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if isinstance(progress, Mapping):
|
||||||
|
parts.append(
|
||||||
|
"初始化"
|
||||||
|
f"{int(progress.get('accepted', 0))}/"
|
||||||
|
f"{int(progress.get('required', 0))}"
|
||||||
|
)
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for source in (tag_rejections, group_rejections):
|
||||||
|
values = (
|
||||||
|
source.get(view)
|
||||||
|
if isinstance(source, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if isinstance(values, Mapping):
|
||||||
|
for name, count in values.items():
|
||||||
|
counts[str(name)] = (
|
||||||
|
counts.get(str(name), 0) + int(count)
|
||||||
|
)
|
||||||
|
if counts:
|
||||||
|
common = sorted(
|
||||||
|
counts.items(), key=lambda pair: (-pair[1], pair[0])
|
||||||
|
)[:3]
|
||||||
|
parts.append(
|
||||||
|
"累计拒绝="
|
||||||
|
+ ",".join(
|
||||||
|
f"{name}×{count}" for name, count in common
|
||||||
|
)
|
||||||
|
)
|
||||||
|
latest = (
|
||||||
|
group_reasons.get(view)
|
||||||
|
if isinstance(group_reasons, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if latest and not str(latest).startswith(
|
||||||
|
"group_initializing:"
|
||||||
|
):
|
||||||
|
parts.append(f"最后状态={latest}")
|
||||||
|
if parts:
|
||||||
|
details.append(
|
||||||
|
f"{VIEW_NAMES_ZH.get(view, view)}=" + ";".join(parts)
|
||||||
|
)
|
||||||
|
reason_text = "、".join(details) or "未形成完整初始化窗口"
|
||||||
|
return (
|
||||||
|
"被测电机已经到达扫描起点,所需Tag也可见,但三维PnP位姿初始化"
|
||||||
|
f"没有完成({reason_text}),因此没有生成同步端点帧。",
|
||||||
|
"不要根据可见性重复粘贴Tag;保留累计拒绝原因并检查PnP候选选择。",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"被测电机已经到达扫描起点,但当前任务所需的实时运动Tag没有形成足够的"
|
||||||
|
"同步有效帧。允许遮挡的固定掌部Tag会显示为“锁”,不会触发此错误。",
|
||||||
|
"只检查标记为✗的实时运动Tag、反光和外部遮挡;不要移动相机或手掌底座。",
|
||||||
|
)
|
||||||
|
if reason == "sweep_timeout":
|
||||||
|
return (
|
||||||
|
"当前方向在规定时间内未完成端点到达、有效帧数和行程覆盖要求。",
|
||||||
|
"检查电机实际值、Tag连续识别和标定速度,修正后调用resume。",
|
||||||
|
)
|
||||||
|
if reason == "return_baseline_timeout":
|
||||||
|
return (
|
||||||
|
"一个或多个标定电机未在规定时间返回基准命令。",
|
||||||
|
"检查机械手状态、CAN和机械卡阻,确认安全后调用resume。",
|
||||||
|
)
|
||||||
|
if reason == "validation_move_timeout":
|
||||||
|
return (
|
||||||
|
"随机复测时电机未在规定时间到达目标命令。",
|
||||||
|
"检查机械手状态和机械卡阻,确认安全后调用resume。",
|
||||||
|
)
|
||||||
|
if reason == "validation_capture_timeout":
|
||||||
|
return (
|
||||||
|
"随机复测位置没有采集到足够的同步有效Tag帧。",
|
||||||
|
"检查当前机位Tag可见性后调用resume。",
|
||||||
|
)
|
||||||
|
if reason == "palm_orientation_quality_failed":
|
||||||
|
failures = active.get("failures", [])
|
||||||
|
detail = (
|
||||||
|
str(failures[0].get("reason", "方向观测不足"))
|
||||||
|
if failures
|
||||||
|
else "方向观测不足"
|
||||||
|
)
|
||||||
|
if "thumb_cmc_" in detail:
|
||||||
|
return (
|
||||||
|
"拇指CMC yaw无法由顶部Tag 8/9的零位邻近短轨迹稳定确定:"
|
||||||
|
+ detail,
|
||||||
|
"保持Tag安装不变;确保顶部Tag 8/9在拇指CMC pitch和roll"
|
||||||
|
"从零位开始的前1/4行程持续可见后重新标定。",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"掌部公共方向无法由至少三根手指的短时正面轨迹稳定确定:"
|
||||||
|
+ detail,
|
||||||
|
"保持Tag安装不变;让正面Tag 10–13在对应MCP-pitch起始段"
|
||||||
|
"至少可见15°行程后重新标定。",
|
||||||
|
)
|
||||||
|
if reason in {"joint_fit_check_failed", "joint_fit_systematic_failure"}:
|
||||||
|
metric_names = {
|
||||||
|
"plane_rms_mm": "平面拟合RMS",
|
||||||
|
"radial_rms_mm": "圆半径拟合RMS",
|
||||||
|
"radius_mm": "拟合半径",
|
||||||
|
"image_radial_rms_px": "二维圆半径拟合RMS",
|
||||||
|
"image_radial_p95_px": "二维圆半径误差P95",
|
||||||
|
"image_radius_px": "二维拟合半径",
|
||||||
|
"arc_deg": "实测圆弧",
|
||||||
|
"monotonic_correction_deg": "最大单调修正",
|
||||||
|
"hysteresis_deg": "最大正反程差",
|
||||||
|
"baseline_hysteresis_deg": "baseline正反程关节角差",
|
||||||
|
"baseline_directional_gap_deg": "baseline方向分支间隙",
|
||||||
|
"baseline_directional_gap_range_deg": "baseline分支间隙跨轮极差",
|
||||||
|
"cycle_travel_range_deg": "三轮行程差",
|
||||||
|
"rotation_orthogonal_rms_deg": "三维旋转轴外残差RMS",
|
||||||
|
"axis_plane_rms_mm": "三维圆轴向RMS",
|
||||||
|
"axis_radial_rms_mm": "三维圆半径RMS",
|
||||||
|
"axis_pose_line_rms_mm": "姿态轨迹轴线RMS",
|
||||||
|
"axis_line_cycle_rms_mm": "四轮轴线位置RMS",
|
||||||
|
"rotation_circle_axis_difference_deg": "姿态轴与圆轨迹轴夹角",
|
||||||
|
"axis_cycle_difference_deg": "各轮转轴方向极差",
|
||||||
|
"cross_view_roll_curve": "正面/侧面关节角曲线差异RMS",
|
||||||
|
"third_cycle_axis_holdout_deg": "第三轮留出零位可观测轴向误差",
|
||||||
|
"third_cycle_axis_line_rms_mm": "第三轮留出轴线RMS",
|
||||||
|
"third_cycle_trajectory_p95_deg": "第三轮留出轨迹误差P95",
|
||||||
|
"zero_cycle_offset_range_deg": "训练轮零位极差",
|
||||||
|
"zero_confidence_95_half_width_deg": "零位95%置信半宽",
|
||||||
|
"state_image_sync_p95_ms": "图像与电机状态同步误差P95",
|
||||||
|
"tag_valid_rate_percent": "所需Tag同时有效率",
|
||||||
|
}
|
||||||
|
metric_units = {
|
||||||
|
"plane_rms_mm": "mm",
|
||||||
|
"radial_rms_mm": "mm",
|
||||||
|
"radius_mm": "mm",
|
||||||
|
"image_radial_rms_px": "px",
|
||||||
|
"image_radial_p95_px": "px",
|
||||||
|
"image_radius_px": "px",
|
||||||
|
"arc_deg": "°",
|
||||||
|
"monotonic_correction_deg": "°",
|
||||||
|
"hysteresis_deg": "°",
|
||||||
|
"baseline_hysteresis_deg": "°",
|
||||||
|
"baseline_directional_gap_deg": "°",
|
||||||
|
"baseline_directional_gap_range_deg": "°",
|
||||||
|
"cycle_travel_range_deg": "°",
|
||||||
|
"rotation_orthogonal_rms_deg": "°",
|
||||||
|
"axis_plane_rms_mm": "mm",
|
||||||
|
"axis_radial_rms_mm": "mm",
|
||||||
|
"axis_pose_line_rms_mm": "mm",
|
||||||
|
"axis_line_cycle_rms_mm": "mm",
|
||||||
|
"rotation_circle_axis_difference_deg": "°",
|
||||||
|
"axis_cycle_difference_deg": "°",
|
||||||
|
"third_cycle_axis_holdout_deg": "°",
|
||||||
|
"third_cycle_axis_line_rms_mm": "mm",
|
||||||
|
"third_cycle_trajectory_p95_deg": "°",
|
||||||
|
"zero_cycle_offset_range_deg": "°",
|
||||||
|
"zero_confidence_95_half_width_deg": "°",
|
||||||
|
"state_image_sync_p95_ms": "ms",
|
||||||
|
"tag_valid_rate_percent": "%",
|
||||||
|
"cross_view_roll_curve": "°",
|
||||||
|
}
|
||||||
|
details: list[str] = []
|
||||||
|
for failure in active.get("failures", []):
|
||||||
|
joint = JOINT_NAMES_ZH.get(
|
||||||
|
str(failure.get("joint")), str(failure.get("joint"))
|
||||||
|
)
|
||||||
|
metric = str(failure.get("metric", ""))
|
||||||
|
if metric in metric_names:
|
||||||
|
comparison = str(failure.get("comparison", ""))
|
||||||
|
requirement = "不超过" if comparison == "maximum" else "至少"
|
||||||
|
unit = metric_units[metric]
|
||||||
|
detail = (
|
||||||
|
f"{joint}的{metric_names[metric]}为"
|
||||||
|
f"{float(failure.get('actual', 0.0)):.2f}{unit},"
|
||||||
|
f"要求{requirement}{float(failure.get('limit', 0.0)):.2f}{unit}"
|
||||||
|
)
|
||||||
|
cycle_travel = failure.get("cycle_travel_deg", [])
|
||||||
|
if cycle_travel:
|
||||||
|
detail += "(各轮=" + "/".join(
|
||||||
|
f"{float(value):.2f}°" for value in cycle_travel
|
||||||
|
) + ")"
|
||||||
|
cycle_values = failure.get("cycle_values_deg", [])
|
||||||
|
if not cycle_values:
|
||||||
|
cycle_values = failure.get("cycle_offset_deg", [])
|
||||||
|
if cycle_values:
|
||||||
|
detail += "(各轮=" + "/".join(
|
||||||
|
f"{float(value):.2f}°" for value in cycle_values
|
||||||
|
) + ")"
|
||||||
|
details.append(detail)
|
||||||
|
else:
|
||||||
|
cycle = failure.get("cycle")
|
||||||
|
cycle_text = "" if cycle is None else f"第{cycle}轮"
|
||||||
|
details.append(
|
||||||
|
f"{joint}的{cycle_text}{metric or '轨迹'}拟合失败:"
|
||||||
|
f"{failure.get('reason', '未知原因')}"
|
||||||
|
)
|
||||||
|
detail_text = ";".join(details) or "当前关节的轨迹拟合未通过"
|
||||||
|
directional_gap_failure = any(
|
||||||
|
str(failure.get("metric", "")).startswith(
|
||||||
|
"baseline_directional_gap"
|
||||||
|
)
|
||||||
|
for failure in active.get("failures", [])
|
||||||
|
)
|
||||||
|
if reason == "joint_fit_systematic_failure":
|
||||||
|
cross_view_systematic = any(
|
||||||
|
failure.get("classification")
|
||||||
|
in {
|
||||||
|
"stable_cross_view_installation_or_model_bias",
|
||||||
|
"stable_cross_view_direction_conflict",
|
||||||
|
}
|
||||||
|
for failure in active.get("failures", [])
|
||||||
|
)
|
||||||
|
suggestion = (
|
||||||
|
"四轮都出现稳定的正面/侧面差异,属于Tag安装外参或跨视角模型偏差,"
|
||||||
|
"继续重扫不会改善;检查Tag刚性安装与跨视角安装变换,不要放宽门限。"
|
||||||
|
if cross_view_systematic
|
||||||
|
else "各轮重复出现同一模型冲突,继续运动不会改善;程序已禁止自动重扫。"
|
||||||
|
"请直接复制诊断块给开发者,不要放宽门限。"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
source_task_names = set(active.get("source_task_names", []))
|
||||||
|
thumb_yaw_source_retry = source_task_names == {
|
||||||
|
"thumb_cmc_pitch_front",
|
||||||
|
"thumb_cmc_roll_front",
|
||||||
|
}
|
||||||
|
suggestion = (
|
||||||
|
"方向分支已由软件保留,不要放宽门限;请检查传动回差或高支架刚度,"
|
||||||
|
"处理后重新执行一键标定命令,程序会从最近可靠断点继续。"
|
||||||
|
if directional_gap_failure
|
||||||
|
else (
|
||||||
|
"保持顶部Tag 8/9无遮挡;程序只替换决定yaw零位的"
|
||||||
|
"CMC pitch/roll顶部轴观测并重扫"
|
||||||
|
f"{active.get('directions_to_rescan', 16)}个方向,"
|
||||||
|
"不会无效重扫yaw侧摆。"
|
||||||
|
if thumb_yaw_source_retry
|
||||||
|
else "修正Tag位置、遮挡或机械行程后重新执行一键标定命令;"
|
||||||
|
"程序只清除当前失败关节的数据并重扫"
|
||||||
|
f"{active.get('directions_to_rescan', 6)}个方向,"
|
||||||
|
"不需要手工调用ROS服务。"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
detail_text + "。程序已在当前关节结束后立即停止后续步骤。",
|
||||||
|
suggestion,
|
||||||
|
)
|
||||||
|
if reason == "zero_model_validation_failed":
|
||||||
|
reason_names = {
|
||||||
|
"zero_offset_reached_configured_bound": "零位解触及安全边界",
|
||||||
|
"zero_offset_exceeds_configured_limit": "零位估计超过安全范围",
|
||||||
|
"zero_offset_reached_diagnostic_bound": "零位估计仍触及诊断搜索边界",
|
||||||
|
"zero_offset_cycle_difference_too_large": "三轮零位离散过大",
|
||||||
|
"zero_offset_not_statistically_significant": "零位偏移未达到统计显著性",
|
||||||
|
"zero_axis_cone_mismatch_too_large": (
|
||||||
|
"父子轴夹角与原始URDF不一致,零位旋转无法解释"
|
||||||
|
),
|
||||||
|
"zero_phase_axis_line_residual_too_large": (
|
||||||
|
"整段SE(3)运动无法稳定确定平行轴线相位"
|
||||||
|
),
|
||||||
|
"zero_offset_did_not_improve_with_95pct_confidence": (
|
||||||
|
"第三轮留出验证未以95%置信度改善"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
details: list[str] = []
|
||||||
|
for failure in active.get("failures", []):
|
||||||
|
joint = JOINT_NAMES_ZH.get(
|
||||||
|
str(failure.get("joint")), str(failure.get("joint"))
|
||||||
|
)
|
||||||
|
if failure.get("metric") == "zero_guard":
|
||||||
|
reason_text = reason_names.get(
|
||||||
|
str(failure.get("reason")), str(failure.get("reason"))
|
||||||
|
)
|
||||||
|
if "actual_deg" in failure and "limit_deg" in failure:
|
||||||
|
reason_text += (
|
||||||
|
f"(估计{float(failure['actual_deg']):+.2f}°,"
|
||||||
|
f"允许±{float(failure['limit_deg']):.2f}°)"
|
||||||
|
)
|
||||||
|
details.append(f"{joint}:{reason_text}")
|
||||||
|
return (
|
||||||
|
"轨迹采集已完成,但零位/URDF几何验证失败"
|
||||||
|
+ ("(" + ";".join(details) + ")" if details else "")
|
||||||
|
+ "。程序没有生成正式JSON或修正URDF。",
|
||||||
|
"该类稳定模型失败不能靠重复运动修复,程序不会自动重扫;"
|
||||||
|
"请检查Tag固定、相机外参和原始URDF后重新启动新标定。",
|
||||||
|
)
|
||||||
|
if reason in {"waiting_for_devices_and_sdk", "device_preflight_lost"}:
|
||||||
|
return (
|
||||||
|
"正在等待三台相机数据、内外参身份以及机械手SDK反馈就绪;此阶段不以Tag可见性阻止基准恢复。",
|
||||||
|
"保持机械手运动范围无障碍;设备就绪后系统会先安全恢复基准形态,再检查掌部Tag。",
|
||||||
|
)
|
||||||
|
if reason in {"waiting_for_three_cameras_tags_and_sdk", "preflight_lost"}:
|
||||||
|
return (
|
||||||
|
"正在等待三台相机内参、外参身份匹配、帧率、全部必需Tag以及机械手SDK同时就绪。",
|
||||||
|
"根据下面每个机位的缺失Tag和有效率排查;全部就绪后程序会进入等待开始状态。",
|
||||||
|
)
|
||||||
|
if reason == "waiting_for_baseline_tags_after_recovery":
|
||||||
|
return (
|
||||||
|
"机械手已经稳定恢复到基准形态,正在用新采集的画面确认三台相机各自的固定掌部Tag。",
|
||||||
|
"若某个掌部Tag持续缺失,只调整遮挡手指或检查Tag固定情况,不要移动相机和手掌底座。",
|
||||||
|
)
|
||||||
|
if reason == "locking_fixed_base_references":
|
||||||
|
return (
|
||||||
|
"基准形态Tag预检已通过,正在把三台相机的固定掌部Tag稳健锁定为本会话参考。",
|
||||||
|
"无需操作;锁定完成后允许任务姿态遮挡固定掌部Tag。",
|
||||||
|
)
|
||||||
|
if reason == "fixed_base_reference_moved":
|
||||||
|
return (
|
||||||
|
"顶部Tag 8在本会话基准锁定后连续多帧发生角点位移;程序已立即保持机械手当前位置,"
|
||||||
|
"本会话中已采集数据不再用于发布。",
|
||||||
|
"Tag 8允许在下一次标定预检前重新摆放,但本次不能继续;固定Tag 8和顶部相机后"
|
||||||
|
"重新启动新会话。",
|
||||||
|
)
|
||||||
|
if reason == "waiting_for_task_tags_at_sweep_start":
|
||||||
|
return (
|
||||||
|
"电机已到扫描起点,正在等待当前任务的实时运动Tag;显示为“锁”的固定掌部Tag"
|
||||||
|
"允许被手指遮挡。",
|
||||||
|
"只检查标记为✗的实时运动Tag;若均为✓或锁,程序会自动开始运动。",
|
||||||
|
)
|
||||||
|
if reason == "call_start_for_baseline_recovery":
|
||||||
|
return (
|
||||||
|
"相机数据和机械手反馈已就绪,等待一键程序触发安全基准恢复。",
|
||||||
|
"保持机械手运动范围无障碍;程序会自动开始,无需手工调用ROS服务。",
|
||||||
|
)
|
||||||
|
if reason == "call_start":
|
||||||
|
return (
|
||||||
|
"三机位预检已经通过,等待操作员确认开始。",
|
||||||
|
"清空机械手运动范围后调用/g20_calibration/start。",
|
||||||
|
)
|
||||||
|
if reason == "operator_pause":
|
||||||
|
return "操作员主动暂停了标定。", "确认安全后调用/g20_calibration/resume。"
|
||||||
|
if reason == "operator_abort":
|
||||||
|
return "操作员终止了本次标定,程序保持终止时的当前姿态。", "需要重新启动一次新标定。"
|
||||||
|
if reason == "collecting_timestamp_synchronised_tag_centres":
|
||||||
|
return "正在按时间戳配对Tag图像和电机状态并采集当前轨迹。", "无需操作,保持相机、标签和底座不动。"
|
||||||
|
if reason == "collecting_dedicated_baseline_hold":
|
||||||
|
return (
|
||||||
|
"正在从当前方向到达关节baseline并静止采集Tag与电机反馈;这批数据单独用于回差验收。",
|
||||||
|
"无需操作,保持相机、标签和底座不动。",
|
||||||
|
)
|
||||||
|
if reason == "steady checkpoint target is missing":
|
||||||
|
return (
|
||||||
|
"首轮稳态检查点已经到达最终端点,但采集状态没有及时切换到端点完成阶段。",
|
||||||
|
"程序已停止发布并保留已采样数据;这是软件状态切换问题,不需要调整相机、Tag或机械手。",
|
||||||
|
)
|
||||||
|
if reason == "cross_view_roll_front_failure_deferred":
|
||||||
|
return (
|
||||||
|
"正面侧摆回差不合格已保留,诊断模式将继续采集同一手指的侧面数据。",
|
||||||
|
"无需操作;该诊断会锁定URDF发布。",
|
||||||
|
)
|
||||||
|
if reason == "cross_view_roll_diagnostic_complete":
|
||||||
|
interpretation = str(active.get("interpretation", ""))
|
||||||
|
explanations = {
|
||||||
|
"both_views_confirm_direction_dependent_pose": (
|
||||||
|
"正面和侧面都确认了方向相关姿态,优先判断为roll输出机构或共同下游链的真实回差。"
|
||||||
|
),
|
||||||
|
"front_only_difference_check_roll_tag_bracket_or_front_pnp": (
|
||||||
|
"只有正面差异超限,优先检查roll Tag高支架刚度和正面PnP。"
|
||||||
|
),
|
||||||
|
"side_only_difference_check_side_tag_chain_or_side_pnp": (
|
||||||
|
"只有侧面差异超限,优先检查侧面Tag链和侧面PnP。"
|
||||||
|
),
|
||||||
|
"both_views_within_formal_hysteresis_limit": (
|
||||||
|
"两个机位的静止回差均满足正式门限。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
explanations.get(interpretation, "四指侧摆跨机位诊断已经完成。")
|
||||||
|
+ " 本次为诊断会话,不会生成或发布URDF。",
|
||||||
|
"保存当前状态和raw_samples.jsonl;根据两机位结论处理后重新启动正式标定。",
|
||||||
|
)
|
||||||
|
if reason == "capturing_random_validation_pose":
|
||||||
|
return "正在当前随机命令位置采集复测数据。", "无需操作,保持设备不动。"
|
||||||
|
if reason in {"calibration_passed", "calibration_complete"}:
|
||||||
|
return "三维轨迹、关节轴零位和第三轮留出验证已经完成。", "检查JSON、修正URDF路径和quality.passed。"
|
||||||
|
if reason == "quality_failed":
|
||||||
|
return "标定流程完成,但拟合或随机复测质量没有达到验收阈值。", "检查最终JSON的quality以及启动终端中的拟合日志。"
|
||||||
|
if reason.startswith("validated_endpoint_zero_state"):
|
||||||
|
return (
|
||||||
|
"轨迹和URDF零位验证已经通过,但发布前检测到端点零位状态缺失或与"
|
||||||
|
"已验证模型不一致;这是程序内部状态生命周期错误,结果未发布。",
|
||||||
|
"不要移动相机、Tag或机械手底座;保留当前会话并把原因码交给开发者。",
|
||||||
|
)
|
||||||
|
if reason.startswith("PUB-ARTIFACT-601:"):
|
||||||
|
return (
|
||||||
|
"标定节点已经生成通过质量门限的JSON和候选URDF,但一键程序在正式发布前"
|
||||||
|
"发现这对产物的坐标、限位、哈希或资源一致性检查失败;原始URDF未被覆盖。",
|
||||||
|
"不要重新标定相机或调整Tag;保留本会话产物和启动日志供开发者检查发布契约。",
|
||||||
|
)
|
||||||
|
if reason == "combination_pose_prediction_failed":
|
||||||
|
return (
|
||||||
|
"单关节、零位和URDF几何验证已通过,但当前多关节组合姿态的Tag实测位姿与模型预测超过门限。",
|
||||||
|
"程序会在原姿态重新初始化PnP并自动复测;若最终仍失败,请把raw_samples.jsonl中的"
|
||||||
|
"combination_validation_failure记录交给开发者,不要重新采集16个单关节任务。",
|
||||||
|
)
|
||||||
|
if reason.startswith("prepare_") or state == "PREPARE_SWEEP":
|
||||||
|
return "正在把当前电机移动到本方向的扫描起点并等待稳定。", "无需操作。"
|
||||||
|
if reason == "holding_same_finger_clearance_before_next_task":
|
||||||
|
return (
|
||||||
|
"同一根手指的上一项已经完成;相邻手指继续保持当前避让姿态,只调整"
|
||||||
|
"被测关节以衔接下一项。",
|
||||||
|
"无需操作,不要手动展开正在避让的手指。",
|
||||||
|
)
|
||||||
|
if state == "RETURN_BASELINE":
|
||||||
|
return "正在把已使用的标定电机恢复到目标姿态。", "无需操作。"
|
||||||
|
if state == "FITTING":
|
||||||
|
return "所有扫描已经完成,正在联合拟合三维机械轴和URDF零位偏移。", "无需操作。"
|
||||||
|
return f"未分类原因码:{reason}", "保留该原因码和启动终端日志用于进一步定位。"
|
||||||
|
|
||||||
|
|
||||||
|
def render_three_camera_status_text_zh(payload: Mapping[str, Any]) -> str:
|
||||||
|
"""Render the complete operator status; the JSON topic remains unchanged."""
|
||||||
|
state = str(payload.get("state", ""))
|
||||||
|
active = payload.get("active", {})
|
||||||
|
reason_zh, action_zh = three_camera_reason_zh(
|
||||||
|
state, str(payload.get("reason", "")), active
|
||||||
|
)
|
||||||
|
progress = float(payload.get("progress", 0.0))
|
||||||
|
completed = payload.get("completed_sweeps", 0)
|
||||||
|
total = payload.get("total_sweeps", 0)
|
||||||
|
scan_progress = float(
|
||||||
|
payload.get(
|
||||||
|
"scan_progress",
|
||||||
|
0.0 if not total else float(completed) / float(total),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lines = [
|
||||||
|
f"状态:{STATE_NAMES_ZH.get(state, state)}({state})",
|
||||||
|
f"原因:{reason_zh}",
|
||||||
|
f"建议:{action_zh}",
|
||||||
|
f"总体进度:{progress:.1%}(计划扫描{completed}/{total}个方向,"
|
||||||
|
f"扫描进度{scan_progress:.1%})",
|
||||||
|
f"当前任务:{_task_text(active)}",
|
||||||
|
]
|
||||||
|
if state == "RETURN_BASELINE":
|
||||||
|
baseline_command = payload.get("baseline_command_u8", [])
|
||||||
|
return_command = payload.get("return_command_u8", baseline_command)
|
||||||
|
label = "恢复姿态" if return_command != baseline_command else "基准姿态"
|
||||||
|
lines.append(f"正在确认{label}:{return_command}")
|
||||||
|
if active and active.get("kind") not in {
|
||||||
|
"fit_failure",
|
||||||
|
"zero_model_failure",
|
||||||
|
"motion_stall",
|
||||||
|
}:
|
||||||
|
retry_count = int(active.get("automatic_retry_count", 0))
|
||||||
|
if retry_count:
|
||||||
|
lines.append(
|
||||||
|
"自动重试:当前方向已自动重扫"
|
||||||
|
f"{retry_count}/{active.get('automatic_retry_limit', '?')}次,"
|
||||||
|
f"速度比例{float(active.get('retry_speed_scale', 1.0)):.0%},"
|
||||||
|
f"端点保持{float(active.get('endpoint_hold_seconds', 0.0)):.2f}s"
|
||||||
|
)
|
||||||
|
sample = active.get("sample", {})
|
||||||
|
motion_progress = active.get("motion_progress")
|
||||||
|
motion_text = (
|
||||||
|
"未知" if motion_progress is None else f"{float(motion_progress):.1%}"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
"运动采样:"
|
||||||
|
f"目标{active.get('target_u8', active.get('command_u8', '?'))},"
|
||||||
|
f"实际{_format_u8(active.get('actual_u8'))},"
|
||||||
|
f"本方向{motion_text},有效帧{active.get('valid_frames', 0)},"
|
||||||
|
f"实际采样范围{_format_u8(sample.get('minimum_u8'))}~"
|
||||||
|
f"{_format_u8(sample.get('maximum_u8'))}"
|
||||||
|
)
|
||||||
|
detection_frames = int(active.get("detection_frames", 0))
|
||||||
|
if detection_frames:
|
||||||
|
lines.append(
|
||||||
|
"本方向Tag检出:"
|
||||||
|
f"{float(active.get('detection_rate', 0.0)):.1%}"
|
||||||
|
f"({active.get('detection_valid_frames', 0)}/"
|
||||||
|
f"{detection_frames}帧)"
|
||||||
|
)
|
||||||
|
auxiliary = active.get("auxiliary_motors", [])
|
||||||
|
if auxiliary:
|
||||||
|
lines.append(
|
||||||
|
"避挡姿态:"
|
||||||
|
+ ",".join(
|
||||||
|
f"电机{item.get('motor_index')}目标"
|
||||||
|
f"{item.get('command_u8')}、实际"
|
||||||
|
f"{_format_u8(item.get('actual_u8'))}"
|
||||||
|
for item in auxiliary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
speed = active.get("speed", {})
|
||||||
|
if speed:
|
||||||
|
lines.append(
|
||||||
|
"阶段速度:五指目标"
|
||||||
|
f"{speed.get('commanded_finger_speed')},SDK报告"
|
||||||
|
f"{speed.get('reported_finger_speed')}"
|
||||||
|
)
|
||||||
|
if active.get("sweep_timeout_seconds") is not None:
|
||||||
|
lines.append(
|
||||||
|
"运动保护:扫描超时"
|
||||||
|
f"{float(active['sweep_timeout_seconds']):.1f}s,"
|
||||||
|
"连续"
|
||||||
|
f"{float(active.get('motor_stall_timeout_seconds', 0.0)):.1f}s"
|
||||||
|
"进展不足"
|
||||||
|
f"{float(active.get('motor_stall_minimum_progress_u8', 0.0)):.1f}"
|
||||||
|
"则立即暂停"
|
||||||
|
)
|
||||||
|
lines.append("机位:")
|
||||||
|
for name, view in payload.get("views", {}).items():
|
||||||
|
missing = view.get("missing_tag_ids", [])
|
||||||
|
missing_text = "无" if not missing else ",".join(map(str, missing))
|
||||||
|
lines.append(
|
||||||
|
f"- {VIEW_NAMES_ZH.get(str(name), str(name))}:"
|
||||||
|
f"{'就绪' if view.get('ready') else '等待'},"
|
||||||
|
f"外参{'匹配' if view.get('camera_extrinsics_valid') else '不匹配'},"
|
||||||
|
f"{float(view.get('detection_hz', 0.0)):.1f}Hz,"
|
||||||
|
f"全部必需Tag同时有效率{float(view.get('valid_rate', 0.0)):.1%},"
|
||||||
|
f"当前缺失Tag={missing_text}"
|
||||||
|
)
|
||||||
|
extrinsics_error = payload.get("camera_extrinsics_error")
|
||||||
|
if extrinsics_error:
|
||||||
|
lines.append(f"外参文件:{extrinsics_error}")
|
||||||
|
lines.append(f"JSON结果:{payload.get('result_path') or '尚未生成'}")
|
||||||
|
lines.append(
|
||||||
|
f"修正URDF:{payload.get('corrected_urdf_path') or '尚未生成'}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
"""Independent reviewed profile for the right 19-Tag product layout."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.core import ProfileKey
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import G20_RIGHT_19_LAYOUT, get_hand_calibration_profile
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_policy import (
|
||||||
|
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
|
||||||
|
RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS,
|
||||||
|
get_zero_calibration_profile,
|
||||||
|
)
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import RegisteredProfile
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20._adapter import adapt_profile
|
||||||
|
from linkerhand_calibration.profiles.loader import load_bundled_hand_profile
|
||||||
|
|
||||||
|
|
||||||
|
KEY = ProfileKey("G20", "right", G20_RIGHT_19_LAYOUT, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def build_profile() -> RegisteredProfile:
|
||||||
|
hand = get_hand_calibration_profile(KEY.side, KEY.layout)
|
||||||
|
zero = get_zero_calibration_profile(KEY.side, KEY.layout)
|
||||||
|
return adapt_profile(
|
||||||
|
key=KEY,
|
||||||
|
namespace="/g20_calibration",
|
||||||
|
hand_profile=hand,
|
||||||
|
zero_profile=zero,
|
||||||
|
mechanical_endpoint_joints=RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
|
||||||
|
post_solve_endpoint_joints=RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS,
|
||||||
|
typed_profile=load_bundled_hand_profile(KEY.layout),
|
||||||
|
)
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
"""Legacy import path; all online work uses the common product runner."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.runtime.runner import main
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.runner_helpers import (
|
||||||
|
_automatic_resume_candidate, _calibration_node_exited_before_status,
|
||||||
|
_launch_command, _resolve_partial_base_session, _status_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
"""Exact JSON handoff between G20 fitting and the existing URDF writer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
ARTIFACT_TYPE = "linkerhand_g20_urdf_correction_input"
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: str | Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with Path(path).expanduser().resolve().open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def build_g20_urdf_input_payload(
|
||||||
|
*,
|
||||||
|
side: str,
|
||||||
|
layout_id: str,
|
||||||
|
serial_number: str,
|
||||||
|
source_urdf: str | Path,
|
||||||
|
offsets_rad: Mapping[str, float],
|
||||||
|
endpoint_anchored_offsets_rad: Mapping[str, float],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the exact correction parameters that will be read from disk."""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"artifact_type": ARTIFACT_TYPE,
|
||||||
|
"profile_id": f"G20/{str(side).lower()}/{layout_id}",
|
||||||
|
"model": "G20",
|
||||||
|
"side": str(side).lower(),
|
||||||
|
"layout_id": str(layout_id),
|
||||||
|
"serial_number": str(serial_number),
|
||||||
|
"source_urdf_sha256": _sha256_file(source_urdf),
|
||||||
|
"offsets_rad": {
|
||||||
|
str(name): float(value) for name, value in sorted(offsets_rad.items())
|
||||||
|
},
|
||||||
|
"endpoint_anchored_offsets_rad": {
|
||||||
|
str(name): float(value)
|
||||||
|
for name, value in sorted(endpoint_anchored_offsets_rad.items())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
validate_g20_urdf_input_payload(payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def validate_g20_urdf_input_payload(payload: Mapping[str, Any]) -> None:
|
||||||
|
required = {
|
||||||
|
"schema_version", "artifact_type", "profile_id", "model", "side",
|
||||||
|
"layout_id", "serial_number", "source_urdf_sha256", "offsets_rad",
|
||||||
|
"endpoint_anchored_offsets_rad",
|
||||||
|
}
|
||||||
|
if set(payload) != required:
|
||||||
|
raise ValueError("G20 URDF correction input has unexpected fields")
|
||||||
|
if (
|
||||||
|
payload.get("schema_version") != SCHEMA_VERSION
|
||||||
|
or payload.get("artifact_type") != ARTIFACT_TYPE
|
||||||
|
or payload.get("model") != "G20"
|
||||||
|
or payload.get("side") not in {"left", "right"}
|
||||||
|
):
|
||||||
|
raise ValueError("G20 URDF correction input identity is invalid")
|
||||||
|
expected_profile = (
|
||||||
|
f"G20/{payload['side']}/{payload['layout_id']}"
|
||||||
|
)
|
||||||
|
if payload.get("profile_id") != expected_profile:
|
||||||
|
raise ValueError("G20 URDF correction input profile is invalid")
|
||||||
|
source_hash = str(payload.get("source_urdf_sha256", ""))
|
||||||
|
if len(source_hash) != 64 or any(
|
||||||
|
character not in "0123456789abcdef" for character in source_hash
|
||||||
|
):
|
||||||
|
raise ValueError("G20 URDF correction input source hash is invalid")
|
||||||
|
offsets = payload.get("offsets_rad")
|
||||||
|
endpoints = payload.get("endpoint_anchored_offsets_rad")
|
||||||
|
if not isinstance(offsets, Mapping) or not offsets:
|
||||||
|
raise ValueError("G20 URDF correction input offsets are missing")
|
||||||
|
if not isinstance(endpoints, Mapping) or not set(endpoints) <= set(offsets):
|
||||||
|
raise ValueError("G20 URDF correction input endpoint offsets are invalid")
|
||||||
|
if any(not math.isfinite(float(value)) for value in offsets.values()):
|
||||||
|
raise ValueError("G20 URDF correction input contains a non-finite offset")
|
||||||
|
if any(not math.isfinite(float(value)) for value in endpoints.values()):
|
||||||
|
raise ValueError("G20 URDF correction input has a non-finite endpoint")
|
||||||
|
|
||||||
|
|
||||||
|
def load_g20_urdf_input(
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
source_urdf: str | Path,
|
||||||
|
side: str,
|
||||||
|
layout_id: str,
|
||||||
|
serial_number: str,
|
||||||
|
) -> tuple[dict[str, float], dict[str, float]]:
|
||||||
|
"""Read, validate and authenticate correction parameters from JSON."""
|
||||||
|
source = Path(path).expanduser().resolve()
|
||||||
|
try:
|
||||||
|
payload = json.loads(source.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise ValueError(f"could not read G20 URDF correction input: {source}") from error
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
raise ValueError("G20 URDF correction input must be a JSON object")
|
||||||
|
validate_g20_urdf_input_payload(payload)
|
||||||
|
if (
|
||||||
|
payload["side"] != str(side).lower()
|
||||||
|
or payload["layout_id"] != str(layout_id)
|
||||||
|
or payload["serial_number"] != str(serial_number)
|
||||||
|
):
|
||||||
|
raise ValueError("G20 URDF correction input does not match the session")
|
||||||
|
if payload["source_urdf_sha256"] != _sha256_file(source_urdf):
|
||||||
|
raise ValueError("G20 source URDF changed after calibration JSON was written")
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
str(name): float(value)
|
||||||
|
for name, value in payload["offsets_rad"].items()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
str(name): float(value)
|
||||||
|
for name, value in payload["endpoint_anchored_offsets_rad"].items()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_g20_urdf_input_payload",
|
||||||
|
"load_g20_urdf_input",
|
||||||
|
"validate_g20_urdf_input_payload",
|
||||||
|
]
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
"""Reviewed static-zero, endpoint, and mimic topology exports."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import MIMIC_DERIVED_FINGER_DIPS
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_solver import (
|
||||||
|
RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS,
|
||||||
|
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
|
||||||
|
RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS,
|
||||||
|
get_zero_calibration_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MIMIC_DERIVED_FINGER_DIPS",
|
||||||
|
"RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS",
|
||||||
|
"RIGHT_19_MECHANICAL_ENDPOINT_JOINTS",
|
||||||
|
"RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS",
|
||||||
|
"get_zero_calibration_profile",
|
||||||
|
]
|
||||||
+931
@@ -0,0 +1,931 @@
|
|||||||
|
"""Three-dimensional joint-axis fitting and URDF zero correction."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.spatial.transform import Rotation
|
||||||
|
|
||||||
|
from linkerhand_calibration.core import fit_rotation_axis, robust_rotation_summary
|
||||||
|
from linkerhand_calibration.core.urdf import (
|
||||||
|
UrdfCorrectionPlan,
|
||||||
|
UrdfJointPatch,
|
||||||
|
UrdfPatchSet,
|
||||||
|
materialize_relative_mesh_assets as _materialize_relative_mesh_assets,
|
||||||
|
write_urdf_patches,
|
||||||
|
)
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import (
|
||||||
|
G20_RIGHT_19_LAYOUT,
|
||||||
|
IMAGE_TRAJECTORY_JOINTS,
|
||||||
|
LEFT_HAND_PROFILE,
|
||||||
|
RIGHT_19_END_ON_IMAGE_CURVE_JOINTS,
|
||||||
|
RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS,
|
||||||
|
HandCalibrationProfile,
|
||||||
|
JointCurveFit,
|
||||||
|
get_hand_calibration_profile,
|
||||||
|
)
|
||||||
|
from linkerhand_calibration.sample_schema import explicit_domain_value
|
||||||
|
from linkerhand_calibration.trajectory import (
|
||||||
|
_fit_circle_with_axis,
|
||||||
|
_fit_joint_curve,
|
||||||
|
_fit_plane_axis,
|
||||||
|
_plane_basis,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from linkerhand_calibration.core.fitting import spatial as _spatial
|
||||||
|
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel, _parse_triplet, _axis_rotation
|
||||||
|
from linkerhand_calibration.core.fitting.spatial import (
|
||||||
|
ZeroCalibrationProfile,
|
||||||
|
circle_direction_is_constrained,
|
||||||
|
select_cross_view_roll_direction_source,
|
||||||
|
_zero_sensitive_axis_error_rad,
|
||||||
|
_axis_cone_mismatch_rad,
|
||||||
|
_vector,
|
||||||
|
_pose_matrix,
|
||||||
|
_relative_rotation,
|
||||||
|
_reference_group_key,
|
||||||
|
_canonical_reference_records,
|
||||||
|
_interpolate_reference_rotation,
|
||||||
|
_near_zero_records,
|
||||||
|
_baseline_reference,
|
||||||
|
baseline_hysteresis_by_cycle_rad,
|
||||||
|
fit_rotation_joint_curve,
|
||||||
|
measure_rotation_joint_observation,
|
||||||
|
measure_joint_curve_observation,
|
||||||
|
rotation_curve_holdout_errors,
|
||||||
|
joint_curve_holdout_errors,
|
||||||
|
JointAxisMeasurement,
|
||||||
|
PalmOrientationMeasurement,
|
||||||
|
_incremental_common_rotation_axis,
|
||||||
|
fit_partial_palm_orientation_measurement,
|
||||||
|
fit_partial_palm_orientation_measurements,
|
||||||
|
with_depth_free_axis_projection,
|
||||||
|
cross_view_side_line_source,
|
||||||
|
axis_line_uses_depth_free_interpretation_plane,
|
||||||
|
refit_axis_line_group_with_shared_radius,
|
||||||
|
maximum_axis_line_cycle_spread_m,
|
||||||
|
axis_line_cycle_rms_m,
|
||||||
|
_fit_axis_point_from_pose_trajectory,
|
||||||
|
ZeroSolveResult,
|
||||||
|
_angles_from_state
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def solve_urdf_zero_offsets(**kwargs) -> ZeroSolveResult:
|
||||||
|
"""Compatibility input binding; all numerical work lives in core."""
|
||||||
|
options = dict(kwargs)
|
||||||
|
profile = options.get("zero_profile")
|
||||||
|
if profile is None:
|
||||||
|
profile = get_zero_calibration_profile(options.get("hand_type", "left"), options.get("tag_layout", "legacy_11"))
|
||||||
|
pattern = profile.hand.layout_id == G20_RIGHT_19_LAYOUT
|
||||||
|
profile = replace(profile,
|
||||||
|
parallel_root_pattern=pattern,
|
||||||
|
common_mode_zero_joints=tuple(name for name in profile.direct_zero_joints if name.endswith("_mcp_roll") and not name.startswith("thumb_")) if pattern else (),
|
||||||
|
small_offset_joints=frozenset(name for name in profile.direct_zero_joints if name.startswith(("index_", "middle_", "ring_", "pinky_"))),
|
||||||
|
orientation_anchor_joint=profile.orientation_anchor_joint or ("thumb_cmc_pitch" if profile.base_pose_strategy == "thumb_serial" else f"{profile.reference_finger}_mcp_pitch"),
|
||||||
|
base_pose_strategy="serial_chain" if profile.base_pose_strategy == "thumb_serial" else profile.base_pose_strategy,
|
||||||
|
)
|
||||||
|
options["zero_profile"] = profile
|
||||||
|
return _spatial.solve_urdf_zero_offsets(**options)
|
||||||
|
|
||||||
|
|
||||||
|
def fit_joint_axis_measurement(*args, **kwargs) -> JointAxisMeasurement:
|
||||||
|
kwargs.setdefault("constrained_circle_joints", CONSTRAINED_CIRCLE_JOINTS)
|
||||||
|
return _spatial.fit_joint_axis_measurement(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_zero_profile(hand: HandCalibrationProfile) -> ZeroCalibrationProfile:
|
||||||
|
if hand.layout_id == G20_RIGHT_19_LAYOUT:
|
||||||
|
return _build_right_19_zero_profile(hand)
|
||||||
|
reference = hand.reference_finger
|
||||||
|
reference_roll = f"{reference}_mcp_roll"
|
||||||
|
reference_pitch = f"{reference}_mcp_pitch"
|
||||||
|
reference_pip = f"{reference}_pip"
|
||||||
|
reference_dip = f"{reference}_dip"
|
||||||
|
direct = (
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"thumb_cmc_yaw",
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"thumb_mcp",
|
||||||
|
reference_roll,
|
||||||
|
reference_pitch,
|
||||||
|
reference_pip,
|
||||||
|
)
|
||||||
|
inherited = {
|
||||||
|
f"{finger}_{suffix}": f"{reference}_{suffix}"
|
||||||
|
for finger in ("index", "middle", "ring", "pinky")
|
||||||
|
if finger != reference
|
||||||
|
for suffix in ("mcp_roll", "mcp_pitch", "pip")
|
||||||
|
}
|
||||||
|
axis_parent = {
|
||||||
|
"thumb_cmc_yaw": "thumb_cmc_roll",
|
||||||
|
"thumb_cmc_pitch": "thumb_cmc_yaw",
|
||||||
|
reference_pitch: reference_roll,
|
||||||
|
}
|
||||||
|
phase_parent = {
|
||||||
|
"thumb_mcp": "thumb_cmc_pitch",
|
||||||
|
"thumb_ip": "thumb_mcp",
|
||||||
|
reference_pip: reference_pitch,
|
||||||
|
reference_dip: reference_pip,
|
||||||
|
}
|
||||||
|
observer = {
|
||||||
|
"thumb_cmc_roll": "thumb_cmc_yaw",
|
||||||
|
"thumb_cmc_yaw": "thumb_cmc_pitch",
|
||||||
|
"thumb_cmc_pitch": "thumb_mcp",
|
||||||
|
"thumb_mcp": "thumb_ip",
|
||||||
|
reference_roll: reference_pitch,
|
||||||
|
reference_pitch: reference_pip,
|
||||||
|
reference_pip: reference_dip,
|
||||||
|
}
|
||||||
|
fixed_direct_zero_offsets = {
|
||||||
|
"thumb_mcp": 0.0,
|
||||||
|
reference_roll: 0.0,
|
||||||
|
reference_pitch: 0.0,
|
||||||
|
reference_pip: 0.0,
|
||||||
|
}
|
||||||
|
return ZeroCalibrationProfile(
|
||||||
|
hand=hand,
|
||||||
|
direct_zero_joints=direct,
|
||||||
|
axis_joints=(
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"thumb_cmc_yaw",
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"thumb_mcp",
|
||||||
|
"thumb_ip",
|
||||||
|
reference_roll,
|
||||||
|
reference_pitch,
|
||||||
|
reference_pip,
|
||||||
|
reference_dip,
|
||||||
|
),
|
||||||
|
inherited_zero_joints=inherited,
|
||||||
|
inherited_static_zero_joints={},
|
||||||
|
constrained_circle_joints=(
|
||||||
|
hand.image_trajectory_joints | {reference_dip, "thumb_cmc_yaw"}
|
||||||
|
),
|
||||||
|
root_anchor_joints=frozenset({"thumb_cmc_roll", reference_roll}),
|
||||||
|
axis_parent_joint=axis_parent,
|
||||||
|
phase_parent_joint=phase_parent,
|
||||||
|
offset_observer_joint=observer,
|
||||||
|
same_view_axis_pair_by_offset={},
|
||||||
|
fixed_direct_zero_offsets_rad=fixed_direct_zero_offsets,
|
||||||
|
static_output_zero_offsets_rad={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_right_19_zero_profile(
|
||||||
|
hand: HandCalibrationProfile,
|
||||||
|
) -> ZeroCalibrationProfile:
|
||||||
|
"""Return the 16-active-zero graph for the 19-Tag layout.
|
||||||
|
|
||||||
|
Distal Tags recover all four PIP/DIP dynamic curves and axis quality, but
|
||||||
|
repeated motion with one unchanged camera/Tag installation cannot by
|
||||||
|
itself distinguish a fixed parallel-axis phase bias from an encoder zero.
|
||||||
|
The three coupled thumb-CMC offsets must therefore remain a visual
|
||||||
|
multi-axis solve; their electrical endpoints are not assumed to coincide
|
||||||
|
with source-CAD limits. Independently repeatable contact endpoints still
|
||||||
|
anchor thumb MCP, finger MCP-pitch and PIP from each session. DIP stays
|
||||||
|
passive and has no independently identifiable static zero.
|
||||||
|
"""
|
||||||
|
fingers = ("index", "middle", "ring", "pinky")
|
||||||
|
direct = (
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"thumb_cmc_yaw",
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"thumb_mcp",
|
||||||
|
*(
|
||||||
|
f"{finger}_{suffix}"
|
||||||
|
for finger in fingers
|
||||||
|
for suffix in ("mcp_roll", "mcp_pitch", "pip")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
axis_joints = (
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"thumb_cmc_yaw",
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"thumb_mcp",
|
||||||
|
"thumb_ip",
|
||||||
|
*(
|
||||||
|
f"{finger}_{suffix}"
|
||||||
|
for finger in fingers
|
||||||
|
for suffix in ("mcp_roll", "mcp_pitch", "pip", "dip")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
axis_parent = {
|
||||||
|
"thumb_cmc_yaw": "thumb_cmc_roll",
|
||||||
|
"thumb_cmc_pitch": "thumb_cmc_yaw",
|
||||||
|
**{
|
||||||
|
f"{finger}_mcp_pitch": f"{finger}_mcp_roll"
|
||||||
|
for finger in fingers
|
||||||
|
},
|
||||||
|
}
|
||||||
|
phase_parent = {
|
||||||
|
"thumb_mcp": "thumb_cmc_pitch",
|
||||||
|
"thumb_ip": "thumb_mcp",
|
||||||
|
**{
|
||||||
|
f"{finger}_{child}": f"{finger}_{parent}"
|
||||||
|
for finger in fingers
|
||||||
|
for child, parent in (
|
||||||
|
("pip", "mcp_pitch"),
|
||||||
|
("dip", "pip"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
observer = {
|
||||||
|
"thumb_cmc_roll": "thumb_cmc_yaw",
|
||||||
|
"thumb_cmc_yaw": "thumb_cmc_pitch",
|
||||||
|
"thumb_cmc_pitch": "thumb_mcp",
|
||||||
|
"thumb_mcp": "thumb_ip",
|
||||||
|
**{
|
||||||
|
f"{finger}_{target}": f"{finger}_{observed}"
|
||||||
|
for finger in fingers
|
||||||
|
for target, observed in (
|
||||||
|
("mcp_roll", "mcp_pitch"),
|
||||||
|
("mcp_pitch", "pip"),
|
||||||
|
("pip", "dip"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
root_anchors = frozenset(
|
||||||
|
{"thumb_cmc_roll", *(f"{finger}_mcp_roll" for finger in fingers)}
|
||||||
|
)
|
||||||
|
constrained = frozenset(
|
||||||
|
set(hand.image_trajectory_joints)
|
||||||
|
| {"thumb_cmc_yaw"}
|
||||||
|
# The side camera observes all four flexion axes nearly end-on. A
|
||||||
|
# 16 mm monocular planar Tag gives a very repeatable orientation axis,
|
||||||
|
# while its command-correlated depth bias can tilt the otherwise clean
|
||||||
|
# centre-trajectory plane by several degrees. Use the orientation
|
||||||
|
# axis to constrain that circle; PIP retains its independently fitted
|
||||||
|
# axis line and all final SE(3)/holdout quality gates.
|
||||||
|
| set(RIGHT_19_END_ON_IMAGE_CURVE_JOINTS)
|
||||||
|
# _fit_axis_measurement_raw constrains every passive DIP direction to
|
||||||
|
# its source-URDF-parallel upstream PIP. Declare the same fact here
|
||||||
|
# so validation does not compare that trusted direction with the
|
||||||
|
# depth-biased free plane of a distal Tag trajectory.
|
||||||
|
| set(RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS)
|
||||||
|
)
|
||||||
|
return ZeroCalibrationProfile(
|
||||||
|
hand=hand,
|
||||||
|
direct_zero_joints=tuple(direct),
|
||||||
|
axis_joints=tuple(axis_joints),
|
||||||
|
inherited_zero_joints={},
|
||||||
|
inherited_static_zero_joints={},
|
||||||
|
constrained_circle_joints=constrained,
|
||||||
|
root_anchor_joints=root_anchors,
|
||||||
|
axis_parent_joint=axis_parent,
|
||||||
|
phase_parent_joint=phase_parent,
|
||||||
|
offset_observer_joint=observer,
|
||||||
|
same_view_axis_pair_by_offset={
|
||||||
|
"thumb_cmc_yaw": ("thumb_cmc_roll", "thumb_cmc_pitch")
|
||||||
|
},
|
||||||
|
# Endpoint-observable offsets are supplied by the caller. CMC remains
|
||||||
|
# visually solved and no serial-specific zero is hidden in the shared
|
||||||
|
# profile.
|
||||||
|
fixed_direct_zero_offsets_rad={},
|
||||||
|
static_output_zero_offsets_rad={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_right_19_thumb_zero_profile() -> ZeroCalibrationProfile:
|
||||||
|
"""Return the independent four-zero thumb observer graph.
|
||||||
|
|
||||||
|
Roll and MCP are supplied as mechanical endpoint datums by the caller;
|
||||||
|
yaw is observed by the same-view roll/pitch axis pair and pitch by the
|
||||||
|
pitch/MCP serial phase. No finger axis is part of this profile.
|
||||||
|
"""
|
||||||
|
full = get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT)
|
||||||
|
thumb_direct = tuple(
|
||||||
|
name for name in full.direct_zero_joints if name.startswith("thumb_")
|
||||||
|
)
|
||||||
|
thumb_axes = tuple(
|
||||||
|
name for name in full.axis_joints if name.startswith("thumb_")
|
||||||
|
)
|
||||||
|
return replace(
|
||||||
|
full,
|
||||||
|
direct_zero_joints=thumb_direct,
|
||||||
|
axis_joints=thumb_axes,
|
||||||
|
constrained_circle_joints=frozenset(
|
||||||
|
name
|
||||||
|
for name in full.constrained_circle_joints
|
||||||
|
if name.startswith("thumb_")
|
||||||
|
),
|
||||||
|
root_anchor_joints=frozenset({"thumb_cmc_roll"}),
|
||||||
|
axis_parent_joint={
|
||||||
|
child: parent
|
||||||
|
for child, parent in full.axis_parent_joint.items()
|
||||||
|
if child.startswith("thumb_") and parent.startswith("thumb_")
|
||||||
|
},
|
||||||
|
phase_parent_joint={
|
||||||
|
child: parent
|
||||||
|
for child, parent in full.phase_parent_joint.items()
|
||||||
|
if child.startswith("thumb_") and parent.startswith("thumb_")
|
||||||
|
},
|
||||||
|
offset_observer_joint={
|
||||||
|
target: observer
|
||||||
|
for target, observer in full.offset_observer_joint.items()
|
||||||
|
if target.startswith("thumb_") and observer.startswith("thumb_")
|
||||||
|
},
|
||||||
|
same_view_axis_pair_by_offset={
|
||||||
|
target: pair
|
||||||
|
for target, pair in full.same_view_axis_pair_by_offset.items()
|
||||||
|
if target.startswith("thumb_")
|
||||||
|
},
|
||||||
|
fixed_direct_zero_offsets_rad={},
|
||||||
|
static_output_zero_offsets_rad={},
|
||||||
|
base_pose_strategy="thumb_serial",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS = frozenset(
|
||||||
|
{
|
||||||
|
"thumb_mcp",
|
||||||
|
*(
|
||||||
|
f"{finger}_{suffix}"
|
||||||
|
for finger in ("index", "middle", "ring", "pinky")
|
||||||
|
for suffix in ("mcp_pitch", "pip")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Thumb CMC roll has a directly observed actuator-to-actuator travel, but its
|
||||||
|
# electrical stop is not used as a runtime URDF limit. Keep it out of
|
||||||
|
# RIGHT_19_MECHANICAL_ENDPOINT_JOINTS (whose offsets also move upper limits),
|
||||||
|
# and use its endpoint estimate only as the final static roll origin. This
|
||||||
|
# makes roll independent of the fitted palm-frame phase and of all yaw logic.
|
||||||
|
RIGHT_19_ROLL_ENDPOINT_ZERO_JOINTS = frozenset({"thumb_cmc_roll"})
|
||||||
|
RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS = (
|
||||||
|
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
|
||||||
|
| RIGHT_19_ROLL_ENDPOINT_ZERO_JOINTS
|
||||||
|
)
|
||||||
|
RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS = RIGHT_19_ROLL_ENDPOINT_ZERO_JOINTS
|
||||||
|
|
||||||
|
|
||||||
|
def anchor_right_19_mechanical_endpoint_curves(
|
||||||
|
curves: Mapping[str, JointCurveFit],
|
||||||
|
records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
|
||||||
|
*,
|
||||||
|
maximum_direction_difference_rad: float = math.radians(1.0),
|
||||||
|
maximum_curve_correction_rad: float = math.radians(3.0),
|
||||||
|
feedback_endpoint_joints: frozenset[str] = frozenset(),
|
||||||
|
endpoint_joints: frozenset[str] | None = None,
|
||||||
|
) -> dict[str, JointCurveFit]:
|
||||||
|
"""Set mechanical-endpoint curve scale from direct SO(3) travel.
|
||||||
|
|
||||||
|
A fitted-axis projection is useful for a dense, signed command curve, but
|
||||||
|
a small view-dependent orthogonal PnP component can change that
|
||||||
|
projection's full-scale value. The rotation magnitude between the two
|
||||||
|
settled endpoint poses is the revolute-joint travel itself: conjugating
|
||||||
|
both poses by a rigid hand/camera transform or either fixed Tag mounting
|
||||||
|
rotation cannot change it.
|
||||||
|
|
||||||
|
Use every supplied cycle and both sweep directions as independent endpoint
|
||||||
|
measurements, reject disagreement, and apply their robust median as one
|
||||||
|
scale correction to the already validated curve. Dense CMC-roll records
|
||||||
|
are selected in feedback coordinates; settled mechanical-contact records
|
||||||
|
remain selected in requested-command coordinates.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
not math.isfinite(maximum_direction_difference_rad)
|
||||||
|
or maximum_direction_difference_rad <= 0.0
|
||||||
|
):
|
||||||
|
raise ValueError("endpoint direction difference limit must be positive")
|
||||||
|
if (
|
||||||
|
not math.isfinite(maximum_curve_correction_rad)
|
||||||
|
or maximum_curve_correction_rad <= 0.0
|
||||||
|
):
|
||||||
|
raise ValueError("endpoint curve correction limit must be positive")
|
||||||
|
|
||||||
|
result = dict(curves)
|
||||||
|
selected_endpoint_joints = (
|
||||||
|
RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS
|
||||||
|
if endpoint_joints is None
|
||||||
|
else frozenset(str(name) for name in endpoint_joints)
|
||||||
|
)
|
||||||
|
unknown_endpoint_joints = sorted(
|
||||||
|
set(selected_endpoint_joints) - set(RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS)
|
||||||
|
)
|
||||||
|
if unknown_endpoint_joints:
|
||||||
|
raise ValueError(
|
||||||
|
"endpoint joints are not endpoint measurements: "
|
||||||
|
+ ",".join(unknown_endpoint_joints)
|
||||||
|
)
|
||||||
|
unknown_feedback_joints = sorted(
|
||||||
|
set(feedback_endpoint_joints) - set(RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS)
|
||||||
|
)
|
||||||
|
if unknown_feedback_joints:
|
||||||
|
raise ValueError(
|
||||||
|
"feedback endpoint joints are not endpoint measurements: "
|
||||||
|
+ ",".join(unknown_feedback_joints)
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in sorted(selected_endpoint_joints):
|
||||||
|
fit = curves.get(name)
|
||||||
|
records = list(records_by_joint.get(name, ()))
|
||||||
|
if fit is None:
|
||||||
|
raise ValueError(f"missing measured endpoint curve for {name}")
|
||||||
|
|
||||||
|
direction_travel: dict[str, float] = {}
|
||||||
|
all_travel: list[float] = []
|
||||||
|
for direction in ("decreasing", "increasing"):
|
||||||
|
cycles = sorted(
|
||||||
|
{
|
||||||
|
int(record.get("cycle", 0))
|
||||||
|
for record in records
|
||||||
|
if str(record.get("direction")) == direction
|
||||||
|
}
|
||||||
|
)
|
||||||
|
branch_travel: list[float] = []
|
||||||
|
for cycle in cycles:
|
||||||
|
endpoint_rotations: dict[int, Rotation] = {}
|
||||||
|
for command in (0, 255):
|
||||||
|
quaternions = []
|
||||||
|
for record in records:
|
||||||
|
if (
|
||||||
|
str(record.get("direction")) != direction
|
||||||
|
or int(record.get("cycle", 0)) != cycle
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
endpoint_value = (
|
||||||
|
explicit_domain_value(record, "feedback")
|
||||||
|
if name in feedback_endpoint_joints
|
||||||
|
else record.get(
|
||||||
|
"requested_command_u8",
|
||||||
|
record.get("command_u8", -1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if endpoint_value is None:
|
||||||
|
continue
|
||||||
|
if int(round(float(endpoint_value))) == command:
|
||||||
|
quaternions.append(_relative_rotation(record))
|
||||||
|
if not quaternions:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} {direction} cycle {cycle} is missing "
|
||||||
|
f"settled endpoint {command}"
|
||||||
|
)
|
||||||
|
endpoint_rotations[command] = Rotation.from_quat(
|
||||||
|
robust_rotation_summary(quaternions)[0]
|
||||||
|
)
|
||||||
|
branch_travel.append(
|
||||||
|
float(
|
||||||
|
(
|
||||||
|
endpoint_rotations[255].inv()
|
||||||
|
* endpoint_rotations[0]
|
||||||
|
).magnitude()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not branch_travel:
|
||||||
|
raise ValueError(f"{name} is missing {direction} endpoint travel")
|
||||||
|
direction_travel[direction] = float(np.median(branch_travel))
|
||||||
|
all_travel.extend(branch_travel)
|
||||||
|
|
||||||
|
travel_values = np.asarray(list(direction_travel.values()), dtype=float)
|
||||||
|
direction_difference = float(np.ptp(travel_values))
|
||||||
|
if direction_difference > maximum_direction_difference_rad:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} settled endpoint directions disagree: "
|
||||||
|
f"{math.degrees(direction_difference):.3f}deg"
|
||||||
|
)
|
||||||
|
cycle_range = float(np.ptp(np.asarray(all_travel, dtype=float)))
|
||||||
|
if cycle_range > maximum_direction_difference_rad:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} settled endpoint cycles disagree: "
|
||||||
|
f"{math.degrees(cycle_range):.3f}deg"
|
||||||
|
)
|
||||||
|
direct_travel = float(np.median(np.asarray(all_travel, dtype=float)))
|
||||||
|
curve = np.asarray(fit.angle_rad, dtype=float)
|
||||||
|
if curve.shape != (256,) or not np.all(np.isfinite(curve)):
|
||||||
|
raise ValueError(f"{name} endpoint curve must contain 256 finite bins")
|
||||||
|
projected_travel = float(curve[0] - curve[255])
|
||||||
|
if direct_travel <= 0.0 or projected_travel <= 0.0:
|
||||||
|
raise ValueError(f"{name} endpoint travel must be positive")
|
||||||
|
correction = abs(projected_travel - direct_travel)
|
||||||
|
if correction > maximum_curve_correction_rad:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} fitted/direct endpoint travel differs by "
|
||||||
|
f"{math.degrees(correction):.3f}deg"
|
||||||
|
)
|
||||||
|
scale = direct_travel / projected_travel
|
||||||
|
|
||||||
|
def scaled(values: Sequence[float]) -> tuple[float, ...]:
|
||||||
|
return tuple(float(value) * scale for value in values)
|
||||||
|
|
||||||
|
circle = dict(fit.circle)
|
||||||
|
circle.update(
|
||||||
|
{
|
||||||
|
"mechanical_endpoint_direct_travel_rad": direct_travel,
|
||||||
|
"mechanical_endpoint_direction_difference_rad": (
|
||||||
|
direction_difference
|
||||||
|
),
|
||||||
|
"mechanical_endpoint_cycle_range_rad": cycle_range,
|
||||||
|
"mechanical_endpoint_sample_count": len(all_travel),
|
||||||
|
"mechanical_endpoint_raw_curve_travel_rad": projected_travel,
|
||||||
|
"mechanical_endpoint_curve_scale": scale,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
quality = dict(fit.quality)
|
||||||
|
if "arc_rad" in quality:
|
||||||
|
quality["arc_rad"] = float(quality["arc_rad"]) * scale
|
||||||
|
result[name] = replace(
|
||||||
|
fit,
|
||||||
|
angle_rad=scaled(fit.angle_rad),
|
||||||
|
decreasing_rad=scaled(fit.decreasing_rad),
|
||||||
|
increasing_rad=scaled(fit.increasing_rad),
|
||||||
|
circle=circle,
|
||||||
|
maximum_monotonic_correction_rad=(
|
||||||
|
float(fit.maximum_monotonic_correction_rad) * scale
|
||||||
|
),
|
||||||
|
maximum_hysteresis_rad=float(fit.maximum_hysteresis_rad) * scale,
|
||||||
|
quality=quality,
|
||||||
|
zero_offset_rad=float(fit.zero_offset_rad) * scale,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def derive_right_19_mechanical_endpoint_offsets(
|
||||||
|
source_urdf: str | Path,
|
||||||
|
curves: Mapping[str, JointCurveFit],
|
||||||
|
*,
|
||||||
|
maximum_offset_rad: float = math.radians(5.0),
|
||||||
|
endpoint_joints: frozenset[str] | None = None,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
"""Estimate encoder origins from repeatable measured mechanical endpoints.
|
||||||
|
|
||||||
|
For the four fingers feedback 0 is the independently verified palm-contact
|
||||||
|
endpoint; thumb MCP uses its independently verified actuator endpoint.
|
||||||
|
The source URDF upper limit describes those same physical endpoints.
|
||||||
|
Therefore ``origin_offset + measured_travel == CAD_upper``.
|
||||||
|
|
||||||
|
Thumb CMC pitch/yaw remain excluded because their endpoint-to-CAD contract
|
||||||
|
is not independently established. CMC roll is included as a post-solve
|
||||||
|
origin only: its measured travel determines roll without allowing the
|
||||||
|
palm common-direction fit to write that joint.
|
||||||
|
|
||||||
|
The returned corrections are measured, generally non-zero encoder zeros;
|
||||||
|
they do not retain the CAD origin. Using full relative rotation travel
|
||||||
|
makes them invariant to rigid hand movement and arbitrary fixed Tag
|
||||||
|
mounting rotation, unlike a cross-camera absolute planar-PnP phase.
|
||||||
|
"""
|
||||||
|
if not math.isfinite(maximum_offset_rad) or maximum_offset_rad <= 0.0:
|
||||||
|
raise ValueError("mechanical endpoint maximum offset must be positive")
|
||||||
|
root = ET.parse(Path(source_urdf).expanduser().resolve()).getroot()
|
||||||
|
joints = {str(node.get("name")): node for node in root.findall("joint")}
|
||||||
|
result: dict[str, float] = {}
|
||||||
|
selected_endpoint_joints = (
|
||||||
|
RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS
|
||||||
|
if endpoint_joints is None
|
||||||
|
else frozenset(str(name) for name in endpoint_joints)
|
||||||
|
)
|
||||||
|
unknown_endpoint_joints = sorted(
|
||||||
|
set(selected_endpoint_joints) - set(RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS)
|
||||||
|
)
|
||||||
|
if unknown_endpoint_joints:
|
||||||
|
raise ValueError(
|
||||||
|
"endpoint joints are not endpoint measurements: "
|
||||||
|
+ ",".join(unknown_endpoint_joints)
|
||||||
|
)
|
||||||
|
for name in sorted(selected_endpoint_joints):
|
||||||
|
fit = curves.get(name)
|
||||||
|
joint = joints.get(name)
|
||||||
|
limit = None if joint is None else joint.find("limit")
|
||||||
|
if fit is None:
|
||||||
|
raise ValueError(f"missing measured endpoint curve for {name}")
|
||||||
|
if limit is None or limit.get("upper") is None:
|
||||||
|
raise ValueError(f"source URDF joint {name} has no upper limit")
|
||||||
|
upper = float(limit.get("upper"))
|
||||||
|
curve = np.asarray(fit.angle_rad, dtype=float)
|
||||||
|
if curve.shape != (256,) or not np.all(np.isfinite(curve)):
|
||||||
|
raise ValueError(f"{name} endpoint curve must contain 256 finite bins")
|
||||||
|
measured_endpoint = float(curve[0])
|
||||||
|
if measured_endpoint <= 0.0 or abs(float(curve[255])) > math.radians(0.05):
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} endpoint curve does not use command 255 as its zero reference"
|
||||||
|
)
|
||||||
|
if abs(measured_endpoint - float(np.max(curve))) > math.radians(0.05):
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} command 0 is not the measured upper mechanical endpoint"
|
||||||
|
)
|
||||||
|
offset = upper - measured_endpoint
|
||||||
|
if not math.isfinite(offset) or abs(offset) > maximum_offset_rad:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} endpoint-derived zero offset is outside the safe range: "
|
||||||
|
f"{math.degrees(offset):.3f}deg"
|
||||||
|
)
|
||||||
|
result[name] = offset
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_zero_calibration_profile(
|
||||||
|
side: str, layout_id: str = "legacy_11"
|
||||||
|
) -> ZeroCalibrationProfile:
|
||||||
|
return _build_zero_profile(get_hand_calibration_profile(side, layout_id))
|
||||||
|
|
||||||
|
|
||||||
|
LEFT_ZERO_PROFILE = _build_zero_profile(LEFT_HAND_PROFILE)
|
||||||
|
|
||||||
|
# Backwards-compatible left-hand aliases.
|
||||||
|
CONSTRAINED_CIRCLE_JOINTS = LEFT_ZERO_PROFILE.constrained_circle_joints
|
||||||
|
DIRECT_ZERO_JOINTS = LEFT_ZERO_PROFILE.direct_zero_joints
|
||||||
|
FIXED_DIRECT_ZERO_OFFSETS_RAD = dict(
|
||||||
|
LEFT_ZERO_PROFILE.fixed_direct_zero_offsets_rad
|
||||||
|
)
|
||||||
|
OPTIMIZED_ZERO_JOINTS = tuple(
|
||||||
|
name for name in DIRECT_ZERO_JOINTS
|
||||||
|
if name not in FIXED_DIRECT_ZERO_OFFSETS_RAD
|
||||||
|
)
|
||||||
|
AXIS_JOINTS = LEFT_ZERO_PROFILE.axis_joints
|
||||||
|
INHERITED_ZERO_JOINTS = dict(LEFT_ZERO_PROFILE.inherited_zero_joints)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def merge_right_19_thumb_zero_result(
|
||||||
|
*,
|
||||||
|
thumb_result: ZeroSolveResult,
|
||||||
|
preserved_offsets_rad: Mapping[str, float] | None = None,
|
||||||
|
companion_result: ZeroSolveResult | None = None,
|
||||||
|
) -> ZeroSolveResult:
|
||||||
|
"""Merge an independent thumb solve without allowing reverse coupling.
|
||||||
|
|
||||||
|
``companion_result`` is the finger/full solver result whose thumb values
|
||||||
|
were fixed to ``thumb_result``. For a thumb-only run, the 12 certified
|
||||||
|
finger values are supplied through ``preserved_offsets_rad`` instead.
|
||||||
|
"""
|
||||||
|
thumb_names = {
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"thumb_cmc_yaw",
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"thumb_mcp",
|
||||||
|
}
|
||||||
|
if set(thumb_result.direct_offsets_rad) != thumb_names:
|
||||||
|
raise ValueError("independent thumb result must contain exactly four zeros")
|
||||||
|
full_profile = get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT)
|
||||||
|
direct_names = set(full_profile.direct_zero_joints)
|
||||||
|
preserved = {
|
||||||
|
str(name): float(value)
|
||||||
|
for name, value in (preserved_offsets_rad or {}).items()
|
||||||
|
}
|
||||||
|
if set(preserved) - (direct_names - thumb_names):
|
||||||
|
raise ValueError("preserved offsets contain a thumb or unknown joint")
|
||||||
|
if any(not math.isfinite(value) for value in preserved.values()):
|
||||||
|
raise ValueError("preserved offsets must be finite")
|
||||||
|
|
||||||
|
if companion_result is None:
|
||||||
|
if set(preserved) != direct_names - thumb_names:
|
||||||
|
raise ValueError("thumb-only merge requires all 12 certified finger zeros")
|
||||||
|
direct = dict(preserved)
|
||||||
|
all_active = {name: 0.0 for name in full_profile.hand.active_joints}
|
||||||
|
all_active.update(preserved)
|
||||||
|
base_result = thumb_result
|
||||||
|
else:
|
||||||
|
if set(companion_result.direct_offsets_rad) != direct_names:
|
||||||
|
raise ValueError("full companion result must contain all 16 zeros")
|
||||||
|
direct = dict(companion_result.direct_offsets_rad)
|
||||||
|
all_active = dict(companion_result.all_active_offsets_rad)
|
||||||
|
base_result = companion_result
|
||||||
|
direct.update(thumb_result.direct_offsets_rad)
|
||||||
|
all_active.update(thumb_result.direct_offsets_rad)
|
||||||
|
|
||||||
|
def merged_map(name: str) -> dict[str, Any]:
|
||||||
|
values = (
|
||||||
|
{}
|
||||||
|
if companion_result is None
|
||||||
|
else dict(getattr(companion_result, name))
|
||||||
|
)
|
||||||
|
values.update(dict(getattr(thumb_result, name)))
|
||||||
|
return values
|
||||||
|
|
||||||
|
failure_reasons = merged_map("failure_reasons")
|
||||||
|
passed = bool(
|
||||||
|
thumb_result.passed
|
||||||
|
and (companion_result is None or companion_result.passed)
|
||||||
|
)
|
||||||
|
return replace(
|
||||||
|
base_result,
|
||||||
|
direct_offsets_rad=direct,
|
||||||
|
all_active_offsets_rad=all_active,
|
||||||
|
validation_errors_rad=(
|
||||||
|
tuple(thumb_result.validation_errors_rad)
|
||||||
|
if companion_result is None
|
||||||
|
else (
|
||||||
|
*companion_result.validation_errors_rad,
|
||||||
|
*thumb_result.validation_errors_rad,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
validation_error_by_joint_rad=merged_map(
|
||||||
|
"validation_error_by_joint_rad"
|
||||||
|
),
|
||||||
|
validation_line_error_by_joint_m=merged_map(
|
||||||
|
"validation_line_error_by_joint_m"
|
||||||
|
),
|
||||||
|
axis_line_rms_m=(
|
||||||
|
thumb_result.axis_line_rms_m
|
||||||
|
if companion_result is None
|
||||||
|
else max(
|
||||||
|
companion_result.axis_line_rms_m,
|
||||||
|
thumb_result.axis_line_rms_m,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
passed=passed,
|
||||||
|
cycle_offsets_rad=merged_map("cycle_offsets_rad"),
|
||||||
|
offset_uncertainty_rad=merged_map("offset_uncertainty_rad"),
|
||||||
|
offset_confidence_half_width_rad=merged_map(
|
||||||
|
"offset_confidence_half_width_rad"
|
||||||
|
),
|
||||||
|
validation_original_error_by_joint_rad=merged_map(
|
||||||
|
"validation_original_error_by_joint_rad"
|
||||||
|
),
|
||||||
|
validation_improvement_by_joint_rad=merged_map(
|
||||||
|
"validation_improvement_by_joint_rad"
|
||||||
|
),
|
||||||
|
validation_improvement_confidence_lower_rad=merged_map(
|
||||||
|
"validation_improvement_confidence_lower_rad"
|
||||||
|
),
|
||||||
|
offset_covariance_rad2=merged_map("offset_covariance_rad2"),
|
||||||
|
axis_cone_mismatch_by_joint_rad=merged_map(
|
||||||
|
"axis_cone_mismatch_by_joint_rad"
|
||||||
|
),
|
||||||
|
axis_cone_bias_classification_by_joint=merged_map(
|
||||||
|
"axis_cone_bias_classification_by_joint"
|
||||||
|
),
|
||||||
|
failure_reasons=failure_reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def expand_right_19_thumb_zero_result_with_cad_fingers(
|
||||||
|
thumb_result: ZeroSolveResult,
|
||||||
|
) -> ZeroSolveResult:
|
||||||
|
"""Build a standalone thumb result with every finger left at source CAD.
|
||||||
|
|
||||||
|
This is intentionally different from inheriting a prior finger
|
||||||
|
calibration: the twelve non-thumb values are explicit zeros and no finger
|
||||||
|
observation or artifact is required.
|
||||||
|
"""
|
||||||
|
full_profile = get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT)
|
||||||
|
thumb_names = {
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"thumb_cmc_yaw",
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"thumb_mcp",
|
||||||
|
}
|
||||||
|
if set(thumb_result.direct_offsets_rad) != thumb_names:
|
||||||
|
raise ValueError("independent thumb result must contain exactly four zeros")
|
||||||
|
direct = {name: 0.0 for name in full_profile.direct_zero_joints}
|
||||||
|
direct.update(thumb_result.direct_offsets_rad)
|
||||||
|
all_active = {name: 0.0 for name in full_profile.hand.active_joints}
|
||||||
|
all_active.update(thumb_result.direct_offsets_rad)
|
||||||
|
return replace(
|
||||||
|
thumb_result,
|
||||||
|
direct_offsets_rad=direct,
|
||||||
|
all_active_offsets_rad=all_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def write_zero_corrected_urdf(
|
||||||
|
*,
|
||||||
|
source_urdf: str | Path,
|
||||||
|
output_directory: str | Path,
|
||||||
|
serial_number: str,
|
||||||
|
offsets_rad: Mapping[str, float],
|
||||||
|
endpoint_anchored_offsets_rad: Mapping[str, float] | None = None,
|
||||||
|
timestamp: str | None = None,
|
||||||
|
correction_plan: UrdfCorrectionPlan | None = None,
|
||||||
|
) -> Path:
|
||||||
|
source = Path(source_urdf).expanduser().resolve()
|
||||||
|
output = Path(output_directory).expanduser().resolve()
|
||||||
|
if not source.is_file():
|
||||||
|
raise ValueError(f"source URDF does not exist: {source}")
|
||||||
|
if not offsets_rad:
|
||||||
|
raise ValueError("offsets_rad must contain at least one joint")
|
||||||
|
offsets = {str(name): float(value) for name, value in offsets_rad.items()}
|
||||||
|
endpoint_offsets = {
|
||||||
|
str(name): float(value)
|
||||||
|
for name, value in dict(endpoint_anchored_offsets_rad or {}).items()
|
||||||
|
}
|
||||||
|
if not set(endpoint_offsets) <= set(offsets):
|
||||||
|
raise ValueError("endpoint-anchored offsets must be URDF zero targets")
|
||||||
|
if correction_plan is not None:
|
||||||
|
correction_plan.verify_source(source)
|
||||||
|
correction_plan.authorize_offsets(offsets)
|
||||||
|
if not set(endpoint_offsets).issubset(
|
||||||
|
correction_plan.endpoint_limit_joints
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"endpoint offsets are not authorized by the correction plan"
|
||||||
|
)
|
||||||
|
if any(
|
||||||
|
not math.isfinite(value) or abs(value) > math.radians(90.0)
|
||||||
|
for value in offsets.values()
|
||||||
|
):
|
||||||
|
raise ValueError("URDF zero offsets must be finite and within +/-90deg")
|
||||||
|
stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
if re.fullmatch(r"\d{8}_\d{6}", stamp) is None:
|
||||||
|
raise ValueError("URDF zero timestamp must use YYYYMMDD_HHMMSS")
|
||||||
|
safe_serial = "".join(
|
||||||
|
character if character.isalnum() or character in "_.-" else "_"
|
||||||
|
for character in str(serial_number)
|
||||||
|
)
|
||||||
|
if not safe_serial:
|
||||||
|
raise ValueError("serial_number must not be empty")
|
||||||
|
from linkerhand_calibration.core.urdf.plan import build_standard_correction_plan
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
if "calibrated" in source.stem.lower():
|
||||||
|
raise ValueError("source must be the original CAD URDF")
|
||||||
|
# The deployed JSON keeps the compatibility shape; zero coordinates must
|
||||||
|
# nevertheless transform both limits and every dependent mimic together.
|
||||||
|
# Endpoint metadata cannot select a different coordinate rule.
|
||||||
|
if any(not math.isclose(value, offsets[name], rel_tol=0, abs_tol=1e-12)
|
||||||
|
for name, value in endpoint_offsets.items()):
|
||||||
|
raise ValueError("endpoint metadata disagrees with the actual zero correction")
|
||||||
|
if correction_plan is not None:
|
||||||
|
rights = correction_plan.authorized_fields
|
||||||
|
if not rights:
|
||||||
|
raise ValueError("production correction plan lacks independent Profile field authorization")
|
||||||
|
source_hash = correction_plan.source_sha256
|
||||||
|
else:
|
||||||
|
# Compatibility for standalone offline correction tools. The online
|
||||||
|
# product path always supplies its protected, scope-authorized plan.
|
||||||
|
model = UrdfKinematicModel(source)
|
||||||
|
rights = {name: ("origin.rpy", "limit.lower", "limit.upper") for name in offsets}
|
||||||
|
for name, joint in model.joints.items():
|
||||||
|
if joint.mimic_joint is not None:
|
||||||
|
rights[name] = (*rights.get(name, ()), "mimic.offset")
|
||||||
|
source_hash = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||||
|
plan = build_standard_correction_plan(source_urdf=source,
|
||||||
|
source_sha256=source_hash, zero_offsets_rad=offsets, authorized_fields=rights)
|
||||||
|
destination = output / f"{source.stem}_zero_calibrated_{safe_serial}_{stamp}.urdf"
|
||||||
|
return plan.write(source, destination)
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
"""Registered L6 calibration profiles."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import ProfileRegistry
|
||||||
|
|
||||||
|
|
||||||
|
def register_profiles(registry: ProfileRegistry) -> None:
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.left_transfer import build_profile as build_left_transfer_profile
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.profile import build_profile
|
||||||
|
|
||||||
|
registry.register(build_profile())
|
||||||
|
registry.register(build_left_transfer_profile())
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["register_profiles"]
|
||||||
+876
@@ -0,0 +1,876 @@
|
|||||||
|
"""Schema-v6 runtime artifact and atomic partial-result publication for L6."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import JointCurveFit
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.fitting import L6FitResult, MimicFit
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.profile import (
|
||||||
|
ACTIVE_JOINTS,
|
||||||
|
CALIBRATED_ACTIVE_JOINTS,
|
||||||
|
COMMAND_INDEX_BY_JOINT,
|
||||||
|
COMMAND_NAMES,
|
||||||
|
COUPLING_MODEL_BY_JOINT,
|
||||||
|
ENDPOINT_ANCHOR_BY_JOINT,
|
||||||
|
KEY,
|
||||||
|
MEASURED_PASSIVE_JOINTS,
|
||||||
|
MIMIC_SOURCE_BY_JOINT,
|
||||||
|
PASSIVE_JOINTS,
|
||||||
|
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
|
||||||
|
TRANSFERRED_PASSIVE_SOURCE_BY_JOINT,
|
||||||
|
build_typed_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ALL_REVOLUTE_JOINTS = frozenset(ACTIVE_JOINTS + PASSIVE_JOINTS)
|
||||||
|
L6_URDF_INPUT_SCHEMA_VERSION = 1
|
||||||
|
L6_URDF_INPUT_ARTIFACT_TYPE = "linkerhand_l6_urdf_correction_input"
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: str | Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with Path(path).open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _source_joint_metadata(
|
||||||
|
source_urdf: str | Path,
|
||||||
|
) -> dict[str, dict[str, float | str]]:
|
||||||
|
root = ET.parse(Path(source_urdf)).getroot()
|
||||||
|
result: dict[str, dict[str, float | str]] = {}
|
||||||
|
for joint in root.findall("joint"):
|
||||||
|
name = str(joint.get("name", ""))
|
||||||
|
if name not in ALL_REVOLUTE_JOINTS:
|
||||||
|
continue
|
||||||
|
if joint.get("type") != "revolute":
|
||||||
|
raise ValueError(f"L6 profile joint is not revolute: {name}")
|
||||||
|
limit = joint.find("limit")
|
||||||
|
if limit is None:
|
||||||
|
raise ValueError(f"L6 source joint has no limit: {name}")
|
||||||
|
item: dict[str, float | str] = {
|
||||||
|
"lower": float(limit.get("lower", "nan")),
|
||||||
|
"upper": float(limit.get("upper", "nan")),
|
||||||
|
}
|
||||||
|
mimic = joint.find("mimic")
|
||||||
|
if mimic is not None:
|
||||||
|
item.update(
|
||||||
|
{
|
||||||
|
"source_joint": str(mimic.get("joint", "")),
|
||||||
|
"multiplier": float(mimic.get("multiplier", "nan")),
|
||||||
|
"offset": float(mimic.get("offset", "0")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result[name] = item
|
||||||
|
if set(result) != ALL_REVOLUTE_JOINTS:
|
||||||
|
raise ValueError("source URDF does not contain exactly 11 L6 revolute joints")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _rounded_curve(values: Sequence[float]) -> list[float]:
|
||||||
|
curve = np.asarray(values, dtype=float)
|
||||||
|
if curve.shape != (256,) or not np.all(np.isfinite(curve)):
|
||||||
|
raise ValueError("runtime curve must contain 256 finite values")
|
||||||
|
return [round(float(value), 8) for value in curve]
|
||||||
|
|
||||||
|
|
||||||
|
def _linear_curve(lower: float, upper: float) -> np.ndarray:
|
||||||
|
# feedback 255 is the open/lower endpoint and feedback 0 is upper/closed.
|
||||||
|
return np.linspace(float(upper), float(lower), 256, dtype=float)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_transfer_topology(
|
||||||
|
source: Mapping[str, Mapping[str, float | str]],
|
||||||
|
) -> None:
|
||||||
|
for transfers in (
|
||||||
|
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
|
||||||
|
TRANSFERRED_PASSIVE_SOURCE_BY_JOINT,
|
||||||
|
):
|
||||||
|
for target, donor in transfers.items():
|
||||||
|
for field in ("lower", "upper"):
|
||||||
|
if abs(
|
||||||
|
float(source[target][field]) - float(source[donor][field])
|
||||||
|
) > 1.0e-8:
|
||||||
|
raise ValueError(
|
||||||
|
f"L6 transfer {target} differs from {donor} {field}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_l6_runtime_payload(
|
||||||
|
*,
|
||||||
|
serial_number: str,
|
||||||
|
source_urdf: str | Path,
|
||||||
|
result: L6FitResult,
|
||||||
|
protected_inputs: Mapping[str, str],
|
||||||
|
passed: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build all eleven L6 curves with explicit measured/frozen provenance."""
|
||||||
|
profile = build_typed_profile()
|
||||||
|
source = _source_joint_metadata(source_urdf)
|
||||||
|
_validate_transfer_topology(source)
|
||||||
|
joints: dict[str, dict[str, Any]] = {}
|
||||||
|
curves: dict[str, np.ndarray] = {}
|
||||||
|
|
||||||
|
for name in ACTIVE_JOINTS:
|
||||||
|
metadata = source[name]
|
||||||
|
motor_index = COMMAND_INDEX_BY_JOINT[name]
|
||||||
|
measurement_source = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(
|
||||||
|
name, name
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
name in CALIBRATED_ACTIVE_JOINTS
|
||||||
|
or name in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT
|
||||||
|
):
|
||||||
|
fit = result.curves[measurement_source]
|
||||||
|
decreasing = np.asarray(fit.decreasing_rad, dtype=float)
|
||||||
|
increasing = np.asarray(fit.increasing_rad, dtype=float)
|
||||||
|
curve = 0.5 * (decreasing + increasing)
|
||||||
|
curve -= float(curve[255])
|
||||||
|
decreasing = decreasing - float(decreasing[255])
|
||||||
|
increasing = increasing - float(increasing[255])
|
||||||
|
zero_method = result.zero_method_by_joint[measurement_source]
|
||||||
|
zero_angles: dict[str, Any] = {
|
||||||
|
"policy": zero_method,
|
||||||
|
"measured_travel_rad": round(
|
||||||
|
float(result.travels_rad[measurement_source]), 8
|
||||||
|
),
|
||||||
|
"urdf_origin_offset_rad": round(
|
||||||
|
float(result.zero_offsets_rad[measurement_source]), 8
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if measurement_source in ENDPOINT_ANCHOR_BY_JOINT:
|
||||||
|
anchor = ENDPOINT_ANCHOR_BY_JOINT[measurement_source]
|
||||||
|
if anchor == "cad_range_center":
|
||||||
|
zero_angles.update(
|
||||||
|
{
|
||||||
|
"source_lower_rad": round(
|
||||||
|
float(metadata["lower"]), 8
|
||||||
|
),
|
||||||
|
"source_upper_rad": round(
|
||||||
|
float(metadata["upper"]), 8
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
endpoint_field = {
|
||||||
|
"lower_at_start": "source_lower_rad",
|
||||||
|
"upper_at_end": "source_upper_rad",
|
||||||
|
"zero_at_start": "source_joint_zero_rad",
|
||||||
|
}[anchor]
|
||||||
|
endpoint_value = {
|
||||||
|
"lower_at_start": metadata["lower"],
|
||||||
|
"upper_at_end": metadata["upper"],
|
||||||
|
"zero_at_start": 0.0,
|
||||||
|
}[anchor]
|
||||||
|
zero_angles[endpoint_field] = round(
|
||||||
|
float(endpoint_value), 8
|
||||||
|
)
|
||||||
|
if measurement_source in result.zero_fallback_reason_by_joint:
|
||||||
|
zero_angles["geometry_fallback_reason"] = (
|
||||||
|
result.zero_fallback_reason_by_joint[measurement_source]
|
||||||
|
)
|
||||||
|
if name in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT:
|
||||||
|
zero_angles.update(
|
||||||
|
{
|
||||||
|
"policy": "transferred_from_pinky",
|
||||||
|
"transferred_from_joint": measurement_source,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
curve = _linear_curve(
|
||||||
|
float(metadata["lower"]), float(metadata["upper"])
|
||||||
|
)
|
||||||
|
decreasing = curve.copy()
|
||||||
|
increasing = curve.copy()
|
||||||
|
zero_angles = {
|
||||||
|
"policy": "cad_nominal",
|
||||||
|
"source_lower_rad": round(float(metadata["lower"]), 8),
|
||||||
|
"source_upper_rad": round(float(metadata["upper"]), 8),
|
||||||
|
}
|
||||||
|
curves[name] = curve
|
||||||
|
joint_payload = {
|
||||||
|
"urdf_joint": name,
|
||||||
|
"sdk_channel": COMMAND_NAMES[motor_index],
|
||||||
|
"motor_index": motor_index,
|
||||||
|
"passive": False,
|
||||||
|
"calibration_status": profile.joint_coverage[name],
|
||||||
|
"zero_command_u8": 255,
|
||||||
|
"zero_angles": zero_angles,
|
||||||
|
"angle_rad": _rounded_curve(curve),
|
||||||
|
"decreasing_rad": _rounded_curve(decreasing),
|
||||||
|
"increasing_rad": _rounded_curve(increasing),
|
||||||
|
}
|
||||||
|
if name in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT:
|
||||||
|
joint_payload["transferred_from_joint"] = measurement_source
|
||||||
|
joints[name] = joint_payload
|
||||||
|
|
||||||
|
for name in PASSIVE_JOINTS:
|
||||||
|
metadata = source[name]
|
||||||
|
source_name = MIMIC_SOURCE_BY_JOINT[name]
|
||||||
|
motor_index = COMMAND_INDEX_BY_JOINT[source_name]
|
||||||
|
measurement_source = TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.get(
|
||||||
|
name, name
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
name in MEASURED_PASSIVE_JOINTS
|
||||||
|
or name in TRANSFERRED_PASSIVE_SOURCE_BY_JOINT
|
||||||
|
):
|
||||||
|
fit = result.curves[measurement_source]
|
||||||
|
decreasing = np.asarray(fit.decreasing_rad, dtype=float)
|
||||||
|
increasing = np.asarray(fit.increasing_rad, dtype=float)
|
||||||
|
curve = 0.5 * (decreasing + increasing)
|
||||||
|
curve -= float(curve[255])
|
||||||
|
decreasing = decreasing - float(decreasing[255])
|
||||||
|
increasing = increasing - float(increasing[255])
|
||||||
|
offset = float(metadata["offset"])
|
||||||
|
curve += offset
|
||||||
|
decreasing += offset
|
||||||
|
increasing += offset
|
||||||
|
coupling = result.mimic_fits[measurement_source]
|
||||||
|
coupling_model = coupling.model
|
||||||
|
coefficients = list(coupling.mujoco_polycoef)
|
||||||
|
coefficients[0] = offset
|
||||||
|
urdf_mimic_multiplier = coupling.urdf_mimic_multiplier
|
||||||
|
urdf_mimic_policy = coupling.urdf_mimic_policy
|
||||||
|
else:
|
||||||
|
multiplier = float(metadata["multiplier"])
|
||||||
|
offset = float(metadata["offset"])
|
||||||
|
curve = offset + multiplier * curves[source_name]
|
||||||
|
decreasing = curve.copy()
|
||||||
|
increasing = curve.copy()
|
||||||
|
coupling_model = "linear_mimic"
|
||||||
|
coefficients = [offset, multiplier, 0.0, 0.0, 0.0, 0.0]
|
||||||
|
urdf_mimic_multiplier = multiplier
|
||||||
|
urdf_mimic_policy = "cad_nominal"
|
||||||
|
joint_payload = {
|
||||||
|
"urdf_joint": name,
|
||||||
|
"sdk_channel": COMMAND_NAMES[motor_index],
|
||||||
|
"motor_index": motor_index,
|
||||||
|
"passive": True,
|
||||||
|
"source_joint": source_name,
|
||||||
|
"mimic_offset_rad": round(float(metadata["offset"]), 8),
|
||||||
|
"coupling_model": coupling_model,
|
||||||
|
"coupling_coefficients": [
|
||||||
|
round(float(value), 10) for value in coefficients
|
||||||
|
],
|
||||||
|
"urdf_mimic_enabled": True,
|
||||||
|
"urdf_mimic_policy": urdf_mimic_policy,
|
||||||
|
"mimic_multiplier": round(float(urdf_mimic_multiplier), 8),
|
||||||
|
"calibration_status": profile.joint_coverage[name],
|
||||||
|
"zero_command_u8": 255,
|
||||||
|
"zero_angles": {"policy": "cad_static"},
|
||||||
|
"angle_rad": _rounded_curve(curve),
|
||||||
|
"decreasing_rad": _rounded_curve(decreasing),
|
||||||
|
"increasing_rad": _rounded_curve(increasing),
|
||||||
|
}
|
||||||
|
joints[name] = joint_payload
|
||||||
|
if name in TRANSFERRED_PASSIVE_SOURCE_BY_JOINT:
|
||||||
|
joint_payload["transferred_from_joint"] = measurement_source
|
||||||
|
|
||||||
|
errors = np.abs(
|
||||||
|
np.concatenate(
|
||||||
|
[np.asarray(values, dtype=float) for values in result.holdout_errors_rad.values()]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"schema_version": 6,
|
||||||
|
"profile_id": KEY.profile_id,
|
||||||
|
"layout_id": KEY.layout,
|
||||||
|
"model": "L6",
|
||||||
|
"side": "right",
|
||||||
|
"serial_number": str(serial_number),
|
||||||
|
"calibration_scope": "partial",
|
||||||
|
"publication_pointer": "latest_partial_passed",
|
||||||
|
"angle_unit": "rad",
|
||||||
|
"command_range": [0, 255],
|
||||||
|
"curve_input_domain": "feedback_u8",
|
||||||
|
"runtime_curve_policy": "direction_aware",
|
||||||
|
"command_names": list(COMMAND_NAMES),
|
||||||
|
"baseline_command_u8": [255] * 6,
|
||||||
|
"protected_inputs": dict(protected_inputs),
|
||||||
|
"joints": joints,
|
||||||
|
"quality": {
|
||||||
|
"passed": bool(passed),
|
||||||
|
"scope": "partial",
|
||||||
|
"validation_mae_rad": round(float(np.mean(errors)), 8),
|
||||||
|
"validation_p95_rad": round(float(np.percentile(errors, 95.0)), 8),
|
||||||
|
"validation_max_rad": round(float(np.max(errors)), 8),
|
||||||
|
"thumb_axis_zero": (
|
||||||
|
None
|
||||||
|
if result.thumb_zero_result is None
|
||||||
|
else {
|
||||||
|
"method": (
|
||||||
|
"hybrid_axis_geometry_cad_range_center"
|
||||||
|
if result.zero_fallback_reason_by_joint
|
||||||
|
else "g20_serial_axis_geometry"
|
||||||
|
),
|
||||||
|
"passed": bool(result.thumb_zero_result.passed),
|
||||||
|
"geometry_fallback_reasons": dict(
|
||||||
|
sorted(result.zero_fallback_reason_by_joint.items())
|
||||||
|
),
|
||||||
|
"axis_line_rms_m": round(
|
||||||
|
float(result.thumb_zero_result.axis_line_rms_m), 10
|
||||||
|
),
|
||||||
|
"offsets_rad": {
|
||||||
|
name: round(float(value), 10)
|
||||||
|
for name, value in sorted(
|
||||||
|
result.thumb_zero_result.direct_offsets_rad.items()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"cycle_offsets_rad": {
|
||||||
|
name: [round(float(value), 10) for value in values]
|
||||||
|
for name, values in sorted(
|
||||||
|
result.thumb_zero_result.cycle_offsets_rad.items()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"validation_error_by_joint_rad": {
|
||||||
|
name: round(float(value), 10)
|
||||||
|
for name, value in sorted(
|
||||||
|
result.thumb_zero_result.validation_error_by_joint_rad.items()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
validate_l6_runtime_payload(payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def build_l6_urdf_input_payload(
|
||||||
|
*,
|
||||||
|
serial_number: str,
|
||||||
|
source_urdf: str | Path,
|
||||||
|
result: L6FitResult,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Serialize the exact, minimal L6 fit consumed by the URDF writer.
|
||||||
|
|
||||||
|
The public schema-v6 artifact deliberately rounds runtime lookup tables.
|
||||||
|
Feeding those rounded values back into the URDF writer would alter
|
||||||
|
established URDF bytes. This correction-input JSON preserves Python's
|
||||||
|
round-trip float representation without changing either public schema or
|
||||||
|
correction mathematics.
|
||||||
|
"""
|
||||||
|
measured = CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS
|
||||||
|
if set(result.curves) != measured:
|
||||||
|
raise ValueError("L6 URDF input has the wrong measured curve set")
|
||||||
|
if set(result.zero_offsets_rad) != CALIBRATED_ACTIVE_JOINTS:
|
||||||
|
raise ValueError("L6 URDF input has the wrong zero-offset set")
|
||||||
|
if set(result.travels_rad) != CALIBRATED_ACTIVE_JOINTS:
|
||||||
|
raise ValueError("L6 URDF input has the wrong travel set")
|
||||||
|
if set(result.mimic_fits) != MEASURED_PASSIVE_JOINTS:
|
||||||
|
raise ValueError("L6 URDF input has the wrong mimic-fit set")
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"schema_version": L6_URDF_INPUT_SCHEMA_VERSION,
|
||||||
|
"artifact_type": L6_URDF_INPUT_ARTIFACT_TYPE,
|
||||||
|
"profile_id": KEY.profile_id,
|
||||||
|
"model": "L6",
|
||||||
|
"side": "right",
|
||||||
|
"serial_number": str(serial_number),
|
||||||
|
"source_urdf_sha256": _sha256_file(source_urdf),
|
||||||
|
"zero_offsets_rad": {
|
||||||
|
name: float(result.zero_offsets_rad[name])
|
||||||
|
for name in sorted(CALIBRATED_ACTIVE_JOINTS)
|
||||||
|
},
|
||||||
|
"travels_rad": {
|
||||||
|
name: float(result.travels_rad[name])
|
||||||
|
for name in sorted(CALIBRATED_ACTIVE_JOINTS)
|
||||||
|
},
|
||||||
|
"measured_curves": {
|
||||||
|
name: {
|
||||||
|
"angle_rad": [float(value) for value in result.curves[name].angle_rad],
|
||||||
|
"decreasing_rad": [
|
||||||
|
float(value) for value in result.curves[name].decreasing_rad
|
||||||
|
],
|
||||||
|
"increasing_rad": [
|
||||||
|
float(value) for value in result.curves[name].increasing_rad
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for name in sorted(measured)
|
||||||
|
},
|
||||||
|
"mimic_fits": {
|
||||||
|
name: {
|
||||||
|
"source_joint": fit.source_joint,
|
||||||
|
"target_joint": fit.target_joint,
|
||||||
|
"model": fit.model,
|
||||||
|
"coefficients": [float(value) for value in fit.coefficients],
|
||||||
|
"urdf_mimic_multiplier": float(fit.urdf_mimic_multiplier),
|
||||||
|
"urdf_mimic_policy": fit.urdf_mimic_policy,
|
||||||
|
}
|
||||||
|
for name in sorted(MEASURED_PASSIVE_JOINTS)
|
||||||
|
for fit in (result.mimic_fits[name],)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
validate_l6_urdf_input_payload(payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def validate_l6_urdf_input_payload(payload: Mapping[str, Any]) -> None:
|
||||||
|
required = {
|
||||||
|
"schema_version", "artifact_type", "profile_id", "model", "side",
|
||||||
|
"serial_number", "source_urdf_sha256", "zero_offsets_rad",
|
||||||
|
"travels_rad", "measured_curves", "mimic_fits",
|
||||||
|
}
|
||||||
|
if set(payload) != required:
|
||||||
|
raise ValueError("L6 URDF correction input has unexpected fields")
|
||||||
|
if (
|
||||||
|
payload.get("schema_version") != L6_URDF_INPUT_SCHEMA_VERSION
|
||||||
|
or payload.get("artifact_type") != L6_URDF_INPUT_ARTIFACT_TYPE
|
||||||
|
or payload.get("profile_id") != KEY.profile_id
|
||||||
|
or payload.get("model") != "L6"
|
||||||
|
or payload.get("side") != "right"
|
||||||
|
):
|
||||||
|
raise ValueError("L6 URDF correction input identity is invalid")
|
||||||
|
source_hash = str(payload.get("source_urdf_sha256", ""))
|
||||||
|
if len(source_hash) != 64 or any(
|
||||||
|
char not in "0123456789abcdef" for char in source_hash
|
||||||
|
):
|
||||||
|
raise ValueError("L6 URDF correction input source hash is invalid")
|
||||||
|
active = set(CALIBRATED_ACTIVE_JOINTS)
|
||||||
|
measured = active | set(MEASURED_PASSIVE_JOINTS)
|
||||||
|
zeros = payload.get("zero_offsets_rad")
|
||||||
|
travels = payload.get("travels_rad")
|
||||||
|
curves = payload.get("measured_curves")
|
||||||
|
mimics = payload.get("mimic_fits")
|
||||||
|
if not isinstance(zeros, Mapping) or set(zeros) != active:
|
||||||
|
raise ValueError("L6 URDF correction input zero offsets are incomplete")
|
||||||
|
if not isinstance(travels, Mapping) or set(travels) != active:
|
||||||
|
raise ValueError("L6 URDF correction input travels are incomplete")
|
||||||
|
if not isinstance(curves, Mapping) or set(curves) != measured:
|
||||||
|
raise ValueError("L6 URDF correction input curves are incomplete")
|
||||||
|
if not isinstance(mimics, Mapping) or set(mimics) != MEASURED_PASSIVE_JOINTS:
|
||||||
|
raise ValueError("L6 URDF correction input mimic fits are incomplete")
|
||||||
|
if any(not math.isfinite(float(value)) for value in zeros.values()):
|
||||||
|
raise ValueError("L6 URDF correction input has a non-finite zero offset")
|
||||||
|
if any(
|
||||||
|
not math.isfinite(float(value)) or float(value) <= 0.0
|
||||||
|
for value in travels.values()
|
||||||
|
):
|
||||||
|
raise ValueError("L6 URDF correction input has an invalid travel")
|
||||||
|
for name, item in curves.items():
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
raise ValueError(f"L6 URDF correction curve is invalid: {name}")
|
||||||
|
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
|
||||||
|
values = np.asarray(item.get(field), dtype=float)
|
||||||
|
if values.shape != (256,) or not np.all(np.isfinite(values)):
|
||||||
|
raise ValueError(
|
||||||
|
f"L6 URDF correction curve is invalid: {name}.{field}"
|
||||||
|
)
|
||||||
|
for name, item in mimics.items():
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
raise ValueError(f"L6 URDF correction mimic fit is invalid: {name}")
|
||||||
|
if (
|
||||||
|
item.get("target_joint") != name
|
||||||
|
or item.get("source_joint") != MIMIC_SOURCE_BY_JOINT[name]
|
||||||
|
or item.get("model") != COUPLING_MODEL_BY_JOINT[name]
|
||||||
|
):
|
||||||
|
raise ValueError(f"L6 URDF correction mimic topology is invalid: {name}")
|
||||||
|
coefficients = np.asarray(item.get("coefficients"), dtype=float)
|
||||||
|
expected_count = 2 if item.get("model") == "quadratic_runtime" else 1
|
||||||
|
if coefficients.shape != (expected_count,) or not np.all(
|
||||||
|
np.isfinite(coefficients)
|
||||||
|
):
|
||||||
|
raise ValueError(f"L6 URDF correction coefficients are invalid: {name}")
|
||||||
|
multiplier = float(item.get("urdf_mimic_multiplier", "nan"))
|
||||||
|
if not math.isfinite(multiplier) or multiplier <= 0.0:
|
||||||
|
raise ValueError(f"L6 URDF correction multiplier is invalid: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_l6_urdf_input(
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
source_urdf: str | Path,
|
||||||
|
serial_number: str,
|
||||||
|
) -> L6FitResult:
|
||||||
|
"""Load and authenticate the exact L6 fit used to materialize a URDF."""
|
||||||
|
source = Path(path).expanduser().resolve()
|
||||||
|
try:
|
||||||
|
payload = json.loads(source.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise ValueError(f"could not read L6 URDF correction input: {source}") from error
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
raise ValueError("L6 URDF correction input must be a JSON object")
|
||||||
|
validate_l6_urdf_input_payload(payload)
|
||||||
|
if str(payload["serial_number"]) != str(serial_number):
|
||||||
|
raise ValueError("L6 URDF correction input serial number differs")
|
||||||
|
if str(payload["source_urdf_sha256"]) != _sha256_file(source_urdf):
|
||||||
|
raise ValueError("L6 source URDF changed after calibration JSON was written")
|
||||||
|
curves = {
|
||||||
|
str(name): JointCurveFit(
|
||||||
|
angle_rad=tuple(float(value) for value in item["angle_rad"]),
|
||||||
|
decreasing_rad=tuple(
|
||||||
|
float(value) for value in item["decreasing_rad"]
|
||||||
|
),
|
||||||
|
increasing_rad=tuple(
|
||||||
|
float(value) for value in item["increasing_rad"]
|
||||||
|
),
|
||||||
|
circle={},
|
||||||
|
maximum_monotonic_correction_rad=0.0,
|
||||||
|
maximum_hysteresis_rad=0.0,
|
||||||
|
quality={},
|
||||||
|
)
|
||||||
|
for name, item in payload["measured_curves"].items()
|
||||||
|
}
|
||||||
|
mimic_fits = {
|
||||||
|
str(name): MimicFit(
|
||||||
|
source_joint=str(item["source_joint"]),
|
||||||
|
target_joint=str(item["target_joint"]),
|
||||||
|
model=str(item["model"]),
|
||||||
|
coefficients=tuple(float(value) for value in item["coefficients"]),
|
||||||
|
urdf_mimic_multiplier=float(item["urdf_mimic_multiplier"]),
|
||||||
|
urdf_mimic_policy=str(item["urdf_mimic_policy"]),
|
||||||
|
cycle_coefficients=(),
|
||||||
|
maximum_cycle_range=0.0,
|
||||||
|
maximum_cycle_prediction_range_rad=0.0,
|
||||||
|
residual_rms_rad=0.0,
|
||||||
|
residual_p95_rad=0.0,
|
||||||
|
residual_max_rad=0.0,
|
||||||
|
)
|
||||||
|
for name, item in payload["mimic_fits"].items()
|
||||||
|
}
|
||||||
|
return L6FitResult(
|
||||||
|
curves=curves,
|
||||||
|
zero_offsets_rad={
|
||||||
|
str(name): float(value)
|
||||||
|
for name, value in payload["zero_offsets_rad"].items()
|
||||||
|
},
|
||||||
|
travels_rad={
|
||||||
|
str(name): float(value)
|
||||||
|
for name, value in payload["travels_rad"].items()
|
||||||
|
},
|
||||||
|
mimic_fits=mimic_fits,
|
||||||
|
holdout_errors_rad={},
|
||||||
|
zero_method_by_joint={},
|
||||||
|
zero_fallback_reason_by_joint={},
|
||||||
|
thumb_zero_result=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None:
|
||||||
|
from linkerhand_calibration.core import ProfileKey
|
||||||
|
|
||||||
|
key = ProfileKey.parse(str(payload.get("profile_id", "")))
|
||||||
|
if key == KEY:
|
||||||
|
profile = build_typed_profile()
|
||||||
|
active_joints = ACTIVE_JOINTS
|
||||||
|
passive_joints = PASSIVE_JOINTS
|
||||||
|
active_transfers = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT
|
||||||
|
passive_transfers = TRANSFERRED_PASSIVE_SOURCE_BY_JOINT
|
||||||
|
else:
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.left_transfer import (
|
||||||
|
KEY as LEFT_TRANSFER_KEY,
|
||||||
|
build_typed_profile as build_left_transfer_profile,
|
||||||
|
left_joint_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if key != LEFT_TRANSFER_KEY:
|
||||||
|
raise ValueError(f"unsupported L6 runtime profile: {key.profile_id}")
|
||||||
|
profile = build_left_transfer_profile()
|
||||||
|
active_joints = tuple(left_joint_name(name) for name in ACTIVE_JOINTS)
|
||||||
|
passive_joints = tuple(left_joint_name(name) for name in PASSIVE_JOINTS)
|
||||||
|
active_transfers = {
|
||||||
|
left_joint_name(name): left_joint_name(source)
|
||||||
|
for name, source in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items()
|
||||||
|
}
|
||||||
|
passive_transfers = {
|
||||||
|
left_joint_name(name): left_joint_name(source)
|
||||||
|
for name, source in TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.items()
|
||||||
|
}
|
||||||
|
passive_set = frozenset(passive_joints)
|
||||||
|
all_joints = frozenset(active_joints + passive_joints)
|
||||||
|
mimic_sources = profile.zero.mimic_source_by_joint
|
||||||
|
coupling_models = profile.zero.coupling_model_by_joint
|
||||||
|
command_indices = profile.command.command_index_by_joint
|
||||||
|
|
||||||
|
required_top = {
|
||||||
|
"schema_version", "profile_id", "layout_id", "model", "side",
|
||||||
|
"serial_number", "calibration_scope", "publication_pointer",
|
||||||
|
"angle_unit", "command_range", "curve_input_domain",
|
||||||
|
"runtime_curve_policy", "command_names", "baseline_command_u8",
|
||||||
|
"protected_inputs", "joints", "quality",
|
||||||
|
}
|
||||||
|
if set(payload) != required_top:
|
||||||
|
raise ValueError("schema v6 calibration has unexpected top-level fields")
|
||||||
|
if (
|
||||||
|
payload["schema_version"] != 6
|
||||||
|
or payload["profile_id"] != profile.key.profile_id
|
||||||
|
or payload["model"] != "L6"
|
||||||
|
or payload["side"] != profile.key.side
|
||||||
|
or payload["layout_id"] != profile.key.layout
|
||||||
|
or payload["calibration_scope"] != "partial"
|
||||||
|
):
|
||||||
|
raise ValueError("schema v6 identity is invalid")
|
||||||
|
if payload["publication_pointer"] != profile.artifacts.publication_pointer:
|
||||||
|
raise ValueError("L6 partial result has the wrong publication pointer")
|
||||||
|
if payload["curve_input_domain"] != "feedback_u8":
|
||||||
|
raise ValueError("schema v6 must be indexed by feedback_u8")
|
||||||
|
if payload["runtime_curve_policy"] != "direction_aware":
|
||||||
|
raise ValueError("schema v6 must retain both motion directions")
|
||||||
|
if payload["angle_unit"] != "rad" or payload["command_range"] != [0, 255]:
|
||||||
|
raise ValueError("schema v6 units are invalid")
|
||||||
|
if tuple(payload["command_names"]) != profile.command.names:
|
||||||
|
raise ValueError("schema v6 command channel order is invalid")
|
||||||
|
if tuple(payload["baseline_command_u8"]) != profile.command.baseline_u8:
|
||||||
|
raise ValueError("schema v6 baseline must be six open commands")
|
||||||
|
protected = payload["protected_inputs"]
|
||||||
|
expected_hashes = {
|
||||||
|
"source_urdf_sha256", "camera_extrinsics_sha256",
|
||||||
|
"calibration_config_sha256", "tag_config_sha256",
|
||||||
|
}
|
||||||
|
if not isinstance(protected, Mapping) or set(protected) != expected_hashes:
|
||||||
|
raise ValueError("schema v6 protected inputs are incomplete")
|
||||||
|
if any(
|
||||||
|
len(str(value)) != 64
|
||||||
|
or any(char not in "0123456789abcdef" for char in str(value))
|
||||||
|
for value in protected.values()
|
||||||
|
):
|
||||||
|
raise ValueError("schema v6 protected input hash is invalid")
|
||||||
|
joints = payload["joints"]
|
||||||
|
if not isinstance(joints, Mapping) or set(joints) != all_joints:
|
||||||
|
raise ValueError("schema v6 must contain all 11 L6 revolute joints")
|
||||||
|
for name in active_joints + passive_joints:
|
||||||
|
joint = joints[name]
|
||||||
|
motor = command_indices[
|
||||||
|
mimic_sources.get(name, name)
|
||||||
|
]
|
||||||
|
if joint.get("urdf_joint") != name or int(joint.get("motor_index", -1)) != motor:
|
||||||
|
raise ValueError(f"{name} has an invalid URDF/SDK mapping")
|
||||||
|
if joint.get("sdk_channel") != profile.command.names[motor]:
|
||||||
|
raise ValueError(f"{name} has an invalid SDK channel")
|
||||||
|
if joint.get("calibration_status") != profile.joint_coverage[name]:
|
||||||
|
raise ValueError(f"{name} has an invalid coverage status")
|
||||||
|
if joint.get("passive") is not (name in passive_set):
|
||||||
|
raise ValueError(f"{name} passive flag is invalid")
|
||||||
|
if joint.get("zero_command_u8") != 255:
|
||||||
|
raise ValueError(f"{name} zero command must be 255")
|
||||||
|
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
|
||||||
|
curve = np.asarray(joint.get(field), dtype=float)
|
||||||
|
if curve.shape != (256,) or not np.all(np.isfinite(curve)):
|
||||||
|
raise ValueError(f"{name}.{field} must contain 256 finite values")
|
||||||
|
if np.any(np.diff(curve) > 1.0e-7):
|
||||||
|
raise ValueError(f"{name}.{field} must be non-increasing")
|
||||||
|
if name in passive_set:
|
||||||
|
if joint.get("source_joint") != mimic_sources[name]:
|
||||||
|
raise ValueError(f"{name} mimic source is invalid")
|
||||||
|
model = str(joint.get("coupling_model", ""))
|
||||||
|
expected_model = coupling_models.get(
|
||||||
|
name, "linear_mimic"
|
||||||
|
)
|
||||||
|
if model not in {"linear_mimic", "quadratic_runtime"}:
|
||||||
|
raise ValueError(f"{name} coupling model is invalid")
|
||||||
|
coefficients = np.asarray(
|
||||||
|
joint.get("coupling_coefficients"), dtype=float
|
||||||
|
)
|
||||||
|
if coefficients.shape != (6,) or not np.all(
|
||||||
|
np.isfinite(coefficients)
|
||||||
|
):
|
||||||
|
raise ValueError(f"{name} coupling coefficients are invalid")
|
||||||
|
enabled = joint.get("urdf_mimic_enabled")
|
||||||
|
if enabled is not True:
|
||||||
|
raise ValueError(f"{name} URDF mimic policy is invalid")
|
||||||
|
expected_policy = (
|
||||||
|
"endpoint_linear_fallback"
|
||||||
|
if model == "quadratic_runtime"
|
||||||
|
else "exact_linear"
|
||||||
|
if name in profile.zero.fitted_mimic_joints or name in passive_transfers
|
||||||
|
else "cad_nominal"
|
||||||
|
)
|
||||||
|
# Early schema-v6 linear artifacts predate the explicit policy
|
||||||
|
# label; their unambiguous model/coverage combination remains
|
||||||
|
# readable. New writers always materialize the field.
|
||||||
|
policy = str(
|
||||||
|
joint.get("urdf_mimic_policy", expected_policy)
|
||||||
|
)
|
||||||
|
if policy not in {expected_policy, "exact_linear" if model == "linear_mimic" else "endpoint_linear_fallback"}:
|
||||||
|
raise ValueError(f"{name} URDF mimic fallback is invalid")
|
||||||
|
multiplier = float(joint.get("mimic_multiplier", "nan"))
|
||||||
|
if not math.isfinite(multiplier) or multiplier <= 0.0:
|
||||||
|
raise ValueError(f"{name} mimic multiplier is invalid")
|
||||||
|
if model == "linear_mimic":
|
||||||
|
if abs(multiplier - coefficients[1]) > 1.0e-7:
|
||||||
|
raise ValueError(f"{name} mimic multiplier is inconsistent")
|
||||||
|
if np.any(np.abs(coefficients[2:]) > 1.0e-10):
|
||||||
|
raise ValueError(f"{name} linear mimic is not linear")
|
||||||
|
if abs(float(coefficients[0]) - float(
|
||||||
|
joint.get("mimic_offset_rad", "nan")
|
||||||
|
)) > 1.0e-7:
|
||||||
|
raise ValueError(f"{name} coupling offset is inconsistent")
|
||||||
|
transferred_from = (
|
||||||
|
active_transfers.get(name)
|
||||||
|
or passive_transfers.get(name)
|
||||||
|
)
|
||||||
|
if transferred_from is not None:
|
||||||
|
if joint.get("transferred_from_joint") != transferred_from:
|
||||||
|
raise ValueError(f"{name} transfer provenance is invalid")
|
||||||
|
donor = joints[transferred_from]
|
||||||
|
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
|
||||||
|
if name not in passive_set and joint[field] != donor[field]:
|
||||||
|
raise ValueError(f"{name} transfer curve differs from donor")
|
||||||
|
if name in passive_set and joint["coupling_model"] == "linear_mimic":
|
||||||
|
parent = joints[mimic_sources[name]]
|
||||||
|
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
|
||||||
|
expected = float(joint["mimic_offset_rad"]) + float(joint["mimic_multiplier"]) * np.asarray(parent[field])
|
||||||
|
if not np.allclose(joint[field], expected, atol=1e-7, rtol=0):
|
||||||
|
raise ValueError(f"{name} curve differs from its declared standard mimic")
|
||||||
|
quality = payload["quality"]
|
||||||
|
if not isinstance(quality, Mapping) or quality.get("scope") != "partial":
|
||||||
|
raise ValueError("schema v6 quality scope must be partial")
|
||||||
|
if quality.get("passed") is not True:
|
||||||
|
raise ValueError("schema v6 quality.passed must be true")
|
||||||
|
|
||||||
|
|
||||||
|
def build_l6_left_transferred_runtime_payload(
|
||||||
|
*,
|
||||||
|
right_payload: Mapping[str, Any],
|
||||||
|
source_left_urdf: str | Path,
|
||||||
|
transferred_left_urdf: str | Path,
|
||||||
|
serial_number: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a validated L6 left runtime JSON paired with a mirrored URDF."""
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.left_transfer import (
|
||||||
|
KEY as LEFT_TRANSFER_KEY,
|
||||||
|
build_typed_profile as build_left_transfer_profile,
|
||||||
|
left_joint_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
validate_l6_runtime_payload(right_payload)
|
||||||
|
if str(right_payload["side"]) != "right":
|
||||||
|
raise ValueError("L6 left transfer requires a measured right payload")
|
||||||
|
left_source = Path(source_left_urdf).expanduser().resolve()
|
||||||
|
left_urdf = Path(transferred_left_urdf).expanduser().resolve()
|
||||||
|
if not left_source.is_file() or not left_urdf.is_file():
|
||||||
|
raise ValueError("L6 left source and transferred URDF files are required")
|
||||||
|
profile = build_left_transfer_profile()
|
||||||
|
source_joint_names = {
|
||||||
|
str(joint.get("name"))
|
||||||
|
for joint in ET.parse(left_source).getroot().findall("joint")
|
||||||
|
if joint.get("type") == "revolute"
|
||||||
|
}
|
||||||
|
if source_joint_names != profile.zero.active_joints | profile.zero.passive_joints:
|
||||||
|
raise ValueError("L6 left source URDF differs from transferred profile")
|
||||||
|
|
||||||
|
payload = deepcopy(dict(right_payload))
|
||||||
|
payload.update({
|
||||||
|
"profile_id": LEFT_TRANSFER_KEY.profile_id,
|
||||||
|
"layout_id": LEFT_TRANSFER_KEY.layout,
|
||||||
|
"side": "left",
|
||||||
|
"serial_number": str(serial_number),
|
||||||
|
"publication_pointer": profile.artifacts.publication_pointer,
|
||||||
|
})
|
||||||
|
payload["protected_inputs"] = {
|
||||||
|
**dict(right_payload["protected_inputs"]),
|
||||||
|
"source_urdf_sha256": _sha256_file(left_source),
|
||||||
|
}
|
||||||
|
left_joints: dict[str, dict[str, Any]] = {}
|
||||||
|
for right_name, source_item in right_payload["joints"].items():
|
||||||
|
left_name = left_joint_name(str(right_name))
|
||||||
|
item = deepcopy(dict(source_item))
|
||||||
|
item["urdf_joint"] = left_name
|
||||||
|
item["calibration_status"] = profile.joint_coverage[left_name]
|
||||||
|
item["mirrored_from_joint"] = str(right_name)
|
||||||
|
if "source_joint" in item:
|
||||||
|
item["source_joint"] = left_joint_name(str(item["source_joint"]))
|
||||||
|
if "transferred_from_joint" in item:
|
||||||
|
item["transferred_from_joint"] = left_joint_name(
|
||||||
|
str(item["transferred_from_joint"])
|
||||||
|
)
|
||||||
|
zero = item.get("zero_angles")
|
||||||
|
if isinstance(zero, Mapping):
|
||||||
|
zero = deepcopy(dict(zero))
|
||||||
|
if "transferred_from_joint" in zero:
|
||||||
|
zero["transferred_from_joint"] = left_joint_name(
|
||||||
|
str(zero["transferred_from_joint"])
|
||||||
|
)
|
||||||
|
item["zero_angles"] = {
|
||||||
|
**zero,
|
||||||
|
"mirrored_from_joint": str(right_name),
|
||||||
|
"transfer_kind": "right_measurement_mirrored_to_left",
|
||||||
|
}
|
||||||
|
left_joints[left_name] = item
|
||||||
|
payload["joints"] = left_joints
|
||||||
|
|
||||||
|
quality = deepcopy(dict(right_payload["quality"]))
|
||||||
|
thumb = quality.get("thumb_axis_zero")
|
||||||
|
if isinstance(thumb, Mapping):
|
||||||
|
thumb = deepcopy(dict(thumb))
|
||||||
|
for field in (
|
||||||
|
"offsets_rad",
|
||||||
|
"cycle_offsets_rad",
|
||||||
|
"geometry_fallback_reasons",
|
||||||
|
"validation_error_by_joint_rad",
|
||||||
|
):
|
||||||
|
values = thumb.get(field)
|
||||||
|
if isinstance(values, Mapping):
|
||||||
|
thumb[field] = {
|
||||||
|
left_joint_name(str(name)): value
|
||||||
|
for name, value in values.items()
|
||||||
|
}
|
||||||
|
quality["thumb_axis_zero"] = thumb
|
||||||
|
canonical_right = json.dumps(
|
||||||
|
right_payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||||
|
).encode("utf-8")
|
||||||
|
quality["transfer_provenance"] = {
|
||||||
|
"kind": "right_measurement_mirrored_to_left",
|
||||||
|
"source_profile_id": str(right_payload["profile_id"]),
|
||||||
|
"source_serial_number": str(right_payload["serial_number"]),
|
||||||
|
"source_payload_sha256": hashlib.sha256(canonical_right).hexdigest(),
|
||||||
|
"left_source_urdf_sha256": _sha256_file(left_source),
|
||||||
|
"transferred_left_urdf_sha256": _sha256_file(left_urdf),
|
||||||
|
"left_hand_measured": False,
|
||||||
|
}
|
||||||
|
payload["quality"] = quality
|
||||||
|
validate_l6_runtime_payload(payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_json(path: str | Path, payload: Mapping[str, Any]) -> Path:
|
||||||
|
destination = Path(path).resolve()
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
||||||
|
try:
|
||||||
|
with temporary.open("w", encoding="utf-8") as stream:
|
||||||
|
json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
stream.write("\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
finally:
|
||||||
|
if temporary.exists():
|
||||||
|
temporary.unlink()
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
def publish_partial_session(*args, **kwargs):
|
||||||
|
raise ValueError("legacy publication is retired; use the common ArtifactPublisher")
|
||||||
|
|
||||||
|
|
||||||
|
def artifact_hashes(json_path: str | Path, urdf_path: str | Path) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"calibration_json_sha256": _sha256_file(json_path),
|
||||||
|
"corrected_urdf_sha256": _sha256_file(urdf_path),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ALL_REVOLUTE_JOINTS",
|
||||||
|
"artifact_hashes",
|
||||||
|
"atomic_write_json",
|
||||||
|
"build_l6_left_transferred_runtime_payload",
|
||||||
|
"build_l6_urdf_input_payload",
|
||||||
|
"build_l6_runtime_payload",
|
||||||
|
"load_l6_urdf_input",
|
||||||
|
"publish_partial_session",
|
||||||
|
"validate_l6_urdf_input_payload",
|
||||||
|
"validate_l6_runtime_payload",
|
||||||
|
]
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
"""Legacy result types plus a direct call to the ONE profile fitter.
|
||||||
|
|
||||||
|
Old endpoint/range-centre fallbacks and model-specific refitting are retired.
|
||||||
|
The legacy result type remains only for reading archived JSON/tool inputs.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping
|
||||||
|
from linkerhand_calibration.core.domain.measurement import JointCurveFit
|
||||||
|
from linkerhand_calibration.core.domain.result import JointMapping
|
||||||
|
from linkerhand_calibration.core.fitting import CouplingFit as MimicFit, fit_coupling_model, curve_travel_rad
|
||||||
|
from linkerhand_calibration.core.fitting.coupling import LinearMimicFit, RelativeMimicEvidence
|
||||||
|
from linkerhand_calibration.core.fitting.spatial import ZeroSolveResult
|
||||||
|
from linkerhand_calibration.core.fitting.session import fit_profile_calibration, compile_spatial_profile
|
||||||
|
from linkerhand_calibration.profiles import load_bundled_hand_profile
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class L6FitResult:
|
||||||
|
curves: Mapping[str, JointCurveFit]
|
||||||
|
zero_offsets_rad: Mapping[str, float]
|
||||||
|
travels_rad: Mapping[str, float]
|
||||||
|
mimic_fits: Mapping[str, MimicFit]
|
||||||
|
holdout_errors_rad: Mapping[str, tuple[float, ...]]
|
||||||
|
zero_method_by_joint: Mapping[str, str]
|
||||||
|
zero_fallback_reason_by_joint: Mapping[str, str]
|
||||||
|
thumb_zero_result: ZeroSolveResult | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def fit_l6_session(source_urdf, records_by_joint, *, require_thumb_axis_zero=False):
|
||||||
|
# Historical flags can no longer disable spatial or holdout verification.
|
||||||
|
return fit_profile_calibration(load_bundled_hand_profile("l6_right_8"),
|
||||||
|
Path(source_urdf), records_by_joint)
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
"""Runtime-only L6 left profile derived from reviewed right-hand calibration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from linkerhand_calibration.core import ProfileKey
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import EngineBindings, RegisteredProfile
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.profile import build_typed_profile as build_right_typed_profile
|
||||||
|
|
||||||
|
|
||||||
|
KEY = ProfileKey("L6", "left", "l6_left_transferred_8", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def left_joint_name(name: str) -> str:
|
||||||
|
value = str(name)
|
||||||
|
if not value.startswith("rh_"):
|
||||||
|
raise ValueError(f"L6 right joint name cannot be mirrored: {value}")
|
||||||
|
return "lh_" + value[3:]
|
||||||
|
|
||||||
|
|
||||||
|
def build_typed_profile():
|
||||||
|
right = build_right_typed_profile()
|
||||||
|
command_index = {
|
||||||
|
left_joint_name(name): index
|
||||||
|
for name, index in right.command.command_index_by_joint.items()
|
||||||
|
}
|
||||||
|
active = frozenset(left_joint_name(name) for name in right.zero.active_joints)
|
||||||
|
passive = frozenset(left_joint_name(name) for name in right.zero.passive_joints)
|
||||||
|
measurements = {
|
||||||
|
left_joint_name(name): replace(spec, joint=left_joint_name(name))
|
||||||
|
for name, spec in right.measurement.measurements.items()
|
||||||
|
}
|
||||||
|
tasks = tuple(
|
||||||
|
replace(task, joints=tuple(left_joint_name(name) for name in task.joints))
|
||||||
|
for task in right.motion.tasks
|
||||||
|
)
|
||||||
|
return replace(
|
||||||
|
right,
|
||||||
|
key=KEY,
|
||||||
|
namespace="/l6_left_transferred",
|
||||||
|
command=replace(
|
||||||
|
right.command,
|
||||||
|
command_index_by_joint=command_index,
|
||||||
|
urdf_joint_by_joint={name: name for name in active},
|
||||||
|
),
|
||||||
|
motion=replace(right.motion, tasks=tasks),
|
||||||
|
measurement=replace(
|
||||||
|
right.measurement,
|
||||||
|
measurements=measurements,
|
||||||
|
cross_view_sources={
|
||||||
|
left_joint_name(name): left_joint_name(source)
|
||||||
|
for name, source in right.measurement.cross_view_sources.items()
|
||||||
|
},
|
||||||
|
image_curve_joints=frozenset(
|
||||||
|
left_joint_name(name)
|
||||||
|
for name in right.measurement.image_curve_joints
|
||||||
|
),
|
||||||
|
),
|
||||||
|
zero=replace(
|
||||||
|
right.zero,
|
||||||
|
transferred_zero_sources={left_joint_name(k): left_joint_name(v)
|
||||||
|
for k, v in right.zero.transferred_zero_sources.items()},
|
||||||
|
transferred_mimic_sources={left_joint_name(k): left_joint_name(v)
|
||||||
|
for k, v in right.zero.transferred_mimic_sources.items()},
|
||||||
|
# Runtime-only mirrored products do not acquire/solve new geometry.
|
||||||
|
spatial={},
|
||||||
|
active_joints=active,
|
||||||
|
passive_joints=passive,
|
||||||
|
direct_zero_joints=tuple(
|
||||||
|
left_joint_name(name) for name in right.zero.direct_zero_joints
|
||||||
|
),
|
||||||
|
axis_joints=tuple(
|
||||||
|
left_joint_name(name) for name in right.zero.axis_joints
|
||||||
|
),
|
||||||
|
mechanical_endpoint_joints=frozenset(
|
||||||
|
left_joint_name(name)
|
||||||
|
for name in right.zero.mechanical_endpoint_joints
|
||||||
|
),
|
||||||
|
post_solve_endpoint_joints=frozenset(
|
||||||
|
left_joint_name(name)
|
||||||
|
for name in right.zero.post_solve_endpoint_joints
|
||||||
|
),
|
||||||
|
mimic_source_by_joint={
|
||||||
|
left_joint_name(name): left_joint_name(source)
|
||||||
|
for name, source in right.zero.mimic_source_by_joint.items()
|
||||||
|
},
|
||||||
|
cad_frozen_joints=passive,
|
||||||
|
endpoint_anchor_by_joint={
|
||||||
|
left_joint_name(name): policy
|
||||||
|
for name, policy in right.zero.endpoint_anchor_by_joint.items()
|
||||||
|
},
|
||||||
|
fitted_mimic_joints=frozenset(
|
||||||
|
left_joint_name(name) for name in right.zero.fitted_mimic_joints
|
||||||
|
),
|
||||||
|
coupling_model_by_joint={
|
||||||
|
left_joint_name(name): model
|
||||||
|
for name, model in right.zero.coupling_model_by_joint.items()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
scope=replace(
|
||||||
|
right.scope,
|
||||||
|
calibrate_joints={
|
||||||
|
scope: frozenset(left_joint_name(name) for name in names)
|
||||||
|
for scope, names in right.scope.calibrate_joints.items()
|
||||||
|
},
|
||||||
|
frozen_joints={
|
||||||
|
scope: frozenset(left_joint_name(name) for name in names)
|
||||||
|
for scope, names in right.scope.frozen_joints.items()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
artifacts=replace(
|
||||||
|
right.artifacts,
|
||||||
|
calibration_filename=(
|
||||||
|
"l6_left_{serial_number}_transferred_calibration.json"
|
||||||
|
),
|
||||||
|
corrected_urdf_filename=(
|
||||||
|
"linkerhand_l6_left_{serial_number}_transferred.urdf"
|
||||||
|
),
|
||||||
|
publication_pointer="latest_transferred",
|
||||||
|
session_compatibility_tokens=frozenset(
|
||||||
|
{"l6_left_transfer_v1", "feedback_curves_v6"}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
joint_coverage={
|
||||||
|
left_joint_name(name): status
|
||||||
|
for name, status in right.joint_coverage.items()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unsupported(_args=None) -> None:
|
||||||
|
raise ValueError(
|
||||||
|
"L6 left transferred profile is runtime-only; calibrate the left hand "
|
||||||
|
"with a dedicated measured profile before treating it as measured"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_profile() -> RegisteredProfile:
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.motion import (
|
||||||
|
build_calibration_motion_command,
|
||||||
|
build_calibration_preparation_waypoints,
|
||||||
|
build_calibration_return_waypoints,
|
||||||
|
)
|
||||||
|
|
||||||
|
typed = build_typed_profile()
|
||||||
|
return RegisteredProfile(
|
||||||
|
profile=typed,
|
||||||
|
engine=EngineBindings(
|
||||||
|
hand_profile=typed,
|
||||||
|
zero_profile=typed.zero,
|
||||||
|
motion_command=build_calibration_motion_command,
|
||||||
|
preparation_waypoints=build_calibration_preparation_waypoints,
|
||||||
|
return_waypoints=build_calibration_return_waypoints,
|
||||||
|
cli_main=_unsupported,
|
||||||
|
node_main=_unsupported,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["KEY", "build_profile", "build_typed_profile", "left_joint_name"]
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
"""Safe six-channel motion helpers for the partial L6 profile."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from linkerhand_calibration.runtime.trajectory import (
|
||||||
|
build_calibration_motion_command,
|
||||||
|
build_calibration_preparation_waypoints,
|
||||||
|
build_calibration_return_waypoints,
|
||||||
|
cosine_position_trajectory_u8,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_calibration_motion_command",
|
||||||
|
"build_calibration_preparation_waypoints",
|
||||||
|
"build_calibration_return_waypoints",
|
||||||
|
"cosine_position_trajectory_u8",
|
||||||
|
]
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
"""Reviewed partial-calibration profile for the right L6 eight-Tag rig."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from linkerhand_calibration.core import CalibrationProfile, ProfileKey
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import EngineBindings, RegisteredProfile
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.motion import (
|
||||||
|
build_calibration_motion_command,
|
||||||
|
build_calibration_preparation_waypoints,
|
||||||
|
build_calibration_return_waypoints,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Historical import constants are projections of the authoritative YAML,
|
||||||
|
# never a second set of model/zero/coupling definitions.
|
||||||
|
from linkerhand_calibration.profiles.loader import load_bundled_hand_profile
|
||||||
|
_DECLARED = load_bundled_hand_profile("l6_right_8")
|
||||||
|
KEY = _DECLARED.key
|
||||||
|
COMMAND_NAMES = _DECLARED.command.names
|
||||||
|
COMMAND_INDEX_BY_JOINT = dict(_DECLARED.command.command_index_by_joint)
|
||||||
|
ACTIVE_JOINTS = tuple(sorted(COMMAND_INDEX_BY_JOINT, key=COMMAND_INDEX_BY_JOINT.get))
|
||||||
|
MIMIC_SOURCE_BY_JOINT = dict(_DECLARED.zero.mimic_source_by_joint)
|
||||||
|
PASSIVE_JOINTS = tuple(name for source in ACTIVE_JOINTS
|
||||||
|
for name, parent in MIMIC_SOURCE_BY_JOINT.items() if parent == source)
|
||||||
|
CALIBRATED_ACTIVE_JOINTS = _DECLARED.scope.selected_joints(_DECLARED.scope.default_scope)
|
||||||
|
MEASURED_PASSIVE_JOINTS = _DECLARED.zero.fitted_mimic_joints
|
||||||
|
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT = dict(_DECLARED.zero.transferred_zero_sources)
|
||||||
|
TRANSFERRED_PASSIVE_SOURCE_BY_JOINT = dict(_DECLARED.zero.transferred_mimic_sources)
|
||||||
|
CORRECTED_ACTIVE_JOINTS = _DECLARED.zero.active_joints
|
||||||
|
CORRECTED_PASSIVE_JOINTS = frozenset(MEASURED_PASSIVE_JOINTS | TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.keys())
|
||||||
|
ENDPOINT_ANCHOR_BY_JOINT = dict(_DECLARED.zero.endpoint_anchor_by_joint)
|
||||||
|
COUPLING_MODEL_BY_JOINT = dict(_DECLARED.zero.coupling_model_by_joint)
|
||||||
|
|
||||||
|
|
||||||
|
def build_typed_profile() -> CalibrationProfile:
|
||||||
|
"""Compatibility entry; the complete typed contract lives only in YAML."""
|
||||||
|
from linkerhand_calibration.profiles.loader import load_bundled_hand_profile
|
||||||
|
return load_bundled_hand_profile("l6_right_8")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cli(args: list[str] | None = None) -> None:
|
||||||
|
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.runner import main
|
||||||
|
|
||||||
|
main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_node(args: list[str] | None = None) -> None:
|
||||||
|
from linkerhand_calibration.runtime.ros.calibration_node import run_profile_node
|
||||||
|
run_profile_node(build_typed_profile(), args)
|
||||||
|
|
||||||
|
|
||||||
|
def build_profile() -> RegisteredProfile:
|
||||||
|
typed = build_typed_profile()
|
||||||
|
return RegisteredProfile(
|
||||||
|
profile=typed,
|
||||||
|
engine=EngineBindings(
|
||||||
|
hand_profile=typed,
|
||||||
|
zero_profile=typed.zero,
|
||||||
|
motion_command=build_calibration_motion_command,
|
||||||
|
preparation_waypoints=build_calibration_preparation_waypoints,
|
||||||
|
return_waypoints=build_calibration_return_waypoints,
|
||||||
|
cli_main=_run_cli,
|
||||||
|
node_main=_run_node,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ACTIVE_JOINTS",
|
||||||
|
"CALIBRATED_ACTIVE_JOINTS",
|
||||||
|
"COMMAND_NAMES",
|
||||||
|
"KEY",
|
||||||
|
"MEASURED_PASSIVE_JOINTS",
|
||||||
|
"MIMIC_SOURCE_BY_JOINT",
|
||||||
|
"PASSIVE_JOINTS",
|
||||||
|
"build_profile",
|
||||||
|
"build_typed_profile",
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user