mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-24 10:16:09 +00:00
(add) sonic 3-point teleop, safe startup/shutdown, tested on real g1
This commit is contained in:
@@ -71,3 +71,8 @@ class UnitreeG1Config(RobotConfig):
|
|||||||
# Locomotion controller class name, e.g. "GrootLocomotionController",
|
# Locomotion controller class name, e.g. "GrootLocomotionController",
|
||||||
# "HolosomaLocomotionController", or "SonicWholeBodyController". None disables it.
|
# "HolosomaLocomotionController", or "SonicWholeBodyController". None disables it.
|
||||||
controller: str | None = None
|
controller: str | None = None
|
||||||
|
|
||||||
|
# On disconnect (e.g. Ctrl-C), seconds to hold the current pose while ramping joint
|
||||||
|
# stiffness (kp) to zero — a soft, damped settle instead of an instant limp /
|
||||||
|
# free-fall. 0 disables it (immediate zero-torque). Real robot only.
|
||||||
|
graceful_stop_s: float = 1.5
|
||||||
|
|||||||
@@ -1244,16 +1244,15 @@ def _parse_wireless(wr):
|
|||||||
return lx, ly, rx, ry
|
return lx, ly, rx, ry
|
||||||
|
|
||||||
|
|
||||||
def process_joystick(obs, ms, controller=None):
|
def apply_joystick_axes(lx, ly, rx, ry, ms, controller=None):
|
||||||
"""Joystick mirrors keyboard: left stick=WASD, right stick X=Q/E, right stick Y=height."""
|
"""Map raw stick axes onto ``MovementState`` (left stick=WASD, right stick X=Q/E,
|
||||||
wr = obs.get("wireless_remote")
|
right stick Y=height).
|
||||||
if wr is None:
|
|
||||||
return
|
|
||||||
parsed = _parse_wireless(wr)
|
|
||||||
if parsed is None:
|
|
||||||
return
|
|
||||||
lx, ly, rx, ry = parsed
|
|
||||||
|
|
||||||
|
Shared by the G1 wireless remote (:func:`process_joystick`) and the PICO
|
||||||
|
controller sticks (3-point teleop), so both drive the planner identically. Axes
|
||||||
|
are expected pre-negated to the same convention as the parsed wireless remote:
|
||||||
|
``ly`` and ``ry`` already flipped, dead zone not yet applied.
|
||||||
|
"""
|
||||||
# Dead zone + negate both Y axes (bridge already flips them once)
|
# Dead zone + negate both Y axes (bridge already flips them once)
|
||||||
lx = 0.0 if abs(lx) < DEADZONE else lx
|
lx = 0.0 if abs(lx) < DEADZONE else lx
|
||||||
ly = 0.0 if abs(ly) < DEADZONE else -ly
|
ly = 0.0 if abs(ly) < DEADZONE else -ly
|
||||||
@@ -1284,3 +1283,15 @@ def process_joystick(obs, ms, controller=None):
|
|||||||
if abs(ry) > 0:
|
if abs(ry) > 0:
|
||||||
step = -0.005 * ry
|
step = -0.005 * ry
|
||||||
ms.height = max(0.1, min(1.0, (ms.height if ms.height >= 0 else DEFAULT_HEIGHT) + step))
|
ms.height = max(0.1, min(1.0, (ms.height if ms.height >= 0 else DEFAULT_HEIGHT) + step))
|
||||||
|
|
||||||
|
|
||||||
|
def process_joystick(obs, ms, controller=None):
|
||||||
|
"""Drive ``MovementState`` from the G1 wireless remote in ``obs``."""
|
||||||
|
wr = obs.get("wireless_remote")
|
||||||
|
if wr is None:
|
||||||
|
return
|
||||||
|
parsed = _parse_wireless(wr)
|
||||||
|
if parsed is None:
|
||||||
|
return
|
||||||
|
lx, ly, rx, ry = parsed
|
||||||
|
apply_joystick_axes(lx, ly, rx, ry, ms, controller)
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import numpy as np
|
|||||||
from huggingface_hub import hf_hub_download
|
from huggingface_hub import hf_hub_download
|
||||||
|
|
||||||
from lerobot.teleoperators.pico_headset.smpl_constants import (
|
from lerobot.teleoperators.pico_headset.smpl_constants import (
|
||||||
|
LOCO_AXES_PREFIX,
|
||||||
|
LOCO_BTN_PREFIX,
|
||||||
|
LOCO_N_AXES,
|
||||||
|
LOCO_N_BTN,
|
||||||
ROOT_ACTION_DIM,
|
ROOT_ACTION_DIM,
|
||||||
ROOT_ACTION_PREFIX,
|
ROOT_ACTION_PREFIX,
|
||||||
SMPL_ACTION_PREFIX,
|
SMPL_ACTION_PREFIX,
|
||||||
@@ -37,7 +41,7 @@ from lerobot.teleoperators.pico_headset.smpl_constants import (
|
|||||||
)
|
)
|
||||||
from lerobot.utils.import_utils import _onnxruntime_available, require_package
|
from lerobot.utils.import_utils import _onnxruntime_available, require_package
|
||||||
|
|
||||||
from ..g1_utils import KEYBOARD_KEYS_FIELD, lowstate_to_obs
|
from ..g1_utils import KEYBOARD_KEYS_FIELD, G1_29_JointIndex, lowstate_to_obs
|
||||||
from .sonic_pipeline import (
|
from .sonic_pipeline import (
|
||||||
CONTROL_DT,
|
CONTROL_DT,
|
||||||
DEBUG_PRINT_EVERY,
|
DEBUG_PRINT_EVERY,
|
||||||
@@ -49,6 +53,7 @@ from .sonic_pipeline import (
|
|||||||
MovementState,
|
MovementState,
|
||||||
PlannerController,
|
PlannerController,
|
||||||
SonicPlanner,
|
SonicPlanner,
|
||||||
|
apply_joystick_axes,
|
||||||
clamp_mode_params,
|
clamp_mode_params,
|
||||||
compute_kp_kd,
|
compute_kp_kd,
|
||||||
make_ort_session_options,
|
make_ort_session_options,
|
||||||
@@ -65,6 +70,11 @@ else:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Startup blend duration: over the first control ticks, linearly interpolate every joint
|
||||||
|
# from the robot's initial measured pose into the policy's commanded target, so control
|
||||||
|
# eases in without a snap on the first command.
|
||||||
|
INIT_RAMP_S = 3.0
|
||||||
|
|
||||||
|
|
||||||
def _extract_smpl_from_action(action: dict | None) -> np.ndarray | None:
|
def _extract_smpl_from_action(action: dict | None) -> np.ndarray | None:
|
||||||
"""Reassemble a (720,) SMPL window from ``smpl.{i}`` action keys, or None.
|
"""Reassemble a (720,) SMPL window from ``smpl.{i}`` action keys, or None.
|
||||||
@@ -122,6 +132,27 @@ def _extract_vr3_from_action(action: dict | None) -> tuple[np.ndarray, np.ndarra
|
|||||||
return pos, orn
|
return pos, orn
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_loco_from_action(action: dict | None) -> tuple[np.ndarray, np.ndarray] | None:
|
||||||
|
"""Reassemble controller-stick locomotion from ``loco_axes.{i}`` / ``loco_btn.{i}``.
|
||||||
|
|
||||||
|
Returns ``(axes (4,) = [lx, ly, rx, ry], buttons (4,) = [A, B, X, Y])`` or None
|
||||||
|
when no locomotion state was sent this tick (sentinel: ``loco_axes.0``).
|
||||||
|
"""
|
||||||
|
if not action or f"{LOCO_AXES_PREFIX}0" not in action:
|
||||||
|
return None
|
||||||
|
axes = np.fromiter(
|
||||||
|
(float(action.get(f"{LOCO_AXES_PREFIX}{i}", 0.0)) for i in range(LOCO_N_AXES)),
|
||||||
|
dtype=np.float32,
|
||||||
|
count=LOCO_N_AXES,
|
||||||
|
)
|
||||||
|
buttons = np.fromiter(
|
||||||
|
(float(action.get(f"{LOCO_BTN_PREFIX}{i}", 0.0)) for i in range(LOCO_N_BTN)),
|
||||||
|
dtype=np.float32,
|
||||||
|
count=LOCO_N_BTN,
|
||||||
|
)
|
||||||
|
return axes, buttons
|
||||||
|
|
||||||
|
|
||||||
class SonicRuntime:
|
class SonicRuntime:
|
||||||
"""Shared SONIC control loop state (standalone demo + locomotion controller)."""
|
"""Shared SONIC control loop state (standalone demo + locomotion controller)."""
|
||||||
|
|
||||||
@@ -230,6 +261,14 @@ class SonicWholeBodyController:
|
|||||||
# motion set, replan, e-stop, WASD direction) fire once per physical press
|
# motion set, replan, e-stop, WASD direction) fire once per physical press
|
||||||
# instead of every 50 Hz tick while the key is held.
|
# instead of every 50 Hz tick while the key is held.
|
||||||
self._prev_keys: set[str] = set()
|
self._prev_keys: set[str] = set()
|
||||||
|
# Edge state for the PICO A+B / X+Y locomotion-mode cycle (3-point teleop).
|
||||||
|
self._prev_loco_mode_pair: tuple[bool, bool] = (False, False)
|
||||||
|
|
||||||
|
# Startup blend: ease from the robot's initial pose into the first commanded
|
||||||
|
# policy targets over INIT_RAMP_S (captured on the first control tick).
|
||||||
|
self._init_ramp_steps = max(1, round(INIT_RAMP_S / CONTROL_DT))
|
||||||
|
self._init_step = 0
|
||||||
|
self._start_pose: dict[str, float] = {}
|
||||||
|
|
||||||
# Optional: subscribe directly to the rt/smpl headset stream so full-body
|
# Optional: subscribe directly to the rt/smpl headset stream so full-body
|
||||||
# teleop works with ANY teleoperator (e.g. --teleop.type=unitree_g1 for the
|
# teleop works with ANY teleoperator (e.g. --teleop.type=unitree_g1 for the
|
||||||
@@ -370,6 +409,61 @@ class SonicWholeBodyController:
|
|||||||
if "-" in held:
|
if "-" in held:
|
||||||
ms.height = max(0.1, (ms.height if ms.height >= 0 else DEFAULT_HEIGHT) - 0.005)
|
ms.height = max(0.1, (ms.height if ms.height >= 0 else DEFAULT_HEIGHT) - 0.005)
|
||||||
|
|
||||||
|
def _process_pico_loco(self, axes: np.ndarray, buttons: np.ndarray) -> None:
|
||||||
|
"""Drive locomotion from the PICO controller sticks/buttons (encode_mode 1).
|
||||||
|
|
||||||
|
Mirrors gear_sonic's ``PlannerLoop`` VR-3PT tick: left/right sticks steer
|
||||||
|
movement/facing/height (via the shared :func:`apply_joystick_axes`, identical
|
||||||
|
to the G1 remote), and A+B / X+Y edge-cycle the locomotion mode within the
|
||||||
|
current motion set.
|
||||||
|
"""
|
||||||
|
lx, ly, rx, ry = (float(v) for v in axes)
|
||||||
|
apply_joystick_axes(lx, ly, rx, ry, self.ms, self.controller)
|
||||||
|
|
||||||
|
# Mode cycling: step linearly through the LocomotionMode enum (A+B = next,
|
||||||
|
# X+Y = previous), exactly like gear_sonic's PlannerLoop — so the operator can
|
||||||
|
# reach squat/kneel/crawl, not just the modes in one UI motion set.
|
||||||
|
a, b, x, y = (v > 0.5 for v in buttons)
|
||||||
|
ab_now, xy_now = (a and b), (x and y)
|
||||||
|
ab_prev, xy_prev = self._prev_loco_mode_pair
|
||||||
|
mode = int(self.ms.mode)
|
||||||
|
if ab_now and not ab_prev:
|
||||||
|
mode = min(int(LM.INJURED_WALK), mode + 1)
|
||||||
|
elif xy_now and not xy_prev:
|
||||||
|
mode = max(int(LM.IDLE), mode - 1)
|
||||||
|
if mode != int(self.ms.mode):
|
||||||
|
self.ms.mode = LM(mode)
|
||||||
|
self.ms.needs_replan = True
|
||||||
|
self.controller.playing = True
|
||||||
|
logger.info("SONIC 3-point: locomotion mode -> %s", LM(self.ms.mode).name)
|
||||||
|
self._prev_loco_mode_pair = (ab_now, xy_now)
|
||||||
|
|
||||||
|
def _startup_blend(self, obs: dict, out: dict) -> dict:
|
||||||
|
"""Ease into policy control at startup: for the first ``INIT_RAMP_S`` seconds,
|
||||||
|
interpolate between the robot's pose captured on the first tick and the policy's
|
||||||
|
live commanded target, so the handoff has no snap.
|
||||||
|
|
||||||
|
``out`` is the policy's ``<joint>.q`` target dict for this tick; the blend ratio
|
||||||
|
climbs 0->1 over the ramp, after which the raw policy target passes through.
|
||||||
|
"""
|
||||||
|
if self._init_step >= self._init_ramp_steps or not out:
|
||||||
|
return out
|
||||||
|
if self._init_step == 0:
|
||||||
|
# Capture the robot's actual pose as the interpolation start point.
|
||||||
|
self._start_pose = {
|
||||||
|
f"{m.name}.q": float(obs.get(f"{m.name}.q", DEFAULT_ANGLES[m.value]))
|
||||||
|
for m in G1_29_JointIndex
|
||||||
|
}
|
||||||
|
self._init_step += 1
|
||||||
|
ratio = min(1.0, self._init_step / self._init_ramp_steps)
|
||||||
|
blended = {
|
||||||
|
k: self._start_pose.get(k, float(tgt)) * (1.0 - ratio) + float(tgt) * ratio
|
||||||
|
for k, tgt in out.items()
|
||||||
|
}
|
||||||
|
if self._init_step >= self._init_ramp_steps:
|
||||||
|
logger.info("SONIC startup blend complete -> full policy control")
|
||||||
|
return blended
|
||||||
|
|
||||||
def run_step(self, action: dict, lowstate) -> dict:
|
def run_step(self, action: dict, lowstate) -> dict:
|
||||||
if lowstate is None:
|
if lowstate is None:
|
||||||
return {}
|
return {}
|
||||||
@@ -387,13 +481,18 @@ class SonicWholeBodyController:
|
|||||||
smpl = _extract_smpl_from_action(action)
|
smpl = _extract_smpl_from_action(action)
|
||||||
root_quat = _extract_root_from_action(action)
|
root_quat = _extract_root_from_action(action)
|
||||||
vr3 = _extract_vr3_from_action(action)
|
vr3 = _extract_vr3_from_action(action)
|
||||||
|
loco = _extract_loco_from_action(action)
|
||||||
if smpl is None and vr3 is None and self._smpl_stream is not None:
|
if smpl is None and vr3 is None and self._smpl_stream is not None:
|
||||||
window = self._smpl_stream.step()
|
window = self._smpl_stream.step()
|
||||||
if self._smpl_stream.has_data and not self._smpl_stream.is_stale:
|
if self._smpl_stream.has_data and not self._smpl_stream.is_stale:
|
||||||
smpl = window
|
smpl = window
|
||||||
root_quat = np.asarray(self._smpl_stream.root_quat, np.float32)
|
root_quat = np.asarray(self._smpl_stream.root_quat, np.float32)
|
||||||
if self._smpl_stream.has_vr3:
|
# VR3 is independent of the SMPL window: the controller-state source
|
||||||
vr3 = (self._smpl_stream.vr3_pos, self._smpl_stream.vr3_orn)
|
# (head + controllers only) sends 3-point targets with no SMPL frame.
|
||||||
|
elif self._smpl_stream.has_fresh_vr3:
|
||||||
|
vr3 = (self._smpl_stream.vr3_pos, self._smpl_stream.vr3_orn)
|
||||||
|
if self._smpl_stream.has_fresh_loco:
|
||||||
|
loco = (self._smpl_stream.loco_axes, self._smpl_stream.loco_buttons)
|
||||||
|
|
||||||
if smpl is not None:
|
if smpl is not None:
|
||||||
# Full-body whole-body tracking: SMPL drives the reference, not joystick.
|
# Full-body whole-body tracking: SMPL drives the reference, not joystick.
|
||||||
@@ -405,9 +504,8 @@ class SonicWholeBodyController:
|
|||||||
# self-driven to avoid root-acceleration spikes from an unsmoothed
|
# self-driven to avoid root-acceleration spikes from an unsmoothed
|
||||||
# reference trajectory.
|
# reference trajectory.
|
||||||
self.controller.smpl_root_quat = root_quat if self.enable_smpl_root else None
|
self.controller.smpl_root_quat = root_quat if self.enable_smpl_root else None
|
||||||
return self._runtime.tick(obs, debug=False, use_joystick=False)
|
out = self._runtime.tick(obs, debug=False, use_joystick=False)
|
||||||
|
elif vr3 is not None:
|
||||||
if vr3 is not None:
|
|
||||||
# 3-point VR teleop: upper body tracks the wrist/neck targets; the lower
|
# 3-point VR teleop: upper body tracks the wrist/neck targets; the lower
|
||||||
# body / locomotion keeps running off the planner, so the joystick (and
|
# body / locomotion keeps running off the planner, so the joystick (and
|
||||||
# keyboard) still steer walking/turning underneath.
|
# keyboard) still steer walking/turning underneath.
|
||||||
@@ -415,16 +513,29 @@ class SonicWholeBodyController:
|
|||||||
self._enter_3point()
|
self._enter_3point()
|
||||||
self.controller.vr_3point_local_target = vr3[0]
|
self.controller.vr_3point_local_target = vr3[0]
|
||||||
self.controller.vr_3point_local_orn_target = vr3[1]
|
self.controller.vr_3point_local_orn_target = vr3[1]
|
||||||
return self._runtime.tick(obs, debug=False, use_joystick=True)
|
# Replicate the original encode_mode-1 handling: when the PICO controller
|
||||||
|
# sticks are forwarded, drive locomotion from them directly (and skip the
|
||||||
|
# wireless-remote joystick read). Otherwise leave the remote/keyboard path.
|
||||||
|
if loco is not None:
|
||||||
|
self._process_pico_loco(loco[0], loco[1])
|
||||||
|
out = self._runtime.tick(obs, debug=False, use_joystick=False)
|
||||||
|
else:
|
||||||
|
out = self._runtime.tick(obs, debug=False, use_joystick=True)
|
||||||
|
else:
|
||||||
|
# No (or stale) teleop reference: fall back to locomotion so the robot stays balanced.
|
||||||
|
if self.controller.encode_mode != 0:
|
||||||
|
self.controller.smpl_root_quat = None
|
||||||
|
self._exit_wholebody()
|
||||||
|
out = self._runtime.tick(obs, debug=False)
|
||||||
|
|
||||||
# No (or stale) teleop reference: fall back to locomotion so the robot stays balanced.
|
# Startup interpolation: blend from the robot's initial pose into the policy's
|
||||||
if self.controller.encode_mode != 0:
|
# commanded target over INIT_RAMP_S, regardless of mode.
|
||||||
self.controller.smpl_root_quat = None
|
return self._startup_blend(obs, out)
|
||||||
self._exit_wholebody()
|
|
||||||
return self._runtime.tick(obs, debug=False)
|
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
self._runtime.reset()
|
self._runtime.reset()
|
||||||
|
self._init_step = 0 # re-run the startup blend after a reset
|
||||||
|
self._start_pose = {}
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
if self._smpl_stream is not None:
|
if self._smpl_stream is not None:
|
||||||
|
|||||||
@@ -375,13 +375,47 @@ class UnitreeG1(Robot):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to send zero-torque on disconnect: {e}")
|
logger.warning(f"Failed to send zero-torque on disconnect: {e}")
|
||||||
|
|
||||||
def disconnect(self):
|
def _graceful_stop(self) -> None:
|
||||||
# Put robot in passive mode before stopping threads
|
"""Soft shutdown: hold the current pose and ramp joint stiffness (kp) to zero
|
||||||
if not self.config.is_simulation:
|
over ``graceful_stop_s`` while keeping damping (kd), then go passive.
|
||||||
self._send_zero_torque()
|
|
||||||
|
|
||||||
# Signal thread to stop and unblock any waits
|
Prevents the robot from collapsing the instant control ends (a bare
|
||||||
|
zero-torque command is kp=kd=0 ≈ free-fall). Must run after the controller
|
||||||
|
loop has stopped so the two aren't publishing at once.
|
||||||
|
"""
|
||||||
|
if self.config.graceful_stop_s <= 0:
|
||||||
|
self._send_zero_torque()
|
||||||
|
return
|
||||||
|
with self._lowstate_lock:
|
||||||
|
lowstate = self._lowstate
|
||||||
|
if lowstate is None:
|
||||||
|
self._send_zero_torque()
|
||||||
|
return
|
||||||
|
q_hold = {f"{motor.name}.q": lowstate.motor_state[motor.value].q for motor in G1_29_JointIndex}
|
||||||
|
kp = np.array(self.kp, dtype=np.float32)
|
||||||
|
kd = np.array(self.kd, dtype=np.float32)
|
||||||
|
zeros = np.zeros(29, dtype=np.float32)
|
||||||
|
dt = self.controller.control_dt if self.controller is not None else self.config.control_dt
|
||||||
|
steps = max(1, int(self.config.graceful_stop_s / dt))
|
||||||
|
logger.info("Graceful stop: damping down over %.1fs", self.config.graceful_stop_s)
|
||||||
|
for i in range(steps):
|
||||||
|
ratio = (i + 1) / steps
|
||||||
|
self.publish_lowcmd(q_hold, kp=kp * (1.0 - ratio), kd=kd, tau=zeros)
|
||||||
|
time.sleep(dt)
|
||||||
|
self._send_zero_torque()
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
# Stop the controller loop first so it isn't fighting the shutdown ramp.
|
||||||
self._shutdown_event.set()
|
self._shutdown_event.set()
|
||||||
|
if self._controller_thread is not None:
|
||||||
|
self._controller_thread.join(timeout=2.0)
|
||||||
|
if self._controller_thread.is_alive():
|
||||||
|
logger.warning("Controller thread did not stop cleanly")
|
||||||
|
|
||||||
|
# Soft, damped settle instead of an instant limp (real robot only; the
|
||||||
|
# subscribe thread is still alive here to supply the current pose).
|
||||||
|
if not self.config.is_simulation:
|
||||||
|
self._graceful_stop()
|
||||||
|
|
||||||
if self.controller is not None and hasattr(self.controller, "shutdown"):
|
if self.controller is not None and hasattr(self.controller, "shutdown"):
|
||||||
self.controller.shutdown()
|
self.controller.shutdown()
|
||||||
@@ -392,12 +426,6 @@ class UnitreeG1(Robot):
|
|||||||
if self.subscribe_thread.is_alive():
|
if self.subscribe_thread.is_alive():
|
||||||
logger.warning("Subscribe thread did not stop cleanly")
|
logger.warning("Subscribe thread did not stop cleanly")
|
||||||
|
|
||||||
# Wait for controller thread to finish
|
|
||||||
if self._controller_thread is not None:
|
|
||||||
self._controller_thread.join(timeout=2.0)
|
|
||||||
if self._controller_thread.is_alive():
|
|
||||||
logger.warning("Controller thread did not stop cleanly")
|
|
||||||
|
|
||||||
# Close simulation environment
|
# Close simulation environment
|
||||||
if self.config.is_simulation and self.sim_env is not None:
|
if self.config.is_simulation and self.sim_env is not None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ from lerobot.types import RobotAction
|
|||||||
from ..teleoperator import Teleoperator
|
from ..teleoperator import Teleoperator
|
||||||
from .config_pico_headset import PicoHeadsetConfig
|
from .config_pico_headset import PicoHeadsetConfig
|
||||||
from .smpl_constants import (
|
from .smpl_constants import (
|
||||||
|
LOCO_AXES_PREFIX,
|
||||||
|
LOCO_BTN_PREFIX,
|
||||||
|
LOCO_N_AXES,
|
||||||
|
LOCO_N_BTN,
|
||||||
ROOT_ACTION_DIM,
|
ROOT_ACTION_DIM,
|
||||||
ROOT_ACTION_PREFIX,
|
ROOT_ACTION_PREFIX,
|
||||||
SMPL_ACTION_PREFIX,
|
SMPL_ACTION_PREFIX,
|
||||||
@@ -60,6 +64,9 @@ class PicoHeadset(Teleoperator):
|
|||||||
if self.config.mode == "vr3":
|
if self.config.mode == "vr3":
|
||||||
feats = {f"{VR3_POS_PREFIX}{i}": float for i in range(VR3_POS_DIM)}
|
feats = {f"{VR3_POS_PREFIX}{i}": float for i in range(VR3_POS_DIM)}
|
||||||
feats.update({f"{VR3_ORN_PREFIX}{i}": float for i in range(VR3_ORN_DIM)})
|
feats.update({f"{VR3_ORN_PREFIX}{i}": float for i in range(VR3_ORN_DIM)})
|
||||||
|
# Controller-stick locomotion travels alongside the VR targets.
|
||||||
|
feats.update({f"{LOCO_AXES_PREFIX}{i}": float for i in range(LOCO_N_AXES)})
|
||||||
|
feats.update({f"{LOCO_BTN_PREFIX}{i}": float for i in range(LOCO_N_BTN)})
|
||||||
return feats
|
return feats
|
||||||
feats = {f"{SMPL_ACTION_PREFIX}{i}": float for i in range(SMPL_OBS_DIM)}
|
feats = {f"{SMPL_ACTION_PREFIX}{i}": float for i in range(SMPL_OBS_DIM)}
|
||||||
feats.update({f"{ROOT_ACTION_PREFIX}{i}": float for i in range(ROOT_ACTION_DIM)})
|
feats.update({f"{ROOT_ACTION_PREFIX}{i}": float for i in range(ROOT_ACTION_DIM)})
|
||||||
@@ -107,16 +114,27 @@ class PicoHeadset(Teleoperator):
|
|||||||
# collapsed pose) and once the stream goes stale (so the controller falls
|
# collapsed pose) and once the stream goes stale (so the controller falls
|
||||||
# back to a safe standing/locomotion mode instead of freezing on the last
|
# back to a safe standing/locomotion mode instead of freezing on the last
|
||||||
# pose).
|
# pose).
|
||||||
if not self._stream.has_data or self._stream.is_stale:
|
|
||||||
return {}
|
|
||||||
if self.config.mode == "vr3":
|
if self.config.mode == "vr3":
|
||||||
# Sparse 3-point upper-body teleop (encode_mode 1). Needs the producer to
|
# Sparse 3-point upper-body teleop (encode_mode 1). Gated on fresh vr3_*
|
||||||
# be sending vr3_* fields; otherwise emit nothing and stay in locomotion.
|
# frames only (independent of the SMPL window), so the controller-state
|
||||||
if not self._stream.has_vr3:
|
# source (head + controllers, no body tracking) works. Emit nothing
|
||||||
|
# otherwise and stay in locomotion.
|
||||||
|
if not self._stream.has_fresh_vr3:
|
||||||
return {}
|
return {}
|
||||||
action = {f"{VR3_POS_PREFIX}{i}": float(v) for i, v in enumerate(self._stream.vr3_pos)}
|
action = {f"{VR3_POS_PREFIX}{i}": float(v) for i, v in enumerate(self._stream.vr3_pos)}
|
||||||
action.update({f"{VR3_ORN_PREFIX}{i}": float(v) for i, v in enumerate(self._stream.vr3_orn)})
|
action.update({f"{VR3_ORN_PREFIX}{i}": float(v) for i, v in enumerate(self._stream.vr3_orn)})
|
||||||
|
# Forward controller-stick locomotion when present, so the planner can
|
||||||
|
# steer walking/turning under the upper-body tracking (encode_mode 1).
|
||||||
|
if self._stream.has_fresh_loco:
|
||||||
|
action.update(
|
||||||
|
{f"{LOCO_AXES_PREFIX}{i}": float(v) for i, v in enumerate(self._stream.loco_axes)}
|
||||||
|
)
|
||||||
|
action.update(
|
||||||
|
{f"{LOCO_BTN_PREFIX}{i}": float(v) for i, v in enumerate(self._stream.loco_buttons)}
|
||||||
|
)
|
||||||
return action
|
return action
|
||||||
|
if not self._stream.has_data or self._stream.is_stale:
|
||||||
|
return {}
|
||||||
action = {f"{SMPL_ACTION_PREFIX}{i}": float(v) for i, v in enumerate(window)}
|
action = {f"{SMPL_ACTION_PREFIX}{i}": float(v) for i, v in enumerate(window)}
|
||||||
action.update({f"{ROOT_ACTION_PREFIX}{i}": float(v) for i, v in enumerate(self._stream.root_quat)})
|
action.update({f"{ROOT_ACTION_PREFIX}{i}": float(v) for i, v in enumerate(self._stream.root_quat)})
|
||||||
return action
|
return action
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ import zmq
|
|||||||
|
|
||||||
from lerobot.teleoperators.pico_headset.smpl_fk import (
|
from lerobot.teleoperators.pico_headset.smpl_fk import (
|
||||||
SmplForwardKinematics,
|
SmplForwardKinematics,
|
||||||
|
ThreePointCalibrator,
|
||||||
canonicalize_smpl_joints,
|
canonicalize_smpl_joints,
|
||||||
compute_3point,
|
compute_3point,
|
||||||
|
compute_3point_from_devices,
|
||||||
root_quats_from_aa,
|
root_quats_from_aa,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,6 +68,8 @@ def pack_message(
|
|||||||
root_transl: np.ndarray | None = None,
|
root_transl: np.ndarray | None = None,
|
||||||
vr3_pos: np.ndarray | None = None,
|
vr3_pos: np.ndarray | None = None,
|
||||||
vr3_orn: np.ndarray | None = None,
|
vr3_orn: np.ndarray | None = None,
|
||||||
|
loco_axes: np.ndarray | None = None,
|
||||||
|
loco_buttons: np.ndarray | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Build the rt/smpl JSON message (single frame, topic embedded in payload).
|
"""Build the rt/smpl JSON message (single frame, topic embedded in payload).
|
||||||
|
|
||||||
@@ -86,6 +90,10 @@ def pack_message(
|
|||||||
data["vr3_pos"] = np.asarray(vr3_pos, np.float32).reshape(-1).tolist()
|
data["vr3_pos"] = np.asarray(vr3_pos, np.float32).reshape(-1).tolist()
|
||||||
if vr3_orn is not None:
|
if vr3_orn is not None:
|
||||||
data["vr3_orn"] = np.asarray(vr3_orn, np.float32).reshape(-1).tolist()
|
data["vr3_orn"] = np.asarray(vr3_orn, np.float32).reshape(-1).tolist()
|
||||||
|
if loco_axes is not None:
|
||||||
|
data["loco_axes"] = np.asarray(loco_axes, np.float32).reshape(-1).tolist()
|
||||||
|
if loco_buttons is not None:
|
||||||
|
data["loco_buttons"] = np.asarray(loco_buttons, np.float32).reshape(-1).tolist()
|
||||||
return json.dumps({"topic": SMPL_TOPIC, "data": data}).encode("utf-8")
|
return json.dumps({"topic": SMPL_TOPIC, "data": data}).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +135,18 @@ def main() -> None:
|
|||||||
p.add_argument("--port", type=int, default=DEFAULT_SMPL_PORT, help="ZMQ PUB port for rt/smpl")
|
p.add_argument("--port", type=int, default=DEFAULT_SMPL_PORT, help="ZMQ PUB port for rt/smpl")
|
||||||
p.add_argument("--fps", type=float, default=50.0, help="Target publish rate (Hz)")
|
p.add_argument("--fps", type=float, default=50.0, help="Target publish rate (Hz)")
|
||||||
p.add_argument("--skeleton", type=str, default=None, help="Path to smpl_skeleton.npz")
|
p.add_argument("--skeleton", type=str, default=None, help="Path to smpl_skeleton.npz")
|
||||||
|
p.add_argument(
|
||||||
|
"--headset-source",
|
||||||
|
choices=["body", "devices"],
|
||||||
|
default="body",
|
||||||
|
help=(
|
||||||
|
"Live headset keypoint source: 'body' uses full-body tracking "
|
||||||
|
"(get_body_joints_pose, needs PICO Motion Trackers) and drives both SMPL "
|
||||||
|
"(encode_mode 2) and 3-point; 'devices' uses head + 2 controllers only "
|
||||||
|
"(get_headset_pose + get_*_controller_pose, no trackers) and emits 3-point "
|
||||||
|
"(encode_mode 1) exclusively."
|
||||||
|
),
|
||||||
|
)
|
||||||
src = p.add_mutually_exclusive_group()
|
src = p.add_mutually_exclusive_group()
|
||||||
src.add_argument("--fake", action="store_true", help="Publish synthetic motion (no headset)")
|
src.add_argument("--fake", action="store_true", help="Publish synthetic motion (no headset)")
|
||||||
src.add_argument("--motion-file", type=str, default=None, help="Replay an SMPL .npz clip over rt/smpl")
|
src.add_argument("--motion-file", type=str, default=None, help="Replay an SMPL .npz clip over rt/smpl")
|
||||||
@@ -155,7 +175,11 @@ def main() -> None:
|
|||||||
ctx = zmq.Context.instance()
|
ctx = zmq.Context.instance()
|
||||||
sock = ctx.socket(zmq.PUB)
|
sock = ctx.socket(zmq.PUB)
|
||||||
sock.bind(f"tcp://*:{args.port}")
|
sock.bind(f"tcp://*:{args.port}")
|
||||||
src_desc = f"motion-file {args.motion_file}" if clip else ("fake" if args.fake else "headset")
|
src_desc = (
|
||||||
|
f"motion-file {args.motion_file}"
|
||||||
|
if clip
|
||||||
|
else ("fake" if args.fake else f"headset:{args.headset_source}")
|
||||||
|
)
|
||||||
print(
|
print(
|
||||||
f"[pico_publisher] '{SMPL_TOPIC}' bound to tcp://*:{args.port} @ {args.fps:.0f} Hz "
|
f"[pico_publisher] '{SMPL_TOPIC}' bound to tcp://*:{args.port} @ {args.fps:.0f} Hz "
|
||||||
f"[source: {src_desc}]"
|
f"[source: {src_desc}]"
|
||||||
@@ -165,11 +189,24 @@ def main() -> None:
|
|||||||
|
|
||||||
period = 1.0 / max(1.0, args.fps)
|
period = 1.0 / max(1.0, args.fps)
|
||||||
frame_index = 0
|
frame_index = 0
|
||||||
|
last_tracked = -1
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
|
|
||||||
|
# 3-point operator calibration (device source only): map the operator's neutral
|
||||||
|
# rest pose onto the G1's neutral stance. Trigger a (re)capture with the A+B+X+Y
|
||||||
|
# controller combo, mirroring gear_sonic's ThreePointPose.calibrate_now.
|
||||||
|
calibrator = ThreePointCalibrator() if (xrt is not None and args.headset_source == "devices") else None
|
||||||
|
calib_combo_last = False
|
||||||
|
if calibrator is not None:
|
||||||
|
print(
|
||||||
|
"[pico_publisher] 3-point calibration: stand in a neutral rest pose and press "
|
||||||
|
"A+B+X+Y on the controllers to (re)calibrate."
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
loop_start = time.time()
|
loop_start = time.time()
|
||||||
vr3_pos = vr3_orn = None
|
vr3_pos = vr3_orn = None
|
||||||
|
loco_axes = loco_buttons = None
|
||||||
if clip is not None:
|
if clip is not None:
|
||||||
n = clip["joints"].shape[0]
|
n = clip["joints"].shape[0]
|
||||||
if args.no_loop and frame_index >= n:
|
if args.no_loop and frame_index >= n:
|
||||||
@@ -180,6 +217,53 @@ def main() -> None:
|
|||||||
root_quat = None if clip["root_quat"] is None else clip["root_quat"][i]
|
root_quat = None if clip["root_quat"] is None else clip["root_quat"][i]
|
||||||
root_transl = None if clip["transl"] is None else clip["transl"][i]
|
root_transl = None if clip["transl"] is None else clip["transl"][i]
|
||||||
stamp_ns = time.time_ns()
|
stamp_ns = time.time_ns()
|
||||||
|
elif not args.fake and args.headset_source == "devices":
|
||||||
|
# Controller-state 3-point path: head + 2 controllers only, no PICO
|
||||||
|
# Motion Trackers / body tracking. Emits encode_mode-1 targets only;
|
||||||
|
# the SMPL whole-body window is left as a zero placeholder.
|
||||||
|
head = np.asarray(xrt.get_headset_pose(), np.float32)
|
||||||
|
lc = np.asarray(xrt.get_left_controller_pose(), np.float32)
|
||||||
|
rc = np.asarray(xrt.get_right_controller_pose(), np.float32)
|
||||||
|
stamp_ns = int(xrt.get_time_stamp_ns())
|
||||||
|
if head.shape != (7,) or lc.shape != (7,) or rc.shape != (7,):
|
||||||
|
time.sleep(0.005)
|
||||||
|
continue
|
||||||
|
last_tracked = int(sum(np.linalg.norm(d[3:7]) > 1e-6 for d in (head, lc, rc)))
|
||||||
|
# Empty SMPL window: this source drives encode_mode 1 only, so the
|
||||||
|
# consumer must not mistake it for a (zero) whole-body reference.
|
||||||
|
joints = np.zeros((0, 3), np.float32)
|
||||||
|
root_quat = None
|
||||||
|
root_transl = None
|
||||||
|
vr3_pos, vr3_orn = compute_3point_from_devices(head, lc, rc)
|
||||||
|
# Edge-triggered (re)calibration on the A+B+X+Y combo.
|
||||||
|
combo_now = bool(
|
||||||
|
xrt.get_A_button() and xrt.get_B_button() and xrt.get_X_button() and xrt.get_Y_button()
|
||||||
|
)
|
||||||
|
if combo_now and not calib_combo_last:
|
||||||
|
calibrator.capture(vr3_pos, vr3_orn)
|
||||||
|
print("\n[pico_publisher] 3-point calibration captured (neutral pose).")
|
||||||
|
calib_combo_last = combo_now
|
||||||
|
vr3_pos, vr3_orn = calibrator.apply(vr3_pos, vr3_orn)
|
||||||
|
# Controller-stick locomotion (encode_mode 1, replicated): left/right
|
||||||
|
# sticks + A/B/X/Y. The all-four combo is reserved for calibration, so
|
||||||
|
# suppress the button pairs on that frame to avoid a spurious mode cycle.
|
||||||
|
la = np.asarray(xrt.get_left_axis(), np.float32).reshape(-1)
|
||||||
|
ra = np.asarray(xrt.get_right_axis(), np.float32).reshape(-1)
|
||||||
|
loco_axes = np.array([la[0], la[1], ra[0], ra[1]], np.float32)
|
||||||
|
btn = (
|
||||||
|
np.zeros(4, np.float32)
|
||||||
|
if combo_now
|
||||||
|
else np.array(
|
||||||
|
[
|
||||||
|
float(xrt.get_A_button()),
|
||||||
|
float(xrt.get_B_button()),
|
||||||
|
float(xrt.get_X_button()),
|
||||||
|
float(xrt.get_Y_button()),
|
||||||
|
],
|
||||||
|
np.float32,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loco_buttons = btn
|
||||||
else:
|
else:
|
||||||
if args.fake:
|
if args.fake:
|
||||||
body_poses = _fake_body_poses(loop_start - t0)
|
body_poses = _fake_body_poses(loop_start - t0)
|
||||||
@@ -190,6 +274,13 @@ def main() -> None:
|
|||||||
if body_poses.shape != (24, 7):
|
if body_poses.shape != (24, 7):
|
||||||
time.sleep(0.005)
|
time.sleep(0.005)
|
||||||
continue
|
continue
|
||||||
|
# How many joints are actually being tracked (non-zero-norm quat).
|
||||||
|
# If this stays near 0, the headset isn't streaming body data (e.g.
|
||||||
|
# "Full body"/"Send" not enabled, trackers uncalibrated, or a test
|
||||||
|
# device) and the reference will be static regardless of your motion.
|
||||||
|
# In that case, use --headset-source devices for head+controllers only.
|
||||||
|
quat_norms = np.linalg.norm(body_poses[:, 3:7], axis=1)
|
||||||
|
last_tracked = int(np.count_nonzero(quat_norms > 1e-6))
|
||||||
out = fk.compute(body_poses)
|
out = fk.compute(body_poses)
|
||||||
joints = out["smpl_joints_local"]
|
joints = out["smpl_joints_local"]
|
||||||
root_quat = out["root_quat"]
|
root_quat = out["root_quat"]
|
||||||
@@ -207,11 +298,18 @@ def main() -> None:
|
|||||||
root_transl=root_transl,
|
root_transl=root_transl,
|
||||||
vr3_pos=vr3_pos,
|
vr3_pos=vr3_pos,
|
||||||
vr3_orn=vr3_orn,
|
vr3_orn=vr3_orn,
|
||||||
|
loco_axes=loco_axes,
|
||||||
|
loco_buttons=loco_buttons,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
frame_index += 1
|
frame_index += 1
|
||||||
if frame_index % int(max(1, args.fps)) == 0:
|
if frame_index % int(max(1, args.fps)) == 0:
|
||||||
print(f"[pico_publisher] sent {frame_index} frames", end="\r")
|
denom = 3 if (not args.fake and clip is None and args.headset_source == "devices") else 24
|
||||||
|
unit = "devices" if denom == 3 else "joints"
|
||||||
|
extra = f" | tracked {last_tracked}/{denom} {unit}" if last_tracked >= 0 else ""
|
||||||
|
if calibrator is not None:
|
||||||
|
extra += " | calibrated" if calibrator.is_calibrated else " | UNCALIBRATED"
|
||||||
|
print(f"[pico_publisher] sent {frame_index} frames{extra}", end="\r")
|
||||||
|
|
||||||
dt = time.time() - loop_start
|
dt = time.time() - loop_start
|
||||||
if dt < period:
|
if dt < period:
|
||||||
|
|||||||
@@ -49,3 +49,13 @@ VR3_ORN_DIM = VR3_N_POINTS * 4 # 12 (3 x wxyz)
|
|||||||
# Flat action-dict keys: ``vr3_pos.0 .. vr3_pos.8`` and ``vr3_orn.0 .. vr3_orn.11``.
|
# Flat action-dict keys: ``vr3_pos.0 .. vr3_pos.8`` and ``vr3_orn.0 .. vr3_orn.11``.
|
||||||
VR3_POS_PREFIX = "vr3_pos."
|
VR3_POS_PREFIX = "vr3_pos."
|
||||||
VR3_ORN_PREFIX = "vr3_orn."
|
VR3_ORN_PREFIX = "vr3_orn."
|
||||||
|
|
||||||
|
# ── Controller-stick locomotion (SONIC encode_mode 1, replicated exactly) ────
|
||||||
|
# In the original 3-point teleop the same tick that sends the VR targets also drives
|
||||||
|
# locomotion from the PICO controller sticks/buttons (left stick -> move, right stick
|
||||||
|
# -> yaw, A+B / X+Y -> cycle locomotion mode). We forward that raw controller state so
|
||||||
|
# the consumer's planner can steer walking/turning underneath the upper-body tracking.
|
||||||
|
LOCO_N_AXES = 4 # [left_x, left_y, right_x, right_y]
|
||||||
|
LOCO_N_BTN = 4 # [A, B, X, Y]
|
||||||
|
LOCO_AXES_PREFIX = "loco_axes."
|
||||||
|
LOCO_BTN_PREFIX = "loco_btn."
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ plus the root orientation quaternion and pelvis translation.
|
|||||||
Quaternions are scalar-first (w, x, y, z) unless noted.
|
Quaternions are scalar-first (w, x, y, z) unless noted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -234,6 +235,19 @@ _VR3_OFFSETS = [
|
|||||||
_UNITY_TO_ROBOT = np.array([[-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]])
|
_UNITY_TO_ROBOT = np.array([[-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]])
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_quat(quats: np.ndarray) -> np.ndarray:
|
||||||
|
"""Replace zero-norm quaternions with the scalar-last identity.
|
||||||
|
|
||||||
|
The headset reports ``[0, 0, 0, 0]`` for joints it isn't currently tracking;
|
||||||
|
``scipy.Rotation.from_quat`` rejects zero-norm quaternions, so we substitute the
|
||||||
|
identity ``[0, 0, 0, 1]`` (no rotation) for those rows to keep FK robust.
|
||||||
|
"""
|
||||||
|
quats = np.asarray(quats, np.float64).copy()
|
||||||
|
bad = np.linalg.norm(quats, axis=-1) < 1e-8
|
||||||
|
quats[bad] = (0.0, 0.0, 0.0, 1.0) # scalar-last identity
|
||||||
|
return quats
|
||||||
|
|
||||||
|
|
||||||
def compute_3point(body_poses_np: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
def compute_3point(body_poses_np: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
"""Extract the SONIC 3-point VR targets from headset body poses.
|
"""Extract the SONIC 3-point VR targets from headset body poses.
|
||||||
|
|
||||||
@@ -261,9 +275,10 @@ def compute_3point(body_poses_np: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|||||||
# and rotation-offset-corrected.
|
# and rotation-offset-corrected.
|
||||||
positions = np.zeros((4, 3), np.float64)
|
positions = np.zeros((4, 3), np.float64)
|
||||||
rotations: list[R] = []
|
rotations: list[R] = []
|
||||||
|
quats = _safe_quat(body[:, 3:7])
|
||||||
for out_i, j in enumerate((0, *_VR3_JOINTS)):
|
for out_i, j in enumerate((0, *_VR3_JOINTS)):
|
||||||
positions[out_i] = q @ body[j, :3]
|
positions[out_i] = q @ body[j, :3]
|
||||||
rot = R.from_quat(body[j, 3:7]).as_matrix() # scalar-last input
|
rot = R.from_quat(quats[j]).as_matrix() # scalar-last input
|
||||||
rotations.append(R.from_matrix(q @ rot @ q.T) * _VR3_OFFSETS[out_i])
|
rotations.append(R.from_matrix(q @ rot @ q.T) * _VR3_OFFSETS[out_i])
|
||||||
|
|
||||||
root_inv = rotations[0].inv()
|
root_inv = rotations[0].inv()
|
||||||
@@ -276,6 +291,159 @@ def compute_3point(body_poses_np: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|||||||
return pos, orn
|
return pos, orn
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3-point VR teleop from raw device poses (no body trackers) ───────────────
|
||||||
|
# PICO Y-up (X-right, Y-up, Z-back) -> robot Z-up world. Ported verbatim from
|
||||||
|
# gear_sonic's controller path (``decoupled_wbc`` ``PicoStreamer.R_HEADSET_TO_WORLD``).
|
||||||
|
_HEADSET_TO_WORLD = np.array([[0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
|
||||||
|
|
||||||
|
|
||||||
|
def _device_pose_to_world(pose: np.ndarray) -> tuple[np.ndarray, R]:
|
||||||
|
"""Convert a raw (7,) device pose ``[x, y, z, qx, qy, qz, qw]`` (PICO Y-up frame)
|
||||||
|
to a Z-up world ``(position, Rotation)`` pair.
|
||||||
|
|
||||||
|
Handles the all-zero quaternion the SDK emits when a device is momentarily
|
||||||
|
untracked by substituting the identity, matching ``PicoStreamer._process_xr_pose``.
|
||||||
|
"""
|
||||||
|
pose = np.asarray(pose, np.float64)
|
||||||
|
xyz = _HEADSET_TO_WORLD @ pose[:3]
|
||||||
|
quat = pose[3:7] # scalar-last
|
||||||
|
if np.linalg.norm(quat) < 1e-8:
|
||||||
|
quat = np.array([0.0, 0.0, 0.0, 1.0])
|
||||||
|
rot = _HEADSET_TO_WORLD @ R.from_quat(quat).as_matrix() @ _HEADSET_TO_WORLD.T
|
||||||
|
return xyz, R.from_matrix(rot)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_3point_from_devices(
|
||||||
|
head_pose: np.ndarray,
|
||||||
|
left_pose: np.ndarray,
|
||||||
|
right_pose: np.ndarray,
|
||||||
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Build the SONIC 3-point VR targets from raw head + controller poses.
|
||||||
|
|
||||||
|
This is the controller-state path (no PICO Motion Trackers / body tracking
|
||||||
|
required): the 3 keypoints are the left controller, right controller, and the
|
||||||
|
headset, each expressed relative to the **headset yaw frame** — mirroring
|
||||||
|
gear_sonic's ``decoupled_wbc`` ``PicoStreamer._process_xr_pose`` (Y-up -> Z-up,
|
||||||
|
then de-headed by the headset yaw). The headset stands in for the "neck" point,
|
||||||
|
so its root-relative position is ~0 and its orientation carries pitch/roll.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
head_pose, left_pose, right_pose: (7,) ``[x, y, z, qx, qy, qz, qw]`` device
|
||||||
|
poses (scalar-last) from ``xrt.get_headset_pose()`` /
|
||||||
|
``xrt.get_left_controller_pose()`` / ``xrt.get_right_controller_pose()``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(pos, orn):
|
||||||
|
- pos: (9,) float32, headset-yaw-relative ``[x, y, z]`` for [l-wrist, r-wrist, head]
|
||||||
|
- orn: (12,) float32, headset-yaw-relative ``[w, x, y, z]`` for the same order
|
||||||
|
"""
|
||||||
|
head_pos, head_rot = _device_pose_to_world(head_pose)
|
||||||
|
left_pos, left_rot = _device_pose_to_world(left_pose)
|
||||||
|
right_pos, right_rot = _device_pose_to_world(right_pose)
|
||||||
|
|
||||||
|
# De-head: cancel the headset yaw so targets are expressed in a heading-local frame.
|
||||||
|
head_yaw = head_rot.as_euler("xyz")[2]
|
||||||
|
inv_yaw = R.from_euler("z", -head_yaw)
|
||||||
|
|
||||||
|
points = ((left_pos, left_rot), (right_pos, right_rot), (head_pos, head_rot))
|
||||||
|
pos = np.zeros(VR3_POS_DIM, np.float32)
|
||||||
|
orn = np.zeros(VR3_ORN_DIM, np.float32)
|
||||||
|
for k, (p_pos, p_rot) in enumerate(points):
|
||||||
|
pos[k * 3 : k * 3 + 3] = inv_yaw.apply(p_pos - head_pos)
|
||||||
|
orn[k * 4 : k * 4 + 4] = (inv_yaw * p_rot).as_quat(scalar_first=True) # wxyz
|
||||||
|
return pos, orn
|
||||||
|
|
||||||
|
|
||||||
|
# ── operator calibration for the 3-point targets ────────────────────────────
|
||||||
|
# G1 neutral (zero-q) key-frame targets, pelvis-relative [x, y, z] in metres, from
|
||||||
|
# MuJoCo FK on g1_29dof with gear_sonic's local offsets (wrists +0.18x ∓0.025y,
|
||||||
|
# torso +0.35z). These are the poses the operator's rest pose is mapped onto so the
|
||||||
|
# robot starts at its neutral stance. Orientations are identity at neutral.
|
||||||
|
_G1_NEUTRAL_WRIST_POS = np.array([[0.3798, 0.1237, 0.0952], [0.3798, -0.1237, 0.0952]], np.float64)
|
||||||
|
# Neck reconstruction chain (mirrors ThreePointPose._apply_calibration): torso link
|
||||||
|
# +0.05 z, then +0.35 along the neck's local Z.
|
||||||
|
_NECK_TORSO_OFFSET_Z = 0.05
|
||||||
|
_NECK_LINK_LENGTH = 0.35
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ThreePointCalibrator:
|
||||||
|
"""Aligns raw 3-point VR targets to the G1's neutral stance.
|
||||||
|
|
||||||
|
Ports gear_sonic ``ThreePointPose._capture_calibration`` / ``_apply_calibration``:
|
||||||
|
on :meth:`capture` (operator holding a neutral rest pose) it records (a) the
|
||||||
|
inverse of the head/neck orientation, used to de-tilt all points to upright, and
|
||||||
|
(b) per-wrist position + orientation offsets that map the corrected rest pose onto
|
||||||
|
the fixed G1 neutral wrist targets. :meth:`apply` then transforms every subsequent
|
||||||
|
frame by those offsets, and reconstructs the head/neck position from the calibrated
|
||||||
|
neck orientation via the torso->neck kinematic chain.
|
||||||
|
|
||||||
|
All quaternions are scalar-first (w, x, y, z), matching :func:`compute_3point`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_neck_quat_inv: R | None = field(default=None, init=False)
|
||||||
|
_wrist_pos_offset: np.ndarray | None = field(default=None, init=False)
|
||||||
|
_wrist_rot_offset: list[R] = field(default_factory=list, init=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_calibrated(self) -> bool:
|
||||||
|
return self._neck_quat_inv is not None
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._neck_quat_inv = None
|
||||||
|
self._wrist_pos_offset = None
|
||||||
|
self._wrist_rot_offset = []
|
||||||
|
|
||||||
|
def capture(self, pos: np.ndarray, orn: np.ndarray) -> None:
|
||||||
|
"""Capture calibration offsets from a neutral-pose frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pos: (9,) root-relative ``[x, y, z]`` for [l-wrist, r-wrist, head].
|
||||||
|
orn: (12,) root-relative ``[w, x, y, z]`` for the same order.
|
||||||
|
"""
|
||||||
|
pos = np.asarray(pos, np.float64).reshape(3, 3)
|
||||||
|
orn = np.asarray(orn, np.float64).reshape(3, 4)
|
||||||
|
neck_rot = R.from_quat(orn[2], scalar_first=True)
|
||||||
|
neck_inv = neck_rot.inv()
|
||||||
|
|
||||||
|
self._wrist_pos_offset = np.zeros((2, 3), np.float64)
|
||||||
|
self._wrist_rot_offset = []
|
||||||
|
for k in range(2):
|
||||||
|
corrected_pos = neck_inv.apply(pos[k])
|
||||||
|
corrected_rot = neck_inv * R.from_quat(orn[k], scalar_first=True)
|
||||||
|
self._wrist_pos_offset[k] = corrected_pos - _G1_NEUTRAL_WRIST_POS[k]
|
||||||
|
self._wrist_rot_offset.append(corrected_rot.inv()) # g1 neutral rot = identity
|
||||||
|
self._neck_quat_inv = neck_inv
|
||||||
|
|
||||||
|
def apply(self, pos: np.ndarray, orn: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Apply the stored calibration; returns calibrated ``(pos (9,), orn (12,))``.
|
||||||
|
|
||||||
|
A no-op (returns the inputs unchanged) until :meth:`capture` has been called.
|
||||||
|
"""
|
||||||
|
if self._neck_quat_inv is None:
|
||||||
|
return (
|
||||||
|
np.asarray(pos, np.float32).reshape(-1),
|
||||||
|
np.asarray(orn, np.float32).reshape(-1),
|
||||||
|
)
|
||||||
|
pos = np.asarray(pos, np.float64).reshape(3, 3)
|
||||||
|
orn = np.asarray(orn, np.float64).reshape(3, 4)
|
||||||
|
neck_inv = self._neck_quat_inv
|
||||||
|
|
||||||
|
out_pos = np.zeros((3, 3), np.float64)
|
||||||
|
out_orn = np.zeros((3, 4), np.float64)
|
||||||
|
for k in range(2): # wrists
|
||||||
|
out_pos[k] = neck_inv.apply(pos[k]) - self._wrist_pos_offset[k]
|
||||||
|
corrected_rot = neck_inv * R.from_quat(orn[k], scalar_first=True)
|
||||||
|
out_orn[k] = (self._wrist_rot_offset[k] * corrected_rot).as_quat(scalar_first=True)
|
||||||
|
|
||||||
|
# Head/neck: orientation de-tilted, position from the torso->neck chain.
|
||||||
|
neck_rot = neck_inv * R.from_quat(orn[2], scalar_first=True)
|
||||||
|
out_orn[2] = neck_rot.as_quat(scalar_first=True)
|
||||||
|
neck_z = neck_rot.apply([0.0, 0.0, 1.0])
|
||||||
|
out_pos[2] = np.array([0.0, 0.0, _NECK_TORSO_OFFSET_Z]) + _NECK_LINK_LENGTH * neck_z
|
||||||
|
return out_pos.reshape(-1).astype(np.float32), out_orn.reshape(-1).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
class SmplForwardKinematics:
|
class SmplForwardKinematics:
|
||||||
"""Rest-skeleton SMPL forward kinematics (no mesh, no torch)."""
|
"""Rest-skeleton SMPL forward kinematics (no mesh, no torch)."""
|
||||||
|
|
||||||
@@ -323,7 +491,7 @@ class SmplForwardKinematics:
|
|||||||
|
|
||||||
# Global joint rotations from the headset (scalar-last), with the SMPL
|
# Global joint rotations from the headset (scalar-last), with the SMPL
|
||||||
# +180 deg-about-Y frame fix, converted to per-joint local axis-angle.
|
# +180 deg-about-Y frame fix, converted to per-joint local axis-angle.
|
||||||
global_rots = R.from_quat(body_poses_np[:, 3:7]) * R.from_euler("y", 180, degrees=True)
|
global_rots = R.from_quat(_safe_quat(body_poses_np[:, 3:7])) * R.from_euler("y", 180, degrees=True)
|
||||||
gm = global_rots.as_matrix() # (24, 3, 3)
|
gm = global_rots.as_matrix() # (24, 3, 3)
|
||||||
|
|
||||||
local_aa = np.zeros((24, 3), np.float64)
|
local_aa = np.zeros((24, 3), np.float64)
|
||||||
|
|||||||
@@ -41,7 +41,16 @@ from collections import deque
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import zmq
|
import zmq
|
||||||
|
|
||||||
from .smpl_constants import JOINT_DIM, N_JOINTS, SMPL_OBS_DIM, VR3_ORN_DIM, VR3_POS_DIM, WINDOW
|
from .smpl_constants import (
|
||||||
|
JOINT_DIM,
|
||||||
|
LOCO_N_AXES,
|
||||||
|
LOCO_N_BTN,
|
||||||
|
N_JOINTS,
|
||||||
|
SMPL_OBS_DIM,
|
||||||
|
VR3_ORN_DIM,
|
||||||
|
VR3_POS_DIM,
|
||||||
|
WINDOW,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -95,6 +104,12 @@ class SmplStream:
|
|||||||
self.vr3_pos = np.zeros(VR3_POS_DIM, np.float32)
|
self.vr3_pos = np.zeros(VR3_POS_DIM, np.float32)
|
||||||
self.vr3_orn = np.tile([1.0, 0.0, 0.0, 0.0], VR3_ORN_DIM // 4).astype(np.float32)
|
self.vr3_orn = np.tile([1.0, 0.0, 0.0, 0.0], VR3_ORN_DIM // 4).astype(np.float32)
|
||||||
self._got_vr3 = False
|
self._got_vr3 = False
|
||||||
|
self._last_vr3_t = 0.0
|
||||||
|
# Latest controller-stick locomotion (encode_mode 1): [lx, ly, rx, ry] + [A,B,X,Y].
|
||||||
|
self.loco_axes = np.zeros(LOCO_N_AXES, np.float32)
|
||||||
|
self.loco_buttons = np.zeros(LOCO_N_BTN, np.float32)
|
||||||
|
self._got_loco = False
|
||||||
|
self._last_loco_t = 0.0
|
||||||
self._last_index = -1
|
self._last_index = -1
|
||||||
self._last_recv_t = 0.0
|
self._last_recv_t = 0.0
|
||||||
self._warned_stale = False
|
self._warned_stale = False
|
||||||
@@ -121,6 +136,29 @@ class SmplStream:
|
|||||||
"""True once the producer has sent at least one 3-point VR frame."""
|
"""True once the producer has sent at least one 3-point VR frame."""
|
||||||
return self._got_vr3
|
return self._got_vr3
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_fresh_vr3(self) -> bool:
|
||||||
|
"""True when a 3-point VR frame arrived within ``stale_after_s``.
|
||||||
|
|
||||||
|
Unlike :attr:`has_data`, this is independent of the SMPL window, so the
|
||||||
|
controller-state source (head + controllers only, empty SMPL) still drives
|
||||||
|
``encode_mode 1`` without a whole-body reference.
|
||||||
|
"""
|
||||||
|
if not self._got_vr3:
|
||||||
|
return False
|
||||||
|
if not self.stale_after_s:
|
||||||
|
return True
|
||||||
|
return (time.time() - self._last_vr3_t) <= self.stale_after_s
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_fresh_loco(self) -> bool:
|
||||||
|
"""True when controller-stick locomotion arrived within ``stale_after_s``."""
|
||||||
|
if not self._got_loco:
|
||||||
|
return False
|
||||||
|
if not self.stale_after_s:
|
||||||
|
return True
|
||||||
|
return (time.time() - self._last_loco_t) <= self.stale_after_s
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def seconds_since_last(self) -> float:
|
def seconds_since_last(self) -> float:
|
||||||
"""Wall-clock seconds since the last real frame (inf before the first)."""
|
"""Wall-clock seconds since the last real frame (inf before the first)."""
|
||||||
@@ -143,6 +181,9 @@ class SmplStream:
|
|||||||
self._buf.clear()
|
self._buf.clear()
|
||||||
self._got_first = False
|
self._got_first = False
|
||||||
self._got_vr3 = False
|
self._got_vr3 = False
|
||||||
|
self._last_vr3_t = 0.0
|
||||||
|
self._got_loco = False
|
||||||
|
self._last_loco_t = 0.0
|
||||||
|
|
||||||
# -- core ----------------------------------------------------------------
|
# -- core ----------------------------------------------------------------
|
||||||
def _drain_latest(self) -> np.ndarray | None:
|
def _drain_latest(self) -> np.ndarray | None:
|
||||||
@@ -155,6 +196,28 @@ class SmplStream:
|
|||||||
while dict(self._poller.poll(0)).get(self._sock) == zmq.POLLIN:
|
while dict(self._poller.poll(0)).get(self._sock) == zmq.POLLIN:
|
||||||
payload = self._sock.recv()
|
payload = self._sock.recv()
|
||||||
data = json.loads(payload.decode("utf-8")).get("data", {})
|
data = json.loads(payload.decode("utf-8")).get("data", {})
|
||||||
|
|
||||||
|
# Sparse 3-point VR targets (encode_mode 1). Parsed independently of the
|
||||||
|
# SMPL window so the controller-state source (head + controllers, empty
|
||||||
|
# SMPL) is still handled.
|
||||||
|
vp = data.get("vr3_pos")
|
||||||
|
vo = data.get("vr3_orn")
|
||||||
|
if vp is not None and vo is not None and len(vp) == VR3_POS_DIM and len(vo) == VR3_ORN_DIM:
|
||||||
|
self.vr3_pos = np.asarray(vp, np.float32)
|
||||||
|
self.vr3_orn = np.asarray(vo, np.float32)
|
||||||
|
self._got_vr3 = True
|
||||||
|
self._last_vr3_t = time.time()
|
||||||
|
|
||||||
|
# Controller-stick locomotion (encode_mode 1), also independent of SMPL.
|
||||||
|
la = data.get("loco_axes")
|
||||||
|
lb = data.get("loco_buttons")
|
||||||
|
if la is not None and lb is not None and len(la) == LOCO_N_AXES and len(lb) == LOCO_N_BTN:
|
||||||
|
self.loco_axes = np.asarray(la, np.float32)
|
||||||
|
self.loco_buttons = np.asarray(lb, np.float32)
|
||||||
|
self._got_loco = True
|
||||||
|
self._last_loco_t = time.time()
|
||||||
|
|
||||||
|
# SMPL whole-body window (encode_mode 2), optional on this stream.
|
||||||
joints = np.asarray(data.get("smpl_joints_local", []), np.float32)
|
joints = np.asarray(data.get("smpl_joints_local", []), np.float32)
|
||||||
if joints.size != N_JOINTS * JOINT_DIM:
|
if joints.size != N_JOINTS * JOINT_DIM:
|
||||||
continue
|
continue
|
||||||
@@ -166,12 +229,6 @@ class SmplStream:
|
|||||||
rt = data.get("root_transl")
|
rt = data.get("root_transl")
|
||||||
if rt is not None and len(rt) == 3:
|
if rt is not None and len(rt) == 3:
|
||||||
self.root_transl = np.asarray(rt, np.float32)
|
self.root_transl = np.asarray(rt, np.float32)
|
||||||
vp = data.get("vr3_pos")
|
|
||||||
vo = data.get("vr3_orn")
|
|
||||||
if vp is not None and vo is not None and len(vp) == VR3_POS_DIM and len(vo) == VR3_ORN_DIM:
|
|
||||||
self.vr3_pos = np.asarray(vp, np.float32)
|
|
||||||
self.vr3_orn = np.asarray(vo, np.float32)
|
|
||||||
self._got_vr3 = True
|
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
def step(self) -> np.ndarray:
|
def step(self) -> np.ndarray:
|
||||||
|
|||||||
@@ -60,8 +60,18 @@ def is_package_available(
|
|||||||
# If the package can't be imported, it's not available
|
# If the package can't be imported, it's not available
|
||||||
package_exists = False
|
package_exists = False
|
||||||
else:
|
else:
|
||||||
# For packages other than "torch", don't attempt the fallback and set as not available
|
# The distribution may be published under a name that differs from the
|
||||||
package_exists = False
|
# import name (e.g. ``onnxruntime`` imports from ``onnxruntime-gpu`` /
|
||||||
|
# ``onnxruntime-silicon``). Resolve the import name to its actual
|
||||||
|
# distribution(s) and read the version from there before giving up.
|
||||||
|
try:
|
||||||
|
dists = importlib.metadata.packages_distributions().get(import_name, [])
|
||||||
|
if dists:
|
||||||
|
package_version = importlib.metadata.version(dists[0])
|
||||||
|
else:
|
||||||
|
package_exists = False
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
package_exists = False
|
||||||
logging.debug(f"Detected {pkg_name} version: {package_version}")
|
logging.debug(f"Detected {pkg_name} version: {package_version}")
|
||||||
if return_version:
|
if return_version:
|
||||||
return package_exists, package_version
|
return package_exists, package_version
|
||||||
|
|||||||
Reference in New Issue
Block a user