新零位相机外参标定方案

This commit is contained in:
lxp
2026-08-07 16:22:24 +08:00
parent 4107da4c22
commit 41ff4a61a9
21 changed files with 5178 additions and 367 deletions
+2
View File
@@ -62,6 +62,7 @@ Thumbs.db
# src/linkerhand_retarget/resource/linkerforce_v2/profiles/.
/profiles/
/calibration_output/
/config/g20_three_camera_extrinsics.yaml
*.wear_check.json
*.checkpoint.json
*.verification.json
@@ -69,6 +70,7 @@ Thumbs.db
# Device-specific robot descriptions derived from local calibration runs
/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
# ROS bag / MCAP recordings and CAN captures
+102 -59
View File
@@ -1,9 +1,10 @@
# G20 左手 AprilTag 标定
## 三机位全手一键标定
## 三机位三维关节轴零位标定(schema v4)
正式全手入口同时使用三台海康 `MV-CS020-10U/10UM` 黑白全局快门相机,
但只有 `/g20_calibration` 一个节点拥有机械手命令发布权。默认机位绑定为:
正式入口同时使用三台海康 `MV-CS020-10U/10UM` 黑白全局快门相机,只有
`/g20_calibration` 一个节点拥有机械手命令发布权。相机不需要水平,Tag方向也不需要
贴正;相机和Tag在一次标定中必须固定。默认绑定为:
```text
front = DB2163742Tag 0/1/2/3/10
@@ -31,7 +32,7 @@ ID 4 不贴在侧面相机看不到的掌心正面;ID 8 必须始终固定且
ID 9 必须在拇指横摆的完整行程中持续可见。
贴纸不能跨关节、贴在软胶上或在扫描过程中翘起。
每台相机必须有独立内参文件:
每台相机必须有独立内参文件:
```text
~/.ros/camera_info/hikrobot_DB2163742.yaml
@@ -39,16 +40,56 @@ ID 9 必须在拇指横摆的完整行程中持续可见。
~/.ros/camera_info/hikrobot_DB2163739.yaml
```
先使用禁止运动模式检查三个机位、内参和标签:
### 1. 一次性三相机外参
三相机第一次安装、任何相机移动、镜头重新聚焦或内参变化后,必须重标外参。使用
`8x5` 内角点、实测方格边长 `27 mm`、粘在硬质平板上的棋盘:
```bash
mkdir -p /home/lxp/projects/linkerhand_retarget_ros2/config
ros2 launch g20_thumb_apriltag_calibration \
three_camera_extrinsics.launch.py \
output_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \
checkerboard_columns:=8 checkerboard_rows:=5 square_size_m:=0.027
```
启动后默认打开 `G20 Three-Camera Extrinsics` 交互窗口。可切换
`FRONT + SIDE``FRONT + TOP`;窗口实时显示棋盘角点、单相机/组合
RMS、时间差、联合拟合稳定性和候选/内点数量。单张只要棋盘完整、
同步、RMS和姿态差异合格,`ADD CANDIDATE` 就会变绿;不再用单张
PnP的最终外参偏差锁死采集。点击 `AUTO ON` 后,棋盘稳定1秒会自动
采集,移到新姿态后再自动采下一组。
外参分两组采集。让棋盘静止且同时出现在正面/侧面画面中,每改变一次位置和倾角添加
一个候选;随后以相同方法采集正面/上面。程序使用固定内参的
`stereoCalibrate` 联合优化唯一旋转/平移,自动剔除粗大异常组;两组均得到
至少15个内点且联合RMS、三折稳定性合格后 `SAVE` 才变绿。
```bash
ros2 service call /g20_camera_extrinsics/capture_front_side std_srvs/srv/Trigger {}
ros2 service call /g20_camera_extrinsics/capture_front_top std_srvs/srv/Trigger {}
ros2 service call /g20_camera_extrinsics/save std_srvs/srv/Trigger {}
```
采集时可分别查看 `/g20_extrinsics/{front,side,top}/camera/image_rect`。保存门限为:
联合重投影RMS不超过1.2 px、三折重拟外参最大旋转差不超过0.3°、最大平移差不超过
1.5 mm。文件同时绑定三台相机序列号、分辨率和内参哈希;不满足任一项时不会保存
通过结果,正式标定也不会运动。
### 2. 预检和正式标定
先使用禁止运动模式检查三个机位、外参、内参和标签:
```bash
ros2 launch g20_thumb_apriltag_calibration \
three_camera_calibration.launch.py \
serial_number:=G20_LEFT_001 \
camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \
source_urdf_path:=/home/lxp/projects/linkerhand_retarget_ros2/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left.urdf \
commands_enabled:=false
```
分别查看三个相机画面:
分别查看正式流程的三个画面:
```bash
ros2 run image_view image_view --ros-args \
@@ -59,39 +100,14 @@ ros2 run image_view image_view --ros-args \
--remap image:=/g20_calibration/top/camera/image_rect
```
安装相机时可以按机位启动红/蓝线对准辅助节点。红线是画面理想水平线,
蓝线是在画面下部检测到的桌边、底座边或临时刚性直尺;两线夹角不超过
`±0.5°` 且上下构图偏差不超过 `±12 px` 时显示 `ALIGNED`。Tag只画绿色
识别框,其角点方向完全不参与红/蓝线角度计算,因此Tag无需为了相机对准而贴正。
下面以正面机位为例,先启动辅助节点:
```bash
ros2 run g20_thumb_apriltag_calibration camera_alignment_view \
--ros-args -p view:=front
```
再打开它发布的叠加画面:
```bash
ros2 run image_view image_view --ros-args \
--remap image:=/g20_camera_alignment_view/image
```
侧面和上面分别把 `view:=front` 改为 `view:=side``view:=top`。建议一次只开
一个机位完成调整;侧面或上面没有合适长边时,临时放置与目标机械轴平行的刚性
直尺。调整完成后退出辅助节点和
`image_view`,再进行正式标定,以免额外的200万像素图像订阅影响采集帧率。
这组红/蓝线只检查图像平面滚转角,不检查相机距离、俯仰、偏航,也不会阻止
`/g20_calibration/start`
确认所有目标关节的 `0255` 行程安全、MVS 客户端已关闭且没有其他命令发布者后,
重新启动正式流程:
确认全行程安全、MVS客户端已关闭且没有其他命令发布者后,重启正式流程:
```bash
ros2 launch g20_thumb_apriltag_calibration \
three_camera_calibration.launch.py \
serial_number:=G20_LEFT_001 \
camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \
source_urdf_path:=/home/lxp/projects/linkerhand_retarget_ros2/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left.urdf \
can_interface:=can0
```
@@ -110,28 +126,51 @@ ros2 service call /g20_calibration/start std_srvs/srv/Trigger {}
255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
```
程序依次完成正面电机 `0/5/15/6`、侧面电机 `1/16`、上面电机 `10`三轮
往返扫描。中指、无名指、小指复制食指模板。四指侧摆先以命令255
为原始0角测出总行程,再减去总行程的一半;最终满足命令0为正、命令255为负,
零位命令是实测曲线上最接近角度中点的整数命令。
程序依次扫描正面电机 `0/5/15/6`、侧面 `1/16`、上面 `10`,每项三轮
`255→0→255`。轨迹角由父/子Tag完整相对四元数的旋转向量投影到三维拟合轴得到。
对正面/侧面近图像平面的浅圆弧,使用姿态旋转轴约束Tag中心三维圆,只由圆轨迹
拟合轴线位置和半径,避免单目平面Tag深度噪声改变轴方向;对运动平面充分可观测的
斜视轨迹,仍保留姿态轴和独立三维圆轴的交叉检查。每条主动曲线在其baseline命令
严格归零:普通通道255,四指侧摆127。中指、无名指、小指继续继承食指曲线;四指
`mcp_roll/mcp_pitch/pip` 的静态URDF偏移统一固定为0,不再把单根食指的安装残差复制
到四根手指。`thumb_mcp` 的静态URDF偏移同样固定为0,避免IP轴线深度偏差在命令255
处产生额外向外伸展;其256项实测轨迹保持不变。
零位模式锁死原始CAD几何。URDF中确定平行的 `thumb_cmc_pitch→thumb_mcp→thumb_ip`
`index_mcp_pitch→index_pip→index_dip` 使用同轮上游实测方向,同时由各自Tag中心
轨迹独立拟合轴线位置;被动角度曲线仍由对应父/子Tag完整相对姿态生成。这样保留
下游轴线对上游零位的约束,同时避免小平面Tag的稳定PnP方向偏差被误认为URDF轴误差。
前两轮拟合,第三轮强制留出验证;第三轮轨迹MAE必须≤1°、P95≤2°,三轮真实
姿态轴方向极差≤0.75°、轴向/径向RMS≤3 mm。零位留出验收只统计能观测对应上游
零位的下游轴分量:方向MAE≤1°、P95≤2°,轴线RMS≤3 mm;根轴自身及与当前零位
无关的固定CAD/PnP残差不会再把零位推向±20°。独立三维运动平面充分可观测的关节
额外要求姿态轴与圆轴夹角≤1°。任一指标失败会暂停并指出失败关节;调用 `resume`
只重扫对应电机的6个方向。
单轮姿态相对理想固定轴的轴外RMS与跨轮重复性分别判定:主动关节上限2.5°,被动
耦合关节上限7.5°。较宽的被动模型门限只容纳可重复的机构耦合和双Tag PnP系统误差,
不会替代三轮轴方向≤0.75°和第三轮MAE/P95留出验证。
零位求解联合优化公共手掌六维位姿和3个拇指CMC直接主动关节的
`urdf_zero_offset_rad`。依赖链为 yaw轴约束拇指roll、pitch轴约束拇指yaw、MCP轴线
约束拇指pitch`thumb_mcp` 固定为0,不再使用IP轴线修改它的静态零位。食指扫描仍
用于生成四指轨迹,但不再用于修改四指静态URDF零位。
原始URDF的 `origin.xyz``axis.xyz`、连杆长度、mesh和被动结构固定。yaw扫描时电机5
保持145,求解器使用实测 `angle_rad[145]` 还原该条件,不会把145误当成baseline。
偏移超过±20°时整次失败。
生成修正URDF时,四指主动 `mcp_roll/mcp_pitch/pip` 的关节限位会检查对应256项实测
轨迹;仅当实测端点越过原始CAD限位时才向外扩展,并额外保留0.5°数值余量。不会
收窄原限位,也不会修改拇指或四指被动DIP的限位。
每个直接测量任务完成 `3轮×2方向=6个扫描方向` 后,程序立即试拟合
该电机对应的所有主动/被动关节。`thumb_cmc_pitch``thumb_cmc_roll`
`thumb_mcp``thumb_ip``index_mcp_roll``index_mcp_pitch``index_pip` 使用图像平面的
连杆相对中心圆相位,避免小尺寸平面 Tag 的PnP深度双解把稳定的二维圆轨迹扭曲成
错误三维轨迹。上面斜视的 `thumb_cmc_yaw` 和侧面的被动 `index_dip` 继续使用
父Tag坐标系下的三维相对圆。`thumb_ip` 理论上也适合相对三维,但当前正面小Tag的
PnP深度在三轮间不稳定,实测会让三维行程漂移,因此继续采用可重复的二维投影轨迹。
程序分别检查二维圆残差/半径或三维平面RMS/圆残差/半径,并统一检查最小圆弧、
单调修正量、正反程回差、三轮行程一致性和直接零位拟合。主动关节三轮行程最大差
默认不超过3°;被动耦合关节允许不超过10°,但仍必须通过其余质量门限。
任一指标失败时会立即暂停,中文状态显示关节名、实测值和阈值,不再等到42个方向
全部结束。修正现场问题后调用 `resume`
程序只清除该电机任务的内存样本并重扫它的6个方向;前面已通过的关节保留。
失败样本不从 `raw_samples.jsonl` 删除,而是使用 `attempt``retry` 记录区分,
便于调试;最终拟合只使用当前通过尝试的内存数据。
标定食指 `index_mcp_roll`(电机6及其随机复测时,为避免中指遮挡ID 10
标定食指 `index_mcp_roll`(电机6)时,为避免中指遮挡ID 10,
程序将中指、无名指和小指的侧摆电机7/8/9固定为0;开始采样前会同时确认
电机6到达扫描起点且电机7/8/9均已到达0。离开该标定项后恢复统一基准姿态。
该项目还会通过SDK设置接口把五指速度临时设为 `[15,5,15,15,15]`,即只把
@@ -140,31 +179,34 @@ PnP深度在三轮间不稳定,实测会让三维行程漂移,因此继续
五指速度设为 `[15,10,15,15,15]`,即食指屈伸使用第三档速度10;其余直接
测量关节保持普通速度15。
标定 `thumb_cmc_yaw`(电机10及其随机复测时,程序将
标定 `thumb_cmc_yaw`(电机10)时,程序将
`thumb_cmc_roll`(电机5)固定为145,并在它到位后才开始采样,以保持运动Tag
ID 9的可见性和PnP稳定性。离开该标定项后,电机5恢复基准值255;
最终JSON的 `baseline_command_u8` 不变。
三机位流程默认设置 `validation_enabled:=false`因此拟合完成后会直接
恢复基准姿态并生成JSON,不再进入 `VALIDATION_MOVE/VALIDATION_CAPTURE`
此时 `quality.passed` 只由轨迹与零位拟合质量决定,`validation_mae_rad`
`validation_p95_rad``null`。需要恢复随机复测时,启动参数加
`validation_enabled:=true`
三机位流程默认 `validation_enabled:=false`即不增加随机机械动作,但第三轮留出验证
始终启用且不能关闭;最终 `quality.validation_mae_rad/p95_rad` 正是第三轮轨迹误差
上面机位在Tag二维质量合格但PnP连续无效达到1秒时,会自动重置该机位的单Tag
和双Tag连续性跟踪器,并从下一帧重新建链,早于3秒采集超时。暂停恢复时只要求
当前活动机位就绪;首次调用 `start` 仍要求三个机位全部通过预检。
对外只生成一个精简运行时结果:
### 3. 输出
通过后生成精简JSON和一个新URDF:
```text
calibration_output/G20_LEFT_001/<时间戳>/
g20_left_G20_LEFT_001_calibration.json
src/.../g20_left/
linkerhand_g20_left_zero_calibrated_G20_LEFT_001_<时间戳>.urdf
```
文件包含21个关节的256项 `angle_rad`、16个主动关节的 `zero_command_u8`
5个被动标记、模板来源和总体质量。相机、Tag、正反程及每轮质量只进入状态、日志和
`raw_samples.jsonl`,不写入最终运行时 JSON。
`zero_angles.urdf_zero_offset_rad`、5个被动标记、模板来源和总体质量。新URDF每次从
指定原始CAD文件生成,采用 `T_original × Rot(axis, offset)`,绝不叠加旧校准文件或
覆盖原文件;除16个主动关节的 `origin.rpy` 外,原URDF文本保持不变。每帧Tag SE(3)、
图像时间戳、20通道状态、同步误差和PnP误差只进入 `raw_samples.jsonl`
下面保留原有正面拇指独立标定说明和兼容入口。
@@ -205,7 +247,8 @@ PnP 双分支跟踪和整段刚体复核仍保留,用于选出稳定的三维
`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.sizes`/`tag_sizes_m` 必须填写每张 Tag 的实测有效边长(米),当前实物黑色正方形实测为
`16 mm`,因此配置为 `0.016`
测量检测角点所围成的正方形边长,不包含外围白色留边。
- 当前试标定允许四张 Tag 的有效边长至少 30 px(实测静态约 32~38 px),最终仍由
静止角度 RMS 和随机复测误差决定是否合格。四张 Tag 必须在全行程内均可见。需要短时检查标记时,
@@ -6,7 +6,7 @@
# from back-pressuring image_proc's reliable image publisher.
qos_profile: sensor_data
family: 36h11
size: 0.01
size: 0.016
profile: false
max_hamming: 0
detector:
@@ -20,11 +20,11 @@
tag:
ids: [0, 1, 2, 3]
frames: [tag_t0, tag_t3, tag_t4, tag_t5]
sizes: [0.010, 0.010, 0.010, 0.010]
sizes: [0.016, 0.016, 0.016, 0.016]
g20_thumb_calibration:
ros__parameters:
tag_roles: [t0, t3, t4, t5]
tag_ids: [0, 1, 2, 3]
tag_frames: [tag_t0, tag_t3, tag_t4, tag_t5]
tag_sizes_m: [0.010, 0.010, 0.010, 0.010]
tag_sizes_m: [0.016, 0.016, 0.016, 0.016]
@@ -18,7 +18,8 @@ g20_calibration:
index_flex_calibration_speed: 10
speed_setting_settle_seconds: 0.25
tag_size_m: 0.010
# tag36h11检测角点围成的黑色正方形实测为16 mm;不包含外围白边。
tag_size_m: 0.016
repetitions: 3
preflight_frames: 60
minimum_detection_rate: 0.95
@@ -34,9 +35,27 @@ g20_calibration:
pnp_maximum_tag_tilt_deg: 75.0
pnp_tracker_reset_seconds: 5.0
top_pnp_invalid_reset_seconds: 1.0
maximum_state_image_skew_ms: 150.0
# 三维位姿必须与实测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
# 仅用于运动平面在三维中可观测的斜视关节;近图像平面关节使用姿态轴
# 约束三维圆,不让单目平面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
zero_maximum_offset_deg: 20.0
endpoint_tolerance_u8: 2.0
# 电机10在命令0时实测会稳定反馈为3;仅其0端使用±3,255端及其他电机仍为±2。
thumb_yaw_zero_endpoint_tolerance_u8: 3.0
endpoint_hold_seconds: 0.5
baseline_hold_seconds: 0.5
position_timeout_seconds: 30.0
@@ -51,26 +70,22 @@ g20_calibration:
trajectory_maximum_radial_rms_m: 0.004
trajectory_minimum_radius_m: 0.003
trajectory_minimum_arc_deg: 15.0
# 正面拇指pitch/MCP/IP使用二维圆相位,避免平面Tag的PnP深度歧义
# 以下二维参数只供旧轨迹工具兼容,三机位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
zero_minimum_radius_px: 20.0
zero_maximum_radial_rms_px: 2.0
zero_maximum_radial_p95_px: 3.5
zero_maximum_round_difference_deg: 1.0
maximum_monotonic_correction_deg: 2.0
maximum_hysteresis_deg: 5.0
passive_maximum_monotonic_correction_deg: 3.0
passive_maximum_hysteresis_deg: 7.5
# 默认跳过耗时的随机复测;需要验收精度时可在launch中设为true
# 默认无额外随机动作;第三轮扫描始终作为不可关闭的留出验证
validation_enabled: false
validation_command_count: 3
validation_frames: 10
validation_seed: 20260804
validation_timeout_seconds: 20.0
maximum_validation_mae_deg: 2.0
maximum_validation_p95_deg: 3.0
maximum_validation_mae_deg: 1.0
maximum_validation_p95_deg: 2.0
@@ -3,7 +3,7 @@
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.010
size: 0.016
profile: false
max_hamming: 0
detector:
@@ -17,14 +17,14 @@
tag:
ids: [0, 1, 2, 3, 10]
frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, index_roll]
sizes: [0.010, 0.010, 0.010, 0.010, 0.010]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016]
/g20_calibration/side/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.010
size: 0.016
profile: false
max_hamming: 0
detector:
@@ -38,14 +38,14 @@
tag:
ids: [4, 5, 6, 7]
frames: [side_base, index_mcp, index_pip, index_dip]
sizes: [0.010, 0.010, 0.010, 0.010]
sizes: [0.016, 0.016, 0.016, 0.016]
/g20_calibration/top/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.010
size: 0.016
profile: false
max_hamming: 0
detector:
@@ -59,4 +59,4 @@
tag:
ids: [8, 9]
frames: [top_base, thumb_yaw]
sizes: [0.010, 0.010]
sizes: [0.016, 0.016]
@@ -0,0 +1,227 @@
"""Camera-extrinsic data model shared by calibration and runtime nodes."""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping, Sequence
import numpy as np
import yaml
from scipy.spatial.transform import Rotation
VIEWS: tuple[str, ...] = ("front", "side", "top")
def camera_info_fingerprint(
*,
width: int,
height: int,
camera_matrix: Sequence[Sequence[float]] | Sequence[float],
distortion: Sequence[float] = (),
rectification: Sequence[float] = (),
projection: Sequence[float] = (),
) -> str:
"""Return a stable fingerprint for rectified image geometry."""
matrix = np.asarray(camera_matrix, dtype=float).reshape(3, 3)
payload = {
"width": int(width),
"height": int(height),
"camera_matrix": [round(float(value), 12) for value in matrix.flat],
"distortion": [round(float(value), 12) for value in distortion],
"rectification": [round(float(value), 12) for value in rectification],
"projection": [round(float(value), 12) for value in projection],
}
encoded = json.dumps(
payload, sort_keys=True, separators=(",", ":")
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def transform_matrix(
translation_xyz_m: Sequence[float],
quaternion_xyzw: Sequence[float],
) -> np.ndarray:
translation = np.asarray(translation_xyz_m, dtype=float)
quaternion = np.asarray(quaternion_xyzw, dtype=float)
if translation.shape != (3,) or not np.all(np.isfinite(translation)):
raise ValueError("translation_xyz_m must contain three finite values")
if quaternion.shape != (4,) or not np.all(np.isfinite(quaternion)):
raise ValueError("quaternion_xyzw must contain four finite values")
norm = float(np.linalg.norm(quaternion))
if norm < 1.0e-12:
raise ValueError("quaternion_xyzw has zero norm")
result = np.eye(4, dtype=float)
result[:3, :3] = Rotation.from_quat(quaternion / norm).as_matrix()
result[:3, 3] = translation
return result
def matrix_payload(matrix: Sequence[Sequence[float]]) -> dict[str, list[float]]:
value = np.asarray(matrix, dtype=float)
if value.shape != (4, 4) or not np.all(np.isfinite(value)):
raise ValueError("transform must be a finite 4x4 matrix")
return {
"translation_xyz_m": [float(item) for item in value[:3, 3]],
"quaternion_xyzw": [
float(item) for item in Rotation.from_matrix(value[:3, :3]).as_quat()
],
}
@dataclass(frozen=True)
class CameraCalibrationIdentity:
serial_number: str
width: int
height: int
intrinsics_sha256: str
@dataclass(frozen=True)
class ThreeCameraExtrinsics:
"""Transforms points from each camera optical frame into front optical."""
cameras: Mapping[str, CameraCalibrationIdentity]
front_from_view: Mapping[str, np.ndarray]
quality: Mapping[str, float]
def transform(self, view: str) -> np.ndarray:
if view not in self.front_from_view:
raise KeyError(f"extrinsics do not contain view {view}")
return np.asarray(self.front_from_view[view], dtype=float).copy()
def camera_matches(
self,
view: str,
*,
serial_number: str,
width: int,
height: int,
intrinsics_sha256: str,
) -> bool:
expected = self.cameras.get(view)
return bool(
expected is not None
and expected.serial_number == str(serial_number)
and expected.width == int(width)
and expected.height == int(height)
and expected.intrinsics_sha256 == str(intrinsics_sha256)
)
def validate_extrinsics_payload(payload: Mapping[str, Any]) -> None:
if int(payload.get("schema_version", -1)) != 1:
raise ValueError("camera extrinsics schema_version must be 1")
if payload.get("reference_view") != "front":
raise ValueError("camera extrinsics reference_view must be front")
cameras = payload.get("cameras")
transforms = payload.get("front_from_view")
quality = payload.get("quality")
if not isinstance(cameras, Mapping) or set(cameras) != set(VIEWS):
raise ValueError("camera extrinsics must contain front/side/top cameras")
if not isinstance(transforms, Mapping) or set(transforms) != set(VIEWS):
raise ValueError("camera extrinsics must contain all three transforms")
if not isinstance(quality, Mapping) or not bool(quality.get("passed")):
raise ValueError("camera extrinsics quality is not passed")
quality_limits = {
"reprojection_rms_px": 1.2,
"maximum_rotation_repeatability_deg": 0.3,
"maximum_translation_repeatability_m": 0.0015,
}
for key, limit in quality_limits.items():
value = float(quality.get(key, float("inf")))
if not np.isfinite(value) or value > limit:
raise ValueError(
f"camera extrinsics {key}={value} exceeds {limit}"
)
for key in ("front_side_captures", "front_top_captures"):
if int(quality.get(key, 0)) < 15:
raise ValueError(f"camera extrinsics {key} must be at least 15")
for view in VIEWS:
identity = cameras[view]
if not isinstance(identity, Mapping):
raise ValueError(f"{view} camera identity must be an object")
if not str(identity.get("serial_number", "")):
raise ValueError(f"{view} camera serial_number is missing")
if int(identity.get("width", 0)) <= 0 or int(identity.get("height", 0)) <= 0:
raise ValueError(f"{view} camera image dimensions are invalid")
fingerprint = str(identity.get("intrinsics_sha256", ""))
if len(fingerprint) != 64:
raise ValueError(f"{view} camera intrinsics fingerprint is invalid")
transform = transforms[view]
if not isinstance(transform, Mapping):
raise ValueError(f"{view} transform must be an object")
matrix = transform_matrix(
transform.get("translation_xyz_m", ()),
transform.get("quaternion_xyzw", ()),
)
if view == "front" and not np.allclose(matrix, np.eye(4), atol=1.0e-9):
raise ValueError("front_from_view.front must be identity")
serials = [str(cameras[view]["serial_number"]) for view in VIEWS]
if len(set(serials)) != len(VIEWS):
raise ValueError("camera extrinsics serial numbers must be unique")
def load_three_camera_extrinsics(path: str | Path) -> ThreeCameraExtrinsics:
source = Path(path).expanduser().resolve()
if not source.is_file():
raise ValueError(f"camera extrinsics file does not exist: {source}")
with source.open("r", encoding="utf-8") as stream:
payload = yaml.safe_load(stream)
if not isinstance(payload, Mapping):
raise ValueError("camera extrinsics file must contain an object")
validate_extrinsics_payload(payload)
cameras = {
view: CameraCalibrationIdentity(
serial_number=str(payload["cameras"][view]["serial_number"]),
width=int(payload["cameras"][view]["width"]),
height=int(payload["cameras"][view]["height"]),
intrinsics_sha256=str(
payload["cameras"][view]["intrinsics_sha256"]
),
)
for view in VIEWS
}
transforms = {
view: transform_matrix(
payload["front_from_view"][view]["translation_xyz_m"],
payload["front_from_view"][view]["quaternion_xyzw"],
)
for view in VIEWS
}
return ThreeCameraExtrinsics(
cameras=cameras,
front_from_view=transforms,
quality={
str(key): float(value) if isinstance(value, (int, float)) else value
for key, value in payload["quality"].items()
},
)
def dump_three_camera_extrinsics(
path: str | Path,
*,
cameras: Mapping[str, Mapping[str, Any]],
front_from_view: Mapping[str, Sequence[Sequence[float]]],
quality: Mapping[str, Any],
) -> None:
payload = {
"schema_version": 1,
"reference_view": "front",
"cameras": {view: dict(cameras[view]) for view in VIEWS},
"front_from_view": {
view: matrix_payload(front_from_view[view]) for view in VIEWS
},
"quality": dict(quality),
}
validate_extrinsics_payload(payload)
destination = Path(path).expanduser().resolve()
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_suffix(destination.suffix + ".tmp")
with temporary.open("w", encoding="utf-8") as stream:
yaml.safe_dump(payload, stream, allow_unicode=True, sort_keys=False)
temporary.replace(destination)
@@ -2,7 +2,7 @@
The hardware node records one parent/child AprilTag trajectory for each
directly observable joint. This module deliberately contains no ROS imports:
curve fitting, four-finger splay centring, inheritance, and schema validation
curve fitting, baseline centring, inheritance, and schema validation
remain deterministic and unit-testable without connected cameras or a hand.
"""
@@ -14,7 +14,6 @@ from typing import Any, Mapping, Sequence
import numpy as np
from .core import BASELINE_COMMAND
from .trajectory import (
_angle_for_circle,
_fit_circle_with_axis,
@@ -31,6 +30,13 @@ from .zero_calibration import (
)
THREE_CAMERA_BASELINE_COMMAND: tuple[int, ...] = (
255, 255, 255, 255, 255, 255,
127, 127, 127, 127,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
)
@dataclass(frozen=True)
class JointSpec:
name: str
@@ -90,11 +96,11 @@ VIEW_TAGS: dict[str, dict[str, int]] = {
JOINT_SPECS: dict[str, JointSpec] = {
"thumb_cmc_pitch": JointSpec(
"thumb_cmc_pitch", 0, True, "front", "front_base", "thumb_cmc",
zero_kind="projected",
zero_kind="urdf_axis_chain",
),
"index_mcp_pitch": JointSpec(
"index_mcp_pitch", 1, True, "side", "side_base", "index_mcp",
zero_kind="projected",
zero_kind="urdf_axis_chain",
),
"middle_mcp_pitch": JointSpec(
"middle_mcp_pitch", 2, True, None, None, None,
@@ -110,11 +116,11 @@ JOINT_SPECS: dict[str, JointSpec] = {
),
"thumb_cmc_roll": JointSpec(
"thumb_cmc_roll", 5, True, "front", "front_base", "thumb_cmc",
zero_kind="projected",
zero_kind="urdf_axis_chain",
),
"index_mcp_roll": JointSpec(
"index_mcp_roll", 6, True, "front", "front_base", "index_roll",
zero_kind="travel_midpoint",
zero_kind="urdf_axis_chain",
),
"middle_mcp_roll": JointSpec(
"middle_mcp_roll", 7, True, None, None, None,
@@ -130,15 +136,15 @@ JOINT_SPECS: dict[str, JointSpec] = {
),
"thumb_cmc_yaw": JointSpec(
"thumb_cmc_yaw", 10, True, "top", "top_base", "thumb_yaw",
zero_kind="projected",
zero_kind="urdf_axis_chain",
),
"thumb_mcp": JointSpec(
"thumb_mcp", 15, True, "front", "thumb_cmc", "thumb_mcp",
zero_kind="projected",
zero_kind="urdf_axis_chain",
),
"index_pip": JointSpec(
"index_pip", 16, True, "side", "index_mcp", "index_pip",
zero_kind="projected",
zero_kind="urdf_axis_chain",
),
"middle_pip": JointSpec(
"middle_pip", 17, True, None, None, None,
@@ -203,8 +209,10 @@ SPLAY_JOINTS: tuple[str, ...] = (
# and a motion plane that is close to the active camera's image plane. Their
# projected circles are substantially more repeatable than the difference of
# two independently estimated planar-Tag PnP depths. thumb_ip remains projected
# because its front-view PnP depth is not repeatable enough for a 3-D fit; the
# side-view index_dip and oblique top-view yaw remain parent-relative 3-D.
# because its front-view PnP depth is not repeatable enough for a 3-D fit. The
# side-view index_dip and oblique top-view yaw remain parent-relative full
# SE(3) measurements; their circle directions are constrained by the more
# repeatable relative-orientation screw axis in urdf_zero.py.
IMAGE_TRAJECTORY_JOINTS: frozenset[str] = frozenset(
{
"thumb_cmc_pitch",
@@ -244,7 +252,7 @@ def calibration_auxiliary_commands(spec: SweepSpec) -> dict[int, int]:
def build_full_hand_command(
motor_index: int,
command_u8: int,
baseline: Sequence[int] = BASELINE_COMMAND,
baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
) -> list[int]:
if len(baseline) != 20:
raise ValueError("baseline must contain exactly 20 values")
@@ -264,7 +272,7 @@ def build_full_hand_command(
def build_calibration_motion_command(
spec: SweepSpec,
command_u8: int,
baseline: Sequence[int] = BASELINE_COMMAND,
baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
) -> list[int]:
"""Build a sweep command, including any required clearance pose."""
result = build_full_hand_command(
@@ -612,23 +620,20 @@ def build_compact_payload(
*,
serial_number: str,
measured_fits: Mapping[str, JointCurveFit],
projected_zeros_rad: Mapping[str, float],
splay_zero_command_u8: int,
splay_midpoint_rad: float,
urdf_zero_offsets_rad: Mapping[str, float],
validation_errors_rad: Sequence[float],
passed: bool,
baseline: Sequence[int] = BASELINE_COMMAND,
baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
) -> dict[str, Any]:
if set(measured_fits) != set(MEASURED_JOINTS):
raise ValueError("measured_fits must contain all directly measured joints")
expected_projected = {
name for name, spec in JOINT_SPECS.items()
if spec.zero_kind == "projected"
expected_active = {
name for name, spec in JOINT_SPECS.items() if spec.active
}
if set(projected_zeros_rad) != expected_projected:
raise ValueError("projected_zeros_rad has the wrong joint set")
if not 0 <= int(splay_zero_command_u8) <= 255:
raise ValueError("splay zero command must be in [0, 255]")
if set(urdf_zero_offsets_rad) != expected_active:
raise ValueError("urdf_zero_offsets_rad has the wrong active-joint set")
if len(baseline) != 20:
raise ValueError("baseline must contain exactly 20 commands")
joints: dict[str, dict[str, Any]] = {}
for name, spec in JOINT_SPECS.items():
@@ -639,32 +644,23 @@ def build_compact_payload(
"angle_rad": [round(float(value), 8) for value in fit.angle_rad],
}
if spec.active:
joint["zero_command_u8"] = (
int(splay_zero_command_u8)
if name in SPLAY_JOINTS
else 255
)
joint["zero_command_u8"] = int(baseline[spec.motor_index])
joint["zero_angles"] = {
"urdf_zero_offset_rad": round(
float(urdf_zero_offsets_rad[name]), 8
)
}
else:
joint["passive"] = True
if spec.source_joint is not None:
joint["source_joint"] = spec.source_joint
elif spec.zero_kind == "projected":
joint["zero_angles"] = {
"table_projected_zero_rad": round(
float(projected_zeros_rad[name]), 8
)
}
elif spec.zero_kind == "travel_midpoint":
joint["zero_angles"] = {
"travel_midpoint_rad": round(float(splay_midpoint_rad), 8)
}
joints[name] = joint
errors = np.abs(np.asarray(validation_errors_rad, dtype=float))
mae = float(np.mean(errors)) if errors.size else float("nan")
p95 = float(np.percentile(errors, 95.0)) if errors.size else float("nan")
payload = {
"schema_version": 3,
"schema_version": 4,
"model": "G20",
"side": "left",
"serial_number": str(serial_number),
@@ -693,8 +689,8 @@ def validate_compact_payload(payload: Mapping[str, Any]) -> None:
}
if set(payload) != expected_top:
raise ValueError("compact calibration has unexpected top-level fields")
if payload["schema_version"] != 3:
raise ValueError("schema_version must be 3")
if payload["schema_version"] != 4:
raise ValueError("schema_version must be 4")
if payload["model"] != "G20" or payload["side"] != "left":
raise ValueError("payload must describe a left G20")
if payload["angle_unit"] != "rad" or payload["command_range"] != [0, 255]:
@@ -710,10 +706,10 @@ def validate_compact_payload(payload: Mapping[str, Any]) -> None:
joint = joints[name]
allowed = {"motor_index", "angle_rad"}
allowed.add("zero_command_u8" if spec.active else "passive")
if spec.active:
allowed.add("zero_angles")
if spec.source_joint is not None:
allowed.add("source_joint")
elif spec.zero_kind in {"projected", "travel_midpoint"}:
allowed.add("zero_angles")
if set(joint) != allowed:
raise ValueError(f"{name} has unexpected fields")
if int(joint["motor_index"]) != spec.motor_index:
@@ -727,6 +723,20 @@ def validate_compact_payload(payload: Mapping[str, Any]) -> None:
zero = joint["zero_command_u8"]
if not isinstance(zero, int) or not 0 <= zero <= 255:
raise ValueError(f"{name}.zero_command_u8 is invalid")
zero_angles = joint.get("zero_angles")
if not isinstance(zero_angles, Mapping) or set(zero_angles) != {
"urdf_zero_offset_rad"
}:
raise ValueError(
f"{name}.zero_angles must contain urdf_zero_offset_rad"
)
offset = float(zero_angles["urdf_zero_offset_rad"])
if not math.isfinite(offset):
raise ValueError(f"{name}.urdf_zero_offset_rad is invalid")
if abs(float(curve[zero])) > 1.0e-6:
raise ValueError(
f"{name}.angle_rad must be zero at zero_command_u8"
)
elif joint.get("passive") is not True:
raise ValueError(f"{name} must be marked passive")
if spec.source_joint is not None:
@@ -737,17 +747,8 @@ def validate_compact_payload(payload: Mapping[str, Any]) -> None:
raise ValueError(f"{name} must copy its source curve exactly")
if spec.active and joint["zero_command_u8"] != source["zero_command_u8"]:
raise ValueError(f"{name} must copy its source zero command")
index_roll = np.asarray(joints["index_mcp_roll"]["angle_rad"], dtype=float)
if not index_roll[0] > 0.0 or not index_roll[255] < 0.0:
raise ValueError("index_mcp_roll endpoints must be positive then negative")
if abs(float(index_roll[0] + index_roll[255])) > 1.0e-7:
raise ValueError("index_mcp_roll endpoints must be symmetric")
for name, spec in JOINT_SPECS.items():
if name not in SPLAY_JOINTS and spec.source_joint != "index_mcp_roll":
curve = np.asarray(joints[name]["angle_rad"], dtype=float)
if abs(float(curve[255])) > 1.0e-6:
raise ValueError(f"{name}.angle_rad[255] must be zero")
if spec.active and joint["zero_angles"] != source["zero_angles"]:
raise ValueError(f"{name} must copy its source zero offset")
quality = payload["quality"]
if set(quality) != {
@@ -30,6 +30,24 @@ def append_jsonl(path: str | Path, payload: Mapping[str, Any]) -> None:
os.fsync(stream.fileno())
def append_jsonl_many(
path: str | Path, payloads: Iterable[Mapping[str, Any]]
) -> None:
"""Durably append a batch while paying the fsync cost only once."""
destination = Path(path)
destination.parent.mkdir(parents=True, exist_ok=True)
lines = [
json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
for payload in payloads
]
if not lines:
return
with destination.open("a", encoding="utf-8") as stream:
stream.write("\n".join(lines) + "\n")
stream.flush()
os.fsync(stream.fileno())
def load_jsonl(path: str | Path) -> list[dict[str, Any]]:
source = Path(path)
if not source.exists():
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
from typing import Any, Mapping
@@ -92,6 +93,27 @@ def three_camera_reason_zh(
)
tolerance = sample.get("endpoint_tolerance_u8", "?")
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 (
@@ -159,6 +181,16 @@ def three_camera_reason_zh(
"monotonic_correction_deg": "最大单调修正",
"hysteresis_deg": "最大正反程差",
"cycle_travel_range_deg": "三轮行程差",
"rotation_orthogonal_rms_deg": "三维旋转轴外残差RMS",
"axis_plane_rms_mm": "三维圆轴向RMS",
"axis_radial_rms_mm": "三维圆半径RMS",
"rotation_circle_axis_difference_deg": "姿态轴与圆轨迹轴夹角",
"axis_cycle_difference_deg": "三轮转轴方向极差",
"third_cycle_axis_holdout_deg": "第三轮留出零位可观测轴向误差",
"third_cycle_axis_line_rms_mm": "第三轮留出轴线RMS",
"third_cycle_trajectory_p95_deg": "第三轮留出轨迹误差P95",
"state_image_sync_p95_ms": "图像与电机状态同步误差P95",
"tag_valid_rate_percent": "所需Tag同时有效率",
}
metric_units = {
"plane_rms_mm": "mm",
@@ -171,6 +203,16 @@ def three_camera_reason_zh(
"monotonic_correction_deg": "°",
"hysteresis_deg": "°",
"cycle_travel_range_deg": "°",
"rotation_orthogonal_rms_deg": "°",
"axis_plane_rms_mm": "mm",
"axis_radial_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": "°",
"state_image_sync_p95_ms": "ms",
"tag_valid_rate_percent": "%",
}
details: list[str] = []
for failure in active.get("failures", []):
@@ -209,7 +251,7 @@ def three_camera_reason_zh(
)
if reason in {"waiting_for_three_cameras_tags_and_sdk", "preflight_lost"}:
return (
"正在等待三台相机内参、帧率、全部必需Tag以及机械手SDK同时就绪。",
"正在等待三台相机内参、外参身份匹配、帧率、全部必需Tag以及机械手SDK同时就绪。",
"根据下面每个机位的缺失Tag和有效率排查;全部就绪后程序会进入等待开始状态。",
)
if reason == "call_start":
@@ -226,7 +268,7 @@ def three_camera_reason_zh(
if reason == "capturing_random_validation_pose":
return "正在当前随机命令位置采集复测数据。", "无需操作,保持设备不动。"
if reason in {"calibration_passed", "calibration_complete"}:
return "轨迹、零位和随机复测已经完成。", "检查结果路径和quality.passed。"
return "三维轨迹、关节轴零位和第三轮留出验证已经完成。", "检查JSON、修正URDF路径和quality.passed。"
if reason == "quality_failed":
return "标定流程完成,但拟合或随机复测质量没有达到验收阈值。", "检查最终JSON的quality以及启动终端中的拟合日志。"
if reason.startswith("prepare_") or state == "PREPARE_SWEEP":
@@ -234,7 +276,7 @@ def three_camera_reason_zh(
if state == "RETURN_BASELINE":
return "正在把已使用的标定电机恢复到统一基准命令。", "无需操作。"
if state == "FITTING":
return "所有扫描已经完成,正在拟合21个关节的轨迹和零位", "无需操作。"
return "所有扫描已经完成,正在联合拟合三维机械轴和URDF零位偏移", "无需操作。"
return f"未分类原因码:{reason}", "保留该原因码和启动终端日志用于进一步定位。"
@@ -298,9 +340,16 @@ def render_three_camera_status_text_zh(payload: Mapping[str, Any]) -> str:
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}"
)
lines.append(f"结果:{payload.get('result_path') or '尚未生成'}")
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)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -180,7 +180,8 @@ def _launch_stack(context):
LaunchConfiguration("calibration_speed"), value_type=int
),
"startup_torque": 80,
"state_poll_rate": 10.0,
# Match 30 Hz cameras so state/image p95 skew stays below 50 ms.
"state_poll_rate": 30.0,
"repeat_position_commands": False,
"is_touch": False,
}
@@ -197,6 +198,17 @@ def _launch_stack(context):
{
"serial_number": hand_serial,
"session_dir": str(session_dir),
"camera_extrinsics_file": LaunchConfiguration(
"camera_extrinsics_file"
),
"source_urdf_path": LaunchConfiguration("source_urdf_path"),
"corrected_urdf_output_dir": LaunchConfiguration(
"corrected_urdf_output_dir"
),
**{
f"{view}_camera_serial": camera_serials[view]
for view in VIEWS
},
"commands_enabled": ParameterValue(
LaunchConfiguration("commands_enabled"), value_type=bool
),
@@ -263,6 +275,25 @@ def generate_launch_description() -> LaunchDescription:
get_package_share_directory("g20_thumb_apriltag_calibration")
)
info_root = Path.home() / ".ros" / "camera_info"
workspace_source_urdf = (
Path.cwd()
/ "src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/"
"linker_hand/g20_left/linkerhand_g20_left.urdf"
)
try:
linkerhand_share = Path(
get_package_share_directory("linkerhand_retarget")
)
installed_source_urdf = linkerhand_share / (
"assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left.urdf"
)
except Exception:
installed_source_urdf = workspace_source_urdf
default_source_urdf = (
workspace_source_urdf
if workspace_source_urdf.is_file()
else installed_source_urdf
)
return LaunchDescription(
[
SetEnvironmentVariable(
@@ -315,6 +346,18 @@ def generate_launch_description() -> LaunchDescription:
"index_flex_calibration_speed", default_value="10"
),
DeclareLaunchArgument("validation_enabled", default_value="false"),
DeclareLaunchArgument(
"camera_extrinsics_file",
default_value=str(
Path.cwd() / "config" / "g20_three_camera_extrinsics.yaml"
),
),
DeclareLaunchArgument(
"source_urdf_path", default_value=str(default_source_urdf)
),
DeclareLaunchArgument(
"corrected_urdf_output_dir", default_value=""
),
DeclareLaunchArgument("commands_enabled", default_value="true"),
DeclareLaunchArgument("start_cameras", default_value="true"),
DeclareLaunchArgument("start_sdk", default_value="true"),
@@ -0,0 +1,177 @@
"""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="g20_thumb_apriltag_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="g20_thumb_apriltag_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,
),
"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("g20_thumb_apriltag_calibration")
)
camera_info = Path.home() / ".ros" / "camera_info"
return LaunchDescription(
[
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("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),
]
)
@@ -47,6 +47,10 @@ setup(
"three_camera_calibration_node = "
"g20_thumb_apriltag_calibration.three_camera_node:main"
),
(
"three_camera_extrinsics_node = "
"g20_thumb_apriltag_calibration.extrinsics_node:main"
),
(
"camera_alignment_view = "
"g20_thumb_apriltag_calibration.alignment_view:main"
@@ -57,6 +57,8 @@ def test_three_camera_tag_ids_and_topics_are_disjoint() -> None:
key = f"/g20_calibration/{view}/apriltag/apriltag"
parameters = tags[key]["ros__parameters"]
assert parameters["tag"]["ids"] == ids
assert parameters["size"] == 0.016
assert parameters["tag"]["sizes"] == [0.016] * len(ids)
assert parameters["qos_profile"] == "sensor_data"
assert parameters["detector"]["decimate"] == 1.5
all_ids.update(ids)
@@ -69,6 +71,8 @@ def test_three_camera_calibration_has_hard_preflight_and_three_rounds() -> None:
)
parameters = config["g20_calibration"]["ros__parameters"]
assert parameters["tag_size_m"] == 0.016
assert parameters["baseline_command_u8"] == [
255,
255,
@@ -102,10 +106,18 @@ def test_three_camera_calibration_has_hard_preflight_and_three_rounds() -> None:
assert parameters["minimum_detection_rate"] == 0.95
assert parameters["minimum_detection_hz"] == 15.0
assert parameters["minimum_state_span_u8"] >= 240.0
assert parameters["endpoint_tolerance_u8"] == 2.0
assert parameters["thumb_yaw_zero_endpoint_tolerance_u8"] == 3.0
assert parameters["minimum_sweep_bins"] >= 32
assert parameters["maximum_bin_gap"] <= 16
assert parameters["position_timeout_seconds"] >= 20.0
assert parameters["zero_maximum_round_difference_deg"] <= 1.0
assert parameters["maximum_state_image_skew_ms"] <= 50.0
assert parameters["axis_maximum_rotation_circle_difference_deg"] <= 1.0
assert parameters["passive_axis_maximum_plane_rms_m"] == 0.004
assert parameters["active_maximum_rotation_orthogonal_rms_deg"] == 2.5
assert parameters["passive_maximum_rotation_orthogonal_rms_deg"] == 7.5
assert parameters["zero_maximum_axis_cycle_difference_deg"] <= 0.75
assert parameters["zero_maximum_offset_deg"] <= 20.0
assert parameters["image_trajectory_maximum_radial_rms_px"] <= 2.0
assert parameters["image_trajectory_maximum_radial_p95_px"] <= 3.5
assert parameters["image_trajectory_minimum_radius_px"] >= 20.0
@@ -0,0 +1,194 @@
"""Focused tests for checkerboard frame pairing."""
import cv2
import numpy as np
from scipy.spatial.transform import Rotation
from g20_thumb_apriltag_calibration.extrinsics_node import (
BoardPose,
StereoCapture,
_fit_stereo_robust,
_minimum_history_skew_ns,
_select_latest_synchronised_pair,
_summarize_transform_repeatability,
)
def _pose(stamp_ns: int) -> BoardPose:
return BoardPose(
stamp_ns=stamp_ns,
camera_from_board_candidates=(),
reprojection_rms_px=0.1,
)
def test_pairing_uses_recent_history_instead_of_mismatched_latest_frames():
front = [_pose(1_000_000_000), _pose(1_200_000_000)]
side = [_pose(1_030_000_000), _pose(1_370_000_000)]
pair = _select_latest_synchronised_pair(front, side, 100_000_000)
assert pair is not None
selected_front, selected_side, skew = pair
assert selected_front.stamp_ns == 1_000_000_000
assert selected_side.stamp_ns == 1_030_000_000
assert skew == 30_000_000
def test_pairing_prefers_newest_valid_common_pair():
front = [_pose(1_000_000_000), _pose(1_200_000_000)]
side = [_pose(1_010_000_000), _pose(1_240_000_000)]
pair = _select_latest_synchronised_pair(front, side, 100_000_000)
assert pair is not None
selected_front, selected_side, skew = pair
assert selected_front.stamp_ns == 1_200_000_000
assert selected_side.stamp_ns == 1_240_000_000
assert skew == 40_000_000
def test_pairing_reports_minimum_skew_when_no_pair_passes():
front = [_pose(1_000_000_000), _pose(1_200_000_000)]
side = [_pose(1_370_000_000)]
assert _select_latest_synchronised_pair(
front, side, 100_000_000
) is None
assert _minimum_history_skew_ns(front, side) == 170_000_000
def _transform(rotation_deg: float, translation_m: float) -> np.ndarray:
value = np.eye(4)
value[:3, :3] = Rotation.from_euler(
"z", rotation_deg, degrees=True
).as_matrix()
value[0, 3] = translation_m
return value
def test_repeatability_summary_accepts_consistent_capture_set():
captures = [
[_transform(-0.1, -0.0005)],
[_transform(0.0, 0.0)],
[_transform(0.1, 0.0005)],
]
_, selected, rotation_error, translation_error = (
_summarize_transform_repeatability(captures)
)
assert len(selected) == 3
np.testing.assert_allclose(
np.degrees(rotation_error), 0.1, atol=1.0e-6
)
np.testing.assert_allclose(translation_error, 0.0005, atol=1.0e-9)
def test_repeatability_summary_exposes_current_capture_outlier():
captures = [
[_transform(0.0, 0.0)],
[_transform(0.1, 0.0005)],
[_transform(1.0, 0.008)],
]
_, _, rotation_error, translation_error = (
_summarize_transform_repeatability(captures)
)
assert np.degrees(rotation_error) > 0.3
assert translation_error > 0.0015
def test_joint_stereo_fit_recovers_transform_and_rejects_bad_view():
random = np.random.default_rng(7)
object_points = np.zeros((40, 3), dtype=np.float32)
object_points[:, :2] = (
np.mgrid[0:8, 0:5].T.reshape(-1, 2) * 0.027
)
matrix = np.asarray(
[[1800.0, 0.0, 812.0], [0.0, 1795.0, 620.0], [0.0, 0.0, 1.0]]
)
other_from_front = np.eye(4)
other_from_front[:3, :3] = Rotation.from_euler(
"xyz", [2.0, 18.0, -1.0], degrees=True
).as_matrix()
other_from_front[:3, 3] = [0.20, -0.01, 0.04]
expected_front_from_other = np.linalg.inv(other_from_front)
captures = []
for index in range(21):
front_from_board = np.eye(4)
front_from_board[:3, :3] = Rotation.from_euler(
"xyz",
[
-8.0 + index * 0.7,
5.0 + (index % 5) * 2.0,
-5.0 + (index % 4) * 3.0,
],
degrees=True,
).as_matrix()
front_from_board[:3, 3] = [
-0.08 + (index % 5) * 0.035,
-0.04 + (index % 4) * 0.025,
0.75 + (index % 3) * 0.08,
]
other_from_board = other_from_front @ front_from_board
front_rvec = Rotation.from_matrix(
front_from_board[:3, :3]
).as_rotvec()
other_rvec = Rotation.from_matrix(
other_from_board[:3, :3]
).as_rotvec()
front_points, _ = cv2.projectPoints(
object_points,
front_rvec,
front_from_board[:3, 3],
matrix,
np.zeros(5),
)
other_points, _ = cv2.projectPoints(
object_points,
other_rvec,
other_from_board[:3, 3],
matrix,
np.zeros(5),
)
front_points = front_points.reshape(-1, 2)
other_points = other_points.reshape(-1, 2)
front_points += random.normal(0.0, 0.12, front_points.shape)
other_points += random.normal(0.0, 0.12, other_points.shape)
if index == 20:
other_points += random.normal(0.0, 4.0, other_points.shape)
captures.append(
StereoCapture(
front_points_px=front_points.astype(np.float32),
other_points_px=other_points.astype(np.float32),
transform_candidates=(expected_front_from_other.copy(),),
pair_reprojection_rms_px=0.2,
skew_ns=10_000_000,
)
)
result = _fit_stereo_robust(
captures,
object_points,
matrix,
matrix,
(1624, 1240),
minimum_inliers=15,
maximum_rms_px=1.2,
maximum_rotation_stability_rad=np.deg2rad(0.3),
maximum_translation_stability_m=0.0015,
)
assert result.passed
assert 20 in result.rejected_indices
assert len(result.inlier_indices) >= 15
rotation_error = Rotation.from_matrix(
result.front_from_other[:3, :3]
).inv() * Rotation.from_matrix(expected_front_from_other[:3, :3])
assert np.degrees(rotation_error.magnitude()) < 0.05
assert np.linalg.norm(
result.front_from_other[:3, 3]
- expected_front_from_other[:3, 3]
) < 0.001
@@ -1,4 +1,5 @@
import math
from dataclasses import replace
import numpy as np
import pytest
@@ -257,24 +258,28 @@ def test_splay_uses_angular_midpoint_not_fixed_command_midpoint() -> None:
def test_compact_payload_contains_only_runtime_fields_and_inheritance() -> None:
base = fit_joint_center_curve(_records())
splay, zero_command, midpoint = center_splay_curve(base)
splay, zero_command, _ = center_splay_curve(base)
splay = replace(
splay,
angle_rad=tuple(
value - splay.angle_rad[zero_command]
for value in splay.angle_rad
),
)
measured = {
name: splay if name == "index_mcp_roll" else base
for name in MEASURED_JOINTS
}
projected = {
name: 0.01 * index
for index, (name, spec) in enumerate(JOINT_SPECS.items())
if spec.zero_kind == "projected"
}
offsets = {name: 0.01 for name in ACTIVE_JOINTS}
baseline = [255] * 20
baseline[6:10] = [zero_command] * 4
payload = build_compact_payload(
serial_number="G20_LEFT_001",
measured_fits=measured,
projected_zeros_rad=projected,
splay_zero_command_u8=zero_command,
splay_midpoint_rad=midpoint,
urdf_zero_offsets_rad=offsets,
validation_errors_rad=[0.01, -0.02],
passed=True,
baseline=baseline,
)
validate_compact_payload(payload)
assert set(payload) == {
@@ -298,7 +303,7 @@ def test_compact_payload_contains_only_runtime_fields_and_inheritance() -> None:
== payload["joints"]["index_mcp_roll"]["angle_rad"]
)
assert payload["joints"]["index_mcp_roll"]["zero_angles"] == {
"travel_midpoint_rad": pytest.approx(0.35, abs=1.0e-4)
"urdf_zero_offset_rad": pytest.approx(0.01)
}
for name in SPLAY_JOINTS:
assert payload["joints"][name]["zero_command_u8"] == zero_command
@@ -306,25 +311,29 @@ def test_compact_payload_contains_only_runtime_fields_and_inheritance() -> None:
def test_compact_payload_allows_skipped_random_validation() -> None:
base = fit_joint_center_curve(_records())
splay, zero_command, midpoint = center_splay_curve(base)
splay, zero_command, _ = center_splay_curve(base)
splay = replace(
splay,
angle_rad=tuple(
value - splay.angle_rad[zero_command]
for value in splay.angle_rad
),
)
measured = {
name: splay if name == "index_mcp_roll" else base
for name in MEASURED_JOINTS
}
projected = {
name: 0.0
for name, spec in JOINT_SPECS.items()
if spec.zero_kind == "projected"
}
offsets = {name: 0.0 for name in ACTIVE_JOINTS}
baseline = [255] * 20
baseline[6:10] = [zero_command] * 4
payload = build_compact_payload(
serial_number="G20_LEFT_001",
measured_fits=measured,
projected_zeros_rad=projected,
splay_zero_command_u8=zero_command,
splay_midpoint_rad=midpoint,
urdf_zero_offsets_rad=offsets,
validation_errors_rad=[],
passed=True,
baseline=baseline,
)
validate_compact_payload(payload)
@@ -174,6 +174,32 @@ def test_status_numeric_diagnostics_are_json_serializable() -> None:
assert '"maximum_bin_gap": 239' in encoded
def test_urdf_zero_bound_has_specific_chinese_scale_guidance() -> None:
payload = {
"state": "PAUSED",
"reason": (
"URDF zero offset reached the configured 20.000 degree bound: "
"thumb_cmc_roll=+20.000deg, index_pip=-20.000deg; "
"all_offsets: thumb_cmc_roll=+20.000deg"
),
"progress": 1.0,
"completed_sweeps": 42,
"total_sweeps": 42,
"active": {},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "触及±20.000°安全边界" in text
assert "拇指CMC滚转=+20.000deg" in text
assert "食指PIP=-20.000deg" in text
assert "不要调用resume" in text
assert "Tag有效黑框边长" in text
assert "未分类原因码" not in text
def test_index_roll_status_prints_clearance_motor_feedback() -> None:
payload = {
"state": "SWEEP",
@@ -1,14 +1,21 @@
import json
import math
from dataclasses import replace
from types import SimpleNamespace
import numpy as np
from scipy.spatial.transform import Rotation
from g20_thumb_apriltag_calibration.core import (
DIRECTION_DECREASING,
DIRECTION_INCREASING,
)
from g20_thumb_apriltag_calibration.full_hand import SWEEP_SPECS
from g20_thumb_apriltag_calibration.three_camera_node import (
FrameObservation,
G20ThreeCameraCalibrationNode,
STATE_PREPARE_SWEEP,
STATE_SWEEP,
SweepItem,
)
@@ -22,23 +29,176 @@ def _sweep_items() -> list[SweepItem]:
]
def _image_cycle_records(travels_rad: list[float]) -> list[dict]:
def _frame(view: str, state_u8: list[float]) -> FrameObservation:
return FrameObservation(
stamp_ns=1,
received_at=2.0,
view=view,
state_u8=tuple(state_u8),
state_sync_error_ns=0,
joint_vectors_xyz_m={},
image_vectors_xy_px={},
joint_quaternions_xyzw={},
parent_poses_common={},
child_poses_common={},
joint_reprojection_error_px={},
)
def test_prepare_sweep_retains_synchronised_start_endpoint_frame() -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 6)
item = SweepItem(spec, 2, DIRECTION_DECREASING)
baseline = [255] * 20
endpoint_state = list(baseline)
endpoint_state[6] = 253.0
endpoint_state[7:10] = [0.0, 0.0, 0.0]
outside_state = list(endpoint_state)
outside_state[6] = 252.0
node = SimpleNamespace(
state=STATE_PREPARE_SWEEP,
active_sweep=item,
baseline_command=tuple(baseline),
endpoint_tolerance_u8=2.0,
preflight_frames=30,
latest_state_u8=tuple(endpoint_state),
sweep_start_frames=[],
)
node._motion_command_reached = lambda selected, command, state=None: (
G20ThreeCameraCalibrationNode._motion_command_reached(
node, selected, command, state
)
)
node._endpoint_tolerance_for_spec = lambda selected, endpoint: (
G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
node, selected, endpoint
)
)
endpoint_frame = _frame("front", endpoint_state)
G20ThreeCameraCalibrationNode._accept_frame(node, endpoint_frame)
G20ThreeCameraCalibrationNode._accept_frame(
node, _frame("front", outside_state)
)
assert node.sweep_start_frames == [endpoint_frame]
def test_begin_sweep_carries_start_endpoint_frame_into_sweep() -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 6)
item = SweepItem(spec, 2, DIRECTION_DECREASING)
baseline = [255] * 20
endpoint_state = list(baseline)
endpoint_state[6] = 253.0
endpoint_state[7:10] = [0.0, 0.0, 0.0]
endpoint_frame = _frame("front", endpoint_state)
published: list[list[int]] = []
node = SimpleNamespace(
active_sweep=item,
baseline_command=tuple(baseline),
sweep_frames=[],
sweep_start_frames=[endpoint_frame],
_publish_command=lambda command: published.append(command),
)
G20ThreeCameraCalibrationNode._begin_active_sweep(node, 10.0)
assert node.state == STATE_SWEEP
assert node.sweep_frames == [endpoint_frame]
assert node.sweep_start_frames == []
assert node.sweep_last_valid_at == 10.0
assert published[0][6] == 0
assert published[0][7:10] == [0, 0, 0]
def test_only_thumb_yaw_uses_three_count_endpoint_deadband() -> None:
yaw = next(item for item in SWEEP_SPECS if item.motor_index == 10)
index_roll = next(item for item in SWEEP_SPECS if item.motor_index == 6)
node = SimpleNamespace(
endpoint_tolerance_u8=2.0,
thumb_yaw_zero_endpoint_tolerance_u8=3.0,
)
assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
node, yaw, 0
) == 3.0
assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
node, yaw, 255
) == 2.0
assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
node, index_roll, 0
) == 2.0
def test_thumb_yaw_command_zero_accepts_feedback_three_only_for_swept_motor() -> None:
yaw = next(item for item in SWEEP_SPECS if item.motor_index == 10)
state = [255.0] * 20
state[10] = 3.0
state[5] = 143.0
node = SimpleNamespace(
latest_state_u8=tuple(state),
baseline_command=tuple([255] * 20),
endpoint_tolerance_u8=2.0,
thumb_yaw_zero_endpoint_tolerance_u8=3.0,
)
node._endpoint_tolerance_for_spec = lambda spec, endpoint: (
G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
node, spec, endpoint
)
)
assert G20ThreeCameraCalibrationNode._motion_command_reached(
node, yaw, 0, tuple(state)
)
state[5] = 142.0
assert not G20ThreeCameraCalibrationNode._motion_command_reached(
node, yaw, 0, tuple(state)
)
def _image_cycle_records(
travels_rad: list[float], *, depth_slope: float = 0.0
) -> list[dict]:
commands = list(range(0, 256, 16)) + [255]
records = []
for cycle, travel in enumerate(travels_rad):
for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING):
for command in commands:
angle = travel * (255.0 - command) / 255.0
x = 0.03 * math.cos(angle)
y = 0.03 * math.sin(angle)
z = float(depth_slope) * (x - 0.03)
records.append(
{
"cycle": cycle,
"direction": direction,
"command_u8": command,
"relative_translation_xyz_m": [0.01, 0.0, 0.0],
"relative_translation_xyz_m": [
x,
y,
z,
],
"image_relative_xy_px": [
100.0 * math.cos(angle),
100.0 * math.sin(angle),
],
"relative_quaternion_xyzw": Rotation.from_rotvec(
np.asarray([0.0, 0.0, angle])
).as_quat().tolist(),
"parent_pose_common": {
"translation_xyz_m": [0.0, 0.0, 0.0],
"quaternion_xyzw": [0.0, 0.0, 0.0, 1.0],
},
"child_pose_common": {
"translation_xyz_m": [
x,
y,
z,
],
"quaternion_xyzw": Rotation.from_rotvec(
np.asarray([0.0, 0.0, angle])
).as_quat().tolist(),
},
"state_u8": [float(command)] + [255.0] * 19,
}
)
return records
@@ -61,16 +221,23 @@ def _fit_check_node(records_by_joint: dict) -> SimpleNamespace:
maximum_hysteresis_rad=math.radians(5.0),
passive_maximum_monotonic_correction_rad=math.radians(3.0),
passive_maximum_hysteresis_rad=math.radians(7.5),
zero_minimum_radius_px=20.0,
zero_maximum_radial_rms_px=2.0,
zero_maximum_radial_p95_px=3.5,
zero_maximum_round_difference_rad=math.radians(1.0),
baseline_command=[255] * 20,
axis_maximum_plane_rms_m=0.003,
passive_axis_maximum_plane_rms_m=0.004,
axis_maximum_radial_rms_m=0.003,
axis_maximum_rotation_circle_difference_rad=math.radians(1.0),
active_maximum_rotation_orthogonal_rms_rad=math.radians(2.5),
passive_maximum_rotation_orthogonal_rms_rad=math.radians(7.5),
zero_maximum_axis_cycle_difference_rad=math.radians(0.75),
)
node._fit_joint_records = lambda name, records, relaxed=False: (
G20ThreeCameraCalibrationNode._fit_joint_records(
node, name, records, relaxed=relaxed
)
)
node._fit_axis_measurement = lambda name, cycle: (
G20ThreeCameraCalibrationNode._fit_axis_measurement(node, name, cycle)
)
return node
@@ -80,6 +247,7 @@ def test_fit_failure_rewinds_to_failed_specs_first_direction(tmp_path) -> None:
sweep_items=_sweep_items(),
sweep_attempts={spec.motor_index: 1},
retry_sweep_spec=None,
retry_resume_index=None,
fit_failure={},
sweep_index=24,
raw_path=tmp_path / "raw_samples.jsonl",
@@ -133,7 +301,7 @@ def test_resume_discards_only_failed_specs_samples(tmp_path) -> None:
assert records["thumb_ip"] == []
assert records["thumb_cmc_pitch"] == [{"keep": True}]
assert node.sweep_attempts[15] == 2
assert node.retry_sweep_spec is None
assert node.retry_sweep_spec == spec
assert node.fit_failure == {}
event = json.loads((tmp_path / "raw_samples.jsonl").read_text())
assert event == {
@@ -168,3 +336,127 @@ def test_provisional_fit_rejects_inconsistent_cycle_travel() -> None:
assert cycle_failure["joint"] == "thumb_cmc_pitch"
assert cycle_failure["actual"] == 8.0
assert cycle_failure["limit"] == 3.0
def test_provisional_fit_accepts_constrained_circle_for_image_joint() -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 0)
node = _fit_check_node(
{
"thumb_cmc_pitch": _image_cycle_records(
[math.radians(47.0)] * 3,
depth_slope=2.00,
)
}
)
failures = G20ThreeCameraCalibrationNode._provisional_fit_failures(
node, spec
)
assert not [
failure
for failure in failures
if failure["metric"]
in {
"rotation_circle_axis_difference_deg",
"axis_cycle_difference_deg",
"axis_plane_rms_mm",
"axis_radial_rms_mm",
}
]
def test_thumb_ip_axis_uses_same_cycle_thumb_mcp_direction() -> None:
records = _image_cycle_records([math.radians(47.0)] * 3)
node = _fit_check_node(
{
"thumb_cmc_pitch": records,
"thumb_mcp": records,
"thumb_ip": records,
}
)
upstream = node._fit_axis_measurement("thumb_mcp", 0)
passive = node._fit_axis_measurement("thumb_ip", 0)
assert abs(
float(
np.asarray(upstream.axis_common_xyz)
@ np.asarray(passive.axis_common_xyz)
)
) > math.cos(math.radians(0.01))
def test_provisional_fit_uses_orientation_constrained_circle_for_yaw() -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 10)
node = _fit_check_node(
{
"thumb_cmc_yaw": _image_cycle_records(
[math.radians(47.0)] * 3,
depth_slope=0.30,
)
}
)
failures = G20ThreeCameraCalibrationNode._provisional_fit_failures(
node, spec
)
assert not [
failure
for failure in failures
if failure["metric"]
in {
"rotation_circle_axis_difference_deg",
"axis_cycle_difference_deg",
"axis_plane_rms_mm",
"axis_radial_rms_mm",
}
]
def test_provisional_fit_uses_active_and_passive_axis_model_limits() -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 15)
records = _image_cycle_records([math.radians(47.0)] * 3)
node = _fit_check_node(
{
"thumb_mcp": records,
"thumb_ip": records,
}
)
residuals_deg = {"thumb_mcp": 2.0, "thumb_ip": 6.0}
def fitted(name, selected, relaxed=False):
fit = G20ThreeCameraCalibrationNode._fit_joint_records(
node, name, selected, relaxed=relaxed
)
quality = dict(fit.quality)
quality["rotation_orthogonal_rms_rad"] = math.radians(
residuals_deg[name]
)
return replace(fit, quality=quality)
node._fit_joint_records = fitted
failures = G20ThreeCameraCalibrationNode._provisional_fit_failures(
node, spec
)
assert not [
failure
for failure in failures
if failure["metric"] == "rotation_orthogonal_rms_deg"
]
residuals_deg["thumb_mcp"] = 3.0
residuals_deg["thumb_ip"] = 8.0
failures = G20ThreeCameraCalibrationNode._provisional_fit_failures(
node, spec
)
axis_failures = [
failure
for failure in failures
if failure["metric"] == "rotation_orthogonal_rms_deg"
]
assert {(item["joint"], item["limit"]) for item in axis_failures} == {
("thumb_mcp", 2.5),
("thumb_ip", 7.5),
}
@@ -0,0 +1,550 @@
import math
from pathlib import Path
import xml.etree.ElementTree as ET
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from g20_thumb_apriltag_calibration.extrinsics import (
camera_info_fingerprint,
dump_three_camera_extrinsics,
load_three_camera_extrinsics,
)
from g20_thumb_apriltag_calibration.urdf_zero import (
AXIS_JOINTS,
DIRECT_ZERO_JOINTS,
INHERITED_ZERO_JOINTS,
JointAxisMeasurement,
UrdfKinematicModel,
_angles_from_state,
_zero_sensitive_axis_error_rad,
fit_joint_axis_measurement,
fit_rotation_joint_curve,
solve_urdf_zero_offsets,
write_zero_corrected_urdf,
)
from g20_thumb_apriltag_calibration.full_hand import (
ACTIVE_JOINTS,
JOINT_SPECS,
MEASURED_JOINTS,
PASSIVE_JOINTS,
JointCurveFit,
)
REPOSITORY = Path(__file__).resolve().parents[3]
SOURCE_URDF = REPOSITORY / (
"src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/"
"linker_hand/g20_left/linkerhand_g20_left.urdf"
)
def test_zero_sensitive_axis_error_ignores_fixed_cone_angle_mismatch():
parent = np.asarray([0.0, 0.0, 1.0])
predicted = np.asarray([1.0, 0.0, 0.0])
cone_mismatch = np.asarray(
[math.cos(math.radians(10.0)), 0.0, math.sin(math.radians(10.0))]
)
zero_mismatch = np.asarray(
[math.cos(math.radians(3.0)), math.sin(math.radians(3.0)), 0.0]
)
assert _zero_sensitive_axis_error_rad(
predicted, cone_mismatch, parent
) == pytest.approx(0.0, abs=1.0e-12)
assert math.degrees(
_zero_sensitive_axis_error_rad(predicted, zero_mismatch, parent)
) == pytest.approx(3.0, abs=1.0e-9)
def _payload(transform: np.ndarray) -> dict[str, list[float]]:
return {
"translation_xyz_m": transform[:3, 3].tolist(),
"quaternion_xyzw": Rotation.from_matrix(
transform[:3, :3]
).as_quat().tolist(),
}
def _arbitrary_tag_records() -> tuple[list[dict], np.ndarray, np.ndarray]:
axis_parent = np.asarray([0.23, -0.31, 0.922], dtype=float)
axis_parent /= np.linalg.norm(axis_parent)
centre_parent = np.asarray([0.012, -0.008, 0.021])
radial = np.cross(axis_parent, np.asarray([0.7, 0.1, -0.2]))
radial = 0.035 * radial / np.linalg.norm(radial)
child_tag_mount = Rotation.from_euler(
"xyz", [1.1, -0.7, 0.45]
)
common_from_parent = np.eye(4)
common_from_parent[:3, :3] = Rotation.from_euler(
"xyz", [-0.8, 0.55, 1.3]
).as_matrix()
common_from_parent[:3, 3] = [0.41, -0.12, 0.73]
expected_axis = common_from_parent[:3, :3] @ axis_parent
expected_point = (
common_from_parent[:3, :3] @ centre_parent
+ common_from_parent[:3, 3]
)
commands = list(range(0, 256, 16)) + [255]
records = []
for cycle in range(3):
for direction in ("decreasing", "increasing"):
for command in commands:
angle = math.radians(62.0) * (255.0 - command) / 255.0
motion = Rotation.from_rotvec(axis_parent * angle)
relative_rotation = motion * child_tag_mount
relative_translation = centre_parent + motion.apply(radial)
child_common = common_from_parent.copy()
child_common[:3, :3] = (
common_from_parent[:3, :3]
@ relative_rotation.as_matrix()
)
child_common[:3, 3] = (
common_from_parent[:3, :3] @ relative_translation
+ common_from_parent[:3, 3]
)
state = [255.0] * 20
state[5] = float(command)
records.append(
{
"cycle": cycle,
"direction": direction,
"command_u8": command,
"relative_translation_xyz_m": relative_translation.tolist(),
"relative_quaternion_xyzw": relative_rotation.as_quat().tolist(),
"parent_pose_common": _payload(common_from_parent),
"child_pose_common": _payload(child_common),
"state_u8": state,
}
)
return records, expected_axis, expected_point
def test_axis_and_curve_ignore_camera_and_tag_mount_rotation() -> None:
records, expected_axis, expected_point = _arbitrary_tag_records()
curve = fit_rotation_joint_curve(records, zero_command_u8=255)
measurement = fit_joint_axis_measurement(
"thumb_cmc_roll", records, cycle=0, zero_command_u8=255
)
observed_axis = np.asarray(measurement.axis_common_xyz)
observed_point = np.asarray(measurement.point_common_xyz_m)
assert float(observed_axis @ expected_axis) > math.cos(math.radians(0.05))
assert np.linalg.norm(
np.cross(observed_point - expected_point, expected_axis)
) < 1.0e-6
assert curve.angle_rad[255] == pytest.approx(0.0, abs=1.0e-9)
assert curve.angle_rad[0] == pytest.approx(math.radians(62.0), abs=1.0e-6)
def test_image_plane_joint_uses_rotation_axis_to_constrain_noisy_depth() -> None:
records, expected_axis, expected_point = _arbitrary_tag_records()
parent_rotation = Rotation.from_euler("xyz", [-0.8, 0.55, 1.3])
axis_parent = parent_rotation.inv().apply(expected_axis)
tangent = np.cross(axis_parent, np.asarray([0.4, -0.2, 0.7]))
tangent /= np.linalg.norm(tangent)
# Reproduce monocular planar-PnP depth bias: the centre trajectory remains
# precise in its dominant directions but receives a command-correlated
# component that makes a free 3-D plane normal substantially wrong.
biased_records = []
for record in records:
biased = dict(record)
point = np.asarray(record["relative_translation_xyz_m"], dtype=float)
depth_bias = 0.30 * float(point @ tangent)
biased["relative_translation_xyz_m"] = (
point + depth_bias * axis_parent
).tolist()
biased_records.append(biased)
measurement = fit_joint_axis_measurement(
"thumb_cmc_pitch", biased_records, cycle=0, zero_command_u8=255
)
observed_axis = np.asarray(measurement.axis_common_xyz)
observed_point = np.asarray(measurement.point_common_xyz_m)
assert abs(float(observed_axis @ expected_axis)) > math.cos(
math.radians(0.05)
)
assert np.linalg.norm(
np.cross(observed_point - expected_point, expected_axis)
) < 0.003
assert measurement.rotation_circle_axis_difference_rad > math.radians(5.0)
assert measurement.plane_rms_m < 0.003
assert measurement.radial_rms_m < 0.003
def test_splay_zero_interpolates_when_scan_does_not_hit_command_127() -> None:
records, expected_axis, _ = _arbitrary_tag_records()
assert not any(record["command_u8"] == 127 for record in records)
curve = fit_rotation_joint_curve(records, zero_command_u8=127)
measurement = fit_joint_axis_measurement(
"index_mcp_roll", records, cycle=0, zero_command_u8=127
)
observed_axis = np.asarray(measurement.axis_common_xyz)
assert abs(float(observed_axis @ expected_axis)) > math.cos(
math.radians(0.05)
)
assert curve.angle_rad[127] == pytest.approx(0.0, abs=1.0e-9)
def test_passive_axis_can_use_trusted_upstream_direction_constraint() -> None:
records, expected_axis, expected_point = _arbitrary_tag_records()
common_from_parent = Rotation.from_euler("xyz", [-0.8, 0.55, 1.3])
physical_axis_parent = common_from_parent.inv().apply(expected_axis)
wrong_axis_parent = np.cross(
physical_axis_parent, np.asarray([0.2, 0.8, -0.1])
)
wrong_axis_parent /= np.linalg.norm(wrong_axis_parent)
mount = Rotation.from_quat(records[0]["relative_quaternion_xyzw"])
contradictory = []
for record in records:
changed = dict(record)
angle = math.radians(62.0) * (
255.0 - float(record["command_u8"])
) / 255.0
changed["relative_quaternion_xyzw"] = (
Rotation.from_rotvec(wrong_axis_parent * angle) * mount
).as_quat().tolist()
contradictory.append(changed)
measurement = fit_joint_axis_measurement(
"index_dip",
contradictory,
cycle=0,
zero_command_u8=255,
axis_common_constraint=expected_axis,
)
observed_axis = np.asarray(measurement.axis_common_xyz)
observed_point = np.asarray(measurement.point_common_xyz_m)
assert abs(float(observed_axis @ expected_axis)) > math.cos(
math.radians(0.05)
)
assert np.linalg.norm(
np.cross(observed_point - expected_point, expected_axis)
) < 1.0e-6
def test_extrinsics_round_trip_keeps_camera_identity(tmp_path: Path) -> None:
cameras = {
view: {
"serial_number": f"SERIAL_{view}",
"width": 1624,
"height": 1240,
"intrinsics_sha256": camera_info_fingerprint(
width=1624,
height=1240,
camera_matrix=np.asarray(
[[1100.0, 0.0, 812.0], [0.0, 1099.0, 620.0], [0.0, 0.0, 1.0]]
),
),
}
for view in ("front", "side", "top")
}
transforms = {"front": np.eye(4), "side": np.eye(4), "top": np.eye(4)}
transforms["side"][:3, :3] = Rotation.from_euler("y", 0.7).as_matrix()
transforms["side"][:3, 3] = [0.2, 0.0, 0.1]
transforms["top"][:3, :3] = Rotation.from_euler("x", -0.9).as_matrix()
transforms["top"][:3, 3] = [-0.1, 0.3, 0.2]
destination = tmp_path / "extrinsics.yaml"
dump_three_camera_extrinsics(
destination,
cameras=cameras,
front_from_view=transforms,
quality={
"passed": True,
"reprojection_rms_px": 0.3,
"maximum_rotation_repeatability_deg": 0.2,
"maximum_translation_repeatability_m": 0.001,
"front_side_captures": 15,
"front_top_captures": 15,
},
)
loaded = load_three_camera_extrinsics(destination)
assert loaded.cameras["front"].serial_number == "SERIAL_front"
assert np.allclose(loaded.transform("side"), transforms["side"])
assert np.allclose(loaded.transform("top"), transforms["top"])
assert loaded.camera_matches(
"front",
serial_number="SERIAL_front",
width=1624,
height=1240,
intrinsics_sha256=cameras["front"]["intrinsics_sha256"],
)
assert not loaded.camera_matches(
"front",
serial_number="WRONG_SERIAL",
width=1624,
height=1240,
intrinsics_sha256=cameras["front"]["intrinsics_sha256"],
)
def _joint_origin(path: Path, name: str) -> tuple[np.ndarray, np.ndarray]:
joint = next(
element
for element in ET.parse(path).getroot().findall("joint")
if element.get("name") == name
)
origin = joint.find("origin")
axis = joint.find("axis")
xyz = np.asarray([float(value) for value in origin.get("xyz").split()])
rpy = np.asarray([float(value) for value in origin.get("rpy").split()])
axis_xyz = np.asarray([float(value) for value in axis.get("xyz").split()])
return np.block(
[
[Rotation.from_euler("xyz", rpy).as_matrix(), xyz[:, None]],
[np.asarray([[0.0, 0.0, 0.0, 1.0]])],
]
), axis_xyz / np.linalg.norm(axis_xyz)
def _joint_limit(path: Path, name: str) -> tuple[float, float]:
joint = next(
element
for element in ET.parse(path).getroot().findall("joint")
if element.get("name") == name
)
limit = joint.find("limit")
return float(limit.get("lower")), float(limit.get("upper"))
def test_urdf_writer_postmultiplies_joint_axis_and_never_overwrites(tmp_path: Path) -> None:
offset = math.radians(7.3)
destination = write_zero_corrected_urdf(
source_urdf=SOURCE_URDF,
output_directory=tmp_path,
serial_number="G20_LEFT_001",
offsets_rad={"thumb_cmc_yaw": offset},
timestamp="20260806_120000",
)
original, axis = _joint_origin(SOURCE_URDF, "thumb_cmc_yaw")
corrected, _ = _joint_origin(destination, "thumb_cmc_yaw")
expected = original.copy()
expected[:3, :3] = original[:3, :3] @ Rotation.from_rotvec(
axis * offset
).as_matrix()
assert destination != SOURCE_URDF
assert np.allclose(corrected, expected, atol=1.0e-12)
with pytest.raises(ValueError, match="refusing to overwrite"):
write_zero_corrected_urdf(
source_urdf=SOURCE_URDF,
output_directory=tmp_path,
serial_number="G20_LEFT_001",
offsets_rad={"thumb_cmc_yaw": offset},
timestamp="20260806_120000",
)
with pytest.raises(ValueError, match="original CAD URDF"):
write_zero_corrected_urdf(
source_urdf=destination,
output_directory=tmp_path,
serial_number="G20_LEFT_001",
offsets_rad={"thumb_cmc_yaw": offset},
timestamp="20260806_120001",
)
def test_urdf_writer_changes_only_the_16_active_zero_origins(
tmp_path: Path,
) -> None:
before = SOURCE_URDF.read_bytes()
offsets = {
name: math.radians(0.25 * (index + 1))
for index, name in enumerate(ACTIVE_JOINTS)
}
destination = write_zero_corrected_urdf(
source_urdf=SOURCE_URDF,
output_directory=tmp_path,
serial_number="G20_LEFT_001",
offsets_rad=offsets,
timestamp="20260807_180000",
)
assert len(offsets) == 16
assert SOURCE_URDF.read_bytes() == before
for name in ACTIVE_JOINTS:
original, axis = _joint_origin(SOURCE_URDF, name)
corrected, corrected_axis = _joint_origin(destination, name)
expected = original.copy()
expected[:3, :3] = original[:3, :3] @ Rotation.from_rotvec(
axis * offsets[name]
).as_matrix()
assert np.allclose(corrected, expected, atol=1.0e-12)
assert np.allclose(corrected_axis, axis, atol=1.0e-12)
for name in PASSIVE_JOINTS:
original, axis = _joint_origin(SOURCE_URDF, name)
corrected, corrected_axis = _joint_origin(destination, name)
assert np.allclose(corrected, original, atol=1.0e-12)
assert np.allclose(corrected_axis, axis, atol=1.0e-12)
def test_urdf_writer_expands_only_requested_limits_with_margin(
tmp_path: Path,
) -> None:
margin = math.radians(0.5)
measured_ranges = {
"index_mcp_roll": (-0.25113634, 0.24813698),
"index_mcp_pitch": (0.0, 1.22424601),
"index_pip": (0.0, 1.74215779),
}
destination = write_zero_corrected_urdf(
source_urdf=SOURCE_URDF,
output_directory=tmp_path,
serial_number="G20_LEFT_001",
offsets_rad={"thumb_cmc_yaw": math.radians(1.0)},
timestamp="20260807_181500",
joint_angle_ranges_rad=measured_ranges,
joint_limit_margin_rad=margin,
)
roll_lower, roll_upper = _joint_limit(destination, "index_mcp_roll")
pitch_lower, pitch_upper = _joint_limit(destination, "index_mcp_pitch")
pip_lower, pip_upper = _joint_limit(destination, "index_pip")
assert roll_lower == pytest.approx(measured_ranges["index_mcp_roll"][0] - margin)
assert roll_upper == pytest.approx(measured_ranges["index_mcp_roll"][1] + margin)
assert pitch_lower == pytest.approx(_joint_limit(SOURCE_URDF, "index_mcp_pitch")[0])
assert pitch_upper == pytest.approx(measured_ranges["index_mcp_pitch"][1] + margin)
assert pip_lower == pytest.approx(_joint_limit(SOURCE_URDF, "index_pip")[0])
assert pip_upper == pytest.approx(measured_ranges["index_pip"][1] + margin)
assert _joint_limit(destination, "thumb_cmc_yaw") == pytest.approx(
_joint_limit(SOURCE_URDF, "thumb_cmc_yaw")
)
assert _joint_limit(destination, "middle_mcp_roll") == pytest.approx(
_joint_limit(SOURCE_URDF, "middle_mcp_roll")
)
def _synthetic_curve(zero_command: int, travel: float) -> JointCurveFit:
values = np.asarray(
[travel * (255.0 - command) / 255.0 for command in range(256)]
)
values -= values[zero_command]
data = tuple(float(value) for value in values)
return JointCurveFit(
angle_rad=data,
decreasing_rad=data,
increasing_rad=data,
circle={},
maximum_monotonic_correction_rad=0.0,
maximum_hysteresis_rad=0.0,
quality={},
)
def test_joint_chain_solver_recovers_offsets_and_yaw_uses_roll_145() -> None:
baseline = [255.0] * 20
baseline[6:10] = [127.0] * 4
curves = {
name: _synthetic_curve(
int(baseline[JOINT_SPECS[name].motor_index]),
math.radians(50.0),
)
for name in MEASURED_JOINTS
}
motor_by_joint = {
name: spec.motor_index for name, spec in JOINT_SPECS.items()
}
true_offsets = {
name: math.radians(value)
for name, value in zip(
DIRECT_ZERO_JOINTS, [2.0, -3.0, 4.0, 0.0, 0.0, 0.0, 0.0]
)
}
model = UrdfKinematicModel(SOURCE_URDF)
base_rotation = Rotation.from_euler("xyz", [0.5, -0.4, 0.8])
base_translation = np.asarray([0.31, -0.19, 0.72])
measurements = []
yaw_axis_without_clearance = None
yaw_axis_with_clearance = None
for cycle in range(3):
for joint in AXIS_JOINTS:
state = list(baseline)
if joint == "thumb_cmc_yaw":
state[5] = 145.0
angles = _angles_from_state(
state, curves=curves, motor_by_joint=motor_by_joint
)
axis, point = model.axis_line(
joint,
zero_offsets=true_offsets,
joint_angles=angles,
)
if joint == "thumb_cmc_yaw":
yaw_axis_with_clearance = axis.copy()
baseline_angles = _angles_from_state(
baseline, curves=curves, motor_by_joint=motor_by_joint
)
yaw_axis_without_clearance = model.axis_line(
joint,
zero_offsets=true_offsets,
joint_angles=baseline_angles,
)[0]
measurements.append(
JointAxisMeasurement(
joint=joint,
cycle=cycle,
axis_common_xyz=tuple(base_rotation.apply(axis)),
point_common_xyz_m=tuple(
base_rotation.apply(point) + base_translation
),
condition_state_u8=tuple(state),
plane_rms_m=0.0002,
radial_rms_m=0.0002,
rotation_circle_axis_difference_rad=math.radians(0.1),
)
)
result = solve_urdf_zero_offsets(
source_urdf=SOURCE_URDF,
measurements=measurements,
curves=curves,
motor_by_joint=motor_by_joint,
)
assert math.degrees(
math.acos(
np.clip(yaw_axis_with_clearance @ yaw_axis_without_clearance, -1.0, 1.0)
)
) > 1.0
assert result.passed is True
for name, expected in true_offsets.items():
assert result.direct_offsets_rad[name] == pytest.approx(
expected, abs=math.radians(0.05)
)
assert set(result.all_active_offsets_rad) == set(ACTIVE_JOINTS)
assert result.all_active_offsets_rad["thumb_mcp"] == pytest.approx(
0.0, abs=1.0e-12
)
for target, source in INHERITED_ZERO_JOINTS.items():
assert result.all_active_offsets_rad[target] == pytest.approx(
result.direct_offsets_rad[source], abs=1.0e-12
)
for joint in (
"index_mcp_roll",
"middle_mcp_roll",
"ring_mcp_roll",
"pinky_mcp_roll",
):
assert result.all_active_offsets_rad[joint] == pytest.approx(
0.0, abs=1.0e-12
)
for joint in (
"index_mcp_pitch",
"middle_mcp_pitch",
"ring_mcp_pitch",
"pinky_mcp_pitch",
):
assert result.all_active_offsets_rad[joint] == pytest.approx(
0.0, abs=1.0e-12
)
for joint in ("index_pip", "middle_pip", "ring_pip", "pinky_pip"):
assert result.all_active_offsets_rad[joint] == pytest.approx(
0.0, abs=1.0e-12
)