Merge remote-tracking branch 'origin/main' into worktree-lingbot-va-port

# Conflicts:
#	docs/source/_toctree.yml
#	src/lerobot/policies/factory.py
#	uv.lock
This commit is contained in:
Maxime Ellerbach
2026-07-02 14:15:09 +00:00
171 changed files with 14407 additions and 3343 deletions
+3 -3
View File
@@ -167,9 +167,9 @@ jobs:
# ── LIBERO TRAIN+EVAL SMOKE ────────────────────────────────────────────── # ── LIBERO TRAIN+EVAL SMOKE ──────────────────────────────────────────────
# Train SmolVLA for 1 step (batch_size=1, dataset episode 0 only) then # Train SmolVLA for 1 step (batch_size=1, dataset episode 0 only) then
# immediately runs eval inside the training loop (eval_freq=1, 1 episode). # immediately runs eval inside the training loop (env_eval_freq=1, 1 episode).
# Tests the full train→eval-within-training pipeline end-to-end. # Tests the full train→eval-within-training pipeline end-to-end.
- name: Run Libero train+eval smoke (1 step, eval_freq=1) - name: Run Libero train+eval smoke (1 step, env_eval_freq=1)
if: env.HF_USER_TOKEN != '' if: env.HF_USER_TOKEN != ''
run: | run: |
docker run --name libero-train-smoke --gpus all \ docker run --name libero-train-smoke --gpus all \
@@ -196,7 +196,7 @@ jobs:
--output_dir=/tmp/train-smoke \ --output_dir=/tmp/train-smoke \
--steps=1 \ --steps=1 \
--batch_size=1 \ --batch_size=1 \
--eval_freq=1 \ --env_eval_freq=1 \
--eval.n_episodes=1 \ --eval.n_episodes=1 \
--eval.batch_size=1 \ --eval.batch_size=1 \
--eval.use_async_envs=false \ --eval.use_async_envs=false \
+1 -1
View File
@@ -138,7 +138,7 @@ lerobot-replay --robot.type=so101_follower --robot.port=<FOLLOWER_PORT> --robot.
--dataset.repo_id=${HF_USER}/my_task --dataset.episode=0 --dataset.repo_id=${HF_USER}/my_task --dataset.episode=0
``` ```
**4.9 Train** (default: ACT — fastest, lowest memory). Apple silicon: `--policy.device=mps`. See §6/§7 for policy and duration. **4.9 Train** (default: ACT — fastest, lowest memory). Apple silicon: `--policy.device=mps`. No local GPU? Add `--job.target=<flavor>` (e.g. `a10g-small`, list them with `hf jobs hardware`) to run on Hugging Face Jobs instead. See §6/§7 for policy and duration.
```bash ```bash
lerobot-train \ lerobot-train \
+4 -4
View File
@@ -58,7 +58,7 @@ test-act-ete-train:
--dataset.episodes="[0]" \ --dataset.episodes="[0]" \
--batch_size=2 \ --batch_size=2 \
--steps=4 \ --steps=4 \
--eval_freq=2 \ --env_eval_freq=2 \
--eval.n_episodes=1 \ --eval.n_episodes=1 \
--eval.batch_size=1 \ --eval.batch_size=1 \
--save_freq=2 \ --save_freq=2 \
@@ -96,7 +96,7 @@ test-diffusion-ete-train:
--dataset.episodes="[0]" \ --dataset.episodes="[0]" \
--batch_size=2 \ --batch_size=2 \
--steps=2 \ --steps=2 \
--eval_freq=2 \ --env_eval_freq=2 \
--eval.n_episodes=1 \ --eval.n_episodes=1 \
--eval.batch_size=1 \ --eval.batch_size=1 \
--save_checkpoint=true \ --save_checkpoint=true \
@@ -126,7 +126,7 @@ test-tdmpc-ete-train:
--dataset.episodes="[0]" \ --dataset.episodes="[0]" \
--batch_size=2 \ --batch_size=2 \
--steps=2 \ --steps=2 \
--eval_freq=2 \ --env_eval_freq=2 \
--eval.n_episodes=1 \ --eval.n_episodes=1 \
--eval.batch_size=1 \ --eval.batch_size=1 \
--save_checkpoint=true \ --save_checkpoint=true \
@@ -161,7 +161,7 @@ test-smolvla-ete-train:
--dataset.episodes="[0]" \ --dataset.episodes="[0]" \
--batch_size=2 \ --batch_size=2 \
--steps=4 \ --steps=4 \
--eval_freq=2 \ --env_eval_freq=2 \
--eval.n_episodes=1 \ --eval.n_episodes=1 \
--eval.batch_size=1 \ --eval.batch_size=1 \
--save_freq=2 \ --save_freq=2 \
+1 -1
View File
@@ -97,7 +97,7 @@ Training a policy is as simple as running a script configuration:
```bash ```bash
lerobot-train \ lerobot-train \
--policy=act \ --policy.type=act \
--dataset.repo_id=lerobot/aloha_mobile_cabinet --dataset.repo_id=lerobot/aloha_mobile_cabinet
``` ```
+2
View File
@@ -71,6 +71,8 @@
title: EO-1 title: EO-1
- local: lingbot_va - local: lingbot_va
title: LingBot-VA title: LingBot-VA
- local: fastwam
title: FastWAM
- local: groot - local: groot
title: NVIDIA GR00T N1.5 title: NVIDIA GR00T N1.5
- local: xvla - local: xvla
+8
View File
@@ -157,6 +157,14 @@ finally:
</hfoption> </hfoption>
</hfoptions> </hfoptions>
### Working with depth
The Intel RealSense and Reachy 2 cameras can capture both color and depth in lockstep. Calling `read()` returns the **color** frame as `(H, W, 3)` `uint8`. Calling `read_depth()` returns the **depth map** as `(H, W, 1)` `uint16`, where each pixel value is the distance from the sensor expressed in **millimetres**. A pixel value of `0` typically means "no measurement available" (out-of-range, occluded, or low-confidence).
During recording, the control loop peeks the freshest buffered frames non-blockingly via `read_latest()` (color) and `read_latest_depth()` (depth), adding the depth map as a sibling feature (e.g. `front_depth` next to `front`).
For how depth streams are stored and encoded when recording a dataset, see the [Depth streams](./video_encoding_parameters#depth-streams) section of the video encoding guide.
## Use your phone's camera ## Use your phone's camera
<hfoptions id="use phone"> <hfoptions id="use phone">
+38
View File
@@ -89,6 +89,36 @@ Control the data recording flow using keyboard shortcuts:
- Press **Left Arrow (`←`)**: Delete current episode and retry. - Press **Left Arrow (`←`)**: Delete current episode and retry.
- Press **Escape (`ESC`)**: Stop, encode videos, and upload. - Press **Escape (`ESC`)**: Stop, encode videos, and upload.
### Recording depth
Intel RealSense cameras (`type: intelrealsense`) record a depth stream when you set `use_depth: true`. Depth is quantized to 12-bit codes and stored as its own video.
```bash
lerobot-record \
... \
--robot.cameras="{ head: {type: intelrealsense, serial_number_or_name: \"0123456789\", width: 640, height: 480, fps: 30, use_depth: true} }" \
--dataset.repo_id=${HF_USER}/so101_depth_test \
--dataset.single_task="put the red brick in a bowl" \
--dataset.depth_encoder.depth_min=0.01 \
--dataset.depth_encoder.depth_max=10.0 \
--dataset.depth_encoder.shift=0.0 \
--dataset.depth_encoder.use_log=true
```
### Video encoding parameters
RGB and depth streams are encoded independently via the `--dataset.rgb_encoder.*` and `--dataset.depth_encoder.*` keys.
```bash
lerobot-record \
... \
--dataset.rgb_encoder.vcodec=h264 \
--dataset.rgb_encoder.pix_fmt=yuv420p \
--dataset.rgb_encoder.crf=23 \
--dataset.depth_encoder.vcodec=hevc \
--dataset.depth_encoder.extra_options='{"x265-params": "lossless=1"}'
```
### Training ### Training
Depending on your hardware training the policy might take a few hours. That's how you train simple `ACT` policy: Depending on your hardware training the policy might take a few hours. That's how you train simple `ACT` policy:
@@ -120,6 +150,14 @@ lerobot-train \
--steps=20000 --steps=20000
``` ```
No local GPU? Add `--job.target=<flavor>` (e.g. `a10g-small`) to either command and `lerobot-train` runs it on [Hugging Face Jobs](https://huggingface.co/docs/hub/jobs) instead — it uploads a local-only dataset for you and pushes the trained model. List flavors with `hf jobs hardware`.
To resume, point `--config_path` at a checkpoint and add `--resume=true`. It accepts a local path or a Hub repo id (the latest checkpoint is fetched), and works locally or on a job by adding `--job.target=<flavor>`:
```bash
lerobot-train --config_path=${HF_USER}/policy_test --resume=true --job.target=a10g-small
```
### Inference ### Inference
Inference means running the trained policy/model on a robot. For that we use `lerobot-rollout`. You will need to provide a path to your policy. It can be a local path or a path to Hugging Face for example "lerobot/folding_latest". Your cameras configuration needs to match what was used when collecting the dataset. Duration is in seconds if unspecified, it will run forever. Inference means running the trained policy/model on a robot. For that we use `lerobot-rollout`. You will need to provide a path to your policy. It can be a local path or a path to Hugging Face for example "lerobot/folding_latest". Your cameras configuration needs to match what was used when collecting the dataset. Duration is in seconds if unspecified, it will run forever.
+1 -1
View File
@@ -194,7 +194,7 @@ lerobot-record \
--dataset.single_task="Navigate around obstacles" \ --dataset.single_task="Navigate around obstacles" \
--dataset.streaming_encoding=true \ --dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \ --dataset.encoder_threads=2 \
# --dataset.camera_encoder.vcodec=auto \ # --dataset.rgb_encoder.vcodec=auto \
--display_data=true --display_data=true
``` ```
+167
View File
@@ -0,0 +1,167 @@
# FastWAM
FastWAM is a World Action Model policy for robot control. The LeRobot integration exposes FastWAM through the standard policy API so it can be configured with `policy.type=fastwam`, trained with `lerobot-train`, and loaded through the LeRobot pretrained policy interface.
## Model Overview
FastWAM keeps video modeling during training, but uses direct action prediction at inference time instead of iteratively generating future observations. This LeRobot policy wraps the FastWAM action model, adapts LeRobot batches to FastWAM training samples, and provides the standard processor pipeline for normalization and action postprocessing.
The implementation initializes the visual world-model components from `Wan-AI/Wan2.2-TI2V-5B` by default and predicts action chunks with shape `[batch, action_horizon, action_dim]`.
### What the LeRobot Integration Covers
- Standard `policy.type=fastwam` configuration through LeRobot
- Image, state, action, and language-task batch adaptation
- Action chunk inference through `select_action` and `predict_action_chunk`
- Checkpoint save/load through the LeRobot policy APIs
- Configurable LIBERO gripper action postprocessing
## Installation Requirements
Install LeRobot from source, then install FastWAM dependencies:
```bash
pip install -e ".[fastwam]"
```
This installs the FastWAM policy extra from `pyproject.toml`: `transformers`,
`diffusers`, `ftfy`, and `regex`, plus LeRobot's base dependencies.
For LIBERO evaluation, install the benchmark dependencies too:
```bash
pip install -e ".[fastwam,libero]"
```
This installs both extras. In addition to the FastWAM dependencies above, the
`libero` extra installs LeRobot dataset dependencies, `hf-libero` on Linux, and
`scipy`.
FastWAM uses the Wan2.2 TI2V backbone. The default model id is:
```python
policy.model_id=Wan-AI/Wan2.2-TI2V-5B
```
## Data Requirements
FastWAM expects a LeRobot dataset with:
- one or more visual observations whose widths concatenate to `policy.image_size[1]`
- `observation.state` when `policy.proprio_dim` is not `None`
- `action`
- a language task instruction through the dataset task field, or precomputed `context` and `context_mask` tensors
The default visual setup is one image feature named `observation.images.image` with shape `(3, 224, 448)`. If the dataset uses two cameras, configure `policy.input_features` so their heights match `224` and their widths sum to `448`.
## Usage
Create a new FastWAM policy with:
```bash
lerobot-train \
--dataset.repo_id=your-org/your-dataset \
--policy.type=fastwam \
--policy.action_dim=7 \
--policy.proprio_dim=8 \
--policy.action_horizon=32 \
--policy.n_action_steps=10 \
--policy.image_size='[224,448]' \
--output_dir=./outputs/fastwam_training \
--job_name=fastwam_training \
--steps=300000 \
--batch_size=8 \
--policy.device=cuda
```
Evaluate an existing LeRobot-format checkpoint on LIBERO-10 with:
```bash
lerobot-eval \
--policy.path=ZibinDong/fastwam_libero_uncond_2cam224 \
--policy.device=cuda \
--policy.torch_dtype=float32 \
--policy.n_action_steps=10 \
--env.type=libero \
--env.task=libero_10 \
--env.observation_height=224 \
--env.observation_width=224 \
--eval.batch_size=1 \
--eval.n_episodes=50 \
--seed=0 \
--env.episode_length=600
```
For `libero_goal`, `libero_spatial`, and `libero_object`, use
`--env.episode_length=300`.
For real-robot rollout, use the same checkpoint path:
```bash
lerobot-rollout \
--robot.type=so101_follower \
--robot.port=/dev/ttyACM0 \
--policy.path=your-org/fastwam-real-robot
```
## Configuration Notes
### Image Features
`policy.image_size` is the size of the concatenated FastWAM image tensor as `(height, width)`. Each configured image feature must have shape `(3, height, camera_width)`, and all camera widths must sum to the configured width.
### Action Chunking
`policy.action_horizon` controls the number of future actions supervised during training and predicted during inference. `policy.n_action_steps` controls how many actions are consumed before the policy predicts a fresh chunk. `policy.n_action_steps` must be less than or equal to `policy.action_horizon`.
### Wan Components
FastWAM loads the Wan VAE, video DiT, text encoder, and tokenizer from the configured Wan model directory or Hugging Face Hub model id. LeRobot-format FastWAM checkpoints saved by `save_pretrained` also copy the local Wan component files needed by `from_pretrained`.
### Attention Backend
FastWAM's DiT uses PyTorch's `scaled_dot_product_attention` (SDPA) for all attention. It does **not** use FlashAttention: its Mixture-of-Transformers (MoT) routing needs arbitrary boolean `[query, key]` attention masks, which the FlashAttention varlen API cannot express. Installing the `flash-attn` package therefore has no effect on the FastWAM path. (Note that SDPA itself may still select PyTorch's own flash / memory-efficient / math kernel internally — this is unrelated to the `flash-attn` package.)
### LIBERO Action Toggle
FastWAM LIBERO checkpoints use `policy.toggle_action_dimensions=[-1]` by
default to match the gripper action convention used by the original FastWAM
evaluation pipeline:
```bash
--policy.toggle_action_dimensions='[-1]'
```
## Results
Evaluated on LIBERO with [`ZibinDong/fastwam_libero_uncond_2cam224`](https://huggingface.co/ZibinDong/fastwam_libero_uncond_2cam224):
| Suite | Success rate | n_episodes |
| -------------- | -----------: | ---------: |
| libero_spatial | 97.6% | 500 |
| libero_object | 99.0% | 500 |
| libero_goal | 95.0% | 500 |
| libero_10 | 94.0% | 500 |
| **average** | **96.4%** | 2000 |
Reproduce: `lerobot-eval --policy.path=ZibinDong/fastwam_libero_uncond_2cam224 --policy.device=cuda --policy.torch_dtype=float32 --policy.n_action_steps=10 --env.type=libero --env.task=libero_spatial --env.observation_height=256 --env.observation_width=256 --eval.batch_size=1 --eval.n_episodes=50 --seed=0 --env.episode_length=300` (1x H20 140 GB).
## References
- [Fast-WAM paper](https://arxiv.org/abs/2603.16666)
- [Fast-WAM project page](https://yuantianyuan01.github.io/FastWAM/)
- [Fast-WAM code](https://github.com/yuantianyuan01/FastWAM)
- [Released upstream checkpoints](https://huggingface.co/yuanty/fastwam)
- [Wan2.2 TI2V 5B](https://huggingface.co/Wan-AI/Wan2.2-TI2V-5B)
## Citation
```bibtex
@article{yuan2026fastwam,
title = {Fast-WAM: Do World Action Models Need Test-time Future Imagination?},
author = {Tianyuan Yuan and Zibin Dong and Yicheng Liu and Hang Zhao},
journal = {arXiv preprint arXiv:2603.16666},
year = {2026},
url = {https://arxiv.org/abs/2603.16666}
}
```
+1 -1
View File
@@ -124,7 +124,7 @@ lerobot-rollout\
--dataset.single_task="Grab and handover the red cube to the other arm" \ --dataset.single_task="Grab and handover the red cube to the other arm" \
--dataset.streaming_encoding=true \ --dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \ --dataset.encoder_threads=2 \
# --dataset.camera_encoder.vcodec=auto \ # --dataset.rgb_encoder.vcodec=auto \
--policy.path=<user>/groot-bimanual \ # your trained model --policy.path=<user>/groot-bimanual \ # your trained model
--duration=600 --duration=600
``` ```
+1
View File
@@ -96,3 +96,4 @@ Notes:
- The leading `nvidia-smi` is a quick sanity check that CUDA is visible inside the container — useful to fail fast if the flavor or driver mismatched. - The leading `nvidia-smi` is a quick sanity check that CUDA is visible inside the container — useful to fail fast if the flavor or driver mismatched.
- The default Job timeout is 30 minutes; pass `--timeout 4h` (or longer) for real training. - The default Job timeout is 30 minutes; pass `--timeout 4h` (or longer) for real training.
- `--flavor` maps onto the table above: `t4-small`/`t4-medium` (T4, ACT only), `l4x1`/`l4x4` (L4 24 GB), `a10g-small/large/largex2/largex4` (A10G 24 GB scaled out), `a100-large` (A100). For the current full catalogue + pricing see [https://huggingface.co/docs/hub/jobs](https://huggingface.co/docs/hub/jobs). - `--flavor` maps onto the table above: `t4-small`/`t4-medium` (T4, ACT only), `l4x1`/`l4x4` (L4 24 GB), `a10g-small/large/largex2/largex4` (A10G 24 GB scaled out), `a100-large` (A100). For the current full catalogue + pricing see [https://huggingface.co/docs/hub/jobs](https://huggingface.co/docs/hub/jobs).
- Prefer not to write the `hf jobs run` wrapper yourself? `lerobot-train` can submit the job for you: just add `--job.target=<flavor>` to a normal training command and it handles dataset upload, log streaming, and the final model push. See the [imitation-learning training guide](./il_robots).
+1 -1
View File
@@ -719,7 +719,7 @@ Example configuration for training the [reward classifier](https://huggingface.c
"num_workers": 4, "num_workers": 4,
"steps": 5000, "steps": 5000,
"log_freq": 10, "log_freq": 10,
"eval_freq": 1000, "env_eval_freq": 1000,
"save_freq": 1000, "save_freq": 1000,
"save_checkpoint": true, "save_checkpoint": true,
"seed": 2, "seed": 2,
+2 -2
View File
@@ -232,7 +232,7 @@ lerobot-record \
--dataset.private=true \ --dataset.private=true \
--dataset.streaming_encoding=true \ --dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \ --dataset.encoder_threads=2 \
# --dataset.camera_encoder.vcodec=auto \ # --dataset.rgb_encoder.vcodec=auto \
--display_data=true --display_data=true
``` ```
@@ -278,6 +278,6 @@ lerobot-record \
--dataset.num_episodes=10 \ --dataset.num_episodes=10 \
--dataset.streaming_encoding=true \ --dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \ --dataset.encoder_threads=2 \
# --dataset.camera_encoder.vcodec=auto \ # --dataset.rgb_encoder.vcodec=auto \
--policy.path=outputs/train/hopejr_hand/checkpoints/last/pretrained_model --policy.path=outputs/train/hopejr_hand/checkpoints/last/pretrained_model
``` ```
+74 -11
View File
@@ -126,7 +126,7 @@ import time
from lerobot.teleoperators.so_leader import SO101Leader, SO101LeaderConfig from lerobot.teleoperators.so_leader import SO101Leader, SO101LeaderConfig
from lerobot.robots.so_follower import SO101Follower, SO101FollowerConfig from lerobot.robots.so_follower import SO101Follower, SO101FollowerConfig
from lerobot.cameras.opencv import OpenCVCameraConfig from lerobot.cameras.opencv import OpenCVCameraConfig
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data, shutdown_rerun from lerobot.utils.visualization_utils import init_visualization, log_visualization_data, shutdown_visualization
robot_config = SO101FollowerConfig( robot_config = SO101FollowerConfig(
port="/dev/tty.usbmodem5AB90687491", port="/dev/tty.usbmodem5AB90687491",
@@ -142,7 +142,7 @@ teleop_config = SO101LeaderConfig(
id="my_leader_arm", id="my_leader_arm",
) )
init_rerun(session_name="teleoperation") init_visualization("rerun", session_name="teleoperation") # pass "foxglove" to stream to Foxglove instead
robot = SO101Follower(robot_config) robot = SO101Follower(robot_config)
teleop_device = SO101Leader(teleop_config) teleop_device = SO101Leader(teleop_config)
@@ -158,7 +158,7 @@ while True:
observation = robot.get_observation() observation = robot.get_observation()
action = teleop_device.get_action() action = teleop_device.get_action()
robot.send_action(action) robot.send_action(action)
log_rerun_data(observation=observation, action=action) log_visualization_data("rerun", observation=observation, action=action)
elapsed_time = time.perf_counter() - start_time elapsed_time = time.perf_counter() - start_time
sleep_time = TIME_PER_FRAME - elapsed_time sleep_time = TIME_PER_FRAME - elapsed_time
@@ -207,7 +207,7 @@ lerobot-record \
--dataset.num_episodes=5 \ --dataset.num_episodes=5 \
--dataset.single_task="Grab the black cube" \ --dataset.single_task="Grab the black cube" \
--dataset.streaming_encoding=true \ --dataset.streaming_encoding=true \
# --dataset.camera_encoder.vcodec=auto \ # --dataset.rgb_encoder.vcodec=auto \
--dataset.encoder_threads=2 --dataset.encoder_threads=2
``` ```
</hfoption> </hfoption>
@@ -223,7 +223,7 @@ from lerobot.teleoperators.so_leader.config_so_leader import SO101LeaderConfig
from lerobot.teleoperators.so_leader.so_leader import SO101Leader from lerobot.teleoperators.so_leader.so_leader import SO101Leader
from lerobot.common.control_utils import init_keyboard_listener from lerobot.common.control_utils import init_keyboard_listener
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun from lerobot.utils.visualization_utils import init_visualization
from lerobot.scripts.lerobot_record import record_loop from lerobot.scripts.lerobot_record import record_loop
from lerobot.processor import make_default_processors from lerobot.processor import make_default_processors
@@ -270,7 +270,7 @@ def main():
# Initialize the keyboard listener and rerun visualization # Initialize the keyboard listener and rerun visualization
_, events = init_keyboard_listener() _, events = init_keyboard_listener()
init_rerun(session_name="recording") init_visualization("rerun", session_name="recording")
# Connect the robot and teleoperator # Connect the robot and teleoperator
robot.connect() robot.connect()
@@ -390,9 +390,17 @@ Set the flow of data recording using command-line arguments:
Control the data recording flow using keyboard shortcuts: Control the data recording flow using keyboard shortcuts:
- Press **Right Arrow (`→`)**: Early stop the current episode or reset time and move to the next. - Press **Right Arrow (`→`)** or **`n`**: Early stop the current episode or reset time and move to the next.
- Press **Left Arrow (`←`)**: Cancel the current episode and re-record it. - Press **Left Arrow (`←`)** or **`r`**: Cancel the current episode and re-record it.
- Press **Escape (`ESC`)**: Immediately stop the session, encode videos, and upload the dataset. - Press **Escape (`ESC`)** or **`q`**: Immediately stop the session, encode videos, and upload the dataset.
<Tip>
These control-flow shortcuts work on **X11, Wayland, and headless/SSH** sessions. When a global keyboard backend isn't available (Wayland, a headless machine, or macOS without Accessibility permission), `lerobot-record` automatically reads the same keys from the terminal — launch it from an interactive terminal and keep it focused. You can also use the letter equivalents **`n`** (next, same as `→`), **`r`** (re-record, same as `←`) and **`q`** (quit, same as `ESC`). No `$DISPLAY` setup is required.
This applies to the recording control flow only. Keyboard **teleoperation** (driving the robot with the keyboard) still needs a global key backend, so it works only on an X11 session, a Windows desktop, or macOS with Accessibility/Input Monitoring granted — not on Wayland or headless sessions.
</Tip>
#### Tips for gathering data #### Tips for gathering data
@@ -406,7 +414,7 @@ If you want to dive deeper into this important topic, you can check out the [blo
#### Troubleshooting: #### Troubleshooting:
- On Linux, if the left and right arrow keys and escape key don't have any effect during data recording, make sure you've set the `$DISPLAY` environment variable. See [pynput limitations](https://pynput.readthedocs.io/en/latest/limitations.html#linux). - On Linux, the recording control-flow keys (arrow keys, Escape) work on X11, Wayland, and headless/SSH sessions as long as `lerobot-record` runs in an interactive terminal — no `$DISPLAY` setup is needed. If the keys have no effect, make sure you are in an interactive (TTY) terminal, not a piped/non-TTY session, and that it is focused; the letter equivalents `n` / `r` / `q` also work. Keyboard _teleoperation_ (as opposed to the recording control flow) still requires a global key backend — an X11 session, a Windows desktop, or macOS with Accessibility/Input Monitoring granted — and is unavailable on Wayland or headless machines. See [pynput limitations](https://pynput.readthedocs.io/en/latest/limitations.html#linux).
## Visualize a dataset ## Visualize a dataset
@@ -506,6 +514,12 @@ lerobot-train \
--resume=true --resume=true
``` ```
`--config_path` also accepts a **Hub repo id**: if a run pushed its checkpoints to the Hub (with `--save_checkpoint_to_hub=true`), you can resume straight from the repo — its latest checkpoint is downloaded and training continues, restoring the optimizer, scheduler, step counter and data order:
```bash
lerobot-train --config_path=${HF_USER}/my_policy --resume=true
```
If you do not want to push your model to the hub after training use `--policy.push_to_hub=false`. If you do not want to push your model to the hub after training use `--policy.push_to_hub=false`.
Additionally you can provide extra `tags` or specify a `license` for your model or make the model repo `private` by adding this: `--policy.private=true --policy.tags=\[ppo,rl\] --policy.license=mit` Additionally you can provide extra `tags` or specify a `license` for your model or make the model repo `private` by adding this: `--policy.private=true --policy.tags=\[ppo,rl\] --policy.license=mit`
@@ -518,7 +532,9 @@ If your local computer doesn't have a powerful GPU you could utilize Google Cola
Hugging Face jobs let's you easily select hardware and run the training in the cloud. So if you don't have a powerful GPU or you need more VRAM or just want to train a model much faster use HF Jobs! It's pay as you go and you simply pay for each second of use, you can see the pricing and additional information [here](https://huggingface.co/docs/hub/jobs). Hugging Face jobs let's you easily select hardware and run the training in the cloud. So if you don't have a powerful GPU or you need more VRAM or just want to train a model much faster use HF Jobs! It's pay as you go and you simply pay for each second of use, you can see the pricing and additional information [here](https://huggingface.co/docs/hub/jobs).
To run the training use this command: > **Tip:** if you just want to launch a standard training run, you can skip building the command below and use the integrated **Train on HF Jobs via `--job.target`** flow described further down — `lerobot-train` then submits the job, uploads a local-only dataset for you, and streams the logs.
To run the training manually use this command:
<hfoptions id="train_with_hf_jobs"> <hfoptions id="train_with_hf_jobs">
<hfoption id="Command"> <hfoption id="Command">
@@ -591,6 +607,51 @@ Once the training is started you can go to [Jobs](https://huggingface.co/setting
After training the model will be pushed to hub and you can use it as any other model with LeRobot. After training the model will be pushed to hub and you can use it as any other model with LeRobot.
#### Train on HF Jobs via `--job.target` (integrated CLI)
`lerobot-train` runs locally by default. To run on a HuggingFace GPU without constructing the Docker command yourself, pass `--job.target` with a hardware flavor name:
```bash
lerobot-train \
--dataset.repo_id=${HF_USER}/so101_test \
--policy.type=act \
--policy.repo_id=${HF_USER}/my_policy \
--job.target=a10g-small
```
List available flavors and pricing with `hf jobs hardware`. The run streams its logs to your terminal; press Ctrl-C to detach (the job keeps running in the cloud). Re-attach or cancel with:
```bash
hf jobs logs <job-id>
hf jobs cancel <job-id>
```
If your dataset exists only locally (not yet on the Hub), it is automatically pushed to a **private** Hub repo so the job can download it by `repo_id` (nothing is made public). The trained model is pushed to the model repo at the end of the run. To also push every intermediate checkpoint to the Hub as it is saved (so you can monitor progress mid-run), add `--save_checkpoint_to_hub=true` — this requires a runtime image that includes this feature.
Every job (and any dataset pushed by the run) is tagged `lerobot` so it's easy to find on the Hub. Add your own with `--job.tags '["my-tag"]'`.
By default the job is capped at `2d` (48h) of wall-clock. Override it with an HF Jobs duration string, e.g. `--job.timeout=4h` to fail faster or `--job.timeout=7d` for a longer run.
> **Note:** the model repo is created up front (it holds the staged training config the job runs from). If a run fails before the model is pushed, that repo is left on the Hub so you can inspect it — it is not deleted automatically, so repeated failures can leave empty repos behind. Remove one with `hf repo delete <repo-id>`.
**Prerequisites:** run `hf auth login` before submitting. For Weights & Biases integration, run `wandb login` or set `WANDB_API_KEY` on your machine — the key is forwarded to the job automatically.
**Resuming on a job.** Adding `--job.target` to a resume command runs the resume in the cloud — the same command works locally or remotely. The checkpoint repo is the source of truth, and new checkpoints continue the lineage in the same repo:
```bash
# resume a Hub run on a job (its checkpoints are already on the Hub)
lerobot-train --config_path=${HF_USER}/my_policy --resume=true --job.target=a10g-small
# resume a LOCAL run on a job — the checkpoint is uploaded to a private Hub repo first,
# then the job resumes from it (a local-only dataset is uploaded the same way)
lerobot-train \
--config_path=outputs/train/act_so101_test/checkpoints/last/pretrained_model/train_config.json \
--resume=true \
--job.target=a10g-small
```
Job settings come from the current command, so override `--job.target`, `--job.timeout`, etc. as needed; for the resumed run to itself be resumable later, keep `--save_checkpoint_to_hub=true`.
#### Upload policy checkpoints #### Upload policy checkpoints
Once training is done, upload the latest checkpoint with: Once training is done, upload the latest checkpoint with:
@@ -612,6 +673,8 @@ hf upload ${HF_USER}/act_so101_test${CKPT} \
Use `lerobot-rollout` to deploy a trained policy on your robot. You can choose different strategies depending on your needs: Use `lerobot-rollout` to deploy a trained policy on your robot. You can choose different strategies depending on your needs:
The examples below load the model from `--policy.path`. To pin a specific pushed version — useful once `--save_checkpoint_to_hub=true` has committed several checkpoints — add `--policy.pretrained_revision` with a commit hash, branch, or tag. Each pushed checkpoint is tagged with its step (e.g. `--policy.pretrained_revision=010000`), so you can recover a checkpoint by step without looking up its commit sha.
<hfoptions id="eval"> <hfoptions id="eval">
<hfoption id="Base mode (no recording)"> <hfoption id="Base mode (no recording)">
```bash ```bash
+1 -1
View File
@@ -319,7 +319,7 @@ If you want to dive deeper into this important topic, you can check out the [blo
#### Troubleshooting: #### Troubleshooting:
- On Linux, if the left and right arrow keys and escape key don't have any effect during data recording, make sure you've set the `$DISPLAY` environment variable. See [pynput limitations](https://pynput.readthedocs.io/en/latest/limitations.html#linux). - On Linux, the recording control-flow keys (arrow keys, Escape) work on X11, Wayland, and headless/SSH sessions as long as you run the recording from an interactive terminal (keep it focused) — no `$DISPLAY` setup is needed; the letter equivalents `n` / `r` / `q` also work. Note that **keyboard teleoperation of the LeKiwi base** is different: it relies on a global key backend and therefore works only on an X11 session, a Windows desktop, or macOS with Accessibility/Input Monitoring granted — not on Wayland or headless machines. See [pynput limitations](https://pynput.readthedocs.io/en/latest/limitations.html#linux).
## Replay an episode ## Replay an episode
+1 -1
View File
@@ -44,7 +44,7 @@ lerobot-record \
--dataset.num_episodes=5 \ --dataset.num_episodes=5 \
--dataset.single_task="Grab the black cube" \ --dataset.single_task="Grab the black cube" \
--dataset.streaming_encoding=true \ --dataset.streaming_encoding=true \
# --dataset.camera_encoder.vcodec=auto \ # --dataset.rgb_encoder.vcodec=auto \
--dataset.encoder_threads=2 --dataset.encoder_threads=2
``` ```
+1 -1
View File
@@ -143,7 +143,7 @@ lerobot-train \
--batch_size=4 \ --batch_size=4 \
--eval.batch_size=1 \ --eval.batch_size=1 \
--eval.n_episodes=1 \ --eval.n_episodes=1 \
--eval_freq=1000 --env_eval_freq=1000
``` ```
## Reproducing published results ## Reproducing published results
+1 -1
View File
@@ -173,7 +173,7 @@ lerobot-train \
--batch_size=4 \ --batch_size=4 \
--eval.batch_size=1 \ --eval.batch_size=1 \
--eval.n_episodes=1 \ --eval.n_episodes=1 \
--eval_freq=1000 --env_eval_freq=1000
``` ```
## Relationship to LIBERO ## Relationship to LIBERO
+2 -2
View File
@@ -120,11 +120,11 @@ lerobot-train \
--batch_size=4 \ --batch_size=4 \
--eval.batch_size=1 \ --eval.batch_size=1 \
--eval.n_episodes=1 \ --eval.n_episodes=1 \
--eval_freq=1000 --env_eval_freq=1000
``` ```
## Practical tips ## Practical tips
- Use the one-hot task conditioning for multi-task training (MT10/MT50 conventions) so policies have explicit task context. - Use the one-hot task conditioning for multi-task training (MT10/MT50 conventions) so policies have explicit task context.
- Inspect the dataset task descriptions and the `info["is_success"]` keys when writing post-processing or logging so your success metrics line up with the benchmark. - Inspect the dataset task descriptions and the `info["is_success"]` keys when writing post-processing or logging so your success metrics line up with the benchmark.
- Adjust `batch_size`, `steps`, and `eval_freq` to match your compute budget. - Adjust `batch_size`, `steps`, and `env_eval_freq` to match your compute budget.
+67 -5
View File
@@ -17,7 +17,7 @@ the paper, see [allenai/molmoact2](https://github.com/allenai/molmoact2).
Install LeRobot with the MolmoAct2 optional dependencies: Install LeRobot with the MolmoAct2 optional dependencies:
```bash ```bash
pip install -e ".[molmoact2]" uv sync --locked --extra molmoact2
``` ```
To run the models in this repository, you need an NVIDIA GPU. The measurements To run the models in this repository, you need an NVIDIA GPU. The measurements
@@ -46,8 +46,8 @@ The repo has been tested with Ubuntu 22.04.
To use MolmoAct2 in a LeRobot training config, set: To use MolmoAct2 in a LeRobot training config, set:
```python ```bash
policy.type=molmoact2 --policy.type=molmoact2
``` ```
## Training ## Training
@@ -103,7 +103,7 @@ accelerate launch \
--batch_size=32 \ --batch_size=32 \
--num_workers=4 \ --num_workers=4 \
--log_freq=20 \ --log_freq=20 \
--eval_freq=-1 \ --env_eval_freq=-1 \
--save_checkpoint=true \ --save_checkpoint=true \
--save_freq=2000 --save_freq=2000
``` ```
@@ -142,7 +142,7 @@ accelerate launch \
--batch_size=32 \ --batch_size=32 \
--num_workers=4 \ --num_workers=4 \
--log_freq=20 \ --log_freq=20 \
--eval_freq=-1 \ --env_eval_freq=-1 \
--save_checkpoint=true \ --save_checkpoint=true \
--save_freq=2000 --save_freq=2000
``` ```
@@ -386,6 +386,68 @@ These results demonstrate MolmoAct2's strong performance across diverse robotic
manipulation tasks. To reproduce them, follow the instructions in the LIBERO manipulation tasks. To reproduce them, follow the instructions in the LIBERO
evaluation section. evaluation section.
## Hardware Deployment (lerobot-rollout)
LeRobot-format checkpoints are available on the Hub for direct use with
`lerobot-rollout`. Each checkpoint uses specific camera names that must
match your robot's camera configuration.
### Camera naming convention
Each checkpoint expects specific `observation.images.*` keys.
If your robot cameras have different names, use `--rename_map` to map them:
| Checkpoint | Camera keys | Description |
| ----------------------------- | ---------------------- | ------------------------ |
| MolmoAct2-LIBERO-LeRobot | `image`, `wrist_image` | LIBERO sim cameras |
| MolmoAct2-BimanualYAM-LeRobot | `top`, `left`, `right` | YAM 3-camera setup |
| MolmoAct2-DROID-LeRobot | `cam0`, `cam1` | External + wrist |
| MolmoAct2-SO100_101-LeRobot | `cam0`, `cam1` | Primary + secondary view |
Example with an SO-100 robot using top and side cameras:
```bash
lerobot-rollout \
--policy.path=lerobot/MolmoAct2-SO100_101-LeRobot \
--rename_map='{"observation.images.top": "observation.images.cam0", "observation.images.side": "observation.images.cam1"}' \
--robot.type=so100_follower \
--robot.port=/dev/ttyACM0 \
--robot.cameras='{
top: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30},
side: {type: opencv, index_or_path: 2, width: 640, height: 480, fps: 30}
}' \
--task="pick up the red cube" --duration=30
```
To use a wrist camera instead, just change the rename mapping:
```bash
--rename_map='{"observation.images.top": "observation.images.cam0", "observation.images.wrist": "observation.images.cam1"}'
```
### Joint frame transform (SO-100/101 zero-shot)
<Tip warning={true}>
The MolmoAct2-SO100_101 checkpoint was trained on data that uses a different
joint calibration convention than LeRobot >= 0.5.0. Without a frame
correction, the arm may move in the wrong direction.
This affects both **zero-shot deployment** and **fine-tuning** from the
original checkpoint. The pretrained weights expect the old convention, so
all joint data (observations and actions) must be transformed to match.
The converted LeRobot checkpoint (`lerobot/MolmoAct2-SO100_101-LeRobot`)
already includes this correction in its processor pipeline. If you convert
or fine-tune the checkpoint yourself, set the following in the policy config (`configuration_molmoact2.py`):
- `joint_signs`: `[1, -1, 1, 1, 1, 1]` (flips shoulder_lift direction)
- `joint_offsets`: `[0, 90, 90, 0, 0, 0]` (shifts shoulder_lift and elbow_flex by 90°)
See the [backward compatibility guide](./backwardcomp) for details on the
calibration change.
</Tip>
## Differences From the Original Implementation ## Differences From the Original Implementation
This LeRobot port is intended to match MolmoAct2 behavior while using LeRobot's This LeRobot port is intended to match MolmoAct2 behavior while using LeRobot's
+2 -2
View File
@@ -95,7 +95,7 @@ If you want to scale your hyperparameters when using multiple GPUs, you should d
accelerate launch --num_processes=2 $(which lerobot-train) \ accelerate launch --num_processes=2 $(which lerobot-train) \
--optimizer.lr=2e-4 \ --optimizer.lr=2e-4 \
--dataset.repo_id=lerobot/pusht \ --dataset.repo_id=lerobot/pusht \
--policy=act --policy.type=act
``` ```
**Training Steps Scaling:** **Training Steps Scaling:**
@@ -110,7 +110,7 @@ accelerate launch --num_processes=2 $(which lerobot-train) \
--batch_size=8 \ --batch_size=8 \
--steps=50000 \ --steps=50000 \
--dataset.repo_id=lerobot/pusht \ --dataset.repo_id=lerobot/pusht \
--policy=act --policy.type=act
``` ```
## Training Large Models with FSDP ## Training Large Models with FSDP
+1 -1
View File
@@ -314,7 +314,7 @@ lerobot-train \
--steps=30000 \ --steps=30000 \
--save_freq=1000 \ --save_freq=1000 \
--log_freq=100 \ --log_freq=100 \
--eval_freq=1000 \ --env_eval_freq=1000 \
--policy.type=multi_task_dit \ --policy.type=multi_task_dit \
--policy.device=cuda \ --policy.device=cuda \
--policy.horizon=32 \ --policy.horizon=32 \
+2 -2
View File
@@ -96,7 +96,7 @@ lerobot-train \
--policy.type=pi0_fast \ --policy.type=pi0_fast \
--output_dir=./outputs/pi0fast_training \ --output_dir=./outputs/pi0fast_training \
--job_name=pi0fast_training \ --job_name=pi0fast_training \
--policy.pretrained_path=lerobot/pi0_fast_base \ --policy.pretrained_path=lerobot/pi0fast-base \
--policy.dtype=bfloat16 \ --policy.dtype=bfloat16 \
--policy.gradient_checkpointing=true \ --policy.gradient_checkpointing=true \
--policy.chunk_size=10 \ --policy.chunk_size=10 \
@@ -187,7 +187,7 @@ lerobot-train \
--dataset.repo_id=lerobot/libero \ --dataset.repo_id=lerobot/libero \
--output_dir=outputs/libero_pi0fast \ --output_dir=outputs/libero_pi0fast \
--job_name=libero_pi0fast \ --job_name=libero_pi0fast \
--policy.path=lerobot/pi0fast_base \ --policy.path=lerobot/pi0fast-base \
--policy.dtype=bfloat16 \ --policy.dtype=bfloat16 \
--steps=100000 \ --steps=100000 \
--save_freq=20000 \ --save_freq=20000 \
+56
View File
@@ -0,0 +1,56 @@
## Research Paper
Paper: https://arxiv.org/abs/2603.16666
## Repository
Code: https://github.com/yuantianyuan01/FastWAM
Project page: https://yuantianyuan01.github.io/FastWAM/
## Citation
```bibtex
@article{yuan2026fastwam,
title = {Fast-WAM: Do World Action Models Need Test-time Future Imagination?},
author = {Tianyuan Yuan and Zibin Dong and Yicheng Liu and Hang Zhao},
journal = {arXiv preprint arXiv:2603.16666},
year = {2026},
url = {https://arxiv.org/abs/2603.16666}
}
```
## Additional Resources
Base video model: https://huggingface.co/Wan-AI/Wan2.2-TI2V-5B
Released upstream checkpoints: https://huggingface.co/yuanty/fastwam
## Results
Evaluated on LIBERO with [`ZibinDong/fastwam_libero_uncond_2cam224`](https://huggingface.co/ZibinDong/fastwam_libero_uncond_2cam224):
| Suite | Success rate | n_episodes |
| -------------- | -----------: | ---------: |
| libero_spatial | 97.6% | 500 |
| libero_object | 99.0% | 500 |
| libero_goal | 95.0% | 500 |
| libero_10 | 94.0% | 500 |
| **average** | **96.4%** | 2000 |
Reproduce: `lerobot-eval --policy.path=ZibinDong/fastwam_libero_uncond_2cam224 --policy.device=cuda --policy.torch_dtype=float32 --policy.n_action_steps=10 --env.type=libero --env.task=libero_spatial --env.observation_height=256 --env.observation_width=256 --eval.batch_size=1 --eval.n_episodes=50 --seed=0 --env.episode_length=300`.
For LIBERO-10, use `--env.task=libero_10 --env.episode_length=600`:
```bash
lerobot-eval \
--policy.path=ZibinDong/fastwam_libero_uncond_2cam224 \
--policy.device=cuda \
--policy.torch_dtype=float32 \
--policy.n_action_steps=10 \
--env.type=libero \
--env.task=libero_10 --env.observation_height=256 --env.observation_width=256 \
--eval.batch_size=1 \
--eval.n_episodes=50 \
--seed=0 --env.episode_length=600
```
+2 -2
View File
@@ -161,7 +161,7 @@ lerobot-record \
--dataset.private=true \ --dataset.private=true \
--dataset.streaming_encoding=true \ --dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \ --dataset.encoder_threads=2 \
# --dataset.camera_encoder.vcodec=auto \ # --dataset.rgb_encoder.vcodec=auto \
--display_data=true --display_data=true
``` ```
@@ -203,7 +203,7 @@ lerobot-record \
--dataset.private=true \ --dataset.private=true \
--dataset.streaming_encoding=true \ --dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \ --dataset.encoder_threads=2 \
# --dataset.camera_encoder.vcodec=auto \ # --dataset.rgb_encoder.vcodec=auto \
--display_data=true --display_data=true
``` ```
+1 -1
View File
@@ -166,7 +166,7 @@ lerobot-train \
--output_dir=./outputs/smolvla_robocasa_CloseFridge \ --output_dir=./outputs/smolvla_robocasa_CloseFridge \
--steps=100000 \ --steps=100000 \
--batch_size=4 \ --batch_size=4 \
--eval_freq=5000 \ --env_eval_freq=5000 \
--eval.batch_size=1 \ --eval.batch_size=1 \
--eval.n_episodes=5 \ --eval.n_episodes=5 \
--save_freq=10000 --save_freq=10000
+1 -1
View File
@@ -122,7 +122,7 @@ The video below shows the sequence of steps for setting the motor ids.
#### Follower #### Follower
Connect the usb cable from your computer and the power supply to the follower arm's controller board. Then, run the following command or run the API example with the port you got from the previous step. You'll also need to give your leader arm a name with the `id` parameter. Connect the usb cable from your computer and the power supply to the follower arm's controller board. Then, run the following command or run the API example with the port you got from the previous step. You'll also need to give your follower arm a name with the `id` parameter.
<hfoptions id="setup_motors"> <hfoptions id="setup_motors">
<hfoption id="Command"> <hfoption id="Command">
+20 -20
View File
@@ -17,7 +17,7 @@ This makes `save_episode()` near-instant (the video is already encoded by the ti
| Parameter | CLI Flag | Type | Default | Description | | Parameter | CLI Flag | Type | Default | Description |
| ----------------------- | --------------------------------- | ------------- | ------------- | ----------------------------------------------------------------- | | ----------------------- | --------------------------------- | ------------- | ------------- | ----------------------------------------------------------------- |
| `streaming_encoding` | `--dataset.streaming_encoding` | `bool` | `True` | Enable real-time encoding during capture | | `streaming_encoding` | `--dataset.streaming_encoding` | `bool` | `True` | Enable real-time encoding during capture |
| `vcodec` | `--dataset.camera_encoder.vcodec` | `str` | `"libsvtav1"` | Video codec. `"auto"` detects best HW encoder | | `vcodec` | `--dataset.rgb_encoder.vcodec` | `str` | `"libsvtav1"` | Video codec. `"auto"` detects best HW encoder |
| `encoder_threads` | `--dataset.encoder_threads` | `int \| None` | `None` (auto) | Threads per encoder instance. `None` will leave the vcoded decide | | `encoder_threads` | `--dataset.encoder_threads` | `int \| None` | `None` (auto) | Threads per encoder instance. `None` will leave the vcoded decide |
| `encoder_queue_maxsize` | `--dataset.encoder_queue_maxsize` | `int` | `30` | Max buffered frames per camera (~1s at 30fps). Consumes RAM | | `encoder_queue_maxsize` | `--dataset.encoder_queue_maxsize` | `int` | `30` | Max buffered frames per camera (~1s at 30fps). Consumes RAM |
@@ -82,15 +82,15 @@ Use HW encoding when:
### Available HW Encoders ### Available HW Encoders
| Encoder | Platform | Hardware | CLI Value | | Encoder | Platform | Hardware | CLI Value |
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------- | | ------------------- | ------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
| `h264_videotoolbox` | macOS | Apple Silicon / Intel | `--dataset.camera_encoder.vcodec=h264_videotoolbox` | | `h264_videotoolbox` | macOS | Apple Silicon / Intel | `--dataset.rgb_encoder.vcodec=h264_videotoolbox` |
| `hevc_videotoolbox` | macOS | Apple Silicon / Intel | `--dataset.camera_encoder.vcodec=hevc_videotoolbox` | | `hevc_videotoolbox` | macOS | Apple Silicon / Intel | `--dataset.rgb_encoder.vcodec=hevc_videotoolbox` |
| `h264_nvenc` | Linux/Windows | NVIDIA GPU | `--dataset.camera_encoder.vcodec=h264_nvenc` | | `h264_nvenc` | Linux/Windows | NVIDIA GPU | `--dataset.rgb_encoder.vcodec=h264_nvenc` |
| `hevc_nvenc` | Linux/Windows | NVIDIA GPU | `--dataset.camera_encoder.vcodec=hevc_nvenc` | | `hevc_nvenc` | Linux/Windows | NVIDIA GPU | `--dataset.rgb_encoder.vcodec=hevc_nvenc` |
| `h264_vaapi` | Linux | Intel/AMD GPU | `--dataset.camera_encoder.vcodec=h264_vaapi` | | `h264_vaapi` | Linux | Intel/AMD GPU | `--dataset.rgb_encoder.vcodec=h264_vaapi` |
| `h264_qsv` | Linux/Windows | Intel Quick Sync | `--dataset.camera_encoder.vcodec=h264_qsv` | | `h264_qsv` | Linux/Windows | Intel Quick Sync | `--dataset.rgb_encoder.vcodec=h264_qsv` |
| `auto` | Any | Probes the system for available HW encoders. Falls back to `libsvtav1` if no HW encoder is found | `--dataset.camera_encoder.vcodec=auto` | | `auto` | Any | Probes the system for available HW encoders. Falls back to `libsvtav1` if no HW encoder is found | `--dataset.rgb_encoder.vcodec=auto` |
> [!NOTE] > [!NOTE]
> In order to use the HW accelerated encoders you might need to upgrade your GPU drivers. > In order to use the HW accelerated encoders you might need to upgrade your GPU drivers.
@@ -100,15 +100,15 @@ Use HW encoding when:
## 5. Troubleshooting ## 5. Troubleshooting
| Symptom | Likely Cause | Fix | | Symptom | Likely Cause | Fix |
| ------------------------------------------------------------------ | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| System freezes or choppy robot movement or Rerun visualization lag | CPU starved (100% load usage) | Close other apps, reduce encoding throughput, lower `encoder_threads`, use `h264`, use `display_data=False`. If the CPU continues to be at 100% then it might be insufficient for your setup, consider `--dataset.streaming_encoding=false` or HW encoding (`--dataset.camera_encoder.vcodec=auto`) | | System freezes or choppy robot movement or Rerun visualization lag | CPU starved (100% load usage) | Close other apps, reduce encoding throughput, lower `encoder_threads`, use `h264`, use `display_data=False`. If the CPU continues to be at 100% then it might be insufficient for your setup, consider `--dataset.streaming_encoding=false` or HW encoding (`--dataset.rgb_encoder.vcodec=auto`) |
| "Encoder queue full" warnings or dropped frames in dataset | Encoder can't keep up (Queue overflow) | If CPU is not at 100%: Increase `encoder_threads`, increase `encoder_queue_maxsize` or use HW encoding (`--dataset.camera_encoder.vcodec=auto`). | | "Encoder queue full" warnings or dropped frames in dataset | Encoder can't keep up (Queue overflow) | If CPU is not at 100%: Increase `encoder_threads`, increase `encoder_queue_maxsize` or use HW encoding (`--dataset.rgb_encoder.vcodec=auto`). |
| High RAM usage | Queue filling faster than encoding | `encoder_threads` too low or CPU insufficient. Reduce `encoder_queue_maxsize` or use HW encoding | | High RAM usage | Queue filling faster than encoding | `encoder_threads` too low or CPU insufficient. Reduce `encoder_queue_maxsize` or use HW encoding |
| Large video files | Using HW encoder or H.264 | Expected trade-off. Switch to `libsvtav1` if CPU allows | | Large video files | Using HW encoder or H.264 | Expected trade-off. Switch to `libsvtav1` if CPU allows |
| `save_episode()` still slow | `streaming_encoding` is `False` | Set `--dataset.streaming_encoding=true` | | `save_episode()` still slow | `streaming_encoding` is `False` | Set `--dataset.streaming_encoding=true` |
| Encoder thread crash | Codec not available or invalid settings | Check `vcodec` is installed, try `--dataset.camera_encoder.vcodec=auto` | | Encoder thread crash | Codec not available or invalid settings | Check `vcodec` is installed, try `--dataset.rgb_encoder.vcodec=auto` |
| Recorded dataset is missing frames | CPU/GPU starvation or occasional load spikes | If ~5% of frames are missing, your system is likely overloaded — follow the recommendations above. If fewer frames are missing (~2%), they are probably due to occasional transient load spikes (often at startup) and can be considered expected. | | Recorded dataset is missing frames | CPU/GPU starvation or occasional load spikes | If ~5% of frames are missing, your system is likely overloaded — follow the recommendations above. If fewer frames are missing (~2%), they are probably due to occasional transient load spikes (often at startup) and can be considered expected. |
## 6. Recommended Configurations ## 6. Recommended Configurations
@@ -146,7 +146,7 @@ On very constrained systems, streaming encoding may compete too heavily with the
# 2camsx 640x480x3 @30fps: Requires some tuning. # 2camsx 640x480x3 @30fps: Requires some tuning.
# Use H.264, disable streaming, consider batching encoding # Use H.264, disable streaming, consider batching encoding
lerobot-record --dataset.camera_encoder.vcodec=h264 --dataset.streaming_encoding=false ... lerobot-record --dataset.rgb_encoder.vcodec=h264 --dataset.streaming_encoding=false ...
``` ```
## 7. Closing note ## 7. Closing note
+51 -8
View File
@@ -11,8 +11,9 @@ LeRobot provides several utilities for manipulating datasets:
3. **Merge Datasets** - Combine multiple datasets into one. The datasets must have identical features, and episodes are concatenated in the order specified in `repo_ids` 3. **Merge Datasets** - Combine multiple datasets into one. The datasets must have identical features, and episodes are concatenated in the order specified in `repo_ids`
4. **Add Features** - Add new features to a dataset 4. **Add Features** - Add new features to a dataset
5. **Remove Features** - Remove features from a dataset 5. **Remove Features** - Remove features from a dataset
6. **Convert to Video** - Convert image-based datasets to video format for efficient storage 6. **Convert to Video** - Convert image-based datasets to video format for efficient storage (RGB and depth cameras are encoded with separate encoders)
7. **Show the Info of Datasets** - Show the summary of datasets information such as number of episode etc. 7. **Re-encode Videos** - Re-encode an existing video dataset's RGB and/or depth streams with new encoder settings
8. **Show the Info of Datasets** - Show the summary of datasets information such as number of episode etc.
The core implementation is in `lerobot.datasets.dataset_tools`. The core implementation is in `lerobot.datasets.dataset_tools`.
An example script detailing how to use the tools API is available in `examples/dataset/use_dataset_tools.py`. An example script detailing how to use the tools API is available in `examples/dataset/use_dataset_tools.py`.
@@ -117,10 +118,19 @@ lerobot-edit-dataset \
--repo_id lerobot/pusht_image \ --repo_id lerobot/pusht_image \
--operation.type convert_image_to_video \ --operation.type convert_image_to_video \
--operation.output_dir outputs/pusht_video \ --operation.output_dir outputs/pusht_video \
--operation.camera_encoder.vcodec libsvtav1 \ --operation.rgb_encoder.vcodec libsvtav1 \
--operation.camera_encoder.pix_fmt yuv420p \ --operation.rgb_encoder.pix_fmt yuv420p \
--operation.camera_encoder.g 2 \ --operation.rgb_encoder.g 2 \
--operation.camera_encoder.crf 30 --operation.rgb_encoder.crf 30
# Convert a dataset that includes depth maps, customizing the depth encoder
lerobot-edit-dataset \
--repo_id lerobot/pusht_image \
--operation.type convert_image_to_video \
--operation.output_dir outputs/pusht_video \
--operation.depth_encoder.depth_min 0.01 \
--operation.depth_encoder.depth_max 10.0 \
--operation.depth_encoder.use_log true
# Convert only specific episodes # Convert only specific episodes
lerobot-edit-dataset \ lerobot-edit-dataset \
@@ -147,11 +157,42 @@ lerobot-edit-dataset \
**Parameters:** **Parameters:**
- `output_dir`: Custom output directory (optional - by default uses `new_repo_id` or `{repo_id}_video`) - `output_dir`: Custom output directory (optional - by default uses `new_repo_id` or `{repo_id}_video`)
- `camera_encoder`: Video encoder settings — all sub-fields accessible via `--operation.camera_encoder.<field>. See [Video Encoding Parameters](./video_encoding_parameters) for more details. - `rgb_encoder`: Video encoder settings applied to RGB cameras — all sub-fields accessible via `--operation.rgb_encoder.<field>`. See [Video Encoding Parameters](./video_encoding_parameters) for more details.
- `depth_encoder`: Video encoder settings applied to depth-map cameras (e.g. from an Intel RealSense). In addition to the standard encoder fields it exposes the depth quantization knobs (`depth_min`, `depth_max`, `shift`, `use_log`), accessible via `--operation.depth_encoder.<field>`. These quantization settings are persisted to the dataset metadata so depth can be dequantized back to physical units on load. See the [Depth streams](./video_encoding_parameters#depth-streams) section for details.
- `episode_indices`: List of specific episodes to convert (default: all episodes) - `episode_indices`: List of specific episodes to convert (default: all episodes)
- `num_workers`: Number of parallel workers for processing (default: 4) - `num_workers`: Number of parallel workers for processing (default: 4)
**Note:** The resulting dataset will be a proper LeRobotDataset with all cameras encoded as videos in the `videos/` directory, with parquet files containing only metadata (no raw image data). All episodes, stats, and tasks are preserved. **Note:** The resulting dataset will be a proper LeRobotDataset with all cameras encoded as videos in the `videos/` directory, with parquet files containing only metadata (no raw image data). Depth-map cameras are detected automatically and routed to the `depth_encoder`, while RGB cameras use the `rgb_encoder`. All episodes, stats, and tasks are preserved.
#### Re-encode Videos
Re-encode the videos of an existing video dataset with different encoder settings, without going back to raw frames. RGB videos use the `rgb_encoder` and depth videos use the `depth_encoder`. Provide only the encoder(s) you want to re-encode; the other stream type is left untouched.
```bash
# Re-encode all RGB videos with new settings (saves to lerobot/pusht_reencoded by default)
lerobot-edit-dataset \
--repo_id lerobot/pusht \
--operation.type reencode_videos \
--operation.rgb_encoder.vcodec h264 \
--operation.rgb_encoder.pix_fmt yuv420p \
--operation.rgb_encoder.crf 23
# Re-encode both RGB and depth videos in a dataset with depth maps
lerobot-edit-dataset \
--repo_id lerobot/pusht_depth \
--operation.type reencode_videos \
--operation.rgb_encoder.vcodec h264 \
--operation.depth_encoder.crf 50
```
**Parameters:**
- `rgb_encoder`: Encoder settings applied to every RGB video. Omit to skip re-encoding RGB videos.
- `depth_encoder`: Encoder settings applied to every depth video. Omit to skip re-encoding depth videos.
- `num_workers`: Number of parallel workers for processing.
> [!NOTE]
> When re-encoding depth videos, the existing depth quantization parameters (`depth_min`, `depth_max`, `shift`, `use_log`) and the `is_depth_map` flag are **preserved** — re-encoding only changes the codec/quality of the stored stream, not how depth is dequantized on load.
### Show the information of datasets ### Show the information of datasets
@@ -224,6 +265,8 @@ lerobot-dataset-viz \
Once executed, the tool opens `rerun.io` and displays the camera streams, robot states, and actions for the selected episode. Once executed, the tool opens `rerun.io` and displays the camera streams, robot states, and actions for the selected episode.
To use [Foxglove](https://foxglove.dev) instead of Rerun, install the extra add `--display-mode foxglove`. This starts a WebSocket server (connect the Foxglove app to `ws://127.0.0.1:8765`) that serves the episode as a seekable timeline you can play/pause and scrub.
For advanced usage—including visualizing datasets stored on a remote server—run: For advanced usage—including visualizing datasets stored on a remote server—run:
```bash ```bash
+84 -13
View File
@@ -2,15 +2,15 @@
When video storage is enabled, LeRobot stores each camera stream as an **MP4** file instead of saving one image file per timestep. Video encoding compresses across time, which usually cuts dataset size and I/O compared to a pile of PNG, while keeping MP4 — a format every player and loader understands. When video storage is enabled, LeRobot stores each camera stream as an **MP4** file instead of saving one image file per timestep. Video encoding compresses across time, which usually cuts dataset size and I/O compared to a pile of PNG, while keeping MP4 — a format every player and loader understands.
Encoding frames into an MP4 is a full FFmpeg pipeline: choice of encoder, pixel format, GOP/keyframes, quality vs. speed, and optional extra encoder flags. Most of these knobs are user-tunable through `camera_encoder`, a nested `VideoEncoderConfig` (`lerobot.configs.video.VideoEncoderConfig`) passed through PyAV. Encoding frames into an MP4 is a full FFmpeg pipeline: choice of encoder, pixel format, GOP/keyframes, quality vs. speed, and optional extra encoder flags. Most of these knobs are user-tunable through `rgb_encoder`, a nested `RGBEncoderConfig` (`lerobot.configs.video.RGBEncoderConfig`) passed through PyAV.
You can set these parameters from the CLI with `--dataset.camera_encoder.<field>` (e.g. with `lerobot-record` or `lerobot-rollout`). The same block applies to every camera video stream in that run. You can set these parameters from the CLI with `--dataset.rgb_encoder.<field>` (e.g. with `lerobot-record` or `lerobot-rollout`). The same block applies to every camera video stream in that run.
<Tip> <Tip>
Video storage must be on for `camera_encoder` to have any effect — 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 `use_videos=True` in Python APIs, or `--dataset.video=true` on the CLI (the
recording default). With video off, inputs stay as images and `camera_encoder` recording default). With video off, inputs stay as images and `rgb_encoder` is
is ignored. ignored.
</Tip> </Tip>
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). 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).
@@ -33,9 +33,9 @@ lerobot-record \
--dataset.single_task="Grab the cube" \ --dataset.single_task="Grab the cube" \
--dataset.streaming_encoding=true \ --dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \ --dataset.encoder_threads=2 \
--dataset.camera_encoder.vcodec=h264 \ --dataset.rgb_encoder.vcodec=h264 \
--dataset.camera_encoder.preset=fast \ --dataset.rgb_encoder.preset=fast \
--dataset.camera_encoder.extra_options={"tune": "film", "profile:v": "high", "bf": 2} \ --dataset.rgb_encoder.extra_options={"tune": "film", "profile:v": "high", "bf": 2} \
--display_data=true --display_data=true
``` ```
@@ -50,7 +50,7 @@ Only override these parameters if you have a specific reason to, and measure the
</Tip> </Tip>
All flags below are prefixed with `--dataset.camera_encoder.` on the CLI. All flags below are prefixed with `--dataset.rgb_encoder.` on the CLI.
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
| --------------- | ---------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------- | ---------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -65,6 +65,77 @@ All flags below are prefixed with `--dataset.camera_encoder.` on the CLI.
--- ---
## Depth streams
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 04095 (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;
```
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.<field>`:
```bash
lerobot-record \
... \
--dataset.depth_encoder.vcodec=hevc \
--dataset.depth_encoder.depth_min=0.05 \
--dataset.depth_encoder.depth_max=5.0 \
--dataset.depth_encoder.use_log=true
```
| Parameter | Type | Default | Description |
| --------------- | ------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `vcodec` | `str` | `"hevc"` | HEVC Main 12 (a 12-bit-capable codec, MP4-compatible). |
| `extra_options` | `dict` | `{"x265-params": "lossless=1"}` | **Depth defaults to lossless** (exact round-trip); `crf` is ignored. Pass `extra_options={}` and set `crf` for a smaller lossy stream. |
| `pix_fmt` | `str` | `"gray12le"` | Single-channel 12-bit pixel format used to carry the quantized codes. |
| `depth_min` | `float` | `0.01` | Depth in metres mapped to quantum `0`. Values below are clipped on decode. |
| `depth_max` | `float` | `10.0` | Depth in metres mapped to quantum `4095`. Values above are clipped on decode. |
| `shift` | `float` | `3.5` | Pre-log offset (metres) used in logarithmic quantization for numerical stability near zero. Must satisfy `depth_min + shift > 0`. |
| `use_log` | `bool` | `True` | If `true`, quantize in log-space (recommended for typical depth sensors). Set to `false` for uniform/linear quantization. |
> [!TIP]
> `depth_min`, `depth_max`, and `shift` are always interpreted in **metres**, regardless of the input depth's unit. Inputs are auto-detected: integer arrays (e.g. `uint16` millimetres straight from a RealSense) are treated as millimetres, floating arrays as metres.
> Pick `depth_min` / `depth_max` to bracket the actual working range of your sensor — quanta outside that range saturate, which can crush detail at the boundaries.
Depth features are flagged with `"is_depth_map": true` in `meta/info.json`, and their quantizer settings (`video.depth_min`, `video.depth_max`, `video.shift`, `video.use_log`) are persisted — which is what lets depth be **dequantized back to physical units** on load.
### Output unit at load time
`depth_encoder` is a **record-time** concern. The unit that depth maps are dequantized to on _load_ (e.g. during training) is set separately by the read-time flag `--dataset.depth_output_unit`:
```bash
lerobot-train \
--dataset.repo_id=<my_username>/<my_dataset_name> \
--dataset.depth_output_unit=m \
--policy.type=act
```
| Parameter | Type | Default | Description |
| ------------------- | ----- | ------- | -------------------------------------------------------------------------------------------- |
| `depth_output_unit` | `str` | `"mm"` | Physical unit depth maps are dequantized to on load: `"mm"` (millimetres) or `"m"` (metres). |
> [!TIP]
> This is purely a decode-time presentation choice — it does **not** alter the stored video or its metadata, so the same dataset can be read as `mm` or `m` without re-encoding. It has no effect on datasets without depth cameras.
---
## Persistence in dataset metadata ## Persistence in dataset metadata
After the first episode of a video stream is encoded, the encoder configuration is **persisted into the dataset metadata** (`meta/info.json`) under each video feature, alongside the values probed from the file itself. For a video feature `observation.images.<camera>`, the layout in `info.json` is: After the first episode of a video stream is encoded, the encoder configuration is **persisted into the dataset metadata** (`meta/info.json`) under each video feature, alongside the values probed from the file itself. For a video feature `observation.images.<camera>`, the layout in `info.json` is:
@@ -82,7 +153,7 @@ After the first episode of a video stream is encoded, the encoder configuration
"video.pix_fmt": "yuv420p", "video.pix_fmt": "yuv420p",
"video.fps": 30, "video.fps": 30,
"video.channels": 3, "video.channels": 3,
"video.is_depth_map": false, "is_depth_map": false,
"video.g": 2, "video.g": 2,
"video.crf": 30, "video.crf": 30,
"video.preset": "fast", "video.preset": "fast",
@@ -97,12 +168,12 @@ After the first episode of a video stream is encoded, the encoder configuration
Two sources contribute to the `info` block: 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`, `video.is_depth_map`, plus `audio.*` if an audio stream is present. - **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 `VideoEncoderConfig`): `video.g`, `video.crf`, `video.preset`, `video.fast_decode`, `video.video_backend`, `video.extra_options`. - **Encoder-derived** (taken from `RGBEncoderConfig` or `DepthEncoderConfig`): `video.g`, `video.crf`, `video.preset`, `video.fast_decode`, `video.video_backend`, `video.extra_options`.
<Tip> <Tip>
This block is populated **once**, from the **first** episode. It assumes every This block is populated **once**, from the **first** episode. It assumes every
episode in the dataset was encoded with the same `camera_encoder`. Changing episode in the dataset was encoded with the same `rgb_encoder`. Changing
encoder settings partway through a recording is not supported — the encoder settings partway through a recording is not supported — the
`info.json` will only reflect the parameters used for the first episode. `info.json` will only reflect the parameters used for the first episode.
</Tip> </Tip>
+1 -1
View File
@@ -165,7 +165,7 @@ lerobot-train \
--output_dir=./outputs/smolvla_vlabench_primitive \ --output_dir=./outputs/smolvla_vlabench_primitive \
--steps=100000 \ --steps=100000 \
--batch_size=4 \ --batch_size=4 \
--eval_freq=5000 \ --env_eval_freq=5000 \
--eval.batch_size=1 \ --eval.batch_size=1 \
--eval.n_episodes=1 \ --eval.n_episodes=1 \
--save_freq=10000 --save_freq=10000
+2 -1
View File
@@ -17,7 +17,7 @@
import logging import logging
import time import time
from lerobot.common.control_utils import init_keyboard_listener, predict_action from lerobot.common.control_utils import predict_action
from lerobot.datasets import LeRobotDataset from lerobot.datasets import LeRobotDataset
from lerobot.policies import make_pre_post_processors from lerobot.policies import make_pre_post_processors
from lerobot.policies.act import ACTPolicy from lerobot.policies.act import ACTPolicy
@@ -26,6 +26,7 @@ from lerobot.processor import make_default_processors
from lerobot.robots.lekiwi import LeKiwiClient, LeKiwiClientConfig from lerobot.robots.lekiwi import LeKiwiClient, LeKiwiClientConfig
from lerobot.utils.constants import ACTION, OBS_STR from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import build_dataset_frame, hw_to_dataset_features from lerobot.utils.feature_utils import build_dataset_frame, hw_to_dataset_features
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.robot_utils import precise_sleep from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data from lerobot.utils.visualization_utils import init_rerun, log_rerun_data
+1 -1
View File
@@ -14,7 +14,6 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from lerobot.common.control_utils import init_keyboard_listener
from lerobot.datasets import LeRobotDataset from lerobot.datasets import LeRobotDataset
from lerobot.processor import make_default_processors from lerobot.processor import make_default_processors
from lerobot.robots.lekiwi import LeKiwiClient, LeKiwiClientConfig from lerobot.robots.lekiwi import LeKiwiClient, LeKiwiClientConfig
@@ -23,6 +22,7 @@ from lerobot.teleoperators.keyboard import KeyboardTeleop, KeyboardTeleopConfig
from lerobot.teleoperators.so_leader import SO100Leader, SO100LeaderConfig from lerobot.teleoperators.so_leader import SO100Leader, SO100LeaderConfig
from lerobot.utils.constants import ACTION, OBS_STR from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import hw_to_dataset_features from lerobot.utils.feature_utils import hw_to_dataset_features
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun from lerobot.utils.visualization_utils import init_rerun
+2 -1
View File
@@ -18,7 +18,7 @@ import logging
import time import time
from lerobot.cameras.opencv import OpenCVCameraConfig from lerobot.cameras.opencv import OpenCVCameraConfig
from lerobot.common.control_utils import init_keyboard_listener, predict_action from lerobot.common.control_utils import predict_action
from lerobot.configs import FeatureType, PolicyFeature from lerobot.configs import FeatureType, PolicyFeature
from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features
from lerobot.model.kinematics import RobotKinematics from lerobot.model.kinematics import RobotKinematics
@@ -41,6 +41,7 @@ from lerobot.robots.so_follower.robot_kinematic_processor import (
from lerobot.types import RobotAction, RobotObservation from lerobot.types import RobotAction, RobotObservation
from lerobot.utils.constants import ACTION, OBS_STR from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import build_dataset_frame, combine_feature_dicts from lerobot.utils.feature_utils import build_dataset_frame, combine_feature_dicts
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.robot_utils import precise_sleep from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data from lerobot.utils.visualization_utils import init_rerun, log_rerun_data
+1 -1
View File
@@ -15,7 +15,6 @@
# limitations under the License. # limitations under the License.
from lerobot.cameras.opencv import OpenCVCameraConfig from lerobot.cameras.opencv import OpenCVCameraConfig
from lerobot.common.control_utils import init_keyboard_listener
from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features
from lerobot.model.kinematics import RobotKinematics from lerobot.model.kinematics import RobotKinematics
from lerobot.processor import ( from lerobot.processor import (
@@ -39,6 +38,7 @@ from lerobot.teleoperators.phone.config_phone import PhoneOS
from lerobot.teleoperators.phone.phone_processor import MapPhoneActionToRobotAction from lerobot.teleoperators.phone.phone_processor import MapPhoneActionToRobotAction
from lerobot.types import RobotAction, RobotObservation from lerobot.types import RobotAction, RobotObservation
from lerobot.utils.feature_utils import combine_feature_dicts from lerobot.utils.feature_utils import combine_feature_dicts
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun from lerobot.utils.visualization_utils import init_rerun
+2 -1
View File
@@ -18,7 +18,7 @@ import logging
import time import time
from lerobot.cameras.opencv import OpenCVCameraConfig from lerobot.cameras.opencv import OpenCVCameraConfig
from lerobot.common.control_utils import init_keyboard_listener, predict_action from lerobot.common.control_utils import predict_action
from lerobot.configs import FeatureType, PolicyFeature from lerobot.configs import FeatureType, PolicyFeature
from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features
from lerobot.model.kinematics import RobotKinematics from lerobot.model.kinematics import RobotKinematics
@@ -41,6 +41,7 @@ from lerobot.robots.so_follower.robot_kinematic_processor import (
from lerobot.types import RobotAction, RobotObservation from lerobot.types import RobotAction, RobotObservation
from lerobot.utils.constants import ACTION, OBS_STR from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import build_dataset_frame, combine_feature_dicts from lerobot.utils.feature_utils import build_dataset_frame, combine_feature_dicts
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.robot_utils import precise_sleep from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data from lerobot.utils.visualization_utils import init_rerun, log_rerun_data
+1 -1
View File
@@ -16,7 +16,6 @@
from lerobot.cameras.opencv import OpenCVCameraConfig from lerobot.cameras.opencv import OpenCVCameraConfig
from lerobot.common.control_utils import init_keyboard_listener
from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features
from lerobot.model.kinematics import RobotKinematics from lerobot.model.kinematics import RobotKinematics
from lerobot.processor import ( from lerobot.processor import (
@@ -36,6 +35,7 @@ from lerobot.scripts.lerobot_record import record_loop
from lerobot.teleoperators.so_leader import SO100Leader, SO100LeaderConfig from lerobot.teleoperators.so_leader import SO100Leader, SO100LeaderConfig
from lerobot.types import RobotAction, RobotObservation from lerobot.types import RobotAction, RobotObservation
from lerobot.utils.feature_utils import combine_feature_dicts from lerobot.utils.feature_utils import combine_feature_dicts
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun from lerobot.utils.visualization_utils import init_rerun
+17 -3
View File
@@ -124,7 +124,8 @@ hardware = [
"lerobot[deepdiff-dep]", "lerobot[deepdiff-dep]",
] ]
viz = [ viz = [
"rerun-sdk>=0.24.0,<0.27.0", "rerun-sdk>=0.24.0,<0.34.0",
"foxglove-sdk>=0.25.1,<0.26.0",
] ]
# ── User-facing composite extras (map to CLI scripts) ───── # ── User-facing composite extras (map to CLI scripts) ─────
# lerobot-record, lerobot-replay, lerobot-calibrate, lerobot-teleoperate, etc. # lerobot-record, lerobot-replay, lerobot-calibrate, lerobot-teleoperate, etc.
@@ -140,7 +141,14 @@ av-dep = ["av>=15.0.0,<16.0.0"]
pygame-dep = ["pygame>=2.5.1,<2.7.0"] pygame-dep = ["pygame>=2.5.1,<2.7.0"]
# NOTE: 0.9.16 links against liburdfdom_sensor.so.4, which is unavailable on Ubuntu 24.04 # NOTE: 0.9.16 links against liburdfdom_sensor.so.4, which is unavailable on Ubuntu 24.04
# (noble ships urdfdom 3.x). Cap below 0.9.16 until system urdfdom 4.x is broadly available. # (noble ships urdfdom 3.x). Cap below 0.9.16 until system urdfdom 4.x is broadly available.
placo-dep = ["placo>=0.9.6,<0.9.16"] #
# NOTE: placo pulls in pin (Pinocchio), whose binary wheels dlopen specific cmeel sonames
# (liburdfdom_sensor.so.4.0, libtinyxml2.so.10) but declare only `>=` floors on their cmeel
# packages. The 2026-05-21 major bumps (cmeel-urdfdom 6.0.0 -> .so.6, cmeel-tinyxml2 11.0.0
# -> .so.11) ship newer sonames, so left unpinned the resolver grabs them and `import placo`
# fails at load with "liburdfdom_sensor.so.4.0: cannot open shared object file" (see #3755).
# There is no cmeel-urdfdom 5.x; <5 selects the 4.x ABI the placo/pin wheels are built against.
placo-dep = ["placo>=0.9.6,<0.9.16", "cmeel-urdfdom>=4,<5", "cmeel-tinyxml2<11"]
transformers-dep = ["transformers>=5.4.0,<5.6.0"] transformers-dep = ["transformers>=5.4.0,<5.6.0"]
grpcio-dep = ["grpcio>=1.73.1,<2.0.0", "protobuf>=6.31.1,<8.0.0"] grpcio-dep = ["grpcio>=1.73.1,<2.0.0", "protobuf>=6.31.1,<8.0.0"]
accelerate-dep = ["accelerate>=1.14.0,<2.0.0"] accelerate-dep = ["accelerate>=1.14.0,<2.0.0"]
@@ -223,6 +231,10 @@ robometer = ["lerobot[transformers-dep]", "lerobot[qwen-vl-utils-dep]", "lerobot
topreward = ["lerobot[transformers-dep]"] topreward = ["lerobot[transformers-dep]"]
xvla = ["lerobot[transformers-dep]"] xvla = ["lerobot[transformers-dep]"]
eo1 = ["lerobot[transformers-dep]", "lerobot[qwen-vl-utils-dep]"] eo1 = ["lerobot[transformers-dep]", "lerobot[qwen-vl-utils-dep]"]
fastwam = [
"lerobot[transformers-dep]",
"lerobot[diffusers-dep]",
]
hilserl = ["lerobot[transformers-dep]", "lerobot[dataset]", "gym-hil>=0.1.14,<0.2.0", "lerobot[grpcio-dep]", "lerobot[placo-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]"] vla_jepa = ["lerobot[transformers-dep]", "lerobot[diffusers-dep]", "lerobot[qwen-vl-utils-dep]"]
lingbot_va = ["lerobot[transformers-dep]", "diffusers>=0.36.0,<0.37.0", "lerobot[imageio-dep]", "accelerate>=1.10.0,<2.0.0", "ftfy>=6.0.0,<7.0.0"] lingbot_va = ["lerobot[transformers-dep]", "diffusers>=0.36.0,<0.37.0", "lerobot[imageio-dep]", "accelerate>=1.10.0,<2.0.0", "ftfy>=6.0.0,<7.0.0"]
@@ -303,6 +315,7 @@ all = [
"lerobot[pi]", "lerobot[pi]",
"lerobot[molmoact2]", "lerobot[molmoact2]",
"lerobot[smolvla]", "lerobot[smolvla]",
"lerobot[fastwam]",
# "lerobot[groot]", TODO(Steven): Gr00t requires specific installation instructions for flash-attn # "lerobot[groot]", TODO(Steven): Gr00t requires specific installation instructions for flash-attn
"lerobot[xvla]", "lerobot[xvla]",
"lerobot[hilserl]", "lerobot[hilserl]",
@@ -443,7 +456,8 @@ default.extend-ignore-identifiers-re = [
"is_compileable", "is_compileable",
"ROBOTIS", "ROBOTIS",
"OT_VALUE", "OT_VALUE",
"VanderBilt" "VanderBilt",
"seperated_timestep",
] ]
# TODO: Uncomment when ready to use # TODO: Uncomment when ready to use
@@ -36,7 +36,7 @@ from typing import Any, Protocol
import PIL.Image import PIL.Image
import torch import torch
from lerobot.configs.video import VideoEncoderConfig from lerobot.configs import RGBEncoderConfig
from lerobot.datasets.video_utils import decode_video_frames, reencode_video from lerobot.datasets.video_utils import decode_video_frames, reencode_video
from .reader import EpisodeRecord, snap_to_frame from .reader import EpisodeRecord, snap_to_frame
@@ -164,7 +164,9 @@ class VideoFrameProvider:
# only for video-stored cameras. Image-stored cameras (also in # only for video-stored cameras. Image-stored cameras (also in
# ``camera_keys``) would KeyError, so restrict the list — and the # ``camera_keys``) would KeyError, so restrict the list — and the
# default — to video keys. # default — to video keys.
keys = list(self._meta.video_keys) # Depth cameras are excluded from the annotation pipeline for now.
depth_keys = set(self._meta.depth_keys)
keys = [key for key in self._meta.video_keys if key not in depth_keys]
# Last-resort fallback: if metadata didn't surface any video keys but # Last-resort fallback: if metadata didn't surface any video keys but
# the caller explicitly named a camera (``--vlm.camera_key=...``), # the caller explicitly named a camera (``--vlm.camera_key=...``),
# trust them — the key is by definition known to exist on the dataset. # trust them — the key is by definition known to exist on the dataset.
@@ -276,12 +278,12 @@ class VideoFrameProvider:
from_timestamp = float(ep[f"videos/{self.camera_key}/from_timestamp"]) from_timestamp = float(ep[f"videos/{self.camera_key}/from_timestamp"])
to_timestamp = float(ep[f"videos/{self.camera_key}/to_timestamp"]) to_timestamp = float(ep[f"videos/{self.camera_key}/to_timestamp"])
src = self.root / self._meta.get_video_file_path(record.episode_index, self.camera_key) src = self.root / self._meta.get_video_file_path(record.episode_index, self.camera_key)
encoder = VideoEncoderConfig(vcodec="h264", pix_fmt="yuv420p", g=None, crf=23, preset="ultrafast") encoder = RGBEncoderConfig(vcodec="h264", pix_fmt="yuv420p", g=None, crf=23, preset="ultrafast")
try: try:
reencode_video( reencode_video(
src, src,
out_path, out_path,
camera_encoder=encoder, video_encoder=encoder,
overwrite=True, overwrite=True,
start_time_s=from_timestamp, start_time_s=from_timestamp,
end_time_s=to_timestamp, end_time_s=to_timestamp,
+3 -2
View File
@@ -105,8 +105,9 @@ def raw_observation_to_observation(
def prepare_image(image: torch.Tensor) -> torch.Tensor: def prepare_image(image: torch.Tensor) -> torch.Tensor:
"""Minimal preprocessing to turn int8 images to float32 in [0, 1], and create a memory-contiguous tensor""" """Minimal preprocessing to turn RGB uint8 images to float32 in [0, 1], and create a memory-contiguous tensor"""
image = image.type(torch.float32) / 255 if image.dtype == torch.uint8:
image = image.type(torch.float32) / 255
image = image.contiguous() image = image.contiguous()
return image return image
+3 -1
View File
@@ -436,7 +436,7 @@ class OpenCVCamera(Camera):
Internal loop run by the background thread for asynchronous reading. Internal loop run by the background thread for asynchronous reading.
On each iteration: On each iteration:
1. Reads a color frame 1. Reads a color frame (blocking call)
2. Stores result in latest_frame and updates timestamp (thread-safe) 2. Stores result in latest_frame and updates timestamp (thread-safe)
3. Sets new_frame_event to notify listeners 3. Sets new_frame_event to notify listeners
@@ -485,6 +485,8 @@ class OpenCVCamera(Camera):
if self.thread is not None and self.thread.is_alive(): if self.thread is not None and self.thread.is_alive():
self.thread.join(timeout=2.0) self.thread.join(timeout=2.0)
if self.thread.is_alive():
logger.warning(f"{self} read thread did not terminate within timeout.")
self.thread = None self.thread = None
self.stop_event = None self.stop_event = None
+120 -63
View File
@@ -128,6 +128,7 @@ class RealSenseCamera(Camera):
self.fps = config.fps self.fps = config.fps
self.color_mode = config.color_mode self.color_mode = config.color_mode
self.use_rgb = config.use_rgb
self.use_depth = config.use_depth self.use_depth = config.use_depth
self.warmup_s = config.warmup_s self.warmup_s = config.warmup_s
@@ -195,12 +196,15 @@ class RealSenseCamera(Camera):
# NOTE(Steven/Caroline): Enforcing at least one second of warmup as RS cameras need a bit of time before the first read. If we don't wait, the first read from the warmup will raise. # NOTE(Steven/Caroline): Enforcing at least one second of warmup as RS cameras need a bit of time before the first read. If we don't wait, the first read from the warmup will raise.
self.warmup_s = max(self.warmup_s, 1) self.warmup_s = max(self.warmup_s, 1)
warmup_read = self.async_read if self.use_rgb else self.async_read_depth
start_time = time.time() start_time = time.time()
while time.time() - start_time < self.warmup_s: while time.time() - start_time < self.warmup_s:
self.async_read(timeout_ms=self.warmup_s * 1000) warmup_read(timeout_ms=self.warmup_s * 1000)
time.sleep(0.1) time.sleep(0.1)
with self.frame_lock: with self.frame_lock:
if self.latest_color_frame is None or self.use_depth and self.latest_depth_frame is None: if (self.use_rgb and self.latest_color_frame is None) or (
self.use_depth and self.latest_depth_frame is None
):
raise ConnectionError(f"{self} failed to capture frames during warmup.") raise ConnectionError(f"{self} failed to capture frames during warmup.")
logger.info(f"{self} connected.") logger.info(f"{self} connected.")
@@ -268,13 +272,13 @@ class RealSenseCamera(Camera):
) )
if len(found_devices) > 1: if len(found_devices) > 1:
serial_numbers = [dev["serial_number"] for dev in found_devices] serial_numbers = [dev["id"] for dev in found_devices]
raise ValueError( raise ValueError(
f"Multiple RealSense cameras found with name '{name}'. " f"Multiple RealSense cameras found with name '{name}'. "
f"Please use a unique serial number instead. Found SNs: {serial_numbers}" f"Please use a unique serial number instead. Found SNs: {serial_numbers}"
) )
serial_number = str(found_devices[0]["serial_number"]) serial_number = str(found_devices[0]["id"])
return serial_number return serial_number
def _configure_rs_pipeline_config(self, rs_config: Any) -> None: def _configure_rs_pipeline_config(self, rs_config: Any) -> None:
@@ -282,15 +286,17 @@ class RealSenseCamera(Camera):
rs.config.enable_device(rs_config, self.serial_number) rs.config.enable_device(rs_config, self.serial_number)
if self.width and self.height and self.fps: if self.width and self.height and self.fps:
rs_config.enable_stream( if self.use_rgb:
rs.stream.color, self.capture_width, self.capture_height, rs.format.rgb8, self.fps rs_config.enable_stream(
) rs.stream.color, self.capture_width, self.capture_height, rs.format.rgb8, self.fps
)
if self.use_depth: if self.use_depth:
rs_config.enable_stream( rs_config.enable_stream(
rs.stream.depth, self.capture_width, self.capture_height, rs.format.z16, self.fps rs.stream.depth, self.capture_width, self.capture_height, rs.format.z16, self.fps
) )
else: else:
rs_config.enable_stream(rs.stream.color) if self.use_rgb:
rs_config.enable_stream(rs.stream.color)
if self.use_depth: if self.use_depth:
rs_config.enable_stream(rs.stream.depth) rs_config.enable_stream(rs.stream.depth)
@@ -298,8 +304,9 @@ class RealSenseCamera(Camera):
def _configure_capture_settings(self) -> None: def _configure_capture_settings(self) -> None:
"""Sets fps, width, and height from device stream if not already configured. """Sets fps, width, and height from device stream if not already configured.
Uses the color stream profile to update unset attributes. Handles rotation by Uses the color stream profile (or the depth stream profile when the color
swapping width/height when needed. Original capture dimensions are always stored. stream is disabled) to update unset attributes. Handles rotation by swapping
width/height when needed. Original capture dimensions are always stored.
Raises: Raises:
DeviceNotConnectedError: If device is not connected. DeviceNotConnectedError: If device is not connected.
@@ -308,7 +315,8 @@ class RealSenseCamera(Camera):
if self.rs_profile is None: if self.rs_profile is None:
raise RuntimeError(f"{self}: rs_profile must be initialized before use.") raise RuntimeError(f"{self}: rs_profile must be initialized before use.")
stream = self.rs_profile.get_stream(rs.stream.color).as_video_stream_profile() rs_stream = rs.stream.color if self.use_rgb else rs.stream.depth
stream = self.rs_profile.get_stream(rs_stream).as_video_stream_profile()
if self.fps is None: if self.fps is None:
self.fps = stream.fps() self.fps = stream.fps()
@@ -323,6 +331,14 @@ class RealSenseCamera(Camera):
self.width, self.height = actual_width, actual_height self.width, self.height = actual_width, actual_height
self.capture_width, self.capture_height = actual_width, actual_height self.capture_width, self.capture_height = actual_width, actual_height
def _read(self, read_depth: bool = False) -> NDArray[Any]:
"""Shared helper for :meth:`read`/:meth:`read_depth`: wait for a fresh color or depth frame."""
if self.thread is None or not self.thread.is_alive():
raise RuntimeError(f"{self} read thread is not running.")
self.new_frame_event.clear()
return self._async_read(timeout_ms=10000, read_depth=read_depth)
@check_if_not_connected @check_if_not_connected
def read_depth(self, timeout_ms: int = 200) -> NDArray[Any]: def read_depth(self, timeout_ms: int = 200) -> NDArray[Any]:
""" """
@@ -332,8 +348,8 @@ class RealSenseCamera(Camera):
from the camera hardware via the RealSense pipeline. from the camera hardware via the RealSense pipeline.
Returns: Returns:
np.ndarray: The depth map as a NumPy array (height, width) np.ndarray: The depth map as a NumPy array (height, width, 1)
of type `np.uint16` (raw depth values in millimeters) and rotation. of type `np.uint16` (raw depth values in millimeters).
Raises: Raises:
DeviceNotConnectedError: If the camera is not connected. DeviceNotConnectedError: If the camera is not connected.
@@ -349,20 +365,7 @@ class RealSenseCamera(Camera):
f"Failed to capture depth frame '.read_depth()'. Depth stream is not enabled for {self}." f"Failed to capture depth frame '.read_depth()'. Depth stream is not enabled for {self}."
) )
if self.thread is None or not self.thread.is_alive(): return self._read(read_depth=True)
raise RuntimeError(f"{self} read thread is not running.")
self.new_frame_event.clear()
_ = self.async_read(timeout_ms=10000)
with self.frame_lock:
depth_map = self.latest_depth_frame
if depth_map is None:
raise RuntimeError("No depth frame available. Ensure camera is streaming.")
return depth_map
def _read_from_hardware(self): def _read_from_hardware(self):
if self.rs_pipeline is None: if self.rs_pipeline is None:
@@ -405,12 +408,10 @@ class RealSenseCamera(Camera):
f"{self} read() timeout_ms parameter is deprecated and will be removed in future versions." f"{self} read() timeout_ms parameter is deprecated and will be removed in future versions."
) )
if self.thread is None or not self.thread.is_alive(): if not self.use_rgb:
raise RuntimeError(f"{self} read thread is not running.") raise RuntimeError(f"{self}: cannot read color — camera was configured with use_rgb=False.")
self.new_frame_event.clear() frame = self._read()
frame = self.async_read(timeout_ms=10000)
read_duration_ms = (time.perf_counter() - start_time) * 1e3 read_duration_ms = (time.perf_counter() - start_time) * 1e3
logger.debug(f"{self} read took: {read_duration_ms:.1f}ms") logger.debug(f"{self} read took: {read_duration_ms:.1f}ms")
@@ -465,8 +466,8 @@ class RealSenseCamera(Camera):
Internal loop run by the background thread for asynchronous reading. Internal loop run by the background thread for asynchronous reading.
On each iteration: On each iteration:
1. Reads a color frame with 500ms timeout 1. Reads a color/depth frame (blocking call with 10s timeout)
2. Stores result in latest_frame and updates timestamp (thread-safe) 2. Stores result in latest_color_frame/latest_depth_frame and updates timestamp (thread-safe)
3. Sets new_frame_event to notify listeners 3. Sets new_frame_event to notify listeners
Stops on DeviceNotConnectedError, logs other errors and continues. Stops on DeviceNotConnectedError, logs other errors and continues.
@@ -479,19 +480,24 @@ class RealSenseCamera(Camera):
while not stop_event.is_set(): while not stop_event.is_set():
try: try:
frame = self._read_from_hardware() frame = self._read_from_hardware()
color_frame_raw = frame.get_color_frame()
color_frame = np.asanyarray(color_frame_raw.get_data()) if self.use_rgb:
processed_color_frame = self._postprocess_image(color_frame) color_frame_raw = frame.get_color_frame()
color_frame = np.asanyarray(color_frame_raw.get_data())
processed_color_frame = self._postprocess_image(color_frame)
if self.use_depth: if self.use_depth:
depth_frame_raw = frame.get_depth_frame() depth_frame_raw = frame.get_depth_frame()
depth_frame = np.asanyarray(depth_frame_raw.get_data()) depth_frame = np.asanyarray(depth_frame_raw.get_data())
processed_depth_frame = self._postprocess_image(depth_frame, depth_frame=True) processed_depth_frame = self._postprocess_image(depth_frame, depth_frame=True)
if processed_depth_frame.ndim == 2: # (H, W) -> (H, W, 1)
processed_depth_frame = processed_depth_frame[..., np.newaxis]
capture_time = time.perf_counter() capture_time = time.perf_counter()
with self.frame_lock: with self.frame_lock:
self.latest_color_frame = processed_color_frame if self.use_rgb:
self.latest_color_frame = processed_color_frame
if self.use_depth: if self.use_depth:
self.latest_depth_frame = processed_depth_frame self.latest_depth_frame = processed_depth_frame
self.latest_timestamp = capture_time self.latest_timestamp = capture_time
@@ -523,6 +529,8 @@ class RealSenseCamera(Camera):
if self.thread is not None and self.thread.is_alive(): if self.thread is not None and self.thread.is_alive():
self.thread.join(timeout=2.0) self.thread.join(timeout=2.0)
if self.thread.is_alive(): # pragma: no cover
logger.warning(f"{self} read thread did not terminate within timeout.")
self.thread = None self.thread = None
self.stop_event = None self.stop_event = None
@@ -533,7 +541,26 @@ class RealSenseCamera(Camera):
self.latest_timestamp = None self.latest_timestamp = None
self.new_frame_event.clear() self.new_frame_event.clear()
# NOTE(Steven): Missing implementation for depth for now def _async_read(self, timeout_ms: float, read_depth: bool = False) -> NDArray[Any]:
"""Shared helper for :meth:`async_read`/:meth:`async_read_depth`: return the latest buffered frame."""
if self.thread is None or not self.thread.is_alive():
raise RuntimeError(f"{self} read thread is not running.")
if not self.new_frame_event.wait(timeout=timeout_ms / 1000.0):
raise TimeoutError(
f"Timed out waiting for frame from camera {self} after {timeout_ms} ms. "
f"Read thread alive: {self.thread.is_alive()}."
)
with self.frame_lock:
frame = self.latest_depth_frame if read_depth else self.latest_color_frame
self.new_frame_event.clear()
if frame is None:
raise RuntimeError(f"Internal error: Event set but no frame available for {self}.")
return frame
@check_if_not_connected @check_if_not_connected
def async_read(self, timeout_ms: float = 200) -> NDArray[Any]: def async_read(self, timeout_ms: float = 200) -> NDArray[Any]:
""" """
@@ -558,25 +585,31 @@ class RealSenseCamera(Camera):
RuntimeError: If the background thread died unexpectedly or another error occurs. RuntimeError: If the background thread died unexpectedly or another error occurs.
""" """
if not self.use_rgb:
raise RuntimeError(f"{self}: cannot read color — camera was configured with use_rgb=False.")
return self._async_read(timeout_ms=timeout_ms)
def _read_latest(self, max_age_ms: int, read_depth: bool = False) -> NDArray[Any]:
"""Shared helper for :meth:`read_latest`/:meth:`read_latest_depth`: peek the latest buffered frame."""
if self.thread is None or not self.thread.is_alive(): if self.thread is None or not self.thread.is_alive():
raise RuntimeError(f"{self} read thread is not running.") raise RuntimeError(f"{self} read thread is not running.")
if not self.new_frame_event.wait(timeout=timeout_ms / 1000.0):
raise TimeoutError(
f"Timed out waiting for frame from camera {self} after {timeout_ms} ms. "
f"Read thread alive: {self.thread.is_alive()}."
)
with self.frame_lock: with self.frame_lock:
frame = self.latest_color_frame frame = self.latest_depth_frame if read_depth else self.latest_color_frame
self.new_frame_event.clear() timestamp = self.latest_timestamp
if frame is None: if frame is None or timestamp is None:
raise RuntimeError(f"Internal error: Event set but no frame available for {self}.") raise RuntimeError(f"{self} has not captured any frames yet.")
age_ms = (time.perf_counter() - timestamp) * 1e3
if age_ms > max_age_ms:
raise TimeoutError(
f"{self} latest frame is too old: {age_ms:.1f} ms (max allowed: {max_age_ms} ms)."
)
return frame return frame
# NOTE(Steven): Missing implementation for depth for now
@check_if_not_connected @check_if_not_connected
def read_latest(self, max_age_ms: int = 500) -> NDArray[Any]: def read_latest(self, max_age_ms: int = 500) -> NDArray[Any]:
"""Return the most recent (color) frame captured immediately (Peeking). """Return the most recent (color) frame captured immediately (Peeking).
@@ -593,24 +626,48 @@ class RealSenseCamera(Camera):
DeviceNotConnectedError: If the camera is not connected. DeviceNotConnectedError: If the camera is not connected.
RuntimeError: If the camera is connected but has not captured any frames yet. RuntimeError: If the camera is connected but has not captured any frames yet.
""" """
if not self.use_rgb:
raise RuntimeError(f"{self}: cannot read color — camera was configured with use_rgb=False.")
if self.thread is None or not self.thread.is_alive(): return self._read_latest(max_age_ms=max_age_ms)
raise RuntimeError(f"{self} read thread is not running.")
with self.frame_lock: @check_if_not_connected
frame = self.latest_color_frame def async_read_depth(self, timeout_ms: float = 200) -> NDArray[np.uint16]:
timestamp = self.latest_timestamp """Read the latest depth frame asynchronously, in millimeters.
if frame is None or timestamp is None: Mirrors :meth:`async_read` but returns the depth stream rather than the
raise RuntimeError(f"{self} has not captured any frames yet.") color stream. Output is ``np.uint16`` of shape ``(H, W, 1)``, where each
pixel is the distance from the sensor in millimeters.
age_ms = (time.perf_counter() - timestamp) * 1e3 Raises:
if age_ms > max_age_ms: DeviceNotConnectedError: If the camera is not connected.
raise TimeoutError( RuntimeError: If ``use_depth`` is ``False`` for this camera, or if
f"{self} latest frame is too old: {age_ms:.1f} ms (max allowed: {max_age_ms} ms)." the background read thread is not running.
) TimeoutError: If no frame becomes available within ``timeout_ms``.
"""
if not self.use_depth:
raise RuntimeError(f"{self}: cannot read depth — camera was configured with use_depth=False.")
return frame return self._async_read(timeout_ms=timeout_ms, read_depth=True)
@check_if_not_connected
def read_latest_depth(self, max_age_ms: int = 500) -> NDArray[Any]:
"""Return the most recent depth frame in millimeters (peeking).
Non-blocking counterpart of :meth:`read_latest` for the depth stream.
Output is ``np.uint16`` of shape ``(H, W, 1)``, where each pixel is the
distance from the sensor in millimeters.
Raises:
DeviceNotConnectedError: If the camera is not connected.
RuntimeError: If ``use_depth`` is ``False`` for this camera, or if
no depth frame has been captured yet.
TimeoutError: If the latest depth frame is older than ``max_age_ms``.
"""
if not self.use_depth:
raise RuntimeError(f"{self}: cannot read depth — camera was configured with use_depth=False.")
return self._read_latest(max_age_ms=max_age_ms, read_depth=True)
def disconnect(self) -> None: def disconnect(self) -> None:
""" """
@@ -42,12 +42,14 @@ class RealSenseCameraConfig(CameraConfig):
height: Requested frame height in pixels for the color stream. height: Requested frame height in pixels for the color stream.
serial_number_or_name: Unique serial number or human-readable name to identify the camera. serial_number_or_name: Unique serial number or human-readable name to identify the camera.
color_mode: Color mode for image output (RGB or BGR). Defaults to RGB. color_mode: Color mode for image output (RGB or BGR). Defaults to RGB.
use_rgb: Whether to enable the color stream. Defaults to True.
use_depth: Whether to enable depth stream. Defaults to False. use_depth: Whether to enable depth stream. Defaults to False.
rotation: Image rotation setting (0°, 90°, 180°, or 270°). Defaults to no rotation. rotation: Image rotation setting (0°, 90°, 180°, or 270°). Defaults to no rotation.
warmup_s: Time reading frames before returning from connect (in seconds) warmup_s: Time reading frames before returning from connect (in seconds)
Note: Note:
- Either name or serial_number must be specified. - Either name or serial_number must be specified.
- At least one of `use_rgb` or `use_depth` must be enabled.
- Depth stream configuration (if enabled) will use the same FPS as the color stream. - Depth stream configuration (if enabled) will use the same FPS as the color stream.
- The actual resolution and FPS may be adjusted by the camera to the nearest supported mode. - The actual resolution and FPS may be adjusted by the camera to the nearest supported mode.
- For `fps`, `width` and `height`, either all of them need to be set, or none of them. - For `fps`, `width` and `height`, either all of them need to be set, or none of them.
@@ -55,6 +57,7 @@ class RealSenseCameraConfig(CameraConfig):
serial_number_or_name: str serial_number_or_name: str
color_mode: ColorMode = ColorMode.RGB color_mode: ColorMode = ColorMode.RGB
use_rgb: bool = True
use_depth: bool = False use_depth: bool = False
rotation: Cv2Rotation = Cv2Rotation.NO_ROTATION rotation: Cv2Rotation = Cv2Rotation.NO_ROTATION
warmup_s: int = 1 warmup_s: int = 1
@@ -63,6 +66,9 @@ class RealSenseCameraConfig(CameraConfig):
self.color_mode = ColorMode(self.color_mode) self.color_mode = ColorMode(self.color_mode)
self.rotation = Cv2Rotation(self.rotation) self.rotation = Cv2Rotation(self.rotation)
if not self.use_rgb and not self.use_depth:
raise ValueError("At least one of `use_rgb` or `use_depth` must be enabled.")
values = (self.fps, self.width, self.height) values = (self.fps, self.width, self.height)
if any(v is not None for v in values) and any(v is None for v in values): if any(v is not None for v in values) and any(v is None for v in values):
raise ValueError( raise ValueError(
+2
View File
@@ -293,6 +293,8 @@ class ZMQCamera(Camera):
if self.thread is not None and self.thread.is_alive(): if self.thread is not None and self.thread.is_alive():
self.thread.join(timeout=2.0) self.thread.join(timeout=2.0)
if self.thread.is_alive():
logger.warning(f"{self} read thread did not terminate within timeout.")
self.thread = None self.thread = None
self.stop_event = None self.stop_event = None
-84
View File
@@ -17,12 +17,9 @@ from __future__ import annotations
######################################################################################## ########################################################################################
# Utilities # Utilities
######################################################################################## ########################################################################################
import logging
import time import time
import traceback
from contextlib import nullcontext from contextlib import nullcontext
from copy import copy from copy import copy
from functools import cache
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import numpy as np import numpy as np
@@ -43,34 +40,6 @@ from lerobot.robots import Robot
from lerobot.types import PolicyAction from lerobot.types import PolicyAction
@cache
def is_headless():
"""
Detects if the Python script is running in a headless environment (e.g., without a display).
This function attempts to import `pynput`, a library that requires a graphical environment.
If the import fails, it assumes the environment is headless. The result is cached to avoid
re-running the check.
Returns:
True if the environment is determined to be headless, False otherwise.
"""
try:
import pynput # noqa
return False
except Exception:
print(
"Error trying to import pynput. Switching to headless mode. "
"As a result, the video stream from the cameras won't be shown, "
"and you won't be able to change the control flow with keyboards. "
"For more info, see traceback below.\n"
)
traceback.print_exc()
print()
return True
def predict_action( def predict_action(
observation: dict[str, np.ndarray], observation: dict[str, np.ndarray],
policy: PreTrainedPolicy, policy: PreTrainedPolicy,
@@ -122,59 +91,6 @@ def predict_action(
return action return action
def init_keyboard_listener():
"""
Initializes a non-blocking keyboard listener for real-time user interaction.
This function sets up a listener for specific keys (right arrow, left arrow, escape) to control
the program flow during execution, such as stopping recording or exiting loops. It gracefully
handles headless environments where keyboard listening is not possible.
Returns:
A tuple containing:
- The `pynput.keyboard.Listener` instance, or `None` if in a headless environment.
- A dictionary of event flags (e.g., `exit_early`) that are set by key presses.
"""
# Allow to exit early while recording an episode or resetting the environment,
# by tapping the right arrow key '->'. This might require a sudo permission
# to allow your terminal to monitor keyboard events.
events = {}
events["exit_early"] = False
events["rerecord_episode"] = False
events["stop_recording"] = False
if is_headless():
logging.warning(
"Headless environment detected. On-screen cameras display and keyboard inputs will not be available."
)
listener = None
return listener, events
# Only import pynput if not in a headless environment
from pynput import keyboard
def on_press(key):
try:
if key == keyboard.Key.right:
print("Right arrow key pressed. Exiting loop...")
events["exit_early"] = True
elif key == keyboard.Key.left:
print("Left arrow key pressed. Exiting loop and rerecord the last episode...")
events["rerecord_episode"] = True
events["exit_early"] = True
elif key == keyboard.Key.esc:
print("Escape key pressed. Stopping data recording...")
events["stop_recording"] = True
events["exit_early"] = True
except Exception as e:
print(f"Error handling key press: {e}")
listener = keyboard.Listener(on_press=on_press)
listener.start()
return listener, events
def sanity_check_dataset_name(repo_id, policy_cfg): def sanity_check_dataset_name(repo_id, policy_cfg):
""" """
Validates the dataset repository name against the presence of a policy configuration. Validates the dataset repository name against the presence of a policy configuration.
+60
View File
@@ -15,6 +15,7 @@
# limitations under the License. # limitations under the License.
from pathlib import Path from pathlib import Path
from huggingface_hub import HfApi, snapshot_download
from torch.optim import Optimizer from torch.optim import Optimizer
from torch.optim.lr_scheduler import LRScheduler from torch.optim.lr_scheduler import LRScheduler
@@ -35,6 +36,7 @@ from lerobot.utils.constants import (
TRAINING_STATE_DIR, TRAINING_STATE_DIR,
TRAINING_STEP, TRAINING_STEP,
) )
from lerobot.utils.hub import find_latest_hub_checkpoint
from lerobot.utils.io_utils import load_json, write_json from lerobot.utils.io_utils import load_json, write_json
from lerobot.utils.random_utils import load_rng_state, save_rng_state from lerobot.utils.random_utils import load_rng_state, save_rng_state
@@ -283,3 +285,61 @@ def load_fsdp_optimizer_state(model, optimizer, checkpoint_dir: Path) -> None:
with FSDP.state_dict_type(model, StateDictType.FULL_STATE_DICT, state_cfg, optim_cfg): with FSDP.state_dict_type(model, StateDictType.FULL_STATE_DICT, state_cfg, optim_cfg):
sharded_osd = FSDP.optim_state_dict_to_load(model=model, optim=optimizer, optim_state_dict=full_osd) sharded_osd = FSDP.optim_state_dict_to_load(model=model, optim=optimizer, optim_state_dict=full_osd)
optimizer.load_state_dict(sharded_osd) optimizer.load_state_dict(sharded_osd)
def push_checkpoint_to_hub(
checkpoint_dir: Path,
repo_id: str,
*,
private: bool | None = None,
) -> None:
"""Upload a saved checkpoint directory to the Hub under checkpoints/<name>/.
Called once per save step when save_checkpoint_to_hub is enabled, so a
timed-out or crashed run still leaves recoverable checkpoints on the Hub.
The model repo is created idempotently, and the commit is tagged with the
checkpoint step so a checkpoint can be recovered with
--policy.pretrained_revision=<step> instead of a commit sha.
"""
api = HfApi()
api.create_repo(repo_id=repo_id, repo_type="model", private=private, exist_ok=True)
commit = api.upload_folder(
folder_path=str(checkpoint_dir),
repo_id=repo_id,
repo_type="model",
path_in_repo=f"checkpoints/{checkpoint_dir.name}",
commit_message=f"checkpoint {checkpoint_dir.name}",
)
api.create_tag(
repo_id=repo_id,
tag=checkpoint_dir.name,
revision=commit.oid,
repo_type="model",
exist_ok=True,
)
def resolve_resume_checkpoint(repo_id: str, output_dir: Path) -> Path:
"""Download the latest checkpoint of a Hub training repo into a local run dir.
The symmetric counterpart to `push_checkpoint_to_hub`: given a model repo holding
`checkpoints/<step>/{pretrained_model,training_state}` subtrees, download the highest-numbered step
into `output_dir/checkpoints/<step>/`, recreate the local `last` symlink, and return that local
checkpoint dir. Used to resume training from the Hub on a machine (or HF Jobs pod) that does not
have the original local run dir.
"""
latest = find_latest_hub_checkpoint(repo_id)
if latest is None:
raise FileNotFoundError(
f"No checkpoint found in '{repo_id}' under '{CHECKPOINTS_DIR}/'. "
"Was the run trained with --save_checkpoint_to_hub?"
)
snapshot_download(
repo_id=repo_id,
repo_type="model",
allow_patterns=f"{latest}/*",
local_dir=str(output_dir),
)
checkpoint_dir = output_dir / latest
update_last_checkpoint(checkpoint_dir)
return checkpoint_dir
+21 -3
View File
@@ -22,7 +22,7 @@ Import them directly: ``from lerobot.configs.train import TrainPipelineConfig``
""" """
from .dataset import DatasetRecordConfig from .dataset import DatasetRecordConfig
from .default import DatasetConfig, EvalConfig, PeftConfig, WandBConfig from .default import DatasetConfig, EvalConfig, JobConfig, PeftConfig, WandBConfig
from .policies import PreTrainedConfig from .policies import PreTrainedConfig
from .recipe import MessageTurn, TrainingRecipe, load_recipe from .recipe import MessageTurn, TrainingRecipe, load_recipe
from .types import ( from .types import (
@@ -33,10 +33,18 @@ from .types import (
RTCAttentionSchedule, RTCAttentionSchedule,
) )
from .video import ( from .video import (
DEFAULT_DEPTH_UNIT,
DEPTH_METER_UNIT,
DEPTH_MILLIMETER_UNIT,
VALID_VIDEO_CODECS, VALID_VIDEO_CODECS,
VIDEO_ENCODER_INFO_KEYS, VIDEO_ENCODER_INFO_KEYS,
DepthEncoderConfig,
RGBEncoderConfig,
VideoEncoderConfig, VideoEncoderConfig,
camera_encoder_defaults, depth_encoder_defaults,
encoder_config_from_video_info,
infer_depth_unit,
rgb_encoder_defaults,
) )
__all__ = [ __all__ = [
@@ -50,6 +58,7 @@ __all__ = [
"DatasetRecordConfig", "DatasetRecordConfig",
"DatasetConfig", "DatasetConfig",
"EvalConfig", "EvalConfig",
"JobConfig",
"MessageTurn", "MessageTurn",
"PeftConfig", "PeftConfig",
"PreTrainedConfig", "PreTrainedConfig",
@@ -57,9 +66,18 @@ __all__ = [
"WandBConfig", "WandBConfig",
"load_recipe", "load_recipe",
"VideoEncoderConfig", "VideoEncoderConfig",
"RGBEncoderConfig",
"DepthEncoderConfig",
# Defaults # Defaults
"camera_encoder_defaults", "rgb_encoder_defaults",
"depth_encoder_defaults",
# Factories
"encoder_config_from_video_info",
"infer_depth_unit",
# Constants # Constants
"DEFAULT_DEPTH_UNIT",
"DEPTH_METER_UNIT",
"DEPTH_MILLIMETER_UNIT",
"VALID_VIDEO_CODECS", "VALID_VIDEO_CODECS",
"VIDEO_ENCODER_INFO_KEYS", "VIDEO_ENCODER_INFO_KEYS",
] ]
+5 -3
View File
@@ -18,7 +18,7 @@ from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from .video import VideoEncoderConfig, camera_encoder_defaults from .video import DepthEncoderConfig, RGBEncoderConfig, depth_encoder_defaults, rgb_encoder_defaults
@dataclass @dataclass
@@ -58,8 +58,10 @@ class DatasetRecordConfig:
# Set to 1 for immediate encoding (default behavior), or higher for batched encoding # Set to 1 for immediate encoding (default behavior), or higher for batched encoding
video_encoding_batch_size: int = 1 video_encoding_batch_size: int = 1
# Video encoder settings for camera MP4s (codec, quality, GOP, etc.). Tuned via CLI nested keys, # Video encoder settings for camera MP4s (codec, quality, GOP, etc.). Tuned via CLI nested keys,
# e.g. ``--dataset.camera_encoder.vcodec=h264`` (see ``VideoEncoderConfig``). # e.g. ``--dataset.rgb_encoder.vcodec=h264`` (see ``RGBEncoderConfig``).
camera_encoder: VideoEncoderConfig = field(default_factory=camera_encoder_defaults) rgb_encoder: RGBEncoderConfig = field(default_factory=rgb_encoder_defaults)
# Video encoder settings for depth-map MP4s (codec, quality, GOP, etc.). Tuned via CLI nested keys.
depth_encoder: DepthEncoderConfig = field(default_factory=depth_encoder_defaults)
# Enable streaming video encoding: encode frames in real-time during capture instead # Enable streaming video encoding: encode frames in real-time during capture instead
# of writing PNG images first. Makes save_episode() near-instant. More info in the documentation: https://huggingface.co/docs/lerobot/streaming_video_encoding # of writing PNG images first. Makes save_episode() near-instant. More info in the documentation: https://huggingface.co/docs/lerobot/streaming_video_encoding
streaming_encoding: bool = False streaming_encoding: bool = False
+46 -1
View File
@@ -19,6 +19,8 @@ from dataclasses import dataclass, field
from lerobot.transforms import ImageTransformsConfig from lerobot.transforms import ImageTransformsConfig
from lerobot.utils.import_utils import get_safe_default_video_backend from lerobot.utils.import_utils import get_safe_default_video_backend
from .video import DEFAULT_DEPTH_UNIT, DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT
@dataclass @dataclass
class DatasetConfig: class DatasetConfig:
@@ -35,12 +37,23 @@ class DatasetConfig:
revision: str | None = None revision: str | None = None
use_imagenet_stats: bool = True use_imagenet_stats: bool = True
video_backend: str = field(default_factory=get_safe_default_video_backend) video_backend: str = field(default_factory=get_safe_default_video_backend)
# When True, video frames are returned as uint8 tensors (0-255) instead of float32 (0.0-1.0). # When True, RGB video frames are returned as uint8 tensors (0-255) instead of float32 (0.0-1.0).
# This reduces memory and speeds up DataLoader IPC. The training pipeline handles the conversion. # This reduces memory and speeds up DataLoader IPC. The training pipeline handles the conversion.
return_uint8: bool = False return_uint8: bool = False
# Physical unit depth maps are dequantized to at load time: "mm" (millimeters) or "m" (metres).
# Has no effect on datasets without depth cameras.
depth_output_unit: str = DEFAULT_DEPTH_UNIT
streaming: bool = False streaming: bool = False
# Fraction of episodes held out per task for offline evaluation (0.0 = disabled).
eval_split: float = 0.0
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.depth_output_unit not in (DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT):
raise ValueError(
f"depth_output_unit must be '{DEPTH_METER_UNIT}' or '{DEPTH_MILLIMETER_UNIT}', got {self.depth_output_unit!r}"
)
if not (0.0 <= self.eval_split < 1.0):
raise ValueError(f"eval_split must be in [0.0, 1.0), got {self.eval_split}")
if self.episodes is not None: if self.episodes is not None:
if any(ep < 0 for ep in self.episodes): if any(ep < 0 for ep in self.episodes):
raise ValueError( raise ValueError(
@@ -132,3 +145,35 @@ class PeftConfig:
# If None, the PEFT library defaults to alpha=8, which may dampen high-rank adapters. # If None, the PEFT library defaults to alpha=8, which may dampen high-rank adapters.
# Common values are r (alpha == rank) or 2*r. # Common values are r (alpha == rank) or 2*r.
lora_alpha: int | None = None lora_alpha: int | None = None
@dataclass
class JobConfig:
# Where training runs. None (omitted) or "local" runs on this machine.
# Any other value is an HF Jobs flavor and submits the run to HF Jobs.
# List available flavors + pricing with `hf jobs hardware` command.
target: str | None = None
# Runtime image for the remote job (ignored for local runs).
image: str = "huggingface/lerobot-gpu:latest"
# Max wall-clock for the remote job as an HF Jobs duration string (e.g. "2h").
# Defaults to "2d": We pass an explicit, generous cap instead. Set a smaller
# value to fail fast, or a larger one for long runs.
timeout: str | None = "2d"
# Submit and exit instead of streaming the job logs in the foreground.
detach: bool = False
# Extra tags attached to the HF job and to any dataset this run pushes to the
# Hub. A "lerobot" tag is always added; e.g. --job.tags '["lelab"]' adds more.
tags: list[str] = field(default_factory=list)
# Two entry points to the same predicate: the staticmethod tests a raw target string
# straight from argv (before any JobConfig exists, to decide dispatch early), while the
# property is the ergonomic accessor for code that already holds a config instance.
@staticmethod
def is_remote_target(target: str | None) -> bool:
"""True when `target` names an HF Jobs flavor rather than a local run."""
return target not in (None, "local")
@property
def is_remote(self) -> bool:
"""True when training should run on HF Jobs rather than this machine."""
return self.is_remote_target(self.target)
+109 -44
View File
@@ -26,11 +26,12 @@ from huggingface_hub.errors import HfHubHTTPError
from lerobot import envs from lerobot import envs
from lerobot.optim import LRSchedulerConfig, OptimizerConfig from lerobot.optim import LRSchedulerConfig, OptimizerConfig
from lerobot.utils.hub import HubMixin from lerobot.utils.constants import PRETRAINED_MODEL_DIR
from lerobot.utils.hub import HubMixin, find_latest_hub_checkpoint
from lerobot.utils.sample_weighting import SampleWeightingConfig from lerobot.utils.sample_weighting import SampleWeightingConfig
from . import parser from . import parser
from .default import DatasetConfig, EvalConfig, PeftConfig, WandBConfig from .default import DatasetConfig, EvalConfig, JobConfig, PeftConfig, WandBConfig
from .policies import PreTrainedConfig from .policies import PreTrainedConfig
from .rewards import RewardModelConfig from .rewards import RewardModelConfig
@@ -83,10 +84,11 @@ class TrainPipelineConfig(HubMixin):
# with the same value for `dir` its contents will be overwritten unless you set `resume` to true. # with the same value for `dir` its contents will be overwritten unless you set `resume` to true.
output_dir: Path | None = None output_dir: Path | None = None
job_name: str | None = None job_name: str | None = None
# Set `resume` to true to resume a previous run. In order for this to work, you will need to make sure # Set `resume` to true to resume a previous run. Pass `--config_path` pointing at either a local
# `dir` is the directory of an existing run with at least one checkpoint in it. # checkpoint's train_config.json or a Hub repo id holding `checkpoints/<step>/` subtrees (the
# Note that when resuming a run, the default behavior is to use the configuration from the checkpoint, # latest checkpoint is downloaded and resumed from). Note that when resuming, the default behavior
# regardless of what's provided with the training command at the time of resumption. # is to use the configuration from the checkpoint, regardless of what's provided with the training
# command at the time of resumption (CLI `--*` flags still override).
resume: bool = False resume: bool = False
# `seed` is used for training (eg: model initialization, dataset shuffling) # `seed` is used for training (eg: model initialization, dataset shuffling)
# AND for the evaluation environments. # AND for the evaluation environments.
@@ -100,8 +102,13 @@ class TrainPipelineConfig(HubMixin):
prefetch_factor: int = 4 prefetch_factor: int = 4
persistent_workers: bool = True persistent_workers: bool = True
steps: int = 100_000 steps: int = 100_000
eval_freq: int = 20_000 # Run policy in the simulation environment every N steps to measure reward/success (0 = disabled).
env_eval_freq: int = 20_000
log_freq: int = 200 log_freq: int = 200
# Compute eval loss on held-out episodes every N steps (0 = disabled). Requires eval_split > 0.
eval_steps: int = 0
# Cap on total eval samples, split uniformly across tasks (0 = use all held-out data).
max_eval_samples: int = 0
tolerance_s: float = 1e-4 tolerance_s: float = 1e-4
save_checkpoint: bool = True save_checkpoint: bool = True
# Checkpoint is saved every `save_freq` training iterations and after the last training step. # Checkpoint is saved every `save_freq` training iterations and after the last training step.
@@ -113,6 +120,13 @@ class TrainPipelineConfig(HubMixin):
wandb: WandBConfig = field(default_factory=WandBConfig) wandb: WandBConfig = field(default_factory=WandBConfig)
peft: PeftConfig | None = None peft: PeftConfig | None = None
# Where to run training (local default, or an HF Jobs flavor). See JobConfig.
job: JobConfig = field(default_factory=JobConfig)
# Push each saved checkpoint to the Hub (policy.repo_id) as it is written, not
# just the final model (useful to monitor progress mid-run). Optional; the
# final model is pushed regardless. Works the same locally and remotely.
save_checkpoint_to_hub: bool = False
# Sample weighting configuration (e.g., for RA-BC training) # Sample weighting configuration (e.g., for RA-BC training)
sample_weighting: SampleWeightingConfig | None = None sample_weighting: SampleWeightingConfig | None = None
@@ -132,10 +146,17 @@ class TrainPipelineConfig(HubMixin):
return self.reward_model # type: ignore[return-value] return self.reward_model # type: ignore[return-value]
return self.policy # type: ignore[return-value] return self.policy # type: ignore[return-value]
def validate(self) -> None: def _resolve_pretrained_from_cli(self) -> None:
# HACK: We parse again the cli args here to get the pretrained paths if there was some. """Resolve the pretrained source passed on the CLI into a loaded config.
policy_path = parser.get_path_arg("policy")
The pretrained paths (`--policy.path`, `--reward_model.path`) and
`--config_path` are only recoverable by re-reading the CLI args: draccus
has already consumed them by the time `validate()` runs, so they are not
reflected on `self`. Exactly one source applies, in priority order:
reward-model path, policy path, then resume.
"""
reward_model_path = parser.get_path_arg("reward_model") reward_model_path = parser.get_path_arg("reward_model")
policy_path = parser.get_path_arg("policy")
if reward_model_path: if reward_model_path:
cli_overrides = parser.get_cli_overrides("reward_model") cli_overrides = parser.get_cli_overrides("reward_model")
@@ -144,31 +165,54 @@ class TrainPipelineConfig(HubMixin):
) )
self.reward_model.pretrained_path = str(Path(reward_model_path)) self.reward_model.pretrained_path = str(Path(reward_model_path))
elif policy_path: elif policy_path:
yaml_overrides = parser.get_yaml_overrides("policy") overrides = parser.get_yaml_overrides("policy") + (parser.get_cli_overrides("policy") or [])
cli_overrides = parser.get_cli_overrides("policy") or [] self.policy = PreTrainedConfig.from_pretrained(policy_path, cli_overrides=overrides)
self.policy = PreTrainedConfig.from_pretrained(
policy_path, cli_overrides=yaml_overrides + cli_overrides
)
self.policy.pretrained_path = Path(policy_path) self.policy.pretrained_path = Path(policy_path)
elif self.resume: elif self.resume:
config_path = parser.parse_arg("config_path") self._resolve_resume_checkpoint()
if not config_path:
raise ValueError(
f"A config_path is expected when resuming a run. Please specify path to {TRAIN_CONFIG_NAME}"
)
if not Path(config_path).resolve().exists(): def _resolve_resume_checkpoint(self) -> None:
raise NotADirectoryError( """Point the trainable config at the checkpoint named by `--config_path`.
f"{config_path=} is expected to be a local path. "
"Resuming from the hub is not supported for now."
)
`config_path` is either a local path (to a checkpoint's train_config.json or its
pretrained_model/ dir) or a Hub repo id. For a Hub repo, the latest checkpoint is downloaded
into a fresh local run dir and resumed from there. The download is skipped when dispatching to
an HF Job (`job.is_remote`): the pod performs it when it runs the resume locally, and
`submit_to_hf` resolves the source repo for the remote command.
"""
config_path = parser.parse_arg("config_path")
if not config_path:
raise ValueError(
f"A config_path is expected when resuming a run. Please specify path to {TRAIN_CONFIG_NAME}"
)
if Path(config_path).resolve().exists():
policy_dir = Path(config_path).parent policy_dir = Path(config_path).parent
if self.policy is not None:
self.policy.pretrained_path = policy_dir
if self.reward_model is not None:
self.reward_model.pretrained_path = str(policy_dir)
self.checkpoint_path = policy_dir.parent self.checkpoint_path = policy_dir.parent
elif self.job.is_remote:
return
else:
from lerobot.common.train_utils import resolve_resume_checkpoint
# `self.output_dir` was loaded from the checkpoint's config and points at the original
# run's (now-absent) local dir. Resume into a fresh local dir instead, unless the user
# passed --output_dir explicitly.
cli_output_dir = parser.parse_arg("output_dir")
if cli_output_dir:
self.output_dir = Path(cli_output_dir)
else:
now = dt.datetime.now()
self.output_dir = Path("outputs/train") / f"{now:%Y-%m-%d}/{now:%H-%M-%S}_resume"
self.checkpoint_path = resolve_resume_checkpoint(config_path, self.output_dir)
policy_dir = self.checkpoint_path / PRETRAINED_MODEL_DIR
if self.policy is not None:
self.policy.pretrained_path = policy_dir
if self.reward_model is not None:
self.reward_model.pretrained_path = str(policy_dir)
def validate(self) -> None:
self._resolve_pretrained_from_cli()
if self.policy is None and self.reward_model is None: if self.policy is None and self.reward_model is None:
raise ValueError( raise ValueError(
@@ -208,9 +252,22 @@ class TrainPipelineConfig(HubMixin):
self.optimizer = active_cfg.get_optimizer_preset() self.optimizer = active_cfg.get_optimizer_preset()
self.scheduler = active_cfg.get_scheduler_preset() self.scheduler = active_cfg.get_scheduler_preset()
if hasattr(active_cfg, "push_to_hub") and active_cfg.push_to_hub and not active_cfg.repo_id: if self.eval_steps > 0 and self.dataset.eval_split == 0.0:
raise ValueError("eval_steps > 0 requires dataset.eval_split > 0.0 to hold out eval data.")
# Remote runs auto-generate the repo_id in submit_to_hf (the policy may only be
# resolved here, from --policy.path), so don't demand it up front for them.
if (
hasattr(active_cfg, "push_to_hub")
and active_cfg.push_to_hub
and not active_cfg.repo_id
and not self.job.is_remote
):
raise ValueError("'repo_id' argument missing. Please specify it to push the model to the hub.") raise ValueError("'repo_id' argument missing. Please specify it to push the model to the hub.")
if self.save_checkpoint_to_hub and not (self.policy is not None and self.policy.repo_id):
raise ValueError("save_checkpoint_to_hub requires --policy.repo_id.")
@classmethod @classmethod
def __get_path_fields__(cls) -> list[str]: def __get_path_fields__(cls) -> list[str]:
"""Keys for draccus pretrained-path loading.""" """Keys for draccus pretrained-path loading."""
@@ -247,22 +304,30 @@ class TrainPipelineConfig(HubMixin):
elif Path(model_id).is_file(): elif Path(model_id).is_file():
config_file = model_id config_file = model_id
else: else:
dl_kwargs = {
"repo_id": model_id,
"revision": revision,
"cache_dir": cache_dir,
"force_download": force_download,
"proxies": proxies,
"resume_download": resume_download,
"token": token,
"local_files_only": local_files_only,
}
try: try:
config_file = hf_hub_download( config_file = hf_hub_download(filename=TRAIN_CONFIG_NAME, **dl_kwargs)
repo_id=model_id,
filename=TRAIN_CONFIG_NAME,
revision=revision,
cache_dir=cache_dir,
force_download=force_download,
proxies=proxies,
resume_download=resume_download,
token=token,
local_files_only=local_files_only,
)
except HfHubHTTPError as e: except HfHubHTTPError as e:
raise FileNotFoundError( # No root train_config.json: this is a repo of periodic checkpoints from an
f"{TRAIN_CONFIG_NAME} not found on the HuggingFace Hub in {model_id}" # interrupted run. Fall back to the latest checkpoint's config so the run can be
) from e # resumed straight from the repo with `--config_path=<repo>`.
latest = find_latest_hub_checkpoint(model_id, token=token, revision=revision)
if latest is None:
raise FileNotFoundError(
f"{TRAIN_CONFIG_NAME} not found on the HuggingFace Hub in {model_id}"
) from e
config_file = hf_hub_download(
filename=f"{latest}/{PRETRAINED_MODEL_DIR}/{TRAIN_CONFIG_NAME}", **dl_kwargs
)
cli_args = kwargs.pop("cli_args", []) cli_args = kwargs.pop("cli_args", [])
# Legacy RA-BC migration only applies to framework-saved checkpoints (always JSON). # Legacy RA-BC migration only applies to framework-saved checkpoints (always JSON).
+147 -41
View File
@@ -20,7 +20,9 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any, ClassVar, Self
import numpy as np
from lerobot.utils.import_utils import require_package from lerobot.utils.import_utils import require_package
@@ -36,11 +38,12 @@ HW_VIDEO_CODECS = [
"h264_vaapi", # Linux Intel/AMD "h264_vaapi", # Linux Intel/AMD
"h264_qsv", # Intel Quick Sync "h264_qsv", # Intel Quick Sync
] ]
VALID_VIDEO_CODECS: frozenset[str] = frozenset({"h264", "hevc", "libsvtav1", "auto", *HW_VIDEO_CODECS}) VALID_VIDEO_CODECS: frozenset[str] = frozenset(
{"h264", "hevc", "libsvtav1", "libaom-av1", "auto", *HW_VIDEO_CODECS}
)
# Aliases for legacy video codec names. # Aliases for legacy video codec names.
VIDEO_CODECS_ALIASES: dict[str, str] = {"av1": "libsvtav1"} VIDEO_CODECS_ALIASES: dict[str, str] = {"av1": "libsvtav1"}
LIBSVTAV1_DEFAULT_PRESET: int = 12 LIBSVTAV1_DEFAULT_PRESET: int = 12
# Keys persisted under ``features[*]["info"]`` as ``video.<name>`` (from :class:`VideoEncoderConfig`). # Keys persisted under ``features[*]["info"]`` as ``video.<name>`` (from :class:`VideoEncoderConfig`).
@@ -52,40 +55,54 @@ VIDEO_ENCODER_INFO_KEYS: frozenset[str] = frozenset(
f"video.{name}" for name in VIDEO_ENCODER_INFO_FIELD_NAMES f"video.{name}" for name in VIDEO_ENCODER_INFO_FIELD_NAMES
) )
# Default depth quantization and encoding parameters.
DEPTH_QUANT_BITS: int = 12
DEPTH_QMAX: int = (1 << DEPTH_QUANT_BITS) - 1 # 4095
DEFAULT_DEPTH_MIN: float = 0.01
DEFAULT_DEPTH_MAX: float = 10.0
DEFAULT_DEPTH_SHIFT: float = 3.5
DEFAULT_DEPTH_USE_LOG: bool = True
DEFAULT_DEPTH_PIX_FMT: str = "gray12le"
DEPTH_METER_UNIT: str = "m"
DEPTH_MILLIMETER_UNIT: str = "mm"
DEFAULT_DEPTH_UNIT: str = DEPTH_MILLIMETER_UNIT
def infer_depth_unit(dtype: np.dtype | type) -> str:
"""Infer the physical unit of raw depth frames from their dtype.
Floating-point frames are assumed to be in metres, integer frames in millimetres.
"""
return DEPTH_METER_UNIT if np.issubdtype(np.dtype(dtype), np.floating) else DEPTH_MILLIMETER_UNIT
# Depth-specific tuning fields persisted under ``features[*]["info"]`` as ``video.<name>``.
DEPTH_ENCODER_INFO_FIELD_NAMES: frozenset[str] = frozenset({"depth_min", "depth_max", "shift", "use_log"})
@dataclass @dataclass
class VideoEncoderConfig: class VideoEncoderConfig:
"""Video encoder configuration. """Video encoder configuration."""
Attributes: vcodec: str = "libsvtav1" # Video codec name. "auto" picks a hardware codec if available, else libsvtav1.
vcodec: Video encoder name. ``"auto"`` is resolved during pix_fmt: str = "yuv420p" # Pixel format (e.g. yuv420p).
construction (HW encoder if available, else ``libsvtav1``). g: int | None = 2 # GOP size (keyframe interval).
pix_fmt: Pixel format (e.g. ``"yuv420p"``). crf: int | float | None = 30 # Quality level. Lower means better quality and larger files.
g: GOP size (keyframe interval). preset: int | str | None = None # Speed/quality preset. Accepted values are codec-specific.
crf: Quality level mapped to the native quality parameter of the fast_decode: int = 0 # Fast-decode tuning. Accepted values are codec-specific, 0 disables it.
codec (``crf`` for software, ``qp`` for NVENC/VAAPI,
``q:v`` for VideoToolbox, ``global_quality`` for QSV).
preset: Speed/quality preset. Accepted type is per-codec.
fast_decode: Fast-decode tuning. For ``libsvtav1`` this is a level (0-2)
embedded in ``svtav1-params``. For ``h264`` and ``hevc`` non-zero values
set ``tune=fastdecode``. Ignored for other codecs.
video_backend: Python to be used for encoding. Only ``"pyav"``
is currently supported.
extra_options: Free-form dictionary of additional video encoder options
(e.g. ``{"tune": "film", "profile:v": "high", "bf": 2}``).
"""
vcodec: str = "libsvtav1" # TODO(CarolinePascal): rename to codec ?
pix_fmt: str = "yuv420p"
g: int | None = 2
crf: int | float | None = 30
preset: int | str | None = None
fast_decode: int = 0
# TODO(CarolinePascal): add torchcodec support + find a way to unify the # TODO(CarolinePascal): add torchcodec support + find a way to unify the
# two backends (encoding and decoding). # two backends (encoding and decoding).
video_backend: str = "pyav" video_backend: str = "pyav" # Encoding backend. Only "pyav" is currently supported.
# Extra codec options merged last, e.g. {"tune": "film"}.
extra_options: dict[str, Any] = field(default_factory=dict) extra_options: dict[str, Any] = field(default_factory=dict)
# Source-data channel count this encoder is expected to handle. ``None``
# disables the pix_fmt channel-count check; concrete subclasses set it
# (3 for RGB, 1 for depth, etc.).
_DEFAULT_CHANNELS: ClassVar[int | None] = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.resolve_vcodec() self.resolve_vcodec()
# Empty-constructor ergonomics: ``VideoEncoderConfig()`` must "just work". # Empty-constructor ergonomics: ``VideoEncoderConfig()`` must "just work".
@@ -94,9 +111,9 @@ class VideoEncoderConfig:
self.validate() self.validate()
@classmethod @classmethod
def from_video_info(cls, video_info: dict | None) -> VideoEncoderConfig: def _kwargs_from_video_info(cls, video_info: dict | None) -> dict[str, Any]:
"""Reconstruct a :class:`VideoEncoderConfig` from a video feature's ``info`` block. """Parse the ``video.*`` keys of a feature ``info`` block into
Missing or ``None`` values fall back to the class defaults. constructor kwargs.
""" """
video_info = video_info or {} video_info = video_info or {}
kwargs: dict[str, Any] = {} kwargs: dict[str, Any] = {}
@@ -115,7 +132,15 @@ class VideoEncoderConfig:
continue continue
kwargs[field_name] = value kwargs[field_name] = value
return cls(**kwargs) return kwargs
@classmethod
def from_video_info(cls, video_info: dict | None) -> Self:
"""Reconstruct an encoder config from a video feature's ``info`` block.
Missing or ``None`` values fall back to the class defaults.
"""
return cls(**cls._kwargs_from_video_info(video_info))
def detect_available_encoders(self, encoders: list[str] | str) -> list[str]: def detect_available_encoders(self, encoders: list[str] | str) -> list[str]:
"""Return the subset of available encoders based on the specified video backend. """Return the subset of available encoders based on the specified video backend.
@@ -138,7 +163,9 @@ class VideoEncoderConfig:
require_package("av", extra="dataset") require_package("av", extra="dataset")
from lerobot.datasets import check_video_encoder_parameters_pyav from lerobot.datasets import check_video_encoder_parameters_pyav
check_video_encoder_parameters_pyav(self.vcodec, self.pix_fmt, self.get_codec_options()) check_video_encoder_parameters_pyav(
self.vcodec, self.pix_fmt, self.get_codec_options(), channels=self._DEFAULT_CHANNELS
)
def resolve_vcodec(self) -> None: def resolve_vcodec(self) -> None:
"""Check ``vcodec`` and, when it is ``"auto"``, pick a concrete encoder. """Check ``vcodec`` and, when it is ``"auto"``, pick a concrete encoder.
@@ -199,18 +226,24 @@ class VideoEncoderConfig:
if encoder_threads is not None: if encoder_threads is not None:
svtav1_parts.append(f"lp={encoder_threads}") svtav1_parts.append(f"lp={encoder_threads}")
if svtav1_parts: if svtav1_parts:
opts["svtav1-params"] = ":".join(svtav1_parts) set_if("svtav1-params", ":".join(svtav1_parts))
elif self.vcodec in ("h264", "hevc"): elif self.vcodec in ("h264", "hevc"):
set_if("crf", self.crf) set_if("crf", self.crf)
set_if("preset", self.preset) set_if("preset", self.preset)
if self.fast_decode: if self.fast_decode:
opts["tune"] = "fastdecode" set_if("tune", "fastdecode")
set_if("threads", encoder_threads) set_if("threads", encoder_threads)
elif self.vcodec == "libaom-av1":
set_if("crf", self.crf)
set_if("preset", self.preset)
if encoder_threads is not None:
set_if("threads", encoder_threads)
set_if("row-mt", 1)
elif self.vcodec in ("h264_videotoolbox", "hevc_videotoolbox"): elif self.vcodec in ("h264_videotoolbox", "hevc_videotoolbox"):
if self.crf is not None: if self.crf is not None:
opts["q:v"] = max(1, min(100, 100 - self.crf * 2)) set_if("q:v", max(1, min(100, 100 - self.crf * 2)))
elif self.vcodec in ("h264_nvenc", "hevc_nvenc"): elif self.vcodec in ("h264_nvenc", "hevc_nvenc"):
opts["rc"] = 0 set_if("rc", 0)
set_if("qp", self.crf) set_if("qp", self.crf)
set_if("preset", self.preset) set_if("preset", self.preset)
elif self.vcodec == "h264_vaapi": elif self.vcodec == "h264_vaapi":
@@ -230,6 +263,79 @@ class VideoEncoderConfig:
return opts return opts
def camera_encoder_defaults() -> VideoEncoderConfig: @dataclass
"""Return a :class:`VideoEncoderConfig` with RGB-camera defaults.""" class RGBEncoderConfig(VideoEncoderConfig):
return VideoEncoderConfig() """Encoder configuration for RGB camera streams.
Identical to :class:`VideoEncoderConfig` but declares the 3-channel
source-data layout so ``pix_fmt`` is validated against RGB inputs.
"""
_DEFAULT_CHANNELS: ClassVar[int] = 3
def rgb_encoder_defaults() -> RGBEncoderConfig:
"""Return a :class:`RGBEncoderConfig` with RGB-camera defaults."""
return RGBEncoderConfig()
@dataclass
class DepthEncoderConfig(VideoEncoderConfig):
"""Encoder configuration for depth-map streams.
Inherits the full :class:`VideoEncoderConfig` surface (codec, GOP, CRF,
preset, ``extra_options``) and adds the parameters of the depth quantizer.
Defaults flip ``vcodec`` to ``"hevc"`` (Main 12 profile) and ``pix_fmt`` to
``"gray12le"``.
"""
vcodec: str = "hevc" # Video codec name. Defaults to HEVC Main 12 (a 12-bit-capable codec).
pix_fmt: str = "gray12le" # Pixel format. Defaults to 12-bit grayscale.
extra_options: dict[str, Any] = field(default_factory=lambda: {"x265-params": "lossless=1"})
depth_min: float = DEFAULT_DEPTH_MIN # Minimum depth in meters, mapped to the lowest quantum.
depth_max: float = DEFAULT_DEPTH_MAX # Maximum depth in meters, mapped to the highest quantum.
shift: float = DEFAULT_DEPTH_SHIFT # Pre-log offset in meters for numerical stability near zero.
use_log: bool = DEFAULT_DEPTH_USE_LOG # Use logarithmic quantization (True) or linear (False).
_DEFAULT_CHANNELS: ClassVar[int] = 1
@classmethod
def _kwargs_from_video_info(cls, video_info: dict | None) -> dict[str, Any]:
"""Layer the depth-specific tuning (``depth_min`` / ``depth_max`` /
``shift`` / ``use_log``) on top of the base parser. Missing keys
fall back to the class defaults.
"""
kwargs = super()._kwargs_from_video_info(video_info)
video_info = video_info or {}
for name in DEPTH_ENCODER_INFO_FIELD_NAMES:
value = video_info.get(f"video.{name}")
if value is not None:
kwargs[name] = value
return kwargs
def depth_encoder_defaults() -> DepthEncoderConfig:
"""Return a :class:`DepthEncoderConfig` with depth-camera defaults."""
return DepthEncoderConfig()
def encoder_config_from_video_info(video_info: dict | None) -> VideoEncoderConfig:
"""Build the appropriate encoder config from a feature's ``info`` block.
Dispatches to :class:`DepthEncoderConfig` when the dict marks the feature
as a depth map and to :class:`RGBEncoderConfig`
otherwise.
Args:
video_info: A feature's ``info`` dict as persisted in ``info.json``,
or ``None`` (treated as an empty dict).
Returns:
A :class:`DepthEncoderConfig` for depth features, otherwise a
:class:`RGBEncoderConfig`.
"""
video_info = video_info or {}
is_depth = bool(video_info.get("is_depth_map") or video_info.get("video.is_depth_map"))
cls: type[VideoEncoderConfig] = DepthEncoderConfig if is_depth else RGBEncoderConfig
return cls.from_video_info(video_info)
+2 -1
View File
@@ -35,7 +35,7 @@ from .dataset_tools import (
remove_feature, remove_feature,
split_dataset, split_dataset,
) )
from .factory import make_dataset, resolve_delta_timestamps from .factory import make_dataset, make_train_eval_datasets, resolve_delta_timestamps
from .image_writer import safe_stop_image_writer from .image_writer import safe_stop_image_writer
from .io_utils import load_episodes, write_stats from .io_utils import load_episodes, write_stats
from .language import ( from .language import (
@@ -89,6 +89,7 @@ __all__ = [
"get_feature_stats", "get_feature_stats",
"load_episodes", "load_episodes",
"make_dataset", "make_dataset",
"make_train_eval_datasets",
"merge_datasets", "merge_datasets",
"modify_features", "modify_features",
"modify_tasks", "modify_tasks",
+15 -7
View File
@@ -242,12 +242,12 @@ def sample_images(image_paths: list[str]) -> np.ndarray:
images = None images = None
for i, idx in enumerate(sampled_indices): for i, idx in enumerate(sampled_indices):
path = image_paths[idx] path = image_paths[idx]
# we load as uint8 to reduce memory usage # we load RGB images as uint8 to reduce memory usage; depth keeps its native dtype
img = load_image_as_numpy(path, dtype=np.uint8, channel_first=True) img = load_image_as_numpy(path, dtype=np.uint8, channel_first=True)
img = auto_downsample_height_width(img) img = auto_downsample_height_width(img)
if images is None: if images is None:
images = np.empty((len(sampled_indices), *img.shape), dtype=np.uint8) images = np.empty((len(sampled_indices), *img.shape), dtype=img.dtype)
images[i] = img images[i] = img
@@ -506,8 +506,10 @@ def compute_episode_stats(
Each statistics dictionary contains min, max, mean, std, count, and quantiles. Each statistics dictionary contains min, max, mean, std, count, and quantiles.
Note: Note:
Image statistics are normalized to [0,1] range and have shape (3,1,1) for For 'image'/'video' features, stats are computed per channel and kept with a
per-channel values when dtype is 'image' or 'video'. leading channel axis (e.g. shape (3, 1, 1) for RGB). RGB stats are divided by
255 to land in [0, 1]; depth maps (features flagged with ``is_depth_map``) skip
this rescaling and remain in their stored units (stored in ``depth_unit``).
""" """
if quantile_list is None: if quantile_list is None:
quantile_list = DEFAULT_QUANTILES quantile_list = DEFAULT_QUANTILES
@@ -531,8 +533,12 @@ def compute_episode_stats(
) )
if features[key]["dtype"] in ["image", "video"]: if features[key]["dtype"] in ["image", "video"]:
normalization_factor = (
255.0 if not (features[key].get("info") or {}).get("is_depth_map", False) else 1.0
)
ep_stats[key] = { ep_stats[key] = {
k: v if k == "count" else np.squeeze(v / 255.0, axis=0) for k, v in ep_stats[key].items() k: v if k == "count" else np.squeeze(v / normalization_factor, axis=0)
for k, v in ep_stats[key].items()
} }
return ep_stats return ep_stats
@@ -552,8 +558,10 @@ def _validate_stat_value(value: np.ndarray, key: str, feature_key: str) -> None:
if key == "count" and value.shape != (1,): if key == "count" and value.shape != (1,):
raise ValueError(f"Shape of 'count' must be (1), but is {value.shape} instead.") raise ValueError(f"Shape of 'count' must be (1), but is {value.shape} instead.")
if "image" in feature_key and key != "count" and value.shape != (3, 1, 1): if "image" in feature_key and key != "count" and value.shape not in ((3, 1, 1), (1, 1, 1)):
raise ValueError(f"Shape of quantile '{key}' must be (3,1,1), but is {value.shape} instead.") raise ValueError(
f"Shape of quantile '{key}' must be (3,1,1) or (1,1,1) but is {value.shape} instead."
)
def _assert_type_and_shape(stats_list: list[dict[str, dict]]): def _assert_type_and_shape(stats_list: list[dict[str, dict]]):
+77 -8
View File
@@ -14,7 +14,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import contextlib import contextlib
from collections.abc import Callable import logging
from collections.abc import Callable, Iterable
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
@@ -25,12 +26,13 @@ import pyarrow as pa
import pyarrow.parquet as pq import pyarrow.parquet as pq
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
from lerobot.configs import VideoEncoderConfig from lerobot.configs import DEPTH_METER_UNIT, VideoEncoderConfig
from lerobot.utils.constants import DEFAULT_FEATURES, HF_LEROBOT_HOME, HF_LEROBOT_HUB_CACHE from lerobot.utils.constants import DEFAULT_FEATURES, HF_LEROBOT_HOME, HF_LEROBOT_HUB_CACHE
from lerobot.utils.feature_utils import _validate_feature_names from lerobot.utils.feature_utils import _validate_feature_names
from lerobot.utils.utils import flatten_dict from lerobot.utils.utils import flatten_dict
from .compute_stats import aggregate_stats from .compute_stats import aggregate_stats
from .depth_utils import MM_PER_METRE
from .feature_utils import create_empty_dataset_info from .feature_utils import create_empty_dataset_info
from .io_utils import ( from .io_utils import (
get_file_size_in_mb, get_file_size_in_mb,
@@ -338,6 +340,54 @@ class LeRobotDatasetMetadata:
"""Keys to access visual modalities stored as videos.""" """Keys to access visual modalities stored as videos."""
return [key for key, ft in self.features.items() if ft["dtype"] == "video"] return [key for key, ft in self.features.items() if ft["dtype"] == "video"]
@property
def depth_keys(self) -> list[str]:
"""Keys to access depth-map modalities stored as videos or images.
A depth key is a feature whose ``info`` dict carries ``"is_depth_map": True``
(or the legacy ``"video.is_depth_map"`` inside ``info`` or ``video_info``).
"""
def _is_depth(ft: dict) -> bool:
info = ft.get("info") or {}
video_info = ft.get("video_info") or {}
return (
info.get("is_depth_map", False)
or info.get("video.is_depth_map", False)
or video_info.get("video.is_depth_map", False)
)
return [key for key, ft in self.features.items() if _is_depth(ft)]
def rescale_depth_stats(self, output_unit: str) -> None:
"""Rescale depth feature stats in place from their recorded unit to ``output_unit``.
Depth stats are stored in the unit the frames were recorded in
(``features[key]["info"]["depth_unit"]``), while frames are returned in
``output_unit`` on read. This converts the unit-bearing stat entries so
stats match the frames consumers see.
"""
missing_unit_keys = [
key for key in self.depth_keys if (self.features[key].get("info") or {}).get("depth_unit") is None
]
if missing_unit_keys:
logging.warning(
f"Depth feature(s) {missing_unit_keys} have no recorded 'depth_unit' in their info. "
f"Depth maps and stats for these keys will be returned AS IS, with no unit conversion "
f"to the requested output unit {output_unit!r}. Re-record the dataset or set 'depth_unit' "
f"in the feature info (meta/info.json) to enable conversion."
)
if self.stats is None:
return
for key in self.depth_keys:
stored_unit = (self.features[key].get("info") or {}).get("depth_unit")
if stored_unit is None or stored_unit == output_unit or key not in self.stats:
continue
factor = MM_PER_METRE if stored_unit == DEPTH_METER_UNIT else 1.0 / MM_PER_METRE
self.stats[key] = {
stat: value if stat == "count" else value * factor for stat, value in self.stats[key].items()
}
@property @property
def camera_keys(self) -> list[str]: def camera_keys(self) -> list[str]:
"""Keys to access visual modalities (regardless of their storage method).""" """Keys to access visual modalities (regardless of their storage method)."""
@@ -581,29 +631,48 @@ class LeRobotDatasetMetadata:
def update_video_info( def update_video_info(
self, self,
video_key: str | None = None, video_key: str | None = None,
camera_encoder: VideoEncoderConfig | None = None, video_encoder: VideoEncoderConfig | None = None,
preserve_keys: Iterable[str] | None = None,
) -> None: ) -> None:
"""Populate per-feature video info in ``info.json``. """Populate or refresh per-feature video info in ``info.json``.
Warning: this function writes info from first episode videos, implicitly assuming that all videos have Warning: this function writes info from first episode videos, implicitly assuming that all videos have
been encoded the same way. Also, this means it assumes the first episode exists. been encoded the same way. Also, this means it assumes the first episode exists.
Always re-probes the videos and overwrites existing info for every recomputed
key. ``preserve_keys`` lists keys whose existing values must be kept (e.g.
data-intrinsic entries like ``is_depth_map`` and depth quantization params)
instead of being recomputed.
Args: Args:
video_key: If provided, only update this video key. Otherwise update video_key: If provided, only update this video key. Otherwise update
all video keys in the dataset. all video keys in the dataset.
camera_encoder: Encoder configuration used to produce the video_encoder: Encoder configuration used to produce the
videos. When provided, its fields are recorded as videos. When provided, its fields are recorded as
``video.<field>`` entries alongside the stream-derived ``video.<field>`` entries alongside the stream-derived
``video.*`` entries (see :func:`get_video_info`). ``video.*`` entries (see :func:`get_video_info`).
preserve_keys: Keys whose existing values are kept instead of being
recomputed. ``None`` (default) recomputes every key.
""" """
if video_key is not None and video_key not in self.video_keys: if video_key is not None and video_key not in self.video_keys:
raise ValueError(f"Video key {video_key} not found in dataset") raise ValueError(f"Video key {video_key} not found in dataset")
video_keys = [video_key] if video_key is not None else self.video_keys video_keys = [video_key] if video_key is not None else self.video_keys
preserve_set = set(preserve_keys or ())
for key in video_keys: for key in video_keys:
if not self.features[key].get("info", None): existing = self.features[key].get("info") or {}
video_path = self.root / self.video_path.format(video_key=key, chunk_index=0, file_index=0) video_path = self.root / self.video_path.format(video_key=key, chunk_index=0, file_index=0)
self.info.features[key]["info"] = get_video_info(video_path, camera_encoder=camera_encoder) new_info = get_video_info(video_path, video_encoder=video_encoder)
# Drop preserved keys so the existing values win on merge.
new_info = {k: v for k, v in new_info.items() if k not in preserve_set}
merged = {**existing, **new_info}
# Migrate the legacy depth marker to the canonical key.
if "video.is_depth_map" in merged:
logging.warning(
f"Migrating legacy 'video.is_depth_map' to 'is_depth_map' for feature {key!r}."
)
merged.setdefault("is_depth_map", merged.pop("video.is_depth_map"))
self.info.features[key]["info"] = merged
def update_chunk_settings( def update_chunk_settings(
self, self,
+46 -2
View File
@@ -22,7 +22,14 @@ from pathlib import Path
import datasets import datasets
import torch import torch
from lerobot.configs import (
DEFAULT_DEPTH_UNIT,
DEPTH_METER_UNIT,
DepthEncoderConfig,
)
from .dataset_metadata import LeRobotDatasetMetadata from .dataset_metadata import LeRobotDatasetMetadata
from .depth_utils import MM_PER_METRE, dequantize_depth
from .feature_utils import ( from .feature_utils import (
check_delta_timestamps, check_delta_timestamps,
get_delta_indices, get_delta_indices,
@@ -51,6 +58,7 @@ class DatasetReader:
delta_timestamps: dict[str, list[float]] | None, delta_timestamps: dict[str, list[float]] | None,
image_transforms: Callable | None, image_transforms: Callable | None,
return_uint8: bool = False, return_uint8: bool = False,
depth_output_unit: str = DEFAULT_DEPTH_UNIT,
): ):
"""Initialize the reader with metadata, filtering, and transform config. """Initialize the reader with metadata, filtering, and transform config.
@@ -68,6 +76,10 @@ class DatasetReader:
relative timestamp offsets for temporal context windows. relative timestamp offsets for temporal context windows.
image_transforms: Optional torchvision v2 transform applied to image_transforms: Optional torchvision v2 transform applied to
visual features. visual features.
return_uint8: If True, return RGB video frames as raw uint8 tensors
instead of normalized float32.
depth_output_unit: Physical unit depth maps are dequantized to
(``"m"`` or ``"mm"``). Defaults to ``"mm"``.
""" """
self._meta = meta self._meta = meta
self.root = root self.root = root
@@ -78,6 +90,7 @@ class DatasetReader:
raise TypeError("image_transforms must be callable or None.") raise TypeError("image_transforms must be callable or None.")
self._image_transforms = image_transforms self._image_transforms = image_transforms
self._return_uint8 = return_uint8 self._return_uint8 = return_uint8
self._depth_output_unit = depth_output_unit
self.hf_dataset: datasets.Dataset | None = None self.hf_dataset: datasets.Dataset | None = None
self._absolute_to_relative_idx: dict[int, int] | None = None self._absolute_to_relative_idx: dict[int, int] | None = None
@@ -88,6 +101,18 @@ class DatasetReader:
check_delta_timestamps(delta_timestamps, meta.fps, tolerance_s) check_delta_timestamps(delta_timestamps, meta.fps, tolerance_s)
self.delta_indices = get_delta_indices(delta_timestamps, meta.fps) self.delta_indices = get_delta_indices(delta_timestamps, meta.fps)
self._depth_encoder_configs: dict[str, DepthEncoderConfig] = {
vid_key: DepthEncoderConfig.from_video_info(self._meta.features[vid_key].get("info"))
for vid_key in self._meta.depth_keys
}
# Get the input unit of each depth feature stored as raw images.
self._image_depth_units: dict[str, str | None] = {
key: (self._meta.features[key].get("info") or {}).get("depth_unit")
for key in self._meta.depth_keys
if key in self._meta.image_keys
}
def set_image_transforms(self, image_transforms: Callable | None) -> None: def set_image_transforms(self, image_transforms: Callable | None) -> None:
"""Replace the transform applied to visual observations.""" """Replace the transform applied to visual observations."""
if image_transforms is not None and not callable(image_transforms): if image_transforms is not None and not callable(image_transforms):
@@ -259,7 +284,18 @@ class DatasetReader:
self._tolerance_s, self._tolerance_s,
self._video_backend, self._video_backend,
return_uint8=self._return_uint8, return_uint8=self._return_uint8,
is_depth=vid_key in self._meta.depth_keys,
) )
if vid_key in self._meta.depth_keys:
depth_encoder = self._depth_encoder_configs[vid_key]
frames = dequantize_depth(
frames,
depth_min=depth_encoder.depth_min,
depth_max=depth_encoder.depth_max,
shift=depth_encoder.shift,
use_log=depth_encoder.use_log,
output_unit=self._depth_output_unit,
)
return vid_key, frames.squeeze(0) return vid_key, frames.squeeze(0)
items = list(query_timestamps.items()) items = list(query_timestamps.items())
@@ -299,10 +335,18 @@ class DatasetReader:
item = {**video_frames, **item} item = {**video_frames, **item}
if self._image_transforms is not None: if self._image_transforms is not None:
image_keys = self._meta.camera_keys for cam in self._meta.camera_keys:
for cam in image_keys: if cam in self._meta.depth_keys:
continue
item[cam] = self._image_transforms(item[cam]) item[cam] = self._image_transforms(item[cam])
# Convert depth features to the output unit.
for key, stored_unit in self._image_depth_units.items():
if key in item and stored_unit is not None and stored_unit != self._depth_output_unit:
item[key] = (
item[key] * MM_PER_METRE if stored_unit == DEPTH_METER_UNIT else item[key] / MM_PER_METRE
)
# Add task as a string # Add task as a string
task_idx = item["task_index"].item() task_idx = item["task_index"].item()
item["task"] = self._meta.tasks.iloc[task_idx].name item["task"] = self._meta.tasks.iloc[task_idx].name
+113 -72
View File
@@ -37,7 +37,15 @@ import pyarrow.parquet as pq
import torch import torch
from tqdm import tqdm from tqdm import tqdm
from lerobot.configs import VideoEncoderConfig, camera_encoder_defaults from lerobot.configs import (
DepthEncoderConfig,
RGBEncoderConfig,
VideoEncoderConfig,
depth_encoder_defaults,
encoder_config_from_video_info,
rgb_encoder_defaults,
)
from lerobot.configs.video import DEPTH_ENCODER_INFO_FIELD_NAMES
from lerobot.utils.constants import ACTION, HF_LEROBOT_HOME, OBS_IMAGE, OBS_STATE from lerobot.utils.constants import ACTION, HF_LEROBOT_HOME, OBS_IMAGE, OBS_STATE
from lerobot.utils.utils import flatten_dict from lerobot.utils.utils import flatten_dict
@@ -48,6 +56,7 @@ from .compute_stats import (
compute_relative_action_stats, compute_relative_action_stats,
) )
from .dataset_metadata import LeRobotDatasetMetadata from .dataset_metadata import LeRobotDatasetMetadata
from .image_writer import write_image
from .io_utils import ( from .io_utils import (
get_parquet_file_size_in_mb, get_parquet_file_size_in_mb,
load_episodes, load_episodes,
@@ -62,12 +71,13 @@ from .utils import (
DEFAULT_DATA_FILE_SIZE_IN_MB, DEFAULT_DATA_FILE_SIZE_IN_MB,
DEFAULT_DATA_PATH, DEFAULT_DATA_PATH,
DEFAULT_EPISODES_PATH, DEFAULT_EPISODES_PATH,
DEPTH_FILE_PATTERN,
IMAGE_FILE_PATTERN,
VIDEO_DIR, VIDEO_DIR,
update_chunk_file_indices, update_chunk_file_indices,
) )
from .video_utils import ( from .video_utils import (
encode_video_frames, encode_video_frames,
get_video_info,
reencode_video, reencode_video,
) )
@@ -601,7 +611,7 @@ def _keep_episodes_from_video_with_av(
output_path: Path, output_path: Path,
episodes_to_keep: list[tuple[int, int]], episodes_to_keep: list[tuple[int, int]],
fps: float, fps: float,
camera_encoder: VideoEncoderConfig, video_encoder: VideoEncoderConfig,
) -> None: ) -> None:
"""Keep only specified episodes from a video file using PyAV. """Keep only specified episodes from a video file using PyAV.
@@ -615,7 +625,7 @@ def _keep_episodes_from_video_with_av(
Ranges are half-open intervals: [start_frame, end_frame), where start_frame Ranges are half-open intervals: [start_frame, end_frame), where start_frame
is inclusive and end_frame is exclusive. is inclusive and end_frame is exclusive.
fps: Frame rate of the video. fps: Frame rate of the video.
camera_encoder: Video encoder settings used to re-encode the kept frames. video_encoder: Video encoder settings used to re-encode the kept frames.
""" """
from fractions import Fraction from fractions import Fraction
@@ -640,13 +650,13 @@ def _keep_episodes_from_video_with_av(
# Convert fps to Fraction for PyAV compatibility. # Convert fps to Fraction for PyAV compatibility.
fps_fraction = Fraction(fps).limit_denominator(1000) fps_fraction = Fraction(fps).limit_denominator(1000)
codec_options = camera_encoder.get_codec_options(as_strings=True) codec_options = video_encoder.get_codec_options(as_strings=True)
v_out = out.add_stream(camera_encoder.vcodec, rate=fps_fraction, options=codec_options) v_out = out.add_stream(video_encoder.vcodec, rate=fps_fraction, options=codec_options)
# PyAV type stubs don't distinguish video streams from audio/subtitle streams. # PyAV type stubs don't distinguish video streams from audio/subtitle streams.
v_out.width = v_in.codec_context.width v_out.width = v_in.codec_context.width
v_out.height = v_in.codec_context.height v_out.height = v_in.codec_context.height
v_out.pix_fmt = camera_encoder.pix_fmt v_out.pix_fmt = video_encoder.pix_fmt
# Set time_base to match the frame rate for proper timestamp handling. # Set time_base to match the frame rate for proper timestamp handling.
v_out.time_base = Fraction(1, int(fps)) v_out.time_base = Fraction(1, int(fps))
@@ -733,7 +743,7 @@ def _copy_and_reindex_videos(
for video_key in src_dataset.meta.video_keys: for video_key in src_dataset.meta.video_keys:
logging.info(f"Processing videos for {video_key}") logging.info(f"Processing videos for {video_key}")
camera_encoder = VideoEncoderConfig.from_video_info( video_encoder = encoder_config_from_video_info(
src_dataset.meta.info.features.get(video_key, {}).get("info") src_dataset.meta.info.features.get(video_key, {}).get("info")
) )
@@ -817,7 +827,7 @@ def _copy_and_reindex_videos(
dst_video_path, dst_video_path,
episodes_to_keep_ranges, episodes_to_keep_ranges,
src_dataset.meta.fps, src_dataset.meta.fps,
camera_encoder, video_encoder,
) )
cumulative_ts = 0.0 cumulative_ts = 0.0
@@ -874,11 +884,11 @@ def _copy_and_reindex_episodes_metadata(
episode_meta.update(video_metadata[new_idx]) episode_meta.update(video_metadata[new_idx])
# Extract episode statistics from parquet metadata. # Extract episode statistics from parquet metadata.
# Note (maractingi): When pandas/pyarrow serializes numpy arrays with shape (3, 1, 1) to parquet, # When pandas/pyarrow serializes numpy arrays with shape (C, 1, 1) to parquet,
# they are being deserialized as nested object arrays like: # they are being deserialized as nested object arrays like:
# array([array([array([0.])]), array([array([0.])]), array([array([0.])])]) # array([array([array([0.])]), array([array([0.])]), array([array([0.])])])
# This happens particularly with image/video statistics. We need to detect and flatten # This happens particularly with image/video statistics. We need to detect and flatten
# these nested structures back to proper (3, 1, 1) arrays so aggregate_stats can process them. # these nested structures back to proper (C, 1, 1) arrays so aggregate_stats can process them.
episode_stats = {} episode_stats = {}
for key in src_episode_full: for key in src_episode_full:
if key.startswith("stats/"): if key.startswith("stats/"):
@@ -894,15 +904,16 @@ def _copy_and_reindex_episodes_metadata(
if feature_name in src_dataset.meta.features: if feature_name in src_dataset.meta.features:
feature_dtype = src_dataset.meta.features[feature_name]["dtype"] feature_dtype = src_dataset.meta.features[feature_name]["dtype"]
if feature_dtype in ["image", "video"] and stat_name != "count": if feature_dtype in ["image", "video"] and stat_name != "count":
# Stats are channel-first (C, 1, 1)
if isinstance(value, np.ndarray) and value.dtype == object: if isinstance(value, np.ndarray) and value.dtype == object:
flat_values = [] flat_values = []
for item in value: for item in value:
while isinstance(item, np.ndarray): while isinstance(item, np.ndarray):
item = item.flatten()[0] item = item.flatten()[0]
flat_values.append(item) flat_values.append(item)
value = np.array(flat_values, dtype=np.float64).reshape(3, 1, 1) value = np.array(flat_values, dtype=np.float64).reshape(-1, 1, 1)
elif isinstance(value, np.ndarray) and value.shape == (3,): elif isinstance(value, np.ndarray) and value.ndim == 1:
value = value.reshape(3, 1, 1) value = value.reshape(-1, 1, 1)
episode_stats[feature_name][stat_name] = value episode_stats[feature_name][stat_name] = value
@@ -1153,15 +1164,15 @@ def _save_episode_images_for_video(
# Get all items for this episode # Get all items for this episode
episode_dataset = imgs_dataset.select(range(from_idx, to_idx)) episode_dataset = imgs_dataset.select(range(from_idx, to_idx))
is_depth = img_key in dataset.meta.depth_keys
frame_pattern = DEPTH_FILE_PATTERN if is_depth else IMAGE_FILE_PATTERN
# Define function to save a single image # Define function to save a single image
def save_single_image(i_item_tuple): def save_single_image(i_item_tuple):
i, item = i_item_tuple i, item = i_item_tuple
img = item[img_key] write_image(item[img_key], imgs_dir / frame_pattern.format(frame_index=i))
# Use frame-XXXXXX.png format to match encode_video_frames expectations
img.save(str(imgs_dir / f"frame-{i:06d}.png"), quality=100)
return i return i
# Save images with proper naming convention for encode_video_frames (frame-XXXXXX.png)
items = list(enumerate(episode_dataset)) items = list(enumerate(episode_dataset))
with ThreadPoolExecutor(max_workers=num_workers) as executor: with ThreadPoolExecutor(max_workers=num_workers) as executor:
@@ -1193,13 +1204,14 @@ def _save_batch_episodes_images(
hf_dataset = dataset.hf_dataset.with_format(None) hf_dataset = dataset.hf_dataset.with_format(None)
imgs_dataset = hf_dataset.select_columns(img_key) imgs_dataset = hf_dataset.select_columns(img_key)
is_depth = img_key in dataset.meta.depth_keys
frame_pattern = DEPTH_FILE_PATTERN if is_depth else IMAGE_FILE_PATTERN
# Define function to save a single image with global frame index # Define function to save a single image with global frame index
# Defined once outside the loop to avoid repeated closure creation # Defined once outside the loop to avoid repeated closure creation
def save_single_image(i_item_tuple, base_frame_idx, img_key_param): def save_single_image(i_item_tuple, base_frame_idx, img_key_param):
i, item = i_item_tuple i, item = i_item_tuple
img = item[img_key_param] write_image(item[img_key_param], imgs_dir / frame_pattern.format(frame_index=base_frame_idx + i))
# Use global frame index for naming
img.save(str(imgs_dir / f"frame-{base_frame_idx + i:06d}.png"), quality=100)
return i return i
episode_durations = [] episode_durations = []
@@ -1290,7 +1302,7 @@ def _estimate_frame_size_via_calibration(
episode_indices: list[int], episode_indices: list[int],
temp_dir: Path, temp_dir: Path,
fps: int, fps: int,
camera_encoder: VideoEncoderConfig, video_encoder: VideoEncoderConfig,
num_calibration_frames: int = 30, num_calibration_frames: int = 30,
) -> float: ) -> float:
"""Estimate MB per frame by encoding a small calibration sample. """Estimate MB per frame by encoding a small calibration sample.
@@ -1304,7 +1316,7 @@ def _estimate_frame_size_via_calibration(
episode_indices: List of episode indices being processed. episode_indices: List of episode indices being processed.
temp_dir: Temporary directory for calibration files. temp_dir: Temporary directory for calibration files.
fps: Frames per second for video encoding. fps: Frames per second for video encoding.
camera_encoder: Video encoder settings used for calibration encoding. video_encoder: Video encoder settings used for calibration encoding.
num_calibration_frames: Number of frames to use for calibration (default: 30). num_calibration_frames: Number of frames to use for calibration (default: 30).
Returns: Returns:
@@ -1329,10 +1341,11 @@ def _estimate_frame_size_via_calibration(
hf_dataset = dataset.hf_dataset.with_format(None) hf_dataset = dataset.hf_dataset.with_format(None)
sample_indices = range(from_idx, from_idx + num_frames) sample_indices = range(from_idx, from_idx + num_frames)
# Save calibration frames # Save calibration frames using the suffix/format the encoder expects.
is_depth = img_key in dataset.meta.depth_keys
frame_pattern = DEPTH_FILE_PATTERN if is_depth else IMAGE_FILE_PATTERN
for i, idx in enumerate(sample_indices): for i, idx in enumerate(sample_indices):
img = hf_dataset[idx][img_key] write_image(hf_dataset[idx][img_key], calibration_dir / frame_pattern.format(frame_index=i))
img.save(str(calibration_dir / f"frame-{i:06d}.png"), quality=100)
# Encode calibration video # Encode calibration video
calibration_video_path = calibration_dir / "calibration.mp4" calibration_video_path = calibration_dir / "calibration.mp4"
@@ -1340,7 +1353,7 @@ def _estimate_frame_size_via_calibration(
imgs_dir=calibration_dir, imgs_dir=calibration_dir,
video_path=calibration_video_path, video_path=calibration_video_path,
fps=fps, fps=fps,
camera_encoder=camera_encoder, video_encoder=video_encoder,
overwrite=True, overwrite=True,
) )
@@ -1613,6 +1626,7 @@ def recompute_stats(
raise ValueError(f"No parquet files found in {data_dir}") raise ValueError(f"No parquet files found in {data_dir}")
all_episode_stats = [] all_episode_stats = []
# TODO: enable image and video stats re-computation
numeric_keys = [k for k, v in features_to_compute.items() if v["dtype"] not in ["image", "video"]] numeric_keys = [k for k, v in features_to_compute.items() if v["dtype"] not in ["image", "video"]]
for parquet_path in tqdm(parquet_files, desc="Computing stats from data files"): for parquet_path in tqdm(parquet_files, desc="Computing stats from data files"):
@@ -1658,7 +1672,8 @@ def convert_image_to_video_dataset(
dataset: LeRobotDataset, dataset: LeRobotDataset,
output_dir: Path | None = None, output_dir: Path | None = None,
repo_id: str | None = None, repo_id: str | None = None,
camera_encoder: VideoEncoderConfig | None = None, rgb_encoder: RGBEncoderConfig | None = None,
depth_encoder: DepthEncoderConfig | None = None,
episode_indices: list[int] | None = None, episode_indices: list[int] | None = None,
num_workers: int = 4, num_workers: int = 4,
max_episodes_per_batch: int | None = None, max_episodes_per_batch: int | None = None,
@@ -1670,21 +1685,32 @@ def convert_image_to_video_dataset(
LeRobot dataset structure with videos stored in chunked MP4 files. LeRobot dataset structure with videos stored in chunked MP4 files.
Args: Args:
dataset: The source LeRobot dataset with images dataset: The source LeRobot dataset with images.
output_dir: Root directory where the edited dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id. Equivalent to new_root in EditDatasetConfig. output_dir: Root directory where the converted dataset will be stored. When
repo_id: Edited dataset identifier. Equivalent to new_repo_id in EditDatasetConfig. ``None``, defaults to ``$HF_LEROBOT_HOME/repo_id``. Equivalent to
camera_encoder: Video encoder settings ``new_root`` in ``EditDatasetConfig``.
(``None`` uses :func:`~lerobot.configs.camera_encoder_defaults`). repo_id: Converted dataset identifier. Equivalent to ``new_repo_id`` in
episode_indices: List of episode indices to convert (None = all episodes) ``EditDatasetConfig``.
num_workers: Number of threads for parallel processing (default: 4) rgb_encoder: Video encoder settings applied to RGB cameras. When ``None``,
max_episodes_per_batch: Maximum episodes per video batch to avoid memory issues (None = no limit) :func:`~lerobot.configs.video.rgb_encoder_defaults` is used.
max_frames_per_batch: Maximum frames per video batch to avoid memory issues (None = no limit) depth_encoder: Video encoder settings applied to depth-map cameras, including
the quantization parameters persisted to the dataset metadata. When
``None``, :func:`~lerobot.configs.video.depth_encoder_defaults` is used.
episode_indices: Episode indices to convert. When ``None``, all episodes are
converted.
num_workers: Number of threads for parallel processing.
max_episodes_per_batch: Maximum episodes per video batch, to bound memory use.
``None`` means no limit.
max_frames_per_batch: Maximum frames per video batch, to bound memory use.
``None`` means no limit.
Returns: Returns:
New LeRobotDataset with images encoded as videos A new :class:`LeRobotDataset` with images encoded as videos.
""" """
if camera_encoder is None: if rgb_encoder is None:
camera_encoder = camera_encoder_defaults() rgb_encoder = rgb_encoder_defaults()
if depth_encoder is None:
depth_encoder = depth_encoder_defaults()
# Check that it's an image dataset # Check that it's an image dataset
if len(dataset.meta.video_keys) > 0: if len(dataset.meta.video_keys) > 0:
@@ -1709,10 +1735,7 @@ def convert_image_to_video_dataset(
logging.info( logging.info(
f"Converting {len(episode_indices)} episodes with {len(img_keys)} cameras from {dataset.repo_id}" f"Converting {len(episode_indices)} episodes with {len(img_keys)} cameras from {dataset.repo_id}"
) )
logging.info( logging.info(f"RGB video encoder: {rgb_encoder}, depth video encoder: {depth_encoder}")
f"Video codec: {camera_encoder.vcodec}, pixel format: {camera_encoder.pix_fmt}, "
f"GOP: {camera_encoder.g}, CRF: {camera_encoder.crf}"
)
# Create new features dict, converting image features to video features # Create new features dict, converting image features to video features
new_features = {} new_features = {}
@@ -1774,6 +1797,8 @@ def convert_image_to_video_dataset(
episode_lengths = {ep_idx: dataset.meta.episodes["length"][ep_idx] for ep_idx in episode_indices} episode_lengths = {ep_idx: dataset.meta.episodes["length"][ep_idx] for ep_idx in episode_indices}
for img_key in tqdm(img_keys, desc="Processing cameras"): for img_key in tqdm(img_keys, desc="Processing cameras"):
target_encoder = depth_encoder if img_key in dataset.meta.depth_keys else rgb_encoder
# Estimate size per frame by encoding a small calibration sample # Estimate size per frame by encoding a small calibration sample
# This provides accurate compression ratio for the specific codec parameters # This provides accurate compression ratio for the specific codec parameters
size_per_frame_mb = _estimate_frame_size_via_calibration( size_per_frame_mb = _estimate_frame_size_via_calibration(
@@ -1782,7 +1807,7 @@ def convert_image_to_video_dataset(
episode_indices=episode_indices, episode_indices=episode_indices,
temp_dir=temp_dir, temp_dir=temp_dir,
fps=fps, fps=fps,
camera_encoder=camera_encoder, video_encoder=target_encoder,
) )
logging.info(f"Processing camera: {img_key}") logging.info(f"Processing camera: {img_key}")
@@ -1824,7 +1849,7 @@ def convert_image_to_video_dataset(
imgs_dir=imgs_dir, imgs_dir=imgs_dir,
video_path=video_path, video_path=video_path,
fps=fps, fps=fps,
camera_encoder=camera_encoder, video_encoder=target_encoder,
overwrite=True, overwrite=True,
) )
@@ -1863,16 +1888,11 @@ def convert_image_to_video_dataset(
new_meta.info.total_tasks = dataset.meta.total_tasks new_meta.info.total_tasks = dataset.meta.total_tasks
new_meta.info.splits = {"train": f"0:{len(episode_indices)}"} new_meta.info.splits = {"train": f"0:{len(episode_indices)}"}
# Update video info for all image keys (now videos) # Update video info for all image keys (now videos). They are registered as
# We need to manually set video info since update_video_info() checks video_keys first # video features above, so update_video_info populates their (still-empty) info.
for img_key in img_keys: for img_key in img_keys:
if not new_meta.features[img_key].get("info", None): target_encoder = depth_encoder if img_key in dataset.meta.depth_keys else rgb_encoder
video_path = new_meta.root / new_meta.video_path.format( new_meta.update_video_info(video_key=img_key, video_encoder=target_encoder)
video_key=img_key, chunk_index=0, file_index=0
)
new_meta.info.features[img_key]["info"] = get_video_info(
video_path, camera_encoder=camera_encoder
)
write_info(new_meta.info, new_meta.root) write_info(new_meta.info, new_meta.root)
@@ -1899,11 +1919,11 @@ def convert_image_to_video_dataset(
def _reencode_video_worker(args: tuple) -> Path: def _reencode_video_worker(args: tuple) -> Path:
"""Picklable worker for :func:`reencode_dataset`'s process pool.""" """Picklable worker for :func:`reencode_dataset`'s process pool."""
video_path, camera_encoder, encoder_threads = args video_path, video_encoder, encoder_threads = args
reencode_video( reencode_video(
input_video_path=video_path, input_video_path=video_path,
output_video_path=video_path, output_video_path=video_path,
camera_encoder=camera_encoder, video_encoder=video_encoder,
encoder_threads=encoder_threads, encoder_threads=encoder_threads,
overwrite=True, overwrite=True,
) )
@@ -1912,7 +1932,8 @@ def _reencode_video_worker(args: tuple) -> Path:
def reencode_dataset( def reencode_dataset(
dataset: LeRobotDataset, dataset: LeRobotDataset,
camera_encoder: VideoEncoderConfig, rgb_encoder: RGBEncoderConfig | None = None,
depth_encoder: DepthEncoderConfig | None = None,
encoder_threads: int | None = None, encoder_threads: int | None = None,
num_workers: int | None = None, num_workers: int | None = None,
) -> LeRobotDataset: ) -> LeRobotDataset:
@@ -1923,8 +1944,11 @@ def reencode_dataset(
Args: Args:
dataset: An existing :class:`LeRobotDataset` whose videos will be dataset: An existing :class:`LeRobotDataset` whose videos will be
re-encoded. re-encoded.
camera_encoder: Target encoder configuration applied to every video rgb_encoder: Target encoder configuration applied to every RGB video
file. file. If ``None``, re-encoding is skipped for RGB videos.
depth_encoder: Target encoder configuration applied to every depth video
file. If ``None``, re-encoding is skipped for depth videos.
Quantization parameters will not override the ones in the current dataset.
encoder_threads: Per-encoder thread count forwarded to encoder_threads: Per-encoder thread count forwarded to
:func:`reencode_video`. ``None`` lets the codec decide. :func:`reencode_video`. ``None`` lets the codec decide.
num_workers: Number of parallel processes. ``None`` or ``0`` means num_workers: Number of parallel processes. ``None`` or ``0`` means
@@ -1936,23 +1960,35 @@ def reencode_dataset(
on disk. on disk.
""" """
meta = dataset.meta meta = dataset.meta
video_paths_list = [] video_keys_encoders_dict = {}
video_keys_paths_dict = {}
if rgb_encoder is None and depth_encoder is None:
raise ValueError("Either rgb_encoder or depth_encoder must be provided")
# Only re-encode if the videos are not already encoded with the given video encoding parameters # Only re-encode if the videos are not already encoded with the given video encoding parameters
for video_key in meta.video_keys: for video_key in meta.video_keys:
current_info = meta.info.features[video_key].get("info", {}) current_info = meta.info.features[video_key].get("info", {})
current_encoder = VideoEncoderConfig.from_video_info(current_info) current_encoder = encoder_config_from_video_info(current_info)
if current_encoder != camera_encoder: target_encoder = depth_encoder if video_key in meta.depth_keys else rgb_encoder
video_paths_list.extend((meta.root / VIDEO_DIR / video_key).rglob("*.mp4")) if target_encoder is None:
logging.info(f"No encoder provided for {video_key} video. Skipping re-encoding.")
elif current_encoder != target_encoder:
video_keys_paths_dict[video_key] = list((meta.root / VIDEO_DIR / video_key).rglob("*.mp4"))
video_keys_encoders_dict[video_key] = target_encoder
else: else:
logging.info(f"{video_key} videos are already encoded with {camera_encoder}. Nothing to do.") logging.info(f"{video_key} videos are already encoded with {target_encoder}. Nothing to do.")
if len(video_paths_list) == 0: if len(video_keys_paths_dict) == 0:
logging.warning("Dataset has no videos to re-encode.") logging.warning("Dataset has no videos to re-encode.")
return dataset return dataset
logging.info(f"Re-encoding {len(video_paths_list)} video file(s) with {camera_encoder}") logging.info(f"Re-encoding {sum(len(paths) for paths in video_keys_paths_dict.values())} video file(s).")
worker_args = [(vp, camera_encoder, encoder_threads) for vp in video_paths_list] worker_args = [
(path, encoder, encoder_threads)
for video_key, encoder in video_keys_encoders_dict.items()
for path in video_keys_paths_dict[video_key]
]
if num_workers and num_workers > 1: if num_workers and num_workers > 1:
with ProcessPoolExecutor(max_workers=num_workers) as pool: with ProcessPoolExecutor(max_workers=num_workers) as pool:
futures = [pool.submit(_reencode_video_worker, args) for args in worker_args] futures = [pool.submit(_reencode_video_worker, args) for args in worker_args]
@@ -1966,10 +2002,15 @@ def reencode_dataset(
for args in tqdm(worker_args, desc="Re-encoding videos"): for args in tqdm(worker_args, desc="Re-encoding videos"):
_reencode_video_worker(args) _reencode_video_worker(args)
# Refresh video info in metadata for every video key. # Refresh video info in metadata for every re-encoded key. Re-encoding only
for vid_key in meta.video_keys: # changes codec/container params, so for depth videos we preserve ``is_depth_map``
video_path = meta.root / meta.get_video_file_path(0, vid_key) # and the depth quantization params (``video.depth_min`` / ``video.depth_max`` /
meta.info.features[vid_key]["info"] = get_video_info(video_path, camera_encoder=camera_encoder) # ...), which describe the data rather than the codec and must survive a transcode.
# RGB videos pass an empty set: still a refresh, but nothing to preserve.
depth_preserve_keys = {"is_depth_map", *(f"video.{n}" for n in DEPTH_ENCODER_INFO_FIELD_NAMES)}
for video_key, encoder in video_keys_encoders_dict.items():
preserve_keys = depth_preserve_keys if video_key in meta.depth_keys else set()
meta.update_video_info(video_key=video_key, video_encoder=encoder, preserve_keys=preserve_keys)
write_info(meta.info, meta.root) write_info(meta.info, meta.root)
logging.info("Dataset metadata updated.") logging.info("Dataset metadata updated.")
+52 -14
View File
@@ -31,7 +31,14 @@ import PIL.Image
import pyarrow.parquet as pq import pyarrow.parquet as pq
import torch import torch
from lerobot.configs import VideoEncoderConfig, camera_encoder_defaults from lerobot.configs import (
DepthEncoderConfig,
RGBEncoderConfig,
VideoEncoderConfig,
depth_encoder_defaults,
infer_depth_unit,
rgb_encoder_defaults,
)
from .compute_stats import compute_episode_stats from .compute_stats import compute_episode_stats
from .dataset_metadata import LeRobotDatasetMetadata from .dataset_metadata import LeRobotDatasetMetadata
@@ -48,6 +55,7 @@ from .io_utils import (
write_info, write_info,
) )
from .utils import ( from .utils import (
DEFAULT_DEPTH_PATH,
DEFAULT_EPISODES_PATH, DEFAULT_EPISODES_PATH,
DEFAULT_IMAGE_PATH, DEFAULT_IMAGE_PATH,
update_chunk_file_indices, update_chunk_file_indices,
@@ -67,17 +75,22 @@ def _encode_video_worker(
episode_index: int, episode_index: int,
root: Path, root: Path,
fps: int, fps: int,
camera_encoder: VideoEncoderConfig | None = None, video_encoder: VideoEncoderConfig | None = None,
encoder_threads: int | None = None, encoder_threads: int | None = None,
) -> Path: ) -> Path:
temp_path = Path(tempfile.mkdtemp(dir=root)) / f"{video_key}_{episode_index:03d}.mp4" temp_path = Path(tempfile.mkdtemp(dir=root)) / f"{video_key}_{episode_index:03d}.mp4"
fpath = DEFAULT_IMAGE_PATH.format(image_key=video_key, episode_index=episode_index, frame_index=0) path_template = (
DEFAULT_DEPTH_PATH
if video_encoder is not None and isinstance(video_encoder, DepthEncoderConfig)
else DEFAULT_IMAGE_PATH
)
fpath = path_template.format(image_key=video_key, episode_index=episode_index, frame_index=0)
img_dir = (root / fpath).parent img_dir = (root / fpath).parent
encode_video_frames( encode_video_frames(
img_dir, img_dir,
temp_path, temp_path,
fps, fps,
camera_encoder=camera_encoder, video_encoder=video_encoder,
encoder_threads=encoder_threads, encoder_threads=encoder_threads,
overwrite=True, overwrite=True,
) )
@@ -96,7 +109,8 @@ class DatasetWriter:
self, self,
meta: LeRobotDatasetMetadata, meta: LeRobotDatasetMetadata,
root: Path, root: Path,
camera_encoder: VideoEncoderConfig | None, rgb_encoder: RGBEncoderConfig | None,
depth_encoder: DepthEncoderConfig | None,
encoder_threads: int | None, encoder_threads: int | None,
batch_encoding_size: int, batch_encoding_size: int,
streaming_encoder: StreamingVideoEncoder | None = None, streaming_encoder: StreamingVideoEncoder | None = None,
@@ -108,8 +122,11 @@ class DatasetWriter:
meta: Dataset metadata instance (used for feature schema, chunk meta: Dataset metadata instance (used for feature schema, chunk
settings, and episode persistence). settings, and episode persistence).
root: Local dataset root directory. root: Local dataset root directory.
camera_encoder: Video encoder settings applied to all cameras. rgb_encoder: Video encoder settings applied to RGB cameras. When
``None`` uses :func:`~lerobot.configs.camera_encoder_defaults`. ``None``, :func:`~lerobot.configs.video.rgb_encoder_defaults` is used.
depth_encoder: Video encoder settings applied to depth cameras, including
the quantization parameters. When ``None``,
:func:`~lerobot.configs.video.depth_encoder_defaults` is used.
encoder_threads: Number of encoder threads (global). ``None`` encoder_threads: Number of encoder threads (global). ``None``
lets the codec decide. lets the codec decide.
batch_encoding_size: Number of episodes to accumulate before batch_encoding_size: Number of episodes to accumulate before
@@ -120,7 +137,8 @@ class DatasetWriter:
""" """
self._meta = meta self._meta = meta
self._root = root self._root = root
self._camera_encoder = camera_encoder or camera_encoder_defaults() self._rgb_encoder = rgb_encoder or rgb_encoder_defaults()
self._depth_encoder = depth_encoder or depth_encoder_defaults()
self._encoder_threads = encoder_threads self._encoder_threads = encoder_threads
self._batch_encoding_size = batch_encoding_size self._batch_encoding_size = batch_encoding_size
self._streaming_encoder = streaming_encoder self._streaming_encoder = streaming_encoder
@@ -145,7 +163,8 @@ class DatasetWriter:
return ep_buffer return ep_buffer
def _get_image_file_path(self, episode_index: int, image_key: str, frame_index: int) -> Path: def _get_image_file_path(self, episode_index: int, image_key: str, frame_index: int) -> Path:
fpath = DEFAULT_IMAGE_PATH.format( path_template = DEFAULT_DEPTH_PATH if image_key in self._meta.depth_keys else DEFAULT_IMAGE_PATH
fpath = path_template.format(
image_key=image_key, episode_index=episode_index, frame_index=frame_index image_key=image_key, episode_index=episode_index, frame_index=frame_index
) )
return self._root / fpath return self._root / fpath
@@ -191,10 +210,20 @@ class DatasetWriter:
self.episode_buffer["timestamp"].append(timestamp) self.episode_buffer["timestamp"].append(timestamp)
self.episode_buffer["task"].append(frame.pop("task")) self.episode_buffer["task"].append(frame.pop("task"))
# Record each depth feature's input unit once, inferred from the first frame's dtype.
if frame_index == 0:
for depth_key in self._meta.depth_keys:
if depth_key not in frame:
continue
info = self._meta.features[depth_key].setdefault("info", {})
if info.get("depth_unit") is None:
info["depth_unit"] = infer_depth_unit(np.asarray(frame[depth_key]).dtype)
# Start streaming encoder on first frame of episode # Start streaming encoder on first frame of episode
if frame_index == 0 and self._streaming_encoder is not None: if frame_index == 0 and self._streaming_encoder is not None:
self._streaming_encoder.start_episode( self._streaming_encoder.start_episode(
video_keys=list(self._meta.video_keys), video_keys=list(self._meta.video_keys),
depth_video_keys=list(self._meta.depth_keys),
temp_dir=self._root, temp_dir=self._root,
) )
@@ -282,10 +311,13 @@ class DatasetWriter:
if use_streaming: if use_streaming:
streaming_results = self._streaming_encoder.finish_episode() streaming_results = self._streaming_encoder.finish_episode()
for video_key in self._meta.video_keys: for video_key in self._meta.video_keys:
normalization_factor = 255.0 if video_key not in self._meta.depth_keys else 1.0
temp_path, video_stats = streaming_results[video_key] temp_path, video_stats = streaming_results[video_key]
if video_stats is not None: if video_stats is not None:
ep_stats[video_key] = { ep_stats[video_key] = {
k: v if k == "count" else np.squeeze(v.reshape(1, -1, 1, 1) / 255.0, axis=0) k: v
if k == "count"
else np.squeeze(v.reshape(1, -1, 1, 1) / normalization_factor, axis=0)
for k, v in video_stats.items() for k, v in video_stats.items()
} }
ep_metadata.update(self._save_episode_video(video_key, episode_index, temp_path=temp_path)) ep_metadata.update(self._save_episode_video(video_key, episode_index, temp_path=temp_path))
@@ -300,7 +332,7 @@ class DatasetWriter:
episode_index, episode_index,
self._root, self._root,
self._meta.fps, self._meta.fps,
self._camera_encoder, self._depth_encoder if video_key in self._meta.depth_keys else self._rgb_encoder,
self._encoder_threads, self._encoder_threads,
): video_key ): video_key
for video_key in self._meta.video_keys for video_key in self._meta.video_keys
@@ -511,7 +543,12 @@ class DatasetWriter:
# Update video info (only needed when first episode is encoded) # Update video info (only needed when first episode is encoded)
if episode_index == 0: if episode_index == 0:
self._meta.update_video_info(video_key, camera_encoder=self._camera_encoder) self._meta.update_video_info(
video_key,
video_encoder=self._depth_encoder
if video_key in self._meta.depth_keys
else self._rgb_encoder,
)
write_info(self._meta.info, self._meta.root) write_info(self._meta.info, self._meta.root)
metadata = { metadata = {
@@ -578,13 +615,14 @@ class DatasetWriter:
self.image_writer.wait_until_done() self.image_writer.wait_until_done()
def _encode_temporary_episode_video(self, video_key: str, episode_index: int) -> Path: def _encode_temporary_episode_video(self, video_key: str, episode_index: int) -> Path:
"""Use ffmpeg to convert frames stored as png into mp4 videos.""" """Use ffmpeg to convert frames stored as png/tiff into mp4 videos."""
is_depth = video_key in self._meta.depth_keys
return _encode_video_worker( return _encode_video_worker(
video_key, video_key,
episode_index, episode_index,
self._root, self._root,
self._meta.fps, self._meta.fps,
self._camera_encoder, self._depth_encoder if is_depth else self._rgb_encoder,
self._encoder_threads, self._encoder_threads,
) )
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Depth encoding/decoding helpers for :class:`DepthEncoderConfig`.
"""
import math
from typing import Literal
import av
import numpy as np
import torch
from numpy.typing import NDArray
from lerobot.configs.video import (
DEFAULT_DEPTH_MAX,
DEFAULT_DEPTH_MIN,
DEFAULT_DEPTH_PIX_FMT,
DEFAULT_DEPTH_SHIFT,
DEFAULT_DEPTH_USE_LOG,
DEPTH_METER_UNIT,
DEPTH_MILLIMETER_UNIT,
DEPTH_QMAX,
infer_depth_unit,
)
from .image_writer import squeeze_single_channel
from .pyav_utils import write_u16_plane
MM_PER_METRE = 1000.0
_UINT16_MAX = 65535
def _validate_log_quant_params(depth_min: float, shift: float) -> None:
"""Ensure ``log(depth_min + shift)`` is finite."""
if depth_min + shift <= 0:
raise ValueError(
f"depth_min + shift must be positive for logarithmic quantization, "
f"got depth_min={depth_min} + shift={shift} = {depth_min + shift}"
)
def _depth_input_to_float32_and_unit(
depth: NDArray[np.integer] | NDArray[np.floating],
input_unit: Literal["auto", DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT],
) -> tuple[NDArray[np.float32], Literal[DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT]]:
"""Convert depth to float32 in the chosen unit, and return the resolved unit."""
resolved_unit = infer_depth_unit(depth.dtype) if input_unit == "auto" else input_unit
return depth.astype(np.float32, order="K"), resolved_unit
def quantize_depth(
depth: NDArray[np.uint16] | NDArray[np.float32] | torch.Tensor,
depth_min: float = DEFAULT_DEPTH_MIN,
depth_max: float = DEFAULT_DEPTH_MAX,
shift: float = DEFAULT_DEPTH_SHIFT,
use_log: bool = DEFAULT_DEPTH_USE_LOG,
pix_fmt: str = DEFAULT_DEPTH_PIX_FMT,
video_backend: str | None = "pyav",
input_unit: Literal["auto", DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT] = "auto",
) -> NDArray[np.uint16] | av.VideoFrame:
"""Quantize depth to 12-bit codes (``uint16``, values ``0…DEPTH_QMAX``).
Depth maps are packed into 12-bit integer frames so they fit in standard
high-bit-depth pixel formats (e.g. ``yuv420p12le`` / ``gray12le``)
and can be encoded by widely supported video codecs (e.g. HEVC Main 12).
Logarithmic quantization is the default because it allocates more quanta
to near-range depth, which matches the (1/depth) error profile of typical
depth sensors. Math is ported from BEHAVIOR-1K's ``obs_utils.py``.
**Input units**:
- ``input_unit="auto"`` (default): infer from dtype (floating = m, non-floating = mm).
- ``input_unit="mm"``: interpret input values as millimetres.
- ``input_unit="m"``: interpret input values as metres.
Quantization math runs in the **resolved input unit**.
``depth_min``, ``depth_max``, and ``shift`` are always in **metres**.
Args:
depth: Depth map; ``torch.Tensor`` is moved to CPU for conversion.
depth_min: Depth (metres) at quantum ``0``.
depth_max: Depth (metres) at quantum :data:`DEPTH_QMAX`.
shift: Depth shift (metres); used in log mode. Must satisfy ``depth_min + shift > 0``.
use_log: If ``True`` (default), quantize in log space.
video_backend: Video backend to use for encoding. Defaults to "pyav".
input_unit: Input unit policy (``"auto"``, ``"mm"``, ``"m"``).
Returns:
``numpy.ndarray``, ``dtype=uint16``, same shape as ``depth``, values in
``[0, DEPTH_QMAX]``.
Raises:
ValueError: If ``input_unit`` is not ``"auto"``, ``"mm"``, or ``"m"``.
ValueError: If ``use_log=True`` and ``depth_min + shift <= 0``.
"""
if input_unit not in ("auto", DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT):
raise ValueError(
f"input_unit must be 'auto', '{DEPTH_METER_UNIT}', or '{DEPTH_MILLIMETER_UNIT}', got {input_unit!r}"
)
if isinstance(depth, torch.Tensor):
depth = depth.detach().cpu().numpy()
# Squeeze single-channel dim: (H, W, 1) or (1, H, W) → (H, W)
depth = squeeze_single_channel(depth)
depth_f, resolved_unit = _depth_input_to_float32_and_unit(depth, input_unit=input_unit)
# Convert depth_min, depth_max, and shift to the resolved input unit.
depth_min_u = (
np.float32(depth_min) if resolved_unit == DEPTH_METER_UNIT else np.float32(depth_min * MM_PER_METRE)
)
depth_max_u = (
np.float32(depth_max) if resolved_unit == DEPTH_METER_UNIT else np.float32(depth_max * MM_PER_METRE)
)
shift_u = np.float32(shift) if resolved_unit == DEPTH_METER_UNIT else np.float32(shift * MM_PER_METRE)
# Normalization and quantization is performed in the resolved input unit.
if use_log:
_validate_log_quant_params(depth_min, shift)
log_min = math.log(float(depth_min_u + shift_u))
log_max = math.log(float(depth_max_u + shift_u))
norm = (np.log(depth_f + shift_u) - log_min) / (log_max - log_min)
else:
norm = (depth_f - depth_min_u) / (depth_max_u - depth_min_u)
quantized = np.rint(norm * DEPTH_QMAX).clip(0, DEPTH_QMAX).astype(np.uint16, copy=False)
if video_backend == "pyav":
frame = av.VideoFrame.from_ndarray(quantized, format=pix_fmt)
write_u16_plane(frame.planes[0], quantized)
return frame
else:
return quantized
def dequantize_depth(
quantized: NDArray[np.uint16] | av.VideoFrame | torch.Tensor,
depth_min: float = DEFAULT_DEPTH_MIN,
depth_max: float = DEFAULT_DEPTH_MAX,
shift: float = DEFAULT_DEPTH_SHIFT,
use_log: bool = DEFAULT_DEPTH_USE_LOG,
pix_fmt: str = DEFAULT_DEPTH_PIX_FMT,
output_unit: Literal[DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT] = DEPTH_MILLIMETER_UNIT,
output_tensor: bool = True,
output_channel_last: bool = False,
) -> NDArray[np.uint16] | NDArray[np.float32] | torch.Tensor:
"""Inverse of :func:`quantize_depth`.
Decoding inverts the same normalized code mapping as :func:`quantize_depth`
using ``depth_min`` / ``depth_max`` / ``shift`` (in metres), then returns
the requested output unit. Tuning arguments **must match** :func:`quantize_depth`.
Accepted input layouts :
- ``(H, W, 1)`` or ``(H, W)`` single frame with channel-last.
- ``(..., 1, H, W)`` batched frames with channel-first.
- ``(..., H, W, 1)`` batched frames with channel-last.
Output layout is determined by ``output_channel_last``.
Args:
quantized: 12-bit codes in ``[0, DEPTH_QMAX]``. ``np.ndarray``,
``av.VideoFrame``, or ``torch.Tensor`` (any integer or float dtype).
depth_min, depth_max, shift, use_log: Same as :func:`quantize_depth` (metres).
pix_fmt: Pixel format used to extract the plane from an ``av.VideoFrame``.
output_unit: ``"mm"`` returns ``uint16`` millimetres (rint, clip
``[0, 65535]``) when returning a numpy array, or ``float32`` mm when
``output_tensor=True``. ``"m"`` returns ``float32`` metres in
``[depth_min, depth_max]``.
output_tensor: If True, return a ``torch.Tensor`` instead of a numpy array.
Returns:
Depth map in the requested unit and dtype.
Raises:
ValueError: If ``output_unit`` is not ``"m"`` or ``"mm"``.
ValueError: If ``use_log=True`` and ``depth_min + shift <= 0``.
"""
if output_unit not in (DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT):
raise ValueError(
f"output_unit must be '{DEPTH_METER_UNIT}' or '{DEPTH_MILLIMETER_UNIT}', got {output_unit!r}"
)
if use_log:
_validate_log_quant_params(depth_min, shift)
if isinstance(quantized, av.VideoFrame):
quantized = quantized.to_ndarray(format=pix_fmt)
# Compute the scale and offset first.
depth_min_m = float(depth_min)
depth_max_m = float(depth_max)
shift_m = float(shift)
if use_log:
log_min = math.log(depth_min_m + shift_m)
log_max = math.log(depth_max_m + shift_m)
scale = (log_max - log_min) / DEPTH_QMAX
offset = log_min
else:
scale = (depth_max_m - depth_min_m) / DEPTH_QMAX
offset = depth_min_m
# ── Torch path: stay on the input device, single fp32 allocation. ────────
if isinstance(quantized, torch.Tensor):
if quantized.ndim >= 3:
# Drop the single-channel dimension so the math runs on (..., H, W).
quantized = quantized.squeeze(-3) if quantized.shape[-3] == 1 else quantized.squeeze(-1)
# Single allocation we own; everything else is in-place.
buf = quantized.to(dtype=torch.float32, copy=True)
buf.mul_(scale).add_(offset)
if use_log:
buf.exp_().sub_(shift_m)
buf.clamp_(depth_min_m, depth_max_m)
buf.unsqueeze_(-1) if output_channel_last else buf.unsqueeze_(-3)
if output_unit == DEPTH_METER_UNIT:
return buf if output_tensor else buf.cpu().numpy()
# mm path: round + clamp in float32, skipping the uint16 round-trip
# when returning a tensor (torch.uint16 is poorly supported).
buf.mul_(MM_PER_METRE).round_().clamp_(0.0, _UINT16_MAX)
if output_tensor:
return buf
return buf.cpu().numpy().astype(np.uint16, copy=False)
# ── NumPy path: single fp32 allocation, ``out=`` for in-place math. ─────
arr = np.asarray(quantized)
if arr.ndim >= 3:
# Drop the single-channel dimension so the math runs on (..., H, W).
arr = np.squeeze(arr, axis=-3) if arr.shape[-3] == 1 else np.squeeze(arr, axis=-1)
buf = np.empty(arr.shape, dtype=np.float32)
np.multiply(arr, scale, out=buf)
np.add(buf, offset, out=buf)
if use_log:
np.exp(buf, out=buf)
np.subtract(buf, shift_m, out=buf)
np.clip(buf, depth_min_m, depth_max_m, out=buf)
buf = np.expand_dims(buf, axis=-1) if output_channel_last else np.expand_dims(buf, axis=-3)
if output_unit == DEPTH_METER_UNIT:
return torch.from_numpy(buf) if output_tensor else buf
np.multiply(buf, MM_PER_METRE, out=buf)
np.rint(buf, out=buf)
np.clip(buf, 0.0, _UINT16_MAX, out=buf)
if output_tensor:
# torch.uint16 support is very limited; return float32 millimetres.
return torch.from_numpy(buf)
return buf.astype(np.uint16, copy=False)
+82
View File
@@ -14,6 +14,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import logging import logging
import math
from pprint import pformat from pprint import pformat
import torch import torch
@@ -96,6 +97,7 @@ def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDatas
revision=cfg.dataset.revision, revision=cfg.dataset.revision,
video_backend=cfg.dataset.video_backend, video_backend=cfg.dataset.video_backend,
return_uint8=True, return_uint8=True,
depth_output_unit=cfg.dataset.depth_output_unit,
tolerance_s=cfg.tolerance_s, tolerance_s=cfg.tolerance_s,
) )
else: else:
@@ -126,7 +128,87 @@ def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDatas
if cfg.dataset.use_imagenet_stats: if cfg.dataset.use_imagenet_stats:
for key in dataset.meta.camera_keys: for key in dataset.meta.camera_keys:
if key in dataset.meta.depth_keys:
continue # Exclude depth keys from ImageNet stats
for stats_type, stats in IMAGENET_STATS.items(): for stats_type, stats in IMAGENET_STATS.items():
dataset.meta.stats[key][stats_type] = torch.tensor(stats, dtype=torch.float32) dataset.meta.stats[key][stats_type] = torch.tensor(stats, dtype=torch.float32)
return dataset return dataset
def make_train_eval_datasets(
cfg: TrainPipelineConfig,
) -> tuple[LeRobotDataset | MultiLeRobotDataset, LeRobotDataset | None]:
"""Create train and optional eval datasets by splitting episodes based on eval_split.
The last ceil(n_episodes * eval_split) episodes per task are held out for evaluation.
If eval_split == 0.0, returns (full_dataset, None).
"""
full_dataset = make_dataset(cfg)
if cfg.dataset.eval_split == 0.0:
return full_dataset, None
base_episodes = (
full_dataset.episodes if full_dataset.episodes is not None else list(range(full_dataset.num_episodes))
)
episode_tasks = full_dataset.meta.episodes["tasks"]
task_to_episodes: dict[str, list[int]] = {}
for ep_idx in base_episodes:
task_key = episode_tasks[ep_idx][0] if episode_tasks[ep_idx] else ""
task_to_episodes.setdefault(task_key, []).append(ep_idx)
train_episodes, eval_episodes = [], []
for eps in task_to_episodes.values():
n_eval = math.ceil(len(eps) * cfg.dataset.eval_split)
train_episodes.extend(eps[: len(eps) - n_eval])
eval_episodes.extend(eps[len(eps) - n_eval :])
if not train_episodes:
raise ValueError(
f"eval_split={cfg.dataset.eval_split} leaves 0 training episodes from {len(base_episodes)} total."
)
logging.info(
f"Train/eval split: {len(train_episodes)} train, {len(eval_episodes)} eval "
f"(eval_split={cfg.dataset.eval_split}, {len(task_to_episodes)} tasks)"
)
delta_timestamps = resolve_delta_timestamps(cfg.trainable_config, full_dataset.meta)
train_image_transforms = (
ImageTransforms(cfg.dataset.image_transforms) if cfg.dataset.image_transforms.enable else None
)
train_dataset = LeRobotDataset(
cfg.dataset.repo_id,
root=cfg.dataset.root,
episodes=train_episodes,
delta_timestamps=delta_timestamps,
image_transforms=train_image_transforms,
revision=cfg.dataset.revision,
video_backend=cfg.dataset.video_backend,
return_uint8=True,
tolerance_s=cfg.tolerance_s,
)
eval_dataset = LeRobotDataset(
cfg.dataset.repo_id,
root=cfg.dataset.root,
episodes=eval_episodes,
delta_timestamps=delta_timestamps,
image_transforms=None,
revision=cfg.dataset.revision,
video_backend=cfg.dataset.video_backend,
return_uint8=True,
tolerance_s=cfg.tolerance_s,
)
if cfg.dataset.use_imagenet_stats:
for ds in (train_dataset, eval_dataset):
for key in ds.meta.camera_keys:
for stats_type, stats in IMAGENET_STATS.items():
ds.meta.stats[key][stats_type] = torch.tensor(stats, dtype=torch.float32)
return train_dataset, eval_dataset
+1 -1
View File
@@ -336,7 +336,7 @@ def validate_feature_image_or_video(
Args: Args:
name (str): The name of the feature. name (str): The name of the feature.
expected_shape (list[str]): The expected shape (C, H, W). expected_shape (list[str]): The expected shape, e.g. (C, H, W) or (H, W, C).
value: The image data to validate. value: The image data to validate.
Returns: Returns:
+62 -6
View File
@@ -41,11 +41,51 @@ def safe_stop_image_writer(func):
return wrapper return wrapper
def image_array_to_pil_image(image_array: np.ndarray, range_check: bool = True) -> PIL.Image.Image: def squeeze_single_channel(array: np.ndarray) -> np.ndarray:
# TODO(aliberts): handle 1 channel and 4 for depth images """Drop a leading or trailing singleton channel dim: ``(1, H, W)`` / ``(H, W, 1)`` -> ``(H, W)``.
if image_array.ndim != 3:
raise ValueError(f"The array has {image_array.ndim} dimensions, but 3 is expected for an image.")
Unlike ``array.squeeze()``, this only removes the channel axis, never an ``H`` or ``W`` of size 1.
"""
if array.ndim == 3:
if array.shape[0] == 1:
return array[0]
if array.shape[-1] == 1:
return array[..., 0]
return array
def image_array_to_pil_image(image_array: np.ndarray, range_check: bool = True) -> PIL.Image.Image:
"""Convert a NumPy array to a PIL Image, preserving precision for grayscale.
Behaviour by shape:
- ``(H, W)`` or ``(1, H, W)`` / ``(H, W, 1)``: single-channel grayscale.
The native dtype is preserved using the matching PIL mode
(``I;16`` / ``F``). This is the path used for raw depth maps (no rescaling, clamping, or downcasting)
- ``(3, H, W)`` / ``(H, W, 3)``: RGB. Channels-first inputs are transposed
to channels-last. Float inputs in ``[0, 1]`` are scaled to ``uint8``
(existing behaviour, gated by ``range_check``).
Other shapes / channel counts raise ``NotImplementedError`` or
``ValueError``.
"""
# TODO(CarolinePascal): 4 dimensions RGB-D images
if image_array.ndim not in (2, 3):
raise ValueError(f"The array has {image_array.ndim} dimensions, but 2 or 3 is expected for an image.")
# Squeeze 3D single-channel inputs to 2D so depth maps work whether the
# caller emits (H, W), (1, H, W), or (H, W, 1).
image_array = squeeze_single_channel(image_array)
if image_array.ndim == 2:
if image_array.dtype not in [np.uint16, np.float32]:
raise ValueError(
f"Unsupported single-channel image dtype: {image_array.dtype}. "
f"Supported dtypes: {sorted(str(d) for d in [np.uint16, np.float32])}."
)
return PIL.Image.fromarray(np.ascontiguousarray(image_array))
# 3D path: must be RGB (3 channels), channels-first or channels-last.
if image_array.shape[0] == 3: if image_array.shape[0] == 3:
# Transpose from pytorch convention (C, H, W) to (H, W, C) # Transpose from pytorch convention (C, H, W) to (H, W, C)
image_array = image_array.transpose(1, 2, 0) image_array = image_array.transpose(1, 2, 0)
@@ -71,13 +111,29 @@ def image_array_to_pil_image(image_array: np.ndarray, range_check: bool = True)
return PIL.Image.fromarray(image_array) return PIL.Image.fromarray(image_array)
def save_kwargs_for_path(fpath: Path, compress_level: int) -> dict:
"""Pick the right format-specific kwargs for :meth:`PIL.Image.Image.save`.
PNG uses ``compress_level`` (0-9, zlib). TIFF uses ``compression`` (raw) for lossless raw depth maps.
"""
suffix = Path(fpath).suffix.lower()
if suffix == ".png":
return {"compress_level": compress_level}
if suffix in (".tif", ".tiff"):
return {"compression": "raw"}
else:
raise ValueError(f"Unsupported image file extension: {suffix}")
def write_image(image: np.ndarray | PIL.Image.Image, fpath: Path, compress_level: int = 1): def write_image(image: np.ndarray | PIL.Image.Image, fpath: Path, compress_level: int = 1):
""" """
Saves a NumPy array or PIL Image to a file. Saves a NumPy array or PIL Image to a file.
This function handles both NumPy arrays and PIL Image objects, converting This function handles both NumPy arrays and PIL Image objects, converting
the former to a PIL Image before saving. It includes error handling for the former to a PIL Image before saving. It includes error handling for
the save operation. the save operation. The output format is inferred from the *fpath*
extension: ``.png`` PNG with ``compress_level``, ``.tiff`` / ``.tif``
lossless raw depth maps (TIFF).
Args: Args:
image (np.ndarray | PIL.Image.Image): The image data to save. image (np.ndarray | PIL.Image.Image): The image data to save.
@@ -101,7 +157,7 @@ def write_image(image: np.ndarray | PIL.Image.Image, fpath: Path, compress_level
img = image img = image
else: else:
raise TypeError(f"Unsupported image type: {type(image)}") raise TypeError(f"Unsupported image type: {type(image)}")
img.save(fpath, compress_level=compress_level) img.save(fpath, **save_kwargs_for_path(fpath, compress_level))
except Exception as e: except Exception as e:
logger.error("Error writing image %s: %s", fpath, e) logger.error("Error writing image %s: %s", fpath, e)
+36 -11
View File
@@ -226,28 +226,50 @@ def load_image_as_numpy(
Args: Args:
fpath (str | Path): Path to the image file. fpath (str | Path): Path to the image file.
dtype (np.dtype): The desired data type of the output array. If floating, dtype (np.dtype): The desired data type of the output array. If floating,
pixels are scaled to [0, 1]. pixels are scaled to [0, 1]. Only used for RGB images.
channel_first (bool): If True, converts the image to (C, H, W) format. channel_first (bool): If True, converts the image to (C, H, W) format.
Otherwise, it remains in (H, W, C) format. Otherwise, it remains in (H, W, C) format.
Returns: Returns:
np.ndarray: The image as a numpy array. np.ndarray: The image as a numpy array.
""" """
img = PILImage.open(fpath).convert("RGB") is_depth = fpath.endswith(".tiff") or fpath.endswith(".tif")
img_array = np.array(img, dtype=dtype) if is_depth:
# Preserve the native depth dtype (uint16 -> "I;16", float32 -> "F").
img = PILImage.open(fpath)
img_array = np.array(img)
else:
img = PILImage.open(fpath).convert("RGB")
img_array = np.array(img, dtype=dtype)
if np.issubdtype(dtype, np.floating):
img_array /= 255.0
if channel_first: # (H, W, C) -> (C, H, W) if channel_first: # (H, W, C) -> (C, H, W)
img_array = np.transpose(img_array, (2, 0, 1)) img_array = img_array[np.newaxis, ...] if img_array.ndim == 2 else np.transpose(img_array, (2, 0, 1))
if np.issubdtype(dtype, np.floating):
img_array /= 255.0
return img_array return img_array
# PIL modes for 16-bit unsigned depth maps.
UINT16_PIL_MODES = {"I;16", "I;16B", "I;16L"}
def pil_to_chw_tensor(img: PILImage.Image) -> torch.Tensor:
"""Convert a PIL image to a channel-first tensor.
``uint16`` depth maps become ``float32 (1, H, W)`` in native units (``ToTensor``
would overflow them to ``int16``); all other modes use the standard ``ToTensor`` path.
"""
if img.mode in UINT16_PIL_MODES:
return torch.from_numpy(np.array(img, dtype=np.float32))[None, ...]
return transforms.ToTensor()(img)
def hf_transform_to_torch(items_dict: dict[str, list[Any]]) -> dict[str, list[torch.Tensor | str]]: def hf_transform_to_torch(items_dict: dict[str, list[Any]]) -> dict[str, list[torch.Tensor | str]]:
"""Convert a batch from a Hugging Face dataset to torch tensors. """Convert a batch from a Hugging Face dataset to torch tensors.
This transform function converts items from Hugging Face dataset format (pyarrow) This transform function converts items from Hugging Face dataset format (pyarrow)
to torch tensors. Importantly, images are converted from PIL objects (H, W, C, uint8) to torch tensors. RGB images are converted from PIL objects (H, W, C, uint8)
to a torch image representation (C, H, W, float32) in the range [0, 1]. Other to a torch image representation (C, H, W, float32) in the range [0, 1]. Depth
maps are returned as float32 (1, H, W) in their native units. Other
types are converted to torch.tensor. types are converted to torch.tensor.
Args: Args:
@@ -262,8 +284,7 @@ def hf_transform_to_torch(items_dict: dict[str, list[Any]]) -> dict[str, list[to
continue continue
first_item = items_dict[key][0] first_item = items_dict[key][0]
if isinstance(first_item, PILImage.Image): if isinstance(first_item, PILImage.Image):
to_tensor = transforms.ToTensor() items_dict[key] = [pil_to_chw_tensor(img) for img in items_dict[key]]
items_dict[key] = [to_tensor(img) for img in items_dict[key]]
elif first_item is None or isinstance(first_item, dict): elif first_item is None or isinstance(first_item, dict):
pass pass
else: else:
@@ -329,7 +350,11 @@ def item_to_torch(item: dict) -> dict:
""" """
skip_keys = {"task", *LANGUAGE_COLUMNS} skip_keys = {"task", *LANGUAGE_COLUMNS}
for key, val in item.items(): for key, val in item.items():
if isinstance(val, (np.ndarray | list)) and key not in skip_keys: if key in skip_keys:
continue
if isinstance(val, PILImage.Image):
item[key] = pil_to_chw_tensor(val)
elif isinstance(val, (np.ndarray | list)):
# Convert numpy arrays and lists to torch tensors # Convert numpy arrays and lists to torch tensors
item[key] = torch.tensor(val) item[key] = torch.tensor(val)
return item return item
+58 -18
View File
@@ -24,7 +24,7 @@ import torch.utils
from huggingface_hub import HfApi, snapshot_download from huggingface_hub import HfApi, snapshot_download
from huggingface_hub.errors import RevisionNotFoundError from huggingface_hub.errors import RevisionNotFoundError
from lerobot.configs import VideoEncoderConfig from lerobot.configs import DEFAULT_DEPTH_UNIT, DepthEncoderConfig, RGBEncoderConfig
from lerobot.utils.constants import HF_LEROBOT_HUB_CACHE from lerobot.utils.constants import HF_LEROBOT_HUB_CACHE
from .dataset_metadata import CODEBASE_VERSION, LeRobotDatasetMetadata from .dataset_metadata import CODEBASE_VERSION, LeRobotDatasetMetadata
@@ -58,8 +58,10 @@ class LeRobotDataset(torch.utils.data.Dataset):
download_videos: bool = True, download_videos: bool = True,
video_backend: str | None = None, video_backend: str | None = None,
return_uint8: bool = False, return_uint8: bool = False,
depth_output_unit: str = DEFAULT_DEPTH_UNIT,
batch_encoding_size: int = 1, batch_encoding_size: int = 1,
camera_encoder: VideoEncoderConfig | None = None, rgb_encoder: RGBEncoderConfig | None = None,
depth_encoder: DepthEncoderConfig | None = None,
encoder_threads: int | None = None, encoder_threads: int | None = None,
streaming_encoding: bool = False, streaming_encoding: bool = False,
encoder_queue_maxsize: int = 30, encoder_queue_maxsize: int = 30,
@@ -183,8 +185,11 @@ class LeRobotDataset(torch.utils.data.Dataset):
You can also use the 'pyav' decoder used by Torchvision, which used to be the default option, or 'video_reader' which is another decoder of Torchvision. You can also use the 'pyav' decoder used by Torchvision, which used to be the default option, or 'video_reader' which is another decoder of Torchvision.
batch_encoding_size (int, optional): Number of episodes to accumulate before batch encoding videos. batch_encoding_size (int, optional): Number of episodes to accumulate before batch encoding videos.
Set to 1 for immediate encoding (default), or higher for batched encoding. Defaults to 1. Set to 1 for immediate encoding (default), or higher for batched encoding. Defaults to 1.
camera_encoder (VideoEncoderConfig | None, optional): Video encoder settings for cameras rgb_encoder (RGBEncoderConfig | None, optional): Video encoder settings for cameras
(codec, quality, etc.). When ``None``, :func:`~lerobot.configs.video.camera_encoder_defaults` (codec, quality, etc.). When ``None``, :func:`~lerobot.configs.video.rgb_encoder_defaults`
is used by the writer.
depth_encoder (DepthEncoderConfig | None, optional): Video encoder settings for depth cameras
(codec, quality, etc.). When ``None``, :func:`~lerobot.configs.video.depth_encoder_defaults`
is used by the writer. is used by the writer.
encoder_threads (int | None, optional): Number of encoder threads (global). ``None`` lets the encoder_threads (int | None, optional): Number of encoder threads (global). ``None`` lets the
codec decide. codec decide.
@@ -206,6 +211,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
self.revision = revision if revision else CODEBASE_VERSION self.revision = revision if revision else CODEBASE_VERSION
self._video_backend = video_backend if video_backend else get_safe_default_video_backend() self._video_backend = video_backend if video_backend else get_safe_default_video_backend()
self._return_uint8 = return_uint8 self._return_uint8 = return_uint8
self._depth_output_unit = depth_output_unit
self._batch_encoding_size = batch_encoding_size self._batch_encoding_size = batch_encoding_size
self._encoder_threads = encoder_threads self._encoder_threads = encoder_threads
@@ -218,6 +224,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
) )
self.root = self.meta.root self.root = self.meta.root
self.revision = self.meta.revision self.revision = self.meta.revision
self.meta.rescale_depth_stats(self._depth_output_unit)
if episodes is not None and any( if episodes is not None and any(
episode >= self.meta.total_episodes or episode < 0 for episode in episodes episode >= self.meta.total_episodes or episode < 0 for episode in episodes
@@ -246,6 +253,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
delta_timestamps=delta_timestamps, delta_timestamps=delta_timestamps,
image_transforms=image_transforms, image_transforms=image_transforms,
return_uint8=self._return_uint8, return_uint8=self._return_uint8,
depth_output_unit=self._depth_output_unit,
) )
self.image_transforms = image_transforms self.image_transforms = image_transforms
@@ -271,14 +279,16 @@ class LeRobotDataset(torch.utils.data.Dataset):
if streaming_encoding and len(self.meta.video_keys) > 0: if streaming_encoding and len(self.meta.video_keys) > 0:
streaming_enc = self._build_streaming_encoder( streaming_enc = self._build_streaming_encoder(
self.meta.fps, self.meta.fps,
camera_encoder, rgb_encoder,
depth_encoder,
encoder_queue_maxsize, encoder_queue_maxsize,
encoder_threads, encoder_threads,
) )
self.writer = DatasetWriter( self.writer = DatasetWriter(
meta=self.meta, meta=self.meta,
root=self.root, root=self.root,
camera_encoder=camera_encoder, rgb_encoder=rgb_encoder,
depth_encoder=depth_encoder,
encoder_threads=encoder_threads, encoder_threads=encoder_threads,
batch_encoding_size=batch_encoding_size, batch_encoding_size=batch_encoding_size,
streaming_encoder=streaming_enc, streaming_encoder=streaming_enc,
@@ -314,19 +324,22 @@ class LeRobotDataset(torch.utils.data.Dataset):
delta_timestamps=self.delta_timestamps, delta_timestamps=self.delta_timestamps,
image_transforms=self.image_transforms, image_transforms=self.image_transforms,
return_uint8=self._return_uint8, return_uint8=self._return_uint8,
depth_output_unit=self._depth_output_unit,
) )
return self.reader return self.reader
@staticmethod @staticmethod
def _build_streaming_encoder( def _build_streaming_encoder(
fps: int, fps: int,
camera_encoder: VideoEncoderConfig | None, rgb_encoder: RGBEncoderConfig | None,
depth_encoder: DepthEncoderConfig | None,
encoder_queue_maxsize: int, encoder_queue_maxsize: int,
encoder_threads: int | None, encoder_threads: int | None,
) -> StreamingVideoEncoder: ) -> StreamingVideoEncoder:
return StreamingVideoEncoder( return StreamingVideoEncoder(
fps=fps, fps=fps,
camera_encoder=camera_encoder, rgb_encoder=rgb_encoder,
depth_encoder=depth_encoder,
queue_maxsize=encoder_queue_maxsize, queue_maxsize=encoder_queue_maxsize,
encoder_threads=encoder_threads, encoder_threads=encoder_threads,
) )
@@ -338,6 +351,11 @@ class LeRobotDataset(torch.utils.data.Dataset):
"""Frames per second used during data collection.""" """Frames per second used during data collection."""
return self.meta.fps return self.meta.fps
@property
def depth_output_unit(self) -> str:
"""Physical unit (``"m"`` or ``"mm"``) depth maps and statistics are returned in on read."""
return self._depth_output_unit
@property @property
def num_frames(self) -> int: def num_frames(self) -> int:
"""Number of frames in selected episodes.""" """Number of frames in selected episodes."""
@@ -369,6 +387,18 @@ class LeRobotDataset(torch.utils.data.Dataset):
self.reader.load_and_activate() self.reader.load_and_activate()
return self.reader.hf_dataset return self.reader.hf_dataset
@property
def absolute_to_relative_idx(self) -> dict[int, int] | None:
"""Mapping from absolute frame indices to HF dataset row positions.
Non-None only for episode-filtered datasets where absolute indices
(from metadata) differ from row positions in the loaded HF dataset.
"""
reader = self._ensure_reader()
if reader.hf_dataset is None:
reader.load_and_activate()
return reader._absolute_to_relative_idx
# ── Writer-delegated methods ────────────────────────────────────── # ── Writer-delegated methods ──────────────────────────────────────
def add_frame(self, frame: dict) -> None: def add_frame(self, frame: dict) -> None:
@@ -643,7 +673,8 @@ class LeRobotDataset(torch.utils.data.Dataset):
image_writer_threads: int = 0, image_writer_threads: int = 0,
video_backend: str | None = None, video_backend: str | None = None,
batch_encoding_size: int = 1, batch_encoding_size: int = 1,
camera_encoder: VideoEncoderConfig | None = None, rgb_encoder: RGBEncoderConfig | None = None,
depth_encoder: DepthEncoderConfig | None = None,
metadata_buffer_size: int = 10, metadata_buffer_size: int = 10,
streaming_encoding: bool = False, streaming_encoding: bool = False,
encoder_queue_maxsize: int = 30, encoder_queue_maxsize: int = 30,
@@ -674,8 +705,10 @@ class LeRobotDataset(torch.utils.data.Dataset):
video_backend: Video decoding backend (used when reading back). video_backend: Video decoding backend (used when reading back).
batch_encoding_size: Number of episodes to accumulate before batch_encoding_size: Number of episodes to accumulate before
batch-encoding videos. ``1`` means encode immediately. batch-encoding videos. ``1`` means encode immediately.
camera_encoder: Video encoder settings for cameras (codec, quality, etc.). rgb_encoder: Video encoder settings for cameras (codec, quality, etc.).
When ``None``, :func:`~lerobot.configs.video.camera_encoder_defaults` is used. When ``None``, :func:`~lerobot.configs.video.rgb_encoder_defaults` is used.
depth_encoder: Video encoder settings for depth cameras (codec, quality, etc.).
When ``None``, :func:`~lerobot.configs.video.depth_encoder_defaults` is used.
encoder_threads: Number of encoder threads (global). ``None`` encoder_threads: Number of encoder threads (global). ``None``
lets the codec decide. lets the codec decide.
metadata_buffer_size: Number of episode metadata records to buffer metadata_buffer_size: Number of episode metadata records to buffer
@@ -710,6 +743,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
obj.episodes = None obj.episodes = None
obj._video_backend = video_backend if video_backend is not None else get_safe_default_video_backend() obj._video_backend = video_backend if video_backend is not None else get_safe_default_video_backend()
obj._return_uint8 = False obj._return_uint8 = False
obj._depth_output_unit = DEFAULT_DEPTH_UNIT
obj._batch_encoding_size = batch_encoding_size obj._batch_encoding_size = batch_encoding_size
obj._encoder_threads = encoder_threads obj._encoder_threads = encoder_threads
@@ -719,12 +753,13 @@ class LeRobotDataset(torch.utils.data.Dataset):
streaming_enc = None streaming_enc = None
if streaming_encoding and len(obj.meta.video_keys) > 0: if streaming_encoding and len(obj.meta.video_keys) > 0:
streaming_enc = cls._build_streaming_encoder( streaming_enc = cls._build_streaming_encoder(
fps, camera_encoder, encoder_queue_maxsize, encoder_threads fps, rgb_encoder, depth_encoder, encoder_queue_maxsize, encoder_threads
) )
obj.writer = DatasetWriter( obj.writer = DatasetWriter(
meta=obj.meta, meta=obj.meta,
root=obj.root, root=obj.root,
camera_encoder=camera_encoder, rgb_encoder=rgb_encoder,
depth_encoder=depth_encoder,
encoder_threads=encoder_threads, encoder_threads=encoder_threads,
batch_encoding_size=batch_encoding_size, batch_encoding_size=batch_encoding_size,
streaming_encoder=streaming_enc, streaming_encoder=streaming_enc,
@@ -747,7 +782,8 @@ class LeRobotDataset(torch.utils.data.Dataset):
force_cache_sync: bool = False, force_cache_sync: bool = False,
video_backend: str | None = None, video_backend: str | None = None,
batch_encoding_size: int = 1, batch_encoding_size: int = 1,
camera_encoder: VideoEncoderConfig | None = None, rgb_encoder: RGBEncoderConfig | None = None,
depth_encoder: DepthEncoderConfig | None = None,
encoder_threads: int | None = None, encoder_threads: int | None = None,
image_writer_processes: int = 0, image_writer_processes: int = 0,
image_writer_threads: int = 0, image_writer_threads: int = 0,
@@ -775,8 +811,10 @@ class LeRobotDataset(torch.utils.data.Dataset):
video_backend: Video decoding backend for reading back data. video_backend: Video decoding backend for reading back data.
batch_encoding_size: Number of episodes to accumulate before batch_encoding_size: Number of episodes to accumulate before
batch-encoding videos. batch-encoding videos.
camera_encoder: Video encoder settings for cameras (codec, quality, etc.). rgb_encoder: Video encoder settings for cameras (codec, quality, etc.).
When ``None``, :func:`~lerobot.configs.video.camera_encoder_defaults` is used. When ``None``, :func:`~lerobot.configs.video.rgb_encoder_defaults` is used.
depth_encoder: Video encoder settings for depth cameras (codec, quality, etc.).
When ``None``, :func:`~lerobot.configs.video.depth_encoder_defaults` is used.
encoder_threads: Number of encoder threads (global). ``None`` encoder_threads: Number of encoder threads (global). ``None``
lets the codec decide. lets the codec decide.
image_writer_processes: Subprocesses for async image writing. image_writer_processes: Subprocesses for async image writing.
@@ -804,6 +842,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
obj.episodes = None obj.episodes = None
obj._video_backend = video_backend if video_backend else get_safe_default_video_backend() obj._video_backend = video_backend if video_backend else get_safe_default_video_backend()
obj._return_uint8 = False obj._return_uint8 = False
obj._depth_output_unit = DEFAULT_DEPTH_UNIT
obj._batch_encoding_size = batch_encoding_size obj._batch_encoding_size = batch_encoding_size
if obj._requested_root is not None: if obj._requested_root is not None:
@@ -823,12 +862,13 @@ class LeRobotDataset(torch.utils.data.Dataset):
streaming_enc = None streaming_enc = None
if streaming_encoding and len(obj.meta.video_keys) > 0: if streaming_encoding and len(obj.meta.video_keys) > 0:
streaming_enc = cls._build_streaming_encoder( streaming_enc = cls._build_streaming_encoder(
obj.meta.fps, camera_encoder, encoder_queue_maxsize, encoder_threads obj.meta.fps, rgb_encoder, depth_encoder, encoder_queue_maxsize, encoder_threads
) )
obj.writer = DatasetWriter( obj.writer = DatasetWriter(
meta=obj.meta, meta=obj.meta,
root=obj.root, root=obj.root,
camera_encoder=camera_encoder, rgb_encoder=rgb_encoder,
depth_encoder=depth_encoder,
encoder_threads=encoder_threads, encoder_threads=encoder_threads,
batch_encoding_size=batch_encoding_size, batch_encoding_size=batch_encoding_size,
streaming_encoder=streaming_enc, streaming_encoder=streaming_enc,
+49 -2
View File
@@ -24,6 +24,7 @@ import logging
from typing import Any from typing import Any
import av import av
import numpy as np
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,6 +32,34 @@ FFMPEG_NUMERIC_OPTION_TYPES = ("INT", "INT64", "UINT64", "FLOAT", "DOUBLE")
FFMPEG_INTEGER_OPTION_TYPES = ("INT", "INT64", "UINT64") FFMPEG_INTEGER_OPTION_TYPES = ("INT", "INT64", "UINT64")
def write_u16_plane(plane: av.video.plane.VideoPlane, src: np.ndarray, fill_value: int | None = None) -> None:
"""Copy a 2D ``uint16`` image into the plane's memory buffer, row by row.
For speed, each row is padded to a wider size than ``width``, so the true row width in
memory is ``plane.line_size`` (bytes), not ``width``. Copying as one straight stream
would skew the image, so we write only the first ``width`` columns of each row and
leave the padding untouched.
Args:
plane: Destination 16-bit plane.
src: Source image, shape ``(height, width)``, dtype ``uint16``.
fill_value: If given, every pixel (padding included) is set to this first, so the
padding holds clean data instead of garbage.
"""
height, width = src.shape
stride_u16 = plane.line_size // np.dtype(np.uint16).itemsize
dst = np.frombuffer(plane, dtype=np.uint16).reshape(height, stride_u16)
if fill_value is not None:
dst.fill(fill_value)
dst[:, :width] = src
@functools.cache
def get_pix_fmt_channels(pix_fmt: str) -> int:
"""Return the number of components (channels) for *pix_fmt*."""
return len(av.VideoFormat(pix_fmt).components)
@functools.cache @functools.cache
def get_codec(vcodec: str) -> av.codec.Codec | None: def get_codec(vcodec: str) -> av.codec.Codec | None:
"""PyAV write-mode ``Codec`` for *vcodec*, or ``None`` if unavailable.""" """PyAV write-mode ``Codec`` for *vcodec*, or ``None`` if unavailable."""
@@ -92,7 +121,7 @@ def _check_option_value(vcodec: str, label: str, value: Any, opt: av.option.Opti
f"{label}={value!r} is not numeric; codec {vcodec!r} expects a number for this option." f"{label}={value!r} is not numeric; codec {vcodec!r} expects a number for this option."
) from e ) from e
elif isinstance(value, (float, int)): elif isinstance(value, (float, int)):
num_val = value num_val = float(value)
else: else:
raise ValueError( raise ValueError(
f"{label}={value!r} is not numeric; codec {vcodec!r} expects a number for this option." f"{label}={value!r} is not numeric; codec {vcodec!r} expects a number for this option."
@@ -142,6 +171,16 @@ def _check_pixel_format(vcodec: str, pix_fmt: str) -> None:
) )
def _check_pix_fmt_channels(pix_fmt: str, channels: int) -> None:
"""Ensure *pix_fmt* can carry at least *channels* components."""
pix_fmt_channels = get_pix_fmt_channels(pix_fmt)
if pix_fmt_channels < channels:
raise ValueError(
f"pix_fmt={pix_fmt!r} carries only {pix_fmt_channels} component(s) "
f"but the source data has {channels} channel(s)."
)
def _check_codec_options(vcodec: str, codec_options: dict[str, Any]) -> None: def _check_codec_options(vcodec: str, codec_options: dict[str, Any]) -> None:
"""Validate merged encoder options (typed) against the codec's published AVOptions.""" """Validate merged encoder options (typed) against the codec's published AVOptions."""
supported_options = _get_codec_options_by_name(vcodec) supported_options = _get_codec_options_by_name(vcodec)
@@ -156,12 +195,18 @@ def _check_codec_options(vcodec: str, codec_options: dict[str, Any]) -> None:
_check_option_value(vcodec, key, value, supported_options[key]) _check_option_value(vcodec, key, value, supported_options[key])
def check_video_encoder_parameters_pyav(vcodec: str, pix_fmt: str, codec_options: dict[str, Any]) -> None: def check_video_encoder_parameters_pyav(
vcodec: str,
pix_fmt: str,
codec_options: dict[str, Any],
channels: int | None = None,
) -> None:
"""Verify *config* is compatible with the bundled FFmpeg build. """Verify *config* is compatible with the bundled FFmpeg build.
Checks pixel format, abstract tuning-field compatibility, and each merged Checks pixel format, abstract tuning-field compatibility, and each merged
encoder option from :meth:`~lerobot.configs.video.VideoEncoderConfig.get_codec_options` encoder option from :meth:`~lerobot.configs.video.VideoEncoderConfig.get_codec_options`
against PyAV (including numeric ``extra_options`` present in that dict). against PyAV (including numeric ``extra_options`` present in that dict).
When given, additionally verify that *pix_fmt* carries as many components as the source data channels.
No-op when ``config.vcodec`` isn't in the local FFmpeg build. No-op when ``config.vcodec`` isn't in the local FFmpeg build.
Raises: Raises:
@@ -171,4 +216,6 @@ def check_video_encoder_parameters_pyav(vcodec: str, pix_fmt: str, codec_options
if not options: if not options:
raise ValueError(f"Codec {vcodec!r} is not available in the bundled FFmpeg build") raise ValueError(f"Codec {vcodec!r} is not available in the bundled FFmpeg build")
_check_pixel_format(vcodec, pix_fmt) _check_pixel_format(vcodec, pix_fmt)
if channels is not None:
_check_pix_fmt_channels(pix_fmt, channels)
_check_codec_options(vcodec, codec_options) _check_codec_options(vcodec, codec_options)
+6 -1
View File
@@ -53,6 +53,7 @@ class EpisodeAwareSampler:
drop_n_last_frames: int = 0, drop_n_last_frames: int = 0,
shuffle: bool = False, shuffle: bool = False,
seed: int = 0, seed: int = 0,
absolute_to_relative_idx: dict[int, int] | None = None,
): ):
""" """
Args: Args:
@@ -107,6 +108,7 @@ class EpisodeAwareSampler:
self.seed = seed self.seed = seed
self._epoch = 0 self._epoch = 0
self._start_index = 0 self._start_index = 0
self._absolute_to_relative = absolute_to_relative_idx
@property @property
def indices(self) -> list[int]: def indices(self) -> list[int]:
@@ -132,7 +134,10 @@ class EpisodeAwareSampler:
def _frame_index(self, position: int) -> int: def _frame_index(self, position: int) -> int:
episode = int(np.searchsorted(self._cum_lengths, position, side="right")) episode = int(np.searchsorted(self._cum_lengths, position, side="right"))
position_in_episode = position - (int(self._cum_lengths[episode - 1]) if episode > 0 else 0) position_in_episode = position - (int(self._cum_lengths[episode - 1]) if episode > 0 else 0)
return int(self._starts[episode]) + position_in_episode absolute_idx = int(self._starts[episode]) + position_in_episode
if self._absolute_to_relative is not None:
return self._absolute_to_relative[absolute_idx]
return absolute_idx
def __iter__(self) -> Iterator[int]: def __iter__(self) -> Iterator[int]:
# Advance epoch state eagerly, not on first consumption of the generator. # Advance epoch state eagerly, not on first consumption of the generator.
+62 -7
View File
@@ -22,9 +22,11 @@ import numpy as np
import torch import torch
from datasets import load_dataset from datasets import load_dataset
from lerobot.configs import DEFAULT_DEPTH_UNIT, DEPTH_METER_UNIT, DepthEncoderConfig
from lerobot.utils.constants import HF_LEROBOT_HOME, LOOKAHEAD_BACKTRACKTABLE, LOOKBACK_BACKTRACKTABLE from lerobot.utils.constants import HF_LEROBOT_HOME, LOOKAHEAD_BACKTRACKTABLE, LOOKBACK_BACKTRACKTABLE
from .dataset_metadata import CODEBASE_VERSION, LeRobotDatasetMetadata from .dataset_metadata import CODEBASE_VERSION, LeRobotDatasetMetadata
from .depth_utils import MM_PER_METRE, dequantize_depth
from .feature_utils import get_delta_indices from .feature_utils import get_delta_indices
from .io_utils import item_to_torch from .io_utils import item_to_torch
from .utils import ( from .utils import (
@@ -35,6 +37,7 @@ from .utils import (
) )
from .video_utils import ( from .video_utils import (
VideoDecoderCache, VideoDecoderCache,
decode_video_frames,
decode_video_frames_torchcodec, decode_video_frames_torchcodec,
) )
@@ -252,6 +255,7 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
rng: np.random.Generator | None = None, rng: np.random.Generator | None = None,
shuffle: bool = True, shuffle: bool = True,
return_uint8: bool = False, return_uint8: bool = False,
depth_output_unit: str = DEFAULT_DEPTH_UNIT,
): ):
"""Initialize a StreamingLeRobotDataset. """Initialize a StreamingLeRobotDataset.
@@ -272,6 +276,8 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
seed (int, optional): Reproducibility random seed. seed (int, optional): Reproducibility random seed.
rng (np.random.Generator | None, optional): Random number generator. rng (np.random.Generator | None, optional): Random number generator.
shuffle (bool, optional): Whether to shuffle the dataset across exhaustions. Defaults to True. shuffle (bool, optional): Whether to shuffle the dataset across exhaustions. Defaults to True.
depth_output_unit (str, optional): Physical unit depth maps are dequantized to ("m" or "mm").
Defaults to "mm".
""" """
super().__init__() super().__init__()
self.repo_id = repo_id self.repo_id = repo_id
@@ -290,6 +296,7 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
self.streaming = streaming self.streaming = streaming
self.buffer_size = buffer_size self.buffer_size = buffer_size
self._return_uint8 = return_uint8 self._return_uint8 = return_uint8
self._depth_output_unit = depth_output_unit
# We cache the video decoders to avoid re-initializing them at each frame (avoiding a ~10x slowdown) # We cache the video decoders to avoid re-initializing them at each frame (avoiding a ~10x slowdown)
self.video_decoder_cache = None self.video_decoder_cache = None
@@ -303,9 +310,22 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
) )
self.root = self.meta.root self.root = self.meta.root
self.revision = self.meta.revision self.revision = self.meta.revision
self.meta.rescale_depth_stats(self._depth_output_unit)
# Check version # Check version
check_version_compatibility(self.repo_id, self.meta._version, CODEBASE_VERSION) check_version_compatibility(self.repo_id, self.meta._version, CODEBASE_VERSION)
self._depth_encoder_configs: dict[str, DepthEncoderConfig] = {
vid_key: DepthEncoderConfig.from_video_info(self.meta.features[vid_key].get("info"))
for vid_key in self.meta.depth_keys
}
# Input unit of each depth feature stored as raw images (dequantized separately from videos).
self._image_depth_units: dict[str, str | None] = {
key: (self.meta.features[key].get("info") or {}).get("depth_unit")
for key in self.meta.depth_keys
if key in self.meta.image_keys
}
self.delta_timestamps = None self.delta_timestamps = None
self.delta_indices = None self.delta_indices = None
@@ -336,6 +356,11 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
def fps(self): def fps(self):
return self.meta.fps return self.meta.fps
@property
def depth_output_unit(self) -> str:
"""Physical unit (``"m"`` or ``"mm"``) depth maps are returned in on read."""
return self._depth_output_unit
@staticmethod @staticmethod
def _iter_random_indices( def _iter_random_indices(
rng: np.random.Generator, buffer_size: int, random_batch_size=100 rng: np.random.Generator, buffer_size: int, random_batch_size=100
@@ -518,6 +543,15 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
for update in updates: for update in updates:
result.update(update) result.update(update)
# Convert raw-image depth features to the output unit (video depth is already converted).
for key, stored_unit in self._image_depth_units.items():
if key in result and stored_unit is not None and stored_unit != self._depth_output_unit:
result[key] = (
result[key] * MM_PER_METRE
if stored_unit == DEPTH_METER_UNIT
else result[key] / MM_PER_METRE
)
result["task"] = self.meta.tasks.iloc[item["task_index"]].name result["task"] = self.meta.tasks.iloc[item["task_index"]].name
yield result yield result
@@ -554,13 +588,34 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
for video_key, query_ts in query_timestamps.items(): for video_key, query_ts in query_timestamps.items():
root = self.meta.url_root if self.streaming and not self.streaming_from_local else self.root root = self.meta.url_root if self.streaming and not self.streaming_from_local else self.root
video_path = f"{root}/{self.meta.get_video_file_path(ep_idx, video_key)}" video_path = f"{root}/{self.meta.get_video_file_path(ep_idx, video_key)}"
frames = decode_video_frames_torchcodec( if video_key in self.meta.depth_keys:
video_path, # Depth maps are 12-bit quantized and only decodable via pyav; dequantize back
query_ts, # to physical units to match the non-streaming reader.
self.tolerance_s, frames = decode_video_frames(
decoder_cache=self.video_decoder_cache, video_path,
return_uint8=self._return_uint8, query_ts,
) self.tolerance_s,
backend="pyav",
return_uint8=False,
is_depth=True,
)
depth_encoder = self._depth_encoder_configs[video_key]
frames = dequantize_depth(
frames,
depth_min=depth_encoder.depth_min,
depth_max=depth_encoder.depth_max,
shift=depth_encoder.shift,
use_log=depth_encoder.use_log,
output_unit=self._depth_output_unit,
)
else:
frames = decode_video_frames_torchcodec(
video_path,
query_ts,
self.tolerance_s,
decoder_cache=self.video_decoder_cache,
return_uint8=self._return_uint8,
)
item[video_key] = frames.squeeze(0) if len(query_ts) == 1 else frames item[video_key] = frames.squeeze(0) if len(query_ts) == 1 else frames
+4 -1
View File
@@ -87,11 +87,14 @@ DATA_DIR = "data"
VIDEO_DIR = "videos" VIDEO_DIR = "videos"
CHUNK_FILE_PATTERN = "chunk-{chunk_index:03d}/file-{file_index:03d}" CHUNK_FILE_PATTERN = "chunk-{chunk_index:03d}/file-{file_index:03d}"
IMAGE_FILE_PATTERN = "frame-{frame_index:06d}.png"
DEPTH_FILE_PATTERN = "frame-{frame_index:06d}.tiff"
DEFAULT_TASKS_PATH = "meta/tasks.parquet" DEFAULT_TASKS_PATH = "meta/tasks.parquet"
DEFAULT_EPISODES_PATH = EPISODES_DIR + "/" + CHUNK_FILE_PATTERN + ".parquet" DEFAULT_EPISODES_PATH = EPISODES_DIR + "/" + CHUNK_FILE_PATTERN + ".parquet"
DEFAULT_DATA_PATH = DATA_DIR + "/" + CHUNK_FILE_PATTERN + ".parquet" DEFAULT_DATA_PATH = DATA_DIR + "/" + CHUNK_FILE_PATTERN + ".parquet"
DEFAULT_VIDEO_PATH = VIDEO_DIR + "/{video_key}/" + CHUNK_FILE_PATTERN + ".mp4" DEFAULT_VIDEO_PATH = VIDEO_DIR + "/{video_key}/" + CHUNK_FILE_PATTERN + ".mp4"
DEFAULT_IMAGE_PATH = "images/{image_key}/episode-{episode_index:06d}/frame-{frame_index:06d}.png" DEFAULT_IMAGE_PATH = "images/{image_key}/episode-{episode_index:06d}/" + IMAGE_FILE_PATTERN
DEFAULT_DEPTH_PATH = "images/{image_key}/episode-{episode_index:06d}/" + DEPTH_FILE_PATTERN
LEGACY_EPISODES_PATH = "meta/episodes.jsonl" LEGACY_EPISODES_PATH = "meta/episodes.jsonl"
LEGACY_EPISODES_STATS_PATH = "meta/episodes_stats.jsonl" LEGACY_EPISODES_STATS_PATH = "meta/episodes_stats.jsonl"
+163 -76
View File
@@ -39,11 +39,17 @@ from datasets.features.features import register_feature
from PIL import Image from PIL import Image
from lerobot.configs import ( from lerobot.configs import (
DepthEncoderConfig,
RGBEncoderConfig,
VideoEncoderConfig, VideoEncoderConfig,
camera_encoder_defaults, depth_encoder_defaults,
rgb_encoder_defaults,
) )
from lerobot.utils.import_utils import get_safe_default_video_backend from lerobot.utils.import_utils import get_safe_default_video_backend
from .depth_utils import quantize_depth
from .pyav_utils import get_pix_fmt_channels
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -53,6 +59,7 @@ def decode_video_frames(
tolerance_s: float, tolerance_s: float,
backend: str | None = None, backend: str | None = None,
return_uint8: bool = False, return_uint8: bool = False,
is_depth: bool = False,
) -> torch.Tensor: ) -> torch.Tensor:
""" """
Decodes video frames using the specified backend. Decodes video frames using the specified backend.
@@ -64,23 +71,35 @@ def decode_video_frames(
backend (str, optional): Backend to use for decoding. Defaults to "torchcodec" when available backend (str, optional): Backend to use for decoding. Defaults to "torchcodec" when available
in the platform; otherwise, defaults to "pyav". The legacy value "video_reader" is in the platform; otherwise, defaults to "pyav". The legacy value "video_reader" is
accepted for one release as an alias for "pyav" and will be removed in a future version. accepted for one release as an alias for "pyav" and will be removed in a future version.
return_uint8 (bool): If True, return raw uint8 frames without float32 normalization. return_uint8 (bool): For RGB videos, if True return raw uint8 frames without float32 normalization.
This reduces memory for DataLoader IPC; normalization can be done on GPU afterward. This reduces memory for DataLoader IPC; normalization can be done on GPU afterward.
is_depth (bool): Set to True if the video is a depth map (1 channel, uint12).
Returns: Returns:
torch.Tensor: Decoded frames (float32 in [0,1] by default, or uint8 if return_uint8=True). torch.Tensor: Decoded frames (RGB: float32 in [0,1] by default, or uint8 if return_uint8=True, Depth: uint12).
Currently supports torchcodec on cpu and pyav. Currently supports torchcodec on cpu and pyav.
""" """
if backend != "pyav" and is_depth:
logger.debug("Decoding depth maps is only supported with the 'pyav' backend, falling back to pyav.")
# We do not actually return uint8 here, but we avoid the 255 normalization step.
return decode_video_frames_pyav(
video_path, timestamps, tolerance_s, return_uint8=False, is_depth=True
)
if backend is None: if backend is None:
backend = get_safe_default_video_backend() backend = get_safe_default_video_backend()
if backend == "torchcodec": if backend == "torchcodec":
return decode_video_frames_torchcodec(video_path, timestamps, tolerance_s, return_uint8=return_uint8) return decode_video_frames_torchcodec(video_path, timestamps, tolerance_s, return_uint8=return_uint8)
elif backend == "pyav": elif backend == "pyav":
return decode_video_frames_pyav(video_path, timestamps, tolerance_s, return_uint8=return_uint8) return decode_video_frames_pyav(
video_path, timestamps, tolerance_s, return_uint8=return_uint8, is_depth=is_depth
)
elif backend == "video_reader": elif backend == "video_reader":
logger.warning("backend='video_reader' is deprecated and now aliases to 'pyav'.") logger.warning("backend='video_reader' is deprecated and now aliases to 'pyav'.")
return decode_video_frames_pyav(video_path, timestamps, tolerance_s, return_uint8=return_uint8) return decode_video_frames_pyav(
video_path, timestamps, tolerance_s, return_uint8=return_uint8, is_depth=is_depth
)
else: else:
raise ValueError(f"Unsupported video backend: {backend}") raise ValueError(f"Unsupported video backend: {backend}")
@@ -91,6 +110,7 @@ def decode_video_frames_pyav(
tolerance_s: float, tolerance_s: float,
log_loaded_timestamps: bool = False, log_loaded_timestamps: bool = False,
return_uint8: bool = False, return_uint8: bool = False,
is_depth: bool = False,
) -> torch.Tensor: ) -> torch.Tensor:
"""Loads frames associated to the requested timestamps of a video using PyAV. """Loads frames associated to the requested timestamps of a video using PyAV.
@@ -109,8 +129,9 @@ def decode_video_frames_pyav(
tolerance_s: Allowed deviation in seconds between a queried timestamp and the closest tolerance_s: Allowed deviation in seconds between a queried timestamp and the closest
decoded frame. decoded frame.
log_loaded_timestamps: When True, log every decoded frame's timestamp at INFO level. log_loaded_timestamps: When True, log every decoded frame's timestamp at INFO level.
return_uint8: When True, return raw uint8 frames (C, H, W). Otherwise, return float32 in return_uint8: For RGB videos, if True return raw uint8 frames (C, H, W).
[0, 1] range. Otherwise, return float32 in [0, 1] range.
is_depth: Set to True if the video is a depth map (1 channel, uint12).
Returns: Returns:
torch.Tensor of shape (len(timestamps), C, H, W). torch.Tensor of shape (len(timestamps), C, H, W).
@@ -132,7 +153,13 @@ def decode_video_frames_pyav(
# https://pyav.basswood-io.com/docs/stable/api/container.html#av.container.InputContainer.seek # https://pyav.basswood-io.com/docs/stable/api/container.html#av.container.InputContainer.seek
with av.open(video_path) as container: with av.open(video_path) as container:
stream = container.streams.video[0] stream = container.streams.video[0]
container.seek(int(first_ts * av.time_base), backward=True) # Seek to the nearest keyframe at or before `first_ts` with a 1 frame margin
container.seek(
round(first_ts / stream.time_base) - 1,
backward=True,
any_frame=False,
stream=stream,
)
for frame in container.decode(stream): for frame in container.decode(stream):
if frame.pts is None: if frame.pts is None:
@@ -140,9 +167,13 @@ def decode_video_frames_pyav(
current_ts = float(frame.pts * stream.time_base) current_ts = float(frame.pts * stream.time_base)
if log_loaded_timestamps: if log_loaded_timestamps:
logger.info(f"frame loaded at timestamp={current_ts:.4f}") logger.info(f"frame loaded at timestamp={current_ts:.4f}")
# Convert to CHW uint8 to match torchcodec's output layout. if is_depth:
arr = frame.to_ndarray(format="rgb24") # H, W, 3 arr = frame.to_ndarray(format="gray12le") # (H, W) uint12
loaded_frames.append(torch.from_numpy(arr).permute(2, 0, 1).contiguous()) loaded_frames.append(torch.from_numpy(arr).unsqueeze(0).contiguous())
else:
arr = frame.to_ndarray(format="rgb24") # (H, W, 3)
# Convert to CHW uint8 to match torchcodec's output layout.
loaded_frames.append(torch.from_numpy(arr).permute(2, 0, 1).contiguous())
loaded_ts.append(current_ts) loaded_ts.append(current_ts)
if current_ts >= last_ts: if current_ts >= last_ts:
break break
@@ -185,7 +216,7 @@ def decode_video_frames_pyav(
f"number of queried timestamps ({len(timestamps)})" f"number of queried timestamps ({len(timestamps)})"
) )
if return_uint8: if return_uint8 or is_depth:
return closest_frames return closest_frames
# convert to the pytorch format which is float32 in [0,1] range (and channel first) # convert to the pytorch format which is float32 in [0,1] range (and channel first)
@@ -406,17 +437,38 @@ def encode_video_frames(
imgs_dir: Path | str, imgs_dir: Path | str,
video_path: Path | str, video_path: Path | str,
fps: int, fps: int,
camera_encoder: VideoEncoderConfig | None = None, video_encoder: VideoEncoderConfig | None = None,
encoder_threads: int | None = None, encoder_threads: int | None = None,
*, *,
log_level: int | None = av.logging.WARNING, log_level: int | None = av.logging.WARNING,
overwrite: bool = False, overwrite: bool = False,
) -> None: ) -> None:
"""More info on ffmpeg arguments tuning on `benchmark/video/README.md`""" """Encode a directory of image frames into an MP4 video.
if camera_encoder is None:
camera_encoder = camera_encoder_defaults() When ``video_encoder`` is a :class:`~lerobot.configs.video.DepthEncoderConfig`,
vcodec = camera_encoder.vcodec frames are read from ``.tiff`` files and quantized to 12-bit depth codes using the
pix_fmt = camera_encoder.pix_fmt encoder's ``depth_min`` / ``depth_max`` / ``shift`` / ``use_log``; otherwise ``.png``
RGB frames are encoded directly.
Args:
imgs_dir: Directory containing the frames to encode, named ``frame-000000``
onwards (``.png`` for RGB, ``.tiff`` for depth).
video_path: Output path for the encoded ``.mp4`` file.
fps: Frame rate of the output video.
video_encoder: Encoder settings (codec, pixel format, quality, ...). When
``None``, :func:`rgb_encoder_defaults` is used. Pass a
:class:`~lerobot.configs.video.DepthEncoderConfig` to encode depth frames.
encoder_threads: Per-encoder thread count forwarded to the codec. ``None``
lets the codec decide.
log_level: libav log level to set while encoding, or ``None`` to leave the
current logging configuration unchanged.
overwrite: When ``False`` and ``video_path`` already exists, skip encoding and
log a warning. When ``True``, re-encode and replace the existing file.
"""
if video_encoder is None:
video_encoder = rgb_encoder_defaults()
vcodec = video_encoder.vcodec
pix_fmt = video_encoder.pix_fmt
video_path = Path(video_path) video_path = Path(video_path)
imgs_dir = Path(imgs_dir) imgs_dir = Path(imgs_dir)
@@ -428,17 +480,19 @@ def encode_video_frames(
video_path.parent.mkdir(parents=True, exist_ok=True) video_path.parent.mkdir(parents=True, exist_ok=True)
# Get input frames # Get input frames
template = "frame-" + ("[0-9]" * 6) + ".png" is_depth = isinstance(video_encoder, DepthEncoderConfig)
suffix = ".png" if not is_depth else ".tiff"
template = "frame-" + ("[0-9]" * 6) + suffix
input_list = sorted( input_list = sorted(
glob.glob(str(imgs_dir / template)), key=lambda x: int(x.split("-")[-1].split(".")[0]) glob.glob(str(imgs_dir / template)), key=lambda x: int(x.split("-")[-1].split(".")[0])
) )
if len(input_list) == 0: if len(input_list) == 0:
raise FileNotFoundError(f"No images found in {imgs_dir}.") raise FileNotFoundError(f"No images with suffix {suffix} found in {imgs_dir}.")
with Image.open(input_list[0]) as dummy_image: with Image.open(input_list[0]) as dummy_image:
width, height = dummy_image.size width, height = dummy_image.size
video_options = camera_encoder.get_codec_options(encoder_threads, as_strings=True) video_options = video_encoder.get_codec_options(encoder_threads, as_strings=True)
# Set logging level # Set logging level
if log_level is not None: if log_level is not None:
@@ -455,8 +509,19 @@ def encode_video_frames(
# Loop through input frames and encode them # Loop through input frames and encode them
for input_data in input_list: for input_data in input_list:
with Image.open(input_data) as input_image: with Image.open(input_data) as input_image:
input_image = input_image.convert("RGB") if is_depth:
input_frame = av.VideoFrame.from_image(input_image) input_frame = quantize_depth(
np.array(input_image),
depth_min=video_encoder.depth_min,
depth_max=video_encoder.depth_max,
shift=video_encoder.shift,
use_log=video_encoder.use_log,
pix_fmt=video_encoder.pix_fmt,
video_backend="pyav",
)
else:
input_image = input_image.convert("RGB")
input_frame = av.VideoFrame.from_image(input_image)
packet = output_stream.encode(input_frame) packet = output_stream.encode(input_frame)
if packet: if packet:
output.mux(packet) output.mux(packet)
@@ -477,7 +542,7 @@ def encode_video_frames(
def reencode_video( def reencode_video(
input_video_path: Path | str, input_video_path: Path | str,
output_video_path: Path | str, output_video_path: Path | str,
camera_encoder: VideoEncoderConfig | None = None, video_encoder: VideoEncoderConfig | None = None,
encoder_threads: int | None = None, encoder_threads: int | None = None,
log_level: int | None = av.logging.WARNING, log_level: int | None = av.logging.WARNING,
overwrite: bool = False, overwrite: bool = False,
@@ -489,7 +554,7 @@ def reencode_video(
Args: Args:
input_video_path: Existing video file to read. input_video_path: Existing video file to read.
output_video_path: Path for the re-encoded file. output_video_path: Path for the re-encoded file.
camera_encoder: Encoder configuration. Defaults to :func:`camera_encoder_defaults`. video_encoder: Encoder configuration. Defaults to :func:`rgb_encoder_defaults`.
encoder_threads: Optional thread count forwarded to :meth:`VideoEncoderConfig.get_codec_options`. encoder_threads: Optional thread count forwarded to :meth:`VideoEncoderConfig.get_codec_options`.
log_level: libav log level while encoding, or ``None`` to leave logging unchanged. Defaults to WARNING. log_level: libav log level while encoding, or ``None`` to leave logging unchanged. Defaults to WARNING.
overwrite: When ``False`` and ``output_video_path`` already exists, skip and log a warning. overwrite: When ``False`` and ``output_video_path`` already exists, skip and log a warning.
@@ -497,7 +562,7 @@ def reencode_video(
end_time_s: When set, trim the output to end at this timestamp (seconds, exclusive). end_time_s: When set, trim the output to end at this timestamp (seconds, exclusive).
""" """
camera_encoder = camera_encoder or camera_encoder_defaults() video_encoder = video_encoder or rgb_encoder_defaults()
if (start_time_s is not None and start_time_s < 0) or (end_time_s is not None and end_time_s < 0): if (start_time_s is not None and start_time_s < 0) or (end_time_s is not None and end_time_s < 0):
raise ValueError(f"Trim times must be non-negative, got start={start_time_s}, end={end_time_s}.") raise ValueError(f"Trim times must be non-negative, got start={start_time_s}, end={end_time_s}.")
@@ -512,9 +577,9 @@ def reencode_video(
output_video_path.parent.mkdir(parents=True, exist_ok=True) output_video_path.parent.mkdir(parents=True, exist_ok=True)
video_options = camera_encoder.get_codec_options(encoder_threads, as_strings=True) video_options = video_encoder.get_codec_options(encoder_threads, as_strings=True)
vcodec = camera_encoder.vcodec vcodec = video_encoder.vcodec
pix_fmt = camera_encoder.pix_fmt pix_fmt = video_encoder.pix_fmt
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp_named_file: with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp_named_file:
tmp_output_video_path = tmp_named_file.name tmp_output_video_path = tmp_named_file.name
@@ -696,22 +761,21 @@ class _CameraEncoderThread(threading.Thread):
self, self,
video_path: Path, video_path: Path,
fps: int, fps: int,
vcodec: str, video_encoder: VideoEncoderConfig,
pix_fmt: str,
codec_options: dict[str, str],
frame_queue: queue.Queue, frame_queue: queue.Queue,
result_queue: queue.Queue, result_queue: queue.Queue,
stop_event: threading.Event, stop_event: threading.Event,
encoder_threads: int | None = None,
): ):
super().__init__(daemon=True) super().__init__(daemon=True)
self.video_path = video_path self.video_path = video_path
self.fps = fps self.fps = fps
self.vcodec = vcodec self.video_encoder = video_encoder
self.pix_fmt = pix_fmt self.is_depth = isinstance(video_encoder, DepthEncoderConfig)
self.codec_options = codec_options
self.frame_queue = frame_queue self.frame_queue = frame_queue
self.result_queue = result_queue self.result_queue = result_queue
self.stop_event = stop_event self.stop_event = stop_event
self.encoder_threads = encoder_threads
def run(self) -> None: def run(self) -> None:
from .compute_stats import RunningQuantileStats, auto_downsample_height_width from .compute_stats import RunningQuantileStats, auto_downsample_height_width
@@ -736,12 +800,12 @@ class _CameraEncoderThread(threading.Thread):
# Sentinel: flush and close # Sentinel: flush and close
break break
# Ensure HWC uint8 numpy array # Ensure HWC (RGB or depth) uint8 (RGB only) numpy array
if isinstance(frame_data, np.ndarray): if isinstance(frame_data, np.ndarray):
if frame_data.ndim == 3 and frame_data.shape[0] == 3: if frame_data.ndim == 3 and frame_data.shape[0] in (1, 3):
# CHW -> HWC # CHW -> HWC
frame_data = frame_data.transpose(1, 2, 0) frame_data = frame_data.transpose(1, 2, 0)
if frame_data.dtype != np.uint8: if not self.is_depth and frame_data.dtype != np.uint8:
frame_data = (frame_data * 255).astype(np.uint8) frame_data = (frame_data * 255).astype(np.uint8)
# Open container on first frame (to get width/height) # Open container on first frame (to get width/height)
@@ -749,15 +813,29 @@ class _CameraEncoderThread(threading.Thread):
height, width = frame_data.shape[:2] height, width = frame_data.shape[:2]
Path(self.video_path).parent.mkdir(parents=True, exist_ok=True) Path(self.video_path).parent.mkdir(parents=True, exist_ok=True)
container = av.open(str(self.video_path), "w") container = av.open(str(self.video_path), "w")
output_stream = container.add_stream(self.vcodec, self.fps, options=self.codec_options) output_stream = container.add_stream(
output_stream.pix_fmt = self.pix_fmt self.video_encoder.vcodec,
self.fps,
options=self.video_encoder.get_codec_options(self.encoder_threads, as_strings=True),
)
output_stream.pix_fmt = self.video_encoder.pix_fmt
output_stream.width = width output_stream.width = width
output_stream.height = height output_stream.height = height
output_stream.time_base = Fraction(1, self.fps) output_stream.time_base = Fraction(1, self.fps)
# Encode frame with explicit timestamps # Encode frame with explicit timestamps
pil_img = Image.fromarray(frame_data) if not self.is_depth:
video_frame = av.VideoFrame.from_image(pil_img) pil_img = Image.fromarray(frame_data)
video_frame = av.VideoFrame.from_image(pil_img)
else:
video_frame = quantize_depth(
frame_data,
depth_min=self.video_encoder.depth_min,
depth_max=self.video_encoder.depth_max,
shift=self.video_encoder.shift,
use_log=self.video_encoder.use_log,
video_backend=self.video_encoder.video_backend,
)
video_frame.pts = frame_count video_frame.pts = frame_count
video_frame.time_base = Fraction(1, self.fps) video_frame.time_base = Fraction(1, self.fps)
packet = output_stream.encode(video_frame) packet = output_stream.encode(video_frame)
@@ -815,22 +893,27 @@ class StreamingVideoEncoder:
def __init__( def __init__(
self, self,
fps: int, fps: int,
camera_encoder: VideoEncoderConfig | None = None, rgb_encoder: RGBEncoderConfig | None = None,
depth_encoder: DepthEncoderConfig | None = None,
queue_maxsize: int = 30, queue_maxsize: int = 30,
encoder_threads: int | None = None, encoder_threads: int | None = None,
): ):
""" """
Args: Args:
fps: Frames per second for the output videos. fps: Frames per second for the output videos.
camera_encoder: Video encoder settings applied to all cameras. rgb_encoder: Video encoder settings applied to all RGB cameras.
When ``None``, :func:`camera_encoder_defaults` is used. When ``None``, :func:`rgb_encoder_defaults` is used.
encoder_threads: Number of encoder threads (global setting). depth_encoder: Video encoder settings applied to all depth cameras,
``None`` lets the codec decide. including the depth quantization parameters. When ``None``,
:func:`depth_encoder_defaults` is used.
queue_maxsize: Max frames to buffer per camera before queue_maxsize: Max frames to buffer per camera before
back-pressure drops frames. back-pressure drops frames.
encoder_threads: Number of encoder threads (global setting).
``None`` lets the codec decide.
""" """
self.fps = fps self.fps = fps
self._camera_encoder = camera_encoder or camera_encoder_defaults() self._rgb_encoder = rgb_encoder or rgb_encoder_defaults()
self._depth_encoder = depth_encoder or depth_encoder_defaults()
self._encoder_threads = encoder_threads self._encoder_threads = encoder_threads
self.queue_maxsize = queue_maxsize self.queue_maxsize = queue_maxsize
@@ -843,18 +926,25 @@ class StreamingVideoEncoder:
self._episode_active = False self._episode_active = False
self._closed = False self._closed = False
def start_episode(self, video_keys: list[str], temp_dir: Path) -> None: def start_episode(
self, video_keys: list[str], temp_dir: Path, depth_video_keys: list[str] | None = None
) -> None:
"""Start encoder threads for a new episode. """Start encoder threads for a new episode.
Args: Args:
video_keys: List of video feature keys (e.g. ["observation.images.laptop"]) video_keys: List of video feature keys (e.g. ["observation.images.laptop"])
temp_dir: Base directory for temporary MP4 files temp_dir: Base directory for temporary MP4 files
depth_video_keys: List of video or image feature keys that carry depth maps (e.g.
["observation.images.laptop_depth"]). Defaults to ``[]`` (no depth keys).
""" """
if self._episode_active: if self._episode_active:
self.cancel_episode() self.cancel_episode()
self._dropped_frames.clear() self._dropped_frames.clear()
if depth_video_keys is None:
depth_video_keys = []
for video_key in video_keys: for video_key in video_keys:
frame_queue: queue.Queue = queue.Queue(maxsize=self.queue_maxsize) frame_queue: queue.Queue = queue.Queue(maxsize=self.queue_maxsize)
result_queue: queue.Queue = queue.Queue(maxsize=1) result_queue: queue.Queue = queue.Queue(maxsize=1)
@@ -863,17 +953,15 @@ class StreamingVideoEncoder:
temp_video_dir = Path(tempfile.mkdtemp(dir=temp_dir)) temp_video_dir = Path(tempfile.mkdtemp(dir=temp_dir))
video_path = temp_video_dir / f"{video_key.replace('/', '_')}_streaming.mp4" video_path = temp_video_dir / f"{video_key.replace('/', '_')}_streaming.mp4"
vcodec = self._camera_encoder.vcodec encoder = self._depth_encoder if video_key in depth_video_keys else self._rgb_encoder
codec_options = self._camera_encoder.get_codec_options(self._encoder_threads, as_strings=True)
encoder_thread = _CameraEncoderThread( encoder_thread = _CameraEncoderThread(
video_path=video_path, video_path=video_path,
fps=self.fps, fps=self.fps,
vcodec=vcodec, video_encoder=encoder,
pix_fmt=self._camera_encoder.pix_fmt,
codec_options=codec_options,
frame_queue=frame_queue, frame_queue=frame_queue,
result_queue=result_queue, result_queue=result_queue,
stop_event=stop_event, stop_event=stop_event,
encoder_threads=self._encoder_threads,
) )
encoder_thread.start() encoder_thread.start()
@@ -1080,15 +1168,23 @@ def get_audio_info(video_path: Path | str) -> dict:
def get_video_info( def get_video_info(
video_path: Path | str, video_path: Path | str,
camera_encoder: VideoEncoderConfig | None = None, video_encoder: VideoEncoderConfig | None = None,
) -> dict: ) -> dict:
"""Build the ``video.*`` / ``audio.*`` info dict persisted in ``info.json``. """Build the ``video.*`` / ``audio.*`` info dict persisted in ``info.json``.
Args: Args:
video_path: Path to the encoded video file to probe. video_path: Path to the encoded video file to probe.
camera_encoder: If provided, record the exact encoder settings used to encode this video_encoder: If provided, record the exact encoder settings used to encode this
video. Stream-derived values take precedence encoder fields are only written for keys video. Stream-derived values take precedence encoder fields are only written for keys
not already populated from the video file itself. not already populated from the video file itself. When a
:class:`~lerobot.configs.video.DepthEncoderConfig` is passed, the depth
quantization parameters (``depth_min`` / ``depth_max`` / ``shift`` /
``use_log``) are recorded so frames can be dequantized on read.
Returns:
The ``video.*`` / ``audio.*`` info dict, including ``is_depth_map`` which is
``True`` only when ``video_encoder`` is a
:class:`~lerobot.configs.video.DepthEncoderConfig`.
""" """
logging.getLogger("libav").setLevel(av.logging.WARNING) logging.getLogger("libav").setLevel(av.logging.WARNING)
@@ -1106,13 +1202,10 @@ def get_video_info(
video_info["video.width"] = video_stream.width video_info["video.width"] = video_stream.width
video_info["video.codec"] = video_stream.codec.canonical_name video_info["video.codec"] = video_stream.codec.canonical_name
video_info["video.pix_fmt"] = video_stream.pix_fmt video_info["video.pix_fmt"] = video_stream.pix_fmt
video_info["video.is_depth_map"] = False
# Calculate fps from r_frame_rate # Calculate fps from r_frame_rate
video_info["video.fps"] = int(video_stream.base_rate) video_info["video.fps"] = int(video_stream.base_rate)
video_info["video.channels"] = get_pix_fmt_channels(video_stream.pix_fmt)
pixel_channels = get_video_pixel_channels(video_stream.pix_fmt)
video_info["video.channels"] = pixel_channels
# Reset logging level # Reset logging level
av.logging.restore_default_callback() av.logging.restore_default_callback()
@@ -1121,27 +1214,18 @@ def get_video_info(
video_info.update(**get_audio_info(video_path)) video_info.update(**get_audio_info(video_path))
# Add additional encoder configuration if provided # Add additional encoder configuration if provided
if camera_encoder is not None: if video_encoder is not None:
for field_name, field_value in asdict(camera_encoder).items(): for field_name, field_value in asdict(video_encoder).items():
# vcodec is already populated from the video stream # vcodec is already populated from the video stream
if field_name == "vcodec": if field_name == "vcodec":
continue continue
video_info.setdefault(f"video.{field_name}", field_value) video_info.setdefault(f"video.{field_name}", field_value)
video_info["is_depth_map"] = isinstance(video_encoder, DepthEncoderConfig)
return video_info return video_info
def get_video_pixel_channels(pix_fmt: str) -> int:
if "gray" in pix_fmt or "depth" in pix_fmt or "monochrome" in pix_fmt:
return 1
elif "rgba" in pix_fmt or "yuva" in pix_fmt:
return 4
elif "rgb" in pix_fmt or "yuv" in pix_fmt:
return 3
else:
raise ValueError("Unknown format")
def get_video_duration_in_s(video_path: Path | str) -> float: def get_video_duration_in_s(video_path: Path | str) -> float:
""" """
Get the duration of a video file in seconds using PyAV. Get the duration of a video file in seconds using PyAV.
@@ -1202,10 +1286,13 @@ class VideoEncodingManager:
img_dir = self.dataset.root / "images" img_dir = self.dataset.root / "images"
if img_dir.exists(): if img_dir.exists():
png_files = list(img_dir.rglob("*.png")) png_files = list(img_dir.rglob("*.png"))
if len(png_files) == 0: tiff_files = list(img_dir.rglob("*.tiff"))
if len(png_files) == 0 and len(tiff_files) == 0:
shutil.rmtree(img_dir) shutil.rmtree(img_dir)
logger.debug("Cleaned up empty images directory") logger.debug("Cleaned up empty images directory")
else: else:
logger.debug(f"Images directory is not empty, containing {len(png_files)} PNG files") logger.debug(
f"Images directory is not empty, containing {len(png_files)} PNG and {len(tiff_files)} TIFF files"
)
return False # Don't suppress the original exception return False # Don't suppress the original exception
+23
View File
@@ -0,0 +1,23 @@
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from lerobot.utils.import_utils import require_package
# LeRobotDataset (imported at module top in dataset.py) pulls in heavy dataset deps;
# guard the optional dependency here so importing this package fails loudly if it's missing.
require_package("datasets", extra="dataset")
from .hf import submit_to_hf
__all__ = ["submit_to_hf"]
+53
View File
@@ -0,0 +1,53 @@
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Make a training dataset reachable from an HF Job pod.
The pod can't see the host's ~/.cache/huggingface/lerobot, so the dataset has to
live on the Hub: the pod downloads it by repo_id at train time (the forwarded
HF_TOKEN covers private datasets). A dataset already on the Hub is used as-is; a
local-only dataset is pushed to a PRIVATE repo first (never public).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from lerobot.datasets import LeRobotDataset
from lerobot.utils.constants import HF_LEROBOT_HOME
if TYPE_CHECKING:
from huggingface_hub import HfApi
def ensure_dataset_available(repo_id: str, *, api: HfApi, tags: list[str] | None = None) -> None:
"""Ensure repo_id resolves on the Hub, pushing a local-only dataset privately first.
`tags` are attached to the dataset only when we push it (an already-on-Hub
dataset is left untouched). Raises RuntimeError if the dataset is neither on
the Hub nor in the local cache.
"""
if api.repo_exists(repo_id, repo_type="dataset"):
return
local_present = (HF_LEROBOT_HOME / repo_id / "meta" / "info.json").is_file()
if not local_present:
raise RuntimeError(
f"Dataset '{repo_id}' is not in the local cache ({HF_LEROBOT_HOME}) and could not be "
f"reached on the Hub — it may not exist, or be private and inaccessible with your "
f"token. Record or download it first, or run `hf auth login`."
)
print(f"[dataset] '{repo_id}' is local-only; pushing to a PRIVATE Hub repo...")
LeRobotDataset(repo_id).push_to_hub(private=True, tags=tags)
print(f"[dataset] '{repo_id}' uploaded (private). The job will download it by repo_id.")
+425
View File
@@ -0,0 +1,425 @@
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Run a lerobot training on HF Jobs (HuggingFace GPUs).
Ported and simplified from lelab's runners/hf_cloud.py: no UI log queue, no
registry just submit and stream to stdout.
"""
from __future__ import annotations
import copy
import datetime as dt
import json
import netrc
import os
import re
import signal
import sys
import tempfile
import threading
from pathlib import Path
from typing import TYPE_CHECKING
import httpx
from huggingface_hub import (
HfApi,
create_repo,
fetch_job_logs,
get_token,
inspect_job,
run_job,
upload_file,
)
from lerobot.common.train_utils import push_checkpoint_to_hub
from lerobot.configs import parser
from .dataset import ensure_dataset_available
if TYPE_CHECKING:
from lerobot.configs.train import TrainPipelineConfig
_SLUG_RE = re.compile(r"[^a-zA-Z0-9._-]+")
_TERMINAL_STAGES = {"COMPLETED", "CANCELED", "ERROR", "DELETED"}
# huggingface_hub 1.x runs on httpx: transient HTTP/transport failures surface as
# httpx.HTTPError and socket-level errors as OSError. Catching only these keeps real
# bugs (TypeError, AttributeError, ...) from being silently retried or counted as
# job failures.
_TRANSIENT_NET_ERRORS = (OSError, httpx.HTTPError)
# Always attached to remote jobs and pushed datasets so LeRobot-originated work
# is identifiable on the Hub; callers (e.g. LeLab) add their own via --job.tags.
LEROBOT_TAG = "lerobot"
def resolve_job_tags(extra: list[str] | None) -> list[str]:
"""Return the tag list for a run: the lerobot tag plus any extras, deduped, order-stable."""
tags = [LEROBOT_TAG, *(extra or [])]
seen: set[str] = set()
return [t for t in tags if not (t in seen or seen.add(t))]
def resolve_wandb_api_key() -> str | None:
"""Host's wandb key for forwarding to the job: $WANDB_API_KEY, else ~/.netrc."""
key = os.environ.get("WANDB_API_KEY")
if key:
return key
try:
rc = netrc.netrc()
except (FileNotFoundError, netrc.NetrcParseError, OSError):
return None
auth = rc.authenticators("api.wandb.ai")
if auth is None:
return None
_login, _account, password = auth
return password or None
def build_repo_id(username: str, job_name: str, now: dt.datetime) -> str:
"""Generate the model repo id for a remote run: <user>/<job_name>_<timestamp>."""
slug = _SLUG_RE.sub("-", job_name).strip("-") or "train"
stamp = now.strftime("%Y-%m-%d_%H-%M-%S")
return f"{username}/{slug}_{stamp}"
def build_remote_config_file(cfg, repo_id: str, dest: Path, tags: list[str] | None = None) -> Path:
"""Write a train_config.json for the pod, with remote overrides applied.
The pod runs `lerobot-train --config_path=<dest>` and downloads the dataset
by repo_id into its own cache. Client-only fields are stripped so the config
is accepted by the trainer image: `job` (pure client orchestration) is always
removed, and `save_checkpoint_to_hub` is removed unless explicitly enabled
older lerobot images reject unknown keys, so the default keeps the config
compatible with the released `lerobot-gpu` image. `tags` are merged into
policy.tags so the trained model the pod pushes carries them too.
"""
remote = copy.deepcopy(cfg)
remote.policy.push_to_hub = True
remote.policy.repo_id = repo_id
# Don't pin the client's resolved device (e.g. "mps"); let the pod auto-detect its GPU.
remote.policy.device = None
# Drop any host-local dataset root; the pod resolves the dataset by repo_id.
remote.dataset.root = None
if tags:
existing = list(remote.policy.tags or [])
remote.policy.tags = existing + [t for t in tags if t not in existing]
# Encode to the canonical, pod-parseable dict, then drop the keys the released
# trainer image doesn't know about.
data = remote.to_dict()
data.pop("job", None)
if not remote.save_checkpoint_to_hub:
data.pop("save_checkpoint_to_hub", None)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(json.dumps(data, indent=4))
return dest
def _stage_config_on_hub(cfg, repo_id: str, token: str, tags: list[str] | None = None) -> str:
"""Upload train_config.json to the model repo and return the repo_id for --config_path."""
create_repo(repo_id, repo_type="model", private=True, exist_ok=True, token=token)
with tempfile.TemporaryDirectory() as tmp:
config_path = build_remote_config_file(cfg, repo_id, Path(tmp) / "train_config.json", tags=tags)
upload_file(
path_or_fileobj=config_path,
path_in_repo="train_config.json",
repo_id=repo_id,
repo_type="model",
token=token,
)
return repo_id
def _tail_logs(
job_id: str,
done: threading.Event,
success_marker: str | None = None,
success_event: threading.Event | None = None,
) -> None:
"""Stream job logs to stdout, reconnecting on dropped streams until done is set.
Each reconnect re-fetches the full buffered log, so we track how many lines
were already printed and skip them otherwise a fast-failing job's traceback
gets reprinted on every reconnect.
When `success_marker` appears in a line, set `success_event` and `done` so the
caller can finish as soon as the trained model lands on the Hub, rather than
waiting out the platform's post-run finalization (which can add ~30s).
"""
printed = 0
while not done.is_set():
try:
seen = 0
for line in fetch_job_logs(job_id=job_id, follow=True):
seen += 1
if seen <= printed:
continue # already shown on a previous connection
printed = seen
# fetch_job_logs yields SSE data without trailing newlines, so add one
# per entry — otherwise all log lines concatenate onto a single line.
print(line.rstrip("\n"), flush=True)
if success_marker and success_event is not None and success_marker in line:
success_event.set()
done.set()
return
if done.is_set():
return
# Stream closed cleanly. Wait a moment so the status poller can mark
# the job terminal before we reconnect (avoids re-tailing the buffer).
if done.wait(3):
return
except _TRANSIENT_NET_ERRORS:
if done.wait(2):
return
def _poll_until_done(
job_id: str,
done: threading.Event,
poll_interval: float = 5.0,
status_holder: dict | None = None,
max_failures: int = 6,
) -> str | None:
"""Poll inspect_job until a terminal stage or until `done` is set.
Returns the terminal stage string, or None if `done` was set first (detach)
or after `max_failures` consecutive inspect_job errors. When a terminal stage
is reached and `status_holder` is given, records `status_holder["message"]`
(the platform's status message, e.g. "Job timeout").
"""
failures = 0
while not done.is_set():
try:
info = inspect_job(job_id=job_id)
failures = 0
# `stage` is an enum in some huggingface_hub versions and a plain str in others.
stage = getattr(info.status.stage, "value", info.status.stage)
if stage in _TERMINAL_STAGES:
if status_holder is not None:
status_holder["message"] = getattr(info.status, "message", None)
done.set()
return stage
except _TRANSIENT_NET_ERRORS:
failures += 1
if failures >= max_failures:
done.set()
return None
done.wait(poll_interval)
return None
def _pod_forwarded_args(
argv: list[str], drop_names: tuple[str, ...] = (), drop_prefixes: tuple[str, ...] = ()
) -> list[str]:
"""User CLI overrides to replay on the pod, minus flags the submitter sets itself.
Handles both `--name=value` and `--name value` forms. Forwarding the user's overrides (e.g.
`--steps`, `--save_checkpoint_to_hub`) makes a remote resume behave like the same local command.
"""
out: list[str] = []
skip_next = False
for i, tok in enumerate(argv):
if skip_next:
skip_next = False
continue
name = tok.split("=", 1)[0]
if name in drop_names or any(name.startswith(p) for p in drop_prefixes):
if "=" not in tok and i + 1 < len(argv) and not argv[i + 1].startswith("--"):
skip_next = True # also drop the space-separated value
continue
out.append(tok)
return out
def _build_resume_job(cfg: TrainPipelineConfig, username: str) -> tuple[str, list[str]]:
"""Resolve the model repo and pod command to resume a run on a job.
A Hub `config_path` is resumed from directly: its checkpoint config already targets that repo,
so new checkpoints continue the lineage there. A local `config_path` has its checkpoint uploaded
to a new PRIVATE repo first, and the resumed run is forced to push back to it. The pod command
always carries `--job.target=local` so the checkpoint's saved `job.target` can't make the pod
re-dispatch itself.
"""
config_path = parser.parse_arg("config_path")
forwarded = _pod_forwarded_args(
sys.argv[1:],
drop_names=("--config_path", "--policy.repo_id", "--policy.push_to_hub", "--dataset.root"),
drop_prefixes=("--job.",),
)
if Path(config_path).exists():
# Local checkpoint: stage it on the Hub so the pod can resume from it, and push back there.
# Resolve so a `last` symlink uploads under its real step name (digit), which the pod's
# latest-checkpoint lookup keys on.
checkpoint_dir = Path(cfg.checkpoint_path).resolve()
source_repo = build_repo_id(username, cfg.job_name or "train", dt.datetime.now(dt.UTC))
push_checkpoint_to_hub(checkpoint_dir, source_repo, private=True)
extra = [f"--policy.repo_id={source_repo}", "--policy.push_to_hub=true"]
else:
source_repo = config_path
extra = []
command = [
"lerobot-train",
*forwarded,
f"--config_path={source_repo}",
"--job.target=local",
*extra,
]
return source_repo, command
def submit_to_hf(cfg: TrainPipelineConfig) -> None:
"""Submit a training job to HF Jobs infrastructure.
Validates cfg, resolves credentials, ensures the dataset is on the Hub, then either stages a
sanitized config (fresh run) or resumes from a checkpoint repo, submits the job, and tails logs
until completion or detaches immediately. Ctrl-C detaches without cancelling the remote job.
"""
token = get_token()
if not token:
raise RuntimeError("Not logged in to Hugging Face. Run `hf auth login` first.")
api = HfApi(token=token)
user_info = api.whoami(token=token)
username = user_info["name"]
now = dt.datetime.now(dt.UTC)
fresh_repo_id: str | None = None
if not cfg.resume:
# Resolve the model repo and mark it for push BEFORE validate(): validate() requires repo_id
# to be set whenever push_to_hub is True. (A resume reuses the checkpoint's repo instead.)
if cfg.policy is not None:
base_name = cfg.job_name or cfg.policy.type
fresh_repo_id = cfg.policy.repo_id or build_repo_id(username, base_name, now)
cfg.policy.repo_id = fresh_repo_id
cfg.policy.push_to_hub = True
else:
# Path-based policy is resolved inside validate(); fall back to a generic slug.
fresh_repo_id = build_repo_id(username, cfg.job_name or "train", now)
cfg.validate()
if cfg.is_reward_model_training:
raise ValueError(
"Remote training via --job.target only supports policy training, not reward models. "
"Run reward-model training locally."
)
secrets: dict[str, str] = {"HF_TOKEN": token}
if cfg.wandb.enable:
wandb_key = resolve_wandb_api_key()
if wandb_key is None:
raise ValueError(
"wandb is enabled but no WANDB_API_KEY found. "
"Set it via `export WANDB_API_KEY=...` or add it to ~/.netrc."
)
secrets["WANDB_API_KEY"] = wandb_key
tags = resolve_job_tags(cfg.job.tags)
# The dataset must be reachable from the pod for both fresh and resumed runs; a local-only
# dataset is pushed PRIVATE here. Hoisted before the resume/fresh branch since it applies to both.
ensure_dataset_available(cfg.dataset.repo_id, api=api, tags=tags)
if cfg.resume:
repo_id, command = _build_resume_job(cfg, username)
else:
config_repo_id = _stage_config_on_hub(cfg, fresh_repo_id, token, tags=tags)
repo_id = fresh_repo_id
command = ["lerobot-train", f"--config_path={config_repo_id}"]
print(f"Submitting job to HF Jobs (flavor={cfg.job.target}, image={cfg.job.image}) ...")
job_info = run_job(
image=cfg.job.image,
command=command,
flavor=cfg.job.target,
secrets=secrets,
timeout=cfg.job.timeout,
# HF Jobs labels are key/value; expose each tag as a queryable label.
labels=dict.fromkeys(tags, "true"),
)
job_id = job_info.id
job_url = getattr(job_info, "url", None)
print(f"Job submitted: {job_id}")
if job_url:
print(f" Job page: {job_url}")
print(f" Model repo: https://huggingface.co/{repo_id}")
print(f" Monitor: hf jobs logs {job_id}")
print(f" Cancel: hf jobs cancel {job_id}")
if cfg.job.detach:
return
done = threading.Event()
detached = threading.Event()
pushed_ok = threading.Event()
stage_holder: dict[str, str | None] = {}
def _poll() -> None:
stage_holder["stage"] = _poll_until_done(job_id, done, status_holder=stage_holder)
poll_thread = threading.Thread(target=_poll, daemon=True)
poll_thread.start()
# Finish as soon as the model is pushed, rather than waiting out the platform's
# post-run finalization before the job stage flips to COMPLETED. This matches the
# exact log line emitted by PreTrainedPolicy.push_model_to_hub — the two must stay
# in sync. If it ever stops matching we just fall back to stage-based completion
# (~30s slower), so the contract is an optimization, not a correctness requirement.
success_marker = f"Model pushed to https://huggingface.co/{repo_id}"
log_thread = threading.Thread(
target=_tail_logs, args=(job_id, done, success_marker, pushed_ok), daemon=True
)
log_thread.start()
def _detach(sig, frame):
detached.set()
done.set()
print("\nDetached. Job is still running.")
print(f" Monitor: hf jobs logs {job_id}")
print(f" Cancel: hf jobs cancel {job_id}")
# signal.signal only works on the main thread; when called from a worker thread
# (e.g. an orchestration framework) skip the Ctrl-C-detaches-instead-of-cancels
# handler rather than crashing with ValueError.
install_sigint = threading.current_thread() is threading.main_thread()
original_sigint = signal.getsignal(signal.SIGINT) if install_sigint else None
if install_sigint:
signal.signal(signal.SIGINT, _detach)
try:
# Timeout-based join so SIGINT is delivered to the main thread promptly.
while poll_thread.is_alive():
poll_thread.join(timeout=0.5)
log_thread.join(timeout=5)
finally:
if install_sigint:
signal.signal(signal.SIGINT, original_sigint)
if detached.is_set():
return
if pushed_ok.is_set():
print(f"\nTraining complete — model pushed to https://huggingface.co/{repo_id}")
return
stage = stage_holder.get("stage")
if stage != "COMPLETED":
message = stage_holder.get("message")
detail = f" ({message})" if message else ""
raise RuntimeError(
f"Job {job_id} ended with stage={stage}{detail}. Check logs: hf jobs logs {job_id}"
)
+2
View File
@@ -18,6 +18,7 @@ from .act.configuration_act import ACTConfig as ACTConfig
from .diffusion.configuration_diffusion import DiffusionConfig as DiffusionConfig from .diffusion.configuration_diffusion import DiffusionConfig as DiffusionConfig
from .eo1.configuration_eo1 import EO1Config as EO1Config from .eo1.configuration_eo1 import EO1Config as EO1Config
from .factory import get_policy_class, make_policy, make_policy_config, make_pre_post_processors 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 .gaussian_actor.configuration_gaussian_actor import GaussianActorConfig as GaussianActorConfig
from .groot.configuration_groot import GrootConfig as GrootConfig from .groot.configuration_groot import GrootConfig as GrootConfig
from .lingbot_va.configuration_lingbot_va import LingBotVAConfig as LingBotVAConfig from .lingbot_va.configuration_lingbot_va import LingBotVAConfig as LingBotVAConfig
@@ -43,6 +44,7 @@ __all__ = [
"ACTConfig", "ACTConfig",
"DiffusionConfig", "DiffusionConfig",
"EO1Config", "EO1Config",
"FastWAMConfig",
"GaussianActorConfig", "GaussianActorConfig",
"GrootConfig", "GrootConfig",
"LingBotVAConfig", "LingBotVAConfig",
+15
View File
@@ -47,6 +47,7 @@ from lerobot.utils.feature_utils import dataset_to_policy_features
from .act.configuration_act import ACTConfig from .act.configuration_act import ACTConfig
from .diffusion.configuration_diffusion import DiffusionConfig from .diffusion.configuration_diffusion import DiffusionConfig
from .eo1.configuration_eo1 import EO1Config from .eo1.configuration_eo1 import EO1Config
from .fastwam.configuration_fastwam import FastWAMConfig
from .gaussian_actor.configuration_gaussian_actor import GaussianActorConfig from .gaussian_actor.configuration_gaussian_actor import GaussianActorConfig
from .groot.configuration_groot import GrootConfig from .groot.configuration_groot import GrootConfig
from .lingbot_va.configuration_lingbot_va import LingBotVAConfig from .lingbot_va.configuration_lingbot_va import LingBotVAConfig
@@ -167,6 +168,10 @@ def get_policy_class(name: str) -> type[PreTrainedPolicy]:
from .lingbot_va.modeling_lingbot_va import LingBotVAPolicy from .lingbot_va.modeling_lingbot_va import LingBotVAPolicy
return LingBotVAPolicy return LingBotVAPolicy
elif name == "fastwam":
from .fastwam.modeling_fastwam import FastWAMPolicy
return FastWAMPolicy
else: else:
try: try:
return _get_policy_cls_from_policy_name(name=name) return _get_policy_cls_from_policy_name(name=name)
@@ -225,6 +230,8 @@ def make_policy_config(policy_type: str, **kwargs) -> PreTrainedConfig:
return VLAJEPAConfig(**kwargs) return VLAJEPAConfig(**kwargs)
elif policy_type == "lingbot_va": elif policy_type == "lingbot_va":
return LingBotVAConfig(**kwargs) return LingBotVAConfig(**kwargs)
elif policy_type == "fastwam":
return FastWAMConfig(**kwargs)
else: else:
try: try:
config_cls = PreTrainedConfig.get_choice_class(policy_type) config_cls = PreTrainedConfig.get_choice_class(policy_type)
@@ -466,6 +473,14 @@ def make_pre_post_processors(
dataset_stats=kwargs.get("dataset_stats"), dataset_stats=kwargs.get("dataset_stats"),
) )
elif isinstance(policy_cfg, FastWAMConfig):
from .fastwam.processor_fastwam import make_fastwam_pre_post_processors
processors = make_fastwam_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
else: else:
try: try:
processors = _make_processors_from_policy_config( processors = _make_processors_from_policy_config(
+1
View File
@@ -0,0 +1 @@
../../../../docs/source/policy_fastwam_README.md
+23
View File
@@ -0,0 +1,23 @@
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .configuration_fastwam import FastWAMConfig
from .modeling_fastwam import FastWAMPolicy
from .processor_fastwam import make_fastwam_pre_post_processors
__all__ = [
"FastWAMConfig",
"FastWAMPolicy",
"make_fastwam_pre_post_processors",
]
@@ -0,0 +1,399 @@
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from lerobot.configs import (
FeatureType,
NormalizationMode,
PolicyFeature,
PreTrainedConfig,
)
from lerobot.optim import AdamWConfig
from lerobot.utils.constants import ACTION, OBS_STATE
WAN22_MODEL_ID = "Wan-AI/Wan2.2-TI2V-5B"
WAN22_DIFFUSERS_MODEL_ID = "Wan-AI/Wan2.2-TI2V-5B-Diffusers"
FASTWAM_BASE_MODEL_ID = "lerobot/fastwam_base"
WAN_T5_TOKENIZER_ID = "google/umt5-xxl"
_FASTWAM_VIDEO_BASE_COMPAT_KEYS = (
"patch_size",
"in_dim",
"hidden_dim",
"ffn_dim",
"freq_dim",
"text_dim",
"out_dim",
"num_heads",
"attn_head_dim",
"num_layers",
)
_FASTWAM_ACTION_BASE_COMPAT_KEYS = (
"hidden_dim",
"ffn_dim",
"num_heads",
"attn_head_dim",
"num_layers",
"text_dim",
"freq_dim",
)
def default_video_dit_config(action_dim: int) -> dict[str, Any]:
return {
"patch_size": [1, 2, 2],
"in_dim": 48,
"hidden_dim": 3072,
"ffn_dim": 14336,
"freq_dim": 256,
"text_dim": 4096,
"out_dim": 48,
"num_heads": 24,
"attn_head_dim": 128,
"num_layers": 30,
"eps": 1.0e-6,
"seperated_timestep": True,
"use_gradient_checkpointing": False,
"video_attention_mask_mode": "first_frame_causal",
"action_conditioned": False,
"action_dim": action_dim,
"action_group_causal_mask_mode": "group_diagonal",
"fp32_attention": True,
}
def default_action_dit_config(action_dim: int) -> dict[str, Any]:
return {
"action_dim": action_dim,
"hidden_dim": 1024,
"ffn_dim": 4096,
"num_heads": 24,
"attn_head_dim": 128,
"num_layers": 30,
"text_dim": 4096,
"freq_dim": 256,
"eps": 1.0e-6,
"use_gradient_checkpointing": False,
"fp32_attention": True,
}
def _coerce_enum(enum_cls: type, value: Any) -> Any:
if isinstance(value, enum_cls):
return value
try:
return enum_cls(value)
except (TypeError, ValueError) as exc:
member = getattr(enum_cls, str(value), None)
if member is None:
raise ValueError(f"Cannot coerce {value!r} into {enum_cls.__name__}.") from exc
return member
def _coerce_policy_features(features: dict[str, Any] | None) -> dict[str, PolicyFeature] | None:
if features is None:
return None
coerced = {}
for name, feature in features.items():
if isinstance(feature, PolicyFeature):
coerced[name] = feature
continue
coerced[name] = PolicyFeature(
type=_coerce_enum(FeatureType, feature["type"]),
shape=tuple(feature["shape"]),
)
return coerced
def _is_local_model_id(value: str) -> bool:
path = Path(value).expanduser()
return path.is_absolute() or value.startswith(("./", "../", "~")) or path.exists()
def _validate_wan_model_id(value: str, field_name: str) -> str:
if value == WAN22_MODEL_ID or _is_local_model_id(value):
return value
raise ValueError(f"`{field_name}` must be `{WAN22_MODEL_ID}` or an explicit local path, got `{value}`.")
def is_fastwam_base_compatible_config(config: FastWAMConfig) -> bool:
"""Return whether `fastwam_base` partial weights can initialize this config."""
default_video_config = default_video_dit_config(config.action_dim)
default_action_config = default_action_dit_config(config.action_dim)
return all(
config.video_dit_config.get(key) == default_video_config.get(key)
for key in _FASTWAM_VIDEO_BASE_COMPAT_KEYS
) and all(
config.action_dit_config.get(key) == default_action_config.get(key)
for key in _FASTWAM_ACTION_BASE_COMPAT_KEYS
)
@PreTrainedConfig.register_subclass("fastwam")
@dataclass
class FastWAMConfig(PreTrainedConfig):
"""Configuration for the FastWAM LeRobot policy.
Args:
action_dim (int): Number of scalar action channels per timestep.
proprio_dim (int | None): Number of proprioception channels used as an
extra text-context token. `None` disables proprio conditioning.
action_horizon (int): Number of actions predicted by one policy call.
num_video_frames (int): Raw video sampling window (in dataset frames). The
model actually operates on `model_video_frames` frames after subsampling
by `action_video_freq_ratio`.
action_video_freq_ratio (int): Actions are sampled at this multiple of the
video frame rate. Video frames are taken every `action_video_freq_ratio`-th
raw frame, so the model sees `(num_video_frames - 1) // ratio + 1` frames
spanning the same time window as `action_horizon` actions (ratio actions
per video frame).
image_size (tuple[int, int]): Concatenated image size as `(height, width)`.
context_len (int): Maximum text embedding token length.
video_dit_config (dict[str, Any] | None): Wan video expert config.
action_dit_config (dict[str, Any] | None): Action expert config.
use_gradient_checkpointing (bool): Enable activation checkpointing in both DiT
experts (trades compute for memory; propagated into the DiT configs).
freeze_video_expert (bool): Freeze the ~5B Wan video expert
(`model.video_expert`) so only the action expert + proprio encoder train.
Cuts the AdamW optimizer footprint substantially; the video expert keeps its
pretrained weights. (If enabled, also set `loss.lambda_video=0` to skip the
now-gradient-free video loss compute.)
"""
n_obs_steps: int = 1
action_dim: int = 7
proprio_dim: int | None = 8
action_horizon: int = 32
n_action_steps: int = 32
num_video_frames: int = 33
action_video_freq_ratio: int = 4
image_size: tuple[int, int] = (224, 448)
context_len: int = 128
model_id: str = WAN22_MODEL_ID
tokenizer_model_id: str = WAN_T5_TOKENIZER_ID
text_encoder_model_id: str = WAN22_DIFFUSERS_MODEL_ID
base_model_id: str | None = FASTWAM_BASE_MODEL_ID
tokenizer_max_len: int = 128
load_text_encoder: bool = True
mot_checkpoint_mixed_attn: bool = False
torch_dtype: str = "bfloat16"
prompt_template: str = (
"A video recorded from a robot's point of view executing the following instruction: {task}"
)
num_inference_steps: int = 10
inference_seed: int | None = 42
rand_device: str = "cpu"
text_cfg_scale: float = 1.0
negative_prompt: str = ""
sigma_shift: float | None = None
tiled: bool = False
fp32_attention: bool = True
use_gradient_checkpointing: bool = False
freeze_video_expert: bool = False
toggle_action_dimensions: list[int] = field(default_factory=list)
video_scheduler: dict[str, float | int] = field(
default_factory=lambda: {"train_shift": 5.0, "infer_shift": 5.0, "num_train_timesteps": 1000}
)
action_scheduler: dict[str, float | int] = field(
default_factory=lambda: {"train_shift": 5.0, "infer_shift": 5.0, "num_train_timesteps": 1000}
)
loss: dict[str, float] = field(default_factory=lambda: {"lambda_video": 1.0, "lambda_action": 1.0})
video_dit_config: dict[str, Any] | None = None
action_dit_config: dict[str, Any] | None = None
normalization_mapping: dict[str, NormalizationMode] = field(
default_factory=lambda: {
"VISUAL": NormalizationMode.IDENTITY,
"STATE": NormalizationMode.MEAN_STD,
"ACTION": NormalizationMode.MEAN_STD,
}
)
input_features: dict[str, PolicyFeature] | None = None
output_features: dict[str, PolicyFeature] | None = None
optimizer_lr: float = 1.0e-4
optimizer_weight_decay: float = 1.0e-2
def __post_init__(self) -> None:
super().__post_init__()
self.image_size = tuple(self.image_size)
self.model_id = _validate_wan_model_id(self.model_id, "model_id")
self.input_features = _coerce_policy_features(self.input_features)
self.output_features = _coerce_policy_features(self.output_features)
self.toggle_action_dimensions = [int(dim) for dim in self.toggle_action_dimensions]
self.video_dit_config = self.video_dit_config or default_video_dit_config(self.action_dim)
self.action_dit_config = self.action_dit_config or default_action_dit_config(self.action_dim)
self.video_dit_config["fp32_attention"] = bool(self.fp32_attention)
self.action_dit_config["fp32_attention"] = bool(self.fp32_attention)
self.video_dit_config["use_gradient_checkpointing"] = bool(self.use_gradient_checkpointing)
self.action_dit_config["use_gradient_checkpointing"] = bool(self.use_gradient_checkpointing)
if self.input_features is None:
height, width = self.image_size
self.input_features = {
"observation.images.image": PolicyFeature(
type=FeatureType.VISUAL,
shape=(3, height, width),
)
}
if self.proprio_dim is not None:
self.input_features[OBS_STATE] = PolicyFeature(
type=FeatureType.STATE,
shape=(self.proprio_dim,),
)
if self.output_features is None:
self.output_features = {ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(self.action_dim,))}
self.validate_features()
if self.pretrained_path or self.use_peft or not self.base_model_id:
return
if not is_fastwam_base_compatible_config(self):
return
self.pretrained_path = Path(self.base_model_id)
self._auto_pretrained_path = True
def _save_pretrained(self, save_directory: Path) -> None:
if not getattr(self, "_auto_pretrained_path", False):
super()._save_pretrained(save_directory)
return
pretrained_path = self.pretrained_path
self.pretrained_path = None
try:
super()._save_pretrained(save_directory)
finally:
self.pretrained_path = pretrained_path
def get_optimizer_preset(self) -> AdamWConfig:
return AdamWConfig(lr=self.optimizer_lr, weight_decay=self.optimizer_weight_decay)
def get_scheduler_preset(self) -> None:
return None
def set_dataset_feature_metadata(self, dataset_features: dict[str, Any]) -> None:
"""Rebuild visual input features from the dataset's real camera keys.
FastWAM's `__post_init__` installs a synthetic single-image default
(`observation.images.image` at full `image_size` width). For datasets
with one or more separately-named cameras (e.g. `observation.images.top`,
`observation.images.wrist`), this hook invoked by `make_policy` once the
dataset metadata is known replaces that default with the actual camera
keys, each declared at the policy's native per-camera resolution
(`image_size[0]` x `image_size[1] // num_cameras`). The accompanying
resize step in `make_fastwam_pre_post_processors` resizes raw frames to
match, so heterogeneous source resolutions (e.g. 480x640) are supported.
"""
image_keys = sorted(
key
for key, feature in dataset_features.items()
if key.startswith("observation.images.") and feature.get("dtype") in ("video", "image")
)
if not image_keys:
return
height, total_width = self.image_size
per_cam_width = total_width // len(image_keys)
new_inputs: dict[str, PolicyFeature] = {
key: PolicyFeature(type=FeatureType.VISUAL, shape=(3, height, per_cam_width))
for key in image_keys
}
if self.proprio_dim is not None and OBS_STATE in dataset_features:
new_inputs[OBS_STATE] = PolicyFeature(type=FeatureType.STATE, shape=(self.proprio_dim,))
self.input_features = new_inputs
self.validate_features()
def validate_features(self) -> None:
if self.action_dim <= 0:
raise ValueError(f"`action_dim` must be positive, got {self.action_dim}.")
if self.action_horizon <= 0:
raise ValueError(f"`action_horizon` must be positive, got {self.action_horizon}.")
if self.n_action_steps > self.action_horizon:
raise ValueError("`n_action_steps` cannot exceed `action_horizon`.")
if self.action_video_freq_ratio <= 0:
raise ValueError(
f"`action_video_freq_ratio` must be positive, got {self.action_video_freq_ratio}."
)
# Video frames are subsampled by action_video_freq_ratio; the resulting model frame
# count must satisfy T % 4 == 1 for the VAE temporal tokenization (mirrors the
# original FastWAM dataset asserts).
if (self.num_video_frames - 1) % self.action_video_freq_ratio != 0:
raise ValueError(
f"`num_video_frames - 1` ({self.num_video_frames - 1}) must be divisible by "
f"`action_video_freq_ratio` ({self.action_video_freq_ratio})."
)
if ((self.num_video_frames - 1) // self.action_video_freq_ratio) % 4 != 0:
raise ValueError(
f"Subsampled video transitions ({(self.num_video_frames - 1) // self.action_video_freq_ratio}) "
"must be divisible by 4 for VAE tokenization (i.e. model_video_frames % 4 == 1)."
)
if self.action_horizon % ((self.num_video_frames - 1) // self.action_video_freq_ratio) != 0:
raise ValueError(
f"`action_horizon` ({self.action_horizon}) must be divisible by the number of "
f"video transitions ({(self.num_video_frames - 1) // self.action_video_freq_ratio})."
)
if not self.image_features:
raise ValueError("FastWAM requires at least one image feature.")
if self.action_feature is None:
raise ValueError("FastWAM requires `action` in output_features.")
action_shape = tuple(self.action_feature.shape)
if action_shape != (self.action_dim,):
raise ValueError(
f"FastWAM action feature shape must be ({self.action_dim},), got {action_shape}."
)
if self.proprio_dim is not None:
state_feature = self.robot_state_feature
if state_feature is None:
raise ValueError("FastWAM requires `observation.state` when `proprio_dim` is set.")
state_shape = tuple(state_feature.shape)
if state_shape != (self.proprio_dim,):
raise ValueError(
f"FastWAM state feature shape must be ({self.proprio_dim},), got {state_shape}."
)
height, width = self.image_size
image_width_sum = 0
for name, feature in self.image_features.items():
shape = tuple(feature.shape)
if len(shape) != 3 or shape[0] != 3:
raise ValueError(f"FastWAM image feature `{name}` must have shape (3, H, W), got {shape}.")
if shape[1] != height:
raise ValueError(f"FastWAM image feature `{name}` height must be {height}, got {shape[1]}.")
image_width_sum += shape[2]
if image_width_sum != width:
raise ValueError(f"FastWAM image feature widths must sum to {width}, got {image_width_sum}.")
@property
def model_video_frames(self) -> int:
"""Number of video frames the model actually operates on, after subsampling the
raw `num_video_frames` window by `action_video_freq_ratio` (e.g. 33 -> 9)."""
return (self.num_video_frames - 1) // self.action_video_freq_ratio + 1
@property
def observation_delta_indices(self) -> list[int]:
# Load the video frames the model is supervised on: the future window subsampled by
# action_video_freq_ratio (e.g. [0, 4, 8, ..., 32] -> 9 frames). Each video frame is
# thus `action_video_freq_ratio` actions apart, while actions load at the full rate
# (`action_delta_indices` = range(action_horizon)). Returning None would load only the
# current frame, making the video target a static repeat (degenerate supervision).
return list(range(0, self.num_video_frames, self.action_video_freq_ratio))
@property
def action_delta_indices(self) -> list[int]:
return list(range(self.action_horizon))
@property
def reward_delta_indices(self) -> None:
return None
@@ -0,0 +1,440 @@
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import logging
from collections import deque
from typing import Any
import torch
from torch import Tensor
from lerobot.policies.pretrained import PreTrainedPolicy
from lerobot.utils.constants import OBS_STATE
from lerobot.utils.import_utils import require_package
from .configuration_fastwam import FastWAMConfig
from .wan import (
ActionDiT,
FastWAM,
MoT,
WanVideoDiT,
build_wan_tokenizer,
load_pretrained_wan_text_encoder,
load_pretrained_wan_vae,
)
class FastWAMPolicy(PreTrainedPolicy):
"""LeRobot policy wrapper for FastWAM.
Attention backend: FastWAM's DiT uses ``torch.nn.functional.scaled_dot_product_attention``
(SDPA) for all attention. It does not use FlashAttention, because MoT routing requires
arbitrary boolean ``[query, key]`` masks that the FlashAttention varlen API cannot express;
installing ``flash-attn`` has no effect on the FastWAM path. (SDPA may still dispatch to
PyTorch's own flash/mem-efficient/math kernel internally, unrelated to the ``flash-attn`` package.)
Args:
config (FastWAMConfig): FastWAM policy configuration.
dataset_stats (dict[str, dict[str, Tensor]] | None): Optional LeRobot
dataset statistics passed by the training/evaluation stack.
"""
config_class = FastWAMConfig
name = "fastwam"
def __init__(
self,
config: FastWAMConfig,
dataset_stats: dict[str, dict[str, Tensor]] | None = None,
**kwargs: Any,
):
# FastWAM's Wan2.2 backbone needs transformers (UMT5 text encoder/tokenizer) and
# diffusers (Wan VAE), both behind the `fastwam` extra. Fail fast with an actionable
# message in base installs rather than deep in Wan component construction.
require_package("transformers", extra="fastwam")
require_package("diffusers", extra="fastwam")
# `make_policy`/`from_pretrained` forward extra kwargs (e.g. `dataset_meta`); the
# dataset feature metadata is already applied to `config` by make_policy upstream,
# so we accept and ignore them, matching the other LeRobot policies.
super().__init__(config, dataset_stats)
config.validate_features()
self.config = config
self.dataset_stats = dataset_stats
self.model = self._build_core_model(config)
if config.freeze_video_expert and getattr(self.model, "video_expert", None) is not None:
# Freeze the ~5B Wan video expert; get_optim_params filters on requires_grad,
# so its params drop out of the optimizer (and DDP skips them).
self.model.video_expert.requires_grad_(False)
# The transformer blocks are re-parented onto the MoTLayers (single FSDP owner), so
# `video_expert.requires_grad_` no longer reaches them — freeze them via the layers.
mot = getattr(self.model, "mot", None)
if mot is not None and getattr(mot, "layers", None) is not None:
for layer in mot.layers:
if "video" in layer.blocks:
layer.blocks["video"].requires_grad_(False)
self.reset()
@classmethod
def _load_as_safetensor(cls, model, model_file: str, map_location: str, strict: bool):
"""Shape-aware load that supports cross-embodiment fine-tuning.
`safetensors.load_model(strict=False)` ignores missing/unexpected keys but
still raises on a shape mismatch for a shared key. When fine-tuning from a
checkpoint trained on a different embodiment (e.g. the LIBERO 7-DoF / 8-dim
checkpoint adapted to a 6-DoF / 6-dim arm), the action encoder/head and
proprio encoder legitimately differ in shape. With `strict=False` we drop
only those shape-mismatched tensors leaving them at their freshly
initialized values and load every compatible tensor. With `strict=True`
the standard exact-match loader is used.
"""
from safetensors import safe_open
model_state_dict = model.state_dict()
mismatched = []
with safe_open(model_file, framework="pt") as f:
checkpoint_keys = list(f.keys())
for key in checkpoint_keys:
if key in model_state_dict and tuple(model_state_dict[key].shape) != tuple(
f.get_slice(key).get_shape()
):
mismatched.append(key)
if not mismatched:
return super()._load_as_safetensor(model, model_file, map_location, strict)
if strict:
raise RuntimeError(
f"FastWAM: {len(mismatched)} checkpoint tensors have a shape mismatch under "
f"strict=True: {mismatched}"
)
from safetensors.torch import load_file
logging.warning(
"FastWAM cross-embodiment load: reinitializing %d shape-mismatched tensor(s), keeping "
"every compatible weight: %s",
len(mismatched),
mismatched,
)
state_dict = load_file(model_file, device="cpu")
for key in mismatched:
state_dict.pop(key, None)
model.load_state_dict(state_dict, strict=False)
if map_location and map_location != "cpu":
model.to(map_location)
return model
def get_optim_params(self) -> list[Tensor]:
# Return the trainable tensors directly (a single param group). The optimizer
# builder wraps these in a param group; returning a bare {"params": [...]} dict
# instead would make `list(...)` yield the key string "params".
params = (
list(self.model.dit.parameters()) if hasattr(self.model, "dit") else list(self.model.parameters())
)
proprio_encoder = getattr(self.model, "proprio_encoder", None)
if proprio_encoder is not None:
params.extend(list(proprio_encoder.parameters()))
return [p for p in params if p.requires_grad]
def reset(self) -> None:
self._action_queue: deque[Tensor] = deque([], maxlen=self.config.n_action_steps)
def _batch_to_training_sample(self, batch: dict[str, Tensor]) -> dict[str, Tensor]:
"""Adapt a standard LeRobot batch to the FastWAM-native sample that
`FastWAM.build_inputs` consumes (`video`, `action`, `context`/`context_mask`,
per-frame `proprio`).
The LeRobot training loop passes raw `observation.images.*`, a single-step
`observation.state` `[B, D]`, `action`, and a language `task` string. We do
only the translation `build_inputs` can't: stack the camera frames into a
video, encode the prompt with the (frozen) text encoder (mirroring inference,
so language-conditioned datasets need no precomputed context), and give proprio
the per-frame axis `build_inputs` indexes. All shape/presence validation is
left to `build_inputs`, the single authority on the contract.
"""
sample = dict(batch)
if "video" not in sample:
sample["video"] = _stack_video_from_images(batch, self.config)
if "context" not in sample or "context_mask" not in sample:
prompt = _prompt_from_batch(batch=batch, config=self.config)
if prompt is None:
raise KeyError(
"FastWAM training requires a `task`/`prompt` to encode text context, "
"or precomputed `context`/`context_mask` in the batch."
)
sample["context"], sample["context_mask"] = self.model.encode_prompt(prompt)
if self.config.proprio_dim is not None and "proprio" not in sample:
state = sample.get(OBS_STATE)
if state is not None:
# LeRobot gives a single-step state [B, D]; build_inputs expects
# per-frame [B, T, D] and uses frame 0, so add a T=1 axis.
sample["proprio"] = state.unsqueeze(1) if state.ndim == 2 else state
return sample
def forward(self, batch: dict[str, Tensor]) -> tuple[Tensor, dict[str, Any]]:
"""Compute FastWAM training loss for a LeRobot batch.
Args:
batch (dict[str, Tensor]): Batch containing FastWAM-ready keys
(`video`, `action`, `context`, `context_mask`) or LeRobot keys
that can be adapted (`observation.images.*`, `observation.state`,
`action`, `action_is_pad`).
Returns:
tuple[Tensor, dict[str, Any]]: The scalar loss to backprop, and a dict of
logging metrics (e.g. `loss_video`, `loss_action`) the `(loss, output_dict)`
contract the LeRobot training loop expects.
"""
sample = self._batch_to_training_sample(batch)
loss, metrics = self.model.training_loss(sample)
return loss, dict(metrics or {})
@torch.no_grad()
def predict_action_chunk(self, batch: dict[str, Tensor], **_: Any) -> Tensor:
"""Predict a chunk of actions from the current FastWAM observation.
Args:
batch (dict[str, Tensor]): Inference batch with `input_image` or
image observation keys, plus `context/context_mask` or `prompt`.
Returns:
Tensor: Action chunk with shape `[B, action_horizon, action_dim]`.
"""
self.eval()
infer_kwargs = _batch_to_infer_kwargs(batch=batch, config=self.config)
batch_size = _infer_kwargs_batch_size(infer_kwargs)
if batch_size == 1:
action = _action_from_model_output(self.model.infer_action(**infer_kwargs))
else:
action = torch.cat(
[
_action_from_model_output(
self.model.infer_action(
**_slice_infer_kwargs(infer_kwargs, index=i, batch_size=batch_size)
)
)
for i in range(batch_size)
],
dim=0,
)
return action.to(device=batch_device(batch), dtype=torch.float32)
@torch.no_grad()
def select_action(self, batch: dict[str, Tensor], **kwargs: Any) -> Tensor:
self.eval()
if len(self._action_queue) == 0:
actions = self.predict_action_chunk(batch, **kwargs)[:, : self.config.n_action_steps]
self._action_queue.extend(actions.transpose(0, 1))
return self._action_queue.popleft()
def _build_core_model(self, config: FastWAMConfig) -> FastWAM:
"""Build the FastWAM core for training / inference.
Only the trainable parts (the MoT DiT and the proprio encoder) are
materialized empty here and then filled from the policy's
`model.safetensors` by the base `from_pretrained`. The *frozen* Wan2.2 VAE
and UMT5 text encoder are loaded with their real weights from the
`Wan-AI/Wan2.2-TI2V-5B-Diffusers` repo (cached in the HF cache, shared
across checkpoints) and are intentionally excluded from `model.safetensors`
see `FastWAM.__init__`. The tokenizer comes from `google/umt5-xxl`.
"""
dtype = _dtype_from_name(config.torch_dtype)
device = config.device
video_expert = WanVideoDiT(**config.video_dit_config).to(device=device, dtype=dtype)
action_expert = ActionDiT(**config.action_dit_config).to(device=device, dtype=dtype)
mot = MoT(
mixtures={"video": video_expert, "action": action_expert},
mot_checkpoint_mixed_attn=config.mot_checkpoint_mixed_attn,
)
text_encoder = (
load_pretrained_wan_text_encoder(
model_id=config.text_encoder_model_id, torch_dtype=dtype, device=device
)
if config.load_text_encoder
else None
)
return FastWAM(
video_expert=video_expert,
action_expert=action_expert,
mot=mot,
vae=load_pretrained_wan_vae(torch_dtype=dtype, device=device),
text_encoder=text_encoder,
tokenizer=build_wan_tokenizer(
model_id=config.tokenizer_model_id, tokenizer_max_len=config.tokenizer_max_len
),
text_dim=int(config.video_dit_config["text_dim"]),
proprio_dim=config.proprio_dim,
device=device,
torch_dtype=dtype,
video_train_shift=float(config.video_scheduler["train_shift"]),
video_infer_shift=float(config.video_scheduler["infer_shift"]),
video_num_train_timesteps=int(config.video_scheduler["num_train_timesteps"]),
action_train_shift=float(config.action_scheduler["train_shift"]),
action_infer_shift=float(config.action_scheduler["infer_shift"]),
action_num_train_timesteps=int(config.action_scheduler["num_train_timesteps"]),
loss_lambda_video=float(config.loss["lambda_video"]),
loss_lambda_action=float(config.loss["lambda_action"]),
)
def _scalar(value: Any) -> Any:
"""Unwrap a 0-/1-element tensor (e.g. from DataLoader collation) to a Python scalar."""
return value.item() if isinstance(value, Tensor) else value
def _batch_to_infer_kwargs(batch: dict[str, Tensor], config: FastWAMConfig) -> dict[str, Any]:
return {
"prompt": _prompt_from_batch(batch=batch, config=config),
"input_image": _input_image_from_batch(batch, config),
"action_horizon": config.action_horizon,
"proprio": batch.get("proprio", batch.get(OBS_STATE)),
"context": batch.get("context"),
"context_mask": batch.get("context_mask"),
"negative_prompt": batch.get("negative_prompt", config.negative_prompt),
"text_cfg_scale": float(_scalar(batch.get("text_cfg_scale", config.text_cfg_scale))),
"num_inference_steps": int(_scalar(batch.get("num_inference_steps", config.num_inference_steps))),
"sigma_shift": batch.get("sigma_shift", config.sigma_shift),
"seed": batch.get("seed", config.inference_seed),
"rand_device": batch.get("rand_device", config.rand_device),
"tiled": bool(batch.get("tiled", config.tiled)),
}
def _prompt_from_batch(batch: dict[str, Tensor], config: FastWAMConfig) -> Any:
prompt = batch.get("prompt")
if prompt is not None:
return prompt
task = batch.get("task")
if task is None:
return None
if isinstance(task, str):
return config.prompt_template.format(task=task)
if isinstance(task, (list, tuple)):
return [config.prompt_template.format(task=str(item)) for item in task]
return config.prompt_template.format(task=str(task))
def _action_from_model_output(output: Any) -> Tensor:
action = output["action"] if isinstance(output, dict) else output
if action.ndim == 2:
action = action.unsqueeze(0)
return action
def _infer_kwargs_batch_size(infer_kwargs: dict[str, Any]) -> int:
image = infer_kwargs["input_image"]
if not isinstance(image, Tensor):
raise TypeError(f"`input_image` must be a tensor, got {type(image).__name__}.")
if image.ndim == 3:
return 1
if image.ndim == 4:
return int(image.shape[0])
raise ValueError(f"`input_image` must be [B,C,H,W] or [C,H,W], got {tuple(image.shape)}.")
def _slice_infer_kwargs(infer_kwargs: dict[str, Any], *, index: int, batch_size: int) -> dict[str, Any]:
return {
key: _slice_infer_value(value, index=index, batch_size=batch_size)
for key, value in infer_kwargs.items()
}
def _slice_infer_value(value: Any, *, index: int, batch_size: int) -> Any:
if isinstance(value, Tensor) and value.ndim > 0 and value.shape[0] == batch_size:
return value[index : index + 1]
if isinstance(value, (list, tuple)) and len(value) == batch_size:
return value[index]
return value
def _dtype_from_name(name: str) -> torch.dtype:
dtype_map = {"float32": torch.float32, "float16": torch.float16, "bfloat16": torch.bfloat16}
if name not in dtype_map:
raise ValueError(f"Unsupported torch dtype `{name}`.")
return dtype_map[name]
def batch_device(batch: dict[str, Any]) -> torch.device:
for value in batch.values():
if isinstance(value, Tensor):
return value.device
return torch.device("cpu")
def _resize_frames(frames: Tensor, size: tuple[int, int]) -> Tensor:
"""Resize a frame tensor to `size` (H, W), tolerating a leading temporal/batch stack.
`interpolate` only accepts a single leading batch dim (`[N, C, H, W]`), but FastWAM camera
tensors arrive as `[B, C, H, W]` (live eval) or `[B, T, C, H, W]` (temporal stack), so flatten
any leading dims into the batch, resize, then restore. A no-op when already at `size`.
"""
if tuple(frames.shape[-2:]) == size:
return frames
lead = frames.shape[:-3]
flat = frames.reshape(-1, *frames.shape[-3:])
flat = torch.nn.functional.interpolate(
flat, size=size, mode="bilinear", align_corners=False, antialias=True
)
return flat.reshape(*lead, *flat.shape[-3:])
def _stack_video_from_images(batch: dict[str, Tensor], config: FastWAMConfig) -> Tensor:
# Exclude the `*_is_pad` companion tensors that delta-timestamp loading adds alongside
# each camera (shape [B, T]); they share the `observation.images.` prefix but are not frames.
image_keys = sorted(k for k in batch if k.startswith("observation.images.") and not k.endswith("_is_pad"))
if not image_keys:
raise KeyError("FastWAM batch must contain `video` or `observation.images.*` keys.")
per_cam = (int(config.image_size[0]), int(config.image_size[1]) // len(image_keys))
images = [_resize_frames(batch[key], per_cam) for key in image_keys]
# Cameras concatenate along width (last dim) in both the single-frame and temporal case.
image = torch.cat(images, dim=-1) if len(images) > 1 else images[0]
if image.ndim == 4:
# [B, C, H, W]: a single frame (e.g. the live eval observation) -> repeat across time.
image = image.unsqueeze(2).repeat(1, 1, config.model_video_frames, 1, 1)
elif image.ndim == 5:
# [B, T, C, H, W]: temporal stack from delta-timestamp loading -> [B, C, T, H, W].
image = image.permute(0, 2, 1, 3, 4)
else:
raise ValueError(f"Expected image batch [B,C,H,W] or temporal [B,T,C,H,W], got {tuple(image.shape)}.")
return image
def _input_image_from_batch(batch: dict[str, Tensor], config: FastWAMConfig) -> Tensor:
if "input_image" in batch:
return _prepare_infer_image(batch["input_image"], config)
video = batch.get("video")
if video is None:
video = _stack_video_from_images(batch, config)
if video.ndim == 5:
return _prepare_infer_image(video[:, :, 0], config)
if video.ndim == 4:
return _prepare_infer_image(video, config)
raise ValueError(f"Cannot build input image from tensor with shape {tuple(video.shape)}.")
def _prepare_infer_image(image: Tensor, config: FastWAMConfig) -> Tensor:
if image.ndim == 3:
image = image.unsqueeze(0)
if image.ndim != 4:
raise ValueError(f"Expected image tensor [B,C,H,W] or [C,H,W], got {tuple(image.shape)}.")
# Resize to the full configured resolution (no-op when the video path already produced it, but
# also covers a directly-supplied `input_image`). The model owns its input resolution — see
# `_stack_video_from_images` — so we resize rather than assert on a mismatch.
target_h, target_w = int(config.image_size[0]), int(config.image_size[1])
return _resize_frames(image, (target_h, target_w))
@@ -0,0 +1,142 @@
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import torch
from lerobot.configs import PipelineFeatureType, PolicyFeature
from lerobot.processor import (
ActionProcessorStep,
AddBatchDimensionProcessorStep,
DeviceProcessorStep,
NormalizerProcessorStep,
PolicyAction,
PolicyProcessorPipeline,
ProcessorStepRegistry,
RenameObservationsProcessorStep,
UnnormalizerProcessorStep,
policy_action_to_transition,
transition_to_policy_action,
)
from lerobot.utils.constants import (
POLICY_POSTPROCESSOR_DEFAULT_NAME,
POLICY_PREPROCESSOR_DEFAULT_NAME,
)
from .configuration_fastwam import FastWAMConfig
@dataclass
@ProcessorStepRegistry.register(name="fastwam_action_toggle_processor")
class FastWAMActionToggleProcessorStep(ActionProcessorStep):
"""Apply FastWAM LIBERO toggle semantics to configured action dimensions."""
toggle_dimensions: list[int]
def action(self, action: PolicyAction) -> PolicyAction:
if not self.toggle_dimensions:
return action
processed_action = action.clone()
action_dim = int(processed_action.shape[-1])
for dim in self.toggle_dimensions:
resolved_dim = dim if dim >= 0 else action_dim + dim
if resolved_dim < 0 or resolved_dim >= action_dim:
raise ValueError(
f"FastWAM action toggle dimension {dim} is out of bounds for action dim {action_dim}."
)
value = processed_action[..., resolved_dim]
value = value * 2.0 - 1.0
processed_action[..., resolved_dim] = torch.sign(-value)
return processed_action
def get_config(self) -> dict[str, Any]:
return {"toggle_dimensions": self.toggle_dimensions}
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
return features
def make_fastwam_pre_post_processors(
config: FastWAMConfig,
dataset_stats: dict[str, dict[str, torch.Tensor]] | None = None,
) -> tuple[PolicyProcessorPipeline, PolicyProcessorPipeline]:
"""Create LeRobot pre- and post-processing pipelines for FastWAM.
Args:
config (FastWAMConfig): Policy configuration controlling device and
normalization feature metadata.
dataset_stats (dict[str, dict[str, torch.Tensor]] | None): Optional
LeRobot dataset statistics used by normalization processors.
Returns:
tuple[PolicyProcessorPipeline, PolicyProcessorPipeline]: Input and
output processor pipelines discoverable by LeRobot.
"""
# NOTE: no visual normalization here. VISUAL is IDENTITY (see configuration_fastwam.normalization_mapping)
# — images pass through in [0, 1] and the model maps them to the Wan VAE's [-1, 1] at the encode
# boundary. This is deliberate: `lerobot_train.py` overrides the normalizer stats with
# `dataset.meta.stats` when fine-tuning, and a real dataset's per-channel image std is the tiny
# frame-to-frame brightness variance, which would blow images far outside [-1,1] and saturate them.
# STATE/ACTION still normalize with dataset stats below.
normalization_stats: dict[str, dict[str, Any]] = dict(dataset_stats or {})
# NOTE: no resize step here. The model is the single authority on input resolution: it resizes
# each camera to the per-camera target (image_size split across cameras) in
# `_stack_video_from_images` / `_prepare_infer_image`, on every path (train forward, rollout and
# eval select_action). A preprocessor resize step would be both redundant (the model re-resizes
# anyway) and unsafe across fine-tuning: its `resize_size` would be inherited from the base
# checkpoint's camera geometry, not this dataset's, making the concatenation N_cameras x too wide.
input_steps = [
RenameObservationsProcessorStep(rename_map={}),
AddBatchDimensionProcessorStep(),
DeviceProcessorStep(device=config.device),
NormalizerProcessorStep(
features={**config.input_features, **config.output_features},
norm_map=config.normalization_mapping,
stats=normalization_stats,
device=config.device,
),
]
output_steps = [
UnnormalizerProcessorStep(
features=config.output_features,
norm_map=config.normalization_mapping,
stats=normalization_stats,
),
]
if config.toggle_action_dimensions:
output_steps.append(
FastWAMActionToggleProcessorStep(toggle_dimensions=config.toggle_action_dimensions)
)
output_steps.append(DeviceProcessorStep(device="cpu"))
return (
PolicyProcessorPipeline[dict[str, Any], dict[str, Any]](
steps=input_steps,
name=POLICY_PREPROCESSOR_DEFAULT_NAME,
),
PolicyProcessorPipeline[PolicyAction, PolicyAction](
steps=output_steps,
name=POLICY_POSTPROCESSOR_DEFAULT_NAME,
to_transition=policy_action_to_transition,
to_output=transition_to_policy_action,
),
)
@@ -0,0 +1,34 @@
# FastWAM `wan` package
This package holds FastWAM's model implementation. It mixes a small **vendored
subset of the official Wan2.2 source tree** with FastWAM's own code, kept flat in
a single directory.
## Vendored from Wan2.2
- Upstream repository: https://github.com/Wan-Video/Wan2.2
- Upstream commit: `42bf4cfaa384bc21833865abc2f9e6c0e67233dc`
- License: Apache-2.0, matching the license in `LICENSE.txt` from the upstream repository
Copied files:
- `model.py` (was `wan/modules/model.py`), trimmed: the flash-attention path
(the vendored `attention.py` and the block/model `forward`s) was removed.
FastWAM's DiT uses SDPA instead (see `video_dit.py`).
- `get_sampling_sigmas` in `video_dit.py` (was `wan/utils/fm_solvers.py`), inlined
next to its only caller.
This subset only backs FastWAM's **custom MoT video DiT**. The Wan2.2 VAE,
UMT5 text encoder, and tokenizer are no longer vendored - they come from
`diffusers.AutoencoderKLWan`, `transformers.UMT5EncoderModel`, and
`transformers.AutoTokenizer` (see `components.py` and `adapters.py`).
## FastWAM's own code
- `video_dit.py` builds on `model` (`sinusoidal_embedding_1d`, `rope_params`,
`rope_apply`, …) and computes attention with SDPA (`fastwam_masked_attention`). Its
`WanContinuousFlowMatchScheduler` uses `get_sampling_sigmas` for Wan-compatible
inference timesteps.
- `components.py` / `adapters.py` load the VAE, text encoder, tokenizer, and the
custom DiT weights.
- `modular.py` defines the FastWAM model (`ActionDiT`, `MoT`, `FastWAM`, …).
@@ -0,0 +1,33 @@
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .adapters import WanVideoVAE38
from .components import (
build_wan_tokenizer,
load_pretrained_wan_text_encoder,
load_pretrained_wan_vae,
)
from .modular import ActionDiT, FastWAM, MoT
from .video_dit import WanVideoDiT
__all__ = [
"ActionDiT",
"FastWAM",
"MoT",
"WanVideoDiT",
"WanVideoVAE38",
"build_wan_tokenizer",
"load_pretrained_wan_text_encoder",
"load_pretrained_wan_vae",
]
@@ -0,0 +1,108 @@
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from typing import TYPE_CHECKING
import torch
if TYPE_CHECKING:
from diffusers import AutoencoderKLWan
class WanVideoVAE38(torch.nn.Module):
"""FastWAM VAE contract over `diffusers.AutoencoderKLWan` (Wan2.2-TI2V-5B).
16x spatial / 4x temporal compression, 48 latent channels. diffusers'
`AutoencoderKLWan` returns *raw* latents (it does not apply `latents_mean`/
`latents_std`), so `encode`/`decode` here apply the same standardization the
Wan reference uses `(latents - mean) / std` done in fp32 for stability.
`encode` uses the deterministic posterior mode, matching the original VAE
which returned the latent mean `mu`.
"""
upsampling_factor = 16
temporal_downsample_factor = 4
z_dim = 48
def __init__(
self,
dtype: torch.dtype = torch.float32,
device: str | torch.device = "cuda",
*,
pretrained: AutoencoderKLWan,
) -> None:
super().__init__()
# The Wan2.2 VAE is a fixed pretrained model — it is never trained from scratch,
# so a real `AutoencoderKLWan` (with weights) must always be supplied (loaded from
# the diffusers repo by `load_pretrained_wan_vae`). No random/offline build path.
self.vae = pretrained.to(device=device, dtype=dtype)
# Read the standardization stats from the VAE's own config (diffusers populates
# these from vae/config.json) — single source of truth, no local copy. diffusers'
# encode/decode return *raw* latents, so we apply (latent - mean) / std ourselves.
# Non-persistent: kept out of state_dict.
self.register_buffer(
"latents_mean",
torch.tensor(self.vae.config.latents_mean).view(1, self.z_dim, 1, 1, 1),
persistent=False,
)
self.register_buffer(
"latents_std",
torch.tensor(self.vae.config.latents_std).view(1, self.z_dim, 1, 1, 1),
persistent=False,
)
def _device_dtype(self) -> tuple[torch.device, torch.dtype]:
param = next(self.vae.parameters())
return param.device, param.dtype
def encode(
self,
videos: list[torch.Tensor] | torch.Tensor,
device: str | torch.device | None = None,
tiled: bool = False,
tile_size: tuple[int, int] = (34, 34),
tile_stride: tuple[int, int] = (18, 16),
) -> torch.Tensor:
del device, tile_size, tile_stride
if tiled:
raise NotImplementedError("Tiled Wan2.2 VAE encoding is not supported by the FastWAM adapter.")
if isinstance(videos, (list, tuple)):
videos = torch.stack(list(videos))
dev, dtype = self._device_dtype()
mu = self.vae.encode(videos.to(device=dev, dtype=dtype)).latent_dist.mode().float()
mean = self.latents_mean.float().to(mu.device)
std = self.latents_std.float().to(mu.device)
return (mu - mean) / std
def decode(
self,
hidden_states: list[torch.Tensor] | torch.Tensor,
device: str | torch.device | None = None,
tiled: bool = False,
tile_size: tuple[int, int] = (34, 34),
tile_stride: tuple[int, int] = (18, 16),
) -> torch.Tensor:
del device, tile_size, tile_stride
if tiled:
raise NotImplementedError("Tiled Wan2.2 VAE decoding is not supported by the FastWAM adapter.")
if isinstance(hidden_states, (list, tuple)):
hidden_states = torch.stack(list(hidden_states))
dev, dtype = self._device_dtype()
z = hidden_states.float()
z = z * self.latents_std.float().to(z.device) + self.latents_mean.float().to(z.device)
out = self.vae.decode(z.to(device=dev, dtype=dtype)).sample
return out.float().clamp_(-1.0, 1.0)
@@ -0,0 +1,175 @@
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import logging
from collections.abc import Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any
import torch
from huggingface_hub import snapshot_download
from safetensors.torch import load_file
from lerobot.utils.import_utils import _diffusers_available, _transformers_available, require_package
if TYPE_CHECKING or _transformers_available:
from transformers import AutoTokenizer, UMT5EncoderModel
else:
AutoTokenizer = None
UMT5EncoderModel = None
if TYPE_CHECKING or _diffusers_available:
from diffusers import AutoencoderKLWan
else:
AutoencoderKLWan = None
from .adapters import WanVideoVAE38
from .video_dit import WanVideoDiT
logger = logging.getLogger(__name__)
# The custom MoT video DiT still ships in the original (non-diffusers) Wan2.2
# repo as sharded `diffusion_pytorch_model*.safetensors`; the VAE and UMT5 text
# encoder come from the diffusers conversion. Tokenizer is the stock UMT5 one.
WAN_DIT_PATTERN = "diffusion_pytorch_model*.safetensors"
WAN_T5_TOKENIZER = "google/umt5-xxl"
WAN22_DIFFUSERS_MODEL_ID = "Wan-AI/Wan2.2-TI2V-5B-Diffusers"
class WanTextEncoder(torch.nn.Module):
"""FastWAM text-encoder contract over `transformers.UMT5EncoderModel`.
Exposes `.dim` (hidden size) and `forward(ids, mask) -> [B, L, dim]`, matching
the call in `FastWAM.encode_prompt`.
"""
def __init__(
self,
dtype: torch.dtype = torch.bfloat16,
device: str | torch.device = "cuda",
*,
pretrained: torch.nn.Module,
) -> None:
super().__init__()
# UMT5-XXL is a fixed pretrained encoder — never trained from scratch, so a real
# `UMT5EncoderModel` (with weights) must always be supplied (loaded from the
# diffusers repo by `load_pretrained_wan_text_encoder`). No random/offline build.
self.model = pretrained.to(device=device, dtype=dtype)
self.dim = int(self.model.config.d_model)
def forward(self, ids: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
return self.model(input_ids=ids, attention_mask=mask.long()).last_hidden_state
class WanTokenizer:
"""UMT5 tokenizer wrapper returning `(input_ids, attention_mask)` like the
FastWAM call site expects."""
def __init__(self, name: str = WAN_T5_TOKENIZER, seq_len: int = 512) -> None:
require_package("transformers", extra="fastwam")
self.tokenizer = AutoTokenizer.from_pretrained(name)
self.seq_len = int(seq_len)
def __call__(
self,
sequence: str | Sequence[str],
return_mask: bool = False,
add_special_tokens: bool = True,
**_: Any,
):
if isinstance(sequence, str):
sequence = [sequence]
out = self.tokenizer(
list(sequence),
padding="max_length",
truncation=True,
max_length=self.seq_len,
add_special_tokens=add_special_tokens,
return_tensors="pt",
)
if return_mask:
return out.input_ids, out.attention_mask
return out.input_ids
def build_wan_tokenizer(*, model_id: str = WAN_T5_TOKENIZER, tokenizer_max_len: int) -> WanTokenizer:
return WanTokenizer(name=model_id, seq_len=int(tokenizer_max_len))
def load_pretrained_wan_vae(*, torch_dtype: torch.dtype, device: str) -> WanVideoVAE38:
"""Load real Wan2.2 VAE weights from the diffusers repo (offline base creation)."""
require_package("diffusers", extra="fastwam")
vae = AutoencoderKLWan.from_pretrained(WAN22_DIFFUSERS_MODEL_ID, subfolder="vae", torch_dtype=torch_dtype)
return WanVideoVAE38(dtype=torch_dtype, device=device, pretrained=vae)
def load_pretrained_wan_text_encoder(
*,
model_id: str = WAN22_DIFFUSERS_MODEL_ID,
subfolder: str | None = "text_encoder",
torch_dtype: torch.dtype,
device: str,
) -> WanTextEncoder:
"""Load UMT5-XXL encoder weights (defaults to the Wan2.2 diffusers repo).
Must stay compatible with the tokenizer (see `build_wan_tokenizer`): the encoder's
embedding table is indexed by the tokenizer's vocabulary.
"""
require_package("transformers", extra="fastwam")
encoder = UMT5EncoderModel.from_pretrained(model_id, subfolder=subfolder, torch_dtype=torch_dtype)
return WanTextEncoder(dtype=torch_dtype, device=device, pretrained=encoder)
def resolve_wan_dit_paths(
model_id_or_path: str | Path,
*,
cache_dir: str | Path | None = None,
local_files_only: bool = False,
revision: str | None = None,
) -> list[Path]:
"""Resolve the custom MoT DiT shards from the original Wan2.2 repo or a local dir."""
path = Path(model_id_or_path).expanduser()
if path.is_dir():
return sorted(path.glob(WAN_DIT_PATTERN))
snapshot_path = snapshot_download(
repo_id=str(model_id_or_path),
revision=revision,
cache_dir=cache_dir,
local_files_only=local_files_only,
allow_patterns=[WAN_DIT_PATTERN],
)
return sorted(Path(snapshot_path).glob(WAN_DIT_PATTERN))
def load_wan_video_dit(
paths: list[str | Path],
*,
dit_config: dict[str, Any],
torch_dtype: torch.dtype,
device: str,
) -> WanVideoDiT:
model = WanVideoDiT(**dit_config)
state_dict = _read_wan_dit_safetensors(paths)
model.load_state_dict(state_dict, strict=False)
return model.to(device=device, dtype=torch_dtype)
def _read_wan_dit_safetensors(paths: list[str | Path]) -> dict[str, torch.Tensor]:
state_dict = {}
for path in paths:
state_dict.update(load_file(str(path), device="cpu"))
return state_dict
+341
View File
@@ -0,0 +1,341 @@
# Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved.
import math
import torch
import torch.nn as nn
def sinusoidal_embedding_1d(dim, position):
# preprocess
if dim % 2 != 0:
raise ValueError(f"dim must be even, got {dim}.")
half = dim // 2
position = position.type(torch.float64)
# calculation
sinusoid = torch.outer(position, torch.pow(10000, -torch.arange(half).to(position).div(half)))
x = torch.cat([torch.cos(sinusoid), torch.sin(sinusoid)], dim=1)
return x
@torch.amp.autocast("cuda", enabled=False)
def rope_params(max_seq_len, dim, theta=10000):
if dim % 2 != 0:
raise ValueError(f"dim must be even, got {dim}.")
freqs = torch.outer(
torch.arange(max_seq_len), 1.0 / torch.pow(theta, torch.arange(0, dim, 2).to(torch.float64).div(dim))
)
freqs = torch.polar(torch.ones_like(freqs), freqs)
return freqs
@torch.amp.autocast("cuda", enabled=False)
def rope_apply(x, grid_sizes, freqs):
n, c = x.size(2), x.size(3) // 2
# split freqs
freqs = freqs.split([c - 2 * (c // 3), c // 3, c // 3], dim=1)
# loop over samples
output = []
for i, (f, h, w) in enumerate(grid_sizes.tolist()):
seq_len = f * h * w
# precompute multipliers
x_i = torch.view_as_complex(x[i, :seq_len].to(torch.float64).reshape(seq_len, n, -1, 2))
freqs_i = torch.cat(
[
freqs[0][:f].view(f, 1, 1, -1).expand(f, h, w, -1),
freqs[1][:h].view(1, h, 1, -1).expand(f, h, w, -1),
freqs[2][:w].view(1, 1, w, -1).expand(f, h, w, -1),
],
dim=-1,
).reshape(seq_len, 1, -1)
# apply rotary embedding
x_i = torch.view_as_real(x_i * freqs_i).flatten(2)
x_i = torch.cat([x_i, x[i, seq_len:]])
# append to collection
output.append(x_i)
return torch.stack(output).float()
class WanRMSNorm(nn.Module):
def __init__(self, dim, eps=1e-5):
super().__init__()
self.dim = dim
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim))
def forward(self, x):
r"""
Args:
x(Tensor): Shape [B, L, C]
"""
return self._norm(x.float()).type_as(x) * self.weight
def _norm(self, x):
return x * torch.rsqrt(x.pow(2).mean(dim=-1, keepdim=True) + self.eps)
class WanLayerNorm(nn.LayerNorm):
def __init__(self, dim, eps=1e-6, elementwise_affine=False):
super().__init__(dim, elementwise_affine=elementwise_affine, eps=eps)
def forward(self, x):
r"""
Args:
x(Tensor): Shape [B, L, C]
"""
return super().forward(x.float()).type_as(x)
class WanSelfAttention(nn.Module):
def __init__(self, dim, num_heads, qk_norm=True, eps=1e-6):
if dim % num_heads != 0:
raise ValueError(f"dim ({dim}) must be divisible by num_heads ({num_heads}).")
super().__init__()
self.num_heads = num_heads
self.head_dim = dim // num_heads
# layers
self.q = nn.Linear(dim, dim)
self.k = nn.Linear(dim, dim)
self.v = nn.Linear(dim, dim)
self.o = nn.Linear(dim, dim)
self.norm_q = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity()
self.norm_k = WanRMSNorm(dim, eps=eps) if qk_norm else nn.Identity()
# NOTE: FastWAM never runs the upstream Wan attention forward. FastWAMAttentionBlock
# reuses only the q/k/v/o/norm submodules defined above and computes attention via
# `fastwam_masked_attention` (SDPA). The original flash-attention forward was removed,
# which also collapsed the former WanCrossAttention subclass into this class (it only
# differed by its forward): self- and cross-attention now share the same projection module.
class WanAttentionBlock(nn.Module):
def __init__(self, dim, ffn_dim, num_heads, qk_norm=True, cross_attn_norm=False, eps=1e-6):
super().__init__()
self.dim = dim
self.ffn_dim = ffn_dim
self.num_heads = num_heads
self.qk_norm = qk_norm
self.cross_attn_norm = cross_attn_norm
self.eps = eps
# layers
self.norm1 = WanLayerNorm(dim, eps)
self.self_attn = WanSelfAttention(dim, num_heads, qk_norm, eps)
self.norm3 = WanLayerNorm(dim, eps, elementwise_affine=True) if cross_attn_norm else nn.Identity()
self.cross_attn = WanSelfAttention(dim, num_heads, qk_norm, eps)
self.norm2 = WanLayerNorm(dim, eps)
self.ffn = nn.Sequential(
nn.Linear(dim, ffn_dim), nn.GELU(approximate="tanh"), nn.Linear(ffn_dim, dim)
)
# modulation
self.modulation = nn.Parameter(torch.randn(1, 6, dim) / dim**0.5)
# NOTE: The upstream Wan block forward (self-attention + cross-attention + FFN via
# flash-attention) was removed. FastWAM subclasses this block as FastWAMAttentionBlock
# and overrides forward to use SDPA with explicit boolean masks; only __init__ (the
# norm/attention/ffn submodules) is reused here.
class Head(nn.Module):
def __init__(self, dim, out_dim, patch_size, eps=1e-6):
super().__init__()
self.dim = dim
self.out_dim = out_dim
self.patch_size = patch_size
self.eps = eps
# layers
out_dim = math.prod(patch_size) * out_dim
self.norm = WanLayerNorm(dim, eps)
self.head = nn.Linear(dim, out_dim)
# modulation
self.modulation = nn.Parameter(torch.randn(1, 2, dim) / dim**0.5)
def forward(self, x, e):
r"""
Args:
x(Tensor): Shape [B, L1, C]
e(Tensor): Shape [B, L1, C]
"""
with torch.amp.autocast("cuda", dtype=torch.float32):
e = (self.modulation.unsqueeze(0) + e.unsqueeze(2)).chunk(2, dim=2)
x = self.head(self.norm(x) * (1 + e[1].squeeze(2)) + e[0].squeeze(2))
return x
class WanModel(nn.Module):
r"""
Wan diffusion backbone supporting both text-to-video and image-to-video.
"""
def __init__(
self,
model_type="t2v",
patch_size=(1, 2, 2),
text_len=512,
in_dim=16,
dim=2048,
ffn_dim=8192,
freq_dim=256,
text_dim=4096,
out_dim=16,
num_heads=16,
num_layers=32,
qk_norm=True,
cross_attn_norm=True,
eps=1e-6,
):
r"""
Initialize the diffusion model backbone.
Args:
model_type (`str`, *optional*, defaults to 't2v'):
Model variant - 't2v' (text-to-video) or 'i2v' (image-to-video)
patch_size (`tuple`, *optional*, defaults to (1, 2, 2)):
3D patch dimensions for video embedding (t_patch, h_patch, w_patch)
text_len (`int`, *optional*, defaults to 512):
Fixed length for text embeddings
in_dim (`int`, *optional*, defaults to 16):
Input video channels (C_in)
dim (`int`, *optional*, defaults to 2048):
Hidden dimension of the transformer
ffn_dim (`int`, *optional*, defaults to 8192):
Intermediate dimension in feed-forward network
freq_dim (`int`, *optional*, defaults to 256):
Dimension for sinusoidal time embeddings
text_dim (`int`, *optional*, defaults to 4096):
Input dimension for text embeddings
out_dim (`int`, *optional*, defaults to 16):
Output video channels (C_out)
num_heads (`int`, *optional*, defaults to 16):
Number of attention heads
num_layers (`int`, *optional*, defaults to 32):
Number of transformer blocks
qk_norm (`bool`, *optional*, defaults to True):
Enable query/key normalization
cross_attn_norm (`bool`, *optional*, defaults to False):
Enable cross-attention normalization
eps (`float`, *optional*, defaults to 1e-6):
Epsilon value for normalization layers
"""
super().__init__()
if model_type not in ["t2v", "i2v", "ti2v", "s2v"]:
raise ValueError(f"model_type must be one of ['t2v', 'i2v', 'ti2v', 's2v'], got {model_type!r}.")
self.model_type = model_type
self.patch_size = patch_size
self.text_len = text_len
self.in_dim = in_dim
self.dim = dim
self.ffn_dim = ffn_dim
self.freq_dim = freq_dim
self.text_dim = text_dim
self.out_dim = out_dim
self.num_heads = num_heads
self.num_layers = num_layers
self.qk_norm = qk_norm
self.cross_attn_norm = cross_attn_norm
self.eps = eps
# embeddings
self.patch_embedding = nn.Conv3d(in_dim, dim, kernel_size=patch_size, stride=patch_size)
self.text_embedding = nn.Sequential(
nn.Linear(text_dim, dim), nn.GELU(approximate="tanh"), nn.Linear(dim, dim)
)
self.time_embedding = nn.Sequential(nn.Linear(freq_dim, dim), nn.SiLU(), nn.Linear(dim, dim))
self.time_projection = nn.Sequential(nn.SiLU(), nn.Linear(dim, dim * 6))
# blocks
self.blocks = nn.ModuleList(
[
WanAttentionBlock(dim, ffn_dim, num_heads, qk_norm, cross_attn_norm, eps)
for _ in range(num_layers)
]
)
# head
self.head = Head(dim, out_dim, patch_size, eps)
# buffers (don't use register_buffer otherwise dtype will be changed in to())
if (dim % num_heads) != 0 or (dim // num_heads) % 2 != 0:
raise ValueError(
f"dim ({dim}) must be divisible by num_heads ({num_heads}) with an even head dim."
)
d = dim // num_heads
self.freqs = torch.cat(
[
rope_params(1024, d - 4 * (d // 6)),
rope_params(1024, 2 * (d // 6)),
rope_params(1024, 2 * (d // 6)),
],
dim=1,
)
# initialize weights
self.init_weights()
# NOTE: The upstream Wan diffusion forward (flash-attention based) was removed.
# FastWAM's WanVideoDiT subclasses this model, rebuilds `self.blocks` with
# FastWAMAttentionBlock, and provides its own SDPA-based forward. Only the
# constructor (embeddings, blocks, head, rope buffers) and the helpers below
# (unpatchify / init_weights) are reused. WanModel is never run directly.
def unpatchify(self, x, grid_sizes):
r"""
Reconstruct video tensors from patch embeddings.
Args:
x (List[Tensor]):
List of patchified features, each with shape [L, C_out * prod(patch_size)]
grid_sizes (Tensor):
Original spatial-temporal grid dimensions before patching,
shape [B, 3] (3 dimensions correspond to F_patches, H_patches, W_patches)
Returns:
List[Tensor]:
Reconstructed video tensors with shape [C_out, F, H / 8, W / 8]
"""
c = self.out_dim
out = []
for u, v in zip(x, grid_sizes.tolist(), strict=False):
u = u[: math.prod(v)].view(*v, *self.patch_size, c)
u = torch.einsum("fhwpqrc->cfphqwr", u)
u = u.reshape(c, *[i * j for i, j in zip(v, self.patch_size, strict=False)])
out.append(u)
return out
def init_weights(self):
r"""
Initialize model parameters using Xavier initialization.
"""
# basic init
for m in self.modules():
if isinstance(m, nn.Linear):
nn.init.xavier_uniform_(m.weight)
if m.bias is not None:
nn.init.zeros_(m.bias)
# init embeddings
nn.init.xavier_uniform_(self.patch_embedding.weight.flatten(1))
for m in self.text_embedding.modules():
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight, std=0.02)
for m in self.time_embedding.modules():
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight, std=0.02)
# init output layer
nn.init.zeros_(self.head.head.weight)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,800 @@
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from typing import Any
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as functional
from einops import rearrange
from .model import (
WanAttentionBlock,
WanLayerNorm,
WanModel,
WanRMSNorm,
rope_apply,
rope_params,
sinusoidal_embedding_1d,
)
logger = logging.getLogger(__name__)
def get_sampling_sigmas(sampling_steps, shift):
# Vendored from Wan2.2 (formerly wan/utils/fm_solvers.py); computes the
# noise-level (sigma) schedule for Wan-compatible flow-matching inference.
sigma = np.linspace(1, 0, sampling_steps + 1)[:sampling_steps]
sigma = shift * sigma / (1 + (shift - 1) * sigma)
return sigma
def create_custom_forward(module):
def custom_forward(*inputs, **kwargs):
return module(*inputs, **kwargs)
return custom_forward
def gradient_checkpoint_forward(
model,
use_gradient_checkpointing,
*args,
**kwargs,
):
if use_gradient_checkpointing:
model_output = torch.utils.checkpoint.checkpoint(
create_custom_forward(model),
*args,
**kwargs,
use_reentrant=False,
)
else:
model_output = model(*args, **kwargs)
return model_output
def fastwam_masked_attention(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
num_heads: int,
ctx_mask: torch.Tensor | None = None,
fp32_attention: bool = True,
) -> torch.Tensor:
"""FastWAM masked attention wrapper for MoT masks and CPU test coverage.
The official Wan attention implementation is still used as the source of
the projection/norm modules. This wrapper only replaces the final attention
kernel because FastWAM needs explicit boolean masks for video/action MoT
routing, while the upstream FlashAttention path accepts sequence lengths
but not arbitrary [query, key] masks.
"""
q = rearrange(q, "b s (n d) -> b n s d", n=num_heads)
k = rearrange(k, "b s (n d) -> b n s d", n=num_heads)
v = rearrange(v, "b s (n d) -> b n s d", n=num_heads)
if fp32_attention:
q = q.float()
k = k.float()
v = v.float()
else:
q = q.to(dtype=v.dtype)
k = k.to(dtype=v.dtype)
x = functional.scaled_dot_product_attention(q, k, v, attn_mask=ctx_mask)
return rearrange(x, "b n s d -> b s (n d)", n=num_heads)
def modulate(x: torch.Tensor, shift: torch.Tensor, scale: torch.Tensor):
return x * (1 + scale) + shift
class WanContinuousFlowMatchScheduler:
"""Continuous-time Flow-Matching scheduler with shift-based Wan sampling."""
def __init__(self, num_train_timesteps: int = 1000, shift: float = 5.0, eps: float = 1e-10):
if num_train_timesteps <= 0:
raise ValueError(f"`num_train_timesteps` must be positive, got {num_train_timesteps}")
if shift <= 0:
raise ValueError(f"`shift` must be positive, got {shift}")
self.num_train_timesteps = int(num_train_timesteps)
self.shift = float(shift)
self.eps = float(eps)
self._y_min, self._weight_norm_const = self._precompute_training_weight_stats()
@staticmethod
def _phi(u: torch.Tensor, shift: float) -> torch.Tensor:
return shift * u / (1.0 + (shift - 1.0) * u)
def _precompute_training_weight_stats(self) -> tuple[float, float]:
steps = self.num_train_timesteps
u_grid = torch.linspace(1.0, 0.0, steps + 1, dtype=torch.float64)[:-1]
t_grid = self._phi(u_grid, self.shift) * float(steps)
y_grid = torch.exp(-2.0 * ((t_grid - (steps / 2.0)) / steps) ** 2)
y_min = float(y_grid.min().item())
y_shifted_grid = y_grid - y_min
norm_const = float(y_shifted_grid.mean().item())
return y_min, norm_const
def sample_training_t(self, batch_size: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
if batch_size <= 0:
raise ValueError(f"`batch_size` must be positive, got {batch_size}")
u = torch.rand((batch_size,), device=device, dtype=torch.float32)
sigma = self._phi(u, self.shift)
timestep = sigma * float(self.num_train_timesteps)
return timestep.to(dtype=dtype)
def training_weight(self, timestep: torch.Tensor) -> torch.Tensor:
t = timestep.to(dtype=torch.float32)
steps = float(self.num_train_timesteps)
y = torch.exp(-2.0 * ((t - (steps / 2.0)) / steps) ** 2)
y_shifted = y - self._y_min
weight = y_shifted / (self._weight_norm_const + self.eps)
if weight.numel() == 1:
return weight.reshape(())
return weight
def add_noise(
self, original_samples: torch.Tensor, noise: torch.Tensor, timestep: torch.Tensor
) -> torch.Tensor:
sigma = (timestep / float(self.num_train_timesteps)).to(
original_samples.device, dtype=original_samples.dtype
)
if sigma.ndim == 0:
return (1 - sigma) * original_samples + sigma * noise
sigma = sigma.view(-1, *([1] * (original_samples.ndim - 1)))
return (1 - sigma) * original_samples + sigma * noise
@staticmethod
def training_target(sample: torch.Tensor, noise: torch.Tensor, timestep: torch.Tensor) -> torch.Tensor:
del timestep
return noise - sample
def build_inference_schedule(
self,
num_inference_steps: int,
device: torch.device,
dtype: torch.dtype,
shift_override: float | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
if num_inference_steps <= 0:
raise ValueError(f"`num_inference_steps` must be positive, got {num_inference_steps}")
shift = self.shift if shift_override is None else float(shift_override)
if shift <= 0:
raise ValueError(f"`shift` must be positive, got {shift}")
sigma_steps = torch.as_tensor(
get_sampling_sigmas(num_inference_steps, shift),
device=device,
dtype=torch.float32,
)
timesteps = sigma_steps * float(self.num_train_timesteps)
sigma_next = torch.cat([sigma_steps[1:], sigma_steps.new_zeros(1)])
deltas = sigma_next - sigma_steps
return timesteps.to(dtype=dtype), deltas.to(dtype=dtype)
@staticmethod
def step(model_output: torch.Tensor, delta: torch.Tensor, sample: torch.Tensor) -> torch.Tensor:
delta = delta.to(sample.device, dtype=sample.dtype)
if delta.ndim == 0:
return sample + model_output * delta
delta = delta.view(-1, *([1] * (sample.ndim - 1)))
return sample + model_output * delta
def precompute_freqs_cis(dim: int, end: int = 1024, theta: float = 10000.0):
return rope_params(end, dim, theta)
def apply_dense_rope(x: torch.Tensor, freqs: torch.Tensor, num_heads: int) -> torch.Tensor:
x = rearrange(x, "b s (n d) -> b s n d", n=num_heads)
x_out = torch.view_as_complex(x.to(torch.float32).reshape(x.shape[0], x.shape[1], x.shape[2], -1, 2))
freqs = freqs.to(torch.complex64) if freqs.device.type == "npu" else freqs
x_out = torch.view_as_real(x_out * freqs).flatten(2)
return x_out.to(x.dtype)
def _linear_input(linear: nn.Linear, x: torch.Tensor) -> torch.Tensor:
return x.to(dtype=linear.weight.dtype)
def _wan_layer_norm(norm: nn.Module, x: torch.Tensor) -> torch.Tensor:
if isinstance(norm, WanLayerNorm) and norm.weight is not None:
weight = norm.weight.float()
bias = norm.bias.float() if norm.bias is not None else None
return functional.layer_norm(x.float(), norm.normalized_shape, weight, bias, norm.eps).to(
dtype=x.dtype
)
return norm(x)
def create_group_causal_attn_mask(
num_temporal_groups: int, num_query_per_group: int, num_key_per_group: int, mode: str = "causal"
) -> torch.Tensor:
if mode not in ["causal", "group_diagonal"]:
raise ValueError(f"`mode` must be 'causal' or 'group_diagonal', got {mode}.")
if num_temporal_groups <= 0:
raise ValueError(f"`num_temporal_groups` must be positive, got {num_temporal_groups}.")
if num_query_per_group <= 0:
raise ValueError(f"`num_query_per_group` must be positive, got {num_query_per_group}.")
if num_key_per_group <= 0:
raise ValueError(f"`num_key_per_group` must be positive, got {num_key_per_group}.")
total_num_query_tokens = num_temporal_groups * num_query_per_group
total_num_key_tokens = num_temporal_groups * num_key_per_group
query_time_indices = torch.arange(num_temporal_groups).repeat_interleave(num_query_per_group).unsqueeze(1)
key_time_indices = torch.arange(num_temporal_groups).repeat_interleave(num_key_per_group).unsqueeze(0)
if mode == "causal":
attn_mask = query_time_indices >= key_time_indices
else:
attn_mask = query_time_indices == key_time_indices
if attn_mask.shape != (total_num_query_tokens, total_num_key_tokens):
raise RuntimeError("Attention mask shape mismatch.")
return attn_mask
class FastWAMAttentionBlock(WanAttentionBlock):
"""Wan attention block with FastWAM's arbitrary boolean mask support."""
def __init__(
self,
hidden_dim: int,
attn_head_dim: int,
num_heads: int,
ffn_dim: int,
eps: float = 1e-6,
fp32_attention: bool = True,
):
attention_dim = attn_head_dim * num_heads
if hidden_dim == attention_dim:
super().__init__(
dim=hidden_dim,
ffn_dim=ffn_dim,
num_heads=num_heads,
qk_norm=True,
cross_attn_norm=True,
eps=eps,
)
else:
nn.Module.__init__(self)
self.dim = hidden_dim
self.ffn_dim = ffn_dim
self.num_heads = num_heads
self.qk_norm = True
self.cross_attn_norm = True
self.eps = eps
self.norm1 = WanLayerNorm(hidden_dim, eps)
self.self_attn = _FastWAMProjectedAttention(hidden_dim, attention_dim, num_heads, eps)
self.norm3 = WanLayerNorm(hidden_dim, eps, elementwise_affine=True)
self.cross_attn = _FastWAMProjectedAttention(hidden_dim, attention_dim, num_heads, eps)
self.norm2 = WanLayerNorm(hidden_dim, eps)
self.ffn = nn.Sequential(
nn.Linear(hidden_dim, ffn_dim),
nn.GELU(approximate="tanh"),
nn.Linear(ffn_dim, hidden_dim),
)
self.modulation = nn.Parameter(torch.randn(1, 6, hidden_dim) / hidden_dim**0.5)
self.attn_head_dim = attn_head_dim
self.fp32_attention = bool(fp32_attention)
@staticmethod
def split_modulation(block, t_mod: torch.Tensor):
has_seq = len(t_mod.shape) == 4
chunk_dim = 2 if has_seq else 1
base_mod = block.modulation.to(dtype=t_mod.dtype, device=t_mod.device)
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = (base_mod + t_mod).chunk(
6, dim=chunk_dim
)
if has_seq:
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = (
shift_msa.squeeze(2),
scale_msa.squeeze(2),
gate_msa.squeeze(2),
shift_mlp.squeeze(2),
scale_mlp.squeeze(2),
gate_mlp.squeeze(2),
)
return shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp
def project_self_attention(
self, x: torch.Tensor, freqs: torch.Tensor | dict[str, torch.Tensor]
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
q = self.self_attn.norm_q(self.self_attn.q(x))
k = self.self_attn.norm_k(self.self_attn.k(x))
v = self.self_attn.v(x)
if isinstance(freqs, dict):
b, s = x.shape[:2]
q = rope_apply(
q.view(b, s, self.num_heads, self.attn_head_dim),
freqs["grid_sizes"],
freqs["freqs"],
).flatten(2)
k = rope_apply(
k.view(b, s, self.num_heads, self.attn_head_dim),
freqs["grid_sizes"],
freqs["freqs"],
).flatten(2)
else:
q = apply_dense_rope(q, freqs, self.num_heads)
k = apply_dense_rope(k, freqs, self.num_heads)
return q, k, v
def apply_cross_attention(
self, x: torch.Tensor, context: torch.Tensor, context_mask: torch.Tensor | None = None
) -> torch.Tensor:
if context_mask is not None and context_mask.dim() == 3:
context_mask = context_mask.unsqueeze(1)
attn = self.cross_attn
b, n, d = x.size(0), attn.num_heads, attn.head_dim
q = attn.norm_q(attn.q(x)).view(b, -1, n * d)
k = attn.norm_k(attn.k(context)).view(b, -1, n * d)
v = attn.v(context).view(b, -1, n * d)
x = fastwam_masked_attention(
q=q,
k=k,
v=v,
num_heads=n,
ctx_mask=context_mask,
fp32_attention=self.fp32_attention,
)
return attn.o(_linear_input(attn.o, x))
def project_self_attention_output(self, x: torch.Tensor) -> torch.Tensor:
return self.self_attn.o(_linear_input(self.self_attn.o, x))
def apply_norm1(self, x: torch.Tensor) -> torch.Tensor:
return _wan_layer_norm(self.norm1, x)
def apply_norm2(self, x: torch.Tensor) -> torch.Tensor:
return _wan_layer_norm(self.norm2, x)
def apply_norm3(self, x: torch.Tensor) -> torch.Tensor:
return _wan_layer_norm(self.norm3, x)
def forward(
self,
x: torch.Tensor,
context: torch.Tensor,
t_mod: torch.Tensor,
freqs: torch.Tensor,
context_mask: torch.Tensor | None = None,
self_attn_mask: torch.Tensor | None = None,
) -> torch.Tensor:
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.split_modulation(self, t_mod)
residual_x = x
attn_input = modulate(self.apply_norm1(x), shift_msa, scale_msa)
q, k, v = self.project_self_attention(attn_input, freqs)
y = fastwam_masked_attention(
q=q,
k=k,
v=v,
num_heads=self.num_heads,
ctx_mask=self_attn_mask,
fp32_attention=self.fp32_attention,
)
x = residual_x + gate_msa * self.project_self_attention_output(y)
x = x + self.apply_cross_attention(self.apply_norm3(x), context, context_mask=context_mask)
mlp_input = modulate(self.apply_norm2(x), shift_mlp, scale_mlp)
return x + gate_mlp * self.ffn(mlp_input)
class _FastWAMProjectedAttention(nn.Module):
def __init__(self, hidden_dim: int, attention_dim: int, num_heads: int, eps: float):
super().__init__()
self.dim = hidden_dim
self.num_heads = num_heads
self.head_dim = attention_dim // num_heads
self.q = nn.Linear(hidden_dim, attention_dim)
self.k = nn.Linear(hidden_dim, attention_dim)
self.v = nn.Linear(hidden_dim, attention_dim)
self.o = nn.Linear(attention_dim, hidden_dim)
self.norm_q = WanRMSNorm(attention_dim, eps=eps)
self.norm_k = WanRMSNorm(attention_dim, eps=eps)
class WanVideoDiT(WanModel):
def __init__(
self,
hidden_dim: int,
in_dim: int,
ffn_dim: int,
out_dim: int,
text_dim: int,
freq_dim: int,
eps: float,
patch_size: tuple[int, int, int],
num_heads: int,
attn_head_dim: int,
num_layers: int,
has_image_input: bool = False,
has_image_pos_emb: bool = False,
has_ref_conv: bool = False,
add_control_adapter: bool = False,
in_dim_control_adapter: int = 24,
seperated_timestep: bool = False,
require_vae_embedding: bool = False,
require_clip_embedding: bool = False,
fuse_vae_embedding_in_latents: bool = True,
action_conditioned: bool = False,
action_dim: int = 7,
action_group_causal_mask_mode="causal",
video_attention_mask_mode: str = "bidirectional",
use_gradient_checkpointing: bool = False,
fp32_attention: bool = True,
):
del in_dim_control_adapter
if has_image_input:
raise ValueError("FastWAM currently expects Wan2.2 TI2V latents with fused image conditioning.")
if has_image_pos_emb:
raise ValueError("FastWAM does not support extra image positional embeddings in WanVideoDiT.")
if has_ref_conv:
raise ValueError("FastWAM does not support reference convolutions in WanVideoDiT.")
if add_control_adapter:
raise ValueError("FastWAM does not support control adapters in WanVideoDiT.")
if require_clip_embedding:
raise ValueError("FastWAM does not support CLIP embedding conditioning in WanVideoDiT.")
if require_vae_embedding or not fuse_vae_embedding_in_latents:
raise ValueError("FastWAM expects VAE conditioning to be fused in latents.")
if attn_head_dim != hidden_dim // num_heads:
raise ValueError(
"`attn_head_dim` must match the upstream Wan head dimension `hidden_dim // num_heads`; "
f"got {attn_head_dim} vs {hidden_dim // num_heads}."
)
super().__init__(
model_type="ti2v",
patch_size=patch_size,
text_len=512,
in_dim=in_dim,
dim=hidden_dim,
ffn_dim=ffn_dim,
freq_dim=freq_dim,
text_dim=text_dim,
out_dim=out_dim,
num_heads=num_heads,
num_layers=num_layers,
qk_norm=True,
cross_attn_norm=True,
eps=eps,
)
self.blocks = torch.nn.ModuleList(
[
FastWAMAttentionBlock(
hidden_dim=hidden_dim,
attn_head_dim=attn_head_dim,
num_heads=num_heads,
ffn_dim=ffn_dim,
eps=eps,
fp32_attention=fp32_attention,
)
for _ in range(num_layers)
]
)
self.init_weights()
self.hidden_dim = hidden_dim
self.attn_head_dim = attn_head_dim
self.seperated_timestep = seperated_timestep
self.fuse_vae_embedding_in_latents = fuse_vae_embedding_in_latents
self.video_attention_mask_mode = str(video_attention_mask_mode)
self.action_conditioned = action_conditioned
self.action_dim = action_dim
self.fp32_attention = bool(fp32_attention)
if self.action_conditioned:
self.action_embedding = torch.nn.Linear(action_dim, hidden_dim)
self.action_group_causal_mask_mode = action_group_causal_mask_mode
self.use_gradient_checkpointing = use_gradient_checkpointing
if self.use_gradient_checkpointing:
logger.info(
"Using gradient checkpointing for DiT blocks. This will save memory but use more computation."
)
def patchify(self, x: torch.Tensor):
return self.patch_embedding(x)
def _validate_forward_inputs(
self,
x: torch.Tensor,
timestep: torch.Tensor,
context: torch.Tensor,
context_mask: torch.Tensor | None,
action: torch.Tensor | None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
if x.ndim != 5:
raise ValueError(f"`latents` must be 5D [B, C, T, H, W], got shape {tuple(x.shape)}")
num_latent_frames = x.shape[2]
if context.ndim != 3:
raise ValueError(f"`context` must be 3D [B, L, D], got shape {tuple(context.shape)}")
if timestep.ndim != 1:
raise ValueError(f"`timestep` must be 1D [B] or [1], got shape {tuple(timestep.shape)}")
if self.action_conditioned:
allow_text_only_single_frame = num_latent_frames == 1 and action is None
if not allow_text_only_single_frame:
if action is None:
raise ValueError("Action input is required for action-conditioned model.")
if action.ndim != 3:
raise ValueError(
f"`action` must be 3D [B, action_horizon, action_dim], got shape {tuple(action.shape)}"
)
if action.shape[2] != self.action_dim:
raise ValueError(
f"`action` last dimension must be {self.action_dim}, got {action.shape[2]}"
)
if num_latent_frames <= 1:
raise ValueError(
f"video length must be > 1 for action-conditioned model, got {num_latent_frames}"
)
if action.shape[1] % (num_latent_frames - 1) != 0:
raise ValueError(
"action horizon must be divisible by (num_latent_frames - 1), "
f"got action_horizon={action.shape[1]}"
)
if context_mask is None:
context_mask = torch.ones(
(context.shape[0], context.shape[1]), dtype=torch.bool, device=context.device
)
else:
if context_mask.ndim != 2:
raise ValueError(f"`context_mask` must be 2D [B, L], got shape {tuple(context_mask.shape)}")
if context_mask.shape[0] != context.shape[0] or context_mask.shape[1] != context.shape[1]:
raise ValueError(
"`context_mask` shape must match `context` shape [B, L], "
f"got {tuple(context_mask.shape)} vs {tuple(context.shape)}"
)
batch_size = x.shape[0]
if batch_size != context.shape[0]:
if not self.training and batch_size == 1:
x = x.expand(context.shape[0], -1, -1, -1, -1)
batch_size = context.shape[0]
else:
raise ValueError(
f"Batch mismatch between latents and context: {batch_size} vs {context.shape[0]}."
)
if timestep.shape[0] not in (1, batch_size):
raise ValueError(
f"`timestep` length must be 1 or batch_size({batch_size}), got {timestep.shape[0]}"
)
if timestep.shape[0] == 1 and batch_size > 1:
if self.training:
raise ValueError("During training, timestep length must match batch_size.")
timestep = timestep.expand(batch_size)
return x, timestep, context_mask
def build_video_to_video_mask(
self,
video_seq_len: int,
video_tokens_per_frame: int,
device: torch.device,
) -> torch.Tensor:
if video_seq_len <= 0:
raise ValueError(f"`video_seq_len` must be positive, got {video_seq_len}")
if video_tokens_per_frame <= 0:
raise ValueError(f"`video_tokens_per_frame` must be positive, got {video_tokens_per_frame}")
if self.video_attention_mask_mode == "bidirectional":
return torch.ones((video_seq_len, video_seq_len), dtype=torch.bool, device=device)
if self.video_attention_mask_mode == "per_frame_causal":
if video_seq_len % video_tokens_per_frame != 0:
raise ValueError(
"`video_seq_len` must be divisible by `video_tokens_per_frame` in `per_frame_causal` mode, "
f"got {video_seq_len} and {video_tokens_per_frame}"
)
num_video_frames = video_seq_len // video_tokens_per_frame
frame_causal = torch.tril(
torch.ones((num_video_frames, num_video_frames), dtype=torch.bool, device=device)
)
return frame_causal.repeat_interleave(video_tokens_per_frame, dim=0).repeat_interleave(
video_tokens_per_frame, dim=1
)
if self.video_attention_mask_mode == "first_frame_causal":
video_mask = torch.ones((video_seq_len, video_seq_len), dtype=torch.bool, device=device)
first_frame_tokens = min(video_tokens_per_frame, video_seq_len)
video_mask[:first_frame_tokens, first_frame_tokens:] = False
return video_mask
raise ValueError(f"Unsupported video attention mask mode: {self.video_attention_mask_mode}")
def pre_dit(
self,
x: torch.Tensor,
timestep: torch.Tensor,
context: torch.Tensor,
context_mask: torch.Tensor | None = None,
action: torch.Tensor | None = None,
fuse_vae_embedding_in_latents: bool = False,
) -> dict[str, Any]:
x, timestep, context_mask = self._validate_forward_inputs(
x=x,
timestep=timestep,
context=context,
context_mask=context_mask,
action=action,
)
model_dtype = self.patch_embedding.weight.dtype
x = x.to(dtype=model_dtype)
context = context.to(dtype=model_dtype)
if action is not None:
action = action.to(dtype=model_dtype)
batch_size = x.shape[0]
patch_h = int(self.patch_size[1])
patch_w = int(self.patch_size[2])
if x.shape[3] % patch_h != 0 or x.shape[4] % patch_w != 0:
raise ValueError(
"Latent spatial shape must be divisible by DiT patch size, "
f"got HxW=({x.shape[3]}, {x.shape[4]}), patch=({patch_h}, {patch_w})"
)
tokens_per_frame = (x.shape[3] // patch_h) * (x.shape[4] // patch_w)
if not (self.seperated_timestep and fuse_vae_embedding_in_latents):
raise NotImplementedError(
"FastWAM currently requires separated timesteps with fused VAE latents."
)
token_timesteps = torch.ones(
(batch_size, x.shape[2], tokens_per_frame),
dtype=model_dtype,
device=timestep.device,
) * timestep.to(dtype=model_dtype).view(batch_size, 1, 1)
token_timesteps[:, 0, :] = 0
token_timesteps = token_timesteps.reshape(batch_size, -1)
# Wan keeps the time embedding in fp32: the AdaLN modulation in the vendored
# Head/Block asserts e.dtype == float32 (numerical stability of the scale/shift).
# Upstream guarantees this via an fp32 autocast region, so it holds even when the
# model runs in bf16. Mirror that here, then cast the per-block modulation back to
# model_dtype so the bf16 attention blocks are not upcast to fp32.
with torch.amp.autocast("cuda", dtype=torch.float32):
token_t_emb = sinusoidal_embedding_1d(self.freq_dim, token_timesteps.reshape(-1)).float()
t = self.time_embedding(token_t_emb).reshape(batch_size, -1, self.hidden_dim)
t_mod = self.time_projection(t).unflatten(2, (6, self.hidden_dim))
t_mod = t_mod.to(dtype=model_dtype)
x = self.patchify(x)
f, h, w = x.shape[2:]
context = self.text_embedding(context)
context_len = context.shape[1]
if self.action_conditioned and action is not None:
action_len = action.shape[1]
action_emb = self.action_embedding(action)
action_pos_embed = sinusoidal_embedding_1d(
self.hidden_dim, torch.arange(action_len, device=action_emb.device)
).to(dtype=action_emb.dtype)
action_emb = action_emb + action_pos_embed.unsqueeze(0)
context = torch.cat([context, action_emb], dim=1)
num_temporal_groups = f - 1
if num_temporal_groups <= 0:
raise ValueError(
"Action-conditioned context mask requires at least 2 latent frames when `action` is provided."
)
if action_emb.shape[1] % num_temporal_groups != 0:
raise ValueError(
f"Action embedding length {action_emb.shape[1]} must be divisible by "
f"number of temporal groups {num_temporal_groups}"
)
action_group_mask = create_group_causal_attn_mask(
num_temporal_groups=num_temporal_groups,
num_query_per_group=tokens_per_frame,
num_key_per_group=action_len // num_temporal_groups,
mode=self.action_group_causal_mask_mode,
).to(context.device)
seq_len = f * h * w
final_context_mask = torch.zeros(
(batch_size, seq_len, context.shape[1]), dtype=torch.bool, device=context.device
)
final_context_mask[:, :, :context_len] = context_mask.unsqueeze(1).expand(-1, seq_len, -1)
final_context_mask[:, tokens_per_frame:, context_len:] = action_group_mask.unsqueeze(0).expand(
batch_size, -1, -1
)
context_mask = final_context_mask
elif self.action_conditioned and action is None:
if f != 1:
raise ValueError(
"Action-conditioned model requires `action` unless running single-frame text-only mode "
"with num_latent_frames=1."
)
context_mask = context_mask.unsqueeze(1).expand(-1, f * h * w, -1)
else:
context_mask = context_mask.unsqueeze(1).expand(-1, f * h * w, -1)
x_tokens = rearrange(x, "b c f h w -> b (f h w) c").contiguous()
grid_sizes = torch.tensor([[f, h, w]] * batch_size, dtype=torch.long, device=x_tokens.device)
freqs = {"grid_sizes": grid_sizes, "freqs": self.freqs.to(x_tokens.device)}
return {
"tokens": x_tokens,
"freqs": freqs,
"t": t,
"t_mod": t_mod,
"context": context,
"context_mask": context_mask,
"meta": {
"grid_sizes": grid_sizes,
"tokens_per_frame": tokens_per_frame,
"batch_size": batch_size,
},
}
def post_dit(self, x_tokens: torch.Tensor, pre_state: dict[str, Any]) -> torch.Tensor:
x = self.head(x_tokens, pre_state["t"])
return torch.stack(super().unpatchify(x, pre_state["meta"]["grid_sizes"]))
def forward(
self,
x: torch.Tensor,
timestep: torch.Tensor,
context: torch.Tensor,
context_mask: torch.Tensor | None = None,
action: torch.Tensor | None = None,
fuse_vae_embedding_in_latents: bool = False,
):
pre_state = self.pre_dit(
x=x,
timestep=timestep,
context=context,
context_mask=context_mask,
action=action,
fuse_vae_embedding_in_latents=fuse_vae_embedding_in_latents,
)
x_tokens = pre_state["tokens"]
context_emb = pre_state["context"]
t_mod = pre_state["t_mod"]
freqs = pre_state["freqs"]
context_attn_mask = pre_state["context_mask"]
self_attn_mask = (
self.build_video_to_video_mask(
video_seq_len=x_tokens.shape[1],
video_tokens_per_frame=int(pre_state["meta"]["tokens_per_frame"]),
device=x_tokens.device,
)
if self.video_attention_mask_mode != "bidirectional"
else None
)
for block in self.blocks:
if self.use_gradient_checkpointing:
x_tokens = gradient_checkpoint_forward(
block,
self.use_gradient_checkpointing,
x_tokens,
context_emb,
t_mod,
freqs,
context_mask=context_attn_mask,
self_attn_mask=self_attn_mask,
)
else:
x_tokens = block(
x_tokens,
context_emb,
t_mod,
freqs,
context_mask=context_attn_mask,
self_attn_mask=self_attn_mask,
)
return self.post_dit(x_tokens, pre_state)
+1 -1
View File
@@ -1 +1 @@
../../../../docs/source/policy_molmoact2_README.md ../../../../docs/source/molmoact2.mdx
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved. # Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved. # Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -16,16 +14,9 @@
from __future__ import annotations from __future__ import annotations
import json
import math
import os
from contextlib import suppress
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Any from typing import Any
from huggingface_hub import snapshot_download
from lerobot.configs import FeatureType, NormalizationMode, PolicyFeature, PreTrainedConfig from lerobot.configs import FeatureType, NormalizationMode, PolicyFeature, PreTrainedConfig
from lerobot.optim import ( from lerobot.optim import (
AdamWConfig, AdamWConfig,
@@ -37,146 +28,6 @@ from lerobot.utils.constants import ACTION, OBS_STATE
from ..rtc.configuration_rtc import RTCConfig from ..rtc.configuration_rtc import RTCConfig
MOLMOACT2_DEFAULT_NUM_IMAGES = 2
MOLMOACT2_IMAGE_TOKENS_PER_IMAGE = 196
MOLMOACT2_FIXED_PROMPT_TOKEN_BUDGET = 80
MOLMOACT2_TASK_TOKEN_BUDGET = 32
MOLMOACT2_SEQUENCE_LENGTH_MARGIN = 32
MOLMOACT2_SEQUENCE_LENGTH_MULTIPLE = 64
MOLMOACT2_DISCRETE_ACTION_WRAPPER_TOKENS = 4
MOLMOACT2_MIN_DISCRETE_ACTION_TOKENS_PER_STEP = 6
MOLMOACT2_DISCRETE_ACTION_TOKENS_PER_DIM = 0.95
def _hf_token() -> str | None:
return os.environ.get("HF_TOKEN") or os.environ.get("HF_ACCESS_TOKEN")
def _resolve_checkpoint_location(
checkpoint_path: str,
*,
revision: str | None = None,
force_download: bool = False,
) -> str:
checkpoint_path = str(checkpoint_path or "").strip()
if not checkpoint_path:
raise ValueError("MolmoAct2 policy requires `checkpoint_path`.")
local_path = Path(checkpoint_path).expanduser()
if local_path.exists():
return str(local_path)
return snapshot_download(
repo_id=checkpoint_path,
repo_type="model",
revision=revision,
force_download=force_download,
ignore_patterns=["*.py", "*.pyc", "__pycache__/*"],
token=_hf_token(),
)
def _load_hf_norm_metadata_for_tag(
checkpoint_path: str,
*,
revision: str | None,
force_download: bool,
norm_tag: str | None,
) -> dict[str, Any]:
norm_tag = str(norm_tag or "").strip()
if not norm_tag:
return {}
checkpoint_location = Path(
_resolve_checkpoint_location(
checkpoint_path,
revision=revision,
force_download=force_download,
)
)
norm_stats_filename = "norm_stats.json"
config_path = checkpoint_location / "config.json"
if config_path.exists():
with suppress(OSError, json.JSONDecodeError):
norm_stats_filename = str(
json.loads(config_path.read_text()).get("norm_stats_filename") or norm_stats_filename
)
stats_path = checkpoint_location / norm_stats_filename
if not stats_path.exists():
raise FileNotFoundError(
f"MolmoAct2 HF checkpoint is missing {norm_stats_filename!r}; cannot resolve norm_tag={norm_tag!r}."
)
payload = json.loads(stats_path.read_text())
metadata_by_tag = payload.get("metadata_by_tag")
if not isinstance(metadata_by_tag, dict):
raise ValueError(f"MolmoAct2 norm stats file {stats_path} has no metadata_by_tag mapping.")
metadata = metadata_by_tag.get(norm_tag)
if not isinstance(metadata, dict):
available = sorted(str(tag) for tag in metadata_by_tag)
raise ValueError(f"Unknown MolmoAct2 norm_tag={norm_tag!r}. Available tags: {available}.")
return metadata
@LRSchedulerConfig.register_subclass("molmoact2_cosine_decay_with_warmup")
@dataclass
class MolmoAct2CosineDecayWithWarmupSchedulerConfig(CosineDecayWithWarmupSchedulerConfig):
"""MolmoAct2-local cosine scheduler with optional decay-step auto-match.
LeRobot's generic cosine scheduler keeps an explicit integer decay length.
For MolmoAct2, leaving num_decay_steps unset means "decay across this run's
training steps"; build() is the first point where num_training_steps is known.
"""
num_decay_steps: int | None
def build(self, optimizer, num_training_steps: int):
return CosineDecayWithWarmupSchedulerConfig(
peak_lr=self.peak_lr,
decay_lr=self.decay_lr,
num_warmup_steps=self.num_warmup_steps,
num_decay_steps=num_training_steps if self.num_decay_steps is None else self.num_decay_steps,
).build(optimizer, num_training_steps=num_training_steps)
def _round_up(value: int, multiple: int) -> int:
return int(math.ceil(value / multiple) * multiple)
def infer_molmoact2_max_sequence_length(
*,
num_images: int,
state_dim: int,
action_dim: int,
action_horizon: int,
include_discrete_action: bool,
) -> int:
"""Infer the padded text/image sequence cap from MolmoAct2's fixed token layout."""
if num_images < 1:
num_images = MOLMOACT2_DEFAULT_NUM_IMAGES
if state_dim < 0:
state_dim = 0
if action_dim < 1:
action_dim = 1
if action_horizon < 1:
action_horizon = 1
image_tokens = num_images * MOLMOACT2_IMAGE_TOKENS_PER_IMAGE
prompt_tokens = (
MOLMOACT2_FIXED_PROMPT_TOKEN_BUDGET
+ MOLMOACT2_TASK_TOKEN_BUDGET
+ state_dim
+ MOLMOACT2_SEQUENCE_LENGTH_MARGIN
)
action_tokens = 0
if include_discrete_action:
action_tokens_per_step = max(
MOLMOACT2_MIN_DISCRETE_ACTION_TOKENS_PER_STEP,
math.ceil(action_dim * MOLMOACT2_DISCRETE_ACTION_TOKENS_PER_DIM),
)
action_tokens = MOLMOACT2_DISCRETE_ACTION_WRAPPER_TOKENS + action_horizon * action_tokens_per_step
return _round_up(
image_tokens + prompt_tokens + action_tokens,
MOLMOACT2_SEQUENCE_LENGTH_MULTIPLE,
)
@PreTrainedConfig.register_subclass("molmoact2") @PreTrainedConfig.register_subclass("molmoact2")
@dataclass @dataclass
@@ -228,6 +79,15 @@ class MolmoAct2Config(PreTrainedConfig):
eval_seed: int | None = None eval_seed: int | None = None
rtc_config: RTCConfig | None = None rtc_config: RTCConfig | None = None
# Joint frame transform for cross-calibration compatibility.
# Some MolmoAct2 checkpoints were trained on data using a different joint
# convention than the current LeRobot calibration. Set both to apply a
# sign/offset correction at runtime (state before model, action after).
# See: https://huggingface.co/docs/lerobot/backwardcomp
# Default is None (no transform). Both must be set together.
joint_signs: list[float] | None = None
joint_offsets: list[float] | None = None
# Default is full finetuning with gradients from the action expert flowing into the VLM. # Default is full finetuning with gradients from the action expert flowing into the VLM.
enable_lora_vlm: bool = False enable_lora_vlm: bool = False
lora_rank: int = 64 lora_rank: int = 64
@@ -255,7 +115,7 @@ class MolmoAct2Config(PreTrainedConfig):
optimizer_grad_clip_norm: float = 1.0 optimizer_grad_clip_norm: float = 1.0
scheduler_warmup_steps: int = 200 scheduler_warmup_steps: int = 200
scheduler_decay_steps: int | None = None scheduler_decay_steps: int = 100_000
scheduler_decay_lr: float = 1e-6 scheduler_decay_lr: float = 1e-6
normalization_mapping: dict[str, NormalizationMode] = field( normalization_mapping: dict[str, NormalizationMode] = field(
@@ -272,6 +132,10 @@ class MolmoAct2Config(PreTrainedConfig):
def __post_init__(self) -> None: def __post_init__(self) -> None:
super().__post_init__() super().__post_init__()
if (self.joint_signs is None) != (self.joint_offsets is None):
raise ValueError("joint_signs and joint_offsets must both be set or both be None.")
if self.joint_signs is not None and len(self.joint_signs) != len(self.joint_offsets):
raise ValueError("joint_signs and joint_offsets must have the same length.")
if self.action_mode not in {"continuous", "discrete", "both"}: if self.action_mode not in {"continuous", "discrete", "both"}:
raise ValueError( raise ValueError(
f"Unsupported action_mode={self.action_mode!r}. " f"Unsupported action_mode={self.action_mode!r}. "
@@ -333,41 +197,6 @@ class MolmoAct2Config(PreTrainedConfig):
if self.max_sequence_length is not None and self.max_sequence_length < 1: if self.max_sequence_length is not None and self.max_sequence_length < 1:
raise ValueError(f"max_sequence_length must be >= 1 or None, got {self.max_sequence_length}.") raise ValueError(f"max_sequence_length must be >= 1 or None, got {self.max_sequence_length}.")
def inferred_max_sequence_length(
self,
*,
num_images: int | None = None,
state_dim: int | None = None,
action_dim: int | None = None,
action_horizon: int | None = None,
include_discrete_action: bool | None = None,
) -> int:
if self.max_sequence_length is not None:
return int(self.max_sequence_length)
if num_images is None:
num_images = len(self.image_keys) or len(self.image_features) or MOLMOACT2_DEFAULT_NUM_IMAGES
if state_dim is None:
state_feature = self.robot_state_feature
state_dim = int(state_feature.shape[0]) if state_feature is not None else 0
if action_dim is None:
action_feature = self.action_feature
action_dim = (
int(action_feature.shape[0]) if action_feature is not None else self.expected_max_action_dim
)
if action_horizon is None:
action_horizon = self.chunk_size
if include_discrete_action is None:
include_discrete_action = self.action_mode in {"discrete", "both"}
return infer_molmoact2_max_sequence_length(
num_images=int(num_images),
state_dim=int(state_dim),
action_dim=int(action_dim),
action_horizon=int(action_horizon),
include_discrete_action=bool(include_discrete_action),
)
@property @property
def observation_delta_indices(self) -> None: def observation_delta_indices(self) -> None:
return None return None
@@ -390,7 +219,7 @@ class MolmoAct2Config(PreTrainedConfig):
) )
def get_scheduler_preset(self) -> LRSchedulerConfig | None: def get_scheduler_preset(self) -> LRSchedulerConfig | None:
return MolmoAct2CosineDecayWithWarmupSchedulerConfig( return CosineDecayWithWarmupSchedulerConfig(
peak_lr=self.optimizer_lr, peak_lr=self.optimizer_lr,
decay_lr=self.scheduler_decay_lr, decay_lr=self.scheduler_decay_lr,
num_warmup_steps=self.scheduler_warmup_steps, num_warmup_steps=self.scheduler_warmup_steps,
@@ -426,94 +255,3 @@ class MolmoAct2Config(PreTrainedConfig):
shape=(self.expected_max_action_dim,), shape=(self.expected_max_action_dim,),
) )
self.output_features[ACTION] = action_feature self.output_features[ACTION] = action_feature
def apply_norm_tag_metadata(self) -> None:
if not str(self.norm_tag or "").strip():
return
metadata = _load_hf_norm_metadata_for_tag(
self.checkpoint_path,
revision=self.checkpoint_revision,
force_download=bool(self.checkpoint_force_download),
norm_tag=self.norm_tag,
)
if metadata.get("action_horizon") is not None:
self.chunk_size = int(metadata["action_horizon"])
if metadata.get("n_action_steps") is not None:
self.n_action_steps = int(metadata["n_action_steps"])
if not self.setup_type and metadata.get("setup_type") is not None:
self.setup_type = str(metadata["setup_type"])
if not self.control_mode and metadata.get("control_mode") is not None:
self.control_mode = str(metadata["control_mode"])
def saved_policy_action_mode(self) -> str | None:
pretrained_path = getattr(self, "pretrained_path", None)
if pretrained_path is None:
return None
config_path = Path(pretrained_path) / "config.json"
if not config_path.exists():
return None
try:
mode = json.loads(config_path.read_text()).get("action_mode")
except (OSError, json.JSONDecodeError):
return None
if mode in {"continuous", "discrete", "both"}:
return str(mode)
return None
def training_action_mode(self, saved_policy_action_mode: str | None = None) -> str:
return saved_policy_action_mode or self.action_mode
def validate_inference_action_mode(self, saved_policy_action_mode: str | None = None) -> None:
requested_mode = self.inference_action_mode
if requested_mode is None:
return
training_mode = self.training_action_mode(saved_policy_action_mode)
if requested_mode == "continuous" and training_mode == "discrete":
raise ValueError(
"MolmoAct2 checkpoint was trained with action_mode='discrete' and cannot run "
"continuous inference."
)
if requested_mode == "discrete" and training_mode == "continuous":
raise ValueError(
"MolmoAct2 checkpoint was trained with action_mode='continuous' and cannot run "
"discrete inference. Train with action_mode='both' or action_mode='discrete' first."
)
def validate_checkpoint_action_mode(
self,
checkpoint_action_mode: str,
*,
has_action_expert: bool,
) -> None:
if self.action_mode == "both" and checkpoint_action_mode != "both":
raise ValueError(
f"action_mode='both' requires checkpoint action_mode='both', got {checkpoint_action_mode!r}."
)
if self.action_mode == "discrete" and checkpoint_action_mode not in {"discrete", "both"}:
raise ValueError(
f"action_mode='discrete' requires checkpoint action_mode in {{'discrete', 'both'}}, "
f"got {checkpoint_action_mode!r}."
)
if self.action_mode in {"continuous", "both"} and not has_action_expert:
raise ValueError("Continuous MolmoAct2 training requires an action expert checkpoint.")
def resolve_inference_action_mode(
self,
requested_mode: str | None,
saved_policy_action_mode: str | None = None,
) -> str:
training_mode = self.training_action_mode(saved_policy_action_mode)
if requested_mode is None:
requested_mode = self.inference_action_mode
if requested_mode is None:
raise ValueError(
"MolmoAct2 inference requires `inference_action_mode` to be set explicitly "
"to either 'continuous' or 'discrete'."
)
if requested_mode not in {"continuous", "discrete"}:
raise ValueError("MolmoAct2 inference_action_mode must be either 'continuous' or 'discrete'.")
if requested_mode == "continuous" and training_mode == "discrete":
raise ValueError("MolmoAct2 action_mode='discrete' checkpoint cannot run continuous inference.")
if requested_mode == "discrete" and training_mode == "continuous":
raise ValueError("MolmoAct2 action_mode='continuous' checkpoint cannot run discrete inference.")
return requested_mode
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved. # Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,9 +12,22 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""MolmoAct2 policy for LeRobot.
MolmoAct2 is a VLM-based robotics policy from Allen AI that combines a
Molmo vision-language backbone with a per-layer flow-matching action expert
for continuous action generation, plus an optional discrete action token
head. This module wraps the vendored HF model implementation
(``molmoact2_hf_model/``) into the LeRobot ``PreTrainedPolicy`` interface.
Paper: https://allenai.org/blog/molmoact2
Code: https://github.com/allenai/molmoact2
"""
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import os import os
import types import types
from collections import deque from collections import deque
@@ -35,13 +46,58 @@ from lerobot.utils.constants import ACTION
from lerobot.utils.import_utils import _scipy_available, _transformers_available, require_package from lerobot.utils.import_utils import _scipy_available, _transformers_available, require_package
from ..rtc.modeling_rtc import RTCProcessor from ..rtc.modeling_rtc import RTCProcessor
from .configuration_molmoact2 import MolmoAct2Config, _hf_token, _resolve_checkpoint_location from .configuration_molmoact2 import MolmoAct2Config
logger = logging.getLogger(__name__)
def _hf_token() -> str | None:
return os.environ.get("HF_TOKEN") or os.environ.get("HF_ACCESS_TOKEN")
def _resolve_checkpoint_location(
checkpoint_path: str,
*,
revision: str | None = None,
force_download: bool = False,
) -> str:
"""Resolve a checkpoint path to a local directory, downloading from Hub if needed."""
checkpoint_path = str(checkpoint_path or "").strip()
if not checkpoint_path:
raise ValueError("MolmoAct2 policy requires `checkpoint_path`.")
from pathlib import Path
local_path = Path(checkpoint_path).expanduser()
if local_path.exists():
return str(local_path)
from huggingface_hub import snapshot_download
return snapshot_download(
repo_id=checkpoint_path,
repo_type="model",
revision=revision,
force_download=force_download,
ignore_patterns=["*.py", "*.pyc", "__pycache__/*"],
token=_hf_token(),
)
def _torch_dtype(dtype: str) -> torch.dtype:
"""Convert a dtype name string to a torch.dtype."""
if dtype == "float32":
return torch.float32
if dtype == "bfloat16":
return torch.bfloat16
if dtype == "float16":
return torch.float16
raise ValueError(f"Unsupported dtype: {dtype}")
if TYPE_CHECKING or _transformers_available: if TYPE_CHECKING or _transformers_available:
from transformers.utils import SAFE_WEIGHTS_INDEX_NAME, SAFE_WEIGHTS_NAME from transformers.utils import SAFE_WEIGHTS_INDEX_NAME, SAFE_WEIGHTS_NAME
from .hf_model.configuration_molmoact2 import MolmoAct2Config as HFMolmoAct2Config from .molmoact2_hf_model.configuration_molmoact2 import MolmoAct2Config as HFMolmoAct2Config
from .hf_model.modeling_molmoact2 import MolmoAct2ForConditionalGeneration from .molmoact2_hf_model.modeling_molmoact2 import MolmoAct2ForConditionalGeneration
else: else:
SAFE_WEIGHTS_INDEX_NAME = "model.safetensors.index.json" SAFE_WEIGHTS_INDEX_NAME = "model.safetensors.index.json"
SAFE_WEIGHTS_NAME = "model.safetensors" SAFE_WEIGHTS_NAME = "model.safetensors"
@@ -49,7 +105,7 @@ else:
MolmoAct2ForConditionalGeneration = None MolmoAct2ForConditionalGeneration = None
if TYPE_CHECKING or (_transformers_available and _scipy_available): if TYPE_CHECKING or (_transformers_available and _scipy_available):
from .hf_model.action_tokenizer import UniversalActionProcessor from .molmoact2_hf_model.action_tokenizer import UniversalActionProcessor
else: else:
UniversalActionProcessor = None UniversalActionProcessor = None
@@ -70,6 +126,156 @@ _MODEL_INPUT_KEYS = {
} }
def _load_hf_norm_metadata_for_tag(
checkpoint_path: str,
*,
revision: str | None,
force_download: bool,
norm_tag: str | None,
) -> dict[str, Any]:
"""Read per-tag metadata from the checkpoint's ``norm_stats.json``."""
norm_tag = str(norm_tag or "").strip()
if not norm_tag:
return {}
from contextlib import suppress
from pathlib import Path
checkpoint_location = Path(
_resolve_checkpoint_location(
checkpoint_path,
revision=revision,
force_download=force_download,
)
)
norm_stats_filename = "norm_stats.json"
config_path = checkpoint_location / "config.json"
if config_path.exists():
with suppress(OSError, json.JSONDecodeError):
norm_stats_filename = str(
json.loads(config_path.read_text()).get("norm_stats_filename") or norm_stats_filename
)
stats_path = checkpoint_location / norm_stats_filename
if not stats_path.exists():
raise FileNotFoundError(
f"MolmoAct2 HF checkpoint is missing {norm_stats_filename!r}; cannot resolve norm_tag={norm_tag!r}."
)
payload = json.loads(stats_path.read_text())
metadata_by_tag = payload.get("metadata_by_tag")
if not isinstance(metadata_by_tag, dict):
raise ValueError(f"MolmoAct2 norm stats file {stats_path} has no metadata_by_tag mapping.")
metadata = metadata_by_tag.get(norm_tag)
if not isinstance(metadata, dict):
available = sorted(str(tag) for tag in metadata_by_tag)
raise ValueError(f"Unknown MolmoAct2 norm_tag={norm_tag!r}. Available tags: {available}.")
return metadata
def _apply_norm_tag_metadata(config: MolmoAct2Config) -> None:
"""Populate config fields from the checkpoint's norm-tag metadata."""
if not str(config.norm_tag or "").strip():
return
metadata = _load_hf_norm_metadata_for_tag(
config.checkpoint_path,
revision=config.checkpoint_revision,
force_download=bool(config.checkpoint_force_download),
norm_tag=config.norm_tag,
)
if metadata.get("action_horizon") is not None:
config.chunk_size = int(metadata["action_horizon"])
if metadata.get("n_action_steps") is not None:
config.n_action_steps = int(metadata["n_action_steps"])
if not config.setup_type and metadata.get("setup_type") is not None:
config.setup_type = str(metadata["setup_type"])
if not config.control_mode and metadata.get("control_mode") is not None:
config.control_mode = str(metadata["control_mode"])
def _saved_policy_action_mode(config: MolmoAct2Config) -> str | None:
"""Read the action mode from a LeRobot-saved checkpoint's ``config.json``."""
from pathlib import Path
pretrained_path = getattr(config, "pretrained_path", None)
if pretrained_path is None:
return None
config_path = Path(pretrained_path) / "config.json"
if not config_path.exists():
return None
try:
mode = json.loads(config_path.read_text()).get("action_mode")
except (OSError, json.JSONDecodeError):
return None
if mode in {"continuous", "discrete", "both"}:
return str(mode)
return None
def _training_action_mode(config: MolmoAct2Config, saved_policy_action_mode: str | None = None) -> str:
return saved_policy_action_mode or config.action_mode
def _validate_inference_action_mode(
config: MolmoAct2Config, saved_policy_action_mode: str | None = None
) -> None:
"""Check that the requested inference mode is compatible with the training mode."""
requested_mode = config.inference_action_mode
if requested_mode is None:
return
training_mode = _training_action_mode(config, saved_policy_action_mode)
if requested_mode == "continuous" and training_mode == "discrete":
raise ValueError(
"MolmoAct2 checkpoint was trained with action_mode='discrete' and cannot run "
"continuous inference."
)
if requested_mode == "discrete" and training_mode == "continuous":
raise ValueError(
"MolmoAct2 checkpoint was trained with action_mode='continuous' and cannot run "
"discrete inference. Train with action_mode='both' or action_mode='discrete' first."
)
def _validate_checkpoint_action_mode(
config: MolmoAct2Config,
checkpoint_action_mode: str,
*,
has_action_expert: bool,
) -> None:
"""Check that the checkpoint's action mode is compatible with the config."""
if config.action_mode == "both" and checkpoint_action_mode != "both":
raise ValueError(
f"action_mode='both' requires checkpoint action_mode='both', got {checkpoint_action_mode!r}."
)
if config.action_mode == "discrete" and checkpoint_action_mode not in {"discrete", "both"}:
raise ValueError(
f"action_mode='discrete' requires checkpoint action_mode in {{'discrete', 'both'}}, "
f"got {checkpoint_action_mode!r}."
)
if config.action_mode in {"continuous", "both"} and not has_action_expert:
raise ValueError("Continuous MolmoAct2 training requires an action expert checkpoint.")
def _resolve_inference_action_mode(
config: MolmoAct2Config,
requested_mode: str | None,
saved_policy_action_mode: str | None = None,
) -> str:
"""Resolve the final inference action mode, validating compatibility."""
training_mode = _training_action_mode(config, saved_policy_action_mode)
if requested_mode is None:
requested_mode = config.inference_action_mode
if requested_mode is None:
raise ValueError(
"MolmoAct2 inference requires `inference_action_mode` to be set explicitly "
"to either 'continuous' or 'discrete'."
)
if requested_mode not in {"continuous", "discrete"}:
raise ValueError("MolmoAct2 inference_action_mode must be either 'continuous' or 'discrete'.")
if requested_mode == "continuous" and training_mode == "discrete":
raise ValueError("MolmoAct2 action_mode='discrete' checkpoint cannot run continuous inference.")
if requested_mode == "discrete" and training_mode == "continuous":
raise ValueError("MolmoAct2 action_mode='continuous' checkpoint cannot run discrete inference.")
return requested_mode
def _strict_load_safetensors_weights(model: torch.nn.Module, checkpoint_location: str) -> None: def _strict_load_safetensors_weights(model: torch.nn.Module, checkpoint_location: str) -> None:
index_path = os.path.join(checkpoint_location, SAFE_WEIGHTS_INDEX_NAME) index_path = os.path.join(checkpoint_location, SAFE_WEIGHTS_INDEX_NAME)
single_file_path = os.path.join(checkpoint_location, SAFE_WEIGHTS_NAME) single_file_path = os.path.join(checkpoint_location, SAFE_WEIGHTS_NAME)
@@ -103,16 +309,6 @@ def _strict_load_safetensors_weights(model: torch.nn.Module, checkpoint_location
) )
def _torch_dtype(dtype: str) -> torch.dtype:
if dtype == "float32":
return torch.float32
if dtype == "bfloat16":
return torch.bfloat16
if dtype == "float16":
return torch.float16
raise ValueError(f"Unsupported dtype: {dtype}")
def _sample_beta_timesteps( def _sample_beta_timesteps(
*, *,
batch_size: int, batch_size: int,
@@ -136,7 +332,180 @@ def _sample_beta_timesteps(
return time_offset + scale * samples return time_offset + scale * samples
def _mask_discrete_action_spans(
*,
input_ids: Tensor,
mask: Tensor,
start_token_id: int | None,
end_token_id: int | None,
) -> Tensor:
if start_token_id is None or end_token_id is None:
return mask
mask = mask.clone()
for batch_idx in range(input_ids.shape[0]):
row = input_ids[batch_idx]
starts = (row == int(start_token_id)).nonzero(as_tuple=False).flatten().tolist()
ends = (row == int(end_token_id)).nonzero(as_tuple=False).flatten().tolist()
end_ptr = 0
for start in starts:
while end_ptr < len(ends) and ends[end_ptr] < start:
end_ptr += 1
if end_ptr >= len(ends):
mask[batch_idx, start:] = False
break
end = int(ends[end_ptr])
mask[batch_idx, start : end + 1] = False
end_ptr += 1
return mask
def _drop_trivial_attention_mask(model_inputs: dict[str, Tensor]) -> dict[str, Tensor]:
attention_mask = model_inputs.get("attention_mask")
if torch.is_tensor(attention_mask) and bool(attention_mask.to(dtype=torch.bool).all().item()):
model_inputs = dict(model_inputs)
model_inputs.pop("attention_mask", None)
return model_inputs
def _expand_mask(mask: Tensor | None, num_flow_timesteps: int) -> Tensor | None:
if mask is None:
return None
return (
mask.unsqueeze(1)
.expand(-1, num_flow_timesteps, *([-1] * (mask.ndim - 1)))
.reshape(mask.shape[0] * num_flow_timesteps, *mask.shape[1:])
)
def _action_dim_valid_mask(target: Tensor, action_dim_is_pad: Tensor | None) -> Tensor | None:
if action_dim_is_pad is None:
return None
mask = ~action_dim_is_pad.to(device=target.device, dtype=torch.bool)
if mask.ndim == 1:
mask = mask.unsqueeze(0)
if mask.shape[-1] != target.shape[-1]:
raise ValueError(
f"action_dim_is_pad width {mask.shape[-1]} does not match target width {target.shape[-1]}."
)
if mask.shape[0] == 1 and target.shape[0] != 1:
mask = mask.expand(target.shape[0], -1)
if mask.shape[0] != target.shape[0]:
raise ValueError(
f"action_dim_is_pad batch {mask.shape[0]} does not match target batch {target.shape[0]}."
)
while mask.ndim < target.ndim:
mask = mask.unsqueeze(1)
return mask
def _mask_action_dim_tensor(tensor: Tensor, action_dim_is_pad: Tensor | None) -> Tensor:
if action_dim_is_pad is None:
return tensor
valid_mask = _action_dim_valid_mask(tensor, action_dim_is_pad)
if valid_mask is None:
return tensor
return tensor.masked_fill(~valid_mask, 0)
def _apply_action_dim_padding_mask(loss: Tensor, action_dim_is_pad: Tensor | None) -> Tensor:
valid_mask = _action_dim_valid_mask(loss, action_dim_is_pad)
if valid_mask is None:
return loss
valid = valid_mask.to(dtype=loss.dtype)
denom = valid.sum(dim=-1).clamp_min(1.0)
return (loss * valid).sum(dim=-1) / denom
def _apply_action_chunk_padding_mask(loss: Tensor, action_horizon_is_pad: Tensor | None) -> Tensor:
if action_horizon_is_pad is None:
return loss
valid_action = (
(~action_horizon_is_pad.to(device=loss.device, dtype=torch.bool)).unsqueeze(1).unsqueeze(-1)
)
return loss * valid_action
def _combine_rollout_seeds(first_seed: int, batch_size: int) -> int:
seed = 0
for idx in range(batch_size):
seed = (seed + (idx + 1) * (first_seed + idx)) % (2**63 - 1)
return seed
def _rollout_task_signature(batch: dict[str, Any]) -> tuple[Any, ...] | None:
task = batch.get("task")
if task is None:
task = batch.get("observation.language")
if task is None:
return None
if isinstance(task, str):
return (task,)
if isinstance(task, (list, tuple)):
return tuple(str(item) for item in task)
return (str(task),)
def _extract_discrete_token_bins(
generated_ids: list[int],
start_token_id: int,
end_token_id: int,
token_id_to_bin: dict[int, int],
) -> list[int]:
start_idx = None
end_idx = None
for idx, token_id in enumerate(generated_ids):
if token_id == start_token_id:
start_idx = idx
break
if start_idx is not None:
for idx in range(start_idx + 1, len(generated_ids)):
if generated_ids[idx] == end_token_id:
end_idx = idx
break
span_start = 0 if start_idx is None else start_idx + 1
span_end = len(generated_ids) if end_idx is None else end_idx
return [
int(token_id_to_bin[token_id])
for token_id in generated_ids[span_start:span_end]
if token_id in token_id_to_bin
]
def _weighted_mean(values: Tensor, weights: Tensor | None) -> Tensor:
if weights is None:
return values.mean()
weights = weights.to(device=values.device, dtype=values.dtype)
return torch.dot(values, weights) / weights.sum().clamp_min(1.0)
def _weighted_per_example(
values: Tensor,
weights: Tensor | None,
example_indices: Tensor,
batch_size: int,
) -> Tensor:
values = values.float()
if weights is None:
weights = torch.ones_like(values)
else:
weights = weights.to(device=values.device, dtype=values.dtype)
loss_sum = torch.zeros(batch_size, device=values.device, dtype=torch.float32)
weight_sum = torch.zeros(batch_size, device=values.device, dtype=torch.float32)
loss_sum.scatter_add_(0, example_indices, values * weights)
weight_sum.scatter_add_(0, example_indices, weights)
global_weight_sum = weight_sum.sum().clamp_min(1.0)
return loss_sum * float(batch_size) / global_weight_sum
class MolmoAct2Policy(PreTrainedPolicy): class MolmoAct2Policy(PreTrainedPolicy):
"""MolmoAct2 policy wrapping the vendored HF model for LeRobot.
Supports three training modes via ``config.action_mode``:
``"continuous"`` (flow-matching only), ``"discrete"`` (autoregressive
token prediction only), or ``"both"`` (joint loss). At inference,
``config.inference_action_mode`` selects which head generates actions.
"""
config_class = MolmoAct2Config config_class = MolmoAct2Config
name = "molmoact2" name = "molmoact2"
@@ -149,10 +518,10 @@ class MolmoAct2Policy(PreTrainedPolicy):
**kwargs, **kwargs,
): ):
super().__init__(config, *inputs, **kwargs) super().__init__(config, *inputs, **kwargs)
self.config.apply_norm_tag_metadata() _apply_norm_tag_metadata(self.config)
self.config.validate_features() self.config.validate_features()
del inputs, kwargs, dataset_stats, dataset_meta del inputs, kwargs, dataset_stats, dataset_meta
self._checkpoint_action_mode = self.config.saved_policy_action_mode() self._checkpoint_action_mode = _saved_policy_action_mode(self.config)
self._action_queue: deque[Tensor] = deque(maxlen=self.config.n_action_steps) self._action_queue: deque[Tensor] = deque(maxlen=self.config.n_action_steps)
self._rollout_action_generator: torch.Generator | None = None self._rollout_action_generator: torch.Generator | None = None
self._rollout_task_key: tuple[Any, ...] | None = None self._rollout_task_key: tuple[Any, ...] | None = None
@@ -160,7 +529,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
self.rtc_processor: RTCProcessor | None = None self.rtc_processor: RTCProcessor | None = None
self.action_tokenizer: Any | None = None self.action_tokenizer: Any | None = None
self._load_hf_model() self._load_hf_model()
self.config.validate_inference_action_mode(self._checkpoint_action_mode) _validate_inference_action_mode(self.config, self._checkpoint_action_mode)
if self.config.enable_lora_vlm: if self.config.enable_lora_vlm:
self._apply_lora_adapters() self._apply_lora_adapters()
self.init_rtc_processor() self.init_rtc_processor()
@@ -212,7 +581,8 @@ class MolmoAct2Policy(PreTrainedPolicy):
"`policy.checkpoint_force_download=true` after the updated files are pushed." "`policy.checkpoint_force_download=true` after the updated files are pushed."
) )
checkpoint_action_mode = str(self.model.config.action_mode) checkpoint_action_mode = str(self.model.config.action_mode)
self.config.validate_checkpoint_action_mode( _validate_checkpoint_action_mode(
self.config,
checkpoint_action_mode, checkpoint_action_mode,
has_action_expert=bool(getattr(self.model.config, "add_action_expert", False)), has_action_expert=bool(getattr(self.model.config, "add_action_expert", False)),
) )
@@ -226,6 +596,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
self.train(self.training) self.train(self.training)
def reset(self) -> None: def reset(self) -> None:
"""Clear the action queue and rollout generator between episodes."""
self._action_queue = deque(maxlen=self.config.n_action_steps) self._action_queue = deque(maxlen=self.config.n_action_steps)
self._rollout_action_generator = None self._rollout_action_generator = None
@@ -334,6 +705,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
param.requires_grad = False param.requires_grad = False
def get_optim_params(self) -> list[dict[str, Any]]: def get_optim_params(self) -> list[dict[str, Any]]:
"""Return optimizer param groups with per-component learning rates."""
vit_params: list[Tensor] = [] vit_params: list[Tensor] = []
connector_params: list[Tensor] = [] connector_params: list[Tensor] = []
action_expert_params: list[Tensor] = [] action_expert_params: list[Tensor] = []
@@ -419,33 +791,6 @@ class MolmoAct2Policy(PreTrainedPolicy):
return int(value) return int(value)
raise RuntimeError("MolmoAct2 could not resolve an action generation horizon.") raise RuntimeError("MolmoAct2 could not resolve an action generation horizon.")
@staticmethod
def _mask_discrete_action_spans(
*,
input_ids: Tensor,
mask: Tensor,
start_token_id: int | None,
end_token_id: int | None,
) -> Tensor:
if start_token_id is None or end_token_id is None:
return mask
mask = mask.clone()
for batch_idx in range(input_ids.shape[0]):
row = input_ids[batch_idx]
starts = (row == int(start_token_id)).nonzero(as_tuple=False).flatten().tolist()
ends = (row == int(end_token_id)).nonzero(as_tuple=False).flatten().tolist()
end_ptr = 0
for start in starts:
while end_ptr < len(ends) and ends[end_ptr] < start:
end_ptr += 1
if end_ptr >= len(ends):
mask[batch_idx, start:] = False
break
end = int(ends[end_ptr])
mask[batch_idx, start : end + 1] = False
end_ptr += 1
return mask
def _encoder_attention_mask_for_action_expert( def _encoder_attention_mask_for_action_expert(
self, self,
*, *,
@@ -470,21 +815,13 @@ class MolmoAct2Policy(PreTrainedPolicy):
eos_token_id = getattr(self.model.config, "eos_token_id", None) eos_token_id = getattr(self.model.config, "eos_token_id", None)
if eos_token_id is not None: if eos_token_id is not None:
mask &= input_ids != int(eos_token_id) mask &= input_ids != int(eos_token_id)
return self._mask_discrete_action_spans( return _mask_discrete_action_spans(
input_ids=input_ids, input_ids=input_ids,
mask=mask, mask=mask,
start_token_id=getattr(self.model.config, "action_start_token_id", None), start_token_id=getattr(self.model.config, "action_start_token_id", None),
end_token_id=getattr(self.model.config, "action_end_token_id", None), end_token_id=getattr(self.model.config, "action_end_token_id", None),
) )
@staticmethod
def _drop_trivial_attention_mask(model_inputs: dict[str, Tensor]) -> dict[str, Tensor]:
attention_mask = model_inputs.get("attention_mask")
if torch.is_tensor(attention_mask) and bool(attention_mask.to(dtype=torch.bool).all().item()):
model_inputs = dict(model_inputs)
model_inputs.pop("attention_mask", None)
return model_inputs
def _load_discrete_action_tokenizer(self) -> Any: def _load_discrete_action_tokenizer(self) -> Any:
if self.action_tokenizer is None: if self.action_tokenizer is None:
require_package("transformers", extra="molmoact2") require_package("transformers", extra="molmoact2")
@@ -498,27 +835,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
return self.action_tokenizer return self.action_tokenizer
def _resolve_inference_action_mode(self, requested_mode: str | None) -> str: def _resolve_inference_action_mode(self, requested_mode: str | None) -> str:
return self.config.resolve_inference_action_mode(requested_mode, self._checkpoint_action_mode) return _resolve_inference_action_mode(self.config, requested_mode, self._checkpoint_action_mode)
@staticmethod
def _combine_rollout_seeds(first_seed: int, batch_size: int) -> int:
seed = 0
for idx in range(batch_size):
seed = (seed + (idx + 1) * (first_seed + idx)) % (2**63 - 1)
return seed
@staticmethod
def _rollout_task_signature(batch: dict[str, Any]) -> tuple[Any, ...] | None:
task = batch.get("task")
if task is None:
task = batch.get("observation.language")
if task is None:
return None
if isinstance(task, str):
return (task,)
if isinstance(task, (list, tuple)):
return tuple(str(item) for item in task)
return (str(task),)
def _rollout_generator_for_inputs( def _rollout_generator_for_inputs(
self, self,
@@ -532,7 +849,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
if self._rollout_action_generator is not None: if self._rollout_action_generator is not None:
return self._rollout_action_generator return self._rollout_action_generator
task_signature = self._rollout_task_signature(batch) task_signature = _rollout_task_signature(batch)
if task_signature != self._rollout_task_key: if task_signature != self._rollout_task_key:
self._rollout_task_key = task_signature self._rollout_task_key = task_signature
self._rollout_index_for_task = 0 self._rollout_index_for_task = 0
@@ -545,72 +862,10 @@ class MolmoAct2Policy(PreTrainedPolicy):
device if device.type == "cuda" and torch.cuda.is_available() else torch.device("cpu") device if device.type == "cuda" and torch.cuda.is_available() else torch.device("cpu")
) )
generator = torch.Generator(device=generator_device) generator = torch.Generator(device=generator_device)
generator.manual_seed(self._combine_rollout_seeds(first_seed, batch_size)) generator.manual_seed(_combine_rollout_seeds(first_seed, batch_size))
self._rollout_action_generator = generator self._rollout_action_generator = generator
return generator return generator
@staticmethod
def _expand_mask(mask: Tensor | None, num_flow_timesteps: int) -> Tensor | None:
if mask is None:
return None
return (
mask.unsqueeze(1)
.expand(-1, num_flow_timesteps, *([-1] * (mask.ndim - 1)))
.reshape(mask.shape[0] * num_flow_timesteps, *mask.shape[1:])
)
@staticmethod
def _action_dim_valid_mask(target: Tensor, action_dim_is_pad: Tensor | None) -> Tensor | None:
if action_dim_is_pad is None:
return None
mask = ~action_dim_is_pad.to(device=target.device, dtype=torch.bool)
if mask.ndim == 1:
mask = mask.unsqueeze(0)
if mask.shape[-1] != target.shape[-1]:
raise ValueError(
f"action_dim_is_pad width {mask.shape[-1]} does not match target width {target.shape[-1]}."
)
if mask.shape[0] == 1 and target.shape[0] != 1:
mask = mask.expand(target.shape[0], -1)
if mask.shape[0] != target.shape[0]:
raise ValueError(
f"action_dim_is_pad batch {mask.shape[0]} does not match target batch {target.shape[0]}."
)
while mask.ndim < target.ndim:
mask = mask.unsqueeze(1)
return mask
@classmethod
def _mask_action_dim_tensor(cls, tensor: Tensor, action_dim_is_pad: Tensor | None) -> Tensor:
if not cls._mask_enabled_static(action_dim_is_pad):
return tensor
valid_mask = cls._action_dim_valid_mask(tensor, action_dim_is_pad)
if valid_mask is None:
return tensor
return tensor.masked_fill(~valid_mask, 0)
@staticmethod
def _mask_enabled_static(action_dim_is_pad: Tensor | None) -> bool:
return action_dim_is_pad is not None
@classmethod
def _apply_action_dim_padding_mask(cls, loss: Tensor, action_dim_is_pad: Tensor | None) -> Tensor:
valid_mask = cls._action_dim_valid_mask(loss, action_dim_is_pad)
if valid_mask is None:
return loss
valid = valid_mask.to(dtype=loss.dtype)
denom = valid.sum(dim=-1).clamp_min(1.0)
return (loss * valid).sum(dim=-1) / denom
@staticmethod
def _apply_action_chunk_padding_mask(loss: Tensor, action_horizon_is_pad: Tensor | None) -> Tensor:
if action_horizon_is_pad is None:
return loss
valid_action = (
(~action_horizon_is_pad.to(device=loss.device, dtype=torch.bool)).unsqueeze(1).unsqueeze(-1)
)
return loss * valid_action
def _prepare_flow_matching_tensors( def _prepare_flow_matching_tensors(
self, self,
*, *,
@@ -649,7 +904,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
) )
if self.config.mask_action_dim_padding: if self.config.mask_action_dim_padding:
actions = self._mask_action_dim_tensor(actions, action_dim_is_pad) actions = _mask_action_dim_tensor(actions, action_dim_is_pad)
expected_noise_shape = (batch_size, num_flow_timesteps, actions.shape[1], actions.shape[2]) expected_noise_shape = (batch_size, num_flow_timesteps, actions.shape[1], actions.shape[2])
if noise is None: if noise is None:
@@ -661,7 +916,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
f"flow noise must have shape {expected_noise_shape}, got {tuple(noise.shape)}." f"flow noise must have shape {expected_noise_shape}, got {tuple(noise.shape)}."
) )
if self.config.mask_action_dim_padding: if self.config.mask_action_dim_padding:
noise = self._mask_action_dim_tensor(noise, action_dim_is_pad) noise = _mask_action_dim_tensor(noise, action_dim_is_pad)
t_broadcast = timesteps.view(batch_size, num_flow_timesteps, 1, 1) t_broadcast = timesteps.view(batch_size, num_flow_timesteps, 1, 1)
actions_expanded = actions.unsqueeze(1).expand(-1, num_flow_timesteps, -1, -1) actions_expanded = actions.unsqueeze(1).expand(-1, num_flow_timesteps, -1, -1)
@@ -789,7 +1044,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
valid_action = None valid_action = None
if action_attention_mask is not None: if action_attention_mask is not None:
valid_action = action_attention_mask.to(device=device, dtype=actions.dtype).unsqueeze(-1) valid_action = action_attention_mask.to(device=device, dtype=actions.dtype).unsqueeze(-1)
valid_action = self._expand_mask(valid_action, num_flow_timesteps) valid_action = _expand_mask(valid_action, num_flow_timesteps)
rope_cache = None rope_cache = None
if len(action_expert.blocks) > 0 and action_expert.blocks[0].self_attn.rope is not None: if len(action_expert.blocks) > 0 and action_expert.blocks[0].self_attn.rope is not None:
@@ -804,14 +1059,14 @@ class MolmoAct2Policy(PreTrainedPolicy):
batch_size, batch_size,
actions.dtype, actions.dtype,
) )
cross_mask = self._expand_mask(cross_mask, num_flow_timesteps) cross_mask = _expand_mask(cross_mask, num_flow_timesteps)
self_mask = action_expert._build_self_attention_mask( self_mask = action_expert._build_self_attention_mask(
action_attention_mask, action_attention_mask,
actions.shape[1], actions.shape[1],
device, device,
actions.dtype, actions.dtype,
) )
self_mask = self._expand_mask(self_mask, num_flow_timesteps) self_mask = _expand_mask(self_mask, num_flow_timesteps)
conditioning = self._action_time_conditioning(action_expert, timesteps_flat) conditioning = self._action_time_conditioning(action_expert, timesteps_flat)
action_hidden = action_expert.action_embed(xt_flat) action_hidden = action_expert.action_embed(xt_flat)
@@ -871,8 +1126,8 @@ class MolmoAct2Policy(PreTrainedPolicy):
if k_norm is not None: if k_norm is not None:
k_ctx = k_norm(k_ctx.transpose(1, 2)).transpose(1, 2) k_ctx = k_norm(k_ctx.transpose(1, 2)).transpose(1, 2)
if num_flow_timesteps != 1: if num_flow_timesteps != 1:
k_ctx = self._expand_mask(k_ctx, num_flow_timesteps) k_ctx = _expand_mask(k_ctx, num_flow_timesteps)
v_ctx = self._expand_mask(v_ctx, num_flow_timesteps) v_ctx = _expand_mask(v_ctx, num_flow_timesteps)
next_action_hidden = action_block( next_action_hidden = action_block(
layer_action_hidden, layer_action_hidden,
@@ -912,9 +1167,9 @@ class MolmoAct2Policy(PreTrainedPolicy):
) )
loss = F.mse_loss(pred_velocity, target_velocity, reduction="none") loss = F.mse_loss(pred_velocity, target_velocity, reduction="none")
loss = self._apply_action_chunk_padding_mask(loss, batch.get("action_horizon_is_pad")) loss = _apply_action_chunk_padding_mask(loss, batch.get("action_horizon_is_pad"))
if self.config.mask_action_dim_padding: if self.config.mask_action_dim_padding:
loss = self._apply_action_dim_padding_mask(loss, batch.get("action_dim_is_pad")) loss = _apply_action_dim_padding_mask(loss, batch.get("action_dim_is_pad"))
loss = loss.reshape(batch_size, -1).mean(dim=1) loss = loss.reshape(batch_size, -1).mean(dim=1)
if reduction == "mean": if reduction == "mean":
loss = loss.mean() loss = loss.mean()
@@ -933,32 +1188,6 @@ class MolmoAct2Policy(PreTrainedPolicy):
example_weights[nonempty] = 2.0 / torch.sqrt(token_counts[nonempty]) example_weights[nonempty] = 2.0 / torch.sqrt(token_counts[nonempty])
return example_weights[:, None].expand_as(valid_positions)[valid_positions].to(dtype=torch.float32) return example_weights[:, None].expand_as(valid_positions)[valid_positions].to(dtype=torch.float32)
@staticmethod
def _weighted_mean(values: Tensor, weights: Tensor | None) -> Tensor:
if weights is None:
return values.mean()
weights = weights.to(device=values.device, dtype=values.dtype)
return torch.dot(values, weights) / weights.sum().clamp_min(1.0)
@staticmethod
def _weighted_per_example(
values: Tensor,
weights: Tensor | None,
example_indices: Tensor,
batch_size: int,
) -> Tensor:
values = values.float()
if weights is None:
weights = torch.ones_like(values)
else:
weights = weights.to(device=values.device, dtype=values.dtype)
loss_sum = torch.zeros(batch_size, device=values.device, dtype=torch.float32)
weight_sum = torch.zeros(batch_size, device=values.device, dtype=torch.float32)
loss_sum.scatter_add_(0, example_indices, values * weights)
weight_sum.scatter_add_(0, example_indices, weights)
global_weight_sum = weight_sum.sum().clamp_min(1.0)
return loss_sum * float(batch_size) / global_weight_sum
def _discrete_loss_from_backbone_outputs( def _discrete_loss_from_backbone_outputs(
self, self,
batch: dict[str, Tensor], batch: dict[str, Tensor],
@@ -992,56 +1221,28 @@ class MolmoAct2Policy(PreTrainedPolicy):
token_weights = self._discrete_token_weights(valid_positions) token_weights = self._discrete_token_weights(valid_positions)
if reduction == "none": if reduction == "none":
example_indices = valid_positions.nonzero(as_tuple=False)[:, 0].to(device=hidden_states.device) example_indices = valid_positions.nonzero(as_tuple=False)[:, 0].to(device=hidden_states.device)
ce_loss = self._weighted_per_example( ce_loss = _weighted_per_example(
token_ce_loss, token_ce_loss,
token_weights, token_weights,
example_indices, example_indices,
int(labels.shape[0]), int(labels.shape[0]),
) )
else: else:
ce_loss = self._weighted_mean(token_ce_loss, token_weights) ce_loss = _weighted_mean(token_ce_loss, token_weights)
if not self.config.softmax_auxiliary_loss: if not self.config.softmax_auxiliary_loss:
return ce_loss, None return ce_loss, None
if reduction == "none": if reduction == "none":
z_loss = self.config.softmax_auxiliary_loss_scale * self._weighted_per_example( z_loss = self.config.softmax_auxiliary_loss_scale * _weighted_per_example(
log_z.pow(2), log_z.pow(2),
token_weights, token_weights,
example_indices, example_indices,
int(labels.shape[0]), int(labels.shape[0]),
) )
else: else:
z_loss = self.config.softmax_auxiliary_loss_scale * self._weighted_mean( z_loss = self.config.softmax_auxiliary_loss_scale * _weighted_mean(log_z.pow(2), token_weights)
log_z.pow(2), token_weights
)
return ce_loss, z_loss return ce_loss, z_loss
@staticmethod
def _extract_discrete_token_bins(
generated_ids: list[int],
start_token_id: int,
end_token_id: int,
token_id_to_bin: dict[int, int],
) -> list[int]:
start_idx = None
end_idx = None
for idx, token_id in enumerate(generated_ids):
if token_id == start_token_id:
start_idx = idx
break
if start_idx is not None:
for idx in range(start_idx + 1, len(generated_ids)):
if generated_ids[idx] == end_token_id:
end_idx = idx
break
span_start = 0 if start_idx is None else start_idx + 1
span_end = len(generated_ids) if end_idx is None else end_idx
return [
int(token_id_to_bin[token_id])
for token_id in generated_ids[span_start:span_end]
if token_id in token_id_to_bin
]
def _action_token_id_to_bin(self) -> dict[int, int]: def _action_token_id_to_bin(self) -> dict[int, int]:
method = getattr(self.model, "_action_token_id_to_bin", None) method = getattr(self.model, "_action_token_id_to_bin", None)
if callable(method): if callable(method):
@@ -1179,7 +1380,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
chunks: list[Tensor] = [] chunks: list[Tensor] = []
for token_row in generated_token_ids: for token_row in generated_token_ids:
generated_ids = [int(token_id) for token_id in token_row.detach().cpu().tolist()] generated_ids = [int(token_id) for token_id in token_row.detach().cpu().tolist()]
discrete_token_ids = self._extract_discrete_token_bins( discrete_token_ids = _extract_discrete_token_bins(
generated_ids, generated_ids,
int(self.model.config.action_start_token_id), int(self.model.config.action_start_token_id),
int(self.model.config.action_end_token_id), int(self.model.config.action_end_token_id),
@@ -1218,7 +1419,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
model_inputs: dict[str, Tensor], model_inputs: dict[str, Tensor],
action_dim: int, action_dim: int,
) -> Tensor: ) -> Tensor:
model_inputs = self._drop_trivial_attention_mask(model_inputs) model_inputs = _drop_trivial_attention_mask(model_inputs)
max_steps = self._discrete_generation_max_steps() max_steps = self._discrete_generation_max_steps()
static_cache, attention_bias = self._make_discrete_ar_graph_decode_inputs( static_cache, attention_bias = self._make_discrete_ar_graph_decode_inputs(
model_inputs, model_inputs,
@@ -1294,7 +1495,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
generator=generator, generator=generator,
) )
if self.config.mask_action_dim_padding: if self.config.mask_action_dim_padding:
trajectory = self._mask_action_dim_tensor(trajectory, action_dim_is_pad) trajectory = _mask_action_dim_tensor(trajectory, action_dim_is_pad)
action_context = action_expert.prepare_context( action_context = action_expert.prepare_context(
encoder_kv_states=encoder_kv_states, encoder_kv_states=encoder_kv_states,
@@ -1327,7 +1528,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
modulation=step_modulation, modulation=step_modulation,
) )
if mask_enabled: if mask_enabled:
velocity = self._mask_action_dim_tensor(velocity, action_dim_is_pad) velocity = _mask_action_dim_tensor(velocity, action_dim_is_pad)
return velocity return velocity
if self._rtc_enabled(): if self._rtc_enabled():
@@ -1352,7 +1553,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
trajectory = trajectory + dt * velocity trajectory = trajectory + dt * velocity
if mask_enabled: if mask_enabled:
trajectory = self._mask_action_dim_tensor(trajectory, action_dim_is_pad) trajectory = _mask_action_dim_tensor(trajectory, action_dim_is_pad)
if self.rtc_processor is not None and self.rtc_processor.is_debug_enabled(): if self.rtc_processor is not None and self.rtc_processor.is_debug_enabled():
self.rtc_processor.track(time=float(flow_timestep[0].item()), x_t=trajectory, v_t=velocity) self.rtc_processor.track(time=float(flow_timestep[0].item()), x_t=trajectory, v_t=velocity)
@@ -1363,6 +1564,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
batch: dict[str, Tensor], batch: dict[str, Tensor],
reduction: str = "mean", reduction: str = "mean",
) -> tuple[Tensor, dict[str, Any]]: ) -> tuple[Tensor, dict[str, Any]]:
"""Compute training loss (flow-matching and/or discrete token loss)."""
if reduction not in {"mean", "none"}: if reduction not in {"mean", "none"}:
raise ValueError(f"Unsupported reduction={reduction!r}. Expected 'mean' or 'none'.") raise ValueError(f"Unsupported reduction={reduction!r}. Expected 'mean' or 'none'.")
model_inputs = self._model_inputs(batch) model_inputs = self._model_inputs(batch)
@@ -1422,6 +1624,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
@torch.no_grad() @torch.no_grad()
def predict_action_chunk(self, batch: dict[str, Tensor], **kwargs) -> Tensor: def predict_action_chunk(self, batch: dict[str, Tensor], **kwargs) -> Tensor:
"""Generate an action chunk via continuous flow matching or discrete AR decoding."""
if "action_mode" in kwargs: if "action_mode" in kwargs:
raise TypeError( raise TypeError(
"MolmoAct2 predict_action_chunk got unexpected keyword argument 'action_mode'; " "MolmoAct2 predict_action_chunk got unexpected keyword argument 'action_mode'; "
@@ -1476,6 +1679,7 @@ class MolmoAct2Policy(PreTrainedPolicy):
@torch.no_grad() @torch.no_grad()
def select_action(self, batch: dict[str, Tensor], **kwargs) -> Tensor: def select_action(self, batch: dict[str, Tensor], **kwargs) -> Tensor:
"""Pop one action step from the queue, regenerating the chunk when empty."""
if self._rtc_enabled(): if self._rtc_enabled():
raise AssertionError("RTC is not supported for select_action, use it with predict_action_chunk") raise AssertionError("RTC is not supported for select_action, use it with predict_action_chunk")
self.eval() self.eval()
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved. # Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -13,5 +11,3 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# ruff: noqa
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved. # Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,23 +12,19 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# ruff: noqa
import logging import logging
import os
from pathlib import Path from pathlib import Path
from typing import ClassVar from typing import ClassVar
import numpy as np import numpy as np
from tokenizers import ByteLevelBPETokenizer from tokenizers import ByteLevelBPETokenizer
from tokenizers.trainers import BpeTrainer from tokenizers.trainers import BpeTrainer
from huggingface_hub import snapshot_download
from transformers import PreTrainedTokenizerFast from transformers import PreTrainedTokenizerFast
from transformers.processing_utils import ProcessorMixin from transformers.processing_utils import ProcessorMixin
from ..modeling_molmoact2 import _hf_token
def _hf_token() -> str | None: logger = logging.getLogger(__name__)
return os.environ.get("HF_TOKEN") or os.environ.get("HF_ACCESS_TOKEN")
def _resolve_tokenizer_location( def _resolve_tokenizer_location(
@@ -42,6 +36,8 @@ def _resolve_tokenizer_location(
local_path = Path(str(tokenizer_path)).expanduser() local_path = Path(str(tokenizer_path)).expanduser()
if local_path.exists(): if local_path.exists():
return str(local_path) return str(local_path)
from huggingface_hub import snapshot_download
return snapshot_download( return snapshot_download(
repo_id=str(tokenizer_path), repo_id=str(tokenizer_path),
repo_type="model", repo_type="model",
@@ -134,9 +130,8 @@ class UniversalActionProcessor(ProcessorMixin):
), ( ), (
f"Decoded DCT coefficients have shape {decoded_dct_coeff.shape}, expected ({self.time_horizon}, {self.action_dim})" f"Decoded DCT coefficients have shape {decoded_dct_coeff.shape}, expected ({self.time_horizon}, {self.action_dim})"
) )
except Exception as e: except Exception:
print(f"Error decoding tokens: {e}") logger.warning("Error decoding tokens: %s", token, exc_info=True)
print(f"Tokens: {token}")
decoded_dct_coeff = np.zeros((self.time_horizon, self.action_dim)) decoded_dct_coeff = np.zeros((self.time_horizon, self.action_dim))
decoded_actions.append(idct(decoded_dct_coeff / self.scale, axis=0, norm="ortho")) decoded_actions.append(idct(decoded_dct_coeff / self.scale, axis=0, norm="ortho"))
return np.stack(decoded_actions) return np.stack(decoded_actions)
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved. # Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,13 +12,12 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# ruff: noqa
""" """
MolmoAct2 configuration MolmoAct2 configuration
""" """
from typing import Optional, Any from typing import Any
from transformers import PretrainedConfig from transformers import PretrainedConfig
from transformers.modeling_rope_utils import rope_config_validation from transformers.modeling_rope_utils import rope_config_validation
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved. # Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,33 +12,28 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# ruff: noqa
"""Image processor class for MolmoAct2""" """Image processor class for MolmoAct2"""
from typing import Optional, Union
import numpy as np
import einops import einops
import numpy as np
import torch import torch
import torchvision.transforms import torchvision.transforms
from transformers.feature_extraction_utils import BatchFeature
from transformers.image_processing_utils import BaseImageProcessor, get_size_dict
from transformers.image_transforms import convert_to_rgb
from transformers.image_utils import ( from transformers.image_utils import (
IMAGENET_STANDARD_MEAN, IMAGENET_STANDARD_MEAN,
IMAGENET_STANDARD_STD, IMAGENET_STANDARD_STD,
ImageInput, ImageInput,
PILImageResampling, PILImageResampling,
make_flat_list_of_images, make_flat_list_of_images,
valid_images,
to_numpy_array, to_numpy_array,
valid_images,
) )
from transformers.image_transforms import convert_to_rgb
from transformers.processing_utils import ImagesKwargs from transformers.processing_utils import ImagesKwargs
from transformers.image_processing_utils import BaseImageProcessor, get_size_dict
from transformers.utils import logging
from transformers.feature_extraction_utils import BatchFeature
from transformers.utils import TensorType, logging from transformers.utils import TensorType, logging
logger = logging.get_logger(__name__) logger = logging.get_logger(__name__)
@@ -73,8 +66,8 @@ def resize_image(
)(image) )(image)
resized = torch.clip(resized, 0.0, 1.0).to(dtype) resized = torch.clip(resized, 0.0, 1.0).to(dtype)
else: else:
assert image.dtype == torch.uint8, "SigLIP expects float images or uint8 images, but got {}".format( assert image.dtype == torch.uint8, (
image.dtype f"SigLIP expects float images or uint8 images, but got {image.dtype}"
) )
in_min = 0.0 in_min = 0.0
in_max = 255.0 in_max = 255.0
@@ -96,7 +89,6 @@ def resize_image(
def select_tiling(h, w, patch_size, max_num_crops): def select_tiling(h, w, patch_size, max_num_crops):
"""Divide in image of size [w, h] in up to max_num_patches of size patch_size""" """Divide in image of size [w, h] in up to max_num_patches of size patch_size"""
original_size = np.stack([h, w]) # [1, 2] original_size = np.stack([h, w]) # [1, 2]
original_res = h * w
tilings = [] tilings = []
for i in range(1, max_num_crops + 1): for i in range(1, max_num_crops + 1):
for j in range(1, max_num_crops + 1): for j in range(1, max_num_crops + 1):
@@ -406,13 +398,17 @@ class MolmoAct2ImageProcessor(BaseImageProcessor):
image_std: float | list[float] | None = None, image_std: float | list[float] | None = None,
do_convert_rgb: bool = True, do_convert_rgb: bool = True,
max_crops: int = 8, max_crops: int = 8,
overlap_margins: list[int] = [4, 4], overlap_margins: list[int] | None = None,
crop_mode: str = "overlap-and-resize-c2", crop_mode: str = "overlap-and-resize-c2",
patch_size: int = 14, patch_size: int = 14,
pooling_size: list[int] = [2, 2], pooling_size: list[int] | None = None,
**kwargs, **kwargs,
) -> None: ) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
if overlap_margins is None:
overlap_margins = [4, 4]
if pooling_size is None:
pooling_size = [2, 2]
size = size if size is not None else {"height": 378, "width": 378} size = size if size is not None else {"height": 378, "width": 378}
size = get_size_dict(size, default_to_square=True) size = get_size_dict(size, default_to_square=True)
self.size = size self.size = size
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved. # Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,16 +12,15 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# ruff: noqa
"""Inference utilities for MolmoAct2""" """Inference utilities for MolmoAct2"""
from dataclasses import dataclass
from typing import Any, Optional, Tuple
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from typing import Any
import torch import torch
from torch.nn import functional as F from torch.nn import functional as F # noqa: N812
from transformers.cache_utils import Cache from transformers.cache_utils import Cache
from transformers.configuration_utils import PretrainedConfig from transformers.configuration_utils import PretrainedConfig
@@ -679,7 +676,7 @@ def _clone_static_inputs(inputs: _ActionFlowInputs) -> _ActionFlowInputs:
def _copy_context_(dst: Any, src: Any) -> None: def _copy_context_(dst: Any, src: Any) -> None:
for (dst_k, dst_v), (src_k, src_v) in zip(dst.kv_contexts, src.kv_contexts): for (dst_k, dst_v), (src_k, src_v) in zip(dst.kv_contexts, src.kv_contexts, strict=False):
dst_k.copy_(src_k) dst_k.copy_(src_k)
dst_v.copy_(src_v) dst_v.copy_(src_v)
if src.cross_mask is not None: if src.cross_mask is not None:
@@ -689,7 +686,7 @@ def _copy_context_(dst: Any, src: Any) -> None:
if src.valid_action is not None: if src.valid_action is not None:
dst.valid_action.copy_(src.valid_action) dst.valid_action.copy_(src.valid_action)
if src.rope_cache is not None: if src.rope_cache is not None:
for dst_tensor, src_tensor in zip(dst.rope_cache, src.rope_cache): for dst_tensor, src_tensor in zip(dst.rope_cache, src.rope_cache, strict=False):
dst_tensor.copy_(src_tensor) dst_tensor.copy_(src_tensor)
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved. # Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,24 +12,25 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# ruff: noqa
"""Modeling code for MolmoAct2""" """Modeling code for MolmoAct2"""
# ruff: noqa: N806
import json import json
import math import math
import os import os
import re import re
from collections.abc import Callable, Mapping, Sequence
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Optional
from collections.abc import Callable, Mapping, Sequence
import numpy as np import numpy as np
import torch import torch
import torch.utils.checkpoint import torch.utils.checkpoint
from torch import nn from torch import nn
from torch.nn import functional as F from torch.nn import functional as F # noqa: N812
from torch.nn.attention import SDPBackend, sdpa_kernel from torch.nn.attention import SDPBackend, sdpa_kernel
from transformers.activations import ACT2FN from transformers.activations import ACT2FN
from transformers.cache_utils import Cache, DynamicCache from transformers.cache_utils import Cache, DynamicCache
@@ -647,7 +646,7 @@ class ActionExpert(nn.Module):
f"got {len(encoder_kv_states)}." f"got {len(encoder_kv_states)}."
) )
kv_contexts = [] kv_contexts = []
for block, (k_in, v_in) in zip(self.blocks, encoder_kv_states): for block, (k_in, v_in) in zip(self.blocks, encoder_kv_states, strict=False):
k_ctx = self._project_kv_tensor(k_in, self.context_k_proj) k_ctx = self._project_kv_tensor(k_in, self.context_k_proj)
v_ctx = self._project_kv_tensor(v_in, self.context_v_proj) v_ctx = self._project_kv_tensor(v_in, self.context_v_proj)
k_norm = block.cross_attn.k_norm k_norm = block.cross_attn.k_norm
@@ -732,7 +731,7 @@ class ActionExpert(nn.Module):
timesteps: Sequence[torch.Tensor], timesteps: Sequence[torch.Tensor],
) -> Sequence[ActionExpertStepModulation]: ) -> Sequence[ActionExpertStepModulation]:
cache = [] cache = []
for idx, step_t in enumerate(timesteps): for _idx, step_t in enumerate(timesteps):
conditioning = self._time_conditioning(step_t) conditioning = self._time_conditioning(step_t)
block_modulations = [] block_modulations = []
for block in self.blocks: for block in self.blocks:
@@ -786,8 +785,8 @@ class ActionExpert(nn.Module):
x = self.action_embed(actions) x = self.action_embed(actions)
if context.valid_action is not None: if context.valid_action is not None:
x = x * context.valid_action x = x * context.valid_action
for idx, (block, kv_context, block_modulation) in enumerate( for _idx, (block, kv_context, block_modulation) in enumerate(
zip(self.blocks, context.kv_contexts, block_modulations) zip(self.blocks, context.kv_contexts, block_modulations, strict=False)
): ):
x = block( x = block(
x, x,
@@ -2874,7 +2873,7 @@ class MolmoAct2Model(MolmoAct2PreTrainedModel):
depth_mask=depth_mask, depth_mask=depth_mask,
encoder_attention_mask=encoder_attention_mask, encoder_attention_mask=encoder_attention_mask,
) )
for gate, source in zip(gate_head, sources) for gate, source in zip(gate_head, sources, strict=False)
] ]
return gates, depth_mask return gates, depth_mask
gate = self._depth_gate_from_source( gate = self._depth_gate_from_source(
@@ -4458,7 +4457,7 @@ class MolmoAct2ForConditionalGeneration(MolmoAct2PreTrainedModel, GenerationMixi
```python ```python
>>> from PIL import Image >>> from PIL import Image
>>> import requests >>> import requests
>>> from lerobot.policies.molmoact2.hf_model.modeling_molmoact2 import MolmoAct2ForConditionalGeneration >>> from lerobot.policies.molmoact2.molmoact2_hf_model.modeling_molmoact2 import MolmoAct2ForConditionalGeneration
>>> from lerobot.policies.molmoact2.processor_molmoact2 import _load_local_molmoact2_processor >>> from lerobot.policies.molmoact2.processor_molmoact2 import _load_local_molmoact2_processor
>>> model = MolmoAct2ForConditionalGeneration.from_pretrained("...") >>> model = MolmoAct2ForConditionalGeneration.from_pretrained("...")
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved. # Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,45 +12,39 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# ruff: noqa
""" """
Processor class for MolmoAct2. Processor class for MolmoAct2.
""" """
from typing import Optional, Union
import dataclasses
import numpy as np import numpy as np
from transformers import AutoTokenizer
from transformers.feature_extraction_utils import BatchFeature
from transformers.image_utils import ImageInput from transformers.image_utils import ImageInput
from transformers.video_utils import VideoInput
from transformers.processing_utils import ( from transformers.processing_utils import (
Unpack,
ProcessingKwargs, ProcessingKwargs,
ProcessorMixin, ProcessorMixin,
Unpack,
) )
from transformers.feature_extraction_utils import BatchFeature from transformers.tokenization_utils_base import PreTokenizedInput, TextInput
from transformers.tokenization_utils_base import TextInput, PreTokenizedInput
from transformers.utils import logging from transformers.utils import logging
from transformers.video_utils import VideoInput
from transformers import AutoTokenizer from .image_processing_molmoact2 import MolmoAct2ImageProcessor, MolmoAct2ImagesKwargs
from .image_processing_molmoact2 import MolmoAct2ImagesKwargs, MolmoAct2ImageProcessor from .video_processing_molmoact2 import MolmoAct2VideoProcessor, MolmoAct2VideoProcessorKwargs
from .video_processing_molmoact2 import MolmoAct2VideoProcessorKwargs, MolmoAct2VideoProcessor
logger = logging.get_logger(__name__) logger = logging.get_logger(__name__)
# Special tokens, these should be present in any tokenizer we use since the preprocessor uses them # Special tokens, these should be present in any tokenizer we use since the preprocessor uses them
IMAGE_PATCH_TOKEN = f"<im_patch>" # Where to insert high-res tokens IMAGE_PATCH_TOKEN = "<im_patch>" # nosec B105 # Where to insert high-res tokens
IMAGE_LOW_RES_TOKEN = f"<im_low>" # Where to insert low-res tokens IMAGE_LOW_RES_TOKEN = "<im_low>" # nosec B105 # Where to insert low-res tokens
IM_START_TOKEN = f"<im_start>" IM_START_TOKEN = "<im_start>" # nosec B105
LOW_RES_IMAGE_START_TOKEN = f"<low_res_im_start>" LOW_RES_IMAGE_START_TOKEN = "<low_res_im_start>" # nosec B105
FRAME_START_TOKEN = f"<frame_start>" FRAME_START_TOKEN = "<frame_start>" # nosec B105
IM_END_TOKEN = f"<im_end>" IM_END_TOKEN = "<im_end>" # nosec B105
FRAME_END_TOKEN = f"<frame_end>" FRAME_END_TOKEN = "<frame_end>" # nosec B105
IM_COL_TOKEN = f"<im_col>" IM_COL_TOKEN = "<im_col>" # nosec B105
IMAGE_PROMPT = "<|image|>" IMAGE_PROMPT = "<|image|>"
VIDEO_PROMPT = "<|video|>" VIDEO_PROMPT = "<|video|>"
@@ -224,7 +216,7 @@ class MolmoAct2Processor(ProcessorMixin):
input_ids = input_ids[None, :] input_ids = input_ids[None, :]
attention_mask = attention_mask[None, :] attention_mask = attention_mask[None, :]
B, S = input_ids.shape B, S = input_ids.shape # noqa: N806
# Handle zero-length sequence # Handle zero-length sequence
if S == 0: if S == 0:
@@ -364,7 +356,7 @@ class MolmoAct2Processor(ProcessorMixin):
assert num_videos in {0, 1}, "At most one video is supported for now" assert num_videos in {0, 1}, "At most one video is supported for now"
video_grids_i = video_grids[index : index + num_videos] video_grids_i = video_grids[index : index + num_videos]
metadata_i = video_metadata[index : index + num_videos] metadata_i = video_metadata[index : index + num_videos]
for video_grid, metadata in zip(video_grids_i, metadata_i): for video_grid, metadata in zip(video_grids_i, metadata_i, strict=False):
video_string = self.get_video_string( video_string = self.get_video_string(
video_grid, video_grid,
metadata.timestamps, metadata.timestamps,
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved. # Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,25 +12,23 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# ruff: noqa
"""Video processor class for MolmoAct2""" """Video processor class for MolmoAct2"""
from functools import partial
import os import os
import warnings import warnings
from collections.abc import Callable
from contextlib import redirect_stdout from contextlib import redirect_stdout
from functools import partial
from io import BytesIO from io import BytesIO
from urllib.parse import urlparse from urllib.parse import urlparse
from typing import Optional, Union
from collections.abc import Callable
import einops
import numpy as np import numpy as np
import requests import requests
import einops
import torch import torch
import torchvision.transforms import torchvision.transforms
from transformers.feature_extraction_utils import BatchFeature
from transformers.image_utils import ( from transformers.image_utils import (
IMAGENET_STANDARD_MEAN, IMAGENET_STANDARD_MEAN,
IMAGENET_STANDARD_STD, IMAGENET_STANDARD_STD,
@@ -41,27 +37,24 @@ from transformers.image_utils import (
SizeDict, SizeDict,
validate_kwargs, validate_kwargs,
) )
from transformers.video_utils import (
VideoInput,
is_valid_video,
make_batched_videos,
make_batched_metadata,
VideoMetadata,
)
from transformers.processing_utils import Unpack, VideosKwargs from transformers.processing_utils import Unpack, VideosKwargs
from transformers.video_processing_utils import BaseVideoProcessor
from transformers.utils import logging
from transformers.feature_extraction_utils import BatchFeature
from transformers.utils import ( from transformers.utils import (
TensorType,
is_av_available, is_av_available,
is_decord_available, is_decord_available,
is_torchcodec_available, is_torchcodec_available,
is_yt_dlp_available, is_yt_dlp_available,
TensorType,
logging, logging,
to_numpy, to_numpy,
) )
from transformers.video_processing_utils import BaseVideoProcessor
from transformers.video_utils import (
VideoInput,
VideoMetadata,
is_valid_video,
make_batched_metadata,
make_batched_videos,
)
logger = logging.get_logger(__name__) logger = logging.get_logger(__name__)
@@ -102,8 +95,8 @@ def resize_image(
)(image) )(image)
resized = torch.clip(resized, 0.0, 1.0).to(dtype) resized = torch.clip(resized, 0.0, 1.0).to(dtype)
else: else:
assert image.dtype == torch.uint8, "SigLIP expects float images or uint8 images, but got {}".format( assert image.dtype == torch.uint8, (
image.dtype f"SigLIP expects float images or uint8 images, but got {image.dtype}"
) )
in_min = 0.0 in_min = 0.0
in_max = 255.0 in_max = 255.0
@@ -548,9 +541,8 @@ def get_target_fps(
step_size = max(int(video_fps / target_fps), 1) step_size = max(int(video_fps / target_fps), 1)
num_frames_sampled_at_fps = int(total_frames / step_size) num_frames_sampled_at_fps = int(total_frames / step_size)
if num_frames_sampled == 0: if num_frames_sampled == 0:
if "uniform" in frame_sample_mode: if "uniform" in frame_sample_mode and num_frames_sampled_at_fps > max_frames:
if num_frames_sampled_at_fps > max_frames: break
break
selected_target_fps = target_fps selected_target_fps = target_fps
num_frames_sampled = num_frames_sampled_at_fps num_frames_sampled = num_frames_sampled_at_fps
@@ -779,13 +771,15 @@ class MolmoAct2VideoProcessor(BaseVideoProcessor):
elif is_torchcodec_available(): elif is_torchcodec_available():
warnings.warn( warnings.warn(
"`decord` is not installed and cannot be used to decode the video by default. " "`decord` is not installed and cannot be used to decode the video by default. "
"Falling back to `torchcodec`." "Falling back to `torchcodec`.",
stacklevel=2,
) )
backend = "torchcodec" backend = "torchcodec"
else: else:
warnings.warn( warnings.warn(
"`decord` is not installed and cannot be used to decode the video by default. " "`decord` is not installed and cannot be used to decode the video by default. "
"Falling back to `PyAV`." "Falling back to `PyAV`.",
stacklevel=2,
) )
backend = "pyav" backend = "pyav"
@@ -795,7 +789,8 @@ class MolmoAct2VideoProcessor(BaseVideoProcessor):
*[ *[
self.fetch_videos(x, sample_timestamps_fn=sample_timestamps_fn) self.fetch_videos(x, sample_timestamps_fn=sample_timestamps_fn)
for x in video_url_or_urls for x in video_url_or_urls
] ],
strict=False,
) )
) )
else: else:
@@ -821,7 +816,7 @@ class MolmoAct2VideoProcessor(BaseVideoProcessor):
assert video_metadata[0].fps is not None, "FPS must be provided for video input" assert video_metadata[0].fps is not None, "FPS must be provided for video input"
sampled_videos = [] sampled_videos = []
sampled_metadata = [] sampled_metadata = []
for video, metadata in zip(videos, video_metadata): for video, metadata in zip(videos, video_metadata, strict=False):
indices = sample_indices_fn(metadata=metadata) indices = sample_indices_fn(metadata=metadata)
metadata.frames_indices = indices metadata.frames_indices = indices
sampled_videos.append(video[indices]) sampled_videos.append(video[indices])
@@ -985,11 +980,11 @@ class MolmoAct2VideoProcessor(BaseVideoProcessor):
pixel_values_videos = np.concatenate(batch_crops, 0) pixel_values_videos = np.concatenate(batch_crops, 0)
video_token_pooling = np.concatenate(batch_pooled_patches_idx, 0) video_token_pooling = np.concatenate(batch_pooled_patches_idx, 0)
data = dict( data = {
pixel_values_videos=pixel_values_videos, "pixel_values_videos": pixel_values_videos,
video_token_pooling=video_token_pooling, "video_token_pooling": video_token_pooling,
video_grids=video_grids, "video_grids": video_grids,
) }
return BatchFeature(data, tensor_type=return_tensors) return BatchFeature(data, tensor_type=return_tensors)
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved. # Copyright 2026 The Allen Institute for Artificial Intelligence and The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,10 +12,18 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""MolmoAct2 pre/post processing pipeline.
Builds the multimodal prompt (images, discretised state, task text),
tokenises it via the vendored MolmoAct2 processor, and handles quantile
normalisation with optional per-dimension gripper masking.
"""
from __future__ import annotations from __future__ import annotations
import json import json
import os import logging
import math
import re import re
from contextlib import suppress from contextlib import suppress
from copy import deepcopy from copy import deepcopy
@@ -27,7 +33,6 @@ from typing import TYPE_CHECKING, Any
import numpy as np import numpy as np
import torch import torch
from huggingface_hub import snapshot_download
from torch import Tensor from torch import Tensor
from lerobot.configs import FeatureType, PipelineFeatureType, PolicyFeature from lerobot.configs import FeatureType, PipelineFeatureType, PolicyFeature
@@ -54,14 +59,71 @@ from lerobot.utils.constants import (
) )
from lerobot.utils.import_utils import _scipy_available, _transformers_available, require_package from lerobot.utils.import_utils import _scipy_available, _transformers_available, require_package
from .configuration_molmoact2 import MolmoAct2Config, infer_molmoact2_max_sequence_length from .configuration_molmoact2 import MolmoAct2Config
from .modeling_molmoact2 import _hf_token, _resolve_checkpoint_location
logger = logging.getLogger(__name__)
MOLMOACT2_DEFAULT_NUM_IMAGES = 2
MOLMOACT2_IMAGE_TOKENS_PER_IMAGE = 196
MOLMOACT2_FIXED_PROMPT_TOKEN_BUDGET = 80
MOLMOACT2_TASK_TOKEN_BUDGET = 32
MOLMOACT2_SEQUENCE_LENGTH_MARGIN = 32
MOLMOACT2_SEQUENCE_LENGTH_MULTIPLE = 64
MOLMOACT2_DISCRETE_ACTION_WRAPPER_TOKENS = 4
MOLMOACT2_MIN_DISCRETE_ACTION_TOKENS_PER_STEP = 6
MOLMOACT2_DISCRETE_ACTION_TOKENS_PER_DIM = 0.95
def _round_up(value: int, multiple: int) -> int:
return int(math.ceil(value / multiple) * multiple)
def infer_molmoact2_max_sequence_length(
*,
num_images: int,
state_dim: int,
action_dim: int,
action_horizon: int,
include_discrete_action: bool,
) -> int:
"""Infer the padded text/image sequence cap from MolmoAct2's fixed token layout."""
if num_images < 1:
num_images = MOLMOACT2_DEFAULT_NUM_IMAGES
if state_dim < 0:
state_dim = 0
if action_dim < 1:
action_dim = 1
if action_horizon < 1:
action_horizon = 1
image_tokens = num_images * MOLMOACT2_IMAGE_TOKENS_PER_IMAGE
prompt_tokens = (
MOLMOACT2_FIXED_PROMPT_TOKEN_BUDGET
+ MOLMOACT2_TASK_TOKEN_BUDGET
+ state_dim
+ MOLMOACT2_SEQUENCE_LENGTH_MARGIN
)
action_tokens = 0
if include_discrete_action:
action_tokens_per_step = max(
MOLMOACT2_MIN_DISCRETE_ACTION_TOKENS_PER_STEP,
math.ceil(action_dim * MOLMOACT2_DISCRETE_ACTION_TOKENS_PER_DIM),
)
action_tokens = MOLMOACT2_DISCRETE_ACTION_WRAPPER_TOKENS + action_horizon * action_tokens_per_step
return _round_up(
image_tokens + prompt_tokens + action_tokens,
MOLMOACT2_SEQUENCE_LENGTH_MULTIPLE,
)
if TYPE_CHECKING or _transformers_available: if TYPE_CHECKING or _transformers_available:
from transformers import Qwen2Tokenizer from transformers import Qwen2Tokenizer
from .hf_model.image_processing_molmoact2 import MolmoAct2ImageProcessor from .molmoact2_hf_model.image_processing_molmoact2 import MolmoAct2ImageProcessor
from .hf_model.processing_molmoact2 import MolmoAct2Processor from .molmoact2_hf_model.processing_molmoact2 import MolmoAct2Processor
from .hf_model.video_processing_molmoact2 import MolmoAct2VideoProcessor from .molmoact2_hf_model.video_processing_molmoact2 import MolmoAct2VideoProcessor
else: else:
Qwen2Tokenizer = None Qwen2Tokenizer = None
MolmoAct2ImageProcessor = None MolmoAct2ImageProcessor = None
@@ -69,7 +131,7 @@ else:
MolmoAct2VideoProcessor = None MolmoAct2VideoProcessor = None
if TYPE_CHECKING or (_transformers_available and _scipy_available): if TYPE_CHECKING or (_transformers_available and _scipy_available):
from .hf_model.action_tokenizer import UniversalActionProcessor from .molmoact2_hf_model.action_tokenizer import UniversalActionProcessor
else: else:
UniversalActionProcessor = None UniversalActionProcessor = None
@@ -97,32 +159,6 @@ _QUESTION_PREFIX_PATTERNS = tuple(
) )
def _hf_token() -> str | None:
return os.environ.get("HF_TOKEN") or os.environ.get("HF_ACCESS_TOKEN")
def _resolve_checkpoint_location(
checkpoint_path: str,
*,
revision: str | None = None,
force_download: bool = False,
) -> str:
checkpoint_path = str(checkpoint_path or "").strip()
if not checkpoint_path:
raise ValueError("MolmoAct2 policy requires `checkpoint_path`.")
local_path = Path(checkpoint_path).expanduser()
if local_path.exists():
return str(local_path)
return snapshot_download(
repo_id=checkpoint_path,
repo_type="model",
revision=revision,
force_download=force_download,
ignore_patterns=["*.py", "*.pyc", "__pycache__/*"],
token=_hf_token(),
)
def _load_hf_norm_stats_for_tag( def _load_hf_norm_stats_for_tag(
checkpoint_path: str, checkpoint_path: str,
*, *,
@@ -969,6 +1005,93 @@ class MolmoAct2PackInputsProcessorStep(ProcessorStep):
return features return features
@ProcessorStepRegistry.register(name="molmoact2_state_frame_transform")
@dataclass
class MolmoAct2StateFrameTransformStep(ProcessorStep):
"""Convert robot state from arm frame to model frame before normalization.
Required for zero-shot deployment of MolmoAct2-SO100_101 on SO-100/101
arms calibrated with LeRobot >= 0.5.0 (v3.0 convention). The checkpoint
was trained on data using a different joint convention (sign flip on
shoulder_lift, 90 deg offset on shoulder_lift and elbow_flex).
No-op when joint_signs and joint_offsets are None (default), so this
step has no effect on fine-tuned models or other embodiments.
state_model = signs * arm_state + offsets
See: https://huggingface.co/docs/lerobot/backwardcomp
"""
joint_signs: list[float] | None = None
joint_offsets: list[float] | None = None
def __call__(self, transition: EnvTransition) -> EnvTransition:
if self.joint_signs is None or self.joint_offsets is None:
return transition
observation = transition.get(TransitionKey.OBSERVATION)
if not isinstance(observation, dict) or OBS_STATE not in observation:
return transition
transition = transition.copy()
observation = observation.copy()
state = torch.as_tensor(observation[OBS_STATE], dtype=torch.float32).clone()
n = len(self.joint_signs)
signs = torch.tensor(self.joint_signs, dtype=torch.float32, device=state.device)
offsets = torch.tensor(self.joint_offsets, dtype=torch.float32, device=state.device)
state[..., :n] = signs * state[..., :n] + offsets
observation[OBS_STATE] = state
transition[TransitionKey.OBSERVATION] = observation
return transition
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
return features
def get_config(self) -> dict[str, Any]:
return {"joint_signs": self.joint_signs, "joint_offsets": self.joint_offsets}
@ProcessorStepRegistry.register(name="molmoact2_action_frame_transform")
@dataclass
class MolmoAct2ActionFrameTransformStep(ProcessorStep):
"""Convert model action from model frame back to arm frame after unnormalization.
Inverse of MolmoAct2StateFrameTransformStep. Required for zero-shot
MolmoAct2-SO100_101 on SO-100/101 arms. No-op when both fields are None.
action_arm = signs * (model_action - offsets)
See: https://huggingface.co/docs/lerobot/backwardcomp
"""
joint_signs: list[float] | None = None
joint_offsets: list[float] | None = None
def __call__(self, transition: EnvTransition) -> EnvTransition:
if self.joint_signs is None or self.joint_offsets is None:
return transition
action = transition.get(TransitionKey.ACTION)
if action is None:
return transition
transition = transition.copy()
action = torch.as_tensor(action, dtype=torch.float32).clone()
n = len(self.joint_signs)
signs = torch.tensor(self.joint_signs, dtype=torch.float32, device=action.device)
offsets = torch.tensor(self.joint_offsets, dtype=torch.float32, device=action.device)
action[..., :n] = signs * (action[..., :n] - offsets)
transition[TransitionKey.ACTION] = action
return transition
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
return features
def get_config(self) -> dict[str, Any]:
return {"joint_signs": self.joint_signs, "joint_offsets": self.joint_offsets}
@ProcessorStepRegistry.register(name="molmoact2_clamp_action") @ProcessorStepRegistry.register(name="molmoact2_clamp_action")
@dataclass @dataclass
class MolmoAct2ClampActionProcessorStep(ProcessorStep): class MolmoAct2ClampActionProcessorStep(ProcessorStep):
@@ -1031,6 +1154,10 @@ def make_molmoact2_pre_post_processors(
input_steps: list[ProcessorStep] = [ input_steps: list[ProcessorStep] = [
RenameObservationsProcessorStep(rename_map={}), RenameObservationsProcessorStep(rename_map={}),
AddBatchDimensionProcessorStep(), AddBatchDimensionProcessorStep(),
MolmoAct2StateFrameTransformStep(
joint_signs=config.joint_signs,
joint_offsets=config.joint_offsets,
),
MolmoAct2MaskedNormalizerProcessorStep( MolmoAct2MaskedNormalizerProcessorStep(
features={**config.input_features, **config.output_features}, features={**config.input_features, **config.output_features},
norm_map=config.normalization_mapping, norm_map=config.normalization_mapping,
@@ -1066,6 +1193,10 @@ def make_molmoact2_pre_post_processors(
norm_map=config.normalization_mapping, norm_map=config.normalization_mapping,
stats=masked_dataset_stats, stats=masked_dataset_stats,
), ),
MolmoAct2ActionFrameTransformStep(
joint_signs=config.joint_signs,
joint_offsets=config.joint_offsets,
),
DeviceProcessorStep(device="cpu"), DeviceProcessorStep(device="cpu"),
] ]
+30 -29
View File
@@ -11,6 +11,8 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from __future__ import annotations
import abc import abc
import builtins import builtins
import dataclasses import dataclasses
@@ -19,7 +21,7 @@ import os
from importlib.resources import files from importlib.resources import files
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import TypedDict, TypeVar, Unpack from typing import TYPE_CHECKING, TypedDict, TypeVar, Unpack
import packaging import packaging
import safetensors import safetensors
@@ -38,10 +40,13 @@ from .utils import log_model_loading_keys
T = TypeVar("T", bound="PreTrainedPolicy") T = TypeVar("T", bound="PreTrainedPolicy")
if TYPE_CHECKING:
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
def _build_card_context( def _build_card_context(
cfg: TrainPipelineConfig | None, cfg: TrainPipelineConfig | None,
dataset_repo_id: str | None, dataset_meta: LeRobotDatasetMetadata | None,
input_features: dict | None, input_features: dict | None,
output_features: dict | None, output_features: dict | None,
) -> dict: ) -> dict:
@@ -72,30 +77,16 @@ def _build_card_context(
"lerobot_version": __version__, "lerobot_version": __version__,
} }
if dataset_repo_id: if dataset_meta is not None:
dataset_cfg = getattr(cfg, "dataset", None) context["dataset"] = {
try: "repo_id": dataset_meta.repo_id,
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata "episodes": dataset_meta.total_episodes,
"frames": dataset_meta.total_frames,
meta = LeRobotDatasetMetadata( "fps": dataset_meta.fps,
dataset_repo_id, "tasks": [str(task) for task in dataset_meta.tasks.index],
root=getattr(dataset_cfg, "root", None), }
revision=getattr(dataset_cfg, "revision", None), context["robot_type"] = dataset_meta.robot_type
) context["cameras"] = [key.split(".")[-1] for key in dataset_meta.camera_keys]
context["dataset"] = {
"repo_id": dataset_repo_id,
"episodes": meta.total_episodes,
"frames": meta.total_frames,
"fps": meta.fps,
"tasks": [str(task) for task in meta.tasks.index],
}
context["robot_type"] = meta.robot_type
context["cameras"] = [key.split(".")[-1] for key in meta.camera_keys]
except Exception as e: # noqa: BLE001 — dataset details are optional, never fail the push
logging.warning(
f"Could not load dataset metadata for '{dataset_repo_id}'; those sections will be "
f"omitted from the model card. ({e})"
)
return context return context
@@ -304,6 +295,7 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
cfg: TrainPipelineConfig, cfg: TrainPipelineConfig,
peft_model=None, peft_model=None,
state_dict: dict[str, Tensor] | None = None, state_dict: dict[str, Tensor] | None = None,
dataset_meta: LeRobotDatasetMetadata | None = None,
): ):
api = HfApi() api = HfApi()
repo_id = api.create_repo( repo_id = api.create_repo(
@@ -325,7 +317,12 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
self.save_pretrained(saved_path, state_dict=state_dict) self.save_pretrained(saved_path, state_dict=state_dict)
card = self.generate_model_card( card = self.generate_model_card(
cfg.dataset.repo_id, self.config.type, self.config.license, self.config.tags, cfg=cfg cfg.dataset.repo_id,
self.config.type,
self.config.license,
self.config.tags,
cfg=cfg,
dataset_meta=dataset_meta,
) )
card.save(str(saved_path / "README.md")) card.save(str(saved_path / "README.md"))
@@ -340,6 +337,9 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
ignore_patterns=["*.tmp", "*.log"], ignore_patterns=["*.tmp", "*.log"],
) )
# Contract: lerobot.jobs.hf.submit_to_hf watches for this exact
# "Model pushed to <url>" line to end a remote run early. Keep the wording
# and URL format in sync (it falls back to status polling if they drift).
logging.info(f"Model pushed to {commit_info.repo_url.url}") logging.info(f"Model pushed to {commit_info.repo_url.url}")
def generate_model_card( def generate_model_card(
@@ -349,6 +349,7 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
license: str | None, license: str | None,
tags: list[str] | None, tags: list[str] | None,
cfg: TrainPipelineConfig | None = None, cfg: TrainPipelineConfig | None = None,
dataset_meta: LeRobotDatasetMetadata | None = None,
) -> ModelCard: ) -> ModelCard:
base_model_mapping = { base_model_mapping = {
"smolvla": "lerobot/smolvla_base", "smolvla": "lerobot/smolvla_base",
@@ -369,7 +370,7 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
) )
context = _build_card_context( context = _build_card_context(
cfg, dataset_repo_id, self.config.input_features, self.config.output_features cfg, dataset_meta, self.config.input_features, self.config.output_features
) )
# Used by the template to pre-fill commands and the "Fine-tuned from" line. # Used by the template to pre-fill commands and the "Fine-tuned from" line.
context["policy_repo_id"] = getattr(self.config, "repo_id", None) context["policy_repo_id"] = getattr(self.config, "repo_id", None)
@@ -386,7 +387,7 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
self, self,
peft_config=None, peft_config=None,
peft_cli_overrides: dict | None = None, peft_cli_overrides: dict | None = None,
) -> "PreTrainedPolicy": ) -> PreTrainedPolicy:
""" """
Wrap this policy with PEFT adapters for parameter-efficient fine-tuning. Wrap this policy with PEFT adapters for parameter-efficient fine-tuning.
+2 -1
View File
@@ -126,7 +126,8 @@ def prepare_observation_for_inference(
for name in observation: for name in observation:
observation[name] = torch.from_numpy(observation[name]) observation[name] = torch.from_numpy(observation[name])
if "image" in name: if "image" in name:
observation[name] = observation[name].type(torch.float32) / 255 if observation[name].dtype == torch.uint8:
observation[name] = observation[name].type(torch.float32) / 255
observation[name] = observation[name].permute(2, 0, 1).contiguous() observation[name] = observation[name].permute(2, 0, 1).contiguous()
observation[name] = observation[name].unsqueeze(0) observation[name] = observation[name].unsqueeze(0)
observation[name] = observation[name].to(device) observation[name] = observation[name].to(device)

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