Add G20 left-hand MuJoCo PLAN bridge, assets, and docs.

Bring in kinematic hard-write control with mesh-convex collision projection, SDK mapping aligned to Isaac, launch/run scripts, and implementation/tech writeups.
This commit is contained in:
2026-07-27 13:45:33 +08:00
parent 818ba00524
commit 246b48b1fb
33 changed files with 4030 additions and 0 deletions
+328
View File
@@ -0,0 +1,328 @@
# G20 左手 · Isaac Sim 仿真实现说明
本文记录 **Isaac G20 PLAN 名义弧度桥** 从 URDF 到 USD、再到可跑仿真的完整实现过程,以及相对原始 URDF / 自动转换结果的全部具体改动。
- **函数调用与逐步逻辑**:见 [`G20_Isaac_技术详解.md`](./G20_Isaac_技术详解.md)(推荐先读此文理解代码)
- **MuJoCo 对照**[`G20_MuJoCo_实现说明.md`](./G20_MuJoCo_实现说明.md) / [`G20_MuJoCo_技术详解.md`](./G20_MuJoCo_技术详解.md)
---
## 1. 目标与数据流
### 1.1 目标
- 在 Isaac Sim / PhysX 中仿真 LinkerHand G20 **左手**
- 默认兼容真机 SDK 话题:`/g20/cb_left_hand_control_cmd`0~255)。
- 可选 PLAN 名义弧度话题(按关节名)。
- 关节名、映射、mimic、拇指上限 **1.47**、对比视角与 MuJoCo 桥对齐。
- 默认 **位置硬写**`set_dof_positions`),可选 `--pd-drive`
### 1.2 端到端数据流
```text
/g20/cb_left_hand_control_cmd (默认,0~255)
或 /retarget/g20/left/joint_target_nominal (--plan-nominal)
或 /cb_left_hand_control_cmd (--legacy-sdk,旧无前缀)
joint_mapping.py
· range_to_arc / sdk_range_to_*hand="G20", side="left"
· SDK15thumb_mcp = arc/1.02
· SDK16..19*_pip = arc/0.89
· complete_g20_mimic_positions
isaac_g20_plan_bridge.py
· Articulation 硬写 set_dof_positions(默认)
或 set_dof_position_targets--pd-drive
· 每 render 帧内 phys_per_render 次物理步进(默认 16
/sim/isaac/g20/left/joint_state
+ Omniverse 透视相机(COMPARE_*
```
---
## 2. 关键文件路径
| 角色 | 绝对路径 |
|------|----------|
| 启动脚本 | `/home/lxqs/isaacsim/linkerhand/run_g20_plan.sh` |
| 桥主程序 | `/home/lxqs/isaacsim/linkerhand/isaac_g20_plan_bridge.py` |
| 关节映射 | `/home/lxqs/isaacsim/linkerhand/joint_mapping.py` |
| 源 URDF | `/home/lxqs/isaacsim/linkerhand/urdf/g20/left/linkerhand_g20_left.urdf` |
| 网格 | `/home/lxqs/isaacsim/linkerhand/urdf/g20/left/meshes/*.STL` |
| 默认 USD 根 | `/home/lxqs/isaacsim/linkerhand/urdf/g20/left/linkerhand_g20_left/linkerhand_g20_left.usda` |
| Isaac Python | `$ISAAC_SIM_DIR/python.sh`(默认 `/home/lxqs/isaacsim/_build/linux-x86_64/release/python.sh` |
**旁路(非 PLAN 桥,勿混淆):**
- `linker_hand_dual_sim.py` + `run_sim.sh`:另一套双端/稳定性仿真,默认 PD 参数不同。
---
## 3. 从 URDF 到 USD
### 3.1 URDF 来源
- SolidWorks → URDF Exporter 导出的 G20 左手。
- 与 MuJoCo 仓内 URDF/网格同源;网格与 MuJoCo 侧 **MD5 一致**
- 当前仅有 **左手** USD;无 G20 右手资产。
### 3.2 URDF 上我们改过什么
| 改动项 | 具体内容 |
|--------|----------|
| `thumb_cmc_roll` 上限 | `upper="1.47"`rad);注释/映射写明相对原 **1.4** 试调 |
| mimicURDF 内保留) | `thumb_ip``thumb_mcp` × **1.02**;四指 `*_dip``*_pip` × **0.89** |
| 其余限位 | 与 MuJoCo 侧同一套(见 MuJoCo 文档关节表) |
Isaac 侧 Physics USD 中对应角度上限:
- `physics:upperLimit = 84.22480`(度)= **1.47 rad**`payloads/Physics/physics.usda``thumb_cmc_roll`)。
### 3.3 转换方式(重要)
**运行时不导入 URDF。**
桥内 `_load_g20()` 使用:
```text
stage_utils.add_reference_to_stage(usd_path=默认 .usda)
```
挂的是 **预烘焙 USD**。根层标注:
```text
creator = "URDF USD Converter v0.1.3"
```
即离线用 Isaac / URDF→USD 转换器生成,再经手改 Physics / 碰撞实例后定稿。
### 3.4 USD 资源树(转换产物结构)
```text
linkerhand_g20_left.usda # 根;默认 Physics variant = "physx"
└── payloads/
base.usda
robot.usda
geometries.usd / materials.usda
instances.usda # 碰撞实例(convexHull + 选择性开关)
Physics/
physics.usda # 关节限位等
physx.usda # PhysX articulation / 自碰等
mujoco.usda # 转换器附带的 Mjc 层(桥走 physx)
hand_body_limits.usda # 刚体线/角速度与阻尼限制
```
桥启动时显式:`set_prim_variants(..., [("Physics", "physx")])`
### 3.5 相对「纯 URDF→USD」我们改过的 Physics / 碰撞
#### A. 自碰与求解器(`physx.usda`
| 项 | 值 |
|----|-----|
| `physxArticulation:enabledSelfCollisions` | **1**(开) |
| solver 位置/速度迭代 | **32 / 4** |
| `sleepThreshold` | **0.005** |
| 关节 `maxJointVelocity` | **171.887** |
| 关节 `armature` | **0.05** |
#### B. 刚体限速(`hand_body_limits.usda`
各 link 大致:
- `maxLinearVelocity = 3.0`
- `maxAngularVelocity = 360.0`
- linear/angular damping **6.0**
目的:降低 PD 握拳等场景下爆炸/乱飞。
#### C. 选择性碰撞(`instances.usda`)——核心手改
碰撞近似:`physics:approximation = "convexHull"``purpose = "guide"`
注释写明策略:**掌心 + 指尖开碰撞;近端/中节多数关掉**,避免张开姿态假嵌入、以及 PD 握拳末端爆炸。
| 碰撞 | 典型 link`physics:collisionEnabled` |
|------|------------------------------------------|
| **ON (=1)** | `hand_base_link`(掌心)、`thumb_distal``index_distal``middle_distal``pinky_distal``ring_distal` 复用 index 远端实例,同样 ON) |
| **OFF (=0)** | CMC/掌骨近端、四指 `proximal`/`middle`、拇指 shaft 等 |
接触偏移示例:`contactOffset = 0.001``restOffset = -0.0005`
说明:这与 MuJoCo 的「胶囊 + exclude + 投影」是 **不同手段**Isaac 默认硬写时 PhysX 接触 **不能** 像 MuJoCo `collision_project` 那样在写位置前把姿态投影回去,硬写仍可能视觉穿模。
#### D. 运行时补丁(桥代码,非 USD 文件)
| 函数/常量 | 具体行为 |
|-----------|----------|
| `_patch_joint_max_forces` | RevoluteJoint angular drive `maxForce`**< 8.0** 则抬到 **8.0** |
| `_fix_base_to_world` | 钉住 `hand_base_link`(改 `root_joint` 或建 `WorldFixedJoint` |
| 安装位姿 | `/World/G20Hand` 位置 **`[0.0, 0.45, 0.70]`**`HAND_MOUNT_Y/Z` |
| 自碰 API | 默认跟随 USD 开自碰;`--no-self-collision``set_enabled_self_collisions(False)` |
---
## 4. 关节映射 `joint_mapping.py`
与 MuJoCo `g20_joints.py` 的 G20 左手数值 **一致**(同一套 SDK 表 / MIN/MAX/DIR / OPEN / mimic / SDK1519 规则)。
### 4.1 SDK → 关节
| SDK | 关节 |
|-----|------|
| 0 | `thumb_cmc_pitch` |
| 14 | `index/middle/ring/pinky_mcp_pitch` |
| 5 | `thumb_cmc_roll` |
| 69 | `index/middle/ring/pinky_mcp_roll` |
| 10 | `thumb_cmc_yaw` |
| 1114 | 预留 |
| 15 | `thumb_mcp` ← 写入 `arc/1.02` |
| 1619 | `*_pip` ← 写入 `arc/0.89` |
### 4.2 我们写入/试调过的表项
```text
# 通道5 注释:试调 1.47(原 1.4)
G20_L_MAX[5] = 1.47
# 左手侧摆 6..9:与 L20_L_DIR 不同,强制为 +1
G20_L_DIR = [-1×6, 1,1,1,1, -1, 0×4, -1×5]
DIP_MIMIC_MULTIPLIER = 0.89
THUMB_IP_MIMIC_MULTIPLIER = 1.02
```
张开预设 `G20_OPEN_CMD` 与 MuJoCo 相同:
```text
[255×6, 193, 148, 105, 42, 245, 255×4, 255×5]
```
主动 16 关节 / 从动 5 关节(mimic)划分与 MuJoCo 一致。
---
## 5. 桥节点 `isaac_g20_plan_bridge.py` 做了哪些事
### 5.1 话题
| 模式 | 订阅 | 发布 |
|------|------|------|
| 默认 | `/g20/cb_left_hand_control_cmd` | `/sim/isaac/g20/left/joint_state` |
| `--plan-nominal` | `/retarget/g20/left/joint_target_nominal` | 同上 |
| `--legacy-sdk` | `/cb_left_hand_control_cmd` | 同上 |
- 消息类型:`sensor_msgs/JointState`
- cmd:优先按 `name` 映射到 SDK 索引;否则按下标。
- 发布:仿真 DOF 名 + 弧度(及 velocity)。
- `TOPIC_LEGACY_STATE = /cb_left_hand_state` 已定义,但当前 **未实际发布**`legacy_pub` 恒为 `None`)。
ROS2 节点名:`isaac_g20_plan_bridge`;启用扩展:`isaacsim.ros2.bridge`
### 5.2 写关节方式
| 模式 | API | 细节 |
|------|-----|------|
| **默认硬写** | `set_dof_positions` + `set_dof_velocities(0)` | 写 **全关节含 mimic**;gains 全 0,避免与硬写冲突;物理步进后常再钉一次 |
| **`--pd-drive`** | `set_dof_position_targets` | **仅主动关节**mimic gains=0;拇指四关节 stiff/damp/effort ≈ **10/3/6**,其余 ≈ **15/2.5/8** |
其它:
- 物理 `dt = 1/120`;默认 `phys_per_render = 16`
- slew`--slew-rad`,单位是 **rad / 物理步**,默认 **`0.0`(瞬达)**(与 MuJoCo 的 rad/s 不同)。
- 限位边距:`LIMIT_MARGIN_RAD = 0.05`
- `--hand-gravity`:仅 PD 模式下有意义。
- 检测到 NaN/Inf → 复位到张开位姿。
### 5.3 对比视角(与 MuJoCo 对齐)
| 常量 | 值 |
|------|-----|
| `COMPARE_LOOKAT_LOCAL` | `(0.04, -0.04, 0.12)` |
| `COMPARE_DISTANCE` | `0.50` |
| `COMPARE_ELEVATION_DEG` | `-12.0` |
| 方位 | 相机从 **+X** 看向 lookat(等价 MuJoCo `azimuth=180` |
| 手世界原点 | `(0, HAND_MOUNT_Y, HAND_MOUNT_Z) = (0, 0.45, 0.70)` |
| 相机 prim | `/OmniverseKit_Persp` |
### 5.4 CLI 开关一览
`--headless``--g20-usd``--plan-nominal``--legacy-sdk``--pd-drive``--hand-gravity``--no-self-collision``--slew-rad``--phys-per-render``--track-test``--cmd-topic``--nominal-topic``--sim-state-topic``--max-steps`
**没有** MuJoCo 那套 `--no-collision-project`Isaac 侧无同款投影算法)。
### 5.5 相对「只加载 URDF 默认导入」桥层额外保证
1. 固定使用预烘焙 USD + PhysX variant。
2. 基座钉世界,手抬离地面安装。
3. 驱动 maxForce 下限补丁。
4. 默认硬写零增益;PD 模式单独设拇指/四指增益。
5. 自碰默认 ON,可用 CLI 关。
6. ROS2 话题与 MuJoCo/真机协议对齐(状态话题前缀不同:`/sim/isaac/...`)。
---
## 6. 如何启动
```bash
/home/lxqs/isaacsim/linkerhand/run_g20_plan.sh
```
脚本等价于:
```bash
"$ISAAC_SIM_DIR/python.sh" /home/lxqs/isaacsim/linkerhand/isaac_g20_plan_bridge.py "$@"
```
常用:
```bash
./run_g20_plan.sh --plan-nominal
./run_g20_plan.sh --pd-drive
./run_g20_plan.sh --no-self-collision
./run_g20_plan.sh --headless --track-test
./run_g20_plan.sh --slew-rad 0.02 --phys-per-render 8
```
---
## 7. 与 MuJoCo 实现的差异(必须分清)
| 项 | Isaac(本文) | MuJoCo |
|----|---------------|--------|
| 模型 | 预烘焙 **USD** | **MJCF** |
| 运行时是否读 URDF | **否** | 否(读 MJCFMJCF 由 URDF 转出) |
| 状态话题 | `/sim/isaac/g20/left/joint_state` | `/sim/mujoco/g20/left/joint_state` |
| 默认驱动 | `set_dof_positions` 硬写 | `qpos` 硬写 + `mj_forward` |
| 可选 PD | PhysX angular drive | MJCF `<position>` + `mj_step` |
| **collision project** | **无** | **有**(默认开) |
| 防穿模资产手段 | `instances.usda` 选择性 collider + 自碰 | visual/capsule 分层 + 28 条 exclude |
| slew | rad/**物理步**,默认 0 | rad/**秒**,默认 5.0;另有墙钟 `speed=1.35` |
| 手位姿 | 世界 `(0, 0.45, 0.70)` | 世界原点 |
| 映射 | `joint_mapping.py` | `g20_joints.py`G20 left 同数值) |
---
## 8. 改动 chronologically(便于对照)
1. URDF + meshes 入库;离线 **URDF USD Converter v0.1.3**`linkerhand_g20_left.usda`
2. 手改 Physics:自碰、求解器、armature、body limits。
3. 手改 `instances.usda`:只开掌心 + 指尖等 collider。
4. `thumb_cmc_roll` 上限改为 **1.47**URDF + Physics 度制上限 + `G20_L_MAX`)。
5. 实现 `isaac_g20_plan_bridge.py`:硬写默认、可选 PD、钉基座、maxForce 补丁、安装偏置。
6. 话题对齐 `/g20/cb_left_hand_control_cmd`;状态发 `/sim/isaac/...`
7. 视角 `COMPARE_*` 与 MuJoCo 对齐(+X 看掌心)。
8. SDK1519 除 mimic 与 MuJoCo/真机语义对齐(写进 `joint_mapping.py`)。
---
## 9. 已知限制
1. 默认硬写下,PhysX 接触 **推不开** 被写死的 DOF;穿模缓解主要靠选择性碰撞几何,**没有** MuJoCo 式投影。
2. 近端/中节碰撞多为 OFF:邻指中段穿模可能检不出。
3. 凸包近似与视觉 mesh 非凸外形不一致。
4. 仅左手 G20 USD。
5. `dual_sim` 与 PLAN 桥参数不同,联调时不要混用入口脚本。
+499
View File
@@ -0,0 +1,499 @@
# G20 左手 · Isaac Sim 技术详解
资产路径、URDF→USD 改动清单见 [`G20_Isaac_实现说明.md`](./G20_Isaac_实现说明.md)。
MuJoCo 对照:[`G20_MuJoCo_技术详解.md`](./G20_MuJoCo_技术详解.md)。
主文件:
| 角色 | 路径 |
|------|------|
| 桥主程序 | `/home/lxqs/isaacsim/linkerhand/isaac_g20_plan_bridge.py` |
| 关节映射 | `/home/lxqs/isaacsim/linkerhand/joint_mapping.py` |
| 启动脚本 | `/home/lxqs/isaacsim/linkerhand/run_g20_plan.sh` |
| 默认 USD | `.../urdf/g20/left/linkerhand_g20_left/linkerhand_g20_left.usda` |
---
## 1. 总体结构:单线程循环(与 MuJoCo 不同)
Isaac 桥**没有**单独的 ROS daemon 线程,也**没有** `threading.Lock`
主循环里每个物理步先 `rclpy.spin_once`,再写关节、再 `SimulationManager.step()`
```text
┌─────────────────────────────────────────────────────────────┐
│ 模块加载期(import 顶层) │
│ parse_args() │
│ SimulationApp(...) # 必须最早启动 Isaac │
│ enable_extension(ros2.bridge) │
│ import joint_mapping / rclpy │
├─────────────────────────────────────────────────────────────┤
│ main() 初始化 │
│ create_new_stage / GroundPlane / DistantLight │
│ _load_g20(usd) → Articulation │
│ _fix_base_to_world / set_dof_gains / 自碰 │
│ G20PlanBridge(...) │
│ apply_targets() 初始 snap 到 open │
├─────────────────────────────────────────────────────────────┤
│ 主循环(每 render 帧内跑 phys_per_render 次物理步) │
│ for _ in range(phys_per_render): │
│ rclpy.spin_once(node) # 处理回调 → 改 goal │
│ bridge.apply_targets() # slew → 写 Articulation │
│ SimulationManager.step() # PhysX 一步 │
│ recover_if_invalid() │
│ bridge.publish_state() │
│ if 位置硬写: apply_targets() 再钉一次 │
│ RenderingManager.render() │
│ simulation_app.update() │
└─────────────────────────────────────────────────────────────┘
```
**核心状态变量(`G20PlanBridge`):**
| 变量 | 类型 | 含义 |
|------|------|------|
| `goal` | `dict[str, float]` | 最新目标关节角(弧度,含 mimic) |
| `applied` | `dict[str, float]` | 本步实际写入的角(slew 后) |
| `targets` | 同 `applied` | 别名,历史兼容 |
| `track_nominal` | `dict[str, float] \| None` | 主动关节目标,供 `tracking_error()` |
| `dof_index` | `dict[str, int]` | 关节名 → Articulation DOF 下标 |
| `use_physics_drive` | `bool` | True=`--pd-drive`False=位置硬写 |
**与 MuJoCo 的关键差异:**
| 项 | Isaac | MuJoCo |
|----|-------|--------|
| 线程模型 | 单线程 `spin_once` + 物理循环 | ROS daemon + 主仿真线程 + Lock |
| 防穿模 | USD 选择性 collider + 自碰 | `collision_project` + `mj_forward` |
| slew 单位 | **rad / 物理步**,默认 **0**(瞬达) | **rad / 秒**,默认 **5.0** |
| 写关节 API | `set_dof_positions` / `set_dof_position_targets` | `data.qpos` + `mj_forward` |
| 物理步进 | 每 render 默认 16 次 `SimulationManager.step` | 硬写模式不 `mj_step` |
---
## 2. 程序入口与加载顺序(很重要)
入口:`./run_g20_plan.sh``$ISAAC_SIM_DIR/python.sh isaac_g20_plan_bridge.py`
### 2.1 为何 `parse_args` + `SimulationApp` 在顶层?
```text
# 文件顶层执行顺序(main 之前)
parse_args() # ① 先解析,因为 SimulationApp 要 headless
SimulationApp({"headless": ...}) # ② 启动 Omniverse / PhysX 运行时
... import isaacsim.* ...
from joint_mapping import ...
enable_extension("isaacsim.ros2.bridge")
simulation_app.update()
import rclpy ...
```
Isaac 要求:**先有 `SimulationApp`,才能 import 多数 isaacsim 模块**。
因此参数解析不能放在 `main()` 里才做;顶层 `args = parse_args()` 是刻意的。
`_normalize_argv`:把 `-pd-drive` 这类单横杠改成 `--pd-drive`,避免被 argparse 拆成 `-p -d ...`
### 2.2 `main()` 调用链
```text
main()
├── stage_utils.create_new_stage()
├── GroundPlane / DistantLight
├── _compare_camera_eye_target() → set_camera_view(...)
├── hand_path = _load_g20(args.g20_usd)
│ ├── define_prim("/World/G20Hand")
│ ├── XformPrim(..., positions=[[0, 0.45, 0.70]])
│ ├── add_reference_to_stage(usd → .../hand)
│ ├── set_prim_variants(Physics=physx)
│ └── _patch_joint_max_forces(hand_path)
├── SimulationManager.set_physics_dt(1/120)
├── play → update → _fix_base_to_world → stop → play
├── art = Articulation(hand_path)
├── art.set_enabled_self_collisions(...)
├── art.set_link_enabled_gravities(...)
├── set_dof_gains / set_dof_max_efforts # 见 §5
├── rclpy.init(); Node("isaac_g20_plan_bridge")
├── G20PlanBridge(...)
├── slew=0; apply_targets(); 恢复 slew # 初始 snap open
└── while simulation_app.is_running():
for _ in range(phys_per_render): # 默认 16
spin_once → apply_targets → step → publish → [再钉]
render + update
```
---
## 3. 场景加载相关函数
### 3.1 `_load_g20(usd_path) → hand_path`
```python
mount = "/World/G20Hand"
stage_utils.define_prim(mount, "Xform")
XformPrim(mount, positions=[[0.0, HAND_MOUNT_Y, HAND_MOUNT_Z]], ...)
hand_path = f"{mount}/hand"
stage_utils.add_reference_to_stage(usd_path=..., path=hand_path)
prim_utils.set_prim_variants(hand_path, variants=[("Physics", "physx")])
_patch_joint_max_forces(hand_path)
return hand_path
```
要点:
1. **运行时不读 URDF**,只挂预烘焙 USD。
2. 手装在世界坐标 `(0, 0.45, 0.70)`,离地,避免和地面穿插。
3. Physics variant 强制 `physx`USD 里还有 mujoco/newton 等层)。
### 3.2 `_patch_joint_max_forces(hand_path, max_force=8.0)`
遍历手下面所有 `UsdPhysics.RevoluteJoint`,取 angular DriveAPI
```python
if maxForce < 8.0:
attr.Set(8.0)
```
防止某些关节 drive 力矩上限太小,PD 跟不上。
### 3.3 `_fix_base_to_world(hand_path)`
钉住掌心,手不会掉下去:
1. 在手 prim 树里找 `hand_base_link`(或 `base_link` / `palm`RigidBody。
2. 若已有合法 `root_joint`:改 `LocalPos0/Rot0` 对齐当前世界位姿,Body1=baseBody0=世界。
3. 否则新建 `{hand_path}/WorldFixedJoint``UsdPhysics.FixedJoint`)。
调用时机:`play → update → fix → stop → play`,让 PhysX 在重建 articulation 前把固定关节写进 stage。
### 3.4 `_compare_camera_eye_target()`
与 MuJoCo `COMPARE_*` 对齐:
```python
target = hand_origin + COMPARE_LOOKAT_LOCAL # (0.04,-0.04,0.12) 相对手
eye.x = target.x + distance * cos(elev) # 从 +X 看
eye.z = target.z - distance * sin(elev) # elev=-12° 略俯视
```
`set_camera_view(eye, target, "/OmniverseKit_Persp")`;play/stop 后会再设一次,防止 viewport 被重置。
---
## 4. 映射层:`joint_mapping.py` 函数说明
与 MuJoCo `g20_joints.py` **数值一致**,但 API 多了 `hand`/`side` 参数(同一文件还服务 O6/L20)。
### 4.1 Legacy 0~255 调用链
```text
_on_cmd_u8(msg)
vals = [255]*20
优先按 msg.name → L20_SDK_TO_URDF 反查 SDK 索引填 vals
否则按下标填 vals[:20]
sdk_range_to_full_urdf_positions(vals, "G20", "left")
├── sdk_range_to_urdf_positions(...)
│ ├── range_to_arc(vals, "G20", "left")
│ └── SDK15: thumb_mcp = arc/1.02
│ SDK16..19: *_pip = arc/0.89
└── 补全 dip/ip(函数内部直接写,不等价于再调 complete_*,结果相同)
set_nominal_targets(full)
└── goal = complete_g20_mimic_positions(positions)
```
### 4.2 `range_to_arc(values, hand, side)`
```python
j_min, j_max, j_dir = _tables("G20", "left") # → G20_L_MIN/MAX/DIR
for i in 0..19:
if 11 <= i <= 14: continue
if j_dir[i] == -1:
arcs[i] = lerp(val, 0, 255, j_max[i], j_min[i])
else:
arcs[i] = lerp(val, 0, 255, j_min[i], j_max[i]) # 侧摆 6..9
```
### 4.3 `sdk_range_to_urdf_positions` vs `sdk_range_to_full_urdf_positions`
| 函数 | 输出 |
|------|------|
| `sdk_range_to_urdf_positions` | 仅主动 16 关节(已做 SDK15/16~19 除 mimic |
| `sdk_range_to_full_urdf_positions` | 主动 + 软件解析的 `thumb_ip` / `*_dip` |
Isaac **硬写模式**写全关节(含 mimic);**PD 模式**只写主动关节,mimic 交给 PhysX USD mimic。
### 4.4 `complete_g20_mimic_positions`
与 MuJoCo 相同:`thumb_ip = thumb_mcp * 1.02``*_dip = *_pip * 0.89`;已有 mimic 值不覆盖。
### 4.5 `open_hand_command("G20")`
返回 `G20_OPEN_CMD`float 列表),与 MuJoCo `G20_OPEN_CMD` 同值。
### 4.6 PLAN 名义路径
```text
_on_nominal(msg)
incoming = {name: position}
缺主动关节 → 与旧 goal 合并
goal = complete_g20_mimic_positions(merged)
track_nominal = {主动关节: goal[...]}
```
注意:Isaac 回调里**直接改 `self.goal`**,没有 MuJoCo 的 `_set_goal_only` + Lock;因为主循环同线程 `spin_once`,不存在并发写 Articulation。
---
## 5. 驱动模式:`set_dof_gains` 与 `apply_targets`
### 5.1 初始化时增益怎么设(`main` 里)
**默认位置硬写:**
```python
art.set_dof_gains(stiffnesses=zeros(n), dampings=zeros(n))
```
零增益,避免 PD 与 `set_dof_positions` 抢控制。
**`--pd-drive`**
```python
# 主动关节
拇指四关节: stiff/damp/effort = 10 / 3 / 6
其余主动: stiff/damp/effort = 15 / 2.5 / 8
art.set_dof_gains(...); art.set_dof_max_efforts(...)
# mimic 关节强制 0 增益 —— 否则与 PhysX mimic 冲突 → Invalid PhysX transform
art.set_dof_gains(0, 0, dof_indices=mimic_idx)
```
`--hand-gravity` 仅在 PD 下生效:`set_link_enabled_gravities(True)`
### 5.2 `apply_targets()` — 每物理步的核心
```python
def apply_targets(self):
self._slew_applied_toward_goal() # goal → applied
if use_physics_drive:
name_iter = (n for n in applied if n in actuated) # 只主动
else:
name_iter = applied.keys() # 含 mimic
positions = [...]
if use_physics_drive:
art.set_dof_position_targets(positions, dof_indices=indices)
else:
art.set_dof_positions(positions, dof_indices=indices)
art.set_dof_velocities(zeros(...), dof_indices=indices)
```
| 模式 | API | 写哪些关节 |
|------|-----|------------|
| 位置硬写(默认) | `set_dof_positions` + `set_dof_velocities(0)` | 全部(含软件算好的 mimic) |
| PD | `set_dof_position_targets` | 仅主动 16 个 |
### 5.3 `_slew_applied_toward_goal()`
```python
max_step = self.slew_rad # 默认 0 → 每步直达 goal
margin = LIMIT_MARGIN_RAD # 0.05,离限位留边
for name, g in goal.items():
g = clip(g, lo+margin, hi-margin)
if max_step <= 0:
applied[name] = g
else:
applied[name] = a ± min(|g-a|, max_step)
applied = complete_g20_mimic_positions(applied)
```
单位是 **每物理步弧度**,不是每秒。
物理 dt=1/120,默认每 render 16 步 → 墙钟约 16/120 ≈ 0.133 s 一帧渲染。
### 5.4 为何硬写后还要「再钉一次」?
主循环:
```python
bridge.apply_targets()
SimulationManager.step() # PhysX 可能轻微挪动 DOF
bridge.publish_state()
if not use_pd:
bridge.apply_targets() # 再钉回 applied
```
接触/积分会让硬写姿态漂移;步进后再写一遍,保证跟指令贴。
---
## 6. 异常恢复与状态发布
### 6.1 `recover_if_invalid()`
```python
pos = art.get_dof_positions()
if not all finite:
goal = applied = open_full
set_dof_positions(open)
return True
```
PD 爆炸或数值坏掉时复位张开姿态。
### 6.2 `publish_state()`
每物理步调用(不是独立 50Hz 定时器):
```python
pos = art.get_dof_positions()
vel = art.get_dof_velocities() # 失败则填 0
pub /sim/isaac/g20/left/joint_state
```
`legacy_pub` 恒为 `None`,不会发 `/cb_left_hand_state`
`urdf_positions_to_sdk_range` 已实现,预留反算 0~255。
### 6.3 `tracking_error()`
`track_nominal` 里每个主动关节:`|q_sim - q_nominal|`,返回 `(max, mean)`
---
## 7. 自碰与防穿模(Isaac 怎么做)
**没有** MuJoCo 的 `collision_project`。手段是:
1. **USD `instances.usda`**:选择性开碰撞(掌心 + 指尖 ON,近端/中节多数 OFF)。
2. **`physx.usda`**`enabledSelfCollisions = 1`
3. **运行时**`art.set_enabled_self_collisions([True])``--no-self-collision` 关)。
因此:
- 硬写时 PhysX **推不开** 被写死的 DOF → 仍可能视觉穿模。
- PD 模式下接触力可挡一部分,但跟手变差。
- 这与 MuJoCo「硬写前投影」是不同哲学。
---
## 8. 完整时序例子:收到半握命令后的一物理步
假设默认硬写、`slew_rad=0`
```text
1. rclpy.spin_once
→ _on_cmd_u8 → sdk_range_to_full_urdf_positions
→ set_nominal_targets → goal = 半握全关节弧度
2. apply_targets()
→ _slew_applied_toward_goal: applied = goal(瞬达)
→ set_dof_positions(全部关节含 mimic)
→ set_dof_velocities(0)
3. SimulationManager.step()
→ PhysX 积分(硬写下姿态应贴近刚写的值)
4. recover_if_invalid() # 通常 False
5. publish_state()
→ 读 get_dof_positions → 发 /sim/isaac/...
6. apply_targets() 再钉一次(硬写模式)
```
`slew_rad=0.02`:每物理步每个关节最多走 0.02 rad,需多步才到 goal。
---
## 9. `--track-test` 路径
```text
max_steps 默认 480(若未指定)
每 160 物理步切换一次目标:
phase 偶: open_hand_command("G20")
phase 奇: [80.0]*20
set_nominal_targets(sdk_range_to_full_urdf_positions(...))
循环结束后再 settle 240 步:
apply_targets + SimulationManager.step
tracking_error() → max <= 0.02 → PASS,否则 exit 2
```
与 MuJoCo track-testopen→半握、关投影、slew=0)流程类似,但步进计数和切换方式不同。
---
## 10. 常用 Isaac / PhysX API 速查(本工程用到的)
| API | 用途 |
|-----|------|
| `SimulationApp` | 启动 Isaac 运行时 |
| `stage_utils.add_reference_to_stage` | 挂 USD |
| `prim_utils.set_prim_variants` | 选 Physics=physx |
| `Articulation(path)` | 包装手的 DOF |
| `set_dof_positions` / `set_dof_position_targets` | 硬写 / PD 目标 |
| `set_dof_gains` / `set_dof_max_efforts` | PD 参数 |
| `set_enabled_self_collisions` | 自碰开关 |
| `get_dof_positions` / `get_dof_velocities` / `get_dof_limits` | 读状态 |
| `SimulationManager.set_physics_dt` / `step` | 物理节拍 |
| `RenderingManager.render` | 渲染一帧 |
| `set_camera_view` | 对比视角 |
---
## 11. CLI 模式对照
| 参数 | 效果 |
|------|------|
| (默认) | 订 `/g20/cb_left_hand_control_cmd`,位置硬写,slew=0,自碰 ON |
| `--plan-nominal` | 订名义弧度话题,`use_cmd_u8=False` |
| `--legacy-sdk` | 订 `/cb_left_hand_control_cmd` |
| `--pd-drive` | `set_dof_position_targets` + 主动关节增益 |
| `--hand-gravity` | 仅 PD 下开手部重力 |
| `--no-self-collision` | 关自碰(会穿模) |
| `--slew-rad N` | 每物理步最多转 N rad |
| `--phys-per-render N` | 每渲染帧物理步数(默认 16) |
| `--track-test` | 内置跟踪验收 |
---
## 12. 改代码时最常动的位置
| 需求 | 改哪里 |
|------|--------|
| SDK→弧度、mimic、OPEN_CMD | `joint_mapping.py` |
| 默认硬写/PD 增益、拇指特殊增益 | `isaac_g20_plan_bridge.py` 顶部常量 + `main``set_dof_gains` |
| slew / 物理步 / 安装高度 | `DEFAULT_SLEW_RAD` / `DEFAULT_PHYS_PER_RENDER` / `HAND_MOUNT_*` |
| 相机 | `COMPARE_*` + `_compare_camera_eye_target` |
| 碰撞谁开谁关 | USD `payloads/instances.usda` |
| 自碰/求解器 | USD `payloads/Physics/physx.usda` |
| 话题名 | `TOPIC_*` 或 CLI |
---
## 13. 与 MuJoCo 技术详解的对照表
| 概念 | Isaac 函数/位置 | MuJoCo 函数/位置 |
|------|-----------------|------------------|
| 映射入口 | `joint_mapping.sdk_range_to_full_urdf_positions` | `g20_joints.sdk_range_to_full_urdf_positions` |
| 收命令 | `_on_cmd_u8` / `_on_nominal` | `_on_legacy_cmd` / `_on_nominal` |
| 更新目标 | `set_nominal_targets` / 直接改 `goal` | `_set_goal_only`(持锁) |
| 限速 | `_slew_applied_toward_goal`rad/步) | `_slew_applied_unlocked`rad/s |
| 写入仿真 | `apply_targets``set_dof_*` | `_hard_write_pose_unlocked``mj_forward` |
| 防穿模 | USD collider + 自碰(无投影) | `_project_applied_no_penetration_unlocked` |
| 物理步进 | `SimulationManager.step` | 硬写不 stepPD 用 `mj_step` |
| 发状态 | 每物理步 `publish_state` | 50Hz timer `_publish_state` |
| 状态话题 | `/sim/isaac/g20/left/joint_state` | `/sim/mujoco/g20/left/joint_state` |
---
## 14. 读代码建议顺序
1. `run_g20_plan.sh` → 确认用的是哪份 `python.sh`
2. 文件顶层:`parse_args``SimulationApp` → import
3. `main()` 前半:`_load_g20``_fix_base` → gains
4. `G20PlanBridge.__init__` + `_on_cmd_u8`
5. `apply_targets` + 主循环里「写→step→再钉」
6. 需要改防穿模时去看 USD `instances.usda`,而不是在桥里找 project 函数
资产与改动清单仍见 [`G20_Isaac_实现说明.md`](./G20_Isaac_实现说明.md)。
+362
View File
@@ -0,0 +1,362 @@
# G20 左手 · MuJoCo 仿真实现说明
本文记录 **MuJoCo G20 PLAN 名义弧度桥** 从 URDF 到可跑仿真的完整实现过程,以及相对原始 URDF 的全部具体改动。
- **函数调用与逐步逻辑**:见 [`G20_MuJoCo_技术详解.md`](./G20_MuJoCo_技术详解.md)(推荐先读此文理解代码)
- **Isaac 对照**[`G20_Isaac_实现说明.md`](./G20_Isaac_实现说明.md)
---
## 1. 目标与数据流
### 1.1 目标
- 在 MuJoCo 中仿真 LinkerHand G20 **左手**
- 默认兼容真机 SDK 话题协议:`sensor_msgs/JointState``position`**0~255**
- 可选订阅 PLAN 名义弧度(按关节名)。
- 关节名、0~255→弧度映射、mimic、拇指限位、对比视角与 Isaac 侧对齐。
- 默认 **运动学硬写** `qpos`(跟手紧);用碰撞投影减轻穿模,而不是改成默认 PD。
### 1.2 端到端数据流
```text
/g20/cb_left_hand_control_cmd (默认,0~255)
或 /retarget/g20/left/joint_target_nominal (--plan-nominal,弧度+关节名)
utils/g20_joints.py
· range_to_arc_g20_left / sdk_range_to_urdf_positions
· SDK15thumb_mcp = arc/1.02
· SDK16..19*_pip = arc/0.89
· complete_g20_mimic_positionsdip/ip
g20_plan_bridge.py
· ROS 回调只写 goal(不碰 MjData
· 仿真线程:slew → [collision_project] → 硬写 qpos/ctrl + mj_forward
/sim/mujoco/g20/left/joint_state @ 50 Hz
+ MuJoCo viewer(可选)
```
---
## 2. 关键文件路径
| 角色 | 绝对路径 |
|------|----------|
| 启动脚本 | `/home/lxqs/linker_hand_mujoco_ros2/run_g20_mujoco_plan.sh` |
| 桥主程序 | `.../linker_hand_mujoco_ros2/g20_plan_bridge.py` |
| 关节映射 | `.../linker_hand_mujoco_ros2/utils/g20_joints.py` |
| Launch | `.../launch/g20_plan_bridge.launch.py` |
| URDF | `.../urdf/G20/linker_hand_g20_left/linkerhand_g20_left.urdf` |
| MJCF | `.../urdf/G20/linker_hand_g20_left/linker_hand_g20_left.xml` |
| 网格 | `.../urdf/G20/linker_hand_g20_left/meshes/*.STL`22 个) |
| entry_point | `setup.py``g20_plan_bridge=linker_hand_mujoco_ros2.g20_plan_bridge:main` |
包根目录:
`/home/lxqs/linker_hand_mujoco_ros2/src/linkerhand-sim/linker_hand_mujoco_ros2/linker_hand_mujoco_ros2/`
---
## 3. 从 URDF 到 MJCF
### 3.1 URDF 来源
1. 原始模型由 **SolidWorks → URDF Exporter** 导出(URDF 头注释:Commit `1.6.0-4-g7f85cfe`)。
2. 本仓库资产来自 retarget 工程拷贝(非运行时自动下载),与 Isaac 侧网格 **MD5 一致**22/22)。
3. 入库后的 URDF 文件名:`linkerhand_g20_left.urdf`
### 3.2 URDF 上我们改过什么
相对更早的 G20 URDF(历史上限曾为 `thumb_cmc_roll` **1.4**),当前仓库 URDF **已写入**
| 改动项 | 具体内容 |
|--------|----------|
| `thumb_cmc_roll` 上限 | `upper="1.47"`rad);与映射表 `G20_L_MAX[5]=1.47`、Isaac 同步试调 |
| mimic(保留自原 URDF | `thumb_ip``thumb_mcp` × **1.02**;四指 `*_dip``*_pip` × **0.89** |
| 其它关节限位 | 与 Isaac URDF 一致(见下表),未再改数值 |
**当前 URDF 关节限位(rad,与 MJCF `range` / actuator `ctrlrange` 一致):**
| 关节 | lower | upper |
|------|-------|-------|
| `thumb_cmc_roll` | 0 | **1.47** |
| `thumb_cmc_yaw` | 0 | 1.57 |
| `thumb_cmc_pitch` | 0 | 0.84 |
| `thumb_mcp` | 0 | 1.26 |
| `thumb_ip` | 0 | 1.29 |
| 四指 `*_mcp_roll` | -0.23 | 0.23 |
| 四指 `*_mcp_pitch` | 0 | 1.22 |
| 四指 `*_pip` | 0 | 1.74 |
| 四指 `*_dip` | 0 | 1.55 |
说明:MuJoCo **不读取** URDF 的 `<mimic>`。mimic 在 Python 里用 `complete_g20_mimic_positions` 补全后硬写进 `qpos`
### 3.3 转换步骤(URDF → MJCF
仓库内 **没有** 保留可一键重跑的转换脚本;实际流程是:
1. **初转**:用 MuJoCo `MjSpec.from_file(urdf)` 编译得到骨架 MJCF(关节树、mesh、惯性)。
2. **手改定稿** 为当前 `linker_hand_g20_left.xml`(下列改动全部是相对「纯 URDF 导入结果」的人工修改)。
### 3.4 MJCF 相对 URDF / 纯导入结果的全部改动清单
#### A. 仿真选项与默认参数
| 项 | 写入值 |
|----|--------|
| `compiler` | `angle="radian" meshdir="meshes" balanceinertia="true"` |
| `option` | `timestep="0.002"``gravity="0 0 -9.81"``integrator="implicitfast"` |
| 默认 joint | `damping="0.15"``armature="0.01"` |
| 默认 position 执行器 | `kp="200"``kv="8"``forcelimited="true"``forcerange="-8 8"` |
#### B. 显示 / 碰撞分层(相对 URDF「全身 mesh 碰撞」)
| 类型 | 改动 |
|------|------|
| `class="visual"` | `type="mesh"``contype="0"``conaffinity="0"``group="2"`**只显示,不参与碰撞** |
| 碰撞体 | **同视觉 STL 的凸包 mesh**`type="mesh"` + `class="collision"`):掌心 + 拇指三段 + 四指近/中/远节;比 AABB 更贴外形 |
| collision 默认 | `margin="0.0005"``friction="1 0.5 0.01"``solref="0.004 1"``solimp="0.9 0.95 0.001"` |
**当前碰撞体(与视觉共用同一 `meshes/*.STL`MuJoCo 按凸包做碰撞):**
| body | 类型 | 说明 |
|------|------|------|
| `hand_base` | mesh 凸包 | `hand_base_link.STL` |
| `thumb_metacarpals` / `thumb_proximal` / `thumb_distal` | mesh 凸包 | 同名 STL |
| 四指 `*_proximal` / `*_middle` / `*_distal` | mesh 凸包 | 同名 STL |
| 掌骨 metacarpals、拇指 base1/base2 | 无碰撞 geom | 仅 visual;与掌心假重叠靠 `exclude` |
说明:MuJoCo 对 mesh 碰撞使用 **凸包**,不能还原 STL 凹坑,但整体轮廓远比 AABB/手写胶囊贴近视觉;张开/半握无假碰,握拳可检出指尖↔掌心穿透。
#### C. `<contact><exclude>`(共 28 条)
用途:排除 **mesh/几何本就嵌套** 造成的假碰撞(张开姿态假重叠)。
- 掌心 `hand_base` ↔ 拇指 base1/base2/metacarpals、四指 metacarpals/proximal
- 拇指链相邻 / 隔代相邻
- 四指各自 metacarpalsproximal、proximalmiddle、middledistal
未 exclude 的对(如指尖↔掌心、邻指远端)仍可产生接触,供投影使用。
#### D. 执行器
-**全部 21 个 hinge**(含 5 个 mimic 关节)各加一个 `<position name="*_pos" joint="...">`
- `thumb_cmc_roll``ctrlrange="0.0 1.47"`(与 URDF 上限一致)。
- 默认硬写模式下主要靠写 `qpos``--pd-drive` 时才真正用这些执行器做 `mj_step`
#### E. 命名差异
| URDF | MJCF |
|------|------|
| `<robot name="...">` | `<mujoco model="linker_hand_g20_left">` |
| link `hand_base_link` | body 名 `hand_base`geom 仍引用 mesh `hand_base_link` |
---
## 4. 关节映射 `g20_joints.py`(相对 SDK / Isaac
文件:`utils/g20_joints.py`。声明与 Isaac `joint_mapping.py` 的 G20 左手表对齐。
### 4.1 SDK 通道 → URDF 关节名
| SDK 索引 | 关节名 |
|----------|--------|
| 0 | `thumb_cmc_pitch` |
| 14 | `index/middle/ring/pinky_mcp_pitch` |
| 5 | `thumb_cmc_roll` |
| 69 | `index/middle/ring/pinky_mcp_roll` |
| 10 | `thumb_cmc_yaw` |
| 1114 | **预留**(转换时跳过) |
| 15 | `thumb_mcp`(见 4.3 |
| 1619 | `index/middle/ring/pinky_pip`(见 4.3 |
### 4.2 线性插值表(我们写入的数值)
```text
G20_L_MIN = [0,0,0,0,0, 0.0, -0.23,-0.23,-0.23,-0.23, 0.0, 0×4, 0×5]
G20_L_MAX = [0.84, 1.22×4, 1.47, 0.23×4, 1.57, 0×4, 1.29, 1.55×4]
G20_L_DIR = [-1×6, +1,+1,+1,+1, -1, 0×4, -1×5]
```
含义:
- `DIR == -1`SDK `0 → MAX``255 → MIN`(多数弯曲通道)。
- `DIR == +1`(仅侧摆 6..9):SDK `0 → MIN``255 → MAX`(左手侧摆与 retarget/`g20_mapping` 一致)。
- 通道 5 上限 **1.47**(相对历史 1.4 的试调,已写进 URDF/MJCF/Isaac)。
### 4.3 SDK 1519 的「除 mimic」规则(与 Isaac 相同,必须写清)
SDK 通道语义不是「直接写主动关节弧度」,而是电机/指尖弧度空间:
| SDK | 先算出的弧度 `arc` | 写入主动关节 |
|-----|-------------------|--------------|
| 15 | 按 `G20_L_*` 插值得到 | `thumb_mcp = arc / 1.02` |
| 1619 | 同上 | `*_pip = arc / 0.89` |
然后再:
- `thumb_ip = thumb_mcp * 1.02`
- `*_dip = *_pip * 0.89`
若漏掉「除 mimic」,DIP/IP 会整体偏大,表现为关节对不上 Isaac。
### 4.4 张开预设
```text
G20_OPEN_CMD = [
255,255,255,255,255,255,
193,148,105,42, # 四指侧摆
245, # thumb_cmc_yaw
255,255,255,255, # 预留
255,255,255,255,255 # 15..19
]
```
`open_hand_positions()` / `half_fist_positions()` 基于此生成测试姿态。
---
## 5. 桥节点 `g20_plan_bridge.py` 做了哪些事
### 5.1 话题
| 方向 | 默认话题 | 内容 |
|------|----------|------|
| 订阅(默认) | `/g20/cb_left_hand_control_cmd` | `position` 0~255,取前 20,不足补 255 |
| 订阅(`--plan-nominal` | `/retarget/g20/left/joint_target_nominal` | 按关节名弧度 |
| 发布 | `/sim/mujoco/g20/left/joint_state` | 全部 hinge 的 name/position/velocity50 Hz |
### 5.2 默认驱动:运动学硬写
- **默认**(无 `--pd-drive`):把 `applied` 写入 `data.qpos` 与对应 `ctrl``qvel=0`,调用 `mj_forward`**不调用** `mj_step`
- **`--pd-drive`**:写 `ctrl``mj_step` → 用真实 `qpos` 回写 `applied`(跟手变差,接触可挡,一般不作为默认)。
### 5.3 速度相关默认值(相对「瞬移」做过的调参)
| 常量 | 值 | 含义 |
|------|-----|------|
| `DEFAULT_SPEED` | `1.35` | 相对墙钟倍率 |
| `DEFAULT_SLEW_RAD_S` | `5.0` | 关节逼近 goal 的最大角速度 (rad/s)`0` = 瞬移 |
| `sim_dt` | `0.002`(来自 MJCF) | 每步最大转角 ≈ `5.0 * 0.002 = 0.01` rad |
### 5.4 防穿模:`collision_project`(硬写专用,默认开)
相对早期实现,做过这些关键修正:
| 版本问题 | 改动 |
|----------|------|
| 仅靠 STL 自碰 | 改为 visual(不碰)+ **同 STL 凸包 collision** |
| 「相对上一帧更深才算穿」 | 改为 **绝对** 判据:`min(contact.dist) < -5e-4` 即穿 |
| 关节空间整体二分 | 拇指撞掌心会拖死四指 → 改为 **按主动关节、Δq 从大到小** 逐关节接受或二分 |
| 仍穿模 | 回退 `_last_safe_pose` |
开关:
- 默认:开(且仅在硬写模式生效)
- `--no-collision-project`:关
- `--track-test`:强制关投影,且 `slew=0`,方便验收跟手误差 ≤ 0.02 rad
常量:`PENETRATION_TOL_M = 5e-4``COLLISION_PROJECT_ITERS = 12`
### 5.5 线程安全(修过闪退)
- `threading.Lock` 保护 `MjData`
- ROS 回调 **只** 更新 `goal` / `track_nominal``_set_goal_only`),**禁止**在回调里碰 `MjData`
- 主循环持锁调用 `step_simulation_unlocked()`;发 state 时持锁读 `qpos/qvel`
### 5.6 对比视角(与 Isaac 对齐)
| 常量 | 值 |
|------|-----|
| `COMPARE_LOOKAT` | `(0.04, -0.04, 0.12)` |
| `COMPARE_DISTANCE` | `0.50` |
| `COMPARE_AZIMUTH` | `180.0`(从 +X 看向掌心) |
| `COMPARE_ELEVATION` | `-12.0` |
手模型放在世界原点;Isaac 手有安装偏置,但 lookat 本地坐标与 elevation/distance 相同。
### 5.7 启动时校验
-`G20_ALL_JOINT_NAMES` 检查 MJCF 是否缺关节;缺则 **直接退出**
- 缺 position actuator 也会报错退出。
### 5.8 其它明确改动
- **不** import 真机包 `linker_hand_ros2_sdk`(只兼容同话题协议)。
- **不** 写死 `ROS_DOMAIN_ID`(启动脚本里曾有过,已去掉)。
- 退出用 `os._exit`,规避 rclpy/mujoco 析构 abort。
---
## 6. 如何启动
```bash
cd /home/lxqs/linker_hand_mujoco_ros2
./run_g20_mujoco_plan.sh
```
脚本会:`source /opt/ros/jazzy` → 可选 `install/setup.bash` → 把 `venv` site-packages 与包源码加入 `PYTHONPATH`
`python3 -m linker_hand_mujoco_ros2.g20_plan_bridge`
常用参数:
```bash
./run_g20_mujoco_plan.sh --plan-nominal
./run_g20_mujoco_plan.sh --pd-drive
./run_g20_mujoco_plan.sh --no-collision-project
./run_g20_mujoco_plan.sh --speed 0.5 --slew-rad-s 3
./run_g20_mujoco_plan.sh --headless --track-test --max-steps 800
```
Launch(无参数透传,调参请用 shell):
```bash
ros2 launch linker_hand_mujoco_ros2 g20_plan_bridge.launch.py
```
测试半握示例:
```bash
ros2 topic pub -r 20 /g20/cb_left_hand_control_cmd sensor_msgs/msg/JointState \
"{position: [80,80,80,80,80,255,193,148,105,42,245,255,255,255,255,80,80,80,80,80]}"
```
---
## 7. 已知限制(实现层面)
1. 硬写 + 投影 **不是** 真接触动力学;碰到凸包后关节会停在接触边界,不会「挤过去」。
2. 碰撞是 **凸包**,凹进去的视觉细节不会产生接触;比 AABB 贴,但仍可能与非凸视觉有细微差别。
3. 大量 `exclude` 关掉了掌心–近端假碰;真实可碰区域依赖开启碰撞的那些 link。
4. mimic 只在软件层保证;物理引擎内无 URDF mimic 约束。
5. 仅左手 G20;右手未做。
---
## 8. 改动 chronologically(便于对照 Git / 联调)
1. 拷贝 G20 URDF + meshes,生成带 position actuator 的 MJCF。
2. 实现 `g20_joints.py`(对齐 Isaac;含 SDK1519 除 mimic)。
3. 实现桥:名校验、订 nominal / legacy、发 `/sim/mujoco/...`
4. 修回调碰 `MjData` 导致的闪退 → 回调只改 goal。
5. 话题改为 `/g20/cb_left_hand_control_cmd`;去掉脚本里 DOMAIN。
6.`speed` / `slew` 到当前默认。
7. 拇指 `max=1.47` 写入映射 + URDF/MJCF。
8. 视角 `COMPARE_*` 与 Isaac 对齐。
9. 默认保持硬写;加碰撞投影;后改为绝对穿透 + **mesh 凸包碰撞** + 按关节投影。
---
## 9. 与 Isaac 的对照摘要
| 项 | MuJoCo(本文) | Isaac |
|----|----------------|-------|
| 模型格式 | MJCF | 预烘焙 USD(非运行时 URDF 导入) |
| 状态话题 | `/sim/mujoco/g20/left/joint_state` | `/sim/isaac/g20/left/joint_state` |
| 映射表 | `g20_joints.py` | `joint_mapping.py`G20 左手数值一致) |
| 默认驱动 | 硬写 qpos | 硬写 `set_dof_positions` |
| 防穿模 | mesh 凸包 + `collision_project` | USD 选择性 collider + PhysX 自碰 |
| slew | 默认 5 rad/s | 默认 0(每物理步瞬达) |
+457
View File
@@ -0,0 +1,457 @@
# G20 左手 · MuJoCo 技术详解
本文面向**读代码、改逻辑、排查问题**:按「程序从哪进、函数怎么调、数据怎么流」写清楚实现细节。
资产路径、URDF/MJCF 改动清单见 [`G20_MuJoCo_实现说明.md`](./G20_MuJoCo_实现说明.md)。
---
## 1. 总体结构:两个线程、三块逻辑
```text
┌─────────────────────────────────────────────────────────────┐
│ ROS 线程 (daemon) │
│ rclpy.spin_once() │
│ → _on_legacy_cmd() / _on_nominal() │
│ → set_nominal_targets() → _set_goal_only() │
│ → 只改 goal / track_nominal(持锁) │
│ → 绝不碰 model / data │
├─────────────────────────────────────────────────────────────┤
│ 主线程(仿真循环) │
│ with lock: step_simulation_unlocked() │
│ → _slew_applied_unlocked() # goal → applied │
│ → _project_applied_...() # 防穿模(默认开) │
│ → _hard_write_pose_unlocked() # applied → qpos │
│ → mujoco.mj_forward() # 更新运动学+接触 │
│ viewer.sync() / _pace() │
├─────────────────────────────────────────────────────────────┤
│ 定时器线程 (50Hz) │
│ _publish_state() → /sim/mujoco/g20/left/joint_state │
└─────────────────────────────────────────────────────────────┘
```
**核心状态变量(`G20PlanBridge` 内):**
| 变量 | 类型 | 含义 |
|------|------|------|
| `goal` | `dict[str, float]` | 最新目标关节角(弧度,含 mimic) |
| `applied` | `dict[str, float]` | 本步实际要写入仿真的角(slew + 投影后) |
| `_last_safe_pose` | `dict[str, float]` | 上一次「无穿透」的 applied,投影失败时回退 |
| `track_nominal` | `dict[str, float] \| None` | 仅主动关节,供 `tracking_error()` 验收 |
**为什么分 `goal` 和 `applied`**
`goal` 可以瞬间跳到新命令;`applied``slew_rad_s` 逐步逼近,且碰撞投影可能让 `applied` 停在比 `goal` 更「开」的位置。
---
## 2. 程序入口:`main()` 调用链
入口:`python3 -m linker_hand_mujoco_ros2.g20_plan_bridge`(或 `run_g20_mujoco_plan.sh`)。
```text
main()
├── parse_args() # CLI 参数
├── mujoco.MjModel.from_xml_path(xml) # 加载 MJCF
├── mujoco.MjData(model)
├── mujoco.mj_forward(model, data) # 初始化前向
├── validate_model_joint_names(names) # 关节名必须齐全
├── rclpy.init()
├── G20PlanBridge.__init__(...) # 建桥、订/发话题、初始 open 姿态
├── threading.Thread(spin_ros) # ROS 回调线程
└── while running:
with bridge.lock:
bridge.step_simulation_unlocked()
viewer.sync() / _pace()
```
### 2.1 `main()` 里与模式相关的赋值
```python
use_legacy = args.legacy_sdk and not args.plan_nominal # 默认订 0~255
slew_rad_s = 0.0 if args.track_test else args.slew_rad_s
collision_project = (not args.no_collision_project) and (not args.track_test)
use_pd_drive = args.pd_drive and not args.track_test
```
| 模式 | `hard_kinematic` | `collision_project` | `slew_rad_s` |
|------|------------------|---------------------|--------------|
| 默认 | True | True | 5.0 |
| `--pd-drive` | False | False(仅硬写模式有效) | 5.0 |
| `--no-collision-project` | True | False | 5.0 |
| `--track-test` | True | False | 0(瞬达) |
---
## 3. 初始化:`G20PlanBridge.__init__`
```text
G20PlanBridge.__init__
├── build_joint_maps(model)
│ → joint_qpos[name] # 关节名 → qpos 下标
│ → joint_dof[name] # 关节名 → qvel 下标
│ → joint_lo/hi[name] # MJCF range
│ → act_id[name] # 关节名 → actuator 下标
├── validate_model_joint_names(...)
├── open_full = open_hand_positions() # 见 §4
├── goal = applied = open_full
├── _set_goal_only(open_full)
├── slew_rad_s 临时置 0 → _slew_applied_unlocked() → _hard_write_applied_unlocked()
├── _last_safe_pose = applied
├── create_subscription(_on_legacy_cmd 或 _on_nominal)
├── create_publisher(sim_state)
└── create_timer(50Hz, _publish_state)
```
### 3.1 `build_joint_maps(model)`
遍历 `model.njnt`,只保留 **hinge**`mjJNT_HINGE`):
```python
name = mujoco.mj_id2name(model, mjOBJ_JOINT, i)
joint_qpos[name] = model.jnt_qposadr[i]
joint_dof[name] = model.jnt_dofadr[i]
joint_lo/hi = model.jnt_range[i]
```
遍历 `model.nu` 找 position actuator:优先匹配 `<joint>_pos` 命名。
---
## 4. 映射层:`g20_joints.py` 函数说明
外部命令(0~255 或弧度)最终都要变成 **`dict[关节名, 弧度]`**,且 mimic 关节要补全。
### 4.1 调用关系(legacy SDK 路径)
```text
_on_legacy_cmd(msg)
vals = msg.position[:20] # 不足补 255
sdk_range_to_full_urdf_positions(vals)
├── sdk_range_to_urdf_positions(vals)
│ ├── range_to_arc_g20_left(vals) # 20 维弧度
│ └── 按 L20_SDK_TO_URDF 填主动关节
│ SDK15 → thumb_mcp = arc/1.02
│ SDK16..19 → *_pip = arc/0.89
└── complete_g20_mimic_positions(...)
thumb_ip = thumb_mcp * 1.02
index_dip = index_pip * 0.89
...
set_nominal_targets(full)
└── _set_goal_only(full)
```
### 4.2 `range_to_arc_g20_left(values)` — 单通道怎么算
对每个 SDK 索引 `i`(跳过 11~14):
```python
val = clamp(values[i], 0, 255)
if G20_L_DIR[i] == -1:
arc = lerp(val, 0, 255, G20_L_MAX[i], G20_L_MIN[i]) # 0→MAX, 255→MIN
else:
arc = lerp(val, 0, 255, G20_L_MIN[i], G20_L_MAX[i]) # 侧摆 6..9
```
**例子:** `index_mcp_pitch` 是 SDK 1`DIR=-1``MAX=1.22``MIN=0`
→ SDK `0``1.22` rad(弯到底),SDK `255``0`(伸直)。
### 4.3 `sdk_range_to_urdf_positions` — SDK15/16~19 特殊处理
```python
# SDK 15:电机语义是「指尖弧度空间」
out["thumb_mcp"] = arc / 1.02
# SDK 16..19:电机语义是「DIP 弧度空间」,写入的是 pip
out["index_pip"] = arc / 0.89 # 同理 middle/ring/pinky
```
漏掉除法会导致仿真关节比真机/Isaac 偏大。
### 4.4 `complete_g20_mimic_positions(positions)`
```python
for mimic, (master, ratio) in G20_MIMIC_OF.items():
if mimic not in out and master in out:
out[mimic] = out[master] * ratio
```
若调用方已给出 `index_dip`,则**不覆盖**。
### 4.5 PLAN 名义弧度路径
```text
_on_nominal(msg)
incoming = {msg.name[i]: msg.position[i]}
set_nominal_targets(merged)
└── _set_goal_only(merged)
complete_g20_mimic_positions(merged) # 补 dip/ip
```
---
## 5. 仿真一步:`step_simulation_unlocked()`
**调用方必须已持有 `self.lock`。**
### 5.1 默认模式(运动学硬写 + 碰撞投影)
```python
def step_simulation_unlocked(self):
prev = dict(self.applied) # ① 记下上一帧姿态
self._slew_applied_unlocked() # ② goal → applied(限速)
if self.hard_kinematic:
if self.collision_project:
self._project_applied_no_penetration_unlocked(prev) # ③
else:
self._hard_write_applied_unlocked() # ③'
return # ④ 不调用 mj_step
# --pd-drive 分支见 §5.3
```
### 5.2 `_slew_applied_unlocked()` — 限速逼近
```python
max_step = slew_rad_s * sim_dt # 默认 5.0 * 0.002 = 0.01 rad/步
for name, target in self.goal.items():
cur = applied[name]
delta = target - cur
if abs(delta) <= max_step:
applied[name] = target
else:
applied[name] = cur + sign(delta) * max_step
applied[name] = clip(applied[name], joint_lo, joint_hi)
```
`slew_rad_s == 0``max_step is None`,直接 `applied = goal`
### 5.3 `_hard_write_pose_unlocked(pose)` — 写入 MuJoCo
对每个关节名:
```python
self.data.qpos[joint_qpos[name]] = val
self.data.qvel[joint_dof[name]] = 0.0
self.data.ctrl[act_id[name]] = val # 同步执行器目标
self.data.qvel[:] = 0.0
mujoco.mj_forward(self.model, self.data)
```
**`mj_forward` 做什么:** 根据当前 `qpos` 更新 body 位姿、碰撞几何位置,并填充 `data.contact``ncon``contact[i].dist`)。
**不调用 `mj_step`**:没有积分、没有力矩求解,纯运动学。
### 5.4 `_project_applied_no_penetration_unlocked(prev)` — 防穿模(重点)
**目的:** `applied``goal` 走了一步后,若 mesh 凸包穿透,则缩小步进,且**按关节**处理,避免拇指撞掌心时四指一起被卡住。
**穿透判定:**
```python
def _worst_penetration_unlocked(self):
worst = 0.0
for i in range(data.ncon):
worst = min(worst, data.contact[i].dist)
return worst # 负值 = 重叠深度(米)
def _is_penetrating_unlocked(self):
return _worst_penetration_unlocked() < -5e-4 # PENETRATION_TOL_M
```
`contact[i].dist`:两碰撞体表面距离;**负**表示已经穿进去。
**算法步骤:**
```text
cand = complete_g20_mimic_positions(applied) # slew 后的候选
_hard_write_pose_unlocked(cand)
if not penetrating:
applied = cand; _last_safe_pose = cand; return
# 有穿透:从安全起点 base 开始
_hard_write_pose_unlocked(prev)
if penetrating(prev):
base = complete_g20_mimic_positions(_last_safe_pose)
else:
base = complete_g20_mimic_positions(prev)
# 按 |cand[name]-base[name]| 从大到小排序主动关节
for name in order:
target = cand[name]
trial = complete_g20_mimic_positions({**base, name: target})
_hard_write_pose_unlocked(trial)
if not penetrating:
base = trial # 该关节可走到位
continue
# 否则在 [cur, target] 上二分 12 次
for _ in range(12):
mid = (lo+hi)/2
trial = complete_g20_mimic_positions({**base, name: mid})
_hard_write_pose_unlocked(trial)
if penetrating: hi = mid
else: lo = mid; best = trial
base = best
applied = complete_g20_mimic_positions(base)
_hard_write_pose_unlocked(applied)
if still penetrating:
applied = _last_safe_pose; 硬写回退
else:
_last_safe_pose = applied
```
**注意:** 每次试姿态都要 `complete_g20_mimic_positions`,因为改 `thumb_mcp` 会带动 `thumb_ip`
### 5.5 `--pd-drive` 分支
```python
self._ctrl_from_applied_unlocked() # data.ctrl[act_id] = applied
mujoco.mj_step(self.model, self.data) # PhysX 式积分+接触力
self._sync_applied_from_qpos_unlocked() # applied 跟真实 qpos
```
此时靠 MuJoCo 执行器 + 接触求解,**不用** `collision_project`
---
## 6. ROS 回调与发布
### 6.1 Legacy 命令
```python
def _on_legacy_cmd(self, msg):
vals = [float(x) for x in msg.position[:20]]
while len(vals) < 20: vals.append(255.0)
full = sdk_range_to_full_urdf_positions(vals)
self.set_nominal_targets(full) # → _set_goal_only
```
### 6.2 状态发布(50Hz
```python
def _publish_state(self):
with self.lock:
for name in ordered_names:
pos.append(data.qpos[joint_qpos[name]])
vel.append(data.qvel[joint_dof[name]])
pub.publish(JointState(...))
```
`qpos` 也持锁,避免与仿真步并发写 `MjData`
---
## 7. MJCF 碰撞:如何配合 `mj_forward`
### 7.1 双层 geom
每个要显示的 link 上通常有两个 geom:
```xml
<geom class="visual" mesh="index_proximal"/> <!-- contype=0,不碰 -->
<geom class="collision" type="mesh" mesh="index_proximal"/> <!-- 凸包碰撞 -->
```
- **visual**`contype=0 conaffinity=0`,只渲染。
- **collision**`contype=1 conaffinity=1`MuJoCo 对 STL 自动生成**凸包**做碰撞。
有碰撞的 body**掌心** + **拇指三段** + **四指近/中/远节**(共 16 个 collision geom)。
掌骨 `*_metacarpals`、拇指 `base1/base2` **无** collision geom。
### 7.2 `contact/exclude`28 条)
排除「几何嵌套」导致的假接触,例如:
```xml
<exclude body1="hand_base" body2="index_proximal"/>
<exclude body1="index_proximal" body2="index_middle"/>
```
张开手时这些对不应产生 `ncon`;握拳时**指尖↔掌心**、**邻指远端**仍可接触,供投影使用。
### 7.3 碰撞检测在代码里何时发生
每次 `_hard_write_pose_unlocked` 末尾调用 `mujoco.mj_forward` 后:
- `data.ncon`:接触对数量
- `data.contact[i].dist`:距离(负=穿透)
投影逻辑只读这些字段,**不**施加接触力。
---
## 8. 完整时序例子:收到半握命令后的一帧
假设上一帧 `applied` 已是张开,`goal` 刚被 `_on_legacy_cmd` 设为半握弧度。
```text
1. 主线程 step_simulation_unlocked()
2. prev = applied(张开)
3. _slew_applied_unlocked()
index_pip: 0.0 → min(0.01, 目标-0) = 0.01 … 各关节类似
4. _project_applied_no_penetration_unlocked(prev)
cand = complete(applied)
_hard_write_pose_unlocked(cand) → mj_forward → 检查 ncon
若无穿透:applied=cand,结束
若有穿透:按关节二分缩小,更新 applied
5. (③ 里已硬写)viewer 显示新姿态
6. 定时器 _publish_state 读出 qpos 发布
```
ROS 回调可能在步骤 3 与 4 之间再次更新 `goal`;因持锁,要么整步完成后再处理新消息,要么下一步再 slew。
---
## 9. 自检:`--track-test` 函数路径
```text
main()
bridge.set_nominal_targets(open_hand_positions())
loop step=0..199:
step_simulation_unlocked() # slew=0,无投影,瞬到 open
step==200:
bridge.set_nominal_targets(half_fist_positions())
step==600:
tracking_error() → max|q_sim - q_nominal| 主动关节
max <= 0.02 → PASS
```
`half_fist_positions()`:复制 `G20_OPEN_CMD`,把通道 `0..4, 15..19` 设为 `80`,再 `sdk_range_to_full_urdf_positions`
---
## 10. 常用 MuJoCo API 速查(本工程用到的)
| API | 用途 |
|-----|------|
| `MjModel.from_xml_path(path)` | 加载 MJCF |
| `mj_forward(model, data)` | 运动学前向 + 碰撞检测 |
| `mj_step(model, data)` | 动力学步进(仅 `--pd-drive` |
| `mj_id2name(model, mjOBJ_JOINT, i)` | 关节名 |
| `data.qpos[adr]` / `data.qvel[adr]` | 广义坐标 |
| `data.ncon` / `data.contact[i].dist` | 接触对与穿透深度 |
| `viewer.launch_passive` + `cam.lookat/distance/azimuth/elevation` | 对比视角 |
---
## 11. 改代码时最常动的位置
| 需求 | 改哪里 |
|------|--------|
| SDK→弧度映射、张开/半握预设 | `utils/g20_joints.py` |
| 限速、穿透阈值、投影迭代次数 | `g20_plan_bridge.py` 顶部常量 |
| 默认驱动/投影开关 | `G20PlanBridge.__init__` 或 CLI |
| 碰撞形状、exclude | `linker_hand_g20_left.xml` |
| 话题名 | `TOPIC_*` 常量或 CLI |
| 对比相机 | `COMPARE_*` + `main()``viewer.cam.*` |
---
## 12. 与 Isaac 桥的对应关系(读代码对照用)
| MuJoCo | Isaac (`isaac_g20_plan_bridge.py`) |
|--------|--------------------------------------|
| `sdk_range_to_full_urdf_positions` | `joint_mapping.sdk_range_to_full_urdf_positions` |
| `_hard_write_pose` / `set_dof_positions` | 默认都是位置硬写 |
| `collision_project` + `mj_forward` | **无**;靠 USD 选择性 collider |
| `slew_rad_s`rad/s | `slew_rad`rad/物理步,默认 0 |
| `/sim/mujoco/g20/left/joint_state` | `/sim/isaac/g20/left/joint_state` |
Isaac 对照见 [`G20_Isaac_技术详解.md`](./G20_Isaac_技术详解.md) / [`G20_Isaac_实现说明.md`](./G20_Isaac_实现说明.md)。
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# MuJoCo G20 左手仿真桥
# 默认订阅 /g20/cb_left_hand_control_cmd0~255
# 发布 /sim/mujoco/g20/left/joint_state
# 默认 1.35× 墙钟 + 关节限速;可用 --speed / --slew-rad-s 再调
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
set +u
# shellcheck disable=SC1091
source /opt/ros/jazzy/setup.bash
if [[ -f "$ROOT/install/setup.bash" ]]; then
# shellcheck disable=SC1091
source "$ROOT/install/setup.bash"
fi
set -u
VENV_SITE="$ROOT/venv/lib/python3.12/site-packages"
if [[ -d "$VENV_SITE" ]]; then
export PYTHONPATH="$VENV_SITE${PYTHONPATH:+:$PYTHONPATH}"
fi
# 源码优先(含 urdf 资产)
PKG_SRC="$ROOT/src/linkerhand-sim/linker_hand_mujoco_ros2"
export PYTHONPATH="$PKG_SRC${PYTHONPATH:+:$PYTHONPATH}"
exec python3 -m linker_hand_mujoco_ros2.g20_plan_bridge "$@"
@@ -0,0 +1,17 @@
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
"""启动 MuJoCo G20 桥(默认订 /g20/cb_left_hand_control_cmd)。
更多参数请用根目录: ./run_g20_mujoco_plan.sh [--speed 0.5] [--plan-nominal]
"""
return LaunchDescription([
Node(
package="linker_hand_mujoco_ros2",
executable="g20_plan_bridge",
name="g20_mujoco_plan_bridge",
output="screen",
),
])
@@ -0,0 +1,677 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""MuJoCo G20 左手仿真桥。
默认:
订阅 /g20/cb_left_hand_control_cmd JointState(position 0~255)
发布 /sim/mujoco/g20/left/joint_state JointState(实际仿真 DOF)
可选 ``--plan-nominal``:订 /retarget/g20/left/joint_target_nominal(弧度按名)。
默认按墙钟实时步进(``--speed`` 可调;``--no-realtime`` 尽快跑)。
"""
from __future__ import annotations
import argparse
import os
import signal
import sys
import threading
import time
from pathlib import Path
import numpy as np
from linker_hand_mujoco_ros2.utils.g20_joints import (
complete_g20_mimic_positions,
g20_actuated_joint_names,
g20_required_joint_names,
half_fist_positions,
open_hand_positions,
sdk_range_to_full_urdf_positions,
)
PKG_DIR = Path(__file__).resolve().parent
DEFAULT_XML = PKG_DIR / "urdf/G20/linker_hand_g20_left/linker_hand_g20_left.xml"
TOPIC_NOMINAL = "/retarget/g20/left/joint_target_nominal"
TOPIC_SIM_STATE = "/sim/mujoco/g20/left/joint_state"
TOPIC_LEGACY_CMD = "/g20/cb_left_hand_control_cmd"
TRACK_TOL_RAD = 0.02
WARN_INTERVAL_S = 2.0
STATE_HZ = 50.0
# 相对墙钟倍率;1.0=实时,>1 更快
DEFAULT_SPEED = 1.35
# 每秒最大关节角速度(rad/s);0=瞬移到目标
DEFAULT_SLEW_RAD_S = 5.0
# 运动学防穿模:绝对重叠深度超过该值(米)则二分回退(勿用相对判据,否则会逐步钻入)
PENETRATION_TOL_M = 5e-4
COLLISION_PROJECT_ITERS = 12
# 与 Isaac G20 共用的对比视角:正对掌心(法向约 -X)、手指朝上(+Z)
# lookat = 手几何中心(本地);相机从 +X 看向 -X
COMPARE_LOOKAT = (0.04, -0.04, 0.12)
COMPARE_DISTANCE = 0.50
COMPARE_AZIMUTH = 180.0 # MuJoCo180 = 从 +X 看向掌心
COMPARE_ELEVATION = -12.0
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="MuJoCo G20 left-hand ROS2 bridge")
p.add_argument("--xml", type=Path, default=DEFAULT_XML)
p.add_argument("--headless", action="store_true", help="无 MuJoCo viewer")
p.add_argument("--max-steps", type=int, default=0, help=">0 时跑满步数退出")
p.add_argument(
"--plan-nominal",
action="store_true",
help="订 PLAN 名义弧度话题 /retarget/g20/left/joint_target_nominal",
)
p.add_argument(
"--legacy-sdk",
action="store_true",
default=True,
help="订 /g20/cb_left_hand_control_cmd0~255,默认开启)",
)
p.add_argument(
"--no-legacy-sdk",
action="store_false",
dest="legacy_sdk",
help="关闭 legacy,改用 --plan-nominal / --nominal-topic",
)
p.add_argument(
"--track-test",
action="store_true",
help="内置 open→半握 nominal,验收主动关节跟踪 ≤0.02rad",
)
p.add_argument(
"--pd-drive",
action="store_true",
help="改用执行器+自碰(可减轻穿模,跟手不如默认运动学)",
)
p.add_argument(
"--no-collision-project",
action="store_true",
help="关闭运动学硬写的碰撞投影(允许穿模)",
)
p.add_argument(
"--speed",
type=float,
default=DEFAULT_SPEED,
help=f"相对实时倍率(默认 {DEFAULT_SPEED}1.0=墙钟实时)",
)
p.add_argument(
"--slew-rad-s",
type=float,
default=DEFAULT_SLEW_RAD_S,
help=f"关节逼近目标的最大角速度 rad/s(默认 {DEFAULT_SLEW_RAD_S}0=瞬移)",
)
p.add_argument(
"--no-realtime",
action="store_true",
help="不按墙钟节拍,尽快步进(自检/benchmark 用)",
)
p.add_argument("--nominal-topic", default=TOPIC_NOMINAL)
p.add_argument("--cmd-topic", default=TOPIC_LEGACY_CMD, help="legacy SDK 控制话题")
p.add_argument("--sim-state-topic", default=TOPIC_SIM_STATE)
return p.parse_args()
def validate_model_joint_names(joint_names: set[str]) -> None:
required = g20_required_joint_names()
missing = sorted(required - joint_names)
if missing:
raise RuntimeError(
"G20 MJCF 关节名与 PLAN/URDF 规范不匹配,缺失: "
+ ", ".join(missing)
+ f" | have={sorted(joint_names)}"
)
def build_joint_maps(model):
import mujoco
joint_qpos: dict[str, int] = {}
joint_dof: dict[str, int] = {}
joint_lo: dict[str, float] = {}
joint_hi: dict[str, float] = {}
for i in range(model.njnt):
if model.jnt_type[i] != int(mujoco.mjtJoint.mjJNT_HINGE):
continue
name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, i)
if not name:
continue
joint_qpos[name] = int(model.jnt_qposadr[i])
joint_dof[name] = int(model.jnt_dofadr[i])
joint_lo[name] = float(model.jnt_range[i, 0])
joint_hi[name] = float(model.jnt_range[i, 1])
act_id: dict[str, int] = {}
for i in range(model.nu):
aname = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_ACTUATOR, i) or ""
# prefer "<joint>_pos"
if aname.endswith("_pos"):
jname = aname[: -len("_pos")]
if jname in joint_qpos:
act_id[jname] = i
continue
# fallback: actuator target joint
# model.actuator_trnid[i,0] is joint id for joint transmission
jid = int(model.actuator_trnid[i, 0])
if 0 <= jid < model.njnt:
jname = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, jid)
if jname and jname not in act_id:
act_id[jname] = i
return joint_qpos, joint_dof, joint_lo, joint_hi, act_id
class G20PlanBridge:
def __init__(
self,
node,
model,
data,
*,
legacy_sdk: bool,
nominal_topic: str,
cmd_topic: str,
sim_state_topic: str,
use_pd_drive: bool = False,
collision_project: bool = True,
slew_rad_s: float = DEFAULT_SLEW_RAD_S,
sim_dt: float = 0.002,
):
from sensor_msgs.msg import JointState
self.node = node
self.model = model
self.data = data
self.legacy_sdk = legacy_sdk
# 默认运动学硬写(原模式);--pd-drive 才走执行器+接触
self.hard_kinematic = not bool(use_pd_drive)
self.collision_project = bool(collision_project) and self.hard_kinematic
self.slew_rad_s = max(0.0, float(slew_rad_s))
self.sim_dt = float(sim_dt)
self.lock = threading.Lock()
(
self.joint_qpos,
self.joint_dof,
self.joint_lo,
self.joint_hi,
self.act_id,
) = build_joint_maps(model)
validate_model_joint_names(set(self.joint_qpos.keys()))
missing_act = sorted(set(self.joint_qpos) - set(self.act_id))
if missing_act:
raise RuntimeError(f"MJCF 缺少 position actuator: {missing_act}")
self.actuated = g20_actuated_joint_names()
self.ordered_names = sorted(self.joint_qpos.keys())
open_full = open_hand_positions()
self.goal: dict[str, float] = dict(open_full)
self.applied: dict[str, float] = dict(open_full)
self.track_nominal: dict[str, float] | None = None
self._last_warn_missing = 0.0
self._last_warn_unknown = 0.0
self._set_goal_only(open_full)
# 初始瞬移到 open
old_slew = self.slew_rad_s
self.slew_rad_s = 0.0
self._slew_applied_unlocked()
self._hard_write_applied_unlocked()
self._last_safe_pose: dict[str, float] = dict(self.applied)
self.slew_rad_s = old_slew
if legacy_sdk:
self.sub = node.create_subscription(
JointState, cmd_topic, self._on_legacy_cmd, 10
)
node.get_logger().info(f"G20: sub {cmd_topic} (SDK 0~255)")
else:
self.sub = node.create_subscription(
JointState, nominal_topic, self._on_nominal, 10
)
node.get_logger().info(f"G20 PLAN: sub {nominal_topic} (rad by name)")
self.pub = node.create_publisher(JointState, sim_state_topic, 10)
self._state_timer = node.create_timer(1.0 / STATE_HZ, self._publish_state)
mode = "kinematic-qpos" if self.hard_kinematic else "actuator+contact"
proj = "on" if self.collision_project else "off"
node.get_logger().info(
f"G20: pub {sim_state_topic}, joints={len(self.ordered_names)}, "
f"actuated={len(self.actuated)}, drive={mode}, "
f"collision_project={proj}, slew={self.slew_rad_s:.3f} rad/s"
)
def _clamp_name(self, name: str, val: float) -> float:
lo = self.joint_lo.get(name, -10.0)
hi = self.joint_hi.get(name, 10.0)
return float(np.clip(val, lo, hi))
def _set_goal_only(self, positions: dict[str, float]) -> None:
"""仅更新 goal(线程安全)。禁止在 ROS 回调里碰 MjData。"""
full = complete_g20_mimic_positions(positions)
with self.lock:
self.goal = full
self.track_nominal = {
n: float(full[n]) for n in self.actuated if n in full
}
def _slew_applied_unlocked(self) -> None:
max_step = (
self.slew_rad_s * self.sim_dt if self.slew_rad_s > 0.0 else None
)
for name, target in self.goal.items():
if name not in self.joint_qpos:
continue
target = self._clamp_name(name, target)
if max_step is None:
val = target
else:
cur = float(self.applied.get(name, target))
delta = target - cur
if abs(delta) <= max_step:
val = target
else:
val = cur + max_step * (1.0 if delta > 0 else -1.0)
val = self._clamp_name(name, val)
self.applied[name] = val
def _hard_write_pose_unlocked(self, pose: dict[str, float]) -> None:
import mujoco
for name, val in pose.items():
if name not in self.joint_qpos:
continue
aid = self.act_id.get(name)
if aid is not None:
self.data.ctrl[aid] = val
self.data.qpos[self.joint_qpos[name]] = val
self.data.qvel[self.joint_dof[name]] = 0.0
self.data.qvel[:] = 0.0
mujoco.mj_forward(self.model, self.data)
def _hard_write_applied_unlocked(self) -> None:
self._hard_write_pose_unlocked(self.applied)
def _worst_penetration_unlocked(self) -> float:
"""最严重重叠深度(负=穿透,0=无重叠)。"""
worst = 0.0
for i in range(int(self.data.ncon)):
dist = float(self.data.contact[i].dist)
if dist < worst:
worst = dist
return worst
def _is_penetrating_unlocked(self) -> bool:
"""绝对穿透判定(相对上一帧会漏掉每步 <tol 的缓慢钻入)。"""
return self._worst_penetration_unlocked() < -PENETRATION_TOL_M
def _project_applied_no_penetration_unlocked(
self, prev: dict[str, float]
) -> None:
"""按主动关节分别投影:某关节撞到时不拖死其它手指(仍硬写)。"""
cand = complete_g20_mimic_positions(dict(self.applied))
self._hard_write_pose_unlocked(cand)
if not self._is_penetrating_unlocked():
self.applied = cand
self._last_safe_pose = dict(self.applied)
return
self._hard_write_pose_unlocked(prev)
if self._is_penetrating_unlocked():
base = complete_g20_mimic_positions(dict(self._last_safe_pose))
else:
base = complete_g20_mimic_positions(dict(prev))
# 变化大的关节优先尝试完整步进,减少“拇指撞掌心把四指一起卡住”
order = sorted(
self.actuated,
key=lambda n: -abs(float(cand.get(n, 0.0)) - float(base.get(n, 0.0))),
)
for name in order:
if name not in cand or name not in self.joint_qpos:
continue
target = float(cand[name])
cur = float(base.get(name, target))
if abs(target - cur) < 1e-12:
continue
trial = complete_g20_mimic_positions({**base, name: target})
self._hard_write_pose_unlocked(trial)
if not self._is_penetrating_unlocked():
base = trial
continue
lo, hi = cur, target
best = dict(base)
for _ in range(COLLISION_PROJECT_ITERS):
mid = 0.5 * (lo + hi)
trial = complete_g20_mimic_positions({**base, name: mid})
self._hard_write_pose_unlocked(trial)
if self._is_penetrating_unlocked():
hi = mid
else:
lo = mid
best = trial
base = best
self.applied = complete_g20_mimic_positions(base)
self._hard_write_pose_unlocked(self.applied)
if self._is_penetrating_unlocked():
self.applied = dict(self._last_safe_pose)
self._hard_write_pose_unlocked(self.applied)
else:
self._last_safe_pose = dict(self.applied)
def _ctrl_from_applied_unlocked(self) -> None:
for name, val in self.applied.items():
aid = self.act_id.get(name)
if aid is not None:
self.data.ctrl[aid] = val
def _sync_applied_from_qpos_unlocked(self) -> None:
"""接触挡住后,以真实 qpos 为下一次 slew 起点,避免硬顶穿模。"""
for name, qadr in self.joint_qpos.items():
self.applied[name] = float(self.data.qpos[qadr])
def step_simulation_unlocked(self) -> None:
"""调用方须已持有 self.lock。"""
import mujoco
prev = dict(self.applied)
self._slew_applied_unlocked()
if self.hard_kinematic:
if self.collision_project:
self._project_applied_no_penetration_unlocked(prev)
else:
self._hard_write_applied_unlocked()
return
self._ctrl_from_applied_unlocked()
mujoco.mj_step(self.model, self.data)
self._sync_applied_from_qpos_unlocked()
def set_nominal_targets(self, positions: dict[str, float]) -> None:
self._set_goal_only(positions)
def _on_nominal(self, msg) -> None:
if not msg.name:
self._warn_missing("empty name[]")
return
n = min(len(msg.name), len(msg.position))
incoming = {str(msg.name[i]): float(msg.position[i]) for i in range(n)}
unknown = [k for k in incoming if k not in self.joint_qpos]
if unknown:
self._warn_unknown(unknown)
missing_act = sorted(self.actuated - set(incoming.keys()))
if missing_act:
self._warn_missing(f"missing actuated: {missing_act}")
with self.lock:
merged = dict(self.goal)
for k, v in incoming.items():
if k in self.joint_qpos:
merged[k] = v
self.set_nominal_targets(merged)
else:
merged = {k: v for k, v in incoming.items() if k in self.joint_qpos}
self.set_nominal_targets(merged)
def _on_legacy_cmd(self, msg) -> None:
vals = [float(x) for x in msg.position[:20]]
while len(vals) < 20:
vals.append(255.0)
full = sdk_range_to_full_urdf_positions(vals)
self.set_nominal_targets(full)
def _warn_missing(self, detail: str) -> None:
now = time.monotonic()
if now - self._last_warn_missing < WARN_INTERVAL_S:
return
self._last_warn_missing = now
self.node.get_logger().warn(
f"nominal cmd incomplete, holding last targets ({detail})"
)
def _warn_unknown(self, names: list[str]) -> None:
now = time.monotonic()
if now - self._last_warn_unknown < WARN_INTERVAL_S:
return
self._last_warn_unknown = now
self.node.get_logger().warn(f"unknown joint names ignored: {names[:8]}")
def _publish_state(self) -> None:
from sensor_msgs.msg import JointState
from std_msgs.msg import Header
msg = JointState()
msg.header = Header()
msg.header.stamp = self.node.get_clock().now().to_msg()
names = []
pos = []
vel = []
with self.lock:
for name in self.ordered_names:
names.append(name)
pos.append(float(self.data.qpos[self.joint_qpos[name]]))
vel.append(float(self.data.qvel[self.joint_dof[name]]))
msg.name = names
msg.position = pos
msg.velocity = vel
self.pub.publish(msg)
def tracking_error(self) -> tuple[float, float]:
"""返回主动关节 |q_sim - q_nominal| 的 (max, mean)。"""
if not self.track_nominal:
return 0.0, 0.0
errs = []
with self.lock:
for name, nom in self.track_nominal.items():
q = float(self.data.qpos[self.joint_qpos[name]])
errs.append(abs(q - float(nom)))
if not errs:
return 0.0, 0.0
return float(max(errs)), float(sum(errs) / len(errs))
def main(argv: list[str] | None = None) -> int:
# argparse 读 sys.argv;保留签名兼容 entry_point
_ = argv
args = parse_args()
xml_path = args.xml.expanduser().resolve()
if not xml_path.is_file():
print(f"[error] MJCF not found: {xml_path}", file=sys.stderr)
return 1
import mujoco
import mujoco.viewer
model = mujoco.MjModel.from_xml_path(str(xml_path))
data = mujoco.MjData(model)
model.opt.disableflags = 0
data.qpos[:] = 0
data.qvel[:] = 0
mujoco.mj_forward(model, data)
# Pre-validate before ROS init so failures are loud
names = set()
for i in range(model.njnt):
if model.jnt_type[i] != int(mujoco.mjtJoint.mjJNT_HINGE):
continue
n = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, i)
if n:
names.add(n)
try:
validate_model_joint_names(names)
except RuntimeError as e:
print(f"[error] {e}", file=sys.stderr)
return 1
import rclpy
from rclpy.node import Node
# --plan-nominal 优先于默认 legacy
use_legacy = bool(args.legacy_sdk) and not bool(args.plan_nominal)
use_realtime = (not args.no_realtime) and (not args.track_test)
speed = max(1e-6, float(args.speed))
dt = float(model.opt.timestep)
# track-test 需瞬达;默认运动学,仅 --pd-drive 开接触驱动
slew_rad_s = 0.0 if args.track_test else float(args.slew_rad_s)
rclpy.init()
node = Node("g20_mujoco_plan_bridge")
bridge = G20PlanBridge(
node,
model,
data,
legacy_sdk=use_legacy,
nominal_topic=args.nominal_topic,
cmd_topic=args.cmd_topic,
sim_state_topic=args.sim_state_topic,
use_pd_drive=bool(args.pd_drive) and not bool(args.track_test),
collision_project=(not bool(args.no_collision_project))
and (not bool(args.track_test)),
slew_rad_s=slew_rad_s,
sim_dt=dt,
)
node.get_logger().info(
f"timing: realtime={use_realtime} speed={speed:.3f} dt={dt:.4f}s"
)
running = True
def _stop(*_):
nonlocal running
running = False
signal.signal(signal.SIGINT, _stop)
signal.signal(signal.SIGTERM, _stop)
def spin_ros():
while running and rclpy.ok():
rclpy.spin_once(node, timeout_sec=0.01)
ros_thread = threading.Thread(target=spin_ros, daemon=True)
ros_thread.start()
track_phase = 0
track_switch_step = 200
if args.track_test:
bridge.set_nominal_targets(open_hand_positions())
node.get_logger().info("track-test: phase0 open")
step = 0
exit_code = 0
viewer = None
wall0 = time.perf_counter()
sim_time = 0.0
def _pace() -> None:
nonlocal sim_time
sim_time += dt
if not use_realtime:
return
target = wall0 + sim_time / speed
# 短延时用忙等,避免 time.sleep 粒度导致跑超实时
while True:
delay = target - time.perf_counter()
if delay <= 0.0:
break
if delay > 0.002:
time.sleep(delay * 0.5)
else:
# busy-wait 尾部
while time.perf_counter() < target:
pass
break
def _maybe_track() -> bool:
"""更新 track-test;返回 True 表示应结束。"""
nonlocal track_phase, exit_code
if not args.track_test:
return False
if step == track_switch_step:
bridge.set_nominal_targets(half_fist_positions())
track_phase = 1
node.get_logger().info("track-test: phase1 half-fist")
if track_phase == 1 and step >= track_switch_step + 400:
mx, mean = bridge.tracking_error()
node.get_logger().info(
f"track-test settle: max={mx:.4f} mean={mean:.4f} tol={TRACK_TOL_RAD}"
)
if mx > TRACK_TOL_RAD:
node.get_logger().error("track-test FAIL")
exit_code = 2
else:
node.get_logger().info("track-test PASS")
return True
return False
try:
if args.headless:
while running:
with bridge.lock:
bridge.step_simulation_unlocked()
step += 1
_pace()
if _maybe_track():
break
if args.max_steps > 0 and step >= args.max_steps:
if args.track_test and track_phase == 1:
mx, mean = bridge.tracking_error()
node.get_logger().info(
f"track-test @max-steps: max={mx:.4f} mean={mean:.4f}"
)
if mx > TRACK_TOL_RAD:
exit_code = 2
break
else:
viewer = mujoco.viewer.launch_passive(model, data)
# 初始对比视角:正对掌心(与 Isaac 同 lookat/距离)
viewer.cam.lookat[:] = COMPARE_LOOKAT
viewer.cam.distance = COMPARE_DISTANCE
viewer.cam.azimuth = COMPARE_AZIMUTH
viewer.cam.elevation = COMPARE_ELEVATION
while running and viewer.is_running():
with bridge.lock:
bridge.step_simulation_unlocked()
viewer.sync()
step += 1
_pace()
if _maybe_track():
break
if args.max_steps > 0 and step >= args.max_steps:
break
finally:
running = False
if viewer is not None:
try:
viewer.close()
except Exception:
pass
try:
bridge._state_timer.cancel()
except Exception:
pass
try:
ros_thread.join(timeout=1.0)
except Exception:
pass
try:
node.destroy_node()
except Exception:
pass
try:
if rclpy.ok():
rclpy.shutdown()
except Exception:
pass
print(f"[done] steps={step} exit={exit_code}")
# rclpy/mujoco 析构顺序偶发 abort;直接退出避免污染退出码
os._exit(exit_code)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,228 @@
<mujoco model="linker_hand_g20_left">
<compiler angle="radian" meshdir="meshes" balanceinertia="true"/>
<option timestep="0.002" gravity="0 0 -9.81" integrator="implicitfast" />
<default>
<joint damping="0.15" armature="0.01" />
<position kp="200" kv="8" forcelimited="true" forcerange="-8 8" />
<!-- visual: 仅显示;collision: 同 mesh 的凸包(MuJoCo 默认),比 AABB 更贴视觉外形 -->
<default class="visual">
<geom type="mesh" contype="0" conaffinity="0" group="2" rgba="1 1 1 1"/>
</default>
<default class="collision">
<geom contype="1" conaffinity="1" condim="3" group="3" rgba="0 1 0 0"
friction="1 0.5 0.01" solref="0.004 1" solimp="0.9 0.95 0.001" margin="0.0005"/>
</default>
</default>
<asset>
<mesh name="hand_base_link" file="hand_base_link.STL"/>
<mesh name="thumb_metacarpals_base1" file="thumb_metacarpals_base1.STL"/>
<mesh name="thumb_metacarpals_base2" file="thumb_metacarpals_base2.STL"/>
<mesh name="thumb_metacarpals" file="thumb_metacarpals.STL"/>
<mesh name="thumb_proximal" file="thumb_proximal.STL"/>
<mesh name="thumb_distal" file="thumb_distal.STL"/>
<mesh name="index_metacarpals" file="index_metacarpals.STL"/>
<mesh name="index_proximal" file="index_proximal.STL"/>
<mesh name="index_middle" file="index_middle.STL"/>
<mesh name="index_distal" file="index_distal.STL"/>
<mesh name="middle_metacarpals" file="middle_metacarpals.STL"/>
<mesh name="middle_proximal" file="middle_proximal.STL"/>
<mesh name="middle_middle" file="middle_middle.STL"/>
<mesh name="middle_distal" file="middle_distal.STL"/>
<mesh name="ring_metacarpals" file="ring_metacarpals.STL"/>
<mesh name="ring_proximal" file="ring_proximal.STL"/>
<mesh name="ring_middle" file="ring_middle.STL"/>
<mesh name="ring_distal" file="ring_distal.STL"/>
<mesh name="pinky_metacarpals" file="pinky_metacarpals.STL"/>
<mesh name="pinky_proximal" file="pinky_proximal.STL"/>
<mesh name="pinky_middle" file="pinky_middle.STL"/>
<mesh name="pinky_distal" file="pinky_distal.STL"/>
</asset>
<worldbody>
<body name="hand_base" pos="0 0 0">
<geom class="visual" mesh="hand_base_link"/>
<geom class="collision" type="mesh" mesh="hand_base_link"/>
</body>
<body name="thumb_metacarpals_base1" pos="0.01916 -0.01 0.0436">
<inertial pos="0.0115155 0.00398069 0.000272888" quat="0.982721 -0.180506 -0.00245546 0.0408729" mass="0.0160352" diaginertia="4.05464e-06 2.4729e-06 2.02979e-06"/>
<joint name="thumb_cmc_roll" pos="0 0 0" axis="-1 0 0" range="0 1.47" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="thumb_metacarpals_base1"/>
<body name="thumb_metacarpals_base2" pos="0.019845 0 0" quat="0.926488 0.376324 0 0">
<inertial pos="0.00286715 -0.00615727 0.00472605" quat="0.751727 0.570385 0.0649353 -0.32458" mass="0.00183265" diaginertia="1.63035e-07 1.63035e-07 1.63035e-07"/>
<joint name="thumb_cmc_yaw" pos="0 0 0" axis="0 0 1" range="0 1.57" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="thumb_metacarpals_base2"/>
<body name="thumb_metacarpals" pos="0.0088218 -0.024245 0.0005" quat="0.49419 0.415774 -0.291116 0.705804">
<inertial pos="0.00670658 8.60759e-05 0.0331413" quat="0.705208 -0.00142811 -0.0031017 0.708992" mass="0.028716" diaginertia="1.35288e-05 1.13359e-05 3.37772e-06"/>
<joint name="thumb_cmc_pitch" pos="0 0 0" axis="0 1 0" range="0 0.84" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="thumb_metacarpals"/>
<geom class="collision" type="mesh" mesh="thumb_metacarpals"/>
<body name="thumb_proximal" pos="0.0127018 0 0.0627256">
<inertial pos="-0.00268734 0.000568245 0.0118502" quat="0.705679 -0.0691221 -0.0785141 0.700767" mass="0.00549016" diaginertia="6.2328e-07 4.89863e-07 2.23372e-07"/>
<joint name="thumb_mcp" pos="0 0 0" axis="0 1 0" range="0 1.26" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="thumb_proximal"/>
<geom class="collision" type="mesh" mesh="thumb_proximal"/>
<body name="thumb_distal" pos="-0.00483652 0 0.0342226">
<inertial pos="-0.000710022 -0.00267576 0.0124763" quat="0.561846 0.213938 0.139751 0.786785" mass="0.00382047" diaginertia="2.69897e-07 2.52035e-07 8.26387e-08"/>
<joint name="thumb_ip" pos="0 0 0" axis="0 1 0" range="0 1.29" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="thumb_distal"/>
<geom class="collision" type="mesh" mesh="thumb_distal"/>
</body>
</body>
</body>
</body>
</body>
<body name="index_metacarpals" pos="0.0038 -0.03525 0.1481">
<inertial pos="-0.007943 -2.93674e-07 0.00220106" quat="0.599922 0.599922 -0.374291 0.374291" mass="0.000895516" diaginertia="4.67031e-08 3.17351e-08 2.85435e-08"/>
<joint name="index_mcp_roll" pos="0 0 0" axis="1 0 0" range="-0.23 0.23" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="index_metacarpals"/>
<body name="index_proximal" pos="-0.0021302 0 0">
<inertial pos="-0.00114185 3.47316e-05 0.0228928" quat="0.706728 0.0167051 0.0169055 0.707086" mass="0.00450921" diaginertia="6.74196e-07 6.67877e-07 1.34555e-07"/>
<joint name="index_mcp_pitch" pos="0 0 0" axis="0 1 0" range="0 1.22" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="index_proximal"/>
<geom class="collision" type="mesh" mesh="index_proximal"/>
<body name="index_middle" pos="-0.0041946 0 0.044819">
<inertial pos="-0.000206992 2.60617e-06 0.0167065" quat="0.99852 7.15582e-05 0.054381 -3.89717e-06" mass="0.00237827" diaginertia="2.31148e-07 1.76761e-07 9.46702e-08"/>
<joint name="index_pip" pos="0 0 0" axis="0 1 0" range="0 1.74" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="index_middle"/>
<geom class="collision" type="mesh" mesh="index_middle"/>
<body name="index_distal" pos="0.0028245 0 0.031696">
<inertial pos="-0.00816247 2.27059e-05 0.00811807" quat="0.942747 0.00109864 0.333492 0.00313565" mass="0.0033417" diaginertia="2.07631e-07 1.78094e-07 8.6082e-08"/>
<joint name="index_dip" pos="0 0 0" axis="0 1 0" range="0 1.55" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="index_distal"/>
<geom class="collision" type="mesh" mesh="index_distal"/>
</body>
</body>
</body>
</body>
<body name="middle_metacarpals" pos="0.0038 -0.01175 0.1526">
<inertial pos="-0.007943 -2.93007e-07 0.00220106" quat="0.599922 0.599922 -0.374291 0.374291" mass="0.000895517" diaginertia="4.67031e-08 3.17351e-08 2.85436e-08"/>
<joint name="middle_mcp_roll" pos="0 0 0" axis="1 0 0" range="-0.23 0.23" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="middle_metacarpals"/>
<body name="middle_proximal" pos="-0.0021302 0 0">
<inertial pos="-0.00114185 3.47316e-05 0.0228928" quat="0.706728 0.0167051 0.0169055 0.707086" mass="0.00450921" diaginertia="6.74196e-07 6.67877e-07 1.34555e-07"/>
<joint name="middle_mcp_pitch" pos="0 0 0" axis="0 1 0" range="0 1.22" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="middle_proximal"/>
<geom class="collision" type="mesh" mesh="middle_proximal"/>
<body name="middle_middle" pos="-0.00419458 0 0.0448194">
<inertial pos="-0.000206992 2.60625e-06 0.0167065" quat="0.99852 7.15718e-05 0.054381 -3.89791e-06" mass="0.00237828" diaginertia="2.31148e-07 1.76761e-07 9.46702e-08"/>
<joint name="middle_pip" pos="0 0 0" axis="0 1 0" range="0 1.74" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="middle_middle"/>
<geom class="collision" type="mesh" mesh="middle_middle"/>
<body name="middle_distal" pos="0.0028245 0 0.031696">
<inertial pos="-0.00816247 2.27059e-05 0.00811807" quat="0.942747 0.00109865 0.333492 0.00313567" mass="0.0033417" diaginertia="2.07631e-07 1.78094e-07 8.6082e-08"/>
<joint name="middle_dip" pos="0 0 0" axis="0 1 0" range="0 1.55" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="middle_distal"/>
<geom class="collision" type="mesh" mesh="middle_distal"/>
</body>
</body>
</body>
</body>
<body name="ring_metacarpals" pos="0.0038 0.01175 0.1481">
<inertial pos="-0.007943 -2.94513e-07 0.00220106" quat="0.599922 0.599922 -0.374291 0.374291" mass="0.000895517" diaginertia="4.67031e-08 3.17351e-08 2.85436e-08"/>
<joint name="ring_mcp_roll" pos="0 0 0" axis="1 0 0" range="-0.23 0.23" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="ring_metacarpals"/>
<body name="ring_proximal" pos="-0.0021302 0 0">
<inertial pos="-0.00114185 3.47316e-05 0.0228928" quat="0.706728 0.0167051 0.0169055 0.707086" mass="0.00450921" diaginertia="6.74196e-07 6.67877e-07 1.34555e-07"/>
<joint name="ring_mcp_pitch" pos="0 0 0" axis="0 1 0" range="0 1.22" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="ring_proximal"/>
<geom class="collision" type="mesh" mesh="ring_proximal"/>
<body name="ring_middle" pos="-0.00419458 0 0.0448194">
<inertial pos="-0.000206993 2.60747e-06 0.0167065" quat="0.99852 7.16159e-05 0.054381 -3.90032e-06" mass="0.00237827" diaginertia="2.31148e-07 1.76761e-07 9.46701e-08"/>
<joint name="ring_pip" pos="0 0 0" axis="0 1 0" range="0 1.74" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="ring_middle"/>
<geom class="collision" type="mesh" mesh="ring_middle"/>
<body name="ring_distal" pos="0.0028245 0 0.031696">
<inertial pos="-0.00816247 2.27057e-05 0.00811807" quat="0.942747 0.00109865 0.333492 0.00313572" mass="0.0033417" diaginertia="2.07631e-07 1.78094e-07 8.6082e-08"/>
<joint name="ring_dip" pos="0 0 0" axis="0 1 0" range="0 1.55" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="ring_distal"/>
<geom class="collision" type="mesh" mesh="ring_distal"/>
</body>
</body>
</body>
</body>
<body name="pinky_metacarpals" pos="0.0038 0.03525 0.1436">
<inertial pos="-0.007943 -2.93007e-07 0.00220106" quat="0.599922 0.599922 -0.374291 0.374291" mass="0.000895517" diaginertia="4.67031e-08 3.17351e-08 2.85436e-08"/>
<joint name="pinky_mcp_roll" pos="0 0 0" axis="1 0 0" range="-0.23 0.23" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="pinky_metacarpals"/>
<body name="pinky_proximal" pos="-0.0021302 0 0">
<inertial pos="-0.00114185 3.47316e-05 0.0228928" quat="0.706728 0.0167051 0.0169055 0.707086" mass="0.00450921" diaginertia="6.74196e-07 6.67877e-07 1.34555e-07"/>
<joint name="pinky_mcp_pitch" pos="0 0 0" axis="0 1 0" range="0 1.22" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="pinky_proximal"/>
<geom class="collision" type="mesh" mesh="pinky_proximal"/>
<body name="pinky_middle" pos="-0.0041946 0 0.044819">
<inertial pos="-0.000206993 2.60749e-06 0.0167065" quat="0.99852 7.16152e-05 0.054381 -3.90028e-06" mass="0.00237827" diaginertia="2.31148e-07 1.76761e-07 9.46701e-08"/>
<joint name="pinky_pip" pos="0 0 0" axis="0 1 0" range="0 1.74" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="pinky_middle"/>
<geom class="collision" type="mesh" mesh="pinky_middle"/>
<body name="pinky_distal" pos="0.0028245 0 0.031696">
<inertial pos="-0.00816247 2.27054e-05 0.00811807" quat="0.942747 0.00109863 0.333492 0.00313569" mass="0.0033417" diaginertia="2.07631e-07 1.78094e-07 8.6082e-08"/>
<joint name="pinky_dip" pos="0 0 0" axis="0 1 0" range="0 1.55" actuatorfrcrange="-100 100"/>
<geom class="visual" mesh="pinky_distal"/>
<geom class="collision" type="mesh" mesh="pinky_distal"/>
</body>
</body>
</body>
</body>
</worldbody>
<contact>
<!-- 掌心与各指根/近端:mesh 本就嵌套,排除假碰撞 -->
<exclude body1="hand_base" body2="thumb_metacarpals_base1"/>
<exclude body1="hand_base" body2="thumb_metacarpals_base2"/>
<exclude body1="hand_base" body2="thumb_metacarpals"/>
<exclude body1="hand_base" body2="index_metacarpals"/>
<exclude body1="hand_base" body2="index_proximal"/>
<exclude body1="hand_base" body2="middle_metacarpals"/>
<exclude body1="hand_base" body2="middle_proximal"/>
<exclude body1="hand_base" body2="ring_metacarpals"/>
<exclude body1="hand_base" body2="ring_proximal"/>
<exclude body1="hand_base" body2="pinky_metacarpals"/>
<exclude body1="hand_base" body2="pinky_proximal"/>
<!-- 拇指链相邻 / 隔代假重叠 -->
<exclude body1="thumb_metacarpals_base1" body2="thumb_metacarpals_base2"/>
<exclude body1="thumb_metacarpals_base1" body2="thumb_metacarpals"/>
<exclude body1="thumb_metacarpals_base2" body2="thumb_metacarpals"/>
<exclude body1="thumb_metacarpals" body2="thumb_proximal"/>
<exclude body1="thumb_proximal" body2="thumb_distal"/>
<!-- 食指链 -->
<exclude body1="index_metacarpals" body2="index_proximal"/>
<exclude body1="index_proximal" body2="index_middle"/>
<exclude body1="index_middle" body2="index_distal"/>
<!-- 中指链 -->
<exclude body1="middle_metacarpals" body2="middle_proximal"/>
<exclude body1="middle_proximal" body2="middle_middle"/>
<exclude body1="middle_middle" body2="middle_distal"/>
<!-- 无名指链 -->
<exclude body1="ring_metacarpals" body2="ring_proximal"/>
<exclude body1="ring_proximal" body2="ring_middle"/>
<exclude body1="ring_middle" body2="ring_distal"/>
<!-- 小指链 -->
<exclude body1="pinky_metacarpals" body2="pinky_proximal"/>
<exclude body1="pinky_proximal" body2="pinky_middle"/>
<exclude body1="pinky_middle" body2="pinky_distal"/>
</contact>
<actuator>
<position name="thumb_cmc_roll_pos" joint="thumb_cmc_roll" kp="200" kv="8" ctrlrange="0.0 1.47" forcelimited="true" forcerange="-8 8" />
<position name="thumb_cmc_yaw_pos" joint="thumb_cmc_yaw" kp="200" kv="8" ctrlrange="0.0 1.57" forcelimited="true" forcerange="-8 8" />
<position name="thumb_cmc_pitch_pos" joint="thumb_cmc_pitch" kp="200" kv="8" ctrlrange="0.0 0.84" forcelimited="true" forcerange="-8 8" />
<position name="thumb_mcp_pos" joint="thumb_mcp" kp="200" kv="8" ctrlrange="0.0 1.26" forcelimited="true" forcerange="-8 8" />
<position name="thumb_ip_pos" joint="thumb_ip" kp="200" kv="8" ctrlrange="0.0 1.29" forcelimited="true" forcerange="-8 8" />
<position name="index_mcp_roll_pos" joint="index_mcp_roll" kp="200" kv="8" ctrlrange="-0.23 0.23" forcelimited="true" forcerange="-8 8" />
<position name="index_mcp_pitch_pos" joint="index_mcp_pitch" kp="200" kv="8" ctrlrange="0.0 1.22" forcelimited="true" forcerange="-8 8" />
<position name="index_pip_pos" joint="index_pip" kp="200" kv="8" ctrlrange="0.0 1.74" forcelimited="true" forcerange="-8 8" />
<position name="index_dip_pos" joint="index_dip" kp="200" kv="8" ctrlrange="0.0 1.55" forcelimited="true" forcerange="-8 8" />
<position name="middle_mcp_roll_pos" joint="middle_mcp_roll" kp="200" kv="8" ctrlrange="-0.23 0.23" forcelimited="true" forcerange="-8 8" />
<position name="middle_mcp_pitch_pos" joint="middle_mcp_pitch" kp="200" kv="8" ctrlrange="0.0 1.22" forcelimited="true" forcerange="-8 8" />
<position name="middle_pip_pos" joint="middle_pip" kp="200" kv="8" ctrlrange="0.0 1.74" forcelimited="true" forcerange="-8 8" />
<position name="middle_dip_pos" joint="middle_dip" kp="200" kv="8" ctrlrange="0.0 1.55" forcelimited="true" forcerange="-8 8" />
<position name="ring_mcp_roll_pos" joint="ring_mcp_roll" kp="200" kv="8" ctrlrange="-0.23 0.23" forcelimited="true" forcerange="-8 8" />
<position name="ring_mcp_pitch_pos" joint="ring_mcp_pitch" kp="200" kv="8" ctrlrange="0.0 1.22" forcelimited="true" forcerange="-8 8" />
<position name="ring_pip_pos" joint="ring_pip" kp="200" kv="8" ctrlrange="0.0 1.74" forcelimited="true" forcerange="-8 8" />
<position name="ring_dip_pos" joint="ring_dip" kp="200" kv="8" ctrlrange="0.0 1.55" forcelimited="true" forcerange="-8 8" />
<position name="pinky_mcp_roll_pos" joint="pinky_mcp_roll" kp="200" kv="8" ctrlrange="-0.23 0.23" forcelimited="true" forcerange="-8 8" />
<position name="pinky_mcp_pitch_pos" joint="pinky_mcp_pitch" kp="200" kv="8" ctrlrange="0.0 1.22" forcelimited="true" forcerange="-8 8" />
<position name="pinky_pip_pos" joint="pinky_pip" kp="200" kv="8" ctrlrange="0.0 1.74" forcelimited="true" forcerange="-8 8" />
<position name="pinky_dip_pos" joint="pinky_dip" kp="200" kv="8" ctrlrange="0.0 1.55" forcelimited="true" forcerange="-8 8" />
</actuator>
</mujoco>
@@ -0,0 +1,139 @@
# SPDX-License-Identifier: Apache-2.0
"""G20 关节名 / mimic / SDK0~255→弧度(与 Isaac joint_mapping 对齐)。"""
from __future__ import annotations
# SDK 索引 → URDF 关节名(不含前缀)
L20_SDK_TO_URDF: dict[int, str] = {
0: "thumb_cmc_pitch",
1: "index_mcp_pitch",
2: "middle_mcp_pitch",
3: "ring_mcp_pitch",
4: "pinky_mcp_pitch",
5: "thumb_cmc_roll",
6: "index_mcp_roll",
7: "middle_mcp_roll",
8: "ring_mcp_roll",
9: "pinky_mcp_roll",
10: "thumb_cmc_yaw",
15: "thumb_mcp",
16: "index_pip",
17: "middle_pip",
18: "ring_pip",
19: "pinky_pip",
}
DIP_MIMIC_MULTIPLIER = 0.89
THUMB_IP_MIMIC_MULTIPLIER = 1.02
G20_ACTUATED_JOINT_NAMES: tuple[str, ...] = tuple(
sorted({name for name in L20_SDK_TO_URDF.values()})
)
G20_MIMIC_JOINT_NAMES: tuple[str, ...] = (
"thumb_ip",
"index_dip",
"middle_dip",
"ring_dip",
"pinky_dip",
)
G20_ALL_JOINT_NAMES: tuple[str, ...] = tuple(
sorted(set(G20_ACTUATED_JOINT_NAMES) | set(G20_MIMIC_JOINT_NAMES))
)
G20_MIMIC_OF: dict[str, tuple[str, float]] = {
"thumb_ip": ("thumb_mcp", THUMB_IP_MIMIC_MULTIPLIER),
"index_dip": ("index_pip", DIP_MIMIC_MULTIPLIER),
"middle_dip": ("middle_pip", DIP_MIMIC_MULTIPLIER),
"ring_dip": ("ring_pip", DIP_MIMIC_MULTIPLIER),
"pinky_dip": ("pinky_pip", DIP_MIMIC_MULTIPLIER),
}
# 左手 G20 限位(与 Isaac / URDF 一致);SDK 索引语义同 L20
G20_L_MIN = [0, 0, 0, 0, 0, 0.0, -0.23, -0.23, -0.23, -0.23, 0.0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# 通道5 thumb_cmc_roll:试调 max=1.47(原 URDF/Isaac 为 1.4
G20_L_MAX = [0.84, 1.22, 1.22, 1.22, 1.22, 1.47, 0.23, 0.23, 0.23, 0.23, 1.57, 0, 0, 0, 0, 1.29, 1.55, 1.55, 1.55, 1.55]
# 四指侧摆 (6..9)SDK0→下限、255→上限
G20_L_DIR = [-1, -1, -1, -1, -1, -1, 1, 1, 1, 1, -1, 0, 0, 0, 0, -1, -1, -1, -1, -1]
G20_OPEN_CMD = [
255, 255, 255, 255, 255, 255, 193, 148, 105, 42, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255
]
def g20_required_joint_names() -> frozenset[str]:
return frozenset(G20_ALL_JOINT_NAMES)
def g20_actuated_joint_names() -> frozenset[str]:
return frozenset(G20_ACTUATED_JOINT_NAMES)
def complete_g20_mimic_positions(positions: dict[str, float]) -> dict[str, float]:
"""补全缺失的 mimic 关节;已给出的 mimic 值优先保留。"""
out = dict(positions)
for mimic, (master, ratio) in G20_MIMIC_OF.items():
if mimic in out:
continue
if master in out:
out[mimic] = float(out[master]) * float(ratio)
return out
def scale_value(original_value: float, a_min: float, a_max: float, b_min: float, b_max: float) -> float:
if a_max == a_min:
return b_min
return (original_value - a_min) * (b_max - b_min) / (a_max - a_min) + b_min
def clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
def range_to_arc_g20_left(values: list[float]) -> list[float]:
"""SDK 0~255 → 20 维弧度(预留通道 11~14 保持 0)。"""
arcs = [0.0] * 20
for i in range(20):
if 11 <= i <= 14:
continue
val = clamp(values[i] if i < len(values) else 0.0, 0.0, 255.0)
if G20_L_DIR[i] == -1:
arcs[i] = scale_value(val, 0.0, 255.0, G20_L_MAX[i], G20_L_MIN[i])
else:
arcs[i] = scale_value(val, 0.0, 255.0, G20_L_MIN[i], G20_L_MAX[i])
return arcs
def sdk_range_to_urdf_positions(values: list[float]) -> dict[str, float]:
"""20 维 SDK → URDF 主动关节弧度(与 Isaac joint_mapping 一致)。
SDK 15 控制指尖弧度空间,写入 thumb_mcp = arc/1.02
SDK 16..19 控制 DIP 电机弧度,写入 *_pip = arc/0.89。
"""
arcs = range_to_arc_g20_left(values)
out: dict[str, float] = {}
for sdk_idx, short_name in L20_SDK_TO_URDF.items():
arc = float(arcs[sdk_idx])
if sdk_idx == 15:
out["thumb_mcp"] = arc / THUMB_IP_MIMIC_MULTIPLIER
elif sdk_idx in (16, 17, 18, 19):
out[short_name] = arc / DIP_MIMIC_MULTIPLIER
else:
out[short_name] = arc
return out
def sdk_range_to_full_urdf_positions(values: list[float]) -> dict[str, float]:
"""主动 + mimic,供运动学写入(对齐 Isaac sdk_range_to_full_urdf_positions)。"""
return complete_g20_mimic_positions(sdk_range_to_urdf_positions(values))
def open_hand_positions() -> dict[str, float]:
return sdk_range_to_full_urdf_positions(list(G20_OPEN_CMD))
def half_fist_positions() -> dict[str, float]:
"""半握:主动弯曲通道约中间值,侧摆保持张开。"""
cmd = list(G20_OPEN_CMD)
for i in (0, 1, 2, 3, 4, 15, 16, 17, 18, 19):
cmd[i] = 80.0
return sdk_range_to_full_urdf_positions(cmd)
@@ -14,6 +14,15 @@ setup(
(os.path.join('share', package_name, 'launch'), glob('launch/*.launch.py')), (os.path.join('share', package_name, 'launch'), glob('launch/*.launch.py')),
('share/' + package_name, ['package.xml']), ('share/' + package_name, ['package.xml']),
], ],
package_data={
package_name: [
'urdf/**/*.xml',
'urdf/**/*.urdf',
'urdf/**/*.STL',
'urdf/**/*.stl',
],
},
include_package_data=True,
install_requires=['setuptools'], install_requires=['setuptools'],
zip_safe=True, zip_safe=True,
maintainer='linkerhand', maintainer='linkerhand',
@@ -25,6 +34,7 @@ setup(
'console_scripts': [ 'console_scripts': [
'linker_hand_mujoco_ros2_node=linker_hand_mujoco_ros2.linker_hand_mujoco_ros2:main', 'linker_hand_mujoco_ros2_node=linker_hand_mujoco_ros2.linker_hand_mujoco_ros2:main',
'hand_curve_recorder=linker_hand_mujoco_ros2.hand_curve_recorder:main', 'hand_curve_recorder=linker_hand_mujoco_ros2.hand_curve_recorder:main',
'g20_plan_bridge=linker_hand_mujoco_ros2.g20_plan_bridge:main',
], ],
}, },
) )