Compare commits

..

1 Commits

Author SHA1 Message Date
Martino Russi 012bde51cb trim episodes 2026-02-25 23:09:33 +01:00
122 changed files with 2172 additions and 3772 deletions
+1 -8
View File
@@ -44,7 +44,7 @@ permissions:
# Sets up the environment variables # Sets up the environment variables
env: env:
UV_VERSION: "0.8.0" UV_VERSION: "0.8.0"
PYTHON_VERSION: "3.12" PYTHON_VERSION: "3.10"
# Ensures that only the latest commit for a PR or branch is built, canceling older runs. # Ensures that only the latest commit for a PR or branch is built, canceling older runs.
concurrency: concurrency:
@@ -61,7 +61,6 @@ jobs:
MUJOCO_GL: egl MUJOCO_GL: egl
HF_HOME: /mnt/cache/.cache/huggingface HF_HOME: /mnt/cache/.cache/huggingface
HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
@@ -90,11 +89,5 @@ jobs:
- name: Install lerobot with test extras - name: Install lerobot with test extras
run: uv sync --extra "test" run: uv sync --extra "test"
- name: Login to Hugging Face
if: env.HF_USER_TOKEN != ''
run: |
uv run hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
uv run hf auth whoami
- name: Run pytest - name: Run pytest
run: uv run pytest tests -vv --maxfail=10 run: uv run pytest tests -vv --maxfail=10
+2 -15
View File
@@ -37,7 +37,7 @@ permissions:
# Sets up the environment variables # Sets up the environment variables
env: env:
UV_VERSION: "0.8.0" UV_VERSION: "0.8.0"
PYTHON_VERSION: "3.12" PYTHON_VERSION: "3.10"
DOCKER_IMAGE_NAME: huggingface/lerobot-gpu DOCKER_IMAGE_NAME: huggingface/lerobot-gpu
# Ensures that only the latest action is built, canceling older runs. # Ensures that only the latest action is built, canceling older runs.
@@ -60,7 +60,6 @@ jobs:
MUJOCO_GL: egl MUJOCO_GL: egl
HF_HOME: /mnt/cache/.cache/huggingface HF_HOME: /mnt/cache/.cache/huggingface
HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
@@ -88,12 +87,6 @@ jobs:
- name: Install lerobot with all extras - name: Install lerobot with all extras
run: uv sync --extra all # TODO(Steven): Make flash-attn optional run: uv sync --extra all # TODO(Steven): Make flash-attn optional
- name: Login to Hugging Face
if: env.HF_USER_TOKEN != ''
run: |
uv run hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
uv run hf auth whoami
- name: Run pytest (all extras) - name: Run pytest (all extras)
run: uv run pytest tests -vv --maxfail=10 run: uv run pytest tests -vv --maxfail=10
@@ -169,7 +162,6 @@ jobs:
HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot
TORCH_HOME: /home/user_lerobot/.cache/torch TORCH_HOME: /home/user_lerobot/.cache/torch
TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
container: container:
image: ${{ needs.build-and-push-docker.outputs.image_tag }} # zizmor: ignore[unpinned-images] image: ${{ needs.build-and-push-docker.outputs.image_tag }} # zizmor: ignore[unpinned-images]
options: --gpus all --shm-size "16gb" options: --gpus all --shm-size "16gb"
@@ -181,13 +173,8 @@ jobs:
shell: bash shell: bash
working-directory: /lerobot working-directory: /lerobot
steps: steps:
- name: Login to Hugging Face
if: env.HF_USER_TOKEN != ''
run: |
hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
hf auth whoami
- name: Fix ptxas permissions - name: Fix ptxas permissions
run: chmod +x /lerobot/.venv/lib/python3.12/site-packages/triton/backends/nvidia/bin/ptxas run: chmod +x /lerobot/.venv/lib/python3.10/site-packages/triton/backends/nvidia/bin/ptxas
- name: Run pytest on GPU - name: Run pytest on GPU
run: pytest tests -vv --maxfail=10 run: pytest tests -vv --maxfail=10
- name: Run end-to-end tests - name: Run end-to-end tests
+4 -20
View File
@@ -28,7 +28,7 @@ on:
# Sets up the environment variables # Sets up the environment variables
env: env:
UV_VERSION: "0.8.0" UV_VERSION: "0.8.0"
PYTHON_VERSION: "3.12" PYTHON_VERSION: "3.10"
DOCKER_IMAGE_NAME_CPU: huggingface/lerobot-cpu:latest DOCKER_IMAGE_NAME_CPU: huggingface/lerobot-cpu:latest
DOCKER_IMAGE_NAME_GPU: huggingface/lerobot-gpu:latest DOCKER_IMAGE_NAME_GPU: huggingface/lerobot-gpu:latest
@@ -119,7 +119,6 @@ jobs:
HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot
TORCH_HOME: /home/user_lerobot/.cache/torch TORCH_HOME: /home/user_lerobot/.cache/torch
TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
container: container:
image: ${{ needs.build-docker-cpu-nightly.outputs.image_tag }} # zizmor: ignore[unpinned-images] image: ${{ needs.build-docker-cpu-nightly.outputs.image_tag }} # zizmor: ignore[unpinned-images]
options: --shm-size "16gb" options: --shm-size "16gb"
@@ -131,11 +130,6 @@ jobs:
shell: bash shell: bash
working-directory: /lerobot working-directory: /lerobot
steps: steps:
- name: Login to Hugging Face
if: env.HF_USER_TOKEN != ''
run: |
hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
hf auth whoami
- name: Run pytest on CPU - name: Run pytest on CPU
run: pytest tests -vv --maxfail=10 run: pytest tests -vv --maxfail=10
- name: Run end-to-end tests - name: Run end-to-end tests
@@ -152,7 +146,6 @@ jobs:
HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot
TORCH_HOME: /home/user_lerobot/.cache/torch TORCH_HOME: /home/user_lerobot/.cache/torch
TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
container: container:
image: ${{ needs.build-docker-gpu-nightly.outputs.image_tag }} # zizmor: ignore[unpinned-images] image: ${{ needs.build-docker-gpu-nightly.outputs.image_tag }} # zizmor: ignore[unpinned-images]
options: --gpus all --shm-size "16gb" options: --gpus all --shm-size "16gb"
@@ -164,11 +157,6 @@ jobs:
shell: bash shell: bash
working-directory: /lerobot working-directory: /lerobot
steps: steps:
- name: Login to Hugging Face
if: env.HF_USER_TOKEN != ''
run: |
hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
hf auth whoami
- name: Run pytest on GPU - name: Run pytest on GPU
run: pytest tests -vv --maxfail=10 run: pytest tests -vv --maxfail=10
- name: Run end-to-end tests - name: Run end-to-end tests
@@ -186,7 +174,6 @@ jobs:
TORCH_HOME: /home/user_lerobot/.cache/torch TORCH_HOME: /home/user_lerobot/.cache/torch
TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton
CUDA_VISIBLE_DEVICES: "0,1,2,3" CUDA_VISIBLE_DEVICES: "0,1,2,3"
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
container: container:
image: ${{ needs.build-docker-gpu-nightly.outputs.image_tag }} # zizmor: ignore[unpinned-images] image: ${{ needs.build-docker-gpu-nightly.outputs.image_tag }} # zizmor: ignore[unpinned-images]
options: --gpus all --shm-size "16gb" options: --gpus all --shm-size "16gb"
@@ -198,15 +185,12 @@ jobs:
shell: bash shell: bash
working-directory: /lerobot working-directory: /lerobot
steps: steps:
- name: Login to Hugging Face
if: env.HF_USER_TOKEN != ''
run: |
hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
hf auth whoami
- name: Verify GPU availability - name: Verify GPU availability
run: | run: |
nvidia-smi nvidia-smi
python -c "import torch; print(f'PyTorch CUDA available: {torch.cuda.is_available()}'); print(f'Number of GPUs: {torch.cuda.device_count()}')" python -c "import torch; print(f'PyTorch CUDA available: {torch.cuda.is_available()}'); print(f'Number of GPUs: {torch.cuda.device_count()}')"
- name: Run multi-GPU training tests - name: Run multi-GPU training tests
run: pytest -vv tests/training/ # TODO(Steven): Investigate why motors tests are failing in multi-GPU setup
run: pytest tests -vv --maxfail=10 --ignore=tests/motors/
timeout-minutes: 10
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: '3.12' python-version: '3.10'
- name: Run pre-commit hooks - name: Run pre-commit hooks
uses: pre-commit/action@v3.0.1 # zizmor: ignore[unpinned-uses] uses: pre-commit/action@v3.0.1 # zizmor: ignore[unpinned-uses]
+10 -2
View File
@@ -22,7 +22,7 @@ on:
# Sets up the environment variables # Sets up the environment variables
env: env:
UV_VERSION: "0.8.0" UV_VERSION: "0.8.0"
PYTHON_VERSION: "3.12" PYTHON_VERSION: "3.10"
jobs: jobs:
# This job builds the Python package and publishes it to PyPI # This job builds the Python package and publishes it to PyPI
@@ -45,7 +45,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: '3.12' python-version: '3.10'
- name: Extract Version - name: Extract Version
id: extract_info id: extract_info
@@ -83,6 +83,14 @@ jobs:
exit 1 exit 1
fi fi
- name: Remove Tags with Git dependencies
# TODO(Steven): Temporary patch to remove pi from PyPi 0.4.0 release due to its reliance on git dependencies.
run: |
echo "::info:: Checking for Git dependencies to remove from pyproject.toml..."
grep -E '@ git\+https|lerobot\[pi\]' pyproject.toml | sed 's/^/::warning:: Removing line: /' || true
sed -E -i '/@ git\+https|lerobot\[pi\]/d' pyproject.toml
echo "::info:: Git dependencies removed. Proceeding with build."
- name: Install build dependencies - name: Install build dependencies
run: python -m pip install build run: python -m pip install build
+2 -13
View File
@@ -29,7 +29,7 @@ permissions:
# Sets up the environment variables # Sets up the environment variables
env: env:
UV_VERSION: "0.8.0" UV_VERSION: "0.8.0"
PYTHON_VERSION: "3.12" PYTHON_VERSION: "3.10"
DOCKER_IMAGE_NAME: huggingface/lerobot-gpu:unbound DOCKER_IMAGE_NAME: huggingface/lerobot-gpu:unbound
# Ensures that only the latest action is built, canceling older runs. # Ensures that only the latest action is built, canceling older runs.
@@ -48,7 +48,6 @@ jobs:
MUJOCO_GL: egl MUJOCO_GL: egl
HF_HOME: /mnt/cache/.cache/huggingface HF_HOME: /mnt/cache/.cache/huggingface
HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
@@ -80,11 +79,7 @@ jobs:
- name: Install lerobot with all extras - name: Install lerobot with all extras
run: uv sync --extra all # TODO(Steven): Make flash-attn optional run: uv sync --extra all # TODO(Steven): Make flash-attn optional
- name: Login to Hugging Face
if: env.HF_USER_TOKEN != ''
run: |
uv run hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
uv run hf auth whoami
- name: Run pytest (all extras) - name: Run pytest (all extras)
run: uv run pytest tests -vv run: uv run pytest tests -vv
@@ -142,7 +137,6 @@ jobs:
HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot
TORCH_HOME: /home/user_lerobot/.cache/torch TORCH_HOME: /home/user_lerobot/.cache/torch
TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
container: container:
image: ${{ needs.build-and-push-docker.outputs.image_tag }} # zizmor: ignore[unpinned-images] image: ${{ needs.build-and-push-docker.outputs.image_tag }} # zizmor: ignore[unpinned-images]
options: --gpus all --shm-size "16gb" options: --gpus all --shm-size "16gb"
@@ -154,11 +148,6 @@ jobs:
shell: bash shell: bash
working-directory: /lerobot working-directory: /lerobot
steps: steps:
- name: Login to Hugging Face
if: env.HF_USER_TOKEN != ''
run: |
hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
hf auth whoami
- name: Run pytest on GPU - name: Run pytest on GPU
run: pytest tests -vv run: pytest tests -vv
- name: Run end-to-end tests - name: Run end-to-end tests
+2 -2
View File
@@ -13,7 +13,7 @@
# limitations under the License. # limitations under the License.
default_language_version: default_language_version:
python: python3.12 python: python3.10
exclude: "tests/artifacts/.*\\.safetensors$" exclude: "tests/artifacts/.*\\.safetensors$"
@@ -55,7 +55,7 @@ repos:
rev: v3.21.0 rev: v3.21.0
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py312-plus] args: [--py310-plus]
##### Markdown Quality ##### ##### Markdown Quality #####
- repo: https://github.com/rbubley/mirrors-prettier - repo: https://github.com/rbubley/mirrors-prettier
-25
View File
@@ -1,25 +0,0 @@
# AI Usage Policy
The LeRobot project welcomes contributions from everyone, and we have a few guidelines regarding AI usage to ensure high code quality, clear communication, and a healthy open-source ecosystem:
- **Please disclose significant AI assistance.** If you used AI tools (e.g., Copilot, Claude, Cursor, ChatGPT) to generate a substantial portion of your code or text, let us know in your PR description. Transparency helps us review your changes more effectively.
- **Own your code (The Human-in-the-Loop).** You must fully understand all the changes you are proposing. If you cannot explain what your AI-assisted code does or how it interacts with LeRobot's broader architecture, please take the time to learn and test it before submitting.
- **Keep issues and discussions focused.** You are welcome to use AI to help draft issues or PR descriptions, but please review and edit them carefully before posting. AI can often be overly verbose; trimming the noise and getting straight to the point helps our maintainers address your needs faster.
Our core maintainers also use AI tools to aid their workflows, but they do so while bringing deep contextual knowledge of the LeRobot codebase to validate the output. We ask all contributors to apply that same level of rigor.
## Remember the Human Maintainers
Please remember that LeRobot is maintained by a dedicated team of humans.
Every discussion, issue, and pull request is read and reviewed by real people. While AI tools can generate thousands of lines of code in seconds, reviewing that code still takes human time and energy. Submitting unverified or low-effort AI output puts an unfair burden on our maintainers.
Today, the quality of the AI output still heavily depends on the developer driving the tool. We ask that you respect our maintainers' time by thoroughly vetting, testing, and refining your submissions.
## AI is Welcome Here
LeRobot operates at the cutting edge of AI and robotics, and many of our maintainers actively embrace AI coding assistants as valuable productivity tools. We are a pro-AI project!
Our reason for having an AI policy is not an anti-AI stance. Rather, it exists to ensure that AI is used to enhance human contributions, not replace them with unverified noise. It's about how the tools are used, not the tools themselves.
We value the unique human insight you bring to the LeRobot community. Let AI empower your workflow, but always let your own judgment take the wheel.
+1 -1
View File
@@ -2,7 +2,7 @@
Everyone is welcome to contribute, and we value everybody's contribution. Code is not the only way to help the community. Answering questions, helping others, reaching out, and improving the documentation are immensely valuable. Everyone is welcome to contribute, and we value everybody's contribution. Code is not the only way to help the community. Answering questions, helping others, reaching out, and improving the documentation are immensely valuable.
Whichever way you choose to contribute, please be mindful to respect our [code of conduct](./CODE_OF_CONDUCT.md) and our [AI policy](./AI_POLICY.md). Whichever way you choose to contribute, please be mindful to respect our [code of conduct](./CODE_OF_CONDUCT.md).
## Ways to Contribute ## Ways to Contribute
+1 -18
View File
@@ -135,7 +135,7 @@ Learn how to implement your own simulation environment or benchmark and distribu
## Citation ## Citation
If you use LeRobot in your project, please cite the GitHub repository to acknowledge the ongoing development and contributors: If you use LeRobot in your research, please cite:
```bibtex ```bibtex
@misc{cadene2024lerobot, @misc{cadene2024lerobot,
@@ -146,23 +146,6 @@ If you use LeRobot in your project, please cite the GitHub repository to acknowl
} }
``` ```
If you are referencing our research or the academic paper, please also cite our ICLR publication:
<details>
<summary><b>ICLR 2026 Paper</b></summary>
```bibtex
@inproceedings{cadenelerobot,
title={LeRobot: An Open-Source Library for End-to-End Robot Learning},
author={Cadene, Remi and Alibert, Simon and Capuano, Francesco and Aractingi, Michel and Zouitine, Adil and Kooijmans, Pepijn and Choghari, Jade and Russi, Martino and Pascal, Caroline and Palma, Steven and Shukor, Mustafa and Moss, Jess and Soare, Alexander and Aubakirova, Dana and Lhoest, Quentin and Gallou\'edec, Quentin and Wolf, Thomas},
booktitle={The Fourteenth International Conference on Learning Representations},
year={2026},
url={https://arxiv.org/abs/2602.22818}
}
```
</details>
## Contribute ## Contribute
We welcome contributions from everyone in the community! To get started, please read our [CONTRIBUTING.md](./CONTRIBUTING.md) guide. Whether you're adding a new feature, improving documentation, or fixing a bug, your help and feedback are invaluable. We're incredibly excited about the future of open-source robotics and can't wait to work with you on what's next—thank you for your support! We welcome contributions from everyone in the community! To get started, please read our [CONTRIBUTING.md](./CONTRIBUTING.md) guide. Whether you're adding a new feature, improving documentation, or fixing a bug, your help and feedback are invaluable. We're incredibly excited about the future of open-source robotics and can't wait to work with you on what's next—thank you for your support!
+1 -1
View File
@@ -24,7 +24,7 @@ ARG OS_VERSION=22.04
FROM nvidia/cuda:${CUDA_VERSION}-base-ubuntu${OS_VERSION} FROM nvidia/cuda:${CUDA_VERSION}-base-ubuntu${OS_VERSION}
# Define Python version argument # Define Python version argument
ARG PYTHON_VERSION=3.12 ARG PYTHON_VERSION=3.10
# Configure environment variables # Configure environment variables
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
+1 -1
View File
@@ -19,7 +19,7 @@
# docker run -it --rm lerobot-user # docker run -it --rm lerobot-user
# Configure the base image # Configure the base image
ARG PYTHON_VERSION=3.12 ARG PYTHON_VERSION=3.10
FROM python:${PYTHON_VERSION}-slim FROM python:${PYTHON_VERSION}-slim
# Configure environment variables # Configure environment variables
+1 -1
View File
@@ -48,7 +48,7 @@ python -m lerobot.async_inference.robot_client \
--task="dummy" \ # POLICY: The task to run the policy on (`Fold my t-shirt`). Not necessarily defined for all policies, such as `act` --task="dummy" \ # POLICY: The task to run the policy on (`Fold my t-shirt`). Not necessarily defined for all policies, such as `act`
--policy_type=your_policy_type \ # POLICY: the type of policy to run (smolvla, act, etc) --policy_type=your_policy_type \ # POLICY: the type of policy to run (smolvla, act, etc)
--pretrained_name_or_path=user/model \ # POLICY: the model name/path on server to the checkpoint to run (e.g., lerobot/smolvla_base) --pretrained_name_or_path=user/model \ # POLICY: the model name/path on server to the checkpoint to run (e.g., lerobot/smolvla_base)
--policy_device=mps \ # POLICY: the device to run the policy on, on the server (cuda, mps, xpu, cpu) --policy_device=mps \ # POLICY: the device to run the policy on, on the server
--actions_per_chunk=50 \ # POLICY: the number of actions to output at once --actions_per_chunk=50 \ # POLICY: the number of actions to output at once
--chunk_size_threshold=0.5 \ # CLIENT: the threshold for the chunk size before sending a new observation to the server --chunk_size_threshold=0.5 \ # CLIENT: the threshold for the chunk size before sending a new observation to the server
--aggregate_fn_name=weighted_average \ # CLIENT: the function to aggregate actions on overlapping portions --aggregate_fn_name=weighted_average \ # CLIENT: the function to aggregate actions on overlapping portions
+4 -4
View File
@@ -32,7 +32,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
# your policy-specific dependencies # your policy-specific dependencies
] ]
requires-python = ">= 3.12" requires-python = ">= 3.11"
[build-system] [build-system]
build-backend = # your-build-backend build-backend = # your-build-backend
@@ -82,7 +82,7 @@ Create your policy implementation by inheriting from LeRobot's base `PreTrainedP
# modeling_my_custom_policy.py # modeling_my_custom_policy.py
import torch import torch
import torch.nn as nn import torch.nn as nn
from typing import Any from typing import Dict, Any
from lerobot.policies.pretrained import PreTrainedPolicy from lerobot.policies.pretrained import PreTrainedPolicy
from .configuration_my_custom_policy import MyCustomPolicyConfig from .configuration_my_custom_policy import MyCustomPolicyConfig
@@ -91,7 +91,7 @@ class MyCustomPolicy(PreTrainedPolicy):
config_class = MyCustomPolicyConfig config_class = MyCustomPolicyConfig
name = "my_custom_policy" name = "my_custom_policy"
def __init__(self, config: MyCustomPolicyConfig, dataset_stats: dict[str, Any] = None): def __init__(self, config: MyCustomPolicyConfig, dataset_stats: Dict[str, Any] = None):
super().__init__(config, dataset_stats) super().__init__(config, dataset_stats)
... ...
``` ```
@@ -102,7 +102,7 @@ Create processor functions:
```python ```python
# processor_my_custom_policy.py # processor_my_custom_policy.py
from typing import Any from typing import Dict, Any
import torch import torch
+3 -3
View File
@@ -13,7 +13,7 @@ The EarthRover Mini Plus is a fully open source mobile robot that connects throu
### Hardware ### Hardware
- EarthRover Mini robot - EarthRover Mini robot
- Computer with Python 3.12 or newer - Computer with Python 3.10 or newer
- Internet connection - Internet connection
### Setting Up the Frodobots SDK ### Setting Up the Frodobots SDK
@@ -170,13 +170,13 @@ Once you can drive the robot well, you can start recording data to train AI mode
We use Hugging Face to store your data online. First, log in with your token from [Hugging Face settings](https://huggingface.co/settings/tokens): We use Hugging Face to store your data online. First, log in with your token from [Hugging Face settings](https://huggingface.co/settings/tokens):
```bash ```bash
hf auth login --token ${HUGGINGFACE_TOKEN} --add-to-git-credential huggingface-cli login --token ${HUGGINGFACE_TOKEN} --add-to-git-credential
``` ```
Store your Hugging Face username: Store your Hugging Face username:
```bash ```bash
HF_USER=$(hf auth whoami | awk -F': *' 'NR==1 {print $2}') HF_USER=$(huggingface-cli whoami | head -n 1)
echo $HF_USER echo $HF_USER
``` ```
+2 -2
View File
@@ -155,10 +155,10 @@ Upload your repository to Hugging Face:
pip install huggingface_hub pip install huggingface_hub
# Login to Hugging Face # Login to Hugging Face
hf auth login huggingface-cli login
# Create a new repository # Create a new repository
hf repo create my-org/my-custom-env huggingface-cli repo create my-custom-env --type space --org my-org
# Initialize git and push # Initialize git and push
git init git init
+4 -4
View File
@@ -159,7 +159,7 @@ We use the Hugging Face hub features for uploading your dataset. If you haven't
Add your token to the CLI by running this command: Add your token to the CLI by running this command:
```bash ```bash
hf auth login --token ${HUGGINGFACE_TOKEN} --add-to-git-credential huggingface-cli login --token ${HUGGINGFACE_TOKEN} --add-to-git-credential
``` ```
Then store your Hugging Face repository name in a variable: Then store your Hugging Face repository name in a variable:
@@ -327,7 +327,7 @@ You can look for other LeRobot datasets on the hub by searching for `LeRobot` [t
You can also push your local dataset to the Hub manually, running: You can also push your local dataset to the Hub manually, running:
```bash ```bash
hf upload ${HF_USER}/record-test ~/.cache/huggingface/lerobot/{repo-id} --repo-type dataset huggingface-cli upload ${HF_USER}/record-test ~/.cache/huggingface/lerobot/{repo-id} --repo-type dataset
``` ```
#### Record function #### Record function
@@ -491,7 +491,7 @@ If your local computer doesn't have a powerful GPU you could utilize Google Cola
Once training is done, upload the latest checkpoint with: Once training is done, upload the latest checkpoint with:
```bash ```bash
hf upload ${HF_USER}/act_so101_test \ huggingface-cli upload ${HF_USER}/act_so101_test \
outputs/train/act_so101_test/checkpoints/last/pretrained_model outputs/train/act_so101_test/checkpoints/last/pretrained_model
``` ```
@@ -499,7 +499,7 @@ You can also upload intermediate checkpoints with:
```bash ```bash
CKPT=010000 CKPT=010000
hf upload ${HF_USER}/act_so101_test${CKPT} \ huggingface-cli upload ${HF_USER}/act_so101_test${CKPT} \
outputs/train/act_so101_test/checkpoints/${CKPT}/pretrained_model outputs/train/act_so101_test/checkpoints/${CKPT}/pretrained_model
``` ```
+13 -60
View File
@@ -1,8 +1,8 @@
# Installation # Installation
This guide uses `conda` (via miniforge) to manage environments (recommended). If you prefer another environment manager (e.g. `uv`, `venv`), ensure you have Python >=3.12 and `ffmpeg` installed with the `libsvtav1` encoder, then skip ahead to [Environment Setup](#step-2-environment-setup). This guide uses conda (via miniforge) to manage environments. If you prefer another environment manager (e.g. `uv`, `venv`), ensure you have Python >=3.10 and ffmpeg installed with the `libsvtav1` encoder, then skip ahead to [Install LeRobot](#step-3-install-lerobot-).
## Step 1 (`conda` only): Install [`miniforge`](https://conda-forge.org/download/) ## Step 1: Install [`miniforge`](https://conda-forge.org/download/)
```bash ```bash
wget "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh" wget "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"
@@ -11,47 +11,22 @@ bash Miniforge3-$(uname)-$(uname -m).sh
## Step 2: Environment Setup ## Step 2: Environment Setup
Create a virtual environment with Python 3.12: Create a virtual environment with Python 3.10, using conda:
<!-- prettier-ignore-start -->
<hfoptions id="create_venv">
<hfoption id="conda">
```bash ```bash
conda create -y -n lerobot python=3.12 conda create -y -n lerobot python=3.10
``` ```
</hfoption>
<hfoption id="uv"> Then activate your conda environment, you have to do this each time you open a shell to use lerobot:
```bash ```bash
uv python install 3.12
uv venv --python 3.12
```
</hfoption>
</hfoptions>
<!-- prettier-ignore-end -->
Then activate your virtual environment, you have to do this each time you open a shell to use lerobot:
<!-- prettier-ignore-start -->
<hfoptions id="activate_venv">
<hfoption id="conda">```bash
conda activate lerobot conda activate lerobot
```</hfoption>
<hfoption id="uv">
```bash
# Linux/macOSsource
source .venv/bin/activate
# Windows PowerShell
source .venv\Scripts\Activate.ps1
``` ```
</hfoption>
</hfoptions>
<!-- prettier-ignore-end -->
When using `conda`, install `ffmpeg` in your environment: When using `conda`, install `ffmpeg` in your environment:
```bash ```bash
conda install ffmpeg -c conda-forge conda install ffmpeg -c conda-forge
ffmpeg -version # ffmpeg 8.X is not yet supported !
``` ```
> [!TIP] > [!TIP]
@@ -72,9 +47,6 @@ ffmpeg -version # ffmpeg 8.X is not yet supported !
> conda install evdev -c conda-forge > conda install evdev -c conda-forge
> ``` > ```
> [!IMPORTANT]
> If you are using `uv` you will have to install `ffmpeg` system-wide (outside of the virtual environment). You rely on `uv` and `torchcodec` ability to dynamically link to the system `ffmpeg`.
## Step 3: Install LeRobot 🤗 ## Step 3: Install LeRobot 🤗
### From Source ### From Source
@@ -88,45 +60,23 @@ cd lerobot
Then, install the library in editable mode. This is useful if you plan to contribute to the code. Then, install the library in editable mode. This is useful if you plan to contribute to the code.
<!-- prettier-ignore-start -->
<hfoptions id="install_lerobot_src">
<hfoption id="conda">
```bash ```bash
pip install -e . pip install -e .
``` ```
</hfoption>
<hfoption id="uv">
```bash
uv pip install -e .
```
</hfoption>
</hfoptions>
<!-- prettier-ignore-end -->
### Installation from PyPI ### Installation from PyPI
**Core Library:** **Core Library:**
Install the base package with: Install the base package with:
<!-- prettier-ignore-start -->
<hfoptions id="install_lerobot_pypi">
<hfoption id="conda">
```bash ```bash
pip install lerobot pip install lerobot
``` ```
</hfoption>
<hfoption id="uv">
```bash
uv pip install lerobot
```
</hfoption>
</hfoptions>
<!-- prettier-ignore-end -->
_This installs only the default dependencies._ _This installs only the default dependencies._
**Extra Features:** **Extra Features:**
To install additional functionality, use one of the following (If you are using `uv`, replace `pip install` with `uv pip install` in the commands below.): To install additional functionality, use one of the following:
```bash ```bash
pip install 'lerobot[all]' # All available features pip install 'lerobot[all]' # All available features
@@ -140,10 +90,13 @@ _Replace `[...]` with your desired features._
For a full list of optional dependencies, see: For a full list of optional dependencies, see:
https://pypi.org/project/lerobot/ https://pypi.org/project/lerobot/
> [!NOTE]
> For lerobot 0.4.0, if you want to install pi, you will have to do: `pip install "lerobot[pi]@git+https://github.com/huggingface/lerobot.git"`
### Troubleshooting ### Troubleshooting
If you encounter build errors, you may need to install additional dependencies: `cmake`, `build-essential`, and `ffmpeg libs`. If you encounter build errors, you may need to install additional dependencies: `cmake`, `build-essential`, and `ffmpeg libs`.
To install these for Linux run: To install these for linux run:
```bash ```bash
sudo apt-get install cmake build-essential python3-dev pkg-config libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev sudo apt-get install cmake build-essential python3-dev pkg-config libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev
@@ -153,7 +106,7 @@ For other systems, see: [Compiling PyAV](https://pyav.org/docs/develop/overview/
## Optional dependencies ## Optional dependencies
LeRobot provides optional extras for specific functionalities. Multiple extras can be combined (e.g., `.[aloha,feetech]`). For all available extras, refer to `pyproject.toml`. If you are using `uv`, replace `pip install` with `uv pip install` in the commands below. LeRobot provides optional extras for specific functionalities. Multiple extras can be combined (e.g., `.[aloha,feetech]`). For all available extras, refer to `pyproject.toml`.
### Simulations ### Simulations
+2 -2
View File
@@ -279,13 +279,13 @@ We use the Hugging Face hub features for uploading your dataset. If you haven't
Add your token to the CLI by running this command: Add your token to the CLI by running this command:
```bash ```bash
hf auth login --token ${HUGGINGFACE_TOKEN} --add-to-git-credential huggingface-cli login --token ${HUGGINGFACE_TOKEN} --add-to-git-credential
``` ```
Then store your Hugging Face repository name in a variable: Then store your Hugging Face repository name in a variable:
```bash ```bash
HF_USER=$(hf auth whoami | awk -F': *' 'NR==1 {print $2}') HF_USER=$(huggingface-cli whoami | head -n 1)
echo $HF_USER echo $HF_USER
``` ```
+5
View File
@@ -34,6 +34,11 @@ As described by Physical Intelligence, while AI has achieved remarkable success
pip install -e ".[pi]" pip install -e ".[pi]"
``` ```
> [!NOTE]
> For lerobot 0.4.0, if you want to install pi tag, you will have to do: `pip install "lerobot[pi]@git+https://github.com/huggingface/lerobot.git"`.
>
> This will be solved in the next patch release
## Training Data and Capabilities ## Training Data and Capabilities
π₀ is trained on the largest robot interaction dataset to date, combining three key data sources: π₀ is trained on the largest robot interaction dataset to date, combining three key data sources:
+5
View File
@@ -36,6 +36,11 @@ This diverse training mixture creates a "curriculum" that enables generalization
pip install -e ".[pi]" pip install -e ".[pi]"
``` ```
> [!NOTE]
> For lerobot 0.4.0, if you want to install pi tag, you will have to do: `pip install "lerobot[pi]@git+https://github.com/huggingface/lerobot.git"`.
>
> This will be solved in the next patch release
## Usage ## Usage
To use π₀.₅ in your LeRobot configuration, specify the policy type as: To use π₀.₅ in your LeRobot configuration, specify the policy type as:
+15 -10
View File
@@ -43,11 +43,16 @@ This approach can transform **any existing VLM** into a VLA by training it to pr
pip install -e ".[pi]" pip install -e ".[pi]"
``` ```
> [!NOTE]
> For lerobot 0.4.0, if you want to install the pi tag, you will have to do: `pip install "lerobot[pi]@git+https://github.com/huggingface/lerobot.git"`.
>
> This will be solved in the next patch release
## Training a Custom FAST Tokenizer ## Training a Custom FAST Tokenizer
You have two options for the FAST tokenizer: You have two options for the FAST tokenizer:
1. **Use the pre-trained tokenizer**: The `lerobot/fast-action-tokenizer` tokenizer was trained on 1M+ real robot action sequences and works as a general-purpose tokenizer. 1. **Use the pre-trained tokenizer**: The `physical-intelligence/fast` tokenizer was trained on 1M+ real robot action sequences and works as a general-purpose tokenizer.
2. **Train your own tokenizer**: For maximum performance on your specific dataset, you can finetune the tokenizer on your own data. 2. **Train your own tokenizer**: For maximum performance on your specific dataset, you can finetune the tokenizer on your own data.
@@ -109,15 +114,15 @@ lerobot-train \
### Key Training Parameters ### Key Training Parameters
| Parameter | Description | Default | | Parameter | Description | Default |
| -------------------------------------- | -------------------------------------------------- | ------------------------------- | | -------------------------------------- | -------------------------------------------------- | ---------------------------- |
| `--policy.gradient_checkpointing=true` | Reduces memory usage significantly during training | `false` | | `--policy.gradient_checkpointing=true` | Reduces memory usage significantly during training | `false` |
| `--policy.dtype=bfloat16` | Use mixed precision training for efficiency | `float32` | | `--policy.dtype=bfloat16` | Use mixed precision training for efficiency | `float32` |
| `--policy.chunk_size` | Number of action steps to predict (action horizon) | `50` | | `--policy.chunk_size` | Number of action steps to predict (action horizon) | `50` |
| `--policy.n_action_steps` | Number of action steps to execute | `50` | | `--policy.n_action_steps` | Number of action steps to execute | `50` |
| `--policy.max_action_tokens` | Maximum number of FAST tokens per action chunk | `256` | | `--policy.max_action_tokens` | Maximum number of FAST tokens per action chunk | `256` |
| `--policy.action_tokenizer_name` | FAST tokenizer to use | `lerobot/fast-action-tokenizer` | | `--policy.action_tokenizer_name` | FAST tokenizer to use | `physical-intelligence/fast` |
| `--policy.compile_model=true` | Enable torch.compile for faster training | `false` | | `--policy.compile_model=true` | Enable torch.compile for faster training | `false` |
## Inference ## Inference
+186 -140
View File
@@ -1,49 +1,23 @@
# Unitree G1 # Unitree G1
<img This guide covers the complete setup process for the Unitree G1 humanoid, from initial connection to running gr00t_wbc locomotion.
src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/unitree_thumbnail.jpg"
alt="Unitree G1 locomanipulation demo"
style={{ width: "100%" }}
/>
The Unitree G1 humanoid is now supported in LeRobot! You can teleoperate, train locomanipulation policies, test in sim, and more. Both 29 and 23 DoF variants are supported. ## About
We support both 29 and 23 DOF G1 EDU version. We introduce:
- **`unitree g1` robot class, handling low level read/write from/to the humanoid**
- **ZMQ socket bridge** for remote communication and camera streaming, allowing for remote policy deployment over wlan, eth or directly on the robot
- **Locomotion policies** from NVIDIA gr00t and Amazon FAR Holosoma
- **Simulation mode** for testing policies without the physical robot in mujoco
--- ---
## Part 1: Getting Started ## Connection guide
### Install LeRobot on Your Machine ### Step 1: Configure Ethernet Interface
```bash Set a static IP on the same subnet as the robot:
conda create -y -n lerobot python=3.12
conda activate lerobot
git clone https://github.com/unitreerobotics/unitree_sdk2_python.git
cd unitree_sdk2_python && pip install -e .
git clone https://github.com/huggingface/lerobot.git
cd lerobot
pip install -e '.[unitree_g1]'
```
### Test the Installation (Simulation)
```bash
lerobot-teleoperate \
--robot.type=unitree_g1 \
--robot.is_simulation=true \
--teleop.type=unitree_g1 \
--teleop.id=wbc_unitree \
--robot.cameras='{"global_view": {"type": "zmq", "server_address": "localhost", "port": 5555, "camera_name": "head_camera", "width": 640, "height": 480, "fps": 30}}' \
--display_data=true
```
This will launch a [MuJoCo sim instance](https://huggingface.co/lerobot/unitree-g1-mujoco/tree/main) for the G1.
- Press `9` to release the robot
- Press `7` / `8` to increase / decrease waist height
### Connect to the Robot
The G1's Ethernet IP is fixed at `192.168.123.164`. Your machine must have a static IP on the same subnet: `192.168.123.x` where `x ≠ 164`.
```bash ```bash
# Replace 'enp131s0' with your ethernet interface name (check with `ip a`) # Replace 'enp131s0' with your ethernet interface name (check with `ip a`)
@@ -52,200 +26,272 @@ sudo ip addr add 192.168.123.200/24 dev enp131s0
sudo ip link set enp131s0 up sudo ip link set enp131s0 up
``` ```
### SSH into the Robot **Note**: The G1's Ethernet IP is fixed at `192.168.123.164`. Your computer must use `192.168.123.x` with x ≠ 164.
### Step 2: SSH into the Robot
```bash ```bash
ssh unitree@192.168.123.164 ssh unitree@192.168.123.164
# Password: 123 # Password: 123
``` ```
### Install LeRobot on the G1 You should now be connected to the G1's Orin.
From the robot:
```bash
conda create -y -n lerobot python=3.12
conda activate lerobot
git clone https://github.com/unitreerobotics/unitree_sdk2_python.git
cd unitree_sdk2_python && pip install -e .
git clone https://github.com/huggingface/lerobot.git
cd lerobot
pip install -e '.[unitree_g1]'
```
> **Note:** The Unitree SDK requires CycloneDDS v0.10.2. See the [Unitree SDK docs](https://github.com/unitreerobotics/unitree_sdk2_python) for details.
--- ---
## Part 2: Enable WiFi on the Robot ## Part 2: Enable WiFi on the Robot
Wi-Fi connectivity is blocked by default on the G1. To activate: Wlan0 is disabled by default on the G1. To enable it:
### Step 1: Enable WiFi Hardware
```bash ```bash
sudo rfkill unblock wifi
sudo rfkill unblock all sudo rfkill unblock all
# Bring up wlan0
sudo ip link set wlan0 up sudo ip link set wlan0 up
# Enable NetworkManager control of wlan0
sudo nmcli radio wifi on sudo nmcli radio wifi on
sudo nmcli device set wlan0 managed yes sudo nmcli device set wlan0 managed yes
sudo systemctl restart NetworkManager sudo systemctl restart NetworkManager
``` ```
**On your laptop** (share internet via Ethernet): ### Step 2: Enable Internet Forwarding
**On your laptop:**
```bash ```bash
# Enable IP forwarding
sudo sysctl -w net.ipv4.ip_forward=1 sudo sysctl -w net.ipv4.ip_forward=1
# Replace wlp132s0f0 with your WiFi interface name # Set up NAT (replace wlp132s0f0 with your WiFi interface)
sudo iptables -t nat -A POSTROUTING -o wlp132s0f0 -s 192.168.123.0/24 -j MASQUERADE sudo iptables -t nat -A POSTROUTING -o wlp132s0f0 -s 192.168.123.0/24 -j MASQUERADE
sudo iptables -A FORWARD -i wlp132s0f0 -o enp131s0 -m state --state RELATED,ESTABLISHED -j ACCEPT sudo iptables -A FORWARD -i wlp132s0f0 -o enp131s0 -m state --state RELATED,ESTABLISHED -j ACCEPT
sudo iptables -A FORWARD -i enp131s0 -o wlp132s0f0 -j ACCEPT sudo iptables -A FORWARD -i enp131s0 -o wlp132s0f0 -j ACCEPT
``` ```
**On the G1** (set default route through your laptop): **On the G1:**
```bash ```bash
# Add laptop as default gateway
sudo ip route del default 2>/dev/null || true sudo ip route del default 2>/dev/null || true
sudo ip route add default via 192.168.123.200 dev eth0 sudo ip route add default via 192.168.123.200 dev eth0
echo "nameserver 8.8.8.8" | sudo tee /etc/resolv.conf echo "nameserver 8.8.8.8" | sudo tee /etc/resolv.conf
# Verify # Test connection
ping -c 3 8.8.8.8 ping -c 3 8.8.8.8
``` ```
**Connect to a WiFi network:** ### Step 3: Connect to WiFi Network
```bash ```bash
# List available networks
nmcli device wifi list nmcli device wifi list
# Connect to your WiFi (example)
sudo nmcli connection add type wifi ifname wlan0 con-name "YourNetwork" ssid "YourNetwork" sudo nmcli connection add type wifi ifname wlan0 con-name "YourNetwork" ssid "YourNetwork"
sudo nmcli connection modify "YourNetwork" wifi-sec.key-mgmt wpa-psk sudo nmcli connection modify "YourNetwork" wifi-sec.key-mgmt wpa-psk
sudo nmcli connection modify "YourNetwork" wifi-sec.psk "YourPassword" sudo nmcli connection modify "YourNetwork" wifi-sec.psk "YourPassword"
sudo nmcli connection modify "YourNetwork" connection.autoconnect yes sudo nmcli connection modify "YourNetwork" connection.autoconnect yes
sudo nmcli connection up "YourNetwork" sudo nmcli connection up "YourNetwork"
# Check WiFi IP address
ip a show wlan0 ip a show wlan0
``` ```
You can now SSH over WiFi: ### Step 4: 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 ```bash
ssh unitree@<ROBOT_WIFI_IP> ssh unitree@<YOUR_ROBOT_IP>
# Password: 123 # Password: 123
``` ```
Replace `<YOUR_ROBOT_IP>` with your robot's actual WiFi IP address.
--- ---
## Part 3: Teleoperation & Locomotion ## Part 3: Robot Server Setup
### Run the Robot Server ### Step 1: Install LeRobot on the Orin
SSH into the robot and install LeRobot:
```bash
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 .
```
**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 2: Run the Robot Server
On the robot: On the robot:
```bash ```bash
python src/lerobot/robots/unitree_g1/run_g1_server.py --camera python src/lerobot/robots/unitree_g1/run_g1_server.py
``` ```
### Run the Locomotion Policy **Important**: Keep this terminal running. The server must be active for remote control.
```bash
lerobot-teleoperate \
--robot.type=unitree_g1 \
--robot.is_simulation=false \
--robot.robot_ip=<ROBOT_IP> \
--teleop.type=unitree_g1 \
--teleop.id=wbc_unitree \
--robot.cameras='{"global_view": {"type": "zmq", "server_address": "<ROBOT_IP>", "port": 5555, "camera_name": "head_camera", "width": 640, "height": 480, "fps": 30}}' \
--display_data=true \
--robot.controller=HolosomaLocomotionController
```
We support both [HolosomaLocomotionController](https://github.com/amazon-far/holosoma) and [GrootLocomotionController](https://github.com/NVlabs/GR00T-WholeBodyControl).
--- ---
## Part 4: Loco-Manipulation with the Homunculus Exoskeleton ## Part 4: Controlling the robot
We provide a loco-manipulation solution via the Homunculus Exoskeleton — an open-source 7 DoF exoskeleton for whole-body control. Assembly instructions [here](https://github.com/nepyope/hmc_exo). With the robot server running, you can now control the robot remotely. Let's launch a locomotion policy
### Calibrate ### 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
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 = "<YOUR_ROBOT_IP>" # Replace with your robot's WiFi IP.
```
### Step 3: Run the Locomotion Policy
```bash
# Run GR00T locomotion controller
python examples/unitree_g1/gr00t_locomotion.py --repo-id "nepyope/GR00T-WholeBodyControl_g1"
# Run Holosoma locomotion controller
python examples/unitree_g1/holosoma_locomotion.py
```
Press `Ctrl+C` to stop the policy.
---
## Running in Simulation Mode (MuJoCo)
You can test policies before deploying on the physical robot using MuJoCo simulation. Set `is_simulation=True` in config or pass `--robot.is_simulation=true` via CLI.
### Calibrate Exoskeleton Teleoperator
```bash ```bash
lerobot-calibrate \ lerobot-calibrate \
--teleop.type=unitree_g1 \ --teleop.type=unitree_g1 \
--teleop.left_arm_config.port=/dev/ttyACM1 \ --teleop.left_arm_config.port=/dev/ttyACM1 \
--teleop.right_arm_config.port=/dev/ttyACM0 \ --teleop.right_arm_config.port=/dev/ttyACM0 \
--teleop.id=exo --teleop.id=exo
``` ```
During calibration move each joint through its entire range. After fitting, move the joint in a neutral position and press `n` to advance. ### Teleoperate in Simulation
### Record a Dataset ```bash
lerobot-teleoperate \
--robot.type=unitree_g1 \
--robot.is_simulation=true \
--teleop.type=unitree_g1 \
--teleop.left_arm_config.port=/dev/ttyACM1 \
--teleop.right_arm_config.port=/dev/ttyACM0 \
--teleop.id=exo \
--fps=100
```
### Record Dataset in Simulation
```bash ```bash
lerobot-record \ lerobot-record \
--robot.type=unitree_g1 \ --robot.type=unitree_g1 \
--robot.is_simulation=true \ --robot.is_simulation=true \
--robot.cameras='{"global_view": {"type": "zmq", "server_address": "localhost", "port": 5555, "camera_name": "head_camera", "width": 640, "height": 480, "fps": 30}}' \ --robot.cameras='{"global_view": {"type": "zmq", "server_address": "localhost", "port": 5555, "camera_name": "head_camera", "width": 640, "height": 480, "fps": 30}}' \
--teleop.type=unitree_g1 \ --teleop.type=unitree_g1 \
--teleop.left_arm_config.port=/dev/ttyACM1 \ --teleop.left_arm_config.port=/dev/ttyACM1 \
--teleop.right_arm_config.port=/dev/ttyACM0 \ --teleop.right_arm_config.port=/dev/ttyACM0 \
--teleop.id=exo \ --teleop.id=exo \
--dataset.repo_id=your-username/dataset-name \ --dataset.repo_id=your-username/dataset-name \
--dataset.single_task="Test" \ --dataset.single_task="Test" \
--dataset.num_episodes=2 \ --dataset.num_episodes=2 \
--dataset.episode_time_s=5 \ --dataset.episode_time_s=5 \
--dataset.reset_time_s=5 \ --dataset.reset_time_s=5 \
--dataset.push_to_hub=true \ --dataset.push_to_hub=true \
--dataset.streaming_encoding=true \ --dataset.streaming_encoding=true \
--dataset.encoder_threads=2 # --dataset.vcodec=auto \
--dataset.encoder_threads=2
``` ```
> **Note:** Omit `--teleop.left_arm_config.port` and `--teleop.right_arm_config.port` if you're only using the joystick. Example simulation dataset: [nepyope/teleop_test_sim](https://huggingface.co/datasets/nepyope/teleop_test_sim)
Example dataset: [nepyope/unitree_box_move_blue_full](https://huggingface.co/datasets/nepyope/unitree_box_move_blue_full)
--- ---
## Part 5: Training & Inference ## Running on Real Robot
### Train Once the robot server is running on the G1 (see Part 3), you can teleoperate and record on the real robot.
### Start the Camera Server
On the robot, start the ZMQ image server:
```bash ```bash
python src/lerobot/scripts/lerobot_train.py \ python src/lerobot/cameras/zmq/image_server.py
--dataset.repo_id=your-username/dataset-name \
--policy.type=pi05 \
--output_dir=./outputs/pi05_training \
--job_name=pi05_training \
--policy.repo_id=your-username/your-repo-id \
--policy.pretrained_path=lerobot/pi05_base \
--policy.compile_model=true \
--policy.gradient_checkpointing=true \
--wandb.enable=true \
--policy.dtype=bfloat16 \
--policy.freeze_vision_encoder=false \
--policy.train_expert_only=false \
--steps=3000 \
--policy.device=cuda \
--batch_size=32
``` ```
### Inference with RTC Keep this running in a separate terminal for camera streaming during recording.
Once trained, we recommend deploying policies using inference-time RTC: ### Teleoperate Real Robot
```bash ```bash
python examples/rtc/eval_with_real_robot.py \ lerobot-teleoperate \
--policy.path=your-username/your-repo-id \ --robot.type=unitree_g1 \
--policy.device=cuda \ --robot.is_simulation=false \
--robot.type=unitree_g1 \ --teleop.type=unitree_g1 \
--robot.is_simulation=false \ --teleop.left_arm_config.port=/dev/ttyACM1 \
--robot.controller=HolosomaLocomotionController \ --teleop.right_arm_config.port=/dev/ttyACM0 \
--robot.cameras='{"global_view": {"type": "zmq", "server_address": "<ROBOT_IP>", "port": 5555, "camera_name": "head_camera", "width": 640, "height": 480, "fps": 30}}' \ --teleop.id=exo \
--task="task_description" \ --fps=100
--duration=1000 \
--fps=30 \
--rtc.enabled=true
``` ```
### Record Dataset on Real Robot
```bash
lerobot-record \
--robot.type=unitree_g1 \
--robot.is_simulation=false \
--robot.cameras='{"global_view": {"type": "zmq", "server_address": "172.18.129.215", "port": 5555, "camera_name": "head_camera", "width": 640, "height": 480, "fps": 30}}' \
--teleop.type=unitree_g1 \
--teleop.left_arm_config.port=/dev/ttyACM1 \
--teleop.right_arm_config.port=/dev/ttyACM0 \
--teleop.id=exo \
--dataset.repo_id=your-username/dataset-name \
--dataset.single_task="Test" \
--dataset.num_episodes=2 \
--dataset.episode_time_s=5 \
--dataset.reset_time_s=5 \
--dataset.push_to_hub=true \
--dataset.streaming_encoding=true \
# --dataset.vcodec=auto \
--dataset.encoder_threads=2
```
**Note**: Update `server_address` to match your robot's camera server IP.
Example real robot dataset: [nepyope/teleop_test_real](https://huggingface.co/datasets/nepyope/teleop_test_real)
--- ---
## Additional Resources ## Additional Resources
@@ -254,8 +300,8 @@ python examples/rtc/eval_with_real_robot.py \
- [GR00T-WholeBodyControl](https://github.com/NVlabs/GR00T-WholeBodyControl) - [GR00T-WholeBodyControl](https://github.com/NVlabs/GR00T-WholeBodyControl)
- [Holosoma](https://github.com/amazon-far/holosoma) - [Holosoma](https://github.com/amazon-far/holosoma)
- [LeRobot Documentation](https://github.com/huggingface/lerobot) - [LeRobot Documentation](https://github.com/huggingface/lerobot)
- [Unitree IL LeRobot](https://github.com/unitreerobotics/unitree_IL_lerobot) - [Unitree_IL_Lerobot](https://github.com/unitreerobotics/unitree_IL_lerobot)
--- ---
_Last updated: March 2026_ _Last updated: December 2025_
+1 -1
View File
@@ -57,7 +57,7 @@ class DatasetReplayConfig:
repo_id: str repo_id: str
# Episode to replay. # Episode to replay.
episode: int episode: int
# Root directory where the dataset will be stored (e.g. 'dataset/path'). If None, defaults to $HF_LEROBOT_HOME/repo_id. # Root directory where the dataset will be stored (e.g. 'dataset/path').
root: str | Path | None = None root: str | Path | None = None
# Limit the frames per second. By default, uses the policy fps. # Limit the frames per second. By default, uses the policy fps.
fps: int = 30 fps: int = 30
-490
View File
@@ -1,490 +0,0 @@
#!/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.
"""
SLURM-distributed SARM RA-BC annotation pipeline.
Computes SARM progress values for all frames in a dataset, distributed across
SLURM workers, then merges the shards into a single sarm_progress.parquet.
Two subcommands, each a separate SLURM submission:
compute N workers, each computes progress for a subset of episodes
aggregate 1 worker, merges N shards into sarm_progress.parquet, pushes to hub
Usage:
python slurm_compute_rabc.py compute \\
--repo-id user/dataset --reward-model-path user/sarm_model \\
--stride 10 --device cpu --workers 50 --partition cpu
python slurm_compute_rabc.py aggregate \\
--repo-id user/dataset --reward-model-path user/sarm_model \\
--partition cpu --push-to-hub
"""
import argparse
from pathlib import Path
from datatrove.executor import LocalPipelineExecutor
from datatrove.executor.slurm import SlurmPipelineExecutor
from datatrove.pipeline.base import PipelineStep
class ComputeProgressShards(PipelineStep):
"""Each worker computes SARM progress for its assigned episodes."""
def __init__(
self, repo_id, reward_model_path, stride=1, head_mode="sparse", device="cpu", shard_dir="rabc_shards"
):
super().__init__()
if stride < 1:
raise ValueError(f"stride must be >= 1, got {stride}")
self.repo_id = repo_id
self.reward_model_path = reward_model_path
self.stride = stride
self.head_mode = head_mode
self.device = device
self.shard_dir = shard_dir
def run(self, data=None, rank: int = 0, world_size: int = 1):
import logging
from pathlib import Path
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
import torch
from tqdm import tqdm
from lerobot.policies.sarm.compute_rabc_weights import (
generate_all_frame_indices,
interpolate_progress,
load_sarm_resources,
)
from lerobot.utils.utils import init_logging
init_logging()
dataset, reward_model, preprocess = load_sarm_resources(
self.repo_id,
self.reward_model_path,
self.device,
)
if hasattr(preprocess, "eval"):
preprocess.eval()
for step in preprocess.steps:
if hasattr(step, "eval"):
step.eval()
image_key = reward_model.config.image_key
state_key = reward_model.config.state_key
frame_gap = reward_model.config.frame_gap
center_idx = reward_model.config.n_obs_steps // 2
dual_mode = reward_model.config.uses_dual_heads
compute_sparse = self.head_mode in ("sparse", "both") or not dual_mode
compute_dense = self.head_mode in ("dense", "both") and dual_mode
my_episodes = list(range(dataset.num_episodes))[rank::world_size]
if not my_episodes:
logging.info(f"Rank {rank}: no episodes assigned")
return
logging.info(f"Rank {rank}: {len(my_episodes)} / {dataset.num_episodes} episodes")
all_rows = []
for ep_idx in tqdm(my_episodes, desc=f"Rank {rank}"):
ep = dataset.meta.episodes[ep_idx]
ep_start, ep_end = ep["dataset_from_index"], ep["dataset_to_index"]
task = dataset[ep_start].get("task", "perform the task")
all_ep_indices = generate_all_frame_indices(ep_start, ep_end, frame_gap)
if self.stride > 1:
compute_indices = [i for i in all_ep_indices if (i - ep_start) % self.stride == 0]
if (ep_end - 1) not in compute_indices:
compute_indices.append(ep_end - 1)
compute_indices = sorted(set(compute_indices))
else:
compute_indices = all_ep_indices
frame_results = {}
for qi in tqdm(compute_indices, desc=f" Ep {ep_idx}", leave=False):
try:
sample = dataset[qi]
batch = {
image_key: sample[image_key],
"task": task,
"index": qi,
"episode_index": ep_idx,
}
if state_key in sample:
batch[state_key] = sample[state_key]
with torch.no_grad():
processed = preprocess(batch)
vf = processed["video_features"].to(self.device)
tf = processed["text_features"].to(self.device)
sf = processed.get("state_features")
if sf is not None:
sf = sf.to(self.device)
lengths = processed.get("lengths")
sparse_val = dense_val = np.nan
if compute_sparse:
r = reward_model.calculate_rewards(
text_embeddings=tf,
video_embeddings=vf,
state_features=sf,
lengths=lengths,
return_all_frames=True,
head_mode="sparse",
)
sparse_val = float(r[0, center_idx] if r.ndim == 2 else r[center_idx])
if compute_dense:
r = reward_model.calculate_rewards(
text_embeddings=tf,
video_embeddings=vf,
state_features=sf,
lengths=lengths,
return_all_frames=True,
head_mode="dense",
)
dense_val = float(r[0, center_idx] if r.ndim == 2 else r[center_idx])
frame_results[qi] = (sparse_val, dense_val)
except Exception as e:
logging.warning(f"Failed frame {qi}: {e}")
if not frame_results:
logging.warning(f"Episode {ep_idx}: all frames failed, skipping")
continue
# Interpolate to all frames in this episode
computed_idx = np.array(sorted(frame_results.keys()))
all_frame_arr = np.arange(ep_start, ep_end)
sparse_vals = np.array([frame_results[i][0] for i in computed_idx]) if compute_sparse else None
dense_vals = np.array([frame_results[i][1] for i in computed_idx]) if compute_dense else None
if self.stride > 1 and len(computed_idx) > 1:
if compute_sparse:
sparse_vals = interpolate_progress(computed_idx, sparse_vals, all_frame_arr)
if compute_dense:
dense_vals = interpolate_progress(computed_idx, dense_vals, all_frame_arr)
output_frames = all_frame_arr
else:
# Use only successfully computed frames to avoid indexing mismatch on failures
output_frames = computed_idx
for i, fi in enumerate(output_frames):
row = {"index": int(fi), "episode_index": ep_idx, "frame_index": int(fi - ep_start)}
if compute_sparse:
row["progress_sparse"] = float(sparse_vals[i])
if compute_dense:
row["progress_dense"] = float(dense_vals[i])
all_rows.append(row)
if all_rows:
import pandas as pd
df = pd.DataFrame(all_rows).sort_values("index").reset_index(drop=True)
table = pa.Table.from_pandas(df, preserve_index=False)
table = table.replace_schema_metadata({b"reward_model_path": self.reward_model_path.encode()})
shard_dir = Path(self.shard_dir)
shard_dir.mkdir(parents=True, exist_ok=True)
out = shard_dir / f"shard_{rank:05d}.parquet"
pq.write_table(table, out)
logging.info(f"Rank {rank}: saved {len(df)} rows to {out}")
class AggregateProgress(PipelineStep):
"""Merge all shard parquets into final sarm_progress.parquet."""
def __init__(self, repo_id, reward_model_path, shard_dir="rabc_shards", push_to_hub=False):
super().__init__()
self.repo_id = repo_id
self.reward_model_path = reward_model_path
self.shard_dir = shard_dir
self.push_to_hub = push_to_hub
def run(self, data=None, rank: int = 0, world_size: int = 1):
import datetime
import logging
import os
from pathlib import Path
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
from lerobot.datasets.lerobot_dataset import LeRobotDataset
from lerobot.utils.utils import init_logging
init_logging()
if rank != 0:
return
shard_dir = Path(self.shard_dir)
shards = sorted(shard_dir.glob("shard_*.parquet"))
if not shards:
raise FileNotFoundError(f"No shards found in {shard_dir}")
# Log shard modification time range to help detect stale files
mtimes = [os.path.getmtime(s) for s in shards]
oldest = datetime.datetime.fromtimestamp(min(mtimes)).isoformat(timespec="seconds")
newest = datetime.datetime.fromtimestamp(max(mtimes)).isoformat(timespec="seconds")
logging.info(f"Aggregating {len(shards)} shards (oldest: {oldest}, newest: {newest})")
df = pd.concat([pd.read_parquet(s) for s in shards], ignore_index=True)
df = df.sort_values("index").reset_index(drop=True)
table = pa.Table.from_pandas(df, preserve_index=False)
table = table.replace_schema_metadata({b"reward_model_path": self.reward_model_path.encode()})
temp_ds = LeRobotDataset(self.repo_id, download_videos=False)
out_path = Path(temp_ds.root) / "sarm_progress.parquet"
out_path.parent.mkdir(parents=True, exist_ok=True)
pq.write_table(table, out_path)
logging.info(f"Saved {len(df)} rows to {out_path}")
for col in ["progress_sparse", "progress_dense"]:
if col in df.columns:
v = df[col].dropna()
logging.info(
f"{col}: mean={v.mean():.4f} std={v.std():.4f} min={v.min():.4f} max={v.max():.4f}"
)
if self.push_to_hub:
from huggingface_hub import HfApi
api = HfApi()
hub_path = "sarm_progress.parquet"
logging.info(f"Uploading to {self.repo_id}/{hub_path}")
api.upload_file(
path_or_fileobj=str(out_path),
path_in_repo=hub_path,
repo_id=self.repo_id,
repo_type="dataset",
)
logging.info(f"Uploaded: https://huggingface.co/datasets/{self.repo_id}/blob/main/{hub_path}")
def make_compute_executor(
repo_id,
reward_model_path,
stride,
head_mode,
device,
shard_dir,
logs_dir,
job_name,
slurm,
workers,
partition,
cpus_per_task,
mem_per_cpu,
):
kwargs = {
"pipeline": [
ComputeProgressShards(repo_id, reward_model_path, stride, head_mode, device, str(shard_dir)),
],
"logging_dir": str(logs_dir / job_name),
}
if slurm:
kwargs.update(
{
"job_name": job_name,
"tasks": workers,
"workers": workers,
"time": "24:00:00",
"partition": partition,
"cpus_per_task": cpus_per_task,
"sbatch_args": {"mem-per-cpu": mem_per_cpu},
}
)
return SlurmPipelineExecutor(**kwargs)
kwargs.update({"tasks": workers, "workers": 1})
return LocalPipelineExecutor(**kwargs)
def make_aggregate_executor(
repo_id,
reward_model_path,
shard_dir,
logs_dir,
job_name,
slurm,
partition,
cpus_per_task,
mem_per_cpu,
push_to_hub,
):
kwargs = {
"pipeline": [
AggregateProgress(repo_id, reward_model_path, str(shard_dir), push_to_hub),
],
"logging_dir": str(logs_dir / job_name),
}
if slurm:
kwargs.update(
{
"job_name": job_name,
"tasks": 1,
"workers": 1,
"time": "02:00:00",
"partition": partition,
"cpus_per_task": cpus_per_task,
"sbatch_args": {"mem-per-cpu": mem_per_cpu},
}
)
return SlurmPipelineExecutor(**kwargs)
kwargs.update({"tasks": 1, "workers": 1})
return LocalPipelineExecutor(**kwargs)
def _add_shared_args(p):
p.add_argument(
"--repo-id",
type=str,
required=True,
help="Hugging Face repository identifier, e.g. 'user/dataset'.",
)
p.add_argument(
"--shard-dir",
type=Path,
default=Path("rabc_shards"),
help="Directory to read/write per-rank parquet shards.",
)
p.add_argument(
"--logs-dir",
type=Path,
default=Path("logs"),
help="Directory for datatrove logs.",
)
p.add_argument(
"--job-name",
type=str,
default=None,
help="SLURM job name (defaults to rabc_<subcommand>).",
)
p.add_argument(
"--slurm",
type=int,
default=1,
help="1 = submit via SLURM; 0 = run locally (useful for debugging).",
)
p.add_argument(
"--partition",
type=str,
default=None,
help="SLURM partition to submit to.",
)
p.add_argument(
"--cpus-per-task",
type=int,
default=4,
help="Number of CPUs per SLURM task.",
)
p.add_argument(
"--mem-per-cpu",
type=str,
default="4G",
help="Memory per CPU, e.g. '4G' or '1950M'.",
)
def main():
parser = argparse.ArgumentParser(
description="SLURM-distributed SARM RA-BC annotation pipeline",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="command", required=True)
# compute subcommand
cp = sub.add_parser(
"compute",
help="Distribute progress computation across SLURM workers.",
)
_add_shared_args(cp)
cp.add_argument(
"--reward-model-path",
type=str,
required=True,
help="Path or HF repo id of the SARM reward model.",
)
cp.add_argument(
"--stride",
type=int,
default=1,
help="Compute every Nth frame; intermediate frames are interpolated (must be >= 1).",
)
cp.add_argument(
"--head-mode",
type=str,
default="sparse",
choices=["sparse", "dense", "both"],
help="Which reward head(s) to compute.",
)
cp.add_argument(
"--device",
type=str,
default="cpu",
help="Device for reward model inference, e.g. 'cpu' or 'cuda'.",
)
cp.add_argument(
"--workers",
type=int,
default=50,
help="Number of parallel SLURM tasks (one shard per worker).",
)
# aggregate subcommand
ap = sub.add_parser(
"aggregate",
help="Merge per-rank shards into a single sarm_progress.parquet.",
)
_add_shared_args(ap)
ap.add_argument(
"--reward-model-path",
type=str,
required=True,
help="Path or HF repo id of the SARM reward model (stored in parquet metadata).",
)
ap.add_argument(
"--push-to-hub",
action="store_true",
help="Upload sarm_progress.parquet to the Hugging Face Hub after aggregation.",
)
args = parser.parse_args()
job_name = args.job_name or f"rabc_{args.command}"
kwargs = vars(args)
kwargs["slurm"] = kwargs.pop("slurm") == 1
kwargs["job_name"] = job_name
command = kwargs.pop("command")
executor = make_compute_executor(**kwargs) if command == "compute" else make_aggregate_executor(**kwargs)
executor.run()
if __name__ == "__main__":
main()
-2
View File
@@ -78,7 +78,6 @@ from torch import Tensor
from lerobot.cameras.opencv.configuration_opencv import OpenCVCameraConfig # noqa: F401 from lerobot.cameras.opencv.configuration_opencv import OpenCVCameraConfig # noqa: F401
from lerobot.cameras.realsense.configuration_realsense import RealSenseCameraConfig # noqa: F401 from lerobot.cameras.realsense.configuration_realsense import RealSenseCameraConfig # noqa: F401
from lerobot.cameras.zmq.configuration_zmq import ZMQCameraConfig # noqa: F401
from lerobot.configs import parser from lerobot.configs import parser
from lerobot.configs.policies import PreTrainedConfig from lerobot.configs.policies import PreTrainedConfig
from lerobot.configs.types import RTCAttentionSchedule from lerobot.configs.types import RTCAttentionSchedule
@@ -98,7 +97,6 @@ from lerobot.robots import ( # noqa: F401
bi_so_follower, bi_so_follower,
koch_follower, koch_follower,
so_follower, so_follower,
unitree_g1,
) )
from lerobot.robots.utils import make_robot_from_config from lerobot.robots.utils import make_robot_from_config
from lerobot.utils.constants import OBS_IMAGES from lerobot.utils.constants import OBS_IMAGES
@@ -14,20 +14,20 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import argparse
import logging import logging
import time
from collections import deque from collections import deque
import numpy as np import numpy as np
import onnxruntime as ort import onnxruntime as ort
from huggingface_hub import hf_hub_download from huggingface_hub import hf_hub_download
from lerobot.robots.unitree_g1.g1_utils import ( from lerobot.robots.unitree_g1.config_unitree_g1 import UnitreeG1Config
REMOTE_AXES, from lerobot.robots.unitree_g1.g1_utils import G1_29_JointIndex
REMOTE_BUTTONS, from lerobot.robots.unitree_g1.unitree_g1 import UnitreeG1
G1_29_JointIndex,
get_gravity_orientation,
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -36,13 +36,18 @@ GROOT_DEFAULT_ANGLES[[0, 6]] = -0.1 # Hip pitch
GROOT_DEFAULT_ANGLES[[3, 9]] = 0.3 # Knee GROOT_DEFAULT_ANGLES[[3, 9]] = 0.3 # Knee
GROOT_DEFAULT_ANGLES[[4, 10]] = -0.2 # Ankle pitch GROOT_DEFAULT_ANGLES[[4, 10]] = -0.2 # Ankle pitch
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
# Control parameters # Control parameters
ACTION_SCALE = 0.25 ACTION_SCALE = 0.25
CONTROL_DT = 0.02 # 50Hz CONTROL_DT = 0.02 # 50Hz
ANG_VEL_SCALE: float = 0.25 ANG_VEL_SCALE: float = 0.25
DOF_POS_SCALE: float = 1.0 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.25] CMD_SCALE: list = [2.0, 2.0, 0.25]
DEFAULT_GROOT_REPO_ID = "nepyope/GR00T-WholeBodyControl_g1" DEFAULT_GROOT_REPO_ID = "nepyope/GR00T-WholeBodyControl_g1"
@@ -80,11 +85,11 @@ def load_groot_policies(
class GrootLocomotionController: class GrootLocomotionController:
"""GR00T lower-body locomotion controller for the Unitree G1.""" """GR00T lower-body locomotion controller for the Unitree G1."""
control_dt = CONTROL_DT # Expose for unitree_g1.py def __init__(self, policy_balance, policy_walk, robot, config):
self.policy_balance = policy_balance
def __init__(self): self.policy_walk = policy_walk
# Load policies self.robot = robot
self.policy_balance, self.policy_walk = load_groot_policies() self.config = config
self.cmd = np.array([0.0, 0.0, 0.0], dtype=np.float32) # vx, vy, theta_dot self.cmd = np.array([0.0, 0.0, 0.0], dtype=np.float32) # vx, vy, theta_dot
@@ -104,60 +109,45 @@ class GrootLocomotionController:
logger.info("GrootLocomotionController initialized") logger.info("GrootLocomotionController initialized")
def reset(self) -> None: def run_step(self):
"""Reset internal state for a new episode.""" # Get current observation
self.cmd[:] = 0.0 obs = self.robot.get_observation()
self.groot_qj_all[:] = 0.0
self.groot_dqj_all[:] = 0.0
self.groot_action[:] = 0.0
self.groot_obs_single[:] = 0.0
self.groot_obs_stacked[:] = 0.0
self.groot_height_cmd = 0.74
self.groot_orientation_cmd[:] = 0.0
self.groot_obs_history.clear()
for _ in range(6):
self.groot_obs_history.append(np.zeros(86, dtype=np.float32))
def run_step(self, action: dict, lowstate) -> dict: if not obs:
"""Run one step of the locomotion controller. return
Args: # Get command from remote controller
action: Action dict containing remote.lx/ly/rx/ry and buttons if obs["remote.buttons"][0]: # R1 - raise waist
lowstate: Robot lowstate containing motor positions/velocities and IMU
Returns:
Action dict for lower body joints (0-14)
"""
if lowstate is None:
return {}
buttons = [int(action.get(k, 0)) for k in REMOTE_BUTTONS]
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, 0.50, 1.00)
if buttons[4]: # R2 - lower waist if obs["remote.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, 0.50, 1.00)
lx, ly, rx, _ry = (action.get(k, 0.0) for k in REMOTE_AXES) self.cmd[0] = obs["remote.ly"] # Forward/backward
self.cmd[0] = ly # Forward/backward self.cmd[1] = obs["remote.lx"] * -1 # Left/right
self.cmd[1] = -lx # Left/right (negated) self.cmd[2] = obs["remote.rx"] * -1 # Rotation rate
self.cmd[2] = -rx # Rotation rate (negated)
# Get joint positions and velocities from lowstate # Get joint positions and velocities from flat dict
for motor in G1_29_JointIndex: for motor in G1_29_JointIndex:
name = motor.name
idx = motor.value idx = motor.value
self.groot_qj_all[idx] = lowstate.motor_state[idx].q self.groot_qj_all[idx] = obs[f"{name}.q"]
self.groot_dqj_all[idx] = lowstate.motor_state[idx].dq self.groot_dqj_all[idx] = obs[f"{name}.dq"]
# Adapt observation for g1_23dof
for idx in MISSING_JOINTS:
self.groot_qj_all[idx] = 0.0
self.groot_dqj_all[idx] = 0.0
# Scale joint positions and velocities # Scale joint positions and velocities
qj_obs = self.groot_qj_all.copy() qj_obs = self.groot_qj_all.copy()
dqj_obs = self.groot_dqj_all.copy() dqj_obs = self.groot_dqj_all.copy()
# Express IMU data in gravity frame of reference # Express IMU data in gravity frame of reference
quat = lowstate.imu_state.quaternion quat = [obs["imu.quat.w"], obs["imu.quat.x"], obs["imu.quat.y"], obs["imu.quat.z"]]
ang_vel = np.array(lowstate.imu_state.gyroscope, dtype=np.float32) ang_vel = np.array([obs["imu.gyro.x"], obs["imu.gyro.y"], obs["imu.gyro.z"]], dtype=np.float32)
gravity_orientation = get_gravity_orientation(quat) gravity_orientation = self.robot.get_gravity_orientation(quat)
# Scale joint positions and velocities before policy inference # Scale joint positions and velocities before policy inference
qj_obs = (qj_obs - GROOT_DEFAULT_ANGLES) * DOF_POS_SCALE qj_obs = (qj_obs - GROOT_DEFAULT_ANGLES) * DOF_POS_SCALE
@@ -196,10 +186,73 @@ class GrootLocomotionController:
# Transform action back to target joint positions # Transform action back to target joint positions
target_dof_pos_15 = GROOT_DEFAULT_ANGLES[:15] + self.groot_action * ACTION_SCALE target_dof_pos_15 = GROOT_DEFAULT_ANGLES[:15] + self.groot_action * ACTION_SCALE
# Build action dict # Build action dict (only first 15 joints for GR00T)
action_dict = {} action_dict = {}
for i in range(15): for i in range(15):
motor_name = G1_29_JointIndex(i).name motor_name = G1_29_JointIndex(i).name
action_dict[f"{motor_name}.q"] = float(target_dof_pos_15[i]) action_dict[f"{motor_name}.q"] = float(target_dof_pos_15[i])
return action_dict # Zero out missing joints for g1_23dof
for joint_idx in MISSING_JOINTS:
motor_name = G1_29_JointIndex(joint_idx).name
action_dict[f"{motor_name}.q"] = 0.0
# Send action to robot
self.robot.send_action(action_dict)
def run(repo_id: str = DEFAULT_GROOT_REPO_ID) -> None:
"""Main function to run the GR00T locomotion controller.
Args:
repo_id: Hugging Face Hub repository ID for GR00T policies.
"""
# Load policies
policy_balance, policy_walk = load_groot_policies(repo_id=repo_id)
# Initialize robot
config = UnitreeG1Config()
robot = UnitreeG1(config)
robot.connect()
# Initialize gr00T locomotion controller
groot_controller = GrootLocomotionController(
policy_balance=policy_balance,
policy_walk=policy_walk,
robot=robot,
config=config,
)
try:
robot.reset(CONTROL_DT, GROOT_DEFAULT_ANGLES)
logger.info("Use joystick: LY=fwd/back, LX=left/right, RX=rotate, R1=raise waist, R2=lower waist")
logger.info("Press Ctrl+C to stop")
# Run step
while not robot._shutdown_event.is_set():
start_time = time.time()
groot_controller.run_step()
elapsed = time.time() - start_time
sleep_time = max(0, CONTROL_DT - elapsed)
time.sleep(sleep_time)
except KeyboardInterrupt:
logger.info("Stopping locomotion...")
finally:
if robot.is_connected:
robot.disconnect()
logger.info("Done!")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="GR00T Locomotion Controller for Unitree G1")
parser.add_argument(
"--repo-id",
type=str,
default=DEFAULT_GROOT_REPO_ID,
help=f"Hugging Face Hub repo ID for GR00T policies (default: {DEFAULT_GROOT_REPO_ID})",
)
args = parser.parse_args()
run(repo_id=args.repo_id)
@@ -14,21 +14,21 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import argparse
import json import json
import logging import logging
import time
import numpy as np import numpy as np
import onnx import onnx
import onnxruntime as ort import onnxruntime as ort
from huggingface_hub import hf_hub_download from huggingface_hub import hf_hub_download
from lerobot.robots.unitree_g1.g1_utils import ( from lerobot.robots.unitree_g1.config_unitree_g1 import UnitreeG1Config
REMOTE_AXES, from lerobot.robots.unitree_g1.g1_utils import G1_29_JointIndex
G1_29_JointArmIndex, from lerobot.robots.unitree_g1.unitree_g1 import UnitreeG1
G1_29_JointIndex,
get_gravity_orientation,
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_ANGLES = np.zeros(29, dtype=np.float32) DEFAULT_ANGLES = np.zeros(29, dtype=np.float32)
@@ -40,13 +40,18 @@ DEFAULT_ANGLES[16] = 0.2 # Left shoulder roll
DEFAULT_ANGLES[23] = -0.2 # Right shoulder roll DEFAULT_ANGLES[23] = -0.2 # Right shoulder roll
DEFAULT_ANGLES[[18, 25]] = 0.6 # Elbow DEFAULT_ANGLES[[18, 25]] = 0.6 # Elbow
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
# Control parameters # Control parameters
ACTION_SCALE = 0.25 ACTION_SCALE = 0.25
CONTROL_DT = 0.005 # 200Hz CONTROL_DT = 0.02 # 50Hz
ANG_VEL_SCALE = 0.25 ANG_VEL_SCALE = 0.25
DOF_POS_SCALE = 1.0 DOF_POS_SCALE = 1.0
DOF_VEL_SCALE = 0.05 DOF_VEL_SCALE = 0.05
GAIT_PERIOD = 0.5 GAIT_PERIOD = 1.0
DEFAULT_HOLOSOMA_REPO_ID = "nepyope/holosoma_locomotion" DEFAULT_HOLOSOMA_REPO_ID = "nepyope/holosoma_locomotion"
@@ -82,7 +87,7 @@ def load_policy(
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
model = onnx.load(policy_path, load_external_data=False) model = onnx.load(policy_path)
metadata = {prop.key: prop.value for prop in model.metadata_props} metadata = {prop.key: prop.value for prop in model.metadata_props}
if "kp" not in metadata or "kd" not in metadata: if "kp" not in metadata or "kd" not in metadata:
@@ -96,13 +101,15 @@ def load_policy(
class HolosomaLocomotionController: class HolosomaLocomotionController:
"""Holosoma lower-body locomotion controller for Unitree G1.""" """Holosoma whole-body locomotion controller for Unitree G1."""
control_dt = CONTROL_DT # Expose for unitree_g1.py def __init__(self, policy, robot, kp: np.ndarray, kd: np.ndarray):
self.policy = policy
self.robot = robot
def __init__(self): # Override robot's PD gains with policy gains
# Load policy and gains self.robot.kp = kp
self.policy, self.kp, self.kd = load_policy() self.robot.kd = kd
self.cmd = np.zeros(3, dtype=np.float32) self.cmd = np.zeros(3, dtype=np.float32)
@@ -117,55 +124,35 @@ class HolosomaLocomotionController:
self.phase_dt = 2 * np.pi / ((1.0 / CONTROL_DT) * GAIT_PERIOD) self.phase_dt = 2 * np.pi / ((1.0 / CONTROL_DT) * GAIT_PERIOD)
self.is_standing = True self.is_standing = True
logger.info("HolosomaLocomotionController initialized") def run_step(self):
# Get current observation
obs = self.robot.get_observation()
def reset(self) -> None: if not obs:
"""Reset internal state for a new episode.""" return
self.cmd[:] = 0.0
self.qj[:] = 0.0
self.dqj[:] = 0.0
self.obs[:] = 0.0
self.last_action[:] = 0.0
self.phase = np.array([[0.0, np.pi]], dtype=np.float32)
self.is_standing = True
def run_step(self, action: dict, lowstate) -> dict: # Get command from remote controller
"""Run one step of the locomotion controller. ly = obs["remote.ly"] if abs(obs["remote.ly"]) > 0.1 else 0.0
lx = obs["remote.lx"] if abs(obs["remote.lx"]) > 0.1 else 0.0
Args: rx = obs["remote.rx"] if abs(obs["remote.rx"]) > 0.1 else 0.0
action: Action dict containing remote.lx/ly/rx/ry
lowstate: Robot lowstate containing motor positions/velocities and IMU
Returns:
Action dict for lower body joints (0-14)
"""
if lowstate is None:
return {}
lx, ly, rx, _ry = (action.get(k, 0.0) for k in REMOTE_AXES)
ly = ly if abs(ly) > 0.1 else 0.0
lx = lx if abs(lx) > 0.1 else 0.0
rx = rx if abs(rx) > 0.1 else 0.0
ly = np.clip(ly, -0.3, 0.3)
lx = np.clip(lx, -0.3, 0.3)
self.cmd[:] = [ly, -lx, -rx] self.cmd[:] = [ly, -lx, -rx]
# Get joint positions and velocities from lowstate # Get joint positions and velocities
for motor in G1_29_JointIndex: for motor in G1_29_JointIndex:
name = motor.name
idx = motor.value idx = motor.value
self.qj[idx] = lowstate.motor_state[idx].q self.qj[idx] = obs[f"{name}.q"]
self.dqj[idx] = lowstate.motor_state[idx].dq self.dqj[idx] = obs[f"{name}.dq"]
# Hide arm positions from policy (show DEFAULT_ANGLES instead) # Adapt observation for g1_23dof
# This prevents policy from reacting to teleop arm movements for idx in MISSING_JOINTS:
for arm_joint in G1_29_JointArmIndex: self.qj[idx] = 0.0
self.qj[arm_joint.value] = DEFAULT_ANGLES[arm_joint.value] self.dqj[idx] = 0.0
self.dqj[arm_joint.value] = 0.0
# Express IMU data in gravity frame of reference # Express IMU data in gravity frame of reference
quat = lowstate.imu_state.quaternion quat = [obs["imu.quat.w"], obs["imu.quat.x"], obs["imu.quat.y"], obs["imu.quat.z"]]
ang_vel = np.array(lowstate.imu_state.gyroscope, dtype=np.float32) ang_vel = np.array([obs["imu.gyro.x"], obs["imu.gyro.y"], obs["imu.gyro.z"]], dtype=np.float32)
gravity = get_gravity_orientation(quat) gravity = self.robot.get_gravity_orientation(quat)
# Scale joint positions and velocities before policy inference # Scale joint positions and velocities before policy inference
qj_obs = (self.qj - DEFAULT_ANGLES) * DOF_POS_SCALE qj_obs = (self.qj - DEFAULT_ANGLES) * DOF_POS_SCALE
@@ -199,16 +186,79 @@ class HolosomaLocomotionController:
# Run policy inference # Run policy inference
ort_in = {self.policy.get_inputs()[0].name: self.obs.reshape(1, -1).astype(np.float32)} ort_in = {self.policy.get_inputs()[0].name: self.obs.reshape(1, -1).astype(np.float32)}
raw_action = self.policy.run(None, ort_in)[0].squeeze() raw_action = self.policy.run(None, ort_in)[0].squeeze()
policy_action = np.clip(raw_action, -100.0, 100.0) action = np.clip(raw_action, -100.0, 100.0)
self.last_action = policy_action.copy() self.last_action = action.copy()
# Transform action back to target joint positions # Transform action back to target joint positions
target = DEFAULT_ANGLES + policy_action * ACTION_SCALE target = DEFAULT_ANGLES + action * ACTION_SCALE
# Build action dict (first 15 joints only) # Build action dict
action_dict = {} action_dict = {}
for i in range(15): for motor in G1_29_JointIndex:
motor_name = G1_29_JointIndex(i).name action_dict[f"{motor.name}.q"] = float(target[motor.value])
action_dict[f"{motor_name}.q"] = float(target[i])
return action_dict # Zero out missing joints for g1_23dof
for joint_idx in MISSING_JOINTS:
motor_name = G1_29_JointIndex(joint_idx).name
action_dict[f"{motor_name}.q"] = 0.0
# Send action to robot
self.robot.send_action(action_dict)
def run(repo_id: str = DEFAULT_HOLOSOMA_REPO_ID, policy_type: str = "fastsac") -> None:
"""Main function to run the Holosoma locomotion controller.
Args:
repo_id: Hugging Face Hub repository ID for Holosoma policies.
policy_type: Policy type to use ('fastsac' or 'ppo').
"""
# Load policy and gains
policy, kp, kd = load_policy(repo_id=repo_id, policy_type=policy_type)
# Initialize robot
config = UnitreeG1Config()
robot = UnitreeG1(config)
robot.connect()
holosoma_controller = HolosomaLocomotionController(policy, robot, kp, kd)
try:
robot.reset(CONTROL_DT, DEFAULT_ANGLES)
logger.info("Use joystick: LY=fwd/back, LX=left/right, RX=rotate")
logger.info("Press Ctrl+C to stop")
# Run step
while not robot._shutdown_event.is_set():
start_time = time.time()
holosoma_controller.run_step()
elapsed = time.time() - start_time
sleep_time = max(0, CONTROL_DT - elapsed)
time.sleep(sleep_time)
except KeyboardInterrupt:
logger.info("Stopping locomotion...")
finally:
if robot.is_connected:
robot.disconnect()
logger.info("Done!")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Holosoma Locomotion Controller for Unitree G1")
parser.add_argument(
"--repo-id",
type=str,
default=DEFAULT_HOLOSOMA_REPO_ID,
help=f"Hugging Face Hub repo ID for Holosoma policies (default: {DEFAULT_HOLOSOMA_REPO_ID})",
)
parser.add_argument(
"--policy",
type=str,
choices=["fastsac", "ppo"],
default="fastsac",
help="Policy type to use: 'fastsac' (default) or 'ppo'",
)
args = parser.parse_args()
run(repo_id=args.repo_id, policy_type=args.policy)
+118 -52
View File
@@ -25,11 +25,11 @@ discord = "https://discord.gg/s3KuuzsPFb"
[project] [project]
name = "lerobot" name = "lerobot"
version = "0.4.5" version = "0.4.4"
description = "🤗 LeRobot: State-of-the-art Machine Learning for Real-World Robotics in Pytorch" description = "🤗 LeRobot: State-of-the-art Machine Learning for Real-World Robotics in Pytorch"
dynamic = ["readme"] dynamic = ["readme"]
license = { text = "Apache-2.0" } license = { text = "Apache-2.0" }
requires-python = ">=3.12" requires-python = ">=3.10"
authors = [ authors = [
{ name = "Rémi Cadène", email = "re.cadene@gmail.com" }, { name = "Rémi Cadène", email = "re.cadene@gmail.com" },
{ name = "Simon Alibert", email = "alibert.sim@gmail.com" }, { name = "Simon Alibert", email = "alibert.sim@gmail.com" },
@@ -50,8 +50,7 @@ classifiers = [
"Intended Audience :: Education", "Intended Audience :: Education",
"Intended Audience :: Science/Research", "Intended Audience :: Science/Research",
"License :: OSI Approved :: Apache Software License", "License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Build Tools", "Topic :: Software Development :: Build Tools",
"Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Scientific/Engineering :: Artificial Intelligence",
] ]
@@ -62,28 +61,26 @@ dependencies = [
# Hugging Face dependencies # Hugging Face dependencies
"datasets>=4.0.0,<5.0.0", "datasets>=4.0.0,<5.0.0",
"diffusers>=0.27.2,<0.36.0", "diffusers>=0.27.2,<0.36.0",
"huggingface-hub>=1.0.0,<2.0.0", "huggingface-hub[hf-transfer,cli]>=0.34.2,<0.36.0",
"accelerate>=1.10.0,<2.0.0", "accelerate>=1.10.0,<2.0.0",
# Core dependencies # Core dependencies
"numpy>=2.0.0,<2.3.0", # NOTE: Explicitly listing numpy helps the resolver converge faster. Upper bound imposed by opencv-python-headless.
"setuptools>=71.0.0,<81.0.0", "setuptools>=71.0.0,<81.0.0",
"cmake>=3.29.0.1,<4.2.0", "cmake>=3.29.0.1,<4.2.0",
"packaging>=24.2,<26.0",
"torch>=2.2.1,<2.11.0",
"torchcodec>=0.2.1,<0.11.0; sys_platform != 'win32' and (sys_platform != 'linux' or (platform_machine != 'aarch64' and platform_machine != 'arm64' and platform_machine != 'armv7l')) and (sys_platform != 'darwin' or platform_machine != 'x86_64')",
"torchvision>=0.21.0,<0.26.0",
"einops>=0.8.0,<0.9.0", "einops>=0.8.0,<0.9.0",
"opencv-python-headless>=4.9.0,<4.13.0", "opencv-python-headless>=4.9.0,<4.13.0",
"av>=15.0.0,<16.0.0", "av>=15.0.0,<16.0.0",
"jsonlines>=4.0.0,<5.0.0", "jsonlines>=4.0.0,<5.0.0",
"pynput>=1.7.8,<1.9.0", "packaging>=24.2,<26.0",
"pynput>=1.7.7,<1.9.0",
"pyserial>=3.5,<4.0", "pyserial>=3.5,<4.0",
"wandb>=0.24.0,<0.25.0", "wandb>=0.24.0,<0.25.0",
"draccus==0.10.0", # TODO: Relax version constraint
"torch>=2.2.1,<2.11.0", # TODO: Bump dependency
"torchcodec>=0.2.1,<0.11.0; sys_platform != 'win32' and (sys_platform != 'linux' or (platform_machine != 'aarch64' and platform_machine != 'arm64' and platform_machine != 'armv7l')) and (sys_platform != 'darwin' or platform_machine != 'x86_64')", # TODO: Bump dependency
"torchvision>=0.21.0,<0.26.0", # TODO: Bump dependency
"draccus==0.10.0", # TODO: Remove ==
"gymnasium>=1.1.1,<2.0.0", "gymnasium>=1.1.1,<2.0.0",
"rerun-sdk>=0.24.0,<0.27.0", "rerun-sdk>=0.24.0,<0.27.0",
@@ -98,14 +95,10 @@ dependencies = [
# Common # Common
pygame-dep = ["pygame>=2.5.1,<2.7.0"] pygame-dep = ["pygame>=2.5.1,<2.7.0"]
placo-dep = ["placo>=0.9.6,<0.9.17"] placo-dep = ["placo>=0.9.6,<0.10.0"]
transformers-dep = ["transformers>=5.3.0,<6.0.0"] transformers-dep = ["transformers>=4.57.1,<5.0.0"]
grpcio-dep = ["grpcio==1.73.1", "protobuf>=6.31.1,<6.34.0"] grpcio-dep = ["grpcio==1.73.1", "protobuf>=6.31.1,<6.32.0"]
can-dep = ["python-can>=4.2.0,<5.0.0"] can-dep = ["python-can>=4.2.0,<5.0.0"]
peft-dep = ["peft>=0.18.0,<1.0.0"]
scipy-dep = ["scipy>=1.14.0,<2.0.0"]
qwen-vl-utils-dep = ["qwen-vl-utils>=0.0.11,<0.1.0"]
matplotlib-dep = ["matplotlib>=3.10.3,<4.0.0", "contourpy>=1.3.0,<2.0.0"] # NOTE: Explicitly listing contourpy helps the resolver converge faster.
# Motors # Motors
feetech = ["feetech-servo-sdk>=1.0.0,<2.0.0"] feetech = ["feetech-servo-sdk>=1.0.0,<2.0.0"]
@@ -119,36 +112,34 @@ gamepad = ["lerobot[pygame-dep]", "hidapi>=0.14.0,<0.15.0"]
hopejr = ["lerobot[feetech]", "lerobot[pygame-dep]"] hopejr = ["lerobot[feetech]", "lerobot[pygame-dep]"]
lekiwi = ["lerobot[feetech]", "pyzmq>=26.2.1,<28.0.0"] lekiwi = ["lerobot[feetech]", "pyzmq>=26.2.1,<28.0.0"]
unitree_g1 = [ unitree_g1 = [
"unitree-sdk2==1.0.1",
"pyzmq>=26.2.1,<28.0.0", "pyzmq>=26.2.1,<28.0.0",
"onnxruntime>=1.16.0,<2.0.0", "onnxruntime>=1.16.0,<2.0.0",
"pin>=3.0.0,<4.0.0", "pin>=3.0.0,<4.0.0",
"meshcat>=0.3.0,<0.4.0", "meshcat>=0.3.0,<0.4.0",
"lerobot[matplotlib-dep]", "matplotlib>=3.9.0,<4.0.0",
"lerobot[pygame-dep]",
"casadi>=3.6.0,<4.0.0", "casadi>=3.6.0,<4.0.0",
] ]
reachy2 = ["reachy2_sdk>=1.0.15,<1.1.0"] reachy2 = ["reachy2_sdk>=1.0.15,<1.1.0"]
kinematics = ["lerobot[placo-dep]"] kinematics = ["lerobot[placo-dep]"]
intelrealsense = [ intelrealsense = [
"pyrealsense2>=2.55.1.6486,<2.57.0 ; sys_platform != 'darwin'", "pyrealsense2>=2.55.1.6486,<2.57.0 ; sys_platform != 'darwin'",
"pyrealsense2-macosx>=2.54,<2.57.0 ; sys_platform == 'darwin'", "pyrealsense2-macosx>=2.54,<2.55.0 ; sys_platform == 'darwin'",
] ]
phone = ["hebi-py>=2.8.0,<2.12.0", "teleop>=0.1.0,<0.2.0", "fastapi<1.0", "lerobot[scipy-dep]"] phone = ["hebi-py>=2.8.0,<2.12.0", "teleop>=0.1.0,<0.2.0", "fastapi<1.0"]
# Policies # Policies
wallx = [ wallx = [
"lerobot[transformers-dep]", "transformers==4.49.0",
"lerobot[peft]", "peft==0.17.1",
"lerobot[scipy-dep]", "scipy==1.15.3",
"torchdiffeq>=0.2.4,<0.3.0", "torchdiffeq==0.2.5",
"lerobot[qwen-vl-utils-dep]", "qwen_vl_utils==0.0.11"
] ]
pi = ["lerobot[transformers-dep]", "lerobot[scipy-dep]"] pi = ["transformers @ git+https://github.com/huggingface/transformers.git@fix/lerobot_openpi", "scipy>=1.10.1,<1.15"]
smolvla = ["lerobot[transformers-dep]", "num2words>=0.5.14,<0.6.0", "accelerate>=1.7.0,<2.0.0", "safetensors>=0.4.3,<1.0.0"] smolvla = ["lerobot[transformers-dep]", "num2words>=0.5.14,<0.6.0", "accelerate>=1.7.0,<2.0.0", "safetensors>=0.4.3,<1.0.0"]
groot = [ groot = [
"lerobot[transformers-dep]", "lerobot[transformers-dep]",
"lerobot[peft]", "peft>=0.13.0,<1.0.0",
"dm-tree>=0.1.8,<1.0.0", "dm-tree>=0.1.8,<1.0.0",
"timm>=1.0.0,<1.1.0", "timm>=1.0.0,<1.1.0",
"safetensors>=0.4.3,<1.0.0", "safetensors>=0.4.3,<1.0.0",
@@ -157,13 +148,13 @@ groot = [
"ninja>=1.11.1,<2.0.0", "ninja>=1.11.1,<2.0.0",
"flash-attn>=2.5.9,<3.0.0 ; sys_platform != 'darwin'" "flash-attn>=2.5.9,<3.0.0 ; sys_platform != 'darwin'"
] ]
sarm = ["lerobot[transformers-dep]", "faker>=33.0.0,<35.0.0", "lerobot[matplotlib-dep]", "lerobot[qwen-vl-utils-dep]"] sarm = ["lerobot[transformers-dep]", "faker>=33.0.0,<35.0.0", "matplotlib>=3.10.3,<4.0.0", "qwen-vl-utils>=0.0.14,<0.1.0"]
xvla = ["lerobot[transformers-dep]"] xvla = ["lerobot[transformers-dep]"]
hilserl = ["lerobot[transformers-dep]", "gym-hil>=0.1.13,<0.2.0", "lerobot[grpcio-dep]", "lerobot[placo-dep]"] hilserl = ["lerobot[transformers-dep]", "gym-hil>=0.1.13,<0.2.0", "lerobot[grpcio-dep]", "lerobot[placo-dep]"]
# Features # Features
async = ["lerobot[grpcio-dep]", "lerobot[matplotlib-dep]"] async = ["lerobot[grpcio-dep]", "matplotlib>=3.10.3,<4.0.0"]
peft = ["lerobot[transformers-dep]", "lerobot[peft-dep]"] peft = ["lerobot[transformers-dep]", "peft>=0.18.0,<1.0.0"]
# Development # Development
dev = ["pre-commit>=3.7.0,<5.0.0", "debugpy>=1.8.1,<1.9.0", "lerobot[grpcio-dep]", "grpcio-tools==1.73.1", "mypy>=1.19.1"] dev = ["pre-commit>=3.7.0,<5.0.0", "debugpy>=1.8.1,<1.9.0", "lerobot[grpcio-dep]", "grpcio-tools==1.73.1", "mypy>=1.19.1"]
@@ -171,19 +162,13 @@ test = ["pytest>=8.1.0,<9.0.0", "pytest-timeout>=2.4.0,<3.0.0", "pytest-cov>=5.0
video_benchmark = ["scikit-image>=0.23.2,<0.26.0", "pandas>=2.2.2,<2.4.0"] video_benchmark = ["scikit-image>=0.23.2,<0.26.0", "pandas>=2.2.2,<2.4.0"]
# Simulation # Simulation
# NOTE: Explicitly listing scipy helps flatten the dependecy tree. aloha = ["gym-aloha>=0.1.2,<0.2.0"]
aloha = ["gym-aloha>=0.1.2,<0.2.0", "lerobot[scipy-dep]"]
pusht = ["gym-pusht>=0.1.5,<0.2.0", "pymunk>=6.6.0,<7.0.0"] # TODO: Fix pymunk version in gym-pusht instead pusht = ["gym-pusht>=0.1.5,<0.2.0", "pymunk>=6.6.0,<7.0.0"] # TODO: Fix pymunk version in gym-pusht instead
libero = ["lerobot[transformers-dep]", "hf-libero>=0.1.3,<0.2.0; sys_platform == 'linux'", "lerobot[scipy-dep]"] libero = ["lerobot[transformers-dep]", "hf-libero>=0.1.3,<0.2.0"]
metaworld = ["metaworld==3.0.0", "lerobot[scipy-dep]"] metaworld = ["metaworld==3.0.0"]
# All # All
all = [ all = [
# NOTE(resolver hint): scipy is pulled in transitively via lerobot[scipy-dep] through
# multiple extras (aloha, metaworld, pi, wallx, phone). Listing it explicitly
# helps pip's resolver converge by constraining scipy early, before it encounters
# the loose scipy requirements from transitive deps like dm-control and metaworld.
"scipy>=1.14.0,<2.0.0",
"lerobot[dynamixel]", "lerobot[dynamixel]",
"lerobot[gamepad]", "lerobot[gamepad]",
"lerobot[hopejr]", "lerobot[hopejr]",
@@ -191,8 +176,8 @@ all = [
"lerobot[reachy2]", "lerobot[reachy2]",
"lerobot[kinematics]", "lerobot[kinematics]",
"lerobot[intelrealsense]", "lerobot[intelrealsense]",
"lerobot[wallx]", # "lerobot[wallx]",
"lerobot[pi]", # "lerobot[pi]", TODO(Pepijn): Update pi to transformers v5
"lerobot[smolvla]", "lerobot[smolvla]",
# "lerobot[groot]", TODO(Steven): Gr00t requires specific installation instructions for flash-attn # "lerobot[groot]", TODO(Steven): Gr00t requires specific installation instructions for flash-attn
"lerobot[xvla]", "lerobot[xvla]",
@@ -204,11 +189,10 @@ all = [
"lerobot[aloha]", "lerobot[aloha]",
"lerobot[pusht]", "lerobot[pusht]",
"lerobot[phone]", "lerobot[phone]",
"lerobot[libero]; sys_platform == 'linux'", "lerobot[libero]",
"lerobot[metaworld]", "lerobot[metaworld]",
"lerobot[sarm]", "lerobot[sarm]",
"lerobot[peft]", "lerobot[peft]",
# "lerobot[unitree_g1]", TODO: Unitree requires specific installation instructions for unitree_sdk2
] ]
[project.scripts] [project.scripts]
@@ -237,7 +221,7 @@ lerobot = ["envs/*.json"]
where = ["src"] where = ["src"]
[tool.ruff] [tool.ruff]
target-version = "py312" target-version = "py310"
line-length = 110 line-length = 110
exclude = ["tests/artifacts/**/*.safetensors", "*_pb2.py", "*_pb2_grpc.py"] exclude = ["tests/artifacts/**/*.safetensors", "*_pb2.py", "*_pb2_grpc.py"]
@@ -329,7 +313,7 @@ default.extend-ignore-identifiers-re = [
# Uncomment [tool.mypy] first, then uncomment individual module overrides as they get proper type annotations # Uncomment [tool.mypy] first, then uncomment individual module overrides as they get proper type annotations
[tool.mypy] [tool.mypy]
python_version = "3.12" python_version = "3.10"
ignore_missing_imports = true ignore_missing_imports = true
follow_imports = "skip" follow_imports = "skip"
# warn_return_any = true # warn_return_any = true
@@ -413,3 +397,85 @@ ignore_errors = false
# [[tool.mypy.overrides]] # [[tool.mypy.overrides]]
# module = "lerobot.scripts.*" # module = "lerobot.scripts.*"
# ignore_errors = false # ignore_errors = false
[tool.uv]
# wallx requires transformers==4.49.0 which conflicts with other extras that need >=4.53.0
conflicts = [
[
{ extra = "wallx" },
{ extra = "transformers-dep" },
],
[
{ extra = "wallx" },
{ extra = "pi" },
],
[
{ extra = "wallx" },
{ extra = "smolvla" },
],
[
{ extra = "wallx" },
{ extra = "groot" },
],
[
{ extra = "wallx" },
{ extra = "xvla" },
],
[
{ extra = "wallx" },
{ extra = "sarm" },
],
[
{ extra = "wallx" },
{ extra = "hilserl" },
],
[
{ extra = "wallx" },
{ extra = "libero" },
],
[
{ extra = "wallx" },
{ extra = "peft" },
],
[
{ extra = "wallx" },
{ extra = "all" },
],
# pi uses custom branch which conflicts with transformers-dep
[
{ extra = "pi" },
{ extra = "transformers-dep" },
],
[
{ extra = "pi" },
{ extra = "smolvla" },
],
[
{ extra = "pi" },
{ extra = "groot" },
],
[
{ extra = "pi" },
{ extra = "xvla" },
],
[
{ extra = "pi" },
{ extra = "sarm" },
],
[
{ extra = "pi" },
{ extra = "hilserl" },
],
[
{ extra = "pi" },
{ extra = "libero" },
],
[
{ extra = "pi" },
{ extra = "peft" },
],
[
{ extra = "pi" },
{ extra = "all" },
],
]
+276 -175
View File
@@ -1,73 +1,76 @@
# #
# This file is autogenerated by pip-compile with Python 3.12 # This file is autogenerated by pip-compile with Python 3.10
# by the following command: # by the following command:
# #
# pip-compile --output-file=requirements-macos.txt requirements.in # pip-compile --output-file=requirements-macos.txt requirements.in
# #
-e .[all] -e .[all]
# via -[all] # via -[all]
absl-py==2.4.0 absl-py==2.3.1
# via # via
# dm-control # dm-control
# dm-env # dm-env
# dm-tree # dm-tree
# labmaze # labmaze
# mujoco # mujoco
accelerate==1.13.0 # tensorboard
accelerate==1.11.0
# via # via
# lerobot # lerobot
# peft # peft
aiohappyeyeballs==2.6.1 aiohappyeyeballs==2.6.1
# via aiohttp # via aiohttp
aiohttp==3.13.3 aiohttp==3.13.1
# via fsspec # via fsspec
aiosignal==1.4.0 aiosignal==1.4.0
# via aiohttp # via aiohttp
annotated-doc==0.0.4
# via
# fastapi
# typer
annotated-types==0.7.0 annotated-types==0.7.0
# via pydantic # via pydantic
anyio==4.12.1 antlr4-python3-runtime==4.9.3
# via
# hydra-core
# omegaconf
anyio==4.11.0
# via # via
# httpx
# starlette # starlette
# watchfiles # watchfiles
asttokens==3.0.1 asttokens==3.0.0
# via stack-data # via stack-data
async-timeout==5.0.1
# via aiohttp
attrs==25.4.0 attrs==25.4.0
# via # via
# aiohttp # aiohttp
# dm-tree # dm-tree
# jsonlines # jsonlines
# jsonschema
# referencing
# rerun-sdk # rerun-sdk
av==15.1.0 av==15.1.0
# via lerobot
bddl==1.0.1
# via libero
certifi==2025.10.5
# via # via
# lerobot
# qwen-vl-utils
certifi==2026.2.25
# via
# httpcore
# httpx
# requests # requests
# sentry-sdk # sentry-sdk
cffi==2.0.0 cffi==2.0.0
# via pymunk # via pymunk
cfgv==3.5.0 cfgv==3.4.0
# via pre-commit # via pre-commit
charset-normalizer==3.4.5 charset-normalizer==3.4.4
# via requests # via requests
click==8.3.1 click==8.3.0
# via # via
# typer
# uvicorn # uvicorn
# wandb # wandb
cloudpickle==3.1.2 cloudpickle==3.1.1
# via gymnasium # via
cmake==4.1.3 # gymnasium
# libero
cmake==4.1.0
# via lerobot # via lerobot
cmeel==0.59.0 cmeel==0.57.3
# via # via
# cmeel-assimp # cmeel-assimp
# cmeel-boost # cmeel-boost
@@ -105,17 +108,15 @@ cmeel-zlib==1.3.1
# via cmeel-assimp # via cmeel-assimp
coal-library==3.0.1 coal-library==3.0.1
# via pin # via pin
contourpy==1.3.3 contourpy==1.3.2
# via # via matplotlib
# lerobot coverage[toml]==7.11.0
# matplotlib
coverage[toml]==7.13.4
# via pytest-cov # via pytest-cov
cycler==0.12.1 cycler==0.12.1
# via matplotlib # via matplotlib
datasets==4.6.1 datasets==4.1.1
# via lerobot # via lerobot
debugpy==1.8.20 debugpy==1.8.17
# via lerobot # via lerobot
decorator==5.2.1 decorator==5.2.1
# via ipython # via ipython
@@ -129,7 +130,7 @@ dill==0.4.0
# multiprocess # multiprocess
distlib==0.4.0 distlib==0.4.0
# via virtualenv # via virtualenv
dm-control==1.0.37 dm-control==1.0.34
# via gym-aloha # via gym-aloha
dm-env==1.6 dm-env==1.6
# via dm-control # via dm-control
@@ -137,55 +138,69 @@ dm-tree==0.1.9
# via # via
# dm-control # dm-control
# dm-env # dm-env
# lerobot
docopt==0.6.2 docopt==0.6.2
# via num2words # via num2words
draccus==0.10.0 draccus==0.10.0
# via lerobot # via lerobot
dynamixel-sdk==3.8.4 dynamixel-sdk==3.8.4
# via lerobot # via lerobot
easydict==1.13
# via libero
egl-probe @ git+https://github.com/huggingface/egl_probe.git
# via
# libero
# robomimic
eigenpy==3.10.3 eigenpy==3.10.3
# via coal-library # via coal-library
einops==0.8.2 einops==0.8.1
# via lerobot
eiquadprog==1.2.9
# via placo
etils[epath,epy]==1.14.0
# via mujoco
executing==2.2.1
# via stack-data
faker==34.0.2
# via lerobot
farama-notifications==0.0.4
# via gymnasium
fastapi==0.135.1
# via # via
# lerobot # lerobot
# teleop # libero
eiquadprog==1.2.9
# via placo
etils[epath,epy]==1.13.0
# via mujoco
exceptiongroup==1.3.0
# via
# anyio
# ipython
# pytest
executing==2.2.1
# via stack-data
farama-notifications==0.0.4
# via gymnasium
fastapi==0.119.1
# via teleop
fastjsonschema==2.21.2
# via nbformat
feetech-servo-sdk==1.0.0 feetech-servo-sdk==1.0.0
# via lerobot # via lerobot
filelock==3.25.0 filelock==3.20.0
# via # via
# datasets # datasets
# diffusers # diffusers
# huggingface-hub # huggingface-hub
# python-discovery
# torch # torch
# transformers
# virtualenv # virtualenv
fonttools==4.61.1 fonttools==4.60.1
# via matplotlib # via matplotlib
frozenlist==1.8.0 frozenlist==1.8.0
# via # via
# aiohttp # aiohttp
# aiosignal # aiosignal
fsspec[http]==2026.2.0 fsspec[http]==2025.9.0
# via # via
# datasets # datasets
# etils # etils
# huggingface-hub # huggingface-hub
# torch # torch
future==1.0.0
# via libero
gitdb==4.0.12 gitdb==4.0.12
# via gitpython # via gitpython
gitpython==3.1.46 gitpython==3.1.45
# via wandb # via wandb
glfw==2.10.0 glfw==2.10.0
# via # via
@@ -197,6 +212,7 @@ grpcio==1.73.1
# lerobot # lerobot
# reachy2-sdk # reachy2-sdk
# reachy2-sdk-api # reachy2-sdk-api
# tensorboard
grpcio-tools==1.73.1 grpcio-tools==1.73.1
# via # via
# lerobot # lerobot
@@ -207,67 +223,71 @@ gym-hil==0.1.13
# via lerobot # via lerobot
gym-pusht==0.1.6 gym-pusht==0.1.6
# via lerobot # via lerobot
gymnasium==1.2.3 gymnasium==1.2.1
# via # via
# gym-aloha # gym-aloha
# gym-hil # gym-hil
# gym-pusht # gym-pusht
# lerobot # lerobot
# libero
# metaworld # metaworld
h11==0.16.0 h11==0.16.0
# via # via uvicorn
# httpcore h5py==3.15.1
# uvicorn # via robomimic
hebi-py==2.11.0 hebi-py==2.11.0
# via lerobot # via lerobot
hf-xet==1.3.2 hf-transfer==0.1.9
# via huggingface-hub
hf-xet==1.1.10
# via huggingface-hub # via huggingface-hub
hidapi==0.14.0.post4 hidapi==0.14.0.post4
# via # via
# gym-hil # gym-hil
# lerobot # lerobot
httpcore==1.0.9
# via httpx
httptools==0.7.1 httptools==0.7.1
# via uvicorn # via uvicorn
httpx==0.28.1 huggingface-hub[cli,hf-transfer]==0.35.3
# via
# datasets
# huggingface-hub
huggingface-hub==1.6.0
# via # via
# accelerate # accelerate
# datasets # datasets
# diffusers # diffusers
# lerobot # lerobot
# peft # peft
# timm
# tokenizers # tokenizers
# transformers # transformers
identify==2.6.17 hydra-core==1.3.2
# via libero
identify==2.6.15
# via pre-commit # via pre-commit
idna==3.11 idna==3.11
# via # via
# anyio # anyio
# httpx
# requests # requests
# yarl # yarl
imageio[ffmpeg]==2.37.2 imageio[ffmpeg]==2.37.0
# via # via
# gym-aloha # gym-aloha
# gym-hil # gym-hil
# lerobot # lerobot
# metaworld # metaworld
# robomimic
# scikit-image # scikit-image
imageio-ffmpeg==0.6.0 imageio-ffmpeg==0.6.0
# via imageio # via
importlib-metadata==8.7.1 # imageio
# robomimic
importlib-metadata==8.7.0
# via diffusers # via diffusers
importlib-resources==6.5.2
# via etils
iniconfig==2.3.0 iniconfig==2.3.0
# via pytest # via pytest
ipython==9.11.0 inquirerpy==0.3.4
# via huggingface-hub
ipython==8.37.0
# via meshcat # via meshcat
ipython-pygments-lexers==1.1.1
# via ipython
ischedule==1.2.7 ischedule==1.2.7
# via placo # via placo
jedi==0.19.2 jedi==0.19.2
@@ -276,24 +296,44 @@ jinja2==3.1.6
# via torch # via torch
jsonlines==4.0.0 jsonlines==4.0.0
# via lerobot # via lerobot
jsonschema==4.25.1
# via nbformat
jsonschema-specifications==2025.9.1
# via jsonschema
jupyter-core==5.9.1
# via nbformat
jupytext==1.18.1
# via bddl
kiwisolver==1.4.9 kiwisolver==1.4.9
# via matplotlib # via matplotlib
labmaze==1.0.6 labmaze==1.0.6
# via dm-control # via dm-control
lazy-loader==0.5 lazy-loader==0.4
# via scikit-image # via scikit-image
librt==0.8.1 libero @ git+https://github.com/huggingface/lerobot-libero.git@main
# via mypy # via lerobot
llvmlite==0.45.1
# via numba
lxml==6.0.2 lxml==6.0.2
# via dm-control # via dm-control
markdown==3.9
# via tensorboard
markdown-it-py==4.0.0 markdown-it-py==4.0.0
# via rich # via
# jupytext
# mdit-py-plugins
markupsafe==3.0.3 markupsafe==3.0.3
# via jinja2 # via
matplotlib==3.10.8 # jinja2
# via lerobot # werkzeug
matplotlib==3.10.7
# via
# lerobot
# libero
matplotlib-inline==0.2.1 matplotlib-inline==0.2.1
# via ipython # via ipython
mdit-py-plugins==0.5.0
# via jupytext
mdurl==0.1.2 mdurl==0.1.2
# via markdown-it-py # via markdown-it-py
mergedeep==1.3.4 mergedeep==1.3.4
@@ -306,35 +346,41 @@ mock-serial==0.0.1
# via lerobot # via lerobot
mpmath==1.3.0 mpmath==1.3.0
# via sympy # via sympy
mujoco==3.5.0 mujoco==3.3.7
# via # via
# dm-control # dm-control
# gym-aloha # gym-aloha
# gym-hil # gym-hil
# libero
# metaworld # metaworld
multidict==6.7.1 # robosuite
multidict==6.7.0
# via # via
# aiohttp # aiohttp
# yarl # yarl
multiprocess==0.70.18 multiprocess==0.70.16
# via datasets # via datasets
mypy==1.19.1
# via lerobot
mypy-extensions==1.1.0 mypy-extensions==1.1.0
# via typing-inspect
nbformat==5.10.4
# via jupytext
networkx==3.4.2
# via # via
# mypy # bddl
# typing-inspect
networkx==3.6.1
# via
# scikit-image # scikit-image
# torch # torch
nodeenv==1.10.0 ninja==1.13.0
# via lerobot
nodeenv==1.9.1
# via pre-commit # via pre-commit
num2words==0.5.14 num2words==0.5.14
# via lerobot # via lerobot
numba==0.62.1
# via robosuite
numpy==2.2.6 numpy==2.2.6
# via # via
# accelerate # accelerate
# bddl
# cmeel-boost # cmeel-boost
# contourpy # contourpy
# datasets # datasets
@@ -343,14 +389,16 @@ numpy==2.2.6
# dm-env # dm-env
# dm-tree # dm-tree
# gymnasium # gymnasium
# h5py
# hebi-py # hebi-py
# imageio # imageio
# labmaze # labmaze
# lerobot # libero
# matplotlib # matplotlib
# meshcat # meshcat
# metaworld # metaworld
# mujoco # mujoco
# numba
# opencv-python # opencv-python
# opencv-python-headless # opencv-python-headless
# pandas # pandas
@@ -358,18 +406,26 @@ numpy==2.2.6
# pyquaternion # pyquaternion
# reachy2-sdk # reachy2-sdk
# rerun-sdk # rerun-sdk
# robomimic
# robosuite
# scikit-image # scikit-image
# scipy # scipy
# shapely # shapely
# teleop # teleop
# tensorboard
# tensorboardx
# tifffile # tifffile
# torchvision # torchvision
# transformers # transformers
# transforms3d # transforms3d
opencv-python==4.13.0.92 omegaconf==2.3.0
# via hydra-core
opencv-python==4.12.0.88
# via # via
# gym-pusht # gym-pusht
# libero
# reachy2-sdk # reachy2-sdk
# robosuite
opencv-python-headless==4.12.0.88 opencv-python-headless==4.12.0.88
# via lerobot # via lerobot
orderly-set==5.5.0 orderly-set==5.5.0
@@ -379,87 +435,97 @@ packaging==25.0
# accelerate # accelerate
# datasets # datasets
# huggingface-hub # huggingface-hub
# hydra-core
# jupytext
# lazy-loader # lazy-loader
# lerobot # lerobot
# matplotlib # matplotlib
# peft # peft
# pytest # pytest
# qwen-vl-utils
# reachy2-sdk # reachy2-sdk
# scikit-image # scikit-image
# tensorboard
# tensorboardx
# transformers # transformers
# wandb # wandb
pandas==2.3.3 pandas==2.3.3
# via # via
# datasets # datasets
# lerobot # lerobot
parso==0.8.6 parso==0.8.5
# via jedi # via jedi
pathspec==1.0.4 peft==0.17.1
# via mypy
peft==0.18.1
# via lerobot # via lerobot
pexpect==4.9.0 pexpect==4.9.0
# via ipython # via ipython
pillow==12.1.1 pfzy==0.3.4
# via inquirerpy
pillow==12.0.0
# via # via
# diffusers # diffusers
# imageio # imageio
# lerobot
# matplotlib # matplotlib
# meshcat # meshcat
# qwen-vl-utils
# rerun-sdk # rerun-sdk
# robosuite
# scikit-image # scikit-image
# tensorboard
# torchvision # torchvision
pin==3.4.0 pin==3.4.0
# via placo # via placo
placo==0.9.16 placo==0.9.14
# via lerobot # via lerobot
platformdirs==4.9.4 platformdirs==4.5.0
# via # via
# python-discovery # jupyter-core
# virtualenv # virtualenv
# wandb # wandb
pluggy==1.6.0 pluggy==1.6.0
# via # via
# pytest # pytest
# pytest-cov # pytest-cov
pre-commit==4.5.1 pre-commit==4.3.0
# via lerobot # via lerobot
prompt-toolkit==3.0.52 prompt-toolkit==3.0.52
# via ipython # via
# inquirerpy
# ipython
propcache==0.4.1 propcache==0.4.1
# via # via
# aiohttp # aiohttp
# yarl # yarl
protobuf==6.31.1 protobuf==6.31.0
# via # via
# dm-control # dm-control
# grpcio-tools # grpcio-tools
# lerobot # lerobot
# reachy2-sdk # reachy2-sdk
# reachy2-sdk-api # reachy2-sdk-api
# tensorboard
# tensorboardx
# wandb # wandb
psutil==7.2.2 psutil==7.1.1
# via # via
# accelerate # accelerate
# imageio # imageio
# peft # peft
# robomimic
ptyprocess==0.7.0 ptyprocess==0.7.0
# via pexpect # via pexpect
pure-eval==0.2.3 pure-eval==0.2.3
# via stack-data # via stack-data
pyarrow==23.0.1 pyarrow==21.0.0
# via # via
# datasets # datasets
# rerun-sdk # rerun-sdk
pycparser==3.0 pycparser==2.23
# via cffi # via cffi
pydantic==2.12.5 pydantic==2.12.3
# via # via
# fastapi # fastapi
# wandb # wandb
pydantic-core==2.41.5 pydantic-core==2.41.4
# via pydantic # via pydantic
pygame==2.6.1 pygame==2.6.1
# via # via
@@ -469,35 +535,33 @@ pygame==2.6.1
pygments==2.19.2 pygments==2.19.2
# via # via
# ipython # ipython
# ipython-pygments-lexers
# pytest # pytest
# rich
pymunk==6.11.1 pymunk==6.11.1
# via # via
# gym-pusht # gym-pusht
# lerobot # lerobot
pyngrok==7.5.1 pyngrok==7.4.1
# via meshcat # via meshcat
pynput==1.8.1 pynput==1.8.1
# via # via
# gym-hil # gym-hil
# lerobot # lerobot
pyobjc-core==12.1 pyobjc-core==12.0
# via # via
# pyobjc-framework-applicationservices # pyobjc-framework-applicationservices
# pyobjc-framework-cocoa # pyobjc-framework-cocoa
# pyobjc-framework-coretext # pyobjc-framework-coretext
# pyobjc-framework-quartz # pyobjc-framework-quartz
pyobjc-framework-applicationservices==12.1 pyobjc-framework-applicationservices==12.0
# via pynput # via pynput
pyobjc-framework-cocoa==12.1 pyobjc-framework-cocoa==12.0
# via # via
# pyobjc-framework-applicationservices # pyobjc-framework-applicationservices
# pyobjc-framework-coretext # pyobjc-framework-coretext
# pyobjc-framework-quartz # pyobjc-framework-quartz
pyobjc-framework-coretext==12.1 pyobjc-framework-coretext==12.0
# via pyobjc-framework-applicationservices # via pyobjc-framework-applicationservices
pyobjc-framework-quartz==12.1 pyobjc-framework-quartz==12.0
# via # via
# pynput # pynput
# pyobjc-framework-applicationservices # pyobjc-framework-applicationservices
@@ -506,13 +570,13 @@ pyopengl==3.1.10
# via # via
# dm-control # dm-control
# mujoco # mujoco
pyparsing==3.3.2 pyparsing==3.2.5
# via # via
# dm-control # dm-control
# matplotlib # matplotlib
pyquaternion==0.9.9 pyquaternion==0.9.9
# via reachy2-sdk # via reachy2-sdk
pyrealsense2-macosx==2.56.5 pyrealsense2-macosx==2.54.2
# via lerobot # via lerobot
pyserial==3.5 pyserial==3.5
# via # via
@@ -521,6 +585,7 @@ pyserial==3.5
# lerobot # lerobot
pytest==8.4.2 pytest==8.4.2
# via # via
# bddl
# lerobot # lerobot
# pytest-cov # pytest-cov
# pytest-timeout # pytest-timeout
@@ -531,14 +596,11 @@ pytest-timeout==2.4.0
# via lerobot # via lerobot
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
# via # via
# faker
# matplotlib # matplotlib
# pandas # pandas
python-discovery==1.1.1 python-dotenv==1.1.1
# via virtualenv
python-dotenv==1.2.2
# via uvicorn # via uvicorn
pytz==2026.1.post1 pytz==2025.2
# via pandas # via pandas
pyyaml==6.0.3 pyyaml==6.0.3
# via # via
@@ -547,10 +609,13 @@ pyyaml==6.0.3
# draccus # draccus
# hebi-py # hebi-py
# huggingface-hub # huggingface-hub
# jupytext
# omegaconf
# peft # peft
# pre-commit # pre-commit
# pyngrok # pyngrok
# pyyaml-include # pyyaml-include
# timm
# transformers # transformers
# uvicorn # uvicorn
# wandb # wandb
@@ -560,13 +625,15 @@ pyzmq==27.1.0
# via # via
# lerobot # lerobot
# meshcat # meshcat
qwen-vl-utils==0.0.14 reachy2-sdk==1.0.14
# via lerobot
reachy2-sdk==1.0.15
# via lerobot # via lerobot
reachy2-sdk-api==1.0.21 reachy2-sdk-api==1.0.21
# via reachy2-sdk # via reachy2-sdk
regex==2026.2.28 referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
regex==2025.10.23
# via # via
# diffusers # diffusers
# transformers # transformers
@@ -575,150 +642,184 @@ requests==2.32.5
# datasets # datasets
# diffusers # diffusers
# dm-control # dm-control
# qwen-vl-utils # huggingface-hub
# teleop # teleop
# transformers
# wandb # wandb
rerun-sdk==0.26.2 rerun-sdk==0.26.1
# via lerobot # via lerobot
rhoban-cmeel-jsoncpp==1.9.4.9 rhoban-cmeel-jsoncpp==1.9.4.9
# via placo # via placo
rich==14.3.3 robomimic==0.2.0
# via typer # via libero
safetensors==0.7.0 robosuite==1.4.0
# via libero
rpds-py==0.28.0
# via
# jsonschema
# referencing
safetensors==0.6.2
# via # via
# accelerate # accelerate
# diffusers # diffusers
# lerobot # lerobot
# peft # peft
# timm
# transformers # transformers
scikit-image==0.25.2 scikit-image==0.25.2
# via # via
# gym-pusht # gym-pusht
# lerobot # lerobot
scipy==1.17.1 scipy==1.15.3
# via # via
# dm-control # dm-control
# lerobot
# metaworld # metaworld
# robosuite
# scikit-image # scikit-image
# torchdiffeq sentry-sdk==2.42.1
sentry-sdk==2.54.0
# via wandb # via wandb
shapely==2.1.2 shapely==2.1.2
# via gym-pusht # via gym-pusht
shellingham==1.5.4
# via typer
six==1.17.0 six==1.17.0
# via # via
# pynput # pynput
# python-dateutil # python-dateutil
smmap==5.0.3 smmap==5.0.2
# via gitdb # via gitdb
sniffio==1.3.1
# via anyio
stack-data==0.6.3 stack-data==0.6.3
# via ipython # via ipython
starlette==0.52.1 starlette==0.48.0
# via fastapi # via fastapi
sympy==1.14.0 sympy==1.14.0
# via torch # via torch
teleop==0.1.4 teleop==0.1.2
# via lerobot # via lerobot
termcolor==3.3.0 tensorboard==2.20.0
# via lerobot # via robomimic
tifffile==2026.3.3 tensorboard-data-server==0.7.2
# via tensorboard
tensorboardx==2.6.4
# via robomimic
termcolor==3.1.0
# via
# lerobot
# robomimic
thop==0.1.1.post2209072238
# via libero
tifffile==2025.5.10
# via scikit-image # via scikit-image
tokenizers==0.22.2 timm==1.0.20
# via lerobot
tokenizers==0.22.1
# via transformers # via transformers
toml==0.10.2 toml==0.10.2
# via draccus # via draccus
torch==2.10.0 tomli==2.3.0
# via
# cmeel
# coverage
# jupytext
# pytest
torch==2.7.1
# via # via
# accelerate # accelerate
# lerobot # lerobot
# peft # peft
# torchdiffeq # robomimic
# thop
# timm
# torchvision # torchvision
torchcodec==0.10.0 torchcodec==0.5
# via lerobot # via lerobot
torchdiffeq==0.2.5 torchvision==0.22.1
# via lerobot # via
torchvision==0.25.0 # lerobot
# via lerobot # robomimic
tornado==6.5.4 # timm
tornado==6.5.2
# via meshcat # via meshcat
tqdm==4.67.3 tqdm==4.67.1
# via # via
# datasets # datasets
# dm-control # dm-control
# huggingface-hub # huggingface-hub
# peft # peft
# robomimic
# transformers # transformers
traitlets==5.14.3 traitlets==5.14.3
# via # via
# ipython # ipython
# jupyter-core
# matplotlib-inline # matplotlib-inline
transformers==5.3.0 # nbformat
transformers==4.57.1
# via # via
# lerobot # lerobot
# libero
# peft # peft
transforms3d==0.4.2 transforms3d==0.4.2
# via teleop # via teleop
typer==0.24.1
# via
# huggingface-hub
# transformers
typing-extensions==4.15.0 typing-extensions==4.15.0
# via # via
# aiosignal # aiosignal
# anyio # anyio
# etils # etils
# faker # exceptiongroup
# fastapi # fastapi
# gymnasium # gymnasium
# huggingface-hub # huggingface-hub
# mypy # ipython
# multidict
# pydantic # pydantic
# pydantic-core # pydantic-core
# referencing
# rerun-sdk # rerun-sdk
# starlette # starlette
# torch # torch
# typing-inspect # typing-inspect
# typing-inspection # typing-inspection
# uvicorn
# virtualenv
# wandb # wandb
typing-inspect==0.9.0 typing-inspect==0.9.0
# via draccus # via draccus
typing-inspection==0.4.2 typing-inspection==0.4.2
# via # via pydantic
# fastapi tzdata==2025.2
# pydantic
tzdata==2025.3
# via pandas # via pandas
u-msgpack-python==2.8.0 u-msgpack-python==2.8.0
# via meshcat # via meshcat
urllib3==2.6.3 urllib3==2.5.0
# via # via
# requests # requests
# sentry-sdk # sentry-sdk
uvicorn[standard]==0.41.0 uvicorn[standard]==0.38.0
# via teleop # via teleop
uvloop==0.22.1 uvloop==0.22.1
# via uvicorn # via uvicorn
virtualenv==21.1.0 virtualenv==20.35.3
# via pre-commit # via pre-commit
wandb==0.24.2 wandb==0.21.4
# via lerobot # via
# lerobot
# libero
watchfiles==1.1.1 watchfiles==1.1.1
# via uvicorn # via uvicorn
wcwidth==0.6.0 wcwidth==0.2.14
# via prompt-toolkit # via prompt-toolkit
websocket-client==1.9.0 websocket-client==1.9.0
# via teleop # via teleop
websockets==16.0 websockets==15.0.1
# via uvicorn # via uvicorn
wrapt==2.1.2 werkzeug==3.1.3
# via tensorboard
wrapt==2.0.0
# via dm-tree # via dm-tree
xxhash==3.6.0 xxhash==3.6.0
# via datasets # via datasets
yarl==1.23.0 yarl==1.22.0
# via aiohttp # via aiohttp
zipp==3.23.0 zipp==3.23.0
# via # via
+187 -208
View File
@@ -1,12 +1,12 @@
# #
# This file is autogenerated by pip-compile with Python 3.12 # This file is autogenerated by pip-compile with Python 3.10
# by the following command: # by the following command:
# #
# pip-compile --output-file=requirements-ubuntu.txt requirements.in # pip-compile --output-file=requirements-ubuntu.txt requirements.in
# #
-e .[all] -e .[all]
# via -[all] # via -[all]
absl-py==2.4.0 absl-py==2.3.1
# via # via
# dm-control # dm-control
# dm-env # dm-env
@@ -14,33 +14,30 @@ absl-py==2.4.0
# labmaze # labmaze
# mujoco # mujoco
# tensorboard # tensorboard
accelerate==1.13.0 accelerate==1.11.0
# via # via
# lerobot # lerobot
# peft # peft
aiohappyeyeballs==2.6.1 aiohappyeyeballs==2.6.1
# via aiohttp # via aiohttp
aiohttp==3.13.3 aiohttp==3.13.1
# via fsspec # via fsspec
aiosignal==1.4.0 aiosignal==1.4.0
# via aiohttp # via aiohttp
annotated-doc==0.0.4
# via
# fastapi
# typer
annotated-types==0.7.0 annotated-types==0.7.0
# via pydantic # via pydantic
antlr4-python3-runtime==4.9.3 antlr4-python3-runtime==4.9.3
# via # via
# hydra-core # hydra-core
# omegaconf # omegaconf
anyio==4.12.1 anyio==4.11.0
# via # via
# httpx
# starlette # starlette
# watchfiles # watchfiles
asttokens==3.0.1 asttokens==3.0.0
# via stack-data # via stack-data
async-timeout==5.0.1
# via aiohttp
attrs==25.4.0 attrs==25.4.0
# via # via
# aiohttp # aiohttp
@@ -50,35 +47,30 @@ attrs==25.4.0
# referencing # referencing
# rerun-sdk # rerun-sdk
av==15.1.0 av==15.1.0
# via # via lerobot
# lerobot
# qwen-vl-utils
bddl==1.0.1 bddl==1.0.1
# via hf-libero # via libero
certifi==2026.2.25 certifi==2025.10.5
# via # via
# httpcore
# httpx
# requests # requests
# sentry-sdk # sentry-sdk
cffi==2.0.0 cffi==2.0.0
# via pymunk # via pymunk
cfgv==3.5.0 cfgv==3.4.0
# via pre-commit # via pre-commit
charset-normalizer==3.4.5 charset-normalizer==3.4.4
# via requests # via requests
click==8.3.1 click==8.3.0
# via # via
# typer
# uvicorn # uvicorn
# wandb # wandb
cloudpickle==3.1.2 cloudpickle==3.1.1
# via # via
# gymnasium # gymnasium
# hf-libero # libero
cmake==4.1.3 cmake==4.1.0
# via lerobot # via lerobot
cmeel==0.59.0 cmeel==0.57.3
# via # via
# cmeel-assimp # cmeel-assimp
# cmeel-boost # cmeel-boost
@@ -116,24 +108,20 @@ cmeel-zlib==1.3.1
# via cmeel-assimp # via cmeel-assimp
coal-library==3.0.1 coal-library==3.0.1
# via pin # via pin
contourpy==1.3.3 contourpy==1.3.2
# via # via matplotlib
# lerobot coverage[toml]==7.11.0
# matplotlib
coverage[toml]==7.13.4
# via pytest-cov # via pytest-cov
cuda-bindings==12.9.4
# via torch
cuda-pathfinder==1.4.1
# via cuda-bindings
cycler==0.12.1 cycler==0.12.1
# via matplotlib # via matplotlib
datasets==4.6.1 datasets==4.1.1
# via lerobot # via lerobot
debugpy==1.8.20 debugpy==1.8.17
# via lerobot # via lerobot
decorator==5.2.1 decorator==5.2.1
# via ipython # via ipython
decord==0.6.0
# via lerobot
deepdiff==8.6.1 deepdiff==8.6.1
# via lerobot # via lerobot
diffusers==0.35.2 diffusers==0.35.2
@@ -144,7 +132,7 @@ dill==0.4.0
# multiprocess # multiprocess
distlib==0.4.0 distlib==0.4.0
# via virtualenv # via virtualenv
dm-control==1.0.37 dm-control==1.0.34
# via gym-aloha # via gym-aloha
dm-env==1.6 dm-env==1.6
# via dm-control # via dm-control
@@ -152,6 +140,7 @@ dm-tree==0.1.9
# via # via
# dm-control # dm-control
# dm-env # dm-env
# lerobot
docopt==0.6.2 docopt==0.6.2
# via num2words # via num2words
draccus==0.10.0 draccus==0.10.0
@@ -159,60 +148,66 @@ draccus==0.10.0
dynamixel-sdk==3.8.4 dynamixel-sdk==3.8.4
# via lerobot # via lerobot
easydict==1.13 easydict==1.13
# via hf-libero # via libero
egl-probe==1.0.2 egl-probe @ git+https://github.com/huggingface/egl_probe.git
# via robomimic # via
# libero
# robomimic
eigenpy==3.10.3 eigenpy==3.10.3
# via coal-library # via coal-library
einops==0.8.2 einops==0.8.1
# via # via
# hf-libero # flash-attn
# lerobot # lerobot
# libero
eiquadprog==1.2.9 eiquadprog==1.2.9
# via placo # via placo
etils[epath,epy]==1.14.0 etils[epath,epy]==1.13.0
# via mujoco # via mujoco
evdev==1.9.3 evdev==1.9.2
# via pynput # via pynput
exceptiongroup==1.3.0
# via
# anyio
# ipython
# pytest
executing==2.2.1 executing==2.2.1
# via stack-data # via stack-data
faker==34.0.2
# via lerobot
farama-notifications==0.0.4 farama-notifications==0.0.4
# via gymnasium # via gymnasium
fastapi==0.135.1 fastapi==0.119.1
# via # via teleop
# lerobot
# teleop
fastjsonschema==2.21.2 fastjsonschema==2.21.2
# via nbformat # via nbformat
feetech-servo-sdk==1.0.0 feetech-servo-sdk==1.0.0
# via lerobot # via lerobot
filelock==3.25.0 filelock==3.20.0
# via # via
# datasets # datasets
# diffusers # diffusers
# huggingface-hub # huggingface-hub
# python-discovery
# torch # torch
# transformers
# virtualenv # virtualenv
fonttools==4.61.1 flash-attn==2.8.3
# via lerobot
fonttools==4.60.1
# via matplotlib # via matplotlib
frozenlist==1.8.0 frozenlist==1.8.0
# via # via
# aiohttp # aiohttp
# aiosignal # aiosignal
fsspec[http]==2026.2.0 fsspec[http]==2025.9.0
# via # via
# datasets # datasets
# etils # etils
# huggingface-hub # huggingface-hub
# torch # torch
future==1.0.0 future==1.0.0
# via hf-libero # via libero
gitdb==4.0.12 gitdb==4.0.12
# via gitpython # via gitpython
gitpython==3.1.46 gitpython==3.1.45
# via wandb # via wandb
glfw==2.10.0 glfw==2.10.0
# via # via
@@ -235,60 +230,50 @@ gym-hil==0.1.13
# via lerobot # via lerobot
gym-pusht==0.1.6 gym-pusht==0.1.6
# via lerobot # via lerobot
gymnasium==1.2.3 gymnasium==1.2.1
# via # via
# gym-aloha # gym-aloha
# gym-hil # gym-hil
# gym-pusht # gym-pusht
# hf-libero
# lerobot # lerobot
# libero
# metaworld # metaworld
h11==0.16.0 h11==0.16.0
# via # via uvicorn
# httpcore h5py==3.15.1
# uvicorn
h5py==3.16.0
# via robomimic # via robomimic
hebi-py==2.11.0 hebi-py==2.11.0
# via lerobot # via lerobot
hf-egl-probe==1.0.2 hf-transfer==0.1.9
# via hf-libero # via huggingface-hub
hf-libero==0.1.3 hf-xet==1.1.10
# via lerobot
hf-xet==1.3.2
# via huggingface-hub # via huggingface-hub
hidapi==0.14.0.post4 hidapi==0.14.0.post4
# via # via
# gym-hil # gym-hil
# lerobot # lerobot
httpcore==1.0.9
# via httpx
httptools==0.7.1 httptools==0.7.1
# via uvicorn # via uvicorn
httpx==0.28.1 huggingface-hub[cli,hf-transfer]==0.35.3
# via
# datasets
# huggingface-hub
huggingface-hub==1.6.0
# via # via
# accelerate # accelerate
# datasets # datasets
# diffusers # diffusers
# lerobot # lerobot
# peft # peft
# timm
# tokenizers # tokenizers
# transformers # transformers
hydra-core==1.3.2 hydra-core==1.3.2
# via hf-libero # via libero
identify==2.6.17 identify==2.6.15
# via pre-commit # via pre-commit
idna==3.11 idna==3.11
# via # via
# anyio # anyio
# httpx
# requests # requests
# yarl # yarl
imageio[ffmpeg]==2.37.2 imageio[ffmpeg]==2.37.0
# via # via
# gym-aloha # gym-aloha
# gym-hil # gym-hil
@@ -300,14 +285,16 @@ imageio-ffmpeg==0.6.0
# via # via
# imageio # imageio
# robomimic # robomimic
importlib-metadata==8.7.1 importlib-metadata==8.7.0
# via diffusers # via diffusers
importlib-resources==6.5.2
# via etils
iniconfig==2.3.0 iniconfig==2.3.0
# via pytest # via pytest
ipython==9.11.0 inquirerpy==0.3.4
# via huggingface-hub
ipython==8.37.0
# via meshcat # via meshcat
ipython-pygments-lexers==1.1.1
# via ipython
ischedule==1.2.7 ischedule==1.2.7
# via placo # via placo
jedi==0.19.2 jedi==0.19.2
@@ -316,41 +303,40 @@ jinja2==3.1.6
# via torch # via torch
jsonlines==4.0.0 jsonlines==4.0.0
# via lerobot # via lerobot
jsonschema==4.26.0 jsonschema==4.25.1
# via nbformat # via nbformat
jsonschema-specifications==2025.9.1 jsonschema-specifications==2025.9.1
# via jsonschema # via jsonschema
jupyter-core==5.9.1 jupyter-core==5.9.1
# via nbformat # via nbformat
jupytext==1.19.1 jupytext==1.18.1
# via bddl # via bddl
kiwisolver==1.4.9 kiwisolver==1.4.9
# via matplotlib # via matplotlib
labmaze==1.0.6 labmaze==1.0.6
# via dm-control # via dm-control
lazy-loader==0.5 lazy-loader==0.4
# via scikit-image # via scikit-image
librt==0.8.1 libero @ git+https://github.com/huggingface/lerobot-libero.git@main
# via mypy # via lerobot
llvmlite==0.46.0 llvmlite==0.45.1
# via numba # via numba
lxml==6.0.2 lxml==6.0.2
# via dm-control # via dm-control
markdown==3.10.2 markdown==3.9
# via tensorboard # via tensorboard
markdown-it-py==4.0.0 markdown-it-py==4.0.0
# via # via
# jupytext # jupytext
# mdit-py-plugins # mdit-py-plugins
# rich
markupsafe==3.0.3 markupsafe==3.0.3
# via # via
# jinja2 # jinja2
# werkzeug # werkzeug
matplotlib==3.10.8 matplotlib==3.10.7
# via # via
# hf-libero
# lerobot # lerobot
# libero
matplotlib-inline==0.2.1 matplotlib-inline==0.2.1
# via ipython # via ipython
mdit-py-plugins==0.5.0 mdit-py-plugins==0.5.0
@@ -367,38 +353,36 @@ mock-serial==0.0.1
# via lerobot # via lerobot
mpmath==1.3.0 mpmath==1.3.0
# via sympy # via sympy
mujoco==3.5.0 mujoco==3.3.7
# via # via
# dm-control # dm-control
# gym-aloha # gym-aloha
# gym-hil # gym-hil
# hf-libero # libero
# metaworld # metaworld
# robosuite # robosuite
multidict==6.7.1 multidict==6.7.0
# via # via
# aiohttp # aiohttp
# yarl # yarl
multiprocess==0.70.18 multiprocess==0.70.16
# via datasets # via datasets
mypy==1.19.1
# via lerobot
mypy-extensions==1.1.0 mypy-extensions==1.1.0
# via # via typing-inspect
# mypy
# typing-inspect
nbformat==5.10.4 nbformat==5.10.4
# via jupytext # via jupytext
networkx==3.6.1 networkx==3.4.2
# via # via
# bddl # bddl
# scikit-image # scikit-image
# torch # torch
nodeenv==1.10.0 ninja==1.13.0
# via lerobot
nodeenv==1.9.1
# via pre-commit # via pre-commit
num2words==0.5.14 num2words==0.5.14
# via lerobot # via lerobot
numba==0.64.0 numba==0.62.1
# via robosuite # via robosuite
numpy==2.2.6 numpy==2.2.6
# via # via
@@ -407,6 +391,7 @@ numpy==2.2.6
# cmeel-boost # cmeel-boost
# contourpy # contourpy
# datasets # datasets
# decord
# diffusers # diffusers
# dm-control # dm-control
# dm-env # dm-env
@@ -414,10 +399,9 @@ numpy==2.2.6
# gymnasium # gymnasium
# h5py # h5py
# hebi-py # hebi-py
# hf-libero
# imageio # imageio
# labmaze # labmaze
# lerobot # libero
# matplotlib # matplotlib
# meshcat # meshcat
# metaworld # metaworld
@@ -442,51 +426,49 @@ numpy==2.2.6
# torchvision # torchvision
# transformers # transformers
# transforms3d # transforms3d
nvidia-cublas-cu12==12.8.4.1 nvidia-cublas-cu12==12.6.4.1
# via # via
# nvidia-cudnn-cu12 # nvidia-cudnn-cu12
# nvidia-cusolver-cu12 # nvidia-cusolver-cu12
# torch # torch
nvidia-cuda-cupti-cu12==12.8.90 nvidia-cuda-cupti-cu12==12.6.80
# via torch # via torch
nvidia-cuda-nvrtc-cu12==12.8.93 nvidia-cuda-nvrtc-cu12==12.6.77
# via torch # via torch
nvidia-cuda-runtime-cu12==12.8.90 nvidia-cuda-runtime-cu12==12.6.77
# via torch # via torch
nvidia-cudnn-cu12==9.10.2.21 nvidia-cudnn-cu12==9.5.1.17
# via torch # via torch
nvidia-cufft-cu12==11.3.3.83 nvidia-cufft-cu12==11.3.0.4
# via torch # via torch
nvidia-cufile-cu12==1.13.1.3 nvidia-cufile-cu12==1.11.1.6
# via torch # via torch
nvidia-curand-cu12==10.3.9.90 nvidia-curand-cu12==10.3.7.77
# via torch # via torch
nvidia-cusolver-cu12==11.7.3.90 nvidia-cusolver-cu12==11.7.1.2
# via torch # via torch
nvidia-cusparse-cu12==12.5.8.93 nvidia-cusparse-cu12==12.5.4.2
# via # via
# nvidia-cusolver-cu12 # nvidia-cusolver-cu12
# torch # torch
nvidia-cusparselt-cu12==0.7.1 nvidia-cusparselt-cu12==0.6.3
# via torch # via torch
nvidia-nccl-cu12==2.27.5 nvidia-nccl-cu12==2.26.2
# via torch # via torch
nvidia-nvjitlink-cu12==12.8.93 nvidia-nvjitlink-cu12==12.6.85
# via # via
# nvidia-cufft-cu12 # nvidia-cufft-cu12
# nvidia-cusolver-cu12 # nvidia-cusolver-cu12
# nvidia-cusparse-cu12 # nvidia-cusparse-cu12
# torch # torch
nvidia-nvshmem-cu12==3.4.5 nvidia-nvtx-cu12==12.6.77
# via torch
nvidia-nvtx-cu12==12.8.90
# via torch # via torch
omegaconf==2.3.0 omegaconf==2.3.0
# via hydra-core # via hydra-core
opencv-python==4.13.0.92 opencv-python==4.12.0.88
# via # via
# gym-pusht # gym-pusht
# hf-libero # libero
# reachy2-sdk # reachy2-sdk
# robosuite # robosuite
opencv-python-headless==4.12.0.88 opencv-python-headless==4.12.0.88
@@ -505,7 +487,6 @@ packaging==25.0
# matplotlib # matplotlib
# peft # peft
# pytest # pytest
# qwen-vl-utils
# reachy2-sdk # reachy2-sdk
# scikit-image # scikit-image
# tensorboard # tensorboard
@@ -516,21 +497,21 @@ pandas==2.3.3
# via # via
# datasets # datasets
# lerobot # lerobot
parso==0.8.6 parso==0.8.5
# via jedi # via jedi
pathspec==1.0.4 peft==0.17.1
# via mypy
peft==0.18.1
# via lerobot # via lerobot
pexpect==4.9.0 pexpect==4.9.0
# via ipython # via ipython
pillow==12.1.1 pfzy==0.3.4
# via inquirerpy
pillow==12.0.0
# via # via
# diffusers # diffusers
# imageio # imageio
# lerobot
# matplotlib # matplotlib
# meshcat # meshcat
# qwen-vl-utils
# rerun-sdk # rerun-sdk
# robosuite # robosuite
# scikit-image # scikit-image
@@ -538,27 +519,28 @@ pillow==12.1.1
# torchvision # torchvision
pin==3.4.0 pin==3.4.0
# via placo # via placo
placo==0.9.16 placo==0.9.14
# via lerobot # via lerobot
platformdirs==4.9.4 platformdirs==4.5.0
# via # via
# jupyter-core # jupyter-core
# python-discovery
# virtualenv # virtualenv
# wandb # wandb
pluggy==1.6.0 pluggy==1.6.0
# via # via
# pytest # pytest
# pytest-cov # pytest-cov
pre-commit==4.5.1 pre-commit==4.3.0
# via lerobot # via lerobot
prompt-toolkit==3.0.52 prompt-toolkit==3.0.52
# via ipython # via
# inquirerpy
# ipython
propcache==0.4.1 propcache==0.4.1
# via # via
# aiohttp # aiohttp
# yarl # yarl
protobuf==6.31.1 protobuf==6.31.0
# via # via
# dm-control # dm-control
# grpcio-tools # grpcio-tools
@@ -568,7 +550,7 @@ protobuf==6.31.1
# tensorboard # tensorboard
# tensorboardx # tensorboardx
# wandb # wandb
psutil==7.2.2 psutil==7.1.1
# via # via
# accelerate # accelerate
# imageio # imageio
@@ -578,17 +560,17 @@ ptyprocess==0.7.0
# via pexpect # via pexpect
pure-eval==0.2.3 pure-eval==0.2.3
# via stack-data # via stack-data
pyarrow==23.0.1 pyarrow==21.0.0
# via # via
# datasets # datasets
# rerun-sdk # rerun-sdk
pycparser==3.0 pycparser==2.23
# via cffi # via cffi
pydantic==2.12.5 pydantic==2.12.3
# via # via
# fastapi # fastapi
# wandb # wandb
pydantic-core==2.41.5 pydantic-core==2.41.4
# via pydantic # via pydantic
pygame==2.6.1 pygame==2.6.1
# via # via
@@ -598,14 +580,12 @@ pygame==2.6.1
pygments==2.19.2 pygments==2.19.2
# via # via
# ipython # ipython
# ipython-pygments-lexers
# pytest # pytest
# rich
pymunk==6.11.1 pymunk==6.11.1
# via # via
# gym-pusht # gym-pusht
# lerobot # lerobot
pyngrok==7.5.1 pyngrok==7.4.1
# via meshcat # via meshcat
pynput==1.8.1 pynput==1.8.1
# via # via
@@ -615,7 +595,7 @@ pyopengl==3.1.10
# via # via
# dm-control # dm-control
# mujoco # mujoco
pyparsing==3.3.2 pyparsing==3.2.5
# via # via
# dm-control # dm-control
# matplotlib # matplotlib
@@ -641,16 +621,13 @@ pytest-timeout==2.4.0
# via lerobot # via lerobot
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
# via # via
# faker
# matplotlib # matplotlib
# pandas # pandas
python-discovery==1.1.1 python-dotenv==1.1.1
# via virtualenv
python-dotenv==1.2.2
# via uvicorn # via uvicorn
python-xlib==0.33 python-xlib==0.33
# via pynput # via pynput
pytz==2026.1.post1 pytz==2025.2
# via pandas # via pandas
pyyaml==6.0.3 pyyaml==6.0.3
# via # via
@@ -665,6 +642,7 @@ pyyaml==6.0.3
# pre-commit # pre-commit
# pyngrok # pyngrok
# pyyaml-include # pyyaml-include
# timm
# transformers # transformers
# uvicorn # uvicorn
# wandb # wandb
@@ -674,9 +652,7 @@ pyzmq==27.1.0
# via # via
# lerobot # lerobot
# meshcat # meshcat
qwen-vl-utils==0.0.14 reachy2-sdk==1.0.14
# via lerobot
reachy2-sdk==1.0.15
# via lerobot # via lerobot
reachy2-sdk-api==1.0.21 reachy2-sdk-api==1.0.21
# via reachy2-sdk # via reachy2-sdk
@@ -684,7 +660,7 @@ referencing==0.37.0
# via # via
# jsonschema # jsonschema
# jsonschema-specifications # jsonschema-specifications
regex==2026.2.28 regex==2025.10.23
# via # via
# diffusers # diffusers
# transformers # transformers
@@ -693,62 +669,60 @@ requests==2.32.5
# datasets # datasets
# diffusers # diffusers
# dm-control # dm-control
# qwen-vl-utils # huggingface-hub
# teleop # teleop
# transformers
# wandb # wandb
rerun-sdk==0.26.2 rerun-sdk==0.26.1
# via lerobot # via lerobot
rhoban-cmeel-jsoncpp==1.9.4.9 rhoban-cmeel-jsoncpp==1.9.4.9
# via placo # via placo
rich==14.3.3
# via typer
robomimic==0.2.0 robomimic==0.2.0
# via hf-libero # via libero
robosuite==1.4.0 robosuite==1.4.0
# via hf-libero # via libero
rpds-py==0.30.0 rpds-py==0.28.0
# via # via
# jsonschema # jsonschema
# referencing # referencing
safetensors==0.7.0 safetensors==0.6.2
# via # via
# accelerate # accelerate
# diffusers # diffusers
# lerobot # lerobot
# peft # peft
# timm
# transformers # transformers
scikit-image==0.25.2 scikit-image==0.25.2
# via # via
# gym-pusht # gym-pusht
# lerobot # lerobot
scipy==1.17.1 scipy==1.15.3
# via # via
# dm-control # dm-control
# lerobot
# metaworld # metaworld
# robosuite # robosuite
# scikit-image # scikit-image
# torchdiffeq sentry-sdk==2.42.1
sentry-sdk==2.54.0
# via wandb # via wandb
shapely==2.1.2 shapely==2.1.2
# via gym-pusht # via gym-pusht
shellingham==1.5.4
# via typer
six==1.17.0 six==1.17.0
# via # via
# pynput # pynput
# python-dateutil # python-dateutil
# python-xlib # python-xlib
smmap==5.0.3 smmap==5.0.2
# via gitdb # via gitdb
sniffio==1.3.1
# via anyio
stack-data==0.6.3 stack-data==0.6.3
# via ipython # via ipython
starlette==0.52.1 starlette==0.48.0
# via fastapi # via fastapi
sympy==1.14.0 sympy==1.14.0
# via torch # via torch
teleop==0.1.4 teleop==0.1.2
# via lerobot # via lerobot
tensorboard==2.20.0 tensorboard==2.20.0
# via robomimic # via robomimic
@@ -756,38 +730,46 @@ tensorboard-data-server==0.7.2
# via tensorboard # via tensorboard
tensorboardx==2.6.4 tensorboardx==2.6.4
# via robomimic # via robomimic
termcolor==3.3.0 termcolor==3.1.0
# via # via
# lerobot # lerobot
# robomimic # robomimic
thop==0.1.1.post2209072238 thop==0.1.1.post2209072238
# via hf-libero # via libero
tifffile==2026.3.3 tifffile==2025.5.10
# via scikit-image # via scikit-image
tokenizers==0.22.2 timm==1.0.20
# via lerobot
tokenizers==0.22.1
# via transformers # via transformers
toml==0.10.2 toml==0.10.2
# via draccus # via draccus
torch==2.10.0 tomli==2.3.0
# via
# cmeel
# coverage
# jupytext
# pytest
torch==2.7.1
# via # via
# accelerate # accelerate
# flash-attn
# lerobot # lerobot
# peft # peft
# robomimic # robomimic
# thop # thop
# torchdiffeq # timm
# torchvision # torchvision
torchcodec==0.10.0 torchcodec==0.5
# via lerobot # via lerobot
torchdiffeq==0.2.5 torchvision==0.22.1
# via lerobot
torchvision==0.25.0
# via # via
# lerobot # lerobot
# robomimic # robomimic
tornado==6.5.4 # timm
tornado==6.5.2
# via meshcat # via meshcat
tqdm==4.67.3 tqdm==4.67.1
# via # via
# datasets # datasets
# dm-control # dm-control
@@ -801,29 +783,26 @@ traitlets==5.14.3
# jupyter-core # jupyter-core
# matplotlib-inline # matplotlib-inline
# nbformat # nbformat
transformers==5.3.0 transformers==4.57.1
# via # via
# hf-libero
# lerobot # lerobot
# libero
# peft # peft
transforms3d==0.4.2 transforms3d==0.4.2
# via teleop # via teleop
triton==3.6.0 triton==3.3.1
# via torch # via torch
typer==0.24.1
# via
# huggingface-hub
# transformers
typing-extensions==4.15.0 typing-extensions==4.15.0
# via # via
# aiosignal # aiosignal
# anyio # anyio
# etils # etils
# faker # exceptiongroup
# fastapi # fastapi
# gymnasium # gymnasium
# huggingface-hub # huggingface-hub
# mypy # ipython
# multidict
# pydantic # pydantic
# pydantic-core # pydantic-core
# referencing # referencing
@@ -832,46 +811,46 @@ typing-extensions==4.15.0
# torch # torch
# typing-inspect # typing-inspect
# typing-inspection # typing-inspection
# uvicorn
# virtualenv
# wandb # wandb
typing-inspect==0.9.0 typing-inspect==0.9.0
# via draccus # via draccus
typing-inspection==0.4.2 typing-inspection==0.4.2
# via # via pydantic
# fastapi tzdata==2025.2
# pydantic
tzdata==2025.3
# via pandas # via pandas
u-msgpack-python==2.8.0 u-msgpack-python==2.8.0
# via meshcat # via meshcat
urllib3==2.6.3 urllib3==2.5.0
# via # via
# requests # requests
# sentry-sdk # sentry-sdk
uvicorn[standard]==0.41.0 uvicorn[standard]==0.38.0
# via teleop # via teleop
uvloop==0.22.1 uvloop==0.22.1
# via uvicorn # via uvicorn
virtualenv==21.1.0 virtualenv==20.35.3
# via pre-commit # via pre-commit
wandb==0.24.2 wandb==0.21.4
# via # via
# hf-libero
# lerobot # lerobot
# libero
watchfiles==1.1.1 watchfiles==1.1.1
# via uvicorn # via uvicorn
wcwidth==0.6.0 wcwidth==0.2.14
# via prompt-toolkit # via prompt-toolkit
websocket-client==1.9.0 websocket-client==1.9.0
# via teleop # via teleop
websockets==16.0 websockets==15.0.1
# via uvicorn # via uvicorn
werkzeug==3.1.6 werkzeug==3.1.3
# via tensorboard # via tensorboard
wrapt==2.1.2 wrapt==2.0.0
# via dm-tree # via dm-tree
xxhash==3.6.0 xxhash==3.6.0
# via datasets # via datasets
yarl==1.23.0 yarl==1.22.0
# via aiohttp # via aiohttp
zipp==3.23.0 zipp==3.23.0
# via # via
+4 -4
View File
@@ -1,9 +1,9 @@
# requirements.in # requirements.in
# requirements-macos.txt was generated on macOS and is platform-specific (macOS 26.3.1 25D2128 arm64). # requirements-macos.txt was generated on macOS and is platform-specific (macOS 26.0.1 25A362 arm64).
# Darwin MacBook-Pro.local 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:54:55 PST 2026; root:xnu-12377.91.3~2/RELEASE_ARM64_T8132 arm64 # Darwin MacBook-Pro.local 25.0.0 Darwin Kernel Version 25.0.0: Wed Sep 17 21:42:08 PDT 2025; root:xnu-12377.1.9~141/RELEASE_ARM64_T8132 arm64
# requirements-ubuntu.txt was generated on Linux and is platform-specific (Ubuntu 24.04.4 LTS x86_64). # requirements-ubuntu.txt was generated on Linux and is platform-specific (Ubuntu 24.04.3 LTS x86_64).
# Linux lerobot-linux 6.17.0-14-generic #14~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Thu Jan 15 15:52:10 UTC 2 x86_64 x86_64 x86_64 GNU/Linux # Linux mlerobot-linux 6.14.0-33-generic #33~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Fri Sep 19 17:02:30 UTC 2 x86_64 x86_64 x86_64 GNU/Linux
-e .[all] -e .[all]
+3 -5
View File
@@ -63,9 +63,9 @@ from lerobot.transport import (
services_pb2_grpc, # type: ignore services_pb2_grpc, # type: ignore
) )
from lerobot.transport.utils import grpc_channel_options, send_bytes_in_chunks from lerobot.transport.utils import grpc_channel_options, send_bytes_in_chunks
from lerobot.utils.import_utils import register_third_party_plugins
from .configs import RobotClientConfig from .configs import RobotClientConfig
from .constants import SUPPORTED_ROBOTS
from .helpers import ( from .helpers import (
Action, Action,
FPSTracker, FPSTracker,
@@ -485,9 +485,8 @@ class RobotClient:
def async_client(cfg: RobotClientConfig): def async_client(cfg: RobotClientConfig):
logging.info(pformat(asdict(cfg))) logging.info(pformat(asdict(cfg)))
# TODO: Assert if checking robot support is still needed with the plugin system if cfg.robot.type not in SUPPORTED_ROBOTS:
# if cfg.robot.type not in SUPPORTED_ROBOTS: raise ValueError(f"Robot {cfg.robot.type} not yet supported!")
# raise ValueError(f"Robot {cfg.robot.type} not yet supported!")
client = RobotClient(cfg) client = RobotClient(cfg)
@@ -513,5 +512,4 @@ def async_client(cfg: RobotClientConfig):
if __name__ == "__main__": if __name__ == "__main__":
register_third_party_plugins()
async_client() # run the client async_client() # run the client
+1 -1
View File
@@ -181,7 +181,7 @@ class ZMQCamera(Camera):
try: try:
message = self.socket.recv_string() message = self.socket.recv_string()
except Exception as e: except Exception as e:
# zmq is lazy-imported in connect(), so check by name to avoid a top-level import # Check for ZMQ timeout (EAGAIN/Again) without requiring global zmq import
if type(e).__name__ == "Again": if type(e).__name__ == "Again":
raise TimeoutError(f"{self} timeout after {self.timeout_ms}ms") from e raise TimeoutError(f"{self} timeout after {self.timeout_ms}ms") from e
raise raise
+4 -72
View File
@@ -23,7 +23,6 @@ import base64
import contextlib import contextlib
import json import json
import logging import logging
import threading
import time import time
from collections import deque from collections import deque
@@ -43,57 +42,10 @@ def encode_image(image: np.ndarray, quality: int = 80) -> str:
return base64.b64encode(buffer).decode("utf-8") return base64.b64encode(buffer).decode("utf-8")
class CameraCaptureThread:
"""Background thread that continuously captures and encodes frames from a camera."""
def __init__(self, camera: OpenCVCamera, name: str):
self.camera = camera
self.name = name
self.latest_encoded: str | None = None # Pre-encoded JPEG as base64
self.latest_timestamp: float = 0.0
self.frame_lock = threading.Lock()
self.running = False
self.thread: threading.Thread | None = None
def start(self):
"""Start the capture thread."""
self.running = True
self.thread = threading.Thread(target=self._capture_loop, daemon=True)
self.thread.start()
def stop(self):
"""Stop the capture thread."""
self.running = False
if self.thread:
self.thread.join(timeout=1.0)
def _capture_loop(self):
"""Continuously capture and encode frames at the camera's native rate."""
while self.running:
try:
frame = self.camera.read() # Blocks at camera's native rate
timestamp = time.time()
# Encode immediately in capture thread (this is the slow part)
encoded = encode_image(frame)
with self.frame_lock:
self.latest_encoded = encoded
self.latest_timestamp = timestamp
except Exception as e:
logger.warning(f"Camera {self.name} capture error: {e}")
time.sleep(0.01)
def get_latest(self) -> tuple[str | None, float]:
"""Get the latest encoded frame and its timestamp."""
with self.frame_lock:
return self.latest_encoded, self.latest_timestamp
class ImageServer: class ImageServer:
def __init__(self, config: dict, port: int = 5555): def __init__(self, config: dict, port: int = 5555):
# fps controls the publish loop rate (how often frames are sent over ZMQ), not the camera capture rate
self.fps = config.get("fps", 30) self.fps = config.get("fps", 30)
self.cameras: dict[str, OpenCVCamera] = {} self.cameras: dict[str, OpenCVCamera] = {}
self.capture_threads: dict[str, CameraCaptureThread] = {}
for name, cfg in config.get("cameras", {}).items(): for name, cfg in config.get("cameras", {}).items():
shape = cfg.get("shape", [480, 640]) shape = cfg.get("shape", [480, 640])
@@ -109,10 +61,6 @@ class ImageServer:
self.cameras[name] = camera self.cameras[name] = camera
logger.info(f"Camera {name}: {shape[1]}x{shape[0]}") logger.info(f"Camera {name}: {shape[1]}x{shape[0]}")
# Create capture thread for this camera
capture_thread = CameraCaptureThread(camera, name)
self.capture_threads[name] = capture_thread
# ZMQ PUB socket # ZMQ PUB socket
self.context = zmq.Context() self.context = zmq.Context()
self.socket = self.context.socket(zmq.PUB) self.socket = self.context.socket(zmq.PUB)
@@ -125,18 +73,6 @@ class ImageServer:
def run(self): def run(self):
frame_count = 0 frame_count = 0
frame_times = deque(maxlen=60) frame_times = deque(maxlen=60)
last_published_ts: dict[str, float] = {}
# Start all capture threads
for capture_thread in self.capture_threads.values():
capture_thread.start()
# Wait for first frames to be captured and encoded
logger.info("Waiting for cameras to start capturing...")
for name, capture_thread in self.capture_threads.items():
while capture_thread.get_latest()[0] is None:
time.sleep(0.01)
logger.info(f"Camera {name} ready (capture + encode in background)")
try: try:
while True: while True:
@@ -144,12 +80,10 @@ class ImageServer:
# Build message # Build message
message = {"timestamps": {}, "images": {}} message = {"timestamps": {}, "images": {}}
for name, capture_thread in self.capture_threads.items(): for name, cam in self.cameras.items():
encoded, timestamp = capture_thread.get_latest() frame = cam.read() # Returns RGB
if encoded is not None and timestamp > last_published_ts.get(name, 0.0): message["timestamps"][name] = time.time()
message["timestamps"][name] = timestamp message["images"][name] = encode_image(frame)
message["images"][name] = encoded
last_published_ts[name] = timestamp
# Send as JSON string (suppress if buffer full) # Send as JSON string (suppress if buffer full)
with contextlib.suppress(zmq.Again): with contextlib.suppress(zmq.Again):
@@ -168,8 +102,6 @@ class ImageServer:
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: finally:
for capture_thread in self.capture_threads.values():
capture_thread.stop()
for cam in self.cameras.values(): for cam in self.cameras.values():
cam.disconnect() cam.disconnect()
self.socket.close() self.socket.close()
+1 -1
View File
@@ -27,7 +27,7 @@ class DatasetConfig:
# "dataset_index" into the returned item. The index mapping is made according to the order in which the # "dataset_index" into the returned item. The index mapping is made according to the order in which the
# datasets are provided. # datasets are provided.
repo_id: str repo_id: str
# Root directory where the dataset will be stored (e.g. 'dataset/path'). If None, defaults to $HF_LEROBOT_HOME/repo_id. # Root directory where the dataset will be stored (e.g. 'dataset/path').
root: str | None = None root: str | None = None
episodes: list[int] | None = None episodes: list[int] | None = None
image_transforms: ImageTransformsConfig = field(default_factory=ImageTransformsConfig) image_transforms: ImageTransformsConfig = field(default_factory=ImageTransformsConfig)
-3
View File
@@ -50,9 +50,6 @@ class TrainPipelineConfig(HubMixin):
# `seed` is used for training (eg: model initialization, dataset shuffling) # `seed` is used for training (eg: model initialization, dataset shuffling)
# AND for the evaluation environments. # AND for the evaluation environments.
seed: int | None = 1000 seed: int | None = 1000
# Set to True to use deterministic cuDNN algorithms for reproducibility.
# This disables cudnn.benchmark and may reduce training speed by ~10-20%.
cudnn_deterministic: bool = False
# Number of workers for the dataloader. # Number of workers for the dataloader.
num_workers: int = 4 num_workers: int = 4
batch_size: int = 8 batch_size: int = 8
+1 -3
View File
@@ -289,9 +289,7 @@ def aggregate_datasets(
logging.info("Find all tasks") logging.info("Find all tasks")
unique_tasks = pd.concat([m.tasks for m in all_metadata]).index.unique() unique_tasks = pd.concat([m.tasks for m in all_metadata]).index.unique()
dst_meta.tasks = pd.DataFrame( dst_meta.tasks = pd.DataFrame({"task_index": range(len(unique_tasks))}, index=unique_tasks)
{"task_index": range(len(unique_tasks))}, index=pd.Index(unique_tasks, name="task")
)
meta_idx = {"chunk": 0, "file": 0} meta_idx = {"chunk": 0, "file": 0}
data_idx = {"chunk": 0, "file": 0} data_idx = {"chunk": 0, "file": 0}
+309 -18
View File
@@ -47,6 +47,7 @@ from lerobot.datasets.utils import (
DEFAULT_EPISODES_PATH, DEFAULT_EPISODES_PATH,
get_parquet_file_size_in_mb, get_parquet_file_size_in_mb,
load_episodes, load_episodes,
load_info,
update_chunk_file_indices, update_chunk_file_indices,
write_info, write_info,
write_stats, write_stats,
@@ -89,8 +90,8 @@ def delete_episodes(
Args: Args:
dataset: The source LeRobotDataset. dataset: The source LeRobotDataset.
episode_indices: List of episode indices to delete. episode_indices: List of episode indices to delete.
output_dir: Root directory where the edited dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id. Equivalent to new_root in EditDatasetConfig. output_dir: Directory to save the new dataset. If None, uses default location.
repo_id: Edited dataset identifier. Equivalent to new_repo_id in EditDatasetConfig. repo_id: Repository ID for the new dataset. If None, appends "_modified" to original.
""" """
if not episode_indices: if not episode_indices:
raise ValueError("No episodes to delete") raise ValueError("No episodes to delete")
@@ -152,7 +153,7 @@ def split_dataset(
dataset: The source LeRobotDataset to split. dataset: The source LeRobotDataset to split.
splits: Either a dict mapping split names to episode indices, or a dict mapping splits: Either a dict mapping split names to episode indices, or a dict mapping
split names to fractions (must sum to <= 1.0). split names to fractions (must sum to <= 1.0).
output_dir: Root directory where the split datasets will be stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id. output_dir: Base directory for output datasets. If None, uses default location.
Examples: Examples:
Split by specific episodes Split by specific episodes
@@ -243,8 +244,8 @@ def merge_datasets(
Args: Args:
datasets: List of LeRobotDatasets to merge. datasets: List of LeRobotDatasets to merge.
output_repo_id: Merged dataset identifier. output_repo_id: Repository ID for the merged dataset.
output_dir: Root directory where the merged dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/output_repo_id. output_dir: Directory to save the merged dataset. If None, uses default location.
""" """
if not datasets: if not datasets:
raise ValueError("No datasets to merge") raise ValueError("No datasets to merge")
@@ -288,8 +289,8 @@ def modify_features(
dataset: The source LeRobotDataset. dataset: The source LeRobotDataset.
add_features: Optional dict mapping feature names to (feature_values, feature_info) tuples. add_features: Optional dict mapping feature names to (feature_values, feature_info) tuples.
remove_features: Optional feature name(s) to remove. Can be a single string or list. remove_features: Optional feature name(s) to remove. Can be a single string or list.
output_dir: Root directory where the edited dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id. Equivalent to new_root in EditDatasetConfig. output_dir: Directory to save the new dataset. If None, uses default location.
repo_id: Edited dataset identifier. Equivalent to new_repo_id in EditDatasetConfig. repo_id: Repository ID for the new dataset. If None, appends "_modified" to original.
Returns: Returns:
New dataset with features modified. New dataset with features modified.
@@ -390,8 +391,8 @@ def add_features(
Args: Args:
dataset: The source LeRobotDataset. dataset: The source LeRobotDataset.
features: Dictionary mapping feature names to (feature_values, feature_info) tuples. features: Dictionary mapping feature names to (feature_values, feature_info) tuples.
output_dir: Root directory where the edited dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id. Equivalent to new_root in EditDatasetConfig. output_dir: Directory to save the new dataset. If None, uses default location.
repo_id: Edited dataset identifier. Equivalent to new_repo_id in EditDatasetConfig. repo_id: Repository ID for the new dataset. If None, appends "_modified" to original.
Returns: Returns:
New dataset with all features added. New dataset with all features added.
@@ -427,8 +428,8 @@ def remove_feature(
Args: Args:
dataset: The source LeRobotDataset. dataset: The source LeRobotDataset.
feature_names: Name(s) of features to remove. Can be a single string or list. feature_names: Name(s) of features to remove. Can be a single string or list.
output_dir: Root directory where the edited dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id. Equivalent to new_root in EditDatasetConfig. output_dir: Directory to save the new dataset. If None, uses default location.
repo_id: Edited dataset identifier. Equivalent to new_repo_id in EditDatasetConfig. repo_id: Repository ID for the new dataset. If None, appends "_modified" to original.
Returns: Returns:
New dataset with features removed. New dataset with features removed.
@@ -1475,9 +1476,7 @@ def modify_tasks(
# Collect all unique tasks and create new task mapping # Collect all unique tasks and create new task mapping
unique_tasks = sorted(set(episode_to_task.values())) unique_tasks = sorted(set(episode_to_task.values()))
new_task_df = pd.DataFrame( new_task_df = pd.DataFrame({"task_index": list(range(len(unique_tasks)))}, index=unique_tasks)
{"task_index": list(range(len(unique_tasks)))}, index=pd.Index(unique_tasks, name="task")
)
task_to_index = {task: idx for idx, task in enumerate(unique_tasks)} task_to_index = {task: idx for idx, task in enumerate(unique_tasks)}
logging.info(f"Modifying tasks in {dataset.repo_id}") logging.info(f"Modifying tasks in {dataset.repo_id}")
@@ -1531,7 +1530,7 @@ def modify_tasks(
def convert_image_to_video_dataset( def convert_image_to_video_dataset(
dataset: LeRobotDataset, dataset: LeRobotDataset,
output_dir: Path | None = None, output_dir: Path,
repo_id: str | None = None, repo_id: str | None = None,
vcodec: str = "libsvtav1", vcodec: str = "libsvtav1",
pix_fmt: str = "yuv420p", pix_fmt: str = "yuv420p",
@@ -1550,8 +1549,8 @@ def convert_image_to_video_dataset(
Args: Args:
dataset: The source LeRobot dataset with images dataset: The source LeRobot dataset with images
output_dir: Root directory where the edited dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id. Equivalent to new_root in EditDatasetConfig. output_dir: Directory to save the new video dataset
repo_id: Edited dataset identifier. Equivalent to new_repo_id in EditDatasetConfig. repo_id: Repository ID for the new dataset (default: original_id + "_video")
vcodec: Video codec (default: libsvtav1) vcodec: Video codec (default: libsvtav1)
pix_fmt: Pixel format (default: yuv420p) pix_fmt: Pixel format (default: yuv420p)
g: Group of pictures size (default: 2) g: Group of pictures size (default: 2)
@@ -1602,7 +1601,6 @@ def convert_image_to_video_dataset(
# Video info will be updated after episodes are encoded # Video info will be updated after episodes are encoded
# Create new metadata for video dataset # Create new metadata for video dataset
output_dir = Path(output_dir) if output_dir is not None else HF_LEROBOT_HOME / repo_id
new_meta = LeRobotDatasetMetadata.create( new_meta = LeRobotDatasetMetadata.create(
repo_id=repo_id, repo_id=repo_id,
fps=dataset.meta.fps, fps=dataset.meta.fps,
@@ -1777,3 +1775,296 @@ def convert_image_to_video_dataset(
# Return new dataset # Return new dataset
return LeRobotDataset(repo_id=repo_id, root=output_dir) return LeRobotDataset(repo_id=repo_id, root=output_dir)
def trim_episodes_by_frames(
dataset: LeRobotDataset,
episode_frames_to_keep: dict[int, list[int]],
output_dir: str | Path | None = None,
repo_id: str | None = None,
) -> LeRobotDataset:
"""Trim multiple episodes to keep only specific frames.
This function creates a new dataset where the specified episodes contain only
the frames at the given indices. All other episodes are copied as-is.
Args:
dataset: The source LeRobotDataset.
episode_frames_to_keep: Dict mapping episode indices to lists of global frame indices to keep.
output_dir: Directory to save the new dataset. If None, uses default location.
repo_id: Repository ID for the new dataset. If None, appends "_trimmed" to original.
Returns:
A new LeRobotDataset with the trimmed episodes.
"""
if not episode_frames_to_keep:
raise ValueError("No episodes to trim")
for ep_idx in episode_frames_to_keep:
if ep_idx >= dataset.meta.total_episodes:
raise ValueError(f"Episode {ep_idx} does not exist")
if not episode_frames_to_keep[ep_idx]:
raise ValueError(f"No frames to keep for episode {ep_idx}")
if repo_id is None:
repo_id = f"{dataset.repo_id}_trimmed"
output_dir = Path(output_dir) if output_dir is not None else HF_LEROBOT_HOME / repo_id
total_trimmed = sum(len(frames) for frames in episode_frames_to_keep.values())
logging.info(f"Trimming {len(episode_frames_to_keep)} episodes, keeping {total_trimmed} frames total")
# Create new metadata
new_meta = LeRobotDatasetMetadata.create(
repo_id=repo_id,
fps=dataset.meta.fps,
features=dataset.meta.features,
robot_type=dataset.meta.robot_type,
root=output_dir,
use_videos=len(dataset.meta.video_keys) > 0,
)
# Build set of all frames to keep (for episodes being trimmed)
# and compute new frame counts per episode
all_keep_frames: set[int] = set()
trimmed_frame_counts: dict[int, int] = {}
for ep_idx, frames in episode_frames_to_keep.items():
all_keep_frames.update(frames)
trimmed_frame_counts[ep_idx] = len(frames)
# Copy and filter data
_copy_and_reindex_data_with_multi_frame_filter(
dataset, new_meta, episode_frames_to_keep, all_keep_frames
)
# Handle videos if present
if dataset.meta.video_keys:
_copy_and_reindex_videos_with_multi_frame_filter(
dataset, new_meta, episode_frames_to_keep
)
# Copy episode metadata
_copy_and_reindex_episodes_metadata_for_multi_trim(
dataset, new_meta, trimmed_frame_counts
)
logging.info(f"Created trimmed dataset with {new_meta.total_frames} frames at {output_dir}")
# Return the metadata instead of trying to load as LeRobotDataset
# This avoids Hub validation issues when the repo doesn't exist yet
return new_meta
# Keep old function for backward compatibility
def trim_episode_by_frames(
dataset: LeRobotDataset,
episode_index: int,
keep_frame_indices: list[int],
output_dir: str | Path | None = None,
repo_id: str | None = None,
) -> LeRobotDataset:
"""Trim a single episode. Wrapper around trim_episodes_by_frames."""
return trim_episodes_by_frames(
dataset,
episode_frames_to_keep={episode_index: keep_frame_indices},
output_dir=output_dir,
repo_id=repo_id,
)
def _copy_and_reindex_data_with_multi_frame_filter(
src_dataset: LeRobotDataset,
dst_meta: LeRobotDatasetMetadata,
episode_frames_to_keep: dict[int, list[int]],
all_keep_frames: set[int],
) -> None:
"""Copy data files with frame-level filtering for multiple episodes."""
if src_dataset.meta.episodes is None:
src_dataset.meta.episodes = load_episodes(src_dataset.meta.root)
# Copy tasks
if dst_meta.tasks is None and src_dataset.meta.tasks is not None:
# Tasks are stored with task string as index
dst_meta.save_episode_tasks(list(src_dataset.meta.tasks.index))
# Get all parquet files
data_dir = src_dataset.root / "data"
parquet_files = sorted(data_dir.glob("chunk-*/file-*.parquet"))
trim_episode_set = set(episode_frames_to_keep.keys())
global_index = 0
for parquet_path in tqdm(parquet_files, desc="Processing data files"):
df = pd.read_parquet(parquet_path)
# Filter: keep all frames from non-trimmed episodes,
# and only specified frames from trimmed episodes
mask = (~df["episode_index"].isin(trim_episode_set)) | (df["index"].isin(all_keep_frames))
df = df[mask].copy().reset_index(drop=True)
if len(df) == 0:
continue
# Reindex
df["index"] = range(global_index, global_index + len(df))
# Recalculate frame_index within each episode
for ep_idx in df["episode_index"].unique():
ep_mask = df["episode_index"] == ep_idx
df.loc[ep_mask, "frame_index"] = range(ep_mask.sum())
# Recalculate timestamps based on frame_index and fps
df["timestamp"] = df["frame_index"] / src_dataset.meta.fps
# Determine output path (keep same structure)
rel_path = parquet_path.relative_to(src_dataset.root)
dst_path = dst_meta.root / rel_path
dst_path.parent.mkdir(parents=True, exist_ok=True)
_write_parquet(df, dst_path, dst_meta)
global_index += len(df)
def _copy_and_reindex_videos_with_multi_frame_filter(
src_dataset: LeRobotDataset,
dst_meta: LeRobotDatasetMetadata,
episode_frames_to_keep: dict[int, list[int]],
) -> None:
"""Copy video files for trimmed dataset.
In v3.0 datasets, multiple episodes are concatenated into single video files.
Each episode has from_timestamp/to_timestamp indicating its portion of the video.
For trimming, we copy the original video files as-is and update the metadata
timestamps in _copy_and_reindex_episodes_metadata_for_multi_trim.
"""
for video_key in src_dataset.meta.video_keys:
video_dir = src_dataset.root / "videos" / video_key
dst_video_dir = dst_meta.root / "videos" / video_key
if not video_dir.exists():
logging.warning(f"Video directory not found: {video_dir}")
continue
# Copy all video files (they contain concatenated episodes)
# The metadata timestamps will handle which portions to use
copied_files = set()
for chunk_dir in video_dir.glob("chunk-*"):
dst_chunk_dir = dst_video_dir / chunk_dir.name
dst_chunk_dir.mkdir(parents=True, exist_ok=True)
for video_file in chunk_dir.glob("*.mp4"):
if video_file.name not in copied_files:
dst_path = dst_chunk_dir / video_file.name
if not dst_path.exists():
shutil.copy(video_file, dst_path)
copied_files.add(video_file.name)
logging.info(f"Copied {len(copied_files)} video files for {video_key}")
def _trim_video_frames(
src_path: Path,
dst_path: Path,
keep_frame_indices: list[int],
fps: float,
episode_start_idx: int,
) -> None:
"""Trim a video to keep only specific frames using ffmpeg."""
import subprocess
# Convert global indices to local indices within the episode
local_indices = sorted([idx - episode_start_idx for idx in keep_frame_indices])
if not local_indices:
logging.warning(f"No frames to keep for video {src_path}")
return
# Calculate start and end times
start_frame = local_indices[0]
end_frame = local_indices[-1]
start_time = start_frame / fps
duration = (end_frame - start_frame + 1) / fps
# Use ffmpeg to trim
cmd = [
"ffmpeg", "-y",
"-ss", str(start_time),
"-i", str(src_path),
"-t", str(duration),
"-c", "copy", # Fast copy without re-encoding
str(dst_path)
]
try:
subprocess.run(cmd, check=True, capture_output=True)
except subprocess.CalledProcessError as e:
logging.error(f"Failed to trim video: {e.stderr.decode()}")
# Fallback: copy the whole video
shutil.copy(src_path, dst_path)
def _copy_and_reindex_episodes_metadata_for_multi_trim(
src_dataset: LeRobotDataset,
dst_meta: LeRobotDatasetMetadata,
trimmed_frame_counts: dict[int, int],
) -> None:
"""Copy and update episode metadata for trimmed dataset."""
if src_dataset.meta.episodes is None:
src_dataset.meta.episodes = load_episodes(src_dataset.meta.root)
# Calculate new frame counts and indices
episodes_data = []
global_idx = 0
for old_ep_idx in range(src_dataset.meta.total_episodes):
src_ep = src_dataset.meta.episodes[old_ep_idx]
if old_ep_idx in trimmed_frame_counts:
ep_length = trimmed_frame_counts[old_ep_idx]
else:
ep_length = src_ep["length"]
ep_data = {
"episode_index": old_ep_idx,
"tasks": src_ep["tasks"],
"length": ep_length,
"data/chunk_index": src_ep["data/chunk_index"],
"data/file_index": src_ep["data/file_index"],
"dataset_from_index": global_idx,
"dataset_to_index": global_idx + ep_length,
}
# Copy video metadata - preserve timestamps for concatenated videos
for video_key in src_dataset.meta.video_keys:
ep_data[f"videos/{video_key}/chunk_index"] = src_ep[f"videos/{video_key}/chunk_index"]
ep_data[f"videos/{video_key}/file_index"] = src_ep[f"videos/{video_key}/file_index"]
# Keep original from_timestamp (start position in concatenated video)
orig_from_ts = src_ep[f"videos/{video_key}/from_timestamp"]
ep_data[f"videos/{video_key}/from_timestamp"] = orig_from_ts
# For trimmed episodes, update to_timestamp based on new length
# For non-trimmed episodes, keep original to_timestamp
if old_ep_idx in trimmed_frame_counts:
ep_data[f"videos/{video_key}/to_timestamp"] = orig_from_ts + (ep_length / src_dataset.meta.fps)
else:
ep_data[f"videos/{video_key}/to_timestamp"] = src_ep[f"videos/{video_key}/to_timestamp"]
ep_data["meta/episodes/chunk_index"] = 0
ep_data["meta/episodes/file_index"] = 0
episodes_data.append(ep_data)
global_idx += ep_length
# Save episodes metadata
df = pd.DataFrame(episodes_data)
episodes_path = dst_meta.root / DEFAULT_EPISODES_PATH.format(chunk_index=0, file_index=0)
episodes_path.parent.mkdir(parents=True, exist_ok=True)
df.to_parquet(episodes_path)
# Update info.json
info = load_info(src_dataset.root)
info["total_episodes"] = len(episodes_data)
info["total_frames"] = global_idx
write_info(info, dst_meta.root)
+11 -12
View File
@@ -314,7 +314,7 @@ class LeRobotDatasetMetadata:
if self.tasks is None: if self.tasks is None:
new_tasks = tasks new_tasks = tasks
task_indices = range(len(tasks)) task_indices = range(len(tasks))
self.tasks = pd.DataFrame({"task_index": task_indices}, index=pd.Index(tasks, name="task")) self.tasks = pd.DataFrame({"task_index": task_indices}, index=tasks)
else: else:
new_tasks = [task for task in tasks if task not in self.tasks.index] new_tasks = [task for task in tasks if task not in self.tasks.index]
new_task_indices = range(len(self.tasks), len(self.tasks) + len(new_tasks)) new_task_indices = range(len(self.tasks), len(self.tasks) + len(new_tasks))
@@ -664,11 +664,11 @@ class LeRobotDataset(torch.utils.data.Dataset):
for the README). for the README).
Args: Args:
repo_id (str): This is the repo id that will be used to fetch the dataset. repo_id (str): This is the repo id that will be used to fetch the dataset. Locally, the dataset
root (Path | None, optional): Local directory where the dataset will be downloaded and will be stored under root/repo_id.
stored. If set, all dataset files will be stored directly under this path. If not set, the root (Path | None, optional): Local directory to use for downloading/writing files. You can also
dataset files will be stored under $HF_LEROBOT_HOME/repo_id (configurable via the set the HF_LEROBOT_HOME environment variable to point to a different location. Defaults to
HF_LEROBOT_HOME environment variable). '~/.cache/huggingface/lerobot'.
episodes (list[int] | None, optional): If specified, this will only load episodes specified by episodes (list[int] | None, optional): If specified, this will only load episodes specified by
their episode_index in this list. Defaults to None. their episode_index in this list. Defaults to None.
image_transforms (Callable | None, optional): You can pass standard v2 image transforms from image_transforms (Callable | None, optional): You can pass standard v2 image transforms from
@@ -1771,12 +1771,11 @@ class MultiLeRobotDataset(torch.utils.data.Dataset):
) )
for repo_id, ds in zip(self.repo_ids, self._datasets, strict=True): for repo_id, ds in zip(self.repo_ids, self._datasets, strict=True):
extra_keys = set(ds.features).difference(intersection_features) extra_keys = set(ds.features).difference(intersection_features)
if extra_keys: logging.warning(
logging.warning( f"keys {extra_keys} of {repo_id} were disabled as they are not contained in all the "
f"keys {extra_keys} of {repo_id} were disabled as they are not contained in all the " "other datasets."
"other datasets." )
) self.disabled_features.update(extra_keys)
self.disabled_features.update(extra_keys)
self.image_transforms = image_transforms self.image_transforms = image_transforms
self.delta_timestamps = delta_timestamps self.delta_timestamps = delta_timestamps
+4 -3
View File
@@ -21,7 +21,7 @@ from collections import deque
from collections.abc import Iterable, Iterator from collections.abc import Iterable, Iterator
from pathlib import Path from pathlib import Path
from pprint import pformat from pprint import pformat
from typing import Any from typing import Any, Generic, TypeVar
import datasets import datasets
import numpy as np import numpy as np
@@ -78,6 +78,8 @@ DEFAULT_FEATURES = {
"task_index": {"dtype": "int64", "shape": (1,), "names": None}, "task_index": {"dtype": "int64", "shape": (1,), "names": None},
} }
T = TypeVar("T")
def get_parquet_file_size_in_mb(parquet_path: str | Path) -> float: def get_parquet_file_size_in_mb(parquet_path: str | Path) -> float:
metadata = pq.read_metadata(parquet_path) metadata = pq.read_metadata(parquet_path)
@@ -339,7 +341,6 @@ def write_tasks(tasks: pandas.DataFrame, local_dir: Path) -> None:
def load_tasks(local_dir: Path) -> pandas.DataFrame: def load_tasks(local_dir: Path) -> pandas.DataFrame:
tasks = pd.read_parquet(local_dir / DEFAULT_TASKS_PATH) tasks = pd.read_parquet(local_dir / DEFAULT_TASKS_PATH)
tasks.index.name = "task"
return tasks return tasks
@@ -1232,7 +1233,7 @@ class LookAheadError(Exception):
pass pass
class Backtrackable[T]: class Backtrackable(Generic[T]):
""" """
Wrap any iterator/iterable so you can step back up to `history` items Wrap any iterator/iterable so you can step back up to `history` items
and look ahead up to `lookahead` items. and look ahead up to `lookahead` items.
@@ -36,11 +36,8 @@ Convert a local dataset (works in place):
```bash ```bash
python src/lerobot/datasets/v30/convert_dataset_v21_to_v30.py \ python src/lerobot/datasets/v30/convert_dataset_v21_to_v30.py \
--repo-id=lerobot/pusht \ --repo-id=lerobot/pusht \
--root=/path/to/local/dataset/directory \ --root=/path/to/local/dataset/directory
--push-to-hub=false --push-to-hub=false
N.B. Path semantics (v2): --root is the exact dataset folder containing
meta/, data/, videos/. When omitted, defaults to $HF_LEROBOT_HOME/{repo_id}.
``` ```
""" """
@@ -108,7 +105,7 @@ episodes.jsonl
{"episode_index": 1, "tasks": ["Put the blue block in the green bowl"], "length": 266} {"episode_index": 1, "tasks": ["Put the blue block in the green bowl"], "length": 266}
NEW NEW
meta/episodes/chunk-000/file_000.parquet meta/episodes/chunk-000/episodes_000.parquet
episode_index | video_chunk_index | video_file_index | data_chunk_index | data_file_index | tasks | length episode_index | video_chunk_index | video_file_index | data_chunk_index | data_file_index | tasks | length
------------------------- -------------------------
OLD OLD
@@ -116,16 +113,15 @@ tasks.jsonl
{"task_index": 1, "task": "Put the blue block in the green bowl"} {"task_index": 1, "task": "Put the blue block in the green bowl"}
NEW NEW
meta/tasks.parquet meta/tasks/chunk-000/file_000.parquet
task_index | task task_index | task
------------------------- -------------------------
OLD OLD
episodes_stats.jsonl episodes_stats.jsonl
{"episode_index": 1, "stats": {"feature_name": {"min": ..., "max": ..., "mean": ..., "std": ..., "count": ...}}}
NEW NEW
meta/episodes/chunk-000/file_000.parquet meta/episodes_stats/chunk-000/file_000.parquet
episode_index | feature_name/min | feature_name/max | feature_name/mean | feature_name/std | feature_name/count episode_index | mean | std | min | max
------------------------- -------------------------
UPDATE UPDATE
meta/info.json meta/info.json
@@ -174,7 +170,7 @@ def convert_tasks(root, new_root):
tasks, _ = legacy_load_tasks(root) tasks, _ = legacy_load_tasks(root)
task_indices = tasks.keys() task_indices = tasks.keys()
task_strings = tasks.values() task_strings = tasks.values()
df_tasks = pd.DataFrame({"task_index": task_indices}, index=pd.Index(task_strings, name="task")) df_tasks = pd.DataFrame({"task_index": task_indices}, index=task_strings)
write_tasks(df_tasks, new_root) write_tasks(df_tasks, new_root)
@@ -205,6 +201,7 @@ def convert_data(root: Path, new_root: Path, data_file_size_in_mb: int):
image_keys = get_image_keys(root) image_keys = get_image_keys(root)
ep_idx = 0
chunk_idx = 0 chunk_idx = 0
file_idx = 0 file_idx = 0
size_in_mb = 0 size_in_mb = 0
@@ -214,23 +211,9 @@ def convert_data(root: Path, new_root: Path, data_file_size_in_mb: int):
logging.info(f"Converting data files from {len(ep_paths)} episodes") logging.info(f"Converting data files from {len(ep_paths)} episodes")
for ep_idx, ep_path in enumerate(tqdm.tqdm(ep_paths, desc="convert data files")): for ep_path in tqdm.tqdm(ep_paths, desc="convert data files"):
ep_size_in_mb = get_parquet_file_size_in_mb(ep_path) ep_size_in_mb = get_parquet_file_size_in_mb(ep_path)
ep_num_frames = get_parquet_num_frames(ep_path) ep_num_frames = get_parquet_num_frames(ep_path)
# Check if we need to start a new file BEFORE creating metadata
if size_in_mb + ep_size_in_mb >= data_file_size_in_mb and len(paths_to_cat) > 0:
# Write the accumulated data files
concat_data_files(paths_to_cat, new_root, chunk_idx, file_idx, image_keys)
# Move to next file
chunk_idx, file_idx = update_chunk_file_indices(chunk_idx, file_idx, DEFAULT_CHUNK_SIZE)
# Reset for the next file
size_in_mb = 0
paths_to_cat = []
# Now create metadata with correct chunk/file indices
ep_metadata = { ep_metadata = {
"episode_index": ep_idx, "episode_index": ep_idx,
"data/chunk_index": chunk_idx, "data/chunk_index": chunk_idx,
@@ -241,7 +224,20 @@ def convert_data(root: Path, new_root: Path, data_file_size_in_mb: int):
size_in_mb += ep_size_in_mb size_in_mb += ep_size_in_mb
num_frames += ep_num_frames num_frames += ep_num_frames
episodes_metadata.append(ep_metadata) episodes_metadata.append(ep_metadata)
paths_to_cat.append(ep_path) ep_idx += 1
if size_in_mb < data_file_size_in_mb:
paths_to_cat.append(ep_path)
continue
if paths_to_cat:
concat_data_files(paths_to_cat, new_root, chunk_idx, file_idx, image_keys)
# Reset for the next file
size_in_mb = ep_size_in_mb
paths_to_cat = [ep_path]
chunk_idx, file_idx = update_chunk_file_indices(chunk_idx, file_idx, DEFAULT_CHUNK_SIZE)
# Write remaining data if any # Write remaining data if any
if paths_to_cat: if paths_to_cat:
@@ -473,7 +469,7 @@ def convert_dataset(
# Set root based on whether local dataset path is provided # Set root based on whether local dataset path is provided
use_local_dataset = False use_local_dataset = False
root = HF_LEROBOT_HOME / repo_id if root is None else Path(root) root = HF_LEROBOT_HOME / repo_id if root is None else Path(root) / repo_id
if root.exists(): if root.exists():
validate_local_dataset_version(root) validate_local_dataset_version(root)
use_local_dataset = True use_local_dataset = True
@@ -557,7 +553,7 @@ if __name__ == "__main__":
"--root", "--root",
type=str, type=str,
default=None, default=None,
help="Local directory to use for downloading/writing the dataset. Defaults to $HF_LEROBOT_HOME/repo_id.", help="Local directory to use for downloading/writing the dataset.",
) )
parser.add_argument( parser.add_argument(
"--push-to-hub", "--push-to-hub",
+4 -4
View File
@@ -29,7 +29,7 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from functools import cached_property from functools import cached_property
from pprint import pformat from pprint import pformat
from typing import Protocol from typing import Protocol, TypeAlias
import serial import serial
from deepdiff import DeepDiff from deepdiff import DeepDiff
@@ -38,8 +38,8 @@ from tqdm import tqdm
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected
from lerobot.utils.utils import enter_pressed, move_cursor_up from lerobot.utils.utils import enter_pressed, move_cursor_up
type NameOrID = str | int NameOrID: TypeAlias = str | int
type Value = int | float Value: TypeAlias = int | float
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1277,4 +1277,4 @@ class SerialMotorsBus(MotorsBusBase):
# Backward compatibility alias # Backward compatibility alias
MotorsBus = SerialMotorsBus MotorsBus: TypeAlias = SerialMotorsBus
@@ -55,16 +55,10 @@ class DiffusionConfig(PreTrainedConfig):
normalization_mapping: A dictionary that maps from a str value of FeatureType (e.g., "STATE", "VISUAL") to normalization_mapping: A dictionary that maps from a str value of FeatureType (e.g., "STATE", "VISUAL") to
a corresponding NormalizationMode (e.g., NormalizationMode.MIN_MAX) a corresponding NormalizationMode (e.g., NormalizationMode.MIN_MAX)
vision_backbone: Name of the torchvision resnet backbone to use for encoding images. vision_backbone: Name of the torchvision resnet backbone to use for encoding images.
resize_shape: (H, W) shape to resize images to as a preprocessing step for the vision crop_shape: (H, W) shape to crop images to as a preprocessing step for the vision backbone. Must fit
backbone. If None, no resizing is done and the original image resolution is used. within the image size. If None, no cropping is done.
crop_ratio: Ratio in (0, 1] used to derive the crop size from resize_shape crop_is_random: Whether the crop should be random at training time (it's always a center crop in eval
(crop_h = int(resize_shape[0] * crop_ratio), likewise for width). mode).
Set to 1.0 to disable cropping. Only takes effect when resize_shape is not None.
crop_shape: (H, W) shape to crop images to. When resize_shape is set and crop_ratio < 1.0,
this is computed automatically. Can also be set directly for legacy configs that use
crop-only (without resize). If None and no derivation applies, no cropping is done.
crop_is_random: Whether the crop should be random at training time (it's always a center
crop in eval mode).
pretrained_backbone_weights: Pretrained weights from torchvision to initialize the backbone. pretrained_backbone_weights: Pretrained weights from torchvision to initialize the backbone.
`None` means no pretrained weights. `None` means no pretrained weights.
use_group_norm: Whether to replace batch normalization with group normalization in the backbone. use_group_norm: Whether to replace batch normalization with group normalization in the backbone.
@@ -120,9 +114,7 @@ class DiffusionConfig(PreTrainedConfig):
# Architecture / modeling. # Architecture / modeling.
# Vision backbone. # Vision backbone.
vision_backbone: str = "resnet18" vision_backbone: str = "resnet18"
resize_shape: tuple[int, int] | None = None crop_shape: tuple[int, int] | None = (84, 84)
crop_ratio: float = 1.0
crop_shape: tuple[int, int] | None = None
crop_is_random: bool = True crop_is_random: bool = True
pretrained_backbone_weights: str | None = None pretrained_backbone_weights: str | None = None
use_group_norm: bool = True use_group_norm: bool = True
@@ -183,25 +175,6 @@ class DiffusionConfig(PreTrainedConfig):
f"Got {self.noise_scheduler_type}." f"Got {self.noise_scheduler_type}."
) )
if self.resize_shape is not None and (
len(self.resize_shape) != 2 or any(d <= 0 for d in self.resize_shape)
):
raise ValueError(f"`resize_shape` must be a pair of positive integers. Got {self.resize_shape}.")
if not (0 < self.crop_ratio <= 1.0):
raise ValueError(f"`crop_ratio` must be in (0, 1]. Got {self.crop_ratio}.")
if self.resize_shape is not None:
if self.crop_ratio < 1.0:
self.crop_shape = (
int(self.resize_shape[0] * self.crop_ratio),
int(self.resize_shape[1] * self.crop_ratio),
)
else:
# Explicitly disable cropping for resize+ratio path when crop_ratio == 1.0.
self.crop_shape = None
if self.crop_shape is not None and (self.crop_shape[0] <= 0 or self.crop_shape[1] <= 0):
raise ValueError(f"`crop_shape` must have positive dimensions. Got {self.crop_shape}.")
# Check that the horizon size and U-Net downsampling is compatible. # Check that the horizon size and U-Net downsampling is compatible.
# U-Net downsamples by 2 with each stage. # U-Net downsamples by 2 with each stage.
downsampling_factor = 2 ** len(self.down_dims) downsampling_factor = 2 ** len(self.down_dims)
@@ -229,12 +202,13 @@ class DiffusionConfig(PreTrainedConfig):
if len(self.image_features) == 0 and self.env_state_feature is None: if len(self.image_features) == 0 and self.env_state_feature is None:
raise ValueError("You must provide at least one image or the environment state among the inputs.") raise ValueError("You must provide at least one image or the environment state among the inputs.")
if self.resize_shape is None and self.crop_shape is not None: if self.crop_shape is not None:
for key, image_ft in self.image_features.items(): for key, image_ft in self.image_features.items():
if self.crop_shape[0] > image_ft.shape[1] or self.crop_shape[1] > image_ft.shape[2]: if self.crop_shape[0] > image_ft.shape[1] or self.crop_shape[1] > image_ft.shape[2]:
raise ValueError( raise ValueError(
f"`crop_shape` should fit within the image shapes. Got {self.crop_shape} " f"`crop_shape` should fit within the images shapes. Got {self.crop_shape} "
f"for `crop_shape` and {image_ft.shape} for `{key}`." f"for `crop_shape` and {image_ft.shape} for "
f"`{key}`."
) )
# Check that all input images have the same shape. # Check that all input images have the same shape.
@@ -454,18 +454,12 @@ class DiffusionRgbEncoder(nn.Module):
def __init__(self, config: DiffusionConfig): def __init__(self, config: DiffusionConfig):
super().__init__() super().__init__()
# Set up optional preprocessing. # Set up optional preprocessing.
if config.resize_shape is not None: if config.crop_shape is not None:
self.resize = torchvision.transforms.Resize(config.resize_shape)
else:
self.resize = None
crop_shape = config.crop_shape
if crop_shape is not None:
self.do_crop = True self.do_crop = True
# Always use center crop for eval # Always use center crop for eval
self.center_crop = torchvision.transforms.CenterCrop(crop_shape) self.center_crop = torchvision.transforms.CenterCrop(config.crop_shape)
if config.crop_is_random: if config.crop_is_random:
self.maybe_random_crop = torchvision.transforms.RandomCrop(crop_shape) self.maybe_random_crop = torchvision.transforms.RandomCrop(config.crop_shape)
else: else:
self.maybe_random_crop = self.center_crop self.maybe_random_crop = self.center_crop
else: else:
@@ -491,16 +485,13 @@ class DiffusionRgbEncoder(nn.Module):
# Set up pooling and final layers. # Set up pooling and final layers.
# Use a dry run to get the feature map shape. # Use a dry run to get the feature map shape.
# The dummy shape mirrors the runtime preprocessing order: resize -> crop. # The dummy input should take the number of image channels from `config.image_features` and it should
# use the height and width from `config.crop_shape` if it is provided, otherwise it should use the
# height and width from `config.image_features`.
# Note: we have a check in the config class to make sure all images have the same shape. # Note: we have a check in the config class to make sure all images have the same shape.
images_shape = next(iter(config.image_features.values())).shape images_shape = next(iter(config.image_features.values())).shape
if config.crop_shape is not None: dummy_shape_h_w = config.crop_shape if config.crop_shape is not None else images_shape[1:]
dummy_shape_h_w = config.crop_shape
elif config.resize_shape is not None:
dummy_shape_h_w = config.resize_shape
else:
dummy_shape_h_w = images_shape[1:]
dummy_shape = (1, images_shape[0], *dummy_shape_h_w) dummy_shape = (1, images_shape[0], *dummy_shape_h_w)
feature_map_shape = get_output_shape(self.backbone, dummy_shape)[1:] feature_map_shape = get_output_shape(self.backbone, dummy_shape)[1:]
@@ -516,10 +507,7 @@ class DiffusionRgbEncoder(nn.Module):
Returns: Returns:
(B, D) image feature. (B, D) image feature.
""" """
# Preprocess: resize if configured, then crop if configured. # Preprocess: maybe crop (if it was set up in the __init__).
if self.resize is not None:
x = self.resize(x)
if self.do_crop: if self.do_crop:
if self.training: # noqa: SIM108 if self.training: # noqa: SIM108
x = self.maybe_random_crop(x) x = self.maybe_random_crop(x)
+2 -1
View File
@@ -18,9 +18,10 @@ from __future__ import annotations
import importlib import importlib
import logging import logging
from typing import Any, TypedDict, Unpack from typing import Any, TypedDict
import torch import torch
from typing_extensions import Unpack
from lerobot.configs.policies import PreTrainedConfig from lerobot.configs.policies import PreTrainedConfig
from lerobot.configs.types import FeatureType from lerobot.configs.types import FeatureType
@@ -4,16 +4,17 @@
# Licensed under The MIT License [see LICENSE for details] # Licensed under The MIT License [see LICENSE for details]
# -------------------------------------------------------- # --------------------------------------------------------
from __future__ import annotations
# copy from https://github.com/huggingface/transformers/blob/main/src/transformers/models/llava_onevision/image_processing_llava_onevision_fast.py # copy from https://github.com/huggingface/transformers/blob/main/src/transformers/models/llava_onevision/image_processing_llava_onevision_fast.py
from typing import Optional
from transformers.image_processing_utils import ( from transformers.image_processing_utils import (
BatchFeature, BatchFeature,
get_patch_output_size, get_patch_output_size,
) )
from transformers.image_processing_utils_fast import ( from transformers.image_processing_utils_fast import (
BaseImageProcessorFast, BaseImageProcessorFast,
ImagesKwargs, DefaultFastImageProcessorKwargs,
group_images_by_shape, group_images_by_shape,
reorder_images, reorder_images,
) )
@@ -76,7 +77,7 @@ def crop(img: torch.Tensor, left: int, top: int, right: int, bottom: int) -> tor
return img[:, top:bottom, left:right] return img[:, top:bottom, left:right]
class Eagle25VLFastImageProcessorKwargs(ImagesKwargs): class Eagle25VLFastImageProcessorKwargs(DefaultFastImageProcessorKwargs):
max_dynamic_tiles: int | None max_dynamic_tiles: int | None
min_dynamic_tiles: int | None min_dynamic_tiles: int | None
use_thumbnail: bool | None use_thumbnail: bool | None
@@ -164,11 +165,11 @@ class Eagle25VLImageProcessorFast(BaseImageProcessorFast):
def _resize_for_patching( def _resize_for_patching(
self, self,
image: torch.Tensor, image: "torch.Tensor",
target_resolution: tuple, target_resolution: tuple,
interpolation: F.InterpolationMode, interpolation: "F.InterpolationMode",
input_data_format: ChannelDimension, input_data_format: ChannelDimension,
) -> torch.Tensor: ) -> "torch.Tensor":
""" """
Resizes an image to a target resolution while maintaining aspect ratio. Resizes an image to a target resolution while maintaining aspect ratio.
@@ -218,8 +219,8 @@ class Eagle25VLImageProcessorFast(BaseImageProcessorFast):
return best_ratio return best_ratio
def _pad_for_patching( def _pad_for_patching(
self, image: torch.Tensor, target_resolution: tuple, input_data_format: ChannelDimension self, image: "torch.Tensor", target_resolution: tuple, input_data_format: ChannelDimension
) -> torch.Tensor: ) -> "torch.Tensor":
""" """
Pad an image to a target resolution while maintaining aspect ratio. Pad an image to a target resolution while maintaining aspect ratio.
""" """
@@ -235,15 +236,15 @@ class Eagle25VLImageProcessorFast(BaseImageProcessorFast):
def _get_image_patches( def _get_image_patches(
self, self,
image: torch.Tensor, image: "torch.Tensor",
min_num: int, min_num: int,
max_num: int, max_num: int,
size: tuple, size: tuple,
tile_size: int, tile_size: int,
use_thumbnail: bool, use_thumbnail: bool,
interpolation: F.InterpolationMode, interpolation: "F.InterpolationMode",
pad_during_tiling: bool, pad_during_tiling: bool,
) -> list[torch.Tensor]: ) -> list["torch.Tensor"]:
image_size = get_image_size(image, channel_dim=ChannelDimension.FIRST) image_size = get_image_size(image, channel_dim=ChannelDimension.FIRST)
orig_height, orig_width = image_size orig_height, orig_width = image_size
aspect_ratio = orig_width / orig_height aspect_ratio = orig_width / orig_height
@@ -304,8 +305,8 @@ class Eagle25VLImageProcessorFast(BaseImageProcessorFast):
def _pad_for_batching( def _pad_for_batching(
self, self,
pixel_values: list[torch.Tensor], pixel_values: list["torch.Tensor"],
) -> list[torch.Tensor]: ) -> list["torch.Tensor"]:
""" """
Pads images on the `num_of_patches` dimension with zeros to form a batch of same number of patches. Pads images on the `num_of_patches` dimension with zeros to form a batch of same number of patches.
@@ -326,14 +327,14 @@ class Eagle25VLImageProcessorFast(BaseImageProcessorFast):
def _preprocess( def _preprocess(
self, self,
images: list[torch.Tensor], images: list["torch.Tensor"],
do_resize: bool, do_resize: bool,
size: SizeDict, size: SizeDict,
max_dynamic_tiles: int, max_dynamic_tiles: int,
min_dynamic_tiles: int, min_dynamic_tiles: int,
use_thumbnail: bool, use_thumbnail: bool,
pad_during_tiling: bool, pad_during_tiling: bool,
interpolation: F.InterpolationMode | None, interpolation: Optional["F.InterpolationMode"],
do_center_crop: bool, do_center_crop: bool,
crop_size: SizeDict, crop_size: SizeDict,
do_rescale: bool, do_rescale: bool,
+54 -68
View File
@@ -15,16 +15,16 @@
# limitations under the License. # limitations under the License.
import builtins import builtins
import copy
import logging import logging
import math import math
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Literal, TypedDict, Unpack from typing import TYPE_CHECKING, Literal, TypedDict
import torch import torch
import torch.nn.functional as F # noqa: N812 import torch.nn.functional as F # noqa: N812
from torch import Tensor, nn from torch import Tensor, nn
from typing_extensions import Unpack
from lerobot.utils.import_utils import _transformers_available from lerobot.utils.import_utils import _transformers_available
@@ -32,21 +32,13 @@ from lerobot.utils.import_utils import _transformers_available
if TYPE_CHECKING or _transformers_available: if TYPE_CHECKING or _transformers_available:
from transformers.models.auto import CONFIG_MAPPING from transformers.models.auto import CONFIG_MAPPING
from transformers.models.gemma import modeling_gemma from transformers.models.gemma import modeling_gemma
from transformers.models.gemma.modeling_gemma import GemmaForCausalLM
from lerobot.policies.pi_gemma import ( from transformers.models.paligemma.modeling_paligemma import PaliGemmaForConditionalGeneration
PaliGemmaForConditionalGenerationWithPiGemma,
PiGemmaForCausalLM,
_gated_residual,
layernorm_forward,
)
else: else:
CONFIG_MAPPING = None CONFIG_MAPPING = None
modeling_gemma = None modeling_gemma = None
PiGemmaForCausalLM = None GemmaForCausalLM = None
_gated_residual = None PaliGemmaForConditionalGeneration = None
layernorm_forward = None
PaliGemmaForConditionalGenerationWithPiGemma = None
from lerobot.configs.policies import PreTrainedConfig from lerobot.configs.policies import PreTrainedConfig
from lerobot.policies.pi0.configuration_pi0 import DEFAULT_IMAGE_SIZE, PI0Config from lerobot.policies.pi0.configuration_pi0 import DEFAULT_IMAGE_SIZE, PI0Config
@@ -199,7 +191,7 @@ def resize_with_pad_torch( # see openpi `resize_with_pad_torch` (exact copy)
if images.dtype == torch.uint8: if images.dtype == torch.uint8:
resized_images = torch.round(resized_images).clamp(0, 255).to(torch.uint8) resized_images = torch.round(resized_images).clamp(0, 255).to(torch.uint8)
elif images.dtype == torch.float32: elif images.dtype == torch.float32:
resized_images = resized_images.clamp(0.0, 1.0) resized_images = resized_images.clamp(-1.0, 1.0)
else: else:
raise ValueError(f"Unsupported image dtype: {images.dtype}") raise ValueError(f"Unsupported image dtype: {images.dtype}")
@@ -210,7 +202,7 @@ def resize_with_pad_torch( # see openpi `resize_with_pad_torch` (exact copy)
pad_w1 = pad_w0 + remainder_w pad_w1 = pad_w0 + remainder_w
# Pad # Pad
constant_value = 0 if images.dtype == torch.uint8 else 0.0 constant_value = 0 if images.dtype == torch.uint8 else -1.0
padded_images = F.pad( padded_images = F.pad(
resized_images, resized_images,
(pad_w0, pad_w1, pad_h0, pad_h1), # left, right, top, bottom (pad_w0, pad_w1, pad_h0, pad_h1), # left, right, top, bottom
@@ -229,14 +221,14 @@ def resize_with_pad_torch( # see openpi `resize_with_pad_torch` (exact copy)
def compute_layer_complete( def compute_layer_complete(
layer_idx, inputs_embeds, attention_mask, position_ids, adarms_cond, paligemma, gemma_expert layer_idx, inputs_embeds, attention_mask, position_ids, adarms_cond, paligemma, gemma_expert
): ):
models = [paligemma.model.language_model, gemma_expert.model] models = [paligemma.language_model, gemma_expert.model]
query_states = [] query_states = []
key_states = [] key_states = []
value_states = [] value_states = []
gates = [] gates = []
for i, hidden_states in enumerate(inputs_embeds): for i, hidden_states in enumerate(inputs_embeds):
layer = models[i].layers[layer_idx] layer = models[i].layers[layer_idx]
hidden_states, gate = layernorm_forward(layer.input_layernorm, hidden_states, adarms_cond[i]) hidden_states, gate = layer.input_layernorm(hidden_states, cond=adarms_cond[i]) # noqa: PLW2901
gates.append(gate) gates.append(gate)
input_shape = hidden_states.shape[:-1] input_shape = hidden_states.shape[:-1]
hidden_shape = (*input_shape, -1, layer.self_attn.head_dim) hidden_shape = (*input_shape, -1, layer.self_attn.head_dim)
@@ -262,10 +254,10 @@ def compute_layer_complete(
query_states, key_states, cos, sin, unsqueeze_dim=1 query_states, key_states, cos, sin, unsqueeze_dim=1
) )
batch_size = query_states.shape[0] batch_size = query_states.shape[0]
scaling = paligemma.model.language_model.layers[layer_idx].self_attn.scaling scaling = paligemma.language_model.layers[layer_idx].self_attn.scaling
# Attention computation # Attention computation
att_output, _ = modeling_gemma.eager_attention_forward( att_output, _ = modeling_gemma.eager_attention_forward(
paligemma.model.language_model.layers[layer_idx].self_attn, paligemma.language_model.layers[layer_idx].self_attn,
query_states, query_states,
key_states, key_states,
value_states, value_states,
@@ -273,7 +265,7 @@ def compute_layer_complete(
scaling, scaling,
) )
# Get head_dim from the current layer, not from the model # Get head_dim from the current layer, not from the model
head_dim = paligemma.model.language_model.layers[layer_idx].self_attn.head_dim head_dim = paligemma.language_model.layers[layer_idx].self_attn.head_dim
att_output = att_output.reshape(batch_size, -1, 1 * 8 * head_dim) att_output = att_output.reshape(batch_size, -1, 1 * 8 * head_dim)
# Process layer outputs # Process layer outputs
outputs_embeds = [] outputs_embeds = []
@@ -285,15 +277,15 @@ def compute_layer_complete(
att_output = att_output.to(layer.self_attn.o_proj.weight.dtype) att_output = att_output.to(layer.self_attn.o_proj.weight.dtype)
out_emb = layer.self_attn.o_proj(att_output[:, start_pos:end_pos]) out_emb = layer.self_attn.o_proj(att_output[:, start_pos:end_pos])
# first residual # first residual
out_emb = _gated_residual(hidden_states, out_emb, gates[i]) out_emb = modeling_gemma._gated_residual(hidden_states, out_emb, gates[i]) # noqa: SLF001
after_first_residual = out_emb.clone() after_first_residual = out_emb.clone()
out_emb, gate = layernorm_forward(layer.post_attention_layernorm, out_emb, adarms_cond[i]) out_emb, gate = layer.post_attention_layernorm(out_emb, cond=adarms_cond[i])
# Convert to bfloat16 if the next layer (mlp) uses bfloat16 # Convert to bfloat16 if the next layer (mlp) uses bfloat16
if layer.mlp.up_proj.weight.dtype == torch.bfloat16: if layer.mlp.up_proj.weight.dtype == torch.bfloat16:
out_emb = out_emb.to(dtype=torch.bfloat16) out_emb = out_emb.to(dtype=torch.bfloat16)
out_emb = layer.mlp(out_emb) out_emb = layer.mlp(out_emb)
# second residual # second residual
out_emb = _gated_residual(after_first_residual, out_emb, gate) out_emb = modeling_gemma._gated_residual(after_first_residual, out_emb, gate) # noqa: SLF001
outputs_embeds.append(out_emb) outputs_embeds.append(out_emb)
start_pos = end_pos start_pos = end_pos
return outputs_embeds return outputs_embeds
@@ -366,7 +358,7 @@ class PaliGemmaWithExpertModel(
vlm_config_hf.text_config.num_hidden_layers = vlm_config.depth vlm_config_hf.text_config.num_hidden_layers = vlm_config.depth
vlm_config_hf.text_config.num_key_value_heads = vlm_config.num_kv_heads vlm_config_hf.text_config.num_key_value_heads = vlm_config.num_kv_heads
vlm_config_hf.text_config.hidden_activation = "gelu_pytorch_tanh" vlm_config_hf.text_config.hidden_activation = "gelu_pytorch_tanh"
vlm_config_hf.text_config.dtype = "float32" vlm_config_hf.text_config.torch_dtype = "float32"
vlm_config_hf.text_config.vocab_size = 257152 vlm_config_hf.text_config.vocab_size = 257152
vlm_config_hf.text_config.use_adarms = use_adarms[0] vlm_config_hf.text_config.use_adarms = use_adarms[0]
vlm_config_hf.text_config.adarms_cond_dim = vlm_config.width if use_adarms[0] else None vlm_config_hf.text_config.adarms_cond_dim = vlm_config.width if use_adarms[0] else None
@@ -374,7 +366,7 @@ class PaliGemmaWithExpertModel(
vlm_config_hf.vision_config.intermediate_size = 4304 vlm_config_hf.vision_config.intermediate_size = 4304
vlm_config_hf.vision_config.projection_dim = 2048 vlm_config_hf.vision_config.projection_dim = 2048
vlm_config_hf.vision_config.projector_hidden_act = "gelu_fast" vlm_config_hf.vision_config.projector_hidden_act = "gelu_fast"
vlm_config_hf.vision_config.dtype = "float32" vlm_config_hf.vision_config.torch_dtype = "float32"
action_expert_config_hf = CONFIG_MAPPING["gemma"]( action_expert_config_hf = CONFIG_MAPPING["gemma"](
head_dim=action_expert_config.head_dim, head_dim=action_expert_config.head_dim,
@@ -385,13 +377,13 @@ class PaliGemmaWithExpertModel(
num_key_value_heads=action_expert_config.num_kv_heads, num_key_value_heads=action_expert_config.num_kv_heads,
vocab_size=257152, vocab_size=257152,
hidden_activation="gelu_pytorch_tanh", hidden_activation="gelu_pytorch_tanh",
dtype="float32", torch_dtype="float32",
use_adarms=use_adarms[1], use_adarms=use_adarms[1],
adarms_cond_dim=action_expert_config.width if use_adarms[1] else None, adarms_cond_dim=action_expert_config.width if use_adarms[1] else None,
) )
self.paligemma = PaliGemmaForConditionalGenerationWithPiGemma(config=vlm_config_hf) self.paligemma = PaliGemmaForConditionalGeneration(config=vlm_config_hf)
self.gemma_expert = PiGemmaForCausalLM(config=action_expert_config_hf) self.gemma_expert = GemmaForCausalLM(config=action_expert_config_hf)
self.gemma_expert.model.embed_tokens = None self.gemma_expert.model.embed_tokens = None
self.to_bfloat16_for_selected_params(precision) self.to_bfloat16_for_selected_params(precision)
@@ -406,11 +398,10 @@ class PaliGemmaWithExpertModel(
else: else:
raise ValueError(f"Invalid precision: {precision}") raise ValueError(f"Invalid precision: {precision}")
# Keep full vision path in float32 so we never toggle (toggle causes optimizer
# "same dtype" error). Align with PI05.
params_to_keep_float32 = [ params_to_keep_float32 = [
"vision_tower", "vision_tower.vision_model.embeddings.patch_embedding.weight",
"multi_modal_projector", "vision_tower.vision_model.embeddings.patch_embedding.bias",
"vision_tower.vision_model.embeddings.position_embedding.weight",
"input_layernorm", "input_layernorm",
"post_attention_layernorm", "post_attention_layernorm",
"model.norm", "model.norm",
@@ -422,8 +413,8 @@ class PaliGemmaWithExpertModel(
def _set_requires_grad(self): def _set_requires_grad(self):
if self.freeze_vision_encoder: if self.freeze_vision_encoder:
self.paligemma.model.vision_tower.eval() self.paligemma.vision_tower.eval()
for param in self.paligemma.model.vision_tower.parameters(): for param in self.paligemma.vision_tower.parameters():
param.requires_grad = False param.requires_grad = False
if self.train_expert_only: if self.train_expert_only:
self.paligemma.eval() self.paligemma.eval()
@@ -433,23 +424,15 @@ class PaliGemmaWithExpertModel(
def train(self, mode: bool = True): def train(self, mode: bool = True):
super().train(mode) super().train(mode)
if self.freeze_vision_encoder: if self.freeze_vision_encoder:
self.paligemma.model.vision_tower.eval() self.paligemma.vision_tower.eval()
if self.train_expert_only: if self.train_expert_only:
self.paligemma.eval() self.paligemma.eval()
def embed_image(self, image: torch.Tensor): def embed_image(self, image: torch.Tensor):
# Vision tower and multi_modal_projector are kept in float32 (params_to_keep_float32). Align with PI05. return self.paligemma.model.get_image_features(image)
out_dtype = image.dtype
if image.dtype != torch.float32:
image = image.to(torch.float32)
image_outputs = self.paligemma.model.get_image_features(image)
features = image_outputs.pooler_output * self.paligemma.config.text_config.hidden_size**0.5
if features.dtype != out_dtype:
features = features.to(out_dtype)
return features
def embed_language_tokens(self, tokens: torch.Tensor): def embed_language_tokens(self, tokens: torch.Tensor):
return self.paligemma.model.language_model.embed_tokens(tokens) return self.paligemma.language_model.embed_tokens(tokens)
def forward( def forward(
self, self,
@@ -463,7 +446,7 @@ class PaliGemmaWithExpertModel(
if adarms_cond is None: if adarms_cond is None:
adarms_cond = [None, None] adarms_cond = [None, None]
if inputs_embeds[1] is None: if inputs_embeds[1] is None:
prefix_output = self.paligemma.model.language_model.forward( prefix_output = self.paligemma.language_model.forward(
inputs_embeds=inputs_embeds[0], inputs_embeds=inputs_embeds[0],
attention_mask=attention_mask, attention_mask=attention_mask,
position_ids=position_ids, position_ids=position_ids,
@@ -487,7 +470,7 @@ class PaliGemmaWithExpertModel(
prefix_output = None prefix_output = None
prefix_past_key_values = None prefix_past_key_values = None
else: else:
models = [self.paligemma.model.language_model, self.gemma_expert.model] models = [self.paligemma.language_model, self.gemma_expert.model]
num_layers = self.paligemma.config.text_config.num_hidden_layers num_layers = self.paligemma.config.text_config.num_hidden_layers
# Check if gradient checkpointing is enabled for any of the models # Check if gradient checkpointing is enabled for any of the models
@@ -527,7 +510,7 @@ class PaliGemmaWithExpertModel(
def compute_final_norms(inputs_embeds, adarms_cond): def compute_final_norms(inputs_embeds, adarms_cond):
outputs_embeds = [] outputs_embeds = []
for i, hidden_states in enumerate(inputs_embeds): for i, hidden_states in enumerate(inputs_embeds):
out_emb, _ = layernorm_forward(models[i].norm, hidden_states, adarms_cond[i]) out_emb, _ = models[i].norm(hidden_states, cond=adarms_cond[i])
outputs_embeds.append(out_emb) outputs_embeds.append(out_emb)
return outputs_embeds return outputs_embeds
@@ -593,19 +576,29 @@ class PI0Pytorch(nn.Module): # see openpi `PI0Pytorch`
# Also compile the main forward pass used during training # Also compile the main forward pass used during training
self.forward = torch.compile(self.forward, mode=config.compile_mode) 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"""
try:
from transformers.models.siglip import check
if not check.check_whether_transformers_replace_is_installed_correctly():
raise ValueError(msg)
except ImportError:
raise ValueError(msg) from None
def gradient_checkpointing_enable(self): def gradient_checkpointing_enable(self):
"""Enable gradient checkpointing for memory optimization.""" """Enable gradient checkpointing for memory optimization."""
self.gradient_checkpointing_enabled = True self.gradient_checkpointing_enabled = True
self.paligemma_with_expert.paligemma.model.language_model.gradient_checkpointing = True self.paligemma_with_expert.paligemma.language_model.gradient_checkpointing = True
self.paligemma_with_expert.paligemma.model.vision_tower.gradient_checkpointing = True self.paligemma_with_expert.paligemma.vision_tower.gradient_checkpointing = True
self.paligemma_with_expert.gemma_expert.model.gradient_checkpointing = True self.paligemma_with_expert.gemma_expert.model.gradient_checkpointing = True
logging.info("Enabled gradient checkpointing for PI0Pytorch model") logging.info("Enabled gradient checkpointing for PI0Pytorch model")
def gradient_checkpointing_disable(self): def gradient_checkpointing_disable(self):
"""Disable gradient checkpointing.""" """Disable gradient checkpointing."""
self.gradient_checkpointing_enabled = False self.gradient_checkpointing_enabled = False
self.paligemma_with_expert.paligemma.model.language_model.gradient_checkpointing = False self.paligemma_with_expert.paligemma.language_model.gradient_checkpointing = False
self.paligemma_with_expert.paligemma.model.vision_tower.gradient_checkpointing = False self.paligemma_with_expert.paligemma.vision_tower.gradient_checkpointing = False
self.paligemma_with_expert.gemma_expert.model.gradient_checkpointing = False self.paligemma_with_expert.gemma_expert.model.gradient_checkpointing = False
logging.info("Disabled gradient checkpointing for PI0Pytorch model") logging.info("Disabled gradient checkpointing for PI0Pytorch model")
@@ -767,7 +760,7 @@ class PI0Pytorch(nn.Module): # see openpi `PI0Pytorch`
suffix_embs, suffix_pad_masks, suffix_att_masks, adarms_cond = self.embed_suffix(state, x_t, time) suffix_embs, suffix_pad_masks, suffix_att_masks, adarms_cond = self.embed_suffix(state, x_t, time)
if ( if (
self.paligemma_with_expert.paligemma.model.language_model.layers[0].self_attn.q_proj.weight.dtype self.paligemma_with_expert.paligemma.language_model.layers[0].self_attn.q_proj.weight.dtype
== torch.bfloat16 == torch.bfloat16
): ):
suffix_embs = suffix_embs.to(dtype=torch.bfloat16) suffix_embs = suffix_embs.to(dtype=torch.bfloat16)
@@ -841,7 +834,7 @@ class PI0Pytorch(nn.Module): # see openpi `PI0Pytorch`
prefix_position_ids = torch.cumsum(prefix_pad_masks, dim=1) - 1 prefix_position_ids = torch.cumsum(prefix_pad_masks, dim=1) - 1
prefix_att_2d_masks_4d = self._prepare_attention_masks_4d(prefix_att_2d_masks) prefix_att_2d_masks_4d = self._prepare_attention_masks_4d(prefix_att_2d_masks)
self.paligemma_with_expert.paligemma.model.language_model.config._attn_implementation = "eager" # noqa: SLF001 self.paligemma_with_expert.paligemma.language_model.config._attn_implementation = "eager" # noqa: SLF001
_, past_key_values = self.paligemma_with_expert.forward( _, past_key_values = self.paligemma_with_expert.forward(
attention_mask=prefix_att_2d_masks_4d, attention_mask=prefix_att_2d_masks_4d,
@@ -915,7 +908,6 @@ class PI0Pytorch(nn.Module): # see openpi `PI0Pytorch`
full_att_2d_masks_4d = self._prepare_attention_masks_4d(full_att_2d_masks) full_att_2d_masks_4d = self._prepare_attention_masks_4d(full_att_2d_masks)
self.paligemma_with_expert.gemma_expert.model.config._attn_implementation = "eager" # noqa: SLF001 self.paligemma_with_expert.gemma_expert.model.config._attn_implementation = "eager" # noqa: SLF001
past_key_values = copy.deepcopy(past_key_values)
outputs_embeds, _ = self.paligemma_with_expert.forward( outputs_embeds, _ = self.paligemma_with_expert.forward(
attention_mask=full_att_2d_masks_4d, attention_mask=full_att_2d_masks_4d,
position_ids=position_ids, position_ids=position_ids,
@@ -1005,12 +997,14 @@ class PI0Policy(PreTrainedPolicy):
# Check if dataset_stats were provided in kwargs # Check if dataset_stats were provided in kwargs
model = cls(config, **kwargs) model = cls(config, **kwargs)
# Load state dict (expects keys with "model." prefix) # Now manually load and remap the state dict
try: try:
# Try to load the pytorch_model.bin or model.safetensors file
print(f"Loading model from: {pretrained_name_or_path}") print(f"Loading model from: {pretrained_name_or_path}")
try: try:
from transformers.utils import cached_file from transformers.utils import cached_file
# Try safetensors first
resolved_file = cached_file( resolved_file = cached_file(
pretrained_name_or_path, pretrained_name_or_path,
"model.safetensors", "model.safetensors",
@@ -1018,7 +1012,7 @@ class PI0Policy(PreTrainedPolicy):
force_download=kwargs.get("force_download", False), force_download=kwargs.get("force_download", False),
resume_download=kwargs.get("resume_download"), resume_download=kwargs.get("resume_download"),
proxies=kwargs.get("proxies"), proxies=kwargs.get("proxies"),
token=kwargs.get("token"), use_auth_token=kwargs.get("use_auth_token"),
revision=kwargs.get("revision"), revision=kwargs.get("revision"),
local_files_only=kwargs.get("local_files_only", False), local_files_only=kwargs.get("local_files_only", False),
) )
@@ -1031,7 +1025,7 @@ class PI0Policy(PreTrainedPolicy):
print("Returning model without loading pretrained weights") print("Returning model without loading pretrained weights")
return model return model
# First, fix any key differences (see openpi model.py, _fix_pytorch_state_dict_keys) # First, fix any key differences # see openpi `model.py, _fix_pytorch_state_dict_keys`
fixed_state_dict = model._fix_pytorch_state_dict_keys(original_state_dict, model.config) fixed_state_dict = model._fix_pytorch_state_dict_keys(original_state_dict, model.config)
# Then add "model." prefix for all keys that don't already have it # Then add "model." prefix for all keys that don't already have it
@@ -1076,7 +1070,7 @@ class PI0Policy(PreTrainedPolicy):
print("All keys loaded successfully!") print("All keys loaded successfully!")
except Exception as e: except Exception as e:
print(f"Warning: Could not load state dict: {e}") print(f"Warning: Could not remap state dict keys: {e}")
return model return model
@@ -1126,14 +1120,6 @@ class PI0Policy(PreTrainedPolicy):
# Some checkpoints might have this, but current model expects different structure # Some checkpoints might have this, but current model expects different structure
logging.warning(f"Vision embedding key might need handling: {key}") logging.warning(f"Vision embedding key might need handling: {key}")
if (
key == "model.paligemma_with_expert.paligemma.lm_head.weight"
or key == "paligemma_with_expert.paligemma.lm_head.weight"
):
fixed_state_dict[
"model.paligemma_with_expert.paligemma.model.language_model.embed_tokens.weight"
] = value.clone()
fixed_state_dict[new_key] = value fixed_state_dict[new_key] = value
return fixed_state_dict return fixed_state_dict
+60 -71
View File
@@ -15,16 +15,16 @@
# limitations under the License. # limitations under the License.
import builtins import builtins
import copy
import logging import logging
import math import math
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Literal, TypedDict, Unpack from typing import TYPE_CHECKING, Literal, TypedDict
import torch import torch
import torch.nn.functional as F # noqa: N812 import torch.nn.functional as F # noqa: N812
from torch import Tensor, nn from torch import Tensor, nn
from typing_extensions import Unpack
from lerobot.utils.import_utils import _transformers_available from lerobot.utils.import_utils import _transformers_available
@@ -32,20 +32,14 @@ from lerobot.utils.import_utils import _transformers_available
if TYPE_CHECKING or _transformers_available: if TYPE_CHECKING or _transformers_available:
from transformers.models.auto import CONFIG_MAPPING from transformers.models.auto import CONFIG_MAPPING
from transformers.models.gemma import modeling_gemma from transformers.models.gemma import modeling_gemma
from transformers.models.gemma.modeling_gemma import GemmaForCausalLM
from lerobot.policies.pi_gemma import ( from transformers.models.paligemma.modeling_paligemma import PaliGemmaForConditionalGeneration
PaliGemmaForConditionalGenerationWithPiGemma,
PiGemmaForCausalLM,
_gated_residual,
layernorm_forward,
)
else: else:
CONFIG_MAPPING = None CONFIG_MAPPING = None
modeling_gemma = None modeling_gemma = None
PiGemmaForCausalLM = None GemmaForCausalLM = None
_gated_residual = None PaliGemmaForConditionalGeneration = None
layernorm_forward = None
PaliGemmaForConditionalGenerationWithPiGemma = None
from lerobot.configs.policies import PreTrainedConfig from lerobot.configs.policies import PreTrainedConfig
from lerobot.policies.pi05.configuration_pi05 import DEFAULT_IMAGE_SIZE, PI05Config from lerobot.policies.pi05.configuration_pi05 import DEFAULT_IMAGE_SIZE, PI05Config
from lerobot.policies.pretrained import PreTrainedPolicy, T from lerobot.policies.pretrained import PreTrainedPolicy, T
@@ -98,11 +92,10 @@ def create_sinusoidal_pos_embedding( # see openpi `create_sinusoidal_pos_embedd
def sample_beta(alpha, beta, bsize, device): # see openpi `sample_beta` (exact copy) def sample_beta(alpha, beta, bsize, device): # see openpi `sample_beta` (exact copy)
# Beta sampling uses _sample_dirichlet which isn't implemented for MPS, so sample on CPU alpha_t = torch.as_tensor(alpha, dtype=torch.float32, device=device)
alpha_t = torch.tensor(alpha, dtype=torch.float32) beta_t = torch.as_tensor(beta, dtype=torch.float32, device=device)
beta_t = torch.tensor(beta, dtype=torch.float32)
dist = torch.distributions.Beta(alpha_t, beta_t) dist = torch.distributions.Beta(alpha_t, beta_t)
return dist.sample((bsize,)).to(device) return dist.sample((bsize,))
def make_att_2d_masks(pad_masks, att_masks): # see openpi `make_att_2d_masks` (exact copy) def make_att_2d_masks(pad_masks, att_masks): # see openpi `make_att_2d_masks` (exact copy)
@@ -196,7 +189,7 @@ def resize_with_pad_torch( # see openpi `resize_with_pad_torch` (exact copy)
if images.dtype == torch.uint8: if images.dtype == torch.uint8:
resized_images = torch.round(resized_images).clamp(0, 255).to(torch.uint8) resized_images = torch.round(resized_images).clamp(0, 255).to(torch.uint8)
elif images.dtype == torch.float32: elif images.dtype == torch.float32:
resized_images = resized_images.clamp(0.0, 1.0) resized_images = resized_images.clamp(-1.0, 1.0)
else: else:
raise ValueError(f"Unsupported image dtype: {images.dtype}") raise ValueError(f"Unsupported image dtype: {images.dtype}")
@@ -207,7 +200,7 @@ def resize_with_pad_torch( # see openpi `resize_with_pad_torch` (exact copy)
pad_w1 = pad_w0 + remainder_w pad_w1 = pad_w0 + remainder_w
# Pad # Pad
constant_value = 0 if images.dtype == torch.uint8 else 0.0 constant_value = 0 if images.dtype == torch.uint8 else -1.0
padded_images = F.pad( padded_images = F.pad(
resized_images, resized_images,
(pad_w0, pad_w1, pad_h0, pad_h1), # left, right, top, bottom (pad_w0, pad_w1, pad_h0, pad_h1), # left, right, top, bottom
@@ -226,14 +219,14 @@ def resize_with_pad_torch( # see openpi `resize_with_pad_torch` (exact copy)
def compute_layer_complete( def compute_layer_complete(
layer_idx, inputs_embeds, attention_mask, position_ids, adarms_cond, paligemma, gemma_expert layer_idx, inputs_embeds, attention_mask, position_ids, adarms_cond, paligemma, gemma_expert
): ):
models = [paligemma.model.language_model, gemma_expert.model] models = [paligemma.language_model, gemma_expert.model]
query_states = [] query_states = []
key_states = [] key_states = []
value_states = [] value_states = []
gates = [] gates = []
for i, hidden_states in enumerate(inputs_embeds): for i, hidden_states in enumerate(inputs_embeds):
layer = models[i].layers[layer_idx] layer = models[i].layers[layer_idx]
hidden_states, gate = layernorm_forward(layer.input_layernorm, hidden_states, adarms_cond[i]) hidden_states, gate = layer.input_layernorm(hidden_states, cond=adarms_cond[i]) # noqa: PLW2901
gates.append(gate) gates.append(gate)
input_shape = hidden_states.shape[:-1] input_shape = hidden_states.shape[:-1]
hidden_shape = (*input_shape, -1, layer.self_attn.head_dim) hidden_shape = (*input_shape, -1, layer.self_attn.head_dim)
@@ -259,10 +252,10 @@ def compute_layer_complete(
query_states, key_states, cos, sin, unsqueeze_dim=1 query_states, key_states, cos, sin, unsqueeze_dim=1
) )
batch_size = query_states.shape[0] batch_size = query_states.shape[0]
scaling = paligemma.model.language_model.layers[layer_idx].self_attn.scaling scaling = paligemma.language_model.layers[layer_idx].self_attn.scaling
# Attention computation # Attention computation
att_output, _ = modeling_gemma.eager_attention_forward( att_output, _ = modeling_gemma.eager_attention_forward(
paligemma.model.language_model.layers[layer_idx].self_attn, paligemma.language_model.layers[layer_idx].self_attn,
query_states, query_states,
key_states, key_states,
value_states, value_states,
@@ -270,7 +263,7 @@ def compute_layer_complete(
scaling, scaling,
) )
# Get head_dim from the current layer, not from the model # Get head_dim from the current layer, not from the model
head_dim = paligemma.model.language_model.layers[layer_idx].self_attn.head_dim head_dim = paligemma.language_model.layers[layer_idx].self_attn.head_dim
att_output = att_output.reshape(batch_size, -1, 1 * 8 * head_dim) att_output = att_output.reshape(batch_size, -1, 1 * 8 * head_dim)
# Process layer outputs # Process layer outputs
outputs_embeds = [] outputs_embeds = []
@@ -282,15 +275,15 @@ def compute_layer_complete(
att_output = att_output.to(layer.self_attn.o_proj.weight.dtype) att_output = att_output.to(layer.self_attn.o_proj.weight.dtype)
out_emb = layer.self_attn.o_proj(att_output[:, start_pos:end_pos]) out_emb = layer.self_attn.o_proj(att_output[:, start_pos:end_pos])
# first residual # first residual
out_emb = _gated_residual(hidden_states, out_emb, gates[i]) out_emb = modeling_gemma._gated_residual(hidden_states, out_emb, gates[i]) # noqa: SLF001
after_first_residual = out_emb.clone() after_first_residual = out_emb.clone()
out_emb, gate = layernorm_forward(layer.post_attention_layernorm, out_emb, adarms_cond[i]) out_emb, gate = layer.post_attention_layernorm(out_emb, cond=adarms_cond[i])
# Convert to bfloat16 if the next layer (mlp) uses bfloat16 # Convert to bfloat16 if the next layer (mlp) uses bfloat16
if layer.mlp.up_proj.weight.dtype == torch.bfloat16: if layer.mlp.up_proj.weight.dtype == torch.bfloat16:
out_emb = out_emb.to(dtype=torch.bfloat16) out_emb = out_emb.to(dtype=torch.bfloat16)
out_emb = layer.mlp(out_emb) out_emb = layer.mlp(out_emb)
# second residual # second residual
out_emb = _gated_residual(after_first_residual, out_emb, gate) out_emb = modeling_gemma._gated_residual(after_first_residual, out_emb, gate) # noqa: SLF001
outputs_embeds.append(out_emb) outputs_embeds.append(out_emb)
start_pos = end_pos start_pos = end_pos
return outputs_embeds return outputs_embeds
@@ -363,7 +356,7 @@ class PaliGemmaWithExpertModel(
vlm_config_hf.text_config.num_hidden_layers = vlm_config.depth vlm_config_hf.text_config.num_hidden_layers = vlm_config.depth
vlm_config_hf.text_config.num_key_value_heads = vlm_config.num_kv_heads vlm_config_hf.text_config.num_key_value_heads = vlm_config.num_kv_heads
vlm_config_hf.text_config.hidden_activation = "gelu_pytorch_tanh" vlm_config_hf.text_config.hidden_activation = "gelu_pytorch_tanh"
vlm_config_hf.text_config.dtype = "float32" vlm_config_hf.text_config.torch_dtype = "float32"
vlm_config_hf.text_config.vocab_size = 257152 vlm_config_hf.text_config.vocab_size = 257152
vlm_config_hf.text_config.use_adarms = use_adarms[0] vlm_config_hf.text_config.use_adarms = use_adarms[0]
vlm_config_hf.text_config.adarms_cond_dim = vlm_config.width if use_adarms[0] else None vlm_config_hf.text_config.adarms_cond_dim = vlm_config.width if use_adarms[0] else None
@@ -371,7 +364,7 @@ class PaliGemmaWithExpertModel(
vlm_config_hf.vision_config.intermediate_size = 4304 vlm_config_hf.vision_config.intermediate_size = 4304
vlm_config_hf.vision_config.projection_dim = 2048 vlm_config_hf.vision_config.projection_dim = 2048
vlm_config_hf.vision_config.projector_hidden_act = "gelu_fast" vlm_config_hf.vision_config.projector_hidden_act = "gelu_fast"
vlm_config_hf.vision_config.dtype = "float32" vlm_config_hf.vision_config.torch_dtype = "float32"
action_expert_config_hf = CONFIG_MAPPING["gemma"]( action_expert_config_hf = CONFIG_MAPPING["gemma"](
head_dim=action_expert_config.head_dim, head_dim=action_expert_config.head_dim,
@@ -382,13 +375,13 @@ class PaliGemmaWithExpertModel(
num_key_value_heads=action_expert_config.num_kv_heads, num_key_value_heads=action_expert_config.num_kv_heads,
vocab_size=257152, vocab_size=257152,
hidden_activation="gelu_pytorch_tanh", hidden_activation="gelu_pytorch_tanh",
dtype="float32", torch_dtype="float32",
use_adarms=use_adarms[1], use_adarms=use_adarms[1],
adarms_cond_dim=action_expert_config.width if use_adarms[1] else None, adarms_cond_dim=action_expert_config.width if use_adarms[1] else None,
) )
self.paligemma = PaliGemmaForConditionalGenerationWithPiGemma(config=vlm_config_hf) self.paligemma = PaliGemmaForConditionalGeneration(config=vlm_config_hf)
self.gemma_expert = PiGemmaForCausalLM(config=action_expert_config_hf) self.gemma_expert = GemmaForCausalLM(config=action_expert_config_hf)
self.gemma_expert.model.embed_tokens = None self.gemma_expert.model.embed_tokens = None
self.to_bfloat16_for_selected_params(precision) self.to_bfloat16_for_selected_params(precision)
@@ -403,11 +396,10 @@ class PaliGemmaWithExpertModel(
else: else:
raise ValueError(f"Invalid precision: {precision}") raise ValueError(f"Invalid precision: {precision}")
# Keep full vision path in float32 so we never toggle (toggle causes optimizer
# "same dtype" error). Saves memory vs full float32; more memory than only 3 params.
params_to_keep_float32 = [ params_to_keep_float32 = [
"vision_tower", "vision_tower.vision_model.embeddings.patch_embedding.weight",
"multi_modal_projector", "vision_tower.vision_model.embeddings.patch_embedding.bias",
"vision_tower.vision_model.embeddings.position_embedding.weight",
"input_layernorm", "input_layernorm",
"post_attention_layernorm", "post_attention_layernorm",
"model.norm", "model.norm",
@@ -419,8 +411,8 @@ class PaliGemmaWithExpertModel(
def _set_requires_grad(self): def _set_requires_grad(self):
if self.freeze_vision_encoder: if self.freeze_vision_encoder:
self.paligemma.model.vision_tower.eval() self.paligemma.vision_tower.eval()
for param in self.paligemma.model.vision_tower.parameters(): for param in self.paligemma.vision_tower.parameters():
param.requires_grad = False param.requires_grad = False
if self.train_expert_only: if self.train_expert_only:
self.paligemma.eval() self.paligemma.eval()
@@ -430,23 +422,15 @@ class PaliGemmaWithExpertModel(
def train(self, mode: bool = True): def train(self, mode: bool = True):
super().train(mode) super().train(mode)
if self.freeze_vision_encoder: if self.freeze_vision_encoder:
self.paligemma.model.vision_tower.eval() self.paligemma.vision_tower.eval()
if self.train_expert_only: if self.train_expert_only:
self.paligemma.eval() self.paligemma.eval()
def embed_image(self, image: torch.Tensor): def embed_image(self, image: torch.Tensor):
# Vision tower and multi_modal_projector are kept in float32 (params_to_keep_float32). return self.paligemma.model.get_image_features(image)
out_dtype = image.dtype
if image.dtype != torch.float32:
image = image.to(torch.float32)
image_outputs = self.paligemma.model.get_image_features(image)
features = image_outputs.pooler_output * self.paligemma.config.text_config.hidden_size**0.5
if features.dtype != out_dtype:
features = features.to(out_dtype)
return features
def embed_language_tokens(self, tokens: torch.Tensor): def embed_language_tokens(self, tokens: torch.Tensor):
return self.paligemma.model.language_model.embed_tokens(tokens) return self.paligemma.language_model.embed_tokens(tokens)
def forward( def forward(
self, self,
@@ -460,7 +444,7 @@ class PaliGemmaWithExpertModel(
if adarms_cond is None: if adarms_cond is None:
adarms_cond = [None, None] adarms_cond = [None, None]
if inputs_embeds[1] is None: if inputs_embeds[1] is None:
prefix_output = self.paligemma.model.language_model.forward( prefix_output = self.paligemma.language_model.forward(
inputs_embeds=inputs_embeds[0], inputs_embeds=inputs_embeds[0],
attention_mask=attention_mask, attention_mask=attention_mask,
position_ids=position_ids, position_ids=position_ids,
@@ -484,7 +468,7 @@ class PaliGemmaWithExpertModel(
prefix_output = None prefix_output = None
prefix_past_key_values = None prefix_past_key_values = None
else: else:
models = [self.paligemma.model.language_model, self.gemma_expert.model] models = [self.paligemma.language_model, self.gemma_expert.model]
num_layers = self.paligemma.config.text_config.num_hidden_layers num_layers = self.paligemma.config.text_config.num_hidden_layers
# Check if gradient checkpointing is enabled for any of the models # Check if gradient checkpointing is enabled for any of the models
@@ -524,7 +508,7 @@ class PaliGemmaWithExpertModel(
def compute_final_norms(inputs_embeds, adarms_cond): def compute_final_norms(inputs_embeds, adarms_cond):
outputs_embeds = [] outputs_embeds = []
for i, hidden_states in enumerate(inputs_embeds): for i, hidden_states in enumerate(inputs_embeds):
out_emb, _ = layernorm_forward(models[i].norm, hidden_states, adarms_cond[i]) out_emb, _ = models[i].norm(hidden_states, cond=adarms_cond[i])
outputs_embeds.append(out_emb) outputs_embeds.append(out_emb)
return outputs_embeds return outputs_embeds
@@ -589,19 +573,29 @@ class PI05Pytorch(nn.Module): # see openpi `PI0Pytorch`
# Also compile the main forward pass used during training # Also compile the main forward pass used during training
self.forward = torch.compile(self.forward, mode=config.compile_mode) 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"""
try:
from transformers.models.siglip import check
if not check.check_whether_transformers_replace_is_installed_correctly():
raise ValueError(msg)
except ImportError:
raise ValueError(msg) from None
def gradient_checkpointing_enable(self): def gradient_checkpointing_enable(self):
"""Enable gradient checkpointing for memory optimization.""" """Enable gradient checkpointing for memory optimization."""
self.gradient_checkpointing_enabled = True self.gradient_checkpointing_enabled = True
self.paligemma_with_expert.paligemma.model.language_model.gradient_checkpointing = True self.paligemma_with_expert.paligemma.language_model.gradient_checkpointing = True
self.paligemma_with_expert.paligemma.model.vision_tower.gradient_checkpointing = True self.paligemma_with_expert.paligemma.vision_tower.gradient_checkpointing = True
self.paligemma_with_expert.gemma_expert.model.gradient_checkpointing = True self.paligemma_with_expert.gemma_expert.model.gradient_checkpointing = True
logging.info("Enabled gradient checkpointing for PI05Pytorch model") logging.info("Enabled gradient checkpointing for PI05Pytorch model")
def gradient_checkpointing_disable(self): def gradient_checkpointing_disable(self):
"""Disable gradient checkpointing.""" """Disable gradient checkpointing."""
self.gradient_checkpointing_enabled = False self.gradient_checkpointing_enabled = False
self.paligemma_with_expert.paligemma.model.language_model.gradient_checkpointing = False self.paligemma_with_expert.paligemma.language_model.gradient_checkpointing = False
self.paligemma_with_expert.paligemma.model.vision_tower.gradient_checkpointing = False self.paligemma_with_expert.paligemma.vision_tower.gradient_checkpointing = False
self.paligemma_with_expert.gemma_expert.model.gradient_checkpointing = False self.paligemma_with_expert.gemma_expert.model.gradient_checkpointing = False
logging.info("Disabled gradient checkpointing for PI05Pytorch model") logging.info("Disabled gradient checkpointing for PI05Pytorch model")
@@ -743,7 +737,7 @@ class PI05Pytorch(nn.Module): # see openpi `PI0Pytorch`
suffix_embs, suffix_pad_masks, suffix_att_masks, adarms_cond = self.embed_suffix(x_t, time) suffix_embs, suffix_pad_masks, suffix_att_masks, adarms_cond = self.embed_suffix(x_t, time)
if ( if (
self.paligemma_with_expert.paligemma.model.language_model.layers[0].self_attn.q_proj.weight.dtype self.paligemma_with_expert.paligemma.language_model.layers[0].self_attn.q_proj.weight.dtype
== torch.bfloat16 == torch.bfloat16
): ):
suffix_embs = suffix_embs.to(dtype=torch.bfloat16) suffix_embs = suffix_embs.to(dtype=torch.bfloat16)
@@ -814,7 +808,7 @@ class PI05Pytorch(nn.Module): # see openpi `PI0Pytorch`
prefix_position_ids = torch.cumsum(prefix_pad_masks, dim=1) - 1 prefix_position_ids = torch.cumsum(prefix_pad_masks, dim=1) - 1
prefix_att_2d_masks_4d = self._prepare_attention_masks_4d(prefix_att_2d_masks) prefix_att_2d_masks_4d = self._prepare_attention_masks_4d(prefix_att_2d_masks)
self.paligemma_with_expert.paligemma.model.language_model.config._attn_implementation = "eager" # noqa: SLF001 self.paligemma_with_expert.paligemma.language_model.config._attn_implementation = "eager" # noqa: SLF001
_, past_key_values = self.paligemma_with_expert.forward( _, past_key_values = self.paligemma_with_expert.forward(
attention_mask=prefix_att_2d_masks_4d, attention_mask=prefix_att_2d_masks_4d,
@@ -886,7 +880,6 @@ class PI05Pytorch(nn.Module): # see openpi `PI0Pytorch`
full_att_2d_masks_4d = self._prepare_attention_masks_4d(full_att_2d_masks) full_att_2d_masks_4d = self._prepare_attention_masks_4d(full_att_2d_masks)
self.paligemma_with_expert.gemma_expert.model.config._attn_implementation = "eager" # noqa: SLF001 self.paligemma_with_expert.gemma_expert.model.config._attn_implementation = "eager" # noqa: SLF001
past_key_values = copy.deepcopy(past_key_values)
outputs_embeds, _ = self.paligemma_with_expert.forward( outputs_embeds, _ = self.paligemma_with_expert.forward(
attention_mask=full_att_2d_masks_4d, attention_mask=full_att_2d_masks_4d,
position_ids=position_ids, position_ids=position_ids,
@@ -976,12 +969,14 @@ class PI05Policy(PreTrainedPolicy):
# Check if dataset_stats were provided in kwargs # Check if dataset_stats were provided in kwargs
model = cls(config, **kwargs) model = cls(config, **kwargs)
# Load state dict (expects keys with "model." prefix) # Now manually load and remap the state dict
try: try:
# Try to load the pytorch_model.bin or model.safetensors file
print(f"Loading model from: {pretrained_name_or_path}") print(f"Loading model from: {pretrained_name_or_path}")
try: try:
from transformers.utils import cached_file from transformers.utils import cached_file
# Try safetensors first
resolved_file = cached_file( resolved_file = cached_file(
pretrained_name_or_path, pretrained_name_or_path,
"model.safetensors", "model.safetensors",
@@ -989,7 +984,7 @@ class PI05Policy(PreTrainedPolicy):
force_download=kwargs.get("force_download", False), force_download=kwargs.get("force_download", False),
resume_download=kwargs.get("resume_download"), resume_download=kwargs.get("resume_download"),
proxies=kwargs.get("proxies"), proxies=kwargs.get("proxies"),
token=kwargs.get("token"), use_auth_token=kwargs.get("use_auth_token"),
revision=kwargs.get("revision"), revision=kwargs.get("revision"),
local_files_only=kwargs.get("local_files_only", False), local_files_only=kwargs.get("local_files_only", False),
) )
@@ -1002,7 +997,7 @@ class PI05Policy(PreTrainedPolicy):
print("Returning model without loading pretrained weights") print("Returning model without loading pretrained weights")
return model return model
# First, fix any key differences (see openpi model.py, _fix_pytorch_state_dict_keys) # First, fix any key differences # see openpi `model.py, _fix_pytorch_state_dict_keys`
fixed_state_dict = model._fix_pytorch_state_dict_keys(original_state_dict, model.config) fixed_state_dict = model._fix_pytorch_state_dict_keys(original_state_dict, model.config)
# Then add "model." prefix for all keys that don't already have it # Then add "model." prefix for all keys that don't already have it
@@ -1014,6 +1009,8 @@ class PI05Policy(PreTrainedPolicy):
new_key = f"model.{key}" new_key = f"model.{key}"
remapped_state_dict[new_key] = value remapped_state_dict[new_key] = value
remap_count += 1 remap_count += 1
if remap_count <= 10: # Only print first 10 to avoid spam
print(f"Remapped: {key} -> {new_key}")
else: else:
remapped_state_dict[key] = value remapped_state_dict[key] = value
@@ -1047,7 +1044,7 @@ class PI05Policy(PreTrainedPolicy):
print("All keys loaded successfully!") print("All keys loaded successfully!")
except Exception as e: except Exception as e:
print(f"Warning: Could not load state dict: {e}") print(f"Warning: Could not remap state dict keys: {e}")
return model return model
@@ -1101,14 +1098,6 @@ class PI05Policy(PreTrainedPolicy):
# Some checkpoints might have this, but current model expects different structure # Some checkpoints might have this, but current model expects different structure
logging.warning(f"Vision embedding key might need handling: {key}") logging.warning(f"Vision embedding key might need handling: {key}")
if (
key == "model.paligemma_with_expert.paligemma.lm_head.weight"
or key == "paligemma_with_expert.paligemma.lm_head.weight"
):
fixed_state_dict[
"model.paligemma_with_expert.paligemma.model.language_model.embed_tokens.weight"
] = value.clone()
fixed_state_dict[new_key] = value fixed_state_dict[new_key] = value
return fixed_state_dict return fixed_state_dict
@@ -23,6 +23,7 @@ import torch
from lerobot.configs.types import PipelineFeatureType, PolicyFeature from lerobot.configs.types import PipelineFeatureType, PolicyFeature
from lerobot.policies.pi05.configuration_pi05 import PI05Config from lerobot.policies.pi05.configuration_pi05 import PI05Config
from lerobot.policies.pi05.modeling_pi05 import pad_vector
from lerobot.processor import ( from lerobot.processor import (
AddBatchDimensionProcessorStep, AddBatchDimensionProcessorStep,
DeviceProcessorStep, DeviceProcessorStep,
@@ -67,6 +68,9 @@ class Pi05PrepareStateTokenizerProcessorStep(ProcessorStep):
# TODO: check if this necessary # TODO: check if this necessary
state = deepcopy(state) state = deepcopy(state)
# Prepare state (pad to max_state_dim)
state = pad_vector(state, self.max_state_dim)
# State should already be normalized to [-1, 1] by the NormalizerProcessorStep that runs before this step # State should already be normalized to [-1, 1] by the NormalizerProcessorStep that runs before this step
# Discretize into 256 bins (see openpi `PaligemmaTokenizer.tokenize()`) # Discretize into 256 bins (see openpi `PaligemmaTokenizer.tokenize()`)
state_np = state.cpu().numpy() state_np = state.cpu().numpy()
@@ -54,7 +54,7 @@ class PI0FastConfig(PreTrainedConfig):
tokenizer_max_length: int = 200 # see openpi `__post_init__` tokenizer_max_length: int = 200 # see openpi `__post_init__`
text_tokenizer_name: str = "google/paligemma-3b-pt-224" text_tokenizer_name: str = "google/paligemma-3b-pt-224"
action_tokenizer_name: str = "lerobot/fast-action-tokenizer" action_tokenizer_name: str = "physical-intelligence/fast"
temperature: float = 0.0 temperature: float = 0.0
max_decoding_steps: int = 256 max_decoding_steps: int = 256
fast_skip_tokens: int = 128 fast_skip_tokens: int = 128
@@ -19,12 +19,13 @@ import logging
import math import math
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Literal, TypedDict, Unpack from typing import TYPE_CHECKING, Literal, TypedDict
import numpy as np import numpy as np
import torch import torch
import torch.nn.functional as F # noqa: N812 import torch.nn.functional as F # noqa: N812
from torch import Tensor, nn from torch import Tensor, nn
from typing_extensions import Unpack
from lerobot.utils.import_utils import _scipy_available, _transformers_available from lerobot.utils.import_utils import _scipy_available, _transformers_available
@@ -37,16 +38,11 @@ else:
if TYPE_CHECKING or _transformers_available: if TYPE_CHECKING or _transformers_available:
from transformers import AutoTokenizer from transformers import AutoTokenizer
from transformers.models.auto import CONFIG_MAPPING from transformers.models.auto import CONFIG_MAPPING
from transformers.models.paligemma.modeling_paligemma import PaliGemmaForConditionalGeneration
from lerobot.policies.pi_gemma import (
PaliGemmaForConditionalGenerationWithPiGemma,
PiGemmaModel,
)
else: else:
CONFIG_MAPPING = None CONFIG_MAPPING = None
PaliGemmaForConditionalGeneration = None
AutoTokenizer = None AutoTokenizer = None
PiGemmaModel = None
PaliGemmaForConditionalGenerationWithPiGemma = None
from lerobot.configs.policies import PreTrainedConfig from lerobot.configs.policies import PreTrainedConfig
from lerobot.policies.pi0_fast.configuration_pi0_fast import PI0FastConfig from lerobot.policies.pi0_fast.configuration_pi0_fast import PI0FastConfig
@@ -125,7 +121,7 @@ def resize_with_pad_torch( # see openpi `resize_with_pad_torch` (exact copy)
if images.dtype == torch.uint8: if images.dtype == torch.uint8:
resized_images = torch.round(resized_images).clamp(0, 255).to(torch.uint8) resized_images = torch.round(resized_images).clamp(0, 255).to(torch.uint8)
elif images.dtype == torch.float32: elif images.dtype == torch.float32:
resized_images = resized_images.clamp(0.0, 1.0) resized_images = resized_images.clamp(-1.0, 1.0)
else: else:
raise ValueError(f"Unsupported image dtype: {images.dtype}") raise ValueError(f"Unsupported image dtype: {images.dtype}")
@@ -136,7 +132,7 @@ def resize_with_pad_torch( # see openpi `resize_with_pad_torch` (exact copy)
pad_w1 = pad_w0 + remainder_w pad_w1 = pad_w0 + remainder_w
# Pad # Pad
constant_value = 0 if images.dtype == torch.uint8 else 0.0 constant_value = 0 if images.dtype == torch.uint8 else -1.0
padded_images = F.pad( padded_images = F.pad(
resized_images, resized_images,
(pad_w0, pad_w1, pad_h0, pad_h1), # left, right, top, bottom (pad_w0, pad_w1, pad_h0, pad_h1), # left, right, top, bottom
@@ -210,22 +206,16 @@ class PI0FastPaliGemma(nn.Module):
vlm_config_hf.text_config.num_hidden_layers = vlm_config.depth vlm_config_hf.text_config.num_hidden_layers = vlm_config.depth
vlm_config_hf.text_config.num_key_value_heads = vlm_config.num_kv_heads vlm_config_hf.text_config.num_key_value_heads = vlm_config.num_kv_heads
vlm_config_hf.text_config.hidden_activation = "gelu_pytorch_tanh" vlm_config_hf.text_config.hidden_activation = "gelu_pytorch_tanh"
vlm_config_hf.text_config.dtype = "float32" vlm_config_hf.text_config.torch_dtype = "float32"
vlm_config_hf.text_config.vocab_size = 257152 vlm_config_hf.text_config.vocab_size = 257152
vlm_config_hf.text_config.use_adarms = use_adarms[0] vlm_config_hf.text_config.use_adarms = use_adarms[0]
vlm_config_hf.text_config.adarms_cond_dim = vlm_config.width if use_adarms[0] else None vlm_config_hf.text_config.adarms_cond_dim = vlm_config.width if use_adarms[0] else None
vlm_config_hf.vision_config.intermediate_size = 4304 vlm_config_hf.vision_config.intermediate_size = 4304
vlm_config_hf.vision_config.projection_dim = 2048 vlm_config_hf.vision_config.projection_dim = 2048
vlm_config_hf.vision_config.projector_hidden_act = "gelu_fast" vlm_config_hf.vision_config.projector_hidden_act = "gelu_fast"
vlm_config_hf.vision_config.dtype = "float32" vlm_config_hf.vision_config.torch_dtype = "float32"
self.paligemma = PaliGemmaForConditionalGenerationWithPiGemma(config=vlm_config_hf) self.paligemma = PaliGemmaForConditionalGeneration(config=vlm_config_hf)
# Use PI Gemma (AdaRMS) as language model when use_adarms[0] is True so that
# forward(..., adarms_cond=...) is supported (same as pi0/pi05).
if use_adarms[0]:
text_config = self.paligemma.config.text_config
self.paligemma.model.language_model = PiGemmaModel(text_config)
self.to_bfloat16_for_selected_params(precision) self.to_bfloat16_for_selected_params(precision)
@@ -238,11 +228,10 @@ class PI0FastPaliGemma(nn.Module):
else: else:
raise ValueError(f"Invalid precision: {precision}") raise ValueError(f"Invalid precision: {precision}")
# Keep full vision path in float32 so we never toggle (toggle causes optimizer
# "same dtype" error). Align with PI05.
params_to_keep_float32 = [ params_to_keep_float32 = [
"vision_tower", "vision_tower.vision_model.embeddings.patch_embedding.weight",
"multi_modal_projector", "vision_tower.vision_model.embeddings.patch_embedding.bias",
"vision_tower.vision_model.embeddings.position_embedding.weight",
"input_layernorm", "input_layernorm",
"post_attention_layernorm", "post_attention_layernorm",
"model.norm", "model.norm",
@@ -253,18 +242,10 @@ class PI0FastPaliGemma(nn.Module):
param.data = param.data.to(dtype=torch.float32) param.data = param.data.to(dtype=torch.float32)
def embed_image(self, image: torch.Tensor): def embed_image(self, image: torch.Tensor):
# Vision tower and multi_modal_projector are kept in float32 (params_to_keep_float32). Align with PI05. return self.paligemma.model.get_image_features(image)
out_dtype = image.dtype
if image.dtype != torch.float32:
image = image.to(torch.float32)
image_outputs = self.paligemma.model.get_image_features(image)
features = image_outputs.pooler_output * self.paligemma.config.text_config.hidden_size**0.5
if features.dtype != out_dtype:
features = features.to(out_dtype)
return features
def embed_language_tokens(self, tokens: torch.Tensor): def embed_language_tokens(self, tokens: torch.Tensor):
return self.paligemma.model.language_model.embed_tokens(tokens) return self.paligemma.language_model.embed_tokens(tokens)
def forward( def forward(
self, self,
@@ -278,7 +259,7 @@ class PI0FastPaliGemma(nn.Module):
if adarms_cond is None: if adarms_cond is None:
adarms_cond = [None, None] adarms_cond = [None, None]
if inputs_embeds[1] is None: if inputs_embeds[1] is None:
prefix_output = self.paligemma.model.language_model.forward( prefix_output = self.paligemma.language_model.forward(
inputs_embeds=inputs_embeds[0], inputs_embeds=inputs_embeds[0],
attention_mask=attention_mask, attention_mask=attention_mask,
position_ids=position_ids, position_ids=position_ids,
@@ -325,14 +306,24 @@ class PI0FastPytorch(nn.Module): # see openpi `PI0Pytorch`
self.sample_actions_fast = torch.compile(self.sample_actions_fast, mode=config.compile_mode) self.sample_actions_fast = torch.compile(self.sample_actions_fast, mode=config.compile_mode)
self.forward = torch.compile(self.forward, mode=config.compile_mode) 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"""
try:
from transformers.models.siglip import check
if not check.check_whether_transformers_replace_is_installed_correctly():
raise ValueError(msg)
except ImportError:
raise ValueError(msg) from None
def gradient_checkpointing_enable(self): def gradient_checkpointing_enable(self):
"""Enable gradient checkpointing for memory optimization.""" """Enable gradient checkpointing for memory optimization."""
self.gradient_checkpointing_enabled = True self.gradient_checkpointing_enabled = True
# Call the proper gradient_checkpointing_enable() method with use_reentrant=False for better memory efficiency # Call the proper gradient_checkpointing_enable() method with use_reentrant=False for better memory efficiency
self.paligemma_with_expert.paligemma.model.language_model.gradient_checkpointing_enable( self.paligemma_with_expert.paligemma.language_model.gradient_checkpointing_enable(
gradient_checkpointing_kwargs={"use_reentrant": False} gradient_checkpointing_kwargs={"use_reentrant": False}
) )
self.paligemma_with_expert.paligemma.model.vision_tower.gradient_checkpointing_enable( self.paligemma_with_expert.paligemma.vision_tower.gradient_checkpointing_enable(
gradient_checkpointing_kwargs={"use_reentrant": False} gradient_checkpointing_kwargs={"use_reentrant": False}
) )
logging.info("Enabled gradient checkpointing for PI0FastPytorch model") logging.info("Enabled gradient checkpointing for PI0FastPytorch model")
@@ -341,8 +332,8 @@ class PI0FastPytorch(nn.Module): # see openpi `PI0Pytorch`
"""Disable gradient checkpointing.""" """Disable gradient checkpointing."""
self.gradient_checkpointing_enabled = False self.gradient_checkpointing_enabled = False
# Call the proper gradient_checkpointing_disable() method # Call the proper gradient_checkpointing_disable() method
self.paligemma_with_expert.paligemma.model.language_model.gradient_checkpointing_disable() self.paligemma_with_expert.paligemma.language_model.gradient_checkpointing_disable()
self.paligemma_with_expert.paligemma.model.vision_tower.gradient_checkpointing_disable() self.paligemma_with_expert.paligemma.vision_tower.gradient_checkpointing_disable()
logging.info("Disabled gradient checkpointing for PI0FastPytorch model") logging.info("Disabled gradient checkpointing for PI0FastPytorch model")
def _apply_checkpoint(self, func, *args, **kwargs): def _apply_checkpoint(self, func, *args, **kwargs):
@@ -532,7 +523,7 @@ class PI0FastPytorch(nn.Module): # see openpi `PI0Pytorch`
# Convert embeddings to bfloat16 if needed # Convert embeddings to bfloat16 if needed
if ( if (
self.paligemma_with_expert.paligemma.model.language_model.layers[0].self_attn.q_proj.weight.dtype self.paligemma_with_expert.paligemma.language_model.layers[0].self_attn.q_proj.weight.dtype
== torch.bfloat16 == torch.bfloat16
): ):
prefix_embs = prefix_embs.to(dtype=torch.bfloat16) prefix_embs = prefix_embs.to(dtype=torch.bfloat16)
@@ -625,7 +616,7 @@ class PI0FastPytorch(nn.Module): # see openpi `PI0Pytorch`
) )
if ( if (
self.paligemma_with_expert.paligemma.model.language_model.layers[0].self_attn.q_proj.weight.dtype self.paligemma_with_expert.paligemma.language_model.layers[0].self_attn.q_proj.weight.dtype
== torch.bfloat16 == torch.bfloat16
): ):
prefix_embs = prefix_embs.to(dtype=torch.bfloat16) prefix_embs = prefix_embs.to(dtype=torch.bfloat16)
@@ -723,7 +714,7 @@ class PI0FastPytorch(nn.Module): # see openpi `PI0Pytorch`
# Ensure correct precision (bfloat16/float32) # Ensure correct precision (bfloat16/float32)
if ( if (
self.paligemma_with_expert.paligemma.model.language_model.layers[0].self_attn.q_proj.weight.dtype self.paligemma_with_expert.paligemma.language_model.layers[0].self_attn.q_proj.weight.dtype
== torch.bfloat16 == torch.bfloat16
): ):
prefix_embs = prefix_embs.to(dtype=torch.bfloat16) prefix_embs = prefix_embs.to(dtype=torch.bfloat16)
@@ -906,12 +897,14 @@ class PI0FastPolicy(PreTrainedPolicy):
# Check if dataset_stats were provided in kwargs # Check if dataset_stats were provided in kwargs
model = cls(config, **kwargs) model = cls(config, **kwargs)
# Load state dict (expects keys with "model." prefix) # Now manually load and remap the state dict
try: try:
# Try to load the pytorch_model.bin or model.safetensors file
print(f"Loading model from: {pretrained_name_or_path}") print(f"Loading model from: {pretrained_name_or_path}")
try: try:
from transformers.utils import cached_file from transformers.utils import cached_file
# Try safetensors first
resolved_file = cached_file( resolved_file = cached_file(
pretrained_name_or_path, pretrained_name_or_path,
"model.safetensors", "model.safetensors",
@@ -919,7 +912,7 @@ class PI0FastPolicy(PreTrainedPolicy):
force_download=kwargs.get("force_download", False), force_download=kwargs.get("force_download", False),
resume_download=kwargs.get("resume_download"), resume_download=kwargs.get("resume_download"),
proxies=kwargs.get("proxies"), proxies=kwargs.get("proxies"),
token=kwargs.get("token"), use_auth_token=kwargs.get("use_auth_token"),
revision=kwargs.get("revision"), revision=kwargs.get("revision"),
local_files_only=kwargs.get("local_files_only", False), local_files_only=kwargs.get("local_files_only", False),
) )
@@ -932,9 +925,8 @@ class PI0FastPolicy(PreTrainedPolicy):
print("Returning model without loading pretrained weights") print("Returning model without loading pretrained weights")
return model return model
# First, fix any key differences (see openpi model.py, _fix_pytorch_state_dict_keys) # First, fix any key differences # see openpi `model.py, _fix_pytorch_state_dict_keys`
fixed_state_dict = model._fix_pytorch_state_dict_keys(original_state_dict, model.config) fixed_state_dict = model._fix_pytorch_state_dict_keys(original_state_dict, model.config)
# Then add "model." prefix for all keys that don't already have it # Then add "model." prefix for all keys that don't already have it
remapped_state_dict = {} remapped_state_dict = {}
remap_count = 0 remap_count = 0
@@ -944,6 +936,8 @@ class PI0FastPolicy(PreTrainedPolicy):
new_key = f"model.{key}" new_key = f"model.{key}"
remapped_state_dict[new_key] = value remapped_state_dict[new_key] = value
remap_count += 1 remap_count += 1
if remap_count <= 10: # Only print first 10 to avoid spam
print(f"Remapped: {key} -> {new_key}")
else: else:
remapped_state_dict[key] = value remapped_state_dict[key] = value
@@ -977,7 +971,7 @@ class PI0FastPolicy(PreTrainedPolicy):
print("All keys loaded successfully!") print("All keys loaded successfully!")
except Exception as e: except Exception as e:
print(f"Warning: Could not load state dict: {e}") print(f"Warning: Could not remap state dict keys: {e}")
return model return model
@@ -23,6 +23,7 @@ import torch
from lerobot.configs.types import PipelineFeatureType, PolicyFeature from lerobot.configs.types import PipelineFeatureType, PolicyFeature
from lerobot.policies.pi0_fast.configuration_pi0_fast import PI0FastConfig from lerobot.policies.pi0_fast.configuration_pi0_fast import PI0FastConfig
from lerobot.policies.pi0_fast.modeling_pi0_fast import pad_vector
from lerobot.processor import ( from lerobot.processor import (
ActionTokenizerProcessorStep, ActionTokenizerProcessorStep,
AddBatchDimensionProcessorStep, AddBatchDimensionProcessorStep,
@@ -68,6 +69,9 @@ class Pi0FastPrepareStateAndLanguageTokenizerProcessorStep(ProcessorStep):
# TODO: check if this necessary # TODO: check if this necessary
state = deepcopy(state) state = deepcopy(state)
# Prepare state (pad to max_state_dim)
state = pad_vector(state, self.max_state_dim)
# State should already be normalized to [-1, 1] by the NormalizerProcessorStep that runs before this step # State should already be normalized to [-1, 1] by the NormalizerProcessorStep that runs before this step
# Discretize into 256 bins (see openpi `PaligemmaTokenizer.tokenize()`) # Discretize into 256 bins (see openpi `PaligemmaTokenizer.tokenize()`)
state_np = state.cpu().numpy() state_np = state.cpu().numpy()
-363
View File
@@ -1,363 +0,0 @@
# Copyright 2025 Physical Intelligence and 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 __future__ import annotations
from typing import TYPE_CHECKING
import torch
from torch import nn
from lerobot.utils.import_utils import _transformers_available
if TYPE_CHECKING or _transformers_available:
from transformers.cache_utils import DynamicCache
from transformers.masking_utils import create_causal_mask
from transformers.modeling_layers import GradientCheckpointingLayer
from transformers.modeling_outputs import BaseModelOutputWithPast
from transformers.models.gemma.modeling_gemma import (
GemmaAttention,
GemmaConfig,
GemmaForCausalLM,
GemmaMLP,
GemmaModel,
)
from transformers.models.paligemma.modeling_paligemma import (
PaliGemmaForConditionalGeneration,
PaliGemmaModel,
)
else:
GemmaAttention = None
GemmaConfig = None
GemmaForCausalLM = None
GemmaMLP = None
GemmaModel = None
PaliGemmaModel = None
PaliGemmaForConditionalGeneration = None
DynamicCache = None
GradientCheckpointingLayer = None
BaseModelOutputWithPast = None
create_causal_mask = None
def _gated_residual(
x: torch.Tensor | None,
y: torch.Tensor | None,
gate: torch.Tensor | None,
) -> torch.Tensor | None:
"""Gated residual: x + y when gate is None, else x + y * gate."""
if x is None and y is None:
return None
if x is None or y is None:
return x if x is not None else y
if gate is None:
return x + y
return x + y * gate
def layernorm_forward(
layernorm: nn.Module,
x: torch.Tensor,
cond: torch.Tensor | None = None,
):
"""
call layernorm and return hidden states and gate
if cond is not None, use conditional norm
otherwise, use normal gemma norm
"""
if cond is not None:
return layernorm(x, cond=cond)
else:
return layernorm(x)
class PiGemmaRMSNorm(nn.Module):
"""
Adaptive RMSNorm for PI Gemma (AdaRMS).
When cond_dim is set, uses cond to modulate scale/shift/gate; otherwise behaves like standard GemmaRMSNorm.
forward(x, cond=None) returns (output, gate) for use with _gated_residual.
"""
def __init__(self, dim: int, eps: float = 1e-6, cond_dim: int | None = None):
super().__init__()
self.eps = eps
self.dim = dim
self.cond_dim = cond_dim
if cond_dim is not None:
self.dense = nn.Linear(cond_dim, dim * 3, bias=True)
nn.init.zeros_(self.dense.weight)
else:
self.weight = nn.Parameter(torch.zeros(dim))
self.dense = None
def _norm(self, x):
# Compute variance in float32 (like the source implementation)
var = torch.mean(torch.square(x.float()), dim=-1, keepdim=True)
# Compute normalization in float32
normed_inputs = x * torch.rsqrt(var + self.eps)
return normed_inputs
def forward(
self,
x: torch.Tensor,
cond: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor | None]:
dtype = x.dtype
normed = self._norm(x)
if cond is None or self.dense is None:
normed = normed * (1.0 + self.weight.float())
return normed.type_as(x), None
if cond.shape[-1] != self.cond_dim:
raise ValueError(f"Expected cond dim {self.cond_dim}, got {cond.shape[-1]}")
modulation = self.dense(cond)
if len(x.shape) == 3:
modulation = modulation.unsqueeze(1)
scale, shift, gate = modulation.chunk(3, dim=-1)
normed = normed * (1 + scale.float()) + shift.float()
return normed.to(dtype), gate.to(dtype)
def extra_repr(self) -> str:
if self.dense is not None:
return f"dim={self.dim}, eps={self.eps}, adaptive=True, cond_dim={self.cond_dim}"
return f"dim={self.dim}, eps={self.eps}"
def _get_pi_gemma_decoder_layer_base():
"""base for PiGemmaDecoderLayer"""
class _PiGemmaDecoderLayerBase(GradientCheckpointingLayer):
"""Decoder layer that uses PiGemmaRMSNorm and _gated_residual, compatible with v5 Gemma."""
def __init__(self, config: GemmaConfig, layer_idx: int):
super().__init__()
self.hidden_size = config.hidden_size
self.self_attn = GemmaAttention(config=config, layer_idx=layer_idx)
self.mlp = GemmaMLP(config)
cond_dim = (
getattr(config, "adarms_cond_dim", None) if getattr(config, "use_adarms", False) else None
)
self.input_layernorm = PiGemmaRMSNorm(
config.hidden_size, eps=config.rms_norm_eps, cond_dim=cond_dim
)
self.post_attention_layernorm = PiGemmaRMSNorm(
config.hidden_size, eps=config.rms_norm_eps, cond_dim=cond_dim
)
def forward(
self,
hidden_states: torch.Tensor,
attention_mask: torch.Tensor | None = None,
position_ids: torch.LongTensor | None = None,
past_key_values=None,
use_cache: bool = False,
cache_position: torch.LongTensor | None = None,
position_embeddings: tuple[torch.Tensor, torch.Tensor] | None = None,
adarms_cond: torch.Tensor | None = None,
**kwargs,
) -> torch.Tensor:
residual = hidden_states
hidden_states, gate = self.input_layernorm(hidden_states, cond=adarms_cond)
hidden_states, _ = self.self_attn(
hidden_states,
attention_mask=attention_mask,
position_ids=position_ids,
past_key_values=past_key_values,
use_cache=use_cache,
cache_position=cache_position,
position_embeddings=position_embeddings,
**kwargs,
)
hidden_states = _gated_residual(residual, hidden_states, gate)
residual = hidden_states
hidden_states, gate = self.post_attention_layernorm(hidden_states, cond=adarms_cond)
hidden_states = self.mlp(hidden_states)
hidden_states = _gated_residual(residual, hidden_states, gate)
return hidden_states
return _PiGemmaDecoderLayerBase
class PiGemmaModel(GemmaModel): # type: ignore[misc]
"""
GemmaModel extended with AdaRMS (adaptive RMSNorm) and gated residuals when config.use_adarms is True.
"""
def __init__(self, config: GemmaConfig, **kwargs):
super().__init__(config, **kwargs)
# if not getattr(config, "use_adarms", False):
# return
cond_dim = getattr(config, "adarms_cond_dim", None)
pi_gemma_decoder_layer_base = _get_pi_gemma_decoder_layer_base()
self.layers = nn.ModuleList(
[pi_gemma_decoder_layer_base(config, layer_idx) for layer_idx in range(config.num_hidden_layers)]
)
self.norm = PiGemmaRMSNorm(config.hidden_size, eps=config.rms_norm_eps, cond_dim=cond_dim)
def forward(
self,
input_ids: torch.LongTensor | None = None,
attention_mask: torch.Tensor | None = None,
position_ids: torch.LongTensor | None = None,
past_key_values: DynamicCache | None = None,
inputs_embeds: torch.FloatTensor | None = None,
use_cache: bool | None = None,
output_attentions: bool | None = None,
output_hidden_states: bool | None = None,
cache_position: torch.LongTensor | None = None,
adarms_cond: torch.Tensor | None = None,
**kwargs,
) -> BaseModelOutputWithPast:
"""
adarms_cond (`torch.Tensor` of shape `(batch_size, cond_dim)`, *optional*):
Condition for ADARMS.
"""
output_attentions = (
output_attentions if output_attentions is not None else self.config.output_attentions
)
output_hidden_states = (
output_hidden_states if output_hidden_states is not None else self.config.output_hidden_states
)
use_cache = use_cache if use_cache is not None else self.config.use_cache
if (input_ids is None) ^ (inputs_embeds is not None):
raise ValueError("You must specify exactly one of input_ids or inputs_embeds")
if self.gradient_checkpointing and self.training and use_cache:
import logging
logging.warning(
"`use_cache=True` is incompatible with gradient checkpointing. Setting `use_cache=False`."
)
use_cache = False
if inputs_embeds is None:
inputs_embeds = self.embed_tokens(input_ids)
if use_cache and past_key_values is None:
past_key_values = DynamicCache()
if cache_position is None:
past_seen_tokens = past_key_values.get_seq_length() if past_key_values is not None else 0
cache_position = torch.arange(
past_seen_tokens, past_seen_tokens + inputs_embeds.shape[1], device=inputs_embeds.device
)
if position_ids is None:
position_ids = cache_position.unsqueeze(0)
causal_mask = create_causal_mask(
config=self.config,
inputs_embeds=inputs_embeds,
attention_mask=attention_mask,
cache_position=cache_position,
past_key_values=past_key_values,
position_ids=position_ids,
)
# embed positions
hidden_states = inputs_embeds
# Convert to bfloat16 if the first layer uses bfloat16
if len(self.layers) > 0 and self.layers[0].self_attn.q_proj.weight.dtype == torch.bfloat16:
hidden_states = hidden_states.to(torch.bfloat16)
# create position embeddings to be shared across the decoder layers
position_embeddings = self.rotary_emb(hidden_states, position_ids)
# normalized
# Gemma downcasts the below to float16, causing sqrt(3072)=55.4256 to become 55.5
# See https://github.com/huggingface/transformers/pull/29402
# decoder layers
all_hidden_states = () if output_hidden_states else None
all_self_attns = () if output_attentions else None
for decoder_layer in self.layers[: self.config.num_hidden_layers]:
if output_hidden_states:
all_hidden_states += (hidden_states,)
layer_outputs = decoder_layer(
hidden_states,
attention_mask=causal_mask,
position_ids=position_ids,
past_key_values=past_key_values,
output_attentions=output_attentions,
use_cache=use_cache,
cache_position=cache_position,
position_embeddings=position_embeddings,
adarms_cond=adarms_cond,
**kwargs,
)
hidden_states = layer_outputs
if output_attentions:
all_self_attns += (layer_outputs[1],)
hidden_states, _ = self.norm(hidden_states, adarms_cond)
# add hidden states from the last decoder layer
if output_hidden_states:
all_hidden_states += (hidden_states,)
return BaseModelOutputWithPast(
last_hidden_state=hidden_states,
past_key_values=past_key_values if use_cache else None,
hidden_states=all_hidden_states,
attentions=all_self_attns,
)
class PiGemmaForCausalLM(GemmaForCausalLM): # type: ignore[misc]
"""
Causal LM wrapper using PiGemmaModel as the backbone, for consistency with GemmaForCausalLM
and the language model used in pi0_fast. Use this for the action expert in pi0/pi05.
"""
def __init__(self, config: GemmaConfig, **kwargs):
super().__init__(config, **kwargs)
self.model = PiGemmaModel(config)
class PaliGemmaModelWithPiGemma(PaliGemmaModel):
"""PaliGemmaModel whose language_model is PiGemmaModel (custom decoder with PiGemmaRMSNorm and gated residuals)."""
def __init__(self, config):
super().__init__(config)
self.language_model = PiGemmaModel(config.text_config)
class PaliGemmaForConditionalGenerationWithPiGemma(PaliGemmaForConditionalGeneration):
"""PaliGemmaForConditionalGeneration using PiGemma decoder for the language model."""
def __init__(self, config):
super().__init__(config)
self.model = PaliGemmaModelWithPiGemma(config)
# Make modules available through conditional class for BC
@property
def language_model(self):
return self.model.language_model
__all__ = [
"PiGemmaModel",
"PiGemmaForCausalLM",
"PiGemmaRMSNorm",
"_gated_residual",
"layernorm_forward",
"PaliGemmaModelWithPiGemma",
"PaliGemmaForConditionalGenerationWithPiGemma",
]
+2 -1
View File
@@ -19,7 +19,7 @@ import os
from importlib.resources import files from importlib.resources import files
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import TypedDict, TypeVar, Unpack from typing import TypedDict, TypeVar
import packaging import packaging
import safetensors import safetensors
@@ -28,6 +28,7 @@ from huggingface_hub.constants import SAFETENSORS_SINGLE_FILE
from huggingface_hub.errors import HfHubHTTPError from huggingface_hub.errors import HfHubHTTPError
from safetensors.torch import load_model as load_model_as_safetensor, save_model as save_model_as_safetensor from safetensors.torch import load_model as load_model_as_safetensor, save_model as save_model_as_safetensor
from torch import Tensor, nn from torch import Tensor, nn
from typing_extensions import Unpack
from lerobot.configs.policies import PreTrainedConfig from lerobot.configs.policies import PreTrainedConfig
from lerobot.configs.train import TrainPipelineConfig from lerobot.configs.train import TrainPipelineConfig
@@ -33,7 +33,7 @@ class RewardClassifierConfig(PreTrainedConfig):
latent_dim: int = 256 latent_dim: int = 256
image_embedding_pooling_dim: int = 8 image_embedding_pooling_dim: int = 8
dropout_rate: float = 0.1 dropout_rate: float = 0.1
model_name: str = "helper2424/resnet10" # TODO: This needs to be updated. The model on the Hub doesn't call self.post_init() in its __init__, which is required by transformers v5 to set all_tied_weights_keys. The from_pretrained call fails when it tries to access this attribute during _finalize_model_loading. model_name: str = "helper2424/resnet10"
device: str = "cpu" device: str = "cpu"
model_type: str = "cnn" # "transformer" or "cnn" model_type: str = "cnn" # "transformer" or "cnn"
num_cameras: int = 2 num_cameras: int = 2
@@ -106,9 +106,6 @@ class SmolVLAConfig(PreTrainedConfig):
# Real-Time Chunking (RTC) configuration # Real-Time Chunking (RTC) configuration
rtc_config: RTCConfig | None = None rtc_config: RTCConfig | None = None
compile_model: bool = False # Whether to use torch.compile for model optimization
compile_mode: str = "max-autotune" # Torch compile mode
def __post_init__(self): def __post_init__(self):
super().__post_init__() super().__post_init__()
@@ -54,11 +54,12 @@ policy = SmolVLAPolicy.from_pretrained("lerobot/smolvla_base")
import math import math
from collections import deque from collections import deque
from typing import TypedDict, Unpack from typing import TypedDict
import torch import torch
import torch.nn.functional as F # noqa: N812 import torch.nn.functional as F # noqa: N812
from torch import Tensor, nn from torch import Tensor, nn
from typing_extensions import Unpack
from lerobot.policies.pretrained import PreTrainedPolicy from lerobot.policies.pretrained import PreTrainedPolicy
from lerobot.policies.rtc.modeling_rtc import RTCProcessor from lerobot.policies.rtc.modeling_rtc import RTCProcessor
@@ -592,12 +593,6 @@ class VLAFlowMatching(nn.Module):
self.prefix_length = self.config.prefix_length self.prefix_length = self.config.prefix_length
self.rtc_processor = rtc_processor self.rtc_processor = rtc_processor
# Compile model if requested
if config.compile_model:
torch.set_float32_matmul_precision("high")
self.sample_actions = torch.compile(self.sample_actions, mode=config.compile_mode)
self.forward = torch.compile(self.forward, mode=config.compile_mode)
def _rtc_enabled(self): def _rtc_enabled(self):
return self.config.rtc_config is not None and self.config.rtc_config.enabled return self.config.rtc_config is not None and self.config.rtc_config.enabled
@@ -77,6 +77,7 @@ class SmolVLMWithExpertModel(nn.Module):
print(f"Loading {model_id} weights ...") print(f"Loading {model_id} weights ...")
self.vlm = AutoModelForImageTextToText.from_pretrained( self.vlm = AutoModelForImageTextToText.from_pretrained(
model_id, model_id,
device_map=device,
torch_dtype="bfloat16", torch_dtype="bfloat16",
low_cpu_mem_usage=True, low_cpu_mem_usage=True,
) )
@@ -55,7 +55,7 @@ class WallXConfig(PreTrainedConfig):
pretrained_name_or_path: str = "x-square-robot/wall-oss-flow" pretrained_name_or_path: str = "x-square-robot/wall-oss-flow"
# Tokenizer settings # Tokenizer settings
action_tokenizer_path: str | None = "lerobot/fast-action-tokenizer" action_tokenizer_path: str | None = "physical-intelligence/fast"
# Action prediction mode: "diffusion" or "fast" # Action prediction mode: "diffusion" or "fast"
prediction_mode: str = "diffusion" prediction_mode: str = "diffusion"
+2 -12
View File
@@ -261,15 +261,10 @@ class Qwen2_5_VLMoEForAction(Qwen2_5_VLForConditionalGeneration):
and optional LoRA fine-tuning support. and optional LoRA fine-tuning support.
""" """
_tied_weights_keys = {"lm_head.weight": "model.embed_tokens.weight"} _tied_weights_keys = ["lm_head.weight"]
config_class = Qwen2_5_VLConfig config_class = Qwen2_5_VLConfig
_no_split_modules = ["Qwen2_5_VLDecoderLayer_with_MoE", "Qwen2_5_VLVisionBlock"] _no_split_modules = ["Qwen2_5_VLDecoderLayer_with_MoE", "Qwen2_5_VLVisionBlock"]
def init_weights(self):
if getattr(self.model, "language_model", None) is not None:
return
super().init_weights()
@classmethod @classmethod
def from_pretrained( def from_pretrained(
cls, cls,
@@ -317,11 +312,6 @@ class Qwen2_5_VLMoEForAction(Qwen2_5_VLForConditionalGeneration):
processor.action_processor = action_tokenizer processor.action_processor = action_tokenizer
else: else:
action_tokenizer = None action_tokenizer = None
# add pad_token_id to config
config.pad_token_id = processor.tokenizer.pad_token_id
config.text_config.pad_token_id = processor.tokenizer.pad_token_id
# Initialize model with configuration and processor # Initialize model with configuration and processor
model = cls(config, processor=processor, action_tokenizer=action_tokenizer, **kwargs) model = cls(config, processor=processor, action_tokenizer=action_tokenizer, **kwargs)
@@ -341,7 +331,7 @@ class Qwen2_5_VLMoEForAction(Qwen2_5_VLForConditionalGeneration):
force_download=kwargs.get("force_download", False), force_download=kwargs.get("force_download", False),
resume_download=kwargs.get("resume_download"), resume_download=kwargs.get("resume_download"),
proxies=kwargs.get("proxies"), proxies=kwargs.get("proxies"),
token=kwargs.get("token"), use_auth_token=kwargs.get("use_auth_token"),
revision=kwargs.get("revision"), revision=kwargs.get("revision"),
local_files_only=kwargs.get("local_files_only", False), local_files_only=kwargs.get("local_files_only", False),
) )
@@ -21,7 +21,6 @@ class Qwen2_5_VLVisionConfig(PretrainedConfig):
window_size=112, window_size=112,
out_hidden_size=3584, out_hidden_size=3584,
fullatt_block_indexes=[7, 15, 23, 31], fullatt_block_indexes=[7, 15, 23, 31],
initializer_range=0.02,
**kwargs, **kwargs,
): ):
super().__init__(**kwargs) super().__init__(**kwargs)
@@ -39,7 +38,6 @@ class Qwen2_5_VLVisionConfig(PretrainedConfig):
self.window_size = window_size self.window_size = window_size
self.fullatt_block_indexes = fullatt_block_indexes self.fullatt_block_indexes = fullatt_block_indexes
self.out_hidden_size = out_hidden_size self.out_hidden_size = out_hidden_size
self.initializer_range = initializer_range
class Qwen2_5_VLConfig(PretrainedConfig): class Qwen2_5_VLConfig(PretrainedConfig):
@@ -11,6 +11,7 @@ from transformers.activations import ACT2FN
from transformers.cache_utils import ( from transformers.cache_utils import (
Cache, Cache,
DynamicCache, DynamicCache,
SlidingWindowCache,
StaticCache, StaticCache,
) )
from transformers.generation import GenerationMixin from transformers.generation import GenerationMixin
@@ -30,15 +31,6 @@ from transformers.utils import (
from .configuration_qwen2_5_vl import Qwen2_5_VLConfig, Qwen2_5_VLVisionConfig from .configuration_qwen2_5_vl import Qwen2_5_VLConfig, Qwen2_5_VLVisionConfig
# TODO(Steven): SlidingWindowCache was removed in transformers v5. Define a placeholder so isinstance checks
# always return False (which is the correct behavior when no sliding window cache is in use).
class _SlidingWindowCachePlaceholder:
pass
SlidingWindowCache = _SlidingWindowCachePlaceholder
if is_flash_attn_2_available(): if is_flash_attn_2_available():
from flash_attn import flash_attn_func, flash_attn_varlen_func from flash_attn import flash_attn_func, flash_attn_varlen_func
from flash_attn.layers.rotary import apply_rotary_emb from flash_attn.layers.rotary import apply_rotary_emb
@@ -602,40 +594,19 @@ class Qwen2_5_VisionTransformerPretrainedModel(Qwen2_5_VLPreTrainedModel):
return hidden_states return hidden_states
def _compute_default_rope_parameters_qwen2_5_vl(config, device=None):
"""
compute default rope parameters for Qwen2_5_VL
"""
base = config.text_config.rope_parameters["rope_theta"]
dim = config.hidden_size // config.num_attention_heads
inv_freq = 1.0 / (
base ** (torch.arange(0, dim, 2, dtype=torch.int64).to(device=device, dtype=torch.float) / dim)
)
return inv_freq, 1.0
class Qwen2_5_VLRotaryEmbedding(nn.Module): class Qwen2_5_VLRotaryEmbedding(nn.Module):
def __init__(self, config: Qwen2_5_VLConfig, device=None): def __init__(self, config: Qwen2_5_VLConfig, device=None):
super().__init__() super().__init__()
# BC: "rope_type" was originally "type" # BC: "rope_type" was originally "type"
if hasattr(config, "rope_scaling") and config.rope_scaling is not None: if hasattr(config, "rope_scaling") and config.rope_scaling is not None:
self.rope_type = config.rope_scaling.get("rope_type", config.rope_scaling.get("type")) self.rope_type = config.rope_scaling.get("rope_type", config.rope_scaling.get("type"))
elif hasattr(config, "rope_parameters") and config.rope_parameters is not None:
self.rope_type = config.rope_parameters.get("rope_type", "default")
else: else:
self.rope_type = "default" self.rope_type = "default"
self.max_seq_len_cached = config.max_position_embeddings self.max_seq_len_cached = config.max_position_embeddings
self.original_max_seq_len = config.max_position_embeddings self.original_max_seq_len = config.max_position_embeddings
self.config = config self.config = config
self.rope_init_fn = ROPE_INIT_FUNCTIONS[self.rope_type]
if self.rope_type == "default":
self.rope_init_fn = _compute_default_rope_parameters_qwen2_5_vl
self.rope_kwargs = {}
else:
rope_type_key = "linear" if self.rope_type == "linear" else self.rope_type
self.rope_init_fn = ROPE_INIT_FUNCTIONS[rope_type_key]
self.rope_kwargs = {}
inv_freq, self.attention_scaling = self.rope_init_fn(self.config, device) inv_freq, self.attention_scaling = self.rope_init_fn(self.config, device)
self.register_buffer("inv_freq", inv_freq, persistent=False) self.register_buffer("inv_freq", inv_freq, persistent=False)
@@ -1596,7 +1567,7 @@ QWEN2_5_VL_INPUTS_DOCSTRING = r"""
class Qwen2_5_VLForConditionalGeneration(Qwen2_5_VLPreTrainedModel, GenerationMixin): class Qwen2_5_VLForConditionalGeneration(Qwen2_5_VLPreTrainedModel, GenerationMixin):
_tied_weights_keys = {"lm_head.weight": "model.embed_tokens.weight"} _tied_weights_keys = ["lm_head.weight"]
config_class = Qwen2_5_VLConfig config_class = Qwen2_5_VLConfig
_no_split_modules = ["Qwen2_5_VLDecoderLayer", "Qwen2_5_VLVisionBlock"] _no_split_modules = ["Qwen2_5_VLDecoderLayer", "Qwen2_5_VLVisionBlock"]
+2 -2
View File
@@ -144,7 +144,7 @@ def preprocesser_call(
""" """
# Process image inputs # Process image inputs
if images is not None and len(images) > 0: if images is not None and len(images) > 0:
image_inputs = processor.image_processor(images=images, return_tensors=return_tensors) image_inputs = processor.image_processor(images=images, videos=None, return_tensors=return_tensors)
image_grid_thw = image_inputs["image_grid_thw"] image_grid_thw = image_inputs["image_grid_thw"]
else: else:
image_inputs = {} image_inputs = {}
@@ -152,7 +152,7 @@ def preprocesser_call(
# Process video inputs # Process video inputs
if videos is not None: if videos is not None:
videos_inputs = processor.image_processor(videos=videos, return_tensors=return_tensors) videos_inputs = processor.image_processor(images=None, videos=videos, return_tensors=return_tensors)
video_grid_thw = videos_inputs["video_grid_thw"] video_grid_thw = videos_inputs["video_grid_thw"]
else: else:
videos_inputs = {} videos_inputs = {}
@@ -276,8 +276,6 @@ class Florence2LanguageConfig(PretrainedConfig):
) )
# ensure backward compatibility for BART CNN models # ensure backward compatibility for BART CNN models
if not hasattr(self, "forced_bos_token_id"):
self.forced_bos_token_id = None
if self.forced_bos_token_id is None and kwargs.get("force_bos_token_to_be_generated", False): if self.forced_bos_token_id is None and kwargs.get("force_bos_token_to_be_generated", False):
self.forced_bos_token_id = self.bos_token_id self.forced_bos_token_id = self.bos_token_id
warnings.warn( warnings.warn(
@@ -1951,10 +1951,7 @@ class Florence2Decoder(Florence2LanguagePreTrainedModel):
class Florence2LanguageModel(Florence2LanguagePreTrainedModel): class Florence2LanguageModel(Florence2LanguagePreTrainedModel):
_tied_weights_keys = { _tied_weights_keys = ["encoder.embed_tokens.weight", "decoder.embed_tokens.weight"]
"encoder.embed_tokens.weight": "shared.weight",
"decoder.embed_tokens.weight": "shared.weight",
}
def __init__(self, config: Florence2LanguageConfig): def __init__(self, config: Florence2LanguageConfig):
super().__init__(config) super().__init__(config)
@@ -2079,10 +2076,7 @@ class Florence2LanguageModel(Florence2LanguagePreTrainedModel):
class Florence2LanguageForConditionalGeneration(Florence2LanguagePreTrainedModel, GenerationMixin): class Florence2LanguageForConditionalGeneration(Florence2LanguagePreTrainedModel, GenerationMixin):
base_model_prefix = "model" base_model_prefix = "model"
_tied_weights_keys = { _tied_weights_keys = ["encoder.embed_tokens.weight", "decoder.embed_tokens.weight", "lm_head.weight"]
"model.encoder.embed_tokens.weight": "model.shared.weight",
"model.decoder.embed_tokens.weight": "model.shared.weight",
}
_keys_to_ignore_on_load_missing = ["final_logits_bias"] _keys_to_ignore_on_load_missing = ["final_logits_bias"]
def __init__(self, config: Florence2LanguageConfig): def __init__(self, config: Florence2LanguageConfig):
@@ -2442,10 +2436,11 @@ FLORENCE2_INPUTS_DOCSTRING = r"""
FLORENCE2_START_DOCSTRING, FLORENCE2_START_DOCSTRING,
) )
class Florence2ForConditionalGeneration(Florence2PreTrainedModel): class Florence2ForConditionalGeneration(Florence2PreTrainedModel):
_tied_weights_keys = { _tied_weights_keys = [
"language_model.model.encoder.embed_tokens.weight": "language_model.model.shared.weight", "language_model.encoder.embed_tokens.weight",
"language_model.model.decoder.embed_tokens.weight": "language_model.model.shared.weight", "language_model.decoder.embed_tokens.weight",
} "language_model.lm_head.weight",
]
def __init__(self, config: Florence2Config): def __init__(self, config: Florence2Config):
super().__init__(config) super().__init__(config)
+5 -5
View File
@@ -17,7 +17,7 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum
from typing import Any, TypedDict from typing import Any, TypeAlias, TypedDict
import numpy as np import numpy as np
import torch import torch
@@ -36,10 +36,10 @@ class TransitionKey(str, Enum):
COMPLEMENTARY_DATA = "complementary_data" COMPLEMENTARY_DATA = "complementary_data"
PolicyAction = torch.Tensor PolicyAction: TypeAlias = torch.Tensor
RobotAction = dict[str, Any] RobotAction: TypeAlias = dict[str, Any]
EnvAction = np.ndarray EnvAction: TypeAlias = np.ndarray
RobotObservation = dict[str, Any] RobotObservation: TypeAlias = dict[str, Any]
EnvTransition = TypedDict( EnvTransition = TypedDict(
+4 -4
View File
@@ -39,7 +39,7 @@ from collections.abc import Callable, Iterable, Sequence
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, TypedDict, TypeVar, cast from typing import Any, Generic, TypeAlias, TypedDict, TypeVar, cast
import torch import torch
from huggingface_hub import hf_hub_download from huggingface_hub import hf_hub_download
@@ -251,7 +251,7 @@ class ProcessorMigrationError(Exception):
@dataclass @dataclass
class DataProcessorPipeline[TInput, TOutput](HubMixin): class DataProcessorPipeline(HubMixin, Generic[TInput, TOutput]):
"""A sequential pipeline for processing data, integrated with the Hugging Face Hub. """A sequential pipeline for processing data, integrated with the Hugging Face Hub.
This class chains together multiple `ProcessorStep` instances to form a complete This class chains together multiple `ProcessorStep` instances to form a complete
@@ -1432,8 +1432,8 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
# Type aliases for semantic clarity. # Type aliases for semantic clarity.
RobotProcessorPipeline = DataProcessorPipeline[TInput, TOutput] RobotProcessorPipeline: TypeAlias = DataProcessorPipeline[TInput, TOutput]
PolicyProcessorPipeline = DataProcessorPipeline[TInput, TOutput] PolicyProcessorPipeline: TypeAlias = DataProcessorPipeline[TInput, TOutput]
class ObservationProcessorStep(ProcessorStep, ABC): class ObservationProcessorStep(ProcessorStep, ABC):
+1 -1
View File
@@ -336,7 +336,7 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
Requires the `transformers` library to be installed. Requires the `transformers` library to be installed.
Attributes: Attributes:
tokenizer_name: The name of a pretrained processor from the Hugging Face Hub (e.g., "lerobot/fast-action-tokenizer"). tokenizer_name: The name of a pretrained processor from the Hugging Face Hub (e.g., "physical-intelligence/fast").
tokenizer: A pre-initialized processor/tokenizer object. If provided, `tokenizer_name` is ignored. tokenizer: A pre-initialized processor/tokenizer object. If provided, `tokenizer_name` is ignored.
trust_remote_code: Whether to trust remote code when loading the tokenizer (required for some tokenizers). trust_remote_code: Whether to trust remote code when loading the tokenizer (required for some tokenizers).
action_tokenizer: The internal tokenizer/processor instance, loaded during initialization. action_tokenizer: The internal tokenizer/processor instance, loaded during initialization.
@@ -15,6 +15,7 @@
# limitations under the License. # limitations under the License.
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TypeAlias
from lerobot.cameras import CameraConfig from lerobot.cameras import CameraConfig
@@ -49,5 +50,5 @@ class SOFollowerRobotConfig(RobotConfig, SOFollowerConfig):
pass pass
SO100FollowerConfig = SOFollowerRobotConfig SO100FollowerConfig: TypeAlias = SOFollowerRobotConfig
SO101FollowerConfig = SOFollowerRobotConfig SO101FollowerConfig: TypeAlias = SOFollowerRobotConfig
@@ -17,6 +17,7 @@
import logging import logging
import time import time
from functools import cached_property from functools import cached_property
from typing import TypeAlias
from lerobot.cameras.utils import make_cameras_from_configs from lerobot.cameras.utils import make_cameras_from_configs
from lerobot.motors import Motor, MotorCalibration, MotorNormMode from lerobot.motors import Motor, MotorCalibration, MotorNormMode
@@ -229,5 +230,5 @@ class SOFollower(Robot):
logger.info(f"{self} disconnected.") logger.info(f"{self} disconnected.")
SO100Follower = SOFollower SO100Follower: TypeAlias = SOFollower
SO101Follower = SOFollower SO101Follower: TypeAlias = SOFollower
@@ -16,5 +16,3 @@
from .config_unitree_g1 import UnitreeG1Config from .config_unitree_g1 import UnitreeG1Config
from .unitree_g1 import UnitreeG1 from .unitree_g1 import UnitreeG1
__all__ = ["UnitreeG1", "UnitreeG1Config"]
@@ -27,10 +27,11 @@ _GAINS: dict[str, dict[str, list[float]]] = {
}, # pitch, roll, yaw, knee, ankle_pitch, ankle_roll }, # pitch, roll, yaw, knee, ankle_pitch, ankle_roll
"right_leg": {"kp": [150, 150, 150, 300, 40, 40], "kd": [2, 2, 2, 4, 2, 2]}, "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 "waist": {"kp": [250, 250, 250], "kd": [5, 5, 5]}, # yaw, roll, pitch
"left_arm": {"kp": [50, 50, 80, 80], "kd": [3, 3, 3, 3]}, # shoulder_pitch/roll/yaw, elbow "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 "left_wrist": {"kp": [40, 40, 40], "kd": [1.5, 1.5, 1.5]}, # roll, pitch, yaw
"right_arm": {"kp": [50, 50, 80, 80], "kd": [3, 3, 3, 3]}, "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]}, "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]},
} }
@@ -67,7 +68,3 @@ class UnitreeG1Config(RobotConfig):
# Compensates for gravity on the unitree's arms using the arm ik solver # Compensates for gravity on the unitree's arms using the arm ik solver
gravity_compensation: bool = False gravity_compensation: bool = False
# Lower-body controller class name, e.g. "GrootLocomotionController" or
# "HolosomaLocomotionController". None disables it.
controller: str | None = None
+2 -39
View File
@@ -14,34 +14,12 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import importlib
from enum import IntEnum from enum import IntEnum
import numpy as np
# ruff: noqa: N801, N815 # ruff: noqa: N801, N815
NUM_MOTORS = 29 NUM_MOTORS = 29
REMOTE_AXES = ("remote.lx", "remote.ly", "remote.rx", "remote.ry")
REMOTE_BUTTONS = tuple(f"remote.button.{i}" for i in range(16))
REMOTE_KEYS = REMOTE_AXES + REMOTE_BUTTONS
def default_remote_input() -> dict[str, float]:
"""Return a zeroed-out remote input dict (axes + buttons)."""
return dict.fromkeys(REMOTE_KEYS, 0.0)
def get_gravity_orientation(quaternion: list[float] | np.ndarray) -> np.ndarray:
"""Get gravity orientation from quaternion [w, x, y, z]."""
qw, qx, qy, qz = quaternion
gravity_orientation = np.zeros(3, dtype=np.float32)
gravity_orientation[0] = 2 * (-qz * qx + qw * qy)
gravity_orientation[1] = -2 * (qz * qy + qw * qx)
gravity_orientation[2] = 1 - 2 * (qw * qw + qz * qz)
return gravity_orientation
class G1_29_JointArmIndex(IntEnum): class G1_29_JointArmIndex(IntEnum):
# Left arm # Left arm
@@ -51,7 +29,7 @@ class G1_29_JointArmIndex(IntEnum):
kLeftElbow = 18 kLeftElbow = 18
kLeftWristRoll = 19 kLeftWristRoll = 19
kLeftWristPitch = 20 kLeftWristPitch = 20
kLeftWristYaw = 21 kLeftWristyaw = 21
# Right arm # Right arm
kRightShoulderPitch = 22 kRightShoulderPitch = 22
@@ -63,21 +41,6 @@ class G1_29_JointArmIndex(IntEnum):
kRightWristYaw = 28 kRightWristYaw = 28
def make_locomotion_controller(name: str | None):
"""Instantiate a locomotion controller by class name. Returns None if name is None."""
if name is None:
return None
controllers = {
"GrootLocomotionController": "lerobot.robots.unitree_g1.gr00t_locomotion",
"HolosomaLocomotionController": "lerobot.robots.unitree_g1.holosoma_locomotion",
}
module_path = controllers.get(name)
if module_path is None:
raise ValueError(f"Unknown controller: {name!r}. Available: {list(controllers)}")
module = importlib.import_module(module_path)
return getattr(module, name)()
class G1_29_JointIndex(IntEnum): class G1_29_JointIndex(IntEnum):
# Left leg # Left leg
kLeftHipPitch = 0 kLeftHipPitch = 0
@@ -106,7 +69,7 @@ class G1_29_JointIndex(IntEnum):
kLeftElbow = 18 kLeftElbow = 18
kLeftWristRoll = 19 kLeftWristRoll = 19
kLeftWristPitch = 20 kLeftWristPitch = 20
kLeftWristYaw = 21 kLeftWristyaw = 21
# Right arm # Right arm
kRightShoulderPitch = 22 kRightShoulderPitch = 22
@@ -16,11 +16,13 @@
import logging import logging
import os import os
from collections import deque import sys
import numpy as np import numpy as np
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
parent2_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.append(parent2_dir)
class WeightedMovingFilter: class WeightedMovingFilter:
@@ -29,14 +31,18 @@ class WeightedMovingFilter:
self._weights = np.array(weights) self._weights = np.array(weights)
self._data_size = data_size self._data_size = data_size
self._filtered_data = np.zeros(self._data_size) self._filtered_data = np.zeros(self._data_size)
self._data_queue = deque(maxlen=self._window_size) self._data_queue = []
def _apply_filter(self): def _apply_filter(self):
if len(self._data_queue) < self._window_size: if len(self._data_queue) < self._window_size:
return self._data_queue[-1] return self._data_queue[-1]
data_array = np.array(self._data_queue) data_array = np.array(self._data_queue)
return data_array.T @ self._weights temp_filtered_data = np.zeros(self._data_size)
for i in range(self._data_size):
temp_filtered_data[i] = np.convolve(data_array[:, i], self._weights, mode="valid")[-1]
return temp_filtered_data
def add_data(self, new_data): def add_data(self, new_data):
assert len(new_data) == self._data_size assert len(new_data) == self._data_size
@@ -46,6 +52,9 @@ class WeightedMovingFilter:
): # skip duplicate data ): # skip duplicate data
return return
if len(self._data_queue) >= self._window_size:
self._data_queue.pop(0)
self._data_queue.append(new_data) self._data_queue.append(new_data)
self._filtered_data = self._apply_filter() self._filtered_data = self._apply_filter()
@@ -62,6 +71,8 @@ class G1_29_ArmIK: # noqa: N801
from pinocchio import casadi as cpin from pinocchio import casadi as cpin
self._pin = pin self._pin = pin
np.set_printoptions(precision=5, suppress=True, linewidth=200)
self.unit_test = unit_test self.unit_test = unit_test
self.repo_path = snapshot_download("lerobot/unitree-g1-mujoco") self.repo_path = snapshot_download("lerobot/unitree-g1-mujoco")
@@ -238,35 +249,50 @@ class G1_29_ArmIK: # noqa: N801
self.opti.set_value(self.param_tf_r, right_wrist) self.opti.set_value(self.param_tf_r, right_wrist)
self.opti.set_value(self.var_q_last, self.init_data) # for smooth self.opti.set_value(self.var_q_last, self.init_data) # for smooth
converged = True
try: try:
self.opti.solve() self.opti.solve()
sol_q = self.opti.value(self.var_q) sol_q = self.opti.value(self.var_q)
self.smooth_filter.add_data(sol_q)
sol_q = self.smooth_filter.filtered_data
if current_lr_arm_motor_dq is not None:
v = current_lr_arm_motor_dq * 0.0
else:
v = (sol_q - self.init_data) * 0.0
self.init_data = sol_q
sol_tauff = self._pin.rnea(
self.reduced_robot.model,
self.reduced_robot.data,
sol_q,
v,
np.zeros(self.reduced_robot.model.nv),
)
return sol_q, sol_tauff
except Exception as e: except Exception as e:
converged = False logger.error(f"ERROR in convergence, plotting debug info.{e}")
logger.error(f"IK convergence error: {e}")
sol_q = self.opti.debug.value(self.var_q) sol_q = self.opti.debug.value(self.var_q)
self.smooth_filter.add_data(sol_q)
sol_q = self.smooth_filter.filtered_data
self.smooth_filter.add_data(sol_q) if current_lr_arm_motor_dq is not None:
sol_q = self.smooth_filter.filtered_data v = current_lr_arm_motor_dq * 0.0
self.init_data = sol_q else:
v = (sol_q - self.init_data) * 0.0
self.init_data = sol_q
if not converged:
logger.error( logger.error(
f"sol_q:{sol_q} \nmotorstate: \n{current_lr_arm_motor_q} \nleft_pose: \n{left_wrist} \nright_pose: \n{right_wrist}" f"sol_q:{sol_q} \nmotorstate: \n{current_lr_arm_motor_q} \nleft_pose: \n{left_wrist} \nright_pose: \n{right_wrist}"
) )
return current_lr_arm_motor_q, np.zeros(self.reduced_robot.model.nv) return current_lr_arm_motor_q, np.zeros(self.reduced_robot.model.nv)
sol_tauff = self._pin.rnea(
self.reduced_robot.model,
self.reduced_robot.data,
sol_q,
np.zeros(self.reduced_robot.model.nv),
np.zeros(self.reduced_robot.model.nv),
)
return sol_q, sol_tauff
def solve_tau(self, current_lr_arm_motor_q=None, current_lr_arm_motor_dq=None): def solve_tau(self, current_lr_arm_motor_q=None, current_lr_arm_motor_dq=None):
try: try:
q_g1 = np.array(current_lr_arm_motor_q, dtype=float) q_g1 = np.array(current_lr_arm_motor_q, dtype=float)
@@ -24,7 +24,6 @@ This server runs on the robot and forwards:
Uses JSON for secure serialization instead of pickle. Uses JSON for secure serialization instead of pickle.
""" """
import argparse
import base64 import base64
import contextlib import contextlib
import json import json
@@ -39,8 +38,6 @@ from unitree_sdk2py.idl.default import unitree_hg_msg_dds__LowCmd_
from unitree_sdk2py.idl.unitree_hg.msg.dds_ import LowCmd_ as hg_LowCmd, LowState_ as hg_LowState from unitree_sdk2py.idl.unitree_hg.msg.dds_ import LowCmd_ as hg_LowCmd, LowState_ as hg_LowState
from unitree_sdk2py.utils.crc import CRC from unitree_sdk2py.utils.crc import CRC
from lerobot.cameras.zmq.image_server import ImageServer
# DDS topic names follow Unitree SDK naming conventions # DDS topic names follow Unitree SDK naming conventions
# ruff: noqa: N816 # ruff: noqa: N816
kTopicLowCommand_Debug = "rt/lowcmd" # action to robot kTopicLowCommand_Debug = "rt/lowcmd" # action to robot
@@ -153,32 +150,6 @@ def cmd_forward_loop(
def main() -> None: def main() -> None:
"""Main entry point for the robot server bridge.""" """Main entry point for the robot server bridge."""
parser = argparse.ArgumentParser(description="DDS-to-ZMQ bridge server for Unitree G1")
parser.add_argument("--camera", action="store_true", help="Also launch camera server")
parser.add_argument("--camera-device", type=int, default=4, help="Camera device ID (default: 4)")
parser.add_argument("--camera-fps", type=int, default=30, help="Camera FPS (default: 30)")
parser.add_argument("--camera-width", type=int, default=640, help="Camera width (default: 640)")
parser.add_argument("--camera-height", type=int, default=480, help="Camera height (default: 480)")
parser.add_argument("--camera-port", type=int, default=5555, help="Camera ZMQ port (default: 5555)")
args = parser.parse_args()
# Optionally start camera server in background thread
camera_thread = None
if args.camera:
camera_config = {
"fps": args.camera_fps,
"cameras": {
"head_camera": {
"device_id": args.camera_device,
"shape": [args.camera_height, args.camera_width],
}
},
}
camera_server = ImageServer(camera_config, port=args.camera_port)
camera_thread = threading.Thread(target=camera_server.run, daemon=True)
camera_thread.start()
print(f"Camera server started on port {args.camera_port} (device {args.camera_device})")
# initialize DDS # initialize DDS
ChannelFactoryInitialize(0) ChannelFactoryInitialize(0)
@@ -235,8 +206,6 @@ def main() -> None:
shutdown_event.set() shutdown_event.set()
ctx.term() # terminates blocking zmq.recv() calls ctx.term() # terminates blocking zmq.recv() calls
t_state.join(timeout=2.0) t_state.join(timeout=2.0)
if camera_thread is not None:
camera_thread.join(timeout=2.0)
if __name__ == "__main__": if __name__ == "__main__":
+132 -252
View File
@@ -14,67 +14,27 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from __future__ import annotations
import logging import logging
import struct
import threading import threading
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import cached_property from functools import cached_property
from typing import TYPE_CHECKING, Protocol, runtime_checkable from typing import Any
import numpy as np import numpy as np
from lerobot.cameras.utils import make_cameras_from_configs from lerobot.cameras.utils import make_cameras_from_configs
from lerobot.envs.factory import make_env from lerobot.envs.factory import make_env
from lerobot.processor import RobotAction, RobotObservation from lerobot.processor import RobotAction, RobotObservation
from lerobot.robots.unitree_g1.g1_kinematics import G1_29_ArmIK from lerobot.robots.unitree_g1.g1_utils import G1_29_JointArmIndex, G1_29_JointIndex
from lerobot.robots.unitree_g1.g1_utils import ( from lerobot.robots.unitree_g1.robot_kinematic_processor import G1_29_ArmIK
REMOTE_AXES,
REMOTE_KEYS,
G1_29_JointArmIndex,
G1_29_JointIndex,
default_remote_input,
make_locomotion_controller,
)
from lerobot.utils.import_utils import _unitree_sdk_available
from ..robot import Robot from ..robot import Robot
from .config_unitree_g1 import UnitreeG1Config from .config_unitree_g1 import UnitreeG1Config
if TYPE_CHECKING or _unitree_sdk_available:
from unitree_sdk2py.core.channel import (
ChannelFactoryInitialize as _SDKChannelFactoryInitialize,
ChannelPublisher as _SDKChannelPublisher,
ChannelSubscriber as _SDKChannelSubscriber,
)
from unitree_sdk2py.idl.default import unitree_hg_msg_dds__LowCmd_
from unitree_sdk2py.idl.unitree_hg.msg.dds_ import (
LowCmd_ as hg_LowCmd,
LowState_ as hg_LowState,
)
from unitree_sdk2py.utils.crc import CRC
else:
_SDKChannelFactoryInitialize = None
_SDKChannelPublisher = None
_SDKChannelSubscriber = None
unitree_hg_msg_dds__LowCmd_ = None
hg_LowCmd = None
hg_LowState = None
CRC = None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@runtime_checkable
class LocomotionController(Protocol):
control_dt: float
def run_step(self, action: dict, lowstate) -> dict: ...
def reset(self) -> None: ...
# DDS topic names follow Unitree SDK naming conventions # DDS topic names follow Unitree SDK naming conventions
# ruff: noqa: N816 # ruff: noqa: N816
kTopicLowCommand_Debug = "rt/lowcmd" kTopicLowCommand_Debug = "rt/lowcmd"
@@ -103,7 +63,7 @@ class IMUState:
class G1_29_LowState: # noqa: N801 class G1_29_LowState: # noqa: N801
motor_state: list[MotorState] = field(default_factory=lambda: [MotorState() for _ in G1_29_JointIndex]) motor_state: list[MotorState] = field(default_factory=lambda: [MotorState() for _ in G1_29_JointIndex])
imu_state: IMUState = field(default_factory=IMUState) imu_state: IMUState = field(default_factory=IMUState)
wireless_remote: bytes | None = None # Raw wireless remote data wireless_remote: Any = None # Raw wireless remote data
mode_machine: int = 0 # Robot mode mode_machine: int = 0 # Robot mode
@@ -111,6 +71,25 @@ class UnitreeG1(Robot):
config_class = UnitreeG1Config config_class = UnitreeG1Config
name = "unitree_g1" name = "unitree_g1"
# unitree remote controller
class RemoteController:
def __init__(self):
self.lx = 0
self.ly = 0
self.rx = 0
self.ry = 0
self.button = [0] * 16
def set(self, data):
# wireless_remote
keys = struct.unpack("H", data[2:4])[0]
for i in range(16):
self.button[i] = (keys & (1 << i)) >> i
self.lx = struct.unpack("f", data[4:8])[0]
self.rx = struct.unpack("f", data[8:12])[0]
self.ry = struct.unpack("f", data[12:16])[0]
self.ly = struct.unpack("f", data[20:24])[0]
def __init__(self, config: UnitreeG1Config): def __init__(self, config: UnitreeG1Config):
super().__init__(config) super().__init__(config)
@@ -124,9 +103,11 @@ class UnitreeG1(Robot):
# Import channel classes based on mode # Import channel classes based on mode
if config.is_simulation: if config.is_simulation:
self._ChannelFactoryInitialize = _SDKChannelFactoryInitialize from unitree_sdk2py.core.channel import (
self._ChannelPublisher = _SDKChannelPublisher ChannelFactoryInitialize,
self._ChannelSubscriber = _SDKChannelSubscriber ChannelPublisher,
ChannelSubscriber,
)
else: else:
from lerobot.robots.unitree_g1.unitree_sdk2_socket import ( from lerobot.robots.unitree_g1.unitree_sdk2_socket import (
ChannelFactoryInitialize, ChannelFactoryInitialize,
@@ -134,30 +115,22 @@ class UnitreeG1(Robot):
ChannelSubscriber, ChannelSubscriber,
) )
self._ChannelFactoryInitialize = ChannelFactoryInitialize # Store for use in connect()
self._ChannelPublisher = ChannelPublisher self._ChannelFactoryInitialize = ChannelFactoryInitialize
self._ChannelSubscriber = ChannelSubscriber self._ChannelPublisher = ChannelPublisher
self._ChannelSubscriber = ChannelSubscriber
# Initialize state variables # Initialize state variables
self.sim_env = None self.sim_env = None
self._env_wrapper = None self._env_wrapper = None
self._lowstate = None self._lowstate = None
self._lowstate_lock = threading.Lock()
self._shutdown_event = threading.Event() self._shutdown_event = threading.Event()
self.subscribe_thread = None self.subscribe_thread = None
self.remote_controller = self.RemoteController()
self.arm_ik = G1_29_ArmIK() if config.gravity_compensation else None self.arm_ik = G1_29_ArmIK()
# Lower-body controller loaded dynamically def _subscribe_motor_state(self): # polls robot state @ 250Hz
self.controller: LocomotionController | None = make_locomotion_controller(config.controller)
# Controller thread state
self._controller_thread = None
self._controller_action_lock = threading.Lock()
self.controller_input = default_remote_input()
self.controller_output = {}
def _subscribe_lowstate(self): # polls robot state @ 250Hz
while not self._shutdown_event.is_set(): while not self._shutdown_event.is_set():
start_time = time.time() start_time = time.time()
@@ -170,11 +143,11 @@ class UnitreeG1(Robot):
lowstate = G1_29_LowState() lowstate = G1_29_LowState()
# Capture motor states using jointindex # Capture motor states using jointindex
for joint in G1_29_JointIndex: for id in G1_29_JointIndex:
lowstate.motor_state[joint].q = msg.motor_state[joint].q lowstate.motor_state[id].q = msg.motor_state[id].q
lowstate.motor_state[joint].dq = msg.motor_state[joint].dq lowstate.motor_state[id].dq = msg.motor_state[id].dq
lowstate.motor_state[joint].tau_est = msg.motor_state[joint].tau_est lowstate.motor_state[id].tau_est = msg.motor_state[id].tau_est
lowstate.motor_state[joint].temperature = msg.motor_state[joint].temperature lowstate.motor_state[id].temperature = msg.motor_state[id].temperature
# Capture IMU state # Capture IMU state
lowstate.imu_state.quaternion = list(msg.imu_state.quaternion) lowstate.imu_state.quaternion = list(msg.imu_state.quaternion)
@@ -189,106 +162,31 @@ class UnitreeG1(Robot):
# Capture mode_machine # Capture mode_machine
lowstate.mode_machine = msg.mode_machine lowstate.mode_machine = msg.mode_machine
with self._lowstate_lock: self._lowstate = lowstate
self._lowstate = lowstate
current_time = time.time() current_time = time.time()
all_t_elapsed = current_time - start_time all_t_elapsed = current_time - start_time
sleep_time = max(0, (self.control_dt - all_t_elapsed)) # maintain constant control dt sleep_time = max(0, (self.control_dt - all_t_elapsed)) # maintain constant control dt
time.sleep(sleep_time) time.sleep(sleep_time)
def publish_lowcmd(
self,
action: RobotAction,
kp: np.ndarray | list[float] | None = None,
kd: np.ndarray | list[float] | None = None,
tau: np.ndarray | list[float] | None = None,
) -> None: # writes robot command whenever requested
for motor in G1_29_JointIndex:
key = f"{motor.name}.q"
if key in action:
self.msg.motor_cmd[motor.value].q = action[key]
self.msg.motor_cmd[motor.value].qd = 0
self.msg.motor_cmd[motor.value].kp = (
kp[motor.value] if kp is not None else self.kp[motor.value]
)
self.msg.motor_cmd[motor.value].kd = (
kd[motor.value] if kd is not None else self.kd[motor.value]
)
self.msg.motor_cmd[motor.value].tau = tau[motor.value] if tau is not None else 0.0
self.msg.crc = self.crc.Crc(self.msg)
self.lowcmd_publisher.Write(self.msg)
@property
def _cameras_ft(self) -> dict[str, tuple]:
return {
cam: (self.config.cameras[cam].height, self.config.cameras[cam].width, 3) for cam in self.cameras
}
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
return {**self._motors_ft, **self._cameras_ft}
@cached_property @cached_property
def action_features(self) -> dict[str, type]: def action_features(self) -> dict[str, type]:
if self.controller is None: return {f"{G1_29_JointIndex(motor).name}.q": float for motor in G1_29_JointIndex}
return {f"{G1_29_JointIndex(motor).name}.q": float for motor in G1_29_JointIndex}
arm_features = {f"{G1_29_JointArmIndex(motor).name}.q": float for motor in G1_29_JointArmIndex} def calibrate(self) -> None: # robot is already calibrated
remote_features = dict.fromkeys(REMOTE_AXES, float)
return {**arm_features, **remote_features}
def _controller_loop(self):
"""Background thread that runs controller at policy's control_dt."""
control_dt = self.controller.control_dt
logger.info(f"Controller loop starting with control_dt={control_dt} ({1.0 / control_dt:.1f}Hz)")
loop_count = 0
last_log_time = time.time()
while not self._shutdown_event.is_set():
start_time = time.time()
with self._lowstate_lock:
lowstate = self._lowstate
if lowstate is not None and self.controller is not None:
loop_count += 1
if time.time() - last_log_time >= 5.0: # Log every 5 seconds
actual_hz = loop_count / (time.time() - last_log_time)
logger.info(
f"Controller actual rate: {actual_hz:.1f}Hz (target: {1.0 / control_dt:.1f}Hz)"
)
loop_count = 0
last_log_time = time.time()
# Read controller input snapshot
with self._controller_action_lock:
controller_input = dict(self.controller_input)
# Run controller step
controller_action = self.controller.run_step(controller_input, lowstate)
# Write controller output snapshot
with self._controller_action_lock:
self.controller_output = dict(controller_action)
ctrl_kp = self.controller.kp if hasattr(self.controller, "kp") else None
ctrl_kd = self.controller.kd if hasattr(self.controller, "kd") else None
self.publish_lowcmd(controller_action, kp=ctrl_kp, kd=ctrl_kd)
elapsed = time.time() - start_time
sleep_time = max(0, control_dt - elapsed)
time.sleep(sleep_time)
def calibrate(self) -> None:
# TODO: implement g1_29 calibration
pass pass
def configure(self) -> None: def configure(self) -> None:
pass pass
def connect(self, calibrate: bool = True) -> None: # connect to DDS def connect(self, calibrate: bool = True) -> None: # connect to DDS
from unitree_sdk2py.idl.default import unitree_hg_msg_dds__LowCmd_
from unitree_sdk2py.idl.unitree_hg.msg.dds_ import (
LowCmd_ as hg_LowCmd,
LowState_ as hg_LowState,
)
from unitree_sdk2py.utils.crc import CRC
# Initialize DDS channel and simulation environment # Initialize DDS channel and simulation environment
if self.config.is_simulation: if self.config.is_simulation:
self._ChannelFactoryInitialize(0, "lo") self._ChannelFactoryInitialize(0, "lo")
@@ -296,7 +194,7 @@ class UnitreeG1(Robot):
# 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]
else: else:
self._ChannelFactoryInitialize(0, config=self.config) self._ChannelFactoryInitialize(0)
# Initialize direct motor control interface # Initialize direct motor control interface
self.lowcmd_publisher = self._ChannelPublisher(kTopicLowCommand_Debug, hg_LowCmd) self.lowcmd_publisher = self._ChannelPublisher(kTopicLowCommand_Debug, hg_LowCmd)
@@ -305,7 +203,7 @@ class UnitreeG1(Robot):
self.lowstate_subscriber.Init() self.lowstate_subscriber.Init()
# Start subscribe thread to read robot state # Start subscribe thread to read robot state
self.subscribe_thread = threading.Thread(target=self._subscribe_lowstate) self.subscribe_thread = threading.Thread(target=self._subscribe_motor_state)
self.subscribe_thread.start() self.subscribe_thread.start()
# Connect cameras # Connect cameras
@@ -322,53 +220,25 @@ class UnitreeG1(Robot):
# Wait for first state message to arrive # Wait for first state message to arrive
lowstate = None lowstate = None
deadline = time.time() + 10.0
while lowstate is None: while lowstate is None:
with self._lowstate_lock: lowstate = self._lowstate
lowstate = self._lowstate
if lowstate is None: if lowstate is None:
if time.time() > deadline:
raise TimeoutError("Timed out waiting for robot state (10s)")
logger.warning("[UnitreeG1] Waiting for robot state...")
time.sleep(0.01) time.sleep(0.01)
logger.info("[UnitreeG1] Connected to robot.") logger.warning("[UnitreeG1] Waiting for robot state...")
logger.warning("[UnitreeG1] Connected to robot.")
self.msg.mode_machine = lowstate.mode_machine self.msg.mode_machine = lowstate.mode_machine
# Initialize all motors with unified kp/kd from config
self.kp = np.array(self.config.kp, dtype=np.float32) self.kp = np.array(self.config.kp, dtype=np.float32)
self.kd = np.array(self.config.kd, dtype=np.float32) self.kd = np.array(self.config.kd, dtype=np.float32)
for joint in G1_29_JointIndex: for id in G1_29_JointIndex:
self.msg.motor_cmd[joint].mode = 1 self.msg.motor_cmd[id].mode = 1
self.msg.motor_cmd[joint].kp = self.kp[joint.value] self.msg.motor_cmd[id].kp = self.kp[id.value]
self.msg.motor_cmd[joint].kd = self.kd[joint.value] self.msg.motor_cmd[id].kd = self.kd[id.value]
self.msg.motor_cmd[joint].q = lowstate.motor_state[joint.value].q self.msg.motor_cmd[id].q = lowstate.motor_state[id.value].q
# Start controller thread if enabled
if self.controller is not None:
self._controller_thread = threading.Thread(target=self._controller_loop, daemon=True)
self._controller_thread.start()
fps = int(1.0 / self.controller.control_dt)
logger.info(f"Controller thread started ({fps}Hz)")
def _send_zero_torque(self) -> None:
"""Send a zero-gain command to make joints passive before shutting down."""
try:
with self._lowstate_lock:
lowstate = self._lowstate
if lowstate is None:
return
action = {f"{motor.name}.q": lowstate.motor_state[motor.value].q for motor in G1_29_JointIndex}
zero_gains = np.zeros(29, dtype=np.float32)
self.publish_lowcmd(action, kp=zero_gains, kd=zero_gains, tau=zero_gains)
logger.info("Sent zero-torque command for safe shutdown")
except Exception as e:
logger.warning(f"Failed to send zero-torque on disconnect: {e}")
def disconnect(self): def disconnect(self):
# Put robot in passive mode before stopping threads
if not self.config.is_simulation:
self._send_zero_torque()
# Signal thread to stop and unblock any waits # Signal thread to stop and unblock any waits
self._shutdown_event.set() self._shutdown_event.set()
@@ -378,12 +248,6 @@ class UnitreeG1(Robot):
if self.subscribe_thread.is_alive(): if self.subscribe_thread.is_alive():
logger.warning("Subscribe thread did not stop cleanly") logger.warning("Subscribe thread did not stop cleanly")
# Wait for controller thread to finish
if self._controller_thread is not None:
self._controller_thread.join(timeout=2.0)
if self._controller_thread.is_alive():
logger.warning("Controller thread did not stop cleanly")
# Close simulation environment # Close simulation environment
if self.config.is_simulation and self.sim_env is not None: if self.config.is_simulation and self.sim_env is not None:
try: try:
@@ -410,8 +274,7 @@ class UnitreeG1(Robot):
cam.disconnect() cam.disconnect()
def get_observation(self) -> RobotObservation: def get_observation(self) -> RobotObservation:
with self._lowstate_lock: lowstate = self._lowstate
lowstate = self._lowstate
if lowstate is None: if lowstate is None:
return {} return {}
@@ -450,9 +313,14 @@ class UnitreeG1(Robot):
obs["imu.rpy.pitch"] = lowstate.imu_state.rpy[1] obs["imu.rpy.pitch"] = lowstate.imu_state.rpy[1]
obs["imu.rpy.yaw"] = lowstate.imu_state.rpy[2] obs["imu.rpy.yaw"] = lowstate.imu_state.rpy[2]
# Wireless remote (raw bytes for teleoperator) # Controller - parse wireless_remote and add to obs
if lowstate.wireless_remote: if lowstate.wireless_remote and len(lowstate.wireless_remote) >= 24:
obs["wireless_remote"] = lowstate.wireless_remote self.remote_controller.set(lowstate.wireless_remote)
obs["remote.buttons"] = self.remote_controller.button.copy()
obs["remote.lx"] = self.remote_controller.lx
obs["remote.ly"] = self.remote_controller.ly
obs["remote.rx"] = self.remote_controller.rx
obs["remote.ry"] = self.remote_controller.ry
# Cameras - read images from ZMQ cameras # Cameras - read images from ZMQ cameras
for cam_name, cam in self._cameras.items(): for cam_name, cam in self._cameras.items():
@@ -460,63 +328,73 @@ class UnitreeG1(Robot):
return obs return obs
def send_action(self, action: RobotAction) -> RobotAction:
action_to_publish = action
if self.controller is not None:
# Controller thread owns legs/waist. Here we only update joystick inputs
# and publish arm targets from the teleoperator.
self._update_controller_action(action)
arm_prefixes = tuple(j.name for j in G1_29_JointArmIndex)
action_to_publish = {
key: value
for key, value in action.items()
if key.endswith(".q") and key.startswith(arm_prefixes)
}
tau = None
if self.config.gravity_compensation and self.arm_ik is not None:
tau = np.zeros(29, dtype=np.float32)
action_np = np.array(
[
action_to_publish.get(f"{joint.name}.q", self.msg.motor_cmd[joint.value].q)
for joint in G1_29_JointArmIndex
],
dtype=np.float32,
)
arm_tau = self.arm_ik.solve_tau(action_np)
arm_start_idx = G1_29_JointArmIndex.kLeftShoulderPitch.value
for joint in G1_29_JointArmIndex:
local_idx = joint.value - arm_start_idx
tau[joint.value] = arm_tau[local_idx]
self.publish_lowcmd(action_to_publish, tau=tau)
return action
def _update_controller_action(self, action: RobotAction) -> None:
"""Update controller input state from incoming teleop action."""
with self._controller_action_lock:
for key in REMOTE_KEYS:
if key in action:
self.controller_input[key] = action[key]
@property @property
def is_calibrated(self) -> bool: def is_calibrated(self) -> bool:
return True return True
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
with self._lowstate_lock: return self._lowstate is not None
return self._lowstate is not None
@property @property
def _motors_ft(self) -> dict[str, type]: def _motors_ft(self) -> dict[str, type]:
"""Joint positions for all 29 joints."""
return {f"{G1_29_JointIndex(motor).name}.q": float for motor in G1_29_JointIndex} return {f"{G1_29_JointIndex(motor).name}.q": float for motor in G1_29_JointIndex}
@property @property
def cameras(self) -> dict: def cameras(self) -> dict:
return self._cameras return self._cameras
@property
def _cameras_ft(self) -> dict[str, tuple]:
return {
cam: (self.config.cameras[cam].height, self.config.cameras[cam].width, 3) for cam in self.cameras
}
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
return {**self._motors_ft, **self._cameras_ft}
def send_action(self, action: RobotAction) -> RobotAction:
for motor in G1_29_JointIndex:
key = f"{motor.name}.q"
if key in action:
self.msg.motor_cmd[motor.value].q = action[key]
self.msg.motor_cmd[motor.value].qd = 0
self.msg.motor_cmd[motor.value].kp = self.kp[motor.value]
self.msg.motor_cmd[motor.value].kd = self.kd[motor.value]
self.msg.motor_cmd[motor.value].tau = 0
if self.config.gravity_compensation:
# Build action_np from motor commands (arm joints are indices 15-28, local indices 0-13)
action_np = np.zeros(14)
arm_start_idx = G1_29_JointArmIndex.kLeftShoulderPitch.value # 15
for joint in G1_29_JointArmIndex:
local_idx = joint.value - arm_start_idx
action_np[local_idx] = self.msg.motor_cmd[joint.value].q
tau = self.arm_ik.solve_tau(action_np)
# Apply tau back to motor commands
for joint in G1_29_JointArmIndex:
local_idx = joint.value - arm_start_idx
self.msg.motor_cmd[joint.value].tau = tau[local_idx]
self.msg.crc = self.crc.Crc(self.msg)
self.lowcmd_publisher.Write(self.msg)
return action
def get_gravity_orientation(self, quaternion): # get gravity orientation from quaternion
"""Get gravity orientation from quaternion."""
qw = quaternion[0]
qx = quaternion[1]
qy = quaternion[2]
qz = quaternion[3]
gravity_orientation = np.zeros(3)
gravity_orientation[0] = 2 * (-qz * qx + qw * qy)
gravity_orientation[1] = -2 * (qz * qy + qw * qx)
gravity_orientation[2] = 1 - 2 * (qw * qw + qz * qz)
return gravity_orientation
def reset( def reset(
self, self,
control_dt: float | None = None, control_dt: float | None = None,
@@ -529,9 +407,15 @@ class UnitreeG1(Robot):
if self.config.is_simulation and self.sim_env is not None: if self.config.is_simulation and self.sim_env is not None:
self.sim_env.reset() self.sim_env.reset()
self.publish_lowcmd(
{f"{motor.name}.q": float(default_positions[motor.value]) for motor in G1_29_JointIndex} for motor in G1_29_JointIndex:
) self.msg.motor_cmd[motor.value].q = default_positions[motor.value]
self.msg.motor_cmd[motor.value].qd = 0
self.msg.motor_cmd[motor.value].kp = self.kp[motor.value]
self.msg.motor_cmd[motor.value].kd = self.kd[motor.value]
self.msg.motor_cmd[motor.value].tau = 0
self.msg.crc = self.crc.Crc(self.msg)
self.lowcmd_publisher.Write(self.msg)
else: else:
total_time = 3.0 total_time = 3.0
num_steps = int(total_time / control_dt) num_steps = int(total_time / control_dt)
@@ -562,8 +446,4 @@ class UnitreeG1(Robot):
sleep_time = max(0, control_dt - elapsed) sleep_time = max(0, control_dt - elapsed)
time.sleep(sleep_time) time.sleep(sleep_time)
# Reset controller internal state (gait phase, obs history, etc.)
if self.controller is not None and hasattr(self.controller, "reset"):
self.controller.reset()
logger.info("Reached default position") logger.info("Reached default position")
@@ -22,8 +22,6 @@ import zmq
from lerobot.robots.unitree_g1.config_unitree_g1 import UnitreeG1Config from lerobot.robots.unitree_g1.config_unitree_g1 import UnitreeG1Config
# Module-level ZMQ state mirrors the Unitree SDK's global ChannelFactory Singleton.
# Only one robot connection per process is supported.
_ctx: zmq.Context | None = None _ctx: zmq.Context | None = None
_lowcmd_sock: zmq.Socket | None = None _lowcmd_sock: zmq.Socket | None = None
_lowstate_sock: zmq.Socket | None = None _lowstate_sock: zmq.Socket | None = None
@@ -99,22 +97,17 @@ def lowcmd_to_dict(topic: str, msg: Any) -> dict[str, Any]:
} }
def ChannelFactoryInitialize(domain_id: int = 0, config: Any = None) -> None: # noqa: N802 def ChannelFactoryInitialize(*args: Any, **kwargs: Any) -> None: # noqa: N802
""" """
Initialize ZMQ sockets for robot communication. Initialize ZMQ sockets for robot communication.
This function mimics the Unitree SDK's ChannelFactoryInitialize but uses This function mimics the Unitree SDK's ChannelFactoryInitialize but uses
ZMQ sockets to connect to the robot server bridge instead of DDS. ZMQ sockets to connect to the robot server bridge instead of DDS.
Args:
domain_id: Ignored (for API compatibility with Unitree SDK)
config: UnitreeG1Config instance with robot_ip
""" """
global _ctx, _lowcmd_sock, _lowstate_sock global _ctx, _lowcmd_sock, _lowstate_sock
# read socket config # read socket config
if config is None: config = UnitreeG1Config()
config = UnitreeG1Config()
robot_ip = config.robot_ip robot_ip = config.robot_ip
ctx = zmq.Context.instance() ctx = zmq.Context.instance()
-1
View File
@@ -56,7 +56,6 @@ from lerobot.teleoperators import ( # noqa: F401
make_teleoperator_from_config, make_teleoperator_from_config,
omx_leader, omx_leader,
openarm_leader, openarm_leader,
openarm_mini,
so_leader, so_leader,
unitree_g1, unitree_g1,
) )
+1 -4
View File
@@ -132,13 +132,10 @@ def visualize_dataset(
logging.info("Logging to Rerun") logging.info("Logging to Rerun")
first_index = None
for batch in tqdm.tqdm(dataloader, total=len(dataloader)): for batch in tqdm.tqdm(dataloader, total=len(dataloader)):
if first_index is None:
first_index = batch["index"][0].item()
# iterate over the batch # iterate over the batch
for i in range(len(batch["index"])): for i in range(len(batch["index"])):
rr.set_time("frame_index", sequence=batch["index"][i].item() - first_index) rr.set_time("frame_index", sequence=batch["frame_index"][i].item())
rr.set_time("timestamp", timestamp=batch["timestamp"][i].item()) rr.set_time("timestamp", timestamp=batch["timestamp"][i].item())
# display each camera image # display each camera image
+192 -140
View File
@@ -21,9 +21,6 @@ This script allows you to delete episodes, split datasets, merge datasets,
remove features, modify tasks, and convert image datasets to video format. remove features, modify tasks, and convert image datasets to video format.
When new_repo_id is specified, creates a new dataset. When new_repo_id is specified, creates a new dataset.
Path semantics (v2): --root and --new_root are exact dataset folders containing
meta/, data/, videos/. When omitted, defaults to $HF_LEROBOT_HOME/{repo_id}.
Usage Examples: Usage Examples:
Delete episodes 0, 2, and 5 from a dataset: Delete episodes 0, 2, and 5 from a dataset:
@@ -32,34 +29,19 @@ Delete episodes 0, 2, and 5 from a dataset:
--operation.type delete_episodes \ --operation.type delete_episodes \
--operation.episode_indices "[0, 2, 5]" --operation.episode_indices "[0, 2, 5]"
Delete episodes from a local dataset at a specific path: Delete episodes and save to a new dataset:
lerobot-edit-dataset \
--repo_id lerobot/pusht \
--root /path/to/pusht \
--operation.type delete_episodes \
--operation.episode_indices "[0, 2, 5]"
Delete episodes and save to a new dataset at a specific path and with a new repo_id:
lerobot-edit-dataset \ lerobot-edit-dataset \
--repo_id lerobot/pusht \ --repo_id lerobot/pusht \
--new_repo_id lerobot/pusht_filtered \ --new_repo_id lerobot/pusht_filtered \
--new_root /path/to/pusht_filtered \
--operation.type delete_episodes \ --operation.type delete_episodes \
--operation.episode_indices "[0, 2, 5]" --operation.episode_indices "[0, 2, 5]"
Split dataset by fractions (pusht_train, pusht_val): Split dataset by fractions:
lerobot-edit-dataset \ lerobot-edit-dataset \
--repo_id lerobot/pusht \ --repo_id lerobot/pusht \
--operation.type split \ --operation.type split \
--operation.splits '{"train": 0.8, "val": 0.2}' --operation.splits '{"train": 0.8, "val": 0.2}'
Split dataset by fractions and save split datasets to a specific folder (base_folder/train, base_folder/val):
lerobot-edit-dataset \
--repo_id lerobot/pusht \
--new_root /path/to/base_folder \
--operation.type split \
--operation.splits '{"train": 0.8, "val": 0.2}'
Split dataset by episode indices: Split dataset by episode indices:
lerobot-edit-dataset \ lerobot-edit-dataset \
--repo_id lerobot/pusht \ --repo_id lerobot/pusht \
@@ -74,29 +56,15 @@ Split into more than two splits:
Merge multiple datasets: Merge multiple datasets:
lerobot-edit-dataset \ lerobot-edit-dataset \
--new_repo_id lerobot/pusht_merged \ --repo_id lerobot/pusht_merged \
--operation.type merge \ --operation.type merge \
--operation.repo_ids "['lerobot/pusht_train', 'lerobot/pusht_val']" --operation.repo_ids "['lerobot/pusht_train', 'lerobot/pusht_val']"
Merge multiple datasets to a specific output path:
lerobot-edit-dataset \
--new_repo_id lerobot/pusht_merged \
--new_root /path/to/pusht_merged \
--operation.type merge \
--operation.repo_ids "['lerobot/pusht_train', 'lerobot/pusht_val']"
Merge multiple datasets from a list of local dataset paths:
lerobot-edit-dataset \
--new_repo_id lerobot/pusht_merged \
--operation.type merge \
--operation.repo_ids "['pusht_train', 'pusht_val']" \
--operation.roots "['/path/to/pusht_train', '/path/to/pusht_val']"
Remove camera feature: Remove camera feature:
lerobot-edit-dataset \ lerobot-edit-dataset \
--repo_id lerobot/pusht \ --repo_id lerobot/pusht \
--operation.type remove_feature \ --operation.type remove_feature \
--operation.feature_names "['observation.image']" --operation.feature_names "['observation.images.top']"
Modify tasks - set a single task for all episodes (WARNING: modifies in-place): Modify tasks - set a single task for all episodes (WARNING: modifies in-place):
lerobot-edit-dataset \ lerobot-edit-dataset \
@@ -120,8 +88,8 @@ Modify tasks - set default task with overrides for specific episodes (WARNING: m
Convert image dataset to video format and save locally: Convert image dataset to video format and save locally:
lerobot-edit-dataset \ lerobot-edit-dataset \
--repo_id lerobot/pusht_image \ --repo_id lerobot/pusht_image \
--new_root /path/to/output/pusht_video \ --operation.type convert_image_to_video \
--operation.type convert_image_to_video --operation.output_dir /path/to/output/pusht_video
Convert image dataset to video format and save with new repo_id: Convert image dataset to video format and save with new repo_id:
lerobot-edit-dataset \ lerobot-edit-dataset \
@@ -136,6 +104,28 @@ Convert image dataset to video format and push to hub:
--operation.type convert_image_to_video \ --operation.type convert_image_to_video \
--push_to_hub true --push_to_hub true
Trim single episode to keep only frames within timestamp range:
python -m lerobot.scripts.lerobot_edit_dataset \
--repo_id lerobot/pusht \
--new_repo_id lerobot/pusht_trimmed \
--operation.type trim_episode \
--operation.episode_index 0 \
--operation.start_timestamp 10.0 \
--operation.end_timestamp 30.0
Trim multiple episodes at once (use null for no limit):
python -m lerobot.scripts.lerobot_edit_dataset \
--repo_id lerobot/pusht \
--operation.type trim_episode \
--operation.episode_trims '{"0": [10.0, 30.0], "2": [5.0, null], "3": [null, 20.0]}'
Trim and re-upload to same repo (overwrites original):
python -m lerobot.scripts.lerobot_edit_dataset \
--repo_id lerobot/pusht \
--operation.type trim_episode \
--operation.episode_index 0 \
--operation.start_timestamp 10.0 \
--push_to_hub true
Show dataset information: Show dataset information:
lerobot-edit-dataset \ lerobot-edit-dataset \
--repo_id lerobot/pusht_image \ --repo_id lerobot/pusht_image \
@@ -199,7 +189,6 @@ class SplitConfig(OperationConfig):
@dataclass @dataclass
class MergeConfig(OperationConfig): class MergeConfig(OperationConfig):
repo_ids: list[str] | None = None repo_ids: list[str] | None = None
roots: list[str] | None = None
@OperationConfig.register_subclass("remove_feature") @OperationConfig.register_subclass("remove_feature")
@@ -233,46 +222,59 @@ class ConvertImageToVideoConfig(OperationConfig):
@OperationConfig.register_subclass("info") @OperationConfig.register_subclass("info")
@dataclass @dataclass
class InfoConfig(OperationConfig): class InfoConfig(OperationConfig):
type: str = "info"
show_features: bool = False show_features: bool = False
@dataclass
class TrimEpisodeConfig:
"""Trim episodes to keep only frames within timestamp ranges.
Supports multiple episodes via episode_trims dict:
--operation.episode_trims '{"0": [10.0, 30.0], "2": [5.0, 20.0]}'
Or single episode via legacy parameters:
--operation.episode_index 0 --operation.start_timestamp 10.0 --operation.end_timestamp 30.0
"""
type: str = "trim_episode"
# Multi-episode support: dict mapping episode_index -> [start_timestamp, end_timestamp]
# Use null for no limit, e.g. {"0": [10.0, null], "2": [null, 30.0]}
episode_trims: dict[str, list[float | None]] | None = None
# Legacy single-episode parameters (used if episode_trims is None)
episode_index: int | None = None
start_timestamp: float | None = None # Keep frames from this timestamp (inclusive)
end_timestamp: float | None = None # Keep frames until this timestamp (inclusive)
@dataclass @dataclass
class EditDatasetConfig: class EditDatasetConfig:
# Operation configuration. repo_id: str
operation: (
DeleteEpisodesConfig | SplitConfig | MergeConfig | RemoveFeatureConfig | ConvertImageToVideoConfig | TrimEpisodeConfig
)
operation: OperationConfig operation: OperationConfig
# Input dataset identifier. Always required unless for Merge operation.
repo_id: str | None = None
# Root directory where the input dataset is stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id.
root: str | None = None root: str | None = None
# Edited dataset identifier. When both new_repo_id (resp. new_root) and repo_id (resp. root) are identical, modifications are applied in-place and a backup of the original dataset is created. Required for Merge operation.
new_repo_id: str | None = None new_repo_id: str | None = None
# Root directory where the edited dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/new_repo_id. For Split operation, this is the base directory for the split datasets.
new_root: str | None = None
# Upload dataset to Hugging Face hub.
push_to_hub: bool = False push_to_hub: bool = False
def get_output_path( def get_output_path(repo_id: str, new_repo_id: str | None, root: Path | None) -> tuple[str, Path]:
repo_id: str, if new_repo_id:
new_repo_id: str | None, output_repo_id = new_repo_id
root: Path | str | None, output_dir = root / new_repo_id if root else HF_LEROBOT_HOME / new_repo_id
new_root: Path | str | None, else:
) -> tuple[str, Path]: output_repo_id = repo_id
input_path = Path(root) if root else HF_LEROBOT_HOME / repo_id dataset_path = root / repo_id if root else HF_LEROBOT_HOME / repo_id
old_path = Path(str(dataset_path) + "_old")
output_repo_id = new_repo_id if new_repo_id else repo_id if dataset_path.exists():
output_path = Path(new_root) if new_root else HF_LEROBOT_HOME / output_repo_id if old_path.exists():
shutil.rmtree(old_path)
shutil.move(str(dataset_path), str(old_path))
# In case of in-place modification, create a backup of the original dataset (if it exists) output_dir = dataset_path
if output_path == input_path:
backup_path = input_path.with_name(input_path.name + "_old")
if input_path.exists(): return output_repo_id, output_dir
if backup_path.exists():
shutil.rmtree(backup_path)
shutil.move(input_path, backup_path)
return output_repo_id, output_path
def handle_delete_episodes(cfg: EditDatasetConfig) -> None: def handle_delete_episodes(cfg: EditDatasetConfig) -> None:
@@ -284,15 +286,11 @@ def handle_delete_episodes(cfg: EditDatasetConfig) -> None:
dataset = LeRobotDataset(cfg.repo_id, root=cfg.root) dataset = LeRobotDataset(cfg.repo_id, root=cfg.root)
output_repo_id, output_dir = get_output_path( output_repo_id, output_dir = get_output_path(
cfg.repo_id, cfg.repo_id, cfg.new_repo_id, Path(cfg.root) if cfg.root else None
new_repo_id=cfg.new_repo_id,
root=cfg.root,
new_root=cfg.new_root,
) )
# In case of in-place modification, make the dataset point to the backup directory if cfg.new_repo_id is None:
if output_dir == dataset.root: dataset.root = Path(str(dataset.root) + "_old")
dataset.root = dataset.root.with_name(dataset.root.name + "_old")
logging.info(f"Deleting episodes {cfg.operation.episode_indices} from {cfg.repo_id}") logging.info(f"Deleting episodes {cfg.operation.episode_indices} from {cfg.repo_id}")
new_dataset = delete_episodes( new_dataset = delete_episodes(
@@ -319,27 +317,19 @@ def handle_split(cfg: EditDatasetConfig) -> None:
"splits dict must be specified with split names as keys and fractions/episode lists as values" "splits dict must be specified with split names as keys and fractions/episode lists as values"
) )
if cfg.new_repo_id is not None:
logging.warning(
"split uses the original dataset identifier --repo_id to generate split names. The --new_repo_id parameter is ignored."
)
dataset = LeRobotDataset(cfg.repo_id, root=cfg.root) dataset = LeRobotDataset(cfg.repo_id, root=cfg.root)
logging.info(f"Splitting dataset {cfg.repo_id} with splits: {cfg.operation.splits}") logging.info(f"Splitting dataset {cfg.repo_id} with splits: {cfg.operation.splits}")
split_datasets = split_dataset( split_datasets = split_dataset(dataset, splits=cfg.operation.splits)
dataset,
splits=cfg.operation.splits,
output_dir=cfg.new_root,
)
for split_name, split_ds in split_datasets.items(): for split_name, split_ds in split_datasets.items():
split_repo_id = f"{cfg.repo_id}_{split_name}"
logging.info( logging.info(
f"{split_name}: {split_ds.meta.total_episodes} episodes, {split_ds.meta.total_frames} frames" f"{split_name}: {split_ds.meta.total_episodes} episodes, {split_ds.meta.total_frames} frames"
) )
if cfg.push_to_hub: if cfg.push_to_hub:
logging.info(f"Pushing {split_name} split to hub as {split_ds.repo_id}") logging.info(f"Pushing {split_name} split to hub as {split_repo_id}")
LeRobotDataset(split_ds.repo_id, root=split_ds.root).push_to_hub() LeRobotDataset(split_ds.repo_id, root=split_ds.root).push_to_hub()
@@ -350,29 +340,18 @@ def handle_merge(cfg: EditDatasetConfig) -> None:
if not cfg.operation.repo_ids: if not cfg.operation.repo_ids:
raise ValueError("repo_ids must be specified for merge operation") raise ValueError("repo_ids must be specified for merge operation")
if cfg.repo_id is not None or cfg.root is not None: if not cfg.repo_id:
logging.warning( raise ValueError("repo_id must be specified as the output repository for merged dataset")
"merge uses --new_repo_id and --new_root for the merged dataset. The --repo_id and --root parameters are ignored."
)
if cfg.operation.roots: logging.info(f"Loading {len(cfg.operation.repo_ids)} datasets to merge")
if len(cfg.operation.roots) != len(cfg.operation.repo_ids): datasets = [LeRobotDataset(repo_id, root=cfg.root) for repo_id in cfg.operation.repo_ids]
raise ValueError("repo_ids and roots must have the same length for merge operation")
logging.info(f"Loading {len(cfg.operation.roots)} datasets to merge")
datasets = [
LeRobotDataset(repo_id=repo_id, root=root)
for repo_id, root in zip(cfg.operation.repo_ids, cfg.operation.roots, strict=True)
]
else:
logging.info(f"Loading {len(cfg.operation.repo_ids)} datasets to merge")
datasets = [LeRobotDataset(repo_id) for repo_id in cfg.operation.repo_ids]
output_dir = Path(cfg.new_root) if cfg.new_root else HF_LEROBOT_HOME / cfg.new_repo_id output_dir = Path(cfg.root) / cfg.repo_id if cfg.root else HF_LEROBOT_HOME / cfg.repo_id
logging.info(f"Merging datasets into {cfg.new_repo_id}") logging.info(f"Merging datasets into {cfg.repo_id}")
merged_dataset = merge_datasets( merged_dataset = merge_datasets(
datasets, datasets,
output_repo_id=cfg.new_repo_id, output_repo_id=cfg.repo_id,
output_dir=output_dir, output_dir=output_dir,
) )
@@ -382,7 +361,7 @@ def handle_merge(cfg: EditDatasetConfig) -> None:
) )
if cfg.push_to_hub: if cfg.push_to_hub:
logging.info(f"Pushing to hub as {cfg.new_repo_id}") logging.info(f"Pushing to hub as {cfg.repo_id}")
LeRobotDataset(merged_dataset.repo_id, root=output_dir).push_to_hub() LeRobotDataset(merged_dataset.repo_id, root=output_dir).push_to_hub()
@@ -395,15 +374,11 @@ def handle_remove_feature(cfg: EditDatasetConfig) -> None:
dataset = LeRobotDataset(cfg.repo_id, root=cfg.root) dataset = LeRobotDataset(cfg.repo_id, root=cfg.root)
output_repo_id, output_dir = get_output_path( output_repo_id, output_dir = get_output_path(
cfg.repo_id, cfg.repo_id, cfg.new_repo_id, Path(cfg.root) if cfg.root else None
new_repo_id=cfg.new_repo_id,
root=cfg.root,
new_root=cfg.new_root,
) )
# In case of in-place modification, make the dataset point to the backup directory if cfg.new_repo_id is None:
if output_dir == dataset.root: dataset.root = Path(str(dataset.root) + "_old")
dataset.root = dataset.root.with_name(dataset.root.name + "_old")
logging.info(f"Removing features {cfg.operation.feature_names} from {cfg.repo_id}") logging.info(f"Removing features {cfg.operation.feature_names} from {cfg.repo_id}")
new_dataset = remove_feature( new_dataset = remove_feature(
@@ -421,6 +396,92 @@ def handle_remove_feature(cfg: EditDatasetConfig) -> None:
LeRobotDataset(output_repo_id, root=output_dir).push_to_hub() LeRobotDataset(output_repo_id, root=output_dir).push_to_hub()
def handle_trim_episode(cfg: EditDatasetConfig) -> None:
"""Trim episodes to keep only frames within timestamp ranges."""
if not isinstance(cfg.operation, TrimEpisodeConfig):
raise ValueError("Operation config must be TrimEpisodeConfig")
# Parse episode trims - support both multi-episode dict and legacy single episode
episode_trims: dict[int, tuple[float | None, float | None]] = {}
if cfg.operation.episode_trims is not None:
# Multi-episode mode
for ep_str, ts_range in cfg.operation.episode_trims.items():
ep_idx = int(ep_str)
start_ts = ts_range[0] if len(ts_range) > 0 else None
end_ts = ts_range[1] if len(ts_range) > 1 else None
episode_trims[ep_idx] = (start_ts, end_ts)
elif cfg.operation.episode_index is not None:
# Legacy single-episode mode
if cfg.operation.start_timestamp is None and cfg.operation.end_timestamp is None:
raise ValueError("At least one of start_timestamp or end_timestamp must be specified")
episode_trims[cfg.operation.episode_index] = (
cfg.operation.start_timestamp,
cfg.operation.end_timestamp,
)
else:
raise ValueError("Either episode_trims or episode_index must be specified")
dataset = LeRobotDataset(cfg.repo_id, root=cfg.root)
output_repo_id, output_dir = get_output_path(
cfg.repo_id, cfg.new_repo_id, Path(cfg.root) if cfg.root else None
)
if cfg.new_repo_id is None:
dataset.root = Path(str(dataset.root) + "_old")
logging.info(f"Trimming {len(episode_trims)} episode(s) from {cfg.repo_id}")
# Get episode boundaries and find frames to keep for each episode
episodes_info = dataset.meta.episodes
all_frames_to_keep: dict[int, list[int]] = {}
for ep_idx, (start_ts, end_ts) in episode_trims.items():
if ep_idx >= len(episodes_info["episode_index"]):
raise ValueError(f"Episode {ep_idx} does not exist (dataset has {len(episodes_info['episode_index'])} episodes)")
from_frame = episodes_info["dataset_from_index"][ep_idx]
to_frame = episodes_info["dataset_to_index"][ep_idx]
logging.info(f"Episode {ep_idx}: trimming to [{start_ts}, {end_ts}]")
logging.info(f" Original frames: {from_frame} to {to_frame} ({to_frame - from_frame} frames)")
# Find frames within timestamp range
frames_to_keep = []
for frame_idx in range(from_frame, to_frame):
frame = dataset.hf_dataset[frame_idx]
ts = frame["timestamp"]
in_range = True
if start_ts is not None and ts < start_ts:
in_range = False
if end_ts is not None and ts > end_ts:
in_range = False
if in_range:
frames_to_keep.append(frame_idx)
if not frames_to_keep:
raise ValueError(f"Episode {ep_idx}: No frames found in timestamp range [{start_ts}, {end_ts}]")
logging.info(f" Keeping {len(frames_to_keep)} frames (indices {frames_to_keep[0]} to {frames_to_keep[-1]})")
all_frames_to_keep[ep_idx] = frames_to_keep
from lerobot.datasets.dataset_tools import trim_episodes_by_frames
new_dataset = trim_episodes_by_frames(
dataset,
episode_frames_to_keep=all_frames_to_keep,
output_dir=output_dir,
repo_id=output_repo_id,
)
logging.info(f"Dataset saved to {output_dir}")
logging.info(f"Episodes: {new_dataset.meta.total_episodes}, Frames: {new_dataset.meta.total_frames}")
if cfg.push_to_hub:
logging.info(f"Pushing to hub as {output_repo_id}")
LeRobotDataset(output_repo_id, root=output_dir).push_to_hub()
def handle_modify_tasks(cfg: EditDatasetConfig) -> None: def handle_modify_tasks(cfg: EditDatasetConfig) -> None:
if not isinstance(cfg.operation, ModifyTasksConfig): if not isinstance(cfg.operation, ModifyTasksConfig):
raise ValueError("Operation config must be ModifyTasksConfig") raise ValueError("Operation config must be ModifyTasksConfig")
@@ -431,10 +492,9 @@ def handle_modify_tasks(cfg: EditDatasetConfig) -> None:
if new_task is None and episode_tasks_raw is None: if new_task is None and episode_tasks_raw is None:
raise ValueError("Must specify at least one of new_task or episode_tasks for modify_tasks operation") raise ValueError("Must specify at least one of new_task or episode_tasks for modify_tasks operation")
if cfg.new_repo_id is not None or cfg.new_root is not None: # Warn about in-place modification behavior
logging.warning( if cfg.new_repo_id is not None:
"modify_tasks modifies datasets in-place. The --new_repo_id and --new_root parameters are ignored." logging.warning("modify_tasks modifies datasets in-place. The --new_repo_id parameter is ignored.")
)
dataset = LeRobotDataset(cfg.repo_id, root=cfg.root) dataset = LeRobotDataset(cfg.repo_id, root=cfg.root)
logging.warning(f"Modifying dataset in-place at {dataset.root}. Original data will be overwritten.") logging.warning(f"Modifying dataset in-place at {dataset.root}. Original data will be overwritten.")
@@ -470,30 +530,32 @@ def handle_convert_image_to_video(cfg: EditDatasetConfig) -> None:
dataset = LeRobotDataset(cfg.repo_id, root=cfg.root) dataset = LeRobotDataset(cfg.repo_id, root=cfg.root)
# Determine output directory and repo_id # Determine output directory and repo_id
# Priority: 1) new_root, 2) new_repo_id, 3) operation.output_dir, 4) auto-generated name # Priority: 1) new_repo_id, 2) operation.output_dir, 3) auto-generated name
output_dir_config = getattr(cfg.operation, "output_dir", None) output_dir_config = getattr(cfg.operation, "output_dir", None)
if output_dir_config:
logging.warning(
"--operation.output_dir is deprecated and will be removed in future versions. "
"Please use --new_root instead."
)
if cfg.new_root: if cfg.new_repo_id:
output_dir = Path(cfg.new_root) # Use new_repo_id for both local storage and hub push
output_repo_id = cfg.new_repo_id or f"{cfg.repo_id}_video"
logging.info(f"Saving to new_root: {output_dir} as {output_repo_id}")
elif cfg.new_repo_id:
output_repo_id = cfg.new_repo_id output_repo_id = cfg.new_repo_id
output_dir = HF_LEROBOT_HOME / cfg.new_repo_id # Place new dataset as a sibling to the original dataset
# Get the parent of the actual dataset root (not cfg.root which might be the lerobot cache dir)
# Extract just the dataset name (after last slash) for the local directory
local_dir_name = cfg.new_repo_id.split("/")[-1]
output_dir = dataset.root.parent / local_dir_name
logging.info(f"Saving to new dataset: {cfg.new_repo_id} at {output_dir}") logging.info(f"Saving to new dataset: {cfg.new_repo_id} at {output_dir}")
elif output_dir_config: elif output_dir_config:
# Use custom output directory for local-only storage
output_dir = Path(output_dir_config) output_dir = Path(output_dir_config)
# Extract repo name from output_dir for the dataset
output_repo_id = output_dir.name output_repo_id = output_dir.name
logging.info(f"Saving to local directory: {output_dir} as {output_repo_id}") logging.info(f"Saving to local directory: {output_dir}")
else: else:
# Auto-generate name: append "_video" to original repo_id
output_repo_id = f"{cfg.repo_id}_video" output_repo_id = f"{cfg.repo_id}_video"
output_dir = HF_LEROBOT_HOME / output_repo_id # Place new dataset as a sibling to the original dataset
logging.info(f"Saving to auto-generated location: {output_dir} as {output_repo_id}") # Extract just the dataset name (after last slash) for the local directory
local_dir_name = output_repo_id.split("/")[-1]
output_dir = dataset.root.parent / local_dir_name
logging.info(f"Saving to auto-generated location: {output_dir}")
logging.info(f"Converting dataset {cfg.repo_id} to video format") logging.info(f"Converting dataset {cfg.repo_id} to video format")
@@ -568,20 +630,8 @@ def handle_info(cfg: EditDatasetConfig):
sys.stdout.write(f"{feature_dump_str}\n") sys.stdout.write(f"{feature_dump_str}\n")
def _validate_config(cfg: EditDatasetConfig) -> None:
if isinstance(cfg.operation, MergeConfig):
if not cfg.new_repo_id:
raise ValueError("--new_repo_id is required for merge operation (the merged dataset identifier)")
else:
if not cfg.repo_id:
raise ValueError(
f"--repo_id is required for {cfg.operation.type} operation (the input dataset identifier)"
)
@parser.wrap() @parser.wrap()
def edit_dataset(cfg: EditDatasetConfig) -> None: def edit_dataset(cfg: EditDatasetConfig) -> None:
_validate_config(cfg)
operation_type = cfg.operation.type operation_type = cfg.operation.type
if operation_type == "delete_episodes": if operation_type == "delete_episodes":
@@ -596,6 +646,8 @@ def edit_dataset(cfg: EditDatasetConfig) -> None:
handle_modify_tasks(cfg) handle_modify_tasks(cfg)
elif operation_type == "convert_image_to_video": elif operation_type == "convert_image_to_video":
handle_convert_image_to_video(cfg) handle_convert_image_to_video(cfg)
elif operation_type == "trim_episode":
handle_trim_episode(cfg)
elif operation_type == "info": elif operation_type == "info":
handle_info(cfg) handle_info(cfg)
else: else:
@@ -61,7 +61,6 @@ from lerobot.teleoperators import ( # noqa: F401
make_teleoperator_from_config, make_teleoperator_from_config,
omx_leader, omx_leader,
openarm_leader, openarm_leader,
openarm_mini,
so_leader, so_leader,
) )
from lerobot.utils.robot_utils import precise_sleep from lerobot.utils.robot_utils import precise_sleep
+10 -12
View File
@@ -125,7 +125,6 @@ from lerobot.teleoperators import ( # noqa: F401
make_teleoperator_from_config, make_teleoperator_from_config,
omx_leader, omx_leader,
openarm_leader, openarm_leader,
openarm_mini,
reachy2_teleoperator, reachy2_teleoperator,
so_leader, so_leader,
unitree_g1, unitree_g1,
@@ -155,7 +154,7 @@ class DatasetRecordConfig:
repo_id: str repo_id: str
# A short but accurate description of the task performed during the recording (e.g. "Pick the Lego block and drop it in the box on the right.") # A short but accurate description of the task performed during the recording (e.g. "Pick the Lego block and drop it in the box on the right.")
single_task: str single_task: str
# Root directory where the dataset will be stored (e.g. 'dataset/path'). If None, defaults to $HF_LEROBOT_HOME/repo_id. # Root directory where the dataset will be stored (e.g. 'dataset/path').
root: str | Path | None = None root: str | Path | None = None
# Limit the frames per second. # Limit the frames per second.
fps: int = 30 fps: int = 30
@@ -334,7 +333,6 @@ def record_loop(
preprocessor.reset() preprocessor.reset()
postprocessor.reset() postprocessor.reset()
no_action_count = 0
timestamp = 0 timestamp = 0
start_episode_t = time.perf_counter() start_episode_t = time.perf_counter()
while timestamp < control_time_s: while timestamp < control_time_s:
@@ -369,8 +367,6 @@ def record_loop(
act_processed_policy: RobotAction = make_robot_action(action_values, dataset.features) act_processed_policy: RobotAction = make_robot_action(action_values, dataset.features)
elif policy is None and isinstance(teleop, Teleoperator): elif policy is None and isinstance(teleop, Teleoperator):
if robot.name == "unitree_g1":
teleop.send_feedback(obs)
act = teleop.get_action() act = teleop.get_action()
# Applies a pipeline to the raw teleop action, default is IdentityProcessor # Applies a pipeline to the raw teleop action, default is IdentityProcessor
@@ -384,13 +380,11 @@ def record_loop(
act = {**arm_action, **base_action} if len(base_action) > 0 else arm_action act = {**arm_action, **base_action} if len(base_action) > 0 else arm_action
act_processed_teleop = teleop_action_processor((act, obs)) act_processed_teleop = teleop_action_processor((act, obs))
else: else:
no_action_count += 1 logging.info(
if no_action_count == 1 or no_action_count % 10 == 0: "No policy or teleoperator provided, skipping action generation."
logging.warning( "This is likely to happen when resetting the environment without a teleop device."
"No policy or teleoperator provided, skipping action generation. " "The robot won't be at its rest position at the start of the next episode."
"This is likely to happen when resetting the environment without a teleop device. " )
"The robot won't be at its rest position at the start of the next episode."
)
continue continue
# Applies a pipeline to the action, default is IdentityProcessor # Applies a pipeline to the action, default is IdentityProcessor
@@ -558,6 +552,10 @@ def record(cfg: RecordConfig) -> LeRobotDataset:
): ):
log_say("Reset the environment", cfg.play_sounds) log_say("Reset the environment", cfg.play_sounds)
# reset g1 robot
if robot.name == "unitree_g1":
robot.reset()
record_loop( record_loop(
robot=robot, robot=robot,
events=events, events=events,
+1 -1
View File
@@ -80,7 +80,7 @@ class DatasetReplayConfig:
repo_id: str repo_id: str
# Episode to replay. # Episode to replay.
episode: int episode: int
# Root directory where the dataset will be stored (e.g. 'dataset/path'). If None, defaults to $HF_LEROBOT_HOME/repo_id. # Root directory where the dataset will be stored (e.g. 'dataset/path').
root: str | Path | None = None root: str | Path | None = None
# Limit the frames per second. By default, uses the policy fps. # Limit the frames per second. By default, uses the policy fps.
fps: int = 30 fps: int = 30
+1 -5
View File
@@ -60,7 +60,6 @@ import rerun as rr
from lerobot.cameras.opencv.configuration_opencv import OpenCVCameraConfig # noqa: F401 from lerobot.cameras.opencv.configuration_opencv import OpenCVCameraConfig # noqa: F401
from lerobot.cameras.realsense.configuration_realsense import RealSenseCameraConfig # noqa: F401 from lerobot.cameras.realsense.configuration_realsense import RealSenseCameraConfig # noqa: F401
from lerobot.cameras.zmq.configuration_zmq import ZMQCameraConfig # noqa: F401
from lerobot.configs import parser from lerobot.configs import parser
from lerobot.processor import ( from lerobot.processor import (
RobotAction, RobotAction,
@@ -95,7 +94,6 @@ from lerobot.teleoperators import ( # noqa: F401
make_teleoperator_from_config, make_teleoperator_from_config,
omx_leader, omx_leader,
openarm_leader, openarm_leader,
openarm_mini,
reachy2_teleoperator, reachy2_teleoperator,
so_leader, so_leader,
unitree_g1, unitree_g1,
@@ -154,6 +152,7 @@ def teleop_loop(
display_len = max(len(key) for key in robot.action_features) display_len = max(len(key) for key in robot.action_features)
start = time.perf_counter() start = time.perf_counter()
while True: while True:
loop_start = time.perf_counter() loop_start = time.perf_counter()
@@ -163,9 +162,6 @@ def teleop_loop(
# given that it is the identity processor as default # given that it is the identity processor as default
obs = robot.get_observation() obs = robot.get_observation()
if robot.name == "unitree_g1":
teleop.send_feedback(obs)
# Get teleop action # Get teleop action
raw_action = teleop.get_action() raw_action = teleop.get_action()
+3 -7
View File
@@ -209,11 +209,7 @@ def train(cfg: TrainPipelineConfig, accelerator: Accelerator | None = None):
# Use accelerator's device # Use accelerator's device
device = accelerator.device device = accelerator.device
if cfg.cudnn_deterministic: torch.backends.cudnn.benchmark = True
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
else:
torch.backends.cudnn.benchmark = True
torch.backends.cuda.matmul.allow_tf32 = True torch.backends.cuda.matmul.allow_tf32 = True
# Dataset loading synchronization: main process downloads first to avoid race conditions # Dataset loading synchronization: main process downloads first to avoid race conditions
@@ -384,10 +380,10 @@ def train(cfg: TrainPipelineConfig, accelerator: Accelerator | None = None):
"dataloading_s": AverageMeter("data_s", ":.3f"), "dataloading_s": AverageMeter("data_s", ":.3f"),
} }
# Keep global batch size for logging; MetricsTracker handles world size internally. # Use effective batch size for proper epoch calculation in distributed training
effective_batch_size = cfg.batch_size * accelerator.num_processes effective_batch_size = cfg.batch_size * accelerator.num_processes
train_tracker = MetricsTracker( train_tracker = MetricsTracker(
cfg.batch_size, effective_batch_size,
dataset.num_frames, dataset.num_frames,
dataset.num_episodes, dataset.num_episodes,
train_metrics, train_metrics,
@@ -306,7 +306,7 @@ def train_fast_tokenizer(
# download the tokenizer source code (not pretrained weights) # download the tokenizer source code (not pretrained weights)
# we'll train a new tokenizer on our own data # we'll train a new tokenizer on our own data
base_tokenizer = AutoProcessor.from_pretrained("lerobot/fast-action-tokenizer", trust_remote_code=True) base_tokenizer = AutoProcessor.from_pretrained("physical-intelligence/fast", trust_remote_code=True)
# convert action_chunks array to list of arrays (expected by .fit()) # convert action_chunks array to list of arrays (expected by .fit())
action_data_list = [action_chunks[i] for i in range(len(action_chunks))] action_data_list = [action_chunks[i] for i in range(len(action_chunks))]
@@ -15,6 +15,7 @@
# limitations under the License. # limitations under the License.
from dataclasses import dataclass from dataclasses import dataclass
from typing import TypeAlias
from ..config import TeleoperatorConfig from ..config import TeleoperatorConfig
@@ -37,5 +38,5 @@ class SOLeaderTeleopConfig(TeleoperatorConfig, SOLeaderConfig):
pass pass
SO100LeaderConfig = SOLeaderTeleopConfig SO100LeaderConfig: TypeAlias = SOLeaderTeleopConfig
SO101LeaderConfig = SOLeaderTeleopConfig SO101LeaderConfig: TypeAlias = SOLeaderTeleopConfig
@@ -16,6 +16,7 @@
import logging import logging
import time import time
from typing import TypeAlias
from lerobot.motors import Motor, MotorCalibration, MotorNormMode from lerobot.motors import Motor, MotorCalibration, MotorNormMode
from lerobot.motors.feetech import ( from lerobot.motors.feetech import (
@@ -155,5 +156,5 @@ class SOLeader(Teleoperator):
logger.info(f"{self} disconnected.") logger.info(f"{self} disconnected.")
SO100Leader = SOLeader SO100Leader: TypeAlias = SOLeader
SO101Leader = SOLeader SO101Leader: TypeAlias = SOLeader
@@ -19,13 +19,3 @@ from .exo_calib import ExoskeletonCalibration, ExoskeletonJointCalibration
from .exo_ik import ExoskeletonIKHelper from .exo_ik import ExoskeletonIKHelper
from .exo_serial import ExoskeletonArm from .exo_serial import ExoskeletonArm
from .unitree_g1 import UnitreeG1Teleoperator from .unitree_g1 import UnitreeG1Teleoperator
__all__ = [
"ExoskeletonArmPortConfig",
"ExoskeletonCalibration",
"ExoskeletonIKHelper",
"ExoskeletonJointCalibration",
"ExoskeletonArm",
"UnitreeG1Teleoperator",
"UnitreeG1TeleoperatorConfig",
]
@@ -35,9 +35,6 @@ import serial
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ADC_MAX = 2**12 - 1
ADC_HALF = ADC_MAX / 2
# exoskeleton joint names -> ADC channel pairs. TODO: add wrist pitch and wrist yaw # exoskeleton joint names -> ADC channel pairs. TODO: add wrist pitch and wrist yaw
JOINTS = { JOINTS = {
"shoulder_pitch": (0, 1), "shoulder_pitch": (0, 1),
@@ -62,7 +59,7 @@ class ExoskeletonCalibration:
version: int = 2 version: int = 2
side: str = "" side: str = ""
adc_max: int = ADC_MAX adc_max: int = 2**12 - 1
joints: list[ExoskeletonJointCalibration] = field(default_factory=list) joints: list[ExoskeletonJointCalibration] = field(default_factory=list)
def to_dict(self) -> dict: def to_dict(self) -> dict:
@@ -95,7 +92,7 @@ class ExoskeletonCalibration:
return cls( return cls(
version=data.get("version", 2), version=data.get("version", 2),
side=data.get("side", ""), side=data.get("side", ""),
adc_max=data.get("adc_max", ADC_MAX), adc_max=data.get("adc_max", 2**12 - 1),
joints=joints, joints=joints,
) )
@@ -115,8 +112,11 @@ class CalibParams:
def normalize_angle(angle: float) -> float: def normalize_angle(angle: float) -> float:
"""Normalize angle to [-pi, pi].""" while angle > np.pi:
return float(np.arctan2(np.sin(angle), np.cos(angle))) angle -= 2 * np.pi
while angle < -np.pi:
angle += 2 * np.pi
return angle
def joint_z_and_angle(raw16: list[int], j: ExoskeletonJointCalibration) -> tuple[np.ndarray, float]: def joint_z_and_angle(raw16: list[int], j: ExoskeletonJointCalibration) -> tuple[np.ndarray, float]:
@@ -125,7 +125,7 @@ def joint_z_and_angle(raw16: list[int], j: ExoskeletonJointCalibration) -> tuple
""" """
pair = JOINTS[j.name] pair = JOINTS[j.name]
s, c = raw16[pair[0]], raw16[pair[1]] # get sin and cos s, c = raw16[pair[0]], raw16[pair[1]] # get sin and cos
p = np.array([float(c) - ADC_HALF, float(s) - ADC_HALF]) # center the raw values p = np.array([float(c) - (2**12 - 1) / 2, float(s) - (2**12 - 1) / 2]) # center the raw values
z = np.asarray(j.T) @ ( z = np.asarray(j.T) @ (
p - np.asarray(j.center_fit) p - np.asarray(j.center_fit)
) # center the ellipse and invert the transformation matrix to get unit circle coords ) # center the ellipse and invert the transformation matrix to get unit circle coords
@@ -167,7 +167,7 @@ def run_exo_calibration(
def read_joint_point(raw16: list[int], pair: tuple[int, int]): def read_joint_point(raw16: list[int], pair: tuple[int, int]):
s, c = raw16[pair[0]], raw16[pair[1]] s, c = raw16[pair[0]], raw16[pair[1]]
return float(c) - ADC_HALF, float(s) - ADC_HALF, float(s), float(c) return float(c) - (2**12 - 1) / 2, float(s) - (2**12 - 1) / 2, float(s), float(c)
def select_fit_subset(xs, ys): def select_fit_subset(xs, ys):
"""Select and filter points for ellipse fitting. Trims outliers by radius and downsamples.""" """Select and filter points for ellipse fitting. Trims outliers by radius and downsamples."""
@@ -317,7 +317,7 @@ def run_exo_calibration(
calib = ExoskeletonCalibration( calib = ExoskeletonCalibration(
version=2, version=2,
side=side, side=side,
adc_max=ADC_MAX, adc_max=2**12 - 1,
joints=[ joints=[
ExoskeletonJointCalibration( ExoskeletonJointCalibration(
name=j["name"], name=j["name"],
@@ -367,8 +367,8 @@ def run_exo_calibration(
state["win_s"].append(s_raw) state["win_s"].append(s_raw)
state["win_c"].append(c_raw) state["win_c"].append(c_raw)
if len(state["win_s"]) >= max(3, params.median_window): if len(state["win_s"]) >= max(3, params.median_window):
state["ys"].append(running_median(state["win_s"]) - ADC_HALF) state["ys"].append(running_median(state["win_s"]) - (2**12 - 1) / 2)
state["xs"].append(running_median(state["win_c"]) - ADC_HALF) state["xs"].append(running_median(state["win_c"]) - (2**12 - 1) / 2)
else: else:
jdata = joints_out[-1] jdata = joints_out[-1]
z = np.array(jdata["T"]) @ (np.array([x_raw, y_raw]) - np.array(jdata["center_fit"])) z = np.array(jdata["T"]) @ (np.array([x_raw, y_raw]) - np.array(jdata["center_fit"]))
@@ -25,8 +25,8 @@ from dataclasses import dataclass
import numpy as np import numpy as np
from lerobot.robots.unitree_g1.g1_kinematics import G1_29_ArmIK
from lerobot.robots.unitree_g1.g1_utils import G1_29_JointArmIndex from lerobot.robots.unitree_g1.g1_utils import G1_29_JointArmIndex
from lerobot.robots.unitree_g1.robot_kinematic_processor import G1_29_ArmIK
from .exo_calib import JOINTS from .exo_calib import JOINTS
@@ -32,29 +32,25 @@ def parse_raw16(line: bytes) -> list[int] | None:
if len(parts) < 16: if len(parts) < 16:
return None return None
return [int(x) for x in parts[:16]] return [int(x) for x in parts[:16]]
except (ValueError, IndexError): except Exception:
return None return None
def read_raw_from_serial(ser) -> list[int] | None: def read_raw_from_serial(ser) -> list[int] | None:
"""Read latest sample from serial; if buffer is backed up, keep only the newest.""" """Read latest sample from serial; if buffer is backed up, keep only the newest."""
try: last = None
last = None while ser.in_waiting > 0:
while ser.in_waiting > 0: b = ser.readline()
b = ser.readline() if not b:
if not b: break
break raw16 = parse_raw16(b)
raw16 = parse_raw16(b) if raw16 is not None:
if raw16 is not None: last = raw16
last = raw16 if last is None:
if last is None: b = ser.readline()
b = ser.readline() if b:
if b: last = parse_raw16(b)
last = parse_raw16(b) return last
return last
except serial.SerialException as e:
logger.warning(f"Serial read error: {e}")
return None
@dataclass @dataclass
@@ -119,6 +115,5 @@ class ExoskeletonArm:
return {} if raw is None else exo_raw_to_angles(raw, self.calibration) return {} if raw is None else exo_raw_to_angles(raw, self.calibration)
def calibrate(self) -> None: def calibrate(self) -> None:
if not self.is_connected: ser = self._ser
raise RuntimeError("Cannot calibrate: exoskeleton not connected") self.calibration = run_exo_calibration(ser, self.side, self.calibration_fpath)
self.calibration = run_exo_calibration(self._ser, self.side, self.calibration_fpath)
@@ -17,22 +17,9 @@
import logging import logging
import time import time
from functools import cached_property from functools import cached_property
from typing import TYPE_CHECKING, Any
from lerobot.robots.unitree_g1.g1_utils import REMOTE_AXES, G1_29_JointArmIndex from lerobot.robots.unitree_g1.g1_utils import G1_29_JointIndex
from lerobot.utils.constants import HF_LEROBOT_CALIBRATION, TELEOPERATORS from lerobot.utils.constants import HF_LEROBOT_CALIBRATION, TELEOPERATORS
from lerobot.utils.import_utils import _unitree_sdk_available
if TYPE_CHECKING or _unitree_sdk_available:
from unitree_sdk2py.utils.joystick import Joystick
else:
class Joystick:
def __init__(self):
raise ImportError(
"unitree_sdk2py is required for RemoteController. Install with: pip install unitree_sdk2py"
)
from ..teleoperator import Teleoperator from ..teleoperator import Teleoperator
from .config_unitree_g1 import UnitreeG1TeleoperatorConfig from .config_unitree_g1 import UnitreeG1TeleoperatorConfig
@@ -42,120 +29,6 @@ from .exo_serial import ExoskeletonArm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RemoteController:
"""Unitree remote controller data parser for joystick and button state."""
# ADC parameters for exoskeleton joystick (12-bit ADC)
ADC_MAX = 4095
ADC_HALF = ADC_MAX / 2
JOYSTICK_X_IDX = 11 # X axis in raw ADC array
JOYSTICK_BTN_IDX = 12 # Button in raw ADC array
JOYSTICK_Y_IDX = 13 # Y axis in raw ADC array
# Map SDK named buttons to positional indices matching the wireless_remote
# byte layout (little-endian uint16 from bytes 2-3).
_BUTTON_MAP: list[str] = [
"RB",
"LB",
"start",
"back",
"RT",
"LT",
"",
"",
"A",
"B",
"X",
"Y",
"up",
"right",
"down",
"left",
]
def __init__(self):
self.lx = 0.0
self.ly = 0.0
self.rx = 0.0
self.ry = 0.0
self.button = [0] * 16
self.remote_action = dict.fromkeys(REMOTE_AXES, 0.0)
# SDK joystick parser for wireless remote bytes
self._joystick = Joystick()
# Disable axis smoothing and deadzone to preserve raw values
for axis in (self._joystick.lx, self._joystick.ly, self._joystick.rx, self._joystick.ry):
axis.smooth = 1.0
axis.deadzone = 0.0
# Joystick center calibration (read at connect time)
self.left_center_x = self.ADC_HALF
self.left_center_y = self.ADC_HALF
self.right_center_x = self.ADC_HALF
self.right_center_y = self.ADC_HALF
# Whether to use exo joystick (detected at connect time)
self.use_left_exo_joystick = False
self.use_right_exo_joystick = False
def _sync_remote_action(self) -> None:
self.remote_action.update(zip(REMOTE_AXES, (self.lx, self.ly, self.rx, self.ry), strict=True))
def calibrate_center(self, raw16: list[int] | None, side: str) -> None:
if raw16 is None or len(raw16) < 16:
logger.info(f"{side.capitalize()} exo joystick: no data available")
return
btn_val = raw16[self.JOYSTICK_BTN_IDX]
logger.info(f"{side.capitalize()} exo joystick button ADC: {btn_val} (threshold: {self.ADC_HALF})")
if btn_val <= self.ADC_HALF:
logger.info(f"{side.capitalize()} exo joystick not detected (button below threshold)")
return
x = raw16[self.JOYSTICK_X_IDX]
y = raw16[self.JOYSTICK_Y_IDX]
if side == "left":
self.use_left_exo_joystick = True
self.left_center_x, self.left_center_y = x, y
else:
self.use_right_exo_joystick = True
self.right_center_x, self.right_center_y = x, y
logger.info(f"{side.capitalize()} exo joystick enabled, center: x={x}, y={y}")
def set_from_exo(self, raw16: list[int] | None, side: str) -> None:
if raw16 is None or len(raw16) < 16:
return
if side == "left":
if not self.use_left_exo_joystick:
return
self.lx = (raw16[self.JOYSTICK_X_IDX] - self.left_center_x) / self.ADC_HALF
self.ly = (raw16[self.JOYSTICK_Y_IDX] - self.left_center_y) / self.ADC_HALF
self.button[4] = 1 if raw16[self.JOYSTICK_BTN_IDX] < self.ADC_HALF else 0
return
if not self.use_right_exo_joystick:
return
self.rx = (raw16[self.JOYSTICK_X_IDX] - self.right_center_x) / self.ADC_HALF
self.ry = (raw16[self.JOYSTICK_Y_IDX] - self.right_center_y) / self.ADC_HALF
self.button[0] = 1 if raw16[self.JOYSTICK_BTN_IDX] < self.ADC_HALF else 0
def set_from_wireless(self, wireless_remote: bytes) -> None:
"""Parse Unitree wireless remote raw bytes into joystick + button state."""
if len(wireless_remote) < 24:
return
self._joystick.extract(wireless_remote)
self.lx = self._joystick.lx.data
self.ly = self._joystick.ly.data
self.rx = self._joystick.rx.data
self.ry = self._joystick.ry.data
for i, name in enumerate(self._BUTTON_MAP):
if name:
self.button[i] = getattr(self._joystick, name).data
class UnitreeG1Teleoperator(Teleoperator): class UnitreeG1Teleoperator(Teleoperator):
""" """
Bimanual exoskeleton arms teleoperator for Unitree G1 arms. Bimanual exoskeleton arms teleoperator for Unitree G1 arms.
@@ -170,13 +43,6 @@ class UnitreeG1Teleoperator(Teleoperator):
def __init__(self, config: UnitreeG1TeleoperatorConfig): def __init__(self, config: UnitreeG1TeleoperatorConfig):
super().__init__(config) super().__init__(config)
self.config = config self.config = config
left_exo_enabled = bool(config.left_arm_config.port.strip())
right_exo_enabled = bool(config.right_arm_config.port.strip())
if left_exo_enabled != right_exo_enabled:
raise ValueError(
"Invalid exo config: set both left/right exo ports, or leave both empty for remote-only mode."
)
self._arm_control_enabled = left_exo_enabled and right_exo_enabled
# Setup calibration directory # Setup calibration directory
self.calibration_dir = ( self.calibration_dir = (
@@ -204,37 +70,24 @@ class UnitreeG1Teleoperator(Teleoperator):
) )
self.ik_helper: ExoskeletonIKHelper | None = None self.ik_helper: ExoskeletonIKHelper | None = None
self.remote_controller = RemoteController()
@cached_property @cached_property
def action_features(self) -> dict[str, type]: def action_features(self) -> dict[str, type]:
remote_features = dict.fromkeys(self.remote_controller.remote_action, float) return {f"{name}.q": float for name in self._g1_joint_names}
if not self._arm_control_enabled:
return remote_features
joint_features = {f"{name}.q": float for name in self._g1_arm_joint_names}
return {**joint_features, **remote_features}
@cached_property @cached_property
def feedback_features(self) -> dict[str, type]: def feedback_features(self) -> dict[str, type]:
return {"wireless_remote": bytes} return {}
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
if not self._arm_control_enabled:
return True
return self.left_arm.is_connected and self.right_arm.is_connected return self.left_arm.is_connected and self.right_arm.is_connected
@property @property
def is_calibrated(self) -> bool: def is_calibrated(self) -> bool:
if not self._arm_control_enabled:
return True
return self.left_arm.is_calibrated and self.right_arm.is_calibrated return self.left_arm.is_calibrated and self.right_arm.is_calibrated
def connect(self, calibrate: bool = True) -> None: def connect(self, calibrate: bool = True) -> None:
if not self._arm_control_enabled:
logger.warning("Exo ports not fully configured; teleop will send joystick only (no arm actions)")
return
self.left_arm.connect(calibrate) self.left_arm.connect(calibrate)
self.right_arm.connect(calibrate) self.right_arm.connect(calibrate)
@@ -242,13 +95,6 @@ class UnitreeG1Teleoperator(Teleoperator):
self.ik_helper = ExoskeletonIKHelper(frozen_joints=frozen_joints) self.ik_helper = ExoskeletonIKHelper(frozen_joints=frozen_joints)
logger.info("IK helper initialized") logger.info("IK helper initialized")
time.sleep(0.1) # Give serial time to populate buffer
left_raw = self.left_arm.read_raw()
right_raw = self.right_arm.read_raw()
self.remote_controller.calibrate_center(left_raw, "left")
self.remote_controller.calibrate_center(right_raw, "right")
def calibrate(self) -> None: def calibrate(self) -> None:
if not self.left_arm.is_calibrated: if not self.left_arm.is_calibrated:
logger.info("Starting calibration for left arm...") logger.info("Starting calibration for left arm...")
@@ -269,33 +115,12 @@ class UnitreeG1Teleoperator(Teleoperator):
pass pass
def get_action(self) -> dict[str, float]: def get_action(self) -> dict[str, float]:
joint_action = {} left_angles = self.left_arm.get_angles()
left_raw = None right_angles = self.right_arm.get_angles()
right_raw = None return self.ik_helper.compute_g1_joints_from_exo(left_angles, right_angles)
if self._arm_control_enabled:
left_raw = self.left_arm.read_raw()
right_raw = self.right_arm.read_raw()
left_angles = self.left_arm.get_angles() def send_feedback(self, feedback: dict[str, float]) -> None:
right_angles = self.right_arm.get_angles() raise NotImplementedError("Exoskeleton arms do not support feedback")
joint_action = self.ik_helper.compute_g1_joints_from_exo(left_angles, right_angles)
# Wireless remote has priority when non-zero; otherwise, use exo joystick.
rc = self.remote_controller
wireless_active = (
abs(rc.lx) > 1e-3 or abs(rc.ly) > 1e-3 or abs(rc.rx) > 1e-3 or abs(rc.ry) > 1e-3
) or any(rc.button)
if self._arm_control_enabled and not wireless_active:
rc.set_from_exo(left_raw, "left")
rc.set_from_exo(right_raw, "right")
rc._sync_remote_action()
return {**joint_action, **rc.remote_action}
def send_feedback(self, feedback: dict[str, Any]) -> None:
wireless_remote = feedback.get("wireless_remote")
if wireless_remote is not None:
self.remote_controller.set_from_wireless(wireless_remote)
def disconnect(self) -> None: def disconnect(self) -> None:
self.left_arm.disconnect() self.left_arm.disconnect()
@@ -328,5 +153,5 @@ class UnitreeG1Teleoperator(Teleoperator):
print("\n\nVisualization stopped.") print("\n\nVisualization stopped.")
@cached_property @cached_property
def _g1_arm_joint_names(self) -> list[str]: def _g1_joint_names(self) -> list[str]:
return [joint.name for joint in G1_29_JointArmIndex] return [joint.name for joint in G1_29_JointIndex]
-2
View File
@@ -74,8 +74,6 @@ _peft_available = is_package_available("peft")
_scipy_available = is_package_available("scipy") _scipy_available = is_package_available("scipy")
_reachy2_sdk_available = is_package_available("reachy2_sdk") _reachy2_sdk_available = is_package_available("reachy2_sdk")
_can_available = is_package_available("python-can", "can") _can_available = is_package_available("python-can", "can")
_unitree_sdk_available = is_package_available("unitree-sdk2", "unitree_sdk2py")
_pygame_available = is_package_available("pygame")
def make_device_from_device_class(config: ChoiceRegistry) -> Any: def make_device_from_device_class(config: ChoiceRegistry) -> Any:
+3 -1
View File
@@ -16,10 +16,12 @@
import json import json
import warnings import warnings
from pathlib import Path from pathlib import Path
from typing import TypeVar
import imageio import imageio
JsonLike = str | int | float | bool | None | list["JsonLike"] | dict[str, "JsonLike"] | tuple["JsonLike", ...] JsonLike = str | int | float | bool | None | list["JsonLike"] | dict[str, "JsonLike"] | tuple["JsonLike", ...]
T = TypeVar("T", bound=JsonLike)
def write_video(video_path, stacked_frames, fps): def write_video(video_path, stacked_frames, fps):
@@ -31,7 +33,7 @@ def write_video(video_path, stacked_frames, fps):
imageio.mimsave(video_path, stacked_frames, fps=fps) imageio.mimsave(video_path, stacked_frames, fps=fps)
def deserialize_json_into_object[T: JsonLike](fpath: Path, obj: T) -> T: def deserialize_json_into_object(fpath: Path, obj: T) -> T:
""" """
Loads the JSON data from `fpath` and recursively fills `obj` with the Loads the JSON data from `fpath` and recursively fills `obj` with the
corresponding values (strictly matching structure and types). corresponding values (strictly matching structure and types).
+2 -4
View File
@@ -104,10 +104,9 @@ class MetricsTracker:
self.metrics = metrics self.metrics = metrics
self.steps = initial_step self.steps = initial_step
world_size = accelerator.num_processes if accelerator else 1
# A sample is an (observation,action) pair, where observation and action # A sample is an (observation,action) pair, where observation and action
# can be on multiple timestamps. In a batch, we have `batch_size` number of samples. # can be on multiple timestamps. In a batch, we have `batch_size` number of samples.
self.samples = self.steps * self._batch_size * world_size self.samples = self.steps * self._batch_size
self.episodes = self.samples / self._avg_samples_per_ep self.episodes = self.samples / self._avg_samples_per_ep
self.epochs = self.samples / self._num_frames self.epochs = self.samples / self._num_frames
self.accelerator = accelerator self.accelerator = accelerator
@@ -133,8 +132,7 @@ class MetricsTracker:
Updates metrics that depend on 'step' for one step. Updates metrics that depend on 'step' for one step.
""" """
self.steps += 1 self.steps += 1
world_size = self.accelerator.num_processes if self.accelerator else 1 self.samples += self._batch_size * (self.accelerator.num_processes if self.accelerator else 1)
self.samples += self._batch_size * world_size
self.episodes = self.samples / self._avg_samples_per_ep self.episodes = self.samples / self._avg_samples_per_ep
self.epochs = self.samples / self._num_frames self.epochs = self.samples / self._num_frames
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:54aecbc1af72a4cd5e9261492f5e7601890517516257aacdf2a0ffb3ce281f1b oid sha256:19eaaa85f66ba4aa6388dbb83819ffad6ea4363247208f871a8dc385689f6fc8
size 992 size 992
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:88a9c3775a2aa1e90a08850521970070a4fcf0f6b82aab43cd8ccc5cf77e0013 oid sha256:227296eaeeb54acdc3dae2eb8af3d4d08fb87e245337624447140b1e91cfd002
size 47424 size 47424
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:91a2635e05a75fe187a5081504c5f35ce3417378813fa2deaf9ca4e8200e1819 oid sha256:271b00cb2f0cd5fd26b1d53463638e3d1a6e92692ec625fcffb420ca190869e5
size 68 size 68

Some files were not shown because too many files have changed in this diff Show More