在本教程中,我们将深入剖析 NVIDIA IsaacTeleop 核心的重定向引擎,该框架将 XR 手部追踪和运动控制器输入转化为模拟和真实机器人的指令。我们不接入头显,而是用 NumPy 自行构建每一个输入,因此每一步都能在普通的 Colab CPU 上运行并打印出计算结果。我们从每个节点都使用的类型系统开始,生成合成的手部和控制器数据,编写自己的、参数可实时调整的重定向器,然后用它来驱动内置的夹爪和 SE(3) 重定向器。接着,我们组合出一个完整的图,每一步输出一个动作向量,应用世界坐标系变换,逐步运行,暂停并终止状态机,最后完成控制器到灵巧手的映射以及可跨重启持久化的参数调优。
复制代码已复制请使用其他浏览器
import os
import sys
import json
import math
import tempfile
import traceback
import subprocess
import numpy as np
RESULTS = {}
def banner(title):
print("\n" + "=" * 78)
print(title)
print("=" * 78)
def section(name):
def wrap(fn):
def run(*a, **kw):
banner(name)
try:
out = fn(*a, **kw)
RESULTS[name] = out if isinstance(out, str) else "ok"
return out
except Exception as e:
RESULTS[name] = f"SKIPPED / FAILED -> {type(e).__name__}: {e}"
print(f"\n[!] {name} did not complete: {type(e).__name__}: {e}")
traceback.print_exc(limit=3)
return None
return run
return wrap
banner("0. 安装 isaacteleop 并检查环境")
subprocess.run(
[sys.executable, "-m", "pip", "install", "-q", "isaacteleop[retargeters-lite]==1.4.145"],
check=True,
)
import pkgutil
import isaacteleop
from isaacteleop import schema
print(f" isaacteleop {isaacteleop.__version__} | Python {sys.version.split()[0]} | numpy {np.__version__}")
print(" 顶层模块 :", ", ".join(sorted(m.name for m in pkgutil.iter_modules(isaacteleop.__path__))))
message_types = [n for n in dir(schema) if n[0].isupper()]
print(f" schema 消息类型: {len(message_types)}, 例如 {', '.join(message_types[:6])}")
print(" 下文任何地方都不使用头显、OpenXR 运行时或模拟器。")
我们从 PyPI 安装稳定的 isaacteleop wheel,并带上 retargeters-lite extra,它只额外添加 SciPy,然后报告版本、解释器和 NumPy 构建。该包分为包装 OpenXR 和 CloudXR 的设备 I/O 模块、保存每个追踪器都会发出的 FlatBuffer 消息类型的 schema 模块,以及我们在这里使用的纯 Python 重定向引擎。列出 schema 类型会展示数据层的词汇表,但下面没有任何内容会打开头显会话;从这里开始,每个输入都是我们手工构建的张量。
Copy CodeCopiedUse a different Browser
from isaacteleop.retargeting_engine.interface import (
TensorGroup,
OptionalTensorGroup,
TensorGroupType,
OptionalType,
)
from isaacteleop.retargeting_engine.tensor_types import (
HandInput,
ControllerInput,
HandInputIndex,
ControllerInputIndex,
HandJointIndex,
)
@section("1. 类型契约:TensorGroupType、TensorGroup、Optional")
def type_contract():
hand_t = HandInput()
ctrl_t = ControllerInput()
print(" HandInput :", hand_t)
print(" slot index :", ", ".join(f"{m.name}={m.value}" for m in HandInputIndex))
print(f" ControllerInput: {len(ctrl_t)} slots ->",
", ".join(t.name.replace("controller_", "") for t in ctrl_t.types))
print(f" OpenXR hand joints: {len(HandJointIndex)} "
f"(WRIST={int(HandJointIndex.WRIST)}, THUMB_TIP={int(HandJointIndex.THUMB_TIP)}, "
f"INDEX_TIP={int(HandJointIndex.INDEX_TIP)})")
hand = TensorGroup(hand_t)
hand[HandInputIndex.JOINT_POSITIONS] = np.zeros((26, 3), dtype=np.float32)
print("\n 将一个 (26, 3) float32 数组写入 JOINT_POSITIONS ->", hand)
try:
hand[HandInputIndex.JOINT_POSITIONS] = np.zeros((26, 3))
except TypeError as e:
print(" float64 在写入时被拒绝 :", str(e)[:110])
try:
_ = hand[HandInputIndex.JOINT_VALID]
except ValueError as e:
print(" 读取一个没人写入过的槽位 :", e)
maybe = OptionalTensorGroup(OptionalType(ctrl_t))
print(f"\n Optional 组初始为缺失 : is_none={maybe.is_none} {maybe}")
maybe[ControllerInputIndex.TRIGGER_VALUE] = 0.0
print(f" 一次写入就让它变为存在 : is_none={maybe.is_none} {maybe}")
return f"{len(hand_t)} hand slots, {len(ctrl_t)} controller slots"
type_contract()
引擎的契约是一个 TensorGroupType,即一个有序的类型化槽位列表,以及一个 TensorGroup,即运行时容器,它为每个槽位保存一个值并验证每次写入。HandInput 携带四个 NumPy 数组,对应 26 个 OpenXR 手部关节,而 ControllerInput 携带十四个槽位,用于姿态、按钮和轴,通过生成的 IntEnum 索引而非魔法数字来寻址。在声明为 float32 的位置写入 float64 数组会在写入时失败,而读取无人写入的槽位会抛出异常,而不是返回陈旧数据。OptionalType 标记追踪器可能不会传递的输入;匹配的 OptionalTensorGroup 初始为缺失状态,并在首次写入时变为存在状态,这正是每个下游节点得知手已离开追踪范围的方式。
复制代码已复制使用其他浏览器
J = HandJointIndex
C = ControllerInputIndex
RIGHT_WRIST = np.array([0.30, 1.05, -0.45], dtype=np.float32) # OpenXR:x 向右,y 向上,z 向后
def make_hand(pinch_m, wrist=RIGHT_WRIST, quat=(0.0, 0.0, 0.0, 1.0)):
"""一个符合 HandInput 布局的合成右手:26 个关节,按 OpenXR 顺序排列。"""
pos = np.zeros((26, 3), dtype=np.float32)
pos[J.WRIST] = wrist
pos[J.PALM] = wrist + [0.0, 0.0, -0.06]
fingers = [(J.INDEX_METACARPAL, 0.03), (J.MIDDLE_METACARPAL, 0.01),
(J.RING_METACARPAL, -0.01), (J.LITTLE_METACARPAL, -0.03)]
for base, x_off in fingers: # 掌骨 .. 指尖,每指五个关节
for k in range(5):
pos[base + k] = wrist + [x_off, 0.0, -0.05 - 0.02 * k]
for k in range(4): # 拇指:掌骨 .. 指尖,四个关节
pos[J.THUMB_METACARPAL + k] = wrist + [0.05, -0.01, -0.02 - 0.015 * k]
pos[J.THUMB_TIP] = pos[J.INDEX_TIP] + [pinch_m, 0.0, 0.0]
g = TensorGroup(HandInput())
g[HandInputIndex.JOINT_POSITIONS] = pos
g[HandInputIndex.JOINT_ORIENTATIONS] = np.tile(np.asarray(quat, dtype=np.float32), (26, 1))
g[HandInputIndex.JOINT_RADII] = np.full(26, 0.01, dtype=np.float32)
g[HandInputIndex.JOINT_VALID] = np.ones(26, dtype=np.uint8)
return g
def make_controller(pos, quat=(0.0, 0.0, 0.0, 1.0), trigger=0.0, squeeze=0.0,
thumbstick=(0.0, 0.0), valid=True):
"""一个符合 ControllerInput 布局的合成控制器快照。"""
p = np.asarray(pos, dtype=np.float32)
q = np.asarray(quat, dtype=np.float32)
g = TensorGroup(ControllerInput())
g[C.GRIP_POSITION], g[C.GRIP_ORIENTATION], g[C.GRIP_IS_VALID] = p, q, bool(valid)
g[C.AIM_POSITION] = p + np.array([0.0, 0.0, -0.05], dtype=np.float32)
g[C.AIM_ORIENTATION], g[C.AIM_IS_VALID] = q.copy(), bool(valid)
for idx in (C.PRIMARY_CLICK, C.SECONDARY_CLICK, C.THUMBSTICK_CLICK, C.MENU_CLICK):
g[idx] = 0.0
g[C.THUMBSTICK_X], g[C.THUMBSTICK_Y] = float(thumbstick[0]), float(thumbstick[1])
g[C.SQUEEZE_VALUE], g[C.TRIGGER_VALUE] = float(squeeze), float(trigger)
return g
@section("2. 合成追踪数据:numpy 中的一只手和一个控制器")
def synthetic_inputs():
hand = make_hand(pinch_m=0.06)
pos = hand[HandInputIndex.JOINT_POSITIONS]
print(" joint x y z")
for j in (J.WRIST, J.PALM, J.THUMB_TIP, J.INDEX_TIP, J.LITTLE_TIP):
print(f" {j.name:12s} {pos[j][0]:6.3f} {pos[j][1]:6.3f} {pos[j][2]:6.3f}")
pinch = np.linalg.norm(pos[J.THUMB_TIP] - pos[J.INDEX_TIP])
print(f" thumb-to-index distance: {pinch * 100:.1f} cm "
f"valid joints: {int(hand[HandInputIndex.JOINT_VALID].sum())}/26")
ctrl = make_controller((0.40, 1.20, -0.30), trigger=0.8, thumbstick=(0.0, 0.5))
print(f"\n controller grip {np.round(ctrl[C.GRIP_POSITION], 2)} aim {np.round(ctrl[C.AIM_POSITION], 2)} "
f"trigger {ctrl[C.TRIGGER_VALUE]} squeeze {ctrl[C.SQUEEZE_VALUE]} "
f"thumbstick ({ctrl[C.THUMBSTICK_X]}, {ctrl[C.THUMBSTICK_Y]})")
return "synthetic HandInput + ControllerInput builders"
synthetic_inputs()
我们构建头显通常会提供的追踪数据。make_hand 会按照 OpenXR 顺序围绕一个手腕位置布置 26 个关节,从手掌向外延伸出四条手指链和一条拇指链,并将拇指指尖放置在距食指指尖指定的捏合距离处,因此后续步骤所依赖的那个数值完全由我们掌控。make_controller 会填充每个 ControllerInput 槽位:带有效性标志的抓握和瞄准姿态、四个按钮、拇指摇杆轴,以及模拟的挤压和扳机值。两者都返回普通的 TensorGroup,这正是重定向器所能看到的一切,无论这些数值来自 OpenXR 还是来自 NumPy。
Copy CodeCopiedUse a different Browser
from isaacteleop.retargeting_engine.interface import (
BaseRetargeter,
ParameterState,
FloatParameter,
BoolParameter,
)
from isaacteleop.retargeting_engine.tensor_types import FloatType, BoolType
class PinchRetargeter(BaseRetargeter):
"""拇指到食指的距离 -> (distance_cm, is_pinching),参数可实时调整。"""
def __init__(self, name, config_file=None):
params = [
FloatParameter("threshold_cm", "距离小于此值时判定为捏合",
default_value=3.0, min_value=0.5, max_value=10.0,
sync_fn=lambda v: setattr(self, "threshold_cm", v)),
BoolParameter("use_distal_joints", "在远端关节之间测量,而非指尖",
default_value=False,
sync_fn=lambda v: setattr(self, "use_distal_joints", v)),
]
super().__init__(name, parameter_state=ParameterState(name, params, config_file=config_file))
def input_spec(self):
return {"hand_right": OptionalType(HandInput())}
def output_spec(self):
return {"pinch": TensorGroupType("pinch", [FloatType("distance_cm"), BoolType("is_pinching")])}
def _compute_fn(self, inputs, outputs, context):
hand = inputs["hand_right"]
if hand.is_none: # 跟踪丢失:如实说明,不要猜测
outputs["pinch"][0] = -1.0
outputs["pinch"][1] = False
return
pos = np.from_dlpack(hand[HandInputIndex.JOINT_POSITIONS])
a, b = (J.THUMB_DISTAL, J.INDEX_DISTAL) if self.use_distal_joints else (J.THUMB_TIP, J.INDEX_TIP)
d_cm = float(np.linalg.norm(pos[a] - pos[b]) * 100.0)
outputs["pinch"][0] = d_cm
outputs["pinch"][1] = bool(d_cm < self.threshold_cm)
@section("3. 编写一个重定向器:带实时可调参数的捏合检测")
def custom_retargeter():
pinch = PinchRetargeter("pinch")
for d in (0.06, 0.02):
out = pinch({"hand_right": make_hand(d)})
print(f" hand at {d * 100:.0f} cm -> distance_cm={out['pinch'][0]:.2f} is_pinching={out['pinch'][1]}")
out = pinch({}) # 完全省略可选输入
print(f" no hand tracked -> distance_cm={out['pinch'][0]:.1f} is_pinching={out['pinch'][1]}")
state = pinch.get_parameter_state()
print("\n tunable parameters:", state.get_all_values())
for threshold in (2.5, 3.5):
state.set({"threshold_cm": threshold}) # 调参 UI 从自己的线程执行的操作
out = pinch({"hand_right": make_hand(0.028)})
print(f" threshold_cm={threshold}: a 2.8 cm pinch -> is_pinching={out['pinch'][1]}")
state.set({"use_distal_joints": True})
out = pinch({"hand_right": make_hand(0.028)})
print(f" use_distal_joints=True: distance_cm={out['pinch'][0]:.2f} is_pinching={out['pinch'][1]}")
return "custom retargeter + 2 tunable parameters"
custom_retargeter()
retargeter 是 BaseRetargeter 的子类,它声明 input_spec 和 output_spec 并实现 _compute_fn;框架会用 absent groups 填充缺失的可选输入、校验类型、同步参数,然后才调用我们的代码。PinchRetargeter 测量拇指到食指的距离,并输出一个 float 和一个 bool,在没有手被跟踪时报告 -1,而不是去猜测。它的两个参数位于一个 ParameterState 中,其 sync 函数会在每次计算前写入实例,因此由调参 UI 从另一个线程设置的值会在下一帧生效。我们通过直接调用 set 来复现这一点:同样的 2.8 cm 捏合会随着阈值移动而在未捏合和捏合之间切换,而把测量切换到远端关节则会改变距离本身。
Copy CodeCopiedUse a different Browser
from isaacteleop.retargeters import (
GripperRetargeter,
GripperRetargeterConfig,
Se3AbsRetargeter,
Se3RelRetargeter,
Se3RetargeterConfig,
)
@section("4. Built-in retargeters: gripper hysteresis and SE(3) end-effector pose")
def builtin_retargeters():
gripper = GripperRetargeter(
GripperRetargeterConfig(hand_side="right", gripper_close_meters=0.03, gripper_open_meters=0.05),
name="gripper",
)
print(" pinch sweep (close below 3 cm, open above 5 cm, hold in between):")
for d in (0.07, 0.045, 0.028, 0.040, 0.052, 0.020):
cmd = gripper({"hand_right": make_hand(d)})["gripper_command"][0]
print(f" {d * 100:4.1f} cm -> {cmd:+.0f} {'CLOSED' if cmd < 0 else 'open'}")
cmd = gripper({"hand_right": make_hand(0.07),
"controller_right": make_controller((0.3, 1.0, -0.4), trigger=0.9)})["gripper_command"][0]
print(f" open hand + trigger 0.9 -> {cmd:+.0f} (a present controller outranks hand tracking)")
se3 = Se3AbsRetargeter(
Se3RetargeterConfig(input_device="controller_right", target_offset_roll=90.0, zero_out_xy_rotation=True),
name="ee_pose",
)
ctrl = make_controller((0.40, 1.20, -0.30))
pose = np.from_dlpack(se3({"controller_right": ctrl})["ee_pose"][0])
print(f"\n Se3Abs: grip {np.round(ctrl[C.GRIP_POSITION], 2)} -> ee_pose pos {np.round(pose[:3], 3)} "
f"quat(xyzw) {np.round(pose[3:], 3)}")
lost = make_controller((9.0, 9.0, 9.0), valid=False)
pose2 = np.from_dlpack(se3({"controller_right": lost})["ee_pose"][0])
print(f" grip_is_valid=False -> holds the last pose: {np.round(pose2[:3], 3)}")
rel = Se3RelRetargeter(
Se3RetargeterConfig(input_device="controller_right", delta_pos_scale_factor=10.0, alpha_pos=0.5),
name="ee_delta",
)
print("\n Se3Rel: the controller moves +2 cm in x every frame (delta x10, EMA alpha 0.5):")
for i in range(4):
ctrl = make_controller((0.40 + 0.02 * i, 1.20, -0.30))
delta = np.from_dlpack(rel({"controller_right": ctrl})["ee_delta"][0])
print(f" frame {i}: ee_delta [dx, dy, dz, rx, ry, rz] = {np.round(delta, 3)}")
return "gripper, Se3Abs and Se3Rel driven from synthetic input"
builtin_retargeters()
我们用相同的合成分组驱动两个内置重定向器。GripperRetargeter 将捏合距离转换为 Isaac Lab 期望的 -1/+1 夹爪指令,并带有迟滞:夹爪在低于 3 cm 时闭合,高于 5 cm 时张开,介于两者之间时保持状态,因此 4.0 cm 在闭合后保持闭合,而回扫经过 5.2 cm 时会重新张开。当前存在的控制器优先,超过阈值的扳机会闭合张开的手。Se3AbsRetargeter 将控制器握持位姿映射为 7 维末端执行器目标,应用配置的 roll 偏移,并在设置 zero_out_xy_rotation 时仅保留 yaw。当握持变为无效时,它会保持上一个位姿,而不是将零四元数传递到下游。Se3RelRetargeter 则输出增量:每帧恒定的 2 cm 步长被放大十倍并经 EMA 平滑,收敛至 0.2。
Copy CodeCopiedUse a different Browser
from isaacteleop.retargeting_engine.interface import ValueInput, OutputCombiner
from isaacteleop.retargeters import TensorReorderer
class CountingInput(ValueInput):
"""一个 ValueInput 叶子节点,用于统计图请求它计算的次数。"""
def __init__(self, name, tensor_type):
super().__init__(name, tensor_type)
self.calls = 0
def _compute_fn(self, inputs, outputs, context):
self.calls += 1
super()._compute_fn(inputs, outputs, context)
def build_pipeline():
controller = CountingInput("controller_right", OptionalType(ControllerInput()))
hand = CountingInput("hand_right", OptionalType(HandInput()))
ee_pose = Se3AbsRetargeter(Se3RetargeterConfig(input_device="controller_right"), name="ee_pose").connect(
{"controller_right": controller.output("value")}
)
gripper = GripperRetargeter(GripperRetargeterConfig(hand_side="right"), name="gripper").connect(
{"controller_right": controller.output("value"), "hand_right": hand.output("value")}
)
ee = ["pos_x", "pos_y", "pos_z", "quat_x", "quat_y", "quat_z", "quat_w"]
action = TensorReorderer(
input_config={"ee_pose": ee, "gripper_command": ["gripper"]},
output_order=ee + ["gripper"],
name="action",
input_types={"ee_pose": "array", "gripper_command": "scalar"},
).connect({"ee_pose": ee_pose.output("ee_pose"), "gripper_command": gripper.output("gripper_command")})
return OutputCombiner({"action": action.output("output")}), controller, hand
@section("5. 组合图:叶子节点 -> 重定向器 -> 每步一个动作向量")
def compose_graph():
pipeline, controller, hand = build_pipeline()
print(" 叶子节点 :", [n.name for n in pipeline.get_leaf_nodes()])
print(" 输出 :", {k: str(v) for k, v in pipeline.output_types().items()})
print("\n 帧 触发 action = [x, y, z, qx, qy, qz, qw, gripper]")
for f in range(6):
t = f / 6
trigger = 1.0 if f >= 4 else 0.0
ctrl = make_controller((0.40 + 0.10 * math.cos(2 * math.pi * t), 1.20,
-0.30 + 0.10 * math.sin(2 * math.pi * t)), trigger=trigger)
leaf_inputs = {"controller_right": {"value": ctrl}, "hand_right": {"value": make_hand(0.06)}}
action = np.from_dlpack(pipeline.execute_pipeline(leaf_inputs)["action"][0])
print(f" {f:3d} {trigger:.1f} {np.round(action, 3)}")
print(f"\n controller 叶子节点馈入两个重定向器,但在 6 帧中只计算了 {controller.calls} 次 "
f":每步一个 ExecutionCache")
return f"8 维 action 向量;6 帧共 {controller.calls} 次叶子计算"
compose_graph()
这是一个真实的 Isaac Teleop 流水线的形态。ValueInput 节点作为图的叶子节点替代 DeviceIO 源节点,connect 在连接时进行类型检查,将每个 retargeter 的输入连接到上游输出,TensorReorderer 将 7 维位姿和夹爪标量展平为环境所期望的动作布局,OutputCombiner 将结果暴露在单个 action key 下。execute_pipeline 接收以叶子名称作为键的输入,并针对共享的 ExecutionCache 每步运行一次 DAG,因此 controller 叶子虽然同时接入 SE(3) 和夹爪 retargeter,但每帧只计算一次,我们的计数子类证实了这一点。将叶子替换为 HandsSource 和 ControllersSource 是在头显上运行此图所需的唯一改动。
复制代码已复制使用其他浏览器
from scipy.spatial.transform import Rotation
from isaacteleop.retargeting_engine.utilities import ControllerTransform
from isaacteleop.retargeting_engine.tensor_types import TransformMatrix
@section("6. 坐标系:使用 world_T_anchor 矩阵的 ControllerTransform")
def coordinate_frames():
world_T_anchor = np.eye(4, dtype=np.float32)
world_T_anchor[:3, :3] = Rotation.from_euler("z", 90, degrees=True).as_matrix().astype(np.float32)
world_T_anchor[:3, 3] = [1.0, 0.0, 0.5]
xf = TensorGroup(TransformMatrix())
xf[0] = world_T_anchor
ctrl = make_controller((0.40, 1.20, -0.30), trigger=0.7)
out = ControllerTransform("controller_xform")({"controller_right": ctrl, "transform": xf})
p_in, p_out = ctrl[C.GRIP_POSITION], out["controller_right"][C.GRIP_POSITION]
print(f" 夹爪位置 anchor 坐标系 {np.round(p_in, 3)} -> 世界坐标系 {np.round(p_out, 3)}")
print(f" 检查 R @ p + t = {np.round(world_T_anchor[:3, :3] @ p_in + world_T_anchor[:3, 3], 3)}")
print(f" 夹爪朝向 (xyzw) {np.round(ctrl[C.GRIP_ORIENTATION], 3)} -> "
f"{np.round(out['controller_right'][C.GRIP_ORIENTATION], 3)}")
print(f" 扳机值透传 {ctrl[C.TRIGGER_VALUE]} -> {out['controller_right'][C.TRIGGER_VALUE]}")
print(f" 未提供左手控制器 -> {out['controller_left']}")
return "对夹爪和瞄准应用 yaw 90 度 + 平移,输入保持不变"
coordinate_frames()
头显在运行时的锚点坐标系中报告姿态,而机器人位于世界坐标系中,因此每条与模拟器通信的流水线都会先应用一个 world_T_anchor 变换。ControllerTransform 接收两个可选的控制器组,外加一个持有 4×4 齐次矩阵的 TransformMatrix 组,将握持和瞄准位置重写为 R p + t,方向则通过旋转部分重写,并将按钮、轴和有效性标志原样传递。我们用 NumPy 中相同的矩阵乘积验证位置,并看到缺失的左手控制器以缺失状态传播,这正是让单控制器会话能够流经双控制器图的原因。
Copy CodeCopiedUse a different Browser
from isaacteleop.teleop_session_manager import DefaultTeleopStateManager, bool_signal
from isaacteleop.retargeting_engine.interface import ComputeContext, ExecutionEvents, ExecutionState
from isaacteleop.retargeters import LocomotionRootCmdRetargeter, LocomotionRootCmdRetargeterConfig
def button(name, pressed):
g = TensorGroup(bool_signal(name))
g[0] = bool(pressed)
return g
def state_name(out):
st = out["teleop_state"]
return next(t.name for i, t in enumerate(st.group_type.types) if st[i])
@section("7. 控制状态机:运行、暂停、终止以及重置脉冲")
def state_machine():
sm = DefaultTeleopStateManager("state")
script = [("idle", 0, 0, 0), ("press run", 1, 0, 0), ("release", 0, 0, 0), ("press run", 1, 0, 0),
("hold run", 1, 0, 0), ("release", 0, 0, 0), ("press reset", 0, 0, 1), ("release", 0, 0, 0),
("KILL", 0, 1, 0), ("release", 0, 0, 0)]
print(" input run kill reset | state reset_event")
for label, run, kill, reset in script:
out = sm({"run_toggle_button": button("run_toggle_button", run),
"kill_button": button("kill_button", kill),
"reset_button": button("reset_button", reset)})
print(f" {label:12s} {run} {kill} {reset} | {state_name(out):8s} {out['reset_event'][0]}")
out = sm({"run_toggle_button": button("run_toggle_button", 0)})
print(f" kill signal lost | {state_name(out):8s} (fail-safe: required input absent)")
loco = LocomotionRootCmdRetargeter(LocomotionRootCmdRetargeterConfig(initial_hip_height=0.72), name="loco")
print("\n 右摇杆 Y=+1 每帧抬高髋部高度;一次重置脉冲会将其瞬间拉回:")
for i, reset in enumerate([False, False, False, True, False]):
ctx = ComputeContext(execution_events=ExecutionEvents(reset=reset, execution_state=ExecutionState.RUNNING))
cmd = loco({"controller_left": make_controller((0, 1, 0), thumbstick=(0.0, 0.6)),
"controller_right": make_controller((0, 1, 0), thumbstick=(0.2, 1.0))},
context=ctx)["root_command"][0]
print(f" frame {i} reset={str(reset):5s} root_command [vx, vy, wz, hip] = {np.round(cmd, 4)}")
return "STOPPED -> PAUSED -> RUNNING -> STOPPED,重置脉冲通过 ComputeContext 传递"
state_machine()
遥操作需要一种不依赖重定向器正常工作的方式来武装、暂停和终止机器人。DefaultTeleopStateManager 本身就是一个重定向器:输入三个可选的 bool,输出一个 one-hot 的 teleop_state 和一个 reset_event 脉冲。run 开关上的上升沿会让状态从 STOPPED 走到 PAUSED 再到 RUNNING,然后回到 PAUSED;reset 按钮会发出一个单帧脉冲而不改变状态;kill 会强制进入 STOPPED 并发出 reset 脉冲;丢失 kill 或 run 信号会安全失败到 STOPPED。这些事件通过 ComputeContext.execution_events 到达其他每个节点;LocomotionRootCmdRetargeter 每帧将右摇杆积分进髋部高度,并在携带 reset=True 的那一帧先瞬间回到初始高度,然后再继续积分。
Copy CodeCopiedUse a different Browser
from isaacteleop.retargeters import TriHandMotionControllerRetargeter, TriHandMotionControllerConfig
@section("8. Controller -> dexterous hand joints, and tuned parameters that survive a restart")
def trihand_and_persistence():
joints = ["thumb_rotation", "thumb_proximal", "thumb_distal", "index_proximal",
"index_distal", "middle_proximal", "middle_distal"]
tri = TriHandMotionControllerRetargeter(
TriHandMotionControllerConfig(hand_joint_names=joints, controller_side="right"), name="trihand_right"
)
print(" trigger squeeze |" + "".join(f"{j[:10]:>11s}" for j in joints))
for trig, sq in ((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)):
out = tri({"controller_right": make_controller((0.3, 1.0, -0.4), trigger=trig, squeeze=sq)})["hand_joints"]
print(f" {trig:.1f} {sq:.1f} |" + "".join(f"{out[i]:11.2f}" for i in range(7)))
cfg_path = os.path.join(tempfile.mkdtemp(), "ee_pose_tuning.json")
se3 = Se3AbsRetargeter(Se3RetargeterConfig(input_device="controller_right", parameter_config_path=cfg_path),
name="ee_pose")
se3.get_parameter_state().set({"rotation_offset_rpy": np.array([90.0, 0.0, 45.0]),
"position_offset_xyz": np.array([0.0, 0.0, 0.10])})
print()
se3.get_parameter_state().save_to_file()
print(" saved :", json.load(open(cfg_path)))
ctrl = make_controller((0.4, 1.2, -0.3))
pose_a = np.from_dlpack(se3({"controller_right": ctrl})["ee_pose"][0])
se3_b = Se3AbsRetargeter(Se3RetargeterConfig(input_device="controller_right", parameter_config_path=cfg_path),
name="ee_pose_restarted")
pose_b = np.from_dlpack(se3_b({"controller_right": ctrl})["ee_pose"][0])
print(f" tuned instance ee_pose = {np.round(pose_a, 3)}")
print(f" restarted instance ee_pose = {np.round(pose_b, 3)} <- same tuning, read back from JSON")
return "7-DOF trihand mapping; parameter JSON round trip"
trihand_and_persistence()
两个生产环境关注点为本教程收尾。TriHandMotionControllerRetargeter 是仅用控制器驱动灵巧手的路径:trigger 驱动食指,squeeze 驱动中指,两者中较大的那个使拇指弯曲,两者的差值使拇指旋转,从而给出七个具名关节角度,无需手部追踪,也无需优化库。接着我们让调参持久化:在 Se3AbsRetargeter 的 ParameterState 上设置旋转和位置偏移并调用 save_to_file 会写入一个 JSON 文件,而用相同 parameter_config_path 构造的新实例会在启动时加载它,并产生完全相同的末端执行器姿态,这正是 UI 中调好的标定能在会话重启后依然生效的方式。
Copy CodeCopiedUse a different Browser
banner("SUMMARY")
for name, res in RESULTS.items():
print(f" {name:<78s} {res}")
print("""
Where to go next
- Put a headset on the graph: swap the ValueInput leaves for HandsSource /
ControllersSource and run the same pipeline inside TeleopSession with
CloudXRLauncher(examples/teleop/python/gripper_retargeting_example_simple.py)。
- 录制与回放:TeleopSessionConfig 上的 McapRecordingConfig / McapReplayConfig
在不连接头显的情况下,通过这个完全相同的计算图回放一个 .mcap 文件。
- 驱动模拟器:Step 5 得到的动作向量正是 Isaac Lab 的
IsaacTeleopDevice 所消费的;TensorReorderer 的顺序必须与环境一致。
- 实时调参:MultiRetargeterTuningUI(isaacteleop[ui])编辑的正是
你在 Step 3 和 Step 8 中手动设置的同一批 ParameterState 对象。
""")
该摘要会收集 RESULTS 字典中由 section 装饰器填充的每个小节的标题,因此被跳过或失败的步骤会连同其错误一起显示在这里,而不会从运行中悄无声息地消失。
总之,我们在完全没有该框架所围绕设计的任何硬件的情况下,构建了一条可用的 Isaac Teleop 流水线,因为设备层以下的一切都是操作类型化张量组的纯 Python 代码。类型系统在错误发生的位置就捕获了错误的 dtype 和未写入的槽位;缺失的可选组为每个节点提供了一种明确的方式来得知跟踪已丢失;而 ParameterState 让我们能够在帧与帧之间调整行为并将其保存在磁盘上。内置的 gripper、SE(3)、locomotion 和 TriHand 重定向器在合成输入下的表现与文档所述一致,将它们与 ValueInput 叶子、TensorReorderer 和 OutputCombiner 组合后,产生的动作向量与头显会话馈送给 Isaac Lab 的完全相同,且每个叶子每帧只计算一次。在硬件介入之处,计算图不变:叶子变成 TeleopSession 内的 HandsSource 和 ControllersSource,录制变成 MCAP 回放,而我们手动施加的锚点变换则来自模拟器。
在此查看完整代码。所有功劳归于本项目的研究者。另外,欢迎在 Twitter 上关注我们,别忘了加入我们拥有 15 万+ 成员的 ML SubReddit 并订阅我们的 Newsletter。等等!你在 Telegram 上吗?现在你也可以在 Telegram 上加入我们了。
需要与我们合作来推广你的 GitHub 仓库、Hugging Face 页面、产品发布、网络研讨会等吗?联系我们。
文章《深入解析 NVIDIA 的 IsaacTeleop:从手部与控制器追踪到基于图的 Retargeting Engine 实现机器人动作》首次发布于 MarkTechPost。