Compare commits

..

14 Commits

Author SHA1 Message Date
Pepijn
9c74cbe599 push to specific repo 2025-12-02 18:35:16 +01:00
Pepijn
fa3919a0ff add push to hub 2025-12-02 18:30:11 +01:00
Pepijn
e38346316b add aggregate 2025-12-02 18:27:50 +01:00
Pepijn
2a2b648891 fix use local dir 2025-12-02 18:11:20 +01:00
Pepijn
cf36f4b873 add localdir 2025-12-02 17:26:44 +01:00
Pepijn
e1ae51b02a Add conversion script 2025-12-02 16:51:36 +01:00
Michel Aractingi
797cd2725a fix pi05 forward compile (#2551) 2025-12-02 11:01:43 +01:00
Steven Palma
af4766b602 fix(ci): move hub artifacts to /mnt to avoid runners' No space left on device (#2564)
* fix(ci): move hub & lerobot artefacts to /mnt to avoid No space left on device in the future

* chore(ci): remove dh -h steps
2025-12-01 20:14:51 +01:00
Martino Russi
37f43df88a Feat/add unitree g1 robot (#2530)
* add unitree_g1_robot_class

* finish locomotion loading code

* precommit

* separate groot locomotion logic

* remove leftover locomotion variable, unify kp kd

* format config

* properly comment config, example locomotion and unitree_g1 class

* ready to review

* download policy from the hub in `examples/unitree_g1/gr00t_locomotion`

* fix linter

* make precommit happy, add ignore flags

* linter pt3

* linter pt4

* [done] make precommit happy

* fix linter 5

* add docs

* push utils

* feat(robots): add Unitree G1 humanoid support with ZMQ bridge (#2539)

* feat(robots): add Unitree G1 humanoid support with ZMQ bridge

- Use JSON + base64 serialization for secure communication instead of pickle
- Add documentation section
- Rename robot_server to run_g1_server
- Add dependecies to pyproject.toml

* nit in docs

* remove globals use

* cast robot data to int/float

* ensure robot is connected before changing mode

* temperature can be list, average in such case

---------

Co-authored-by: Martino Russi <nopyeps@gmail.com>

* style nit

* remove transform_imu_data

* remove scipy dependency

* modify toml, add external unitree_sdk2py dep

* return actions from send_action

* cleaning

* add instructions for local deployment

* Update src/lerobot/robots/unitree_g1/unitree_g1.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Martino Russi <77496684+nepyope@users.noreply.github.com>

* update config and readme

* update docs

* update docs

* remove torch import

* fix docs

* remove ip from docs

* add licence header

---------

Signed-off-by: Martino Russi <77496684+nepyope@users.noreply.github.com>
Co-authored-by: Michel Aractingi <michel.aractingi@huggingface.co>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-01 16:10:13 +01:00
Sota Nakamura
5f7b5f2817 remove the sampler cause the relative index is added (#2521)
Co-authored-by: Michel Aractingi <michel.aractingi@huggingface.co>
2025-11-30 22:28:32 +01:00
Steven Palma
c55fbe1b3e chore(dependencies): Bump lerobot to 0.4.3 (#2540) 2025-11-28 10:39:02 +01:00
Steven Palma
58f70b6bd3 fix(scripts): better prints teleop (#2538) 2025-11-27 16:54:17 +01:00
Steven Palma
b07160eb1b feat(utils): precise_sleep() less CPU hungry without sacrificing accuracy (#2526) 2025-11-26 17:42:16 +01:00
Caroline Pascal
648ea8f485 fix(benchmark) : fixing video benchmark (#2094)
* fix(time benchmark): removing deprecated TimeBenchmark dependency

* fix(typo): renaming frames in an up-to-date fashion

* feat(duets): rearanging crf and g parameters in a proper unique combination manner

* fix(segfault): fixing segfault by adding a lock in ThreadPoolExecutor

* chore(update) : update datasets, codecs and backends to the latest versions

* chore(unused files): removing unused files

* fix(dataset paths): fix datasets paths to live among lerobot datasets
2025-11-26 17:41:31 +01:00
34 changed files with 1080 additions and 517 deletions

View File

@@ -60,12 +60,19 @@ jobs:
runs-on: ubuntu-latest
env:
MUJOCO_GL: egl
HF_HOME: /mnt/cache/.cache/huggingface
HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
lfs: true
# NOTE(Steven): Mount to `/mnt` to avoid the limited storage on `/home`. Consider cleaning default SDKs or using self-hosted runners for more space.
# (As of 2024-06-10, the runner's `/home` has only 6.2 GB free—8% of its 72 GB total.)
- name: Setup /mnt storage
run: sudo chown -R $USER:$USER /mnt
# TODO(Steven): Evaluate the need of these dependencies
- name: Install apt dependencies
run: |

View File

@@ -58,12 +58,19 @@ jobs:
github.event_name == 'workflow_dispatch'
env:
MUJOCO_GL: egl
HF_HOME: /mnt/cache/.cache/huggingface
HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot
steps:
- uses: actions/checkout@v4
with:
lfs: true
persist-credentials: false
# NOTE(Steven): Mount to `/mnt` to avoid the limited storage on `/home`. Consider cleaning default SDKs or using self-hosted runners for more space.
# (As of 2024-06-10, the runner's `/home` has only 6.2 GB free—8% of its 72 GB total.)
- name: Setup /mnt storage
run: sudo chown -R $USER:$USER /mnt
- name: Install apt dependencies
run: |
sudo apt-get update && sudo apt-get install -y build-essential \

View File

@@ -45,12 +45,19 @@ jobs:
runs-on: ubuntu-latest
env:
MUJOCO_GL: egl
HF_HOME: /mnt/cache/.cache/huggingface
HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot
steps:
- uses: actions/checkout@v4
with:
lfs: true
persist-credentials: false
# NOTE(Steven): Mount to `/mnt` to avoid the limited storage on `/home`. Consider cleaning default SDKs or using self-hosted runners for more space.
# (As of 2024-06-10, the runner's `/home` has only 6.2 GB free—8% of its 72 GB total.)
- name: Setup /mnt storage
run: sudo chown -R $USER:$USER /mnt
- name: Install apt dependencies
run: |
sudo apt-get update && sudo apt-get install -y build-essential \

View File

@@ -1,94 +0,0 @@
#!/usr/bin/env python
# Copyright 2024 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.
import threading
import time
from contextlib import ContextDecorator
class TimeBenchmark(ContextDecorator):
"""
Measures execution time using a context manager or decorator.
This class supports both context manager and decorator usage, and is thread-safe for multithreaded
environments.
Args:
print: If True, prints the elapsed time upon exiting the context or completing the function. Defaults
to False.
Examples:
Using as a context manager:
>>> benchmark = TimeBenchmark()
>>> with benchmark:
... time.sleep(1)
>>> print(f"Block took {benchmark.result:.4f} seconds")
Block took approximately 1.0000 seconds
Using with multithreading:
```python
import threading
benchmark = TimeBenchmark()
def context_manager_example():
with benchmark:
time.sleep(0.01)
print(f"Block took {benchmark.result_ms:.2f} milliseconds")
threads = []
for _ in range(3):
t1 = threading.Thread(target=context_manager_example)
threads.append(t1)
for t in threads:
t.start()
for t in threads:
t.join()
```
Expected output:
Block took approximately 10.00 milliseconds
Block took approximately 10.00 milliseconds
Block took approximately 10.00 milliseconds
"""
def __init__(self, print=False):
self.local = threading.local()
self.print_time = print
def __enter__(self):
self.local.start_time = time.perf_counter()
return self
def __exit__(self, *exc):
self.local.end_time = time.perf_counter()
self.local.elapsed_time = self.local.end_time - self.local.start_time
if self.print_time:
print(f"Elapsed time: {self.local.elapsed_time:.4f} seconds")
return False
@property
def result(self):
return getattr(self.local, "elapsed_time", None)
@property
def result_ms(self):
return self.result * 1e3

View File

@@ -1,102 +0,0 @@
#!/usr/bin/env python
# Copyright 2024 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.
"""Capture video feed from a camera as raw images."""
import argparse
import datetime as dt
import os
import time
from pathlib import Path
import cv2
import rerun as rr
# see https://rerun.io/docs/howto/visualization/limit-ram
RERUN_MEMORY_LIMIT = os.getenv("LEROBOT_RERUN_MEMORY_LIMIT", "5%")
def display_and_save_video_stream(output_dir: Path, fps: int, width: int, height: int, duration: int):
rr.init("lerobot_capture_camera_feed")
rr.spawn(memory_limit=RERUN_MEMORY_LIMIT)
now = dt.datetime.now()
capture_dir = output_dir / f"{now:%Y-%m-%d}" / f"{now:%H-%M-%S}"
if not capture_dir.exists():
capture_dir.mkdir(parents=True, exist_ok=True)
# Opens the default webcam
cap = cv2.VideoCapture(0)
if not cap.isOpened():
print("Error: Could not open video stream.")
return
cap.set(cv2.CAP_PROP_FPS, fps)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
frame_index = 0
start_time = time.time()
while time.time() - start_time < duration:
ret, frame = cap.read()
if not ret:
print("Error: Could not read frame.")
break
rr.log("video/stream", rr.Image(frame), static=True)
cv2.imwrite(str(capture_dir / f"frame_{frame_index:06d}.png"), frame)
frame_index += 1
# Release the capture
cap.release()
# TODO(Steven): Add a graceful shutdown via a close() method for the Viewer context, though not currently supported in the Rerun API.
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"--output-dir",
type=Path,
default=Path("outputs/cam_capture/"),
help="Directory where the capture images are written. A subfolder named with the current date & time will be created inside it for each capture.",
)
parser.add_argument(
"--fps",
type=int,
default=30,
help="Frames Per Second of the capture.",
)
parser.add_argument(
"--width",
type=int,
default=1280,
help="Width of the captured images.",
)
parser.add_argument(
"--height",
type=int,
default=720,
help="Height of the captured images.",
)
parser.add_argument(
"--duration",
type=int,
default=20,
help="Duration in seconds for which the video stream should be captured.",
)
args = parser.parse_args()
display_and_save_video_stream(**vars(args))

View File

@@ -21,11 +21,13 @@ See the provided README.md or run `python benchmark/video/run_video_benchmark.py
import argparse
import datetime as dt
import itertools
import random
import shutil
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from threading import Lock
import einops
import numpy as np
@@ -35,13 +37,13 @@ import torch
from skimage.metrics import mean_squared_error, peak_signal_noise_ratio, structural_similarity
from tqdm import tqdm
from benchmarks.video.benchmark import TimeBenchmark
from lerobot.datasets.lerobot_dataset import LeRobotDataset
from lerobot.datasets.video_utils import (
decode_video_frames_torchvision,
decode_video_frames,
encode_video_frames,
)
from lerobot.utils.constants import OBS_IMAGE
from lerobot.utils.utils import TimerManager
BASE_ENCODING = OrderedDict(
[
@@ -86,7 +88,7 @@ def load_original_frames(imgs_dir: Path, timestamps: list[float], fps: int) -> t
frames = []
for ts in timestamps:
idx = int(ts * fps)
frame = PIL.Image.open(imgs_dir / f"frame_{idx:06d}.png")
frame = PIL.Image.open(imgs_dir / f"frame-{idx:06d}.png")
frame = torch.from_numpy(np.array(frame))
frame = frame.type(torch.float32) / 255
frame = einops.rearrange(frame, "h w c -> c h w")
@@ -97,21 +99,21 @@ def load_original_frames(imgs_dir: Path, timestamps: list[float], fps: int) -> t
def save_decoded_frames(
imgs_dir: Path, save_dir: Path, frames: torch.Tensor, timestamps: list[float], fps: int
) -> None:
if save_dir.exists() and len(list(save_dir.glob("frame_*.png"))) == len(timestamps):
if save_dir.exists() and len(list(save_dir.glob("frame-*.png"))) == len(timestamps):
return
save_dir.mkdir(parents=True, exist_ok=True)
for i, ts in enumerate(timestamps):
idx = int(ts * fps)
frame_hwc = (frames[i].permute((1, 2, 0)) * 255).type(torch.uint8).cpu().numpy()
PIL.Image.fromarray(frame_hwc).save(save_dir / f"frame_{idx:06d}_decoded.png")
shutil.copyfile(imgs_dir / f"frame_{idx:06d}.png", save_dir / f"frame_{idx:06d}_original.png")
PIL.Image.fromarray(frame_hwc).save(save_dir / f"frame-{idx:06d}_decoded.png")
shutil.copyfile(imgs_dir / f"frame-{idx:06d}.png", save_dir / f"frame-{idx:06d}_original.png")
def save_first_episode(imgs_dir: Path, dataset: LeRobotDataset) -> None:
episode_index = 0
ep_num_images = dataset.meta.episodes["length"][episode_index]
if imgs_dir.exists() and len(list(imgs_dir.glob("frame_*.png"))) == ep_num_images:
if imgs_dir.exists() and len(list(imgs_dir.glob("frame-*.png"))) == ep_num_images:
return
imgs_dir.mkdir(parents=True, exist_ok=True)
@@ -125,7 +127,7 @@ def save_first_episode(imgs_dir: Path, dataset: LeRobotDataset) -> None:
tqdm(imgs_dataset, desc=f"saving {dataset.repo_id} first episode images", leave=False)
):
img = item[img_keys[0]]
img.save(str(imgs_dir / f"frame_{i:06d}.png"), quality=100)
img.save(str(imgs_dir / f"frame-{i:06d}.png"), quality=100)
if i >= ep_num_images - 1:
break
@@ -149,18 +151,6 @@ def sample_timestamps(timestamps_mode: str, ep_num_images: int, fps: int) -> lis
return [idx / fps for idx in frame_indexes]
def decode_video_frames(
video_path: str,
timestamps: list[float],
tolerance_s: float,
backend: str,
) -> torch.Tensor:
if backend in ["pyav", "video_reader"]:
return decode_video_frames_torchvision(video_path, timestamps, tolerance_s, backend)
else:
raise NotImplementedError(backend)
def benchmark_decoding(
imgs_dir: Path,
video_path: Path,
@@ -172,8 +162,8 @@ def benchmark_decoding(
num_workers: int = 4,
save_frames: bool = False,
) -> dict:
def process_sample(sample: int):
time_benchmark = TimeBenchmark()
def process_sample(sample: int, lock: Lock):
time_benchmark = TimerManager(log=False)
timestamps = sample_timestamps(timestamps_mode, ep_num_images, fps)
num_frames = len(timestamps)
result = {
@@ -182,13 +172,13 @@ def benchmark_decoding(
"mse_values": [],
}
with time_benchmark:
with time_benchmark, lock:
frames = decode_video_frames(video_path, timestamps=timestamps, tolerance_s=5e-1, backend=backend)
result["load_time_video_ms"] = time_benchmark.result_ms / num_frames
result["load_time_video_ms"] = (time_benchmark.last * 1000) / num_frames
with time_benchmark:
original_frames = load_original_frames(imgs_dir, timestamps, fps)
result["load_time_images_ms"] = time_benchmark.result_ms / num_frames
result["load_time_images_ms"] = (time_benchmark.last * 1000) / num_frames
frames_np, original_frames_np = frames.numpy(), original_frames.numpy()
for i in range(num_frames):
@@ -215,8 +205,10 @@ def benchmark_decoding(
# A sample is a single set of decoded frames specified by timestamps_mode (e.g. a single frame, 2 frames, etc.).
# For each sample, we record metrics (loading time and quality metrics) which are then averaged over all samples.
# As these samples are independent, we run them in parallel threads to speed up the benchmark.
# Use a single shared lock for all worker threads
shared_lock = Lock()
with ThreadPoolExecutor(max_workers=num_workers) as executor:
futures = [executor.submit(process_sample, i) for i in range(num_samples)]
futures = [executor.submit(process_sample, i, shared_lock) for i in range(num_samples)]
for future in tqdm(as_completed(futures), total=num_samples, desc="samples", leave=False):
result = future.result()
load_times_video_ms.append(result["load_time_video_ms"])
@@ -358,24 +350,27 @@ def main(
imgs_dir = output_dir / "images" / dataset.repo_id.replace("/", "_")
# We only use the first episode
save_first_episode(imgs_dir, dataset)
for key, values in tqdm(encoding_benchmarks.items(), desc="encodings (g, crf)", leave=False):
for value in tqdm(values, desc=f"encodings ({key})", leave=False):
encoding_cfg = BASE_ENCODING.copy()
encoding_cfg["vcodec"] = video_codec
encoding_cfg["pix_fmt"] = pixel_format
for duet in [
dict(zip(encoding_benchmarks.keys(), unique_combination, strict=False))
for unique_combination in itertools.product(*encoding_benchmarks.values())
]:
encoding_cfg = BASE_ENCODING.copy()
encoding_cfg["vcodec"] = video_codec
encoding_cfg["pix_fmt"] = pixel_format
for key, value in duet.items():
encoding_cfg[key] = value
args_path = Path("_".join(str(value) for value in encoding_cfg.values()))
video_path = output_dir / "videos" / args_path / f"{repo_id.replace('/', '_')}.mp4"
benchmark_table += benchmark_encoding_decoding(
dataset,
video_path,
imgs_dir,
encoding_cfg,
decoding_benchmarks,
num_samples,
num_workers,
save_frames,
)
args_path = Path("_".join(str(value) for value in encoding_cfg.values()))
video_path = output_dir / "videos" / args_path / f"{repo_id.replace('/', '_')}.mp4"
benchmark_table += benchmark_encoding_decoding(
dataset,
video_path,
imgs_dir,
encoding_cfg,
decoding_benchmarks,
num_samples,
num_workers,
save_frames,
)
# Save intermediate results
benchmark_df = pd.DataFrame(benchmark_table, columns=headers)
@@ -409,9 +404,9 @@ if __name__ == "__main__":
nargs="*",
default=[
"lerobot/pusht_image",
"aliberts/aloha_mobile_shrimp_image",
"aliberts/paris_street",
"aliberts/kitchen",
"lerobot/aloha_mobile_shrimp_image",
"lerobot/paris_street",
"lerobot/kitchen",
],
help="Datasets repo-ids to test against. First episodes only are used. Must be images.",
)
@@ -419,7 +414,7 @@ if __name__ == "__main__":
"--vcodec",
type=str,
nargs="*",
default=["libx264", "hevc", "libsvtav1"],
default=["h264", "hevc", "libsvtav1"],
help="Video codecs to be tested",
)
parser.add_argument(
@@ -468,7 +463,7 @@ if __name__ == "__main__":
"--backends",
type=str,
nargs="*",
default=["pyav", "video_reader"],
default=["torchcodec", "pyav"],
help="Torchvision decoding backend to be tested.",
)
parser.add_argument(

View File

@@ -139,7 +139,7 @@ from lerobot.teleoperators import ( # noqa: F401
make_teleoperator_from_config,
so101_leader,
)
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import init_logging
from lerobot.envs.factory import make_env
@@ -196,7 +196,7 @@ def teleop_loop(teleop: Teleoperator, env: gym.Env, fps: int):
obs, info = env.reset()
dt_s = time.perf_counter() - loop_start
busy_wait(1 / fps - dt_s)
precise_sleep(1 / fps - dt_s)
loop_s = time.perf_counter() - loop_start
print(f"\ntime: {loop_s * 1e3:.2f}ms ({1 / loop_s:.0f} Hz)")

View File

@@ -393,7 +393,7 @@ import time
from lerobot.datasets.lerobot_dataset import LeRobotDataset
from lerobot.robots.so100_follower.config_so100_follower import SO100FollowerConfig
from lerobot.robots.so100_follower.so100_follower import SO100Follower
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say
episode_idx = 0
@@ -415,7 +415,7 @@ for idx in range(dataset.num_frames):
}
robot.send_action(action)
busy_wait(1.0 / dataset.fps - (time.perf_counter() - t0))
precise_sleep(1.0 / dataset.fps - (time.perf_counter() - t0))
robot.disconnect()
```

View File

@@ -1,14 +1,14 @@
# Unitree G1 Robot Setup and Control
This guide covers the complete setup process for the Unitree G1 humanoid robot, from initial connection to running locomotion policies.
This guide covers the complete setup process for the Unitree G1 humanoid, from initial connection to running gr00t_wbc locomotion.
## 🤖 About the Unitree G1
## About the Unitree G1
The Unitree G1 humanoid comes in two flavors: 29-DOF and 23-DOF humanoid robot capable of whole-body control, manipulation, and locomotion. In this first PR we introduce:
We offer support for both 29 and 23 DOF G1. In this first PR we introduce:
- **Low-level motor control** via DDS (Data Distribution Service)
- **`unitree g1` robot class, handling low level communication with the humanoid**
- **ZMQ socket bridge** for remote communication over WiFi, allowing one to deploy policies remotely instead of over ethernet or directly on the Orin
- **GR00T locomotion policiey** for bipedal walking and balance
- **GR00T locomotion policy** for bipedal walking and balance
---
@@ -25,7 +25,7 @@ sudo ip addr add 192.168.123.200/24 dev enp131s0
sudo ip link set enp131s0 up
```
> **Note**: The robot's Ethernet IP is fixed at `192.168.123.164`. Your computer must use `192.168.123.x` where x ≠ 164.
**Note**: The robot's Ethernet IP is fixed at `192.168.123.164`. Your computer must use `192.168.123.x` where x ≠ 164.
### Step 2: SSH into the Robot
@@ -103,76 +103,63 @@ ip a show wlan0
### Step 4: SSH Over WiFi
Once connected to WiFi, note the robot's IP address (e.g., `172.18.129.215`) and disconnect the Ethernet cable. You can now SSH over WiFi:
Once connected to WiFi, note the robot's IP address and disconnect the Ethernet cable. You can now SSH over WiFi:
```bash
ssh unitree@172.18.129.215
ssh unitree@<YOUR_ROBOT_IP>
# Password: 123
```
Replace `<YOUR_ROBOT_IP>` with your robot's actual WiFi IP address (e.g., `172.18.129.215`).
---
## Part 3: Robot Server Setup
The robot server introduced here acts as a DDS-to-ZMQ bridge, allowing your one to control the robot wirelessly.
### Step 1: Install LeRobot on the Orin
### Step 1: Copy Server Script to Robot
From your laptop, copy the robot server script:
SSH into the robot and install LeRobot:
```bash
# Copy the server script and its dependencies
scp src/lerobot/robots/unitree_g1/run_g1_server.py unitree@172.18.129.215:~/run_g1_server.py
scp src/lerobot/robots/unitree_g1/g1_utils.py unitree@172.18.129.215:~/g1_utils.py
ssh unitree@<YOUR_ROBOT_IP>
conda create -y -n lerobot python=3.10
conda activate lerobot
git clone https://github.com/huggingface/lerobot.git
cd lerobot
pip install -e '.[unitree_g1]'
git clone https://github.com/unitreerobotics/unitree_sdk2_python.git
cd unitree_sdk2_python && pip install -e .
```
### Step 2: Install Dependencies on Robot
**Note**: The Unitree SDK requires CycloneDDS v0.10.2 to be installed. See the [Unitree SDK documentation](https://github.com/unitreerobotics/unitree_sdk2_python) for details.
SSH into the robot and install required packages:
```bash
ssh unitree@172.18.129.215
# Install build tools and Python dependencies
sudo apt update
sudo apt install -y build-essential python3-dev python3-pip
# Install Python packages (pyzmq and Unitree SDK)
pip3 install pyzmq
pip3 install git+https://github.com/unitreerobotics/unitree_sdk2_python.git
```
> **Note**: The Unitree SDK requires CycloneDDS v0.10.2 to be installed. See the [Unitree SDK documentation](https://github.com/unitreerobotics/unitree_sdk2_python) for details.
### Step 3: Run the Robot Server
### Step 2: Run the Robot Server
On the robot:
```bash
python3 ~/run_g1_server.py
python src/lerobot/robots/unitree_g1/run_g1_server.py
```
You should see output like:
```
Robot server listening on:
Commands: tcp://*:6000 (PULL)
State: tcp://*:6001 (PUB)
DDS initialized, forwarding started...
```
> **Important**: Keep this terminal running. The server must be active for remote control.
**Important**: Keep this terminal running. The server must be active for remote control.
---
## 🚶 Part 4: Running GR00T Locomotion
## Part 4: Running GR00T Locomotion
With the robot server running, you can now control the robot from your laptop.
### Step 1: Install LeRobot with Unitree G1 Support (on your laptop)
### Step 1: Install LeRobot on your machine
```bash
conda create -y -n lerobot python=3.10
conda activate lerobot
git clone https://github.com/huggingface/lerobot.git
cd lerobot
pip install -e '.[unitree_g1]'
git clone https://github.com/unitreerobotics/unitree_sdk2_python.git
cd unitree_sdk2_python && pip install -e .
```
### Step 2: Update Robot IP in Config
@@ -181,40 +168,16 @@ Edit the config file to match your robot's WiFi IP:
```python
# In src/lerobot/robots/unitree_g1/config_unitree_g1.py
robot_ip: str = "172.18.129.215" # Your robot's WiFi IP
robot_ip: str = "<YOUR_ROBOT_IP>" # Replace with your robot's WiFi IP.
```
**Note**: When running directly on the G1 (not remotely), set `robot_ip: str = "127.0.0.1"` instead.
### Step 3: Run the Locomotion Policy
```bash
# Run GR00T locomotion controller (downloads policies from HuggingFace)
# Run GR00T locomotion controller
python examples/unitree_g1/gr00t_locomotion.py --repo-id "nepyope/GR00T-WholeBodyControl_g1"
# Or use the default repo (same as above):
python examples/unitree_g1/gr00t_locomotion.py
```
The script will:
1. Download Balance and Walk policies from the Hub (cached locally after first run)
2. Connect to the robot server over WiFi/ZMQ
3. Initialize the robot and locomotion controller
4. Gradually move legs to default standing position (3 seconds)
5. Start locomotion control loop at 50Hz in background thread
6. Accept commands from the wireless remote controller
**Expected output:**
```
INFO - Loading GR00T Balance policy...
INFO - Loading GR00T Walk policy...
INFO - [UnitreeG1] Initialize UnitreeG1...
INFO - [UnitreeG1] Connected to robot.
INFO - Reached default position (legs only)
INFO - Locomotion control thread started!
INFO - Robot initialized with GR00T locomotion policies
INFO - Locomotion controller running in background thread
INFO - Press Ctrl+C to stop
```
### Step 4: Control with Remote
@@ -224,7 +187,7 @@ INFO - Press Ctrl+C to stop
- **R1 button**: Raise waist height
- **R2 button**: Lower waist height
To stop, press `Ctrl+C` in the terminal.
Press `Ctrl+C` to stop the policy.
---
@@ -237,4 +200,4 @@ To stop, press `Ctrl+C` in the terminal.
---
_Last updated: November 2025_
_Last updated: December 2025_

View File

@@ -45,7 +45,7 @@ from lerobot.robots import ( # noqa: F401
so101_follower,
)
from lerobot.utils.constants import ACTION
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import (
init_logging,
log_say,
@@ -97,7 +97,7 @@ def replay(cfg: ReplayConfig):
robot.send_action(action)
dt_s = time.perf_counter() - start_episode_t
busy_wait(1 / dataset.fps - dt_s)
precise_sleep(1 / dataset.fps - dt_s)
robot.disconnect()

View File

@@ -0,0 +1,245 @@
#!/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.
"""
Aggregate EgoDex shards into a single dataset.
After distributed processing creates multiple shards, this script combines
them into a single unified dataset.
Reference: https://arxiv.org/abs/2505.11709, https://github.com/apple/ml-egodex
"""
import argparse
import logging
from pathlib import Path
from datatrove.executor import LocalPipelineExecutor
from datatrove.executor.slurm import SlurmPipelineExecutor
from datatrove.pipeline.base import PipelineStep
class AggregateEgoDexDatasets(PipelineStep):
"""Datatrove pipeline step for aggregating EgoDex shards."""
def __init__(
self,
repo_ids: list[str],
aggregated_repo_id: str,
local_dir: Path | str | None = None,
push_to_hub: bool = False,
hf_repo_id: str | None = None,
):
super().__init__()
self.repo_ids = repo_ids
self.aggr_repo_id = aggregated_repo_id
self.local_dir = Path(local_dir) if local_dir else None
self.push_to_hub = push_to_hub
self.hf_repo_id = hf_repo_id if hf_repo_id else aggregated_repo_id
def run(self, data=None, rank: int = 0, world_size: int = 1):
import logging
from lerobot.datasets.aggregate import aggregate_datasets
from lerobot.datasets.lerobot_dataset import LeRobotDataset
from lerobot.utils.utils import init_logging
init_logging()
# Only worker 0 performs aggregation (aggregate_datasets handles parallelism internally)
if rank == 0:
logging.info(f"Starting aggregation of {len(self.repo_ids)} shards into {self.aggr_repo_id}")
# Build roots list if local_dir is specified
roots = None
if self.local_dir:
roots = [self.local_dir / repo_id for repo_id in self.repo_ids]
# Filter to only existing directories
existing_roots = [r for r in roots if r.exists()]
if len(existing_roots) != len(self.repo_ids):
logging.warning(
f"Only {len(existing_roots)} of {len(self.repo_ids)} shard directories found. "
"Missing shards will be skipped."
)
# Update repo_ids to match existing roots
existing_repo_ids = [
repo_id for repo_id, r in zip(self.repo_ids, roots, strict=False) if r.exists()
]
roots = existing_roots
self.repo_ids = existing_repo_ids
if len(self.repo_ids) == 0:
logging.error("No shard directories found. Nothing to aggregate.")
return
aggr_root = self.local_dir / self.aggr_repo_id if self.local_dir else None
aggregate_datasets(
repo_ids=self.repo_ids,
aggr_repo_id=self.aggr_repo_id,
roots=roots,
aggr_root=aggr_root,
)
logging.info("Aggregation complete!")
# Push to Hugging Face Hub if requested
if self.push_to_hub:
logging.info(f"Pushing to Hugging Face Hub as {self.hf_repo_id}...")
dataset = LeRobotDataset(
repo_id=self.aggr_repo_id,
root=aggr_root,
)
# Update repo_id for pushing to different HF account if specified
dataset.repo_id = self.hf_repo_id
dataset.push_to_hub(
tags=["egodex", "hand", "dexterous", "lerobot"],
license="cc-by-nc-nd-4.0",
)
logging.info("Push to hub complete!")
else:
logging.info(f"Worker {rank} skipping - only worker 0 performs aggregation")
def make_aggregate_executor(
repo_id,
num_shards,
job_name,
logs_dir,
partition,
cpus_per_task,
mem_per_cpu,
local_dir,
push_to_hub,
hf_repo_id,
slurm=True,
):
"""Create executor for aggregating EgoDex shards."""
# Generate repo IDs for all shards
repo_ids = [f"{repo_id}_world_{num_shards}_rank_{rank}" for rank in range(num_shards)]
kwargs = {
"pipeline": [
AggregateEgoDexDatasets(repo_ids, repo_id, local_dir, push_to_hub, hf_repo_id),
],
"logging_dir": str(logs_dir / job_name),
}
if slurm:
kwargs.update(
{
"job_name": job_name,
"tasks": 1, # Only need 1 task for aggregation
"workers": 1, # Only need 1 worker
"time": "24:00:00", # 24 hours for aggregation
"partition": partition,
"cpus_per_task": cpus_per_task,
"sbatch_args": {"mem-per-cpu": mem_per_cpu},
}
)
executor = SlurmPipelineExecutor(**kwargs)
else:
kwargs.update(
{
"tasks": 1,
"workers": 1,
}
)
executor = LocalPipelineExecutor(**kwargs)
return executor
def main():
parser = argparse.ArgumentParser(
description="Aggregate EgoDex dataset shards into a single unified dataset."
)
parser.add_argument(
"--repo-id",
type=str,
required=True,
help="Repository identifier (base name without shard suffix, e.g., pepijn/egodex-test)",
)
parser.add_argument(
"--num-shards",
type=int,
required=True,
help="Number of shards to aggregate (must match --workers from slurm_port_egodex.py)",
)
parser.add_argument(
"--logs-dir",
type=Path,
default=Path("logs"),
help="Path to logs directory for datatrove",
)
parser.add_argument(
"--job-name",
type=str,
default="aggr_egodex",
help="Job name used in SLURM",
)
parser.add_argument(
"--slurm",
type=int,
default=1,
help="Launch over SLURM. Use --slurm 0 to launch locally (for debugging)",
)
parser.add_argument(
"--partition",
type=str,
help="SLURM partition (ideally CPU partition)",
)
parser.add_argument(
"--cpus-per-task",
type=int,
default=16,
help="Number of CPUs for aggregation task",
)
parser.add_argument(
"--mem-per-cpu",
type=str,
default="8G",
help="Memory per CPU for aggregation",
)
parser.add_argument(
"--local-dir",
type=Path,
default=None,
help="Local directory where shards are stored. If not specified, uses default HF cache.",
)
parser.add_argument(
"--push-to-hub",
action="store_true",
help="Push aggregated dataset to Hugging Face Hub after aggregation.",
)
parser.add_argument(
"--hf-repo-id",
type=str,
default=None,
help="Hugging Face repo ID for upload (e.g., username/dataset-name). Defaults to --repo-id.",
)
args = parser.parse_args()
kwargs = vars(args)
kwargs["slurm"] = kwargs.pop("slurm") == 1
aggregate_executor = make_aggregate_executor(**kwargs)
aggregate_executor.run()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,129 @@
#!/bin/bash
# Download EgoDex dataset
# Reference: https://arxiv.org/abs/2505.11709, https://github.com/apple/ml-egodex
#
# Usage: ./download_egodex.sh [output_dir] [parts...]
#
# Examples:
# ./download_egodex.sh ./data test # Download test set only (16 GB)
# ./download_egodex.sh ./data part1 part2 # Download training parts 1 and 2
# ./download_egodex.sh ./data all # Download everything (~1.7 TB)
#
# Available parts:
# test - Test set (16 GB)
# part1 - Training set part 1 (300 GB)
# part2 - Training set part 2 (300 GB)
# part3 - Training set part 3 (300 GB)
# part4 - Training set part 4 (300 GB)
# part5 - Training set part 5 (300 GB)
# extra - Additional data (200 GB)
# all - Download all parts (~1.7 TB total)
set -e
BASE_URL="https://ml-site.cdn-apple.com/datasets/egodex"
# Map part names to filenames
declare -A PART_FILES=(
["test"]="test.zip"
["part1"]="part1.zip"
["part2"]="part2.zip"
["part3"]="part3.zip"
["part4"]="part4.zip"
["part5"]="part5.zip"
["extra"]="extra.zip"
)
ALL_PARTS=("test" "part1" "part2" "part3" "part4" "part5" "extra")
usage() {
echo "Usage: $0 <output_dir> <parts...>"
echo ""
echo "Examples:"
echo " $0 ./data test # Download test set only (16 GB)"
echo " $0 ./data part1 part2 # Download training parts 1 and 2"
echo " $0 ./data all # Download everything (~1.7 TB)"
echo ""
echo "Available parts: test, part1, part2, part3, part4, part5, extra, all"
exit 1
}
download_part() {
local output_dir="$1"
local part="$2"
local filename="${PART_FILES[$part]}"
local url="${BASE_URL}/${filename}"
local output_file="${output_dir}/${filename}"
echo "----------------------------------------"
echo "Downloading: ${part} (${filename})"
echo "URL: ${url}"
echo "Output: ${output_file}"
echo "----------------------------------------"
# Download with curl, showing progress
curl -L --progress-bar "${url}" -o "${output_file}"
# Unzip
echo "Extracting ${filename}..."
unzip -q "${output_file}" -d "${output_dir}"
# Optionally remove zip file to save space
# Uncomment the next line if you want to delete zips after extraction
# rm "${output_file}"
echo "Done: ${part}"
echo ""
}
# Check arguments
if [ $# -lt 2 ]; then
usage
fi
OUTPUT_DIR="$1"
shift
# Create output directory
mkdir -p "${OUTPUT_DIR}"
# Determine which parts to download
PARTS_TO_DOWNLOAD=()
for arg in "$@"; do
if [ "$arg" == "all" ]; then
PARTS_TO_DOWNLOAD=("${ALL_PARTS[@]}")
break
elif [ -n "${PART_FILES[$arg]}" ]; then
PARTS_TO_DOWNLOAD+=("$arg")
else
echo "Error: Unknown part '${arg}'"
echo "Available parts: test, part1, part2, part3, part4, part5, extra, all"
exit 1
fi
done
if [ ${#PARTS_TO_DOWNLOAD[@]} -eq 0 ]; then
echo "Error: No valid parts specified"
usage
fi
echo "========================================"
echo "EgoDex Dataset Download"
echo "========================================"
echo "Output directory: ${OUTPUT_DIR}"
echo "Parts to download: ${PARTS_TO_DOWNLOAD[*]}"
echo "========================================"
echo ""
# Download each part
for part in "${PARTS_TO_DOWNLOAD[@]}"; do
download_part "${OUTPUT_DIR}" "${part}"
done
echo "========================================"
echo "Download complete!"
echo "Data saved to: ${OUTPUT_DIR}"
echo "========================================"

View File

@@ -0,0 +1,443 @@
#!/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.
"""
Distributed EgoDex dataset porting using SLURM and datatrove.
EgoDex is a large-scale dataset for egocentric dexterous manipulation collected
with ARKit on Apple Vision Pro. This script converts EgoDex data to LeRobot format.
Reference: https://arxiv.org/abs/2505.11709, https://github.com/apple/ml-egodex
"""
import argparse
from pathlib import Path
import cv2
import h5py
import mediapy as mpy
import numpy as np
from datatrove.executor import LocalPipelineExecutor
from datatrove.executor.slurm import SlurmPipelineExecutor
from datatrove.pipeline.base import PipelineStep
from lerobot.datasets.lerobot_dataset import LeRobotDataset
# Image dimensions
DEFAULT_IMAGE_HEIGHT = 1080
DEFAULT_IMAGE_WIDTH = 1920
class PortEgoDexShards(PipelineStep):
def __init__(
self,
raw_dir: Path | str,
repo_id: str,
local_dir: Path | str = None,
percentage: float = 100.0,
):
super().__init__()
self.raw_dir = Path(raw_dir)
self.repo_id = repo_id
self.local_dir = Path(local_dir) if local_dir else Path("data/local_datasets")
self.percentage = percentage
def run(self, data=None, rank: int = 0, world_size: int = 1):
from pathlib import Path
import cv2
import h5py
import mediapy as mpy
import numpy as np
from lerobot.datasets.lerobot_dataset import LeRobotDataset
from lerobot.utils.utils import init_logging
def _get_state_for_single_frame(transforms_group, frame_idx):
"""
Construct 48D hand state representation from EgoDex.
State vector composition (per hand = 24D, total = 48D):
- Wrist 3D position (3)
- Wrist orientation in 6D representation (6)
- 5 fingertip 3D positions (15)
"""
state_vector = []
fingertip_joints = {
"left": [
"leftThumbTip",
"leftIndexFingerTip",
"leftMiddleFingerTip",
"leftRingFingerTip",
"leftLittleFingerTip",
],
"right": [
"rightThumbTip",
"rightIndexFingerTip",
"rightMiddleFingerTip",
"rightRingFingerTip",
"rightLittleFingerTip",
],
}
for hand_side in ["left", "right"]:
hand_key = f"{hand_side}Hand"
hand_transform = transforms_group[hand_key][frame_idx]
# 1. Wrist 3D position
hand_position = hand_transform[:3, 3]
state_vector.extend(hand_position)
# 2. Wrist orientation in compact 6D representation
rotation_matrix = hand_transform[:3, :3]
rotation_6d = np.concatenate([rotation_matrix[:, 0], rotation_matrix[:, 1]])
state_vector.extend(rotation_6d)
# 3. 3D positions of 5 fingertips
for fingertip in fingertip_joints[hand_side]:
fingertip_transform = transforms_group[fingertip][frame_idx]
fingertip_pos = fingertip_transform[:3, 3]
state_vector.extend(fingertip_pos)
# Also return camera extrinsics for optional coordinate frame transformations
return np.array(state_vector, dtype=np.float32), transforms_group["camera"][frame_idx]
def get_state_and_action_from_egodex_annotations(demo):
"""
Convert EgoDex demo annotations into states and actions.
The "action" is the state at time t+1 (next-pose prediction).
"""
transforms_group = demo["transforms"]
total_frames = list(transforms_group.values())[0].shape[0]
states_list, extrinsics_list = [], []
for frame_idx in range(total_frames):
state_vector, extrinsics = _get_state_for_single_frame(transforms_group, frame_idx)
states_list.append(state_vector)
extrinsics_list.append(extrinsics.flatten()) # Flatten 4x4 to 16D
state = np.array(states_list, dtype=np.float32)
extrinsics = np.array(extrinsics_list, dtype=np.float32)
# Shift by 1 timestep to convert state to action
action = np.roll(state, -1, axis=0)
return state, action, extrinsics
def process_demo(hdf5_file_path, video_path):
"""Process a single EgoDex demo and return frames for LeRobot."""
video = mpy.read_video(str(video_path))
video = np.asarray(video)
num_frames = video.shape[0]
frames = []
with h5py.File(hdf5_file_path, "r") as demo:
state, action, extrinsics = get_state_and_action_from_egodex_annotations(demo)
# Get natural language task description
if demo.attrs.get("llm_type") == "reversible":
direction = demo.attrs.get("which_llm_description", "1")
lang_instruction = demo.attrs.get(
"llm_description" if direction == "1" else "llm_description2",
"manipulation task",
)
else:
lang_instruction = demo.attrs.get("llm_description", "manipulation task")
for step_idx in range(num_frames):
# Resize image to default dimensions
image_resized = cv2.resize(
video[step_idx],
(DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT),
interpolation=cv2.INTER_AREA,
)
frame = {
"task": lang_instruction,
"observation.image": image_resized,
"observation.state": state[step_idx],
"observation.extrinsics": extrinsics[step_idx],
"action": action[step_idx],
}
frames.append(frame)
return frames
init_logging()
# Define EgoDex features
EGODEX_FEATURES = {
"observation.image": {
"dtype": "video",
"shape": (DEFAULT_IMAGE_HEIGHT, DEFAULT_IMAGE_WIDTH, 3),
"names": ["height", "width", "rgb"],
},
"observation.state": {
"dtype": "float32",
"shape": (48,),
"names": [
# Left hand wrist position (3)
"left_wrist_x",
"left_wrist_y",
"left_wrist_z",
# Left hand wrist rotation 6D (6)
"left_rot_0",
"left_rot_1",
"left_rot_2",
"left_rot_3",
"left_rot_4",
"left_rot_5",
# Left fingertips (15)
"left_thumb_x",
"left_thumb_y",
"left_thumb_z",
"left_index_x",
"left_index_y",
"left_index_z",
"left_middle_x",
"left_middle_y",
"left_middle_z",
"left_ring_x",
"left_ring_y",
"left_ring_z",
"left_little_x",
"left_little_y",
"left_little_z",
# Right hand wrist position (3)
"right_wrist_x",
"right_wrist_y",
"right_wrist_z",
# Right hand wrist rotation 6D (6)
"right_rot_0",
"right_rot_1",
"right_rot_2",
"right_rot_3",
"right_rot_4",
"right_rot_5",
# Right fingertips (15)
"right_thumb_x",
"right_thumb_y",
"right_thumb_z",
"right_index_x",
"right_index_y",
"right_index_z",
"right_middle_x",
"right_middle_y",
"right_middle_z",
"right_ring_x",
"right_ring_y",
"right_ring_z",
"right_little_x",
"right_little_y",
"right_little_z",
],
},
"observation.extrinsics": {
"dtype": "float32",
"shape": (16,),
"names": [f"extrinsic_{i}" for i in range(16)],
},
"action": {
"dtype": "float32",
"shape": (48,),
"names": [f"action_{i}" for i in range(48)],
},
}
# 1. Discover all HDF5 files
files = sorted(list(self.raw_dir.rglob("*.hdf5")))
if not files:
print(f"No HDF5 files found in {self.raw_dir}")
return
# 2. Apply percentage filter
if self.percentage < 100:
num_files = max(1, int(len(files) * self.percentage / 100))
files = files[:num_files]
print(f"Processing {self.percentage}% of dataset: {num_files} files")
# 3. Assign files to this worker
my_files = files[rank::world_size]
if not my_files:
print(f"Rank {rank} has no files to process.")
return
print(f"Rank {rank} processing {len(my_files)} files out of {len(files)} total.")
# 4. Create a LeRobot dataset for this shard
shard_repo_id = f"{self.repo_id}_world_{world_size}_rank_{rank}"
shard_root = self.local_dir / shard_repo_id if self.local_dir else None
dataset = LeRobotDataset.create(
repo_id=shard_repo_id,
fps=30,
robot_type="hand",
features=EGODEX_FEATURES,
root=shard_root,
)
# 5. Process each file
for input_h5 in my_files:
try:
# Derive corresponding video path
video_path = input_h5.with_suffix(".mp4")
if not video_path.exists():
print(f"Warning: Video file not found for {input_h5}, skipping.")
continue
# Process demo and add frames
frames = process_demo(input_h5, video_path)
for frame in frames:
dataset.add_frame(frame)
dataset.save_episode()
# Clean up to avoid OOM
del frames
except Exception as e:
print(f"Error processing {input_h5}: {e}")
continue
# 6. Finalize the dataset
dataset.finalize()
def make_port_executor(
raw_dir,
repo_id,
job_name,
logs_dir,
workers,
partition,
cpus_per_task,
mem_per_cpu,
local_dir,
percentage,
slurm=True,
):
kwargs = {
"pipeline": [
PortEgoDexShards(raw_dir, repo_id, local_dir, percentage),
],
"logging_dir": str(logs_dir / job_name),
}
if slurm:
kwargs.update(
{
"job_name": job_name,
"tasks": workers,
"workers": workers,
"time": "10:00:00", # EgoDex is large, allow more time
"partition": partition,
"cpus_per_task": cpus_per_task,
"sbatch_args": {"mem-per-cpu": mem_per_cpu},
}
)
executor = SlurmPipelineExecutor(**kwargs)
else:
kwargs.update(
{
"tasks": workers,
"workers": 1, # Run locally sequentially for debugging
}
)
executor = LocalPipelineExecutor(**kwargs)
return executor
def main():
parser = argparse.ArgumentParser(
description="Convert EgoDex dataset to LeRobot format using SLURM."
)
parser.add_argument(
"--raw-dir",
type=Path,
required=True,
help="Directory containing input EgoDex data (HDF5 + MP4 files).",
)
parser.add_argument(
"--repo-id",
type=str,
required=True,
help="Repository identifier (e.g., user/egodex-lerobot).",
)
parser.add_argument(
"--logs-dir",
type=Path,
default=Path("logs"),
help="Path to logs directory.",
)
parser.add_argument(
"--job-name",
type=str,
default="port_egodex",
help="Job name used in SLURM.",
)
parser.add_argument(
"--slurm",
type=int,
default=1,
help="Launch over SLURM. Use --slurm 0 to launch sequentially (useful for debugging).",
)
parser.add_argument(
"--workers",
type=int,
default=50,
help="Number of SLURM workers.",
)
parser.add_argument(
"--partition",
type=str,
help="SLURM partition.",
)
parser.add_argument(
"--cpus-per-task",
type=int,
default=4,
help="Number of CPUs per worker.",
)
parser.add_argument(
"--mem-per-cpu",
type=str,
default="4G",
help="Memory per CPU.",
)
parser.add_argument(
"--percentage",
type=float,
default=100.0,
help="Percentage of dataset to process (e.g., 1.0 for 1%%). Useful for testing.",
)
parser.add_argument(
"--local-dir",
type=Path,
default=None,
help="Local directory to save the LeRobot dataset. Defaults to data/local_datasets.",
)
args = parser.parse_args()
kwargs = vars(args)
kwargs["slurm"] = kwargs.pop("slurm") == 1
port_executor = make_port_executor(**kwargs)
port_executor.run()
if __name__ == "__main__":
main()

View File

@@ -20,7 +20,7 @@ from lerobot.datasets.lerobot_dataset import LeRobotDataset
from lerobot.robots.lekiwi.config_lekiwi import LeKiwiClientConfig
from lerobot.robots.lekiwi.lekiwi_client import LeKiwiClient
from lerobot.utils.constants import ACTION
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say
EPISODE_IDX = 0
@@ -58,7 +58,7 @@ def main():
# Send action to robot
_ = robot.send_action(action)
busy_wait(max(1.0 / dataset.fps - (time.perf_counter() - t0), 0.0))
precise_sleep(max(1.0 / dataset.fps - (time.perf_counter() - t0), 0.0))
robot.disconnect()

View File

@@ -19,7 +19,7 @@ import time
from lerobot.robots.lekiwi import LeKiwiClient, LeKiwiClientConfig
from lerobot.teleoperators.keyboard.teleop_keyboard import KeyboardTeleop, KeyboardTeleopConfig
from lerobot.teleoperators.so100_leader import SO100Leader, SO100LeaderConfig
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data
FPS = 30
@@ -71,7 +71,7 @@ def main():
# Visualize
log_rerun_data(observation=observation, action=action)
busy_wait(max(1.0 / FPS - (time.perf_counter() - t0), 0.0))
precise_sleep(max(1.0 / FPS - (time.perf_counter() - t0), 0.0))
if __name__ == "__main__":

View File

@@ -29,7 +29,7 @@ from lerobot.robots.so100_follower.robot_kinematic_processor import (
)
from lerobot.robots.so100_follower.so100_follower import SO100Follower
from lerobot.utils.constants import ACTION
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say
EPISODE_IDX = 0
@@ -96,7 +96,7 @@ def main():
# Send action to robot
_ = robot.send_action(joint_action)
busy_wait(1.0 / dataset.fps - (time.perf_counter() - t0))
precise_sleep(1.0 / dataset.fps - (time.perf_counter() - t0))
# Clean up
robot.disconnect()

View File

@@ -32,7 +32,7 @@ from lerobot.robots.so100_follower.so100_follower import SO100Follower
from lerobot.teleoperators.phone.config_phone import PhoneConfig, PhoneOS
from lerobot.teleoperators.phone.phone_processor import MapPhoneActionToRobotAction
from lerobot.teleoperators.phone.teleop_phone import Phone
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data
FPS = 30
@@ -114,7 +114,7 @@ def main():
# Visualize
log_rerun_data(observation=phone_obs, action=joint_action)
busy_wait(max(1.0 / FPS - (time.perf_counter() - t0), 0.0))
precise_sleep(max(1.0 / FPS - (time.perf_counter() - t0), 0.0))
if __name__ == "__main__":

View File

@@ -30,7 +30,7 @@ from lerobot.robots.so100_follower.robot_kinematic_processor import (
)
from lerobot.robots.so100_follower.so100_follower import SO100Follower
from lerobot.utils.constants import ACTION
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say
EPISODE_IDX = 0
@@ -97,7 +97,7 @@ def main():
# Send action to robot
_ = robot.send_action(joint_action)
busy_wait(1.0 / dataset.fps - (time.perf_counter() - t0))
precise_sleep(1.0 / dataset.fps - (time.perf_counter() - t0))
# Clean up
robot.disconnect()

View File

@@ -32,7 +32,7 @@ from lerobot.robots.so100_follower.robot_kinematic_processor import (
from lerobot.robots.so100_follower.so100_follower import SO100Follower
from lerobot.teleoperators.so100_leader.config_so100_leader import SO100LeaderConfig
from lerobot.teleoperators.so100_leader.so100_leader import SO100Leader
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data
FPS = 30
@@ -120,7 +120,7 @@ def main():
# Visualize
log_rerun_data(observation=leader_ee_act, action=follower_joints_act)
busy_wait(max(1.0 / FPS - (time.perf_counter() - t0), 0.0))
precise_sleep(max(1.0 / FPS - (time.perf_counter() - t0), 0.0))
if __name__ == "__main__":

View File

@@ -1,5 +1,18 @@
#!/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.
"""
Example: GR00T Locomotion with Pre-loaded Policies
@@ -15,7 +28,6 @@ from collections import deque
import numpy as np
import onnxruntime as ort
import torch
from huggingface_hub import hf_hub_download
from lerobot.robots.unitree_g1.config_unitree_g1 import UnitreeG1Config
@@ -23,46 +35,15 @@ from lerobot.robots.unitree_g1.unitree_g1 import UnitreeG1
logger = logging.getLogger(__name__)
GROOT_DEFAULT_ANGLES = np.array(
[
-0.1,
0.0,
0.0,
0.3,
-0.2,
0.0, # left leg
-0.1,
0.0,
0.0,
0.3,
-0.2,
0.0, # right leg
0.0,
0.0,
0.0, # waist
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0, # left arm
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0, # right arm
],
dtype=np.float32,
)
GROOT_DEFAULT_ANGLES = np.zeros(29, dtype=np.float32)
GROOT_DEFAULT_ANGLES[[0, 6]] = -0.1 # hip pitch
GROOT_DEFAULT_ANGLES[[3, 9]] = 0.3 # knee
GROOT_DEFAULT_ANGLES[[4, 10]] = -0.2 # ankle pitch
G1_MODEL = "g1_23"
MISSING_JOINTS = []
G1_MODEL = "g1_23" # or "g1_29"
if G1_MODEL == "g1_23":
MISSING_JOINTS = [12, 14, 20, 21, 27, 28] # waist yaw/pitch, wrist pitch/yaw
elif G1_MODEL == "g1_29":
MISSING_JOINTS = [] # waist yaw/pitch, wrist pitch/yaw
LOCOMOTION_ACTION_SCALE = 0.25
@@ -214,7 +195,6 @@ class GrootLocomotionController:
self.groot_obs_stacked[start_idx:end_idx] = obs_frame
# Run policy inference (ONNX) with 516D stacked observation
obs_tensor = torch.from_numpy(self.groot_obs_stacked).unsqueeze(0)
cmd_magnitude = np.linalg.norm(self.locomotion_cmd)
@@ -223,7 +203,7 @@ class GrootLocomotionController:
) # balance/standing policy for small commands, walking policy for movement commands
# run policy inference
ort_inputs = {selected_policy.get_inputs()[0].name: obs_tensor.cpu().numpy()}
ort_inputs = {selected_policy.get_inputs()[0].name: np.expand_dims(self.groot_obs_stacked, axis=0)}
ort_outs = selected_policy.run(None, ort_inputs)
self.groot_action = ort_outs[0].squeeze()

View File

@@ -25,7 +25,7 @@ discord = "https://discord.gg/s3KuuzsPFb"
[project]
name = "lerobot"
version = "0.4.2"
version = "0.4.3"
description = "🤗 LeRobot: State-of-the-art Machine Learning for Real-World Robotics in Pytorch"
readme = "README.md"
license = { text = "Apache-2.0" }
@@ -109,7 +109,7 @@ hopejr = ["lerobot[feetech]", "lerobot[pygame-dep]"]
lekiwi = ["lerobot[feetech]", "pyzmq>=26.2.1,<28.0.0"]
unitree_g1 = [
"pyzmq>=26.2.1,<28.0.0",
"unitree_sdk2py @ git+https://github.com/unitreerobotics/unitree_sdk2_python.git",
"onnxruntime>=1.16.0"
]
reachy2 = ["reachy2_sdk>=1.0.14,<1.1.0"]
kinematics = ["lerobot[placo-dep]"]

View File

@@ -538,6 +538,8 @@ class PI05Pytorch(nn.Module): # see openpi `PI0Pytorch`
if config.compile_model:
torch.set_float32_matmul_precision("high")
self.sample_actions = torch.compile(self.sample_actions, mode=config.compile_mode)
# Also compile the main forward pass used during training
self.forward = torch.compile(self.forward, mode=config.compile_mode)
msg = """An incorrect transformer version is used, please create an issue on https://github.com/huggingface/lerobot/issues"""

View File

@@ -78,7 +78,7 @@ from lerobot.transport.utils import (
transitions_to_bytes,
)
from lerobot.utils.random_utils import set_seed
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.transition import (
Transition,
move_state_dict_to_device,
@@ -398,7 +398,7 @@ def act_with_policy(
if cfg.env.fps is not None:
dt_time = time.perf_counter() - start_time
busy_wait(1 / cfg.env.fps - dt_time)
precise_sleep(1 / cfg.env.fps - dt_time)
# Communication Functions - Group all gRPC/messaging functions

View File

@@ -74,7 +74,7 @@ from lerobot.teleoperators import (
from lerobot.teleoperators.teleoperator import Teleoperator
from lerobot.teleoperators.utils import TeleopEvents
from lerobot.utils.constants import ACTION, DONE, OBS_IMAGES, OBS_STATE, REWARD
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say
logging.basicConfig(level=logging.INFO)
@@ -114,7 +114,7 @@ def reset_follower_position(robot_arm: Robot, target_position: np.ndarray) -> No
for pose in trajectory:
action_dict = dict(zip(current_position_dict, pose, strict=False))
robot_arm.bus.sync_write("Goal_Position", action_dict)
busy_wait(0.015)
precise_sleep(0.015)
class RobotEnv(gym.Env):
@@ -238,7 +238,7 @@ class RobotEnv(gym.Env):
reset_follower_position(self.robot, np.array(self.reset_pose))
log_say("Reset the environment done.", play_sounds=True)
busy_wait(self.reset_time_s - (time.perf_counter() - start_time))
precise_sleep(self.reset_time_s - (time.perf_counter() - start_time))
super().reset(seed=seed, options=options)
@@ -713,7 +713,7 @@ def control_loop(
transition = env_processor(transition)
# Maintain fps timing
busy_wait(dt - (time.perf_counter() - step_start_time))
precise_sleep(dt - (time.perf_counter() - step_start_time))
if dataset is not None and cfg.dataset.push_to_hub:
logging.info("Pushing dataset to hub")
@@ -745,7 +745,7 @@ def replay_trajectory(
)
transition = action_processor(transition)
env.step(transition[TransitionKey.ACTION])
busy_wait(1 / cfg.env.fps - (time.perf_counter() - start_time))
precise_sleep(1 / cfg.env.fps - (time.perf_counter() - start_time))
@parser.wrap()

View File

@@ -18,93 +18,38 @@ from dataclasses import dataclass, field
from ..config import RobotConfig
_GAINS: dict[str, dict[str, list[float]]] = {
"left_leg": {
"kp": [150, 150, 150, 300, 40, 40],
"kd": [2, 2, 2, 4, 2, 2],
}, # pitch, roll, yaw, knee, ankle_pitch, ankle_roll
"right_leg": {"kp": [150, 150, 150, 300, 40, 40], "kd": [2, 2, 2, 4, 2, 2]},
"waist": {"kp": [250, 250, 250], "kd": [5, 5, 5]}, # yaw, roll, pitch
"left_arm": {"kp": [80, 80, 80, 80], "kd": [3, 3, 3, 3]}, # shoulder_pitch/roll/yaw, elbow
"left_wrist": {"kp": [40, 40, 40], "kd": [1.5, 1.5, 1.5]}, # roll, pitch, yaw
"right_arm": {"kp": [80, 80, 80, 80], "kd": [3, 3, 3, 3]},
"right_wrist": {"kp": [40, 40, 40], "kd": [1.5, 1.5, 1.5]},
"other": {"kp": [80, 80, 80, 80, 80, 80], "kd": [3, 3, 3, 3, 3, 3]},
}
def _build_gains() -> tuple[list[float], list[float]]:
"""Build kp and kd lists from body-part groupings."""
kp = [v for g in _GAINS.values() for v in g["kp"]]
kd = [v for g in _GAINS.values() for v in g["kd"]]
return kp, kd
_DEFAULT_KP, _DEFAULT_KD = _build_gains()
@RobotConfig.register_subclass("unitree_g1")
@dataclass
class UnitreeG1Config(RobotConfig):
# id: str = "unitree_g1"
kp: list[float] = field(default_factory=lambda: _DEFAULT_KP.copy())
kd: list[float] = field(default_factory=lambda: _DEFAULT_KD.copy())
kp: list = field(
default_factory=lambda: [
150,
150,
150,
300,
40,
40, # Left leg pitch, roll, yaw, knee, ankle pitch, ankle roll
150,
150,
150,
300,
40,
40, # Right leg pitch, roll, yaw, knee, ankle pitch, ankle roll
250,
250,
250, # Waist yaw, roll, pitch
80,
80,
80,
80, # Left shoulder pitch, roll, yaw, elbow (kp_low)
40,
40,
40, # Left wrist roll, pitch, yaw (kp_wrist)
80,
80,
80,
80, # Right shoulder pitch, roll, yaw, elbow (kp_low)
40,
40,
40, # Right wrist roll, pitch, yaw (kp_wrist)
80,
80,
80,
80,
80,
80, # Other
]
)
kd: list = field(
default_factory=lambda: [
2,
2,
2,
4,
2,
2, # Left leg pitch, roll, yaw, knee, ankle pitch, ankle roll
2,
2,
2,
4,
2,
2, # Right leg pitch, roll, yaw, knee, ankle pitch, ankle roll
5,
5,
5, # Waist yaw, roll, pitch
3,
3,
3,
3, # Left shoulder pitch, roll, yaw, elbow (kd_low)
1.5,
1.5,
1.5, # Left wrist roll, pitch, yaw (kd_wrist)
3,
3,
3,
3, # Right shoulder pitch, roll, yaw, elbow (kd_low)
1.5,
1.5,
1.5, # Right wrist roll, pitch, yaw (kd_wrist)
3,
3,
3,
3,
3,
3, # Other
]
)
control_dt = 1.0 / 250.0 # 250Hz
control_dt: float = 1.0 / 250.0 # 250Hz
# socket config for ZMQ bridge
robot_ip: str = "172.18.129.215"
robot_ip: str = "192.168.123.164"

View File

@@ -1,3 +1,19 @@
#!/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.
from enum import IntEnum
# ruff: noqa: N801, N815

View File

@@ -73,7 +73,9 @@ class IMUState:
# g1 observation class
@dataclass
class G1_29_LowState: # noqa: N801
motor_state: list[MotorState] = field(default_factory=lambda: [MotorState() for _ in range(G1_29_Num_Motors)])
motor_state: list[MotorState] = field(
default_factory=lambda: [MotorState() for _ in range(G1_29_Num_Motors)]
)
imu_state: IMUState = field(default_factory=IMUState)
wireless_remote: Any = None # Raw wireless remote data
mode_machine: int = 0 # Robot mode
@@ -147,7 +149,7 @@ class UnitreeG1(Robot):
self.crc = CRC()
self.msg = unitree_hg_msg_dds__LowCmd_()
self.msg.mode_pr = 0
# Wait for first state message to arrive
lowstate = None
while lowstate is None:
@@ -194,7 +196,7 @@ class UnitreeG1(Robot):
# Capture wireless remote data
lowstate.wireless_remote = msg.wireless_remote
# Capture mode_machine
lowstate.mode_machine = msg.mode_machine
@@ -202,7 +204,7 @@ class UnitreeG1(Robot):
current_time = time.time()
all_t_elapsed = current_time - start_time
sleep_time = max(0, (self.control_dt - all_t_elapsed)) # maintina constant control dt
sleep_time = max(0, (self.control_dt - all_t_elapsed)) # maintain constant control dt
time.sleep(sleep_time)
@cached_property
@@ -249,6 +251,7 @@ class UnitreeG1(Robot):
def send_action(self, action: dict[str, Any]) -> dict[str, Any]:
self.msg.crc = self.crc.Crc(action)
self.lowcmd_publisher.Write(action)
return action
def get_gravity_orientation(self, quaternion): # get gravity orientation from quaternion
"""Get gravity orientation from quaternion."""

View File

@@ -1,12 +1,18 @@
"""
ZMQ socket wrapper that mimics the Unitree SDK Channel interface.
#!/usr/bin/env python
This module provides a drop-in replacement for the Unitree SDK's DDS-based
ChannelPublisher and ChannelSubscriber, using ZMQ sockets instead. This allows
remote communication with the robot over WiFi via the robot_server bridge.
Uses JSON for secure serialization instead of pickle.
"""
# 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.
import base64
import json

View File

@@ -65,7 +65,6 @@ import argparse
import gc
import logging
import time
from collections.abc import Iterator
from pathlib import Path
import numpy as np
@@ -78,19 +77,6 @@ from lerobot.datasets.lerobot_dataset import LeRobotDataset
from lerobot.utils.constants import ACTION, DONE, OBS_STATE, REWARD
class EpisodeSampler(torch.utils.data.Sampler):
def __init__(self, dataset: LeRobotDataset, episode_index: int):
from_idx = dataset.meta.episodes["dataset_from_index"][episode_index]
to_idx = dataset.meta.episodes["dataset_to_index"][episode_index]
self.frame_ids = range(from_idx, to_idx)
def __iter__(self) -> Iterator:
return iter(self.frame_ids)
def __len__(self) -> int:
return len(self.frame_ids)
def to_hwc_uint8_numpy(chw_float32_torch: torch.Tensor) -> np.ndarray:
assert chw_float32_torch.dtype == torch.float32
assert chw_float32_torch.ndim == 3
@@ -119,12 +105,10 @@ def visualize_dataset(
repo_id = dataset.repo_id
logging.info("Loading dataloader")
episode_sampler = EpisodeSampler(dataset, episode_index)
dataloader = torch.utils.data.DataLoader(
dataset,
num_workers=num_workers,
batch_size=batch_size,
sampler=episode_sampler,
)
logging.info("Starting Rerun")

View File

@@ -50,7 +50,7 @@ from lerobot.teleoperators import ( # noqa: F401
make_teleoperator_from_config,
so100_leader,
)
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
@dataclass
@@ -114,7 +114,7 @@ def find_joint_and_ee_bounds(cfg: FindJointLimitsConfig):
print(f"Min joint pos position {np.round(min_pos, 4).tolist()}")
break
busy_wait(0.01)
precise_sleep(0.01)
def main():

View File

@@ -119,7 +119,7 @@ from lerobot.utils.control_utils import (
sanity_check_dataset_robot_compatibility,
)
from lerobot.utils.import_utils import register_third_party_devices
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import (
get_safe_torch_device,
init_logging,
@@ -364,7 +364,7 @@ def record_loop(
log_rerun_data(observation=obs_processed, action=action_values)
dt_s = time.perf_counter() - start_loop_t
busy_wait(1 / fps - dt_s)
precise_sleep(1 / fps - dt_s)
timestamp = time.perf_counter() - start_episode_t

View File

@@ -62,7 +62,7 @@ from lerobot.robots import ( # noqa: F401
)
from lerobot.utils.constants import ACTION
from lerobot.utils.import_utils import register_third_party_devices
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import (
init_logging,
log_say,
@@ -121,7 +121,7 @@ def replay(cfg: ReplayConfig):
_ = robot.send_action(processed_action)
dt_s = time.perf_counter() - start_episode_t
busy_wait(1 / dataset.fps - dt_s)
precise_sleep(1 / dataset.fps - dt_s)
robot.disconnect()

View File

@@ -89,7 +89,7 @@ from lerobot.teleoperators import ( # noqa: F401
so101_leader,
)
from lerobot.utils.import_utils import register_third_party_devices
from lerobot.utils.robot_utils import busy_wait
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import init_logging, move_cursor_up
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data
@@ -170,12 +170,13 @@ def teleop_loop(
# Display the final robot action that was sent
for motor, value in robot_action_to_send.items():
print(f"{motor:<{display_len}} | {value:>7.2f}")
move_cursor_up(len(robot_action_to_send) + 5)
move_cursor_up(len(robot_action_to_send) + 3)
dt_s = time.perf_counter() - loop_start
busy_wait(1 / fps - dt_s)
precise_sleep(1 / fps - dt_s)
loop_s = time.perf_counter() - loop_start
print(f"\ntime: {loop_s * 1e3:.2f}ms ({1 / loop_s:.0f} Hz)")
print(f"Teleop loop time: {loop_s * 1e3:.2f}ms ({1 / loop_s:.0f} Hz)")
move_cursor_up(1)
if duration is not None and time.perf_counter() - start >= duration:
return

View File

@@ -16,14 +16,40 @@ import platform
import time
def busy_wait(seconds):
if platform.system() == "Darwin" or platform.system() == "Windows":
# On Mac and Windows, `time.sleep` is not accurate and we need to use this while loop trick,
# but it consumes CPU cycles.
def precise_sleep(seconds: float, spin_threshold: float = 0.010, sleep_margin: float = 0.003):
"""
Wait for `seconds` with better precision than time.sleep alone at the expense of more CPU usage.
Parameters:
- seconds: duration to wait
- spin_threshold: if remaining <= spin_threshold -> spin; otherwise sleep (seconds). Default 10ms
- sleep_margin: when sleeping leave this much time before deadline to avoid oversleep. Default 3ms
Note:
The default parameters are chosen to prioritize timing accuracy over CPU usage for the common 30 FPS use case.
"""
if seconds <= 0:
return
system = platform.system()
# On macOS and Windows the scheduler / sleep granularity can make
# short sleeps inaccurate. Instead of burning CPU for the whole
# duration, sleep for most of the time and spin for the final few
# milliseconds to achieve good accuracy with much lower CPU usage.
if system in ("Darwin", "Windows"):
end_time = time.perf_counter() + seconds
while time.perf_counter() < end_time:
pass
while True:
remaining = end_time - time.perf_counter()
if remaining <= 0:
break
# If there's more than a couple milliseconds left, sleep most
# of the remaining time and leave a small margin for the final spin.
if remaining > spin_threshold:
# Sleep but avoid sleeping past the end by leaving a small margin.
time.sleep(max(remaining - sleep_margin, 0))
else:
# Final short spin to hit precise timing without long sleeps.
pass
else:
# On Linux time.sleep is accurate
if seconds > 0:
time.sleep(seconds)
# On Linux time.sleep is accurate enough for most uses
time.sleep(seconds)