diff --git a/.github/workflows/unbound_deps_tests.yml b/.github/workflows/unbound_deps_tests.yml new file mode 100644 index 000000000..902074a83 --- /dev/null +++ b/.github/workflows/unbound_deps_tests.yml @@ -0,0 +1,183 @@ +# 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. + +# This workflow handles full testing with unboud dependencies versions. +name: Unbound Dependency Tests + +on: + # Allows running this workflow manually from the Actions tab + workflow_dispatch: + + # Run on the 1st and 15th of every month at 09:00 UTC + schedule: + - cron: '0 2 1,15 * *' + +permissions: + contents: read + +# Sets up the environment variables +env: + UV_VERSION: "0.8.0" + PYTHON_VERSION: "3.10" + DOCKER_IMAGE_NAME: huggingface/lerobot-gpu:unbound + +# Ensures that only the latest action is built, canceling older runs. +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + + # This job runs the E2E tests + pytest with all unbound extras + full-tests: + name: Full Unbound Tests + runs-on: ubuntu-latest + env: + MUJOCO_GL: egl + steps: + - uses: actions/checkout@v4 + with: + lfs: true + persist-credentials: false + + - name: Install apt dependencies + run: | + sudo apt-get update && sudo apt-get install -y build-essential \ + git curl libglib2.0-0 libegl1-mesa-dev ffmpeg libusb-1.0-0-dev \ + speech-dispatcher libgeos-dev portaudio19-dev + + - name: Setup uv and Python + uses: astral-sh/setup-uv@v6 # zizmor: ignore[unpinned-uses] + with: + enable-cache: true + version: ${{ env.UV_VERSION }} + python-version: ${{ env.PYTHON_VERSION }} + + - name: Unbound dependencies + run: | + sed -i 's/,[[:space:]]*<[0-9\.]*//g' pyproject.toml + echo "Dependencies unbound:" && cat pyproject.toml + + - name: Install lerobot with all extras + run: uv sync --all-extras + + - name: Run pytest (all extras) + run: uv run pytest tests -vv + + - name: Run end-to-end tests + run: uv run make test-end-to-end + + # This job builds a GPU enabled image for testing + build-and-push-docker: + name: Build and Push Docker + runs-on: + group: aws-general-8-plus + outputs: + image_tag: ${{ env.DOCKER_IMAGE_NAME }} + env: + GITHUB_REF: ${{ github.ref }} + steps: + - name: Install Git LFS + run: | + sudo apt-get update + sudo apt-get install git-lfs + git lfs install + - uses: actions/checkout@v4 + with: + lfs: true + persist-credentials: false + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 # zizmor: ignore[unpinned-uses] + with: + cache-binary: false + - name: Login to Docker Hub + uses: docker/login-action@v3 # zizmor: ignore[unpinned-uses] + with: + username: ${{ secrets.DOCKERHUB_LEROBOT_USERNAME }} + password: ${{ secrets.DOCKERHUB_LEROBOT_PASSWORD }} + - name: Build and push Docker image + uses: docker/build-push-action@v6 # zizmor: ignore[unpinned-uses] + with: + context: . + file: ./docker/Dockerfile.internal + push: true + tags: ${{ env.DOCKER_IMAGE_NAME }} + build-args: | + UNBOUND_DEPS=true + + # This job runs pytest with all unbound extras in a GPU enabled host + # It runs everytime a test image is created + gpu-tests: + name: GPU Unbound Tests + needs: [build-and-push-docker] + runs-on: + group: aws-g6-4xlarge-plus + env: + HF_HOME: /home/user_lerobot/.cache/huggingface + HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot + TORCH_HOME: /home/user_lerobot/.cache/torch + TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton + container: + image: ${{ needs.build-and-push-docker.outputs.image_tag }} # zizmor: ignore[unpinned-images] + options: --gpus all --shm-size "16gb" + credentials: + username: ${{ secrets.DOCKERHUB_LEROBOT_USERNAME }} + password: ${{ secrets.DOCKERHUB_LEROBOT_PASSWORD }} + defaults: + run: + shell: bash + working-directory: /lerobot + steps: + - name: Run pytest on GPU + run: pytest tests -vv + - name: Run end-to-end tests + run: make test-end-to-end + + # This job deletes the test image recently created + # It runs everytime after the gpu-tests have finished + delete-unbound-image: + name: Delete Unbound Image + needs: [gpu-tests, build-and-push-docker] + if: always() && needs.build-and-push-docker.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Get Docker Hub Token and Delete Image + # zizmor: ignore[template-injection] + run: | + IMAGE_NAME=$(echo "${{ needs.build-and-push-docker.outputs.image_tag }}" | cut -d':' -f1) + IMAGE_TAG=$(echo "${{ needs.build-and-push-docker.outputs.image_tag }}" | cut -d':' -f2) + + echo "Attempting to delete image: $IMAGE_NAME:$IMAGE_TAG" + + TOKEN=$(curl -s -H "Content-Type: application/json" \ + -X POST \ + -d '{"username": "${{ secrets.DOCKERHUB_LEROBOT_USERNAME }}", "password": "${{ secrets.DOCKERHUB_LEROBOT_PASSWORD }}"}' \ + https://hub.docker.com/v2/users/login/ | jq -r .token) + + if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then + echo "::error::Failed to get Docker Hub token." + exit 1 + fi + + HTTP_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: JWT ${TOKEN}" \ + -X DELETE \ + https://hub.docker.com/v2/repositories/${IMAGE_NAME}/tags/${IMAGE_TAG}/) + + if [ "$HTTP_RESPONSE" -eq 204 ]; then + echo "Successfully deleted Docker image tag: $IMAGE_NAME:$IMAGE_TAG" + else + echo "::error::Failed to delete Docker image. HTTP status: $HTTP_RESPONSE" + exit 1 + fi diff --git a/README.md b/README.md index a59f96deb..357e62cc1 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ wandb login ### Visualize datasets -Check out [example 1](https://github.com/huggingface/lerobot/blob/main/examples/1_load_lerobot_dataset.py) that illustrates how to use our dataset class which automatically downloads data from the Hugging Face hub. +Check out [example 1](https://github.com/huggingface/lerobot/blob/main/examples/dataset/load_lerobot_dataset.py) that illustrates how to use our dataset class which automatically downloads data from the Hugging Face hub. You can also locally visualize episodes from a dataset on the hub by executing our script from the command line: diff --git a/docker/Dockerfile.internal b/docker/Dockerfile.internal index 52becb830..2616cd06c 100644 --- a/docker/Dockerfile.internal +++ b/docker/Dockerfile.internal @@ -75,6 +75,14 @@ RUN uv venv --python python${PYTHON_VERSION} # Install Python dependencies for caching COPY --chown=user_lerobot:user_lerobot pyproject.toml README.md MANIFEST.in ./ COPY --chown=user_lerobot:user_lerobot src/ src/ + +ARG UNBOUND_DEPS=false + +RUN if [ "$UNBOUND_DEPS" = "true" ]; then \ + sed -i 's/,[[:space:]]*<[0-9\.]*//g' pyproject.toml; \ + echo "Dependencies unbound:" && cat pyproject.toml; \ + fi + RUN uv pip install --no-cache ".[all]" # Copy the rest of the application source code diff --git a/docker/Dockerfile.user b/docker/Dockerfile.user index 59fd3e0b3..c1b284453 100644 --- a/docker/Dockerfile.user +++ b/docker/Dockerfile.user @@ -61,6 +61,14 @@ RUN uv venv # Install Python dependencies for caching COPY --chown=user_lerobot:user_lerobot pyproject.toml README.md MANIFEST.in ./ COPY --chown=user_lerobot:user_lerobot src/ src/ + +ARG UNBOUND_DEPS=false + +RUN if [ "$UNBOUND_DEPS" = "true" ]; then \ + sed -i 's/,[[:space:]]*<[0-9\.]*//g' pyproject.toml; \ + echo "Dependencies unbound:" && cat pyproject.toml; \ + fi + RUN uv pip install --no-cache ".[all]" # Copy the rest of the application code diff --git a/docs/source/_toctree.yml b/docs/source/_toctree.yml index 9ee875f5c..350094f18 100644 --- a/docs/source/_toctree.yml +++ b/docs/source/_toctree.yml @@ -29,6 +29,8 @@ title: Porting Large Datasets title: "Datasets" - sections: + - local: act + title: ACT - local: smolvla title: SmolVLA - local: pi0 diff --git a/docs/source/act.mdx b/docs/source/act.mdx new file mode 100644 index 000000000..e3294ca69 --- /dev/null +++ b/docs/source/act.mdx @@ -0,0 +1,92 @@ +# ACT (Action Chunking with Transformers) + +ACT is a **lightweight and efficient policy for imitation learning**, especially well-suited for fine-grained manipulation tasks. It's the **first model we recommend when you're starting out** with LeRobot due to its fast training time, low computational requirements, and strong performance. + +
+ +
+ +_Watch this tutorial from the LeRobot team to learn how ACT works: [LeRobot ACT Tutorial](https://www.youtube.com/watch?v=ft73x0LfGpM)_ + +## Model Overview + +Action Chunking with Transformers (ACT) was introduced in the paper [Learning Fine-Grained Bimanual Manipulation with Low-Cost Hardware](https://arxiv.org/abs/2304.13705) by Zhao et al. The policy was designed to enable precise, contact-rich manipulation tasks using affordable hardware and minimal demonstration data. + +### Why ACT is Great for Beginners + +ACT stands out as an excellent starting point for several reasons: + +- **Fast Training**: Trains in a few hours on a single GPU +- **Lightweight**: Only ~80M parameters, making it efficient and easy to work with +- **Data Efficient**: Often achieves high success rates with just 50 demonstrations + +### Architecture + +ACT uses a transformer-based architecture with three main components: + +1. **Vision Backbone**: ResNet-18 processes images from multiple camera viewpoints +2. **Transformer Encoder**: Synthesizes information from camera features, joint positions, and a learned latent variable +3. **Transformer Decoder**: Generates coherent action sequences using cross-attention + +The policy takes as input: + +- Multiple RGB images (e.g., from wrist cameras, front/top cameras) +- Current robot joint positions +- A latent style variable `z` (learned during training, set to zero during inference) + +And outputs a chunk of `k` future action sequences. + +## Installation Requirements + +1. Install LeRobot by following our [Installation Guide](./installation). +2. ACT is included in the base LeRobot installation, so no additional dependencies are needed! + +## Training ACT + +ACT works seamlessly with the standard LeRobot training pipeline. Here's a complete example for training ACT on your dataset: + +```bash +lerobot-train \ + --dataset.repo_id=${HF_USER}/your_dataset \ + --policy.type=act \ + --output_dir=outputs/train/act_your_dataset \ + --job_name=act_your_dataset \ + --policy.device=cuda \ + --wandb.enable=true \ + --policy.repo_id=${HF_USER}/act_policy +``` + +### Training Tips + +1. **Start with defaults**: ACT's default hyperparameters work well for most tasks +2. **Training duration**: Expect a few hours for 100k training steps on a single GPU +3. **Batch size**: Start with batch size 8 and adjust based on your GPU memory + +### Train using Google Colab + +If your local computer doesn't have a powerful GPU, you can utilize Google Colab to train your model by following the [ACT training notebook](./notebooks#training-act). + +## Evaluating ACT + +Once training is complete, you can evaluate your ACT policy using the `lerobot-record` command with your trained policy. This will run inference and record evaluation episodes: + +```bash +lerobot-record \ + --robot.type=so100_follower \ + --robot.port=/dev/ttyACM0 \ + --robot.id=my_robot \ + --robot.cameras="{ front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}}" \ + --display_data=true \ + --dataset.repo_id=${HF_USER}/eval_act_your_dataset \ + --dataset.num_episodes=10 \ + --dataset.single_task="Your task description" \ + --policy.path=${HF_USER}/act_policy +``` diff --git a/docs/source/async.mdx b/docs/source/async.mdx index c66cdb143..be10f8baf 100644 --- a/docs/source/async.mdx +++ b/docs/source/async.mdx @@ -31,15 +31,15 @@ Then, spin up a policy server (in one terminal, or in a separate machine) specif You can spin up a policy server running: ```shell -python src/lerobot/async_inference/policy_server.py \ - --host=127.0.0.1 \ - --port=8080 \ +python -m lerobot.async_inference.policy_server \ + --host=127.0.0.1 \ + --port=8080 ``` This will start a policy server listening on `127.0.0.1:8080` (`localhost`, port 8080). At this stage, the policy server is empty, as all information related to which policy to run and with which parameters are specified during the first handshake with the client. Spin up a client with: ```shell -python src/lerobot/async_inference/robot_client.py \ +python -m lerobot.async_inference.robot_client \ --server_address=127.0.0.1:8080 \ # SERVER: the host address and port of the policy server --robot.type=so100_follower \ # ROBOT: your robot type --robot.port=/dev/tty.usbmodem585A0076841 \ # ROBOT: your robot port @@ -113,9 +113,9 @@ As such, spinning up a policy server is as easy as specifying the host address a ```bash -python -m lerobot.scripts.server.policy_server \ - --host="localhost" \ - --port=8080 +python -m lerobot.async_inference.policy_server \ + --host=127.0.0.1 \ + --port=8080 ``` @@ -148,7 +148,7 @@ The `RobotClient` streams observations to the `PolicyServer`, and receives actio ```bash -python src/lerobot/async_inference/robot_client.py \ +python -m lerobot.async_inference.robot_client \ --server_address=127.0.0.1:8080 \ # SERVER: the host address and port of the policy server --robot.type=so100_follower \ # ROBOT: your robot type --robot.port=/dev/tty.usbmodem585A0076841 \ # ROBOT: your robot port diff --git a/docs/source/integrate_hardware.mdx b/docs/source/integrate_hardware.mdx index 7b2e3833f..7e7fe0bff 100644 --- a/docs/source/integrate_hardware.mdx +++ b/docs/source/integrate_hardware.mdx @@ -335,6 +335,134 @@ For implementing teleoperation devices, we also provide a [`Teleoperator`](https The main differences are in the I/O functions: a teleoperator allows you to produce action via `get_action` and can receive feedback actions via `send_feedback`. Feedback could be anything controllable on the teleoperation device that could help the person controlling it understand the consequences of the actions sent. Think motion/force feedback on a leader arm, vibrations on a gamepad controller for example. To implement a teleoperator, you can follow this same tutorial and adapt it for these two methods. +## Using Your Own `LeRobot` Devices πŸ”Œ + +You can easily extend `lerobot` with your own custom hardwareβ€”be it a camera, robot, or teleoperation deviceβ€”by creating a separate, installable Python package. If you follow a few simple conventions, the `lerobot` command-line tools (like `lerobot-teleop` and `lerobot-record`) will **automatically discover and integrate your creations** without requiring any changes to the `lerobot` source code. + +This guide outlines the conventions your plugin must follow. + +### The 4 Core Conventions + +To ensure your custom device is discoverable, you must adhere to the following four rules. + +#### 1\. Create an Installable Package with a Specific Prefix + +Your project must be a standard, installable Python package. Crucially, the name of your package (as defined in `pyproject.toml` or `setup.py`) must begin with one of these prefixes: + +- `lerobot_robot_` for a robot. +- `lerobot_camera_` for a camera. +- `lerobot_teleoperator_` for a teleoperation device. + +This prefix system is how `lerobot` automatically finds your plugin in the Python environment. + +#### 2\. Follow the `SomethingConfig`/`Something` Naming Pattern + +Your device's implementation class must be named after its configuration class, simply by removing the `Config` suffix. + +- **Config Class:** `MyAwesomeTeleopConfig` +- **Device Class:** `MyAwesomeTeleop` + +#### 3\. Place Your Files in a Predictable Structure + +The device class (`MyAwesomeTeleop`) must be located in a predictable module relative to its configuration class (`MyAwesomeTeleopConfig`). `lerobot` will automatically search in these locations: + +- In the **same module** as the config class. +- In a **submodule named after the device** (e.g., `my_awesome_teleop.py`). + +The recommended and simplest structure is to place them in separate, clearly named files within the same directory. + +#### 4\. Expose Classes in `__init__.py` + +Your package's `__init__.py` file should import and expose both the configuration and the device classes, making them easily accessible. + +### Putting It All Together: A Complete Example + +Let's create a new teleoperator called `my_awesome_teleop`. + +#### Directory Structure + +Here is what the project folder should look like. The package name, `lerobot_teleoperator_my_awesome_teleop`, follows **Convention \#1**. + +``` +lerobot_teleoperator_my_awesome_teleop/ +β”œβ”€β”€ pyproject.toml # (or setup.py) lists lerobot as a dependency +└── lerobot_teleoperator_my_awesome_teleop/ + β”œβ”€β”€ __init__.py + β”œβ”€β”€ config_my_awesome_teleop.py + └── my_awesome_teleop.py +``` + +#### File Contents + +- **`config_my_awesome_teleop.py`**: Defines the configuration class. Note the `Config` suffix (**Convention \#2**). + + ```python + from dataclasses import dataclass + + from lerobot.teleoperators.config import TeleoperatorConfig + + @TeleoperatorConfig.register_subclass("my_awesome_teleop") + @dataclass + class MyAwesomeTeleopConfig(TeleoperatorConfig): + # Your configuration fields go here + port: str = "192.168.1.1" + ``` + +- **`my_awesome_teleop.py`**: Implements the device. The class name `MyAwesomeTeleop` matches its config class name (**Convention \#2**). This file structure adheres to **Convention \#3**. + + ```python + from lerobot.teleoperators.teleoperator import Teleoperator + + from .config_my_awesome_teleop import MyAwesomeTeleopConfig + + class MyAwesomeTeleop(Teleoperator): + config_class = MyAwesomeTeleopConfig + name = "my_awesome_teleop" + + def __init__(self, config: MyAwesomeTeleopConfig): + super().__init__(config) + self.config = config + + # Your device logic (e.g., connect) goes here + ``` + +- **`__init__.py`**: Exposes the key classes (**Convention \#4**). + + ```python + from .config_my_awesome_teleop import MyAwesomeTeleopConfig + from .my_awesome_teleop import MyAwesomeTeleop + ``` + +### Installation and Usage + +1. **Install your new plugin in your Python environment.** You can install your local plugin package using `pip`'s editable mode or from PyPi. + + ```bash + # Locally + # Navigate to your plugin's root directory and install it + cd lerobot_teleoperator_my_awesome_teleop + pip install -e . + + # From PyPi + pip install lerobot_teleoperator_my_awesome_teleop + ``` + +2. **Use it directly from the command line.** Now, you can use your custom device by referencing its type. + + ```bash + lerobot-teleoperate --teleop.type=my_awesome_teleop \ + # other arguments + ``` + +And that's it\! Your custom device is now fully integrated. + +### Looking for an example ? + +Check out these two packages from the community: + +- https://github.com/SpesRobotics/lerobot-robot-xarm +- https://github.com/SpesRobotics/lerobot-teleoperator-teleop + ## Wrapping Up Once your robot class is complete, you can leverage the LeRobot ecosystem: diff --git a/docs/source/pi0.mdx b/docs/source/pi0.mdx index 10260ee72..d36fe0ce4 100644 --- a/docs/source/pi0.mdx +++ b/docs/source/pi0.mdx @@ -49,7 +49,7 @@ policy.type=pi0 For training Ο€β‚€, you can use the standard LeRobot training script with the appropriate configuration: ```bash -python src/lerobot/scripts/train.py \ +python src/lerobot/scripts/lerobot_train.py \ --dataset.repo_id=your_dataset \ --policy.type=pi0 \ --output_dir=./outputs/pi0_training \ diff --git a/docs/source/pi05.mdx b/docs/source/pi05.mdx index b777fcd58..b6267fc5e 100644 --- a/docs/source/pi05.mdx +++ b/docs/source/pi05.mdx @@ -51,13 +51,13 @@ policy.type=pi05 Here's a complete training command for finetuning the base Ο€β‚€.β‚… model on your own dataset: ```bash -python src/lerobot/scripts/train.py \ +python src/lerobot/scripts/lerobot_train.py\ --dataset.repo_id=your_dataset \ --policy.type=pi05 \ - --output_dir=./outputs/pi0_training \ - --job_name=pi0_training \ - --policy.repo_id=lerobot/pi05_base \ - --policy.pretrained_path=your_repo_id \ + --output_dir=./outputs/pi05_training \ + --job_name=pi05_training \ + --policy.repo_id=your_repo_id \ + --policy.pretrained_path=lerobot/pi05_base \ --policy.compile_model=true \ --policy.gradient_checkpointing=true \ --wandb.enable=true \ @@ -77,6 +77,15 @@ python src/lerobot/scripts/train.py \ - [lerobot/pi05_base](https://huggingface.co/lerobot/pi05_base) - [lerobot/pi05_libero](https://huggingface.co/lerobot/pi05_libero) (specifically trained on the Libero dataset) +If your dataset is not converted with `quantiles`, you can convert it with the following command: + +```bash +python src/lerobot/datasets/v30/augment_dataset_quantile_stats.py \ + --repo-id=your_dataset \ +``` + +Or train pi05 with this normalization mapping: `--policy.normalization_mapping='{"ACTION": "MEAN_STD", "STATE": "MEAN_STD", "VISUAL": "IDENTITY"}'` + ## Performance Results ### Libero Benchmark Results diff --git a/pyproject.toml b/pyproject.toml index f639fa0a3..1fabbb0af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,20 +59,20 @@ keywords = ["lerobot", "huggingface", "robotics", "machine learning", "artifici dependencies = [ # Hugging Face dependencies - "datasets>=4.0.0", - "diffusers>=0.27.2", - "huggingface-hub[hf-transfer,cli]>=0.34.2", + "datasets>=4.0.0,<4.2.0", + "diffusers>=0.27.2,<0.36.0", + "huggingface-hub[hf-transfer,cli]>=0.34.2,<0.36.0", # Core dependencies - "cmake>=3.29.0.1", - "einops>=0.8.0", - "opencv-python-headless>=4.9.0", - "av>=14.2.0", - "jsonlines>=4.0.0", - "packaging>=24.2", - "pynput>=1.7.7", - "pyserial>=3.5", - "wandb>=0.20.0", + "cmake>=3.29.0.1,<4.2.0", + "einops>=0.8.0,<0.9.0", + "opencv-python-headless>=4.9.0,<4.13.0", + "av>=14.2.0,<16.0.0", + "jsonlines>=4.0.0,<5.0.0", + "packaging>=24.2,<26.0", + "pynput>=1.7.7,<1.9.0", + "pyserial>=3.5,<4.0", + "wandb>=0.20.0,<0.23.0", "torch>=2.2.1,<2.8.0", # TODO: Bumb dependency "torchcodec>=0.2.1,<0.6.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: Bumb dependency @@ -92,26 +92,26 @@ dependencies = [ [project.optional-dependencies] # Common -pygame-dep = ["pygame>=2.5.1"] -placo-dep = ["placo>=0.9.6"] -transformers-dep = ["transformers>=4.53.0"] +pygame-dep = ["pygame>=2.5.1,<2.7.0"] +placo-dep = ["placo>=0.9.6,<0.10.0"] +transformers-dep = ["transformers>=4.53.0,<5.0.0"] grpcio-dep = ["grpcio==1.73.1", "protobuf==6.31.0"] # Motors -feetech = ["feetech-servo-sdk>=1.0.0"] -dynamixel = ["dynamixel-sdk>=3.7.31"] +feetech = ["feetech-servo-sdk>=1.0.0,<2.0.0"] +dynamixel = ["dynamixel-sdk>=3.7.31,<3.9.0"] # Robots -gamepad = ["lerobot[pygame-dep]", "hidapi>=0.14.0"] +gamepad = ["lerobot[pygame-dep]", "hidapi>=0.14.0,<0.15.0"] hopejr = ["lerobot[feetech]", "lerobot[pygame-dep]"] -lekiwi = ["lerobot[feetech]", "pyzmq>=26.2.1"] -reachy2 = ["reachy2_sdk>=1.0.14"] +lekiwi = ["lerobot[feetech]", "pyzmq>=26.2.1,<28.0.0"] +reachy2 = ["reachy2_sdk>=1.0.14,<1.1.0"] kinematics = ["lerobot[placo-dep]"] intelrealsense = [ - "pyrealsense2>=2.55.1.6486 ; sys_platform != 'darwin'", - "pyrealsense2-macosx>=2.54 ; sys_platform == 'darwin'", + "pyrealsense2>=2.55.1.6486,<2.57.0 ; sys_platform != 'darwin'", + "pyrealsense2-macosx>=2.54,<2.55.0 ; sys_platform == 'darwin'", ] -phone = ["hebi-py>=2.8.0", "teleop>=0.1.0"] +phone = ["hebi-py>=2.8.0,<2.12.0", "teleop>=0.1.0,<0.2.0"] # stretch = [ # "hello-robot-stretch-body>=0.7.27 ; sys_platform == 'linux'", # "pyrender @ git+https://github.com/mmatl/pyrender.git ; sys_platform == 'linux'", @@ -120,22 +120,22 @@ phone = ["hebi-py>=2.8.0", "teleop>=0.1.0"] # Policies pi = ["transformers @ git+https://github.com/huggingface/transformers.git@fix/lerobot_openpi"] -smolvla = ["lerobot[transformers-dep]", "num2words>=0.5.14", "accelerate>=1.7.0", "safetensors>=0.4.3"] -hilserl = ["lerobot[transformers-dep]", "gym-hil>=0.1.11", "lerobot[grpcio-dep]", "lerobot[placo-dep]"] +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"] +hilserl = ["lerobot[transformers-dep]", "gym-hil>=0.1.11,<0.2.0", "lerobot[grpcio-dep]", "lerobot[placo-dep]"] # Features -async = ["lerobot[grpcio-dep]", "matplotlib>=3.10.3"] accelerate = ["accelerate>=1.10.0"] +async = ["lerobot[grpcio-dep]", "matplotlib>=3.10.3,<4.0.0"] # Development -dev = ["pre-commit>=3.7.0", "debugpy>=1.8.1", "lerobot[grpcio-dep]", "grpcio-tools==1.73.1"] -test = ["pytest>=8.1.0", "pytest-timeout>=2.4.0", "pytest-cov>=5.0.0", "mock-serial>=0.0.1 ; sys_platform != 'win32'"] -video_benchmark = ["scikit-image>=0.23.2", "pandas>=2.2.2"] +dev = ["pre-commit>=3.7.0,<5.0.0", "debugpy>=1.8.1,<1.9.0", "lerobot[grpcio-dep]", "grpcio-tools==1.73.1"] +test = ["pytest>=8.1.0,<9.0.0", "pytest-timeout>=2.4.0,<3.0.0", "pytest-cov>=5.0.0,<8.0.0", "mock-serial>=0.0.1,<0.1.0 ; sys_platform != 'win32'"] +video_benchmark = ["scikit-image>=0.23.2,<0.26.0", "pandas>=2.2.2,<2.4.0"] # Simulation -aloha = ["gym-aloha>=0.1.1"] -pusht = ["gym-pusht>=0.1.5", "pymunk>=6.6.0,<7.0.0"] # TODO: Fix pymunk version in gym-pusht instead -xarm = ["gym-xarm>=0.1.1"] +aloha = ["gym-aloha>=0.1.1,<0.2.0"] +pusht = ["gym-pusht>=0.1.5,<0.2.0", "pymunk>=6.6.0,<7.0.0"] # TODO: Fix pymunk version in gym-pusht instead +xarm = ["gym-xarm>=0.1.1,<0.2.0"] libero = ["lerobot[transformers-dep]", "libero @ git+https://github.com/huggingface/lerobot-libero.git@main#egg=libero"] diff --git a/src/lerobot/async_inference/constants.py b/src/lerobot/async_inference/constants.py index 5ebf3780c..1b1dac0f5 100644 --- a/src/lerobot/async_inference/constants.py +++ b/src/lerobot/async_inference/constants.py @@ -26,4 +26,4 @@ DEFAULT_OBS_QUEUE_TIMEOUT = 2 SUPPORTED_POLICIES = ["act", "smolvla", "diffusion", "tdmpc", "vqbet", "pi0", "pi05"] # TODO: Add all other robots -SUPPORTED_ROBOTS = ["so100_follower", "so101_follower"] +SUPPORTED_ROBOTS = ["so100_follower", "so101_follower", "bi_so100_follower"] diff --git a/src/lerobot/async_inference/helpers.py b/src/lerobot/async_inference/helpers.py index 88fb00a3f..54fad8c54 100644 --- a/src/lerobot/async_inference/helpers.py +++ b/src/lerobot/async_inference/helpers.py @@ -92,11 +92,11 @@ def resize_robot_observation_image(image: torch.tensor, resize_dims: tuple[int, return resized.squeeze(0) +# TODO(Steven): Consider implementing a pipeline step for this def raw_observation_to_observation( raw_observation: RawObservation, lerobot_features: dict[str, dict], policy_image_features: dict[str, PolicyFeature], - device: str, ) -> Observation: observation = {} @@ -105,9 +105,7 @@ def raw_observation_to_observation( if isinstance(v, torch.Tensor): # VLAs present natural-language instructions in observations if "image" in k: # Policy expects images in shape (B, C, H, W) - observation[k] = prepare_image(v).unsqueeze(0).to(device) - else: - observation[k] = v.to(device) + observation[k] = prepare_image(v).unsqueeze(0) else: observation[k] = v diff --git a/src/lerobot/async_inference/policy_server.py b/src/lerobot/async_inference/policy_server.py index 125727060..f7e00dea4 100644 --- a/src/lerobot/async_inference/policy_server.py +++ b/src/lerobot/async_inference/policy_server.py @@ -15,7 +15,7 @@ """ Example: ```shell -python src/lerobot/async_inference/policy_server.py \ +python -m lerobot.async_inference.policy_server \ --host=127.0.0.1 \ --port=8080 \ --fps=30 \ @@ -32,12 +32,17 @@ from concurrent import futures from dataclasses import asdict from pprint import pformat from queue import Empty, Queue +from typing import Any import draccus import grpc import torch -from lerobot.policies.factory import get_policy_class +from lerobot.policies.factory import get_policy_class, make_pre_post_processors +from lerobot.processor import ( + PolicyAction, + PolicyProcessorPipeline, +) from lerobot.transport import ( services_pb2, # type: ignore services_pb2_grpc, # type: ignore @@ -82,6 +87,8 @@ class PolicyServer(services_pb2_grpc.AsyncInferenceServicer): self.lerobot_features = None self.actions_per_chunk = None self.policy = None + self.preprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]] | None = None + self.postprocessor: PolicyProcessorPipeline[PolicyAction, PolicyAction] | None = None @property def running(self): @@ -146,6 +153,16 @@ class PolicyServer(services_pb2_grpc.AsyncInferenceServicer): start = time.perf_counter() self.policy = policy_class.from_pretrained(policy_specs.pretrained_name_or_path) self.policy.to(self.device) + + # Load preprocessor and postprocessor, overriding device to match requested device + device_override = {"device": self.device} + self.preprocessor, self.postprocessor = make_pre_post_processors( + self.policy.config, + pretrained_path=policy_specs.pretrained_name_or_path, + preprocessor_overrides={"device_processor": device_override}, + postprocessor_overrides={"device_processor": device_override}, + ) + end = time.perf_counter() self.logger.info(f"Time taken to put policy on {self.device}: {end - start:.4f} seconds") @@ -173,7 +190,7 @@ class PolicyServer(services_pb2_grpc.AsyncInferenceServicer): # Calculate FPS metrics fps_metrics = self.fps_tracker.calculate_fps_metrics(obs_timestamp) - self.logger.info( + self.logger.debug( f"Received observation #{obs_timestep} | " f"Avg FPS: {fps_metrics['avg_fps']:.2f} | " # fps at which observations are received from client f"Target: {fps_metrics['target_fps']:.2f} | " @@ -189,7 +206,7 @@ class PolicyServer(services_pb2_grpc.AsyncInferenceServicer): if not self._enqueue_observation( timed_observation # wrapping a RawObservation ): - self.logger.info(f"Observation #{obs_timestep} has been filtered out") + self.logger.debug(f"Observation #{obs_timestep} has been filtered out") return services_pb2.Empty() @@ -301,23 +318,6 @@ class PolicyServer(services_pb2_grpc.AsyncInferenceServicer): for i, action in enumerate(action_chunk) ] - def _prepare_observation(self, observation_t: TimedObservation) -> Observation: - """ - Prepare observation, ready for policy inference. - E.g.: To keep observation sampling rate high (and network packet tiny) we send int8 [0,255] images from the - client and then convert them to float32 [0,1] images here, before running inference. - """ - # RawObservation from robot.get_observation() - wrong keys, wrong dtype, wrong image shape - observation: Observation = raw_observation_to_observation( - observation_t.get_observation(), - self.lerobot_features, - self.policy_image_features, - self.device, - ) - # processed Observation - right keys, right dtype, right image shape - - return observation - def _get_action_chunk(self, observation: dict[str, torch.Tensor]) -> torch.Tensor: """Get an action chunk from the policy. The chunk contains only""" chunk = self.policy.predict_action_chunk(observation) @@ -327,44 +327,76 @@ class PolicyServer(services_pb2_grpc.AsyncInferenceServicer): return chunk[:, : self.actions_per_chunk, :] def _predict_action_chunk(self, observation_t: TimedObservation) -> list[TimedAction]: - """Predict an action chunk based on an observation""" - inference_starts = time.perf_counter() + """Predict an action chunk based on an observation. + Pipeline: + 1. Convert raw observation to LeRobot format + 2. Apply preprocessor (tokenization, normalization, batching, device placement) + 3. Run policy inference to get action chunk + 4. Apply postprocessor (unnormalization, device movement) + 5. Convert to TimedAction list + """ """1. Prepare observation""" - start_time = time.perf_counter() - observation = self._prepare_observation(observation_t) - preprocessing_time = time.perf_counter() - start_time + start_prepare = time.perf_counter() + observation: Observation = raw_observation_to_observation( + observation_t.get_observation(), + self.lerobot_features, + self.policy_image_features, + ) + prepare_time = time.perf_counter() - start_prepare + """2. Apply preprocessor""" + start_preprocess = time.perf_counter() + observation = self.preprocessor(observation) self.last_processed_obs: TimedObservation = observation_t + preprocessing_time = time.perf_counter() - start_preprocess - """2. Get action chunk""" - start_time = time.perf_counter() + """3. Get action chunk""" + start_inference = time.perf_counter() action_tensor = self._get_action_chunk(observation) - inference_time = time.perf_counter() - start_time + inference_time = time.perf_counter() - start_inference + self.logger.info( + f"Preprocessing and inference took {inference_time:.4f}s, action shape: {action_tensor.shape}" + ) - """3. Post-inference processing""" - start_time = time.perf_counter() - # Move to CPU before serializing - action_tensor = action_tensor.cpu().squeeze(0) + """4. Apply postprocessor""" + # Apply postprocessor (handles unnormalization and device movement) + # Postprocessor expects (B, action_dim) per action, but we have (B, chunk_size, action_dim) + # So we process each action in the chunk individually + start_postprocess = time.perf_counter() + _, chunk_size, _ = action_tensor.shape + # Process each action in the chunk + processed_actions = [] + for i in range(chunk_size): + # Extract action at timestep i: (B, action_dim) + single_action = action_tensor[:, i, :] + processed_action = self.postprocessor(single_action) + processed_actions.append(processed_action) + + # Stack back to (B, chunk_size, action_dim), then remove batch dim + action_tensor = torch.stack(processed_actions, dim=1).squeeze(0) + self.logger.debug(f"Postprocessed action shape: {action_tensor.shape}") + + """5. Convert to TimedAction list""" action_chunk = self._time_action_chunk( observation_t.get_timestamp(), list(action_tensor), observation_t.get_timestep() ) - postprocessing_time = time.perf_counter() - start_time - inference_stops = time.perf_counter() + postprocess_stops = time.perf_counter() + postprocessing_time = postprocess_stops - start_postprocess self.logger.info( - f"Observation {observation_t.get_timestep()} |" - f"Inference time: {1000 * (inference_stops - inference_starts):.2f}ms" + f"Observation {observation_t.get_timestep()} | " + f"Total time: {1000 * (postprocess_stops - start_prepare):.2f}ms" ) - # full-process latency breakdown for debugging purposes self.logger.debug( f"Observation {observation_t.get_timestep()} | " - f"Preprocessing time: {1000 * (preprocessing_time - inference_starts):.2f}ms | " - f"Inference time: {1000 * (inference_time - preprocessing_time):.2f}ms | " - f"Postprocessing time: {1000 * (postprocessing_time - inference_time):.2f}ms | " - f"Total time: {1000 * (postprocessing_time - inference_starts):.2f}ms" + f"Prepare time: {1000 * prepare_time:.2f}ms | " + f"Preprocessing time: {1000 * preprocessing_time:.2f}ms | " + f"Inference time: {1000 * inference_time:.2f}ms | " + f"Postprocessing time: {1000 * postprocessing_time:.2f}ms | " + f"Total time: {1000 * (postprocess_stops - start_prepare):.2f}ms" ) return action_chunk diff --git a/src/lerobot/async_inference/robot_client.py b/src/lerobot/async_inference/robot_client.py index c969bc605..8c4425c6b 100644 --- a/src/lerobot/async_inference/robot_client.py +++ b/src/lerobot/async_inference/robot_client.py @@ -52,6 +52,7 @@ from lerobot.configs.policies import PreTrainedConfig from lerobot.robots import ( # noqa: F401 Robot, RobotConfig, + bi_so100_follower, koch_follower, make_robot_from_config, so100_follower, @@ -214,7 +215,7 @@ class RobotClient: ) _ = self.stub.SendObservations(observation_iterator) obs_timestep = obs.get_timestep() - self.logger.info(f"Sent observation #{obs_timestep} | ") + self.logger.debug(f"Sent observation #{obs_timestep} | ") return True @@ -467,7 +468,7 @@ class RobotClient: if self._ready_to_send_observation(): _captured_observation = self.control_loop_observation(task, verbose) - self.logger.info(f"Control loop (ms): {(time.perf_counter() - control_loop_start) * 1000:.2f}") + self.logger.debug(f"Control loop (ms): {(time.perf_counter() - control_loop_start) * 1000:.2f}") # Dynamically adjust sleep time to maintain the desired control frequency time.sleep(max(0, self.config.environment_dt - (time.perf_counter() - control_loop_start))) diff --git a/src/lerobot/cameras/utils.py b/src/lerobot/cameras/utils.py index 4a23843b2..aa6ff98b4 100644 --- a/src/lerobot/cameras/utils.py +++ b/src/lerobot/cameras/utils.py @@ -15,15 +15,19 @@ # limitations under the License. import platform +from typing import cast + +from lerobot.utils.import_utils import make_device_from_device_class from .camera import Camera from .configs import CameraConfig, Cv2Rotation def make_cameras_from_configs(camera_configs: dict[str, CameraConfig]) -> dict[str, Camera]: - cameras = {} + cameras: dict[str, Camera] = {} for key, cfg in camera_configs.items(): + # TODO(Steven): Consider just using the make_device_from_device_class for all types if cfg.type == "opencv": from .opencv import OpenCVCamera @@ -40,7 +44,10 @@ def make_cameras_from_configs(camera_configs: dict[str, CameraConfig]) -> dict[s cameras[key] = Reachy2Camera(cfg) else: - raise ValueError(f"The camera type '{cfg.type}' is not valid.") + try: + cameras[key] = cast(Camera, make_device_from_device_class(cfg)) + except Exception as e: + raise ValueError(f"Error creating camera {key} with config {cfg}: {e}") from e return cameras diff --git a/src/lerobot/datasets/backward_compatibility.py b/src/lerobot/datasets/backward_compatibility.py index 1d600434a..ae95c5f7b 100644 --- a/src/lerobot/datasets/backward_compatibility.py +++ b/src/lerobot/datasets/backward_compatibility.py @@ -23,6 +23,9 @@ Please, update your dataset to the new format using this command: python -m lerobot.datasets.v30.convert_dataset_v21_to_v30 --repo-id={repo_id} ``` +If you already have a converted version uploaded to the hub, then this error might be because of +an older version in your local cache. Consider deleting the cached version and retrying. + If you encounter a problem, contact LeRobot maintainers on [Discord](https://discord.com/invite/s3KuuzsPFb) or open an [issue on GitHub](https://github.com/huggingface/lerobot/issues/new/choose). """ diff --git a/src/lerobot/datasets/image_writer.py b/src/lerobot/datasets/image_writer.py index 4a4e1ab05..ee10df6e1 100644 --- a/src/lerobot/datasets/image_writer.py +++ b/src/lerobot/datasets/image_writer.py @@ -68,7 +68,30 @@ def image_array_to_pil_image(image_array: np.ndarray, range_check: bool = True) return PIL.Image.fromarray(image_array) -def write_image(image: np.ndarray | PIL.Image.Image, fpath: Path): +def write_image(image: np.ndarray | PIL.Image.Image, fpath: Path, compress_level: int = 1): + """ + Saves a NumPy array or PIL Image to a file. + + This function handles both NumPy arrays and PIL Image objects, converting + the former to a PIL Image before saving. It includes error handling for + the save operation. + + Args: + image (np.ndarray | PIL.Image.Image): The image data to save. + fpath (Path): The destination file path for the image. + compress_level (int, optional): The compression level for the saved + image, as used by PIL.Image.save(). Defaults to 1. + Refer to: https://github.com/huggingface/lerobot/pull/2135 + for more details on the default value rationale. + + Raises: + TypeError: If the input 'image' is not a NumPy array or a + PIL.Image.Image object. + + Side Effects: + Prints an error message to the console if the image writing process + fails for any reason. + """ try: if isinstance(image, np.ndarray): img = image_array_to_pil_image(image) @@ -76,7 +99,7 @@ def write_image(image: np.ndarray | PIL.Image.Image, fpath: Path): img = image else: raise TypeError(f"Unsupported image type: {type(image)}") - img.save(fpath) + img.save(fpath, compress_level=compress_level) except Exception as e: print(f"Error writing image {fpath}: {e}") diff --git a/src/lerobot/datasets/v30/augment_dataset_quantile_stats.py b/src/lerobot/datasets/v30/augment_dataset_quantile_stats.py index ff4689efa..900a43a4f 100644 --- a/src/lerobot/datasets/v30/augment_dataset_quantile_stats.py +++ b/src/lerobot/datasets/v30/augment_dataset_quantile_stats.py @@ -40,10 +40,12 @@ from pathlib import Path import numpy as np import torch +from huggingface_hub import HfApi +from requests import HTTPError from tqdm import tqdm from lerobot.datasets.compute_stats import DEFAULT_QUANTILES, aggregate_stats, get_feature_stats -from lerobot.datasets.lerobot_dataset import LeRobotDataset +from lerobot.datasets.lerobot_dataset import CODEBASE_VERSION, LeRobotDataset from lerobot.datasets.utils import write_stats from lerobot.utils.utils import init_logging @@ -85,13 +87,27 @@ def process_single_episode(dataset: LeRobotDataset, episode_idx: int) -> dict: start_idx = dataset.meta.episodes[episode_idx]["dataset_from_index"] end_idx = dataset.meta.episodes[episode_idx]["dataset_to_index"] + collected_data: dict[str, list] = {} + for idx in range(start_idx, end_idx): + item = dataset[idx] + for key, value in item.items(): + if key not in dataset.features: + continue + + if key not in collected_data: + collected_data[key] = [] + collected_data[key].append(value) + ep_stats = {} - for key, data in dataset.hf_dataset[start_idx:end_idx].items(): + for key, data_list in collected_data.items(): if dataset.features[key]["dtype"] == "string": continue - data = torch.stack(data).cpu().numpy() + data = torch.stack(data_list).cpu().numpy() if dataset.features[key]["dtype"] in ["image", "video"]: + if data.dtype == np.uint8: + data = data.astype(np.float32) / 255.0 + axes_to_reduce = (0, 2, 3) keepdims = True else: @@ -103,12 +119,9 @@ def process_single_episode(dataset: LeRobotDataset, episode_idx: int) -> dict: ) if dataset.features[key]["dtype"] in ["image", "video"]: - for k, v in ep_stats[key].items(): - if dataset.features[key]["dtype"] == "video": - v = v / 255.0 - if k != "count": - v = np.squeeze(v, axis=0) - ep_stats[key][k] = v + ep_stats[key] = { + k: v if k == "count" else np.squeeze(v, axis=0) for k, v in ep_stats[key].items() + } return ep_stats @@ -121,25 +134,39 @@ def compute_quantile_stats_for_dataset(dataset: LeRobotDataset) -> dict[str, dic Returns: Dictionary containing aggregated statistics with quantiles + + Note: + Video decoding operations are not thread-safe, so we process episodes sequentially + when video keys are present. For datasets without videos, we use parallel processing + with ThreadPoolExecutor for better performance. """ logging.info(f"Computing quantile statistics for dataset with {dataset.num_episodes} episodes") episode_stats_list = [] - max_workers = min(dataset.num_episodes, 16) + has_videos = len(dataset.meta.video_keys) > 0 - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - future_to_episode = { - executor.submit(process_single_episode, dataset, episode_idx): episode_idx - for episode_idx in range(dataset.num_episodes) - } + if has_videos: + logging.info("Dataset contains video keys - using sequential processing for thread safety") + for episode_idx in tqdm(range(dataset.num_episodes), desc="Processing episodes"): + ep_stats = process_single_episode(dataset, episode_idx) + episode_stats_list.append(ep_stats) + else: + logging.info("Dataset has no video keys - using parallel processing for better performance") + max_workers = min(dataset.num_episodes, 16) - episode_results = {} - with tqdm(total=dataset.num_episodes, desc="Processing episodes") as pbar: - for future in concurrent.futures.as_completed(future_to_episode): - episode_idx = future_to_episode[future] - ep_stats = future.result() - episode_results[episode_idx] = ep_stats - pbar.update(1) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_episode = { + executor.submit(process_single_episode, dataset, episode_idx): episode_idx + for episode_idx in range(dataset.num_episodes) + } + + episode_results = {} + with tqdm(total=dataset.num_episodes, desc="Processing episodes") as pbar: + for future in concurrent.futures.as_completed(future_to_episode): + episode_idx = future_to_episode[future] + ep_stats = future.result() + episode_results[episode_idx] = ep_stats + pbar.update(1) for episode_idx in range(dataset.num_episodes): if episode_idx in episode_results: @@ -186,6 +213,14 @@ def augment_dataset_with_quantile_stats( logging.info("Successfully updated dataset with quantile statistics") dataset.push_to_hub() + hub_api = HfApi() + try: + hub_api.delete_tag(repo_id, tag=CODEBASE_VERSION, repo_type="dataset") + except HTTPError as e: + logging.info(f"tag={CODEBASE_VERSION} probably doesn't exist. Skipping exception ({e})") + pass + hub_api.create_tag(repo_id, tag=CODEBASE_VERSION, revision=None, repo_type="dataset") + def main(): """Main function to run the augmentation script.""" diff --git a/src/lerobot/datasets/v30/convert_dataset_v21_to_v30.py b/src/lerobot/datasets/v30/convert_dataset_v21_to_v30.py index 03d135d7c..42ab2f642 100644 --- a/src/lerobot/datasets/v30/convert_dataset_v21_to_v30.py +++ b/src/lerobot/datasets/v30/convert_dataset_v21_to_v30.py @@ -26,11 +26,20 @@ This script will help you convert any LeRobot dataset already pushed to the hub Usage: +Convert a dataset from the hub: ```bash python src/lerobot/datasets/v30/convert_dataset_v21_to_v30.py \ --repo-id=lerobot/pusht ``` +Convert a local dataset (works in place): +```bash +python src/lerobot/datasets/v30/convert_dataset_v21_to_v30.py \ + --repo-id=lerobot/pusht \ + --root=/path/to/local/dataset/directory + --push-to-hub=false +``` + """ import argparse @@ -75,7 +84,7 @@ from lerobot.utils.constants import HF_LEROBOT_HOME from lerobot.utils.utils import init_logging V21 = "v2.1" - +V30 = "v3.0" """ ------------------------- @@ -145,6 +154,17 @@ def legacy_load_tasks(local_dir: Path) -> tuple[dict, dict]: return tasks, task_to_task_index +def validate_local_dataset_version(local_path: Path) -> None: + """Validate that the local dataset has the expected v2.1 version.""" + info = load_info(local_path) + dataset_version = info.get("codebase_version", "unknown") + if dataset_version != V21: + raise ValueError( + f"Local dataset has codebase version '{dataset_version}', expected '{V21}'. " + f"This script is specifically for converting v2.1 datasets to v3.0." + ) + + def convert_tasks(root, new_root): logging.info(f"Converting tasks from {root} to {new_root}") tasks, _ = legacy_load_tasks(root) @@ -407,7 +427,7 @@ def convert_episodes_metadata(root, new_root, episodes_metadata, episodes_video_ def convert_info(root, new_root, data_file_size_in_mb, video_file_size_in_mb): info = load_info(root) - info["codebase_version"] = "v3.0" + info["codebase_version"] = V30 del info["total_chunks"] del info["total_videos"] info["data_files_size_in_mb"] = data_file_size_in_mb @@ -429,16 +449,36 @@ def convert_dataset( branch: str | None = None, data_file_size_in_mb: int | None = None, video_file_size_in_mb: int | None = None, + root: str | Path | None = None, + push_to_hub: bool = True, + force_conversion: bool = False, ): - root = HF_LEROBOT_HOME / repo_id - old_root = HF_LEROBOT_HOME / f"{repo_id}_old" - new_root = HF_LEROBOT_HOME / f"{repo_id}_v30" - if data_file_size_in_mb is None: data_file_size_in_mb = DEFAULT_DATA_FILE_SIZE_IN_MB if video_file_size_in_mb is None: video_file_size_in_mb = DEFAULT_VIDEO_FILE_SIZE_IN_MB + # First check if the dataset already has a v3.0 version + if root is None and not force_conversion: + try: + print("Trying to download v3.0 version of the dataset from the hub...") + snapshot_download(repo_id, repo_type="dataset", revision=V30, local_dir=HF_LEROBOT_HOME / repo_id) + return + except Exception: + print("Dataset does not have an uploaded v3.0 version. Continuing with conversion.") + + # Set root based on whether local dataset path is provided + use_local_dataset = False + root = HF_LEROBOT_HOME / repo_id if root is None else Path(root) / repo_id + if root.exists(): + validate_local_dataset_version(root) + use_local_dataset = True + print(f"Using local dataset at {root}") + + old_root = root.parent / f"{root.name}_old" + new_root = root.parent / f"{root.name}_v30" + + # Handle old_root cleanup if both old_root and root exist if old_root.is_dir() and root.is_dir(): shutil.rmtree(str(root)) shutil.move(str(old_root), str(root)) @@ -446,12 +486,13 @@ def convert_dataset( if new_root.is_dir(): shutil.rmtree(new_root) - snapshot_download( - repo_id, - repo_type="dataset", - revision=V21, - local_dir=root, - ) + if not use_local_dataset: + snapshot_download( + repo_id, + repo_type="dataset", + revision=V21, + local_dir=root, + ) convert_info(root, new_root, data_file_size_in_mb, video_file_size_in_mb) convert_tasks(root, new_root) @@ -462,21 +503,22 @@ def convert_dataset( shutil.move(str(root), str(old_root)) shutil.move(str(new_root), str(root)) - hub_api = HfApi() - try: - hub_api.delete_tag(repo_id, tag=CODEBASE_VERSION, repo_type="dataset") - except HTTPError as e: - print(f"tag={CODEBASE_VERSION} probably doesn't exist. Skipping exception ({e})") - pass - hub_api.delete_files( - delete_patterns=["data/chunk*/episode_*", "meta/*.jsonl", "videos/chunk*"], - repo_id=repo_id, - revision=branch, - repo_type="dataset", - ) - hub_api.create_tag(repo_id, tag=CODEBASE_VERSION, revision=branch, repo_type="dataset") + if push_to_hub: + hub_api = HfApi() + try: + hub_api.delete_tag(repo_id, tag=CODEBASE_VERSION, repo_type="dataset") + except HTTPError as e: + print(f"tag={CODEBASE_VERSION} probably doesn't exist. Skipping exception ({e})") + pass + hub_api.delete_files( + delete_patterns=["data/chunk*/episode_*", "meta/*.jsonl", "videos/chunk*"], + repo_id=repo_id, + revision=branch, + repo_type="dataset", + ) + hub_api.create_tag(repo_id, tag=CODEBASE_VERSION, revision=branch, repo_type="dataset") - LeRobotDataset(repo_id).push_to_hub() + LeRobotDataset(repo_id).push_to_hub() if __name__ == "__main__": @@ -507,6 +549,23 @@ if __name__ == "__main__": default=None, help="File size in MB. Defaults to 100 for data and 500 for videos.", ) + parser.add_argument( + "--root", + type=str, + default=None, + help="Local directory to use for downloading/writing the dataset.", + ) + parser.add_argument( + "--push-to-hub", + type=lambda input: input.lower() == "true", + default=True, + help="Push the converted dataset to the hub.", + ) + parser.add_argument( + "--force-conversion", + action="store_true", + help="Force conversion even if the dataset already has a v3.0 version.", + ) args = parser.parse_args() convert_dataset(**vars(args)) diff --git a/src/lerobot/datasets/video_utils.py b/src/lerobot/datasets/video_utils.py index 2c0e116cb..1d4f07c76 100644 --- a/src/lerobot/datasets/video_utils.py +++ b/src/lerobot/datasets/video_utils.py @@ -451,11 +451,6 @@ def concatenate_video_files( stream_map[input_stream.index] = output_container.add_stream_from_template( template=input_stream, opaque=True ) - stream_map[ - input_stream.index - ].time_base = ( - input_stream.time_base - ) # set the time base to the input stream time base (missing in the codec context) # Demux + remux packets (no re-encode) for packet in input_container.demux(): diff --git a/src/lerobot/motors/__init__.py b/src/lerobot/motors/__init__.py index dfbfbaee8..850ef33d7 100644 --- a/src/lerobot/motors/__init__.py +++ b/src/lerobot/motors/__init__.py @@ -1 +1,17 @@ +#!/usr/bin/env python + +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from .motors_bus import Motor, MotorCalibration, MotorNormMode, MotorsBus diff --git a/src/lerobot/processor/policy_robot_bridge.py b/src/lerobot/processor/policy_robot_bridge.py index 845ee065a..25887d414 100644 --- a/src/lerobot/processor/policy_robot_bridge.py +++ b/src/lerobot/processor/policy_robot_bridge.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python + +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from dataclasses import asdict, dataclass from typing import Any diff --git a/src/lerobot/robots/__init__.py b/src/lerobot/robots/__init__.py index d8fd0de93..1dba0f1b0 100644 --- a/src/lerobot/robots/__init__.py +++ b/src/lerobot/robots/__init__.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python + +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from .config import RobotConfig from .robot import Robot from .utils import make_robot_from_config diff --git a/src/lerobot/robots/utils.py b/src/lerobot/robots/utils.py index 0455bce3f..aca5c8716 100644 --- a/src/lerobot/robots/utils.py +++ b/src/lerobot/robots/utils.py @@ -14,13 +14,16 @@ import logging from pprint import pformat +from typing import cast -from lerobot.robots import RobotConfig +from lerobot.utils.import_utils import make_device_from_device_class +from .config import RobotConfig from .robot import Robot def make_robot_from_config(config: RobotConfig) -> Robot: + # TODO(Steven): Consider just using the make_device_from_device_class for all types if config.type == "koch_follower": from .koch_follower import KochFollower @@ -66,7 +69,10 @@ def make_robot_from_config(config: RobotConfig) -> Robot: return MockRobot(config) else: - raise ValueError(config.type) + try: + return cast(Robot, make_device_from_device_class(config)) + except Exception as e: + raise ValueError(f"Error creating robot with config {config}: {e}") from e # TODO(pepijn): Move to pipeline step to make sure we don't have to do this in the robot code and send action to robot is clean for use in dataset diff --git a/src/lerobot/scripts/lerobot_calibrate.py b/src/lerobot/scripts/lerobot_calibrate.py index 0aa61a2f9..0f247caef 100644 --- a/src/lerobot/scripts/lerobot_calibrate.py +++ b/src/lerobot/scripts/lerobot_calibrate.py @@ -52,6 +52,7 @@ from lerobot.teleoperators import ( # noqa: F401 so100_leader, so101_leader, ) +from lerobot.utils.import_utils import register_third_party_devices from lerobot.utils.utils import init_logging @@ -83,6 +84,7 @@ def calibrate(cfg: CalibrateConfig): def main(): + register_third_party_devices() calibrate() diff --git a/src/lerobot/scripts/lerobot_record.py b/src/lerobot/scripts/lerobot_record.py index ddb21e917..55846ff63 100644 --- a/src/lerobot/scripts/lerobot_record.py +++ b/src/lerobot/scripts/lerobot_record.py @@ -117,6 +117,7 @@ from lerobot.utils.control_utils import ( sanity_check_dataset_name, sanity_check_dataset_robot_compatibility, ) +from lerobot.utils.import_utils import register_third_party_devices from lerobot.utils.robot_utils import busy_wait from lerobot.utils.utils import ( get_safe_torch_device, @@ -513,6 +514,7 @@ def record(cfg: RecordConfig) -> LeRobotDataset: def main(): + register_third_party_devices() record() diff --git a/src/lerobot/scripts/lerobot_replay.py b/src/lerobot/scripts/lerobot_replay.py index b899745b6..ffd7b2b22 100644 --- a/src/lerobot/scripts/lerobot_replay.py +++ b/src/lerobot/scripts/lerobot_replay.py @@ -61,6 +61,7 @@ from lerobot.robots import ( # noqa: F401 so101_follower, ) from lerobot.utils.constants import ACTION +from lerobot.utils.import_utils import register_third_party_devices from lerobot.utils.robot_utils import busy_wait from lerobot.utils.utils import ( init_logging, @@ -126,6 +127,7 @@ def replay(cfg: ReplayConfig): def main(): + register_third_party_devices() replay() diff --git a/src/lerobot/scripts/lerobot_teleoperate.py b/src/lerobot/scripts/lerobot_teleoperate.py index ab9a6361d..0a418f3bc 100644 --- a/src/lerobot/scripts/lerobot_teleoperate.py +++ b/src/lerobot/scripts/lerobot_teleoperate.py @@ -88,6 +88,7 @@ from lerobot.teleoperators import ( # noqa: F401 so100_leader, so101_leader, ) +from lerobot.utils.import_utils import register_third_party_devices from lerobot.utils.robot_utils import busy_wait from lerobot.utils.utils import init_logging, move_cursor_up from lerobot.utils.visualization_utils import init_rerun, log_rerun_data @@ -215,6 +216,7 @@ def teleoperate(cfg: TeleoperateConfig): def main(): + register_third_party_devices() teleoperate() diff --git a/src/lerobot/teleoperators/utils.py b/src/lerobot/teleoperators/utils.py index bad7d9c37..ada7ee8a1 100644 --- a/src/lerobot/teleoperators/utils.py +++ b/src/lerobot/teleoperators/utils.py @@ -13,6 +13,9 @@ # limitations under the License. from enum import Enum +from typing import cast + +from lerobot.utils.import_utils import make_device_from_device_class from .config import TeleoperatorConfig from .teleoperator import Teleoperator @@ -29,6 +32,7 @@ class TeleopEvents(Enum): def make_teleoperator_from_config(config: TeleoperatorConfig) -> Teleoperator: + # TODO(Steven): Consider just using the make_device_from_device_class for all types if config.type == "keyboard": from .keyboard import KeyboardTeleop @@ -82,4 +86,7 @@ def make_teleoperator_from_config(config: TeleoperatorConfig) -> Teleoperator: return Reachy2Teleoperator(config) else: - raise ValueError(config.type) + try: + return cast(Teleoperator, make_device_from_device_class(config)) + except Exception as e: + raise ValueError(f"Error creating robot with config {config}: {e}") from e diff --git a/src/lerobot/utils/import_utils.py b/src/lerobot/utils/import_utils.py index 5f41ea3a3..de43e58db 100644 --- a/src/lerobot/utils/import_utils.py +++ b/src/lerobot/utils/import_utils.py @@ -15,6 +15,10 @@ # limitations under the License. import importlib import logging +import pkgutil +from typing import Any + +from draccus.choice_types import ChoiceRegistry def is_package_available(pkg_name: str, return_version: bool = False) -> tuple[bool, str] | bool: @@ -58,3 +62,93 @@ def is_package_available(pkg_name: str, return_version: bool = False) -> tuple[b _transformers_available = is_package_available("transformers") + + +def make_device_from_device_class(config: ChoiceRegistry) -> Any: + """ + Dynamically instantiates an object from its `ChoiceRegistry` configuration. + + This factory uses the module path and class name from the `config` object's + type to locate and instantiate the corresponding device class (not the config). + It derives the device class name by removing a trailing 'Config' from the config + class name and tries a few candidate modules where the device implementation is + commonly located. + """ + if not isinstance(config, ChoiceRegistry): + raise ValueError(f"Config should be an instance of `ChoiceRegistry`, got {type(config)}") + + config_cls = config.__class__ + module_path = config_cls.__module__ # typical: lerobot_teleop_mydevice.config_mydevice + config_name = config_cls.__name__ # typical: MyDeviceConfig + + # Derive device class name (strip "Config") + if not config_name.endswith("Config"): + raise ValueError(f"Config class name '{config_name}' does not end with 'Config'") + + device_class_name = config_name[:-6] # typical: MyDeviceConfig -> MyDevice + + # Build candidate modules to search for the device class + parts = module_path.split(".") + parent_module = ".".join(parts[:-1]) if len(parts) > 1 else module_path + candidates = [ + parent_module, # typical: lerobot_teleop_mydevice + parent_module + "." + device_class_name.lower(), # typical: lerobot_teleop_mydevice.mydevice + ] + + # handle modules named like "config_xxx" -> try replacing that piece with "xxx" + last = parts[-1] if parts else "" + if last.startswith("config_"): + candidates.append(".".join(parts[:-1] + [last.replace("config_", "")])) + + # de-duplicate while preserving order + seen: set[str] = set() + candidates = [c for c in candidates if not (c in seen or seen.add(c))] + + tried: list[str] = [] + for candidate in candidates: + tried.append(candidate) + try: + module = importlib.import_module(candidate) + except ImportError: + continue + + if hasattr(module, device_class_name): + cls = getattr(module, device_class_name) + if callable(cls): + try: + return cls(config) + except TypeError as e: + raise TypeError( + f"Failed to instantiate '{device_class_name}' from module '{candidate}': {e}" + ) from e + + raise ImportError( + f"Could not locate device class '{device_class_name}' for config '{config_name}'. " + f"Tried modules: {tried}. Ensure your device class name is the config class name without " + f"'Config' and that it's importable from one of those modules." + ) + + +def register_third_party_devices() -> None: + """ + Discover and import third-party lerobot_* plugins so they can register themselves. + + Scans top-level modules on sys.path for packages starting with + 'lerobot_robot_', 'lerobot_camera_' or 'lerobot_teleoperator_' and imports them. + """ + prefixes = ("lerobot_robot_", "lerobot_camera_", "lerobot_teleoperator_") + imported: list[str] = [] + failed: list[str] = [] + + for module_info in pkgutil.iter_modules(): + name = module_info.name + if name.startswith(prefixes): + try: + importlib.import_module(name) + imported.append(name) + logging.info("Imported third-party plugin: %s", name) + except Exception: + logging.exception("Could not import third-party plugin: %s", name) + failed.append(name) + + logging.debug("Third-party plugin import summary: imported=%s failed=%s", imported, failed) diff --git a/tests/async_inference/test_e2e.py b/tests/async_inference/test_e2e.py index 2689f0618..ebaef2ef1 100644 --- a/tests/async_inference/test_e2e.py +++ b/tests/async_inference/test_e2e.py @@ -91,6 +91,9 @@ def test_async_inference_e2e(monkeypatch): policy_server.policy = MockPolicy() policy_server.actions_per_chunk = 20 policy_server.device = "cpu" + # NOTE(Steven): Smelly tests as the Server is a state machine being partially mocked. Adding these processors as a quick fix. + policy_server.preprocessor = lambda obs: obs + policy_server.postprocessor = lambda tensor: tensor # Set up robot config and features robot_config = MockRobotConfig() diff --git a/tests/async_inference/test_helpers.py b/tests/async_inference/test_helpers.py index acf5870d5..1e2d1e311 100644 --- a/tests/async_inference/test_helpers.py +++ b/tests/async_inference/test_helpers.py @@ -333,9 +333,8 @@ def test_raw_observation_to_observation_basic(): robot_obs = _create_mock_robot_observation() lerobot_features = _create_mock_lerobot_features() policy_image_features = _create_mock_policy_image_features() - device = "cpu" - observation = raw_observation_to_observation(robot_obs, lerobot_features, policy_image_features, device) + observation = raw_observation_to_observation(robot_obs, lerobot_features, policy_image_features) # Check that all expected keys are present assert OBS_STATE in observation @@ -345,7 +344,6 @@ def test_raw_observation_to_observation_basic(): # Check state processing state = observation[OBS_STATE] assert isinstance(state, torch.Tensor) - assert state.device.type == device assert state.shape == (1, 4) # Batched # Check image processing @@ -356,10 +354,6 @@ def test_raw_observation_to_observation_basic(): assert laptop_img.shape == (1, 3, 224, 224) assert phone_img.shape == (1, 3, 160, 160) - # Check device placement - assert laptop_img.device.type == device - assert phone_img.device.type == device - # Check image dtype and range (should be float32 in [0, 1]) assert laptop_img.dtype == torch.float32 assert phone_img.dtype == torch.float32 @@ -374,9 +368,8 @@ def test_raw_observation_to_observation_with_non_tensor_data(): lerobot_features = _create_mock_lerobot_features() policy_image_features = _create_mock_policy_image_features() - device = "cpu" - observation = raw_observation_to_observation(robot_obs, lerobot_features, policy_image_features, device) + observation = raw_observation_to_observation(robot_obs, lerobot_features, policy_image_features) # Check that task string is preserved assert "task" in observation @@ -386,19 +379,17 @@ def test_raw_observation_to_observation_with_non_tensor_data(): @torch.no_grad() def test_raw_observation_to_observation_device_handling(): - """Test that tensors are properly moved to the specified device.""" - device = "mps" if torch.backends.mps.is_available() else "cpu" - + """Test that tensors are created (device placement is handled by preprocessor).""" robot_obs = _create_mock_robot_observation() lerobot_features = _create_mock_lerobot_features() policy_image_features = _create_mock_policy_image_features() - observation = raw_observation_to_observation(robot_obs, lerobot_features, policy_image_features, device) + observation = raw_observation_to_observation(robot_obs, lerobot_features, policy_image_features) - # Check that all tensors are on the correct device + # Check that all expected keys produce tensors (device placement handled by preprocessor later) for key, value in observation.items(): if isinstance(value, torch.Tensor): - assert value.device.type == device, f"Tensor {key} not on {device}" + assert value.device.type in ["cpu", "cuda", "mps"], f"Tensor {key} on unexpected device" def test_raw_observation_to_observation_deterministic(): @@ -406,11 +397,10 @@ def test_raw_observation_to_observation_deterministic(): robot_obs = _create_mock_robot_observation() lerobot_features = _create_mock_lerobot_features() policy_image_features = _create_mock_policy_image_features() - device = "cpu" # Run twice with same input - obs1 = raw_observation_to_observation(robot_obs, lerobot_features, policy_image_features, device) - obs2 = raw_observation_to_observation(robot_obs, lerobot_features, policy_image_features, device) + obs1 = raw_observation_to_observation(robot_obs, lerobot_features, policy_image_features) + obs2 = raw_observation_to_observation(robot_obs, lerobot_features, policy_image_features) # Results should be identical assert set(obs1.keys()) == set(obs2.keys()) @@ -448,7 +438,7 @@ def test_image_processing_pipeline_preserves_content(): ) } - observation = raw_observation_to_observation(robot_obs, lerobot_features, policy_image_features, "cpu") + observation = raw_observation_to_observation(robot_obs, lerobot_features, policy_image_features) processed_img = observation[f"{OBS_IMAGES}.laptop"].squeeze(0) # Remove batch dim diff --git a/tests/async_inference/test_policy_server.py b/tests/async_inference/test_policy_server.py index de441ff09..29583d4fa 100644 --- a/tests/async_inference/test_policy_server.py +++ b/tests/async_inference/test_policy_server.py @@ -196,6 +196,9 @@ def test_predict_action_chunk(monkeypatch, policy_server): # Force server to act-style policy; patch method to return deterministic tensor policy_server.policy_type = "act" + # NOTE(Steven): Smelly tests as the Server is a state machine being partially mocked. Adding these processors as a quick fix. + policy_server.preprocessor = lambda obs: obs + policy_server.postprocessor = lambda tensor: tensor action_dim = 6 batch_size = 1 actions_per_chunk = policy_server.actions_per_chunk diff --git a/tests/plugins/reachy2_sdk.py b/tests/plugins/reachy2_sdk.py index f56b59efb..457fcf0f9 100644 --- a/tests/plugins/reachy2_sdk.py +++ b/tests/plugins/reachy2_sdk.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python + +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import sys import types from unittest.mock import MagicMock diff --git a/tests/policies/pi0_pi05/test_pi0.py b/tests/policies/pi0_pi05/test_pi0.py index 65f64e6bc..b580310eb 100644 --- a/tests/policies/pi0_pi05/test_pi0.py +++ b/tests/policies/pi0_pi05/test_pi0.py @@ -1,5 +1,19 @@ #!/usr/bin/env python +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Test script to verify PI0 policy integration with LeRobot, only meant to be run locally!""" import os diff --git a/tests/policies/pi0_pi05/test_pi05.py b/tests/policies/pi0_pi05/test_pi05.py index 72828a02f..964539446 100644 --- a/tests/policies/pi0_pi05/test_pi05.py +++ b/tests/policies/pi0_pi05/test_pi05.py @@ -1,5 +1,19 @@ #!/usr/bin/env python +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Test script to verify PI0.5 (pi05) support in PI0 policy, only meant to be run locally!""" import os diff --git a/tests/policies/pi0_pi05/test_pi05_original_vs_lerobot.py b/tests/policies/pi0_pi05/test_pi05_original_vs_lerobot.py index 7bea89486..0d5244e1c 100644 --- a/tests/policies/pi0_pi05/test_pi05_original_vs_lerobot.py +++ b/tests/policies/pi0_pi05/test_pi05_original_vs_lerobot.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python + +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Test script to verify PI0OpenPI policy integration with LeRobot vs the original implementation, only meant to be run locally!""" import os diff --git a/tests/policies/pi0_pi05/test_pi0_original_vs_lerobot.py b/tests/policies/pi0_pi05/test_pi0_original_vs_lerobot.py index d91f716f1..41db2dceb 100644 --- a/tests/policies/pi0_pi05/test_pi0_original_vs_lerobot.py +++ b/tests/policies/pi0_pi05/test_pi0_original_vs_lerobot.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python + +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Test script to verify PI0 policy integration with LeRobot vs the original implementation, only meant to be run locally!""" import os diff --git a/tests/processor/test_batch_conversion.py b/tests/processor/test_batch_conversion.py index 88b873128..477381618 100644 --- a/tests/processor/test_batch_conversion.py +++ b/tests/processor/test_batch_conversion.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python + +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import torch from lerobot.processor import DataProcessorPipeline, TransitionKey diff --git a/tests/processor/test_converters.py b/tests/processor/test_converters.py index bc58f7a61..47a6eea18 100644 --- a/tests/processor/test_converters.py +++ b/tests/processor/test_converters.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python + +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import numpy as np import pytest import torch diff --git a/tests/processor/test_tokenizer_processor.py b/tests/processor/test_tokenizer_processor.py index b81710db1..d6f87f567 100644 --- a/tests/processor/test_tokenizer_processor.py +++ b/tests/processor/test_tokenizer_processor.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python + +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """ Tests for the TokenizerProcessorStep class. """ diff --git a/tests/utils/test_io_utils.py b/tests/utils/test_io_utils.py index 9768a5ef9..0beea639d 100644 --- a/tests/utils/test_io_utils.py +++ b/tests/utils/test_io_utils.py @@ -1,4 +1,6 @@ -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. +#!/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. @@ -11,6 +13,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + import json from pathlib import Path from typing import Any diff --git a/tests/utils/test_logging_utils.py b/tests/utils/test_logging_utils.py index 927fdc14d..560ba5701 100644 --- a/tests/utils/test_logging_utils.py +++ b/tests/utils/test_logging_utils.py @@ -1,4 +1,6 @@ -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. +#!/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. @@ -11,6 +13,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + import pytest from lerobot.utils.logging_utils import AverageMeter, MetricsTracker diff --git a/tests/utils/test_random_utils.py b/tests/utils/test_random_utils.py index 5865361d0..e3a5d420f 100644 --- a/tests/utils/test_random_utils.py +++ b/tests/utils/test_random_utils.py @@ -1,4 +1,6 @@ -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. +#!/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. @@ -11,6 +13,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + import random import numpy as np diff --git a/tests/utils/test_train_utils.py b/tests/utils/test_train_utils.py index 0eeaf907c..892503e97 100644 --- a/tests/utils/test_train_utils.py +++ b/tests/utils/test_train_utils.py @@ -1,4 +1,6 @@ -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. +#!/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. @@ -11,6 +13,7 @@ # 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 pathlib import Path from unittest.mock import Mock, patch diff --git a/tests/utils/test_visualization_utils.py b/tests/utils/test_visualization_utils.py index 65a97c6a3..08a827570 100644 --- a/tests/utils/test_visualization_utils.py +++ b/tests/utils/test_visualization_utils.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python + +# Copyright 2025 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import importlib import sys from types import SimpleNamespace