Browse Source
Add retargeting bridge for Isaac Sim teleoperation
Add retargeting bridge for Isaac Sim teleoperation
Adds a joint retargeting bridge that converts Quest 3 body tracking data into G1 robot joint commands for Isaac Sim via CycloneDDS. - Extend body_tracker.gd with 8 body joint transforms (56 floats) - Extend teleop_server.py with body_joints_shared memory - Add retarget_bridge.py: body retargeting, hand mapping, DDS publishing - Add launch_bridge.py: one-command launcher for sim + bridge - Update requirements.txt with unitree-sdk2py dependency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>master
5 changed files with 1090 additions and 1 deletions
-
20scripts/body_tracker.gd
-
207server/launch_bridge.py
-
5server/requirements.txt
-
846server/retarget_bridge.py
-
13server/teleop_server.py
@ -0,0 +1,207 @@ |
|||||
|
#!/usr/bin/env python3 |
||||
|
""" |
||||
|
Launch Isaac Sim + retargeting bridge, and print the Quest 3 connection URL. |
||||
|
|
||||
|
Starts everything needed in one command: |
||||
|
1. Isaac Sim (G1 with Inspire hands, DDS teleoperation) |
||||
|
2. Retargeting bridge (WebSocket server + body retargeting + DDS publisher) |
||||
|
|
||||
|
Usage: |
||||
|
python launch_bridge.py |
||||
|
python launch_bridge.py --task Isaac-PickPlace-RedBlock-G129-Inspire-Joint |
||||
|
python launch_bridge.py --no-sim # bridge only (if sim is already running) |
||||
|
python launch_bridge.py --device cuda # use GPU for simulation |
||||
|
""" |
||||
|
|
||||
|
import socket |
||||
|
import subprocess |
||||
|
import sys |
||||
|
import os |
||||
|
import signal |
||||
|
import time |
||||
|
import threading |
||||
|
|
||||
|
|
||||
|
SIM_DIR = os.path.expanduser("~/git/unitree_sim_isaaclab") |
||||
|
BRIDGE_DIR = os.path.dirname(os.path.abspath(__file__)) |
||||
|
|
||||
|
# Default Isaac Sim arguments |
||||
|
DEFAULT_TASK = "Isaac-PickPlace-Cylinder-G129-Inspire-Joint" |
||||
|
DEFAULT_DEVICE = "cpu" |
||||
|
|
||||
|
# Message printed by sim when DDS is ready |
||||
|
SIM_READY_MARKER = "start controller success" |
||||
|
|
||||
|
|
||||
|
def get_local_ip(): |
||||
|
"""Get local network IP via UDP socket (no traffic sent).""" |
||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
||||
|
try: |
||||
|
s.connect(("8.8.8.8", 80)) |
||||
|
return s.getsockname()[0] |
||||
|
finally: |
||||
|
s.close() |
||||
|
|
||||
|
|
||||
|
def stream_and_watch(proc, marker, event): |
||||
|
"""Read process stdout line-by-line, print it, and set event when marker seen.""" |
||||
|
for line in iter(proc.stdout.readline, ""): |
||||
|
sys.stdout.write(f" [sim] {line}") |
||||
|
sys.stdout.flush() |
||||
|
if marker in line: |
||||
|
event.set() |
||||
|
proc.stdout.close() |
||||
|
|
||||
|
|
||||
|
def stream_stderr(proc): |
||||
|
"""Forward process stderr.""" |
||||
|
for line in iter(proc.stderr.readline, ""): |
||||
|
sys.stderr.write(f" [sim] {line}") |
||||
|
sys.stderr.flush() |
||||
|
proc.stderr.close() |
||||
|
|
||||
|
|
||||
|
def main(): |
||||
|
# --- Parse our args (separate from bridge args) --- |
||||
|
port = 8765 |
||||
|
task = DEFAULT_TASK |
||||
|
device = DEFAULT_DEVICE |
||||
|
no_sim = False |
||||
|
bridge_args = [] |
||||
|
|
||||
|
# Simple arg parser that pulls out our flags and passes the rest to the bridge |
||||
|
args = sys.argv[1:] |
||||
|
i = 0 |
||||
|
while i < len(args): |
||||
|
if args[i] == "--port" and i + 1 < len(args): |
||||
|
port = int(args[i + 1]) |
||||
|
bridge_args.extend(args[i:i+2]) |
||||
|
i += 2 |
||||
|
elif args[i] == "--task" and i + 1 < len(args): |
||||
|
task = args[i + 1] |
||||
|
i += 2 |
||||
|
elif args[i] == "--device" and i + 1 < len(args): |
||||
|
device = args[i + 1] |
||||
|
i += 2 |
||||
|
elif args[i] == "--no-sim": |
||||
|
no_sim = True |
||||
|
i += 1 |
||||
|
else: |
||||
|
bridge_args.append(args[i]) |
||||
|
i += 1 |
||||
|
|
||||
|
ip = get_local_ip() |
||||
|
|
||||
|
# --- Banner --- |
||||
|
print() |
||||
|
print("=" * 60) |
||||
|
print(" G1 Teleop -> Isaac Sim Retargeting Bridge") |
||||
|
print("=" * 60) |
||||
|
print() |
||||
|
print(f" Quest 3 connection:") |
||||
|
print(f" IP: {ip}") |
||||
|
print(f" Port: {port}") |
||||
|
print() |
||||
|
if not no_sim: |
||||
|
print(f" Isaac Sim:") |
||||
|
print(f" Task: {task}") |
||||
|
print(f" Device: {device}") |
||||
|
print() |
||||
|
print("=" * 60) |
||||
|
print() |
||||
|
sys.stdout.flush() |
||||
|
|
||||
|
sim_proc = None |
||||
|
bridge_proc = None |
||||
|
|
||||
|
def cleanup(sig=None, frame=None): |
||||
|
"""Shut down both processes on exit.""" |
||||
|
print("\nShutting down...") |
||||
|
if bridge_proc and bridge_proc.poll() is None: |
||||
|
bridge_proc.send_signal(signal.SIGINT) |
||||
|
bridge_proc.wait(timeout=5) |
||||
|
if sim_proc and sim_proc.poll() is None: |
||||
|
sim_proc.send_signal(signal.SIGINT) |
||||
|
sim_proc.wait(timeout=10) |
||||
|
sys.exit(0) |
||||
|
|
||||
|
signal.signal(signal.SIGINT, cleanup) |
||||
|
signal.signal(signal.SIGTERM, cleanup) |
||||
|
|
||||
|
# --- Launch Isaac Sim --- |
||||
|
if not no_sim: |
||||
|
sim_cmd = [ |
||||
|
sys.executable, os.path.join(SIM_DIR, "sim_main.py"), |
||||
|
"--device", device, |
||||
|
"--enable_cameras", |
||||
|
"--task", task, |
||||
|
"--enable_inspire_dds", |
||||
|
"--robot_type", "g129", |
||||
|
] |
||||
|
print(f"[1/2] Starting Isaac Sim...") |
||||
|
print(f" {' '.join(sim_cmd)}") |
||||
|
print() |
||||
|
sys.stdout.flush() |
||||
|
|
||||
|
sim_proc = subprocess.Popen( |
||||
|
sim_cmd, |
||||
|
cwd=SIM_DIR, |
||||
|
stdout=subprocess.PIPE, |
||||
|
stderr=subprocess.PIPE, |
||||
|
text=True, |
||||
|
bufsize=1, |
||||
|
) |
||||
|
|
||||
|
# Stream sim output and watch for ready marker |
||||
|
sim_ready = threading.Event() |
||||
|
stdout_thread = threading.Thread( |
||||
|
target=stream_and_watch, args=(sim_proc, SIM_READY_MARKER, sim_ready), |
||||
|
daemon=True) |
||||
|
stderr_thread = threading.Thread( |
||||
|
target=stream_stderr, args=(sim_proc,), |
||||
|
daemon=True) |
||||
|
stdout_thread.start() |
||||
|
stderr_thread.start() |
||||
|
|
||||
|
# Wait for sim to be ready (timeout after 120s) |
||||
|
print(" Waiting for Isaac Sim to initialize (this may take a minute)...") |
||||
|
sys.stdout.flush() |
||||
|
if not sim_ready.wait(timeout=120): |
||||
|
if sim_proc.poll() is not None: |
||||
|
print("\n ERROR: Isaac Sim exited unexpectedly.") |
||||
|
sys.exit(1) |
||||
|
print("\n WARNING: Timed out waiting for sim ready marker.") |
||||
|
print(" Starting bridge anyway (sim may still be loading).") |
||||
|
else: |
||||
|
print(" Isaac Sim is ready!") |
||||
|
print() |
||||
|
sys.stdout.flush() |
||||
|
|
||||
|
# --- Launch bridge --- |
||||
|
bridge_cmd = [ |
||||
|
sys.executable, os.path.join(BRIDGE_DIR, "retarget_bridge.py"), |
||||
|
"--port", str(port), |
||||
|
] + bridge_args |
||||
|
|
||||
|
step = "2/2" if not no_sim else "1/1" |
||||
|
print(f"[{step}] Starting retargeting bridge...") |
||||
|
print() |
||||
|
print("=" * 60) |
||||
|
print(f" READY -- connect Quest 3 to: {ip}:{port}") |
||||
|
print("=" * 60) |
||||
|
print() |
||||
|
sys.stdout.flush() |
||||
|
|
||||
|
bridge_proc = subprocess.Popen(bridge_cmd, cwd=BRIDGE_DIR) |
||||
|
|
||||
|
try: |
||||
|
# Wait for bridge to exit (or Ctrl-C) |
||||
|
bridge_proc.wait() |
||||
|
except KeyboardInterrupt: |
||||
|
pass |
||||
|
finally: |
||||
|
cleanup() |
||||
|
|
||||
|
|
||||
|
if __name__ == "__main__": |
||||
|
main() |
||||
@ -0,0 +1,846 @@ |
|||||
|
#!/usr/bin/env python3 |
||||
|
""" |
||||
|
Retargeting bridge: Quest 3 body tracking -> G1 robot joint commands via DDS. |
||||
|
|
||||
|
Reads body tracking data from the teleop server's shared memory, retargets |
||||
|
body joint orientations to G1 robot joint angles, and publishes commands |
||||
|
over CycloneDDS for Isaac Sim (or real robot). |
||||
|
|
||||
|
Usage: |
||||
|
python retarget_bridge.py [--port 8765] [--dds-domain 1] [--hz 50] |
||||
|
|
||||
|
Architecture: |
||||
|
Quest 3 -> WebSocket -> TeleopServer (shared memory) |
||||
|
| |
||||
|
retarget_bridge.py |
||||
|
| |
||||
|
CycloneDDS -> Isaac Sim |
||||
|
""" |
||||
|
|
||||
|
import sys |
||||
|
import os |
||||
|
import time |
||||
|
import argparse |
||||
|
import logging |
||||
|
import signal |
||||
|
import numpy as np |
||||
|
from multiprocessing import Array |
||||
|
|
||||
|
# Local imports |
||||
|
from teleop_server import TeleopServer |
||||
|
|
||||
|
logger = logging.getLogger("retarget_bridge") |
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Coordinate transform constants |
||||
|
# --------------------------------------------------------------------------- |
||||
|
# OpenXR (Y-up, -Z forward, X-right) -> Robot (X-forward, Y-left, Z-up) |
||||
|
R_ROBOT_OPENXR = np.array([[ 0, 0,-1], |
||||
|
[-1, 0, 0], |
||||
|
[ 0, 1, 0]], dtype=np.float64) |
||||
|
R_OPENXR_ROBOT = R_ROBOT_OPENXR.T |
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# G1 joint indices (29 DOF) |
||||
|
# --------------------------------------------------------------------------- |
||||
|
NUM_JOINTS = 29 |
||||
|
|
||||
|
# Waist |
||||
|
WAIST_YAW = 12 |
||||
|
WAIST_ROLL = 13 |
||||
|
WAIST_PITCH = 14 |
||||
|
|
||||
|
# Left arm |
||||
|
L_SHOULDER_PITCH = 15 |
||||
|
L_SHOULDER_ROLL = 16 |
||||
|
L_SHOULDER_YAW = 17 |
||||
|
L_ELBOW = 18 |
||||
|
L_WRIST_ROLL = 19 |
||||
|
L_WRIST_PITCH = 20 |
||||
|
L_WRIST_YAW = 21 |
||||
|
|
||||
|
# Right arm |
||||
|
R_SHOULDER_PITCH = 22 |
||||
|
R_SHOULDER_ROLL = 23 |
||||
|
R_SHOULDER_YAW = 24 |
||||
|
R_ELBOW = 25 |
||||
|
R_WRIST_ROLL = 26 |
||||
|
R_WRIST_PITCH = 27 |
||||
|
R_WRIST_YAW = 28 |
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Body joints shared memory layout (8 joints x 7 floats = 56) |
||||
|
# Each joint: [x, y, z, qx, qy, qz, qw] |
||||
|
# --------------------------------------------------------------------------- |
||||
|
BJ_HIPS = 0 # offset 0 |
||||
|
BJ_CHEST = 1 # offset 7 |
||||
|
BJ_L_UPPER_ARM = 2 # offset 14 |
||||
|
BJ_L_LOWER_ARM = 3 # offset 21 |
||||
|
BJ_L_WRIST = 4 # offset 28 |
||||
|
BJ_R_UPPER_ARM = 5 # offset 35 |
||||
|
BJ_R_LOWER_ARM = 6 # offset 42 |
||||
|
BJ_R_WRIST = 7 # offset 49 |
||||
|
NUM_BODY_JOINTS = 8 |
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# URDF joint limits (from g1_custom_collision_29dof.urdf) |
||||
|
# --------------------------------------------------------------------------- |
||||
|
JOINT_LIMITS = { |
||||
|
12: (-2.618, 2.618), # waist_yaw |
||||
|
13: (-0.52, 0.52), # waist_roll |
||||
|
14: (-0.52, 0.52), # waist_pitch |
||||
|
15: (-3.0892, 2.6704), # left_shoulder_pitch |
||||
|
16: (-1.5882, 2.2515), # left_shoulder_roll |
||||
|
17: (-2.618, 2.618), # left_shoulder_yaw |
||||
|
18: (-1.0472, 2.0944), # left_elbow |
||||
|
19: (-1.972, 1.972), # left_wrist_roll |
||||
|
20: (-1.614, 1.614), # left_wrist_pitch |
||||
|
21: (-1.614, 1.614), # left_wrist_yaw |
||||
|
22: (-3.0892, 2.6704), # right_shoulder_pitch |
||||
|
23: (-2.2515, 1.5882), # right_shoulder_roll |
||||
|
24: (-2.618, 2.618), # right_shoulder_yaw |
||||
|
25: (-1.0472, 2.0944), # right_elbow |
||||
|
26: (-1.972, 1.972), # right_wrist_roll |
||||
|
27: (-1.614, 1.614), # right_wrist_pitch |
||||
|
28: (-1.614, 1.614), # right_wrist_yaw |
||||
|
} |
||||
|
|
||||
|
SOFT_LIMIT_FACTOR = 0.9 |
||||
|
|
||||
|
# Default Kp/Kd from Unitree SDK example |
||||
|
KP = [ |
||||
|
60, 60, 60, 100, 40, 40, # Left leg |
||||
|
60, 60, 60, 100, 40, 40, # Right leg |
||||
|
60, 40, 40, # Waist |
||||
|
40, 40, 40, 40, 40, 40, 40, # Left arm |
||||
|
40, 40, 40, 40, 40, 40, 40, # Right arm |
||||
|
] |
||||
|
KD = [ |
||||
|
1, 1, 1, 2, 1, 1, |
||||
|
1, 1, 1, 2, 1, 1, |
||||
|
1, 1, 1, |
||||
|
1, 1, 1, 1, 1, 1, 1, |
||||
|
1, 1, 1, 1, 1, 1, 1, |
||||
|
] |
||||
|
|
||||
|
# Inspire hand joint ordering in DDS MotorCmds_ (12 total) |
||||
|
# Indices 0-5: right hand, 6-11: left hand |
||||
|
INSPIRE_R_PINKY = 0 |
||||
|
INSPIRE_R_RING = 1 |
||||
|
INSPIRE_R_MIDDLE = 2 |
||||
|
INSPIRE_R_INDEX = 3 |
||||
|
INSPIRE_R_THUMB_PITCH = 4 |
||||
|
INSPIRE_R_THUMB_YAW = 5 |
||||
|
INSPIRE_L_PINKY = 6 |
||||
|
INSPIRE_L_RING = 7 |
||||
|
INSPIRE_L_MIDDLE = 8 |
||||
|
INSPIRE_L_INDEX = 9 |
||||
|
INSPIRE_L_THUMB_PITCH = 10 |
||||
|
INSPIRE_L_THUMB_YAW = 11 |
||||
|
|
||||
|
# Quest hand joint indices (XR_EXT_hand_tracking, 25 per hand) |
||||
|
QH_PALM = 0 |
||||
|
QH_WRIST = 1 |
||||
|
QH_THUMB_METACARPAL = 2 |
||||
|
QH_THUMB_PROXIMAL = 3 |
||||
|
QH_THUMB_TIP = 4 |
||||
|
QH_INDEX_METACARPAL = 5 |
||||
|
QH_INDEX_PROXIMAL = 6 |
||||
|
QH_INDEX_INTERMEDIATE = 7 |
||||
|
QH_INDEX_TIP = 8 |
||||
|
QH_MIDDLE_METACARPAL = 9 |
||||
|
QH_MIDDLE_PROXIMAL = 10 |
||||
|
QH_MIDDLE_INTERMEDIATE = 11 |
||||
|
QH_MIDDLE_TIP = 12 |
||||
|
QH_RING_METACARPAL = 13 |
||||
|
QH_RING_PROXIMAL = 14 |
||||
|
QH_RING_INTERMEDIATE = 15 |
||||
|
QH_RING_TIP = 16 |
||||
|
QH_PINKY_METACARPAL = 17 |
||||
|
QH_PINKY_PROXIMAL = 18 |
||||
|
QH_PINKY_INTERMEDIATE = 19 |
||||
|
QH_PINKY_TIP = 20 |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Rotation math utilities |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
def _rx(angle): |
||||
|
"""Rotation matrix about X axis.""" |
||||
|
c, s = np.cos(angle), np.sin(angle) |
||||
|
return np.array([[1, 0, 0], [0, c, -s], [0, s, c]], dtype=np.float64) |
||||
|
|
||||
|
def _ry(angle): |
||||
|
"""Rotation matrix about Y axis.""" |
||||
|
c, s = np.cos(angle), np.sin(angle) |
||||
|
return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]], dtype=np.float64) |
||||
|
|
||||
|
def _rz(angle): |
||||
|
"""Rotation matrix about Z axis.""" |
||||
|
c, s = np.cos(angle), np.sin(angle) |
||||
|
return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]], dtype=np.float64) |
||||
|
|
||||
|
|
||||
|
def quat_to_rotmat(qx, qy, qz, qw): |
||||
|
"""Quaternion to 3x3 rotation matrix.""" |
||||
|
xx = qx * qx; yy = qy * qy; zz = qz * qz |
||||
|
xy = qx * qy; xz = qx * qz; yz = qy * qz |
||||
|
wx = qw * qx; wy = qw * qy; wz = qw * qz |
||||
|
return np.array([ |
||||
|
[1 - 2*(yy + zz), 2*(xy - wz), 2*(xz + wy)], |
||||
|
[2*(xy + wz), 1 - 2*(xx + zz), 2*(yz - wx)], |
||||
|
[2*(xz - wy), 2*(yz + wx), 1 - 2*(xx + yy)], |
||||
|
], dtype=np.float64) |
||||
|
|
||||
|
|
||||
|
def euler_zxy(R): |
||||
|
"""Decompose R = Rz(yaw) * Rx(roll) * Ry(pitch). Returns (yaw, roll, pitch). |
||||
|
|
||||
|
Used for waist joints: yaw(Z) -> roll(X) -> pitch(Y). |
||||
|
""" |
||||
|
# R[2,1] = sin(roll) |
||||
|
roll = np.arcsin(np.clip(R[2, 1], -1.0, 1.0)) |
||||
|
if np.abs(R[2, 1]) < 0.9999: |
||||
|
yaw = np.arctan2(-R[0, 1], R[1, 1]) |
||||
|
pitch = np.arctan2(-R[2, 0], R[2, 2]) |
||||
|
else: |
||||
|
yaw = np.arctan2(R[1, 0], R[0, 0]) |
||||
|
pitch = 0.0 |
||||
|
return yaw, roll, pitch |
||||
|
|
||||
|
|
||||
|
def euler_yxz(R): |
||||
|
"""Decompose R = Ry(pitch) * Rx(roll) * Rz(yaw). Returns (pitch, roll, yaw). |
||||
|
|
||||
|
Used for shoulder joints: pitch(Y) -> roll(X) -> yaw(Z). |
||||
|
""" |
||||
|
# R[1,2] = -sin(roll) |
||||
|
roll = np.arcsin(np.clip(-R[1, 2], -1.0, 1.0)) |
||||
|
if np.abs(R[1, 2]) < 0.9999: |
||||
|
pitch = np.arctan2(R[0, 2], R[2, 2]) |
||||
|
yaw = np.arctan2(R[1, 0], R[1, 1]) |
||||
|
else: |
||||
|
pitch = np.arctan2(-R[2, 0], R[0, 0]) |
||||
|
yaw = 0.0 |
||||
|
return pitch, roll, yaw |
||||
|
|
||||
|
|
||||
|
def euler_xyz(R): |
||||
|
"""Decompose R = Rx(roll) * Ry(pitch) * Rz(yaw). Returns (roll, pitch, yaw). |
||||
|
|
||||
|
Used for wrist joints: roll(X) -> pitch(Y) -> yaw(Z). |
||||
|
""" |
||||
|
# R[0,2] = sin(pitch) |
||||
|
pitch = np.arcsin(np.clip(R[0, 2], -1.0, 1.0)) |
||||
|
if np.abs(R[0, 2]) < 0.9999: |
||||
|
roll = np.arctan2(-R[1, 2], R[2, 2]) |
||||
|
yaw = np.arctan2(-R[0, 1], R[0, 0]) |
||||
|
else: |
||||
|
roll = np.arctan2(R[2, 1], R[1, 1]) |
||||
|
yaw = 0.0 |
||||
|
return roll, pitch, yaw |
||||
|
|
||||
|
|
||||
|
def extract_y_rotation(R): |
||||
|
"""Extract the Y-axis rotation angle from a rotation matrix. |
||||
|
|
||||
|
Used for elbow (single-axis pitch). |
||||
|
""" |
||||
|
return np.arctan2(R[0, 2], R[0, 0]) |
||||
|
|
||||
|
|
||||
|
# Pre-compute shoulder rest pose rotations |
||||
|
L_SHOULDER_REST = _ry(0.27931) @ _rx(-0.27925) |
||||
|
R_SHOULDER_REST = _ry(-0.27931) @ _rx(0.27925) |
||||
|
L_SHOULDER_REST_INV = L_SHOULDER_REST.T |
||||
|
R_SHOULDER_REST_INV = R_SHOULDER_REST.T |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Joint smoother |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
class JointSmoother: |
||||
|
"""EMA smoothing with rate limiter for joint angles.""" |
||||
|
|
||||
|
def __init__(self, n_joints, alpha=0.3, max_delta=0.15): |
||||
|
""" |
||||
|
Args: |
||||
|
n_joints: Number of joints to smooth. |
||||
|
alpha: EMA factor (0=no change, 1=instant). Default 0.3. |
||||
|
max_delta: Max angle change per step in radians. At 50Hz, |
||||
|
0.15 rad/step = 7.5 rad/s. |
||||
|
""" |
||||
|
self.prev = np.zeros(n_joints) |
||||
|
self.alpha = alpha |
||||
|
self.max_delta = max_delta |
||||
|
|
||||
|
def smooth(self, target): |
||||
|
"""Apply EMA + rate limiting. Returns smoothed values.""" |
||||
|
# EMA |
||||
|
smoothed = self.alpha * target + (1.0 - self.alpha) * self.prev |
||||
|
# Rate limiter |
||||
|
delta = np.clip(smoothed - self.prev, -self.max_delta, self.max_delta) |
||||
|
result = self.prev + delta |
||||
|
self.prev = result.copy() |
||||
|
return result |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Body retargeter |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
class BodyRetargeter: |
||||
|
"""Converts body tracking poses to G1 joint angles via orientation decomposition.""" |
||||
|
|
||||
|
def __init__(self): |
||||
|
# Pre-compute soft joint limits |
||||
|
self.soft_lo = np.zeros(NUM_JOINTS) |
||||
|
self.soft_hi = np.zeros(NUM_JOINTS) |
||||
|
for idx, (lo, hi) in JOINT_LIMITS.items(): |
||||
|
mid = (lo + hi) / 2.0 |
||||
|
half = (hi - lo) / 2.0 |
||||
|
self.soft_lo[idx] = mid - SOFT_LIMIT_FACTOR * half |
||||
|
self.soft_hi[idx] = mid + SOFT_LIMIT_FACTOR * half |
||||
|
|
||||
|
# Calibration: captured at first valid frame |
||||
|
self._calibrated = False |
||||
|
self._ref_rotations = {} # joint_group -> R_rel at calibration |
||||
|
|
||||
|
def retarget(self, body_rotmats): |
||||
|
"""Compute 29 joint angles from body tracking rotation matrices. |
||||
|
|
||||
|
Args: |
||||
|
body_rotmats: dict mapping joint index (BJ_*) to 3x3 rotation matrix |
||||
|
in OpenXR world frame. |
||||
|
|
||||
|
Returns: |
||||
|
np.ndarray of shape (29,) with joint angles in radians. |
||||
|
""" |
||||
|
angles = np.zeros(NUM_JOINTS) |
||||
|
|
||||
|
# --- Waist: HIPS -> CHEST --- |
||||
|
R_hips = body_rotmats[BJ_HIPS] |
||||
|
R_chest = body_rotmats[BJ_CHEST] |
||||
|
R_waist_rel = self._to_robot_rel(R_hips, R_chest) |
||||
|
|
||||
|
if not self._calibrated: |
||||
|
self._ref_rotations['waist'] = R_waist_rel.copy() |
||||
|
|
||||
|
R_waist_delta = self._ref_rotations.get('waist', np.eye(3)).T @ R_waist_rel |
||||
|
yaw, roll, pitch = euler_zxy(R_waist_delta) |
||||
|
angles[WAIST_YAW] = yaw |
||||
|
angles[WAIST_ROLL] = roll |
||||
|
angles[WAIST_PITCH] = pitch |
||||
|
|
||||
|
# --- Left shoulder: CHEST -> LEFT_UPPER_ARM --- |
||||
|
R_l_upper = body_rotmats[BJ_L_UPPER_ARM] |
||||
|
R_l_shoulder_rel = self._to_robot_rel(R_chest, R_l_upper) |
||||
|
|
||||
|
if not self._calibrated: |
||||
|
self._ref_rotations['l_shoulder'] = R_l_shoulder_rel.copy() |
||||
|
|
||||
|
R_l_shoulder_delta = self._ref_rotations.get('l_shoulder', np.eye(3)).T @ R_l_shoulder_rel |
||||
|
l_sp, l_sr, l_sy = euler_yxz(R_l_shoulder_delta) |
||||
|
angles[L_SHOULDER_PITCH] = l_sp |
||||
|
angles[L_SHOULDER_ROLL] = l_sr |
||||
|
angles[L_SHOULDER_YAW] = l_sy |
||||
|
|
||||
|
# --- Left elbow: LEFT_UPPER_ARM -> LEFT_LOWER_ARM --- |
||||
|
R_l_lower = body_rotmats[BJ_L_LOWER_ARM] |
||||
|
R_l_elbow_rel = self._to_robot_rel(R_l_upper, R_l_lower) |
||||
|
|
||||
|
if not self._calibrated: |
||||
|
self._ref_rotations['l_elbow'] = R_l_elbow_rel.copy() |
||||
|
|
||||
|
R_l_elbow_delta = self._ref_rotations.get('l_elbow', np.eye(3)).T @ R_l_elbow_rel |
||||
|
angles[L_ELBOW] = extract_y_rotation(R_l_elbow_delta) |
||||
|
|
||||
|
# --- Left wrist: LEFT_LOWER_ARM -> LEFT_WRIST --- |
||||
|
R_l_wrist = body_rotmats[BJ_L_WRIST] |
||||
|
R_l_wrist_rel = self._to_robot_rel(R_l_lower, R_l_wrist) |
||||
|
|
||||
|
if not self._calibrated: |
||||
|
self._ref_rotations['l_wrist'] = R_l_wrist_rel.copy() |
||||
|
|
||||
|
R_l_wrist_delta = self._ref_rotations.get('l_wrist', np.eye(3)).T @ R_l_wrist_rel |
||||
|
l_wr, l_wp, l_wy = euler_xyz(R_l_wrist_delta) |
||||
|
angles[L_WRIST_ROLL] = l_wr |
||||
|
angles[L_WRIST_PITCH] = l_wp |
||||
|
angles[L_WRIST_YAW] = l_wy |
||||
|
|
||||
|
# --- Right shoulder: CHEST -> RIGHT_UPPER_ARM --- |
||||
|
R_r_upper = body_rotmats[BJ_R_UPPER_ARM] |
||||
|
R_r_shoulder_rel = self._to_robot_rel(R_chest, R_r_upper) |
||||
|
|
||||
|
if not self._calibrated: |
||||
|
self._ref_rotations['r_shoulder'] = R_r_shoulder_rel.copy() |
||||
|
|
||||
|
R_r_shoulder_delta = self._ref_rotations.get('r_shoulder', np.eye(3)).T @ R_r_shoulder_rel |
||||
|
r_sp, r_sr, r_sy = euler_yxz(R_r_shoulder_delta) |
||||
|
angles[R_SHOULDER_PITCH] = r_sp |
||||
|
angles[R_SHOULDER_ROLL] = r_sr |
||||
|
angles[R_SHOULDER_YAW] = r_sy |
||||
|
|
||||
|
# --- Right elbow: RIGHT_UPPER_ARM -> RIGHT_LOWER_ARM --- |
||||
|
R_r_lower = body_rotmats[BJ_R_LOWER_ARM] |
||||
|
R_r_elbow_rel = self._to_robot_rel(R_r_upper, R_r_lower) |
||||
|
|
||||
|
if not self._calibrated: |
||||
|
self._ref_rotations['r_elbow'] = R_r_elbow_rel.copy() |
||||
|
|
||||
|
R_r_elbow_delta = self._ref_rotations.get('r_elbow', np.eye(3)).T @ R_r_elbow_rel |
||||
|
angles[R_ELBOW] = extract_y_rotation(R_r_elbow_delta) |
||||
|
|
||||
|
# --- Right wrist: RIGHT_LOWER_ARM -> RIGHT_WRIST --- |
||||
|
R_r_wrist = body_rotmats[BJ_R_WRIST] |
||||
|
R_r_wrist_rel = self._to_robot_rel(R_r_lower, R_r_wrist) |
||||
|
|
||||
|
if not self._calibrated: |
||||
|
self._ref_rotations['r_wrist'] = R_r_wrist_rel.copy() |
||||
|
|
||||
|
R_r_wrist_delta = self._ref_rotations.get('r_wrist', np.eye(3)).T @ R_r_wrist_rel |
||||
|
r_wr, r_wp, r_wy = euler_xyz(R_r_wrist_delta) |
||||
|
angles[R_WRIST_ROLL] = r_wr |
||||
|
angles[R_WRIST_PITCH] = r_wp |
||||
|
angles[R_WRIST_YAW] = r_wy |
||||
|
|
||||
|
# Mark calibration complete |
||||
|
if not self._calibrated: |
||||
|
self._calibrated = True |
||||
|
logger.info("Calibration captured (neutral pose reference)") |
||||
|
|
||||
|
# Clamp to soft joint limits |
||||
|
for idx in JOINT_LIMITS: |
||||
|
angles[idx] = np.clip(angles[idx], self.soft_lo[idx], self.soft_hi[idx]) |
||||
|
|
||||
|
return angles |
||||
|
|
||||
|
def _to_robot_rel(self, R_parent_openxr, R_child_openxr): |
||||
|
"""Compute parent-relative rotation in robot frame. |
||||
|
|
||||
|
R_rel_openxr = R_parent^T @ R_child |
||||
|
R_rel_robot = R_ROBOT_OPENXR @ R_rel_openxr @ R_OPENXR_ROBOT |
||||
|
""" |
||||
|
R_rel = R_parent_openxr.T @ R_child_openxr |
||||
|
return R_ROBOT_OPENXR @ R_rel @ R_OPENXR_ROBOT |
||||
|
|
||||
|
def reset_calibration(self): |
||||
|
"""Reset calibration so next frame becomes the new reference.""" |
||||
|
self._calibrated = False |
||||
|
self._ref_rotations.clear() |
||||
|
logger.info("Calibration reset -- next valid frame will be captured") |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Hand retargeter |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
class HandRetargeter: |
||||
|
"""Maps Quest hand tracking to Inspire hand joint values.""" |
||||
|
|
||||
|
def retarget(self, left_hand_pos, right_hand_pos): |
||||
|
"""Compute 12 Inspire hand joint values from Quest hand positions. |
||||
|
|
||||
|
Args: |
||||
|
left_hand_pos: (25, 3) wrist-relative hand positions (robot frame) |
||||
|
right_hand_pos: (25, 3) wrist-relative hand positions (robot frame) |
||||
|
|
||||
|
Returns: |
||||
|
np.ndarray of shape (12,) with normalized hand values. |
||||
|
Convention: 1.0 = closed, 0.0 = open (matching Inspire DDS). |
||||
|
""" |
||||
|
hand_cmd = np.zeros(12) |
||||
|
|
||||
|
if np.any(left_hand_pos != 0): |
||||
|
hand_cmd[INSPIRE_L_INDEX] = self._finger_curl( |
||||
|
left_hand_pos, QH_INDEX_METACARPAL, QH_INDEX_PROXIMAL, |
||||
|
QH_INDEX_INTERMEDIATE, QH_INDEX_TIP) |
||||
|
hand_cmd[INSPIRE_L_MIDDLE] = self._finger_curl( |
||||
|
left_hand_pos, QH_MIDDLE_METACARPAL, QH_MIDDLE_PROXIMAL, |
||||
|
QH_MIDDLE_INTERMEDIATE, QH_MIDDLE_TIP) |
||||
|
hand_cmd[INSPIRE_L_RING] = self._finger_curl( |
||||
|
left_hand_pos, QH_RING_METACARPAL, QH_RING_PROXIMAL, |
||||
|
QH_RING_INTERMEDIATE, QH_RING_TIP) |
||||
|
hand_cmd[INSPIRE_L_PINKY] = self._finger_curl( |
||||
|
left_hand_pos, QH_PINKY_METACARPAL, QH_PINKY_PROXIMAL, |
||||
|
QH_PINKY_INTERMEDIATE, QH_PINKY_TIP) |
||||
|
t_pitch, t_yaw = self._thumb_angles(left_hand_pos) |
||||
|
hand_cmd[INSPIRE_L_THUMB_PITCH] = t_pitch |
||||
|
hand_cmd[INSPIRE_L_THUMB_YAW] = t_yaw |
||||
|
|
||||
|
if np.any(right_hand_pos != 0): |
||||
|
hand_cmd[INSPIRE_R_INDEX] = self._finger_curl( |
||||
|
right_hand_pos, QH_INDEX_METACARPAL, QH_INDEX_PROXIMAL, |
||||
|
QH_INDEX_INTERMEDIATE, QH_INDEX_TIP) |
||||
|
hand_cmd[INSPIRE_R_MIDDLE] = self._finger_curl( |
||||
|
right_hand_pos, QH_MIDDLE_METACARPAL, QH_MIDDLE_PROXIMAL, |
||||
|
QH_MIDDLE_INTERMEDIATE, QH_MIDDLE_TIP) |
||||
|
hand_cmd[INSPIRE_R_RING] = self._finger_curl( |
||||
|
right_hand_pos, QH_RING_METACARPAL, QH_RING_PROXIMAL, |
||||
|
QH_RING_INTERMEDIATE, QH_RING_TIP) |
||||
|
hand_cmd[INSPIRE_R_PINKY] = self._finger_curl( |
||||
|
right_hand_pos, QH_PINKY_METACARPAL, QH_PINKY_PROXIMAL, |
||||
|
QH_PINKY_INTERMEDIATE, QH_PINKY_TIP) |
||||
|
t_pitch, t_yaw = self._thumb_angles(right_hand_pos) |
||||
|
hand_cmd[INSPIRE_R_THUMB_PITCH] = t_pitch |
||||
|
hand_cmd[INSPIRE_R_THUMB_YAW] = t_yaw |
||||
|
|
||||
|
return hand_cmd |
||||
|
|
||||
|
def _finger_curl(self, positions, meta_idx, prox_idx, inter_idx, tip_idx): |
||||
|
"""Compute normalized finger curl value. |
||||
|
|
||||
|
Returns: 0.0 (open) to 1.0 (closed) matching Inspire DDS convention. |
||||
|
""" |
||||
|
v1 = positions[prox_idx] - positions[meta_idx] |
||||
|
v2 = positions[inter_idx] - positions[prox_idx] |
||||
|
v3 = positions[tip_idx] - positions[inter_idx] |
||||
|
|
||||
|
n1 = np.linalg.norm(v1) |
||||
|
n2 = np.linalg.norm(v2) |
||||
|
n3 = np.linalg.norm(v3) |
||||
|
|
||||
|
if n1 < 1e-6 or n2 < 1e-6 or n3 < 1e-6: |
||||
|
return 0.0 |
||||
|
|
||||
|
# Angle between consecutive bone segments |
||||
|
cos1 = np.clip(np.dot(v1 / n1, v2 / n2), -1.0, 1.0) |
||||
|
cos2 = np.clip(np.dot(v2 / n2, v3 / n3), -1.0, 1.0) |
||||
|
angle1 = np.arccos(cos1) |
||||
|
angle2 = np.arccos(cos2) |
||||
|
|
||||
|
# Average curl angle: 0 (straight) to ~pi/2 (curled) |
||||
|
avg_curl = (angle1 + angle2) / 2.0 |
||||
|
|
||||
|
# Normalize to [0, 1] where 1 = fully curled |
||||
|
# Max curl is approximately 1.5 rad |
||||
|
normalized = np.clip(avg_curl / 1.5, 0.0, 1.0) |
||||
|
return normalized |
||||
|
|
||||
|
def _thumb_angles(self, positions): |
||||
|
"""Compute thumb pitch and yaw from hand positions. |
||||
|
|
||||
|
Returns: (pitch_normalized, yaw_normalized) both in [0, 1]. |
||||
|
""" |
||||
|
meta = positions[QH_THUMB_METACARPAL] |
||||
|
prox = positions[QH_THUMB_PROXIMAL] |
||||
|
tip = positions[QH_THUMB_TIP] |
||||
|
|
||||
|
v1 = prox - meta |
||||
|
v2 = tip - prox |
||||
|
n1 = np.linalg.norm(v1) |
||||
|
n2 = np.linalg.norm(v2) |
||||
|
|
||||
|
if n1 < 1e-6 or n2 < 1e-6: |
||||
|
return 0.0, 0.5 |
||||
|
|
||||
|
v1n = v1 / n1 |
||||
|
v2n = v2 / n2 |
||||
|
|
||||
|
# Pitch: flexion angle between thumb bones |
||||
|
cos_pitch = np.clip(np.dot(v1n, v2n), -1.0, 1.0) |
||||
|
pitch_rad = np.arccos(cos_pitch) |
||||
|
# Inspire thumb pitch range: [0.0, 0.5] rad |
||||
|
# Normalize: 1.0 = max flexion (0.5 rad), 0.0 = straight (0 rad) |
||||
|
pitch_norm = np.clip(pitch_rad / 0.5, 0.0, 1.0) |
||||
|
|
||||
|
# Yaw: abduction (spread) of thumb |
||||
|
# Use the lateral component of the thumb direction relative to palm |
||||
|
palm = positions[QH_PALM] |
||||
|
index_meta = positions[QH_INDEX_METACARPAL] |
||||
|
palm_to_index = index_meta - palm |
||||
|
palm_to_index_n = np.linalg.norm(palm_to_index) |
||||
|
if palm_to_index_n < 1e-6: |
||||
|
return pitch_norm, 0.5 |
||||
|
|
||||
|
# Project thumb direction onto palm plane |
||||
|
palm_dir = palm_to_index / palm_to_index_n |
||||
|
thumb_dir = (tip - meta) |
||||
|
thumb_dir_n = np.linalg.norm(thumb_dir) |
||||
|
if thumb_dir_n < 1e-6: |
||||
|
return pitch_norm, 0.5 |
||||
|
thumb_dir = thumb_dir / thumb_dir_n |
||||
|
|
||||
|
# Yaw angle: how far the thumb points away from the index finger |
||||
|
cos_yaw = np.clip(np.dot(thumb_dir, palm_dir), -1.0, 1.0) |
||||
|
yaw_rad = np.arccos(cos_yaw) |
||||
|
# Inspire thumb yaw range: [-0.1, 1.3] rad. Normalize to [0, 1]. |
||||
|
yaw_norm = np.clip((yaw_rad - (-0.1)) / (1.3 - (-0.1)), 0.0, 1.0) |
||||
|
|
||||
|
return pitch_norm, yaw_norm |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# DDS publisher |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
class DDSPublisher: |
||||
|
"""Publishes G1 joint commands and Inspire hand commands via CycloneDDS.""" |
||||
|
|
||||
|
def __init__(self, domain_id=1): |
||||
|
from unitree_sdk2py.core.channel import ( |
||||
|
ChannelPublisher, ChannelFactoryInitialize) |
||||
|
from unitree_sdk2py.idl.unitree_hg.msg.dds_ import LowCmd_ |
||||
|
from unitree_sdk2py.idl.default import unitree_hg_msg_dds__LowCmd_ |
||||
|
from unitree_sdk2py.utils.crc import CRC |
||||
|
from unitree_sdk2py.idl.unitree_go.msg.dds_ import MotorCmds_ |
||||
|
from unitree_sdk2py.idl.default import unitree_go_msg_dds__MotorCmd_ |
||||
|
|
||||
|
logger.info(f"Initializing DDS (domain {domain_id})...") |
||||
|
ChannelFactoryInitialize(domain_id) |
||||
|
|
||||
|
# Body joint publisher |
||||
|
self._body_pub = ChannelPublisher("rt/lowcmd", LowCmd_) |
||||
|
self._body_pub.Init() |
||||
|
self._low_cmd = unitree_hg_msg_dds__LowCmd_() |
||||
|
self._crc = CRC() |
||||
|
self._MotorCmd_ = unitree_go_msg_dds__MotorCmd_ |
||||
|
|
||||
|
# Inspire hand publisher |
||||
|
self._hand_pub = ChannelPublisher("rt/inspire/cmd", MotorCmds_) |
||||
|
self._hand_pub.Init() |
||||
|
self._MotorCmds_ = MotorCmds_ |
||||
|
|
||||
|
logger.info("DDS publishers initialized (rt/lowcmd, rt/inspire/cmd)") |
||||
|
|
||||
|
def publish_body(self, joint_angles): |
||||
|
"""Publish 29 joint angle commands via LowCmd_. |
||||
|
|
||||
|
Args: |
||||
|
joint_angles: np.ndarray of shape (29,) in radians. |
||||
|
""" |
||||
|
cmd = self._low_cmd |
||||
|
cmd.mode_pr = 0 |
||||
|
cmd.mode_machine = 0 |
||||
|
|
||||
|
for i in range(NUM_JOINTS): |
||||
|
cmd.motor_cmd[i].mode = 1 |
||||
|
cmd.motor_cmd[i].q = float(joint_angles[i]) |
||||
|
cmd.motor_cmd[i].dq = 0.0 |
||||
|
cmd.motor_cmd[i].tau = 0.0 |
||||
|
cmd.motor_cmd[i].kp = float(KP[i]) |
||||
|
cmd.motor_cmd[i].kd = float(KD[i]) |
||||
|
|
||||
|
# Zero out legs (not retargeted -- leave to standing controller) |
||||
|
for i in range(12): |
||||
|
cmd.motor_cmd[i].mode = 0 |
||||
|
cmd.motor_cmd[i].kp = 0.0 |
||||
|
cmd.motor_cmd[i].kd = 0.0 |
||||
|
|
||||
|
cmd.crc = self._crc.Crc(cmd) |
||||
|
self._body_pub.Write(cmd) |
||||
|
|
||||
|
def publish_hands(self, hand_values): |
||||
|
"""Publish 12 Inspire hand joint commands via MotorCmds_. |
||||
|
|
||||
|
Args: |
||||
|
hand_values: np.ndarray of shape (12,) with normalized [0,1] values. |
||||
|
""" |
||||
|
hand_cmd = self._MotorCmds_() |
||||
|
hand_cmd.cmds = [] |
||||
|
for i in range(12): |
||||
|
mc = self._MotorCmd_() |
||||
|
mc.q = float(np.clip(hand_values[i], 0.0, 1.0)) |
||||
|
mc.dq = 0.0 |
||||
|
mc.tau = 0.0 |
||||
|
mc.kp = 5.0 |
||||
|
mc.kd = 0.5 |
||||
|
hand_cmd.cmds.append(mc) |
||||
|
self._hand_pub.Write(hand_cmd) |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Shared memory reader |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
def read_body_joints(server): |
||||
|
"""Read body joint rotation matrices from shared memory. |
||||
|
|
||||
|
Returns: |
||||
|
dict mapping BJ_* index to 3x3 rotation matrix, or None if no data. |
||||
|
""" |
||||
|
with server.body_joints_shared.get_lock(): |
||||
|
raw = np.array(server.body_joints_shared[:]) |
||||
|
|
||||
|
# Check if any data has arrived (all zeros = no data yet) |
||||
|
if np.all(raw == 0): |
||||
|
return None |
||||
|
|
||||
|
rotmats = {} |
||||
|
for i in range(NUM_BODY_JOINTS): |
||||
|
offset = i * 7 |
||||
|
qx, qy, qz, qw = raw[offset+3], raw[offset+4], raw[offset+5], raw[offset+6] |
||||
|
# Check for valid quaternion |
||||
|
qnorm = qx*qx + qy*qy + qz*qz + qw*qw |
||||
|
if qnorm < 0.5: |
||||
|
return None # Invalid data |
||||
|
rotmats[i] = quat_to_rotmat(qx, qy, qz, qw) |
||||
|
|
||||
|
return rotmats |
||||
|
|
||||
|
|
||||
|
def read_hand_positions(server): |
||||
|
"""Read hand joint positions from shared memory. |
||||
|
|
||||
|
Returns: |
||||
|
(left_25x3, right_25x3) numpy arrays in robot frame. |
||||
|
""" |
||||
|
with server.left_hand_position_shared.get_lock(): |
||||
|
raw_left = np.array(server.left_hand_position_shared[:]) |
||||
|
with server.right_hand_position_shared.get_lock(): |
||||
|
raw_right = np.array(server.right_hand_position_shared[:]) |
||||
|
|
||||
|
left = np.zeros((25, 3)) |
||||
|
right = np.zeros((25, 3)) |
||||
|
|
||||
|
if np.any(raw_left != 0): |
||||
|
left_openxr = raw_left.reshape(25, 3) |
||||
|
# Transform each position from OpenXR to robot frame |
||||
|
left = (R_ROBOT_OPENXR @ left_openxr.T).T |
||||
|
|
||||
|
if np.any(raw_right != 0): |
||||
|
right_openxr = raw_right.reshape(25, 3) |
||||
|
right = (R_ROBOT_OPENXR @ right_openxr.T).T |
||||
|
|
||||
|
return left, right |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Main loop |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
def main(): |
||||
|
parser = argparse.ArgumentParser( |
||||
|
description="Retargeting bridge: Quest 3 body tracking -> G1 DDS commands") |
||||
|
parser.add_argument("--port", type=int, default=8765, |
||||
|
help="WebSocket server port (default: 8765)") |
||||
|
parser.add_argument("--host", default="0.0.0.0", |
||||
|
help="WebSocket bind address (default: 0.0.0.0)") |
||||
|
parser.add_argument("--dds-domain", type=int, default=1, |
||||
|
help="DDS domain ID (default: 1 for sim)") |
||||
|
parser.add_argument("--hz", type=float, default=50.0, |
||||
|
help="Publishing frequency in Hz (default: 50)") |
||||
|
parser.add_argument("--alpha", type=float, default=0.3, |
||||
|
help="Smoothing factor, 0=slow 1=instant (default: 0.3)") |
||||
|
parser.add_argument("--no-hands", action="store_true", |
||||
|
help="Disable hand retargeting") |
||||
|
parser.add_argument("--verbose", action="store_true", |
||||
|
help="Enable debug logging") |
||||
|
args = parser.parse_args() |
||||
|
|
||||
|
logging.basicConfig( |
||||
|
level=logging.DEBUG if args.verbose else logging.INFO, |
||||
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s" |
||||
|
) |
||||
|
|
||||
|
# --- Start teleop server --- |
||||
|
logger.info(f"Starting teleop server on ws://{args.host}:{args.port}") |
||||
|
server = TeleopServer(host=args.host, port=args.port) |
||||
|
server_thread = server.start_in_thread() |
||||
|
|
||||
|
# --- Initialize DDS --- |
||||
|
dds = DDSPublisher(domain_id=args.dds_domain) |
||||
|
|
||||
|
# --- Initialize retargeters --- |
||||
|
body_retargeter = BodyRetargeter() |
||||
|
hand_retargeter = HandRetargeter() |
||||
|
body_smoother = JointSmoother(NUM_JOINTS, alpha=args.alpha, max_delta=0.15) |
||||
|
hand_smoother = JointSmoother(12, alpha=0.4, max_delta=0.2) |
||||
|
|
||||
|
# --- Deadman / timeout state --- |
||||
|
tracking_timeout = 0.5 # seconds |
||||
|
return_to_zero_rate = 0.02 # rad/step |
||||
|
last_valid_time = 0.0 |
||||
|
startup_ramp = 0.0 # 0.0 -> 1.0 over 2 seconds |
||||
|
startup_ramp_duration = 2.0 |
||||
|
startup_time = None |
||||
|
|
||||
|
# --- Control loop --- |
||||
|
interval = 1.0 / args.hz |
||||
|
running = True |
||||
|
|
||||
|
def signal_handler(sig, frame): |
||||
|
nonlocal running |
||||
|
running = False |
||||
|
|
||||
|
signal.signal(signal.SIGINT, signal_handler) |
||||
|
signal.signal(signal.SIGTERM, signal_handler) |
||||
|
|
||||
|
logger.info(f"Bridge running at {args.hz} Hz. Waiting for Quest 3 connection...") |
||||
|
step_count = 0 |
||||
|
current_angles = np.zeros(NUM_JOINTS) |
||||
|
current_hands = np.zeros(12) |
||||
|
|
||||
|
while running: |
||||
|
t_start = time.perf_counter() |
||||
|
|
||||
|
# Read body tracking data |
||||
|
body_rotmats = read_body_joints(server) |
||||
|
|
||||
|
if body_rotmats is not None: |
||||
|
now = time.time() |
||||
|
with server.last_data_time_shared.get_lock(): |
||||
|
data_time = server.last_data_time_shared.value |
||||
|
|
||||
|
if data_time > 0: |
||||
|
last_valid_time = now |
||||
|
|
||||
|
# Retarget body |
||||
|
raw_angles = body_retargeter.retarget(body_rotmats) |
||||
|
smoothed_angles = body_smoother.smooth(raw_angles) |
||||
|
|
||||
|
# Startup ramp |
||||
|
if startup_time is None: |
||||
|
startup_time = now |
||||
|
logger.info("First tracking data received -- starting ramp-up") |
||||
|
elapsed = now - startup_time |
||||
|
startup_ramp = min(elapsed / startup_ramp_duration, 1.0) |
||||
|
current_angles = smoothed_angles * startup_ramp |
||||
|
|
||||
|
# Retarget hands |
||||
|
if not args.no_hands: |
||||
|
left_hand, right_hand = read_hand_positions(server) |
||||
|
raw_hands = hand_retargeter.retarget(left_hand, right_hand) |
||||
|
current_hands = hand_smoother.smooth(raw_hands) * startup_ramp |
||||
|
|
||||
|
else: |
||||
|
# No data -- check timeout |
||||
|
now = time.time() |
||||
|
if last_valid_time > 0 and (now - last_valid_time) > tracking_timeout: |
||||
|
# Gradually return to zero |
||||
|
for i in range(12, NUM_JOINTS): |
||||
|
if abs(current_angles[i]) > return_to_zero_rate: |
||||
|
current_angles[i] -= np.sign(current_angles[i]) * return_to_zero_rate |
||||
|
else: |
||||
|
current_angles[i] = 0.0 |
||||
|
# Return hands to open |
||||
|
current_hands = np.maximum(current_hands - 0.02, 0.0) |
||||
|
|
||||
|
# Publish |
||||
|
dds.publish_body(current_angles) |
||||
|
if not args.no_hands: |
||||
|
dds.publish_hands(current_hands) |
||||
|
|
||||
|
# Status logging |
||||
|
step_count += 1 |
||||
|
if step_count % int(args.hz * 5) == 0: # Every 5 seconds |
||||
|
status = "TRACKING" if body_rotmats is not None else "WAITING" |
||||
|
ramp_pct = int(startup_ramp * 100) |
||||
|
logger.info(f"[{status}] step={step_count} ramp={ramp_pct}% " |
||||
|
f"waist=[{current_angles[12]:.2f},{current_angles[13]:.2f},{current_angles[14]:.2f}] " |
||||
|
f"L_shoulder=[{current_angles[15]:.2f},{current_angles[16]:.2f},{current_angles[17]:.2f}] " |
||||
|
f"R_shoulder=[{current_angles[22]:.2f},{current_angles[23]:.2f},{current_angles[24]:.2f}]") |
||||
|
|
||||
|
# Sleep to maintain target frequency |
||||
|
elapsed = time.perf_counter() - t_start |
||||
|
sleep_time = interval - elapsed |
||||
|
if sleep_time > 0: |
||||
|
time.sleep(sleep_time) |
||||
|
|
||||
|
logger.info("Bridge shutting down") |
||||
|
|
||||
|
|
||||
|
if __name__ == "__main__": |
||||
|
main() |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue