Browse Source
Add per-joint scale+offset calibration, hand tracker wrist, calibration tools
Add per-joint scale+offset calibration, hand tracker wrist, calibration tools
- Replace flat JOINT_GAIN with per-joint JOINT_SCALE/JOINT_OFFSET linear mapping for accurate retargeting (l_shoulder_pitch/roll/yaw, l_elbow calibrated) - Switch shoulder parent from chest to hips to avoid head rotation contamination - Add solo-joint mode (--solo-joint) for isolating single joints during testing - Use XRHandTracker wrist transforms in body_tracker.gd for better wrist rotation - Add joint_test.py for single-joint animation with pause-at-peak support - Add joint_calibrate.py for XR-to-robot range calibration with scale+offset output - Add calibration_log.md documenting extent tuning and axis mappings - Disable waist/right arm/hands temporarily for left-arm-only testing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>master
7 changed files with 1341 additions and 657 deletions
-
19scripts/body_tracker.gd
-
131server/calibration_log.md
-
265server/joint_calibrate.py
-
255server/joint_test.py
-
90server/native_tv_wrapper.py
-
1234server/retarget_bridge.py
-
4server/teleop_server.py
@ -0,0 +1,131 @@ |
|||
# Joint Calibration Log |
|||
|
|||
Using simple basis change: `R_delta_robot = R_basis @ R_delta_xr @ R_basis.T` |
|||
Decomposition order for shoulders: YXZ Euler (matches URDF: pitch(Y), roll(X), yaw(Z)) |
|||
|
|||
## l_shoulder_pitch (joint 15) - DONE |
|||
- Robot motion: sine ±0.6 rad, period 8s (arm forward/backward) |
|||
- XR parent-child: chest -> l_upper |
|||
- **Result**: Y component (YXZ Euler) correlates positively with robot motion |
|||
- Robot +0.57 → XR Y ≈ +0.3 to +0.6 |
|||
- Robot -0.58 → XR Y ≈ -1.0 to -1.1 |
|||
- **Sign**: `SIGN_SH_L[0] = +1` (CORRECT, same sign) |
|||
- Note: X (roll) had constant negative offset (~-0.4 to -0.8), likely natural arm rotation during pitch motion |
|||
|
|||
## l_shoulder_roll (joint 16) - DONE |
|||
- Robot motion: sine ±0.6 rad, period 8s (arm out to left side) |
|||
- XR parent-child: chest -> l_upper |
|||
- **Result**: Z component (YXZ Euler) correlates with robot roll, NOT X |
|||
- Robot +0.60 → Z ≈ +0.93 to +1.23 |
|||
- Robot -0.60 → Z ≈ +0.05 (can't push arm into body) |
|||
- **Sign**: +1 (same sign) |
|||
- **Important**: This means YXZ Euler order may be wrong, or axis assignment needs swapping (roll=Z, yaw=X?) |
|||
## l_shoulder_yaw (joint 17) - DONE |
|||
- Robot motion: sine ±0.4 rad, period 8s (upper arm twist inward/outward) |
|||
- XR parent-child: chest -> l_upper |
|||
- **Result**: X component (YXZ Euler) correlates negatively with robot yaw |
|||
- Robot +0.40 → X ≈ -0.90 to -0.98 |
|||
- Robot -0.40 → X ≈ -0.001 to -0.09 |
|||
- **Sign**: -1 (inverted) |
|||
|
|||
### LEFT SHOULDER SUMMARY |
|||
- Pitch → Y component of YXZ, sign +1 |
|||
- Roll → Z component of YXZ, sign +1 |
|||
- Yaw → X component of YXZ, sign -1 |
|||
- **Effective Euler order is YZX, not YXZ!** |
|||
- Code fix: `as_euler('YZX')` gives (pitch, roll, yaw) directly, with yaw negated |
|||
## l_elbow (joint 18) - DONE |
|||
- Robot motion: sine ±0.7 rad, period 8s (elbow bend/extend) |
|||
- XR parent-child: l_upper -> l_lower |
|||
- **Result**: Z component (YXZ Euler) correlates positively with robot elbow |
|||
- Robot +0.69 → Z ≈ +0.82 to +0.92 |
|||
- Robot -0.69 → Z ≈ -0.65 to -0.93 |
|||
- **Sign**: +1 (same sign) |
|||
- Note: Currently code extracts Y[0] for elbow, should use Z[2] |
|||
## r_shoulder_pitch (joint 22) - DONE |
|||
- Robot motion: sine ±0.6 rad, period 8s (right arm forward/backward) |
|||
- XR parent-child: chest -> r_upper |
|||
- **Result**: Y component (YXZ Euler) has weak positive correlation |
|||
- Large constant offset (~0.8 rad) - user's arm drifted forward |
|||
- Robot +0.6 → Y ≈ 0.92, Robot -0.6 → Y ≈ 0.76 (difference ~0.16) |
|||
- **Sign**: +1 (same as left, weak signal) |
|||
- **Note**: Right arm Quest tracking is significantly weaker than left |
|||
## r_shoulder_roll (joint 23) - DONE |
|||
- Robot motion: sine ±0.6 rad, period 8s (right arm out to side) |
|||
- XR parent-child: chest -> r_upper |
|||
- **Result**: Z component (YXZ Euler) correlates (weak/intermittent signal) |
|||
- Robot -0.60 (arm out) → Z ≈ -0.82 to -0.96 (strong cycles) |
|||
- Robot +0.60 (arm in) → Z ≈ +0.07 |
|||
- **Sign**: +1 (same sign) |
|||
- **Note**: Very noisy, right arm Quest tracking weak |
|||
## r_shoulder_yaw (joint 24) - SKIPPED |
|||
- Assumed same as left: X component, sign -1 (right arm tracking too weak for subtle yaw) |
|||
|
|||
## r_elbow (joint 25) - DONE |
|||
- Robot motion: sine ±0.7 rad, period 8s (right elbow bend) |
|||
- XR parent-child: r_upper -> r_lower |
|||
- **Result**: ALL axes near zero (max ±0.07 despite ±0.7 robot motion) |
|||
- Quest right arm tracking is extremely weak - barely detects elbow bend |
|||
- **Assumed**: Same mapping as left elbow (Z component, sign +1) |
|||
|
|||
## EXTENT TUNING |
|||
|
|||
### l_shoulder_pitch (joint 15) |
|||
- Backward (positive): +0.8 rad (+45.8 deg) — OK |
|||
- Forward (negative): -2.4 rad (-137.5 deg) — OK |
|||
- Note: positive = backward, negative = forward for this joint |
|||
- Settings: amplitude=1.6, offset=-0.8 |
|||
|
|||
### l_shoulder_roll (joint 16) |
|||
- Inward (negative): ~0 rad (0 deg) — just touching body, no clipping |
|||
- Outward (positive): +1.585 rad (+91 deg) — arm straight out to side |
|||
- Settings: amplitude=0.8, offset=0.785 |
|||
- Note: needs outward bias to avoid body clipping |
|||
|
|||
### l_shoulder_yaw (joint 17) |
|||
- Inward (positive): +1.15 rad (+66 deg) |
|||
- Outward (negative): -1.15 rad (-66 deg) |
|||
- Settings: amplitude=1.15, offset=0.0 |
|||
- Note: clips into body at rest pose but OK since other joints move arm away in practice |
|||
|
|||
### l_elbow (joint 18) |
|||
- Extend (negative): -0.8 rad (-45.8 deg) |
|||
- Bend (positive): +1.33 rad (+76.2 deg) |
|||
- Settings: amplitude=1.065, offset=0.265 |
|||
- Note: positive = bend, negative = extend (opposite of label in joint_test.py) |
|||
|
|||
### l_wrist_roll (joint 19) |
|||
- Palm down (positive): +1.35 rad (+77.3 deg) |
|||
- Palm up (negative): -1.35 rad (-77.3 deg) |
|||
- Settings: amplitude=1.35, offset=0.0 |
|||
- Note: positive = palm down, negative = palm up (opposite of label) |
|||
|
|||
### l_wrist_pitch (joint 20) |
|||
- Wrist down (positive): +0.8 rad (+45.8 deg) |
|||
- Wrist up (negative): -0.8 rad (-45.8 deg) |
|||
- Settings: amplitude=0.8, offset=0.0 |
|||
- Directions confirmed correct |
|||
|
|||
### l_wrist_yaw (joint 21) |
|||
- Inward (negative): -1.28 rad (-73 deg) |
|||
- Outward (positive): +0.5 rad (+29 deg) |
|||
- Settings: amplitude=0.89, offset=-0.39 |
|||
- Note: negative = inward, positive = outward |
|||
|
|||
## OVERALL AXIS MAPPING SUMMARY |
|||
|
|||
After `R_basis @ R_delta @ R_basis.T` + `as_euler('YXZ')` → (Y, X, Z): |
|||
|
|||
### Shoulder (chest → upper_arm) — parent roughly upright: |
|||
- Robot pitch (Y joint) = Y component [0], sign +1 |
|||
- Robot roll (X joint) = Z component [2], sign +1 |
|||
- Robot yaw (Z joint) = X component [1], sign -1 |
|||
|
|||
### Elbow (upper_arm → lower_arm) — parent hangs down: |
|||
- Robot pitch (Y joint) = Z component [2], sign +1 |
|||
- Note: axis mapping differs from shoulder because parent orientation differs |
|||
|
|||
### Why axes differ per joint: |
|||
The basis change `R_basis @ R_delta @ R_basis.T` treats the delta as if in XR world-aligned frame, |
|||
but the delta is in the PARENT's local frame. When the parent hangs down (upper arm), its local |
|||
axes are rotated ~90° from world, causing the axis mapping to shift. |
|||
@ -0,0 +1,265 @@ |
|||
#!/usr/bin/env python3 |
|||
""" |
|||
Joint calibration tool: animates ONE robot joint while recording Quest tracking data. |
|||
|
|||
The user copies the robot's motion. By comparing the known robot joint angle |
|||
with the raw XR tracking deltas, we determine the correct axis mapping and sign. |
|||
|
|||
Usage: |
|||
python joint_calibrate.py l_shoulder_pitch [--period 6] [--amplitude 0.8] |
|||
""" |
|||
|
|||
import sys |
|||
import os |
|||
import time |
|||
import argparse |
|||
import signal |
|||
import numpy as np |
|||
from scipy.spatial.transform import Rotation |
|||
|
|||
# Add xr_teleoperate to path |
|||
sys.path.insert(0, "/home/sparky/git/xr_teleoperate") |
|||
import logging_mp |
|||
if not hasattr(logging_mp, 'getLogger'): |
|||
logging_mp.getLogger = logging_mp.get_logger |
|||
import dex_retargeting |
|||
if not hasattr(dex_retargeting, 'RetargetingConfig'): |
|||
from dex_retargeting.retargeting_config import RetargetingConfig |
|||
dex_retargeting.RetargetingConfig = RetargetingConfig |
|||
|
|||
from teleop_server import TeleopServer |
|||
from native_tv_wrapper import T_ROBOT_OPENXR, NativeTeleWrapper |
|||
|
|||
NUM_JOINTS = 29 |
|||
R3_ROBOT_OPENXR = T_ROBOT_OPENXR[:3, :3] |
|||
|
|||
JOINTS = { |
|||
'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, |
|||
'r_shoulder_pitch': 22, 'r_shoulder_roll': 23, 'r_shoulder_yaw': 24, |
|||
'r_elbow': 25, |
|||
} |
|||
|
|||
# Which XR parent-child pair to monitor for each robot joint |
|||
# Using hips (not chest) for shoulder to avoid head rotation contamination |
|||
JOINT_PARENTS = { |
|||
'l_shoulder_pitch': ('hips', 'l_upper'), |
|||
'l_shoulder_roll': ('hips', 'l_upper'), |
|||
'l_shoulder_yaw': ('hips', 'l_upper'), |
|||
'l_elbow': ('l_upper', 'l_lower'), |
|||
'l_wrist_roll': ('l_lower', 'l_wrist'), |
|||
'l_wrist_pitch': ('l_lower', 'l_wrist'), |
|||
'l_wrist_yaw': ('l_lower', 'l_wrist'), |
|||
'r_shoulder_pitch': ('hips', 'r_upper'), |
|||
'r_shoulder_roll': ('hips', 'r_upper'), |
|||
'r_shoulder_yaw': ('hips', 'r_upper'), |
|||
'r_elbow': ('r_upper', 'r_lower'), |
|||
} |
|||
|
|||
KP = [60,60,60,100,40,40, 60,60,60,100,40,40, 60,40,40, 40,40,40,40,40,40,40, 40,40,40,40,40,40,40] |
|||
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] |
|||
|
|||
# Calibrated extents from joint testing |
|||
DEFAULT_AMP = { |
|||
'l_shoulder_pitch': 1.6, 'l_shoulder_roll': 0.8, 'l_shoulder_yaw': 1.15, |
|||
'l_elbow': 1.065, |
|||
'l_wrist_roll': 1.35, 'l_wrist_pitch': 0.8, 'l_wrist_yaw': 0.89, |
|||
'r_shoulder_pitch': 1.6, 'r_shoulder_roll': 0.8, 'r_shoulder_yaw': 1.15, |
|||
'r_elbow': 1.065, |
|||
} |
|||
DEFAULT_OFFSET = { |
|||
'l_shoulder_pitch': -0.8, 'l_shoulder_roll': 0.785, 'l_shoulder_yaw': 0.0, |
|||
'l_elbow': 0.265, |
|||
'l_wrist_roll': 0.0, 'l_wrist_pitch': 0.0, 'l_wrist_yaw': -0.39, |
|||
'r_shoulder_pitch': -0.8, 'r_shoulder_roll': -0.785, 'r_shoulder_yaw': 0.0, |
|||
'r_elbow': 0.265, |
|||
} |
|||
|
|||
|
|||
def pose7_to_mat4(pose7): |
|||
mat = np.eye(4) |
|||
r = Rotation.from_quat(pose7[3:7]) |
|||
mat[:3, :3] = r.as_matrix() |
|||
mat[:3, 3] = pose7[:3] |
|||
return mat |
|||
|
|||
|
|||
def parse_body_joints(flat): |
|||
names = ['hips', 'chest', 'l_upper', 'l_lower', 'l_wrist', |
|||
'r_upper', 'r_lower', 'r_wrist'] |
|||
joints = {} |
|||
for i, name in enumerate(names): |
|||
p7 = flat[i*7:(i+1)*7] |
|||
if np.all(p7 == 0): |
|||
joints[name] = None |
|||
else: |
|||
joints[name] = pose7_to_mat4(p7) |
|||
return joints |
|||
|
|||
|
|||
def main(): |
|||
parser = argparse.ArgumentParser(description="Joint calibration with Quest tracking") |
|||
parser.add_argument("joint", choices=list(JOINTS.keys())) |
|||
parser.add_argument("--amplitude", "-a", type=float, default=None) |
|||
parser.add_argument("--offset", "-o", type=float, default=None) |
|||
parser.add_argument("--period", "-p", type=float, default=8.0) |
|||
parser.add_argument("--hz", type=float, default=50.0) |
|||
parser.add_argument("--port", type=int, default=8765) |
|||
parser.add_argument("--dds-domain", type=int, default=1) |
|||
parser.add_argument("--cal-delay", type=float, default=10.0, |
|||
help="Seconds to wait before calibrating XR (default: 10.0)") |
|||
args = parser.parse_args() |
|||
|
|||
joint_idx = JOINTS[args.joint] |
|||
amplitude = args.amplitude if args.amplitude is not None else DEFAULT_AMP.get(args.joint, 0.5) |
|||
offset = args.offset if args.offset is not None else DEFAULT_OFFSET.get(args.joint, 0.0) |
|||
parent_name, child_name = JOINT_PARENTS[args.joint] |
|||
|
|||
print(f"\n{'='*70}") |
|||
print(f"JOINT CALIBRATION: {args.joint} (index {joint_idx})") |
|||
print(f"Monitoring XR: {parent_name} -> {child_name}") |
|||
print(f"Animation: sine amp={amplitude:.2f} offset={offset:.2f} rad, period {args.period:.1f}s") |
|||
print(f"Range: [{offset-amplitude:.2f}, {offset+amplitude:.2f}] rad") |
|||
print(f"XR cal delay: {args.cal_delay}s") |
|||
print(f"{'='*70}") |
|||
print(f"\nStarting WebSocket server on port {args.port}...") |
|||
|
|||
# Start teleop server |
|||
tv = NativeTeleWrapper(port=args.port, host="0.0.0.0") |
|||
tv.start() |
|||
time.sleep(0.5) |
|||
|
|||
# Init DDS |
|||
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 |
|||
|
|||
ChannelFactoryInitialize(args.dds_domain) |
|||
pub = ChannelPublisher("rt/lowcmd", LowCmd_) |
|||
pub.Init() |
|||
cmd = unitree_hg_msg_dds__LowCmd_() |
|||
crc = CRC() |
|||
|
|||
running = True |
|||
def handler(sig, frame): |
|||
nonlocal running |
|||
running = False |
|||
signal.signal(signal.SIGINT, handler) |
|||
signal.signal(signal.SIGTERM, handler) |
|||
|
|||
# Wait for Quest connection |
|||
print("\nConnect Quest 3 and hold the ROBOT'S REST POSE for calibration...") |
|||
print("(Arms at sides, elbows bent 90 degrees, forearms forward)") |
|||
print(f"XR calibration will happen {args.cal_delay}s after first tracking frame.\n") |
|||
|
|||
cal_ref = None |
|||
first_tracking_time = None |
|||
interval = 1.0 / args.hz |
|||
t0 = time.time() # Start animation immediately |
|||
step = 0 |
|||
|
|||
while running: |
|||
t_start = time.perf_counter() |
|||
now = time.time() |
|||
step += 1 |
|||
|
|||
# 1) Compute robot animation value |
|||
elapsed = now - t0 |
|||
robot_val = offset + amplitude * np.sin(2 * np.pi * elapsed / args.period) |
|||
angles = np.zeros(NUM_JOINTS) |
|||
angles[joint_idx] = robot_val |
|||
|
|||
# 2) Read XR tracking and compute comparison (if available) |
|||
xr_info = None |
|||
with tv.server.body_joints_shared.get_lock(): |
|||
bj_raw = np.array(tv.server.body_joints_shared[:]) |
|||
has_data = np.any(bj_raw != 0) |
|||
data_time = tv.last_data_time |
|||
tracking = data_time > 0 and (now - data_time) < 0.5 |
|||
|
|||
if tracking and has_data: |
|||
if first_tracking_time is None: |
|||
first_tracking_time = now |
|||
print(f"\nFirst tracking data! Calibrating in {args.cal_delay}s — hold the rest pose...") |
|||
|
|||
bj = parse_body_joints(bj_raw) |
|||
parent = bj.get(parent_name) |
|||
child = bj.get(child_name) |
|||
|
|||
if parent is not None and child is not None: |
|||
R_parent = parent[:3, :3] |
|||
R_child = child[:3, :3] |
|||
R_rel = R_parent.T @ R_child |
|||
|
|||
# Calibrate after delay |
|||
if cal_ref is None and first_tracking_time is not None and (now - first_tracking_time) >= args.cal_delay: |
|||
cal_ref = R_rel.copy() |
|||
print("\nCALIBRATED! Now copy the robot's motion.\n") |
|||
print(f"{'Robot Value':>12s} | {'XR Delta (robot frame YXZ Euler)':>40s} | {'XR Delta (robot frame XYZ)':>35s}") |
|||
print("-" * 95) |
|||
|
|||
if cal_ref is not None: |
|||
# Simple basis change: XR frame -> robot frame |
|||
R_delta = cal_ref.T @ R_rel |
|||
R_delta_robot = R3_ROBOT_OPENXR @ R_delta @ R3_ROBOT_OPENXR.T |
|||
yxz = Rotation.from_matrix(R_delta_robot).as_euler('YXZ') |
|||
xyz = Rotation.from_matrix(R_delta_robot).as_euler('XYZ') |
|||
xr_info = (yxz, xyz) |
|||
|
|||
# 3) Publish DDS (once per frame, always with current angles) |
|||
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(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]) |
|||
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 = crc.Crc(cmd) |
|||
pub.Write(cmd) |
|||
|
|||
# 4) Log |
|||
if xr_info and step % int(args.hz) == 0: |
|||
yxz, xyz = xr_info |
|||
print(f" {robot_val:+.3f} rad | " |
|||
f"Y={yxz[0]:+.3f} X={yxz[1]:+.3f} Z={yxz[2]:+.3f} | " |
|||
f"X={xyz[0]:+.3f} Y={xyz[1]:+.3f} Z={xyz[2]:+.3f}") |
|||
elif not xr_info and step % int(args.hz * 2) == 0: |
|||
print(f" robot={robot_val:+.3f} (Waiting for Quest tracking data...)") |
|||
|
|||
sleep_time = interval - (time.perf_counter() - t_start) |
|||
if sleep_time > 0: |
|||
time.sleep(sleep_time) |
|||
|
|||
# Return to zero |
|||
print("\nReturning to zero...") |
|||
angles = np.zeros(NUM_JOINTS) |
|||
for _ in range(25): |
|||
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 = 0.0 |
|||
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]) |
|||
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 = crc.Crc(cmd) |
|||
pub.Write(cmd) |
|||
time.sleep(0.02) |
|||
print("Done.") |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
main() |
|||
@ -0,0 +1,255 @@ |
|||
#!/usr/bin/env python3 |
|||
""" |
|||
Systematic joint-by-joint testing for sign calibration. |
|||
|
|||
Animates ONE joint at a time with a slow sine wave so the user can see |
|||
exactly what each joint does, then match the motion with their Quest 3. |
|||
|
|||
Usage: |
|||
python joint_test.py <joint_name> [--amplitude 0.5] [--period 4.0] |
|||
|
|||
Joint names: |
|||
waist_yaw (joint 12) - Z axis |
|||
waist_roll (joint 13) - X axis |
|||
waist_pitch (joint 14) - Y axis |
|||
l_shoulder_pitch (joint 15) - Y axis |
|||
l_shoulder_roll (joint 16) - X axis |
|||
l_shoulder_yaw (joint 17) - Z axis |
|||
l_elbow (joint 18) - Y axis |
|||
l_wrist_roll (joint 19) - X axis |
|||
l_wrist_pitch (joint 20) - Y axis |
|||
l_wrist_yaw (joint 21) - Z axis |
|||
r_shoulder_pitch (joint 22) - Y axis |
|||
r_shoulder_roll (joint 23) - X axis |
|||
r_shoulder_yaw (joint 24) - Z axis |
|||
r_elbow (joint 25) - Y axis |
|||
r_wrist_roll (joint 26) - X axis |
|||
r_wrist_pitch (joint 27) - Y axis |
|||
r_wrist_yaw (joint 28) - Z axis |
|||
|
|||
Also: "list" to show all joints, "all_shoulders" to cycle through shoulder joints |
|||
""" |
|||
|
|||
import sys |
|||
import os |
|||
import time |
|||
import argparse |
|||
import signal |
|||
import numpy as np |
|||
|
|||
NUM_JOINTS = 29 |
|||
|
|||
JOINTS = { |
|||
'waist_yaw': 12, |
|||
'waist_roll': 13, |
|||
'waist_pitch': 14, |
|||
'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, |
|||
'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, |
|||
} |
|||
|
|||
# Joint descriptions - what POSITIVE values should do |
|||
DESCRIPTIONS = { |
|||
'waist_yaw': 'Torso rotates LEFT (counterclockwise from above)', |
|||
'waist_roll': 'Torso leans RIGHT', |
|||
'waist_pitch': 'Torso leans FORWARD', |
|||
'l_shoulder_pitch': 'Left arm swings FORWARD', |
|||
'l_shoulder_roll': 'Left arm raises OUT to the LEFT side', |
|||
'l_shoulder_yaw': 'Left upper arm rotates INWARD', |
|||
'l_elbow': 'Left elbow BENDS (forearm toward shoulder)', |
|||
'l_wrist_roll': 'Left wrist rotates (palm faces down→up)', |
|||
'l_wrist_pitch': 'Left wrist bends DOWN (flexion)', |
|||
'l_wrist_yaw': 'Left wrist bends toward THUMB side', |
|||
'r_shoulder_pitch': 'Right arm swings FORWARD', |
|||
'r_shoulder_roll': 'Right arm raises OUT to the RIGHT side (NEGATIVE values)', |
|||
'r_shoulder_yaw': 'Right upper arm rotates INWARD', |
|||
'r_elbow': 'Right elbow BENDS (forearm toward shoulder)', |
|||
'r_wrist_roll': 'Right wrist rotates', |
|||
'r_wrist_pitch': 'Right wrist bends DOWN (flexion)', |
|||
'r_wrist_yaw': 'Right wrist bends toward THUMB side', |
|||
} |
|||
|
|||
# Default amplitudes (stay within safe range) |
|||
DEFAULT_AMPLITUDES = { |
|||
'waist_yaw': 0.4, |
|||
'waist_roll': 0.3, |
|||
'waist_pitch': 0.3, |
|||
'l_shoulder_pitch': 0.8, |
|||
'l_shoulder_roll': 0.8, |
|||
'l_shoulder_yaw': 0.5, |
|||
'l_elbow': 0.8, |
|||
'l_wrist_roll': 0.5, |
|||
'l_wrist_pitch': 0.5, |
|||
'l_wrist_yaw': 0.5, |
|||
'r_shoulder_pitch': 0.8, |
|||
'r_shoulder_roll': 0.8, |
|||
'r_shoulder_yaw': 0.5, |
|||
'r_elbow': 0.8, |
|||
'r_wrist_roll': 0.5, |
|||
'r_wrist_pitch': 0.5, |
|||
'r_wrist_yaw': 0.5, |
|||
} |
|||
|
|||
# Default Kp/Kd gains |
|||
KP = [ |
|||
60, 60, 60, 100, 40, 40, |
|||
60, 60, 60, 100, 40, 40, |
|||
60, 40, 40, |
|||
40, 40, 40, 40, 40, 40, 40, |
|||
40, 40, 40, 40, 40, 40, 40, |
|||
] |
|||
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, |
|||
] |
|||
|
|||
|
|||
def init_dds(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 |
|||
|
|||
ChannelFactoryInitialize(domain_id) |
|||
pub = ChannelPublisher("rt/lowcmd", LowCmd_) |
|||
pub.Init() |
|||
cmd = unitree_hg_msg_dds__LowCmd_() |
|||
crc = CRC() |
|||
return pub, cmd, crc |
|||
|
|||
|
|||
def publish(pub, cmd, crc, angles): |
|||
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(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 legs |
|||
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 = crc.Crc(cmd) |
|||
pub.Write(cmd) |
|||
|
|||
|
|||
def main(): |
|||
parser = argparse.ArgumentParser(description="Joint-by-joint animation tester") |
|||
parser.add_argument("joint", help="Joint name (or 'list' to show all)") |
|||
parser.add_argument("--amplitude", "-a", type=float, default=None, |
|||
help="Sine wave amplitude in radians (default: joint-specific)") |
|||
parser.add_argument("--period", "-p", type=float, default=4.0, |
|||
help="Sine wave period in seconds (default: 4.0)") |
|||
parser.add_argument("--hz", type=float, default=50.0) |
|||
parser.add_argument("--offset", "-o", type=float, default=0.0, |
|||
help="Static offset added to sine wave") |
|||
parser.add_argument("--dds-domain", type=int, default=1) |
|||
parser.add_argument("--pause-at-peak", choices=["pos", "neg"], default=None, |
|||
help="Pause at peak each cycle: 'pos' or 'neg'") |
|||
parser.add_argument("--pause-duration", type=float, default=3.0, |
|||
help="How long to pause in seconds (default: 3.0)") |
|||
args = parser.parse_args() |
|||
|
|||
if args.joint == 'list': |
|||
print("\nAvailable joints:\n") |
|||
for name, idx in sorted(JOINTS.items(), key=lambda x: x[1]): |
|||
desc = DESCRIPTIONS.get(name, '') |
|||
amp = DEFAULT_AMPLITUDES.get(name, 0.5) |
|||
print(f" {name:20s} (joint {idx:2d}) +val: {desc}") |
|||
print() |
|||
return |
|||
|
|||
if args.joint not in JOINTS: |
|||
print(f"Unknown joint: {args.joint}") |
|||
print(f"Available: {', '.join(sorted(JOINTS.keys()))}") |
|||
return |
|||
|
|||
joint_idx = JOINTS[args.joint] |
|||
amplitude = args.amplitude if args.amplitude is not None else DEFAULT_AMPLITUDES.get(args.joint, 0.5) |
|||
desc = DESCRIPTIONS.get(args.joint, '') |
|||
|
|||
print(f"\n{'='*60}") |
|||
print(f"Testing: {args.joint} (joint index {joint_idx})") |
|||
print(f"Positive direction: {desc}") |
|||
print(f"Animation: sine wave, amplitude={amplitude:.2f} rad, period={args.period:.1f}s") |
|||
print(f"Offset: {args.offset:.2f} rad") |
|||
print(f"{'='*60}\n") |
|||
|
|||
pub, cmd, crc = init_dds(args.dds_domain) |
|||
|
|||
running = True |
|||
def handler(sig, frame): |
|||
nonlocal running |
|||
running = False |
|||
signal.signal(signal.SIGINT, handler) |
|||
signal.signal(signal.SIGTERM, handler) |
|||
|
|||
interval = 1.0 / args.hz |
|||
t0 = time.time() |
|||
step = 0 |
|||
|
|||
print("Animating... (Ctrl+C to stop)\n") |
|||
|
|||
last_pause_time = 0 |
|||
while running: |
|||
t_start = time.perf_counter() |
|||
elapsed = time.time() - t0 |
|||
angles = np.zeros(NUM_JOINTS) |
|||
|
|||
value = args.offset + amplitude * np.sin(2 * np.pi * elapsed / args.period) |
|||
angles[joint_idx] = value |
|||
|
|||
publish(pub, cmd, crc, angles) |
|||
|
|||
step += 1 |
|||
if step % int(args.hz * 1) == 0: |
|||
print(f" {args.joint} = {value:+.3f} rad ({np.degrees(value):+.1f} deg)") |
|||
|
|||
# Pause at peak (with cooldown to avoid re-triggering) |
|||
if args.pause_at_peak and (time.time() - last_pause_time) > args.period * 0.5: |
|||
if args.pause_at_peak == "pos": |
|||
peak_val = args.offset + amplitude |
|||
else: |
|||
peak_val = args.offset - amplitude |
|||
if abs(value - peak_val) < 0.05: |
|||
print(f"\n >>> PAUSED at {args.pause_at_peak} peak: {value:+.3f} rad ({np.degrees(value):+.1f} deg) <<<") |
|||
pause_end = time.time() + args.pause_duration |
|||
while time.time() < pause_end and running: |
|||
publish(pub, cmd, crc, angles) |
|||
time.sleep(interval) |
|||
last_pause_time = time.time() |
|||
print(f" >>> Resuming...\n") |
|||
|
|||
sleep_time = interval - (time.perf_counter() - t_start) |
|||
if sleep_time > 0: |
|||
time.sleep(sleep_time) |
|||
|
|||
# Return to zero |
|||
print("\nReturning to zero...") |
|||
angles = np.zeros(NUM_JOINTS) |
|||
for _ in range(25): |
|||
publish(pub, cmd, crc, angles) |
|||
time.sleep(0.02) |
|||
print("Done.") |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
main() |
|||
1234
server/retarget_bridge.py
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue