34 Commits

Author SHA1 Message Date
admin f94cf2c500 重构 2026-09-11 10:07:45 +08:00
admin c4ad2b968a O12重构一版提交 2026-09-09 15:05:09 +08:00
admin a8ddcc6296 O6gui预设动作修改、O12标定初始化提交 2026-09-04 18:35:04 +08:00
admin 2356bd6247 O12 gui 2026-09-04 15:35:45 +08:00
admin 889e0ea8db o12右手原始urdf 2026-09-04 10:48:19 +08:00
admin e1fb458eff 新增o6/l6左手原始urdf 2026-09-03 17:39:37 +08:00
admin 8de69c34a1 o6右手标定 2026-09-03 10:11:13 +08:00
admin d6b7bd6209 通用 URDF patch engine 抽取 2026-09-02 15:17:50 +08:00
admin 8a749a3687 根据json修正urdf 2026-09-02 14:20:29 +08:00
admin 08fe190b3a O6原始urdf 2026-09-02 13:36:16 +08:00
admin f7aeef87a8 L6右手标定 2026-09-02 13:26:32 +08:00
admin 2b7c1f92e7 原始urdf位置修改 2026-09-01 13:57:37 +08:00
admin 1ed36ecdd8 标定代码结构修改 2026-09-01 11:51:28 +08:00
admin 7f84225ba8 refactor: dispatch formal calibration through model profiles 2026-08-31 19:22:51 +08:00
admin ba9f1b25e8 refactor: establish reusable calibration architecture 2026-08-31 18:33:31 +08:00
admin 0d606c2ba2 refactor: rename calibration package 2026-08-31 18:06:15 +08:00
admin 06c050e446 test: restore calibration regression baseline 2026-08-31 17:58:11 +08:00
admin 286581bcba 大拇指单独标定,yaw正确和稳定性修改 2026-08-31 17:17:39 +08:00
admin 4dadfb954b 大拇指零位正确性稳定性修改 2026-08-30 17:35:23 +08:00
admin 83c69b48c2 标定稳定性 2026-08-27 16:24:01 +08:00
admin 4e594ddb09 G20右手四指独立标定(少thumb_mcp) 2026-08-24 10:12:23 +08:00
admin ef65681230 G20四指单独标定(少末端tag) 2026-08-21 12:21:39 +08:00
admin a609d521a0 g20右手标定 2026-08-11 15:51:36 +08:00
admin 41ff4a61a9 新零位相机外参标定方案 2026-08-07 16:22:24 +08:00
admin 4107da4c22 三机位标定 2026-08-05 18:43:10 +08:00
admin 5d206bcb73 三机位标定 2026-08-05 18:32:51 +08:00
admin 05634f5472 拇指侧摆零位标定 2026-08-03 15:36:49 +08:00
admin ce9d0129b9 拇指零位标定 2026-07-30 17:05:22 +08:00
admin 9210373fb2 圆心标定方案 2026-07-29 18:30:08 +08:00
admin 44975620a7 标定 2026-07-29 11:49:16 +08:00
admin 0d92e5f998 大拇指安全限制去除 2026-07-27 17:40:57 +08:00
admin fc7c66d30e 大拇指横摆安全限制去除 2026-07-27 17:28:06 +08:00
admin b7cf448a4d 多手势标定 2026-07-24 17:36:23 +08:00
admin 1bec806c6e chore: save current code 2026-07-23 11:10:00 +08:00
503 changed files with 77521 additions and 2187 deletions
+40 -1
View File
@@ -50,12 +50,51 @@ Thumbs.db
# Runtime and calibration scratch files
/logs/
/MvSdkLog/
*.tmp
*.log
*.bak
*.orig
*.rej
# Operator/device-specific calibration artifacts
# Reproducible seed profiles remain under
# src/linkerhand_retarget/resource/linkerforce_v2/profiles/.
/profiles/
/calibration_output/
/config/*_three_camera_extrinsics.yaml
*.wear_check.json
*.checkpoint.json
*.verification.json
*_mapping_quality.json
# Device-specific robot descriptions derived from local calibration runs
# Includes full/partial zero-calibration outputs and local copies.
/src/linkerhand_calibration/urdf/*/*_calibrated_*.urdf
/src/linkerhand_calibration/urdf/*/*_zero_calibrated*.urdf
/src/linkerhand_calibration/urdf/*/*_transferred_from_*.urdf
/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_*/
/bags/
/recordings/
/captures/
/sessions/
/reports/
*.db3
*.mcap
candump-*
l10_*_state_*/
*_state_*/
# Local Codex/agent workspace metadata
/.agents/
/.codex/
/.codebuddy/
/.zcode/
+1
View File
@@ -0,0 +1 @@
避免产生屎山代码,要求代码结构清晰可读性好
+3
View File
@@ -1,5 +1,8 @@
# 1. LinkerFFG手套
> 当前FFG多手势标定、G20/O6 profile映射及实机操作请优先参考:
> [FFG多手势标定映射与遥操操作说明](docs/FFG多手势标定映射与遥操操作说明.md)。
## 1.1 产品介绍
本产品的具体介绍参考,内含标定示例说明
附件1、Linker FFG(FFG01)产品说明手册
@@ -0,0 +1,985 @@
# FFG多手势标定映射与遥操技术实现
## 1. 文档定位
本文面向 `linkerforce_v2` 的开发、联调和维护人员,说明新版FFG手套映射遥操链路的
软件架构、标定拟合方法、实时映射算法、ROS 2接口、profile约束和安全门控。
实机标定与启动步骤见
[《FFG多手势标定映射与遥操操作说明》](./FFG多手势标定映射与遥操操作说明.md)。
本文不重复完整操作流程,而是回答以下实现问题:
- 21维FFG数据如何变成模型无关的手部语义;
- 张手、桌面、钩拳、握拳四个锚点如何解耦根部和末端屈伸;
- G20和O6如何共用手套语义、同时保持各自的机械执行标尺;
- 捏合与握持为什么不会把整只手锁定到离散模板;
- `cmd_u8`、`actuation_target`、`q_nominal`三类目标有什么区别;
- profile如何生成、校验、配对和追踪;
- 节点在什么条件下允许或撤销实机控制。
当前实现基于:
- profile schema:`schema_version=1`;
- 手套侧:单只左手FFG,21维输入;
- 机械手侧:左手G20和左手O6;
- 映射模式:`factorized_paired_v2`;
- 机械手profile策略:`paired_continuous_v1`;
- 仿真策略:`semantic_urdf_v1`;
- 标定等级:`provisional`,没有真实关节角GT。
## 2. 代码组织
核心实现位于
[`linkerforce_v2`](../src/linkerhand_retarget/linkerhand_retarget/motion/linkerforce_v2/):
| 文件 | 职责 |
|---|---|
| `constants.py` | FFG关节名、手部语义名、静态/动态手势集合和默认参数 |
| `calibrate_glove.py` | 订阅FFG原始话题,交互采集完整标定和快速佩戴检查 |
| `calibrate_robot.py` | 从GUI命令、快照和SDK状态生成G20/O6实机profile |
| `calibration.py` | 鲁棒统计、FFG特征拟合、机械手通道权重和分段曲线拟合 |
| `profiles.py` | profile加载、严格校验、规范化哈希和原子保存 |
| `mapping.py` | 人手语义提取、连续配对映射、捏合/握持修正和命令滤波 |
| `node.py` | ROS 2实时节点、话题、服务、定时循环和安全门控 |
| `safety.py` | 与ROS无关的超时撤权判定 |
| `simulation.py` | 按关节名重排仿真目标并执行限位检查 |
| `quality.py` | 静态、捏合、动态轨迹的离线回放质量检查 |
| `verify_robot_profile.py` | 低速回放机械手标定姿势并生成独立人工复核报告 |
| `session_manifest.py` | 生成provisional数采清单并绑定profile、URDF和设备信息 |
ROS 2入口在
[`setup.py`](../src/linkerhand_retarget/setup.py),双手机型启动文件为
[`ffg_dual_g20_o6.launch.py`](../src/linkerhand_retarget/launch/ffg_dual_g20_o6.launch.py),
可提交的机械手种子profile位于
[`resource/linkerforce_v2/profiles`](../src/linkerhand_retarget/resource/linkerforce_v2/profiles/)。
## 3. 总体架构
```text
┌──────────────────────────────┐
FFG串口 / ROS JointState│ 21维左手套原始弧度 raw[21] │
└──────────────┬───────────────┘
│ 可选逐维Kalman
▼
┌──────────────────────────────┐
│ HandIntentExtractor │
│ 21维 → 22维0~1人手语义 │
└──────────────┬───────────────┘
│ 同一hand_intent
┌───────────────────┴───────────────────┐
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ G20 RobotMapper │ │ O6 RobotMapper │
│ 16个主动执行语义 │ │ 6个主动执行语义 │
└─────────┬─────────┘ └─────────┬─────────┘
│ │
┌─────────┴─────────┐ ┌─────────┴─────────┐
▼ ▼ ▼ ▼
20维cmd_u8 16维q_nominal 6维cmd_u8 6维q_nominal
G20实机电机空间 G20仿真弧度目标 O6实机电机空间 O6仿真弧度目标
```
架构的关键是分成三层:
1. **传感器层**:FFG原始值和佩戴差异由手套profile吸收;
2. **解剖语义层**:`hand_intent`只描述人的手部动作,不依赖G20或O6;
3. **执行器层**:每个机械手profile独立定义语义到本机电机命令和URDF目标的映射。
因此,换机械手通常不需要改手套语义提取器;换手套或操作者也不需要改G20/O6的
通道定义,只需重新建立对应profile并在运行时配对。
## 4. 数据契约
### 4.1 FFG 21维原始数据
原始输入使用 `sensor_msgs/msg/JointState`。名称和规范顺序为:
```text
thumb_0 ... thumb_4
index_0 ... index_3
middle_0 ... middle_3
ring_0 ... ring_3
pinky_0 ... pinky_3
```
串口解析器将设备角度转换为弧度。实时节点只接受长度为21、全部有限的数据。
- 串口模式直接使用上述顺序;
- topic模式允许输入名称顺序不同,但要求名称集合完整且唯一,节点按规范顺序重排;
- 标定工具要求输入消息已经使用规范顺序;
- 不支持右手FFG,也不会因右手套缺失而退出。
普通四指每指4个原始量:第0维主要用于侧摆,第1~3维共同参与根部和末端屈伸拟合。
拇指5个原始量由不同组合共同拟合旋转、外展、对掌、根部屈伸和末端屈伸。
### 4.2 人手语义
`hand_intent`共22维,所有值限制在 `[0, 1]`:
| 类别 | 名称 |
|---|---|
| 拇指 | `thumb_rotate`、`thumb_abduction`、`thumb_opposition`、`thumb_root`、`thumb_tip` |
| 四指屈伸 | 每指的 `<finger>_root`、`<finger>_tip` |
| 四指侧摆 | 每指的 `<finger>_splay` |
| 捏合证据 | `pinch_index`、`pinch_middle`、`pinch_ring`、`pinch_pinky` |
| 整体握持 | `power_grasp` |
这里的0和1是由个人手套标定定义的语义端点,不是机械手角度,也不代表统一的物理角度。
### 4.3 G20命令空间
G20输出完整20维 `cmd_u8`:
| 下标 | 通道 |
|---:|---|
| 0 | `thumb_cmc_pitch` |
| 1~4 | `index/middle/ring/pinky_mcp_pitch` |
| 5 | `thumb_cmc_roll` |
| 6~9 | `index/middle/ring/pinky_mcp_roll` |
| 10 | `thumb_cmc_yaw` |
| 11~14 | `reserved_11`~`reserved_14`,固定为255 |
| 15 | `thumb_mcp` |
| 16~19 | `index/middle/ring/pinky_pip` |
其中16个通道是主动映射通道,4个保留通道不参与映射。新版种子profile中
`thumb_cmc_yaw`使用完整的 `[0, 255]` 命令范围,不再继承旧版的80下限。
### 4.4 O6命令空间
O6输出6维 `cmd_u8`:
```text
thumb_cmc_pitch
thumb_cmc_yaw
index_mcp_pitch
middle_mcp_pitch
ring_mcp_pitch
pinky_mcp_pitch
```
O6没有独立的四指PIP和侧摆执行通道,因此每个普通手指的单一屈伸通道由
`root`和`tip`语义融合得到。
### 4.5 三种输出标尺
| 输出 | 范围/单位 | 含义 |
|---|---|---|
| `actuation_target` | `[0,1]` | 当前型号各主动通道的归一化语义激活量 |
| `cmd_u8_preview` / 实机命令 | `[0,255]` | 设备电机命令空间,包含机械耦合和本机标定 |
| `joint_target_nominal` | rad | 根据语义激活量和URDF名义端点生成的仿真目标 |
`state_u8`是SDK返回的设备状态,仍属于设备空间。它既不是编码器关节角,也不能作为
`q_nominal`或真实物理关节角的GT。
## 5. FFG手套profile的生成
### 5.1 鲁棒采样统计
每次采集保留:
```text
sample_count
median[21]
mad[21]
raw_frames[N][21]
```
对第 `j` 维:
```text
median_j = median(raw[:, j])
MAD_j = median(abs(raw[:, j] - median_j))
```
每个静态姿势和动态轨迹还保存3次独立重复的上述统计。`approved_for_runtime=true`
要求:
- 11个静态姿势全部存在;
- 7个动态轨迹全部存在;
- 每项恰好3次重复;
- 每次至少50帧;
- 汇总帧数等于3次重复的帧数之和。
因此,CLI虽然允许修改静态 `--repeats`,但不是3次时生成的profile只能用于预览。
### 5.2 基础语义特征拟合
每个语义特征定义一组原始下标和带标签的标定姿势。以某个特征为例:
1. 从语义标签为0的姿势求原始端点 `low`;
2. 从语义标签为1的姿势求原始端点 `high`;
3. 将各标定姿势归一化为:
```text
n_j = clip((raw[index_j] - low_j) / (high_j - low_j), 0, 1)
```
4. 用最小二乘拟合各原始维度对语义标签的贡献;
5. 将负权重截为0,再归一化为权重和1;
6. 运行时计算:
```text
feature = clip(sum(weight_j * n_j), 0, 1)
```
无有效跨度的维度不参与归一化。若拟合后所有权重都接近0,则回退为等权。
普通四指的 `root`和`tip`故意使用同一组3个屈伸传感器,但使用不同姿势标签:
| 姿势 | root目标 | tip目标 |
|---|---:|---:|
| 张手/并拢 | 0 | 0 |
| 桌面 | 1 | 0 |
| 钩拳 | 0 | 1 |
| 握拳 | 1 | 1 |
这一设计先得到两个可能仍有耦合的初始特征,再由下一步二维标定面解耦。
### 5.3 根部—末端双线性解耦
对每个普通手指,在初始 `(root_feature, tip_feature)` 平面中取得四个锚点:
```text
p00 = 张手
p10 = 桌面
p01 = 钩拳
p11 = 握拳
```
建立双线性标定面:
```text
p(u, v) = p00
+ (p10 - p00) * u
+ (p01 - p00) * v
+ (p11 - p10 - p01 + p00) * u * v
```
其中 `u`是解耦后的根部屈伸,`v`是解耦后的末端屈伸。运行时先用线性最小二乘得到
初值,再执行最多5次Newton迭代反解 `(u, v)`,最后限制到 `[0,1]`。
只有标定四边形在四角的Jacobian行列式符号一致,且最小绝对值不小于 `1e-3` 时才启用
该解码器。退化或发生折叠的标定面不会用于反解,此时保留基础特征结果。
### 5.4 动态屈伸对侧摆的串扰补偿
每个普通手指的独立屈伸往返轨迹假设该手指侧摆应基本不变。对每次重复:
```text
x = 0.5 * (root + tip)
y = splay
x, y分别减去各自中位数
coefficient = dot(x, y) / (dot(x, x) + ridge)
```
其中 `ridge = 1e-3 * max(dot(x,x), 1e-6)`。
以下情况拒绝学习该次轨迹:
- 少于10帧、长度错误或存在非有限值;
- 屈伸变化范围小于0.05;
- 侧摆几乎没有变化,无法估计;
- 补偿后残差方差仍大于原方差的80%;
- 三次重复的有效系数方向互相矛盾。
最终系数取各有效重复的中位数并限制到 `[-1,1]`,运行时执行:
```text
splay_corrected = clip(
splay - coefficient * 0.5 * root
- coefficient * 0.5 * tip,
0,
1
)
```
补偿只发生在人手语义层,不直接学习或修改任何G20/O6电机系数。
### 5.5 捏合证据
每种捏合只使用拇指5维和目标手指4维。标定时保存:
```text
center = 目标捏合姿势中位数
scale = max(abs(center - open), 6 * pinch_pose_MAD, 0.02)
```
并计算张手到捏合中心的归一化距离 `open_distance`。运行时:
```text
distance = RMS((raw_selected - center) / scale)
pinch_strength = clip(1 - distance / open_distance, 0, 1)
```
这4个值是候选证据,不直接等于4个离散状态;最终是否施加捏合修正还要经过竞争门控。
### 5.6 快速佩戴检查
快速检查重新采集张手、握拳和食指捏合。每个姿势计算:
```text
normalized_error =
RMS((observed_median - reference_median) / max(6 * MAD, 0.05))
```
默认要求每个误差不大于4.0。凭据保存当前手套profile的规范化SHA-256、检查时间、
阈值、各姿势误差和通过状态。
实时节点只在加载profile时检查凭据:
- `kind=ffg_wear_check`;
- `passed=true`;
- 绑定哈希等于当前手套profile哈希;
- 凭据年龄在配置范围内,默认12小时。
节点不会在长时间运行期间周期性重新读取凭据或重新计算年龄。需要跨时段运行时,应按
作业流程主动重启节点并重新执行佩戴检查。
## 6. 机械手profile的生成
### 6.1 捕获数据
`hand_pose_capture`同时监听:
- GUI连续命令 `/<model>/cb_left_hand_control_cmd`;
- SDK状态 `/<model>/cb_left_hand_state`;
- GUI保存快照 `/<model>/calibration_pose_snapshot`。
消息名称允许任意顺序,但必须与seed中的 `command_names`集合完全一致;保存前统一重排为
profile顺序。每个姿势保存:
```text
cmd_u8
command_names
state_u8
state_names
status = exact | approximate | unsupported
confirmed
captured_at
```
每次人工确认后立即原子写入checkpoint。恢复时会核对seed哈希、型号、输出路径、
SN、固件、CAN、操作者、命令名和姿势列表;身份不一致时拒绝续标。若旧checkpoint中
某个命令超出新的安全范围,只删除该姿势并要求重拍。
最终 `approved_for_control=true` 同时要求:
- 用户在最后明确批准;
- SN、CAN和操作者非空;
- 所有必需姿势均已确认;
- 命令与状态名称完整;
- 所有命令位于profile安全范围。
`unsupported`表示该姿势不参与对应运行时约束,但该姿势记录本身仍需人工确认并保存。
### 6.2 多源执行通道权重
一个机械手主动通道可以融合多个人手语义。对seed中列出的 `fit_sources`,使用
张手、桌面、钩拳和握拳的实机命令拟合。
先以张手和握拳命令归一化该通道:
```text
y_pose = (cmd_pose - cmd_open) / (cmd_fist - cmd_open)
```
设计矩阵来自各姿势的规范语义目标,然后执行最小二乘;负权重截为0并归一化。
- G20大部分主动通道只有一个语义源;
- O6普通手指通道同时使用对应的 `root`和`tip`,权重由实机捕获结果拟合;
- 若张手和握拳命令跨度退化,回退为等权。
### 6.3 分段曲线与单调约束
profile生成时,根据通道语义激活量和各标定姿势的 `cmd_u8`产生曲线点。
- `piecewise`:同一激活量的命令取中位数,然后按激活量排序;
- `monotonic_piecewise`:在上述基础上使用相邻违例合并算法执行等距单调回归;
- 曲线至少需要两个不同的激活量;
- 命令点必须位于 `[0,255]`和该通道 `command_bounds`内。
该profile曲线是一条可独立验证和追踪的型号级基线,也是在运行时手套锚点退化时的
单通道回退曲线。
## 7. 运行时分解式配对映射
### 7.1 配对曲线构造
`RobotMapper`同时收到手套profile和机械手profile时,不直接使用抽象规范姿势坐标,
而是:
1. 用当前手套profile的静态姿势中位数重新计算真实 `hand_intent`;
2. 找出手套和机械手共有且未标为 `unsupported` 的姿势;
3. 对每个机械手主动通道,选择真正定义该解剖通道的姿势;
4. 以当前手套语义激活量为横轴、当前实机profile命令为纵轴重建分段曲线;
5. 对单调通道再次执行单调回归。
姿势选择规则为:
| 通道 | 使用的基础姿势 |
|---|---|
| 普通四指屈伸 | 张手、并拢、桌面、钩拳、握拳中双方共有的姿势 |
| G20普通四指侧摆 | 并拢、张手 |
| 拇指基础通道 | 最大外展、张手、横跨掌心 |
捏合姿势不进入普通通道曲线,握拳也不直接进入拇指基础曲线;它们分别由局部残差分支
处理。这样,某个捏合捕获中的非目标手指残留命令不会污染普通手指曲线。
如果某个通道的实际手套锚点退化为少于两个不同激活量,该通道使用机械手profile中
已经校验的曲线;其他通道仍可保持配对曲线。
完成构造后,`mapping_mode`为 `factorized_paired_v2`。
### 7.2 基础通道映射
对第 `k` 个主动通道,其来源权重为 `w_ki`,当前人手语义为 `h_i`:
```text
a_k = clip(sum(w_ki * h_i) / sum(w_ki), 0, 1)
```
`a_k`组成 `actuation_target`。基础电机命令由该通道配对曲线分段线性插值得到:
```text
cmd_base[index_k] = piecewise_linear(a_k, paired_points_k)
```
完整命令向量先以张手命令初始化,主动通道逐个覆盖;未映射的保留通道之后强制写回
固定值。
### 7.3 竞争式局部捏合修正
#### 7.3.1 标定自适应阈值
映射器先对手套profile中的所有静态姿势计算4种捏合分数。对每个真实捏合姿势记录:
- 目标分数;
- 目标分数相对其他3种分数的领先量。
对所有非捏合姿势记录:
- 最大误触分数;
- 第一名相对第二名的误触领先量。
满激活阈值取4个目标姿势中的最弱值,起始阈值位于最大负样本与满激活阈值之间的20%:
```text
score_onset = negative_score + 0.2 * (score_full - negative_score)
margin_onset = negative_margin + 0.2 * (margin_full - negative_margin)
```
若当前手套profile无法在正负样本间形成有效分数或领先量间隔,所有捏合门均保持0。
#### 7.3.2 单赢家连续门控
运行时仅选择当前分数最高的候选,并计算:
```text
score_gate = smoothstep((top_score - score_onset) / score_span)
margin_gate = smoothstep((top_score - second_score - margin_onset) / margin_span)
pinch_gate = score_gate * margin_gate
```
`smoothstep(x)=x²(3-2x)`,输入先限制到 `[0,1]`。其他3种捏合门为0。
因此:
- 证据不足时保持普通连续映射;
- 两种捏合证据接近时,领先量门控将修正降到0;
- 不存在确认帧数、进入/退出滞回或历史姿势锁存;
- 捏合切换只依赖当前帧,且权重连续变化。
#### 7.3.3 局部残差
对每种捏合,在该手套捏合中位数处先计算基础命令,再与机械手目标捏合命令做差:
```text
residual = robot_pinch_target - base_command_at_glove_pinch
```
运行时只把 `pinch_gate * residual`加到:
- 所有拇指主动通道;
- 当前目标手指的主动通道。
其他3根手指不参与该分支。G20的保留通道也不参与。
### 7.4 握持时的拇指协调
`power_grasp`是8个普通四指 `root/tip`语义的平均值。握持分数进一步要求拇指主动折叠:
```text
grasp_score = min(
power_grasp,
thumb_opposition,
thumb_root,
thumb_tip
)
```
满分取手套握拳姿势,负样本取其他静态姿势的最高分,门控同样使用从负样本到握拳分数
20%处开始的 `smoothstep`。
握持残差是机械手握拳目标与握拳处基础命令的差,但只施加到拇指主动通道。四指仍由
各自连续屈伸曲线决定,普通拇指动作也不会仅因四指弯曲而被强制成握拳拇指。
### 7.5 安全范围与保留通道
局部修正完成后依次执行:
1. 写回保留通道固定值;
2. 按每通道 `command_bounds`裁剪;
3. 执行可选命令滤波;
4. 再次裁剪并再次写回保留通道;
5. 最终四舍五入为整数命令。
`raw_command`保留滤波前浮点目标,当前ROS节点不发布该字段;`cmd_u8_preview`发布滤波后
并取整的最终目标。
## 8. 滤波与实时执行
### 8.1 输入Kalman
输入滤波是21个互相独立的一维Kalman滤波器,共享参数:
```text
P_pred = P + process_variance
K = P_pred / (P_pred + measurement_variance)
x = x + K * (z - x)
P = (1 - K) * P_pred
```
首帧、时间倒退或帧间隔超过 `input_filter_reset_gap` 时直接重置到当前测量,避免断流后
从旧状态缓慢追赶。默认关闭。
### 8.2 输出命令滤波
`CommandFilter`支持:
| 模式 | 行为 |
|---|---|
| `passthrough` | 直接使用本帧目标,仅应用deadband |
| `ema` | `step=clip(alpha*(target-last), ±max_step)` |
| `acceleration_limited` | 同时限制速度、帧间加速度,并根据剩余距离提前制动 |
默认参数匹配旧版左手G20的有效执行路径:
```text
input_filter_enabled=false
command_filter_mode=passthrough
command_filter_ema_alpha=1.0
command_filter_max_step_u8=255
command_filter_deadband_u8=0
```
实时节点没有单独暴露 `command_filter_max_acceleration_u8_per_frame2` 参数;
`acceleration_limited`模式下它使用与 `command_filter_max_step_u8`相同的值。
### 8.3 30 Hz最新帧策略
实时节点的处理定时器默认30 Hz:
1. 串口模式从线程安全快照取得最新序列号、数据和接收时刻;
2. 只有出现新FFG序列时,才发布/更新raw、filtered、intent和frame metadata;
3. 每个定时周期都用最近一次有效intent重新计算两个型号目标;
4. 预览始终发布,只有已使能型号才发布到SDK命令话题;
5. 硬件命令QoS为 `RELIABLE + KEEP_LAST(depth=1)`。
固定控制心跳不会排队重放旧手套帧。FFG停止更新时,节点可在超时窗口内短暂复用最后
intent,随后watchdog撤销使能。
使能某型号时,命令滤波器会重置到该型号最新有效SDK状态,而不是张手或上一次内部
目标,从而降低重新使能的第一帧跳变。
## 9. 独立仿真目标
仿真目标不从 `cmd_u8`反解。对主动通道激活量 `a_k`:
```text
q_nominal_k = clip(
q_open_k + a_k * (q_closed_k - q_open_k),
q_lower_k,
q_upper_k
)
```
其输入是基础解剖通道激活量,不使用电机命令曲线,也不直接使用捏合或握持的电机残差。
因此实机姿势捕获中的机械耦合、偶然残留值和保留通道不会污染仿真弧度目标。
仿真消费者必须按 `JointState.name`建立映射。`simulation.py`提供:
- `build_name_mapping()`:检查空名、重名、缺名和多余名称;
- `reorder_named_target()`:按仿真模型顺序重排,检查有限值并应用仿真限位。
名称合同不满足时抛出 `JointNameMismatch`,不得按裸下标猜测。
profile中的 `urdf_sha256`用于追踪生成名义端点时对应的URDF版本,但实时映射节点本身
不读取或重新计算URDF文件哈希;数采manifest工具会执行文件哈希核对。
## 10. ROS 2实时节点
### 10.1 输入与输出话题
| 话题 | 类型 | 维度 | 发布条件 |
|---|---|---:|---|
| `/ffg/left/raw_joint_state` | `JointState` | 21 | 串口模式收到新帧;topic模式直接使用上游话题 |
| `/ffg/left/filtered_joint_state` | `JointState` | 21 | 有有效手套profile和新帧 |
| `/retarget/left/hand_intent` | `JointState` | 22 | 有有效手套profile和新帧 |
| `/retarget/left/frame_meta` | `String(JSON)` | - | 每个新映射手套帧 |
| `/retarget/g20/left/actuation_target` | `JointState` | 16 | G20 mapper有效 |
| `/retarget/o6/left/actuation_target` | `JointState` | 6 | O6 mapper有效 |
| `/retarget/g20/left/joint_target_nominal` | `JointState` | 16 | G20 mapper有效 |
| `/retarget/o6/left/joint_target_nominal` | `JointState` | 6 | O6 mapper有效 |
| `/retarget/g20/left/cmd_u8_preview` | `JointState` | 20 | G20 mapper有效 |
| `/retarget/o6/left/cmd_u8_preview` | `JointState` | 6 | O6 mapper有效 |
| `/g20/cb_left_hand_control_cmd` | `JointState` | 20 | G20已使能 |
| `/o6/cb_left_hand_control_cmd` | `JointState` | 6 | O6已使能 |
| `/ffg_dual_retarget/status` | `String(JSON)` | - | 1 Hz |
节点订阅:
| 话题 | 说明 |
|---|---|
| `raw_input_topic` | `input_mode=topic`时的FFG输入,默认 `/ffg/left/raw_joint_state` |
| `/g20/cb_left_hand_state` | G20驱动状态心跳 |
| `/o6/cb_left_hand_state` | O6驱动状态心跳 |
topic输入模式不会再次向raw话题发布收到的消息,避免默认同名话题形成反馈。
驱动状态的名称必须已经按profile `command_names`规范顺序排列;这里与FFG topic输入不同,
不会对驱动状态按集合重排。
### 10.2 帧元数据
`frame_meta`包含:
```json
{
"timestamp_ns": 0,
"sequence": 0,
"calibration": "provisional",
"q_gt": null
}
```
一个新手套帧产生的filtered、intent、frame_meta及该次定时周期的型号目标共用ROS时间戳。
定时器复用旧intent时,型号目标使用新的当前时间戳,但不会重复发布intent和frame_meta。
### 10.3 状态诊断
1 Hz状态JSON包含:
- 当前输入模式、FFG帧序号和数据年龄;
- G20/O6分别是否使能;
- glove、G20、O6的批准状态和SHA-256;
- `mapping_mode`、`simulation_mapping_mode`;
- 输入和命令滤波配置;
- 当前捏合/握持局部分支权重 `anchor_weights`;
- wear-check有效性;
- 驱动状态年龄;
- profile和URDF哈希;
- profile加载错误、最近故障和p95调度延迟。
`latency_p95_ms`以本机接收手套数据的单调时钟为起点,表示接收至映射调度的延迟,
不是基于设备硬件时间戳的端到端链路延迟。
### 10.4 服务与状态转换
| 服务 | 类型 | 作用 |
|---|---|---|
| `~/enable_g20` | `SetBool` | 单独申请/撤销G20实机控制 |
| `~/enable_o6` | `SetBool` | 单独申请/撤销O6实机控制 |
| `~/enable_all` | `SetBool` | 原子检查两台后同时使能,或同时撤销 |
| `~/emergency_stop` | `Trigger` | 立即撤销两个型号的命令发布权限 |
```text
显式SetBool(true)且全部检查通过
┌──────────────────────────────────────────┐
│ ▼
PREVIEW / DISABLED ENABLED(model)
▲ │
└──────────────────────────────────────────┘
SetBool(false)、超时、映射异常或软件急停
```
撤销使能的含义是停止向SDK命令话题发布新命令,不会主动发送张手、零位或其他安全姿势。
驱动/固件将保持最后命令相关行为;物理急停仍应由系统级安全链路负责。
## 11. 实机使能门控
某个型号从PREVIEW进入ENABLED前依次检查:
1. 手套profile已加载且 `approved_for_runtime=true`;
2. wear-check已通过启动时校验;
3. 机械手profile已加载且 `approved_for_control=true`;
4. 启动参数中的期望SN非空,并与profile SN完全一致;
5. profile CAN接口与启动配置一致;
6. mapper为 `factorized_paired_v2`;
7. FFG最近一帧未超过 `glove_timeout`,默认0.35秒;
8. 对应SDK状态名称、长度、数值和范围有效,且未超过 `driver_timeout`,默认1秒。
运行时身份门控比较SN和CAN接口,不比较profile中的固件版本。固件兼容性目前依赖操作
流程和数采manifest的可选校验;若固件变更会改变电机响应,应重新标定机械手profile。
`enable_all`先检查G20和O6两者,任意一个失败都不会使能任何一个。单型号服务互相独立。
## 12. Watchdog与故障策略
watchdog周期为50 ms:
| 故障 | 动作 |
|---|---|
| 任意型号已使能且FFG超时 | 同时撤销G20和O6 |
| 某型号SDK状态无效或超时 | 只撤销该型号,另一型号保持 |
| 映射计算出现数值/形状错误 | 同时撤销G20和O6 |
| 软件急停 | 同时撤销G20和O6 |
| profile加载失败 | 启动时降级;不创建对应mapper或只保留raw |
故障恢复不会自动重新使能。排除原因后必须再次调用对应使能服务。
节点启动和profile加载采用fail-closed策略:
- 无手套profile:只发布原始FFG;
- 手套profile有效但未获运行批准:允许完整预览,拒绝实机;
- seed机械手profile `approved_for_control=false`:允许预览,拒绝实机;
- 单个型号profile无效:另一个有效型号仍可生成目标和独立使能。
## 13. 主要ROS参数
### 13.1 FFG输入
| 参数 | 默认值 | 说明 |
|---|---|---|
| `input_mode` | `serial` | `serial`或`topic` |
| `raw_input_topic` | `/ffg/left/raw_joint_state` | topic模式输入 |
| `serial_port` | 空 | 指定串口;为空时可自动扫描 |
| `baudrate` | `0` | 大于0时优先尝试该波特率 |
| `baudrates` | `[2000000,460800,1000000,921600]` | 探测候选 |
| `auto_scan` | `true` | 指定端口失败或为空时扫描 |
| `serial_debug` | `false` | 串口调试日志 |
### 13.2 Profile与身份
| 参数 | 默认值 | 说明 |
|---|---|---|
| `glove_profile` | 空 | FFG完整标定profile |
| `wear_check` | 空 | 快速佩戴检查凭据 |
| `wear_check_max_age_hours` | `12.0` | 启动加载时允许的最大年龄 |
| `g20_profile` / `o6_profile` | 空 | 单机机械手profile |
| `g20_serial_number` / `o6_serial_number` | 空 | 运行期望SN |
| `g20_can_interface` | `can0` | G20身份核对 |
| `o6_can_interface` | `can1` | O6身份核对 |
### 13.3 时序和滤波
| 参数 | 默认值 | 说明 |
|---|---:|---|
| `publish_rate` | `30.0` | 固定映射/命令心跳Hz |
| `glove_timeout` | `0.35` | FFG超时秒数 |
| `driver_timeout` | `1.0` | SDK状态超时秒数 |
| `input_filter_enabled` | `false` | 是否启用逐维Kalman |
| `input_filter_process_variance` | `1e-5` | Kalman过程噪声 |
| `input_filter_measurement_variance` | `5e-4` | Kalman测量噪声 |
| `input_filter_reset_gap` | `0.35` | 断流重置阈值 |
| `command_filter_mode` | `passthrough` | 输出滤波模式 |
| `command_filter_ema_alpha` | `1.0` | EMA/限加速度目标增益 |
| `command_filter_max_step_u8` | `255.0` | 每帧最大速度尺度 |
| `command_filter_deadband_u8` | `0.0` | 小于该差值时保持上一目标 |
启动文件还负责创建两个SDK节点,并设置启动速度、力矩、状态轮询和G20控制期间延迟状态
读取等驱动参数;这些不是 `ffg_dual_retarget`自身参数。
## 14. Profile校验与可追踪性
### 14.1 严格加载
`profiles.py`在构造mapper之前检查:
- schema、profile类型、型号和左手侧;
- 固定的FFG关节名或机械手命令名;
- 所有数组长度、有限值和范围;
- 特征权重非负且和为1;
- 分段曲线激活量、命令范围和单调性;
- 主动通道与保留通道完整覆盖命令向量;
- 仿真名称顺序、端点、限位和URDF哈希格式;
- 已批准profile的设备身份、人工确认、状态和名称完整性。
profile错误不会被静默修正为另一种型号或旧映射策略。
### 14.2 规范化哈希
profile哈希不是原文件字节哈希,而是:
1. 排除加载器添加的 `_profile_path`和 `_profile_sha256`;
2. JSON key排序;
3. 使用紧凑分隔符和UTF-8;
4. 计算SHA-256。
因此仅缩进或JSON键顺序变化不会改变profile身份,持久字段变化会改变哈希。
保存使用同目录临时文件加原子替换,避免中途退出留下半个JSON。
### 14.3 相关运行文件
| 文件 | 技术作用 |
|---|---|
| glove profile | 原始帧、鲁棒统计、特征参数和捏合锚点 |
| wear-check | 绑定glove profile哈希的短期佩戴凭据 |
| robot profile | 设备身份、姿势、通道曲线、安全范围和仿真端点 |
| checkpoint | 绑定seed和设备元数据的可恢复捕获进度 |
| verification | 绑定robot profile哈希的独立人工复核结果 |
| session manifest | 绑定profile、wear-check、URDF、设备和rosbag话题 |
`session_manifest.py`当前要求G20和O6都已批准,并按 `can0/can1`核对;它适用于标准双手
型号数采拓扑,不是任意单型号或任意CAN配置的通用manifest生成器。
## 15. 离线质量检查
`retarget_profile_check`不启动ROS、不连接机械手,直接回放profile中的原始帧和姿势。
### 15.1 静态复现
对每个共有姿势,只比较该姿势真正定义的相关通道:
- 捏合:拇指和目标手指;
- 拇指姿势:拇指通道;
- 并拢:侧摆通道;
- 桌面/钩拳:普通四指屈伸通道;
- 握拳:除普通侧摆外的通道。
任一相关通道最大误差大于5个u8单位,记为hard failure。
### 15.2 捏合混淆
四个捏合中位数必须:
- 竞争winner等于目标手指;
- 目标门控不小于0.95。
否则记为hard failure。
### 15.3 动态连续性和局部性
每组动态重复记录:
- 目标通道跨度;
- 非目标通道跨度;
- 原始浮点命令帧间步长p95和最大值;
- 取整后整帧不变比例;
- 应用profile执行滤波后的同类指标。
普通手指屈伸轨迹中,若非目标通道跨度中位数大于
`max(15, 0.2 * target_span)`,生成warning。四指开合轨迹中若任一屈伸语义范围中位数
大于0.5,也生成warning。
动态步长目前只报告统计量,没有统一hard-failure阈值;应结合采样率、动作速度和设备
允许步长分析。
离线通过只证明profile内部复现和分解逻辑满足这些判据,不证明实机物理角度精度。
## 16. 实机姿势复核实现
`hand_pose_verify`加载已批准机械手profile后:
1. 查询命令话题是否已有其他发布者,有则拒绝开始;
2. 要求显式输入安全确认;
3. 从最新SDK状态而不是上一次目标开始;
4. 将目标分成每通道步长不超过 `max_step_u8` 的线性序列;
5. 默认30 Hz发送,运动中周期检查新竞争发布者;
6. 稳定后读取命名状态并计算设备空间绝对误差;
7. 保存人工通过/失败、备注、目标、状态和误差摘要。
默认 `max_step_u8=4`,CLI硬限制不超过8。复核报告明确记录
`state_is_angle_ground_truth=false`,并且不修改原机械手profile。
## 17. 扩展和维护约束
### 17.1 增加新的机械手型号
至少需要:
1. 在 `MODEL_COMMAND_LENGTHS`登记型号和命令长度;
2. 定义唯一、稳定的 `command_names`;
3. 创建seed profile,包括姿势、安全范围、主动通道、保留通道和仿真端点;
4. 明确每个执行通道的解剖语义源;
5. 扩展profile校验器的必需姿势集合;
6. 扩展节点的话题、身份参数、状态和服务;
7. 增加静态复现、局部性、限位和名称合同测试。
不要通过复制G20的裸下标映射来接入新型号;名称、主动通道和保留通道必须显式定义。
### 17.2 增加新的手套语义
需要同步更新:
- `BASE_INTENT_NAMES`或派生语义列表;
- 标定姿势标签和原始下标;
- glove profile生成及严格校验;
- `HandIntentExtractor.extract()`输出顺序;
- 使用该语义的机械手seed和测试;
- rosbag/下游消费者的数据合同。
修改名称或顺序会影响profile兼容性,应升级schema而不是让旧profile静默通过。
### 17.3 修改手势或阈值
捏合和握持阈值由当前手套profile自动推导。优先修复标定数据或距离定义,不要增加隐藏
的全局常量绕过竞争判据。若确需改变门控公式,应同时更新:
- 正/负样本定义;
- 连续性和混淆测试;
- 离线质量报告;
- `mapping_mode`或schema版本,以便数据可追踪。
### 17.4 线程与实时性
- 串口读取在线程中更新带锁快照;
- ROS节点定时器只消费最新快照,不阻塞等待串口;
- 运行时没有无界命令队列;
- 标定和复核CLI可使用后台executor线程,因为它们包含交互式终端等待;
- 映射主要是小向量NumPy运算,不包含在线优化或模型推理。
## 18. 测试与验收建议
核心单元/集成测试集中在
[`test_linkerforce_v2.py`](../src/linkerhand_retarget/test/test_linkerforce_v2.py),覆盖:
- 鲁棒统计和profile完整性;
- 根部/末端解耦和曲线内部无平台;
- 捏合局部性、连续切换和无历史锁存;
- 普通手指对拇指动作的独立性;
- 动态侧摆串扰补偿;
- 仿真目标与电机残差隔离;
- 滤波步长、加速度、重置和默认直通行为;
- profile身份、checkpoint、安全范围和命名合同;
- 重使能重基准和超时撤权;
- G20/O6输出长度、名称和限位。
修改核心算法后至少执行:
```bash
source /opt/ros/jazzy/setup.bash
source install/setup.bash
python3 -m pytest -q \
src/linkerhand_retarget/test/test_linkerforce_v2.py
```
完成profile标定后再分别执行G20和O6离线质量回放。涉及ROS接口、SDK命名或launch参数的
修改,还应在PREVIEW状态检查实际话题长度、名称、频率和status JSON,再进入低速实机
验收。
## 19. 已知边界
- 当前只支持左手FFG到左手G20/O6;
- `provisional`不提供真实物理关节角精度声明;
- `cmd_u8`和SDK `state_u8`不能转换成可靠的真实关节弧度;
- 仿真目标只代表语义—URDF名义映射,不是实机测量;
- 捏合竞争无时间滞回,连续性依赖当前帧证据质量和可选输入滤波;
- wear-check只在节点加载时验证,不在长时间运行中自动过期撤权;
- 运行时身份门控不核对固件版本;
- 软件急停只撤销发布权限,不替代硬件急停或独立安全控制器;
- 默认双型号launch会同时创建两个SDK驱动;单型号系统可直接启动所需驱动和
`ffg_dual_retarget`节点。
这些边界应保留在数据报告、实验结论和对外精度声明中。
@@ -0,0 +1,731 @@
# FFG多手势标定映射与遥操操作说明
## 1. 文档目的
本文档说明当前 `linkerforce_v2` 无Marker方案的工作原理、标定流程和实机操作方法。
当前主要使用场景是一只左手FFG控制左手G20,也支持在配置对应profile后同时生成O6目标。
当前方案属于 `provisional` 阶段:
- 可以验证手套语义、机械手通道、方向、动作范围和连续性;
- 可以用于演示和临时数采;
- 不能把G20/O6的 `0~255` 电机命令当作真实关节角;
- 没有Marker、编码器或独立角度传感器时,不能给出实机与仿真的真实角度误差。
旧入口 `handretarget` 仍然保留;本文档只描述新入口 `ffg_dual_retarget`。
## 2. 当前映射架构
```text
FFG左手套21维原始数据
↓
hand_intent:模型无关的人手语义(0~1)
↓
├─→ G20 actuation_target
│ ├─→ G20单机profile → 20维cmd_u8 → G20实机
│ └─→ G20名义URDF范围 → q_nominal → 仿真
│
└─→ O6 actuation_target
├─→ O6单机profile → 6维cmd_u8 → O6实机
└─→ O6名义URDF范围 → q_nominal → 仿真
```
实机命令和仿真目标是两条独立标尺:
- `cmd_u8`:设备电机空间命令,范围为0~255;
- `q_nominal`:根据URDF名义限位生成的仿真弧度目标;
- `state_u8`:SDK返回的设备状态,只用于运行诊断,不是真实关节角GT。
仿真不应直接把 `cmd_u8` 当作真实角度。需要接近实机外观时,可以使用同一
`actuation_target`,再通过实测角度标定完善仿真标尺。
## 3. 多手势标定解决什么问题
### 3.1 FFG静态姿势
完整手套标定采集11个静态姿势:
1. 五指自然张开、自然分开;
2. 五指伸直并拢;
3. 桌面手势:四指根部弯曲、末端伸直;
4. 钩拳:四指根部伸直、末端弯曲;
5. 自然握拳;
6. 拇指最大外展;
7. 拇指横跨掌心;
8. 拇指—食指捏合;
9. 拇指—中指捏合;
10. 拇指—无名指捏合;
11. 拇指—小指捏合。
每个静态姿势默认采集2秒、重复3次,每次至少50帧。profile保留全部原始帧、
每次中位数、MAD和有效帧数,而不是只保存一个平均值。
### 3.2 FFG动态轨迹
完整标定还采集7组短时往返轨迹:
- 食指独立弯曲往返;
- 中指独立弯曲往返;
- 无名指独立弯曲往返;
- 小指独立弯曲往返;
- 拇指弯曲往返;
- 拇指对掌往返;
- 四指开合往返。
动态轨迹主要用于发现和补偿同一手指屈伸对侧摆语义的传感器串扰,并检查非目标
手指是否跟随运动。它们不是额外的离散手势模板。
### 3.3 连续映射原则
当前运行时不会把整只手吸附到“最相似的标定手势”:
- 每根普通手指只读取自身的根部、末端和侧摆语义;
- 张手、桌面、钩拳和握拳构成四指根部—末端标定面,连续解耦传感器串扰;
- 普通屈伸映射保持连续,不在曲线内部加入停止平台;
- 四种捏合分别进行竞争判断;
- 捏合只局部修正拇指和目标手指,不替换整只手命令;
- 捏合证据不明确时,连续退回普通逐关节映射;
- 握拳只增加必要的拇指协调,不把相似动作强制变成握拳模板。
### 3.4 当前实时执行策略
当前默认执行节奏与旧版左手G20的有效路径一致:
```text
publish_rate=30Hz
input_filter_enabled=false
command_filter_mode=passthrough
command_filter_ema_alpha=1.0
command_filter_max_step_u8=255
command_filter_deadband_u8=0
repeat_position_commands=true
```
也就是直接使用最新手套帧,将连续电机目标交给G20固件插值,并在每个30Hz控制心跳
重复发送最新目标。待发送队列深度为1,来不及发送时只保留最新目标,不重放旧命令。
Kalman和EMA仍然可以显式启用,但不要同时启用两层滤波。两层滤波叠加后再进行整数
取整,容易表现为小幅运动停顿、累计后跳变。
## 4. profile与运行文件
| 文件 | 内容 | 是否提交Git |
|---|---|---|
| `glove_<ID>_left_<operator>.json` | 个人佩戴下的FFG完整标定 | 否 |
| `*.wear_check.json` | 绑定手套profile哈希的快速佩戴检查 | 否 |
| `hand_G20_left_<SN>_provisional.json` | 指定G20实机的姿势命令profile | 否 |
| `*.checkpoint.json` | 实机姿势标定中断续标检查点 | 否 |
| `*.verification.json` | 实机姿势人工复核报告 | 否 |
| `g20_seed_profile.json` | G20 GUI安全初值和标定结构 | 是 |
| `o6_seed_profile.json` | O6 GUI安全初值和标定结构 | 是 |
根目录 `profiles/` 已加入 `.gitignore`。可复现的种子profile位于:
```text
src/linkerhand_retarget/resource/linkerforce_v2/profiles/
```
手套profile与操作者、手套和佩戴方式相关;机械手profile与型号、序列号、固件和CAN
接口相关。换手套、换操作者、明显改变佩戴位置时,应重新完整标定FFG。换机械手本体
或固件导致电机响应变化时,应重新标定机械手profile。
## 5. 构建与环境准备
在每个新终端中都要加载ROS和工作空间:
```bash
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
source install/setup.bash
```
源码修改后重新构建:
```bash
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
colcon build --symlink-install --packages-select \
linker_hand_ros2_sdk gui_control linkerhand_retarget
source install/setup.bash
```
如果出现 `Package 'linkerhand_retarget' not found`,通常是当前终端没有执行上述两个
`source`,或者源码修改后还没有构建。
## 6. 完整标定FFG左手套
### 6.1 启动FFG原始数据发布
终端A启动只读FFG节点。只连接左手套即可,不要求右手套存在:
```bash
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
source install/setup.bash
ros2 run linkerhand_retarget ffg_dual_retarget --ros-args \
-p serial_port:=/dev/ttyUSB0 \
-p baudrate:=2000000 \
-p auto_scan:=true
```
确认原始话题有数据:
```bash
ros2 topic hz /ffg/left/raw_joint_state
```
如果标定提示“0个有效帧”,不要继续重复按Enter。先确认:
- 终端A仍在运行;
- 日志显示左手FFG已连接;
- `/dev/ttyUSB0`没有被另一个FFG进程占用;
- `/ffg/left/raw_joint_state`有稳定数据。
### 6.2 执行完整手套标定
终端B执行:
```bash
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
source install/setup.bash
ros2 run linkerhand_retarget ffg_calibrate -- \
--glove-id FFG_LEFT_SN \
--operator lxp \
--firmware 2.1.4 \
--output /home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp.json
```
静态姿势的正确操作:
1. 先摆好终端提示的固定姿势;
2. 姿势稳定后按Enter;
3. 按Enter后继续保持不动约2秒;
4. 终端显示保存帧数后再放松;
5. 同一姿势按相同方法重复3次。
动态往返轨迹的正确操作:
1. 先回到该动作的自然起始位置;
2. 按Enter后立即开始连续、缓慢地往返运动;
3. 在默认3秒采集窗口内完成若干次完整往返;
4. 非目标手指尽量保持稳定;
5. 不要先弯好后全程静止,否则采不到动态关系。
标定成功应显示:
```text
approved_for_runtime=True
sha256=<手套profile哈希>
```
不要使用 `--skip-dynamic` 生成正式运行profile。该参数只适合调试。
## 7. 快速佩戴检查
每次正式实机启动前,对当前准备使用的手套profile执行快速检查:
```bash
ros2 run linkerhand_retarget ffg_calibrate -- \
--quick-check /home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp.json \
--quick-output /home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp.wear_check.json
```
依次检查张手、握拳和食指捏合。每个姿势也是“先摆好,再按Enter,然后保持2秒”。
快速检查凭据:
- 默认12小时有效;
- 必须显示 `passed=true`;
- 必须与启动时使用的手套profile SHA-256完全一致;
- 切换v4、v5等手套profile时,必须同时切换到对应的wear-check文件。
快速检查失败时,先重新调整手套佩戴位置并重试。如果多次失败,说明当前佩戴与完整
标定差异过大,应重新完整标定,不要通过增大阈值静默放行实机。
## 8. 标定G20实机姿势
### 8.1 安全要求
- 标定时使用低速、低力矩;
- 配备软件急停,并保证机械手周围无障碍物;
- 不得使用旧手套映射把机械手带到标定姿势;
- 不得在带电状态强行手掰;
- 使用GUI逐通道调整,并确认通道方向正确。
### 8.2 启动G20 SDK
终端A:
```bash
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
source install/setup.bash
ros2 run linker_hand_ros2_sdk linker_hand_sdk --ros-args \
-p hand_type:=left \
-p hand_joint:=G20 \
-p is_touch:=false \
-p can:=can0 \
-p modbus:=None \
-p topic_prefix:=/g20 \
-p startup_speed:=30 \
-p startup_torque:=80 \
-p move_on_startup:=false \
-p state_poll_rate:=10.0
```
### 8.3 启动G20标定GUI
终端B:
```bash
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
source install/setup.bash
ros2 run gui_control gui_control --ros-args \
-r __node:=g20_calibration_gui \
-p hand_type:=left \
-p hand_joint:=G20 \
-p topic_prefix:=/g20
```
### 8.4 捕获11个G20姿势
终端C:
```bash
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
source install/setup.bash
ros2 run linkerhand_retarget hand_pose_capture -- \
--model G20 \
--seed /home/lxp/projects/linkerhand_retarget_ros2/install/linkerhand_retarget/share/linkerhand_retarget/linkerforce_v2/profiles/g20_seed_profile.json \
--serial-number G20_LEFT_001 \
--operator lxp \
--firmware unknown \
--can-interface can0 \
--output /home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json
```
每个姿势的操作:
1. 用GUI低速调整机械手;
2. 目视确认目标手指、通道方向和最终姿势;
3. 等待实机稳定;
4. 点击GUI“保存当前标定姿势”;
5. CLI收到快照后选择姿势状态。
状态含义:
- `exact`:机械手能够准确实现该姿势;
- `approximate`:受机构自由度限制,只能实现最佳近似;
- `unsupported`:该型号不能可靠实现,不用于对应姿势约束;
- 输入 `r`:放弃刚才的快照,重新调整和保存。
CLI每完成一个姿势都会立即写入 `*.checkpoint.json`。程序中断后,使用完全相同的
命令会自动恢复并跳过已保存姿势,不需要从头开始。
只有确实要放弃原进度时才增加:
```text
--fresh
```
全部姿势完成后,只有输入 `y` 批准,最终profile才会包含:
```text
approved_for_control=True
```
O6操作相同,但使用 `--model O6`、O6 seed、`can1`和 `/o6` 命名空间。O6自由度较少,
桌面、钩拳及部分捏合通常应标为 `approximate`。
## 9. 复核机械手profile
姿势复核可以避免手工把JSON中的20维命令复制到GUI。
复核前:
- 停止 `ffg_dual_retarget`;
- 停止GUI,避免命令话题存在其他发布者;
- 只保留低速、低力矩的G20 SDK。
执行:
```bash
ros2 run linkerhand_retarget hand_pose_verify -- \
--profile /home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json \
--operator lxp \
--topic-prefix /g20
```
只复核单个姿势:
```bash
ros2 run linkerhand_retarget hand_pose_verify -- \
--profile /home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json \
--operator lxp \
--topic-prefix /g20 \
--pose pinch_index
```
按照提示输入 `VERIFY`、`MOVE`,再选择:
- `p`:目视通过;
- `f`:目视未通过;
- `r`:重放;
- `s`:跳过。
工具会低速平滑过渡,并保存独立的 `*.verification.json`,不会修改原始机械手profile。
## 10. 离线检查映射质量
不连接实机即可回放profile中的静态与动态数据:
```bash
ros2 run linkerhand_retarget retarget_profile_check -- \
--glove /home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp.json \
--robot /home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json \
--model G20 \
--output /home/lxp/projects/linkerhand_retarget_ros2/profiles/g20_mapping_quality.json
```
重点查看:
- `passed`和`hard_failures`;
- 静态有效通道复现误差;
- 四种捏合的winner和gate;
- 四指动态轨迹的非目标通道跨度;
- 小指、侧摆等动作是否有明显串扰;
- 帧间命令变化是否存在异常突跳。
离线检查通过不等于实机角度准确,只表示profile内部逻辑一致。
## 11. 启动G20正式遥操
正式启动前,停止旧SDK、标定GUI、姿势捕获工具和占用FFG串口的只读节点。每种节点
只保留一个实例。
### 11.1 终端A:启动G20驱动
```bash
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
source install/setup.bash
ros2 run linker_hand_ros2_sdk linker_hand_sdk --ros-args \
-p hand_type:=left \
-p hand_joint:=G20 \
-p is_touch:=false \
-p can:=can0 \
-p modbus:=None \
-p topic_prefix:=/g20 \
-p startup_speed:=255 \
-p startup_torque:=80 \
-p move_on_startup:=false \
-p state_poll_rate:=10.0 \
-p velocity_poll_rate:=10.0 \
-p defer_state_reads_while_commanding:=true \
-p repeat_position_commands:=true
```
`startup_speed`控制电机最大运动速度;`startup_torque`控制最大输出力矩。提高力矩不会
解决映射卡顿。建议先使用80,在确有负载需要并完成安全评估后再提高。
确认状态话题已有发布者:
```bash
ros2 topic info /g20/cb_left_hand_state
```
应至少显示:
```text
Publisher count: 1
```
### 11.2 终端B:启动FFG映射节点
以下示例使用当前v4手套profile:
```bash
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
source install/setup.bash
ros2 run linkerhand_retarget ffg_dual_retarget --ros-args \
-p serial_port:=/dev/ttyUSB0 \
-p baudrate:=2000000 \
-p auto_scan:=true \
-p publish_rate:=30.0 \
-p input_filter_enabled:=false \
-p command_filter_mode:=passthrough \
-p command_filter_ema_alpha:=1.0 \
-p command_filter_max_step_u8:=255.0 \
-p command_filter_deadband_u8:=0.0 \
-p glove_profile:=/home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp_v4.json \
-p wear_check:=/home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp_v4.wear_check.json \
-p g20_profile:=/home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json \
-p g20_serial_number:=G20_LEFT_001 \
-p g20_can_interface:=can0
```
正常日志应包含:
```text
FFG profile已加载
三姿势快速佩戴检查有效
G20 profile已加载(可申请实机使能)
G20映射=factorized_paired_v2
执行滤波=passthrough alpha=1.0, max_step=255.0
左手FFG已连接
```
节点启动后默认处于PREVIEW,不会立即控制实机。
### 11.3 PREVIEW检查
在使能前观察:
```text
/ffg/left/raw_joint_state
/ffg/left/filtered_joint_state
/retarget/left/hand_intent
/retarget/g20/left/actuation_target
/retarget/g20/left/joint_target_nominal
/retarget/g20/left/cmd_u8_preview
```
检查要求:
- 张手、半握、握拳过程中命令连续;
- 弯曲一根手指时,主要变化的是对应手指通道;
- 食指捏合主要影响拇指与食指;
- 不应锁定在某个历史捏合模板;
- 不应出现明显越限或突跳。
### 11.4 使能G20
终端C:
```bash
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
source install/setup.bash
ros2 service call /ffg_dual_retarget/enable_g20 \
std_srvs/srv/SetBool "{data: true}"
```
成功返回:
```text
success=True
message='G20已使能'
```
停用G20:
```bash
ros2 service call /ffg_dual_retarget/enable_g20 \
std_srvs/srv/SetBool "{data: false}"
```
软件急停:
```bash
ros2 service call /ffg_dual_retarget/emergency_stop \
std_srvs/srv/Trigger "{}"
```
急停、FFG断开、驱动状态超时或profile错误后,都需要排除问题并重新显式使能。
## 12. ROS话题说明
| 话题 | 说明 |
|---|---|
| `/ffg/left/raw_joint_state` | 21维FFG原始数据 |
| `/ffg/left/filtered_joint_state` | 实际送入语义提取器的数据;默认与raw相同 |
| `/retarget/left/hand_intent` | 模型无关的0~1人手语义 |
| `/retarget/g20/left/actuation_target` | G20归一化目标 |
| `/retarget/g20/left/joint_target_nominal` | G20仿真名义弧度目标 |
| `/retarget/g20/left/cmd_u8_preview` | 未使能时也持续发布的20维预览命令 |
| `/g20/cb_left_hand_control_cmd` | 使能后发送给G20 SDK的20维命令 |
| `/g20/cb_left_hand_state` | G20 SDK状态心跳 |
| `/ffg_dual_retarget/status` | profile、使能、超时、滤波和延迟诊断 |
所有向量都带名称。仿真桥和其他消费者必须按 `JointState.name` 匹配,不得依赖裸下标。
## 13. 安全与自动停用
- 默认PREVIEW,必须通过服务显式使能;
- FFG超过0.35秒没有新帧:撤销全部实机使能;
- 对应驱动状态超过1秒未更新:只撤销该型号;
- profile缺失、未批准、SN/CAN不匹配:拒绝实机使能;
- wear-check缺失、失败、过期或哈希不匹配:拒绝实机使能;
- 所有命令检查长度、名称、有限值和0~255范围;
- G20四个保留通道保持安全固定值;
- 带电机械手不得强行手掰。
## 14. 常见问题排查
### 14.1 `Package 'linkerhand_retarget' not found`
```bash
source /opt/ros/jazzy/setup.bash
source /home/lxp/projects/linkerhand_retarget_ros2/install/setup.bash
```
如果仍然找不到,重新执行第5节的构建命令。
### 14.2 手套标定只有0个有效帧
原因通常是没有单独启动FFG原始数据发布节点。检查:
```bash
ros2 topic info /ffg/left/raw_joint_state
ros2 topic hz /ffg/left/raw_joint_state
```
### 14.3 快速佩戴检查失败
- 确认使用的是正确版本profile;
- 调整手套位置、腕带和手指传感器;
- 每个姿势先摆好再按Enter;
- 按Enter后保持不动2秒;
- 多次失败则重新完整标定。
### 14.4 `三姿势快速佩戴检查缺失、失败或过期`
重新对启动时使用的同一个手套profile执行第7节命令。不能拿v5的wear-check启动v4。
### 14.5 `G20驱动状态无效或已超时`
先执行:
```bash
ros2 topic info /g20/cb_left_hand_state
```
如果 `Publisher count: 0`,说明G20 SDK未启动或没有使用 `/g20` 命名空间。按第11.1节
启动驱动。如果有发布者,再检查:
```bash
ros2 topic echo /g20/cb_left_hand_state --once
```
状态必须是20维、名称与G20 profile一致、数值有限且位于0~255。
### 14.6 服务一直显示 `waiting for service`
检查:
```bash
ros2 node list
ros2 service list | grep ffg_dual_retarget
```
常见原因是 `ffg_dual_retarget`没有启动、当前终端未source,或者服务名称中多写了反斜杠。
### 14.7 服务成功但机械手不动
检查命令话题:
```bash
ros2 topic info /g20/cb_left_hand_control_cmd
ros2 topic hz /g20/cb_left_hand_control_cmd
```
使能后应同时存在发布者和订阅者,并接近30Hz。还要检查SDK终端是否报告CAN错误。
### 14.8 机械手运动卡顿
确认运行参数:
```bash
ros2 param get /ffg_dual_retarget input_filter_enabled
ros2 param get /ffg_dual_retarget command_filter_mode
ros2 param get /linker_hand_sdk repeat_position_commands
```
当前推荐结果:
```text
False
passthrough
True
```
同时检查:
- 只运行一个G20 SDK和一个映射节点;
- 命令话题稳定接近30Hz;
- CAN状态查询在遥操期间已延后;
- 不要用提高力矩解决卡顿;
- 如果只有某一根手指异常,运行离线profile质量检查,重点看该手指动态轨迹。
### 14.9 某根手指张手不到位或发生串指
依次比较:
```text
raw_joint_state
→ hand_intent
→ cmd_u8_preview
→ cb_left_hand_state
```
- raw异常:佩戴或FFG采集问题;
- hand_intent异常:手套标定/语义解耦问题;
- intent正确但preview异常:映射/profile问题;
- preview正确但实机异常:机械手profile、驱动、固件或机构问题。
不要直接通过修改某个写死系数掩盖问题。
### 14.10 实机姿势标定中断
使用完全相同的 `hand_pose_capture` 命令重新启动,会自动读取检查点并跳过已保存姿势。
不要增加 `--fresh`,除非明确要删除当前标定进度并从头开始。
## 15. 当前精度边界与后续优化
当前多手势方案可以继续优化:
- 重采质量较差的小指、侧摆或拇指动态轨迹;
- 改善21维传感器到人体语义的连续解耦;
- 完善拇指对掌和四种捏合的局部连续映射;
- 为每台机械手建立方向相关、非线性的电机命令曲线;
- 记录输入、映射、发布、CAN和状态时间戳,量化延迟与丢帧。
要得到可量化的真实角度精度,仍需增加Marker、编码器或独立角度传感器,建立:
```text
hand_intent
→ 实机真实关节角q_target
→ 单机关节角—cmd_u8标定
```
在此之前,验收结论只能是动作语义、通道、连续性和外观接近程度,不能声明实机与仿真
达到某个真实关节角误差。
## 16. 正式运行前检查清单
- [ ] ROS与工作空间已source;
- [ ] 当前只有一个FFG读取进程;
- [ ] 当前只有一个G20 SDK,使用`can0`和`/g20`;
- [ ] FFG profile显示`approved_for_runtime=True`;
- [ ] wear-check通过、未过期且哈希匹配;
- [ ] G20 profile显示`approved_for_control=True`;
- [ ] profile中的SN和CAN与启动参数一致;
- [ ] `/g20/cb_left_hand_state`有有效发布者;
- [ ] PREVIEW下逐指、握拳和四种捏合动作正确;
- [ ] 默认实时参数为关闭输入滤波、直通命令、30Hz重复目标;
- [ ] 周围安全、急停可用;
- [ ] 最后才调用`enable_g20`。
Submodule src/agillink_omnihand_sdk added at 026740d9fd
+79 -11
View File
@@ -9,6 +9,20 @@ class HandConfig:
joint_names_en: Optional[List[str]] = None
init_pos: List[int] = field(default_factory=list)
preset_actions: Optional[Dict[str, List[int]]] = None
preset_action_overrides: Optional[Dict[str, Dict[str, List[int]]]] = None
# Integer GUI values are divided by this scale before publishing.
# Legacy hands use raw u8 values (scale=1); O12 uses milliradians.
position_scale: int = 1
position_unit: str = "u8"
def get_preset_actions(self, hand_type: str) -> Dict[str, List[int]]:
"""Return preset actions with hand-specific values applied."""
actions = dict(self.preset_actions or {})
if self.preset_action_overrides:
actions.update(
self.preset_action_overrides.get(hand_type.lower(), {})
)
return actions
# ------------------------------------------------------------------
# 常量字典(仅构建一次)
@@ -75,10 +89,10 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
"点赞": [255, 0, 0, 0, 0, 255, 162, 162, 144, 100, 210, 255, 255, 255, 255, 255, 0, 0, 0, 0],
"握拳": [96, 0, 0, 0, 0, 0, 193, 158, 128, 91, 132, 255, 255, 255, 255, 144, 0, 0, 0, 0],
"张开": [255, 255, 255, 255, 255, 255, 193, 148, 105, 42, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"OK": [148, 110, 255, 255, 255, 44, 164, 100, 114, 127, 178, 255, 255, 255, 255, 94, 71, 255, 255, 255],
"拇指对中指": [191, 255, 55, 255, 255, 96, 95, 100, 114, 127, 105, 255, 255, 255, 255, 94, 255, 108, 255, 255],
"拇指对无名指": [191, 255, 255, 72, 255, 115, 95, 100, 114, 127, 60, 255, 255, 255, 255, 94, 255, 255, 97, 255],
"拇指对小指": [191, 255, 255, 255, 55, 0, 95, 100, 114, 121, 70, 255, 255, 255, 255, 94, 255, 255, 255, 100],
"OK": [0, 0, 255, 255, 255, 138, 147, 148, 105, 42, 109, 255, 255, 255, 255, 255, 211, 255, 255, 255],
"拇指对中指": [0, 255, 0, 255, 255, 107, 149, 148, 105, 42, 109, 255, 255, 255, 255, 255, 225, 202, 255, 255],
"拇指对无名指": [0, 255, 255, 0, 255, 88, 171, 148, 105, 42, 59, 255, 255, 255, 255, 255, 255, 255, 206, 254],
"拇指对小指": [0, 255, 255, 255, 0, 32, 170, 148, 105, 42, 109, 255, 255, 255, 255, 255, 255, 255, 255, 203],
"准备1": [255, 0, 0, 0, 0, 255, 162, 162, 144, 100, 210, 255, 255, 255, 255, 255, 0, 0, 0, 0],
"壹": [96, 255, 0, 0, 0, 0, 190, 161, 127, 80, 68, 255, 255, 255, 255, 144, 255, 0, 0, 0],
"贰": [96, 255, 255, 0, 0, 0, 190, 66, 127, 80, 68, 255, 255, 255, 255, 144, 255, 255, 0, 0],
@@ -100,8 +114,6 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
"动作9": [255, 255, 255, 255, 255, 255, 125, 129, 125, 130, 210, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"根部1": [0, 0, 0, 0, 0, 255, 125, 129, 125, 130, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"根部2": [255, 255, 255, 255, 255, 255, 125, 129, 125, 130, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"根部1": [0, 0, 0, 0, 0, 255, 125, 129, 125, 130, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"根部2": [255, 255, 255, 255, 255, 255, 125, 129, 125, 130, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"末端1": [6, 0, 0, 0, 0, 255, 125, 129, 125, 130, 219, 255, 255, 255, 255, 125, 0, 0, 0, 0],
"末端2": [6, 0, 0, 0, 0, 255, 125, 129, 125, 130, 219, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"末端3": [6, 0, 0, 0, 0, 255, 125, 129, 125, 130, 219, 255, 255, 255, 255, 125, 0, 0, 0, 0],
@@ -224,13 +236,20 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
"叁": [92, 87, 255, 255, 255, 0],
"肆": [92, 87, 255, 255, 255, 255],
"伍": [255, 255, 255, 255, 255, 255],
"OK": [139, 91, 103, 250, 250, 250],
"OK": [95, 75, 116, 255, 255, 255],
"拇指对中指": [88, 2, 255, 114, 255, 255],
"点赞": [250, 79, 0, 0, 0, 0],
"握拳": [102, 18, 0, 0, 0, 0],
}
},
preset_action_overrides={
"right": {
"OK": [95, 83, 122, 255, 255, 255],
"拇指对中指": [95, 8, 255, 114, 255, 255],
},
},
),
"L6": HandConfig(
joint_names_en=["thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch", "middle_mcp_pitch", "pinky_mcp_pitch", "ring_mcp_pitch"],
joint_names_en=["thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"],
joint_names=["大拇指弯曲", "大拇指横摆", "食指弯曲", "中指弯曲", "无名指弯曲", "小拇指弯曲"],
init_pos=[250] * 6,
preset_actions={
@@ -240,7 +259,7 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
"叁": [0, 39, 255, 255, 255, 0],
"肆": [0, 0, 255, 255, 255, 255],
"伍": [255, 255, 255, 255, 255, 255],
"OK": [74, 13, 153, 255, 255, 255],
"OK": [62, 5, 151, 255, 255, 255],
"点赞": [255, 255, 0, 0, 0, 0],
"握拳": [79, 11, 0, 0, 0, 0],
"序列动作1": [250, 250, 250, 250, 250, 250],
@@ -256,7 +275,56 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
"拇指压感准备1": [139, 18, 130, 0, 0, 0],
"拇指压感测试": [39, 30, 122, 250, 250, 250],
"拇指压感准备2": [139, 18, 130, 0, 0, 0]
}
},
preset_action_overrides={
"right": {
"OK": [58, 6, 153, 255, 255, 255],
},
},
),
"O12": HandConfig(
# O12 active-angle API order. Keep this independent from the
# physical motor order exposed by some SDK metadata.
joint_names_en=[
"thumb_roll", "thumb_abad", "thumb_mcp", "thumb_pip",
"index_abad", "index_mcp", "index_pip",
"middle_abad", "middle_mcp", "middle_pip",
"ring_mcp", "pinky_mcp",
],
joint_names=[
"拇指旋转", "拇指侧摆", "拇指根部", "拇指中部",
"食指侧摆", "食指根部", "食指中部",
"中指侧摆", "中指根部", "中指中部",
"无名指根部", "小指根部",
],
# Values are milliradians so QSlider can retain 0.001 rad resolution.
init_pos=[0] * 12,
preset_actions={
"展开": [0] * 12,
"食指轻弯": [0, 0, 0, 0, 0, 300, 400, 0, 0, 0, 0, 0],
"半握": [
300, -200, -200, -500,
0, 650, 700, 0, 650, 800, 700, 700,
],
},
preset_action_overrides={
"right": {
"拇指对食指": [
245, -766, -818, 0, 0, 1046, 0, 0, 0, 0, 0, 0,
],
"拇指对中指": [
489, -843, -685, 0, 0, 0, 0, 0, 1147, 0, 0, 0,
],
"拇指对无名指": [
699, -779, -827, 0, 0, 0, 0, 0, 0, 0, 690, 0,
],
"拇指对小指": [
786, -1065, -827, 0, 0, 0, 0, 0, 0, 0, 0, 645,
],
},
},
position_scale=1000,
position_unit="rad",
),
}
HAND_CONFIGS = MappingProxyType(_HAND_CONFIGS)
+272 -32
View File
@@ -1,5 +1,6 @@
import sys
import time, json
import math
import threading
from dataclasses import dataclass
from typing import List, Dict
@@ -19,14 +20,72 @@ from .utils.mapping import *
from .config.constants import _HAND_CONFIGS
LOOP_TIME = 1000 # 循环动作间隔时间 毫秒
_CANONICAL_COMMAND_NAMES = {
"G20": [
"thumb_cmc_pitch", "index_mcp_pitch", "middle_mcp_pitch",
"ring_mcp_pitch", "pinky_mcp_pitch", "thumb_cmc_roll",
"index_mcp_roll", "middle_mcp_roll", "ring_mcp_roll",
"pinky_mcp_roll", "thumb_cmc_yaw", "reserved_11",
"reserved_12", "reserved_13", "reserved_14", "thumb_mcp",
"index_pip", "middle_pip", "ring_pip", "pinky_pip",
],
"O6": [
"thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch",
"middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch",
],
"L6": [
"thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch",
"middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch",
],
"O12": [
"thumb_roll", "thumb_abad", "thumb_mcp", "thumb_pip",
"index_abad", "index_mcp", "index_pip",
"middle_abad", "middle_mcp", "middle_pip",
"ring_mcp", "pinky_mcp",
],
}
_CANONICAL_COMMAND_BOUNDS = {
"G20": [
*[(0, 255)] * 10,
(0, 255),
*[(255, 255)] * 4,
*[(0, 255)] * 5,
],
"O6": [(0, 255)] * 6,
"L6": [(0, 255)] * 6,
}
# Integer slider bounds in milliradians, derived from the O12 active-angle
# limits. Right and left thumb signs differ; all other limits are shared.
_O12_COMMAND_BOUNDS_MRAD = {
"right": [
(0, 942), (-1387, 0), (-827, 0), (-1291, 0),
(-261, 261), (0, 1352), (0, 1530), (-261, 261),
(0, 1357), (0, 1815), (0, 1535), (0, 1535),
],
"left": [
(-942, 0), (0, 1387), (-827, 0), (-1291, 0),
(-261, 261), (0, 1352), (0, 1530), (-261, 261),
(0, 1357), (0, 1815), (0, 1535), (0, 1535),
],
}
class ROS2NodeManager(QObject):
"""ROS2节点管理器,处理ROS通信"""
status_updated = pyqtSignal(str, str) # 状态类型, 消息内容
feedback_updated = pyqtSignal(object)
def __init__(self, node_name: str = "hand_control_node"):
super().__init__()
self.node = None
self.publisher = None
self.publisher_arc = None
self.state_subscription = None
self.speed_pub = None
self.torque_pub = None
self.joint_state = JointState()
self.joint_state.header = Header()
@@ -45,28 +104,56 @@ class ROS2NodeManager(QObject):
self.node.declare_parameter('hand_joint', 'L10')
self.node.declare_parameter('topic_hz', 30)
self.node.declare_parameter('is_arc', False)
self.node.declare_parameter('topic_prefix', '')
# 获取参数
self.hand_type = self.node.get_parameter('hand_type').value
self.hand_joint = self.node.get_parameter('hand_joint').value
self.hz = self.node.get_parameter('topic_hz').value
self.is_arc = self.node.get_parameter('is_arc').value
self.topic_prefix = self.normalize_topic_prefix(
self.node.get_parameter('topic_prefix').value
)
if self.is_arc == True:
if self.hand_joint == "O12":
topic_base = f'/o12/{self.hand_type}'
self.publisher = self.node.create_publisher(
JointState, f'{topic_base}/joint_cmd', 10
)
self.state_subscription = self.node.create_subscription(
JointState,
f'{topic_base}/joint_states',
self._o12_feedback_callback,
10,
)
elif self.is_arc == True:
# 创建发布者
self.publisher_arc = self.node.create_publisher(
JointState, f'/cb_{self.hand_type}_hand_control_cmd_arc', 10
JointState,
self.topic(f'/cb_{self.hand_type}_hand_control_cmd_arc'),
10
)
# 创建发布者
self.publisher = self.node.create_publisher(
JointState, f'/cb_{self.hand_type}_hand_control_cmd', 10
# 创建旧型号发布者
if self.hand_joint != "O12":
self.publisher = self.node.create_publisher(
JointState,
self.topic(f'/cb_{self.hand_type}_hand_control_cmd'),
10
)
self.snapshot_publisher = self.node.create_publisher(
JointState, self.topic('/calibration_pose_snapshot'), 10
)
# 新增 speed / torque 发布者
self.speed_pub = self.node.create_publisher(
String, f'/cb_hand_setting_cmd', 10)
self.torque_pub = self.node.create_publisher(
String, f'/cb_hand_setting_cmd', 10)
self.status_updated.emit("info", f"ROS2节点初始化成功: {self.hand_type} {self.hand_joint}")
if self.hand_joint != "O12":
self.speed_pub = self.node.create_publisher(
String, self.topic('/cb_hand_setting_cmd'), 10)
self.torque_pub = self.node.create_publisher(
String, self.topic('/cb_hand_setting_cmd'), 10)
self.status_updated.emit(
"info",
f"ROS2节点初始化成功: {self.topic_prefix or '/'} "
f"{self.hand_type} {self.hand_joint}"
)
# 启动ROS2自旋线程
self.spin_thread = threading.Thread(target=self.spin_node, daemon=True)
@@ -75,11 +162,55 @@ class ROS2NodeManager(QObject):
self.status_updated.emit("error", f"ROS2初始化失败: {str(e)}")
raise
@staticmethod
def normalize_topic_prefix(prefix: str) -> str:
prefix = str(prefix).strip()
if not prefix or prefix == '/':
return ''
if not prefix.startswith('/'):
prefix = '/' + prefix
return prefix.rstrip('/')
def topic(self, absolute_topic: str) -> str:
if not absolute_topic.startswith('/'):
raise ValueError('base topic must be absolute')
return self.topic_prefix + absolute_topic
def spin_node(self):
"""运行ROS2节点自旋循环"""
while rclpy.ok() and self.node:
rclpy.spin_once(self.node, timeout_sec=0.1)
def _o12_feedback_callback(self, msg: JointState):
"""Forward 12-channel O12 radian feedback safely into the Qt thread."""
if len(msg.position) != 12:
self.status_updated.emit(
"error", f"O12位置反馈长度错误: {len(msg.position)},应为12"
)
return
self.feedback_updated.emit([float(value) for value in msg.position])
def command_bounds(self, count: int):
if self.hand_joint == "O12":
bounds = _O12_COMMAND_BOUNDS_MRAD.get(self.hand_type)
else:
bounds = _CANONICAL_COMMAND_BOUNDS.get(self.hand_joint)
if not bounds or len(bounds) != count:
return [(0, 255)] * count
return bounds
def bound_positions(self, positions: List[int]) -> List[int]:
bounds = self.command_bounds(len(positions))
return [
max(minimum, min(maximum, int(value)))
for value, (minimum, maximum) in zip(positions, bounds)
]
def wire_positions(self, positions: List[int]) -> List[float]:
positions = self.bound_positions(positions)
scale = _HAND_CONFIGS[self.hand_joint].position_scale
return [float(value) / float(scale) for value in positions]
def publish_joint_state(self, positions: List[int]):
"""发布关节状态消息"""
if not self.publisher or not self.node:
@@ -87,21 +218,31 @@ class ROS2NodeManager(QObject):
return
try:
positions = self.bound_positions(positions)
wire_positions = self.wire_positions(positions)
self.joint_state.header.stamp = self.node.get_clock().now().to_msg()
self.joint_state.position = [float(pos) for pos in positions]
self.joint_state.position = wire_positions
# self.joint_state.velocity = [0.1] * len(positions)
# self.joint_state.effort = [0.01] * len(positions)
# 如果有关节名称,添加到消息中
#hand_config = HandConfig.from_hand_type(self.hand_joint)
hand_config = _HAND_CONFIGS[self.hand_joint]
if len(hand_config.joint_names) == len(positions):
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
# O12 SDK 1.1.8 may expose names in physical motor order while the
# position array uses active-angle order. An empty name array makes
# the documented position order unambiguous to the driver.
if self.hand_joint == "O12":
self.joint_state.name = []
elif canonical_names and len(canonical_names) == len(positions):
self.joint_state.name = canonical_names
elif len(hand_config.joint_names) == len(positions):
if hand_config.joint_names_en != None:
self.joint_state.name = hand_config.joint_names_en
else:
self.joint_state.name = hand_config.joint_names
self.publisher.publish(self.joint_state)
if self.is_arc == True:
if self.is_arc == True and self.hand_joint != "O12":
if self.hand_joint == "O6":
if self.hand_type == "left":
pose = range_to_arc_left(positions,self.hand_joint)
@@ -131,9 +272,27 @@ class ROS2NodeManager(QObject):
except Exception as e:
self.status_updated.emit("error", f"发布失败: {str(e)}")
def publish_pose_snapshot(self, positions: List[int]):
"""发布带名称的当前标定姿势快照。"""
positions = self.bound_positions(positions)
self.publish_joint_state(positions)
self.joint_state.header.stamp = self.node.get_clock().now().to_msg()
self.joint_state.position = self.wire_positions(positions)
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
if canonical_names and self.hand_joint != "O12":
self.joint_state.name = canonical_names
elif self.hand_joint == "O12":
self.joint_state.name = []
self.snapshot_publisher.publish(self.joint_state)
self.status_updated.emit("info", "当前标定姿势快照已发布")
def publish_speed(self, val: int):
joint_len = 0
if (self.hand_joint.upper() == "O6" or self.hand_joint.upper() == "L6"):
if self.hand_joint == "O12":
self.status_updated.emit(
"warning", "O12不使用旧版u8速度接口;请通过角度轨迹限制速度"
)
return
if self.hand_joint.upper() in ("O6", "L6"):
joint_len = 6
elif self.hand_joint == "L7":
joint_len = 7
@@ -152,8 +311,12 @@ class ROS2NodeManager(QObject):
self.speed_pub.publish(msg)
def publish_torque(self, val: int):
joint_len = 0
if (self.hand_joint.upper() == "O6" or self.hand_joint.upper() == "L6"):
if self.hand_joint == "O12":
self.status_updated.emit(
"warning", "O12不使用旧版u8扭矩接口;位置GUI已禁用该设置"
)
return
if self.hand_joint.upper() in ("O6", "L6"):
joint_len = 6
elif self.hand_joint == "L7":
joint_len = 7
@@ -194,11 +357,17 @@ class HandControlGUI(QWidget):
# 设置ROS管理器
self.ros_manager = ros_manager
self.ros_manager.status_updated.connect(self.update_status)
self.ros_manager.feedback_updated.connect(self.on_feedback_updated)
# 获取手部配置
self.hand_joint = self.ros_manager.hand_joint
self.hand_type = self.ros_manager.hand_type
self.hand_config = _HAND_CONFIGS[self.hand_joint]
self.preset_actions = self.hand_config.get_preset_actions(self.hand_type)
self.feedback_positions = None
# O12 only publishes after an explicit slider/preset action. This
# prevents opening or closing the real hand merely by launching GUI.
self.command_dirty = self.hand_joint != "O12"
# 初始化UI
self.init_ui()
@@ -209,10 +378,27 @@ class HandControlGUI(QWidget):
self.publish_timer.timeout.connect(self.publish_joint_state)
self.publish_timer.start()
def format_position(self, value: int) -> str:
"""Format an integer GUI value in the hand's actual command unit."""
scale = self.hand_config.position_scale
if self.hand_config.position_unit == "rad":
radians = float(value) / float(scale)
return f"{radians:.3f} rad / {math.degrees(radians):.1f}°"
return str(value)
def command_positions(self) -> List[float]:
"""Return current slider values converted to wire units."""
return self.ros_manager.wire_positions(
[slider.value() for slider in self.sliders]
)
def init_ui(self):
"""初始化用户界面"""
# 设置窗口属性
self.setWindowTitle(f'灵巧手控制界面 - {self.hand_type} {self.hand_joint}')
self.setWindowTitle(
f'灵巧手控制界面 - {self.ros_manager.topic_prefix or "/"} '
f'{self.hand_type} {self.hand_joint}'
)
self.setMinimumSize(1200, 900)
# 设置样式
@@ -383,13 +569,16 @@ class HandControlGUI(QWidget):
for i, (name, value) in enumerate(zip(
self.hand_config.joint_names, self.hand_config.init_pos
)):
bounds = self.ros_manager.command_bounds(len(self.hand_config.init_pos))
minimum, maximum = bounds[i]
value = max(minimum, min(maximum, int(value)))
# 创建标签
label = QLabel(f"{name}: {value}")
label = QLabel(f"{name}: {self.format_position(value)}")
label.setMinimumWidth(120)
# 创建滑动条
slider = QSlider(Qt.Horizontal)
slider.setRange(0, 255)
slider.setRange(minimum, maximum)
slider.setValue(value)
slider.valueChanged.connect(
lambda val, idx=i: self.on_slider_value_changed(idx, val)
@@ -436,6 +625,11 @@ class HandControlGUI(QWidget):
self.stop_button.clicked.connect(self.on_stop_clicked)
actions_layout.addWidget(self.stop_button)
self.save_pose_button = QPushButton("保存当前标定姿势")
self.save_pose_button.setProperty("category", "action")
self.save_pose_button.clicked.connect(self.on_save_pose_clicked)
actions_layout.addWidget(self.save_pose_button)
layout.addLayout(actions_layout)
return panel
@@ -443,9 +637,9 @@ class HandControlGUI(QWidget):
def create_system_preset_buttons(self, parent_layout):
"""创建系统预设动作按钮"""
self.preset_buttons = [] # 清空按钮列表
if self.hand_config.preset_actions:
if self.preset_actions:
buttons = []
for idx, (name, positions) in enumerate(self.hand_config.preset_actions.items()):
for idx, (name, positions) in enumerate(self.preset_actions.items()):
button = QPushButton(name)
button.setProperty("category", "preset")
button.clicked.connect(
@@ -472,6 +666,9 @@ class HandControlGUI(QWidget):
# —— 2. 新增:速度与扭矩设置(每行一个)——
quick_set_gb = QGroupBox("快速设置")
if self.hand_joint == "O12":
quick_set_gb.setEnabled(False)
quick_set_gb.setToolTip("O12不使用旧版0-255速度/扭矩设置接口")
qv_layout = QVBoxLayout(quick_set_gb)
# 速度行
@@ -600,7 +797,10 @@ class HandControlGUI(QWidget):
"""滑动条值改变事件处理"""
if 0 <= index < len(self.slider_labels):
joint_name = self.hand_config.joint_names[index]
self.slider_labels[index].setText(f"{joint_name}: {value}")
self.slider_labels[index].setText(
f"{joint_name}: {self.format_position(value)}"
)
self.command_dirty = True
# 更新数值显示
self.update_value_display()
@@ -608,10 +808,23 @@ class HandControlGUI(QWidget):
def update_value_display(self):
"""更新数值显示面板内容"""
# 获取所有滑动条的当前值
values = [slider.value() for slider in self.sliders]
command = self.command_positions()
command_text = ", ".join(f"{value:.3f}" for value in command)
lines = [f"目标({self.hand_config.position_unit}): [{command_text}]"]
if self.feedback_positions is not None:
feedback_text = ", ".join(
f"{value:.3f}" for value in self.feedback_positions
)
lines.append(f"反馈(rad): [{feedback_text}]")
self.value_display.setText("\n".join(lines))
# 格式化显示为列表形式
self.value_display.setText(f"{values}")
def on_feedback_updated(self, positions):
"""Display actual O12 joint feedback without moving command sliders."""
self.feedback_positions = list(positions)
self.update_value_display()
if hasattr(self, "connection_status"):
self.connection_status.setText("O12右手已连接并收到位置反馈")
self.connection_status.setObjectName("StatusInfo")
def on_preset_action_clicked(self, positions: List[int]):
"""预设动作按钮点击事件处理"""
@@ -650,11 +863,34 @@ class HandControlGUI(QWidget):
self.cycle_button.setText("循环运行预设动作")
self.reset_preset_buttons_color()
self.status_updated.emit("warning", "已停止所有动作")
if self.hand_joint == "O12" and self.feedback_positions is not None:
# Hold the latest measured pose instead of merely stopping topic
# publication while a previous position target is still active.
scale = self.hand_config.position_scale
hold_values = [
int(round(value * scale)) for value in self.feedback_positions
]
hold_values = self.ros_manager.bound_positions(hold_values)
for slider, value in zip(self.sliders, hold_values):
slider.blockSignals(True)
slider.setValue(value)
slider.blockSignals(False)
self.command_dirty = True
self.publish_joint_state()
self.update_value_display()
self.status_updated.emit("warning", "已下发当前反馈位置并保持")
else:
self.command_dirty = False
self.status_updated.emit("warning", "已停止连续动作和新的位置发布")
def on_save_pose_clicked(self):
"""发布当前滑块姿势,供hand_pose_capture写入profile。"""
positions = [slider.value() for slider in self.sliders]
self.ros_manager.publish_pose_snapshot(positions)
def on_cycle_clicked(self):
"""循环运行预设动作按钮点击事件处理"""
if not self.hand_config.preset_actions:
if not self.preset_actions:
QMessageBox.warning(self, "无预设动作", "当前手部型号没有预设动作可循环运行")
return
@@ -677,19 +913,19 @@ class HandControlGUI(QWidget):
def run_next_action(self):
"""运行下一个预设动作"""
if not self.hand_config.preset_actions:
if not self.preset_actions:
return
# 重置所有按钮颜色
self.reset_preset_buttons_color()
# 计算下一个动作索引
self.current_action_index = (self.current_action_index + 1) % len(self.hand_config.preset_actions)
self.current_action_index = (self.current_action_index + 1) % len(self.preset_actions)
# 获取下一个动作
action_names = list(self.hand_config.preset_actions.keys())
action_names = list(self.preset_actions.keys())
action_name = action_names[self.current_action_index]
action_positions = self.hand_config.preset_actions[action_name]
action_positions = self.preset_actions[action_name]
# 执行动作
self.on_preset_action_clicked(action_positions)
@@ -714,6 +950,7 @@ class HandControlGUI(QWidget):
"""关节类型改变事件处理"""
self.hand_joint = joint_type
self.hand_config = _HAND_CONFIGS[self.hand_joint]
self.preset_actions = self.hand_config.get_preset_actions(self.hand_type)
# 更新手部信息
info_text = f"""手部类型: {self.hand_type}
@@ -732,8 +969,11 @@ class HandControlGUI(QWidget):
def publish_joint_state(self):
"""发布当前关节状态"""
if self.hand_joint == "O12" and not self.command_dirty:
return
positions = [slider.value() for slider in self.sliders]
self.ros_manager.publish_joint_state(positions)
self.command_dirty = False
def update_status(self, status_type: str, message: str):
"""更新状态显示"""
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Start the HCAN-connected O12 right hand and its safe position GUI."""
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription, TimerAction
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import PathJoinSubstitution
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare
def generate_launch_description():
o12_driver = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
PathJoinSubstitution([
FindPackageShare("omnihand_node"),
"launch",
"omnihand_pro_2025_node.launch.py",
])
)
)
o12_gui = Node(
package="gui_control",
executable="gui_control",
name="o12_right_gui_control",
output="screen",
parameters=[{
"hand_type": "right",
"hand_joint": "O12",
# Commands are coalesced, so dragging a slider sends at most 10 Hz.
"topic_hz": 10,
"is_arc": False,
"topic_prefix": "",
}],
)
return LaunchDescription([
o12_driver,
# Give the CANFD driver a moment to claim and initialise the adapter.
TimerAction(period=1.5, actions=[o12_gui]),
])
+1
View File
@@ -13,6 +13,7 @@
<test_depend>python3-pytest</test_depend>
<exec_depend>linker_hand_ros2_sdk</exec_depend>
<exec_depend>omnihand_node</exec_depend>
<export>
<build_type>ament_python</build_type>
+23
View File
@@ -0,0 +1,23 @@
from gui_control.config.constants import HAND_CONFIGS
def test_l6_gui_uses_the_sdk_channel_order() -> None:
assert HAND_CONFIGS["L6"].joint_names_en == [
"thumb_cmc_pitch",
"thumb_cmc_roll",
"index_mcp_pitch",
"middle_mcp_pitch",
"ring_mcp_pitch",
"pinky_mcp_pitch",
]
def test_l6_ok_preset_is_hand_specific() -> None:
config = HAND_CONFIGS["L6"]
assert config.get_preset_actions("left")["OK"] == [
62, 5, 151, 255, 255, 255,
]
assert config.get_preset_actions("right")["OK"] == [
58, 6, 153, 255, 255, 255,
]
+46
View File
@@ -0,0 +1,46 @@
from gui_control.config.constants import HAND_CONFIGS
def test_o12_gui_uses_active_angle_order_and_milliradians() -> None:
config = HAND_CONFIGS["O12"]
assert config.joint_names_en == [
"thumb_roll", "thumb_abad", "thumb_mcp", "thumb_pip",
"index_abad", "index_mcp", "index_pip",
"middle_abad", "middle_mcp", "middle_pip",
"ring_mcp", "pinky_mcp",
]
assert config.position_scale == 1000
assert config.position_unit == "rad"
assert len(config.init_pos) == 12
assert all(len(pose) == 12 for pose in config.preset_actions.values())
def test_o12_right_fingertip_preset_actions_use_requested_radians() -> None:
config = HAND_CONFIGS["O12"]
actions = config.get_preset_actions("right")
assert actions["拇指对食指"] == [
245, -766, -818, 0, 0, 1046, 0, 0, 0, 0, 0, 0,
]
assert actions["拇指对中指"] == [
489, -843, -685, 0, 0, 0, 0, 0, 1147, 0, 0, 0,
]
assert actions["拇指对无名指"] == [
699, -779, -827, 0, 0, 0, 0, 0, 0, 0, 690, 0,
]
assert actions["拇指对小指"] == [
786, -1065, -827, 0, 0, 0, 0, 0, 0, 0, 0, 645,
]
assert all(len(actions[name]) == 12 for name in (
"拇指对食指", "拇指对中指", "拇指对无名指", "拇指对小指",
))
def test_o12_fingertip_preset_actions_are_right_hand_only() -> None:
actions = HAND_CONFIGS["O12"].get_preset_actions("left")
assert "拇指对食指" not in actions
assert "拇指对中指" not in actions
assert "拇指对无名指" not in actions
assert "拇指对小指" not in actions
+18
View File
@@ -0,0 +1,18 @@
from gui_control.config.constants import HAND_CONFIGS
def test_o6_ok_preset_is_hand_specific() -> None:
config = HAND_CONFIGS["O6"]
assert config.get_preset_actions("left")["OK"] == [
95, 75, 116, 255, 255, 255,
]
assert config.get_preset_actions("right")["OK"] == [
95, 83, 122, 255, 255, 255,
]
assert config.get_preset_actions("left")["拇指对中指"] == [
88, 2, 255, 114, 255, 255,
]
assert config.get_preset_actions("right")["拇指对中指"] == [
95, 8, 255, 114, 255, 255,
]
-31
View File
@@ -1,31 +0,0 @@
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
return LaunchDescription([
Node(
package='linker_hand_ros2_sdk',
executable='linker_hand_sdk',
name='linker_hand_sdk_left',
output='screen',
parameters=[{
'hand_type': 'left',
'hand_joint': "L10", # 这里需要修改为实际Linker Hand的型号 L7、L10、L20、L21、L25
'is_touch': True, # 是否带有压力传感器
'can': 'can0', # 这里需要修改为实际的CAN总线名称
}],
),
Node(
package='linker_hand_ros2_sdk',
executable='linker_hand_sdk',
name='linker_hand_sdk_right',
output='screen',
parameters=[{
'hand_type': 'right',
'hand_joint': "L10", # 这里需要修改为实际Linker Hand的型号 L7、L10、L20、L21、L25
'is_touch': True, # 是否带有压力传感器
'can': 'can0', # 这里需要修改为实际的CAN总线名称
}],
),
])
@@ -1030,6 +1030,16 @@ class LinkerHandG20Can:
cmd_state = self.joint_state_to_cmd_state(state=s)
return cmd_state
def get_cached_current_status(self):
"""Return the latest received five-finger state without CAN queries."""
state = [self.x41, self.x42, self.x43, self.x44, self.x45]
if not all(
isinstance(finger, (list, tuple)) and len(finger) == 6
for finger in state
):
return None
return self.joint_state_to_cmd_state(state=state)
def get_current_pub_status(self):
"""API接口:获取手指当前状态"""
self.get_current_status()
@@ -1266,4 +1276,25 @@ class LinkerHandG20Can:
except:
return "-1"
def get_finger_order(self):
return ["Thumb Base", "Index Finger Base", "Middle Finger Base", "Ring Finger Base", "Pinky Finger Base", "Thumb Abduction", "Index Finger Abduction", "Middle Finger Abduction", "Ring Finger Abduction", "Pinky Finger Abduction", "Thumb Horizontal Abduction", "Reserved", "Reserved", "Reserved", "Reserved", "Thumb Tip", "Index Finger Tip", "Middle Finger Tip", "Ring Finger Tip", "Pinky Finger Tip"]
return [
"thumb_cmc_pitch",
"index_mcp_pitch",
"middle_mcp_pitch",
"ring_mcp_pitch",
"pinky_mcp_pitch",
"thumb_cmc_roll",
"index_mcp_roll",
"middle_mcp_roll",
"ring_mcp_roll",
"pinky_mcp_roll",
"thumb_cmc_yaw",
"reserved_11",
"reserved_12",
"reserved_13",
"reserved_14",
"thumb_mcp",
"index_pip",
"middle_pip",
"ring_pip",
"pinky_pip",
]
@@ -1,3 +1,5 @@
from collections import deque
import can
import time, sys
import threading
@@ -57,6 +59,14 @@ class LinkerHandL6Can:
self.normal_force, self.tangential_force, self.tangential_force_dir, self.approach_inc = [[-1] * 6 for _ in range(4)]
self.is_lock = False
self.version = None
# L6 replies to a six-byte 0x01 position command with an immediate
# byte-for-byte echo on the same CAN ID. A zero-payload 0x01 state
# query also replies on that ID, but with the measured positions.
# Keep the two transactions distinct so command echoes never enter
# the published feedback stream used by calibration.
self._position_echo_lock = threading.Lock()
self._pending_position_echoes = deque(maxlen=32)
self._position_echo_timeout_seconds = 0.02
# Start the receiving thread
self.running = True
self.receive_thread = threading.Thread(target=self.receive_response)
@@ -111,6 +121,11 @@ class LinkerHandL6Can:
frame_property_value = int(frame_property.value) if hasattr(frame_property, 'value') else frame_property
data = [frame_property_value] + [int(val) for val in data_list]
msg = can.Message(arbitration_id=self.can_id, data=data, is_extended_id=False)
if frame_property_value == 0x01 and len(data_list) == 6:
with self._position_echo_lock:
self._pending_position_echoes.append(
(time.monotonic(), tuple(int(value) for value in data_list))
)
try:
self.bus.send(msg)
except can.CanError as e:
@@ -201,7 +216,23 @@ class LinkerHandL6Can:
except:
return
if frame_type == 0x01: # 0x01
self.x01 = list(response_data)
response = tuple(int(value) for value in response_data)
now = time.monotonic()
is_position_echo = False
with self._position_echo_lock:
while (
self._pending_position_echoes
and now - self._pending_position_echoes[0][0]
> self._position_echo_timeout_seconds
):
self._pending_position_echoes.popleft()
for pending in tuple(self._pending_position_echoes):
if pending[1] == response:
self._pending_position_echoes.remove(pending)
is_position_echo = True
break
if not is_position_echo:
self.x01 = list(response)
elif frame_type == 0x02: # 0x02
self.x02 = list(response_data)
elif frame_type == 0x05: # Set speed
@@ -391,7 +422,10 @@ class LinkerHandL6Can:
return self.x35
def get_finger_order(self):
return ["thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
# L6 channel 1 is the physical CMC roll actuator. Older SDK releases
# exposed the channel as ``thumb_cmc_yaw`` even though the wire order
# and mechanism have always been roll.
return ["thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
def show_fun_table(self):
pass
@@ -372,7 +372,7 @@ class LinkerHandL6RS485:
return [0] * 6
def get_finger_order(self):
return ["thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
return ["thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
# --------------------------------------------------
# 便捷方法
@@ -1,444 +0,0 @@
#!/usr/bin/env python3
import os
import time
from pymodbus.client import ModbusSerialClient
from typing import List, Dict
import numpy as np
_INTERVAL = 0.006 # 8 ms
class LinkerHandL6RS485:
"""L6机械手 Modbus-RTU 控制类"""
# 6个关节名称
JOINT_NAMES = ["thumb_pitch", "thumb_yaw", "index_pitch",
"middle_pitch", "ring_pitch", "little_pitch"]
# 手指名称
FINGER_NAMES = ["thumb", "index", "middle", "ring", "little"]
def __init__(self, hand_id=0x27, modbus_port="/dev/ttyUSB0", baudrate=115200):
"""
初始化L6机械手
hand_id: 右手0x27(39), 左手0x28(40)
modbus_port: 串口设备路径
baudrate: 波特率,固定115200
"""
self.slave = hand_id
self.cli = ModbusSerialClient(
port=modbus_port,
baudrate=baudrate,
bytesize=8,
parity="N",
stopbits=1,
timeout=0.05
)
# pymodbus 3.5.1 需要显式连接
self.connected = self.cli.connect()
if not self.connected:
raise ConnectionError(f"RS485连接失败,端口: {modbus_port}")
def _read_input_registers(self, address: int, count: int) -> List[int]:
"""读取输入寄存器"""
time.sleep(_INTERVAL)
result = self.cli.read_input_registers(address=address, count=count, slave=self.slave)
if result.isError():
raise RuntimeError(f"读取输入寄存器失败: address={address}, count={count}")
return result.registers
def _write_register(self, address: int, value: int):
"""写入单个寄存器"""
time.sleep(_INTERVAL)
result = self.cli.write_register(address=address, value=value, slave=self.slave)
if result.isError():
raise RuntimeError(f"写入寄存器失败: address={address}, value={value}")
def _write_registers(self, address: int, values: List[int]):
"""写入多个寄存器"""
time.sleep(_INTERVAL)
result = self.cli.write_registers(address=address, values=values, slave=self.slave)
if result.isError():
raise RuntimeError(f"写入多个寄存器失败: address={address}, values={values}")
# --------------------------------------------------
# 基础读取接口
# --------------------------------------------------
def read_angles(self) -> List[int]:
"""读取6个关节角度 (输入寄存器 0-5)"""
return self._read_input_registers(0, 6)
def read_torques(self) -> List[int]:
"""读取6个关节转矩 (输入寄存器 6-11)"""
return self._read_input_registers(6, 6)
def read_speeds(self) -> List[int]:
"""读取6个关节速度 (输入寄存器 12-17)"""
return self._read_input_registers(12, 6)
def read_temperatures(self) -> List[int]:
"""读取6个关节温度 (输入寄存器 18-23)"""
return self._read_input_registers(18, 6)
def read_error_codes(self) -> List[int]:
"""读取6个关节错误码 (输入寄存器 24-29)"""
return self._read_input_registers(24, 6)
# --------------------------------------------------
# 压力传感器接口
# --------------------------------------------------
# def _pressure(self, finger: int) -> List[int]:
# """内部:选手指 → 读压力数据"""
# # 选择手指 (保持寄存器 36)
# self._write_register(36, finger)
# time.sleep(_INTERVAL)
# # 读取压力数据 (输入寄存器 52-122)
# return np.array(self._read_input_registers(52, 71))
def _pressure(self, finger: int) -> np.ndarray:
"""
6x12 (72点) 矩阵尺寸。
Modbus 地址 60/62。
"""
rows = 12 # 12 行
cols = 6 # 6 列
finger_size = rows * cols # 72 个数据点
# modbus 地址和计数
write_address = 60 # 写入手指选择
read_address = 62 # 读取压力数据
read_count = 96 # 读取 96 个寄存器
skip_count = 10 # 跳过前 10 个校验点
# 0. 参数校验和手指写入值确定
if finger < 1 or finger > 5:
raise ValueError(f"无效的手指编号: {finger}。手指编号应在 1 到 5 之间。")
finger_write_value = finger
# 1. 写入手指选择寄存器 (地址 60)
time.sleep(0.008)
wrsp = self.cli.write_register(address=write_address, value=finger_write_value, slave=self.slave)
if wrsp.isError():
raise RuntimeError(f"写入手指选择 {finger} 到地址 {write_address} 失败: {wrsp}")
# 写入后等待片刻
time.sleep(0.008)
# 2. 读取地址 62 的数据
rrsp = self.cli.read_input_registers(address=read_address, count=read_count, slave=self.slave)
if rrsp.isError():
raise RuntimeError(f"读取地址 {read_address} 压力数据失败: {rrsp}")
registers_16bit: List[int] = rrsp.registers
# 3. 核心数据处理
# a. 提取低 8 位数据 (得到 96 个 8 位数据点)
final_data_96 = [reg_value & 255 for reg_value in registers_16bit]
# b. 跳过前 10 个校验/头部数据点 (得到 86 个有效数据点)
effective_data = np.array(final_data_96[skip_count:], dtype=np.uint8)
# c. 截取当前手指的矩阵数据 (从 86 个有效点中截取 72 个点)
start_idx = 0
end_idx = finger_size # 72
finger_data_flat = effective_data[start_idx:end_idx]
# d. 验证数据长度
if finger_data_flat.size != finger_size:
raise ValueError(
f"数据提取失败。期望 {finger_size} 点 ({rows}x{cols}),"
f"但仅截取到 {finger_data_flat.size} 点。请检查协议,确认地址 62 是否一次性返回了所有手指数据。"
)
# e. 重塑为二维矩阵 (12 行 6 列)
finger_matrix = finger_data_flat.reshape((rows, cols))
return finger_matrix
def read_pressure_thumb(self) -> np.ndarray:
"""读取大拇指压力数据"""
return np.array(self._pressure(1), dtype=np.uint8)
def read_pressure_index(self) -> np.ndarray:
"""读取食指压力数据"""
return np.array(self._pressure(2), dtype=np.uint8)
def read_pressure_middle(self) -> np.ndarray:
"""读取中指压力数据"""
return np.array(self._pressure(3), dtype=np.uint8)
def read_pressure_ring(self) -> np.ndarray:
"""读取无名指压力数据"""
return np.array(self._pressure(4), dtype=np.uint8)
def read_pressure_little(self) -> np.ndarray:
"""读取小拇指压力数据"""
return np.array(self._pressure(5), dtype=np.uint8)
# --------------------------------------------------
# 版本信息接口
# --------------------------------------------------
def read_versions(self) -> Dict[str, int]:
"""读取版本信息 (输入寄存器 148-155)"""
result = self._read_input_registers(148, 8)
return {
"hand_freedom": result[0],
"hand_version": result[1],
"hand_number": result[2],
"hand_direction": result[3],
"software_version_major": result[4],
"software_version_minor": result[5] if len(result) > 5 else 0,
"software_version_revision": result[6] if len(result) > 6 else 0,
"hardware_version": result[7] if len(result) > 7 else 0
}
# --------------------------------------------------
# 写入接口
# --------------------------------------------------
def write_angles(self, vals: List[int]):
"""设置6个关节角度 (保持寄存器 0-5)"""
vals = [int(x) for x in vals]
if not self.is_valid_6xuint8(vals):
raise ValueError("需要6个0-255的整数")
self._write_registers(0, vals)
def write_torques(self, vals: List[int]):
"""设置6个关节转矩 (保持寄存器 6-11)"""
vals = [int(x) for x in vals]
if not self.is_valid_6xuint8(vals):
raise ValueError("需要6个0-255的整数")
self._write_registers(6, vals)
def write_speeds(self, vals: List[int]):
"""设置6个关节速度 (保持寄存器 12-17)"""
vals = [int(x) for x in vals]
if not self.is_valid_6xuint8(vals):
raise ValueError("需要6个0-255的整数")
self._write_registers(12, vals)
# --------------------------------------------------
# 上下文管理
# --------------------------------------------------
def close(self):
"""关闭连接"""
if self.connected:
self.cli.close()
self.connected = False
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
# --------------------------------------------------
# API固定接口函数
# --------------------------------------------------
def is_valid_6xuint8(self, lst) -> bool:
"""验证6个0-255的整数列表"""
if len(lst) != 6:
return False
return all(isinstance(x, int) and 0 <= x <= 255 for x in lst)
def set_joint_positions(self, joint_angles=None):
"""设置关节位置"""
joint_angles = joint_angles or [0] * 6
self.write_angles(joint_angles)
def set_speed(self, speed=None):
"""设置速度"""
speed = speed or [200] * 6
self.write_speeds(speed)
def set_torque(self, torque=None):
"""设置扭矩"""
torque = torque or [200] * 6
self.write_torques(torque)
def set_current(self, current=None):
"""设置电流 (L6不支持)"""
print("当前L6不支持设置电流", flush=True)
def get_version(self) -> list:
"""获取版本信息"""
versions = self.read_versions()
return [
versions.get("hand_freedom", 0),
versions.get("hand_version", 0),
versions.get("hand_number", 0),
versions.get("hand_direction", 0),
versions.get("software_version_major", 0),
versions.get("hardware_version", 0)
]
def get_current(self):
"""获取电流 (L6不支持)"""
print("当前L6不支持获取电流", flush=True)
return []
def get_state(self) -> list:
"""获取关节状态"""
return self.read_angles()
def get_state_for_pub(self) -> list:
return self.get_state()
def get_current_status(self) -> list:
return self.get_state()
def get_speed(self) -> list:
"""获取当前速度"""
return self.read_speeds()
def get_joint_speed(self) -> list:
return self.get_speed()
def get_touch_type(self) -> int:
"""获取压感类型 (2=矩阵式)"""
return 2
def get_normal_force(self) -> list:
"""获取压感数据:点式"""
return [-1] * 5
def get_tangential_force(self) -> list:
"""获取压感数据:点式"""
return [-1] * 5
def get_approach_inc(self) -> list:
"""获取压感数据:点式"""
return [-1] * 5
def get_touch(self) -> list:
return [-1] * 5
def get_thumb_matrix_touch(self,sleep_time=0):
return self._pressure(1)
def get_index_matrix_touch(self,sleep_time=0):
return self._pressure(2)
def get_middle_matrix_touch(self,sleep_time=0):
return self._pressure(3)
def get_ring_matrix_touch(self,sleep_time=0):
return self._pressure(4)
def get_little_matrix_touch(self,sleep_time=0):
return self._pressure(5)
def get_matrix_touch(self) -> list:
"""获取压感数据:矩阵式"""
return [self._pressure(1), self._pressure(2), self._pressure(3),
self._pressure(4), self._pressure(5)]
def get_matrix_touch_v2(self) -> list:
"""获取压感数据:矩阵式"""
return self.get_matrix_touch()
def get_torque(self) -> list:
"""获取当前扭矩"""
return self.read_torques()
def get_temperature(self) -> list:
"""获取当前电机温度"""
return self.read_temperatures()
def get_fault(self) -> list:
"""获取当前电机故障码"""
return self.read_error_codes()
def get_serial_number(self):
return [0] * 6
# --------------------------------------------------
# 便捷方法
# --------------------------------------------------
def relax(self):
"""所有手指伸直"""
self.set_joint_positions([255] * 6)
def fist(self):
"""所有手指握拳"""
self.set_joint_positions([0] * 6)
def dump_status(self):
"""打印状态信息"""
print("=" * 50)
print("L6机械手状态信息")
print("=" * 50)
try:
# 关节状态
angles = self.read_angles()
torques = self.read_torques()
speeds = self.read_speeds()
temps = self.read_temperatures()
errors = self.read_error_codes()
print("关节状态:")
for i, name in enumerate(self.JOINT_NAMES):
print(f" {name:15s}: 角度={angles[i]:3d}, 扭矩={torques[i]:3d}, "
f"速度={speeds[i]:3d}, 温度={temps[i]:2d}℃, 错误={errors[i]:2d}")
# 版本信息
versions = self.read_versions()
print("\n版本信息:")
for key, value in versions.items():
print(f" {key:20s}: {value}")
# 压力传感器测试
print("\n压力传感器测试:")
thumb_pressure = self.read_pressure_thumb()
print(f"大拇指压力数据长度: {len(thumb_pressure)}")
except Exception as e:
print(f"读取状态时出错: {e}")
print("=" * 50)
# ------------------- 演示程序 -------------------
if __name__ == "__main__":
# 使用示例
try:
with LinkerHandL6RS485(hand_id=0x27, modbus_port="/dev/ttyUSB0", baudrate=115200) as hand:
print("连接成功!")
# 打印状态信息
hand.dump_status()
# 测试基本控制
print("\n测试控制功能...")
print("伸直手指...")
hand.relax()
time.sleep(2)
print("握拳...")
hand.fist()
time.sleep(2)
print("恢复伸直...")
hand.relax()
# 测试压力传感器
print("\n测试压力传感器...")
thumb_matrix = hand.get_thumb_matrix_touch()
print(f"大拇指压力数据: {len(thumb_matrix)}个点")
# 获取所有手指压力数据
all_matrices = hand.get_matrix_touch()
for i, name in enumerate(hand.FINGER_NAMES):
matrix = all_matrices[i]
print(f"{name}手指压力数据长度: {len(matrix)}")
except Exception as e:
print(f"错误: {e}")
@@ -202,6 +202,11 @@ class LinkerHandApi:
'''Get current joint state'''
return self.hand.get_current_status()
def get_state_cached(self):
"""Get the latest received state without transmitting new queries."""
getter = getattr(self.hand, "get_cached_current_status", None)
return getter() if getter is not None else None
def get_state_for_pub(self):
return self.hand.get_current_pub_status()
@@ -37,11 +37,30 @@ def command_changed(previous, current):
return any(float(old) != float(new) for old, new in zip(previous, values))
def position_command_should_queue(previous, current, repeat=True):
"""Whether the newest position target should be written on this heartbeat."""
values = list(current)
return bool(values) and (
bool(repeat) or command_changed(previous, values)
)
def state_poll_due(last_poll_time, now, poll_period):
"""Keep slow CAN state reads off the latency-sensitive command path."""
return last_poll_time is None or now >= last_poll_time + poll_period
def state_reads_deferred(
last_command_time, now, quiet_period, enabled=True
):
"""Return whether blocking state reads must yield to active commands."""
return (
enabled
and last_command_time is not None
and now < last_command_time + quiet_period
)
class LinkerHand(Node):
def __init__(self, name):
super().__init__(name)
@@ -54,6 +73,12 @@ class LinkerHand(Node):
# -1 keeps the model's original startup speed. Camera teleoperation can
# set this to a conservative value before the startup pose is sent.
self.declare_parameter('startup_speed', -1)
# -1 keeps the model's original startup torque. The retarget v2
# launch uses a conservative value for calibration and preview.
self.declare_parameter('startup_torque', -1)
# Preserve the legacy behaviour by default. Safety-critical launch
# files can configure limits without moving to a startup pose.
self.declare_parameter('move_on_startup', True)
# Empty keeps the legacy absolute topics/startup pose. A prefix lets
# two same-side hands coexist without receiving each other's commands.
self.declare_parameter('topic_prefix', '')
@@ -63,6 +88,17 @@ class LinkerHand(Node):
# incoming position commands.
self.declare_parameter('state_poll_rate', 60.0)
self.declare_parameter('velocity_poll_rate', 60.0)
# G20 state and velocity reads each transmit five synchronous CAN
# queries. Defer them while teleoperation commands are arriving.
self.declare_parameter('defer_state_reads_while_commanding', True)
self.declare_parameter('command_quiet_period', 0.2)
# Legacy teleoperation sent the latest target on every 30 Hz callback,
# including an unchanged target. Some firmware revisions track that
# cadence more smoothly than sparse change-only updates.
self.declare_parameter('repeat_position_commands', True)
# Faults stay manually clearable through cb_hand_setting_cmd. Repeated
# automatic clears add periodic CAN traffic to the command stream.
self.declare_parameter('auto_clear_faults', False)
# ros时间获取
self.stamp_clock = Clock()
@@ -75,6 +111,12 @@ class LinkerHand(Node):
self.startup_speed = int(self.get_parameter('startup_speed').value)
if self.startup_speed < -1 or self.startup_speed > 255:
raise ValueError('startup_speed must be -1 or in the range [0, 255]')
self.startup_torque = int(self.get_parameter('startup_torque').value)
if self.startup_torque < -1 or self.startup_torque > 255:
raise ValueError('startup_torque must be -1 or in the range [0, 255]')
self.move_on_startup = bool(
self.get_parameter('move_on_startup').value
)
self.topic_prefix = self.normalize_topic_prefix(
self.get_parameter('topic_prefix').value
)
@@ -92,6 +134,22 @@ class LinkerHand(Node):
raise ValueError('velocity_poll_rate must be greater than zero')
self.velocity_poll_period = 1.0 / self.velocity_poll_rate
self.last_velocity_poll_time = None
self.defer_state_reads_while_commanding = bool(
self.get_parameter(
'defer_state_reads_while_commanding'
).value
)
self.command_quiet_period = float(
self.get_parameter('command_quiet_period').value
)
if self.command_quiet_period < 0.0:
raise ValueError('command_quiet_period must not be negative')
self.auto_clear_faults = bool(
self.get_parameter('auto_clear_faults').value
)
self.repeat_position_commands = bool(
self.get_parameter('repeat_position_commands').value
)
configured_startup_pose = self.get_parameter_or(
'startup_pose',
Parameter('startup_pose', Parameter.Type.INTEGER_ARRAY, []),
@@ -107,6 +165,7 @@ class LinkerHand(Node):
self.last_hand_eff_cmd = None # 最新手指力矩命令
self.applied_hand_post_cmd = None
self.applied_hand_vel_cmd = None
self.last_position_command_time = None
self.last_hand_state = [-1] * 10
self.last_hand_vel = [-1] * 10
@@ -244,26 +303,38 @@ class LinkerHand(Node):
pose = list(self.startup_pose)
if self.startup_speed >= 0:
speed = [self.startup_speed] * len(speed)
if self.startup_torque >= 0:
torque = [self.startup_torque] * len(torque)
if pose is not None:
for i in range(1):
self.api.set_speed(speed=speed)
time.sleep(0.1)
self.api.set_torque(torque=torque)
time.sleep(0.1)
self.api.finger_move(pose=pose)
time.sleep(0.1)
if self.move_on_startup:
self.api.finger_move(pose=pose)
time.sleep(0.1)
def hand_control_cb(self, msg):
# The hardware can be slower than the camera. Always replace a
# pending command with the newest sample and never replay an already
# applied sample; this prevents latency from accumulating in software.
# The hardware can be slower than the publisher, so a pending target is
# always replaced by the newest sample. By default the newest target
# is also resent at the publisher cadence, matching the legacy driver.
position = list(msg.position)
if position:
self.last_hand_post_cmd = (
position
if command_changed(self.applied_hand_post_cmd, position)
else None
)
# Treat every valid sample as an active teleoperation heartbeat,
# even if integer quantisation made it identical to the previous
# target. This keeps all synchronous CAN diagnostics off the bus
# for the entire control session, matching the legacy execution
# path that had no state subscriber.
self.last_position_command_time = time.monotonic()
if position_command_should_queue(
self.applied_hand_post_cmd,
position,
self.repeat_position_commands,
):
self.last_hand_post_cmd = position
else:
self.last_hand_post_cmd = None
velocity = list(msg.velocity)
if velocity:
@@ -286,6 +357,9 @@ class LinkerHand(Node):
self.api.finger_move(pose=pose)
self.applied_hand_post_cmd = pose
self.last_hand_post_cmd = None
cached_state = self.api.get_state_cached()
if cached_state is not None:
self.last_hand_state = cached_state
if self.last_hand_vel_cmd is not None:
vel = list(self.last_hand_vel_cmd)
@@ -320,6 +394,15 @@ class LinkerHand(Node):
if self.hand_state_pub.get_subscription_count() < 1:
return
now = time.monotonic()
if state_reads_deferred(
self.last_position_command_time,
now,
self.command_quiet_period,
self.defer_state_reads_while_commanding,
):
# pub_state continues to publish the last completed state as a
# heartbeat. A fresh blocking read is made after motion settles.
return
if not state_poll_due(
self.last_state_poll_time, now, self.state_poll_period
):
@@ -342,6 +425,12 @@ class LinkerHand(Node):
# Position commands have priority over synchronous state reads.
self._apply_pending_commands()
self._poll_state_if_due()
diagnostics_deferred = state_reads_deferred(
self.last_position_command_time,
time.monotonic(),
self.command_quiet_period,
self.defer_state_reads_while_commanding,
)
if self.cmd_lock == False:
time.sleep(0.003)
if self.run_count == 3 and self.is_touch == True and self.touch_type == 1 and self.modbus == "None" and self.touch_pub.get_subscription_count() > 0:
@@ -360,7 +449,11 @@ class LinkerHand(Node):
if self.run_count == 7:
self.matrix_dic["little_matrix"] = self.api.get_little_matrix_touch(sleep_time=self.sleep_time).tolist()
time.sleep(0.005)
if self.run_count == 8 and self.hand_info_pub.get_subscription_count() > 0:
if (
self.run_count == 8
and self.hand_info_pub.get_subscription_count() > 0
and not diagnostics_deferred
):
"""手部信息"""
self.last_hand_info = {
"version": self.embedded_version, # Dexterous hand version number
@@ -375,8 +468,9 @@ class LinkerHand(Node):
"finger_order": self.api.get_finger_order() # Finger motor order
}
if self.run_count == 9:
if self.run_count == 9 and self.auto_clear_faults:
self.api.clear_faults() # 自动清除错误编码
if self.run_count == 9:
self.run_count = 0
self.run_count += 1
time.sleep(0.003)
@@ -1,414 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
'''
编译: colcon build --symlink-install
启动命令:ros2 run linker_hand_ros2_sdk linker_hand_sdk
'''
from re import A
import rclpy,sys # ROS2 Python接口库
import time
import numpy as np
from rclpy.node import Node # ROS2 节点类
from rclpy.clock import Clock
from std_msgs.msg import String, Header, Float32MultiArray
from sensor_msgs.msg import JointState, PointCloud2, PointField
import time, json, threading
from linker_hand_ros2_sdk.LinkerHand.linker_hand_api import LinkerHandApi
from linker_hand_ros2_sdk.LinkerHand.utils.color_msg import ColorMsg
from linker_hand_ros2_sdk.LinkerHand.utils.open_can import OpenCan
class LinkerHand(Node):
def __init__(self, name):
super().__init__(name)
# 声明参数(带默认值)
self.declare_parameter('hand_type', 'left')
self.declare_parameter('hand_joint', 'L6')
self.declare_parameter('is_touch', False)
self.declare_parameter('can', 'can0')
self.declare_parameter('modbus', "None")
# ros时间获取
self.stamp_clock = Clock()
# 获取参数值
self.hand_type = self.get_parameter('hand_type').value
self.hand_joint = self.get_parameter('hand_joint').value
self.is_touch = self.get_parameter('is_touch').value
self.can = self.get_parameter('can').value
self.modbus = self.get_parameter('modbus').value
self.sdk_v = 2
self.sleep_time = 0.005
self.cmd_lock = False
self.last_hand_post_cmd = None # 最新手指位置命令
self.last_hand_vel_cmd = None # 最新手指速度命令
self.last_hand_eff_cmd = None # 最新手指力矩命令
self.last_hand_state = [-1] * 10
self.last_hand_vel = [-1] * 10
self.force = [[-1] * 5] * 4
self.matrix_dic = {
"stamp":{
"sec": 0,
"nanosec": 0,
},
"thumb_matrix":[[-1] * 6 for _ in range(12)],
"index_matrix":[[-1] * 6 for _ in range(12)],
"middle_matrix":[[-1] * 6 for _ in range(12)],
"ring_matrix":[[-1] * 6 for _ in range(12)],
"little_matrix":[[-1] * 6 for _ in range(12)]
}
# 压感矩阵合值,单位g 克
self.matrix_mass_dic = {
"stamp":{
"secs": 0,
"nsecs": 0,
},
"thumb_mass":[-1],
"index_mass":[-1],
"middle_mass":[-1],
"ring_mass":[-1],
"little_mass":[-1]
}
self.last_hand_info = {
"version": [-1], # Dexterous hand version number
"hand_joint": self.hand_joint, # Dexterous hand joint type
"speed": [-1] * 10, # Current speed threshold of the dexterous hand
"current": [-1] * 10, # Current of the dexterous hand
"fault": [-1] * 10, # Current fault of the dexterous hand
"motor_temperature": [-1] * 10, # Current motor temperature of the dexterous hand
"torque": [-1] * 10, # Current torque of the dexterous hand
"is_touch":self.is_touch,
"touch_type": -1,
"finger_order": None # Finger motor order
}
self.version = []
self.touch_type = -1
self.hz = 1.0/60.0
self.hand_setting_sub = self.create_subscription(String,'/cb_hand_setting_cmd', self.hand_setting_cb, 10)
self._init_hand()
time.sleep(1)
self.run_count = 0 # 计数器,用于记录运行次数
self.timer = self.create_timer(0.01, self.run) # 100 Hz
self.thread_pub_state = threading.Thread(target=self.pub_state)
self.thread_pub_state.daemon = True
self.thread_pub_state.start()
def _init_hand(self):
self.api = LinkerHandApi(hand_type=self.hand_type, hand_joint=self.hand_joint,modbus=self.modbus,can=self.can)
time.sleep(0.1)
self.touch_type = self.api.get_touch_type()
self.hand_cmd_sub = self.create_subscription(JointState, f'/cb_{self.hand_type}_hand_control_cmd', self.hand_control_cb,10)
self.hand_state_pub = self.create_publisher(JointState, f'/cb_{self.hand_type}_hand_state',10)
self.hand_info_pub = self.create_publisher(String, f'/cb_{self.hand_type}_hand_info', 10)
if self.is_touch == True:
if self.touch_type > 1:
ColorMsg(msg=f"{self.hand_type} {self.hand_joint} Equipped with matrix pressure sensing", color='green')
self.matrix_touch_pub = self.create_publisher(String, f'/cb_{self.hand_type}_hand_matrix_touch', 10)
self.matrix_touch_pub_pc = self.create_publisher(PointCloud2, f'/cb_{self.hand_type}_hand_matrix_touch_pc', 10)
self.matrix_touch_mass_pub = self.create_publisher(String, f'/cb_{self.hand_type}_hand_matrix_touch_mass', 10)
elif self.touch_type != -1:
ColorMsg(msg=f"{self.hand_type} {self.hand_joint} Equipped with pressure sensor", color="green")
self.touch_pub = self.create_publisher(Float32MultiArray, f'/cb_{self.hand_type}_hand_force', 10)
else:
ColorMsg(msg=f"{self.hand_type} {self.hand_joint} Not equipped with any pressure sensors", color="red")
self.is_touch = False
self.embedded_version = self.api.get_embedded_version()
pose = None
torque = [200, 200, 200, 200, 200]
speed = [200, 250, 250, 250, 250]
if self.hand_joint.upper() == "O6" or self.hand_joint.upper() == "L6" or self.hand_joint.upper() == "L6P":
pose = [200, 255, 255, 255, 255, 180]
torque = [250, 250, 250, 250, 250, 250]
# O6 最大速度阈值
speed = [200, 250, 250, 250, 250, 250]
elif self.hand_joint == "L7":
# The data length of L7 is 7, reinitialize here
pose = [255, 200, 255, 255, 255, 255, 180]
torque = [250, 250, 250, 250, 250, 250, 250]
speed = [120, 250, 250, 250, 250, 250, 250]
elif self.hand_joint == "L10":
torque = [255] * 10
pose = [255, 200, 255, 255, 255, 255, 180, 180, 180, 41]
speed = [200, 250, 250, 250, 250, 250, 250, 250, 250, 250]
elif self.hand_joint == "L20":
pose = [255,255,255,255,255,255,10,100,180,240,245,255,255,255,255,255,255,255,255,255]
elif self.hand_joint == "L21":
pose = [75, 255, 255, 255, 255, 176, 97, 81, 114, 147, 202, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
elif self.hand_joint == "L25":
pose = [75, 255, 255, 255, 255, 176, 97, 81, 114, 147, 202, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
if pose is not None:
for i in range(1):
self.api.set_speed(speed=speed)
time.sleep(0.1)
self.api.set_torque(torque=torque)
time.sleep(0.1)
self.api.finger_move(pose=pose)
time.sleep(0.1)
def list_check(self,pose):
if isinstance(pose, list) == False:
return False
if len(self.last_hand_post_cmd) != len(pose):
return False
return any(abs(self.last_hand_post_cmd - pose) >= 3 for self.last_hand_post_cmd, pose in zip(self.last_hand_post_cmd, pose))
def hand_control_cb(self, msg):
if self.last_hand_post_cmd == None or self.list_check(msg.position) == True:
self.last_hand_post_cmd = msg.position
if self.last_hand_vel_cmd == None or self.list_check(msg.velocity) == True:
self.last_hand_vel_cmd = msg.velocity
if self.last_hand_eff_cmd == None or self.list_check(msg.effort) == True:
self.last_hand_eff_cmd = msg.effort
def run(self):
if self.sdk_v == 1:
self.sleep_time = 0.009
if self.hand_state_pub.get_subscription_count() > 0:
# 优先获取手指状态并且发布
self.last_hand_state = self.api.get_state()
time.sleep(0.003)
self.last_hand_vel = self.api.get_joint_speed()
time.sleep(0.002)
if self.cmd_lock == False:
if self.last_hand_post_cmd != None:
self.api.finger_move(pose=self.last_hand_post_cmd)
self.last_hand_post_cmd = None
if self.last_hand_vel_cmd != None:
vel = list(self.last_hand_vel_cmd)
if all(x == 0 for x in vel):
pass
else:
if (str(self.hand_joint).upper() == "O6" or str(self.hand_joint).upper() == "L6" or str(self.hand_joint).upper() == "L6P") and len(vel) == 6:
speed = vel
self.api.set_joint_speed(speed=speed)
elif self.hand_joint == "L7" and len(vel) == 7:
speed = vel
self.api.set_joint_speed(speed=speed)
elif self.hand_joint == "L10" and len(vel) == 10:
speed = [vel[0],vel[2],vel[3],vel[4],vel[5]]
self.api.set_joint_speed(speed=speed)
elif self.hand_joint == "L20" and len(vel) == 20:
speed = [vel[10],vel[1],vel[2],vel[3],vel[4]]
self.api.set_joint_speed(speed=speed)
elif self.hand_joint == "L21" and len(vel) == 25:
speed = vel
self.api.set_joint_speed(speed=speed)
elif self.hand_joint == "L25" and len(vel) == 25:
speed = vel
self.api.set_joint_speed(speed=speed)
self.last_hand_vel_cmd = None
time.sleep(0.003)
if self.run_count == 3 and self.is_touch == True and self.touch_type == 1 and self.touch_pub.get_subscription_count() > 0:
"""单点式压力传感器"""
self.force = self.api.get_force()
if self.is_touch == True and self.touch_type > 1 and (self.matrix_touch_pub.get_subscription_count() > 0 or self.matrix_touch_mass_pub.get_subscription_count() > 0 or self.matrix_touch_pub_pc.get_subscription_count() > 0):
"""矩阵式压力传感器"""
if self.run_count == 3:
self.matrix_dic["thumb_matrix"] = self.api.get_thumb_matrix_touch(sleep_time=self.sleep_time).tolist()
if self.run_count == 4:
self.matrix_dic["index_matrix"] = self.api.get_index_matrix_touch(sleep_time=self.sleep_time).tolist()
if self.run_count == 5:
self.matrix_dic["middle_matrix"] = self.api.get_middle_matrix_touch(sleep_time=self.sleep_time).tolist()
if self.run_count == 6:
self.matrix_dic["ring_matrix"] = self.api.get_ring_matrix_touch(sleep_time=self.sleep_time).tolist()
if self.run_count == 7:
self.matrix_dic["little_matrix"] = self.api.get_little_matrix_touch(sleep_time=self.sleep_time).tolist()
time.sleep(0.005)
if self.run_count == 8 and self.hand_info_pub.get_subscription_count() > 0:
"""手部信息"""
self.last_hand_info = {
"version": self.embedded_version, # Dexterous hand version number
"hand_joint": self.hand_joint, # Dexterous hand joint type
"speed": self.api.get_speed(), # Current speed threshold of the dexterous hand
"current": self.api.get_current(), # Current of the dexterous hand
"fault": self.api.get_fault(), # Current fault of the dexterous hand
"motor_temperature": self.api.get_temperature(), # Current motor temperature of the dexterous hand
"torque": self.api.get_torque(), # Current torque of the dexterous hand
"is_touch":self.is_touch,
"touch_type": self.touch_type,
"finger_order": self.api.get_finger_order() # Finger motor order
}
if self.run_count == 9:
self.run_count = 0
self.run_count += 1
time.sleep(0.003)
def pub_state(self):
while True:
if self.hand_state_pub.get_subscription_count() > 0:
msg = self.joint_state_msg(self.last_hand_state, self.last_hand_vel)
self.hand_state_pub.publish(msg)
if self.is_touch == True and self.touch_type == 1 and self.touch_pub.get_subscription_count() > 0:
msg = Float32MultiArray()
msg.data = [float(val) for sublist in self.force for val in sublist]
self.touch_pub.publish(msg)
if self.is_touch == True and self.touch_type > 1 and (self.matrix_touch_pub.get_subscription_count() > 0 or self.matrix_touch_mass_pub.get_subscription_count() > 0 or self.matrix_touch_pub_pc.get_subscription_count() > 0):
# 发布矩阵压感数据JSON格式
self.pub_matrix_dic()
# 发布矩阵压感和值JSON格式
self.pub_matrix_mass(dic=self.matrix_dic)
# 发布矩阵压感点云格式
self.pub_matrix_point_cloud()
if self.hand_info_pub.get_subscription_count() > 0:
msg = String()
msg.data = json.dumps(self.last_hand_info)
self.hand_info_pub.publish(msg)
time.sleep(self.hz)
def pub_matrix_mass(self, dic):
"""发布矩阵数据合值 单位g 克 JSON格式"""
msg = String()
# 获取当前的 ROS 时间
current_time = self.stamp_clock.now()
# 提取 secs 和 nsecs
t_secs = current_time.to_msg().sec
t_nsecs = current_time.to_msg().nanosec
self.matrix_mass_dic["stamp"]["secs"] = t_secs
self.matrix_mass_dic["stamp"]["nsecs"] = t_nsecs
self.matrix_mass_dic["unit"] = "g"
self.matrix_mass_dic["thumb_mass"] = sum(sum(row) for row in dic["thumb_matrix"])
self.matrix_mass_dic["index_mass"] = sum(sum(row) for row in dic["index_matrix"])
self.matrix_mass_dic["middle_mass"] = sum(sum(row) for row in dic["middle_matrix"])
self.matrix_mass_dic["ring_mass"] = sum(sum(row) for row in dic["ring_matrix"])
self.matrix_mass_dic["little_mass"] = sum(sum(row) for row in dic["little_matrix"])
msg.data = json.dumps(self.matrix_mass_dic)
self.matrix_touch_mass_pub.publish(msg)
def pub_matrix_point_cloud(self):
"""发布矩阵数据点云格式"""
tmp_dic = self.matrix_dic.copy()
del tmp_dic['stamp'] # 去掉时间戳字段
all_matrices = list(tmp_dic.values()) # 5 帧,每帧 6×12=72 个数
# 摊平到一维:360 个 float
flat_list = [v for frame in all_matrices for v in frame] # 360
flat = np.concatenate([np.asarray(np.clip(c, 0, 255), dtype=np.uint8) for c in flat_list])
fields = [PointField(
name='val',
offset=0,
datatype=PointField.UINT8,
count=1
)]
pc = PointCloud2()
pc.header.stamp = self.stamp_clock.now().to_msg()
pc.header.frame_id = ''
pc.height = 1
pc.width = flat.size # 360
pc.fields = fields
pc.is_bigendian = False
pc.point_step = 1 # 1 个 float32
pc.row_step = pc.point_step * pc.width
pc.data = flat.tobytes() # 1440 字节
self.matrix_touch_pub_pc.publish(pc)
def pub_matrix_dic(self):
"""发布矩阵数据JSON格式"""
msg = String()
# 获取当前的 ROS 时间
current_time = self.stamp_clock.now()
# 提取 secs 和 nsecs
t_secs = current_time.to_msg().sec
t_nsecs = current_time.to_msg().nanosec
self.matrix_dic["stamp"]["secs"] = t_secs
self.matrix_dic["stamp"]["nsecs"] = t_nsecs
msg.data = json.dumps(self.matrix_dic)
self.matrix_touch_pub.publish(msg)
def joint_state_msg(self, pose,vel=[]):
joint_state = JointState()
joint_state.header = Header()
joint_state.header.stamp = self.get_clock().now().to_msg()
joint_state.name = self.api.get_finger_order()
joint_state.position = [float(x) for x in pose]
if len(vel) > 1:
joint_state.velocity = [float(x) for x in vel]
else:
joint_state.velocity = [0.0] * len(pose)
joint_state.effort = [0.0] * len(pose)
return joint_state
def hand_setting_cb(self,msg):
'''控制命令回调'''
data = json.loads(msg.data)
print(f"Received setting command: {data['setting_cmd']}",flush=True)
try:
if data["params"]["hand_type"] == "left":
hand = self.api
hand_left = True
elif data["params"]["hand_type"] == "right":
hand = self.api
hand_right = True
else:
print("Please specify the hand part to be set",flush=True)
return
self.cmd_lock = True
# Set maximum torque
if data["setting_cmd"] == "set_max_torque_limits": # Set maximum torque
torque = list(data["params"]["torque"])
hand.set_torque(torque=torque)
if data["setting_cmd"] == "set_speed": # Set speed
if isinstance(data["params"]["speed"], list) == True:
speed = data["params"]["speed"]
hand.set_speed(speed=speed)
else:
ColorMsg(msg=f"Speed parameter error, speed must be a list", color="red")
if data["setting_cmd"] == "clear_faults": # Clear faults
if hand_left == True and self.hand_joint == "L10" :
ColorMsg(msg=f"L10 left hand cannot clear faults")
elif hand_right == True and self.hand_joint == "L10" :
ColorMsg(msg=f"L10 right hand cannot clear faults")
else:
hand.clear_faults()
if data["setting_cmd"] == "get_faults": # Get faults
f = hand.get_fault()
ColorMsg(msg=f"Get faults: {f}")
if data["setting_cmd"] == "electric_current": # Get current
ColorMsg(msg=f"Get current: {hand.get_current()}")
if data["setting_cmd"] == "set_electric_current": # Set current
if isinstance(data["params"]["current"], list) == True:
hand.set_current(data["params"]["current"])
if data["setting_cmd"] == "show_fun_table": # Get faults
f = hand.show_fun_table()
except:
print("命令参数错误")
self.cmd_lock = False
finally:
self.cmd_lock = False
def close_can(self):
self.api.open_can.close_can(can=self.can)
sys.exit(0)
def main(args=None):
try:
rclpy.init(args=args)
node = LinkerHand("linker_hand_sdk")
embedded_version = node.embedded_version
if len(embedded_version) == 3 or node.hand_joint.upper() == "O6" or node.hand_joint.upper() == "L6" or node.hand_joint.upper() == "G20":
ColorMsg(msg=f"New Matrix Touch For SDK V2", color="green")
node.sdk_v = 2
elif len(embedded_version) == 6 and node.hand_joint == "L10":
ColorMsg(msg=f"New Matrix Touch For SDK V2", color="green")
node.sdk_v = 2
elif len(embedded_version) > 4 and ((embedded_version[0]==10 and embedded_version[4]>35) or (embedded_version[0]==7 and embedded_version[4]>50) or (embedded_version[0] == 6)):
ColorMsg(msg=f"New Matrix Touch For SDK V2", color="green")
node.sdk_v = 2
else:
ColorMsg(msg=f"SDK V1", color="green")
node.sdk_v = 1
rclpy.spin(node) # 主循环,监听 ROS 回调
except KeyboardInterrupt:
print("收到 Ctrl+C,准备退出...")
finally:
# node.close_can() # 关闭 CAN 或其他硬件资源
# node.destroy_node() # 销毁 ROS 节点
# rclpy.shutdown() # 关闭 ROS
print("程序已退出。")
@@ -0,0 +1,72 @@
import ast
from collections import deque
from pathlib import Path
import sys
import threading
import time
from types import SimpleNamespace
PACKAGE = Path(__file__).resolve().parents[1] / "linker_hand_ros2_sdk/LinkerHand/core"
EXPECTED = [
"thumb_cmc_pitch",
"thumb_cmc_roll",
"index_mcp_pitch",
"middle_mcp_pitch",
"ring_mcp_pitch",
"pinky_mcp_pitch",
]
def _finger_order(path: Path, class_name: str) -> list[str]:
module = ast.parse(path.read_text(encoding="utf-8"))
selected = next(
item
for item in module.body
if isinstance(item, ast.ClassDef) and item.name == class_name
)
method = next(
item
for item in selected.body
if isinstance(item, ast.FunctionDef) and item.name == "get_finger_order"
)
returned = next(item for item in method.body if isinstance(item, ast.Return))
return ast.literal_eval(returned.value)
def test_l6_can_and_rs485_publish_the_same_physical_channel_order() -> None:
assert _finger_order(PACKAGE / "can/linker_hand_l6_can.py", "LinkerHandL6Can") == EXPECTED
assert _finger_order(
PACKAGE / "rs485/linker_hand_l6_rs485.py", "LinkerHandL6RS485"
) == EXPECTED
def test_l6_can_position_echo_does_not_replace_measured_feedback() -> None:
linker_hand_root = PACKAGE.parent
sys.path.insert(0, str(linker_hand_root))
try:
from core.can.linker_hand_l6_can import LinkerHandL6Can
finally:
sys.path.remove(str(linker_hand_root))
hand = LinkerHandL6Can.__new__(LinkerHandL6Can)
hand.can_id = 0x27
hand.x01 = [10, 20, 30, 40, 50, 60]
hand._position_echo_lock = threading.Lock()
command = (255, 2, 253, 253, 253, 253)
hand._pending_position_echoes = deque(
[(time.monotonic(), command)], maxlen=32
)
hand._position_echo_timeout_seconds = 0.02
hand.process_response(
SimpleNamespace(arbitration_id=0x27, data=bytes((0x01, *command)))
)
assert hand.x01 == [10, 20, 30, 40, 50, 60]
assert not hand._pending_position_echoes
measured = (250, 3, 252, 252, 252, 252)
hand.process_response(
SimpleNamespace(arbitration_id=0x27, data=bytes((0x01, *measured)))
)
assert hand.x01 == list(measured)
@@ -4,7 +4,9 @@ from linker_hand_ros2_sdk.linker_hand import (
COMMAND_QOS,
LinkerHand,
command_changed,
position_command_should_queue,
state_poll_due,
state_reads_deferred,
)
@@ -30,7 +32,25 @@ def test_identical_commands_are_not_reapplied():
assert not command_changed([60, 60], [])
def test_legacy_heartbeat_resends_unchanged_position_target():
assert position_command_should_queue([60, 60], [60, 60], repeat=True)
assert not position_command_should_queue(
[60, 60], [60, 60], repeat=False
)
assert position_command_should_queue(
[60, 60], [60, 61], repeat=False
)
assert not position_command_should_queue([60, 60], [], repeat=True)
def test_state_polling_is_throttled_without_missing_deadline():
assert state_poll_due(None, 10.0, 0.1)
assert not state_poll_due(10.0, 10.09, 0.1)
assert state_poll_due(10.0, 10.1, 0.1)
def test_state_reads_yield_to_recent_motion_then_resume():
assert state_reads_deferred(10.0, 10.1, 0.2)
assert not state_reads_deferred(10.0, 10.2, 0.2)
assert not state_reads_deferred(None, 10.1, 0.2)
assert not state_reads_deferred(10.0, 10.1, 0.2, enabled=False)
Submodule src/linkerhand-l30-sdk added at 0103fc55f9
@@ -0,0 +1,169 @@
# 统一标定需求代码审查(2026-09-10)
## 结论与审查范围
审查当前工作区(包含未提交的统一重构、精简 JSON v2 和本会话启动参数修复),
不是只审查 Git HEAD,也没有把历史 PASS 当作当前实机验收。
通用采集、拟合、URDF 修正、最终文件验收和发布架构已经存在,但仍有迁移残留和精度验证缺口。
本轮修正可由代码及隔离测试确认的问题,没有改变原始 CAD、机械限位、SDK、贴 Tag 配置或相机外参。
没有启动 SDK、相机或发送机械手运动指令。
## 已修复的问题
| 问题 | 影响 | 修改 |
| --- | --- | --- |
| `vendor_sdk_config:=` 等空 launch 实参 | O6/L6/G20 可能在节点启动前退出 | runner 省略空参数,使用 ROS CLI 的实际解析器覆盖四型号 |
| 在线 IO 使用固定 front/side/top 外参加载器 | 新 Profile 即使静态校验通过,在线仍可能不能使用其他机位 | 直接用通用外参加载器,视图和参考机位来自 Profile |
| launch 固定三机位、型号文件名回退和过期参数 | 新型号需修改启动代码;部分开关没有实际作用 | 按 Profile 创建机位;要求受保护 Profile/原始 URDF/Tag 路径;移除旧诊断、速度、scope 和输出目录覆盖参数 |
| MVS 无图像时只显示“等待设备” | 操作者无法区分 SDK、相机或检测链路故障 | 统一显示每机位有效内参/检测消息等待项、SDK 条件和内参不匹配原因 |
| READY 后设备断流仍保留可开始状态 | 启动请求可能使用已失效的就绪条件 | 开始前可撤销 READY;Start 再次检查完整设备条件 |
| 实时 CameraInfo 只比较 P 的部分字段 | K、D、R 改变但 P 未变时,整流图可能与原外参不一致 | 实时校验宽高及 K/D/R/P 指纹;开始后不匹配按原有坐标变化故障停止继续采集 |
| 每个稳态采样点清空整方向统计 | 显示有效帧和覆盖率归零,误导进度判断 | 分离方向数据与当前稳态点数据,重扫/换方向才重置方向统计 |
| 标题只显示序列号 | 序列号不含型号时无法确认当前型号 | 所有型号统一显示 model、side、serial_number |
| 正式 finalizer 仍允许旧格式绕过指令拟合 | 新型号配置旧版本可能没有所需的 SDK 指令→rad 产物 | 正式 Profile 与 finalizer 均只接受统一输出版本 2,取消旧输出分派 |
| 完成回调读取精简 JSON 已删除的 `quality` | 文件可能已经发布,但节点在结束时抛 KeyError | 删除没有消费者的 `final_quality` 赋值,增加无 quality 字段的完成回调回归 |
| 视觉暂停与控制定时器之间的状态竞争 | 定时器等待锁期间已暂停,取得锁后仍可能继续动作 | 在控制锁内再次检查终止状态 |
开始前等待相机消息的 2 秒窗口不用于扫描中的实时暂停;空检测数组算视觉链路存活。
短时 Tag 丢失、低检测率等仍按方向结束的数据质量策略处理,同速重扫一次后仍不足才暂停。
清理的在线残留包括无人读取的回调计数、命令频率缓存/方法、型号质量标签、旧 tracker reset hook,
以及每次反馈回调不必要构造的备用 Adapter。没有删除 SDK 协议接口的抽象方法。
五个过渡期写出器已移出生产包并保留原文件:
`calibration_output/code_review_retired_serializers_umhxlepj/`。
包括 `select.py`、`generic_v1.py`、`legacy_v4.py`、`legacy_v6.py`、`native_v7.py`。
历史读取器和历史诊断工具仍在;此归档不包含或改写用户的历史标定产物。
## 视频复核后的补充修复:共用姿态分支筛选
视频复核发现旧配置把 `pnp_reprojection_tie_px` 和姿态拒绝阈值均设为 `1.5 px`,
导致通过质量门槛的 IPPE 候选都参加时间连续性比较,旧镜像分支可能压过明确更好的图像拟合。
用现有候选姿态复现时,旧分支 `0.24 px` 会压过另一分支 `0.05 px`。
公共模块统一默认近似同误差阈值为 `0.03 px`;ROS 与离线采集继承该值。
五份旧 YAML 的重复覆盖已移除,四产品的 `calibration_config_sha256` 已同步更新。
新增校验拒绝非有限、负值或不小于图像拒绝阈值的分支容差,防止旧的 `1.5/1.5` 配置再次生效。
保持实际近似同误差时的连续性选择、原有倾角/图像质量限制和方向结束重扫策略。
因此前文“配置保持不变”仅描述首轮结构审查;这次补充修复修改了上述算法配置及其哈希。
覆盖五份配置和四型号默认采集的九个复现用例,在修复前全部失败,修复后全部通过。
第一组姿态、配置、采集与加载实际参数文件的四型号隔离 ROS host 检查共 51 项通过。
第二组公共采集/拟合与发布、O12 姿态解析、来源保护、四型号配置及启动检查共 141 项通过,
耗时 114.53 秒。两组为本次修改的定向回归,没有宣称本次又执行过全包测试。
本次 `colcon build --packages-select linkerhand_calibration --symlink-install` 通过;
安装环境读取四产品配置、校验受保护哈希及公共默认值均通过,`git diff --check` 通过。
这不代表所有平面双解已消除,也不替代下述相机时序和实机重复性验证。
## 仍不能由本轮软件审查保证的事项
### P1:真实采集时间与传输积压
`hikrobot_camera.py::_publish_frame` 使用主机取到帧时的 ROS 时间戳,未将设备曝光时间映射到统一时钟。
SDK 帧结构有设备时间戳和帧号,但设备时钟单位、偏移、漂移与主机时钟同步还没有在当前硬件验证。
`runtime/ros/io.py::_state_callback` 对无时间戳反馈也采用接收时间。
因此,USB/驱动缓存可能把旧图像与新反馈配对;50 ms 的消息时间戳检查和“只取最新帧”不能排除该问题。
本会话已经实测到三台相机共享 480 Mbps Hub 上行,且正面/上方取帧超时。
后续应在恢复 USB 3.x 链路后,核验设备时间戳、帧号、曝光与反馈时序,并用独立运动数据检查配对误差。
不能猜测设备时钟单位、直接给时间戳减一个常数,或放宽质量阈值作为修复。
### P1:重复性和实机/仿真验收尚未完成
前三轮训练、第四轮独立验证、冻结 Tag 安装和双文件重读校验已接入。
但同手多次独立重采、重新摆放和重新贴 Tag、不同保持姿态/多关节组合动作的实际验收仍缺证据。
L6/O6 部分关节由小指参数迁移,不属于所有关节独立实测。
发布报告仍应如实保留 `arbitrary_multiaxis_validated: false`。
外部仿真要加载本次修正 URDF,用同一 SDK 指令话题及配套 JSON 驱动;用实机反馈直接驱动仿真,
只能显示反馈对应姿态,不能证明指令映射正确。桥本身是消息转换器,不是独立精度测量工具。
### P2:数学模块和旧配置仍有维护负担
`core/fitting/spatial.py` 约 3,656 行,包含轴线拟合、基座位姿、零位优化、可观测性和验证,
其中 `solve_urdf_zero_offsets` 从约第 1,921 行开始,嵌套函数及共享局部状态较多。
它是当前生产算法,不是可直接删除的废代码。后续拆分应按这些数学职责进行,
用固定输入的参数、残差、失败原因和产物回归验证等价性,不应复制成各型号独立算法。
当前约 58 个兼容模块仍用于旧导入路径、历史回放/迁移工具和旧回归;不能仅因目录名称或静态引用少就删除。
几行的公开兼容导出不是第二套拟合算法。旧型号诊断、旧 URDF plan 和部分旧 YAML 参数仍需明确退休范围后继续收缩。
受保护的历史配置在本轮保持不变;旧 YAML 中一些已不被在线节点读取的参数,不应当作当前算法的生效开关。
原始 G20/O12 的 mimic/限位冲突和 CAD 保留策略仍需机械资料确认;当前通用 URDF 修正
主要处理零位、限位和线性 mimic,不估计连杆长度、轴位置、mesh 或惯量。
## 按需求核对
| 需求 | 当前判断 |
| --- | --- |
| 多型号共用拟合和 URDF 修正 | 已实现统一生产链;SDK 新协议仍需 Adapter 和 ROS 启动绑定 |
| 新型号只关心 SDK、贴 Tag、避让 | 基本成立,但必须声明关节绑定、可观测性、迁移/保留策略和可修改字段,不能从未知安装的单轴 Tag 自动猜出 CAD 零位 |
| 统一 SDK 指令→rad JSON 与修正 URDF | 正式仅生成统一 v2 指令表;u8 为 256 项,rad 为显式节点;被动表由标准 mimic 推导并验收,详细证据保留在报告 |
| 开始前可移动、开始后固定 | 每次 Start 丢弃预览参考,正式锁定;可见固定基准有漂移监测;遮挡对象是否移动不能实时保证 |
| 正确、可重复 | 有独立验证和来源保护,但仍受采集时序与未完成实机验收限制 |
| 少暂停且原因明确 | 统一策略保留;开始前明确缺失设备,不用短时视觉丢失实时打断扫描 |
| 统一进度且明确型号 | 已修正型号、设备等待原因及方向统计显示 |
| 同源指令实机/仿真验证 | 有统一转换桥;实际动态/组合动作对比尚未完成 |
## 本轮验证
- 审查中完整标定包回归:**544 通过、1 跳过,383.25 秒**。跳过项要求显式提供
`O12_REPLAY_RAW` / `O12_REPLAY_REFERENCE`,没有用合成数据替代历史实测。
- 随后补充的启动清理、单一正式输出及归档改动:启动/runner/正式 Profile 拟合/精简产物定向组
**74 通过,108.78 秒**,包含四型号与虚拟型号的实际公共拟合和发布路径。
- 最后完成回调、暂停竞争、ROS host、等待诊断、实时内参保护:**25 通过,0.98 秒**。
与上一组有重叠,不相加冒充一次全量结果;没有宣称最后所有变更又跑过一次全量。
- `colcon build --packages-select linkerhand_calibration --symlink-install` 通过。
安装入口读取四产品的受保护配置、ROS launch 实参解析均通过;167 个生产/兼容 Python 文件 AST 通过;
`git diff --check` 通过。
- 新 launch 测试实际构造四型号及改名机位的 launch actions,但不执行这些动作。
隔离 ROS 节点/标准加载器测试不启动 SDK 或相机,不代表实机验收。
## 数学核心与 ROS 组合重构的验收记录
本轮以包含公共 `0.03 px` 姿态分支修复的工作区为基线,只拆分职责、明确数据和并发边界。
没有删除基线中仍存在的历史入口,没有修改配置、拟合公式、优化初值/顺序、残差权重或验收阈值。
- `core/fitting/spatial.py` 保留 45 个原定义符号的显式导出;实际计算在 `spatial_solver/`。
`solve.py` 的入口顺序为输入准备、训练求解、统计、独立验证及结果装配。
数据通过 `ZeroProblem`、`TrainingProblem`、`TrainingFit` 和各验收结果类型传递。
训练接口只接收训练观测,训练几何中的掌部姿态按训练轮筛选。
- `core/geometry/pnp.py` 保留 14 个原定义符号;实现拆为 IPPE、单 Tag 跟踪、刚性组和轨迹选择。
跟踪默认值集中在 `tag_pose/parameters.py`,ROS 参数只负责加载和 deg/rad 转换。
- `UnifiedCalibrationNode` 直接继承 ROS `Node`,组合消息 IO 与 `CalibrationCoordinator`。
协调器不加载 ROS 库,复用 `SessionExecution`、采集、运动、固定基准和断点组件。
运行参数、观测输入和快照使用明确类型;阶段只有 `CalibrationSession.phase` 一个来源。
- `FinalizationController` 组合原 worker 与发布器,协调器接收阶段事件并授权提交。
状态锁先于 worker 锁;PnP 在状态锁外计算,提交时检查会话版本、采集/运动对象及稳态边界。
暂停、中止与提交互斥;失败不触发自动恢复运动,成功只提交一次。
SDK 绑定显式接收反馈新鲜度、时钟、健康订阅和发布接口。
固定输入记录在本次工作区快照 `/tmp/calibration_structure_baseline_30qf3ozo/`。
持久化的对比报告及压缩前后输出位于 `calibration_output/structure_refactor_20260910/`,
其中 `comparison.json` 记录输入摘要、差异、产物摘要和拒绝原因,`protected_inputs.json` 保存输入哈希。
这些记录是合成数据的结构等价证据,不是实机精度验收。
| 固定输入 | 比较浮点值数量 | 最大绝对差异 | JSON/拟合结果/阶段事件及 URDF 文本 |
| --- | ---: | ---: | --- |
| G20 | 46,046 | 0 | 一致 |
| L6 | 15,304 | 0 | 一致 |
| O6 | 15,304 | 0 | 一致 |
| O12 | 14,494 | 0 | 一致 |
| 虚拟型号(任务重排) | 15,304 | 0 | 一致 |
对五组输入分别移除第四轮或注入第四轮 Tag 姿态滑移,10 个拒绝原因和阶段事件与基线一致。
167 个配置、Profile、原始 URDF/资源文件的哈希不变。
另一个虚拟型号同时重排 SDK 通道、机位名称和扫描任务,经过真实公共采集、finalizer、
最终 JSON/URDF 验收和发布,再检查同一 SDK 指令经过模拟传输、查表和标准 mimic 图的一致性。
验证结果:
- 整个标定包:581 passed,1 skipped,337.43 s。
- SDK 绑定时钟补充后,协调器、四型号 ROS 构造和安全策略回归:27 passed。
- 跳过项是 `test_o12_recorded_replay.py`:未提供 `O12_REPLAY_RAW` 与独立参考模型;不计作实机通过。
- 改动模块通过 Pyflakes,`git diff --check` 通过。
- `colcon build --packages-select linkerhand_calibration --symlink-install` 成功。
- 安装后的 12 个 console entry 可导入;两个产品 CLI 的 `--help` 正常。
两个 ROS 节点入口均通过四型号受保护 Profile 的转交检查;安装环境下四型号 ROS 构造/Start 回归 4 passed。
本轮不处理相机采集时刻同步,也不替代实机多次标定和同话题实机/仿真运动对照。
+236
View File
@@ -0,0 +1,236 @@
# LinkerHand 多型号统一标定
G20、L6、O6、O12 使用同一个产品启动器、在线状态机、采集器、拟合器、标准 URDF
验收和发布器。型号差异来自 `config/profiles/*.yaml` 和 SDK Adapter,不再调用型号节点。
本包校准关节零位、有效行程及标准线性 mimic;保留 CAD 连杆尺寸、轴位置、mesh、惯量。
JSON 描述 SDK 输入与修正 URDF 角度的转换,不能补偿错误的 URDF。
软件仿真通过不等于实机精度通过;当前原始 G20/O12 存在需要确认的 mimic/限位冲突。
详见 [执行与验收记录](CALIBRATION_REFACTOR_PROGRESS.md)。
## 启动
```bash
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
colcon build --packages-select linkerhand_calibration --symlink-install
source install/setup.bash
# 无运动、无 SDK 启动的配置检查
ros2 run linkerhand_calibration calibrate_hand --config \
src/linkerhand_calibration/config/o12_right_product.yaml --validate-only
# 正式启动:自动启动 SDK、相机、检测及标定,READY 后自动开始运动
ros2 run linkerhand_calibration calibrate_hand --config \
src/linkerhand_calibration/config/o12_right_product.yaml
```
其他型号替换为 `g20_right_product.yaml`、`l6_right_product.yaml`、`o6_right_product.yaml`。
`calibrate_g20_right` 是同一 runner 的旧命令别名。不要同时运行 GUI、单独 SDK 或其他控制器。
`--commands-disabled` 只预览,不发送运动;`--no-resume` 强制新采集。Ctrl+C 中止,不自动快速张手。
重新开始时先安全回基准,仍必须确保现场没有障碍物。
| Profile | SDK 位置通道 | 扫描任务 | Tag | 输出格式 |
| --- | ---: | ---: | ---: | --- |
| G20/right/g20_right_19/v1 | 20 | 16 | 19 | unified v2,256 项指令查表 |
| L6/right/l6_right_8/v1 | 6 | 3 | 8 | unified v2,256 项指令查表 |
| O6/right/o6_right_8/v1 | 6 | 3 | 8 | unified v2,256 项指令查表 |
| O12/right/o12_right_16/v1 | 12 | 11 | 16 | unified v2,rad 输入节点查表 |
任务数、主动关节数和实测关节数不必相等。一条运动可观测多个主动/被动关节。
L6/O6 未贴 Tag 的同机构关节使用显式迁移,不能把迁移称为独立实测。
每任务四个往返周期:前三轮训练,第四轮独立验证。
每个方向先连续扫描,再单通道访问稳态点。训练每方向默认 9 点,第四轮为交错点加端点;
每点至少 3 个稳定同步图像,轨迹结束后最多等待 2 秒,不能用精确指令/反馈相等判断稳定。
稳态点缺失与连续扫描不足统一在方向结束处理,最多同速重扫一次。
字节拟合不再改写原始 command/feedback,也不将接近端点的读数伪装成 0/255。
## 基准、避让与恢复
- 开始前可以调整手掌、相机和 Tag;预览数据不会成为正式参考。
- 开始后清空预览缓存,先安全回基准,再为每机位固定 Tag 收集至少 10 个有效帧。
缺少基准时保持并显示缺少的 ID,不因为等待时间长暂停。
- 锁定后禁止人工移动手掌、相机、支架、Tag 粘贴位置;程序驱动关节和避让仍是允许的。
- 相机相对位置改变时须重新确认/标定外参;仅重新锁定掌心 Tag 不能替代外参标定。
- 固定 Tag 被避让遮住时可用已锁定参考继续采集;软件不能保证检测完全不可见对象的移动。
运动 Tag 的刚性和几何一致性仍须通过最终独立验证。
- 断点版本为 `unified_engine_v4_dual_mapping`,不复用缺少稳态证据的旧 v1/v2/v3。
新断点检查配置哈希、基准及所复用 Tag 安装关系(5 px、2°、5 mm)。缺失或不一致即放弃复用,
自动完整重采,旧会话不修改。恢复时重新执行准备与避让,不直接跳到旧姿态。
O12 避让:小指和无名指到 Profile 的最大弯曲指令;标定食指前,中指 MCP/PIP 到最大弯曲指令,
中指侧摆为 0 rad。到位采用完成轨迹、正确方向、至少 80% 请求位移和短时稳定,
不要求反馈数值精确等于命令。SDK 指令最大值与实测 URDF 角度不是同一个量。
O12 使用 HCAN device 0/channel 0 的已锁定 vendor Python wheel,不检查 `can0`。
只读反馈桥可在第一次运动命令之前取得状态,不会发送零位命令来“激活回读”。
POSITION 和活动故障由 SDK 确认;无法单独回读温度时使用 SDK 错误位过热保护,不伪造温度。
字节 SDK 当前没有独立硬件故障遥测,状态会明确注明这一能力边界。
## 哪些情况暂停
实时仅因人工中止、竞争控制器、活动硬件故障/模式错误、真实失联、反馈超过 1 秒未更新、
物理越限、明显运动要求下连续 2 秒无推进、已锁固定基准连续 10 帧漂移超过 5 px 而暂停。
开始后相机投影参数变化也会拒绝继续使用混合坐标数据。
短时 Tag 丢失、PnP/同步失败、低检测率/低反馈 Hz、正常跟随滞后、固有耦合和非目标小幅运动
不会实时中断轨迹。无效视觉帧直接丢弃。
所有型号的独立 Tag 姿态筛选共用 `core/geometry/pnp.py` 中的近似同误差阈值,默认 `0.03 px`。
ROS 和离线采集使用相同默认值,型号 YAML 不重复声明该值。`1.5 px` 是图像质量拒绝阈值,
不能用它判断两种姿态是否同样可信;仅在误差差值不超过近似同误差阈值时使用时间连续性。
当另一分支的图像拟合明确更好时允许纠正旧分支。覆盖参数必须有限、非负且小于图像质量拒绝阈值。
这项修复不保证解决所有平面 Tag 双解,真实角点噪声、采集时序和最终运动精度仍需实测验证。
方向结束才检查 ≥40 个有效同步样本、≥32 分箱、内部空白 ≤行程的 1/16、规定的有效行程覆盖,
以及 Profile 声明的端点/双视角观测。数据不足只同速重扫一次;第二次失败暂停。
拟合或最终文件验收失败不重新自动运动、不更新发布指针。
所有型号显示相同进度:阶段、任务/轮次/方向、分机位 Tag ID、命令/反馈/单位/速度、
有效数据、基准、断点来源,以及中文原因、建议和原始诊断。耗时拟合在后台执行,不阻塞反馈与状态。
标题明确显示型号、左右手和序列号。开始前等待状态列出缺少或过期的相机内参、检测消息和 SDK 条件;
空 Tag 检测消息可以证明视觉链路工作,不要求开始前已经识别全部 Tag。
收到开始请求时重新检查设备状态;相机有效内参必须与受保护外参中的指纹一致。
开始前相机消息的新鲜度窗口为 2 秒,该窗口不用于扫描中的实时暂停。
## 唯一生产代码链
```text
linkerhand_calibration/
├── config/profiles/ 唯一型号定义(受保护 YAML)
├── config/*_product.yaml 设备、序列号、相机、路径与哈希
├── urdf/ 原始 CAD/mesh,不覆盖
└── linkerhand_calibration/
├── profiles/ 加载、静态验证、Tag/CAD 观测关系
├── core/domain/ Profile、样本、CalibrationResult、状态
├── core/geometry/ 相机、变换、PnP 兼容导出
│ └── tag_pose/ IPPE、连续跟踪、刚性组、轨迹分支选择
├── core/fitting/ 运动曲线、线性耦合、固定安装
│ └── spatial_solver/ 空间观测、基座、零位、统计与独立验收
├── core/urdf/ 授权修正、FK、范围与最终文件验收
├── runtime/runner.py 唯一产品启动器
├── runtime/coordinator.py 会话协调、采集提交、运动和收尾接线
├── runtime/parameters.py 已解析的运行参数;无 ROS 类型
├── runtime/inputs.py 相机/检测输入、时钟与发布接口
├── runtime/cameras.py 受保护相机身份与消息新鲜度
├── runtime/session.py 唯一在线状态机
├── runtime/execution.py Profile 任务与运动效果调度
├── runtime/motion_execution.py 平滑轨迹与位移到位判定
├── runtime/capture.py 多机位视觉/反馈采集
├── runtime/scan_quality.py 唯一方向数据门
├── runtime/reference_lock.py 开始后固定基准
├── runtime/resume.py 唯一断点证据检查
├── runtime/safety.py 最小实时保护
├── runtime/status.py 统一状态与中文显示
├── runtime/snapshot.py 类型化状态快照与旧状态映射
├── runtime/adapters/ SDK 协议,不包含标定业务
├── runtime/ros/ ROS 参数加载、消息转换、订阅/服务/定时器
├── runtime/artifacts/ 收尾控制、后台 finalizer、serializer、原子发布
└── compat/ 历史格式、布局与离线诊断;不提供旧在线流程
```
`calibrate_hand → runner → runtime/ros/entrypoint → UnifiedCalibrationNode → CalibrationCoordinator → SessionExecution`
是所有型号的生产调用链。节点直接继承 ROS `Node`,组合 `RosCalibrationIO` 和协调器,
不通过父子类业务回调执行标定。SDK 绑定接收明确的反馈新鲜度、时钟、健康订阅和发布接口。
空间入口 `core/fitting/spatial.py` 保留显式兼容导出,计算顺序在
`spatial_solver/solve.py`:输入准备 → 仅训练求解 → 可观测性/轮间统计 → 冻结结果的独立验证 → 结果装配。
`TrainingProblem` 不包含第四轮观测,训练几何中的掌部姿态也按训练轮筛选。
基座策略由 Profile 的几何约束选择;曲线、零位坐标约定、权重、阈值和原始 URDF 授权修正规则不变。
PnP 的原导入路径继续可用。`tag_pose/parameters.py` 集中定义跟踪默认值,
ROS 加载器将角度参数由 deg 转为 rad,离线采集直接使用同一套默认值。
公共分支容差仍为 `0.03 px`,重投影质量、倾角、过滤原因和连续跟踪恢复策略保持一致。
`CalibrationSession.phase` 是流程状态来源。协调器在状态锁内开始、中止、推进和提交观测;
耗时姿态计算在锁外进行,提交时核对会话版本、采集对象、运动对象及稳态采样边界。
Start 更换预览采集对象并重新锁定基准,暂停/中止使尚未完成的旧观测失效。
收尾由 `FinalizationController` 组合 worker 和发布器,worker 只上报阶段事件、准备候选文件;
协调器核实全部验收阶段后才能提交。锁顺序固定为状态锁 → worker 锁,
拟合失败维持当前姿态,中止后禁止提交,成功产物只提交一次。
老的单相机拇指/CMC 在线节点和 launch 已退出安装入口;需要局部测量时应增加经过验证的 Profile,
不能重新启用旧状态机。老版本离线诊断不是新的标准 URDF 发布证据。
## 新增型号
必需文件:
```text
config/profiles/new_hand.yaml
config/new_hand_product.yaml
urdf/new_hand/raw.urdf
urdf/new_hand/meshes/*
```
Profile 声明:
1. Adapter、SDK 通道顺序、单位、正负方向、命令/反馈范围、基准值、独立速度槽布局。
2. Tag ID/尺寸/机位/安装 link,以及每次运动的父子观测关系;不明确的 link 不能靠名称猜。
3. 扫描任务、速度、准备/避让/收尾 waypoint;必要时指定不可见 Tag 的恢复验证姿态。
4. 哪些零位可观测、哪些固定保留 CAD、哪些参数同机构迁移;声明空间观测约束和拟合原语。
5. 每关节允许修正的 `origin.rpy`、`limit.lower/upper`、`mimic.multiplier/offset`。
这仍需要正确的可观测性设计:单轴加一对未知安装角的 Tag 不能自动辨识绝对 CAD 零位。
不能为了减少配置,默认把 SDK 零点或最大指令当成 CAD 零位。可参考最简单的 L6/O6 Profile,
删除不适用关系后重新声明;不要盲目复制另一型号的空间约束。
复用 SDK 不增加 Python;新协议只增加 `runtime/adapters/` 实现及协议工厂注册。
没有型号节点、runner、状态机、质量/断点/发布器;新数学只能增加通用拟合原语。
新型号使用 `artifacts.output_schema_version: 2` 选择统一指令查表格式;产物自身为
`format: unified_calibration_v2, schema_version: 2`。详细双映射保留在独立标定报告中。
旧 generic v1、v4/v6/v7 只保留历史兼容工具,不把反馈表改名为指令表来伪造兼容。
正式 Profile 校验及 finalizer 只接受 `output_schema_version: 2`,不能用旧格式跳过指令映射拟合。
配置错字、错误绑定、未知修正字段在启动前失败;修改受保护文件后应审查并更新对应 SHA256。
## 产物与离线验证
会话目录保存原始样本、冻结 Tag 安装、拟合诊断、标准 URDF 验收、JSON、URDF。
有效发布由 `release_manifest.json` 及产品发布指针共同证明;单独的 `PASS` 摘要或存在 URDF 文件不等于已发布。
manifest 记录 JSON、URDF、标定报告三文件 SHA256、受保护输入和独立 holdout 证据。任一验收失败都不会替换原发布指针。
`measurement_contract.json` 提前列出 Tag/link、零位观测关系、CAD 保留/迁移与原始 mimic 限位冲突。
主 JSON 以原始 URDF 关节名为键,每个关节仅包含 `sdk_channel` 和 `angle_rad`;
O12 等 rad 输入还包含一一对应的 `input_values`(在有效节点间线性插值,不外推)。
字节型号固定 256 项:SDK 指令 `v` 对应 `angle_rad[v]`,只能在训练指令实际覆盖 0–255 时导出。
输出均是修正 URDF 的 q_output,不再加零偏。顶层保留 `format/schema_version/profile_id/model/side/serial_number/input_unit`。
被动关节的查表由导出 URDF 的标准 mimic 递归生成,绑定同一驱动通道,必须与 URDF 完全一致。
单张查表取训练正反向指令曲线的平均值,再用实际导出表重放第四轮视觉数据;回差等因素导致超差时禁止发布,
不通过隐藏方向分支、外推、放宽精度或 JSON 非线性被动联动制造通过结果。
`calibration_report.json` 保存完整的 `command_to_rad` / `feedback_to_rad`、输入域、方向分支、
零位、来源、迁移和训练/holdout 指标。报告中的 `applicability` 记录保持姿态;
未采集多姿态证据不能宣称任意多轴工况已验证。主 JSON 用于指令查表,不能误作反馈转换。
旧 unified v1 会话继续只读兼容,不自动覆盖或转换历史产物;新输出单表必须重新通过最终文件验收。
统一消费桥可直接读取通过验证的 manifest(纯消息转换,不会发送实机命令):
```bash
ros2 run linkerhand_calibration calibrated_joint_state_bridge --ros-args \
-p calibration_file:=<session/release_manifest.json> -p input_kind:=command \
-p input_topic:=<SDK原始指令话题> -p output_topic:=/calibration/verification/joint_states
```
需要反馈角度显示时显式选择 `input_kind:=feedback`,桥读取配套报告中的反馈映射。
桥检查三文件哈希、拒绝越域输入,
被动角由实际导出 URDF 计算,不加第二次零偏。仿真输出不能与实机反馈话题同名。
MuJoCo 是用户后续使用的外部验收项目,本轮不改动它、不增加 MuJoCo 依赖,自动标定不要求打开 GUI。
外部对比须确认加载的模型来自这份 URDF,指令转换使用配套 JSON;不能用实机反馈直接驱动仿真来证明指令映射。
```bash
# 同一算法离线回放新策略的完整采集;不启动硬件,默认不发布
ros2 run linkerhand_calibration calibrate_hand --config <product.yaml> \
--offline-raw <raw_samples.jsonl> --offline-output <新的输出目录>
# 只比较两份 URDF,不修改,不控制机械手
ros2 run linkerhand_calibration compare_calibration_urdfs \
--reference <参考.urdf> --candidate <本次.urdf> --output <新报告.json>
```
离线回放也检查原始会话策略、SDK 输入域、配置/相机证据、每方向完整数据及最终文件。
旧会话不能自动获得当前哈希或新版本认证。`--publish-offline` 必须显式给出,且不会绕过验证。
算法和坐标规则见 [CALIBRATION_FLOW.md](CALIBRATION_FLOW.md),批准的原始方案保持在
[CALIBRATION_REFACTOR_PLAN.md](CALIBRATION_REFACTOR_PLAN.md)。
当前代码审查、已修复问题与未完成的精度验收见
[2026-09-10 需求审查](CALIBRATION_CODE_REVIEW_20260910.md)。相机图像目前采用主机取帧时间;
USB/驱动积压情况下,图像与反馈的时间戳接近不能证明曝光时刻同步,不能据此宣称实机精度通过。
@@ -0,0 +1,133 @@
g20_thumb_calibration:
ros__parameters:
command_topic: /g20/cb_left_hand_control_cmd
state_topic: /g20/cb_left_hand_state
info_topic: /g20/cb_left_hand_info
camera_info_topic: /camera/camera/color/camera_info
image_topic: /camera/camera/color/image_rect
detections_topic: /apriltag/detections
tf_topic: /tf
# Use PnP translations as 3-D Tag centres and fit the directly observable
# root/MCP circles. The passive IP output follows the G20 URDF mimic
# relation below; its small residual T5 circle is diagnostic only. PnP
# orientations remain auxiliary quality checks. Command 255 is zero.
angle_estimation_mode: trajectory_center_3d
passive_ip_multiplier: 1.02
publish_debug_image: false
debug_max_rate_hz: 10.0
debug_scale: 0.5
# Default: send one end-to-end command per direction and pair every valid
# AprilTag frame with the timestamp-interpolated actual G20 state.
scan_mode: continuous
continuous_motion_mode: endpoint
repetitions: 1
# Used only by point-mode fallback and validation approach offsets.
command_step: 8
auto_start_tip: true
maximum_state_image_skew_ms: 150.0
continuous_endpoint_tolerance_u8: 2.0
continuous_endpoint_hold_seconds: 1.0
continuous_timeout_seconds: 90.0
continuous_invalid_timeout_seconds: 3.0
continuous_minimum_valid_frames: 40
continuous_minimum_state_span_u8: 240.0
continuous_minimum_bins: 32
continuous_maximum_bin_gap: 16
# Keep the responsive firmware speed, but pace it through the same
# 8-unit grid without waiting for static image captures at each point.
continuous_segment_minimum_seconds: 0.1
continuous_segment_timeout_seconds: 5.0
continuous_prepare_timeout_seconds: 30.0
preflight_frames: 150
minimum_detection_rate: 0.95
minimum_detection_hz: 15.0
maximum_hamming: 0
minimum_decision_margin: 30.0
# Trial threshold for small/far tags (historically observed at 32-38 px).
# Final acceptance is still guarded by static RMS and random validation.
minimum_edge_pixels: 30.0
# Current 30 px tags measure about 0.50-0.53 deg RMS while stationary.
# Keep a small practical margin here; final random validation stays at
# MAE <= 2 deg and P95 <= 3 deg.
# Match the preflight noise gate to the 3 deg robust capture gate below.
# The final calibration is still accepted only by the independent
# validation MAE/P95 limits, not by this readiness check.
maximum_static_std_deg: 3.0
pose_outlier_threshold_deg: 5.0
minimum_pose_inlier_rate: 0.90
pnp_minimum_valid_rate: 0.95
# 30-38 px tags are usable, but only if IPPE gives a tight image fit and
# a pose continuous with the preceding frame.
pnp_maximum_reprojection_error_px: 1.5
# Independent IPPE selection inherits the shared near-tie tolerance from
# core/geometry/pnp.py; continuity cannot override a clear image-fit lead.
pnp_maximum_pose_jump_deg: 35.0
pnp_maximum_translation_jump_m: 0.04
pnp_maximum_tag_tilt_deg: 75.0
# Preserve the branch through short detector gaps. A continuous sweep
# already pauses after 3 s without valid synchronised observations.
pnp_tracker_reset_seconds: 5.0
# Select all four IPPE branches as one kinematic chain. This prevents T4
# and T5 from independently changing mirror branches at the turnaround or
# during validation while still allowing real joint motion frame-to-frame.
pnp_group_relative_rotation_scale_deg: 5.0
pnp_group_relative_translation_scale_m: 0.01
# Reprojection remains a tie-breaker; temporal joint-chain continuity is
# deliberately dominant for the current 30-38 px planar tags.
pnp_group_reprojection_weight: 0.05
# Whole-sweep branch review. During a root sweep T3/T4/T5 should retain
# rigid relative poses; during a tip sweep T0/T3 should remain fixed.
pnp_trajectory_reprojection_scale_px: 0.1
pnp_rigid_rotation_scale_deg: 5.0
pnp_rigid_translation_scale_m: 0.01
# Judge the complete rigid trajectory against a robust sweep reference.
# Reject persistent drift at P95; keep a looser hard maximum so one noisy
# 30 px endpoint frame does not discard an otherwise sound sweep.
pnp_rigid_p95_accepted_drift_deg: 8.0
pnp_rigid_maximum_accepted_drift_deg: 15.0
# Centre-trajectory mode judges branch consistency by the Euclidean
# distance between rigid Tag centres. This is deliberately independent
# of the noisy planar-Tag orientation returned by PnP.
pnp_rigid_p95_accepted_distance_drift_m: 0.003
pnp_rigid_maximum_accepted_distance_drift_m: 0.006
# Three-dimensional centre-trajectory geometry gates. T0 stays on the
# palm as the translation anchor; T3/T4/T5 are the moving thumb points.
trajectory_maximum_plane_rms_m: 0.004
trajectory_maximum_radial_rms_m: 0.004
trajectory_minimum_radius_m: 0.005
trajectory_minimum_arc_deg: 15.0
trajectory_maximum_root_role_disagreement_deg: 5.0
trajectory_maximum_anchor_drift_m: 0.005
trajectory_static_translation_outlier_m: 0.005
trajectory_maximum_static_translation_rms_m: 0.002
# Static captures are now used only for sweep preparation and validation.
stable_frames: 5
capture_frames: 8
minimum_settle_seconds: 0.4
# This only confirms that the hand has stopped before an 8-frame robust
# median capture. The passive T4->T5 pair currently has about 2.3 deg
# peak spread over five 30 px PnP frames, while its two IPPE branches are
# separated by about 5.5 deg. A 3 deg gate accepts measurement jitter but
# still rejects a branch change. Final MAE/P95 limits remain unchanged.
maximum_stable_spread_deg: 3.0
# In centre mode the stationary capture gate is expressed in metres.
maximum_stable_translation_spread_m: 0.003
settle_timeout_seconds: 10.0
capture_timeout_seconds: 10.0
validation_command_count: 5
# The backlash approach point only waits for feedback to reach the target;
# it no longer performs an unnecessary image capture.
validation_approach_minimum_seconds: 0.2
validation_approach_timeout_seconds: 10.0
validation_position_tolerance_u8: 2.0
validation_seed: 20260727
maximum_validation_mae_deg: 2.0
maximum_validation_p95_deg: 3.0
maximum_coupling_drift_deg: 2.0
minimum_ip_coupling_r_squared: 0.98
maximum_monotonic_correction_deg: 2.0
maximum_hysteresis_deg: 5.0
@@ -0,0 +1,59 @@
g20_thumb_cmc_pitch_zero:
ros__parameters:
t0_id: 0
t3_id: 1
joint_name: thumb_cmc_pitch
motor_index: 0
zero_command_u8: 255
measure_travel: false
baseline_command_u8:
[255, 255, 255, 255, 255, 255, 193, 148, 105, 42,
245, 255, 255, 255, 255, 255, 255, 255, 255, 255]
repetitions: 3
zero_capture_frames: 30
# Each round sends one 255->64 endpoint command and one 64->255 return
# command. All valid T3-minus-T0 centres observed during both motions are
# state-binned and fitted to one image-plane circle.
trajectory_command_u8: 64
trajectory_bin_size_u8: 8.0
trajectory_minimum_frames: 45
trajectory_minimum_bins: 18
trajectory_minimum_state_span_u8: 160.0
trajectory_minimum_radius_px: 20.0
trajectory_minimum_arc_deg: 20.0
trajectory_maximum_radial_rms_px: 2.0
trajectory_maximum_p95_radial_error_px: 3.5
trajectory_endpoint_settle_seconds: 0.3
trajectory_timeout_seconds: 30.0
settle_seconds: 0.5
move_timeout_seconds: 20.0
capture_timeout_seconds: 15.0
state_tolerance_u8: 2.0
preflight_frames: 60
minimum_detection_rate: 0.95
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 40.0
maximum_static_position_rms_px: 1.5
maximum_round_difference_deg: 1.0
maximum_return_error_deg: 1.0
# Zero direction is always T3 centre -> fitted circle centre. T3's printed
# orientation and corner +x direction are deliberately not used.
maximum_zero_radial_error_px: 4.0
# Detect a long physical table/reference edge in the lower image. The red
# target and blue detected line are display-only aids for manual alignment;
# they never block preflight or the start service.
camera_alignment_enabled: true
camera_alignment_reference_y_ratio: 0.90
camera_alignment_roi_y_min_ratio: 0.55
camera_alignment_roi_y_max_ratio: 0.98
camera_alignment_minimum_line_length_ratio: 0.30
camera_alignment_max_candidate_angle_deg: 15.0
camera_alignment_max_angle_deg: 0.5
camera_alignment_max_vertical_offset_px: 12.0
camera_alignment_required_frames: 10
camera_alignment_minimum_detection_rate: 0.8
camera_alignment_max_age_seconds: 1.0
publish_debug_image: true
debug_max_rate_hz: 10.0
debug_scale: 0.75
@@ -0,0 +1,60 @@
g20_thumb_cmc_roll_calibration:
ros__parameters:
t0_id: 0
t3_id: 1
joint_name: thumb_cmc_roll
motor_index: 5
zero_command_u8: 255
measure_travel: true
baseline_command_u8:
[255, 255, 255, 255, 255, 255, 193, 148, 105, 42,
245, 255, 255, 255, 255, 255, 255, 255, 255, 255]
repetitions: 3
zero_capture_frames: 30
# Measure the complete motor-5 range. Each round captures both static
# endpoints around one 255->0->255 circle trajectory.
trajectory_command_u8: 0
trajectory_bin_size_u8: 8.0
trajectory_minimum_frames: 65
trajectory_minimum_bins: 30
trajectory_minimum_state_span_u8: 240.0
trajectory_minimum_radius_px: 20.0
trajectory_minimum_arc_deg: 20.0
trajectory_maximum_radial_rms_px: 2.0
trajectory_maximum_p95_radial_error_px: 3.5
trajectory_endpoint_settle_seconds: 0.3
trajectory_timeout_seconds: 35.0
settle_seconds: 0.5
move_timeout_seconds: 25.0
capture_timeout_seconds: 15.0
state_tolerance_u8: 2.0
preflight_frames: 60
minimum_detection_rate: 0.95
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 40.0
maximum_static_position_rms_px: 1.5
maximum_round_difference_deg: 1.0
maximum_travel_difference_deg: 1.0
minimum_travel_deg: 20.0
maximum_return_error_deg: 1.0
# Zero direction is always T3 centre -> fitted circle centre. T3's printed
# orientation and corner +x direction are deliberately not used.
maximum_zero_radial_error_px: 4.0
# Detect a long physical table/reference edge in the lower image. The red
# target and blue detected line are display-only aids for manual alignment;
# they never block preflight or the start service.
camera_alignment_enabled: true
camera_alignment_reference_y_ratio: 0.90
camera_alignment_roi_y_min_ratio: 0.55
camera_alignment_roi_y_max_ratio: 0.98
camera_alignment_minimum_line_length_ratio: 0.30
camera_alignment_max_candidate_angle_deg: 15.0
camera_alignment_max_angle_deg: 0.5
camera_alignment_max_vertical_offset_px: 12.0
camera_alignment_required_frames: 10
camera_alignment_minimum_detection_rate: 0.8
camera_alignment_max_age_seconds: 1.0
publish_debug_image: true
debug_max_rate_hz: 10.0
debug_scale: 0.75
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" ?>
<dds>
<profiles xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">
<transport_descriptors>
<transport_descriptor>
<transport_id>g20_udp_transport</transport_id>
<type>UDPv4</type>
<sendBufferSize>10485760</sendBufferSize>
<receiveBufferSize>10485760</receiveBufferSize>
</transport_descriptor>
<transport_descriptor>
<transport_id>g20_shm_transport</transport_id>
<type>SHM</type>
<segment_size>67108864</segment_size>
<port_queue_capacity>512</port_queue_capacity>
<healthy_check_timeout_ms>1000</healthy_check_timeout_ms>
</transport_descriptor>
</transport_descriptors>
<participant
profile_name="g20_large_image_participant"
is_default_profile="true">
<rtps>
<userTransports>
<transport_id>g20_udp_transport</transport_id>
<transport_id>g20_shm_transport</transport_id>
</userTransports>
<useBuiltinTransports>false</useBuiltinTransports>
</rtps>
</participant>
</profiles>
</dds>
@@ -0,0 +1,30 @@
/apriltag/apriltag:
ros__parameters:
image_transport: raw
# Live calibration needs the newest frame, not lossless delivery of stale
# frames. BEST_EFFORT prevents a slow full-resolution detection callback
# from back-pressuring image_proc's reliable image publisher.
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [0, 1, 2, 3]
frames: [tag_t0, tag_t3, tag_t4, tag_t5]
sizes: [0.016, 0.016, 0.016, 0.016]
g20_thumb_calibration:
ros__parameters:
tag_roles: [t0, t3, t4, t5]
tag_ids: [0, 1, 2, 3]
tag_frames: [tag_t0, tag_t3, tag_t4, tag_t5]
tag_sizes_m: [0.016, 0.016, 0.016, 0.016]
@@ -0,0 +1,40 @@
schema_version: 3
profile_id: G20/right/g20_right_19/v1
profile_config: package://linkerhand_calibration/config/profiles/g20_right_19.yaml
profile_config_sha256: 35cad4432a01b3e1af157f1e914c2054e71522a44c6786db2519e4c2a0966a46
model: G20
side: right
tag_layout: g20_right_19
serial_number: G20_RIGHT_001
can_interface: can0
output_root: calibration_output
cameras:
front:
serial_number: DB2163742
camera_name: hikrobot_front_DB2163742
camera_info: ~/.ros/camera_info/hikrobot_DB2163742.yaml
side:
serial_number: DB2163749
camera_name: hikrobot_side_DB2163749
camera_info: ~/.ros/camera_info/hikrobot_DB2163749.yaml
top:
serial_number: DB2163739
camera_name: hikrobot_top_DB2163739
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
artifacts:
source_urdf: package://linkerhand_calibration/urdf/g20_right/linkerhand_g20_right.urdf
source_urdf_sha256: eeb6ffb0e95d2a6acd4c26331ae68062e0d74160de4b552b4f6d395cce5ca4e8
camera_extrinsics: config/g20_three_camera_extrinsics.yaml
camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80
calibration_config: src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml
calibration_config_sha256: 4927506d787d665c16f0209ee4d052654e648bb25beaa1913ed0d416f449d523
tag_config: src/g20_thumb_apriltag_calibration/config/three_camera_tags_g20_right_19.yaml
tag_config_sha256: b1ab45e97ae42d57b0a3a63c725107b5aa3828c06b2ced16f8222d6e9ebadc41
release:
# Each task already contains three training cycles plus an isolated fourth
# holdout, so a second complete hardware session duplicates hours of motion.
required_independent_passes: 1
static_repeatability_deg: 1.0
@@ -0,0 +1,59 @@
/l6_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [0, 1, 2]
frames: [front_base, thumb_pitch, thumb_dip]
sizes: [0.016, 0.016, 0.016]
/l6_calibration/side/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [3, 4, 5]
frames: [side_base, pinky_pitch, pinky_dip]
sizes: [0.016, 0.016, 0.016]
/l6_calibration/top/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [6, 7]
frames: [top_base, thumb_roll]
sizes: [0.016, 0.016]
@@ -0,0 +1,39 @@
schema_version: 3
profile_id: L6/right/l6_right_8/v1
profile_config: package://linkerhand_calibration/config/profiles/l6_right_8.yaml
profile_config_sha256: b9654ce998b56c10d7094364e8b715663163d2101e1f70e077cb0723fc47f02e
model: L6
side: right
tag_layout: l6_right_8
namespace: /l6_calibration
serial_number: L6_RIGHT_001
can_interface: can0
output_root: calibration_output
cameras:
front:
serial_number: DB2163742
camera_name: hikrobot_front_DB2163742
camera_info: ~/.ros/camera_info/hikrobot_DB2163742.yaml
side:
serial_number: DB2163749
camera_name: hikrobot_side_DB2163749
camera_info: ~/.ros/camera_info/hikrobot_DB2163749.yaml
top:
serial_number: DB2163739
camera_name: hikrobot_top_DB2163739
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
artifacts:
source_urdf: package://linkerhand_calibration/urdf/l6_right/linkerhand_l6v3.1_right.urdf
source_urdf_sha256: 298c1fbf5189648911426f530b50bdbeea4830cab9c54e20f46c532485df4666
camera_extrinsics: config/g20_three_camera_extrinsics.yaml
camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80
calibration_config: package://linkerhand_calibration/config/l6_three_camera_calibration.yaml
calibration_config_sha256: 090d82a5609e8b981c1b8c6ebeaf4c2f477323209ae3db3b9d8713ac6d6169b5
tag_config: package://linkerhand_calibration/config/l6_right_8_tags.yaml
tag_config_sha256: be1499eb947b61d2fe360ae2c92307a87710480fae8a9dd4cd171fc959fdcbf5
release:
required_independent_passes: 1
static_repeatability_deg: 1.0
@@ -0,0 +1,66 @@
l6_calibration:
ros__parameters:
command_topic: /l6/cb_right_hand_control_cmd
state_topic: /l6/cb_right_hand_state
setting_topic: /l6/cb_hand_setting_cmd
front_camera_info_topic: /l6_calibration/front/camera/camera_info
front_detections_topic: /l6_calibration/front/apriltag/detections
side_camera_info_topic: /l6_calibration/side/camera/camera_info
side_detections_topic: /l6_calibration/side/apriltag/detections
top_camera_info_topic: /l6_calibration/top/camera/camera_info
top_detections_topic: /l6_calibration/top/apriltag/detections
baseline_command_u8: [255, 255, 255, 255, 255, 255]
# L6_RIGHT_001 measured a 250->5 travel of only ~0.9 s at speed 10,
# which left fewer than 32 useful feedback bins. Speed 1 is still only a
# firmware ceiling: different L6 motors complete a full stroke in 0.7-1.3 s.
# A 100 Hz cosine trajectory therefore sets the actual, model-level pace.
preflight_speed_u8: 1
formal_speed_u8: 1
speed_settle_seconds: 0.2
command_trajectory_full_range_seconds: 6.0
torque_u8: 80
repetitions: 4
preflight_checkpoints_u8: [255, 127, 0]
tag_size_m: 0.016
tag_size_override_ids: [0, 1, 2, 3, 4, 5, 6, 7]
tag_size_overrides_m: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
minimum_detection_rate: 0.95
# Per-Tag quality remains >=95%. With three independently detected Tags,
# the fully joined frame rate may be 0.95^3 ~= 85.7%.
minimum_joint_frame_rate: 0.85
minimum_feedback_hz: 25.0
maximum_state_image_skew_ms: 50.0
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 30.0
pnp_maximum_reprojection_error_px: 1.5
pnp_maximum_pose_jump_deg: 35.0
pnp_maximum_translation_jump_m: 0.04
pnp_maximum_tag_tilt_deg: 75.0
pnp_tracker_reset_seconds: 5.0
minimum_sweep_frames: 40
minimum_state_span_u8: 240.0
minimum_sweep_bins: 32
maximum_bin_gap: 16
maximum_monotonic_correction_deg: 2.0
passive_maximum_monotonic_correction_deg: 3.0
maximum_validation_mae_deg: 1.0
maximum_validation_p95_deg: 2.0
maximum_validation_error_deg: 3.0
mimic_minimum_multiplier: 0.5
mimic_maximum_multiplier: 1.5
mimic_maximum_cycle_range: 0.03
mimic_maximum_residual_p95_deg: 2.0
endpoint_tolerance_u8: 2.0
endpoint_hold_seconds: 1.0
motor_stall_timeout_seconds: 2.0
position_timeout_seconds: 30.0
sweep_timeout_seconds: 90.0
automatic_sweep_retry_limit: 1
non_target_motion_tolerance_u8: 3.0
fixed_base_maximum_corner_drift_px: 5.0
fixed_base_movement_confirmation_frames: 10
@@ -0,0 +1,41 @@
/o12_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector: {threads: 4, decimate: 1.0, blur: 0.0, refine: true, sharpening: 0.25, debug: false}
pose_estimation_method: pnp
tag:
ids: [0, 1, 2, 3, 12, 13]
frames: [front_base, thumb_cmc, thumb_mcp, thumb_dip, middle_roll, index_roll]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
/o12_calibration/side/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector: {threads: 4, decimate: 1.0, blur: 0.0, refine: true, sharpening: 0.25, debug: false}
pose_estimation_method: pnp
tag:
ids: [4, 5, 6, 7, 8, 9, 10, 11]
frames: [side_base, pinky_mcp, pinky_pip, pinky_dip, middle_pip, middle_dip, index_pip, index_dip]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
/o12_calibration/top/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector: {threads: 4, decimate: 1.0, blur: 0.0, refine: true, sharpening: 0.25, debug: false}
pose_estimation_method: pnp
tag:
ids: [14, 15]
frames: [top_base, thumb_yaw]
sizes: [0.016, 0.016]
@@ -0,0 +1,47 @@
schema_version: 3
profile_id: O12/right/o12_right_16/v1
profile_config: package://linkerhand_calibration/config/profiles/o12_right_16.yaml
profile_config_sha256: 0b5cd27168bc863e5c1aed5382c08e33ab4c02a23359a325dc9925fd30599e5f
model: O12
side: right
tag_layout: o12_right_16
namespace: /o12_calibration
serial_number: O12_RIGHT_001
output_root: calibration_output
sdk:
driver: o12_sdk_bridge
python_package: src/agillink_omnihand_sdk/linux/x64/python/omnihand-1.1.8-cp312-cp312-linux_x86_64.whl
package_sha256: cae7a0d5bce7e7c9d72cc90a0dd15e152f11e2a1ecf04cb6761e8171399ff170
transport: hcan
setup: src/agillink_omnihand_sdk/linux/x64/ros2/jazzy/setup.bash
config: src/agillink_omnihand_sdk/linux/x64/ros2/jazzy/share/omnihand_node/config/omnihand_pro_2025_node.yaml
config_sha256: 1e3c0942b32128943fbba27846a69a8483af6da9551d99ed687e45c4321ade9c
cameras:
front:
serial_number: DB2163742
camera_name: hikrobot_front_DB2163742
camera_info: ~/.ros/camera_info/hikrobot_DB2163742.yaml
side:
serial_number: DB2163749
camera_name: hikrobot_side_DB2163749
camera_info: ~/.ros/camera_info/hikrobot_DB2163749.yaml
top:
serial_number: DB2163739
camera_name: hikrobot_top_DB2163739
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
artifacts:
source_urdf: package://linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703.urdf
source_urdf_sha256: 75b3c18992a3640d2d7d36719da5ff75428ded477b97d9cf29adbe43a6eb27eb
camera_extrinsics: config/o12_three_camera_extrinsics.yaml
camera_extrinsics_sha256: 5a515d0706f4e67d5e26bfddb348b519817bd72e885ea9e43997e41016176e53
calibration_config: package://linkerhand_calibration/config/o12_three_camera_calibration.yaml
calibration_config_sha256: c2fcdd532e22c15013413e655311de2ca87107c23a96d5c043e8f340f232eebd
tag_config: package://linkerhand_calibration/config/o12_right_16_tags.yaml
tag_config_sha256: 41001c3afba74cc01eb524a75dc58561a37e9364fab029ea12d879156a008dab
release:
required_independent_passes: 1
static_repeatability_deg: 1.0
@@ -0,0 +1,53 @@
o12_calibration:
ros__parameters:
command_topic: /o12/right/joint_cmd
state_topic: /o12/right/joint_states
front_camera_info_topic: /o12_calibration/front/camera/camera_info
front_detections_topic: /o12_calibration/front/apriltag/detections
side_camera_info_topic: /o12_calibration/side/camera/camera_info
side_detections_topic: /o12_calibration/side/apriltag/detections
top_camera_info_topic: /o12_calibration/top/camera/camera_info
top_detections_topic: /o12_calibration/top/apriltag/detections
# O12 standard interface is radians. Scan the complete reviewed SDK range;
# the source CAD limits are outputs to correct, not acquisition limits.
# Raw 0..2000 mixed control is forbidden.
command_rate_hz: 50.0
repetitions: 4
tag_size_m: 0.016
tag_size_override_ids: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
tag_size_overrides_m: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
minimum_detection_rate: 0.95
minimum_joint_frame_rate: 0.85
minimum_feedback_hz: 35.0
maximum_state_image_skew_ms: 50.0
minimum_sweep_frames: 40
minimum_state_span_fraction: 0.90
minimum_sweep_bins: 32
maximum_bin_gap: 2
endpoint_tolerance_rad: 0.01
endpoint_hold_seconds: 0.5
motor_stall_timeout_seconds: 2.0
position_timeout_seconds: 60.0
sweep_timeout_seconds: 180.0
automatic_sweep_retry_limit: 1
non_target_motion_tolerance_rad: 0.015
maximum_temperature_c: 70
# 当前 O12 固件返回空温度报告。仍发起查询;5秒无结果后依赖已验证的
# joint_error_states bit1 过热保护,并在会话记录中明确标注降级。
temperature_report_required: false
temperature_fallback_after_seconds: 5.0
# 快速标定档:各任务按4倍请求,但拇指pitch、侧摆、屈曲及避让均有独立硬上限。
motion_speed_scale: 4.0
# 正弦速度加减速时间;中段保持关节限速,避免位置余弦全程低速。
trajectory_ramp_seconds: 0.4
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 30.0
fixed_base_maximum_corner_drift_px: 5.0
fixed_base_movement_confirmation_frames: 10
pnp_maximum_reprojection_error_px: 1.5
pnp_maximum_pose_jump_deg: 35.0
pnp_maximum_translation_jump_m: 0.04
pnp_maximum_tag_tilt_deg: 75.0
pnp_tracker_reset_seconds: 5.0
@@ -0,0 +1,59 @@
/o6_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [0, 1, 2]
frames: [front_base, thumb_pitch, thumb_ip]
sizes: [0.016, 0.016, 0.016]
/o6_calibration/side/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [3, 4, 5]
frames: [side_base, pinky_pitch, pinky_dip]
sizes: [0.016, 0.016, 0.016]
/o6_calibration/top/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [6, 7]
frames: [top_base, thumb_yaw]
sizes: [0.016, 0.016]
@@ -0,0 +1,39 @@
schema_version: 3
profile_id: O6/right/o6_right_8/v1
profile_config: package://linkerhand_calibration/config/profiles/o6_right_8.yaml
profile_config_sha256: ab1d86cb0c3e514de5322a44c1444e99d9b1f1ce43f8a2451cd032ed9c31e4ff
model: O6
side: right
tag_layout: o6_right_8
namespace: /o6_calibration
serial_number: O6_RIGHT_001
can_interface: can0
output_root: calibration_output
cameras:
front:
serial_number: DB2163742
camera_name: hikrobot_front_DB2163742
camera_info: ~/.ros/camera_info/hikrobot_DB2163742.yaml
side:
serial_number: DB2163749
camera_name: hikrobot_side_DB2163749
camera_info: ~/.ros/camera_info/hikrobot_DB2163749.yaml
top:
serial_number: DB2163739
camera_name: hikrobot_top_DB2163739
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
artifacts:
source_urdf: package://linkerhand_calibration/urdf/o6_right/linkerhand_o6_right.urdf
source_urdf_sha256: 8f184faad699fbf771e388f109a4e8793b5cb190c33a87b2eba8491a3a37dd62
camera_extrinsics: config/o6_three_camera_extrinsics.yaml
camera_extrinsics_sha256: 29af61f7bf1bad6718cbbaa54b0536f0a471c83f5bb3554f264ab9d292e56ca4
calibration_config: package://linkerhand_calibration/config/o6_three_camera_calibration.yaml
calibration_config_sha256: 15adaec462d0895dc68d19760e8f333c62e57786d124f22cfc481007a3d976a4
tag_config: package://linkerhand_calibration/config/o6_right_8_tags.yaml
tag_config_sha256: 16abe7119b4764f86333dae8264247571d1e0bca45af959d558bef4fb5485f5e
release:
required_independent_passes: 1
static_repeatability_deg: 1.0
@@ -0,0 +1,63 @@
o6_calibration:
ros__parameters:
command_topic: /o6/cb_right_hand_control_cmd
state_topic: /o6/cb_right_hand_state
setting_topic: /o6/cb_hand_setting_cmd
front_camera_info_topic: /o6_calibration/front/camera/camera_info
front_detections_topic: /o6_calibration/front/apriltag/detections
side_camera_info_topic: /o6_calibration/side/camera/camera_info
side_detections_topic: /o6_calibration/side/apriltag/detections
top_camera_info_topic: /o6_calibration/top/camera/camera_info
top_detections_topic: /o6_calibration/top/apriltag/detections
baseline_command_u8: [255, 255, 255, 255, 255, 255]
# O6 has a different speed scale from L6. Motion is still bounded by the
# six-second cosine command trajectory; these values are firmware limits.
baseline_speed_u8: 80
preflight_speed_u8: 60
formal_speed_u8: 40
speed_settle_seconds: 0.2
command_trajectory_full_range_seconds: 6.0
torque_u8: 80
repetitions: 4
preflight_checkpoints_u8: [255, 127, 0]
tag_size_m: 0.016
tag_size_override_ids: [0, 1, 2, 3, 4, 5, 6, 7]
tag_size_overrides_m: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
minimum_detection_rate: 0.95
minimum_joint_frame_rate: 0.85
minimum_feedback_hz: 25.0
maximum_state_image_skew_ms: 50.0
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 30.0
pnp_maximum_reprojection_error_px: 1.5
pnp_maximum_pose_jump_deg: 35.0
pnp_maximum_translation_jump_m: 0.04
pnp_maximum_tag_tilt_deg: 75.0
pnp_tracker_reset_seconds: 5.0
minimum_sweep_frames: 40
minimum_state_span_u8: 240.0
minimum_sweep_bins: 32
maximum_bin_gap: 16
maximum_monotonic_correction_deg: 2.0
passive_maximum_monotonic_correction_deg: 3.0
maximum_validation_mae_deg: 1.0
maximum_validation_p95_deg: 2.0
maximum_validation_error_deg: 3.0
mimic_minimum_multiplier: 0.5
mimic_maximum_multiplier: 2.2
mimic_maximum_cycle_range: 0.03
mimic_maximum_residual_p95_deg: 2.0
endpoint_tolerance_u8: 2.0
endpoint_hold_seconds: 1.0
motor_stall_timeout_seconds: 2.0
position_timeout_seconds: 30.0
sweep_timeout_seconds: 90.0
automatic_sweep_retry_limit: 1
non_target_motion_tolerance_u8: 3.0
fixed_base_maximum_corner_drift_px: 5.0
fixed_base_movement_confirmation_frames: 10
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,372 @@
schema_version: 1
profile_id: L6/right/l6_right_8/v1
namespace: /l6_calibration
sdk_adapter: legacy_byte_sdk
command:
sdk_to_joint_direction: [-1, -1, -1, -1, -1, -1]
names:
- thumb_cmc_pitch
- thumb_cmc_roll
- index_mcp_pitch
- middle_mcp_pitch
- ring_mcp_pitch
- pinky_mcp_pitch
baseline_u8:
- 255
- 255
- 255
- 255
- 255
- 255
command_index_by_joint:
rh_thumb_cmc_pitch: 0
rh_thumb_cmc_roll: 1
rh_index_mcp_pitch: 2
rh_middle_mcp_pitch: 3
rh_ring_mcp_pitch: 4
rh_pinky_mcp_pitch: 5
disabled_indices: []
urdf_joint_by_joint:
rh_thumb_cmc_pitch: rh_thumb_cmc_pitch
rh_thumb_cmc_roll: rh_thumb_cmc_roll
rh_index_mcp_pitch: rh_index_mcp_pitch
rh_middle_mcp_pitch: rh_middle_mcp_pitch
rh_ring_mcp_pitch: rh_ring_mcp_pitch
rh_pinky_mcp_pitch: rh_pinky_mcp_pitch
feedback_name_aliases:
thumb_cmc_yaw: thumb_cmc_roll
speed_slot_by_command_index:
'0': 0
'1': 1
'2': 2
'3': 3
'4': 4
'5': 5
unit: u8
baseline: []
lower_bounds: []
upper_bounds: []
feedback_lower_bounds: []
feedback_upper_bounds: []
feedback_by_index: false
vision:
views:
- name: front
tags:
- role: front_base
fixed_reference: true
id: 0
- role: thumb_pitch
fixed_reference: false
id: 1
- role: thumb_dip
fixed_reference: false
id: 2
- name: side
tags:
- role: side_base
fixed_reference: true
id: 3
- role: pinky_pitch
fixed_reference: false
id: 4
- role: pinky_dip
fixed_reference: false
id: 5
- name: top
tags:
- role: top_base
fixed_reference: true
id: 6
- role: thumb_roll
fixed_reference: false
id: 7
common_frame: calibration_common
extrinsic_reference_view: front
extrinsics_quality_limits:
reprojection_rms_px: 1.2
maximum_rotation_repeatability_deg: 0.3
maximum_translation_repeatability_m: 0.0015
minimum_capture_counts:
front_side_captures: 15
front_top_captures: 15
motion:
tasks:
- key: thumb_roll_top
view: top
command_index: 1
joints:
- rh_thumb_cmc_roll
auxiliary_commands:
- - 0
- 255
validation_only: false
start_u8: 255
end_u8: 0
preflight_speed_u8: 1
formal_speed_u8: 1
start: null
end: null
preflight_speed: null
formal_speed: null
- key: thumb_pitch_dip_front
view: front
command_index: 0
joints:
- rh_thumb_cmc_pitch
- rh_thumb_dip
auxiliary_commands:
- - 1
- 255
validation_only: false
start_u8: 255
end_u8: 0
preflight_speed_u8: 1
formal_speed_u8: 1
start: null
end: null
preflight_speed: null
formal_speed: null
- key: pinky_pitch_dip_side
view: side
command_index: 5
joints:
- rh_pinky_mcp_pitch
- rh_pinky_dip
auxiliary_commands: []
validation_only: false
start_u8: 255
end_u8: 0
preflight_speed_u8: 1
formal_speed_u8: 1
start: null
end: null
preflight_speed: null
formal_speed: null
preparation_waypoints_u8: []
safe_return_waypoints_u8: []
speed_parameters:
preflight_u8: 1
formal_u8: 1
speed_settle_seconds: 0.2
command_trajectory_full_range_seconds: 6.0
torque_u8: 80
endpoint_hold_seconds: 1.0
stall_timeout_seconds: 2.0
precheck_sweeps: false
steady_command_checkpoints: false
measurement:
measurements:
rh_thumb_cmc_roll:
joint: rh_thumb_cmc_roll
kind: relative_rotation
view: top
parent_role: top_base
child_role: thumb_roll
validation_source: null
pose_axis_line_required: true
rh_thumb_cmc_pitch:
joint: rh_thumb_cmc_pitch
kind: relative_rotation
view: front
parent_role: front_base
child_role: thumb_pitch
validation_source: null
pose_axis_line_required: true
rh_thumb_dip:
joint: rh_thumb_dip
kind: relative_rotation
view: front
parent_role: thumb_pitch
child_role: thumb_dip
validation_source: null
pose_axis_line_required: true
rh_pinky_mcp_pitch:
joint: rh_pinky_mcp_pitch
kind: relative_rotation
view: side
parent_role: side_base
child_role: pinky_pitch
validation_source: null
pose_axis_line_required: true
rh_pinky_dip:
joint: rh_pinky_dip
kind: relative_rotation
view: side
parent_role: pinky_pitch
child_role: pinky_dip
validation_source: null
pose_axis_line_required: true
cross_view_sources: {}
image_curve_joints: []
directional_zero: true
cross_view_roll_curve: false
stable_cross_view_cone_bias: false
zero:
active_joints:
- rh_index_mcp_pitch
- rh_middle_mcp_pitch
- rh_pinky_mcp_pitch
- rh_ring_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_roll
passive_joints:
- rh_index_dip
- rh_middle_dip
- rh_pinky_dip
- rh_ring_dip
- rh_thumb_dip
direct_zero_joints:
- rh_pinky_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_roll
axis_joints:
- rh_pinky_dip
- rh_pinky_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_roll
- rh_thumb_dip
mechanical_endpoint_joints: []
post_solve_endpoint_joints: []
mimic_source_by_joint:
rh_thumb_dip: rh_thumb_cmc_pitch
rh_index_dip: rh_index_mcp_pitch
rh_middle_dip: rh_middle_mcp_pitch
rh_ring_dip: rh_ring_mcp_pitch
rh_pinky_dip: rh_pinky_mcp_pitch
cad_frozen_joints:
- rh_index_dip
- rh_middle_dip
- rh_pinky_dip
- rh_ring_dip
- rh_thumb_dip
endpoint_anchor_by_joint: {}
fitted_mimic_joints:
- rh_pinky_dip
- rh_thumb_dip
coupling_model_by_joint:
rh_thumb_dip: linear_mimic
rh_pinky_dip: linear_mimic
rh_index_dip: linear_mimic
rh_middle_dip: linear_mimic
rh_ring_dip: linear_mimic
transferred_zero_sources: {rh_index_mcp_pitch: rh_pinky_mcp_pitch, rh_middle_mcp_pitch: rh_pinky_mcp_pitch, rh_ring_mcp_pitch: rh_pinky_mcp_pitch}
transferred_mimic_sources: {rh_index_dip: rh_pinky_dip, rh_middle_dip: rh_pinky_dip, rh_ring_dip: rh_pinky_dip}
spatial:
base_pose_strategy: thumb_serial
root_anchor_joints: [rh_thumb_cmc_roll]
orientation_anchor_joint: rh_pinky_mcp_pitch
directed_base_axis_joints: [rh_thumb_cmc_roll, rh_pinky_mcp_pitch]
depth_free_axis_projection: true
axis_order: [rh_thumb_cmc_roll, rh_thumb_cmc_pitch, rh_thumb_dip, rh_pinky_mcp_pitch, rh_pinky_dip]
axis_parent_joint: {rh_thumb_cmc_pitch: rh_thumb_cmc_roll}
phase_parent_joint: {rh_thumb_dip: rh_thumb_cmc_pitch, rh_pinky_dip: rh_pinky_mcp_pitch}
offset_observer_joint: {rh_thumb_cmc_roll: rh_thumb_cmc_pitch, rh_thumb_cmc_pitch: rh_thumb_dip, rh_pinky_mcp_pitch: rh_pinky_dip}
quality:
training_cycles:
- 0
- 1
- 2
holdout_cycle: 3
hard_threshold_keys:
- maximum_mimic_residual_rad
- maximum_state_image_skew_ms
- maximum_validation_error_rad
- minimum_detection_rate
retry_metric_scope: {}
isolated_holdout: true
scope:
calibrate_joints:
partial:
- rh_pinky_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_roll
frozen_joints:
partial:
- rh_index_mcp_pitch
- rh_middle_mcp_pitch
- rh_ring_mcp_pitch
default_scope: partial
artifacts:
output_schema_version: 2
calibration_filename: l6_right_{serial_number}_partial_calibration.json
corrected_urdf_filename: linkerhand_l6_right_{serial_number}_partial_zero_calibrated.urdf
protected_input_fields:
- calibration_config_sha256
- camera_extrinsics_sha256
- profile_config_sha256
- source_urdf_sha256
- tag_config_sha256
publication_pointer: latest_partial_passed
session_compatibility_tokens:
- feedback_curves_v6
- l6_partial_v1
publish_corrected_urdf: true
acquisition:
policy_version: unified_engine_v4_dual_mapping
mapping_probe_maximum_rad: 0.0
automatic_rescan_limit: 1
minimum_valid_samples: 40
minimum_bins: 32
maximum_unobserved_fraction: 0.0625
legacy_minimum_span_01: 0.9411764705882353
physical_first_cycle_minimum_span_01: 0.85
physical_repeat_minimum_fraction: 0.9
stall_timeout_seconds: 2.0
feedback_stale_seconds: 1.0
fixed_reference_minimum_frames: 10
fixed_reference_maximum_drift_px: 5.0
fixed_reference_confirmation_frames: 10
urdf:
authorized_fields:
rh_pinky_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_thumb_cmc_roll:
- limit.lower
- limit.upper
- origin.rpy
rh_index_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_thumb_cmc_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_middle_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_ring_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_ring_dip:
- mimic.multiplier
- mimic.offset
rh_thumb_dip:
- mimic.multiplier
- mimic.offset
rh_index_dip:
- mimic.multiplier
- mimic.offset
rh_pinky_dip:
- mimic.multiplier
- mimic.offset
rh_middle_dip:
- mimic.multiplier
- mimic.offset
joint_coverage:
rh_pinky_mcp_pitch: measured_static_dynamic
rh_thumb_cmc_roll: measured_static_dynamic
rh_thumb_cmc_pitch: measured_static_dynamic
rh_middle_mcp_pitch: transferred_static_dynamic
rh_index_mcp_pitch: transferred_static_dynamic
rh_ring_mcp_pitch: transferred_static_dynamic
rh_pinky_dip: measured_dynamic_cad_static
rh_middle_dip: transferred_dynamic_cad_static
rh_ring_dip: transferred_dynamic_cad_static
rh_thumb_dip: measured_dynamic_cad_static
rh_index_dip: transferred_dynamic_cad_static
@@ -0,0 +1,926 @@
schema_version: 1
profile_id: O12/right/o12_right_16/v1
namespace: /o12_calibration
sdk_adapter: o12_hcan_sdk
command:
unit: rad
feedback_by_index: true
sdk_to_joint_direction:
- 1
- -1
- -1
- -1
- -1
- 1
- 1
- -1
- 1
- 1
- 1
- 1
names:
- thumb_roll
- thumb_abad
- thumb_mcp
- thumb_pip
- index_abad
- index_mcp
- index_pip
- middle_abad
- middle_mcp
- middle_pip
- ring_mcp
- pinky_mcp
baseline_u8: []
baseline:
- 0
- 0
- 0
- 0
- 0
- 0
- 0
- 0
- 0
- 0
- 0
- 0
lower_bounds:
- 0
- -1.387536755335492
- -0.8272860654453121
- -1.2915436464758039
- -0.2617993877991494
- 0
- 0
- -0.2617993877991494
- 0
- 0
- 0
- 0
upper_bounds:
- 0.9424777960769379
- 0
- 0
- 0
- 0.2617993877991494
- 1.3526301702956054
- 1.530653753999027
- 0.2617993877991494
- 1.3578661580515883
- 1.8151424220741028
- 1.53588974175501
- 1.53588974175501
feedback_lower_bounds:
- -0.03490658503988659
- -1.4224433403753787
- -0.8621926504851987
- -1.3264502315156905
- -0.296705972839036
- -0.03490658503988659
- -0.03490658503988659
- -0.296705972839036
- -0.03490658503988659
- -0.03490658503988659
- -0.03490658503988659
- -0.03490658503988659
feedback_upper_bounds:
- 0.9773843811168246
- 0.03490658503988659
- 0.03490658503988659
- 0.03490658503988659
- 0.296705972839036
- 1.387536755335492
- 1.5655603390389137
- 0.296705972839036
- 1.392772743091475
- 1.8500490071139892
- 1.5707963267948966
- 1.5707963267948966
command_index_by_joint:
thumb_cmc_roll: 0
thumb_cmc_yaw: 1
thumb_cmc_pitch: 2
thumb_mcp: 3
index_mcp_roll: 4
index_mcp_pitch: 5
index_pip: 6
middle_mcp_roll: 7
middle_mcp_pitch: 8
middle_pip: 9
ring_mcp_pitch: 10
pinky_mcp_pitch: 11
urdf_joint_by_joint:
thumb_cmc_roll: thumb_cmc_roll
thumb_cmc_yaw: thumb_cmc_yaw
thumb_cmc_pitch: thumb_cmc_pitch
thumb_mcp: thumb_mcp
index_mcp_roll: index_mcp_roll
index_mcp_pitch: index_mcp_pitch
index_pip: index_pip
middle_mcp_roll: middle_mcp_roll
middle_mcp_pitch: middle_mcp_pitch
middle_pip: middle_pip
ring_mcp_pitch: ring_mcp_pitch
pinky_mcp_pitch: pinky_mcp_pitch
maximum_velocity:
- 0.16
- 0.16
- 0.1
- 0.32
- 0.12
- 0.32
- 0.32
- 0.12
- 0.32
- 0.32
- 0.32
- 0.32
vision:
common_frame: calibration_common
extrinsic_reference_view: front
extrinsics_quality_limits:
reprojection_rms_px: 2.0
maximum_rotation_repeatability_deg: 0.3
maximum_translation_repeatability_m: 0.0015
minimum_capture_counts:
front_side_captures: 15
front_top_captures: 15
views:
- name: front
tags:
- role: front_base
id: 0
fixed_reference: true
- role: thumb_cmc
id: 1
- role: thumb_mcp
id: 2
- role: thumb_dip
id: 3
- role: middle_roll
id: 12
- role: index_roll
id: 13
- name: side
tags:
- role: side_base
id: 4
fixed_reference: true
- role: pinky_mcp
id: 5
- role: pinky_pip
id: 6
- role: pinky_dip
id: 7
- role: middle_pip
id: 8
- role: middle_dip
id: 9
- role: index_pip
id: 10
- role: index_dip
id: 11
- name: top
tags:
- role: top_base
id: 14
fixed_reference: true
- role: thumb_yaw
id: 15
motion:
speed_parameters:
command_rate_hz: 50
clearance_flex_rad_s: 0.1
clearance_splay_rad_s: 0.04
probe_travel_rad: 0.05235987755982989
probe_speed_rad_s: 0.02
endpoint_hold_seconds: 1.0
stall_timeout_seconds: 2.0
baseline_rad_s: 0.2
trajectory_ramp_seconds: 0.4
tasks:
- key: thumb_pitch_front
view: front
command_index: 2
joints:
- thumb_cmc_pitch
start: 0
end: -0.8272860654453121
preflight_speed: 0.06
formal_speed: 0.1
preparation_groups:
- - 0
- 1
- 3
- - 4
- 7
- - 5
- 6
- 8
- 9
- - 10
- 11
- - 2
- key: thumb_roll_front
view: front
command_index: 0
joints:
- thumb_cmc_roll
start: 0
end: 0.9424777960769379
preflight_speed: 0.06
formal_speed: 0.16
preparation_groups:
- - 1
- 2
- 3
- - 4
- 7
- - 5
- 6
- 8
- 9
- - 10
- 11
- - 0
- key: thumb_mcp_dip_front
view: front
command_index: 3
joints:
- thumb_mcp
- thumb_dip
start: 0
end: -1.2915436464758039
preflight_speed: 0.06
formal_speed: 0.32
preparation_groups:
- - 0
- 1
- 2
- - 4
- 7
- - 5
- 6
- 8
- 9
- - 10
- 11
- - 3
- key: thumb_yaw_top
view: top
command_index: 1
joints:
- thumb_cmc_yaw
start: 0
end: -1.387536755335492
preflight_speed: 0.06
formal_speed: 0.16
preparation_groups:
- - 0
- 2
- 3
- - 4
- 7
- - 5
- 6
- 8
- 9
- - 10
- 11
- - 1
- key: pinky_chain_side
view: side
command_index: 11
joints:
- pinky_mcp_pitch
- pinky_pip
- pinky_dip
start: 0
end: 1.53588974175501
preflight_speed: 0.06
formal_speed: 0.32
preparation_groups:
- - 0
- 1
- 2
- 3
- - 4
- 7
- - 5
- 6
- 8
- 9
- - 10
- - 11
- key: middle_roll_front
view: front
command_index: 7
joints:
- middle_mcp_roll
auxiliary_commands:
- - 4
- -0.17453292519943295
- - 5
- 0.11
- - 10
- 1.53588974175501
- - 11
- 1.53588974175501
- - 8
- 0.17
start: 0.2617993877991494
end: -0.2617993877991494
preflight_speed: 0.06
formal_speed: 0.12
preparation_groups:
- - 10
- 11
- - 5
- 6
- - 4
- - 0
- 1
- 2
- 3
- - 8
- 9
- - 7
- key: middle_mcp_side
view: side
command_index: 8
joints:
- middle_mcp_pitch
auxiliary_commands:
- - 4
- -0.17453292519943295
- - 5
- 0.11
- - 10
- 1.53588974175501
- - 11
- 1.53588974175501
start: 0
end: 1.3578661580515883
preflight_speed: 0.06
formal_speed: 0.32
preparation_groups:
- - 10
- 11
- - 5
- 6
- - 4
- - 0
- 1
- 2
- 3
- - 7
- 9
- - 8
- key: middle_pip_dip_side
view: side
command_index: 9
joints:
- middle_pip
- middle_dip
auxiliary_commands:
- - 4
- -0.17453292519943295
- - 5
- 0.11
- - 10
- 1.53588974175501
- - 11
- 1.53588974175501
start: 0
end: 1.8151424220741028
preflight_speed: 0.06
formal_speed: 0.32
preparation_groups:
- - 10
- 11
- - 5
- 6
- - 4
- - 0
- 1
- 2
- 3
- - 7
- 8
- - 9
- key: index_roll_front
view: front
command_index: 4
joints:
- index_mcp_roll
auxiliary_commands:
- - 7
- 0
- - 8
- 1.3578661580515883
- - 9
- 1.8151424220741028
- - 10
- 1.53588974175501
- - 11
- 1.53588974175501
- - 5
- 0.17
start: 0.2617993877991494
end: -0.2617993877991494
preflight_speed: 0.06
formal_speed: 0.12
entry_waypoints:
- - - 10
- 1.53588974175501
- - 11
- 1.53588974175501
- - - 4
- 0
- - 7
- 0
- - - 8
- 1.3578661580515883
- - 9
- 1.8151424220741028
preparation_groups:
- - 10
- 11
- - 7
- - 8
- 9
- - 0
- 1
- 2
- 3
- - 5
- 6
- - 4
- key: index_mcp_side
view: side
command_index: 5
joints:
- index_mcp_pitch
auxiliary_commands:
- - 7
- 0
- - 8
- 1.3578661580515883
- - 9
- 1.8151424220741028
- - 10
- 1.53588974175501
- - 11
- 1.53588974175501
start: 0
end: 1.3526301702956054
preflight_speed: 0.06
formal_speed: 0.32
entry_waypoints:
- - - 10
- 1.53588974175501
- - 11
- 1.53588974175501
- - - 4
- 0
- - 7
- 0
- - - 8
- 1.3578661580515883
- - 9
- 1.8151424220741028
preparation_groups:
- - 10
- 11
- - 7
- - 8
- 9
- - 0
- 1
- 2
- 3
- - 4
- 6
- - 5
- key: index_pip_dip_side
view: side
command_index: 6
joints:
- index_pip
- index_dip
auxiliary_commands:
- - 7
- 0
- - 8
- 1.3578661580515883
- - 9
- 1.8151424220741028
- - 10
- 1.53588974175501
- - 11
- 1.53588974175501
start: 0
end: 1.530653753999027
preflight_speed: 0.06
formal_speed: 0.32
entry_waypoints:
- - - 10
- 1.53588974175501
- - 11
- 1.53588974175501
- - - 4
- 0
- - 7
- 0
- - - 8
- 1.3578661580515883
- - 9
- 1.8151424220741028
preparation_groups:
- - 10
- 11
- - 7
- - 8
- 9
- - 0
- 1
- 2
- 3
- - 4
- 5
- - 6
return_groups:
- - 4
- 7
- - 8
- 9
- - 10
- 11
- - 0
- 1
- 2
- 3
- 5
- 6
measurement:
candidate_selection_tasks:
- thumb_mcp_dip_front
directional_zero: true
stable_cross_view_cone_bias: true
cross_view_sources:
middle_mcp_roll: middle_mcp_pitch
index_mcp_roll: index_mcp_pitch
measurements:
thumb_cmc_pitch:
kind: relative_rotation
view: front
parent_role: front_base
child_role: thumb_cmc
thumb_cmc_roll:
kind: relative_rotation
view: front
parent_role: front_base
child_role: thumb_cmc
thumb_mcp:
kind: relative_rotation
view: front
parent_role: thumb_cmc
child_role: thumb_mcp
thumb_dip:
kind: relative_rotation
view: front
parent_role: thumb_mcp
child_role: thumb_dip
pose_axis_line_required: false
thumb_cmc_yaw:
kind: relative_rotation
view: top
parent_role: top_base
child_role: thumb_yaw
pinky_mcp_pitch:
kind: relative_rotation
view: side
parent_role: side_base
child_role: pinky_mcp
pinky_pip:
kind: relative_rotation
view: side
parent_role: pinky_mcp
child_role: pinky_pip
pose_axis_line_required: false
pinky_dip:
kind: relative_rotation
view: side
parent_role: pinky_pip
child_role: pinky_dip
pose_axis_line_required: false
middle_mcp_roll:
kind: relative_rotation
view: front
parent_role: front_base
child_role: middle_roll
middle_mcp_pitch:
kind: relative_rotation
view: side
parent_role: side_base
child_role: middle_pip
middle_pip:
kind: relative_rotation
view: side
parent_role: side_base
child_role: middle_pip
middle_dip:
kind: relative_rotation
view: side
parent_role: middle_pip
child_role: middle_dip
pose_axis_line_required: false
index_mcp_roll:
kind: relative_rotation
view: front
parent_role: front_base
child_role: index_roll
index_mcp_pitch:
kind: relative_rotation
view: side
parent_role: side_base
child_role: index_pip
index_pip:
kind: relative_rotation
view: side
parent_role: side_base
child_role: index_pip
index_dip:
kind: relative_rotation
view: side
parent_role: index_pip
child_role: index_dip
pose_axis_line_required: false
zero:
active_joints:
- thumb_cmc_roll
- thumb_cmc_yaw
- thumb_cmc_pitch
- thumb_mcp
- index_mcp_roll
- index_mcp_pitch
- index_pip
- middle_mcp_roll
- middle_mcp_pitch
- middle_pip
- ring_mcp_pitch
- pinky_mcp_pitch
passive_joints:
- thumb_dip
- index_dip
- middle_dip
- ring_pip
- ring_dip
- pinky_pip
- pinky_dip
direct_zero_joints:
- thumb_cmc_roll
- thumb_cmc_yaw
- thumb_cmc_pitch
- index_mcp_roll
- index_mcp_pitch
- index_pip
- middle_mcp_roll
- middle_mcp_pitch
- middle_pip
- pinky_mcp_pitch
axis_joints:
- thumb_cmc_roll
- thumb_cmc_yaw
- thumb_cmc_pitch
- thumb_mcp
- index_mcp_roll
- index_mcp_pitch
- index_pip
- middle_mcp_roll
- middle_mcp_pitch
- middle_pip
- pinky_mcp_pitch
- index_dip
- middle_dip
- pinky_pip
mechanical_endpoint_joints: []
post_solve_endpoint_joints: []
mimic_source_by_joint:
thumb_dip: thumb_mcp
index_dip: index_pip
middle_dip: middle_pip
ring_pip: ring_mcp_pitch
ring_dip: ring_pip
pinky_pip: pinky_mcp_pitch
pinky_dip: pinky_pip
cad_frozen_joints:
- thumb_mcp
- thumb_dip
- index_dip
- middle_dip
- ring_pip
- ring_dip
- pinky_pip
- pinky_dip
fitted_mimic_joints:
- thumb_dip
- index_dip
- middle_dip
- pinky_pip
- pinky_dip
coupling_model_by_joint:
thumb_dip: linear_mimic
index_dip: linear_mimic
middle_dip: linear_mimic
pinky_pip: linear_mimic
pinky_dip: linear_mimic
transferred_zero_sources:
ring_mcp_pitch: pinky_mcp_pitch
spatial:
base_pose_strategy: thumb_serial
root_anchor_joints:
- thumb_cmc_roll
orientation_anchor_joint: pinky_mcp_pitch
directed_base_axis_joints:
- thumb_cmc_roll
- pinky_mcp_pitch
depth_free_axis_projection: true
accept_validated_zero_in_confidence_interval: true
project_axis_gauge_before_image: true
axis_order:
- thumb_cmc_roll
- thumb_cmc_yaw
- thumb_cmc_pitch
- thumb_mcp
- pinky_mcp_pitch
- pinky_pip
- middle_mcp_roll
- middle_mcp_pitch
- middle_pip
- middle_dip
- index_mcp_roll
- index_mcp_pitch
- index_pip
- index_dip
axis_parent_joint:
thumb_cmc_yaw: thumb_cmc_roll
thumb_cmc_pitch: thumb_cmc_yaw
index_mcp_pitch: index_mcp_roll
middle_mcp_pitch: middle_mcp_roll
phase_parent_joint:
thumb_mcp: thumb_cmc_pitch
index_pip: index_mcp_pitch
index_dip: index_pip
middle_pip: middle_mcp_pitch
middle_dip: middle_pip
pinky_pip: pinky_mcp_pitch
offset_observer_joint:
thumb_cmc_roll: thumb_cmc_yaw
thumb_cmc_yaw: thumb_cmc_pitch
thumb_cmc_pitch: thumb_mcp
index_mcp_roll: index_mcp_pitch
index_mcp_pitch: index_pip
index_pip: index_dip
middle_mcp_roll: middle_mcp_pitch
middle_mcp_pitch: middle_pip
middle_pip: middle_dip
pinky_mcp_pitch: pinky_pip
quality:
training_cycles:
- 0
- 1
- 2
holdout_cycle: 3
hard_threshold_keys:
- holdout
isolated_holdout: true
scope:
default_scope: full
calibrate_joints:
full:
- thumb_cmc_roll
- thumb_cmc_yaw
- thumb_cmc_pitch
- thumb_mcp
- index_mcp_roll
- index_mcp_pitch
- index_pip
- middle_mcp_roll
- middle_mcp_pitch
- middle_pip
- ring_mcp_pitch
- pinky_mcp_pitch
frozen_joints:
full: []
artifacts:
output_schema_version: 2
calibration_filename: o12_right_{serial_number}_calibration.json
corrected_urdf_filename: linkerhand_o12_right_{serial_number}_zero_calibrated.urdf
protected_input_fields:
- source_urdf_sha256
- camera_extrinsics_sha256
- calibration_config_sha256
- tag_config_sha256
- sdk_config_sha256
- sdk_package_sha256
- profile_config_sha256
publication_pointer: latest_passed
session_compatibility_tokens:
- o12_right_16_v1
- feedback_rad_v1
- full_sdk_range_v2
publish_corrected_urdf: true
acquisition:
policy_version: unified_engine_v4_dual_mapping
mapping_probe_maximum_rad: 0.05235987755982989
urdf:
authorized_fields:
thumb_cmc_roll:
- origin.rpy
- limit.lower
- limit.upper
thumb_cmc_yaw:
- origin.rpy
- limit.lower
- limit.upper
thumb_cmc_pitch:
- origin.rpy
- limit.lower
- limit.upper
thumb_mcp:
- origin.rpy
- limit.lower
- limit.upper
index_mcp_roll:
- origin.rpy
- limit.lower
- limit.upper
index_mcp_pitch:
- origin.rpy
- limit.lower
- limit.upper
index_pip:
- origin.rpy
- limit.lower
- limit.upper
middle_mcp_roll:
- origin.rpy
- limit.lower
- limit.upper
middle_mcp_pitch:
- origin.rpy
- limit.lower
- limit.upper
middle_pip:
- origin.rpy
- limit.lower
- limit.upper
ring_mcp_pitch:
- origin.rpy
- limit.lower
- limit.upper
pinky_mcp_pitch:
- origin.rpy
- limit.lower
- limit.upper
thumb_dip:
- mimic.multiplier
- mimic.offset
index_dip:
- mimic.multiplier
- mimic.offset
middle_dip:
- mimic.multiplier
- mimic.offset
pinky_pip:
- mimic.multiplier
- mimic.offset
pinky_dip:
- mimic.multiplier
- mimic.offset
ring_pip:
- mimic.offset
ring_dip:
- mimic.offset
joint_coverage:
thumb_cmc_roll: measured_static_dynamic
thumb_cmc_yaw: measured_static_dynamic
thumb_cmc_pitch: measured_static_dynamic
thumb_mcp: measured_dynamic_cad_static
thumb_dip: measured_dynamic_cad_static
index_mcp_roll: measured_static_dynamic
index_mcp_pitch: measured_static_dynamic
index_pip: measured_static_dynamic
index_dip: measured_dynamic_cad_static
middle_mcp_roll: measured_static_dynamic
middle_mcp_pitch: measured_static_dynamic
middle_pip: measured_static_dynamic
middle_dip: measured_dynamic_cad_static
ring_mcp_pitch: transferred_static_dynamic
ring_pip: mimic_nominal
ring_dip: mimic_nominal
pinky_mcp_pitch: measured_static_dynamic
pinky_pip: measured_dynamic_cad_static
pinky_dip: measured_dynamic_cad_static
@@ -0,0 +1,368 @@
schema_version: 1
profile_id: O6/right/o6_right_8/v1
namespace: /o6_calibration
sdk_adapter: legacy_byte_sdk
command:
sdk_to_joint_direction: [-1, -1, -1, -1, -1, -1]
names:
- thumb_cmc_pitch
- thumb_cmc_yaw
- index_mcp_pitch
- middle_mcp_pitch
- ring_mcp_pitch
- pinky_mcp_pitch
baseline_u8:
- 255
- 255
- 255
- 255
- 255
- 255
command_index_by_joint:
rh_thumb_cmc_pitch: 0
rh_thumb_cmc_yaw: 1
rh_index_mcp_pitch: 2
rh_middle_mcp_pitch: 3
rh_ring_mcp_pitch: 4
rh_pinky_mcp_pitch: 5
disabled_indices: []
urdf_joint_by_joint:
rh_thumb_cmc_pitch: rh_thumb_cmc_pitch
rh_thumb_cmc_yaw: rh_thumb_cmc_yaw
rh_index_mcp_pitch: rh_index_mcp_pitch
rh_middle_mcp_pitch: rh_middle_mcp_pitch
rh_ring_mcp_pitch: rh_ring_mcp_pitch
rh_pinky_mcp_pitch: rh_pinky_mcp_pitch
feedback_name_aliases: {}
speed_slot_by_command_index:
'0': 0
'1': 1
'2': 2
'3': 3
'4': 4
'5': 5
unit: u8
baseline: []
lower_bounds: []
upper_bounds: []
feedback_lower_bounds: []
feedback_upper_bounds: []
feedback_by_index: false
vision:
views:
- name: front
tags:
- role: front_base
fixed_reference: true
id: 0
- role: thumb_pitch
fixed_reference: false
id: 1
- role: thumb_ip
fixed_reference: false
id: 2
- name: side
tags:
- role: side_base
fixed_reference: true
id: 3
- role: pinky_pitch
fixed_reference: false
id: 4
- role: pinky_dip
fixed_reference: false
id: 5
- name: top
tags:
- role: top_base
fixed_reference: true
id: 6
- role: thumb_yaw
fixed_reference: false
id: 7
common_frame: calibration_common
extrinsic_reference_view: front
extrinsics_quality_limits:
reprojection_rms_px: 1.5
maximum_rotation_repeatability_deg: 0.3
maximum_translation_repeatability_m: 0.0015
minimum_capture_counts:
front_side_captures: 15
front_top_captures: 15
motion:
tasks:
- key: thumb_yaw_top
view: top
command_index: 1
joints:
- rh_thumb_cmc_yaw
auxiliary_commands: []
validation_only: false
start_u8: 255
end_u8: 0
preflight_speed_u8: 60
formal_speed_u8: 40
start: null
end: null
preflight_speed: null
formal_speed: null
- key: thumb_pitch_ip_front
view: front
command_index: 0
joints:
- rh_thumb_cmc_pitch
- rh_thumb_ip
auxiliary_commands: []
validation_only: false
start_u8: 255
end_u8: 0
preflight_speed_u8: 60
formal_speed_u8: 40
start: null
end: null
preflight_speed: null
formal_speed: null
- key: pinky_pitch_dip_side
view: side
command_index: 5
joints:
- rh_pinky_mcp_pitch
- rh_pinky_dip
auxiliary_commands: []
validation_only: false
start_u8: 255
end_u8: 0
preflight_speed_u8: 60
formal_speed_u8: 40
start: null
end: null
preflight_speed: null
formal_speed: null
preparation_waypoints_u8: []
safe_return_waypoints_u8: []
speed_parameters:
baseline_u8: 80
preflight_u8: 60
formal_u8: 40
speed_settle_seconds: 0.2
command_trajectory_full_range_seconds: 6.0
torque_u8: 80
endpoint_hold_seconds: 1.0
stall_timeout_seconds: 2.0
precheck_sweeps: false
steady_command_checkpoints: false
measurement:
measurements:
rh_thumb_cmc_yaw:
joint: rh_thumb_cmc_yaw
kind: relative_rotation
view: top
parent_role: top_base
child_role: thumb_yaw
validation_source: null
pose_axis_line_required: true
rh_thumb_cmc_pitch:
joint: rh_thumb_cmc_pitch
kind: relative_rotation
view: front
parent_role: front_base
child_role: thumb_pitch
validation_source: null
pose_axis_line_required: true
rh_thumb_ip:
joint: rh_thumb_ip
kind: relative_rotation
view: front
parent_role: thumb_pitch
child_role: thumb_ip
validation_source: null
pose_axis_line_required: true
rh_pinky_mcp_pitch:
joint: rh_pinky_mcp_pitch
kind: relative_rotation
view: side
parent_role: side_base
child_role: pinky_pitch
validation_source: null
pose_axis_line_required: true
rh_pinky_dip:
joint: rh_pinky_dip
kind: relative_rotation
view: side
parent_role: pinky_pitch
child_role: pinky_dip
validation_source: null
pose_axis_line_required: true
cross_view_sources: {}
image_curve_joints: []
directional_zero: true
cross_view_roll_curve: false
stable_cross_view_cone_bias: false
zero:
active_joints:
- rh_index_mcp_pitch
- rh_middle_mcp_pitch
- rh_pinky_mcp_pitch
- rh_ring_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_yaw
passive_joints:
- rh_index_dip
- rh_middle_dip
- rh_pinky_dip
- rh_ring_dip
- rh_thumb_ip
direct_zero_joints:
- rh_pinky_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_yaw
axis_joints:
- rh_pinky_dip
- rh_pinky_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_yaw
- rh_thumb_ip
mechanical_endpoint_joints: []
post_solve_endpoint_joints: []
mimic_source_by_joint:
rh_thumb_ip: rh_thumb_cmc_pitch
rh_index_dip: rh_index_mcp_pitch
rh_middle_dip: rh_middle_mcp_pitch
rh_ring_dip: rh_ring_mcp_pitch
rh_pinky_dip: rh_pinky_mcp_pitch
cad_frozen_joints:
- rh_index_dip
- rh_middle_dip
- rh_pinky_dip
- rh_ring_dip
- rh_thumb_ip
endpoint_anchor_by_joint: {}
fitted_mimic_joints:
- rh_pinky_dip
- rh_thumb_ip
coupling_model_by_joint:
rh_thumb_ip: linear_mimic
rh_index_dip: linear_mimic
rh_middle_dip: linear_mimic
rh_ring_dip: linear_mimic
rh_pinky_dip: linear_mimic
transferred_zero_sources: {rh_index_mcp_pitch: rh_pinky_mcp_pitch, rh_middle_mcp_pitch: rh_pinky_mcp_pitch, rh_ring_mcp_pitch: rh_pinky_mcp_pitch}
transferred_mimic_sources: {rh_index_dip: rh_pinky_dip, rh_middle_dip: rh_pinky_dip, rh_ring_dip: rh_pinky_dip}
spatial:
base_pose_strategy: thumb_serial
root_anchor_joints: [rh_thumb_cmc_yaw]
orientation_anchor_joint: rh_pinky_mcp_pitch
directed_base_axis_joints: [rh_thumb_cmc_yaw, rh_pinky_mcp_pitch]
depth_free_axis_projection: true
axis_order: [rh_thumb_cmc_yaw, rh_thumb_cmc_pitch, rh_thumb_ip, rh_pinky_mcp_pitch, rh_pinky_dip]
axis_parent_joint: {rh_thumb_cmc_pitch: rh_thumb_cmc_yaw}
phase_parent_joint: {rh_thumb_ip: rh_thumb_cmc_pitch, rh_pinky_dip: rh_pinky_mcp_pitch}
offset_observer_joint: {rh_thumb_cmc_yaw: rh_thumb_cmc_pitch, rh_thumb_cmc_pitch: rh_thumb_ip, rh_pinky_mcp_pitch: rh_pinky_dip}
quality:
training_cycles:
- 0
- 1
- 2
holdout_cycle: 3
hard_threshold_keys:
- maximum_mimic_residual_rad
- maximum_state_image_skew_ms
- maximum_validation_error_rad
- minimum_detection_rate
retry_metric_scope: {}
isolated_holdout: true
scope:
calibrate_joints:
partial:
- rh_pinky_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_yaw
frozen_joints:
partial:
- rh_index_mcp_pitch
- rh_middle_mcp_pitch
- rh_ring_mcp_pitch
default_scope: partial
artifacts:
output_schema_version: 2
calibration_filename: o6_right_{serial_number}_partial_calibration.json
corrected_urdf_filename: linkerhand_o6_right_{serial_number}_partial_zero_calibrated.urdf
protected_input_fields:
- calibration_config_sha256
- camera_extrinsics_sha256
- profile_config_sha256
- source_urdf_sha256
- tag_config_sha256
publication_pointer: latest_partial_passed
session_compatibility_tokens:
- feedback_curves_v6
- o6_partial_v1
publish_corrected_urdf: true
acquisition:
policy_version: unified_engine_v4_dual_mapping
mapping_probe_maximum_rad: 0.0
automatic_rescan_limit: 1
minimum_valid_samples: 40
minimum_bins: 32
maximum_unobserved_fraction: 0.0625
legacy_minimum_span_01: 0.9411764705882353
physical_first_cycle_minimum_span_01: 0.85
physical_repeat_minimum_fraction: 0.9
stall_timeout_seconds: 2.0
feedback_stale_seconds: 1.0
fixed_reference_minimum_frames: 10
fixed_reference_maximum_drift_px: 5.0
fixed_reference_confirmation_frames: 10
urdf:
authorized_fields:
rh_pinky_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_index_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_thumb_cmc_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_middle_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_ring_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_thumb_cmc_yaw:
- limit.lower
- limit.upper
- origin.rpy
rh_ring_dip:
- mimic.multiplier
- mimic.offset
rh_index_dip:
- mimic.multiplier
- mimic.offset
rh_pinky_dip:
- mimic.multiplier
- mimic.offset
rh_thumb_ip:
- mimic.multiplier
- mimic.offset
rh_middle_dip:
- mimic.multiplier
- mimic.offset
joint_coverage:
rh_pinky_mcp_pitch: measured_static_dynamic
rh_thumb_cmc_pitch: measured_static_dynamic
rh_middle_mcp_pitch: transferred_static_dynamic
rh_index_mcp_pitch: transferred_static_dynamic
rh_ring_mcp_pitch: transferred_static_dynamic
rh_thumb_cmc_yaw: measured_static_dynamic
rh_pinky_dip: measured_dynamic_cad_static
rh_thumb_ip: measured_dynamic_cad_static
rh_middle_dip: transferred_dynamic_cad_static
rh_ring_dip: transferred_dynamic_cad_static
rh_index_dip: transferred_dynamic_cad_static
@@ -0,0 +1,44 @@
$schema: https://json-schema.org/draft/2020-12/schema
title: LinkerHand calibration profile
type: object
additionalProperties: false
required:
- schema_version
- profile_id
- namespace
- sdk_adapter
- command
- vision
- motion
- measurement
- zero
- quality
- scope
- artifacts
- urdf
properties:
schema_version: {const: 1}
profile_id: {type: string, pattern: "^[A-Za-z0-9_-]+/(left|right)/[a-z0-9_-]+/v[1-9][0-9]*$"}
namespace: {type: string, pattern: "^/[^/].*[^/]$"}
sdk_adapter: {type: string, minLength: 1}
command: {type: object}
vision: {type: object}
motion: {type: object}
measurement: {type: object}
zero: {type: object}
quality: {type: object}
scope: {type: object}
artifacts: {type: object}
acquisition: {type: object}
urdf:
type: object
required: [authorized_fields]
properties:
authorized_fields:
type: object
additionalProperties:
type: array
uniqueItems: true
items:
enum: [origin.rpy, limit.lower, limit.upper, mimic.multiplier, mimic.offset]
joint_coverage: {type: object}
@@ -0,0 +1,14 @@
$schema: https://json-schema.org/draft/2020-12/schema
title: LinkerHand calibration product
type: object
required: [schema_version, profile_id, serial_number, cameras, artifacts]
properties:
schema_version: {type: integer, minimum: 2}
profile_id: {type: string}
profile_config: {type: string}
serial_number: {type: string, pattern: "^[A-Za-z0-9_.-]+$"}
namespace: {type: string}
sdk: {type: object}
cameras: {type: object, minProperties: 1}
artifacts: {type: object}
release: {type: object}
@@ -0,0 +1,191 @@
g20_calibration:
ros__parameters:
command_topic: /g20/cb_left_hand_control_cmd
state_topic: /g20/cb_left_hand_state
info_topic: /g20/cb_left_hand_info
setting_topic: /g20/cb_hand_setting_cmd
front_camera_info_topic: /g20_calibration/front/camera/camera_info
front_detections_topic: /g20_calibration/front/apriltag/detections
side_camera_info_topic: /g20_calibration/side/camera/camera_info
side_detections_topic: /g20_calibration/side/apriltag/detections
top_camera_info_topic: /g20_calibration/top/camera/camera_info
top_detections_topic: /g20_calibration/top/apriltag/detections
# /start先下发并确认这个20通道基准姿态,稳定后才进入第一条扫描。
baseline_command_u8: [255, 255, 255, 255, 255, 255, 127, 127, 127, 127, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
normal_calibration_speed: 15
index_roll_calibration_speed: 5
index_flex_calibration_speed: 10
# 19-Tag产品预检仍使用上面保守速度;只有正反预检都留出至少双倍正式分箱余量,
# 才把非roll任务正式扫描最多提速1.5倍。四指roll受0.5°回差门限约束,
# 始终保持速度5;任一方向采样余量不足也保持原速度。
adaptive_formal_speed_enabled: true
adaptive_formal_speed_max_scale: 1.5
adaptive_formal_speed_minimum_bins: 64
adaptive_formal_speed_maximum_bin_gap: 8
speed_setting_settle_seconds: 0.25
# tag36h11尺寸是检测角点围成的黑色正方形边长,不包含外围白边。
# 19张Tag的黑色码区外边长均为16 mm。自定义PnP必须与
# apriltag_ros逐ID尺寸一致,禁止用纸张/白边尺寸代替码区尺寸。
tag_size_m: 0.016
# ROS 2无法从YAML空数组推断整数/浮点数组类型。这四个
# 末端Tag仍显式写16 mm,防止节点启动时得到未初始化参数。
tag_size_override_ids: [7, 14, 16, 18]
tag_size_overrides_m: [0.016, 0.016, 0.016, 0.016]
repetitions: 3
# 19-Tag产品正式零位使用前三轮训练、最后一轮完全留出;旧11-Tag仍读取repetitions=3。
g20_right_19_repetitions: 4
preflight_frames: 60
minimum_detection_rate: 0.95
minimum_detection_hz: 15.0
minimum_feedback_hz: 25.0
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 30.0
pnp_maximum_reprojection_error_px: 1.5
pnp_maximum_pose_jump_deg: 35.0
pnp_maximum_translation_jump_m: 0.04
pnp_maximum_tag_tilt_deg: 75.0
pnp_tracker_reset_seconds: 5.0
# 标定任务不再用第一帧决定平面Tag的IPPE分支;静止端点联合8帧选择整组最稳定解。
pnp_group_initialization_frames: 8
# 侧面当前任务所需Tag在初始化端点的贴面法向应一致;用此先验消除静态IPPE镜像双解。
pnp_group_normal_alignment_scale_deg: 5.0
pnp_group_maximum_normal_alignment_deg: 15.0
# 三个拇指顶部任务共用预检时冻结的Tag 8位姿。Tag 8仍须实时可见;
# 任一角点相对会话基准漂移超过2 px并连续5帧时,判定标定中基准被移动。
fixed_base_maximum_corner_drift_px: 5.0
fixed_base_movement_confirmation_frames: 10
# 仅在拇指MCP/IP同步运动且至少一个候选落入可信区间时,用源URDF mimic
# 关系辅助选择IPPE分支;若全部候选超限则退回纯视觉,绝不丢帧,也不生成、
# 缩放或替代被动IP的自身Tag实测曲线。
thumb_ip_pnp_coupling_multiplier: 1.03
thumb_ip_pnp_coupling_scale_deg: 3.0
thumb_ip_pnp_maximum_coupling_residual_deg: 7.5
top_pnp_invalid_reset_seconds: 1.0
# 三维位姿必须与实测20通道状态严格按时间戳配对。
maximum_state_image_skew_ms: 50.0
axis_maximum_plane_rms_m: 0.003
# 被动耦合轴只用轨迹确定轴线位置,允许更大的轴向深度噪声;径向和跨轮
# 轴线一致性仍沿用严格检查。
passive_axis_maximum_plane_rms_m: 0.004
axis_maximum_radial_rms_m: 0.003
# 整段相对SE(3)运动拟合轴线点;端视关节会投影掉单目PnP光轴深度。
axis_maximum_pose_line_rms_m: 0.001
# 仅用于运动平面在三维中可观测的斜视关节;近图像平面关节使用姿态轴
# 约束三维圆,不让单目平面Tag的深度噪声自由决定转轴方向。
axis_maximum_rotation_circle_difference_deg: 1.0
# 单轴模型残差与跨轮重复误差分开判定:主动刚性关节要求更严;被动耦合
# 关节允许可重复的非理想单轴分量,但仍须通过0.75°跨轮轴差及最终轮留出。
active_maximum_rotation_orthogonal_rms_deg: 2.5
passive_maximum_rotation_orthogonal_rms_deg: 7.5
zero_maximum_axis_cycle_difference_deg: 0.75
# 零位无法改变父子轴夹角;超过该值属于CAD/PnP几何错误,不能吸收到零位。
zero_maximum_axis_cone_mismatch_deg: 5.0
zero_maximum_observability_condition_number: 10000000000.0
zero_maximum_offset_deg: 20.0
# 四指MCP roll保留严格的装配保护范围。thumb CMC三轴由多轴视觉几何
# 求解且不假定电气端点等于CAD上限;thumb_mcp及四指MCP pitch/PIP
# 静态零位由实测全行程与CAD机械端点联合求解,不写死为0。
zero_finger_maximum_offset_deg: 3.0
# 只对实物已确认等同CAD端点的关节使用该限制;CMC电气端点不作此假设。
mechanical_endpoint_maximum_offset_deg: 5.0
endpoint_tolerance_u8: 2.0
# 请求命令与固件反馈是两个标定域。稳态检查点允许小幅死区,但反馈
# 必须已经稳定;大残差仍由机械卡滞保护处理。
steady_checkpoint_command_feedback_tolerance_u8: 8.0
steady_checkpoint_maximum_feedback_range_u8: 2.0
# 电机10在命令0时实测会稳定反馈为4;该0端使用±4。
thumb_yaw_zero_endpoint_tolerance_u8: 4.0
# 右手电机10在命令255时多次实测稳定反馈为250;仅右手该端点使用±5。
right_thumb_yaw_255_endpoint_tolerance_u8: 5.0
# 右手小指PIP电机19在命令0时固件反馈稳定饱和为5;仅其0端使用±5。
pinky_pip_zero_endpoint_tolerance_u8: 5.0
endpoint_hold_seconds: 0.5
# roll零位127必须从两个方向到位并静止采集,禁止用运动中经过127的帧判回差。
baseline_hold_seconds: 0.5
minimum_baseline_hold_frames: 10
# unified_engine_v2 不执行每任务全行程预检;保留参数仅兼容旧配置读取。
task_precheck_hold_seconds: 2.0
position_timeout_seconds: 30.0
sweep_timeout_seconds: 90.0
# 启动宽限1秒后,反馈连续2秒没有至少1个u8的进展,按机械卡滞立即暂停;
# 这类硬故障不自动重试。
motor_stall_timeout_seconds: 2.0
motor_stall_startup_grace_seconds: 1.0
motor_stall_minimum_progress_u8: 1.0
invalid_timeout_seconds: 3.0
minimum_sweep_frames: 40
minimum_state_span_u8: 240.0
minimum_sweep_bins: 32
maximum_bin_gap: 16
# 可恢复的采样失败自动重扫当前方向;超过次数才暂停等待人工处理。
automatic_sweep_retry_limit: 1
# 轨迹拟合失败优先只重扫失败轮次;零位/URDF模型失败不重复运动。
automatic_fit_retry_limit: 0
automatic_motion_retry_limit: 0
# 留空为正式标定;设为pinky/ring/middle/index时只采该指正面+侧面roll,
# 即使正面baseline回差失败也继续完成侧面对照,并永久锁定本会话URDF发布。
cross_view_roll_diagnostic_finger: ""
# 过程检查允许25%的黄色预警带,最终验收仍使用下面的严格门限。
provisional_warning_ratio: 1.25
retry_minimum_speed: 3
retry_speed_scales: [1.0]
retry_endpoint_hold_seconds: [0.5]
trajectory_maximum_plane_rms_m: 0.004
trajectory_maximum_radial_rms_m: 0.004
trajectory_minimum_radius_m: 0.003
trajectory_minimum_arc_deg: 15.0
# 以下二维参数只供旧轨迹工具兼容,三机位v4零位不使用二维投影。
image_trajectory_maximum_radial_rms_px: 2.0
image_trajectory_maximum_radial_p95_px: 3.5
image_trajectory_minimum_radius_px: 20.0
trajectory_maximum_cycle_travel_difference_deg: 3.0
passive_maximum_cycle_travel_difference_deg: 10.0
maximum_monotonic_correction_deg: 2.0
# 旧布局仍用连续扫描正反程差门限;19-Tag产品的连续运动包含速度相关滞后,
# 由方向曲线和最终留出验证建模,不再重复硬判。其绝对正反程门禁使用下面
# 的九点稳态command_maximum_direction_gap_deg。
maximum_hysteresis_deg: 2.0
# 19-Tag产品模式额外要求每轮正反方向在各自baseline处绕实测关节轴的角度差
# 不超过0.5°;四指roll例外:127以255→127为唯一物理零位,反向分支
# 保留实测偏差,并改为检查分支间隙上限及跨轮稳定性。
baseline_maximum_hysteresis_deg: 0.5
directional_zero_maximum_branch_gap_deg: 2.0
directional_zero_maximum_branch_gap_range_deg: 0.3
cross_view_roll_maximum_branch_gap_difference_deg: 0.3
# 正面roll是Tag中心的二维投影角,侧面roll是三维姿态角。允许一个有界的
# 固定比例吸收Tag安装倾角/偏置带来的投影缩放,再严格比较两条曲线形状;
# 比例过大、方向相反、形状RMS及两视角各自的四轮重复性仍会失败。
cross_view_roll_maximum_shape_rms_deg: 1.25
cross_view_roll_maximum_projection_scale_ratio: 1.5
passive_maximum_monotonic_correction_deg: 3.0
passive_maximum_hysteresis_deg: 2.0
# 九点姿态先按实测反馈重新对齐,再用此门限检查固有机械回差;请求命令域
# 的两条运行曲线仍原样保留固件方向死区,不能把command/feedback差算成回差。
command_maximum_direction_gap_deg: 2.0
# 默认无额外随机动作;19-Tag产品最终一轮始终作为不可关闭的留出验证。
validation_enabled: false
# 第四轮留出求解后必须再走8个固定安全组合姿态;三机位规定Tag全部可见
# 且实测20通道到位才允许发布。只保存Tag位姿,不保存原始图像。
# Developer diagnostic only. The formal fourth sweep cycle already gives
# every isolated PIP/DIP pair an independent holdout.
combination_validation_enabled: false
combination_validation_frames: 10
combination_maximum_position_p95_m: 0.003
combination_maximum_orientation_p95_deg: 2.0
validation_command_count: 3
validation_frames: 10
validation_seed: 20260804
validation_timeout_seconds: 20.0
maximum_validation_mae_deg: 1.0
maximum_validation_p95_deg: 2.0
# 19-Tag产品模式使用更严格的任一点及静态零偏95%置信区间门限。
maximum_validation_error_deg: 3.0
zero_maximum_confidence_half_width_deg: 1.5
@@ -0,0 +1,62 @@
/g20_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [0, 1, 2, 3, 10]
frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, index_roll]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016]
/g20_calibration/side/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [4, 5, 6, 7]
frames: [side_base, index_mcp, index_pip, index_dip]
sizes: [0.016, 0.016, 0.016, 0.016]
/g20_calibration/top/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [8, 9]
frames: [top_base, thumb_yaw]
sizes: [0.016, 0.016]
@@ -0,0 +1,62 @@
/g20_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [0, 1, 2, 3, 10, 11, 12, 13]
frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, pinky_roll, ring_roll, middle_roll, index_roll]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
/g20_calibration/side/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [4, 5, 6, 15, 17]
frames: [side_base, ring_pip, pinky_pip, middle_pip, index_pip]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016]
/g20_calibration/top/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [8, 9]
frames: [top_base, thumb_yaw]
sizes: [0.016, 0.016]
@@ -0,0 +1,63 @@
/g20_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [0, 1, 2, 3, 10, 11, 12, 13]
frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, pinky_roll, ring_roll, middle_roll, index_roll]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
/g20_calibration/side/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
# Distal Tags use the same measured 16 mm black-code edge as all others.
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [4, 5, 6, 7, 14, 15, 16, 17, 18]
frames: [side_base, ring_pip, pinky_pip, pinky_dip, ring_dip, middle_pip, middle_dip, index_pip, index_dip]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
/g20_calibration/top/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [8, 9]
frames: [top_base, thumb_yaw]
sizes: [0.016, 0.016]
@@ -0,0 +1,21 @@
"""One-release compatibility surface for the former Python package name.
New code must import :mod:`linkerhand_calibration`. Only the documented
configuration loader is re-exported here; calibration algorithms continue to
have a single implementation in the renamed package.
"""
from __future__ import annotations
import warnings
warnings.warn(
"g20_thumb_apriltag_calibration is deprecated; "
"import linkerhand_calibration instead",
DeprecationWarning,
stacklevel=2,
)
from linkerhand_calibration.product import ProductConfig, load_product_config
__all__ = ["ProductConfig", "load_product_config"]
@@ -0,0 +1,9 @@
"""Deprecated forwarding entry point for the runtime joint-state bridge."""
from linkerhand_calibration.calibrated_joint_state_bridge import main
__all__ = ["main"]
if __name__ == "__main__":
main()
@@ -0,0 +1,9 @@
"""Deprecated forwarding entry point for offline replay."""
from linkerhand_calibration.offline_replay import main
__all__ = ["main"]
if __name__ == "__main__":
main()
@@ -0,0 +1,9 @@
"""Deprecated forwarding entry point for the former Python package."""
from linkerhand_calibration.one_command import main
__all__ = ["main"]
if __name__ == "__main__":
main()
@@ -0,0 +1,37 @@
"""Publish profile-calibrated URDF angles from raw command/feedback u8 values."""
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
def generate_launch_description() -> LaunchDescription:
return LaunchDescription(
[
DeclareLaunchArgument("hand_type", default_value="right"),
DeclareLaunchArgument("calibration_file"),
DeclareLaunchArgument("input_topic", default_value=""),
DeclareLaunchArgument("output_topic", default_value=""),
Node(
package="linkerhand_calibration",
executable="calibrated_joint_state_bridge",
name=[
"calibrated_joint_state_bridge_",
LaunchConfiguration("hand_type"),
],
output="screen",
emulate_tty=True,
parameters=[
{
"hand_type": LaunchConfiguration("hand_type"),
"calibration_file": LaunchConfiguration(
"calibration_file"
),
"input_topic": LaunchConfiguration("input_topic"),
"output_topic": LaunchConfiguration("output_topic"),
}
],
),
]
)
@@ -0,0 +1,452 @@
"""Launch Profile-declared Hikrobot views and one calibration owner."""
from __future__ import annotations
from datetime import datetime
import hashlib
from pathlib import Path
import re
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import (
DeclareLaunchArgument,
ExecuteProcess,
LogInfo,
OpaqueFunction,
SetEnvironmentVariable,
)
from launch.conditions import IfCondition
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import ComposableNodeContainer, Node
from launch_ros.descriptions import ComposableNode
from launch_ros.parameter_descriptions import ParameterValue
def _launch_stack(context):
from linkerhand_calibration.product import (
ProductCalibrationContract,
)
from linkerhand_calibration.profiles import load_hand_profile
from linkerhand_calibration.runtime.adapters.ros_topics import sdk_topics
model = LaunchConfiguration("model").perform(context).strip().upper()
hand_type = LaunchConfiguration("hand_type").perform(context).lower()
if hand_type not in {"left", "right"}:
raise RuntimeError("hand_type must be left or right")
tag_layout = LaunchConfiguration("tag_layout").perform(context).lower()
try:
profile_path = LaunchConfiguration("profile_config").perform(context).strip()
if not profile_path:
raise ValueError("online calibration requires a protected YAML Profile; use calibrate_hand --config")
expected = LaunchConfiguration("profile_config_expected_sha256").perform(context).strip()
if not expected or hashlib.sha256(Path(profile_path).read_bytes()).hexdigest() != expected:
raise ValueError("Profile changed between product validation and launch")
contract = ProductCalibrationContract(declarative=load_hand_profile(profile_path))
key = contract.typed_profile.key
if (key.model, key.side, key.layout) != (model, hand_type, tag_layout):
raise ValueError("launch identity differs from the protected Profile")
except ValueError as error:
raise RuntimeError(str(error)) from error
views = contract.typed_profile.vision.view_names
requested_tag_config = LaunchConfiguration("tag_config").perform(context)
if not requested_tag_config:
raise RuntimeError("tag_config is required; use calibrate_hand --config")
tag_config = Path(requested_tag_config).expanduser().resolve()
if not tag_config.is_file():
raise RuntimeError(f"tag config does not exist: {tag_config}")
topic_prefix = f"/{model.lower()}"
uses_hcan = contract.typed_profile.sdk_adapter == "o12_hcan_sdk"
if contract.typed_profile.sdk_adapter not in {"legacy_byte_sdk", "o12_hcan_sdk"}:
raise RuntimeError("SDK adapter has no ROS launch binding")
topics = sdk_topics(contract.typed_profile)
command_topic, state_topic = topics.command, topics.feedback
requested_source = LaunchConfiguration("source_urdf_path").perform(context)
if not requested_source:
raise RuntimeError("source_urdf_path is required; use calibrate_hand --config")
source_urdf = Path(requested_source).expanduser().resolve()
if not source_urdf.is_file():
raise RuntimeError(f"source URDF does not exist: {source_urdf}")
expected_source_hash = LaunchConfiguration(
"source_urdf_expected_sha256"
).perform(context).strip().lower()
if contract.typed_profile.artifacts.publish_corrected_urdf:
if re.fullmatch(r"[0-9a-f]{64}", expected_source_hash) is None:
raise RuntimeError(
"this profile requires source_urdf_expected_sha256 confirmed "
"by the CAD/hardware owner"
)
actual_source_hash = hashlib.sha256(source_urdf.read_bytes()).hexdigest()
if actual_source_hash != expected_source_hash:
raise RuntimeError(
"source_urdf_expected_sha256 does not match source_urdf_path"
)
hand_serial = LaunchConfiguration("serial_number").perform(context)
if (
not hand_serial
or hand_serial == "UNSET"
or re.fullmatch(r"[A-Za-z0-9_.-]+", hand_serial) is None
or hand_serial in {".", ".."}
):
raise RuntimeError("serial_number must be a safe non-empty hand serial")
requested_session = LaunchConfiguration("session_dir").perform(context)
output_root = Path(
LaunchConfiguration("output_root").perform(context)
).expanduser().resolve()
if requested_session:
session_dir = Path(requested_session).expanduser().resolve()
else:
session_dir = (
output_root
/ hand_serial
/ datetime.now().strftime("%Y%m%d_%H%M%S")
)
session_dir.mkdir(parents=True, exist_ok=True)
camera_serials = {
view: LaunchConfiguration(f"{view}_camera_serial").perform(context)
for view in views
}
if any(not serial for serial in camera_serials.values()):
raise RuntimeError("all Profile camera serial numbers are required")
if len(set(camera_serials.values())) != len(views):
raise RuntimeError("Profile camera serial numbers must be unique")
cameras = []
components = []
raw_topics = []
info_topics = []
detection_topics = []
calibration_namespace = contract.typed_profile.namespace
for view in views:
namespace = f"{calibration_namespace}/{view}/camera"
raw_topic = f"{namespace}/image_raw"
info_topic = f"{namespace}/camera_info"
rect_topic = f"{namespace}/image_rect"
detector_namespace = f"{calibration_namespace}/{view}/apriltag"
detection_topic = f"{detector_namespace}/detections"
raw_topics.append(raw_topic)
info_topics.append(info_topic)
detection_topics.append(detection_topic)
cameras.append(
Node(
package="linkerhand_calibration",
executable="hikrobot_camera_node",
name="hikrobot_camera",
namespace=namespace,
output="screen",
emulate_tty=True,
condition=IfCondition(LaunchConfiguration("start_cameras")),
parameters=[
{
"serial_number": LaunchConfiguration(
f"{view}_camera_serial"
),
"expected_model": LaunchConfiguration("camera_model"),
"camera_name": LaunchConfiguration(
f"{view}_camera_name"
),
"frame_id": (
f"{model.lower()}_calibration_{view}_optical_frame"
),
"image_width": 1624,
"image_height": 1240,
"frame_rate": ParameterValue(
LaunchConfiguration("camera_frame_rate"),
value_type=float,
),
"exposure_time_us": ParameterValue(
LaunchConfiguration("exposure_time_us"),
value_type=float,
),
"gain_db": ParameterValue(
LaunchConfiguration("gain_db"), value_type=float
),
"auto_exposure": ParameterValue(
LaunchConfiguration("auto_exposure"), value_type=bool
),
"camera_info_url": LaunchConfiguration(
f"{view}_camera_info_url"
),
}
],
)
)
components.extend(
[
ComposableNode(
package="image_proc",
plugin="image_proc::RectifyNode",
name=f"rectify_{view}",
namespace=namespace,
remappings=[
("image", raw_topic),
("camera_info", info_topic),
("image_rect", rect_topic),
],
parameters=[{"queue_size": 1}],
extra_arguments=[{"use_intra_process_comms": True}],
),
ComposableNode(
package="apriltag_ros",
plugin="AprilTagNode",
name="apriltag",
namespace=detector_namespace,
parameters=[
str(tag_config),
{
"detector.decimate": ParameterValue(
LaunchConfiguration("apriltag_decimate"),
value_type=float,
)
},
],
remappings=[
("image_rect", rect_topic),
("camera_info", info_topic),
],
extra_arguments=[{"use_intra_process_comms": True}],
),
]
)
vision = ComposableNodeContainer(
name=f"{model.lower()}_calibration_vision",
namespace="/",
package="rclcpp_components",
executable="component_container_mt",
composable_node_descriptions=components,
output="screen",
emulate_tty=True,
)
sdk = (
Node(
package="linkerhand_calibration",
executable="o12_sdk_bridge",
name="o12_sdk_bridge",
output="screen",
condition=IfCondition(LaunchConfiguration("start_sdk")),
parameters=[{
"vendor_config": LaunchConfiguration("vendor_sdk_config"),
"vendor_config_sha256": LaunchConfiguration("sdk_config_expected_sha256"),
"vendor_python_package": LaunchConfiguration("vendor_sdk_python_package"),
"vendor_package_sha256": LaunchConfiguration("sdk_package_expected_sha256"),
"hand_type": hand_type,
"topic_prefix": f"/{model.lower()}/{hand_type}",
}],
)
if uses_hcan
else Node(
package="linker_hand_ros2_sdk",
executable="linker_hand_sdk",
name="linker_hand_sdk",
output="screen",
condition=IfCondition(LaunchConfiguration("start_sdk")),
parameters=[{
"hand_type": hand_type,
"hand_joint": model,
"can": LaunchConfiguration("can_interface"),
"modbus": "None",
"topic_prefix": topic_prefix,
"move_on_startup": False,
"startup_speed": ParameterValue(
LaunchConfiguration("calibration_speed"), value_type=int
),
"startup_torque": 80,
# Match 30 Hz cameras so state/image p95 skew stays below 50 ms.
"state_poll_rate": 30.0,
# Calibration does not consume measured joint velocity. A
# G20 velocity read sends another five synchronous CAN
# queries, so keep it off the trajectory-critical path.
"velocity_poll_rate": 1.0,
# G20 sends an endpoint and L6 streams a bounded trajectory.
# Keep polling the real motor state during either command path;
# otherwise the SDK republishes stale state and creates large
# command-unit holes in the trajectory bins.
"defer_state_reads_while_commanding": False,
"repeat_position_commands": False,
"is_touch": False,
}],
)
)
calibration = Node(
package="linkerhand_calibration",
executable="three_camera_calibration_node",
name=f"{model.lower()}_calibration",
output="screen",
emulate_tty=True,
arguments=[
"--profile-id",
contract.typed_profile.key.profile_id,
"--profile-config", profile_path, "--profile-sha256", expected,
],
parameters=[
LaunchConfiguration("calibration_config"),
{
"serial_number": hand_serial,
"session_dir": str(session_dir),
"resume_raw_samples_path": LaunchConfiguration(
"resume_raw_samples_path"
),
"command_topic": command_topic,
"state_topic": state_topic,
"camera_extrinsics_file": LaunchConfiguration(
"camera_extrinsics_file"
),
"source_urdf_path": str(source_urdf),
"source_urdf_expected_sha256": LaunchConfiguration(
"source_urdf_expected_sha256"
),
"camera_extrinsics_expected_sha256": LaunchConfiguration(
"camera_extrinsics_expected_sha256"
),
"calibration_config_expected_sha256": LaunchConfiguration(
"calibration_config_expected_sha256"
),
"tag_config_expected_sha256": LaunchConfiguration(
"tag_config_expected_sha256"
),
"sdk_config_expected_sha256": LaunchConfiguration(
"sdk_config_expected_sha256"
),
"sdk_package_expected_sha256": LaunchConfiguration("sdk_package_expected_sha256"),
"profile_config_expected_sha256": LaunchConfiguration(
"profile_config_expected_sha256"
),
"commands_enabled": ParameterValue(
LaunchConfiguration("commands_enabled"), value_type=bool
),
},
],
)
bag = ExecuteProcess(
condition=IfCondition(LaunchConfiguration("record_bag")),
cmd=[
"ros2",
"bag",
"record",
"--storage",
"mcap",
"--storage-preset-profile",
"zstd_fast",
"--max-bag-size",
"10737418240",
"--output",
str(session_dir / "rosbag"),
*raw_topics,
*info_topics,
*detection_topics,
command_topic,
state_topic,
*(
[
f"/{model.lower()}/{hand_type}/calibration_health",
]
if uses_hcan else []
),
f"{calibration_namespace}/status",
],
output="screen",
)
return [
LogInfo(
msg=(
f"{model} {hand_type} {tag_layout} calibration session: {session_dir}; "
f"source_urdf={source_urdf}"
)
),
LogInfo(
msg=(
"Camera mapping: " + " ".join(f"{view}={serial}" for view, serial in camera_serials.items())
)
),
*cameras,
vision,
sdk,
calibration,
bag,
]
def generate_launch_description() -> LaunchDescription:
package_share = Path(
get_package_share_directory("linkerhand_calibration")
)
return LaunchDescription(
[
# Camera processes publish ~2 MB frames across DDS. Force the
# matching RMW and provide both current and legacy profile names
# so the configured 64 MB shared-memory segment is actually used.
SetEnvironmentVariable(
name="RMW_IMPLEMENTATION",
value="rmw_fastrtps_cpp",
),
SetEnvironmentVariable(
name="FASTDDS_DEFAULT_PROFILES_FILE",
value=str(package_share / "config" / "fastdds_large_images.xml"),
),
SetEnvironmentVariable(
name="FASTRTPS_DEFAULT_PROFILES_FILE",
value=str(package_share / "config" / "fastdds_large_images.xml"),
),
DeclareLaunchArgument("model", default_value=""),
DeclareLaunchArgument("hand_type", default_value=""),
DeclareLaunchArgument("tag_layout", default_value=""),
DeclareLaunchArgument("serial_number", default_value="UNSET"),
DeclareLaunchArgument("camera_model", default_value="MV-CS020-10U"),
DeclareLaunchArgument("camera_frame_rate", default_value="30.0"),
DeclareLaunchArgument("exposure_time_us", default_value="5000.0"),
DeclareLaunchArgument("gain_db", default_value="0.0"),
DeclareLaunchArgument("auto_exposure", default_value="false"),
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
DeclareLaunchArgument("can_interface", default_value="can0"),
DeclareLaunchArgument("calibration_speed", default_value="15"),
DeclareLaunchArgument("camera_extrinsics_file", default_value=""),
DeclareLaunchArgument(
"source_urdf_path", default_value=""
),
DeclareLaunchArgument(
"source_urdf_expected_sha256", default_value=""
),
DeclareLaunchArgument(
"camera_extrinsics_expected_sha256", default_value=""
),
DeclareLaunchArgument(
"calibration_config_expected_sha256", default_value=""
),
DeclareLaunchArgument(
"tag_config_expected_sha256", default_value=""
),
DeclareLaunchArgument("vendor_sdk_config", default_value=""),
DeclareLaunchArgument("vendor_sdk_python_package", default_value=""),
DeclareLaunchArgument("sdk_package_expected_sha256", default_value=""),
DeclareLaunchArgument(
"sdk_config_expected_sha256", default_value=""
),
DeclareLaunchArgument(
"profile_config_expected_sha256", default_value=""
),
DeclareLaunchArgument("profile_config", default_value=""),
DeclareLaunchArgument("commands_enabled", default_value="true"),
DeclareLaunchArgument("start_cameras", default_value="true"),
DeclareLaunchArgument("start_sdk", default_value="true"),
DeclareLaunchArgument("record_bag", default_value="false"),
DeclareLaunchArgument(
"output_root",
default_value=str(Path.cwd() / "calibration_output"),
),
DeclareLaunchArgument("session_dir", default_value=""),
DeclareLaunchArgument("resume_raw_samples_path", default_value=""),
DeclareLaunchArgument(
"calibration_config",
default_value="",
),
DeclareLaunchArgument(
"tag_config",
default_value="",
),
OpaqueFunction(function=_launch_stack),
]
)
@@ -0,0 +1,209 @@
"""Launch three Hikrobot cameras for one-time checkerboard extrinsics."""
from pathlib import Path
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, OpaqueFunction, SetEnvironmentVariable
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import ComposableNodeContainer, Node
from launch_ros.descriptions import ComposableNode
from launch_ros.parameter_descriptions import ParameterValue
VIEWS = ("front", "side", "top")
def _launch(context):
cameras = []
rectifiers = []
serials = {}
for view in VIEWS:
serial = LaunchConfiguration(f"{view}_camera_serial").perform(context)
if not serial:
raise RuntimeError(f"{view}_camera_serial is required")
serials[view] = serial
namespace = f"/g20_extrinsics/{view}/camera"
cameras.append(
Node(
package="linkerhand_calibration",
executable="hikrobot_camera_node",
name="hikrobot_camera",
namespace=namespace,
output="screen",
emulate_tty=True,
parameters=[
{
"serial_number": serial,
"expected_model": LaunchConfiguration("camera_model"),
"camera_name": LaunchConfiguration(
f"{view}_camera_name"
),
"frame_id": f"g20_extrinsics_{view}_optical_frame",
"image_width": 1624,
"image_height": 1240,
"frame_rate": ParameterValue(
LaunchConfiguration("camera_frame_rate"),
value_type=float,
),
"exposure_time_us": ParameterValue(
LaunchConfiguration("exposure_time_us"),
value_type=float,
),
"gain_db": ParameterValue(
LaunchConfiguration("gain_db"), value_type=float
),
"auto_exposure": False,
"camera_info_url": LaunchConfiguration(
f"{view}_camera_info_url"
),
}
],
)
)
rectifiers.append(
ComposableNode(
package="image_proc",
plugin="image_proc::RectifyNode",
name=f"rectify_{view}",
namespace=namespace,
remappings=[
("image", f"{namespace}/image_raw"),
("camera_info", f"{namespace}/camera_info"),
("image_rect", f"{namespace}/image_rect"),
],
parameters=[{"queue_size": 1}],
extra_arguments=[{"use_intra_process_comms": True}],
)
)
container = ComposableNodeContainer(
name="g20_extrinsics_vision",
namespace="/",
package="rclcpp_components",
executable="component_container_mt",
composable_node_descriptions=rectifiers,
output="screen",
)
solver = Node(
package="linkerhand_calibration",
executable="three_camera_extrinsics_node",
name="g20_camera_extrinsics",
output="screen",
emulate_tty=True,
parameters=[
{
"output_file": LaunchConfiguration("output_file"),
"checkerboard_columns": ParameterValue(
LaunchConfiguration("checkerboard_columns"), value_type=int
),
"checkerboard_rows": ParameterValue(
LaunchConfiguration("checkerboard_rows"), value_type=int
),
"square_size_m": ParameterValue(
LaunchConfiguration("square_size_m"), value_type=float
),
"enable_gui": ParameterValue(
LaunchConfiguration("enable_gui"), value_type=bool
),
"gui_refresh_hz": ParameterValue(
LaunchConfiguration("gui_refresh_hz"), value_type=float
),
"maximum_reprojection_rms_px": ParameterValue(
LaunchConfiguration("maximum_reprojection_rms_px"),
value_type=float,
),
"maximum_candidate_pair_reprojection_rms_px": ParameterValue(
LaunchConfiguration(
"maximum_candidate_pair_reprojection_rms_px"
),
value_type=float,
),
"maximum_single_camera_reprojection_rms_px": ParameterValue(
LaunchConfiguration(
"maximum_single_camera_reprojection_rms_px"
),
value_type=float,
),
"auto_capture_default": ParameterValue(
LaunchConfiguration("auto_capture_default"),
value_type=bool,
),
"auto_capture_stable_seconds": ParameterValue(
LaunchConfiguration("auto_capture_stable_seconds"),
value_type=float,
),
**{
f"{view}_camera_serial": serials[view]
for view in VIEWS
},
}
],
)
return [*cameras, container, solver]
def generate_launch_description() -> LaunchDescription:
package_share = Path(
get_package_share_directory("linkerhand_calibration")
)
camera_info = Path.home() / ".ros" / "camera_info"
return LaunchDescription(
[
# Keep the large-image transport deterministic even when the
# calling shell selected another ROS 2 RMW implementation.
SetEnvironmentVariable(
name="RMW_IMPLEMENTATION",
value="rmw_fastrtps_cpp",
),
SetEnvironmentVariable(
name="FASTDDS_DEFAULT_PROFILES_FILE",
value=str(
package_share / "config" / "fastdds_large_images.xml"
),
),
SetEnvironmentVariable(
name="FASTRTPS_DEFAULT_PROFILES_FILE",
value=str(
package_share / "config" / "fastdds_large_images.xml"
),
),
DeclareLaunchArgument("front_camera_serial", default_value="DB2163742"),
DeclareLaunchArgument("side_camera_serial", default_value="DB2163749"),
DeclareLaunchArgument("top_camera_serial", default_value="DB2163739"),
DeclareLaunchArgument("camera_model", default_value="MV-CS020-10U"),
DeclareLaunchArgument("front_camera_name", default_value="hikrobot_front_DB2163742"),
DeclareLaunchArgument("side_camera_name", default_value="hikrobot_side_DB2163749"),
DeclareLaunchArgument("top_camera_name", default_value="hikrobot_top_DB2163739"),
DeclareLaunchArgument("front_camera_info_url", default_value=str(camera_info / "hikrobot_DB2163742.yaml")),
DeclareLaunchArgument("side_camera_info_url", default_value=str(camera_info / "hikrobot_DB2163749.yaml")),
DeclareLaunchArgument("top_camera_info_url", default_value=str(camera_info / "hikrobot_DB2163739.yaml")),
DeclareLaunchArgument("camera_frame_rate", default_value="15.0"),
DeclareLaunchArgument("exposure_time_us", default_value="5000.0"),
DeclareLaunchArgument("gain_db", default_value="0.0"),
DeclareLaunchArgument("checkerboard_columns", default_value="8"),
DeclareLaunchArgument("checkerboard_rows", default_value="5"),
DeclareLaunchArgument("square_size_m", default_value="0.027"),
DeclareLaunchArgument("enable_gui", default_value="true"),
DeclareLaunchArgument("gui_refresh_hz", default_value="2.0"),
DeclareLaunchArgument(
"maximum_reprojection_rms_px", default_value="1.2"
),
DeclareLaunchArgument(
"maximum_candidate_pair_reprojection_rms_px",
default_value="1.5",
),
DeclareLaunchArgument(
"maximum_single_camera_reprojection_rms_px",
default_value="1.5",
),
DeclareLaunchArgument("auto_capture_default", default_value="false"),
DeclareLaunchArgument(
"auto_capture_stable_seconds", default_value="1.0"
),
DeclareLaunchArgument(
"output_file",
default_value=str(Path.cwd() / "config" / "g20_three_camera_extrinsics.yaml"),
),
OpaqueFunction(function=_launch),
]
)
@@ -0,0 +1,18 @@
"""Stable launch name for the profile-driven calibration stack."""
from __future__ import annotations
import importlib.util
from pathlib import Path
def generate_launch_description():
implementation = Path(__file__).with_name("three_camera_calibration.launch.py")
spec = importlib.util.spec_from_file_location(
"linkerhand_unified_calibration_launch", implementation
)
if spec is None or spec.loader is None:
raise RuntimeError("unified calibration launch implementation is missing")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.generate_launch_description()
@@ -0,0 +1,5 @@
"""Profile-driven LinkerHand calibration and validated URDF correction."""
from .core import CalibrationProfile, ProfileKey
__all__ = ["CalibrationProfile", "ProfileKey"]
@@ -0,0 +1,724 @@
"""Hardware-independent point acquisition state."""
from __future__ import annotations
from bisect import bisect_left
from collections import deque
from dataclasses import dataclass, field
from typing import Any, Mapping, Sequence
import numpy as np
from .core import robust_rotation_summary
from .pnp import SquareTagPose
TAG_PAIR_ROLES: dict[str, tuple[str, str]] = {
"t0_t3": ("t0", "t3"),
"t3_t4": ("t3", "t4"),
"t4_t5": ("t4", "t5"),
}
PAIR_NAMES: tuple[str, ...] = tuple(TAG_PAIR_ROLES)
@dataclass(frozen=True)
class TagQuality:
hamming: int
decision_margin: float
edge_pixels: float
reprojection_error_px: float | None = None
def tag_quality_is_valid(
quality: TagQuality,
*,
maximum_hamming: int,
minimum_decision_margin: float,
minimum_edge_pixels: float,
maximum_reprojection_error_px: float | None = None,
) -> bool:
detection_valid = (
quality.hamming <= maximum_hamming
and quality.decision_margin >= minimum_decision_margin
and quality.edge_pixels >= minimum_edge_pixels
)
if not detection_valid:
return False
if maximum_reprojection_error_px is None:
return True
return (
quality.reprojection_error_px is not None
and quality.reprojection_error_px <= maximum_reprojection_error_px
)
def update_pnp_reset_watchdog(
*,
detection_good: bool,
pnp_valid: bool,
now: float,
invalid_since: float | None,
reset_after_seconds: float,
) -> tuple[float | None, bool]:
"""Track continuous PnP-only failures and request a throttled reset."""
reset_after = float(reset_after_seconds)
if reset_after <= 0.0:
raise ValueError("reset_after_seconds must be positive")
if not detection_good or pnp_valid:
return None, False
since = float(now) if invalid_since is None else float(invalid_since)
if float(now) - since >= reset_after:
# Start a new interval so a permanently bad view is not reset on every
# frame. The next valid frame clears the interval.
return float(now), True
return since, False
def required_resume_views(active_view: str | None) -> tuple[str, ...]:
"""Require only the active view on resume; start still checks all views."""
all_views = ("front", "side", "top")
if active_view is None:
return all_views
view = str(active_view)
if view not in all_views:
raise ValueError(f"unknown calibration view: {view}")
return (view,)
@dataclass(frozen=True)
class Observation:
stamp_ns: int
received_at: float
relative_quaternion_xyzw: Mapping[str, tuple[float, float, float, float]]
tag_quality: Mapping[str, TagQuality]
state_u8: tuple[float, ...] = ()
state_stamp_ns: int | None = None
state_sync_error_ns: int | None = None
tag_quaternion_xyzw: Mapping[
str, tuple[float, float, float, float]
] = field(default_factory=dict)
tag_translation_xyz_m: Mapping[
str, tuple[float, float, float]
] = field(default_factory=dict)
tag_pose_candidates: Mapping[
str, tuple[SquareTagPose, ...]
] = field(default_factory=dict)
@dataclass(frozen=True)
class StateSample:
stamp_ns: int
position_u8: tuple[float, ...]
def interpolate_state_u8(
samples: Sequence[StateSample],
stamp_ns: int,
*,
maximum_skew_ns: int,
) -> tuple[tuple[float, ...], int] | None:
"""Interpolate a profile-sized hand state at an image timestamp.
The SDK publishes state independently from the camera. Continuous
calibration must therefore use the image timestamp instead of whichever
state happened to arrive most recently in the ROS callback thread.
"""
if maximum_skew_ns < 0:
raise ValueError("maximum_skew_ns must be non-negative")
if not samples:
return None
stamps = [int(sample.stamp_ns) for sample in samples]
index = bisect_left(stamps, int(stamp_ns))
if index < len(samples) and stamps[index] == int(stamp_ns):
state = samples[index].position_u8
return (tuple(float(value) for value in state), 0)
before = samples[index - 1] if index > 0 else None
after = samples[index] if index < len(samples) else None
if before is not None and after is not None:
before_gap = int(stamp_ns) - int(before.stamp_ns)
after_gap = int(after.stamp_ns) - int(stamp_ns)
nearest_gap = min(before_gap, after_gap)
if nearest_gap > maximum_skew_ns:
return None
denominator = int(after.stamp_ns) - int(before.stamp_ns)
if denominator <= 0:
return (
tuple(float(value) for value in before.position_u8),
nearest_gap,
)
fraction = before_gap / denominator
before_values = np.asarray(before.position_u8, dtype=float)
after_values = np.asarray(after.position_u8, dtype=float)
if (
before_values.ndim != 1
or before_values.size == 0
or after_values.shape != before_values.shape
):
return None
interpolated = before_values + fraction * (after_values - before_values)
return (
tuple(float(value) for value in interpolated),
nearest_gap,
)
nearest = before if before is not None else after
if nearest is None:
return None
gap = abs(int(stamp_ns) - int(nearest.stamp_ns))
if gap > maximum_skew_ns or not nearest.position_u8:
return None
return (tuple(float(value) for value in nearest.position_u8), gap)
# Physical-angle profiles use the same timestamp interpolation. Keep the old
# public name for compatibility and offer a unit-neutral spelling to new code.
interpolate_state = interpolate_state_u8
class ContinuousSweepCollector:
"""Collect timestamp-synchronised observations during one end-to-end move."""
def __init__(
self,
*,
endpoint_tolerance_u8: float = 2.0,
endpoint_hold_seconds: float = 1.0,
timeout_seconds: float = 90.0,
invalid_timeout_seconds: float = 2.0,
minimum_valid_frames: int = 40,
minimum_state_span_u8: float = 240.0,
) -> None:
if endpoint_tolerance_u8 < 0.0:
raise ValueError("endpoint_tolerance_u8 must be non-negative")
if endpoint_hold_seconds <= 0.0:
raise ValueError("endpoint_hold_seconds must be positive")
if timeout_seconds <= 0.0 or invalid_timeout_seconds <= 0.0:
raise ValueError("sweep timeouts must be positive")
if minimum_valid_frames < 3:
raise ValueError("minimum_valid_frames must be at least 3")
if minimum_state_span_u8 <= 0.0:
raise ValueError("minimum_state_span_u8 must be positive")
self.endpoint_tolerance_u8 = float(endpoint_tolerance_u8)
self.endpoint_hold_seconds = float(endpoint_hold_seconds)
self.timeout_seconds = float(timeout_seconds)
self.invalid_timeout_seconds = float(invalid_timeout_seconds)
self.minimum_valid_frames = int(minimum_valid_frames)
self.minimum_state_span_u8 = float(minimum_state_span_u8)
self.observations: list[Observation] = []
self.motor_index = 0
self.start_u8 = 255.0
self.target_u8 = 0.0
self.started_at: float | None = None
self.last_valid_at: float | None = None
self.endpoint_since: float | None = None
self.state = "idle"
self.reason = ""
def start(
self,
now: float,
*,
motor_index: int,
start_u8: int,
target_u8: int,
) -> None:
if motor_index not in (0, 15):
raise ValueError("continuous thumb sweep only permits motor 0 or 15")
if {int(start_u8), int(target_u8)} != {0, 255}:
raise ValueError("continuous sweep endpoints must be 0 and 255")
self.observations.clear()
self.motor_index = int(motor_index)
self.start_u8 = float(start_u8)
self.target_u8 = float(target_u8)
self.started_at = float(now)
self.last_valid_at = float(now)
self.endpoint_since = None
self.state = "collecting"
self.reason = ""
@property
def active(self) -> bool:
return self.state == "collecting"
@property
def valid_frames_seen(self) -> int:
return len(self.observations)
@property
def state_span_u8(self) -> float:
if not self.observations:
return 0.0
values = [
float(observation.state_u8[self.motor_index])
for observation in self.observations
]
return float(max(values) - min(values))
def add(
self, observation: Observation, now: float
) -> list[Observation] | None:
if not self.active:
return None
if (
len(observation.state_u8) != 20
or observation.state_sync_error_ns is None
):
return None
value = float(observation.state_u8[self.motor_index])
if not np.isfinite(value) or not -3.0 <= value <= 258.0:
return None
now = float(now)
self.observations.append(observation)
self.last_valid_at = now
if abs(value - self.target_u8) <= self.endpoint_tolerance_u8:
if self.endpoint_since is None:
self.endpoint_since = now
else:
self.endpoint_since = None
enough_endpoint_hold = (
self.endpoint_since is not None
and now - self.endpoint_since >= self.endpoint_hold_seconds
)
if (
enough_endpoint_hold
and len(self.observations) >= self.minimum_valid_frames
and self.state_span_u8 >= self.minimum_state_span_u8
):
self.state = "complete"
return list(self.observations)
return None
def poll(self, now: float) -> None:
if not self.active:
return
now = float(now)
if now - float(self.started_at) > self.timeout_seconds:
self.state = "failed"
self.reason = "sweep_timeout"
elif now - float(self.last_valid_at) > self.invalid_timeout_seconds:
self.state = "failed"
self.reason = "synchronised_tag_state_timeout"
def aggregate_sweep_observations(
observations: Sequence[Observation],
*,
motor_index: int,
start_u8: int,
target_u8: int,
endpoint_tolerance_u8: float,
) -> dict[int, dict[str, Any]]:
"""Robustly aggregate continuous observations into integer motor bins."""
if not observations:
raise ValueError("cannot aggregate an empty continuous sweep")
bins: dict[int, list[Observation]] = {}
for observation in observations:
if len(observation.state_u8) != 20:
continue
value = float(observation.state_u8[motor_index])
if abs(value - float(start_u8)) <= endpoint_tolerance_u8:
command = int(start_u8)
elif abs(value - float(target_u8)) <= endpoint_tolerance_u8:
command = int(target_u8)
else:
command = int(np.clip(np.rint(value), 0, 255))
bins.setdefault(command, []).append(observation)
return {
command: aggregate_observations(values)
for command, values in sorted(bins.items())
}
class PointCollector:
"""Wait for a stable pose, then aggregate a fixed number of frames."""
def __init__(
self,
*,
stable_frames: int = 15,
capture_frames: int = 30,
minimum_settle_seconds: float = 0.4,
maximum_stable_spread_rad: float = np.deg2rad(0.3),
stability_mode: str = "rotation",
maximum_stable_translation_spread_m: float = 0.003,
settle_timeout_seconds: float = 5.0,
capture_timeout_seconds: float = 5.0,
) -> None:
if stable_frames < 3 or capture_frames < 3:
raise ValueError("stable_frames and capture_frames must be at least 3")
self.stable_frames = int(stable_frames)
self.capture_frames = int(capture_frames)
self.minimum_settle_seconds = float(minimum_settle_seconds)
self.maximum_stable_spread_rad = float(maximum_stable_spread_rad)
self.stability_mode = str(stability_mode)
self.maximum_stable_translation_spread_m = float(
maximum_stable_translation_spread_m
)
if self.stability_mode not in {"rotation", "translation"}:
raise ValueError(
"stability_mode must be rotation or translation"
)
if self.maximum_stable_translation_spread_m <= 0.0:
raise ValueError(
"maximum_stable_translation_spread_m must be positive"
)
self.settle_timeout_seconds = float(settle_timeout_seconds)
self.capture_timeout_seconds = float(capture_timeout_seconds)
self._stable: deque[Observation] = deque(maxlen=self.stable_frames)
self._captured: list[Observation] = []
self._consecutive_invalid_frames = 0
self.started_at: float | None = None
self.capture_started_at: float | None = None
self.state = "idle"
self.reason = ""
self.stable_spread_rad: dict[str, float] = {}
self.stable_spread_m: dict[str, float] = {}
self.required_state_index: int | None = None
self.required_state_u8: float | None = None
self.maximum_state_error_u8: float | None = None
def start(
self,
now: float,
*,
required_state_index: int | None = None,
required_state_u8: float | None = None,
maximum_state_error_u8: float | None = None,
) -> None:
state_constraints = (
required_state_index,
required_state_u8,
maximum_state_error_u8,
)
if any(value is not None for value in state_constraints) and not all(
value is not None for value in state_constraints
):
raise ValueError(
"point state constraint parameters must be provided together"
)
if required_state_index is not None:
if not 0 <= int(required_state_index) < 20:
raise ValueError("required_state_index must be in [0, 19]")
if not np.isfinite(float(required_state_u8)):
raise ValueError("required_state_u8 must be finite")
if float(maximum_state_error_u8) < 0.0:
raise ValueError(
"maximum_state_error_u8 must be non-negative"
)
self._stable.clear()
self._captured.clear()
self._consecutive_invalid_frames = 0
self.started_at = float(now)
self.capture_started_at = None
self.state = "settling"
self.reason = ""
self.stable_spread_rad = {}
self.stable_spread_m = {}
self.required_state_index = (
None
if required_state_index is None
else int(required_state_index)
)
self.required_state_u8 = (
None if required_state_u8 is None else float(required_state_u8)
)
self.maximum_state_error_u8 = (
None
if maximum_state_error_u8 is None
else float(maximum_state_error_u8)
)
@property
def active(self) -> bool:
return self.state in {"settling", "capturing"}
@property
def stable_frames_seen(self) -> int:
return len(self._stable)
@property
def capture_frames_seen(self) -> int:
return len(self._captured)
def _window_is_stable(self) -> bool:
if len(self._stable) < self.stable_frames:
return False
if self.stability_mode == "translation":
spreads: dict[str, float] = {}
for pair, (parent, child) in TAG_PAIR_ROLES.items():
if any(
parent not in observation.tag_translation_xyz_m
or child not in observation.tag_translation_xyz_m
for observation in self._stable
):
self.reason = f"{pair}_translation_missing"
return False
vectors = np.asarray(
[
np.asarray(
observation.tag_translation_xyz_m[child],
dtype=float,
)
- np.asarray(
observation.tag_translation_xyz_m[parent],
dtype=float,
)
for observation in self._stable
],
dtype=float,
)
reference = np.median(vectors, axis=0)
spreads[pair] = float(
np.max(np.linalg.norm(vectors - reference, axis=1))
)
self.stable_spread_m = spreads
for pair, spread in spreads.items():
if spread > self.maximum_stable_translation_spread_m:
self.reason = f"{pair}_not_stable"
return False
return True
spreads: dict[str, float] = {}
for pair in PAIR_NAMES:
quaternions = [
observation.relative_quaternion_xyzw[pair]
for observation in self._stable
]
_, spread = robust_rotation_summary(quaternions)
spreads[pair] = float(spread)
self.stable_spread_rad = spreads
for pair, spread in spreads.items():
if spread > self.maximum_stable_spread_rad:
self.reason = f"{pair}_not_stable"
return False
return True
def _state_is_acceptable(self, observation: Observation) -> bool:
if self.required_state_index is None:
return True
if (
len(observation.state_u8) != 20
or observation.state_sync_error_ns is None
):
return False
value = float(observation.state_u8[self.required_state_index])
return bool(
np.isfinite(value)
and abs(value - float(self.required_state_u8))
<= float(self.maximum_state_error_u8)
)
def _return_to_settling(self, reason: str) -> None:
self._stable.clear()
self._captured.clear()
self._consecutive_invalid_frames = 0
self.capture_started_at = None
self.state = "settling"
self.reason = str(reason)
self.stable_spread_rad = {}
self.stable_spread_m = {}
def add(
self, observation: Observation, now: float
) -> dict[str, Any] | None:
if not self.active:
return None
self._consecutive_invalid_frames = 0
now = float(now)
if not self._state_is_acceptable(observation):
self._return_to_settling("motor_position_out_of_tolerance")
return None
if self.state == "settling":
self._stable.append(observation)
elapsed = now - float(self.started_at)
if elapsed >= self.minimum_settle_seconds and self._window_is_stable():
self.state = "capturing"
self.capture_started_at = now
self._captured.clear()
self.reason = ""
return None
self._captured.append(observation)
if len(self._captured) < self.capture_frames:
return None
# Validate continuity across the boundary as well as inside the
# capture block. A planar branch can switch immediately after the
# stable window and then look perfectly stable for every capture
# frame; checking only the captured frames would accept that jump.
stability_aggregate = aggregate_observations(
[*self._stable, *self._captured]
)
if self.stability_mode == "translation":
unstable_pairs = [
pair
for pair, spread in stability_aggregate[
"maximum_translation_spread_m"
].items()
if float(spread)
> self.maximum_stable_translation_spread_m
]
else:
unstable_pairs = [
pair
for pair, spread in stability_aggregate[
"maximum_spread_rad"
].items()
if float(spread) > self.maximum_stable_spread_rad
]
if unstable_pairs:
self._return_to_settling(
f"{unstable_pairs[0]}_capture_not_stable"
)
return None
self.state = "complete"
return aggregate_observations(self._captured)
def poll(self, now: float) -> None:
if not self.active:
return
now = float(now)
if self.state == "settling":
if now - float(self.started_at) > self.settle_timeout_seconds:
self.state = "failed"
self.reason = self.reason or "settle_timeout"
elif self.state == "capturing":
if now - float(self.capture_started_at) > self.capture_timeout_seconds:
self.state = "failed"
self.reason = "capture_timeout"
def mark_invalid_frame(self) -> None:
"""Skip one invalid frame while retaining the recent valid window."""
if self.state == "capturing":
self._return_to_settling("invalid_tag_frame")
return
if self.state == "settling":
self._consecutive_invalid_frames += 1
if self._consecutive_invalid_frames >= 3:
self._stable.clear()
self.reason = "invalid_tag_frame"
def aggregate_observations(
observations: Sequence[Observation],
) -> dict[str, Any]:
if not observations:
raise ValueError("cannot aggregate an empty observation sequence")
relative: dict[str, list[float]] = {}
spread: dict[str, float] = {}
for pair in PAIR_NAMES:
quaternion, maximum = robust_rotation_summary(
[
observation.relative_quaternion_xyzw[pair]
for observation in observations
]
)
relative[pair] = [float(value) for value in quaternion]
spread[pair] = float(maximum)
quality: dict[str, dict[str, float]] = {}
tag_names = sorted(observations[0].tag_quality)
for tag_name in tag_names:
values = [
observation.tag_quality[tag_name] for observation in observations
]
quality[tag_name] = {
"minimum_decision_margin": float(
min(value.decision_margin for value in values)
),
"minimum_edge_pixels": float(min(value.edge_pixels for value in values)),
"maximum_hamming": int(max(value.hamming for value in values)),
}
reprojection_errors = [
float(value.reprojection_error_px)
for value in values
if value.reprojection_error_px is not None
]
if reprojection_errors:
quality[tag_name]["maximum_reprojection_error_px"] = float(
max(reprojection_errors)
)
states = [
observation.state_u8
for observation in observations
if len(observation.state_u8) == 20
]
state_median: list[float] = []
if states:
state_median = [
float(value)
for value in np.median(np.asarray(states, dtype=float), axis=0)
]
sync_errors = [
int(observation.state_sync_error_ns)
for observation in observations
if observation.state_sync_error_ns is not None
]
tag_translations: dict[str, list[float]] = {}
translation_spread: dict[str, float] = {}
translation_roles = sorted(
set.intersection(
*(
set(observation.tag_translation_xyz_m)
for observation in observations
)
)
if observations
else set()
)
for role in translation_roles:
values = np.asarray(
[
observation.tag_translation_xyz_m[role]
for observation in observations
],
dtype=float,
)
if values.shape == (len(observations), 3) and np.all(
np.isfinite(values)
):
tag_translations[role] = [
float(value)
for value in np.median(values, axis=0)
]
for pair, (parent, child) in TAG_PAIR_ROLES.items():
if parent not in translation_roles or child not in translation_roles:
continue
vectors = np.asarray(
[
np.asarray(
observation.tag_translation_xyz_m[child],
dtype=float,
)
- np.asarray(
observation.tag_translation_xyz_m[parent],
dtype=float,
)
for observation in observations
],
dtype=float,
)
reference = np.median(vectors, axis=0)
translation_spread[pair] = float(
np.max(np.linalg.norm(vectors - reference, axis=1))
)
return {
"stamp_start_ns": int(observations[0].stamp_ns),
"stamp_end_ns": int(observations[-1].stamp_ns),
"valid_frames": len(observations),
"relative_quaternion_xyzw": relative,
"maximum_spread_rad": spread,
"tag_quality": quality,
"state_u8_median": state_median,
"tag_translation_xyz_m": tag_translations,
"maximum_translation_spread_m": translation_spread,
"maximum_state_sync_error_ms": (
None
if not sync_errors
else float(max(sync_errors)) / 1_000_000.0
),
}
@@ -0,0 +1,358 @@
"""Visual roll-alignment aid for one G20 calibration camera."""
from __future__ import annotations
from collections import deque
import math
import time
from typing import Any, Mapping, Sequence
from apriltag_msgs.msg import AprilTagDetectionArray
import cv2
from cv_bridge import CvBridge
import numpy as np
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import Image
from .full_hand import VIEW_TAGS
from .hikrobot_camera import configure_fastdds_large_image_transport
from .zero_calibration import detect_reference_alignment_line
VIEWS = ("front", "side", "top")
def summarize_alignment_measurements(
measurements: Sequence[Mapping[str, Any] | None],
) -> dict[str, Any] | None:
"""Return a median-smoothed physical reference-line measurement."""
valid = [measurement for measurement in measurements if measurement]
if not valid:
return None
return {
"line_xyxy_px": np.median(
np.asarray(
[measurement["line_xyxy_px"] for measurement in valid],
dtype=float,
),
axis=0,
).tolist(),
"angle_rad": float(
np.median(
[float(measurement["angle_rad"]) for measurement in valid]
)
),
"vertical_offset_px": float(
np.median(
[
float(measurement["vertical_offset_px"])
for measurement in valid
]
)
),
"detected_frames": len(valid),
"window_frames": len(measurements),
}
class G20CameraAlignmentView(Node):
"""Publish a red/blue roll aid based on a physical scene edge."""
def __init__(self) -> None:
"""Configure one view without taking ownership of hand commands."""
super().__init__("g20_camera_alignment_view")
self.declare_parameter("view", "front")
view = str(self.get_parameter("view").value).strip().lower()
if view not in VIEWS:
raise ValueError(f"view must be one of {VIEWS}")
self.view = view
namespace = f"/g20_calibration/{view}"
self.required_tag_ids = {
int(value) for value in VIEW_TAGS[view].values()
}
self.declare_parameter("image_topic", f"{namespace}/camera/image_rect")
self.declare_parameter(
"detections_topic", f"{namespace}/apriltag/detections"
)
self.declare_parameter("reference_y_ratio", 0.90)
self.declare_parameter("roi_y_min_ratio", 0.55)
self.declare_parameter("roi_y_max_ratio", 0.98)
self.declare_parameter("minimum_line_length_ratio", 0.30)
self.declare_parameter("maximum_candidate_angle_deg", 15.0)
self.declare_parameter("maximum_alignment_error_deg", 0.5)
self.declare_parameter("maximum_vertical_offset_px", 12.0)
self.declare_parameter("maximum_hamming", 0)
self.declare_parameter("minimum_decision_margin", 20.0)
self.declare_parameter("minimum_edge_pixels", 20.0)
self.declare_parameter("smoothing_frames", 10)
self.declare_parameter("maximum_line_age_seconds", 1.0)
self.declare_parameter("maximum_tag_age_seconds", 1.0)
self.declare_parameter("maximum_publish_rate_hz", 10.0)
self.declare_parameter("output_scale", 0.75)
def value(name: str) -> Any:
return self.get_parameter(name).value
self.image_topic = str(value("image_topic"))
self.detections_topic = str(value("detections_topic"))
self.reference_y_ratio = float(value("reference_y_ratio"))
self.roi_y_min_ratio = float(value("roi_y_min_ratio"))
self.roi_y_max_ratio = float(value("roi_y_max_ratio"))
self.minimum_line_length_ratio = float(
value("minimum_line_length_ratio")
)
self.maximum_candidate_angle_rad = math.radians(
float(value("maximum_candidate_angle_deg"))
)
self.maximum_alignment_error_rad = math.radians(
float(value("maximum_alignment_error_deg"))
)
self.maximum_vertical_offset_px = float(
value("maximum_vertical_offset_px")
)
self.maximum_hamming = int(value("maximum_hamming"))
self.minimum_decision_margin = float(value("minimum_decision_margin"))
self.minimum_edge_pixels = float(value("minimum_edge_pixels"))
self.maximum_line_age_seconds = float(
value("maximum_line_age_seconds")
)
self.maximum_tag_age_seconds = float(value("maximum_tag_age_seconds"))
self.maximum_publish_rate_hz = float(value("maximum_publish_rate_hz"))
self.output_scale = float(value("output_scale"))
smoothing_frames = int(value("smoothing_frames"))
if not (
0.0
<= self.roi_y_min_ratio
< self.reference_y_ratio
< self.roi_y_max_ratio
<= 1.0
):
raise ValueError(
"ratios must satisfy 0 <= roi_min < reference < roi_max <= 1"
)
if not 0.0 < self.minimum_line_length_ratio <= 1.0:
raise ValueError("minimum_line_length_ratio must be in (0, 1]")
if not (
0.0
< self.maximum_alignment_error_rad
< self.maximum_candidate_angle_rad
< math.pi / 2.0
):
raise ValueError(
"angle limits must satisfy 0 < alignment < candidate < 90"
)
if self.maximum_vertical_offset_px <= 0.0:
raise ValueError("maximum_vertical_offset_px must be positive")
if smoothing_frames < 1:
raise ValueError("smoothing_frames must be positive")
if self.maximum_line_age_seconds <= 0.0:
raise ValueError("maximum_line_age_seconds must be positive")
if self.maximum_tag_age_seconds <= 0.0:
raise ValueError("maximum_tag_age_seconds must be positive")
if self.maximum_publish_rate_hz <= 0.0:
raise ValueError("maximum_publish_rate_hz must be positive")
if not 0.1 <= self.output_scale <= 1.0:
raise ValueError("output_scale must be in [0.1, 1.0]")
self.bridge = CvBridge()
self.line_history: deque[dict[str, Any] | None] = deque(
maxlen=smoothing_frames
)
self.last_line_at = 0.0
self.latest_tag_corners: dict[int, np.ndarray] = {}
self.latest_tag_at: dict[int, float] = {}
self.last_publish_at = 0.0
self.publisher = self.create_publisher(
Image, "~/image", qos_profile_sensor_data
)
self.create_subscription(
AprilTagDetectionArray,
self.detections_topic,
self._detections_callback,
qos_profile_sensor_data,
)
self.create_subscription(
Image,
self.image_topic,
self._image_callback,
qos_profile_sensor_data,
)
self.get_logger().info(
f"{view} alignment view uses physical long-edge detection; "
f"Tag orientation is ignored; input={self.image_topic}; "
f"output={self.get_name()}/image"
)
def _detections_callback(self, message: AprilTagDetectionArray) -> None:
now = time.monotonic()
for detection in message.detections:
tag_id = int(detection.id)
if tag_id not in self.required_tag_ids:
continue
corners = np.asarray(
[
[float(point.x), float(point.y)]
for point in detection.corners
],
dtype=float,
)
if corners.shape != (4, 2) or not np.all(np.isfinite(corners)):
continue
edges = np.linalg.norm(
corners - np.roll(corners, -1, axis=0), axis=1
)
if (
int(detection.hamming) > self.maximum_hamming
or float(detection.decision_margin)
< self.minimum_decision_margin
or float(np.mean(edges)) < self.minimum_edge_pixels
):
continue
self.latest_tag_corners[tag_id] = corners
self.latest_tag_at[tag_id] = now
def _draw_tags(self, image: np.ndarray, now: float) -> None:
for tag_id in sorted(self.required_tag_ids):
corners = self.latest_tag_corners.get(tag_id)
detected_at = self.latest_tag_at.get(tag_id, 0.0)
if (
corners is None
or now - detected_at > self.maximum_tag_age_seconds
):
continue
points = np.rint(corners * self.output_scale).astype(np.int32)
cv2.polylines(image, [points], True, (0, 220, 0), 2)
centre = np.rint(np.mean(points, axis=0)).astype(int)
cv2.putText(
image,
f"ID {tag_id}",
(int(centre[0]) + 5, int(centre[1]) - 7),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
(0, 220, 0),
2,
)
def _image_callback(self, message: Image) -> None:
# Avoid conversion and Hough work until an image viewer subscribes.
if self.publisher.get_subscription_count() < 1:
return
now = time.monotonic()
if now - self.last_publish_at < 1.0 / self.maximum_publish_rate_hz:
return
self.last_publish_at = now
try:
image = self.bridge.imgmsg_to_cv2(message, desired_encoding="bgr8")
except Exception as error:
self.get_logger().warning(
f"alignment image conversion failed: {error}"
)
return
if self.output_scale != 1.0:
image = cv2.resize(
image,
None,
fx=self.output_scale,
fy=self.output_scale,
interpolation=cv2.INTER_AREA,
)
height, width = image.shape[:2]
reference_y = self.reference_y_ratio * float(height - 1)
detected = detect_reference_alignment_line(
image,
reference_y_px=reference_y,
roi_y_min_ratio=self.roi_y_min_ratio,
roi_y_max_ratio=self.roi_y_max_ratio,
minimum_length_ratio=self.minimum_line_length_ratio,
maximum_candidate_angle_rad=self.maximum_candidate_angle_rad,
)
self.line_history.append(detected)
if detected is not None:
self.last_line_at = now
measurement = summarize_alignment_measurements(self.line_history)
if now - self.last_line_at > self.maximum_line_age_seconds:
measurement = None
red_y = int(round(reference_y))
cv2.line(
image,
(15, red_y),
(max(15, width - 15), red_y),
(0, 0, 255),
4,
)
if measurement is not None:
line = np.rint(measurement["line_xyxy_px"]).astype(int)
blue_ok, blue_start, blue_end = cv2.clipLine(
(0, 0, width, height),
(int(line[0]), int(line[1])),
(int(line[2]), int(line[3])),
)
if blue_ok:
cv2.line(image, blue_start, blue_end, (255, 0, 0), 3)
angle_rad = float(measurement["angle_rad"])
offset_px = float(measurement["vertical_offset_px"])
aligned = bool(
abs(angle_rad) <= self.maximum_alignment_error_rad
and abs(offset_px) <= self.maximum_vertical_offset_px
)
status = "ALIGNED" if aligned else "ADJUST CAMERA"
status_text = (
f"{self.view.upper()} red-blue "
f"{math.degrees(angle_rad):+.2f} deg "
f"dy {offset_px:+.1f}px {status}"
)
status_color = (0, 220, 0) if aligned else (0, 165, 255)
else:
status_text = (
f"{self.view.upper()} PHYSICAL REFERENCE LINE NOT DETECTED"
)
status_color = (0, 165, 255)
cv2.putText(
image,
status_text,
(20, 34),
cv2.FONT_HERSHEY_SIMPLEX,
0.72,
status_color,
2,
)
self._draw_tags(image, now)
cv2.putText(
image,
"RED=target BLUE=physical edge GREEN=Tags (angle ignored)",
(20, max(64, height - 24)),
cv2.FONT_HERSHEY_SIMPLEX,
0.60,
(255, 255, 255),
2,
)
output = self.bridge.cv2_to_imgmsg(image, encoding="bgr8")
output.header = message.header
self.publisher.publish(output)
def main(args: list[str] | None = None) -> None:
"""Run the single-view alignment helper."""
configure_fastdds_large_image_transport()
rclpy.init(args=args)
node: G20CameraAlignmentView | None = None
try:
node = G20CameraAlignmentView()
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
if node is not None:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
main()
@@ -0,0 +1,377 @@
"""Map explicit SDK commands or feedback to corrected URDF joint coordinates.
Unified artifacts load their certified manifest and standard URDF. The old
readers below are used only when a historical payload is explicitly supplied.
The static encoder-zero corrections in ``zero_angles`` are already baked into
the corrected URDF joint origins. This bridge therefore publishes only the
dynamic ``angle_rad`` values and never adds the static offsets a second time.
Schema-v5 trajectories are fitted against timestamp-synchronised hardware
feedback, not controller set-points. They must therefore be queried with the
SDK ``hand_state`` topic. The retained schema-v4 path is command-indexed for
backwards compatibility only.
"""
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import Any, Mapping, Sequence
import numpy as np
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
G20_COMMAND_NAMES: tuple[str, ...] = (
"thumb_cmc_pitch",
"index_mcp_pitch",
"middle_mcp_pitch",
"ring_mcp_pitch",
"pinky_mcp_pitch",
"thumb_cmc_roll",
"index_mcp_roll",
"middle_mcp_roll",
"ring_mcp_roll",
"pinky_mcp_roll",
"thumb_cmc_yaw",
"reserved_11",
"reserved_12",
"reserved_13",
"reserved_14",
"thumb_mcp",
"index_pip",
"middle_pip",
"ring_pip",
"pinky_pip",
)
# Match the stable ordering used by the existing MuJoCo bridge. JointState
# consumers must use names, but retaining the ordering also keeps logs and
# direct comparisons deterministic.
G20_URDF_JOINT_NAMES: tuple[str, ...] = (
"index_dip",
"index_mcp_pitch",
"index_mcp_roll",
"index_pip",
"middle_dip",
"middle_mcp_pitch",
"middle_mcp_roll",
"middle_pip",
"pinky_dip",
"pinky_mcp_pitch",
"pinky_mcp_roll",
"pinky_pip",
"ring_dip",
"ring_mcp_pitch",
"ring_mcp_roll",
"ring_pip",
"thumb_cmc_pitch",
"thumb_cmc_roll",
"thumb_cmc_yaw",
"thumb_ip",
"thumb_mcp",
)
class CalibratedCommandMapper:
"""Validated, profile-specific lookup from SDK u8 values to URDF radians."""
def __init__(
self, payload: Mapping[str, Any], *, expected_side: str | None = None
) -> None:
from .full_hand import get_hand_calibration_profile, infer_compact_payload_layout, validate_compact_payload
from .compat.legacy_diagnostic_tools.models import get_default_registry, validate_schema_v6_runtime_payload
from .compat.legacy_diagnostic_tools.models.o12.artifacts import validate_o12_runtime_payload
from .core import ProfileKey
schema_version = int(payload["schema_version"])
if schema_version == 7:
validate_o12_runtime_payload(payload)
elif schema_version == 6:
validate_schema_v6_runtime_payload(payload)
else:
validate_compact_payload(payload)
side = str(payload["side"]).lower()
if expected_side is not None and side != str(expected_side).lower():
raise ValueError(
f"calibration side {side!r} does not match requested side "
f"{str(expected_side).lower()!r}"
)
quality = payload["quality"]
if quality.get("passed") is not True:
raise ValueError("calibration quality.passed must be true")
layout_id = (
str(payload["layout_id"])
if schema_version in {6, 7}
else infer_compact_payload_layout(payload)
)
self.side = side
self.layout_id = layout_id
self.model = str(payload["model"]).upper()
self.profile_id = str(
payload.get("profile_id", f"G20/{side}/{layout_id}/v1")
)
self.serial_number = str(payload["serial_number"])
self.input_domain = str(
payload.get(
"curve_input_domain",
"command_u8" if schema_version == 4 else "",
)
)
if self.input_domain not in {"command_u8", "feedback_u8", "feedback_rad"}:
raise ValueError("calibration curve_input_domain is invalid")
if schema_version in {6, 7}:
self.command_names = tuple(str(value) for value in payload["command_names"])
self.urdf_joint_names = tuple(str(name) for name in payload["joints"])
self._motor_by_joint = {
name: int(payload["joints"][name]["motor_index"])
for name in self.urdf_joint_names
}
registered = get_default_registry().get(
ProfileKey.parse(self.profile_id)
)
self.feedback_name_aliases = dict(
registered.profile.command.feedback_name_aliases
)
self.feedback_by_index = bool(
registered.profile.command.feedback_by_index
)
else:
profile = get_hand_calibration_profile(side, layout_id)
self.command_names = G20_COMMAND_NAMES
self.urdf_joint_names = G20_URDF_JOINT_NAMES
self._motor_by_joint = {
name: int(profile.joint_specs[name].motor_index)
for name in self.urdf_joint_names
}
self.feedback_name_aliases = {}
self.feedback_by_index = False
self._curves = {
name: tuple(
float(value)
for value in payload["joints"][name]["angle_rad"]
)
for name in self.urdf_joint_names
}
self._decreasing_curves = {
name: tuple(
float(value)
for value in payload["joints"][name].get(
"decreasing_rad", payload["joints"][name]["angle_rad"]
)
)
for name in self.urdf_joint_names
}
self._increasing_curves = {
name: tuple(
float(value)
for value in payload["joints"][name].get(
"increasing_rad", payload["joints"][name]["angle_rad"]
)
)
for name in self.urdf_joint_names
}
self._previous_by_motor: dict[int, float] = {}
self._direction_by_motor: dict[int, str] = {}
self.direction_deadband_u8 = 0.002 if schema_version == 7 else 0.5
self._knots = {
name: tuple(float(value) for value in payload["joints"][name].get(
"curve_input_knots_rad", ()
))
for name in self.urdf_joint_names
}
self._raw_increasing_branch = {
name: str(payload["joints"][name].get(
"raw_increasing_curve_branch", "increasing"
))
for name in self.urdf_joint_names
}
@staticmethod
def _command_index(value: float) -> int:
command = float(value)
if not math.isfinite(command):
raise ValueError("calibrated command positions must be finite")
return max(0, min(255, int(math.floor(command + 0.5))))
def map_positions(
self, positions: Sequence[float], names: Sequence[str] = ()
) -> tuple[float, ...]:
values = tuple(float(value) for value in positions)
if names and not self.feedback_by_index:
if len(names) != len(values):
raise ValueError(
"JointState names and positions must have equal length"
)
if len(set(names)) != len(names):
raise ValueError("JointState names must be unique")
by_name = dict(zip((str(name) for name in names), values))
for alias, canonical in self.feedback_name_aliases.items():
if alias in by_name and canonical not in by_name:
by_name[canonical] = by_name[alias]
missing = [name for name in self.command_names if name not in by_name]
if missing:
raise ValueError(
f"{self.model} feedback is missing named channels: "
+ ",".join(missing)
)
command = tuple(by_name[name] for name in self.command_names)
else:
if len(values) != len(self.command_names):
raise ValueError(
f"unnamed {self.model} feedback must contain exactly "
f"{len(self.command_names)} positions"
)
command = values
indices = (
() if self.input_domain == "feedback_rad"
else tuple(self._command_index(value) for value in command)
)
direction_by_motor: dict[int, str | None] = {}
for motor, value in enumerate(command):
previous = self._previous_by_motor.get(motor)
direction = self._direction_by_motor.get(motor)
if previous is not None:
if value > previous + self.direction_deadband_u8:
direction = "increasing"
elif value < previous - self.direction_deadband_u8:
direction = "decreasing"
direction_by_motor[motor] = direction
result: list[float] = []
for name in self.urdf_joint_names:
motor = self._motor_by_joint[name]
direction = direction_by_motor[motor]
if self.input_domain == "feedback_rad" and direction is not None:
raw_increasing = self._raw_increasing_branch[name]
direction = (
raw_increasing
if direction == "increasing"
else "increasing" if raw_increasing == "decreasing" else "decreasing"
)
curves = (
self._increasing_curves
if direction == "increasing"
else self._decreasing_curves
if direction == "decreasing"
else self._curves
)
if self.input_domain == "feedback_rad":
result.append(float(np.interp(command[motor], self._knots[name], curves[name])))
else:
result.append(curves[name][indices[motor]])
for motor, value in enumerate(command):
self._previous_by_motor[motor] = value
direction = direction_by_motor[motor]
if direction is not None:
self._direction_by_motor[motor] = direction
return tuple(result)
def load_calibrated_command_mapper(
calibration_file: str | Path, *, expected_side: str | None = None, input_kind="command"
) -> CalibratedCommandMapper:
path = Path(calibration_file).expanduser().resolve()
if not path.is_file():
raise ValueError(f"calibration JSON does not exist: {path}")
payload = json.loads(path.read_text(encoding="utf-8"))
if path.name == "release_manifest.json" or payload.get("format") in {"unified_calibration_v1", "unified_calibration_v2"}:
from .runtime.artifacts.reader import load_unified_mapper
return load_unified_mapper(path, expected_side=expected_side, input_kind=input_kind)
return CalibratedCommandMapper(payload, expected_side=expected_side)
def default_input_topic(
hand_type: str, input_domain: str, model: str = "G20"
) -> str:
side = str(hand_type).lower()
if side not in {"left", "right"}:
raise ValueError("hand_type must be left or right")
if input_domain == "feedback_u8":
return f"/{str(model).lower()}/cb_{side}_hand_state"
if input_domain == "command_u8":
return f"/{str(model).lower()}/cb_{side}_hand_control_cmd"
if input_domain == "feedback_rad":
return f"/{str(model).lower()}/{side}/joint_states"
if input_domain == "command_rad":
return f"/{str(model).lower()}/{side}/joint_cmd"
raise ValueError("calibration curve_input_domain is invalid")
class CalibratedJointStateBridge(Node):
def __init__(self) -> None:
super().__init__("calibrated_joint_state_bridge")
self.declare_parameter("hand_type", "right")
self.declare_parameter("calibration_file", "")
self.declare_parameter("input_topic", "")
self.declare_parameter("output_topic", "")
self.declare_parameter("input_kind", "command")
hand_type = str(self.get_parameter("hand_type").value).lower()
if hand_type not in {"left", "right"}:
raise ValueError("hand_type must be left or right")
calibration_file = str(self.get_parameter("calibration_file").value)
if not calibration_file:
raise ValueError("calibration_file is required")
self.mapper = load_calibrated_command_mapper(
calibration_file, expected_side=hand_type, input_kind=str(self.get_parameter("input_kind").value)
)
input_topic = str(self.get_parameter("input_topic").value).strip()
output_topic = str(self.get_parameter("output_topic").value).strip()
self.input_topic = input_topic or default_input_topic(
hand_type, self.mapper.input_domain, self.mapper.model
)
self.output_topic = (
output_topic
or f"/sim/mujoco/{self.mapper.model.lower()}/{hand_type}/joint_state"
)
if self.output_topic in {self.input_topic, default_input_topic(hand_type,
"feedback_rad" if self.mapper.input_domain.endswith("rad") else "feedback_u8", self.mapper.model)}:
raise ValueError("mapped output must not overwrite the real SDK command/feedback topic")
self.publisher = self.create_publisher(JointState, self.output_topic, 10)
self.subscription = self.create_subscription(
JointState, self.input_topic, self._command_callback, 10
)
self._last_error = ""
self.get_logger().info(
f"loaded {self.mapper.profile_id} calibration for "
f"{self.mapper.serial_number}: "
f"{self.input_topic} ({self.mapper.input_domain}) -> "
f"{self.output_topic}"
)
def _command_callback(self, command: JointState) -> None:
try:
positions = self.mapper.map_positions(command.position, command.name)
except ValueError as error:
message = str(error)
if message != self._last_error:
self.get_logger().error(message)
self._last_error = message
return
self._last_error = ""
result = JointState()
result.header = command.header
result.name = list(self.mapper.urdf_joint_names)
result.position = list(positions)
self.publisher.publish(result)
def main(args: Sequence[str] | None = None) -> None:
rclpy.init(args=args)
node: CalibratedJointStateBridge | None = None
try:
node = CalibratedJointStateBridge()
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
if node is not None:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
main()
@@ -0,0 +1,21 @@
"""Compatibility adapters for one-release calibration migrations."""
from .config_v1 import (
legacy_default_profile_key,
product_profile_key,
resolve_legacy_profile_alias,
)
from .defaults import (
default_product_config_path,
default_three_camera_config_path,
)
from .paths import resolve_renamed_package_path
__all__ = [
"default_product_config_path",
"default_three_camera_config_path",
"legacy_default_profile_key",
"product_profile_key",
"resolve_legacy_profile_alias",
"resolve_renamed_package_path",
]
@@ -0,0 +1,47 @@
"""Identity migration for deployed product configuration schemas."""
from __future__ import annotations
from typing import Any, Mapping
from ..core import ProfileKey
def product_profile_key(raw: Mapping[str, Any]) -> ProfileKey:
version = int(raw.get("schema_version", -1))
if version in {2, 3}:
key = ProfileKey.parse(str(raw.get("profile_id", "")))
for field, actual in (
("model", key.model),
("side", key.side),
("tag_layout", key.layout),
):
configured = str(raw.get(field, "")).strip()
if configured and configured.lower() != actual.lower():
raise ValueError(f"{field} differs from profile_id")
return key
if version != 1:
raise ValueError("product config schema_version must be 1, 2 or 3")
model = str(raw.get("model", "")).strip().upper()
side = str(raw.get("side", "")).strip().lower()
layout = str(raw.get("tag_layout", "")).strip().lower()
if not layout and (model, side) == ("G20", "right"):
layout = "g20_right_19"
return ProfileKey(model, side, layout, 1)
def legacy_default_profile_key() -> ProfileKey:
"""Preserve the former no-argument executable for one release."""
return ProfileKey("G20", "right", "g20_right_19", 1)
def resolve_legacy_profile_alias(key: ProfileKey) -> ProfileKey:
"""Map retired layout identifiers to their reviewed physical profile."""
if (
key.model == "G20"
and key.side == "right"
and key.layout == "g20_right_15"
and key.revision == 1
):
return ProfileKey("G20", "right", "g20_right_19", 1)
return key
@@ -0,0 +1,16 @@
"""One-release default selection for invocations without ``--config``."""
from pathlib import Path
from ament_index_python.packages import get_package_share_directory
def default_product_config_path() -> Path:
share = Path(get_package_share_directory("linkerhand_calibration"))
return share / "config/g20_right_product.yaml"
def default_three_camera_config_path() -> Path:
"""Resolve the installed calibration defaults through the ROS index."""
share = Path(get_package_share_directory("linkerhand_calibration"))
return share / "config/three_camera_calibration.yaml"
@@ -0,0 +1,4 @@
"""Legacy single-camera algorithms retained for one compatibility release."""
from .session_v1 import uses_coupled_full_hand_zero_solver
__all__ = ["uses_coupled_full_hand_zero_solver"]
@@ -0,0 +1,24 @@
"""Version selection for replaying durable pre-v3 hardware sessions."""
from __future__ import annotations
from typing import Any, Mapping
def uses_coupled_full_hand_zero_solver(
session_start: Mapping[str, Any],
) -> bool:
"""Return the solver contract recorded by the legacy session header.
Capabilities are not consulted by the live runtime. This adapter reads
the durable v1 header only so offline replay can reproduce an artifact
created before the independent thumb solver was introduced.
"""
capabilities = {
str(value) for value in session_start.get("capabilities", ())
}
return (
int(session_start.get("sample_schema_version", 1)) == 1
and "palm_axis_side_channel_v2" in capabilities
and "palm_axis_relative_motion_v3" not in capabilities
)
@@ -0,0 +1,638 @@
"""Pure calibration math and command helpers.
This module deliberately has no ROS imports so the geometry, fitting, and
output schema can be tested without a camera or a connected hand.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import math
from typing import Any, Iterable, Mapping, Sequence
import numpy as np
from scipy.spatial.transform import Rotation
COMMAND_NAMES: tuple[str, ...] = (
"thumb_cmc_pitch",
"index_mcp_pitch",
"middle_mcp_pitch",
"ring_mcp_pitch",
"pinky_mcp_pitch",
"thumb_cmc_roll",
"index_mcp_roll",
"middle_mcp_roll",
"ring_mcp_roll",
"pinky_mcp_roll",
"thumb_cmc_yaw",
"reserved_11",
"reserved_12",
"reserved_13",
"reserved_14",
"thumb_mcp",
"index_pip",
"middle_pip",
"ring_pip",
"pinky_pip",
)
BASELINE_COMMAND: tuple[int, ...] = (
255,
255,
255,
255,
255,
255,
193,
148,
105,
42,
245,
255,
255,
255,
255,
255,
255,
255,
255,
255,
)
PAIR_ROOT = "t0_t3"
PAIR_MCP = "t3_t4"
PAIR_IP = "t4_t5"
PAIR_NAMES: tuple[str, ...] = (PAIR_ROOT, PAIR_MCP, PAIR_IP)
DIRECTION_DECREASING = "decreasing"
DIRECTION_INCREASING = "increasing"
DIRECTIONS: tuple[str, ...] = (
DIRECTION_DECREASING,
DIRECTION_INCREASING,
)
PHASE_ROOT = "root"
PHASE_TIP = "tip"
JOINT_SPECS: dict[str, tuple[str, str, int]] = {
"thumb_cmc_pitch": (PHASE_ROOT, PAIR_ROOT, 0),
"thumb_mcp": (PHASE_TIP, PAIR_MCP, 15),
"thumb_ip": (PHASE_TIP, PAIR_IP, 15),
}
def build_command(
motor_index: int,
command_u8: int,
baseline: Sequence[int] = BASELINE_COMMAND,
) -> list[int]:
"""Return one full G20 command with exactly one replaced motor slot."""
if len(baseline) != 20:
raise ValueError("baseline must contain exactly 20 values")
values = [int(value) for value in baseline]
if any(value < 0 or value > 255 for value in values):
raise ValueError("baseline values must be in [0, 255]")
if motor_index not in (0, 5, 15):
raise ValueError(
"front thumb calibration only permits motor 0, 5 or 15"
)
command_u8 = int(command_u8)
if command_u8 < 0 or command_u8 > 255:
raise ValueError("command_u8 must be in [0, 255]")
values[motor_index] = command_u8
return values
def scan_targets(
repetitions: int = 3,
command_step: int = 1,
) -> list[tuple[int, str, int]]:
"""Build repeated 255->0->255 scan targets on a bounded command grid."""
if repetitions < 1:
raise ValueError("repetitions must be positive")
if command_step < 1 or command_step > 255:
raise ValueError("command_step must be in [1, 255]")
increasing = list(range(0, 256, command_step))
if increasing[-1] != 255:
increasing.append(255)
decreasing = list(reversed(increasing))
targets: list[tuple[int, str, int]] = []
for cycle in range(repetitions):
targets.extend(
(cycle, DIRECTION_DECREASING, command)
for command in decreasing
)
targets.extend(
(cycle, DIRECTION_INCREASING, command)
for command in increasing
)
return targets
def normalize_quaternion_xyzw(values: Sequence[float]) -> np.ndarray:
quaternion = np.asarray(values, dtype=float)
if quaternion.shape != (4,) or not np.all(np.isfinite(quaternion)):
raise ValueError("quaternion must contain four finite xyzw values")
norm = float(np.linalg.norm(quaternion))
if norm < 1e-12:
raise ValueError("quaternion norm is zero")
return quaternion / norm
def relative_quaternion_xyzw(
parent_camera_quaternion: Sequence[float],
child_camera_quaternion: Sequence[float],
) -> tuple[float, float, float, float]:
"""Compute parent->child orientation from two camera->tag rotations."""
parent = Rotation.from_quat(normalize_quaternion_xyzw(parent_camera_quaternion))
child = Rotation.from_quat(normalize_quaternion_xyzw(child_camera_quaternion))
quaternion = (parent.inv() * child).as_quat()
return tuple(float(value) for value in quaternion)
def image_plane_tag_quaternion_xyzw(
corners_xy: Sequence[Sequence[float]],
) -> tuple[float, float, float, float]:
"""Estimate tag orientation about the optical axis from ordered corners."""
corners = np.asarray(corners_xy, dtype=float)
if corners.shape != (4, 2) or not np.all(np.isfinite(corners)):
raise ValueError("corners_xy must contain four finite xy points")
# AprilTag corners 0->1 and 3->2 both follow the tag-local x axis.
# Average the two edges to reduce sub-pixel corner noise and perspective
# asymmetry. Image y points down, hence the minus sign for a right-handed
# camera-frame z rotation.
x_axis = (corners[1] - corners[0]) + (corners[2] - corners[3])
if float(np.linalg.norm(x_axis)) < 1e-9:
raise ValueError("tag x-axis is degenerate")
angle = -math.atan2(float(x_axis[1]), float(x_axis[0]))
quaternion = Rotation.from_rotvec([0.0, 0.0, angle]).as_quat()
return tuple(float(value) for value in quaternion)
def robust_rotation_summary(
quaternions_xyzw: Sequence[Sequence[float]],
) -> tuple[tuple[float, float, float, float], float]:
"""Return a robust orientation and maximum angular residual in radians."""
if not quaternions_xyzw:
raise ValueError("at least one quaternion is required")
rotations = Rotation.from_quat(
np.asarray(
[normalize_quaternion_xyzw(value) for value in quaternions_xyzw],
dtype=float,
)
)
reference = rotations[0]
delta_vectors = (reference.inv() * rotations).as_rotvec()
median_delta = np.median(delta_vectors, axis=0)
robust = reference * Rotation.from_rotvec(median_delta)
residuals = (robust.inv() * rotations).magnitude()
maximum = float(np.max(residuals)) if residuals.size else 0.0
return (
tuple(float(value) for value in robust.as_quat()),
maximum,
)
def rotation_spread_rad(
quaternions_xyzw: Sequence[Sequence[float]],
) -> float:
"""Return the maximum geodesic residual around a robust orientation."""
_, spread = robust_rotation_summary(quaternions_xyzw)
return spread
def rotation_rms_rad(
quaternions_xyzw: Sequence[Sequence[float]],
*,
outlier_threshold_rad: float | None = None,
) -> float:
"""Return RMS geodesic noise around a robust orientation."""
robust, _ = robust_rotation_summary(quaternions_xyzw)
reference = Rotation.from_quat(robust)
rotations = Rotation.from_quat(
np.asarray(
[normalize_quaternion_xyzw(value) for value in quaternions_xyzw],
dtype=float,
)
)
residuals = (reference.inv() * rotations).magnitude()
if outlier_threshold_rad is not None:
threshold = float(outlier_threshold_rad)
if threshold <= 0.0:
raise ValueError("outlier_threshold_rad must be positive")
residuals = residuals[residuals <= threshold]
if residuals.size == 0:
return float("inf")
return float(np.sqrt(np.mean(np.square(residuals))))
def rotation_inlier_fraction(
quaternions_xyzw: Sequence[Sequence[float]],
*,
outlier_threshold_rad: float,
) -> float:
"""Return the fraction close to the robust orientation."""
threshold = float(outlier_threshold_rad)
if threshold <= 0.0:
raise ValueError("outlier_threshold_rad must be positive")
robust, _ = robust_rotation_summary(quaternions_xyzw)
reference = Rotation.from_quat(robust)
rotations = Rotation.from_quat(
np.asarray(
[normalize_quaternion_xyzw(value) for value in quaternions_xyzw],
dtype=float,
)
)
residuals = (reference.inv() * rotations).magnitude()
return float(np.mean(residuals <= threshold))
def delta_rotation_vector(
reference_xyzw: Sequence[float],
observed_xyzw: Sequence[float],
) -> np.ndarray:
reference = Rotation.from_quat(normalize_quaternion_xyzw(reference_xyzw))
observed = Rotation.from_quat(normalize_quaternion_xyzw(observed_xyzw))
return (reference.inv() * observed).as_rotvec()
def fit_rotation_axis(
vectors: Sequence[Sequence[float]],
commands: Sequence[int],
) -> np.ndarray:
"""Fit and orient the single rotational axis used by one motor sweep."""
matrix = np.asarray(vectors, dtype=float)
command_values = np.asarray(commands, dtype=int)
if matrix.ndim != 2 or matrix.shape[1] != 3:
raise ValueError("vectors must have shape (N, 3)")
if command_values.shape != (matrix.shape[0],):
raise ValueError("commands must match vectors")
useful = np.linalg.norm(matrix, axis=1) > 1e-6
if int(np.count_nonzero(useful)) < 3:
raise ValueError("insufficient non-zero rotations to fit an axis")
_, _, vh = np.linalg.svd(matrix[useful], full_matrices=False)
axis = vh[0]
projections = matrix @ axis
low = projections[command_values <= 16]
high = projections[command_values >= 239]
if low.size and high.size and float(np.median(low)) < float(np.median(high)):
axis = -axis
return axis / np.linalg.norm(axis)
def isotonic_nonincreasing(values: Sequence[float]) -> np.ndarray:
"""Unweighted PAVA projection onto non-increasing values."""
original = np.asarray(values, dtype=float)
if original.ndim != 1 or not np.all(np.isfinite(original)):
raise ValueError("values must be a finite vector")
negated = -original
levels: list[float] = []
weights: list[int] = []
starts: list[int] = []
for index, value in enumerate(negated):
levels.append(float(value))
weights.append(1)
starts.append(index)
while len(levels) >= 2 and levels[-2] > levels[-1]:
total_weight = weights[-2] + weights[-1]
merged = (
levels[-2] * weights[-2] + levels[-1] * weights[-1]
) / total_weight
levels[-2:] = [merged]
weights[-2:] = [total_weight]
starts.pop()
projected = np.empty_like(original)
for block_index, (level, start) in enumerate(zip(levels, starts)):
end = starts[block_index + 1] if block_index + 1 < len(starts) else len(original)
projected[start:end] = -level
return projected
def _record_rotation(record: Mapping[str, Any], pair: str) -> tuple[float, ...]:
rotations = record.get("relative_quaternion_xyzw", {})
value = rotations.get(pair)
if value is None:
raise ValueError(f"sample record is missing {pair}")
return tuple(float(component) for component in value)
@dataclass(frozen=True)
class FitResult:
joints: dict[str, dict[str, Any]]
axes: dict[str, tuple[float, float, float]]
references: dict[str, tuple[float, float, float, float]]
ip_coupling: dict[str, float]
max_monotonic_correction_rad: float
max_hysteresis_rad: float
measurement_mode: str = "rotation"
trajectory_models: dict[str, Any] = field(default_factory=dict)
trajectory_quality: dict[str, Any] = field(default_factory=dict)
def measure_from_reference(
self,
joint_name: str,
observed_quaternion_xyzw: Sequence[float],
reference_quaternion_xyzw: Sequence[float] | None = None,
) -> float:
reference = (
reference_quaternion_xyzw
if reference_quaternion_xyzw is not None
else self.references[joint_name]
)
vector = delta_rotation_vector(reference, observed_quaternion_xyzw)
axis = np.asarray(self.axes[joint_name], dtype=float)
return float(vector @ axis)
def fit_calibration_curves(records: Iterable[Mapping[str, Any]]) -> FitResult:
"""Fit six complete 256-entry curves from dense or sparse scan records."""
samples = [
dict(record)
for record in records
if record.get("kind", "sample") == "sample"
]
if not samples:
raise ValueError("no scan records were provided")
joint_results: dict[str, dict[str, Any]] = {}
axes: dict[str, tuple[float, float, float]] = {}
references: dict[str, tuple[float, float, float, float]] = {}
maximum_correction = 0.0
maximum_hysteresis = 0.0
for joint_name, (phase, pair, motor_index) in JOINT_SPECS.items():
phase_records = [record for record in samples if record.get("phase") == phase]
if not phase_records:
raise ValueError(f"no records for phase {phase}")
cycle_references: dict[int, tuple[float, ...]] = {}
for record in phase_records:
if (
record.get("direction") == DIRECTION_DECREASING
and int(record.get("command_u8", -1)) == 255
):
cycle_references.setdefault(
int(record["cycle"]),
_record_rotation(record, pair),
)
cycles = sorted({int(record["cycle"]) for record in phase_records})
if any(cycle not in cycle_references for cycle in cycles):
raise ValueError(f"{joint_name} is missing a command-255 cycle reference")
vectors: list[np.ndarray] = []
commands: list[int] = []
indexed: list[tuple[Mapping[str, Any], np.ndarray]] = []
for record in phase_records:
cycle = int(record["cycle"])
vector = delta_rotation_vector(
cycle_references[cycle],
_record_rotation(record, pair),
)
vectors.append(vector)
commands.append(int(record["command_u8"]))
indexed.append((record, vector))
axis = fit_rotation_axis(vectors, commands)
axes[joint_name] = tuple(float(value) for value in axis)
references[joint_name] = robust_rotation_summary(
list(cycle_references.values())
)[0]
branch_values: dict[str, list[list[float]]] = {
direction: [[] for _ in range(256)] for direction in DIRECTIONS
}
for record, vector in indexed:
direction = str(record["direction"])
command = int(record["command_u8"])
branch_values[direction][command].append(float(vector @ axis))
fitted_branches: dict[str, list[float]] = {}
for direction in DIRECTIONS:
sample_commands = np.asarray(
[
command
for command, values in enumerate(branch_values[direction])
if values
],
dtype=int,
)
if (
sample_commands.size < 3
or int(sample_commands[0]) != 0
or int(sample_commands[-1]) != 255
):
raise ValueError(
f"{joint_name}.{direction} requires at least three samples "
"including commands 0 and 255"
)
raw = np.asarray(
[
float(np.median(branch_values[direction][command]))
for command in sample_commands
],
dtype=float,
)
raw -= raw[-1]
projected_samples = isotonic_nonincreasing(raw)
projected_samples -= projected_samples[-1]
correction = float(np.max(np.abs(projected_samples - raw)))
maximum_correction = max(maximum_correction, correction)
projected = np.interp(
np.arange(256, dtype=float),
sample_commands.astype(float),
projected_samples,
)
projected -= projected[255]
fitted_branches[direction] = [
round(float(value), 8) for value in projected
]
hysteresis = float(
np.max(
np.abs(
np.asarray(fitted_branches[DIRECTION_DECREASING])
- np.asarray(fitted_branches[DIRECTION_INCREASING])
)
)
)
maximum_hysteresis = max(maximum_hysteresis, hysteresis)
combined_curve = 0.5 * (
np.asarray(
fitted_branches[DIRECTION_DECREASING], dtype=float
)
+ np.asarray(
fitted_branches[DIRECTION_INCREASING], dtype=float
)
)
combined_curve -= combined_curve[255]
joint_result: dict[str, Any] = {
"motor_index": motor_index,
"angle_rad": [
round(float(value), 8) for value in combined_curve
],
"decreasing_rad": fitted_branches[DIRECTION_DECREASING],
"increasing_rad": fitted_branches[DIRECTION_INCREASING],
}
if joint_name == "thumb_ip":
joint_result["passive"] = True
joint_results[joint_name] = joint_result
mcp = joint_results["thumb_mcp"]
ip = joint_results["thumb_ip"]
x = np.asarray(mcp["angle_rad"], dtype=float)
y = np.asarray(ip["angle_rad"], dtype=float)
design = np.column_stack((x, np.ones_like(x)))
multiplier, offset = np.linalg.lstsq(design, y, rcond=None)[0]
predicted = multiplier * x + offset
residual_sum = float(np.sum((y - predicted) ** 2))
total_sum = float(np.sum((y - np.mean(y)) ** 2))
r_squared = 1.0 if total_sum < 1e-12 else 1.0 - residual_sum / total_sum
return FitResult(
joints=joint_results,
axes=axes,
references=references,
ip_coupling={
"multiplier": round(float(multiplier), 8),
"offset_rad": round(float(offset), 8),
"r_squared": round(float(r_squared), 8),
},
max_monotonic_correction_rad=maximum_correction,
max_hysteresis_rad=maximum_hysteresis,
)
def create_final_payload(
*,
serial_number: str,
fit: FitResult,
validation_errors_rad: Sequence[float],
passed: bool,
baseline: Sequence[int] = BASELINE_COMMAND,
) -> dict[str, Any]:
errors = np.abs(np.asarray(validation_errors_rad, dtype=float))
mae = float(np.mean(errors)) if errors.size else float("nan")
p95 = float(np.percentile(errors, 95)) if errors.size else float("nan")
runtime_joints: dict[str, dict[str, Any]] = {}
for joint_name, joint in fit.joints.items():
runtime_joint: dict[str, Any] = {
"motor_index": int(joint["motor_index"]),
"angle_rad": [
round(float(value), 8) for value in joint["angle_rad"]
],
}
if joint_name == "thumb_ip":
runtime_joint["passive"] = True
runtime_joints[joint_name] = runtime_joint
payload = {
"schema_version": 2,
"model": "G20",
"side": "left",
"serial_number": str(serial_number),
"angle_unit": "rad",
"command_range": [0, 255],
"zero_command_u8": 255,
"baseline_command_u8": [int(value) for value in baseline],
"joints": runtime_joints,
"ip_coupling": {
"multiplier": fit.ip_coupling["multiplier"],
"offset_rad": fit.ip_coupling["offset_rad"],
},
"quality": {
"passed": bool(passed),
"validation_mae_rad": None if not np.isfinite(mae) else round(mae, 8),
"validation_p95_rad": None if not np.isfinite(p95) else round(p95, 8),
},
}
validate_final_payload(payload)
return payload
def maximum_non_target_drift_rad(
records: Iterable[Mapping[str, Any]],
fit: FitResult,
) -> float:
"""Measure unintended active-joint motion during the two isolated scans."""
samples = [
dict(record)
for record in records
if record.get("kind", "sample") == "sample"
]
maximum = 0.0
checks = (
(PHASE_ROOT, "thumb_mcp", PAIR_MCP),
(PHASE_ROOT, "thumb_ip", PAIR_IP),
(PHASE_TIP, "thumb_cmc_pitch", PAIR_ROOT),
)
for phase, joint_name, pair in checks:
phase_records = [record for record in samples if record.get("phase") == phase]
for cycle in sorted({int(record["cycle"]) for record in phase_records}):
cycle_records = [
record for record in phase_records if int(record["cycle"]) == cycle
]
reference_record = next(
(
record
for record in cycle_records
if record.get("direction") == DIRECTION_DECREASING
and int(record.get("command_u8", -1)) == 255
),
None,
)
if reference_record is None:
continue
reference = _record_rotation(reference_record, pair)
axis = np.asarray(fit.axes[joint_name], dtype=float)
for record in cycle_records:
drift = abs(
float(
delta_rotation_vector(
reference,
_record_rotation(record, pair),
)
@ axis
)
)
maximum = max(maximum, drift)
return maximum
def validate_final_payload(payload: Mapping[str, Any]) -> None:
"""Validate the deliberately small runtime JSON schema."""
if payload.get("schema_version") != 2:
raise ValueError("schema_version must be 2")
if payload.get("model") != "G20" or payload.get("side") != "left":
raise ValueError("payload must describe a left G20")
if payload.get("angle_unit") != "rad":
raise ValueError("angle_unit must be rad")
baseline = payload.get("baseline_command_u8")
if not isinstance(baseline, list) or len(baseline) != 20:
raise ValueError("baseline_command_u8 must contain 20 values")
joints = payload.get("joints")
if not isinstance(joints, Mapping) or set(joints) != set(JOINT_SPECS):
raise ValueError("payload must contain exactly the three thumb joints")
for joint_name, joint in joints.items():
expected_motor = JOINT_SPECS[joint_name][2]
if int(joint.get("motor_index", -1)) != expected_motor:
raise ValueError(f"{joint_name} has the wrong motor index")
curve = joint.get("angle_rad")
if not isinstance(curve, list) or len(curve) != 256:
raise ValueError(
f"{joint_name}.angle_rad must contain 256 values"
)
values = np.asarray(curve, dtype=float)
if not np.all(np.isfinite(values)):
raise ValueError(
f"{joint_name}.angle_rad contains non-finite values"
)
if np.any(np.diff(values) > 1e-7):
raise ValueError(
f"{joint_name}.angle_rad must be non-increasing"
)
if abs(float(values[255])) > 1e-6:
raise ValueError(
f"{joint_name}.angle_rad[255] must be zero"
)
if joints["thumb_ip"].get("passive") is not True:
raise ValueError("thumb_ip must be marked passive")
@@ -0,0 +1,14 @@
# 历史兼容区
此目录保留旧格式读写、旧布局、历史数据诊断及必要的离线工具,不参与正式在线调度。
四型号的 runner/node/pipeline 已由 `runtime/runner.py`、`runtime/session.py` 和
`runtime/artifacts/finalization.py` 替代。只保留仍被历史工具调用的入口;
L6/O6 无调用的 pipeline 包装已删除。
旧独立断点实现、低速预检和在线节点已移除。旧发布函数已拒绝更新正式发布指针。
这里生成的离线诊断文件不能作为标准 URDF 已验收的证据;正式回放使用
`calibrate_hand --config <product.yaml> --offline-raw <raw_samples.jsonl>`。
不要在此目录增加新型号。新型号提供 Profile、产品 YAML、原始 CAD/mesh;仅新 SDK 协议增加 Adapter。
需要恢复已删除的历史实现时,使用工作区
`calibration_output/refactor_backup.4WRWNn/` 中的归档,不要重新接入生产入口。
@@ -0,0 +1 @@
"""Read-only compatibility for archived sessions and diagnostic tools."""
@@ -0,0 +1,17 @@
"""Model- and side-specific calibration policies."""
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import (
EngineBindings,
ProfileRegistry,
RegisteredProfile,
get_default_registry,
)
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.runtime_schema import validate_schema_v6_runtime_payload
__all__ = [
"EngineBindings",
"ProfileRegistry",
"RegisteredProfile",
"get_default_registry",
"validate_schema_v6_runtime_payload",
]
@@ -0,0 +1,15 @@
"""Registered profiles for this hand family."""
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import ProfileRegistry
def register_profiles(registry: ProfileRegistry) -> None:
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.legacy_11 import build_left_profile, build_right_profile
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.right_19 import build_profile
registry.register(build_profile())
registry.register(build_left_profile())
registry.register(build_right_profile())
__all__ = ["register_profiles"]
@@ -0,0 +1,244 @@
"""Adapt reviewed family profiles to the shared typed contract."""
from __future__ import annotations
from linkerhand_calibration.core import (
CalibrationProfile,
CommandLayout,
MeasurementPolicy,
MeasurementSpec,
MotionPolicy,
ProfileKey,
QualityPolicy,
ScopePolicy,
TagSpec,
TaskSpec,
ViewSpec,
VisionRigSpec,
ZeroSolvePolicy,
)
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import MIMIC_DERIVED_FINGER_DIPS
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import EngineBindings, RegisteredProfile
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.artifacts import build_artifact_policy
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.motion import (
build_calibration_motion_command,
build_calibration_preparation_waypoints,
build_calibration_return_waypoints,
)
_HARD_THRESHOLD_KEYS = frozenset(
{
"minimum_detection_rate",
"maximum_reprojection_error_px",
"maximum_axis_cycle_difference_rad",
"maximum_pose_line_rms_m",
"maximum_validation_error_rad",
}
)
def _run_cli(args: list[str] | None = None) -> None:
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.runner import main
main(args)
def _run_node(args: list[str] | None = None) -> None:
from linkerhand_calibration.runtime.ros.entrypoint import main
main(args)
def _legacy_typed_profile(
*,
key: ProfileKey,
namespace: str,
hand_profile,
zero_profile,
mechanical_endpoint_joints: frozenset[str] = frozenset(),
post_solve_endpoint_joints: frozenset[str] = frozenset(),
) -> CalibrationProfile:
fixed_by_view = {
view: frozenset(roles)
for view, roles in hand_profile.preflight_view_roles.items()
}
views = tuple(
ViewSpec(
name=view,
tags=tuple(
TagSpec(
role=role,
tag_id=int(tag_id),
fixed_reference=role in fixed_by_view.get(view, frozenset()),
)
for role, tag_id in roles.items()
),
)
for view, roles in hand_profile.view_tags.items()
)
record_specs = hand_profile.record_specs
command_index_by_joint = {
name: int(spec.motor_index) for name, spec in record_specs.items()
}
command_index_by_joint.update(
{
name: int(spec.motor_index)
for name, spec in hand_profile.joint_specs.items()
}
)
measurements = {
name: MeasurementSpec(
joint=name,
kind=str(spec.zero_kind or "curve"),
view=spec.view,
parent_role=spec.parent_role,
child_role=spec.child_role,
validation_source=(hand_profile.axis_validation_sources or {}).get(
name
),
pose_axis_line_required=bool(
spec.pose_axis_line_required
),
)
for name, spec in record_specs.items()
}
tasks = tuple(
TaskSpec(
key=spec.key,
view=spec.view,
command_index=int(spec.motor_index),
joints=tuple(spec.joints),
auxiliary_commands=tuple(spec.auxiliary_commands),
validation_only=bool(spec.validation_only),
)
for spec in hand_profile.sweep_specs
)
active = frozenset(hand_profile.active_joints)
passive = frozenset(hand_profile.passive_joints)
thumb = frozenset(name for name in active if name.startswith("thumb_"))
fingers = active - thumb
typed = CalibrationProfile(
key=key,
namespace=namespace,
command=CommandLayout(
names=tuple(hand_profile.command_names),
baseline_u8=tuple(int(value) for value in hand_profile.baseline_command),
command_index_by_joint=command_index_by_joint,
disabled_indices=frozenset(
index
for index, name in enumerate(hand_profile.command_names)
if name.startswith("reserved_")
),
),
vision=VisionRigSpec(
views=views,
common_frame="calibration_common",
extrinsic_reference_view=views[0].name,
extrinsics_quality_limits={
"reprojection_rms_px": 1.2,
"maximum_rotation_repeatability_deg": 0.3,
"maximum_translation_repeatability_m": 0.0015,
},
minimum_capture_counts={
"front_side_captures": 15,
"front_top_captures": 15,
},
),
motion=MotionPolicy(
tasks=tasks,
precheck_sweeps=bool(hand_profile.precheck_sweeps),
steady_command_checkpoints=bool(
hand_profile.steady_command_checkpoints
),
),
measurement=MeasurementPolicy(
measurements=measurements,
cross_view_sources=dict(
hand_profile.axis_validation_sources or {}
),
image_curve_joints=frozenset(
hand_profile.image_trajectory_joints
),
directional_zero=bool(hand_profile.directional_zero),
cross_view_roll_curve=bool(hand_profile.cross_view_roll_curve),
stable_cross_view_cone_bias=bool(
hand_profile.stable_cross_view_cone_bias
),
),
zero=ZeroSolvePolicy(
active_joints=active,
passive_joints=passive,
direct_zero_joints=tuple(zero_profile.direct_zero_joints),
axis_joints=tuple(zero_profile.axis_joints),
mechanical_endpoint_joints=mechanical_endpoint_joints,
post_solve_endpoint_joints=post_solve_endpoint_joints,
mimic_source_by_joint={
target: source
for target, source in MIMIC_DERIVED_FINGER_DIPS.items()
if target in passive and source in active
},
cad_frozen_joints=frozenset(
passive - set(zero_profile.static_output_zero_offsets_rad)
),
),
quality=QualityPolicy(
training_cycles=(0, 1, 2),
holdout_cycle=3 if key.layout != "legacy_11" else None,
hard_threshold_keys=_HARD_THRESHOLD_KEYS,
isolated_holdout=bool(hand_profile.isolated_holdout),
),
scope=ScopePolicy(
calibrate_joints={
"full": active,
"thumb": thumb,
"fingers": fingers,
},
frozen_joints={
"full": frozenset(),
"thumb": fingers,
"fingers": thumb,
},
),
artifacts=build_artifact_policy(
frozenset(hand_profile.capabilities)
),
urdf_authorized_fields={
name: frozenset({"origin.rpy", "limit.upper"})
for name in active
} | {
name: frozenset({"mimic.offset"})
for name in passive
if name in zero_profile.static_output_zero_offsets_rad
},
)
return typed
def adapt_profile(
*, key: ProfileKey, namespace: str, hand_profile, zero_profile,
mechanical_endpoint_joints: frozenset[str] = frozenset(),
post_solve_endpoint_joints: frozenset[str] = frozenset(),
typed_profile: CalibrationProfile | None = None,
) -> RegisteredProfile:
# Current products supply their authoritative YAML; only the archived
# 11-Tag compatibility contract is still compiled from its old definition.
typed = typed_profile if typed_profile is not None else _legacy_typed_profile(
key=key, namespace=namespace, hand_profile=hand_profile,
zero_profile=zero_profile, mechanical_endpoint_joints=mechanical_endpoint_joints,
post_solve_endpoint_joints=post_solve_endpoint_joints,
)
if typed.key != key or typed.namespace != namespace:
raise ValueError("declarative profile identity differs from product binding")
return RegisteredProfile(
profile=typed,
engine=EngineBindings(
hand_profile=hand_profile,
zero_profile=zero_profile,
motion_command=build_calibration_motion_command,
preparation_waypoints=build_calibration_preparation_waypoints,
return_waypoints=build_calibration_return_waypoints,
cli_main=_run_cli,
node_main=_run_node,
),
)
@@ -0,0 +1,31 @@
"""Runtime JSON and corrected-URDF naming policy."""
from linkerhand_calibration.core import ArtifactPolicy
def build_artifact_policy(
compatibility_tokens: frozenset[str],
) -> ArtifactPolicy:
return ArtifactPolicy(
output_schema_version=4,
calibration_filename="g20_{side}_{serial_number}_calibration.json",
corrected_urdf_filename=(
"linkerhand_g20_{side}_{serial_number}_zero_calibrated.urdf"
),
protected_input_fields=frozenset(
{
"source_urdf_sha256",
"camera_extrinsics_sha256",
"calibration_config_sha256",
"tag_config_sha256",
"profile_config_sha256",
}
),
session_compatibility_tokens=frozenset(compatibility_tokens),
publish_corrected_urdf=(
"urdf_zero_publication" in compatibility_tokens
),
)
__all__ = ["build_artifact_policy"]
@@ -0,0 +1,26 @@
"""Reviewed command-channel layouts for this hand family."""
G20_COMMAND_NAMES: tuple[str, ...] = (
"thumb_cmc_pitch",
"index_mcp_pitch",
"middle_mcp_pitch",
"ring_mcp_pitch",
"pinky_mcp_pitch",
"thumb_cmc_roll",
"index_mcp_roll",
"middle_mcp_roll",
"ring_mcp_roll",
"pinky_mcp_roll",
"thumb_cmc_yaw",
"reserved_11",
"reserved_12",
"reserved_13",
"reserved_14",
"thumb_mcp",
"index_pip",
"middle_pip",
"ring_pip",
"pinky_pip",
)
__all__ = ["G20_COMMAND_NAMES"]
@@ -0,0 +1,144 @@
"""Read-only regression checks for the reviewed hardware sessions."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
from typing import Any
_PASS_SESSIONS = {
"20260830_181154": "full",
"20260831_141123": "thumb",
"20260831_163843": "thumb",
}
_FAIL_SESSIONS = {
"20260831_111837": ("FIT-MODEL-401", "joint_fit_check_failed"),
"20260831_142322": ("VAL-QUALITY-501", "zero_model_validation_failed"),
}
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _summary(session: Path) -> dict[str, Any]:
value = json.loads(
(session / "calibration_summary_zh.json").read_text(encoding="utf-8")
)
if not isinstance(value, dict):
raise ValueError(f"invalid golden summary: {session}")
return value
def validate_golden_sessions(
serial_root: str | Path,
*,
replay_full_session: bool = True,
) -> dict[str, Any]:
"""Prove PASS/FAIL, hashes, partial freezes, and failure non-publication."""
root = Path(serial_root).expanduser().resolve()
results: dict[str, Any] = {}
for session_id, scope in _PASS_SESSIONS.items():
session = root / session_id
summary = _summary(session)
if summary.get("result") != "PASS":
raise ValueError(f"golden PASS changed: {session_id}")
if summary.get("calibration_scope") != scope:
raise ValueError(f"golden scope changed: {session_id}")
artifacts = summary["artifacts"]
hashes = summary["hashes"]
calibration_json = session / artifacts["json"]
corrected_urdf = session / artifacts["urdf"]
actual_json_hash = _sha256_file(calibration_json)
actual_urdf_hash = _sha256_file(corrected_urdf)
if actual_json_hash != hashes["calibration_json_sha256"]:
raise ValueError(f"golden JSON hash changed: {session_id}")
if actual_urdf_hash != hashes["corrected_urdf_sha256"]:
raise ValueError(f"golden URDF hash changed: {session_id}")
if session_id == "20260831_141123" and len(
summary.get("preserved_certified_zero_joints", ())
) != 12:
raise ValueError("merged thumb no longer freezes all finger zeros")
if session_id == "20260831_163843":
payload = json.loads(calibration_json.read_text(encoding="utf-8"))
if payload.get("artifact_type") != (
"g20_right_standalone_thumb_calibration"
):
raise ValueError("standalone thumb artifact type changed")
results[session_id] = {
"result": "PASS",
"json_sha256": actual_json_hash,
"urdf_sha256": actual_urdf_hash,
}
for session_id, (error_code, reason) in _FAIL_SESSIONS.items():
session = root / session_id
summary = _summary(session)
if (
summary.get("result") != "FAIL"
or summary.get("error_code") != error_code
or summary.get("reason") != reason
):
raise ValueError(f"golden failure decision changed: {session_id}")
results[session_id] = {
"result": "FAIL",
"error_code": error_code,
"reason": reason,
}
failure_ids = set(_FAIL_SESSIONS)
for pointer_name in ("latest_passed", "latest_thumb_passed"):
pointer = root / pointer_name
if pointer.exists() and pointer.resolve().name in failure_ids:
raise ValueError(f"failure session was published through {pointer_name}")
if replay_full_session:
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.offline_replay import replay_session
session_id = "20260830_181154"
replay = replay_session(root / session_id, write_outputs=False)
expected = results[session_id]
if replay["computed_final_json_sha256"] != expected["json_sha256"]:
raise ValueError(
"full-session replay JSON is not byte-identical: "
f"computed={replay['computed_final_json_sha256']} "
f"expected={expected['json_sha256']}"
)
if replay["corrected_urdf_sha256"] != expected["urdf_sha256"]:
raise ValueError(
"full-session replay URDF is not byte-identical: "
f"computed={replay['corrected_urdf_sha256']} "
f"expected={expected['urdf_sha256']}"
)
results[session_id]["offline_replay"] = "byte_identical"
return results
def main(args: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
description="Validate the five reviewed calibration sessions"
)
parser.add_argument("serial_root")
parser.add_argument("--no-replay", action="store_true")
selected = parser.parse_args(args)
print(
json.dumps(
validate_golden_sessions(
selected.serial_root,
replay_full_session=not selected.no_replay,
),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,35 @@
"""One-release typed wrappers for the legacy 11-Tag layouts."""
from linkerhand_calibration.core import ProfileKey
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import get_hand_calibration_profile
from linkerhand_calibration.urdf_zero import get_zero_calibration_profile
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import RegisteredProfile
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20._adapter import adapt_profile
LEFT_KEY = ProfileKey("G20", "left", "legacy_11", 1)
RIGHT_KEY = ProfileKey("G20", "right", "legacy_11", 1)
def build_left_profile() -> RegisteredProfile:
hand = get_hand_calibration_profile(LEFT_KEY.side, LEFT_KEY.layout)
return adapt_profile(
key=LEFT_KEY,
namespace="/g20_calibration",
hand_profile=hand,
zero_profile=get_zero_calibration_profile(
LEFT_KEY.side, LEFT_KEY.layout
),
)
def build_right_profile() -> RegisteredProfile:
hand = get_hand_calibration_profile(RIGHT_KEY.side, RIGHT_KEY.layout)
return adapt_profile(
key=RIGHT_KEY,
namespace="/g20_calibration",
hand_profile=hand,
zero_profile=get_zero_calibration_profile(
RIGHT_KEY.side, RIGHT_KEY.layout
),
)
@@ -0,0 +1,15 @@
"""Reviewed motion and safe-waypoint strategy exports."""
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import (
build_calibration_motion_command,
build_calibration_preparation_waypoints,
build_calibration_return_waypoints,
build_calibration_speed_profile,
)
__all__ = [
"build_calibration_motion_command",
"build_calibration_preparation_waypoints",
"build_calibration_return_waypoints",
"build_calibration_speed_profile",
]
@@ -0,0 +1,687 @@
"""Validate and atomically publish one inseparable G20 JSON/URDF session."""
from __future__ import annotations
import copy
import json
import math
import os
from pathlib import Path
import re
from typing import Any, Mapping, Sequence
import xml.etree.ElementTree as ET
import numpy as np
from scipy.spatial.transform import Rotation
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import (
G20_COMBINATION_REQUIRED_TARGET_KEYS,
G20_RIGHT_19_LAYOUT,
get_hand_calibration_profile,
validate_compact_payload,
)
from linkerhand_calibration.product import ProductConfig, sha256_file
from linkerhand_calibration.storage import atomic_write_json
from linkerhand_calibration.core.urdf import UrdfCorrectionPlan, build_correction_plan
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_solver import (
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
get_zero_calibration_profile,
)
PASSIVE_JOINTS = frozenset(
{"thumb_ip", "index_dip", "middle_dip", "ring_dip", "pinky_dip"}
)
VISUALLY_MEASURED_FINGER_DIPS = frozenset(
{"index_dip", "middle_dip", "ring_dip", "pinky_dip"}
)
ACTIVE_ZERO_JOINTS = frozenset(
get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT).direct_zero_joints
)
RETAINED_ACTIVE_ZERO_JOINTS = frozenset(
get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT).active_joints
) - ACTIVE_ZERO_JOINTS
SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD = 5.1e-9
def _load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as stream:
payload = json.load(stream)
if not isinstance(payload, dict):
raise ValueError(f"{path} must contain a JSON object")
return payload
def _joint_blocks(text: str) -> dict[str, str]:
pattern = re.compile(
r"<joint\b[^>]*\bname\s*=\s*([\"'])(?P<name>[^\"']+)\1[^>]*>.*?</joint>",
re.DOTALL,
)
return {match.group("name"): match.group(0) for match in pattern.finditer(text)}
def _mask_origin_rpy(block: str) -> str:
return re.sub(
r"(<origin\b[^>]*\brpy\s*=\s*)([\"'])[^\"']*\2",
r"\1\2__CALIBRATED_RPY__\2",
block,
count=1,
)
def _mask_active_origin_rpy_fields(text: str) -> str:
pattern = re.compile(
r"<joint\b[^>]*\bname\s*=\s*([\"'])(?P<name>[^\"']+)\1[^>]*>.*?</joint>",
re.DOTALL,
)
def replace(match: re.Match[str]) -> str:
block = match.group(0)
if match.group("name") not in ACTIVE_ZERO_JOINTS:
return block
masked = _mask_origin_rpy(block)
if masked == block:
raise ValueError(
f"active joint {match.group('name')} has no origin.rpy"
)
return masked
return pattern.sub(replace, text)
def _mask_endpoint_coordinate_fields(text: str) -> str:
"""Mask only limit/mimic fields induced by endpoint zero coordinates."""
pattern = re.compile(
r"<joint\b[^>]*\bname\s*=\s*([\"'])(?P<name>[^\"']+)\1[^>]*>.*?</joint>",
re.DOTALL,
)
def replace(match: re.Match[str]) -> str:
block = match.group(0)
name = match.group("name")
if name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS:
return re.sub(
r"(<limit\b[^>]*\bupper\s*=\s*)([\"'])[^\"']*\2",
r"\1\2__CALIBRATED_UPPER__\2",
block,
count=1,
)
mimic = re.search(
r"<mimic\b[^>]*\bjoint\s*=\s*([\"'])(?P<source>[^\"']+)\1[^>]*>",
block,
re.DOTALL,
)
if (
mimic is not None
and mimic.group("source") in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
):
return re.sub(
r"(<mimic\b[^>]*\boffset\s*=\s*)([\"'])[^\"']*\2",
r"\1\2__CALIBRATED_MIMIC_OFFSET__\2",
block,
count=1,
)
return block
return pattern.sub(replace, text)
def _triplet(value: str) -> np.ndarray:
result = np.asarray([float(item) for item in value.split()], dtype=float)
if result.shape != (3,) or not np.all(np.isfinite(result)):
raise ValueError(f"invalid URDF triplet: {value}")
return result
def _joint_elements(path: str | Path) -> dict[str, ET.Element]:
return {
str(joint.get("name")): joint
for joint in ET.parse(Path(path)).getroot().findall("joint")
}
def _verify_expected_origin_offsets(
source: str | Path,
corrected: str | Path,
expected_offsets_rad: Mapping[str, float],
) -> None:
offsets = {str(name): float(value) for name, value in expected_offsets_rad.items()}
if set(offsets) != ACTIVE_ZERO_JOINTS or any(
not math.isfinite(value) for value in offsets.values()
):
raise ValueError("expected offsets must contain all finite active static-zero values")
before = _joint_elements(source)
after = _joint_elements(corrected)
maximum_rotation_error = 0.0
for name, original_joint in before.items():
corrected_joint = after[name]
original_origin = original_joint.find("origin")
corrected_origin = corrected_joint.find("origin")
if original_origin is None or corrected_origin is None:
if original_origin is not corrected_origin:
raise ValueError(f"corrected URDF changed origin presence for {name}")
continue
original_xyz = _triplet(original_origin.get("xyz", "0 0 0"))
corrected_xyz = _triplet(corrected_origin.get("xyz", "0 0 0"))
if not np.allclose(original_xyz, corrected_xyz, atol=1.0e-12, rtol=0.0):
raise ValueError(f"corrected URDF changed origin.xyz for {name}")
original_rotation = Rotation.from_euler(
"xyz", _triplet(original_origin.get("rpy", "0 0 0"))
)
corrected_rotation = Rotation.from_euler(
"xyz", _triplet(corrected_origin.get("rpy", "0 0 0"))
)
expected_rotation = original_rotation
if name in offsets:
axis_node = original_joint.find("axis")
axis = _triplet(
"1 0 0" if axis_node is None else axis_node.get("xyz", "1 0 0")
)
norm = float(np.linalg.norm(axis))
if norm <= 1.0e-12:
raise ValueError(f"source URDF joint {name} has a degenerate axis")
expected_rotation = expected_rotation * Rotation.from_rotvec(
axis / norm * offsets[name]
)
error = float((expected_rotation.inv() * corrected_rotation).magnitude())
maximum_rotation_error = max(maximum_rotation_error, error)
# Schema v4 intentionally stores zero offsets at eight decimal places.
# Older sessions wrote the corrected URDF from the full-precision solve,
# so comparing that file with the published JSON necessarily permits half
# of one last-place unit. This is about 2.9e-7 degrees and is far below
# any calibration or URDF numerical significance.
if maximum_rotation_error > SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD:
raise ValueError(
"corrected URDF origin.rpy does not match the published zero offsets"
)
def verify_corrected_urdf(
source: str | Path,
corrected: str | Path,
*,
expected_offsets_rad: Mapping[str, float] | None = None,
endpoint_anchored_offsets_rad: Mapping[str, float] | None = None,
correction_plan: UrdfCorrectionPlan | None = None,
) -> tuple[str, ...]:
"""Prove that only active-joint origin.rpy attributes changed.
Passive joint blocks, including their mimic elements, are compared as raw
UTF-8 text so formatting and numeric spelling are protected as well.
"""
source_text = Path(source).read_text(encoding="utf-8")
corrected_text = Path(corrected).read_text(encoding="utf-8")
if correction_plan is not None:
correction_plan.verify_source(source)
if expected_offsets_rad is None:
raise ValueError(
"correction-plan validation requires expected offsets"
)
correction_plan.authorize_offsets(expected_offsets_rad)
before = _joint_blocks(source_text)
after = _joint_blocks(corrected_text)
if set(before) != set(after):
raise ValueError("corrected URDF changed the joint set")
# Text patchers may normalize only the final newline. It has no URDF/XML
# semantics; every joint block and every non-rpy byte remains guarded
# below.
masked_source = _mask_endpoint_coordinate_fields(
_mask_active_origin_rpy_fields(source_text)
).rstrip("\r\n")
masked_corrected = _mask_endpoint_coordinate_fields(
_mask_active_origin_rpy_fields(corrected_text)
).rstrip("\r\n")
if masked_source != masked_corrected:
raise ValueError(
"corrected URDF changed content outside active origin.rpy or "
"endpoint-calibrated fields"
)
changed: list[str] = []
for name in before:
if before[name] == after[name]:
continue
if name not in ACTIVE_ZERO_JOINTS and name not in PASSIVE_JOINTS:
raise ValueError(f"corrected URDF changed protected joint {name}")
if name in ACTIVE_ZERO_JOINTS:
changed.append(name)
if expected_offsets_rad is not None:
_verify_expected_origin_offsets(source, corrected, expected_offsets_rad)
endpoint_offsets = {
str(name): float(value)
for name, value in dict(endpoint_anchored_offsets_rad or {}).items()
}
if endpoint_offsets:
if correction_plan is not None and not set(endpoint_offsets).issubset(
correction_plan.endpoint_limit_joints
):
raise ValueError(
"endpoint offsets are not authorized by the correction plan"
)
source_joints = _joint_elements(source)
corrected_joints = _joint_elements(corrected)
for name, offset in endpoint_offsets.items():
original_limit = source_joints[name].find("limit")
corrected_limit = corrected_joints[name].find("limit")
expected_upper = float(original_limit.get("upper")) - offset
# Schema v4 stores the corresponding zero at eight decimal
# places, while the URDF is written from the full-precision solve.
# Match the half-last-place tolerance used for origin rotations.
if (
abs(float(corrected_limit.get("upper")) - expected_upper)
> SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
):
raise ValueError(f"corrected URDF has invalid {name} upper limit")
for name in PASSIVE_JOINTS:
original_mimic = source_joints[name].find("mimic")
corrected_mimic = corrected_joints[name].find("mimic")
if original_mimic is None or corrected_mimic is None:
continue
source_name = str(original_mimic.get("joint"))
multiplier = float(original_mimic.get("multiplier", "1"))
expected = float(original_mimic.get("offset", "0"))
expected += multiplier * endpoint_offsets.get(source_name, 0.0)
# The active endpoint offset comes from schema-v4 JSON rounded to
# eight decimal places, while the URDF mimic was written from the
# full-precision solve. Propagate exactly the same accepted
# quantization through the mimic multiplier; retain a much
# smaller allowance for XML decimal formatting itself.
tolerance = (
abs(multiplier)
* SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
+ 1.0e-12
)
if abs(float(corrected_mimic.get("offset", "0")) - expected) > tolerance:
raise ValueError(f"corrected URDF has invalid {name} mimic offset")
return tuple(sorted(changed))
def verify_urdf_mesh_resources(urdf: str | Path) -> dict[str, Path]:
"""Return every session-local mesh after proving it resolves safely."""
path = Path(urdf).expanduser().resolve()
root = ET.parse(path).getroot()
resources: dict[str, Path] = {}
for mesh in root.findall(".//mesh"):
filename = str(mesh.get("filename", "")).strip()
if not filename or "://" in filename or filename.startswith("package:"):
continue
relative = Path(filename)
if relative.is_absolute() or ".." in relative.parts:
raise ValueError(f"URDF has an unsafe local mesh path: {filename}")
resolved = (path.parent / relative).resolve()
try:
resolved.relative_to(path.parent)
except ValueError as error:
raise ValueError(f"URDF mesh escapes the session: {filename}") from error
if not resolved.is_file():
raise ValueError(f"URDF mesh resource is missing: {filename}")
resources[relative.as_posix()] = resolved
return dict(sorted(resources.items()))
def validate_runtime_curves_against_urdf_limits(
payload: Mapping[str, Any], runtime_urdf: str | Path
) -> None:
"""Reject a curve whose q leaves its runtime URDF coordinate limits.
Curves in schema v4 are expressed in the corrected URDF joint coordinate,
not in the source-CAD coordinate. Endpoint zero calibration can therefore
move a corrected coordinate limit while preserving the same physical CAD
endpoint; callers publishing a calibrated pair must pass that corrected
URDF here.
"""
validate_compact_payload(payload)
runtime_joints = _joint_elements(runtime_urdf)
for name, calibration in payload["joints"].items():
joint = runtime_joints.get(str(name))
if joint is None:
raise ValueError(f"runtime URDF is missing joint {name}")
limit = joint.find("limit")
if limit is None or limit.get("lower") is None or limit.get("upper") is None:
raise ValueError(f"runtime URDF joint {name} has no finite position limit")
lower = float(limit.get("lower"))
upper = float(limit.get("upper"))
if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
raise ValueError(f"runtime URDF joint {name} has invalid position limits")
curve = np.asarray(calibration["angle_rad"], dtype=float)
minimum = float(np.min(curve))
maximum = float(np.max(curve))
tolerance = 1.0e-7
if minimum < lower - tolerance or maximum > upper + tolerance:
raise ValueError(
f"runtime curve exceeds runtime URDF limit for {name}: "
f"[{minimum:.9g}, {maximum:.9g}] not within "
f"[{lower:.9g}, {upper:.9g}]"
)
def clamp_compact_payload_to_urdf_limits(
payload: Mapping[str, Any], runtime_urdf: str | Path
) -> tuple[dict[str, Any], dict[str, int]]:
"""Return a schema-preserving payload bounded in its runtime coordinates."""
result = copy.deepcopy(dict(payload))
validate_compact_payload(result)
runtime_joints = _joint_elements(runtime_urdf)
clipped_by_joint: dict[str, int] = {}
for name, calibration in result["joints"].items():
joint = runtime_joints.get(str(name))
limit = None if joint is None else joint.find("limit")
if (
limit is None
or limit.get("lower") is None
or limit.get("upper") is None
):
raise ValueError(f"runtime URDF joint {name} has no finite position limit")
lower = float(limit.get("lower"))
upper = float(limit.get("upper"))
if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
raise ValueError(f"runtime URDF joint {name} has invalid position limits")
source = np.asarray(calibration["angle_rad"], dtype=float)
bounded = np.clip(source, lower, upper)
count = int(np.count_nonzero(bounded != source))
if count:
clipped_by_joint[str(name)] = count
calibration["angle_rad"] = [
round(float(value), 8) for value in bounded
]
validate_compact_payload(result)
return result, clipped_by_joint
def build_mujoco_validation_commands(
baseline: Sequence[int],
) -> dict[str, Any]:
if len(baseline) != 20:
raise ValueError("baseline must contain exactly 20 commands")
base = [int(value) for value in baseline]
if any(not 0 <= value <= 255 for value in base):
raise ValueError("baseline commands must be in [0, 255]")
def pose(name: str, label: str, changes: Mapping[int, int]) -> dict[str, Any]:
values = list(base)
for index, value in changes.items():
values[int(index)] = int(value)
return {"name": name, "label_zh": label, "command_u8": values}
return {
"schema_version": 1,
"topic": "/g20/cb_right_hand_control_cmd",
"serial_number": "G20_RIGHT_001",
"poses": [
pose("all_open", "全开", {}),
pose("thumb_middle", "拇指中位", {0: 160, 5: 160, 10: 160, 15: 160}),
pose("index_middle", "食指中位", {1: 160, 6: 127, 16: 160}),
pose("middle_middle", "中指中位", {2: 160, 7: 127, 17: 160}),
pose("ring_middle", "无名指中位", {3: 160, 8: 127, 18: 160}),
pose("pinky_middle", "小指中位", {4: 160, 9: 127, 19: 160}),
pose(
"half_grip",
"四指半握",
{1: 160, 2: 160, 3: 160, 4: 160, 16: 160, 17: 160, 18: 160, 19: 160},
),
pose(
"light_pinch",
"轻捏",
{0: 176, 5: 176, 10: 176, 15: 176, 1: 176, 6: 127, 16: 176},
),
],
}
def atomic_session_pointer(root: str | Path, name: str, session: str | Path) -> Path:
if "passed" in name:
raise ValueError("release pointers require the common ArtifactPublisher")
parent = Path(root).resolve()
target = Path(session).resolve()
if target.parent != parent:
raise ValueError("session pointer target must be a direct child of the serial root")
if not target.is_dir():
raise ValueError(f"session directory does not exist: {target}")
if name not in {"latest_attempt", "latest_passed", "latest_thumb_passed"}:
raise ValueError("unsupported session pointer name")
parent.mkdir(parents=True, exist_ok=True)
destination = parent / name
temporary = parent / f".{name}.{os.getpid()}.tmp"
if temporary.is_symlink():
temporary.unlink()
elif temporary.exists():
raise ValueError(f"temporary pointer path is occupied: {temporary}")
os.symlink(target.name, temporary, target_is_directory=True)
os.replace(temporary, destination)
return destination
def active_offsets(payload: Mapping[str, Any]) -> dict[str, float]:
validate_compact_payload(payload)
result = {
name: float(payload["joints"][name]["zero_angles"]["urdf_zero_offset_rad"])
for name in ACTIVE_ZERO_JOINTS
}
if set(result) != ACTIVE_ZERO_JOINTS or any(
not math.isfinite(value) for value in result.values()
):
raise ValueError(
"payload does not contain all finite observable active zero offsets"
)
return result
def standalone_thumb_offsets(payload: Mapping[str, Any]) -> dict[str, float]:
"""Validate a standalone thumb artifact and expand CAD finger zeros."""
if (
payload.get("schema_version") != 1
or payload.get("artifact_type")
!= "g20_right_standalone_thumb_calibration"
or payload.get("side") != "right"
or payload.get("non_thumb_zero_policy") != "source_cad_unchanged"
or not bool(payload.get("quality", {}).get("passed"))
):
raise ValueError("standalone thumb JSON contract is invalid")
joints = payload.get("joints")
expected_joint_names = {
"thumb_cmc_roll",
"thumb_cmc_yaw",
"thumb_cmc_pitch",
"thumb_mcp",
"thumb_ip",
}
if not isinstance(joints, Mapping) or set(joints) != expected_joint_names:
raise ValueError("standalone thumb JSON has the wrong joint set")
thumb_names = expected_joint_names - {"thumb_ip"}
result = {name: 0.0 for name in ACTIVE_ZERO_JOINTS}
for name in thumb_names:
try:
value = float(joints[name]["zero_angles"]["urdf_zero_offset_rad"])
except (KeyError, TypeError, ValueError) as error:
raise ValueError(
f"standalone thumb JSON is missing zero for {name}"
) from error
curve = np.asarray(joints[name].get("angle_rad", ()), dtype=float)
if curve.shape != (256,) or not np.all(np.isfinite(curve)):
raise ValueError(f"standalone thumb JSON curve is invalid: {name}")
result[name] = value
passive_curve = np.asarray(
joints["thumb_ip"].get("angle_rad", ()), dtype=float
)
if (
passive_curve.shape != (256,)
or not np.all(np.isfinite(passive_curve))
or not bool(joints["thumb_ip"].get("passive"))
or any(not math.isfinite(value) for value in result.values())
):
raise ValueError("standalone thumb JSON passive curve is invalid")
return result
def verify_partial_scope_preserves_certified_zeros(
*,
scope: str,
source_session: str | Path,
serial_root: str | Path | None = None,
serial_number: str,
current_offsets: Mapping[str, float],
) -> None:
"""Reject a partial artifact that changes any certified non-target zero."""
selected = str(scope).strip().lower()
if selected not in {"thumb", "fingers"}:
return
source = Path(source_session).expanduser()
if not source.is_absolute():
if serial_root is None:
raise ValueError(
"relative partial base session requires its serial root"
)
source = Path(serial_root).expanduser() / source
source = source.resolve()
base_payload = _load_json(
source / f"g20_right_{serial_number}_calibration.json"
)
base_offsets = (
standalone_thumb_offsets(base_payload)
if base_payload.get("artifact_type")
== "g20_right_standalone_thumb_calibration"
else active_offsets(base_payload)
)
preserve_thumb = selected == "fingers"
changed = {
name: abs(float(current_offsets[name]) - float(base_offsets[name]))
for name in ACTIVE_ZERO_JOINTS
if name.startswith("thumb_") == preserve_thumb
and abs(float(current_offsets[name]) - float(base_offsets[name]))
> SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
}
if changed:
raise ValueError(
f"partial {selected} calibration changed certified non-target "
"zeros: "
+ ",".join(sorted(changed))
)
def compare_session_offsets(
first: Mapping[str, Any],
second: Mapping[str, Any],
*,
maximum_difference_rad: float,
) -> dict[str, float]:
first_standalone = (
first.get("artifact_type")
== "g20_right_standalone_thumb_calibration"
)
second_standalone = (
second.get("artifact_type")
== "g20_right_standalone_thumb_calibration"
)
if first_standalone != second_standalone:
raise ValueError("cannot compare standalone thumb and full-hand artifacts")
left = (
standalone_thumb_offsets(first)
if first_standalone
else active_offsets(first)
)
right = (
standalone_thumb_offsets(second)
if second_standalone
else active_offsets(second)
)
differences = {
name: abs(left[name] - right[name])
for name in sorted(left)
}
failed = {name: value for name, value in differences.items() if value > maximum_difference_rad}
if failed:
details = ", ".join(
f"{name}={math.degrees(value):.3f}deg" for name, value in failed.items()
)
raise ValueError(f"independent-session static-zero mismatch: {details}")
return differences
def session_artifact_paths(session: str | Path, serial_number: str) -> dict[str, Path]:
directory = Path(session).resolve()
urdfs = sorted(directory.glob(f"*zero_calibrated_{serial_number}_*.urdf"))
if len(urdfs) != 1:
raise ValueError("session must contain exactly one corrected URDF")
return {
"json": directory / f"g20_right_{serial_number}_calibration.json",
"urdf": urdfs[0],
"summary": directory / "calibration_summary_zh.json",
"commands": directory / "mujoco_validation_commands.json",
"raw": directory / "raw_samples.jsonl",
"log": directory / "calibration.log",
}
def find_compatible_prior_session(
config: ProductConfig,
current: str | Path,
current_payload: Mapping[str, Any],
) -> tuple[Path | None, dict[str, float]]:
current_path = Path(current).resolve()
for candidate in sorted(config.session_root.glob("20??????_??????"), reverse=True):
if candidate.resolve() == current_path:
continue
json_path = candidate / f"g20_right_{config.serial_number}_calibration.json"
summary_path = candidate / "calibration_summary_zh.json"
if not json_path.is_file() or not summary_path.is_file():
continue
try:
summary = _load_json(summary_path)
if not bool(summary.get("quality", {}).get("passed")):
continue
payload = _load_json(json_path)
differences = compare_session_offsets(
payload,
current_payload,
maximum_difference_rad=config.static_repeatability_rad,
)
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
continue
return candidate, differences
return None, {}
def _verify_combination_validation(combination: Mapping[str, Any]) -> None:
# The formal product uses the independently held-out fourth sweep cycle.
# The optional eight-pose check remains a developer diagnostic because the
# axis-line zero solve does not establish an absolute Cartesian hand-base
# transform. When explicitly enabled, retain its strict coverage and
# error checks.
if not bool(combination.get("enabled")):
return
position_p95 = float(combination.get("position_p95_m", float("inf")))
orientation_p95 = float(
combination.get("orientation_p95_rad", float("inf"))
)
required = {str(value) for value in combination.get("required_targets", ())}
expected = set(G20_COMBINATION_REQUIRED_TARGET_KEYS)
observations = combination.get("observation_counts")
validations = combination.get("validation_counts")
if required != expected:
raise ValueError("combination validation has the wrong required-target set")
if not isinstance(observations, Mapping) or not isinstance(validations, Mapping):
raise ValueError("combination validation is missing per-target coverage")
missing_observations = sorted(
key for key in expected if int(observations.get(key, 0)) < 2
)
missing_validations = sorted(
key for key in expected if int(validations.get(key, 0)) < 1
)
if missing_observations or missing_validations:
raise ValueError(
"combination validation target coverage is incomplete: "
f"observations={','.join(missing_observations) or '-'}; "
f"validations={','.join(missing_validations) or '-'}"
)
if (
not bool(combination.get("completed"))
or int(combination.get("completed_poses", 0)) != 8
or position_p95 > 0.003
or orientation_p95 > math.radians(2.0)
):
raise ValueError(
"eight-pose four-finger combination validation is incomplete or failed"
)
def finalize_session_artifacts(*args, **kwargs):
raise ValueError("legacy publication is retired; use calibrate_hand --offline-raw with the common finalizer")
@@ -0,0 +1,893 @@
"""Chinese, operator-facing diagnostics for three-camera calibration."""
from __future__ import annotations
import re
from typing import Any, Mapping, Sequence
STATE_NAMES_ZH = {
"PREFLIGHT": "设备和标签预检",
"WAIT_START": "等待开始标定",
"IMPORTING_BASE": "正在读取基础标定会话",
"REVALIDATING_INHERITED": "正在复核继承的四指数据",
"RETURN_BASELINE": "正在恢复目标姿态",
"PREPARE_SWEEP": "正在到达扫描起点",
"SWEEP": "正在采集轨迹",
"FITTING": "正在拟合轨迹和零位",
"VALIDATION_MOVE": "正在移动到随机复测位置",
"VALIDATION_CAPTURE": "正在采集随机复测数据",
"PAUSED": "标定已暂停",
"ABORTED": "标定已终止",
"COMPLETE": "标定已完成",
}
VIEW_NAMES_ZH = {
"front": "正面",
"side": "侧面",
"top": "上面",
}
JOINT_NAMES_ZH = {
"thumb_cmc_pitch": "拇指CMC俯仰",
"thumb_cmc_roll": "拇指CMC滚转",
"thumb_mcp": "拇指MCP",
"thumb_ip": "拇指IP(被动)",
"index_mcp_roll": "食指MCP侧摆",
"index_mcp_pitch": "食指MCP屈伸",
"index_pip": "食指PIP",
"index_dip": "食指DIP(被动)",
"middle_mcp_roll": "中指MCP侧摆",
"middle_mcp_pitch": "中指MCP屈伸",
"middle_pip": "中指PIP",
"middle_dip": "中指DIP(被动)",
"ring_mcp_roll": "无名指MCP侧摆",
"ring_mcp_pitch": "无名指MCP屈伸",
"ring_pip": "无名指PIP",
"ring_dip": "无名指DIP(被动)",
"pinky_mcp_roll": "小指MCP侧摆",
"pinky_mcp_pitch": "小指MCP屈伸",
"pinky_pip": "小指PIP",
"pinky_dip": "小指DIP(被动)",
"thumb_cmc_yaw": "拇指CMC侧摆",
"index_mcp_roll_side": "食指MCP侧摆(侧面校验)",
"middle_mcp_roll_side": "中指MCP侧摆(侧面校验)",
"ring_mcp_roll_side": "无名指MCP侧摆(侧面校验)",
"pinky_mcp_roll_side": "小指MCP侧摆(侧面校验)",
}
def _format_u8(value: Any) -> str:
if value is None:
return "尚无反馈"
return f"{float(value):.1f}"
def _task_text(active: Mapping[str, Any]) -> str:
if not active:
return "尚无活动任务"
view = VIEW_NAMES_ZH.get(str(active.get("view", "")), str(active.get("view", "")))
if active.get("kind") == "fit_failure":
joints = active.get("joints", [])
joint_text = "/".join(
JOINT_NAMES_ZH.get(str(joint), str(joint)) for joint in joints
)
return (
f"{view}机位,{joint_text}拟合检查失败,"
f"电机{active.get('motor_index')},"
f"第{active.get('attempt', 1)}次尝试"
)
if active.get("kind") == "zero_model_failure":
joints = active.get("joints", [])
joint_text = "/".join(
JOINT_NAMES_ZH.get(str(joint), str(joint)) for joint in joints
)
return (
f"{view}机位,{joint_text}零位/URDF验证失败,"
f"电机{active.get('motor_index')},不会自动重扫"
)
if active.get("kind") == "motion_stall":
return (
f"电机{active.get('motor_index', '?')}运动停滞,目标"
f"{_format_u8(active.get('target_u8'))}、实际"
f"{_format_u8(active.get('actual_u8'))}"
)
if active.get("kind") == "cross_view_roll_diagnostic":
return (
f"{active.get('finger', '?')}侧摆跨机位诊断完成:"
f"正面最大{float(active.get('front_maximum_deg', 0.0)):.2f}°,"
f"侧面最大{float(active.get('side_maximum_deg', 0.0)):.2f}°"
)
if active.get("kind") == "validation":
return (
f"{view}机位,随机复测,电机{active.get('motor_index')},"
f"目标命令{active.get('command_u8')}"
)
joints = active.get("joints", [])
joint_text = "/".join(
JOINT_NAMES_ZH.get(str(joint), str(joint)) for joint in joints
)
start = active.get("start_u8")
target = active.get("target_u8")
cycle = active.get("cycle", "?")
repetitions = active.get("repetitions", "?")
task = (
f"{view}机位,{joint_text},电机{active.get('motor_index')},"
f"第{cycle}/{repetitions}轮,{start}→{target}"
)
fit_attempt = int(active.get("fit_attempt", 1))
if fit_attempt > 1:
retry_cycles = active.get("fit_retry_cycles", [])
if retry_cycles:
task += "(补采异常轮" + "/".join(
str(cycle) for cycle in retry_cycles
) + ")"
else:
task += f"(拟合补采第{fit_attempt}次)"
return task
def three_camera_reason_zh(
state: str,
reason: str,
active: Mapping[str, Any],
) -> tuple[str, str]:
"""Translate a reason code and provide one concrete operator action."""
reason = str(reason)
sample = active.get("sample", {}) if active else {}
missing = [int(value) for value in sample.get("missing_endpoint_u8", [])]
sample_range = (
f"{_format_u8(sample.get('minimum_u8'))}~"
f"{_format_u8(sample.get('maximum_u8'))}"
)
tolerance = sample.get("endpoint_tolerance_u8", "?")
if reason.startswith("motor_state_stalled:"):
fields = reason.split(":")
context = fields[1] if len(fields) > 1 else "unknown"
error_match = re.search(r"error_u8=([0-9.]+)", reason)
error = error_match.group(1) if error_match else "未知"
timeout_match = re.search(r"timeout_seconds=([0-9.]+)", reason)
timeout_value = active.get("timeout_seconds")
if timeout_value is None and timeout_match is not None:
timeout_value = float(timeout_match.group(1))
duration = (
f"连续{float(timeout_value):g}秒"
if timeout_value is not None
else "在规定时间内"
)
motor = active.get("motor_index")
if motor is not None:
return (
f"电机{motor}反馈{duration}没有向目标推进;目标"
f"{_format_u8(active.get('target_u8'))}、实际"
f"{_format_u8(active.get('actual_u8'))}、误差{error} u8,"
f"允许容差±{_format_u8(active.get('tolerance_u8'))} u8"
f"(阶段={context})。程序已保持当前位置。",
"若实际反馈是稳定的固件端点,应只配置该电机该端点的专用容差后"
"重启;若仍在变化或有摩擦,则先排查机械问题,不要反复resume强推。",
)
return (
f"电机反馈{duration}没有向目标推进;停止位置距目标{error}个u8"
f"(阶段={context})。程序已保持当前位置,防止机械碰撞或摩擦加重。",
"检查该电机是否在机械端点稳定饱和或存在碰撞。若实际反馈已是该型号的"
"正常端点,应配置该电机专用端点容差后重启标定;不要反复调用resume强推。",
)
base_reason, separator, reason_detail = reason.partition(":")
if base_reason in {
"sweep_missing_endpoint_bin",
"sweep_bins_too_few",
"sweep_bin_gap_too_large",
"task_precheck_missing_command_127",
"task_precheck_detection_rate_too_low",
"synchronised_tag_state_timeout",
}:
reason = base_reason
detail_label = (
JOINT_NAMES_ZH.get(reason_detail, reason_detail)
if separator and reason_detail
else ""
)
detail_prefix = f"{detail_label}:" if detail_label else ""
if "URDF zero offset reached the configured" in reason:
bound_match = re.search(
r"configured\s+([0-9.]+)\s+degree bound", reason
)
bound = bound_match.group(1) if bound_match else "配置的"
hit_text = ""
if "bound:" in reason:
hit_text = reason.split("bound:", 1)[1].split(
"; all_offsets:", 1
)[0]
for name, label in JOINT_NAMES_ZH.items():
hit_text = hit_text.replace(name, label)
hit_suffix = f";触边关节:{hit_text}" if hit_text else ""
return (
f"联合URDF零位求解触及±{bound}°安全边界{hit_suffix}。这不是可靠的"
"零位结果,而是三机位米制位姿或固定关节轴链无法由纯零位旋转共同解释。",
"不要调用resume,也不要增大零位边界。先确认Tag有效黑框边长、三相机"
"内外参和原始CAD URDF;Tag尺寸修正后必须调用start重新采集,旧尺度"
"产生的轨迹不能直接生成修正URDF。",
)
if reason == "sweep_missing_endpoint_bin":
missing_text = "、".join(str(value) for value in missing) or "0或255"
return (
f"{detail_prefix}本方向已有{active.get('valid_frames', 0)}帧同步有效数据,但缺少"
f"电机端点{missing_text}附近的有效分箱;采样到的实际电机范围为"
f"{sample_range},端点容差为±{tolerance}。这通常表示电机虽然运动到"
"端点,但该时刻没有同时取得有效Tag图像和电机状态。",
"确认当前机位所需Tag在整个行程(尤其缺失端点)均可见,然后调用"
"/g20_calibration/resume;程序会重新扫描当前方向,不要调用start。",
)
if reason == "sweep_bins_too_few":
return (
f"{detail_prefix}有效电机分箱只有{sample.get('bin_count', 0)}个,要求至少"
f"{sample.get('minimum_bin_count', '?')}个;当前采样范围{sample_range}。",
"检查Tag连续识别和电机状态频率,修正后调用resume重新扫描当前方向。",
)
if reason == "sweep_bin_gap_too_large":
gap_start = sample.get("maximum_bin_gap_start_u8")
gap_end = sample.get("maximum_bin_gap_end_u8")
gap_range = (
""
if gap_start is None or gap_end is None
else f"({gap_start}→{gap_end})"
)
return (
f"{detail_prefix}轨迹相邻有效电机分箱的最大空缺为{sample.get('maximum_bin_gap', '?')},"
f"{gap_range}允许值不超过"
f"{sample.get('allowed_maximum_bin_gap', '?')}。",
"检查运动中Tag是否间歇丢失;修正遮挡、反光或对焦后调用resume。",
)
if reason == "synchronised_tag_state_timeout":
group_reasons = active.get("group_pnp_reasons", {})
if isinstance(group_reasons, Mapping) and group_reasons:
tag_rejections = active.get("pnp_rejection_counts", {})
group_rejections = active.get(
"group_pnp_rejection_counts", {}
)
missing_roles = active.get(
"group_missing_candidate_roles", {}
)
candidate_diagnostics = active.get(
"pnp_candidate_diagnostics", {}
)
view_details: list[str] = []
for view, value in group_reasons.items():
view_name = str(view)
parts = [str(value)]
missing = (
missing_roles.get(view_name, ())
if isinstance(missing_roles, Mapping)
else ()
)
if isinstance(missing, Sequence) and not isinstance(
missing, (str, bytes)
) and missing:
parts.append(
"缺候选=" + ",".join(str(role) for role in missing)
)
counts: dict[str, int] = {}
for source in (tag_rejections, group_rejections):
values = (
source.get(view_name)
if isinstance(source, Mapping)
else None
)
if isinstance(values, Mapping):
for name, count in values.items():
counts[str(name)] = counts.get(str(name), 0) + int(
count
)
if counts:
common = sorted(
counts.items(), key=lambda pair: (-pair[1], pair[0])
)[:3]
parts.append(
"累计拒绝="
+ ",".join(
f"{name}×{count}" for name, count in common
)
)
view_candidates = (
candidate_diagnostics.get(view_name, {})
if isinstance(candidate_diagnostics, Mapping)
else {}
)
if isinstance(view_candidates, Mapping) and missing:
summaries: list[str] = []
for role in missing:
diagnostic = view_candidates.get(str(role), {})
if not isinstance(diagnostic, Mapping):
continue
summaries.append(
f"{role}(solve="
f"{int(diagnostic.get('solved_candidate_count', 0))},"
"reproj="
f"{int(diagnostic.get('reprojection_candidate_count', 0))},"
"tilt="
f"{int(diagnostic.get('independent_tilt_candidate_count', 0))})"
)
if summaries:
parts.append("候选统计=" + ",".join(summaries))
view_details.append(
f"{VIEW_NAMES_ZH.get(view_name, view_name)}="
+ ";".join(parts)
)
reason_text = "、".join(view_details)
return (
f"{detail_prefix}已经取得部分有效轨迹,但Tag仍可见且反馈正常时,"
"后续连续图像帧"
"被整组PnP几何检查拒绝"
f"({reason_text}),因此无法与电机状态形成有效轨迹帧。",
"不要调整或反复粘贴Tag;保留当前会话中的"
"group_pnp_candidate_event,"
"按缺失角色的候选统计检查PnP分支逻辑。",
)
return (
f"{detail_prefix}运动过程中连续超过允许时间没有取得“所需Tag全部有效且能与电机状态"
"按时间戳配对”的图像帧。",
"查看下面活动机位的缺失Tag,确认状态话题仍在更新;修正后调用resume,"
"程序会重扫当前方向。",
)
if reason == "task_precheck_missing_command_127":
return (
f"{detail_prefix}低速预检没有取得反馈127附近的同步Tag样本。",
"检查中位姿态的Tag遮挡和反光;程序只会重扫当前物理任务。",
)
if reason == "task_precheck_detection_rate_too_low":
return (
f"{detail_prefix}低速预检的有效Tag识别率低于门限。",
"检查该机位当前任务Tag的遮挡、反光和对焦;程序只会重扫当前物理任务。",
)
if reason == "sweep_start_position_timeout":
return (
f"电机{active.get('motor_index')}未在规定时间到达扫描起点"
f"{active.get('start_u8')},当前实际值{_format_u8(active.get('actual_u8'))}。",
"检查CAN、机械手使能和是否存在机械卡阻,确认安全后调用resume。",
)
if reason == "sweep_start_tag_timeout":
group_reasons = active.get("group_pnp_reasons", {})
progress_by_view = active.get("pnp_initialization_progress", {})
tag_rejections = active.get("pnp_rejection_counts", {})
group_rejections = active.get("group_pnp_rejection_counts", {})
if any(
isinstance(value, Mapping) and bool(value)
for value in (
group_reasons,
progress_by_view,
tag_rejections,
group_rejections,
)
):
details: list[str] = []
views = set()
for value in (
group_reasons,
progress_by_view,
tag_rejections,
group_rejections,
):
if isinstance(value, Mapping):
views.update(str(view) for view in value)
for view in sorted(views):
parts: list[str] = []
progress = (
progress_by_view.get(view)
if isinstance(progress_by_view, Mapping)
else None
)
if isinstance(progress, Mapping):
parts.append(
"初始化"
f"{int(progress.get('accepted', 0))}/"
f"{int(progress.get('required', 0))}"
)
counts: dict[str, int] = {}
for source in (tag_rejections, group_rejections):
values = (
source.get(view)
if isinstance(source, Mapping)
else None
)
if isinstance(values, Mapping):
for name, count in values.items():
counts[str(name)] = (
counts.get(str(name), 0) + int(count)
)
if counts:
common = sorted(
counts.items(), key=lambda pair: (-pair[1], pair[0])
)[:3]
parts.append(
"累计拒绝="
+ ",".join(
f"{name}×{count}" for name, count in common
)
)
latest = (
group_reasons.get(view)
if isinstance(group_reasons, Mapping)
else None
)
if latest and not str(latest).startswith(
"group_initializing:"
):
parts.append(f"最后状态={latest}")
if parts:
details.append(
f"{VIEW_NAMES_ZH.get(view, view)}=" + ";".join(parts)
)
reason_text = "、".join(details) or "未形成完整初始化窗口"
return (
"被测电机已经到达扫描起点,所需Tag也可见,但三维PnP位姿初始化"
f"没有完成({reason_text}),因此没有生成同步端点帧。",
"不要根据可见性重复粘贴Tag;保留累计拒绝原因并检查PnP候选选择。",
)
return (
"被测电机已经到达扫描起点,但当前任务所需的实时运动Tag没有形成足够的"
"同步有效帧。允许遮挡的固定掌部Tag会显示为“锁”,不会触发此错误。",
"只检查标记为✗的实时运动Tag、反光和外部遮挡;不要移动相机或手掌底座。",
)
if reason == "sweep_timeout":
return (
"当前方向在规定时间内未完成端点到达、有效帧数和行程覆盖要求。",
"检查电机实际值、Tag连续识别和标定速度,修正后调用resume。",
)
if reason == "return_baseline_timeout":
return (
"一个或多个标定电机未在规定时间返回基准命令。",
"检查机械手状态、CAN和机械卡阻,确认安全后调用resume。",
)
if reason == "validation_move_timeout":
return (
"随机复测时电机未在规定时间到达目标命令。",
"检查机械手状态和机械卡阻,确认安全后调用resume。",
)
if reason == "validation_capture_timeout":
return (
"随机复测位置没有采集到足够的同步有效Tag帧。",
"检查当前机位Tag可见性后调用resume。",
)
if reason == "palm_orientation_quality_failed":
failures = active.get("failures", [])
detail = (
str(failures[0].get("reason", "方向观测不足"))
if failures
else "方向观测不足"
)
if "thumb_cmc_" in detail:
return (
"拇指CMC yaw无法由顶部Tag 8/9的零位邻近短轨迹稳定确定:"
+ detail,
"保持Tag安装不变;确保顶部Tag 8/9在拇指CMC pitch和roll"
"从零位开始的前1/4行程持续可见后重新标定。",
)
return (
"掌部公共方向无法由至少三根手指的短时正面轨迹稳定确定:"
+ detail,
"保持Tag安装不变;让正面Tag 10–13在对应MCP-pitch起始段"
"至少可见15°行程后重新标定。",
)
if reason in {"joint_fit_check_failed", "joint_fit_systematic_failure"}:
metric_names = {
"plane_rms_mm": "平面拟合RMS",
"radial_rms_mm": "圆半径拟合RMS",
"radius_mm": "拟合半径",
"image_radial_rms_px": "二维圆半径拟合RMS",
"image_radial_p95_px": "二维圆半径误差P95",
"image_radius_px": "二维拟合半径",
"arc_deg": "实测圆弧",
"monotonic_correction_deg": "最大单调修正",
"hysteresis_deg": "最大正反程差",
"baseline_hysteresis_deg": "baseline正反程关节角差",
"baseline_directional_gap_deg": "baseline方向分支间隙",
"baseline_directional_gap_range_deg": "baseline分支间隙跨轮极差",
"cycle_travel_range_deg": "三轮行程差",
"rotation_orthogonal_rms_deg": "三维旋转轴外残差RMS",
"axis_plane_rms_mm": "三维圆轴向RMS",
"axis_radial_rms_mm": "三维圆半径RMS",
"axis_pose_line_rms_mm": "姿态轨迹轴线RMS",
"axis_line_cycle_rms_mm": "四轮轴线位置RMS",
"rotation_circle_axis_difference_deg": "姿态轴与圆轨迹轴夹角",
"axis_cycle_difference_deg": "各轮转轴方向极差",
"cross_view_roll_curve": "正面/侧面关节角曲线差异RMS",
"third_cycle_axis_holdout_deg": "第三轮留出零位可观测轴向误差",
"third_cycle_axis_line_rms_mm": "第三轮留出轴线RMS",
"third_cycle_trajectory_p95_deg": "第三轮留出轨迹误差P95",
"zero_cycle_offset_range_deg": "训练轮零位极差",
"zero_confidence_95_half_width_deg": "零位95%置信半宽",
"state_image_sync_p95_ms": "图像与电机状态同步误差P95",
"tag_valid_rate_percent": "所需Tag同时有效率",
}
metric_units = {
"plane_rms_mm": "mm",
"radial_rms_mm": "mm",
"radius_mm": "mm",
"image_radial_rms_px": "px",
"image_radial_p95_px": "px",
"image_radius_px": "px",
"arc_deg": "°",
"monotonic_correction_deg": "°",
"hysteresis_deg": "°",
"baseline_hysteresis_deg": "°",
"baseline_directional_gap_deg": "°",
"baseline_directional_gap_range_deg": "°",
"cycle_travel_range_deg": "°",
"rotation_orthogonal_rms_deg": "°",
"axis_plane_rms_mm": "mm",
"axis_radial_rms_mm": "mm",
"axis_pose_line_rms_mm": "mm",
"axis_line_cycle_rms_mm": "mm",
"rotation_circle_axis_difference_deg": "°",
"axis_cycle_difference_deg": "°",
"third_cycle_axis_holdout_deg": "°",
"third_cycle_axis_line_rms_mm": "mm",
"third_cycle_trajectory_p95_deg": "°",
"zero_cycle_offset_range_deg": "°",
"zero_confidence_95_half_width_deg": "°",
"state_image_sync_p95_ms": "ms",
"tag_valid_rate_percent": "%",
"cross_view_roll_curve": "°",
}
details: list[str] = []
for failure in active.get("failures", []):
joint = JOINT_NAMES_ZH.get(
str(failure.get("joint")), str(failure.get("joint"))
)
metric = str(failure.get("metric", ""))
if metric in metric_names:
comparison = str(failure.get("comparison", ""))
requirement = "不超过" if comparison == "maximum" else "至少"
unit = metric_units[metric]
detail = (
f"{joint}的{metric_names[metric]}为"
f"{float(failure.get('actual', 0.0)):.2f}{unit},"
f"要求{requirement}{float(failure.get('limit', 0.0)):.2f}{unit}"
)
cycle_travel = failure.get("cycle_travel_deg", [])
if cycle_travel:
detail += "(各轮=" + "/".join(
f"{float(value):.2f}°" for value in cycle_travel
) + ")"
cycle_values = failure.get("cycle_values_deg", [])
if not cycle_values:
cycle_values = failure.get("cycle_offset_deg", [])
if cycle_values:
detail += "(各轮=" + "/".join(
f"{float(value):.2f}°" for value in cycle_values
) + ")"
details.append(detail)
else:
cycle = failure.get("cycle")
cycle_text = "" if cycle is None else f"第{cycle}轮"
details.append(
f"{joint}的{cycle_text}{metric or '轨迹'}拟合失败:"
f"{failure.get('reason', '未知原因')}"
)
detail_text = ";".join(details) or "当前关节的轨迹拟合未通过"
directional_gap_failure = any(
str(failure.get("metric", "")).startswith(
"baseline_directional_gap"
)
for failure in active.get("failures", [])
)
if reason == "joint_fit_systematic_failure":
cross_view_systematic = any(
failure.get("classification")
in {
"stable_cross_view_installation_or_model_bias",
"stable_cross_view_direction_conflict",
}
for failure in active.get("failures", [])
)
suggestion = (
"四轮都出现稳定的正面/侧面差异,属于Tag安装外参或跨视角模型偏差,"
"继续重扫不会改善;检查Tag刚性安装与跨视角安装变换,不要放宽门限。"
if cross_view_systematic
else "各轮重复出现同一模型冲突,继续运动不会改善;程序已禁止自动重扫。"
"请直接复制诊断块给开发者,不要放宽门限。"
)
else:
source_task_names = set(active.get("source_task_names", []))
thumb_yaw_source_retry = source_task_names == {
"thumb_cmc_pitch_front",
"thumb_cmc_roll_front",
}
suggestion = (
"方向分支已由软件保留,不要放宽门限;请检查传动回差或高支架刚度,"
"处理后重新执行一键标定命令,程序会从最近可靠断点继续。"
if directional_gap_failure
else (
"保持顶部Tag 8/9无遮挡;程序只替换决定yaw零位的"
"CMC pitch/roll顶部轴观测并重扫"
f"{active.get('directions_to_rescan', 16)}个方向,"
"不会无效重扫yaw侧摆。"
if thumb_yaw_source_retry
else "修正Tag位置、遮挡或机械行程后重新执行一键标定命令;"
"程序只清除当前失败关节的数据并重扫"
f"{active.get('directions_to_rescan', 6)}个方向,"
"不需要手工调用ROS服务。"
)
)
return (
detail_text + "。程序已在当前关节结束后立即停止后续步骤。",
suggestion,
)
if reason == "zero_model_validation_failed":
reason_names = {
"zero_offset_reached_configured_bound": "零位解触及安全边界",
"zero_offset_exceeds_configured_limit": "零位估计超过安全范围",
"zero_offset_reached_diagnostic_bound": "零位估计仍触及诊断搜索边界",
"zero_offset_cycle_difference_too_large": "三轮零位离散过大",
"zero_offset_not_statistically_significant": "零位偏移未达到统计显著性",
"zero_axis_cone_mismatch_too_large": (
"父子轴夹角与原始URDF不一致,零位旋转无法解释"
),
"zero_phase_axis_line_residual_too_large": (
"整段SE(3)运动无法稳定确定平行轴线相位"
),
"zero_offset_did_not_improve_with_95pct_confidence": (
"第三轮留出验证未以95%置信度改善"
),
}
details: list[str] = []
for failure in active.get("failures", []):
joint = JOINT_NAMES_ZH.get(
str(failure.get("joint")), str(failure.get("joint"))
)
if failure.get("metric") == "zero_guard":
reason_text = reason_names.get(
str(failure.get("reason")), str(failure.get("reason"))
)
if "actual_deg" in failure and "limit_deg" in failure:
reason_text += (
f"(估计{float(failure['actual_deg']):+.2f}°,"
f"允许±{float(failure['limit_deg']):.2f}°)"
)
details.append(f"{joint}:{reason_text}")
return (
"轨迹采集已完成,但零位/URDF几何验证失败"
+ ("(" + ";".join(details) + ")" if details else "")
+ "。程序没有生成正式JSON或修正URDF。",
"该类稳定模型失败不能靠重复运动修复,程序不会自动重扫;"
"请检查Tag固定、相机外参和原始URDF后重新启动新标定。",
)
if reason in {"waiting_for_devices_and_sdk", "device_preflight_lost"}:
return (
"正在等待三台相机数据、内外参身份以及机械手SDK反馈就绪;此阶段不以Tag可见性阻止基准恢复。",
"保持机械手运动范围无障碍;设备就绪后系统会先安全恢复基准形态,再检查掌部Tag。",
)
if reason in {"waiting_for_three_cameras_tags_and_sdk", "preflight_lost"}:
return (
"正在等待三台相机内参、外参身份匹配、帧率、全部必需Tag以及机械手SDK同时就绪。",
"根据下面每个机位的缺失Tag和有效率排查;全部就绪后程序会进入等待开始状态。",
)
if reason == "waiting_for_baseline_tags_after_recovery":
return (
"机械手已经稳定恢复到基准形态,正在用新采集的画面确认三台相机各自的固定掌部Tag。",
"若某个掌部Tag持续缺失,只调整遮挡手指或检查Tag固定情况,不要移动相机和手掌底座。",
)
if reason == "locking_fixed_base_references":
return (
"基准形态Tag预检已通过,正在把三台相机的固定掌部Tag稳健锁定为本会话参考。",
"无需操作;锁定完成后允许任务姿态遮挡固定掌部Tag。",
)
if reason == "fixed_base_reference_moved":
return (
"顶部Tag 8在本会话基准锁定后连续多帧发生角点位移;程序已立即保持机械手当前位置,"
"本会话中已采集数据不再用于发布。",
"Tag 8允许在下一次标定预检前重新摆放,但本次不能继续;固定Tag 8和顶部相机后"
"重新启动新会话。",
)
if reason == "waiting_for_task_tags_at_sweep_start":
return (
"电机已到扫描起点,正在等待当前任务的实时运动Tag;显示为“锁”的固定掌部Tag"
"允许被手指遮挡。",
"只检查标记为✗的实时运动Tag;若均为✓或锁,程序会自动开始运动。",
)
if reason == "call_start_for_baseline_recovery":
return (
"相机数据和机械手反馈已就绪,等待一键程序触发安全基准恢复。",
"保持机械手运动范围无障碍;程序会自动开始,无需手工调用ROS服务。",
)
if reason == "call_start":
return (
"三机位预检已经通过,等待操作员确认开始。",
"清空机械手运动范围后调用/g20_calibration/start。",
)
if reason == "operator_pause":
return "操作员主动暂停了标定。", "确认安全后调用/g20_calibration/resume。"
if reason == "operator_abort":
return "操作员终止了本次标定,程序保持终止时的当前姿态。", "需要重新启动一次新标定。"
if reason == "collecting_timestamp_synchronised_tag_centres":
return "正在按时间戳配对Tag图像和电机状态并采集当前轨迹。", "无需操作,保持相机、标签和底座不动。"
if reason == "collecting_dedicated_baseline_hold":
return (
"正在从当前方向到达关节baseline并静止采集Tag与电机反馈;这批数据单独用于回差验收。",
"无需操作,保持相机、标签和底座不动。",
)
if reason == "steady checkpoint target is missing":
return (
"首轮稳态检查点已经到达最终端点,但采集状态没有及时切换到端点完成阶段。",
"程序已停止发布并保留已采样数据;这是软件状态切换问题,不需要调整相机、Tag或机械手。",
)
if reason == "cross_view_roll_front_failure_deferred":
return (
"正面侧摆回差不合格已保留,诊断模式将继续采集同一手指的侧面数据。",
"无需操作;该诊断会锁定URDF发布。",
)
if reason == "cross_view_roll_diagnostic_complete":
interpretation = str(active.get("interpretation", ""))
explanations = {
"both_views_confirm_direction_dependent_pose": (
"正面和侧面都确认了方向相关姿态,优先判断为roll输出机构或共同下游链的真实回差。"
),
"front_only_difference_check_roll_tag_bracket_or_front_pnp": (
"只有正面差异超限,优先检查roll Tag高支架刚度和正面PnP。"
),
"side_only_difference_check_side_tag_chain_or_side_pnp": (
"只有侧面差异超限,优先检查侧面Tag链和侧面PnP。"
),
"both_views_within_formal_hysteresis_limit": (
"两个机位的静止回差均满足正式门限。"
),
}
return (
explanations.get(interpretation, "四指侧摆跨机位诊断已经完成。")
+ " 本次为诊断会话,不会生成或发布URDF。",
"保存当前状态和raw_samples.jsonl;根据两机位结论处理后重新启动正式标定。",
)
if reason == "capturing_random_validation_pose":
return "正在当前随机命令位置采集复测数据。", "无需操作,保持设备不动。"
if reason in {"calibration_passed", "calibration_complete"}:
return "三维轨迹、关节轴零位和第三轮留出验证已经完成。", "检查JSON、修正URDF路径和quality.passed。"
if reason == "quality_failed":
return "标定流程完成,但拟合或随机复测质量没有达到验收阈值。", "检查最终JSON的quality以及启动终端中的拟合日志。"
if reason.startswith("validated_endpoint_zero_state"):
return (
"轨迹和URDF零位验证已经通过,但发布前检测到端点零位状态缺失或与"
"已验证模型不一致;这是程序内部状态生命周期错误,结果未发布。",
"不要移动相机、Tag或机械手底座;保留当前会话并把原因码交给开发者。",
)
if reason.startswith("PUB-ARTIFACT-601:"):
return (
"标定节点已经生成通过质量门限的JSON和候选URDF,但一键程序在正式发布前"
"发现这对产物的坐标、限位、哈希或资源一致性检查失败;原始URDF未被覆盖。",
"不要重新标定相机或调整Tag;保留本会话产物和启动日志供开发者检查发布契约。",
)
if reason == "combination_pose_prediction_failed":
return (
"单关节、零位和URDF几何验证已通过,但当前多关节组合姿态的Tag实测位姿与模型预测超过门限。",
"程序会在原姿态重新初始化PnP并自动复测;若最终仍失败,请把raw_samples.jsonl中的"
"combination_validation_failure记录交给开发者,不要重新采集16个单关节任务。",
)
if reason.startswith("prepare_") or state == "PREPARE_SWEEP":
return "正在把当前电机移动到本方向的扫描起点并等待稳定。", "无需操作。"
if reason == "holding_same_finger_clearance_before_next_task":
return (
"同一根手指的上一项已经完成;相邻手指继续保持当前避让姿态,只调整"
"被测关节以衔接下一项。",
"无需操作,不要手动展开正在避让的手指。",
)
if state == "RETURN_BASELINE":
return "正在把已使用的标定电机恢复到目标姿态。", "无需操作。"
if state == "FITTING":
return "所有扫描已经完成,正在联合拟合三维机械轴和URDF零位偏移。", "无需操作。"
return f"未分类原因码:{reason}", "保留该原因码和启动终端日志用于进一步定位。"
def render_three_camera_status_text_zh(payload: Mapping[str, Any]) -> str:
"""Render the complete operator status; the JSON topic remains unchanged."""
state = str(payload.get("state", ""))
active = payload.get("active", {})
reason_zh, action_zh = three_camera_reason_zh(
state, str(payload.get("reason", "")), active
)
progress = float(payload.get("progress", 0.0))
completed = payload.get("completed_sweeps", 0)
total = payload.get("total_sweeps", 0)
scan_progress = float(
payload.get(
"scan_progress",
0.0 if not total else float(completed) / float(total),
)
)
lines = [
f"状态:{STATE_NAMES_ZH.get(state, state)}({state})",
f"原因:{reason_zh}",
f"建议:{action_zh}",
f"总体进度:{progress:.1%}(计划扫描{completed}/{total}个方向,"
f"扫描进度{scan_progress:.1%})",
f"当前任务:{_task_text(active)}",
]
if state == "RETURN_BASELINE":
baseline_command = payload.get("baseline_command_u8", [])
return_command = payload.get("return_command_u8", baseline_command)
label = "恢复姿态" if return_command != baseline_command else "基准姿态"
lines.append(f"正在确认{label}:{return_command}")
if active and active.get("kind") not in {
"fit_failure",
"zero_model_failure",
"motion_stall",
}:
retry_count = int(active.get("automatic_retry_count", 0))
if retry_count:
lines.append(
"自动重试:当前方向已自动重扫"
f"{retry_count}/{active.get('automatic_retry_limit', '?')}次,"
f"速度比例{float(active.get('retry_speed_scale', 1.0)):.0%},"
f"端点保持{float(active.get('endpoint_hold_seconds', 0.0)):.2f}s"
)
sample = active.get("sample", {})
motion_progress = active.get("motion_progress")
motion_text = (
"未知" if motion_progress is None else f"{float(motion_progress):.1%}"
)
lines.append(
"运动采样:"
f"目标{active.get('target_u8', active.get('command_u8', '?'))},"
f"实际{_format_u8(active.get('actual_u8'))},"
f"本方向{motion_text},有效帧{active.get('valid_frames', 0)},"
f"实际采样范围{_format_u8(sample.get('minimum_u8'))}~"
f"{_format_u8(sample.get('maximum_u8'))}"
)
detection_frames = int(active.get("detection_frames", 0))
if detection_frames:
lines.append(
"本方向Tag检出:"
f"{float(active.get('detection_rate', 0.0)):.1%}"
f"({active.get('detection_valid_frames', 0)}/"
f"{detection_frames}帧)"
)
auxiliary = active.get("auxiliary_motors", [])
if auxiliary:
lines.append(
"避挡姿态:"
+ ",".join(
f"电机{item.get('motor_index')}目标"
f"{item.get('command_u8')}、实际"
f"{_format_u8(item.get('actual_u8'))}"
for item in auxiliary
)
)
speed = active.get("speed", {})
if speed:
lines.append(
"阶段速度:五指目标"
f"{speed.get('commanded_finger_speed')},SDK报告"
f"{speed.get('reported_finger_speed')}"
)
if active.get("sweep_timeout_seconds") is not None:
lines.append(
"运动保护:扫描超时"
f"{float(active['sweep_timeout_seconds']):.1f}s,"
"连续"
f"{float(active.get('motor_stall_timeout_seconds', 0.0)):.1f}s"
"进展不足"
f"{float(active.get('motor_stall_minimum_progress_u8', 0.0)):.1f}"
"则立即暂停"
)
lines.append("机位:")
for name, view in payload.get("views", {}).items():
missing = view.get("missing_tag_ids", [])
missing_text = "无" if not missing else ",".join(map(str, missing))
lines.append(
f"- {VIEW_NAMES_ZH.get(str(name), str(name))}:"
f"{'就绪' if view.get('ready') else '等待'},"
f"外参{'匹配' if view.get('camera_extrinsics_valid') else '不匹配'},"
f"{float(view.get('detection_hz', 0.0)):.1f}Hz,"
f"全部必需Tag同时有效率{float(view.get('valid_rate', 0.0)):.1%},"
f"当前缺失Tag={missing_text}"
)
extrinsics_error = payload.get("camera_extrinsics_error")
if extrinsics_error:
lines.append(f"外参文件:{extrinsics_error}")
lines.append(f"JSON结果:{payload.get('result_path') or '尚未生成'}")
lines.append(
f"修正URDF:{payload.get('corrected_urdf_path') or '尚未生成'}"
)
return "\n".join(lines)
@@ -0,0 +1,29 @@
"""Independent reviewed profile for the right 19-Tag product layout."""
from linkerhand_calibration.core import ProfileKey
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import G20_RIGHT_19_LAYOUT, get_hand_calibration_profile
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_policy import (
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS,
get_zero_calibration_profile,
)
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import RegisteredProfile
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20._adapter import adapt_profile
from linkerhand_calibration.profiles.loader import load_bundled_hand_profile
KEY = ProfileKey("G20", "right", G20_RIGHT_19_LAYOUT, 1)
def build_profile() -> RegisteredProfile:
hand = get_hand_calibration_profile(KEY.side, KEY.layout)
zero = get_zero_calibration_profile(KEY.side, KEY.layout)
return adapt_profile(
key=KEY,
namespace="/g20_calibration",
hand_profile=hand,
zero_profile=zero,
mechanical_endpoint_joints=RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
post_solve_endpoint_joints=RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS,
typed_profile=load_bundled_hand_profile(KEY.layout),
)
@@ -0,0 +1,10 @@
"""Legacy import path; all online work uses the common product runner."""
from linkerhand_calibration.runtime.runner import main
from linkerhand_calibration.compat.legacy_diagnostic_tools.runner_helpers import (
_automatic_resume_candidate, _calibration_node_exited_before_status,
_launch_command, _resolve_partial_base_session, _status_timeout_seconds,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,133 @@
"""Exact JSON handoff between G20 fitting and the existing URDF writer."""
from __future__ import annotations
import hashlib
import json
import math
from pathlib import Path
from typing import Any, Mapping
SCHEMA_VERSION = 1
ARTIFACT_TYPE = "linkerhand_g20_urdf_correction_input"
def _sha256_file(path: str | Path) -> str:
digest = hashlib.sha256()
with Path(path).expanduser().resolve().open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def build_g20_urdf_input_payload(
*,
side: str,
layout_id: str,
serial_number: str,
source_urdf: str | Path,
offsets_rad: Mapping[str, float],
endpoint_anchored_offsets_rad: Mapping[str, float],
) -> dict[str, Any]:
"""Build the exact correction parameters that will be read from disk."""
payload: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"artifact_type": ARTIFACT_TYPE,
"profile_id": f"G20/{str(side).lower()}/{layout_id}",
"model": "G20",
"side": str(side).lower(),
"layout_id": str(layout_id),
"serial_number": str(serial_number),
"source_urdf_sha256": _sha256_file(source_urdf),
"offsets_rad": {
str(name): float(value) for name, value in sorted(offsets_rad.items())
},
"endpoint_anchored_offsets_rad": {
str(name): float(value)
for name, value in sorted(endpoint_anchored_offsets_rad.items())
},
}
validate_g20_urdf_input_payload(payload)
return payload
def validate_g20_urdf_input_payload(payload: Mapping[str, Any]) -> None:
required = {
"schema_version", "artifact_type", "profile_id", "model", "side",
"layout_id", "serial_number", "source_urdf_sha256", "offsets_rad",
"endpoint_anchored_offsets_rad",
}
if set(payload) != required:
raise ValueError("G20 URDF correction input has unexpected fields")
if (
payload.get("schema_version") != SCHEMA_VERSION
or payload.get("artifact_type") != ARTIFACT_TYPE
or payload.get("model") != "G20"
or payload.get("side") not in {"left", "right"}
):
raise ValueError("G20 URDF correction input identity is invalid")
expected_profile = (
f"G20/{payload['side']}/{payload['layout_id']}"
)
if payload.get("profile_id") != expected_profile:
raise ValueError("G20 URDF correction input profile is invalid")
source_hash = str(payload.get("source_urdf_sha256", ""))
if len(source_hash) != 64 or any(
character not in "0123456789abcdef" for character in source_hash
):
raise ValueError("G20 URDF correction input source hash is invalid")
offsets = payload.get("offsets_rad")
endpoints = payload.get("endpoint_anchored_offsets_rad")
if not isinstance(offsets, Mapping) or not offsets:
raise ValueError("G20 URDF correction input offsets are missing")
if not isinstance(endpoints, Mapping) or not set(endpoints) <= set(offsets):
raise ValueError("G20 URDF correction input endpoint offsets are invalid")
if any(not math.isfinite(float(value)) for value in offsets.values()):
raise ValueError("G20 URDF correction input contains a non-finite offset")
if any(not math.isfinite(float(value)) for value in endpoints.values()):
raise ValueError("G20 URDF correction input has a non-finite endpoint")
def load_g20_urdf_input(
path: str | Path,
*,
source_urdf: str | Path,
side: str,
layout_id: str,
serial_number: str,
) -> tuple[dict[str, float], dict[str, float]]:
"""Read, validate and authenticate correction parameters from JSON."""
source = Path(path).expanduser().resolve()
try:
payload = json.loads(source.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ValueError(f"could not read G20 URDF correction input: {source}") from error
if not isinstance(payload, Mapping):
raise ValueError("G20 URDF correction input must be a JSON object")
validate_g20_urdf_input_payload(payload)
if (
payload["side"] != str(side).lower()
or payload["layout_id"] != str(layout_id)
or payload["serial_number"] != str(serial_number)
):
raise ValueError("G20 URDF correction input does not match the session")
if payload["source_urdf_sha256"] != _sha256_file(source_urdf):
raise ValueError("G20 source URDF changed after calibration JSON was written")
return (
{
str(name): float(value)
for name, value in payload["offsets_rad"].items()
},
{
str(name): float(value)
for name, value in payload["endpoint_anchored_offsets_rad"].items()
},
)
__all__ = [
"build_g20_urdf_input_payload",
"load_g20_urdf_input",
"validate_g20_urdf_input_payload",
]
@@ -0,0 +1,17 @@
"""Reviewed static-zero, endpoint, and mimic topology exports."""
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import MIMIC_DERIVED_FINGER_DIPS
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_solver import (
RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS,
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS,
get_zero_calibration_profile,
)
__all__ = [
"MIMIC_DERIVED_FINGER_DIPS",
"RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS",
"RIGHT_19_MECHANICAL_ENDPOINT_JOINTS",
"RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS",
"get_zero_calibration_profile",
]
@@ -0,0 +1,931 @@
"""Three-dimensional joint-axis fitting and URDF zero correction."""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime
import math
from pathlib import Path
import re
from typing import Any, Mapping, Sequence
import xml.etree.ElementTree as ET
import numpy as np
from scipy.spatial.transform import Rotation
from linkerhand_calibration.core import fit_rotation_axis, robust_rotation_summary
from linkerhand_calibration.core.urdf import (
UrdfCorrectionPlan,
UrdfJointPatch,
UrdfPatchSet,
materialize_relative_mesh_assets as _materialize_relative_mesh_assets,
write_urdf_patches,
)
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import (
G20_RIGHT_19_LAYOUT,
IMAGE_TRAJECTORY_JOINTS,
LEFT_HAND_PROFILE,
RIGHT_19_END_ON_IMAGE_CURVE_JOINTS,
RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS,
HandCalibrationProfile,
JointCurveFit,
get_hand_calibration_profile,
)
from linkerhand_calibration.sample_schema import explicit_domain_value
from linkerhand_calibration.trajectory import (
_fit_circle_with_axis,
_fit_joint_curve,
_fit_plane_axis,
_plane_basis,
)
from linkerhand_calibration.core.fitting import spatial as _spatial
from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel, _parse_triplet, _axis_rotation
from linkerhand_calibration.core.fitting.spatial import (
ZeroCalibrationProfile,
circle_direction_is_constrained,
select_cross_view_roll_direction_source,
_zero_sensitive_axis_error_rad,
_axis_cone_mismatch_rad,
_vector,
_pose_matrix,
_relative_rotation,
_reference_group_key,
_canonical_reference_records,
_interpolate_reference_rotation,
_near_zero_records,
_baseline_reference,
baseline_hysteresis_by_cycle_rad,
fit_rotation_joint_curve,
measure_rotation_joint_observation,
measure_joint_curve_observation,
rotation_curve_holdout_errors,
joint_curve_holdout_errors,
JointAxisMeasurement,
PalmOrientationMeasurement,
_incremental_common_rotation_axis,
fit_partial_palm_orientation_measurement,
fit_partial_palm_orientation_measurements,
with_depth_free_axis_projection,
cross_view_side_line_source,
axis_line_uses_depth_free_interpretation_plane,
refit_axis_line_group_with_shared_radius,
maximum_axis_line_cycle_spread_m,
axis_line_cycle_rms_m,
_fit_axis_point_from_pose_trajectory,
ZeroSolveResult,
_angles_from_state
)
def solve_urdf_zero_offsets(**kwargs) -> ZeroSolveResult:
"""Compatibility input binding; all numerical work lives in core."""
options = dict(kwargs)
profile = options.get("zero_profile")
if profile is None:
profile = get_zero_calibration_profile(options.get("hand_type", "left"), options.get("tag_layout", "legacy_11"))
pattern = profile.hand.layout_id == G20_RIGHT_19_LAYOUT
profile = replace(profile,
parallel_root_pattern=pattern,
common_mode_zero_joints=tuple(name for name in profile.direct_zero_joints if name.endswith("_mcp_roll") and not name.startswith("thumb_")) if pattern else (),
small_offset_joints=frozenset(name for name in profile.direct_zero_joints if name.startswith(("index_", "middle_", "ring_", "pinky_"))),
orientation_anchor_joint=profile.orientation_anchor_joint or ("thumb_cmc_pitch" if profile.base_pose_strategy == "thumb_serial" else f"{profile.reference_finger}_mcp_pitch"),
base_pose_strategy="serial_chain" if profile.base_pose_strategy == "thumb_serial" else profile.base_pose_strategy,
)
options["zero_profile"] = profile
return _spatial.solve_urdf_zero_offsets(**options)
def fit_joint_axis_measurement(*args, **kwargs) -> JointAxisMeasurement:
kwargs.setdefault("constrained_circle_joints", CONSTRAINED_CIRCLE_JOINTS)
return _spatial.fit_joint_axis_measurement(*args, **kwargs)
def _build_zero_profile(hand: HandCalibrationProfile) -> ZeroCalibrationProfile:
if hand.layout_id == G20_RIGHT_19_LAYOUT:
return _build_right_19_zero_profile(hand)
reference = hand.reference_finger
reference_roll = f"{reference}_mcp_roll"
reference_pitch = f"{reference}_mcp_pitch"
reference_pip = f"{reference}_pip"
reference_dip = f"{reference}_dip"
direct = (
"thumb_cmc_roll",
"thumb_cmc_yaw",
"thumb_cmc_pitch",
"thumb_mcp",
reference_roll,
reference_pitch,
reference_pip,
)
inherited = {
f"{finger}_{suffix}": f"{reference}_{suffix}"
for finger in ("index", "middle", "ring", "pinky")
if finger != reference
for suffix in ("mcp_roll", "mcp_pitch", "pip")
}
axis_parent = {
"thumb_cmc_yaw": "thumb_cmc_roll",
"thumb_cmc_pitch": "thumb_cmc_yaw",
reference_pitch: reference_roll,
}
phase_parent = {
"thumb_mcp": "thumb_cmc_pitch",
"thumb_ip": "thumb_mcp",
reference_pip: reference_pitch,
reference_dip: reference_pip,
}
observer = {
"thumb_cmc_roll": "thumb_cmc_yaw",
"thumb_cmc_yaw": "thumb_cmc_pitch",
"thumb_cmc_pitch": "thumb_mcp",
"thumb_mcp": "thumb_ip",
reference_roll: reference_pitch,
reference_pitch: reference_pip,
reference_pip: reference_dip,
}
fixed_direct_zero_offsets = {
"thumb_mcp": 0.0,
reference_roll: 0.0,
reference_pitch: 0.0,
reference_pip: 0.0,
}
return ZeroCalibrationProfile(
hand=hand,
direct_zero_joints=direct,
axis_joints=(
"thumb_cmc_roll",
"thumb_cmc_yaw",
"thumb_cmc_pitch",
"thumb_mcp",
"thumb_ip",
reference_roll,
reference_pitch,
reference_pip,
reference_dip,
),
inherited_zero_joints=inherited,
inherited_static_zero_joints={},
constrained_circle_joints=(
hand.image_trajectory_joints | {reference_dip, "thumb_cmc_yaw"}
),
root_anchor_joints=frozenset({"thumb_cmc_roll", reference_roll}),
axis_parent_joint=axis_parent,
phase_parent_joint=phase_parent,
offset_observer_joint=observer,
same_view_axis_pair_by_offset={},
fixed_direct_zero_offsets_rad=fixed_direct_zero_offsets,
static_output_zero_offsets_rad={},
)
def _build_right_19_zero_profile(
hand: HandCalibrationProfile,
) -> ZeroCalibrationProfile:
"""Return the 16-active-zero graph for the 19-Tag layout.
Distal Tags recover all four PIP/DIP dynamic curves and axis quality, but
repeated motion with one unchanged camera/Tag installation cannot by
itself distinguish a fixed parallel-axis phase bias from an encoder zero.
The three coupled thumb-CMC offsets must therefore remain a visual
multi-axis solve; their electrical endpoints are not assumed to coincide
with source-CAD limits. Independently repeatable contact endpoints still
anchor thumb MCP, finger MCP-pitch and PIP from each session. DIP stays
passive and has no independently identifiable static zero.
"""
fingers = ("index", "middle", "ring", "pinky")
direct = (
"thumb_cmc_roll",
"thumb_cmc_yaw",
"thumb_cmc_pitch",
"thumb_mcp",
*(
f"{finger}_{suffix}"
for finger in fingers
for suffix in ("mcp_roll", "mcp_pitch", "pip")
),
)
axis_joints = (
"thumb_cmc_roll",
"thumb_cmc_yaw",
"thumb_cmc_pitch",
"thumb_mcp",
"thumb_ip",
*(
f"{finger}_{suffix}"
for finger in fingers
for suffix in ("mcp_roll", "mcp_pitch", "pip", "dip")
),
)
axis_parent = {
"thumb_cmc_yaw": "thumb_cmc_roll",
"thumb_cmc_pitch": "thumb_cmc_yaw",
**{
f"{finger}_mcp_pitch": f"{finger}_mcp_roll"
for finger in fingers
},
}
phase_parent = {
"thumb_mcp": "thumb_cmc_pitch",
"thumb_ip": "thumb_mcp",
**{
f"{finger}_{child}": f"{finger}_{parent}"
for finger in fingers
for child, parent in (
("pip", "mcp_pitch"),
("dip", "pip"),
)
},
}
observer = {
"thumb_cmc_roll": "thumb_cmc_yaw",
"thumb_cmc_yaw": "thumb_cmc_pitch",
"thumb_cmc_pitch": "thumb_mcp",
"thumb_mcp": "thumb_ip",
**{
f"{finger}_{target}": f"{finger}_{observed}"
for finger in fingers
for target, observed in (
("mcp_roll", "mcp_pitch"),
("mcp_pitch", "pip"),
("pip", "dip"),
)
},
}
root_anchors = frozenset(
{"thumb_cmc_roll", *(f"{finger}_mcp_roll" for finger in fingers)}
)
constrained = frozenset(
set(hand.image_trajectory_joints)
| {"thumb_cmc_yaw"}
# The side camera observes all four flexion axes nearly end-on. A
# 16 mm monocular planar Tag gives a very repeatable orientation axis,
# while its command-correlated depth bias can tilt the otherwise clean
# centre-trajectory plane by several degrees. Use the orientation
# axis to constrain that circle; PIP retains its independently fitted
# axis line and all final SE(3)/holdout quality gates.
| set(RIGHT_19_END_ON_IMAGE_CURVE_JOINTS)
# _fit_axis_measurement_raw constrains every passive DIP direction to
# its source-URDF-parallel upstream PIP. Declare the same fact here
# so validation does not compare that trusted direction with the
# depth-biased free plane of a distal Tag trajectory.
| set(RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS)
)
return ZeroCalibrationProfile(
hand=hand,
direct_zero_joints=tuple(direct),
axis_joints=tuple(axis_joints),
inherited_zero_joints={},
inherited_static_zero_joints={},
constrained_circle_joints=constrained,
root_anchor_joints=root_anchors,
axis_parent_joint=axis_parent,
phase_parent_joint=phase_parent,
offset_observer_joint=observer,
same_view_axis_pair_by_offset={
"thumb_cmc_yaw": ("thumb_cmc_roll", "thumb_cmc_pitch")
},
# Endpoint-observable offsets are supplied by the caller. CMC remains
# visually solved and no serial-specific zero is hidden in the shared
# profile.
fixed_direct_zero_offsets_rad={},
static_output_zero_offsets_rad={},
)
def get_right_19_thumb_zero_profile() -> ZeroCalibrationProfile:
"""Return the independent four-zero thumb observer graph.
Roll and MCP are supplied as mechanical endpoint datums by the caller;
yaw is observed by the same-view roll/pitch axis pair and pitch by the
pitch/MCP serial phase. No finger axis is part of this profile.
"""
full = get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT)
thumb_direct = tuple(
name for name in full.direct_zero_joints if name.startswith("thumb_")
)
thumb_axes = tuple(
name for name in full.axis_joints if name.startswith("thumb_")
)
return replace(
full,
direct_zero_joints=thumb_direct,
axis_joints=thumb_axes,
constrained_circle_joints=frozenset(
name
for name in full.constrained_circle_joints
if name.startswith("thumb_")
),
root_anchor_joints=frozenset({"thumb_cmc_roll"}),
axis_parent_joint={
child: parent
for child, parent in full.axis_parent_joint.items()
if child.startswith("thumb_") and parent.startswith("thumb_")
},
phase_parent_joint={
child: parent
for child, parent in full.phase_parent_joint.items()
if child.startswith("thumb_") and parent.startswith("thumb_")
},
offset_observer_joint={
target: observer
for target, observer in full.offset_observer_joint.items()
if target.startswith("thumb_") and observer.startswith("thumb_")
},
same_view_axis_pair_by_offset={
target: pair
for target, pair in full.same_view_axis_pair_by_offset.items()
if target.startswith("thumb_")
},
fixed_direct_zero_offsets_rad={},
static_output_zero_offsets_rad={},
base_pose_strategy="thumb_serial",
)
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS = frozenset(
{
"thumb_mcp",
*(
f"{finger}_{suffix}"
for finger in ("index", "middle", "ring", "pinky")
for suffix in ("mcp_pitch", "pip")
),
}
)
# Thumb CMC roll has a directly observed actuator-to-actuator travel, but its
# electrical stop is not used as a runtime URDF limit. Keep it out of
# RIGHT_19_MECHANICAL_ENDPOINT_JOINTS (whose offsets also move upper limits),
# and use its endpoint estimate only as the final static roll origin. This
# makes roll independent of the fitted palm-frame phase and of all yaw logic.
RIGHT_19_ROLL_ENDPOINT_ZERO_JOINTS = frozenset({"thumb_cmc_roll"})
RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS = (
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
| RIGHT_19_ROLL_ENDPOINT_ZERO_JOINTS
)
RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS = RIGHT_19_ROLL_ENDPOINT_ZERO_JOINTS
def anchor_right_19_mechanical_endpoint_curves(
curves: Mapping[str, JointCurveFit],
records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
*,
maximum_direction_difference_rad: float = math.radians(1.0),
maximum_curve_correction_rad: float = math.radians(3.0),
feedback_endpoint_joints: frozenset[str] = frozenset(),
endpoint_joints: frozenset[str] | None = None,
) -> dict[str, JointCurveFit]:
"""Set mechanical-endpoint curve scale from direct SO(3) travel.
A fitted-axis projection is useful for a dense, signed command curve, but
a small view-dependent orthogonal PnP component can change that
projection's full-scale value. The rotation magnitude between the two
settled endpoint poses is the revolute-joint travel itself: conjugating
both poses by a rigid hand/camera transform or either fixed Tag mounting
rotation cannot change it.
Use every supplied cycle and both sweep directions as independent endpoint
measurements, reject disagreement, and apply their robust median as one
scale correction to the already validated curve. Dense CMC-roll records
are selected in feedback coordinates; settled mechanical-contact records
remain selected in requested-command coordinates.
"""
if (
not math.isfinite(maximum_direction_difference_rad)
or maximum_direction_difference_rad <= 0.0
):
raise ValueError("endpoint direction difference limit must be positive")
if (
not math.isfinite(maximum_curve_correction_rad)
or maximum_curve_correction_rad <= 0.0
):
raise ValueError("endpoint curve correction limit must be positive")
result = dict(curves)
selected_endpoint_joints = (
RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS
if endpoint_joints is None
else frozenset(str(name) for name in endpoint_joints)
)
unknown_endpoint_joints = sorted(
set(selected_endpoint_joints) - set(RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS)
)
if unknown_endpoint_joints:
raise ValueError(
"endpoint joints are not endpoint measurements: "
+ ",".join(unknown_endpoint_joints)
)
unknown_feedback_joints = sorted(
set(feedback_endpoint_joints) - set(RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS)
)
if unknown_feedback_joints:
raise ValueError(
"feedback endpoint joints are not endpoint measurements: "
+ ",".join(unknown_feedback_joints)
)
for name in sorted(selected_endpoint_joints):
fit = curves.get(name)
records = list(records_by_joint.get(name, ()))
if fit is None:
raise ValueError(f"missing measured endpoint curve for {name}")
direction_travel: dict[str, float] = {}
all_travel: list[float] = []
for direction in ("decreasing", "increasing"):
cycles = sorted(
{
int(record.get("cycle", 0))
for record in records
if str(record.get("direction")) == direction
}
)
branch_travel: list[float] = []
for cycle in cycles:
endpoint_rotations: dict[int, Rotation] = {}
for command in (0, 255):
quaternions = []
for record in records:
if (
str(record.get("direction")) != direction
or int(record.get("cycle", 0)) != cycle
):
continue
endpoint_value = (
explicit_domain_value(record, "feedback")
if name in feedback_endpoint_joints
else record.get(
"requested_command_u8",
record.get("command_u8", -1),
)
)
if endpoint_value is None:
continue
if int(round(float(endpoint_value))) == command:
quaternions.append(_relative_rotation(record))
if not quaternions:
raise ValueError(
f"{name} {direction} cycle {cycle} is missing "
f"settled endpoint {command}"
)
endpoint_rotations[command] = Rotation.from_quat(
robust_rotation_summary(quaternions)[0]
)
branch_travel.append(
float(
(
endpoint_rotations[255].inv()
* endpoint_rotations[0]
).magnitude()
)
)
if not branch_travel:
raise ValueError(f"{name} is missing {direction} endpoint travel")
direction_travel[direction] = float(np.median(branch_travel))
all_travel.extend(branch_travel)
travel_values = np.asarray(list(direction_travel.values()), dtype=float)
direction_difference = float(np.ptp(travel_values))
if direction_difference > maximum_direction_difference_rad:
raise ValueError(
f"{name} settled endpoint directions disagree: "
f"{math.degrees(direction_difference):.3f}deg"
)
cycle_range = float(np.ptp(np.asarray(all_travel, dtype=float)))
if cycle_range > maximum_direction_difference_rad:
raise ValueError(
f"{name} settled endpoint cycles disagree: "
f"{math.degrees(cycle_range):.3f}deg"
)
direct_travel = float(np.median(np.asarray(all_travel, dtype=float)))
curve = np.asarray(fit.angle_rad, dtype=float)
if curve.shape != (256,) or not np.all(np.isfinite(curve)):
raise ValueError(f"{name} endpoint curve must contain 256 finite bins")
projected_travel = float(curve[0] - curve[255])
if direct_travel <= 0.0 or projected_travel <= 0.0:
raise ValueError(f"{name} endpoint travel must be positive")
correction = abs(projected_travel - direct_travel)
if correction > maximum_curve_correction_rad:
raise ValueError(
f"{name} fitted/direct endpoint travel differs by "
f"{math.degrees(correction):.3f}deg"
)
scale = direct_travel / projected_travel
def scaled(values: Sequence[float]) -> tuple[float, ...]:
return tuple(float(value) * scale for value in values)
circle = dict(fit.circle)
circle.update(
{
"mechanical_endpoint_direct_travel_rad": direct_travel,
"mechanical_endpoint_direction_difference_rad": (
direction_difference
),
"mechanical_endpoint_cycle_range_rad": cycle_range,
"mechanical_endpoint_sample_count": len(all_travel),
"mechanical_endpoint_raw_curve_travel_rad": projected_travel,
"mechanical_endpoint_curve_scale": scale,
}
)
quality = dict(fit.quality)
if "arc_rad" in quality:
quality["arc_rad"] = float(quality["arc_rad"]) * scale
result[name] = replace(
fit,
angle_rad=scaled(fit.angle_rad),
decreasing_rad=scaled(fit.decreasing_rad),
increasing_rad=scaled(fit.increasing_rad),
circle=circle,
maximum_monotonic_correction_rad=(
float(fit.maximum_monotonic_correction_rad) * scale
),
maximum_hysteresis_rad=float(fit.maximum_hysteresis_rad) * scale,
quality=quality,
zero_offset_rad=float(fit.zero_offset_rad) * scale,
)
return result
def derive_right_19_mechanical_endpoint_offsets(
source_urdf: str | Path,
curves: Mapping[str, JointCurveFit],
*,
maximum_offset_rad: float = math.radians(5.0),
endpoint_joints: frozenset[str] | None = None,
) -> dict[str, float]:
"""Estimate encoder origins from repeatable measured mechanical endpoints.
For the four fingers feedback 0 is the independently verified palm-contact
endpoint; thumb MCP uses its independently verified actuator endpoint.
The source URDF upper limit describes those same physical endpoints.
Therefore ``origin_offset + measured_travel == CAD_upper``.
Thumb CMC pitch/yaw remain excluded because their endpoint-to-CAD contract
is not independently established. CMC roll is included as a post-solve
origin only: its measured travel determines roll without allowing the
palm common-direction fit to write that joint.
The returned corrections are measured, generally non-zero encoder zeros;
they do not retain the CAD origin. Using full relative rotation travel
makes them invariant to rigid hand movement and arbitrary fixed Tag
mounting rotation, unlike a cross-camera absolute planar-PnP phase.
"""
if not math.isfinite(maximum_offset_rad) or maximum_offset_rad <= 0.0:
raise ValueError("mechanical endpoint maximum offset must be positive")
root = ET.parse(Path(source_urdf).expanduser().resolve()).getroot()
joints = {str(node.get("name")): node for node in root.findall("joint")}
result: dict[str, float] = {}
selected_endpoint_joints = (
RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS
if endpoint_joints is None
else frozenset(str(name) for name in endpoint_joints)
)
unknown_endpoint_joints = sorted(
set(selected_endpoint_joints) - set(RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS)
)
if unknown_endpoint_joints:
raise ValueError(
"endpoint joints are not endpoint measurements: "
+ ",".join(unknown_endpoint_joints)
)
for name in sorted(selected_endpoint_joints):
fit = curves.get(name)
joint = joints.get(name)
limit = None if joint is None else joint.find("limit")
if fit is None:
raise ValueError(f"missing measured endpoint curve for {name}")
if limit is None or limit.get("upper") is None:
raise ValueError(f"source URDF joint {name} has no upper limit")
upper = float(limit.get("upper"))
curve = np.asarray(fit.angle_rad, dtype=float)
if curve.shape != (256,) or not np.all(np.isfinite(curve)):
raise ValueError(f"{name} endpoint curve must contain 256 finite bins")
measured_endpoint = float(curve[0])
if measured_endpoint <= 0.0 or abs(float(curve[255])) > math.radians(0.05):
raise ValueError(
f"{name} endpoint curve does not use command 255 as its zero reference"
)
if abs(measured_endpoint - float(np.max(curve))) > math.radians(0.05):
raise ValueError(
f"{name} command 0 is not the measured upper mechanical endpoint"
)
offset = upper - measured_endpoint
if not math.isfinite(offset) or abs(offset) > maximum_offset_rad:
raise ValueError(
f"{name} endpoint-derived zero offset is outside the safe range: "
f"{math.degrees(offset):.3f}deg"
)
result[name] = offset
return result
def get_zero_calibration_profile(
side: str, layout_id: str = "legacy_11"
) -> ZeroCalibrationProfile:
return _build_zero_profile(get_hand_calibration_profile(side, layout_id))
LEFT_ZERO_PROFILE = _build_zero_profile(LEFT_HAND_PROFILE)
# Backwards-compatible left-hand aliases.
CONSTRAINED_CIRCLE_JOINTS = LEFT_ZERO_PROFILE.constrained_circle_joints
DIRECT_ZERO_JOINTS = LEFT_ZERO_PROFILE.direct_zero_joints
FIXED_DIRECT_ZERO_OFFSETS_RAD = dict(
LEFT_ZERO_PROFILE.fixed_direct_zero_offsets_rad
)
OPTIMIZED_ZERO_JOINTS = tuple(
name for name in DIRECT_ZERO_JOINTS
if name not in FIXED_DIRECT_ZERO_OFFSETS_RAD
)
AXIS_JOINTS = LEFT_ZERO_PROFILE.axis_joints
INHERITED_ZERO_JOINTS = dict(LEFT_ZERO_PROFILE.inherited_zero_joints)
def merge_right_19_thumb_zero_result(
*,
thumb_result: ZeroSolveResult,
preserved_offsets_rad: Mapping[str, float] | None = None,
companion_result: ZeroSolveResult | None = None,
) -> ZeroSolveResult:
"""Merge an independent thumb solve without allowing reverse coupling.
``companion_result`` is the finger/full solver result whose thumb values
were fixed to ``thumb_result``. For a thumb-only run, the 12 certified
finger values are supplied through ``preserved_offsets_rad`` instead.
"""
thumb_names = {
"thumb_cmc_roll",
"thumb_cmc_yaw",
"thumb_cmc_pitch",
"thumb_mcp",
}
if set(thumb_result.direct_offsets_rad) != thumb_names:
raise ValueError("independent thumb result must contain exactly four zeros")
full_profile = get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT)
direct_names = set(full_profile.direct_zero_joints)
preserved = {
str(name): float(value)
for name, value in (preserved_offsets_rad or {}).items()
}
if set(preserved) - (direct_names - thumb_names):
raise ValueError("preserved offsets contain a thumb or unknown joint")
if any(not math.isfinite(value) for value in preserved.values()):
raise ValueError("preserved offsets must be finite")
if companion_result is None:
if set(preserved) != direct_names - thumb_names:
raise ValueError("thumb-only merge requires all 12 certified finger zeros")
direct = dict(preserved)
all_active = {name: 0.0 for name in full_profile.hand.active_joints}
all_active.update(preserved)
base_result = thumb_result
else:
if set(companion_result.direct_offsets_rad) != direct_names:
raise ValueError("full companion result must contain all 16 zeros")
direct = dict(companion_result.direct_offsets_rad)
all_active = dict(companion_result.all_active_offsets_rad)
base_result = companion_result
direct.update(thumb_result.direct_offsets_rad)
all_active.update(thumb_result.direct_offsets_rad)
def merged_map(name: str) -> dict[str, Any]:
values = (
{}
if companion_result is None
else dict(getattr(companion_result, name))
)
values.update(dict(getattr(thumb_result, name)))
return values
failure_reasons = merged_map("failure_reasons")
passed = bool(
thumb_result.passed
and (companion_result is None or companion_result.passed)
)
return replace(
base_result,
direct_offsets_rad=direct,
all_active_offsets_rad=all_active,
validation_errors_rad=(
tuple(thumb_result.validation_errors_rad)
if companion_result is None
else (
*companion_result.validation_errors_rad,
*thumb_result.validation_errors_rad,
)
),
validation_error_by_joint_rad=merged_map(
"validation_error_by_joint_rad"
),
validation_line_error_by_joint_m=merged_map(
"validation_line_error_by_joint_m"
),
axis_line_rms_m=(
thumb_result.axis_line_rms_m
if companion_result is None
else max(
companion_result.axis_line_rms_m,
thumb_result.axis_line_rms_m,
)
),
passed=passed,
cycle_offsets_rad=merged_map("cycle_offsets_rad"),
offset_uncertainty_rad=merged_map("offset_uncertainty_rad"),
offset_confidence_half_width_rad=merged_map(
"offset_confidence_half_width_rad"
),
validation_original_error_by_joint_rad=merged_map(
"validation_original_error_by_joint_rad"
),
validation_improvement_by_joint_rad=merged_map(
"validation_improvement_by_joint_rad"
),
validation_improvement_confidence_lower_rad=merged_map(
"validation_improvement_confidence_lower_rad"
),
offset_covariance_rad2=merged_map("offset_covariance_rad2"),
axis_cone_mismatch_by_joint_rad=merged_map(
"axis_cone_mismatch_by_joint_rad"
),
axis_cone_bias_classification_by_joint=merged_map(
"axis_cone_bias_classification_by_joint"
),
failure_reasons=failure_reasons,
)
def expand_right_19_thumb_zero_result_with_cad_fingers(
thumb_result: ZeroSolveResult,
) -> ZeroSolveResult:
"""Build a standalone thumb result with every finger left at source CAD.
This is intentionally different from inheriting a prior finger
calibration: the twelve non-thumb values are explicit zeros and no finger
observation or artifact is required.
"""
full_profile = get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT)
thumb_names = {
"thumb_cmc_roll",
"thumb_cmc_yaw",
"thumb_cmc_pitch",
"thumb_mcp",
}
if set(thumb_result.direct_offsets_rad) != thumb_names:
raise ValueError("independent thumb result must contain exactly four zeros")
direct = {name: 0.0 for name in full_profile.direct_zero_joints}
direct.update(thumb_result.direct_offsets_rad)
all_active = {name: 0.0 for name in full_profile.hand.active_joints}
all_active.update(thumb_result.direct_offsets_rad)
return replace(
thumb_result,
direct_offsets_rad=direct,
all_active_offsets_rad=all_active,
)
def write_zero_corrected_urdf(
*,
source_urdf: str | Path,
output_directory: str | Path,
serial_number: str,
offsets_rad: Mapping[str, float],
endpoint_anchored_offsets_rad: Mapping[str, float] | None = None,
timestamp: str | None = None,
correction_plan: UrdfCorrectionPlan | None = None,
) -> Path:
source = Path(source_urdf).expanduser().resolve()
output = Path(output_directory).expanduser().resolve()
if not source.is_file():
raise ValueError(f"source URDF does not exist: {source}")
if not offsets_rad:
raise ValueError("offsets_rad must contain at least one joint")
offsets = {str(name): float(value) for name, value in offsets_rad.items()}
endpoint_offsets = {
str(name): float(value)
for name, value in dict(endpoint_anchored_offsets_rad or {}).items()
}
if not set(endpoint_offsets) <= set(offsets):
raise ValueError("endpoint-anchored offsets must be URDF zero targets")
if correction_plan is not None:
correction_plan.verify_source(source)
correction_plan.authorize_offsets(offsets)
if not set(endpoint_offsets).issubset(
correction_plan.endpoint_limit_joints
):
raise ValueError(
"endpoint offsets are not authorized by the correction plan"
)
if any(
not math.isfinite(value) or abs(value) > math.radians(90.0)
for value in offsets.values()
):
raise ValueError("URDF zero offsets must be finite and within +/-90deg")
stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S")
if re.fullmatch(r"\d{8}_\d{6}", stamp) is None:
raise ValueError("URDF zero timestamp must use YYYYMMDD_HHMMSS")
safe_serial = "".join(
character if character.isalnum() or character in "_.-" else "_"
for character in str(serial_number)
)
if not safe_serial:
raise ValueError("serial_number must not be empty")
from linkerhand_calibration.core.urdf.plan import build_standard_correction_plan
import hashlib
if "calibrated" in source.stem.lower():
raise ValueError("source must be the original CAD URDF")
# The deployed JSON keeps the compatibility shape; zero coordinates must
# nevertheless transform both limits and every dependent mimic together.
# Endpoint metadata cannot select a different coordinate rule.
if any(not math.isclose(value, offsets[name], rel_tol=0, abs_tol=1e-12)
for name, value in endpoint_offsets.items()):
raise ValueError("endpoint metadata disagrees with the actual zero correction")
if correction_plan is not None:
rights = correction_plan.authorized_fields
if not rights:
raise ValueError("production correction plan lacks independent Profile field authorization")
source_hash = correction_plan.source_sha256
else:
# Compatibility for standalone offline correction tools. The online
# product path always supplies its protected, scope-authorized plan.
model = UrdfKinematicModel(source)
rights = {name: ("origin.rpy", "limit.lower", "limit.upper") for name in offsets}
for name, joint in model.joints.items():
if joint.mimic_joint is not None:
rights[name] = (*rights.get(name, ()), "mimic.offset")
source_hash = hashlib.sha256(source.read_bytes()).hexdigest()
plan = build_standard_correction_plan(source_urdf=source,
source_sha256=source_hash, zero_offsets_rad=offsets, authorized_fields=rights)
destination = output / f"{source.stem}_zero_calibrated_{safe_serial}_{stamp}.urdf"
return plan.write(source, destination)
@@ -0,0 +1,14 @@
"""Registered L6 calibration profiles."""
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import ProfileRegistry
def register_profiles(registry: ProfileRegistry) -> None:
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.left_transfer import build_profile as build_left_transfer_profile
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.profile import build_profile
registry.register(build_profile())
registry.register(build_left_transfer_profile())
__all__ = ["register_profiles"]
@@ -0,0 +1,876 @@
"""Schema-v6 runtime artifact and atomic partial-result publication for L6."""
from __future__ import annotations
from copy import deepcopy
import json
import hashlib
import math
import os
from pathlib import Path
from typing import Any, Mapping, Sequence
import xml.etree.ElementTree as ET
import numpy as np
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import JointCurveFit
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.fitting import L6FitResult, MimicFit
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.profile import (
ACTIVE_JOINTS,
CALIBRATED_ACTIVE_JOINTS,
COMMAND_INDEX_BY_JOINT,
COMMAND_NAMES,
COUPLING_MODEL_BY_JOINT,
ENDPOINT_ANCHOR_BY_JOINT,
KEY,
MEASURED_PASSIVE_JOINTS,
MIMIC_SOURCE_BY_JOINT,
PASSIVE_JOINTS,
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
TRANSFERRED_PASSIVE_SOURCE_BY_JOINT,
build_typed_profile,
)
ALL_REVOLUTE_JOINTS = frozenset(ACTIVE_JOINTS + PASSIVE_JOINTS)
L6_URDF_INPUT_SCHEMA_VERSION = 1
L6_URDF_INPUT_ARTIFACT_TYPE = "linkerhand_l6_urdf_correction_input"
def _sha256_file(path: str | Path) -> str:
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _source_joint_metadata(
source_urdf: str | Path,
) -> dict[str, dict[str, float | str]]:
root = ET.parse(Path(source_urdf)).getroot()
result: dict[str, dict[str, float | str]] = {}
for joint in root.findall("joint"):
name = str(joint.get("name", ""))
if name not in ALL_REVOLUTE_JOINTS:
continue
if joint.get("type") != "revolute":
raise ValueError(f"L6 profile joint is not revolute: {name}")
limit = joint.find("limit")
if limit is None:
raise ValueError(f"L6 source joint has no limit: {name}")
item: dict[str, float | str] = {
"lower": float(limit.get("lower", "nan")),
"upper": float(limit.get("upper", "nan")),
}
mimic = joint.find("mimic")
if mimic is not None:
item.update(
{
"source_joint": str(mimic.get("joint", "")),
"multiplier": float(mimic.get("multiplier", "nan")),
"offset": float(mimic.get("offset", "0")),
}
)
result[name] = item
if set(result) != ALL_REVOLUTE_JOINTS:
raise ValueError("source URDF does not contain exactly 11 L6 revolute joints")
return result
def _rounded_curve(values: Sequence[float]) -> list[float]:
curve = np.asarray(values, dtype=float)
if curve.shape != (256,) or not np.all(np.isfinite(curve)):
raise ValueError("runtime curve must contain 256 finite values")
return [round(float(value), 8) for value in curve]
def _linear_curve(lower: float, upper: float) -> np.ndarray:
# feedback 255 is the open/lower endpoint and feedback 0 is upper/closed.
return np.linspace(float(upper), float(lower), 256, dtype=float)
def _validate_transfer_topology(
source: Mapping[str, Mapping[str, float | str]],
) -> None:
for transfers in (
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
TRANSFERRED_PASSIVE_SOURCE_BY_JOINT,
):
for target, donor in transfers.items():
for field in ("lower", "upper"):
if abs(
float(source[target][field]) - float(source[donor][field])
) > 1.0e-8:
raise ValueError(
f"L6 transfer {target} differs from {donor} {field}"
)
def build_l6_runtime_payload(
*,
serial_number: str,
source_urdf: str | Path,
result: L6FitResult,
protected_inputs: Mapping[str, str],
passed: bool = True,
) -> dict[str, Any]:
"""Build all eleven L6 curves with explicit measured/frozen provenance."""
profile = build_typed_profile()
source = _source_joint_metadata(source_urdf)
_validate_transfer_topology(source)
joints: dict[str, dict[str, Any]] = {}
curves: dict[str, np.ndarray] = {}
for name in ACTIVE_JOINTS:
metadata = source[name]
motor_index = COMMAND_INDEX_BY_JOINT[name]
measurement_source = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(
name, name
)
if (
name in CALIBRATED_ACTIVE_JOINTS
or name in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT
):
fit = result.curves[measurement_source]
decreasing = np.asarray(fit.decreasing_rad, dtype=float)
increasing = np.asarray(fit.increasing_rad, dtype=float)
curve = 0.5 * (decreasing + increasing)
curve -= float(curve[255])
decreasing = decreasing - float(decreasing[255])
increasing = increasing - float(increasing[255])
zero_method = result.zero_method_by_joint[measurement_source]
zero_angles: dict[str, Any] = {
"policy": zero_method,
"measured_travel_rad": round(
float(result.travels_rad[measurement_source]), 8
),
"urdf_origin_offset_rad": round(
float(result.zero_offsets_rad[measurement_source]), 8
),
}
if measurement_source in ENDPOINT_ANCHOR_BY_JOINT:
anchor = ENDPOINT_ANCHOR_BY_JOINT[measurement_source]
if anchor == "cad_range_center":
zero_angles.update(
{
"source_lower_rad": round(
float(metadata["lower"]), 8
),
"source_upper_rad": round(
float(metadata["upper"]), 8
),
}
)
else:
endpoint_field = {
"lower_at_start": "source_lower_rad",
"upper_at_end": "source_upper_rad",
"zero_at_start": "source_joint_zero_rad",
}[anchor]
endpoint_value = {
"lower_at_start": metadata["lower"],
"upper_at_end": metadata["upper"],
"zero_at_start": 0.0,
}[anchor]
zero_angles[endpoint_field] = round(
float(endpoint_value), 8
)
if measurement_source in result.zero_fallback_reason_by_joint:
zero_angles["geometry_fallback_reason"] = (
result.zero_fallback_reason_by_joint[measurement_source]
)
if name in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT:
zero_angles.update(
{
"policy": "transferred_from_pinky",
"transferred_from_joint": measurement_source,
}
)
else:
curve = _linear_curve(
float(metadata["lower"]), float(metadata["upper"])
)
decreasing = curve.copy()
increasing = curve.copy()
zero_angles = {
"policy": "cad_nominal",
"source_lower_rad": round(float(metadata["lower"]), 8),
"source_upper_rad": round(float(metadata["upper"]), 8),
}
curves[name] = curve
joint_payload = {
"urdf_joint": name,
"sdk_channel": COMMAND_NAMES[motor_index],
"motor_index": motor_index,
"passive": False,
"calibration_status": profile.joint_coverage[name],
"zero_command_u8": 255,
"zero_angles": zero_angles,
"angle_rad": _rounded_curve(curve),
"decreasing_rad": _rounded_curve(decreasing),
"increasing_rad": _rounded_curve(increasing),
}
if name in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT:
joint_payload["transferred_from_joint"] = measurement_source
joints[name] = joint_payload
for name in PASSIVE_JOINTS:
metadata = source[name]
source_name = MIMIC_SOURCE_BY_JOINT[name]
motor_index = COMMAND_INDEX_BY_JOINT[source_name]
measurement_source = TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.get(
name, name
)
if (
name in MEASURED_PASSIVE_JOINTS
or name in TRANSFERRED_PASSIVE_SOURCE_BY_JOINT
):
fit = result.curves[measurement_source]
decreasing = np.asarray(fit.decreasing_rad, dtype=float)
increasing = np.asarray(fit.increasing_rad, dtype=float)
curve = 0.5 * (decreasing + increasing)
curve -= float(curve[255])
decreasing = decreasing - float(decreasing[255])
increasing = increasing - float(increasing[255])
offset = float(metadata["offset"])
curve += offset
decreasing += offset
increasing += offset
coupling = result.mimic_fits[measurement_source]
coupling_model = coupling.model
coefficients = list(coupling.mujoco_polycoef)
coefficients[0] = offset
urdf_mimic_multiplier = coupling.urdf_mimic_multiplier
urdf_mimic_policy = coupling.urdf_mimic_policy
else:
multiplier = float(metadata["multiplier"])
offset = float(metadata["offset"])
curve = offset + multiplier * curves[source_name]
decreasing = curve.copy()
increasing = curve.copy()
coupling_model = "linear_mimic"
coefficients = [offset, multiplier, 0.0, 0.0, 0.0, 0.0]
urdf_mimic_multiplier = multiplier
urdf_mimic_policy = "cad_nominal"
joint_payload = {
"urdf_joint": name,
"sdk_channel": COMMAND_NAMES[motor_index],
"motor_index": motor_index,
"passive": True,
"source_joint": source_name,
"mimic_offset_rad": round(float(metadata["offset"]), 8),
"coupling_model": coupling_model,
"coupling_coefficients": [
round(float(value), 10) for value in coefficients
],
"urdf_mimic_enabled": True,
"urdf_mimic_policy": urdf_mimic_policy,
"mimic_multiplier": round(float(urdf_mimic_multiplier), 8),
"calibration_status": profile.joint_coverage[name],
"zero_command_u8": 255,
"zero_angles": {"policy": "cad_static"},
"angle_rad": _rounded_curve(curve),
"decreasing_rad": _rounded_curve(decreasing),
"increasing_rad": _rounded_curve(increasing),
}
joints[name] = joint_payload
if name in TRANSFERRED_PASSIVE_SOURCE_BY_JOINT:
joint_payload["transferred_from_joint"] = measurement_source
errors = np.abs(
np.concatenate(
[np.asarray(values, dtype=float) for values in result.holdout_errors_rad.values()]
)
)
payload: dict[str, Any] = {
"schema_version": 6,
"profile_id": KEY.profile_id,
"layout_id": KEY.layout,
"model": "L6",
"side": "right",
"serial_number": str(serial_number),
"calibration_scope": "partial",
"publication_pointer": "latest_partial_passed",
"angle_unit": "rad",
"command_range": [0, 255],
"curve_input_domain": "feedback_u8",
"runtime_curve_policy": "direction_aware",
"command_names": list(COMMAND_NAMES),
"baseline_command_u8": [255] * 6,
"protected_inputs": dict(protected_inputs),
"joints": joints,
"quality": {
"passed": bool(passed),
"scope": "partial",
"validation_mae_rad": round(float(np.mean(errors)), 8),
"validation_p95_rad": round(float(np.percentile(errors, 95.0)), 8),
"validation_max_rad": round(float(np.max(errors)), 8),
"thumb_axis_zero": (
None
if result.thumb_zero_result is None
else {
"method": (
"hybrid_axis_geometry_cad_range_center"
if result.zero_fallback_reason_by_joint
else "g20_serial_axis_geometry"
),
"passed": bool(result.thumb_zero_result.passed),
"geometry_fallback_reasons": dict(
sorted(result.zero_fallback_reason_by_joint.items())
),
"axis_line_rms_m": round(
float(result.thumb_zero_result.axis_line_rms_m), 10
),
"offsets_rad": {
name: round(float(value), 10)
for name, value in sorted(
result.thumb_zero_result.direct_offsets_rad.items()
)
},
"cycle_offsets_rad": {
name: [round(float(value), 10) for value in values]
for name, values in sorted(
result.thumb_zero_result.cycle_offsets_rad.items()
)
},
"validation_error_by_joint_rad": {
name: round(float(value), 10)
for name, value in sorted(
result.thumb_zero_result.validation_error_by_joint_rad.items()
)
},
}
),
},
}
validate_l6_runtime_payload(payload)
return payload
def build_l6_urdf_input_payload(
*,
serial_number: str,
source_urdf: str | Path,
result: L6FitResult,
) -> dict[str, Any]:
"""Serialize the exact, minimal L6 fit consumed by the URDF writer.
The public schema-v6 artifact deliberately rounds runtime lookup tables.
Feeding those rounded values back into the URDF writer would alter
established URDF bytes. This correction-input JSON preserves Python's
round-trip float representation without changing either public schema or
correction mathematics.
"""
measured = CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS
if set(result.curves) != measured:
raise ValueError("L6 URDF input has the wrong measured curve set")
if set(result.zero_offsets_rad) != CALIBRATED_ACTIVE_JOINTS:
raise ValueError("L6 URDF input has the wrong zero-offset set")
if set(result.travels_rad) != CALIBRATED_ACTIVE_JOINTS:
raise ValueError("L6 URDF input has the wrong travel set")
if set(result.mimic_fits) != MEASURED_PASSIVE_JOINTS:
raise ValueError("L6 URDF input has the wrong mimic-fit set")
payload: dict[str, Any] = {
"schema_version": L6_URDF_INPUT_SCHEMA_VERSION,
"artifact_type": L6_URDF_INPUT_ARTIFACT_TYPE,
"profile_id": KEY.profile_id,
"model": "L6",
"side": "right",
"serial_number": str(serial_number),
"source_urdf_sha256": _sha256_file(source_urdf),
"zero_offsets_rad": {
name: float(result.zero_offsets_rad[name])
for name in sorted(CALIBRATED_ACTIVE_JOINTS)
},
"travels_rad": {
name: float(result.travels_rad[name])
for name in sorted(CALIBRATED_ACTIVE_JOINTS)
},
"measured_curves": {
name: {
"angle_rad": [float(value) for value in result.curves[name].angle_rad],
"decreasing_rad": [
float(value) for value in result.curves[name].decreasing_rad
],
"increasing_rad": [
float(value) for value in result.curves[name].increasing_rad
],
}
for name in sorted(measured)
},
"mimic_fits": {
name: {
"source_joint": fit.source_joint,
"target_joint": fit.target_joint,
"model": fit.model,
"coefficients": [float(value) for value in fit.coefficients],
"urdf_mimic_multiplier": float(fit.urdf_mimic_multiplier),
"urdf_mimic_policy": fit.urdf_mimic_policy,
}
for name in sorted(MEASURED_PASSIVE_JOINTS)
for fit in (result.mimic_fits[name],)
},
}
validate_l6_urdf_input_payload(payload)
return payload
def validate_l6_urdf_input_payload(payload: Mapping[str, Any]) -> None:
required = {
"schema_version", "artifact_type", "profile_id", "model", "side",
"serial_number", "source_urdf_sha256", "zero_offsets_rad",
"travels_rad", "measured_curves", "mimic_fits",
}
if set(payload) != required:
raise ValueError("L6 URDF correction input has unexpected fields")
if (
payload.get("schema_version") != L6_URDF_INPUT_SCHEMA_VERSION
or payload.get("artifact_type") != L6_URDF_INPUT_ARTIFACT_TYPE
or payload.get("profile_id") != KEY.profile_id
or payload.get("model") != "L6"
or payload.get("side") != "right"
):
raise ValueError("L6 URDF correction input identity is invalid")
source_hash = str(payload.get("source_urdf_sha256", ""))
if len(source_hash) != 64 or any(
char not in "0123456789abcdef" for char in source_hash
):
raise ValueError("L6 URDF correction input source hash is invalid")
active = set(CALIBRATED_ACTIVE_JOINTS)
measured = active | set(MEASURED_PASSIVE_JOINTS)
zeros = payload.get("zero_offsets_rad")
travels = payload.get("travels_rad")
curves = payload.get("measured_curves")
mimics = payload.get("mimic_fits")
if not isinstance(zeros, Mapping) or set(zeros) != active:
raise ValueError("L6 URDF correction input zero offsets are incomplete")
if not isinstance(travels, Mapping) or set(travels) != active:
raise ValueError("L6 URDF correction input travels are incomplete")
if not isinstance(curves, Mapping) or set(curves) != measured:
raise ValueError("L6 URDF correction input curves are incomplete")
if not isinstance(mimics, Mapping) or set(mimics) != MEASURED_PASSIVE_JOINTS:
raise ValueError("L6 URDF correction input mimic fits are incomplete")
if any(not math.isfinite(float(value)) for value in zeros.values()):
raise ValueError("L6 URDF correction input has a non-finite zero offset")
if any(
not math.isfinite(float(value)) or float(value) <= 0.0
for value in travels.values()
):
raise ValueError("L6 URDF correction input has an invalid travel")
for name, item in curves.items():
if not isinstance(item, Mapping):
raise ValueError(f"L6 URDF correction curve is invalid: {name}")
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
values = np.asarray(item.get(field), dtype=float)
if values.shape != (256,) or not np.all(np.isfinite(values)):
raise ValueError(
f"L6 URDF correction curve is invalid: {name}.{field}"
)
for name, item in mimics.items():
if not isinstance(item, Mapping):
raise ValueError(f"L6 URDF correction mimic fit is invalid: {name}")
if (
item.get("target_joint") != name
or item.get("source_joint") != MIMIC_SOURCE_BY_JOINT[name]
or item.get("model") != COUPLING_MODEL_BY_JOINT[name]
):
raise ValueError(f"L6 URDF correction mimic topology is invalid: {name}")
coefficients = np.asarray(item.get("coefficients"), dtype=float)
expected_count = 2 if item.get("model") == "quadratic_runtime" else 1
if coefficients.shape != (expected_count,) or not np.all(
np.isfinite(coefficients)
):
raise ValueError(f"L6 URDF correction coefficients are invalid: {name}")
multiplier = float(item.get("urdf_mimic_multiplier", "nan"))
if not math.isfinite(multiplier) or multiplier <= 0.0:
raise ValueError(f"L6 URDF correction multiplier is invalid: {name}")
def load_l6_urdf_input(
path: str | Path,
*,
source_urdf: str | Path,
serial_number: str,
) -> L6FitResult:
"""Load and authenticate the exact L6 fit used to materialize a URDF."""
source = Path(path).expanduser().resolve()
try:
payload = json.loads(source.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ValueError(f"could not read L6 URDF correction input: {source}") from error
if not isinstance(payload, Mapping):
raise ValueError("L6 URDF correction input must be a JSON object")
validate_l6_urdf_input_payload(payload)
if str(payload["serial_number"]) != str(serial_number):
raise ValueError("L6 URDF correction input serial number differs")
if str(payload["source_urdf_sha256"]) != _sha256_file(source_urdf):
raise ValueError("L6 source URDF changed after calibration JSON was written")
curves = {
str(name): JointCurveFit(
angle_rad=tuple(float(value) for value in item["angle_rad"]),
decreasing_rad=tuple(
float(value) for value in item["decreasing_rad"]
),
increasing_rad=tuple(
float(value) for value in item["increasing_rad"]
),
circle={},
maximum_monotonic_correction_rad=0.0,
maximum_hysteresis_rad=0.0,
quality={},
)
for name, item in payload["measured_curves"].items()
}
mimic_fits = {
str(name): MimicFit(
source_joint=str(item["source_joint"]),
target_joint=str(item["target_joint"]),
model=str(item["model"]),
coefficients=tuple(float(value) for value in item["coefficients"]),
urdf_mimic_multiplier=float(item["urdf_mimic_multiplier"]),
urdf_mimic_policy=str(item["urdf_mimic_policy"]),
cycle_coefficients=(),
maximum_cycle_range=0.0,
maximum_cycle_prediction_range_rad=0.0,
residual_rms_rad=0.0,
residual_p95_rad=0.0,
residual_max_rad=0.0,
)
for name, item in payload["mimic_fits"].items()
}
return L6FitResult(
curves=curves,
zero_offsets_rad={
str(name): float(value)
for name, value in payload["zero_offsets_rad"].items()
},
travels_rad={
str(name): float(value)
for name, value in payload["travels_rad"].items()
},
mimic_fits=mimic_fits,
holdout_errors_rad={},
zero_method_by_joint={},
zero_fallback_reason_by_joint={},
thumb_zero_result=None,
)
def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None:
from linkerhand_calibration.core import ProfileKey
key = ProfileKey.parse(str(payload.get("profile_id", "")))
if key == KEY:
profile = build_typed_profile()
active_joints = ACTIVE_JOINTS
passive_joints = PASSIVE_JOINTS
active_transfers = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT
passive_transfers = TRANSFERRED_PASSIVE_SOURCE_BY_JOINT
else:
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.left_transfer import (
KEY as LEFT_TRANSFER_KEY,
build_typed_profile as build_left_transfer_profile,
left_joint_name,
)
if key != LEFT_TRANSFER_KEY:
raise ValueError(f"unsupported L6 runtime profile: {key.profile_id}")
profile = build_left_transfer_profile()
active_joints = tuple(left_joint_name(name) for name in ACTIVE_JOINTS)
passive_joints = tuple(left_joint_name(name) for name in PASSIVE_JOINTS)
active_transfers = {
left_joint_name(name): left_joint_name(source)
for name, source in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items()
}
passive_transfers = {
left_joint_name(name): left_joint_name(source)
for name, source in TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.items()
}
passive_set = frozenset(passive_joints)
all_joints = frozenset(active_joints + passive_joints)
mimic_sources = profile.zero.mimic_source_by_joint
coupling_models = profile.zero.coupling_model_by_joint
command_indices = profile.command.command_index_by_joint
required_top = {
"schema_version", "profile_id", "layout_id", "model", "side",
"serial_number", "calibration_scope", "publication_pointer",
"angle_unit", "command_range", "curve_input_domain",
"runtime_curve_policy", "command_names", "baseline_command_u8",
"protected_inputs", "joints", "quality",
}
if set(payload) != required_top:
raise ValueError("schema v6 calibration has unexpected top-level fields")
if (
payload["schema_version"] != 6
or payload["profile_id"] != profile.key.profile_id
or payload["model"] != "L6"
or payload["side"] != profile.key.side
or payload["layout_id"] != profile.key.layout
or payload["calibration_scope"] != "partial"
):
raise ValueError("schema v6 identity is invalid")
if payload["publication_pointer"] != profile.artifacts.publication_pointer:
raise ValueError("L6 partial result has the wrong publication pointer")
if payload["curve_input_domain"] != "feedback_u8":
raise ValueError("schema v6 must be indexed by feedback_u8")
if payload["runtime_curve_policy"] != "direction_aware":
raise ValueError("schema v6 must retain both motion directions")
if payload["angle_unit"] != "rad" or payload["command_range"] != [0, 255]:
raise ValueError("schema v6 units are invalid")
if tuple(payload["command_names"]) != profile.command.names:
raise ValueError("schema v6 command channel order is invalid")
if tuple(payload["baseline_command_u8"]) != profile.command.baseline_u8:
raise ValueError("schema v6 baseline must be six open commands")
protected = payload["protected_inputs"]
expected_hashes = {
"source_urdf_sha256", "camera_extrinsics_sha256",
"calibration_config_sha256", "tag_config_sha256",
}
if not isinstance(protected, Mapping) or set(protected) != expected_hashes:
raise ValueError("schema v6 protected inputs are incomplete")
if any(
len(str(value)) != 64
or any(char not in "0123456789abcdef" for char in str(value))
for value in protected.values()
):
raise ValueError("schema v6 protected input hash is invalid")
joints = payload["joints"]
if not isinstance(joints, Mapping) or set(joints) != all_joints:
raise ValueError("schema v6 must contain all 11 L6 revolute joints")
for name in active_joints + passive_joints:
joint = joints[name]
motor = command_indices[
mimic_sources.get(name, name)
]
if joint.get("urdf_joint") != name or int(joint.get("motor_index", -1)) != motor:
raise ValueError(f"{name} has an invalid URDF/SDK mapping")
if joint.get("sdk_channel") != profile.command.names[motor]:
raise ValueError(f"{name} has an invalid SDK channel")
if joint.get("calibration_status") != profile.joint_coverage[name]:
raise ValueError(f"{name} has an invalid coverage status")
if joint.get("passive") is not (name in passive_set):
raise ValueError(f"{name} passive flag is invalid")
if joint.get("zero_command_u8") != 255:
raise ValueError(f"{name} zero command must be 255")
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
curve = np.asarray(joint.get(field), dtype=float)
if curve.shape != (256,) or not np.all(np.isfinite(curve)):
raise ValueError(f"{name}.{field} must contain 256 finite values")
if np.any(np.diff(curve) > 1.0e-7):
raise ValueError(f"{name}.{field} must be non-increasing")
if name in passive_set:
if joint.get("source_joint") != mimic_sources[name]:
raise ValueError(f"{name} mimic source is invalid")
model = str(joint.get("coupling_model", ""))
expected_model = coupling_models.get(
name, "linear_mimic"
)
if model not in {"linear_mimic", "quadratic_runtime"}:
raise ValueError(f"{name} coupling model is invalid")
coefficients = np.asarray(
joint.get("coupling_coefficients"), dtype=float
)
if coefficients.shape != (6,) or not np.all(
np.isfinite(coefficients)
):
raise ValueError(f"{name} coupling coefficients are invalid")
enabled = joint.get("urdf_mimic_enabled")
if enabled is not True:
raise ValueError(f"{name} URDF mimic policy is invalid")
expected_policy = (
"endpoint_linear_fallback"
if model == "quadratic_runtime"
else "exact_linear"
if name in profile.zero.fitted_mimic_joints or name in passive_transfers
else "cad_nominal"
)
# Early schema-v6 linear artifacts predate the explicit policy
# label; their unambiguous model/coverage combination remains
# readable. New writers always materialize the field.
policy = str(
joint.get("urdf_mimic_policy", expected_policy)
)
if policy not in {expected_policy, "exact_linear" if model == "linear_mimic" else "endpoint_linear_fallback"}:
raise ValueError(f"{name} URDF mimic fallback is invalid")
multiplier = float(joint.get("mimic_multiplier", "nan"))
if not math.isfinite(multiplier) or multiplier <= 0.0:
raise ValueError(f"{name} mimic multiplier is invalid")
if model == "linear_mimic":
if abs(multiplier - coefficients[1]) > 1.0e-7:
raise ValueError(f"{name} mimic multiplier is inconsistent")
if np.any(np.abs(coefficients[2:]) > 1.0e-10):
raise ValueError(f"{name} linear mimic is not linear")
if abs(float(coefficients[0]) - float(
joint.get("mimic_offset_rad", "nan")
)) > 1.0e-7:
raise ValueError(f"{name} coupling offset is inconsistent")
transferred_from = (
active_transfers.get(name)
or passive_transfers.get(name)
)
if transferred_from is not None:
if joint.get("transferred_from_joint") != transferred_from:
raise ValueError(f"{name} transfer provenance is invalid")
donor = joints[transferred_from]
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
if name not in passive_set and joint[field] != donor[field]:
raise ValueError(f"{name} transfer curve differs from donor")
if name in passive_set and joint["coupling_model"] == "linear_mimic":
parent = joints[mimic_sources[name]]
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
expected = float(joint["mimic_offset_rad"]) + float(joint["mimic_multiplier"]) * np.asarray(parent[field])
if not np.allclose(joint[field], expected, atol=1e-7, rtol=0):
raise ValueError(f"{name} curve differs from its declared standard mimic")
quality = payload["quality"]
if not isinstance(quality, Mapping) or quality.get("scope") != "partial":
raise ValueError("schema v6 quality scope must be partial")
if quality.get("passed") is not True:
raise ValueError("schema v6 quality.passed must be true")
def build_l6_left_transferred_runtime_payload(
*,
right_payload: Mapping[str, Any],
source_left_urdf: str | Path,
transferred_left_urdf: str | Path,
serial_number: str,
) -> dict[str, Any]:
"""Create a validated L6 left runtime JSON paired with a mirrored URDF."""
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.left_transfer import (
KEY as LEFT_TRANSFER_KEY,
build_typed_profile as build_left_transfer_profile,
left_joint_name,
)
validate_l6_runtime_payload(right_payload)
if str(right_payload["side"]) != "right":
raise ValueError("L6 left transfer requires a measured right payload")
left_source = Path(source_left_urdf).expanduser().resolve()
left_urdf = Path(transferred_left_urdf).expanduser().resolve()
if not left_source.is_file() or not left_urdf.is_file():
raise ValueError("L6 left source and transferred URDF files are required")
profile = build_left_transfer_profile()
source_joint_names = {
str(joint.get("name"))
for joint in ET.parse(left_source).getroot().findall("joint")
if joint.get("type") == "revolute"
}
if source_joint_names != profile.zero.active_joints | profile.zero.passive_joints:
raise ValueError("L6 left source URDF differs from transferred profile")
payload = deepcopy(dict(right_payload))
payload.update({
"profile_id": LEFT_TRANSFER_KEY.profile_id,
"layout_id": LEFT_TRANSFER_KEY.layout,
"side": "left",
"serial_number": str(serial_number),
"publication_pointer": profile.artifacts.publication_pointer,
})
payload["protected_inputs"] = {
**dict(right_payload["protected_inputs"]),
"source_urdf_sha256": _sha256_file(left_source),
}
left_joints: dict[str, dict[str, Any]] = {}
for right_name, source_item in right_payload["joints"].items():
left_name = left_joint_name(str(right_name))
item = deepcopy(dict(source_item))
item["urdf_joint"] = left_name
item["calibration_status"] = profile.joint_coverage[left_name]
item["mirrored_from_joint"] = str(right_name)
if "source_joint" in item:
item["source_joint"] = left_joint_name(str(item["source_joint"]))
if "transferred_from_joint" in item:
item["transferred_from_joint"] = left_joint_name(
str(item["transferred_from_joint"])
)
zero = item.get("zero_angles")
if isinstance(zero, Mapping):
zero = deepcopy(dict(zero))
if "transferred_from_joint" in zero:
zero["transferred_from_joint"] = left_joint_name(
str(zero["transferred_from_joint"])
)
item["zero_angles"] = {
**zero,
"mirrored_from_joint": str(right_name),
"transfer_kind": "right_measurement_mirrored_to_left",
}
left_joints[left_name] = item
payload["joints"] = left_joints
quality = deepcopy(dict(right_payload["quality"]))
thumb = quality.get("thumb_axis_zero")
if isinstance(thumb, Mapping):
thumb = deepcopy(dict(thumb))
for field in (
"offsets_rad",
"cycle_offsets_rad",
"geometry_fallback_reasons",
"validation_error_by_joint_rad",
):
values = thumb.get(field)
if isinstance(values, Mapping):
thumb[field] = {
left_joint_name(str(name)): value
for name, value in values.items()
}
quality["thumb_axis_zero"] = thumb
canonical_right = json.dumps(
right_payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
quality["transfer_provenance"] = {
"kind": "right_measurement_mirrored_to_left",
"source_profile_id": str(right_payload["profile_id"]),
"source_serial_number": str(right_payload["serial_number"]),
"source_payload_sha256": hashlib.sha256(canonical_right).hexdigest(),
"left_source_urdf_sha256": _sha256_file(left_source),
"transferred_left_urdf_sha256": _sha256_file(left_urdf),
"left_hand_measured": False,
}
payload["quality"] = quality
validate_l6_runtime_payload(payload)
return payload
def atomic_write_json(path: str | Path, payload: Mapping[str, Any]) -> Path:
destination = Path(path).resolve()
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
try:
with temporary.open("w", encoding="utf-8") as stream:
json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=True)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, destination)
finally:
if temporary.exists():
temporary.unlink()
return destination
def publish_partial_session(*args, **kwargs):
raise ValueError("legacy publication is retired; use the common ArtifactPublisher")
def artifact_hashes(json_path: str | Path, urdf_path: str | Path) -> dict[str, str]:
return {
"calibration_json_sha256": _sha256_file(json_path),
"corrected_urdf_sha256": _sha256_file(urdf_path),
}
__all__ = [
"ALL_REVOLUTE_JOINTS",
"artifact_hashes",
"atomic_write_json",
"build_l6_left_transferred_runtime_payload",
"build_l6_urdf_input_payload",
"build_l6_runtime_payload",
"load_l6_urdf_input",
"publish_partial_session",
"validate_l6_urdf_input_payload",
"validate_l6_runtime_payload",
]
@@ -0,0 +1,33 @@
"""Legacy result types plus a direct call to the ONE profile fitter.
Old endpoint/range-centre fallbacks and model-specific refitting are retired.
The legacy result type remains only for reading archived JSON/tool inputs.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping
from linkerhand_calibration.core.domain.measurement import JointCurveFit
from linkerhand_calibration.core.domain.result import JointMapping
from linkerhand_calibration.core.fitting import CouplingFit as MimicFit, fit_coupling_model, curve_travel_rad
from linkerhand_calibration.core.fitting.coupling import LinearMimicFit, RelativeMimicEvidence
from linkerhand_calibration.core.fitting.spatial import ZeroSolveResult
from linkerhand_calibration.core.fitting.session import fit_profile_calibration, compile_spatial_profile
from linkerhand_calibration.profiles import load_bundled_hand_profile
@dataclass(frozen=True)
class L6FitResult:
curves: Mapping[str, JointCurveFit]
zero_offsets_rad: Mapping[str, float]
travels_rad: Mapping[str, float]
mimic_fits: Mapping[str, MimicFit]
holdout_errors_rad: Mapping[str, tuple[float, ...]]
zero_method_by_joint: Mapping[str, str]
zero_fallback_reason_by_joint: Mapping[str, str]
thumb_zero_result: ZeroSolveResult | None = None
def fit_l6_session(source_urdf, records_by_joint, *, require_thumb_axis_zero=False):
# Historical flags can no longer disable spatial or holdout verification.
return fit_profile_calibration(load_bundled_hand_profile("l6_right_8"),
Path(source_urdf), records_by_joint)
@@ -0,0 +1,161 @@
"""Runtime-only L6 left profile derived from reviewed right-hand calibration."""
from __future__ import annotations
from dataclasses import replace
from linkerhand_calibration.core import ProfileKey
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import EngineBindings, RegisteredProfile
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.profile import build_typed_profile as build_right_typed_profile
KEY = ProfileKey("L6", "left", "l6_left_transferred_8", 1)
def left_joint_name(name: str) -> str:
value = str(name)
if not value.startswith("rh_"):
raise ValueError(f"L6 right joint name cannot be mirrored: {value}")
return "lh_" + value[3:]
def build_typed_profile():
right = build_right_typed_profile()
command_index = {
left_joint_name(name): index
for name, index in right.command.command_index_by_joint.items()
}
active = frozenset(left_joint_name(name) for name in right.zero.active_joints)
passive = frozenset(left_joint_name(name) for name in right.zero.passive_joints)
measurements = {
left_joint_name(name): replace(spec, joint=left_joint_name(name))
for name, spec in right.measurement.measurements.items()
}
tasks = tuple(
replace(task, joints=tuple(left_joint_name(name) for name in task.joints))
for task in right.motion.tasks
)
return replace(
right,
key=KEY,
namespace="/l6_left_transferred",
command=replace(
right.command,
command_index_by_joint=command_index,
urdf_joint_by_joint={name: name for name in active},
),
motion=replace(right.motion, tasks=tasks),
measurement=replace(
right.measurement,
measurements=measurements,
cross_view_sources={
left_joint_name(name): left_joint_name(source)
for name, source in right.measurement.cross_view_sources.items()
},
image_curve_joints=frozenset(
left_joint_name(name)
for name in right.measurement.image_curve_joints
),
),
zero=replace(
right.zero,
transferred_zero_sources={left_joint_name(k): left_joint_name(v)
for k, v in right.zero.transferred_zero_sources.items()},
transferred_mimic_sources={left_joint_name(k): left_joint_name(v)
for k, v in right.zero.transferred_mimic_sources.items()},
# Runtime-only mirrored products do not acquire/solve new geometry.
spatial={},
active_joints=active,
passive_joints=passive,
direct_zero_joints=tuple(
left_joint_name(name) for name in right.zero.direct_zero_joints
),
axis_joints=tuple(
left_joint_name(name) for name in right.zero.axis_joints
),
mechanical_endpoint_joints=frozenset(
left_joint_name(name)
for name in right.zero.mechanical_endpoint_joints
),
post_solve_endpoint_joints=frozenset(
left_joint_name(name)
for name in right.zero.post_solve_endpoint_joints
),
mimic_source_by_joint={
left_joint_name(name): left_joint_name(source)
for name, source in right.zero.mimic_source_by_joint.items()
},
cad_frozen_joints=passive,
endpoint_anchor_by_joint={
left_joint_name(name): policy
for name, policy in right.zero.endpoint_anchor_by_joint.items()
},
fitted_mimic_joints=frozenset(
left_joint_name(name) for name in right.zero.fitted_mimic_joints
),
coupling_model_by_joint={
left_joint_name(name): model
for name, model in right.zero.coupling_model_by_joint.items()
},
),
scope=replace(
right.scope,
calibrate_joints={
scope: frozenset(left_joint_name(name) for name in names)
for scope, names in right.scope.calibrate_joints.items()
},
frozen_joints={
scope: frozenset(left_joint_name(name) for name in names)
for scope, names in right.scope.frozen_joints.items()
},
),
artifacts=replace(
right.artifacts,
calibration_filename=(
"l6_left_{serial_number}_transferred_calibration.json"
),
corrected_urdf_filename=(
"linkerhand_l6_left_{serial_number}_transferred.urdf"
),
publication_pointer="latest_transferred",
session_compatibility_tokens=frozenset(
{"l6_left_transfer_v1", "feedback_curves_v6"}
),
),
joint_coverage={
left_joint_name(name): status
for name, status in right.joint_coverage.items()
},
)
def _unsupported(_args=None) -> None:
raise ValueError(
"L6 left transferred profile is runtime-only; calibrate the left hand "
"with a dedicated measured profile before treating it as measured"
)
def build_profile() -> RegisteredProfile:
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.motion import (
build_calibration_motion_command,
build_calibration_preparation_waypoints,
build_calibration_return_waypoints,
)
typed = build_typed_profile()
return RegisteredProfile(
profile=typed,
engine=EngineBindings(
hand_profile=typed,
zero_profile=typed.zero,
motion_command=build_calibration_motion_command,
preparation_waypoints=build_calibration_preparation_waypoints,
return_waypoints=build_calibration_return_waypoints,
cli_main=_unsupported,
node_main=_unsupported,
),
)
__all__ = ["KEY", "build_profile", "build_typed_profile", "left_joint_name"]
@@ -0,0 +1,18 @@
"""Safe six-channel motion helpers for the partial L6 profile."""
from __future__ import annotations
from linkerhand_calibration.runtime.trajectory import (
build_calibration_motion_command,
build_calibration_preparation_waypoints,
build_calibration_return_waypoints,
cosine_position_trajectory_u8,
)
__all__ = [
"build_calibration_motion_command",
"build_calibration_preparation_waypoints",
"build_calibration_return_waypoints",
"cosine_position_trajectory_u8",
]
@@ -0,0 +1,78 @@
"""Reviewed partial-calibration profile for the right L6 eight-Tag rig."""
from __future__ import annotations
from linkerhand_calibration.core import CalibrationProfile, ProfileKey
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import EngineBindings, RegisteredProfile
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.motion import (
build_calibration_motion_command,
build_calibration_preparation_waypoints,
build_calibration_return_waypoints,
)
# Historical import constants are projections of the authoritative YAML,
# never a second set of model/zero/coupling definitions.
from linkerhand_calibration.profiles.loader import load_bundled_hand_profile
_DECLARED = load_bundled_hand_profile("l6_right_8")
KEY = _DECLARED.key
COMMAND_NAMES = _DECLARED.command.names
COMMAND_INDEX_BY_JOINT = dict(_DECLARED.command.command_index_by_joint)
ACTIVE_JOINTS = tuple(sorted(COMMAND_INDEX_BY_JOINT, key=COMMAND_INDEX_BY_JOINT.get))
MIMIC_SOURCE_BY_JOINT = dict(_DECLARED.zero.mimic_source_by_joint)
PASSIVE_JOINTS = tuple(name for source in ACTIVE_JOINTS
for name, parent in MIMIC_SOURCE_BY_JOINT.items() if parent == source)
CALIBRATED_ACTIVE_JOINTS = _DECLARED.scope.selected_joints(_DECLARED.scope.default_scope)
MEASURED_PASSIVE_JOINTS = _DECLARED.zero.fitted_mimic_joints
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT = dict(_DECLARED.zero.transferred_zero_sources)
TRANSFERRED_PASSIVE_SOURCE_BY_JOINT = dict(_DECLARED.zero.transferred_mimic_sources)
CORRECTED_ACTIVE_JOINTS = _DECLARED.zero.active_joints
CORRECTED_PASSIVE_JOINTS = frozenset(MEASURED_PASSIVE_JOINTS | TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.keys())
ENDPOINT_ANCHOR_BY_JOINT = dict(_DECLARED.zero.endpoint_anchor_by_joint)
COUPLING_MODEL_BY_JOINT = dict(_DECLARED.zero.coupling_model_by_joint)
def build_typed_profile() -> CalibrationProfile:
"""Compatibility entry; the complete typed contract lives only in YAML."""
from linkerhand_calibration.profiles.loader import load_bundled_hand_profile
return load_bundled_hand_profile("l6_right_8")
def _run_cli(args: list[str] | None = None) -> None:
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.runner import main
main(args)
def _run_node(args: list[str] | None = None) -> None:
from linkerhand_calibration.runtime.ros.calibration_node import run_profile_node
run_profile_node(build_typed_profile(), args)
def build_profile() -> RegisteredProfile:
typed = build_typed_profile()
return RegisteredProfile(
profile=typed,
engine=EngineBindings(
hand_profile=typed,
zero_profile=typed.zero,
motion_command=build_calibration_motion_command,
preparation_waypoints=build_calibration_preparation_waypoints,
return_waypoints=build_calibration_return_waypoints,
cli_main=_run_cli,
node_main=_run_node,
),
)
__all__ = [
"ACTIVE_JOINTS",
"CALIBRATED_ACTIVE_JOINTS",
"COMMAND_NAMES",
"KEY",
"MEASURED_PASSIVE_JOINTS",
"MIMIC_SOURCE_BY_JOINT",
"PASSIVE_JOINTS",
"build_profile",
"build_typed_profile",
]
@@ -0,0 +1,7 @@
"""Legacy import path; all online work uses the common product runner."""
from linkerhand_calibration.runtime.runner import main
from linkerhand_calibration.runtime.runner_support import launch_command as _launch_command
from linkerhand_calibration.compat.legacy_diagnostic_tools.progress import (
render_l6_progress_zh, render_six_channel_progress_zh,
)
@@ -0,0 +1,403 @@
"""Minimal, auditable URDF correction for the partial L6 profile."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import math
from pathlib import Path
import re
from typing import Mapping
import xml.etree.ElementTree as ET
import numpy as np
from scipy.spatial.transform import Rotation
from linkerhand_calibration.core.urdf import (
MujocoEqualityPatch,
UrdfJointPatch,
UrdfPatchSet,
corrected_origin_rpy as _corrected_origin_rpy,
parse_vector3 as _triplet,
write_urdf_patches,
)
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.fitting import L6FitResult
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.profile import (
CALIBRATED_ACTIVE_JOINTS,
CORRECTED_ACTIVE_JOINTS,
CORRECTED_PASSIVE_JOINTS,
MEASURED_PASSIVE_JOINTS,
MIMIC_SOURCE_BY_JOINT,
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
TRANSFERRED_PASSIVE_SOURCE_BY_JOINT,
build_typed_profile,
)
@dataclass(frozen=True)
class L6UrdfCorrection:
path: Path
origin_offsets_rad: Mapping[str, float]
corrected_limits_rad: Mapping[str, tuple[float, float]]
mimic_multipliers: Mapping[str, float]
coupling_polycoef: Mapping[str, tuple[float, ...]]
explicit_runtime_joints: frozenset[str]
def _revolute_joints(root: ET.Element) -> dict[str, ET.Element]:
return {
str(joint.get("name")): joint
for joint in root.findall("joint")
if joint.get("type") == "revolute"
}
def _origin_rotation(joint: ET.Element) -> Rotation:
origin = joint.find("origin")
if origin is None or origin.get("rpy") is None:
raise ValueError(f"joint {joint.get('name')} has no origin.rpy")
return Rotation.from_euler("xyz", _triplet(origin.get("rpy", "0 0 0")))
def write_l6_left_from_right_calibration(
*,
source_left_urdf: str | Path,
source_right_urdf: str | Path,
calibrated_right_urdf: str | Path,
destination_urdf: str | Path,
) -> Path:
"""Mirror a reviewed L6 right correction onto the original left CAD."""
source_left = Path(source_left_urdf).expanduser().resolve()
source_right = Path(source_right_urdf).expanduser().resolve()
calibrated_right = Path(calibrated_right_urdf).expanduser().resolve()
destination = Path(destination_urdf).expanduser().resolve()
if not source_left.is_file() or not source_right.is_file():
raise ValueError("original L6 left and right URDF files are required")
if not calibrated_right.is_file():
raise ValueError("calibrated L6 right URDF is required")
if (
"calibrated" in source_left.stem.lower()
or "calibrated" in source_right.stem.lower()
):
raise ValueError("L6 transfer sources must be immutable original URDF files")
left_root = ET.parse(source_left).getroot()
right_source_root = ET.parse(source_right).getroot()
right_corrected_root = ET.parse(calibrated_right).getroot()
left = _revolute_joints(left_root)
right_source = _revolute_joints(right_source_root)
right_corrected = _revolute_joints(right_corrected_root)
expected_right = {name.replace("lh_", "rh_", 1) for name in left}
if set(right_source) != expected_right or set(right_corrected) != expected_right:
raise ValueError("L6 left/right revolute topology is not mirror-compatible")
joint_patches: dict[str, UrdfJointPatch] = {}
for left_name, left_joint in sorted(left.items()):
right_name = left_name.replace("lh_", "rh_", 1)
source_joint = right_source[right_name]
corrected_joint = right_corrected[right_name]
source_axis_node = source_joint.find("axis")
source_axis = _triplet(
"1 0 0"
if source_axis_node is None
else source_axis_node.get("xyz", "1 0 0")
)
source_axis /= np.linalg.norm(source_axis)
relative = _origin_rotation(source_joint).inv() * _origin_rotation(
corrected_joint
)
rotation_vector = relative.as_rotvec()
offset = float(rotation_vector @ source_axis)
if np.linalg.norm(rotation_vector - offset * source_axis) > 1.0e-7:
raise ValueError(
f"right L6 correction is not about its declared axis: {right_name}"
)
if abs(offset) > math.radians(15.0):
raise ValueError(f"right L6 correction exceeds transfer bound: {right_name}")
corrected_limit = corrected_joint.find("limit")
if corrected_limit is None:
raise ValueError(f"right L6 joint has no limit: {right_name}")
values: dict[str, str] = {
"limit_lower": str(corrected_limit.get("lower")),
"limit_upper": str(corrected_limit.get("upper")),
}
if abs(offset) > 1.0e-12:
values["origin_rpy"] = _corrected_origin_rpy(left_joint, offset)
left_mimic = left_joint.find("mimic")
right_mimic = corrected_joint.find("mimic")
if (left_mimic is None) != (right_mimic is None):
raise ValueError(f"L6 passive topology differs: {left_name}")
if left_mimic is not None and right_mimic is not None:
expected_source = str(right_mimic.get("joint")).replace("rh_", "lh_", 1)
if left_mimic.get("joint") != expected_source:
raise ValueError(f"L6 mimic source is not mirrored: {left_name}")
values["mimic_multiplier"] = str(right_mimic.get("multiplier"))
values["mimic_offset"] = str(right_mimic.get("offset", "0"))
joint_patches[left_name] = UrdfJointPatch(**values)
right_equalities = {
str(node.get("joint1")): node
for node in right_corrected_root.findall("./mujoco/equality/joint")
}
equality_patches: dict[str, MujocoEqualityPatch] = {}
for left_equality in left_root.findall("./mujoco/equality/joint"):
left_target = str(left_equality.get("joint1", ""))
left_source_name = str(left_equality.get("joint2", ""))
right_target = left_target.replace("lh_", "rh_", 1)
right_equality = right_equalities.get(right_target)
if right_equality is None:
raise ValueError(f"right L6 equality is missing: {right_target}")
expected_left_source = str(right_equality.get("joint2", "")).replace(
"rh_", "lh_", 1
)
if left_source_name != expected_left_source:
raise ValueError(f"L6 equality source is not mirrored: {left_target}")
equality_name = str(left_equality.get("name", ""))
if not equality_name:
raise ValueError(f"left L6 equality has no name: {left_target}")
equality_patches[equality_name] = MujocoEqualityPatch(
polycoef=str(right_equality.get("polycoef", "")),
expected_joint1=left_target,
expected_joint2=left_source_name,
)
if len(equality_patches) != len(right_equalities):
raise ValueError("L6 left/right equality topology differs")
write_urdf_patches(
source_urdf=source_left,
destination_urdf=destination,
patches=UrdfPatchSet(
joints=joint_patches,
mujoco_equalities=equality_patches,
),
forbidden_source_stem_patterns=(r"calibrated",),
copy_complete_mesh_directory=True,
)
return destination
def _validate_passive_ranges(
joints: Mapping[str, ET.Element], result: L6FitResult
) -> None:
for target in sorted(CORRECTED_PASSIVE_JOINTS):
source = MIMIC_SOURCE_BY_JOINT[target]
measured_source = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(
source, source
)
measured_target = TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.get(
target, target
)
joint = joints[target]
mimic = joint.find("mimic")
limit = joint.find("limit")
if mimic is None or limit is None:
raise ValueError(f"passive joint is missing mimic or limit: {target}")
if mimic.get("joint") != source:
raise ValueError(f"passive source differs from profile: {target}")
offset = float(mimic.get("offset", "0"))
coupling = result.mimic_fits[measured_target]
travel = float(result.travels_rad[measured_source])
grid = np.linspace(0.0, travel, 256)
predicted = np.full_like(grid, offset)
for power, coefficient in enumerate(coupling.coefficients, 1):
predicted += float(coefficient) * grid ** power
lower = float(limit.get("lower", "-inf"))
upper = float(limit.get("upper", "inf"))
if (
float(np.min(predicted)) < lower - 1.0e-8
or float(np.max(predicted)) > upper + 1.0e-8
):
raise ValueError(
f"{target} fitted coupling range exceeds preserved passive limit"
)
measured = result.curves[measured_target]
observed = np.concatenate(
(
np.asarray(measured.decreasing_rad, dtype=float),
np.asarray(measured.increasing_rad, dtype=float),
)
) + offset
if float(np.min(observed)) < lower - 1.0e-8 or float(
np.max(observed)
) > upper + 1.0e-8:
raise ValueError(
f"{target} measured curve exceeds preserved passive limit"
)
def write_l6_corrected_urdf(
*,
source_urdf: str | Path,
output_directory: str | Path,
serial_number: str,
result: L6FitResult,
timestamp: str | None = None,
) -> L6UrdfCorrection:
source = Path(source_urdf).expanduser().resolve()
output = Path(output_directory).expanduser().resolve()
if not source.is_file():
raise ValueError(f"source URDF does not exist: {source}")
if "calibrated" in source.stem.lower():
raise ValueError("source URDF must be the immutable original CAD file")
if set(result.zero_offsets_rad) != CALIBRATED_ACTIVE_JOINTS:
raise ValueError("L6 zero result has the wrong active joint set")
if set(result.travels_rad) != CALIBRATED_ACTIVE_JOINTS:
raise ValueError("L6 travel result has the wrong active joint set")
if set(result.mimic_fits) != MEASURED_PASSIVE_JOINTS:
raise ValueError("L6 mimic result has the wrong passive joint set")
tree = ET.parse(source)
root = tree.getroot()
joints = {
str(joint.get("name")): joint for joint in root.findall("joint")
}
required = CORRECTED_ACTIVE_JOINTS | CORRECTED_PASSIVE_JOINTS
missing = required - set(joints)
if missing:
raise ValueError("source URDF is missing L6 targets: " + ",".join(sorted(missing)))
_validate_passive_ranges(joints, result)
active_replacements: dict[str, tuple[str, str, str]] = {}
for name in sorted(CORRECTED_ACTIVE_JOINTS):
measured_source = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(name, name)
offset = float(result.zero_offsets_rad[measured_source])
travel = float(result.travels_rad[measured_source])
if not math.isfinite(offset) or abs(offset) > math.radians(15.0):
raise ValueError(f"invalid L6 zero offset: {name}")
if not math.isfinite(travel) or not 0.0 < travel < math.pi:
raise ValueError(f"invalid L6 travel: {name}")
active_replacements[name] = (
_corrected_origin_rpy(joints[name], offset),
"0",
f"{travel:.15g}",
)
mimic_replacements = {
name: f"{float(result.mimic_fits[donor].urdf_mimic_multiplier):.15g}"
for name, donor in sorted(
{
target: TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.get(
target, target
)
for target in CORRECTED_PASSIVE_JOINTS
}.items()
)
}
explicit_runtime_joints = frozenset(
name for name in CORRECTED_PASSIVE_JOINTS
if result.mimic_fits[
TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.get(name, name)
].model == "quadratic_runtime"
)
coupling_polycoef = {
name: tuple(
float(value)
for value in result.mimic_fits[
TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.get(name, name)
].mujoco_polycoef
)
for name in sorted(CORRECTED_PASSIVE_JOINTS)
}
equality_patches: dict[str, MujocoEqualityPatch] = {}
for equality in root.findall("./mujoco/equality/joint"):
target = str(equality.get("joint1", ""))
source_name = str(equality.get("joint2", ""))
if target not in coupling_polycoef:
continue
if MIMIC_SOURCE_BY_JOINT[target] != source_name:
raise ValueError(f"MuJoCo equality source differs for {target}")
coefficients = [
float(value) for value in str(equality.get("polycoef", "")).split()
]
if len(coefficients) != 6:
raise ValueError(f"MuJoCo equality polycoef is invalid for {target}")
offset = float(joints[target].find("mimic").get("offset", "0"))
coefficients = list(coupling_polycoef[target])
coefficients[0] = offset
equality_name = str(equality.get("name", ""))
if not equality_name:
raise ValueError(f"MuJoCo equality has no name for {target}")
equality_patches[equality_name] = MujocoEqualityPatch(
polycoef=" ".join(f"{value:.15g}" for value in coefficients),
expected_joint1=target,
expected_joint2=source_name,
)
if len(equality_patches) != len(coupling_polycoef):
raise ValueError("source URDF lacks a MuJoCo equality for fitted coupling")
patch_names = set(active_replacements) | set(mimic_replacements)
joint_patches = {}
for name in sorted(patch_names):
active = active_replacements.get(name)
joint_patches[name] = UrdfJointPatch(
origin_rpy=None if active is None else active[0],
limit_lower=None if active is None else active[1],
limit_upper=None if active is None else active[2],
mimic_multiplier=mimic_replacements.get(name),
)
stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S")
if re.fullmatch(r"\d{8}_\d{6}", stamp) is None:
raise ValueError("URDF zero timestamp must use YYYYMMDD_HHMMSS")
safe_serial = "".join(
char if char.isalnum() or char in "_.-" else "_"
for char in str(serial_number)
)
if not safe_serial:
raise ValueError("serial number must not be empty")
output.mkdir(parents=True, exist_ok=True)
destination = output / (
f"{source.stem}_partial_zero_calibrated_{safe_serial}_{stamp}.urdf"
)
write_urdf_patches(
source_urdf=source,
destination_urdf=destination,
patches=UrdfPatchSet(
joints=joint_patches,
mujoco_equalities=equality_patches,
),
forbidden_source_stem_patterns=(r"calibrated",),
# Keep the complete vendor mesh bundle, including auxiliary meshes not
# referenced by this XML revision.
copy_complete_mesh_directory=True,
authorized_fields=build_typed_profile().urdf_authorized_fields,
)
return L6UrdfCorrection(
path=destination,
origin_offsets_rad={
name: float(
result.zero_offsets_rad[
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(name, name)
]
)
for name in sorted(CORRECTED_ACTIVE_JOINTS)
},
corrected_limits_rad={
name: (
0.0,
float(
result.travels_rad[
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(name, name)
]
),
)
for name in sorted(CORRECTED_ACTIVE_JOINTS)
},
mimic_multipliers={
name: float(
result.mimic_fits[
TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.get(name, name)
].urdf_mimic_multiplier
)
for name in sorted(mimic_replacements)
},
coupling_polycoef=coupling_polycoef,
explicit_runtime_joints=explicit_runtime_joints,
)
__all__ = [
"L6UrdfCorrection",
"write_l6_corrected_urdf",
"write_l6_left_from_right_calibration",
]
@@ -0,0 +1,11 @@
"""Registered O12 calibration profiles."""
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import ProfileRegistry
def register_profiles(registry: ProfileRegistry) -> None:
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.profile import build_profile
registry.register(build_profile())
__all__ = ["register_profiles"]
@@ -0,0 +1,495 @@
"""Schema-v7 radian-knot runtime artifact for O12."""
from __future__ import annotations
from dataclasses import asdict
import math
from pathlib import Path
from typing import Any, Mapping, Sequence
import xml.etree.ElementTree as ET
import numpy as np
from scipy.spatial.transform import Rotation
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.fitting import O12FitResult
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.profile import (
ACTIVE_JOINTS,
COMMAND_INDEX_BY_JOINT,
COMMAND_NAMES,
KEY,
MEASURED_PASSIVE_JOINTS,
MIMIC_SOURCE_BY_JOINT,
PASSIVE_JOINTS,
SAFE_LOWER_RAD,
SAFE_UPPER_RAD,
SDK_LOWER_RAD,
SDK_TO_URDF_JOINT,
SDK_TO_URDF_SIGN,
SDK_UPPER_RAD,
STATIC_ZERO_EXCLUDED_JOINTS,
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT,
build_typed_profile,
)
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.urdf import o12_correction_plan
from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.zero import SPATIAL_ZERO_POLICY
ALL_REVOLUTE_JOINTS = frozenset(ACTIVE_JOINTS + PASSIVE_JOINTS)
def _motor_index(joint: str) -> int:
source = str(joint)
while source not in COMMAND_INDEX_BY_JOINT:
source = MIMIC_SOURCE_BY_JOINT[source]
return COMMAND_INDEX_BY_JOINT[source]
def _source_joint_metadata(source_urdf: str | Path) -> dict[str, dict[str, Any]]:
root = ET.parse(Path(source_urdf)).getroot()
result: dict[str, dict[str, Any]] = {}
for node in root.findall("joint"):
name = str(node.get("name", ""))
if name not in ALL_REVOLUTE_JOINTS:
continue
limit = node.find("limit")
if node.get("type") != "revolute" or limit is None:
raise ValueError(f"invalid O12 revolute joint: {name}")
item: dict[str, Any] = {
"lower": float(limit.get("lower", "nan")),
"upper": float(limit.get("upper", "nan")),
}
mimic = node.find("mimic")
if mimic is not None:
item.update({
"source_joint": str(mimic.get("joint", "")),
"multiplier": float(mimic.get("multiplier", "nan")),
"offset": float(mimic.get("offset", "0")),
})
result[name] = item
if set(result) != ALL_REVOLUTE_JOINTS:
raise ValueError("source URDF must contain all 19 O12 revolute joints")
return result
def _round(values: Sequence[float], digits: int = 10) -> list[float]:
array = np.asarray(values, dtype=float)
if array.shape != (65,) or not np.all(np.isfinite(array)):
raise ValueError("O12 knot curve must contain 65 finite values")
return [round(float(value), digits) for value in array]
def build_o12_runtime_payload(
*,
serial_number: str,
source_urdf: str | Path,
result: O12FitResult,
protected_inputs: Mapping[str, str],
passed: bool,
) -> dict[str, Any]:
profile = build_typed_profile()
if result.full_hand_zero_result is not None and not result.full_hand_zero_result.passed:
raise ValueError("O12 full-hand spatial zero validation has not passed")
for name in STATIC_ZERO_EXCLUDED_JOINTS:
if (
not math.isclose(
float(result.zero_offsets_rad.get(name, math.nan)),
0.0,
rel_tol=0.0,
abs_tol=1.0e-12,
)
or result.zero_method_by_joint.get(name)
!= "source_cad_zero_profile_excluded"
):
raise ValueError(
f"O12 {name} must retain immutable source-CAD static zero"
)
plan = o12_correction_plan(source_urdf, result)
curves: dict[str, tuple[np.ndarray, np.ndarray, np.ndarray, list[float]]] = {}
joints: dict[str, dict[str, Any]] = {}
for name in ACTIVE_JOINTS:
donor = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(name, name)
feedback_domain = result.feedback_domains_rad[donor]
mapping = result.output_mappings[name]
inputs = list(mapping.knots)
motor = mapping.motor_index
sign = float(SDK_TO_URDF_SIGN[motor])
decreasing = np.asarray(mapping.decreasing_rad)
increasing = np.asarray(mapping.increasing_rad)
angle = np.asarray(mapping.angle_rad)
if donor != name:
lower, upper = plan.limits_output_rad[name]
values = np.concatenate((angle, decreasing, increasing))
if np.min(values) < lower - 1e-9 or np.max(values) > upper + 1e-9:
raise ValueError(f"transferred curve exceeds recipient physical range: {name}; no clipping is permitted")
curves[name] = (angle, decreasing, increasing, inputs)
joint: dict[str, Any] = {
"urdf_joint": name,
"sdk_channel": COMMAND_NAMES[motor],
"motor_index": motor,
"passive": False,
"calibration_status": (
"transferred_static_dynamic" if donor != name
else "measured_dynamic_cad_static"
if str(result.zero_method_by_joint[name]).startswith("source_cad_zero")
else "measured_static_dynamic"
),
"curve_input_knots_rad": _round(inputs),
"angle_rad": _round(angle),
"decreasing_rad": _round(decreasing),
"increasing_rad": _round(increasing),
"zero_feedback_rad": 0.0,
"measured_feedback_domain_rad": [
round(float(value), 10) for value in feedback_domain
],
"static_urdf_origin_offset_rad": round(
float(result.zero_offsets_rad[name]), 10
),
"static_zero_method": str(result.zero_method_by_joint[name]),
"sdk_to_urdf_direction": sign,
"urdf_sign_adapter": "negate" if sign < 0.0 else "identity",
"runtime_mapping_source": "tag_rotation_over_sdk_feedback_rad",
"visual_arc_diagnostic_rad": round(
float(result.visual_arc_diagnostics_rad[donor]), 10
),
"raw_increasing_curve_branch": "increasing",
}
if donor != name:
joint["transferred_from_joint"] = donor
joint["transfer_policy"] = (
"donor_scalar_mapping_on_own_cad_frame"
)
joint["static_zero_transfer_policy"] = "donor_offset_on_own_cad_frame"
joints[name] = joint
for name in PASSIVE_JOINTS:
source_name, multiplier, offset = plan.mimic_output[name]
motor = _motor_index(source_name)
source_curve, source_dec, source_inc, inputs = curves[source_name]
angle = offset + multiplier * source_curve
decreasing = offset + multiplier * source_dec
increasing = offset + multiplier * source_inc
coefficients = [offset, multiplier, 0.0, 0.0, 0.0, 0.0]
coupling_model = "linear_mimic"
policy = ("visual_standard_linear_mimic" if name in MEASURED_PASSIVE_JOINTS
else "cad_nominal_coordinate_transformed")
status = profile.joint_coverage[name]
curves[name] = (angle, decreasing, increasing, list(inputs))
joint = {
"urdf_joint": name,
"sdk_channel": COMMAND_NAMES[motor],
"motor_index": motor,
"passive": True,
"source_joint": source_name,
"calibration_status": status,
"static_zero_policy": "unobservable_static_offset_retains_cad",
"curve_input_knots_rad": _round(inputs),
"angle_rad": _round(angle),
"decreasing_rad": _round(decreasing),
"increasing_rad": _round(increasing),
"coupling_model": coupling_model,
"coupling_coefficients": [round(float(v), 10) for v in coefficients],
"mimic_multiplier": round(float(multiplier), 10),
"urdf_mimic_policy": policy,
"runtime_mapping_source": (
"exported_standard_urdf_mimic"
),
"raw_increasing_curve_branch": "increasing",
}
if name in MEASURED_PASSIVE_JOINTS:
evidence = result.standard_mimic_evidence[name]
joint.update({
"measured_feedback_domain_rad": list(result.feedback_domains_rad[name]),
"visual_observation_role": "independent_linear_mimic_training_and_holdout",
"observed_coupling_model": "linear_mimic",
"observed_mimic_multiplier": round(evidence.fit.multiplier, 10),
"observed_residual_rms_rad": round(float(np.sqrt(np.mean(np.square(evidence.fit.residuals_rad)))), 10),
"observed_visual_arc_rad": round(float(result.visual_arc_diagnostics_rad[name]), 10),
"linear_mimic_holdout_mae_rad": float(np.mean(np.abs(evidence.holdout_errors_rad))),
"linear_mimic_holdout_max_rad": float(np.max(np.abs(evidence.holdout_errors_rad))),
})
joints[name] = joint
errors = np.abs(np.concatenate([
np.asarray(values, dtype=float)
for values in result.holdout_errors_rad.values()
]))
payload: dict[str, Any] = {
"schema_version": 7,
"profile_id": KEY.profile_id,
"layout_id": KEY.layout,
"model": "O12",
"side": "right",
"serial_number": str(serial_number),
"calibration_scope": "full_dynamic_except_thumb_mcp_static_zero",
"publication_pointer": "latest_passed",
"angle_unit": "rad",
"curve_input_domain": "feedback_rad",
"runtime_curve_policy": "direction_aware_knots",
"command_names": list(COMMAND_NAMES),
"command_lower_rad": list(SAFE_LOWER_RAD),
"command_upper_rad": list(SAFE_UPPER_RAD),
"baseline_command_rad": [0.0] * 12,
"sdk_mapping": [
{
"motor_index": i,
"sdk_channel": command,
"urdf_joint": urdf,
"sdk_lower_rad": SDK_LOWER_RAD[i],
"sdk_upper_rad": SDK_UPPER_RAD[i],
"calibration_lower_rad": SAFE_LOWER_RAD[i],
"calibration_upper_rad": SAFE_UPPER_RAD[i],
}
for i, (command, urdf) in enumerate(zip(COMMAND_NAMES, SDK_TO_URDF_JOINT))
],
"protected_inputs": dict(protected_inputs),
"joints": joints,
"quality": {
"passed": bool(passed),
"training_cycles": [0, 1, 2],
"holdout_cycle": 3,
"validation_mae_rad": round(float(np.mean(errors)), 10),
"validation_p95_rad": round(float(np.percentile(errors, 95.0)), 10),
"validation_max_rad": round(float(np.max(errors)), 10),
"roll_cross_view": {
joint: {
key: round(float(value), 10)
for key, value in metrics.items()
}
for joint, metrics in result.cross_view_roll_metrics.items()
},
"thumb_root_spatial_zero": (
None
if result.thumb_root_zero_result is None
else {
"offsets_rad": {
name: round(float(value), 10)
for name, value in result.thumb_root_zero_result.direct_offsets_rad.items()
},
"validation_mae_rad": round(float(np.mean(np.abs(
result.thumb_root_zero_result.validation_errors_rad
))), 10),
"validation_p95_rad": round(float(np.percentile(np.abs(
result.thumb_root_zero_result.validation_errors_rad
), 95.0)), 10),
"validation_max_rad": round(float(np.max(np.abs(
result.thumb_root_zero_result.validation_errors_rad
))), 10),
"axis_line_rms_m_diagnostic": round(float(
result.thumb_root_zero_result.axis_line_rms_m
), 10),
"method": "roll_yaw_pitch_axes_with_pinky_palm_orientation",
"validation_scope": "root_axis_angles_not_full_chain_position",
}
),
"ring_transfer_source": "pinky_mcp_pitch",
"full_hand_spatial_zero": (
None if result.full_hand_zero_result is None else {
**asdict(result.full_hand_zero_result),
"policy": SPATIAL_ZERO_POLICY,
"validation_scope": "active_zero_axis_geometry_not_fingertip_contact",
}
),
"cad_static_joints_not_measured": [
name for name, method in result.zero_method_by_joint.items()
if str(method).startswith("source_cad_zero")
],
"static_zero_exclusions": {
name: {
"static_origin_source": "immutable_source_cad",
"reason": "downstream_curved_shell_tag_not_absolute_axis_line_datum",
"dynamic_curve_and_holdout_required": True,
}
for name in sorted(STATIC_ZERO_EXCLUDED_JOINTS)
},
"ring_cad_geometry_and_mimic_preserved": True,
"active_angle_source": "tag_rotation_over_sdk_feedback_rad",
"passive_runtime_source": "exported_standard_urdf_mimic",
"visual_measurement_role": "active_curve_and_passive_linear_fit_with_independent_holdout",
},
}
validate_o12_runtime_payload(payload)
return payload
def validate_o12_runtime_payload(payload: Mapping[str, Any]) -> None:
required = {
"schema_version", "profile_id", "layout_id", "model", "side",
"serial_number", "calibration_scope", "publication_pointer",
"angle_unit", "curve_input_domain", "runtime_curve_policy",
"command_names", "command_lower_rad", "command_upper_rad",
"baseline_command_rad", "sdk_mapping", "protected_inputs", "joints",
"quality",
}
if set(payload) != required:
raise ValueError("schema v7 O12 artifact has unexpected top-level fields")
if (
payload["schema_version"] != 7
or payload["profile_id"] != KEY.profile_id
or payload["model"] != "O12"
or payload["side"] != "right"
or payload["angle_unit"] != "rad"
or payload["curve_input_domain"] != "feedback_rad"
or payload["runtime_curve_policy"] != "direction_aware_knots"
):
raise ValueError("schema v7 O12 artifact identity/domain is invalid")
if tuple(payload["command_names"]) != COMMAND_NAMES:
raise ValueError("O12 SDK channel order differs from fixed contract")
if len(payload["sdk_mapping"]) != 12 or any(
int(row.get("motor_index", -1)) != i
or row.get("sdk_channel") != COMMAND_NAMES[i]
or row.get("urdf_joint") != SDK_TO_URDF_JOINT[i]
or float(row.get("sdk_lower_rad", math.nan)) != SDK_LOWER_RAD[i]
or float(row.get("sdk_upper_rad", math.nan)) != SDK_UPPER_RAD[i]
or float(row.get("calibration_lower_rad", math.nan)) != SAFE_LOWER_RAD[i]
or float(row.get("calibration_upper_rad", math.nan)) != SAFE_UPPER_RAD[i]
for i, row in enumerate(payload["sdk_mapping"])
):
raise ValueError("O12 SDK/URDF mapping differs from fixed contract")
protected = payload["protected_inputs"]
expected = build_typed_profile().artifacts.protected_input_fields
if not isinstance(protected, Mapping) or set(protected) != expected:
raise ValueError("O12 protected input hashes are incomplete")
if any(len(str(v)) != 64 or any(c not in "0123456789abcdef" for c in str(v)) for v in protected.values()):
raise ValueError("O12 protected input hash is invalid")
joints = payload["joints"]
if not isinstance(joints, Mapping) or set(joints) != ALL_REVOLUTE_JOINTS:
raise ValueError("schema v7 must contain all 19 O12 revolute joints")
for name, joint in joints.items():
inputs = np.asarray(joint.get("curve_input_knots_rad"), dtype=float)
if inputs.shape != (65,) or not np.all(np.isfinite(inputs)) or np.any(np.diff(inputs) <= 0.0):
raise ValueError(f"{name} has invalid radian knots")
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
values = np.asarray(joint.get(field), dtype=float)
if values.shape != (65,) or not np.all(np.isfinite(values)):
raise ValueError(f"{name}.{field} must contain 65 finite values")
if int(joint.get("motor_index", -1)) != _motor_index(name):
raise ValueError(f"{name} has invalid O12 motor binding")
if joint.get("raw_increasing_curve_branch") not in {"decreasing", "increasing"}:
raise ValueError(f"{name} has invalid O12 direction binding")
for name in ACTIVE_JOINTS:
joint = joints[name]
motor = COMMAND_INDEX_BY_JOINT[name]
sign = float(SDK_TO_URDF_SIGN[motor])
offset = float(joint.get("static_urdf_origin_offset_rad", math.nan))
if not math.isfinite(offset) or abs(offset) > math.radians(20.0):
raise ValueError(f"{name} has invalid O12 static origin offset")
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
differences = np.diff(np.asarray(joint[field], dtype=float))
if np.any(sign * differences < -1.0e-7):
raise ValueError(
f"{name}.{field} disagrees with fixed O12 SDK/URDF direction"
)
for target, donor in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items():
if not math.isclose(
float(joints[target]["static_urdf_origin_offset_rad"]),
float(joints[donor]["static_urdf_origin_offset_rad"]),
rel_tol=0.0, abs_tol=1.0e-9,
):
raise ValueError(f"{target} static zero differs from transfer donor {donor}")
for name in PASSIVE_JOINTS:
joint = joints[name]
source_joint = str(joint.get("source_joint", ""))
if source_joint != MIMIC_SOURCE_BY_JOINT[name]:
raise ValueError(f"{name} has invalid O12 mimic source")
multiplier = float(joint.get("mimic_multiplier", math.nan))
coefficients = np.asarray(
joint.get("coupling_coefficients", ()), dtype=float
)
if coefficients.shape != (6,) or not np.all(np.isfinite(coefficients)):
raise ValueError(f"{name} has invalid O12 CAD mimic coefficients")
if not math.isfinite(multiplier) or multiplier <= 0.0:
raise ValueError(f"{name} has invalid O12 mimic multiplier")
if joint.get("coupling_model") != "linear_mimic" or not np.allclose(
coefficients[1:], [multiplier, 0, 0, 0, 0], rtol=0, atol=2e-9
):
raise ValueError(f"{name} cannot replace standard URDF mimic with a nonlinear JSON model")
if not np.allclose(joint["curve_input_knots_rad"], joints[source_joint]["curve_input_knots_rad"], rtol=0, atol=1e-10):
raise ValueError(f"{name} mimic and source must have the same input knots")
offset = float(coefficients[0])
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
expected_values = offset + multiplier * np.asarray(joints[source_joint][field], dtype=float)
if not np.allclose(np.asarray(joint[field], dtype=float), expected_values, rtol=0, atol=2e-9):
raise ValueError(f"{name}.{field} differs from standard URDF mimic chain")
if not bool(payload["quality"].get("passed")):
raise ValueError("failed O12 calibration cannot be published")
def validate_o12_runtime_payload_against_urdf(
payload: Mapping[str, Any], urdf: str | Path,
*, source_urdf: str | Path | None = None,
) -> None:
"""Check curve bounds/mimics and, with original CAD, actual static frames.
This verifies the artifact contract, not full-hand physical accuracy.
Passive runtime values must equal the actual exported linear URDF mimic.
"""
validate_o12_runtime_payload(payload)
metadata = _source_joint_metadata(urdf)
joints = payload["joints"]
for name, joint in joints.items():
expected = metadata[name]
for field in ("angle_rad", "decreasing_rad", "increasing_rad"):
values = np.asarray(joint[field], dtype=float)
if bool(joint.get("passive")):
source_name = str(expected["source_joint"])
if source_name != str(joint["source_joint"]):
raise ValueError(
f"{name} corrected URDF mimic source differs from payload"
)
if not math.isclose(
float(expected["multiplier"]),
float(joint["mimic_multiplier"]),
rel_tol=0.0,
abs_tol=2.0e-9,
):
raise ValueError(
f"{name} corrected URDF mimic differs from payload"
)
source_values = np.asarray(joints[source_name][field], dtype=float)
urdf_values = float(expected["offset"]) + float(expected["multiplier"]) * source_values
if not np.allclose(values, urdf_values, rtol=0, atol=2e-9):
raise ValueError(f"{name}.{field} differs from exported standard URDF mimic")
if (
float(np.min(values))
< float(expected["lower"]) - 2.0e-9
):
raise ValueError(f"{name}.{field} is below the URDF physical limit")
if (
float(np.max(values))
> float(expected["upper"]) + 2.0e-9
):
raise ValueError(f"{name}.{field} is above the URDF physical limit")
if source_urdf is not None:
original = ET.parse(source_urdf).getroot()
corrected = ET.parse(urdf).getroot()
for name in ACTIVE_JOINTS:
before = original.find(f"joint[@name='{name}']")
after = corrected.find(f"joint[@name='{name}']")
if before is None or after is None:
raise ValueError(f"{name} missing from static-frame validation")
b_origin, a_origin = before.find("origin"), after.find("origin")
if b_origin is None or a_origin is None:
raise ValueError(f"{name} missing static origin")
if b_origin.get("xyz") != a_origin.get("xyz"):
raise ValueError(f"{name} static origin translation changed")
axis_node = before.find("axis")
if axis_node is None:
raise ValueError(f"{name} missing CAD axis")
axis = np.asarray([float(v) for v in axis_node.get("xyz").split()])
axis /= np.linalg.norm(axis)
b_rot = Rotation.from_euler("xyz", [float(v) for v in b_origin.get("rpy", "0 0 0").split()])
a_rot = Rotation.from_euler("xyz", [float(v) for v in a_origin.get("rpy", "0 0 0").split()])
expected_rot = b_rot * Rotation.from_rotvec(
axis * float(joints[name]["static_urdf_origin_offset_rad"])
)
if (expected_rot.inv() * a_rot).magnitude() > 1.0e-8:
raise ValueError(f"{name} static origin disagrees with payload")
__all__ = [
"ALL_REVOLUTE_JOINTS",
"build_o12_runtime_payload",
"validate_o12_runtime_payload",
"validate_o12_runtime_payload_against_urdf",
]
@@ -0,0 +1,38 @@
"""Legacy result types plus a direct call to the ONE profile fitter.
Old endpoint/range-centre fallbacks and model-specific refitting are retired.
The legacy result type remains only for reading archived JSON/tool inputs.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Mapping
from linkerhand_calibration.core.domain.measurement import JointCurveFit
from linkerhand_calibration.core.domain.result import JointMapping
from linkerhand_calibration.core.fitting import CouplingFit as MimicFit, fit_coupling_model, curve_travel_rad
from linkerhand_calibration.core.fitting.coupling import LinearMimicFit, RelativeMimicEvidence
from linkerhand_calibration.core.fitting.spatial import ZeroSolveResult
from linkerhand_calibration.core.fitting.session import fit_profile_calibration, compile_spatial_profile
from linkerhand_calibration.profiles import load_bundled_hand_profile
@dataclass(frozen=True)
class O12FitResult:
curves: Mapping[str, JointCurveFit]
zero_offsets_rad: Mapping[str, float]
travels_rad: Mapping[str, float]
mimic_fits: Mapping[str, LinearMimicFit]
holdout_errors_rad: Mapping[str, tuple[float, ...]]
cross_view_roll_metrics: Mapping[str, Mapping[str, float]]
visual_arc_diagnostics_rad: Mapping[str, float]
feedback_domains_rad: Mapping[str, tuple[float, float]]
zero_method_by_joint: Mapping[str, str]
thumb_root_zero_result: ZeroSolveResult | None
full_hand_zero_result: ZeroSolveResult | None = None
standard_mimic_evidence: Mapping[str, RelativeMimicEvidence] = field(default_factory=dict)
output_mappings: Mapping[str, JointMapping] = field(default_factory=dict)
def fit_o12_session(source_urdf, records_by_joint, *, cross_view_records_by_joint=None, require_cross_view=False, require_thumb_root_spatial_zero=False, require_full_hand_spatial_zero=False):
# Historical flags can no longer disable spatial or holdout verification.
return fit_profile_calibration(load_bundled_hand_profile("o12_right_16"),
Path(source_urdf), records_by_joint, cross_view_records=cross_view_records_by_joint)

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