Files
isaac_linkerbot/linker_hand_dual_sim.py
sunxianghui 0b727785c6 Add LinkerHand Isaac Sim bridge and G20 assets.
Includes G20 PLAN/cmd_u8 bridge, joint mapping, dual-hand sim scripts,
and G20/L20/O6 URDF with USD payloads for Isaac Sim integration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:13:00 +08:00

1163 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""在 Isaac Sim 中加载 LinkerHand G20 灵巧手,并通过 ROS2 话题控制。
默认仅加载 G20``linkerhand_g20_left`` USD)。
默认 PD 动力学,以体现交付 USD 中的自碰/掌心接触垫;
``--position-drive`` 硬写关节(UI 跟手,但会穿模);
``--hand-gravity`` 开手部重力。
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
LINKERHAND_ROOT = Path(__file__).resolve().parent
URDF_ROOT = LINKERHAND_ROOT / "urdf"
DEFAULT_ASSETS = {
("O6", "left"): URDF_ROOT / "o6/left/linkerhand_o6_left/linkerhand_o6_left.usda",
("G20", "left"): URDF_ROOT / "g20/left/linkerhand_g20_left/linkerhand_g20_left.usda",
}
TOPICS = {
"O6": {
"cmd": "/o6/cb_left_hand_control_cmd",
"state": "/o6/cb_left_hand_state",
},
"G20": {
"cmd": "/cb_left_hand_control_cmd",
"state": "/cb_left_hand_state",
},
}
# 默认 PD:交付 USD 的自碰/掌心垫才会生效。硬写位置用 --position-drive(可穿模)。
DEFAULT_DRIVE_STIFFNESS = 80.0
DEFAULT_DRIVE_DAMPING = 25.0
G20_DRIVE_STIFFNESS = 25.0
G20_DRIVE_DAMPING = 40.0
GRAVITY_STIFFNESS_SCALE = 1.0
GRAVITY_DAMPING_SCALE = 1.2
DEFAULT_ACTUATED_MAX_EFFORT = 8.0
# 每物理步最大目标变化(rad
MAX_TARGET_STEP_RAD = 0.006
CMD_SMOOTH_ALPHA = 0.08
CMD_SETTLE_EPS = 0.5
# 手掌安装高度(米)——抬高避免手指触地冲飞
HAND_MOUNT_Z = 0.70
HAND_MOUNT_Y = 0.45
# 根节点偏离安装位超过该距离视为爆炸并复位
ROOT_EXPLODE_DIST_M = 0.50
# 接触尖峰可达上千 rad/s;硬复位只会更抖。真正爆炸只看 NaN/根漂移。
DOF_VEL_SOFT_CLAMP = 30.0
DOF_VEL_HARD_LIMIT = 1.0e9
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="LinkerHand O6 + G20 dual-hand Isaac Sim ROS2 bridge")
parser.add_argument("--headless", action="store_true", help="无 GUI 运行")
parser.add_argument("--kinematic", action="store_true", help="运动学驱动(无物理,不适合测摩擦)")
parser.add_argument(
"--hand-gravity",
action="store_true",
help="启用手部连杆重力(PD 下生效;硬写位置驱动会忽略)",
)
parser.add_argument(
"--pd-drive",
action="store_true",
help="PD 动力学驱动(默认已开启;与 --position-drive 互斥时以位置驱动为准)",
)
parser.add_argument(
"--self-collision",
action="store_true",
help="强制走 PD 以便体现 USD 自碰(默认 PD 下已启用 USD 自碰)",
)
parser.add_argument(
"--anti-penetration",
action="store_true",
help="兼容旧参数:碰撞/自碰已烘焙进 USD,无需再开",
)
parser.add_argument(
"--no-collision",
action="store_true",
help="关闭全部碰撞几何(调试用)",
)
parser.add_argument(
"--position-drive",
action="store_true",
help="硬写关节位置(UI 跟手,但会穿模;忽略 USD 自碰)",
)
parser.add_argument("--drive-stiffness", type=float, default=DEFAULT_DRIVE_STIFFNESS)
parser.add_argument("--drive-damping", type=float, default=DEFAULT_DRIVE_DAMPING)
parser.add_argument("--g20-drive-stiffness", type=float, default=G20_DRIVE_STIFFNESS)
parser.add_argument("--g20-drive-damping", type=float, default=G20_DRIVE_DAMPING)
parser.add_argument("--cmd-smooth", type=float, default=CMD_SMOOTH_ALPHA, help="指令低通 0~1,越大越跟手")
parser.add_argument("--o6-usd", type=Path, default=DEFAULT_ASSETS[("O6", "left")])
parser.add_argument("--g20-usd", type=Path, default=DEFAULT_ASSETS[("G20", "left")])
parser.add_argument("--l20-usd", type=Path, dest="g20_usd", help=argparse.SUPPRESS)
parser.add_argument("--o6-side", choices=["left", "right"], default="left")
parser.add_argument("--g20-side", choices=["left", "right"], default="left")
parser.add_argument("--o6-only", action="store_true", help="仅加载 O6")
parser.add_argument("--g20-only", action="store_true", help="仅加载 G20")
parser.add_argument("--both", action="store_true", help="同时加载 O6 + G20")
parser.add_argument("--l20-side", choices=["left", "right"], dest="g20_side", help=argparse.SUPPRESS)
parser.add_argument("--max-steps", type=int, default=0, help="运行指定步数后退出,0 表示持续运行")
parser.add_argument(
"--hold-open",
action="store_true",
help="忽略外部 control_cmd,始终保持 SDK 张开姿态(排查漂移/外部节点干扰)",
)
parser.add_argument(
"--stability-test",
action="store_true",
help="跑固定步数并打印 G20 状态漂移统计后退出(自动 hold-open",
)
parser.add_argument(
"--motion-test",
action="store_true",
help="开合循环压力测试(内部切指令,不依赖外部 ROS pub)",
)
return parser.parse_args()
args = parse_args()
from isaacsim import SimulationApp
simulation_app = SimulationApp({"headless": args.headless})
import numpy as np
import isaacsim.core.experimental.utils.app as app_utils
import isaacsim.core.experimental.utils.prim as prim_utils
import isaacsim.core.experimental.utils.stage as stage_utils
from isaacsim.core.experimental.objects import DistantLight, GroundPlane
from isaacsim.core.experimental.prims import Articulation, XformPrim
from isaacsim.core.rendering_manager import RenderingManager
from isaacsim.core.simulation_manager import SimulationManager
from isaacsim.core.utils.viewports import set_camera_view
sys.path.insert(0, str(LINKERHAND_ROOT))
from joint_mapping import (
G20_OPEN_CMD,
O6_SDK_JOINTS,
mimic_joint_names,
open_hand_command,
sdk_range_to_full_urdf_positions,
sdk_range_to_urdf_positions,
urdf_positions_to_sdk_range,
)
app_utils.enable_extension("isaacsim.ros2.bridge")
simulation_app.update()
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
class HandBridge:
"""单只手的 ROS2 ↔ Articulation 桥。"""
def __init__(
self,
node: Node,
articulation: Articulation,
hand: str,
side: str,
cmd_topic: str,
state_topic: str,
*,
kinematic: bool,
cmd_smooth: float,
hold_open: bool = False,
use_physics_drive: bool = False,
) -> None:
self.node = node
self.art = articulation
self.hand = hand
self.mapping_hand = hand if hand in ("O6", "G20", "L20") else "L20"
self.side = side
self.kinematic = kinematic
self.hold_open = hold_open
self.use_physics_drive = use_physics_drive
self.cmd_smooth = float(np.clip(cmd_smooth, 0.0, 1.0))
self.dof_count = 6 if hand == "O6" else 20
open_cmd = open_hand_command(hand)
self.cmd_values = list(open_cmd)
self.smoothed_cmd = list(open_cmd)
self._last_applied_key: tuple[float, ...] | None = None
self._slew_targets: dict[int, float] = {}
self._mount_position = None # set by main after load
self.dof_names = list(articulation.dof_names)
self.dof_index = {name: i for i, name in enumerate(self.dof_names)}
self.mimic_dofs = mimic_joint_names(self.mapping_hand, side)
self.actuated_dof_indices = [
i for i, name in enumerate(self.dof_names) if name not in self.mimic_dofs
]
self._actuated_names = {self.dof_names[i] for i in self.actuated_dof_indices}
lower, upper = articulation.get_dof_limits()
self._limit_lo = lower.numpy().flatten()
self._limit_hi = upper.numpy().flatten()
if not hold_open:
self.sub = node.create_subscription(JointState, cmd_topic, self._on_cmd, 10)
self.pub = node.create_publisher(JointState, state_topic, 10)
self._recover_cooldown = 0
self.explode_count = 0
self.max_root_drift = 0.0
mode = "kinematic" if kinematic else "dynamic(PD)"
hold_note = ", hold-open" if hold_open else ""
drive_note = "PD" if use_physics_drive else "position"
node.get_logger().info(
f"{hand} ({side}) [{mode}{hold_note}, drive={drive_note}]: sub {cmd_topic}, "
f"actuated={len(self.actuated_dof_indices)} mimic={len(self.mimic_dofs)}"
)
def set_sdk_command(self, values: list[float]) -> None:
"""内部压力测试:直接写 SDK 指令(绕过 ROS)。"""
n = min(len(values), self.dof_count)
self.cmd_values[:n] = [float(v) for v in values[:n]]
def _on_cmd(self, msg: JointState) -> None:
if self.hold_open or not msg.position:
return
n = min(len(msg.position), self.dof_count)
incoming = [float(v) for v in msg.position[:n]]
if all(abs(incoming[i] - self.cmd_values[i]) < CMD_SETTLE_EPS for i in range(n)):
return
self.cmd_values[:n] = incoming
def _smooth_commands(self) -> None:
if self.hold_open:
self.cmd_values = list(open_hand_command(self.hand))
self.smoothed_cmd = list(self.cmd_values)
return
a = self.cmd_smooth
if a <= 0.0:
self.smoothed_cmd = list(self.cmd_values)
return
for i in range(self.dof_count):
self.smoothed_cmd[i] = (1.0 - a) * self.smoothed_cmd[i] + a * self.cmd_values[i]
if max(abs(self.smoothed_cmd[i] - self.cmd_values[i]) for i in range(self.dof_count)) < CMD_SETTLE_EPS:
self.smoothed_cmd = list(self.cmd_values)
def _resolve_targets(self) -> dict[str, float]:
# 位置驱动/运动学:写全关节(含 mimic,比率需与 URDF 一致)
if self.kinematic or not self.use_physics_drive:
return sdk_range_to_full_urdf_positions(self.smoothed_cmd, self.mapping_hand, self.side)
return sdk_range_to_urdf_positions(self.smoothed_cmd, self.mapping_hand, self.side)
def _targets_to_dof_lists(self, targets: dict[str, float]) -> tuple[list[int], list[float]]:
dof_indices: list[int] = []
positions: list[float] = []
for joint_name, rad in targets.items():
idx = self._resolve_dof_index(joint_name)
if idx is None:
continue
lo = float(self._limit_lo[idx])
hi = float(self._limit_hi[idx])
if hi > lo:
rad = float(np.clip(rad, lo, hi))
# 斜坡限制:相对上一帧已下发目标,避免大跳
prev = self._slew_targets.get(idx, rad)
delta = rad - prev
if abs(delta) > MAX_TARGET_STEP_RAD:
rad = prev + float(np.sign(delta) * MAX_TARGET_STEP_RAD)
if hi > lo:
rad = float(np.clip(rad, lo, hi))
self._slew_targets[idx] = rad
dof_indices.append(idx)
positions.append(rad)
return dof_indices, positions
def _is_exploded(self) -> tuple[bool, str]:
"""返回 (是否需硬复位, 原因)。速度尖峰单独处理,不触发张开复位。"""
try:
root = self.art.get_world_poses()[0]
root = root.numpy().flatten() if hasattr(root, "numpy") else np.asarray(root).flatten()
if np.isnan(root).any() or np.isinf(root).any():
return True, "root_nan"
if self._mount_position is not None:
mount = np.asarray(self._mount_position, dtype=float)
drift = float(np.linalg.norm(root[:3] - mount))
self.max_root_drift = max(self.max_root_drift, drift)
limit = ROOT_EXPLODE_DIST_M if self.use_physics_drive else 5.0
if drift > limit:
return True, f"root_drift={drift:.3f}m"
elif float(np.max(np.abs(root))) > 5.0:
return True, "root_far"
pos = self.art.get_dof_positions()
pos = pos.numpy().flatten() if hasattr(pos, "numpy") else np.asarray(pos).flatten()
if np.isnan(pos).any() or np.isinf(pos).any():
return True, "dof_pos_nan"
if self.use_physics_drive:
try:
vel = self.art.get_dof_velocities()
vel = vel.numpy().flatten() if hasattr(vel, "numpy") else np.asarray(vel).flatten()
if np.isnan(vel).any() or np.isinf(vel).any():
return True, "dof_vel_nan"
max_abs = float(np.max(np.abs(vel))) if len(vel) else 0.0
if max_abs > DOF_VEL_HARD_LIMIT:
return True, f"dof_vel={max_abs:.1f}"
if max_abs > DOF_VEL_SOFT_CLAMP:
# 自碰接触尖峰:只清速度,不当成爆炸
try:
self.art.set_dof_velocities([0.0] * len(vel))
except Exception:
pass
return False, f"vel_spike={max_abs:.1f}_clamped"
except Exception:
pass
except Exception as exc:
return True, f"exception:{exc}"
return False, ""
def _pin_root(self) -> None:
"""把掌根钉回安装位(仅硬写位置驱动备份;PD+WorldFixedJoint 时不要调)。"""
if self._mount_position is None or self.use_physics_drive:
return
try:
self.art.set_world_poses(
positions=[self._mount_position],
orientations=[[1.0, 0.0, 0.0, 0.0]],
)
if hasattr(self.art, "set_velocities"):
self.art.set_velocities(
linear_velocities=[[0.0, 0.0, 0.0]],
angular_velocities=[[0.0, 0.0, 0.0]],
)
except Exception as exc:
self.node.get_logger().warn(f"{self.hand}: _pin_root failed: {exc}")
def recover_if_exploded(self) -> bool:
"""物理爆炸后复位到张开姿态。返回是否发生了硬复位。"""
if getattr(self, "_recover_cooldown", 0) > 0:
self._recover_cooldown -= 1
if not self.use_physics_drive:
self._pin_root()
# PD 下若仍 NaN,必须再 snap 一次,否则冷却期结束后会立刻再爆
try:
pos = self.art.get_dof_positions()
pos = pos.numpy().flatten() if hasattr(pos, "numpy") else np.asarray(pos).flatten()
if np.isnan(pos).any() or np.isinf(pos).any():
self.initialize_pose()
self.art.set_dof_velocities([0.0] * int(self.art.num_dofs))
except Exception:
try:
self.initialize_pose()
except Exception:
pass
return True
exploded, reason = self._is_exploded()
if not exploded:
return False
try:
root = self.art.get_world_poses()[0]
root = root.numpy().flatten() if hasattr(root, "numpy") else np.asarray(root).flatten()
root_txt = [round(float(x), 3) for x in root[:3]]
except Exception:
root_txt = ["?"]
self.node.get_logger().warn(
f"{self.hand}: 物理爆炸复位 reason={reason} root={root_txt}"
)
self.explode_count += 1
self.cmd_values = list(open_hand_command(self.hand))
self.smoothed_cmd = list(self.cmd_values)
self._slew_targets.clear()
self._last_applied_key = None
self._pin_root()
try:
n = self.art.num_dofs
if n > 0:
self.art.set_dof_velocities([0.0] * n)
except Exception:
pass
self.initialize_pose()
self._pin_root()
self._recover_cooldown = 30
return True
def apply_targets(self) -> None:
if self.recover_if_exploded():
return
# 位置驱动:每帧钉掌根(O6 无 WorldFixedJointG20 作备份)
if not self.use_physics_drive:
self._pin_root()
self._smooth_commands()
targets = self._resolve_targets()
dof_indices, positions = self._targets_to_dof_lists(targets)
if not dof_indices:
return
apply_key = tuple(round(p, 5) for p in positions)
self._last_applied_key = apply_key
if self.kinematic or not self.use_physics_drive:
self.art.set_dof_positions(positions, dof_indices=dof_indices)
else:
self.art.set_dof_position_targets(positions, dof_indices=dof_indices)
def _read_sdk_state(self) -> list[float]:
dof_pos = self.art.get_dof_positions()
if hasattr(dof_pos, "numpy"):
pos_array = dof_pos.numpy().flatten()
else:
pos_array = np.asarray(dof_pos).flatten()
joint_pos_map: dict[str, float] = {}
for name in self._actuated_names:
idx = self.dof_index.get(name)
if idx is not None and idx < len(pos_array):
joint_pos_map[name] = float(pos_array[idx])
return [float(v) for v in urdf_positions_to_sdk_range(joint_pos_map, self.hand, self.side)]
def publish_state(self) -> None:
sdk_range = self._read_sdk_state()
msg = JointState()
msg.header.stamp = self.node.get_clock().now().to_msg()
if self.hand == "O6":
msg.name = list(O6_SDK_JOINTS)
else:
msg.name = [f"motor_{i}" for i in range(20)]
msg.position = sdk_range
self.pub.publish(msg)
def _resolve_dof_index(self, joint_name: str) -> int | None:
if joint_name in self.dof_index:
return self.dof_index[joint_name]
for name, idx in self.dof_index.items():
if name.endswith(joint_name) or name.split("/")[-1] == joint_name:
return idx
return None
def snap_pose(self) -> None:
"""强制写入关节角与 PD 目标(初始化/复位)。"""
if self.hold_open:
self.smoothed_cmd = list(open_hand_command(self.hand))
self.cmd_values = list(self.smoothed_cmd)
# snap 时跳过斜坡,直接到位
self._slew_targets.clear()
targets = self._resolve_targets()
# 直接限幅,不走斜坡字典预填
dof_indices: list[int] = []
positions: list[float] = []
for joint_name, rad in targets.items():
idx = self._resolve_dof_index(joint_name)
if idx is None:
continue
lo = float(self._limit_lo[idx])
hi = float(self._limit_hi[idx])
if hi > lo:
rad = float(np.clip(rad, lo, hi))
self._slew_targets[idx] = rad
dof_indices.append(idx)
positions.append(rad)
if not dof_indices:
return
self.art.set_dof_positions(positions, dof_indices=dof_indices)
if not self.kinematic and self.use_physics_drive:
self.art.set_dof_position_targets(positions, dof_indices=dof_indices)
self._last_applied_key = tuple(round(p, 5) for p in positions)
def initialize_pose(self) -> None:
self.snap_pose()
if not self.kinematic and self.use_physics_drive and self.actuated_dof_indices:
self.art.set_dof_velocities(
[0.0] * len(self.actuated_dof_indices),
dof_indices=self.actuated_dof_indices,
)
def _configure_drives(
articulation: Articulation,
hand: str,
side: str,
*,
kinematic: bool,
stiffness: float,
damping: float,
max_effort: float = DEFAULT_ACTUATED_MAX_EFFORT,
) -> None:
mapping_hand = "O6" if hand == "O6" else "L20"
mimics = mimic_joint_names(mapping_hand, side)
actuated: list[int] = []
mimic_indices: list[int] = []
for i, name in enumerate(articulation.dof_names):
(mimic_indices if name in mimics else actuated).append(i)
if kinematic:
n = articulation.num_dofs
if n > 0:
articulation.set_dof_gains(stiffnesses=[0.0] * n, dampings=[0.0] * n)
return
# 位置驱动遥操作:增益必须为 0,否则 PD 与 set_dof_positions 硬刚导致爆炸
if stiffness <= 0.0 and damping <= 0.0:
n = articulation.num_dofs
if n > 0:
articulation.set_dof_gains(stiffnesses=[0.0] * n, dampings=[0.0] * n)
return
if actuated:
articulation.set_dof_gains(
stiffnesses=[stiffness] * len(actuated),
dampings=[damping] * len(actuated),
dof_indices=actuated,
)
articulation.set_dof_max_efforts(
[max_effort] * len(actuated),
dof_indices=actuated,
)
if mimic_indices:
# 不要把 mimic 的 stiffness 设为 0:部分后端会破坏 mimic 约束,连带整手塌陷
articulation.set_dof_max_efforts(
[max_effort] * len(mimic_indices),
dof_indices=mimic_indices,
)
def _author_self_collisions(hand_path: str, enabled: bool) -> None:
"""在 ArticulationRoot 上写入 PhysX/Newton 自碰,并配置碰撞体。
enabled=True 时:关掉掌心/CMC 肥凸包,只保留手指段碰撞,降低自碰爆炸概率。
"""
from pxr import Sdf, Usd, UsdPhysics
stage = stage_utils.get_current_stage()
hand_prim = stage.GetPrimAtPath(hand_path)
if not hand_prim.IsValid():
return
try:
from pxr import PhysxSchema
has_physx_schema = True
except ImportError:
has_physx_schema = False
# 这些碰撞体与拇指运动包络严重重叠,开自碰必顶死/NaN
disable_col_names = {
"hand_base_link",
"thumb_metacarpals_base1",
"thumb_metacarpals_base2",
"thumb_metacarpals",
"index_metacarpals",
"middle_metacarpals",
"ring_metacarpals",
"pinky_metacarpals",
}
predicate = Usd.TraverseInstanceProxies(Usd.PrimAllPrimsPredicate)
n_roots = 0
n_col_on = 0
n_col_off = 0
for prim in Usd.PrimRange(hand_prim, predicate):
if prim.HasAPI(UsdPhysics.ArticulationRootAPI) or prim.HasAPI("PhysicsArticulationRootAPI"):
if has_physx_schema:
PhysxSchema.PhysxArticulationAPI.Apply(prim)
attr = prim.GetAttribute("physxArticulation:enabledSelfCollisions")
if not attr:
attr = prim.CreateAttribute(
"physxArticulation:enabledSelfCollisions", Sdf.ValueTypeNames.Bool
)
attr.Set(bool(enabled))
nattr = prim.GetAttribute("newton:selfCollisionEnabled")
if not nattr:
nattr = prim.CreateAttribute("newton:selfCollisionEnabled", Sdf.ValueTypeNames.Bool)
nattr.Set(bool(enabled))
n_roots += 1
if (
prim.HasAPI(UsdPhysics.CollisionAPI)
or prim.HasAPI("PhysicsCollisionAPI")
or prim.HasAPI("PhysicsMeshCollisionAPI")
):
name = prim.GetName()
col_on = True
if enabled and name in disable_col_names:
col_on = False
try:
if prim.HasAPI(UsdPhysics.CollisionAPI):
UsdPhysics.CollisionAPI(prim).CreateCollisionEnabledAttr().Set(col_on)
else:
attr = prim.GetAttribute("physics:collisionEnabled")
if not attr:
attr = prim.CreateAttribute(
"physics:collisionEnabled", Sdf.ValueTypeNames.Bool
)
attr.Set(col_on)
if col_on:
n_col_on += 1
else:
n_col_off += 1
except Exception:
pass
print(
f"[stabilize] authored self_collision={enabled} roots={n_roots} "
f"col_on={n_col_on} col_off={n_col_off} under {hand_path}"
)
if enabled:
_filter_thumb_palm_collisions(hand_path)
def _filter_thumb_palm_collisions(hand_path: str) -> None:
"""拇指链与掌根凸包在 CMC 处天然重叠;PhysX 只自动忽略父子连杆。
不过滤时自碰会顶住拇指。处理:
1) 关掉 CMC 中间连杆 + 掌根碰撞体(显示 mesh 仍在;物理不互顶)
2) palm↔拇指 FilteredPairs
四指之间自碰仍保留,用来减轻拇指穿其它指。
"""
from pxr import Usd, UsdPhysics
stage = stage_utils.get_current_stage()
hand_prim = stage.GetPrimAtPath(hand_path)
if not hand_prim.IsValid():
return
predicate = Usd.TraverseInstanceProxies(Usd.PrimAllPrimsPredicate)
palm = None
thumb_links: list = []
cmc_disabled = 0
palm_col_off = 0
for prim in Usd.PrimRange(hand_prim, predicate):
name = prim.GetName()
if prim.HasAPI(UsdPhysics.CollisionAPI) and name in (
"thumb_metacarpals_base1",
"thumb_metacarpals_base2",
"hand_base_link",
):
try:
UsdPhysics.CollisionAPI(prim).CreateCollisionEnabledAttr().Set(False)
if name == "hand_base_link":
palm_col_off += 1
else:
cmc_disabled += 1
except Exception as exc:
print(f"[stabilize] collision disable failed {prim.GetPath()}: {exc}")
if not prim.HasAPI(UsdPhysics.RigidBodyAPI):
continue
if name in ("hand_base_link", "base_link", "palm"):
palm = prim
elif name.startswith("thumb_"):
thumb_links.append(prim)
if palm is None or not thumb_links:
print(
f"[stabilize] thumb-palm filter skipped "
f"(palm={palm is not None}, thumbs={len(thumb_links)}, "
f"cmc_off={cmc_disabled}, palm_col_off={palm_col_off})"
)
return
api = UsdPhysics.FilteredPairsAPI.Apply(palm)
rel = api.CreateFilteredPairsRel()
existing = {str(p) for p in (rel.GetTargets() or [])}
added = 0
for link in thumb_links:
path = link.GetPath()
if str(path) in existing:
continue
rel.AddTarget(path)
other = UsdPhysics.FilteredPairsAPI.Apply(link)
other_rel = other.CreateFilteredPairsRel()
if palm.GetPath() not in (other_rel.GetTargets() or []):
other_rel.AddTarget(palm.GetPath())
added += 1
print(
f"[stabilize] palm↔thumb filter pairs={added} cmc_off={cmc_disabled} "
f"palm_col_off={palm_col_off} thumbs={[t.GetName() for t in thumb_links]}"
)
def _stabilize_articulation(
articulation: Articulation,
*,
enable_gravity: bool,
disable_collisions: bool = False,
enable_self_collisions: bool = False,
) -> None:
"""动力学仿真稳定性:默认保留碰撞;自碰需配合 PD 位置目标才有效。"""
try:
articulation.set_enabled_self_collisions([bool(enable_self_collisions)])
except Exception as exc:
print(f"[stabilize] set_enabled_self_collisions failed: {exc}")
articulation.set_link_enabled_gravities(enable_gravity)
if disable_collisions:
from pxr import Usd, UsdPhysics
try:
link_groups = articulation.link_paths
except Exception:
link_groups = []
stage = stage_utils.get_current_stage()
predicate = Usd.TraverseInstanceProxies(Usd.PrimAllPrimsPredicate)
n_disabled = 0
for group in link_groups:
for path in group:
prim = stage.GetPrimAtPath(path)
if not prim.IsValid():
continue
for child in Usd.PrimRange(prim, predicate):
if child.HasAPI(UsdPhysics.CollisionAPI):
try:
UsdPhysics.CollisionAPI(child).CreateCollisionEnabledAttr().Set(False)
n_disabled += 1
except Exception:
pass
print(f"[stabilize] disabled {n_disabled} collision APIs under articulation")
else:
print(
f"[stabilize] collisions ON, self_collision={'ON' if enable_self_collisions else 'OFF'}"
)
def _patch_joint_max_forces(hand_path: str, max_force: float = DEFAULT_ACTUATED_MAX_EFFORT) -> None:
"""URDF 转换默认 maxForce=1,直接改 USD 驱动上限。"""
from pxr import Usd, UsdPhysics
stage = stage_utils.get_current_stage()
root = stage.GetPrimAtPath(hand_path)
if not root.IsValid():
return
for prim in Usd.PrimRange(root):
for drive_name in ("angular", "linear"):
if not prim.HasAPI(UsdPhysics.DriveAPI, drive_name):
continue
drive = UsdPhysics.DriveAPI(prim, drive_name)
attr = drive.GetMaxForceAttr()
if attr:
attr.Set(float(max_force))
stiff = drive.GetStiffnessAttr()
damp = drive.GetDampingAttr()
# 仅保证属性存在;实际增益仍由 Articulation.set_dof_gains 写入
if stiff and stiff.Get() is None:
stiff.Set(0.0)
if damp and damp.Get() is None:
damp.Set(0.0)
def _disable_floating_root_joint(hand_path: str) -> None:
"""禁用资产内无效 root_jointbody0 非刚体)。"""
from pxr import Usd, UsdPhysics
stage = stage_utils.get_current_stage()
hand_prim = stage.GetPrimAtPath(hand_path)
if not hand_prim.IsValid():
return
for prim in Usd.PrimRange(hand_prim):
if prim.GetName() == "root_joint" and prim.IsA(UsdPhysics.Joint):
prim.SetActive(False)
print(f"[anchor] disabled floating {prim.GetPath()}")
return
def _find_root_rigid_body(hand_prim) -> object | None:
"""找掌根刚体:优先 hand_base_link / base_link,否则第一个 RigidBody。"""
from pxr import Usd, UsdPhysics
preferred = None
first = None
for prim in Usd.PrimRange(hand_prim):
if not prim.HasAPI(UsdPhysics.RigidBodyAPI):
continue
if first is None:
first = prim
name = prim.GetName()
if name in ("hand_base_link", "base_link", "palm"):
preferred = prim
break
return preferred or first
def _fix_base_to_world(hand_path: str) -> None:
"""把掌根钉到当前世界位姿:优先改写 USD 内 root_joint 的 localPos0/Rot0。
交付 USD 的 root_joint 已是 body0=world、body1=palm;安装位姿由场景 Xform
决定,因此这里只写入世界坐标,不另造 WorldFixedJoint(避免双固定)。
"""
from pxr import Gf, Usd, UsdGeom, UsdPhysics
stage = stage_utils.get_current_stage()
hand_prim = stage.GetPrimAtPath(hand_path)
if not hand_prim.IsValid():
return
base = _find_root_rigid_body(hand_prim)
if base is None:
print(f"[anchor] no RigidBody under {hand_path}")
return
xf = UsdGeom.Xformable(base).ComputeLocalToWorldTransform(Usd.TimeCode.Default())
pos = xf.ExtractTranslation()
quat = xf.ExtractRotationQuat()
pos_f = Gf.Vec3f(float(pos[0]), float(pos[1]), float(pos[2]))
rot_f = Gf.Quatf(float(quat.GetReal()), *[float(v) for v in quat.GetImaginary()])
for prim in Usd.PrimRange(hand_prim):
if prim.GetName() != "root_joint" or not prim.IsA(UsdPhysics.Joint):
continue
joint = UsdPhysics.Joint(prim)
b1 = [str(t) for t in (joint.GetBody1Rel().GetTargets() or [])]
b0 = [str(t) for t in (joint.GetBody0Rel().GetTargets() or [])]
if str(base.GetPath()) not in b1 or len(b0) != 0:
prim.SetActive(False)
print(f"[anchor] disabled invalid {prim.GetPath()} body0={b0} body1={b1}")
continue
if not prim.IsActive():
prim.SetActive(True)
joint.CreateLocalPos0Attr().Set(pos_f)
joint.CreateLocalRot0Attr().Set(rot_f)
joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0, 0.0, 0.0))
joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0, 0.0, 0.0, 0.0))
print(
f"[anchor] USD root_joint {prim.GetPath()} -> {base.GetPath()} @ "
f"({float(pos[0]):.3f},{float(pos[1]):.3f},{float(pos[2]):.3f})"
)
return
# Fallback if asset has no usable root_joint
joint_path = f"{hand_path}/WorldFixedJoint"
if stage.GetPrimAtPath(joint_path).IsValid():
stage.RemovePrim(joint_path)
joint = UsdPhysics.FixedJoint.Define(stage, joint_path)
joint.CreateBody0Rel().SetTargets([])
joint.CreateBody1Rel().SetTargets([base.GetPath()])
joint.CreateLocalPos0Attr().Set(pos_f)
joint.CreateLocalRot0Attr().Set(rot_f)
joint.CreateLocalPos1Attr().Set(Gf.Vec3f(0.0, 0.0, 0.0))
joint.CreateLocalRot1Attr().Set(Gf.Quatf(1.0, 0.0, 0.0, 0.0))
joint.CreateExcludeFromArticulationAttr().Set(False)
print(
f"[anchor] WorldFixedJoint {joint_path} -> {base.GetPath()} @ "
f"({float(pos[0]):.3f},{float(pos[1]):.3f},{float(pos[2]):.3f})"
)
def _load_hand_reference(usd_path: Path, mount_path: str, position: list[float]) -> str:
"""加载手资产引用,返回 hand_path(此时尚未创建 Articulation)。"""
if not usd_path.is_file():
raise FileNotFoundError(f"USD 不存在: {usd_path}")
stage_utils.define_prim(mount_path, "Xform")
XformPrim(mount_path, positions=[position], reset_xform_op_properties=True)
hand_path = f"{mount_path}/hand"
stage_utils.add_reference_to_stage(usd_path=str(usd_path), path=hand_path)
prim_utils.set_prim_variants(hand_path, variants=[("Physics", "physx")])
_patch_joint_max_forces(hand_path)
return hand_path
def _print_g20_stability_report(samples: list[list[float]]) -> bool:
"""Return True if G20 state is stable enough."""
import statistics
open_pose = [float(v) for v in G20_OPEN_CMD]
if len(samples) < 5:
print(f"G20 stability: insufficient samples ({len(samples)})")
return False
max_step = 0.0
for i in range(1, len(samples)):
for m in range(20):
max_step = max(max_step, abs(samples[i][m] - samples[i - 1][m]))
motor_std = [statistics.pstdev(s[m] for s in samples) for m in range(20)]
max_dev = max(max(abs(s[m] - open_pose[m]) for s in samples) for m in range(20))
unstable = [m for m, std in enumerate(motor_std) if std > 1.0]
print("=== G20 stability report ===")
print(f"samples={len(samples)} max_step_delta={max_step:.2f} max_dev_open={max_dev:.2f}")
print(f"unstable_motors(std>1): {unstable}")
print(f"first: {[round(v, 1) for v in samples[0]]}")
print(f"last: {[round(v, 1) for v in samples[-1]]}")
ok = max_step <= 2.0 and len(unstable) == 0 and max_dev <= 80.0
print("RESULT:", "PASS" if ok else "FAIL")
return ok
def main() -> None:
stage_utils.create_new_stage()
stage_utils.set_stage_units(meters_per_unit=1.0)
GroundPlane("/World/GroundPlane", positions=[0.0, 0.0, 0.0])
light = DistantLight("/World/DistantLight")
light.set_intensities(300.0)
set_camera_view(eye=[0.55, 0.0, 0.55], target=[0.0, 0.0, 0.35], camera_prim_path="/OmniverseKit_Persp")
# 默认只加载 G20--o6-only / --both 可改
if args.both:
load_o6 = load_g20 = True
elif args.o6_only:
load_o6, load_g20 = True, False
else:
# --g20-only 或未指定:仅 G20
load_o6, load_g20 = False, True
o6_path = (
_load_hand_reference(args.o6_usd, "/World/O6Hand", [0.0, -HAND_MOUNT_Y, HAND_MOUNT_Z])
if load_o6
else None
)
g20_path = (
_load_hand_reference(args.g20_usd, "/World/G20Hand", [0.0, HAND_MOUNT_Y, HAND_MOUNT_Z])
if load_g20
else None
)
SimulationManager.set_physics_dt(1.0 / 120.0)
app_utils.play()
simulation_app.update()
# play 之后再钉世界固定关节(此时世界位姿已正确)
if o6_path:
_disable_floating_root_joint(o6_path)
if g20_path:
_fix_base_to_world(g20_path)
# 碰撞/自碰已写进交付 USD;此处不再运行时改 mesh
# 仅在显式 --no-collision 时关掉
want_self_collision_early = False
if args.anti_penetration:
print(
"[stabilize] 碰撞/自碰已烘焙进 USD"
f"{args.g20_usd if hasattr(args,'g20_usd') else 'g20'}"
"请直接加载该资产。运行时勿再改 collision。"
)
args.anti_penetration = False
app_utils.stop()
simulation_app.update()
app_utils.play()
simulation_app.update()
# 不再调用 _author_self_collisions(交付以 USD 为准)
app_utils.stop()
simulation_app.update()
app_utils.play()
simulation_app.update()
# 必须在最终 play 之后创建 Articulation,否则 tensor 句柄会过期
o6 = Articulation(o6_path) if o6_path else None
g20 = Articulation(g20_path) if g20_path else None
hand_configs = []
if load_o6:
hand_configs.append((o6, "O6", args.o6_side))
if load_g20:
hand_configs.append((g20, "G20", args.g20_side))
# 硬写 set_dof_positions 会穿透接触;自碰必须用 PD 目标
# 默认 PD(交付 USD 自碰生效);仅 --position-drive / --kinematic 关闭
want_self_collision = (not args.no_collision) and (not args.kinematic)
use_physics_drive_early = (not args.kinematic) and (not args.position_drive)
if args.position_drive:
print(
"[stabilize] --position-drive:硬写关节,USD 自碰无法阻止穿模。"
"防拇指穿掌请去掉该参数(默认 PD)。"
)
if args.self_collision and args.position_drive:
print("[stabilize] --self-collision 与 --position-drive 冲突,改用 PD")
use_physics_drive_early = True
if args.hand_gravity and not use_physics_drive_early:
print(
"[warn] --hand-gravity 在硬写位置驱动下已忽略。"
"测重力/摩擦: 默认 PD + --hand-gravity(不要加 --position-drive"
)
enable_link_gravity = bool(args.hand_gravity) and use_physics_drive_early
for art, hand, side in hand_configs:
if use_physics_drive_early:
stiff = args.drive_stiffness
damp = args.drive_damping
if hand == "G20":
stiff = args.g20_drive_stiffness
damp = args.g20_drive_damping
if enable_link_gravity:
stiff *= GRAVITY_STIFFNESS_SCALE
damp *= GRAVITY_DAMPING_SCALE
effort = DEFAULT_ACTUATED_MAX_EFFORT
else:
# 位置驱动:零增益,只靠 set_dof_positions
stiff = 0.0
damp = 0.0
effort = DEFAULT_ACTUATED_MAX_EFFORT
_configure_drives(
art,
hand,
side,
kinematic=args.kinematic,
stiffness=stiff,
damping=damp,
max_effort=effort,
)
if hand == "G20" and g20_path:
# 已在 stop/play 前写入;此处再刷一遍 API 状态
try:
art.set_enabled_self_collisions([want_self_collision])
except Exception:
pass
elif hand == "O6" and o6_path:
try:
art.set_enabled_self_collisions([want_self_collision])
except Exception:
pass
_stabilize_articulation(
art,
enable_gravity=enable_link_gravity,
# 尊重 USD 碰撞;仅 --no-collision 时关掉
disable_collisions=bool(args.no_collision),
enable_self_collisions=want_self_collision and use_physics_drive_early,
)
simulation_app.update()
rclpy.init()
ros_node = Node("linker_hand_isaac_sim")
# 与上面一致:自碰开启时必须用 PD 目标
use_physics_drive = use_physics_drive_early
bridge_kwargs = {
"kinematic": args.kinematic,
"cmd_smooth": args.cmd_smooth,
"hold_open": args.hold_open or args.stability_test,
"use_physics_drive": use_physics_drive,
}
o6_bridge = None
g20_bridge = None
if load_o6:
o6_bridge = HandBridge(
ros_node, o6, hand="O6", side=args.o6_side,
cmd_topic=TOPICS["O6"]["cmd"], state_topic=TOPICS["O6"]["state"],
**bridge_kwargs,
)
o6_bridge._mount_position = [0.0, -HAND_MOUNT_Y, HAND_MOUNT_Z]
if load_g20:
g20_bridge = HandBridge(
ros_node, g20, hand="G20", side=args.g20_side,
cmd_topic=TOPICS["G20"]["cmd"], state_topic=TOPICS["G20"]["state"],
**bridge_kwargs,
)
g20_bridge._mount_position = [0.0, HAND_MOUNT_Y, HAND_MOUNT_Z]
if o6_bridge:
o6_bridge.initialize_pose()
if g20_bridge:
g20_bridge.initialize_pose()
if use_physics_drive:
for _ in range(120):
if o6_bridge:
o6_bridge.apply_targets()
if g20_bridge:
g20_bridge.apply_targets()
SimulationManager.step()
simulation_app.update()
if g20_bridge and args.stability_test:
ros_node.get_logger().info(f"G20 after settle: {g20_bridge._read_sdk_state()[:8]}")
else:
for _ in range(5):
simulation_app.update()
mode_str = (
"运动学"
if args.kinematic
else (
"PD+自碰"
if use_physics_drive and want_self_collision
else ("纯 PD 动力学" if use_physics_drive else "位置驱动(可穿模)")
)
)
grav_str = "开" if enable_link_gravity else ("关(硬写忽略)" if args.hand_gravity else "关")
ros_node.get_logger().info(
f"仿真就绪 [{mode_str}, 手部重力={grav_str}]。"
f"UI: run_sim.sh ;测摩擦: run_sim.sh --pd-drive --hand-gravity"
)
stability_samples: list[list[float]] = []
max_steps = args.max_steps
if args.stability_test and max_steps <= 0:
max_steps = 960
if args.motion_test and max_steps <= 0:
max_steps = 720
def _motion_pose(phase: int) -> None:
"""phase 偶数=张开,奇数=半握。"""
if phase % 2 == 0:
if o6_bridge:
o6_bridge.set_sdk_command(open_hand_command("O6"))
if g20_bridge:
g20_bridge.set_sdk_command(open_hand_command("G20"))
else:
if o6_bridge:
o6_bridge.set_sdk_command([80.0] * 6)
if g20_bridge:
g20_bridge.set_sdk_command([80.0] * 20)
try:
step = 0
while simulation_app.is_running():
rclpy.spin_once(ros_node, timeout_sec=0.0)
if args.motion_test and step % 90 == 0:
_motion_pose(step // 90)
if o6_bridge:
o6_bridge.apply_targets()
if g20_bridge:
g20_bridge.apply_targets()
if step % 6 == 0:
if o6_bridge:
o6_bridge.publish_state()
if g20_bridge:
g20_bridge.publish_state()
if args.stability_test and g20_bridge and step >= 360:
stability_samples.append(g20_bridge._read_sdk_state())
SimulationManager.step()
if not use_physics_drive:
if o6_bridge:
o6_bridge._pin_root()
if g20_bridge:
g20_bridge._pin_root()
RenderingManager.render()
simulation_app.update()
# update() 可能再推物理,渲染前最后再钉一次
if not use_physics_drive:
if o6_bridge:
o6_bridge._pin_root()
if g20_bridge:
g20_bridge._pin_root()
step += 1
if max_steps > 0 and step >= max_steps:
if args.stability_test:
_print_g20_stability_report(stability_samples)
if args.motion_test:
for br in (o6_bridge, g20_bridge):
if br is None:
continue
print(
f"[motion-test] {br.hand}: explode_count={br.explode_count} "
f"max_root_drift={br.max_root_drift:.4f}m"
)
ok = all(
br is None or br.explode_count == 0
for br in (o6_bridge, g20_bridge)
)
print("[motion-test] RESULT:", "PASS" if ok else "FAIL")
else:
ros_node.get_logger().info(f"已完成 {max_steps} 步,退出。")
break
except KeyboardInterrupt:
pass
finally:
ros_node.destroy_node()
rclpy.shutdown()
app_utils.stop()
simulation_app.close()
if __name__ == "__main__":
main()