Compare commits
32 Commits
master
...
a8ddcc6296
| Author | SHA1 | Date | |
|---|---|---|---|
| a8ddcc6296 | |||
| 2356bd6247 | |||
| 889e0ea8db | |||
| e1fb458eff | |||
| 8de69c34a1 | |||
| d6b7bd6209 | |||
| 8a749a3687 | |||
| 08fe190b3a | |||
| f7aeef87a8 | |||
| 2b7c1f92e7 | |||
| 1ed36ecdd8 | |||
| 7f84225ba8 | |||
| ba9f1b25e8 | |||
| 0d606c2ba2 | |||
| 06c050e446 | |||
| 286581bcba | |||
| 4dadfb954b | |||
| 83c69b48c2 | |||
| 4e594ddb09 | |||
| ef65681230 | |||
| a609d521a0 | |||
| 41ff4a61a9 | |||
| 4107da4c22 | |||
| 5d206bcb73 | |||
| 05634f5472 | |||
| ce9d0129b9 | |||
| 9210373fb2 | |||
| 44975620a7 | |||
| 0d92e5f998 | |||
| fc7c66d30e | |||
| b7cf448a4d | |||
| 1bec806c6e |
+39
-1
@@ -50,12 +50,50 @@ Thumbs.db
|
|||||||
|
|
||||||
# Runtime and calibration scratch files
|
# Runtime and calibration scratch files
|
||||||
/logs/
|
/logs/
|
||||||
|
/MvSdkLog/
|
||||||
*.tmp
|
*.tmp
|
||||||
*.log
|
*.log
|
||||||
|
*.bak
|
||||||
|
*.orig
|
||||||
|
*.rej
|
||||||
|
|
||||||
|
# Operator/device-specific calibration artifacts
|
||||||
|
# Reproducible seed profiles remain under
|
||||||
|
# src/linkerhand_retarget/resource/linkerforce_v2/profiles/.
|
||||||
|
/profiles/
|
||||||
|
/calibration_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/*/*_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
|
# ROS bag / MCAP recordings and CAN captures
|
||||||
rosbag2_*/
|
rosbag2_*/
|
||||||
|
/bags/
|
||||||
|
/recordings/
|
||||||
|
/captures/
|
||||||
|
/sessions/
|
||||||
|
/reports/
|
||||||
*.db3
|
*.db3
|
||||||
*.mcap
|
*.mcap
|
||||||
candump-*
|
candump-*
|
||||||
l10_*_state_*/
|
*_state_*/
|
||||||
|
|
||||||
|
# Local Codex/agent workspace metadata
|
||||||
|
/.agents/
|
||||||
|
/.codex/
|
||||||
|
/.codebuddy/
|
||||||
|
/.zcode/
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,8 @@
|
|||||||
# 1. LinkerFFG手套
|
# 1. LinkerFFG手套
|
||||||
|
|
||||||
|
> 当前FFG多手势标定、G20/O6 profile映射及实机操作请优先参考:
|
||||||
|
> [FFG多手势标定映射与遥操操作说明](docs/FFG多手势标定映射与遥操操作说明.md)。
|
||||||
|
|
||||||
## 1.1 产品介绍
|
## 1.1 产品介绍
|
||||||
本产品的具体介绍参考,内含标定示例说明
|
本产品的具体介绍参考,内含标定示例说明
|
||||||
附件1、Linker FFG(FFG01)产品说明手册
|
附件1、Linker FFG(FFG01)产品说明手册
|
||||||
@@ -416,4 +419,4 @@ if self.calibrationoriginal is not None and self.calibrationfistpose is not None
|
|||||||
|
|
||||||
改写成如上图的示例,即可启用右机械手的校准,启动后就会让机械手按照映射的角度固定在当前角度
|
改写成如上图的示例,即可启用右机械手的校准,启动后就会让机械手按照映射的角度固定在当前角度
|
||||||
|
|
||||||
当左右两手都达到期望的对指位置后,就可以恢复原状,按正常顺序使用遥操系统
|
当左右两手都达到期望的对指位置后,就可以恢复原状,按正常顺序使用遥操系统
|
||||||
|
|||||||
@@ -0,0 +1,985 @@
|
|||||||
|
# FFG多手势标定映射与遥操技术实现
|
||||||
|
|
||||||
|
## 1. 文档定位
|
||||||
|
|
||||||
|
本文面向 `linkerforce_v2` 的开发、联调和维护人员,说明新版FFG手套映射遥操链路的
|
||||||
|
软件架构、标定拟合方法、实时映射算法、ROS 2接口、profile约束和安全门控。
|
||||||
|
|
||||||
|
实机标定与启动步骤见
|
||||||
|
[《FFG多手势标定映射与遥操操作说明》](./FFG多手势标定映射与遥操操作说明.md)。
|
||||||
|
本文不重复完整操作流程,而是回答以下实现问题:
|
||||||
|
|
||||||
|
- 21维FFG数据如何变成模型无关的手部语义;
|
||||||
|
- 张手、桌面、钩拳、握拳四个锚点如何解耦根部和末端屈伸;
|
||||||
|
- G20和O6如何共用手套语义、同时保持各自的机械执行标尺;
|
||||||
|
- 捏合与握持为什么不会把整只手锁定到离散模板;
|
||||||
|
- `cmd_u8`、`actuation_target`、`q_nominal`三类目标有什么区别;
|
||||||
|
- profile如何生成、校验、配对和追踪;
|
||||||
|
- 节点在什么条件下允许或撤销实机控制。
|
||||||
|
|
||||||
|
当前实现基于:
|
||||||
|
|
||||||
|
- profile schema:`schema_version=1`;
|
||||||
|
- 手套侧:单只左手FFG,21维输入;
|
||||||
|
- 机械手侧:左手G20和左手O6;
|
||||||
|
- 映射模式:`factorized_paired_v2`;
|
||||||
|
- 机械手profile策略:`paired_continuous_v1`;
|
||||||
|
- 仿真策略:`semantic_urdf_v1`;
|
||||||
|
- 标定等级:`provisional`,没有真实关节角GT。
|
||||||
|
|
||||||
|
## 2. 代码组织
|
||||||
|
|
||||||
|
核心实现位于
|
||||||
|
[`linkerforce_v2`](../src/linkerhand_retarget/linkerhand_retarget/motion/linkerforce_v2/):
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `constants.py` | FFG关节名、手部语义名、静态/动态手势集合和默认参数 |
|
||||||
|
| `calibrate_glove.py` | 订阅FFG原始话题,交互采集完整标定和快速佩戴检查 |
|
||||||
|
| `calibrate_robot.py` | 从GUI命令、快照和SDK状态生成G20/O6实机profile |
|
||||||
|
| `calibration.py` | 鲁棒统计、FFG特征拟合、机械手通道权重和分段曲线拟合 |
|
||||||
|
| `profiles.py` | profile加载、严格校验、规范化哈希和原子保存 |
|
||||||
|
| `mapping.py` | 人手语义提取、连续配对映射、捏合/握持修正和命令滤波 |
|
||||||
|
| `node.py` | ROS 2实时节点、话题、服务、定时循环和安全门控 |
|
||||||
|
| `safety.py` | 与ROS无关的超时撤权判定 |
|
||||||
|
| `simulation.py` | 按关节名重排仿真目标并执行限位检查 |
|
||||||
|
| `quality.py` | 静态、捏合、动态轨迹的离线回放质量检查 |
|
||||||
|
| `verify_robot_profile.py` | 低速回放机械手标定姿势并生成独立人工复核报告 |
|
||||||
|
| `session_manifest.py` | 生成provisional数采清单并绑定profile、URDF和设备信息 |
|
||||||
|
|
||||||
|
ROS 2入口在
|
||||||
|
[`setup.py`](../src/linkerhand_retarget/setup.py),双手机型启动文件为
|
||||||
|
[`ffg_dual_g20_o6.launch.py`](../src/linkerhand_retarget/launch/ffg_dual_g20_o6.launch.py),
|
||||||
|
可提交的机械手种子profile位于
|
||||||
|
[`resource/linkerforce_v2/profiles`](../src/linkerhand_retarget/resource/linkerforce_v2/profiles/)。
|
||||||
|
|
||||||
|
## 3. 总体架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
FFG串口 / ROS JointState│ 21维左手套原始弧度 raw[21] │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
│ 可选逐维Kalman
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ HandIntentExtractor │
|
||||||
|
│ 21维 → 22维0~1人手语义 │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
│ 同一hand_intent
|
||||||
|
┌───────────────────┴───────────────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────────┐ ┌───────────────────┐
|
||||||
|
│ G20 RobotMapper │ │ O6 RobotMapper │
|
||||||
|
│ 16个主动执行语义 │ │ 6个主动执行语义 │
|
||||||
|
└─────────┬─────────┘ └─────────┬─────────┘
|
||||||
|
│ │
|
||||||
|
┌─────────┴─────────┐ ┌─────────┴─────────┐
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
20维cmd_u8 16维q_nominal 6维cmd_u8 6维q_nominal
|
||||||
|
G20实机电机空间 G20仿真弧度目标 O6实机电机空间 O6仿真弧度目标
|
||||||
|
```
|
||||||
|
|
||||||
|
架构的关键是分成三层:
|
||||||
|
|
||||||
|
1. **传感器层**:FFG原始值和佩戴差异由手套profile吸收;
|
||||||
|
2. **解剖语义层**:`hand_intent`只描述人的手部动作,不依赖G20或O6;
|
||||||
|
3. **执行器层**:每个机械手profile独立定义语义到本机电机命令和URDF目标的映射。
|
||||||
|
|
||||||
|
因此,换机械手通常不需要改手套语义提取器;换手套或操作者也不需要改G20/O6的
|
||||||
|
通道定义,只需重新建立对应profile并在运行时配对。
|
||||||
|
|
||||||
|
## 4. 数据契约
|
||||||
|
|
||||||
|
### 4.1 FFG 21维原始数据
|
||||||
|
|
||||||
|
原始输入使用 `sensor_msgs/msg/JointState`。名称和规范顺序为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
thumb_0 ... thumb_4
|
||||||
|
index_0 ... index_3
|
||||||
|
middle_0 ... middle_3
|
||||||
|
ring_0 ... ring_3
|
||||||
|
pinky_0 ... pinky_3
|
||||||
|
```
|
||||||
|
|
||||||
|
串口解析器将设备角度转换为弧度。实时节点只接受长度为21、全部有限的数据。
|
||||||
|
|
||||||
|
- 串口模式直接使用上述顺序;
|
||||||
|
- topic模式允许输入名称顺序不同,但要求名称集合完整且唯一,节点按规范顺序重排;
|
||||||
|
- 标定工具要求输入消息已经使用规范顺序;
|
||||||
|
- 不支持右手FFG,也不会因右手套缺失而退出。
|
||||||
|
|
||||||
|
普通四指每指4个原始量:第0维主要用于侧摆,第1~3维共同参与根部和末端屈伸拟合。
|
||||||
|
拇指5个原始量由不同组合共同拟合旋转、外展、对掌、根部屈伸和末端屈伸。
|
||||||
|
|
||||||
|
### 4.2 人手语义
|
||||||
|
|
||||||
|
`hand_intent`共22维,所有值限制在 `[0, 1]`:
|
||||||
|
|
||||||
|
| 类别 | 名称 |
|
||||||
|
|---|---|
|
||||||
|
| 拇指 | `thumb_rotate`、`thumb_abduction`、`thumb_opposition`、`thumb_root`、`thumb_tip` |
|
||||||
|
| 四指屈伸 | 每指的 `<finger>_root`、`<finger>_tip` |
|
||||||
|
| 四指侧摆 | 每指的 `<finger>_splay` |
|
||||||
|
| 捏合证据 | `pinch_index`、`pinch_middle`、`pinch_ring`、`pinch_pinky` |
|
||||||
|
| 整体握持 | `power_grasp` |
|
||||||
|
|
||||||
|
这里的0和1是由个人手套标定定义的语义端点,不是机械手角度,也不代表统一的物理角度。
|
||||||
|
|
||||||
|
### 4.3 G20命令空间
|
||||||
|
|
||||||
|
G20输出完整20维 `cmd_u8`:
|
||||||
|
|
||||||
|
| 下标 | 通道 |
|
||||||
|
|---:|---|
|
||||||
|
| 0 | `thumb_cmc_pitch` |
|
||||||
|
| 1~4 | `index/middle/ring/pinky_mcp_pitch` |
|
||||||
|
| 5 | `thumb_cmc_roll` |
|
||||||
|
| 6~9 | `index/middle/ring/pinky_mcp_roll` |
|
||||||
|
| 10 | `thumb_cmc_yaw` |
|
||||||
|
| 11~14 | `reserved_11`~`reserved_14`,固定为255 |
|
||||||
|
| 15 | `thumb_mcp` |
|
||||||
|
| 16~19 | `index/middle/ring/pinky_pip` |
|
||||||
|
|
||||||
|
其中16个通道是主动映射通道,4个保留通道不参与映射。新版种子profile中
|
||||||
|
`thumb_cmc_yaw`使用完整的 `[0, 255]` 命令范围,不再继承旧版的80下限。
|
||||||
|
|
||||||
|
### 4.4 O6命令空间
|
||||||
|
|
||||||
|
O6输出6维 `cmd_u8`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
thumb_cmc_pitch
|
||||||
|
thumb_cmc_yaw
|
||||||
|
index_mcp_pitch
|
||||||
|
middle_mcp_pitch
|
||||||
|
ring_mcp_pitch
|
||||||
|
pinky_mcp_pitch
|
||||||
|
```
|
||||||
|
|
||||||
|
O6没有独立的四指PIP和侧摆执行通道,因此每个普通手指的单一屈伸通道由
|
||||||
|
`root`和`tip`语义融合得到。
|
||||||
|
|
||||||
|
### 4.5 三种输出标尺
|
||||||
|
|
||||||
|
| 输出 | 范围/单位 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| `actuation_target` | `[0,1]` | 当前型号各主动通道的归一化语义激活量 |
|
||||||
|
| `cmd_u8_preview` / 实机命令 | `[0,255]` | 设备电机命令空间,包含机械耦合和本机标定 |
|
||||||
|
| `joint_target_nominal` | rad | 根据语义激活量和URDF名义端点生成的仿真目标 |
|
||||||
|
|
||||||
|
`state_u8`是SDK返回的设备状态,仍属于设备空间。它既不是编码器关节角,也不能作为
|
||||||
|
`q_nominal`或真实物理关节角的GT。
|
||||||
|
|
||||||
|
## 5. FFG手套profile的生成
|
||||||
|
|
||||||
|
### 5.1 鲁棒采样统计
|
||||||
|
|
||||||
|
每次采集保留:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sample_count
|
||||||
|
median[21]
|
||||||
|
mad[21]
|
||||||
|
raw_frames[N][21]
|
||||||
|
```
|
||||||
|
|
||||||
|
对第 `j` 维:
|
||||||
|
|
||||||
|
```text
|
||||||
|
median_j = median(raw[:, j])
|
||||||
|
MAD_j = median(abs(raw[:, j] - median_j))
|
||||||
|
```
|
||||||
|
|
||||||
|
每个静态姿势和动态轨迹还保存3次独立重复的上述统计。`approved_for_runtime=true`
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 11个静态姿势全部存在;
|
||||||
|
- 7个动态轨迹全部存在;
|
||||||
|
- 每项恰好3次重复;
|
||||||
|
- 每次至少50帧;
|
||||||
|
- 汇总帧数等于3次重复的帧数之和。
|
||||||
|
|
||||||
|
因此,CLI虽然允许修改静态 `--repeats`,但不是3次时生成的profile只能用于预览。
|
||||||
|
|
||||||
|
### 5.2 基础语义特征拟合
|
||||||
|
|
||||||
|
每个语义特征定义一组原始下标和带标签的标定姿势。以某个特征为例:
|
||||||
|
|
||||||
|
1. 从语义标签为0的姿势求原始端点 `low`;
|
||||||
|
2. 从语义标签为1的姿势求原始端点 `high`;
|
||||||
|
3. 将各标定姿势归一化为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
n_j = clip((raw[index_j] - low_j) / (high_j - low_j), 0, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 用最小二乘拟合各原始维度对语义标签的贡献;
|
||||||
|
5. 将负权重截为0,再归一化为权重和1;
|
||||||
|
6. 运行时计算:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feature = clip(sum(weight_j * n_j), 0, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
无有效跨度的维度不参与归一化。若拟合后所有权重都接近0,则回退为等权。
|
||||||
|
|
||||||
|
普通四指的 `root`和`tip`故意使用同一组3个屈伸传感器,但使用不同姿势标签:
|
||||||
|
|
||||||
|
| 姿势 | root目标 | tip目标 |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 张手/并拢 | 0 | 0 |
|
||||||
|
| 桌面 | 1 | 0 |
|
||||||
|
| 钩拳 | 0 | 1 |
|
||||||
|
| 握拳 | 1 | 1 |
|
||||||
|
|
||||||
|
这一设计先得到两个可能仍有耦合的初始特征,再由下一步二维标定面解耦。
|
||||||
|
|
||||||
|
### 5.3 根部—末端双线性解耦
|
||||||
|
|
||||||
|
对每个普通手指,在初始 `(root_feature, tip_feature)` 平面中取得四个锚点:
|
||||||
|
|
||||||
|
```text
|
||||||
|
p00 = 张手
|
||||||
|
p10 = 桌面
|
||||||
|
p01 = 钩拳
|
||||||
|
p11 = 握拳
|
||||||
|
```
|
||||||
|
|
||||||
|
建立双线性标定面:
|
||||||
|
|
||||||
|
```text
|
||||||
|
p(u, v) = p00
|
||||||
|
+ (p10 - p00) * u
|
||||||
|
+ (p01 - p00) * v
|
||||||
|
+ (p11 - p10 - p01 + p00) * u * v
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 `u`是解耦后的根部屈伸,`v`是解耦后的末端屈伸。运行时先用线性最小二乘得到
|
||||||
|
初值,再执行最多5次Newton迭代反解 `(u, v)`,最后限制到 `[0,1]`。
|
||||||
|
|
||||||
|
只有标定四边形在四角的Jacobian行列式符号一致,且最小绝对值不小于 `1e-3` 时才启用
|
||||||
|
该解码器。退化或发生折叠的标定面不会用于反解,此时保留基础特征结果。
|
||||||
|
|
||||||
|
### 5.4 动态屈伸对侧摆的串扰补偿
|
||||||
|
|
||||||
|
每个普通手指的独立屈伸往返轨迹假设该手指侧摆应基本不变。对每次重复:
|
||||||
|
|
||||||
|
```text
|
||||||
|
x = 0.5 * (root + tip)
|
||||||
|
y = splay
|
||||||
|
x, y分别减去各自中位数
|
||||||
|
coefficient = dot(x, y) / (dot(x, x) + ridge)
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 `ridge = 1e-3 * max(dot(x,x), 1e-6)`。
|
||||||
|
|
||||||
|
以下情况拒绝学习该次轨迹:
|
||||||
|
|
||||||
|
- 少于10帧、长度错误或存在非有限值;
|
||||||
|
- 屈伸变化范围小于0.05;
|
||||||
|
- 侧摆几乎没有变化,无法估计;
|
||||||
|
- 补偿后残差方差仍大于原方差的80%;
|
||||||
|
- 三次重复的有效系数方向互相矛盾。
|
||||||
|
|
||||||
|
最终系数取各有效重复的中位数并限制到 `[-1,1]`,运行时执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
splay_corrected = clip(
|
||||||
|
splay - coefficient * 0.5 * root
|
||||||
|
- coefficient * 0.5 * tip,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
补偿只发生在人手语义层,不直接学习或修改任何G20/O6电机系数。
|
||||||
|
|
||||||
|
### 5.5 捏合证据
|
||||||
|
|
||||||
|
每种捏合只使用拇指5维和目标手指4维。标定时保存:
|
||||||
|
|
||||||
|
```text
|
||||||
|
center = 目标捏合姿势中位数
|
||||||
|
scale = max(abs(center - open), 6 * pinch_pose_MAD, 0.02)
|
||||||
|
```
|
||||||
|
|
||||||
|
并计算张手到捏合中心的归一化距离 `open_distance`。运行时:
|
||||||
|
|
||||||
|
```text
|
||||||
|
distance = RMS((raw_selected - center) / scale)
|
||||||
|
pinch_strength = clip(1 - distance / open_distance, 0, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
这4个值是候选证据,不直接等于4个离散状态;最终是否施加捏合修正还要经过竞争门控。
|
||||||
|
|
||||||
|
### 5.6 快速佩戴检查
|
||||||
|
|
||||||
|
快速检查重新采集张手、握拳和食指捏合。每个姿势计算:
|
||||||
|
|
||||||
|
```text
|
||||||
|
normalized_error =
|
||||||
|
RMS((observed_median - reference_median) / max(6 * MAD, 0.05))
|
||||||
|
```
|
||||||
|
|
||||||
|
默认要求每个误差不大于4.0。凭据保存当前手套profile的规范化SHA-256、检查时间、
|
||||||
|
阈值、各姿势误差和通过状态。
|
||||||
|
|
||||||
|
实时节点只在加载profile时检查凭据:
|
||||||
|
|
||||||
|
- `kind=ffg_wear_check`;
|
||||||
|
- `passed=true`;
|
||||||
|
- 绑定哈希等于当前手套profile哈希;
|
||||||
|
- 凭据年龄在配置范围内,默认12小时。
|
||||||
|
|
||||||
|
节点不会在长时间运行期间周期性重新读取凭据或重新计算年龄。需要跨时段运行时,应按
|
||||||
|
作业流程主动重启节点并重新执行佩戴检查。
|
||||||
|
|
||||||
|
## 6. 机械手profile的生成
|
||||||
|
|
||||||
|
### 6.1 捕获数据
|
||||||
|
|
||||||
|
`hand_pose_capture`同时监听:
|
||||||
|
|
||||||
|
- GUI连续命令 `/<model>/cb_left_hand_control_cmd`;
|
||||||
|
- SDK状态 `/<model>/cb_left_hand_state`;
|
||||||
|
- GUI保存快照 `/<model>/calibration_pose_snapshot`。
|
||||||
|
|
||||||
|
消息名称允许任意顺序,但必须与seed中的 `command_names`集合完全一致;保存前统一重排为
|
||||||
|
profile顺序。每个姿势保存:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cmd_u8
|
||||||
|
command_names
|
||||||
|
state_u8
|
||||||
|
state_names
|
||||||
|
status = exact | approximate | unsupported
|
||||||
|
confirmed
|
||||||
|
captured_at
|
||||||
|
```
|
||||||
|
|
||||||
|
每次人工确认后立即原子写入checkpoint。恢复时会核对seed哈希、型号、输出路径、
|
||||||
|
SN、固件、CAN、操作者、命令名和姿势列表;身份不一致时拒绝续标。若旧checkpoint中
|
||||||
|
某个命令超出新的安全范围,只删除该姿势并要求重拍。
|
||||||
|
|
||||||
|
最终 `approved_for_control=true` 同时要求:
|
||||||
|
|
||||||
|
- 用户在最后明确批准;
|
||||||
|
- SN、CAN和操作者非空;
|
||||||
|
- 所有必需姿势均已确认;
|
||||||
|
- 命令与状态名称完整;
|
||||||
|
- 所有命令位于profile安全范围。
|
||||||
|
|
||||||
|
`unsupported`表示该姿势不参与对应运行时约束,但该姿势记录本身仍需人工确认并保存。
|
||||||
|
|
||||||
|
### 6.2 多源执行通道权重
|
||||||
|
|
||||||
|
一个机械手主动通道可以融合多个人手语义。对seed中列出的 `fit_sources`,使用
|
||||||
|
张手、桌面、钩拳和握拳的实机命令拟合。
|
||||||
|
|
||||||
|
先以张手和握拳命令归一化该通道:
|
||||||
|
|
||||||
|
```text
|
||||||
|
y_pose = (cmd_pose - cmd_open) / (cmd_fist - cmd_open)
|
||||||
|
```
|
||||||
|
|
||||||
|
设计矩阵来自各姿势的规范语义目标,然后执行最小二乘;负权重截为0并归一化。
|
||||||
|
|
||||||
|
- G20大部分主动通道只有一个语义源;
|
||||||
|
- O6普通手指通道同时使用对应的 `root`和`tip`,权重由实机捕获结果拟合;
|
||||||
|
- 若张手和握拳命令跨度退化,回退为等权。
|
||||||
|
|
||||||
|
### 6.3 分段曲线与单调约束
|
||||||
|
|
||||||
|
profile生成时,根据通道语义激活量和各标定姿势的 `cmd_u8`产生曲线点。
|
||||||
|
|
||||||
|
- `piecewise`:同一激活量的命令取中位数,然后按激活量排序;
|
||||||
|
- `monotonic_piecewise`:在上述基础上使用相邻违例合并算法执行等距单调回归;
|
||||||
|
- 曲线至少需要两个不同的激活量;
|
||||||
|
- 命令点必须位于 `[0,255]`和该通道 `command_bounds`内。
|
||||||
|
|
||||||
|
该profile曲线是一条可独立验证和追踪的型号级基线,也是在运行时手套锚点退化时的
|
||||||
|
单通道回退曲线。
|
||||||
|
|
||||||
|
## 7. 运行时分解式配对映射
|
||||||
|
|
||||||
|
### 7.1 配对曲线构造
|
||||||
|
|
||||||
|
`RobotMapper`同时收到手套profile和机械手profile时,不直接使用抽象规范姿势坐标,
|
||||||
|
而是:
|
||||||
|
|
||||||
|
1. 用当前手套profile的静态姿势中位数重新计算真实 `hand_intent`;
|
||||||
|
2. 找出手套和机械手共有且未标为 `unsupported` 的姿势;
|
||||||
|
3. 对每个机械手主动通道,选择真正定义该解剖通道的姿势;
|
||||||
|
4. 以当前手套语义激活量为横轴、当前实机profile命令为纵轴重建分段曲线;
|
||||||
|
5. 对单调通道再次执行单调回归。
|
||||||
|
|
||||||
|
姿势选择规则为:
|
||||||
|
|
||||||
|
| 通道 | 使用的基础姿势 |
|
||||||
|
|---|---|
|
||||||
|
| 普通四指屈伸 | 张手、并拢、桌面、钩拳、握拳中双方共有的姿势 |
|
||||||
|
| G20普通四指侧摆 | 并拢、张手 |
|
||||||
|
| 拇指基础通道 | 最大外展、张手、横跨掌心 |
|
||||||
|
|
||||||
|
捏合姿势不进入普通通道曲线,握拳也不直接进入拇指基础曲线;它们分别由局部残差分支
|
||||||
|
处理。这样,某个捏合捕获中的非目标手指残留命令不会污染普通手指曲线。
|
||||||
|
|
||||||
|
如果某个通道的实际手套锚点退化为少于两个不同激活量,该通道使用机械手profile中
|
||||||
|
已经校验的曲线;其他通道仍可保持配对曲线。
|
||||||
|
|
||||||
|
完成构造后,`mapping_mode`为 `factorized_paired_v2`。
|
||||||
|
|
||||||
|
### 7.2 基础通道映射
|
||||||
|
|
||||||
|
对第 `k` 个主动通道,其来源权重为 `w_ki`,当前人手语义为 `h_i`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
a_k = clip(sum(w_ki * h_i) / sum(w_ki), 0, 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
`a_k`组成 `actuation_target`。基础电机命令由该通道配对曲线分段线性插值得到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cmd_base[index_k] = piecewise_linear(a_k, paired_points_k)
|
||||||
|
```
|
||||||
|
|
||||||
|
完整命令向量先以张手命令初始化,主动通道逐个覆盖;未映射的保留通道之后强制写回
|
||||||
|
固定值。
|
||||||
|
|
||||||
|
### 7.3 竞争式局部捏合修正
|
||||||
|
|
||||||
|
#### 7.3.1 标定自适应阈值
|
||||||
|
|
||||||
|
映射器先对手套profile中的所有静态姿势计算4种捏合分数。对每个真实捏合姿势记录:
|
||||||
|
|
||||||
|
- 目标分数;
|
||||||
|
- 目标分数相对其他3种分数的领先量。
|
||||||
|
|
||||||
|
对所有非捏合姿势记录:
|
||||||
|
|
||||||
|
- 最大误触分数;
|
||||||
|
- 第一名相对第二名的误触领先量。
|
||||||
|
|
||||||
|
满激活阈值取4个目标姿势中的最弱值,起始阈值位于最大负样本与满激活阈值之间的20%:
|
||||||
|
|
||||||
|
```text
|
||||||
|
score_onset = negative_score + 0.2 * (score_full - negative_score)
|
||||||
|
margin_onset = negative_margin + 0.2 * (margin_full - negative_margin)
|
||||||
|
```
|
||||||
|
|
||||||
|
若当前手套profile无法在正负样本间形成有效分数或领先量间隔,所有捏合门均保持0。
|
||||||
|
|
||||||
|
#### 7.3.2 单赢家连续门控
|
||||||
|
|
||||||
|
运行时仅选择当前分数最高的候选,并计算:
|
||||||
|
|
||||||
|
```text
|
||||||
|
score_gate = smoothstep((top_score - score_onset) / score_span)
|
||||||
|
margin_gate = smoothstep((top_score - second_score - margin_onset) / margin_span)
|
||||||
|
pinch_gate = score_gate * margin_gate
|
||||||
|
```
|
||||||
|
|
||||||
|
`smoothstep(x)=x²(3-2x)`,输入先限制到 `[0,1]`。其他3种捏合门为0。
|
||||||
|
|
||||||
|
因此:
|
||||||
|
|
||||||
|
- 证据不足时保持普通连续映射;
|
||||||
|
- 两种捏合证据接近时,领先量门控将修正降到0;
|
||||||
|
- 不存在确认帧数、进入/退出滞回或历史姿势锁存;
|
||||||
|
- 捏合切换只依赖当前帧,且权重连续变化。
|
||||||
|
|
||||||
|
#### 7.3.3 局部残差
|
||||||
|
|
||||||
|
对每种捏合,在该手套捏合中位数处先计算基础命令,再与机械手目标捏合命令做差:
|
||||||
|
|
||||||
|
```text
|
||||||
|
residual = robot_pinch_target - base_command_at_glove_pinch
|
||||||
|
```
|
||||||
|
|
||||||
|
运行时只把 `pinch_gate * residual`加到:
|
||||||
|
|
||||||
|
- 所有拇指主动通道;
|
||||||
|
- 当前目标手指的主动通道。
|
||||||
|
|
||||||
|
其他3根手指不参与该分支。G20的保留通道也不参与。
|
||||||
|
|
||||||
|
### 7.4 握持时的拇指协调
|
||||||
|
|
||||||
|
`power_grasp`是8个普通四指 `root/tip`语义的平均值。握持分数进一步要求拇指主动折叠:
|
||||||
|
|
||||||
|
```text
|
||||||
|
grasp_score = min(
|
||||||
|
power_grasp,
|
||||||
|
thumb_opposition,
|
||||||
|
thumb_root,
|
||||||
|
thumb_tip
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
满分取手套握拳姿势,负样本取其他静态姿势的最高分,门控同样使用从负样本到握拳分数
|
||||||
|
20%处开始的 `smoothstep`。
|
||||||
|
|
||||||
|
握持残差是机械手握拳目标与握拳处基础命令的差,但只施加到拇指主动通道。四指仍由
|
||||||
|
各自连续屈伸曲线决定,普通拇指动作也不会仅因四指弯曲而被强制成握拳拇指。
|
||||||
|
|
||||||
|
### 7.5 安全范围与保留通道
|
||||||
|
|
||||||
|
局部修正完成后依次执行:
|
||||||
|
|
||||||
|
1. 写回保留通道固定值;
|
||||||
|
2. 按每通道 `command_bounds`裁剪;
|
||||||
|
3. 执行可选命令滤波;
|
||||||
|
4. 再次裁剪并再次写回保留通道;
|
||||||
|
5. 最终四舍五入为整数命令。
|
||||||
|
|
||||||
|
`raw_command`保留滤波前浮点目标,当前ROS节点不发布该字段;`cmd_u8_preview`发布滤波后
|
||||||
|
并取整的最终目标。
|
||||||
|
|
||||||
|
## 8. 滤波与实时执行
|
||||||
|
|
||||||
|
### 8.1 输入Kalman
|
||||||
|
|
||||||
|
输入滤波是21个互相独立的一维Kalman滤波器,共享参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
P_pred = P + process_variance
|
||||||
|
K = P_pred / (P_pred + measurement_variance)
|
||||||
|
x = x + K * (z - x)
|
||||||
|
P = (1 - K) * P_pred
|
||||||
|
```
|
||||||
|
|
||||||
|
首帧、时间倒退或帧间隔超过 `input_filter_reset_gap` 时直接重置到当前测量,避免断流后
|
||||||
|
从旧状态缓慢追赶。默认关闭。
|
||||||
|
|
||||||
|
### 8.2 输出命令滤波
|
||||||
|
|
||||||
|
`CommandFilter`支持:
|
||||||
|
|
||||||
|
| 模式 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| `passthrough` | 直接使用本帧目标,仅应用deadband |
|
||||||
|
| `ema` | `step=clip(alpha*(target-last), ±max_step)` |
|
||||||
|
| `acceleration_limited` | 同时限制速度、帧间加速度,并根据剩余距离提前制动 |
|
||||||
|
|
||||||
|
默认参数匹配旧版左手G20的有效执行路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
input_filter_enabled=false
|
||||||
|
command_filter_mode=passthrough
|
||||||
|
command_filter_ema_alpha=1.0
|
||||||
|
command_filter_max_step_u8=255
|
||||||
|
command_filter_deadband_u8=0
|
||||||
|
```
|
||||||
|
|
||||||
|
实时节点没有单独暴露 `command_filter_max_acceleration_u8_per_frame2` 参数;
|
||||||
|
`acceleration_limited`模式下它使用与 `command_filter_max_step_u8`相同的值。
|
||||||
|
|
||||||
|
### 8.3 30 Hz最新帧策略
|
||||||
|
|
||||||
|
实时节点的处理定时器默认30 Hz:
|
||||||
|
|
||||||
|
1. 串口模式从线程安全快照取得最新序列号、数据和接收时刻;
|
||||||
|
2. 只有出现新FFG序列时,才发布/更新raw、filtered、intent和frame metadata;
|
||||||
|
3. 每个定时周期都用最近一次有效intent重新计算两个型号目标;
|
||||||
|
4. 预览始终发布,只有已使能型号才发布到SDK命令话题;
|
||||||
|
5. 硬件命令QoS为 `RELIABLE + KEEP_LAST(depth=1)`。
|
||||||
|
|
||||||
|
固定控制心跳不会排队重放旧手套帧。FFG停止更新时,节点可在超时窗口内短暂复用最后
|
||||||
|
intent,随后watchdog撤销使能。
|
||||||
|
|
||||||
|
使能某型号时,命令滤波器会重置到该型号最新有效SDK状态,而不是张手或上一次内部
|
||||||
|
目标,从而降低重新使能的第一帧跳变。
|
||||||
|
|
||||||
|
## 9. 独立仿真目标
|
||||||
|
|
||||||
|
仿真目标不从 `cmd_u8`反解。对主动通道激活量 `a_k`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
q_nominal_k = clip(
|
||||||
|
q_open_k + a_k * (q_closed_k - q_open_k),
|
||||||
|
q_lower_k,
|
||||||
|
q_upper_k
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
其输入是基础解剖通道激活量,不使用电机命令曲线,也不直接使用捏合或握持的电机残差。
|
||||||
|
因此实机姿势捕获中的机械耦合、偶然残留值和保留通道不会污染仿真弧度目标。
|
||||||
|
|
||||||
|
仿真消费者必须按 `JointState.name`建立映射。`simulation.py`提供:
|
||||||
|
|
||||||
|
- `build_name_mapping()`:检查空名、重名、缺名和多余名称;
|
||||||
|
- `reorder_named_target()`:按仿真模型顺序重排,检查有限值并应用仿真限位。
|
||||||
|
|
||||||
|
名称合同不满足时抛出 `JointNameMismatch`,不得按裸下标猜测。
|
||||||
|
|
||||||
|
profile中的 `urdf_sha256`用于追踪生成名义端点时对应的URDF版本,但实时映射节点本身
|
||||||
|
不读取或重新计算URDF文件哈希;数采manifest工具会执行文件哈希核对。
|
||||||
|
|
||||||
|
## 10. ROS 2实时节点
|
||||||
|
|
||||||
|
### 10.1 输入与输出话题
|
||||||
|
|
||||||
|
| 话题 | 类型 | 维度 | 发布条件 |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `/ffg/left/raw_joint_state` | `JointState` | 21 | 串口模式收到新帧;topic模式直接使用上游话题 |
|
||||||
|
| `/ffg/left/filtered_joint_state` | `JointState` | 21 | 有有效手套profile和新帧 |
|
||||||
|
| `/retarget/left/hand_intent` | `JointState` | 22 | 有有效手套profile和新帧 |
|
||||||
|
| `/retarget/left/frame_meta` | `String(JSON)` | - | 每个新映射手套帧 |
|
||||||
|
| `/retarget/g20/left/actuation_target` | `JointState` | 16 | G20 mapper有效 |
|
||||||
|
| `/retarget/o6/left/actuation_target` | `JointState` | 6 | O6 mapper有效 |
|
||||||
|
| `/retarget/g20/left/joint_target_nominal` | `JointState` | 16 | G20 mapper有效 |
|
||||||
|
| `/retarget/o6/left/joint_target_nominal` | `JointState` | 6 | O6 mapper有效 |
|
||||||
|
| `/retarget/g20/left/cmd_u8_preview` | `JointState` | 20 | G20 mapper有效 |
|
||||||
|
| `/retarget/o6/left/cmd_u8_preview` | `JointState` | 6 | O6 mapper有效 |
|
||||||
|
| `/g20/cb_left_hand_control_cmd` | `JointState` | 20 | G20已使能 |
|
||||||
|
| `/o6/cb_left_hand_control_cmd` | `JointState` | 6 | O6已使能 |
|
||||||
|
| `/ffg_dual_retarget/status` | `String(JSON)` | - | 1 Hz |
|
||||||
|
|
||||||
|
节点订阅:
|
||||||
|
|
||||||
|
| 话题 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `raw_input_topic` | `input_mode=topic`时的FFG输入,默认 `/ffg/left/raw_joint_state` |
|
||||||
|
| `/g20/cb_left_hand_state` | G20驱动状态心跳 |
|
||||||
|
| `/o6/cb_left_hand_state` | O6驱动状态心跳 |
|
||||||
|
|
||||||
|
topic输入模式不会再次向raw话题发布收到的消息,避免默认同名话题形成反馈。
|
||||||
|
|
||||||
|
驱动状态的名称必须已经按profile `command_names`规范顺序排列;这里与FFG topic输入不同,
|
||||||
|
不会对驱动状态按集合重排。
|
||||||
|
|
||||||
|
### 10.2 帧元数据
|
||||||
|
|
||||||
|
`frame_meta`包含:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp_ns": 0,
|
||||||
|
"sequence": 0,
|
||||||
|
"calibration": "provisional",
|
||||||
|
"q_gt": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
一个新手套帧产生的filtered、intent、frame_meta及该次定时周期的型号目标共用ROS时间戳。
|
||||||
|
定时器复用旧intent时,型号目标使用新的当前时间戳,但不会重复发布intent和frame_meta。
|
||||||
|
|
||||||
|
### 10.3 状态诊断
|
||||||
|
|
||||||
|
1 Hz状态JSON包含:
|
||||||
|
|
||||||
|
- 当前输入模式、FFG帧序号和数据年龄;
|
||||||
|
- G20/O6分别是否使能;
|
||||||
|
- glove、G20、O6的批准状态和SHA-256;
|
||||||
|
- `mapping_mode`、`simulation_mapping_mode`;
|
||||||
|
- 输入和命令滤波配置;
|
||||||
|
- 当前捏合/握持局部分支权重 `anchor_weights`;
|
||||||
|
- wear-check有效性;
|
||||||
|
- 驱动状态年龄;
|
||||||
|
- profile和URDF哈希;
|
||||||
|
- profile加载错误、最近故障和p95调度延迟。
|
||||||
|
|
||||||
|
`latency_p95_ms`以本机接收手套数据的单调时钟为起点,表示接收至映射调度的延迟,
|
||||||
|
不是基于设备硬件时间戳的端到端链路延迟。
|
||||||
|
|
||||||
|
### 10.4 服务与状态转换
|
||||||
|
|
||||||
|
| 服务 | 类型 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| `~/enable_g20` | `SetBool` | 单独申请/撤销G20实机控制 |
|
||||||
|
| `~/enable_o6` | `SetBool` | 单独申请/撤销O6实机控制 |
|
||||||
|
| `~/enable_all` | `SetBool` | 原子检查两台后同时使能,或同时撤销 |
|
||||||
|
| `~/emergency_stop` | `Trigger` | 立即撤销两个型号的命令发布权限 |
|
||||||
|
|
||||||
|
```text
|
||||||
|
显式SetBool(true)且全部检查通过
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ ▼
|
||||||
|
PREVIEW / DISABLED ENABLED(model)
|
||||||
|
▲ │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
SetBool(false)、超时、映射异常或软件急停
|
||||||
|
```
|
||||||
|
|
||||||
|
撤销使能的含义是停止向SDK命令话题发布新命令,不会主动发送张手、零位或其他安全姿势。
|
||||||
|
驱动/固件将保持最后命令相关行为;物理急停仍应由系统级安全链路负责。
|
||||||
|
|
||||||
|
## 11. 实机使能门控
|
||||||
|
|
||||||
|
某个型号从PREVIEW进入ENABLED前依次检查:
|
||||||
|
|
||||||
|
1. 手套profile已加载且 `approved_for_runtime=true`;
|
||||||
|
2. wear-check已通过启动时校验;
|
||||||
|
3. 机械手profile已加载且 `approved_for_control=true`;
|
||||||
|
4. 启动参数中的期望SN非空,并与profile SN完全一致;
|
||||||
|
5. profile CAN接口与启动配置一致;
|
||||||
|
6. mapper为 `factorized_paired_v2`;
|
||||||
|
7. FFG最近一帧未超过 `glove_timeout`,默认0.35秒;
|
||||||
|
8. 对应SDK状态名称、长度、数值和范围有效,且未超过 `driver_timeout`,默认1秒。
|
||||||
|
|
||||||
|
运行时身份门控比较SN和CAN接口,不比较profile中的固件版本。固件兼容性目前依赖操作
|
||||||
|
流程和数采manifest的可选校验;若固件变更会改变电机响应,应重新标定机械手profile。
|
||||||
|
|
||||||
|
`enable_all`先检查G20和O6两者,任意一个失败都不会使能任何一个。单型号服务互相独立。
|
||||||
|
|
||||||
|
## 12. Watchdog与故障策略
|
||||||
|
|
||||||
|
watchdog周期为50 ms:
|
||||||
|
|
||||||
|
| 故障 | 动作 |
|
||||||
|
|---|---|
|
||||||
|
| 任意型号已使能且FFG超时 | 同时撤销G20和O6 |
|
||||||
|
| 某型号SDK状态无效或超时 | 只撤销该型号,另一型号保持 |
|
||||||
|
| 映射计算出现数值/形状错误 | 同时撤销G20和O6 |
|
||||||
|
| 软件急停 | 同时撤销G20和O6 |
|
||||||
|
| profile加载失败 | 启动时降级;不创建对应mapper或只保留raw |
|
||||||
|
|
||||||
|
故障恢复不会自动重新使能。排除原因后必须再次调用对应使能服务。
|
||||||
|
|
||||||
|
节点启动和profile加载采用fail-closed策略:
|
||||||
|
|
||||||
|
- 无手套profile:只发布原始FFG;
|
||||||
|
- 手套profile有效但未获运行批准:允许完整预览,拒绝实机;
|
||||||
|
- seed机械手profile `approved_for_control=false`:允许预览,拒绝实机;
|
||||||
|
- 单个型号profile无效:另一个有效型号仍可生成目标和独立使能。
|
||||||
|
|
||||||
|
## 13. 主要ROS参数
|
||||||
|
|
||||||
|
### 13.1 FFG输入
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `input_mode` | `serial` | `serial`或`topic` |
|
||||||
|
| `raw_input_topic` | `/ffg/left/raw_joint_state` | topic模式输入 |
|
||||||
|
| `serial_port` | 空 | 指定串口;为空时可自动扫描 |
|
||||||
|
| `baudrate` | `0` | 大于0时优先尝试该波特率 |
|
||||||
|
| `baudrates` | `[2000000,460800,1000000,921600]` | 探测候选 |
|
||||||
|
| `auto_scan` | `true` | 指定端口失败或为空时扫描 |
|
||||||
|
| `serial_debug` | `false` | 串口调试日志 |
|
||||||
|
|
||||||
|
### 13.2 Profile与身份
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `glove_profile` | 空 | FFG完整标定profile |
|
||||||
|
| `wear_check` | 空 | 快速佩戴检查凭据 |
|
||||||
|
| `wear_check_max_age_hours` | `12.0` | 启动加载时允许的最大年龄 |
|
||||||
|
| `g20_profile` / `o6_profile` | 空 | 单机机械手profile |
|
||||||
|
| `g20_serial_number` / `o6_serial_number` | 空 | 运行期望SN |
|
||||||
|
| `g20_can_interface` | `can0` | G20身份核对 |
|
||||||
|
| `o6_can_interface` | `can1` | O6身份核对 |
|
||||||
|
|
||||||
|
### 13.3 时序和滤波
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `publish_rate` | `30.0` | 固定映射/命令心跳Hz |
|
||||||
|
| `glove_timeout` | `0.35` | FFG超时秒数 |
|
||||||
|
| `driver_timeout` | `1.0` | SDK状态超时秒数 |
|
||||||
|
| `input_filter_enabled` | `false` | 是否启用逐维Kalman |
|
||||||
|
| `input_filter_process_variance` | `1e-5` | Kalman过程噪声 |
|
||||||
|
| `input_filter_measurement_variance` | `5e-4` | Kalman测量噪声 |
|
||||||
|
| `input_filter_reset_gap` | `0.35` | 断流重置阈值 |
|
||||||
|
| `command_filter_mode` | `passthrough` | 输出滤波模式 |
|
||||||
|
| `command_filter_ema_alpha` | `1.0` | EMA/限加速度目标增益 |
|
||||||
|
| `command_filter_max_step_u8` | `255.0` | 每帧最大速度尺度 |
|
||||||
|
| `command_filter_deadband_u8` | `0.0` | 小于该差值时保持上一目标 |
|
||||||
|
|
||||||
|
启动文件还负责创建两个SDK节点,并设置启动速度、力矩、状态轮询和G20控制期间延迟状态
|
||||||
|
读取等驱动参数;这些不是 `ffg_dual_retarget`自身参数。
|
||||||
|
|
||||||
|
## 14. Profile校验与可追踪性
|
||||||
|
|
||||||
|
### 14.1 严格加载
|
||||||
|
|
||||||
|
`profiles.py`在构造mapper之前检查:
|
||||||
|
|
||||||
|
- schema、profile类型、型号和左手侧;
|
||||||
|
- 固定的FFG关节名或机械手命令名;
|
||||||
|
- 所有数组长度、有限值和范围;
|
||||||
|
- 特征权重非负且和为1;
|
||||||
|
- 分段曲线激活量、命令范围和单调性;
|
||||||
|
- 主动通道与保留通道完整覆盖命令向量;
|
||||||
|
- 仿真名称顺序、端点、限位和URDF哈希格式;
|
||||||
|
- 已批准profile的设备身份、人工确认、状态和名称完整性。
|
||||||
|
|
||||||
|
profile错误不会被静默修正为另一种型号或旧映射策略。
|
||||||
|
|
||||||
|
### 14.2 规范化哈希
|
||||||
|
|
||||||
|
profile哈希不是原文件字节哈希,而是:
|
||||||
|
|
||||||
|
1. 排除加载器添加的 `_profile_path`和 `_profile_sha256`;
|
||||||
|
2. JSON key排序;
|
||||||
|
3. 使用紧凑分隔符和UTF-8;
|
||||||
|
4. 计算SHA-256。
|
||||||
|
|
||||||
|
因此仅缩进或JSON键顺序变化不会改变profile身份,持久字段变化会改变哈希。
|
||||||
|
|
||||||
|
保存使用同目录临时文件加原子替换,避免中途退出留下半个JSON。
|
||||||
|
|
||||||
|
### 14.3 相关运行文件
|
||||||
|
|
||||||
|
| 文件 | 技术作用 |
|
||||||
|
|---|---|
|
||||||
|
| glove profile | 原始帧、鲁棒统计、特征参数和捏合锚点 |
|
||||||
|
| wear-check | 绑定glove profile哈希的短期佩戴凭据 |
|
||||||
|
| robot profile | 设备身份、姿势、通道曲线、安全范围和仿真端点 |
|
||||||
|
| checkpoint | 绑定seed和设备元数据的可恢复捕获进度 |
|
||||||
|
| verification | 绑定robot profile哈希的独立人工复核结果 |
|
||||||
|
| session manifest | 绑定profile、wear-check、URDF、设备和rosbag话题 |
|
||||||
|
|
||||||
|
`session_manifest.py`当前要求G20和O6都已批准,并按 `can0/can1`核对;它适用于标准双手
|
||||||
|
型号数采拓扑,不是任意单型号或任意CAN配置的通用manifest生成器。
|
||||||
|
|
||||||
|
## 15. 离线质量检查
|
||||||
|
|
||||||
|
`retarget_profile_check`不启动ROS、不连接机械手,直接回放profile中的原始帧和姿势。
|
||||||
|
|
||||||
|
### 15.1 静态复现
|
||||||
|
|
||||||
|
对每个共有姿势,只比较该姿势真正定义的相关通道:
|
||||||
|
|
||||||
|
- 捏合:拇指和目标手指;
|
||||||
|
- 拇指姿势:拇指通道;
|
||||||
|
- 并拢:侧摆通道;
|
||||||
|
- 桌面/钩拳:普通四指屈伸通道;
|
||||||
|
- 握拳:除普通侧摆外的通道。
|
||||||
|
|
||||||
|
任一相关通道最大误差大于5个u8单位,记为hard failure。
|
||||||
|
|
||||||
|
### 15.2 捏合混淆
|
||||||
|
|
||||||
|
四个捏合中位数必须:
|
||||||
|
|
||||||
|
- 竞争winner等于目标手指;
|
||||||
|
- 目标门控不小于0.95。
|
||||||
|
|
||||||
|
否则记为hard failure。
|
||||||
|
|
||||||
|
### 15.3 动态连续性和局部性
|
||||||
|
|
||||||
|
每组动态重复记录:
|
||||||
|
|
||||||
|
- 目标通道跨度;
|
||||||
|
- 非目标通道跨度;
|
||||||
|
- 原始浮点命令帧间步长p95和最大值;
|
||||||
|
- 取整后整帧不变比例;
|
||||||
|
- 应用profile执行滤波后的同类指标。
|
||||||
|
|
||||||
|
普通手指屈伸轨迹中,若非目标通道跨度中位数大于
|
||||||
|
`max(15, 0.2 * target_span)`,生成warning。四指开合轨迹中若任一屈伸语义范围中位数
|
||||||
|
大于0.5,也生成warning。
|
||||||
|
|
||||||
|
动态步长目前只报告统计量,没有统一hard-failure阈值;应结合采样率、动作速度和设备
|
||||||
|
允许步长分析。
|
||||||
|
|
||||||
|
离线通过只证明profile内部复现和分解逻辑满足这些判据,不证明实机物理角度精度。
|
||||||
|
|
||||||
|
## 16. 实机姿势复核实现
|
||||||
|
|
||||||
|
`hand_pose_verify`加载已批准机械手profile后:
|
||||||
|
|
||||||
|
1. 查询命令话题是否已有其他发布者,有则拒绝开始;
|
||||||
|
2. 要求显式输入安全确认;
|
||||||
|
3. 从最新SDK状态而不是上一次目标开始;
|
||||||
|
4. 将目标分成每通道步长不超过 `max_step_u8` 的线性序列;
|
||||||
|
5. 默认30 Hz发送,运动中周期检查新竞争发布者;
|
||||||
|
6. 稳定后读取命名状态并计算设备空间绝对误差;
|
||||||
|
7. 保存人工通过/失败、备注、目标、状态和误差摘要。
|
||||||
|
|
||||||
|
默认 `max_step_u8=4`,CLI硬限制不超过8。复核报告明确记录
|
||||||
|
`state_is_angle_ground_truth=false`,并且不修改原机械手profile。
|
||||||
|
|
||||||
|
## 17. 扩展和维护约束
|
||||||
|
|
||||||
|
### 17.1 增加新的机械手型号
|
||||||
|
|
||||||
|
至少需要:
|
||||||
|
|
||||||
|
1. 在 `MODEL_COMMAND_LENGTHS`登记型号和命令长度;
|
||||||
|
2. 定义唯一、稳定的 `command_names`;
|
||||||
|
3. 创建seed profile,包括姿势、安全范围、主动通道、保留通道和仿真端点;
|
||||||
|
4. 明确每个执行通道的解剖语义源;
|
||||||
|
5. 扩展profile校验器的必需姿势集合;
|
||||||
|
6. 扩展节点的话题、身份参数、状态和服务;
|
||||||
|
7. 增加静态复现、局部性、限位和名称合同测试。
|
||||||
|
|
||||||
|
不要通过复制G20的裸下标映射来接入新型号;名称、主动通道和保留通道必须显式定义。
|
||||||
|
|
||||||
|
### 17.2 增加新的手套语义
|
||||||
|
|
||||||
|
需要同步更新:
|
||||||
|
|
||||||
|
- `BASE_INTENT_NAMES`或派生语义列表;
|
||||||
|
- 标定姿势标签和原始下标;
|
||||||
|
- glove profile生成及严格校验;
|
||||||
|
- `HandIntentExtractor.extract()`输出顺序;
|
||||||
|
- 使用该语义的机械手seed和测试;
|
||||||
|
- rosbag/下游消费者的数据合同。
|
||||||
|
|
||||||
|
修改名称或顺序会影响profile兼容性,应升级schema而不是让旧profile静默通过。
|
||||||
|
|
||||||
|
### 17.3 修改手势或阈值
|
||||||
|
|
||||||
|
捏合和握持阈值由当前手套profile自动推导。优先修复标定数据或距离定义,不要增加隐藏
|
||||||
|
的全局常量绕过竞争判据。若确需改变门控公式,应同时更新:
|
||||||
|
|
||||||
|
- 正/负样本定义;
|
||||||
|
- 连续性和混淆测试;
|
||||||
|
- 离线质量报告;
|
||||||
|
- `mapping_mode`或schema版本,以便数据可追踪。
|
||||||
|
|
||||||
|
### 17.4 线程与实时性
|
||||||
|
|
||||||
|
- 串口读取在线程中更新带锁快照;
|
||||||
|
- ROS节点定时器只消费最新快照,不阻塞等待串口;
|
||||||
|
- 运行时没有无界命令队列;
|
||||||
|
- 标定和复核CLI可使用后台executor线程,因为它们包含交互式终端等待;
|
||||||
|
- 映射主要是小向量NumPy运算,不包含在线优化或模型推理。
|
||||||
|
|
||||||
|
## 18. 测试与验收建议
|
||||||
|
|
||||||
|
核心单元/集成测试集中在
|
||||||
|
[`test_linkerforce_v2.py`](../src/linkerhand_retarget/test/test_linkerforce_v2.py),覆盖:
|
||||||
|
|
||||||
|
- 鲁棒统计和profile完整性;
|
||||||
|
- 根部/末端解耦和曲线内部无平台;
|
||||||
|
- 捏合局部性、连续切换和无历史锁存;
|
||||||
|
- 普通手指对拇指动作的独立性;
|
||||||
|
- 动态侧摆串扰补偿;
|
||||||
|
- 仿真目标与电机残差隔离;
|
||||||
|
- 滤波步长、加速度、重置和默认直通行为;
|
||||||
|
- profile身份、checkpoint、安全范围和命名合同;
|
||||||
|
- 重使能重基准和超时撤权;
|
||||||
|
- G20/O6输出长度、名称和限位。
|
||||||
|
|
||||||
|
修改核心算法后至少执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
python3 -m pytest -q \
|
||||||
|
src/linkerhand_retarget/test/test_linkerforce_v2.py
|
||||||
|
```
|
||||||
|
|
||||||
|
完成profile标定后再分别执行G20和O6离线质量回放。涉及ROS接口、SDK命名或launch参数的
|
||||||
|
修改,还应在PREVIEW状态检查实际话题长度、名称、频率和status JSON,再进入低速实机
|
||||||
|
验收。
|
||||||
|
|
||||||
|
## 19. 已知边界
|
||||||
|
|
||||||
|
- 当前只支持左手FFG到左手G20/O6;
|
||||||
|
- `provisional`不提供真实物理关节角精度声明;
|
||||||
|
- `cmd_u8`和SDK `state_u8`不能转换成可靠的真实关节弧度;
|
||||||
|
- 仿真目标只代表语义—URDF名义映射,不是实机测量;
|
||||||
|
- 捏合竞争无时间滞回,连续性依赖当前帧证据质量和可选输入滤波;
|
||||||
|
- wear-check只在节点加载时验证,不在长时间运行中自动过期撤权;
|
||||||
|
- 运行时身份门控不核对固件版本;
|
||||||
|
- 软件急停只撤销发布权限,不替代硬件急停或独立安全控制器;
|
||||||
|
- 默认双型号launch会同时创建两个SDK驱动;单型号系统可直接启动所需驱动和
|
||||||
|
`ffg_dual_retarget`节点。
|
||||||
|
|
||||||
|
这些边界应保留在数据报告、实验结论和对外精度声明中。
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
# FFG多手势标定映射与遥操操作说明
|
||||||
|
|
||||||
|
## 1. 文档目的
|
||||||
|
|
||||||
|
本文档说明当前 `linkerforce_v2` 无Marker方案的工作原理、标定流程和实机操作方法。
|
||||||
|
当前主要使用场景是一只左手FFG控制左手G20,也支持在配置对应profile后同时生成O6目标。
|
||||||
|
|
||||||
|
当前方案属于 `provisional` 阶段:
|
||||||
|
|
||||||
|
- 可以验证手套语义、机械手通道、方向、动作范围和连续性;
|
||||||
|
- 可以用于演示和临时数采;
|
||||||
|
- 不能把G20/O6的 `0~255` 电机命令当作真实关节角;
|
||||||
|
- 没有Marker、编码器或独立角度传感器时,不能给出实机与仿真的真实角度误差。
|
||||||
|
|
||||||
|
旧入口 `handretarget` 仍然保留;本文档只描述新入口 `ffg_dual_retarget`。
|
||||||
|
|
||||||
|
## 2. 当前映射架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
FFG左手套21维原始数据
|
||||||
|
↓
|
||||||
|
hand_intent:模型无关的人手语义(0~1)
|
||||||
|
↓
|
||||||
|
├─→ G20 actuation_target
|
||||||
|
│ ├─→ G20单机profile → 20维cmd_u8 → G20实机
|
||||||
|
│ └─→ G20名义URDF范围 → q_nominal → 仿真
|
||||||
|
│
|
||||||
|
└─→ O6 actuation_target
|
||||||
|
├─→ O6单机profile → 6维cmd_u8 → O6实机
|
||||||
|
└─→ O6名义URDF范围 → q_nominal → 仿真
|
||||||
|
```
|
||||||
|
|
||||||
|
实机命令和仿真目标是两条独立标尺:
|
||||||
|
|
||||||
|
- `cmd_u8`:设备电机空间命令,范围为0~255;
|
||||||
|
- `q_nominal`:根据URDF名义限位生成的仿真弧度目标;
|
||||||
|
- `state_u8`:SDK返回的设备状态,只用于运行诊断,不是真实关节角GT。
|
||||||
|
|
||||||
|
仿真不应直接把 `cmd_u8` 当作真实角度。需要接近实机外观时,可以使用同一
|
||||||
|
`actuation_target`,再通过实测角度标定完善仿真标尺。
|
||||||
|
|
||||||
|
## 3. 多手势标定解决什么问题
|
||||||
|
|
||||||
|
### 3.1 FFG静态姿势
|
||||||
|
|
||||||
|
完整手套标定采集11个静态姿势:
|
||||||
|
|
||||||
|
1. 五指自然张开、自然分开;
|
||||||
|
2. 五指伸直并拢;
|
||||||
|
3. 桌面手势:四指根部弯曲、末端伸直;
|
||||||
|
4. 钩拳:四指根部伸直、末端弯曲;
|
||||||
|
5. 自然握拳;
|
||||||
|
6. 拇指最大外展;
|
||||||
|
7. 拇指横跨掌心;
|
||||||
|
8. 拇指—食指捏合;
|
||||||
|
9. 拇指—中指捏合;
|
||||||
|
10. 拇指—无名指捏合;
|
||||||
|
11. 拇指—小指捏合。
|
||||||
|
|
||||||
|
每个静态姿势默认采集2秒、重复3次,每次至少50帧。profile保留全部原始帧、
|
||||||
|
每次中位数、MAD和有效帧数,而不是只保存一个平均值。
|
||||||
|
|
||||||
|
### 3.2 FFG动态轨迹
|
||||||
|
|
||||||
|
完整标定还采集7组短时往返轨迹:
|
||||||
|
|
||||||
|
- 食指独立弯曲往返;
|
||||||
|
- 中指独立弯曲往返;
|
||||||
|
- 无名指独立弯曲往返;
|
||||||
|
- 小指独立弯曲往返;
|
||||||
|
- 拇指弯曲往返;
|
||||||
|
- 拇指对掌往返;
|
||||||
|
- 四指开合往返。
|
||||||
|
|
||||||
|
动态轨迹主要用于发现和补偿同一手指屈伸对侧摆语义的传感器串扰,并检查非目标
|
||||||
|
手指是否跟随运动。它们不是额外的离散手势模板。
|
||||||
|
|
||||||
|
### 3.3 连续映射原则
|
||||||
|
|
||||||
|
当前运行时不会把整只手吸附到“最相似的标定手势”:
|
||||||
|
|
||||||
|
- 每根普通手指只读取自身的根部、末端和侧摆语义;
|
||||||
|
- 张手、桌面、钩拳和握拳构成四指根部—末端标定面,连续解耦传感器串扰;
|
||||||
|
- 普通屈伸映射保持连续,不在曲线内部加入停止平台;
|
||||||
|
- 四种捏合分别进行竞争判断;
|
||||||
|
- 捏合只局部修正拇指和目标手指,不替换整只手命令;
|
||||||
|
- 捏合证据不明确时,连续退回普通逐关节映射;
|
||||||
|
- 握拳只增加必要的拇指协调,不把相似动作强制变成握拳模板。
|
||||||
|
|
||||||
|
### 3.4 当前实时执行策略
|
||||||
|
|
||||||
|
当前默认执行节奏与旧版左手G20的有效路径一致:
|
||||||
|
|
||||||
|
```text
|
||||||
|
publish_rate=30Hz
|
||||||
|
input_filter_enabled=false
|
||||||
|
command_filter_mode=passthrough
|
||||||
|
command_filter_ema_alpha=1.0
|
||||||
|
command_filter_max_step_u8=255
|
||||||
|
command_filter_deadband_u8=0
|
||||||
|
repeat_position_commands=true
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是直接使用最新手套帧,将连续电机目标交给G20固件插值,并在每个30Hz控制心跳
|
||||||
|
重复发送最新目标。待发送队列深度为1,来不及发送时只保留最新目标,不重放旧命令。
|
||||||
|
|
||||||
|
Kalman和EMA仍然可以显式启用,但不要同时启用两层滤波。两层滤波叠加后再进行整数
|
||||||
|
取整,容易表现为小幅运动停顿、累计后跳变。
|
||||||
|
|
||||||
|
## 4. profile与运行文件
|
||||||
|
|
||||||
|
| 文件 | 内容 | 是否提交Git |
|
||||||
|
|---|---|---|
|
||||||
|
| `glove_<ID>_left_<operator>.json` | 个人佩戴下的FFG完整标定 | 否 |
|
||||||
|
| `*.wear_check.json` | 绑定手套profile哈希的快速佩戴检查 | 否 |
|
||||||
|
| `hand_G20_left_<SN>_provisional.json` | 指定G20实机的姿势命令profile | 否 |
|
||||||
|
| `*.checkpoint.json` | 实机姿势标定中断续标检查点 | 否 |
|
||||||
|
| `*.verification.json` | 实机姿势人工复核报告 | 否 |
|
||||||
|
| `g20_seed_profile.json` | G20 GUI安全初值和标定结构 | 是 |
|
||||||
|
| `o6_seed_profile.json` | O6 GUI安全初值和标定结构 | 是 |
|
||||||
|
|
||||||
|
根目录 `profiles/` 已加入 `.gitignore`。可复现的种子profile位于:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/linkerhand_retarget/resource/linkerforce_v2/profiles/
|
||||||
|
```
|
||||||
|
|
||||||
|
手套profile与操作者、手套和佩戴方式相关;机械手profile与型号、序列号、固件和CAN
|
||||||
|
接口相关。换手套、换操作者、明显改变佩戴位置时,应重新完整标定FFG。换机械手本体
|
||||||
|
或固件导致电机响应变化时,应重新标定机械手profile。
|
||||||
|
|
||||||
|
## 5. 构建与环境准备
|
||||||
|
|
||||||
|
在每个新终端中都要加载ROS和工作空间:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
```
|
||||||
|
|
||||||
|
源码修改后重新构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
|
||||||
|
colcon build --symlink-install --packages-select \
|
||||||
|
linker_hand_ros2_sdk gui_control linkerhand_retarget
|
||||||
|
|
||||||
|
source install/setup.bash
|
||||||
|
```
|
||||||
|
|
||||||
|
如果出现 `Package 'linkerhand_retarget' not found`,通常是当前终端没有执行上述两个
|
||||||
|
`source`,或者源码修改后还没有构建。
|
||||||
|
|
||||||
|
## 6. 完整标定FFG左手套
|
||||||
|
|
||||||
|
### 6.1 启动FFG原始数据发布
|
||||||
|
|
||||||
|
终端A启动只读FFG节点。只连接左手套即可,不要求右手套存在:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run linkerhand_retarget ffg_dual_retarget --ros-args \
|
||||||
|
-p serial_port:=/dev/ttyUSB0 \
|
||||||
|
-p baudrate:=2000000 \
|
||||||
|
-p auto_scan:=true
|
||||||
|
```
|
||||||
|
|
||||||
|
确认原始话题有数据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic hz /ffg/left/raw_joint_state
|
||||||
|
```
|
||||||
|
|
||||||
|
如果标定提示“0个有效帧”,不要继续重复按Enter。先确认:
|
||||||
|
|
||||||
|
- 终端A仍在运行;
|
||||||
|
- 日志显示左手FFG已连接;
|
||||||
|
- `/dev/ttyUSB0`没有被另一个FFG进程占用;
|
||||||
|
- `/ffg/left/raw_joint_state`有稳定数据。
|
||||||
|
|
||||||
|
### 6.2 执行完整手套标定
|
||||||
|
|
||||||
|
终端B执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run linkerhand_retarget ffg_calibrate -- \
|
||||||
|
--glove-id FFG_LEFT_SN \
|
||||||
|
--operator lxp \
|
||||||
|
--firmware 2.1.4 \
|
||||||
|
--output /home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp.json
|
||||||
|
```
|
||||||
|
|
||||||
|
静态姿势的正确操作:
|
||||||
|
|
||||||
|
1. 先摆好终端提示的固定姿势;
|
||||||
|
2. 姿势稳定后按Enter;
|
||||||
|
3. 按Enter后继续保持不动约2秒;
|
||||||
|
4. 终端显示保存帧数后再放松;
|
||||||
|
5. 同一姿势按相同方法重复3次。
|
||||||
|
|
||||||
|
动态往返轨迹的正确操作:
|
||||||
|
|
||||||
|
1. 先回到该动作的自然起始位置;
|
||||||
|
2. 按Enter后立即开始连续、缓慢地往返运动;
|
||||||
|
3. 在默认3秒采集窗口内完成若干次完整往返;
|
||||||
|
4. 非目标手指尽量保持稳定;
|
||||||
|
5. 不要先弯好后全程静止,否则采不到动态关系。
|
||||||
|
|
||||||
|
标定成功应显示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
approved_for_runtime=True
|
||||||
|
sha256=<手套profile哈希>
|
||||||
|
```
|
||||||
|
|
||||||
|
不要使用 `--skip-dynamic` 生成正式运行profile。该参数只适合调试。
|
||||||
|
|
||||||
|
## 7. 快速佩戴检查
|
||||||
|
|
||||||
|
每次正式实机启动前,对当前准备使用的手套profile执行快速检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_retarget ffg_calibrate -- \
|
||||||
|
--quick-check /home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp.json \
|
||||||
|
--quick-output /home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp.wear_check.json
|
||||||
|
```
|
||||||
|
|
||||||
|
依次检查张手、握拳和食指捏合。每个姿势也是“先摆好,再按Enter,然后保持2秒”。
|
||||||
|
|
||||||
|
快速检查凭据:
|
||||||
|
|
||||||
|
- 默认12小时有效;
|
||||||
|
- 必须显示 `passed=true`;
|
||||||
|
- 必须与启动时使用的手套profile SHA-256完全一致;
|
||||||
|
- 切换v4、v5等手套profile时,必须同时切换到对应的wear-check文件。
|
||||||
|
|
||||||
|
快速检查失败时,先重新调整手套佩戴位置并重试。如果多次失败,说明当前佩戴与完整
|
||||||
|
标定差异过大,应重新完整标定,不要通过增大阈值静默放行实机。
|
||||||
|
|
||||||
|
## 8. 标定G20实机姿势
|
||||||
|
|
||||||
|
### 8.1 安全要求
|
||||||
|
|
||||||
|
- 标定时使用低速、低力矩;
|
||||||
|
- 配备软件急停,并保证机械手周围无障碍物;
|
||||||
|
- 不得使用旧手套映射把机械手带到标定姿势;
|
||||||
|
- 不得在带电状态强行手掰;
|
||||||
|
- 使用GUI逐通道调整,并确认通道方向正确。
|
||||||
|
|
||||||
|
### 8.2 启动G20 SDK
|
||||||
|
|
||||||
|
终端A:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run linker_hand_ros2_sdk linker_hand_sdk --ros-args \
|
||||||
|
-p hand_type:=left \
|
||||||
|
-p hand_joint:=G20 \
|
||||||
|
-p is_touch:=false \
|
||||||
|
-p can:=can0 \
|
||||||
|
-p modbus:=None \
|
||||||
|
-p topic_prefix:=/g20 \
|
||||||
|
-p startup_speed:=30 \
|
||||||
|
-p startup_torque:=80 \
|
||||||
|
-p move_on_startup:=false \
|
||||||
|
-p state_poll_rate:=10.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 启动G20标定GUI
|
||||||
|
|
||||||
|
终端B:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run gui_control gui_control --ros-args \
|
||||||
|
-r __node:=g20_calibration_gui \
|
||||||
|
-p hand_type:=left \
|
||||||
|
-p hand_joint:=G20 \
|
||||||
|
-p topic_prefix:=/g20
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 捕获11个G20姿势
|
||||||
|
|
||||||
|
终端C:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run linkerhand_retarget hand_pose_capture -- \
|
||||||
|
--model G20 \
|
||||||
|
--seed /home/lxp/projects/linkerhand_retarget_ros2/install/linkerhand_retarget/share/linkerhand_retarget/linkerforce_v2/profiles/g20_seed_profile.json \
|
||||||
|
--serial-number G20_LEFT_001 \
|
||||||
|
--operator lxp \
|
||||||
|
--firmware unknown \
|
||||||
|
--can-interface can0 \
|
||||||
|
--output /home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json
|
||||||
|
```
|
||||||
|
|
||||||
|
每个姿势的操作:
|
||||||
|
|
||||||
|
1. 用GUI低速调整机械手;
|
||||||
|
2. 目视确认目标手指、通道方向和最终姿势;
|
||||||
|
3. 等待实机稳定;
|
||||||
|
4. 点击GUI“保存当前标定姿势”;
|
||||||
|
5. CLI收到快照后选择姿势状态。
|
||||||
|
|
||||||
|
状态含义:
|
||||||
|
|
||||||
|
- `exact`:机械手能够准确实现该姿势;
|
||||||
|
- `approximate`:受机构自由度限制,只能实现最佳近似;
|
||||||
|
- `unsupported`:该型号不能可靠实现,不用于对应姿势约束;
|
||||||
|
- 输入 `r`:放弃刚才的快照,重新调整和保存。
|
||||||
|
|
||||||
|
CLI每完成一个姿势都会立即写入 `*.checkpoint.json`。程序中断后,使用完全相同的
|
||||||
|
命令会自动恢复并跳过已保存姿势,不需要从头开始。
|
||||||
|
|
||||||
|
只有确实要放弃原进度时才增加:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--fresh
|
||||||
|
```
|
||||||
|
|
||||||
|
全部姿势完成后,只有输入 `y` 批准,最终profile才会包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
approved_for_control=True
|
||||||
|
```
|
||||||
|
|
||||||
|
O6操作相同,但使用 `--model O6`、O6 seed、`can1`和 `/o6` 命名空间。O6自由度较少,
|
||||||
|
桌面、钩拳及部分捏合通常应标为 `approximate`。
|
||||||
|
|
||||||
|
## 9. 复核机械手profile
|
||||||
|
|
||||||
|
姿势复核可以避免手工把JSON中的20维命令复制到GUI。
|
||||||
|
|
||||||
|
复核前:
|
||||||
|
|
||||||
|
- 停止 `ffg_dual_retarget`;
|
||||||
|
- 停止GUI,避免命令话题存在其他发布者;
|
||||||
|
- 只保留低速、低力矩的G20 SDK。
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_retarget hand_pose_verify -- \
|
||||||
|
--profile /home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json \
|
||||||
|
--operator lxp \
|
||||||
|
--topic-prefix /g20
|
||||||
|
```
|
||||||
|
|
||||||
|
只复核单个姿势:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_retarget hand_pose_verify -- \
|
||||||
|
--profile /home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json \
|
||||||
|
--operator lxp \
|
||||||
|
--topic-prefix /g20 \
|
||||||
|
--pose pinch_index
|
||||||
|
```
|
||||||
|
|
||||||
|
按照提示输入 `VERIFY`、`MOVE`,再选择:
|
||||||
|
|
||||||
|
- `p`:目视通过;
|
||||||
|
- `f`:目视未通过;
|
||||||
|
- `r`:重放;
|
||||||
|
- `s`:跳过。
|
||||||
|
|
||||||
|
工具会低速平滑过渡,并保存独立的 `*.verification.json`,不会修改原始机械手profile。
|
||||||
|
|
||||||
|
## 10. 离线检查映射质量
|
||||||
|
|
||||||
|
不连接实机即可回放profile中的静态与动态数据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 run linkerhand_retarget retarget_profile_check -- \
|
||||||
|
--glove /home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp.json \
|
||||||
|
--robot /home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json \
|
||||||
|
--model G20 \
|
||||||
|
--output /home/lxp/projects/linkerhand_retarget_ros2/profiles/g20_mapping_quality.json
|
||||||
|
```
|
||||||
|
|
||||||
|
重点查看:
|
||||||
|
|
||||||
|
- `passed`和`hard_failures`;
|
||||||
|
- 静态有效通道复现误差;
|
||||||
|
- 四种捏合的winner和gate;
|
||||||
|
- 四指动态轨迹的非目标通道跨度;
|
||||||
|
- 小指、侧摆等动作是否有明显串扰;
|
||||||
|
- 帧间命令变化是否存在异常突跳。
|
||||||
|
|
||||||
|
离线检查通过不等于实机角度准确,只表示profile内部逻辑一致。
|
||||||
|
|
||||||
|
## 11. 启动G20正式遥操
|
||||||
|
|
||||||
|
正式启动前,停止旧SDK、标定GUI、姿势捕获工具和占用FFG串口的只读节点。每种节点
|
||||||
|
只保留一个实例。
|
||||||
|
|
||||||
|
### 11.1 终端A:启动G20驱动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run linker_hand_ros2_sdk linker_hand_sdk --ros-args \
|
||||||
|
-p hand_type:=left \
|
||||||
|
-p hand_joint:=G20 \
|
||||||
|
-p is_touch:=false \
|
||||||
|
-p can:=can0 \
|
||||||
|
-p modbus:=None \
|
||||||
|
-p topic_prefix:=/g20 \
|
||||||
|
-p startup_speed:=255 \
|
||||||
|
-p startup_torque:=80 \
|
||||||
|
-p move_on_startup:=false \
|
||||||
|
-p state_poll_rate:=10.0 \
|
||||||
|
-p velocity_poll_rate:=10.0 \
|
||||||
|
-p defer_state_reads_while_commanding:=true \
|
||||||
|
-p repeat_position_commands:=true
|
||||||
|
```
|
||||||
|
|
||||||
|
`startup_speed`控制电机最大运动速度;`startup_torque`控制最大输出力矩。提高力矩不会
|
||||||
|
解决映射卡顿。建议先使用80,在确有负载需要并完成安全评估后再提高。
|
||||||
|
|
||||||
|
确认状态话题已有发布者:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic info /g20/cb_left_hand_state
|
||||||
|
```
|
||||||
|
|
||||||
|
应至少显示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Publisher count: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 终端B:启动FFG映射节点
|
||||||
|
|
||||||
|
以下示例使用当前v4手套profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 run linkerhand_retarget ffg_dual_retarget --ros-args \
|
||||||
|
-p serial_port:=/dev/ttyUSB0 \
|
||||||
|
-p baudrate:=2000000 \
|
||||||
|
-p auto_scan:=true \
|
||||||
|
-p publish_rate:=30.0 \
|
||||||
|
-p input_filter_enabled:=false \
|
||||||
|
-p command_filter_mode:=passthrough \
|
||||||
|
-p command_filter_ema_alpha:=1.0 \
|
||||||
|
-p command_filter_max_step_u8:=255.0 \
|
||||||
|
-p command_filter_deadband_u8:=0.0 \
|
||||||
|
-p glove_profile:=/home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp_v4.json \
|
||||||
|
-p wear_check:=/home/lxp/projects/linkerhand_retarget_ros2/profiles/glove_FFG_LEFT_SN_left_lxp_v4.wear_check.json \
|
||||||
|
-p g20_profile:=/home/lxp/projects/linkerhand_retarget_ros2/profiles/hand_G20_left_G20_LEFT_001_provisional.json \
|
||||||
|
-p g20_serial_number:=G20_LEFT_001 \
|
||||||
|
-p g20_can_interface:=can0
|
||||||
|
```
|
||||||
|
|
||||||
|
正常日志应包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FFG profile已加载
|
||||||
|
三姿势快速佩戴检查有效
|
||||||
|
G20 profile已加载(可申请实机使能)
|
||||||
|
G20映射=factorized_paired_v2
|
||||||
|
执行滤波=passthrough alpha=1.0, max_step=255.0
|
||||||
|
左手FFG已连接
|
||||||
|
```
|
||||||
|
|
||||||
|
节点启动后默认处于PREVIEW,不会立即控制实机。
|
||||||
|
|
||||||
|
### 11.3 PREVIEW检查
|
||||||
|
|
||||||
|
在使能前观察:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ffg/left/raw_joint_state
|
||||||
|
/ffg/left/filtered_joint_state
|
||||||
|
/retarget/left/hand_intent
|
||||||
|
/retarget/g20/left/actuation_target
|
||||||
|
/retarget/g20/left/joint_target_nominal
|
||||||
|
/retarget/g20/left/cmd_u8_preview
|
||||||
|
```
|
||||||
|
|
||||||
|
检查要求:
|
||||||
|
|
||||||
|
- 张手、半握、握拳过程中命令连续;
|
||||||
|
- 弯曲一根手指时,主要变化的是对应手指通道;
|
||||||
|
- 食指捏合主要影响拇指与食指;
|
||||||
|
- 不应锁定在某个历史捏合模板;
|
||||||
|
- 不应出现明显越限或突跳。
|
||||||
|
|
||||||
|
### 11.4 使能G20
|
||||||
|
|
||||||
|
终端C:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lxp/projects/linkerhand_retarget_ros2
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
|
||||||
|
ros2 service call /ffg_dual_retarget/enable_g20 \
|
||||||
|
std_srvs/srv/SetBool "{data: true}"
|
||||||
|
```
|
||||||
|
|
||||||
|
成功返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
success=True
|
||||||
|
message='G20已使能'
|
||||||
|
```
|
||||||
|
|
||||||
|
停用G20:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 service call /ffg_dual_retarget/enable_g20 \
|
||||||
|
std_srvs/srv/SetBool "{data: false}"
|
||||||
|
```
|
||||||
|
|
||||||
|
软件急停:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 service call /ffg_dual_retarget/emergency_stop \
|
||||||
|
std_srvs/srv/Trigger "{}"
|
||||||
|
```
|
||||||
|
|
||||||
|
急停、FFG断开、驱动状态超时或profile错误后,都需要排除问题并重新显式使能。
|
||||||
|
|
||||||
|
## 12. ROS话题说明
|
||||||
|
|
||||||
|
| 话题 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `/ffg/left/raw_joint_state` | 21维FFG原始数据 |
|
||||||
|
| `/ffg/left/filtered_joint_state` | 实际送入语义提取器的数据;默认与raw相同 |
|
||||||
|
| `/retarget/left/hand_intent` | 模型无关的0~1人手语义 |
|
||||||
|
| `/retarget/g20/left/actuation_target` | G20归一化目标 |
|
||||||
|
| `/retarget/g20/left/joint_target_nominal` | G20仿真名义弧度目标 |
|
||||||
|
| `/retarget/g20/left/cmd_u8_preview` | 未使能时也持续发布的20维预览命令 |
|
||||||
|
| `/g20/cb_left_hand_control_cmd` | 使能后发送给G20 SDK的20维命令 |
|
||||||
|
| `/g20/cb_left_hand_state` | G20 SDK状态心跳 |
|
||||||
|
| `/ffg_dual_retarget/status` | profile、使能、超时、滤波和延迟诊断 |
|
||||||
|
|
||||||
|
所有向量都带名称。仿真桥和其他消费者必须按 `JointState.name` 匹配,不得依赖裸下标。
|
||||||
|
|
||||||
|
## 13. 安全与自动停用
|
||||||
|
|
||||||
|
- 默认PREVIEW,必须通过服务显式使能;
|
||||||
|
- FFG超过0.35秒没有新帧:撤销全部实机使能;
|
||||||
|
- 对应驱动状态超过1秒未更新:只撤销该型号;
|
||||||
|
- profile缺失、未批准、SN/CAN不匹配:拒绝实机使能;
|
||||||
|
- wear-check缺失、失败、过期或哈希不匹配:拒绝实机使能;
|
||||||
|
- 所有命令检查长度、名称、有限值和0~255范围;
|
||||||
|
- G20四个保留通道保持安全固定值;
|
||||||
|
- 带电机械手不得强行手掰。
|
||||||
|
|
||||||
|
## 14. 常见问题排查
|
||||||
|
|
||||||
|
### 14.1 `Package 'linkerhand_retarget' not found`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /opt/ros/jazzy/setup.bash
|
||||||
|
source /home/lxp/projects/linkerhand_retarget_ros2/install/setup.bash
|
||||||
|
```
|
||||||
|
|
||||||
|
如果仍然找不到,重新执行第5节的构建命令。
|
||||||
|
|
||||||
|
### 14.2 手套标定只有0个有效帧
|
||||||
|
|
||||||
|
原因通常是没有单独启动FFG原始数据发布节点。检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic info /ffg/left/raw_joint_state
|
||||||
|
ros2 topic hz /ffg/left/raw_joint_state
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.3 快速佩戴检查失败
|
||||||
|
|
||||||
|
- 确认使用的是正确版本profile;
|
||||||
|
- 调整手套位置、腕带和手指传感器;
|
||||||
|
- 每个姿势先摆好再按Enter;
|
||||||
|
- 按Enter后保持不动2秒;
|
||||||
|
- 多次失败则重新完整标定。
|
||||||
|
|
||||||
|
### 14.4 `三姿势快速佩戴检查缺失、失败或过期`
|
||||||
|
|
||||||
|
重新对启动时使用的同一个手套profile执行第7节命令。不能拿v5的wear-check启动v4。
|
||||||
|
|
||||||
|
### 14.5 `G20驱动状态无效或已超时`
|
||||||
|
|
||||||
|
先执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic info /g20/cb_left_hand_state
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `Publisher count: 0`,说明G20 SDK未启动或没有使用 `/g20` 命名空间。按第11.1节
|
||||||
|
启动驱动。如果有发布者,再检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic echo /g20/cb_left_hand_state --once
|
||||||
|
```
|
||||||
|
|
||||||
|
状态必须是20维、名称与G20 profile一致、数值有限且位于0~255。
|
||||||
|
|
||||||
|
### 14.6 服务一直显示 `waiting for service`
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 node list
|
||||||
|
ros2 service list | grep ffg_dual_retarget
|
||||||
|
```
|
||||||
|
|
||||||
|
常见原因是 `ffg_dual_retarget`没有启动、当前终端未source,或者服务名称中多写了反斜杠。
|
||||||
|
|
||||||
|
### 14.7 服务成功但机械手不动
|
||||||
|
|
||||||
|
检查命令话题:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 topic info /g20/cb_left_hand_control_cmd
|
||||||
|
ros2 topic hz /g20/cb_left_hand_control_cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
使能后应同时存在发布者和订阅者,并接近30Hz。还要检查SDK终端是否报告CAN错误。
|
||||||
|
|
||||||
|
### 14.8 机械手运动卡顿
|
||||||
|
|
||||||
|
确认运行参数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ros2 param get /ffg_dual_retarget input_filter_enabled
|
||||||
|
ros2 param get /ffg_dual_retarget command_filter_mode
|
||||||
|
ros2 param get /linker_hand_sdk repeat_position_commands
|
||||||
|
```
|
||||||
|
|
||||||
|
当前推荐结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
False
|
||||||
|
passthrough
|
||||||
|
True
|
||||||
|
```
|
||||||
|
|
||||||
|
同时检查:
|
||||||
|
|
||||||
|
- 只运行一个G20 SDK和一个映射节点;
|
||||||
|
- 命令话题稳定接近30Hz;
|
||||||
|
- CAN状态查询在遥操期间已延后;
|
||||||
|
- 不要用提高力矩解决卡顿;
|
||||||
|
- 如果只有某一根手指异常,运行离线profile质量检查,重点看该手指动态轨迹。
|
||||||
|
|
||||||
|
### 14.9 某根手指张手不到位或发生串指
|
||||||
|
|
||||||
|
依次比较:
|
||||||
|
|
||||||
|
```text
|
||||||
|
raw_joint_state
|
||||||
|
→ hand_intent
|
||||||
|
→ cmd_u8_preview
|
||||||
|
→ cb_left_hand_state
|
||||||
|
```
|
||||||
|
|
||||||
|
- raw异常:佩戴或FFG采集问题;
|
||||||
|
- hand_intent异常:手套标定/语义解耦问题;
|
||||||
|
- intent正确但preview异常:映射/profile问题;
|
||||||
|
- preview正确但实机异常:机械手profile、驱动、固件或机构问题。
|
||||||
|
|
||||||
|
不要直接通过修改某个写死系数掩盖问题。
|
||||||
|
|
||||||
|
### 14.10 实机姿势标定中断
|
||||||
|
|
||||||
|
使用完全相同的 `hand_pose_capture` 命令重新启动,会自动读取检查点并跳过已保存姿势。
|
||||||
|
不要增加 `--fresh`,除非明确要删除当前标定进度并从头开始。
|
||||||
|
|
||||||
|
## 15. 当前精度边界与后续优化
|
||||||
|
|
||||||
|
当前多手势方案可以继续优化:
|
||||||
|
|
||||||
|
- 重采质量较差的小指、侧摆或拇指动态轨迹;
|
||||||
|
- 改善21维传感器到人体语义的连续解耦;
|
||||||
|
- 完善拇指对掌和四种捏合的局部连续映射;
|
||||||
|
- 为每台机械手建立方向相关、非线性的电机命令曲线;
|
||||||
|
- 记录输入、映射、发布、CAN和状态时间戳,量化延迟与丢帧。
|
||||||
|
|
||||||
|
要得到可量化的真实角度精度,仍需增加Marker、编码器或独立角度传感器,建立:
|
||||||
|
|
||||||
|
```text
|
||||||
|
hand_intent
|
||||||
|
→ 实机真实关节角q_target
|
||||||
|
→ 单机关节角—cmd_u8标定
|
||||||
|
```
|
||||||
|
|
||||||
|
在此之前,验收结论只能是动作语义、通道、连续性和外观接近程度,不能声明实机与仿真
|
||||||
|
达到某个真实关节角误差。
|
||||||
|
|
||||||
|
## 16. 正式运行前检查清单
|
||||||
|
|
||||||
|
- [ ] ROS与工作空间已source;
|
||||||
|
- [ ] 当前只有一个FFG读取进程;
|
||||||
|
- [ ] 当前只有一个G20 SDK,使用`can0`和`/g20`;
|
||||||
|
- [ ] FFG profile显示`approved_for_runtime=True`;
|
||||||
|
- [ ] wear-check通过、未过期且哈希匹配;
|
||||||
|
- [ ] G20 profile显示`approved_for_control=True`;
|
||||||
|
- [ ] profile中的SN和CAN与启动参数一致;
|
||||||
|
- [ ] `/g20/cb_left_hand_state`有有效发布者;
|
||||||
|
- [ ] PREVIEW下逐指、握拳和四种捏合动作正确;
|
||||||
|
- [ ] 默认实时参数为关闭输入滤波、直通命令、30Hz重复目标;
|
||||||
|
- [ ] 周围安全、急停可用;
|
||||||
|
- [ ] 最后才调用`enable_g20`。
|
||||||
Submodule
+1
Submodule src/agillink_omnihand_sdk added at 026740d9fd
@@ -9,6 +9,20 @@ class HandConfig:
|
|||||||
joint_names_en: Optional[List[str]] = None
|
joint_names_en: Optional[List[str]] = None
|
||||||
init_pos: List[int] = field(default_factory=list)
|
init_pos: List[int] = field(default_factory=list)
|
||||||
preset_actions: Optional[Dict[str, List[int]]] = None
|
preset_actions: Optional[Dict[str, List[int]]] = None
|
||||||
|
preset_action_overrides: Optional[Dict[str, Dict[str, List[int]]]] = None
|
||||||
|
# Integer GUI values are divided by this scale before publishing.
|
||||||
|
# Legacy hands use raw u8 values (scale=1); O12 uses milliradians.
|
||||||
|
position_scale: int = 1
|
||||||
|
position_unit: str = "u8"
|
||||||
|
|
||||||
|
def get_preset_actions(self, hand_type: str) -> Dict[str, List[int]]:
|
||||||
|
"""Return preset actions with hand-specific values applied."""
|
||||||
|
actions = dict(self.preset_actions or {})
|
||||||
|
if self.preset_action_overrides:
|
||||||
|
actions.update(
|
||||||
|
self.preset_action_overrides.get(hand_type.lower(), {})
|
||||||
|
)
|
||||||
|
return actions
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 常量字典(仅构建一次)
|
# 常量字典(仅构建一次)
|
||||||
@@ -75,10 +89,10 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
|
|||||||
"点赞": [255, 0, 0, 0, 0, 255, 162, 162, 144, 100, 210, 255, 255, 255, 255, 255, 0, 0, 0, 0],
|
"点赞": [255, 0, 0, 0, 0, 255, 162, 162, 144, 100, 210, 255, 255, 255, 255, 255, 0, 0, 0, 0],
|
||||||
"握拳": [96, 0, 0, 0, 0, 0, 193, 158, 128, 91, 132, 255, 255, 255, 255, 144, 0, 0, 0, 0],
|
"握拳": [96, 0, 0, 0, 0, 0, 193, 158, 128, 91, 132, 255, 255, 255, 255, 144, 0, 0, 0, 0],
|
||||||
"张开": [255, 255, 255, 255, 255, 255, 193, 148, 105, 42, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
|
"张开": [255, 255, 255, 255, 255, 255, 193, 148, 105, 42, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
|
||||||
"OK": [148, 110, 255, 255, 255, 44, 164, 100, 114, 127, 178, 255, 255, 255, 255, 94, 71, 255, 255, 255],
|
"OK": [0, 0, 255, 255, 255, 138, 147, 148, 105, 42, 109, 255, 255, 255, 255, 255, 211, 255, 255, 255],
|
||||||
"拇指对中指": [191, 255, 55, 255, 255, 96, 95, 100, 114, 127, 105, 255, 255, 255, 255, 94, 255, 108, 255, 255],
|
"拇指对中指": [0, 255, 0, 255, 255, 107, 149, 148, 105, 42, 109, 255, 255, 255, 255, 255, 225, 202, 255, 255],
|
||||||
"拇指对无名指": [191, 255, 255, 72, 255, 115, 95, 100, 114, 127, 60, 255, 255, 255, 255, 94, 255, 255, 97, 255],
|
"拇指对无名指": [0, 255, 255, 0, 255, 88, 171, 148, 105, 42, 59, 255, 255, 255, 255, 255, 255, 255, 206, 254],
|
||||||
"拇指对小指": [191, 255, 255, 255, 55, 0, 95, 100, 114, 121, 70, 255, 255, 255, 255, 94, 255, 255, 255, 100],
|
"拇指对小指": [0, 255, 255, 255, 0, 32, 170, 148, 105, 42, 109, 255, 255, 255, 255, 255, 255, 255, 255, 203],
|
||||||
"准备1": [255, 0, 0, 0, 0, 255, 162, 162, 144, 100, 210, 255, 255, 255, 255, 255, 0, 0, 0, 0],
|
"准备1": [255, 0, 0, 0, 0, 255, 162, 162, 144, 100, 210, 255, 255, 255, 255, 255, 0, 0, 0, 0],
|
||||||
"壹": [96, 255, 0, 0, 0, 0, 190, 161, 127, 80, 68, 255, 255, 255, 255, 144, 255, 0, 0, 0],
|
"壹": [96, 255, 0, 0, 0, 0, 190, 161, 127, 80, 68, 255, 255, 255, 255, 144, 255, 0, 0, 0],
|
||||||
"贰": [96, 255, 255, 0, 0, 0, 190, 66, 127, 80, 68, 255, 255, 255, 255, 144, 255, 255, 0, 0],
|
"贰": [96, 255, 255, 0, 0, 0, 190, 66, 127, 80, 68, 255, 255, 255, 255, 144, 255, 255, 0, 0],
|
||||||
@@ -224,13 +238,20 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
|
|||||||
"叁": [92, 87, 255, 255, 255, 0],
|
"叁": [92, 87, 255, 255, 255, 0],
|
||||||
"肆": [92, 87, 255, 255, 255, 255],
|
"肆": [92, 87, 255, 255, 255, 255],
|
||||||
"伍": [255, 255, 255, 255, 255, 255],
|
"伍": [255, 255, 255, 255, 255, 255],
|
||||||
"OK": [139, 91, 103, 250, 250, 250],
|
"OK": [95, 75, 116, 255, 255, 255],
|
||||||
|
"拇指对中指": [88, 2, 255, 114, 255, 255],
|
||||||
"点赞": [250, 79, 0, 0, 0, 0],
|
"点赞": [250, 79, 0, 0, 0, 0],
|
||||||
"握拳": [102, 18, 0, 0, 0, 0],
|
"握拳": [102, 18, 0, 0, 0, 0],
|
||||||
}
|
},
|
||||||
|
preset_action_overrides={
|
||||||
|
"right": {
|
||||||
|
"OK": [95, 83, 122, 255, 255, 255],
|
||||||
|
"拇指对中指": [95, 8, 255, 114, 255, 255],
|
||||||
|
},
|
||||||
|
},
|
||||||
),
|
),
|
||||||
"L6": HandConfig(
|
"L6": HandConfig(
|
||||||
joint_names_en=["thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch", "middle_mcp_pitch", "pinky_mcp_pitch", "ring_mcp_pitch"],
|
joint_names_en=["thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"],
|
||||||
joint_names=["大拇指弯曲", "大拇指横摆", "食指弯曲", "中指弯曲", "无名指弯曲", "小拇指弯曲"],
|
joint_names=["大拇指弯曲", "大拇指横摆", "食指弯曲", "中指弯曲", "无名指弯曲", "小拇指弯曲"],
|
||||||
init_pos=[250] * 6,
|
init_pos=[250] * 6,
|
||||||
preset_actions={
|
preset_actions={
|
||||||
@@ -258,5 +279,33 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
|
|||||||
"拇指压感准备2": [139, 18, 130, 0, 0, 0]
|
"拇指压感准备2": [139, 18, 130, 0, 0, 0]
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
"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,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
position_scale=1000,
|
||||||
|
position_unit="rad",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
HAND_CONFIGS = MappingProxyType(_HAND_CONFIGS)
|
HAND_CONFIGS = MappingProxyType(_HAND_CONFIGS)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import sys
|
import sys
|
||||||
import time, json
|
import time, json
|
||||||
|
import math
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List, Dict
|
from typing import List, Dict
|
||||||
@@ -19,14 +20,72 @@ from .utils.mapping import *
|
|||||||
|
|
||||||
from .config.constants import _HAND_CONFIGS
|
from .config.constants import _HAND_CONFIGS
|
||||||
LOOP_TIME = 1000 # 循环动作间隔时间 毫秒
|
LOOP_TIME = 1000 # 循环动作间隔时间 毫秒
|
||||||
|
|
||||||
|
_CANONICAL_COMMAND_NAMES = {
|
||||||
|
"G20": [
|
||||||
|
"thumb_cmc_pitch", "index_mcp_pitch", "middle_mcp_pitch",
|
||||||
|
"ring_mcp_pitch", "pinky_mcp_pitch", "thumb_cmc_roll",
|
||||||
|
"index_mcp_roll", "middle_mcp_roll", "ring_mcp_roll",
|
||||||
|
"pinky_mcp_roll", "thumb_cmc_yaw", "reserved_11",
|
||||||
|
"reserved_12", "reserved_13", "reserved_14", "thumb_mcp",
|
||||||
|
"index_pip", "middle_pip", "ring_pip", "pinky_pip",
|
||||||
|
],
|
||||||
|
"O6": [
|
||||||
|
"thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch",
|
||||||
|
"middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch",
|
||||||
|
],
|
||||||
|
"L6": [
|
||||||
|
"thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch",
|
||||||
|
"middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch",
|
||||||
|
],
|
||||||
|
"O12": [
|
||||||
|
"thumb_roll", "thumb_abad", "thumb_mcp", "thumb_pip",
|
||||||
|
"index_abad", "index_mcp", "index_pip",
|
||||||
|
"middle_abad", "middle_mcp", "middle_pip",
|
||||||
|
"ring_mcp", "pinky_mcp",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
_CANONICAL_COMMAND_BOUNDS = {
|
||||||
|
"G20": [
|
||||||
|
*[(0, 255)] * 10,
|
||||||
|
(0, 255),
|
||||||
|
*[(255, 255)] * 4,
|
||||||
|
*[(0, 255)] * 5,
|
||||||
|
],
|
||||||
|
"O6": [(0, 255)] * 6,
|
||||||
|
"L6": [(0, 255)] * 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Integer slider bounds in milliradians, derived from the O12 active-angle
|
||||||
|
# limits. Right and left thumb signs differ; all other limits are shared.
|
||||||
|
_O12_COMMAND_BOUNDS_MRAD = {
|
||||||
|
"right": [
|
||||||
|
(0, 942), (-1387, 0), (-827, 0), (-1291, 0),
|
||||||
|
(-261, 261), (0, 1352), (0, 1530), (-261, 261),
|
||||||
|
(0, 1357), (0, 1815), (0, 1535), (0, 1535),
|
||||||
|
],
|
||||||
|
"left": [
|
||||||
|
(-942, 0), (0, 1387), (-827, 0), (-1291, 0),
|
||||||
|
(-261, 261), (0, 1352), (0, 1530), (-261, 261),
|
||||||
|
(0, 1357), (0, 1815), (0, 1535), (0, 1535),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ROS2NodeManager(QObject):
|
class ROS2NodeManager(QObject):
|
||||||
"""ROS2节点管理器,处理ROS通信"""
|
"""ROS2节点管理器,处理ROS通信"""
|
||||||
status_updated = pyqtSignal(str, str) # 状态类型, 消息内容
|
status_updated = pyqtSignal(str, str) # 状态类型, 消息内容
|
||||||
|
feedback_updated = pyqtSignal(object)
|
||||||
|
|
||||||
def __init__(self, node_name: str = "hand_control_node"):
|
def __init__(self, node_name: str = "hand_control_node"):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.node = None
|
self.node = None
|
||||||
self.publisher = None
|
self.publisher = None
|
||||||
|
self.publisher_arc = None
|
||||||
|
self.state_subscription = None
|
||||||
|
self.speed_pub = None
|
||||||
|
self.torque_pub = None
|
||||||
self.joint_state = JointState()
|
self.joint_state = JointState()
|
||||||
self.joint_state.header = Header()
|
self.joint_state.header = Header()
|
||||||
|
|
||||||
@@ -45,28 +104,56 @@ class ROS2NodeManager(QObject):
|
|||||||
self.node.declare_parameter('hand_joint', 'L10')
|
self.node.declare_parameter('hand_joint', 'L10')
|
||||||
self.node.declare_parameter('topic_hz', 30)
|
self.node.declare_parameter('topic_hz', 30)
|
||||||
self.node.declare_parameter('is_arc', False)
|
self.node.declare_parameter('is_arc', False)
|
||||||
|
self.node.declare_parameter('topic_prefix', '')
|
||||||
|
|
||||||
# 获取参数
|
# 获取参数
|
||||||
self.hand_type = self.node.get_parameter('hand_type').value
|
self.hand_type = self.node.get_parameter('hand_type').value
|
||||||
self.hand_joint = self.node.get_parameter('hand_joint').value
|
self.hand_joint = self.node.get_parameter('hand_joint').value
|
||||||
self.hz = self.node.get_parameter('topic_hz').value
|
self.hz = self.node.get_parameter('topic_hz').value
|
||||||
self.is_arc = self.node.get_parameter('is_arc').value
|
self.is_arc = self.node.get_parameter('is_arc').value
|
||||||
|
self.topic_prefix = self.normalize_topic_prefix(
|
||||||
|
self.node.get_parameter('topic_prefix').value
|
||||||
|
)
|
||||||
|
|
||||||
if self.is_arc == True:
|
if self.hand_joint == "O12":
|
||||||
|
topic_base = f'/o12/{self.hand_type}'
|
||||||
|
self.publisher = self.node.create_publisher(
|
||||||
|
JointState, f'{topic_base}/joint_cmd', 10
|
||||||
|
)
|
||||||
|
self.state_subscription = self.node.create_subscription(
|
||||||
|
JointState,
|
||||||
|
f'{topic_base}/joint_states',
|
||||||
|
self._o12_feedback_callback,
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
elif self.is_arc == True:
|
||||||
# 创建发布者
|
# 创建发布者
|
||||||
self.publisher_arc = self.node.create_publisher(
|
self.publisher_arc = self.node.create_publisher(
|
||||||
JointState, f'/cb_{self.hand_type}_hand_control_cmd_arc', 10
|
JointState,
|
||||||
|
self.topic(f'/cb_{self.hand_type}_hand_control_cmd_arc'),
|
||||||
|
10
|
||||||
)
|
)
|
||||||
# 创建发布者
|
# 创建旧型号发布者
|
||||||
self.publisher = self.node.create_publisher(
|
if self.hand_joint != "O12":
|
||||||
JointState, f'/cb_{self.hand_type}_hand_control_cmd', 10
|
self.publisher = self.node.create_publisher(
|
||||||
|
JointState,
|
||||||
|
self.topic(f'/cb_{self.hand_type}_hand_control_cmd'),
|
||||||
|
10
|
||||||
|
)
|
||||||
|
self.snapshot_publisher = self.node.create_publisher(
|
||||||
|
JointState, self.topic('/calibration_pose_snapshot'), 10
|
||||||
)
|
)
|
||||||
# 新增 speed / torque 发布者
|
# 新增 speed / torque 发布者
|
||||||
self.speed_pub = self.node.create_publisher(
|
if self.hand_joint != "O12":
|
||||||
String, f'/cb_hand_setting_cmd', 10)
|
self.speed_pub = self.node.create_publisher(
|
||||||
self.torque_pub = self.node.create_publisher(
|
String, self.topic('/cb_hand_setting_cmd'), 10)
|
||||||
String, f'/cb_hand_setting_cmd', 10)
|
self.torque_pub = self.node.create_publisher(
|
||||||
self.status_updated.emit("info", f"ROS2节点初始化成功: {self.hand_type} {self.hand_joint}")
|
String, self.topic('/cb_hand_setting_cmd'), 10)
|
||||||
|
self.status_updated.emit(
|
||||||
|
"info",
|
||||||
|
f"ROS2节点初始化成功: {self.topic_prefix or '/'} "
|
||||||
|
f"{self.hand_type} {self.hand_joint}"
|
||||||
|
)
|
||||||
|
|
||||||
# 启动ROS2自旋线程
|
# 启动ROS2自旋线程
|
||||||
self.spin_thread = threading.Thread(target=self.spin_node, daemon=True)
|
self.spin_thread = threading.Thread(target=self.spin_node, daemon=True)
|
||||||
@@ -75,11 +162,55 @@ class ROS2NodeManager(QObject):
|
|||||||
self.status_updated.emit("error", f"ROS2初始化失败: {str(e)}")
|
self.status_updated.emit("error", f"ROS2初始化失败: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_topic_prefix(prefix: str) -> str:
|
||||||
|
prefix = str(prefix).strip()
|
||||||
|
if not prefix or prefix == '/':
|
||||||
|
return ''
|
||||||
|
if not prefix.startswith('/'):
|
||||||
|
prefix = '/' + prefix
|
||||||
|
return prefix.rstrip('/')
|
||||||
|
|
||||||
|
def topic(self, absolute_topic: str) -> str:
|
||||||
|
if not absolute_topic.startswith('/'):
|
||||||
|
raise ValueError('base topic must be absolute')
|
||||||
|
return self.topic_prefix + absolute_topic
|
||||||
|
|
||||||
def spin_node(self):
|
def spin_node(self):
|
||||||
"""运行ROS2节点自旋循环"""
|
"""运行ROS2节点自旋循环"""
|
||||||
while rclpy.ok() and self.node:
|
while rclpy.ok() and self.node:
|
||||||
rclpy.spin_once(self.node, timeout_sec=0.1)
|
rclpy.spin_once(self.node, timeout_sec=0.1)
|
||||||
|
|
||||||
|
def _o12_feedback_callback(self, msg: JointState):
|
||||||
|
"""Forward 12-channel O12 radian feedback safely into the Qt thread."""
|
||||||
|
if len(msg.position) != 12:
|
||||||
|
self.status_updated.emit(
|
||||||
|
"error", f"O12位置反馈长度错误: {len(msg.position)},应为12"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.feedback_updated.emit([float(value) for value in msg.position])
|
||||||
|
|
||||||
|
def command_bounds(self, count: int):
|
||||||
|
if self.hand_joint == "O12":
|
||||||
|
bounds = _O12_COMMAND_BOUNDS_MRAD.get(self.hand_type)
|
||||||
|
else:
|
||||||
|
bounds = _CANONICAL_COMMAND_BOUNDS.get(self.hand_joint)
|
||||||
|
if not bounds or len(bounds) != count:
|
||||||
|
return [(0, 255)] * count
|
||||||
|
return bounds
|
||||||
|
|
||||||
|
def bound_positions(self, positions: List[int]) -> List[int]:
|
||||||
|
bounds = self.command_bounds(len(positions))
|
||||||
|
return [
|
||||||
|
max(minimum, min(maximum, int(value)))
|
||||||
|
for value, (minimum, maximum) in zip(positions, bounds)
|
||||||
|
]
|
||||||
|
|
||||||
|
def wire_positions(self, positions: List[int]) -> List[float]:
|
||||||
|
positions = self.bound_positions(positions)
|
||||||
|
scale = _HAND_CONFIGS[self.hand_joint].position_scale
|
||||||
|
return [float(value) / float(scale) for value in positions]
|
||||||
|
|
||||||
def publish_joint_state(self, positions: List[int]):
|
def publish_joint_state(self, positions: List[int]):
|
||||||
"""发布关节状态消息"""
|
"""发布关节状态消息"""
|
||||||
if not self.publisher or not self.node:
|
if not self.publisher or not self.node:
|
||||||
@@ -87,21 +218,31 @@ class ROS2NodeManager(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
positions = self.bound_positions(positions)
|
||||||
|
wire_positions = self.wire_positions(positions)
|
||||||
self.joint_state.header.stamp = self.node.get_clock().now().to_msg()
|
self.joint_state.header.stamp = self.node.get_clock().now().to_msg()
|
||||||
self.joint_state.position = [float(pos) for pos in positions]
|
self.joint_state.position = wire_positions
|
||||||
# self.joint_state.velocity = [0.1] * len(positions)
|
# self.joint_state.velocity = [0.1] * len(positions)
|
||||||
# self.joint_state.effort = [0.01] * len(positions)
|
# self.joint_state.effort = [0.01] * len(positions)
|
||||||
# 如果有关节名称,添加到消息中
|
# 如果有关节名称,添加到消息中
|
||||||
#hand_config = HandConfig.from_hand_type(self.hand_joint)
|
#hand_config = HandConfig.from_hand_type(self.hand_joint)
|
||||||
hand_config = _HAND_CONFIGS[self.hand_joint]
|
hand_config = _HAND_CONFIGS[self.hand_joint]
|
||||||
if len(hand_config.joint_names) == len(positions):
|
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
|
||||||
|
# O12 SDK 1.1.8 may expose names in physical motor order while the
|
||||||
|
# position array uses active-angle order. An empty name array makes
|
||||||
|
# the documented position order unambiguous to the driver.
|
||||||
|
if self.hand_joint == "O12":
|
||||||
|
self.joint_state.name = []
|
||||||
|
elif canonical_names and len(canonical_names) == len(positions):
|
||||||
|
self.joint_state.name = canonical_names
|
||||||
|
elif len(hand_config.joint_names) == len(positions):
|
||||||
if hand_config.joint_names_en != None:
|
if hand_config.joint_names_en != None:
|
||||||
self.joint_state.name = hand_config.joint_names_en
|
self.joint_state.name = hand_config.joint_names_en
|
||||||
else:
|
else:
|
||||||
self.joint_state.name = hand_config.joint_names
|
self.joint_state.name = hand_config.joint_names
|
||||||
|
|
||||||
self.publisher.publish(self.joint_state)
|
self.publisher.publish(self.joint_state)
|
||||||
if self.is_arc == True:
|
if self.is_arc == True and self.hand_joint != "O12":
|
||||||
if self.hand_joint == "O6":
|
if self.hand_joint == "O6":
|
||||||
if self.hand_type == "left":
|
if self.hand_type == "left":
|
||||||
pose = range_to_arc_left(positions,self.hand_joint)
|
pose = range_to_arc_left(positions,self.hand_joint)
|
||||||
@@ -131,9 +272,27 @@ class ROS2NodeManager(QObject):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status_updated.emit("error", f"发布失败: {str(e)}")
|
self.status_updated.emit("error", f"发布失败: {str(e)}")
|
||||||
|
|
||||||
|
def publish_pose_snapshot(self, positions: List[int]):
|
||||||
|
"""发布带名称的当前标定姿势快照。"""
|
||||||
|
positions = self.bound_positions(positions)
|
||||||
|
self.publish_joint_state(positions)
|
||||||
|
self.joint_state.header.stamp = self.node.get_clock().now().to_msg()
|
||||||
|
self.joint_state.position = self.wire_positions(positions)
|
||||||
|
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
|
||||||
|
if canonical_names and self.hand_joint != "O12":
|
||||||
|
self.joint_state.name = canonical_names
|
||||||
|
elif self.hand_joint == "O12":
|
||||||
|
self.joint_state.name = []
|
||||||
|
self.snapshot_publisher.publish(self.joint_state)
|
||||||
|
self.status_updated.emit("info", "当前标定姿势快照已发布")
|
||||||
|
|
||||||
def publish_speed(self, val: int):
|
def publish_speed(self, val: int):
|
||||||
joint_len = 0
|
if self.hand_joint == "O12":
|
||||||
if (self.hand_joint.upper() == "O6" or self.hand_joint.upper() == "L6"):
|
self.status_updated.emit(
|
||||||
|
"warning", "O12不使用旧版u8速度接口;请通过角度轨迹限制速度"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if self.hand_joint.upper() in ("O6", "L6"):
|
||||||
joint_len = 6
|
joint_len = 6
|
||||||
elif self.hand_joint == "L7":
|
elif self.hand_joint == "L7":
|
||||||
joint_len = 7
|
joint_len = 7
|
||||||
@@ -152,8 +311,12 @@ class ROS2NodeManager(QObject):
|
|||||||
self.speed_pub.publish(msg)
|
self.speed_pub.publish(msg)
|
||||||
|
|
||||||
def publish_torque(self, val: int):
|
def publish_torque(self, val: int):
|
||||||
joint_len = 0
|
if self.hand_joint == "O12":
|
||||||
if (self.hand_joint.upper() == "O6" or self.hand_joint.upper() == "L6"):
|
self.status_updated.emit(
|
||||||
|
"warning", "O12不使用旧版u8扭矩接口;位置GUI已禁用该设置"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if self.hand_joint.upper() in ("O6", "L6"):
|
||||||
joint_len = 6
|
joint_len = 6
|
||||||
elif self.hand_joint == "L7":
|
elif self.hand_joint == "L7":
|
||||||
joint_len = 7
|
joint_len = 7
|
||||||
@@ -194,11 +357,17 @@ class HandControlGUI(QWidget):
|
|||||||
# 设置ROS管理器
|
# 设置ROS管理器
|
||||||
self.ros_manager = ros_manager
|
self.ros_manager = ros_manager
|
||||||
self.ros_manager.status_updated.connect(self.update_status)
|
self.ros_manager.status_updated.connect(self.update_status)
|
||||||
|
self.ros_manager.feedback_updated.connect(self.on_feedback_updated)
|
||||||
|
|
||||||
# 获取手部配置
|
# 获取手部配置
|
||||||
self.hand_joint = self.ros_manager.hand_joint
|
self.hand_joint = self.ros_manager.hand_joint
|
||||||
self.hand_type = self.ros_manager.hand_type
|
self.hand_type = self.ros_manager.hand_type
|
||||||
self.hand_config = _HAND_CONFIGS[self.hand_joint]
|
self.hand_config = _HAND_CONFIGS[self.hand_joint]
|
||||||
|
self.preset_actions = self.hand_config.get_preset_actions(self.hand_type)
|
||||||
|
self.feedback_positions = None
|
||||||
|
# O12 only publishes after an explicit slider/preset action. This
|
||||||
|
# prevents opening or closing the real hand merely by launching GUI.
|
||||||
|
self.command_dirty = self.hand_joint != "O12"
|
||||||
|
|
||||||
# 初始化UI
|
# 初始化UI
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
@@ -209,10 +378,27 @@ class HandControlGUI(QWidget):
|
|||||||
self.publish_timer.timeout.connect(self.publish_joint_state)
|
self.publish_timer.timeout.connect(self.publish_joint_state)
|
||||||
self.publish_timer.start()
|
self.publish_timer.start()
|
||||||
|
|
||||||
|
def format_position(self, value: int) -> str:
|
||||||
|
"""Format an integer GUI value in the hand's actual command unit."""
|
||||||
|
scale = self.hand_config.position_scale
|
||||||
|
if self.hand_config.position_unit == "rad":
|
||||||
|
radians = float(value) / float(scale)
|
||||||
|
return f"{radians:.3f} rad / {math.degrees(radians):.1f}°"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def command_positions(self) -> List[float]:
|
||||||
|
"""Return current slider values converted to wire units."""
|
||||||
|
return self.ros_manager.wire_positions(
|
||||||
|
[slider.value() for slider in self.sliders]
|
||||||
|
)
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
"""初始化用户界面"""
|
"""初始化用户界面"""
|
||||||
# 设置窗口属性
|
# 设置窗口属性
|
||||||
self.setWindowTitle(f'灵巧手控制界面 - {self.hand_type} {self.hand_joint}')
|
self.setWindowTitle(
|
||||||
|
f'灵巧手控制界面 - {self.ros_manager.topic_prefix or "/"} '
|
||||||
|
f'{self.hand_type} {self.hand_joint}'
|
||||||
|
)
|
||||||
self.setMinimumSize(1200, 900)
|
self.setMinimumSize(1200, 900)
|
||||||
|
|
||||||
# 设置样式
|
# 设置样式
|
||||||
@@ -383,13 +569,16 @@ class HandControlGUI(QWidget):
|
|||||||
for i, (name, value) in enumerate(zip(
|
for i, (name, value) in enumerate(zip(
|
||||||
self.hand_config.joint_names, self.hand_config.init_pos
|
self.hand_config.joint_names, self.hand_config.init_pos
|
||||||
)):
|
)):
|
||||||
|
bounds = self.ros_manager.command_bounds(len(self.hand_config.init_pos))
|
||||||
|
minimum, maximum = bounds[i]
|
||||||
|
value = max(minimum, min(maximum, int(value)))
|
||||||
# 创建标签
|
# 创建标签
|
||||||
label = QLabel(f"{name}: {value}")
|
label = QLabel(f"{name}: {self.format_position(value)}")
|
||||||
label.setMinimumWidth(120)
|
label.setMinimumWidth(120)
|
||||||
|
|
||||||
# 创建滑动条
|
# 创建滑动条
|
||||||
slider = QSlider(Qt.Horizontal)
|
slider = QSlider(Qt.Horizontal)
|
||||||
slider.setRange(0, 255)
|
slider.setRange(minimum, maximum)
|
||||||
slider.setValue(value)
|
slider.setValue(value)
|
||||||
slider.valueChanged.connect(
|
slider.valueChanged.connect(
|
||||||
lambda val, idx=i: self.on_slider_value_changed(idx, val)
|
lambda val, idx=i: self.on_slider_value_changed(idx, val)
|
||||||
@@ -435,6 +624,11 @@ class HandControlGUI(QWidget):
|
|||||||
self.stop_button.setProperty("category", "danger")
|
self.stop_button.setProperty("category", "danger")
|
||||||
self.stop_button.clicked.connect(self.on_stop_clicked)
|
self.stop_button.clicked.connect(self.on_stop_clicked)
|
||||||
actions_layout.addWidget(self.stop_button)
|
actions_layout.addWidget(self.stop_button)
|
||||||
|
|
||||||
|
self.save_pose_button = QPushButton("保存当前标定姿势")
|
||||||
|
self.save_pose_button.setProperty("category", "action")
|
||||||
|
self.save_pose_button.clicked.connect(self.on_save_pose_clicked)
|
||||||
|
actions_layout.addWidget(self.save_pose_button)
|
||||||
|
|
||||||
layout.addLayout(actions_layout)
|
layout.addLayout(actions_layout)
|
||||||
|
|
||||||
@@ -443,9 +637,9 @@ class HandControlGUI(QWidget):
|
|||||||
def create_system_preset_buttons(self, parent_layout):
|
def create_system_preset_buttons(self, parent_layout):
|
||||||
"""创建系统预设动作按钮"""
|
"""创建系统预设动作按钮"""
|
||||||
self.preset_buttons = [] # 清空按钮列表
|
self.preset_buttons = [] # 清空按钮列表
|
||||||
if self.hand_config.preset_actions:
|
if self.preset_actions:
|
||||||
buttons = []
|
buttons = []
|
||||||
for idx, (name, positions) in enumerate(self.hand_config.preset_actions.items()):
|
for idx, (name, positions) in enumerate(self.preset_actions.items()):
|
||||||
button = QPushButton(name)
|
button = QPushButton(name)
|
||||||
button.setProperty("category", "preset")
|
button.setProperty("category", "preset")
|
||||||
button.clicked.connect(
|
button.clicked.connect(
|
||||||
@@ -472,6 +666,9 @@ class HandControlGUI(QWidget):
|
|||||||
|
|
||||||
# —— 2. 新增:速度与扭矩设置(每行一个)——
|
# —— 2. 新增:速度与扭矩设置(每行一个)——
|
||||||
quick_set_gb = QGroupBox("快速设置")
|
quick_set_gb = QGroupBox("快速设置")
|
||||||
|
if self.hand_joint == "O12":
|
||||||
|
quick_set_gb.setEnabled(False)
|
||||||
|
quick_set_gb.setToolTip("O12不使用旧版0-255速度/扭矩设置接口")
|
||||||
qv_layout = QVBoxLayout(quick_set_gb)
|
qv_layout = QVBoxLayout(quick_set_gb)
|
||||||
|
|
||||||
# 速度行
|
# 速度行
|
||||||
@@ -600,7 +797,10 @@ class HandControlGUI(QWidget):
|
|||||||
"""滑动条值改变事件处理"""
|
"""滑动条值改变事件处理"""
|
||||||
if 0 <= index < len(self.slider_labels):
|
if 0 <= index < len(self.slider_labels):
|
||||||
joint_name = self.hand_config.joint_names[index]
|
joint_name = self.hand_config.joint_names[index]
|
||||||
self.slider_labels[index].setText(f"{joint_name}: {value}")
|
self.slider_labels[index].setText(
|
||||||
|
f"{joint_name}: {self.format_position(value)}"
|
||||||
|
)
|
||||||
|
self.command_dirty = True
|
||||||
|
|
||||||
# 更新数值显示
|
# 更新数值显示
|
||||||
self.update_value_display()
|
self.update_value_display()
|
||||||
@@ -608,10 +808,23 @@ class HandControlGUI(QWidget):
|
|||||||
def update_value_display(self):
|
def update_value_display(self):
|
||||||
"""更新数值显示面板内容"""
|
"""更新数值显示面板内容"""
|
||||||
# 获取所有滑动条的当前值
|
# 获取所有滑动条的当前值
|
||||||
values = [slider.value() for slider in self.sliders]
|
command = self.command_positions()
|
||||||
|
command_text = ", ".join(f"{value:.3f}" for value in command)
|
||||||
# 格式化显示为列表形式
|
lines = [f"目标({self.hand_config.position_unit}): [{command_text}]"]
|
||||||
self.value_display.setText(f"{values}")
|
if self.feedback_positions is not None:
|
||||||
|
feedback_text = ", ".join(
|
||||||
|
f"{value:.3f}" for value in self.feedback_positions
|
||||||
|
)
|
||||||
|
lines.append(f"反馈(rad): [{feedback_text}]")
|
||||||
|
self.value_display.setText("\n".join(lines))
|
||||||
|
|
||||||
|
def on_feedback_updated(self, positions):
|
||||||
|
"""Display actual O12 joint feedback without moving command sliders."""
|
||||||
|
self.feedback_positions = list(positions)
|
||||||
|
self.update_value_display()
|
||||||
|
if hasattr(self, "connection_status"):
|
||||||
|
self.connection_status.setText("O12右手已连接并收到位置反馈")
|
||||||
|
self.connection_status.setObjectName("StatusInfo")
|
||||||
|
|
||||||
def on_preset_action_clicked(self, positions: List[int]):
|
def on_preset_action_clicked(self, positions: List[int]):
|
||||||
"""预设动作按钮点击事件处理"""
|
"""预设动作按钮点击事件处理"""
|
||||||
@@ -650,11 +863,34 @@ class HandControlGUI(QWidget):
|
|||||||
self.cycle_button.setText("循环运行预设动作")
|
self.cycle_button.setText("循环运行预设动作")
|
||||||
self.reset_preset_buttons_color()
|
self.reset_preset_buttons_color()
|
||||||
|
|
||||||
self.status_updated.emit("warning", "已停止所有动作")
|
if self.hand_joint == "O12" and self.feedback_positions is not None:
|
||||||
|
# Hold the latest measured pose instead of merely stopping topic
|
||||||
|
# publication while a previous position target is still active.
|
||||||
|
scale = self.hand_config.position_scale
|
||||||
|
hold_values = [
|
||||||
|
int(round(value * scale)) for value in self.feedback_positions
|
||||||
|
]
|
||||||
|
hold_values = self.ros_manager.bound_positions(hold_values)
|
||||||
|
for slider, value in zip(self.sliders, hold_values):
|
||||||
|
slider.blockSignals(True)
|
||||||
|
slider.setValue(value)
|
||||||
|
slider.blockSignals(False)
|
||||||
|
self.command_dirty = True
|
||||||
|
self.publish_joint_state()
|
||||||
|
self.update_value_display()
|
||||||
|
self.status_updated.emit("warning", "已下发当前反馈位置并保持")
|
||||||
|
else:
|
||||||
|
self.command_dirty = False
|
||||||
|
self.status_updated.emit("warning", "已停止连续动作和新的位置发布")
|
||||||
|
|
||||||
|
def on_save_pose_clicked(self):
|
||||||
|
"""发布当前滑块姿势,供hand_pose_capture写入profile。"""
|
||||||
|
positions = [slider.value() for slider in self.sliders]
|
||||||
|
self.ros_manager.publish_pose_snapshot(positions)
|
||||||
|
|
||||||
def on_cycle_clicked(self):
|
def on_cycle_clicked(self):
|
||||||
"""循环运行预设动作按钮点击事件处理"""
|
"""循环运行预设动作按钮点击事件处理"""
|
||||||
if not self.hand_config.preset_actions:
|
if not self.preset_actions:
|
||||||
QMessageBox.warning(self, "无预设动作", "当前手部型号没有预设动作可循环运行")
|
QMessageBox.warning(self, "无预设动作", "当前手部型号没有预设动作可循环运行")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -677,19 +913,19 @@ class HandControlGUI(QWidget):
|
|||||||
|
|
||||||
def run_next_action(self):
|
def run_next_action(self):
|
||||||
"""运行下一个预设动作"""
|
"""运行下一个预设动作"""
|
||||||
if not self.hand_config.preset_actions:
|
if not self.preset_actions:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 重置所有按钮颜色
|
# 重置所有按钮颜色
|
||||||
self.reset_preset_buttons_color()
|
self.reset_preset_buttons_color()
|
||||||
|
|
||||||
# 计算下一个动作索引
|
# 计算下一个动作索引
|
||||||
self.current_action_index = (self.current_action_index + 1) % len(self.hand_config.preset_actions)
|
self.current_action_index = (self.current_action_index + 1) % len(self.preset_actions)
|
||||||
|
|
||||||
# 获取下一个动作
|
# 获取下一个动作
|
||||||
action_names = list(self.hand_config.preset_actions.keys())
|
action_names = list(self.preset_actions.keys())
|
||||||
action_name = action_names[self.current_action_index]
|
action_name = action_names[self.current_action_index]
|
||||||
action_positions = self.hand_config.preset_actions[action_name]
|
action_positions = self.preset_actions[action_name]
|
||||||
|
|
||||||
# 执行动作
|
# 执行动作
|
||||||
self.on_preset_action_clicked(action_positions)
|
self.on_preset_action_clicked(action_positions)
|
||||||
@@ -714,6 +950,7 @@ class HandControlGUI(QWidget):
|
|||||||
"""关节类型改变事件处理"""
|
"""关节类型改变事件处理"""
|
||||||
self.hand_joint = joint_type
|
self.hand_joint = joint_type
|
||||||
self.hand_config = _HAND_CONFIGS[self.hand_joint]
|
self.hand_config = _HAND_CONFIGS[self.hand_joint]
|
||||||
|
self.preset_actions = self.hand_config.get_preset_actions(self.hand_type)
|
||||||
|
|
||||||
# 更新手部信息
|
# 更新手部信息
|
||||||
info_text = f"""手部类型: {self.hand_type}
|
info_text = f"""手部类型: {self.hand_type}
|
||||||
@@ -732,8 +969,11 @@ class HandControlGUI(QWidget):
|
|||||||
|
|
||||||
def publish_joint_state(self):
|
def publish_joint_state(self):
|
||||||
"""发布当前关节状态"""
|
"""发布当前关节状态"""
|
||||||
|
if self.hand_joint == "O12" and not self.command_dirty:
|
||||||
|
return
|
||||||
positions = [slider.value() for slider in self.sliders]
|
positions = [slider.value() for slider in self.sliders]
|
||||||
self.ros_manager.publish_joint_state(positions)
|
self.ros_manager.publish_joint_state(positions)
|
||||||
|
self.command_dirty = False
|
||||||
|
|
||||||
def update_status(self, status_type: str, message: str):
|
def update_status(self, status_type: str, message: str):
|
||||||
"""更新状态显示"""
|
"""更新状态显示"""
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Start the HCAN-connected O12 right hand and its safe position GUI."""
|
||||||
|
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import IncludeLaunchDescription, TimerAction
|
||||||
|
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||||
|
from launch.substitutions import PathJoinSubstitution
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
from launch_ros.substitutions import FindPackageShare
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
o12_driver = IncludeLaunchDescription(
|
||||||
|
PythonLaunchDescriptionSource(
|
||||||
|
PathJoinSubstitution([
|
||||||
|
FindPackageShare("omnihand_node"),
|
||||||
|
"launch",
|
||||||
|
"omnihand_pro_2025_node.launch.py",
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
o12_gui = Node(
|
||||||
|
package="gui_control",
|
||||||
|
executable="gui_control",
|
||||||
|
name="o12_right_gui_control",
|
||||||
|
output="screen",
|
||||||
|
parameters=[{
|
||||||
|
"hand_type": "right",
|
||||||
|
"hand_joint": "O12",
|
||||||
|
# Commands are coalesced, so dragging a slider sends at most 10 Hz.
|
||||||
|
"topic_hz": 10,
|
||||||
|
"is_arc": False,
|
||||||
|
"topic_prefix": "",
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
|
return LaunchDescription([
|
||||||
|
o12_driver,
|
||||||
|
# Give the CANFD driver a moment to claim and initialise the adapter.
|
||||||
|
TimerAction(period=1.5, actions=[o12_gui]),
|
||||||
|
])
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<test_depend>python3-pytest</test_depend>
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
|
||||||
<exec_depend>linker_hand_ros2_sdk</exec_depend>
|
<exec_depend>linker_hand_ros2_sdk</exec_depend>
|
||||||
|
<exec_depend>omnihand_node</exec_depend>
|
||||||
|
|
||||||
<export>
|
<export>
|
||||||
<build_type>ament_python</build_type>
|
<build_type>ament_python</build_type>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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())
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from gui_control.config.constants import HAND_CONFIGS
|
||||||
|
|
||||||
|
|
||||||
|
def test_o6_ok_preset_is_hand_specific() -> None:
|
||||||
|
config = HAND_CONFIGS["O6"]
|
||||||
|
|
||||||
|
assert config.get_preset_actions("left")["OK"] == [
|
||||||
|
95, 75, 116, 255, 255, 255,
|
||||||
|
]
|
||||||
|
assert config.get_preset_actions("right")["OK"] == [
|
||||||
|
95, 83, 122, 255, 255, 255,
|
||||||
|
]
|
||||||
|
assert config.get_preset_actions("left")["拇指对中指"] == [
|
||||||
|
88, 2, 255, 114, 255, 255,
|
||||||
|
]
|
||||||
|
assert config.get_preset_actions("right")["拇指对中指"] == [
|
||||||
|
95, 8, 255, 114, 255, 255,
|
||||||
|
]
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from launch import LaunchDescription
|
|
||||||
from launch_ros.actions import Node
|
|
||||||
|
|
||||||
def generate_launch_description():
|
|
||||||
return LaunchDescription([
|
|
||||||
Node(
|
|
||||||
package='linker_hand_ros2_sdk',
|
|
||||||
executable='linker_hand_sdk',
|
|
||||||
name='linker_hand_sdk_left',
|
|
||||||
output='screen',
|
|
||||||
parameters=[{
|
|
||||||
'hand_type': 'left',
|
|
||||||
'hand_joint': "L10", # 这里需要修改为实际Linker Hand的型号 L7、L10、L20、L21、L25
|
|
||||||
'is_touch': True, # 是否带有压力传感器
|
|
||||||
'can': 'can0', # 这里需要修改为实际的CAN总线名称
|
|
||||||
}],
|
|
||||||
),
|
|
||||||
|
|
||||||
Node(
|
|
||||||
package='linker_hand_ros2_sdk',
|
|
||||||
executable='linker_hand_sdk',
|
|
||||||
name='linker_hand_sdk_right',
|
|
||||||
output='screen',
|
|
||||||
parameters=[{
|
|
||||||
'hand_type': 'right',
|
|
||||||
'hand_joint': "L10", # 这里需要修改为实际Linker Hand的型号 L7、L10、L20、L21、L25
|
|
||||||
'is_touch': True, # 是否带有压力传感器
|
|
||||||
'can': 'can0', # 这里需要修改为实际的CAN总线名称
|
|
||||||
}],
|
|
||||||
),
|
|
||||||
])
|
|
||||||
+32
-1
@@ -1030,6 +1030,16 @@ class LinkerHandG20Can:
|
|||||||
cmd_state = self.joint_state_to_cmd_state(state=s)
|
cmd_state = self.joint_state_to_cmd_state(state=s)
|
||||||
return cmd_state
|
return cmd_state
|
||||||
|
|
||||||
|
def get_cached_current_status(self):
|
||||||
|
"""Return the latest received five-finger state without CAN queries."""
|
||||||
|
state = [self.x41, self.x42, self.x43, self.x44, self.x45]
|
||||||
|
if not all(
|
||||||
|
isinstance(finger, (list, tuple)) and len(finger) == 6
|
||||||
|
for finger in state
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return self.joint_state_to_cmd_state(state=state)
|
||||||
|
|
||||||
def get_current_pub_status(self):
|
def get_current_pub_status(self):
|
||||||
"""API接口:获取手指当前状态"""
|
"""API接口:获取手指当前状态"""
|
||||||
self.get_current_status()
|
self.get_current_status()
|
||||||
@@ -1266,4 +1276,25 @@ class LinkerHandG20Can:
|
|||||||
except:
|
except:
|
||||||
return "-1"
|
return "-1"
|
||||||
def get_finger_order(self):
|
def get_finger_order(self):
|
||||||
return ["Thumb Base", "Index Finger Base", "Middle Finger Base", "Ring Finger Base", "Pinky Finger Base", "Thumb Abduction", "Index Finger Abduction", "Middle Finger Abduction", "Ring Finger Abduction", "Pinky Finger Abduction", "Thumb Horizontal Abduction", "Reserved", "Reserved", "Reserved", "Reserved", "Thumb Tip", "Index Finger Tip", "Middle Finger Tip", "Ring Finger Tip", "Pinky Finger Tip"]
|
return [
|
||||||
|
"thumb_cmc_pitch",
|
||||||
|
"index_mcp_pitch",
|
||||||
|
"middle_mcp_pitch",
|
||||||
|
"ring_mcp_pitch",
|
||||||
|
"pinky_mcp_pitch",
|
||||||
|
"thumb_cmc_roll",
|
||||||
|
"index_mcp_roll",
|
||||||
|
"middle_mcp_roll",
|
||||||
|
"ring_mcp_roll",
|
||||||
|
"pinky_mcp_roll",
|
||||||
|
"thumb_cmc_yaw",
|
||||||
|
"reserved_11",
|
||||||
|
"reserved_12",
|
||||||
|
"reserved_13",
|
||||||
|
"reserved_14",
|
||||||
|
"thumb_mcp",
|
||||||
|
"index_pip",
|
||||||
|
"middle_pip",
|
||||||
|
"ring_pip",
|
||||||
|
"pinky_pip",
|
||||||
|
]
|
||||||
|
|||||||
+36
-2
@@ -1,3 +1,5 @@
|
|||||||
|
from collections import deque
|
||||||
|
|
||||||
import can
|
import can
|
||||||
import time, sys
|
import time, sys
|
||||||
import threading
|
import threading
|
||||||
@@ -57,6 +59,14 @@ class LinkerHandL6Can:
|
|||||||
self.normal_force, self.tangential_force, self.tangential_force_dir, self.approach_inc = [[-1] * 6 for _ in range(4)]
|
self.normal_force, self.tangential_force, self.tangential_force_dir, self.approach_inc = [[-1] * 6 for _ in range(4)]
|
||||||
self.is_lock = False
|
self.is_lock = False
|
||||||
self.version = None
|
self.version = None
|
||||||
|
# L6 replies to a six-byte 0x01 position command with an immediate
|
||||||
|
# byte-for-byte echo on the same CAN ID. A zero-payload 0x01 state
|
||||||
|
# query also replies on that ID, but with the measured positions.
|
||||||
|
# Keep the two transactions distinct so command echoes never enter
|
||||||
|
# the published feedback stream used by calibration.
|
||||||
|
self._position_echo_lock = threading.Lock()
|
||||||
|
self._pending_position_echoes = deque(maxlen=32)
|
||||||
|
self._position_echo_timeout_seconds = 0.02
|
||||||
# Start the receiving thread
|
# Start the receiving thread
|
||||||
self.running = True
|
self.running = True
|
||||||
self.receive_thread = threading.Thread(target=self.receive_response)
|
self.receive_thread = threading.Thread(target=self.receive_response)
|
||||||
@@ -111,6 +121,11 @@ class LinkerHandL6Can:
|
|||||||
frame_property_value = int(frame_property.value) if hasattr(frame_property, 'value') else frame_property
|
frame_property_value = int(frame_property.value) if hasattr(frame_property, 'value') else frame_property
|
||||||
data = [frame_property_value] + [int(val) for val in data_list]
|
data = [frame_property_value] + [int(val) for val in data_list]
|
||||||
msg = can.Message(arbitration_id=self.can_id, data=data, is_extended_id=False)
|
msg = can.Message(arbitration_id=self.can_id, data=data, is_extended_id=False)
|
||||||
|
if frame_property_value == 0x01 and len(data_list) == 6:
|
||||||
|
with self._position_echo_lock:
|
||||||
|
self._pending_position_echoes.append(
|
||||||
|
(time.monotonic(), tuple(int(value) for value in data_list))
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
self.bus.send(msg)
|
self.bus.send(msg)
|
||||||
except can.CanError as e:
|
except can.CanError as e:
|
||||||
@@ -201,7 +216,23 @@ class LinkerHandL6Can:
|
|||||||
except:
|
except:
|
||||||
return
|
return
|
||||||
if frame_type == 0x01: # 0x01
|
if frame_type == 0x01: # 0x01
|
||||||
self.x01 = list(response_data)
|
response = tuple(int(value) for value in response_data)
|
||||||
|
now = time.monotonic()
|
||||||
|
is_position_echo = False
|
||||||
|
with self._position_echo_lock:
|
||||||
|
while (
|
||||||
|
self._pending_position_echoes
|
||||||
|
and now - self._pending_position_echoes[0][0]
|
||||||
|
> self._position_echo_timeout_seconds
|
||||||
|
):
|
||||||
|
self._pending_position_echoes.popleft()
|
||||||
|
for pending in tuple(self._pending_position_echoes):
|
||||||
|
if pending[1] == response:
|
||||||
|
self._pending_position_echoes.remove(pending)
|
||||||
|
is_position_echo = True
|
||||||
|
break
|
||||||
|
if not is_position_echo:
|
||||||
|
self.x01 = list(response)
|
||||||
elif frame_type == 0x02: # 0x02
|
elif frame_type == 0x02: # 0x02
|
||||||
self.x02 = list(response_data)
|
self.x02 = list(response_data)
|
||||||
elif frame_type == 0x05: # Set speed
|
elif frame_type == 0x05: # Set speed
|
||||||
@@ -391,7 +422,10 @@ class LinkerHandL6Can:
|
|||||||
return self.x35
|
return self.x35
|
||||||
|
|
||||||
def get_finger_order(self):
|
def get_finger_order(self):
|
||||||
return ["thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
|
# L6 channel 1 is the physical CMC roll actuator. Older SDK releases
|
||||||
|
# exposed the channel as ``thumb_cmc_yaw`` even though the wire order
|
||||||
|
# and mechanism have always been roll.
|
||||||
|
return ["thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
|
||||||
|
|
||||||
def show_fun_table(self):
|
def show_fun_table(self):
|
||||||
pass
|
pass
|
||||||
|
|||||||
+1
-1
@@ -372,7 +372,7 @@ class LinkerHandL6RS485:
|
|||||||
return [0] * 6
|
return [0] * 6
|
||||||
|
|
||||||
def get_finger_order(self):
|
def get_finger_order(self):
|
||||||
return ["thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
|
return ["thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
|
||||||
|
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
# 便捷方法
|
# 便捷方法
|
||||||
|
|||||||
-444
@@ -1,444 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from pymodbus.client import ModbusSerialClient
|
|
||||||
from typing import List, Dict
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
_INTERVAL = 0.006 # 8 ms
|
|
||||||
|
|
||||||
class LinkerHandL6RS485:
|
|
||||||
"""L6机械手 Modbus-RTU 控制类"""
|
|
||||||
|
|
||||||
# 6个关节名称
|
|
||||||
JOINT_NAMES = ["thumb_pitch", "thumb_yaw", "index_pitch",
|
|
||||||
"middle_pitch", "ring_pitch", "little_pitch"]
|
|
||||||
|
|
||||||
# 手指名称
|
|
||||||
FINGER_NAMES = ["thumb", "index", "middle", "ring", "little"]
|
|
||||||
|
|
||||||
def __init__(self, hand_id=0x27, modbus_port="/dev/ttyUSB0", baudrate=115200):
|
|
||||||
"""
|
|
||||||
初始化L6机械手
|
|
||||||
hand_id: 右手0x27(39), 左手0x28(40)
|
|
||||||
modbus_port: 串口设备路径
|
|
||||||
baudrate: 波特率,固定115200
|
|
||||||
"""
|
|
||||||
self.slave = hand_id
|
|
||||||
self.cli = ModbusSerialClient(
|
|
||||||
port=modbus_port,
|
|
||||||
baudrate=baudrate,
|
|
||||||
bytesize=8,
|
|
||||||
parity="N",
|
|
||||||
stopbits=1,
|
|
||||||
timeout=0.05
|
|
||||||
)
|
|
||||||
# pymodbus 3.5.1 需要显式连接
|
|
||||||
self.connected = self.cli.connect()
|
|
||||||
if not self.connected:
|
|
||||||
raise ConnectionError(f"RS485连接失败,端口: {modbus_port}")
|
|
||||||
|
|
||||||
def _read_input_registers(self, address: int, count: int) -> List[int]:
|
|
||||||
"""读取输入寄存器"""
|
|
||||||
time.sleep(_INTERVAL)
|
|
||||||
result = self.cli.read_input_registers(address=address, count=count, slave=self.slave)
|
|
||||||
if result.isError():
|
|
||||||
raise RuntimeError(f"读取输入寄存器失败: address={address}, count={count}")
|
|
||||||
return result.registers
|
|
||||||
|
|
||||||
def _write_register(self, address: int, value: int):
|
|
||||||
"""写入单个寄存器"""
|
|
||||||
time.sleep(_INTERVAL)
|
|
||||||
result = self.cli.write_register(address=address, value=value, slave=self.slave)
|
|
||||||
if result.isError():
|
|
||||||
raise RuntimeError(f"写入寄存器失败: address={address}, value={value}")
|
|
||||||
|
|
||||||
def _write_registers(self, address: int, values: List[int]):
|
|
||||||
"""写入多个寄存器"""
|
|
||||||
time.sleep(_INTERVAL)
|
|
||||||
result = self.cli.write_registers(address=address, values=values, slave=self.slave)
|
|
||||||
if result.isError():
|
|
||||||
raise RuntimeError(f"写入多个寄存器失败: address={address}, values={values}")
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# 基础读取接口
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
def read_angles(self) -> List[int]:
|
|
||||||
"""读取6个关节角度 (输入寄存器 0-5)"""
|
|
||||||
return self._read_input_registers(0, 6)
|
|
||||||
|
|
||||||
def read_torques(self) -> List[int]:
|
|
||||||
"""读取6个关节转矩 (输入寄存器 6-11)"""
|
|
||||||
return self._read_input_registers(6, 6)
|
|
||||||
|
|
||||||
def read_speeds(self) -> List[int]:
|
|
||||||
"""读取6个关节速度 (输入寄存器 12-17)"""
|
|
||||||
return self._read_input_registers(12, 6)
|
|
||||||
|
|
||||||
def read_temperatures(self) -> List[int]:
|
|
||||||
"""读取6个关节温度 (输入寄存器 18-23)"""
|
|
||||||
return self._read_input_registers(18, 6)
|
|
||||||
|
|
||||||
def read_error_codes(self) -> List[int]:
|
|
||||||
"""读取6个关节错误码 (输入寄存器 24-29)"""
|
|
||||||
return self._read_input_registers(24, 6)
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# 压力传感器接口
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
# def _pressure(self, finger: int) -> List[int]:
|
|
||||||
# """内部:选手指 → 读压力数据"""
|
|
||||||
# # 选择手指 (保持寄存器 36)
|
|
||||||
# self._write_register(36, finger)
|
|
||||||
# time.sleep(_INTERVAL)
|
|
||||||
# # 读取压力数据 (输入寄存器 52-122)
|
|
||||||
# return np.array(self._read_input_registers(52, 71))
|
|
||||||
def _pressure(self, finger: int) -> np.ndarray:
|
|
||||||
"""
|
|
||||||
6x12 (72点) 矩阵尺寸。
|
|
||||||
Modbus 地址 60/62。
|
|
||||||
"""
|
|
||||||
rows = 12 # 12 行
|
|
||||||
cols = 6 # 6 列
|
|
||||||
finger_size = rows * cols # 72 个数据点
|
|
||||||
|
|
||||||
# modbus 地址和计数
|
|
||||||
write_address = 60 # 写入手指选择
|
|
||||||
read_address = 62 # 读取压力数据
|
|
||||||
read_count = 96 # 读取 96 个寄存器
|
|
||||||
skip_count = 10 # 跳过前 10 个校验点
|
|
||||||
|
|
||||||
# 0. 参数校验和手指写入值确定
|
|
||||||
if finger < 1 or finger > 5:
|
|
||||||
raise ValueError(f"无效的手指编号: {finger}。手指编号应在 1 到 5 之间。")
|
|
||||||
|
|
||||||
finger_write_value = finger
|
|
||||||
|
|
||||||
# 1. 写入手指选择寄存器 (地址 60)
|
|
||||||
time.sleep(0.008)
|
|
||||||
wrsp = self.cli.write_register(address=write_address, value=finger_write_value, slave=self.slave)
|
|
||||||
if wrsp.isError():
|
|
||||||
raise RuntimeError(f"写入手指选择 {finger} 到地址 {write_address} 失败: {wrsp}")
|
|
||||||
|
|
||||||
# 写入后等待片刻
|
|
||||||
time.sleep(0.008)
|
|
||||||
|
|
||||||
# 2. 读取地址 62 的数据
|
|
||||||
rrsp = self.cli.read_input_registers(address=read_address, count=read_count, slave=self.slave)
|
|
||||||
|
|
||||||
if rrsp.isError():
|
|
||||||
raise RuntimeError(f"读取地址 {read_address} 压力数据失败: {rrsp}")
|
|
||||||
|
|
||||||
registers_16bit: List[int] = rrsp.registers
|
|
||||||
|
|
||||||
# 3. 核心数据处理
|
|
||||||
# a. 提取低 8 位数据 (得到 96 个 8 位数据点)
|
|
||||||
final_data_96 = [reg_value & 255 for reg_value in registers_16bit]
|
|
||||||
|
|
||||||
# b. 跳过前 10 个校验/头部数据点 (得到 86 个有效数据点)
|
|
||||||
effective_data = np.array(final_data_96[skip_count:], dtype=np.uint8)
|
|
||||||
# c. 截取当前手指的矩阵数据 (从 86 个有效点中截取 72 个点)
|
|
||||||
start_idx = 0
|
|
||||||
end_idx = finger_size # 72
|
|
||||||
|
|
||||||
finger_data_flat = effective_data[start_idx:end_idx]
|
|
||||||
|
|
||||||
# d. 验证数据长度
|
|
||||||
if finger_data_flat.size != finger_size:
|
|
||||||
raise ValueError(
|
|
||||||
f"数据提取失败。期望 {finger_size} 点 ({rows}x{cols}),"
|
|
||||||
f"但仅截取到 {finger_data_flat.size} 点。请检查协议,确认地址 62 是否一次性返回了所有手指数据。"
|
|
||||||
)
|
|
||||||
|
|
||||||
# e. 重塑为二维矩阵 (12 行 6 列)
|
|
||||||
finger_matrix = finger_data_flat.reshape((rows, cols))
|
|
||||||
|
|
||||||
return finger_matrix
|
|
||||||
|
|
||||||
def read_pressure_thumb(self) -> np.ndarray:
|
|
||||||
"""读取大拇指压力数据"""
|
|
||||||
return np.array(self._pressure(1), dtype=np.uint8)
|
|
||||||
|
|
||||||
def read_pressure_index(self) -> np.ndarray:
|
|
||||||
"""读取食指压力数据"""
|
|
||||||
return np.array(self._pressure(2), dtype=np.uint8)
|
|
||||||
|
|
||||||
def read_pressure_middle(self) -> np.ndarray:
|
|
||||||
"""读取中指压力数据"""
|
|
||||||
return np.array(self._pressure(3), dtype=np.uint8)
|
|
||||||
|
|
||||||
def read_pressure_ring(self) -> np.ndarray:
|
|
||||||
"""读取无名指压力数据"""
|
|
||||||
return np.array(self._pressure(4), dtype=np.uint8)
|
|
||||||
|
|
||||||
def read_pressure_little(self) -> np.ndarray:
|
|
||||||
"""读取小拇指压力数据"""
|
|
||||||
return np.array(self._pressure(5), dtype=np.uint8)
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# 版本信息接口
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
def read_versions(self) -> Dict[str, int]:
|
|
||||||
"""读取版本信息 (输入寄存器 148-155)"""
|
|
||||||
result = self._read_input_registers(148, 8)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"hand_freedom": result[0],
|
|
||||||
"hand_version": result[1],
|
|
||||||
"hand_number": result[2],
|
|
||||||
"hand_direction": result[3],
|
|
||||||
"software_version_major": result[4],
|
|
||||||
"software_version_minor": result[5] if len(result) > 5 else 0,
|
|
||||||
"software_version_revision": result[6] if len(result) > 6 else 0,
|
|
||||||
"hardware_version": result[7] if len(result) > 7 else 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# 写入接口
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
def write_angles(self, vals: List[int]):
|
|
||||||
"""设置6个关节角度 (保持寄存器 0-5)"""
|
|
||||||
vals = [int(x) for x in vals]
|
|
||||||
if not self.is_valid_6xuint8(vals):
|
|
||||||
raise ValueError("需要6个0-255的整数")
|
|
||||||
self._write_registers(0, vals)
|
|
||||||
|
|
||||||
def write_torques(self, vals: List[int]):
|
|
||||||
"""设置6个关节转矩 (保持寄存器 6-11)"""
|
|
||||||
vals = [int(x) for x in vals]
|
|
||||||
if not self.is_valid_6xuint8(vals):
|
|
||||||
raise ValueError("需要6个0-255的整数")
|
|
||||||
self._write_registers(6, vals)
|
|
||||||
|
|
||||||
def write_speeds(self, vals: List[int]):
|
|
||||||
"""设置6个关节速度 (保持寄存器 12-17)"""
|
|
||||||
vals = [int(x) for x in vals]
|
|
||||||
if not self.is_valid_6xuint8(vals):
|
|
||||||
raise ValueError("需要6个0-255的整数")
|
|
||||||
self._write_registers(12, vals)
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# 上下文管理
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
"""关闭连接"""
|
|
||||||
if self.connected:
|
|
||||||
self.cli.close()
|
|
||||||
self.connected = False
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# API固定接口函数
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
def is_valid_6xuint8(self, lst) -> bool:
|
|
||||||
"""验证6个0-255的整数列表"""
|
|
||||||
if len(lst) != 6:
|
|
||||||
return False
|
|
||||||
return all(isinstance(x, int) and 0 <= x <= 255 for x in lst)
|
|
||||||
|
|
||||||
def set_joint_positions(self, joint_angles=None):
|
|
||||||
"""设置关节位置"""
|
|
||||||
joint_angles = joint_angles or [0] * 6
|
|
||||||
self.write_angles(joint_angles)
|
|
||||||
|
|
||||||
def set_speed(self, speed=None):
|
|
||||||
"""设置速度"""
|
|
||||||
speed = speed or [200] * 6
|
|
||||||
self.write_speeds(speed)
|
|
||||||
|
|
||||||
def set_torque(self, torque=None):
|
|
||||||
"""设置扭矩"""
|
|
||||||
torque = torque or [200] * 6
|
|
||||||
self.write_torques(torque)
|
|
||||||
|
|
||||||
def set_current(self, current=None):
|
|
||||||
"""设置电流 (L6不支持)"""
|
|
||||||
print("当前L6不支持设置电流", flush=True)
|
|
||||||
|
|
||||||
def get_version(self) -> list:
|
|
||||||
"""获取版本信息"""
|
|
||||||
versions = self.read_versions()
|
|
||||||
return [
|
|
||||||
versions.get("hand_freedom", 0),
|
|
||||||
versions.get("hand_version", 0),
|
|
||||||
versions.get("hand_number", 0),
|
|
||||||
versions.get("hand_direction", 0),
|
|
||||||
versions.get("software_version_major", 0),
|
|
||||||
versions.get("hardware_version", 0)
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_current(self):
|
|
||||||
"""获取电流 (L6不支持)"""
|
|
||||||
print("当前L6不支持获取电流", flush=True)
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_state(self) -> list:
|
|
||||||
"""获取关节状态"""
|
|
||||||
return self.read_angles()
|
|
||||||
|
|
||||||
def get_state_for_pub(self) -> list:
|
|
||||||
return self.get_state()
|
|
||||||
|
|
||||||
def get_current_status(self) -> list:
|
|
||||||
return self.get_state()
|
|
||||||
|
|
||||||
def get_speed(self) -> list:
|
|
||||||
"""获取当前速度"""
|
|
||||||
return self.read_speeds()
|
|
||||||
|
|
||||||
def get_joint_speed(self) -> list:
|
|
||||||
return self.get_speed()
|
|
||||||
|
|
||||||
def get_touch_type(self) -> int:
|
|
||||||
"""获取压感类型 (2=矩阵式)"""
|
|
||||||
return 2
|
|
||||||
|
|
||||||
def get_normal_force(self) -> list:
|
|
||||||
"""获取压感数据:点式"""
|
|
||||||
return [-1] * 5
|
|
||||||
|
|
||||||
def get_tangential_force(self) -> list:
|
|
||||||
"""获取压感数据:点式"""
|
|
||||||
return [-1] * 5
|
|
||||||
|
|
||||||
def get_approach_inc(self) -> list:
|
|
||||||
"""获取压感数据:点式"""
|
|
||||||
return [-1] * 5
|
|
||||||
|
|
||||||
def get_touch(self) -> list:
|
|
||||||
return [-1] * 5
|
|
||||||
|
|
||||||
def get_thumb_matrix_touch(self,sleep_time=0):
|
|
||||||
return self._pressure(1)
|
|
||||||
|
|
||||||
def get_index_matrix_touch(self,sleep_time=0):
|
|
||||||
return self._pressure(2)
|
|
||||||
|
|
||||||
def get_middle_matrix_touch(self,sleep_time=0):
|
|
||||||
return self._pressure(3)
|
|
||||||
|
|
||||||
def get_ring_matrix_touch(self,sleep_time=0):
|
|
||||||
return self._pressure(4)
|
|
||||||
|
|
||||||
def get_little_matrix_touch(self,sleep_time=0):
|
|
||||||
return self._pressure(5)
|
|
||||||
|
|
||||||
def get_matrix_touch(self) -> list:
|
|
||||||
"""获取压感数据:矩阵式"""
|
|
||||||
return [self._pressure(1), self._pressure(2), self._pressure(3),
|
|
||||||
self._pressure(4), self._pressure(5)]
|
|
||||||
|
|
||||||
def get_matrix_touch_v2(self) -> list:
|
|
||||||
"""获取压感数据:矩阵式"""
|
|
||||||
return self.get_matrix_touch()
|
|
||||||
|
|
||||||
def get_torque(self) -> list:
|
|
||||||
"""获取当前扭矩"""
|
|
||||||
return self.read_torques()
|
|
||||||
|
|
||||||
def get_temperature(self) -> list:
|
|
||||||
"""获取当前电机温度"""
|
|
||||||
return self.read_temperatures()
|
|
||||||
|
|
||||||
def get_fault(self) -> list:
|
|
||||||
"""获取当前电机故障码"""
|
|
||||||
return self.read_error_codes()
|
|
||||||
|
|
||||||
def get_serial_number(self):
|
|
||||||
return [0] * 6
|
|
||||||
|
|
||||||
# --------------------------------------------------
|
|
||||||
# 便捷方法
|
|
||||||
# --------------------------------------------------
|
|
||||||
|
|
||||||
def relax(self):
|
|
||||||
"""所有手指伸直"""
|
|
||||||
self.set_joint_positions([255] * 6)
|
|
||||||
|
|
||||||
def fist(self):
|
|
||||||
"""所有手指握拳"""
|
|
||||||
self.set_joint_positions([0] * 6)
|
|
||||||
|
|
||||||
def dump_status(self):
|
|
||||||
"""打印状态信息"""
|
|
||||||
print("=" * 50)
|
|
||||||
print("L6机械手状态信息")
|
|
||||||
print("=" * 50)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 关节状态
|
|
||||||
angles = self.read_angles()
|
|
||||||
torques = self.read_torques()
|
|
||||||
speeds = self.read_speeds()
|
|
||||||
temps = self.read_temperatures()
|
|
||||||
errors = self.read_error_codes()
|
|
||||||
|
|
||||||
print("关节状态:")
|
|
||||||
for i, name in enumerate(self.JOINT_NAMES):
|
|
||||||
print(f" {name:15s}: 角度={angles[i]:3d}, 扭矩={torques[i]:3d}, "
|
|
||||||
f"速度={speeds[i]:3d}, 温度={temps[i]:2d}℃, 错误={errors[i]:2d}")
|
|
||||||
|
|
||||||
# 版本信息
|
|
||||||
versions = self.read_versions()
|
|
||||||
print("\n版本信息:")
|
|
||||||
for key, value in versions.items():
|
|
||||||
print(f" {key:20s}: {value}")
|
|
||||||
|
|
||||||
# 压力传感器测试
|
|
||||||
print("\n压力传感器测试:")
|
|
||||||
thumb_pressure = self.read_pressure_thumb()
|
|
||||||
print(f"大拇指压力数据长度: {len(thumb_pressure)}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"读取状态时出错: {e}")
|
|
||||||
|
|
||||||
print("=" * 50)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------- 演示程序 -------------------
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# 使用示例
|
|
||||||
try:
|
|
||||||
with LinkerHandL6RS485(hand_id=0x27, modbus_port="/dev/ttyUSB0", baudrate=115200) as hand:
|
|
||||||
print("连接成功!")
|
|
||||||
|
|
||||||
# 打印状态信息
|
|
||||||
hand.dump_status()
|
|
||||||
|
|
||||||
# 测试基本控制
|
|
||||||
print("\n测试控制功能...")
|
|
||||||
print("伸直手指...")
|
|
||||||
hand.relax()
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
print("握拳...")
|
|
||||||
hand.fist()
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
print("恢复伸直...")
|
|
||||||
hand.relax()
|
|
||||||
|
|
||||||
# 测试压力传感器
|
|
||||||
print("\n测试压力传感器...")
|
|
||||||
thumb_matrix = hand.get_thumb_matrix_touch()
|
|
||||||
print(f"大拇指压力数据: {len(thumb_matrix)}个点")
|
|
||||||
|
|
||||||
# 获取所有手指压力数据
|
|
||||||
all_matrices = hand.get_matrix_touch()
|
|
||||||
for i, name in enumerate(hand.FINGER_NAMES):
|
|
||||||
matrix = all_matrices[i]
|
|
||||||
print(f"{name}手指压力数据长度: {len(matrix)}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"错误: {e}")
|
|
||||||
-1157
File diff suppressed because it is too large
Load Diff
@@ -202,6 +202,11 @@ class LinkerHandApi:
|
|||||||
'''Get current joint state'''
|
'''Get current joint state'''
|
||||||
return self.hand.get_current_status()
|
return self.hand.get_current_status()
|
||||||
|
|
||||||
|
def get_state_cached(self):
|
||||||
|
"""Get the latest received state without transmitting new queries."""
|
||||||
|
getter = getattr(self.hand, "get_cached_current_status", None)
|
||||||
|
return getter() if getter is not None else None
|
||||||
|
|
||||||
|
|
||||||
def get_state_for_pub(self):
|
def get_state_for_pub(self):
|
||||||
return self.hand.get_current_pub_status()
|
return self.hand.get_current_pub_status()
|
||||||
|
|||||||
@@ -37,11 +37,30 @@ def command_changed(previous, current):
|
|||||||
return any(float(old) != float(new) for old, new in zip(previous, values))
|
return any(float(old) != float(new) for old, new in zip(previous, values))
|
||||||
|
|
||||||
|
|
||||||
|
def position_command_should_queue(previous, current, repeat=True):
|
||||||
|
"""Whether the newest position target should be written on this heartbeat."""
|
||||||
|
values = list(current)
|
||||||
|
return bool(values) and (
|
||||||
|
bool(repeat) or command_changed(previous, values)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def state_poll_due(last_poll_time, now, poll_period):
|
def state_poll_due(last_poll_time, now, poll_period):
|
||||||
"""Keep slow CAN state reads off the latency-sensitive command path."""
|
"""Keep slow CAN state reads off the latency-sensitive command path."""
|
||||||
return last_poll_time is None or now >= last_poll_time + poll_period
|
return last_poll_time is None or now >= last_poll_time + poll_period
|
||||||
|
|
||||||
|
|
||||||
|
def state_reads_deferred(
|
||||||
|
last_command_time, now, quiet_period, enabled=True
|
||||||
|
):
|
||||||
|
"""Return whether blocking state reads must yield to active commands."""
|
||||||
|
return (
|
||||||
|
enabled
|
||||||
|
and last_command_time is not None
|
||||||
|
and now < last_command_time + quiet_period
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LinkerHand(Node):
|
class LinkerHand(Node):
|
||||||
def __init__(self, name):
|
def __init__(self, name):
|
||||||
super().__init__(name)
|
super().__init__(name)
|
||||||
@@ -54,6 +73,12 @@ class LinkerHand(Node):
|
|||||||
# -1 keeps the model's original startup speed. Camera teleoperation can
|
# -1 keeps the model's original startup speed. Camera teleoperation can
|
||||||
# set this to a conservative value before the startup pose is sent.
|
# set this to a conservative value before the startup pose is sent.
|
||||||
self.declare_parameter('startup_speed', -1)
|
self.declare_parameter('startup_speed', -1)
|
||||||
|
# -1 keeps the model's original startup torque. The retarget v2
|
||||||
|
# launch uses a conservative value for calibration and preview.
|
||||||
|
self.declare_parameter('startup_torque', -1)
|
||||||
|
# Preserve the legacy behaviour by default. Safety-critical launch
|
||||||
|
# files can configure limits without moving to a startup pose.
|
||||||
|
self.declare_parameter('move_on_startup', True)
|
||||||
# Empty keeps the legacy absolute topics/startup pose. A prefix lets
|
# Empty keeps the legacy absolute topics/startup pose. A prefix lets
|
||||||
# two same-side hands coexist without receiving each other's commands.
|
# two same-side hands coexist without receiving each other's commands.
|
||||||
self.declare_parameter('topic_prefix', '')
|
self.declare_parameter('topic_prefix', '')
|
||||||
@@ -63,6 +88,17 @@ class LinkerHand(Node):
|
|||||||
# incoming position commands.
|
# incoming position commands.
|
||||||
self.declare_parameter('state_poll_rate', 60.0)
|
self.declare_parameter('state_poll_rate', 60.0)
|
||||||
self.declare_parameter('velocity_poll_rate', 60.0)
|
self.declare_parameter('velocity_poll_rate', 60.0)
|
||||||
|
# G20 state and velocity reads each transmit five synchronous CAN
|
||||||
|
# queries. Defer them while teleoperation commands are arriving.
|
||||||
|
self.declare_parameter('defer_state_reads_while_commanding', True)
|
||||||
|
self.declare_parameter('command_quiet_period', 0.2)
|
||||||
|
# Legacy teleoperation sent the latest target on every 30 Hz callback,
|
||||||
|
# including an unchanged target. Some firmware revisions track that
|
||||||
|
# cadence more smoothly than sparse change-only updates.
|
||||||
|
self.declare_parameter('repeat_position_commands', True)
|
||||||
|
# Faults stay manually clearable through cb_hand_setting_cmd. Repeated
|
||||||
|
# automatic clears add periodic CAN traffic to the command stream.
|
||||||
|
self.declare_parameter('auto_clear_faults', False)
|
||||||
|
|
||||||
# ros时间获取
|
# ros时间获取
|
||||||
self.stamp_clock = Clock()
|
self.stamp_clock = Clock()
|
||||||
@@ -75,6 +111,12 @@ class LinkerHand(Node):
|
|||||||
self.startup_speed = int(self.get_parameter('startup_speed').value)
|
self.startup_speed = int(self.get_parameter('startup_speed').value)
|
||||||
if self.startup_speed < -1 or self.startup_speed > 255:
|
if self.startup_speed < -1 or self.startup_speed > 255:
|
||||||
raise ValueError('startup_speed must be -1 or in the range [0, 255]')
|
raise ValueError('startup_speed must be -1 or in the range [0, 255]')
|
||||||
|
self.startup_torque = int(self.get_parameter('startup_torque').value)
|
||||||
|
if self.startup_torque < -1 or self.startup_torque > 255:
|
||||||
|
raise ValueError('startup_torque must be -1 or in the range [0, 255]')
|
||||||
|
self.move_on_startup = bool(
|
||||||
|
self.get_parameter('move_on_startup').value
|
||||||
|
)
|
||||||
self.topic_prefix = self.normalize_topic_prefix(
|
self.topic_prefix = self.normalize_topic_prefix(
|
||||||
self.get_parameter('topic_prefix').value
|
self.get_parameter('topic_prefix').value
|
||||||
)
|
)
|
||||||
@@ -92,6 +134,22 @@ class LinkerHand(Node):
|
|||||||
raise ValueError('velocity_poll_rate must be greater than zero')
|
raise ValueError('velocity_poll_rate must be greater than zero')
|
||||||
self.velocity_poll_period = 1.0 / self.velocity_poll_rate
|
self.velocity_poll_period = 1.0 / self.velocity_poll_rate
|
||||||
self.last_velocity_poll_time = None
|
self.last_velocity_poll_time = None
|
||||||
|
self.defer_state_reads_while_commanding = bool(
|
||||||
|
self.get_parameter(
|
||||||
|
'defer_state_reads_while_commanding'
|
||||||
|
).value
|
||||||
|
)
|
||||||
|
self.command_quiet_period = float(
|
||||||
|
self.get_parameter('command_quiet_period').value
|
||||||
|
)
|
||||||
|
if self.command_quiet_period < 0.0:
|
||||||
|
raise ValueError('command_quiet_period must not be negative')
|
||||||
|
self.auto_clear_faults = bool(
|
||||||
|
self.get_parameter('auto_clear_faults').value
|
||||||
|
)
|
||||||
|
self.repeat_position_commands = bool(
|
||||||
|
self.get_parameter('repeat_position_commands').value
|
||||||
|
)
|
||||||
configured_startup_pose = self.get_parameter_or(
|
configured_startup_pose = self.get_parameter_or(
|
||||||
'startup_pose',
|
'startup_pose',
|
||||||
Parameter('startup_pose', Parameter.Type.INTEGER_ARRAY, []),
|
Parameter('startup_pose', Parameter.Type.INTEGER_ARRAY, []),
|
||||||
@@ -107,6 +165,7 @@ class LinkerHand(Node):
|
|||||||
self.last_hand_eff_cmd = None # 最新手指力矩命令
|
self.last_hand_eff_cmd = None # 最新手指力矩命令
|
||||||
self.applied_hand_post_cmd = None
|
self.applied_hand_post_cmd = None
|
||||||
self.applied_hand_vel_cmd = None
|
self.applied_hand_vel_cmd = None
|
||||||
|
self.last_position_command_time = None
|
||||||
|
|
||||||
self.last_hand_state = [-1] * 10
|
self.last_hand_state = [-1] * 10
|
||||||
self.last_hand_vel = [-1] * 10
|
self.last_hand_vel = [-1] * 10
|
||||||
@@ -244,26 +303,38 @@ class LinkerHand(Node):
|
|||||||
pose = list(self.startup_pose)
|
pose = list(self.startup_pose)
|
||||||
if self.startup_speed >= 0:
|
if self.startup_speed >= 0:
|
||||||
speed = [self.startup_speed] * len(speed)
|
speed = [self.startup_speed] * len(speed)
|
||||||
|
if self.startup_torque >= 0:
|
||||||
|
torque = [self.startup_torque] * len(torque)
|
||||||
if pose is not None:
|
if pose is not None:
|
||||||
for i in range(1):
|
for i in range(1):
|
||||||
self.api.set_speed(speed=speed)
|
self.api.set_speed(speed=speed)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
self.api.set_torque(torque=torque)
|
self.api.set_torque(torque=torque)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
self.api.finger_move(pose=pose)
|
if self.move_on_startup:
|
||||||
time.sleep(0.1)
|
self.api.finger_move(pose=pose)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
def hand_control_cb(self, msg):
|
def hand_control_cb(self, msg):
|
||||||
# The hardware can be slower than the camera. Always replace a
|
# The hardware can be slower than the publisher, so a pending target is
|
||||||
# pending command with the newest sample and never replay an already
|
# always replaced by the newest sample. By default the newest target
|
||||||
# applied sample; this prevents latency from accumulating in software.
|
# is also resent at the publisher cadence, matching the legacy driver.
|
||||||
position = list(msg.position)
|
position = list(msg.position)
|
||||||
if position:
|
if position:
|
||||||
self.last_hand_post_cmd = (
|
# Treat every valid sample as an active teleoperation heartbeat,
|
||||||
position
|
# even if integer quantisation made it identical to the previous
|
||||||
if command_changed(self.applied_hand_post_cmd, position)
|
# target. This keeps all synchronous CAN diagnostics off the bus
|
||||||
else None
|
# for the entire control session, matching the legacy execution
|
||||||
)
|
# path that had no state subscriber.
|
||||||
|
self.last_position_command_time = time.monotonic()
|
||||||
|
if position_command_should_queue(
|
||||||
|
self.applied_hand_post_cmd,
|
||||||
|
position,
|
||||||
|
self.repeat_position_commands,
|
||||||
|
):
|
||||||
|
self.last_hand_post_cmd = position
|
||||||
|
else:
|
||||||
|
self.last_hand_post_cmd = None
|
||||||
|
|
||||||
velocity = list(msg.velocity)
|
velocity = list(msg.velocity)
|
||||||
if velocity:
|
if velocity:
|
||||||
@@ -286,6 +357,9 @@ class LinkerHand(Node):
|
|||||||
self.api.finger_move(pose=pose)
|
self.api.finger_move(pose=pose)
|
||||||
self.applied_hand_post_cmd = pose
|
self.applied_hand_post_cmd = pose
|
||||||
self.last_hand_post_cmd = None
|
self.last_hand_post_cmd = None
|
||||||
|
cached_state = self.api.get_state_cached()
|
||||||
|
if cached_state is not None:
|
||||||
|
self.last_hand_state = cached_state
|
||||||
|
|
||||||
if self.last_hand_vel_cmd is not None:
|
if self.last_hand_vel_cmd is not None:
|
||||||
vel = list(self.last_hand_vel_cmd)
|
vel = list(self.last_hand_vel_cmd)
|
||||||
@@ -320,6 +394,15 @@ class LinkerHand(Node):
|
|||||||
if self.hand_state_pub.get_subscription_count() < 1:
|
if self.hand_state_pub.get_subscription_count() < 1:
|
||||||
return
|
return
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
if state_reads_deferred(
|
||||||
|
self.last_position_command_time,
|
||||||
|
now,
|
||||||
|
self.command_quiet_period,
|
||||||
|
self.defer_state_reads_while_commanding,
|
||||||
|
):
|
||||||
|
# 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(
|
if not state_poll_due(
|
||||||
self.last_state_poll_time, now, self.state_poll_period
|
self.last_state_poll_time, now, self.state_poll_period
|
||||||
):
|
):
|
||||||
@@ -342,6 +425,12 @@ class LinkerHand(Node):
|
|||||||
# Position commands have priority over synchronous state reads.
|
# Position commands have priority over synchronous state reads.
|
||||||
self._apply_pending_commands()
|
self._apply_pending_commands()
|
||||||
self._poll_state_if_due()
|
self._poll_state_if_due()
|
||||||
|
diagnostics_deferred = state_reads_deferred(
|
||||||
|
self.last_position_command_time,
|
||||||
|
time.monotonic(),
|
||||||
|
self.command_quiet_period,
|
||||||
|
self.defer_state_reads_while_commanding,
|
||||||
|
)
|
||||||
if self.cmd_lock == False:
|
if self.cmd_lock == False:
|
||||||
time.sleep(0.003)
|
time.sleep(0.003)
|
||||||
if self.run_count == 3 and self.is_touch == True and self.touch_type == 1 and self.modbus == "None" and self.touch_pub.get_subscription_count() > 0:
|
if self.run_count == 3 and self.is_touch == True and self.touch_type == 1 and self.modbus == "None" and self.touch_pub.get_subscription_count() > 0:
|
||||||
@@ -360,7 +449,11 @@ class LinkerHand(Node):
|
|||||||
if self.run_count == 7:
|
if self.run_count == 7:
|
||||||
self.matrix_dic["little_matrix"] = self.api.get_little_matrix_touch(sleep_time=self.sleep_time).tolist()
|
self.matrix_dic["little_matrix"] = self.api.get_little_matrix_touch(sleep_time=self.sleep_time).tolist()
|
||||||
time.sleep(0.005)
|
time.sleep(0.005)
|
||||||
if self.run_count == 8 and self.hand_info_pub.get_subscription_count() > 0:
|
if (
|
||||||
|
self.run_count == 8
|
||||||
|
and self.hand_info_pub.get_subscription_count() > 0
|
||||||
|
and not diagnostics_deferred
|
||||||
|
):
|
||||||
"""手部信息"""
|
"""手部信息"""
|
||||||
self.last_hand_info = {
|
self.last_hand_info = {
|
||||||
"version": self.embedded_version, # Dexterous hand version number
|
"version": self.embedded_version, # Dexterous hand version number
|
||||||
@@ -375,8 +468,9 @@ class LinkerHand(Node):
|
|||||||
"finger_order": self.api.get_finger_order() # Finger motor order
|
"finger_order": self.api.get_finger_order() # Finger motor order
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.run_count == 9:
|
if self.run_count == 9 and self.auto_clear_faults:
|
||||||
self.api.clear_faults() # 自动清除错误编码
|
self.api.clear_faults() # 自动清除错误编码
|
||||||
|
if self.run_count == 9:
|
||||||
self.run_count = 0
|
self.run_count = 0
|
||||||
self.run_count += 1
|
self.run_count += 1
|
||||||
time.sleep(0.003)
|
time.sleep(0.003)
|
||||||
|
|||||||
@@ -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,
|
COMMAND_QOS,
|
||||||
LinkerHand,
|
LinkerHand,
|
||||||
command_changed,
|
command_changed,
|
||||||
|
position_command_should_queue,
|
||||||
state_poll_due,
|
state_poll_due,
|
||||||
|
state_reads_deferred,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +32,25 @@ def test_identical_commands_are_not_reapplied():
|
|||||||
assert not command_changed([60, 60], [])
|
assert not command_changed([60, 60], [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_heartbeat_resends_unchanged_position_target():
|
||||||
|
assert position_command_should_queue([60, 60], [60, 60], repeat=True)
|
||||||
|
assert not position_command_should_queue(
|
||||||
|
[60, 60], [60, 60], repeat=False
|
||||||
|
)
|
||||||
|
assert position_command_should_queue(
|
||||||
|
[60, 60], [60, 61], repeat=False
|
||||||
|
)
|
||||||
|
assert not position_command_should_queue([60, 60], [], repeat=True)
|
||||||
|
|
||||||
|
|
||||||
def test_state_polling_is_throttled_without_missing_deadline():
|
def test_state_polling_is_throttled_without_missing_deadline():
|
||||||
assert state_poll_due(None, 10.0, 0.1)
|
assert state_poll_due(None, 10.0, 0.1)
|
||||||
assert not state_poll_due(10.0, 10.09, 0.1)
|
assert not state_poll_due(10.0, 10.09, 0.1)
|
||||||
assert state_poll_due(10.0, 10.1, 0.1)
|
assert state_poll_due(10.0, 10.1, 0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_reads_yield_to_recent_motion_then_resume():
|
||||||
|
assert state_reads_deferred(10.0, 10.1, 0.2)
|
||||||
|
assert not state_reads_deferred(10.0, 10.2, 0.2)
|
||||||
|
assert not state_reads_deferred(None, 10.1, 0.2)
|
||||||
|
assert not state_reads_deferred(10.0, 10.1, 0.2, enabled=False)
|
||||||
|
|||||||
Submodule
+1
Submodule src/linkerhand-l30-sdk added at 0103fc55f9
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
|||||||
|
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
|
||||||
|
# All four tags keep a temporally continuous IPPE solution throughout the
|
||||||
|
# complete session. With 30 px planar tags, tiny reprojection differences
|
||||||
|
# do not reliably identify the physical branch and previously caused
|
||||||
|
# stationary T0 to flip by about 25 deg between scan and validation.
|
||||||
|
pnp_reprojection_tie_px: 1.5
|
||||||
|
pnp_maximum_pose_jump_deg: 35.0
|
||||||
|
pnp_maximum_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,37 @@
|
|||||||
|
schema_version: 1
|
||||||
|
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: 0faaf891ebb616c4c8a3bb3052c48fa4b6c8aa0c5fdc5abaaa89f4fc29cca1c3
|
||||||
|
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,37 @@
|
|||||||
|
schema_version: 2
|
||||||
|
profile_id: L6/right/l6_right_8/v1
|
||||||
|
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: 0934699c8225891e748deefef6791eb28355821b89aeadd1f7ff0b7f7b4d265f
|
||||||
|
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,67 @@
|
|||||||
|
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_reprojection_tie_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: 2
|
||||||
|
non_target_motion_tolerance_u8: 3.0
|
||||||
|
fixed_base_maximum_corner_drift_px: 2.0
|
||||||
|
fixed_base_movement_confirmation_frames: 5
|
||||||
@@ -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,43 @@
|
|||||||
|
schema_version: 3
|
||||||
|
profile_id: O12/right/o12_right_16/v1
|
||||||
|
model: O12
|
||||||
|
side: right
|
||||||
|
tag_layout: o12_right_16
|
||||||
|
namespace: /o12_calibration
|
||||||
|
serial_number: O12_RIGHT_001
|
||||||
|
output_root: calibration_output
|
||||||
|
|
||||||
|
sdk:
|
||||||
|
driver: omnihand_pro_2025_node
|
||||||
|
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: ca1791c822bf1e99f25db8806397c3f877100c9252fcce6c53a7a6b8cef7694a
|
||||||
|
|
||||||
|
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/g20_three_camera_extrinsics.yaml
|
||||||
|
camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80
|
||||||
|
calibration_config: package://linkerhand_calibration/config/o12_three_camera_calibration.yaml
|
||||||
|
calibration_config_sha256: 9defe39a3f4b31c3764cbdcd9ee5b570da70e810215844515e8cc01e56414728
|
||||||
|
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,50 @@
|
|||||||
|
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. Raw 0..2000 mixed control is forbidden.
|
||||||
|
command_rate_hz: 20.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: 15.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: 1.0
|
||||||
|
motor_stall_timeout_seconds: 2.0
|
||||||
|
position_timeout_seconds: 60.0
|
||||||
|
sweep_timeout_seconds: 180.0
|
||||||
|
automatic_sweep_retry_limit: 2
|
||||||
|
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
|
||||||
|
# 现场确认初始保守速度过慢;正式/点动速度提高到2倍,避让另有硬上限。
|
||||||
|
motion_speed_scale: 2.0
|
||||||
|
maximum_hamming: 0
|
||||||
|
minimum_decision_margin: 30.0
|
||||||
|
minimum_edge_pixels: 30.0
|
||||||
|
fixed_base_maximum_corner_drift_px: 2.0
|
||||||
|
fixed_base_movement_confirmation_frames: 5
|
||||||
|
pnp_maximum_reprojection_error_px: 1.5
|
||||||
|
pnp_reprojection_tie_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,37 @@
|
|||||||
|
schema_version: 2
|
||||||
|
profile_id: O6/right/o6_right_8/v1
|
||||||
|
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: ce20d998a4342dfaacb14568513aa9af5063df48566fabd42180acc8da47e4a6
|
||||||
|
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,64 @@
|
|||||||
|
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_reprojection_tie_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: 2
|
||||||
|
non_target_motion_tolerance_u8: 3.0
|
||||||
|
fixed_base_maximum_corner_drift_px: 2.0
|
||||||
|
fixed_base_movement_confirmation_frames: 5
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
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_reprojection_tie_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: 2.0
|
||||||
|
fixed_base_movement_confirmation_frames: 5
|
||||||
|
# 仅在拇指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
|
||||||
|
# 19-Tag产品每项正式四轮前先做一次低速往返,端点Tag稳定至少2秒。
|
||||||
|
task_precheck_hold_seconds: 2.0
|
||||||
|
position_timeout_seconds: 30.0
|
||||||
|
sweep_timeout_seconds: 90.0
|
||||||
|
# 启动宽限1秒后,反馈连续2秒没有至少1个u8的进展,按机械卡滞立即暂停;
|
||||||
|
# 这类故障不进入遮挡/超时的三次自动重扫。
|
||||||
|
# 低速5也应持续产生反馈进展;5秒无进展即停,减少机构持续顶死时间。
|
||||||
|
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: 2
|
||||||
|
# 轨迹拟合失败优先只重扫失败轮次;零位/URDF模型失败不重复运动。
|
||||||
|
automatic_fit_retry_limit: 2
|
||||||
|
automatic_motion_retry_limit: 2
|
||||||
|
# 留空为正式标定;设为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: [0.8, 0.6]
|
||||||
|
retry_endpoint_hold_seconds: [0.75, 1.0]
|
||||||
|
|
||||||
|
trajectory_maximum_plane_rms_m: 0.004
|
||||||
|
trajectory_maximum_radial_rms_m: 0.004
|
||||||
|
trajectory_minimum_radius_m: 0.003
|
||||||
|
trajectory_minimum_arc_deg: 15.0
|
||||||
|
# 以下二维参数只供旧轨迹工具兼容,三机位v4零位不使用二维投影。
|
||||||
|
image_trajectory_maximum_radial_rms_px: 2.0
|
||||||
|
image_trajectory_maximum_radial_p95_px: 3.5
|
||||||
|
image_trajectory_minimum_radius_px: 20.0
|
||||||
|
trajectory_maximum_cycle_travel_difference_deg: 3.0
|
||||||
|
passive_maximum_cycle_travel_difference_deg: 10.0
|
||||||
|
maximum_monotonic_correction_deg: 2.0
|
||||||
|
# 旧布局仍用连续扫描正反程差门限;19-Tag产品的连续运动包含速度相关滞后,
|
||||||
|
# 由方向曲线和最终留出验证建模,不再重复硬判。其绝对正反程门禁使用下面
|
||||||
|
# 的九点稳态command_maximum_direction_gap_deg。
|
||||||
|
maximum_hysteresis_deg: 2.0
|
||||||
|
# 19-Tag产品模式额外要求每轮正反方向在各自baseline处绕实测关节轴的角度差
|
||||||
|
# 不超过0.5°;四指roll例外:127以255→127为唯一物理零位,反向分支
|
||||||
|
# 保留实测偏差,并改为检查分支间隙上限及跨轮稳定性。
|
||||||
|
baseline_maximum_hysteresis_deg: 0.5
|
||||||
|
directional_zero_maximum_branch_gap_deg: 2.0
|
||||||
|
directional_zero_maximum_branch_gap_range_deg: 0.3
|
||||||
|
cross_view_roll_maximum_branch_gap_difference_deg: 0.3
|
||||||
|
# 正面roll是Tag中心的二维投影角,侧面roll是三维姿态角。允许一个有界的
|
||||||
|
# 固定比例吸收Tag安装倾角/偏置带来的投影缩放,再严格比较两条曲线形状;
|
||||||
|
# 比例过大、方向相反、形状RMS及两视角各自的四轮重复性仍会失败。
|
||||||
|
cross_view_roll_maximum_shape_rms_deg: 1.25
|
||||||
|
cross_view_roll_maximum_projection_scale_ratio: 1.5
|
||||||
|
passive_maximum_monotonic_correction_deg: 3.0
|
||||||
|
passive_maximum_hysteresis_deg: 2.0
|
||||||
|
# 九点姿态先按实测反馈重新对齐,再用此门限检查固有机械回差;请求命令域
|
||||||
|
# 的两条运行曲线仍原样保留固件方向死区,不能把command/feedback差算成回差。
|
||||||
|
command_maximum_direction_gap_deg: 2.0
|
||||||
|
|
||||||
|
# 默认无额外随机动作;19-Tag产品最终一轮始终作为不可关闭的留出验证。
|
||||||
|
validation_enabled: false
|
||||||
|
# 第四轮留出求解后必须再走8个固定安全组合姿态;三机位规定Tag全部可见
|
||||||
|
# 且实测20通道到位才允许发布。只保存Tag位姿,不保存原始图像。
|
||||||
|
# Developer diagnostic only. The formal fourth sweep cycle already gives
|
||||||
|
# every isolated PIP/DIP pair an independent holdout.
|
||||||
|
combination_validation_enabled: false
|
||||||
|
combination_validation_frames: 10
|
||||||
|
combination_maximum_position_p95_m: 0.003
|
||||||
|
combination_maximum_orientation_p95_deg: 2.0
|
||||||
|
validation_command_count: 3
|
||||||
|
validation_frames: 10
|
||||||
|
validation_seed: 20260804
|
||||||
|
validation_timeout_seconds: 20.0
|
||||||
|
maximum_validation_mae_deg: 1.0
|
||||||
|
maximum_validation_p95_deg: 2.0
|
||||||
|
# 19-Tag产品模式使用更严格的任一点及静态零偏95%置信区间门限。
|
||||||
|
maximum_validation_error_deg: 3.0
|
||||||
|
zero_maximum_confidence_half_width_deg: 1.5
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/g20_calibration/front/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
profile: false
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.5
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [0, 1, 2, 3, 10]
|
||||||
|
frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, index_roll]
|
||||||
|
sizes: [0.016, 0.016, 0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
/g20_calibration/side/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
profile: false
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.5
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [4, 5, 6, 7]
|
||||||
|
frames: [side_base, index_mcp, index_pip, index_dip]
|
||||||
|
sizes: [0.016, 0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
/g20_calibration/top/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
profile: false
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.5
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [8, 9]
|
||||||
|
frames: [top_base, thumb_yaw]
|
||||||
|
sizes: [0.016, 0.016]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/g20_calibration/front/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
profile: false
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.5
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [0, 1, 2, 3, 10, 11, 12, 13]
|
||||||
|
frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, pinky_roll, ring_roll, middle_roll, index_roll]
|
||||||
|
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
/g20_calibration/side/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
profile: false
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.5
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [4, 5, 6, 15, 17]
|
||||||
|
frames: [side_base, ring_pip, pinky_pip, middle_pip, index_pip]
|
||||||
|
sizes: [0.016, 0.016, 0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
/g20_calibration/top/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
profile: false
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.5
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [8, 9]
|
||||||
|
frames: [top_base, thumb_yaw]
|
||||||
|
sizes: [0.016, 0.016]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/g20_calibration/front/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
profile: false
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.5
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [0, 1, 2, 3, 10, 11, 12, 13]
|
||||||
|
frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, pinky_roll, ring_roll, middle_roll, index_roll]
|
||||||
|
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
/g20_calibration/side/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
profile: false
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
# Distal Tags use the same measured 16 mm black-code edge as all others.
|
||||||
|
decimate: 1.0
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [4, 5, 6, 7, 14, 15, 16, 17, 18]
|
||||||
|
frames: [side_base, ring_pip, pinky_pip, pinky_dip, ring_dip, middle_pip, middle_dip, index_pip, index_dip]
|
||||||
|
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
|
||||||
|
|
||||||
|
/g20_calibration/top/apriltag/apriltag:
|
||||||
|
ros__parameters:
|
||||||
|
image_transport: raw
|
||||||
|
qos_profile: sensor_data
|
||||||
|
family: 36h11
|
||||||
|
size: 0.016
|
||||||
|
profile: false
|
||||||
|
max_hamming: 0
|
||||||
|
detector:
|
||||||
|
threads: 4
|
||||||
|
decimate: 1.5
|
||||||
|
blur: 0.0
|
||||||
|
refine: true
|
||||||
|
sharpening: 0.25
|
||||||
|
debug: false
|
||||||
|
pose_estimation_method: pnp
|
||||||
|
tag:
|
||||||
|
ids: [8, 9]
|
||||||
|
frames: [top_base, thumb_yaw]
|
||||||
|
sizes: [0.016, 0.016]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""One-release compatibility surface for the former Python package name.
|
||||||
|
|
||||||
|
New code must import :mod:`linkerhand_calibration`. Only the documented
|
||||||
|
configuration loader is re-exported here; calibration algorithms continue to
|
||||||
|
have a single implementation in the renamed package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"g20_thumb_apriltag_calibration is deprecated; "
|
||||||
|
"import linkerhand_calibration instead",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
from linkerhand_calibration.product import ProductConfig, load_product_config
|
||||||
|
|
||||||
|
__all__ = ["ProductConfig", "load_product_config"]
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
"""Deprecated forwarding entry point for the runtime joint-state bridge."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.calibrated_joint_state_bridge import main
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Deprecated forwarding entry point for offline replay."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.offline_replay import main
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Deprecated forwarding entry point for the former Python package."""
|
||||||
|
|
||||||
|
from linkerhand_calibration.one_command import main
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Publish profile-calibrated URDF angles from raw command/feedback u8 values."""
|
||||||
|
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import DeclareLaunchArgument
|
||||||
|
from launch.substitutions import LaunchConfiguration
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description() -> LaunchDescription:
|
||||||
|
return LaunchDescription(
|
||||||
|
[
|
||||||
|
DeclareLaunchArgument("hand_type", default_value="right"),
|
||||||
|
DeclareLaunchArgument("calibration_file"),
|
||||||
|
DeclareLaunchArgument("input_topic", default_value=""),
|
||||||
|
DeclareLaunchArgument("output_topic", default_value=""),
|
||||||
|
Node(
|
||||||
|
package="linkerhand_calibration",
|
||||||
|
executable="calibrated_joint_state_bridge",
|
||||||
|
name=[
|
||||||
|
"calibrated_joint_state_bridge_",
|
||||||
|
LaunchConfiguration("hand_type"),
|
||||||
|
],
|
||||||
|
output="screen",
|
||||||
|
emulate_tty=True,
|
||||||
|
parameters=[
|
||||||
|
{
|
||||||
|
"hand_type": LaunchConfiguration("hand_type"),
|
||||||
|
"calibration_file": LaunchConfiguration(
|
||||||
|
"calibration_file"
|
||||||
|
),
|
||||||
|
"input_topic": LaunchConfiguration("input_topic"),
|
||||||
|
"output_topic": LaunchConfiguration("output_topic"),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""Launch front-camera trajectory-circle CMC pitch zero measurement."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import (
|
||||||
|
DeclareLaunchArgument,
|
||||||
|
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):
|
||||||
|
serial_number = LaunchConfiguration("serial_number").perform(context)
|
||||||
|
if (
|
||||||
|
not serial_number
|
||||||
|
or serial_number == "UNSET"
|
||||||
|
or re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None
|
||||||
|
or serial_number in {".", ".."}
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"serial_number is required and may contain only letters, "
|
||||||
|
"digits, dot, underscore and dash"
|
||||||
|
)
|
||||||
|
|
||||||
|
requested_session = LaunchConfiguration("session_dir").perform(context)
|
||||||
|
output_root = Path(
|
||||||
|
LaunchConfiguration("output_root").perform(context)
|
||||||
|
).expanduser().resolve()
|
||||||
|
if requested_session:
|
||||||
|
session_dir = Path(requested_session).expanduser().resolve()
|
||||||
|
else:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
session_dir = output_root / serial_number / timestamp
|
||||||
|
session_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
tag_config = LaunchConfiguration("tag_config").perform(context)
|
||||||
|
zero_config = LaunchConfiguration("zero_config").perform(context)
|
||||||
|
camera = Node(
|
||||||
|
package="linkerhand_calibration",
|
||||||
|
executable="hikrobot_camera_node",
|
||||||
|
name="hikrobot_camera",
|
||||||
|
namespace="/camera/camera/color",
|
||||||
|
output="screen",
|
||||||
|
emulate_tty=True,
|
||||||
|
condition=IfCondition(LaunchConfiguration("start_camera")),
|
||||||
|
parameters=[
|
||||||
|
{
|
||||||
|
"serial_number": LaunchConfiguration("camera_serial_number"),
|
||||||
|
"expected_model": LaunchConfiguration("camera_model"),
|
||||||
|
"camera_name": LaunchConfiguration("camera_name"),
|
||||||
|
"frame_id": LaunchConfiguration("camera_frame_id"),
|
||||||
|
"image_width": ParameterValue(
|
||||||
|
LaunchConfiguration("image_width"), value_type=int
|
||||||
|
),
|
||||||
|
"image_height": ParameterValue(
|
||||||
|
LaunchConfiguration("image_height"), value_type=int
|
||||||
|
),
|
||||||
|
"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("camera_info_url"),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_topic = "/camera/camera/color/image_raw"
|
||||||
|
camera_info_topic = "/camera/camera/color/camera_info"
|
||||||
|
rect_topic = "/camera/camera/color/image_rect"
|
||||||
|
vision_container = ComposableNodeContainer(
|
||||||
|
name="g20_thumb_zero_vision_container",
|
||||||
|
namespace="/",
|
||||||
|
package="rclcpp_components",
|
||||||
|
executable="component_container_mt",
|
||||||
|
composable_node_descriptions=[
|
||||||
|
ComposableNode(
|
||||||
|
package="image_proc",
|
||||||
|
plugin="image_proc::RectifyNode",
|
||||||
|
name="rectify_color",
|
||||||
|
namespace="/camera/camera/color",
|
||||||
|
remappings=[
|
||||||
|
("image", raw_topic),
|
||||||
|
("camera_info", camera_info_topic),
|
||||||
|
("image_rect", rect_topic),
|
||||||
|
],
|
||||||
|
parameters=[{"queue_size": 1}],
|
||||||
|
extra_arguments=[{"use_intra_process_comms": True}],
|
||||||
|
),
|
||||||
|
ComposableNode(
|
||||||
|
package="apriltag_ros",
|
||||||
|
plugin="AprilTagNode",
|
||||||
|
name="apriltag",
|
||||||
|
namespace="/apriltag",
|
||||||
|
parameters=[
|
||||||
|
tag_config,
|
||||||
|
{
|
||||||
|
"detector.decimate": ParameterValue(
|
||||||
|
LaunchConfiguration("apriltag_decimate"),
|
||||||
|
value_type=float,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
remappings=[
|
||||||
|
("image_rect", rect_topic),
|
||||||
|
("camera_info", camera_info_topic),
|
||||||
|
],
|
||||||
|
extra_arguments=[{"use_intra_process_comms": True}],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
output="screen",
|
||||||
|
emulate_tty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
sdk = Node(
|
||||||
|
package="linker_hand_ros2_sdk",
|
||||||
|
executable="linker_hand_sdk",
|
||||||
|
name="linker_hand_sdk",
|
||||||
|
output="screen",
|
||||||
|
condition=IfCondition(LaunchConfiguration("start_sdk")),
|
||||||
|
parameters=[
|
||||||
|
{
|
||||||
|
"hand_type": "left",
|
||||||
|
"hand_joint": "G20",
|
||||||
|
"can": LaunchConfiguration("can_interface"),
|
||||||
|
"modbus": "None",
|
||||||
|
"topic_prefix": "/g20",
|
||||||
|
"move_on_startup": False,
|
||||||
|
"startup_speed": ParameterValue(
|
||||||
|
LaunchConfiguration("calibration_speed"),
|
||||||
|
value_type=int,
|
||||||
|
),
|
||||||
|
"startup_torque": 80,
|
||||||
|
"state_poll_rate": 10.0,
|
||||||
|
"repeat_position_commands": False,
|
||||||
|
"is_touch": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
zero_node = Node(
|
||||||
|
package="linkerhand_calibration",
|
||||||
|
executable="cmc_pitch_zero_node",
|
||||||
|
name="g20_thumb_cmc_pitch_zero",
|
||||||
|
output="screen",
|
||||||
|
parameters=[
|
||||||
|
zero_config,
|
||||||
|
{
|
||||||
|
"serial_number": serial_number,
|
||||||
|
"session_dir": str(session_dir),
|
||||||
|
"commands_enabled": ParameterValue(
|
||||||
|
LaunchConfiguration("commands_enabled"),
|
||||||
|
value_type=bool,
|
||||||
|
),
|
||||||
|
"image_topic": rect_topic,
|
||||||
|
"publish_debug_image": ParameterValue(
|
||||||
|
LaunchConfiguration("publish_debug_image"),
|
||||||
|
value_type=bool,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
LogInfo(msg=f"G20 CMC pitch zero session: {session_dir}"),
|
||||||
|
LogInfo(
|
||||||
|
msg=(
|
||||||
|
"Only T0(ID 0) and T3(ID 1) are required; "
|
||||||
|
"T4/T5 detections are ignored"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
LogInfo(
|
||||||
|
msg=(
|
||||||
|
"Motor 0 performs three 255->64->255 sweeps; "
|
||||||
|
"zero angles come from the fitted T3-centre trajectory radius"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
camera,
|
||||||
|
vision_container,
|
||||||
|
sdk,
|
||||||
|
zero_node,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description() -> LaunchDescription:
|
||||||
|
package_share = Path(
|
||||||
|
get_package_share_directory("linkerhand_calibration")
|
||||||
|
)
|
||||||
|
return LaunchDescription(
|
||||||
|
[
|
||||||
|
SetEnvironmentVariable(
|
||||||
|
name="FASTRTPS_DEFAULT_PROFILES_FILE",
|
||||||
|
value=str(
|
||||||
|
package_share / "config" / "fastdds_large_images.xml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("serial_number", default_value="UNSET"),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_serial_number", default_value="DB2163742"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_model", default_value="MV-CS020-10UM"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_name", default_value="hikrobot_front_DB2163742"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_frame_id", default_value="camera_color_optical_frame"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("image_width", default_value="1624"),
|
||||||
|
DeclareLaunchArgument("image_height", default_value="1240"),
|
||||||
|
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(
|
||||||
|
"camera_info_url",
|
||||||
|
default_value=str(
|
||||||
|
Path.home()
|
||||||
|
/ ".ros"
|
||||||
|
/ "camera_info"
|
||||||
|
/ "hikrobot_DB2163742.yaml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("can_interface", default_value="can0"),
|
||||||
|
DeclareLaunchArgument("calibration_speed", default_value="15"),
|
||||||
|
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
|
||||||
|
DeclareLaunchArgument("commands_enabled", default_value="true"),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"publish_debug_image", default_value="true"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("start_camera", default_value="true"),
|
||||||
|
DeclareLaunchArgument("start_sdk", default_value="true"),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"output_root",
|
||||||
|
default_value=str(Path.cwd() / "calibration_output"),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("session_dir", default_value=""),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"zero_config",
|
||||||
|
default_value=str(
|
||||||
|
package_share / "config" / "cmc_pitch_zero.yaml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"tag_config",
|
||||||
|
default_value=str(
|
||||||
|
package_share / "config" / "front_tags.yaml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OpaqueFunction(function=_launch_stack),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""Launch front-camera trajectory-circle CMC roll zero/travel calibration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import (
|
||||||
|
DeclareLaunchArgument,
|
||||||
|
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):
|
||||||
|
serial_number = LaunchConfiguration("serial_number").perform(context)
|
||||||
|
if (
|
||||||
|
not serial_number
|
||||||
|
or serial_number == "UNSET"
|
||||||
|
or re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None
|
||||||
|
or serial_number in {".", ".."}
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"serial_number is required and may contain only letters, "
|
||||||
|
"digits, dot, underscore and dash"
|
||||||
|
)
|
||||||
|
|
||||||
|
requested_session = LaunchConfiguration("session_dir").perform(context)
|
||||||
|
output_root = Path(
|
||||||
|
LaunchConfiguration("output_root").perform(context)
|
||||||
|
).expanduser().resolve()
|
||||||
|
if requested_session:
|
||||||
|
session_dir = Path(requested_session).expanduser().resolve()
|
||||||
|
else:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
session_dir = output_root / serial_number / timestamp
|
||||||
|
session_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
tag_config = LaunchConfiguration("tag_config").perform(context)
|
||||||
|
calibration_config = LaunchConfiguration(
|
||||||
|
"calibration_config"
|
||||||
|
).perform(context)
|
||||||
|
camera = Node(
|
||||||
|
package="linkerhand_calibration",
|
||||||
|
executable="hikrobot_camera_node",
|
||||||
|
name="hikrobot_camera",
|
||||||
|
namespace="/camera/camera/color",
|
||||||
|
output="screen",
|
||||||
|
emulate_tty=True,
|
||||||
|
condition=IfCondition(LaunchConfiguration("start_camera")),
|
||||||
|
parameters=[
|
||||||
|
{
|
||||||
|
"serial_number": LaunchConfiguration("camera_serial_number"),
|
||||||
|
"expected_model": LaunchConfiguration("camera_model"),
|
||||||
|
"camera_name": LaunchConfiguration("camera_name"),
|
||||||
|
"frame_id": LaunchConfiguration("camera_frame_id"),
|
||||||
|
"image_width": ParameterValue(
|
||||||
|
LaunchConfiguration("image_width"), value_type=int
|
||||||
|
),
|
||||||
|
"image_height": ParameterValue(
|
||||||
|
LaunchConfiguration("image_height"), value_type=int
|
||||||
|
),
|
||||||
|
"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("camera_info_url"),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_topic = "/camera/camera/color/image_raw"
|
||||||
|
camera_info_topic = "/camera/camera/color/camera_info"
|
||||||
|
rect_topic = "/camera/camera/color/image_rect"
|
||||||
|
vision_container = ComposableNodeContainer(
|
||||||
|
name="g20_thumb_roll_vision_container",
|
||||||
|
namespace="/",
|
||||||
|
package="rclcpp_components",
|
||||||
|
executable="component_container_mt",
|
||||||
|
composable_node_descriptions=[
|
||||||
|
ComposableNode(
|
||||||
|
package="image_proc",
|
||||||
|
plugin="image_proc::RectifyNode",
|
||||||
|
name="rectify_color",
|
||||||
|
namespace="/camera/camera/color",
|
||||||
|
remappings=[
|
||||||
|
("image", raw_topic),
|
||||||
|
("camera_info", camera_info_topic),
|
||||||
|
("image_rect", rect_topic),
|
||||||
|
],
|
||||||
|
parameters=[{"queue_size": 1}],
|
||||||
|
extra_arguments=[{"use_intra_process_comms": True}],
|
||||||
|
),
|
||||||
|
ComposableNode(
|
||||||
|
package="apriltag_ros",
|
||||||
|
plugin="AprilTagNode",
|
||||||
|
name="apriltag",
|
||||||
|
namespace="/apriltag",
|
||||||
|
parameters=[
|
||||||
|
tag_config,
|
||||||
|
{
|
||||||
|
"detector.decimate": ParameterValue(
|
||||||
|
LaunchConfiguration("apriltag_decimate"),
|
||||||
|
value_type=float,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
remappings=[
|
||||||
|
("image_rect", rect_topic),
|
||||||
|
("camera_info", camera_info_topic),
|
||||||
|
],
|
||||||
|
extra_arguments=[{"use_intra_process_comms": True}],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
output="screen",
|
||||||
|
emulate_tty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
sdk = Node(
|
||||||
|
package="linker_hand_ros2_sdk",
|
||||||
|
executable="linker_hand_sdk",
|
||||||
|
name="linker_hand_sdk",
|
||||||
|
output="screen",
|
||||||
|
condition=IfCondition(LaunchConfiguration("start_sdk")),
|
||||||
|
parameters=[
|
||||||
|
{
|
||||||
|
"hand_type": "left",
|
||||||
|
"hand_joint": "G20",
|
||||||
|
"can": LaunchConfiguration("can_interface"),
|
||||||
|
"modbus": "None",
|
||||||
|
"topic_prefix": "/g20",
|
||||||
|
"move_on_startup": False,
|
||||||
|
"startup_speed": ParameterValue(
|
||||||
|
LaunchConfiguration("calibration_speed"),
|
||||||
|
value_type=int,
|
||||||
|
),
|
||||||
|
"startup_torque": 80,
|
||||||
|
"state_poll_rate": 10.0,
|
||||||
|
"repeat_position_commands": False,
|
||||||
|
"is_touch": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
calibration_node = Node(
|
||||||
|
package="linkerhand_calibration",
|
||||||
|
executable="cmc_roll_calibration_node",
|
||||||
|
name="g20_thumb_cmc_roll_calibration",
|
||||||
|
output="screen",
|
||||||
|
parameters=[
|
||||||
|
calibration_config,
|
||||||
|
{
|
||||||
|
"serial_number": serial_number,
|
||||||
|
"session_dir": str(session_dir),
|
||||||
|
"commands_enabled": ParameterValue(
|
||||||
|
LaunchConfiguration("commands_enabled"),
|
||||||
|
value_type=bool,
|
||||||
|
),
|
||||||
|
"image_topic": rect_topic,
|
||||||
|
"publish_debug_image": ParameterValue(
|
||||||
|
LaunchConfiguration("publish_debug_image"),
|
||||||
|
value_type=bool,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
LogInfo(msg=f"G20 CMC roll calibration session: {session_dir}"),
|
||||||
|
LogInfo(
|
||||||
|
msg=(
|
||||||
|
"Only T0(ID 0) and T3(ID 1) are required; "
|
||||||
|
"T4/T5 detections are ignored"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
LogInfo(
|
||||||
|
msg=(
|
||||||
|
"Motor 5 performs three 255->0->255 sweeps; "
|
||||||
|
"static captures measure both zero and angular travel"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
camera,
|
||||||
|
vision_container,
|
||||||
|
sdk,
|
||||||
|
calibration_node,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description() -> LaunchDescription:
|
||||||
|
package_share = Path(
|
||||||
|
get_package_share_directory("linkerhand_calibration")
|
||||||
|
)
|
||||||
|
return LaunchDescription(
|
||||||
|
[
|
||||||
|
SetEnvironmentVariable(
|
||||||
|
name="FASTRTPS_DEFAULT_PROFILES_FILE",
|
||||||
|
value=str(
|
||||||
|
package_share / "config" / "fastdds_large_images.xml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("serial_number", default_value="UNSET"),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_serial_number", default_value="DB2163742"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_model", default_value="MV-CS020-10UM"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_name", default_value="hikrobot_front_DB2163742"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_frame_id", default_value="camera_color_optical_frame"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("image_width", default_value="1624"),
|
||||||
|
DeclareLaunchArgument("image_height", default_value="1240"),
|
||||||
|
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(
|
||||||
|
"camera_info_url",
|
||||||
|
default_value=str(
|
||||||
|
Path.home()
|
||||||
|
/ ".ros"
|
||||||
|
/ "camera_info"
|
||||||
|
/ "hikrobot_DB2163742.yaml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("can_interface", default_value="can0"),
|
||||||
|
DeclareLaunchArgument("calibration_speed", default_value="15"),
|
||||||
|
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
|
||||||
|
DeclareLaunchArgument("commands_enabled", default_value="true"),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"publish_debug_image", default_value="true"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("start_camera", default_value="true"),
|
||||||
|
DeclareLaunchArgument("start_sdk", default_value="true"),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"output_root",
|
||||||
|
default_value=str(Path.cwd() / "calibration_output"),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("session_dir", default_value=""),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"calibration_config",
|
||||||
|
default_value=str(
|
||||||
|
package_share
|
||||||
|
/ "config"
|
||||||
|
/ "cmc_roll_zero_travel.yaml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"tag_config",
|
||||||
|
default_value=str(
|
||||||
|
package_share / "config" / "front_tags.yaml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OpaqueFunction(function=_launch_stack),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
"""Launch the complete front-camera G20 thumb calibration stack."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch.actions import (
|
||||||
|
DeclareLaunchArgument,
|
||||||
|
ExecuteProcess,
|
||||||
|
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):
|
||||||
|
serial_number = LaunchConfiguration("serial_number").perform(context)
|
||||||
|
if not serial_number or serial_number == "UNSET":
|
||||||
|
raise RuntimeError(
|
||||||
|
"serial_number is required, for example serial_number:=G20_LEFT_001"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None
|
||||||
|
or serial_number in {".", ".."}
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"serial_number may contain only letters, digits, dot, underscore and dash"
|
||||||
|
)
|
||||||
|
requested_session = LaunchConfiguration("session_dir").perform(context)
|
||||||
|
output_root = Path(LaunchConfiguration("output_root").perform(context)).resolve()
|
||||||
|
if requested_session:
|
||||||
|
session_dir = Path(requested_session).expanduser().resolve()
|
||||||
|
else:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
session_dir = output_root / serial_number / timestamp
|
||||||
|
session_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
bag_path = session_dir / "rosbag"
|
||||||
|
|
||||||
|
calibration_config = LaunchConfiguration("calibration_config").perform(context)
|
||||||
|
tag_config = LaunchConfiguration("tag_config").perform(context)
|
||||||
|
use_roi_text = LaunchConfiguration("use_roi").perform(context).strip().lower()
|
||||||
|
if use_roi_text not in {"true", "false"}:
|
||||||
|
raise RuntimeError("use_roi must be true or false")
|
||||||
|
use_roi = use_roi_text == "true"
|
||||||
|
|
||||||
|
roi_values = {}
|
||||||
|
for name in ("roi_x", "roi_y", "roi_width", "roi_height"):
|
||||||
|
text = LaunchConfiguration(name).perform(context)
|
||||||
|
try:
|
||||||
|
roi_values[name] = int(text)
|
||||||
|
except ValueError as error:
|
||||||
|
raise RuntimeError(f"{name} must be an integer") from error
|
||||||
|
if roi_values["roi_x"] < 0 or roi_values["roi_y"] < 0:
|
||||||
|
raise RuntimeError("roi_x and roi_y must be non-negative")
|
||||||
|
if roi_values["roi_width"] <= 0 or roi_values["roi_height"] <= 0:
|
||||||
|
raise RuntimeError("roi_width and roi_height must be positive")
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_width = int(LaunchConfiguration("image_width").perform(context))
|
||||||
|
image_height = int(LaunchConfiguration("image_height").perform(context))
|
||||||
|
except ValueError as error:
|
||||||
|
raise RuntimeError("image_width and image_height must be integers") from error
|
||||||
|
if image_width <= 0 or image_height <= 0:
|
||||||
|
raise RuntimeError("image_width and image_height must be positive")
|
||||||
|
if use_roi:
|
||||||
|
if (
|
||||||
|
roi_values["roi_x"] + roi_values["roi_width"] > image_width
|
||||||
|
or roi_values["roi_y"] + roi_values["roi_height"] > image_height
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"ROI lies outside camera image "
|
||||||
|
f"{image_width}x{image_height}"
|
||||||
|
)
|
||||||
|
|
||||||
|
camera = Node(
|
||||||
|
package="linkerhand_calibration",
|
||||||
|
executable="hikrobot_camera_node",
|
||||||
|
name="hikrobot_camera",
|
||||||
|
namespace="/camera/camera/color",
|
||||||
|
output="screen",
|
||||||
|
emulate_tty=True,
|
||||||
|
condition=IfCondition(LaunchConfiguration("start_camera")),
|
||||||
|
parameters=[
|
||||||
|
{
|
||||||
|
"serial_number": LaunchConfiguration("camera_serial_number"),
|
||||||
|
"expected_model": LaunchConfiguration("camera_model"),
|
||||||
|
"camera_name": LaunchConfiguration("camera_name"),
|
||||||
|
"frame_id": LaunchConfiguration("camera_frame_id"),
|
||||||
|
"image_width": ParameterValue(
|
||||||
|
LaunchConfiguration("image_width"), value_type=int
|
||||||
|
),
|
||||||
|
"image_height": ParameterValue(
|
||||||
|
LaunchConfiguration("image_height"), value_type=int
|
||||||
|
),
|
||||||
|
"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("camera_info_url"),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
vision_components = []
|
||||||
|
if use_roi:
|
||||||
|
processed_image_raw_topic = "/g20_thumb_roi/image_raw"
|
||||||
|
processed_camera_info_topic = "/g20_thumb_roi/camera_info"
|
||||||
|
processed_image_rect_topic = "/g20_thumb_roi/image_rect"
|
||||||
|
vision_components.append(
|
||||||
|
ComposableNode(
|
||||||
|
package="image_proc",
|
||||||
|
plugin="image_proc::CropDecimateNode",
|
||||||
|
name="crop_color_roi",
|
||||||
|
namespace="/g20_thumb_roi",
|
||||||
|
remappings=[
|
||||||
|
("in/image_raw", "/camera/camera/color/image_raw"),
|
||||||
|
("in/camera_info", "/camera/camera/color/camera_info"),
|
||||||
|
("out/image_raw", processed_image_raw_topic),
|
||||||
|
("out/camera_info", processed_camera_info_topic),
|
||||||
|
],
|
||||||
|
parameters=[
|
||||||
|
{
|
||||||
|
"queue_size": 5,
|
||||||
|
"decimation_x": 1,
|
||||||
|
"decimation_y": 1,
|
||||||
|
"offset_x": roi_values["roi_x"],
|
||||||
|
"offset_y": roi_values["roi_y"],
|
||||||
|
"width": roi_values["roi_width"],
|
||||||
|
"height": roi_values["roi_height"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extra_arguments=[{"use_intra_process_comms": True}],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rectifier_namespace = "/g20_thumb_roi"
|
||||||
|
rectifier_name = "rectify_color_roi"
|
||||||
|
else:
|
||||||
|
processed_image_raw_topic = "/camera/camera/color/image_raw"
|
||||||
|
processed_camera_info_topic = "/camera/camera/color/camera_info"
|
||||||
|
processed_image_rect_topic = "/camera/camera/color/image_rect"
|
||||||
|
rectifier_namespace = "/camera/camera/color"
|
||||||
|
rectifier_name = "rectify_color"
|
||||||
|
|
||||||
|
vision_components.append(
|
||||||
|
ComposableNode(
|
||||||
|
package="image_proc",
|
||||||
|
plugin="image_proc::RectifyNode",
|
||||||
|
name=rectifier_name,
|
||||||
|
namespace=rectifier_namespace,
|
||||||
|
remappings=[
|
||||||
|
("image", processed_image_raw_topic),
|
||||||
|
("camera_info", processed_camera_info_topic),
|
||||||
|
("image_rect", processed_image_rect_topic),
|
||||||
|
],
|
||||||
|
parameters=[{"queue_size": 1}],
|
||||||
|
extra_arguments=[{"use_intra_process_comms": True}],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
vision_components.append(
|
||||||
|
ComposableNode(
|
||||||
|
package="apriltag_ros",
|
||||||
|
plugin="AprilTagNode",
|
||||||
|
name="apriltag",
|
||||||
|
namespace="/apriltag",
|
||||||
|
parameters=[
|
||||||
|
tag_config,
|
||||||
|
{
|
||||||
|
"detector.decimate": ParameterValue(
|
||||||
|
LaunchConfiguration("apriltag_decimate"),
|
||||||
|
value_type=float,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
remappings=[
|
||||||
|
("image_rect", processed_image_rect_topic),
|
||||||
|
("camera_info", processed_camera_info_topic),
|
||||||
|
],
|
||||||
|
extra_arguments=[{"use_intra_process_comms": True}],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
vision_container = ComposableNodeContainer(
|
||||||
|
name="g20_thumb_vision_container",
|
||||||
|
namespace="/",
|
||||||
|
package="rclcpp_components",
|
||||||
|
executable="component_container_mt",
|
||||||
|
composable_node_descriptions=vision_components,
|
||||||
|
output="screen",
|
||||||
|
emulate_tty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
sdk = Node(
|
||||||
|
package="linker_hand_ros2_sdk",
|
||||||
|
executable="linker_hand_sdk",
|
||||||
|
name="linker_hand_sdk",
|
||||||
|
output="screen",
|
||||||
|
condition=IfCondition(LaunchConfiguration("start_sdk")),
|
||||||
|
parameters=[
|
||||||
|
{
|
||||||
|
"hand_type": "left",
|
||||||
|
"hand_joint": "G20",
|
||||||
|
"can": LaunchConfiguration("can_interface"),
|
||||||
|
"modbus": "None",
|
||||||
|
"topic_prefix": "/g20",
|
||||||
|
"move_on_startup": False,
|
||||||
|
"startup_speed": ParameterValue(
|
||||||
|
LaunchConfiguration("calibration_speed"),
|
||||||
|
value_type=int,
|
||||||
|
),
|
||||||
|
"startup_torque": 80,
|
||||||
|
"state_poll_rate": 10.0,
|
||||||
|
"repeat_position_commands": False,
|
||||||
|
"is_touch": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
calibration = Node(
|
||||||
|
package="linkerhand_calibration",
|
||||||
|
executable="calibration_node",
|
||||||
|
name="g20_thumb_calibration",
|
||||||
|
output="screen",
|
||||||
|
parameters=[
|
||||||
|
calibration_config,
|
||||||
|
tag_config,
|
||||||
|
{
|
||||||
|
"serial_number": serial_number,
|
||||||
|
"session_dir": str(session_dir),
|
||||||
|
"commands_enabled": LaunchConfiguration("commands_enabled"),
|
||||||
|
"calibration_speed": ParameterValue(
|
||||||
|
LaunchConfiguration("calibration_speed"),
|
||||||
|
value_type=int,
|
||||||
|
),
|
||||||
|
"continuous_motion_mode": ParameterValue(
|
||||||
|
LaunchConfiguration("continuous_motion_mode"),
|
||||||
|
value_type=str,
|
||||||
|
),
|
||||||
|
"angle_estimation_mode": ParameterValue(
|
||||||
|
LaunchConfiguration("angle_estimation_mode"),
|
||||||
|
value_type=str,
|
||||||
|
),
|
||||||
|
"camera_serial_number": LaunchConfiguration("camera_serial_number"),
|
||||||
|
"rosbag_path": str(bag_path),
|
||||||
|
"camera_info_topic": processed_camera_info_topic,
|
||||||
|
"image_topic": processed_image_rect_topic,
|
||||||
|
"publish_debug_image": LaunchConfiguration(
|
||||||
|
"publish_debug_image"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
bag = ExecuteProcess(
|
||||||
|
condition=IfCondition(LaunchConfiguration("record_bag")),
|
||||||
|
cmd=[
|
||||||
|
"ros2",
|
||||||
|
"bag",
|
||||||
|
"record",
|
||||||
|
"--storage",
|
||||||
|
"mcap",
|
||||||
|
"--storage-preset-profile",
|
||||||
|
"zstd_fast",
|
||||||
|
"--max-bag-size",
|
||||||
|
"10737418240",
|
||||||
|
"--output",
|
||||||
|
str(bag_path),
|
||||||
|
processed_image_raw_topic,
|
||||||
|
processed_camera_info_topic,
|
||||||
|
"/apriltag/detections",
|
||||||
|
"/tf",
|
||||||
|
"/g20/cb_left_hand_control_cmd",
|
||||||
|
"/g20/cb_left_hand_state",
|
||||||
|
"/g20/cb_left_hand_info",
|
||||||
|
"/g20_thumb_calibration/status",
|
||||||
|
],
|
||||||
|
output="screen",
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = [
|
||||||
|
LogInfo(msg=f"G20 thumb calibration session: {session_dir}"),
|
||||||
|
LogInfo(
|
||||||
|
msg=(
|
||||||
|
"G20 thumb image ROI: "
|
||||||
|
f"x={roi_values['roi_x']}, y={roi_values['roi_y']}, "
|
||||||
|
f"width={roi_values['roi_width']}, "
|
||||||
|
f"height={roi_values['roi_height']}"
|
||||||
|
if use_roi
|
||||||
|
else "G20 thumb image ROI: disabled"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
camera,
|
||||||
|
vision_container,
|
||||||
|
]
|
||||||
|
actions.extend([sdk, calibration, bag])
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description() -> LaunchDescription:
|
||||||
|
package_share = Path(
|
||||||
|
get_package_share_directory("linkerhand_calibration")
|
||||||
|
)
|
||||||
|
default_output = str(Path.cwd() / "calibration_output")
|
||||||
|
return LaunchDescription(
|
||||||
|
[
|
||||||
|
SetEnvironmentVariable(
|
||||||
|
name="FASTRTPS_DEFAULT_PROFILES_FILE",
|
||||||
|
value=str(
|
||||||
|
package_share / "config" / "fastdds_large_images.xml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("serial_number", default_value="UNSET"),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_serial_number", default_value="DB2163742"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_model", default_value="MV-CS020-10UM"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_name", default_value="hikrobot_front_DB2163742"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_frame_id", default_value="camera_color_optical_frame"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("image_width", default_value="1624"),
|
||||||
|
DeclareLaunchArgument("image_height", default_value="1240"),
|
||||||
|
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(
|
||||||
|
"camera_info_url",
|
||||||
|
default_value=str(
|
||||||
|
Path.home()
|
||||||
|
/ ".ros"
|
||||||
|
/ "camera_info"
|
||||||
|
/ "hikrobot_DB2163742.yaml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"publish_debug_image", default_value="false"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("use_roi", default_value="false"),
|
||||||
|
DeclareLaunchArgument("roi_x", default_value="128"),
|
||||||
|
DeclareLaunchArgument("roi_y", default_value="192"),
|
||||||
|
DeclareLaunchArgument("roi_width", default_value="1024"),
|
||||||
|
DeclareLaunchArgument("roi_height", default_value="528"),
|
||||||
|
DeclareLaunchArgument("can_interface", default_value="can0"),
|
||||||
|
DeclareLaunchArgument("calibration_speed", default_value="15"),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"continuous_motion_mode", default_value="endpoint"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"angle_estimation_mode",
|
||||||
|
default_value="trajectory_center_3d",
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
|
||||||
|
DeclareLaunchArgument("commands_enabled", default_value="true"),
|
||||||
|
DeclareLaunchArgument("start_camera", default_value="true"),
|
||||||
|
DeclareLaunchArgument("start_sdk", default_value="true"),
|
||||||
|
DeclareLaunchArgument("record_bag", default_value="false"),
|
||||||
|
DeclareLaunchArgument("output_root", default_value=default_output),
|
||||||
|
DeclareLaunchArgument("session_dir", default_value=""),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"calibration_config",
|
||||||
|
default_value=str(package_share / "config" / "calibration.yaml"),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"tag_config",
|
||||||
|
default_value=str(package_share / "config" / "front_tags.yaml"),
|
||||||
|
),
|
||||||
|
OpaqueFunction(function=_launch_stack),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,572 @@
|
|||||||
|
"""Launch three Hikrobot views and one registered hand 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
|
||||||
|
|
||||||
|
|
||||||
|
VIEWS = ("front", "side", "top")
|
||||||
|
|
||||||
|
|
||||||
|
def _default_source_urdf(model: str, hand_type: str) -> Path:
|
||||||
|
relative = (
|
||||||
|
Path("urdf") / "l6_right" / "linkerhand_l6v3.1_right.urdf"
|
||||||
|
if model.upper() == "L6" and hand_type == "right"
|
||||||
|
else Path("urdf")
|
||||||
|
/ f"{model.lower()}_{hand_type}"
|
||||||
|
/ f"linkerhand_{model.lower()}_{hand_type}.urdf"
|
||||||
|
)
|
||||||
|
package_source_or_share = Path(__file__).resolve().parents[1] / relative
|
||||||
|
try:
|
||||||
|
installed = (
|
||||||
|
Path(get_package_share_directory("linkerhand_calibration"))
|
||||||
|
/ relative
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
installed = package_source_or_share
|
||||||
|
return installed if installed.is_file() else package_source_or_share
|
||||||
|
|
||||||
|
|
||||||
|
def _launch_stack(context):
|
||||||
|
from linkerhand_calibration.product import (
|
||||||
|
get_product_calibration_contract,
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
contract = get_product_calibration_contract(
|
||||||
|
model, hand_type, tag_layout
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
raise RuntimeError(str(error)) from error
|
||||||
|
requested_tag_config = LaunchConfiguration("tag_config").perform(context)
|
||||||
|
package_share = Path(
|
||||||
|
get_package_share_directory("linkerhand_calibration")
|
||||||
|
)
|
||||||
|
tag_config = (
|
||||||
|
Path(requested_tag_config).expanduser().resolve()
|
||||||
|
if requested_tag_config
|
||||||
|
else package_share
|
||||||
|
/ "config"
|
||||||
|
/ (
|
||||||
|
"three_camera_tags_g20_right_19.yaml"
|
||||||
|
if tag_layout == "g20_right_19"
|
||||||
|
else "o12_right_16_tags.yaml"
|
||||||
|
if tag_layout == "o12_right_16"
|
||||||
|
else "o6_right_8_tags.yaml"
|
||||||
|
if tag_layout == "o6_right_8"
|
||||||
|
else "l6_right_8_tags.yaml"
|
||||||
|
if tag_layout == "l6_right_8"
|
||||||
|
else "three_camera_tags_g20_right_15.yaml"
|
||||||
|
if tag_layout == "g20_right_15"
|
||||||
|
else "three_camera_tags.yaml"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not tag_config.is_file():
|
||||||
|
raise RuntimeError(f"tag config does not exist: {tag_config}")
|
||||||
|
topic_prefix = f"/{model.lower()}"
|
||||||
|
is_o12 = model == "O12"
|
||||||
|
command_topic = (
|
||||||
|
f"{topic_prefix}/{hand_type}/joint_cmd"
|
||||||
|
if is_o12 else f"{topic_prefix}/cb_{hand_type}_hand_control_cmd"
|
||||||
|
)
|
||||||
|
state_topic = (
|
||||||
|
f"{topic_prefix}/{hand_type}/joint_states"
|
||||||
|
if is_o12 else f"{topic_prefix}/cb_{hand_type}_hand_state"
|
||||||
|
)
|
||||||
|
info_topic = f"{topic_prefix}/cb_{hand_type}_hand_info"
|
||||||
|
requested_source = LaunchConfiguration("source_urdf_path").perform(context)
|
||||||
|
source_urdf = (
|
||||||
|
Path(requested_source).expanduser().resolve()
|
||||||
|
if requested_source
|
||||||
|
else _default_source_urdf(model, hand_type).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 three camera serial numbers are required")
|
||||||
|
if len(set(camera_serials.values())) != 3:
|
||||||
|
raise RuntimeError("front/side/top 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()}_three_camera_vision",
|
||||||
|
namespace="/",
|
||||||
|
package="rclcpp_components",
|
||||||
|
executable="component_container_mt",
|
||||||
|
composable_node_descriptions=components,
|
||||||
|
output="screen",
|
||||||
|
emulate_tty=True,
|
||||||
|
)
|
||||||
|
sdk = (
|
||||||
|
Node(
|
||||||
|
package="omnihand_node",
|
||||||
|
executable="omnihand_pro_2025_node",
|
||||||
|
name="omnihand_pro_2025_node",
|
||||||
|
namespace="o12",
|
||||||
|
output="screen",
|
||||||
|
condition=IfCondition(LaunchConfiguration("start_sdk")),
|
||||||
|
parameters=[LaunchConfiguration("vendor_sdk_config")],
|
||||||
|
)
|
||||||
|
if is_o12
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
parameters=[
|
||||||
|
LaunchConfiguration("calibration_config"),
|
||||||
|
{
|
||||||
|
"serial_number": hand_serial,
|
||||||
|
"model": model,
|
||||||
|
"hand_type": hand_type,
|
||||||
|
"tag_layout": tag_layout,
|
||||||
|
"session_dir": str(session_dir),
|
||||||
|
"resume_raw_samples_path": LaunchConfiguration(
|
||||||
|
"resume_raw_samples_path"
|
||||||
|
),
|
||||||
|
"recalibration_scope": LaunchConfiguration(
|
||||||
|
"recalibration_scope"
|
||||||
|
),
|
||||||
|
# The SDK performs roughly 25 synchronous CAN queries whenever
|
||||||
|
# cb_<side>_hand_info has a subscriber. Calibration only used
|
||||||
|
# that topic to display a speed diagnostic, while those reads
|
||||||
|
# created 17-33 command-unit holes in position trajectories.
|
||||||
|
"info_topic": f"{calibration_namespace}/disabled_hand_info",
|
||||||
|
"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"
|
||||||
|
),
|
||||||
|
"corrected_urdf_output_dir": LaunchConfiguration(
|
||||||
|
"corrected_urdf_output_dir"
|
||||||
|
),
|
||||||
|
**{
|
||||||
|
f"{view}_camera_serial": camera_serials[view]
|
||||||
|
for view in VIEWS
|
||||||
|
},
|
||||||
|
"commands_enabled": ParameterValue(
|
||||||
|
LaunchConfiguration("commands_enabled"), value_type=bool
|
||||||
|
),
|
||||||
|
"normal_calibration_speed": ParameterValue(
|
||||||
|
LaunchConfiguration("calibration_speed"), value_type=int
|
||||||
|
),
|
||||||
|
"index_roll_calibration_speed": ParameterValue(
|
||||||
|
LaunchConfiguration("index_roll_calibration_speed"),
|
||||||
|
value_type=int,
|
||||||
|
),
|
||||||
|
"index_flex_calibration_speed": ParameterValue(
|
||||||
|
LaunchConfiguration("index_flex_calibration_speed"),
|
||||||
|
value_type=int,
|
||||||
|
),
|
||||||
|
"adaptive_formal_speed_enabled": ParameterValue(
|
||||||
|
LaunchConfiguration("adaptive_formal_speed_enabled"),
|
||||||
|
value_type=bool,
|
||||||
|
),
|
||||||
|
"cross_view_roll_diagnostic_finger": LaunchConfiguration(
|
||||||
|
"cross_view_roll_diagnostic_finger"
|
||||||
|
),
|
||||||
|
"validation_enabled": ParameterValue(
|
||||||
|
LaunchConfiguration("validation_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,
|
||||||
|
info_topic,
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
"/o12/right/joint_control_mode_states",
|
||||||
|
"/o12/right/joint_error_states",
|
||||||
|
"/o12/right/joint_temperature_states",
|
||||||
|
]
|
||||||
|
if is_o12 else []
|
||||||
|
),
|
||||||
|
f"{calibration_namespace}/status",
|
||||||
|
],
|
||||||
|
output="screen",
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
LogInfo(
|
||||||
|
msg=(
|
||||||
|
f"{model} {hand_type} {tag_layout} three-camera session: {session_dir}; "
|
||||||
|
f"source_urdf={source_urdf}"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
LogInfo(
|
||||||
|
msg=(
|
||||||
|
"Camera mapping: front="
|
||||||
|
f"{camera_serials['front']} side={camera_serials['side']} "
|
||||||
|
f"top={camera_serials['top']}"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
*cameras,
|
||||||
|
vision,
|
||||||
|
sdk,
|
||||||
|
calibration,
|
||||||
|
bag,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description() -> LaunchDescription:
|
||||||
|
package_share = Path(
|
||||||
|
get_package_share_directory("linkerhand_calibration")
|
||||||
|
)
|
||||||
|
info_root = Path.home() / ".ros" / "camera_info"
|
||||||
|
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="G20"),
|
||||||
|
DeclareLaunchArgument("hand_type", default_value="left"),
|
||||||
|
DeclareLaunchArgument("tag_layout", default_value="legacy_11"),
|
||||||
|
DeclareLaunchArgument("serial_number", default_value="UNSET"),
|
||||||
|
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(info_root / "hikrobot_DB2163742.yaml"),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"side_camera_info_url",
|
||||||
|
default_value=str(info_root / "hikrobot_DB2163749.yaml"),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"top_camera_info_url",
|
||||||
|
default_value=str(info_root / "hikrobot_DB2163739.yaml"),
|
||||||
|
),
|
||||||
|
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(
|
||||||
|
"index_roll_calibration_speed", default_value="5"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"index_flex_calibration_speed", default_value="10"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"adaptive_formal_speed_enabled", default_value="true"
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"cross_view_roll_diagnostic_finger", default_value=""
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("validation_enabled", default_value="false"),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"camera_extrinsics_file",
|
||||||
|
default_value=str(
|
||||||
|
Path.cwd() / "config" / "g20_three_camera_extrinsics.yaml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"source_urdf_path", default_value=""
|
||||||
|
),
|
||||||
|
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(
|
||||||
|
"sdk_config_expected_sha256", default_value=""
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"corrected_urdf_output_dir", default_value=""
|
||||||
|
),
|
||||||
|
DeclareLaunchArgument("commands_enabled", default_value="true"),
|
||||||
|
DeclareLaunchArgument("start_cameras", default_value="true"),
|
||||||
|
DeclareLaunchArgument("start_sdk", default_value="true"),
|
||||||
|
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("recalibration_scope", default_value="full"),
|
||||||
|
DeclareLaunchArgument(
|
||||||
|
"calibration_config",
|
||||||
|
default_value=str(
|
||||||
|
package_share / "config" / "three_camera_calibration.yaml"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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,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,371 @@
|
|||||||
|
"""Map model SDK u8 feedback to URDF joint angles using one calibration JSON.
|
||||||
|
|
||||||
|
The static encoder-zero corrections in ``zero_angles`` are already baked into
|
||||||
|
the corrected URDF joint origins. This bridge therefore publishes only the
|
||||||
|
dynamic ``angle_rad`` values and never adds the static offsets a second time.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
from .full_hand import (
|
||||||
|
get_hand_calibration_profile,
|
||||||
|
infer_compact_payload_layout,
|
||||||
|
validate_compact_payload,
|
||||||
|
)
|
||||||
|
from .models import get_default_registry, validate_schema_v6_runtime_payload
|
||||||
|
from .models.o12.artifacts import validate_o12_runtime_payload
|
||||||
|
from .core import ProfileKey
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
) -> CalibratedCommandMapper:
|
||||||
|
path = Path(calibration_file).expanduser().resolve()
|
||||||
|
if not path.is_file():
|
||||||
|
raise ValueError(f"calibration JSON does not exist: {path}")
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return CalibratedCommandMapper(payload, expected_side=expected_side)
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
hand_type = str(self.get_parameter("hand_type").value).lower()
|
||||||
|
if hand_type not in {"left", "right"}:
|
||||||
|
raise ValueError("hand_type must be left or right")
|
||||||
|
calibration_file = str(self.get_parameter("calibration_file").value)
|
||||||
|
if not calibration_file:
|
||||||
|
raise ValueError("calibration_file is required")
|
||||||
|
self.mapper = load_calibrated_command_mapper(
|
||||||
|
calibration_file, expected_side=hand_type
|
||||||
|
)
|
||||||
|
input_topic = str(self.get_parameter("input_topic").value).strip()
|
||||||
|
output_topic = str(self.get_parameter("output_topic").value).strip()
|
||||||
|
self.input_topic = input_topic or 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"
|
||||||
|
)
|
||||||
|
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,52 @@
|
|||||||
|
"""Path compatibility for immutable v1 product configurations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_LEGACY_SOURCE_PREFIX = Path("src/g20_thumb_apriltag_calibration")
|
||||||
|
_CURRENT_SOURCE_PREFIX = Path("src/linkerhand_calibration")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_package_uri(value: str, workspace: Path) -> Path | None:
|
||||||
|
prefix = "package://"
|
||||||
|
if not value.startswith(prefix):
|
||||||
|
return None
|
||||||
|
package_name, separator, relative = value[len(prefix) :].partition("/")
|
||||||
|
if not separator or not package_name or not relative:
|
||||||
|
raise ValueError(f"invalid ROS package resource path: {value}")
|
||||||
|
workspace_candidate = (workspace / "src" / package_name / relative).resolve()
|
||||||
|
if workspace_candidate.exists():
|
||||||
|
return workspace_candidate
|
||||||
|
try:
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
|
||||||
|
package_share = Path(get_package_share_directory(package_name))
|
||||||
|
except Exception:
|
||||||
|
return workspace_candidate
|
||||||
|
return (package_share / relative).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_renamed_package_path(value: str | Path, workspace: Path) -> Path:
|
||||||
|
"""Resolve workspace paths, ROS package resources and the former prefix.
|
||||||
|
|
||||||
|
Deployed v1 product YAML files are kept byte-for-byte stable because the
|
||||||
|
artifact paths participate in operational review. Existing paths always
|
||||||
|
win; package URIs prefer a source-workspace copy, and the rename mapping
|
||||||
|
is used only when the literal legacy path no longer exists.
|
||||||
|
"""
|
||||||
|
text = str(value).strip()
|
||||||
|
package_resource = _resolve_package_uri(text, workspace)
|
||||||
|
if package_resource is not None:
|
||||||
|
return package_resource
|
||||||
|
raw = Path(text).expanduser()
|
||||||
|
candidate = raw if raw.is_absolute() else workspace / raw
|
||||||
|
candidate = candidate.resolve()
|
||||||
|
if candidate.exists() or raw.is_absolute():
|
||||||
|
return candidate
|
||||||
|
try:
|
||||||
|
suffix = raw.relative_to(_LEGACY_SOURCE_PREFIX)
|
||||||
|
except ValueError:
|
||||||
|
return candidate
|
||||||
|
return (workspace / _CURRENT_SOURCE_PREFIX / suffix).resolve()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Hardware- and model-independent calibration kernel."""
|
||||||
|
|
||||||
|
from .domain import (
|
||||||
|
ArtifactPolicy,
|
||||||
|
CalibrationProfile,
|
||||||
|
CommandLayout,
|
||||||
|
MeasurementPolicy,
|
||||||
|
MeasurementSpec,
|
||||||
|
MotionPolicy,
|
||||||
|
ProfileKey,
|
||||||
|
ProfileValidationError,
|
||||||
|
QualityPolicy,
|
||||||
|
SampleRecord,
|
||||||
|
ScopePolicy,
|
||||||
|
TagSpec,
|
||||||
|
TaskSpec,
|
||||||
|
ViewSpec,
|
||||||
|
VisionRigSpec,
|
||||||
|
ZeroSolvePolicy,
|
||||||
|
validate_profile,
|
||||||
|
)
|
||||||
|
from .domain.task import (
|
||||||
|
DIRECTION_DECREASING,
|
||||||
|
DIRECTION_INCREASING,
|
||||||
|
DIRECTIONS,
|
||||||
|
PHASE_ROOT,
|
||||||
|
PHASE_TIP,
|
||||||
|
)
|
||||||
|
from .fitting import FitResult, isotonic_nonincreasing
|
||||||
|
from .geometry import (
|
||||||
|
delta_rotation_vector,
|
||||||
|
fit_rotation_axis,
|
||||||
|
image_plane_tag_quaternion_xyzw,
|
||||||
|
normalize_quaternion_xyzw,
|
||||||
|
relative_quaternion_xyzw,
|
||||||
|
robust_rotation_summary,
|
||||||
|
rotation_inlier_fraction,
|
||||||
|
rotation_rms_rad,
|
||||||
|
rotation_spread_rad,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ArtifactPolicy",
|
||||||
|
"CalibrationProfile",
|
||||||
|
"CommandLayout",
|
||||||
|
"DIRECTION_DECREASING",
|
||||||
|
"DIRECTION_INCREASING",
|
||||||
|
"DIRECTIONS",
|
||||||
|
"FitResult",
|
||||||
|
"MeasurementPolicy",
|
||||||
|
"MeasurementSpec",
|
||||||
|
"MotionPolicy",
|
||||||
|
"PHASE_ROOT",
|
||||||
|
"PHASE_TIP",
|
||||||
|
"ProfileKey",
|
||||||
|
"ProfileValidationError",
|
||||||
|
"QualityPolicy",
|
||||||
|
"SampleRecord",
|
||||||
|
"ScopePolicy",
|
||||||
|
"TagSpec",
|
||||||
|
"TaskSpec",
|
||||||
|
"ViewSpec",
|
||||||
|
"VisionRigSpec",
|
||||||
|
"ZeroSolvePolicy",
|
||||||
|
"delta_rotation_vector",
|
||||||
|
"fit_rotation_axis",
|
||||||
|
"image_plane_tag_quaternion_xyzw",
|
||||||
|
"isotonic_nonincreasing",
|
||||||
|
"normalize_quaternion_xyzw",
|
||||||
|
"relative_quaternion_xyzw",
|
||||||
|
"robust_rotation_summary",
|
||||||
|
"rotation_inlier_fraction",
|
||||||
|
"rotation_rms_rad",
|
||||||
|
"rotation_spread_rad",
|
||||||
|
"validate_profile",
|
||||||
|
]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Artifact schema and release validation contracts."""
|
||||||
|
|
||||||
|
from .release import ReleaseValidation, ReleaseValidator
|
||||||
|
|
||||||
|
__all__ = ["ReleaseValidation", "ReleaseValidator"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Release validation protocol used before atomic publication."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping, Protocol
|
||||||
|
|
||||||
|
from ..domain import CalibrationProfile
|
||||||
|
from ..urdf import UrdfCorrectionPlan
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReleaseValidation:
|
||||||
|
passed: bool
|
||||||
|
errors: tuple[str, ...] = ()
|
||||||
|
verified_hashes: Mapping[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseValidator(Protocol):
|
||||||
|
def validate_release(
|
||||||
|
self,
|
||||||
|
profile: CalibrationProfile,
|
||||||
|
plan: UrdfCorrectionPlan,
|
||||||
|
calibration_json: Path,
|
||||||
|
corrected_urdf: Path,
|
||||||
|
) -> ReleaseValidation: ...
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Crash-safe session storage for hardware calibration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_json(path: str | Path, payload: Mapping[str, Any]) -> None:
|
||||||
|
destination = Path(path)
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = destination.with_suffix(destination.suffix + ".tmp")
|
||||||
|
with temporary.open("w", encoding="utf-8") as stream:
|
||||||
|
json.dump(payload, stream, ensure_ascii=False, indent=2)
|
||||||
|
stream.write("\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
|
||||||
|
|
||||||
|
def append_jsonl(path: str | Path, payload: Mapping[str, Any]) -> None:
|
||||||
|
destination = Path(path)
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
line = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
with destination.open("a", encoding="utf-8") as stream:
|
||||||
|
stream.write(line + "\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
def append_jsonl_many(
|
||||||
|
path: str | Path, payloads: Iterable[Mapping[str, Any]]
|
||||||
|
) -> None:
|
||||||
|
"""Durably append a batch while paying the fsync cost only once."""
|
||||||
|
destination = Path(path)
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lines = [
|
||||||
|
json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
for payload in payloads
|
||||||
|
]
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
with destination.open("a", encoding="utf-8") as stream:
|
||||||
|
stream.write("\n".join(lines) + "\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
|
||||||
|
|
||||||
|
def load_jsonl(path: str | Path) -> list[dict[str, Any]]:
|
||||||
|
source = Path(path)
|
||||||
|
if not source.exists():
|
||||||
|
return []
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
with source.open("r", encoding="utf-8") as stream:
|
||||||
|
lines = stream.readlines()
|
||||||
|
nonempty_lines = [
|
||||||
|
index for index, line in enumerate(lines, 1) if line.strip()
|
||||||
|
]
|
||||||
|
last_nonempty_line = nonempty_lines[-1] if nonempty_lines else 0
|
||||||
|
for line_number, line in enumerate(lines, 1):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = json.loads(line)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
if line_number == last_nonempty_line:
|
||||||
|
break
|
||||||
|
raise ValueError(
|
||||||
|
f"{source}:{line_number}: invalid JSONL record"
|
||||||
|
) from error
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(f"{source}:{line_number}: record must be an object")
|
||||||
|
records.append(value)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: str | Path) -> dict[str, Any] | None:
|
||||||
|
source = Path(path)
|
||||||
|
if not source.exists():
|
||||||
|
return None
|
||||||
|
with source.open("r", encoding="utf-8") as stream:
|
||||||
|
value = json.load(stream)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(f"{source} must contain a JSON object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def completed_scan_keys(
|
||||||
|
records: Iterable[Mapping[str, Any]],
|
||||||
|
) -> set[tuple[str, int, str, int]]:
|
||||||
|
keys: set[tuple[str, int, str, int]] = set()
|
||||||
|
for record in records:
|
||||||
|
if record.get("kind", "sample") != "sample":
|
||||||
|
continue
|
||||||
|
keys.add(
|
||||||
|
(
|
||||||
|
str(record["phase"]),
|
||||||
|
int(record["cycle"]),
|
||||||
|
str(record["direction"]),
|
||||||
|
int(record["command_u8"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return keys
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Calibration domain types."""
|
||||||
|
|
||||||
|
from .profile import (
|
||||||
|
ArtifactPolicy,
|
||||||
|
CalibrationProfile,
|
||||||
|
CommandLayout,
|
||||||
|
MeasurementPolicy,
|
||||||
|
MeasurementSpec,
|
||||||
|
MotionPolicy,
|
||||||
|
ProfileKey,
|
||||||
|
ProfileValidationError,
|
||||||
|
QualityPolicy,
|
||||||
|
ScopePolicy,
|
||||||
|
TagSpec,
|
||||||
|
TaskSpec,
|
||||||
|
ViewSpec,
|
||||||
|
VisionRigSpec,
|
||||||
|
ZeroSolvePolicy,
|
||||||
|
validate_profile,
|
||||||
|
)
|
||||||
|
from .sample import SampleRecord
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ArtifactPolicy",
|
||||||
|
"CalibrationProfile",
|
||||||
|
"CommandLayout",
|
||||||
|
"MeasurementPolicy",
|
||||||
|
"MeasurementSpec",
|
||||||
|
"MotionPolicy",
|
||||||
|
"ProfileKey",
|
||||||
|
"ProfileValidationError",
|
||||||
|
"QualityPolicy",
|
||||||
|
"SampleRecord",
|
||||||
|
"ScopePolicy",
|
||||||
|
"TagSpec",
|
||||||
|
"TaskSpec",
|
||||||
|
"ViewSpec",
|
||||||
|
"VisionRigSpec",
|
||||||
|
"ZeroSolvePolicy",
|
||||||
|
"validate_profile",
|
||||||
|
]
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
"""Typed, hardware-independent calibration profile contracts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import math
|
||||||
|
from pathlib import PurePath
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, order=True)
|
||||||
|
class ProfileKey:
|
||||||
|
"""Stable identity for one independently reviewed hand profile."""
|
||||||
|
|
||||||
|
model: str
|
||||||
|
side: str
|
||||||
|
layout: str
|
||||||
|
revision: int = 1
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
object.__setattr__(self, "model", str(self.model).strip().upper())
|
||||||
|
object.__setattr__(self, "side", str(self.side).strip().lower())
|
||||||
|
object.__setattr__(self, "layout", str(self.layout).strip().lower())
|
||||||
|
object.__setattr__(self, "revision", int(self.revision))
|
||||||
|
if not self.model or not self.side or not self.layout:
|
||||||
|
raise ValueError("profile identity fields must be non-empty")
|
||||||
|
if self.side not in {"left", "right"}:
|
||||||
|
raise ValueError("profile side must be left or right")
|
||||||
|
if self.revision < 1:
|
||||||
|
raise ValueError("profile revision must be positive")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profile_id(self) -> str:
|
||||||
|
return f"{self.model}/{self.side}/{self.layout}/v{self.revision}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, value: str) -> "ProfileKey":
|
||||||
|
parts = str(value).strip().split("/")
|
||||||
|
if len(parts) != 4 or not parts[3].startswith("v"):
|
||||||
|
raise ValueError(
|
||||||
|
"profile_id must be MODEL/side/layout/vREVISION"
|
||||||
|
)
|
||||||
|
return cls(parts[0], parts[1], parts[2], int(parts[3][1:]))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommandLayout:
|
||||||
|
"""Command channels, joint bindings, and the reviewed baseline pose."""
|
||||||
|
|
||||||
|
names: tuple[str, ...]
|
||||||
|
baseline_u8: tuple[int, ...]
|
||||||
|
command_index_by_joint: Mapping[str, int]
|
||||||
|
disabled_indices: frozenset[int] = frozenset()
|
||||||
|
# Calibration names are allowed to stay model-neutral while the source
|
||||||
|
# URDF keeps any vendor/side prefixes (for example ``rh_``).
|
||||||
|
urdf_joint_by_joint: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
# Older SDKs occasionally published a wrong label for a physically stable
|
||||||
|
# channel. Aliases are accepted only at the declared channel index.
|
||||||
|
feedback_name_aliases: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
# SDK speed commands are not necessarily one value per position channel.
|
||||||
|
# This mapping makes that protocol detail explicit in a profile.
|
||||||
|
speed_slot_by_command_index: Mapping[int, int] = field(default_factory=dict)
|
||||||
|
# Physical-angle SDKs use these fields. ``baseline_u8`` remains the
|
||||||
|
# deployed compatibility contract for legacy byte-command profiles.
|
||||||
|
unit: str = "u8"
|
||||||
|
baseline: tuple[float, ...] = ()
|
||||||
|
lower_bounds: tuple[float, ...] = ()
|
||||||
|
upper_bounds: tuple[float, ...] = ()
|
||||||
|
feedback_by_index: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def command_count(self) -> int:
|
||||||
|
return len(self.names)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def baseline_values(self) -> tuple[float, ...]:
|
||||||
|
source = self.baseline if self.baseline else self.baseline_u8
|
||||||
|
return tuple(float(value) for value in source)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def minimum_values(self) -> tuple[float, ...]:
|
||||||
|
return (
|
||||||
|
tuple(float(value) for value in self.lower_bounds)
|
||||||
|
if self.lower_bounds
|
||||||
|
else (0.0,) * self.command_count
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def maximum_values(self) -> tuple[float, ...]:
|
||||||
|
return (
|
||||||
|
tuple(float(value) for value in self.upper_bounds)
|
||||||
|
if self.upper_bounds
|
||||||
|
else (255.0,) * self.command_count
|
||||||
|
)
|
||||||
|
|
||||||
|
def normalize(self, index: int, value: float) -> float:
|
||||||
|
lower = self.minimum_values[int(index)]
|
||||||
|
upper = self.maximum_values[int(index)]
|
||||||
|
if not math.isfinite(float(value)) or upper <= lower:
|
||||||
|
raise ValueError("command value or bounds are invalid")
|
||||||
|
return (float(value) - lower) / (upper - lower)
|
||||||
|
|
||||||
|
def denormalize(self, index: int, progress: float) -> float:
|
||||||
|
lower = self.minimum_values[int(index)]
|
||||||
|
upper = self.maximum_values[int(index)]
|
||||||
|
phase = float(progress)
|
||||||
|
if not math.isfinite(phase) or not 0.0 <= phase <= 1.0:
|
||||||
|
raise ValueError("normalized command progress must be in [0, 1]")
|
||||||
|
return lower + phase * (upper - lower)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TagSpec:
|
||||||
|
role: str
|
||||||
|
tag_id: int
|
||||||
|
fixed_reference: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ViewSpec:
|
||||||
|
name: str
|
||||||
|
tags: tuple[TagSpec, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VisionRigSpec:
|
||||||
|
"""Any number of named views and their Tag roles."""
|
||||||
|
|
||||||
|
views: tuple[ViewSpec, ...]
|
||||||
|
common_frame: str
|
||||||
|
extrinsic_reference_view: str
|
||||||
|
extrinsics_quality_limits: Mapping[str, float] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
minimum_capture_counts: Mapping[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def view_names(self) -> tuple[str, ...]:
|
||||||
|
return tuple(view.name for view in self.views)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tag_ids(self) -> frozenset[int]:
|
||||||
|
return frozenset(tag.tag_id for view in self.views for tag in view.tags)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskSpec:
|
||||||
|
key: str
|
||||||
|
view: str
|
||||||
|
command_index: int
|
||||||
|
joints: tuple[str, ...]
|
||||||
|
auxiliary_commands: tuple[tuple[int, float], ...] = ()
|
||||||
|
validation_only: bool = False
|
||||||
|
start_u8: int = 255
|
||||||
|
end_u8: int = 0
|
||||||
|
preflight_speed_u8: int | None = None
|
||||||
|
formal_speed_u8: int | None = None
|
||||||
|
start: float | None = None
|
||||||
|
end: float | None = None
|
||||||
|
preflight_speed: float | None = None
|
||||||
|
formal_speed: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def start_value(self) -> float:
|
||||||
|
return float(self.start_u8 if self.start is None else self.start)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def end_value(self) -> float:
|
||||||
|
return float(self.end_u8 if self.end is None else self.end)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MotionPolicy:
|
||||||
|
"""Reviewed motion tasks and optional safe waypoint sequences."""
|
||||||
|
|
||||||
|
tasks: tuple[TaskSpec, ...]
|
||||||
|
preparation_waypoints_u8: tuple[tuple[int, ...], ...] = ()
|
||||||
|
safe_return_waypoints_u8: tuple[tuple[int, ...], ...] = ()
|
||||||
|
speed_parameters: Mapping[str, float] = field(default_factory=dict)
|
||||||
|
precheck_sweeps: bool = False
|
||||||
|
steady_command_checkpoints: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MeasurementSpec:
|
||||||
|
joint: str
|
||||||
|
kind: str
|
||||||
|
view: str | None
|
||||||
|
parent_role: str | None
|
||||||
|
child_role: str | None
|
||||||
|
validation_source: str | None = None
|
||||||
|
# Some measured trajectories publish only a dynamic curve while their
|
||||||
|
# static URDF zero/axis remains CAD- or mimic-owned. For those joints a
|
||||||
|
# monocular 3-D axis-line residual is useful diagnostic evidence, but it
|
||||||
|
# must not reject an otherwise clean image/SO(3) trajectory merely because
|
||||||
|
# the hand was placed at a different valid position in the camera view.
|
||||||
|
pose_axis_line_required: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MeasurementPolicy:
|
||||||
|
measurements: Mapping[str, MeasurementSpec]
|
||||||
|
cross_view_sources: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
image_curve_joints: frozenset[str] = frozenset()
|
||||||
|
directional_zero: bool = False
|
||||||
|
cross_view_roll_curve: bool = False
|
||||||
|
stable_cross_view_cone_bias: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZeroSolvePolicy:
|
||||||
|
active_joints: frozenset[str]
|
||||||
|
passive_joints: frozenset[str]
|
||||||
|
direct_zero_joints: tuple[str, ...]
|
||||||
|
axis_joints: tuple[str, ...]
|
||||||
|
mechanical_endpoint_joints: frozenset[str]
|
||||||
|
post_solve_endpoint_joints: frozenset[str]
|
||||||
|
mimic_source_by_joint: Mapping[str, str]
|
||||||
|
cad_frozen_joints: frozenset[str]
|
||||||
|
# ``upper_at_end`` means TaskSpec.end_u8 is the trusted source-URDF upper
|
||||||
|
# physical endpoint. The measured travel then defines the electrical
|
||||||
|
# zero and corrected [0, travel] coordinate range.
|
||||||
|
endpoint_anchor_by_joint: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
fitted_mimic_joints: frozenset[str] = frozenset()
|
||||||
|
# Passive coupling is not necessarily representable by the linear URDF
|
||||||
|
# ``mimic`` element. Profiles must opt in explicitly before a nonlinear
|
||||||
|
# runtime/MuJoCo relation may be published.
|
||||||
|
coupling_model_by_joint: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QualityPolicy:
|
||||||
|
training_cycles: tuple[int, ...]
|
||||||
|
holdout_cycle: int | None
|
||||||
|
hard_threshold_keys: frozenset[str]
|
||||||
|
retry_metric_scope: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
isolated_holdout: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScopePolicy:
|
||||||
|
calibrate_joints: Mapping[str, frozenset[str]]
|
||||||
|
frozen_joints: Mapping[str, frozenset[str]]
|
||||||
|
default_scope: str = "full"
|
||||||
|
|
||||||
|
def selected_joints(self, scope: str) -> frozenset[str]:
|
||||||
|
try:
|
||||||
|
return self.calibrate_joints[str(scope)]
|
||||||
|
except KeyError as error:
|
||||||
|
raise ValueError(f"unsupported calibration scope: {scope}") from error
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ArtifactPolicy:
|
||||||
|
output_schema_version: int
|
||||||
|
calibration_filename: str
|
||||||
|
corrected_urdf_filename: str
|
||||||
|
protected_input_fields: frozenset[str]
|
||||||
|
publication_pointer: str = "latest_passed"
|
||||||
|
session_compatibility_tokens: frozenset[str] = frozenset()
|
||||||
|
publish_corrected_urdf: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CalibrationProfile:
|
||||||
|
key: ProfileKey
|
||||||
|
namespace: str
|
||||||
|
command: CommandLayout
|
||||||
|
vision: VisionRigSpec
|
||||||
|
motion: MotionPolicy
|
||||||
|
measurement: MeasurementPolicy
|
||||||
|
zero: ZeroSolvePolicy
|
||||||
|
quality: QualityPolicy
|
||||||
|
scope: ScopePolicy
|
||||||
|
artifacts: ArtifactPolicy
|
||||||
|
# Per-URDF-joint provenance used by partial calibration artifacts.
|
||||||
|
# Known values are: measured_static_dynamic, measured_dynamic_cad_static,
|
||||||
|
# transferred_static_dynamic, transferred_dynamic_cad_static, cad_nominal,
|
||||||
|
# and mimic_nominal.
|
||||||
|
joint_coverage: Mapping[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileValidationError(ValueError):
|
||||||
|
"""Raised before hardware startup when a profile is internally unsafe."""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_profile(profile: CalibrationProfile) -> None:
|
||||||
|
"""Hard-check all cross-policy references before hardware is enabled."""
|
||||||
|
errors: list[str] = []
|
||||||
|
command = profile.command
|
||||||
|
baseline = command.baseline_values
|
||||||
|
if not command.names or len(command.names) != len(baseline):
|
||||||
|
errors.append("command names and baseline must be non-empty and aligned")
|
||||||
|
if len(set(command.names)) != len(command.names):
|
||||||
|
errors.append("command names must be unique")
|
||||||
|
if command.unit not in {"u8", "rad"}:
|
||||||
|
errors.append("command unit must be u8 or rad")
|
||||||
|
if command.unit == "u8" and any(value < 0 or value > 255 for value in baseline):
|
||||||
|
errors.append("baseline command values must be in [0, 255]")
|
||||||
|
if (
|
||||||
|
len(command.minimum_values) != command.command_count
|
||||||
|
or len(command.maximum_values) != command.command_count
|
||||||
|
):
|
||||||
|
errors.append("command bounds must align with command names")
|
||||||
|
elif any(
|
||||||
|
not math.isfinite(lower)
|
||||||
|
or not math.isfinite(upper)
|
||||||
|
or lower >= upper
|
||||||
|
or not lower <= value <= upper
|
||||||
|
for lower, upper, value in zip(
|
||||||
|
command.minimum_values, command.maximum_values, baseline
|
||||||
|
)
|
||||||
|
):
|
||||||
|
errors.append("command bounds or baseline values are invalid")
|
||||||
|
indices = set(range(command.command_count))
|
||||||
|
if not set(command.disabled_indices).issubset(indices):
|
||||||
|
errors.append("disabled command index is out of range")
|
||||||
|
if any(index not in indices for index in command.command_index_by_joint.values()):
|
||||||
|
errors.append("joint command index is out of range")
|
||||||
|
if command.urdf_joint_by_joint:
|
||||||
|
if not set(command.command_index_by_joint).issubset(
|
||||||
|
command.urdf_joint_by_joint
|
||||||
|
):
|
||||||
|
errors.append("every commanded joint must map to a URDF joint")
|
||||||
|
urdf_names = tuple(command.urdf_joint_by_joint.values())
|
||||||
|
if len(set(urdf_names)) != len(urdf_names):
|
||||||
|
errors.append("URDF joint mappings must be unique")
|
||||||
|
if any(
|
||||||
|
index not in indices or slot < 0
|
||||||
|
for index, slot in command.speed_slot_by_command_index.items()
|
||||||
|
):
|
||||||
|
errors.append("speed-slot mapping is invalid")
|
||||||
|
if any(
|
||||||
|
not str(alias).strip() or canonical not in command.names
|
||||||
|
for alias, canonical in command.feedback_name_aliases.items()
|
||||||
|
):
|
||||||
|
errors.append("feedback name alias is not part of the command schema")
|
||||||
|
|
||||||
|
view_names = profile.vision.view_names
|
||||||
|
if not view_names or len(set(view_names)) != len(view_names):
|
||||||
|
errors.append("vision views must be non-empty and unique")
|
||||||
|
if profile.vision.extrinsic_reference_view not in view_names:
|
||||||
|
errors.append("extrinsic reference view is not declared")
|
||||||
|
tag_ids = [tag.tag_id for view in profile.vision.views for tag in view.tags]
|
||||||
|
tag_roles = [tag.role for view in profile.vision.views for tag in view.tags]
|
||||||
|
if len(set(tag_ids)) != len(tag_ids):
|
||||||
|
errors.append("Tag IDs must be unique across views")
|
||||||
|
if len(set(tag_roles)) != len(tag_roles):
|
||||||
|
errors.append("Tag roles must be unique across views")
|
||||||
|
if not any(
|
||||||
|
tag.fixed_reference for view in profile.vision.views for tag in view.tags
|
||||||
|
):
|
||||||
|
errors.append("at least one fixed reference Tag is required")
|
||||||
|
|
||||||
|
task_keys = [task.key for task in profile.motion.tasks]
|
||||||
|
if not task_keys or len(set(task_keys)) != len(task_keys):
|
||||||
|
errors.append("motion task keys must be non-empty and unique")
|
||||||
|
measurement_names = set(profile.measurement.measurements)
|
||||||
|
for task in profile.motion.tasks:
|
||||||
|
if task.view not in view_names:
|
||||||
|
errors.append(f"task {task.key} uses an unknown view")
|
||||||
|
if task.command_index not in indices:
|
||||||
|
errors.append(f"task {task.key} command index is out of range")
|
||||||
|
if not task.joints or not set(task.joints).issubset(measurement_names):
|
||||||
|
errors.append(f"task {task.key} references unknown measurements")
|
||||||
|
if any(index not in indices for index, _ in task.auxiliary_commands):
|
||||||
|
errors.append(f"task {task.key} auxiliary index is out of range")
|
||||||
|
elif any(
|
||||||
|
not command.minimum_values[index] <= float(value) <= command.maximum_values[index]
|
||||||
|
for index, value in task.auxiliary_commands
|
||||||
|
):
|
||||||
|
errors.append(f"task {task.key} auxiliary command is out of range")
|
||||||
|
lower = command.minimum_values[task.command_index]
|
||||||
|
upper = command.maximum_values[task.command_index]
|
||||||
|
if not lower <= task.start_value <= upper or not lower <= task.end_value <= upper:
|
||||||
|
errors.append(f"task {task.key} sweep endpoint is out of range")
|
||||||
|
if task.start_value == task.end_value:
|
||||||
|
errors.append(f"task {task.key} sweep endpoints must differ")
|
||||||
|
for speed in (task.preflight_speed_u8, task.formal_speed_u8):
|
||||||
|
if speed is not None and not 0 <= speed <= 255:
|
||||||
|
errors.append(f"task {task.key} speed is out of range")
|
||||||
|
if command.unit == "rad" and (
|
||||||
|
task.formal_speed is None or float(task.formal_speed) <= 0.0
|
||||||
|
):
|
||||||
|
errors.append(f"task {task.key} physical speed must be positive")
|
||||||
|
for name, spec in profile.measurement.measurements.items():
|
||||||
|
if name != spec.joint:
|
||||||
|
errors.append(f"measurement mapping key differs for {name}")
|
||||||
|
if spec.view is not None and spec.view not in view_names:
|
||||||
|
errors.append(f"measurement {name} uses an unknown view")
|
||||||
|
for primary, validation in profile.measurement.cross_view_sources.items():
|
||||||
|
if primary not in measurement_names or validation not in measurement_names:
|
||||||
|
errors.append("cross-view measurement source is unknown")
|
||||||
|
|
||||||
|
zero = profile.zero
|
||||||
|
if zero.active_joints & zero.passive_joints:
|
||||||
|
errors.append("active and passive joints must be disjoint")
|
||||||
|
all_joints = zero.active_joints | zero.passive_joints
|
||||||
|
if not zero.active_joints.issubset(command.command_index_by_joint):
|
||||||
|
errors.append("every active joint must bind to a command channel")
|
||||||
|
if not set(zero.direct_zero_joints).issubset(zero.active_joints):
|
||||||
|
errors.append("direct zero targets must be active joints")
|
||||||
|
if not set(zero.axis_joints).issubset(all_joints):
|
||||||
|
errors.append("axis targets must be known joints")
|
||||||
|
if not zero.mechanical_endpoint_joints.issubset(zero.active_joints):
|
||||||
|
errors.append("mechanical endpoint targets must be active joints")
|
||||||
|
if not zero.post_solve_endpoint_joints.issubset(zero.active_joints):
|
||||||
|
errors.append("post-solve endpoint targets must be active joints")
|
||||||
|
if not set(zero.mimic_source_by_joint).issubset(zero.passive_joints):
|
||||||
|
errors.append("mimic targets must be passive joints")
|
||||||
|
if not set(zero.mimic_source_by_joint.values()).issubset(all_joints):
|
||||||
|
errors.append("mimic sources must be known joints")
|
||||||
|
if not set(zero.endpoint_anchor_by_joint).issubset(zero.active_joints):
|
||||||
|
errors.append("endpoint anchors must target active joints")
|
||||||
|
if not set(zero.endpoint_anchor_by_joint.values()).issubset(
|
||||||
|
{
|
||||||
|
"upper_at_end",
|
||||||
|
"lower_at_start",
|
||||||
|
"zero_at_start",
|
||||||
|
"cad_range_center",
|
||||||
|
}
|
||||||
|
):
|
||||||
|
errors.append("endpoint anchor policy is unsupported")
|
||||||
|
if not zero.fitted_mimic_joints.issubset(zero.passive_joints):
|
||||||
|
errors.append("fitted mimic targets must be passive joints")
|
||||||
|
if not zero.fitted_mimic_joints.issubset(zero.mimic_source_by_joint):
|
||||||
|
errors.append("fitted mimic target has no source mapping")
|
||||||
|
if not set(zero.coupling_model_by_joint).issubset(
|
||||||
|
zero.mimic_source_by_joint
|
||||||
|
):
|
||||||
|
errors.append("coupling model target has no source mapping")
|
||||||
|
if not set(zero.coupling_model_by_joint.values()).issubset(
|
||||||
|
{"linear_mimic", "quadratic_runtime"}
|
||||||
|
):
|
||||||
|
errors.append("coupling model policy is unsupported")
|
||||||
|
|
||||||
|
scopes = set(profile.scope.calibrate_joints)
|
||||||
|
if profile.scope.default_scope not in scopes:
|
||||||
|
errors.append("default scope is not declared")
|
||||||
|
if scopes != set(profile.scope.frozen_joints):
|
||||||
|
errors.append("scope calibration and frozen mappings must align")
|
||||||
|
for name in scopes:
|
||||||
|
selected = profile.scope.calibrate_joints[name]
|
||||||
|
frozen = profile.scope.frozen_joints[name]
|
||||||
|
if selected & frozen or selected | frozen != zero.active_joints:
|
||||||
|
errors.append(f"scope {name} must partition all active joints")
|
||||||
|
|
||||||
|
artifacts = profile.artifacts
|
||||||
|
if artifacts.output_schema_version < 1:
|
||||||
|
errors.append("artifact schema version must be positive")
|
||||||
|
for label, filename in (
|
||||||
|
("calibration", artifacts.calibration_filename),
|
||||||
|
("corrected URDF", artifacts.corrected_urdf_filename),
|
||||||
|
("publication pointer", artifacts.publication_pointer),
|
||||||
|
):
|
||||||
|
if not filename or PurePath(filename).name != filename:
|
||||||
|
errors.append(f"{label} filename must not contain a directory")
|
||||||
|
if not profile.namespace.startswith("/"):
|
||||||
|
errors.append("runtime namespace must be absolute")
|
||||||
|
if profile.joint_coverage:
|
||||||
|
valid_coverage = {
|
||||||
|
"measured_static_dynamic",
|
||||||
|
"measured_dynamic_cad_static",
|
||||||
|
"transferred_static_dynamic",
|
||||||
|
"transferred_dynamic_cad_static",
|
||||||
|
"cad_nominal",
|
||||||
|
"mimic_nominal",
|
||||||
|
}
|
||||||
|
if set(profile.joint_coverage) != all_joints:
|
||||||
|
errors.append("joint coverage must describe every profile joint")
|
||||||
|
if not set(profile.joint_coverage.values()).issubset(valid_coverage):
|
||||||
|
errors.append("joint coverage contains an unsupported status")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise ProfileValidationError("; ".join(errors))
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Normalized records shared by online evaluation and offline replay."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import math
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SampleRecord:
|
||||||
|
task_key: str
|
||||||
|
measurement: str
|
||||||
|
view: str
|
||||||
|
cycle: int
|
||||||
|
direction: str
|
||||||
|
command_u8: int
|
||||||
|
timestamp_ns: int
|
||||||
|
values: Mapping[str, Any]
|
||||||
|
quality: Mapping[str, float] = field(default_factory=dict)
|
||||||
|
command: float | None = None
|
||||||
|
command_unit: str = "u8"
|
||||||
|
progress_01: float | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.task_key or not self.measurement or not self.view:
|
||||||
|
raise ValueError("sample task, measurement, and view are required")
|
||||||
|
if self.cycle < 0:
|
||||||
|
raise ValueError("sample cycle or command is out of range")
|
||||||
|
if self.command_unit == "u8" and not 0 <= self.command_u8 <= 255:
|
||||||
|
raise ValueError("sample cycle or command is out of range")
|
||||||
|
if self.command_unit not in {"u8", "rad"}:
|
||||||
|
raise ValueError("sample command unit must be u8 or rad")
|
||||||
|
if self.command is not None and not math.isfinite(float(self.command)):
|
||||||
|
raise ValueError("sample command must be finite")
|
||||||
|
if self.progress_01 is not None and not 0.0 <= float(self.progress_01) <= 1.0:
|
||||||
|
raise ValueError("sample progress must be in [0, 1]")
|
||||||
|
if self.timestamp_ns < 0:
|
||||||
|
raise ValueError("sample timestamp must be non-negative")
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Canonical command/feedback schema for calibration observations.
|
||||||
|
|
||||||
|
The hand command and its measured motor feedback are different physical
|
||||||
|
domains. Durable samples always retain both. Fitting code may still use the
|
||||||
|
historical ``command_u8`` key, but it is created only as an explicit projection
|
||||||
|
of a canonical record at the fitting boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Any, Iterable, Literal, Mapping
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE_KINDS = frozenset(
|
||||||
|
{
|
||||||
|
'sample',
|
||||||
|
'baseline_hold_sample',
|
||||||
|
'steady_command_sample',
|
||||||
|
'palm_axis_sample',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
FitDomain = Literal['default', 'requested', 'feedback']
|
||||||
|
|
||||||
|
|
||||||
|
class SampleDataContractError(ValueError):
|
||||||
|
"""A calibration observation mixes or omits command domains."""
|
||||||
|
|
||||||
|
|
||||||
|
def _finite_u8(value: Any, field: str, *, integral: bool) -> int | float:
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise SampleDataContractError(
|
||||||
|
f'DATA-CONTRACT-701:{field} must be numeric'
|
||||||
|
) from error
|
||||||
|
if not math.isfinite(number) or not 0.0 <= number <= 255.0:
|
||||||
|
raise SampleDataContractError(
|
||||||
|
f'DATA-CONTRACT-701:{field} must be finite and in [0, 255]'
|
||||||
|
)
|
||||||
|
if integral:
|
||||||
|
rounded = int(round(number))
|
||||||
|
if not math.isclose(number, rounded, rel_tol=0.0, abs_tol=1.0e-9):
|
||||||
|
raise SampleDataContractError(
|
||||||
|
f'DATA-CONTRACT-701:{field} must be an integer command'
|
||||||
|
)
|
||||||
|
return rounded
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def explicit_domain_value(
|
||||||
|
source: Mapping[str, Any], domain: Literal['requested', 'feedback']
|
||||||
|
) -> int | float:
|
||||||
|
"""Read and validate one explicitly named domain from any observation."""
|
||||||
|
field = (
|
||||||
|
'requested_command_u8' if domain == 'requested' else 'feedback_u8'
|
||||||
|
)
|
||||||
|
if field not in source or source[field] is None:
|
||||||
|
raise SampleDataContractError(
|
||||||
|
f'DATA-CONTRACT-701:observation is missing explicit {field}'
|
||||||
|
)
|
||||||
|
return _finite_u8(source[field], field, integral=domain == 'requested')
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_sample_record(
|
||||||
|
source: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
allow_legacy_command: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return one durable, unambiguous calibration observation.
|
||||||
|
|
||||||
|
``allow_legacy_command`` is restricted to importing historical sessions
|
||||||
|
and unit fixtures. New online observations must provide both explicit
|
||||||
|
fields and therefore cannot silently reinterpret ``command_u8``.
|
||||||
|
"""
|
||||||
|
record = dict(source)
|
||||||
|
kind = str(record.get('kind', ''))
|
||||||
|
if not kind and allow_legacy_command:
|
||||||
|
# Old in-memory steady-curve fixtures predate durable sample kinds.
|
||||||
|
# This adapter is never enabled by the new online/import contract.
|
||||||
|
kind = 'steady_command_sample'
|
||||||
|
record['kind'] = kind
|
||||||
|
if kind not in SAMPLE_KINDS:
|
||||||
|
raise SampleDataContractError(
|
||||||
|
f'DATA-CONTRACT-701:unsupported calibration sample kind {kind!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
requested = record.get('requested_command_u8')
|
||||||
|
feedback = record.get('feedback_u8')
|
||||||
|
legacy = record.get('command_u8')
|
||||||
|
if requested is None or feedback is None:
|
||||||
|
if not allow_legacy_command or legacy is None:
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
('requested_command_u8', requested),
|
||||||
|
('feedback_u8', feedback),
|
||||||
|
)
|
||||||
|
if value is None
|
||||||
|
]
|
||||||
|
raise SampleDataContractError(
|
||||||
|
'DATA-CONTRACT-701:'
|
||||||
|
f'{kind} is missing explicit {",".join(missing)}'
|
||||||
|
)
|
||||||
|
# Historical in-memory records used requested commands for settled
|
||||||
|
# checkpoints and feedback bins for dense/baseline/palm observations.
|
||||||
|
if requested is None:
|
||||||
|
requested = legacy
|
||||||
|
if feedback is None:
|
||||||
|
feedback = legacy
|
||||||
|
|
||||||
|
record.pop('command_u8', None)
|
||||||
|
record['requested_command_u8'] = explicit_domain_value(
|
||||||
|
{'requested_command_u8': requested}, 'requested'
|
||||||
|
)
|
||||||
|
record['feedback_u8'] = explicit_domain_value(
|
||||||
|
{'feedback_u8': feedback}, 'feedback'
|
||||||
|
)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def fitting_sample_record(
|
||||||
|
source: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
domain: FitDomain = 'default',
|
||||||
|
allow_legacy_command: bool = False,
|
||||||
|
snap_requested_endpoints: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Project a canonical sample into the legacy curve-fitter interface."""
|
||||||
|
record = canonical_sample_record(
|
||||||
|
source, allow_legacy_command=allow_legacy_command
|
||||||
|
)
|
||||||
|
kind = str(record['kind'])
|
||||||
|
selected = domain
|
||||||
|
if selected == 'default':
|
||||||
|
selected = (
|
||||||
|
'requested' if kind == 'steady_command_sample' else 'feedback'
|
||||||
|
)
|
||||||
|
if selected not in {'requested', 'feedback'}:
|
||||||
|
raise SampleDataContractError(
|
||||||
|
f'DATA-CONTRACT-701:unsupported fitting domain {domain!r}'
|
||||||
|
)
|
||||||
|
requested = int(record['requested_command_u8'])
|
||||||
|
if selected == 'requested' or (
|
||||||
|
snap_requested_endpoints and requested in {0, 255}
|
||||||
|
):
|
||||||
|
index = requested
|
||||||
|
else:
|
||||||
|
index = int(
|
||||||
|
np.clip(np.rint(float(record['feedback_u8'])), 0, 255)
|
||||||
|
)
|
||||||
|
record['command_u8'] = index
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def fitting_sample_records(
|
||||||
|
records: Iterable[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
domain: FitDomain = 'default',
|
||||||
|
allow_legacy_command: bool = False,
|
||||||
|
snap_requested_endpoints: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Project several canonical samples into one explicit fitting domain."""
|
||||||
|
return [
|
||||||
|
fitting_sample_record(
|
||||||
|
record,
|
||||||
|
domain=domain,
|
||||||
|
allow_legacy_command=allow_legacy_command,
|
||||||
|
snap_requested_endpoints=snap_requested_endpoints,
|
||||||
|
)
|
||||||
|
for record in records
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_sample_records(
|
||||||
|
records: Iterable[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
allow_legacy_command: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Validate a collection without changing its representation."""
|
||||||
|
for index, record in enumerate(records):
|
||||||
|
try:
|
||||||
|
canonical_sample_record(
|
||||||
|
record, allow_legacy_command=allow_legacy_command
|
||||||
|
)
|
||||||
|
except SampleDataContractError as error:
|
||||||
|
raise SampleDataContractError(
|
||||||
|
f'{error};record_index={index}'
|
||||||
|
) from error
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Shared task direction vocabulary."""
|
||||||
|
|
||||||
|
DIRECTION_DECREASING = "decreasing"
|
||||||
|
DIRECTION_INCREASING = "increasing"
|
||||||
|
DIRECTIONS: tuple[str, ...] = (
|
||||||
|
DIRECTION_DECREASING,
|
||||||
|
DIRECTION_INCREASING,
|
||||||
|
)
|
||||||
|
|
||||||
|
PHASE_ROOT = "root"
|
||||||
|
PHASE_TIP = "tip"
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DIRECTION_DECREASING",
|
||||||
|
"DIRECTION_INCREASING",
|
||||||
|
"DIRECTIONS",
|
||||||
|
"PHASE_ROOT",
|
||||||
|
"PHASE_TIP",
|
||||||
|
]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Pure curve and axis fitting."""
|
||||||
|
|
||||||
|
from .curve import FitResult, isotonic_nonincreasing
|
||||||
|
|
||||||
|
__all__ = ["FitResult", "isotonic_nonincreasing"]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Model-independent curve fitting result and monotonic projection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ..geometry import delta_rotation_vector
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Pure geometry used by online and offline calibration."""
|
||||||
|
|
||||||
|
from .extrinsics import (
|
||||||
|
CameraCalibrationIdentity,
|
||||||
|
CameraExtrinsics,
|
||||||
|
camera_info_fingerprint,
|
||||||
|
load_camera_extrinsics,
|
||||||
|
matrix_payload,
|
||||||
|
transform_matrix,
|
||||||
|
validate_camera_extrinsics_payload,
|
||||||
|
)
|
||||||
|
from .rotation import (
|
||||||
|
delta_rotation_vector,
|
||||||
|
fit_rotation_axis,
|
||||||
|
image_plane_tag_quaternion_xyzw,
|
||||||
|
normalize_quaternion_xyzw,
|
||||||
|
relative_quaternion_xyzw,
|
||||||
|
robust_rotation_summary,
|
||||||
|
rotation_inlier_fraction,
|
||||||
|
rotation_rms_rad,
|
||||||
|
rotation_spread_rad,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CameraCalibrationIdentity",
|
||||||
|
"CameraExtrinsics",
|
||||||
|
"camera_info_fingerprint",
|
||||||
|
"delta_rotation_vector",
|
||||||
|
"fit_rotation_axis",
|
||||||
|
"image_plane_tag_quaternion_xyzw",
|
||||||
|
"load_camera_extrinsics",
|
||||||
|
"matrix_payload",
|
||||||
|
"normalize_quaternion_xyzw",
|
||||||
|
"relative_quaternion_xyzw",
|
||||||
|
"robust_rotation_summary",
|
||||||
|
"rotation_inlier_fraction",
|
||||||
|
"rotation_rms_rad",
|
||||||
|
"rotation_spread_rad",
|
||||||
|
"transform_matrix",
|
||||||
|
"validate_camera_extrinsics_payload",
|
||||||
|
]
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Camera-extrinsic data model shared by calibration and runtime nodes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import yaml
|
||||||
|
from scipy.spatial.transform import Rotation
|
||||||
|
|
||||||
|
|
||||||
|
def camera_info_fingerprint(
|
||||||
|
*,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
camera_matrix: Sequence[Sequence[float]] | Sequence[float],
|
||||||
|
distortion: Sequence[float] = (),
|
||||||
|
rectification: Sequence[float] = (),
|
||||||
|
projection: Sequence[float] = (),
|
||||||
|
) -> str:
|
||||||
|
"""Return a stable fingerprint for rectified image geometry."""
|
||||||
|
matrix = np.asarray(camera_matrix, dtype=float).reshape(3, 3)
|
||||||
|
payload = {
|
||||||
|
"width": int(width),
|
||||||
|
"height": int(height),
|
||||||
|
"camera_matrix": [round(float(value), 12) for value in matrix.flat],
|
||||||
|
"distortion": [round(float(value), 12) for value in distortion],
|
||||||
|
"rectification": [round(float(value), 12) for value in rectification],
|
||||||
|
"projection": [round(float(value), 12) for value in projection],
|
||||||
|
}
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload, sort_keys=True, separators=(",", ":")
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def transform_matrix(
|
||||||
|
translation_xyz_m: Sequence[float],
|
||||||
|
quaternion_xyzw: Sequence[float],
|
||||||
|
) -> np.ndarray:
|
||||||
|
translation = np.asarray(translation_xyz_m, dtype=float)
|
||||||
|
quaternion = np.asarray(quaternion_xyzw, dtype=float)
|
||||||
|
if translation.shape != (3,) or not np.all(np.isfinite(translation)):
|
||||||
|
raise ValueError("translation_xyz_m must contain three finite values")
|
||||||
|
if quaternion.shape != (4,) or not np.all(np.isfinite(quaternion)):
|
||||||
|
raise ValueError("quaternion_xyzw must contain four finite values")
|
||||||
|
norm = float(np.linalg.norm(quaternion))
|
||||||
|
if norm < 1.0e-12:
|
||||||
|
raise ValueError("quaternion_xyzw has zero norm")
|
||||||
|
result = np.eye(4, dtype=float)
|
||||||
|
result[:3, :3] = Rotation.from_quat(quaternion / norm).as_matrix()
|
||||||
|
result[:3, 3] = translation
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def matrix_payload(matrix: Sequence[Sequence[float]]) -> dict[str, list[float]]:
|
||||||
|
value = np.asarray(matrix, dtype=float)
|
||||||
|
if value.shape != (4, 4) or not np.all(np.isfinite(value)):
|
||||||
|
raise ValueError("transform must be a finite 4x4 matrix")
|
||||||
|
return {
|
||||||
|
"translation_xyz_m": [float(item) for item in value[:3, 3]],
|
||||||
|
"quaternion_xyzw": [
|
||||||
|
float(item) for item in Rotation.from_matrix(value[:3, :3]).as_quat()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CameraCalibrationIdentity:
|
||||||
|
serial_number: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
intrinsics_sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CameraExtrinsics:
|
||||||
|
"""Transforms every declared camera into one Profile-selected reference."""
|
||||||
|
|
||||||
|
cameras: Mapping[str, CameraCalibrationIdentity]
|
||||||
|
reference_view: str
|
||||||
|
reference_from_view: Mapping[str, np.ndarray]
|
||||||
|
quality: Mapping[str, float]
|
||||||
|
|
||||||
|
def transform(self, view: str) -> np.ndarray:
|
||||||
|
if view not in self.reference_from_view:
|
||||||
|
raise KeyError(f"extrinsics do not contain view {view}")
|
||||||
|
return np.asarray(self.reference_from_view[view], dtype=float).copy()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_camera_extrinsics_payload(
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
required_views: Sequence[str],
|
||||||
|
reference_view: str,
|
||||||
|
quality_limits: Mapping[str, float] | None = None,
|
||||||
|
minimum_capture_counts: Mapping[str, int] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if int(payload.get("schema_version", -1)) != 1:
|
||||||
|
raise ValueError("camera extrinsics schema_version must be 1")
|
||||||
|
reference = str(reference_view)
|
||||||
|
if payload.get("reference_view") != reference:
|
||||||
|
raise ValueError(
|
||||||
|
"camera extrinsics reference_view differs from the Profile"
|
||||||
|
)
|
||||||
|
cameras = payload.get("cameras")
|
||||||
|
transforms = payload.get(f"{reference}_from_view")
|
||||||
|
quality = payload.get("quality")
|
||||||
|
views = tuple(str(view) for view in required_views)
|
||||||
|
if not views or len(set(views)) != len(views):
|
||||||
|
raise ValueError("required extrinsic views must be non-empty and unique")
|
||||||
|
if reference not in views:
|
||||||
|
raise ValueError("extrinsic reference view is not required")
|
||||||
|
if not isinstance(cameras, Mapping) or set(cameras) != set(views):
|
||||||
|
raise ValueError("camera extrinsics differ from the Profile views")
|
||||||
|
if not isinstance(transforms, Mapping) or set(transforms) != set(views):
|
||||||
|
raise ValueError("camera transforms differ from the Profile views")
|
||||||
|
if not isinstance(quality, Mapping) or not bool(quality.get("passed")):
|
||||||
|
raise ValueError("camera extrinsics quality is not passed")
|
||||||
|
for key, limit in dict(quality_limits or {}).items():
|
||||||
|
value = float(quality.get(key, float("inf")))
|
||||||
|
if not np.isfinite(value) or value > limit:
|
||||||
|
raise ValueError(
|
||||||
|
f"camera extrinsics {key}={value} exceeds {limit}"
|
||||||
|
)
|
||||||
|
for key, minimum in dict(minimum_capture_counts or {}).items():
|
||||||
|
if int(quality.get(key, 0)) < int(minimum):
|
||||||
|
raise ValueError(
|
||||||
|
f"camera extrinsics {key} must be at least {minimum}"
|
||||||
|
)
|
||||||
|
for view in views:
|
||||||
|
identity = cameras[view]
|
||||||
|
if not isinstance(identity, Mapping):
|
||||||
|
raise ValueError(f"{view} camera identity must be an object")
|
||||||
|
if not str(identity.get("serial_number", "")):
|
||||||
|
raise ValueError(f"{view} camera serial_number is missing")
|
||||||
|
if int(identity.get("width", 0)) <= 0 or int(identity.get("height", 0)) <= 0:
|
||||||
|
raise ValueError(f"{view} camera image dimensions are invalid")
|
||||||
|
fingerprint = str(identity.get("intrinsics_sha256", ""))
|
||||||
|
if len(fingerprint) != 64:
|
||||||
|
raise ValueError(f"{view} camera intrinsics fingerprint is invalid")
|
||||||
|
transform = transforms[view]
|
||||||
|
if not isinstance(transform, Mapping):
|
||||||
|
raise ValueError(f"{view} transform must be an object")
|
||||||
|
matrix = transform_matrix(
|
||||||
|
transform.get("translation_xyz_m", ()),
|
||||||
|
transform.get("quaternion_xyzw", ()),
|
||||||
|
)
|
||||||
|
if view == reference and not np.allclose(
|
||||||
|
matrix, np.eye(4), atol=1.0e-9
|
||||||
|
):
|
||||||
|
raise ValueError("reference-view transform must be identity")
|
||||||
|
serials = [str(cameras[view]["serial_number"]) for view in views]
|
||||||
|
if len(set(serials)) != len(views):
|
||||||
|
raise ValueError("camera extrinsics serial numbers must be unique")
|
||||||
|
|
||||||
|
|
||||||
|
def load_camera_extrinsics(
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
required_views: Sequence[str],
|
||||||
|
reference_view: str,
|
||||||
|
quality_limits: Mapping[str, float] | None = None,
|
||||||
|
minimum_capture_counts: Mapping[str, int] | None = None,
|
||||||
|
) -> CameraExtrinsics:
|
||||||
|
source = Path(path).expanduser().resolve()
|
||||||
|
if not source.is_file():
|
||||||
|
raise ValueError(f"camera extrinsics file does not exist: {source}")
|
||||||
|
with source.open("r", encoding="utf-8") as stream:
|
||||||
|
payload = yaml.safe_load(stream)
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
raise ValueError("camera extrinsics file must contain an object")
|
||||||
|
validate_camera_extrinsics_payload(
|
||||||
|
payload,
|
||||||
|
required_views=required_views,
|
||||||
|
reference_view=reference_view,
|
||||||
|
quality_limits=quality_limits,
|
||||||
|
minimum_capture_counts=minimum_capture_counts,
|
||||||
|
)
|
||||||
|
views = tuple(str(view) for view in required_views)
|
||||||
|
transform_key = f"{reference_view}_from_view"
|
||||||
|
cameras = {
|
||||||
|
view: CameraCalibrationIdentity(
|
||||||
|
serial_number=str(payload["cameras"][view]["serial_number"]),
|
||||||
|
width=int(payload["cameras"][view]["width"]),
|
||||||
|
height=int(payload["cameras"][view]["height"]),
|
||||||
|
intrinsics_sha256=str(
|
||||||
|
payload["cameras"][view]["intrinsics_sha256"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for view in views
|
||||||
|
}
|
||||||
|
transforms = {
|
||||||
|
view: transform_matrix(
|
||||||
|
payload[transform_key][view]["translation_xyz_m"],
|
||||||
|
payload[transform_key][view]["quaternion_xyzw"],
|
||||||
|
)
|
||||||
|
for view in views
|
||||||
|
}
|
||||||
|
return CameraExtrinsics(
|
||||||
|
cameras=cameras,
|
||||||
|
reference_view=str(reference_view),
|
||||||
|
reference_from_view=transforms,
|
||||||
|
quality={
|
||||||
|
str(key): float(value) if isinstance(value, (int, float)) else value
|
||||||
|
for key, value in payload["quality"].items()
|
||||||
|
},
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
|||||||
|
"""Pure quaternion summaries and rotation-axis fitting."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.spatial.transform import Rotation
|
||||||
|
|
||||||
|
|
||||||
|
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-to-child orientation from two camera-to-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")
|
||||||
|
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 command 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)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Task acceptance and final-session solver contracts."""
|
||||||
|
|
||||||
|
from .interfaces import (
|
||||||
|
SessionSolution,
|
||||||
|
SessionSolver,
|
||||||
|
TaskEvaluation,
|
||||||
|
TaskEvaluator,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SessionSolution",
|
||||||
|
"SessionSolver",
|
||||||
|
"TaskEvaluation",
|
||||||
|
"TaskEvaluator",
|
||||||
|
]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Shared evaluator and final-solver interfaces."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Mapping, Protocol, Sequence
|
||||||
|
|
||||||
|
from ..domain import CalibrationProfile, SampleRecord, TaskSpec
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskEvaluation:
|
||||||
|
accepted: bool
|
||||||
|
failures: tuple[Mapping[str, Any], ...] = ()
|
||||||
|
rescan_measurements: frozenset[str] = frozenset()
|
||||||
|
rescan_cycles: frozenset[int] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SessionSolution:
|
||||||
|
passed: bool
|
||||||
|
calibration: Mapping[str, Any]
|
||||||
|
zero_offsets_rad: Mapping[str, float]
|
||||||
|
failures: tuple[Mapping[str, Any], ...] = ()
|
||||||
|
diagnostics: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskEvaluator(Protocol):
|
||||||
|
def evaluate_task(
|
||||||
|
self,
|
||||||
|
profile: CalibrationProfile,
|
||||||
|
task: TaskSpec,
|
||||||
|
samples: Sequence[SampleRecord],
|
||||||
|
) -> TaskEvaluation: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSolver(Protocol):
|
||||||
|
def solve_session(
|
||||||
|
self,
|
||||||
|
profile: CalibrationProfile,
|
||||||
|
samples: Sequence[SampleRecord],
|
||||||
|
) -> SessionSolution: ...
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""URDF correction authorization and validation types."""
|
||||||
|
|
||||||
|
from .plan import UrdfCorrectionPlan, build_correction_plan
|
||||||
|
from .patch import (
|
||||||
|
MujocoEqualityPatch,
|
||||||
|
UrdfJointPatch,
|
||||||
|
UrdfPatchSet,
|
||||||
|
apply_urdf_patch_text,
|
||||||
|
materialize_relative_mesh_assets,
|
||||||
|
write_urdf_patches,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MujocoEqualityPatch",
|
||||||
|
"UrdfCorrectionPlan",
|
||||||
|
"UrdfJointPatch",
|
||||||
|
"UrdfPatchSet",
|
||||||
|
"apply_urdf_patch_text",
|
||||||
|
"build_correction_plan",
|
||||||
|
"materialize_relative_mesh_assets",
|
||||||
|
"write_urdf_patches",
|
||||||
|
]
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""Byte-preserving, declarative URDF patch application.
|
||||||
|
|
||||||
|
Model profiles decide *what* values are authorized. This module owns the
|
||||||
|
shared mechanics of locating those fields in the original XML text, changing
|
||||||
|
only the declared attributes, materializing mesh resources and atomically
|
||||||
|
publishing a new file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from typing import Mapping, Sequence
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UrdfJointPatch:
|
||||||
|
"""Authorized attribute replacements inside one top-level URDF joint."""
|
||||||
|
|
||||||
|
origin_rpy: str | None = None
|
||||||
|
limit_lower: str | None = None
|
||||||
|
limit_upper: str | None = None
|
||||||
|
mimic_multiplier: str | None = None
|
||||||
|
mimic_offset: str | None = None
|
||||||
|
|
||||||
|
def replacements(self) -> tuple[tuple[str, str, str], ...]:
|
||||||
|
values = (
|
||||||
|
("origin", "rpy", self.origin_rpy),
|
||||||
|
("limit", "lower", self.limit_lower),
|
||||||
|
("limit", "upper", self.limit_upper),
|
||||||
|
("mimic", "multiplier", self.mimic_multiplier),
|
||||||
|
("mimic", "offset", self.mimic_offset),
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
(element, attribute, str(value))
|
||||||
|
for element, attribute, value in values
|
||||||
|
if value is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MujocoEqualityPatch:
|
||||||
|
"""Replacement and optional topology assertion for one equality joint."""
|
||||||
|
|
||||||
|
polycoef: str
|
||||||
|
expected_joint1: str | None = None
|
||||||
|
expected_joint2: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UrdfPatchSet:
|
||||||
|
"""Complete declarative edit set for one generated URDF."""
|
||||||
|
|
||||||
|
joints: Mapping[str, UrdfJointPatch]
|
||||||
|
mujoco_equalities: Mapping[str, MujocoEqualityPatch] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
empty = [
|
||||||
|
name for name, patch in self.joints.items() if not patch.replacements()
|
||||||
|
]
|
||||||
|
if empty:
|
||||||
|
raise ValueError(
|
||||||
|
"URDF joint patch contains no replacements: "
|
||||||
|
+ ",".join(sorted(empty))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_attribute(
|
||||||
|
block: str, element: str, attribute: str, value: str
|
||||||
|
) -> str:
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"(<{element}\b[^>]*\b{attribute}\s*=\s*)([\"'])"
|
||||||
|
rf"(?P<value>[^\"']*)\2",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
match = pattern.search(block)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError(f"{element} has no {attribute} attribute")
|
||||||
|
start, end = match.span("value")
|
||||||
|
return block[:start] + str(value) + block[end:]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_urdf_patch_text(
|
||||||
|
original_text: str,
|
||||||
|
root: ET.Element,
|
||||||
|
patches: UrdfPatchSet,
|
||||||
|
) -> str:
|
||||||
|
"""Apply declared patches without serializing unaffected XML."""
|
||||||
|
top_level_joints = {
|
||||||
|
str(joint.get("name")): joint for joint in root.findall("joint")
|
||||||
|
}
|
||||||
|
missing_joints = set(patches.joints) - set(top_level_joints)
|
||||||
|
if missing_joints:
|
||||||
|
raise ValueError(
|
||||||
|
"source URDF is missing target joints: "
|
||||||
|
+ ",".join(sorted(missing_joints))
|
||||||
|
)
|
||||||
|
|
||||||
|
equality_nodes = {
|
||||||
|
str(joint.get("name")): joint
|
||||||
|
for joint in root.findall("./mujoco/equality/joint")
|
||||||
|
}
|
||||||
|
missing_equalities = set(patches.mujoco_equalities) - set(equality_nodes)
|
||||||
|
if missing_equalities:
|
||||||
|
raise ValueError(
|
||||||
|
"source URDF is missing MuJoCo equalities: "
|
||||||
|
+ ",".join(sorted(missing_equalities))
|
||||||
|
)
|
||||||
|
for name, patch in patches.mujoco_equalities.items():
|
||||||
|
node = equality_nodes[name]
|
||||||
|
if (
|
||||||
|
patch.expected_joint1 is not None
|
||||||
|
and node.get("joint1") != patch.expected_joint1
|
||||||
|
):
|
||||||
|
raise ValueError(f"MuJoCo equality joint1 differs for {name}")
|
||||||
|
if (
|
||||||
|
patch.expected_joint2 is not None
|
||||||
|
and node.get("joint2") != patch.expected_joint2
|
||||||
|
):
|
||||||
|
raise ValueError(f"MuJoCo equality joint2 differs for {name}")
|
||||||
|
|
||||||
|
# Requiring the URDF ``type`` attribute excludes transmission and MuJoCo
|
||||||
|
# elements which also use the tag name ``joint``.
|
||||||
|
joint_pattern = re.compile(
|
||||||
|
r"<joint\b(?=[^>]*\btype\s*=)[^>]*\bname\s*=\s*"
|
||||||
|
r"([\"'])(?P<name>[^\"']+)\1[^>]*>"
|
||||||
|
r".*?</joint>",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
applied_joints: set[str] = set()
|
||||||
|
|
||||||
|
def replace_joint(match: re.Match[str]) -> str:
|
||||||
|
name = match.group("name")
|
||||||
|
patch = patches.joints.get(name)
|
||||||
|
if patch is None:
|
||||||
|
return match.group(0)
|
||||||
|
if name in applied_joints:
|
||||||
|
raise ValueError(f"duplicate top-level URDF joint text: {name}")
|
||||||
|
block = match.group(0)
|
||||||
|
for element, attribute, value in patch.replacements():
|
||||||
|
block = _replace_attribute(block, element, attribute, value)
|
||||||
|
applied_joints.add(name)
|
||||||
|
return block
|
||||||
|
|
||||||
|
corrected = joint_pattern.sub(replace_joint, original_text)
|
||||||
|
if applied_joints != set(patches.joints):
|
||||||
|
missing = set(patches.joints) - applied_joints
|
||||||
|
raise ValueError(
|
||||||
|
"could not locate every target joint in source URDF text: "
|
||||||
|
+ ",".join(sorted(missing))
|
||||||
|
)
|
||||||
|
|
||||||
|
applied_equalities: set[str] = set()
|
||||||
|
for name, patch in patches.mujoco_equalities.items():
|
||||||
|
equality_pattern = re.compile(
|
||||||
|
rf"(<joint\b[^>]*\bname\s*=\s*([\"']))"
|
||||||
|
rf"{re.escape(name)}\2[^>]*>",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
matches = list(equality_pattern.finditer(corrected))
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise ValueError(f"could not uniquely locate MuJoCo equality {name}")
|
||||||
|
match = matches[0]
|
||||||
|
replacement = _replace_attribute(
|
||||||
|
match.group(0), "joint", "polycoef", patch.polycoef
|
||||||
|
)
|
||||||
|
corrected = corrected[: match.start()] + replacement + corrected[match.end() :]
|
||||||
|
applied_equalities.add(name)
|
||||||
|
if applied_equalities != set(patches.mujoco_equalities):
|
||||||
|
raise ValueError("could not apply every MuJoCo equality patch")
|
||||||
|
return corrected
|
||||||
|
|
||||||
|
|
||||||
|
def _files_have_identical_contents(left: Path, right: Path) -> bool:
|
||||||
|
if left.stat().st_size != right.stat().st_size:
|
||||||
|
return False
|
||||||
|
with left.open("rb") as left_stream, right.open("rb") as right_stream:
|
||||||
|
while True:
|
||||||
|
left_chunk = left_stream.read(1024 * 1024)
|
||||||
|
right_chunk = right_stream.read(1024 * 1024)
|
||||||
|
if left_chunk != right_chunk:
|
||||||
|
return False
|
||||||
|
if not left_chunk:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_relative_mesh_assets(
|
||||||
|
*, source: Path, output: Path, urdf_root: ET.Element
|
||||||
|
) -> tuple[Path, ...]:
|
||||||
|
"""Copy safe relative mesh resources beside the generated URDF."""
|
||||||
|
filenames = sorted(
|
||||||
|
{
|
||||||
|
str(mesh.get("filename", "")).strip()
|
||||||
|
for mesh in urdf_root.findall(".//mesh")
|
||||||
|
if str(mesh.get("filename", "")).strip()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
materialized: list[Path] = []
|
||||||
|
for filename in filenames:
|
||||||
|
if "://" in filename or filename.startswith("package:"):
|
||||||
|
continue
|
||||||
|
relative = Path(filename)
|
||||||
|
if relative.is_absolute() or ".." in relative.parts:
|
||||||
|
raise ValueError(
|
||||||
|
f"URDF mesh path must be a safe relative path or URI: {filename}"
|
||||||
|
)
|
||||||
|
source_asset = (source.parent / relative).resolve()
|
||||||
|
if not source_asset.is_file():
|
||||||
|
raise ValueError(f"URDF mesh resource does not exist: {source_asset}")
|
||||||
|
destination_asset = (output / relative).resolve()
|
||||||
|
try:
|
||||||
|
destination_asset.relative_to(output)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(
|
||||||
|
f"URDF mesh destination escapes output directory: {filename}"
|
||||||
|
) from error
|
||||||
|
if destination_asset == source_asset:
|
||||||
|
materialized.append(destination_asset)
|
||||||
|
continue
|
||||||
|
destination_asset.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if destination_asset.exists():
|
||||||
|
if not destination_asset.is_file() or not _files_have_identical_contents(
|
||||||
|
source_asset, destination_asset
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"refusing to overwrite a different mesh resource: "
|
||||||
|
f"{destination_asset}"
|
||||||
|
)
|
||||||
|
materialized.append(destination_asset)
|
||||||
|
continue
|
||||||
|
temporary_asset = destination_asset.with_name(
|
||||||
|
f".{destination_asset.name}.{os.getpid()}.tmp"
|
||||||
|
)
|
||||||
|
if temporary_asset.exists():
|
||||||
|
raise ValueError(f"temporary mesh path is occupied: {temporary_asset}")
|
||||||
|
try:
|
||||||
|
shutil.copy2(source_asset, temporary_asset)
|
||||||
|
os.replace(temporary_asset, destination_asset)
|
||||||
|
finally:
|
||||||
|
if temporary_asset.exists():
|
||||||
|
temporary_asset.unlink()
|
||||||
|
materialized.append(destination_asset)
|
||||||
|
return tuple(materialized)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_complete_mesh_directory(source: Path, output: Path) -> None:
|
||||||
|
source_meshes = source.parent / "meshes"
|
||||||
|
if not source_meshes.is_dir():
|
||||||
|
return
|
||||||
|
destination_meshes = output / "meshes"
|
||||||
|
destination_meshes.mkdir(parents=True, exist_ok=True)
|
||||||
|
for mesh in source_meshes.iterdir():
|
||||||
|
if mesh.is_file():
|
||||||
|
shutil.copy2(mesh, destination_meshes / mesh.name)
|
||||||
|
|
||||||
|
|
||||||
|
def write_urdf_patches(
|
||||||
|
*,
|
||||||
|
source_urdf: str | Path,
|
||||||
|
destination_urdf: str | Path,
|
||||||
|
patches: UrdfPatchSet,
|
||||||
|
forbidden_source_stem_patterns: Sequence[str] = (),
|
||||||
|
copy_complete_mesh_directory: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
"""Validate and atomically materialize one patched URDF."""
|
||||||
|
source = Path(source_urdf).expanduser().resolve()
|
||||||
|
destination = Path(destination_urdf).expanduser().resolve()
|
||||||
|
if not source.is_file():
|
||||||
|
raise ValueError(f"source URDF does not exist: {source}")
|
||||||
|
for pattern in forbidden_source_stem_patterns:
|
||||||
|
if re.search(str(pattern), source.stem, re.IGNORECASE):
|
||||||
|
raise ValueError(
|
||||||
|
"source URDF must be the immutable original CAD URDF"
|
||||||
|
)
|
||||||
|
if destination == source or destination.exists():
|
||||||
|
raise ValueError(f"refusing to overwrite URDF: {destination}")
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tree = ET.parse(source)
|
||||||
|
root = tree.getroot()
|
||||||
|
corrected = apply_urdf_patch_text(
|
||||||
|
source.read_text(encoding="utf-8"), root, patches
|
||||||
|
)
|
||||||
|
materialize_relative_mesh_assets(
|
||||||
|
source=source, output=destination.parent, urdf_root=root
|
||||||
|
)
|
||||||
|
if copy_complete_mesh_directory:
|
||||||
|
_copy_complete_mesh_directory(source, destination.parent)
|
||||||
|
temporary = destination.with_suffix(destination.suffix + ".tmp")
|
||||||
|
try:
|
||||||
|
with temporary.open("w", encoding="utf-8") as stream:
|
||||||
|
stream.write(corrected)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
finally:
|
||||||
|
if temporary.exists():
|
||||||
|
temporary.unlink()
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MujocoEqualityPatch",
|
||||||
|
"UrdfJointPatch",
|
||||||
|
"UrdfPatchSet",
|
||||||
|
"apply_urdf_patch_text",
|
||||||
|
"materialize_relative_mesh_assets",
|
||||||
|
"write_urdf_patches",
|
||||||
|
]
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""One authorization plan shared by URDF writers and validators."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from ..domain import CalibrationProfile
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UrdfCorrectionPlan:
|
||||||
|
source_sha256: str
|
||||||
|
allowed_active_joints: frozenset[str]
|
||||||
|
endpoint_limit_joints: frozenset[str]
|
||||||
|
mimic_source_by_joint: Mapping[str, str]
|
||||||
|
frozen_joints: frozenset[str]
|
||||||
|
frozen_offsets_rad: Mapping[str, float] = field(default_factory=dict)
|
||||||
|
forbid_calibrated_source: bool = True
|
||||||
|
forbid_overwrite: bool = True
|
||||||
|
preserve_passive_joints: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if len(self.source_sha256) != 64 or any(
|
||||||
|
character not in "0123456789abcdef"
|
||||||
|
for character in self.source_sha256.lower()
|
||||||
|
):
|
||||||
|
raise ValueError("source URDF SHA-256 is invalid")
|
||||||
|
if self.allowed_active_joints & self.frozen_joints:
|
||||||
|
raise ValueError("allowed and frozen URDF joints overlap")
|
||||||
|
applied = self.allowed_active_joints | set(self.frozen_offsets_rad)
|
||||||
|
if not set(self.frozen_offsets_rad).issubset(self.frozen_joints):
|
||||||
|
raise ValueError("frozen offsets must belong to frozen joints")
|
||||||
|
if not self.endpoint_limit_joints.issubset(applied):
|
||||||
|
raise ValueError("endpoint limit joint is not an applied active joint")
|
||||||
|
if set(self.mimic_source_by_joint) & self.allowed_active_joints:
|
||||||
|
raise ValueError("dependent mimic joints cannot be active edit targets")
|
||||||
|
|
||||||
|
def authorize_offsets(self, offsets_rad: Mapping[str, float]) -> None:
|
||||||
|
required = self.allowed_active_joints | set(self.frozen_offsets_rad)
|
||||||
|
unexpected = set(offsets_rad) - required
|
||||||
|
if unexpected:
|
||||||
|
raise ValueError(
|
||||||
|
"URDF correction contains unauthorized joints: "
|
||||||
|
+ ", ".join(sorted(unexpected))
|
||||||
|
)
|
||||||
|
missing = required - set(offsets_rad)
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
"URDF correction is missing active joints: "
|
||||||
|
+ ", ".join(sorted(missing))
|
||||||
|
)
|
||||||
|
changed_frozen = {
|
||||||
|
name
|
||||||
|
for name, expected in self.frozen_offsets_rad.items()
|
||||||
|
if abs(float(offsets_rad[name]) - float(expected)) > 1.0e-12
|
||||||
|
}
|
||||||
|
if changed_frozen:
|
||||||
|
raise ValueError(
|
||||||
|
"URDF correction changed frozen offsets: "
|
||||||
|
+ ", ".join(sorted(changed_frozen))
|
||||||
|
)
|
||||||
|
|
||||||
|
def verify_source(self, source_urdf: str | Path) -> None:
|
||||||
|
digest = hashlib.sha256(Path(source_urdf).read_bytes()).hexdigest()
|
||||||
|
if digest != self.source_sha256.lower():
|
||||||
|
raise ValueError("source URDF SHA-256 differs from correction plan")
|
||||||
|
|
||||||
|
|
||||||
|
def build_correction_plan(
|
||||||
|
profile: CalibrationProfile,
|
||||||
|
*,
|
||||||
|
source_sha256: str,
|
||||||
|
scope: str,
|
||||||
|
frozen_offsets_rad: Mapping[str, float] | None = None,
|
||||||
|
) -> UrdfCorrectionPlan:
|
||||||
|
"""Build one scope-aware edit authorization from typed policies."""
|
||||||
|
selected = profile.scope.selected_joints(scope)
|
||||||
|
frozen = profile.scope.frozen_joints[str(scope)]
|
||||||
|
expected_frozen = {
|
||||||
|
str(name): float(value)
|
||||||
|
for name, value in dict(frozen_offsets_rad or {}).items()
|
||||||
|
}
|
||||||
|
if set(expected_frozen) != set(frozen):
|
||||||
|
missing = set(frozen) - set(expected_frozen)
|
||||||
|
extra = set(expected_frozen) - set(frozen)
|
||||||
|
raise ValueError(
|
||||||
|
"frozen URDF offset state differs from scope policy: "
|
||||||
|
f"missing={','.join(sorted(missing)) or '-'};"
|
||||||
|
f"extra={','.join(sorted(extra)) or '-'}"
|
||||||
|
)
|
||||||
|
applied = selected | frozen
|
||||||
|
return UrdfCorrectionPlan(
|
||||||
|
source_sha256=source_sha256,
|
||||||
|
allowed_active_joints=selected,
|
||||||
|
endpoint_limit_joints=(
|
||||||
|
profile.zero.mechanical_endpoint_joints & applied
|
||||||
|
),
|
||||||
|
mimic_source_by_joint=profile.zero.mimic_source_by_joint,
|
||||||
|
frozen_joints=frozen | profile.zero.cad_frozen_joints,
|
||||||
|
frozen_offsets_rad=expected_frozen,
|
||||||
|
)
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""Human-readable diagnostics for the calibration status topic."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .acquisition import TagQuality
|
||||||
|
|
||||||
|
|
||||||
|
ROLE_NAMES_ZH = {
|
||||||
|
"t0": "掌心T0",
|
||||||
|
"t3": "拇指根部T3",
|
||||||
|
"t4": "拇指中节T4",
|
||||||
|
"t5": "拇指末节T5",
|
||||||
|
}
|
||||||
|
|
||||||
|
STATE_NAMES_ZH = {
|
||||||
|
"PREFLIGHT": "设备和标签预检",
|
||||||
|
"WAIT_ROOT_CONFIRM": "等待开始根部标定",
|
||||||
|
"SCAN_ROOT": "正在标定拇指根部",
|
||||||
|
"WAIT_TIP_CONFIRM": "等待开始尖部标定",
|
||||||
|
"SCAN_TIP": "正在标定拇指尖部",
|
||||||
|
"VALIDATING": "正在随机复测",
|
||||||
|
"PAUSED": "标定已暂停",
|
||||||
|
"ABORTED": "标定已终止",
|
||||||
|
"COMPLETE": "标定已完成",
|
||||||
|
}
|
||||||
|
|
||||||
|
_REJECTION_ZH = {
|
||||||
|
"camera_info_not_ready": "相机内参尚未就绪",
|
||||||
|
"tag_not_detected": "当前画面未检测到标签",
|
||||||
|
"tag_quality_invalid": "标签图像质量未达到门限",
|
||||||
|
"no_pose_within_reprojection_or_tilt_limit": (
|
||||||
|
"PnP重投影误差过大或标签倾斜角过大"
|
||||||
|
),
|
||||||
|
"pose_jump": "PnP位置或姿态发生突跳",
|
||||||
|
"pnp_solve_failed": "PnP三维位置求解失败",
|
||||||
|
"group_missing_pose_candidates": "标签组缺少可用的PnP候选解",
|
||||||
|
"group_pose_jump": "标签组PnP结果发生突跳",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pnp_rejection_zh(reason: str) -> str:
|
||||||
|
"""Translate a PnP rejection code without hiding its machine value."""
|
||||||
|
return _REJECTION_ZH.get(str(reason), f"PnP拒绝:{reason}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_tag_quality_diagnostics(
|
||||||
|
tag_config: Mapping[str, Mapping[str, Any]],
|
||||||
|
qualities: Mapping[str, TagQuality],
|
||||||
|
rejections: Mapping[str, str],
|
||||||
|
reprojection_errors_px: Mapping[str, float],
|
||||||
|
*,
|
||||||
|
maximum_hamming: int,
|
||||||
|
minimum_decision_margin: float,
|
||||||
|
minimum_edge_pixels: float,
|
||||||
|
maximum_reprojection_error_px: float,
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Return compact per-tag values, failures and Chinese explanations."""
|
||||||
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
for role, config in tag_config.items():
|
||||||
|
quality = qualities.get(role)
|
||||||
|
rejection = rejections.get(role)
|
||||||
|
problems: list[str] = []
|
||||||
|
if quality is None:
|
||||||
|
problems.append("当前画面未检测到")
|
||||||
|
else:
|
||||||
|
if int(quality.hamming) > int(maximum_hamming):
|
||||||
|
problems.append(
|
||||||
|
f"hamming={quality.hamming},要求≤{maximum_hamming}"
|
||||||
|
)
|
||||||
|
if float(quality.decision_margin) < float(
|
||||||
|
minimum_decision_margin
|
||||||
|
):
|
||||||
|
problems.append(
|
||||||
|
"识别置信度"
|
||||||
|
f"{quality.decision_margin:.1f},要求≥"
|
||||||
|
f"{minimum_decision_margin:.1f}"
|
||||||
|
)
|
||||||
|
if float(quality.edge_pixels) < float(minimum_edge_pixels):
|
||||||
|
problems.append(
|
||||||
|
f"边长{quality.edge_pixels:.1f}px,要求≥"
|
||||||
|
f"{minimum_edge_pixels:.1f}px"
|
||||||
|
)
|
||||||
|
reprojection = reprojection_errors_px.get(role)
|
||||||
|
if (
|
||||||
|
reprojection is not None
|
||||||
|
and float(reprojection) > float(maximum_reprojection_error_px)
|
||||||
|
):
|
||||||
|
problems.append(
|
||||||
|
f"重投影误差{reprojection:.2f}px,要求≤"
|
||||||
|
f"{maximum_reprojection_error_px:.2f}px"
|
||||||
|
)
|
||||||
|
if rejection is not None and not problems:
|
||||||
|
problems.append(pnp_rejection_zh(rejection))
|
||||||
|
|
||||||
|
detection_valid = bool(
|
||||||
|
quality is not None
|
||||||
|
and int(quality.hamming) <= int(maximum_hamming)
|
||||||
|
and float(quality.decision_margin)
|
||||||
|
>= float(minimum_decision_margin)
|
||||||
|
and float(quality.edge_pixels) >= float(minimum_edge_pixels)
|
||||||
|
)
|
||||||
|
individual_valid = bool(
|
||||||
|
detection_valid
|
||||||
|
and rejection is None
|
||||||
|
and (
|
||||||
|
reprojection is None
|
||||||
|
or float(reprojection)
|
||||||
|
<= float(maximum_reprojection_error_px)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
name_zh = ROLE_NAMES_ZH.get(role, role)
|
||||||
|
result[role] = {
|
||||||
|
"id": int(config["id"]),
|
||||||
|
"name_zh": name_zh,
|
||||||
|
"detected": quality is not None,
|
||||||
|
"individual_valid": individual_valid,
|
||||||
|
"hamming": None if quality is None else int(quality.hamming),
|
||||||
|
"decision_margin": (
|
||||||
|
None
|
||||||
|
if quality is None
|
||||||
|
else round(float(quality.decision_margin), 2)
|
||||||
|
),
|
||||||
|
"edge_pixels": (
|
||||||
|
None
|
||||||
|
if quality is None
|
||||||
|
else round(float(quality.edge_pixels), 2)
|
||||||
|
),
|
||||||
|
"reprojection_error_px": (
|
||||||
|
None
|
||||||
|
if reprojection is None
|
||||||
|
else round(float(reprojection), 3)
|
||||||
|
),
|
||||||
|
"rejection": rejection,
|
||||||
|
"problems_zh": problems,
|
||||||
|
"summary_zh": (
|
||||||
|
f"{name_zh}正常"
|
||||||
|
if not problems
|
||||||
|
else f"{name_zh}:" + ";".join(problems)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def status_guidance_zh(
|
||||||
|
state: str,
|
||||||
|
reason: str,
|
||||||
|
tag_diagnostics: Mapping[str, Mapping[str, Any]],
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Return a concise Chinese reason and the next practical action."""
|
||||||
|
reason_text = str(reason)
|
||||||
|
invalid_tags = [
|
||||||
|
item
|
||||||
|
for item in tag_diagnostics.values()
|
||||||
|
if not bool(item.get("individual_valid", False))
|
||||||
|
]
|
||||||
|
tag_failure = (
|
||||||
|
";".join(str(item["summary_zh"]) for item in invalid_tags)
|
||||||
|
if invalid_tags
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
if "invalid_tag_frame" in reason_text or (
|
||||||
|
state == "PREFLIGHT" and invalid_tags
|
||||||
|
):
|
||||||
|
actions: list[str] = []
|
||||||
|
problem_text = ";".join(
|
||||||
|
str(problem)
|
||||||
|
for item in invalid_tags
|
||||||
|
for problem in item.get("problems_zh", [])
|
||||||
|
)
|
||||||
|
if "未检测到" in problem_text:
|
||||||
|
actions.append("调整遮挡或画面范围,让四张标签同时可见")
|
||||||
|
if "边长" in problem_text:
|
||||||
|
actions.append("将相机稍微靠近,建议标签边长稳定大于32px")
|
||||||
|
if "识别置信度" in problem_text:
|
||||||
|
actions.append("改善照明、对焦并避免标签反光")
|
||||||
|
if "hamming" in problem_text:
|
||||||
|
actions.append("清洁或重新打印标签,并检查对焦")
|
||||||
|
if "相机内参" in problem_text:
|
||||||
|
actions.append("确认CameraInfo话题正常")
|
||||||
|
if not actions:
|
||||||
|
actions.append("保持机械手不动,检查标签和相机画面")
|
||||||
|
actions.append("修正后调用resume继续")
|
||||||
|
return (
|
||||||
|
f"标签帧无效,无法采集。{tag_failure}",
|
||||||
|
";".join(dict.fromkeys(actions)),
|
||||||
|
)
|
||||||
|
|
||||||
|
exact = {
|
||||||
|
"waiting_for_camera_tags_and_sdk": (
|
||||||
|
"正在等待相机、四张标签和灵巧手SDK就绪",
|
||||||
|
"保持设备连接,观察预检状态",
|
||||||
|
),
|
||||||
|
"call_start": (
|
||||||
|
"预检已通过,等待开始标定",
|
||||||
|
"调用/g20_thumb_calibration/start",
|
||||||
|
),
|
||||||
|
"call_confirm_root_full_range": (
|
||||||
|
"预检已通过,等待开始拇指根部全行程标定",
|
||||||
|
"调用/g20_thumb_calibration/start",
|
||||||
|
),
|
||||||
|
"operator_pause": (
|
||||||
|
"操作员已暂停标定",
|
||||||
|
"确认安全后调用resume继续",
|
||||||
|
),
|
||||||
|
"calibration_complete": (
|
||||||
|
"标定和随机复测已经完成",
|
||||||
|
"检查最终JSON中的quality.passed",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if reason_text in exact:
|
||||||
|
return exact[reason_text]
|
||||||
|
if reason_text.startswith("collecting_detection_preflight"):
|
||||||
|
return "正在采集标签预检帧", "保持相机和机械手静止"
|
||||||
|
if reason_text.startswith("detection_hz_too_low"):
|
||||||
|
return (
|
||||||
|
f"AprilTag检测频率过低({reason_text.split(':')[-1]}Hz)",
|
||||||
|
"关闭图像显示等额外订阅,检查相机和检测节点负载",
|
||||||
|
)
|
||||||
|
if reason_text.startswith("detection_rate_too_low"):
|
||||||
|
return "四张标签同时有效的比例过低", "检查遮挡、边长和照明"
|
||||||
|
if "not_stable" in reason_text:
|
||||||
|
return "标签三维中心尚未稳定,无法完成静态采集", (
|
||||||
|
"不要触碰相机和机械手,检查标签固定及关节抖动后调用resume"
|
||||||
|
)
|
||||||
|
if reason_text.startswith("continuous_sweep_insufficient_coverage"):
|
||||||
|
return "连续扫描采样覆盖不足", "降低标定速度或提高AprilTag检测频率后重扫"
|
||||||
|
if "synchronised_tag_state_timeout" in reason_text:
|
||||||
|
return (
|
||||||
|
"运动过程中连续3秒没有取得同时有效的标签帧和电机状态;"
|
||||||
|
"暂停后当前画面恢复正常也不会自动继续",
|
||||||
|
"确认四张标签全行程稳定可见,并检查/g20/cb_left_hand_state;"
|
||||||
|
"修正后调用resume继续,不要调用start",
|
||||||
|
)
|
||||||
|
if "timeout" in reason_text:
|
||||||
|
return f"等待超时:{reason_text}", "检查标签、同步状态和机械手是否到位"
|
||||||
|
if reason_text.startswith("pnp_trajectory"):
|
||||||
|
return f"三维轨迹质量检查未通过:{reason_text}", (
|
||||||
|
"检查标签固定、相机稳定和机械抖动"
|
||||||
|
)
|
||||||
|
if reason_text.startswith("root:continuous"):
|
||||||
|
return "正在连续采集拇指根部轨迹", "无需操作,等待该方向完成"
|
||||||
|
if reason_text.startswith("tip:continuous"):
|
||||||
|
return "正在连续采集拇指尖部轨迹", "无需操作,等待该方向完成"
|
||||||
|
return (
|
||||||
|
f"{STATE_NAMES_ZH.get(state, state)}:{reason_text}",
|
||||||
|
"根据英文reason机器码检查详细原因",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_status_text_zh(
|
||||||
|
state_zh: str,
|
||||||
|
reason_zh: str,
|
||||||
|
action_zh: str,
|
||||||
|
tag_diagnostics: Mapping[str, Mapping[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
"""Render a terminal-friendly multiline Chinese status message."""
|
||||||
|
lines = [
|
||||||
|
f"状态:{state_zh}",
|
||||||
|
f"原因:{reason_zh}",
|
||||||
|
f"建议:{action_zh}",
|
||||||
|
"标签:",
|
||||||
|
]
|
||||||
|
for item in tag_diagnostics.values():
|
||||||
|
detected = bool(item.get("detected", False))
|
||||||
|
valid = bool(item.get("individual_valid", False))
|
||||||
|
if detected:
|
||||||
|
metrics = (
|
||||||
|
f"边长{float(item['edge_pixels']):.1f}px,"
|
||||||
|
f"置信度{float(item['decision_margin']):.1f},"
|
||||||
|
f"hamming={int(item['hamming'])}"
|
||||||
|
)
|
||||||
|
reprojection = item.get("reprojection_error_px")
|
||||||
|
if reprojection is not None:
|
||||||
|
metrics += f",重投影{float(reprojection):.2f}px"
|
||||||
|
else:
|
||||||
|
metrics = "未检测到"
|
||||||
|
problems = item.get("problems_zh", [])
|
||||||
|
problem_suffix = (
|
||||||
|
"" if not problems else ";" + ";".join(map(str, problems))
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f"- {item['name_zh']}(ID {item['id']}):"
|
||||||
|
f"{'正常' if valid else '异常'},{metrics}{problem_suffix}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Three-view compatibility policy over generic camera extrinsics."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from .core.geometry.extrinsics import (
|
||||||
|
CameraCalibrationIdentity,
|
||||||
|
CameraExtrinsics,
|
||||||
|
camera_info_fingerprint,
|
||||||
|
load_camera_extrinsics,
|
||||||
|
matrix_payload,
|
||||||
|
transform_matrix,
|
||||||
|
validate_camera_extrinsics_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
VIEWS: tuple[str, ...] = ("front", "side", "top")
|
||||||
|
_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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThreeCameraExtrinsics:
|
||||||
|
"""Transforms points from each camera optical frame into front optical."""
|
||||||
|
|
||||||
|
cameras: Mapping[str, CameraCalibrationIdentity]
|
||||||
|
front_from_view: Mapping[str, np.ndarray]
|
||||||
|
quality: Mapping[str, float]
|
||||||
|
|
||||||
|
def transform(self, view: str) -> np.ndarray:
|
||||||
|
if view not in self.front_from_view:
|
||||||
|
raise KeyError(f"extrinsics do not contain view {view}")
|
||||||
|
return np.asarray(self.front_from_view[view], dtype=float).copy()
|
||||||
|
|
||||||
|
def camera_matches(
|
||||||
|
self,
|
||||||
|
view: str,
|
||||||
|
*,
|
||||||
|
serial_number: str,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
intrinsics_sha256: str,
|
||||||
|
) -> bool:
|
||||||
|
expected = self.cameras.get(view)
|
||||||
|
return bool(
|
||||||
|
expected is not None
|
||||||
|
and expected.serial_number == str(serial_number)
|
||||||
|
and expected.width == int(width)
|
||||||
|
and expected.height == int(height)
|
||||||
|
and expected.intrinsics_sha256 == str(intrinsics_sha256)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_extrinsics_payload(payload: Mapping[str, Any]) -> None:
|
||||||
|
validate_camera_extrinsics_payload(
|
||||||
|
payload,
|
||||||
|
required_views=VIEWS,
|
||||||
|
reference_view="front",
|
||||||
|
quality_limits=_QUALITY_LIMITS,
|
||||||
|
minimum_capture_counts=_MINIMUM_CAPTURE_COUNTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_three_camera_extrinsics(
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
quality_limits: Mapping[str, float] | None = None,
|
||||||
|
minimum_capture_counts: Mapping[str, int] | None = None,
|
||||||
|
) -> ThreeCameraExtrinsics:
|
||||||
|
generic = load_camera_extrinsics(
|
||||||
|
path,
|
||||||
|
required_views=VIEWS,
|
||||||
|
reference_view="front",
|
||||||
|
quality_limits=(
|
||||||
|
_QUALITY_LIMITS if quality_limits is None else quality_limits
|
||||||
|
),
|
||||||
|
minimum_capture_counts=(
|
||||||
|
_MINIMUM_CAPTURE_COUNTS
|
||||||
|
if minimum_capture_counts is None
|
||||||
|
else minimum_capture_counts
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ThreeCameraExtrinsics(
|
||||||
|
cameras=generic.cameras,
|
||||||
|
front_from_view=generic.reference_from_view,
|
||||||
|
quality=generic.quality,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dump_three_camera_extrinsics(
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
cameras: Mapping[str, Mapping[str, Any]],
|
||||||
|
front_from_view: Mapping[str, Sequence[Sequence[float]]],
|
||||||
|
quality: Mapping[str, Any],
|
||||||
|
quality_limits: Mapping[str, float] | None = None,
|
||||||
|
) -> None:
|
||||||
|
payload = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"reference_view": "front",
|
||||||
|
"cameras": {view: dict(cameras[view]) for view in VIEWS},
|
||||||
|
"front_from_view": {
|
||||||
|
view: matrix_payload(front_from_view[view]) for view in VIEWS
|
||||||
|
},
|
||||||
|
"quality": dict(quality),
|
||||||
|
}
|
||||||
|
validate_camera_extrinsics_payload(
|
||||||
|
payload,
|
||||||
|
required_views=VIEWS,
|
||||||
|
reference_view="front",
|
||||||
|
quality_limits=(
|
||||||
|
_QUALITY_LIMITS if quality_limits is None else quality_limits
|
||||||
|
),
|
||||||
|
minimum_capture_counts=_MINIMUM_CAPTURE_COUNTS,
|
||||||
|
)
|
||||||
|
destination = Path(path).expanduser().resolve()
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = destination.with_suffix(destination.suffix + ".tmp")
|
||||||
|
with temporary.open("w", encoding="utf-8") as stream:
|
||||||
|
yaml.safe_dump(payload, stream, allow_unicode=True, sort_keys=False)
|
||||||
|
temporary.replace(destination)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CameraCalibrationIdentity",
|
||||||
|
"CameraExtrinsics",
|
||||||
|
"ThreeCameraExtrinsics",
|
||||||
|
"VIEWS",
|
||||||
|
"camera_info_fingerprint",
|
||||||
|
"dump_three_camera_extrinsics",
|
||||||
|
"load_camera_extrinsics",
|
||||||
|
"load_three_camera_extrinsics",
|
||||||
|
"matrix_payload",
|
||||||
|
"transform_matrix",
|
||||||
|
"validate_camera_extrinsics_payload",
|
||||||
|
"validate_extrinsics_payload",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
"""One-release module alias for the relocated model profile implementation."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .models.g20 import profile as _implementation
|
||||||
|
|
||||||
|
sys.modules[__name__] = _implementation
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
"""ROS 2 image publisher for Hikrobot MVS USB3 Vision cameras."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ctypes import byref, cast, memset, POINTER, sizeof, string_at
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from threading import Event, Lock, Thread
|
||||||
|
from typing import Any, Sequence
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from rclpy.qos import qos_profile_sensor_data
|
||||||
|
from sensor_msgs.msg import CameraInfo, Image
|
||||||
|
from sensor_msgs.srv import SetCameraInfo
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_MVS_PYTHON_PATH = "/opt/MVS/Samples/64/Python/MvImport"
|
||||||
|
DEFAULT_MVS_RUNTIME_PATH = "/opt/MVS/lib"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DeviceDescriptor:
|
||||||
|
"""Stable identity fields for one enumerated USB3 Vision camera."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
model: str
|
||||||
|
serial: str
|
||||||
|
guid: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CameraCalibration:
|
||||||
|
"""Validated subset of a standard ROS camera calibration YAML file."""
|
||||||
|
|
||||||
|
camera_name: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
distortion_model: str
|
||||||
|
d: tuple[float, ...]
|
||||||
|
k: tuple[float, ...]
|
||||||
|
r: tuple[float, ...]
|
||||||
|
p: tuple[float, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def decode_c_string(value: Any) -> str:
|
||||||
|
"""Decode a null-terminated MVS SDK character array."""
|
||||||
|
raw = bytes(value).split(b"\0", 1)[0]
|
||||||
|
for encoding in ("utf-8", "gbk", "latin-1"):
|
||||||
|
try:
|
||||||
|
return raw.decode(encoding)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
return raw.decode("latin-1", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def select_device(
|
||||||
|
devices: Sequence[DeviceDescriptor],
|
||||||
|
selector: str,
|
||||||
|
expected_model: str,
|
||||||
|
) -> DeviceDescriptor:
|
||||||
|
"""Select exactly one camera by serial or GUID and check its model."""
|
||||||
|
if not devices:
|
||||||
|
raise RuntimeError("MVS did not enumerate any USB3 Vision cameras")
|
||||||
|
selector = selector.strip()
|
||||||
|
if selector:
|
||||||
|
matches = [
|
||||||
|
device
|
||||||
|
for device in devices
|
||||||
|
if selector in {device.serial, device.guid}
|
||||||
|
]
|
||||||
|
if not matches:
|
||||||
|
available = ", ".join(
|
||||||
|
f"{device.model}:{device.serial}:{device.guid}"
|
||||||
|
for device in devices
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"camera selector {selector!r} was not found; "
|
||||||
|
f"available cameras: {available}"
|
||||||
|
)
|
||||||
|
elif len(devices) == 1:
|
||||||
|
matches = [devices[0]]
|
||||||
|
else:
|
||||||
|
available = ", ".join(device.serial for device in devices)
|
||||||
|
raise RuntimeError(
|
||||||
|
"camera selector is required when multiple cameras are present; "
|
||||||
|
f"available serials: {available}"
|
||||||
|
)
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise RuntimeError(f"camera selector {selector!r} is not unique")
|
||||||
|
selected = matches[0]
|
||||||
|
if expected_model and expected_model not in selected.model:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"camera {selected.serial} is {selected.model!r}, expected a model "
|
||||||
|
f"containing {expected_model!r}"
|
||||||
|
)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_camera_info_path(url_or_path: str) -> Path | None:
|
||||||
|
"""Resolve a plain path or file:// URL used for CameraInfo persistence."""
|
||||||
|
value = url_or_path.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
parsed = urlparse(value)
|
||||||
|
if parsed.scheme not in {"", "file"}:
|
||||||
|
raise ValueError(
|
||||||
|
"camera_info_url must be a filesystem path or file:// URL"
|
||||||
|
)
|
||||||
|
if parsed.scheme == "file":
|
||||||
|
if parsed.netloc not in {"", "localhost"}:
|
||||||
|
raise ValueError("remote file:// camera_info_url is not supported")
|
||||||
|
value = unquote(parsed.path)
|
||||||
|
return Path(value).expanduser().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _matrix_data(mapping: dict[str, Any], name: str, length: int) -> tuple[float, ...]:
|
||||||
|
section = mapping.get(name)
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
raise ValueError(f"camera calibration is missing {name}")
|
||||||
|
data = section.get("data")
|
||||||
|
if not isinstance(data, list) or len(data) != length:
|
||||||
|
raise ValueError(f"{name}.data must contain exactly {length} values")
|
||||||
|
result = tuple(float(value) for value in data)
|
||||||
|
if not all(value == value and abs(value) != float("inf") for value in result):
|
||||||
|
raise ValueError(f"{name}.data contains a non-finite value")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_camera_calibration(path: Path) -> CameraCalibration:
|
||||||
|
"""Load and validate a standard ROS camera calibration YAML file."""
|
||||||
|
mapping = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(mapping, dict):
|
||||||
|
raise ValueError("camera calibration YAML root must be a mapping")
|
||||||
|
width = int(mapping.get("image_width", 0))
|
||||||
|
height = int(mapping.get("image_height", 0))
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError("camera calibration image dimensions must be positive")
|
||||||
|
distortion = mapping.get("distortion_coefficients")
|
||||||
|
if not isinstance(distortion, dict) or not isinstance(
|
||||||
|
distortion.get("data"), list
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"camera calibration is missing distortion_coefficients.data"
|
||||||
|
)
|
||||||
|
d = tuple(float(value) for value in distortion["data"])
|
||||||
|
calibration = CameraCalibration(
|
||||||
|
camera_name=str(mapping.get("camera_name", "hikrobot_camera")),
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
distortion_model=str(mapping.get("distortion_model", "plumb_bob")),
|
||||||
|
d=d,
|
||||||
|
k=_matrix_data(mapping, "camera_matrix", 9),
|
||||||
|
r=_matrix_data(mapping, "rectification_matrix", 9),
|
||||||
|
p=_matrix_data(mapping, "projection_matrix", 12),
|
||||||
|
)
|
||||||
|
if calibration.k[0] <= 0.0 or calibration.k[4] <= 0.0:
|
||||||
|
raise ValueError("camera calibration focal lengths must be positive")
|
||||||
|
if calibration.p[0] <= 0.0 or calibration.p[5] <= 0.0:
|
||||||
|
raise ValueError("camera calibration projection focal lengths must be positive")
|
||||||
|
return calibration
|
||||||
|
|
||||||
|
|
||||||
|
def calibration_to_mapping(
|
||||||
|
camera_name: str, camera_info: CameraInfo
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Convert CameraInfo into the standard ROS YAML representation."""
|
||||||
|
return {
|
||||||
|
"image_width": int(camera_info.width),
|
||||||
|
"image_height": int(camera_info.height),
|
||||||
|
"camera_name": camera_name,
|
||||||
|
"camera_matrix": {
|
||||||
|
"rows": 3,
|
||||||
|
"cols": 3,
|
||||||
|
"data": [float(value) for value in camera_info.k],
|
||||||
|
},
|
||||||
|
"distortion_model": camera_info.distortion_model,
|
||||||
|
"distortion_coefficients": {
|
||||||
|
"rows": 1,
|
||||||
|
"cols": len(camera_info.d),
|
||||||
|
"data": [float(value) for value in camera_info.d],
|
||||||
|
},
|
||||||
|
"rectification_matrix": {
|
||||||
|
"rows": 3,
|
||||||
|
"cols": 3,
|
||||||
|
"data": [float(value) for value in camera_info.r],
|
||||||
|
},
|
||||||
|
"projection_matrix": {
|
||||||
|
"rows": 3,
|
||||||
|
"cols": 4,
|
||||||
|
"data": [float(value) for value in camera_info.p],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def configure_fastdds_large_image_transport() -> Path:
|
||||||
|
"""Select the package's large-image SHM profile before creating ROS nodes."""
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
|
||||||
|
profile = (
|
||||||
|
Path(get_package_share_directory("linkerhand_calibration"))
|
||||||
|
/ "config"
|
||||||
|
/ "fastdds_large_images.xml"
|
||||||
|
)
|
||||||
|
if not profile.is_file():
|
||||||
|
raise RuntimeError(f"Fast DDS large-image profile not found: {profile}")
|
||||||
|
os.environ.setdefault("FASTRTPS_DEFAULT_PROFILES_FILE", str(profile))
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
class HikrobotCameraNode(Node):
|
||||||
|
"""Publish synchronized Mono8 Image and CameraInfo messages from MVS."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("hikrobot_camera")
|
||||||
|
self._declare_parameters()
|
||||||
|
self._stop_event = Event()
|
||||||
|
self._camera_info_lock = Lock()
|
||||||
|
self._grab_thread: Thread | None = None
|
||||||
|
self._camera: Any | None = None
|
||||||
|
self._mvs: Any | None = None
|
||||||
|
self._sdk_initialized = False
|
||||||
|
self._handle_created = False
|
||||||
|
self._camera_opened = False
|
||||||
|
self._grabbing = False
|
||||||
|
self._closed = False
|
||||||
|
self._frame_count = 0
|
||||||
|
self._timeout_count = 0
|
||||||
|
|
||||||
|
self.selector = str(self.get_parameter("serial_number").value)
|
||||||
|
self.expected_model = str(self.get_parameter("expected_model").value)
|
||||||
|
self.frame_id = str(self.get_parameter("frame_id").value)
|
||||||
|
self.camera_name = str(self.get_parameter("camera_name").value)
|
||||||
|
self.image_width = int(self.get_parameter("image_width").value)
|
||||||
|
self.image_height = int(self.get_parameter("image_height").value)
|
||||||
|
if self.image_width <= 0 or self.image_height <= 0:
|
||||||
|
raise ValueError("image_width and image_height must be positive")
|
||||||
|
if float(self.get_parameter("frame_rate").value) <= 0.0:
|
||||||
|
raise ValueError("frame_rate must be positive")
|
||||||
|
if int(self.get_parameter("grab_timeout_ms").value) <= 0:
|
||||||
|
raise ValueError("grab_timeout_ms must be positive")
|
||||||
|
self.camera_info_path = resolve_camera_info_path(
|
||||||
|
str(self.get_parameter("camera_info_url").value)
|
||||||
|
)
|
||||||
|
self._calibration = self._load_calibration()
|
||||||
|
|
||||||
|
self.image_publisher = self.create_publisher(
|
||||||
|
Image, "image_raw", qos_profile_sensor_data
|
||||||
|
)
|
||||||
|
self.camera_info_publisher = self.create_publisher(
|
||||||
|
CameraInfo, "camera_info", qos_profile_sensor_data
|
||||||
|
)
|
||||||
|
self.create_service(
|
||||||
|
SetCameraInfo, "set_camera_info", self._set_camera_info_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._open_camera()
|
||||||
|
self._grab_thread = Thread(
|
||||||
|
target=self._grab_loop,
|
||||||
|
name="hikrobot_mvs_grab",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._grab_thread.start()
|
||||||
|
except Exception:
|
||||||
|
self.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _declare_parameters(self) -> None:
|
||||||
|
self.declare_parameter("serial_number", "")
|
||||||
|
self.declare_parameter("expected_model", "MV-CS020-10UM")
|
||||||
|
self.declare_parameter("camera_name", "hikrobot_front")
|
||||||
|
self.declare_parameter("frame_id", "camera_color_optical_frame")
|
||||||
|
self.declare_parameter("image_width", 1624)
|
||||||
|
self.declare_parameter("image_height", 1240)
|
||||||
|
self.declare_parameter("frame_rate", 30.0)
|
||||||
|
self.declare_parameter("exposure_time_us", 5000.0)
|
||||||
|
self.declare_parameter("gain_db", 0.0)
|
||||||
|
self.declare_parameter("auto_exposure", False)
|
||||||
|
self.declare_parameter("camera_info_url", "")
|
||||||
|
self.declare_parameter("mvs_python_path", DEFAULT_MVS_PYTHON_PATH)
|
||||||
|
self.declare_parameter("mvs_runtime_path", DEFAULT_MVS_RUNTIME_PATH)
|
||||||
|
self.declare_parameter("grab_timeout_ms", 1000)
|
||||||
|
|
||||||
|
def _load_calibration(self) -> CameraCalibration | None:
|
||||||
|
if self.camera_info_path is None or not self.camera_info_path.exists():
|
||||||
|
target = self.camera_info_path or "an unset camera_info_url"
|
||||||
|
self.get_logger().warning(
|
||||||
|
f"No camera calibration found at {target}; publishing "
|
||||||
|
"uncalibrated CameraInfo. Trajectory calibration will remain "
|
||||||
|
"locked until valid intrinsics are installed."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
calibration = load_camera_calibration(self.camera_info_path)
|
||||||
|
self._validate_calibration_dimensions(calibration)
|
||||||
|
self.get_logger().info(
|
||||||
|
f"Loaded camera intrinsics from {self.camera_info_path}"
|
||||||
|
)
|
||||||
|
return calibration
|
||||||
|
|
||||||
|
def _validate_calibration_dimensions(
|
||||||
|
self, calibration: CameraCalibration
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
calibration.width != self.image_width
|
||||||
|
or calibration.height != self.image_height
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"camera calibration dimensions "
|
||||||
|
f"{calibration.width}x{calibration.height} do not match "
|
||||||
|
f"configured stream {self.image_width}x{self.image_height}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _import_mvs(self) -> Any:
|
||||||
|
python_path = Path(
|
||||||
|
str(self.get_parameter("mvs_python_path").value)
|
||||||
|
).expanduser()
|
||||||
|
runtime_path = Path(
|
||||||
|
str(self.get_parameter("mvs_runtime_path").value)
|
||||||
|
).expanduser()
|
||||||
|
if not python_path.is_dir():
|
||||||
|
raise RuntimeError(f"MVS Python bindings not found: {python_path}")
|
||||||
|
if not runtime_path.is_dir():
|
||||||
|
raise RuntimeError(f"MVS runtime not found: {runtime_path}")
|
||||||
|
os.environ.setdefault("MVCAM_COMMON_RUNENV", str(runtime_path))
|
||||||
|
if str(python_path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(python_path))
|
||||||
|
return importlib.import_module("MvCameraControl_class")
|
||||||
|
|
||||||
|
def _enumerate_usb_cameras(
|
||||||
|
self, mvs: Any
|
||||||
|
) -> tuple[Any, list[DeviceDescriptor]]:
|
||||||
|
device_list = mvs.MV_CC_DEVICE_INFO_LIST()
|
||||||
|
result = mvs.MvCamera.MV_CC_EnumDevices(
|
||||||
|
mvs.MV_USB_DEVICE, device_list
|
||||||
|
)
|
||||||
|
self._require_ok("enumerate USB3 Vision devices", result)
|
||||||
|
descriptors: list[DeviceDescriptor] = []
|
||||||
|
for index in range(device_list.nDeviceNum):
|
||||||
|
device_info = cast(
|
||||||
|
device_list.pDeviceInfo[index], POINTER(mvs.MV_CC_DEVICE_INFO)
|
||||||
|
).contents
|
||||||
|
usb_info = device_info.SpecialInfo.stUsb3VInfo
|
||||||
|
descriptors.append(
|
||||||
|
DeviceDescriptor(
|
||||||
|
index=index,
|
||||||
|
model=decode_c_string(usb_info.chModelName),
|
||||||
|
serial=decode_c_string(usb_info.chSerialNumber),
|
||||||
|
guid=decode_c_string(usb_info.chDeviceGUID),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return device_list, descriptors
|
||||||
|
|
||||||
|
def _open_camera(self) -> None:
|
||||||
|
mvs = self._import_mvs()
|
||||||
|
self._mvs = mvs
|
||||||
|
self._require_ok("initialize MVS SDK", mvs.MvCamera.MV_CC_Initialize())
|
||||||
|
self._sdk_initialized = True
|
||||||
|
device_list, descriptors = self._enumerate_usb_cameras(mvs)
|
||||||
|
selected = select_device(descriptors, self.selector, self.expected_model)
|
||||||
|
device_info = cast(
|
||||||
|
device_list.pDeviceInfo[selected.index],
|
||||||
|
POINTER(mvs.MV_CC_DEVICE_INFO),
|
||||||
|
).contents
|
||||||
|
|
||||||
|
camera = mvs.MvCamera()
|
||||||
|
self._require_ok("create camera handle", camera.MV_CC_CreateHandle(device_info))
|
||||||
|
self._camera = camera
|
||||||
|
self._handle_created = True
|
||||||
|
self._require_ok(
|
||||||
|
"open camera",
|
||||||
|
camera.MV_CC_OpenDevice(mvs.MV_ACCESS_Exclusive, 0),
|
||||||
|
)
|
||||||
|
self._camera_opened = True
|
||||||
|
|
||||||
|
self._set_required_enum("AcquisitionMode", "Continuous")
|
||||||
|
self._set_required_enum("TriggerMode", "Off")
|
||||||
|
self._set_required_enum("PixelFormat", "Mono8")
|
||||||
|
self._set_required_integer("OffsetX", 0)
|
||||||
|
self._set_required_integer("OffsetY", 0)
|
||||||
|
self._set_required_integer("Width", self.image_width)
|
||||||
|
self._set_required_integer("Height", self.image_height)
|
||||||
|
self._set_optional_bool("AcquisitionFrameRateEnable", True)
|
||||||
|
self._set_optional_float(
|
||||||
|
"AcquisitionFrameRate",
|
||||||
|
float(self.get_parameter("frame_rate").value),
|
||||||
|
)
|
||||||
|
if bool(self.get_parameter("auto_exposure").value):
|
||||||
|
self._set_required_enum("ExposureAuto", "Continuous")
|
||||||
|
else:
|
||||||
|
self._set_required_enum("ExposureAuto", "Off")
|
||||||
|
self._set_optional_float(
|
||||||
|
"ExposureTime",
|
||||||
|
float(self.get_parameter("exposure_time_us").value),
|
||||||
|
)
|
||||||
|
self._set_required_enum("GainAuto", "Off")
|
||||||
|
self._set_optional_float(
|
||||||
|
"Gain", float(self.get_parameter("gain_db").value)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._require_ok(
|
||||||
|
"set latest-frame grab strategy",
|
||||||
|
camera.MV_CC_SetGrabStrategy(mvs.MV_GrabStrategy_LatestImagesOnly),
|
||||||
|
)
|
||||||
|
self._require_ok("start image acquisition", camera.MV_CC_StartGrabbing())
|
||||||
|
self._grabbing = True
|
||||||
|
self.get_logger().info(
|
||||||
|
"Opened Hikrobot camera "
|
||||||
|
f"model={selected.model} serial={selected.serial} "
|
||||||
|
f"guid={selected.guid} stream={self.image_width}x{self.image_height} "
|
||||||
|
"encoding=mono8"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set_required_enum(self, name: str, value: str) -> None:
|
||||||
|
assert self._camera is not None
|
||||||
|
self._require_ok(
|
||||||
|
f"set {name}={value}",
|
||||||
|
self._camera.MV_CC_SetEnumValueByString(name, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set_required_integer(self, name: str, value: int) -> None:
|
||||||
|
assert self._camera is not None
|
||||||
|
self._require_ok(
|
||||||
|
f"set {name}={value}",
|
||||||
|
self._camera.MV_CC_SetIntValueEx(name, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set_optional_bool(self, name: str, value: bool) -> None:
|
||||||
|
assert self._camera is not None
|
||||||
|
result = self._camera.MV_CC_SetBoolValue(name, value)
|
||||||
|
if result != 0:
|
||||||
|
self.get_logger().warning(
|
||||||
|
f"MVS could not set {name}={value}: 0x{result:08x}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set_optional_float(self, name: str, value: float) -> None:
|
||||||
|
assert self._camera is not None
|
||||||
|
result = self._camera.MV_CC_SetFloatValue(name, value)
|
||||||
|
if result != 0:
|
||||||
|
self.get_logger().warning(
|
||||||
|
f"MVS could not set {name}={value}: 0x{result:08x}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _require_ok(operation: str, result: int) -> None:
|
||||||
|
if result != 0:
|
||||||
|
hint = ""
|
||||||
|
if result == 0x80000203:
|
||||||
|
hint = (
|
||||||
|
"; access denied: stop acquisition and close the MVS "
|
||||||
|
"viewer or any other process using this camera"
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"MVS failed to {operation}: 0x{result:08x}{hint}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _grab_loop(self) -> None:
|
||||||
|
assert self._camera is not None
|
||||||
|
assert self._mvs is not None
|
||||||
|
timeout_ms = int(self.get_parameter("grab_timeout_ms").value)
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
frame = self._mvs.MV_FRAME_OUT()
|
||||||
|
memset(byref(frame), 0, sizeof(frame))
|
||||||
|
result = self._camera.MV_CC_GetImageBuffer(frame, timeout_ms)
|
||||||
|
if result != 0:
|
||||||
|
self._timeout_count += 1
|
||||||
|
if self._timeout_count == 1 or self._timeout_count % 10 == 0:
|
||||||
|
self.get_logger().warning(
|
||||||
|
"MVS image acquisition timed out or failed: "
|
||||||
|
f"0x{result:08x}; consecutive={self._timeout_count}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
self._timeout_count = 0
|
||||||
|
self._publish_frame(frame)
|
||||||
|
except Exception as error:
|
||||||
|
self.get_logger().error(f"Failed to publish MVS frame: {error}")
|
||||||
|
finally:
|
||||||
|
free_result = self._camera.MV_CC_FreeImageBuffer(frame)
|
||||||
|
if free_result != 0:
|
||||||
|
self.get_logger().error(
|
||||||
|
f"MVS failed to release frame: 0x{free_result:08x}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _publish_frame(self, frame: Any) -> None:
|
||||||
|
assert self._mvs is not None
|
||||||
|
info = frame.stFrameInfo
|
||||||
|
width = int(info.nWidth)
|
||||||
|
height = int(info.nHeight)
|
||||||
|
if width != self.image_width or height != self.image_height:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"received {width}x{height}, expected "
|
||||||
|
f"{self.image_width}x{self.image_height}"
|
||||||
|
)
|
||||||
|
if int(info.enPixelType) != int(self._mvs.PixelType_Gvsp_Mono8):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"received pixel type 0x{int(info.enPixelType):x}, expected Mono8"
|
||||||
|
)
|
||||||
|
expected_size = width * height
|
||||||
|
if int(info.nFrameLen) < expected_size:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"frame contains {info.nFrameLen} bytes, expected {expected_size}"
|
||||||
|
)
|
||||||
|
stamp = self.get_clock().now().to_msg()
|
||||||
|
image = Image()
|
||||||
|
image.header.stamp = stamp
|
||||||
|
image.header.frame_id = self.frame_id
|
||||||
|
image.height = height
|
||||||
|
image.width = width
|
||||||
|
image.encoding = "mono8"
|
||||||
|
image.is_bigendian = 0
|
||||||
|
image.step = width
|
||||||
|
image.data = string_at(frame.pBufAddr, expected_size)
|
||||||
|
|
||||||
|
camera_info = self._make_camera_info(width, height)
|
||||||
|
camera_info.header.stamp = stamp
|
||||||
|
camera_info.header.frame_id = self.frame_id
|
||||||
|
self.image_publisher.publish(image)
|
||||||
|
self.camera_info_publisher.publish(camera_info)
|
||||||
|
self._frame_count += 1
|
||||||
|
|
||||||
|
def _make_camera_info(self, width: int, height: int) -> CameraInfo:
|
||||||
|
message = CameraInfo()
|
||||||
|
message.width = width
|
||||||
|
message.height = height
|
||||||
|
with self._camera_info_lock:
|
||||||
|
calibration = self._calibration
|
||||||
|
if calibration is None:
|
||||||
|
message.distortion_model = "plumb_bob"
|
||||||
|
message.d = []
|
||||||
|
message.k = [0.0] * 9
|
||||||
|
message.r = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
|
||||||
|
message.p = [0.0] * 12
|
||||||
|
return message
|
||||||
|
message.distortion_model = calibration.distortion_model
|
||||||
|
message.d = list(calibration.d)
|
||||||
|
message.k = list(calibration.k)
|
||||||
|
message.r = list(calibration.r)
|
||||||
|
message.p = list(calibration.p)
|
||||||
|
return message
|
||||||
|
|
||||||
|
def _set_camera_info_callback(
|
||||||
|
self,
|
||||||
|
request: SetCameraInfo.Request,
|
||||||
|
response: SetCameraInfo.Response,
|
||||||
|
) -> SetCameraInfo.Response:
|
||||||
|
try:
|
||||||
|
if self.camera_info_path is None:
|
||||||
|
raise ValueError(
|
||||||
|
"camera_info_url is empty; set it before saving calibration"
|
||||||
|
)
|
||||||
|
message = request.camera_info
|
||||||
|
if (
|
||||||
|
int(message.width) != self.image_width
|
||||||
|
or int(message.height) != self.image_height
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"calibration is {message.width}x{message.height}, expected "
|
||||||
|
f"{self.image_width}x{self.image_height}"
|
||||||
|
)
|
||||||
|
mapping = calibration_to_mapping(self.camera_name, message)
|
||||||
|
self.camera_info_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary_path = self.camera_info_path.with_suffix(
|
||||||
|
self.camera_info_path.suffix + ".tmp"
|
||||||
|
)
|
||||||
|
temporary_path.write_text(
|
||||||
|
yaml.safe_dump(mapping, sort_keys=False), encoding="utf-8"
|
||||||
|
)
|
||||||
|
temporary_path.replace(self.camera_info_path)
|
||||||
|
calibration = load_camera_calibration(self.camera_info_path)
|
||||||
|
self._validate_calibration_dimensions(calibration)
|
||||||
|
with self._camera_info_lock:
|
||||||
|
self._calibration = calibration
|
||||||
|
response.success = True
|
||||||
|
response.status_message = (
|
||||||
|
f"saved camera calibration to {self.camera_info_path}"
|
||||||
|
)
|
||||||
|
self.get_logger().info(response.status_message)
|
||||||
|
except Exception as error:
|
||||||
|
response.success = False
|
||||||
|
response.status_message = str(error)
|
||||||
|
self.get_logger().error(
|
||||||
|
f"Rejected camera calibration: {response.status_message}"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._closed = True
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._grab_thread is not None:
|
||||||
|
self._grab_thread.join(timeout=2.0)
|
||||||
|
if self._camera is not None and self._camera_opened:
|
||||||
|
if self._grabbing:
|
||||||
|
result = self._camera.MV_CC_StopGrabbing()
|
||||||
|
if result != 0:
|
||||||
|
self.get_logger().warning(
|
||||||
|
f"MVS failed to stop grabbing: 0x{result:08x}"
|
||||||
|
)
|
||||||
|
self._grabbing = False
|
||||||
|
result = self._camera.MV_CC_CloseDevice()
|
||||||
|
if result != 0:
|
||||||
|
self.get_logger().warning(
|
||||||
|
f"MVS failed to close camera: 0x{result:08x}"
|
||||||
|
)
|
||||||
|
self._camera.MV_CC_DestroyHandle()
|
||||||
|
elif self._camera is not None and self._handle_created:
|
||||||
|
self._camera.MV_CC_DestroyHandle()
|
||||||
|
self._camera = None
|
||||||
|
self._handle_created = False
|
||||||
|
self._camera_opened = False
|
||||||
|
if self._sdk_initialized and self._mvs is not None:
|
||||||
|
self._mvs.MvCamera.MV_CC_Finalize()
|
||||||
|
self._sdk_initialized = False
|
||||||
|
|
||||||
|
def destroy_node(self) -> None:
|
||||||
|
self.close()
|
||||||
|
super().destroy_node()
|
||||||
|
|
||||||
|
|
||||||
|
def main(args: list[str] | None = None) -> None:
|
||||||
|
configure_fastdds_large_image_transport()
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node: HikrobotCameraNode | None = None
|
||||||
|
try:
|
||||||
|
node = HikrobotCameraNode()
|
||||||
|
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,17 @@
|
|||||||
|
"""Model- and side-specific calibration policies."""
|
||||||
|
|
||||||
|
from .registry import (
|
||||||
|
EngineBindings,
|
||||||
|
ProfileRegistry,
|
||||||
|
RegisteredProfile,
|
||||||
|
get_default_registry,
|
||||||
|
)
|
||||||
|
from .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 ..registry import ProfileRegistry
|
||||||
|
|
||||||
|
|
||||||
|
def register_profiles(registry: ProfileRegistry) -> None:
|
||||||
|
from .legacy_11 import build_left_profile, build_right_profile
|
||||||
|
from .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,219 @@
|
|||||||
|
"""Adapt reviewed family profiles to the shared typed contract."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...core import (
|
||||||
|
CalibrationProfile,
|
||||||
|
CommandLayout,
|
||||||
|
MeasurementPolicy,
|
||||||
|
MeasurementSpec,
|
||||||
|
MotionPolicy,
|
||||||
|
ProfileKey,
|
||||||
|
QualityPolicy,
|
||||||
|
ScopePolicy,
|
||||||
|
TagSpec,
|
||||||
|
TaskSpec,
|
||||||
|
ViewSpec,
|
||||||
|
VisionRigSpec,
|
||||||
|
ZeroSolvePolicy,
|
||||||
|
)
|
||||||
|
from .profile import MIMIC_DERIVED_FINGER_DIPS
|
||||||
|
from ..registry import EngineBindings, RegisteredProfile
|
||||||
|
from .artifacts import build_artifact_policy
|
||||||
|
from .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_hysteresis_rad",
|
||||||
|
"maximum_validation_error_rad",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cli(args: list[str] | None = None) -> None:
|
||||||
|
from .runner import main
|
||||||
|
|
||||||
|
main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_node(args: list[str] | None = None) -> None:
|
||||||
|
from .node import main
|
||||||
|
|
||||||
|
main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_profile(
|
||||||
|
*,
|
||||||
|
key: ProfileKey,
|
||||||
|
namespace: str,
|
||||||
|
hand_profile,
|
||||||
|
zero_profile,
|
||||||
|
mechanical_endpoint_joints: frozenset[str] = frozenset(),
|
||||||
|
post_solve_endpoint_joints: frozenset[str] = frozenset(),
|
||||||
|
) -> RegisteredProfile:
|
||||||
|
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)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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,30 @@
|
|||||||
|
"""Runtime JSON and corrected-URDF naming policy."""
|
||||||
|
|
||||||
|
from ...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",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
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 .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 ...core import ProfileKey
|
||||||
|
from .profile import get_hand_calibration_profile
|
||||||
|
from ...urdf_zero import get_zero_calibration_profile
|
||||||
|
from ..registry import RegisteredProfile
|
||||||
|
from ._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 .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",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user