Switch arm IK from world-frame to body-frame control so that pushing the XR controller forward always moves the arm in the robot's forward direction, regardless of chassis rotation. Key changes: - dual_arm_agent: convert EEF observations to body frame before passing to XR controller; send body-frame IK targets directly (removed convert_action_world_to_root) - xr_controller: XR deltas treated as body-frame deltas (no yaw rotation needed — VR view tracks robot heading naturally) - streaming: add debug frame save for stereo alignment diagnostics - mindrobot_2i_cfg: IdealPDActuator for trunk, disabled gravity - Author headers updated to Yutang Li, SIAT Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
192 lines
8.0 KiB
Python
192 lines
8.0 KiB
Python
# Copyright (c) 2025, Yutang Li, SIAT (yt.li2@siat.ac.cn)
|
|
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
"""XR teleoperation controller with absolute IK target accumulation and grip clutch."""
|
|
|
|
from collections.abc import Callable
|
|
|
|
import numpy as np
|
|
import torch
|
|
from scipy.spatial.transform import Rotation as R
|
|
|
|
from xr_utils import XrClient, transform_xr_pose, quat_diff_as_rotvec_xyzw, is_valid_quaternion
|
|
|
|
from .frame_utils import quat_wxyz_to_rotation, rotation_to_quat_wxyz
|
|
|
|
|
|
class XrTeleopController:
|
|
"""Teleop controller for PICO XR headset (absolute IK mode).
|
|
|
|
Accumulates an absolute EEF target in Isaac Sim world frame.
|
|
Grip button acts as clutch; B/Y buttons trigger environment reset.
|
|
"""
|
|
|
|
def __init__(self, arm="left", pos_sensitivity=1.0, rot_sensitivity=0.3,
|
|
max_pos_per_step=0.05, max_rot_per_step=0.08, xr_client=None):
|
|
self.xr_client = xr_client if xr_client is not None else XrClient()
|
|
self._owns_client = xr_client is None
|
|
self.pos_sensitivity = pos_sensitivity
|
|
self.rot_sensitivity = rot_sensitivity
|
|
self.max_pos_per_step = max_pos_per_step
|
|
self.max_rot_per_step = max_rot_per_step
|
|
self.arm = arm
|
|
|
|
self.controller_name = "left_controller" if arm == "left" else "right_controller"
|
|
self.grip_name = "left_grip" if arm == "left" else "right_grip"
|
|
self.trigger_name = "left_trigger" if arm == "left" else "right_trigger"
|
|
|
|
from xr_utils.geometry import R_HEADSET_TO_WORLD
|
|
self.R_headset_world = R_HEADSET_TO_WORLD
|
|
|
|
self.prev_xr_pos = None
|
|
self.prev_xr_quat = None
|
|
self.target_eef_pos = None
|
|
self.target_eef_quat = None
|
|
|
|
self.grip_active = False
|
|
self.frame_count = 0
|
|
self.reset_button_latched = False
|
|
self.require_grip_reengage = False
|
|
self.grip_engage_threshold = 0.8
|
|
self.grip_release_threshold = 0.2
|
|
self.callbacks = {}
|
|
self._root_yaw_rad = 0.0 # current chassis heading, updated each frame
|
|
|
|
def add_callback(self, name: str, func: Callable):
|
|
self.callbacks[name] = func
|
|
|
|
def reset(self):
|
|
self.prev_xr_pos = None
|
|
self.prev_xr_quat = None
|
|
self.grip_active = False
|
|
self.frame_count = 0
|
|
self.target_eef_pos = None
|
|
self.target_eef_quat = None
|
|
self.require_grip_reengage = True
|
|
|
|
def set_root_yaw(self, yaw_rad: float):
|
|
"""Update the chassis heading so XR deltas are in body frame."""
|
|
self._root_yaw_rad = yaw_rad
|
|
|
|
def close(self):
|
|
if self._owns_client:
|
|
self.xr_client.close()
|
|
|
|
def get_zero_action(self, trigger, current_eef_pos=None, current_eef_quat=None) -> torch.Tensor:
|
|
"""Return 8D hold action [x, y, z, qw, qx, qy, qz, gripper] at frozen target pose."""
|
|
action = torch.zeros(8)
|
|
if self.target_eef_pos is not None and self.target_eef_quat is not None:
|
|
action[:3] = torch.tensor(self.target_eef_pos.copy())
|
|
action[3:7] = torch.tensor(self.target_eef_quat.copy())
|
|
elif current_eef_pos is not None and current_eef_quat is not None:
|
|
action[:3] = torch.tensor(current_eef_pos.copy())
|
|
action[3:7] = torch.tensor(current_eef_quat.copy())
|
|
else:
|
|
action[3] = 1.0
|
|
action[7] = 1.0 if trigger > 0.5 else -1.0
|
|
return action
|
|
|
|
def advance(self, current_eef_pos=None, current_eef_quat=None) -> torch.Tensor:
|
|
"""Read XR controller and return 8D absolute action [x, y, z, qw, qx, qy, qz, gripper].
|
|
|
|
Position and quaternion are in Isaac Sim world frame.
|
|
Caller must convert to robot root frame before sending to IK.
|
|
"""
|
|
try:
|
|
if self.arm == "left":
|
|
reset_pressed = self.xr_client.get_button("Y")
|
|
else:
|
|
reset_pressed = False
|
|
if reset_pressed and not self.reset_button_latched:
|
|
if "RESET" in self.callbacks:
|
|
self.callbacks["RESET"]()
|
|
self.reset_button_latched = reset_pressed
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
raw_pose = self.xr_client.get_pose(self.controller_name)
|
|
grip = self.xr_client.get_key_value(self.grip_name)
|
|
trigger = self.xr_client.get_key_value(self.trigger_name)
|
|
except Exception:
|
|
return self.get_zero_action(0.0, current_eef_pos, current_eef_quat)
|
|
|
|
if not is_valid_quaternion(raw_pose[3:]):
|
|
return self.get_zero_action(trigger, current_eef_pos, current_eef_quat)
|
|
|
|
if self.require_grip_reengage:
|
|
if grip <= self.grip_release_threshold:
|
|
self.require_grip_reengage = False
|
|
else:
|
|
if current_eef_pos is not None and current_eef_quat is not None:
|
|
self.target_eef_pos = current_eef_pos.copy()
|
|
self.target_eef_quat = current_eef_quat.copy()
|
|
return self.get_zero_action(trigger, current_eef_pos, current_eef_quat)
|
|
|
|
grip_pressed = grip > self.grip_release_threshold if self.grip_active else grip >= self.grip_engage_threshold
|
|
|
|
if not grip_pressed:
|
|
self.prev_xr_pos = None
|
|
self.prev_xr_quat = None
|
|
if self.grip_active or self.target_eef_pos is None:
|
|
if current_eef_pos is not None and current_eef_quat is not None:
|
|
self.target_eef_pos = current_eef_pos.copy()
|
|
self.target_eef_quat = current_eef_quat.copy()
|
|
self.grip_active = False
|
|
return self.get_zero_action(trigger, current_eef_pos, current_eef_quat)
|
|
|
|
if not self.grip_active:
|
|
self.prev_xr_pos = raw_pose[:3].copy()
|
|
self.prev_xr_quat = raw_pose[3:].copy()
|
|
if current_eef_pos is not None and current_eef_quat is not None:
|
|
self.target_eef_pos = current_eef_pos.copy()
|
|
self.target_eef_quat = current_eef_quat.copy()
|
|
self.grip_active = True
|
|
return self.get_zero_action(trigger, current_eef_pos, current_eef_quat)
|
|
|
|
xr_world_pos, xr_world_quat_xyzw = transform_xr_pose(raw_pose[:3], raw_pose[3:])
|
|
prev_xr_world_pos, prev_xr_world_quat_xyzw = transform_xr_pose(self.prev_xr_pos, self.prev_xr_quat)
|
|
|
|
world_delta_pos = (xr_world_pos - prev_xr_world_pos) * self.pos_sensitivity
|
|
|
|
# In VR teleop, user "inhabits" the robot: their physical forward (XR +X)
|
|
# maps directly to robot forward (body +X). No rotation needed — the VR view
|
|
# already rotates with the robot, so XR deltas naturally align with body frame.
|
|
|
|
# Diagnostic
|
|
if self.frame_count % 30 == 0 and self.target_eef_pos is not None:
|
|
print(f"[XR {self.arm}] delta=({world_delta_pos[0]:+.4f},{world_delta_pos[1]:+.4f},{world_delta_pos[2]:+.4f}) "
|
|
f"target=({self.target_eef_pos[0]:.3f},{self.target_eef_pos[1]:.3f},{self.target_eef_pos[2]:.3f})")
|
|
|
|
pos_norm = np.linalg.norm(world_delta_pos)
|
|
if pos_norm > self.max_pos_per_step:
|
|
world_delta_pos *= self.max_pos_per_step / pos_norm
|
|
|
|
world_delta_rot = quat_diff_as_rotvec_xyzw(prev_xr_world_quat_xyzw, xr_world_quat_xyzw) * self.rot_sensitivity
|
|
rot_norm = np.linalg.norm(world_delta_rot)
|
|
if rot_norm > self.max_rot_per_step:
|
|
world_delta_rot *= self.max_rot_per_step / rot_norm
|
|
|
|
gripper_action = 1.0 if trigger > 0.5 else -1.0
|
|
|
|
if self.target_eef_pos is None:
|
|
self.target_eef_pos = np.zeros(3)
|
|
self.target_eef_quat = np.array([1.0, 0.0, 0.0, 0.0])
|
|
|
|
self.target_eef_pos += world_delta_pos
|
|
|
|
target_r = quat_wxyz_to_rotation(self.target_eef_quat)
|
|
self.target_eef_quat = rotation_to_quat_wxyz(R.from_rotvec(world_delta_rot) * target_r)
|
|
|
|
self.prev_xr_pos = raw_pose[:3].copy()
|
|
self.prev_xr_quat = raw_pose[3:].copy()
|
|
self.frame_count += 1
|
|
|
|
action = torch.tensor([
|
|
*self.target_eef_pos,
|
|
*self.target_eef_quat,
|
|
gripper_action,
|
|
], dtype=torch.float32)
|
|
|
|
return action
|