g20右手标定

This commit is contained in:
lxp
2026-08-11 15:51:36 +08:00
parent 41ff4a61a9
commit a609d521a0
25 changed files with 6996 additions and 1066 deletions
+4
View File
@@ -72,6 +72,10 @@ Thumbs.db
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_cmc_pitch_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_calibrated_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_zero_calibrated_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_g20_right_cmc_pitch_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_g20_right_calibrated_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_g20_right_zero_calibrated_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_G20_RIGHT_tag.urdf
# ROS bag / MCAP recordings and CAN captures
rosbag2_*/
+167 -57
View File
@@ -1,4 +1,4 @@
# G20 左手 AprilTag 标定
# G20 左右手 AprilTag 标定
## 三机位三维关节轴零位标定(schema v4)
@@ -17,16 +17,16 @@ top = DB2163739,Tag 8/9
| ID | 机位 | 固定位置/运动件 |
|---:|---|---|
| 0 | 正面 | 正面掌壳固定基准 |
| 1 | 正面 | 拇指 CMC 后连杆 |
| 1 | 正面;右手电机0时也由侧面观测 | 拇指 CMC 后连杆 |
| 2 | 正面 | 拇指 MCP 后连杆 |
| 3 | 正面 | 拇指 IP 后末节 |
| 4 | 侧面 | 掌壳侧面固定基准(最底下) |
| 5 | 侧面 | 食指 MCP 后连杆 |
| 6 | 侧面 | 食指 PIP 后连杆 |
| 7 | 侧面 | 食指 DIP 后末节 |
| 5 | 侧面 | 左手食指/右手小指 MCP 后连杆 |
| 6 | 侧面 | 左手食指/右手小指 PIP 后连杆 |
| 7 | 侧面 | 左手食指/右手小指 DIP 后末节 |
| 8 | 上面 | 上面相机可见的掌壳/底座固定基准 |
| 9 | 上面 | 拇指 CMC yaw 运动件 |
| 10 | 正面 | 食指根部侧摆运动件(index_mcp_roll) |
| 10 | 正面 | 左手食指/右手小指根部侧摆运动件 |
ID 4 不贴在侧面相机看不到的掌心正面;ID 8 必须始终固定且可见,
ID 9 必须在拇指横摆的完整行程中持续可见。
@@ -62,7 +62,12 @@ PnP的最终外参偏差锁死采集。点击 `AUTO ON` 后,棋盘稳定1秒
外参分两组采集。让棋盘静止且同时出现在正面/侧面画面中,每改变一次位置和倾角添加
一个候选;随后以相同方法采集正面/上面。程序使用固定内参的
`stereoCalibrate` 联合优化唯一旋转/平移,自动剔除粗大异常组;两组均得到
`stereoCalibrate` 联合优化唯一旋转/平移。采集准入和最终验收分离:FRONT和
配对相机的单帧RMS分别不得超过1.5 px,同时组合RMS不得超过1.2 px;界面中
单相机1.2 px以内显示绿色、1.2~1.5 px显示黄色且仍可采集、超过1.5 px显示红色。
新姿态会与全部已采姿态比较,避免在少数姿态间反复采集。拟合先剔除粗大异常组,
再在不低于15个内点的前提下有界裁剪联合误差最高的候选,最终1.2 px门限不会被
放宽。两组均得到
至少15个内点且联合RMS、三折稳定性合格后 `SAVE` 才变绿。
```bash
@@ -71,10 +76,12 @@ 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。文件同时绑定三台相机序列号、分辨率和内参哈希;不满足任一项时不会保存
通过结果,正式标定也不会运动。
采集时可分别查看 `/g20_extrinsics/{front,side,top}/camera/image_rect`。界面始终
显示当前配对的整批RMS、旋转稳定性和平移稳定性;`BATCH FAIL` 后会直接列出
`INLIERS`、`RMS`、`ROT` 或 `TRANS` 失败项。保存门限为:联合重投影RMS不超过
1.2 px、三折重拟外参最大旋转差不超过0.3°、最大平移差不超过1.5 mm。文件同时
绑定三台相机序列号、分辨率和内参哈希;不满足任一项时不会保存通过结果,正式
标定也不会运动。
### 2. 预检和正式标定
@@ -83,6 +90,7 @@ ros2 service call /g20_camera_extrinsics/save std_srvs/srv/Trigger {}
```bash
ros2 launch g20_thumb_apriltag_calibration \
three_camera_calibration.launch.py \
hand_type:=left \
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 \
@@ -105,12 +113,24 @@ ros2 run image_view image_view --ros-args \
```bash
ros2 launch g20_thumb_apriltag_calibration \
three_camera_calibration.launch.py \
hand_type:=left \
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
```
右手使用同一入口;默认自动选择右手SDK话题和原始URDF:
```bash
ros2 launch g20_thumb_apriltag_calibration \
three_camera_calibration.launch.py \
hand_type:=right \
serial_number:=G20_RIGHT_001 \
camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \
can_interface:=can0
```
状态显示三个机位均“就绪”后只调用一次:
```bash
@@ -126,66 +146,121 @@ 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→255`。轨迹角由父/子Tag完整相对四元数的旋转向量投影到三维拟合轴得到。
对正面/侧面近图像平面的浅圆弧,使用姿态旋转轴约束Tag中心三维圆,只由圆轨迹
拟合轴线位置和半径,避免单目平面Tag深度噪声改变轴方向;对运动平面充分可观测的
斜视轨迹,仍保留姿态轴和独立三维圆轴的交叉检查。每条主动曲线在其baseline命令
严格归零:普通通道255,四指侧摆127。中指、无名指、小指继续继承食指曲线;四指
`mcp_roll/mcp_pitch/pip` 的静态URDF偏移统一固定为0,不再把单根食指的安装残差复制
到四根手指。`thumb_mcp` 的静态URDF偏移同样固定为0,避免IP轴线深度偏差在命令255
处产生额外向外伸展;其256项实测轨迹保持不变。
左手依次扫描电机 `0/5/15/6/1/16/10`,右手依次扫描
`0/5/15/9/4/19/10`,每项三轮 `255→0→255`。轨迹角由父/子Tag完整相对
四元数的旋转向量投影到三维拟合轴得到。
轴方向使用相对姿态旋转轴和可信上游轴约束;仅对斜视、非约束关节将中心圆作为独立
交叉检查并参与融合。轴线上一点则由整段
相对SE(3)轨迹的 `(I-R)p=t` 方程鲁棒拟合,不再把单目Tag中心自由三维圆的圆心直接
当成机械轴心。正面/侧面端视关节只使用图像平面内可观分量,丢弃无法由单目确定的
光轴深度;斜视轨迹仍保留姿态轴和独立三维圆轴的交叉检查。每条主动曲线在其baseline命令
严格归零:普通通道255,四指侧摆127。左手将食指动态轨迹、右手将小指动态轨迹继承
给其余三指。11-Tag布局只能可靠恢复参考指的动态命令—角度曲线,不能证明四根独立
电机的绝对装配相位相同;因此四指全部MCP侧摆、MCP屈伸和PIP静态URDF零偏都保留
原始CAD的0,只继承动态曲线,避免参考指弯曲或四指整体同向倾斜。
零位模式锁死原始CAD几何。URDF中确定平行的 `thumb_cmc_pitch→thumb_mcp→thumb_ip`
和 `index_mcp_pitch→index_pip→index_dip` 使用同轮上游实测方向,同时由各自Tag中心
轨迹独立拟合轴线位置;被动角度曲线仍由对应父/子Tag完整相对姿态生成。这样保留
下游轴线对上游零位的约束,同时避免小平面Tag的稳定PnP方向偏差被误认为URDF轴误差。
零位求解使用行程更充分的根轴方向和保持原始CAD直立的参考指MCP pitch实测轴方向确定
掌部朝向;两条平行根轴线只确定平移,不再用其单目三维深度间距确定绕根轴的旋转,避免
稳定PnP深度偏差被写成拇指roll零偏。另一条短行程根轴方向只作诊断。随后按两条运动链逐关节
进行一维鲁棒求解,避免远端异常把已确定的上游零位一起拖到边界。非平行相邻轴先将上下游
轴投影到父轴法平面,再计算精确有符号方位角;父子轴夹角是零位无法改变的几何不变量,偏差
超过5°直接判定模型失败。平行相邻轴比较两轴之间的径向相位,三维路径忽略连杆长度和沿轴
Tag位置,接近轴向观察时改用相机图像平面相位并丢弃单目PnP深度。轴线SE(3)拟合RMS超过
1 mm也不允许写URDF。四指静态零位不参与相机相位覆盖,拇指可观测零偏上限20°;
小于0.3°或未超过3倍不确定度的稳定偏移保留原始零位0。
前两轮拟合,第三轮强制留出验证;第三轮轨迹MAE必须≤1°、P95≤2°,三轮真实
姿态轴方向极差≤0.75°、轴向/径向RMS≤3 mm。零位留出验收只统计能观测对应上游
零位的下游轴分量:方向MAE≤1°、P95≤2°,轴线RMS≤3 mm;根轴自身及与当前零位
无关的固定CAD/PnP残差不会再把零位推向±20°。独立三维运动平面充分可观测的关节
额外要求姿态轴与圆轴夹角≤1°。任一指标失败会暂停并指出失败关节;调用 `resume`
只重扫对应电机的6个方向。
`thumb_mcp` 的动态角度曲线仍由电机15的三轮轨迹直接测量,但其绝对静态零位只可通过
被动 `thumb_ip` 的轴线圆心相位间接推断。固定正面单目机位下这条浅圆弧的姿态轴/圆轨迹轴
偏差可达数十度,重复性不能排除稳定系统误差,因此不得把该相位写入URDF;左右手
`thumb_mcp` 都保留原始CAD零位0。该保护只冻结静态 `origin.rpy`,不会冻结或复制其
`angle_rad[256]` 实测轨迹。
前两轮拟合,第三轮强制留出验证;轨迹与零位角度MAE必须≤1°、P95≤2°,三轮轴/零位
差≤0.75°、径向RMS≤3 mm、轴线SE(3)残差≤1 mm。非零修正必须在第三轮优于原始URDF,并通过按三轮分组的
95% bootstrap改善置信检查。最终门限不会因自动重试而放宽。
单轮姿态相对理想固定轴的轴外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零位。
四指参考源的MCP pitch虽有约70°大行程,但侧面机位接近沿转轴观察,单目PnP深度偏差
仍可能把低残差的Tag中心圆平面稳定地倾斜。因此MCP pitch与其他端视关节一样,始终用
完整相对姿态确定轴方向,Tag中心轨迹只参与轴线位置拟合;不再按10°分界在两种轴模型
之间切换。固定Tag安装旋转会在相对旋转中抵消,不需要中心圆回退。被动PIP/DIP继承
上游轴方向时不重复报告同一项跨轮轴失败。
四指MCP侧摆的动态曲线仍由参考指三轮实测并继承,但绝对静态侧摆零位固定使用原始CAD
的0。仅凭下游pitch轴相对CAD掌坐标反推roll相位,会把稳定的跨视角/固定几何偏差写成
约4°的整指倾斜;重复扫描与同源留出不能排除这种系统偏差,因此不得写入URDF。
四指MCP屈伸和PIP采用同一静态策略:参考指轨迹仍参与动态曲线、轴质量和机构诊断,
但拟合出的绝对相位不写入任何一根四指的 `origin.rpy`。拇指CMC roll/yaw/pitch的非零
修正只能来自当前会话的三轮轨迹求解并通过第三轮留出验证;代码和配置中不保存任何
按左右手或序列号写死的拇指零位角。电机5的256点动态曲线也使用本机三轮实测结果。
7个直接零位依赖链为:yaw轴约束拇指roll、pitch轴约束拇指yaw、MCP轴线相位约束
拇指pitch;IP轴线相位仅作诊断,不能覆盖拇指MCP的原始CAD零位。参考指MCP pitch轴
约束roll,PIP/DIP轴线相位只用于参考指机构诊断,不再覆盖四指CAD静态零位。
原始URDF的 `origin.xyz`、`axis.xyz`、连杆长度、mesh和被动结构固定。yaw扫描时电机5
保持145,求解器使用实测 `angle_rad[145]` 还原该条件,不会把145误当成baseline。
偏移超过±20°时整次失败。
偏移超过各关节专用上限时整次失败。数值求解会在更宽的诊断范围内继续估计,因此状态和原始JSONL
会显示实际估计值及配置上限,而不是把所有超限结果都截断成恰好±20°或±3°;该诊断搜索
不会放宽正式结果的硬门限。
生成修正URDF时,四指主动 `mcp_roll/mcp_pitch/pip` 的关节限位会检查对应256项实测
轨迹;仅当实测端点越过原始CAD限位时才向外扩展,并额外保留0.5°数值余量。不会
收窄原限位,也不会修改拇指或四指被动DIP的限位。
生成修正URDF时只修改通过验收的主动关节 `origin.rpy`,不会修改任何关节的
`origin.xyz`、转轴、mimic关系或原始CAD/机械安全限位。256项实测轨迹只保存在最终
JSON;实测曲线即使略微越过CAD限位,也不能自动扩大URDF限位。
任一指标失败时会立即暂停,中文状态显示关节名、实测值和阈值,不再等到42个方向
全部结束。修正现场问题后调用 `resume`,
程序只清除该电机任务的内存样本并重扫它的6个方向;前面已通过的关节保留。
失败样本不从 `raw_samples.jsonl` 删除,而是使用 `attempt` 和 `retry` 记录区分,
便于调试;最终拟合只使用当前通过尝试的内存数据。
坏帧只丢弃。短时Tag丢失、同步帧中断、扫描超时、端点/分箱不足会自动保持当前位置、
重置当前机位PnP、返回基准后重扫当前方向,最多3次;速度依次降为80%/60%/50%,
端点保持延长到0.75/1.0/1.25秒,扫描超时按降速比例同步延长。若反馈在远离目标时
连续8秒没有至少1个u8的进展,则按机械碰撞/摩擦或硬件故障立即保持当前反馈位置并
暂停,不消耗三次采样重试预算。单轮拟合失败只重扫该轮两个方向,全局不一致才重扫
完整关节,每关节最多自动重采2轮。过程指标在最终门限的1.25倍内只发黄色预警,最终
拟合仍按原硬门限验收。零位触边、稳定留出误差或URDF几何无法解释属于模型失败,程序
只暂停一次且不再自动重扫,防止重复运动;此时也拒绝`resume`形成死循环。其他可恢复
失败在预算耗尽后才暂停,`resume`从最小失败单元继续,已通过数据保留。所有失败尝试
仍保存在 `raw_samples.jsonl`。
标定食指 `index_mcp_roll`(电机6)时,为避免中指遮挡ID 10,
程序将中指、无名指和小指的侧摆电机7/8/9固定为0;开始采样前会同时确认
电机6到达扫描起点且电机7/8/9均已到达0。离开该标定项后恢复统一基准姿态。
该项目还会通过SDK设置接口把五指速度临时设为 `[15,5,15,15,15]`,即只把
食指速度从15降为5;离开该项目后恢复 `[15,15,15,15,15]`。
侧面标定 `index_mcp_pitch`(电机1)和 `index_pip/index_dip`(电机16)时,
五指速度设为 `[15,10,15,15,15]`,即食指屈伸使用第三档速度10;其余直接
测量关节保持普通速度15。
每个新机位/Tag组合开始运动前,不使用单个端点帧直接决定平面Tag的IPPE姿态分支。
程序在静止端点联合8帧候选,按相邻Tag相对姿态的跨帧稳定性和重投影误差选择整组
分支;侧面Tag 4/5/6/7贴面在该端点应近似平行,初始化还会比较相邻Tag法向,避免
错误镜像分支虽然8帧稳定且重投影很小仍被选中。每轮 `255→0` 前都会在静止端点独立
重置并重新选择分支,使第三轮同时成为PnP初始化留出,而不是三轮共享同一错误分支。
再开始正式轨迹采集。初始化帧不写入轨迹;最终单轴、跨轮和留出门限不变。
左手测食指roll时将电机7/8/9固定到0;右手测小指roll时因左右手侧摆机构镜像,
将电机6/7/8固定到255。两者均为相机画面向右的物理避挡方向,速度分别为
`[15,5,15,15,15]` 和 `[15,15,15,15,5]`。参考指MCP pitch/PIP扫描分别使用
电机1/16(左)或4/19(右),参考指速度10。
右手扫描拇指CMC俯仰(电机0)前,程序将拇指横摆电机10和拇指侧摆电机5都固定到
255,确认两个辅助关节到位后才允许电机0执行全行程。该关节由正面机位使用掌部
Tag 0和运动Tag 1同帧测量。每帧都会保留两个辅助关节
的实测条件值,轴线经外参转换到公共坐标系,零位求解按URDF上游关节链补偿;左手
仍沿用原有正面机位和基准姿态。
标定 `thumb_cmc_yaw`(电机10)时,程序将
`thumb_cmc_roll`(电机5)固定为145,并在它到位后才开始采样,以保持运动Tag
ID 9的可见性和PnP稳定性。离开该标定项后,电机5恢复基准值255;
最终JSON的 `baseline_command_u8` 不变。
ID 9的可见性和PnP稳定性。当前方向自动重试、失败轮次重试和人工 `resume` 都保持
电机5为145,只让电机10返回待重扫方向的起点;电机10全部三轮完成后,电机5才
恢复基准值255。自动恢复直接发送恢复目标,不会短暂发送保持当前位置命令;操作员
暂停/终止、恢复预算耗尽或机械停滞时仍保持当前位置。最终JSON的
`baseline_command_u8` 不变。
右手标定小指PIP(电机19)时,命令0对应的固件反馈可能稳定饱和在5。只有电机19
的0端使用±5反馈容差,并将该实测机械端点归入命令0端点分箱;255端和其他电机仍
使用默认±2。轨迹仍须覆盖至少240个u8并通过完整拟合门限,所以中途卡滞不会被误判
为端点到达。
右手拇指横摆电机10在命令255时多次实测稳定饱和在250,因此仅右手电机10的255端
使用±5反馈容差;其命令0端实测反馈为4,仍使用±4,其他电机和中间位置不放宽。基准姿态、作为
电机0辅助避挡姿态以及电机10自身扫描端点都使用同一条专用判定。
三机位流程默认 `validation_enabled:=false`,即不增加随机机械动作,但第三轮留出验证
始终启用且不能关闭;最终 `quality.validation_mae_rad/p95_rad` 正是第三轮轨迹误差。
状态中的扫描进度和总体进度分开显示:42/42只表示计划轨迹已采完,总体进度在拟合和
验证完成、正式JSON与URDF成功写入之前不会显示100%。
上面机位在Tag二维质量合格但PnP连续无效达到1秒时,会自动重置该机位的单Tag
和双Tag连续性跟踪器,并从下一帧重新建链,早于3秒采集超时。暂停恢复时只要求
@@ -200,16 +275,51 @@ calibration_output/G20_LEFT_001/<时间戳>/
g20_left_G20_LEFT_001_calibration.json
src/.../g20_left/
linkerhand_g20_left_zero_calibrated_G20_LEFT_001_<时间戳>.urdf
calibration_output/G20_RIGHT_001/<时间戳>/
g20_right_G20_RIGHT_001_calibration.json
src/.../g20_right/
linkerhand_g20_right_zero_calibrated_G20_RIGHT_001_<时间戳>.urdf
```
文件包含21个关节的256项 `angle_rad`、16个主动关节的 `zero_command_u8`、
`zero_angles.urdf_zero_offset_rad`、5个被动标记、模板来源和总体质量。新URDF每次从
指定原始CAD文件生成,采用 `T_original × Rot(axis, offset)`,绝不叠加旧校准文件或
覆盖原文件;除16个主动关节的 `origin.rpy` 外,原URDF文本保持不变。每帧Tag SE(3)、
覆盖原文件;只有通过独立求解验证或明确机械装配基准授权的主动关节 `origin.rpy` 可能
改变,未观测关节和其他URDF文本保持不变。每帧Tag SE(3)、
图像时间戳、20通道状态、同步误差和PnP误差只进入 `raw_samples.jsonl`。
完整 `raw_samples.jsonl` 已存在时,可以按当前算法离线重放,不连接相机、不发送电机
命令。`--output-tag` 为新产物增加安全后缀,已有JSON、URDF和验证报告不会被覆盖:
```bash
python3 -m g20_thumb_apriltag_calibration.offline_replay \
calibration_output/G20_RIGHT_001/20260811_120146 \
--output-tag AXIS_FRAME_V3 \
--write
```
下面保留原有正面拇指独立标定说明和兼容入口。
### 4. 修正URDF的运行时关节映射
修正URDF已经把 `zero_angles.urdf_zero_offset_rad` 写入关节
`origin.rpy`。仿真运行时只能再使用同一台、同一侧机械手JSON中的256点
`angle_rad` 动态曲线,不能把 `urdf_zero_offset_rad` 再加一次,也不能把左手曲线
用于右手URDF。可用桥接节点将GUI的20通道u8命令转换为完整21关节
`JointState`(包括5个被动关节):
```bash
ros2 launch g20_thumb_apriltag_calibration calibrated_joint_state_bridge.launch.py \
hand_type:=right \
calibration_file:=$PWD/calibration_output/G20_RIGHT_001/20260811_120146/g20_right_G20_RIGHT_001_calibration.json
```
默认订阅 `/cb_right_hand_control_cmd`,发布
`/sim/mujoco/g20/right/joint_state`。启动前必须停止任何旧的同名话题桥,避免两个
发布者同时驱动仿真。节点会拒绝左右手不匹配、质量未通过、字段不完整或非有限命令,
因此不会静默退回旧标定。
该包启动海康机器人 MVS USB3 Vision 黑白相机、图像校正、`apriltag_ros`、
Linker Hand SDK 和标定状态机,
只扫描 G20 左手命令下标 `0`、`15`。默认使用单终点连续模式:每个方向只发送一次
@@ -287,9 +397,9 @@ source install/setup.bash
顺序猜测机位。启动 ROS 节点前必须关闭 MVS 客户端中的相机连接,否则设备可能被占用。
`1624x1240 mono8` 每帧约 2.0 MB,超过 Fast DDS 2.14 默认约 512 KB 的共享内存段。
相机节点和三个标定 launch 会自动加载 `config/fastdds_large_images.xml`,使用 64 MB
共享内存段;否则相机内部虽为 30 Hz,大图订阅端通常只能收到约 1~4 Hz。修改配置后
必须重启相关 ROS 进程才能生效。
三相机标定 launch 会固定使用 `rmw_fastrtps_cpp`,并通过新旧两个 Fast DDS 环境变量
加载 `config/fastdds_large_images.xml`,使用 64 MB 共享内存段;否则相机内部虽为 30 Hz,
大图订阅端通常只能收到约 1~4 Hz。修改配置后必须重启相关 ROS 进程才能生效。
首次使用必须先标定该相机和当前镜头的内参。主 launch 默认从
`~/.ros/camera_info/hikrobot_DB2163742.yaml` 加载标准 ROS CameraInfo YAML;文件缺失时
@@ -34,6 +34,11 @@ g20_calibration:
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 4/5/6/7在初始化端点的贴面法向应一致;用此先验消除静态IPPE镜像双解。
pnp_group_normal_alignment_scale_deg: 5.0
pnp_group_maximum_normal_alignment_deg: 15.0
top_pnp_invalid_reset_seconds: 1.0
# 三维位姿必须与实测20通道状态严格按时间戳配对。
maximum_state_image_skew_ms: 50.0
@@ -43,6 +48,8 @@ g20_calibration:
# 轴线一致性仍沿用严格检查。
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
@@ -51,20 +58,42 @@ g20_calibration:
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_offset_deg: 20.0
# 四指绝对静态零偏默认保护范围。MCP侧摆只保留实测动态曲线,静态零位固定为CAD 0。
zero_finger_maximum_offset_deg: 3.0
endpoint_tolerance_u8: 2.0
# 电机10在命令0时实测会稳定反馈为3;仅其0端使用±3,255端及其他电机仍为±2。
thumb_yaw_zero_endpoint_tolerance_u8: 3.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
baseline_hold_seconds: 0.5
position_timeout_seconds: 30.0
sweep_timeout_seconds: 90.0
# 反馈在远离目标时连续8秒没有至少1个u8的进展,按机械卡滞立即暂停;
# 这类故障不进入遮挡/超时的三次自动重扫。
motor_stall_timeout_seconds: 8.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: 3
# 轨迹拟合失败优先只重扫失败轮次;零位/URDF模型失败不重复运动。
automatic_fit_retry_limit: 2
automatic_motion_retry_limit: 2
# 过程检查允许25%的黄色预警带,最终验收仍使用下面的严格门限。
provisional_warning_ratio: 1.25
retry_minimum_speed: 3
retry_speed_scales: [0.8, 0.6, 0.5]
retry_endpoint_hold_seconds: [0.75, 1.0, 1.25]
trajectory_maximum_plane_rms_m: 0.004
trajectory_maximum_radial_rms_m: 0.004
@@ -0,0 +1,218 @@
"""Map G20 u8 commands to URDF joint angles using one calibration JSON.
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.
"""
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import Any, Mapping, Sequence
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
from .full_hand import get_hand_calibration_profile, validate_compact_payload
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, side-specific lookup from a G20 command to URDF radians."""
def __init__(
self, payload: Mapping[str, Any], *, expected_side: str | None = None
) -> None:
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")
profile = get_hand_calibration_profile(side)
self.side = side
self.serial_number = str(payload["serial_number"])
self._motor_by_joint = {
name: int(profile.joint_specs[name].motor_index)
for name in G20_URDF_JOINT_NAMES
}
self._curves = {
name: tuple(
float(value)
for value in payload["joints"][name]["angle_rad"]
)
for name in G20_URDF_JOINT_NAMES
}
@staticmethod
def _command_index(value: float) -> int:
command = float(value)
if not math.isfinite(command):
raise ValueError("G20 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:
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))
missing = [name for name in G20_COMMAND_NAMES if name not in by_name]
if missing:
raise ValueError(
"G20 command is missing named channels: " + ",".join(missing)
)
command = tuple(by_name[name] for name in G20_COMMAND_NAMES)
else:
if len(values) != len(G20_COMMAND_NAMES):
raise ValueError(
"unnamed G20 command must contain exactly 20 positions"
)
command = values
indices = tuple(self._command_index(value) for value in command)
return tuple(
self._curves[name][indices[self._motor_by_joint[name]]]
for name in G20_URDF_JOINT_NAMES
)
def load_calibrated_command_mapper(
calibration_file: str | Path, *, expected_side: str | None = None
) -> 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"))
return CalibratedCommandMapper(payload, expected_side=expected_side)
class CalibratedJointStateBridge(Node):
def __init__(self) -> None:
super().__init__("g20_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", "")
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_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 f"/cb_{hand_type}_hand_control_cmd"
self.output_topic = (
output_topic or f"/sim/mujoco/g20/{hand_type}/joint_state"
)
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 {hand_type} G20 calibration for {self.mapper.serial_number}: "
f"{self.input_topic} -> {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(G20_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()
@@ -67,6 +67,8 @@ class StereoCapture:
transform_candidates: tuple[np.ndarray, ...]
pair_reprojection_rms_px: float
skew_ns: int
front_reprojection_rms_px: float = float("nan")
other_reprojection_rms_px: float = float("nan")
@dataclass(frozen=True)
@@ -81,6 +83,64 @@ class StereoFitResult:
passed: bool
def _individual_reprojection_passes(
front_rms_px: float,
other_rms_px: float,
maximum_rms_px: float,
) -> bool:
"""Do not let one sharp camera hide a poor observation in the other."""
values = np.asarray([front_rms_px, other_rms_px], dtype=float)
return bool(
np.all(np.isfinite(values))
and np.all(values >= 0.0)
and np.all(values <= maximum_rms_px)
)
def _pose_is_novel(
pose: np.ndarray,
previous_poses: list[np.ndarray],
*,
minimum_rotation_rad: float,
minimum_translation_m: float,
) -> bool:
"""Require a new board pose to differ from every accepted pose.
Comparing only with the most recent capture lets an operator alternate
between two almost identical poses and create a large but poorly
conditioned data set. A pose is new when it differs in rotation or
translation from every pose already retained.
"""
return all(
rotation >= minimum_rotation_rad
or translation >= minimum_translation_m
for rotation, translation in (
_transform_residual(previous, pose) for previous in previous_poses
)
)
def _stereo_fit_failures(
result: StereoFitResult,
*,
minimum_inliers: int,
maximum_rms_px: float,
maximum_rotation_stability_rad: float,
maximum_translation_stability_m: float,
) -> tuple[str, ...]:
"""Return stable, user-facing names for failed final quality gates."""
failures: list[str] = []
if len(result.inlier_indices) < minimum_inliers:
failures.append("INLIERS")
if result.stereo_rms_px > maximum_rms_px:
failures.append("RMS")
if result.rotation_stability_rad > maximum_rotation_stability_rad:
failures.append("ROT")
if result.translation_stability_m > maximum_translation_stability_m:
failures.append("TRANS")
return tuple(failures)
def _select_latest_synchronised_pair(
front_history: list[Any] | deque[Any],
other_history: list[Any] | deque[Any],
@@ -313,6 +373,10 @@ def _fit_stereo_robust(
fit_rms = float("inf")
transform = np.eye(4)
per_view = np.full(len(active), float("inf"))
# Always compute a provisional fit from three captures onward so the GUI
# can guide the operator before the final count is reached. Remove only
# unmistakable geometric/reprojection outliers, and never trim below the
# required final inlier count.
while len(active) >= 3:
fit_rms, transform, per_view = _stereo_fit_once(
captures,
@@ -355,10 +419,30 @@ def _fit_stereo_robust(
)
)
worst_local = int(np.argmax(scores))
if scores[worst_local] <= 1.0 or len(active) <= 3:
if scores[worst_local] <= 1.0 or len(active) <= minimum_inliers:
break
active.pop(worst_local)
# Moderate bad views (typically 1.2--1.8 px) are not gross outliers, but
# accumulating them can keep the batch RMS just above the hard gate
# forever. Trim only a view whose own joint stereo error exceeds the
# unchanged final RMS limit, refitting after every removal. This is
# bounded by minimum_inliers and cannot turn an intrinsically poor data set
# into a formal result merely by relaxing the threshold.
while len(active) > minimum_inliers and fit_rms > maximum_rms_px:
worst_local = int(np.argmax(per_view))
if float(per_view[worst_local]) <= maximum_rms_px:
break
active.pop(worst_local)
fit_rms, transform, per_view = _stereo_fit_once(
captures,
active,
object_points,
front_matrix,
other_matrix,
image_size,
)
rotation_stability = float("inf")
translation_stability = float("inf")
if len(active) >= 6:
@@ -389,13 +473,7 @@ def _fit_stereo_robust(
rejected = tuple(
index for index in range(len(captures)) if index not in set(active)
)
passed = bool(
len(active) >= minimum_inliers
and fit_rms <= maximum_rms_px
and rotation_stability <= maximum_rotation_stability_rad
and translation_stability <= maximum_translation_stability_m
)
return StereoFitResult(
provisional = StereoFitResult(
front_from_other=transform,
stereo_rms_px=fit_rms,
per_view_rms_px=tuple(float(value) for value in per_view),
@@ -403,7 +481,24 @@ def _fit_stereo_robust(
rejected_indices=rejected,
rotation_stability_rad=rotation_stability,
translation_stability_m=translation_stability,
passed=passed,
passed=False,
)
failures = _stereo_fit_failures(
provisional,
minimum_inliers=minimum_inliers,
maximum_rms_px=maximum_rms_px,
maximum_rotation_stability_rad=maximum_rotation_stability_rad,
maximum_translation_stability_m=maximum_translation_stability_m,
)
return StereoFitResult(
front_from_other=provisional.front_from_other,
stereo_rms_px=provisional.stereo_rms_px,
per_view_rms_px=provisional.per_view_rms_px,
inlier_indices=provisional.inlier_indices,
rejected_indices=provisional.rejected_indices,
rotation_stability_rad=provisional.rotation_stability_rad,
translation_stability_m=provisional.translation_stability_m,
passed=not failures,
)
@@ -510,6 +605,9 @@ class ThreeCameraExtrinsicsNode(Node):
self.declare_parameter("minimum_captures_per_pair", 15)
self.declare_parameter("maximum_pair_skew_ms", 100.0)
self.declare_parameter("maximum_reprojection_rms_px", 1.2)
self.declare_parameter(
"maximum_single_camera_reprojection_rms_px", 1.5
)
self.declare_parameter("maximum_rotation_repeatability_deg", 0.3)
self.declare_parameter("maximum_translation_repeatability_m", 0.0015)
self.declare_parameter("enable_gui", False)
@@ -542,6 +640,9 @@ class ThreeCameraExtrinsicsNode(Node):
self.maximum_reprojection_rms_px = float(
value("maximum_reprojection_rms_px")
)
self.maximum_single_camera_reprojection_rms_px = float(
value("maximum_single_camera_reprojection_rms_px")
)
self.maximum_rotation_repeatability_rad = math.radians(
float(value("maximum_rotation_repeatability_deg"))
)
@@ -567,6 +668,16 @@ class ThreeCameraExtrinsicsNode(Node):
raise ValueError("checkerboard geometry is invalid")
if self.minimum_captures < 15:
raise ValueError("minimum_captures_per_pair must be at least 15")
if self.maximum_reprojection_rms_px <= 0.0:
raise ValueError("maximum_reprojection_rms_px must be positive")
if (
self.maximum_single_camera_reprojection_rms_px
< self.maximum_reprojection_rms_px
):
raise ValueError(
"maximum_single_camera_reprojection_rms_px must be at "
"least maximum_reprojection_rms_px"
)
if self.gui_refresh_hz <= 0.0 or self.gui_refresh_hz > 10.0:
raise ValueError("gui_refresh_hz must be in (0, 10]")
if self.auto_capture_stable_seconds < 0.5:
@@ -753,13 +864,20 @@ class ThreeCameraExtrinsicsNode(Node):
"skew_ns": skew,
"pair_reprojection_rms_px": pair_rms,
}
if pair_rms > self.maximum_reprojection_rms_px:
camera_rms_failed = not _individual_reprojection_passes(
front.reprojection_rms_px,
selected.reprojection_rms_px,
self.maximum_single_camera_reprojection_rms_px,
)
if camera_rms_failed or pair_rms > self.maximum_reprojection_rms_px:
return PairAssessment(
ready=False,
reason_code="reprojection_rms_too_large",
message=(
f"当前组重投影RMS={pair_rms:.3f}px超过"
f"{self.maximum_reprojection_rms_px:.3f}px门限,未计入。"
f"当前组未通过采集门限,未计入。单相机上限="
f"{self.maximum_single_camera_reprojection_rms_px:.3f}px,"
f"组合上限={self.maximum_reprojection_rms_px:.3f}px;"
f"组合RMS={pair_rms:.3f}px,"
f"front={front.reprojection_rms_px:.3f}px,"
f"{other}={selected.reprojection_rms_px:.3f}px;"
f"已保留{len(self.captures[other])}/{self.minimum_captures}组"
@@ -775,19 +893,11 @@ class ThreeCameraExtrinsicsNode(Node):
previous = self.captures[other]
board_poses = self.captured_front_board[other]
if board_poses:
board_change_rotation = (
Rotation.from_matrix(board_poses[-1][:3, :3]).inv()
* Rotation.from_matrix(
front.camera_from_board_candidates[0][:3, :3]
)
).magnitude()
board_change_translation = np.linalg.norm(
board_poses[-1][:3, 3]
- front.camera_from_board_candidates[0][:3, 3]
)
if (
board_change_rotation < math.radians(2.0)
and board_change_translation < 0.015
if not _pose_is_novel(
front.camera_from_board_candidates[0],
board_poses,
minimum_rotation_rad=math.radians(2.0),
minimum_translation_m=0.015,
):
return PairAssessment(
ready=False,
@@ -891,6 +1001,12 @@ class ThreeCameraExtrinsicsNode(Node):
assessment.pair_reprojection_rms_px
),
skew_ns=assessment.skew_ns,
front_reprojection_rms_px=(
assessment.front_pose.reprojection_rms_px
),
other_reprojection_rms_px=(
assessment.selected_pose.reprojection_rms_px
),
)
)
self.captured_front_board[other].append(
@@ -968,12 +1084,17 @@ class ThreeCameraExtrinsicsNode(Node):
True,
)
rms = "--" if pose is None else f"{pose.reprojection_rms_px:.3f}px"
color = (
(40, 220, 40)
if pose is not None
and pose.reprojection_rms_px <= self.maximum_reprojection_rms_px
else (0, 180, 255)
)
color = (0, 180, 255)
if pose is not None:
if pose.reprojection_rms_px <= self.maximum_reprojection_rms_px:
color = (40, 220, 40)
elif (
pose.reprojection_rms_px
<= self.maximum_single_camera_reprojection_rms_px
):
color = (0, 210, 255)
else:
color = (0, 0, 255)
cv2.putText(
image,
f"{label.upper()} RMS {rms}",
@@ -1150,6 +1271,10 @@ class ThreeCameraExtrinsicsNode(Node):
else:
self.gui_auto_pose = None
self.gui_auto_stable_since = None
# Auto capture can update the fit during this callback. Render
# the new result immediately instead of showing stale metrics for
# one GUI period.
active_fit = self.fit_results[self.gui_active_other]
front_frame = assessment.front_frame
selected_frame = assessment.selected_frame
if front_frame is None and self.image_history["front"]:
@@ -1174,11 +1299,24 @@ class ThreeCameraExtrinsicsNode(Node):
canvas[10 : 10 + view_height, 655 : 655 + view_width] = right
ready_color = (40, 220, 40) if assessment.ready else (0, 80, 230)
batch_status = (
" | BATCH PASSED"
if active_fit is not None and active_fit.passed
else ""
)
batch_status = ""
if active_fit is not None:
failures = _stereo_fit_failures(
active_fit,
minimum_inliers=self.minimum_captures,
maximum_rms_px=self.maximum_reprojection_rms_px,
maximum_rotation_stability_rad=(
self.maximum_rotation_repeatability_rad
),
maximum_translation_stability_m=(
self.maximum_translation_repeatability_m
),
)
batch_status = (
" | BATCH PASSED"
if not failures
else " | BATCH FAIL: " + ",".join(failures)
)
cv2.putText(
canvas,
self._gui_reason_text(assessment) + batch_status,
@@ -1199,27 +1337,31 @@ class ThreeCameraExtrinsicsNode(Node):
if assessment.skew_ns is None
else f"{assessment.skew_ns / 1e6:.1f}"
)
batch_rms = (
"--" if active_fit is None else f"{active_fit.stereo_rms_px:.3f}"
)
rotation = (
"--"
if assessment.provisional_rotation_error_rad is None
else f"{math.degrees(assessment.provisional_rotation_error_rad):.3f}"
if active_fit is None
else f"{math.degrees(active_fit.rotation_stability_rad):.3f}"
)
translation = (
"--"
if assessment.provisional_translation_error_m is None
else f"{assessment.provisional_translation_error_m * 1000.0:.3f}"
if active_fit is None
else f"{active_fit.translation_stability_m * 1000.0:.3f}"
)
cv2.putText(
canvas,
f"Pair RMS {pair_rms}/{self.maximum_reprojection_rms_px:.3f}px "
f"Skew {skew}/{self.maximum_pair_skew_ns / 1e6:.1f}ms "
f"Batch stability {rotation}/"
f"Batch RMS {batch_rms}/{self.maximum_reprojection_rms_px:.3f}px "
f"stability {rotation}/"
f"{math.degrees(self.maximum_rotation_repeatability_rad):.3f}deg "
f"{translation}/"
f"{self.maximum_translation_repeatability_m * 1000.0:.3f}mm",
(20, 562),
cv2.FONT_HERSHEY_SIMPLEX,
0.58,
0.52,
(220, 220, 220),
1,
cv2.LINE_AA,
@@ -1326,15 +1468,31 @@ class ThreeCameraExtrinsicsNode(Node):
results[view] = result
if any(not result.passed for result in results.values()):
response.success = False
diagnostics: list[str] = []
for view, result in results.items():
failures = _stereo_fit_failures(
result,
minimum_inliers=self.minimum_captures,
maximum_rms_px=self.maximum_reprojection_rms_px,
maximum_rotation_stability_rad=(
self.maximum_rotation_repeatability_rad
),
maximum_translation_stability_m=(
self.maximum_translation_repeatability_m
),
)
diagnostics.append(
f"{view}: RMS={result.stereo_rms_px:.3f}px, "
f"内点={len(result.inlier_indices)}/"
f"{len(self.stereo_captures[view])}, "
f"旋转稳定性="
f"{math.degrees(result.rotation_stability_rad):.3f}°, "
f"平移稳定性="
f"{result.translation_stability_m * 1000.0:.3f}mm, "
f"未通过={','.join(failures) or '无'}"
)
response.message = "联合外参质量不合格:" + "; ".join(
f"{view}: RMS={result.stereo_rms_px:.3f}px, "
f"内点={len(result.inlier_indices)}/"
f"{len(self.stereo_captures[view])}, "
f"旋转稳定性="
f"{math.degrees(result.rotation_stability_rad):.3f}°, "
f"平移稳定性="
f"{result.translation_stability_m * 1000.0:.3f}mm"
for view, result in results.items()
diagnostics
)
return response
transforms = {
@@ -72,132 +72,249 @@ class JointCurveFit:
zero_offset_rad: float = 0.0
VIEW_TAGS: dict[str, dict[str, int]] = {
"front": {
"front_base": 0,
"thumb_cmc": 1,
"thumb_mcp": 2,
"thumb_ip": 3,
"index_roll": 10,
},
"side": {
"side_base": 4,
"index_mcp": 5,
"index_pip": 6,
"index_dip": 7,
},
"top": {
"top_base": 8,
"thumb_yaw": 9,
},
@dataclass(frozen=True)
class HandCalibrationProfile:
side: str
reference_finger: str
view_tags: Mapping[str, Mapping[str, int]]
preflight_view_roles: Mapping[str, tuple[str, ...]]
joint_specs: Mapping[str, JointSpec]
sweep_specs: tuple[SweepSpec, ...]
image_trajectory_joints: frozenset[str]
roll_clearance_commands: Mapping[int, int]
thumb_pitch_clearance_commands: Mapping[int, int]
@property
def measured_joints(self) -> tuple[str, ...]:
return tuple(
name for name, spec in self.joint_specs.items() if spec.measured
)
@property
def active_joints(self) -> tuple[str, ...]:
return tuple(
name for name, spec in self.joint_specs.items() if spec.active
)
@property
def passive_joints(self) -> tuple[str, ...]:
return tuple(
name for name, spec in self.joint_specs.items() if not spec.active
)
@property
def reference_roll_motor(self) -> int:
return int(self.joint_specs[f"{self.reference_finger}_mcp_roll"].motor_index)
@property
def reference_pitch_motor(self) -> int:
return int(self.joint_specs[f"{self.reference_finger}_mcp_pitch"].motor_index)
@property
def reference_pip_motor(self) -> int:
return int(self.joint_specs[f"{self.reference_finger}_pip"].motor_index)
@property
def reference_speed_slot(self) -> int:
return {"index": 1, "middle": 2, "ring": 3, "pinky": 4}[
self.reference_finger
]
_FINGERS: tuple[str, ...] = ("index", "middle", "ring", "pinky")
_MOTOR_BY_JOINT: dict[str, int] = {
**{f"{finger}_mcp_pitch": index + 1 for index, finger in enumerate(_FINGERS)},
**{f"{finger}_mcp_roll": index + 6 for index, finger in enumerate(_FINGERS)},
**{f"{finger}_pip": index + 16 for index, finger in enumerate(_FINGERS)},
}
JOINT_SPECS: dict[str, JointSpec] = {
"thumb_cmc_pitch": JointSpec(
"thumb_cmc_pitch", 0, True, "front", "front_base", "thumb_cmc",
def _build_hand_profile(side: str) -> HandCalibrationProfile:
hand_side = str(side).lower()
if hand_side not in {"left", "right"}:
raise ValueError("hand side must be left or right")
reference = "index" if hand_side == "left" else "pinky"
roll_role = f"{reference}_roll"
mcp_role = f"{reference}_mcp"
pip_role = f"{reference}_pip"
dip_role = f"{reference}_dip"
view_tags: dict[str, dict[str, int]] = {
"front": {
"front_base": 0,
"thumb_cmc": 1,
"thumb_mcp": 2,
"thumb_ip": 3,
roll_role: 10,
},
"side": {
"side_base": 4,
mcp_role: 5,
pip_role: 6,
dip_role: 7,
},
"top": {"top_base": 8, "thumb_yaw": 9},
}
preflight_view_roles = {
view: tuple(tags) for view, tags in view_tags.items()
}
joint_specs: dict[str, JointSpec] = {}
# Both hands use the front base/Tag 1 pair for motor 0. Keeping the
# complete thumb root chain in one calibrated view avoids introducing a
# cross-camera axis-line phase into the URDF zero solve.
thumb_pitch_view = "front"
thumb_pitch_parent = "front_base"
joint_specs["thumb_cmc_pitch"] = JointSpec(
"thumb_cmc_pitch",
0,
True,
thumb_pitch_view,
thumb_pitch_parent,
"thumb_cmc",
zero_kind="urdf_axis_chain",
),
"index_mcp_pitch": JointSpec(
"index_mcp_pitch", 1, True, "side", "side_base", "index_mcp",
zero_kind="urdf_axis_chain",
),
"middle_mcp_pitch": JointSpec(
"middle_mcp_pitch", 2, True, None, None, None,
source_joint="index_mcp_pitch", zero_kind="inherited",
),
"ring_mcp_pitch": JointSpec(
"ring_mcp_pitch", 3, True, None, None, None,
source_joint="index_mcp_pitch", zero_kind="inherited",
),
"pinky_mcp_pitch": JointSpec(
"pinky_mcp_pitch", 4, True, None, None, None,
source_joint="index_mcp_pitch", zero_kind="inherited",
),
"thumb_cmc_roll": JointSpec(
)
for finger in _FINGERS:
name = f"{finger}_mcp_pitch"
source = None if finger == reference else f"{reference}_mcp_pitch"
joint_specs[name] = JointSpec(
name,
_MOTOR_BY_JOINT[name],
True,
"side" if source is None else None,
"side_base" if source is None else None,
mcp_role if source is None else None,
source_joint=source,
zero_kind="urdf_axis_chain" if source is None else "inherited",
)
joint_specs["thumb_cmc_roll"] = JointSpec(
"thumb_cmc_roll", 5, True, "front", "front_base", "thumb_cmc",
zero_kind="urdf_axis_chain",
),
"index_mcp_roll": JointSpec(
"index_mcp_roll", 6, True, "front", "front_base", "index_roll",
zero_kind="urdf_axis_chain",
),
"middle_mcp_roll": JointSpec(
"middle_mcp_roll", 7, True, None, None, None,
source_joint="index_mcp_roll", zero_kind="inherited",
),
"ring_mcp_roll": JointSpec(
"ring_mcp_roll", 8, True, None, None, None,
source_joint="index_mcp_roll", zero_kind="inherited",
),
"pinky_mcp_roll": JointSpec(
"pinky_mcp_roll", 9, True, None, None, None,
source_joint="index_mcp_roll", zero_kind="inherited",
),
"thumb_cmc_yaw": JointSpec(
)
for finger in _FINGERS:
name = f"{finger}_mcp_roll"
source = None if finger == reference else f"{reference}_mcp_roll"
joint_specs[name] = JointSpec(
name,
_MOTOR_BY_JOINT[name],
True,
"front" if source is None else None,
"front_base" if source is None else None,
roll_role if source is None else None,
source_joint=source,
zero_kind="urdf_axis_chain" if source is None else "inherited",
)
joint_specs["thumb_cmc_yaw"] = JointSpec(
"thumb_cmc_yaw", 10, True, "top", "top_base", "thumb_yaw",
zero_kind="urdf_axis_chain",
),
"thumb_mcp": JointSpec(
)
joint_specs["thumb_mcp"] = JointSpec(
"thumb_mcp", 15, True, "front", "thumb_cmc", "thumb_mcp",
zero_kind="urdf_axis_chain",
),
"index_pip": JointSpec(
"index_pip", 16, True, "side", "index_mcp", "index_pip",
zero_kind="urdf_axis_chain",
),
"middle_pip": JointSpec(
"middle_pip", 17, True, None, None, None,
source_joint="index_pip", zero_kind="inherited",
),
"ring_pip": JointSpec(
"ring_pip", 18, True, None, None, None,
source_joint="index_pip", zero_kind="inherited",
),
"pinky_pip": JointSpec(
"pinky_pip", 19, True, None, None, None,
source_joint="index_pip", zero_kind="inherited",
),
"thumb_ip": JointSpec(
)
for finger in _FINGERS:
name = f"{finger}_pip"
source = None if finger == reference else f"{reference}_pip"
joint_specs[name] = JointSpec(
name,
_MOTOR_BY_JOINT[name],
True,
"side" if source is None else None,
mcp_role if source is None else None,
pip_role if source is None else None,
source_joint=source,
zero_kind="urdf_axis_chain" if source is None else "inherited",
)
joint_specs["thumb_ip"] = JointSpec(
"thumb_ip", 15, False, "front", "thumb_mcp", "thumb_ip",
),
"index_dip": JointSpec(
"index_dip", 16, False, "side", "index_pip", "index_dip",
),
"middle_dip": JointSpec(
"middle_dip", 17, False, None, None, None,
source_joint="index_dip",
),
"ring_dip": JointSpec(
"ring_dip", 18, False, None, None, None,
source_joint="index_dip",
),
"pinky_dip": JointSpec(
"pinky_dip", 19, False, None, None, None,
source_joint="index_dip",
),
}
)
for finger in _FINGERS:
name = f"{finger}_dip"
source = None if finger == reference else f"{reference}_dip"
joint_specs[name] = JointSpec(
name,
_MOTOR_BY_JOINT[f"{finger}_pip"],
False,
"side" if source is None else None,
pip_role if source is None else None,
dip_role if source is None else None,
source_joint=source,
)
sweep_specs = (
SweepSpec(thumb_pitch_view, 0, ("thumb_cmc_pitch",)),
SweepSpec("front", 5, ("thumb_cmc_roll",)),
SweepSpec("front", 15, ("thumb_mcp", "thumb_ip")),
SweepSpec("front", _MOTOR_BY_JOINT[f"{reference}_mcp_roll"], (
f"{reference}_mcp_roll",
)),
SweepSpec("side", _MOTOR_BY_JOINT[f"{reference}_mcp_pitch"], (
f"{reference}_mcp_pitch",
)),
SweepSpec("side", _MOTOR_BY_JOINT[f"{reference}_pip"], (
f"{reference}_pip", f"{reference}_dip",
)),
SweepSpec("top", 10, ("thumb_cmc_yaw",)),
)
image_joints = frozenset(
{
"thumb_cmc_pitch",
"thumb_cmc_roll",
"thumb_mcp",
"thumb_ip",
f"{reference}_mcp_roll",
f"{reference}_mcp_pitch",
f"{reference}_pip",
}
)
measured_roll = _MOTOR_BY_JOINT[f"{reference}_mcp_roll"]
# The roll channels are mirrored mechanically between hands. Command 0
# moves the three non-reference fingers to the camera-right clearance pose
# on the left hand, while the same physical pose is command 255 on the
# right hand. Using 0 for both sides made the right index/middle/ring
# fingers lean left and could obstruct or load the pinky roll sweep.
roll_clearance_command = 0 if hand_side == "left" else 255
clearance = {
_MOTOR_BY_JOINT[f"{finger}_mcp_roll"]: roll_clearance_command
for finger in _FINGERS
if finger != reference
}
assert measured_roll not in clearance
# Motor 0 is observed from the front with both other thumb-root channels
# fully open. Store the commands explicitly for the right hand so a
# caller-provided baseline cannot silently restore the obsolete 50/120
# pose and hide Tag 1 from the front camera.
thumb_pitch_clearance = {10: 255, 5: 255} if hand_side == "right" else {}
return HandCalibrationProfile(
side=hand_side,
reference_finger=reference,
view_tags=view_tags,
preflight_view_roles=preflight_view_roles,
joint_specs=joint_specs,
sweep_specs=sweep_specs,
image_trajectory_joints=image_joints,
roll_clearance_commands=clearance,
thumb_pitch_clearance_commands=thumb_pitch_clearance,
)
SWEEP_SPECS: tuple[SweepSpec, ...] = (
SweepSpec("front", 0, ("thumb_cmc_pitch",)),
SweepSpec("front", 5, ("thumb_cmc_roll",)),
SweepSpec("front", 15, ("thumb_mcp", "thumb_ip")),
SweepSpec("front", 6, ("index_mcp_roll",)),
SweepSpec("side", 1, ("index_mcp_pitch",)),
SweepSpec("side", 16, ("index_pip", "index_dip")),
SweepSpec("top", 10, ("thumb_cmc_yaw",)),
)
LEFT_HAND_PROFILE = _build_hand_profile("left")
RIGHT_HAND_PROFILE = _build_hand_profile("right")
MEASURED_JOINTS: tuple[str, ...] = tuple(
name for name, spec in JOINT_SPECS.items() if spec.measured
)
ACTIVE_JOINTS: tuple[str, ...] = tuple(
name for name, spec in JOINT_SPECS.items() if spec.active
)
PASSIVE_JOINTS: tuple[str, ...] = tuple(
name for name, spec in JOINT_SPECS.items() if not spec.active
)
def get_hand_calibration_profile(side: str) -> HandCalibrationProfile:
value = str(side).lower()
if value == "left":
return LEFT_HAND_PROFILE
if value == "right":
return RIGHT_HAND_PROFILE
raise ValueError("hand side must be left or right")
# Backwards-compatible aliases keep the existing left-hand API stable.
VIEW_TAGS = LEFT_HAND_PROFILE.view_tags
JOINT_SPECS = LEFT_HAND_PROFILE.joint_specs
SWEEP_SPECS = LEFT_HAND_PROFILE.sweep_specs
MEASURED_JOINTS = LEFT_HAND_PROFILE.measured_joints
ACTIVE_JOINTS = LEFT_HAND_PROFILE.active_joints
PASSIVE_JOINTS = LEFT_HAND_PROFILE.passive_joints
SPLAY_JOINTS: tuple[str, ...] = (
"index_mcp_roll",
"middle_mcp_roll",
@@ -213,25 +330,11 @@ SPLAY_JOINTS: tuple[str, ...] = (
# 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",
"thumb_cmc_roll",
"thumb_mcp",
"thumb_ip",
"index_mcp_roll",
"index_mcp_pitch",
"index_pip",
}
)
IMAGE_TRAJECTORY_JOINTS = LEFT_HAND_PROFILE.image_trajectory_joints
# Keep the three unmeasured finger-roll motors away from the front camera's
# line of sight while index_mcp_roll is measured.
INDEX_ROLL_CLEARANCE_COMMANDS: dict[int, int] = {
7: 0,
8: 0,
9: 0,
}
INDEX_ROLL_CLEARANCE_COMMANDS = LEFT_HAND_PROFILE.roll_clearance_commands
# Hold thumb CMC roll at a camera-friendly pose while thumb CMC yaw is
# measured. This keeps the moving top-view tag sufficiently front-facing.
@@ -240,10 +343,16 @@ THUMB_YAW_CLEARANCE_COMMANDS: dict[int, int] = {
}
def calibration_auxiliary_commands(spec: SweepSpec) -> dict[int, int]:
def calibration_auxiliary_commands(
spec: SweepSpec,
*,
profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
) -> dict[int, int]:
"""Return motors that must remain fixed throughout one calibration task."""
if spec.motor_index == 6:
return dict(INDEX_ROLL_CLEARANCE_COMMANDS)
if spec.motor_index == 0:
return dict(profile.thumb_pitch_clearance_commands)
if spec.motor_index == profile.reference_roll_motor:
return dict(profile.roll_clearance_commands)
if spec.motor_index == 10:
return dict(THUMB_YAW_CLEARANCE_COMMANDS)
return {}
@@ -253,11 +362,12 @@ def build_full_hand_command(
motor_index: int,
command_u8: int,
baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
) -> list[int]:
if len(baseline) != 20:
raise ValueError("baseline must contain exactly 20 values")
motor = int(motor_index)
if motor not in {spec.motor_index for spec in JOINT_SPECS.values()}:
if motor not in {spec.motor_index for spec in profile.joint_specs.values()}:
raise ValueError("motor_index is not a controlled G20 calibration channel")
command = int(command_u8)
if not 0 <= command <= 255:
@@ -273,15 +383,17 @@ def build_calibration_motion_command(
spec: SweepSpec,
command_u8: int,
baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
) -> list[int]:
"""Build a sweep command, including any required clearance pose."""
result = build_full_hand_command(
spec.motor_index,
command_u8,
baseline=baseline,
profile=profile,
)
for motor_index, auxiliary_command in calibration_auxiliary_commands(
spec
spec, profile=profile
).items():
result[motor_index] = auxiliary_command
return result
@@ -293,6 +405,7 @@ def build_calibration_speed_profile(
normal_speed: int,
index_roll_speed: int,
index_flex_speed: int,
profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
) -> list[int]:
"""Return G20's five per-finger speeds for one calibration task."""
normal = int(normal_speed)
@@ -303,10 +416,13 @@ def build_calibration_speed_profile(
):
raise ValueError("calibration speeds must be in [0, 255]")
speeds = [normal] * 5
if spec.motor_index == 6:
speeds[1] = index_roll
elif spec.motor_index in {1, 16}:
speeds[1] = index_flex
if spec.motor_index == profile.reference_roll_motor:
speeds[profile.reference_speed_slot] = index_roll
elif spec.motor_index in {
profile.reference_pitch_motor,
profile.reference_pip_motor,
}:
speeds[profile.reference_speed_slot] = index_flex
return speeds
@@ -487,9 +603,10 @@ def fit_measured_joint_curve(
image_maximum_radial_rms_px: float = 2.0,
image_maximum_radial_p95_px: float = 3.5,
image_minimum_radius_px: float = 20.0,
profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
) -> JointCurveFit:
"""Select the stable trajectory representation for one measured joint."""
if str(joint_name) in IMAGE_TRAJECTORY_JOINTS:
if str(joint_name) in profile.image_trajectory_joints:
return fit_joint_image_curve(
records,
maximum_radial_rms_px=image_maximum_radial_rms_px,
@@ -624,11 +741,13 @@ def build_compact_payload(
validation_errors_rad: Sequence[float],
passed: bool,
baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
side: str = "left",
) -> dict[str, Any]:
if set(measured_fits) != set(MEASURED_JOINTS):
profile = get_hand_calibration_profile(side)
if set(measured_fits) != set(profile.measured_joints):
raise ValueError("measured_fits must contain all directly measured joints")
expected_active = {
name for name, spec in JOINT_SPECS.items() if spec.active
name for name, spec in profile.joint_specs.items() if spec.active
}
if set(urdf_zero_offsets_rad) != expected_active:
raise ValueError("urdf_zero_offsets_rad has the wrong active-joint set")
@@ -636,7 +755,7 @@ def build_compact_payload(
raise ValueError("baseline must contain exactly 20 commands")
joints: dict[str, dict[str, Any]] = {}
for name, spec in JOINT_SPECS.items():
for name, spec in profile.joint_specs.items():
source_name = spec.source_joint or name
fit = measured_fits[source_name]
joint: dict[str, Any] = {
@@ -662,7 +781,7 @@ def build_compact_payload(
payload = {
"schema_version": 4,
"model": "G20",
"side": "left",
"side": profile.side,
"serial_number": str(serial_number),
"angle_unit": "rad",
"command_range": [0, 255],
@@ -691,18 +810,19 @@ def validate_compact_payload(payload: Mapping[str, Any]) -> None:
raise ValueError("compact calibration has unexpected top-level fields")
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["model"] != "G20" or payload["side"] not in {"left", "right"}:
raise ValueError("payload must describe a left or right G20")
profile = get_hand_calibration_profile(str(payload["side"]))
if payload["angle_unit"] != "rad" or payload["command_range"] != [0, 255]:
raise ValueError("payload angle or command units are invalid")
baseline = payload["baseline_command_u8"]
if not isinstance(baseline, list) or len(baseline) != 20:
raise ValueError("baseline_command_u8 must contain 20 values")
joints = payload["joints"]
if not isinstance(joints, Mapping) or set(joints) != set(JOINT_SPECS):
if not isinstance(joints, Mapping) or set(joints) != set(profile.joint_specs):
raise ValueError("payload must contain exactly 21 G20 joints")
for name, spec in JOINT_SPECS.items():
for name, spec in profile.joint_specs.items():
joint = joints[name]
allowed = {"motor_index", "angle_rad"}
allowed.add("zero_command_u8" if spec.active else "passive")
@@ -747,8 +867,9 @@ 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")
if spec.active and joint["zero_angles"] != source["zero_angles"]:
raise ValueError(f"{name} must copy its source zero offset")
# source_joint denotes a dynamic command-angle curve source. The
# absolute URDF zero belongs to an independent motor/assembly and
# must remain per-joint unless it was independently observed.
quality = payload["quality"]
if set(quality) != {
@@ -0,0 +1,846 @@
"""Safely replay a complete three-camera session without moving the hand."""
from __future__ import annotations
import argparse
from collections import defaultdict
from dataclasses import replace
import hashlib
import json
import math
import os
from pathlib import Path
import re
import tempfile
from typing import Any, Mapping, Sequence
import xml.etree.ElementTree as ET
import numpy as np
from scipy.spatial.transform import Rotation
import yaml
from .extrinsics import load_three_camera_extrinsics
from .full_hand import (
HandCalibrationProfile,
JointCurveFit,
build_calibration_motion_command,
build_compact_payload,
get_hand_calibration_profile,
validate_compact_payload,
)
from .storage import atomic_write_json
from .urdf_zero import (
JointAxisMeasurement,
UrdfKinematicModel,
_angles_from_state,
fit_joint_axis_measurement,
fit_rotation_joint_curve,
get_zero_calibration_profile,
rotation_curve_holdout_errors,
solve_urdf_zero_offsets,
write_zero_corrected_urdf,
)
def _sha256(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 _output_suffix(output_tag: str | None) -> str:
"""Return a filename-safe suffix for a non-destructive replay variant."""
if output_tag is None:
return ""
tag = str(output_tag)
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", tag):
raise ValueError(
"output tag must be 1-64 filename-safe characters, beginning "
"with a letter or digit"
)
return f"_{tag}"
def _load_parameters(path: Path) -> dict[str, Any]:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
return dict(payload["g20_calibration"]["ros__parameters"])
def _latest_attempt_records(
rows: Sequence[Mapping[str, Any]],
) -> dict[str, list[dict[str, Any]]]:
"""Reproduce the online retry buffer from append-only raw samples.
Online retry clears only the failed joint/cycle/direction from memory,
while JSONL deliberately retains every attempt for audit. Offline replay
must therefore select the greatest attempt independently for each logical
trajectory rather than mixing rejected attempts into the final fit.
"""
samples = [dict(row) for row in rows if row.get("kind") == "sample"]
latest_attempt: dict[tuple[str, int, str], int] = {}
for row in samples:
key = (
str(row["joint"]),
int(row["cycle"]),
str(row["direction"]),
)
latest_attempt[key] = max(
latest_attempt.get(key, 0), int(row.get("attempt", 1))
)
records: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in samples:
key = (
str(row["joint"]),
int(row["cycle"]),
str(row["direction"]),
)
if int(row.get("attempt", 1)) == latest_attempt[key]:
records[key[0]].append(row)
return dict(records)
def _load_raw_session(
session_dir: Path,
) -> tuple[dict[str, Any], dict[str, list[dict[str, Any]]], Path]:
raw_path = session_dir / "raw_samples.jsonl"
if not raw_path.is_file():
raise ValueError(f"raw session does not exist: {raw_path}")
rows = [
json.loads(line)
for line in raw_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
starts = [row for row in rows if row.get("kind") == "session_start"]
if len(starts) != 1:
raise ValueError("raw session must contain exactly one session_start")
return starts[0], _latest_attempt_records(rows), raw_path
def _fit_curve(
name: str,
records: Sequence[Mapping[str, Any]],
*,
profile: HandCalibrationProfile,
baseline: Sequence[int],
) -> JointCurveFit:
motor = profile.joint_specs[name].motor_index
return fit_rotation_joint_curve(
records,
zero_command_u8=int(baseline[motor]),
)
def _fit_axes(
*,
records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
profile: HandCalibrationProfile,
baseline: Sequence[int],
extrinsics_file: Path,
repetitions: int,
) -> list[JointAxisMeasurement]:
zero_profile = get_zero_calibration_profile(profile.side)
extrinsics = load_three_camera_extrinsics(extrinsics_file)
cache: dict[tuple[str, int], JointAxisMeasurement] = {}
reference = profile.reference_finger
upstream_by_joint = {
"thumb_mcp": "thumb_cmc_pitch",
"thumb_ip": "thumb_mcp",
f"{reference}_pip": f"{reference}_mcp_pitch",
f"{reference}_dip": f"{reference}_pip",
}
def fit_one(name: str, cycle: int) -> JointAxisMeasurement:
key = (name, cycle)
if key in cache:
return cache[key]
upstream = upstream_by_joint.get(name)
constraint = None if upstream is None else fit_one(upstream, cycle).axis_common_xyz
spec = profile.joint_specs[name]
view_normal = extrinsics.transform(spec.view)[:3, :3] @ np.asarray(
[0.0, 0.0, 1.0], dtype=float
)
result = fit_joint_axis_measurement(
name,
records_by_joint[name],
cycle=cycle,
zero_command_u8=int(baseline[spec.motor_index]),
axis_common_constraint=constraint,
constrained_circle_joints=zero_profile.constrained_circle_joints,
view_normal_common_xyz=view_normal,
)
condition = build_calibration_motion_command(
spec,
int(baseline[spec.motor_index]),
baseline=baseline,
profile=profile,
)
result = replace(
result,
condition_command_u8=tuple(float(value) for value in condition),
view_normal_common_xyz=tuple(float(value) for value in view_normal),
)
cache[key] = result
return result
return [
fit_one(name, cycle)
for name in zero_profile.axis_joints
for cycle in range(repetitions)
]
def _maximum_undirected_axis_difference(axes: Sequence[Sequence[float]]) -> float:
maximum = 0.0
for left in axes:
for right in axes:
maximum = max(
maximum,
math.acos(
abs(
float(
np.clip(
np.asarray(left, dtype=float)
@ np.asarray(right, dtype=float),
-1.0,
1.0,
)
)
)
),
)
return maximum
def _quality_failures(
*,
records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
profile: HandCalibrationProfile,
baseline: Sequence[int],
fits: Mapping[str, JointCurveFit],
axes: Sequence[JointAxisMeasurement],
parameters: Mapping[str, Any],
) -> list[str]:
failures: list[str] = []
repetitions = int(parameters["repetitions"])
zero_profile = get_zero_calibration_profile(profile.side)
axis_by_key = {(item.joint, item.cycle): item for item in axes}
expected_directions = {"decreasing", "increasing"}
for name in profile.measured_joints:
records = list(records_by_joint.get(name, ()))
if not records:
failures.append(f"{name}: no samples")
continue
attempts_by_direction: dict[tuple[int, str], set[int]] = defaultdict(set)
for record in records:
attempts_by_direction[
(int(record["cycle"]), str(record["direction"]))
].add(int(record.get("attempt", 1)))
for cycle in range(repetitions):
for direction in expected_directions:
selected = [
record
for record in records
if int(record["cycle"]) == cycle
and str(record["direction"]) == direction
]
attempts = attempts_by_direction.get((cycle, direction), set())
if len(attempts) != 1:
failures.append(
f"{name} cycle {cycle + 1} {direction}: ambiguous attempts {sorted(attempts)}"
)
continue
commands = sorted({int(record["command_u8"]) for record in selected})
if len(selected) < int(parameters["minimum_sweep_frames"]):
failures.append(f"{name} cycle {cycle + 1} {direction}: too few frames")
if not commands or max(commands) - min(commands) < float(
parameters["minimum_state_span_u8"]
):
failures.append(f"{name} cycle {cycle + 1} {direction}: insufficient span")
if len(commands) < int(parameters["minimum_sweep_bins"]):
failures.append(f"{name} cycle {cycle + 1} {direction}: insufficient bins")
if 0 not in commands or 255 not in commands:
failures.append(f"{name} cycle {cycle + 1} {direction}: endpoint missing")
if commands and max(np.diff(commands), default=0) > int(
parameters["maximum_bin_gap"]
):
failures.append(f"{name} cycle {cycle + 1} {direction}: bin gap")
sync_p95 = float(
np.percentile(
[float(record.get("state_image_sync_error_ms", 0.0)) for record in records],
95.0,
)
)
if sync_p95 > float(parameters["maximum_state_image_skew_ms"]):
failures.append(f"{name}: state/image sync p95 {sync_p95:.3f}ms")
spec = profile.joint_specs[name]
fit = fits[name]
orthogonal_limit = math.radians(
float(
parameters[
"active_maximum_rotation_orthogonal_rms_deg"
if spec.active
else "passive_maximum_rotation_orthogonal_rms_deg"
]
)
)
if float(fit.quality["rotation_orthogonal_rms_rad"]) > orthogonal_limit:
failures.append(f"{name}: rotation orthogonal RMS")
if float(fit.quality["arc_rad"]) < math.radians(
float(parameters["trajectory_minimum_arc_deg"])
):
failures.append(f"{name}: trajectory arc")
monotonic_limit = math.radians(
float(
parameters[
"maximum_monotonic_correction_deg"
if spec.active
else "passive_maximum_monotonic_correction_deg"
]
)
)
hysteresis_limit = math.radians(
float(
parameters[
"maximum_hysteresis_deg"
if spec.active
else "passive_maximum_hysteresis_deg"
]
)
)
if fit.maximum_monotonic_correction_rad > monotonic_limit:
failures.append(f"{name}: monotonic correction")
if fit.maximum_hysteresis_rad > hysteresis_limit:
failures.append(f"{name}: hysteresis")
cycle_travels: list[float] = []
cycle_axes: list[Sequence[float]] = []
cycle_axis_sources: list[str] = []
for cycle in range(repetitions):
cycle_fit = _fit_curve(
name,
[record for record in records if int(record["cycle"]) == cycle],
profile=profile,
baseline=baseline,
)
cycle_travels.append(
abs(float(cycle_fit.angle_rad[0]) - float(cycle_fit.angle_rad[255]))
)
axis = axis_by_key[(name, cycle)]
cycle_axes.append(axis.axis_common_xyz)
cycle_axis_sources.append(axis.axis_direction_source)
if axis.radial_rms_m > float(parameters["axis_maximum_radial_rms_m"]):
failures.append(f"{name} cycle {cycle + 1}: radial RMS")
if axis.pose_axis_line_rms_m > float(
parameters["axis_maximum_pose_line_rms_m"]
):
failures.append(
f"{name} cycle {cycle + 1}: pose axis-line RMS"
)
if name not in zero_profile.constrained_circle_joints:
plane_limit = float(
parameters[
"axis_maximum_plane_rms_m"
if spec.active
else "passive_axis_maximum_plane_rms_m"
]
)
if axis.plane_rms_m > plane_limit:
failures.append(f"{name} cycle {cycle + 1}: plane RMS")
if (
name not in zero_profile.constrained_circle_joints
and axis.rotation_circle_axis_difference_rad > math.radians(
float(parameters["axis_maximum_rotation_circle_difference_deg"])
)
):
failures.append(f"{name} cycle {cycle + 1}: axis disagreement")
travel_limit = math.radians(
float(
parameters[
"trajectory_maximum_cycle_travel_difference_deg"
if spec.active
else "passive_maximum_cycle_travel_difference_deg"
]
)
)
if max(cycle_travels) - min(cycle_travels) > travel_limit:
failures.append(f"{name}: cycle travel difference")
if (
not all(
source == "upstream_constraint"
for source in cycle_axis_sources
)
and _maximum_undirected_axis_difference(cycle_axes) > math.radians(
float(parameters["zero_maximum_axis_cycle_difference_deg"])
)
):
failures.append(f"{name}: cycle axis difference")
return failures
def _joint_xml(path: Path) -> dict[str, ET.Element]:
return {
str(joint.get("name")): joint
for joint in ET.parse(path).getroot().findall("joint")
}
def _triplet(value: str) -> np.ndarray:
return np.asarray([float(item) for item in value.split()], dtype=float)
def _validate_corrected_urdf(
*,
source: Path,
corrected: Path,
offsets: Mapping[str, float],
axes: Sequence[JointAxisMeasurement],
curves: Mapping[str, JointCurveFit],
motor_by_joint: Mapping[str, int],
inherited_zero_joints: Mapping[str, str],
) -> dict[str, float]:
source_joints = _joint_xml(source)
corrected_joints = _joint_xml(corrected)
if set(source_joints) != set(corrected_joints):
raise ValueError("corrected URDF changed the joint set")
maximum_origin_rotation_error = 0.0
maximum_origin_translation_error = 0.0
for name, original_joint in source_joints.items():
corrected_joint = corrected_joints[name]
original_origin = original_joint.find("origin")
corrected_origin = corrected_joint.find("origin")
if original_origin is None or corrected_origin is None:
continue
original_xyz = _triplet(original_origin.get("xyz", "0 0 0"))
corrected_xyz = _triplet(corrected_origin.get("xyz", "0 0 0"))
maximum_origin_translation_error = max(
maximum_origin_translation_error,
float(np.linalg.norm(corrected_xyz - original_xyz)),
)
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 = 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")
)
axis /= np.linalg.norm(axis)
expected = original_rotation * Rotation.from_rotvec(
axis * float(offsets[name])
)
error = float((expected.inv() * corrected_rotation).magnitude())
maximum_origin_rotation_error = max(maximum_origin_rotation_error, error)
original_limit = original_joint.find("limit")
corrected_limit = corrected_joint.find("limit")
if original_limit is not None and corrected_limit is not None:
if (
original_limit.get("lower") != corrected_limit.get("lower")
or original_limit.get("upper") != corrected_limit.get("upper")
):
raise ValueError(f"corrected URDF unexpectedly changed {name} limits")
if maximum_origin_translation_error > 1.0e-12:
raise ValueError("corrected URDF changed a joint origin translation")
if maximum_origin_rotation_error > 1.0e-10:
raise ValueError("corrected URDF does not implement T_original * Rot(axis, offset)")
original_model = UrdfKinematicModel(source)
corrected_model = UrdfKinematicModel(corrected)
maximum_axis_error = 0.0
maximum_point_error = 0.0
for measurement in axes:
state = (
measurement.condition_state_u8
if measurement.condition_command_u8 is None
else measurement.condition_command_u8
)
angles = _angles_from_state(
state,
curves=curves,
motor_by_joint=motor_by_joint,
inherited_zero_joints=inherited_zero_joints,
)
expected_axis, expected_point = original_model.axis_line(
measurement.joint,
zero_offsets=offsets,
joint_angles=angles,
)
actual_axis, actual_point = corrected_model.axis_line(
measurement.joint,
zero_offsets={},
joint_angles=angles,
)
maximum_axis_error = max(
maximum_axis_error,
math.acos(float(np.clip(expected_axis @ actual_axis, -1.0, 1.0))),
)
maximum_point_error = max(
maximum_point_error,
float(np.linalg.norm(expected_point - actual_point)),
)
if maximum_axis_error > 1.0e-7 or maximum_point_error > 1.0e-10:
raise ValueError("written URDF kinematics differ from the solved correction")
return {
"maximum_origin_rotation_error_rad": maximum_origin_rotation_error,
"maximum_origin_translation_error_m": maximum_origin_translation_error,
"maximum_axis_equivalence_error_rad": maximum_axis_error,
"maximum_axis_point_equivalence_error_m": maximum_point_error,
}
def replay_session(
session_dir: str | Path,
*,
serial_number: str | None = None,
config_file: str | Path | None = None,
write_outputs: bool = False,
output_tag: str | None = None,
) -> dict[str, Any]:
session = Path(session_dir).expanduser().resolve()
package_root = Path(__file__).resolve().parents[1]
config = (
package_root / "config" / "three_camera_calibration.yaml"
if config_file is None
else Path(config_file).expanduser().resolve()
)
parameters = _load_parameters(config)
start, records_by_joint, raw_path = _load_raw_session(session)
side = str(start["hand_type"]).lower()
profile = get_hand_calibration_profile(side)
zero_profile = get_zero_calibration_profile(side)
baseline = tuple(int(value) for value in start["baseline_command_u8"])
if len(baseline) != 20:
raise ValueError("session baseline must contain exactly 20 commands")
if set(records_by_joint) != set(profile.measured_joints):
raise ValueError("raw session does not contain exactly the measured joint set")
source_urdf = Path(start["source_urdf_path"]).expanduser().resolve()
extrinsics_file = Path(start["camera_extrinsics_file"]).expanduser().resolve()
if not source_urdf.is_file() or not extrinsics_file.is_file():
raise ValueError("session source URDF or camera extrinsics is missing")
if "zero_calibrated" in source_urdf.stem.lower():
raise ValueError("offline replay requires the original CAD URDF")
hand_serial = str(serial_number or session.parent.name)
output_suffix = _output_suffix(output_tag)
repetitions = int(parameters["repetitions"])
measured_fits = {
name: _fit_curve(
name,
records_by_joint[name],
profile=profile,
baseline=baseline,
)
for name in profile.measured_joints
}
training_fits = {
name: _fit_curve(
name,
[
record
for record in records_by_joint[name]
if int(record["cycle"]) in {0, 1}
],
profile=profile,
baseline=baseline,
)
for name in profile.measured_joints
}
axes = _fit_axes(
records_by_joint=records_by_joint,
profile=profile,
baseline=baseline,
extrinsics_file=extrinsics_file,
repetitions=repetitions,
)
failures = _quality_failures(
records_by_joint=records_by_joint,
profile=profile,
baseline=baseline,
fits=measured_fits,
axes=axes,
parameters=parameters,
)
if failures:
raise ValueError("offline trajectory/axis validation failed: " + "; ".join(failures))
holdout_by_joint = {
name: rotation_curve_holdout_errors(
training_fits[name],
[
record
for record in records_by_joint[name]
if int(record["cycle"]) == 2
],
zero_command_u8=int(
baseline[profile.joint_specs[name].motor_index]
),
)
for name in profile.measured_joints
}
trajectory_errors = np.abs(
np.asarray(
[value for values in holdout_by_joint.values() for value in values],
dtype=float,
)
)
maximum_validation_mae = math.radians(
float(parameters["maximum_validation_mae_deg"])
)
maximum_validation_p95 = math.radians(
float(parameters["maximum_validation_p95_deg"])
)
trajectory_mae = float(np.mean(trajectory_errors))
trajectory_p95 = float(np.percentile(trajectory_errors, 95.0))
if (
trajectory_mae > maximum_validation_mae
or trajectory_p95 > maximum_validation_p95
):
raise ValueError("third-cycle trajectory holdout failed")
motor_by_joint = {
name: int(spec.motor_index) for name, spec in profile.joint_specs.items()
}
joint_limits: dict[str, float] = {}
solve_arguments = {
"source_urdf": source_urdf,
"measurements": axes,
"motor_by_joint": motor_by_joint,
"maximum_offset_rad": math.radians(float(parameters["zero_maximum_offset_deg"])),
"finger_maximum_offset_rad": math.radians(
float(parameters.get("zero_finger_maximum_offset_deg", 3.0))
),
"joint_maximum_offset_rad": joint_limits,
"maximum_cycle_difference_rad": math.radians(
float(parameters["zero_maximum_axis_cycle_difference_deg"])
),
"maximum_axis_cone_mismatch_rad": math.radians(
float(parameters["zero_maximum_axis_cone_mismatch_deg"])
),
"maximum_pose_axis_line_rms_m": float(
parameters["axis_maximum_pose_line_rms_m"]
),
"maximum_validation_mae_rad": maximum_validation_mae,
"maximum_validation_p95_rad": maximum_validation_p95,
"hand_type": side,
}
holdout_zero = solve_urdf_zero_offsets(curves=training_fits, **solve_arguments)
if not holdout_zero.passed:
failure = {
"reasons": dict(holdout_zero.failure_reasons),
"fitted_offsets_deg": {
name: math.degrees(value)
for name, value in holdout_zero.direct_offsets_rad.items()
},
"cycle_offsets_deg": {
name: [math.degrees(value) for value in values]
for name, values in holdout_zero.cycle_offsets_rad.items()
},
}
raise ValueError(
"third-cycle zero/URDF holdout failed: "
+ json.dumps(failure, ensure_ascii=False, sort_keys=True)
)
final_zero = solve_urdf_zero_offsets(curves=measured_fits, **solve_arguments)
if not final_zero.passed:
raise ValueError(
"all-cycle zero refit failed: "
+ json.dumps(
{
"reasons": dict(final_zero.failure_reasons),
"fitted_offsets_deg": {
name: math.degrees(value)
for name, value in final_zero.direct_offsets_rad.items()
},
},
ensure_ascii=False,
sort_keys=True,
)
)
for target, source_name in zero_profile.inherited_static_zero_joints.items():
if final_zero.all_active_offsets_rad[target] != final_zero.direct_offsets_rad[source_name]:
raise ValueError(f"inherited static zero mismatch: {target} <- {source_name}")
for target in (
set(zero_profile.inherited_zero_joints)
- set(zero_profile.inherited_static_zero_joints)
):
if final_zero.all_active_offsets_rad[target] != 0.0:
raise ValueError(f"unobserved static zero must retain source CAD: {target}")
validation_errors = [
float(value) for values in holdout_by_joint.values() for value in values
]
validation_errors.extend(float(value) for value in holdout_zero.validation_errors_rad)
payload = build_compact_payload(
serial_number=hand_serial,
measured_fits=measured_fits,
urdf_zero_offsets_rad=final_zero.all_active_offsets_rad,
validation_errors_rad=validation_errors,
passed=True,
baseline=baseline,
side=side,
)
validate_compact_payload(payload)
stamp = session.name
final_json = session / (
f"g20_{side}_{hand_serial}_calibration{output_suffix}.json"
)
expected_urdf_name = (
f"{source_urdf.stem}_zero_calibrated_{hand_serial}_{stamp}"
f"{output_suffix}.urdf"
)
final_urdf = source_urdf.parent / expected_urdf_name
report_path = session / (
f"g20_{side}_{hand_serial}_offline_validation{output_suffix}.json"
)
if write_outputs:
existing = [path for path in (final_json, final_urdf, report_path) if path.exists()]
if existing:
raise ValueError(
"refusing to overwrite replay outputs: "
+ ", ".join(str(path) for path in existing)
)
source_hash_before = _sha256(source_urdf)
with tempfile.TemporaryDirectory(prefix="offline_replay_", dir=session) as temporary:
candidate = write_zero_corrected_urdf(
source_urdf=source_urdf,
output_directory=temporary,
serial_number=hand_serial,
offsets_rad=final_zero.all_active_offsets_rad,
timestamp=stamp,
)
urdf_checks = _validate_corrected_urdf(
source=source_urdf,
corrected=candidate,
offsets=final_zero.all_active_offsets_rad,
axes=axes,
curves=measured_fits,
motor_by_joint=motor_by_joint,
inherited_zero_joints=zero_profile.inherited_zero_joints,
)
residual_zero = solve_urdf_zero_offsets(
curves=measured_fits,
fixed_direct_zero_offsets_rad={
name: 0.0
for name in zero_profile.fixed_direct_zero_offsets_rad
},
static_output_zero_offsets_rad={
name: 0.0
for name in zero_profile.static_output_zero_offsets_rad
},
**{**solve_arguments, "source_urdf": candidate},
)
maximum_residual_offset = max(
abs(float(value)) for value in residual_zero.direct_offsets_rad.values()
)
if not residual_zero.passed or maximum_residual_offset > math.radians(0.3):
raise ValueError("written URDF retains a significant zero correction")
candidate_hash = _sha256(candidate)
if write_outputs:
os.replace(candidate, final_urdf)
if _sha256(source_urdf) != source_hash_before:
raise ValueError("source URDF changed during offline replay")
report: dict[str, Any] = {
"passed": True,
"session_dir": str(session),
"side": side,
"serial_number": hand_serial,
"output_tag": output_tag,
"raw_samples_sha256": _sha256(raw_path),
"source_urdf": str(source_urdf),
"source_urdf_sha256": source_hash_before,
"joint_limits_deg": {
"finger_default": float(
parameters.get("zero_finger_maximum_offset_deg", 3.0)
),
"thumb_default": float(parameters["zero_maximum_offset_deg"]),
},
"static_zero_policy": "direct_measurements_only",
"fixed_zero_offsets_deg": {
name: math.degrees(value)
for name, value in zero_profile.fixed_direct_zero_offsets_rad.items()
},
"static_output_zero_offsets_deg": {
name: math.degrees(value)
for name, value in zero_profile.static_output_zero_offsets_rad.items()
},
"direct_offsets_deg": {
name: math.degrees(value)
for name, value in final_zero.direct_offsets_rad.items()
},
"cycle_offsets_deg": {
name: [math.degrees(value) for value in values]
for name, values in holdout_zero.cycle_offsets_rad.items()
},
"offset_uncertainty_deg": {
name: math.degrees(value)
for name, value in holdout_zero.offset_uncertainty_rad.items()
},
"trajectory_holdout_mae_deg": math.degrees(trajectory_mae),
"trajectory_holdout_p95_deg": math.degrees(trajectory_p95),
"zero_holdout_error_deg": {
name: math.degrees(value)
for name, value in holdout_zero.validation_error_by_joint_rad.items()
},
"zero_original_error_deg": {
name: math.degrees(value)
for name, value in holdout_zero.validation_original_error_by_joint_rad.items()
},
"zero_improvement_95pct_lower_deg": {
name: math.degrees(value)
for name, value in (
holdout_zero.validation_improvement_confidence_lower_rad.items()
)
},
"corrected_urdf_checks": {
**urdf_checks,
"maximum_residual_zero_offset_deg": math.degrees(maximum_residual_offset),
},
"corrected_urdf_sha256": candidate_hash,
"final_json": str(final_json) if write_outputs else None,
"corrected_urdf": str(final_urdf) if write_outputs else None,
}
if write_outputs:
atomic_write_json(final_json, payload)
report["final_json_sha256"] = _sha256(final_json)
if _sha256(final_urdf) != candidate_hash:
raise ValueError("formal corrected URDF differs from validated candidate")
atomic_write_json(report_path, report)
report["validation_report"] = str(report_path)
return report
def main() -> None:
parser = argparse.ArgumentParser(
description="Replay and independently validate a complete G20 calibration session."
)
parser.add_argument("session_dir")
parser.add_argument("--serial-number", default=None)
parser.add_argument("--config-file", default=None)
parser.add_argument("--write", action="store_true")
parser.add_argument(
"--output-tag",
default=None,
help="safe suffix for a replay variant; existing outputs are never overwritten",
)
arguments = parser.parse_args()
result = replay_session(
arguments.session_dir,
serial_number=arguments.serial_number,
config_file=arguments.config_file,
write_outputs=arguments.write,
output_tag=arguments.output_tag,
)
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
if __name__ == "__main__":
main()
@@ -41,6 +41,19 @@ def _relative_pose(
return relative_rotation, relative_translation
def _normal_alignment_rad(first: SquareTagPose, second: SquareTagPose) -> float:
"""Return the undirected angle between two observed Tag face normals."""
first_normal = Rotation.from_quat(first.quaternion_xyzw).apply(
[0.0, 0.0, 1.0]
)
second_normal = Rotation.from_quat(second.quaternion_xyzw).apply(
[0.0, 0.0, 1.0]
)
return math.acos(
abs(float(np.clip(first_normal @ second_normal, -1.0, 1.0)))
)
def select_rigid_group_trajectory(
frames: Sequence[Mapping[str, Sequence[SquareTagPose]]],
*,
@@ -50,6 +63,8 @@ def select_rigid_group_trajectory(
rotation_scale_rad: float,
translation_scale_m: float,
pair_geometry: str = "pose",
normal_alignment_pairs: Sequence[tuple[str, str]] = (),
normal_alignment_scale_rad: float | None = None,
) -> tuple[
list[dict[str, SquareTagPose]],
dict[str, float | str],
@@ -66,20 +81,34 @@ def select_rigid_group_trajectory(
"""
role_names = tuple(str(role) for role in roles)
pair_names = tuple((str(parent), str(child)) for parent, child in fixed_pairs)
normal_pair_names = tuple(
(str(first), str(second))
for first, second in normal_alignment_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
for parent, child in (*pair_names, *normal_pair_names)
):
raise ValueError("fixed_pairs must reference roles")
raise ValueError("geometry 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:
normal_scale = (
rotation_scale
if normal_alignment_scale_rad is None
else float(normal_alignment_scale_rad)
)
if min(
reprojection_scale,
rotation_scale,
translation_scale,
normal_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")
@@ -110,6 +139,12 @@ def select_rigid_group_trajectory(
pose.reprojection_error_px
for pose in combination.values()
) / reprojection_scale
normal_alignment_cost = sum(
_normal_alignment_rad(
combination[first], combination[second]
)
for first, second in normal_pair_names
) / normal_scale
rotation_drifts: list[float] = []
translation_drifts: list[float] = []
distance_drifts: list[float] = []
@@ -156,7 +191,7 @@ def select_rigid_group_trajectory(
+ sum(translation_drifts) / translation_scale
)
return (
reprojection_cost + geometry_cost,
reprojection_cost + geometry_cost + normal_alignment_cost,
max(rotation_drifts, default=0.0),
(
max(distance_drifts, default=0.0)
@@ -384,9 +419,25 @@ def select_rigid_group_trajectory(
translation_drifts_by_frame, dtype=float
)
distance_drifts = np.asarray(distance_drifts_by_frame, dtype=float)
normal_alignments = np.asarray(
[
max(
(
_normal_alignment_rad(frame[first], frame[second])
for first, second in normal_pair_names
),
default=0.0,
)
for frame in best_path
],
dtype=float,
)
return best_path, {
"total_cost": float(best_total),
"pair_geometry": geometry_mode,
"maximum_normal_alignment_rad": float(
np.max(normal_alignments, initial=0.0)
),
"maximum_pair_rotation_drift_rad": float(
np.max(rotation_drifts, initial=0.0)
),
@@ -414,6 +465,172 @@ def select_rigid_group_trajectory(
}
def select_static_rigid_group_initialization(
frames: Sequence[Mapping[str, Sequence[SquareTagPose]]],
*,
roles: Sequence[str],
fixed_pairs: Sequence[tuple[str, str]],
reprojection_scale_px: float,
maximum_pose_jump_rad: float,
maximum_translation_jump_m: float,
relative_rotation_scale_rad: float,
relative_translation_scale_m: float,
normal_alignment_pairs: Sequence[tuple[str, str]] = (),
normal_alignment_scale_rad: float = math.radians(5.0),
) -> tuple[list[dict[str, SquareTagPose]], dict[str, float | str]]:
"""Select a static multi-Tag IPPE branch path in bounded time.
Group initialization is performed while the hand is held at an endpoint,
so every frame should describe the same camera and relative Tag poses.
Enumerating a full Viterbi transition matrix for every possible first-frame
branch is therefore unnecessary: with four two-branch Tags and eight
frames it performs more than one hundred thousand scipy rotations and can
block the live ROS callback for several seconds.
Instead, treat every first-frame combination as a possible static
reference and independently select the closest combination in each later
frame. This preserves the multi-frame rigidity and normal-alignment
evidence while changing the search from O(F*C^2*C0) to O(F*C*C0).
"""
role_names = tuple(str(role) for role in roles)
pair_names = tuple((str(parent), str(child)) for parent, child in fixed_pairs)
normal_pair_names = tuple(
(str(first), str(second)) for first, second in normal_alignment_pairs
)
if not frames:
raise ValueError("at least one PnP frame is required")
if not role_names or len(set(role_names)) != len(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, *normal_pair_names)
):
raise ValueError("geometry pairs must reference roles")
scales = (
float(reprojection_scale_px),
float(maximum_pose_jump_rad),
float(maximum_translation_jump_m),
float(relative_rotation_scale_rad),
float(relative_translation_scale_m),
float(normal_alignment_scale_rad),
)
if min(scales) <= 0.0:
raise ValueError("static initialization scales must be positive")
(
reprojection_scale,
pose_scale,
translation_scale,
relative_rotation_scale,
relative_translation_scale,
normal_scale,
) = scales
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)
]
)
def score_against_reference(
reference: Mapping[str, SquareTagPose],
reference_pairs: Mapping[
tuple[str, str], tuple[Rotation, np.ndarray]
],
combination: Mapping[str, SquareTagPose],
) -> float:
score = sum(
pose.reprojection_error_px for pose in combination.values()
) / reprojection_scale
score += sum(
_normal_alignment_rad(combination[first], combination[second])
for first, second in normal_pair_names
) / normal_scale
score += sum(
rotation_distance_rad(
reference[role].quaternion_xyzw,
combination[role].quaternion_xyzw,
)
/ pose_scale
+ float(
np.linalg.norm(
np.asarray(combination[role].translation_xyz_m, dtype=float)
- np.asarray(reference[role].translation_xyz_m, dtype=float)
)
)
/ translation_scale
for role in role_names
)
for pair, (reference_rotation, reference_translation) in (
reference_pairs.items()
):
rotation, translation = _relative_pose(
combination[pair[0]], combination[pair[1]]
)
score += (
float((reference_rotation.inv() * rotation).magnitude())
/ relative_rotation_scale
+ float(np.linalg.norm(translation - reference_translation))
/ relative_translation_scale
)
return float(score)
best_total = float("inf")
best_path: list[dict[str, SquareTagPose]] | None = None
for reference in combinations_by_frame[0]:
reference_pairs = {
pair: _relative_pose(reference[pair[0]], reference[pair[1]])
for pair in pair_names
}
path = [reference]
total = score_against_reference(reference, reference_pairs, reference)
for combinations in combinations_by_frame[1:]:
scored = [
(
score_against_reference(
reference, reference_pairs, combination
),
combination,
)
for combination in combinations
]
cost, selected = min(scored, key=lambda item: item[0])
total += cost
path.append(selected)
if total < best_total:
best_total = total
best_path = path
if best_path is None:
raise RuntimeError("static group initialization produced no path")
# Reuse the complete quality calculation with exactly one chosen branch
# per role and frame. This retains all existing quality fields without
# reintroducing the combinatorial branch search.
reduced_frames = [
{role: (frame[role],) for role in role_names} for frame in best_path
]
selected_path, quality = select_rigid_group_trajectory(
reduced_frames,
roles=role_names,
fixed_pairs=pair_names,
reprojection_scale_px=reprojection_scale,
rotation_scale_rad=relative_rotation_scale,
translation_scale_m=relative_translation_scale,
normal_alignment_pairs=normal_pair_names,
normal_alignment_scale_rad=normal_scale,
)
quality = dict(quality)
quality["total_cost"] = float(best_total)
quality["initialization_search"] = "static_reference"
return selected_path, quality
def _as_camera_matrix(camera_matrix: Sequence[Sequence[float]]) -> np.ndarray:
matrix = np.asarray(camera_matrix, dtype=np.float64)
if matrix.shape != (3, 3):
@@ -517,9 +734,26 @@ 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())
first = np.asarray(first_xyzw, dtype=float)
second = np.asarray(second_xyzw, dtype=float)
if first.shape != (4,) or second.shape != (4,):
raise ValueError("quaternions must contain four values")
first_norm = float(np.linalg.norm(first))
second_norm = float(np.linalg.norm(second))
if (
not np.all(np.isfinite(first))
or not np.all(np.isfinite(second))
or first_norm <= 0.0
or second_norm <= 0.0
):
raise ValueError("quaternions must be finite and non-zero")
# q and -q represent the same rotation. The absolute dot-product gives
# the geodesic SO(3) distance without constructing two scipy Rotation
# objects for every branch comparison in the live tracker.
cosine_half_angle = abs(
float(np.dot(first / first_norm, second / second_norm))
)
return 2.0 * math.acos(float(np.clip(cosine_half_angle, 0.0, 1.0)))
def select_continuous_pose(
@@ -775,19 +1009,30 @@ class SquareTagGroupPoseTracker:
reprojection_scale_px: float,
reprojection_weight: float,
reset_after_seconds: float,
initialization_frames: int = 1,
normal_alignment_pairs: Sequence[tuple[str, str]] = (),
normal_alignment_scale_rad: float = math.radians(5.0),
maximum_normal_alignment_rad: float | None = None,
) -> None:
self.roles = tuple(str(role) for role in roles)
self.adjacent_pairs = tuple(
(str(parent), str(child))
for parent, child in adjacent_pairs
)
self.normal_alignment_pairs = tuple(
(str(first), str(second))
for first, second in normal_alignment_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
for parent, child in (
*self.adjacent_pairs,
*self.normal_alignment_pairs,
)
):
raise ValueError("adjacent_pairs must reference roles")
raise ValueError("group geometry pairs must reference roles")
self.maximum_pose_jump_rad = float(maximum_pose_jump_rad)
self.maximum_translation_jump_m = float(
maximum_translation_jump_m
@@ -800,6 +1045,15 @@ class SquareTagGroupPoseTracker:
)
self.reprojection_scale_px = float(reprojection_scale_px)
self.reprojection_weight = float(reprojection_weight)
self.initialization_frames = int(initialization_frames)
self.normal_alignment_scale_rad = float(
normal_alignment_scale_rad
)
self.maximum_normal_alignment_rad = (
None
if maximum_normal_alignment_rad is None
else float(maximum_normal_alignment_rad)
)
reset_seconds = float(reset_after_seconds)
if min(
self.maximum_pose_jump_rad,
@@ -807,19 +1061,35 @@ class SquareTagGroupPoseTracker:
self.relative_rotation_scale_rad,
self.relative_translation_scale_m,
self.reprojection_scale_px,
self.normal_alignment_scale_rad,
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")
if self.initialization_frames < 1:
raise ValueError("initialization_frames must be positive")
if (
self.maximum_normal_alignment_rad is not None
and self.maximum_normal_alignment_rad <= 0.0
):
raise ValueError("maximum normal alignment must be positive")
self.reset_after_ns = int(reset_seconds * 1_000_000_000)
self._previous: dict[str, SquareTagPose] = {}
self._previous_stamp_ns: int | None = None
self._initial_candidates: list[
dict[str, tuple[SquareTagPose, ...]]
] = []
self._initial_stamps_ns: list[int] = []
self.last_initialization_quality: dict[str, float | str] = {}
self.branch_correction_counts: dict[str, int] = {}
def reset(self) -> None:
self._previous.clear()
self._previous_stamp_ns = None
self._initial_candidates.clear()
self._initial_stamps_ns.clear()
self.last_initialization_quality.clear()
self.branch_correction_counts.clear()
def select(
@@ -856,13 +1126,110 @@ class SquareTagGroupPoseTracker:
)
if not previous_is_fresh:
selected = min(
combinations,
key=lambda combination: sum(
pose.reprojection_error_px
for pose in combination.values()
),
)
if self._previous_stamp_ns is not None:
self._previous.clear()
self._previous_stamp_ns = None
self._initial_candidates.clear()
self._initial_stamps_ns.clear()
self.last_initialization_quality.clear()
if self.initialization_frames > 1:
if self._initial_stamps_ns and not (
0 <= stamp - self._initial_stamps_ns[-1]
<= self.reset_after_ns
):
self._initial_candidates.clear()
self._initial_stamps_ns.clear()
self._initial_candidates.append(
{
role: tuple(candidates_by_role.get(role, ()))
for role in self.roles
}
)
self._initial_stamps_ns.append(stamp)
if len(self._initial_candidates) < self.initialization_frames:
return (
None,
"group_initializing:"
f"{len(self._initial_candidates)}/"
f"{self.initialization_frames}",
)
selected_path, initialization_quality = (
select_static_rigid_group_initialization(
self._initial_candidates,
roles=self.roles,
fixed_pairs=self.adjacent_pairs,
reprojection_scale_px=self.reprojection_scale_px,
maximum_pose_jump_rad=self.maximum_pose_jump_rad,
maximum_translation_jump_m=(
self.maximum_translation_jump_m
),
relative_rotation_scale_rad=(
self.relative_rotation_scale_rad
),
relative_translation_scale_m=(
self.relative_translation_scale_m
),
normal_alignment_pairs=(
self.normal_alignment_pairs
),
normal_alignment_scale_rad=(
self.normal_alignment_scale_rad
),
)
)
selected = selected_path[-1]
stamp = self._initial_stamps_ns[-1]
self.last_initialization_quality = dict(
initialization_quality
)
self._initial_candidates.clear()
self._initial_stamps_ns.clear()
if (
self.maximum_normal_alignment_rad is not None
and float(
initialization_quality[
"maximum_normal_alignment_rad"
]
)
> self.maximum_normal_alignment_rad
):
return None, "group_normal_alignment"
else:
selected = min(
combinations,
key=lambda combination: (
sum(
pose.reprojection_error_px
for pose in combination.values()
)
/ self.reprojection_scale_px
+ sum(
_normal_alignment_rad(
combination[first], combination[second]
)
for first, second in self.normal_alignment_pairs
)
/ self.normal_alignment_scale_rad
),
)
maximum_alignment = max(
(
_normal_alignment_rad(
selected[first], selected[second]
)
for first, second in self.normal_alignment_pairs
),
default=0.0,
)
self.last_initialization_quality = {
"maximum_normal_alignment_rad": maximum_alignment
}
if (
self.maximum_normal_alignment_rad is not None
and maximum_alignment
> self.maximum_normal_alignment_rad
):
return None, "group_normal_alignment"
else:
previous_relative = {
pair: _relative_pose(
@@ -9,7 +9,7 @@ from typing import Any, Mapping
STATE_NAMES_ZH = {
"PREFLIGHT": "设备和标签预检",
"WAIT_START": "等待开始标定",
"RETURN_BASELINE": "正在返回基准姿态",
"RETURN_BASELINE": "正在恢复目标姿态",
"PREPARE_SWEEP": "正在到达扫描起点",
"SWEEP": "正在采集轨迹",
"FITTING": "正在拟合轨迹和零位",
@@ -35,6 +35,18 @@ JOINT_NAMES_ZH = {
"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侧摆",
}
@@ -59,6 +71,21 @@ def _task_text(active: Mapping[str, Any]) -> str:
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") == "validation":
return (
f"{view}机位,随机复测,电机{active.get('motor_index')},"
@@ -72,10 +99,17 @@ def _task_text(active: Mapping[str, Any]) -> str:
target = active.get("target_u8")
cycle = active.get("cycle", "?")
repetitions = active.get("repetitions", "?")
return (
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:
task += (
f"(整关节自动重采第{fit_attempt}/"
f"{active.get('fit_attempt_limit', '?')}次)"
)
return task
def three_camera_reason_zh(
@@ -93,6 +127,29 @@ def three_camera_reason_zh(
)
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 "未知"
motor = active.get("motor_index")
if motor is not None:
return (
f"电机{motor}反馈连续8秒没有向目标推进;目标"
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"电机反馈连续8秒没有向目标推进;停止位置距目标{error}个u8"
f"(阶段={context})。程序已保持当前位置,防止机械碰撞或摩擦加重。",
"检查该电机是否在机械端点稳定饱和或存在碰撞。若实际反馈已是该型号的"
"正常端点,应配置该电机专用端点容差后重启标定;不要反复调用resume强推。",
)
if "URDF zero offset reached the configured" in reason:
bound_match = re.search(
r"configured\s+([0-9.]+)\s+degree bound", reason
@@ -249,6 +306,45 @@ def three_camera_reason_zh(
"/g20_calibration/resume;程序只清除当前失败关节的数据"
f"并重扫{active.get('directions_to_rescan', 6)}个方向,不要调用start。",
)
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_three_cameras_tags_and_sdk", "preflight_lost"}:
return (
"正在等待三台相机内参、外参身份匹配、帧率、全部必需Tag以及机械手SDK同时就绪。",
@@ -274,7 +370,7 @@ def three_camera_reason_zh(
if reason.startswith("prepare_") or state == "PREPARE_SWEEP":
return "正在把当前电机移动到本方向的扫描起点并等待稳定。", "无需操作。"
if state == "RETURN_BASELINE":
return "正在把已使用的标定电机恢复到统一基准命令。", "无需操作。"
return "正在把已使用的标定电机恢复到目标姿态。", "无需操作。"
if state == "FITTING":
return "所有扫描已经完成,正在联合拟合三维机械轴和URDF零位偏移。", "无需操作。"
return f"未分类原因码:{reason}", "保留该原因码和启动终端日志用于进一步定位。"
@@ -290,18 +386,38 @@ def render_three_camera_status_text_zh(payload: Mapping[str, Any]) -> str:
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"总体进度:{progress:.1%}(计划扫描{completed}/{total}个方向,"
f"扫描进度{scan_progress:.1%})",
f"当前任务:{_task_text(active)}",
]
if state == "RETURN_BASELINE":
lines.append(
f"正在确认基准姿态:{payload.get('baseline_command_u8', [])}"
)
if active and active.get("kind") != "fit_failure":
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 = (
@@ -333,6 +449,16 @@ def render_three_camera_status_text_zh(payload: Mapping[str, Any]) -> str:
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", [])
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
"""Publish calibrated G20 URDF joint angles from raw u8 commands."""
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="g20_thumb_apriltag_calibration",
executable="calibrated_joint_state_bridge",
name=[
"g20_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"),
}
],
),
]
)
@@ -25,7 +25,34 @@ from launch_ros.parameter_descriptions import ParameterValue
VIEWS = ("front", "side", "top")
def _default_source_urdf(hand_type: str) -> Path:
relative = Path(
"assets/robots/hands/linker_hand"
) / f"g20_{hand_type}" / f"linkerhand_g20_{hand_type}.urdf"
workspace = Path.cwd() / "src/linkerhand_retarget/linkerhand_retarget" / relative
try:
installed = Path(get_package_share_directory("linkerhand_retarget")) / relative
except Exception:
installed = workspace
return workspace if workspace.is_file() else installed
def _launch_stack(context):
hand_type = LaunchConfiguration("hand_type").perform(context).lower()
if hand_type not in {"left", "right"}:
raise RuntimeError("hand_type must be left or right")
command_topic = f"/g20/cb_{hand_type}_hand_control_cmd"
state_topic = f"/g20/cb_{hand_type}_hand_state"
info_topic = f"/g20/cb_{hand_type}_hand_info"
requested_source = LaunchConfiguration("source_urdf_path").perform(context)
source_urdf = (
Path(requested_source).expanduser().resolve()
if requested_source
else _default_source_urdf(hand_type).resolve()
)
if not source_urdf.is_file():
raise RuntimeError(f"source URDF does not exist: {source_urdf}")
hand_serial = LaunchConfiguration("serial_number").perform(context)
if (
not hand_serial
@@ -170,7 +197,7 @@ def _launch_stack(context):
condition=IfCondition(LaunchConfiguration("start_sdk")),
parameters=[
{
"hand_type": "left",
"hand_type": hand_type,
"hand_joint": "G20",
"can": LaunchConfiguration("can_interface"),
"modbus": "None",
@@ -182,6 +209,15 @@ def _launch_stack(context):
"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,
# Calibration sends one endpoint command per sweep. Keep
# polling the real motor state during the following motion;
# otherwise the SDK republishes a stale state for 0.2 s and
# creates 17-27 command-unit holes in the trajectory bins.
"defer_state_reads_while_commanding": False,
"repeat_position_commands": False,
"is_touch": False,
}
@@ -197,11 +233,19 @@ def _launch_stack(context):
LaunchConfiguration("calibration_config"),
{
"serial_number": hand_serial,
"hand_type": hand_type,
"session_dir": str(session_dir),
# The SDK performs roughly 25 synchronous CAN queries whenever
# cb_<side>_hand_info has a subscriber. Calibration only used
# that topic to display a speed diagnostic, while those reads
# created 17-33 command-unit holes in position trajectories.
"info_topic": "/g20_calibration/disabled_hand_info",
"command_topic": command_topic,
"state_topic": state_topic,
"camera_extrinsics_file": LaunchConfiguration(
"camera_extrinsics_file"
),
"source_urdf_path": LaunchConfiguration("source_urdf_path"),
"source_urdf_path": str(source_urdf),
"corrected_urdf_output_dir": LaunchConfiguration(
"corrected_urdf_output_dir"
),
@@ -246,15 +290,20 @@ def _launch_stack(context):
*raw_topics,
*info_topics,
*detection_topics,
"/g20/cb_left_hand_control_cmd",
"/g20/cb_left_hand_state",
"/g20/cb_left_hand_info",
command_topic,
state_topic,
info_topic,
"/g20_calibration/status",
],
output="screen",
)
return [
LogInfo(msg=f"G20 three-camera session: {session_dir}"),
LogInfo(
msg=(
f"G20 {hand_type} three-camera session: {session_dir}; "
f"source_urdf={source_urdf}"
)
),
LogInfo(
msg=(
"Camera mapping: front="
@@ -275,31 +324,24 @@ 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(
[
# 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("hand_type", default_value="left"),
DeclareLaunchArgument("serial_number", default_value="UNSET"),
DeclareLaunchArgument(
"front_camera_serial", default_value="DB2163742"
@@ -353,7 +395,7 @@ def generate_launch_description() -> LaunchDescription:
),
),
DeclareLaunchArgument(
"source_urdf_path", default_value=str(default_source_urdf)
"source_urdf_path", default_value=""
),
DeclareLaunchArgument(
"corrected_urdf_output_dir", default_value=""
@@ -112,6 +112,12 @@ def _launch(context):
LaunchConfiguration("maximum_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,
@@ -137,6 +143,18 @@ def generate_launch_description() -> LaunchDescription:
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(
@@ -164,6 +182,10 @@ def generate_launch_description() -> LaunchDescription:
DeclareLaunchArgument(
"maximum_reprojection_rms_px", default_value="1.2"
),
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"
@@ -51,10 +51,19 @@ setup(
"three_camera_extrinsics_node = "
"g20_thumb_apriltag_calibration.extrinsics_node:main"
),
(
"offline_replay = "
"g20_thumb_apriltag_calibration.offline_replay:main"
),
(
"camera_alignment_view = "
"g20_thumb_apriltag_calibration.alignment_view:main"
),
(
"calibrated_joint_state_bridge = "
"g20_thumb_apriltag_calibration."
"calibrated_joint_state_bridge:main"
),
],
},
)
@@ -0,0 +1,87 @@
import copy
import pytest
from g20_thumb_apriltag_calibration.calibrated_joint_state_bridge import (
G20_COMMAND_NAMES,
G20_URDF_JOINT_NAMES,
CalibratedCommandMapper,
)
from g20_thumb_apriltag_calibration.full_hand import (
get_hand_calibration_profile,
)
def _payload(side: str = "right") -> dict:
profile = get_hand_calibration_profile(side)
curve = [round((255 - command) * 0.001, 8) for command in range(256)]
joints = {}
for name, spec in profile.joint_specs.items():
joint = {
"motor_index": spec.motor_index,
"angle_rad": list(curve),
}
if spec.active:
joint["zero_command_u8"] = 255
joint["zero_angles"] = {"urdf_zero_offset_rad": 0.0}
else:
joint["passive"] = True
if spec.source_joint is not None:
joint["source_joint"] = spec.source_joint
joints[name] = joint
return {
"schema_version": 4,
"model": "G20",
"side": side,
"serial_number": "TEST_RIGHT" if side == "right" else "TEST_LEFT",
"angle_unit": "rad",
"command_range": [0, 255],
"baseline_command_u8": [255] * 20,
"joints": joints,
"quality": {
"passed": True,
"validation_mae_rad": 0.01,
"validation_p95_rad": 0.02,
},
}
def test_mapper_uses_each_joint_motor_and_includes_passive_joints() -> None:
mapper = CalibratedCommandMapper(_payload(), expected_side="right")
command = list(range(20))
result = dict(
zip(G20_URDF_JOINT_NAMES, mapper.map_positions(command))
)
assert result["thumb_cmc_pitch"] == pytest.approx(0.255)
assert result["thumb_cmc_roll"] == pytest.approx(0.250)
assert result["thumb_cmc_yaw"] == pytest.approx(0.245)
assert result["thumb_mcp"] == pytest.approx(0.240)
assert result["thumb_ip"] == pytest.approx(0.240)
assert result["pinky_pip"] == pytest.approx(0.236)
assert result["pinky_dip"] == pytest.approx(0.236)
def test_mapper_uses_names_instead_of_message_order() -> None:
mapper = CalibratedCommandMapper(_payload(), expected_side="right")
command = list(range(20))
names = list(reversed(G20_COMMAND_NAMES))
positions = list(reversed(command))
assert mapper.map_positions(positions, names) == mapper.map_positions(command)
def test_mapper_rejects_wrong_side_and_unapproved_payload() -> None:
with pytest.raises(ValueError, match="does not match"):
CalibratedCommandMapper(_payload("left"), expected_side="right")
payload = copy.deepcopy(_payload())
payload["quality"]["passed"] = False
with pytest.raises(ValueError, match="quality.passed"):
CalibratedCommandMapper(payload, expected_side="right")
def test_mapper_rejects_incomplete_named_command() -> None:
mapper = CalibratedCommandMapper(_payload(), expected_side="right")
with pytest.raises(ValueError, match="missing named channels"):
mapper.map_positions([255.0], ["thumb_cmc_pitch"])
@@ -25,6 +25,18 @@ def test_fastdds_profile_has_capacity_for_full_resolution_images() -> None:
assert participant.attrib["is_default_profile"] == "true"
def test_three_camera_launches_force_fastdds_large_image_transport() -> None:
for launch_name in (
"three_camera_calibration.launch.py",
"three_camera_extrinsics.launch.py",
):
launch_text = (PACKAGE_ROOT / "launch" / launch_name).read_text()
assert 'name="RMW_IMPLEMENTATION"' in launch_text
assert 'value="rmw_fastrtps_cpp"' in launch_text
assert 'name="FASTDDS_DEFAULT_PROFILES_FILE"' in launch_text
assert 'name="FASTRTPS_DEFAULT_PROFILES_FILE"' in launch_text
def test_front_tag_parameters_match_namespaced_detector() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "front_tags.yaml").read_text()
@@ -43,7 +55,7 @@ def test_front_tag_parameters_match_namespaced_detector() -> None:
assert detector["detector"]["debug"] is False
def test_three_camera_tag_ids_and_topics_are_disjoint() -> None:
def test_three_camera_tag_ids_and_topics_use_eleven_unique_tags() -> None:
tags = yaml.safe_load(
(PACKAGE_ROOT / "config" / "three_camera_tags.yaml").read_text()
)
@@ -63,6 +75,9 @@ def test_three_camera_tag_ids_and_topics_are_disjoint() -> None:
assert parameters["detector"]["decimate"] == 1.5
all_ids.update(ids)
assert all_ids == set(range(11))
assert tags[
"/g20_calibration/side/apriltag/apriltag"
]["ros__parameters"]["tag"]["frames"][0] == "side_base"
def test_three_camera_calibration_has_hard_preflight_and_three_rounds() -> None:
@@ -99,25 +114,41 @@ def test_three_camera_calibration_has_hard_preflight_and_three_rounds() -> None:
assert parameters["normal_calibration_speed"] == 15
assert parameters["index_roll_calibration_speed"] == 5
assert parameters["index_flex_calibration_speed"] == 10
assert parameters["right_thumb_yaw_255_endpoint_tolerance_u8"] == 5.0
assert parameters["speed_setting_settle_seconds"] >= 0.2
assert parameters["top_pnp_invalid_reset_seconds"] == 1.0
assert parameters["pnp_group_initialization_frames"] == 8
assert parameters["pnp_group_normal_alignment_scale_deg"] == 5.0
assert parameters["pnp_group_maximum_normal_alignment_deg"] == 15.0
assert parameters["repetitions"] == 3
assert parameters["validation_enabled"] is False
assert parameters["minimum_detection_rate"] == 0.95
assert parameters["minimum_detection_hz"] == 15.0
assert parameters["minimum_state_span_u8"] >= 240.0
assert parameters["endpoint_tolerance_u8"] == 2.0
assert parameters["thumb_yaw_zero_endpoint_tolerance_u8"] == 3.0
assert parameters["thumb_yaw_zero_endpoint_tolerance_u8"] == 4.0
assert parameters["pinky_pip_zero_endpoint_tolerance_u8"] == 5.0
assert parameters["minimum_sweep_bins"] >= 32
assert parameters["maximum_bin_gap"] <= 16
assert parameters["automatic_sweep_retry_limit"] == 3
assert parameters["automatic_fit_retry_limit"] == 2
assert parameters["motor_stall_timeout_seconds"] >= 5.0
assert parameters["motor_stall_minimum_progress_u8"] == 1.0
assert parameters["automatic_motion_retry_limit"] == 2
assert parameters["provisional_warning_ratio"] == 1.25
assert parameters["retry_speed_scales"] == [0.8, 0.6, 0.5]
assert parameters["retry_endpoint_hold_seconds"] == [0.75, 1.0, 1.25]
assert parameters["position_timeout_seconds"] >= 20.0
assert parameters["maximum_state_image_skew_ms"] <= 50.0
assert parameters["axis_maximum_rotation_circle_difference_deg"] <= 1.0
assert parameters["axis_maximum_pose_line_rms_m"] <= 0.001
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_axis_cone_mismatch_deg"] <= 5.0
assert parameters["zero_maximum_offset_deg"] <= 20.0
assert parameters["zero_finger_maximum_offset_deg"] <= 3.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
@@ -4,11 +4,14 @@ import cv2
import numpy as np
from scipy.spatial.transform import Rotation
import g20_thumb_apriltag_calibration.extrinsics_node as extrinsics_node
from g20_thumb_apriltag_calibration.extrinsics_node import (
BoardPose,
StereoCapture,
_fit_stereo_robust,
_individual_reprojection_passes,
_minimum_history_skew_ns,
_pose_is_novel,
_select_latest_synchronised_pair,
_summarize_transform_repeatability,
)
@@ -100,6 +103,135 @@ def test_repeatability_summary_exposes_current_capture_outlier():
assert translation_error > 0.0015
def test_each_camera_must_pass_reprojection_gate_independently():
assert _individual_reprojection_passes(0.8, 1.1, 1.2)
assert not _individual_reprojection_passes(0.5, 1.4, 1.2)
assert _individual_reprojection_passes(0.5, 1.4, 1.5)
assert not _individual_reprojection_passes(0.5, 1.6, 1.5)
assert not _individual_reprojection_passes(float("nan"), 0.5, 1.2)
def test_pose_novelty_is_checked_against_all_previous_captures():
previous = [_transform(0.0, 0.0), _transform(10.0, 0.10)]
assert not _pose_is_novel(
_transform(10.5, 0.105),
previous,
minimum_rotation_rad=np.deg2rad(2.0),
minimum_translation_m=0.015,
)
assert _pose_is_novel(
_transform(14.0, 0.13),
previous,
minimum_rotation_rad=np.deg2rad(2.0),
minimum_translation_m=0.015,
)
def test_joint_fit_trims_moderate_bad_views_without_relaxing_gate(
monkeypatch,
):
captures = [
StereoCapture(
front_points_px=np.zeros((40, 2), dtype=np.float32),
other_points_px=np.zeros((40, 2), dtype=np.float32),
transform_candidates=(np.eye(4),),
pair_reprojection_rms_px=1.0,
skew_ns=1,
)
for _ in range(20)
]
def fake_fit_once(
captures_arg,
indices,
object_points,
front_matrix,
other_matrix,
image_size,
):
del (
captures_arg,
object_points,
front_matrix,
other_matrix,
image_size,
)
errors = np.asarray(
[1.1 if index < 15 else 1.5 for index in indices],
dtype=float,
)
return float(np.sqrt(np.mean(np.square(errors)))), np.eye(4), errors
monkeypatch.setattr(extrinsics_node, "_stereo_fit_once", fake_fit_once)
result = _fit_stereo_robust(
captures,
np.zeros((40, 3), dtype=np.float32),
np.eye(3),
np.eye(3),
(640, 480),
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 result.stereo_rms_px <= 1.2
assert len(result.inlier_indices) >= 15
assert result.rejected_indices
assert set(result.rejected_indices) <= set(range(15, 20))
def test_joint_fit_reports_finite_provisional_metrics_before_minimum(
monkeypatch,
):
captures = [
StereoCapture(
front_points_px=np.zeros((40, 2), dtype=np.float32),
other_points_px=np.zeros((40, 2), dtype=np.float32),
transform_candidates=(np.eye(4),),
pair_reprojection_rms_px=0.8,
skew_ns=1,
)
for _ in range(10)
]
def fake_fit_once(
captures_arg,
indices,
object_points,
front_matrix,
other_matrix,
image_size,
):
del (
captures_arg,
object_points,
front_matrix,
other_matrix,
image_size,
)
return 0.8, np.eye(4), np.full(len(indices), 0.8)
monkeypatch.setattr(extrinsics_node, "_stereo_fit_once", fake_fit_once)
result = _fit_stereo_robust(
captures,
np.zeros((40, 3), dtype=np.float32),
np.eye(3),
np.eye(3),
(640, 480),
minimum_inliers=15,
maximum_rms_px=1.2,
maximum_rotation_stability_rad=np.deg2rad(0.3),
maximum_translation_stability_m=0.0015,
)
assert not result.passed
assert result.stereo_rms_px == 0.8
assert len(result.inlier_indices) == 10
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)
@@ -8,8 +8,10 @@ from g20_thumb_apriltag_calibration.full_hand import (
ACTIVE_JOINTS,
IMAGE_TRAJECTORY_JOINTS,
JOINT_SPECS,
LEFT_HAND_PROFILE,
MEASURED_JOINTS,
PASSIVE_JOINTS,
RIGHT_HAND_PROFILE,
SPLAY_JOINTS,
SWEEP_SPECS,
VIEW_TAGS,
@@ -22,6 +24,7 @@ from g20_thumb_apriltag_calibration.full_hand import (
fit_joint_image_curve,
fit_measured_joint_curve,
fit_projected_zero,
get_hand_calibration_profile,
measure_joint_observation,
validate_compact_payload,
)
@@ -70,6 +73,55 @@ def test_joint_layout_covers_16_active_and_5_passive_joints() -> None:
assert len(JOINT_SPECS) == 21
assert len(ACTIVE_JOINTS) == 16
assert len(PASSIVE_JOINTS) == 5
def test_right_profile_measures_pinky_and_inherits_to_other_fingers() -> None:
profile = get_hand_calibration_profile("right")
assert profile is RIGHT_HAND_PROFILE
assert profile.reference_finger == "pinky"
assert [spec.motor_index for spec in profile.sweep_specs] == [
0, 5, 15, 9, 4, 19, 10
]
assert profile.view_tags["front"]["pinky_roll"] == 10
assert profile.view_tags["side"] == {
"side_base": 4,
"pinky_mcp": 5,
"pinky_pip": 6,
"pinky_dip": 7,
}
assert profile.preflight_view_roles["side"] == (
"side_base",
"pinky_mcp",
"pinky_pip",
"pinky_dip",
)
thumb_pitch = profile.joint_specs["thumb_cmc_pitch"]
assert thumb_pitch.view == "front"
assert thumb_pitch.parent_role == "front_base"
assert thumb_pitch.child_role == "thumb_cmc"
assert profile.joint_specs["index_mcp_roll"].source_joint == (
"pinky_mcp_roll"
)
assert profile.joint_specs["middle_mcp_pitch"].source_joint == (
"pinky_mcp_pitch"
)
assert profile.joint_specs["ring_pip"].source_joint == "pinky_pip"
assert profile.joint_specs["index_dip"].source_joint == "pinky_dip"
roll = next(spec for spec in profile.sweep_specs if spec.motor_index == 9)
command = build_calibration_motion_command(
roll, 127, profile=profile
)
assert command[9] == 127
assert command[6:9] == [255, 255, 255]
speeds = build_calibration_speed_profile(
roll,
normal_speed=15,
index_roll_speed=5,
index_flex_speed=10,
profile=profile,
)
assert speeds == [15, 15, 15, 15, 5]
assert {tag for tags in VIEW_TAGS.values() for tag in tags.values()} == set(
range(11)
)
@@ -122,6 +174,20 @@ def test_index_roll_motion_moves_other_three_roll_motors_out_of_view() -> None:
assert result[10:] == baseline[10:]
def test_right_pinky_roll_moves_other_three_fingers_camera_right() -> None:
profile = RIGHT_HAND_PROFILE
pinky_roll = next(
spec for spec in profile.sweep_specs if spec.motor_index == 9
)
result = build_calibration_motion_command(
pinky_roll, 27, profile=profile
)
assert result[6:10] == [255, 255, 255, 27]
assert profile.roll_clearance_commands == {6: 255, 7: 255, 8: 255}
def test_non_index_roll_motion_keeps_clearance_motors_at_baseline() -> None:
baseline = [255] * 20
baseline[6:10] = [127, 127, 127, 127]
@@ -133,6 +199,33 @@ def test_non_index_roll_motion_keeps_clearance_motors_at_baseline() -> None:
assert result[6:10] == [127, 127, 127, 127]
def test_right_thumb_pitch_uses_front_visible_yaw_and_roll_pose() -> None:
profile = RIGHT_HAND_PROFILE
thumb_pitch = next(
spec for spec in profile.sweep_specs if spec.motor_index == 0
)
result = build_calibration_motion_command(
thumb_pitch, 17, profile=profile
)
assert result[0] == 17
assert result[10] == 255
assert result[5] == 255
assert profile.thumb_pitch_clearance_commands == {10: 255, 5: 255}
def test_left_thumb_pitch_keeps_legacy_baseline_pose() -> None:
thumb_pitch = next(spec for spec in SWEEP_SPECS if spec.motor_index == 0)
result = build_calibration_motion_command(thumb_pitch, 17)
assert result[0] == 17
assert result[5] == 255
assert result[10] == 255
assert LEFT_HAND_PROFILE.thumb_pitch_clearance_commands == {}
def test_thumb_yaw_motion_holds_thumb_roll_at_camera_clearance_pose() -> None:
baseline = [255] * 20
baseline[6:10] = [127, 127, 127, 127]
@@ -342,3 +435,66 @@ def test_compact_payload_allows_skipped_random_validation() -> None:
"validation_mae_rad": None,
"validation_p95_rad": None,
}
def test_right_compact_payload_keeps_v4_shape_and_uses_pinky_sources() -> None:
profile = RIGHT_HAND_PROFILE
base = fit_joint_center_curve(_records())
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 == "pinky_mcp_roll" else base
for name in profile.measured_joints
}
offsets = {
name: (0.01 if profile.joint_specs[name].source_joint is None else 0.0)
for name in profile.active_joints
}
baseline = [255] * 20
baseline[6:10] = [zero_command] * 4
payload = build_compact_payload(
serial_number="G20_RIGHT_001",
measured_fits=measured,
urdf_zero_offsets_rad=offsets,
validation_errors_rad=[0.01],
passed=True,
baseline=baseline,
side="right",
)
validate_compact_payload(payload)
assert payload["schema_version"] == 4
assert payload["side"] == "right"
assert set(payload) == {
"schema_version",
"model",
"side",
"serial_number",
"angle_unit",
"command_range",
"baseline_command_u8",
"joints",
"quality",
}
for finger in ("index", "middle", "ring"):
assert payload["joints"][f"{finger}_mcp_roll"]["source_joint"] == (
"pinky_mcp_roll"
)
assert payload["joints"][f"{finger}_mcp_pitch"]["source_joint"] == (
"pinky_mcp_pitch"
)
assert payload["joints"][f"{finger}_pip"]["source_joint"] == (
"pinky_pip"
)
for suffix in ("mcp_roll", "mcp_pitch", "pip"):
joint = payload["joints"][f"{finger}_{suffix}"]
source = payload["joints"][joint["source_joint"]]
assert joint["angle_rad"] == source["angle_rad"]
assert joint["zero_angles"] == {"urdf_zero_offset_rad": 0.0}
assert source["zero_angles"] == {"urdf_zero_offset_rad": 0.01}
@@ -0,0 +1,44 @@
import pytest
from g20_thumb_apriltag_calibration.offline_replay import (
_latest_attempt_records,
_output_suffix,
)
def _sample(joint: str, cycle: int, direction: str, attempt: int) -> dict:
return {
"kind": "sample",
"joint": joint,
"cycle": cycle,
"direction": direction,
"attempt": attempt,
}
def test_latest_attempt_is_selected_per_joint_cycle_and_direction() -> None:
rows = [
{"kind": "session_start"},
_sample("pinky_pip", 0, "decreasing", 1),
_sample("pinky_pip", 0, "decreasing", 3),
_sample("pinky_pip", 0, "increasing", 1),
_sample("pinky_pip", 1, "decreasing", 2),
_sample("thumb_cmc_yaw", 0, "decreasing", 1),
]
selected = _latest_attempt_records(rows)
assert [
record["attempt"] for record in selected["pinky_pip"]
] == [3, 1, 2]
assert [
record["attempt"] for record in selected["thumb_cmc_yaw"]
] == [1]
def test_output_suffix_is_safe_and_explicit() -> None:
assert _output_suffix(None) == ""
assert _output_suffix("MEASURED_ZERO_V2") == "_MEASURED_ZERO_V2"
for invalid in ("", "../escape", "/absolute", "contains space", "x" * 65):
with pytest.raises(ValueError, match="output tag"):
_output_suffix(invalid)
@@ -324,6 +324,125 @@ def test_group_tracker_keeps_same_pair_across_sweep_turnaround() -> None:
assert selected == {"t4": return_t4, "t5": return_t5}
def test_group_tracker_initializes_from_multiple_static_frames() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("parent", "child"),
adjacent_pairs=(("parent", "child"),),
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
relative_rotation_scale_rad=np.deg2rad(5.0),
relative_translation_scale_m=0.01,
reprojection_scale_px=0.1,
reprojection_weight=0.05,
reset_after_seconds=5.0,
initialization_frames=8,
)
parent = _pose(0.0, 0.00, 0.05)
true_child = _pose(20.0, 0.03, 0.10)
for index in range(7):
false_child = _pose(5.0 * index, 0.03, 0.05)
selected, reason = tracker.select(
{
"parent": (parent,),
"child": (false_child, true_child),
},
stamp_ns=1_000_000_000 + index * 33_000_000,
)
assert selected is None
assert reason == f"group_initializing:{index + 1}/8"
false_child = _pose(35.0, 0.03, 0.05)
selected, reason = tracker.select(
{
"parent": (parent,),
"child": (false_child, true_child),
},
stamp_ns=1_231_000_000,
)
assert reason == ""
assert selected == {"parent": parent, "child": true_child}
assert (
tracker.last_initialization_quality["initialization_search"]
== "static_reference"
)
assert tracker.last_initialization_quality[
"p95_pair_rotation_drift_rad"
] < np.deg2rad(1.0)
def test_static_group_normal_prior_rejects_stable_ippe_mirror() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("mcp", "pip", "dip"),
adjacent_pairs=(("mcp", "pip"), ("pip", "dip")),
normal_alignment_pairs=(("mcp", "pip"), ("pip", "dip")),
normal_alignment_scale_rad=np.deg2rad(5.0),
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
relative_rotation_scale_rad=np.deg2rad(5.0),
relative_translation_scale_m=0.01,
reprojection_scale_px=0.1,
reprojection_weight=0.05,
reset_after_seconds=5.0,
initialization_frames=8,
maximum_normal_alignment_rad=np.deg2rad(15.0),
)
mcp = _pose(12.0, 0.00, 0.05)
correct_pip = _pose(14.0, 0.03, 0.10)
mirror_pip = _pose(50.0, 0.03, 0.05)
dip = _pose(15.0, 0.06, 0.05)
for index in range(8):
selected, reason = tracker.select(
{
"mcp": (mcp,),
"pip": (mirror_pip, correct_pip),
"dip": (dip,),
},
stamp_ns=1_000_000_000 + index * 33_000_000,
)
assert reason == ""
assert selected == {
"mcp": mcp,
"pip": correct_pip,
"dip": dip,
}
assert tracker.last_initialization_quality[
"maximum_normal_alignment_rad"
] < np.deg2rad(3.0)
def test_static_group_rejects_when_no_aligned_branch_exists() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("base", "moving"),
adjacent_pairs=(("base", "moving"),),
normal_alignment_pairs=(("base", "moving"),),
normal_alignment_scale_rad=np.deg2rad(5.0),
maximum_normal_alignment_rad=np.deg2rad(15.0),
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
relative_rotation_scale_rad=np.deg2rad(5.0),
relative_translation_scale_m=0.01,
reprojection_scale_px=0.1,
reprojection_weight=0.05,
reset_after_seconds=5.0,
initialization_frames=8,
)
base = _pose(0.0, 0.00, 0.05)
mirror = _pose(38.0, 0.03, 0.05)
for index in range(8):
selected, reason = tracker.select(
{"base": (base,), "moving": (mirror,)},
stamp_ns=1_000_000_000 + index * 33_000_000,
)
assert selected is None
assert reason == "group_normal_alignment"
def test_whole_trajectory_recovers_rigid_group_from_mirror_drift() -> None:
roles = ("t3", "t4", "t5")
mount_rotations = {
@@ -3,7 +3,9 @@ import json
import numpy as np
from g20_thumb_apriltag_calibration.three_camera_diagnostics import (
_task_text,
render_three_camera_status_text_zh,
three_camera_reason_zh,
)
@@ -64,7 +66,7 @@ def test_missing_endpoint_has_specific_chinese_reason_and_recovery() -> None:
assert "缺少电机端点255" in text
assert "实际电机范围为0.4~248.2" in text
assert "第1/3轮,255→0" in text
assert "已完成6/42个扫描方向" in text
assert "计划扫描6/42个方向,扫描进度14.3%" in text
assert "当前缺失Tag=2" in text
assert "group_pose_jump" not in text
assert "PnP拒绝" not in text
@@ -135,6 +137,107 @@ def test_joint_fit_failure_names_metric_and_selective_retry() -> None:
assert "运动采样:" not in text
def test_zero_model_failure_explains_that_rescan_will_not_help() -> None:
payload = {
"state": "PAUSED",
"reason": "zero_model_validation_failed",
"progress": 0.92,
"scan_progress": 1.0,
"completed_sweeps": 42,
"total_sweeps": 42,
"active": {
"kind": "zero_model_failure",
"view": "front",
"motor_index": 15,
"joints": ["thumb_mcp", "thumb_ip"],
"directions_to_rescan": 0,
"failures": [
{
"joint": "thumb_mcp",
"metric": "zero_guard",
"reason": "zero_offset_exceeds_configured_limit",
"actual_deg": -39.81,
"limit_deg": 20.0,
}
],
},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "拇指MCP:零位估计超过安全范围" in text
assert "估计-39.81°,允许±20.00°" in text
assert "不会自动重扫" in text
assert "总体进度:92.0%" in text
assert "扫描进度100.0%" in text
assert "运动采样:" not in text
def test_sweep_status_shows_full_joint_fit_retry_attempt() -> None:
active = {
"kind": "sweep",
"view": "front",
"motor_index": 15,
"joints": ["thumb_mcp", "thumb_ip"],
"cycle": 1,
"repetitions": 3,
"start_u8": 255,
"target_u8": 0,
"fit_attempt": 2,
"fit_attempt_limit": 3,
}
assert "整关节自动重采第2/3次" in _task_text(active)
def test_motor_stall_reason_is_explained_in_chinese() -> None:
reason, action = three_camera_reason_zh(
"PAUSED",
"motor_state_stalled:sweep_motor_19:error_u8=5.000",
{},
)
assert "距目标5.000个u8" in reason
assert "机械端点" in action
assert "不要反复调用resume" in action
def test_baseline_stall_names_motor_target_actual_and_tolerance() -> None:
payload = {
"state": "PAUSED",
"reason": (
"motor_state_stalled:return_baseline:motor_index=10:"
"target_u8=255.0:actual_u8=250.0:tolerance_u8=4.0:"
"error_u8=5.000"
),
"progress": 0.0,
"scan_progress": 0.0,
"completed_sweeps": 0,
"total_sweeps": 42,
"active": {
"kind": "motion_stall",
"stage": "return_baseline",
"motor_index": 10,
"target_u8": 255.0,
"actual_u8": 250.0,
"error_u8": 5.0,
"tolerance_u8": 4.0,
},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "电机10反馈连续8秒" in text
assert "目标255.0、实际250.0、误差5.000 u8" in text
assert "允许容差±4.0 u8" in text
assert "当前任务:电机10运动停滞,目标255.0、实际250.0" in text
assert "运动采样:" not in text
def test_return_baseline_prints_the_exact_command() -> None:
baseline = [255] * 20
baseline[6:10] = [127, 127, 127, 127]
@@ -152,10 +255,34 @@ def test_return_baseline_prints_the_exact_command() -> None:
text = render_three_camera_status_text_zh(payload)
assert "正在返回基准姿态(RETURN_BASELINE)" in text
assert "正在恢复目标姿态(RETURN_BASELINE)" in text
assert f"正在确认基准姿态:{baseline}" in text
def test_return_recovery_prints_thumb_yaw_start_and_clearance_pose() -> None:
baseline = [255] * 20
baseline[6:10] = [127, 127, 127, 127]
recovery = list(baseline)
recovery[5] = 145
recovery[10] = 0
payload = {
"state": "RETURN_BASELINE",
"reason": "return_baseline_before_retry_sweep",
"progress": 0.5,
"completed_sweeps": 36,
"total_sweeps": 42,
"baseline_command_u8": baseline,
"return_command_u8": recovery,
"active": {},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert f"正在确认恢复姿态:{recovery}" in text
def test_status_numeric_diagnostics_are_json_serializable() -> None:
bins = [0, 16, 255]
payload = {
@@ -214,6 +341,8 @@ def test_index_roll_status_prints_clearance_motor_feedback() -> None:
"joints": ["index_mcp_roll"],
"cycle": 1,
"repetitions": 3,
"automatic_retry_count": 1,
"automatic_retry_limit": 2,
"start_u8": 255,
"target_u8": 0,
"actual_u8": 44.0,
@@ -241,3 +370,4 @@ def test_index_roll_status_prints_clearance_motor_feedback() -> None:
assert "电机9目标0、实际0.0" in text
assert "阶段速度:五指目标[15, 5, 15, 15, 15]" in text
assert "SDK报告[15, 5, 15, 15, 15]" in text
assert "自动重试:当前方向已自动重扫1/2次" in text
@@ -10,13 +10,17 @@ 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.full_hand import (
RIGHT_HAND_PROFILE,
SWEEP_SPECS,
)
from g20_thumb_apriltag_calibration.three_camera_node import (
FrameObservation,
G20ThreeCameraCalibrationNode,
STATE_PREPARE_SWEEP,
STATE_SWEEP,
SweepItem,
_overall_progress,
)
@@ -45,6 +49,78 @@ def _frame(view: str, state_u8: list[float]) -> FrameObservation:
)
def test_right_thumb_pitch_requires_only_front_base_and_thumb_tag() -> None:
profile = RIGHT_HAND_PROFILE
spec = next(item for item in profile.sweep_specs if item.motor_index == 0)
runtime = SimpleNamespace(
roles=tuple(profile.view_tags["front"]),
preflight_roles=profile.preflight_view_roles["front"],
)
node = SimpleNamespace(
profile=profile,
views={"front": runtime},
active_sweep=SweepItem(spec, 0, DIRECTION_DECREASING),
active_validation=None,
retry_sweep_spec=None,
)
required = G20ThreeCameraCalibrationNode._required_roles_for_view(
node, "front"
)
assert required == ("front_base", "thumb_cmc")
def test_right_side_preflight_requires_only_pinky_chain_tags() -> None:
profile = RIGHT_HAND_PROFILE
runtime = SimpleNamespace(
roles=tuple(profile.view_tags["side"]),
preflight_roles=profile.preflight_view_roles["side"],
)
node = SimpleNamespace(
profile=profile,
views={"side": runtime},
active_sweep=None,
active_validation=None,
retry_sweep_spec=None,
)
required = G20ThreeCameraCalibrationNode._required_roles_for_view(
node, "side"
)
assert required == ("side_base", "pinky_mcp", "pinky_pip", "pinky_dip")
def test_right_pip_sweep_keeps_side_base_as_pnp_branch_anchor() -> None:
profile = RIGHT_HAND_PROFILE
spec = next(
item for item in profile.sweep_specs if item.motor_index == 19
)
runtime = SimpleNamespace(
roles=tuple(profile.view_tags["side"]),
preflight_roles=profile.preflight_view_roles["side"],
)
node = SimpleNamespace(
profile=profile,
views={"side": runtime},
active_sweep=SweepItem(spec, 0, DIRECTION_DECREASING),
active_validation=None,
retry_sweep_spec=None,
)
required = G20ThreeCameraCalibrationNode._required_roles_for_view(
node, "side"
)
assert required == (
"side_base",
"pinky_mcp",
"pinky_pip",
"pinky_dip",
)
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)
@@ -98,6 +174,8 @@ def test_begin_sweep_carries_start_endpoint_frame_into_sweep() -> None:
sweep_frames=[],
sweep_start_frames=[endpoint_frame],
_publish_command=lambda command: published.append(command),
_motion_command_error_u8=lambda selected, command: 255.0,
_reset_motion_progress=lambda now, error: None,
)
G20ThreeCameraCalibrationNode._begin_active_sweep(node, 10.0)
@@ -110,35 +188,124 @@ def test_begin_sweep_carries_start_endpoint_frame_into_sweep() -> None:
assert published[0][7:10] == [0, 0, 0]
def test_only_thumb_yaw_uses_three_count_endpoint_deadband() -> None:
def test_joint_specific_zero_endpoint_deadbands() -> 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)
pinky_pip = next(
item for item in RIGHT_HAND_PROFILE.sweep_specs
if item.motor_index == 19
)
node = SimpleNamespace(
endpoint_tolerance_u8=2.0,
thumb_yaw_zero_endpoint_tolerance_u8=3.0,
thumb_yaw_zero_endpoint_tolerance_u8=4.0,
right_thumb_yaw_255_endpoint_tolerance_u8=5.0,
pinky_pip_zero_endpoint_tolerance_u8=5.0,
)
assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
node, yaw, 0
) == 3.0
) == 4.0
assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
node, yaw, 255
) == 2.0
node.profile = RIGHT_HAND_PROFILE
assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
node, yaw, 255
) == 5.0
assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
node, index_roll, 0
) == 2.0
assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
node, pinky_pip, 0
) == 5.0
assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
node, pinky_pip, 255
) == 2.0
def test_thumb_yaw_command_zero_accepts_feedback_three_only_for_swept_motor() -> None:
def test_pinky_pip_command_zero_accepts_firmware_feedback_five() -> None:
pinky_pip = next(
item for item in RIGHT_HAND_PROFILE.sweep_specs
if item.motor_index == 19
)
state = [255.0] * 20
state[19] = 5.0
node = SimpleNamespace(
latest_state_u8=tuple(state),
baseline_command=tuple([255] * 20),
endpoint_tolerance_u8=2.0,
pinky_pip_zero_endpoint_tolerance_u8=5.0,
)
node._endpoint_tolerance_for_spec = lambda spec, endpoint: (
G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
node, spec, endpoint
)
)
assert G20ThreeCameraCalibrationNode._motion_command_reached(
node, pinky_pip, 0, tuple(state)
)
state[19] = 6.0
assert not G20ThreeCameraCalibrationNode._motion_command_reached(
node, pinky_pip, 0, tuple(state)
)
def test_right_baseline_and_auxiliary_accept_thumb_yaw_feedback_250() -> None:
state = [253.0] * 20
state[0] = 254.0
state[5] = 254.0
state[6:10] = [127.0] * 4
state[10] = 250.0
state[15] = 255.0
baseline = [255] * 20
baseline[6:10] = [127] * 4
node = SimpleNamespace(
profile=RIGHT_HAND_PROFILE,
latest_state_u8=tuple(state),
baseline_command=tuple(baseline),
endpoint_tolerance_u8=2.0,
thumb_yaw_zero_endpoint_tolerance_u8=4.0,
right_thumb_yaw_255_endpoint_tolerance_u8=5.0,
pinky_pip_zero_endpoint_tolerance_u8=5.0,
)
node._motor_endpoint_tolerance = lambda motor, endpoint: (
G20ThreeCameraCalibrationNode._motor_endpoint_tolerance(
node, motor, endpoint
)
)
node._endpoint_tolerance_for_spec = lambda spec, endpoint: (
G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
node, spec, endpoint
)
)
assert G20ThreeCameraCalibrationNode._baseline_reached(node)
details = G20ThreeCameraCalibrationNode._baseline_error_details(node)
assert details["motor_index"] == 10
assert details["actual_u8"] == 250.0
assert details["tolerance_u8"] == 5.0
thumb_pitch = next(
spec for spec in RIGHT_HAND_PROFILE.sweep_specs
if spec.motor_index == 0
)
state[0] = 255.0
assert G20ThreeCameraCalibrationNode._motion_command_reached(
node, thumb_pitch, 255, tuple(state)
)
def test_thumb_yaw_command_zero_accepts_feedback_four_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[10] = 4.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,
thumb_yaw_zero_endpoint_tolerance_u8=4.0,
)
node._endpoint_tolerance_for_spec = lambda spec, endpoint: (
G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec(
@@ -149,12 +316,128 @@ def test_thumb_yaw_command_zero_accepts_feedback_three_only_for_swept_motor() ->
assert G20ThreeCameraCalibrationNode._motion_command_reached(
node, yaw, 0, tuple(state)
)
state[10] = 5.0
assert not G20ThreeCameraCalibrationNode._motion_command_reached(
node, yaw, 0, tuple(state)
)
state[10] = 4.0
state[5] = 142.0
assert not G20ThreeCameraCalibrationNode._motion_command_reached(
node, yaw, 0, tuple(state)
)
def test_thumb_yaw_direction_recovery_keeps_motor_five_at_clearance() -> None:
yaw = next(
item for item in RIGHT_HAND_PROFILE.sweep_specs
if item.motor_index == 10
)
baseline = [255] * 20
baseline[6:10] = [127] * 4
for direction, expected_start in (
(DIRECTION_DECREASING, 255),
(DIRECTION_INCREASING, 0),
):
node = SimpleNamespace(
profile=RIGHT_HAND_PROFILE,
baseline_command=tuple(baseline),
active_sweep=SweepItem(yaw, 1, direction),
retry_sweep_items=[],
)
command = G20ThreeCameraCalibrationNode._return_command_for_transition(
node, "retry_sweep"
)
assert command[5] == 145
assert command[10] == expected_start
assert all(
command[index] == baseline[index]
for index in range(20)
if index not in {5, 10}
)
def test_thumb_yaw_cycle_and_manual_recovery_use_queued_direction_start() -> None:
yaw = next(
item for item in RIGHT_HAND_PROFILE.sweep_specs
if item.motor_index == 10
)
other = next(
item for item in RIGHT_HAND_PROFILE.sweep_specs
if item.motor_index != 10
)
baseline = [255] * 20
baseline[6:10] = [127] * 4
queued = SweepItem(yaw, 2, DIRECTION_INCREASING)
node = SimpleNamespace(
profile=RIGHT_HAND_PROFILE,
baseline_command=tuple(baseline),
active_sweep=SweepItem(other, 0, DIRECTION_DECREASING),
retry_sweep_items=[queued],
)
command = G20ThreeCameraCalibrationNode._return_command_for_transition(
node, "resume_sweep"
)
assert command[5] == 145
assert command[10] == 0
def test_thumb_yaw_clearance_is_released_only_for_normal_transition() -> None:
yaw = next(
item for item in RIGHT_HAND_PROFILE.sweep_specs
if item.motor_index == 10
)
baseline = [255] * 20
baseline[6:10] = [127] * 4
node = SimpleNamespace(
profile=RIGHT_HAND_PROFILE,
baseline_command=tuple(baseline),
active_sweep=SweepItem(yaw, 2, DIRECTION_INCREASING),
retry_sweep_items=[],
)
command = G20ThreeCameraCalibrationNode._return_command_for_transition(
node, "fit"
)
assert command == tuple(baseline)
assert command[5] == 255
def test_thumb_yaw_recovery_zero_accepts_feedback_four() -> None:
yaw = next(
item for item in RIGHT_HAND_PROFILE.sweep_specs
if item.motor_index == 10
)
baseline = [255] * 20
baseline[6:10] = [127] * 4
return_command = list(baseline)
return_command[5] = 145
return_command[10] = 0
state = [float(value) for value in return_command]
state[10] = 4.0
node = SimpleNamespace(
profile=RIGHT_HAND_PROFILE,
baseline_command=tuple(baseline),
return_command_u8=tuple(return_command),
latest_state_u8=tuple(state),
endpoint_tolerance_u8=2.0,
thumb_yaw_zero_endpoint_tolerance_u8=4.0,
right_thumb_yaw_255_endpoint_tolerance_u8=5.0,
)
assert G20ThreeCameraCalibrationNode._baseline_reached(node)
details = G20ThreeCameraCalibrationNode._motion_command_error_details(
node, yaw, 0, "return_baseline"
)
assert details["motor_index"] == 10
assert details["actual_u8"] == 4.0
assert details["tolerance_u8"] == 4.0
def _image_cycle_records(
travels_rad: list[float], *, depth_slope: float = 0.0
) -> list[dict]:
@@ -225,6 +508,7 @@ def _fit_check_node(records_by_joint: dict) -> SimpleNamespace:
axis_maximum_plane_rms_m=0.003,
passive_axis_maximum_plane_rms_m=0.004,
axis_maximum_radial_rms_m=0.003,
axis_maximum_pose_line_rms_m=0.001,
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),
@@ -241,7 +525,7 @@ def _fit_check_node(records_by_joint: dict) -> SimpleNamespace:
return node
def test_fit_failure_rewinds_to_failed_specs_first_direction(tmp_path) -> None:
def test_fit_failure_preserves_main_progress_and_selects_retry_scope(tmp_path) -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 6)
node = SimpleNamespace(
sweep_items=_sweep_items(),
@@ -272,17 +556,97 @@ def test_fit_failure_rewinds_to_failed_specs_first_direction(tmp_path) -> None:
],
)
assert node.sweep_index == 18
assert node.sweep_index == 24
assert node.retry_sweep_spec == spec
assert node.retry_cycles == {0, 1, 2}
assert node.fit_failure["directions_to_rescan"] == 6
assert node.paused_reason == "joint_fit_check_failed"
def test_zero_model_failure_never_schedules_an_automatic_rescan(tmp_path) -> None:
pauses: list[str] = []
node = SimpleNamespace(
profile=RIGHT_HAND_PROFILE,
raw_path=tmp_path / "raw_samples.jsonl",
maximum_validation_p95_rad=math.radians(2.0),
zero_maximum_offset_rad=math.radians(20.0),
zero_joint_maximum_offsets_rad={},
retry_sweep_spec=RIGHT_HAND_PROFILE.sweep_specs[2],
retry_resume_index=42,
retry_cycles={0, 1, 2},
fit_failure={},
_pause=lambda reason: pauses.append(reason),
)
result = SimpleNamespace(
failure_reasons={
"thumb_mcp": "zero_offset_exceeds_configured_limit"
},
direct_offsets_rad={"thumb_mcp": math.radians(-39.81)},
validation_error_by_joint_rad={
"thumb_ip": math.radians(23.5)
},
)
G20ThreeCameraCalibrationNode._pause_for_zero_model_failure(node, result)
assert pauses == ["zero_model_validation_failed"]
assert node.retry_sweep_spec is None
assert node.retry_resume_index is None
assert node.retry_cycles == set()
assert node.fit_failure["recoverable_by_rescan"] is False
assert node.fit_failure["directions_to_rescan"] == 0
assert node.fit_failure["failures"][0]["actual_deg"] == -39.81
assert node.fit_failure["failures"][0]["limit_deg"] == 20.0
event = json.loads((tmp_path / "raw_samples.jsonl").read_text())
assert event["kind"] == "zero_model_failure"
def test_resume_rejects_zero_model_failure_without_starting_motion() -> None:
node = SimpleNamespace(
state="PAUSED",
paused_reason="zero_model_validation_failed",
)
response = SimpleNamespace(success=None, message="")
result = G20ThreeCameraCalibrationNode._resume_callback(
node, SimpleNamespace(), response
)
assert result.success is False
assert "cannot repair" in result.message
def test_scan_completion_is_not_reported_as_calibration_completion() -> None:
assert _overall_progress("SWEEP", scan_progress=1.0) == 0.90
assert _overall_progress("FITTING", scan_progress=1.0) == 0.92
assert np.isclose(
_overall_progress(
"VALIDATION_CAPTURE",
scan_progress=1.0,
validation_index=5,
validation_total=10,
),
0.955,
)
assert _overall_progress("COMPLETE", scan_progress=1.0) == 1.0
def test_resume_discards_only_failed_specs_samples(tmp_path) -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 15)
reset_runtimes = []
front_runtime = SimpleNamespace(
pnp_invalid_since=12.0,
pnp_reset_count=0,
)
records = {
"thumb_mcp": [{"old": "mcp"}],
"thumb_ip": [{"old": "ip"}],
"thumb_mcp": [
{"cycle": 0, "keep": "mcp"},
{"cycle": 1, "old": "mcp"},
],
"thumb_ip": [
{"cycle": 0, "keep": "ip"},
{"cycle": 1, "old": "ip"},
],
"thumb_cmc_pitch": [{"keep": True}],
}
node = SimpleNamespace(
@@ -292,17 +656,25 @@ def test_resume_discards_only_failed_specs_samples(tmp_path) -> None:
raw_path=tmp_path / "raw_samples.jsonl",
paused_reason="joint_fit_check_failed",
fit_failure={"failures": ["old"]},
retry_cycles={1},
repetitions=3,
sweep_items=_sweep_items(),
views={"front": front_runtime},
_reset_view_trackers=lambda runtime: reset_runtimes.append(runtime),
)
selected = G20ThreeCameraCalibrationNode._prepare_failed_sweep_retry(node)
assert selected == spec
assert records["thumb_mcp"] == []
assert records["thumb_ip"] == []
assert records["thumb_mcp"] == [{"cycle": 0, "keep": "mcp"}]
assert records["thumb_ip"] == [{"cycle": 0, "keep": "ip"}]
assert records["thumb_cmc_pitch"] == [{"keep": True}]
assert node.sweep_attempts[15] == 2
assert node.retry_sweep_spec == spec
assert node.fit_failure == {}
assert reset_runtimes == [front_runtime]
assert front_runtime.pnp_invalid_since is None
assert front_runtime.pnp_reset_count == 1
event = json.loads((tmp_path / "raw_samples.jsonl").read_text())
assert event == {
"kind": "retry",
@@ -311,9 +683,195 @@ def test_resume_discards_only_failed_specs_samples(tmp_path) -> None:
"joints": ["thumb_mcp", "thumb_ip"],
"attempt": 2,
"reason": "joint_fit_check_failed",
"cycles": [2],
}
def test_recoverable_sweep_failure_retries_three_times_before_pause(tmp_path) -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 6)
item = SweepItem(spec, 0, DIRECTION_INCREASING)
transitions: list[str] = []
pauses: list[str] = []
holds: list[bool] = []
node = SimpleNamespace(
active_sweep=item,
automatic_sweep_retry_limit=3,
sweep_retry_counts={},
raw_path=tmp_path / "raw_samples.jsonl",
reason="",
_publish_hold_current=lambda: holds.append(True),
_begin_return_baseline=lambda after: transitions.append(after),
_pause=lambda reason: pauses.append(reason),
retry_speed_scales=(0.8, 0.6, 0.5),
retry_endpoint_hold_seconds=(0.75, 1.0, 1.25),
)
G20ThreeCameraCalibrationNode._retry_active_sweep_or_pause(
node, "sweep_bin_gap_too_large"
)
G20ThreeCameraCalibrationNode._retry_active_sweep_or_pause(
node, "sweep_bin_gap_too_large"
)
G20ThreeCameraCalibrationNode._retry_active_sweep_or_pause(
node, "sweep_bin_gap_too_large"
)
G20ThreeCameraCalibrationNode._retry_active_sweep_or_pause(
node, "sweep_bin_gap_too_large"
)
assert transitions == ["retry_sweep", "retry_sweep", "retry_sweep"]
assert pauses == ["sweep_bin_gap_too_large"]
assert holds == []
assert node.sweep_retry_counts[(6, 0, DIRECTION_INCREASING)] == 3
events = [
json.loads(line)
for line in (tmp_path / "raw_samples.jsonl").read_text().splitlines()
]
assert [event["retry"] for event in events] == [1, 2, 3]
def test_first_provisional_fit_failure_retries_automatically(tmp_path) -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 15)
transitions: list[str] = []
pauses: list[str] = []
prepared: list[bool] = []
node = SimpleNamespace(
sweep_items=_sweep_items(),
sweep_attempts={spec.motor_index: 1},
retry_sweep_spec=None,
retry_resume_index=None,
fit_failure={},
sweep_index=18,
raw_path=tmp_path / "raw_samples.jsonl",
repetitions=3,
automatic_fit_retry_limit=1,
paused_reason="",
reason="",
_sweep_spec_start_index=lambda selected: (
G20ThreeCameraCalibrationNode._sweep_spec_start_index(node, selected)
),
_prepare_failed_sweep_retry=lambda: prepared.append(True),
_begin_return_baseline=lambda after: transitions.append(after),
_pause=lambda reason: pauses.append(reason),
)
G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure(
node,
spec,
[{"joint": "thumb_ip", "metric": "rotation_orthogonal_rms_deg"}],
)
assert prepared == [True]
assert transitions == ["resume_sweep"]
assert pauses == []
assert node.paused_reason == "joint_fit_check_failed"
def test_near_threshold_provisional_failure_warns_without_retry(tmp_path) -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 6)
node = SimpleNamespace(
raw_path=tmp_path / "raw_samples.jsonl",
provisional_warning_ratio=1.25,
reason="",
)
handled = G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure(
node,
spec,
[
{
"joint": "index_mcp_roll",
"metric": "hysteresis_deg",
"actual": 5.5,
"limit": 5.0,
"comparison": "maximum",
}
],
allow_warning=True,
)
assert handled is False
assert node.reason == "provisional_fit_warning_continuing"
event = json.loads((tmp_path / "raw_samples.jsonl").read_text())
assert event["kind"] == "provisional_fit_warning"
def test_motion_timeout_republishes_twice_before_pause(tmp_path) -> None:
commands: list[list[int]] = []
pauses: list[str] = []
holds: list[bool] = []
node = SimpleNamespace(
motion_retry_counts={},
automatic_motion_retry_limit=2,
raw_path=tmp_path / "raw_samples.jsonl",
state="RETURN_BASELINE",
baseline_command=tuple([255] * 20),
position_hold_since=1.0,
reason="",
_publish_hold_current=lambda: holds.append(True),
_publish_speed_profile=lambda profile: None,
_normal_speed_profile=lambda: [15] * 5,
_publish_command=lambda command: commands.append(command),
_pause=lambda reason: pauses.append(reason),
_baseline_error_u8=lambda: 50.0,
_reset_motion_progress=lambda now, error: None,
)
for now in (10.0, 20.0, 30.0):
G20ThreeCameraCalibrationNode._retry_motion_or_pause(
node, "return_baseline_timeout", now
)
assert len(commands) == 2
assert holds == []
assert pauses == ["return_baseline_timeout"]
def test_motion_stall_pauses_without_consuming_sweep_retries(tmp_path) -> None:
pauses: list[str] = []
node = SimpleNamespace(
raw_path=tmp_path / "raw_samples.jsonl",
latest_state_u8=tuple([18.0] + [255.0] * 19),
motion_progress_reference_error_u8=18.0,
motion_last_progress_at=10.0,
motor_stall_timeout_seconds=8.0,
motor_stall_minimum_progress_u8=1.0,
_pause=lambda reason: pauses.append(reason),
_reset_motion_progress=lambda now, error: None,
)
assert not G20ThreeCameraCalibrationNode._pause_if_motion_stalled(
node, now=17.9, error_u8=18.0, context="sweep_motor_0"
)
assert G20ThreeCameraCalibrationNode._pause_if_motion_stalled(
node, now=18.0, error_u8=18.0, context="sweep_motor_0"
)
assert pauses == ["motor_state_stalled:sweep_motor_0:error_u8=18.000"]
event = json.loads((tmp_path / "raw_samples.jsonl").read_text())
assert event["kind"] == "mechanical_motion_stall"
assert event["context"] == "sweep_motor_0"
def test_slower_sweep_retry_extends_timeout_inversely() -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 0)
item = SweepItem(spec, 0, DIRECTION_DECREASING)
key = (spec.motor_index, 0, DIRECTION_DECREASING)
node = SimpleNamespace(
active_sweep=item,
sweep_timeout_seconds=90.0,
sweep_retry_counts={key: 1},
retry_speed_scales=(0.8, 0.6, 0.5),
)
assert G20ThreeCameraCalibrationNode._active_sweep_timeout_seconds(
node
) == 112.5
node.sweep_retry_counts[key] = 3
assert G20ThreeCameraCalibrationNode._active_sweep_timeout_seconds(
node
) == 180.0
def test_provisional_fit_rejects_inconsistent_cycle_travel() -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 0)
node = _fit_check_node(
@@ -379,6 +937,8 @@ def test_thumb_ip_axis_uses_same_cycle_thumb_mcp_direction() -> None:
upstream = node._fit_axis_measurement("thumb_mcp", 0)
passive = node._fit_axis_measurement("thumb_ip", 0)
assert upstream.condition_command_u8 is not None
assert upstream.condition_command_u8[15] == 255.0
assert abs(
float(
np.asarray(upstream.axis_common_xyz)
@@ -22,6 +22,7 @@ from g20_thumb_apriltag_calibration.urdf_zero import (
fit_joint_axis_measurement,
fit_rotation_joint_curve,
solve_urdf_zero_offsets,
get_zero_calibration_profile,
write_zero_corrected_urdf,
)
from g20_thumb_apriltag_calibration.full_hand import (
@@ -30,6 +31,7 @@ from g20_thumb_apriltag_calibration.full_hand import (
MEASURED_JOINTS,
PASSIVE_JOINTS,
JointCurveFit,
get_hand_calibration_profile,
)
@@ -38,6 +40,10 @@ SOURCE_URDF = REPOSITORY / (
"src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/"
"linker_hand/g20_left/linkerhand_g20_left.urdf"
)
RIGHT_SOURCE_URDF = REPOSITORY / (
"src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/"
"linker_hand/g20_right/linkerhand_g20_right.urdf"
)
def test_zero_sensitive_axis_error_ignores_fixed_cone_angle_mismatch():
@@ -58,6 +64,18 @@ def test_zero_sensitive_axis_error_ignores_fixed_cone_angle_mismatch():
) == pytest.approx(3.0, abs=1.0e-9)
def test_zero_sensitive_axis_error_is_exact_for_an_oblique_cone() -> None:
parent = np.asarray([0.0, 0.0, 1.0])
cone = math.radians(32.0)
phase = math.radians(7.0)
predicted = np.asarray([math.sin(cone), 0.0, math.cos(cone)])
observed = Rotation.from_rotvec(parent * phase).apply(predicted)
error = _zero_sensitive_axis_error_rad(predicted, observed, parent)
assert math.degrees(error) == pytest.approx(7.0, abs=1.0e-9)
def _payload(transform: np.ndarray) -> dict[str, list[float]]:
return {
"translation_xyz_m": transform[:3, 3].tolist(),
@@ -139,7 +157,10 @@ def test_axis_and_curve_ignore_camera_and_tag_mount_rotation() -> None:
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:
@pytest.mark.parametrize("joint", ["thumb_cmc_pitch", "index_mcp_pitch"])
def test_image_plane_joint_uses_rotation_axis_to_constrain_noisy_depth(
joint: str,
) -> 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)
@@ -159,7 +180,7 @@ def test_image_plane_joint_uses_rotation_axis_to_constrain_noisy_depth() -> None
biased_records.append(biased)
measurement = fit_joint_axis_measurement(
"thumb_cmc_pitch", biased_records, cycle=0, zero_command_u8=255
joint, biased_records, cycle=0, zero_command_u8=255
)
observed_axis = np.asarray(measurement.axis_common_xyz)
@@ -175,6 +196,44 @@ def test_image_plane_joint_uses_rotation_axis_to_constrain_noisy_depth() -> None
assert measurement.radial_rms_m < 0.003
def test_pose_axis_point_rejects_end_on_optical_depth_bias() -> None:
records, expected_axis, expected_point = _arbitrary_tag_records()
common_from_parent = Rotation.from_euler("xyz", [-0.8, 0.55, 1.3])
axis_parent = common_from_parent.inv().apply(expected_axis)
# Exact end-on depth is a gauge along the physical axis and therefore
# cannot alter the observable axis line. An oblique camera has a small
# irreducible coupling between monocular depth and radial position; that
# case must be bounded by the residual/holdout gates, not asserted to be
# exactly recoverable from one view.
view_normal_parent = axis_parent
view_normal_common = common_from_parent.apply(view_normal_parent)
biased = []
for record in records:
changed = dict(record)
fraction = (255.0 - float(record["command_u8"])) / 255.0
depth_bias = 0.03 * (fraction - 0.5)
changed["relative_translation_xyz_m"] = (
np.asarray(record["relative_translation_xyz_m"], dtype=float)
+ depth_bias * view_normal_parent
).tolist()
biased.append(changed)
measurement = fit_joint_axis_measurement(
"thumb_cmc_pitch",
biased,
cycle=0,
zero_command_u8=255,
view_normal_common_xyz=view_normal_common,
)
observed_point = np.asarray(measurement.point_common_xyz_m)
assert measurement.axis_point_source == "pose_trajectory_image_plane"
assert measurement.pose_axis_line_rms_m < 1.0e-6
assert np.linalg.norm(
np.cross(observed_point - expected_point, expected_axis)
) < 1.0e-6
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)
@@ -349,6 +408,14 @@ def test_urdf_writer_postmultiplies_joint_axis_and_never_overwrites(tmp_path: Pa
offsets_rad={"thumb_cmc_yaw": offset},
timestamp="20260806_120001",
)
with pytest.raises(ValueError, match="finite and within"):
write_zero_corrected_urdf(
source_urdf=SOURCE_URDF,
output_directory=tmp_path,
serial_number="G20_LEFT_001",
offsets_rad={"thumb_cmc_yaw": math.nan},
timestamp="20260806_120002",
)
def test_urdf_writer_changes_only_the_16_active_zero_origins(
@@ -383,42 +450,12 @@ def test_urdf_writer_changes_only_the_16_active_zero_origins(
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")
)
# A zero calibration must not silently expand mechanical/CAD safety
# limits. Dynamic measured ranges remain in the calibration JSON.
for name in (*ACTIVE_JOINTS, *PASSIVE_JOINTS):
assert _joint_limit(destination, name) == pytest.approx(
_joint_limit(SOURCE_URDF, name)
)
def _synthetic_curve(zero_command: int, travel: float) -> JointCurveFit:
@@ -438,7 +475,306 @@ def _synthetic_curve(zero_command: int, travel: float) -> JointCurveFit:
)
def _solve_synthetic_offsets(
side: str,
offset_degrees: list[float],
*,
inject_oblique_optical_depth_bias: bool = False,
inject_secondary_root_axis_bias_degrees: float = 0.0,
inject_secondary_root_point_bias_m: float = 0.0,
inject_observer_cone_bias_degrees: float = 0.0,
pose_axis_line_rms_by_joint_m: dict[str, float] | None = None,
joint_maximum_offset_degrees: dict[str, float] | None = None,
):
hand = get_hand_calibration_profile(side)
zero = get_zero_calibration_profile(side)
source = SOURCE_URDF if side == "left" else RIGHT_SOURCE_URDF
baseline = [255.0] * 20
baseline[6:10] = [127.0] * 4
curves = {
name: _synthetic_curve(
int(baseline[hand.joint_specs[name].motor_index]),
math.radians(50.0),
)
for name in hand.measured_joints
}
if inject_secondary_root_axis_bias_degrees:
# Make the thumb root the higher-travel, directly observed direction,
# matching the real right-hand data where the short pinky splay arc is
# the less reliable root-axis orientation estimate.
curves["thumb_cmc_roll"] = _synthetic_curve(
int(baseline[hand.joint_specs["thumb_cmc_roll"].motor_index]),
math.radians(70.0),
)
motor_by_joint = {
name: spec.motor_index for name, spec in hand.joint_specs.items()
}
offsets = {
name: math.radians(value)
for name, value in zip(zero.direct_zero_joints, offset_degrees)
}
model = UrdfKinematicModel(source)
base_rotation = Rotation.from_euler("xyz", [0.5, -0.4, 0.8])
base_translation = np.asarray([0.31, -0.19, 0.72])
measurements: list[JointAxisMeasurement] = []
for cycle in range(3):
for joint in zero.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,
inherited_zero_joints=zero.inherited_zero_joints,
)
axis, point = model.axis_line(
joint, zero_offsets=offsets, joint_angles=angles
)
point_common = base_rotation.apply(point) + base_translation
if (
inject_secondary_root_point_bias_m
and joint == f"{zero.reference_finger}_mcp_roll"
):
# A repeatable monocular depth error on the second parallel
# root line must affect translation only, never palm rotation
# or the inferred thumb-roll zero.
point_common = point_common + base_rotation.apply(
np.asarray([0.0, 0.0, inject_secondary_root_point_bias_m])
)
view_normal_common = None
if (
inject_oblique_optical_depth_bias
and joint in zero.phase_parent_joint
):
parent_axis = model.axis_line(
zero.phase_parent_joint[joint],
zero_offsets=offsets,
joint_angles=angles,
)[0]
helper = (
np.asarray([1.0, 0.0, 0.0])
if abs(float(parent_axis[0])) < 0.8
else np.asarray([0.0, 1.0, 0.0])
)
tilt_axis = np.cross(parent_axis, helper)
tilt_axis /= np.linalg.norm(tilt_axis)
view_normal = Rotation.from_rotvec(
math.radians(15.0) * tilt_axis
).apply(parent_axis)
view_normal_common = tuple(base_rotation.apply(view_normal))
# Simulate an independent planar-PnP depth error on the child
# Tag. It is large enough to drive the old 3-D phase solve to
# a configured offset bound.
point_common = point_common + 0.03 * np.asarray(
view_normal_common
)
axis_common = base_rotation.apply(axis)
if (
inject_observer_cone_bias_degrees
and joint == "thumb_cmc_pitch"
):
parent_axis = model.axis_line(
zero.axis_parent_joint[joint],
zero_offsets=offsets,
joint_angles=angles,
)[0]
cone_normal = np.cross(axis, parent_axis)
cone_normal /= np.linalg.norm(cone_normal)
axis_common = base_rotation.apply(
Rotation.from_rotvec(
math.radians(inject_observer_cone_bias_degrees)
* cone_normal
).apply(axis)
)
if (
inject_secondary_root_axis_bias_degrees
and joint == f"{zero.reference_finger}_mcp_roll"
):
helper = np.asarray([0.0, 0.0, 1.0])
if abs(float(axis_common @ helper)) > 0.8:
helper = np.asarray([0.0, 1.0, 0.0])
bias_axis = np.cross(axis_common, helper)
bias_axis /= np.linalg.norm(bias_axis)
axis_common = Rotation.from_rotvec(
math.radians(inject_secondary_root_axis_bias_degrees)
* bias_axis
).apply(axis_common)
measurements.append(
JointAxisMeasurement(
joint=joint,
cycle=cycle,
axis_common_xyz=tuple(axis_common),
point_common_xyz_m=tuple(point_common),
condition_state_u8=tuple(state),
plane_rms_m=0.0002,
radial_rms_m=0.0002,
rotation_circle_axis_difference_rad=math.radians(0.1),
view_normal_common_xyz=view_normal_common,
pose_axis_line_rms_m=(
pose_axis_line_rms_by_joint_m or {}
).get(joint, 0.0),
)
)
result = solve_urdf_zero_offsets(
source_urdf=source,
measurements=measurements,
curves=curves,
motor_by_joint=motor_by_joint,
hand_type=side,
joint_maximum_offset_rad={
name: math.radians(value)
for name, value in (joint_maximum_offset_degrees or {}).items()
},
)
return zero, result
def test_small_stable_offsets_are_validated_without_rewriting_urdf_zero() -> None:
zero, result = _solve_synthetic_offsets("right", [0.1] * 7)
assert result.passed is True
static_policy = {
**zero.fixed_direct_zero_offsets_rad,
**zero.static_output_zero_offsets_rad,
}
for name, value in result.direct_offsets_rad.items():
assert value == pytest.approx(static_policy.get(name, 0.0))
def test_profiles_do_not_contain_hard_coded_thumb_zero_offsets() -> None:
right = get_zero_calibration_profile("right")
left = get_zero_calibration_profile("left")
assert "thumb_cmc_roll" not in right.fixed_direct_zero_offsets_rad
assert "thumb_cmc_roll" not in right.static_output_zero_offsets_rad
assert "thumb_cmc_roll" not in left.fixed_direct_zero_offsets_rad
assert "thumb_cmc_roll" not in left.static_output_zero_offsets_rad
def test_reference_finger_roll_static_zero_is_fixed_to_upright_cad() -> None:
zero, result = _solve_synthetic_offsets(
"right", [2.0, -2.0, 2.0, 1.0, 4.0, 1.0, 1.0]
)
reference_roll = f"{zero.reference_finger}_mcp_roll"
assert result.passed is True
assert math.degrees(result.direct_offsets_rad[reference_roll]) == pytest.approx(
0.0, abs=1.0e-12
)
def test_biased_short_root_axis_does_not_tilt_entire_zero_solution() -> None:
zero, result = _solve_synthetic_offsets(
"right",
[2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0],
inject_secondary_root_axis_bias_degrees=15.0,
)
assert result.passed is True
expected = dict(
zip(zero.direct_zero_joints, [2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0])
)
expected.update(
{
name: math.degrees(value)
for name, value in {
**zero.fixed_direct_zero_offsets_rad,
**zero.static_output_zero_offsets_rad,
}.items()
}
)
for name, value in expected.items():
assert math.degrees(result.direct_offsets_rad[name]) == pytest.approx(
value, abs=0.05
)
def test_root_line_depth_bias_does_not_change_thumb_roll_zero() -> None:
zero, result = _solve_synthetic_offsets(
"right",
[2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0],
inject_secondary_root_point_bias_m=0.02,
)
assert result.passed is True
assert math.degrees(
result.direct_offsets_rad["thumb_cmc_roll"]
) == pytest.approx(2.0, abs=0.05)
assert result.direct_offsets_rad[f"{zero.reference_finger}_mcp_roll"] == 0.0
def test_thumb_mcp_static_phase_bias_cannot_override_original_cad_zero() -> None:
offsets = [2.0, -3.0, 4.0, -40.0, 1.0, -1.0, 2.0]
_, result = _solve_synthetic_offsets(
"right",
offsets,
joint_maximum_offset_degrees={"thumb_mcp": 45.0},
)
assert result.passed is True
assert result.direct_offsets_rad["thumb_mcp"] == pytest.approx(0.0)
assert result.cycle_offsets_rad["thumb_mcp"] == pytest.approx((0.0, 0.0, 0.0))
assert "thumb_ip" not in result.validation_error_by_joint_rad
for name in ("thumb_cmc_roll", "thumb_cmc_yaw", "thumb_cmc_pitch"):
assert abs(math.degrees(result.direct_offsets_rad[name])) <= 20.0
for name in ("pinky_mcp_pitch", "pinky_pip"):
assert abs(math.degrees(result.direct_offsets_rad[name])) <= 3.0
assert result.direct_offsets_rad["pinky_mcp_roll"] == pytest.approx(0.0)
def test_end_on_phase_rejects_oblique_monocular_depth_bias() -> None:
zero, result = _solve_synthetic_offsets(
"right",
[2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0],
inject_oblique_optical_depth_bias=True,
)
assert result.passed is True
expected = dict(
zip(zero.direct_zero_joints, [2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0])
)
expected.update(
{
name: math.degrees(value)
for name, value in {
**zero.fixed_direct_zero_offsets_rad,
**zero.static_output_zero_offsets_rad,
}.items()
}
)
for name, value in expected.items():
assert math.degrees(result.direct_offsets_rad[name]) == pytest.approx(
value, abs=0.05
)
def test_zero_solver_rejects_axis_cone_geometry_that_a_zero_cannot_fix() -> None:
_, result = _solve_synthetic_offsets(
"right",
[2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0],
inject_observer_cone_bias_degrees=8.0,
)
assert result.passed is False
assert result.failure_reasons["thumb_cmc_yaw"] == (
"zero_axis_cone_mismatch_too_large"
)
def test_zero_solver_rejects_unreliable_parallel_axis_line_phase() -> None:
_, result = _solve_synthetic_offsets(
"right",
[2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0],
pose_axis_line_rms_by_joint_m={"thumb_mcp": 0.002},
)
assert result.passed is False
assert result.failure_reasons["thumb_cmc_pitch"] == (
"zero_phase_axis_line_residual_too_large"
)
def test_joint_chain_solver_recovers_offsets_and_yaw_uses_roll_145() -> None:
zero = get_zero_calibration_profile("left")
baseline = [255.0] * 20
baseline[6:10] = [127.0] * 4
curves = {
@@ -454,7 +790,7 @@ def test_joint_chain_solver_recovers_offsets_and_yaw_uses_roll_145() -> None:
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]
DIRECT_ZERO_JOINTS, [2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0]
)
}
model = UrdfKinematicModel(SOURCE_URDF)
@@ -514,37 +850,128 @@ def test_joint_chain_solver_recovers_offsets_and_yaw_uses_roll_145() -> None:
)
) > 1.0
assert result.passed is True
static_policy = {
**zero.fixed_direct_zero_offsets_rad,
**zero.static_output_zero_offsets_rad,
}
for name, expected in true_offsets.items():
if name in static_policy:
expected = static_policy[name]
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
0.0, abs=math.radians(0.05)
)
for target, source in INHERITED_ZERO_JOINTS.items():
for target in INHERITED_ZERO_JOINTS:
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(
assert result.offset_uncertainty_rad.keys() == result.direct_offsets_rad.keys()
def test_right_solver_uses_pinky_and_phase_ignores_length_and_depth_bias() -> None:
hand = get_hand_calibration_profile("right")
zero = get_zero_calibration_profile("right")
baseline = [255.0] * 20
baseline[6:10] = [127.0] * 4
curves = {
name: _synthetic_curve(
int(baseline[hand.joint_specs[name].motor_index]),
math.radians(50.0),
)
for name in hand.measured_joints
}
motor_by_joint = {
name: spec.motor_index for name, spec in hand.joint_specs.items()
}
true_offsets = {
name: math.radians(value)
for name, value in zip(
zero.direct_zero_joints,
[2.0, -3.0, 4.0, 1.5, 0.0, -1.0, 2.0],
)
}
model = UrdfKinematicModel(RIGHT_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: list[JointAxisMeasurement] = []
for cycle in range(3):
states: dict[str, list[float]] = {}
lines: dict[str, tuple[np.ndarray, np.ndarray]] = {}
for joint in zero.axis_joints:
state = list(baseline)
if joint == "thumb_cmc_yaw":
state[5] = 145.0
states[joint] = state
angles = _angles_from_state(
state,
curves=curves,
motor_by_joint=motor_by_joint,
inherited_zero_joints=zero.inherited_zero_joints,
)
lines[joint] = model.axis_line(
joint,
zero_offsets=true_offsets,
joint_angles=angles,
)
original_lines = {
name: (axis.copy(), point.copy())
for name, (axis, point) in lines.items()
}
for observer, parent in zero.phase_parent_joint.items():
parent_axis, parent_point = lines[parent]
original_parent_axis, original_parent_point = original_lines[parent]
child_axis, original_child_point = original_lines[observer]
radial = original_child_point - original_parent_point
radial -= original_parent_axis * float(
radial @ original_parent_axis
)
# Preserve angular phase while deliberately corrupting link radius
# and along-axis depth. These components must not move a zero.
lines[observer] = (
child_axis,
parent_point + 1.25 * radial + 0.02 * parent_axis,
)
for joint in zero.axis_joints:
axis, point = lines[joint]
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(states[joint]),
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=RIGHT_SOURCE_URDF,
measurements=measurements,
curves=curves,
motor_by_joint=motor_by_joint,
hand_type="right",
)
assert result.passed is True
static_policy = {
**zero.fixed_direct_zero_offsets_rad,
**zero.static_output_zero_offsets_rad,
}
for name, expected in true_offsets.items():
if name in static_policy:
expected = static_policy[name]
assert result.direct_offsets_rad[name] == pytest.approx(
expected, abs=math.radians(0.05)
)
for target in zero.inherited_zero_joints:
assert result.all_active_offsets_rad[target] == pytest.approx(
0.0, abs=1.0e-12
)
assert set(result.all_active_offsets_rad) == set(hand.active_joints)
@@ -75,10 +75,10 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
"点赞": [255, 0, 0, 0, 0, 255, 162, 162, 144, 100, 210, 255, 255, 255, 255, 255, 0, 0, 0, 0],
"握拳": [96, 0, 0, 0, 0, 0, 193, 158, 128, 91, 132, 255, 255, 255, 255, 144, 0, 0, 0, 0],
"张开": [255, 255, 255, 255, 255, 255, 193, 148, 105, 42, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"OK": [148, 110, 255, 255, 255, 44, 164, 100, 114, 127, 178, 255, 255, 255, 255, 94, 71, 255, 255, 255],
"拇指对中指": [191, 255, 55, 255, 255, 96, 95, 100, 114, 127, 105, 255, 255, 255, 255, 94, 255, 108, 255, 255],
"拇指对无名指": [191, 255, 255, 72, 255, 115, 95, 100, 114, 127, 60, 255, 255, 255, 255, 94, 255, 255, 97, 255],
"拇指对小指": [191, 255, 255, 255, 55, 0, 95, 100, 114, 121, 70, 255, 255, 255, 255, 94, 255, 255, 255, 100],
"OK": [0, 0, 255, 255, 255, 151, 147, 148, 105, 42, 109, 255, 255, 255, 255, 255, 225, 255, 255, 255],
"拇指对中指": [0, 255, 0, 255, 255, 119, 149, 148, 105, 42, 109, 255, 255, 255, 255, 255, 225, 220, 255, 255],
"拇指对无名指": [0, 255, 255, 0, 255, 88, 149, 148, 105, 42, 109, 255, 255, 255, 255, 255, 255, 255, 229, 254],
"拇指对小指": [0, 255, 255, 255, 0, 49, 149, 148, 105, 42, 109, 255, 255, 255, 255, 255, 255, 255, 255, 215],
"准备1": [255, 0, 0, 0, 0, 255, 162, 162, 144, 100, 210, 255, 255, 255, 255, 255, 0, 0, 0, 0],
"壹": [96, 255, 0, 0, 0, 0, 190, 161, 127, 80, 68, 255, 255, 255, 255, 144, 255, 0, 0, 0],
"贰": [96, 255, 255, 0, 0, 0, 190, 66, 127, 80, 68, 255, 255, 255, 255, 144, 255, 255, 0, 0],