test 3-point teleop

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Martino Russi
2026-07-15 18:16:46 +02:00
parent f6a845c30c
commit 9c54665a76
17 changed files with 1379 additions and 13 deletions
+103
View File
@@ -0,0 +1,103 @@
# SONIC fidelity audit — Python/ONNX port vs C++ deploy reference
Compares the lerobot Python/ONNX SONIC port against the original C++ deploy stack
(`gear_sonic_deploy/src/g1/g1_deploy_onnx_ref/src/g1_deploy_onnx_ref.cpp` and headers).
**Verdict:** the port is algorithmically faithful in the core control math (the parts
that determine stability and pose tracking). The gaps are concentrated in (a) update
cadence, (b) the safety layer, and (c) two modes/paths present in C++ but never wired up.
No silent math bug was found; divergences are deliberate or missing-feature.
## Genuinely faithful (verified, no action needed)
- **Action production** — both do `q_target = DEFAULT_ANGLES + policy_out[isaaclab→mujoco] * ACTION_SCALE`
(residual-to-default, not residual-to-previous). C++ `g1_deploy_onnx_ref.cpp:3119-3127`,
Python `sonic_pipeline.py:708-724`.
- **Joint-order remap** — `ISAACLAB_TO_MUJOCO` / `MUJOCO_TO_ISAACLAB` identical arrays.
- **Gains** — same `Kp = armature·ω²`, `Kd = 2ζ·armature·ω`, ω = 10·2π, ζ = 2, and the same
2× set `{4,5,10,11,13,14}` (ankles + waist roll/pitch).
- **History normalization** — robot `q` subtracts defaults, velocities raw,
gravity = `quat_rotate(conj(base), [0,0,-1])`, oldest→newest ordering.
- **6D anchor rotation** — verified element-by-element: C++ takes the first two *columns*
of the rotation matrix flattened row-wise (`.cpp:677-683`); Python `quat_to_6d`
(`sonic_pipeline.py:227-240`) produces the identical 6 values. Only the Python docstring
wording ("rows") is misleading — the math is correct.
- **Planner** — replan intervals (RUN 0.1 / CRAWL 0.2 / boxing 1.0 / default 1.0 s),
8-frame slerp crossfade blend, 30→50 Hz linear+slerp resample, `MOTION_LOOK_AHEAD = 2`,
4-frame 30 Hz context. All match.
- **SLERP / heading / FK conventions** — wxyz, shortest-path slerp with 0.9995 linear
fallback, `calc_heading` yaw extraction.
## Divergences that reduce fidelity (ranked)
### 1. Encoder cadence: 10 Hz vs 50 Hz (biggest)
C++ runs the encoder every control tick — `GatherTokenState → Encode()` is unconditional
inside the 50 Hz `Control()` loop (`.cpp:1644-1684`, no N-step gate). The port recomputes
the token only every 5 ticks (`ENCODER_UPDATE_EVERY = 5`, `sonic_pipeline.py:150`), so the
latent is up to 80 ms stale and the decoder consumes a held token for 4 of every 5 ticks.
Likely a perf shortcut. For full faithfulness set `ENCODER_UPDATE_EVERY = 1` (cheap on GPU).
### 2. SMPL root anchor disabled by default
C++ always feeds the reference root orientation into the anchor/heading. The port sets
`smpl_root_quat = None` unless `enable_smpl_root=True` (`sonic_whole_body.py:366`), because
the raw 30 Hz root caused QACC spikes. Faithful fix: slerp-resample the root 30→50 Hz like
the joints, then re-enable by default. Until then, mode-2 heading steering isn't faithful.
See `SONIC_REPLAY_DEBUGGING.md`.
### 3. VR 3-point teleop (`encode_mode = 1`) — now wired (was inert)
Originally the encoder layout for mode 1 existed but nothing set `encode_mode=1` or
filled `vr_3point_local_target` / `vr_3point_local_orn_target`. **Now implemented
end-to-end:**
- Producer `pico_publisher.py` computes the 3 keypoints via `smpl_fk.compute_3point`
(ported from gear_sonic `_process_3pt_pose`) and adds `vr3_pos` (9) / `vr3_orn` (12)
to the `rt/smpl` message.
- `SmplStream` parses them (`has_vr3`); `PicoHeadset(mode="vr3")` emits `vr3_pos.*` /
`vr3_orn.*` action keys.
- `SonicWholeBodyController` extracts them, switches to `encode_mode=1`, fills the
controller targets, and drives locomotion from the joystick/keyboard planner
(`use_joystick=True`); `PlannerController.build_encoder_obs` gained a mode-1 branch
(lower body per-frame step 5 + VR targets + anchor).
Still not ported: `vr_3point_compliance`, and the operator calibration
(`ThreePointPose.apply_calibration`) / physical wrist offsets — the raw tracked joint
poses are used, so hand-tracking may need calibration tuning.
### 4. Safety layer largely absent
- **Joint-velocity kill switch** at >35 rad/s (`.cpp:2829-2832`) — missing.
- **E-stop damping**: C++ e-stop commands `kp=0, kd=8` (active damping, `.cpp:2708-2714`).
The port's Space/e-stop just sets `playing=False` + `LM.IDLE` and stops the cursor — it
does not switch to a damped hold. Less safe.
- **Motor-temperature** monitor (90 °C / 85 °C hysteresis) — missing.
- **Stale-/late-state watchdogs** (500 ms fail, 50 ms warn, 200 ms token timeout) — not in
the SONIC layer (partly covered by `UnitreeG1`, not equivalently).
- **Per-tick delta clamp** — C++ has none, so the reverted `MAX_DELTA_PER_STEP` was correctly
removed; that part is faithful.
### 5. Idle readaptation blend missing
At planner IDLE at a motion end, C++ runs a double-threshold blend (0.98/0.02 toward
robot-current or original target; thresholds 0.10/0.05/0.045 rad; `.cpp:3303-3361`). The
port just holds. Minor; only matters at motion-end idle.
### 6. Input/streaming paths not ported
- **ZMQ Protocol V1 joint streaming** (`encode_mode=0` from a live joint stream via
`StreamedMotionMerger`) — not implemented; the port's streaming path is SMPL-only.
- **External token injection** (tokens over ZMQ/ROS2 bypassing the encoder) — not supported;
the port always encodes locally. Fine for standalone.
- **Gamepad** — C++ has a full gamepad map (EMA smooth 0.3, deadzone 0.05); the port has
joystick byte-parsing + keyboard, no gamepad manager. Functionally close.
### 7. Minor input-feel differences
Delta-heading is continuous (±0.02 rad/tick) in the port vs discrete steps (±π/6, ±π/12) in
C++; speed/height increments differ slightly. Behavioral feel only, not correctness.
## Recommendation (priority order)
1. `ENCODER_UPDATE_EVERY = 1` (or a param defaulting to 1) — closes the biggest gap for
near-zero cost on GPU.
2. Rate-match the SMPL root 30→50 Hz (slerp) and re-enable `enable_smpl_root` by default.
3. Add the safety envelope: joint-velocity kill (35 rad/s) and a proper damped e-stop
(`kp=0, kd≈8`). Real hardware-safety consequences.
4. Wire `encode_mode=1` from the pico headset (3-point targets), or document as out of scope.
5. Fix the `quat_to_6d` docstring wording ("rows" → "first two columns flattened row-wise").
Items 47 are feature-completeness; 13 are what to do for faithful behavior on the robot.
+97
View File
@@ -0,0 +1,97 @@
# SONIC replay instability — root cause & prevention
This documents the multi-day debugging of "SONIC motion replay is unstable / jitters /
lags / dies on the floor", so we don't chase the same ghosts again.
## TL;DR
There were **two independent problems**, and they masked each other:
1. **Wrong conda environment (the "lag"/jitter).** The debugging env `lerobot_sonic`
had a **CUDA-13** stack that the machine's GPU driver cannot run, so ONNX Runtime
silently fell back to CPU and oversubscribed threads. The known-good env
`lerobot312` has a **CUDA-12** stack matching the driver, so the encoder/decoder/
planner run on the GPU (~1220 ms planner inference) and the control loop holds
~4850 Hz.
2. **SMPL root-motion feeding (the NaN/`unstable` crash).** Passing the per-frame SMPL
root quaternion into the mode-2 anchor produced a root-acceleration spike
(`Nan, Inf or huge value in QACC at DOF 0`) mid-episode. Disabling it gives clean
tracking.
Neither is an algorithmic bug in the ported SONIC pipeline. A lot of earlier "fixes"
(ORT thread caps, `MAX_DELTA_PER_STEP` clamp, planner-disable toggle, resampling)
were chasing symptom #1 in the wrong environment and were reverted.
## Environment: what "good" looks like
Run the replay in `lerobot312` (CUDA-12), pointing at the current sonic checkout:
```bash
conda activate lerobot312
PYTHONPATH=/home/yope/Documents/sonic/lerobot/src \
lerobot-replay \
--robot.type=unitree_g1 --robot.controller=SonicWholeBodyController \
--dataset.repo_id=lerobot/SMPL_samples --dataset.episode=12
```
Known-good versions (`lerobot312`):
| package | good (`lerobot312`) | broken (`lerobot_sonic`) |
|-----------------|---------------------|--------------------------|
| GPU driver | CUDA 12.8 (`12080`) | same (unchanged) |
| torch | `2.10.0+cu128` | `2.11.0+cu130` |
| onnxruntime | `onnxruntime-gpu 1.26.0` | CPU `1.27.0` / cu13 mismatch |
| cudnn | cu12 (bundled) | `nvidia-cudnn-cu13 9.19` |
| mujoco | `3.8.1` | `3.10.0` |
### How to verify the GPU path is actually live (do this FIRST)
```bash
python -c "import torch; print('cuda', torch.cuda.is_available())" # must be True
python -c "import onnxruntime as ort; print(ort.get_available_providers())" # must list CUDAExecutionProvider
```
If `torch.cuda.is_available()` is `False` or `CUDAExecutionProvider` is missing, STOP —
you are in the wrong/broken env. Do not "optimize" anything else until this passes.
### Why CUDA 13 was fatal here
The GPU driver supports up to **CUDA 12.8**. A CUDA-13 build of torch/onnxruntime
cannot initialize on it:
- `torch.cuda.is_available()` returns `False` (silent CPU fallback).
- `onnxruntime-gpu` (a CUDA-12 build) can't find a matching cuDNN because only the
CUDA-13 cuDNN is installed → `CUDNN failure 1001: CUDNN_STATUS_NOT_INITIALIZED`.
Installing `onnxruntime` and `onnxruntime-gpu` **together** also breaks: they share the
`onnxruntime` namespace and whichever installs last clobbers the other's shared libs.
Keep only `onnxruntime-gpu` in a GPU env.
## Root motion: the NaN/unstable crash
Symptom:
```
WARNING: Nan, Inf or huge value in QACC at DOF 0. The simulation is unstable. Time = 4.196.
```
`DOF 0` is the floating base/root. Feeding the per-frame SMPL root quaternion
(`root.*` action keys) into `controller.smpl_root_quat` injected a discontinuity in the
reference root trajectory (frame-to-frame jump and/or 30 Hz→50 Hz timing mismatch) that
the tracker converted into an exploding base acceleration.
Current mitigation (in `sonic_whole_body.py`, `run_step`): the per-frame root quaternion
is ignored (`self.controller.smpl_root_quat = None`) so the anchor stays self-driven.
Result: clean tracking, no NaN.
Proper fix (follow-up, not yet done): smooth/slerp-filter the reference root trajectory
(or resample to the control rate) before feeding it to the anchor, then re-enable.
## Prevention checklist
- **Always confirm the env before debugging behavior.** Run the two verification
commands above. Most of the "instability" was environment, not code.
- **Pin the GPU stack** to match the driver (CUDA 12.8): `torch ==2.10.0+cu128`,
`onnxruntime-gpu ==1.26.0`, `mujoco ==3.8.1`. Do not let `lerobot_sonic` drift to a
CUDA-13 stack.
- **Never install `onnxruntime` and `onnxruntime-gpu` side by side.**
- **Don't add band-aid clamps/thread-caps/resampling to hide a CPU-fallback**; fix the
env instead. Those changes were reverted.
- **Root trajectory must be continuous / rate-matched** before it drives the anchor.
+79
View File
@@ -0,0 +1,79 @@
# feat: ONNX inference support (ACT)
## Summary
This PR introduces a first, end-to-end path for **ONNX-based policy inference** in LeRobot, currently scoped to the **ACT** policy. The goal is to standardize how we export and run policies through ONNX Runtime so that the same workflow can later cover other policies (including the Unitree G1 whole-body / locomotion policies) and so policies can run on **edge devices** without a full PyTorch stack.
> ⚠️ **Scope:** today this only works for **ACT**. ACT is the natural starting point because inference is a single deterministic forward pass (ResNet backbone + transformer enc/dec + action head) with a zeros VAE latent — no denoising loop, no KV cache. Other architectures (e.g. PI0.5) need more work before they can be exported the same way.
## Motivation
- **Standardize ONNX inference** across LeRobot policies behind one export + run convention, instead of one-off conversion scripts.
- **Run on edge devices**: ONNX Runtime has a much smaller footprint than PyTorch and ships CPU / CUDA / TensorRT / mobile execution providers, which is what we want for deploying policies (incl. Unitree G1 policies) on-robot.
- Keep normalization and control logic in Python (the LeRobot processor pipeline + action queue), and export **only the neural network** as a portable graph.
## What's included
All new files live under `examples/onnx/` (no changes to `src/lerobot/...`):
- **`export_act.py`** — exports `policy.model` to ONNX as a pure function `(state, images) -> action_chunk`, then runs a numerical parity check (PyTorch vs ONNX Runtime).
- **`eval_act_onnx.py`** — evaluates ACT in sim with either the PyTorch or the ONNX backend. It swaps **only** `policy.model` with an ONNX Runtime session (wrapped as an `nn.Module`), so processors, action queue and the gym env are identical and any delta is attributable to the backend alone.
- **`convert_legacy_checkpoint.py`** — helper for older hub checkpoints that bake normalization into weights and lack `policy_preprocessor.json` / `policy_postprocessor.json`.
## Design notes
- Only the network is exported. At inference, ACT's `predict_action_chunk` is effectively `self.model(batch)[0]` with a zeros latent, so the graph is deterministic in `(state, images)`.
- **Normalization stays outside ONNX**, in the LeRobot processor pipeline. The ONNX graph consumes already-normalized inputs and emits normalized actions.
- torch 2.9+ defaults to the dynamo exporter (requires `onnxscript`); the exporter uses the legacy TorchScript path (`dynamo=False`) since ACT's graph is fixed-shape.
## Results
**Numerical parity** (PyTorch vs ONNX Runtime):
```
max_abs_diff = 1.073e-06 mean_abs_diff = 1.790e-07 -> PASS
```
**In-sim eval**, `AlohaTransferCube-v0`, identical seed:
| backend | n_episodes | pc_success |
|---------|-----------:|-----------:|
| torch | 10 | 70.0% |
| onnx | 10 | 70.0% |
Identical success rate; sub-1e-6 per-step parity. (Run on CPU here; both backends behave the same on CUDA.)
## How to run
```bash
export PYTHONPATH=src
# export once (also runs the parity check)
python examples/onnx/export_act.py \
--policy-path=lerobot/act_aloha_sim_transfer_cube_human \
--output=outputs/onnx/act_transfer_cube.onnx
# compare backends in sim
python examples/onnx/eval_act_onnx.py \
--policy-path=lerobot/act_aloha_sim_transfer_cube_human \
--task=AlohaTransferCube-v0 \
--backend=torch --n-episodes=50 --batch-size=10 --device=cuda
python examples/onnx/eval_act_onnx.py \
--policy-path=lerobot/act_aloha_sim_transfer_cube_human \
--task=AlohaTransferCube-v0 \
--onnx=outputs/onnx/act_transfer_cube.onnx \
--backend=onnx --n-episodes=50 --batch-size=10 --device=cuda
```
## Follow-ups (out of scope for this PR)
- Generalize the export convention beyond ACT (PI0.5 denoising loop + KV cache, diffusion policies, etc.).
- Cover the **Unitree G1** policies so they can be deployed via ONNX Runtime on-robot.
- Provide an edge-device runner / packaging story (CPU / TensorRT / mobile execution providers) and a latency benchmark.
## Test plan
- [x] ONNX export succeeds for ACT and passes the parity check (`max_abs_diff < 1e-3`).
- [x] In-sim eval matches the PyTorch backend at the same seed.
- [ ] Full 50-episode eval on CUDA (torch vs onnx) reproduces the baseline success rate.
+113
View File
@@ -0,0 +1,113 @@
# Unitree G1 — SONIC whole-body control
This package runs NVIDIA's **SONIC** whole-body controller (and the GR00T/Holosoma
locomotion controllers) on the Unitree G1, in MuJoCo simulation or on real hardware.
SONIC turns a high-level movement intent — or a streamed **SMPL** whole-body pose — into
50 Hz joint-position targets. It is a pure-Python/ONNX reimplementation of the SONIC
deploy stack (no `gear_sonic`/torch dependency).
## Controllers
Selected with `--robot.controller=<ClassName>`:
| Controller | Purpose |
|---|---|
| `SonicWholeBodyController` | SONIC whole-body: locomotion, keyboard, and SMPL imitation (mode 2) |
| `GrootLocomotionController` | GR00T locomotion policy |
| `HolosomaLocomotionController` | Holosoma locomotion policy |
## Requirements
- `onnxruntime` (CPU) **or** `onnxruntime-gpu` (recommended — SONIC runs three ONNX
sessions and is much smoother on GPU). Install the CUDA build that matches your
driver (e.g. `onnxruntime-gpu==1.26.0` for a CUDA-12.x driver). Verify with:
```bash
python -c "import onnxruntime as ort; print(ort.get_available_providers())"
# expect CUDAExecutionProvider in the list for GPU
```
- `mujoco` for simulation (`is_simulation=True`).
- `pyzmq` only if you use the live SMPL stream (pico headset).
- The SONIC ONNX models are downloaded automatically from the `nvidia/GEAR-SONIC` Hub repo.
## Running
**Replay an SMPL dataset (motion imitation):**
```bash
lerobot-replay \
--robot.type=unitree_g1 --robot.controller=SonicWholeBodyController \
--dataset.repo_id=<user>/<smpl_dataset> --dataset.episode=0
```
**Keyboard teleop** (drives locomotion via the native keyboard teleoperator):
```bash
lerobot-teleoperate \
--robot.type=unitree_g1 --robot.controller=SonicWholeBodyController \
--teleop.type=keyboard
```
Controls: `WASD` move · `Q`/`E` turn · `1``8` mode · `9`/`0` speed · `-`/`=` height ·
`R` replan · `Space` emergency-stop.
**PICO headset teleop** (live SMPL whole-body):
```bash
lerobot-teleoperate \
--robot.type=unitree_g1 --robot.controller=SonicWholeBodyController \
--teleop.type=pico_headset
```
This requires the XRoboToolkit stack — see below.
## PICO headset / XRoboToolkit install
Live full-body teleop needs the **XRoboToolkit** system (a PC Service on your
workstation + a PICO app on the headset) and its Python binding, `xrobotoolkit_sdk`.
The full hardware + software walkthrough lives in the SONIC repo:
[`docs/source/getting_started/vr_teleop_setup.md`](https://nvlabs.github.io/GR00T-WholeBodyControl/getting_started/vr_teleop_setup.html).
Summary:
1. **PC Service** (workstation) — install and run it before connecting the headset.
- Ubuntu 22.04 / 24.04 (x86_64): prebuilt `.deb` from the
[XRoboToolkit-PC-Service releases](https://github.com/XR-Robotics/XRoboToolkit-PC-Service/releases).
- Jetson (aarch64): the arm64 `.deb`.
- Windows (x64): the Windows PC Service build.
2. **PICO app** — install `XRoboToolkit-PICO-*.apk` on the headset (see the guide),
enable Developer Mode, pair/calibrate the ankle motion trackers.
3. **`xrobotoolkit_sdk`** — a pybind11/CMake build (not a pip package), from
[`XRoboToolkit-PC-Service-Pybind`](https://github.com/XR-Robotics/XRoboToolkit-PC-Service-Pybind):
- Linux x86_64: `pip install pybind11 cmake` then `bash setup_ubuntu.sh` (or the
SONIC repo's `install_scripts/install_pico.sh`, which builds everything into a
`.venv_teleop`).
- Jetson aarch64: `bash setup_orin.sh` (builds `libPXREARobotSDK.so` from source).
- Windows x64: `pip install pybind11` then `setup_windows.bat` (needs git + an
MSVC/CMake toolchain; uses the prebuilt `PXREARobotSDK.dll`/`.lib`).
4. Connect PICO and workstation to the **same Wi-Fi**, open the XRoboToolkit app, enter
the PC IP, and enable Head/Controller/Full-body/Send.
### Platform support
| Platform | Live headset teleop | Notes |
|---|---|---|
| Linux x86_64 | ✅ | Guided `install_pico.sh` (SONIC repo) |
| Linux aarch64 (Jetson Orin) | ✅ | `setup_orin.sh` builds the native lib |
| Windows x64 | ✅ (manual) | `setup_windows.bat`; no one-shot env script |
| macOS | ❌ | No PC Service / SDK build for Darwin |
### No hardware required (any platform, incl. macOS/Windows)
The SMPL pipeline can be exercised without a headset or the SDK — the publisher emits
`rt/smpl` frames that the controller consumes exactly as it would from the headset:
```bash
# synthetic motion
python -m lerobot.teleoperators.pico_headset.pico_publisher --fake
# replay a canned SMPL clip
python -m lerobot.teleoperators.pico_headset.pico_publisher --motion-file <clip>.npz
```
## Notes
- SMPL **root motion** into the mode-2 anchor is opt-in (`SonicWholeBodyController(enable_smpl_root=True)`);
it is off by default because an unsmoothed 30 Hz→50 Hz root trajectory can spike the
base acceleration.
- Direct `rt/smpl` subscription without the pico teleoperator is available via
`SonicWholeBodyController(enable_smpl_stream=True, smpl_host=..., smpl_port=...)`.
@@ -1153,6 +1153,22 @@ class PlannerController(StandingEncoderDecoder):
obs[1642 + 6 * f : 1642 + 6 * (f + 1)] = anchor obs[1642 + 6 * f : 1642 + 6 * (f + 1)] = anchor
obs[1702 + 6 * f : 1702 + 6 * (f + 1)] = wrist obs[1702 + 6 * f : 1702 + 6 * (f + 1)] = wrist
return obs return obs
if self.encode_mode == 1:
# 3-point VR teleop: the upper body tracks the VR wrist/neck targets
# while the planner reference supplies the lower body + anchor. Lower
# body is per-frame (step 5) like mode 0; the VR targets are current.
rf = min(self.ref_cursor, self.motion_timesteps - 1)
obs[595:601] = self._anchor_6d(self.h_quat[0], self.motion_body_quats[rf].astype(np.float32))
for f in range(10):
tf = min(
self.ref_cursor + f * 5 if self.playing else self.ref_cursor,
self.motion_timesteps - 1,
)
ref_lower = self.motion_joint_positions[tf].astype(np.float32)[LOWER_BODY_IL]
obs[661 + 12 * f : 661 + 12 * (f + 1)] = ref_lower
obs[901:910] = self.vr_3point_local_target
obs[910:922] = self.vr_3point_local_orn_target
return obs
for f in range(10): for f in range(10):
tf = min( tf = min(
self.ref_cursor + f * 5 if self.playing else self.ref_cursor, self.motion_timesteps - 1 self.ref_cursor + f * 5 if self.playing else self.ref_cursor, self.motion_timesteps - 1
@@ -30,6 +30,10 @@ from lerobot.teleoperators.pico_headset.smpl_constants import (
ROOT_ACTION_PREFIX, ROOT_ACTION_PREFIX,
SMPL_ACTION_PREFIX, SMPL_ACTION_PREFIX,
SMPL_OBS_DIM as SMPL_ACTION_DIM, SMPL_OBS_DIM as SMPL_ACTION_DIM,
VR3_ORN_DIM,
VR3_ORN_PREFIX,
VR3_POS_DIM,
VR3_POS_PREFIX,
) )
from lerobot.utils.import_utils import _onnxruntime_available, require_package from lerobot.utils.import_utils import _onnxruntime_available, require_package
@@ -96,6 +100,28 @@ def _extract_root_from_action(action: dict | None) -> np.ndarray | None:
return q / n return q / n
def _extract_vr3_from_action(action: dict | None) -> tuple[np.ndarray, np.ndarray] | None:
"""Reassemble the 3-point VR targets from ``vr3_pos.{i}`` / ``vr3_orn.{i}`` keys.
Returns ``(pos (9,), orn (12,))`` for the [l-wrist, r-wrist, neck] keypoints, or
None when no VR3 reference was sent this tick. Presence of ``vr3_pos.0`` is the
sentinel that a full 3-point frame is available (mirrors the SMPL sentinel).
"""
if not action or f"{VR3_POS_PREFIX}0" not in action:
return None
pos = np.fromiter(
(float(action.get(f"{VR3_POS_PREFIX}{i}", 0.0)) for i in range(VR3_POS_DIM)),
dtype=np.float32,
count=VR3_POS_DIM,
)
orn = np.fromiter(
(float(action.get(f"{VR3_ORN_PREFIX}{i}", 0.0)) for i in range(VR3_ORN_DIM)),
dtype=np.float32,
count=VR3_ORN_DIM,
)
return pos, orn
class SonicRuntime: class SonicRuntime:
"""Shared SONIC control loop state (standalone demo + locomotion controller).""" """Shared SONIC control loop state (standalone demo + locomotion controller)."""
@@ -240,8 +266,20 @@ class SonicWholeBodyController:
self.controller.reinit_heading = True self.controller.reinit_heading = True
logger.info("SONIC: SMPL stream active -> whole-body tracking (mode 2)") logger.info("SONIC: SMPL stream active -> whole-body tracking (mode 2)")
def _enter_3point(self) -> None:
"""Switch into 3-point VR upper-body teleop (encode_mode 1).
The upper body tracks the VR wrist/neck targets while the lower body /
locomotion keeps running off the planner (joystick/keyboard-driven).
"""
self.controller.encode_mode = 1
self.controller.playing = True
self.controller.reinit_heading = True
self.ms.needs_replan = True
logger.info("SONIC: 3-point VR active -> upper-body tracking + planner locomotion (mode 1)")
def _exit_wholebody(self) -> None: def _exit_wholebody(self) -> None:
"""Revert to locomotion/standing (encode_mode 0) after SMPL is lost. """Revert to locomotion/standing (encode_mode 0) after a teleop reference is lost.
Mirrors the 'M' toggle in sonic.py so the handoff is clean: the robot holds Mirrors the 'M' toggle in sonic.py so the handoff is clean: the robot holds
a standing reference and (if a joystick teleop is attached) can be driven. a standing reference and (if a joystick teleop is attached) can be driven.
@@ -250,7 +288,7 @@ class SonicWholeBodyController:
self.controller.playing = True self.controller.playing = True
self.controller.reinit_heading = True self.controller.reinit_heading = True
self.ms.needs_replan = True self.ms.needs_replan = True
logger.warning("SONIC: SMPL stream lost/stale -> reverting to locomotion (standing)") logger.warning("SONIC: teleop reference lost/stale -> reverting to locomotion (standing)")
def _process_keyboard(self, action: dict | None) -> None: def _process_keyboard(self, action: dict | None) -> None:
"""Translate a native KeyboardTeleop's held-key set into MovementState. """Translate a native KeyboardTeleop's held-key set into MovementState.
@@ -348,11 +386,14 @@ class SonicWholeBodyController:
# robot doesn't stay frozen tracking the last pose. # robot doesn't stay frozen tracking the last pose.
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)
if smpl is None and self._smpl_stream is not None: vr3 = _extract_vr3_from_action(action)
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 = (self._smpl_stream.vr3_pos, self._smpl_stream.vr3_orn)
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.
@@ -366,8 +407,18 @@ class SonicWholeBodyController:
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) return self._runtime.tick(obs, debug=False, use_joystick=False)
# No (or stale) SMPL: fall back to locomotion so the robot stays balanced. if vr3 is not None:
if self.controller.encode_mode == 2: # 3-point VR teleop: upper body tracks the wrist/neck targets; the lower
# body / locomotion keeps running off the planner, so the joystick (and
# keyboard) still steer walking/turning underneath.
if self.controller.encode_mode != 1:
self._enter_3point()
self.controller.vr_3point_local_target = vr3[0]
self.controller.vr_3point_local_orn_target = vr3[1]
return self._runtime.tick(obs, debug=False, use_joystick=True)
# 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.controller.smpl_root_quat = None
self._exit_wholebody() self._exit_wholebody()
return self._runtime.tick(obs, debug=False) return self._runtime.tick(obs, debug=False)
@@ -0,0 +1,20 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .config_g1_sonic_slider import G1SonicSliderTeleopConfig
from .g1_sonic_slider import G1SonicSliderTeleop
__all__ = ["G1SonicSliderTeleop", "G1SonicSliderTeleopConfig"]
@@ -0,0 +1,35 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from dataclasses import dataclass
from ..config import TeleoperatorConfig
@TeleoperatorConfig.register_subclass("g1_sonic_slider")
@dataclass
class G1SonicSliderTeleopConfig(TeleoperatorConfig):
"""Pygame sliders for 29-DOF G1 poses (SONIC encoder mode 0 reference)."""
window_width: int = 780
window_height: int = 720
slider_width: int = 200
row_height: int = 22
scroll_step: int = 40
foot_panel_width: int = 248
use_leg_ik: bool = True
foot_xyz_margin: tuple[float, float, float] = (0.22, 0.18, 0.18)
"""Per-axis slider half-range (m) around standing foot FK position in pelvis frame."""
@@ -0,0 +1,458 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Pygame SONIC test UI: foot xyz (leg IK) + waist/arm joint sliders."""
from __future__ import annotations
import logging
import multiprocessing as mp
import queue
from functools import cached_property
import numpy as np
from lerobot.robots.unitree_g1.controllers.sonic_pipeline import DEFAULT_ANGLES
from lerobot.utils.import_utils import require_package
from ..teleoperator import Teleoperator
from .config_g1_sonic_slider import G1SonicSliderTeleopConfig
from .joint_limits import JOINT_HI, JOINT_LO, JOINT_NAMES
logger = logging.getLogger(__name__)
NUM_JOINTS = 29
LEG_JOINT_COUNT = 12
UPPER_BODY_INDICES = list(range(LEG_JOINT_COUNT, NUM_JOINTS))
NUM_FOOT_SLIDERS = 6
FOOT_LABELS = ("L foot X", "L foot Y", "L foot Z", "R foot X", "R foot Y", "R foot Z")
# Pelvis-frame standing foot centers (m) if Pinocchio FK is unavailable at startup.
_FALLBACK_LEFT_FOOT = np.array([0.02, 0.12, -0.76], dtype=np.float32)
_FALLBACK_RIGHT_FOOT = np.array([0.02, -0.12, -0.76], dtype=np.float32)
HEADER_H = 56
LABEL_W = 148
MARGIN = 10
KNOB_W = 10
def _leg_ik_process(target_q: mp.Queue, result_q: mp.Queue, stop_evt) -> None:
"""Child process: build the leg IK once, then solve for the latest foot target.
The IPOPT/CasADi solve holds the GIL, so it must run in a separate *process*
(not a thread) to keep the teleop UI loop responsive.
"""
import numpy as _np
from lerobot.robots.unitree_g1.controllers.sonic_pipeline import DEFAULT_ANGLES as _DEFAULTS
from lerobot.robots.unitree_g1.g1_kinematics import G1_29_LegIK
try:
ik = G1_29_LegIK()
q_legs = _DEFAULTS[:LEG_JOINT_COUNT].astype(_np.float64)
ik.cache_default_orientation(q_legs)
left_pos, right_pos = ik.foot_positions(q_legs)
except Exception as e: # noqa: BLE001
result_q.put(("error", str(e)))
return
current = q_legs.copy()
result_q.put(("ready", _np.concatenate([left_pos, right_pos]).astype(_np.float64)))
while not stop_evt.is_set():
try:
target = target_q.get(timeout=0.1)
except queue.Empty:
continue
if target is None:
break
# Drain to the most recent target so we never solve stale slider positions.
while True:
try:
newer = target_q.get_nowait()
except queue.Empty:
break
if newer is None:
target = None
break
target = newer
if target is None:
break
target = _np.asarray(target, dtype=_np.float64)
# Fast damped-least-squares IK: sub-ms per step, warm-started from the last
# solution so the legs track the sliders in real time.
leg_q = ik.solve_ik_dls(target[:3], target[3:], current_leg_q_g1=current)
current = _np.asarray(leg_q, dtype=_np.float64)
result_q.put(("q", current.copy()))
class G1SonicSliderTeleop(Teleoperator):
"""Foot xyz + waist/arm sliders feeding SONIC encoder mode 0."""
config_class = G1SonicSliderTeleopConfig
name = "g1_sonic_slider"
def __init__(self, config: G1SonicSliderTeleopConfig):
super().__init__(config)
self.config = config
self._values = DEFAULT_ANGLES.astype(np.float32).copy()
self._foot_xyz = np.zeros(6, dtype=np.float32)
self._foot_lo = np.full(6, -0.5, dtype=np.float32)
self._foot_hi = np.full(6, 0.5, dtype=np.float32)
self._scroll_y = 0
self._drag_joint: int | None = None
self._drag_foot: int | None = None
self._connected = False
self._pygame = None
self._screen = None
self._font = None
self._small_font = None
self._clock = None
# Leg IK runs in a separate process: the IPOPT/CasADi solve holds the GIL,
# so a thread would still stall the UI loop. We publish the latest foot
# target and read back the newest solution over queues, never blocking.
self._ik_proc: mp.process.BaseProcess | None = None
self._ik_target_q: mp.Queue | None = None
self._ik_result_q: mp.Queue | None = None
self._ik_stop_evt = None
self._ik_ready = False
self._standing_foot_xyz: np.ndarray | None = None
self._leg_ik_ok = False
self._leg_ik_error: str | None = None
self._foot_divider_x = config.foot_panel_width
@cached_property
def action_features(self) -> dict[str, type]:
return {f"{name}.q": float for name in JOINT_NAMES}
@cached_property
def feedback_features(self) -> dict[str, type]:
return {}
@property
def is_connected(self) -> bool:
return self._connected
@property
def is_calibrated(self) -> bool:
return True
def _set_foot_limits_from_positions(self, left_pos: np.ndarray, right_pos: np.ndarray) -> None:
"""Slider range = FK foot position at standing pose ± foot_xyz_margin (meters, pelvis frame)."""
margin = np.array(self.config.foot_xyz_margin, dtype=np.float32)
for i, pos in enumerate((left_pos, right_pos)):
base = i * 3
self._foot_xyz[base : base + 3] = pos.astype(np.float32)
self._foot_lo[base : base + 3] = pos.astype(np.float32) - margin
self._foot_hi[base : base + 3] = pos.astype(np.float32) + margin
def _set_fallback_foot_limits(self) -> None:
self._set_foot_limits_from_positions(_FALLBACK_LEFT_FOOT, _FALLBACK_RIGHT_FOOT)
def _init_leg_ik(self) -> None:
if not self.config.use_leg_ik:
return
# Fallback limits until the solver process reports standing FK foot positions.
self._set_fallback_foot_limits()
try:
# Metadata name for the Pinocchio distribution is "pin" (conda-forge/PyPI),
# not "pinocchio", which is only the importable module name.
require_package("pin", extra="unitree_g1", import_name="pinocchio")
require_package("casadi", extra="unitree_g1", import_name="casadi")
except Exception as e:
self._leg_ik_ok = False
self._leg_ik_error = str(e)
logger.warning("Leg IK unavailable (%s); foot sliders shown but legs use joint values", e)
return
# Use "spawn": the parent already holds CUDA/pygame/threads and forking that
# state into the solver is unsafe.
ctx = mp.get_context("spawn")
self._ik_target_q = ctx.Queue(maxsize=2)
self._ik_result_q = ctx.Queue()
self._ik_stop_evt = ctx.Event()
self._ik_proc = ctx.Process(
target=_leg_ik_process,
args=(self._ik_target_q, self._ik_result_q, self._ik_stop_evt),
name="g1-leg-ik",
daemon=True,
)
self._ik_proc.start()
self._leg_ik_ok = True
self._leg_ik_error = None
logger.info("Leg IK solver process starting (foot limits update once standing FK is ready)...")
def _publish_ik_target(self) -> None:
if self._ik_target_q is None:
return
# Keep only the newest target; drop if the child is momentarily behind.
try:
self._ik_target_q.put_nowait(self._foot_xyz.copy())
except queue.Full:
pass
def _pump_ik_results(self) -> None:
if self._ik_result_q is None:
return
while True:
try:
kind, payload = self._ik_result_q.get_nowait()
except queue.Empty:
break
if kind == "q":
self._values[:LEG_JOINT_COUNT] = np.asarray(payload, dtype=np.float32)
elif kind == "ready":
payload = np.asarray(payload, dtype=np.float32)
self._standing_foot_xyz = payload.copy()
self._set_foot_limits_from_positions(payload[:3], payload[3:])
self._ik_ready = True
logger.info(
"Leg IK ready — foot slider limits from standing FK ± %s m (pelvis frame)",
self.config.foot_xyz_margin,
)
elif kind == "error":
self._leg_ik_ok = False
self._leg_ik_error = str(payload)
logger.warning(
"Leg IK unavailable (%s); foot sliders shown but legs use joint values", payload
)
def _stop_ik_process(self) -> None:
if self._ik_proc is None:
return
try:
if self._ik_stop_evt is not None:
self._ik_stop_evt.set()
if self._ik_target_q is not None:
try:
self._ik_target_q.put_nowait(None)
except queue.Full:
pass
self._ik_proc.join(timeout=2.0)
if self._ik_proc.is_alive():
self._ik_proc.terminate()
finally:
self._ik_proc = None
def connect(self, calibrate: bool = True) -> None:
require_package("pygame", extra="pygame-dep", import_name="pygame")
import pygame
self._foot_divider_x = self.config.foot_panel_width
self._pygame = pygame
pygame.init()
pygame.display.set_caption("G1 SONIC — foot IK + upper-body sliders")
self._screen = pygame.display.set_mode((self.config.window_width, self.config.window_height))
self._font = pygame.font.SysFont("dejavusans", 15)
self._small_font = pygame.font.SysFont("dejavusans", 12)
self._clock = pygame.time.Clock()
self._init_leg_ik()
self._connected = True
logger.info("G1 sonic slider UI ready (R=reset, wheel=scroll, Esc=quit)")
def configure(self) -> None:
pass
def calibrate(self) -> None:
pass
def _reset_pose(self) -> None:
self._values[:] = DEFAULT_ANGLES
if self._leg_ik_ok and self._standing_foot_xyz is not None:
self._foot_xyz[:] = self._standing_foot_xyz
self._publish_ik_target()
def _foot_row_rect(self, foot_idx: int) -> tuple[int, int, int, int]:
y = HEADER_H + foot_idx * self.config.row_height
track_x = MARGIN + 88
track_w = self.config.foot_panel_width - track_x - MARGIN
return track_x, y, track_w, self.config.row_height
def _joint_row_rect(self, ui_idx: int) -> tuple[int, int, int, int]:
joint_idx = UPPER_BODY_INDICES[ui_idx] if self._leg_ik_ok else ui_idx
row = ui_idx
y = HEADER_H + row * self.config.row_height - self._scroll_y
track_x = self._foot_divider_x + MARGIN + LABEL_W
track_w = self.config.slider_width
return track_x, y, track_w, self.config.row_height, joint_idx
def _value_from_track(self, lo: float, hi: float, mouse_x: int, track_x: int, track_w: int) -> float:
t = (mouse_x - track_x) / max(track_w, 1)
t = float(np.clip(t, 0.0, 1.0))
return lo + t * (hi - lo)
def _knob_x(self, val: float, lo: float, hi: float, track_x: int, track_w: int) -> int:
span = hi - lo if hi > lo else 1.0
t = (val - lo) / span
return int(track_x + t * track_w)
def _handle_events(self) -> bool:
pygame = self._pygame
num_joint_rows = len(UPPER_BODY_INDICES) if self._leg_ik_ok else NUM_JOINTS
max_scroll = max(0, num_joint_rows * self.config.row_height - self.config.window_height + HEADER_H)
for event in pygame.event.get():
if event.type == pygame.QUIT:
return False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
return False
if event.key == pygame.K_r:
self._reset_pose()
if event.type == pygame.MOUSEWHEEL:
self._scroll_y = int(np.clip(self._scroll_y - event.y * self.config.scroll_step, 0, max_scroll))
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mx, my = event.pos
if self.config.use_leg_ik and mx < self._foot_divider_x:
for fi in range(NUM_FOOT_SLIDERS):
rx, ry, rw, rh = self._foot_row_rect(fi)
if ry + 4 <= my <= ry + rh - 4 and rx <= mx <= rx + rw:
self._drag_foot = fi
self._foot_xyz[fi] = self._value_from_track(
float(self._foot_lo[fi]),
float(self._foot_hi[fi]),
mx,
rx,
rw,
)
break
else:
for ui in range(num_joint_rows):
rx, ry, rw, rh, ji = self._joint_row_rect(ui)
if ry + rh < HEADER_H or ry > self.config.window_height:
continue
if ry + 4 <= my <= ry + rh - 4 and rx <= mx <= rx + rw:
self._drag_joint = ji
self._values[ji] = self._value_from_track(
float(JOINT_LO[ji]), float(JOINT_HI[ji]), mx, rx, rw
)
break
if event.type == pygame.MOUSEBUTTONUP and event.button == 1:
self._drag_foot = None
self._drag_joint = None
if event.type == pygame.MOUSEMOTION:
mx, my = event.pos
if self._drag_foot is not None:
rx, _, rw, _ = self._foot_row_rect(self._drag_foot)
self._foot_xyz[self._drag_foot] = self._value_from_track(
float(self._foot_lo[self._drag_foot]),
float(self._foot_hi[self._drag_foot]),
mx,
rx,
rw,
)
elif self._drag_joint is not None:
for ui in range(num_joint_rows):
rx, ry, rw, rh, ji = self._joint_row_rect(ui)
if ji == self._drag_joint:
self._values[ji] = self._value_from_track(
float(JOINT_LO[ji]), float(JOINT_HI[ji]), mx, rx, rw
)
break
return True
def _draw_foot_panel(self) -> None:
pygame = self._pygame
screen = self._screen
panel_title = self._small_font.render("Foot IK (pelvis)", True, (180, 200, 255))
screen.blit(panel_title, (MARGIN, 38))
if self._leg_ik_error:
err = self._leg_ik_error if len(self._leg_ik_error) < 42 else self._leg_ik_error[:39] + "..."
screen.blit(self._small_font.render(f"IK off: {err}", True, (255, 120, 120)), (MARGIN, 50))
pygame.draw.line(
screen,
(60, 60, 70),
(self._foot_divider_x - 1, HEADER_H - 4),
(self._foot_divider_x - 1, self.config.window_height),
1,
)
for fi, label in enumerate(FOOT_LABELS):
rx, ry, rw, rh = self._foot_row_rect(fi)
lo, hi = float(self._foot_lo[fi]), float(self._foot_hi[fi])
txt = self._small_font.render(label, True, (190, 210, 230))
screen.blit(txt, (MARGIN, ry + 4))
track_y = ry + rh // 2 - 2
pygame.draw.rect(screen, (45, 55, 70), (rx, track_y, rw, 4), border_radius=2)
kx = self._knob_x(float(self._foot_xyz[fi]), lo, hi, rx, rw)
pygame.draw.rect(screen, (70, 140, 220), (rx, track_y, max(0, kx - rx), 4), border_radius=2)
pygame.draw.rect(screen, (200, 225, 255), (kx - KNOB_W // 2, track_y - 5, KNOB_W, 14), border_radius=3)
val_txt = self._small_font.render(f"{self._foot_xyz[fi]:+.3f}", True, (150, 200, 220))
screen.blit(val_txt, (rx + rw + 4, ry + 4))
def _draw_joint_panel(self) -> None:
pygame = self._pygame
screen = self._screen
num_joint_rows = len(UPPER_BODY_INDICES) if self._leg_ik_ok else NUM_JOINTS
joint_title = "Waist + arms" if self._leg_ik_ok else "All joints"
screen.blit(
self._small_font.render(joint_title, True, (180, 180, 190)),
(self._foot_divider_x + MARGIN, 38),
)
for ui in range(num_joint_rows):
rx, ry, rw, rh, ji = self._joint_row_rect(ui)
if ry + rh < HEADER_H or ry > self.config.window_height:
continue
short = JOINT_NAMES[ji].removeprefix("k")
label = self._small_font.render(f"{ji:02d} {short}", True, (200, 200, 210))
screen.blit(label, (self._foot_divider_x + MARGIN, ry + 4))
track_y = ry + rh // 2 - 2
lo, hi = float(JOINT_LO[ji]), float(JOINT_HI[ji])
pygame.draw.rect(screen, (55, 55, 65), (rx, track_y, rw, 4), border_radius=2)
kx = self._knob_x(float(self._values[ji]), lo, hi, rx, rw)
pygame.draw.rect(screen, (80, 160, 255), (rx, track_y, max(0, kx - rx), 4), border_radius=2)
pygame.draw.rect(screen, (220, 235, 255), (kx - KNOB_W // 2, track_y - 5, KNOB_W, 14), border_radius=3)
val_txt = self._small_font.render(f"{self._values[ji]:+.3f}", True, (170, 220, 170))
screen.blit(val_txt, (rx + rw + 8, ry + 4))
def _draw(self) -> None:
pygame = self._pygame
screen = self._screen
screen.fill((28, 28, 32))
title = self._font.render("G1 reference → SONIC encoder mode 0", True, (230, 230, 235))
hint = self._small_font.render("Foot xyz (left) · waist/arms (right) · R reset · Esc quit", True, (150, 150, 160))
screen.blit(title, (MARGIN, 10))
screen.blit(hint, (MARGIN, 28))
if self.config.use_leg_ik:
self._draw_foot_panel()
self._draw_joint_panel()
pygame.display.flip()
def get_action(self) -> dict[str, float]:
if not self._connected:
return {f"{name}.q": float(self._values[i]) for i, name in enumerate(JOINT_NAMES)}
if not self._handle_events():
raise KeyboardInterrupt("G1 sonic slider window closed")
if self._leg_ik_ok:
# Read the newest solution from the solver process and hand it the latest
# foot target — never block the teleop loop on the IPOPT solve.
self._pump_ik_results()
self._publish_ik_target()
self._draw()
self._clock.tick(60)
return {f"{name}.q": float(self._values[i]) for i, name in enumerate(JOINT_NAMES)}
def send_feedback(self, feedback: dict) -> None:
del feedback
def disconnect(self) -> None:
self._stop_ik_process()
if self._pygame is not None:
self._pygame.quit()
self._connected = False
self._screen = None
@@ -0,0 +1,64 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""G1 29-DOF joint limits (rad) in MuJoCo / G1_29_JointIndex order — from g1_29dof.xml.
SONIC encoder mode 0 expects Isaac Lab order; the whole-body controller remaps on ingest.
"""
import numpy as np
from lerobot.robots.unitree_g1.g1_utils import G1_29_JointIndex
# (low, high) per joint index 0..28
_G1_LIMITS = np.array(
[
(-2.5307, 2.8798), # kLeftHipPitch
(-0.5236, 2.9671), # kLeftHipRoll
(-2.7576, 2.7576), # kLeftHipYaw
(-0.087267, 2.8798), # kLeftKnee
(-0.87267, 0.5236), # kLeftAnklePitch
(-0.2618, 0.2618), # kLeftAnkleRoll
(-2.5307, 2.8798), # kRightHipPitch
(-2.9671, 0.5236), # kRightHipRoll
(-2.7576, 2.7576), # kRightHipYaw
(-0.087267, 2.8798), # kRightKnee
(-0.87267, 0.5236), # kRightAnklePitch
(-0.2618, 0.2618), # kRightAnkleRoll
(-2.618, 2.618), # kWaistYaw
(-0.52, 0.52), # kWaistRoll
(-0.52, 0.52), # kWaistPitch
(-3.0892, 2.6704), # kLeftShoulderPitch
(-1.5882, 2.2515), # kLeftShoulderRoll
(-2.618, 2.618), # kLeftShoulderYaw
(-1.0472, 2.0944), # kLeftElbow
(-1.97222, 1.97222), # kLeftWristRoll
(-1.61443, 1.61443), # kLeftWristPitch
(-1.61443, 1.61443), # kLeftWristYaw
(-3.0892, 2.6704), # kRightShoulderPitch
(-2.2515, 1.5882), # kRightShoulderRoll
(-2.618, 2.618), # kRightShoulderYaw
(-1.0472, 2.0944), # kRightElbow
(-1.97222, 1.97222), # kRightWristRoll
(-1.61443, 1.61443), # kRightWristPitch
(-1.61443, 1.61443), # kRightWristYaw
],
dtype=np.float32,
)
JOINT_NAMES = [m.name for m in G1_29_JointIndex]
JOINT_LO = _G1_LIMITS[:, 0]
JOINT_HI = _G1_LIMITS[:, 1]
@@ -35,3 +35,7 @@ class PicoHeadsetConfig(TeleoperatorConfig):
"""Port of the rt/smpl publisher.""" """Port of the rt/smpl publisher."""
stale_after_s: float = 0.5 stale_after_s: float = 0.5
"""Warn if no fresh headset frame arrives within this many seconds.""" """Warn if no fresh headset frame arrives within this many seconds."""
mode: str = "smpl"
"""Teleop reference to emit: ``"smpl"`` for whole-body imitation (SONIC
encode_mode 2) or ``"vr3"`` for sparse 3-point upper-body teleop (encode_mode 1,
lower body driven by the joystick/keyboard planner)."""
@@ -23,7 +23,16 @@ 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 ROOT_ACTION_DIM, ROOT_ACTION_PREFIX, SMPL_ACTION_PREFIX, SMPL_OBS_DIM from .smpl_constants import (
ROOT_ACTION_DIM,
ROOT_ACTION_PREFIX,
SMPL_ACTION_PREFIX,
SMPL_OBS_DIM,
VR3_ORN_DIM,
VR3_ORN_PREFIX,
VR3_POS_DIM,
VR3_POS_PREFIX,
)
from .smpl_stream import SmplStream from .smpl_stream import SmplStream
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,6 +57,10 @@ class PicoHeadset(Teleoperator):
@property @property
def action_features(self) -> dict: def action_features(self) -> dict:
if self.config.mode == "vr3":
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)})
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)})
return feats return feats
@@ -89,12 +102,21 @@ class PicoHeadset(Teleoperator):
if self._stream is None: if self._stream is None:
raise RuntimeError(f"{self} is not connected") raise RuntimeError(f"{self} is not connected")
window = self._stream.step() window = self._stream.step()
# Emit SMPL only while the headset is actively streaming: hold back before # Emit a reference only while the headset is actively streaming: hold back
# the first frame (so the controller doesn't track an all-zero collapsed # before the first frame (so the controller doesn't track an all-zero
# pose) and once the stream goes stale (so the controller falls back to a # collapsed pose) and once the stream goes stale (so the controller falls
# safe standing/locomotion mode instead of freezing on the last pose). # back to a safe standing/locomotion mode instead of freezing on the last
# pose).
if not self._stream.has_data or self._stream.is_stale: if not self._stream.has_data or self._stream.is_stale:
return {} return {}
if self.config.mode == "vr3":
# Sparse 3-point upper-body teleop (encode_mode 1). Needs the producer to
# be sending vr3_* fields; otherwise emit nothing and stay in locomotion.
if not self._stream.has_vr3:
return {}
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)})
return action
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
@@ -50,6 +50,7 @@ import zmq
from lerobot.teleoperators.pico_headset.smpl_fk import ( from lerobot.teleoperators.pico_headset.smpl_fk import (
SmplForwardKinematics, SmplForwardKinematics,
canonicalize_smpl_joints, canonicalize_smpl_joints,
compute_3point,
root_quats_from_aa, root_quats_from_aa,
) )
@@ -63,8 +64,15 @@ def pack_message(
stamp_ns: int, stamp_ns: int,
root_quat: np.ndarray | None = None, root_quat: np.ndarray | None = None,
root_transl: np.ndarray | None = None, root_transl: np.ndarray | None = None,
vr3_pos: np.ndarray | None = None,
vr3_orn: 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).
Carries the SMPL whole-body window (``smpl_joints_local`` + ``root_*``) and,
when available, the sparse 3-point VR targets (``vr3_pos`` (9,), ``vr3_orn`` (12,))
so a single stream can drive either SONIC ``encode_mode`` 1 or 2.
"""
data = { data = {
"smpl_joints_local": np.asarray(smpl_joints_local, np.float32).reshape(-1).tolist(), "smpl_joints_local": np.asarray(smpl_joints_local, np.float32).reshape(-1).tolist(),
"frame_index": int(frame_index), "frame_index": int(frame_index),
@@ -74,6 +82,10 @@ def pack_message(
data["root_quat"] = np.asarray(root_quat, np.float32).reshape(-1).tolist() data["root_quat"] = np.asarray(root_quat, np.float32).reshape(-1).tolist()
if root_transl is not None: if root_transl is not None:
data["root_transl"] = np.asarray(root_transl, np.float32).reshape(-1).tolist() data["root_transl"] = np.asarray(root_transl, np.float32).reshape(-1).tolist()
if vr3_pos is not None:
data["vr3_pos"] = np.asarray(vr3_pos, np.float32).reshape(-1).tolist()
if vr3_orn is not None:
data["vr3_orn"] = np.asarray(vr3_orn, 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")
@@ -157,6 +169,7 @@ def main() -> None:
try: try:
while True: while True:
loop_start = time.time() loop_start = time.time()
vr3_pos = vr3_orn = 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:
@@ -181,9 +194,20 @@ def main() -> None:
joints = out["smpl_joints_local"] joints = out["smpl_joints_local"]
root_quat = out["root_quat"] root_quat = out["root_quat"]
root_transl = out["root_transl"] root_transl = out["root_transl"]
# Also emit the sparse 3-point VR targets so the same stream can
# drive encode_mode 1 (3-point teleop) without a second producer.
vr3_pos, vr3_orn = compute_3point(body_poses)
sock.send( sock.send(
pack_message(joints, frame_index, stamp_ns, root_quat=root_quat, root_transl=root_transl) pack_message(
joints,
frame_index,
stamp_ns,
root_quat=root_quat,
root_transl=root_transl,
vr3_pos=vr3_pos,
vr3_orn=vr3_orn,
)
) )
frame_index += 1 frame_index += 1
if frame_index % int(max(1, args.fps)) == 0: if frame_index % int(max(1, args.fps)) == 0:
@@ -0,0 +1,51 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Single source of truth for the SONIC SMPL whole-body action protocol.
These constants define the wire format shared between the PICO teleoperator
(producer, ``pico_headset.py``), the offline motion->dataset converter
(``smpl_to_dataset.py``), the live stream (``smpl_stream.py``), and the Unitree G1
``SonicWholeBodyController`` (consumer). Keeping them here avoids the producer and
consumer silently drifting apart.
"""
# SMPL encoder window geometry (matches ``smpl_joints_10frame_step1``).
WINDOW = 10 # frames per encoder window
N_JOINTS = 24 # SMPL joints per frame
JOINT_DIM = 3 # xyz per joint
SMPL_OBS_DIM = WINDOW * N_JOINTS * JOINT_DIM # 720
# Flat action-dict keys carrying the reference through the standard lerobot action
# pipeline as scalar floats: ``smpl.0 .. smpl.719`` and ``root.0 .. root.3``.
SMPL_ACTION_PREFIX = "smpl."
ROOT_ACTION_PREFIX = "root."
ROOT_ACTION_DIM = 4 # per-frame SMPL root orientation (wxyz)
# Full per-frame action vector: 720 joint window + 4 root quaternion = 724.
ACTION_DIM = SMPL_OBS_DIM + ROOT_ACTION_DIM
# ── 3-point VR teleop protocol (SONIC encode_mode 1) ─────────────────────────
# An alternative, sparse upper-body reference: 3 root-relative keypoints
# (left wrist, right wrist, neck), each a position + orientation. The lower body /
# locomotion is driven by the planner (joystick/keyboard), not by these targets.
VR3_N_POINTS = 3 # left wrist, right wrist, neck
VR3_POS_DIM = VR3_N_POINTS * 3 # 9 (3 x xyz)
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``.
VR3_POS_PREFIX = "vr3_pos."
VR3_ORN_PREFIX = "vr3_orn."
@@ -35,6 +35,8 @@ from pathlib import Path
import numpy as np import numpy as np
from scipy.spatial.transform import Rotation as R # noqa: N817 from scipy.spatial.transform import Rotation as R # noqa: N817
from .smpl_constants import VR3_N_POINTS, VR3_ORN_DIM, VR3_POS_DIM
# 24-joint parent tree used by the headset body-pose stream (SMPL-X body subset). # 24-joint parent tree used by the headset body-pose stream (SMPL-X body subset).
# Matches PoseStreamer.parent_indices in gear_sonic's pico_manager_thread_server.py. # Matches PoseStreamer.parent_indices in gear_sonic's pico_manager_thread_server.py.
BODY24_PARENTS = np.array( BODY24_PARENTS = np.array(
@@ -210,6 +212,70 @@ def root_quats_from_aa(root_aa: np.ndarray) -> np.ndarray:
return root.as_quat(scalar_first=True).astype(np.float32) # wxyz return root.as_quat(scalar_first=True).astype(np.float32) # wxyz
# ── 3-point VR teleop keypoints (SONIC encode_mode 1) ────────────────────────
# SMPL body-joint indices for the 3 tracked keypoints, plus the root/pelvis (0)
# used as the reference frame. Mirrors gear_sonic ``_process_3pt_pose``: neck
# (joint 12) is used rather than head (15) — it is more rigidly coupled to the
# torso and less noisy than the free-looking head.
_VR3_JOINTS = (22, 23, 12) # left wrist, right wrist, neck
# Per-keypoint rotation offsets aligning each SMPL joint frame to the robot
# convention (root, left wrist, right wrist, neck), ported verbatim from
# gear_sonic ``pico_manager_thread_server.OFFSETS`` (extrinsic xyz euler, degrees).
_VR3_OFFSETS = [
R.from_euler("xyz", [0, 0, -90], degrees=True), # root
R.from_euler("xyz", [90, 0, 0], degrees=True), # left wrist
R.from_euler("xyz", [-90, 0, 180], degrees=True), # right wrist
R.from_euler("xyz", [0, 0, -90], degrees=True), # neck
]
# Unity (X-right, Y-up, Z-forward, left-handed) -> robot (X-forward, Y-left,
# Z-up, right-handed) axis remap: Unity [x, y, z] -> robot [-x, z, y].
_UNITY_TO_ROBOT = np.array([[-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]])
def compute_3point(body_poses_np: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Extract the SONIC 3-point VR targets from headset body poses.
Mirrors gear_sonic ``_process_3pt_pose``: transforms the tracked joints from the
Unity frame to the robot frame, applies the per-keypoint rotation offsets, then
expresses the left-wrist / right-wrist / neck keypoints relative to the root
(pelvis) frame. This is the ``encode_mode == 1`` counterpart to :func:`compute`.
Note: physical wrist/neck position offsets and the operator calibration done in
gear_sonic's ``ThreePointPose.apply_calibration`` are not applied here; the raw
tracked joint poses are used.
Args:
body_poses_np: (24, 7) rows of ``[x, y, z, qx, qy, qz, qw]`` (scalar-last),
in the Unity frame, as returned by ``xrt.get_body_joints_pose()``.
Returns:
(pos, orn):
- pos: (9,) float32, root-relative ``[x, y, z]`` for [l-wrist, r-wrist, neck]
- orn: (12,) float32, root-relative ``[w, x, y, z]`` for the same order
"""
body = np.asarray(body_poses_np, np.float64)
q = _UNITY_TO_ROBOT
# Root (index 0) + the 3 tracked keypoints, each transformed to the robot frame
# and rotation-offset-corrected.
positions = np.zeros((4, 3), np.float64)
rotations: list[R] = []
for out_i, j in enumerate((0, *_VR3_JOINTS)):
positions[out_i] = q @ body[j, :3]
rot = R.from_quat(body[j, 3:7]).as_matrix() # scalar-last input
rotations.append(R.from_matrix(q @ rot @ q.T) * _VR3_OFFSETS[out_i])
root_inv = rotations[0].inv()
root_pos = positions[0]
pos = np.zeros(VR3_POS_DIM, np.float32)
orn = np.zeros(VR3_ORN_DIM, np.float32)
for k in range(VR3_N_POINTS):
pos[k * 3 : k * 3 + 3] = root_inv.apply(positions[k + 1] - root_pos)
orn[k * 4 : k * 4 + 4] = (root_inv * rotations[k + 1]).as_quat(scalar_first=True) # wxyz
return pos, orn
class SmplForwardKinematics: class SmplForwardKinematics:
"""Rest-skeleton SMPL forward kinematics (no mesh, no torch).""" """Rest-skeleton SMPL forward kinematics (no mesh, no torch)."""
@@ -41,7 +41,7 @@ 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, WINDOW from .smpl_constants import JOINT_DIM, N_JOINTS, SMPL_OBS_DIM, VR3_ORN_DIM, VR3_POS_DIM, WINDOW
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -91,6 +91,10 @@ class SmplStream:
# Latest root/torso pose (updated every received frame). # Latest root/torso pose (updated every received frame).
self.root_quat = np.array([1.0, 0.0, 0.0, 0.0], np.float32) # (w, x, y, z) self.root_quat = np.array([1.0, 0.0, 0.0, 0.0], np.float32) # (w, x, y, z)
self.root_transl = np.zeros(3, np.float32) self.root_transl = np.zeros(3, np.float32)
# Latest sparse 3-point VR targets (encode_mode 1), if the producer sends them.
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._got_vr3 = False
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
@@ -112,6 +116,11 @@ class SmplStream:
"""True once at least one real frame has been received.""" """True once at least one real frame has been received."""
return self._got_first return self._got_first
@property
def has_vr3(self) -> bool:
"""True once the producer has sent at least one 3-point VR frame."""
return self._got_vr3
@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)."""
@@ -133,6 +142,7 @@ class SmplStream:
def reset(self): def reset(self):
self._buf.clear() self._buf.clear()
self._got_first = False self._got_first = False
self._got_vr3 = False
# -- core ---------------------------------------------------------------- # -- core ----------------------------------------------------------------
def _drain_latest(self) -> np.ndarray | None: def _drain_latest(self) -> np.ndarray | None:
@@ -156,6 +166,12 @@ 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:
@@ -0,0 +1,147 @@
#!/usr/bin/env python
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Convert SMPL ``.npz`` motion clips into a LeRobotDataset for SONIC replay.
Each dataset frame's ``action`` is the 720-dim ``smpl.*`` window that the
``pico_headset`` teleoperator emits and ``SonicWholeBodyController`` reassembles
into ``encode_mode == 2``. So the resulting dataset can be pushed straight
through ``lerobot-replay`` to drive SONIC whole-body tracking with no headset:
lerobot-replay \
--robot.type=unitree_g1 \
--robot.controller=SonicWholeBodyController \
--dataset.repo_id=<user>/<clip> --dataset.episode=0
The 10-frame window is built exactly like the live ``SmplStream`` (oldest->newest,
the first frame repeated to pre-fill), so replayed actions match a live session.
Usage:
# One clip -> one-episode dataset
python -m lerobot.teleoperators.pico_headset.smpl_to_dataset \
--motion-file examples/unitree_g1/motions/walk_forward.npz \
--repo-id me/sonic_walk_forward
# Every clip in a dir -> one episode each
python -m lerobot.teleoperators.pico_headset.smpl_to_dataset \
--motion-dir examples/unitree_g1/motions --repo-id me/sonic_motions
"""
from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
from lerobot.teleoperators.pico_headset.smpl_constants import (
ACTION_DIM,
JOINT_DIM,
N_JOINTS,
ROOT_ACTION_DIM as ROOT_DIM,
ROOT_ACTION_PREFIX,
SMPL_ACTION_PREFIX,
SMPL_OBS_DIM,
WINDOW,
)
from lerobot.teleoperators.pico_headset.smpl_fk import canonicalize_smpl_joints, root_quats_from_aa
def _load_canonical_joints(path: str) -> tuple[np.ndarray, np.ndarray, float]:
"""Load an SMPL clip -> (canonical (T,24,3) joints, (T,4) root wxyz, fps)."""
data = np.load(path)
joints = data["smpl_joints"].astype(np.float32)
if joints.ndim != 3 or joints.shape[1:] != (N_JOINTS, JOINT_DIM):
raise ValueError(f"{path}: expected smpl_joints (T, 24, 3), got {joints.shape}")
t = joints.shape[0]
if "pose_aa" in data.files:
root_aa = data["pose_aa"].astype(np.float32)[:, :3]
joints = canonicalize_smpl_joints(joints, root_aa)
root_quat = root_quats_from_aa(root_aa) # (T, 4) wxyz, matches live stream
else:
# No global orient available: identity root (anchor falls back to standing).
root_quat = np.tile(np.array([1.0, 0.0, 0.0, 0.0], np.float32), (t, 1))
fps = float(data["fps"]) if "fps" in data.files else 50.0
return joints, root_quat, fps
def _windows(joints: np.ndarray) -> np.ndarray:
"""(T, 24, 3) -> (T, 720): rolling 10-frame window, matching SmplStream.
Window t = frames [t-9 .. t], clamped to 0 at the start (first frame repeated).
"""
t = joints.shape[0]
idx = np.clip(np.arange(t)[:, None] + np.arange(-WINDOW + 1, 1)[None, :], 0, t - 1)
return joints[idx].reshape(t, -1).astype(np.float32)
def _action_features() -> dict:
names = [f"{SMPL_ACTION_PREFIX}{i}" for i in range(SMPL_OBS_DIM)]
names += [f"{ROOT_ACTION_PREFIX}{i}" for i in range(ROOT_DIM)]
return {"action": {"dtype": "float32", "shape": (ACTION_DIM,), "names": names}}
def main() -> None:
p = argparse.ArgumentParser(description=__doc__)
src = p.add_mutually_exclusive_group(required=True)
src.add_argument("--motion-file", type=str, help="Single SMPL .npz clip")
src.add_argument("--motion-dir", type=str, help="Directory of .npz clips (one episode each)")
p.add_argument("--repo-id", required=True, help="Dataset repo id, e.g. user/name")
p.add_argument("--root", type=str, default=None, help="Local dataset root (default HF cache)")
p.add_argument("--fps", type=int, default=None, help="Override fps (default: clip fps)")
p.add_argument("--task", type=str, default="sonic whole-body SMPL replay")
args = p.parse_args()
from lerobot.datasets.lerobot_dataset import LeRobotDataset
if args.motion_dir:
clips = sorted(str(pth) for pth in Path(args.motion_dir).glob("*.npz"))
if not clips:
raise SystemExit(f"No .npz clips found in {args.motion_dir}")
else:
clips = [args.motion_file]
first_joints, first_root, first_fps = _load_canonical_joints(clips[0])
fps = args.fps or int(round(first_fps))
dataset = LeRobotDataset.create(
repo_id=args.repo_id,
fps=fps,
features=_action_features(),
root=args.root,
robot_type="unitree_g1",
use_videos=False,
)
for clip_i, clip in enumerate(clips):
if clip_i == 0:
joints, root_quat = first_joints, first_root
else:
joints, root_quat, _ = _load_canonical_joints(clip)
windows = _windows(joints) # (T, 720)
# action = [720 joint window | 4 root wxyz] per frame -> (T, 724)
actions = np.concatenate([windows, root_quat.astype(np.float32)], axis=1)
for a in actions:
dataset.add_frame({"action": a, "task": args.task})
dataset.save_episode()
print(f"[smpl_to_dataset] episode {clip_i}: {Path(clip).name} ({actions.shape[0]} frames)")
dataset.finalize()
print(f"[smpl_to_dataset] wrote {len(clips)} episode(s) to {dataset.root}")
if __name__ == "__main__":
main()