feat(unitree_g1): episode reset, lazy replay decode, safe shutdown

- reset(): pause the background controller and, for full-body controllers,
  publish the default pose directly (new _controller_paused flag) so reset and
  the controller loop aren't both writing low commands.
- SONIC pipeline: add reset() to StandingEncoderDecoder and PlannerController
  (clear token/proprio history/heading, rewind motion buffer); SonicRuntime.reset()
  now calls controller.reset().
- sonic_whole_body: require the full dense 34-D command (no silent zero-fill of a
  partial action) and integrate yaw-rate (idx 33) into heading.
- controllers/__init__: import the controller classes referenced in __all__.
- unitree_g1: lazy replay-frame decode + small cache instead of decoding all
  frames up front; safer disconnect (longer controller-thread join + fail-safe
  that skips the graceful ramp if the thread won't stop).
- lint: ruff-format config_unitree_g1 hand_closed_pose; prettier README table.
This commit is contained in:
Martino Russi
2026-07-24 12:02:49 +02:00
parent 4209639f33
commit 57ea6f4106
6 changed files with 195 additions and 71 deletions
@@ -80,9 +80,7 @@ class UnitreeG1Config(RobotConfig):
publish_hands: bool = False publish_hands: bool = False
hand_open_grip_value: float = 1.0 hand_open_grip_value: float = 1.0
hand_closed_grip_value: float = 0.0 hand_closed_grip_value: float = 0.0
hand_closed_pose: list[float] = field( hand_closed_pose: list[float] = field(default_factory=lambda: [1.0, 0.9, 0.9, 1.3, 1.3, 1.3, 1.3])
default_factory=lambda: [1.0, 0.9, 0.9, 1.3, 1.3, 1.3, 1.3]
)
hand_kp: float = 1.5 hand_kp: float = 1.5
hand_kd: float = 0.1 hand_kd: float = 0.1
@@ -16,9 +16,13 @@
"""Unitree G1 locomotion controllers (Groot, Holosoma, SONIC).""" """Unitree G1 locomotion controllers (Groot, Holosoma, SONIC)."""
from .gr00t_locomotion import GrootLocomotionController
from .holosoma_locomotion import HolosomaLocomotionController
from .sonic_whole_body import SonicRuntime, SonicWholeBodyController
__all__ = [ __all__ = [
"GrootLocomotionController", "GrootLocomotionController",
"HolosomaLocomotionController", "HolosomaLocomotionController",
"SonicWholeBodyController",
"SonicRuntime", "SonicRuntime",
"SonicWholeBodyController",
] ]
@@ -128,7 +128,15 @@ DEBUG_PRINT_EVERY = 100 # ticks between debug prints
def _to_mujoco(a): def _to_mujoco(a):
"""Reorder a 29-vector from IsaacLab order into MuJoCo/deploy order.""" """Apply the ``MUJOCO_TO_ISAACLAB`` gather to a 29-vector (deploy-order reorder).
NOTE: this returns ``a[MUJOCO_TO_ISAACLAB]``. The ``_mj`` suffixes and the exact
permutation direction throughout this module are a fixed convention validated
against the deployed SONIC ONNX policy (the encoder/decoder consume vectors in
this order). Do not "correct" the table or rename toward the opposite direction
without re-validating on hardware the labels are historical, the ordering is
load-bearing.
"""
return a[MUJOCO_TO_ISAACLAB] return a[MUJOCO_TO_ISAACLAB]
@@ -318,6 +326,23 @@ class StandingEncoderDecoder:
self.smpl_root_quat = None self.smpl_root_quat = None
self.set_zero_reference() self.set_zero_reference()
def reset(self):
"""Clear the token, 10-frame proprioception history and heading init.
``UnitreeG1.reset()`` relies on this so the first decoder outputs of a new
episode are not contaminated by the previous episode's state.
"""
self.token = np.zeros(TOKEN_DIM, np.float32)
self.last_action_mj = np.zeros(29, np.float32)
self.h_q_mj = [np.zeros(29, np.float32)] * 10
self.h_dq_mj = [np.zeros(29, np.float32)] * 10
self.h_ang = [np.zeros(3, np.float32)] * 10
self.h_act_mj = [np.zeros(29, np.float32)] * 10
self.h_quat = [np.array([1, 0, 0, 0], np.float32)] * 10
self.init_base_quat = np.array([1, 0, 0, 0], np.float32)
self.init_ref_quat = np.array([1, 0, 0, 0], np.float32)
self._heading_init = False
def update_history(self, q, dq, ang, quat): def update_history(self, q, dq, ang, quat):
"""Push the latest proprioception (pos/vel/gyro/orientation) into the 10-frame buffers.""" """Push the latest proprioception (pos/vel/gyro/orientation) into the 10-frame buffers."""
quat = quat / (np.linalg.norm(quat) + 1e-8) quat = quat / (np.linalg.norm(quat) + 1e-8)
@@ -499,6 +524,28 @@ class PlannerController(StandingEncoderDecoder):
self.playing = self.first_motion = False self.playing = self.first_motion = False
self.motion_lock = threading.Lock() self.motion_lock = threading.Lock()
def reset(self):
"""Full reset: clear enc/dec state (super) plus the motion buffer and heading.
Forces a heading re-init on the next ``step`` so the reference frame is
re-latched to the post-reset robot orientation.
"""
super().reset()
with self.motion_lock:
self.ref_cursor = 0
self.motion_timesteps = 0
self.motion_joint_positions[:] = 0.0
self.motion_joint_velocities[:] = 0.0
self.motion_body_quats[:] = 0.0
self.motion_body_quats[:, 0] = 1.0
self.motion_body_pos[:] = 0.0
self.init_ref_quat = np.array([1, 0, 0, 0], np.float64)
self.heading_init_base_quat = np.array([1, 0, 0, 0], np.float64)
self.delta_heading = 0.0
self.first_motion = False
self.playing = False
self.reinit_heading = True
def _heading_apply_delta(self): def _heading_apply_delta(self):
"""Heading correction quaternion (init base-vs-ref heading + operator ``delta_heading``).""" """Heading correction quaternion (init base-vs-ref heading + operator ``delta_heading``)."""
delta = quat_mul( delta = quat_mul(
@@ -65,10 +65,15 @@ def _extract_wb34_from_action(action: dict | None) -> np.ndarray | None:
units. The ``.pos`` suffix lets these flow through ``lerobot-rollout`` as normal units. The ``.pos`` suffix lets these flow through ``lerobot-rollout`` as normal
joint-position action features. joint-position action features.
""" """
if not action or wb_action_key(0) not in action: if not action:
return None
keys = [wb_action_key(i) for i in range(WB_ACTION_DIM)]
# Require the full dense command: a partial action (e.g. only ``wb.0.pos``)
# must not be silently zero-filled, which would drive most joints toward 0.
if any(key not in action for key in keys):
return None return None
return np.fromiter( return np.fromiter(
(float(action.get(wb_action_key(i), 0.0)) for i in range(WB_ACTION_DIM)), (float(action[key]) for key in keys),
dtype=np.float32, dtype=np.float32,
count=WB_ACTION_DIM, count=WB_ACTION_DIM,
) )
@@ -86,8 +91,9 @@ def _wb34_to_reference(wb: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
The 29 joints are first assembled in MuJoCo / Unitree-SDK order The 29 joints are first assembled in MuJoCo / Unitree-SDK order
([L-leg 0:6, R-leg 6:12, waist 12:15, L-arm 15:22, R-arm 22:29] the ([L-leg 0:6, R-leg 6:12, waist 12:15, L-arm 15:22, R-arm 22:29] the
``G1_29_JointIndex`` grouping OpenHLM uses), then permuted to IsaacLab order via ``G1_29_JointIndex`` grouping OpenHLM uses), then permuted to IsaacLab order via
``MUJOCO_TO_ISAACLAB``. Grippers (7, 15) and yaw-rate (33) are not part of the ``MUJOCO_TO_ISAACLAB``. Grippers (7, 15) are not part of the 29-DoF SONIC
29-DoF SONIC reference. reference, and yaw-rate (33) is integrated into the heading by the caller (it
cannot be represented in this static per-tick anchor).
""" """
ref_mj = np.zeros(29, np.float32) # MuJoCo / Unitree-SDK grouped order ref_mj = np.zeros(29, np.float32) # MuJoCo / Unitree-SDK grouped order
ref_mj[0:6] = wb[16:22] # left leg ref_mj[0:6] = wb[16:22] # left leg
@@ -129,8 +135,10 @@ class SonicRuntime:
return self.controller return self.controller
def reset(self): def reset(self):
self.controller.reinit_heading = True # Full pipeline reset: clears the encoder token, proprioception history and
self.controller.playing = True # heading, and rewinds the motion buffer. reinit_heading is set so the next
# step re-latches the reference frame to the current robot orientation.
self.controller.reset()
def shutdown(self): def shutdown(self):
pass pass
@@ -165,6 +173,9 @@ class SonicWholeBodyController:
# stream of per-tick whole-body commands, fed to the encoder as a batch. # stream of per-tick whole-body commands, fed to the encoder as a batch.
self._wb_traj: deque[np.ndarray] = deque(maxlen=50) self._wb_traj: deque[np.ndarray] = deque(maxlen=50)
self._wb_quat_traj: deque[np.ndarray] = deque(maxlen=50) self._wb_quat_traj: deque[np.ndarray] = deque(maxlen=50)
# Integrated heading (rad) from the whole-body command's yaw-rate (index 33),
# forwarded to the pipeline as ``delta_heading`` so turn commands take effect.
self._heading = 0.0
logger.info("SONIC ready (encoder/decoder, 34-D whole-body command path)") logger.info("SONIC ready (encoder/decoder, 34-D whole-body command path)")
@@ -181,6 +192,11 @@ class SonicWholeBodyController:
if c.encode_mode != 0: if c.encode_mode != 0:
c.encode_mode = 0 c.encode_mode = 0
c.reinit_heading = True c.reinit_heading = True
# Index 33 is a yaw-rate (rad/s): integrate it into a heading offset and hand
# it to the pipeline as ``delta_heading`` so commanded turns are tracked rather
# than silently dropped (the anchor from _wb34_to_reference only carries r/p).
self._heading += float(wb[33]) * CONTROL_DT
c.delta_heading = self._heading
# Capture the heading/anchor reference on the first whole-body tick. The # Capture the heading/anchor reference on the first whole-body tick. The
# controller only latches ``init_ref_quat`` (and the base heading) inside # controller only latches ``init_ref_quat`` (and the base heading) inside
# ``step()`` when ``first_motion or reinit_heading`` — but it already boots in # ``step()`` when ``first_motion or reinit_heading`` — but it already boots in
@@ -282,6 +298,7 @@ class SonicWholeBodyController:
self._wb_step = 0 self._wb_step = 0
self._wb_traj.clear() self._wb_traj.clear()
self._wb_quat_traj.clear() self._wb_quat_traj.clear()
self._heading = 0.0
def shutdown(self): def shutdown(self):
self._runtime.shutdown() self._runtime.shutdown()
+83 -25
View File
@@ -161,41 +161,62 @@ class UnitreeG1(Robot):
# Controller thread state # Controller thread state
self._controller_thread = None self._controller_thread = None
# When set, the controller loop stops publishing low commands so reset() can
# drive the joints directly without two publishers fighting (single-publisher).
self._controller_paused = threading.Event()
self._controller_action_lock = threading.Lock() self._controller_action_lock = threading.Lock()
self.controller_input = default_remote_input() self.controller_input = default_remote_input()
self.controller_output = {} self.controller_output = {}
# Replay-camera state (decoded frames per robot camera name + play cursor). # Replay-camera state: keep the encoded (raw) cells per camera and decode
self._replay_frames: dict[str, list[np.ndarray]] = {} # frames lazily as the play cursor advances, with a small frame cache, so we
# don't materialize gigabytes of decoded RGB at construction time.
self._replay_raw: dict[str, list] = {}
self._replay_cache: dict[tuple[str, int], np.ndarray] = {}
self._replay_cache_cap = 8
self._replay_len = 0 self._replay_len = 0
self._replay_idx = 0 self._replay_idx = 0
if config.replay_camera_parquet and config.replay_camera_map: if config.replay_camera_parquet and config.replay_camera_map:
self._load_replay_frames() self._load_replay_frames()
def _load_replay_frames(self) -> None: def _load_replay_frames(self) -> None:
"""Decode recorded episode frames from a parquet into per-camera image lists.""" """Load only the mapped parquet columns (encoded frames); decode on demand."""
import io
import pyarrow.parquet as pq import pyarrow.parquet as pq
from PIL import Image
table = pq.read_table(self.config.replay_camera_parquet) cols_needed = list(dict.fromkeys(self.config.replay_camera_map.values()))
cols = {col: table.column(col).to_pylist() for col in self.config.replay_camera_map.values()} table = pq.read_table(self.config.replay_camera_parquet, columns=cols_needed)
self._replay_len = table.num_rows self._replay_len = table.num_rows
self._replay_raw = {
def decode(cell) -> np.ndarray: cam_name: table.column(column).to_pylist()
data = cell["bytes"] if isinstance(cell, dict) else cell for cam_name, column in self.config.replay_camera_map.items()
return np.asarray(Image.open(io.BytesIO(data)).convert("RGB"), dtype=np.uint8) }
for cam_name, column in self.config.replay_camera_map.items():
self._replay_frames[cam_name] = [decode(c) for c in cols[column]]
logger.info( logger.info(
"Loaded %d replay frames for cameras %s from %s", "Loaded %d replay frames (lazy-decode) for cameras %s from %s",
self._replay_len, self._replay_len,
list(self.config.replay_camera_map), list(self.config.replay_camera_map),
self.config.replay_camera_parquet, self.config.replay_camera_parquet,
) )
def _decode_replay_cell(self, cell) -> np.ndarray:
import io
from PIL import Image
data = cell["bytes"] if isinstance(cell, dict) else cell
return np.asarray(Image.open(io.BytesIO(data)).convert("RGB"), dtype=np.uint8)
def _replay_frame(self, cam_name: str, idx: int) -> np.ndarray:
"""Decode (and briefly cache) a single replay frame for a camera."""
key = (cam_name, idx)
cached = self._replay_cache.get(key)
if cached is not None:
return cached
frame = self._decode_replay_cell(self._replay_raw[cam_name][idx])
if len(self._replay_cache) >= self._replay_cache_cap:
self._replay_cache.pop(next(iter(self._replay_cache)))
self._replay_cache[key] = frame
return frame
def _subscribe_lowstate(self): # polls robot state @ 250Hz def _subscribe_lowstate(self): # polls robot state @ 250Hz
while not self._shutdown_event.is_set(): while not self._shutdown_event.is_set():
start_time = time.time() start_time = time.time()
@@ -292,8 +313,10 @@ class UnitreeG1(Robot):
@property @property
def _replay_cameras_ft(self) -> dict[str, tuple]: def _replay_cameras_ft(self) -> dict[str, tuple]:
"""Replay cameras, shaped from their first decoded frame.""" """Replay cameras, shaped from their first (lazily decoded) frame."""
return {name: frames[0].shape for name, frames in self._replay_frames.items() if frames} if not self._replay_len:
return {}
return {name: self._replay_frame(name, 0).shape for name in self._replay_raw}
@cached_property @cached_property
def observation_features(self) -> dict[str, type | tuple]: def observation_features(self) -> dict[str, type | tuple]:
@@ -333,6 +356,11 @@ class UnitreeG1(Robot):
while not self._shutdown_event.is_set(): while not self._shutdown_event.is_set():
start_time = time.time() start_time = time.time()
# Paused during reset() so the reset routine is the sole low-cmd publisher.
if self._controller_paused.is_set():
time.sleep(control_dt)
continue
with self._lowstate_lock: with self._lowstate_lock:
lowstate = self._lowstate lowstate = self._lowstate
@@ -495,14 +523,23 @@ class UnitreeG1(Robot):
def disconnect(self): def disconnect(self):
# Stop the controller loop first so it isn't fighting the shutdown ramp. # Stop the controller loop first so it isn't fighting the shutdown ramp.
self._shutdown_event.set() self._shutdown_event.set()
controller_stopped = True
if self._controller_thread is not None: if self._controller_thread is not None:
self._controller_thread.join(timeout=2.0) # Wait long enough for any in-flight inference tick to finish and the loop
# to observe the shutdown flag, so no stray low command is published while
# the ramp runs (the shutdown routine must be the single publisher).
self._controller_thread.join(timeout=5.0)
if self._controller_thread.is_alive(): if self._controller_thread.is_alive():
logger.warning("Controller thread did not stop cleanly") controller_stopped = False
logger.error(
"Controller thread did not stop; skipping graceful ramp to avoid "
"concurrent low commands (fail-safe: joints keep last command until exit)"
)
# Soft, damped settle instead of an instant limp (real robot only; the # Soft, damped settle instead of an instant limp (real robot only; the
# subscribe thread is still alive here to supply the current pose). # subscribe thread is still alive here to supply the current pose). Only ramp
if not self.config.is_simulation: # once the controller thread has definitely exited.
if not self.config.is_simulation and controller_stopped:
self._graceful_stop() 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"):
@@ -569,8 +606,8 @@ class UnitreeG1(Robot):
idx = self._replay_idx idx = self._replay_idx
if idx >= self._replay_len: if idx >= self._replay_len:
idx = self._replay_len - 1 if not self.config.replay_camera_loop else idx % self._replay_len idx = self._replay_len - 1 if not self.config.replay_camera_loop else idx % self._replay_len
for name, frames in self._replay_frames.items(): for name in self._replay_raw:
obs[name] = frames[idx] obs[name] = self._replay_frame(name, idx)
self._replay_idx += 1 self._replay_idx += 1
# Cameras - read images from ZMQ cameras # Cameras - read images from ZMQ cameras
@@ -709,6 +746,18 @@ class UnitreeG1(Robot):
if default_positions is None: if default_positions is None:
default_positions = np.array(self.config.default_positions, dtype=np.float32) default_positions = np.array(self.config.default_positions, dtype=np.float32)
# Full-body controllers (SONIC / OpenHLM) own the whole 29-DoF command and
# ignore ``<joint>.q`` in send_action(), so reset() must publish the default
# pose directly. Pause the background controller first so the two aren't both
# writing low commands while the robot moves to the default pose.
full_body = getattr(self.controller, "full_body", False)
paused = False
if full_body and self._controller_thread is not None:
self._controller_paused.set()
paused = True
time.sleep(control_dt) # let any in-flight controller tick settle
try:
if self.config.is_simulation and self.sim_env is not None: if self.config.is_simulation and self.sim_env is not None:
self.sim_env.reset() self.sim_env.reset()
self.publish_lowcmd( self.publish_lowcmd(
@@ -737,6 +786,11 @@ class UnitreeG1(Robot):
interp_pos = init_dof_pos[motor.value] * (1 - alpha) + target_pos * alpha interp_pos = init_dof_pos[motor.value] * (1 - alpha) + target_pos * alpha
action_dict[f"{motor.name}.q"] = float(interp_pos) action_dict[f"{motor.name}.q"] = float(interp_pos)
# Full-body controllers no-op in send_action(); publish the pose
# directly (arm-only controllers keep the send_action() path).
if full_body:
self.publish_lowcmd(action_dict)
else:
self.send_action(action_dict) self.send_action(action_dict)
# Maintain constant control rate # Maintain constant control rate
@@ -744,8 +798,12 @@ class UnitreeG1(Robot):
sleep_time = max(0, control_dt - elapsed) sleep_time = max(0, control_dt - elapsed)
time.sleep(sleep_time) time.sleep(sleep_time)
# Reset controller internal state (gait phase, obs history, etc.) # Reset controller internal state (gait phase, obs history, etc.) before
# resuming so its buffers reflect the post-reset pose.
if self.controller is not None and hasattr(self.controller, "reset"): if self.controller is not None and hasattr(self.controller, "reset"):
self.controller.reset() self.controller.reset()
finally:
if paused:
self._controller_paused.clear()
logger.info("Reached default position") logger.info("Reached default position")