diff --git a/.dockerignore b/.dockerignore
index c0d8a84b5..3295cc1b4 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -22,6 +22,10 @@ outputs
rl
media
+# Local virtualenvs (the image provides its own)
+.venv
+venv
+
# Logging
logs
diff --git a/README.md b/README.md
index f72952102..53d92f96e 100644
--- a/README.md
+++ b/README.md
@@ -87,7 +87,7 @@ Learn more about it in the [LeRobotDataset Documentation](https://huggingface.co
## SoTA Models
-LeRobot implements state-of-the-art policies in pure PyTorch, covering Imitation Learning, Reinforcement Learning, and Vision-Language-Action (VLA) models, with more coming soon. It also provides you with the tools to instrument and inspect your training process.
+LeRobot implements state-of-the-art policies in pure PyTorch, covering Imitation Learning, Reinforcement Learning, Vision-Language-Action (VLA) models, World Models, and Reward Models, with more coming soon. It also provides you with the tools to instrument and inspect your training process.
@@ -101,13 +101,13 @@ lerobot-train \
--dataset.repo_id=lerobot/aloha_mobile_cabinet
```
-| Category | Models |
-| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Imitation Learning** | [ACT](./docs/source/policy_act_README.md), [Diffusion](./docs/source/policy_diffusion_README.md), [VQ-BeT](./docs/source/policy_vqbet_README.md), [Multitask DiT Policy](./docs/source/policy_multi_task_dit_README.md) |
-| **Reinforcement Learning** | [HIL-SERL](./docs/source/hilserl.mdx), [TDMPC](./docs/source/policy_tdmpc_README.md) & QC-FQL (coming soon) |
-| **VLAs Models** | [Pi0](./docs/source/pi0.mdx), [Pi0Fast](./docs/source/pi0fast.mdx), [Pi0.5](./docs/source/pi05.mdx), [GR00T N1.5](./docs/source/policy_groot_README.md), [SmolVLA](./docs/source/policy_smolvla_README.md), [XVLA](./docs/source/xvla.mdx), [EO-1](./docs/source/eo1.mdx), [MolmoAct2](./docs/source/molmoact2.mdx), [WALL-OSS](./docs/source/walloss.mdx) |
-| **World Models** | [VLA-JEPA](./docs/source/vla_jepa.mdx) (more coming soon) |
-| **Reward Models** | [SARM](./docs/source/sarm.mdx), [TOPReward](./docs/source/topreward.mdx), [Robometer](./docs/source/robometer.mdx) |
+| Category | Models |
+| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **Imitation Learning** | [ACT](./docs/source/policy_act_README.md), [Diffusion](./docs/source/policy_diffusion_README.md), [VQ-BeT](./docs/source/policy_vqbet_README.md), [Multitask DiT Policy](./docs/source/policy_multi_task_dit_README.md) |
+| **Reinforcement Learning** | [HIL-SERL](./docs/source/hilserl.mdx), [TDMPC](./docs/source/policy_tdmpc_README.md) & QC-FQL (coming soon) |
+| **VLAs Models** | [Pi0](./docs/source/pi0.mdx), [Pi0Fast](./docs/source/pi0fast.mdx), [Pi0.5](./docs/source/pi05.mdx), [GR00T N1.7](./docs/source/policy_groot_README.md), [SmolVLA](./docs/source/policy_smolvla_README.md), [XVLA](./docs/source/xvla.mdx), [EO-1](./docs/source/eo1.mdx), [MolmoAct2](./docs/source/molmoact2.mdx), [WALL-OSS](./docs/source/walloss.mdx), [EVO1](./docs/source/evo1.mdx) |
+| **World Models** | [VLA-JEPA](./docs/source/vla_jepa.mdx), [LingBot-VA](./docs/source/lingbot_va.mdx), [FastWAM](./docs/source/fastwam.mdx) |
+| **Reward Models** | [SARM](./docs/source/sarm.mdx), [TOPReward](./docs/source/topreward.mdx), [Robometer](./docs/source/robometer.mdx) |
Similarly to the hardware, you can easily implement your own policy & leverage LeRobot's data collection, training, and visualization tools, and share your model to the HF Hub
diff --git a/docs/source/_toctree.yml b/docs/source/_toctree.yml
index dcd14e131..7f7a34e6a 100644
--- a/docs/source/_toctree.yml
+++ b/docs/source/_toctree.yml
@@ -69,10 +69,14 @@
title: VLA-JEPA
- local: eo1
title: EO-1
+ - local: lingbot_va
+ title: LingBot-VA
- local: fastwam
title: FastWAM
+ - local: evo1
+ title: EVO1
- local: groot
- title: NVIDIA GR00T N1.5
+ title: NVIDIA GR00T
- local: xvla
title: X-VLA
- local: multi_task_dit
@@ -165,6 +169,8 @@
- sections:
- local: phone_teleop
title: Phone
+ - local: isaac_teleop
+ title: Isaac Teleop
title: "Teleoperators"
- sections:
- local: cameras
diff --git a/docs/source/bring_your_own_policies.mdx b/docs/source/bring_your_own_policies.mdx
index 1b3871516..c3cc040e3 100644
--- a/docs/source/bring_your_own_policies.mdx
+++ b/docs/source/bring_your_own_policies.mdx
@@ -295,11 +295,12 @@ The file names are load-bearing: the factory does lazy imports by name, and the
### Wiring
-Three places need to know about your policy. All by name.
+Four places need to know about your policy. All by name.
1. **`policies/__init__.py`** — re-export `MyPolicyConfig` and add it to `__all__`. **Don't** re-export the modeling class; it loads lazily through the factory (so `import lerobot` stays fast).
2. **`factory.py:get_policy_class`** — add a branch returning `MyPolicy` from a lazy import.
3. **`factory.py:make_policy_config`** and **`factory.py:make_pre_post_processors`** — same idea, two more branches.
+4. **`templates/lerobot_modelcard_template.md` and the root `README.md`** — the template is what `push_model_to_hub` renders into the model card of every checkpoint trained with your policy: add a one-line description of your policy in the `model_name` branches, map it in `policy_docs` so cards link to your MDX guide, and optionally add an architecture image to `diagrams`. Then add your policy to the models table in the root `README.md`, under the right category, linking to your doc page.
Mirror an existing policy that's structurally similar to yours; the diff is small.
@@ -371,6 +372,8 @@ The general expectations are in [`CONTRIBUTING.md`](https://github.com/huggingfa
- [ ] Optional deps live behind a `[project.optional-dependencies]` extra and the `TYPE_CHECKING + require_package` guard.
- [ ] `tests/policies/` updated; backward-compat artifact committed & policy-specific tests.
- [ ] `src/lerobot/policies//README.md` symlinked into `docs/source/policy__README.md`; user-facing `docs/source/.mdx` written and added to `_toctree.yml`.
+- [ ] `templates/lerobot_modelcard_template.md` has a description entry and a `policy_docs` link for your policy.
+- [ ] The models table in the root `README.md` lists your policy in the right category, linking to your doc page.
- [ ] At least one reproducible benchmark eval in the policy MDX with a published checkpoint (sim benchmark, or real-robot dataset + checkpoint).
The fastest way to get a clean PR is to copy the directory of the existing policy closest to yours, rename, and replace contents method by method. Don't wait until everything is polished — open a draft PR early and iterate with us; reviewers would much rather give feedback on a half-finished branch than a fully-merged one.
diff --git a/docs/source/envhub_isaaclab_arena.mdx b/docs/source/envhub_isaaclab_arena.mdx
index b934240d6..cd077806d 100644
--- a/docs/source/envhub_isaaclab_arena.mdx
+++ b/docs/source/envhub_isaaclab_arena.mdx
@@ -193,7 +193,7 @@ To learn more about training policies with LeRobot, please refer to the training
- [SmolVLA](./smolvla)
- [Pi0.5](./pi05)
-- [GR00T N1.5](./groot)
+- [GR00T N1.7](./groot)
Sample IsaacLab Arena datasets are available on HuggingFace Hub for experimentation:
diff --git a/docs/source/evo1.mdx b/docs/source/evo1.mdx
new file mode 100644
index 000000000..3f8e42798
--- /dev/null
+++ b/docs/source/evo1.mdx
@@ -0,0 +1,191 @@
+# EVO1
+
+EVO1 is a Vision-Language-Action policy for robot control built around an InternVL3 backbone and a continuous flow-matching action head. This LeRobot integration exposes EVO1 as a standard policy type so it can be trained and evaluated with the usual LeRobot dataset, checkpoint, and processor APIs.
+
+## Model Overview
+
+The policy embeds one or more camera images and the language task prompt with InternVL3, pads robot state/action vectors to fixed maximum dimensions, and predicts future action chunks with a flow-matching action head. During inference, the policy samples an action chunk and returns `n_action_steps` actions from that chunk before sampling again.
+
+### What the LeRobot Integration Covers
+
+- Standard `policy.type=evo1` configuration through LeRobot
+- InternVL3 image/text embedding with optional FlashAttention fallback
+- Stage-based finetuning controls for action-head-only and VLM finetuning runs
+- Continuous flow-matching action prediction
+- Checkpoint save/load through LeRobot policy APIs
+- Training with `lerobot-train` and evaluation with standard policy inference APIs
+
+The broader EVO1 project may include additional training scripts and dataset tooling. This page focuses on the LeRobot robot-control policy path.
+
+## Installation Requirements
+
+1. Install LeRobot by following the [Installation Guide](./installation).
+2. Install EVO1 dependencies:
+
+ ```bash
+ pip install -e ".[evo1]"
+ ```
+
+ For LIBERO evaluation, install the LIBERO extra as well:
+
+ ```bash
+ pip install -e ".[evo1,libero]"
+ ```
+
+3. Install a `flash-attn` wheel only if it is compatible with your Python, PyTorch, CUDA, and GPU stack. EVO1 falls back to standard attention when `flash_attn` is not available.
+
+EVO1 uses the native Hugging Face `transformers` InternVL implementation, so `policy.vlm_model_name` must point to a natively converted checkpoint such as `OpenGVLab/InternVL3-1B-hf` (note the `-hf` suffix). The first run may download the configured VLM checkpoint unless `policy.vlm_model_name` points to a local model directory.
+
+## Data Requirements
+
+EVO1 expects a LeRobot dataset with:
+
+- One to `policy.max_views` visual observations, for example `observation.images.image`
+- `observation.state`
+- `action`
+- A language task instruction in the dataset `task` field, or another field configured with `policy.task_field`
+
+State and action vectors are padded to `policy.max_state_dim` and `policy.max_action_dim`. Predictions are cropped back to the dataset action dimension before being returned.
+
+## Usage
+
+To use EVO1 in a LeRobot configuration, specify:
+
+```python
+policy.type=evo1
+```
+
+By default, a new EVO1 policy initializes its VLM from:
+
+```python
+policy.vlm_model_name=OpenGVLab/InternVL3-1B-hf
+```
+
+Once a LeRobot-format EVO1 checkpoint is available, load it with:
+
+```python
+policy.path=your-org/your-evo1-checkpoint
+```
+
+## Training
+
+### Stage 1
+
+Stage 1 freezes the VLM and trains the action head:
+
+```bash
+lerobot-train \
+ --dataset.repo_id=your_org/your_dataset \
+ --policy.type=evo1 \
+ --policy.training_stage=stage1 \
+ --policy.vlm_model_name=OpenGVLab/InternVL3-1B-hf \
+ --policy.device=cuda \
+ --policy.chunk_size=50 \
+ --policy.n_action_steps=50 \
+ --policy.max_state_dim=24 \
+ --policy.max_action_dim=24 \
+ --policy.optimizer_lr=1e-5 \
+ --batch_size=4 \
+ --steps=5000 \
+ --output_dir=./outputs/evo1_stage1
+```
+
+### Stage 2
+
+Stage 2 finetunes the VLM branches and action head. A common workflow starts from a Stage 1 checkpoint:
+
+```bash
+lerobot-train \
+ --dataset.repo_id=your_org/your_dataset \
+ --policy.path=./outputs/evo1_stage1/checkpoints/005000/pretrained_model \
+ --policy.training_stage=stage2 \
+ --policy.vlm_model_name=OpenGVLab/InternVL3-1B-hf \
+ --policy.device=cuda \
+ --policy.chunk_size=50 \
+ --policy.n_action_steps=50 \
+ --policy.max_state_dim=24 \
+ --policy.max_action_dim=24 \
+ --policy.optimizer_lr=1e-5 \
+ --batch_size=4 \
+ --steps=80000 \
+ --output_dir=./outputs/evo1_stage2
+```
+
+By default, `policy.training_stage` reapplies the finetuning defaults for that stage. This is important when
+starting Stage 2 from a Stage 1 checkpoint, because the Stage 1 checkpoint config stores the VLM finetuning
+flags as disabled. These stage defaults take precedence over saved or manually supplied `policy.finetune_*`
+flags unless `policy.apply_training_stage_defaults=false`, so set that flag only when manually controlling
+every finetuning flag.
+
+### Key Training Parameters
+
+| Parameter | Default | Description |
+| --------------------------------------------- | --------------------------- | ----------------------------------------------------------------- |
+| `policy.vlm_model_name` | `OpenGVLab/InternVL3-1B-hf` | Natively converted InternVL3 checkpoint or local model directory |
+| `policy.training_stage` | `stage1` | `stage1` trains the action head; `stage2` finetunes VLM branches |
+| `policy.apply_training_stage_defaults` | `true` | Reapplies stage finetuning defaults after loading a checkpoint |
+| `policy.vlm_num_layers` | `14` | Number of InternVL3 language layers kept for the policy |
+| `policy.vlm_dtype` | `bfloat16` | Requested VLM dtype |
+| `policy.use_flash_attn` | `true` | Requests FlashAttention when installed; otherwise falls back |
+| `policy.enable_gradient_checkpointing` | `true` | Enables checkpointing on supported InternVL3 modules |
+| `policy.gradient_checkpointing_use_reentrant` | `false` | Reentrant setting passed to gradient checkpointing when supported |
+| `policy.chunk_size` | `50` | Number of future actions predicted per chunk |
+| `policy.n_action_steps` | `50` | Number of actions consumed from a sampled chunk |
+| `policy.max_state_dim` | `24` | State padding dimension |
+| `policy.max_action_dim` | `24` | Action padding dimension |
+| `policy.postprocess_action_dim` | `null` | Optional action dimension returned after EVO1 postprocessing |
+| `policy.binarize_gripper` | `false` | Binarizes the postprocessed gripper channel for LIBERO-style eval |
+| `policy.task_field` | `task` | Batch field used as the language prompt |
+
+## Inference
+
+Try it out with a trained EVO1 checkpoint:
+
+```bash
+lerobot-rollout \
+ --policy.path=your-org/your-evo1-checkpoint \
+ --inference.type=rtc \ # optional
+ ...
+```
+
+## Results
+
+### LIBERO Evaluation
+
+> [!NOTE]
+> Benchmark results for a `lerobot`-hosted LIBERO checkpoint trained with this implementation
+> will be added once training completes.
+
+The official EVO1 LIBERO rollout protocol uses the raw LIBERO camera feature names
+(`observation.images.agentview_image` and `observation.images.robot0_eye_in_hand_image`), replans every
+14 actions, and binarizes the gripper command before stepping the simulator. The EVO1 policy postprocessor
+can crop the padded 24D action back to the 7D LIBERO action space and apply that gripper binarization. To
+evaluate a LIBERO checkpoint under the same one-episode-per-task setting, keep the raw camera names instead
+of the default `image`/`image2` mapping and set the LIBERO action postprocessing flags:
+
+```bash
+lerobot-eval \
+ --policy.path=your-org/your-evo1-libero-checkpoint \
+ --policy.vlm_model_name=OpenGVLab/InternVL3-1B-hf \
+ --policy.device=cuda \
+ --policy.use_flash_attn=true \
+ --policy.n_action_steps=14 \
+ --policy.postprocess_action_dim=7 \
+ --policy.binarize_gripper=true \
+ --env.type=libero \
+ --env.task=libero_object \
+ --env.camera_name_mapping="{agentview_image: agentview_image, robot0_eye_in_hand_image: robot0_eye_in_hand_image}" \
+ --env.observation_height=448 \
+ --env.observation_width=448 \
+ --eval.batch_size=1 \
+ --eval.n_episodes=1
+```
+
+## References
+
+- [EVO1 repository](https://github.com/MINT-SJTU/Evo-1)
+- [InternVL3-1B-hf](https://huggingface.co/OpenGVLab/InternVL3-1B-hf)
+
+## License
+
+This LeRobot integration follows the Apache 2.0 License used by LeRobot. Check the upstream EVO1 and InternVL3 model pages for the licenses of released checkpoints and data.
diff --git a/docs/source/groot.mdx b/docs/source/groot.mdx
index 3ab202fb2..c6dcff2d7 100644
--- a/docs/source/groot.mdx
+++ b/docs/source/groot.mdx
@@ -1,16 +1,19 @@
-# GR00T N1.5 Policy
+# GR00T Policy
-GR00T N1.5 is an open foundation model from NVIDIA designed for generalized humanoid robot reasoning and skills. It is a cross-embodiment model that accepts multimodal input, including language and images, to perform manipulation tasks in diverse environments.
+GR00T is an NVIDIA foundation model family for generalized humanoid robot reasoning and skills. It is a cross-embodiment policy that accepts multimodal input, including language, images, and proprioception, to perform manipulation tasks in diverse environments.
-This document outlines the specifics of its integration and usage within the LeRobot framework.
+LeRobot integrates GR00T N1.7 through the `groot` policy type.
+
+> [!WARNING]
+> **Breaking change:** GR00T N1.5 support was removed from LeRobot, and current releases support GR00T N1.7 only. N1.5 checkpoints and configs are rejected with a migration note. To keep using an N1.5 checkpoint, pin the last release that supports it: `pip install 'lerobot==0.5.1'`. To use the current release, migrate to GR00T N1.7 (base model [`nvidia/GR00T-N1.7-3B`](https://huggingface.co/nvidia/GR00T-N1.7-3B)).
## Model Overview
-NVIDIA Isaac GR00T N1.5 is an upgraded version of the GR00T N1 foundation model. It is built to improve generalization and language-following abilities for humanoid robots.
+GR00T N1.7 uses a Cosmos-Reason2/Qwen3-VL backbone and provides checkpoints for SimplerEnv, DROID, and LIBERO.
-Developers and researchers can post-train GR00T N1.5 with their own real or synthetic data to adapt it for specific humanoid robots or tasks.
+Developers and researchers can post-train GR00T with their own real or synthetic data to adapt it for specific humanoid robots or tasks.
-GR00T N1.5 (specifically the GR00T-N1.5-3B model) is built using pre-trained vision and language encoders. It utilizes a flow matching action transformer to model a chunk of actions, conditioned on vision, language, and proprioception.
+GR00T uses pre-trained vision and language encoders with a flow matching action transformer to model a chunk of actions conditioned on vision, language, and proprioception.
=2.2.1,<2.8.0" "torchvision>=0.21.0,<0.23.0" # --index-url https://download.pytorch.org/whl/cu1XX
-pip install ninja "packaging>=24.2,<26.0" # flash attention dependencies
-pip install "flash-attn>=2.5.9,<3.0.0" --no-build-isolation
-python -c "import flash_attn; print(f'Flash Attention {flash_attn.__version__} imported successfully')"
+pip install "lerobot[groot]"
```
-3. Install LeRobot by running:
+For a source checkout:
```bash
-pip install lerobot[groot]
+pip install -e ".[groot]"
```
## Usage
-To use GR00T in your LeRobot configuration, specify the policy type as:
+To use GR00T N1.7:
-```python
-policy.type=groot
+```bash
+--policy.type=groot
```
## Training
@@ -63,72 +57,171 @@ policy.type=groot
Here's a complete training command for finetuning the base GR00T model on your own dataset:
+This command is using the `new_embodiment` flag, which is used for the SO-101 robot, [read more about how GR00T handles different embodiments.](https://github.com/NVIDIA/Isaac-GR00T/blob/main/getting_started/policy.md#--embodiment-tag).
+
```bash
-# Using a multi-GPU setup
-accelerate launch \
- --multi_gpu \
- --num_processes=$NUM_GPUS \
- $(which lerobot-train) \
- --output_dir=$OUTPUT_DIR \
- --save_checkpoint=true \
- --batch_size=$BATCH_SIZE \
- --steps=$NUM_STEPS \
- --save_freq=$SAVE_FREQ \
- --log_freq=$LOG_FREQ \
- --policy.push_to_hub=true \
+# install extra deps for training
+pip install "lerobot[training]"
+
+hf auth login
+wandb login
+
+export DATASET_NAME=your_data_set
+export HF_USER=your_hf_username
+export DATASET=$HF_USER/$DATASET_NAME
+export REPO_ID="${DATASET}_GR00T17" #this is the model that will be uploaded to huggingface
+export OUTPUT_DIR=outputs/train/$REPO_ID
+
+lerobot-train \
+ --dataset.repo_id=$DATASET \
+ --dataset.image_transforms.enable=true \
--policy.type=groot \
+ --policy.device=cuda \
+ --policy.base_model_path=nvidia/GR00T-N1.7-3B \
+ --policy.embodiment_tag=new_embodiment \
+ --policy.chunk_size=16 \
+ --policy.n_action_steps=16 \
+ --policy.use_relative_actions=true \
+ --policy.relative_exclude_joints='["gripper"]' \
+ --policy.use_bf16=true \
+ --policy.push_to_hub=true \
--policy.repo_id=$REPO_ID \
- --policy.tune_diffusion_model=false \
- --dataset.repo_id=$DATASET_ID \
+ --seed=42 \
+ --batch_size=64 \
+ --steps=20000 \
+ --save_checkpoint=true \
+ --save_freq=5000 \
+ --use_policy_training_preset=true \
+ --env_eval_freq=0 \
+ --eval_steps=0 \
+ --log_freq=10 \
+ --output_dir=$OUTPUT_DIR \
+ --job_name=$DATASET \
--wandb.enable=true \
- --wandb.disable_artifact=true \
- --job_name=$JOB_NAME
+ --wandb.disable_artifact=true
+
```
## Performance Results
-### Libero Benchmark Results
+### LIBERO Benchmark Results
> [!NOTE]
-> Follow our instructions for Libero usage: [Libero](./libero)
+> Follow the [LIBERO](./libero) setup instructions before running `lerobot-eval`.
-GR00T has demonstrated strong performance on the Libero benchmark suite. To compare and test its LeRobot implementation, we finetuned the GR00T N1.5 model for 30k steps on the Libero dataset and compared the results to the GR00T reference results.
+GR00T N1.7 has demonstrated strong performance on the LIBERO benchmark suite. To reproduce LeRobot results, follow the instructions in the [LIBERO](./libero) section.
-| Benchmark | LeRobot Implementation | GR00T Reference |
-| ------------------ | ---------------------- | --------------- |
-| **Libero Spatial** | 82.0% | 92.0% |
-| **Libero Object** | 99.0% | 92.0% |
-| **Libero Long** | 82.0% | 76.0% |
-| **Average** | 87.0% | 87.0% |
+### Train on LIBERO
-These results demonstrate GR00T's strong generalization capabilities across diverse robotic manipulation tasks. To reproduce these results, you can follow the instructions in the [Libero](https://huggingface.co/docs/lerobot/libero) section.
+Example training command for a LIBERO suite (here `libero_spatial`):
+
+```bash
+IMAGE_TRANSFORMS='{
+ "brightness": {"weight": 1.0, "type": "ColorJitter", "kwargs": {"brightness": [0.7, 1.3]}},
+ "contrast": {"weight": 1.0, "type": "ColorJitter", "kwargs": {"contrast": [0.6, 1.4]}},
+ "saturation": {"weight": 1.0, "type": "ColorJitter", "kwargs": {"saturation": [0.5, 1.5]}},
+ "hue": {"weight": 1.0, "type": "ColorJitter", "kwargs": {"hue": [-0.08, 0.08]}}
+}'
+
+lerobot-train \
+ --dataset.repo_id=IPEC-COMMUNITY/libero_spatial_no_noops_1.0.0_lerobot \
+ --dataset.root=/datasets/libero_spatial \
+ --dataset.revision=main \
+ --dataset.video_backend=pyav \
+ --dataset.image_transforms.enable=true \
+ --dataset.image_transforms.max_num_transforms=4 \
+ --dataset.image_transforms.tfs="$IMAGE_TRANSFORMS" \
+ --policy.type=groot \
+ --policy.base_model_path=nvidia/GR00T-N1.7-3B \
+ --policy.embodiment_tag=libero_sim \
+ --policy.push_to_hub=false \
+ --policy.use_relative_actions=false \
+ --policy.max_steps=20000 \
+ --batch_size=320 \
+ --steps=20000 \
+ --save_freq=2000 \
+ --env_eval_freq=0 \
+ --eval_steps=0 \
+ --log_freq=10 \
+ --wandb.enable=true \
+ --wandb.project=lerobot \
+ --wandb.mode=online \
+ --wandb.disable_artifact=true \
+ --num_workers=4 \
+ --prefetch_factor=2 \
+ --persistent_workers=true \
+ --output_dir=$OUTPUT_DIR \
+ --job_name=$JOB_NAME
+```
+
+This will follow the recipe found [here](https://github.com/NVIDIA/Isaac-GR00T/blob/main/examples/LIBERO/README.md).
+
+### GR00T N1.7 LIBERO Results
+
+Preliminary LeRobot integration results (GR00T-LeRobot, `eval.n_episodes >= 50` per suite):
+
+| Suite | Success rate | Checkpoint |
+| ---------------- | -----------: | ------------------------------------------------------------------------------------------------------------- |
+| LIBERO Spatial | 91% | [nvidia/gr00t17-lerobot-libero_spatial-640](https://huggingface.co/nvidia/gr00t17-lerobot-libero_spatial-640) |
+| LIBERO Object | 81% | [nvidia/gr00t17-lerobot-libero_object-640](https://huggingface.co/nvidia/gr00t17-lerobot-libero_object-640) |
+| LIBERO Goal | 97% | [nvidia/gr00t17-lerobot-libero_goal-640](https://huggingface.co/nvidia/gr00t17-lerobot-libero_goal-640) |
+| LIBERO 10 (Long) | 84% | [nvidia/gr00t17-lerobot-libero_10-640](https://huggingface.co/nvidia/gr00t17-lerobot-libero_10-640) |
+| **Average** | **88.25%** | |
+
+```bash
+export MODEL_ID=your_trained_model_on_huggingface
+
+lerobot-eval \
+ --policy.type=groot \
+ --policy.base_model_path=$MODEL_ID \
+ --policy.embodiment_tag=libero_sim \
+ --env.type=libero \
+ --env.task=libero_spatial \
+ --eval.n_episodes=50
+```
+
+Use `eval.n_episodes >= 50` per suite when reporting success rates.
### Evaluate in your hardware setup
Once you have trained your model using your parameters you can run inference in your downstream task. Follow the instructions in [Policy Deployment (lerobot-rollout)](./inference). For example:
```bash
-lerobot-rollout\
- --strategy.type=sentry \
- --strategy.upload_every_n_episodes=5 \
- --robot.type=bi_so_follower \
- --robot.left_arm_port=/dev/ttyACM1 \
- --robot.right_arm_port=/dev/ttyACM0 \
- --robot.id=bimanual_follower \
- --robot.cameras='{ right: {"type": "opencv", "index_or_path": 0, "width": 640, "height": 480, "fps": 30},
- left: {"type": "opencv", "index_or_path": 2, "width": 640, "height": 480, "fps": 30},
- top: {"type": "opencv", "index_or_path": 4, "width": 640, "height": 480, "fps": 30},
- }' \
+# install extra deps for roullout and real hardware
+pip install "lerobot[feetech,viz]"
+
+export MODEL_ID=your_trained_model_on_huggingface
+
+# make sure that camera index matches your setup!
+# find index using `uv run lerobot-find-cameras opencv`
+WRIST_CAM='wrist: {type: opencv, index_or_path: 2, width: 640, height: 480, fps: 30, fourcc: "MJPG"}'
+FRONT_CAM='front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30, fourcc: "MJPG"}'
+export ROBOT_CAMERAS="{ $WRIST_CAM, $FRONT_CAM }"
+export ROBOT_ID=follower_robot
+export ROBOT_PORT=/dev/ttyACM0
+
+uv run lerobot-rollout \
+ --strategy.type=base \
+ --policy.path=$MODEL_ID \
+ --policy.base_model_path=nvidia/GR00T-N1.7-3B \
+ --policy.n_action_steps=8 \
+ --robot.type=so101_follower \
+ --robot.port=$ROBOT_PORT \
+ --robot.id=$ROBOT_ID \
+ --robot.cameras="$ROBOT_CAMERAS" \
+ --task="place the vial in the rack" \
+ --duration=60 \
+ --device=cuda \
--display_data=true \
- --dataset.repo_id=/eval_groot-bimanual \
- --dataset.single_task="Grab and handover the red cube to the other arm" \
- --dataset.streaming_encoding=true \
- --dataset.encoder_threads=2 \
- # --dataset.rgb_encoder.vcodec=auto \
- --policy.path=/groot-bimanual \ # your trained model
- --duration=600
+ --inference.type=rtc \
+ --inference.rtc.enabled=True \ # set to False if it causes inference instability
+ --inference.rtc.execution_horizon=8 \
+ --inference.queue_threshold=0
```
+> [!NOTE]
+> Value of `inference.queue_threshold` should not exceed 5 to ensure stable inference.
+
## License
-This model follows NVIDIA's proprietary license, consistent with the original [GR00T repository](https://github.com/NVIDIA/Isaac-GR00T). Future versions (starting from N1.7) will follow **Apache 2.0 License**.
+GR00T N1.7 is released under the [NVIDIA Open Model License Agreement](https://www.nvidia.com/en-us/agreements/enterprise-software/nvidia-open-model-license/).
diff --git a/docs/source/isaac_teleop.mdx b/docs/source/isaac_teleop.mdx
new file mode 100644
index 000000000..6236a5ebc
--- /dev/null
+++ b/docs/source/isaac_teleop.mdx
@@ -0,0 +1,397 @@
+# Isaac Teleop
+
+Control your robot with NVIDIA [Isaac Teleop](https://github.com/NVIDIA/IsaacTeleop), a
+multi-modal teleoperation framework. Isaac Teleop drives a single `TeleopSession` from a range
+of input devices — XR (VR) controllers, hand tracking, full-body tracking, Manus gloves, foot
+pedals, and more.
+
+In LeRobot, Isaac Teleop ships as a self-contained example under
+[`examples/isaac_teleop_to_so101/`](https://github.com/huggingface/lerobot/tree/main/examples/isaac_teleop_to_so101).
+Each Isaac Teleop input device is its own `Teleoperator` subclass in the example's
+`isaac_teleop` package, sharing one session lifecycle (see `IsaacTeleopTeleoperator`). The
+devices available today are the **XR controller** (`XRController`) and a back-drivable
+**SO-101 leader arm** (`SO101LeaderArm`); Manus gloves and hand/full-body tracking are the
+natural next devices. This guide focuses on the XR controller; the SO-101 leader is summarized
+under [Run the example](#step-3-run-the-example).
+
+**In this guide you'll learn:**
+
+- How an Isaac Teleop device drives a robot end‑effector (EE) target
+- How the _clutch_ (squeeze/grip on the XR controller) engages teleoperation without jerking the arm
+- How to run the SO‑101 teleoperation example and tune motion / gripper / IK
+
+## Installation
+
+The example lives in the LeRobot repository (it is not part of the `lerobot` pip package), so
+clone the repo and install from source. The canonical, always-up-to-date install and usage
+reference is the example's
+[`README.md`](https://github.com/huggingface/lerobot/tree/main/examples/isaac_teleop_to_so101/README.md);
+in short:
+
+```bash
+git clone https://github.com/huggingface/lerobot.git
+cd lerobot
+uv pip install -e ".[feetech,kinematics,dataset]" "huggingface_hub>=1.5"
+uv pip install "isaacteleop[cloudxr,retargeters-lite]~=1.3.131" "scipy>=1.14"
+```
+
+`isaacteleop` is published on public PyPI (Linux only). The `cloudxr` extra brings the CloudXR
+runtime bindings; `retargeters-lite` is the scipy-based retargeter path that resolves on both
+x86_64 and ARM (on aarch64 — e.g. a DGX Spark — the full `retargeters` extra does not resolve
+because of its `dex-retargeting`/`nlopt` pins, which is why it is not the default here). On
+x86_64 you can additionally install the full retargeter stack:
+
+```bash
+uv pip install "isaacteleop[retargeters]~=1.3.131"
+```
+
+### Set up CloudXR and connect a headset
+
+Isaac Teleop streams the headset to your machine over **NVIDIA CloudXR**, which provides the
+OpenXR runtime the session connects to. By default LeTeleop **auto-launches the CloudXR runtime
+for you** when you call `teleop_device.connect()` — you no longer have to run `python -m
+isaacteleop.cloudxr` and `source cloudxr.env` in a separate shell. All you need is a supported
+headset connected and the CloudXR firewall ports open. Follow the Isaac Teleop
+[Quick Start](https://nvidia.github.io/IsaacTeleop/main/getting_started/quick_start.html) for the
+headset-pairing and firewall details.
+
+**First run (EULA).** The very first launch must accept the NVIDIA CloudXR EULA. The auto-launch
+prompts for it **on stdin**, so on a headless machine it will hang waiting for input. Bootstrap
+the EULA once, interactively, with:
+
+```bash
+python -m isaacteleop.cloudxr --accept-eula # one-time: accept the CloudXR EULA
+```
+
+After that, `connect()` launches the runtime non-interactively. The launch **blocks for ~30s**
+while the runtime comes up.
+
+**Configuration.** Two fields on `IsaacTeleopConfig` (shared by every device) control this:
+
+- `auto_launch_cloudxr` (default `True`) — whether `connect()` starts the runtime. Set `False`
+ when CloudXR is already running externally.
+- `cloudxr_env_file` (default `None`) — an optional CloudXR device-profile `.env` selecting the
+ headset transport (e.g. an Apple Vision Pro profile). This is launcher **input**; it is not the
+ `~/.cloudxr/run/cloudxr.env` **output** file the old manual flow told you to `source`. `None`
+ keeps the default auto-WebRTC profile — though the SO-101 example overrides it to the
+ `default.env` shipped next to `teleoperate.py` unless you pass `--teleop.cloudxr_env_file`.
+
+**Opting out.** To skip the auto-launch (CloudXR already running), either set
+`auto_launch_cloudxr=False` or export:
+
+```bash
+export LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1
+```
+
+The **env var takes precedence over the config field**: if `LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1` is
+set, the auto-launch is skipped even when `auto_launch_cloudxr=True`. This variable is
+**independent** of Isaac Lab's `ISAACLAB_CXR_SKIP_AUTOLAUNCH` — setting one does not affect the
+other.
+
+**One teleoperator per process.** The CloudXR runtime configures the environment process-wide (a
+singleton), so run a single Isaac Teleop teleoperator per process.
+
+**Shutting down.** Always call `teleop_device.disconnect()` on exit — including on Ctrl-C. Wrap
+your teleoperation loop in `try/finally` and call `disconnect()` in the `finally`. This tears down
+the OpenXR session **before** the CloudXR runtime, which is the required order; the launcher's
+`atexit` hook only reaps the runtime and does not run the session's `__exit__`, so without an
+explicit `disconnect()` an interrupted run shuts down in the wrong order.
+
+```python
+teleop_device.connect()
+try:
+ while True:
+ action = teleop_device.get_action()
+ # ... drive the robot ...
+finally:
+ teleop_device.disconnect()
+```
+
+See [System Requirements](https://nvidia.github.io/IsaacTeleop/main/references/requirements.html)
+for supported OS / GPU / CloudXR versions and headsets.
+
+## How it works
+
+The XR controller is one Isaac Teleop **input** device. `XRController` is a deliberately thin
+reader: it exposes the **raw** controller grip pose — already statically rebased into the robot
+base frame — plus the squeeze and trigger analog values. It has **no** retargeters and **no**
+clutch logic of its own. The clutch (engage latch + delta rebasing onto the EE) and the gripper
+mapping live downstream in the example loop, which then feeds LeRobot's existing closed‑loop
+Cartesian IK pipeline — the same one the phone teleoperator uses. The device‑specific pieces are
+`XRController`, the loop's `Clutch`, and `MapXRControllerActionToRobotAction`; everything downstream
+(`EEBoundsAndSafety`, `InverseKinematicsEEToJoints`) is shared, and a future device (e.g. Manus
+gloves) would swap in its own `teleop_.py` + processor while reusing the rest.
+
+`XRController._build_pipeline` wires Isaac Teleop's `ControllersSource` — statically rebased into
+the robot base frame by the native `ControllerTransform` (`base_T_anchor`) — and exposes the
+transformed controller stream verbatim. `get_action()` reads the grip pose, squeeze, and trigger
+straight off it; the session is always stepped `RUNNING` (there is no clutch retargeter to gate).
+
+The `Clutch` class (in `examples/isaac_teleop_to_so101/isaac_teleop/clutch.py`, driven by the
+loop in `common.py`) mirrors Isaac Teleop's `SO101ClutchRetargeter`, but lives in-loop so the
+device can stay a thin reader:
+
+- It latches its engage origin on the squeeze **engage edge** (the frame the squeeze first crosses
+ `clutch_threshold`) and rebases both position and orientation around it, so engaging does not
+ teleport the arm. `Clutch.rebase` returns the absolute base-frame target as a `(pos, quat)`
+ pair, which the loop concatenates into the 7D `ee_pose` fed to the processor.
+- The analog trigger becomes a gripper `closedness` in `[0, 1]` (0 = open, 1 = closed),
+ proportional to the trigger pull, which `MapXRControllerActionToRobotAction` maps to a jaw target.
+
+See the Isaac Teleop
+[Retargeting interface](https://nvidia.github.io/IsaacTeleop/main/references/retargeting/index.html)
+and [architecture overview](https://nvidia.github.io/IsaacTeleop/main/overview/architecture.html)
+for how source nodes and retargeters compose.
+
+```text
+ VR controller (OpenXR)
+ │
+ ▼
+ XRController.get_action() ── raw base-frame grip_pos / grip_quat + squeeze + trigger
+ │ (TeleopSession always stepped RUNNING; clutch lives downstream)
+ ▼
+ Clutch.rebase(grip_pos, grip_quat) ── engage-relative delta applied to the EE home (pos + orient)
+ │ ee_pose (7) / closedness → absolute ee_pose; closedness = trigger
+ ▼
+ MapXRControllerActionToRobotAction ── absolute ee.x/y/z; ee.w* = orientation rotvec target;
+ │ ee.x/y/z / ee.w* / ee.gripper_pos ee.gripper_pos = (1 - closedness) * 100
+ ▼
+ EEBoundsAndSafety ── workspace clip + per-frame step clamp (clamp+warn)
+ │
+ ▼
+ InverseKinematicsEEToJoints ── closed-loop Placo IK; position + soft-orientation
+ │ (orientation_weight=0.01) (passes ee.gripper_pos → gripper.pos)
+ ▼
+ SO-101 follower joint targets
+```
+
+### The clutch: owned by the example loop
+
+Unlike the phone pipeline (which splits the clutch across `MapPhoneActionToRobotAction` and
+`EEReferenceAndDelta`), the XR clutch lives entirely in the example loop's `Clutch` class. It emits
+an **absolute** EE pose, so there is no `EEReferenceAndDelta` stage and no delta accumulation in the
+processor — `MapXRControllerActionToRobotAction` is a pure, stateless per‑frame mapping.
+
+The clutch latches its engage origin on the squeeze **engage edge** (the moment the squeeze crosses
+`clutch_threshold`) and drives the EE from the motion _relative_ to that origin, so the arm does not
+teleport on engage. On **every** engage — startup and mid‑task re‑clutch alike — the home
+_position_ is latched from forward kinematics on the arm's **measured joints**, so the home equals
+where the arm physically is even if it moved while disengaged, and the engage is jump‑free. The
+home _orientation_ keeps the last commanded rotation: the 5‑DOF arm tracks orientation only
+softly, so latching the measured wrist orientation would inject its tracking offset into the
+command on every re‑clutch.
+
+## Controls
+
+- **Squeeze / grip** — the **clutch** (deadman). Hold it past `clutch_threshold` to engage
+ teleoperation; release to pause. Each engage re‑captures the origin, so you can reposition
+ your hand while paused and re‑engage without the arm jumping (index/clutch style).
+- **Trigger** — the **gripper**, controlled **analog**. The jaw tracks the trigger
+ proportionally — a half‑pressed trigger leaves the jaw half‑closed — via a closedness in
+ `[0, 1]` (0 = open, 1 = closed) that maps to an absolute gripper joint target.
+- **Controller orientation** — the **wrist**. The clutch rebases the controller orientation
+ (engage‑relative, base‑frame) into a soft IK orientation target the wrist tracks alongside
+ position. On the 5‑DOF SO‑101 the wrist follows the hand only partially by design — see
+ `orientation_weight` below.
+
+## Get started
+
+### Step 1: Create the teleoperator
+
+```python
+# Run from the repo root so the `examples` package is importable.
+from examples.isaac_teleop_to_so101.isaac_teleop import XRController, XRControllerConfig
+
+teleop_config = XRControllerConfig(
+ hand_side="right", # "left" or "right" controller
+ clutch_threshold=0.5, # squeeze value above which the clutch engages
+)
+teleop_device = XRController(teleop_config)
+```
+
+`XRController.get_action()` returns the **raw** base‑frame controller pose, not a clutch‑rebased
+target: `grip_pos` (3,) `[x, y, z]` [m] and `grip_quat` (4,) `[qx, qy, qz, qw]` in the robot base
+frame, plus scalar `squeeze` and `trigger` analog values in `[0, 1]`. The example loop's `Clutch`
+turns these into the absolute `ee_pose`, and the squeeze is thresholded by the loop against
+`clutch_threshold` to engage.
+
+### Step 2: Connect
+
+Calling `teleop_device.connect()` first auto-launches the CloudXR runtime (unless you opted out —
+see [Set up CloudXR and connect a headset](#set-up-cloudxr-and-connect-a-headset); this blocks for
+~30s and on the first run prompts for the EULA on stdin), then starts the Isaac Teleop
+[`TeleopSession`](https://nvidia.github.io/IsaacTeleop/main/getting_started/teleop_session.html)
+(opens the OpenXR session and discovers the controllers). XR controllers are self‑calibrating, so
+there is no manual calibration step — the clutch handles re‑centering each time you engage. Pair
+`connect()` with a `try/finally` that calls `disconnect()` so the session tears down before the
+runtime on exit/Ctrl-C.
+
+### Step 3: Run the example
+
+The example assumes you configured your robot (SO‑101 follower) and set the correct serial port.
+
+The **robot URDF and its meshes are fetched automatically** on first run: the XR device downloads
+the SO-101 URDF from the
+[`lerobot/robot-urdfs` Hugging Face bucket](https://huggingface.co/buckets/lerobot/robot-urdfs/tree/so101)
+into the LeRobot cache (`HF_LEROBOT_HOME/robot-urdfs/so101/`) and reuses it after, so there is no
+separate download step :
+
+```bash
+python -m examples.isaac_teleop_to_so101.teleoperate --robot.type=so101_follower --robot.port=/dev/ttyACM0 \
+ --robot.id=so101_follower_arm --teleop.type=xr_controller
+```
+
+The CLI is `lerobot-teleoperate`-style (draccus): `--robot.*` configures the SO-101 follower and
+`--teleop.type` selects the Isaac input device (`xr_controller` | `so101_leader`), with
+`--teleop.*` its device knobs. `--teleop.type=xr_controller` runs the XR-controller path described
+above. The startup safety contract: by default it slews all joints to a default reset pose over
+`--reset_duration` seconds (`--reset_to_origin=false` keeps the arm where it is), then seeds the
+clutch home from the arm's measured pose so the first engage is jump-free; the follower is
+commanded only while the clutch is engaged.
+
+**Customizing the reset pose.** The reset pose ships as a built-in default (a comfortable mid-range
+pose) and works out of the box — you do **not** need to record anything. To tailor it to your setup,
+back-drive the arm to the pose you want and run
+`python -m examples.isaac_teleop_to_so101.override_reset_pose --id `; it writes the
+current joints to a per-arm file in the LeRobot cache
+(`HF_LEROBOT_HOME/reset_poses//.json`, keyed like calibration), which then takes
+priority over the built-in default on the next run. Because it lives in the user-local cache (not
+the repo), your override stays on your machine, and both `teleoperate` and `record` honor it
+when launched with the same `--robot.id`.
+
+The other device, `--teleop.type=so101_leader`, mirrors the follower 1:1 from a back-drivable
+SO-101 _leader arm_ whose joints are streamed by Isaac Teleop's native `so101_leader` plugin (no
+clutch, no IK — the leader and follower share the SO-101 kinematics).
+
+The `so101_leader_plugin` binary is a C++ plugin that is **not** part of the `isaacteleop` pip
+package — you build it from the Isaac Teleop source tree. Follow
+[Build Isaac Teleop from source](https://nvidia.github.io/IsaacTeleop/main/getting_started/build_from_source/index.html)
+(in short, from your Isaac Teleop checkout: `cmake -B build && cmake --build build --parallel &&
+cmake --install build`); the build installs the plugins under `/install/plugins/`, so
+the binary lands at `install/plugins/so101_leader/so101_leader_plugin` — the `--launch_plugin` path
+below. See the plugin's own `README.md` (next to the binary) for its serial/calibration details.
+
+Point `--teleop.port` at the physical leader's serial port and `--launch_plugin` at that plugin
+binary to have the script spawn it after CloudXR is up:
+
+```bash
+python -m examples.isaac_teleop_to_so101.teleoperate --robot.type=so101_follower --robot.port=/dev/ttyACM0 \
+ --robot.id=so101_follower_arm --teleop.type=so101_leader \
+ --teleop.port=/dev/ttyACM1 --teleop.id=so101_leader_arm \
+ --launch_plugin=/code/Teleop/install/plugins/so101_leader/so101_leader_plugin
+```
+
+(Note `so101_leader` here is the _Isaac_ leader, resolved against the Isaac Teleop device
+registry, distinct from `lerobot-teleoperate`'s serial `so101_leader`.) When a `--teleop.port` is
+set, the plugin's tick→radian calibration is inferred from `--teleop.id` and passed to the plugin
+as its third positional arg — the LeRobot-format JSON at
+`HF_LEROBOT_CALIBRATION/teleoperators/so_leader/.json`, the same file the serial SO-101 leader
+uses (`lerobot-calibrate --teleop.type=so101_leader --teleop.id=`). If it is missing the script
+warns and the plugin uses built-in defaults. Run `python -m examples.isaac_teleop_to_so101.teleoperate --help` for all flags. Its
+startup safety contract: by default the follower is
+slewed to the leader's first reading over `--align_duration` seconds (`--align=false` to skip) so
+the arm does not snap when the mirror begins, and while the leader stream is stale the follower is
+held at its measured pose.
+
+The URDF fetch uses `huggingface_hub` (already a LeRobot dependency) against the public
+`lerobot/robot-urdfs` bucket, so it needs no login. It is cached under
+`HF_LEROBOT_HOME/robot-urdfs/so101/`; delete that folder to force a re‑download.
+
+Then, in your headset: squeeze and hold the grip to engage, move the controller to drive the
+arm, twist/tilt it to orient the wrist, and press the trigger to close the gripper
+(proportionally — release to open).
+
+To record a dataset (not just teleoperate), use `record.py` in the same folder. It dispatches on
+`--teleop.type` (`xr_controller` | `so101_leader`) exactly like `teleoperate.py`, so either device
+can drive the follower, and it saves the commanded joints to a LeRobot dataset (`lerobot-record`-style
+`--dataset.*` flags). See its module docstring for the full CLI and the keyboard recording shortcuts.
+
+## Important pipeline steps and options
+
+The clutch already produces an absolute base‑frame pose, so the processor side is a thin
+**absolute‑pose** path — there is no frame remap, no delta accumulation, and no
+`EEReferenceAndDelta` stage.
+
+- `MapXRControllerActionToRobotAction` is a stateless per‑frame mapping from the device output to
+ the IK input contract. It writes the absolute base‑frame position, encodes the absolute
+ orientation as a rotvec target, and inverts the closedness into a motor gripper target:
+
+ ```python
+ action["ee.x"], action["ee.y"], action["ee.z"] = ee_pose[:3] # absolute, base frame [m]
+ action["ee.wx"], action["ee.wy"], action["ee.wz"] = orient_rotvec # orientation target (rotvec)
+ action["ee.gripper_pos"] = (1 - closedness) * 100 # motor units; SO-101 calibrates 100 = open
+ ```
+
+ The gripper polarity (`100 = open, 0 = closed`) is a hardware‑calibration convention in the source — flip it there if the jaw opens when it should close.
+
+- `EEBoundsAndSafety` clamps the EE to a workspace and rate‑limits per‑frame jumps. The clutch's
+ no‑teleport keeps frames small, so `max_ee_step_m` mostly catches transient controller tracking
+ glitches. The z floor is `0.0` (the table plane) so a stray target cannot drive the EE below the
+ table; x/y stay at the loose `[-1, 1]` m box. Set `raise_on_jump=False` so an over‑limit frame is
+ **clamped and warned** instead of raising — a crash mid‑loop would leave the arm uncontrolled:
+
+ ```python
+ EEBoundsAndSafety(
+ end_effector_bounds={"min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 1.0]},
+ max_ee_step_m=0.10,
+ raise_on_jump=False,
+ )
+ ```
+
+- `InverseKinematicsEEToJoints(initial_guess_current_joints=False, orientation_weight=0.01)` solves
+ closed‑loop Placo IK. SO‑101 is a 5‑DOF arm, so the IK is position‑dominant; the small
+ `orientation_weight` lets it softly track the orientation target carried in `ee.w*` so the wrist
+ follows the hand, while the under‑determined roll stays partial by design. There is **no**
+ `GripperVelocityToJoint`: the absolute `ee.gripper_pos` is passed straight to `gripper.pos`.
+ `initial_guess_current_joints=False` warm‑starts each solve from the **previous IK solution**
+ rather than re‑seeding from the measured joints, so the joint trajectory stays continuous
+ frame‑to‑frame. Tune `orientation_weight` on hardware — too high fights position tracking, too
+ low ignores the orientation command.
+
+The example also gates safety at the loop level: after the startup reset slew (on by default —
+pass `--reset_to_origin=false` to keep the arm where it is), it commands the robot **only while
+the clutch is engaged**, and re‑sends the measured joints while disengaged, so releasing the
+clutch freezes the arm in place.
+
+See the [Processors for Robots and Teleoperators](./processors_robots_teleop) guide for more on
+adapting the pipeline to other robots.
+
+## Troubleshooting
+
+- **`ModuleNotFoundError: isaacteleop`** — the `isaacteleop` package is not installed in the
+ active environment. Re-run the install command at the top of this guide:
+ `uv pip install "isaacteleop[cloudxr,retargeters-lite]~=1.3.131"`.
+- **No controllers found** — make sure the CloudXR runtime is running, the firewall ports are
+ whitelisted, and the headset is connected (see
+ [Set up CloudXR and connect a headset](#set-up-cloudxr-and-connect-a-headset) and the Isaac
+ Teleop [Quick Start](https://nvidia.github.io/IsaacTeleop/main/getting_started/quick_start.html)).
+- **CloudXR auto-launch failed** — `connect()` raises a `RuntimeError` if the runtime does not
+ come up within its startup timeout. Check the launcher logs under `~/.cloudxr/logs`. Common
+ causes: the EULA was never accepted (run `python -m isaacteleop.cloudxr --accept-eula` once,
+ interactively — the auto-launch prompts on stdin and hangs headless), or the runtime is already
+ running externally (set `LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1` or `auto_launch_cloudxr=False` to
+ skip the auto-launch).
+- **Arm does not move** — the clutch is a deadman: you must hold the squeeze/grip past
+ `clutch_threshold`. Lower the threshold if your controller's squeeze is reported softly.
+- **Motion feels misaligned** — confirm the headset/play space orientation. The controller stream
+ is rebased into the robot base frame by the `base_T_anchor` transform on `XRControllerConfig`
+ (default: standard OpenXR → robot axis convention); adjust it if your anchor frame differs.
+
+## Learn more
+
+NVIDIA Isaac Teleop documentation ([docs home](https://nvidia.github.io/IsaacTeleop/),
+[GitHub](https://github.com/NVIDIA/IsaacTeleop)):
+
+- [Quick Start](https://nvidia.github.io/IsaacTeleop/main/getting_started/quick_start.html) —
+ install, run the CloudXR server, connect a headset, run a teleop example.
+- [TeleopSession](https://nvidia.github.io/IsaacTeleop/main/getting_started/teleop_session.html) —
+ the session API `XRController` wraps.
+- [Retargeting interface](https://nvidia.github.io/IsaacTeleop/main/references/retargeting/index.html)
+ and [architecture overview](https://nvidia.github.io/IsaacTeleop/main/overview/architecture.html) —
+ how source nodes and retargeters compose into a pipeline.
+- [Build from source](https://nvidia.github.io/IsaacTeleop/main/getting_started/build_from_source/index.html) —
+ build `isaacteleop` (and its C++ plugins, including the `so101_leader` plugin used above) from a
+ local checkout.
+- [System Requirements](https://nvidia.github.io/IsaacTeleop/main/references/requirements.html) and
+ the [CloudXR SDK docs](https://docs.nvidia.com/cloudxr-sdk) — supported platforms, GPUs,
+ CloudXR/OpenXR runtime versions, and headsets.
diff --git a/docs/source/lingbot_va.mdx b/docs/source/lingbot_va.mdx
new file mode 100644
index 000000000..d33e90340
--- /dev/null
+++ b/docs/source/lingbot_va.mdx
@@ -0,0 +1,187 @@
+# LingBot-VA
+
+LingBot-VA is an **autoregressive video-action world-model policy** built on the **Wan2.2**
+video-diffusion stack. It interleaves, in one autoregressive sequence, the prediction of
+future **video latents** and **robot actions** ("VA" = Video-Action). The LeRobot
+integration wires LingBot-VA into the standard training, evaluation and processor
+interfaces.
+
+## Model Overview
+
+LingBot-VA is a **dual-stream "mixture-of-transformers"**: a video/latent stream
+(`patch_embedding_mlp → blocks → proj_out`) and an action stream
+(`action_embedder → blocks → action_proj_out`) share the same 30 transformer blocks and
+text conditioning.
+
+| Component | Class | Role |
+| ------------------------ | ----------------------- | ----------------------------------------------------------- |
+| DiT backbone (trainable) | `WanTransformer3DModel` | ~5B-param dual-stream transformer. |
+| VAE (frozen) | `AutoencoderKLWan` | Wan2.2 VAE, `z_dim=48`. Lazy-pulled from the source repo. |
+| Text encoder (frozen) | `UMT5EncoderModel` | UMT5-XXL, `d_model=4096`. Lazy-pulled from the source repo. |
+
+At inference the policy runs an autoregressive loop per chunk: it denoises the video-latent
+stream (CFG, ~20 steps) and the action stream (~50 steps) with two independent
+flow-matching schedulers, maintaining a KV cache across chunks. Real observed keyframes are
+fed back into the KV cache as the chunk is executed (closed-loop world modeling).
+
+### What the LeRobot Integration Covers
+
+- Standard `policy.type=lingbot_va` configuration through LeRobot.
+- Ready-to-use LeRobot-format checkpoints on the Hub (converted from the released upstream ones).
+- Autoregressive dual-stream inference behind the standard `select_action` interface
+ (single-environment eval, `--eval.batch_size=1`).
+- Opt-in saving of the policy's **predicted (imagined) videos** during eval / training.
+- Evaluation with `lerobot-eval` on LIBERO and RoboTwin.
+- Training / fine-tuning via the dual-stream flow-matching loss (`policy.forward`), see below.
+
+## Installation
+
+1. Install LeRobot by following the [Installation Guide](./installation).
+2. Install the LingBot-VA extra:
+
+```bash
+pip install -e ".[lingbot_va]"
+```
+
+## Checkpoints
+
+The released upstream checkpoints have been converted to LeRobot format and pushed to the Hub:
+
+| Variant | LeRobot checkpoint |
+| ---------------------- | -------------------------------- |
+| LIBERO-Long post-train | `lerobot/lingbot_va_libero_long` |
+| RoboTwin post-train | `lerobot/lingbot_va_robotwin` |
+| Pretrained base | `lerobot/lingbot_va_base` |
+
+Only the trainable ~5B transformer is stored in the LeRobot
+`model.safetensors`. The frozen VAE + UMT5 + tokenizer (~20 GB) are pulled from
+`config.wan_pretrained_path` at load time (defaults to the source `robbyant/*` repo). The
+UMT5-XXL text encoder runs on CPU by default (`config.text_encoder_device`) so the 5B
+transformer + VAE fit on a single 24–32 GB GPU.
+
+## Evaluation (LIBERO)
+
+```bash
+lerobot-eval \
+ --policy.path=lerobot/lingbot_va_libero_long \
+ --policy.device=cuda \
+ --env.type=libero --env.task=libero_10 \
+ --env.observation_height=128 --env.observation_width=128 \
+ --eval.n_episodes=50 --eval.batch_size=1 \
+ --output_dir=outputs/eval/lingbot_va_libero
+```
+
+LingBot-VA's streaming inference (KV cache + observed-keyframe feedback) is implemented for
+single-environment eval; use `--eval.batch_size=1`.
+
+## Evaluation (RoboTwin)
+
+RoboTwin 2.0 needs the SAPIEN + CuRobo simulator stack. You can use the benchmark Docker image
+(`docker/Dockerfile.benchmark.robotwin`, which also needs `warp-lang==1.3.1` and CuRobo built
+with the GPU's compute capability in `TORCH_CUDA_ARCH_LIST`). RoboTwin uses **end-effector-pose
+control**, so run with `--env.action_mode=ee`: the policy predicts per-arm `xyz+quaternion+gripper`
+deltas (`robotwin_tshape` latent layout) that are composed onto the episode's initial eef pose and
+executed via CuRobo IK.
+
+```bash
+lerobot-eval \
+ --policy.path=lerobot/lingbot_va_robotwin \
+ --policy.device=cuda \
+ --env.type=robotwin --env.task=beat_block_hammer --env.action_mode=ee \
+ --eval.n_episodes=10 --eval.batch_size=1 \
+ --output_dir=outputs/eval/lingbot_va_robotwin
+```
+
+### Saving predicted (imagined) videos
+
+Set `--policy.save_predicted_video=true` to additionally VAE-decode the predicted video
+latents and write `pred_episode_*.mp4` next to the env-rendered `eval_episode_*.mp4` videos.
+The same flag works for the periodic eval during `lerobot-train`.
+
+## Training / fine-tuning
+
+`LingBotVAPolicy.forward(batch)` implements the dual-stream **flow-matching** loss
+(`latent_loss + action_loss`, timestep-weighted, action-masked) from the paper: it VAE-encodes
+the camera clips into video latents, UMT5-encodes the task, noises both streams, runs the
+transformer's block-causal training pass and returns `(loss, metrics)`. Optimizer preset is AdamW
+with a linear-warmup-then-constant schedule (matching upstream).
+
+Requirements:
+
+- The block-causal masks use PyTorch **flex-attention**, so build the policy with
+ `--policy.attn_mode=flex` for training (the default `torch` SDPA is inference-only).
+- The full 5B DiT does not fit a single 24–32 GB GPU under AdamW; fine-tune with **LoRA**
+ (`--policy.use_peft=true`) and/or optimizer offload. `get_optim_params` returns only the
+ trainable (e.g. adapter) parameters; the VAE + UMT5 text encoder stay frozen.
+
+```bash
+lerobot-train \
+ --policy.path=lerobot/lingbot_va_libero_long --policy.attn_mode=flex \
+ --policy.use_peft=true \
+ --dataset.repo_id= \
+ --batch_size=1 --steps=... --output_dir=outputs/train/lingbot_va
+```
+
+The dataset must provide camera clips (a temporal window per camera, VAE-encoded to
+`frame_chunk_size` latent frames) and `frame_chunk_size * action_per_frame` action steps per item.
+
+## Data format (action channels & camera order)
+
+LingBot-VA is an **end-effector (Cartesian) pose** policy, it predicts EEF poses + gripper, not
+joint positions. Actions live in a fixed multi-embodiment **30-dim** layout; map your robot's
+action dimensions into these channels and pad the rest with `0` (`used_action_channel_ids` selects
+the channels a given checkpoint actually uses):
+
+| channels | meaning |
+| -------- | ----------------------------------------------------- |
+| 0–6 | Left-arm end-effector pose |
+| 7–13 | Right-arm end-effector pose |
+| 14–20 | Left-arm joints (unused by the released checkpoints) |
+| 21–27 | Right-arm joints (unused by the released checkpoints) |
+| 28 | Left gripper |
+| 29 | Right gripper |
+
+- **LIBERO** uses channels `0–6`: a 6-DoF EEF delta (xyz + rotation) + gripper (single arm).
+- **RoboTwin** uses channels `[0–6, 28, 7–13, 29]`: left EEF (xyz + quaternion) + left gripper +
+ right EEF + right gripper (16 dims). The env converts these poses to joint trajectories via
+ CuRobo IK — joints are never predicted.
+
+Joint-space datasets (or a different EEF convention) must be remapped into this schema before
+fine-tuning these checkpoints.
+
+**Camera order is fixed and order-sensitive**, per-camera latents are concatenated spatially in
+`obs_cam_keys` order, so the physical camera→slot mapping must match training:
+
+| benchmark | `obs_cam_keys` (in order) | `camera_layout` |
+| --------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
+| LIBERO | `observation.images.image` (agentview / 3rd-person), `observation.images.image2` (eye-in-hand wrist) | `width_concat` (latents concatenated on width) |
+| RoboTwin | `observation.images.head_camera`, `observation.images.left_camera`, `observation.images.right_camera` | `robotwin_tshape` (full-res head below, two half-res wrists on top) |
+
+The first camera is the exterior/head view and the rest are wrist views.
+
+## Inference Hyperparameters (LIBERO)
+
+| Key | Value |
+| -------------------------------------- | --------------------------------------------------------------------------------- |
+| height × width | 128 × 128 |
+| cameras | `observation.images.image` (agentview), `observation.images.image2` (eye-in-hand) |
+| action channels used | 0–6 (7-DoF arm + gripper) |
+| action_per_frame / frame_chunk_size | 4 / 4 |
+| attn_window | 30 |
+| video / action denoising steps | 20 / 50 |
+| guidance_scale / action_guidance_scale | 5 / 1 |
+| snr_shift / action_snr_shift | 5.0 / 0.05 |
+
+These are the defaults of `LingBotVAConfig`; override any of them via `--policy.=...`.
+
+## Notes
+
+- **Attention backend:** inference uses the `torch` SDPA backend (always available). The
+ `flashattn` and `flex` backends are optional; `flex` is only needed for training.
+- **Model size:** the DiT is ~5B params and the frozen VAE+UMT5 add ~20 GB; inference needs
+ roughly 18–24 GB of VRAM.
+
+## License
+
+LingBot-VA is released under Apache-2.0. See the
+[upstream repository](https://github.com/Robbyant/lingbot-va).
diff --git a/docs/source/policy_evo1_README.md b/docs/source/policy_evo1_README.md
new file mode 100644
index 000000000..dc8b75344
--- /dev/null
+++ b/docs/source/policy_evo1_README.md
@@ -0,0 +1,18 @@
+# EVO1
+
+EVO1 is a Vision-Language-Action policy for robot control. The LeRobot
+integration uses an InternVL3 vision-language backbone with a flow-matching
+action head, and supports staged training through the standard LeRobot policy
+APIs.
+
+The upstream EVO1 project is available at
+[MINT-SJTU/Evo-1](https://github.com/MINT-SJTU/Evo-1).
+
+```bibtex
+@misc{evo1,
+ title = {EVO1},
+ author = {{MINT-SJTU}},
+ year = {2025},
+ howpublished = {\url{https://github.com/MINT-SJTU/Evo-1}},
+}
+```
diff --git a/docs/source/policy_groot_README.md b/docs/source/policy_groot_README.md
index efcd76ebe..4b256fb27 100644
--- a/docs/source/policy_groot_README.md
+++ b/docs/source/policy_groot_README.md
@@ -1,6 +1,13 @@
## Research Paper
-Paper: https://research.nvidia.com/labs/gear/gr00t-n1_5/
+GR00T N1 technical report (covers the GR00T N1.x family, including N1.7): https://arxiv.org/abs/2503.14734
+
+GR00T N1.7 model card: https://huggingface.co/nvidia/GR00T-N1.7-3B
+
+GR00T N1.5 research page (earlier version): https://research.nvidia.com/labs/gear/gr00t-n1_5/
+
+> GR00T N1.5 support was removed from LeRobot; the last release supporting it is `lerobot==0.5.1`.
+> Current releases support GR00T N1.7 only.
## Repository
@@ -24,4 +31,108 @@ Code: https://github.com/NVIDIA/Isaac-GR00T
Blog: https://developer.nvidia.com/isaac/gr00t
-Hugging Face Model: https://huggingface.co/nvidia/GR00T-N1.5-3B
+Hugging Face Models:
+
+- GR00T N1.7: https://huggingface.co/nvidia/GR00T-N1.7-3B
+- GR00T N1.7 LIBERO checkpoints: https://huggingface.co/nvidia/GR00T-N1.7-LIBERO
+
+
+Original-vs-LeRobot parity test
+
+## Original-vs-LeRobot parity test
+
+`tests/policies/groot/test_groot_vs_original.py` verifies this LeRobot
+reimplementation of GR00T N1.7 (Qwen3-VL backbone + flow-matching action head)
+against NVIDIA's original `gr00t` package with two comparisons, each parametrized
+over every embodiment tag present in the checkpoint:
+
+1. **Model parity** — given byte-identical pre-processed inputs and the same
+ flow-matching seed (recorded in each artifact), both implementations must produce
+ the **same raw model output** (`get_action(...)["action_pred"]`, the normalized
+ flow-matching prediction). Output shapes must match exactly; any action-horizon
+ or action-dim mismatch fails the test.
+2. **Preprocessor parity** — given the identical raw observations (per-camera
+ frames, state vectors, language instruction), LeRobot's own preprocessor pipeline
+ (real Qwen3-VL chat template / tokenizer / image packing + checkpoint-driven
+ state normalization, no mocks) must produce the **same collated model inputs**
+ (`input_ids`, `attention_mask`, `pixel_values`, `image_grid_thw`, `state`,
+ `embodiment_id`) as the original package's processor.
+
+### Why two environments
+
+The original `gr00t` package pins `transformers==4.57.3` (Python 3.10); this
+integration requires `transformers>=5.x` (Qwen3-VL). Under 5.x, `PretrainedConfig`
+is itself a defaulted dataclass, so the original config dataclasses fail to import
+(`non-default argument follows default argument`). The two implementations therefore
+**cannot be imported in the same Python process**.
+
+So the test uses a **producer / consumer** split across two venvs:
+
+1. **Producer** — `tests/policies/groot/utils/dump_original_n1_7.py`, run in the _original_
+ gr00t venv. For each embodiment it builds dummy inputs generically from the
+ checkpoint metadata (state dims from `statistics.json`; camera/language keys from
+ the processor modality configs), runs the original model, and saves to one `.npz`
+ per tag: the raw observations (`raw::` keys), the exact collated inputs
+ (`in::` keys), the seed, and the raw `action_pred`.
+2. **Consumer** — the pytest above, run in the _LeRobot_ venv. It discovers every
+ `.npz`; the model-parity case replays the byte-identical collated inputs through
+ the LeRobot model with the recorded seed and asserts the outputs match, and the
+ preprocessor-parity case replays the raw observations through LeRobot's full
+ preprocessor pipeline and asserts the collated tensors match.
+
+> Artifacts generated by older versions of the dump script contain no `raw::`
+> fields; the preprocessor-parity case then **skips** with a regeneration hint.
+> Re-run the producer to refresh them.
+
+### Fairness controls
+
+- **Same pre-processed inputs (model parity)** — the original processor's `input_ids`,
+ `pixel_values`, `image_grid_thw`, `attention_mask`, `state`, `embodiment_id` are
+ fed verbatim to the LeRobot model (no re-tokenization / re-normalization), so the
+ model comparison isolates the model. LeRobot's own tokenization / image packing is
+ covered separately by the preprocessor-parity case, which compares its output
+ against those same collated tensors from identical raw observations.
+- **Same precision + attention kernel** — both sides run **fp32 + SDPA**. The
+ original defaults to `use_flash_attention=True` (flash_attention_2 + bf16); the
+ producer forces SDPA + fp32. (With the defaults the gap is ~3e-2 — pure
+ kernel/rounding noise, not an implementation difference.)
+- **Same flow-matching seed** — fixed right before sampling on both sides; the
+ producer records it in each artifact (`--seed`, default 42) and the consumer
+ replays the recorded value.
+
+### How to run
+
+```bash
+# Resolve a local checkpoint (GR00T-N1.7-LIBERO / libero_10)
+CKPT=$(python - <<'PY'
+import os
+from huggingface_hub import snapshot_download
+print(os.path.join(snapshot_download("nvidia/GR00T-N1.7-LIBERO",
+ allow_patterns=["libero_10/*"]), "libero_10"))
+PY
+)
+
+# 1) Produce the original-side artifacts for all embodiments (original gr00t venv, CUDA)
+CUDA_VISIBLE_DEVICES=0 /path/to/Isaac-GR00T/.venv-original/bin/python \
+ tests/policies/groot/utils/dump_original_n1_7.py \
+ --ckpt "$CKPT" --out-dir tests/policies/groot/artifacts --device cuda --seed 42
+
+# 2) Run the parity test (LeRobot venv) — one parametrized case per embodiment
+CUDA_VISIBLE_DEVICES=0 GROOT_PARITY_DEVICE=cuda \
+ uv run pytest tests/policies/groot/test_groot_vs_original.py -v -s
+```
+
+The `.npz` artifacts are local-only (gitignored, ~6–10 MB each) and are regenerated by
+the producer; they are never committed. The tests **skip** (do not fail) on CI or
+when the checkpoint / artifacts are absent.
+
+#### Env knobs (all optional)
+
+| Var | Default | Purpose |
+| ----------------------------------------- | -------------------------------- | ------------------------------------- |
+| `GROOT_N1_7_PARITY_DIR` | `tests/policies/groot/artifacts` | directory of per-tag `.npz` artifacts |
+| `GROOT_N1_7_LIBERO_CKPT` | auto (HF cache) | override checkpoint dir |
+| `GROOT_PARITY_DEVICE` | `cuda` if available | `cpu` or `cuda` |
+| `GROOT_PARITY_ATOL` / `GROOT_PARITY_RTOL` | `1e-3` | comparison tolerance |
+
+
diff --git a/docs/source/video_encoding_parameters.mdx b/docs/source/video_encoding_parameters.mdx
index 132d25056..337ff5e46 100644
--- a/docs/source/video_encoding_parameters.mdx
+++ b/docs/source/video_encoding_parameters.mdx
@@ -6,12 +6,11 @@ Encoding frames into an MP4 is a full FFmpeg pipeline: choice of encoder, pixel
You can set these parameters from the CLI with `--dataset.rgb_encoder.` (e.g. with `lerobot-record` or `lerobot-rollout`). The same block applies to every camera video stream in that run.
-
- Video storage must be on for `rgb_encoder` to have any effect —
- `use_videos=True` in Python APIs, or `--dataset.video=true` on the CLI (the
- recording default). With video off, inputs stay as images and `rgb_encoder` is
- ignored.
-
+> [!TIP]
+> Video storage must be on for `rgb_encoder` to have any effect —
+> `use_videos=True` in Python APIs, or `--dataset.video=true` on the CLI (the
+> recording default). With video off, inputs stay as images and `rgb_encoder` is
+> ignored.
For details on **when** frames are written vs. encoded (streaming vs. post-episode), queues, and other top-level `--dataset.*` switches, see [Streaming Video Encoding](./streaming_video_encoding). For an encoding-parameter comparison and experiments, see the [video-benchmark Space](https://huggingface.co/spaces/lerobot/video-benchmark).
@@ -43,12 +42,10 @@ lerobot-record \
## Tuning parameters
-
-The defaults are tuned to balance **compression ratio**, **visual quality**, and **decoding/seek speed** for typical robotics datasets. Changing them can affect both recording (CPU load, frame drops) and training (decoding throughput, image quality).
-
-Only override these parameters if you have a specific reason to, and measure the impact on your pipeline before relying on the new settings.
-
-
+> [!WARNING]
+> The defaults are tuned to balance **compression ratio**, **visual quality**, and **decoding/seek speed** for typical robotics datasets. Changing them can affect both recording (CPU load, frame drops) and training (decoding throughput, image quality).
+>
+> Only override these parameters if you have a specific reason to, and measure the impact on your pipeline before relying on the new settings.
All flags below are prefixed with `--dataset.rgb_encoder.` on the CLI.
@@ -69,25 +66,92 @@ All flags below are prefixed with `--dataset.rgb_encoder.` on the CLI.
Depth maps (Intel RealSense, Reachy 2) are stored as their **own video streams** alongside the RGB streams. Raw depth (`uint16` millimetres or `float32` metres) can't survive an 8-bit codec, so LeRobot **quantizes** each map to a 12-bit code (`[0, 4095]`) — logarithmically by default, to match the `1/depth` error profile of depth sensors — then packs it into a high-bit-depth pixel format (`gray12le`) and encodes it with a 12-bit codec.
-```mermaid
-flowchart LR
- A["Raw depth (uint16 mm / float32 m)"] --> B["Clip to depth_min, depth_max"]
- B --> C["Quantize to 12-bit code 0–4095 (log or linear)"]
- C --> D["Pack into gray12le"]
- D --> E["Encode video (hevc Main 12)"]
- E --> F[("MP4 + metadata: depth_min/max, shift, use_log")]
- F -. "load time (depth_output_unit)" .-> G["Dequantize to mm or m"]
-
- classDef input fill:#e3f2fd,stroke:#1565c0,color:#0d47a1;
- classDef encode fill:#ede7f6,stroke:#5e35b1,color:#311b92;
- classDef store fill:#fff8e1,stroke:#f9a825,color:#e65100;
- classDef load fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20;
-
- class A input;
- class B,C,D,E encode;
- class F store;
- class G load;
-```
+
+
+
+ Raw depth
+
+ uint16 mm
+
+ float32 m
+
+
+
+ →
+
+
+
+ Record time
+
+
+ Clip
+
+ to [depth_min,
+
+ depth_max]
+
+
+
+ →
+
+
+ Quantize
+
+ 12-bit codes 0–4095
+
+ log (default) or linear
+
+
+
+ →
+
+
+ Pack
+
+ into gray12le
+
+ plane
+
+
+
+ →
+
+
+ Encode
+
+ HEVC
+
+ Main 12
+
+
+
+
+ →
+
+
+ MP4
+
+ stored
+
+ stream
+
+
+
+ →
+
+
+
+ Load time
+
+
+ Dequantize
+
+ to mm / m
+
+
+
+
+
Configure the depth pipeline through a parallel **`depth_encoder`** block (`DepthEncoderConfig`). It shares every `RGBEncoderConfig` field (`vcodec`, `pix_fmt`, `crf`, …) and adds four quantizer knobs, set via `--dataset.depth_encoder.`:
@@ -168,15 +232,16 @@ After the first episode of a video stream is encoded, the encoder configuration
Two sources contribute to the `info` block:
-- **Stream-derived** (read back from the encoded MP4 with PyAV): `video.height`, `video.width`, `video.codec`, `video.pix_fmt`, `video.fps`, `video.channels`, `is_depth_map`, plus `audio.*` if an audio stream is present.
-- **Encoder-derived** (taken from `RGBEncoderConfig` or `DepthEncoderConfig`): `video.g`, `video.crf`, `video.preset`, `video.fast_decode`, `video.video_backend`, `video.extra_options`.
+| Source | Where it comes from | Fields |
+| ------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| **Stream-derived** | Read back from the encoded MP4 with PyAV. | `video.height`, `video.width`, `video.codec`, `video.pix_fmt`, `video.fps`, `video.channels`, `is_depth_map`, `audio.*` |
+| **Encoder-derived** | Taken from `RGBEncoderConfig` / `DepthEncoderConfig`. | `video.g`, `video.crf`, `video.preset`, `video.fast_decode`, `video.video_backend`, `video.extra_options` |
-
- This block is populated **once**, from the **first** episode. It assumes every
- episode in the dataset was encoded with the same `rgb_encoder`. Changing
- encoder settings partway through a recording is not supported — the
- `info.json` will only reflect the parameters used for the first episode.
-
+> [!IMPORTANT]
+> This block is populated **once**, from the **first** episode. It assumes every
+> episode in the dataset was encoded with the same `rgb_encoder`. Changing
+> encoder settings partway through a recording is not supported — the
+> `info.json` will only reflect the parameters used for the first episode.
---
@@ -184,5 +249,7 @@ Two sources contribute to the `info` block:
When aggregating datasets with `merge_datasets`, video files are concatenated as-is (no re-encoding), and encoder fields in `info.json` are merged per-key:
-- **Stream-derived fields must match** across sources: `video.codec`, `video.pix_fmt`, `video.height`, `video.width`, `video.fps`. Otherwise FFmpeg's concat demuxer fails.
-- **Encoder-tuning fields are merged loosely**: `video.g`, `video.crf`, `video.preset`, `video.fast_decode`, `video.extra_options`. If every source agrees, the value is kept; if not, it's set to `null` (or `{}` for `video.extra_options`) and a warning is logged.
+| Merge rule | Fields | Behaviour |
+| ------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Must match** | `video.codec`, `video.pix_fmt`, `video.height`, `video.width`, `video.fps` | Stream-derived fields must match across sources, otherwise FFmpeg's concat demuxer fails. |
+| **Merged loosely** | `video.g`, `video.crf`, `video.preset`, `video.fast_decode`, `video.extra_options` | Encoder-tuning fields. If every source agrees, the value is kept; if not, it's set to `null` (or `{}` for `video.extra_options`) and a warning is logged. |
diff --git a/examples/isaac_teleop_to_so101/README.md b/examples/isaac_teleop_to_so101/README.md
new file mode 100644
index 000000000..a2493af54
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/README.md
@@ -0,0 +1,131 @@
+# Isaac Teleop → SO-101
+
+Teleoperate an SO-101/SO-100 follower arm — and record LeRobot datasets — with NVIDIA
+[Isaac Teleop](https://github.com/NVIDIA/IsaacTeleop). Two input devices ship today:
+
+- **XR (VR) controller** (`--teleop.type=xr_controller`) — the controller's grip pose drives the
+ end-effector through a squeeze-to-engage clutch and LeRobot's Cartesian IK pipeline; the analog
+ trigger drives the gripper.
+- **SO-101 leader arm** (`--teleop.type=so101_leader`) — a back-drivable leader arm mirrored 1:1
+ onto the follower via Isaac Teleop's native `so101_leader` plugin (no clutch, no IK).
+
+The full narrative guide (how the clutch works, CloudXR setup, headset pairing, tuning, and
+troubleshooting) is in the [LeRobot docs](https://huggingface.co/docs/lerobot/isaac_teleop)
+(source: `docs/source/isaac_teleop.mdx`). This README is the canonical install and usage
+reference.
+
+## Requirements
+
+- Linux workstation (see NVIDIA's
+ [system requirements](https://nvidia.github.io/IsaacTeleop/main/references/requirements.html)
+ for supported OS/GPU/headset combinations; `isaacteleop` publishes Linux wheels only).
+- An SO-101 (or SO-100) follower arm, calibrated with `lerobot-calibrate`.
+- For the XR device: a CloudXR-capable headset (e.g. Quest 3, Pico 4, Apple Vision Pro) on the
+ same network.
+- For the leader device: a second, back-drivable SO-101 leader arm and the `so101_leader` plugin
+ binary built from the Isaac Teleop source tree (see
+ [Build from source](https://nvidia.github.io/IsaacTeleop/main/getting_started/build_from_source/index.html)).
+
+## Installation
+
+This example lives in the LeRobot repository and is not part of the `lerobot` pip package, so
+work from a source checkout. From the repo root:
+
+```bash
+# LeRobot with the extras this example uses:
+# feetech - SO-101 serial motor bus
+# kinematics - Placo IK solver (XR controller path)
+# dataset - dataset recording (record.py)
+# huggingface_hub >= 1.5 is needed by the automatic URDF fetch (Buckets API).
+uv pip install -e ".[feetech,kinematics,dataset]" "huggingface_hub>=1.5"
+
+# Isaac Teleop from public PyPI. `cloudxr` brings the CloudXR runtime bindings;
+# `retargeters-lite` is the scipy-based retargeter path that resolves on both
+# x86_64 and ARM (the full `retargeters` extra does not resolve on aarch64).
+uv pip install "isaacteleop[cloudxr,retargeters-lite]~=1.3.131" "scipy>=1.14"
+
+# Optional, x86_64 only: the full retargeter stack.
+uv pip install "isaacteleop[retargeters]~=1.3.131"
+```
+
+One-time CloudXR EULA (the auto-launch prompts on stdin and would hang on a headless machine):
+
+```bash
+python -m isaacteleop.cloudxr --accept-eula
+```
+
+## Usage
+
+Run everything from the repo root with `python -m` so the `examples` package resolves.
+
+### Teleoperate — XR controller
+
+```bash
+python -m examples.isaac_teleop_to_so101.teleoperate \
+ --robot.type=so101_follower \
+ --robot.port=/dev/ttyACM0 \
+ --robot.id=so101_follower_arm \
+ --teleop.type=xr_controller
+```
+
+On startup the script launches the CloudXR runtime (~30 s), prints the workstation IP to enter in
+the headset's CloudXR web client, waits for the controllers to stream, slews the arm to a reset
+pose (`--reset_to_origin=false` to skip), and then: **hold the squeeze/grip** to engage, move the
+controller to drive the arm, pull the trigger to close the gripper. Releasing the squeeze freezes
+the arm. The SO-101 URDF is fetched automatically from the `lerobot/robot-urdfs` Hugging Face
+bucket into the LeRobot cache on first run.
+
+To customize the reset pose: back-drive the arm to the pose you want, then
+
+```bash
+python -m examples.isaac_teleop_to_so101.override_reset_pose --port /dev/ttyACM0 --id so101_follower_arm
+```
+
+which writes it to `HF_LEROBOT_HOME/reset_poses//.json`; runs with the same
+`--robot.id` use it automatically.
+
+### Teleoperate — SO-101 leader arm
+
+```bash
+python -m examples.isaac_teleop_to_so101.teleoperate \
+ --robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=so101_follower_arm \
+ --teleop.type=so101_leader --teleop.port=/dev/ttyACM1 --teleop.id=so101_leader_arm \
+ --launch_plugin=/path/to/IsaacTeleop/install/plugins/so101_leader/so101_leader_plugin
+```
+
+The follower is first slewed to the leader's pose over `--align_duration` seconds
+(`--align=false` to skip), then mirrors it 1:1. The plugin reuses the serial leader's calibration
+(`HF_LEROBOT_CALIBRATION/teleoperators/so_leader/.json`).
+
+### Record a dataset
+
+`record.py` takes the same `--robot.*`/`--teleop.*`/loop flags plus `lerobot-record`-style
+`--dataset.*` flags:
+
+```bash
+python -m examples.isaac_teleop_to_so101.record \
+ --robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=so101_follower_arm \
+ --teleop.type=xr_controller \
+ --robot.cameras="{ front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}}" \
+ --dataset.repo_id=/ \
+ --dataset.single_task="Pick up the cube" \
+ --dataset.num_episodes=3 --dataset.episode_time_s=20 --dataset.reset_time_s=5
+```
+
+Keyboard shortcuts (terminal-first, so they work over SSH): **Right/n** end episode early,
+**Left/r** re-record, **Esc/q** stop after the current episode.
+
+Run either script with `--help` for all flags.
+
+## Layout
+
+```
+isaac_teleop/ device library: session lifecycle (base.py), XRController,
+ SO101LeaderArm, Clutch, configs, and the XR→IK processor step
+common.py shared loop infra: device bundles, clutch/IK pipeline wiring,
+ reset/align slews, URDF fetch, keyboard listener
+teleoperate.py teleoperation CLI (device selected via --teleop.type)
+record.py dataset-recording CLI (same device selection + --dataset.*)
+override_reset_pose.py save the current joints as the per-arm reset pose
+default.env CloudXR device-profile overrides passed to the launcher
+```
diff --git a/examples/isaac_teleop_to_so101/__init__.py b/examples/isaac_teleop_to_so101/__init__.py
new file mode 100644
index 000000000..a05119e31
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/__init__.py
@@ -0,0 +1,17 @@
+#!/usr/bin/env python
+
+# Copyright 2026 NVIDIA Corporation 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.
+
+"""Isaac Teleop -> SO-101 example package."""
diff --git a/examples/isaac_teleop_to_so101/common.py b/examples/isaac_teleop_to_so101/common.py
new file mode 100644
index 000000000..80f56bae4
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/common.py
@@ -0,0 +1,650 @@
+#!/usr/bin/env python
+
+# Copyright 2026 NVIDIA Corporation 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.
+
+"""Shared device + control-loop infrastructure for the Isaac Teleop -> SO-101 examples.
+
+Consumed by ``teleoperate.py`` and ``record.py``, which both build a per-device
+:class:`Device` bundle and run the same loop: read -> (maybe command) -> hold-when-idle ->
+sleep. A :class:`Device` bundles three closures: ``compute(obs) -> RobotAction | None``
+(``None`` = hold at the measured pose while idle), ``startup``, and ``cleanup``. The devices:
+
+* ``xr_controller`` — a thin :class:`XRController` whose raw grip pose an in-loop
+ :class:`Clutch` turns into an EE target for LeRobot's Cartesian IK pipeline.
+* ``so101_leader`` — a back-drivable leader arm mirrored 1:1 into the follower.
+
+Requires the ``isaacteleop`` package and an OpenXR runtime (install instructions in this
+folder's ``README.md``). User-facing guide: ``docs/source/isaac_teleop.mdx``.
+"""
+
+import json
+import logging
+import socket
+import subprocess
+import sys
+import time
+from collections.abc import Callable
+from contextlib import suppress
+from dataclasses import dataclass
+from importlib.resources import files
+from pathlib import Path
+from typing import Protocol
+
+import numpy as np
+
+from lerobot.model.kinematics import RobotKinematics
+from lerobot.processor import (
+ RobotProcessorPipeline,
+ robot_action_observation_to_transition,
+ transition_to_robot_action,
+)
+from lerobot.robots import RobotConfig, make_robot_from_config
+from lerobot.robots.so_follower import SOFollowerConfig # noqa: F401 (registers so101_follower)
+from lerobot.robots.so_follower.robot_kinematic_processor import (
+ EEBoundsAndSafety,
+ InverseKinematicsEEToJoints,
+)
+from lerobot.types import RobotAction, RobotObservation
+from lerobot.utils.constants import HF_LEROBOT_CALIBRATION, HF_LEROBOT_HOME, TELEOPERATORS
+from lerobot.utils.robot_utils import precise_sleep
+
+from .isaac_teleop import (
+ Clutch,
+ IsaacTeleopConfig,
+ MapXRControllerActionToRobotAction,
+ SO101LeaderArm,
+ SO101LeaderArmConfig,
+ XRController,
+)
+
+# Fixed rate [Hz] for the teleoperate loop and the pre-loop slews / connect-wait poll sleeps.
+FPS = 30
+
+# CloudXR device-profile env file passed to the launcher (see default.env in this package).
+CLOUDXR_ENV_FILE = str(files(__package__) / "default.env")
+
+
+class LoopConfig(Protocol):
+ """Structural type for the loop/launch knobs ``build_device`` and the ``setup_*`` read.
+
+ Both ``TeleoperateConfig`` and ``RecordConfig`` satisfy it, keeping ``common`` decoupled
+ from either entry point's concrete config.
+ """
+
+ teleop: IsaacTeleopConfig
+ robot: RobotConfig
+ launch_plugin: str | None
+ reset_to_origin: bool
+ reset_duration: float
+ align: bool
+ align_duration: float
+
+
+# Per-device bundle consumed by the shared loop. ``compute`` returns None to mean
+# "idle -> hold at the measured pose"; ``startup`` warms up; ``cleanup`` reaps/disconnects.
+@dataclass(frozen=True)
+class Device:
+ compute: Callable[[RobotObservation | None], RobotAction | None]
+ startup: Callable[[], None]
+ cleanup: Callable[[], None]
+
+
+def hold_action(obs: RobotObservation, motor_names: list[str]) -> dict[str, float]:
+ """Re-send the measured joints — the explicit hold when a device is idle."""
+ return {f"{name}.pos": float(obs[f"{name}.pos"]) for name in motor_names}
+
+
+class HoldLatch:
+ """Resolve the per-frame action, holding one LATCHED pose while the device is idle.
+
+ Re-sending the freshly measured joints on every idle frame would ratchet the arm
+ downward: under gravity the P-only servo settles below its goal by a steady-state
+ error, so each re-command of the measurement lowers the goal by that error again.
+ Latching the target once on the active->idle transition holds a fixed pose instead.
+ """
+
+ def __init__(self, motor_names: list[str]):
+ self._motor_names = motor_names
+ self._held: dict[str, float] | None = None
+
+ def resolve(self, action: RobotAction | None, obs: RobotObservation) -> RobotAction:
+ """Pass through an active action (clearing the latch); latch + hold when idle."""
+ if action is not None:
+ self._held = None
+ return action
+ if self._held is None:
+ self._held = hold_action(obs, self._motor_names)
+ return self._held
+
+
+def slew(
+ robot,
+ motor_names: list[str],
+ target_fn: Callable[[], dict[str, float]],
+ duration_s: float,
+) -> None:
+ """Linearly slew all joints from their current measured pose toward a target.
+
+ ``target_fn`` is called EACH step, so the leader can pass a live re-read (landing on its
+ current pose at ``alpha == 1`` for a continuous handoff) while XR passes a constant.
+ """
+ obs = robot.get_observation()
+ start = {name: float(obs[f"{name}.pos"]) for name in motor_names}
+ n_steps = max(1, int(duration_s * FPS))
+ for step in range(1, n_steps + 1):
+ alpha = step / n_steps
+ target = target_fn()
+ action = {f"{name}.pos": start[name] + alpha * (target[name] - start[name]) for name in motor_names}
+ robot.send_action(action)
+ precise_sleep(1.0 / FPS)
+
+
+# ============================================================================
+# XR controller device
+# ============================================================================
+
+# Per-frame EE rate limit [m]. With raise_on_jump=False, EEBoundsAndSafety clamps an
+# over-limit step instead of raising, absorbing a tracking glitch as one slow frame. At
+# FPS=30, 0.1 m/frame caps EE speed at ~3 m/s. (end_effector_bounds clips the absolute target.)
+MAX_EE_STEP_M = 0.1
+
+# Soft-orientation IK weight: small but nonzero so the wrist follows the hand while position
+# dominates (the 5-DOF SO-101 cannot realize an arbitrary orientation). 0.0 = position-only.
+IK_ORIENTATION_WEIGHT = 0.01
+
+
+def _ensure_so101_urdf() -> str:
+ """Return the cached SO-101 URDF path, fetching the ``so101`` folder (URDF + meshes) from
+ the public ``lerobot/robot-urdfs`` HF bucket into the LeRobot cache on first use."""
+ dest_dir = HF_LEROBOT_HOME / "robot-urdfs" / "so101"
+ urdf_path = dest_dir / "so101_new_calib.urdf"
+ # Completeness marker written only after a FULL sync: the URDF file alone is not a
+ # completeness signal (an interrupted first sync can leave the meshes it references
+ # missing, which the URDF's mere existence would then hide forever). Re-syncing is
+ # idempotent and repairs a partial cache; delete the folder to force a re-download.
+ marker = dest_dir / ".sync_complete"
+ if not marker.exists():
+ from huggingface_hub import sync_bucket
+
+ sync_bucket("hf://buckets/lerobot/robot-urdfs/so101", str(dest_dir), quiet=True)
+ marker.touch()
+ return str(urdf_path)
+
+
+# Default duration [s] for the startup reset-to-origin slew.
+RESET_DURATION_S = 5.0
+
+# Optional cached file written by override_reset_pose.py. When present it takes priority over RESET_ORIGIN_DEG.
+RESET_POSE_FILE = str(HF_LEROBOT_HOME / "reset_poses" / "{robot_name}" / "{robot_id}.json")
+
+# Reset target in each motor's native units (arm joints in degrees, gripper RANGE_0_100,
+# 100 = open). An empirically comfortable pose (elbow/wrist bent) avoiding the singularity of
+# a fully-extended arm; assumes standard calibration. Override per-arm via override_reset_pose.py.
+RESET_ORIGIN_DEG: dict[str, float] = {
+ "shoulder_pan": -4.0,
+ "shoulder_lift": -103.0,
+ "elbow_flex": 97.0,
+ "wrist_flex": 78.0,
+ "wrist_roll": -65.0,
+ "gripper": 0.0,
+}
+
+
+def _load_reset_target(reset_pose_file: Path, motor_names: list[str]) -> dict[str, float]:
+ """Return reset targets: the saved reset pose if present, else RESET_ORIGIN_DEG."""
+ if reset_pose_file.exists():
+ saved = json.loads(reset_pose_file.read_text())
+ # Fill any missing motors from the fallback dict.
+ return {name: float(saved.get(name, RESET_ORIGIN_DEG.get(name, 0.0))) for name in motor_names}
+ return {name: RESET_ORIGIN_DEG.get(name, 0.0) for name in motor_names}
+
+
+# CloudXR web client URL opened in the headset (Isaac Teleop quick start, step 5).
+_CLOUDXR_WEB_CLIENT_URL = "https://nvidia.github.io/IsaacTeleop/client"
+# WSS-proxy / self-signed-cert port the operator accepts in-browser before connecting.
+_CLOUDXR_WSS_PORT = 48322
+# How often to re-print the connection hint while waiting for the headset [s].
+_XR_CONNECT_REMINDER_S = 15.0
+# Virtual / bridge / USB-gadget interfaces a headset can't reach over the network — skip
+# by name prefix (``docker0``, compose ``br-*``, ``veth*``, libvirt ``virbr*``, and the
+# Tegra USB device-mode bridge ``l4tbr0``).
+_SKIP_IFACE_PREFIXES = ("docker", "br-", "veth", "virbr", "l4tbr")
+
+
+def _primary_ipv4() -> str | None:
+ """The workstation's primary outbound IPv4, via the UDP-socket trick (``connect()`` on a
+ datagram socket selects the egress interface without sending packets)."""
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
+ try:
+ s.connect(("8.8.8.8", 80))
+ return s.getsockname()[0]
+ except OSError:
+ return None
+
+
+def _candidate_ipv4s() -> list[tuple[str, str]]:
+ """Return ``[(interface, ipv4), ...]`` the headset might reach this workstation at.
+
+ Lists each interface's IPv4 via ``psutil`` (dropping loopback, link-local, and the
+ virtual/bridge interfaces in ``_SKIP_IFACE_PREFIXES``), primary outbound first. Falls
+ back to just the primary IP when ``psutil`` is unavailable.
+ """
+ primary = _primary_ipv4()
+ found: list[tuple[str, str]] = []
+ try:
+ import psutil
+
+ for iface, addrs in psutil.net_if_addrs().items():
+ if iface.startswith(_SKIP_IFACE_PREFIXES):
+ continue
+ for addr in addrs:
+ if addr.family != socket.AF_INET:
+ continue
+ ip = addr.address
+ if ip.startswith("127.") or ip.startswith("169.254."):
+ continue
+ found.append((iface, ip))
+ except Exception:
+ if primary:
+ found.append(("default", primary))
+ found.sort(key=lambda t: t[1] != primary) # primary outbound interface first
+ return found
+
+
+def _print_xr_connect_help() -> None:
+ """Print how to connect the headset to this workstation over CloudXR."""
+ ips = _candidate_ipv4s()
+ print("\n" + "=" * 76)
+ print("Connect your XR headset to this workstation over NVIDIA CloudXR:")
+ print(f" 1. In the headset, open the CloudXR web client: {_CLOUDXR_WEB_CLIENT_URL}")
+ print(" 2. Enter this workstation's IP address:")
+ if ips:
+ for iface, ip in ips:
+ print(f" {ip:<15} ({iface})")
+ if len(ips) > 1:
+ print(" (use the address on the same network as your headset)")
+ else:
+ print(" ")
+ print(f" 3. Accept the self-signed cert at https://:{_CLOUDXR_WSS_PORT}/ , then Connect.")
+ print("=" * 76 + "\n")
+
+
+def _wait_for_xr_controller(teleop_device: XRController) -> None:
+ """Block until the XR controller is tracked, polling ``get_action()`` and re-printing a
+ reminder every ``_XR_CONNECT_REMINDER_S``. User-paced; ``Ctrl-C`` aborts (no hard timeout).
+ """
+ _print_xr_connect_help()
+ print("Waiting for the headset controllers to start streaming… (Ctrl-C to abort)")
+ last_reminder = time.time()
+ while True:
+ teleop_device.get_action() # steps the session; updates is_tracking
+ if teleop_device.is_tracking:
+ print("Headset connected — controllers are streaming.")
+ return
+ if time.time() - last_reminder >= _XR_CONNECT_REMINDER_S:
+ print("…still waiting for the headset to connect (Ctrl-C to abort).")
+ last_reminder = time.time()
+ time.sleep(1.0 / FPS)
+
+
+def setup_xr(cfg: LoopConfig, robot, motor_names: list[str]) -> Device:
+ """Build the XR controller device bundle (clutch + soft-orientation IK pipeline)."""
+ kinematics_solver = RobotKinematics(
+ urdf_path=_ensure_so101_urdf(),
+ target_frame_name="gripper_frame_link",
+ joint_names=motor_names,
+ )
+
+ teleop_config = cfg.teleop # XRControllerConfig (selected via --teleop.type=xr_controller)
+ teleop_device = XRController(teleop_config)
+
+ # The clutch (below) turns the raw grip pose into an absolute base-frame ee_pose; this
+ # pipeline maps it to joint targets: rename -> bounds/rate-limit -> IK.
+ xr_to_robot_joints_processor = RobotProcessorPipeline[tuple[RobotAction, RobotObservation], RobotAction](
+ steps=[
+ MapXRControllerActionToRobotAction(),
+ # raise_on_jump=False: an over-limit step (e.g. a tracking glitch) is clamped +
+ # warned instead of raised, since a crash mid-loop would leave the arm uncontrolled.
+ # z floor 0.0 keeps a stray target above the table; x/y stay at a loose [-1,1]m box.
+ EEBoundsAndSafety(
+ end_effector_bounds={"min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 1.0]},
+ max_ee_step_m=MAX_EE_STEP_M,
+ raise_on_jump=False,
+ ),
+ # initial_guess_current_joints=False: warm-start from the previous IK solution so
+ # the joint trajectory stays continuous frame-to-frame.
+ InverseKinematicsEEToJoints(
+ kinematics=kinematics_solver,
+ motor_names=motor_names,
+ initial_guess_current_joints=False,
+ orientation_weight=IK_ORIENTATION_WEIGHT,
+ ),
+ ],
+ to_transition=robot_action_observation_to_transition,
+ to_output=transition_to_robot_action,
+ )
+
+ # The clutch is built in startup() (after the optional reset slew, seeded from the
+ # post-slew MEASURED pose) and shared with compute() via nonlocal.
+ clutch: Clutch | None = None
+ prev_enabled = False
+
+ def startup() -> None:
+ nonlocal clutch
+ # Connect and wait for the operator to don the headset BEFORE moving the arm, so the
+ # reset slew happens while they are watching in VR.
+ teleop_device.connect()
+ if not teleop_device.is_connected:
+ raise ValueError("Teleop is not connected!")
+ _wait_for_xr_controller(teleop_device)
+
+ if cfg.reset_to_origin:
+ reset_pose_file = Path(RESET_POSE_FILE.format(robot_name=robot.name, robot_id=robot.id))
+ target = _load_reset_target(reset_pose_file, motor_names)
+ source = str(reset_pose_file) if reset_pose_file.exists() else "hardcoded defaults"
+ print(f"Reset target source: {source}")
+ print(f"Resetting to origin over {cfg.reset_duration:.1f} s…")
+ slew(robot, motor_names, lambda: target, cfg.reset_duration)
+ print("Reset complete.")
+
+ # Seed the clutch home from the arm's measured pose (FK of the current joints) so the
+ # first engage is jump-free, whether or not a reset slew ran.
+ obs0 = robot.get_observation()
+ q_measured_deg = np.array([float(obs0[f"{name}.pos"]) for name in motor_names], dtype=float)
+ home_base_T_ee = kinematics_solver.forward_kinematics(q_measured_deg) # noqa: N806
+ clutch = Clutch(home_base_T_ee)
+
+ print("Starting teleop loop. Squeeze and move the controller to teleoperate the robot...")
+
+ def compute(robot_obs: RobotObservation | None) -> RobotAction | None:
+ nonlocal prev_enabled
+ if clutch is None: # set in startup(), which runs before compute()
+ raise RuntimeError("compute() called before startup(); the clutch is not initialized")
+ xr_action = teleop_device.get_action()
+ grip_pos = np.asarray(xr_action["grip_pos"], dtype=float)
+ grip_quat = np.asarray(xr_action["grip_quat"], dtype=float)
+ squeeze = float(xr_action["squeeze"])
+ trigger = float(xr_action["trigger"])
+ enabled = squeeze > teleop_config.clutch_threshold
+
+ # On the engage edge, latch the clutch home at the arm's MEASURED EE pose (FK of
+ # the live joints) and the controller origin so the per-frame delta starts at zero.
+ # Latching the last commanded pose instead would snap the arm back to it at full
+ # servo speed if the arm moved while disengaged (gravity sag, external contact).
+ is_engage_frame = enabled and not prev_enabled
+ if is_engage_frame:
+ q_measured = np.array([float(robot_obs[f"{name}.pos"]) for name in motor_names], dtype=float)
+ measured_base_T_ee = kinematics_solver.forward_kinematics(q_measured) # noqa: N806
+ clutch.engage(grip_pos, grip_quat, measured_base_T_ee=measured_base_T_ee)
+ # Re-anchor the pipeline state at the measured pose as well: EEBoundsAndSafety's
+ # rate limiter and the IK warm start otherwise still reference the stale
+ # pre-disengage command and would fight the fresh home for several frames.
+ xr_to_robot_joints_processor.reset()
+ prev_enabled = enabled
+
+ # SAFETY GATE: command the robot ONLY while the clutch is engaged; otherwise return
+ # None so the loop holds the measured joints (releasing the clutch freezes the arm).
+ if not enabled:
+ return None
+
+ # Rebase the raw grip pose onto the EE, then run the pipeline. closedness = trigger.
+ ee_pos, ee_quat = clutch.rebase(grip_pos, grip_quat)
+ ee_action = {
+ "ee_pose": np.concatenate([ee_pos, ee_quat]).astype(np.float32),
+ "closedness": trigger,
+ }
+ return xr_to_robot_joints_processor((ee_action, robot_obs))
+
+ return Device(compute=compute, startup=startup, cleanup=teleop_device.disconnect)
+
+
+# ============================================================================
+# SO-101 leader arm device
+# ============================================================================
+
+# Default duration [s] for the startup alignment slew (follower current -> leader first pose).
+ALIGN_DURATION_S = 3.0
+
+# How long to wait for the leader plugin to start streaming before aligning / looping.
+LEADER_WARMUP_TIMEOUT_S = 20.0
+
+# The plugin converts the leader's servo ticks to radians, so it reuses the serial SO-101
+# leader's calibration, stored by lerobot-calibrate under SO101Leader.name == "so_leader".
+SO_LEADER_CALIBRATION_NAME = "so_leader"
+
+
+def _leader_calibration_path(cfg: LoopConfig) -> Path | None:
+ """Infer the calibration JSON the launched plugin should read, or None.
+
+ Path convention: ``HF_LEROBOT_CALIBRATION / teleoperators / so_leader / {--teleop.id}.json``
+ (or ``--teleop.calibration_dir`` if set). Returns None (plugin falls back to defaults) when
+ it does not exist, warning if an id was given, or when no ``--teleop.id`` is set.
+ """
+ if not cfg.teleop.id:
+ return None
+ calib_dir = cfg.teleop.calibration_dir or (
+ HF_LEROBOT_CALIBRATION / TELEOPERATORS / SO_LEADER_CALIBRATION_NAME
+ )
+ calib_path = Path(calib_dir) / f"{cfg.teleop.id}.json"
+ if calib_path.is_file():
+ return calib_path
+ print(
+ f"WARNING: no leader calibration at {calib_path}; the plugin will use built-in defaults. "
+ f"Calibrate with the serial leader (`lerobot-calibrate --teleop.type=so101_leader "
+ f"--teleop.id={cfg.teleop.id}`) or the plugin's `calibrate` subcommand."
+ )
+ return None
+
+
+def _wait_for_leader(teleop: SO101LeaderArm, timeout_s: float) -> dict[str, float]:
+ """Poll the leader until it streams a live frame; return that frame's ``{joint}.pos``.
+
+ Raises ``SystemExit`` if no live frame arrives within ``timeout_s`` (plugin not pushing,
+ wrong ``--teleop.collection_id``, or CloudXR not up).
+ """
+ print(f"Waiting up to {timeout_s:.0f}s for the so101_leader plugin to stream…")
+ deadline = time.time() + timeout_s
+ while time.time() < deadline:
+ action = teleop.get_action()
+ if teleop.is_tracking:
+ print("Leader is streaming.")
+ return action
+ time.sleep(1.0 / FPS)
+ raise SystemExit(
+ f"FAILED: leader did not stream within {timeout_s:.0f}s. Is the so101_leader plugin "
+ "running and pushing (check --teleop.collection_id)? Is CloudXR up?"
+ )
+
+
+def _maybe_launch_plugin(cfg: LoopConfig) -> subprocess.Popen | None:
+ """Spawn the so101_leader plugin if ``--launch_plugin `` was given (after connect())."""
+ if cfg.launch_plugin is None:
+ return None
+ if not Path(cfg.launch_plugin).exists():
+ raise SystemExit(
+ f"plugin binary not found: {cfg.launch_plugin} (build it in the IsaacTeleop repo first)"
+ )
+ leader_port = cfg.teleop.port # SO101LeaderArmConfig.port, forwarded to the plugin
+ backend = f"leader on {leader_port}" if leader_port else "synthetic trajectory"
+ print(f"launching plugin: {cfg.launch_plugin} ({backend})")
+ # Positional args: [device_path] [collection_id] [calibration_file]. Empty device_path ->
+ # synthetic backend. Calibration (only real hardware needs it) is appended when a port is set.
+ argv = [cfg.launch_plugin, leader_port, cfg.teleop.collection_id]
+ if leader_port:
+ calib_path = _leader_calibration_path(cfg)
+ if calib_path is not None:
+ argv.append(str(calib_path))
+ print(f" leader calibration: {calib_path}")
+ # Spawned after connect() so it inherits the CloudXR runtime env (XR_RUNTIME_JSON, ...).
+ proc = subprocess.Popen(argv)
+ time.sleep(1.5) # let it create its OpenXR session and start pushing
+ return proc
+
+
+def setup_leader(cfg: LoopConfig, robot, motor_names: list[str]) -> Device:
+ """Build the SO-101 leader arm device bundle (1:1 joint mirror)."""
+ teleop_config = cfg.teleop # SO101LeaderArmConfig (selected via --teleop.type=so101_leader)
+ teleop = SO101LeaderArm(teleop_config)
+
+ plugin_proc: subprocess.Popen | None = None
+
+ def startup() -> None:
+ nonlocal plugin_proc
+ # connect() auto-launches CloudXR (unless opted out); spawn the plugin AFTER so it
+ # inherits the runtime env. The plugin is reaped in cleanup().
+ teleop.connect()
+ plugin_proc = _maybe_launch_plugin(cfg)
+
+ if not teleop.is_connected:
+ raise ValueError("Teleop is not connected!")
+
+ # Block until the leader streams a live frame (clear error if it never does).
+ _wait_for_leader(teleop, LEADER_WARMUP_TIMEOUT_S)
+
+ if cfg.align:
+ print(f"Aligning follower to leader over {cfg.align_duration:.1f}s…")
+
+ # Re-read the live leader pose once per step so alpha=1 lands on its current pose
+ # from a single coherent frame.
+ def _leader_target() -> dict[str, float]:
+ leader_now = teleop.get_action()
+ return {name: float(leader_now[f"{name}.pos"]) for name in motor_names}
+
+ slew(robot, motor_names, _leader_target, cfg.align_duration)
+ print("Alignment complete.")
+
+ print(
+ "Starting joint-mirror loop. Back-drive the leader to teleoperate the follower… (Ctrl-C to stop)"
+ )
+
+ def compute(robot_obs: RobotObservation | None) -> RobotAction | None:
+ leader_action = teleop.get_action()
+ # Hold the follower at its measured pose when the leader drops out (stale stream)
+ # rather than commanding a possibly-old target.
+ if not teleop.is_tracking:
+ return None
+ return leader_action
+
+ def cleanup() -> None:
+ # A plugin-reaping failure must not skip the session disconnect (and vice versa
+ # the disconnect runs after the plugin stops pushing on it).
+ try:
+ if plugin_proc is not None:
+ plugin_proc.terminate()
+ try:
+ plugin_proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ plugin_proc.kill()
+ finally:
+ teleop.disconnect()
+
+ return Device(compute=compute, startup=startup, cleanup=cleanup)
+
+
+# ============================================================================
+# Shared setup
+# ============================================================================
+
+
+def build_device(cfg: LoopConfig) -> tuple:
+ """Connect the follower, build the selected Isaac device, and run its pre-loop startup.
+
+ Connects the follower FIRST (so the startup slew / clutch-home seed can read live joints),
+ dispatches on ``--teleop.type``, then runs ``device.startup()`` before returning. On any
+ failure after ``connect()`` the follower is disconnected so the connection never leaks.
+
+ Returns ``(robot, device, motor_names)``.
+ """
+ # Default the CloudXR input profile to this example's default.env unless the user overrode
+ # it via --teleop.cloudxr_env_file.
+ if cfg.teleop.cloudxr_env_file is None:
+ cfg.teleop.cloudxr_env_file = CLOUDXR_ENV_FILE
+
+ # SO-101/SO-100 only (both share the SO-101 URDF), reject other followers.
+ supported_robots = {"so101_follower", "so100_follower"}
+ if cfg.robot.type not in supported_robots:
+ raise ValueError(
+ f"This example only supports SO-101/SO-100 followers ({sorted(supported_robots)}), "
+ f"but got --robot.type={cfg.robot.type}."
+ )
+
+ # The degree-based pipeline relies on --robot.use_degrees (default True).
+ robot = make_robot_from_config(cfg.robot)
+ # Connect FIRST so the startup slew and clutch-home seed can read live joints.
+ robot.connect()
+ # Everything after connect() can fail; this runs outside the callers' try/finally, so
+ # disconnect the follower on any failure to avoid leaking the connection.
+ device: Device | None = None
+ try:
+ # Joint names in action order, read from {name}.pos action features (robot-agnostic).
+ motor_names = [key.removesuffix(".pos") for key in robot.action_features if key.endswith(".pos")]
+
+ if isinstance(cfg.teleop, SO101LeaderArmConfig):
+ device = setup_leader(cfg, robot, motor_names)
+ else:
+ device = setup_xr(cfg, robot, motor_names)
+
+ device.startup()
+ except BaseException:
+ # Reap a partially-started device, then always disconnect the follower.
+ if device is not None:
+ with suppress(Exception):
+ device.cleanup()
+ robot.disconnect()
+ raise
+
+ return robot, device, motor_names
+
+
+# ============================================================================
+# Keyboard control
+# ============================================================================
+
+
+def init_keyboard_listener():
+ """Recording shortcuts, terminal-first so they work over SSH.
+
+ Whenever stdin is a TTY we use the stdlib :class:`TerminalKeyListener` directly rather
+ than upstream's pynput-first :func:`init_keyboard_listener`, whose global listener would
+ capture the workstation console instead of this (often SSH) terminal. With no TTY we defer
+ to upstream (pynput on a GUI, else headless no-op).
+ """
+ if not (sys.stdin is not None and sys.stdin.isatty()):
+ from lerobot.utils.keyboard_input import init_keyboard_listener as _upstream
+
+ return _upstream()
+
+ from lerobot.utils.keyboard_input import TerminalKeyListener, apply_recording_control
+
+ events = {"exit_early": False, "rerecord_episode": False, "stop_recording": False}
+
+ # n/r/q are the arrow/Esc equivalents that survive escape-sequence splitting over laggy
+ # SSH/VNC links. Case-insensitive so Shift+letter still works.
+ def on_key(name: str) -> None:
+ key = name.lower()
+ if key in ("right", "n"):
+ apply_recording_control("right", events)
+ elif key in ("left", "r"):
+ apply_recording_control("left", events)
+ elif key in ("esc", "q"):
+ apply_recording_control("esc", events)
+
+ listener = TerminalKeyListener(on_key)
+ listener.start()
+ logging.info(
+ "Keyboard control via terminal — keep this terminal focused: "
+ "Right/n = end episode early, Left/r = re-record, Esc/q = stop."
+ )
+ return listener, events
diff --git a/examples/isaac_teleop_to_so101/default.env b/examples/isaac_teleop_to_so101/default.env
new file mode 100644
index 000000000..6f815b66e
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/default.env
@@ -0,0 +1,21 @@
+# CloudXR device-profile overrides for the Isaac Teleop XR -> SO-101 example.
+#
+# Passed to isaacteleop's CloudXRLauncher as `env_config` (via
+# XRControllerConfig.cloudxr_env_file). Format: KEY=value, one per line; `#`
+# comments and blank lines ignored; $VARS / ~ expanded. See
+# isaacteleop/cloudxr/env_config.py::_load_env_file.
+#
+# Runtime-resolved keys (XR_RUNTIME_JSON, XRT_NO_STDIN, NV_CXR_RUNTIME_DIR,
+# NV_CXR_OUTPUT_DIR) are reserved and ignored if set here.
+
+# Transport profile the runtime advertises (CloudXR default: auto-webrtc).
+# "Quest3" also covers the Pico 4. Other values: auto-native, AppleVisionPro.
+NV_DEVICE_PROFILE=Quest3
+
+# Input device discovery channels (both default to true; pinned for clarity).
+NV_CXR_ENABLE_PUSH_DEVICES=true
+NV_CXR_ENABLE_TENSOR_DATA=true
+
+# Runtime logs to ~/.cloudxr/logs — helps debug connection issues
+# (e.g. "Failed to get OpenXR system: -35").
+NV_CXR_FILE_LOGGING=true
diff --git a/examples/isaac_teleop_to_so101/isaac_teleop/__init__.py b/examples/isaac_teleop_to_so101/isaac_teleop/__init__.py
new file mode 100644
index 000000000..17d9919b3
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/isaac_teleop/__init__.py
@@ -0,0 +1,40 @@
+#!/usr/bin/env python
+
+# Copyright 2026 NVIDIA Corporation 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.
+
+"""NVIDIA Isaac Teleop teleoperators for LeRobot.
+
+Each input device is an :class:`IsaacTeleopTeleoperator` subclass: :class:`XRController`
+(XR/VR controller) and :class:`SO101LeaderArm` (back-drivable SO-101 leader arm) ship today.
+"""
+
+from .base import IsaacTeleopTeleoperator
+from .clutch import Clutch
+from .config_isaac_teleop import IsaacTeleopConfig, SO101LeaderArmConfig, XRControllerConfig
+from .teleop_so101_leader_arm import SO101LeaderArm, leader_joints_to_robot_action
+from .teleop_xr_controller import XRController
+from .xr_controller_processor import MapXRControllerActionToRobotAction
+
+__all__ = [
+ "Clutch",
+ "IsaacTeleopConfig",
+ "IsaacTeleopTeleoperator",
+ "MapXRControllerActionToRobotAction",
+ "SO101LeaderArm",
+ "SO101LeaderArmConfig",
+ "XRController",
+ "XRControllerConfig",
+ "leader_joints_to_robot_action",
+]
diff --git a/examples/isaac_teleop_to_so101/isaac_teleop/base.py b/examples/isaac_teleop_to_so101/isaac_teleop/base.py
new file mode 100644
index 000000000..3a4e5c585
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/isaac_teleop/base.py
@@ -0,0 +1,282 @@
+#!/usr/bin/env python
+
+# Copyright 2026 NVIDIA Corporation 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.
+
+"""Shared base for NVIDIA Isaac Teleop-backed LeRobot teleoperators.
+
+Isaac Teleop is a multi-modal framework: a single ``TeleopSession`` can be driven by
+XR controllers, hand tracking, Manus gloves, etc. Each modality is a
+:class:`Teleoperator` subclass in its own ``teleop_.py``.
+
+:class:`IsaacTeleopTeleoperator` owns what those devices share — the session
+lifecycle, the per-step staleness/worker-health guard, and the no-op calibration
+tracking devices need. A concrete device implements :meth:`_build_pipeline` (its
+retargeting graph) and :meth:`get_action` (usually via :meth:`_step`).
+
+``isaacteleop`` is an optional NVIDIA dependency (install instructions in the example's
+``README.md``); its imports are guarded behind an availability check at module top, so this
+module imports without it and constructing a device fails fast with install instructions.
+"""
+
+from __future__ import annotations
+
+import abc
+import logging
+import os
+from collections.abc import Mapping
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+from lerobot.teleoperators.teleoperator import Teleoperator
+from lerobot.utils.import_utils import is_package_available
+
+from .config_isaac_teleop import IsaacTeleopConfig
+
+_isaacteleop_available = is_package_available("isaacteleop")
+
+if TYPE_CHECKING or _isaacteleop_available:
+ from isaacteleop.cloudxr import CloudXRLauncher
+ from isaacteleop.retargeting_engine.interface import (
+ ExecutionEvents,
+ ExecutionState,
+ GraphExecutable,
+ RetargeterIO,
+ )
+ from isaacteleop.teleop_session_manager import TeleopSession, TeleopSessionConfig
+else:
+ CloudXRLauncher = None
+ ExecutionEvents = None
+ ExecutionState = None
+ GraphExecutable = None
+ RetargeterIO = None
+ TeleopSession = None
+ TeleopSessionConfig = None
+
+logger = logging.getLogger(__name__)
+
+# Gripper closedness [0, 1] -> SO-101 follower motor units [0, 100] (RANGE_0_100, 100 = OPEN).
+# Shared by the XR processor and leader device, which invert via ``pos = (1 - c) * SCALE``.
+_GRIPPER_MOTOR_SCALE = 100.0
+
+
+def _require_isaacteleop() -> None:
+ """Fail fast with install pointers when the optional ``isaacteleop`` package is missing."""
+ if not _isaacteleop_available:
+ raise ImportError(
+ "The 'isaacteleop' package is required for Isaac Teleop devices but is not "
+ "installed. See examples/isaac_teleop_to_so101/README.md for install instructions."
+ )
+
+
+class IsaacTeleopTeleoperator(Teleoperator):
+ """Abstract base for teleoperators backed by an Isaac Teleop ``TeleopSession``.
+
+ Owns the session lifecycle and the per-step health guard; subclasses supply
+ :meth:`_build_pipeline` and :meth:`get_action`.
+ """
+
+ config_class = IsaacTeleopConfig
+
+ def __init__(self, config: IsaacTeleopConfig):
+ _require_isaacteleop()
+ super().__init__(config)
+ self.config: IsaacTeleopConfig = config
+ self._session: TeleopSession | None = None
+ self._cloudxr_launcher: CloudXRLauncher | None = None
+
+ # ------------------------------------------------------------------
+ # Pipeline construction (device override point)
+ # ------------------------------------------------------------------
+
+ @abc.abstractmethod
+ def _build_pipeline(self) -> GraphExecutable:
+ """Build this device's retargeting pipeline (the ``GraphExecutable`` for
+ ``TeleopSessionConfig.pipeline``). Called once in :meth:`connect`; its output
+ keys must match what :meth:`get_action` unpacks.
+ """
+ raise NotImplementedError
+
+ # ------------------------------------------------------------------
+ # Lifecycle (shared)
+ # ------------------------------------------------------------------
+
+ @property
+ def is_connected(self) -> bool:
+ return self._session is not None
+
+ @property
+ def is_calibrated(self) -> bool:
+ return True # Tracking devices are self-calibrating.
+
+ def calibrate(self) -> None:
+ pass
+
+ def configure(self) -> None:
+ pass
+
+ def connect(self, calibrate: bool = True) -> None:
+ """Auto-launch the CloudXR runtime (unless opted out) and open the session.
+
+ The CloudXR launch blocks ~30s and, on the first run, prompts on stdin for the
+ EULA (accept once via ``python -m isaacteleop.cloudxr --accept-eula``). Opt out
+ when CloudXR runs externally via ``config.auto_launch_cloudxr=False`` or
+ ``LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1`` (env var wins).
+ """
+ if self._session is not None:
+ raise RuntimeError("Already connected. Call disconnect() first.")
+
+ self._ensure_cloudxr_runtime()
+
+ try:
+ pipeline = self._build_pipeline()
+ session_config = TeleopSessionConfig(app_name=self.config.app_name, pipeline=pipeline)
+ self._session = TeleopSession(session_config)
+ self._session.__enter__()
+ except Exception:
+ self._session = None
+ try:
+ self._stop_cloudxr_runtime()
+ except Exception:
+ logger.exception("Failed to stop CloudXR runtime during connect() rollback")
+ raise
+ logger.info("Isaac Teleop session started: %s", self.config.app_name)
+
+ def disconnect(self) -> None:
+ try:
+ if self._session is not None:
+ # Null the handle BEFORE __exit__: even a failed session teardown must not
+ # wedge the device as is_connected (blocking every later connect/disconnect).
+ session = self._session
+ self._session = None
+ session.__exit__(None, None, None)
+ logger.info("Isaac Teleop session ended")
+ finally:
+ # Reap the CloudXR runtime even if session teardown raised, and even if no
+ # session was ever established (e.g. the launcher came up but session creation
+ # failed before this point); a no-op when we never launched CloudXR (opt-out /
+ # externally-owned runtime), so we never stop a runtime we don't own.
+ self._stop_cloudxr_runtime()
+
+ # ------------------------------------------------------------------
+ # CloudXR runtime (shared)
+ # ------------------------------------------------------------------
+
+ def _ensure_cloudxr_runtime(self) -> None:
+ """Auto-launch the CloudXR runtime once, unless opted out.
+
+ Idempotent (no-op once the launcher is up). ``LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH``
+ is checked first and wins over ``config.auto_launch_cloudxr``. Constructing
+ :class:`CloudXRLauncher` mutates the process env (``XR_RUNTIME_JSON`` etc.) and
+ blocks until the runtime is ready or raises :class:`RuntimeError`.
+ """
+ if self._cloudxr_launcher is not None:
+ return
+
+ if os.environ.get("LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH", "").strip() == "1":
+ logger.info(
+ "LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1 set; skipping CloudXR auto-launch "
+ "(assuming CloudXR is already running externally)"
+ )
+ return
+
+ if not self.config.auto_launch_cloudxr:
+ logger.info(
+ "config.auto_launch_cloudxr is False; skipping CloudXR auto-launch "
+ "(assuming CloudXR is already running externally)"
+ )
+ return
+
+ logger.info("Launching CloudXR runtime (first run may prompt for EULA and take ~30s)...")
+
+ self._cloudxr_launcher = CloudXRLauncher(
+ install_dir=str(Path.home() / ".cloudxr"),
+ env_config=self.config.cloudxr_env_file,
+ accept_eula=False,
+ )
+
+ def _stop_cloudxr_runtime(self) -> None:
+ """Stop the auto-launched CloudXR runtime, if any.
+
+ Clean stop nulls the handle. On :class:`RuntimeError` the handle is RETAINED so
+ the launcher's ``atexit`` hook owns the retry — a later :meth:`connect` then
+ treats the retained runtime as still up and will not relaunch.
+ """
+ if self._cloudxr_launcher is None:
+ return
+ try:
+ self._cloudxr_launcher.stop()
+ except RuntimeError:
+ logger.warning("CloudXR runtime could not be terminated; handle retained for atexit cleanup")
+ else:
+ self._cloudxr_launcher = None
+ logger.info("CloudXR runtime stopped")
+
+ def send_feedback(self, feedback: dict[str, Any]) -> None:
+ pass # Haptic feedback not yet implemented.
+
+ # ------------------------------------------------------------------
+ # Stepping (shared)
+ # ------------------------------------------------------------------
+
+ def _running_events(self) -> ExecutionEvents:
+ """Constant ``RUNNING`` ``ExecutionEvents`` for a device with no clutch lifecycle.
+
+ Keeps the stream flowing; ``reset`` stays ``False``. A clutched device that needs
+ a real lifecycle should build its own ``ExecutionEvents`` instead.
+ """
+ return ExecutionEvents(execution_state=ExecutionState.RUNNING, reset=False)
+
+ def _step(
+ self,
+ *,
+ execution_events: ExecutionEvents | None = None,
+ external_inputs: Mapping[str, Any] | None = None,
+ ) -> RetargeterIO:
+ """Step the session once and return the raw pipeline outputs.
+
+ Applies the shared guard: re-raises a retargeting-worker exception and warns on a
+ stale frame. Subclasses call this from :meth:`get_action`.
+
+ Args:
+ execution_events: The ``ExecutionEvents`` driving the session this frame.
+ Devices with a lifecycle (clutch) MUST pass this every frame — when
+ ``None``, ``TeleopSession.step`` auto-fires ``RUNNING`` (the clutch would
+ latch immediately and never stop).
+ external_inputs: Per-step inputs (e.g. a static ``base_T_anchor``) in the
+ ``{leaf_node_name: {output_port_name: TensorGroup}}`` shape ``step`` expects.
+
+ Raises:
+ RuntimeError: If not connected, or if the retargeting worker raised.
+ """
+ if self._session is None:
+ raise RuntimeError("Not connected. Call connect() first.")
+
+ result = self._session.step(
+ execution_events=execution_events,
+ external_inputs=external_inputs,
+ )
+
+ info = self._session.last_step_info
+ if info is not None:
+ if info.worker_exception is not None:
+ raise RuntimeError(
+ "Isaac Teleop retargeting worker raised an exception"
+ ) from info.worker_exception
+ if info.frame_deadline_miss:
+ logger.warning(
+ "Isaac Teleop frame deadline miss (returned_age_frames=%s)",
+ info.returned_age_frames,
+ )
+ return result
diff --git a/examples/isaac_teleop_to_so101/isaac_teleop/clutch.py b/examples/isaac_teleop_to_so101/isaac_teleop/clutch.py
new file mode 100644
index 000000000..af0ae6a41
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/isaac_teleop/clutch.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python
+
+# Copyright 2026 NVIDIA Corporation 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.
+
+"""Engage-relative clutch for the XR -> SO-101 teleop loop.
+
+Turns the raw controller grip pose into an absolute base-frame EE target, so the XR
+device can stay a thin raw-pose reader. Pure numpy + the local ``Rotation`` helper (no
+``isaacteleop``), so it is unit-testable without the XR runtime.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+
+from lerobot.utils.rotation import Rotation
+
+
+class Clutch:
+ """Engage-relative clutch for both position AND orientation.
+
+ Latch an origin on engage, then track the base-frame delta from it, applied
+ independently to position and orientation. State:
+
+ - ``_last_commanded_pos`` / ``_last_commanded_rot``: last commanded EE pose; held
+ while disengaged so the arm freezes where it was left.
+ - ``_home_pos`` / ``_home_rot``: latched on engage — the EE pose the delta applies to.
+ The position comes from the arm's MEASURED pose when the caller provides it (so an
+ arm that moved while disengaged is not snapped back to a stale command); the
+ orientation always comes from the last commanded rotation (see NOTE below).
+ - ``_origin_pos`` / ``_origin_rot``: latched on engage — the controller pose the delta
+ is measured against.
+
+ Each engaged frame :meth:`rebase` returns::
+
+ pos = home_pos + (grip_pos - origin_pos) # 1:1 controller -> EE translation
+ rot = (R_ctrl @ R_origin ^ -1) @ R_home # base-frame delta, left-composed
+
+ On the engage edge the output is exactly the home pose (no teleport). The orientation
+ delta is left-composed (base frame), so hand rotation about base Z maps to EE rotation
+ about base Z. A re-clutch latches a fresh home/origin.
+
+ NOTE: ``_home_rot`` is the last *commanded* orientation even when the measured pose is
+ supplied: the 5-DOF SO-101 tracks orientation only softly, so its measured wrist
+ orientation persistently differs from the command, and latching the measurement would
+ inject that offset into the commanded signal on every re-clutch. Position has no such
+ tracking gap, and there latching the measurement is what prevents the snap-back.
+ """
+
+ def __init__(self, home_base_T_ee: np.ndarray): # noqa: N803
+ # Seed the held pose from the arm's measured startup EE pose so the first
+ # engage latches home there (no jump on the first squeeze).
+ home = np.asarray(home_base_T_ee, dtype=float)
+ self._last_commanded_pos = home[:3, 3].copy()
+ self._last_commanded_rot = Rotation.from_matrix(home[:3, :3])
+ self._home_pos = self._last_commanded_pos.copy()
+ self._home_rot = self._last_commanded_rot
+ self._origin_pos = np.zeros(3, dtype=float)
+ self._origin_rot = Rotation.from_quat(np.array([0.0, 0.0, 0.0, 1.0]))
+
+ def engage(
+ self,
+ grip_pos: np.ndarray,
+ grip_quat: np.ndarray,
+ measured_base_T_ee: np.ndarray | None = None, # noqa: N803
+ ) -> None:
+ """Latch the engage home (where the arm is now) and controller origin.
+
+ Pass ``measured_base_T_ee`` (FK of the measured joints) so the home POSITION is
+ where the arm physically is — if the arm moved while disengaged (gravity sag,
+ external contact), latching the stale last-commanded position would make the
+ first engaged frame command a full-speed jump back to it. The home ORIENTATION
+ always stays the last commanded one (see the class NOTE).
+ """
+ if measured_base_T_ee is not None:
+ self._home_pos = np.asarray(measured_base_T_ee, dtype=float)[:3, 3].copy()
+ else:
+ self._home_pos = self._last_commanded_pos.copy()
+ self._home_rot = self._last_commanded_rot
+ self._origin_pos = np.asarray(grip_pos, dtype=float).copy()
+ self._origin_rot = Rotation.from_quat(np.asarray(grip_quat, dtype=float))
+
+ def rebase(self, grip_pos: np.ndarray, grip_quat: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+ """Return the absolute base-frame EE target ``(pos [m], quat [xyzw])`` for this frame."""
+ pos = self._home_pos + (np.asarray(grip_pos, dtype=float) - self._origin_pos)
+ rot_ctrl = Rotation.from_quat(np.asarray(grip_quat, dtype=float))
+ rot = (rot_ctrl * self._origin_rot.inv()) * self._home_rot
+ self._last_commanded_pos = pos.copy()
+ self._last_commanded_rot = rot
+ return pos, rot.as_quat()
diff --git a/examples/isaac_teleop_to_so101/isaac_teleop/config_isaac_teleop.py b/examples/isaac_teleop_to_so101/isaac_teleop/config_isaac_teleop.py
new file mode 100644
index 000000000..d9f8f993f
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/isaac_teleop/config_isaac_teleop.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python
+
+# Copyright 2026 NVIDIA Corporation 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.
+
+"""Configuration dataclasses for NVIDIA Isaac Teleop-backed teleoperators.
+
+:class:`IsaacTeleopConfig` holds the shared fields; each device adds its own subclass
+(e.g. :class:`XRControllerConfig`, :class:`SO101LeaderArmConfig`).
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import ClassVar
+
+from lerobot.teleoperators.config import TeleoperatorConfig
+
+
+@dataclass(kw_only=True)
+class IsaacTeleopConfig(TeleoperatorConfig):
+ """Shared config for all Isaac Teleop-backed teleoperators.
+
+ Uses its own draccus ``_choice_registry`` (decoupled from the global
+ :class:`TeleoperatorConfig` one) so ``--teleop.type`` on a field typed
+ ``IsaacTeleopConfig`` resolves against ONLY the Isaac devices — letting them claim
+ short names (``xr_controller``, ``so101_leader``) without colliding with the global
+ registry. These devices are selected by the example scripts, not routed through
+ ``make_teleoperator_from_config``.
+ """
+
+ _choice_registry: ClassVar[dict] = {}
+
+ app_name: str = "LeTeleop"
+ """Application name for the OpenXR / Isaac Teleop session."""
+
+ auto_launch_cloudxr: bool = True
+ """Auto-launch the CloudXR runtime on :meth:`connect`. Set ``False`` (or export
+ ``LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1``, which wins) when CloudXR runs externally.
+ """
+
+ cloudxr_env_file: str | None = None
+ """Optional CloudXR device-profile ``.env`` (an INPUT profile selecting the headset
+ transport) passed to ``CloudXRLauncher``. ``None`` keeps the default auto-WebRTC profile.
+ """
+
+
+# Static rebase from the OpenXR controller anchor frame (X=Right, Y=Up, Z=Backward) into the
+# robot base frame (X=Forward, Y=Left, Z=Up). A proper rotation (det=+1): controller motion
+# forward -> robot +X, right -> robot -Y (i.e. rightward), up -> robot +Z.
+_DEFAULT_BASE_T_ANCHOR: list[list[float]] = [
+ [0.0, 0.0, -1.0, 0.0],
+ [-1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+]
+
+
+@IsaacTeleopConfig.register_subclass("xr_controller")
+@dataclass(kw_only=True)
+class XRControllerConfig(IsaacTeleopConfig):
+ """Config for Isaac Teleop XR (VR) controller teleoperation.
+
+ Exposes the raw base-frame grip pose, squeeze, and trigger via ``ControllersSource``.
+ No retargeters: the clutch and gripper mapping live in the owning loop.
+ """
+
+ hand_side: str = "right"
+ """Which controller hand to use: ``"left"`` or ``"right"``. A plain ``str`` (validated in
+ ``__post_init__``) because draccus cannot decode ``Literal``-typed fields from the CLI."""
+
+ clutch_threshold: float = 0.5
+ """Squeeze value above which the owning loop's clutch engages (held-to-enable). The
+ device reports only the raw squeeze; the threshold is applied by the loop."""
+
+ base_T_anchor: list[list[float]] = field( # noqa: N815 (frameA_T_frameB transform-matrix convention)
+ # Fresh copy per instance: returning the module-level list itself would alias one
+ # mutable matrix across every config.
+ default_factory=lambda: [row.copy() for row in _DEFAULT_BASE_T_ANCHOR]
+ )
+ """Static 4x4 [row-major] transform rebasing the OpenXR controller anchor frame into
+ the robot base frame. Defaults to OpenXR (X=Right, Y=Up, Z=Backward) -> robot
+ (X=Forward, Y=Left, Z=Up). Plain nested lists so the config stays serializable.
+ """
+
+ def __post_init__(self):
+ if self.hand_side not in ("left", "right"):
+ raise ValueError(f"hand_side must be 'left' or 'right', got {self.hand_side!r}")
+
+
+# Provisional gripper open/close endpoints [rad], normalizing the streamed gripper angle
+# into the follower's RANGE_0_100 jaw target. Derived from the so101_leader plugin README's
+# example calibration (home_ticks=2048, range 2000..3000; angle = (ticks-home)*2*pi/4096).
+_DEFAULT_GRIPPER_OPEN_RAD = -0.074
+_DEFAULT_GRIPPER_CLOSE_RAD = 1.460
+
+
+@IsaacTeleopConfig.register_subclass("so101_leader")
+@dataclass(kw_only=True)
+class SO101LeaderArmConfig(IsaacTeleopConfig):
+ """Config for an Isaac Teleop SO-101 *leader arm* (generic joint-space device).
+
+ Mirrors the leader's joint angles 1:1 onto a follower SO-101. The leader state is
+ streamed in radians by the native ``so101_leader`` plugin and read via a
+ ``JointStateSource``; the device converts arm joints to degrees and the gripper to the
+ follower's RANGE_0_100 jaw target (no IK/clutch/retargeter on the LeRobot side).
+ """
+
+ port: str = ""
+ """Serial port of the physical LEADER arm (e.g. ``/dev/ttyACM1``), forwarded to the
+ plugin (which reads the servos) when the example launches it. Empty -> the plugin runs
+ its synthetic trajectory."""
+
+ collection_id: str = "so101_leader"
+ """Tensor collection id the leader plugin pushes on; must match the running
+ ``so101_leader`` plugin (its second positional arg, default ``"so101_leader"``)."""
+
+ gripper_open_rad: float = _DEFAULT_GRIPPER_OPEN_RAD
+ """Leader gripper angle [rad] at fully OPEN -> follower jaw 100. Provisional default;
+ set from the plugin's ``calibrate`` subcommand. See ``_DEFAULT_GRIPPER_OPEN_RAD``."""
+
+ gripper_close_rad: float = _DEFAULT_GRIPPER_CLOSE_RAD
+ """Leader gripper angle [rad] at fully CLOSED -> follower jaw 0. Provisional default;
+ set from the plugin's ``calibrate`` subcommand. See ``_DEFAULT_GRIPPER_CLOSE_RAD``."""
diff --git a/examples/isaac_teleop_to_so101/isaac_teleop/teleop_so101_leader_arm.py b/examples/isaac_teleop_to_so101/isaac_teleop/teleop_so101_leader_arm.py
new file mode 100644
index 000000000..f23c4e9b7
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/isaac_teleop/teleop_so101_leader_arm.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python
+
+# Copyright 2026 NVIDIA Corporation 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.
+
+"""SO-101 leader-arm device for NVIDIA Isaac Teleop, exposed to LeRobot.
+
+The leader is a back-drivable SO-101 whose six joint angles are streamed (in radians) by
+the native ``so101_leader`` plugin; this device reads them via a ``JointStateSource`` and
+converts them into follower-ready ``{joint}.pos``. Same kinematics as the follower, so it
+needs no retargeting — a 1:1 joint mirror, direct joint drive.
+
+Units (converted in the device so the output is always follower-valid):
+
+* arm joints: ``rad2deg`` — correct only if the leader's calibrated zero and the follower's
+ homing map to the same physical zero (the standard same-hardware assumption).
+* gripper: normalized from ``[gripper_open_rad, gripper_close_rad]`` to RANGE_0_100.
+
+``isaacteleop`` imports are guarded behind the availability flag so this module — and the
+pure :func:`leader_joints_to_robot_action` converter — import without it (construction
+fails fast via the base class).
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import numpy as np
+
+from lerobot.types import RobotAction
+
+from .base import _GRIPPER_MOTOR_SCALE, IsaacTeleopTeleoperator, _isaacteleop_available
+from .config_isaac_teleop import SO101LeaderArmConfig
+
+if TYPE_CHECKING or _isaacteleop_available:
+ from isaacteleop.retargeting_engine.deviceio_source_nodes import JointStateSource
+ from isaacteleop.retargeting_engine.interface import OutputCombiner
+else:
+ JointStateSource = None
+ OutputCombiner = None
+
+# Canonical SO-101 DOF names and order — matches the plugin stream and the follower's motor
+# order. Passed to the ``JointStateSource`` as its output layout; the source maps by name and
+# :func:`_joints_group_to_rad` reads back by name, so a layout mismatch can't mislabel a DOF.
+SO101_LEADER_JOINTS = [
+ "shoulder_pan",
+ "shoulder_lift",
+ "elbow_flex",
+ "wrist_flex",
+ "wrist_roll",
+ "gripper",
+]
+
+
+def leader_joints_to_robot_action(
+ joints_rad: dict[str, float],
+ *,
+ gripper_joint: str,
+ gripper_open_rad: float,
+ gripper_close_rad: float,
+) -> RobotAction:
+ """Convert streamed leader joint angles [rad] to follower-ready ``{joint}.pos``.
+
+ Pure (no ``isaacteleop``, no I/O). Iteration follows ``joints_rad`` insertion order, so
+ pass it in :data:`SO101_LEADER_JOINTS` order for a stable layout. Arm joints are
+ converted ``rad2deg``; ``gripper_joint`` is normalized from
+ ``[gripper_open_rad, gripper_close_rad]`` to RANGE_0_100 (clipped).
+ """
+ action: RobotAction = {}
+ span = gripper_close_rad - gripper_open_rad
+ for name, rad in joints_rad.items():
+ if name == gripper_joint:
+ # Closedness c=0 at open, c=1 at closed; invert to the follower's 100=open jaw.
+ closedness = 0.0 if span == 0.0 else (rad - gripper_open_rad) / span
+ closedness = min(1.0, max(0.0, closedness))
+ action[f"{name}.pos"] = (1.0 - closedness) * _GRIPPER_MOTOR_SCALE
+ else:
+ action[f"{name}.pos"] = float(np.rad2deg(rad))
+ return action
+
+
+def _joints_group_to_rad(joints) -> dict[str, float]:
+ """Read a ``JointStateSource`` output group into ``{joint_name: angle [rad]}``.
+
+ Pure (duck-typed on the group). The group is positional but each slot carries its joint
+ name in ``group.group_type.types``; we key off those names (not a positional index) so a
+ layout mismatch surfaces as a wrong/missing key here rather than a mislabeled DOF.
+ """
+ names = [t.name for t in joints.group_type.types]
+ return {name: float(joints[i]) for i, name in enumerate(names)}
+
+
+class SO101LeaderArm(IsaacTeleopTeleoperator):
+ """SO-101 leader-arm teleoperator (joint-space), direct joint mirror to the follower.
+
+ Reads the six joint angles off a single ``JointStateSource`` each frame; no retargeter,
+ no clutch. When the leader is not streaming, :meth:`get_action` returns the held-last
+ joints and :attr:`is_tracking` is ``False`` so the owning loop can hold the follower.
+ """
+
+ config_class = SO101LeaderArmConfig
+ name = "isaac_teleop_so101_leader"
+
+ def __init__(self, config: SO101LeaderArmConfig):
+ super().__init__(config)
+ self.config: SO101LeaderArmConfig = config
+ # Held-last joint angles [rad], seeded at zero (URDF/home pose) so the first frames
+ # before the plugin starts pushing read as the home pose, not garbage.
+ self._last_joints_rad: dict[str, float] = dict.fromkeys(SO101_LEADER_JOINTS, 0.0)
+ # Whether the most recent get_action() read live leader data (vs held-last).
+ self._is_tracking = False
+
+ # ------------------------------------------------------------------
+ # Pipeline construction
+ # ------------------------------------------------------------------
+
+ def _build_pipeline(self) -> OutputCombiner:
+ """Build the joint-mirror pipeline: a single ``JointStateSource`` leaf that converts
+ the raw stream into a name-keyed joint group. No retargeter (shared kinematics)."""
+ source = JointStateSource(
+ name="so101_leader",
+ collection_id=self.config.collection_id,
+ joint_names=SO101_LEADER_JOINTS,
+ )
+ return OutputCombiner({"joints": source.output(JointStateSource.JOINTS)})
+
+ # ------------------------------------------------------------------
+ # Action features
+ # ------------------------------------------------------------------
+
+ @property
+ def action_features(self) -> dict[str, type]:
+ # Matches the serial SOLeader's action features so this is a drop-in joint-space
+ # leader: one float `{joint}.pos` per DOF, sendable straight to an SO-101 follower.
+ return {f"{name}.pos": float for name in SO101_LEADER_JOINTS}
+
+ @property
+ def feedback_features(self) -> dict[str, type]:
+ return {}
+
+ @property
+ def is_tracking(self) -> bool:
+ """Whether the last :meth:`get_action` read live leader data (vs held-last)."""
+ return self._is_tracking
+
+ # ------------------------------------------------------------------
+ # Action extraction
+ # ------------------------------------------------------------------
+
+ def get_action(self) -> RobotAction:
+ """Step the session and return the leader joints as follower-ready ``{joint}.pos``.
+
+ When the leader is streaming, the live angles are cached and converted; otherwise the
+ held-last angles are reused and :attr:`is_tracking` is set ``False``.
+ """
+ result = self._step(execution_events=self._running_events())
+
+ joints = result["joints"]
+ # The JointStateSource output is Optional: absent (is_none) when the device is
+ # inactive. Treat that as "not tracking" and reuse the held-last angles.
+ self._is_tracking = not getattr(joints, "is_none", False)
+ if self._is_tracking:
+ try:
+ self._last_joints_rad = _joints_group_to_rad(joints)
+ except (AttributeError, IndexError, KeyError, TypeError, ValueError):
+ # A partially-populated / malformed group on an odd frame: keep held-last, but
+ # report it as not-tracking so the loop holds the follower rather than trusting it.
+ self._is_tracking = False
+
+ return leader_joints_to_robot_action(
+ self._last_joints_rad,
+ gripper_joint="gripper",
+ gripper_open_rad=self.config.gripper_open_rad,
+ gripper_close_rad=self.config.gripper_close_rad,
+ )
diff --git a/examples/isaac_teleop_to_so101/isaac_teleop/teleop_xr_controller.py b/examples/isaac_teleop_to_so101/isaac_teleop/teleop_xr_controller.py
new file mode 100644
index 000000000..25949876b
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/isaac_teleop/teleop_xr_controller.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python
+
+# Copyright 2026 NVIDIA Corporation 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.
+
+"""XR (VR) controller device for NVIDIA Isaac Teleop, exposed to LeRobot.
+
+A deliberately thin reader: exposes the raw controller grip pose off
+``ControllersSource`` (statically rebased into the robot base frame by
+``ControllerTransform``), plus squeeze and trigger. No retargeters and no clutch —
+the clutch rebasing and gripper mapping live downstream in the owning loop, so this
+device is stateless across frames.
+
+``isaacteleop`` imports are guarded behind the availability flag so this module imports
+without it (construction fails fast via the base class).
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+
+from lerobot.types import RobotAction
+
+from .base import IsaacTeleopTeleoperator, _isaacteleop_available
+from .config_isaac_teleop import XRControllerConfig
+
+if TYPE_CHECKING or _isaacteleop_available:
+ from isaacteleop.retargeting_engine.deviceio_source_nodes import ControllersSource
+ from isaacteleop.retargeting_engine.interface import OutputCombiner, TensorGroup, ValueInput
+ from isaacteleop.retargeting_engine.tensor_types import TransformMatrix
+ from isaacteleop.retargeting_engine.tensor_types.indices import ControllerInputIndex
+else:
+ ControllersSource = None
+ OutputCombiner = None
+ TensorGroup = None
+ ValueInput = None
+ TransformMatrix = None
+ ControllerInputIndex = None
+
+# Source-node name for the static base_T_anchor rebase input fed via
+# ``TeleopSession.step(external_inputs=...)`` each frame.
+_BASE_T_ANCHOR_INPUT = "base_T_anchor"
+
+
+class XRController(IsaacTeleopTeleoperator):
+ """Raw XR controller grip-pose teleoperator (base-frame), no retargeters.
+
+ Reads the raw grip pose + squeeze + trigger off a ``ControllersSource`` rebased into
+ the robot base frame. :meth:`get_action` returns the absolute base-frame grip pose
+ untouched; the owning loop owns the clutch and gripper mapping.
+ """
+
+ config_class = XRControllerConfig
+ name = "isaac_teleop_controller"
+
+ def __init__(self, config: XRControllerConfig):
+ super().__init__(config)
+ self.config: XRControllerConfig = config
+
+ # Constant base_T_anchor input, built once in connect() (a TensorGroup is heavy and
+ # isaacteleop-backed) and reused every step.
+ self._external_inputs: dict[str, Any] | None = None
+ # Whether the last get_action() read a tracked controller; the owning loop polls this
+ # to wait for the operator to connect before driving the arm.
+ self._is_tracking = False
+
+ # ------------------------------------------------------------------
+ # Pipeline construction
+ # ------------------------------------------------------------------
+
+ def _build_pipeline(self) -> OutputCombiner:
+ """Build the raw-grip-pose pipeline: a ``ControllersSource`` rebased into the base
+ frame by ``ControllerTransform``, exposed verbatim as ``"controller"``. No retargeters.
+ """
+ side = self.config.hand_side
+ controller_key = f"controller_{side}"
+
+ controllers = ControllersSource(name="controllers")
+ # Static base_T_anchor rebase fed via external_inputs each step.
+ xform = ValueInput(_BASE_T_ANCHOR_INPUT, TransformMatrix())
+ transformed = controllers.transformed(xform.output("value"))
+ ctrl = transformed.output(controller_key)
+
+ return OutputCombiner({"controller": ctrl})
+
+ def _build_external_inputs(self) -> dict[str, Any]:
+ """Materialize the constant ``base_T_anchor`` external input (once, in connect)."""
+ tg = TensorGroup(TransformMatrix())
+ tg[0] = np.asarray(self.config.base_T_anchor, dtype=np.float32)
+ return {_BASE_T_ANCHOR_INPUT: {"value": tg}}
+
+ def connect(self, calibrate: bool = True) -> None:
+ super().connect(calibrate=calibrate)
+ try:
+ self._external_inputs = self._build_external_inputs()
+ except Exception:
+ # Roll the session/runtime back so a failed connect() leaves no half-state
+ # (a live session behind a raised connect would leak the CloudXR runtime).
+ self.disconnect()
+ raise
+
+ # ------------------------------------------------------------------
+ # Action features
+ # ------------------------------------------------------------------
+
+ @property
+ def action_features(self) -> dict:
+ return {
+ "grip_pos": {
+ "dtype": "float32",
+ "shape": (3,),
+ "names": {"x": 0, "y": 1, "z": 2},
+ },
+ "grip_quat": {
+ "dtype": "float32",
+ "shape": (4,),
+ "names": {"qx": 0, "qy": 1, "qz": 2, "qw": 3},
+ },
+ # ``get_action`` returns scalars for these two, so the advertised
+ # shape is () (0-d) to stay consistent with the returned values.
+ "squeeze": {
+ "dtype": "float32",
+ "shape": (),
+ "names": None,
+ },
+ "trigger": {
+ "dtype": "float32",
+ "shape": (),
+ "names": None,
+ },
+ }
+
+ @property
+ def feedback_features(self) -> dict:
+ return {}
+
+ @property
+ def is_tracking(self) -> bool:
+ """Whether the last :meth:`get_action` read a tracked controller. ``False`` until the
+ headset is connected over CloudXR and its controllers are live; the owning loop polls
+ it to wait for the operator before commanding the arm."""
+ return self._is_tracking
+
+ # ------------------------------------------------------------------
+ # Action extraction
+ # ------------------------------------------------------------------
+
+ def get_action(self) -> RobotAction:
+ """Step the session and return the raw base-frame grip pose.
+
+ Reads the grip pose + squeeze + trigger off the transformed controller stream (with
+ the constant ``base_T_anchor`` rebase). When the controller is not tracked, returns
+ identity pose and squeeze/trigger = 0.0 so the owning loop freezes the arm.
+
+ Returns:
+ ``{"grip_pos": (3,) [m], "grip_quat": (4,) [qx,qy,qz,qw], "squeeze": float,
+ "trigger": float}`` — pose in the robot base frame; squeeze/trigger in ``[0, 1]``.
+ """
+ result = self._step(execution_events=self._running_events(), external_inputs=self._external_inputs)
+
+ # Optional controller group is None until the headset is connected and its controllers
+ # are live; expose that as is_tracking so the loop can wait before driving the arm.
+ controller = result["controller"]
+ grip_pos = np.zeros(3, dtype=np.float32)
+ grip_quat = np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float32)
+ squeeze = 0.0
+ trigger = 0.0
+ self._is_tracking = not getattr(controller, "is_none", False)
+ if self._is_tracking:
+ # Read ALL four fields into locals before committing any of them: a failure on a
+ # partially-populated frame must not mix live values with the safe defaults (a
+ # live squeeze paired with a defaulted trigger=0.0 would keep the clutch engaged
+ # while commanding the gripper fully open, dropping whatever is grasped). On
+ # failure the defaults stand untouched and the frame reports not-tracked.
+ try:
+ pos = np.asarray(controller[ControllerInputIndex.GRIP_POSITION], dtype=np.float32)
+ quat = np.asarray(controller[ControllerInputIndex.GRIP_ORIENTATION], dtype=np.float32)
+ squeeze_val = float(controller[ControllerInputIndex.SQUEEZE_VALUE])
+ trigger_val = float(controller[ControllerInputIndex.TRIGGER_VALUE])
+ except (IndexError, KeyError, TypeError, ValueError):
+ self._is_tracking = False
+ else:
+ grip_pos, grip_quat = pos, quat
+ squeeze, trigger = squeeze_val, trigger_val
+
+ return {
+ "grip_pos": grip_pos,
+ "grip_quat": grip_quat,
+ "squeeze": squeeze,
+ "trigger": trigger,
+ }
diff --git a/examples/isaac_teleop_to_so101/isaac_teleop/xr_controller_processor.py b/examples/isaac_teleop_to_so101/isaac_teleop/xr_controller_processor.py
new file mode 100644
index 000000000..b0eaa8280
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/isaac_teleop/xr_controller_processor.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python
+
+# Copyright 2026 NVIDIA Corporation 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.
+
+"""Processor step that maps XR controller actions to robot EE targets.
+
+Analogous to ``MapPhoneActionToRobotAction``, this bridges the clutch-rebased EE pose to
+the IK pipeline's input contract (``EEBoundsAndSafety`` -> ``InverseKinematicsEEToJoints``).
+Pure (no ``isaacteleop``), so it is unit-testable without the XR runtime.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from lerobot.configs.types import FeatureType, PipelineFeatureType, PolicyFeature
+from lerobot.processor import ProcessorStepRegistry, RobotActionProcessorStep
+from lerobot.types import RobotAction
+from lerobot.utils.rotation import Rotation
+
+from .base import _GRIPPER_MOTOR_SCALE
+
+
+@ProcessorStepRegistry.register("map_xr_controller_action_to_robot_action")
+@dataclass
+class MapXRControllerActionToRobotAction(RobotActionProcessorStep):
+ """Maps an absolute base-frame EE pose + gripper closedness to the IK input contract.
+
+ Pure, stateless rename (the owning loop's clutch already produced the absolute base-frame
+ target). Each frame it writes:
+
+ - ``ee.x/y/z`` = ``ee_pose[:3]`` (position [m]);
+ - ``ee.wx/wy/wz`` = rotvec of ``ee_pose[3:7]`` (orientation; the IK tracks it softly at a
+ small ``orientation_weight`` on the 5-DOF SO-101);
+ - ``ee.gripper_pos`` = ``(1 - closedness) * _GRIPPER_MOTOR_SCALE`` (jaw target [0, 100],
+ RANGE_0_100 where 100 = open, so closedness is inverted).
+
+ Input keys: ``ee_pose`` ``(7,)`` ``[x,y,z,qx,qy,qz,qw]``, ``closedness`` float in [0, 1].
+ """
+
+ def action(self, action: RobotAction) -> RobotAction:
+ ee_pose = action.pop("ee_pose")
+ closedness = float(action.pop("closedness"))
+
+ action["ee.x"] = float(ee_pose[0])
+ action["ee.y"] = float(ee_pose[1])
+ action["ee.z"] = float(ee_pose[2])
+ # Orientation target as a rotvec (quat [qx,qy,qz,qw] -> axis-angle); the IK
+ # consumes ee.w* as a rotvec and tracks it with orientation_weight.
+ rotvec = Rotation.from_quat(ee_pose[3:7]).as_rotvec()
+ action["ee.wx"] = float(rotvec[0])
+ action["ee.wy"] = float(rotvec[1])
+ action["ee.wz"] = float(rotvec[2])
+ # Inverted: closedness c=1 (closed) -> 0, c=0 (open) -> 100 (SO-101 calibration).
+ action["ee.gripper_pos"] = (1.0 - closedness) * _GRIPPER_MOTOR_SCALE
+ return action
+
+ def transform_features(
+ self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
+ ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
+ for feat in ["ee_pose", "closedness"]:
+ features[PipelineFeatureType.ACTION].pop(feat, None)
+
+ for feat in [
+ "ee.x",
+ "ee.y",
+ "ee.z",
+ "ee.wx",
+ "ee.wy",
+ "ee.wz",
+ "ee.gripper_pos",
+ ]:
+ features[PipelineFeatureType.ACTION][feat] = PolicyFeature(type=FeatureType.ACTION, shape=(1,))
+
+ return features
diff --git a/examples/isaac_teleop_to_so101/override_reset_pose.py b/examples/isaac_teleop_to_so101/override_reset_pose.py
new file mode 100644
index 000000000..580f9f6f1
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/override_reset_pose.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python
+
+# Copyright 2026 NVIDIA Corporation 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.
+
+"""Save the current SO-101 joint positions as the reset-origin pose (override).
+
+Move the arm to the desired reset pose by hand (torque off), then run this script to write
+those joints to a per-arm file in the LeRobot cache. ``teleoperate.py`` / ``record.py`` load
+it on startup (matched by ``--robot.id``) as the reset target instead of the defaults.
+
+Usage::
+
+ # 1. Move arm to desired reset pose by hand
+ python -m examples.isaac_teleop_to_so101.override_reset_pose [--port /dev/ttyACM0] [--id so101_follower_arm]
+
+ # 2. Launch teleop with the SAME --robot.id — it will now reset to this pose on startup
+ python -m examples.isaac_teleop_to_so101.teleoperate --robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=so101_follower_arm --teleop.type=xr_controller
+"""
+
+import argparse
+import json
+from pathlib import Path
+
+from lerobot.robots.so_follower import SO100Follower, SO100FollowerConfig
+
+from .common import RESET_POSE_FILE
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
+ )
+ parser.add_argument("--port", type=str, default="/dev/ttyACM0")
+ parser.add_argument("--id", type=str, default="so101_follower_arm")
+ return parser.parse_args()
+
+
+def main():
+ args = parse_args()
+ robot = SO100Follower(SO100FollowerConfig(port=args.port, id=args.id, use_degrees=True))
+ robot.connect()
+ # Always disconnect the follower so a failure never leaks the serial connection.
+ try:
+ obs = robot.get_observation()
+ motor_names = list(robot.bus.motors.keys())
+ pose = {name: float(obs[f"{name}.pos"]) for name in motor_names}
+ finally:
+ robot.disconnect()
+
+ print("Current joint positions:")
+ for name, val in pose.items():
+ print(f" {name:20s}: {val:.2f}")
+
+ reset_pose_file = Path(RESET_POSE_FILE.format(robot_name=robot.name, robot_id=robot.id))
+ reset_pose_file.parent.mkdir(parents=True, exist_ok=True)
+ reset_pose_file.write_text(json.dumps(pose, indent=2))
+ print(f"\nSaved to {reset_pose_file}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/isaac_teleop_to_so101/record.py b/examples/isaac_teleop_to_so101/record.py
new file mode 100644
index 000000000..9ae769446
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/record.py
@@ -0,0 +1,321 @@
+#!/usr/bin/env python
+
+# Copyright 2026 NVIDIA Corporation 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.
+
+"""Record a LeRobot dataset via NVIDIA Isaac Teleop -> SO-101.
+
+Runs ``teleoperate.py``'s control loop while also saving each frame to a LeRobot dataset.
+``--teleop.type`` selects the device (``xr_controller`` | ``so101_leader``) as in
+``teleoperate.py``.
+
+Usage::
+
+ # XR (VR) controller: clutch + soft-orientation IK
+ python -m examples.isaac_teleop_to_so101.record \\
+ --robot.type=so101_follower \\
+ --robot.port=/dev/ttyACM0 \\
+ --robot.id=so101_follower_arm \\
+ --teleop.type=xr_controller \\
+ --robot.cameras="{ front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}}" \\
+ --dataset.repo_id=/ \\
+ --dataset.single_task="Pick up vial from rack on the left side" \\
+ --dataset.num_episodes=3 \\
+ --dataset.episode_time_s=20 \\
+ --dataset.reset_time_s=5
+
+ # SO-101 leader arm: 1:1 joint mirror (real leader on /dev/ttyACM1)
+ python -m examples.isaac_teleop_to_so101.record \\
+ --robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=so101_follower_arm \\
+ --teleop.type=so101_leader --teleop.port=/dev/ttyACM1 --teleop.id=so101_leader_arm \\
+ --launch_plugin=/path/to/IsaacTeleop/install/plugins/so101_leader/so101_leader_plugin \\
+ --dataset.repo_id=/ --dataset.single_task="Pick up the cube" \\
+ --dataset.num_episodes=3 --dataset.episode_time_s=20 --dataset.reset_time_s=5
+
+The loop/launch knobs mirror ``teleoperate.py`` (tagged ``[xr]`` / ``[leader]`` below).
+
+Keyboard shortcuts: Right/n = end episode early and save, Left/r = discard + re-record,
+Esc/q = stop after the current episode. All frames are recorded (including hold frames).
+"""
+
+import logging
+import time
+from dataclasses import asdict, dataclass
+from pprint import pformat
+
+from lerobot.cameras import CameraConfig # noqa: F401
+from lerobot.cameras.opencv import OpenCVCameraConfig # noqa: F401
+from lerobot.common.control_utils import sanity_check_dataset_robot_compatibility
+from lerobot.configs import parser
+from lerobot.configs.dataset import DatasetRecordConfig
+from lerobot.datasets import (
+ LeRobotDataset,
+ VideoEncodingManager,
+ aggregate_pipeline_dataset_features,
+ create_initial_features,
+ safe_stop_image_writer,
+)
+from lerobot.processor import make_default_processors
+from lerobot.robots import RobotConfig
+from lerobot.robots.so_follower import SOFollowerConfig # noqa: F401 (registers so101_follower)
+from lerobot.utils.constants import ACTION, OBS_STR
+from lerobot.utils.feature_utils import build_dataset_frame, combine_feature_dicts
+from lerobot.utils.robot_utils import precise_sleep
+from lerobot.utils.utils import init_logging
+
+from .common import (
+ ALIGN_DURATION_S,
+ RESET_DURATION_S,
+ Device,
+ HoldLatch,
+ build_device,
+ init_keyboard_listener,
+)
+from .isaac_teleop import IsaacTeleopConfig
+
+
+@dataclass
+class RecordConfig:
+ """CLI config for Isaac Teleop -> SO-101 dataset recording.
+
+ ``--robot.*`` / ``--teleop.*`` / ``--dataset.*`` configure the follower, device, and
+ recording; the loop/launch knobs below carry the same ``[xr]`` / ``[leader]`` tags as
+ ``teleoperate.py``. Use ``--flag=false`` for booleans (draccus style).
+ """
+
+ robot: RobotConfig
+ # --teleop.type=xr_controller|so101_leader, resolved against IsaacTeleopConfig's registry.
+ teleop: IsaacTeleopConfig
+ dataset: DatasetRecordConfig
+
+ # [leader] Path to the so101_leader plugin binary to spawn after CloudXR is up (it then
+ # inherits the runtime env). None (default) -> assume the plugin already runs externally.
+ launch_plugin: str | None = None
+
+ # [xr] Slew all joints to the reset pose before the first episode (--reset_to_origin=false to
+ # keep the arm where it is). After the slew the clutch seeds its home from the measured pose.
+ reset_to_origin: bool = True
+ # [xr] Duration [s] of the reset-to-origin slew (passed through to setup_xr).
+ reset_duration: float = RESET_DURATION_S
+
+ # [leader] Slew the follower to the leader's first pose before mirroring (--align=false to
+ # begin the 1:1 mirror immediately; the follower may snap).
+ align: bool = True
+ # [leader] Duration [s] of the startup alignment slew.
+ align_duration: float = ALIGN_DURATION_S
+
+ # Resume recording on an existing (previously interrupted) dataset.
+ resume: bool = False
+
+
+@safe_stop_image_writer
+def _record_loop(
+ robot,
+ device: Device,
+ motor_names: list[str],
+ events: dict,
+ fps: int,
+ dataset: LeRobotDataset | None = None,
+ control_time_s: float = 0.0,
+ single_task: str | None = None,
+) -> None:
+ """Run one episode (or reset phase) of the control loop.
+
+ When ``dataset`` is None the loop still controls the robot (so the operator
+ can reposition the arm during the reset window) but does not record frames.
+ """
+ control_interval = 1.0 / fps
+ timestamp = 0.0
+ start_t = time.perf_counter()
+ record_frames = dataset is not None
+ hold = HoldLatch(motor_names)
+
+ while timestamp < control_time_s:
+ loop_start = time.perf_counter()
+
+ if events["exit_early"]:
+ events["exit_early"] = False
+ break
+
+ obs = robot.get_observation()
+
+ if record_frames:
+ observation_frame = build_dataset_frame(dataset.features, obs, prefix=OBS_STR)
+
+ # Device idle (XR clutch disengaged, or leader stream stale) -> hold the pose
+ # latched on the active->idle edge.
+ action = hold.resolve(device.compute(obs), obs)
+
+ robot.send_action(action)
+
+ if record_frames:
+ action_frame = build_dataset_frame(dataset.features, action, prefix=ACTION)
+ dataset.add_frame({**observation_frame, **action_frame, "task": single_task})
+
+ dt_s = time.perf_counter() - loop_start
+ precise_sleep(max(control_interval - dt_s, 0.0))
+ timestamp = time.perf_counter() - start_t
+
+
+@parser.wrap()
+def record(cfg: RecordConfig) -> LeRobotDataset:
+ init_logging()
+ logging.info(pformat(asdict(cfg)))
+
+ # Connect the follower, build the selected Isaac device, and run its pre-loop startup
+ # (reset slew / leader align) — shared with teleoperate.py.
+ robot, device, motor_names = build_device(cfg)
+
+ # Build dataset feature spec. The IK pipeline lives inside device.compute(), so the
+ # action features are exactly robot.action_features (joint positions in degrees).
+ teleop_proc, _, obs_proc = make_default_processors()
+ dataset_features = combine_feature_dicts(
+ aggregate_pipeline_dataset_features(
+ pipeline=teleop_proc,
+ initial_features=create_initial_features(action=robot.action_features),
+ use_videos=cfg.dataset.video,
+ ),
+ aggregate_pipeline_dataset_features(
+ pipeline=obs_proc,
+ initial_features=create_initial_features(observation=robot.observation_features),
+ use_videos=cfg.dataset.video,
+ ),
+ )
+
+ num_cameras = len(robot.cameras) if hasattr(robot, "cameras") else 0
+ image_writer_threads = cfg.dataset.num_image_writer_threads_per_camera * num_cameras
+
+ dataset: LeRobotDataset | None = None
+ listener = None
+ try:
+ if cfg.resume:
+ dataset = LeRobotDataset.resume(
+ cfg.dataset.repo_id,
+ root=cfg.dataset.root,
+ batch_encoding_size=cfg.dataset.video_encoding_batch_size,
+ rgb_encoder=cfg.dataset.rgb_encoder,
+ depth_encoder=cfg.dataset.depth_encoder,
+ encoder_threads=cfg.dataset.encoder_threads,
+ streaming_encoding=cfg.dataset.streaming_encoding,
+ encoder_queue_maxsize=cfg.dataset.encoder_queue_maxsize,
+ image_writer_processes=cfg.dataset.num_image_writer_processes if num_cameras > 0 else 0,
+ image_writer_threads=image_writer_threads if num_cameras > 0 else 0,
+ )
+ sanity_check_dataset_robot_compatibility(dataset, robot, cfg.dataset.fps, dataset_features)
+ else:
+ cfg.dataset.stamp_repo_id()
+ dataset = LeRobotDataset.create(
+ cfg.dataset.repo_id,
+ cfg.dataset.fps,
+ root=cfg.dataset.root,
+ robot_type=robot.name,
+ features=dataset_features,
+ use_videos=cfg.dataset.video,
+ image_writer_processes=cfg.dataset.num_image_writer_processes,
+ image_writer_threads=image_writer_threads,
+ batch_encoding_size=cfg.dataset.video_encoding_batch_size,
+ rgb_encoder=cfg.dataset.rgb_encoder,
+ depth_encoder=cfg.dataset.depth_encoder,
+ encoder_threads=cfg.dataset.encoder_threads,
+ streaming_encoding=cfg.dataset.streaming_encoding,
+ encoder_queue_maxsize=cfg.dataset.encoder_queue_maxsize,
+ )
+
+ listener, events = init_keyboard_listener()
+
+ loop_kwargs = {
+ "robot": robot,
+ "device": device,
+ "motor_names": motor_names,
+ "events": events,
+ "fps": cfg.dataset.fps,
+ "single_task": cfg.dataset.single_task,
+ }
+
+ with VideoEncodingManager(dataset):
+ recorded_episodes = 0
+ while recorded_episodes < cfg.dataset.num_episodes and not events["stop_recording"]:
+ logging.info(f"Recording episode {dataset.num_episodes}")
+ _record_loop(
+ **loop_kwargs,
+ dataset=dataset,
+ control_time_s=cfg.dataset.episode_time_s,
+ )
+
+ # Reset window: give the operator time to reposition the scene.
+ # Skipped for the last episode (or if stop_recording was set).
+ if not events["stop_recording"] and (
+ recorded_episodes < cfg.dataset.num_episodes - 1 or events["rerecord_episode"]
+ ):
+ logging.info("Reset the environment")
+ _record_loop(
+ **loop_kwargs,
+ dataset=None,
+ control_time_s=cfg.dataset.reset_time_s,
+ )
+
+ if events["rerecord_episode"]:
+ logging.info("Re-record episode")
+ events["rerecord_episode"] = False
+ events["exit_early"] = False
+ dataset.clear_episode_buffer()
+ continue
+
+ dataset.save_episode()
+ recorded_episodes += 1
+
+ finally:
+ logging.info("Stop recording")
+
+ # Hardware teardown FIRST, each step guarded: the arm must be freed promptly (not
+ # after a potentially long finalize/encode), a cleanup failure must not skip the
+ # follower disconnect (which is what disables torque), and neither must prevent
+ # the dataset from being finalized below.
+ try:
+ device.cleanup()
+ except Exception:
+ logging.exception("Device cleanup failed")
+ try:
+ if robot.is_connected:
+ robot.disconnect()
+ except Exception:
+ logging.exception("Robot disconnect failed")
+
+ # Restore the terminal before the (potentially long) finalize/encode.
+ if listener is not None:
+ try:
+ listener.stop()
+ except Exception:
+ logging.exception("Keyboard listener stop failed")
+
+ if dataset is not None:
+ dataset.finalize()
+
+ if cfg.dataset.push_to_hub:
+ if dataset is not None and dataset.num_episodes > 0:
+ dataset.push_to_hub(tags=cfg.dataset.tags, private=cfg.dataset.private)
+ else:
+ logging.warning("No episodes saved — skipping push to hub")
+
+ logging.info("Exiting")
+
+ return dataset
+
+
+def main():
+ record()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/isaac_teleop_to_so101/teleoperate.py b/examples/isaac_teleop_to_so101/teleoperate.py
new file mode 100644
index 000000000..5e382f530
--- /dev/null
+++ b/examples/isaac_teleop_to_so101/teleoperate.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python
+
+# Copyright 2026 NVIDIA Corporation 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.
+
+"""Teleoperate an SO-101 follower arm via NVIDIA Isaac Teleop.
+
+``lerobot-teleoperate``-style CLI (draccus): ``--teleop.type`` selects the Isaac device
+(``xr_controller`` | ``so101_leader``), ``--robot.*`` the follower::
+
+ # XR (VR) controller: clutch + soft-orientation IK
+ python -m examples.isaac_teleop_to_so101.teleoperate --robot.type=so101_follower \
+ --robot.port=/dev/ttyACM0 --robot.id=so101_follower_arm --teleop.type=xr_controller
+
+ # SO-101 leader arm: 1:1 joint mirror (real leader on /dev/ttyACM1)
+ python -m examples.isaac_teleop_to_so101.teleoperate --robot.type=so101_follower \
+ --robot.port=/dev/ttyACM0 --robot.id=so101_follower_arm --teleop.type=so101_leader \
+ --teleop.port=/dev/ttyACM1 --teleop.id=so101_leader_arm \
+ --launch_plugin=/code/Teleop/install/plugins/so101_leader/so101_leader_plugin
+
+``--teleop.type`` resolves against the Isaac device registry (see :class:`IsaacTeleopConfig`),
+distinct from the serial ``so101_leader``. The pipelines, clutch/IK/align internals, and
+reset-pose behavior live in ``common.py``. Requires the ``isaacteleop`` package and an OpenXR
+runtime (install instructions in this folder's ``README.md``).
+"""
+
+import time
+from dataclasses import dataclass
+
+from lerobot.configs import parser
+from lerobot.robots import RobotConfig
+from lerobot.robots.so_follower import SOFollowerConfig # noqa: F401 (registers so101_follower)
+from lerobot.utils.robot_utils import precise_sleep
+
+from .common import (
+ ALIGN_DURATION_S,
+ FPS,
+ RESET_DURATION_S,
+ HoldLatch,
+ build_device,
+)
+from .isaac_teleop import IsaacTeleopConfig
+
+
+@dataclass
+class TeleoperateConfig:
+ """``lerobot-teleoperate``-style CLI for the Isaac Teleop -> SO-101 example.
+
+ The fields below are the loop/launch knobs (not part of either device's config); the
+ ``[xr]`` / ``[leader]`` tags mark which device a knob applies to. Use ``--flag=false``
+ for booleans (draccus style).
+ """
+
+ # Isaac Teleop input device + its knobs (--teleop.type=xr_controller|so101_leader,
+ # then --teleop.=...). Resolved against IsaacTeleopConfig's own choice registry.
+ teleop: IsaacTeleopConfig
+ # SO-101 FOLLOWER arm (--robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=...).
+ robot: RobotConfig
+
+ # [leader] Path to the so101_leader plugin binary to spawn AFTER CloudXR is up (it then
+ # inherits the runtime env). None (default) -> assume the plugin already runs externally.
+ # The leader's serial port is --teleop.port (forwarded to the plugin; empty -> synthetic).
+ launch_plugin: str | None = None
+
+ # [xr] Slew all joints to a default reset pose before the loop (--reset_to_origin=false to
+ # keep the arm where it is). After the slew the clutch seeds its home from the measured pose.
+ reset_to_origin: bool = True
+ # [xr] Duration [s] of the reset-to-origin slew.
+ reset_duration: float = RESET_DURATION_S
+
+ # [leader] Slew the follower to the leader's first pose before mirroring (--align=false to
+ # begin the 1:1 mirror immediately; the follower may snap).
+ align: bool = True
+ # [leader] Duration [s] of the startup alignment slew.
+ align_duration: float = ALIGN_DURATION_S
+
+
+@parser.wrap()
+def teleoperate(cfg: TeleoperateConfig):
+ robot, device, motor_names = build_device(cfg)
+ hold = HoldLatch(motor_names)
+ try:
+ while True:
+ t0 = time.perf_counter()
+ obs = robot.get_observation()
+ # Idle (compute() -> None) holds the pose latched on the active->idle edge.
+ action = hold.resolve(device.compute(obs), obs)
+ robot.send_action(action)
+ precise_sleep(max(1.0 / FPS - (time.perf_counter() - t0), 0.0))
+ except KeyboardInterrupt:
+ pass
+ finally:
+ # A failing device cleanup must not skip the follower disconnect (which is what
+ # disables torque on the arm).
+ try:
+ device.cleanup()
+ finally:
+ robot.disconnect()
+
+
+def main():
+ teleoperate()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pyproject.toml b/pyproject.toml
index 03487e8c4..a00deae02 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,7 @@ discord = "https://discord.gg/s3KuuzsPFb"
[project]
name = "lerobot"
-version = "0.5.2"
+version = "0.6.1"
description = "🤗 LeRobot: State-of-the-art Machine Learning for Real-World Robotics in Pytorch"
dynamic = ["readme"]
license = { text = "Apache-2.0" }
@@ -164,6 +164,7 @@ pynput-dep = ["pynput>=1.7.8,<1.9.0"]
pyzmq-dep = ["pyzmq>=26.2.1,<28.0.0"]
motorbridge-dep = ["motorbridge>=0.3.2,<0.4.0"]
motorbridge-smart-servo-dep = ["motorbridge-smart-servo>=0.0.4,<0.1.0"]
+timm-dep = ["timm>=1.0.0,<1.1.0"]
# Motors
feetech = ["feetech-servo-sdk>=1.0.0,<2.0.0", "lerobot[pyserial-dep]", "lerobot[deepdiff-dep]"]
@@ -219,11 +220,10 @@ groot = [
"lerobot[transformers-dep]",
"lerobot[peft-dep]",
"lerobot[diffusers-dep]",
+ "lerobot[dataset]", # NOTE: processor_groot builds a LeRobotDataset for relative-action training stats
"dm-tree>=0.1.8,<1.0.0",
- "timm>=1.0.0,<1.1.0",
+ "lerobot[timm-dep]",
"decord>=0.6.0,<1.0.0; (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
- "ninja>=1.11.1,<2.0.0",
- "flash-attn>=2.5.9,<3.0.0 ; sys_platform != 'darwin'"
]
sarm = ["lerobot[transformers-dep]", "pydantic>=2.0.0,<3.0.0", "faker>=33.0.0,<35.0.0", "lerobot[matplotlib-dep]", "lerobot[qwen-vl-utils-dep]"]
robometer = ["lerobot[transformers-dep]", "lerobot[qwen-vl-utils-dep]", "lerobot[peft-dep]"]
@@ -234,8 +234,10 @@ fastwam = [
"lerobot[transformers-dep]",
"lerobot[diffusers-dep]",
]
+evo1 = ["lerobot[transformers-dep]"]
hilserl = ["lerobot[transformers-dep]", "lerobot[dataset]", "gym-hil>=0.1.14,<0.2.0", "lerobot[grpcio-dep]", "lerobot[placo-dep]"]
vla_jepa = ["lerobot[transformers-dep]", "lerobot[diffusers-dep]", "lerobot[qwen-vl-utils-dep]"]
+lingbot_va = ["lerobot[transformers-dep]", "lerobot[diffusers-dep]", "lerobot[accelerate-dep]"]
# Features
async = ["lerobot[grpcio-dep]", "lerobot[matplotlib-dep]"]
@@ -314,10 +316,12 @@ all = [
"lerobot[molmoact2]",
"lerobot[smolvla]",
"lerobot[fastwam]",
- # "lerobot[groot]", TODO(Steven): Gr00t requires specific installation instructions for flash-attn
+ "lerobot[groot]",
"lerobot[xvla]",
+ "lerobot[evo1]",
"lerobot[hilserl]",
"lerobot[vla_jepa]",
+ "lerobot[lingbot_va]",
"lerobot[async]",
"lerobot[dev]",
"lerobot[test]",
diff --git a/requirements-macos.txt b/requirements-macos.txt
deleted file mode 100644
index c5bbe1c8a..000000000
--- a/requirements-macos.txt
+++ /dev/null
@@ -1,729 +0,0 @@
-#
-# This file is autogenerated by pip-compile with Python 3.12
-# by the following command:
-#
-# pip-compile --output-file=requirements-macos.txt requirements.in
-#
--e .[all]
- # via -[all]
-absl-py==2.4.0
- # via
- # dm-control
- # dm-env
- # dm-tree
- # labmaze
- # mujoco
-accelerate==1.13.0
- # via
- # lerobot
- # peft
-aiohappyeyeballs==2.6.1
- # via aiohttp
-aiohttp==3.13.3
- # via fsspec
-aiosignal==1.4.0
- # via aiohttp
-annotated-doc==0.0.4
- # via
- # fastapi
- # typer
-annotated-types==0.7.0
- # via pydantic
-anyio==4.12.1
- # via
- # httpx
- # starlette
- # watchfiles
-asttokens==3.0.1
- # via stack-data
-attrs==25.4.0
- # via
- # aiohttp
- # dm-tree
- # jsonlines
- # rerun-sdk
-av==15.1.0
- # via
- # lerobot
- # qwen-vl-utils
-certifi==2026.2.25
- # via
- # httpcore
- # httpx
- # requests
- # sentry-sdk
-cffi==2.0.0
- # via pymunk
-cfgv==3.5.0
- # via pre-commit
-charset-normalizer==3.4.5
- # via requests
-click==8.3.1
- # via
- # typer
- # uvicorn
- # wandb
-cloudpickle==3.1.2
- # via gymnasium
-cmake==4.1.3
- # via lerobot
-cmeel==0.59.0
- # via
- # cmeel-assimp
- # cmeel-boost
- # cmeel-console-bridge
- # cmeel-octomap
- # cmeel-qhull
- # cmeel-tinyxml2
- # cmeel-urdfdom
- # cmeel-zlib
- # coal-library
- # eigenpy
- # eiquadprog
- # pin
- # placo
- # rhoban-cmeel-jsoncpp
-cmeel-assimp==5.4.3.1
- # via coal-library
-cmeel-boost==1.87.0.1
- # via
- # coal-library
- # eigenpy
- # eiquadprog
- # pin
-cmeel-console-bridge==1.0.2.3
- # via cmeel-urdfdom
-cmeel-octomap==1.10.0
- # via coal-library
-cmeel-qhull==8.0.2.1
- # via coal-library
-cmeel-tinyxml2==10.0.0
- # via cmeel-urdfdom
-cmeel-urdfdom==4.0.1
- # via pin
-cmeel-zlib==1.3.1
- # via cmeel-assimp
-coal-library==3.0.1
- # via pin
-contourpy==1.3.3
- # via
- # lerobot
- # matplotlib
-coverage[toml]==7.13.4
- # via pytest-cov
-cycler==0.12.1
- # via matplotlib
-datasets==4.6.1
- # via lerobot
-debugpy==1.8.20
- # via lerobot
-decorator==5.2.1
- # via ipython
-deepdiff==8.6.1
- # via lerobot
-diffusers==0.35.2
- # via lerobot
-dill==0.4.0
- # via
- # datasets
- # multiprocess
-distlib==0.4.0
- # via virtualenv
-dm-control==1.0.37
- # via gym-aloha
-dm-env==1.6
- # via dm-control
-dm-tree==0.1.9
- # via
- # dm-control
- # dm-env
-docopt==0.6.2
- # via num2words
-draccus==0.10.0
- # via lerobot
-dynamixel-sdk==3.8.4
- # via lerobot
-eigenpy==3.10.3
- # via coal-library
-einops==0.8.2
- # 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
- # lerobot
- # teleop
-feetech-servo-sdk==1.0.0
- # via lerobot
-filelock==3.25.0
- # via
- # datasets
- # diffusers
- # huggingface-hub
- # python-discovery
- # torch
- # virtualenv
-fonttools==4.61.1
- # via matplotlib
-frozenlist==1.8.0
- # via
- # aiohttp
- # aiosignal
-fsspec[http]==2026.2.0
- # via
- # datasets
- # etils
- # huggingface-hub
- # torch
-gitdb==4.0.12
- # via gitpython
-gitpython==3.1.46
- # via wandb
-glfw==2.10.0
- # via
- # dm-control
- # mujoco
-grpcio==1.73.1
- # via
- # grpcio-tools
- # lerobot
- # reachy2-sdk
- # reachy2-sdk-api
-grpcio-tools==1.73.1
- # via
- # lerobot
- # reachy2-sdk-api
-gym-aloha==0.1.3
- # via lerobot
-gym-hil==0.1.13
- # via lerobot
-gym-pusht==0.1.6
- # via lerobot
-gymnasium==1.2.3
- # via
- # gym-aloha
- # gym-hil
- # gym-pusht
- # lerobot
- # metaworld
-h11==0.16.0
- # via
- # httpcore
- # uvicorn
-hebi-py==2.11.0
- # via lerobot
-hf-xet==1.3.2
- # via huggingface-hub
-hidapi==0.14.0.post4
- # via
- # gym-hil
- # lerobot
-httpcore==1.0.9
- # via httpx
-httptools==0.7.1
- # via uvicorn
-httpx==0.28.1
- # via
- # datasets
- # huggingface-hub
-huggingface-hub==1.6.0
- # via
- # accelerate
- # datasets
- # diffusers
- # lerobot
- # peft
- # tokenizers
- # transformers
-identify==2.6.17
- # via pre-commit
-idna==3.11
- # via
- # anyio
- # httpx
- # requests
- # yarl
-imageio[ffmpeg]==2.37.2
- # via
- # gym-aloha
- # gym-hil
- # lerobot
- # metaworld
- # scikit-image
-imageio-ffmpeg==0.6.0
- # via imageio
-importlib-metadata==8.7.1
- # via diffusers
-iniconfig==2.3.0
- # via pytest
-ipython==9.11.0
- # via meshcat
-ipython-pygments-lexers==1.1.1
- # via ipython
-ischedule==1.2.7
- # via placo
-jedi==0.19.2
- # via ipython
-jinja2==3.1.6
- # via torch
-jsonlines==4.0.0
- # via lerobot
-kiwisolver==1.4.9
- # via matplotlib
-labmaze==1.0.6
- # via dm-control
-lazy-loader==0.5
- # via scikit-image
-librt==0.8.1
- # via mypy
-lxml==6.0.2
- # via dm-control
-markdown-it-py==4.0.0
- # via rich
-markupsafe==3.0.3
- # via jinja2
-matplotlib==3.10.8
- # via lerobot
-matplotlib-inline==0.2.1
- # via ipython
-mdurl==0.1.2
- # via markdown-it-py
-mergedeep==1.3.4
- # via draccus
-meshcat==0.3.2
- # via placo
-metaworld==3.0.0
- # via lerobot
-mock-serial==0.0.1
- # via lerobot
-mpmath==1.3.0
- # via sympy
-mujoco==3.5.0
- # via
- # dm-control
- # gym-aloha
- # gym-hil
- # metaworld
-multidict==6.7.1
- # via
- # aiohttp
- # yarl
-multiprocess==0.70.18
- # via datasets
-mypy==1.19.1
- # via lerobot
-mypy-extensions==1.1.0
- # via
- # mypy
- # typing-inspect
-networkx==3.6.1
- # via
- # scikit-image
- # torch
-nodeenv==1.10.0
- # via pre-commit
-num2words==0.5.14
- # via lerobot
-numpy==2.2.6
- # via
- # accelerate
- # cmeel-boost
- # contourpy
- # datasets
- # diffusers
- # dm-control
- # dm-env
- # dm-tree
- # gymnasium
- # hebi-py
- # imageio
- # labmaze
- # lerobot
- # matplotlib
- # meshcat
- # metaworld
- # mujoco
- # opencv-python
- # opencv-python-headless
- # pandas
- # peft
- # pyquaternion
- # reachy2-sdk
- # rerun-sdk
- # scikit-image
- # scipy
- # shapely
- # teleop
- # tifffile
- # torchvision
- # transformers
- # transforms3d
-opencv-python==4.13.0.92
- # via
- # gym-pusht
- # reachy2-sdk
-opencv-python-headless==4.12.0.88
- # via lerobot
-orderly-set==5.5.0
- # via deepdiff
-packaging==25.0
- # via
- # accelerate
- # datasets
- # huggingface-hub
- # lazy-loader
- # lerobot
- # matplotlib
- # peft
- # pytest
- # qwen-vl-utils
- # reachy2-sdk
- # scikit-image
- # transformers
- # wandb
-pandas==2.3.3
- # via
- # datasets
- # lerobot
-parso==0.8.6
- # via jedi
-pathspec==1.0.4
- # via mypy
-peft==0.18.1
- # via lerobot
-pexpect==4.9.0
- # via ipython
-pillow==12.1.1
- # via
- # diffusers
- # imageio
- # matplotlib
- # meshcat
- # qwen-vl-utils
- # rerun-sdk
- # scikit-image
- # torchvision
-pin==3.4.0
- # via placo
-placo==0.9.16
- # via lerobot
-platformdirs==4.9.4
- # via
- # python-discovery
- # virtualenv
- # wandb
-pluggy==1.6.0
- # via
- # pytest
- # pytest-cov
-pre-commit==4.5.1
- # via lerobot
-prompt-toolkit==3.0.52
- # via ipython
-propcache==0.4.1
- # via
- # aiohttp
- # yarl
-protobuf==6.31.1
- # via
- # dm-control
- # grpcio-tools
- # lerobot
- # reachy2-sdk
- # reachy2-sdk-api
- # wandb
-psutil==7.2.2
- # via
- # accelerate
- # imageio
- # peft
-ptyprocess==0.7.0
- # via pexpect
-pure-eval==0.2.3
- # via stack-data
-pyarrow==23.0.1
- # via
- # datasets
- # rerun-sdk
-pycparser==3.0
- # via cffi
-pydantic==2.12.5
- # via
- # fastapi
- # wandb
-pydantic-core==2.41.5
- # via pydantic
-pygame==2.6.1
- # via
- # gym-hil
- # gym-pusht
- # lerobot
-pygments==2.19.2
- # via
- # ipython
- # ipython-pygments-lexers
- # pytest
- # rich
-pymunk==6.11.1
- # via
- # gym-pusht
- # lerobot
-pyngrok==7.5.1
- # via meshcat
-pynput==1.8.1
- # via
- # gym-hil
- # lerobot
-pyobjc-core==12.1
- # via
- # pyobjc-framework-applicationservices
- # pyobjc-framework-cocoa
- # pyobjc-framework-coretext
- # pyobjc-framework-quartz
-pyobjc-framework-applicationservices==12.1
- # via pynput
-pyobjc-framework-cocoa==12.1
- # via
- # pyobjc-framework-applicationservices
- # pyobjc-framework-coretext
- # pyobjc-framework-quartz
-pyobjc-framework-coretext==12.1
- # via pyobjc-framework-applicationservices
-pyobjc-framework-quartz==12.1
- # via
- # pynput
- # pyobjc-framework-applicationservices
- # pyobjc-framework-coretext
-pyopengl==3.1.10
- # via
- # dm-control
- # mujoco
-pyparsing==3.3.2
- # via
- # dm-control
- # matplotlib
-pyquaternion==0.9.9
- # via reachy2-sdk
-pyrealsense2-macosx==2.56.5
- # via lerobot
-pyserial==3.5
- # via
- # dynamixel-sdk
- # feetech-servo-sdk
- # lerobot
-pytest==8.4.2
- # via
- # lerobot
- # pytest-cov
- # pytest-timeout
- # teleop
-pytest-cov==7.0.0
- # via lerobot
-pytest-timeout==2.4.0
- # via lerobot
-python-dateutil==2.9.0.post0
- # via
- # faker
- # matplotlib
- # pandas
-python-discovery==1.1.1
- # via virtualenv
-python-dotenv==1.2.2
- # via uvicorn
-pytz==2026.1.post1
- # via pandas
-pyyaml==6.0.3
- # via
- # accelerate
- # datasets
- # draccus
- # hebi-py
- # huggingface-hub
- # peft
- # pre-commit
- # pyngrok
- # pyyaml-include
- # transformers
- # uvicorn
- # wandb
-pyyaml-include==1.4.1
- # via draccus
-pyzmq==27.1.0
- # via
- # lerobot
- # meshcat
-qwen-vl-utils==0.0.14
- # via lerobot
-reachy2-sdk==1.0.15
- # via lerobot
-reachy2-sdk-api==1.0.21
- # via reachy2-sdk
-regex==2026.2.28
- # via
- # diffusers
- # transformers
-requests==2.32.5
- # via
- # datasets
- # diffusers
- # dm-control
- # qwen-vl-utils
- # teleop
- # wandb
-rerun-sdk==0.26.2
- # via lerobot
-rhoban-cmeel-jsoncpp==1.9.4.9
- # via placo
-rich==14.3.3
- # via typer
-safetensors==0.7.0
- # via
- # accelerate
- # diffusers
- # lerobot
- # peft
- # transformers
-scikit-image==0.25.2
- # via
- # gym-pusht
- # lerobot
-scipy==1.17.1
- # via
- # dm-control
- # lerobot
- # metaworld
- # scikit-image
- # torchdiffeq
-sentry-sdk==2.54.0
- # via wandb
-shapely==2.1.2
- # via gym-pusht
-shellingham==1.5.4
- # via typer
-six==1.17.0
- # via
- # pynput
- # python-dateutil
-smmap==5.0.3
- # via gitdb
-stack-data==0.6.3
- # via ipython
-starlette==0.52.1
- # via fastapi
-sympy==1.14.0
- # via torch
-teleop==0.1.4
- # via lerobot
-termcolor==3.3.0
- # via lerobot
-tifffile==2026.3.3
- # via scikit-image
-tokenizers==0.22.2
- # via transformers
-toml==0.10.2
- # via draccus
-torch==2.10.0
- # via
- # accelerate
- # lerobot
- # peft
- # torchdiffeq
- # torchvision
-torchcodec==0.10.0
- # via lerobot
-torchdiffeq==0.2.5
- # via lerobot
-torchvision==0.25.0
- # via lerobot
-tornado==6.5.4
- # via meshcat
-tqdm==4.67.3
- # via
- # datasets
- # dm-control
- # huggingface-hub
- # peft
- # transformers
-traitlets==5.14.3
- # via
- # ipython
- # matplotlib-inline
-transformers==5.3.0
- # via
- # lerobot
- # peft
-transforms3d==0.4.2
- # via teleop
-typer==0.24.1
- # via
- # huggingface-hub
- # transformers
-typing-extensions==4.15.0
- # via
- # aiosignal
- # anyio
- # etils
- # faker
- # fastapi
- # gymnasium
- # huggingface-hub
- # mypy
- # pydantic
- # pydantic-core
- # rerun-sdk
- # starlette
- # torch
- # typing-inspect
- # typing-inspection
- # wandb
-typing-inspect==0.9.0
- # via draccus
-typing-inspection==0.4.2
- # via
- # fastapi
- # pydantic
-tzdata==2025.3
- # via pandas
-u-msgpack-python==2.8.0
- # via meshcat
-urllib3==2.6.3
- # via
- # requests
- # sentry-sdk
-uvicorn[standard]==0.41.0
- # via teleop
-uvloop==0.22.1
- # via uvicorn
-virtualenv==21.1.0
- # via pre-commit
-wandb==0.24.2
- # via lerobot
-watchfiles==1.1.1
- # via uvicorn
-wcwidth==0.6.0
- # via prompt-toolkit
-websocket-client==1.9.0
- # via teleop
-websockets==16.0
- # via uvicorn
-wrapt==2.1.2
- # via dm-tree
-xxhash==3.6.0
- # via datasets
-yarl==1.23.0
- # via aiohttp
-zipp==3.23.0
- # via
- # etils
- # importlib-metadata
-
-# The following packages are considered to be unsafe in a requirements file:
-# setuptools
diff --git a/requirements-ubuntu.txt b/requirements-ubuntu.txt
deleted file mode 100644
index 0cdc54190..000000000
--- a/requirements-ubuntu.txt
+++ /dev/null
@@ -1,882 +0,0 @@
-#
-# This file is autogenerated by pip-compile with Python 3.12
-# by the following command:
-#
-# pip-compile --output-file=requirements-ubuntu.txt requirements.in
-#
--e .[all]
- # via -[all]
-absl-py==2.4.0
- # via
- # dm-control
- # dm-env
- # dm-tree
- # labmaze
- # mujoco
- # tensorboard
-accelerate==1.13.0
- # via
- # lerobot
- # peft
-aiohappyeyeballs==2.6.1
- # via aiohttp
-aiohttp==3.13.3
- # via fsspec
-aiosignal==1.4.0
- # via aiohttp
-annotated-doc==0.0.4
- # via
- # fastapi
- # typer
-annotated-types==0.7.0
- # via pydantic
-antlr4-python3-runtime==4.9.3
- # via
- # hydra-core
- # omegaconf
-anyio==4.12.1
- # via
- # httpx
- # starlette
- # watchfiles
-asttokens==3.0.1
- # via stack-data
-attrs==25.4.0
- # via
- # aiohttp
- # dm-tree
- # jsonlines
- # jsonschema
- # referencing
- # rerun-sdk
-av==15.1.0
- # via
- # lerobot
- # qwen-vl-utils
-bddl==1.0.1
- # via hf-libero
-certifi==2026.2.25
- # via
- # httpcore
- # httpx
- # requests
- # sentry-sdk
-cffi==2.0.0
- # via pymunk
-cfgv==3.5.0
- # via pre-commit
-charset-normalizer==3.4.5
- # via requests
-click==8.3.1
- # via
- # typer
- # uvicorn
- # wandb
-cloudpickle==3.1.2
- # via
- # gymnasium
- # hf-libero
-cmake==4.1.3
- # via lerobot
-cmeel==0.59.0
- # via
- # cmeel-assimp
- # cmeel-boost
- # cmeel-console-bridge
- # cmeel-octomap
- # cmeel-qhull
- # cmeel-tinyxml2
- # cmeel-urdfdom
- # cmeel-zlib
- # coal-library
- # eigenpy
- # eiquadprog
- # pin
- # placo
- # rhoban-cmeel-jsoncpp
-cmeel-assimp==5.4.3.1
- # via coal-library
-cmeel-boost==1.87.0.1
- # via
- # coal-library
- # eigenpy
- # eiquadprog
- # pin
-cmeel-console-bridge==1.0.2.3
- # via cmeel-urdfdom
-cmeel-octomap==1.10.0
- # via coal-library
-cmeel-qhull==8.0.2.1
- # via coal-library
-cmeel-tinyxml2==10.0.0
- # via cmeel-urdfdom
-cmeel-urdfdom==4.0.1
- # via pin
-cmeel-zlib==1.3.1
- # via cmeel-assimp
-coal-library==3.0.1
- # via pin
-contourpy==1.3.3
- # via
- # lerobot
- # matplotlib
-coverage[toml]==7.13.4
- # via pytest-cov
-cuda-bindings==12.9.4
- # via torch
-cuda-pathfinder==1.4.1
- # via cuda-bindings
-cycler==0.12.1
- # via matplotlib
-datasets==4.6.1
- # via lerobot
-debugpy==1.8.20
- # via lerobot
-decorator==5.2.1
- # via ipython
-deepdiff==8.6.1
- # via lerobot
-diffusers==0.35.2
- # via lerobot
-dill==0.4.0
- # via
- # datasets
- # multiprocess
-distlib==0.4.0
- # via virtualenv
-dm-control==1.0.37
- # via gym-aloha
-dm-env==1.6
- # via dm-control
-dm-tree==0.1.9
- # via
- # dm-control
- # dm-env
-docopt==0.6.2
- # via num2words
-draccus==0.10.0
- # via lerobot
-dynamixel-sdk==3.8.4
- # via lerobot
-easydict==1.13
- # via hf-libero
-egl-probe==1.0.2
- # via robomimic
-eigenpy==3.10.3
- # via coal-library
-einops==0.8.2
- # via
- # hf-libero
- # lerobot
-eiquadprog==1.2.9
- # via placo
-etils[epath,epy]==1.14.0
- # via mujoco
-evdev==1.9.3
- # via pynput
-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
- # lerobot
- # teleop
-fastjsonschema==2.21.2
- # via nbformat
-feetech-servo-sdk==1.0.0
- # via lerobot
-filelock==3.25.0
- # via
- # datasets
- # diffusers
- # huggingface-hub
- # python-discovery
- # torch
- # virtualenv
-fonttools==4.61.1
- # via matplotlib
-frozenlist==1.8.0
- # via
- # aiohttp
- # aiosignal
-fsspec[http]==2026.2.0
- # via
- # datasets
- # etils
- # huggingface-hub
- # torch
-future==1.0.0
- # via hf-libero
-gitdb==4.0.12
- # via gitpython
-gitpython==3.1.46
- # via wandb
-glfw==2.10.0
- # via
- # dm-control
- # mujoco
-grpcio==1.73.1
- # via
- # grpcio-tools
- # lerobot
- # reachy2-sdk
- # reachy2-sdk-api
- # tensorboard
-grpcio-tools==1.73.1
- # via
- # lerobot
- # reachy2-sdk-api
-gym-aloha==0.1.3
- # via lerobot
-gym-hil==0.1.13
- # via lerobot
-gym-pusht==0.1.6
- # via lerobot
-gymnasium==1.2.3
- # via
- # gym-aloha
- # gym-hil
- # gym-pusht
- # hf-libero
- # lerobot
- # metaworld
-h11==0.16.0
- # via
- # httpcore
- # uvicorn
-h5py==3.16.0
- # via robomimic
-hebi-py==2.11.0
- # via lerobot
-hf-egl-probe==1.0.2
- # via hf-libero
-hf-libero==0.1.3
- # via lerobot
-hf-xet==1.3.2
- # via huggingface-hub
-hidapi==0.14.0.post4
- # via
- # gym-hil
- # lerobot
-httpcore==1.0.9
- # via httpx
-httptools==0.7.1
- # via uvicorn
-httpx==0.28.1
- # via
- # datasets
- # huggingface-hub
-huggingface-hub==1.6.0
- # via
- # accelerate
- # datasets
- # diffusers
- # lerobot
- # peft
- # tokenizers
- # transformers
-hydra-core==1.3.2
- # via hf-libero
-identify==2.6.17
- # via pre-commit
-idna==3.11
- # via
- # anyio
- # httpx
- # requests
- # yarl
-imageio[ffmpeg]==2.37.2
- # via
- # gym-aloha
- # gym-hil
- # lerobot
- # metaworld
- # robomimic
- # scikit-image
-imageio-ffmpeg==0.6.0
- # via
- # imageio
- # robomimic
-importlib-metadata==8.7.1
- # via diffusers
-iniconfig==2.3.0
- # via pytest
-ipython==9.11.0
- # via meshcat
-ipython-pygments-lexers==1.1.1
- # via ipython
-ischedule==1.2.7
- # via placo
-jedi==0.19.2
- # via ipython
-jinja2==3.1.6
- # via torch
-jsonlines==4.0.0
- # via lerobot
-jsonschema==4.26.0
- # via nbformat
-jsonschema-specifications==2025.9.1
- # via jsonschema
-jupyter-core==5.9.1
- # via nbformat
-jupytext==1.19.1
- # via bddl
-kiwisolver==1.4.9
- # via matplotlib
-labmaze==1.0.6
- # via dm-control
-lazy-loader==0.5
- # via scikit-image
-librt==0.8.1
- # via mypy
-llvmlite==0.46.0
- # via numba
-lxml==6.0.2
- # via dm-control
-markdown==3.10.2
- # via tensorboard
-markdown-it-py==4.0.0
- # via
- # jupytext
- # mdit-py-plugins
- # rich
-markupsafe==3.0.3
- # via
- # jinja2
- # werkzeug
-matplotlib==3.10.8
- # via
- # hf-libero
- # lerobot
-matplotlib-inline==0.2.1
- # via ipython
-mdit-py-plugins==0.5.0
- # via jupytext
-mdurl==0.1.2
- # via markdown-it-py
-mergedeep==1.3.4
- # via draccus
-meshcat==0.3.2
- # via placo
-metaworld==3.0.0
- # via lerobot
-mock-serial==0.0.1
- # via lerobot
-mpmath==1.3.0
- # via sympy
-mujoco==3.5.0
- # via
- # dm-control
- # gym-aloha
- # gym-hil
- # hf-libero
- # metaworld
- # robosuite
-multidict==6.7.1
- # via
- # aiohttp
- # yarl
-multiprocess==0.70.18
- # via datasets
-mypy==1.19.1
- # via lerobot
-mypy-extensions==1.1.0
- # via
- # mypy
- # typing-inspect
-nbformat==5.10.4
- # via jupytext
-networkx==3.6.1
- # via
- # bddl
- # scikit-image
- # torch
-nodeenv==1.10.0
- # via pre-commit
-num2words==0.5.14
- # via lerobot
-numba==0.64.0
- # via robosuite
-numpy==2.2.6
- # via
- # accelerate
- # bddl
- # cmeel-boost
- # contourpy
- # datasets
- # diffusers
- # dm-control
- # dm-env
- # dm-tree
- # gymnasium
- # h5py
- # hebi-py
- # hf-libero
- # imageio
- # labmaze
- # lerobot
- # matplotlib
- # meshcat
- # metaworld
- # mujoco
- # numba
- # opencv-python
- # opencv-python-headless
- # pandas
- # peft
- # pyquaternion
- # reachy2-sdk
- # rerun-sdk
- # robomimic
- # robosuite
- # scikit-image
- # scipy
- # shapely
- # teleop
- # tensorboard
- # tensorboardx
- # tifffile
- # torchvision
- # transformers
- # transforms3d
-nvidia-cublas-cu12==12.8.4.1
- # via
- # nvidia-cudnn-cu12
- # nvidia-cusolver-cu12
- # torch
-nvidia-cuda-cupti-cu12==12.8.90
- # via torch
-nvidia-cuda-nvrtc-cu12==12.8.93
- # via torch
-nvidia-cuda-runtime-cu12==12.8.90
- # via torch
-nvidia-cudnn-cu12==9.10.2.21
- # via torch
-nvidia-cufft-cu12==11.3.3.83
- # via torch
-nvidia-cufile-cu12==1.13.1.3
- # via torch
-nvidia-curand-cu12==10.3.9.90
- # via torch
-nvidia-cusolver-cu12==11.7.3.90
- # via torch
-nvidia-cusparse-cu12==12.5.8.93
- # via
- # nvidia-cusolver-cu12
- # torch
-nvidia-cusparselt-cu12==0.7.1
- # via torch
-nvidia-nccl-cu12==2.27.5
- # via torch
-nvidia-nvjitlink-cu12==12.8.93
- # via
- # nvidia-cufft-cu12
- # nvidia-cusolver-cu12
- # nvidia-cusparse-cu12
- # torch
-nvidia-nvshmem-cu12==3.4.5
- # via torch
-nvidia-nvtx-cu12==12.8.90
- # via torch
-omegaconf==2.3.0
- # via hydra-core
-opencv-python==4.13.0.92
- # via
- # gym-pusht
- # hf-libero
- # reachy2-sdk
- # robosuite
-opencv-python-headless==4.12.0.88
- # via lerobot
-orderly-set==5.5.0
- # via deepdiff
-packaging==25.0
- # via
- # accelerate
- # datasets
- # huggingface-hub
- # hydra-core
- # jupytext
- # lazy-loader
- # lerobot
- # matplotlib
- # peft
- # pytest
- # qwen-vl-utils
- # reachy2-sdk
- # scikit-image
- # tensorboard
- # tensorboardx
- # transformers
- # wandb
-pandas==2.3.3
- # via
- # datasets
- # lerobot
-parso==0.8.6
- # via jedi
-pathspec==1.0.4
- # via mypy
-peft==0.18.1
- # via lerobot
-pexpect==4.9.0
- # via ipython
-pillow==12.1.1
- # via
- # diffusers
- # imageio
- # matplotlib
- # meshcat
- # qwen-vl-utils
- # rerun-sdk
- # robosuite
- # scikit-image
- # tensorboard
- # torchvision
-pin==3.4.0
- # via placo
-placo==0.9.16
- # via lerobot
-platformdirs==4.9.4
- # via
- # jupyter-core
- # python-discovery
- # virtualenv
- # wandb
-pluggy==1.6.0
- # via
- # pytest
- # pytest-cov
-pre-commit==4.5.1
- # via lerobot
-prompt-toolkit==3.0.52
- # via ipython
-propcache==0.4.1
- # via
- # aiohttp
- # yarl
-protobuf==6.31.1
- # via
- # dm-control
- # grpcio-tools
- # lerobot
- # reachy2-sdk
- # reachy2-sdk-api
- # tensorboard
- # tensorboardx
- # wandb
-psutil==7.2.2
- # via
- # accelerate
- # imageio
- # peft
- # robomimic
-ptyprocess==0.7.0
- # via pexpect
-pure-eval==0.2.3
- # via stack-data
-pyarrow==23.0.1
- # via
- # datasets
- # rerun-sdk
-pycparser==3.0
- # via cffi
-pydantic==2.12.5
- # via
- # fastapi
- # wandb
-pydantic-core==2.41.5
- # via pydantic
-pygame==2.6.1
- # via
- # gym-hil
- # gym-pusht
- # lerobot
-pygments==2.19.2
- # via
- # ipython
- # ipython-pygments-lexers
- # pytest
- # rich
-pymunk==6.11.1
- # via
- # gym-pusht
- # lerobot
-pyngrok==7.5.1
- # via meshcat
-pynput==1.8.1
- # via
- # gym-hil
- # lerobot
-pyopengl==3.1.10
- # via
- # dm-control
- # mujoco
-pyparsing==3.3.2
- # via
- # dm-control
- # matplotlib
-pyquaternion==0.9.9
- # via reachy2-sdk
-pyrealsense2==2.56.5.9235
- # via lerobot
-pyserial==3.5
- # via
- # dynamixel-sdk
- # feetech-servo-sdk
- # lerobot
-pytest==8.4.2
- # via
- # bddl
- # lerobot
- # pytest-cov
- # pytest-timeout
- # teleop
-pytest-cov==7.0.0
- # via lerobot
-pytest-timeout==2.4.0
- # via lerobot
-python-dateutil==2.9.0.post0
- # via
- # faker
- # matplotlib
- # pandas
-python-discovery==1.1.1
- # via virtualenv
-python-dotenv==1.2.2
- # via uvicorn
-python-xlib==0.33
- # via pynput
-pytz==2026.1.post1
- # via pandas
-pyyaml==6.0.3
- # via
- # accelerate
- # datasets
- # draccus
- # hebi-py
- # huggingface-hub
- # jupytext
- # omegaconf
- # peft
- # pre-commit
- # pyngrok
- # pyyaml-include
- # transformers
- # uvicorn
- # wandb
-pyyaml-include==1.4.1
- # via draccus
-pyzmq==27.1.0
- # via
- # lerobot
- # meshcat
-qwen-vl-utils==0.0.14
- # via lerobot
-reachy2-sdk==1.0.15
- # via lerobot
-reachy2-sdk-api==1.0.21
- # via reachy2-sdk
-referencing==0.37.0
- # via
- # jsonschema
- # jsonschema-specifications
-regex==2026.2.28
- # via
- # diffusers
- # transformers
-requests==2.32.5
- # via
- # datasets
- # diffusers
- # dm-control
- # qwen-vl-utils
- # teleop
- # wandb
-rerun-sdk==0.26.2
- # via lerobot
-rhoban-cmeel-jsoncpp==1.9.4.9
- # via placo
-rich==14.3.3
- # via typer
-robomimic==0.2.0
- # via hf-libero
-robosuite==1.4.0
- # via hf-libero
-rpds-py==0.30.0
- # via
- # jsonschema
- # referencing
-safetensors==0.7.0
- # via
- # accelerate
- # diffusers
- # lerobot
- # peft
- # transformers
-scikit-image==0.25.2
- # via
- # gym-pusht
- # lerobot
-scipy==1.17.1
- # via
- # dm-control
- # lerobot
- # metaworld
- # robosuite
- # scikit-image
- # torchdiffeq
-sentry-sdk==2.54.0
- # via wandb
-shapely==2.1.2
- # via gym-pusht
-shellingham==1.5.4
- # via typer
-six==1.17.0
- # via
- # pynput
- # python-dateutil
- # python-xlib
-smmap==5.0.3
- # via gitdb
-stack-data==0.6.3
- # via ipython
-starlette==0.52.1
- # via fastapi
-sympy==1.14.0
- # via torch
-teleop==0.1.4
- # via lerobot
-tensorboard==2.20.0
- # via robomimic
-tensorboard-data-server==0.7.2
- # via tensorboard
-tensorboardx==2.6.4
- # via robomimic
-termcolor==3.3.0
- # via
- # lerobot
- # robomimic
-thop==0.1.1.post2209072238
- # via hf-libero
-tifffile==2026.3.3
- # via scikit-image
-tokenizers==0.22.2
- # via transformers
-toml==0.10.2
- # via draccus
-torch==2.10.0
- # via
- # accelerate
- # lerobot
- # peft
- # robomimic
- # thop
- # torchdiffeq
- # torchvision
-torchcodec==0.10.0
- # via lerobot
-torchdiffeq==0.2.5
- # via lerobot
-torchvision==0.25.0
- # via
- # lerobot
- # robomimic
-tornado==6.5.4
- # via meshcat
-tqdm==4.67.3
- # via
- # datasets
- # dm-control
- # huggingface-hub
- # peft
- # robomimic
- # transformers
-traitlets==5.14.3
- # via
- # ipython
- # jupyter-core
- # matplotlib-inline
- # nbformat
-transformers==5.3.0
- # via
- # hf-libero
- # lerobot
- # peft
-transforms3d==0.4.2
- # via teleop
-triton==3.6.0
- # via torch
-typer==0.24.1
- # via
- # huggingface-hub
- # transformers
-typing-extensions==4.15.0
- # via
- # aiosignal
- # anyio
- # etils
- # faker
- # fastapi
- # gymnasium
- # huggingface-hub
- # mypy
- # pydantic
- # pydantic-core
- # referencing
- # rerun-sdk
- # starlette
- # torch
- # typing-inspect
- # typing-inspection
- # wandb
-typing-inspect==0.9.0
- # via draccus
-typing-inspection==0.4.2
- # via
- # fastapi
- # pydantic
-tzdata==2025.3
- # via pandas
-u-msgpack-python==2.8.0
- # via meshcat
-urllib3==2.6.3
- # via
- # requests
- # sentry-sdk
-uvicorn[standard]==0.41.0
- # via teleop
-uvloop==0.22.1
- # via uvicorn
-virtualenv==21.1.0
- # via pre-commit
-wandb==0.24.2
- # via
- # hf-libero
- # lerobot
-watchfiles==1.1.1
- # via uvicorn
-wcwidth==0.6.0
- # via prompt-toolkit
-websocket-client==1.9.0
- # via teleop
-websockets==16.0
- # via uvicorn
-werkzeug==3.1.6
- # via tensorboard
-wrapt==2.1.2
- # via dm-tree
-xxhash==3.6.0
- # via datasets
-yarl==1.23.0
- # via aiohttp
-zipp==3.23.0
- # via
- # etils
- # importlib-metadata
-
-# The following packages are considered to be unsafe in a requirements file:
-# setuptools
diff --git a/requirements.in b/requirements.in
deleted file mode 100644
index b39632f71..000000000
--- a/requirements.in
+++ /dev/null
@@ -1,9 +0,0 @@
-# requirements.in
-
-# requirements-macos.txt was generated on macOS and is platform-specific (macOS 26.3.1 25D2128 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
-
-# requirements-ubuntu.txt was generated on Linux and is platform-specific (Ubuntu 24.04.4 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
-
--e .[all]
diff --git a/src/lerobot/envs/configs.py b/src/lerobot/envs/configs.py
index 84c40472f..3624357e2 100644
--- a/src/lerobot/envs/configs.py
+++ b/src/lerobot/envs/configs.py
@@ -757,7 +757,7 @@ class RoboTwinEnvConfig(EnvConfig):
task: str = "beat_block_hammer" # single task or comma-separated list
fps: int = 25
- episode_length: int = 300
+ episode_length: int = 1200
obs_type: str = "pixels_agent_pos"
render_mode: str = "rgb_array"
# Available cameras from RoboTwin's aloha-agilex embodiment: head_camera
@@ -768,6 +768,9 @@ class RoboTwinEnvConfig(EnvConfig):
# must equal what SAPIEN actually renders.
observation_height: int = 240
observation_width: int = 320
+ # "joint": 14-d joint-space control. "ee": 16-d end-effector-pose deltas executed via CuRobo IK
+ # (for world-model policies like LingBot-VA that predict per-arm xyz+quaternion+gripper poses).
+ action_mode: str = "joint"
features: dict[str, PolicyFeature] = field(
default_factory=lambda: {
ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(14,)),
@@ -784,6 +787,8 @@ class RoboTwinEnvConfig(EnvConfig):
)
def __post_init__(self):
+ if self.action_mode == "ee":
+ self.features[ACTION] = PolicyFeature(type=FeatureType.ACTION, shape=(16,))
cam_list = [c.strip() for c in self.camera_names.split(",") if c.strip()]
for cam in cam_list:
self.features[f"pixels/{cam}"] = PolicyFeature(
@@ -826,6 +831,7 @@ class RoboTwinEnvConfig(EnvConfig):
observation_height=self.observation_height,
observation_width=self.observation_width,
episode_length=self.episode_length,
+ action_mode=self.action_mode,
)
diff --git a/src/lerobot/envs/robotwin.py b/src/lerobot/envs/robotwin.py
index 823f14fa0..5b03f337b 100644
--- a/src/lerobot/envs/robotwin.py
+++ b/src/lerobot/envs/robotwin.py
@@ -17,6 +17,7 @@ from __future__ import annotations
import importlib
import logging
+import os
from collections import defaultdict
from collections.abc import Callable, Sequence
from functools import partial
@@ -28,9 +29,17 @@ import torch
from gymnasium import spaces
from lerobot.types import RobotObservation
+from lerobot.utils.import_utils import _scipy_available
from .utils import _LazyAsyncVectorEnv
+# scipy is only used for end-effector-pose composition (``--env.action_mode=ee``); guard it so this
+# module (and its base-env unit tests, which mock the RoboTwin runtime) imports without scipy installed.
+if _scipy_available:
+ from scipy.spatial.transform import Rotation
+else:
+ Rotation = None
+
logger = logging.getLogger(__name__)
# Camera names as used by RoboTwin 2.0. The wrapper appends "_rgb" when looking
@@ -41,10 +50,124 @@ ROBOTWIN_CAMERA_NAMES: tuple[str, ...] = (
"right_camera",
)
-ACTION_DIM = 14 # 7 DOF × 2 arms
+ACTION_DIM = 14 # 7 DOF × 2 arms (joint-space control mode)
+# End-effector-pose control mode: per arm [x, y, z, qx, qy, qz, qw, gripper] = 8, dual-arm = 16.
+# Used by world-model policies (e.g. LingBot-VA) that predict eef-pose deltas executed via CuRobo IK.
+EEF_ACTION_DIM = 16
ACTION_LOW = -1.0
ACTION_HIGH = 1.0
-DEFAULT_EPISODE_LENGTH = 300
+DEFAULT_EPISODE_LENGTH = 1200
+OFFICIAL_INSTRUCTION_ENV = "LEROBOT_ROBOTWIN_OFFICIAL_INSTRUCTION"
+OFFICIAL_INSTRUCTION_TYPE_ENV = "LEROBOT_ROBOTWIN_INSTRUCTION_TYPE"
+OFFICIAL_INSTRUCTION_MAX_ENV = "LEROBOT_ROBOTWIN_INSTRUCTION_MAX"
+
+
+def _compose_eef_pose(new_pose: np.ndarray, init_pose: np.ndarray) -> np.ndarray:
+ """Compose a single-arm predicted delta pose onto the initial pose.
+
+ ``new_pose`` / ``init_pose`` are 8-vectors ``[x, y, z, qx, qy, qz, qw, gripper]``. Translation
+ is added, rotation is composed (``init_R * new_R``), and the gripper is taken from the
+ prediction. Mirrors ``add_eef_pose`` in the upstream LingBot-VA RoboTwin client.
+ """
+ new_r = Rotation.from_quat(new_pose[3:7])
+ init_r = Rotation.from_quat(init_pose[3:7])
+ out_rot = (init_r * new_r).as_quat()
+ out_trans = new_pose[:3] + init_pose[:3]
+ return np.concatenate([out_trans, out_rot, new_pose[7:8]])
+
+
+def _add_init_eef_pose(delta_pose: np.ndarray, init_pose: np.ndarray) -> np.ndarray:
+ """Compose a dual-arm (16-d) predicted delta pose onto the initial eef pose, normalizing quats."""
+ left = _compose_eef_pose(delta_pose[:8], init_pose[:8])
+ right = _compose_eef_pose(delta_pose[8:], init_pose[8:])
+ out = np.concatenate([left, right])
+ # Normalize the two quaternions (indices 3:7 and 11:15) as the upstream client does.
+ out[3:7] = out[3:7] / (np.linalg.norm(out[3:7]) + 1e-8)
+ out[11:15] = out[11:15] / (np.linalg.norm(out[11:15]) + 1e-8)
+ return out
+
+
+def _env_flag(name: str, default: bool = False) -> bool:
+ raw = os.environ.get(name)
+ if raw is None:
+ return default
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _arm_for_block(block: Any) -> str:
+ return "left" if float(block.get_pose().p[0]) < 0 else "right"
+
+
+def _robotwin_blocks_episode_info(task_name: str, env: Any) -> dict[str, str] | None:
+ """Infer the episode-info dict used by RoboTwin's official instruction generator for block ranking."""
+ if task_name == "blocks_ranking_rgb":
+ return {
+ "{A}": "red block",
+ "{B}": "green block",
+ "{C}": "blue block",
+ "{a}": _arm_for_block(env.block1),
+ "{b}": _arm_for_block(env.block2),
+ "{c}": _arm_for_block(env.block3),
+ }
+ if task_name == "blocks_ranking_size":
+ return {
+ "{A}": "large block",
+ "{B}": "medium block",
+ "{C}": "small block",
+ "{a}": _arm_for_block(env.block1),
+ "{b}": _arm_for_block(env.block2),
+ "{c}": _arm_for_block(env.block3),
+ }
+ return None
+
+
+def _generate_robotwin_official_instruction(task_name: str, env: Any) -> str:
+ """Generate language with RoboTwin's official task templates, matching its eval client."""
+ fallback = task_name.replace("_", " ")
+ episode_info = _robotwin_blocks_episode_info(task_name, env)
+ if episode_info is None:
+ logger.warning(
+ "Official RoboTwin instruction is not implemented for task=%s; using %r.", task_name, fallback
+ )
+ return fallback
+
+ try:
+ # Part of the robotwin simulator repo, this is being pulled by the docker image running robotwin
+ # see https://github.com/RoboTwin-Platform/RoboTwin/tree/main/description
+ # Used to generate the official instructions
+ from description.utils.generate_episode_instructions import generate_episode_descriptions
+ except Exception:
+ logger.warning(
+ "Failed to import RoboTwin official instruction generator; using %r.", fallback, exc_info=True
+ )
+ return fallback
+
+ instruction_type = os.environ.get(OFFICIAL_INSTRUCTION_TYPE_ENV, "seen")
+ try:
+ max_descriptions = int(os.environ.get(OFFICIAL_INSTRUCTION_MAX_ENV, "1000000"))
+ except ValueError:
+ max_descriptions = 1000000
+
+ results = generate_episode_descriptions(task_name, [episode_info], max_descriptions=max_descriptions)
+ if not results:
+ logger.warning(
+ "RoboTwin generated no official instructions for task=%s; using %r.", task_name, fallback
+ )
+ return fallback
+
+ options = results[0].get(instruction_type) or results[0].get("seen") or results[0].get("unseen")
+ if not options:
+ logger.warning(
+ "RoboTwin generated no %s official instructions for task=%s; using %r.",
+ instruction_type,
+ task_name,
+ fallback,
+ )
+ return fallback
+
+ return str(np.random.choice(options))
+
+
# D435 dims from task_config/_camera_config.yml (what demo_clean.yml selects).
DEFAULT_CAMERA_H = 240
DEFAULT_CAMERA_W = 320
@@ -234,6 +357,7 @@ class RoboTwinEnv(gym.Env):
observation_width: int | None = None,
episode_length: int = DEFAULT_EPISODE_LENGTH,
render_mode: str = "rgb_array",
+ action_mode: str = "joint",
):
super().__init__()
self.task_name = task_name
@@ -241,6 +365,13 @@ class RoboTwinEnv(gym.Env):
self.task_description = task_name.replace("_", " ")
self.episode_index = episode_index
self._reset_stride = n_envs
+ # "joint": 14-d joint-space actions via take_action(action). "ee": 16-d end-effector-pose
+ # deltas (added onto the episode's initial eef pose) executed via take_action(.., "ee") + IK.
+ if action_mode not in ("joint", "ee"):
+ raise ValueError(f"action_mode must be 'joint' or 'ee'; got {action_mode!r}")
+ self.action_mode = action_mode
+ self._action_dim = EEF_ACTION_DIM if action_mode == "ee" else ACTION_DIM
+ self._init_eef_pose: np.ndarray | None = None
self.camera_names = list(camera_names)
# Default to D435 dims (the camera type baked into task_config/demo_clean.yml).
# The YAML-driven lookup is deferred to reset() so construction doesn't
@@ -271,7 +402,7 @@ class RoboTwinEnv(gym.Env):
}
)
self.action_space = spaces.Box(
- low=ACTION_LOW, high=ACTION_HIGH, shape=(ACTION_DIM,), dtype=np.float32
+ low=ACTION_LOW, high=ACTION_HIGH, shape=(self._action_dim,), dtype=np.float32
)
def _ensure_env(self) -> None:
@@ -317,6 +448,18 @@ class RoboTwinEnv(gym.Env):
return {"pixels": images, "agent_pos": joint_state}
+ def _read_eef_pose(self) -> np.ndarray:
+ """Read the current 16-d dual-arm eef pose [left(xyz+quat)+grip, right(xyz+quat)+grip]."""
+ assert self._env is not None, "_read_eef_pose called before _ensure_env()"
+ ep = self._env.get_obs()["endpose"]
+ pose = (
+ list(ep["left_endpose"])
+ + [ep["left_gripper"]]
+ + list(ep["right_endpose"])
+ + [ep["right_gripper"]]
+ )
+ return np.asarray(pose, dtype=np.float64)
+
def reset(self, seed: int | None = None, **kwargs) -> tuple[RobotObservation, dict]:
self._ensure_env()
super().reset(seed=seed)
@@ -330,16 +473,32 @@ class RoboTwinEnv(gym.Env):
self.episode_index += self._reset_stride
self._step_count = 0
+ use_official_instruction = self.task_name in {"blocks_ranking_rgb", "blocks_ranking_size"}
+ if _env_flag(OFFICIAL_INSTRUCTION_ENV, default=use_official_instruction):
+ self.task_description = _generate_robotwin_official_instruction(self.task_name, self._env)
+ if hasattr(self._env, "set_instruction"):
+ self._env.set_instruction(instruction=self.task_description)
+ logger.info("RoboTwin official instruction | task=%s | %s", self.task_name, self.task_description)
+ else:
+ self.task_description = self.task_name.replace("_", " ")
+
+ # In eef mode the policy predicts pose deltas relative to the initial eef pose.
+ if self.action_mode == "ee":
+ self._init_eef_pose = self._read_eef_pose()
+
obs = self._get_obs()
return obs, {"is_success": False, "task": self.task_name}
def step(self, action: np.ndarray) -> tuple[RobotObservation, float, bool, bool, dict[str, Any]]:
assert self._env is not None, "step() called before reset()"
- if action.ndim != 1 or action.shape[0] != ACTION_DIM:
- raise ValueError(f"Expected 1-D action of shape ({ACTION_DIM},), got {action.shape}")
+ if action.ndim != 1 or action.shape[0] != self._action_dim:
+ raise ValueError(f"Expected 1-D action of shape ({self._action_dim},), got {action.shape}")
with torch.enable_grad():
- if hasattr(self._env, "take_action"):
+ if self.action_mode == "ee":
+ ee_action = _add_init_eef_pose(np.asarray(action, dtype=np.float64), self._init_eef_pose)
+ self._env.take_action(ee_action, action_type="ee")
+ elif hasattr(self._env, "take_action"):
self._env.take_action(action)
else:
self._env.step(action)
@@ -398,6 +557,7 @@ def _make_env_fns(
observation_height: int,
observation_width: int,
episode_length: int,
+ action_mode: str = "joint",
) -> list[Callable[[], RoboTwinEnv]]:
"""Return n_envs factory callables for a single task."""
@@ -410,6 +570,7 @@ def _make_env_fns(
observation_height=observation_height,
observation_width=observation_width,
episode_length=episode_length,
+ action_mode=action_mode,
)
return [partial(_make_one, i) for i in range(n_envs)]
@@ -423,6 +584,7 @@ def create_robotwin_envs(
observation_height: int = DEFAULT_CAMERA_H,
observation_width: int = DEFAULT_CAMERA_W,
episode_length: int = DEFAULT_EPISODE_LENGTH,
+ action_mode: str = "joint",
) -> dict[str, dict[int, Any]]:
"""Create vectorized RoboTwin 2.0 environments.
@@ -473,6 +635,7 @@ def create_robotwin_envs(
observation_height=observation_height,
observation_width=observation_width,
episode_length=episode_length,
+ action_mode=action_mode,
)
if is_async:
lazy = _LazyAsyncVectorEnv(fns, cached_obs_space, cached_act_space, cached_metadata)
diff --git a/src/lerobot/optim/schedulers.py b/src/lerobot/optim/schedulers.py
index 250650089..2a80f74fb 100644
--- a/src/lerobot/optim/schedulers.py
+++ b/src/lerobot/optim/schedulers.py
@@ -83,6 +83,50 @@ class VQBeTSchedulerConfig(LRSchedulerConfig):
return LambdaLR(optimizer, lr_lambda, -1)
+@LRSchedulerConfig.register_subclass("constant_with_warmup")
+@dataclass
+class ConstantWithWarmupSchedulerConfig(LRSchedulerConfig):
+ """Linear warmup followed by a constant learning rate.
+
+ Mirrors the ``warmup_constant_lambda`` used by LingBot-VA (upstream ``wan_va/train.py``):
+ the LR ramps linearly from 0 to the peak over ``num_warmup_steps`` steps, then stays flat.
+ """
+
+ num_warmup_steps: int = 1000
+
+ def build(self, optimizer: Optimizer, num_training_steps: int) -> LambdaLR:
+ warmup_steps = self.num_warmup_steps or 0
+
+ def lr_lambda(current_step):
+ if current_step < warmup_steps:
+ return float(current_step) / float(max(1, warmup_steps))
+ return 1.0
+
+ return LambdaLR(optimizer, lr_lambda, -1)
+
+
+@LRSchedulerConfig.register_subclass("cosine_annealing_with_warmup")
+@dataclass
+class CosineAnnealingWithWarmupSchedulerConfig(LRSchedulerConfig):
+ """Linear warmup followed by cosine annealing from the peak LR to zero.
+
+ Used by EVO1; the annealing phase always spans the remaining training steps.
+ """
+
+ num_warmup_steps: int
+
+ def build(self, optimizer: Optimizer, num_training_steps: int) -> LambdaLR:
+ def lr_lambda(current_step: int) -> float:
+ if current_step < self.num_warmup_steps:
+ return current_step / max(1, self.num_warmup_steps)
+ progress = (current_step - self.num_warmup_steps) / max(
+ 1, num_training_steps - self.num_warmup_steps
+ )
+ return max(0.0, 0.5 * (1.0 + math.cos(math.pi * progress)))
+
+ return LambdaLR(optimizer, lr_lambda, -1)
+
+
@LRSchedulerConfig.register_subclass("cosine_decay_with_warmup")
@dataclass
class CosineDecayWithWarmupSchedulerConfig(LRSchedulerConfig):
diff --git a/src/lerobot/policies/__init__.py b/src/lerobot/policies/__init__.py
index 4daa6abc5..7f0bed2e0 100644
--- a/src/lerobot/policies/__init__.py
+++ b/src/lerobot/policies/__init__.py
@@ -17,10 +17,12 @@ from lerobot.utils.action_interpolator import ActionInterpolator as ActionInterp
from .act.configuration_act import ACTConfig as ACTConfig
from .diffusion.configuration_diffusion import DiffusionConfig as DiffusionConfig
from .eo1.configuration_eo1 import EO1Config as EO1Config
+from .evo1.configuration_evo1 import Evo1Config as Evo1Config
from .factory import get_policy_class, make_policy, make_policy_config, make_pre_post_processors
from .fastwam.configuration_fastwam import FastWAMConfig as FastWAMConfig
from .gaussian_actor.configuration_gaussian_actor import GaussianActorConfig as GaussianActorConfig
from .groot.configuration_groot import GrootConfig as GrootConfig
+from .lingbot_va.configuration_lingbot_va import LingBotVAConfig as LingBotVAConfig
from .molmoact2.configuration_molmoact2 import MolmoAct2Config as MolmoAct2Config
from .multi_task_dit.configuration_multi_task_dit import MultiTaskDiTConfig as MultiTaskDiTConfig
from .pi0.configuration_pi0 import PI0Config as PI0Config
@@ -45,7 +47,9 @@ __all__ = [
"EO1Config",
"FastWAMConfig",
"GaussianActorConfig",
+ "Evo1Config",
"GrootConfig",
+ "LingBotVAConfig",
"MolmoAct2Config",
"MultiTaskDiTConfig",
"PI0Config",
diff --git a/src/lerobot/policies/evo1/README.md b/src/lerobot/policies/evo1/README.md
new file mode 120000
index 000000000..6c4284fb9
--- /dev/null
+++ b/src/lerobot/policies/evo1/README.md
@@ -0,0 +1 @@
+../../../../docs/source/policy_evo1_README.md
\ No newline at end of file
diff --git a/src/lerobot/policies/evo1/__init__.py b/src/lerobot/policies/evo1/__init__.py
new file mode 100644
index 000000000..581b2b824
--- /dev/null
+++ b/src/lerobot/policies/evo1/__init__.py
@@ -0,0 +1,19 @@
+# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from .configuration_evo1 import Evo1Config
+from .modeling_evo1 import Evo1Policy
+from .processor_evo1 import make_evo1_pre_post_processors
+
+__all__ = ["Evo1Config", "Evo1Policy", "make_evo1_pre_post_processors"]
diff --git a/src/lerobot/policies/evo1/configuration_evo1.py b/src/lerobot/policies/evo1/configuration_evo1.py
new file mode 100644
index 000000000..534e84f75
--- /dev/null
+++ b/src/lerobot/policies/evo1/configuration_evo1.py
@@ -0,0 +1,252 @@
+# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+
+from lerobot.configs.policies import PreTrainedConfig
+from lerobot.configs.types import FeatureType, NormalizationMode, PolicyFeature
+from lerobot.optim.optimizers import AdamWConfig
+from lerobot.optim.schedulers import CosineAnnealingWithWarmupSchedulerConfig
+from lerobot.utils.constants import ACTION, OBS_IMAGES, OBS_STATE
+
+from ..rtc.configuration_rtc import RTCConfig
+
+logger = logging.getLogger(__name__)
+
+
+@PreTrainedConfig.register_subclass("evo1")
+@dataclass
+class Evo1Config(PreTrainedConfig):
+ training_stage: str = "stage1"
+ # When True and the policy runs on CUDA, EVO1 wraps its own forward passes (training and
+ # inference) in a bfloat16 autocast block, so its numerics do not depend on the dtype of any
+ # outer autocast context opened by lerobot-train/lerobot-eval.
+ use_amp: bool = True
+
+ n_obs_steps: int = 1
+ chunk_size: int = 50
+ n_action_steps: int = 50
+
+ max_state_dim: int = 24
+ max_action_dim: int = 24
+ max_views: int = 3
+ image_resolution: tuple[int, int] = (448, 448)
+ empty_cameras: int = 0
+ postprocess_action_dim: int | None = None
+ binarize_gripper: bool = False
+ gripper_index: int = 6
+ gripper_threshold: float = 0.5
+ gripper_below_threshold_value: float = 1.0
+ gripper_above_threshold_value: float = -1.0
+
+ normalization_mapping: dict[str, NormalizationMode] = field(
+ default_factory=lambda: {
+ "VISUAL": NormalizationMode.IDENTITY,
+ "STATE": NormalizationMode.MIN_MAX,
+ "ACTION": NormalizationMode.MIN_MAX,
+ }
+ )
+
+ vlm_model_name: str = "OpenGVLab/InternVL3-1B-hf"
+ vlm_num_layers: int | None = 14
+ vlm_dtype: str = "bfloat16"
+ # Max token length for tokenizing the (image placeholders + instruction) prompt. Prompts longer
+ # than this are right-truncated, so raise it for tasks with long language instructions or many views.
+ max_text_length: int = 1024
+ use_flash_attn: bool = True
+ action_head: str = "flowmatching"
+ embed_dim: int = 896
+ hidden_dim: int = 1024
+ state_hidden_dim: int = 1024
+ num_heads: int = 8
+ num_layers: int = 8
+ dropout: float = 0.0
+ num_inference_timesteps: int = 32
+ num_categories: int = 1
+ # When True, the action head is conditioned on a single pooled VL token (the last non-padding
+ # token of the causal decoder) instead of the full fused token sequence.
+ return_cls_only: bool = False
+ enable_gradient_checkpointing: bool = True
+ gradient_checkpointing_use_reentrant: bool = False
+
+ finetune_vlm: bool | None = None
+ finetune_language_model: bool | None = None
+ finetune_vision_model: bool | None = None
+ finetune_action_head: bool | None = None
+ # Reapply stage defaults after loading checkpoint configs so stage2 cannot
+ # accidentally inherit the frozen VLM flags stored by a stage1 checkpoint.
+ apply_training_stage_defaults: bool = True
+
+ task_field: str = "task"
+ embodiment_id_field: str | None = None
+ default_embodiment_id: int = 0
+
+ # Real-Time Chunking guidance for asynchronous inference (lerobot-rollout --inference.type=rtc
+ # sets this and calls init_rtc_processor()); None disables RTC.
+ rtc_config: RTCConfig | None = None
+
+ optimizer_lr: float = 1e-5
+ optimizer_betas: tuple[float, float] = (0.9, 0.999)
+ optimizer_eps: float = 1e-8
+ optimizer_weight_decay: float = 1e-5
+ optimizer_grad_clip_norm: float = 1.0
+
+ scheduler_warmup_steps: int = 300
+
+ def __post_init__(self):
+ super().__post_init__()
+ if self.training_stage not in {"stage1", "stage2"}:
+ raise ValueError(
+ f"Unsupported EVO1 training_stage '{self.training_stage}', expected 'stage1' or 'stage2'"
+ )
+
+ if self.apply_training_stage_defaults:
+ stage_defaults = {
+ "stage1": {
+ "finetune_vlm": False,
+ "finetune_language_model": False,
+ "finetune_vision_model": False,
+ "finetune_action_head": True,
+ },
+ "stage2": {
+ "finetune_vlm": True,
+ "finetune_language_model": True,
+ "finetune_vision_model": True,
+ "finetune_action_head": True,
+ },
+ }[self.training_stage]
+ for flag_name, default_value in stage_defaults.items():
+ current_value = getattr(self, flag_name)
+ if current_value is not None and current_value != default_value:
+ logger.warning(
+ "EVO1 %s=%s is overridden by training_stage=%s default %s. "
+ "Set apply_training_stage_defaults=false to keep explicit finetuning flags.",
+ flag_name,
+ current_value,
+ self.training_stage,
+ default_value,
+ )
+ setattr(self, flag_name, default_value)
+ elif self.training_stage == "stage1":
+ if self.finetune_vlm is None:
+ self.finetune_vlm = False
+ if self.finetune_language_model is None:
+ self.finetune_language_model = False
+ if self.finetune_vision_model is None:
+ self.finetune_vision_model = False
+ if self.finetune_action_head is None:
+ self.finetune_action_head = True
+ elif self.training_stage == "stage2":
+ has_explicit_branch_flags = any(
+ flag is not None for flag in (self.finetune_language_model, self.finetune_vision_model)
+ )
+ if not has_explicit_branch_flags:
+ # An explicit finetune_vlm decides both branches; otherwise stage2 defaults to a
+ # full-VLM finetune.
+ vlm_finetune = self.finetune_vlm if self.finetune_vlm is not None else True
+ self.finetune_vlm = vlm_finetune
+ self.finetune_language_model = vlm_finetune
+ self.finetune_vision_model = vlm_finetune
+ elif self.finetune_vlm is None:
+ self.finetune_vlm = bool(self.finetune_language_model or self.finetune_vision_model)
+ if self.finetune_action_head is None:
+ self.finetune_action_head = True
+
+ if self.finetune_vlm is None:
+ self.finetune_vlm = False
+ if self.finetune_language_model is None:
+ self.finetune_language_model = False
+ if self.finetune_vision_model is None:
+ self.finetune_vision_model = False
+ if self.finetune_action_head is None:
+ self.finetune_action_head = False
+
+ branch_vlm = self.finetune_language_model or self.finetune_vision_model
+ if self.finetune_vlm != branch_vlm:
+ raise ValueError(
+ "Inconsistent EVO1 finetune config: "
+ f"finetune_vlm={self.finetune_vlm} but "
+ f"(finetune_language_model or finetune_vision_model)={branch_vlm}. "
+ "When branch-level flags are used, finetune_vlm must match their effective union."
+ )
+
+ if self.n_action_steps > self.chunk_size:
+ raise ValueError(
+ f"n_action_steps ({self.n_action_steps}) must be <= chunk_size ({self.chunk_size})"
+ )
+ if len(self.image_resolution) != 2 or self.image_resolution[0] != self.image_resolution[1]:
+ raise ValueError(
+ "EVO1 currently expects a square image_resolution because InternVL3 preprocessing "
+ f"uses a scalar image_size, got {self.image_resolution}."
+ )
+ if not 0 <= self.default_embodiment_id < self.num_categories:
+ raise ValueError(
+ f"default_embodiment_id ({self.default_embodiment_id}) must be in "
+ f"[0, num_categories={self.num_categories})"
+ )
+
+ def validate_features(self) -> None:
+ if self.input_features is None:
+ self.input_features = {}
+ if self.output_features is None:
+ self.output_features = {}
+
+ for i in range(self.empty_cameras):
+ key = OBS_IMAGES + f".empty_camera_{i}"
+ if key not in self.input_features:
+ self.input_features[key] = PolicyFeature(
+ type=FeatureType.VISUAL,
+ shape=(3, *self.image_resolution),
+ )
+
+ if OBS_STATE not in self.input_features:
+ self.input_features[OBS_STATE] = PolicyFeature(
+ type=FeatureType.STATE,
+ shape=(self.max_state_dim,),
+ )
+
+ if ACTION not in self.output_features:
+ self.output_features[ACTION] = PolicyFeature(
+ type=FeatureType.ACTION,
+ shape=(self.max_action_dim,),
+ )
+
+ def get_optimizer_preset(self) -> AdamWConfig:
+ return AdamWConfig(
+ lr=self.optimizer_lr,
+ betas=self.optimizer_betas,
+ eps=self.optimizer_eps,
+ weight_decay=self.optimizer_weight_decay,
+ grad_clip_norm=self.optimizer_grad_clip_norm,
+ )
+
+ def get_scheduler_preset(self):
+ return CosineAnnealingWithWarmupSchedulerConfig(
+ num_warmup_steps=self.scheduler_warmup_steps,
+ )
+
+ @property
+ def observation_delta_indices(self) -> list[int]:
+ return [0]
+
+ @property
+ def action_delta_indices(self) -> list[int]:
+ return list(range(self.chunk_size))
+
+ @property
+ def reward_delta_indices(self) -> None:
+ return None
diff --git a/src/lerobot/policies/evo1/evo1_model.py b/src/lerobot/policies/evo1/evo1_model.py
new file mode 100644
index 000000000..129071fda
--- /dev/null
+++ b/src/lerobot/policies/evo1/evo1_model.py
@@ -0,0 +1,210 @@
+# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import torch
+import torch.nn as nn
+
+from .configuration_evo1 import Evo1Config
+from .flow_matching import FlowmatchingActionHead
+from .internvl3_embedder import InternVL3Embedder
+
+
+class Evo1Model(nn.Module):
+ def __init__(self, config: Evo1Config, vlm_hub_kwargs: dict | None = None):
+ super().__init__()
+ self.config = config
+ self._device = config.device
+ self.return_cls_only = config.return_cls_only
+ # Set by Evo1Policy.init_rtc_processor() when config.rtc_config is provided.
+ self.rtc_processor = None
+
+ # Gradient checkpointing only pays off when the VLM is actually being trained; keep it off
+ # whenever every VLM branch is frozen so the frozen forward stays cheap.
+ tracks_vlm_gradients = bool(
+ config.finetune_vlm or config.finetune_language_model or config.finetune_vision_model
+ )
+ enable_gradient_checkpointing = config.enable_gradient_checkpointing and tracks_vlm_gradients
+
+ self.embedder = InternVL3Embedder(
+ model_name=config.vlm_model_name,
+ image_size=int(config.image_resolution[0]),
+ device=self._device,
+ num_language_layers=config.vlm_num_layers,
+ model_dtype=config.vlm_dtype,
+ use_flash_attn=config.use_flash_attn,
+ max_text_length=config.max_text_length,
+ enable_gradient_checkpointing=enable_gradient_checkpointing,
+ gradient_checkpointing_use_reentrant=config.gradient_checkpointing_use_reentrant,
+ hub_kwargs=vlm_hub_kwargs,
+ )
+
+ action_head_type = config.action_head.lower()
+ if action_head_type != "flowmatching":
+ raise NotImplementedError(f"Unknown action_head: {action_head_type}")
+
+ horizon = config.chunk_size
+ per_action_dim = config.max_action_dim
+ action_dim = horizon * per_action_dim
+
+ self.horizon = horizon
+ self.per_action_dim = per_action_dim
+ self.action_head = FlowmatchingActionHead(
+ embed_dim=config.embed_dim,
+ hidden_dim=config.hidden_dim,
+ action_dim=action_dim,
+ horizon=horizon,
+ per_action_dim=per_action_dim,
+ num_heads=config.num_heads,
+ num_layers=config.num_layers,
+ dropout=config.dropout,
+ num_inference_timesteps=config.num_inference_timesteps,
+ num_categories=config.num_categories,
+ state_dim=config.max_state_dim,
+ state_hidden_dim=config.state_hidden_dim,
+ ).to(self._device)
+
+ def get_vl_embeddings(
+ self,
+ images: list[torch.Tensor],
+ image_mask: torch.Tensor,
+ prompt: str | list[str] | None = None,
+ return_cls_only: bool | None = None,
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
+ """Fused VL embeddings from per-camera image batches.
+
+ Args:
+ images: list of per-camera tensors, each shaped ``(B, C, H, W)`` with values in ``[0, 1]``.
+ image_mask: bool tensor ``(B, max_views)`` marking present views.
+
+ Returns:
+ ``(embeddings, valid_mask)``: the fused tokens and the bool mask of attendable context
+ positions (None when a single pooled token is returned).
+ """
+ if return_cls_only is None:
+ return_cls_only = self.return_cls_only
+ if not images:
+ raise ValueError("EVO1 expects at least one image per sample.")
+
+ batch_size = images[0].shape[0]
+ if prompt is None:
+ prompts = [""] * batch_size
+ elif isinstance(prompt, str):
+ prompts = [prompt] * batch_size
+ else:
+ prompts = [str(p) for p in prompt]
+ if len(prompts) != batch_size:
+ raise ValueError(
+ f"Prompt batch size {len(prompts)} does not match image batch size {batch_size}"
+ )
+
+ if image_mask.dim() == 1:
+ image_mask = image_mask.unsqueeze(0)
+ if image_mask.shape[0] != batch_size:
+ raise ValueError(
+ f"image_mask batch size {image_mask.shape[0]} does not match image batch size {batch_size}"
+ )
+
+ return self.embedder.get_fused_image_text_embedding_batched(
+ camera_images=images,
+ image_masks=image_mask,
+ text_prompts=prompts,
+ return_cls_only=return_cls_only,
+ )
+
+ def predict_action(
+ self,
+ fused_tokens: torch.Tensor,
+ state: torch.Tensor,
+ actions_gt: torch.Tensor | None = None,
+ action_mask: torch.Tensor | None = None,
+ embodiment_ids: torch.Tensor | None = None,
+ context_mask: torch.Tensor | None = None,
+ inference_delay: int | None = None,
+ prev_chunk_left_over: torch.Tensor | None = None,
+ execution_horizon: int | None = None,
+ ):
+ if actions_gt is None:
+ return self.action_head.get_action(
+ fused_tokens,
+ state=state,
+ action_mask=action_mask,
+ embodiment_id=embodiment_ids,
+ context_mask=context_mask,
+ inference_delay=inference_delay,
+ prev_chunk_left_over=prev_chunk_left_over,
+ execution_horizon=execution_horizon,
+ rtc_processor=self.rtc_processor,
+ )
+ return self.action_head(
+ fused_tokens,
+ state=state,
+ actions_gt=actions_gt,
+ action_mask=action_mask,
+ embodiment_id=embodiment_ids,
+ context_mask=context_mask,
+ )
+
+ def forward(
+ self,
+ fused_tokens: torch.Tensor,
+ state: torch.Tensor | None = None,
+ actions_gt: torch.Tensor | None = None,
+ action_mask: torch.Tensor | None = None,
+ embodiment_ids: torch.Tensor | None = None,
+ context_mask: torch.Tensor | None = None,
+ inference_delay: int | None = None,
+ prev_chunk_left_over: torch.Tensor | None = None,
+ execution_horizon: int | None = None,
+ ):
+ return self.predict_action(
+ fused_tokens,
+ state,
+ actions_gt,
+ action_mask,
+ embodiment_ids,
+ context_mask,
+ inference_delay,
+ prev_chunk_left_over,
+ execution_horizon,
+ )
+
+ def _set_module_trainable(self, module: nn.Module, trainable: bool):
+ for param in module.parameters():
+ param.requires_grad = trainable
+
+ def _vlm_submodule(self, name: str) -> nn.Module:
+ module = getattr(self.embedder.model, name, None)
+ if not isinstance(module, nn.Module):
+ raise AttributeError(
+ f"InternVL model {type(self.embedder.model).__name__} has no '{name}' submodule; "
+ "the native HF InternVL layout (language_model / vision_tower / "
+ "multi_modal_projector) is required to apply the EVO1 finetune flags."
+ )
+ return module
+
+ def set_finetune_flags(self):
+ # __post_init__ resolves every finetune flag to a concrete boolean, so branch-level flags
+ # are authoritative here. Freeze everything first, then re-enable the requested branches.
+ self._set_module_trainable(self.embedder, False)
+ self._set_module_trainable(
+ self._vlm_submodule("language_model"), bool(self.config.finetune_language_model)
+ )
+ finetune_vision = bool(self.config.finetune_vision_model)
+ self._set_module_trainable(self._vlm_submodule("vision_tower"), finetune_vision)
+ self._set_module_trainable(self._vlm_submodule("multi_modal_projector"), finetune_vision)
+
+ if not self.config.finetune_action_head:
+ self._set_module_trainable(self.action_head, False)
diff --git a/src/lerobot/policies/evo1/flow_matching.py b/src/lerobot/policies/evo1/flow_matching.py
new file mode 100644
index 000000000..207d47039
--- /dev/null
+++ b/src/lerobot/policies/evo1/flow_matching.py
@@ -0,0 +1,483 @@
+# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import logging
+import math
+
+import torch
+import torch.nn as nn
+
+logger = logging.getLogger(__name__)
+
+
+class SinusoidalPositionalEncoding(nn.Module):
+ def __init__(self, dim: int, max_len: int = 1000):
+ super().__init__()
+ pe = torch.zeros(max_len, dim)
+ position = torch.arange(0, max_len).unsqueeze(1)
+ div_term = torch.exp(torch.arange(0, dim, 2) * -(math.log(10000.0) / dim))
+ pe[:, 0::2] = torch.sin(position * div_term)
+ pe[:, 1::2] = torch.cos(position * div_term)
+ pe = pe.unsqueeze(0)
+ self.register_buffer("pe", pe)
+
+ def forward(self, seq_len: int):
+ if seq_len > self.pe.size(1):
+ self._extend_pe(seq_len)
+ return self.pe[:, :seq_len, :]
+
+ def _extend_pe(self, new_max_len):
+ old_max_len, dim = self.pe.size(1), self.pe.size(2)
+ if new_max_len <= old_max_len:
+ return
+ extra_positions = torch.arange(old_max_len, new_max_len, dtype=torch.float).unsqueeze(1)
+ div_term = torch.exp(torch.arange(0, dim, 2, dtype=torch.float) * -(math.log(10000.0) / dim))
+ extra_pe = torch.zeros(new_max_len - old_max_len, dim)
+ extra_pe[:, 0::2] = torch.sin(extra_positions * div_term)
+ extra_pe[:, 1::2] = torch.cos(extra_positions * div_term)
+ extra_pe = extra_pe.unsqueeze(0)
+ new_pe = torch.cat([self.pe, extra_pe.to(self.pe.device)], dim=1)
+ self.pe = new_pe
+
+
+class CategorySpecificLinear(nn.Module):
+ def __init__(self, in_dim: int, out_dim: int, num_categories: int = 1):
+ super().__init__()
+ self.num_categories = num_categories
+ if num_categories <= 1:
+ self.linear = nn.Linear(in_dim, out_dim)
+ else:
+ self.weight = nn.Parameter(torch.empty(num_categories, in_dim, out_dim))
+ self.bias = nn.Parameter(torch.zeros(num_categories, out_dim))
+ # Initialize each per-category (in_dim, out_dim) matrix separately: xavier on the full
+ # 3D tensor would compute fan_in = in_dim * out_dim and badly under-scale the weights.
+ for category in range(num_categories):
+ nn.init.xavier_uniform_(self.weight[category])
+
+ def forward(self, x: torch.Tensor, category_id: torch.LongTensor):
+ if self.num_categories <= 1:
+ if x.dtype != self.linear.weight.dtype:
+ x = x.to(dtype=self.linear.weight.dtype)
+ return self.linear(x)
+
+ if x.dtype != self.weight.dtype:
+ x = x.to(dtype=self.weight.dtype)
+
+ orig_shape = x.shape
+ x_flat = x.reshape(-1, orig_shape[-1])
+ if category_id.dim() == 0:
+ cid = category_id.item()
+ out = x_flat @ self.weight[cid] + self.bias[cid]
+ else:
+ category_id = category_id.reshape(-1)
+ if category_id.numel() != x_flat.size(0):
+ raise ValueError(
+ f"category_id length {category_id.numel()} does not match flattened batch {x_flat.size(0)}"
+ )
+ weight_selected = self.weight[category_id]
+ bias_selected = self.bias[category_id]
+ out = torch.bmm(x_flat.unsqueeze(1), weight_selected).squeeze(1) + bias_selected
+ out_shape = orig_shape[:-1] + (out.shape[-1],)
+ return out.view(out_shape)
+
+
+class CategorySpecificMLP(nn.Module):
+ def __init__(self, input_dim: int, hidden_dim: int, output_dim: int, num_categories: int = 1):
+ super().__init__()
+ self.fc1 = CategorySpecificLinear(input_dim, hidden_dim, num_categories)
+ self.fc2 = CategorySpecificLinear(hidden_dim, output_dim, num_categories)
+ self.activation = nn.ReLU(inplace=True)
+
+ def forward(self, x: torch.Tensor, category_id: torch.LongTensor):
+ out = self.activation(self.fc1(x, category_id))
+ out = self.fc2(out, category_id)
+ return out
+
+
+class MultiEmbodimentActionEncoder(nn.Module):
+ def __init__(
+ self, action_dim: int, embed_dim: int, hidden_dim: int, horizon: int, num_categories: int = 1
+ ):
+ super().__init__()
+ self.horizon = horizon
+ self.embed_dim = embed_dim
+ self.num_categories = num_categories
+
+ self.W1 = CategorySpecificLinear(action_dim, hidden_dim, num_categories)
+ self.W2 = CategorySpecificLinear(hidden_dim, hidden_dim, num_categories)
+ self.W3 = CategorySpecificLinear(hidden_dim, embed_dim, num_categories)
+
+ self.pos_encoding = SinusoidalPositionalEncoding(hidden_dim, max_len=horizon)
+ self.activation = nn.ReLU(inplace=True)
+
+ def forward(self, action_seq: torch.Tensor, category_id: torch.LongTensor):
+ batch_size, horizon, action_dim = action_seq.shape
+ if self.horizon != horizon:
+ raise ValueError(
+ f"Action sequence length must match horizon: got {horizon}, expected {self.horizon}."
+ )
+
+ x = action_seq.reshape(batch_size * horizon, action_dim)
+ if category_id.dim() == 0:
+ cat_ids = category_id.expand(horizon * batch_size)
+ else:
+ cat_ids = category_id.unsqueeze(1).expand(batch_size, horizon).reshape(batch_size * horizon)
+
+ out = self.activation(self.W1(x, cat_ids))
+ pos_enc = self.pos_encoding(horizon).to(device=out.device, dtype=out.dtype)
+ out = out.view(batch_size, horizon, -1) + pos_enc
+ out = out.view(batch_size * horizon, -1)
+ out = self.activation(self.W2(out, cat_ids))
+ out = self.W3(out, cat_ids)
+ return out.view(batch_size, horizon, self.embed_dim)
+
+
+class BasicTransformerBlock(nn.Module):
+ def __init__(self, embed_dim: int, num_heads: int, hidden_dim: int, dropout: float = 0.0):
+ super().__init__()
+ self.attn = nn.MultiheadAttention(embed_dim, num_heads, dropout=dropout, batch_first=True)
+ self.norm1 = nn.LayerNorm(embed_dim)
+ self.norm2 = nn.LayerNorm(embed_dim)
+ self.ff = nn.Sequential(nn.Linear(embed_dim, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, embed_dim))
+
+ def forward(
+ self,
+ action_tokens: torch.Tensor,
+ context_tokens: torch.Tensor,
+ time_emb: torch.Tensor,
+ context_key_padding_mask: torch.Tensor | None = None,
+ ):
+ x = self.norm1(action_tokens)
+ attn_out, _ = self.attn(x, context_tokens, context_tokens, key_padding_mask=context_key_padding_mask)
+ x = action_tokens + attn_out
+ x2 = self.norm2(x)
+ if time_emb is not None:
+ x2 = x2 + time_emb.unsqueeze(1)
+ ff_out = self.ff(x2)
+ return x + ff_out
+
+
+class FlowmatchingActionHead(nn.Module):
+ def __init__(
+ self,
+ embed_dim: int = 896,
+ hidden_dim: int = 1024,
+ action_dim: int = 16 * 7,
+ horizon: int = 16,
+ per_action_dim: int = 7,
+ num_heads: int = 8,
+ num_layers: int = 8,
+ dropout: float = 0.0,
+ num_inference_timesteps: int = 20,
+ num_categories: int = 1,
+ state_dim: int | None = None,
+ state_hidden_dim: int | None = None,
+ ):
+ super().__init__()
+
+ logger.info("FlowmatchingActionHead num_inference_timesteps=%s", num_inference_timesteps)
+ self.embed_dim = embed_dim
+ self.horizon = horizon
+ self.per_action_dim = per_action_dim
+ self.action_dim = action_dim
+ self.num_inference_timesteps = num_inference_timesteps
+ self.num_categories = num_categories
+
+ self.time_pos_enc = SinusoidalPositionalEncoding(embed_dim, max_len=1000)
+ self.transformer_blocks = nn.ModuleList(
+ [
+ BasicTransformerBlock(
+ embed_dim=embed_dim,
+ num_heads=num_heads,
+ hidden_dim=embed_dim * 4,
+ dropout=dropout,
+ )
+ for _ in range(num_layers)
+ ]
+ )
+ self.norm_out = nn.LayerNorm(embed_dim)
+ self.seq_pool_proj = nn.Linear(self.horizon * self.embed_dim, self.embed_dim)
+ self.mlp_head = CategorySpecificMLP(
+ input_dim=embed_dim,
+ hidden_dim=hidden_dim,
+ output_dim=action_dim,
+ num_categories=num_categories,
+ )
+
+ self.state_encoder = None
+ if state_dim is not None:
+ state_hidden = state_hidden_dim if state_hidden_dim is not None else embed_dim
+ self.state_encoder = CategorySpecificMLP(
+ input_dim=state_dim,
+ hidden_dim=state_hidden,
+ output_dim=embed_dim,
+ num_categories=num_categories,
+ )
+
+ if horizon > 1:
+ self.action_encoder = MultiEmbodimentActionEncoder(
+ action_dim=self.per_action_dim,
+ embed_dim=embed_dim,
+ hidden_dim=embed_dim,
+ horizon=horizon,
+ num_categories=num_categories,
+ )
+ self.single_action_proj = None
+ else:
+ self.action_encoder = None
+ self.single_action_proj = nn.Linear(self.per_action_dim, self.embed_dim)
+
+ def _project_actions(self, action_seq: torch.Tensor, embodiment_id: torch.LongTensor) -> torch.Tensor:
+ if self.horizon > 1 and self.action_encoder is not None:
+ return self.action_encoder(action_seq, embodiment_id)
+ if self.single_action_proj is None:
+ raise RuntimeError("single_action_proj is not initialized for horizon <= 1.")
+ return self.single_action_proj(action_seq)
+
+ def _expand_action_mask(
+ self,
+ action_mask: torch.Tensor,
+ batch_size: int,
+ per_action_dim: int,
+ device: torch.device,
+ dtype: torch.dtype,
+ ) -> torch.Tensor:
+ if action_mask is None:
+ raise ValueError("action_mask must be provided for flow matching inference.")
+
+ if action_mask.dim() == 2:
+ expected_last_dim = self.horizon * per_action_dim
+ if action_mask.shape == (batch_size, expected_last_dim):
+ expanded_mask = action_mask.reshape(batch_size, self.horizon, per_action_dim)
+ elif action_mask.shape == (batch_size, per_action_dim):
+ expanded_mask = action_mask.unsqueeze(1).expand(batch_size, self.horizon, per_action_dim)
+ else:
+ raise ValueError(
+ f"Expected action_mask shape {(batch_size, expected_last_dim)} or "
+ f"{(batch_size, per_action_dim)}, got {tuple(action_mask.shape)}"
+ )
+ elif action_mask.dim() == 3:
+ expected_shape = (batch_size, self.horizon, per_action_dim)
+ if tuple(action_mask.shape) != expected_shape:
+ raise ValueError(
+ f"Expected action_mask shape {expected_shape}, got {tuple(action_mask.shape)}"
+ )
+ expanded_mask = action_mask
+ else:
+ raise ValueError(f"Unsupported action_mask rank: {action_mask.dim()}")
+
+ return expanded_mask.to(device=device, dtype=dtype)
+
+ def _prepare_context(
+ self,
+ fused_tokens: torch.Tensor,
+ state: torch.Tensor | None,
+ embodiment_id: torch.LongTensor | None,
+ context_mask: torch.Tensor | None,
+ ) -> tuple[torch.Tensor, torch.Tensor | None, torch.LongTensor]:
+ """Normalize the VL context and embodiment ids shared by training and inference.
+
+ Returns the context tokens ``(B, S, E)``, a key_padding_mask for
+ ``nn.MultiheadAttention`` (True = ignore) or None, and the resolved embodiment ids.
+ """
+ batch_size = fused_tokens.size(0)
+ device = fused_tokens.device
+ if embodiment_id is None:
+ embodiment_id = torch.zeros(batch_size, dtype=torch.long, device=device)
+ elif self.num_categories > 1 and (
+ int(embodiment_id.min()) < 0 or int(embodiment_id.max()) >= self.num_categories
+ ):
+ raise ValueError(
+ f"embodiment ids must be in [0, num_categories={self.num_categories}), "
+ f"got range [{int(embodiment_id.min())}, {int(embodiment_id.max())}]"
+ )
+
+ context_tokens = fused_tokens
+ if context_tokens.dim() == 2:
+ # A single pooled VL token (return_cls_only): give it a sequence dim of 1.
+ context_tokens = context_tokens.unsqueeze(1)
+ context_mask = None
+ if state is not None and self.state_encoder is not None:
+ state_emb = self.state_encoder(state, embodiment_id).unsqueeze(1)
+ context_tokens = torch.cat([context_tokens, state_emb], dim=1)
+ if context_mask is not None:
+ state_valid = torch.ones(batch_size, 1, dtype=torch.bool, device=context_mask.device)
+ context_mask = torch.cat([context_mask.to(torch.bool), state_valid], dim=1)
+
+ key_padding_mask = None if context_mask is None else ~context_mask.to(torch.bool)
+ return context_tokens, key_padding_mask, embodiment_id
+
+ def forward(
+ self,
+ fused_tokens: torch.Tensor,
+ state: torch.Tensor = None,
+ actions_gt: torch.Tensor = None,
+ embodiment_id: torch.LongTensor = None,
+ action_mask: torch.Tensor = None,
+ context_mask: torch.Tensor = None,
+ ):
+ if actions_gt is None:
+ return self.get_action(
+ fused_tokens,
+ state=state,
+ embodiment_id=embodiment_id,
+ action_mask=action_mask,
+ context_mask=context_mask,
+ )
+
+ batch_size = fused_tokens.size(0)
+ device = fused_tokens.device
+ context_tokens, key_padding_mask, embodiment_id = self._prepare_context(
+ fused_tokens, state, embodiment_id, context_mask
+ )
+
+ t = (
+ torch.distributions.Beta(2, 2)
+ .sample((batch_size,))
+ .clamp(0.02, 0.98)
+ .to(device)
+ .to(dtype=self.dtype)
+ )
+ time_index = (t * 999).long().clamp_(0, 999)
+ time_emb = self.time_pos_enc(1000)[:, time_index, :].squeeze(0).to(dtype=context_tokens.dtype)
+
+ actions_gt_seq = actions_gt
+ noise = torch.rand_like(actions_gt) * 2 - 1
+ if action_mask is not None:
+ action_mask = action_mask.to(dtype=noise.dtype, device=noise.device)
+ if action_mask.shape != noise.shape:
+ raise ValueError(f"action_mask shape {action_mask.shape} != noise shape {noise.shape}")
+ actions_gt_seq = actions_gt_seq * action_mask
+ noise = noise * action_mask
+
+ if self.horizon > 1:
+ noise_seq = noise.view(batch_size, self.horizon, self.per_action_dim)
+ else:
+ noise_seq = noise if noise.dim() == 3 else noise.unsqueeze(1)
+ t_broadcast = t.view(batch_size, 1, 1)
+ action_intermediate_seq = (1 - t_broadcast) * noise_seq + t_broadcast * actions_gt_seq
+
+ action_tokens = self._project_actions(action_intermediate_seq, embodiment_id)
+ target_dtype = self.dtype
+ action_tokens = action_tokens.to(dtype=target_dtype)
+ context_tokens = context_tokens.to(dtype=target_dtype)
+ time_emb = time_emb.to(dtype=target_dtype)
+
+ x = action_tokens
+ for block in self.transformer_blocks:
+ x = block(x, context_tokens, time_emb, key_padding_mask)
+ x = self.norm_out(x)
+
+ if self.horizon > 1:
+ x_flat = x.reshape(batch_size, -1)
+ x_pooled = self.seq_pool_proj(x_flat)
+ else:
+ x_pooled = x.squeeze(1)
+
+ pred_velocity = self.mlp_head(x_pooled, embodiment_id)
+ return pred_velocity, noise
+
+ def get_action(
+ self,
+ fused_tokens: torch.Tensor,
+ state: torch.Tensor = None,
+ embodiment_id: torch.LongTensor = None,
+ action_mask: torch.Tensor = None,
+ context_mask: torch.Tensor = None,
+ inference_delay: int | None = None,
+ prev_chunk_left_over: torch.Tensor | None = None,
+ execution_horizon: int | None = None,
+ rtc_processor=None,
+ ):
+ batch_size = fused_tokens.size(0)
+ device = fused_tokens.device
+ context_tokens, key_padding_mask, embodiment_id = self._prepare_context(
+ fused_tokens, state, embodiment_id, context_mask
+ )
+
+ action_dim_total = self.action_dim
+ per_action_dim = self.per_action_dim
+
+ action = torch.rand(batch_size, action_dim_total, device=device, dtype=context_tokens.dtype) * 2 - 1
+ action_seq = action.view(batch_size, self.horizon, per_action_dim)
+ action_mask = self._expand_action_mask(
+ action_mask,
+ batch_size=batch_size,
+ per_action_dim=per_action_dim,
+ device=action_seq.device,
+ dtype=action_seq.dtype,
+ )
+ action_seq = action_seq * action_mask
+
+ target_dtype = self.dtype
+ context_tokens = context_tokens.to(dtype=target_dtype)
+
+ num_steps = int(self.num_inference_timesteps)
+ if num_steps <= 0:
+ raise ValueError(f"num_inference_timesteps must be positive, got {num_steps}")
+ dt = 1.0 / num_steps
+
+ use_rtc = rtc_processor is not None and (
+ inference_delay is not None or prev_chunk_left_over is not None
+ )
+
+ def predict_velocity(seq: torch.Tensor, step_time_emb: torch.Tensor) -> torch.Tensor:
+ """Predict the masked flow velocity (x1 - x0 convention) for one integration step."""
+ seq = seq * action_mask
+ action_tokens = self._project_actions(seq, embodiment_id).to(dtype=target_dtype)
+ x = action_tokens
+ for block in self.transformer_blocks:
+ x = block(x, context_tokens, step_time_emb, key_padding_mask)
+ x = self.norm_out(x)
+ x_pooled = self.seq_pool_proj(x.reshape(batch_size, -1)) if self.horizon > 1 else x.squeeze(1)
+ pred = self.mlp_head(x_pooled, embodiment_id)
+ return pred.view(batch_size, self.horizon, per_action_dim) * action_mask
+
+ for i in range(num_steps):
+ t = i / num_steps
+ time_index = min(int(t * 999), 999)
+ time_emb = self.time_pos_enc(1000)[:, time_index, :].to(device).squeeze(0).to(dtype=target_dtype)
+ time_emb = time_emb.unsqueeze(0).repeat(batch_size, 1)
+
+ if use_rtc:
+ # RTCProcessor assumes the pi0 flow convention: its `time` runs 1 -> 0 and the
+ # clean-action estimate is x1 = x_t - time * v. EVO1 integrates t: 0 -> 1 with
+ # velocity v = x1 - x0 (so x1 = x_t + (1 - t) * v); passing time = 1 - t and
+ # flipping the velocity sign in both directions maps one convention onto the other.
+ guided = rtc_processor.denoise_step(
+ x_t=action_seq,
+ prev_chunk_left_over=prev_chunk_left_over,
+ inference_delay=inference_delay,
+ time=1.0 - t,
+ original_denoise_step_partial=lambda seq, emb=time_emb: -predict_velocity(seq, emb),
+ execution_horizon=execution_horizon,
+ )
+ velocity = -guided
+ else:
+ velocity = predict_velocity(action_seq, time_emb)
+
+ action_seq = action_seq + dt * velocity
+
+ action_seq = action_seq * action_mask
+ return action_seq.reshape(batch_size, -1)
+
+ @property
+ def device(self):
+ return next(self.parameters()).device
+
+ @property
+ def dtype(self):
+ return next(self.parameters()).dtype
diff --git a/src/lerobot/policies/evo1/internvl3_embedder.py b/src/lerobot/policies/evo1/internvl3_embedder.py
new file mode 100644
index 000000000..d47105d96
--- /dev/null
+++ b/src/lerobot/policies/evo1/internvl3_embedder.py
@@ -0,0 +1,369 @@
+# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Sequence
+from typing import TYPE_CHECKING
+
+import torch
+import torch.nn as nn
+import torchvision.transforms.functional as tvf
+from torchvision.transforms.functional import InterpolationMode
+
+from lerobot.utils.import_utils import _transformers_available, require_package
+
+if TYPE_CHECKING or _transformers_available:
+ from transformers import AutoModel, AutoTokenizer
+else:
+ AutoModel = None
+ AutoTokenizer = None
+
+IMAGENET_MEAN = (0.485, 0.456, 0.406)
+IMAGENET_STD = (0.229, 0.224, 0.225)
+IMG_CONTEXT_TOKEN = "" # nosec B105
+IMG_START_TOKEN = "
" # nosec B105
+IMG_END_TOKEN = "" # nosec B105
+
+logger = logging.getLogger(__name__)
+
+
+def _batched_resize_01(images: torch.Tensor, image_size: int) -> torch.Tensor:
+ """Resize a batch of ``[0, 1]`` images to ``(image_size, image_size)`` on-device.
+
+ Numerically mirrors InternVL3's reference PIL preprocessing
+ (``to_pil_image`` -> ``Image.resize`` -> ``to_tensor``): the float input is quantized to uint8
+ exactly as ``to_pil_image`` does, then resized with bicubic interpolation and antialiasing,
+ which matches PIL's default resampler. Matching the reference pixel-for-pixel keeps the policy
+ interchangeable with checkpoints produced by the upstream EVO1 preprocessing.
+
+ Args:
+ images: float tensor of shape ``(N, C, H, W)`` with values in ``[0, 1]``.
+
+ Returns:
+ float32 tensor of shape ``(N, C, image_size, image_size)`` with values in ``[0, 1]``.
+ """
+ # to_pil_image() quantizes float [0, 1] to uint8 (x * 255, truncated); replicate that so the
+ # bicubic resample sees the same integer pixels PIL would.
+ pixels_u8 = (images * 255.0).clamp(0, 255).to(torch.uint8)
+ resized = tvf.resize(
+ pixels_u8, [image_size, image_size], interpolation=InterpolationMode.BICUBIC, antialias=True
+ )
+ return resized.to(torch.float32) / 255.0
+
+
+def _batched_pixel_values(
+ camera_images: Sequence[torch.Tensor],
+ max_views: int,
+ image_size: int,
+ mean: torch.Tensor,
+ std: torch.Tensor,
+ dtype: torch.dtype,
+ device: torch.device | str,
+) -> torch.Tensor:
+ """Build InternVL3 ``pixel_values`` from per-camera ``[0, 1]`` image batches without leaving the device.
+
+ Each image is resized, converted to ``dtype``, and ImageNet-normalized (a single tile per
+ image), batched across the whole minibatch. Absent views (fewer cameras than ``max_views``)
+ are filled with zero images; their placeholder tokens are masked out of attention downstream
+ via ``_mask_absent_image_tokens``.
+
+ Returns:
+ ``pixel_values`` of shape ``(B * max_views, C, image_size, image_size)``, ordered row-major
+ over ``(sample, view)`` to line up with the per-view image placeholders in the prompt.
+ """
+ resized: list[torch.Tensor] = []
+ for image in camera_images:
+ resized.append(_batched_resize_01(image.to(device=device), image_size).to(dtype))
+
+ batch_size = resized[0].shape[0]
+ channels = resized[0].shape[1]
+ while len(resized) < max_views:
+ resized.append(torch.zeros(batch_size, channels, image_size, image_size, dtype=dtype, device=device))
+
+ stacked = torch.stack(resized[:max_views], dim=1) # (B, V, C, H, W)
+ mean = mean.to(device=device, dtype=dtype).view(1, 1, -1, 1, 1)
+ std = std.to(device=device, dtype=dtype).view(1, 1, -1, 1, 1)
+ normalized = (stacked - mean) / std
+ return normalized.reshape(batch_size * max_views, channels, image_size, image_size)
+
+
+class InternVL3Embedder(nn.Module):
+ """Vision-language embedder using the native HF InternVL3 model (no trust_remote_code)."""
+
+ def __init__(
+ self,
+ model_name="OpenGVLab/InternVL3-1B-hf",
+ image_size=448,
+ device="cuda",
+ num_language_layers: int | None = 14,
+ model_dtype: str | torch.dtype = "bfloat16",
+ use_flash_attn: bool = True,
+ max_text_length: int = 1024,
+ enable_gradient_checkpointing: bool = True,
+ gradient_checkpointing_use_reentrant: bool = False,
+ hub_kwargs: dict | None = None,
+ ):
+ super().__init__()
+ self._requested_device = device
+ self.image_size = image_size
+ self.num_language_layers = num_language_layers
+ self.max_text_length = max_text_length
+ self.enable_gradient_checkpointing = bool(enable_gradient_checkpointing)
+ self.gradient_checkpointing_use_reentrant = bool(gradient_checkpointing_use_reentrant)
+ hub_kwargs = hub_kwargs or {}
+
+ require_package("transformers", extra="evo1")
+
+ self.tokenizer = AutoTokenizer.from_pretrained(model_name, **hub_kwargs)
+ if isinstance(model_dtype, str):
+ try:
+ model_dtype = getattr(torch, model_dtype)
+ except AttributeError as exc:
+ raise ValueError(f"Unsupported EVO1 vlm_dtype '{model_dtype}'") from exc
+ self.model_dtype = model_dtype
+
+ attn_implementation = "flash_attention_2" if (use_flash_attn and _flash_attn_available()) else "eager"
+ if use_flash_attn and attn_implementation == "eager":
+ logger.warning("flash_attn is not installed. Falling back to eager attention.")
+
+ self.model = AutoModel.from_pretrained(
+ model_name,
+ torch_dtype=model_dtype,
+ attn_implementation=attn_implementation,
+ low_cpu_mem_usage=True,
+ **hub_kwargs,
+ ).to(self._requested_device)
+
+ checkpoint_image_size = getattr(self.model.config.vision_config, "image_size", None)
+ if isinstance(checkpoint_image_size, (list, tuple)):
+ checkpoint_image_size = checkpoint_image_size[0]
+ if checkpoint_image_size is not None and int(checkpoint_image_size) != int(image_size):
+ raise ValueError(
+ f"EVO1 image_resolution ({image_size}) must match the InternVL checkpoint's native "
+ f"image size ({checkpoint_image_size}): the checkpoint's image_seq_length assumes "
+ "its native resolution, so other sizes would desync the image placeholder tokens "
+ "from the vision features."
+ )
+
+ self.num_image_token = self.model.config.image_seq_length
+
+ # Truncate language model to the requested number of layers
+ layers = self.model.language_model.layers
+ if self.num_language_layers is not None:
+ layers = layers[: self.num_language_layers]
+ self.model.language_model.layers = torch.nn.ModuleList(layers)
+
+ self._configure_memory_features()
+ self.img_context_token_id = self.tokenizer.convert_tokens_to_ids(IMG_CONTEXT_TOKEN)
+
+ def _configure_memory_features(self) -> None:
+ checkpoint_kwargs = {"use_reentrant": self.gradient_checkpointing_use_reentrant}
+
+ if not self.enable_gradient_checkpointing:
+ language_model = self.model.language_model
+ if hasattr(language_model, "gradient_checkpointing_disable"):
+ language_model.gradient_checkpointing_disable()
+ vision_tower = getattr(self.model, "vision_tower", None)
+ if vision_tower is not None and hasattr(vision_tower, "encoder"):
+ vision_tower.encoder.gradient_checkpointing = False
+ return
+
+ def _enable_ckpt(module: nn.Module | None) -> bool:
+ if module is None:
+ return False
+ if hasattr(module, "gradient_checkpointing_enable"):
+ try:
+ module.gradient_checkpointing_enable(gradient_checkpointing_kwargs=checkpoint_kwargs)
+ except TypeError:
+ module.gradient_checkpointing_enable()
+ return True
+ if hasattr(module, "gradient_checkpointing"):
+ module.gradient_checkpointing = True
+ return True
+ return False
+
+ enabled_any = _enable_ckpt(self.model)
+
+ vision_tower = getattr(self.model, "vision_tower", None)
+ if vision_tower is not None:
+ enabled_any = _enable_ckpt(vision_tower) or enabled_any
+
+ language_model = self.model.language_model
+ enabled_any = _enable_ckpt(language_model) or enabled_any
+ if hasattr(language_model, "config"):
+ language_model.config.use_cache = False
+
+ if hasattr(self.model, "config"):
+ self.model.config.use_cache = False
+ if hasattr(self.model, "enable_input_require_grads"):
+ self.model.enable_input_require_grads()
+
+ if enabled_any:
+ logger.info("Gradient checkpointing enabled for InternVL3 embedder.")
+ else:
+ logger.warning(
+ "Requested gradient checkpointing, but model does not expose checkpointing controls."
+ )
+
+ def _build_multimodal_prompts(
+ self,
+ batch_num_tiles_list: list[list[int]],
+ text_prompts: Sequence[str],
+ ) -> list[str]:
+ prompts = []
+ for num_tiles_list, text_prompt in zip(batch_num_tiles_list, text_prompts, strict=True):
+ prompt_segments = []
+ for i, tile_count in enumerate(num_tiles_list):
+ token_count = self.num_image_token * tile_count
+ image_tokens = IMG_START_TOKEN + IMG_CONTEXT_TOKEN * token_count + IMG_END_TOKEN
+ prompt_segments.append(f"Image-{i + 1}: {image_tokens}\n")
+ prompts.append("".join(prompt_segments) + text_prompt.strip())
+ return prompts
+
+ def get_fused_image_text_embedding_batched(
+ self,
+ camera_images: Sequence[torch.Tensor],
+ image_masks: torch.Tensor,
+ text_prompts: Sequence[str],
+ return_cls_only: bool = True,
+ ):
+ """Fused VL embedding from per-camera ``[0, 1]`` image batches (no PIL, no host round-trip).
+
+ Args:
+ camera_images: list of per-camera tensors, each shaped ``(B, C, H, W)`` in ``[0, 1]``.
+ image_masks: bool tensor ``(B, max_views)`` marking present views.
+
+ Returns:
+ A ``(embeddings, valid_mask)`` tuple. With ``return_cls_only=False``, ``embeddings`` is
+ ``(B, L, H)`` and ``valid_mask`` is a ``(B, L)`` bool tensor marking tokens downstream
+ attention may attend to (padding and absent-view tokens are False). With
+ ``return_cls_only=True``, ``embeddings`` is the pooled ``(B, H)`` last-valid-token state
+ and ``valid_mask`` is None.
+ """
+ max_views = int(image_masks.shape[1])
+ batch_size = int(image_masks.shape[0])
+ mean = torch.tensor(IMAGENET_MEAN, device=self.device, dtype=self.model_dtype)
+ std = torch.tensor(IMAGENET_STD, device=self.device, dtype=self.model_dtype)
+ pixel_values = _batched_pixel_values(
+ camera_images, max_views, self.image_size, mean, std, self.model_dtype, self.device
+ )
+ # InternVL3 preprocessing uses a single tile per image (max_num=1).
+ batch_num_tiles_list = [[1] * max_views for _ in range(batch_size)]
+ return self._forward_vlm(
+ pixel_values, batch_num_tiles_list, image_masks, text_prompts, return_cls_only
+ )
+
+ def _mask_absent_image_tokens(
+ self,
+ input_ids: torch.Tensor,
+ attention_mask: torch.Tensor,
+ image_masks: torch.Tensor,
+ batch_num_tiles_list: list[list[int]],
+ ) -> torch.Tensor:
+ """Zero attention over the image-context tokens of absent (zero-padded) views.
+
+ Fully vectorized: runs without any host<->device synchronization.
+ """
+ # A single tile per image (max_num=1), so every image occupies the same number of
+ # context tokens.
+ tiles_per_image = (
+ batch_num_tiles_list[0][0] if batch_num_tiles_list and batch_num_tiles_list[0] else 1
+ )
+ tokens_per_image = self.num_image_token * tiles_per_image
+
+ image_masks = image_masks.to(device=input_ids.device).bool()
+ img_token_mask = input_ids == self.img_context_token_id # (B, L)
+ # keep[b, k] tells whether the k-th image-context token (ordered view0, view1, ...) survives.
+ per_token_keep = image_masks.repeat_interleave(tokens_per_image, dim=1) # (B, V * tokens_per_image)
+ # Rank each context token by its running position among the row's context tokens.
+ ctx_index = img_token_mask.to(torch.long).cumsum(dim=1) - 1
+ ctx_index = ctx_index.clamp(min=0, max=per_token_keep.shape[1] - 1)
+ keep_here = torch.gather(per_token_keep, 1, ctx_index) # (B, L)
+ drop = img_token_mask & ~keep_here
+ return attention_mask.masked_fill(drop, 0)
+
+ def _forward_vlm(
+ self,
+ pixel_values: torch.Tensor,
+ batch_num_tiles_list: list[list[int]],
+ image_masks: torch.Tensor,
+ text_prompts: Sequence[str],
+ return_cls_only: bool,
+ ):
+ if pixel_values.shape[0] == 0:
+ logger.warning("InternVL3 received an empty image batch after preprocessing.")
+ hidden_size = getattr(self.model.config, "hidden_size", None)
+ if hidden_size is None:
+ hidden_size = getattr(self.model.config.text_config, "hidden_size", None)
+ if hidden_size is None:
+ raise RuntimeError("Unable to infer hidden size for empty InternVL3 batch.")
+ return torch.empty(0, hidden_size, device=self.device, dtype=torch.float32), None
+
+ prompts = self._build_multimodal_prompts(batch_num_tiles_list, text_prompts)
+
+ model_inputs = self.tokenizer(
+ list(prompts),
+ return_tensors="pt",
+ padding=True,
+ truncation=True,
+ max_length=self.max_text_length,
+ ).to(self.device)
+ input_ids = model_inputs["input_ids"]
+ if input_ids.shape[1] >= self.max_text_length:
+ # Truncation cuts from the right, so text is dropped before image placeholders — but a
+ # large max_views * image_seq_length budget can still eat into them. Fail loudly instead
+ # of letting the VLM crash on a placeholder/vision-feature count mismatch.
+ expected_image_tokens = self.num_image_token * sum(batch_num_tiles_list[0])
+ image_token_counts = (input_ids == self.img_context_token_id).sum(dim=1)
+ if not bool((image_token_counts == expected_image_tokens).all()):
+ raise ValueError(
+ f"Prompt truncation at max_text_length={self.max_text_length} cut into the "
+ f"image placeholder tokens ({expected_image_tokens} expected per sample). "
+ "Increase max_text_length or reduce max_views."
+ )
+ attention_mask = self._mask_absent_image_tokens(
+ input_ids, model_inputs["attention_mask"], image_masks, batch_num_tiles_list
+ )
+
+ outputs = self.model(
+ input_ids=input_ids,
+ pixel_values=pixel_values,
+ attention_mask=attention_mask,
+ output_hidden_states=True,
+ return_dict=True,
+ )
+ fused_hidden = outputs.hidden_states[-1].to(torch.float32)
+ valid_mask = attention_mask.to(torch.bool)
+ if return_cls_only:
+ # Right-padded causal decoder: the last valid token is the only one that has attended
+ # to the full image + text prompt.
+ positions = torch.arange(valid_mask.shape[1], device=valid_mask.device)
+ last_valid = (valid_mask.long() * positions).argmax(dim=1)
+ batch_index = torch.arange(fused_hidden.shape[0], device=fused_hidden.device)
+ return fused_hidden[batch_index, last_valid], None
+ return fused_hidden, valid_mask
+
+ @property
+ def device(self) -> torch.device:
+ return next(self.model.parameters()).device
+
+
+def _flash_attn_available() -> bool:
+ try:
+ import flash_attn # noqa: F401
+ except ModuleNotFoundError:
+ return False
+ return True
diff --git a/src/lerobot/policies/evo1/modeling_evo1.py b/src/lerobot/policies/evo1/modeling_evo1.py
new file mode 100644
index 000000000..a81a0705a
--- /dev/null
+++ b/src/lerobot/policies/evo1/modeling_evo1.py
@@ -0,0 +1,532 @@
+# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import builtins
+from collections import deque
+from contextlib import nullcontext
+from pathlib import Path
+from typing import TypedDict, Unpack
+
+import torch
+from torch import Tensor
+
+from lerobot.configs.policies import PreTrainedConfig
+from lerobot.policies.pretrained import PreTrainedPolicy, T
+from lerobot.utils.constants import ACTION, OBS_IMAGES, OBS_STATE
+
+from ..rtc.modeling_rtc import RTCProcessor
+from .configuration_evo1 import Evo1Config
+from .evo1_model import Evo1Model
+
+
+class ActionSelectKwargs(TypedDict, total=False):
+ inference_delay: int | None
+ prev_chunk_left_over: Tensor | None
+ execution_horizon: int | None
+
+
+class Evo1Policy(PreTrainedPolicy):
+ config_class = Evo1Config
+ name = "evo1"
+
+ def __init__(self, config: Evo1Config, *, vlm_hub_kwargs: dict | None = None, **kwargs):
+ super().__init__(config)
+ config.validate_features()
+
+ if len(config.image_features) > config.max_views:
+ raise ValueError(
+ f"EVO1 supports at most {config.max_views} camera streams, got {len(config.image_features)}"
+ )
+
+ self.config = config
+ self.model = Evo1Model(config, vlm_hub_kwargs=vlm_hub_kwargs)
+ self.model.set_finetune_flags()
+ self._keep_frozen_embedder_eval()
+ self.init_rtc_processor()
+ self.reset()
+
+ def init_rtc_processor(self):
+ """Create the RTC processor when config.rtc_config is set.
+
+ The RTC rollout backend assigns config.rtc_config after loading the policy and re-invokes
+ this method.
+ """
+ self.rtc_processor = None
+ if self.config.rtc_config is not None:
+ self.rtc_processor = RTCProcessor(self.config.rtc_config)
+ model = getattr(self, "model", None)
+ if model is not None:
+ model.rtc_processor = self.rtc_processor
+
+ def _rtc_enabled(self) -> bool:
+ return self.config.rtc_config is not None and self.config.rtc_config.enabled
+
+ @classmethod
+ def from_pretrained(
+ cls: builtins.type[T],
+ pretrained_name_or_path: str | Path,
+ *,
+ config: PreTrainedConfig | None = None,
+ force_download: bool = False,
+ resume_download: bool | None = None,
+ proxies: dict | None = None,
+ token: str | bool | None = None,
+ cache_dir: str | Path | None = None,
+ local_files_only: bool = False,
+ revision: str | None = None,
+ strict: bool | None = None,
+ **kwargs,
+ ) -> T:
+ if strict is None:
+ strict = True
+ vlm_hub_kwargs = kwargs.pop("vlm_hub_kwargs", None)
+ if config is None:
+ config = PreTrainedConfig.from_pretrained(
+ pretrained_name_or_path=pretrained_name_or_path,
+ force_download=force_download,
+ resume_download=resume_download,
+ proxies=proxies,
+ token=token,
+ cache_dir=cache_dir,
+ local_files_only=local_files_only,
+ revision=revision,
+ **kwargs,
+ )
+ if vlm_hub_kwargs is None:
+ # Forward the hub download options to the base-VLM download as well; `revision` is not
+ # forwarded because it identifies the policy repo, not the VLM repo.
+ vlm_hub_kwargs = {
+ key: value
+ for key, value in (
+ ("token", token),
+ ("cache_dir", cache_dir),
+ ("local_files_only", local_files_only),
+ ("proxies", proxies),
+ )
+ if value not in (None, False)
+ }
+ kwargs["vlm_hub_kwargs"] = vlm_hub_kwargs
+ return super().from_pretrained(
+ pretrained_name_or_path=pretrained_name_or_path,
+ config=config,
+ force_download=force_download,
+ resume_download=resume_download,
+ proxies=proxies,
+ token=token,
+ cache_dir=cache_dir,
+ local_files_only=local_files_only,
+ revision=revision,
+ strict=strict,
+ **kwargs,
+ )
+
+ @property
+ def _camera_keys(self) -> list[str]:
+ return list(self.config.image_features)
+
+ @property
+ def _env_action_dim(self) -> int:
+ action_feature = self.config.action_feature
+ if action_feature is None:
+ return self.config.max_action_dim
+ return int(action_feature.shape[0])
+
+ @property
+ def _compute_dtype(self) -> torch.dtype:
+ return next(self.model.action_head.parameters()).dtype
+
+ @property
+ def _device(self) -> torch.device:
+ # The device the policy actually lives on. Derived from the parameters rather than
+ # config.device so the policy keeps working after accelerate (or a plain .to()) moves it.
+ return next(self.model.action_head.parameters()).device
+
+ @property
+ def _amp_enabled(self) -> bool:
+ return bool(self.config.use_amp) and self._device.type == "cuda"
+
+ def _maybe_autocast(self):
+ # EVO1 manages its own mixed precision: an explicit bf16 autocast that also overrides any
+ # outer autocast context (e.g. lerobot-eval's fp16 default), keeping train and eval
+ # numerics identical.
+ if self._amp_enabled:
+ return torch.autocast(device_type="cuda", dtype=torch.bfloat16)
+ return nullcontext()
+
+ def get_optim_params(self) -> list[dict]:
+ decay, no_decay = [], []
+ for name, param in self.named_parameters():
+ if not param.requires_grad:
+ continue
+ is_bias = name.endswith("bias") or ".bias" in name
+ is_norm = param.dim() == 1 or "norm" in name.lower()
+ if is_bias or is_norm:
+ no_decay.append(param)
+ else:
+ decay.append(param)
+ return [
+ {"params": decay, "weight_decay": self.config.optimizer_weight_decay},
+ {"params": no_decay, "weight_decay": 0.0},
+ ]
+
+ def reset(self):
+ self._action_queue = deque([], maxlen=self.config.n_action_steps)
+
+ def _normalize_task_batch(self, batch: dict[str, Tensor | list[str] | str]) -> list[str]:
+ prompts = batch.get(self.config.task_field)
+ if prompts is None and self.config.task_field != "task":
+ prompts = batch.get("task")
+ if prompts is None:
+ raise ValueError(f"EVO1 expects a '{self.config.task_field}' text field in the batch.")
+ if isinstance(prompts, str):
+ return [prompts]
+ if isinstance(prompts, (list, tuple)):
+ return [str(prompt) for prompt in prompts]
+ raise TypeError(f"Unsupported prompt batch type: {type(prompts)}")
+
+ def _prepare_state(self, batch: dict[str, Tensor]) -> tuple[Tensor, Tensor]:
+ if OBS_STATE not in batch:
+ raise ValueError(f"EVO1 requires '{OBS_STATE}' in the batch.")
+ state = batch[OBS_STATE]
+ if state.dim() == 1:
+ state = state.unsqueeze(0)
+ elif state.dim() == 3:
+ state = state[:, -1]
+ elif state.dim() != 2:
+ raise ValueError(f"Unsupported state tensor shape for EVO1: {tuple(state.shape)}")
+ batch_size, state_dim = state.shape
+ if state_dim > self.config.max_state_dim:
+ raise ValueError(
+ f"State dim {state_dim} exceeds configured max_state_dim {self.config.max_state_dim}"
+ )
+ explicit_mask = batch.get("state_mask")
+ if explicit_mask is not None:
+ if explicit_mask.dim() == 1:
+ explicit_mask = explicit_mask.unsqueeze(0)
+ elif explicit_mask.dim() == 3:
+ explicit_mask = explicit_mask[:, -1]
+ elif explicit_mask.dim() != 2:
+ raise ValueError(
+ f"Unsupported state_mask tensor shape for EVO1: {tuple(explicit_mask.shape)}"
+ )
+ if explicit_mask.shape != (batch_size, state_dim):
+ raise ValueError(
+ f"state_mask shape {tuple(explicit_mask.shape)} does not match state shape {(batch_size, state_dim)}"
+ )
+ device = self._device
+ padded = torch.zeros(
+ batch_size,
+ self.config.max_state_dim,
+ dtype=state.dtype,
+ device=device,
+ )
+ padded[:, :state_dim] = state.to(device=device)
+ mask = torch.zeros(
+ batch_size,
+ self.config.max_state_dim,
+ dtype=torch.bool,
+ device=device,
+ )
+ if explicit_mask is None:
+ mask[:, :state_dim] = True
+ else:
+ mask[:, :state_dim] = explicit_mask.to(device=device, dtype=torch.bool)
+ # Zero out masked state dims so an explicit state_mask actually affects the model input
+ # (the state encoder has no mask argument of its own).
+ padded = padded * mask.to(dtype=padded.dtype)
+ return padded.to(dtype=self._compute_dtype), mask
+
+ def _prepare_actions(self, batch: dict[str, Tensor]) -> tuple[Tensor, Tensor]:
+ if ACTION not in batch:
+ raise ValueError(f"EVO1 requires '{ACTION}' in the batch for training.")
+ action = batch[ACTION]
+ if action.dim() == 2:
+ action = action.unsqueeze(1)
+ batch_size, horizon, action_dim = action.shape
+ if horizon != self.config.chunk_size:
+ raise ValueError(
+ f"EVO1 expects chunk_size={self.config.chunk_size}, got action horizon {horizon}"
+ )
+ if action_dim > self.config.max_action_dim:
+ raise ValueError(
+ f"Action dim {action_dim} exceeds configured max_action_dim {self.config.max_action_dim}"
+ )
+ explicit_mask = batch.get("action_mask")
+ if explicit_mask is not None:
+ if explicit_mask.dim() == 2:
+ if horizon == 1:
+ explicit_mask = explicit_mask.unsqueeze(1)
+ else:
+ raise ValueError(
+ f"2D action_mask is only supported when chunk_size=1, got action horizon {horizon}"
+ )
+ elif explicit_mask.dim() != 3:
+ raise ValueError(
+ f"Unsupported action_mask tensor shape for EVO1: {tuple(explicit_mask.shape)}"
+ )
+ if explicit_mask.shape != (batch_size, horizon, action_dim):
+ raise ValueError(
+ "action_mask shape "
+ f"{tuple(explicit_mask.shape)} does not match action shape {(batch_size, horizon, action_dim)}"
+ )
+ device = self._device
+ padded = torch.zeros(
+ batch_size,
+ horizon,
+ self.config.max_action_dim,
+ dtype=action.dtype,
+ device=device,
+ )
+ padded[:, :, :action_dim] = action.to(device=device)
+ mask = torch.zeros(
+ batch_size,
+ horizon,
+ self.config.max_action_dim,
+ dtype=torch.bool,
+ device=device,
+ )
+ if explicit_mask is None:
+ mask[:, :, :action_dim] = True
+ else:
+ mask[:, :, :action_dim] = explicit_mask.to(device=device, dtype=torch.bool)
+
+ # Timesteps beyond the episode end hold fabricated (repeated) actions; exclude them from
+ # the loss like the other chunked policies do.
+ action_is_pad = batch.get("action_is_pad")
+ if action_is_pad is not None:
+ if action_is_pad.shape != (batch_size, horizon):
+ raise ValueError(
+ f"action_is_pad shape {tuple(action_is_pad.shape)} does not match "
+ f"(batch_size, chunk_size)={(batch_size, horizon)}"
+ )
+ in_episode = ~action_is_pad.to(device=device, dtype=torch.bool)
+ mask = mask & in_episode.unsqueeze(-1)
+ return padded.to(dtype=self._compute_dtype), mask
+
+ def _prepare_inference_action_mask(self, batch_size: int) -> Tensor:
+ mask = torch.zeros(
+ batch_size,
+ self.config.max_action_dim,
+ dtype=torch.bool,
+ device=self._device,
+ )
+ mask[:, : self._env_action_dim] = True
+ return mask
+
+ def _get_embodiment_ids(self, batch: dict[str, Tensor], batch_size: int) -> Tensor:
+ embodiment_ids = batch.get("embodiment_id")
+ if embodiment_ids is None and self.config.embodiment_id_field:
+ embodiment_ids = batch.get(self.config.embodiment_id_field)
+ if embodiment_ids is None:
+ return torch.full(
+ (batch_size,),
+ self.config.default_embodiment_id,
+ dtype=torch.long,
+ device=self._device,
+ )
+ if embodiment_ids.dim() == 0:
+ embodiment_ids = embodiment_ids.unsqueeze(0)
+ elif embodiment_ids.dim() > 1:
+ embodiment_ids = embodiment_ids[:, -1]
+ return embodiment_ids.to(device=self._device, dtype=torch.long)
+
+ @property
+ def _tracks_vlm_gradients(self) -> bool:
+ return bool(
+ self.config.finetune_vlm
+ or self.config.finetune_language_model
+ or self.config.finetune_vision_model
+ )
+
+ def _keep_frozen_embedder_eval(self) -> None:
+ if self._tracks_vlm_gradients:
+ return
+ embedder = getattr(self.model, "embedder", None)
+ if embedder is not None:
+ embedder.eval()
+
+ def train(self, mode: bool = True):
+ super().train(mode)
+ self._keep_frozen_embedder_eval()
+ return self
+
+ def _collect_image_batches(self, batch: dict[str, Tensor]) -> tuple[list[Tensor], Tensor]:
+ camera_keys = self._camera_keys or sorted(key for key in batch if key.startswith(f"{OBS_IMAGES}."))
+ if not camera_keys:
+ raise ValueError("EVO1 requires at least one visual observation feature.")
+ camera_keys = list(camera_keys)[: self.config.max_views]
+
+ # Configured cameras may be absent from the batch up to the empty_cameras budget (e.g. the
+ # placeholder features added by validate_features); they become masked-out views that the
+ # embedder zero-pads. Any other absent camera is an error.
+ present_keys = [key for key in camera_keys if key in batch]
+ missing_keys = [key for key in camera_keys if key not in batch]
+ if len(missing_keys) > self.config.empty_cameras:
+ raise ValueError(
+ f"Missing camera features {missing_keys} in batch; at most "
+ f"empty_cameras={self.config.empty_cameras} may be absent."
+ )
+ if not present_keys:
+ raise ValueError("EVO1 requires at least one visual observation in the batch.")
+
+ # Keep each present camera as a batched (B, C, H, W) tensor on its current (GPU) device.
+ # Resizing/normalization and zero-padding of absent views happen batched inside the
+ # embedder, so images never leave the device here.
+ camera_images: list[Tensor] = []
+ for camera_key in present_keys:
+ image = batch[camera_key]
+ if image.dim() == 3:
+ # Promote an unbatched (C, H, W) frame so batch_size is read from a real batch dim.
+ image = image.unsqueeze(0)
+ elif image.dim() == 5:
+ image = image[:, -1]
+ elif image.dim() != 4:
+ raise ValueError(
+ f"Unsupported image tensor shape for EVO1: key={camera_key} shape={tuple(image.shape)}"
+ )
+ camera_images.append(image)
+
+ batch_size = camera_images[0].shape[0]
+ n_present = len(camera_images)
+ image_masks = torch.zeros(
+ batch_size, self.config.max_views, dtype=torch.bool, device=camera_images[0].device
+ )
+ image_masks[:, :n_present] = True
+
+ return camera_images, image_masks
+
+ def _compute_fused_tokens(
+ self,
+ prompts: list[str],
+ image_batches: list[Tensor],
+ image_masks: Tensor,
+ ) -> tuple[Tensor, Tensor | None]:
+ track_vlm_gradients = self._tracks_vlm_gradients
+ grad_context = nullcontext() if track_vlm_gradients else torch.no_grad()
+ with grad_context:
+ fused_tokens, context_mask = self.model.get_vl_embeddings(
+ images=image_batches,
+ image_mask=image_masks,
+ prompt=prompts,
+ return_cls_only=self.config.return_cls_only,
+ )
+
+ if not track_vlm_gradients:
+ fused_tokens = fused_tokens.detach()
+ fused_tokens = fused_tokens.to(device=self._device, dtype=self._compute_dtype)
+ if context_mask is not None:
+ context_mask = context_mask.to(device=self._device)
+ return fused_tokens, context_mask
+
+ def _compute_masked_loss(
+ self,
+ pred_velocity: Tensor,
+ target_velocity: Tensor,
+ action_mask: Tensor,
+ reduction: str,
+ ) -> Tensor:
+ flat_mask = action_mask.view(action_mask.shape[0], -1).to(dtype=pred_velocity.dtype)
+ sq_error = ((pred_velocity - target_velocity) * flat_mask).pow(2)
+ active = flat_mask.sum(dim=1).clamp_min(1.0)
+ per_sample_loss = sq_error.sum(dim=1) / active
+ if reduction == "none":
+ return per_sample_loss
+ if reduction != "mean":
+ raise ValueError(f"Unsupported reduction '{reduction}'")
+ return sq_error.sum() / active.sum()
+
+ def forward(self, batch: dict[str, Tensor], reduction: str = "mean") -> tuple[Tensor, dict]:
+ prompts = self._normalize_task_batch(batch)
+ image_batches, image_masks = self._collect_image_batches(batch)
+ states, _state_mask = self._prepare_state(batch)
+ actions_gt, action_mask = self._prepare_actions(batch)
+ embodiment_ids = self._get_embodiment_ids(batch, states.shape[0])
+
+ with self._maybe_autocast():
+ fused_tokens, context_mask = self._compute_fused_tokens(prompts, image_batches, image_masks)
+ pred_velocity, noise = self.model(
+ fused_tokens,
+ state=states,
+ actions_gt=actions_gt,
+ action_mask=action_mask.to(device=self._device, dtype=self._compute_dtype),
+ embodiment_ids=embodiment_ids,
+ context_mask=context_mask,
+ )
+
+ # Compute the flow-matching regression loss in fp32, outside the autocast block.
+ pred_velocity = pred_velocity.float()
+ noise = noise.float()
+ flat_action_mask = action_mask.view(action_mask.shape[0], -1).to(dtype=torch.float32)
+ # Flow-matching velocity target. Padded (masked-out) action dims are already zero on both sides
+ # here (`actions_gt` is zero-padded in `_prepare_actions`, and `noise` is masked inside the head),
+ # and the whole difference is multiplied by `flat_action_mask`, so padded dims contribute nothing.
+ target_velocity = (actions_gt.float() - noise).view(actions_gt.shape[0], -1) * flat_action_mask
+ loss = self._compute_masked_loss(pred_velocity, target_velocity, action_mask, reduction)
+ loss_mean = loss.mean().item() if loss.ndim > 0 else loss.item()
+ return loss, {
+ "loss": loss_mean,
+ "active_action_dims": float(action_mask.sum(dim=(1, 2)).float().mean().item()),
+ }
+
+ @torch.no_grad()
+ def predict_action_chunk(self, batch: dict[str, Tensor], **kwargs: Unpack[ActionSelectKwargs]) -> Tensor:
+ inference_delay = kwargs.get("inference_delay")
+ prev_chunk_left_over = kwargs.get("prev_chunk_left_over")
+ execution_horizon = kwargs.get("execution_horizon")
+ if (inference_delay is not None or prev_chunk_left_over is not None) and not self._rtc_enabled():
+ raise RuntimeError(
+ "Received RTC arguments but RTC is not configured for this EVO1 policy: set "
+ "config.rtc_config and call init_rtc_processor() (lerobot-rollout does this for "
+ "--inference.type=rtc)."
+ )
+ self.eval()
+
+ prompts = self._normalize_task_batch(batch)
+ image_batches, image_masks = self._collect_image_batches(batch)
+ states, _state_mask = self._prepare_state(batch)
+ embodiment_ids = self._get_embodiment_ids(batch, states.shape[0])
+ action_mask = self._prepare_inference_action_mask(states.shape[0])
+ if prev_chunk_left_over is not None:
+ prev_chunk_left_over = prev_chunk_left_over.to(device=self._device)
+
+ with self._maybe_autocast():
+ fused_tokens, context_mask = self._compute_fused_tokens(prompts, image_batches, image_masks)
+ actions = self.model(
+ fused_tokens,
+ state=states,
+ action_mask=action_mask,
+ embodiment_ids=embodiment_ids,
+ context_mask=context_mask,
+ inference_delay=inference_delay,
+ prev_chunk_left_over=prev_chunk_left_over,
+ execution_horizon=execution_horizon,
+ )
+ actions = actions.view(states.shape[0], self.config.chunk_size, self.config.max_action_dim)
+ return actions.to(dtype=torch.float32)
+
+ @torch.no_grad()
+ def select_action(self, batch: dict[str, Tensor], **kwargs) -> Tensor:
+ assert not self._rtc_enabled(), (
+ "RTC is not supported for select_action, use it with predict_action_chunk"
+ )
+ self.eval()
+ if len(self._action_queue) == 0:
+ action_chunk = self.predict_action_chunk(batch)[:, : self.config.n_action_steps]
+ self._action_queue.extend(action_chunk.transpose(0, 1))
+ # Returns one step of shape (B, max_action_dim): actions are emitted at the padded max_action_dim
+ # width and cropped to the real action dim downstream by the postprocessor (Evo1ActionProcessorStep).
+ # Callers that bypass the postprocessor receive the padded width.
+ return self._action_queue.popleft()
diff --git a/src/lerobot/policies/evo1/processor_evo1.py b/src/lerobot/policies/evo1/processor_evo1.py
new file mode 100644
index 000000000..adff8b66a
--- /dev/null
+++ b/src/lerobot/policies/evo1/processor_evo1.py
@@ -0,0 +1,400 @@
+# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from copy import deepcopy
+from dataclasses import dataclass
+from typing import Any
+
+import torch
+
+from lerobot.configs import FeatureType, PipelineFeatureType, PolicyFeature
+from lerobot.processor import (
+ AddBatchDimensionProcessorStep,
+ DeviceProcessorStep,
+ NormalizerProcessorStep,
+ ObservationProcessorStep,
+ PolicyAction,
+ PolicyActionProcessorStep,
+ PolicyProcessorPipeline,
+ ProcessorStep,
+ ProcessorStepRegistry,
+ RenameObservationsProcessorStep,
+ UnnormalizerProcessorStep,
+)
+from lerobot.processor.converters import (
+ batch_to_transition,
+ create_transition,
+ policy_action_to_transition,
+ transition_to_policy_action,
+)
+from lerobot.types import EnvTransition, TransitionKey
+from lerobot.utils.constants import (
+ ACTION,
+ DONE,
+ INFO,
+ OBS_PREFIX,
+ OBS_STATE,
+ POLICY_POSTPROCESSOR_DEFAULT_NAME,
+ POLICY_PREPROCESSOR_DEFAULT_NAME,
+ REWARD,
+ TRUNCATED,
+)
+
+from .configuration_evo1 import Evo1Config
+
+
+def evo1_batch_to_transition(batch: dict[str, Any]):
+ transition = batch_to_transition(batch)
+ complementary_data = dict(transition.get("complementary_data") or {})
+ reserved = {ACTION, REWARD, DONE, TRUNCATED, INFO}
+ for key, value in batch.items():
+ if key in reserved or key.startswith(OBS_PREFIX):
+ continue
+ complementary_data.setdefault(key, value)
+ return create_transition(
+ observation=transition.get("observation"),
+ action=transition.get("action"),
+ reward=transition.get("reward", 0.0),
+ done=transition.get("done", False),
+ truncated=transition.get("truncated", False),
+ info=transition.get("info", {}),
+ complementary_data=complementary_data,
+ )
+
+
+@dataclass
+@ProcessorStepRegistry.register(name="evo1_pad_state_processor")
+class Evo1PadStateProcessorStep(ObservationProcessorStep):
+ """Pad policy observations to EVO1's fixed state width before normalization."""
+
+ max_state_dim: int = 24
+
+ def observation(self, observation: dict[str, Any]) -> dict[str, Any]:
+ if OBS_STATE not in observation:
+ return observation
+
+ state = observation[OBS_STATE]
+ state_dim = state.shape[-1]
+ if state_dim > self.max_state_dim:
+ raise ValueError(
+ f"EVO1 state has {state_dim} dims, which exceeds max_state_dim={self.max_state_dim}."
+ )
+ if state_dim < self.max_state_dim:
+ observation = observation.copy()
+ observation[OBS_STATE] = torch.nn.functional.pad(state, (0, self.max_state_dim - state_dim))
+ return observation
+
+ def transform_features(
+ self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
+ ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
+ new_features = {ft: feats.copy() for ft, feats in features.items()}
+ obs_feats = new_features.setdefault(PipelineFeatureType.OBSERVATION, {})
+ if OBS_STATE in obs_feats:
+ obs_feats[OBS_STATE] = PolicyFeature(type=FeatureType.STATE, shape=(self.max_state_dim,))
+ return new_features
+
+ def get_config(self) -> dict[str, Any]:
+ return {"max_state_dim": self.max_state_dim}
+
+
+@dataclass
+@ProcessorStepRegistry.register(name="evo1_pad_action_processor")
+class Evo1PadActionProcessorStep(ProcessorStep):
+ """Pad training actions and preserve the active action dimensions with action_mask."""
+
+ max_action_dim: int = 24
+
+ def __call__(self, transition: EnvTransition) -> EnvTransition:
+ action = transition.get(TransitionKey.ACTION)
+ if action is None:
+ return transition
+ if not isinstance(action, PolicyAction):
+ raise ValueError(f"EVO1 action should be a PolicyAction tensor, but got {type(action)}.")
+
+ action_dim = action.shape[-1]
+ if action_dim > self.max_action_dim:
+ raise ValueError(
+ f"EVO1 action has {action_dim} dims, which exceeds max_action_dim={self.max_action_dim}."
+ )
+
+ new_transition = transition.copy()
+ new_action = action
+ if action_dim < self.max_action_dim:
+ new_action = torch.nn.functional.pad(action, (0, self.max_action_dim - action_dim))
+
+ complementary_data = dict(new_transition.get(TransitionKey.COMPLEMENTARY_DATA) or {})
+ action_mask = complementary_data.get("action_mask")
+ if action_mask is None:
+ action_mask = torch.ones(action.shape, dtype=torch.bool, device=action.device)
+ else:
+ action_mask = torch.as_tensor(action_mask, dtype=torch.bool, device=action.device)
+ if action_mask.shape != action.shape:
+ raise ValueError(
+ f"action_mask shape {tuple(action_mask.shape)} does not match action shape {tuple(action.shape)}."
+ )
+ if action_dim < self.max_action_dim:
+ action_mask = torch.nn.functional.pad(action_mask, (0, self.max_action_dim - action_dim))
+
+ complementary_data["action_mask"] = action_mask
+ new_transition[TransitionKey.ACTION] = new_action
+ new_transition[TransitionKey.COMPLEMENTARY_DATA] = complementary_data
+ return new_transition
+
+ def transform_features(
+ self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
+ ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
+ new_features = {ft: feats.copy() for ft, feats in features.items()}
+ action_feats = new_features.setdefault(PipelineFeatureType.ACTION, {})
+ action_feats[ACTION] = PolicyFeature(type=FeatureType.ACTION, shape=(self.max_action_dim,))
+ return new_features
+
+ def get_config(self) -> dict[str, Any]:
+ return {"max_action_dim": self.max_action_dim}
+
+
+@dataclass
+@ProcessorStepRegistry.register(name="evo1_action_processor")
+class Evo1ActionProcessorStep(PolicyActionProcessorStep):
+ """Crop padded EVO1 actions and optionally binarize the LIBERO gripper channel."""
+
+ action_dim: int
+ binarize_gripper: bool = False
+ gripper_index: int = 6
+ gripper_threshold: float = 0.5
+ gripper_below_threshold_value: float = 1.0
+ gripper_above_threshold_value: float = -1.0
+
+ def action(self, action: PolicyAction) -> PolicyAction:
+ if action.shape[-1] < self.action_dim:
+ raise ValueError(
+ f"EVO1 action has {action.shape[-1]} dims, which is smaller than action_dim={self.action_dim}."
+ )
+
+ action = action[..., : self.action_dim]
+ if not self.binarize_gripper:
+ return action
+
+ if not 0 <= self.gripper_index < self.action_dim:
+ raise ValueError(
+ f"gripper_index={self.gripper_index} must be within action_dim={self.action_dim}."
+ )
+
+ action = action.clone()
+ below = torch.as_tensor(
+ self.gripper_below_threshold_value,
+ dtype=action.dtype,
+ device=action.device,
+ )
+ above = torch.as_tensor(
+ self.gripper_above_threshold_value,
+ dtype=action.dtype,
+ device=action.device,
+ )
+ action[..., self.gripper_index] = torch.where(
+ action[..., self.gripper_index] > self.gripper_threshold,
+ above,
+ below,
+ )
+ return action
+
+ def transform_features(
+ self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
+ ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
+ new_features = {ft: feats.copy() for ft, feats in features.items()}
+ action_feats = new_features.setdefault(PipelineFeatureType.ACTION, {})
+ action_feats[ACTION] = PolicyFeature(type=FeatureType.ACTION, shape=(self.action_dim,))
+ return new_features
+
+ def get_config(self) -> dict[str, Any]:
+ return {
+ "action_dim": self.action_dim,
+ "binarize_gripper": self.binarize_gripper,
+ "gripper_index": self.gripper_index,
+ "gripper_threshold": self.gripper_threshold,
+ "gripper_below_threshold_value": self.gripper_below_threshold_value,
+ "gripper_above_threshold_value": self.gripper_above_threshold_value,
+ }
+
+
+def _evo1_action_dim(config: Evo1Config) -> int:
+ if config.postprocess_action_dim is not None:
+ return config.postprocess_action_dim
+ action_feature = config.action_feature
+ if action_feature is None:
+ return config.max_action_dim
+ return int(action_feature.shape[0])
+
+
+def _evo1_normalization_features(config: Evo1Config) -> dict[str, PolicyFeature]:
+ features = {**config.input_features, **config.output_features}
+ features[OBS_STATE] = PolicyFeature(type=FeatureType.STATE, shape=(config.max_state_dim,))
+ features[ACTION] = PolicyFeature(type=FeatureType.ACTION, shape=(config.max_action_dim,))
+ return features
+
+
+def _evo1_action_features(config: Evo1Config) -> dict[str, PolicyFeature]:
+ return {ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(config.max_action_dim,))}
+
+
+_STAT_PAD_VALUES = {
+ "mean": 0.0,
+ "std": 1.0,
+ "min": -1.0,
+ "max": 1.0,
+ "q01": -1.0,
+ "q99": 1.0,
+ "q10": -1.0,
+ "q90": 1.0,
+}
+
+
+def _pad_stat_value(value: Any, target_dim: int, stat_name: str) -> torch.Tensor:
+ tensor = torch.as_tensor(value)
+ if not tensor.is_floating_point():
+ tensor = tensor.to(dtype=torch.float32)
+ if tensor.ndim == 0 or tensor.shape[-1] >= target_dim:
+ return tensor
+
+ pad_shape = (*tensor.shape[:-1], target_dim - tensor.shape[-1])
+ pad_value = _STAT_PAD_VALUES.get(stat_name, 0.0)
+ padding = torch.full(pad_shape, pad_value, dtype=tensor.dtype, device=tensor.device)
+ return torch.cat([tensor, padding], dim=-1)
+
+
+def _pad_feature_stats(
+ stats: dict[str, dict[str, Any]],
+ feature_key: str,
+ target_dim: int,
+) -> None:
+ if feature_key not in stats:
+ return
+ stats[feature_key] = {
+ stat_name: _pad_stat_value(stat_value, target_dim, stat_name)
+ for stat_name, stat_value in stats[feature_key].items()
+ }
+
+
+def _pad_evo1_stats(
+ config: Evo1Config,
+ stats: dict[str, dict[str, Any]] | None,
+) -> dict[str, dict[str, Any]] | None:
+ if stats is None:
+ return None
+
+ padded_stats = deepcopy(stats)
+ # Added dimensions represent zero-padding inside EVO1. These neutral stats keep
+ # padded observations at normalized zero and only provide shape compatibility.
+ _pad_feature_stats(padded_stats, OBS_STATE, config.max_state_dim)
+ _pad_feature_stats(padded_stats, ACTION, config.max_action_dim)
+ return padded_stats
+
+
+def reconcile_evo1_processors(
+ config: Evo1Config,
+ preprocessor: PolicyProcessorPipeline,
+ postprocessor: PolicyProcessorPipeline,
+) -> tuple[PolicyProcessorPipeline, PolicyProcessorPipeline]:
+ """Reconcile checkpoint-loaded pipelines with the current EVO1 config.
+
+ Two things cannot be restored from a serialized pipeline alone: the EVO1 batch converter
+ (converters are plain functions and are never serialized), and eval-time CLI overrides of the
+ action postprocessing flags (`postprocess_action_dim`, `binarize_gripper`, `gripper_*`). This
+ restores the converter and rebuilds the action step from the current config so those overrides
+ take effect.
+ """
+ # Pipelines reloaded from a checkpoint come back with the default batch converter, which drops
+ # non-observation extras (embodiment_id, state_mask, custom task fields) needed by EVO1.
+ preprocessor.to_transition = evo1_batch_to_transition
+
+ action_step = Evo1ActionProcessorStep(
+ action_dim=_evo1_action_dim(config),
+ binarize_gripper=config.binarize_gripper,
+ gripper_index=config.gripper_index,
+ gripper_threshold=config.gripper_threshold,
+ gripper_below_threshold_value=config.gripper_below_threshold_value,
+ gripper_above_threshold_value=config.gripper_above_threshold_value,
+ )
+ steps = list(postprocessor.steps)
+ action_step_idx = next(
+ (idx for idx, step in enumerate(steps) if isinstance(step, Evo1ActionProcessorStep)), None
+ )
+ if action_step_idx is None:
+ insert_idx = next(
+ (idx + 1 for idx, step in enumerate(steps) if isinstance(step, UnnormalizerProcessorStep)),
+ 0,
+ )
+ steps.insert(insert_idx, action_step)
+ else:
+ steps[action_step_idx] = action_step
+ postprocessor.steps = steps
+
+ return preprocessor, postprocessor
+
+
+def make_evo1_pre_post_processors(
+ config: Evo1Config,
+ dataset_stats: dict[str, dict[str, torch.Tensor]] | None = None,
+) -> tuple[
+ PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
+ PolicyProcessorPipeline[PolicyAction, PolicyAction],
+]:
+ normalization_features = _evo1_normalization_features(config)
+ action_features = _evo1_action_features(config)
+ normalization_stats = _pad_evo1_stats(config, dataset_stats)
+
+ input_steps = [
+ RenameObservationsProcessorStep(rename_map={}),
+ AddBatchDimensionProcessorStep(),
+ Evo1PadStateProcessorStep(max_state_dim=config.max_state_dim),
+ Evo1PadActionProcessorStep(max_action_dim=config.max_action_dim),
+ NormalizerProcessorStep(
+ features=normalization_features,
+ norm_map=config.normalization_mapping,
+ stats=normalization_stats,
+ ),
+ DeviceProcessorStep(device=config.device),
+ ]
+ output_steps = [
+ UnnormalizerProcessorStep(
+ features=action_features,
+ norm_map=config.normalization_mapping,
+ stats=normalization_stats,
+ ),
+ Evo1ActionProcessorStep(
+ action_dim=_evo1_action_dim(config),
+ binarize_gripper=config.binarize_gripper,
+ gripper_index=config.gripper_index,
+ gripper_threshold=config.gripper_threshold,
+ gripper_below_threshold_value=config.gripper_below_threshold_value,
+ gripper_above_threshold_value=config.gripper_above_threshold_value,
+ ),
+ # float32 so downstream numpy conversion works even when the policy computes in bf16.
+ DeviceProcessorStep(device="cpu", float_dtype="float32"),
+ ]
+
+ return (
+ PolicyProcessorPipeline[dict[str, Any], dict[str, Any]](
+ steps=input_steps,
+ name=POLICY_PREPROCESSOR_DEFAULT_NAME,
+ to_transition=evo1_batch_to_transition,
+ ),
+ PolicyProcessorPipeline[PolicyAction, PolicyAction](
+ steps=output_steps,
+ name=POLICY_POSTPROCESSOR_DEFAULT_NAME,
+ to_transition=policy_action_to_transition,
+ to_output=transition_to_policy_action,
+ ),
+ )
diff --git a/src/lerobot/policies/factory.py b/src/lerobot/policies/factory.py
index f5acb0170..73fd9455f 100644
--- a/src/lerobot/policies/factory.py
+++ b/src/lerobot/policies/factory.py
@@ -47,9 +47,11 @@ from lerobot.utils.feature_utils import dataset_to_policy_features
from .act.configuration_act import ACTConfig
from .diffusion.configuration_diffusion import DiffusionConfig
from .eo1.configuration_eo1 import EO1Config
+from .evo1.configuration_evo1 import Evo1Config
from .fastwam.configuration_fastwam import FastWAMConfig
from .gaussian_actor.configuration_gaussian_actor import GaussianActorConfig
from .groot.configuration_groot import GrootConfig
+from .lingbot_va.configuration_lingbot_va import LingBotVAConfig
from .molmoact2.configuration_molmoact2 import MolmoAct2Config
from .multi_task_dit.configuration_multi_task_dit import MultiTaskDiTConfig
from .pi0.configuration_pi0 import PI0Config
@@ -92,7 +94,7 @@ def get_policy_class(name: str) -> type[PreTrainedPolicy]:
Args:
name: The name of the policy. Supported names are "tdmpc", "diffusion", "act",
"multi_task_dit", "vqbet", "pi0", "pi05", "gaussian_actor", "smolvla", "wall_x",
- "molmoact2".
+ "molmoact2", "eo1", "evo1".
Returns:
The policy class corresponding to the given name.
@@ -163,10 +165,18 @@ def get_policy_class(name: str) -> type[PreTrainedPolicy]:
from .vla_jepa.modeling_vla_jepa import VLAJEPAPolicy
return VLAJEPAPolicy
+ elif name == "lingbot_va":
+ from .lingbot_va.modeling_lingbot_va import LingBotVAPolicy
+
+ return LingBotVAPolicy
elif name == "fastwam":
from .fastwam.modeling_fastwam import FastWAMPolicy
return FastWAMPolicy
+ elif name == "evo1":
+ from .evo1.modeling_evo1 import Evo1Policy
+
+ return Evo1Policy
else:
try:
return _get_policy_cls_from_policy_name(name=name)
@@ -184,7 +194,7 @@ def make_policy_config(policy_type: str, **kwargs) -> PreTrainedConfig:
Args:
policy_type: The type of the policy. Supported types include "tdmpc",
"multi_task_dit", "diffusion", "act", "vqbet", "pi0", "pi05", "gaussian_actor",
- "smolvla", "wall_x", "molmoact2".
+ "smolvla", "wall_x", "molmoact2", "eo1", "evo1".
**kwargs: Keyword arguments to be passed to the configuration class constructor.
Returns:
@@ -223,8 +233,12 @@ def make_policy_config(policy_type: str, **kwargs) -> PreTrainedConfig:
return MolmoAct2Config(**kwargs)
elif policy_type == "vla_jepa":
return VLAJEPAConfig(**kwargs)
+ elif policy_type == "lingbot_va":
+ return LingBotVAConfig(**kwargs)
elif policy_type == "fastwam":
return FastWAMConfig(**kwargs)
+ elif policy_type == "evo1":
+ return Evo1Config(**kwargs)
else:
try:
config_cls = PreTrainedConfig.get_choice_class(policy_type)
@@ -288,26 +302,23 @@ def make_pre_post_processors(
policy configuration type.
"""
if pretrained_path:
- # TODO(Steven): Temporary patch, implement correctly the processors for Gr00t
if isinstance(policy_cfg, GrootConfig):
- # GROOT handles normalization in groot_pack_inputs_v3 step
- # Need to override both stats AND normalize_min_max since saved config might be empty
- preprocessor_overrides = {}
- postprocessor_overrides = {}
- preprocessor_overrides["groot_pack_inputs_v3"] = {
- "stats": kwargs.get("dataset_stats"),
- "normalize_min_max": True,
- }
+ from .groot.processor_groot import make_groot_pre_post_processors_from_pretrained
- # Also ensure postprocessing slices to env action dim and unnormalizes with dataset stats
- env_action_dim = policy_cfg.output_features[ACTION].shape[0]
- postprocessor_overrides["groot_action_unpack_unnormalize_v1"] = {
- "stats": kwargs.get("dataset_stats"),
- "normalize_min_max": True,
- "env_action_dim": env_action_dim,
- }
- kwargs["preprocessor_overrides"] = preprocessor_overrides
- kwargs["postprocessor_overrides"] = postprocessor_overrides
+ return make_groot_pre_post_processors_from_pretrained(
+ config=policy_cfg,
+ pretrained_path=pretrained_path,
+ dataset_stats=kwargs.get("dataset_stats"),
+ dataset_meta=kwargs.get("dataset_meta"),
+ preprocessor_overrides=kwargs.get("preprocessor_overrides"),
+ postprocessor_overrides=kwargs.get("postprocessor_overrides"),
+ preprocessor_config_filename=kwargs.get(
+ "preprocessor_config_filename", f"{POLICY_PREPROCESSOR_DEFAULT_NAME}.json"
+ ),
+ postprocessor_config_filename=kwargs.get(
+ "postprocessor_config_filename", f"{POLICY_POSTPROCESSOR_DEFAULT_NAME}.json"
+ ),
+ )
preprocessor = PolicyProcessorPipeline.from_pretrained(
pretrained_model_name_or_path=pretrained_path,
@@ -330,6 +341,14 @@ def make_pre_post_processors(
revision=pretrained_revision,
)
_reconnect_relative_absolute_steps(preprocessor, postprocessor)
+ if isinstance(policy_cfg, Evo1Config):
+ from .evo1.processor_evo1 import reconcile_evo1_processors
+
+ preprocessor, postprocessor = reconcile_evo1_processors(
+ policy_cfg,
+ preprocessor,
+ postprocessor,
+ )
return preprocessor, postprocessor
# Create a new processor based on policy type
@@ -413,6 +432,7 @@ def make_pre_post_processors(
processors = make_groot_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
+ dataset_meta=kwargs.get("dataset_meta"),
)
elif isinstance(policy_cfg, XVLAConfig):
@@ -440,6 +460,13 @@ def make_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
+ elif isinstance(policy_cfg, Evo1Config):
+ from .evo1.processor_evo1 import make_evo1_pre_post_processors
+
+ processors = make_evo1_pre_post_processors(
+ config=policy_cfg,
+ dataset_stats=kwargs.get("dataset_stats"),
+ )
elif isinstance(policy_cfg, MolmoAct2Config):
from .molmoact2.processor_molmoact2 import make_molmoact2_pre_post_processors
@@ -458,6 +485,14 @@ def make_pre_post_processors(
dataset_stats=kwargs.get("dataset_stats"),
)
+ elif isinstance(policy_cfg, LingBotVAConfig):
+ from .lingbot_va.processor_lingbot_va import make_lingbot_va_pre_post_processors
+
+ processors = make_lingbot_va_pre_post_processors(
+ config=policy_cfg,
+ dataset_stats=kwargs.get("dataset_stats"),
+ )
+
elif isinstance(policy_cfg, FastWAMConfig):
from .fastwam.processor_fastwam import make_fastwam_pre_post_processors
@@ -555,6 +590,7 @@ def make_policy(
set_dataset_feature_metadata = getattr(cfg, "set_dataset_feature_metadata", None)
if callable(set_dataset_feature_metadata):
set_dataset_feature_metadata(ds_meta.features)
+ cfg._runtime_dataset_meta = ds_meta
kwargs["config"] = cfg
diff --git a/src/lerobot/policies/groot/action_head/action_encoder.py b/src/lerobot/policies/groot/action_head/action_encoder.py
deleted file mode 100644
index c6fa0a779..000000000
--- a/src/lerobot/policies/groot/action_head/action_encoder.py
+++ /dev/null
@@ -1,54 +0,0 @@
-# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-# SPDX-License-Identifier: Apache-2.0
-#
-# 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
-import torch.nn as nn
-
-
-def swish(x):
- return x * torch.sigmoid(x)
-
-
-class SinusoidalPositionalEncoding(nn.Module):
- """
- Produces a sinusoidal encoding of shape (B, T, w)
- given timesteps of shape (B, T).
- """
-
- def __init__(self, embedding_dim):
- super().__init__()
- self.embedding_dim = embedding_dim
-
- def forward(self, timesteps):
- # timesteps: shape (B, T)
- # We'll compute sin/cos frequencies across dim T
- timesteps = timesteps.float() # ensure float
-
- b, t = timesteps.shape
- device = timesteps.device
-
- half_dim = self.embedding_dim // 2
- # typical log space frequencies for sinusoidal encoding
- exponent = -torch.arange(half_dim, dtype=torch.float, device=device) * (
- torch.log(torch.tensor(10000.0)) / half_dim
- )
- # Expand timesteps to (B, T, 1) then multiply
- freqs = timesteps.unsqueeze(-1) * exponent.exp() # (B, T, half_dim)
-
- sin = torch.sin(freqs)
- cos = torch.cos(freqs)
- enc = torch.cat([sin, cos], dim=-1) # (B, T, w)
-
- return enc
diff --git a/src/lerobot/policies/groot/action_head/cross_attention_dit.py b/src/lerobot/policies/groot/action_head/cross_attention_dit.py
index a4cd1a0b7..697459240 100755
--- a/src/lerobot/policies/groot/action_head/cross_attention_dit.py
+++ b/src/lerobot/policies/groot/action_head/cross_attention_dit.py
@@ -1,11 +1,12 @@
-# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-# SPDX-License-Identifier: Apache-2.0
+#!/usr/bin/env python
+
+# Copyright 2025 NVIDIA Corporation 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
+# 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,
@@ -14,6 +15,7 @@
# limitations under the License.
+import logging
from typing import TYPE_CHECKING
import torch
@@ -42,6 +44,9 @@ else:
Timesteps = None
+logger = logging.getLogger(__name__)
+
+
class TimestepEncoder(nn.Module):
def __init__(self, embedding_dim, compute_dtype=torch.float32):
require_package("diffusers", extra="groot")
@@ -181,8 +186,7 @@ class BasicTransformerBlock(nn.Module):
attn_output = self.attn1(
norm_hidden_states,
encoder_hidden_states=encoder_hidden_states,
- attention_mask=attention_mask,
- # encoder_attention_mask=encoder_attention_mask,
+ attention_mask=encoder_attention_mask if encoder_hidden_states is not None else attention_mask,
)
if self.final_dropout:
attn_output = self.final_dropout(attn_output)
@@ -266,8 +270,8 @@ class DiT(ModelMixin, ConfigMixin):
self.norm_out = nn.LayerNorm(self.inner_dim, elementwise_affine=False, eps=1e-6)
self.proj_out_1 = nn.Linear(self.inner_dim, 2 * self.inner_dim)
self.proj_out_2 = nn.Linear(self.inner_dim, self.config.output_dim)
- print(
- "Total number of DiT parameters: ",
+ logger.debug(
+ "Total number of DiT parameters: %d",
sum(p.numel() for p in self.parameters() if p.requires_grad),
)
@@ -318,6 +322,71 @@ class DiT(ModelMixin, ConfigMixin):
return self.proj_out_2(hidden_states)
+class AlternateVLDiT(DiT):
+ """N1.7 DiT variant that alternates cross-attention over image and text tokens."""
+
+ def __init__(self, *args, attend_text_every_n_blocks: int = 2, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.attend_text_every_n_blocks = attend_text_every_n_blocks
+
+ def forward(
+ self,
+ hidden_states: torch.Tensor,
+ encoder_hidden_states: torch.Tensor,
+ timestep: torch.LongTensor | None = None,
+ encoder_attention_mask: torch.Tensor | None = None,
+ return_all_hidden_states: bool = False,
+ image_mask: torch.Tensor | None = None,
+ backbone_attention_mask: torch.Tensor | None = None,
+ ):
+ if image_mask is None:
+ raise ValueError("image_mask is required for AlternateVLDiT.")
+ if backbone_attention_mask is None:
+ raise ValueError("backbone_attention_mask is required for AlternateVLDiT.")
+
+ temb = self.timestep_encoder(timestep)
+ hidden_states = hidden_states.contiguous()
+ encoder_hidden_states = encoder_hidden_states.contiguous()
+
+ image_attention_mask = image_mask & backbone_attention_mask
+ non_image_attention_mask = (~image_mask) & backbone_attention_mask
+
+ all_hidden_states = [hidden_states]
+ if not self.config.interleave_self_attention:
+ raise ValueError("AlternateVLDiT requires interleave_self_attention=True.")
+
+ for idx, block in enumerate(self.transformer_blocks):
+ if idx % 2 == 1:
+ hidden_states = block(
+ hidden_states,
+ attention_mask=None,
+ encoder_hidden_states=None,
+ encoder_attention_mask=None,
+ temb=temb,
+ )
+ else:
+ curr_encoder_attention_mask = (
+ non_image_attention_mask
+ if idx % (2 * self.attend_text_every_n_blocks) == 0
+ else image_attention_mask
+ )
+ hidden_states = block(
+ hidden_states,
+ attention_mask=None,
+ encoder_hidden_states=encoder_hidden_states,
+ encoder_attention_mask=curr_encoder_attention_mask,
+ temb=temb,
+ )
+ all_hidden_states.append(hidden_states)
+
+ conditioning = temb
+ shift, scale = self.proj_out_1(F.silu(conditioning)).chunk(2, dim=1)
+ hidden_states = self.norm_out(hidden_states) * (1 + scale[:, None]) + shift[:, None]
+ if return_all_hidden_states:
+ return self.proj_out_2(hidden_states), all_hidden_states
+ return self.proj_out_2(hidden_states)
+
+
class SelfAttentionTransformer(ModelMixin, ConfigMixin):
_supports_gradient_checkpointing = True
@@ -362,8 +431,8 @@ class SelfAttentionTransformer(ModelMixin, ConfigMixin):
for _ in range(self.config.num_layers)
]
)
- print(
- "Total number of SelfAttentionTransformer parameters: ",
+ logger.debug(
+ "Total number of SelfAttentionTransformer parameters: %d",
sum(p.numel() for p in self.parameters() if p.requires_grad),
)
diff --git a/src/lerobot/policies/groot/action_head/flow_matching_action_head.py b/src/lerobot/policies/groot/action_head/flow_matching_action_head.py
deleted file mode 100644
index 986820670..000000000
--- a/src/lerobot/policies/groot/action_head/flow_matching_action_head.py
+++ /dev/null
@@ -1,408 +0,0 @@
-# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-# SPDX-License-Identifier: Apache-2.0
-#
-# 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 field
-from typing import TYPE_CHECKING
-
-import torch
-import torch.nn.functional as F # noqa: N812
-from torch import nn
-from torch.distributions import Beta
-
-from lerobot.utils.import_utils import _transformers_available
-
-# Conditional import for type checking and lazy loading
-if TYPE_CHECKING or _transformers_available:
- from transformers import PretrainedConfig
- from transformers.feature_extraction_utils import BatchFeature
-else:
- PretrainedConfig = object
- BatchFeature = None
-
-from .action_encoder import (
- SinusoidalPositionalEncoding,
- swish,
-)
-from .cross_attention_dit import DiT, SelfAttentionTransformer
-
-
-class CategorySpecificLinear(nn.Module):
- def __init__(self, num_categories, input_dim, hidden_dim):
- super().__init__()
- self.num_categories = num_categories
- # For each category, we have separate weights and biases.
- self.W = nn.Parameter(0.02 * torch.randn(num_categories, input_dim, hidden_dim))
- self.b = nn.Parameter(torch.zeros(num_categories, hidden_dim))
-
- def forward(self, x, cat_ids):
- selected_w = self.W[cat_ids]
- selected_b = self.b[cat_ids]
- return torch.bmm(x, selected_w) + selected_b.unsqueeze(1)
-
-
-class CategorySpecificMLP(nn.Module):
- def __init__(self, num_categories, input_dim, hidden_dim, output_dim):
- super().__init__()
- self.num_categories = num_categories
- self.layer1 = CategorySpecificLinear(num_categories, input_dim, hidden_dim)
- self.layer2 = CategorySpecificLinear(num_categories, hidden_dim, output_dim)
-
- def forward(self, x, cat_ids):
- hidden = F.relu(self.layer1(x, cat_ids))
- return self.layer2(hidden, cat_ids)
-
-
-class MultiEmbodimentActionEncoder(nn.Module):
- def __init__(self, action_dim, hidden_size, num_embodiments):
- super().__init__()
- self.hidden_size = hidden_size
- self.num_embodiments = num_embodiments
-
- # W1: R^{w x d}, W2: R^{w x 2w}, W3: R^{w x w}
- self.W1 = CategorySpecificLinear(num_embodiments, action_dim, hidden_size) # (d -> w)
- self.W2 = CategorySpecificLinear(num_embodiments, 2 * hidden_size, hidden_size) # (2w -> w)
- self.W3 = CategorySpecificLinear(num_embodiments, hidden_size, hidden_size) # (w -> w)
- self.pos_encoding = SinusoidalPositionalEncoding(hidden_size)
-
- def forward(self, actions, timesteps, cat_ids):
- """
- actions: shape (B, T, action_dim)
- timesteps: shape (B,) -- a single scalar per batch item
- cat_ids: shape (B,)
- returns: shape (B, T, hidden_size)
- """
- b, t, _ = actions.shape
-
- # 1) Expand each batch's single scalar time 'tau' across all T steps
- # so that shape => (B, T)
- # e.g. if timesteps is (B,), replicate across T
- if timesteps.dim() == 1 and timesteps.shape[0] == b:
- # shape (B,) => (B,T)
- timesteps = timesteps.unsqueeze(1).expand(-1, t)
- else:
- raise ValueError("Expected `timesteps` to have shape (B,) so we can replicate across T.")
-
- # 2) Standard action MLP step for shape => (B, T, w)
- a_emb = self.W1(actions, cat_ids)
-
- # 3) Get the sinusoidal encoding (B, T, w)
- tau_emb = self.pos_encoding(timesteps).to(dtype=a_emb.dtype)
-
- # 4) Concat along last dim => (B, T, 2w), then W2 => (B, T, w), swish
- x = torch.cat([a_emb, tau_emb], dim=-1)
- x = swish(self.W2(x, cat_ids))
-
- # 5) Finally W3 => (B, T, w)
- x = self.W3(x, cat_ids)
- return x
-
-
-class FlowmatchingActionHeadConfig(PretrainedConfig):
- """NOTE: N1.5 uses XEmbFlowmatchingPolicyHeadConfig as action head"""
-
- add_pos_embed: bool = field(default=True, metadata={"help": "Whether to add positional embedding"})
- model_dtype: str = field(default="float32", metadata={"help": "Model data type."})
- diffusion_model_cfg: dict = field(default=None, metadata={"help": "Diffusion model configuration."})
- input_embedding_dim: int = field(default=1536, metadata={"help": "Input embedding channel dimension."})
- backbone_embedding_dim: int = field(
- default=1536, metadata={"help": "Backbone embedding channel dimension."}
- )
-
- hidden_size: int = field(default=1024, metadata={"help": "Input embedding dimension."})
- max_seq_len: int = field(default=1024, metadata={"help": "Maximum Sequence Length"})
- action_dim: int = field(default=None, metadata={"help": "Action dimension."})
- action_horizon: int = field(default=None, metadata={"help": "Action horizon."})
- noise_beta_alpha: float = field(default=1.5, metadata={"help": ""})
- noise_beta_beta: float = field(default=1.0, metadata={"help": ""})
- noise_s: float = field(default=0.999, metadata={"help": "Flow matching noise Beta distribution s."})
- num_timestep_buckets: int = field(
- default=1000, metadata={"help": "Number of timestep discretization buckets."}
- )
- num_inference_timesteps: int = field(
- default=None,
- metadata={"help": "Number of inference steps for noise diffusion."},
- )
- max_num_embodiments: int = field(default=32, metadata={"help": "Number of embodiments."})
- tune_projector: bool = field(default=True, metadata={"help": "Whether to tune the projector."})
- tune_diffusion_model: bool = field(
- default=True, metadata={"help": "Whether to tune the diffusion model."}
- )
- load_pretrained_det_decode_layer_path: str = field(
- default=None, metadata={"help": "Path to pretrained detection model."}
- )
- detection_coeff: float = field(default=1.0, metadata={"help": "Detection coefficient."})
-
- freeze_decode_layer: bool = field(default=False)
- expand_batch: int = field(default=None)
- use_vlln: bool = field(default=True)
-
- vl_self_attention_cfg: dict = field(default=None)
- num_target_vision_tokens: int = field(default=32, metadata={"help": "Number of target vision tokens."})
-
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
- for key, value in kwargs.items():
- setattr(self, key, value)
-
-
-class FlowmatchingActionHead(nn.Module):
- config_class = FlowmatchingActionHeadConfig
- supports_gradient_checkpointing = True
-
- def __init__(
- self,
- config: FlowmatchingActionHeadConfig,
- ):
- super().__init__()
- self.hidden_size = config.hidden_size
- self.input_embedding_dim = config.input_embedding_dim
-
- self.model = DiT(**config.diffusion_model_cfg)
- self.action_dim = config.action_dim
- self.action_horizon = config.action_horizon
- self.num_inference_timesteps = config.num_inference_timesteps
-
- self.state_encoder = CategorySpecificMLP(
- num_categories=config.max_num_embodiments,
- input_dim=config.max_state_dim,
- hidden_dim=self.hidden_size,
- output_dim=self.input_embedding_dim,
- )
- self.action_encoder = MultiEmbodimentActionEncoder(
- action_dim=config.action_dim,
- hidden_size=self.input_embedding_dim,
- num_embodiments=config.max_num_embodiments,
- )
- self.action_decoder = CategorySpecificMLP(
- num_categories=config.max_num_embodiments,
- input_dim=self.hidden_size,
- hidden_dim=self.hidden_size,
- output_dim=self.action_dim,
- )
- self.future_tokens = nn.Embedding(config.num_target_vision_tokens, self.input_embedding_dim)
- nn.init.normal_(self.future_tokens.weight, mean=0.0, std=0.02)
-
- self.vlln = nn.LayerNorm(config.backbone_embedding_dim) if config.use_vlln else nn.Identity()
- self.vl_self_attention = (
- SelfAttentionTransformer(**config.vl_self_attention_cfg) if config.use_vlln else nn.Identity()
- )
-
- if config.add_pos_embed:
- self.position_embedding = nn.Embedding(config.max_seq_len, self.input_embedding_dim)
- nn.init.normal_(self.position_embedding.weight, mean=0.0, std=0.02)
-
- self._noise_beta_alpha = config.noise_beta_alpha
- self._noise_beta_beta = config.noise_beta_beta
- self._beta_dist = None
- self.num_timestep_buckets = config.num_timestep_buckets
- self.config = config
- self.set_trainable_parameters(config.tune_projector, config.tune_diffusion_model)
-
- def set_trainable_parameters(self, tune_projector: bool, tune_diffusion_model: bool):
- self.tune_projector = tune_projector
- self.tune_diffusion_model = tune_diffusion_model
- for p in self.parameters():
- p.requires_grad = True
- if not tune_projector:
- self.state_encoder.requires_grad_(False)
- self.action_encoder.requires_grad_(False)
- self.action_decoder.requires_grad_(False)
- if self.config.add_pos_embed:
- self.position_embedding.requires_grad_(False)
- if not tune_diffusion_model:
- self.model.requires_grad_(False)
- print(f"Tune action head projector: {self.tune_projector}")
- print(f"Tune action head diffusion model: {self.tune_diffusion_model}")
- # Check if any parameters are still trainable. If not, print a warning.
- if not tune_projector and not tune_diffusion_model:
- for name, p in self.named_parameters():
- if p.requires_grad:
- print(f"Action head trainable parameter: {name}")
- if not any(p.requires_grad for p in self.parameters()):
- print("Warning: No action head trainable parameters found.")
-
- def set_frozen_modules_to_eval_mode(self):
- """
- Huggingface will call model.train() at each training_step. To ensure
- the expected behaviors for modules like dropout, batchnorm, etc., we
- need to call model.eval() for the frozen modules.
- """
- if self.training:
- if not self.tune_projector:
- self.state_encoder.eval()
- self.action_encoder.eval()
- self.action_decoder.eval()
- if self.config.add_pos_embed:
- self.position_embedding.eval()
- if not self.tune_diffusion_model:
- self.model.eval()
-
- def sample_time(self, batch_size, device, dtype):
- if self._beta_dist is None:
- self._beta_dist = Beta(self._noise_beta_alpha, self._noise_beta_beta, validate_args=False)
- sample = self._beta_dist.sample([batch_size]).to(device, dtype=dtype)
- return (self.config.noise_s - sample) / self.config.noise_s
-
- def prepare_input(self, batch: dict) -> BatchFeature:
- return BatchFeature(data=batch)
-
- def process_backbone_output(self, backbone_output: BatchFeature) -> BatchFeature:
- backbone_features = backbone_output["backbone_features"]
- backbone_features = self.vlln(backbone_features)
- backbone_features = self.vl_self_attention(backbone_features)
- backbone_output["backbone_features"] = backbone_features
- return backbone_output
-
- def forward(self, backbone_output: BatchFeature, action_input: BatchFeature) -> BatchFeature:
- # Set frozen modules to eval
- self.set_frozen_modules_to_eval_mode()
-
- backbone_output = self.process_backbone_output(backbone_output)
-
- if self.config.expand_batch is not None:
- for k, v in backbone_output.items():
- ndim = len(v.shape)
- factors = [self.config.expand_batch]
- while len(factors) < ndim:
- factors.append(1)
- factors = tuple(factors)
- expanded = v.repeat(*factors)
- backbone_output[k] = expanded
-
- for k, v in action_input.items():
- ndim = len(v.shape)
- factors = [self.config.expand_batch]
- while len(factors) < ndim:
- factors.append(1)
- factors = tuple(factors)
- expanded = v.repeat(*factors)
- action_input[k] = expanded
-
- # Get vision and language embeddings.
- vl_embs = backbone_output.backbone_features
- device = vl_embs.device
-
- # Get embodiment ID.
- embodiment_id = action_input.embodiment_id
-
- # Embed state.
- state_features = self.state_encoder(action_input.state, embodiment_id)
-
- # Embed noised action trajectory.
- actions = action_input.action
- noise = torch.randn(actions.shape, device=actions.device, dtype=actions.dtype)
- t = self.sample_time(actions.shape[0], device=actions.device, dtype=actions.dtype)
- t = t[:, None, None] # shape (B,1,1) for broadcast
-
- noisy_trajectory = (1 - t) * noise + t * actions
- velocity = actions - noise
-
- # Convert (continuous) t -> discrete if needed
- t_discretized = (t[:, 0, 0] * self.num_timestep_buckets).long()
- action_features = self.action_encoder(noisy_trajectory, t_discretized, embodiment_id)
-
- # Maybe add position embedding.
- if self.config.add_pos_embed:
- pos_ids = torch.arange(action_features.shape[1], dtype=torch.long, device=device)
- pos_embs = self.position_embedding(pos_ids).unsqueeze(0)
- action_features = action_features + pos_embs
-
- # Join vision, language, state and action embedding along sequence dimension.
- future_tokens = self.future_tokens.weight.unsqueeze(0).expand(vl_embs.shape[0], -1, -1)
- sa_embs = torch.cat((state_features, future_tokens, action_features), dim=1)
-
- vl_attn_mask = backbone_output.backbone_attention_mask
-
- model_output = self.model(
- hidden_states=sa_embs,
- encoder_hidden_states=vl_embs,
- encoder_attention_mask=vl_attn_mask,
- timestep=t_discretized,
- return_all_hidden_states=False, # NOTE (YL): not using flare now
- )
- pred = self.action_decoder(model_output, embodiment_id)
- pred_actions = pred[:, -actions.shape[1] :]
-
- # Slice out only the action portion of pred and target.
- action_mask = action_input.action_mask
- loss = F.mse_loss(pred_actions, velocity, reduction="none") * action_mask
- loss = loss.sum() / action_mask.sum()
- output_dict = {
- "loss": loss,
- }
- return BatchFeature(data=output_dict)
-
- @torch.no_grad()
- def get_action(self, backbone_output: BatchFeature, action_input: BatchFeature) -> BatchFeature:
- backbone_output = self.process_backbone_output(backbone_output)
-
- # Get vision and language embeddings.
- vl_embs = backbone_output.backbone_features
- embodiment_id = action_input.embodiment_id
-
- # Embed state.
- state_features = self.state_encoder(action_input.state, embodiment_id)
-
- # Set initial actions as the sampled noise.
- batch_size = vl_embs.shape[0]
- device = vl_embs.device
- actions = torch.randn(
- size=(batch_size, self.config.action_horizon, self.config.action_dim),
- dtype=vl_embs.dtype,
- device=device,
- )
-
- num_steps = self.num_inference_timesteps
- dt = 1.0 / num_steps
-
- # Run denoising steps.
- for t in range(num_steps):
- t_cont = t / float(num_steps) # e.g. goes 0, 1/N, 2/N, ...
- t_discretized = int(t_cont * self.num_timestep_buckets)
-
- # Embed noised action trajectory.
- timesteps_tensor = torch.full(size=(batch_size,), fill_value=t_discretized, device=device)
- action_features = self.action_encoder(actions, timesteps_tensor, embodiment_id)
- # Maybe add position embedding.
- if self.config.add_pos_embed:
- pos_ids = torch.arange(action_features.shape[1], dtype=torch.long, device=device)
- pos_embs = self.position_embedding(pos_ids).unsqueeze(0)
- action_features = action_features + pos_embs
-
- # Join vision, language, state and action embedding along sequence dimension.
- future_tokens = self.future_tokens.weight.unsqueeze(0).expand(vl_embs.shape[0], -1, -1)
- sa_embs = torch.cat((state_features, future_tokens, action_features), dim=1)
-
- # Run model forward.
- model_output = self.model(
- hidden_states=sa_embs,
- encoder_hidden_states=vl_embs,
- timestep=timesteps_tensor,
- )
- pred = self.action_decoder(model_output, embodiment_id)
-
- pred_velocity = pred[:, -self.action_horizon :]
-
- # Update actions using euler integration.
- actions = actions + dt * pred_velocity
- return BatchFeature(data={"action_pred": actions})
-
- @property
- def device(self):
- return next(iter(self.parameters())).device
-
- @property
- def dtype(self):
- return next(iter(self.parameters())).dtype
diff --git a/src/lerobot/policies/groot/configuration_groot.py b/src/lerobot/policies/groot/configuration_groot.py
index 17cb631d7..97e08bb76 100644
--- a/src/lerobot/policies/groot/configuration_groot.py
+++ b/src/lerobot/policies/groot/configuration_groot.py
@@ -14,12 +14,229 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import logging
+import math
from dataclasses import dataclass, field
+from pathlib import Path
from lerobot.configs import FeatureType, NormalizationMode, PolicyFeature, PreTrainedConfig
-from lerobot.optim import AdamWConfig, CosineDecayWithWarmupSchedulerConfig
+from lerobot.optim import AdamWConfig, DiffuserSchedulerConfig
from lerobot.utils.constants import ACTION, OBS_STATE
+from .utils import read_json
+
+logger = logging.getLogger(__name__)
+
+GROOT_N1_7 = "n1.7"
+# Legacy GR00T N1.5 identifier. N1.5 is NOT a supported model_version (it is
+# intentionally absent from _GROOT_MODEL_VERSION_ALIASES so normalize_groot_model_version
+# still rejects it). It is retained only so that infer_groot_model_version can recognise
+# an N1.5 base path/checkpoint and the N1.7 config/loader can reject the mismatch.
+GROOT_N1_5 = "n1.5"
+# Canonical guidance appended to every error raised when an N1.5 checkpoint, config,
+# or processor pipeline is detected. Keep this message in sync with docs/source/groot.mdx.
+GROOT_N1_5_REMOVAL_GUIDANCE = (
+ "GR00T N1.5 support was removed from LeRobot. "
+ "To keep using an N1.5 checkpoint, pin the last release that supports it: "
+ "`pip install 'lerobot==0.5.1'`. To use the current release, migrate to GR00T N1.7 "
+ "(model_version='n1.7', base model nvidia/GR00T-N1.7-3B)."
+)
+GROOT_N1_7_BASE_MODEL = "nvidia/GR00T-N1.7-3B"
+GROOT_N1_7_BACKBONE_MODEL = "nvidia/Cosmos-Reason2-2B"
+# Default GR00T N1.7 training resolution. Fallback if processor_config lacks sizing. Prevents mismatched
+# full-res patchification by forcing a resize. Mirrored by GR00T_N1_7_DEFAULTS in groot_n1_7.py.
+N1_7_DEFAULT_IMAGE_TARGET_SIZE = (256, 256)
+N1_7_DEFAULT_IMAGE_CROP_SIZE = (230, 230)
+GROOT_ACTION_DECODE_TRANSFORM_LIBERO = "libero"
+# Sentinel meaning "the user did not pick an action decode transform": __post_init__ resolves it
+# to the embodiment default ('libero' for 'libero_sim', otherwise None). It is distinct from an
+# explicit 'none' (resolved to None) so an opt-out survives a draccus save/load round-trip.
+GROOT_ACTION_DECODE_TRANSFORM_AUTO = "auto"
+
+_GROOT_MODEL_VERSION_ALIASES = {
+ "n1.7": GROOT_N1_7,
+ "n1_7": GROOT_N1_7,
+ "n1d7": GROOT_N1_7,
+ "n17": GROOT_N1_7,
+ "1.7": GROOT_N1_7,
+}
+
+# Legacy N1.5 spellings, kept ONLY so they can be detected and rejected with
+# GROOT_N1_5_REMOVAL_GUIDANCE (see GROOT_N1_5 above). Never map these to a supported version.
+_GROOT_N1_5_VERSION_ALIASES = {"n1.5", "n1_5", "n1d5", "n15", "1.5"}
+
+_GROOT_ACTION_DECODE_TRANSFORM_ALIASES = {
+ GROOT_ACTION_DECODE_TRANSFORM_AUTO: GROOT_ACTION_DECODE_TRANSFORM_AUTO,
+ "none": None,
+ "": None,
+ GROOT_ACTION_DECODE_TRANSFORM_LIBERO: GROOT_ACTION_DECODE_TRANSFORM_LIBERO,
+}
+
+
+def normalize_groot_model_version(model_version: str) -> str:
+ normalized = _GROOT_MODEL_VERSION_ALIASES.get(model_version.lower())
+ if normalized is None:
+ supported = GROOT_N1_7
+ message = f"Unsupported GR00T model_version '{model_version}'. Supported versions: {supported}."
+ if model_version.lower() in _GROOT_N1_5_VERSION_ALIASES:
+ message = f"{message} {GROOT_N1_5_REMOVAL_GUIDANCE}"
+ raise ValueError(message)
+ return normalized
+
+
+def normalize_groot_action_decode_transform(transform: str | None) -> str | None:
+ if transform is None:
+ return None
+ normalized = _GROOT_ACTION_DECODE_TRANSFORM_ALIASES.get(transform.lower())
+ if normalized is None and transform.lower() not in _GROOT_ACTION_DECODE_TRANSFORM_ALIASES:
+ supported = ", ".join(
+ sorted(key for key, value in _GROOT_ACTION_DECODE_TRANSFORM_ALIASES.items() if value is not None)
+ )
+ raise ValueError(
+ f"Unsupported GR00T N1.7 action decode transform '{transform}'. "
+ f"Supported transforms: none, {supported}."
+ )
+ return normalized
+
+
+def infer_groot_model_version(model_path: str | None) -> str | None:
+ if not model_path:
+ return None
+ model_path_lower = model_path.lower()
+ if "gr00t-n1.7" in model_path_lower or "gr00t_n1.7" in model_path_lower:
+ return GROOT_N1_7
+ # Detect legacy N1.5 paths so the N1.7 config/loader can reject the mismatch.
+ # N1.5 is unsupported, but it must still be recognised here to fail loudly
+ # rather than silently treating an N1.5 checkpoint as N1.7.
+ if "gr00t-n1.5" in model_path_lower or "gr00t_n1.5" in model_path_lower:
+ return GROOT_N1_5
+ config_version = _infer_groot_model_version_from_local_config(model_path)
+ if config_version is not None:
+ return config_version
+ return None
+
+
+def is_raw_groot_n1_7_checkpoint(model_path: str | Path | None) -> bool:
+ if model_path is None:
+ return False
+
+ path = Path(model_path).expanduser()
+ if path.is_dir():
+ config_path = path / "config.json"
+ elif path.name == "config.json":
+ config_path = path
+ else:
+ return False
+
+ config = read_json(config_path)
+ return "type" not in config and _infer_groot_model_version_from_config(config) == GROOT_N1_7
+
+
+def infer_groot_n1_7_embodiment_tag(model_path: str | Path | None) -> str | None:
+ if model_path is None:
+ return None
+
+ processor_config_path = Path(model_path).expanduser() / "processor_config.json"
+ processor_config = read_json(processor_config_path)
+
+ modality_configs = processor_config.get("processor_kwargs", {}).get("modality_configs", {})
+ if not isinstance(modality_configs, dict):
+ return None
+ if "libero_sim" in modality_configs:
+ return "libero_sim"
+ if len(modality_configs) == 1:
+ return next(iter(modality_configs))
+ return None
+
+
+def infer_groot_n1_7_action_horizon(
+ model_path: str | Path | None, embodiment_tag: str | None = None
+) -> int | None:
+ if model_path is None:
+ return None
+
+ processor_config_path = Path(model_path).expanduser() / "processor_config.json"
+ processor_config = read_json(processor_config_path)
+
+ processor_kwargs = processor_config.get("processor_kwargs", {})
+ if not isinstance(processor_kwargs, dict):
+ return None
+ modality_configs = processor_kwargs.get("modality_configs", {})
+ if not isinstance(modality_configs, dict):
+ return None
+
+ if embodiment_tag is None:
+ embodiment_tag = infer_groot_n1_7_embodiment_tag(model_path)
+ if embodiment_tag is None:
+ return None
+
+ embodiment_config = modality_configs.get(embodiment_tag, {})
+ if not isinstance(embodiment_config, dict):
+ return None
+ action_config = embodiment_config.get("action", {})
+ if not isinstance(action_config, dict):
+ return None
+ delta_indices = action_config.get("delta_indices", [])
+ if not isinstance(delta_indices, list):
+ return None
+ return len(delta_indices) or None
+
+
+def infer_groot_n1_7_action_execution_horizon(
+ model_path: str | Path | None, embodiment_tag: str | None = None
+) -> int | None:
+ action_horizon = infer_groot_n1_7_action_horizon(model_path, embodiment_tag)
+ if action_horizon is None:
+ return None
+
+ if embodiment_tag is None:
+ embodiment_tag = infer_groot_n1_7_embodiment_tag(model_path)
+ if embodiment_tag == "libero_sim":
+ # NVIDIA's N1.7 LIBERO rollout wrapper replans after 8 of the 16 decoded
+ # actions. Keeping that execution cadence avoids stale open-loop chunks.
+ return min(action_horizon, 8)
+ return action_horizon
+
+
+def _infer_groot_model_version_from_local_config(model_path: str) -> str | None:
+ path = Path(model_path).expanduser()
+ if path.is_dir():
+ config_path = path / "config.json"
+ elif path.name == "config.json":
+ config_path = path
+ else:
+ return None
+
+ return _infer_groot_model_version_from_config(read_json(config_path))
+
+
+def _infer_groot_model_version_from_config(config: dict) -> str | None:
+ model_version = config.get("model_version")
+ if isinstance(model_version, str):
+ if model_version.lower() in _GROOT_N1_5_VERSION_ALIASES:
+ return GROOT_N1_5
+ try:
+ return normalize_groot_model_version(model_version)
+ except ValueError:
+ return None
+
+ candidates = [config.get("model_type"), *(config.get("architectures") or [])]
+ for candidate in candidates:
+ if not isinstance(candidate, str):
+ continue
+ normalized = candidate.lower().replace("-", "_")
+ if normalized in {"gr00tn1d7", "gr00t_n1d7", "gr00t_n1_7"}:
+ return GROOT_N1_7
+ if normalized in {"gr00t_n1_5", "gr00tn1_5", "gr00t_n15", "gr00t_n1d5", "gr00tn1d5"}:
+ return GROOT_N1_5
+ if config.get("model_name") == GROOT_N1_7_BACKBONE_MODEL:
+ return GROOT_N1_7
+ # The Eagle VLM backbone is specific to pre-N1.7 GR00T checkpoints (N1.7 uses Cosmos/Qwen3-VL).
+ backbone_cfg = config.get("backbone_cfg")
+ if isinstance(backbone_cfg, dict) and "eagle_path" in backbone_cfg:
+ return GROOT_N1_5
+ return None
+
@PreTrainedConfig.register_subclass("groot")
@dataclass
@@ -28,35 +245,44 @@ class GrootConfig(PreTrainedConfig):
# Basic policy settings
n_obs_steps: int = 1
- chunk_size: int = 50
- n_action_steps: int = 50
+ chunk_size: int = 40
+ n_action_steps: int = 40
# Dimension settings (must match pretrained GR00T model expectations)
# Maximum state dimension. Shorter states will be zero-padded.
- max_state_dim: int = 64
+ max_state_dim: int = 132
# Maximum action dimension. Shorter actions will be zero-padded.
- max_action_dim: int = 32
+ max_action_dim: int = 132
- # Normalization (start with identity, adjust as needed)
+ # GR00T normalizes state/action internally in its processor steps (min/max with
+ # q01/q99 percentiles, per embodiment), and the Qwen3-VL backbone's image processor
+ # handles image normalization. The policy therefore does NOT use LeRobot's
+ # NormalizerProcessorStep/UnnormalizerProcessorStep, so this mapping is intentionally
+ # IDENTITY for every feature and is not consulted by make_groot_pre_post_processors.
normalization_mapping: dict[str, NormalizationMode] = field(
default_factory=lambda: {
"VISUAL": NormalizationMode.IDENTITY,
- "STATE": NormalizationMode.MEAN_STD,
- "ACTION": NormalizationMode.MEAN_STD,
+ "STATE": NormalizationMode.IDENTITY,
+ "ACTION": NormalizationMode.IDENTITY,
}
)
- # Image preprocessing (adjust to match Groot's expected input)
- image_size: tuple[int, int] = (224, 224)
+ # Groot-specific model parameters
- # Groot-specific model parameters (from groot_finetune_script.py)
+ # Path or HuggingFace model ID for the base GR00T N1.7 model whose backbone weights and
+ # checkpoint sidecars (statistics.json, processor_config.json, ...) are loaded. This is the
+ # model *source*, and is intentionally distinct from the inherited `pretrained_path`:
+ # `pretrained_path` (`--policy.path`) points at a saved LeRobot checkpoint directory whose
+ # `config.json` carries a `type` field, whereas a raw NVIDIA GR00T checkpoint has no such
+ # field and so can only be loaded through `base_model_path` (`--policy.base_model_path`).
+ # Defaults to GROOT_N1_7_BASE_MODEL when unset (resolved in __post_init__).
+ base_model_path: str | None = None
- # Path or HuggingFace model ID for the base Groot model
- base_model_path: str = "nvidia/GR00T-N1.5-3B"
-
- # HF repo ID (or local path) that hosts vocab.json and merges.txt for Eagle tokenizer.
- tokenizer_assets_repo: str = "lerobot/eagle2hg-processor-groot-n1p5"
+ # Optional named action transform applied after raw N1.7 checkpoint decoding and before env.step().
+ # 'auto' (default) resolves to the embodiment default ('libero' for 'libero_sim', otherwise no
+ # transform). Pass 'none' to explicitly disable the transform, including for 'libero_sim'.
+ action_decode_transform: str | None = GROOT_ACTION_DECODE_TRANSFORM_AUTO
# Embodiment tag to use for training (e.g. 'new_embodiment', 'gr1')
embodiment_tag: str = "new_embodiment"
@@ -75,38 +301,67 @@ class GrootConfig(PreTrainedConfig):
# Whether to fine-tune the diffusion model
tune_diffusion_model: bool = True
- # LoRA parameters (from groot_finetune_script.py)
- # Rank for the LORA model. If 0, no LORA will be used.
- lora_rank: int = 0
+ # Whether to fine-tune the VL LayerNorm + VL self-attention projector in the action head.
+ tune_vlln: bool = True
- # Alpha value for the LORA model
- lora_alpha: int = 16
+ # Number of top LLM backbone layers to fine-tune (0 = none). Lets you adapt just the final
+ # language layers without unfreezing the whole backbone; independent of `tune_llm`, which tunes
+ # the entire LLM.
+ tune_top_llm_layers: int = 0
- # Dropout rate for the LORA model
- lora_dropout: float = 0.1
+ # Inference-time knob: Number of flow-matching denoising steps used to decode an action chunk.
+ # Trades inference latency for action quality.
+ # None keeps the checkpoint value (GR00T N1.7 default: 4).
+ num_inference_timesteps: int | None = None
- # Whether to use the full model for LORA
- lora_full_model: bool = False
+ # Inference-time knob: Real-Time Chunking (RTC) overlap-blend ramp rate, used when the RTC engine
+ # supplies a previous-chunk prefix. Higher values blend the overlapping prefix more aggressively.
+ # None keeps the checkpoint value (GR00T N1.7 default: 6.0).
+ rtc_ramp_rate: float | None = None
- # Training parameters (matching groot_finetune_script.py)
+ # Inference-time knob: Whether to request the flash-attention-2 kernel for the Qwen3-VL backbone.
+ # flash-attn is an optional, user-managed optimization; when it is absent (the default),
+ # the backbone transparently falls back to SDPA, which is numerically equivalent.
+ # Set to True only after installing a flash-attn build matching your torch/CUDA env.
+ use_flash_attention: bool = False
+
+ # Enable GR00T-style state-relative action chunks (action chunk expressed relative to the current
+ # observation state).
+ use_relative_actions: bool = False
+
+ # relative_exclude_joints names the action dimensions that stay absolute; the
+ # match is substring/case-insensitive against the dataset action feature names. With the empty
+ # default every dimension is treated as relative, including the gripper -- set e.g. ["gripper"] to
+ # keep the gripper absolute, matching the Isaac-GR00T single-arm + absolute-gripper convention.
+ relative_exclude_joints: list[str] = field(default_factory=list)
+
+ # Training parameters
optimizer_lr: float = 1e-4
- optimizer_betas: tuple[float, float] = (0.95, 0.999)
+ # Isaac-GR00T N1.7 fine-tunes with AdamW betas (0.9, 0.999).
+ optimizer_betas: tuple[float, float] = (0.9, 0.999)
optimizer_eps: float = 1e-8
optimizer_weight_decay: float = 1e-5
warmup_ratio: float = 0.05
use_bf16: bool = True
+ # The native N1.7 fine-tuning recipe keeps model parameters in FP32 and computes under BF16 autocast.
+ model_params_fp32: bool = True
- # Dataset parameters
- # Video backend to use for training ('decord' or 'torchvision_av')
+ # TODO(Steven): Remove these deprecated fields in a future release.
+ # Deprecated Isaac-GR00T runner / GR00T N1.5 fields, plus the (never-wired) LoRA fields — all
+ # unused by the LeRobot N1.7 implementation except the `tokenizer_assets_repo` N1.5 tripwire and
+ # the `image_size` legacy remap in __post_init__. They are kept ONLY so a config.json saved by an
+ # earlier lerobot release (notably a GR00T N1.5 checkpoint) still parses under draccus — which
+ # rejects unknown fields — and is then rejected with a clear N1.5 removal message rather than an
+ # opaque draccus decoding error.
+ image_size: tuple[int, int] = (256, 256) # image sizing is handled by the backbone's image processor.
+ tokenizer_assets_repo: str | None = None
+ lora_rank: int = 0
+ lora_alpha: int = 16
+ lora_dropout: float = 0.1
+ lora_full_model: bool = False
video_backend: str = "decord"
-
- # Whether to balance dataset weights in mixture datasets
balance_dataset_weights: bool = True
-
- # Whether to sample trajectories weighted by their length
balance_trajectory_weights: bool = True
-
- # Optional dataset paths for delegating training to Isaac-GR00T runner
dataset_paths: list[str] | None = None
output_dir: str = "./tmp/gr00t"
save_steps: int = 1000
@@ -117,6 +372,65 @@ class GrootConfig(PreTrainedConfig):
resume: bool = False
def __post_init__(self):
+ if self.tokenizer_assets_repo is not None:
+ raise ValueError(
+ "Config sets 'tokenizer_assets_repo', which only existed for GR00T N1.5; this looks "
+ f"like a legacy GR00T N1.5 checkpoint or config. {GROOT_N1_5_REMOVAL_GUIDANCE}"
+ )
+
+ self.action_decode_transform = normalize_groot_action_decode_transform(self.action_decode_transform)
+ if self.base_model_path is None:
+ self.base_model_path = GROOT_N1_7_BASE_MODEL
+
+ # The N1.7 LIBERO checkpoints emit a [0, 1] gripper action, but the LIBERO
+ # simulator expects the OpenVLA/[-1, 1] sign convention. NVIDIA's rollout
+ # wrapper applies this conversion; mirror it here so eval on the
+ # 'libero_sim' embodiment grasps correctly instead of scoring 0% success.
+ # This matches the embodiment-specific handling already done for the
+ # action execution horizon (see infer_groot_n1_7_action_execution_horizon).
+ # Only the 'auto' sentinel resolves to the embodiment default; an explicit
+ # 'none' (normalized to None above) keeps the transform disabled.
+ if self.action_decode_transform == GROOT_ACTION_DECODE_TRANSFORM_AUTO:
+ self.action_decode_transform = (
+ GROOT_ACTION_DECODE_TRANSFORM_LIBERO if self.embodiment_tag == "libero_sim" else None
+ )
+
+ # GR00T N1.5-era default values (e.g. --policy.chunk_size=50 from old commands or
+ # stale configs) are migrated to the values the N1.7 checkpoints expect, with a
+ # warning. The dataclass defaults are already the N1.7 values, so a plain
+ # GrootConfig() never triggers this.
+ legacy_default_remaps = (
+ ("max_state_dim", 64, 132),
+ ("max_action_dim", 32, 132),
+ ("chunk_size", 50, 40),
+ ("n_action_steps", 50, 40),
+ ("image_size", (224, 224), (256, 256)),
+ )
+ for field_name, legacy_value, n1_7_value in legacy_default_remaps:
+ current_value = getattr(self, field_name)
+ if isinstance(legacy_value, tuple):
+ current_value = tuple(current_value)
+ if current_value == legacy_value:
+ logger.warning(
+ "GrootConfig.%s=%s matches a legacy GR00T N1.5-era default; remapping it to %s, "
+ "the value expected by GR00T N1.7 checkpoints. Set a different value explicitly "
+ "if this is not what you want.",
+ field_name,
+ legacy_value,
+ n1_7_value,
+ )
+ setattr(self, field_name, n1_7_value)
+
+ inferred_version = infer_groot_model_version(self.base_model_path)
+ if inferred_version is not None and inferred_version != GROOT_N1_7:
+ message = (
+ f"GR00T model_version '{GROOT_N1_7}' does not match base_model_path "
+ f"'{self.base_model_path}', which looks like '{inferred_version}'."
+ )
+ if inferred_version == GROOT_N1_5:
+ message = f"{message} {GROOT_N1_5_REMOVAL_GUIDANCE}"
+ raise ValueError(message)
+
super().__post_init__()
if self.n_action_steps > self.chunk_size:
@@ -124,9 +438,6 @@ class GrootConfig(PreTrainedConfig):
f"n_action_steps ({self.n_action_steps}) cannot exceed chunk_size ({self.chunk_size})"
)
- # groot_repo_path is now optional since we ported the components
- # No validation needed
-
def validate_features(self) -> None:
"""Validate and set up input/output features for Groot."""
image_features = [key for key, feat in self.input_features.items() if feat.type == FeatureType.VISUAL]
@@ -173,15 +484,20 @@ class GrootConfig(PreTrainedConfig):
betas=self.optimizer_betas,
eps=self.optimizer_eps,
weight_decay=self.optimizer_weight_decay,
+ grad_clip_norm=1.0,
)
- def get_scheduler_preset(self) -> CosineDecayWithWarmupSchedulerConfig:
- """Return scheduler configuration."""
- return CosineDecayWithWarmupSchedulerConfig(
- num_warmup_steps=int(10000 * self.warmup_ratio), # 5% warmup by default
- num_decay_steps=10000, # Adjust based on training steps
- peak_lr=self.optimizer_lr,
- decay_lr=self.optimizer_lr * 0.1,
+ def get_scheduler_preset(self) -> DiffuserSchedulerConfig:
+ """Return scheduler configuration.
+
+ Isaac-GR00T uses the HF Trainer cosine schedule with ~5% warmup over the
+ actual training update count; DiffuserSchedulerConfig wraps the same
+ diffusers/transformers `get_scheduler("cosine")` implementation and
+ derives num_training_steps from the outer --steps value at runtime.
+ """
+ return DiffuserSchedulerConfig(
+ name="cosine",
+ num_warmup_steps=math.ceil(self.max_steps * self.warmup_ratio),
)
@property
@@ -192,7 +508,15 @@ class GrootConfig(PreTrainedConfig):
@property
def action_delta_indices(self) -> list[int]:
"""Return indices for delta actions."""
- return list(range(min(self.chunk_size, 16)))
+ model_action_horizon = (
+ infer_groot_n1_7_action_horizon(self.base_model_path, self.embodiment_tag) or 40
+ )
+ return list(range(min(self.chunk_size, model_action_horizon)))
+
+ @property
+ def drop_n_last_frames(self) -> int:
+ """Exclude episode tails that cannot supply a complete N1.7 action chunk."""
+ return max(0, len(self.action_delta_indices) - 1)
@property
def reward_delta_indices(self) -> None:
diff --git a/src/lerobot/policies/groot/eagle2_hg_model/configuration_eagle2_5_vl.py b/src/lerobot/policies/groot/eagle2_hg_model/configuration_eagle2_5_vl.py
deleted file mode 100755
index 526b4f7a2..000000000
--- a/src/lerobot/policies/groot/eagle2_hg_model/configuration_eagle2_5_vl.py
+++ /dev/null
@@ -1,135 +0,0 @@
-# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
-# SPDX-License-Identifier: Apache-2.0
-#
-# 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 copy
-
-from transformers.configuration_utils import PretrainedConfig
-from transformers.models.llama.configuration_llama import LlamaConfig
-from transformers.models.qwen2.configuration_qwen2 import Qwen2Config
-from transformers.models.qwen3.configuration_qwen3 import Qwen3Config
-from transformers.models.siglip.configuration_siglip import SiglipVisionConfig
-from transformers.utils import logging
-
-logger = logging.get_logger(__name__)
-
-
-class Eagle25VLConfig(PretrainedConfig):
- model_type = "eagle_2_5_vl"
- is_composition = True
- sub_configs = {"vision_config": SiglipVisionConfig, "text_config": Qwen2Config}
-
- def __init__(
- self,
- vision_config=None,
- text_config=None,
- use_backbone_lora=0,
- use_llm_lora=0,
- pad2square=False,
- select_layer=-4,
- force_image_size=None,
- downsample_ratio=0.5,
- template=None,
- dynamic_image_size=False,
- use_thumbnail=False,
- loss_version="v1",
- min_dynamic_tiles=1,
- max_dynamic_tiles=6,
- mlp_checkpoint=False,
- initializer_range=0.02,
- _attn_implementation="flash_attention_2",
- _attn_implementation_autoset=False,
- llm_config=None,
- image_token_index=None,
- use_pixel_shuffle=True,
- mlp_connector_layers=2,
- **kwargs,
- ):
- super().__init__(**kwargs)
-
- if vision_config is None:
- vision_config = {"model_type": "siglip_vision_model"}
- logger.info("vision_config is None. Initializing the InternVisionConfig with default values.")
-
- if text_config is None:
- text_config = {"architectures": ["Qwen2ForCausalLM"]}
- logger.info(
- "text_config is None. Initializing the LlamaConfig config with default values (`LlamaConfig`)."
- )
-
- if vision_config["model_type"] == "siglip_vision_model":
- self.vision_config = SiglipVisionConfig(**vision_config)
- else:
- raise ValueError("Unsupported model_type: {}".format(vision_config["model_type"]))
-
- if text_config["architectures"][0] == "LlamaForCausalLM":
- self.text_config = LlamaConfig(**text_config)
- elif text_config["architectures"][0] == "Qwen2ForCausalLM":
- self.text_config = Qwen2Config(**text_config)
- elif text_config["architectures"][0] == "Qwen3ForCausalLM":
- self.text_config = Qwen3Config(**text_config)
- else:
- raise ValueError("Unsupported architecture: {}".format(text_config["architectures"][0]))
- self.use_backbone_lora = use_backbone_lora
- self.use_llm_lora = use_llm_lora
- self.mlp_checkpoint = mlp_checkpoint
- self.pad2square = pad2square
- self.select_layer = select_layer
- self.force_image_size = force_image_size
- self.downsample_ratio = downsample_ratio
- self.template = template
- self.dynamic_image_size = dynamic_image_size
- self.use_thumbnail = use_thumbnail
- self.loss_version = loss_version
- self.initializer_range = initializer_range
- self.min_dynamic_tiles = min_dynamic_tiles
- self.max_dynamic_tiles = max_dynamic_tiles
- self.tie_word_embeddings = self.text_config.tie_word_embeddings
- self._attn_implementation = _attn_implementation
- self._attn_implementation_autoset = _attn_implementation_autoset
- self.image_token_index = image_token_index
- self.use_pixel_shuffle = use_pixel_shuffle
- self.mlp_connector_layers = mlp_connector_layers
- logger.info(f"min_dynamic_tiles: {self.min_dynamic_tiles}")
- logger.info(f"max_dynamic_tiles: {self.max_dynamic_tiles}")
-
- def to_dict(self):
- """
- Serializes this instance to a Python dictionary. Override the default [`~PretrainedConfig.to_dict`].
-
- Returns:
- `Dict[str, any]`: Dictionary of all the attributes that make up this configuration instance,
- """
- output = copy.deepcopy(self.__dict__)
- output["vision_config"] = self.vision_config.to_dict()
- output["text_config"] = self.text_config.to_dict()
- output["model_type"] = self.__class__.model_type
- output["use_backbone_lora"] = self.use_backbone_lora
- output["use_llm_lora"] = self.use_llm_lora
- output["pad2square"] = self.pad2square
- output["select_layer"] = self.select_layer
- output["force_image_size"] = self.force_image_size
- output["downsample_ratio"] = self.downsample_ratio
- output["template"] = self.template
- output["dynamic_image_size"] = self.dynamic_image_size
- output["use_thumbnail"] = self.use_thumbnail
- output["min_dynamic_tiles"] = self.min_dynamic_tiles
- output["max_dynamic_tiles"] = self.max_dynamic_tiles
- output["tie_word_embeddings"] = self.tie_word_embeddings
- output["_attn_implementation"] = self._attn_implementation
- output["_attn_implementation_autoset"] = self._attn_implementation_autoset
- output["use_pixel_shuffle"] = self.use_pixel_shuffle
- output["mlp_connector_layers"] = self.mlp_connector_layers
- return output
diff --git a/src/lerobot/policies/groot/eagle2_hg_model/image_processing_eagle2_5_vl_fast.py b/src/lerobot/policies/groot/eagle2_hg_model/image_processing_eagle2_5_vl_fast.py
deleted file mode 100644
index 90e9dcecc..000000000
--- a/src/lerobot/policies/groot/eagle2_hg_model/image_processing_eagle2_5_vl_fast.py
+++ /dev/null
@@ -1,503 +0,0 @@
-# --------------------------------------------------------
-# NVIDIA
-# Copyright (c) 2025 NVIDIA
-# 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
-from transformers.image_processing_utils import (
- BatchFeature,
- get_patch_output_size,
-)
-from transformers.image_processing_utils_fast import (
- BaseImageProcessorFast,
- ImagesKwargs,
- group_images_by_shape,
- reorder_images,
-)
-from transformers.image_utils import (
- IMAGENET_STANDARD_MEAN, # 0.5, 0.5, 0.5
- IMAGENET_STANDARD_STD, # 0.5, 0.5, 0.5
- ChannelDimension,
- ImageInput,
- PILImageResampling,
- SizeDict,
- get_image_size,
- make_flat_list_of_images,
- validate_kwargs,
-)
-from transformers.processing_utils import Unpack
-from transformers.utils import (
- TensorType,
- add_start_docstrings,
- is_torch_available,
- is_torchvision_v2_available,
-)
-from transformers.video_utils import VideoInput
-
-if is_torch_available():
- import torch
-if is_torchvision_v2_available():
- from torchvision.transforms.v2 import functional as F # noqa: N812
- from transformers.image_utils import pil_torch_interpolation_mapping
-else:
- from torchvision.transforms import functional as F # noqa: N812
-
-
-def crop(img: torch.Tensor, left: int, top: int, right: int, bottom: int) -> torch.Tensor:
- """Crop the given numpy array.
-
- Args:
- img (torch.Tensor): Image to be cropped. Format should be (C, H, W).
- left (int): The left coordinate of the crop box.
- top (int): The top coordinate of the crop box.
- right (int): The right coordinate of the crop box.
- bottom (int): The bottom coordinate of the crop box.
-
- Returns:
- torch.Tensor: Cropped image.
- """
- if not isinstance(img, torch.Tensor):
- raise TypeError(f"img should be torch.Tensor. Got {type(img)}")
-
- if img.ndim not in [2, 3]:
- raise ValueError(f"Image should have 2 or 3 dimensions. Got {img.ndim}")
-
- img_height = img.shape[1]
- img_width = img.shape[2]
- if top < 0 or left < 0 or bottom > img_height or right > img_width:
- raise ValueError("Crop coordinates out of bounds")
-
- if top >= bottom or left >= right:
- raise ValueError("Invalid crop coordinates")
-
- return img[:, top:bottom, left:right]
-
-
-class Eagle25VLFastImageProcessorKwargs(ImagesKwargs):
- max_dynamic_tiles: int | None
- min_dynamic_tiles: int | None
- use_thumbnail: bool | None
- pad_during_tiling: bool | None
- do_pad: bool | None
-
-
-@add_start_docstrings(
- "Constructs a fast ConvNeXT image processor. Based on [`SiglipImageProcessor`] with incorporation of processing each video frame.",
- # BASE_IMAGE_PROCESSOR_FAST_DOCSTRING, TODO: this was depreciated from transformers remove!
- """
- image_grid_pinpoints (`List[List[int]]`, *optional*):
- A list of possible resolutions to use for processing high resolution images. The best resolution is selected
- based on the original size of the image. Can be overridden by `image_grid_pinpoints` in the `preprocess`
- method. Not used for processing videos.
- do_pad (`bool`, *optional*):
- Whether to pad the image. If `True`, will pad the patch dimension of the images in the batch to the largest
- number of patches in the batch. Padding will be applied to the bottom and right with zeros.
- """,
-)
-class Eagle25VLImageProcessorFast(BaseImageProcessorFast):
- resample = PILImageResampling.BICUBIC
- image_mean = IMAGENET_STANDARD_MEAN
- image_std = IMAGENET_STANDARD_STD
- size = {"height": 448, "width": 448}
- default_to_square = False
- crop_size = None
- do_resize = True
- do_center_crop = None
- do_rescale = True
- do_normalize = True
- do_convert_rgb = True
- do_pad = True
- max_dynamic_tiles = 12
- min_dynamic_tiles = 1
- use_thumbnail = True
- pad_during_tiling = False
- valid_kwargs = Eagle25VLFastImageProcessorKwargs
- model_input_names = ["pixel_values_videos"]
-
- def __init__(self, **kwargs: Unpack[Eagle25VLFastImageProcessorKwargs]):
- super().__init__(**kwargs)
-
- @add_start_docstrings(
- # BASE_IMAGE_PROCESSOR_FAST_DOCSTRING_PREPROCESS, TODO: this was depreciated from transformers remove!
- """
- max_dynamic_tiles (`int`, *optional*):
- The maximum number of dynamic tiles to use for processing high resolution images.
- min_dynamic_tiles (`int`, *optional*):
- The minimum number of dynamic tiles to use for processing high resolution images.
- use_thumbnail (`bool`, *optional*):
- Whether to use a thumbnail for processing high resolution images.
- pad_during_tiling (`bool`, *optional*):
- Whether to pad the image during tiling.
- do_pad (`bool`, *optional*):
- Whether to pad the image. If `True`, will pad the patch dimension of the images in the batch to the largest
- number of patches in the batch. Padding will be applied to the bottom and right with zeros.
- """,
- )
-
- # NOTE(YL): we will overload the preprocess method to add the image_flags
- # def preprocess(
- # self, images: ImageInput, **kwargs: Unpack[Eagle25VLFastImageProcessorKwargs]
- # ) -> BatchFeature:
- # return super().preprocess(images, **kwargs)
-
- def _prepare_images_structure(
- self,
- images: ImageInput,
- expected_ndims: int = 3,
- ) -> ImageInput:
- """
- Prepare the images structure for processing.
-
- Args:
- images (`ImageInput`):
- The input images to process.
- expected_ndims (`int`, *optional*, defaults to 3):
- Expected number of dimensions for the images (added for transformers >=4.53.0 compatibility).
-
- Returns:
- `ImageInput`: The images with a valid nesting.
- """
- return make_flat_list_of_images(images)
-
- def _resize_for_patching(
- self,
- image: torch.Tensor,
- target_resolution: tuple,
- interpolation: F.InterpolationMode,
- input_data_format: ChannelDimension,
- ) -> torch.Tensor:
- """
- Resizes an image to a target resolution while maintaining aspect ratio.
-
- Args:
- image ("torch.Tensor"):
- The input image.
- target_resolution (tuple):
- The target resolution (height, width) of the image.
- interpolation (`InterpolationMode`):
- Resampling filter to use if resizing the image.
- input_data_format (`ChannelDimension` or `str`):
- The channel dimension format of the input image.
-
- Returns:
- "torch.Tensor": The resized and padded image.
- """
- new_height, new_width = get_patch_output_size(image, target_resolution, input_data_format)
-
- # Resize the image
- resized_image = F.resize(image, (new_height, new_width), interpolation=interpolation)
-
- return resized_image
-
- def find_closest_aspect_ratio(self, aspect_ratio, target_ratios, width, height, image_size):
- """
- previous version mainly focus on ratio.
- We also consider area ratio here.
- """
- best_factor = float("-inf")
- best_ratio = (1, 1)
- area = width * height
- for ratio in target_ratios:
- target_aspect_ratio = ratio[0] / ratio[1]
- # ratio_diff = abs(aspect_ratio - target_aspect_ratio)
- # area_ratio = (ratio[0] * ratio[1] * image_size * image_size) / area
- """
- new area > 60% of original image area is enough.
- """
- factor_based_on_area_n_ratio = min(
- (ratio[0] * ratio[1] * image_size * image_size) / area, 0.6
- ) * min(target_aspect_ratio / aspect_ratio, aspect_ratio / target_aspect_ratio)
-
- if factor_based_on_area_n_ratio > best_factor:
- best_factor = factor_based_on_area_n_ratio
- best_ratio = ratio
-
- return best_ratio
-
- def _pad_for_patching(
- self, image: torch.Tensor, target_resolution: tuple, input_data_format: ChannelDimension
- ) -> torch.Tensor:
- """
- Pad an image to a target resolution while maintaining aspect ratio.
- """
- target_height, target_width = target_resolution
- new_height, new_width = get_patch_output_size(image, target_resolution, input_data_format)
-
- paste_x = (target_width - new_width) // 2
- paste_y = (target_height - new_height) // 2
-
- padded_image = F.pad(image, padding=[paste_x, paste_y, paste_x, paste_y])
-
- return padded_image
-
- def _get_image_patches(
- self,
- image: torch.Tensor,
- min_num: int,
- max_num: int,
- size: tuple,
- tile_size: int,
- use_thumbnail: bool,
- interpolation: F.InterpolationMode,
- pad_during_tiling: bool,
- ) -> list[torch.Tensor]:
- image_size = get_image_size(image, channel_dim=ChannelDimension.FIRST)
- orig_height, orig_width = image_size
- aspect_ratio = orig_width / orig_height
-
- # calculate the existing image aspect ratio
- target_ratios = {
- (i, j)
- for n in range(min_num, max_num + 1)
- for i in range(1, n + 1)
- for j in range(1, n + 1)
- if i * j <= max_num and i * j >= min_num
- }
- target_ratios = sorted(target_ratios, key=lambda x: x[0] * x[1])
-
- # find the closest aspect ratio to the target
- target_aspect_ratio = self.find_closest_aspect_ratio(
- aspect_ratio, target_ratios, orig_width, orig_height, tile_size
- )
-
- # calculate the target width and height
- target_width = tile_size * target_aspect_ratio[0]
- target_height = tile_size * target_aspect_ratio[1]
- blocks = target_aspect_ratio[0] * target_aspect_ratio[1]
- if pad_during_tiling:
- resized_image = self._resize_for_patching(
- image,
- (target_height, target_width),
- interpolation=interpolation,
- input_data_format=ChannelDimension.FIRST,
- )
- padded_image = self._pad_for_patching(
- resized_image,
- (target_height, target_width),
- input_data_format=ChannelDimension.FIRST,
- )
- image_used_to_split = padded_image
- else:
- image_used_to_split = F.resize(image, (target_height, target_width), interpolation=interpolation)
-
- processed_tiles = []
- for i in range(blocks):
- box = (
- (i % (target_width // tile_size)) * tile_size,
- (i // (target_width // tile_size)) * tile_size,
- ((i % (target_width // tile_size)) + 1) * tile_size,
- ((i // (target_width // tile_size)) + 1) * tile_size,
- )
- # split the image
- split_img = crop(image_used_to_split, box[0], box[1], box[2], box[3])
- processed_tiles.append(split_img)
- assert len(processed_tiles) == blocks
-
- if use_thumbnail and len(processed_tiles) != 1:
- thumbnail_img = F.resize(image, (tile_size, tile_size), interpolation=interpolation)
- processed_tiles.append(thumbnail_img)
-
- return processed_tiles
-
- def _pad_for_batching(
- self,
- pixel_values: 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.
-
- Args:
- pixel_values (`List[torch.Tensor]`):
- An array of pixel values of each images of shape (`batch_size`, `num_patches`, `image_in_3D`)
-
- Returns:
- List[`torch.Tensor`]: The padded images.
- """
- max_patch = max(len(x) for x in pixel_values)
- pixel_values = [
- torch.nn.functional.pad(image, pad=[0, 0, 0, 0, 0, 0, 0, max_patch - image.shape[0]])
- for image in pixel_values
- ]
-
- return pixel_values
-
- def _preprocess(
- self,
- images: list[torch.Tensor],
- do_resize: bool,
- size: SizeDict,
- max_dynamic_tiles: int,
- min_dynamic_tiles: int,
- use_thumbnail: bool,
- pad_during_tiling: bool,
- interpolation: F.InterpolationMode | None,
- do_center_crop: bool,
- crop_size: SizeDict,
- do_rescale: bool,
- rescale_factor: float,
- do_normalize: bool,
- image_mean: float | list[float] | None,
- image_std: float | list[float] | None,
- do_pad: bool,
- return_tensors: str | TensorType | None,
- pad_size: SizeDict | None = None, # Added for transformers >=4.53.0 compatibility
- disable_grouping: bool | None = None, # Added for transformers >=4.53.0 compatibility
- ) -> BatchFeature:
- processed_images = []
- image_sizes = []
- # Determine the size tuple
- if size and size.height and size.width:
- size_tuple = (size.height, size.width)
- else:
- size_tuple = (size.shortest_edge, size.shortest_edge)
-
- # Determine the patch size
- if crop_size and crop_size.height:
- tile_size = crop_size.height
- elif size and size.height:
- tile_size = size.height
- else:
- tile_size = size.shortest_edge
-
- for image in images:
- image_patches = self._get_image_patches(
- image,
- min_num=min_dynamic_tiles,
- max_num=max_dynamic_tiles,
- size=size_tuple,
- tile_size=tile_size,
- use_thumbnail=use_thumbnail,
- interpolation=interpolation,
- pad_during_tiling=pad_during_tiling,
- )
-
- # Group images by size for batched processing
- processed_image_patches_grouped = {}
- # Added for transformers >=4.53.0 compatibility
- grouped_image_patches, grouped_image_patches_index = group_images_by_shape(
- image_patches,
- disable_grouping=disable_grouping,
- )
-
- for shape, stacked_image_patches in grouped_image_patches.items():
- if do_resize:
- stacked_image_patches = self.resize(
- image=stacked_image_patches,
- size=size,
- interpolation=interpolation,
- )
- if do_center_crop:
- stacked_image_patches = self.center_crop(stacked_image_patches, crop_size)
- # Fused rescale and normalize
- stacked_image_patches = self.rescale_and_normalize(
- stacked_image_patches,
- do_rescale,
- rescale_factor,
- do_normalize,
- image_mean,
- image_std,
- )
- processed_image_patches_grouped[shape] = stacked_image_patches
- processed_image_patches = reorder_images(
- processed_image_patches_grouped, grouped_image_patches_index
- )
- processed_image_patches = (
- torch.stack(processed_image_patches, dim=0) if return_tensors else processed_image_patches
- )
- processed_images.append(processed_image_patches)
- image_sizes.append(get_image_size(image, ChannelDimension.FIRST))
-
- if do_pad:
- processed_images = self._pad_for_batching(processed_images)
-
- # processed_images = torch.stack(processed_images, dim=0) if return_tensors else processed_images
- processed_images = torch.cat(processed_images, dim=0) if return_tensors else processed_images
- return BatchFeature(
- data={"pixel_values": processed_images, "image_sizes": image_sizes},
- tensor_type=return_tensors,
- )
-
- def preprocess(
- self,
- images: ImageInput,
- videos: VideoInput = None,
- **kwargs: Unpack[Eagle25VLFastImageProcessorKwargs],
- ) -> BatchFeature:
- validate_kwargs(
- captured_kwargs=kwargs.keys(),
- valid_processor_keys=self.valid_kwargs.__annotations__.keys(),
- )
- # Set default kwargs from self. This ensures that if a kwarg is not provided
- # by the user, it gets its default value from the instance, or is set to None.
- for kwarg_name in self.valid_kwargs.__annotations__:
- kwargs.setdefault(kwarg_name, getattr(self, kwarg_name, None))
-
- # Extract parameters that are only used for preparing the input images
- do_convert_rgb = kwargs.pop("do_convert_rgb")
- input_data_format = kwargs.pop("input_data_format")
- device = kwargs.pop("device")
- # Prepare input images
- # transformers >= 4.53.0: uses _prepare_image_like_inputs instead of _prepare_input_images
- if images is not None:
- images = self._prepare_image_like_inputs(
- images=images,
- do_convert_rgb=do_convert_rgb,
- input_data_format=input_data_format,
- device=device,
- )
-
- if videos is not None:
- videos = self._prepare_image_like_inputs(
- images=videos,
- do_convert_rgb=do_convert_rgb,
- input_data_format=input_data_format,
- device=device,
- )
-
- # Update kwargs that need further processing before being validated
- kwargs = self._further_process_kwargs(**kwargs)
-
- # Validate kwargs
- self._validate_preprocess_kwargs(**kwargs)
-
- # torch resize uses interpolation instead of resample
- # Added for transformers >=4.53.0 compatibility
- resample = kwargs.pop("resample", self.resample)
- kwargs["interpolation"] = (
- pil_torch_interpolation_mapping[resample]
- if isinstance(resample, PILImageResampling | int)
- else resample
- )
-
- # Filter kwargs to only include those accepted by _preprocess
- valid_preprocess_kwargs = {
- "do_resize",
- "size",
- "max_dynamic_tiles",
- "min_dynamic_tiles",
- "use_thumbnail",
- "pad_during_tiling",
- "interpolation",
- "do_center_crop",
- "crop_size",
- "do_rescale",
- "rescale_factor",
- "do_normalize",
- "image_mean",
- "image_std",
- "do_pad",
- "return_tensors",
- "pad_size",
- "disable_grouping",
- }
- filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_preprocess_kwargs}
- if images is not None:
- return self._preprocess(images, **filtered_kwargs)
- elif videos is not None:
- return self._preprocess(videos, **filtered_kwargs)
-
-
-__all__ = ["Eagle25VLImageProcessorFast"]
diff --git a/src/lerobot/policies/groot/eagle2_hg_model/modeling_eagle2_5_vl.py b/src/lerobot/policies/groot/eagle2_hg_model/modeling_eagle2_5_vl.py
deleted file mode 100755
index 6e5532ea4..000000000
--- a/src/lerobot/policies/groot/eagle2_hg_model/modeling_eagle2_5_vl.py
+++ /dev/null
@@ -1,396 +0,0 @@
-# --------------------------------------------------------
-# NVIDIA
-# Copyright (c) 2025 NVIDIA
-# Licensed under The MIT License [see LICENSE for details]
-# --------------------------------------------------------
-
-import inspect
-
-import torch
-import torch.utils.checkpoint as cp
-from peft import LoraConfig, get_peft_model
-from torch import nn
-from torch.nn import CrossEntropyLoss
-from transformers import GenerationConfig
-from transformers.generation import GenerationMixin
-from transformers.modeling_outputs import CausalLMOutputWithPast
-from transformers.modeling_utils import PreTrainedModel
-from transformers.models.llama.modeling_llama import LlamaForCausalLM
-from transformers.models.qwen2.modeling_qwen2 import Qwen2ForCausalLM
-from transformers.models.qwen3.modeling_qwen3 import Qwen3ForCausalLM
-from transformers.models.siglip.modeling_siglip import SiglipVisionModel
-from transformers.utils import add_start_docstrings, logging
-
-from .configuration_eagle2_5_vl import Eagle25VLConfig
-
-logger = logging.get_logger(__name__)
-
-
-# copy from https://github.com/huggingface/transformers/blob/main/src/transformers/models/llava_onevision/modeling_llava_onevision.py#L241C1-L280C1
-EAGLE2_5_VL_START_DOCSTRING = r"""
- This model inherits from [`PreTrainedModel`]. Check the superclass documentation for the generic methods the
- library implements for all its model (such as downloading or saving, resizing the input embeddings, pruning heads
- etc.)
-
- This model is also a PyTorch [torch.nn.Module](https://pytorch.org/docs/stable/nn.html#torch.nn.Module) subclass.
- Use it as a regular PyTorch Module and refer to the PyTorch documentation for all matter related to general usage
- and behavior.
-
- Parameters:
- config ([`Eagle25VLConfig`]):
- Model configuration class with all the parameters of the model. Initializing with a config file does not
- load the weights associated with the model, only the configuration. Check out the
- [`~PreTrainedModel.from_pretrained`] method to load the model weights.
-"""
-
-
-@add_start_docstrings(
- "The bare Eagle2_5_VL Model outputting raw hidden-states without any specific head on top.",
- EAGLE2_5_VL_START_DOCSTRING,
-)
-class Eagle25VLPreTrainedModel(PreTrainedModel):
- config_class = Eagle25VLConfig
- base_model_prefix = "model"
- main_input_name = "input_ids"
- supports_gradient_checkpointing = True
- _no_split_modules = [
- "Qwen2DecoderLayer",
- "LlamaDecoderLayer",
- "Siglip2EncoderLayer",
- "SiglipEncoderLayer",
- ]
- _skip_keys_device_placement = "past_key_values"
- _supports_flash_attn = True
- _supports_flash_attn_2 = True
- _supports_cache_class = True
- _supports_static_cache = True
- _supports_quantized_cache = True
- _supports_sdpa = True
-
- def _init_weights(self, module):
- std = self.config.initializer_range
- if isinstance(module, nn.Linear | nn.Conv2d):
- module.weight.data.normal_(mean=0.0, std=std)
- if module.bias is not None:
- module.bias.data.zero_()
- elif isinstance(module, nn.Embedding):
- module.weight.data.normal_(mean=0.0, std=std)
- if module.padding_idx is not None:
- module.weight.data[module.padding_idx].zero_()
-
-
-class Eagle25VLForConditionalGeneration(Eagle25VLPreTrainedModel, GenerationMixin):
- config_class = Eagle25VLConfig
-
- def __init__(self, config: Eagle25VLConfig, vision_model=None, language_model=None):
- super().__init__(config)
-
- image_size = config.force_image_size or config.vision_config.image_size
- patch_size = config.vision_config.patch_size
- self.patch_size = patch_size
- if config.use_pixel_shuffle:
- self.num_image_token = int((image_size // patch_size) ** 2 * (config.downsample_ratio**2))
- else:
- self.num_image_token = int((image_size // patch_size) ** 2)
-
- self.select_layer = config.select_layer
- self.downsample_ratio = config.downsample_ratio
- self.loss_version = config.loss_version
- self.mlp_checkpoint = config.mlp_checkpoint
- self.use_pixel_shuffle = config.use_pixel_shuffle
- self.mlp_connector_layers = config.mlp_connector_layers
- logger.info(f"num_image_token: {self.num_image_token}")
- logger.info(f"mlp_checkpoint: {self.mlp_checkpoint}")
- if vision_model is not None:
- self.vision_model = vision_model
- else:
- if config.vision_config.model_type == "siglip_vision_model":
- config.vision_config._attn_implementation = "flash_attention_2"
- self.vision_model = SiglipVisionModel(config.vision_config)
- else:
- raise NotImplementedError(f"{config.vision_config.model_type} is not implemented.")
-
- if language_model is not None:
- self.language_model = language_model
- else:
- if config.text_config.architectures[0] == "LlamaForCausalLM":
- self.language_model = LlamaForCausalLM(config.text_config)
- elif config.text_config.architectures[0] == "Phi3ForCausalLM":
- raise NotImplementedError("Phi3 is not implemented.")
- # self.language_model = Phi3ForCausalLM(config.text_config)
- elif config.text_config.architectures[0] == "Qwen2ForCausalLM":
- assert config.text_config._attn_implementation == "flash_attention_2", (
- f"Qwen2 must use flash_attention_2 but got {config.text_config._attn_implementation}"
- )
- self.language_model = Qwen2ForCausalLM(config.text_config)
- elif config.text_config.architectures[0] == "Qwen3ForCausalLM":
- self.language_model = Qwen3ForCausalLM(config.text_config)
- else:
- raise NotImplementedError(f"{config.text_config.architectures[0]} is not implemented.")
-
- vit_hidden_size = config.vision_config.hidden_size
- llm_hidden_size = config.text_config.hidden_size
-
- if config.mlp_connector_layers == 2:
- self.mlp1 = nn.Sequential(
- nn.LayerNorm(vit_hidden_size * int(1 / self.downsample_ratio) ** 2),
- nn.Linear(vit_hidden_size * int(1 / self.downsample_ratio) ** 2, llm_hidden_size),
- nn.GELU(),
- nn.Linear(llm_hidden_size, llm_hidden_size),
- )
- elif config.mlp_connector_layers == 1 and config.use_pixel_shuffle:
- self.mlp1 = nn.Sequential(
- nn.Linear(vit_hidden_size * int(1 / self.downsample_ratio) ** 2, llm_hidden_size),
- )
- elif config.mlp_connector_layers == 1 and not config.use_pixel_shuffle:
- self.mlp1 = nn.Sequential(
- nn.Linear(vit_hidden_size, llm_hidden_size),
- )
- else:
- raise NotImplementedError(f"{config.mlp_connector_layers} is not implemented.")
-
- self.image_token_index = config.image_token_index
- self.neftune_alpha = None
-
- if config.use_backbone_lora:
- self.wrap_backbone_lora(r=config.use_backbone_lora, lora_alpha=2 * config.use_backbone_lora)
-
- self.use_llm_lora = config.use_llm_lora
- if config.use_llm_lora:
- self.wrap_llm_lora(r=config.use_llm_lora, lora_alpha=2 * config.use_llm_lora)
-
- self.check_forward_kwargs()
-
- def check_forward_kwargs(self):
- # We intentionally avoid using **kwargs in forward because Hugging Face Transformers
- # has special handling for functions with **kwargs parameters that would affect
- # how our model is processed during training and inference.
- forward_params = inspect.signature(self.forward).parameters
- assert not any(k.kind == inspect.Parameter.VAR_KEYWORD for k in forward_params.values())
-
- def wrap_backbone_lora(self, r=128, lora_alpha=256, lora_dropout=0.05):
- lora_config = LoraConfig(
- r=r,
- target_modules=[
- "self_attn.q_proj",
- "self_attn.k_proj",
- "self_attn.v_proj",
- "self_attn.out_proj",
- "mlp.fc1",
- "mlp.fc2",
- ],
- lora_alpha=lora_alpha,
- lora_dropout=lora_dropout,
- )
- self.vision_model = get_peft_model(self.vision_model, lora_config)
- self.vision_model.print_trainable_parameters()
-
- def wrap_llm_lora(self, r=128, lora_alpha=256, lora_dropout=0.05):
- lora_config = LoraConfig(
- r=r,
- target_modules=[
- "self_attn.q_proj",
- "self_attn.k_proj",
- "self_attn.v_proj",
- "self_attn.o_proj",
- "mlp.gate_proj",
- "mlp.down_proj",
- "mlp.up_proj",
- ],
- lora_alpha=lora_alpha,
- lora_dropout=lora_dropout,
- task_type="CAUSAL_LM",
- )
- self.language_model = get_peft_model(self.language_model, lora_config)
- self.language_model.enable_input_require_grads()
- self.language_model.print_trainable_parameters()
- self.use_llm_lora = True
-
- def forward(
- self,
- pixel_values: torch.FloatTensor,
- input_ids: torch.LongTensor = None,
- attention_mask: torch.Tensor | None = None,
- position_ids: torch.LongTensor | None = None,
- image_flags: torch.LongTensor | None = None,
- past_key_values: list[torch.FloatTensor] | None = None,
- labels: torch.LongTensor | None = None,
- use_cache: bool | None = None,
- output_attentions: bool | None = None,
- output_hidden_states: bool | None = None,
- return_dict: bool | None = None,
- num_tiles_list: list[torch.Tensor] | None = None,
- ) -> tuple | CausalLMOutputWithPast:
- return_dict = return_dict if return_dict is not None else self.config.use_return_dict
-
- input_embeds = self.language_model.get_input_embeddings()(input_ids)
-
- vit_embeds = self.extract_feature(pixel_values)
-
- if image_flags is not None:
- image_flags = image_flags.view(-1)
- vit_embeds = vit_embeds[image_flags == 1]
-
- b, n, c = input_embeds.shape
- input_embeds = input_embeds.reshape(b * n, c)
-
- input_ids = input_ids.reshape(b * n)
- selected = input_ids == self.image_token_index
- try:
- input_embeds[selected] = input_embeds[selected] * 0.0 + vit_embeds.reshape(-1, c)
- except Exception as e:
- vit_embeds = vit_embeds.reshape(-1, c)
- print(
- f"warning: {e}, input_embeds[selected].shape={input_embeds[selected].shape}, "
- f"vit_embeds.shape={vit_embeds.shape}"
- )
- n_token = selected.sum()
- input_embeds[selected] = input_embeds[selected] * 0.0 + vit_embeds[:n_token]
-
- input_embeds = input_embeds.reshape(b, n, c)
-
- outputs = self.language_model(
- inputs_embeds=input_embeds,
- attention_mask=attention_mask,
- position_ids=position_ids,
- past_key_values=past_key_values,
- use_cache=use_cache,
- output_attentions=output_attentions,
- output_hidden_states=output_hidden_states,
- )
- logits = outputs.logits
-
- loss = None
- if labels is not None:
- # Shift so that tokens < n predict n
- shift_logits = logits[..., :-1, :].contiguous()
- shift_labels = labels[..., 1:].contiguous()
- # Flatten the tokens
- loss_fct = CrossEntropyLoss()
- shift_logits = shift_logits.view(-1, self.language_model.config.vocab_size)
- shift_labels = shift_labels.view(-1)
- # Enable model parallelism
- shift_labels = shift_labels.to(shift_logits.device)
- loss = loss_fct(shift_logits, shift_labels)
-
- if not return_dict:
- output = (logits,) + outputs[1:]
- return (loss,) + output if loss is not None else output
-
- return CausalLMOutputWithPast(
- loss=loss,
- logits=logits,
- past_key_values=outputs.past_key_values,
- hidden_states=outputs.hidden_states,
- attentions=outputs.attentions,
- )
-
- def pixel_shuffle(self, x, scale_factor=0.5):
- n, w, h, c = x.size()
- # N, W, H, C --> N, W, H * scale, C // scale
- x = x.view(n, w, int(h * scale_factor), int(c / scale_factor))
- # N, W, H * scale, C // scale --> N, H * scale, W, C // scale
- x = x.permute(0, 2, 1, 3).contiguous()
- # N, H * scale, W, C // scale --> N, H * scale, W * scale, C // (scale ** 2)
- x = x.view(n, int(h * scale_factor), int(w * scale_factor), int(c / (scale_factor * scale_factor)))
-
- x = x.permute(0, 2, 1, 3).contiguous()
- return x
-
- def extract_feature(self, pixel_values):
- if self.select_layer == -1:
- vit_embeds = self.vision_model(
- pixel_values=pixel_values, output_hidden_states=False, return_dict=True
- )
- if hasattr(vit_embeds, "last_hidden_state"):
- vit_embeds = vit_embeds.last_hidden_state
-
- else:
- vit_embeds = self.vision_model(
- pixel_values=pixel_values, output_hidden_states=True, return_dict=True
- ).hidden_states[self.select_layer]
-
- if self.use_pixel_shuffle:
- h = w = int(vit_embeds.shape[1] ** 0.5)
- vit_embeds = vit_embeds.reshape(vit_embeds.shape[0], h, w, -1)
- vit_embeds = self.pixel_shuffle(
- vit_embeds, scale_factor=self.downsample_ratio
- ) # torch.Size([B, 1024, 1024]) -> torch.Size([B, 16, 16, 4096])
- vit_embeds = vit_embeds.reshape(
- vit_embeds.shape[0], -1, vit_embeds.shape[-1]
- ) # torch.Size([B, 16, 16, 4096]) -> torch.Size([B, 256, 4096])
-
- if self.mlp_checkpoint and vit_embeds.requires_grad:
- vit_embeds = cp.checkpoint(self.mlp1, vit_embeds)
- else:
- vit_embeds = self.mlp1(vit_embeds)
-
- return vit_embeds
-
- @torch.no_grad()
- def generate(
- self,
- pixel_values: torch.FloatTensor | None = None,
- input_ids: torch.FloatTensor | None = None,
- attention_mask: torch.LongTensor | None = None,
- visual_features: torch.FloatTensor | None = None,
- generation_config: GenerationConfig | None = None,
- output_hidden_states: bool | None = None,
- image_sizes: list[tuple[int, int]] | None = None,
- **generate_kwargs,
- ) -> torch.LongTensor:
- if pixel_values is not None:
- if visual_features is not None:
- vit_embeds = visual_features
- else:
- vit_embeds = self.extract_feature(pixel_values)
-
- input_embeds = self.language_model.get_input_embeddings()(input_ids)
- b, n, c = input_embeds.shape
- input_embeds = input_embeds.reshape(b * n, c)
-
- input_ids = input_ids.reshape(b * n)
- selected = input_ids == self.config.image_token_index
- assert selected.sum() != 0
- input_embeds[selected] = vit_embeds.reshape(-1, c).to(input_embeds.device)
-
- input_embeds = input_embeds.reshape(b, n, c)
- else:
- input_embeds = self.language_model.get_input_embeddings()(input_ids)
-
- if "use_cache" not in generate_kwargs:
- generate_kwargs["use_cache"] = True
-
- outputs = self.language_model.generate(
- inputs_embeds=input_embeds,
- attention_mask=attention_mask,
- generation_config=generation_config,
- output_hidden_states=output_hidden_states,
- **generate_kwargs,
- )
-
- return outputs
-
- # Copied from transformers.models.llava_next.modeling_llava_next.LlavaNextForConditionalGeneration.get_input_embeddings
- def get_input_embeddings(self):
- return self.language_model.get_input_embeddings()
-
- # Copied from transformers.models.llava_next.modeling_llava_next.LlavaNextForConditionalGeneration.set_input_embeddings
- def set_input_embeddings(self, value):
- self.language_model.set_input_embeddings(value)
-
- # Copied from transformers.models.llava_next.modeling_llava_next.LlavaNextForConditionalGeneration.get_output_embeddings
- def get_output_embeddings(self):
- return self.language_model.get_output_embeddings()
-
- # Copied from transformers.models.llava_next.modeling_llava_next.LlavaNextForConditionalGeneration.set_output_embeddings
- def set_output_embeddings(self, new_embeddings):
- self.language_model.set_output_embeddings(new_embeddings)
-
- # Copied from transformers.models.llava_next.modeling_llava_next.LlavaNextForConditionalGeneration.set_decoder
- def set_decoder(self, decoder):
- self.language_model.set_decoder(decoder)
-
- # Copied from transformers.models.llava_next.modeling_llava_next.LlavaNextForConditionalGeneration.get_decoder
- def get_decoder(self):
- return self.language_model.get_decoder()
diff --git a/src/lerobot/policies/groot/eagle2_hg_model/processing_eagle2_5_vl.py b/src/lerobot/policies/groot/eagle2_hg_model/processing_eagle2_5_vl.py
deleted file mode 100755
index b36e70c47..000000000
--- a/src/lerobot/policies/groot/eagle2_hg_model/processing_eagle2_5_vl.py
+++ /dev/null
@@ -1,541 +0,0 @@
-# Copyright 2024 The HuggingFace Inc. team.
-#
-# 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.
-"""
-Processor class for Eagle25VL.
-copy from https://github.com/huggingface/transformers/blob/main/src/transformers/models/llava_onevision/processing_llava_onevision.py
-"""
-
-import base64
-import os
-import re
-from io import BytesIO
-
-import requests
-import torch
-from PIL import Image
-from transformers.feature_extraction_utils import BatchFeature
-from transformers.image_utils import ImageInput
-from transformers.processing_utils import ProcessingKwargs, ProcessorMixin, Unpack
-from transformers.tokenization_utils_base import PreTokenizedInput, TextInput
-from transformers.utils import logging
-from transformers.video_utils import VideoInput
-
-logger = logging.get_logger(__name__)
-
-
-FRAME_FACTOR = 2
-FPS = 2.0
-FPS_MIN_FRAMES = 4
-FPS_MAX_FRAMES = 256
-
-
-def to_rgb(pil_image: Image.Image) -> Image.Image:
- if pil_image.mode == "RGBA":
- white_background = Image.new("RGB", pil_image.size, (255, 255, 255))
- white_background.paste(pil_image, mask=pil_image.split()[3]) # Use alpha channel as mask
- return white_background
- else:
- return pil_image.convert("RGB")
-
-
-def fetch_image(ele: dict[str, str | Image.Image]) -> Image.Image:
- image = ele["image"] if "image" in ele else ele["image_url"]
- image_obj = None
- if isinstance(image, Image.Image):
- image_obj = image
- elif image.startswith("http://") or image.startswith("https://"):
- response = requests.get(image, stream=True, timeout=10)
- image_obj = Image.open(BytesIO(response.content))
- elif image.startswith("file://"):
- image_obj = Image.open(image[7:])
- elif image.startswith("data:image"):
- if "base64," in image:
- _, base64_data = image.split("base64,", 1)
- data = base64.b64decode(base64_data)
- image_obj = Image.open(BytesIO(data))
- else:
- image_obj = Image.open(image)
- if image_obj is None:
- raise ValueError(
- f"Unrecognized image input, support local path, http url, base64 and PIL.Image, got {image}"
- )
- image = to_rgb(image_obj)
- if "scale_factor" in ele:
- scale_factor = ele["scale_factor"]
- image = image.resize((image.width * scale_factor, image.height * scale_factor), Image.BILINEAR)
- return image
-
-
-class Eagle25VLProcessorKwargs(ProcessingKwargs, total=False):
- # see processing_utils.ProcessingKwargs documentation for usage.
- _defaults = {
- "text_kwargs": {
- "padding": False,
- },
- "images_kwargs": {},
- "videos_kwargs": {"max_dynamic_tiles": 1},
- }
-
-
-class Eagle25VLProcessor(ProcessorMixin):
- r"""
- Constructs a Eagle25VL processor which wraps a Eagle25VL video processor, Eagle25VL image processor and a Eagle25VL tokenizer into a single processor.
-
- [`Eagle25VLProcessor`] offers all the functionalities of [`Eagle25VLVideoProcessor`], [`Eagle25VLImageProcessor`] and [`Eagle25VLTokenizer`]. See the
- [`~Eagle25VLVideoProcessor.__call__`], [`~Eagle25VLProcessor.__call__`] and [`~Eagle25VLProcessor.decode`] for more information.
-
- Args:
- image_processor ([`LlavaOnevisionImageProcessor`], *optional*):
- The image processor is a required input.
- tokenizer ([`LlamaTokenizerFast`], *optional*):
- The tokenizer is a required input.
- num_image_tokens (`int`, *optional*):
- Number of image tokens for one imagethat will be returned by vision tower.
- vision_feature_select_strategy (`str`, *optional*):
- The feature selection strategy used to select the vision feature from the vision backbone.
- Should be same as in model's config
- chat_template (`str`, *optional*): A Jinja template which will be used to convert lists of messages
- in a chat into a tokenizable string.
- image_token (`str`, *optional*, defaults to `""`):
- Special token used to denote image location.
- video_token (`str`, *optional*, defaults to `"