0b727785c6
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>
725 lines
26 KiB
Python
Executable File
725 lines
26 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# SPDX-License-Identifier: Apache-2.0
|
||
"""Isaac Sim G20 实机命令桥。
|
||
|
||
默认:
|
||
订阅 /g20/cb_left_hand_control_cmd JointState(与真机相同的 0~255 cmd)
|
||
发布 /sim/isaac/g20/left/joint_state JointState(仿真 DOF 弧度)
|
||
|
||
按关节名匹配;0~255 经 joint_mapping 转为 URDF 弧度。
|
||
默认位置硬写以跟真机;``--pd-drive`` 可选。
|
||
|
||
PLAN 名义弧度:``--plan-nominal`` 订阅 /retarget/g20/left/joint_target_nominal。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
LINKERHAND_ROOT = Path(__file__).resolve().parent
|
||
URDF_ROOT = LINKERHAND_ROOT / "urdf"
|
||
DEFAULT_G20_USD = URDF_ROOT / "g20/left/linkerhand_g20_left/linkerhand_g20_left.usda"
|
||
|
||
TOPIC_NOMINAL = "/retarget/g20/left/joint_target_nominal"
|
||
TOPIC_SIM_STATE = "/sim/isaac/g20/left/joint_state"
|
||
TOPIC_G20_CMD = "/g20/cb_left_hand_control_cmd"
|
||
TOPIC_LEGACY_CMD = "/cb_left_hand_control_cmd" # 无命名空间旧话题
|
||
TOPIC_LEGACY_STATE = "/cb_left_hand_state"
|
||
|
||
HAND_MOUNT_Y = 0.45
|
||
HAND_MOUNT_Z = 0.70
|
||
TRACK_TOL_RAD = 0.02
|
||
WARN_INTERVAL_S = 2.0
|
||
# 跟真机:默认不限速(0=每步直达 goal);需要柔顺时用 --slew-rad
|
||
DEFAULT_SLEW_RAD = 0.0
|
||
DEFAULT_PHYS_PER_RENDER = 16
|
||
PD_STIFFNESS = 15.0
|
||
PD_DAMPING = 2.5
|
||
|
||
# 与 MuJoCo G20 共用的对比视角:正对掌心(法向约 -X)、手指朝上(+Z)
|
||
# lookat 相对手本地原点;相机从 +X 看向 -X(与 MuJoCo azimuth=180 一致)
|
||
COMPARE_LOOKAT_LOCAL = (0.04, -0.04, 0.12)
|
||
COMPARE_DISTANCE = 0.50
|
||
COMPARE_ELEVATION_DEG = -12.0
|
||
PD_MAX_EFFORT = 8.0
|
||
LIMIT_MARGIN_RAD = 0.05
|
||
THUMB_PD_STIFFNESS = 10.0
|
||
THUMB_PD_DAMPING = 3.0
|
||
THUMB_PD_MAX_EFFORT = 6.0
|
||
THUMB_ACTUATED = frozenset(
|
||
{"thumb_cmc_roll", "thumb_cmc_yaw", "thumb_cmc_pitch", "thumb_mcp"}
|
||
)
|
||
|
||
|
||
def _normalize_argv(argv: list[str]) -> list[str]:
|
||
"""把常见单横杠长选项改成双横杠,避免 ``-hand-gravity`` 被拆成 ``-h`` 等短选项。"""
|
||
aliases = {
|
||
"-hand-gravity": "--hand-gravity",
|
||
"-pd-drive": "--pd-drive",
|
||
"-legacy-sdk": "--legacy-sdk",
|
||
"-plan-nominal": "--plan-nominal",
|
||
"-track-test": "--track-test",
|
||
"-headless": "--headless",
|
||
"-no-self-collision": "--no-self-collision",
|
||
}
|
||
out: list[str] = []
|
||
for a in argv:
|
||
out.append(aliases.get(a, a))
|
||
return out
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
p = argparse.ArgumentParser(description="Isaac G20 bridge (default: /g20/cb_left_hand_control_cmd)")
|
||
p.add_argument("--headless", action="store_true")
|
||
p.add_argument("--g20-usd", type=Path, default=DEFAULT_G20_USD)
|
||
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",
|
||
help="订阅旧话题 /cb_left_hand_control_cmd(无 /g20 前缀)",
|
||
)
|
||
p.add_argument(
|
||
"--pd-drive",
|
||
action="store_true",
|
||
help="PD 动力学(默认位置硬写;大跳指令仍靠 --slew-rad 限速)",
|
||
)
|
||
p.add_argument(
|
||
"--hand-gravity",
|
||
action="store_true",
|
||
help="手部重力(仅 --pd-drive 下生效)",
|
||
)
|
||
p.add_argument(
|
||
"--no-self-collision",
|
||
action="store_true",
|
||
help="关闭自碰(会穿模;默认跟随 USD 开启自碰)",
|
||
)
|
||
p.add_argument(
|
||
"--slew-rad",
|
||
type=float,
|
||
default=DEFAULT_SLEW_RAD,
|
||
help="每物理步最大目标变化(rad);0=瞬跳跟真机。默认 0",
|
||
)
|
||
p.add_argument(
|
||
"--phys-per-render",
|
||
type=int,
|
||
default=DEFAULT_PHYS_PER_RENDER,
|
||
help="每次渲染前进的物理步数(默认 16)",
|
||
)
|
||
p.add_argument(
|
||
"--track-test",
|
||
action="store_true",
|
||
help="内置 open→半握,验收主动关节跟踪 ≤0.02rad",
|
||
)
|
||
p.add_argument("--cmd-topic", default=TOPIC_G20_CMD, help="0~255 命令话题")
|
||
p.add_argument("--nominal-topic", default=TOPIC_NOMINAL)
|
||
p.add_argument("--sim-state-topic", default=TOPIC_SIM_STATE)
|
||
args, unknown = p.parse_known_args(_normalize_argv(sys.argv[1:]))
|
||
if unknown:
|
||
print(f"[args] ignoring unknown argv: {unknown}")
|
||
sys.argv = [sys.argv[0]]
|
||
return 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 (
|
||
L20_SDK_TO_URDF,
|
||
complete_g20_mimic_positions,
|
||
g20_actuated_joint_names,
|
||
g20_required_joint_names,
|
||
open_hand_command,
|
||
sdk_range_to_full_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
|
||
|
||
|
||
def _fix_base_to_world(hand_path: str) -> None:
|
||
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 = None
|
||
first = None
|
||
for prim in Usd.PrimRange(hand_prim):
|
||
if not prim.HasAPI(UsdPhysics.RigidBodyAPI):
|
||
continue
|
||
if first is None:
|
||
first = prim
|
||
if prim.GetName() in ("hand_base_link", "base_link", "palm"):
|
||
base = prim
|
||
break
|
||
base = base or first
|
||
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()}")
|
||
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 -> {base.GetPath()} @ "
|
||
f"({float(pos[0]):.3f},{float(pos[1]):.3f},{float(pos[2]):.3f})"
|
||
)
|
||
return
|
||
|
||
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}")
|
||
|
||
|
||
def _patch_joint_max_forces(hand_path: str, max_force: float = 8.0) -> None:
|
||
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 not prim.IsA(UsdPhysics.RevoluteJoint):
|
||
continue
|
||
drive = UsdPhysics.DriveAPI.Get(prim, "angular")
|
||
if not drive:
|
||
continue
|
||
attr = drive.GetMaxForceAttr()
|
||
if attr and float(attr.Get() or 0.0) < max_force:
|
||
attr.Set(float(max_force))
|
||
|
||
|
||
def _load_g20(usd_path: Path) -> str:
|
||
if not usd_path.is_file():
|
||
raise FileNotFoundError(f"USD 不存在: {usd_path}")
|
||
mount = "/World/G20Hand"
|
||
stage_utils.define_prim(mount, "Xform")
|
||
XformPrim(mount, positions=[[0.0, HAND_MOUNT_Y, HAND_MOUNT_Z]], reset_xform_op_properties=True)
|
||
hand_path = f"{mount}/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 validate_g20_dof_names(dof_names: list[str]) -> None:
|
||
have = set(dof_names)
|
||
required = g20_required_joint_names()
|
||
missing = sorted(required - have)
|
||
if missing:
|
||
raise RuntimeError(
|
||
"G20 Articulation 关节名与 PLAN/URDF 规范不匹配,缺失: "
|
||
+ ", ".join(missing)
|
||
+ f" | have={sorted(have)}"
|
||
)
|
||
|
||
|
||
class G20PlanBridge:
|
||
"""订阅 cmd_u8 / PLAN 名义弧度,驱动 Articulation 并发布仿真状态。"""
|
||
|
||
def __init__(
|
||
self,
|
||
node: Node,
|
||
art: Articulation,
|
||
*,
|
||
use_cmd_u8: bool,
|
||
cmd_topic: str,
|
||
use_physics_drive: bool,
|
||
nominal_topic: str,
|
||
sim_state_topic: str,
|
||
slew_rad: float = DEFAULT_SLEW_RAD,
|
||
) -> None:
|
||
self.node = node
|
||
self.art = art
|
||
self.use_cmd_u8 = use_cmd_u8
|
||
self.use_physics_drive = use_physics_drive
|
||
self.slew_rad = max(0.0, float(slew_rad))
|
||
self.dof_names = list(art.dof_names)
|
||
validate_g20_dof_names(self.dof_names)
|
||
self.dof_index = {n: i for i, n in enumerate(self.dof_names)}
|
||
self.actuated = g20_actuated_joint_names()
|
||
self.actuated_indices = [self.dof_index[n] for n in sorted(self.actuated) if n in self.dof_index]
|
||
|
||
lower, upper = art.get_dof_limits()
|
||
self._lo = lower.numpy().flatten()
|
||
self._hi = upper.numpy().flatten()
|
||
|
||
open_full = sdk_range_to_full_urdf_positions(open_hand_command("G20"), "G20", "left")
|
||
open_full = complete_g20_mimic_positions(open_full)
|
||
self.goal: dict[str, float] = dict(open_full)
|
||
self.applied: dict[str, float] = dict(open_full)
|
||
self.targets = self.applied
|
||
self._last_warn_missing = 0.0
|
||
self._last_warn_unknown = 0.0
|
||
self.track_nominal: dict[str, float] | None = None
|
||
|
||
if use_cmd_u8:
|
||
self.sub = node.create_subscription(JointState, cmd_topic, self._on_cmd_u8, 10)
|
||
self.legacy_pub = None
|
||
node.get_logger().info(f"G20 cmd_u8: sub {cmd_topic} (0~255 → URDF rad)")
|
||
else:
|
||
self.sub = node.create_subscription(JointState, nominal_topic, self._on_nominal, 10)
|
||
self.legacy_pub = None
|
||
node.get_logger().info(f"G20 PLAN: sub {nominal_topic} (by name, rad)")
|
||
|
||
self.pub = node.create_publisher(JointState, sim_state_topic, 10)
|
||
node.get_logger().info(
|
||
f"G20: pub {sim_state_topic}, actuated={len(self.actuated_indices)}, "
|
||
f"drive={'PD' if use_physics_drive else 'position'}, "
|
||
f"slew_rad={self.slew_rad}"
|
||
)
|
||
|
||
def set_nominal_targets(self, positions: dict[str, float]) -> None:
|
||
self.goal = complete_g20_mimic_positions(positions)
|
||
self.track_nominal = {n: float(self.goal[n]) for n in self.actuated if n in self.goal}
|
||
|
||
def _on_nominal(self, msg: JointState) -> 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.dof_index]
|
||
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}")
|
||
merged = dict(self.goal)
|
||
for k, v in incoming.items():
|
||
if k in self.dof_index:
|
||
merged[k] = v
|
||
self.goal = complete_g20_mimic_positions(merged)
|
||
else:
|
||
merged = {k: v for k, v in incoming.items() if k in self.dof_index}
|
||
self.goal = complete_g20_mimic_positions(merged)
|
||
self.track_nominal = {n: float(self.goal[n]) for n in self.actuated if n in self.goal}
|
||
|
||
def _on_cmd_u8(self, msg: JointState) -> None:
|
||
"""接收与真机相同的 0~255 JointState(优先按 name,否则按下标)。"""
|
||
vals = [255.0] * 20
|
||
name_to_sdk = {name: idx for idx, name in L20_SDK_TO_URDF.items()}
|
||
if msg.name:
|
||
n = min(len(msg.name), len(msg.position))
|
||
for i in range(n):
|
||
key = str(msg.name[i])
|
||
pos = float(msg.position[i])
|
||
if key in name_to_sdk:
|
||
vals[name_to_sdk[key]] = pos
|
||
elif key.startswith("reserved_"):
|
||
try:
|
||
vals[int(key.split("_", 1)[1])] = pos
|
||
except ValueError:
|
||
pass
|
||
elif i < 20:
|
||
vals[i] = pos
|
||
else:
|
||
for i, x in enumerate(msg.position[:20]):
|
||
vals[i] = float(x)
|
||
full = sdk_range_to_full_urdf_positions(vals, "G20", "left")
|
||
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 _slew_applied_toward_goal(self) -> None:
|
||
"""每步把 applied 向 goal 限速逼近,产生中间运动过程。"""
|
||
max_step = self.slew_rad
|
||
margin = LIMIT_MARGIN_RAD
|
||
for name, g in self.goal.items():
|
||
idx = self.dof_index.get(name)
|
||
if idx is None:
|
||
continue
|
||
lo = float(self._lo[idx]) + margin
|
||
hi = float(self._hi[idx]) - margin
|
||
if lo > hi:
|
||
lo = float(self._lo[idx])
|
||
hi = float(self._hi[idx])
|
||
g = float(np.clip(g, lo, hi))
|
||
a = float(self.applied.get(name, g))
|
||
if max_step <= 0.0:
|
||
a = g
|
||
else:
|
||
delta = g - a
|
||
if abs(delta) > max_step:
|
||
a = a + float(np.sign(delta) * max_step)
|
||
else:
|
||
a = g
|
||
self.applied[name] = float(np.clip(a, lo, hi))
|
||
self.applied = complete_g20_mimic_positions(self.applied)
|
||
self.targets = self.applied
|
||
|
||
def apply_targets(self) -> None:
|
||
self._slew_applied_toward_goal()
|
||
# PD:绝不能驱动 mimic(与 PhysX mimic 冲突 → Invalid PhysX transform)
|
||
# 位置硬写:写全关节(含已解析 mimic)
|
||
if self.use_physics_drive:
|
||
name_iter = (n for n in self.applied if n in self.actuated)
|
||
else:
|
||
name_iter = self.applied.keys()
|
||
|
||
names = []
|
||
vals = []
|
||
for name in name_iter:
|
||
rad = self.applied[name]
|
||
idx = self.dof_index.get(name)
|
||
if idx is None:
|
||
continue
|
||
names.append(name)
|
||
vals.append(float(rad))
|
||
if not names:
|
||
return
|
||
indices = [self.dof_index[n] for n in names]
|
||
positions = np.asarray(vals, dtype=np.float64)
|
||
if self.use_physics_drive:
|
||
self.art.set_dof_position_targets(positions, dof_indices=indices)
|
||
else:
|
||
self.art.set_dof_positions(positions, dof_indices=indices)
|
||
try:
|
||
self.art.set_dof_velocities(
|
||
np.zeros(len(indices), dtype=np.float64), dof_indices=indices
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def recover_if_invalid(self) -> bool:
|
||
"""若出现 NaN/Inf,复位到张开名义位姿。"""
|
||
pos = self.art.get_dof_positions().numpy().flatten()
|
||
if np.isfinite(pos).all():
|
||
return False
|
||
self.node.get_logger().warn("invalid dof state (NaN/Inf) → reset to open pose")
|
||
open_full = complete_g20_mimic_positions(
|
||
sdk_range_to_full_urdf_positions(open_hand_command("G20"), "G20", "left")
|
||
)
|
||
self.goal = dict(open_full)
|
||
self.applied = dict(open_full)
|
||
self.targets = self.applied
|
||
self.track_nominal = {n: float(open_full[n]) for n in self.actuated if n in open_full}
|
||
names = []
|
||
vals = []
|
||
for name, rad in open_full.items():
|
||
idx = self.dof_index.get(name)
|
||
if idx is None:
|
||
continue
|
||
names.append(name)
|
||
vals.append(float(np.clip(rad, float(self._lo[idx]), float(self._hi[idx]))))
|
||
if names:
|
||
indices = [self.dof_index[n] for n in names]
|
||
self.art.set_dof_positions(np.asarray(vals, dtype=np.float64), dof_indices=indices)
|
||
try:
|
||
self.art.set_dof_velocities(
|
||
np.zeros(len(indices), dtype=np.float64), dof_indices=indices
|
||
)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
|
||
def publish_state(self) -> None:
|
||
pos = self.art.get_dof_positions().numpy().flatten()
|
||
try:
|
||
vel = self.art.get_dof_velocities().numpy().flatten()
|
||
except Exception:
|
||
vel = np.zeros_like(pos)
|
||
msg = JointState()
|
||
msg.header.stamp = self.node.get_clock().now().to_msg()
|
||
msg.name = list(self.dof_names)
|
||
msg.position = [float(x) for x in pos]
|
||
msg.velocity = [float(x) for x in vel]
|
||
self.pub.publish(msg)
|
||
if self.legacy_pub is not None:
|
||
by_name = {n: float(pos[i]) for i, n in enumerate(self.dof_names)}
|
||
sdk = urdf_positions_to_sdk_range(by_name, "G20", "left")
|
||
leg = JointState()
|
||
leg.header = msg.header
|
||
leg.name = [f"joint_{i}" for i in range(20)]
|
||
leg.position = [float(v) for v in sdk]
|
||
self.legacy_pub.publish(leg)
|
||
|
||
def tracking_error(self) -> tuple[float, float]:
|
||
"""Return (max_abs, mean_abs) over actuated joints vs track_nominal."""
|
||
if not self.track_nominal:
|
||
return 0.0, 0.0
|
||
pos = self.art.get_dof_positions().numpy().flatten()
|
||
errs = []
|
||
for name, tgt in self.track_nominal.items():
|
||
idx = self.dof_index.get(name)
|
||
if idx is None:
|
||
continue
|
||
errs.append(abs(float(pos[idx]) - float(tgt)))
|
||
if not errs:
|
||
return 0.0, 0.0
|
||
return float(max(errs)), float(sum(errs) / len(errs))
|
||
|
||
|
||
def _compare_camera_eye_target() -> tuple[list[float], list[float]]:
|
||
"""正对掌心的 eye/target(世界系),与 MuJoCo COMPARE_* 一致(从 +X 看)。"""
|
||
import math
|
||
|
||
ox, oy, oz = 0.0, HAND_MOUNT_Y, HAND_MOUNT_Z
|
||
lx, ly, lz = COMPARE_LOOKAT_LOCAL
|
||
target = [ox + lx, oy + ly, oz + lz]
|
||
elev = math.radians(COMPARE_ELEVATION_DEG)
|
||
# 从 +X 看向 lookat,略俯视(与 MuJoCo azimuth=180 同向)
|
||
eye = [
|
||
target[0] + COMPARE_DISTANCE * math.cos(elev),
|
||
target[1],
|
||
target[2] - COMPARE_DISTANCE * math.sin(elev),
|
||
]
|
||
return eye, target
|
||
|
||
|
||
def main() -> int:
|
||
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])
|
||
DistantLight("/World/DistantLight").set_intensities(300.0)
|
||
eye, target = _compare_camera_eye_target()
|
||
set_camera_view(eye=eye, target=target, camera_prim_path="/OmniverseKit_Persp")
|
||
|
||
hand_path = _load_g20(args.g20_usd)
|
||
SimulationManager.set_physics_dt(1.0 / 120.0)
|
||
|
||
app_utils.play()
|
||
simulation_app.update()
|
||
_fix_base_to_world(hand_path)
|
||
app_utils.stop()
|
||
simulation_app.update()
|
||
app_utils.play()
|
||
simulation_app.update()
|
||
# play/stop 后重设,避免 viewport 被重置
|
||
eye, target = _compare_camera_eye_target()
|
||
set_camera_view(eye=eye, target=target, camera_prim_path="/OmniverseKit_Persp")
|
||
|
||
art = Articulation(hand_path)
|
||
use_pd = bool(args.pd_drive)
|
||
if args.hand_gravity and not use_pd:
|
||
print("[warn] --hand-gravity ignored without --pd-drive (PLAN default is position drive)")
|
||
# 默认跟随 USD:自碰开启(防穿模);仅 --no-self-collision 关闭。
|
||
enable_self_col = not bool(args.no_self_collision)
|
||
try:
|
||
art.set_enabled_self_collisions([enable_self_col])
|
||
except Exception as exc:
|
||
print(f"[stabilize] self_collision set failed: {exc}")
|
||
print(f"[stabilize] self_collision={'ON' if enable_self_col else 'OFF'} (USD selective colliders)")
|
||
art.set_link_enabled_gravities(bool(args.hand_gravity) and use_pd)
|
||
if use_pd:
|
||
act = [
|
||
i
|
||
for i, n in enumerate(art.dof_names)
|
||
if n in g20_actuated_joint_names()
|
||
]
|
||
stiff = np.array(
|
||
[
|
||
THUMB_PD_STIFFNESS if art.dof_names[i] in THUMB_ACTUATED else PD_STIFFNESS
|
||
for i in act
|
||
],
|
||
dtype=float,
|
||
)
|
||
damp = np.array(
|
||
[
|
||
THUMB_PD_DAMPING if art.dof_names[i] in THUMB_ACTUATED else PD_DAMPING
|
||
for i in act
|
||
],
|
||
dtype=float,
|
||
)
|
||
effort = np.array(
|
||
[
|
||
THUMB_PD_MAX_EFFORT if art.dof_names[i] in THUMB_ACTUATED else PD_MAX_EFFORT
|
||
for i in act
|
||
],
|
||
dtype=float,
|
||
)
|
||
art.set_dof_gains(stiffnesses=stiff, dampings=damp, dof_indices=act)
|
||
art.set_dof_max_efforts(effort, dof_indices=act)
|
||
# mimic 零增益,避免与 PhysX mimic 冲突
|
||
mimic_idx = [
|
||
i
|
||
for i, n in enumerate(art.dof_names)
|
||
if n not in g20_actuated_joint_names()
|
||
]
|
||
if mimic_idx:
|
||
art.set_dof_gains(
|
||
stiffnesses=np.zeros(len(mimic_idx)),
|
||
dampings=np.zeros(len(mimic_idx)),
|
||
dof_indices=mimic_idx,
|
||
)
|
||
else:
|
||
# 位置硬写:零增益避免与 set_dof_positions 冲突
|
||
n = len(art.dof_names)
|
||
art.set_dof_gains(stiffnesses=np.zeros(n), dampings=np.zeros(n))
|
||
|
||
if not rclpy.ok():
|
||
rclpy.init()
|
||
node = Node("isaac_g20_plan_bridge")
|
||
try:
|
||
use_cmd_u8 = not bool(args.plan_nominal)
|
||
cmd_topic = TOPIC_LEGACY_CMD if args.legacy_sdk else str(args.cmd_topic)
|
||
bridge = G20PlanBridge(
|
||
node,
|
||
art,
|
||
use_cmd_u8=use_cmd_u8,
|
||
cmd_topic=cmd_topic,
|
||
use_physics_drive=use_pd,
|
||
nominal_topic=args.nominal_topic,
|
||
sim_state_topic=args.sim_state_topic,
|
||
slew_rad=float(args.slew_rad),
|
||
)
|
||
except RuntimeError as exc:
|
||
node.get_logger().error(str(exc))
|
||
node.destroy_node()
|
||
rclpy.shutdown()
|
||
simulation_app.close()
|
||
return 1
|
||
|
||
# 初始 snap 到 open(跳过 slew)
|
||
old_slew = bridge.slew_rad
|
||
bridge.slew_rad = 0.0
|
||
bridge.apply_targets()
|
||
bridge.slew_rad = old_slew
|
||
for _ in range(10):
|
||
SimulationManager.step()
|
||
simulation_app.update()
|
||
|
||
if args.plan_nominal:
|
||
mode = "PLAN nominal"
|
||
elif args.legacy_sdk:
|
||
mode = f"cmd_u8:{TOPIC_LEGACY_CMD}"
|
||
else:
|
||
mode = f"cmd_u8:{args.cmd_topic}"
|
||
drive = "PD" if use_pd else "position"
|
||
node.get_logger().info(
|
||
f"仿真就绪 [{mode}, drive={drive}, slew={args.slew_rad} rad/step, "
|
||
f"phys/render={max(1, int(args.phys_per_render))}] pub={args.sim_state_topic}"
|
||
)
|
||
|
||
track_phase = 0
|
||
track_ok = True
|
||
max_steps = int(args.max_steps)
|
||
if args.track_test and max_steps <= 0:
|
||
max_steps = 480
|
||
phys_per_render = max(1, int(args.phys_per_render))
|
||
|
||
step = 0
|
||
try:
|
||
while simulation_app.is_running():
|
||
for _ in range(phys_per_render):
|
||
rclpy.spin_once(node, timeout_sec=0.0)
|
||
if args.track_test and step % 160 == 0:
|
||
if track_phase % 2 == 0:
|
||
cmd = open_hand_command("G20")
|
||
else:
|
||
cmd = [80.0] * 20
|
||
bridge.set_nominal_targets(
|
||
sdk_range_to_full_urdf_positions(cmd, "G20", "left")
|
||
)
|
||
track_phase += 1
|
||
bridge.apply_targets()
|
||
SimulationManager.step()
|
||
if bridge.recover_if_invalid():
|
||
bridge.apply_targets()
|
||
SimulationManager.step()
|
||
bridge.publish_state()
|
||
# 位置驱动:物理步进后再钉一次
|
||
if not use_pd:
|
||
bridge.apply_targets()
|
||
step += 1
|
||
if max_steps > 0 and step >= max_steps:
|
||
break
|
||
RenderingManager.render()
|
||
simulation_app.update()
|
||
if max_steps > 0 and step >= max_steps:
|
||
break
|
||
finally:
|
||
if args.track_test:
|
||
# settle:给够 slew 走完
|
||
for _ in range(240):
|
||
bridge.apply_targets()
|
||
SimulationManager.step()
|
||
simulation_app.update()
|
||
mx, mean = bridge.tracking_error()
|
||
print(
|
||
f"[track-test] actuated max_abs={mx:.4f} rad mean_abs={mean:.4f} rad "
|
||
f"tol={TRACK_TOL_RAD}"
|
||
)
|
||
track_ok = mx <= TRACK_TOL_RAD
|
||
print("[track-test] RESULT:", "PASS" if track_ok else "FAIL")
|
||
node.destroy_node()
|
||
if rclpy.ok():
|
||
rclpy.shutdown()
|
||
simulation_app.close()
|
||
|
||
return 0 if track_ok else 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|