mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-25 10:46:01 +00:00
feat(unitree_g1): onboard locomotion mode + teleop client + camera viewer
Run the locomotion controller onboard the G1 (real local DDS) instead of on the laptop over the ZMQ bridge, so the tight policy loop no longer crosses the network: - UnitreeG1Config: `onboard` + `dds_interface` - UnitreeG1: onboard mode uses real SDK channels, releases built-in motion services, and defers the arm soft-start to the exo's first commanded pose - run_g1_onboard.py: robot-side runner (applies laptop actions via send_action) - run_g1_teleop_client.py: laptop-side thin client (exos + IK -> action over ZMQ) - view_cameras.py: lightweight side-by-side cv2 viewer for the ZMQ camera streams - run_g1.sh: one-shot launcher (conda + CAN + server) - run_g1_server.py: exit state loop cleanly on ZMQ ContextTerminated - gr00t/holosoma: cap ONNX threads; right-stick-Y waist height + stick deadzone Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -59,6 +59,17 @@ class UnitreeG1Config(RobotConfig):
|
|||||||
# Launch mujoco simulation
|
# Launch mujoco simulation
|
||||||
is_simulation: bool = True
|
is_simulation: bool = True
|
||||||
|
|
||||||
|
# Run the locomotion controller ONBOARD the robot (policy on the G1 itself,
|
||||||
|
# against local DDS) instead of on the laptop over the ZMQ bridge. In this mode
|
||||||
|
# the robot object uses the real Unitree SDK channels and expects high-level
|
||||||
|
# actions (arm targets + joystick axes) to be fed in via send_action (e.g. by
|
||||||
|
# run_g1_onboard.py, which receives them from the laptop). Mutually exclusive
|
||||||
|
# with is_simulation.
|
||||||
|
onboard: bool = False
|
||||||
|
# DDS network interface for onboard mode (None = SDK default, matching
|
||||||
|
# run_g1_server.py's ChannelFactoryInitialize(0)).
|
||||||
|
dds_interface: str | None = None
|
||||||
|
|
||||||
# Socket config for ZMQ bridge
|
# Socket config for ZMQ bridge
|
||||||
robot_ip: str = "192.168.123.164" # default G1 IP
|
robot_ip: str = "192.168.123.164" # default G1 IP
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ DOF_POS_SCALE: float = 1.0
|
|||||||
DOF_VEL_SCALE: float = 0.05
|
DOF_VEL_SCALE: float = 0.05
|
||||||
CMD_SCALE: list[float] = [2.0, 2.0, 0.5]
|
CMD_SCALE: list[float] = [2.0, 2.0, 0.5]
|
||||||
|
|
||||||
|
# Waist-height control via the right stick Y axis (the only unmapped axis).
|
||||||
|
# Rate control (m per control step at full deflection): a self-centering stick
|
||||||
|
# returns to 0 = "hold current height". ~0.2 m/s at 50 Hz.
|
||||||
|
HEIGHT_STICK_RATE: float = 0.004
|
||||||
|
HEIGHT_MIN: float = 0.50
|
||||||
|
HEIGHT_MAX: float = 1.00
|
||||||
|
|
||||||
|
# Deadzone applied to all stick axes. Resting sticks drift by ~0.03-0.05, which
|
||||||
|
# otherwise keeps cmd_magnitude above the balance/walk threshold and makes the
|
||||||
|
# robot march in place instead of standing still on the Balance policy.
|
||||||
|
STICK_DEADZONE: float = 0.1
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_GROOT_REPO_ID = "nepyope/GR00T-WholeBodyControl_g1"
|
DEFAULT_GROOT_REPO_ID = "nepyope/GR00T-WholeBodyControl_g1"
|
||||||
|
|
||||||
@@ -68,9 +80,16 @@ def load_groot_policies(
|
|||||||
filename="GR00T-WholeBodyControl-Walk.onnx",
|
filename="GR00T-WholeBodyControl-Walk.onnx",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load ONNX policies
|
# Load ONNX policies. Cap onnxruntime to a single thread: these are tiny MLP
|
||||||
policy_balance = ort.InferenceSession(balance_path)
|
# policies run in the real-time control loop, and letting ORT grab one intra-op
|
||||||
policy_walk = ort.InferenceSession(walk_path)
|
# thread per core oversubscribes the CPU and starves the teleop/IK loop
|
||||||
|
# (teleop becomes abysmally slow). Sequential + 1 thread is fastest here.
|
||||||
|
so = ort.SessionOptions()
|
||||||
|
so.intra_op_num_threads = 1
|
||||||
|
so.inter_op_num_threads = 1
|
||||||
|
so.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
||||||
|
policy_balance = ort.InferenceSession(balance_path, sess_options=so)
|
||||||
|
policy_walk = ort.InferenceSession(walk_path, sess_options=so)
|
||||||
|
|
||||||
logger.info("GR00T policies loaded successfully")
|
logger.info("GR00T policies loaded successfully")
|
||||||
|
|
||||||
@@ -134,12 +153,19 @@ class GrootLocomotionController:
|
|||||||
buttons = [int(action.get(k, 0)) for k in REMOTE_BUTTONS]
|
buttons = [int(action.get(k, 0)) for k in REMOTE_BUTTONS]
|
||||||
if buttons[0]: # R1 - raise waist
|
if buttons[0]: # R1 - raise waist
|
||||||
self.groot_height_cmd += 0.001
|
self.groot_height_cmd += 0.001
|
||||||
self.groot_height_cmd = np.clip(self.groot_height_cmd, 0.50, 1.00)
|
self.groot_height_cmd = np.clip(self.groot_height_cmd, HEIGHT_MIN, HEIGHT_MAX)
|
||||||
if buttons[4]: # R2 - lower waist
|
if buttons[4]: # R2 - lower waist
|
||||||
self.groot_height_cmd -= 0.001
|
self.groot_height_cmd -= 0.001
|
||||||
self.groot_height_cmd = np.clip(self.groot_height_cmd, 0.50, 1.00)
|
self.groot_height_cmd = np.clip(self.groot_height_cmd, HEIGHT_MIN, HEIGHT_MAX)
|
||||||
|
|
||||||
lx, ly, rx, _ry = (action.get(k, 0.0) for k in REMOTE_AXES)
|
lx, ly, rx, ry = (action.get(k, 0.0) for k in REMOTE_AXES)
|
||||||
|
# Deadzone every axis so resting-stick drift doesn't leak into commands.
|
||||||
|
lx, ly, rx, ry = (0.0 if abs(v) < STICK_DEADZONE else v for v in (lx, ly, rx, ry))
|
||||||
|
# Right stick Y controls waist height (rate control) — the only otherwise
|
||||||
|
# unmapped axis. Push up = raise, push down = lower, release = hold.
|
||||||
|
if ry != 0.0:
|
||||||
|
self.groot_height_cmd += ry * HEIGHT_STICK_RATE
|
||||||
|
self.groot_height_cmd = np.clip(self.groot_height_cmd, HEIGHT_MIN, HEIGHT_MAX)
|
||||||
self.cmd[0] = ly # Forward/backward
|
self.cmd[0] = ly # Forward/backward
|
||||||
self.cmd[1] = -lx # Left/right (negated)
|
self.cmd[1] = -lx # Left/right (negated)
|
||||||
self.cmd[2] = -rx # Rotation rate (negated)
|
self.cmd[2] = -rx # Rotation rate (negated)
|
||||||
|
|||||||
@@ -78,7 +78,14 @@ def load_policy(
|
|||||||
logger.info(f"Loading {policy_type.upper()} policy from: {repo_id}/{filename}")
|
logger.info(f"Loading {policy_type.upper()} policy from: {repo_id}/{filename}")
|
||||||
policy_path = hf_hub_download(repo_id=repo_id, filename=filename)
|
policy_path = hf_hub_download(repo_id=repo_id, filename=filename)
|
||||||
|
|
||||||
policy = ort.InferenceSession(policy_path)
|
# Cap onnxruntime to a single thread: this is a tiny policy run in the
|
||||||
|
# real-time control loop, and letting ORT grab one intra-op thread per core
|
||||||
|
# oversubscribes the CPU and starves the teleop/IK loop (abysmally slow teleop).
|
||||||
|
so = ort.SessionOptions()
|
||||||
|
so.intra_op_num_threads = 1
|
||||||
|
so.inter_op_num_threads = 1
|
||||||
|
so.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
||||||
|
policy = ort.InferenceSession(policy_path, sess_options=so)
|
||||||
logger.info(f"Policy loaded: {policy.get_inputs()[0].shape} → {policy.get_outputs()[0].shape}")
|
logger.info(f"Policy loaded: {policy.get_inputs()[0].shape} → {policy.get_outputs()[0].shape}")
|
||||||
|
|
||||||
# Extract KP/KD from ONNX metadata
|
# Extract KP/KD from ONNX metadata
|
||||||
|
|||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Launch the G1 ZMQ bridge server with grippers + cameras.
|
||||||
|
# Handles conda activation and CAN bring-up so you only run one command on the robot.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- conda -------------------------------------------------------------------
|
||||||
|
CONDA_SH="${CONDA_SH:-$HOME/miniforge3/etc/profile.d/conda.sh}"
|
||||||
|
if [[ ! -f "$CONDA_SH" ]]; then
|
||||||
|
echo "conda.sh not found at $CONDA_SH (set CONDA_SH=/path/to/conda.sh)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$CONDA_SH"
|
||||||
|
conda activate lerobot
|
||||||
|
|
||||||
|
# --- CAN bring-up + test -----------------------------------------------------
|
||||||
|
CAN_INTERFACES="${CAN_INTERFACES:-can0,can1}"
|
||||||
|
echo "==> Setting up CAN interfaces: $CAN_INTERFACES"
|
||||||
|
lerobot-setup-can --mode=setup --interfaces="$CAN_INTERFACES"
|
||||||
|
|
||||||
|
echo "==> Testing CAN motors on: $CAN_INTERFACES"
|
||||||
|
lerobot-setup-can --mode=test --interfaces="$CAN_INTERFACES"
|
||||||
|
|
||||||
|
# --- G1 server ---------------------------------------------------------------
|
||||||
|
CAMERAS="${CAMERAS:-head_camera:/dev/v4l/by-id/usb-Intel_R__RealSense_TM__Depth_Camera_435i_Intel_R__RealSense_TM__Depth_Camera_435i_254343063964-video-index0:640x480:YUYV,left_wrist:/dev/video2:1280x720:MJPG,right_wrist:/dev/video0:1280x720:MJPG}"
|
||||||
|
CAMERA_FPS="${CAMERA_FPS:-30}"
|
||||||
|
GRIPPER_PORT_LEFT="${GRIPPER_PORT_LEFT:-can1}"
|
||||||
|
GRIPPER_PORT_RIGHT="${GRIPPER_PORT_RIGHT:-can0}"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
echo "==> Starting G1 server"
|
||||||
|
exec python "$SCRIPT_DIR/run_g1_server.py" \
|
||||||
|
--grippers \
|
||||||
|
--gripper-port-left "$GRIPPER_PORT_LEFT" \
|
||||||
|
--gripper-port-right "$GRIPPER_PORT_RIGHT" \
|
||||||
|
--camera-fps "$CAMERA_FPS" \
|
||||||
|
--cameras "$CAMERAS"
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/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.
|
||||||
|
|
||||||
|
"""Run the G1 locomotion controller ONBOARD, driven by high-level actions from a laptop.
|
||||||
|
|
||||||
|
The locomotion policy (GRoot / Holosoma) runs on the robot itself against local DDS, at
|
||||||
|
full control rate. The laptop reads the exos, runs IK, and ships only the resulting
|
||||||
|
action (arm joint targets + joystick axes + gripper flags) as JSON over ZMQ. This process
|
||||||
|
applies each action via ``UnitreeG1.send_action`` while the onboard controller thread
|
||||||
|
keeps the legs balanced.
|
||||||
|
|
||||||
|
Pair with ``run_g1_teleop_client.py`` on the laptop. Grippers/cameras are handled
|
||||||
|
separately by ``run_g1_server.py`` and are intentionally out of scope here.
|
||||||
|
|
||||||
|
Example (on the robot):
|
||||||
|
|
||||||
|
python -m lerobot.robots.unitree_g1.run_g1_onboard --controller GrootLocomotionController
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import zmq
|
||||||
|
|
||||||
|
from lerobot.robots.unitree_g1.config_unitree_g1 import UnitreeG1Config
|
||||||
|
from lerobot.robots.unitree_g1.unitree_g1 import UnitreeG1
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", force=True)
|
||||||
|
logger = logging.getLogger("g1_onboard")
|
||||||
|
|
||||||
|
ACTION_PORT = 6004
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
p.add_argument("--controller", default="GrootLocomotionController", help="Locomotion controller class")
|
||||||
|
p.add_argument("--dds-interface", default=None, help="DDS network interface (default: SDK default)")
|
||||||
|
p.add_argument("--action-port", type=int, default=ACTION_PORT, help="ZMQ port for laptop actions")
|
||||||
|
p.add_argument(
|
||||||
|
"--gravity-compensation",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable arm gravity compensation (needs pinocchio/casadi on the robot)",
|
||||||
|
)
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
cfg = UnitreeG1Config(
|
||||||
|
is_simulation=False,
|
||||||
|
onboard=True,
|
||||||
|
controller=args.controller,
|
||||||
|
dds_interface=args.dds_interface,
|
||||||
|
gravity_compensation=args.gravity_compensation,
|
||||||
|
cameras={},
|
||||||
|
)
|
||||||
|
robot = UnitreeG1(cfg)
|
||||||
|
logger.info("Connecting onboard robot (controller=%s)...", args.controller)
|
||||||
|
robot.connect()
|
||||||
|
|
||||||
|
ctx = zmq.Context.instance()
|
||||||
|
sock = ctx.socket(zmq.PULL)
|
||||||
|
sock.setsockopt(zmq.CONFLATE, 1) # only ever act on the freshest command
|
||||||
|
sock.setsockopt(zmq.RCVTIMEO, 1000)
|
||||||
|
sock.bind(f"tcp://0.0.0.0:{args.action_port}")
|
||||||
|
logger.info("Onboard controller live. Waiting for laptop actions on :%d ...", args.action_port)
|
||||||
|
|
||||||
|
stop = threading.Event()
|
||||||
|
signal.signal(signal.SIGINT, lambda *_: stop.set())
|
||||||
|
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
||||||
|
|
||||||
|
n = 0
|
||||||
|
try:
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
payload = sock.recv()
|
||||||
|
except zmq.Again:
|
||||||
|
continue
|
||||||
|
except zmq.ContextTerminated:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = json.loads(payload.decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeDecodeError) as e:
|
||||||
|
logger.warning("Dropping malformed action: %s", e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
robot.send_action(action)
|
||||||
|
n += 1
|
||||||
|
if n % 200 == 0:
|
||||||
|
logger.info("Applied %d actions", n)
|
||||||
|
finally:
|
||||||
|
logger.info("Shutting down onboard controller...")
|
||||||
|
robot.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -26,7 +26,6 @@ Uses JSON for secure serialization instead of pickle.
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
import contextlib
|
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -248,9 +247,14 @@ def state_forward_loop(
|
|||||||
# Convert to dict and serialize with JSON
|
# Convert to dict and serialize with JSON
|
||||||
state_dict = lowstate_to_dict(msg)
|
state_dict = lowstate_to_dict(msg)
|
||||||
payload = json.dumps({"topic": kTopicLowState, "data": state_dict}).encode("utf-8")
|
payload = json.dumps({"topic": kTopicLowState, "data": state_dict}).encode("utf-8")
|
||||||
# if no subscribers / tx buffer full, just drop
|
try:
|
||||||
with contextlib.suppress(zmq.Again):
|
# if no subscribers / tx buffer full, just drop
|
||||||
lowstate_sock.send(payload, zmq.NOBLOCK)
|
lowstate_sock.send(payload, zmq.NOBLOCK)
|
||||||
|
except zmq.Again:
|
||||||
|
pass
|
||||||
|
except zmq.ContextTerminated:
|
||||||
|
# Context torn down during shutdown; exit the loop quietly.
|
||||||
|
break
|
||||||
last_state_time = now
|
last_state_time = now
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/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.
|
||||||
|
|
||||||
|
"""Laptop-side thin client: read the exos, run IK, ship the action to the onboard G1.
|
||||||
|
|
||||||
|
This is the counterpart to ``run_g1_onboard.py``. The heavy IK (pinocchio/casadi) stays
|
||||||
|
on the laptop where it's set up; only the resulting action (arm joint targets + joystick
|
||||||
|
axes + gripper flags) is sent as JSON over ZMQ. The locomotion policy runs on the robot,
|
||||||
|
so nothing latency-critical crosses the network.
|
||||||
|
|
||||||
|
Example (on the laptop):
|
||||||
|
|
||||||
|
export LD_PRELOAD="$HOME/Documents/miniconda3/envs/lerobot312/lib/libstdc++.so.6"
|
||||||
|
export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1
|
||||||
|
python -m lerobot.robots.unitree_g1.run_g1_teleop_client \
|
||||||
|
--robot-ip 172.18.130.111 \
|
||||||
|
--left-arm-port /dev/ttyACM1 --right-arm-port /dev/ttyACM0 \
|
||||||
|
--teleop-id asdasd
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import zmq
|
||||||
|
|
||||||
|
from lerobot.teleoperators.unitree_g1.config_unitree_g1 import (
|
||||||
|
ExoskeletonArmPortConfig,
|
||||||
|
UnitreeG1TeleoperatorConfig,
|
||||||
|
)
|
||||||
|
from lerobot.teleoperators.unitree_g1.unitree_g1 import UnitreeG1Teleoperator
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", force=True)
|
||||||
|
logger = logging.getLogger("g1_teleop_client")
|
||||||
|
|
||||||
|
ACTION_PORT = 6004
|
||||||
|
|
||||||
|
|
||||||
|
def _to_jsonable(action: dict) -> dict:
|
||||||
|
"""Cast numpy scalars to plain Python so json.dumps accepts the action."""
|
||||||
|
out: dict = {}
|
||||||
|
for k, v in action.items():
|
||||||
|
if isinstance(v, (np.generic,)):
|
||||||
|
out[k] = v.item()
|
||||||
|
else:
|
||||||
|
out[k] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
p.add_argument("--robot-ip", required=True, help="IP of the robot running run_g1_onboard.py")
|
||||||
|
p.add_argument("--action-port", type=int, default=ACTION_PORT)
|
||||||
|
p.add_argument("--left-arm-port", required=True, help="Serial port for the LEFT exo arm")
|
||||||
|
p.add_argument("--right-arm-port", required=True, help="Serial port for the RIGHT exo arm")
|
||||||
|
p.add_argument("--teleop-id", default="exo", help="Teleoperator id (for calibration files)")
|
||||||
|
p.add_argument("--frozen-joints", default="", help="Comma-separated joints to freeze in IK")
|
||||||
|
p.add_argument("--fps", type=float, default=60.0, help="Max action send rate")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
cfg = UnitreeG1TeleoperatorConfig(
|
||||||
|
id=args.teleop_id,
|
||||||
|
left_arm_config=ExoskeletonArmPortConfig(port=args.left_arm_port),
|
||||||
|
right_arm_config=ExoskeletonArmPortConfig(port=args.right_arm_port),
|
||||||
|
frozen_joints=args.frozen_joints,
|
||||||
|
)
|
||||||
|
teleop = UnitreeG1Teleoperator(cfg)
|
||||||
|
logger.info("Connecting exo teleoperator (L=%s, R=%s)...", args.left_arm_port, args.right_arm_port)
|
||||||
|
teleop.connect()
|
||||||
|
|
||||||
|
ctx = zmq.Context.instance()
|
||||||
|
sock = ctx.socket(zmq.PUSH)
|
||||||
|
sock.setsockopt(zmq.SNDHWM, 2)
|
||||||
|
sock.setsockopt(zmq.CONFLATE, 1) # drop stale actions rather than queue them
|
||||||
|
sock.setsockopt(zmq.LINGER, 0)
|
||||||
|
sock.connect(f"tcp://{args.robot_ip}:{args.action_port}")
|
||||||
|
logger.info("Sending actions to %s:%d. Ctrl-C to stop.", args.robot_ip, args.action_port)
|
||||||
|
|
||||||
|
period = 1.0 / args.fps if args.fps > 0 else 0.0
|
||||||
|
n = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
t0 = time.time()
|
||||||
|
action = teleop.get_action()
|
||||||
|
try:
|
||||||
|
sock.send_json(_to_jsonable(action), zmq.NOBLOCK)
|
||||||
|
except zmq.Again:
|
||||||
|
pass
|
||||||
|
n += 1
|
||||||
|
if n % 200 == 0:
|
||||||
|
logger.info("Sent %d actions", n)
|
||||||
|
if period:
|
||||||
|
time.sleep(max(0.0, period - (time.time() - t0)))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Stopping teleop client...")
|
||||||
|
finally:
|
||||||
|
with_suppress_disconnect(teleop)
|
||||||
|
sock.close(linger=0)
|
||||||
|
|
||||||
|
|
||||||
|
def with_suppress_disconnect(teleop) -> None:
|
||||||
|
try:
|
||||||
|
teleop.disconnect()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("teleop.disconnect() failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -126,8 +126,10 @@ class UnitreeG1(Robot):
|
|||||||
# Initialize cameras config (ZMQ-based) - actual connection in connect()
|
# Initialize cameras config (ZMQ-based) - actual connection in connect()
|
||||||
self._cameras = make_cameras_from_configs(config.cameras)
|
self._cameras = make_cameras_from_configs(config.cameras)
|
||||||
|
|
||||||
# Import channel classes based on mode
|
# Import channel classes based on mode. Simulation and onboard both talk to a
|
||||||
if config.is_simulation:
|
# real (local) DDS via the Unitree SDK; only the laptop-side bridge client uses
|
||||||
|
# the ZMQ socket shim.
|
||||||
|
if config.is_simulation or config.onboard:
|
||||||
self._ChannelFactoryInitialize = _SDKChannelFactoryInitialize
|
self._ChannelFactoryInitialize = _SDKChannelFactoryInitialize
|
||||||
self._ChannelPublisher = _SDKChannelPublisher
|
self._ChannelPublisher = _SDKChannelPublisher
|
||||||
self._ChannelSubscriber = _SDKChannelSubscriber
|
self._ChannelSubscriber = _SDKChannelSubscriber
|
||||||
@@ -297,8 +299,28 @@ class UnitreeG1(Robot):
|
|||||||
def configure(self) -> None:
|
def configure(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _release_motion_control(self) -> None:
|
||||||
|
"""Release the robot's built-in motion services so we can send raw lowcmd.
|
||||||
|
|
||||||
|
Onboard-only. Mirrors run_g1_server.py: on the real robot the factory
|
||||||
|
locomotion/hand services must relinquish control before our controller can
|
||||||
|
write to ``rt/lowcmd``, otherwise commands are ignored or fought.
|
||||||
|
"""
|
||||||
|
from unitree_sdk2py.comm.motion_switcher.motion_switcher_client import MotionSwitcherClient
|
||||||
|
|
||||||
|
msc = MotionSwitcherClient()
|
||||||
|
msc.SetTimeout(5.0)
|
||||||
|
msc.Init()
|
||||||
|
_, result = msc.CheckMode()
|
||||||
|
while result is not None and "name" in result and result["name"]:
|
||||||
|
logger.info("[UnitreeG1] Releasing built-in mode '%s'...", result["name"])
|
||||||
|
msc.ReleaseMode()
|
||||||
|
_, result = msc.CheckMode()
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
def connect(self, calibrate: bool = True) -> None: # connect to DDS
|
def connect(self, calibrate: bool = True) -> None: # connect to DDS
|
||||||
self._is_disconnected = False
|
self._is_disconnected = False
|
||||||
|
self._pending_arm_softstart = False
|
||||||
# Initialize DDS channel and simulation environment
|
# Initialize DDS channel and simulation environment
|
||||||
if self.config.is_simulation:
|
if self.config.is_simulation:
|
||||||
from lerobot.envs import make_env
|
from lerobot.envs import make_env
|
||||||
@@ -307,6 +329,15 @@ class UnitreeG1(Robot):
|
|||||||
self._env_wrapper = make_env("lerobot/unitree-g1-mujoco", trust_remote_code=True)
|
self._env_wrapper = make_env("lerobot/unitree-g1-mujoco", trust_remote_code=True)
|
||||||
# Extract the actual gym env from the dict structure
|
# Extract the actual gym env from the dict structure
|
||||||
self.sim_env = self._env_wrapper["hub_env"][0].envs[0]
|
self.sim_env = self._env_wrapper["hub_env"][0].envs[0]
|
||||||
|
elif self.config.onboard:
|
||||||
|
# Real robot, controller running onboard against local DDS. Initialize the
|
||||||
|
# real SDK channel factory on the robot's DDS interface and take low-level
|
||||||
|
# control from the built-in services before we start writing lowcmd.
|
||||||
|
if self.config.dds_interface:
|
||||||
|
self._ChannelFactoryInitialize(0, self.config.dds_interface)
|
||||||
|
else:
|
||||||
|
self._ChannelFactoryInitialize(0)
|
||||||
|
self._release_motion_control()
|
||||||
else:
|
else:
|
||||||
self._ChannelFactoryInitialize(0, config=self.config)
|
self._ChannelFactoryInitialize(0, config=self.config)
|
||||||
|
|
||||||
@@ -315,7 +346,7 @@ class UnitreeG1(Robot):
|
|||||||
self._gripper_sock = None
|
self._gripper_sock = None
|
||||||
self._last_gripper_cmd = None
|
self._last_gripper_cmd = None
|
||||||
self._warned_no_gripper_buttons = False
|
self._warned_no_gripper_buttons = False
|
||||||
if not self.config.is_simulation:
|
if not self.config.is_simulation and not self.config.onboard:
|
||||||
try:
|
try:
|
||||||
import zmq
|
import zmq
|
||||||
|
|
||||||
@@ -375,12 +406,11 @@ class UnitreeG1(Robot):
|
|||||||
|
|
||||||
# Start controller thread if enabled
|
# Start controller thread if enabled
|
||||||
if self.controller is not None:
|
if self.controller is not None:
|
||||||
# Soft-start: ramp the arms from their current pose to the default position
|
# Defer the arm soft-start to the first teleop action so we ramp to the
|
||||||
# before the locomotion policy takes over, avoiding a jump at startup. The
|
# exo's actual starting pose instead of the fixed default (avoids a snap
|
||||||
# controller owns the legs, so send_action only moves the arms here. Runs in
|
# when the operator's arms aren't at the default). Arms hold their current
|
||||||
# both sim and on the real robot so the ramp is visible in MuJoCo too.
|
# pose (set above) until then; the controller owns the legs meanwhile.
|
||||||
logger.info("Soft-start: ramping arms to default position...")
|
self._pending_arm_softstart = True
|
||||||
self._interpolate_to_default(duration=3.0)
|
|
||||||
|
|
||||||
self._controller_thread = threading.Thread(target=self._controller_loop, daemon=True)
|
self._controller_thread = threading.Thread(target=self._controller_loop, daemon=True)
|
||||||
self._controller_thread.start()
|
self._controller_thread.start()
|
||||||
@@ -538,6 +568,13 @@ class UnitreeG1(Robot):
|
|||||||
return obs
|
return obs
|
||||||
|
|
||||||
def send_action(self, action: RobotAction) -> RobotAction:
|
def send_action(self, action: RobotAction) -> RobotAction:
|
||||||
|
# One-time soft-start: ramp the arms to the exo's first commanded pose so
|
||||||
|
# they don't snap when teleop starts. Flag is cleared before ramping so the
|
||||||
|
# ramp's own send_action calls don't re-enter this branch.
|
||||||
|
if self.controller is not None and getattr(self, "_pending_arm_softstart", False):
|
||||||
|
self._pending_arm_softstart = False
|
||||||
|
self._softstart_arms_to_action(action)
|
||||||
|
|
||||||
action_to_publish = action
|
action_to_publish = action
|
||||||
if self.controller is not None:
|
if self.controller is not None:
|
||||||
# Controller thread owns legs/waist. Here we only update joystick inputs
|
# Controller thread owns legs/waist. Here we only update joystick inputs
|
||||||
@@ -570,6 +607,29 @@ class UnitreeG1(Robot):
|
|||||||
self._send_gripper_cmd(action)
|
self._send_gripper_cmd(action)
|
||||||
return action
|
return action
|
||||||
|
|
||||||
|
def _softstart_arms_to_action(self, action: RobotAction) -> None:
|
||||||
|
"""Ramp arms from their current pose to the exo's first commanded pose.
|
||||||
|
|
||||||
|
Runs once, on the first teleop action after connect, so the arms move
|
||||||
|
smoothly to wherever the operator is holding the exoskeleton instead of
|
||||||
|
snapping. Legs stay under the controller throughout (send_action filters
|
||||||
|
to arm joints when a controller is active).
|
||||||
|
"""
|
||||||
|
target = np.array(self.config.default_positions, dtype=np.float32).copy()
|
||||||
|
have_arm_target = False
|
||||||
|
for joint in G1_29_JointArmIndex:
|
||||||
|
key = f"{joint.name}.q"
|
||||||
|
if key in action:
|
||||||
|
target[joint.value] = float(action[key])
|
||||||
|
have_arm_target = True
|
||||||
|
if not have_arm_target:
|
||||||
|
return
|
||||||
|
logger.info("Soft-start: ramping arms to exo's first commanded pose...")
|
||||||
|
try:
|
||||||
|
self._interpolate_to_default(duration=3.0, default_positions=target)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Arm soft-start to exo pose failed ({e}); continuing.")
|
||||||
|
|
||||||
def _send_gripper_cmd(self, action: RobotAction) -> None:
|
def _send_gripper_cmd(self, action: RobotAction) -> None:
|
||||||
"""Forward exo R3/L3 button flags to run_g1_server to open/close the grippers.
|
"""Forward exo R3/L3 button flags to run_g1_server to open/close the grippers.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
#!/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.
|
||||||
|
|
||||||
|
"""Lightweight side-by-side viewer for the G1 robot's ZMQ camera streams.
|
||||||
|
|
||||||
|
A fast cv2 alternative to ``--display_data`` (rerun), for eyeballing exactly what
|
||||||
|
the robot is streaming. Subscribes to the same ZMQ PUB server as teleop (PUB/SUB,
|
||||||
|
so it runs concurrently), and shows head + wrists in one window.
|
||||||
|
|
||||||
|
Example (run alongside teleop, no ``--display_data`` needed):
|
||||||
|
|
||||||
|
python -m lerobot.robots.unitree_g1.view_cameras --server-address 172.18.130.111
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Needs a GUI-capable OpenCV build (``pip install opencv-python``, not the
|
||||||
|
``-headless`` variant) since it uses ``cv2.imshow``.
|
||||||
|
- Camera names/resolutions default to what ``run_g1.sh`` streams.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from lerobot.cameras.zmq import ZMQCamera, ZMQCameraConfig
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", force=True)
|
||||||
|
logger = logging.getLogger("g1_view_cameras")
|
||||||
|
|
||||||
|
# name -> (native width, native height) as served by run_g1.sh
|
||||||
|
DEFAULT_CAMERAS: dict[str, tuple[int, int]] = {
|
||||||
|
"head_camera": (640, 480),
|
||||||
|
"left_wrist": (1280, 720),
|
||||||
|
"right_wrist": (1280, 720),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _placeholder(name: str, pane_h: int) -> np.ndarray:
|
||||||
|
img = np.zeros((pane_h, int(pane_h * 4 / 3), 3), dtype=np.uint8)
|
||||||
|
cv2.putText(img, f"{name}: no frame", (10, pane_h // 2), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
p.add_argument("--server-address", required=True, help="Robot IP running the ZMQ camera server")
|
||||||
|
p.add_argument("--port", type=int, default=5555)
|
||||||
|
p.add_argument("--fps", type=int, default=30)
|
||||||
|
p.add_argument("--pane-height", type=int, default=360, help="Display height per camera pane (px)")
|
||||||
|
p.add_argument(
|
||||||
|
"--cameras",
|
||||||
|
default=",".join(DEFAULT_CAMERAS),
|
||||||
|
help="Comma-separated camera names to show (must match the server).",
|
||||||
|
)
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
names = [c.strip() for c in args.cameras.split(",") if c.strip()]
|
||||||
|
cams: dict[str, ZMQCamera] = {}
|
||||||
|
for name in names:
|
||||||
|
w, h = DEFAULT_CAMERAS.get(name, (None, None))
|
||||||
|
cfg = ZMQCameraConfig(
|
||||||
|
server_address=args.server_address,
|
||||||
|
port=args.port,
|
||||||
|
camera_name=name,
|
||||||
|
width=w,
|
||||||
|
height=h,
|
||||||
|
fps=args.fps,
|
||||||
|
warmup_s=5,
|
||||||
|
)
|
||||||
|
cam = ZMQCamera(cfg)
|
||||||
|
logger.info("Connecting to %s @ %s:%d ...", name, args.server_address, args.port)
|
||||||
|
cam.connect()
|
||||||
|
cams[name] = cam
|
||||||
|
logger.info("All cameras connected. Press 'q' or ESC in the window to quit.")
|
||||||
|
|
||||||
|
win = "G1 cameras (q/ESC to quit)"
|
||||||
|
cv2.namedWindow(win, cv2.WINDOW_NORMAL)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
panes = []
|
||||||
|
for name, cam in cams.items():
|
||||||
|
frame = None
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
frame = cam.read_latest(max_age_ms=2000)
|
||||||
|
if frame is None:
|
||||||
|
panes.append(_placeholder(name, args.pane_height))
|
||||||
|
continue
|
||||||
|
bgr = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
|
||||||
|
scale = args.pane_height / bgr.shape[0]
|
||||||
|
disp = cv2.resize(bgr, (int(bgr.shape[1] * scale), args.pane_height))
|
||||||
|
cv2.putText(disp, name, (8, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
|
||||||
|
panes.append(disp)
|
||||||
|
|
||||||
|
if panes:
|
||||||
|
cv2.imshow(win, cv2.hconcat(panes))
|
||||||
|
key = cv2.waitKey(1) & 0xFF
|
||||||
|
if key in (ord("q"), 27):
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
for cam in cams.values():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
cam.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user