mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-06 09:37:06 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8728bde84 | |||
| d3fd459f81 | |||
| ed29db6d22 | |||
| e623733861 | |||
| 141c353206 | |||
| 8414188db0 | |||
| 0da98afd63 | |||
| 2f2b567951 | |||
| 18eee1b477 | |||
| 5ac3b49a5f | |||
| a5821a01a2 |
+1
-1
@@ -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 \
|
||||||
|
|||||||
@@ -69,6 +69,8 @@
|
|||||||
title: VLA-JEPA
|
title: VLA-JEPA
|
||||||
- local: eo1
|
- local: eo1
|
||||||
title: EO-1
|
title: EO-1
|
||||||
|
- local: fastwam
|
||||||
|
title: FastWAM
|
||||||
- local: groot
|
- local: groot
|
||||||
title: NVIDIA GR00T N1.5
|
title: NVIDIA GR00T N1.5
|
||||||
- local: xvla
|
- local: xvla
|
||||||
|
|||||||
@@ -150,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.
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -514,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`
|
||||||
@@ -526,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">
|
||||||
@@ -599,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:
|
||||||
@@ -620,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -134,6 +134,9 @@ lerobot-train \
|
|||||||
> [!TIP]
|
> [!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.
|
> 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.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Depth statistics in `meta/stats.json` are always computed in **millimetres**, regardless of the raw frame dtype.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Persistence in dataset metadata
|
## Persistence in dataset metadata
|
||||||
|
|||||||
+8
-2
@@ -124,7 +124,7 @@ 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",
|
||||||
]
|
]
|
||||||
# ── 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.
|
||||||
@@ -229,6 +229,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]"]
|
||||||
|
|
||||||
@@ -308,6 +312,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]",
|
||||||
@@ -444,7 +449,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -55,6 +55,7 @@ __all__ = [
|
|||||||
"DatasetRecordConfig",
|
"DatasetRecordConfig",
|
||||||
"DatasetConfig",
|
"DatasetConfig",
|
||||||
"EvalConfig",
|
"EvalConfig",
|
||||||
|
"JobConfig",
|
||||||
"MessageTurn",
|
"MessageTurn",
|
||||||
"PeftConfig",
|
"PeftConfig",
|
||||||
"PreTrainedConfig",
|
"PreTrainedConfig",
|
||||||
|
|||||||
@@ -145,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)
|
||||||
|
|||||||
+100
-43
@@ -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.
|
||||||
@@ -118,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
|
||||||
|
|
||||||
@@ -137,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")
|
||||||
@@ -149,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(
|
||||||
@@ -216,9 +255,19 @@ class TrainPipelineConfig(HubMixin):
|
|||||||
if self.eval_steps > 0 and self.dataset.eval_split == 0.0:
|
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.")
|
raise ValueError("eval_steps > 0 requires dataset.eval_split > 0.0 to hold out eval data.")
|
||||||
|
|
||||||
if hasattr(active_cfg, "push_to_hub") and active_cfg.push_to_hub and not active_cfg.repo_id:
|
# 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."""
|
||||||
@@ -255,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).
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import numpy as np
|
|||||||
from lerobot.processor import RelativeActionsProcessorStep
|
from lerobot.processor import RelativeActionsProcessorStep
|
||||||
from lerobot.utils.constants import ACTION, OBS_STATE
|
from lerobot.utils.constants import ACTION, OBS_STATE
|
||||||
|
|
||||||
|
from .depth_utils import MM_PER_METRE
|
||||||
from .io_utils import load_image_as_numpy
|
from .io_utils import load_image_as_numpy
|
||||||
|
|
||||||
DEFAULT_QUANTILES = [0.01, 0.10, 0.50, 0.90, 0.99]
|
DEFAULT_QUANTILES = [0.01, 0.10, 0.50, 0.90, 0.99]
|
||||||
@@ -508,8 +509,8 @@ def compute_episode_stats(
|
|||||||
Note:
|
Note:
|
||||||
For 'image'/'video' features, stats are computed per channel and kept with a
|
For 'image'/'video' features, stats are computed per channel and kept with a
|
||||||
leading channel axis (e.g. shape (3, 1, 1) for RGB). RGB stats are divided by
|
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
|
255 to land in [0, 1]; depth maps (features flagged with ``is_depth_map``) are
|
||||||
this rescaling and remain in their stored units.
|
instead canonicalized to millimetres regardless of the raw frame unit.
|
||||||
"""
|
"""
|
||||||
if quantile_list is None:
|
if quantile_list is None:
|
||||||
quantile_list = DEFAULT_QUANTILES
|
quantile_list = DEFAULT_QUANTILES
|
||||||
@@ -533,9 +534,14 @@ def compute_episode_stats(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if features[key]["dtype"] in ["image", "video"]:
|
if features[key]["dtype"] in ["image", "video"]:
|
||||||
normalization_factor = (
|
if (features[key].get("info") or {}).get("is_depth_map", False):
|
||||||
255.0 if not (features[key].get("info") or {}).get("is_depth_map", False) else 1.0
|
# Depth stats are canonically stored in millimetres; metre (float) depth is
|
||||||
)
|
# scaled up, integer (millimetre) depth is left as-is.
|
||||||
|
normalization_factor = (
|
||||||
|
1.0 / MM_PER_METRE if np.issubdtype(ep_ft_array.dtype, np.floating) else 1.0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
normalization_factor = 255.0
|
||||||
ep_stats[key] = {
|
ep_stats[key] = {
|
||||||
k: v if k == "count" else np.squeeze(v / normalization_factor, axis=0)
|
k: v if k == "count" else np.squeeze(v / normalization_factor, axis=0)
|
||||||
for k, v in ep_stats[key].items()
|
for k, v in ep_stats[key].items()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from lerobot.configs.video import (
|
|||||||
from .image_writer import squeeze_single_channel
|
from .image_writer import squeeze_single_channel
|
||||||
from .pyav_utils import write_u16_plane
|
from .pyav_utils import write_u16_plane
|
||||||
|
|
||||||
_MM_PER_METRE = 1000.0
|
MM_PER_METRE = 1000.0
|
||||||
_UINT16_MAX = 65535
|
_UINT16_MAX = 65535
|
||||||
|
|
||||||
|
|
||||||
@@ -126,12 +126,12 @@ def quantize_depth(
|
|||||||
|
|
||||||
# Convert depth_min, depth_max, and shift to the resolved input unit.
|
# Convert depth_min, depth_max, and shift to the resolved input unit.
|
||||||
depth_min_u = (
|
depth_min_u = (
|
||||||
np.float32(depth_min) if resolved_unit == DEPTH_METER_UNIT else np.float32(depth_min * _MM_PER_METRE)
|
np.float32(depth_min) if resolved_unit == DEPTH_METER_UNIT else np.float32(depth_min * MM_PER_METRE)
|
||||||
)
|
)
|
||||||
depth_max_u = (
|
depth_max_u = (
|
||||||
np.float32(depth_max) if resolved_unit == DEPTH_METER_UNIT else np.float32(depth_max * _MM_PER_METRE)
|
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)
|
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.
|
# Normalization and quantization is performed in the resolved input unit.
|
||||||
if use_log:
|
if use_log:
|
||||||
@@ -236,7 +236,7 @@ def dequantize_depth(
|
|||||||
|
|
||||||
# mm path: round + clamp in float32, skipping the uint16 round-trip
|
# mm path: round + clamp in float32, skipping the uint16 round-trip
|
||||||
# when returning a tensor (torch.uint16 is poorly supported).
|
# when returning a tensor (torch.uint16 is poorly supported).
|
||||||
buf.mul_(_MM_PER_METRE).round_().clamp_(0.0, _UINT16_MAX)
|
buf.mul_(MM_PER_METRE).round_().clamp_(0.0, _UINT16_MAX)
|
||||||
if output_tensor:
|
if output_tensor:
|
||||||
return buf
|
return buf
|
||||||
return buf.cpu().numpy().astype(np.uint16, copy=False)
|
return buf.cpu().numpy().astype(np.uint16, copy=False)
|
||||||
@@ -259,7 +259,7 @@ def dequantize_depth(
|
|||||||
if output_unit == DEPTH_METER_UNIT:
|
if output_unit == DEPTH_METER_UNIT:
|
||||||
return torch.from_numpy(buf) if output_tensor else buf
|
return torch.from_numpy(buf) if output_tensor else buf
|
||||||
|
|
||||||
np.multiply(buf, _MM_PER_METRE, out=buf)
|
np.multiply(buf, MM_PER_METRE, out=buf)
|
||||||
np.rint(buf, out=buf)
|
np.rint(buf, out=buf)
|
||||||
np.clip(buf, 0.0, _UINT16_MAX, out=buf)
|
np.clip(buf, 0.0, _UINT16_MAX, out=buf)
|
||||||
if output_tensor:
|
if output_tensor:
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ from lerobot.configs import (
|
|||||||
)
|
)
|
||||||
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 .depth_utils import MM_PER_METRE, quantize_depth
|
||||||
from .pyav_utils import get_pix_fmt_channels
|
from .pyav_utils import get_pix_fmt_channels
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -848,6 +848,9 @@ class _CameraEncoderThread(threading.Thread):
|
|||||||
# Reshape CHW to (H*W, C) for per-channel stats
|
# Reshape CHW to (H*W, C) for per-channel stats
|
||||||
channels = img_downsampled.shape[0]
|
channels = img_downsampled.shape[0]
|
||||||
img_for_stats = img_downsampled.transpose(1, 2, 0).reshape(-1, channels)
|
img_for_stats = img_downsampled.transpose(1, 2, 0).reshape(-1, channels)
|
||||||
|
# Depth stats are canonically stored in millimetres; metre (float) depth is scaled up.
|
||||||
|
if self.is_depth and np.issubdtype(frame_data.dtype, np.floating):
|
||||||
|
img_for_stats = img_for_stats * MM_PER_METRE
|
||||||
stats_tracker.update(img_for_stats)
|
stats_tracker.update(img_for_stats)
|
||||||
|
|
||||||
frame_count += 1
|
frame_count += 1
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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.")
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -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 .molmoact2.configuration_molmoact2 import MolmoAct2Config as MolmoAct2Config
|
from .molmoact2.configuration_molmoact2 import MolmoAct2Config as MolmoAct2Config
|
||||||
@@ -42,6 +43,7 @@ __all__ = [
|
|||||||
"ACTConfig",
|
"ACTConfig",
|
||||||
"DiffusionConfig",
|
"DiffusionConfig",
|
||||||
"EO1Config",
|
"EO1Config",
|
||||||
|
"FastWAMConfig",
|
||||||
"GaussianActorConfig",
|
"GaussianActorConfig",
|
||||||
"GrootConfig",
|
"GrootConfig",
|
||||||
"MolmoAct2Config",
|
"MolmoAct2Config",
|
||||||
|
|||||||
@@ -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 .molmoact2.configuration_molmoact2 import MolmoAct2Config
|
from .molmoact2.configuration_molmoact2 import MolmoAct2Config
|
||||||
@@ -162,6 +163,10 @@ def get_policy_class(name: str) -> type[PreTrainedPolicy]:
|
|||||||
from .vla_jepa.modeling_vla_jepa import VLAJEPAPolicy
|
from .vla_jepa.modeling_vla_jepa import VLAJEPAPolicy
|
||||||
|
|
||||||
return VLAJEPAPolicy
|
return VLAJEPAPolicy
|
||||||
|
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)
|
||||||
@@ -218,6 +223,8 @@ def make_policy_config(policy_type: str, **kwargs) -> PreTrainedConfig:
|
|||||||
return MolmoAct2Config(**kwargs)
|
return MolmoAct2Config(**kwargs)
|
||||||
elif policy_type == "vla_jepa":
|
elif policy_type == "vla_jepa":
|
||||||
return VLAJEPAConfig(**kwargs)
|
return VLAJEPAConfig(**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)
|
||||||
@@ -451,6 +458,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
@@ -0,0 +1 @@
|
|||||||
|
../../../../docs/source/policy_fastwam_README.md
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -79,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
|
||||||
@@ -123,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}. "
|
||||||
|
|||||||
@@ -1005,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):
|
||||||
@@ -1067,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,
|
||||||
@@ -1102,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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,10 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import torch
|
import torch
|
||||||
import torch.nn.functional as F # noqa: N812
|
import torch.nn.functional as F # noqa: N812
|
||||||
from PIL import Image
|
|
||||||
from torch import Tensor, nn
|
from torch import Tensor, nn
|
||||||
|
|
||||||
from lerobot.policies.pretrained import PreTrainedPolicy, T
|
from lerobot.policies.pretrained import PreTrainedPolicy, T
|
||||||
@@ -55,12 +53,13 @@ class VLAJEPAModel(nn.Module):
|
|||||||
- DiT-B: flow-matching action head for future action prediction
|
- DiT-B: flow-matching action head for future action prediction
|
||||||
- V-JEPA: world model for video frame prediction
|
- V-JEPA: world model for video frame prediction
|
||||||
|
|
||||||
Input: List[dict] native format (same as original starVLA)
|
Inputs are batched tensors kept on the model device
|
||||||
- "image": List[PIL.Image] (multi-view images)
|
- images: List[List[Tensor [C, H, W]]] (float [0,1]) — per sample, per view (Qwen messages)
|
||||||
- "video": np.ndarray [V, T, H, W, 3]
|
- instructions: List[str]
|
||||||
- "lang": str (task instruction)
|
- videos: Tensor [B, V, T, C, H, W] (float [0,1], world model only)
|
||||||
- "action": np.ndarray [T, action_dim] (optional, training only)
|
- actions: Tensor [B, T, action_dim] (optional, training only)
|
||||||
- "state": np.ndarray [1, state_dim] (optional)
|
- state: Tensor [B, 1, state_dim] (optional)
|
||||||
|
- action_is_pad: Tensor [B, T] (optional)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config: VLAJEPAConfig) -> None:
|
def __init__(self, config: VLAJEPAConfig) -> None:
|
||||||
@@ -75,6 +74,11 @@ class VLAJEPAModel(nn.Module):
|
|||||||
self.action_tokens, self.action_token_ids, self.embodied_action_token_id = (
|
self.action_tokens, self.action_token_ids, self.embodied_action_token_id = (
|
||||||
self.qwen.expand_tokenizer()
|
self.qwen.expand_tokenizer()
|
||||||
)
|
)
|
||||||
|
self.register_buffer(
|
||||||
|
"_action_token_ids_t",
|
||||||
|
torch.tensor(self.action_token_ids, dtype=torch.long),
|
||||||
|
persistent=False,
|
||||||
|
)
|
||||||
|
|
||||||
# Action head (flow-matching DiT)
|
# Action head (flow-matching DiT)
|
||||||
self.action_model = VLAJEPAActionHead(config, cross_attention_dim=self.qwen.model.config.hidden_size)
|
self.action_model = VLAJEPAActionHead(config, cross_attention_dim=self.qwen.model.config.hidden_size)
|
||||||
@@ -161,166 +165,123 @@ class VLAJEPAModel(nn.Module):
|
|||||||
|
|
||||||
# ---- Native VLA-JEPA forward (follows original VLA_JEPA.py) ----
|
# ---- Native VLA-JEPA forward (follows original VLA_JEPA.py) ----
|
||||||
|
|
||||||
def forward(self, examples: list[dict]) -> dict[str, Tensor]:
|
def _encode_qwen(
|
||||||
"""
|
self, images: list[list[Tensor]], instructions: list[str], *, need_action_tokens: bool
|
||||||
Native forward pass following original starVLA VLA_JEPA.forward.
|
) -> tuple[Tensor, Tensor, Tensor | None]:
|
||||||
|
"""Run Qwen and gather the embodied-action (and optionally action) token hidden states."""
|
||||||
Args:
|
|
||||||
examples: List of per-sample dicts with keys:
|
|
||||||
"image" : List[PIL.Image] — multi-view images
|
|
||||||
"video" : np.ndarray [V, T, H, W, 3]
|
|
||||||
"lang" : str — task instruction
|
|
||||||
"action" : np.ndarray [T, action_dim] (optional)
|
|
||||||
"state" : np.ndarray [1, state_dim] (optional)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict with "action_loss" and "wm_loss" keys (scalar Tensors).
|
|
||||||
"""
|
|
||||||
# Unpack native format (same pattern as original VLA_JEPA.py)
|
|
||||||
batch_images = [ex["image"] for ex in examples] # List[List[PIL.Image]]
|
|
||||||
batch_videos = [ex["video"] for ex in examples] # List[np.ndarray]
|
|
||||||
instructions = [ex["lang"] for ex in examples] # List[str]
|
|
||||||
has_action = "action" in examples[0] and examples[0]["action"] is not None
|
|
||||||
actions = [ex["action"] for ex in examples] if has_action else None
|
|
||||||
has_state = "state" in examples[0] and examples[0]["state"] is not None
|
|
||||||
state = [ex["state"] for ex in examples] if has_state else None
|
|
||||||
action_is_pad = (
|
|
||||||
[ex["action_is_pad"] for ex in examples]
|
|
||||||
if has_action and "action_is_pad" in examples[0] and examples[0]["action_is_pad"] is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stack videos: [B, V, T, H, W, 3] -> [B, V, T, 3, H, W]
|
|
||||||
batch_videos = np.stack(batch_videos)
|
|
||||||
batch_videos = batch_videos.transpose(0, 1, 2, 5, 3, 4) # [B, V, T, 3, H, W]
|
|
||||||
|
|
||||||
# Adjust number of views for the world model:
|
|
||||||
# - fewer views than expected: duplicate the first view to fill up
|
|
||||||
# - more views than expected: keep only the first num_views_world_model views
|
|
||||||
num_views_world_model = self.config.jepa_tubelet_size
|
|
||||||
if batch_videos.shape[1] < num_views_world_model:
|
|
||||||
num_missing_views = num_views_world_model - batch_videos.shape[1]
|
|
||||||
first_view = np.repeat(batch_videos[:, :1], num_missing_views, axis=1)
|
|
||||||
batch_videos = np.concatenate([batch_videos, first_view], axis=1)
|
|
||||||
elif batch_videos.shape[1] > num_views_world_model:
|
|
||||||
batch_videos = batch_videos[:, :num_views_world_model]
|
|
||||||
|
|
||||||
# ---- Step 1: QwenVL encode (same as original) ----
|
|
||||||
qwen_inputs = self.qwen.build_inputs(
|
qwen_inputs = self.qwen.build_inputs(
|
||||||
images=batch_images,
|
images=images,
|
||||||
instructions=instructions,
|
instructions=instructions,
|
||||||
action_prompt=self.replace_prompt,
|
action_prompt=self.replace_prompt,
|
||||||
embodied_prompt=self.embodied_replace_prompt,
|
embodied_prompt=self.embodied_replace_prompt,
|
||||||
)
|
)
|
||||||
|
input_ids = qwen_inputs["input_ids"]
|
||||||
# Locate embodied-action tokens (always needed for action head)
|
embodied_idx = (input_ids == self.embodied_action_token_id).nonzero(as_tuple=True)
|
||||||
embodied_mask = qwen_inputs["input_ids"] == self.embodied_action_token_id
|
action_idx = None
|
||||||
embodied_indices = embodied_mask.nonzero(as_tuple=True)
|
if need_action_tokens:
|
||||||
|
action_mask = torch.isin(input_ids, self._action_token_ids_t)
|
||||||
# Locate action tokens (only needed for world model predictor)
|
action_idx = action_mask.nonzero(as_tuple=True)
|
||||||
if self.config.enable_world_model:
|
|
||||||
action_mask = torch.isin(
|
|
||||||
qwen_inputs["input_ids"],
|
|
||||||
torch.tensor(self.action_token_ids, device=qwen_inputs["input_ids"].device),
|
|
||||||
)
|
|
||||||
action_indices = action_mask.nonzero(as_tuple=True)
|
|
||||||
|
|
||||||
device_type = next(self.parameters()).device.type
|
device_type = next(self.parameters()).device.type
|
||||||
|
|
||||||
with torch.autocast(device_type=device_type, dtype=torch.bfloat16):
|
with torch.autocast(device_type=device_type, dtype=torch.bfloat16):
|
||||||
last_hidden = self._qwen_last_decoder_hidden(qwen_inputs) # [B, seq_len, H]
|
last_hidden = self._qwen_last_decoder_hidden(qwen_inputs) # [B, seq_len, H]
|
||||||
b, _, h = last_hidden.shape
|
b, _, h = last_hidden.shape
|
||||||
|
embodied_action_tokens = last_hidden[embodied_idx[0], embodied_idx[1], :].view(b, -1, h)
|
||||||
|
action_tokens = (
|
||||||
|
last_hidden[action_idx[0], action_idx[1], :].view(b, -1, h)
|
||||||
|
if action_idx is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return embodied_action_tokens, action_tokens
|
||||||
|
|
||||||
if self.config.enable_world_model:
|
def _world_model_loss(self, videos: Tensor, action_tokens: Tensor) -> Tensor:
|
||||||
action_tokens = last_hidden[action_indices[0], action_indices[1], :].view(b, -1, h)
|
"""JEPA encode + predictor L1 loss. `videos` is [B, V, T, C, H, W] float in [0, 1]."""
|
||||||
|
# Match the world model's expected view count: pad with the first view, or trim extras.
|
||||||
|
num_views = self.config.jepa_tubelet_size
|
||||||
|
if videos.shape[1] < num_views:
|
||||||
|
missing = num_views - videos.shape[1]
|
||||||
|
videos = torch.cat([videos, videos[:, :1].repeat(1, missing, 1, 1, 1, 1)], dim=1)
|
||||||
|
elif videos.shape[1] > num_views:
|
||||||
|
videos = videos[:, :num_views]
|
||||||
|
|
||||||
embodied_action_tokens = last_hidden[embodied_indices[0], embodied_indices[1], :].view(b, -1, h)
|
b, v, t_frames, c, h_img, w_img = videos.shape
|
||||||
|
flat = videos.reshape(b * v, t_frames, c, h_img, w_img)
|
||||||
|
# Fast (torchvision) video processor on-device, do_rescale=False (frames already in [0, 1]).
|
||||||
|
video_pixels = self.video_processor(
|
||||||
|
videos=list(flat),
|
||||||
|
return_tensors="pt",
|
||||||
|
device=self.video_encoder.device,
|
||||||
|
do_rescale=False,
|
||||||
|
)["pixel_values_videos"] # [B*V, T, C, H, W]
|
||||||
|
|
||||||
# ---- Step 2+3: JEPA Encoder + Predictor ----
|
with torch.no_grad():
|
||||||
device_wm = last_hidden.device
|
video_embeddings = self.video_encoder.get_vision_features(pixel_values_videos=video_pixels)
|
||||||
if not self.config.enable_world_model:
|
# Merge views: [B*V, ...] -> [B, ..., V*embed_dim]
|
||||||
wm_loss = torch.tensor(0.0, device=device_wm)
|
video_embeddings = torch.cat(torch.chunk(video_embeddings, chunks=v, dim=0), dim=2)
|
||||||
|
|
||||||
|
tubelet_size = self.video_encoder.config.tubelet_size
|
||||||
|
# num_video_frames raw frames → t_enc_total temporal positions after tubelet compression
|
||||||
|
t_enc_total = self.config.num_video_frames // tubelet_size
|
||||||
|
if t_enc_total < 2:
|
||||||
|
return torch.zeros((), device=video_embeddings.device)
|
||||||
|
|
||||||
|
# Shift-by-one JEPA split: input_states = positions 0..T-2, gt_states = positions 1..T-1
|
||||||
|
t_enc_ctx = t_enc_total - 1
|
||||||
|
tokens_per_frame = video_embeddings.shape[1] // t_enc_total
|
||||||
|
input_states = video_embeddings[:, : tokens_per_frame * t_enc_ctx, :]
|
||||||
|
gt_states = video_embeddings[:, tokens_per_frame:, :]
|
||||||
|
|
||||||
|
expected_actions = t_enc_ctx * self.config.num_action_tokens_per_timestep
|
||||||
|
if action_tokens.shape[1] < expected_actions:
|
||||||
|
pad = action_tokens[:, -1:].repeat(1, expected_actions - action_tokens.shape[1], 1)
|
||||||
|
action_tokens = torch.cat([action_tokens, pad], dim=1)
|
||||||
|
|
||||||
|
predicted_states = self.video_predictor(
|
||||||
|
input_states.float(), action_tokens[:, :expected_actions].float()
|
||||||
|
)
|
||||||
|
return F.l1_loss(predicted_states, gt_states.float(), reduction="mean")
|
||||||
|
|
||||||
|
def _action_loss(
|
||||||
|
self,
|
||||||
|
embodied_action_tokens: Tensor,
|
||||||
|
actions: Tensor,
|
||||||
|
state: Tensor | None,
|
||||||
|
action_is_pad: Tensor | None,
|
||||||
|
) -> Tensor:
|
||||||
|
"""Flow-matching action-head loss, repeated over `repeated_diffusion_steps`."""
|
||||||
|
device_type = next(self.parameters()).device.type
|
||||||
|
with torch.autocast(device_type=device_type, dtype=torch.float32):
|
||||||
|
r = self.config.repeated_diffusion_steps
|
||||||
|
horizon = self.config.chunk_size
|
||||||
|
actions_target = actions[:, -horizon:, :].to(torch.float32).repeat(r, 1, 1)
|
||||||
|
embodied = embodied_action_tokens.repeat(r, 1, 1)
|
||||||
|
state_rep = state.to(embodied_action_tokens.dtype).repeat(r, 1, 1) if state is not None else None
|
||||||
|
pad_rep = action_is_pad[:, -horizon:].repeat(r, 1) if action_is_pad is not None else None
|
||||||
|
return self.action_model(embodied, actions_target, state_rep, pad_rep)
|
||||||
|
|
||||||
|
def forward(
|
||||||
|
self,
|
||||||
|
images: list[list[Tensor]],
|
||||||
|
instructions: list[str],
|
||||||
|
videos: Tensor | None = None,
|
||||||
|
actions: Tensor | None = None,
|
||||||
|
state: Tensor | None = None,
|
||||||
|
action_is_pad: Tensor | None = None,
|
||||||
|
) -> dict[str, Tensor]:
|
||||||
|
"""Native forward: Qwen encode → optional world-model loss → optional action-head loss."""
|
||||||
|
embodied_action_tokens, action_tokens = self._encode_qwen(
|
||||||
|
images, instructions, need_action_tokens=self.config.enable_world_model
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.config.enable_world_model and videos is not None:
|
||||||
|
wm_loss = self._world_model_loss(videos, action_tokens)
|
||||||
else:
|
else:
|
||||||
b, v, t_frames, c, h_img, w_img = batch_videos.shape
|
wm_loss = torch.zeros((), device=embodied_action_tokens.device)
|
||||||
batch_videos_flat = batch_videos.reshape(b * v, t_frames, c, h_img, w_img)
|
|
||||||
|
|
||||||
video_pixels = self.video_processor(videos=list(batch_videos_flat), return_tensors="pt")[
|
if actions is None:
|
||||||
"pixel_values_videos"
|
|
||||||
].to(self.video_encoder.device) # [B*V, T, C, H, W]
|
|
||||||
|
|
||||||
with torch.no_grad():
|
|
||||||
video_embeddings = self.video_encoder.get_vision_features(pixel_values_videos=video_pixels)
|
|
||||||
# Merge views: [B*V, ...] -> [B, ..., V*embed_dim]
|
|
||||||
video_embeddings = torch.cat(torch.chunk(video_embeddings, chunks=v, dim=0), dim=2)
|
|
||||||
|
|
||||||
tubelet_size = self.video_encoder.config.tubelet_size
|
|
||||||
device_wm = video_embeddings.device
|
|
||||||
# num_video_frames raw frames → t_enc_total temporal positions after tubelet compression
|
|
||||||
t_enc_total = self.config.num_video_frames // tubelet_size
|
|
||||||
|
|
||||||
if t_enc_total < 2:
|
|
||||||
wm_loss = torch.tensor(0.0, device=device_wm)
|
|
||||||
else:
|
|
||||||
# Shift-by-one JEPA split (matches original VLA_JEPA.py lines 231-232):
|
|
||||||
# input_states: positions 0..T-2, gt_states: positions 1..T-1
|
|
||||||
t_enc_ctx = t_enc_total - 1
|
|
||||||
tokens_per_frame = video_embeddings.shape[1] // t_enc_total
|
|
||||||
|
|
||||||
input_states = video_embeddings[:, : tokens_per_frame * t_enc_ctx, :]
|
|
||||||
gt_states = video_embeddings[:, tokens_per_frame:, :]
|
|
||||||
|
|
||||||
expected_actions = t_enc_ctx * self.config.num_action_tokens_per_timestep
|
|
||||||
if action_tokens.shape[1] < expected_actions:
|
|
||||||
pad = action_tokens[:, -1:].repeat(1, expected_actions - action_tokens.shape[1], 1)
|
|
||||||
action_tokens = torch.cat([action_tokens, pad], dim=1)
|
|
||||||
|
|
||||||
predicted_states = self.video_predictor(
|
|
||||||
input_states.float(),
|
|
||||||
action_tokens[:, :expected_actions].float(),
|
|
||||||
)
|
|
||||||
|
|
||||||
wm_loss = F.l1_loss(predicted_states, gt_states.float(), reduction="mean")
|
|
||||||
|
|
||||||
if not has_action:
|
|
||||||
return {"wm_loss": wm_loss}
|
return {"wm_loss": wm_loss}
|
||||||
|
|
||||||
# ---- Step 4: Action Head ----
|
action_loss = self._action_loss(embodied_action_tokens, actions, state, action_is_pad)
|
||||||
with torch.autocast(device_type=device_type, dtype=torch.float32):
|
|
||||||
actions_tensor = torch.tensor(
|
|
||||||
np.array(actions), device=last_hidden.device, dtype=torch.float32
|
|
||||||
) # [B, T_full, action_dim]
|
|
||||||
action_horizon = self.config.chunk_size
|
|
||||||
actions_target = actions_tensor[:, -action_horizon:, :]
|
|
||||||
|
|
||||||
state_tensor = None
|
|
||||||
if state is not None:
|
|
||||||
state_tensor = torch.tensor(
|
|
||||||
np.array(state), device=last_hidden.device, dtype=last_hidden.dtype
|
|
||||||
) # [B, 1, state_dim]
|
|
||||||
|
|
||||||
repeated_diffusion_steps = self.config.repeated_diffusion_steps
|
|
||||||
actions_target = actions_target.repeat(repeated_diffusion_steps, 1, 1)
|
|
||||||
embodied_action_tokens = embodied_action_tokens.repeat(repeated_diffusion_steps, 1, 1)
|
|
||||||
if state_tensor is not None:
|
|
||||||
state_tensor = state_tensor.repeat(repeated_diffusion_steps, 1, 1)
|
|
||||||
|
|
||||||
action_is_pad_rep = None
|
|
||||||
if action_is_pad is not None:
|
|
||||||
pad_tensor = torch.stack(
|
|
||||||
[
|
|
||||||
p.to(actions_target.device)
|
|
||||||
if isinstance(p, Tensor)
|
|
||||||
else torch.tensor(p, device=actions_target.device)
|
|
||||||
for p in action_is_pad
|
|
||||||
]
|
|
||||||
) # [B, T_full]
|
|
||||||
pad_tensor = pad_tensor[:, -action_horizon:] # [B, action_horizon]
|
|
||||||
action_is_pad_rep = pad_tensor.repeat(repeated_diffusion_steps, 1) # [B*R, action_horizon]
|
|
||||||
|
|
||||||
action_loss = self.action_model(
|
|
||||||
embodied_action_tokens, actions_target, state_tensor, action_is_pad_rep
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"action_loss": action_loss, "wm_loss": wm_loss * self.config.world_model_loss_weight}
|
return {"action_loss": action_loss, "wm_loss": wm_loss * self.config.world_model_loss_weight}
|
||||||
|
|
||||||
# ---- Native predict_action (follows original VLA_JEPA.predict_action) ----
|
# ---- Native predict_action (follows original VLA_JEPA.predict_action) ----
|
||||||
@@ -328,58 +289,23 @@ class VLAJEPAModel(nn.Module):
|
|||||||
@torch.no_grad()
|
@torch.no_grad()
|
||||||
def predict_action(
|
def predict_action(
|
||||||
self,
|
self,
|
||||||
batch_images: list[list[Image.Image]],
|
images: list[list[Tensor]],
|
||||||
instructions: list[str],
|
instructions: list[str],
|
||||||
state: np.ndarray | None = None,
|
state: Tensor | None = None,
|
||||||
) -> np.ndarray:
|
) -> Tensor:
|
||||||
"""
|
"""Predict an action chunk. `images` is per-sample, per-view float [0,1] [C, H, W] tensors."""
|
||||||
Native action prediction following original VLA_JEPA.predict_action.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
batch_images: List of samples; each is List[PIL.Image] (multi-view).
|
|
||||||
instructions: Task instructions, one per sample.
|
|
||||||
state: Optional [B, state_dim] numpy array.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
np.ndarray [B, action_horizon, action_dim] — predicted actions.
|
|
||||||
"""
|
|
||||||
if self.config.resize_images_to is not None:
|
if self.config.resize_images_to is not None:
|
||||||
height, width = self.config.resize_images_to
|
height, width = self.config.resize_images_to
|
||||||
resampling = getattr(Image, "Resampling", Image).BOX
|
images = [
|
||||||
batch_images = [
|
[F.interpolate(img[None], size=(height, width), mode="area")[0] for img in views]
|
||||||
[image.resize((width, height), resample=resampling) for image in sample_images]
|
for views in images
|
||||||
for sample_images in batch_images
|
|
||||||
]
|
]
|
||||||
|
|
||||||
qwen_inputs = self.qwen.build_inputs(
|
embodied_action_tokens, _ = self._encode_qwen(images, instructions, need_action_tokens=False)
|
||||||
images=batch_images,
|
return self.action_model.predict_action(
|
||||||
instructions=instructions,
|
embodied_action_tokens.float(), state.float() if state is not None else None
|
||||||
action_prompt=self.replace_prompt,
|
|
||||||
embodied_prompt=self.embodied_replace_prompt,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
embodied_mask = qwen_inputs["input_ids"] == self.embodied_action_token_id
|
|
||||||
embodied_indices = embodied_mask.nonzero(as_tuple=True)
|
|
||||||
|
|
||||||
device_type = next(self.parameters()).device.type
|
|
||||||
|
|
||||||
with torch.autocast(device_type=device_type, dtype=torch.bfloat16):
|
|
||||||
last_hidden = self._qwen_last_decoder_hidden(qwen_inputs) # [B, seq_len, H]
|
|
||||||
b, _, h = last_hidden.shape
|
|
||||||
embodied_action_tokens = last_hidden[embodied_indices[0], embodied_indices[1], :].view(b, -1, h)
|
|
||||||
|
|
||||||
state_tensor = None
|
|
||||||
if state is not None:
|
|
||||||
state_tensor = torch.from_numpy(np.array(state)).to(
|
|
||||||
device=last_hidden.device, dtype=last_hidden.dtype
|
|
||||||
)
|
|
||||||
|
|
||||||
pred_actions = self.action_model.predict_action(
|
|
||||||
embodied_action_tokens.float(), state_tensor.float() if state_tensor is not None else None
|
|
||||||
) # [B, action_horizon, action_dim]
|
|
||||||
|
|
||||||
return pred_actions.detach().cpu().numpy()
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# LeRobot Adapter Layer - converts between LeRobot batch format and native VLA-JEPA format
|
# LeRobot Adapter Layer - converts between LeRobot batch format and native VLA-JEPA format
|
||||||
@@ -390,9 +316,9 @@ class VLAJEPAPolicy(PreTrainedPolicy):
|
|||||||
"""
|
"""
|
||||||
LeRobot adapter for VLA-JEPA.
|
LeRobot adapter for VLA-JEPA.
|
||||||
|
|
||||||
Converts LeRobot's standard batch format (dict[str, Tensor]) to the native
|
Converts LeRobot's standard batch format (dict[str, Tensor]) to the batched tensors
|
||||||
VLA-JEPA format (List[dict]), calls the native model, and converts outputs
|
the native model expects (keeping everything on-device), calls the native model, and
|
||||||
back to LeRobot format.
|
converts outputs back to LeRobot format.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
config_class = VLAJEPAConfig
|
config_class = VLAJEPAConfig
|
||||||
@@ -419,9 +345,8 @@ class VLAJEPAPolicy(PreTrainedPolicy):
|
|||||||
|
|
||||||
# ---- Format Conversion: LeRobot → Native ----
|
# ---- Format Conversion: LeRobot → Native ----
|
||||||
|
|
||||||
def _prepare_model_inputs(self, batch: dict[str, Tensor]) -> list[dict]:
|
def _prepare_model_inputs(self, batch: dict[str, Tensor], training=True) -> dict[str, Any]:
|
||||||
"""
|
"""Convert a LeRobot batch to the model's batched, on-device inputs.
|
||||||
Convert LeRobot batch format to native VLA-JEPA examples format.
|
|
||||||
|
|
||||||
LeRobot format:
|
LeRobot format:
|
||||||
batch = {
|
batch = {
|
||||||
@@ -431,65 +356,25 @@ class VLAJEPAPolicy(PreTrainedPolicy):
|
|||||||
"task": str | List[str], (optional instruction)
|
"task": str | List[str], (optional instruction)
|
||||||
}
|
}
|
||||||
|
|
||||||
Native format (List[dict]):
|
Returns the kwargs for `VLAJEPAModel.forward` / `.predict_action` (everything stays
|
||||||
{
|
on the batch device; no per-sample shredding): `images` (per-sample, per-view list for
|
||||||
"image": List[PIL.Image], # multi-view images per sample
|
Qwen messages), `instructions`, and the batched `videos` / `actions` / `state` /
|
||||||
"video": np.ndarray [V, T, H, W, 3],
|
`action_is_pad` when present.
|
||||||
"lang": str, # task instruction
|
|
||||||
"action": np.ndarray [T, action_dim], # optional
|
|
||||||
"state": np.ndarray [1, state_dim], # optional
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
# Determine batch size from the first image feature
|
|
||||||
image_keys = list(self.config.image_features.keys())
|
image_keys = list(self.config.image_features.keys())
|
||||||
if not image_keys:
|
if not image_keys:
|
||||||
raise ValueError("VLAJEPA requires at least one image feature.")
|
raise ValueError("VLAJEPA requires at least one image feature.")
|
||||||
first_key = image_keys[0]
|
batch_size = batch[image_keys[0]].shape[0]
|
||||||
first_tensor = batch[first_key]
|
|
||||||
batch_size = first_tensor.shape[0]
|
|
||||||
|
|
||||||
# ---- Collect images per sample ----
|
# Current-frame image per view ([B, C, H, W]); regroup per sample for Qwen messages.
|
||||||
# images_per_sample[b][v] = PIL.Image for view v
|
frames = []
|
||||||
images_per_sample: list[list[Image.Image]] = [[] for _ in range(batch_size)]
|
|
||||||
for key in image_keys:
|
for key in image_keys:
|
||||||
tensor = batch[key] # [B, C, H, W] or [B, T, C, H, W]
|
t = batch[key]
|
||||||
if tensor.ndim == 5:
|
if t.ndim == 5: # [B, T, C, H, W] -> current observation (delta=0)
|
||||||
# observation_delta_indices = [0, 1, ..., num_video_frames-1]
|
t = t[:, 0]
|
||||||
# index 0 is the current observation (delta=0)
|
frames.append(self.model.qwen.to_pixel_values(t))
|
||||||
tensor = tensor[:, 0]
|
images = [[frame[b] for frame in frames] for b in range(batch_size)]
|
||||||
for b in range(batch_size):
|
|
||||||
images_per_sample[b].append(self.model.qwen.tensor_to_pil(tensor[b]))
|
|
||||||
|
|
||||||
# ---- Collect videos per sample ----
|
|
||||||
# Build video arrays: for each sample, stack views as [V, T, H, W, 3]
|
|
||||||
# Check whether any image feature has a time dimension
|
|
||||||
video_source = None
|
|
||||||
for k in image_keys:
|
|
||||||
if k in batch:
|
|
||||||
video_source = batch[k] # Use first available for shape inspection
|
|
||||||
break
|
|
||||||
|
|
||||||
if video_source is None:
|
|
||||||
raise ValueError("No image data found in batch for video construction.")
|
|
||||||
|
|
||||||
videos_per_sample = []
|
|
||||||
for b in range(batch_size):
|
|
||||||
sample_views = []
|
|
||||||
for k in image_keys:
|
|
||||||
t = batch[k][b] # [C, H, W] or [T, C, H, W]
|
|
||||||
if t.ndim == 3:
|
|
||||||
t = t.unsqueeze(0) # [1, C, H, W]
|
|
||||||
# Convert to [T, H, W, 3] numpy
|
|
||||||
t_np = t.permute(0, 2, 3, 1).detach().cpu().float().numpy()
|
|
||||||
# Clamp to [0, 255]
|
|
||||||
if t_np.max() <= 1.0:
|
|
||||||
t_np = t_np * 255.0
|
|
||||||
t_np = np.rint(t_np.clip(0, 255)).astype(np.uint8)
|
|
||||||
sample_views.append(t_np)
|
|
||||||
# Stack views: [V, T, H, W, 3]
|
|
||||||
videos_per_sample.append(np.stack(sample_views, axis=0))
|
|
||||||
|
|
||||||
# ---- Collect instructions ----
|
|
||||||
tasks = batch.get("task")
|
tasks = batch.get("task")
|
||||||
if tasks is None:
|
if tasks is None:
|
||||||
instructions = ["Execute the robot action."] * batch_size
|
instructions = ["Execute the robot action."] * batch_size
|
||||||
@@ -498,52 +383,32 @@ class VLAJEPAPolicy(PreTrainedPolicy):
|
|||||||
else:
|
else:
|
||||||
instructions = list(tasks)
|
instructions = list(tasks)
|
||||||
|
|
||||||
# ---- Collect actions (training only) ----
|
inputs: dict[str, Any] = {"images": images, "instructions": instructions}
|
||||||
actions_list = None
|
|
||||||
action_is_pad_list = None
|
|
||||||
actions_tensor = batch.get(ACTION)
|
|
||||||
if actions_tensor is not None:
|
|
||||||
if actions_tensor.ndim == 2:
|
|
||||||
actions_tensor = actions_tensor.unsqueeze(1)
|
|
||||||
actions_list = [actions_tensor[b].detach().cpu().float().numpy() for b in range(batch_size)]
|
|
||||||
action_is_pad_tensor = batch.get("action_is_pad")
|
|
||||||
if action_is_pad_tensor is not None:
|
|
||||||
action_is_pad_list = [action_is_pad_tensor[b].detach().cpu() for b in range(batch_size)]
|
|
||||||
|
|
||||||
# ---- Collect state ----
|
# Videos [B, V, T, C, H, W] - only assembled during training when the world model consumes them.
|
||||||
state_list = None
|
if self.model.config.enable_world_model and training:
|
||||||
state_tensor = batch.get(OBS_STATE)
|
views = [batch[k].unsqueeze(1) if batch[k].ndim == 4 else batch[k] for k in image_keys]
|
||||||
if state_tensor is not None:
|
inputs["videos"] = self.model.qwen.to_pixel_values(torch.stack(views, dim=1))
|
||||||
if state_tensor.ndim > 2:
|
|
||||||
state_tensor = state_tensor[:, -1, :]
|
|
||||||
if state_tensor.ndim == 2:
|
|
||||||
state_tensor = state_tensor.unsqueeze(1) # [B, 1, state_dim]
|
|
||||||
state_list = [state_tensor[b].detach().cpu().float().numpy() for b in range(batch_size)]
|
|
||||||
|
|
||||||
# ---- Assemble native examples ----
|
actions = batch.get(ACTION)
|
||||||
examples = []
|
if actions is not None:
|
||||||
for b in range(batch_size):
|
inputs["actions"] = (actions.unsqueeze(1) if actions.ndim == 2 else actions).float()
|
||||||
example = {
|
if (pad := batch.get("action_is_pad")) is not None:
|
||||||
"image": images_per_sample[b],
|
inputs["action_is_pad"] = pad
|
||||||
"video": videos_per_sample[b],
|
|
||||||
"lang": instructions[b],
|
|
||||||
}
|
|
||||||
if actions_list is not None:
|
|
||||||
example["action"] = actions_list[b]
|
|
||||||
if action_is_pad_list is not None:
|
|
||||||
example["action_is_pad"] = action_is_pad_list[b]
|
|
||||||
if state_list is not None:
|
|
||||||
example["state"] = state_list[b]
|
|
||||||
examples.append(example)
|
|
||||||
|
|
||||||
return examples
|
state = batch.get(OBS_STATE)
|
||||||
|
if state is not None:
|
||||||
|
if state.ndim > 2:
|
||||||
|
state = state[:, -1, :]
|
||||||
|
inputs["state"] = (state.unsqueeze(1) if state.ndim == 2 else state).float() # [B, 1, dim]
|
||||||
|
|
||||||
|
return inputs
|
||||||
|
|
||||||
# ---- LeRobot Policy Interface ----
|
# ---- LeRobot Policy Interface ----
|
||||||
|
|
||||||
def forward(self, batch: dict[str, Tensor]) -> tuple[Tensor, dict]:
|
def forward(self, batch: dict[str, Tensor]) -> tuple[Tensor, dict]:
|
||||||
"""LeRobot train forward: convert → native forward → aggregate losses."""
|
"""LeRobot train forward: convert → native forward → aggregate losses."""
|
||||||
examples = self._prepare_model_inputs(batch)
|
native_output = self.model.forward(**self._prepare_model_inputs(batch, training=True))
|
||||||
native_output = self.model.forward(examples)
|
|
||||||
|
|
||||||
ref = next(iter(native_output.values()))
|
ref = next(iter(native_output.values()))
|
||||||
zero = torch.zeros((), device=ref.device, dtype=ref.dtype)
|
zero = torch.zeros((), device=ref.device, dtype=ref.dtype)
|
||||||
@@ -561,16 +426,9 @@ class VLAJEPAPolicy(PreTrainedPolicy):
|
|||||||
self.eval()
|
self.eval()
|
||||||
self._queues = populate_queues(self._queues, batch, exclude_keys=[ACTION])
|
self._queues = populate_queues(self._queues, batch, exclude_keys=[ACTION])
|
||||||
|
|
||||||
examples = self._prepare_model_inputs(batch)
|
inputs = self._prepare_model_inputs(batch, training=False)
|
||||||
batch_images = [ex["image"] for ex in examples]
|
actions = self.model.predict_action(inputs["images"], inputs["instructions"], inputs.get("state"))
|
||||||
instructions = [ex["lang"] for ex in examples]
|
return actions.to(device=self.config.device, dtype=torch.float32)
|
||||||
|
|
||||||
state_np = None
|
|
||||||
if "state" in examples[0] and examples[0]["state"] is not None:
|
|
||||||
state_np = np.stack([ex["state"] for ex in examples])
|
|
||||||
|
|
||||||
actions_np = self.model.predict_action(batch_images, instructions, state_np)
|
|
||||||
return torch.from_numpy(actions_np).to(device=self.config.device, dtype=torch.float32)
|
|
||||||
|
|
||||||
@torch.no_grad()
|
@torch.no_grad()
|
||||||
def select_action(self, batch: dict[str, Tensor], noise: Tensor | None = None) -> Tensor:
|
def select_action(self, batch: dict[str, Tensor], noise: Tensor | None = None) -> Tensor:
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import torch
|
import torch
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from lerobot.utils.import_utils import _transformers_available
|
from lerobot.utils.import_utils import _transformers_available
|
||||||
|
|
||||||
@@ -78,7 +76,7 @@ class Qwen3VLInterface(torch.nn.Module):
|
|||||||
|
|
||||||
def build_inputs(
|
def build_inputs(
|
||||||
self,
|
self,
|
||||||
images: Sequence[Sequence[Image.Image]],
|
images: Sequence[Sequence[torch.Tensor]],
|
||||||
instructions: Sequence[str],
|
instructions: Sequence[str],
|
||||||
action_prompt: str,
|
action_prompt: str,
|
||||||
embodied_prompt: str,
|
embodied_prompt: str,
|
||||||
@@ -94,24 +92,42 @@ class Qwen3VLInterface(torch.nn.Module):
|
|||||||
content.append({"type": "text", "text": prompt})
|
content.append({"type": "text", "text": prompt})
|
||||||
messages.append([{"role": "user", "content": content}])
|
messages.append([{"role": "user", "content": content}])
|
||||||
|
|
||||||
|
# The Qwen image processor is a torchvision-backed fast processor: passing the
|
||||||
|
# images as GPU tensors (with `device`) keeps the whole vision pipeline on-device
|
||||||
|
# and avoids a GPU->CPU->GPU roundtrip. The image tensors are forwarded through
|
||||||
|
# apply_chat_template untouched into Qwen3VLProcessor.__call__.
|
||||||
|
# do_rescale=False: images already arrive as float in [0, 1] (the dataset decoder
|
||||||
|
# yields float32/255 and VISUAL normalization is IDENTITY), so we skip the
|
||||||
|
# processor's /255 rescale instead of round-tripping through uint8.
|
||||||
batch_inputs = self.processor.apply_chat_template(
|
batch_inputs = self.processor.apply_chat_template(
|
||||||
messages,
|
messages,
|
||||||
tokenize=True,
|
tokenize=True,
|
||||||
add_generation_prompt=True,
|
add_generation_prompt=True,
|
||||||
return_dict=True,
|
return_dict=True,
|
||||||
processor_kwargs={"padding": True, "return_tensors": "pt"},
|
processor_kwargs={
|
||||||
|
"padding": True,
|
||||||
|
"return_tensors": "pt",
|
||||||
|
"device": self.model.device,
|
||||||
|
"do_rescale": False,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return batch_inputs.to(self.model.device)
|
return batch_inputs.to(self.model.device)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def tensor_to_pil(image_tensor: torch.Tensor) -> Image.Image:
|
def to_pixel_values(image_tensor: torch.Tensor) -> torch.Tensor:
|
||||||
image = image_tensor.detach().cpu()
|
"""Prepare an image/video tensor for the fast processors (used with do_rescale=False).
|
||||||
if image.ndim == 3 and image.shape[0] in (1, 3):
|
|
||||||
image = image.permute(1, 2, 0)
|
The dataset decoder yields float32 in [0, 1] (channels-first) and VISUAL
|
||||||
image = image.float()
|
normalization is IDENTITY, so the tensor already arrives in [0, 1]; we pass it
|
||||||
if image.max() <= 1.0:
|
through as float and let the processors normalize (no rescale, no uint8
|
||||||
image = image * 255.0
|
quantization). A single channel is expanded to 3 to match the RGB processors.
|
||||||
image = image.clamp(0, 255).round().to(torch.uint8).numpy()
|
|
||||||
if image.shape[-1] == 1:
|
Works for any channels-first layout (channel dim is -3): [C, H, W], [B, C, H, W],
|
||||||
image = np.repeat(image, 3, axis=-1)
|
[T, C, H, W], [B, V, T, C, H, W], ...
|
||||||
return Image.fromarray(image)
|
"""
|
||||||
|
image = image_tensor.detach().float()
|
||||||
|
if image.shape[-3] == 1:
|
||||||
|
repeats = [1] * image.ndim
|
||||||
|
repeats[-3] = 3
|
||||||
|
image = image.repeat(*repeats)
|
||||||
|
return image
|
||||||
|
|||||||
@@ -65,7 +65,13 @@ class BiRebotB601Follower(BimanualMixin, Robot):
|
|||||||
cameras=left_arm_cameras,
|
cameras=left_arm_cameras,
|
||||||
motor_can_ids=config.left_arm_config.motor_can_ids,
|
motor_can_ids=config.left_arm_config.motor_can_ids,
|
||||||
pos_vel_velocity=config.left_arm_config.pos_vel_velocity,
|
pos_vel_velocity=config.left_arm_config.pos_vel_velocity,
|
||||||
|
control_mode=config.left_arm_config.control_mode,
|
||||||
|
mit_kp=config.left_arm_config.mit_kp,
|
||||||
|
mit_kd=config.left_arm_config.mit_kd,
|
||||||
|
gripper_control_mode=config.left_arm_config.gripper_control_mode,
|
||||||
gripper_torque_ratio=config.left_arm_config.gripper_torque_ratio,
|
gripper_torque_ratio=config.left_arm_config.gripper_torque_ratio,
|
||||||
|
gripper_mit_kp=config.left_arm_config.gripper_mit_kp,
|
||||||
|
gripper_mit_kd=config.left_arm_config.gripper_mit_kd,
|
||||||
joint_limits=config.left_arm_config.joint_limits,
|
joint_limits=config.left_arm_config.joint_limits,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,7 +86,13 @@ class BiRebotB601Follower(BimanualMixin, Robot):
|
|||||||
cameras=config.right_arm_config.cameras,
|
cameras=config.right_arm_config.cameras,
|
||||||
motor_can_ids=config.right_arm_config.motor_can_ids,
|
motor_can_ids=config.right_arm_config.motor_can_ids,
|
||||||
pos_vel_velocity=config.right_arm_config.pos_vel_velocity,
|
pos_vel_velocity=config.right_arm_config.pos_vel_velocity,
|
||||||
|
control_mode=config.right_arm_config.control_mode,
|
||||||
|
mit_kp=config.right_arm_config.mit_kp,
|
||||||
|
mit_kd=config.right_arm_config.mit_kd,
|
||||||
|
gripper_control_mode=config.right_arm_config.gripper_control_mode,
|
||||||
gripper_torque_ratio=config.right_arm_config.gripper_torque_ratio,
|
gripper_torque_ratio=config.right_arm_config.gripper_torque_ratio,
|
||||||
|
gripper_mit_kp=config.right_arm_config.gripper_mit_kp,
|
||||||
|
gripper_mit_kd=config.right_arm_config.gripper_mit_kd,
|
||||||
joint_limits=config.right_arm_config.joint_limits,
|
joint_limits=config.right_arm_config.joint_limits,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -65,18 +65,33 @@ class RebotB601FollowerConfig:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Target velocity for joints running in POS_VEL mode, in degrees/s. A scalar is
|
# Max speed (deg/s) per joint for POS_VEL arms and FORCE_POS gripper (motor order).
|
||||||
# applied to every joint; a list provides one value per joint (in motor order).
|
pos_vel_velocity: float | list[float] = field(
|
||||||
pos_vel_velocity: float | list[float] = field(default_factory=lambda: [150.0] * 7)
|
default_factory=lambda: [150.0, 150.0, 150.0, 150.0, 150.0, 150.0, 900.0]
|
||||||
|
)
|
||||||
|
|
||||||
# Torque/current ratio for the gripper's FORCE_POS mode, in range [0, 1].
|
# Arm control: "mit" or "pos_vel".
|
||||||
gripper_torque_ratio: float = 0.1
|
control_mode: str = "mit"
|
||||||
|
|
||||||
|
# MIT kp/kd per arm joint (motor order). Unused when control_mode="pos_vel".
|
||||||
|
mit_kp: float | list[float] = field(default_factory=lambda: [45.0, 45.0, 45.0, 8.0, 9.0, 8.0, 8.0])
|
||||||
|
mit_kd: float | list[float] = field(default_factory=lambda: [12.0, 12.0, 12.0, 1.0, 1.0, 1.0, 1.0])
|
||||||
|
|
||||||
|
# Gripper control: "force_pos" or "mit".
|
||||||
|
gripper_control_mode: str = "force_pos"
|
||||||
|
|
||||||
|
# FORCE_POS only: max grip force, in [0, 1].
|
||||||
|
gripper_torque_ratio: float = 0.07
|
||||||
|
|
||||||
|
# MIT only.
|
||||||
|
gripper_mit_kp: float = 8.0
|
||||||
|
gripper_mit_kd: float = 0.3
|
||||||
|
|
||||||
# Soft joint limits (degrees). These are clipped against on every action.
|
# Soft joint limits (degrees). These are clipped against on every action.
|
||||||
joint_limits: dict[str, tuple[float, float]] = field(
|
joint_limits: dict[str, tuple[float, float]] = field(
|
||||||
default_factory=lambda: {
|
default_factory=lambda: {
|
||||||
"shoulder_pan": (-145.0, 145.0),
|
"shoulder_pan": (-150.0, 150.0),
|
||||||
"shoulder_lift": (-170.0, 1.0),
|
"shoulder_lift": (-200.0, 1.0),
|
||||||
"elbow_flex": (-200.0, 1.0),
|
"elbow_flex": (-200.0, 1.0),
|
||||||
"wrist_flex": (-80.0, 90.0),
|
"wrist_flex": (-80.0, 90.0),
|
||||||
"wrist_yaw": (-90.0, 90.0),
|
"wrist_yaw": (-90.0, 90.0),
|
||||||
|
|||||||
@@ -174,11 +174,25 @@ class RebotB601Follower(Robot):
|
|||||||
print(f"Calibration saved to {self.calibration_fpath}")
|
print(f"Calibration saved to {self.calibration_fpath}")
|
||||||
|
|
||||||
def configure(self) -> None:
|
def configure(self) -> None:
|
||||||
|
if self.config.control_mode not in ("pos_vel", "mit"):
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported control_mode '{self.config.control_mode}'. Use 'pos_vel' or 'mit'."
|
||||||
|
)
|
||||||
|
if self.config.gripper_control_mode not in ("force_pos", "mit"):
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported gripper_control_mode '{self.config.gripper_control_mode}'. "
|
||||||
|
"Use 'force_pos' or 'mit'."
|
||||||
|
)
|
||||||
|
use_mit = self.config.control_mode == "mit"
|
||||||
|
gripper_use_mit = self.config.gripper_control_mode == "mit"
|
||||||
self.bus.enable_all()
|
self.bus.enable_all()
|
||||||
for motor_name, motor in self.motors.items():
|
for motor_name, motor in self.motors.items():
|
||||||
target_mode = (
|
if motor_name == GRIPPER_MOTOR:
|
||||||
MotorBridgeMode.FORCE_POS if motor_name == GRIPPER_MOTOR else MotorBridgeMode.POS_VEL
|
target_mode = MotorBridgeMode.MIT if gripper_use_mit else MotorBridgeMode.FORCE_POS
|
||||||
)
|
elif use_mit:
|
||||||
|
target_mode = MotorBridgeMode.MIT
|
||||||
|
else:
|
||||||
|
target_mode = MotorBridgeMode.POS_VEL
|
||||||
for attempt in range(_ENSURE_MODE_RETRIES + 1):
|
for attempt in range(_ENSURE_MODE_RETRIES + 1):
|
||||||
try:
|
try:
|
||||||
motor.ensure_mode(target_mode)
|
motor.ensure_mode(target_mode)
|
||||||
@@ -264,22 +278,34 @@ class RebotB601Follower(Robot):
|
|||||||
goal_present_pos = {key: (g, present_pos.get(key, g)) for key, g in goal_pos.items()}
|
goal_present_pos = {key: (g, present_pos.get(key, g)) for key, g in goal_pos.items()}
|
||||||
goal_pos = ensure_safe_goal_position(goal_present_pos, self.config.max_relative_target)
|
goal_pos = ensure_safe_goal_position(goal_present_pos, self.config.max_relative_target)
|
||||||
|
|
||||||
|
use_mit = self.config.control_mode == "mit"
|
||||||
for motor_name, position_deg in goal_pos.items():
|
for motor_name, position_deg in goal_pos.items():
|
||||||
motor = self.motors.get(motor_name)
|
motor = self.motors.get(motor_name)
|
||||||
if motor is None:
|
if motor is None:
|
||||||
continue
|
continue
|
||||||
idx = self.motor_names.index(motor_name)
|
idx = self.motor_names.index(motor_name)
|
||||||
vel_deg_s = (
|
|
||||||
self.config.pos_vel_velocity[idx]
|
|
||||||
if isinstance(self.config.pos_vel_velocity, list)
|
|
||||||
else self.config.pos_vel_velocity
|
|
||||||
)
|
|
||||||
pos_rad = math.radians(position_deg)
|
pos_rad = math.radians(position_deg)
|
||||||
vel_rad = math.radians(vel_deg_s)
|
|
||||||
if motor_name == GRIPPER_MOTOR:
|
if motor_name == GRIPPER_MOTOR:
|
||||||
motor.send_force_pos(pos_rad, vel_rad, self.config.gripper_torque_ratio)
|
if self.config.gripper_control_mode == "mit":
|
||||||
|
motor.send_mit(pos_rad, 0.0, self.config.gripper_mit_kp, self.config.gripper_mit_kd, 0.0)
|
||||||
|
else:
|
||||||
|
vel_deg_s = (
|
||||||
|
self.config.pos_vel_velocity[idx]
|
||||||
|
if isinstance(self.config.pos_vel_velocity, list)
|
||||||
|
else self.config.pos_vel_velocity
|
||||||
|
)
|
||||||
|
motor.send_force_pos(pos_rad, math.radians(vel_deg_s), self.config.gripper_torque_ratio)
|
||||||
|
elif use_mit:
|
||||||
|
kp = self.config.mit_kp[idx] if isinstance(self.config.mit_kp, list) else self.config.mit_kp
|
||||||
|
kd = self.config.mit_kd[idx] if isinstance(self.config.mit_kd, list) else self.config.mit_kd
|
||||||
|
motor.send_mit(pos_rad, 0.0, kp, kd, 0.0)
|
||||||
else:
|
else:
|
||||||
motor.send_pos_vel(pos_rad, vel_rad)
|
vel_deg_s = (
|
||||||
|
self.config.pos_vel_velocity[idx]
|
||||||
|
if isinstance(self.config.pos_vel_velocity, list)
|
||||||
|
else self.config.pos_vel_velocity
|
||||||
|
)
|
||||||
|
motor.send_pos_vel(pos_rad, math.radians(vel_deg_s))
|
||||||
|
|
||||||
return {f"{motor}.pos": val for motor, val in goal_pos.items()}
|
return {f"{motor}.pos": val for motor, val in goal_pos.items()}
|
||||||
|
|
||||||
|
|||||||
@@ -320,7 +320,9 @@ def build_rollout_context(
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Visual feature mismatch between policy and robot hardware.\n"
|
f"Visual feature mismatch between policy and robot hardware.\n"
|
||||||
f"Policy expects: {expected_visuals}\n"
|
f"Policy expects: {expected_visuals}\n"
|
||||||
f"Robot provides: {provided_visuals}"
|
f"Robot provides: {provided_visuals}\n"
|
||||||
|
f"Use --rename_map to map camera names, e.g. "
|
||||||
|
f"""--rename_map='{{"observation.images.top": "observation.images.cam0"}}'"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- 5. Dataset -------------
|
# --- 5. Dataset -------------
|
||||||
|
|||||||
@@ -77,6 +77,21 @@ from lerobot.utils.constants import ACTION, DONE, OBS_STATE, REWARD
|
|||||||
from lerobot.utils.utils import init_logging
|
from lerobot.utils.utils import init_logging
|
||||||
|
|
||||||
|
|
||||||
|
def get_feature_names(dataset: LeRobotDataset, key: str) -> list[str]:
|
||||||
|
"""Return per-dimension names for a feature from the dataset metadata.
|
||||||
|
|
||||||
|
Only flat-list ``names`` metadata is used. Dict-style ``names`` and missing names fall back to ``{key}_{i}`` indices.
|
||||||
|
"""
|
||||||
|
feature = dataset.features[key]
|
||||||
|
dim = feature["shape"][-1]
|
||||||
|
|
||||||
|
names = feature.get("names")
|
||||||
|
if isinstance(names, list) and len(names) == dim:
|
||||||
|
return [str(name) for name in names]
|
||||||
|
|
||||||
|
return [f"{key}_{d}" for d in range(dim)]
|
||||||
|
|
||||||
|
|
||||||
def check_chw_float32(frame: torch.Tensor) -> None:
|
def check_chw_float32(frame: torch.Tensor) -> None:
|
||||||
"""
|
"""
|
||||||
Check if a frame is a channel-first, float32 tensor.
|
Check if a frame is a channel-first, float32 tensor.
|
||||||
@@ -93,6 +108,31 @@ def to_hwc_uint8_numpy(chw_float32_torch: torch.Tensor) -> np.ndarray:
|
|||||||
return hwc_uint8_numpy
|
return hwc_uint8_numpy
|
||||||
|
|
||||||
|
|
||||||
|
def build_blueprint_from_dataset(dataset: LeRobotDataset):
|
||||||
|
"""Build a Rerun blueprint laying out camera images and time series for the given dataset.
|
||||||
|
|
||||||
|
Camera images and scalar signals (action, state, reward, done, success) are arranged in a grid.
|
||||||
|
The per-dimension series names for ``action`` and ``state`` are applied directly
|
||||||
|
via blueprint overrides.
|
||||||
|
"""
|
||||||
|
import rerun as rr
|
||||||
|
import rerun.blueprint as rrb
|
||||||
|
|
||||||
|
views = [rrb.Spatial2DView(origin=key, name=key) for key in dataset.meta.camera_keys]
|
||||||
|
|
||||||
|
# Style multi-dimensional signals (action, state) with per-dimension names.
|
||||||
|
for origin, key in ((ACTION, ACTION), ("state", OBS_STATE)):
|
||||||
|
if key in dataset.features:
|
||||||
|
names = get_feature_names(dataset, key)
|
||||||
|
styling = rr.SeriesLines(names=names)
|
||||||
|
views.append(rrb.TimeSeriesView(origin=origin, name=origin, overrides={origin: styling}))
|
||||||
|
for key in (DONE, REWARD, "next.success"):
|
||||||
|
if key in dataset.features:
|
||||||
|
views.append(rrb.TimeSeriesView(origin=key, name=key))
|
||||||
|
|
||||||
|
return rrb.Blueprint(rrb.Grid(*views))
|
||||||
|
|
||||||
|
|
||||||
def to_hwc_uint16_numpy(chw_float32_torch: torch.Tensor) -> np.ndarray:
|
def to_hwc_uint16_numpy(chw_float32_torch: torch.Tensor) -> np.ndarray:
|
||||||
check_chw_float32(chw_float32_torch)
|
check_chw_float32(chw_float32_torch)
|
||||||
hwc_uint16_numpy = chw_float32_torch.round().type(torch.uint16).permute(1, 2, 0).numpy()
|
hwc_uint16_numpy = chw_float32_torch.round().type(torch.uint16).permute(1, 2, 0).numpy()
|
||||||
@@ -137,7 +177,8 @@ def visualize_dataset(
|
|||||||
import rerun as rr
|
import rerun as rr
|
||||||
|
|
||||||
spawn_local_viewer = mode == "local" and not save
|
spawn_local_viewer = mode == "local" and not save
|
||||||
rr.init(f"{repo_id}/episode_{episode_index}", spawn=spawn_local_viewer)
|
blueprint = build_blueprint_from_dataset(dataset)
|
||||||
|
rr.init(f"{repo_id}/episode_{episode_index}", spawn=spawn_local_viewer, default_blueprint=blueprint)
|
||||||
|
|
||||||
# Manually call python garbage collector after `rr.init` to avoid hanging in a blocking flush
|
# Manually call python garbage collector after `rr.init` to avoid hanging in a blocking flush
|
||||||
# when iterating on a dataloader with `num_workers` > 0
|
# when iterating on a dataloader with `num_workers` > 0
|
||||||
@@ -163,12 +204,13 @@ def visualize_dataset(
|
|||||||
for batch in tqdm.tqdm(dataloader, total=len(dataloader)):
|
for batch in tqdm.tqdm(dataloader, total=len(dataloader)):
|
||||||
if first_index is None:
|
if first_index is None:
|
||||||
first_index = batch["index"][0].item()
|
first_index = batch["index"][0].item()
|
||||||
|
|
||||||
# iterate over the batch
|
# iterate over the batch
|
||||||
for i in range(len(batch["index"])):
|
for i in range(len(batch["index"])):
|
||||||
rr.set_time("frame_index", sequence=batch["index"][i].item() - first_index)
|
rr.set_time("frame_index", sequence=batch["index"][i].item() - first_index)
|
||||||
rr.set_time("timestamp", timestamp=batch["timestamp"][i].item())
|
rr.set_time("timestamp", timestamp=batch["timestamp"][i].item())
|
||||||
|
|
||||||
# display each camera image
|
# display each camera image (or depth map)
|
||||||
for key in dataset.meta.camera_keys:
|
for key in dataset.meta.camera_keys:
|
||||||
if key in dataset.meta.depth_keys:
|
if key in dataset.meta.depth_keys:
|
||||||
depth = to_hwc_uint16_numpy(batch[key][i])
|
depth = to_hwc_uint16_numpy(batch[key][i])
|
||||||
@@ -183,15 +225,13 @@ def visualize_dataset(
|
|||||||
img_entity = rr.Image(img).compress() if display_compressed_images else rr.Image(img)
|
img_entity = rr.Image(img).compress() if display_compressed_images else rr.Image(img)
|
||||||
rr.log(key, entity=img_entity)
|
rr.log(key, entity=img_entity)
|
||||||
|
|
||||||
# display each dimension of action space (e.g. actuators command)
|
# display the action space (e.g. actuators command)
|
||||||
if ACTION in batch:
|
if ACTION in batch:
|
||||||
for dim_idx, val in enumerate(batch[ACTION][i]):
|
rr.log(ACTION, rr.Scalars(batch[ACTION][i].numpy()))
|
||||||
rr.log(f"{ACTION}/{dim_idx}", rr.Scalars(val.item()))
|
|
||||||
|
|
||||||
# display each dimension of observed state space (e.g. agent position in joint space)
|
# display the observed state space (e.g. agent position in joint space)
|
||||||
if OBS_STATE in batch:
|
if OBS_STATE in batch:
|
||||||
for dim_idx, val in enumerate(batch[OBS_STATE][i]):
|
rr.log("state", rr.Scalars(batch[OBS_STATE][i].numpy()))
|
||||||
rr.log(f"state/{dim_idx}", rr.Scalars(val.item()))
|
|
||||||
|
|
||||||
if DONE in batch:
|
if DONE in batch:
|
||||||
rr.log(DONE, rr.Scalars(batch[DONE][i].item()))
|
rr.log(DONE, rr.Scalars(batch[DONE][i].item()))
|
||||||
@@ -202,9 +242,8 @@ def visualize_dataset(
|
|||||||
if "next.success" in batch:
|
if "next.success" in batch:
|
||||||
rr.log("next.success", rr.Scalars(batch["next.success"][i].item()))
|
rr.log("next.success", rr.Scalars(batch["next.success"][i].item()))
|
||||||
|
|
||||||
|
# save .rrd locally
|
||||||
if mode == "local" and save:
|
if mode == "local" and save:
|
||||||
# save .rrd locally
|
|
||||||
output_dir = Path(output_dir)
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
repo_id_str = repo_id.replace("/", "_")
|
repo_id_str = repo_id.replace("/", "_")
|
||||||
rrd_path = output_dir / f"{repo_id_str}_episode_{episode_index}.rrd"
|
rrd_path = output_dir / f"{repo_id_str}_episode_{episode_index}.rrd"
|
||||||
@@ -212,7 +251,7 @@ def visualize_dataset(
|
|||||||
return rrd_path
|
return rrd_path
|
||||||
|
|
||||||
elif mode == "distant":
|
elif mode == "distant":
|
||||||
# stop the process from exiting since it is serving the websocket connection
|
# Keep the process alive while it serves the gRPC/web connection.
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
@@ -327,12 +366,14 @@ def main():
|
|||||||
)
|
)
|
||||||
logging.warning("Setting grpc_port to ws_port value.")
|
logging.warning("Setting grpc_port to ws_port value.")
|
||||||
kwargs["grpc_port"] = kwargs.pop("ws_port")
|
kwargs["grpc_port"] = kwargs.pop("ws_port")
|
||||||
|
else:
|
||||||
|
kwargs.pop("ws_port") # Always remove ws_port from kwargs
|
||||||
|
|
||||||
init_logging()
|
init_logging()
|
||||||
logging.info("Loading dataset")
|
logging.info("Loading dataset")
|
||||||
dataset = LeRobotDataset(repo_id, episodes=[args.episode_index], root=root, tolerance_s=tolerance_s)
|
dataset = LeRobotDataset(repo_id, episodes=[args.episode_index], root=root, tolerance_s=tolerance_s)
|
||||||
|
|
||||||
visualize_dataset(dataset, **vars(args))
|
visualize_dataset(dataset, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Requires: pip install 'lerobot[training]' (includes dataset + accelerate + wand
|
|||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
@@ -41,15 +42,17 @@ from lerobot.common.train_utils import (
|
|||||||
load_training_batch_size,
|
load_training_batch_size,
|
||||||
load_training_num_processes,
|
load_training_num_processes,
|
||||||
load_training_state,
|
load_training_state,
|
||||||
|
push_checkpoint_to_hub,
|
||||||
save_checkpoint,
|
save_checkpoint,
|
||||||
update_last_checkpoint,
|
update_last_checkpoint,
|
||||||
)
|
)
|
||||||
from lerobot.common.wandb_utils import WandBLogger
|
from lerobot.common.wandb_utils import WandBLogger
|
||||||
from lerobot.configs import parser
|
from lerobot.configs import JobConfig, parser
|
||||||
from lerobot.configs.train import TrainPipelineConfig
|
from lerobot.configs.train import TrainPipelineConfig
|
||||||
from lerobot.datasets import EpisodeAwareSampler, compute_sampler_state
|
from lerobot.datasets import EpisodeAwareSampler, compute_sampler_state
|
||||||
from lerobot.datasets.factory import make_train_eval_datasets
|
from lerobot.datasets.factory import make_train_eval_datasets
|
||||||
from lerobot.envs import close_envs, make_env, make_env_pre_post_processors
|
from lerobot.envs import close_envs, make_env, make_env_pre_post_processors
|
||||||
|
from lerobot.jobs import submit_to_hf
|
||||||
from lerobot.optim.factory import make_optimizer_and_scheduler
|
from lerobot.optim.factory import make_optimizer_and_scheduler
|
||||||
from lerobot.policies import PreTrainedPolicy, make_policy, make_pre_post_processors
|
from lerobot.policies import PreTrainedPolicy, make_policy, make_pre_post_processors
|
||||||
from lerobot.rewards import make_reward_pre_post_processors
|
from lerobot.rewards import make_reward_pre_post_processors
|
||||||
@@ -188,6 +191,9 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
|
|||||||
cfg: A `TrainPipelineConfig` object containing all training configurations.
|
cfg: A `TrainPipelineConfig` object containing all training configurations.
|
||||||
accelerator: Optional Accelerator instance. If None, one will be created automatically.
|
accelerator: Optional Accelerator instance. If None, one will be created automatically.
|
||||||
"""
|
"""
|
||||||
|
if cfg.job.is_remote:
|
||||||
|
return submit_to_hf(cfg)
|
||||||
|
|
||||||
from lerobot.utils.import_utils import require_package
|
from lerobot.utils.import_utils import require_package
|
||||||
|
|
||||||
require_package("accelerate", extra="training")
|
require_package("accelerate", extra="training")
|
||||||
@@ -655,6 +661,12 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
|
|||||||
optim_state_dict=optim_state_dict,
|
optim_state_dict=optim_state_dict,
|
||||||
)
|
)
|
||||||
update_last_checkpoint(checkpoint_dir)
|
update_last_checkpoint(checkpoint_dir)
|
||||||
|
if cfg.save_checkpoint_to_hub:
|
||||||
|
push_checkpoint_to_hub(
|
||||||
|
checkpoint_dir,
|
||||||
|
cfg.policy.repo_id,
|
||||||
|
private=cfg.policy.private,
|
||||||
|
)
|
||||||
if wandb_logger:
|
if wandb_logger:
|
||||||
wandb_logger.log_policy(checkpoint_dir)
|
wandb_logger.log_policy(checkpoint_dir)
|
||||||
|
|
||||||
@@ -724,9 +736,9 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
|
|||||||
unwrapped_model = accelerator.unwrap_model(policy)
|
unwrapped_model = accelerator.unwrap_model(policy)
|
||||||
# PEFT only applies when training a policy — reward models use the plain path.
|
# PEFT only applies when training a policy — reward models use the plain path.
|
||||||
if not cfg.is_reward_model_training and cfg.policy.use_peft:
|
if not cfg.is_reward_model_training and cfg.policy.use_peft:
|
||||||
unwrapped_model.push_model_to_hub(cfg, peft_model=unwrapped_model)
|
unwrapped_model.push_model_to_hub(cfg, peft_model=unwrapped_model, dataset_meta=dataset.meta)
|
||||||
else:
|
else:
|
||||||
unwrapped_model.push_model_to_hub(cfg, state_dict=model_state_dict)
|
unwrapped_model.push_model_to_hub(cfg, state_dict=model_state_dict, dataset_meta=dataset.meta)
|
||||||
preprocessor.push_to_hub(active_cfg.repo_id)
|
preprocessor.push_to_hub(active_cfg.repo_id)
|
||||||
postprocessor.push_to_hub(active_cfg.repo_id)
|
postprocessor.push_to_hub(active_cfg.repo_id)
|
||||||
|
|
||||||
@@ -735,8 +747,25 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
|
|||||||
accelerator.end_training()
|
accelerator.end_training()
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_target_in_argv() -> bool:
|
||||||
|
"""True when the CLI requests a remote HF Jobs run (--job.target=<non-local>)."""
|
||||||
|
target = None
|
||||||
|
args = sys.argv[1:]
|
||||||
|
for i, tok in enumerate(args):
|
||||||
|
if tok == "--job.target" and i + 1 < len(args):
|
||||||
|
target = args[i + 1]
|
||||||
|
elif tok.startswith("--job.target="):
|
||||||
|
target = tok.split("=", 1)[1]
|
||||||
|
return JobConfig.is_remote_target(target)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
register_third_party_plugins()
|
register_third_party_plugins()
|
||||||
|
if _remote_target_in_argv():
|
||||||
|
# The policy device is resolved on the remote pod, not here, so silence the
|
||||||
|
# client-side "Device '...' is not available" warning PreTrainedConfig emits
|
||||||
|
# while parsing the config (it fires before train() can dispatch remotely).
|
||||||
|
logging.getLogger("lerobot.configs.policies").setLevel(logging.ERROR)
|
||||||
train()
|
train()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class RebotArm102LeaderConfig:
|
|||||||
joint_ranges: dict[str, list[int]] = field(
|
joint_ranges: dict[str, list[int]] = field(
|
||||||
default_factory=lambda: {
|
default_factory=lambda: {
|
||||||
"shoulder_pan": [-150, 150],
|
"shoulder_pan": [-150, 150],
|
||||||
"shoulder_lift": [-170, 1],
|
"shoulder_lift": [-200, 1],
|
||||||
"elbow_flex": [-200, 1],
|
"elbow_flex": [-200, 1],
|
||||||
"wrist_flex": [-80, 90],
|
"wrist_flex": [-80, 90],
|
||||||
"wrist_yaw": [-90, 90],
|
"wrist_yaw": [-90, 90],
|
||||||
|
|||||||
@@ -20,9 +20,33 @@ from typing import Any, TypeVar
|
|||||||
from huggingface_hub import HfApi
|
from huggingface_hub import HfApi
|
||||||
from huggingface_hub.utils import validate_hf_hub_args
|
from huggingface_hub.utils import validate_hf_hub_args
|
||||||
|
|
||||||
|
from .constants import CHECKPOINTS_DIR
|
||||||
|
|
||||||
T = TypeVar("T", bound="HubMixin")
|
T = TypeVar("T", bound="HubMixin")
|
||||||
|
|
||||||
|
|
||||||
|
def find_latest_hub_checkpoint(
|
||||||
|
repo_id: str,
|
||||||
|
*,
|
||||||
|
token: str | bool | None = None,
|
||||||
|
revision: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Repo-relative path of the most recent checkpoint in a training repo.
|
||||||
|
|
||||||
|
Training runs push checkpoints to ``checkpoints/<step>/`` (see
|
||||||
|
``push_checkpoint_to_hub``). This lists those step dirs and returns
|
||||||
|
``checkpoints/<highest-step>``, or ``None`` if the repo has no checkpoints.
|
||||||
|
"""
|
||||||
|
files = HfApi().list_repo_files(repo_id=repo_id, repo_type="model", revision=revision, token=token)
|
||||||
|
prefix = f"{CHECKPOINTS_DIR}/"
|
||||||
|
steps = {
|
||||||
|
name for f in files if f.startswith(prefix) and (name := f[len(prefix) :].split("/", 1)[0]).isdigit()
|
||||||
|
}
|
||||||
|
if not steps:
|
||||||
|
return None
|
||||||
|
return f"{CHECKPOINTS_DIR}/{max(steps, key=int)}"
|
||||||
|
|
||||||
|
|
||||||
class HubMixin:
|
class HubMixin:
|
||||||
"""
|
"""
|
||||||
A Mixin containing the functionality to push an object to the hub.
|
A Mixin containing the functionality to push an object to the hub.
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ def init_rerun(
|
|||||||
require_package("rerun-sdk", extra="viz", import_name="rerun")
|
require_package("rerun-sdk", extra="viz", import_name="rerun")
|
||||||
import rerun as rr
|
import rerun as rr
|
||||||
|
|
||||||
|
log_rerun_data.blueprint = None # Reset blueprint cache for new session
|
||||||
|
|
||||||
batch_size = os.getenv("RERUN_FLUSH_NUM_BYTES", "8000")
|
batch_size = os.getenv("RERUN_FLUSH_NUM_BYTES", "8000")
|
||||||
os.environ["RERUN_FLUSH_NUM_BYTES"] = batch_size
|
os.environ["RERUN_FLUSH_NUM_BYTES"] = batch_size
|
||||||
rr.init(session_name)
|
rr.init(session_name)
|
||||||
@@ -63,6 +65,41 @@ def _is_scalar(x):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_blueprint(observation_paths: set[str], action_paths: set[str], image_paths: set[str]):
|
||||||
|
"""Build a Rerun blueprint laying out camera images, observation and action scalars in separate views.
|
||||||
|
|
||||||
|
Camera images, observation and action scalars are arranged in a grid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Safe + zero-overhead: `log_rerun_data` already ran the `require_package` guard and imported rerun.
|
||||||
|
import rerun.blueprint as rrb
|
||||||
|
|
||||||
|
views = [rrb.Spatial2DView(origin=path, name=path) for path in sorted(image_paths)]
|
||||||
|
|
||||||
|
if observation_paths:
|
||||||
|
views.append(rrb.TimeSeriesView(name="observation", contents=sorted(observation_paths)))
|
||||||
|
if action_paths:
|
||||||
|
views.append(rrb.TimeSeriesView(name="action", contents=sorted(action_paths)))
|
||||||
|
|
||||||
|
return rrb.Blueprint(rrb.Grid(*views))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_blueprint(observation_paths: set[str], action_paths: set[str], image_paths: set[str]) -> None:
|
||||||
|
"""Build and send the blueprint once, from the first observation and action data."""
|
||||||
|
if getattr(log_rerun_data, "blueprint", None) is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not (observation_paths or action_paths or image_paths):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Safe + zero-overhead: `log_rerun_data` already ran the `require_package` guard and imported rerun.
|
||||||
|
import rerun as rr
|
||||||
|
|
||||||
|
blueprint = _build_blueprint(observation_paths, action_paths, image_paths)
|
||||||
|
log_rerun_data.blueprint = blueprint
|
||||||
|
rr.send_blueprint(blueprint)
|
||||||
|
|
||||||
|
|
||||||
def log_rerun_data(
|
def log_rerun_data(
|
||||||
observation: RobotObservation | None = None,
|
observation: RobotObservation | None = None,
|
||||||
action: RobotAction | None = None,
|
action: RobotAction | None = None,
|
||||||
@@ -76,11 +113,15 @@ def log_rerun_data(
|
|||||||
- Scalars values (floats, ints) are logged as `rr.Scalars`.
|
- Scalars values (floats, ints) are logged as `rr.Scalars`.
|
||||||
- 3D NumPy arrays that resemble images (e.g., with 1, 3, or 4 channels first) are transposed
|
- 3D NumPy arrays that resemble images (e.g., with 1, 3, or 4 channels first) are transposed
|
||||||
from CHW to HWC format, (optionally) compressed to JPEG and logged as `rr.Image` or `rr.EncodedImage`.
|
from CHW to HWC format, (optionally) compressed to JPEG and logged as `rr.Image` or `rr.EncodedImage`.
|
||||||
- 1D NumPy arrays are logged as a series of individual scalars, with each element indexed.
|
- 1D NumPy arrays are logged as a single `rr.Scalars` batch under one entity path, so that every
|
||||||
- Other multi-dimensional arrays are flattened and logged as individual scalars.
|
dimension shares the same view instead of being split across one view per element.
|
||||||
|
- Multi-dimensional **action** arrays are flattened and logged as a single `rr.Scalars` batch.
|
||||||
|
|
||||||
Keys are automatically namespaced with "observation." or "action." if not already present.
|
Keys are automatically namespaced with "observation." or "action." if not already present.
|
||||||
|
|
||||||
|
On the first call, a blueprint is built and sent so observation and action scalars get separate
|
||||||
|
time-series views and each image gets its own spatial view.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
observation: An optional dictionary containing observation data to log.
|
observation: An optional dictionary containing observation data to log.
|
||||||
action: An optional dictionary containing action data to log.
|
action: An optional dictionary containing action data to log.
|
||||||
@@ -90,6 +131,10 @@ def log_rerun_data(
|
|||||||
require_package("rerun-sdk", extra="viz", import_name="rerun")
|
require_package("rerun-sdk", extra="viz", import_name="rerun")
|
||||||
import rerun as rr
|
import rerun as rr
|
||||||
|
|
||||||
|
observation_paths: set[str] = set()
|
||||||
|
action_paths: set[str] = set()
|
||||||
|
image_paths: set[str] = set()
|
||||||
|
|
||||||
if observation:
|
if observation:
|
||||||
for k, v in observation.items():
|
for k, v in observation.items():
|
||||||
if v is None:
|
if v is None:
|
||||||
@@ -98,20 +143,22 @@ def log_rerun_data(
|
|||||||
|
|
||||||
if _is_scalar(v):
|
if _is_scalar(v):
|
||||||
rr.log(key, rr.Scalars(float(v)))
|
rr.log(key, rr.Scalars(float(v)))
|
||||||
|
observation_paths.add(key)
|
||||||
elif isinstance(v, np.ndarray):
|
elif isinstance(v, np.ndarray):
|
||||||
arr = v
|
arr = v
|
||||||
# Convert CHW -> HWC when needed
|
# Convert CHW -> HWC when needed
|
||||||
if arr.ndim == 3 and arr.shape[0] in (1, 3, 4) and arr.shape[-1] not in (1, 3, 4):
|
if arr.ndim == 3 and arr.shape[0] in (1, 3, 4) and arr.shape[-1] not in (1, 3, 4):
|
||||||
arr = np.transpose(arr, (1, 2, 0))
|
arr = np.transpose(arr, (1, 2, 0))
|
||||||
if arr.ndim == 1:
|
if arr.ndim == 1:
|
||||||
for i, vi in enumerate(arr):
|
rr.log(key, rr.Scalars(arr.astype(float)))
|
||||||
rr.log(f"{key}_{i}", rr.Scalars(float(vi)))
|
observation_paths.add(key)
|
||||||
else:
|
else:
|
||||||
if arr.shape[-1] == 1:
|
if arr.shape[-1] == 1:
|
||||||
img_entity = rr.DepthImage(arr, colormap=rr.components.Colormap.Viridis)
|
img_entity = rr.DepthImage(arr, colormap=rr.components.Colormap.Viridis)
|
||||||
else:
|
else:
|
||||||
img_entity = rr.Image(arr).compress() if compress_images else rr.Image(arr)
|
img_entity = rr.Image(arr).compress() if compress_images else rr.Image(arr)
|
||||||
rr.log(key, entity=img_entity, static=True)
|
rr.log(key, entity=img_entity, static=True)
|
||||||
|
image_paths.add(key)
|
||||||
|
|
||||||
if action:
|
if action:
|
||||||
for k, v in action.items():
|
for k, v in action.items():
|
||||||
@@ -121,12 +168,10 @@ def log_rerun_data(
|
|||||||
|
|
||||||
if _is_scalar(v):
|
if _is_scalar(v):
|
||||||
rr.log(key, rr.Scalars(float(v)))
|
rr.log(key, rr.Scalars(float(v)))
|
||||||
|
action_paths.add(key)
|
||||||
elif isinstance(v, np.ndarray):
|
elif isinstance(v, np.ndarray):
|
||||||
if v.ndim == 1:
|
# Flatten any (incl. higher-dimensional) array into a single batched Scalars
|
||||||
for i, vi in enumerate(v):
|
rr.log(key, rr.Scalars(v.reshape(-1).astype(float)))
|
||||||
rr.log(f"{key}_{i}", rr.Scalars(float(vi)))
|
action_paths.add(key)
|
||||||
else:
|
|
||||||
# Fall back to flattening higher-dimensional arrays
|
_ensure_blueprint(observation_paths, action_paths, image_paths)
|
||||||
flat = v.flatten()
|
|
||||||
for i, vi in enumerate(flat):
|
|
||||||
rr.log(f"{key}_{i}", rr.Scalars(float(vi)))
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import lerobot.configs.train as tc
|
||||||
|
from lerobot.configs.train import TrainPipelineConfig
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeHTTPError(tc.HfHubHTTPError):
|
||||||
|
"""HfHubHTTPError that can be raised without a real HTTP response object."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_pretrained_falls_back_to_latest_checkpoint_config(tmp_path, monkeypatch):
|
||||||
|
"""A Hub repo with no root train_config.json (an interrupted run that only pushed
|
||||||
|
checkpoints/) resolves via the latest checkpoint's config."""
|
||||||
|
# A real train_config.json written by save_pretrained, to be returned by the fallback.
|
||||||
|
parsed = tc.draccus.parse(TrainPipelineConfig, args=["--dataset.repo_id", "u/d"])
|
||||||
|
cfg_file = tmp_path / "train_config.json"
|
||||||
|
parsed._save_pretrained(tmp_path)
|
||||||
|
assert cfg_file.is_file()
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_hf_hub_download(filename=None, **kwargs):
|
||||||
|
calls.append(filename)
|
||||||
|
if filename == "train_config.json":
|
||||||
|
raise _FakeHTTPError() # no root config
|
||||||
|
if filename == "checkpoints/000010/pretrained_model/train_config.json":
|
||||||
|
return str(cfg_file)
|
||||||
|
raise AssertionError(f"unexpected filename {filename}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tc, "hf_hub_download", fake_hf_hub_download)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tc, "find_latest_hub_checkpoint", lambda repo_id, token=None, revision=None: "checkpoints/000010"
|
||||||
|
)
|
||||||
|
|
||||||
|
loaded = TrainPipelineConfig.from_pretrained("user/interrupted-run")
|
||||||
|
assert loaded.dataset.repo_id == "u/d"
|
||||||
|
# Tried the root config first, then fell back to the latest checkpoint's config.
|
||||||
|
assert calls == ["train_config.json", "checkpoints/000010/pretrained_model/train_config.json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_pretrained_raises_when_no_root_config_and_no_checkpoints(monkeypatch):
|
||||||
|
"""No root config AND no checkpoints → a clear FileNotFoundError, not the raw HTTP error."""
|
||||||
|
|
||||||
|
def fake_hf_hub_download(filename=None, **kwargs):
|
||||||
|
raise _FakeHTTPError()
|
||||||
|
|
||||||
|
monkeypatch.setattr(tc, "hf_hub_download", fake_hf_hub_download)
|
||||||
|
monkeypatch.setattr(tc, "find_latest_hub_checkpoint", lambda repo_id, token=None, revision=None: None)
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError, match="train_config.json not found"):
|
||||||
|
TrainPipelineConfig.from_pretrained("user/empty-repo")
|
||||||
@@ -14,14 +14,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.
|
||||||
|
|
||||||
|
import functools
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
import draccus.wrappers.docstring as _draccus_docstring
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from lerobot.configs.types import FeatureType, PipelineFeatureType, PolicyFeature
|
from lerobot.configs.types import FeatureType, PipelineFeatureType, PolicyFeature
|
||||||
from lerobot.utils.import_utils import is_package_available
|
from lerobot.utils.import_utils import is_package_available
|
||||||
from tests.utils import DEVICE
|
from tests.utils import DEVICE
|
||||||
|
|
||||||
|
# On every `draccus.parse()`, draccus rebuilds each dataclass field's help text by
|
||||||
|
# re-reading and re-parsing the class source (draccus.wrappers.docstring). For a config
|
||||||
|
# as large as TrainPipelineConfig this costs ~2.5s per parse — negligible for the single
|
||||||
|
# parse a CLI does, but tests parse configs hundreds of times. The source can't change
|
||||||
|
# within a run, so memoize it for the whole test session.
|
||||||
|
_draccus_docstring.get_attribute_docstring = functools.cache(_draccus_docstring.get_attribute_docstring)
|
||||||
|
|
||||||
# Import fixture modules as plugins.
|
# Import fixture modules as plugins.
|
||||||
# Fixtures that depend on optional packages are only registered when those packages are available,
|
# Fixtures that depend on optional packages are only registered when those packages are available,
|
||||||
# so that tests can be collected and run even with a minimal install.
|
# so that tests can be collected and run even with a minimal install.
|
||||||
|
|||||||
@@ -245,3 +245,44 @@ class TestFeatureFileRouting:
|
|||||||
|
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
dataset.finalize()
|
dataset.finalize()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5. Depth stats unit canonicalization (millimetres) ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDepthStatsUnit:
|
||||||
|
"""Depth stats are always stored in millimetres, regardless of raw frame dtype."""
|
||||||
|
|
||||||
|
NUM_FRAMES = 4
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("use_videos", [False, True])
|
||||||
|
def test_stats_canonicalized_to_mm(self, tmp_path, features_factory, use_videos):
|
||||||
|
"""Float (metre) and integer (millimetre) depth over the same physical range
|
||||||
|
yield identical millimetre-scale stats."""
|
||||||
|
from lerobot.datasets.lerobot_dataset import LeRobotDataset
|
||||||
|
|
||||||
|
def _record(depth_dtype, root):
|
||||||
|
features = features_factory(
|
||||||
|
camera_features=DUMMY_CAMERA_FEATURES_WITH_DEPTH, use_videos=use_videos
|
||||||
|
)
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID,
|
||||||
|
fps=DEFAULT_FPS,
|
||||||
|
features=features,
|
||||||
|
root=root,
|
||||||
|
use_videos=use_videos,
|
||||||
|
streaming_encoding=use_videos,
|
||||||
|
)
|
||||||
|
add_frames(dataset, num_frames=self.NUM_FRAMES, depth_dtype=depth_dtype)
|
||||||
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
return np.asarray(dataset.meta.stats[DEPTH_KEY]["mean"]).reshape(-1)
|
||||||
|
|
||||||
|
# add_frames ramps float depth over 0.1–10 m and integer depth over 100–10000 mm
|
||||||
|
# (the same physical range), so canonicalized stats must match.
|
||||||
|
mean_m = _record(np.float32, tmp_path / "ds_m")
|
||||||
|
mean_mm = _record(np.uint16, tmp_path / "ds_mm")
|
||||||
|
|
||||||
|
# Float (metre) input is scaled to millimetres, not left in the single-digit metre range.
|
||||||
|
assert mean_m.item() > 50.0
|
||||||
|
np.testing.assert_allclose(mean_m, mean_mm, rtol=0.05)
|
||||||
|
|||||||
Vendored
+12
-7
@@ -49,16 +49,18 @@ from tests.fixtures.constants import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def add_frames(dataset: LeRobotDataset, num_frames: int) -> None:
|
def add_frames(dataset: LeRobotDataset, num_frames: int, depth_dtype: np.dtype = np.uint16) -> None:
|
||||||
"""Append ``num_frames`` synthetic frames to ``dataset``.
|
"""Append ``num_frames`` synthetic frames to ``dataset``.
|
||||||
|
|
||||||
Generates per-feature payloads from ``dataset.meta``: uint16 depth ramps for
|
Generates per-feature payloads from ``dataset.meta``: depth ramps (``depth_dtype``,
|
||||||
keys in ``dataset.meta.depth_keys``, uint8 random noise for video/image keys,
|
default ``uint16`` millimetres; pass ``np.float32`` for metres) for keys in
|
||||||
and float32 zeros for everything else. ``DEFAULT_FEATURES`` (timestamp,
|
``dataset.meta.depth_keys``, uint8 random noise for video/image keys, and float32
|
||||||
frame_index, ...) are auto-populated by ``add_frame`` and skipped here.
|
zeros for everything else. ``DEFAULT_FEATURES`` (timestamp, frame_index, ...) are
|
||||||
|
auto-populated by ``add_frame`` and skipped here.
|
||||||
"""
|
"""
|
||||||
video_keys = dataset.meta.video_keys
|
video_keys = dataset.meta.video_keys
|
||||||
depth_keys = dataset.meta.depth_keys
|
depth_keys = dataset.meta.depth_keys
|
||||||
|
depth_is_float = np.issubdtype(depth_dtype, np.floating)
|
||||||
# Smooth gradient base reused per (H, W) to keep depth frames cheap to
|
# Smooth gradient base reused per (H, W) to keep depth frames cheap to
|
||||||
# encode (HEVC Main 12 hates white noise).
|
# encode (HEVC Main 12 hates white noise).
|
||||||
_depth_base_cache: dict[tuple[int, int], np.ndarray] = {}
|
_depth_base_cache: dict[tuple[int, int], np.ndarray] = {}
|
||||||
@@ -70,11 +72,14 @@ def add_frames(dataset: LeRobotDataset, num_frames: int) -> None:
|
|||||||
shape = ft["shape"]
|
shape = ft["shape"]
|
||||||
if key in depth_keys:
|
if key in depth_keys:
|
||||||
h, w, _ = shape
|
h, w, _ = shape
|
||||||
|
# Float depth is expressed in metres, integer depth in millimetres.
|
||||||
|
lo, hi = (0.1, 10.0) if depth_is_float else (100.0, 10_000.0)
|
||||||
base = _depth_base_cache.setdefault(
|
base = _depth_base_cache.setdefault(
|
||||||
(h, w),
|
(h, w),
|
||||||
np.linspace(100.0, 10_000.0, h * w, dtype=np.float32).reshape(h, w, 1),
|
np.linspace(lo, hi, h * w, dtype=np.float32).reshape(h, w, 1),
|
||||||
)
|
)
|
||||||
frame[key] = (base + 50.0 * i).clip(0, 65535).astype(np.uint16)
|
step = (0.05 if depth_is_float else 50.0) * i
|
||||||
|
frame[key] = (base + step).clip(0, 65535).astype(depth_dtype)
|
||||||
elif key in video_keys:
|
elif key in video_keys:
|
||||||
frame[key] = np.random.randint(0, 256, shape, dtype=np.uint8)
|
frame[key] = np.random.randint(0, 256, shape, dtype=np.uint8)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
# Importing concrete policy configs registers their draccus `--policy.type`
|
||||||
|
# choices (e.g. "act") so tests can parse them.
|
||||||
|
from lerobot.policies.act.configuration_act import ACTConfig # noqa: F401
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 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 unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])")
|
||||||
|
|
||||||
|
from lerobot.jobs.dataset import ensure_dataset_available
|
||||||
|
|
||||||
|
|
||||||
|
def _api_with_dataset(exists: bool):
|
||||||
|
api = MagicMock()
|
||||||
|
api.repo_exists.return_value = exists
|
||||||
|
return api
|
||||||
|
|
||||||
|
|
||||||
|
def _make_local_cache(tmp_path, repo_id: str) -> None:
|
||||||
|
"""Create the minimal local-cache layout that ensure_dataset_available checks."""
|
||||||
|
info = tmp_path / repo_id / "meta" / "info.json"
|
||||||
|
info.parent.mkdir(parents=True)
|
||||||
|
info.write_text("{}")
|
||||||
|
|
||||||
|
|
||||||
|
# Branch 1: dataset already on Hub → no push, no error (pod downloads by repo_id).
|
||||||
|
def test_dataset_already_on_hub_is_noop():
|
||||||
|
api = _api_with_dataset(True)
|
||||||
|
assert ensure_dataset_available("user/ds", api=api) is None
|
||||||
|
api.repo_exists.assert_called_once_with("user/ds", repo_type="dataset")
|
||||||
|
|
||||||
|
|
||||||
|
# Branch 2: not on Hub but present locally → always push privately.
|
||||||
|
def test_dataset_local_only_uploads_privately(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("lerobot.jobs.dataset.HF_LEROBOT_HOME", tmp_path)
|
||||||
|
_make_local_cache(tmp_path, "user/ds")
|
||||||
|
|
||||||
|
api = _api_with_dataset(False)
|
||||||
|
mock_ds_cls = MagicMock()
|
||||||
|
monkeypatch.setattr("lerobot.jobs.dataset.LeRobotDataset", mock_ds_cls)
|
||||||
|
|
||||||
|
assert ensure_dataset_available("user/ds", api=api, tags=["lerobot", "lelab"]) is None
|
||||||
|
|
||||||
|
mock_ds_cls.assert_called_once_with("user/ds")
|
||||||
|
mock_ds_cls.return_value.push_to_hub.assert_called_once_with(private=True, tags=["lerobot", "lelab"])
|
||||||
|
|
||||||
|
|
||||||
|
# Branch 3: not on Hub, NOT in local cache → RuntimeError.
|
||||||
|
def test_dataset_neither_on_hub_nor_local_raises(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("lerobot.jobs.dataset.HF_LEROBOT_HOME", tmp_path)
|
||||||
|
# tmp_path is empty — no local cache.
|
||||||
|
|
||||||
|
api = _api_with_dataset(False)
|
||||||
|
with pytest.raises(RuntimeError, match="not in the local cache"):
|
||||||
|
ensure_dataset_available("user/ds", api=api)
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import draccus
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])")
|
||||||
|
|
||||||
|
from lerobot.configs.train import TrainPipelineConfig
|
||||||
|
from lerobot.jobs.hf import (
|
||||||
|
_pod_forwarded_args,
|
||||||
|
_poll_until_done,
|
||||||
|
build_remote_config_file,
|
||||||
|
build_repo_id,
|
||||||
|
resolve_job_tags,
|
||||||
|
resolve_wandb_api_key,
|
||||||
|
submit_to_hf,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_job_tags_always_includes_lerobot_and_dedups():
|
||||||
|
assert resolve_job_tags(None) == ["lerobot"]
|
||||||
|
assert resolve_job_tags([]) == ["lerobot"]
|
||||||
|
assert resolve_job_tags(["lelab"]) == ["lerobot", "lelab"]
|
||||||
|
# lerobot isn't duplicated if passed explicitly; order is stable.
|
||||||
|
assert resolve_job_tags(["lelab", "lerobot", "lelab"]) == ["lerobot", "lelab"]
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_inspect(stage_value, *, as_enum=True):
|
||||||
|
# huggingface_hub returns `stage` as an enum (with `.value`) in some versions and a plain str in others.
|
||||||
|
stage = SimpleNamespace(value=stage_value) if as_enum else stage_value
|
||||||
|
return lambda job_id: SimpleNamespace(status=SimpleNamespace(stage=stage))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("as_enum", [True, False], ids=["enum_stage", "str_stage"])
|
||||||
|
def test_poll_until_done_returns_terminal_stage(monkeypatch, as_enum):
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.inspect_job", _fake_inspect("COMPLETED", as_enum=as_enum))
|
||||||
|
done = threading.Event()
|
||||||
|
assert _poll_until_done("j", done, poll_interval=0.01) == "COMPLETED"
|
||||||
|
assert done.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_until_done_exits_when_done_already_set(monkeypatch):
|
||||||
|
# Non-terminal forever; with done pre-set the loop must not block and returns None.
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.inspect_job", _fake_inspect("RUNNING"))
|
||||||
|
done = threading.Event()
|
||||||
|
done.set()
|
||||||
|
assert _poll_until_done("j", done, poll_interval=0.01) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_until_done_gives_up_after_repeated_network_failures(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"lerobot.jobs.hf.inspect_job", lambda job_id: (_ for _ in ()).throw(httpx.ConnectError("boom"))
|
||||||
|
)
|
||||||
|
done = threading.Event()
|
||||||
|
result = _poll_until_done("j", done, poll_interval=0.001, max_failures=3)
|
||||||
|
assert result is None
|
||||||
|
assert done.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_until_done_propagates_programming_errors(monkeypatch):
|
||||||
|
"""A bug (e.g. TypeError) must surface, not be silently retried as a transient failure."""
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.inspect_job", lambda job_id: (_ for _ in ()).throw(TypeError("bug")))
|
||||||
|
done = threading.Event()
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
_poll_until_done("j", done, poll_interval=0.001, max_failures=3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_wandb_key_from_env(monkeypatch):
|
||||||
|
monkeypatch.setenv("WANDB_API_KEY", "abc123")
|
||||||
|
assert resolve_wandb_api_key() == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_wandb_key_missing(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.delenv("WANDB_API_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path)) # no ~/.netrc here
|
||||||
|
monkeypatch.setattr("netrc.netrc", lambda *a, **k: (_ for _ in ()).throw(FileNotFoundError()))
|
||||||
|
assert resolve_wandb_api_key() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_wandb_key_from_netrc(monkeypatch):
|
||||||
|
# No env var → fall back to the wandb credentials in ~/.netrc.
|
||||||
|
monkeypatch.delenv("WANDB_API_KEY", raising=False)
|
||||||
|
|
||||||
|
class _FakeNetrc:
|
||||||
|
def authenticators(self, host):
|
||||||
|
assert host == "api.wandb.ai"
|
||||||
|
return ("login", "account", "netrc-secret")
|
||||||
|
|
||||||
|
monkeypatch.setattr("netrc.netrc", lambda *a, **k: _FakeNetrc())
|
||||||
|
assert resolve_wandb_api_key() == "netrc-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_wandb_key_netrc_without_wandb_entry(monkeypatch):
|
||||||
|
# ~/.netrc exists but has no api.wandb.ai entry → None.
|
||||||
|
monkeypatch.delenv("WANDB_API_KEY", raising=False)
|
||||||
|
|
||||||
|
class _FakeNetrc:
|
||||||
|
def authenticators(self, host):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("netrc.netrc", lambda *a, **k: _FakeNetrc())
|
||||||
|
assert resolve_wandb_api_key() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_repo_id_sanitizes_and_timestamps():
|
||||||
|
now = dt.datetime(2026, 6, 19, 10, 22, 3)
|
||||||
|
assert build_repo_id("alice", "act", now) == "alice/act_2026-06-19_10-22-03"
|
||||||
|
# Runs of illegal characters collapse to a single dash; edges are trimmed.
|
||||||
|
assert build_repo_id("alice", "my cool/run!!", now) == "alice/my-cool-run_2026-06-19_10-22-03"
|
||||||
|
# A name with nothing usable falls back to "train".
|
||||||
|
assert build_repo_id("alice", "///", now) == "alice/train_2026-06-19_10-22-03"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pod_forwarded_args_drops_host_only_flags():
|
||||||
|
"""User overrides are replayed on the pod, minus flags that only make sense on the submitter.
|
||||||
|
|
||||||
|
`--dataset.root` is a host-local path the pod can't read, so it must be dropped in both the
|
||||||
|
`--name=value` and `--name value` forms; unrelated overrides are forwarded untouched.
|
||||||
|
"""
|
||||||
|
argv = [
|
||||||
|
"--config_path=u/d",
|
||||||
|
"--dataset.root=/local/data",
|
||||||
|
"--dataset.root",
|
||||||
|
"/other/local/data",
|
||||||
|
"--policy.repo_id=u/keep",
|
||||||
|
"--steps=10",
|
||||||
|
"--job.target=a10g-small",
|
||||||
|
]
|
||||||
|
forwarded = _pod_forwarded_args(
|
||||||
|
argv,
|
||||||
|
drop_names=("--config_path", "--policy.repo_id", "--policy.push_to_hub", "--dataset.root"),
|
||||||
|
drop_prefixes=("--job.",),
|
||||||
|
)
|
||||||
|
assert forwarded == ["--steps=10"]
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_cfg():
|
||||||
|
return draccus.parse(
|
||||||
|
TrainPipelineConfig,
|
||||||
|
args=["--dataset.repo_id", "u/d", "--policy.type", "act", "--job.target", "a10g-small"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_skips_repo_id_check_for_remote():
|
||||||
|
"""Remote runs auto-assign repo_id in submit_to_hf, so validate() must not demand it up front."""
|
||||||
|
cfg = _minimal_cfg() # remote target, push_to_hub default True, no explicit repo_id
|
||||||
|
assert cfg.policy.repo_id is None
|
||||||
|
cfg.validate() # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_requires_repo_id_for_local_push():
|
||||||
|
"""Local runs that push to the Hub still need an explicit repo_id."""
|
||||||
|
cfg = draccus.parse(
|
||||||
|
TrainPipelineConfig,
|
||||||
|
args=["--dataset.repo_id", "u/d", "--policy.type", "act"],
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="repo_id"):
|
||||||
|
cfg.validate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_remote_config_applies_overrides(tmp_path):
|
||||||
|
cfg = _minimal_cfg()
|
||||||
|
dest = tmp_path / "train_config.json"
|
||||||
|
out = build_remote_config_file(cfg, "u/run", dest)
|
||||||
|
assert out == dest
|
||||||
|
data = json.loads(dest.read_text())
|
||||||
|
# `job` is client-only orchestration and must be stripped for the pod.
|
||||||
|
assert "job" not in data
|
||||||
|
# save_checkpoint_to_hub defaults off → omitted so older images accept the config.
|
||||||
|
assert "save_checkpoint_to_hub" not in data
|
||||||
|
assert data["policy"]["push_to_hub"] is True
|
||||||
|
assert data["policy"]["repo_id"] == "u/run"
|
||||||
|
assert data["policy"]["device"] is None # pod auto-detects its GPU
|
||||||
|
assert data["dataset"]["root"] is None # pod resolves the dataset by repo_id
|
||||||
|
# the caller's cfg must be left untouched (function works on a deep copy)
|
||||||
|
assert cfg.job.target == "a10g-small"
|
||||||
|
assert cfg.save_checkpoint_to_hub is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_remote_config_includes_checkpoint_flag_when_enabled(tmp_path):
|
||||||
|
cfg = draccus.parse(
|
||||||
|
TrainPipelineConfig,
|
||||||
|
args=[
|
||||||
|
"--dataset.repo_id",
|
||||||
|
"u/d",
|
||||||
|
"--policy.type",
|
||||||
|
"act",
|
||||||
|
"--job.target",
|
||||||
|
"a10g-small",
|
||||||
|
"--save_checkpoint_to_hub",
|
||||||
|
"true",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
dest = tmp_path / "train_config.json"
|
||||||
|
build_remote_config_file(cfg, "u/run", dest)
|
||||||
|
data = json.loads(dest.read_text())
|
||||||
|
# explicitly enabled → kept in the config (requires a matching trainer image).
|
||||||
|
assert data["save_checkpoint_to_hub"] is True
|
||||||
|
assert "job" not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_remote_config_merges_tags_into_policy(tmp_path):
|
||||||
|
cfg = _minimal_cfg()
|
||||||
|
dest = tmp_path / "train_config.json"
|
||||||
|
build_remote_config_file(cfg, "u/run", dest, tags=["lerobot", "lelab"])
|
||||||
|
data = json.loads(dest.read_text())
|
||||||
|
# tags propagate to the model the pod pushes.
|
||||||
|
assert data["policy"]["tags"] == ["lerobot", "lelab"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_remote_config_merges_tags_without_duplicating(tmp_path):
|
||||||
|
cfg = _minimal_cfg()
|
||||||
|
cfg.policy.tags = ["existing", "lerobot"]
|
||||||
|
dest = tmp_path / "train_config.json"
|
||||||
|
build_remote_config_file(cfg, "u/run", dest, tags=["lerobot", "lelab"])
|
||||||
|
data = json.loads(dest.read_text())
|
||||||
|
# pre-existing policy tags are kept; only genuinely-new tags are appended (no dup "lerobot").
|
||||||
|
assert data["policy"]["tags"] == ["existing", "lerobot", "lelab"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_requires_login(monkeypatch):
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: None)
|
||||||
|
cfg = draccus.parse(
|
||||||
|
TrainPipelineConfig,
|
||||||
|
args=["--dataset.repo_id", "u/d", "--policy.type", "act", "--job.target", "a10g-small"],
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="hf auth login"):
|
||||||
|
submit_to_hf(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_passes_validation_and_submits(monkeypatch):
|
||||||
|
"""A type-based policy with no explicit repo_id is auto-assigned one and submitted."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# Patch get_token
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: "tok")
|
||||||
|
|
||||||
|
# Patch HfApi so whoami returns alice
|
||||||
|
class FakeHfApi:
|
||||||
|
def __init__(self, token=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def whoami(self, token=None):
|
||||||
|
return {"name": "alice"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.HfApi", FakeHfApi)
|
||||||
|
|
||||||
|
# ensure_dataset_available returns None; patch it out so no Hub access happens
|
||||||
|
# (hf.py imports it at module level, so patch it on lerobot.jobs.hf).
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.ensure_dataset_available", lambda *a, **kw: None)
|
||||||
|
|
||||||
|
# Patch _stage_config_on_hub to skip network
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"lerobot.jobs.hf._stage_config_on_hub",
|
||||||
|
lambda cfg, repo_id, token, tags=None: repo_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Patch run_job to return a fake job
|
||||||
|
fake_job = MagicMock()
|
||||||
|
fake_job.id = "job-123"
|
||||||
|
run_job_calls = []
|
||||||
|
|
||||||
|
def fake_run_job(**kwargs):
|
||||||
|
run_job_calls.append(kwargs)
|
||||||
|
return fake_job
|
||||||
|
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.run_job", fake_run_job)
|
||||||
|
|
||||||
|
cfg = draccus.parse(
|
||||||
|
TrainPipelineConfig,
|
||||||
|
args=[
|
||||||
|
"--dataset.repo_id",
|
||||||
|
"u/d",
|
||||||
|
"--policy.type",
|
||||||
|
"act",
|
||||||
|
"--job.target",
|
||||||
|
"a10g-small",
|
||||||
|
"--job.detach",
|
||||||
|
"true",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Must NOT raise (pre-fix this raised ValueError about missing repo_id)
|
||||||
|
submit_to_hf(cfg)
|
||||||
|
|
||||||
|
assert len(run_job_calls) == 1, "run_job should have been called exactly once"
|
||||||
|
assert cfg.policy.repo_id is not None
|
||||||
|
assert cfg.policy.repo_id.startswith("alice/")
|
||||||
|
call = run_job_calls[0]
|
||||||
|
# The pod runs `lerobot-train --config_path=<staged repo>` on the requested flavor/image.
|
||||||
|
assert call["command"][0] == "lerobot-train"
|
||||||
|
assert call["command"][1].startswith("--config_path=")
|
||||||
|
assert call["flavor"] == "a10g-small"
|
||||||
|
assert call["image"] == "huggingface/lerobot-gpu:latest"
|
||||||
|
# The Hub token is forwarded so the pod can pull the (possibly private) dataset.
|
||||||
|
assert call["secrets"]["HF_TOKEN"] == "tok"
|
||||||
|
# Every job carries the lerobot tag as a queryable label.
|
||||||
|
assert call["labels"].get("lerobot") == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_rejects_reward_model_training(monkeypatch):
|
||||||
|
"""Remote training only supports policies; reward-model runs fail fast with a clear error."""
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: "tok")
|
||||||
|
|
||||||
|
class FakeHfApi:
|
||||||
|
def __init__(self, token=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def whoami(self, token=None):
|
||||||
|
return {"name": "alice"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.HfApi", FakeHfApi)
|
||||||
|
|
||||||
|
cfg = _minimal_cfg()
|
||||||
|
cfg.reward_model = SimpleNamespace(type="reward") # marks this as reward-model training
|
||||||
|
monkeypatch.setattr(cfg, "validate", lambda: None) # skip pretrained-path resolution
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="reward model"):
|
||||||
|
submit_to_hf(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.timeout(15)
|
||||||
|
def test_submit_returns_when_job_completes(monkeypatch):
|
||||||
|
"""Non-detach path must RETURN (not hang) once the job reaches a terminal stage."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: "tok")
|
||||||
|
|
||||||
|
class FakeHfApi:
|
||||||
|
def __init__(self, token=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def whoami(self, token=None):
|
||||||
|
return {"name": "alice"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.HfApi", FakeHfApi)
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.ensure_dataset_available", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"lerobot.jobs.hf._stage_config_on_hub", lambda cfg, repo_id, token, tags=None: repo_id
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.run_job", lambda **kw: SimpleNamespace(id="job-1", url="http://x"))
|
||||||
|
# Job is already COMPLETED on the first poll.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"lerobot.jobs.hf.inspect_job",
|
||||||
|
lambda job_id: SimpleNamespace(
|
||||||
|
status=SimpleNamespace(stage=SimpleNamespace(value="COMPLETED"), message=None)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Log stream ends immediately.
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.fetch_job_logs", lambda job_id, follow=True: iter(()))
|
||||||
|
|
||||||
|
cfg = draccus.parse(
|
||||||
|
TrainPipelineConfig,
|
||||||
|
args=["--dataset.repo_id", "u/d", "--policy.type", "act", "--job.target", "a10g-small"],
|
||||||
|
)
|
||||||
|
# Runs in the pytest main thread (signal handler install requires it); the
|
||||||
|
# @timeout marker fails the test instead of hanging if it regresses.
|
||||||
|
submit_to_hf(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.timeout(15)
|
||||||
|
def test_submit_returns_on_model_pushed_marker(monkeypatch):
|
||||||
|
"""Finish when the model-pushed log appears, even if the job stage never flips."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: "tok")
|
||||||
|
|
||||||
|
class FakeHfApi:
|
||||||
|
def __init__(self, token=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def whoami(self, token=None):
|
||||||
|
return {"name": "alice"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.HfApi", FakeHfApi)
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.ensure_dataset_available", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"lerobot.jobs.hf._stage_config_on_hub", lambda cfg, repo_id, token, tags=None: repo_id
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.run_job", lambda **kw: SimpleNamespace(id="job-1", url="http://x"))
|
||||||
|
# Job stays RUNNING forever — only the log marker can end the command.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"lerobot.jobs.hf.inspect_job",
|
||||||
|
lambda job_id: SimpleNamespace(
|
||||||
|
status=SimpleNamespace(stage=SimpleNamespace(value="RUNNING"), message=None)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
pushed_line = "INFO Model pushed to https://huggingface.co/alice/myrun"
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.fetch_job_logs", lambda job_id, follow=True: iter([pushed_line]))
|
||||||
|
|
||||||
|
cfg = draccus.parse(
|
||||||
|
TrainPipelineConfig,
|
||||||
|
args=[
|
||||||
|
"--dataset.repo_id",
|
||||||
|
"u/d",
|
||||||
|
"--policy.type",
|
||||||
|
"act",
|
||||||
|
"--policy.repo_id",
|
||||||
|
"alice/myrun",
|
||||||
|
"--job.target",
|
||||||
|
"a10g-small",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Must return via the model-pushed marker despite the perpetual RUNNING stage.
|
||||||
|
submit_to_hf(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_raises_when_wandb_enabled_without_key(monkeypatch):
|
||||||
|
"""wandb.enable with no key reachable anywhere fails fast, before submitting."""
|
||||||
|
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: "tok")
|
||||||
|
|
||||||
|
class FakeHfApi:
|
||||||
|
def __init__(self, token=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def whoami(self, token=None):
|
||||||
|
return {"name": "alice"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.HfApi", FakeHfApi)
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.resolve_wandb_api_key", lambda: None)
|
||||||
|
|
||||||
|
cfg = draccus.parse(
|
||||||
|
TrainPipelineConfig,
|
||||||
|
args=[
|
||||||
|
"--dataset.repo_id",
|
||||||
|
"u/d",
|
||||||
|
"--policy.type",
|
||||||
|
"act",
|
||||||
|
"--job.target",
|
||||||
|
"a10g-small",
|
||||||
|
"--wandb.enable",
|
||||||
|
"true",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="WANDB_API_KEY"):
|
||||||
|
submit_to_hf(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.timeout(15)
|
||||||
|
def test_submit_raises_when_job_ends_in_error(monkeypatch):
|
||||||
|
"""A terminal non-COMPLETED stage with no model-pushed marker must raise with the status."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.get_token", lambda: "tok")
|
||||||
|
|
||||||
|
class FakeHfApi:
|
||||||
|
def __init__(self, token=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def whoami(self, token=None):
|
||||||
|
return {"name": "alice"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.HfApi", FakeHfApi)
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.ensure_dataset_available", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"lerobot.jobs.hf._stage_config_on_hub", lambda cfg, repo_id, token, tags=None: repo_id
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.run_job", lambda **kw: SimpleNamespace(id="job-1", url="http://x"))
|
||||||
|
# Job fails: a terminal ERROR stage carrying the platform's status message.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"lerobot.jobs.hf.inspect_job",
|
||||||
|
lambda job_id: SimpleNamespace(
|
||||||
|
status=SimpleNamespace(stage=SimpleNamespace(value="ERROR"), message="Job timeout")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Logs end without the model-pushed marker.
|
||||||
|
monkeypatch.setattr("lerobot.jobs.hf.fetch_job_logs", lambda job_id, follow=True: iter(()))
|
||||||
|
|
||||||
|
cfg = draccus.parse(
|
||||||
|
TrainPipelineConfig,
|
||||||
|
args=["--dataset.repo_id", "u/d", "--policy.type", "act", "--job.target", "a10g-small"],
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match=r"stage=ERROR \(Job timeout\)"):
|
||||||
|
submit_to_hf(cfg)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import draccus
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lerobot.configs import JobConfig
|
||||||
|
from lerobot.configs.train import TrainPipelineConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_jobconfig_defaults_are_local():
|
||||||
|
cfg = JobConfig()
|
||||||
|
assert cfg.target is None
|
||||||
|
assert cfg.is_remote is False
|
||||||
|
assert cfg.image == "huggingface/lerobot-gpu:latest"
|
||||||
|
assert cfg.timeout == "2d"
|
||||||
|
assert cfg.detach is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_jobconfig_local_string_is_not_remote():
|
||||||
|
assert JobConfig(target="local").is_remote is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_jobconfig_flavor_is_remote():
|
||||||
|
assert JobConfig(target="a10g-small").is_remote is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_train_config_parses_job_target():
|
||||||
|
parsed = draccus.parse(
|
||||||
|
TrainPipelineConfig,
|
||||||
|
args=["--dataset.repo_id", "u/d", "--policy.type", "act", "--job.target", "a10g-small"],
|
||||||
|
)
|
||||||
|
assert parsed.job.target == "a10g-small"
|
||||||
|
assert parsed.job.is_remote is True
|
||||||
|
assert parsed.save_checkpoint_to_hub is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_checkpoint_to_hub_requires_repo_id():
|
||||||
|
cfg = draccus.parse(
|
||||||
|
TrainPipelineConfig,
|
||||||
|
args=[
|
||||||
|
"--dataset.repo_id",
|
||||||
|
"u/d",
|
||||||
|
"--policy.type",
|
||||||
|
"act",
|
||||||
|
"--policy.push_to_hub",
|
||||||
|
"false",
|
||||||
|
"--save_checkpoint_to_hub",
|
||||||
|
"true",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="requires --policy.repo_id"):
|
||||||
|
cfg.validate()
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
from safetensors import safe_open
|
||||||
|
from torch import nn
|
||||||
|
|
||||||
|
pytest.importorskip("transformers", reason="fastwam requires the `fastwam` extra (transformers)")
|
||||||
|
pytest.importorskip("diffusers", reason="fastwam requires the `fastwam` extra (diffusers)")
|
||||||
|
|
||||||
|
from lerobot.configs import FeatureType, PolicyFeature, PreTrainedConfig
|
||||||
|
from lerobot.policies import FastWAMConfig, get_policy_class, make_policy_config, make_pre_post_processors
|
||||||
|
from lerobot.policies.fastwam.modeling_fastwam import FastWAMPolicy
|
||||||
|
from lerobot.policies.fastwam.processor_fastwam import FastWAMActionToggleProcessorStep
|
||||||
|
from lerobot.utils.constants import ACTION, OBS_STATE
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFastWAMCore(nn.Module):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.dit = nn.Linear(2, 2)
|
||||||
|
|
||||||
|
def training_loss(self, sample):
|
||||||
|
assert sample["video"].ndim == 5
|
||||||
|
assert sample["context"].ndim == 3
|
||||||
|
return sample[ACTION].sum() * 0.0 + torch.tensor(1.0), {"loss_action": 1.0}
|
||||||
|
|
||||||
|
def infer_action(self, **kwargs):
|
||||||
|
return {"action": torch.ones(1, kwargs["action_horizon"], 3)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fastwam_is_registered_and_publicly_exported():
|
||||||
|
cfg = make_policy_config(
|
||||||
|
"fastwam",
|
||||||
|
action_dim=3,
|
||||||
|
proprio_dim=2,
|
||||||
|
action_horizon=4,
|
||||||
|
n_action_steps=2,
|
||||||
|
num_video_frames=5,
|
||||||
|
action_video_freq_ratio=1,
|
||||||
|
base_model_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(cfg, FastWAMConfig)
|
||||||
|
assert cfg.type == "fastwam"
|
||||||
|
assert get_policy_class("fastwam") is FastWAMPolicy
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_validates_features_model_ids_and_saved_auto_route(tmp_path):
|
||||||
|
cfg = FastWAMConfig()
|
||||||
|
cfg.save_pretrained(tmp_path)
|
||||||
|
saved = json.loads((tmp_path / "config.json").read_text())
|
||||||
|
|
||||||
|
assert saved["pretrained_path"] is None
|
||||||
|
assert cfg.image_features["observation.images.image"].type == FeatureType.VISUAL
|
||||||
|
assert cfg.action_feature.shape == (7,)
|
||||||
|
assert cfg.robot_state_feature.shape == (8,)
|
||||||
|
with pytest.raises(ValueError, match="image feature"):
|
||||||
|
FastWAMConfig(input_features={OBS_STATE: PolicyFeature(type=FeatureType.STATE, shape=(8,))})
|
||||||
|
assert FastWAMConfig(tokenizer_model_id="somebody/other-tokenizer").tokenizer_model_id == (
|
||||||
|
"somebody/other-tokenizer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preprocessor_passes_images_through_and_postprocessor_toggles_actions(tmp_path):
|
||||||
|
cfg = FastWAMConfig(
|
||||||
|
action_dim=3,
|
||||||
|
proprio_dim=2,
|
||||||
|
action_horizon=4,
|
||||||
|
n_action_steps=2,
|
||||||
|
num_video_frames=5,
|
||||||
|
action_video_freq_ratio=1,
|
||||||
|
image_size=(2, 2),
|
||||||
|
device="cpu",
|
||||||
|
toggle_action_dimensions=[-1],
|
||||||
|
input_features={
|
||||||
|
"observation.images.image": PolicyFeature(type=FeatureType.VISUAL, shape=(3, 2, 2)),
|
||||||
|
OBS_STATE: PolicyFeature(type=FeatureType.STATE, shape=(2,)),
|
||||||
|
},
|
||||||
|
output_features={ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(3,))},
|
||||||
|
base_model_id=None,
|
||||||
|
)
|
||||||
|
dataset_stats = {
|
||||||
|
"observation.images.image": {
|
||||||
|
"mean": torch.full((3, 1, 1), 0.2),
|
||||||
|
"std": torch.full((3, 1, 1), 0.1),
|
||||||
|
},
|
||||||
|
OBS_STATE: {
|
||||||
|
"mean": torch.tensor([1.0, 3.0]),
|
||||||
|
"std": torch.tensor([2.0, 4.0]),
|
||||||
|
},
|
||||||
|
ACTION: {
|
||||||
|
"mean": torch.zeros(3),
|
||||||
|
"std": torch.ones(3),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
preprocessor, postprocessor = make_pre_post_processors(cfg, dataset_stats=dataset_stats)
|
||||||
|
processed = preprocessor(
|
||||||
|
{
|
||||||
|
"observation.images.image": torch.tensor(
|
||||||
|
[
|
||||||
|
[[0.0, 0.5], [1.0, 0.5]],
|
||||||
|
[[0.0, 0.5], [1.0, 0.5]],
|
||||||
|
[[0.0, 0.5], [1.0, 0.5]],
|
||||||
|
]
|
||||||
|
),
|
||||||
|
OBS_STATE: torch.tensor([3.0, 7.0]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
preprocessor.save_pretrained(tmp_path, config_filename="policy_preprocessor.json")
|
||||||
|
postprocessor.save_pretrained(tmp_path, config_filename="policy_postprocessor.json")
|
||||||
|
_, loaded_postprocessor = make_pre_post_processors(cfg, pretrained_path=str(tmp_path))
|
||||||
|
|
||||||
|
# VISUAL normalization is IDENTITY
|
||||||
|
expected_image = torch.tensor(
|
||||||
|
[[[[0.0, 0.5], [1.0, 0.5]], [[0.0, 0.5], [1.0, 0.5]], [[0.0, 0.5], [1.0, 0.5]]]]
|
||||||
|
)
|
||||||
|
assert preprocessor.name == "policy_preprocessor"
|
||||||
|
assert postprocessor.name == "policy_postprocessor"
|
||||||
|
assert torch.allclose(processed["observation.images.image"], expected_image)
|
||||||
|
assert torch.allclose(processed[OBS_STATE], torch.tensor([[1.0, 1.0]]))
|
||||||
|
assert torch.equal(dataset_stats["observation.images.image"]["mean"], torch.full((3, 1, 1), 0.2))
|
||||||
|
assert any(isinstance(step, FastWAMActionToggleProcessorStep) for step in loaded_postprocessor.steps)
|
||||||
|
assert torch.equal(
|
||||||
|
loaded_postprocessor(torch.tensor([[0.25, 0.5, 1.0]])), torch.tensor([[0.25, 0.5, -1.0]])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_forward_and_predict_action_adapt_lerobot_batches(monkeypatch):
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
class CapturingCore(FakeFastWAMCore):
|
||||||
|
def infer_action(self, **kwargs):
|
||||||
|
captured.append(
|
||||||
|
{
|
||||||
|
"image_shape": tuple(kwargs["input_image"].shape),
|
||||||
|
"proprio_shape": tuple(kwargs["proprio"].shape),
|
||||||
|
"prompt": kwargs["prompt"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"action": torch.full((1, kwargs["action_horizon"], 3), float(len(captured)))}
|
||||||
|
|
||||||
|
monkeypatch.setattr(FastWAMPolicy, "_build_core_model", lambda self, config: CapturingCore())
|
||||||
|
cfg = FastWAMConfig(
|
||||||
|
action_dim=3,
|
||||||
|
proprio_dim=2,
|
||||||
|
action_horizon=4,
|
||||||
|
n_action_steps=2,
|
||||||
|
num_video_frames=5,
|
||||||
|
action_video_freq_ratio=1,
|
||||||
|
image_size=(16, 16),
|
||||||
|
input_features={
|
||||||
|
"observation.images.image": PolicyFeature(type=FeatureType.VISUAL, shape=(3, 16, 16)),
|
||||||
|
OBS_STATE: PolicyFeature(type=FeatureType.STATE, shape=(2,)),
|
||||||
|
},
|
||||||
|
output_features={ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(3,))},
|
||||||
|
base_model_id=None,
|
||||||
|
)
|
||||||
|
policy = FastWAMPolicy(cfg)
|
||||||
|
|
||||||
|
loss, metrics = policy.forward(
|
||||||
|
{
|
||||||
|
"observation.images.image": torch.zeros(1, 3, 16, 16),
|
||||||
|
OBS_STATE: torch.zeros(1, 2),
|
||||||
|
ACTION: torch.zeros(1, 4, 3),
|
||||||
|
"context": torch.zeros(1, 5, 4096),
|
||||||
|
"context_mask": torch.ones(1, 5, dtype=torch.bool),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
action = policy.predict_action_chunk(
|
||||||
|
{
|
||||||
|
"observation.images.image": torch.stack(
|
||||||
|
[
|
||||||
|
torch.zeros(3, 16, 16),
|
||||||
|
torch.ones(3, 16, 16),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
OBS_STATE: torch.tensor([[0.0, 1.0], [2.0, 3.0]]),
|
||||||
|
"task": ["task 0", "task 1"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert loss.item() == 1.0
|
||||||
|
assert metrics["loss_action"] == 1.0
|
||||||
|
assert action.shape == (2, 4, 3)
|
||||||
|
assert action[:, 0, 0].tolist() == [1.0, 2.0]
|
||||||
|
assert [item["image_shape"] for item in captured] == [(1, 3, 16, 16), (1, 3, 16, 16)]
|
||||||
|
assert [item["proprio_shape"] for item in captured] == [(1, 2), (1, 2)]
|
||||||
|
assert [item["prompt"] for item in captured] == [
|
||||||
|
cfg.prompt_template.format(task="task 0"),
|
||||||
|
cfg.prompt_template.format(task="task 1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CoreWithFrozenComponents(FakeFastWAMCore):
|
||||||
|
"""Fake core mirroring the real one: frozen VAE / text encoder held as
|
||||||
|
*unregistered* attributes (via `object.__setattr__`) so they are excluded from
|
||||||
|
`state_dict()` and the saved checkpoint, but still moved by the `_apply` override."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
object.__setattr__(self, "vae", nn.Linear(2, 2))
|
||||||
|
object.__setattr__(self, "text_encoder", nn.Linear(2, 2))
|
||||||
|
self.vae.requires_grad_(False)
|
||||||
|
self.text_encoder.requires_grad_(False)
|
||||||
|
|
||||||
|
def _apply(self, fn, *args, **kwargs):
|
||||||
|
super()._apply(fn, *args, **kwargs)
|
||||||
|
self.vae._apply(fn)
|
||||||
|
self.text_encoder._apply(fn)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_pretrained_uses_base_loader_and_skips_wan_backbone(monkeypatch, tmp_path):
|
||||||
|
cfg = FastWAMConfig(
|
||||||
|
action_dim=3,
|
||||||
|
proprio_dim=2,
|
||||||
|
action_horizon=4,
|
||||||
|
n_action_steps=2,
|
||||||
|
num_video_frames=5,
|
||||||
|
action_video_freq_ratio=1,
|
||||||
|
base_model_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_core(self, config):
|
||||||
|
core = CoreWithFrozenComponents()
|
||||||
|
with torch.no_grad():
|
||||||
|
core.dit.weight.fill_(0.5)
|
||||||
|
return core
|
||||||
|
|
||||||
|
monkeypatch.setattr(FastWAMPolicy, "_build_core_model", build_core)
|
||||||
|
|
||||||
|
reference = FastWAMPolicy(cfg)
|
||||||
|
with torch.no_grad():
|
||||||
|
reference.model.dit.weight.fill_(1.25) # a distinctive, trained-looking weight
|
||||||
|
reference.save_pretrained(tmp_path)
|
||||||
|
|
||||||
|
# Building from Wan2.2 must never happen on a checkpoint load.
|
||||||
|
def fail_if_wan_pretrained_is_loaded(*args, **kwargs):
|
||||||
|
raise AssertionError("from_pretrained must not initialize or download the Wan2.2 backbone")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"lerobot.policies.fastwam.wan.modular.FastWAM.from_wan22_pretrained",
|
||||||
|
fail_if_wan_pretrained_is_loaded,
|
||||||
|
)
|
||||||
|
|
||||||
|
policy = FastWAMPolicy.from_pretrained(tmp_path)
|
||||||
|
|
||||||
|
assert isinstance(policy.model, CoreWithFrozenComponents)
|
||||||
|
# The bundled checkpoint weights overwrote the freshly built (0.5) DiT weights.
|
||||||
|
assert torch.allclose(policy.model.dit.weight, torch.full_like(policy.model.dit.weight, 1.25))
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_pretrained_excludes_frozen_components(monkeypatch, tmp_path):
|
||||||
|
cfg = FastWAMConfig(
|
||||||
|
action_dim=3,
|
||||||
|
proprio_dim=2,
|
||||||
|
action_horizon=4,
|
||||||
|
n_action_steps=2,
|
||||||
|
num_video_frames=5,
|
||||||
|
action_video_freq_ratio=1,
|
||||||
|
base_model_id=None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(FastWAMPolicy, "_build_core_model", lambda self, config: CoreWithFrozenComponents())
|
||||||
|
policy = FastWAMPolicy(cfg)
|
||||||
|
|
||||||
|
save_dir = tmp_path / "saved"
|
||||||
|
policy.save_pretrained(save_dir)
|
||||||
|
|
||||||
|
assert (save_dir / "model.safetensors").is_file()
|
||||||
|
# No Wan sidecar files either: the frozen backbone comes from the diffusers repo.
|
||||||
|
assert not (save_dir / "Wan2.2_VAE.safetensors").exists()
|
||||||
|
assert not (save_dir / "google").exists()
|
||||||
|
|
||||||
|
with safe_open(save_dir / "model.safetensors", framework="pt") as f:
|
||||||
|
keys = set(f.keys())
|
||||||
|
# Lean checkpoint: only the trainable DiT is saved; the frozen VAE / UMT5 text
|
||||||
|
# encoder are excluded (loaded from the diffusers/transformers repos at init).
|
||||||
|
assert any(key.startswith("model.dit.") for key in keys)
|
||||||
|
assert not any(key.startswith("model.vae.") for key in keys)
|
||||||
|
assert not any(key.startswith("model.text_encoder.") for key in keys)
|
||||||
|
|
||||||
|
|
||||||
|
def test_frozen_components_excluded_from_params_but_follow_device_moves(monkeypatch):
|
||||||
|
cfg = FastWAMConfig(
|
||||||
|
action_dim=3,
|
||||||
|
proprio_dim=2,
|
||||||
|
action_horizon=4,
|
||||||
|
n_action_steps=2,
|
||||||
|
num_video_frames=5,
|
||||||
|
action_video_freq_ratio=1,
|
||||||
|
base_model_id=None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(FastWAMPolicy, "_build_core_model", lambda self, config: CoreWithFrozenComponents())
|
||||||
|
policy = FastWAMPolicy(cfg)
|
||||||
|
|
||||||
|
# Unregistered: excluded from state_dict and from the optimizer's parameter set.
|
||||||
|
sd = policy.state_dict()
|
||||||
|
assert not any(k.startswith("model.vae.") or k.startswith("model.text_encoder.") for k in sd)
|
||||||
|
param_names = [n for n, _ in policy.named_parameters()]
|
||||||
|
assert not any("vae" in n or "text_encoder" in n for n in param_names)
|
||||||
|
|
||||||
|
# ...but the `_apply` override still carries them through `.to()` (dtype stands in
|
||||||
|
# for device on a CPU box), so they never strand off the rest of the model.
|
||||||
|
policy.to(torch.float64)
|
||||||
|
assert policy.model.dit.weight.dtype == torch.float64 # registered
|
||||||
|
assert policy.model.vae.weight.dtype == torch.float64 # unregistered, moved via _apply
|
||||||
|
assert policy.model.text_encoder.weight.dtype == torch.float64
|
||||||
|
|
||||||
|
|
||||||
|
def test_pretrained_config_round_trips_fastwam_features(tmp_path):
|
||||||
|
cfg = FastWAMConfig(action_dim=7, proprio_dim=8, image_size=(224, 448), base_model_id=None)
|
||||||
|
cfg.save_pretrained(tmp_path)
|
||||||
|
|
||||||
|
loaded = PreTrainedConfig.from_pretrained(tmp_path)
|
||||||
|
|
||||||
|
assert loaded.type == "fastwam"
|
||||||
|
assert loaded.image_features["observation.images.image"].type == FeatureType.VISUAL
|
||||||
|
assert loaded.action_feature.shape == (7,)
|
||||||
|
assert loaded.robot_state_feature.shape == (8,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vae_adapter_empty_build_encode_decode_shapes():
|
||||||
|
"""Offline glue check of the diffusers-backed VAE adapter (random weights).
|
||||||
|
|
||||||
|
Validates the encode/decode contract — 48 latent channels, 16x spatial / 4x
|
||||||
|
temporal compression, list-or-batch input, scaling round-trip — without any
|
||||||
|
weight download. (Numerical fidelity vs the original Wan VAE is a separate,
|
||||||
|
GPU + real-weights verification step.)
|
||||||
|
"""
|
||||||
|
pytest.importorskip("diffusers")
|
||||||
|
from diffusers import AutoencoderKLWan
|
||||||
|
|
||||||
|
from lerobot.policies.fastwam.wan import WanVideoVAE38
|
||||||
|
|
||||||
|
# Production always loads a real pretrained VAE from the diffusers repo; here we
|
||||||
|
# build the same architecture with random weights and dummy standardization stats
|
||||||
|
# to exercise the adapter's shape/scaling contract offline (fidelity is checked
|
||||||
|
# separately, with real weights, on GPU).
|
||||||
|
arch = {
|
||||||
|
"base_dim": 160,
|
||||||
|
"decoder_base_dim": 256,
|
||||||
|
"z_dim": 48,
|
||||||
|
"dim_mult": [1, 2, 4, 4],
|
||||||
|
"num_res_blocks": 2,
|
||||||
|
"attn_scales": [],
|
||||||
|
"temporal_downsample": [False, True, True],
|
||||||
|
"dropout": 0.0,
|
||||||
|
"is_residual": True,
|
||||||
|
"in_channels": 12,
|
||||||
|
"out_channels": 12,
|
||||||
|
"patch_size": 2,
|
||||||
|
"scale_factor_spatial": 16,
|
||||||
|
"scale_factor_temporal": 4,
|
||||||
|
"clip_output": False,
|
||||||
|
"latents_mean": [0.0] * 48,
|
||||||
|
"latents_std": [1.0] * 48,
|
||||||
|
}
|
||||||
|
raw = AutoencoderKLWan.from_config(arch)
|
||||||
|
vae = WanVideoVAE38(dtype=torch.float32, device="cpu", pretrained=raw)
|
||||||
|
assert vae.z_dim == 48
|
||||||
|
assert vae.upsampling_factor == 16
|
||||||
|
assert vae.temporal_downsample_factor == 4
|
||||||
|
|
||||||
|
video = torch.rand(1, 3, 5, 32, 32) * 2 - 1 # [B,C,T,H,W] in [-1,1]
|
||||||
|
latents = vae.encode(video)
|
||||||
|
assert latents.shape == (1, 48, 2, 2, 2) # T'=(5-1)//4+1, H'=W'=32//16
|
||||||
|
|
||||||
|
decoded = vae.decode(latents)
|
||||||
|
assert decoded.shape[0] == 1 and decoded.shape[1] == 3 and decoded.shape[-2:] == (32, 32)
|
||||||
|
assert decoded.min() >= -1.0 and decoded.max() <= 1.0
|
||||||
|
|
||||||
|
# list input is accepted and equals the batched path
|
||||||
|
assert torch.equal(vae.encode([video[0]]), latents)
|
||||||
@@ -44,10 +44,12 @@ from lerobot.policies.molmoact2.modeling_molmoact2 import (
|
|||||||
_combine_rollout_seeds,
|
_combine_rollout_seeds,
|
||||||
)
|
)
|
||||||
from lerobot.policies.molmoact2.processor_molmoact2 import (
|
from lerobot.policies.molmoact2.processor_molmoact2 import (
|
||||||
|
MolmoAct2ActionFrameTransformStep,
|
||||||
MolmoAct2ClampNormalizedProcessorStep,
|
MolmoAct2ClampNormalizedProcessorStep,
|
||||||
MolmoAct2MaskedNormalizerProcessorStep,
|
MolmoAct2MaskedNormalizerProcessorStep,
|
||||||
MolmoAct2MaskedUnnormalizerProcessorStep,
|
MolmoAct2MaskedUnnormalizerProcessorStep,
|
||||||
MolmoAct2PackInputsProcessorStep,
|
MolmoAct2PackInputsProcessorStep,
|
||||||
|
MolmoAct2StateFrameTransformStep,
|
||||||
_add_gripper_masks_to_stats,
|
_add_gripper_masks_to_stats,
|
||||||
_build_discrete_state_string,
|
_build_discrete_state_string,
|
||||||
_normalize_question_text,
|
_normalize_question_text,
|
||||||
@@ -926,6 +928,39 @@ def test_question_normalization_matches_release_prompt_style():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_joint_frame_transform_round_trip():
|
||||||
|
signs = [1.0, -1.0, 1.0, 1.0, 1.0, 1.0]
|
||||||
|
offsets = [0.0, 90.0, 90.0, 0.0, 0.0, 0.0]
|
||||||
|
original_state = torch.tensor([[10.0, -90.0, -120.0, 30.0, 0.0, -45.0]])
|
||||||
|
|
||||||
|
state_step = MolmoAct2StateFrameTransformStep(joint_signs=signs, joint_offsets=offsets)
|
||||||
|
action_step = MolmoAct2ActionFrameTransformStep(joint_signs=signs, joint_offsets=offsets)
|
||||||
|
|
||||||
|
transition = {
|
||||||
|
TransitionKey.OBSERVATION: {OBS_STATE: original_state.clone()},
|
||||||
|
}
|
||||||
|
transformed = state_step(transition)
|
||||||
|
model_state = transformed[TransitionKey.OBSERVATION][OBS_STATE]
|
||||||
|
|
||||||
|
action_transition = {TransitionKey.ACTION: model_state.clone()}
|
||||||
|
recovered = action_step(action_transition)
|
||||||
|
recovered_state = recovered[TransitionKey.ACTION]
|
||||||
|
|
||||||
|
assert torch.allclose(recovered_state, original_state)
|
||||||
|
|
||||||
|
|
||||||
|
def test_joint_frame_transform_noop_when_none():
|
||||||
|
state_step = MolmoAct2StateFrameTransformStep(joint_signs=None, joint_offsets=None)
|
||||||
|
action_step = MolmoAct2ActionFrameTransformStep(joint_signs=None, joint_offsets=None)
|
||||||
|
state = torch.tensor([[10.0, -90.0, -120.0]])
|
||||||
|
|
||||||
|
state_transition = {TransitionKey.OBSERVATION: {OBS_STATE: state}}
|
||||||
|
assert state_step(state_transition) is state_transition
|
||||||
|
|
||||||
|
action_transition = {TransitionKey.ACTION: state}
|
||||||
|
assert action_step(action_transition) is action_transition
|
||||||
|
|
||||||
|
|
||||||
def test_action_padding_marks_only_real_dimensions():
|
def test_action_padding_marks_only_real_dimensions():
|
||||||
step = object.__new__(MolmoAct2PackInputsProcessorStep)
|
step = object.__new__(MolmoAct2PackInputsProcessorStep)
|
||||||
step.max_action_dim = 32
|
step.max_action_dim = 32
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from types import SimpleNamespace
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
import torch
|
import torch
|
||||||
from PIL import Image
|
|
||||||
from torch import Tensor, nn
|
from torch import Tensor, nn
|
||||||
|
|
||||||
from lerobot.configs.types import FeatureType, PolicyFeature
|
from lerobot.configs.types import FeatureType, PolicyFeature
|
||||||
@@ -191,7 +190,7 @@ class _FakeQwenInterface(nn.Module):
|
|||||||
|
|
||||||
def build_inputs(
|
def build_inputs(
|
||||||
self,
|
self,
|
||||||
images: list[list[Image.Image]],
|
images: list[list[Tensor]],
|
||||||
instructions: list[str],
|
instructions: list[str],
|
||||||
action_prompt: str,
|
action_prompt: str,
|
||||||
embodied_prompt: str,
|
embodied_prompt: str,
|
||||||
@@ -214,12 +213,13 @@ class _FakeQwenInterface(nn.Module):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def tensor_to_pil(image_tensor: Tensor) -> Image.Image:
|
def to_pixel_values(image_tensor: Tensor) -> Tensor:
|
||||||
image = image_tensor.detach().cpu()
|
image = image_tensor.detach().float()
|
||||||
if image.ndim == 3 and image.shape[0] in (1, 3):
|
if image.shape[-3] == 1:
|
||||||
image = image.permute(1, 2, 0)
|
repeats = [1] * image.ndim
|
||||||
image = (image.float().clamp(0, 1) * 255).to(torch.uint8).numpy()
|
repeats[-3] = 3
|
||||||
return Image.fromarray(image)
|
image = image.repeat(*repeats)
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
class _FakeVideoEncoder(nn.Module):
|
class _FakeVideoEncoder(nn.Module):
|
||||||
@@ -242,12 +242,14 @@ class _FakeVideoEncoder(nn.Module):
|
|||||||
|
|
||||||
|
|
||||||
class _FakeVideoProcessor:
|
class _FakeVideoProcessor:
|
||||||
def __call__(self, videos, return_tensors: str) -> dict[str, Tensor]:
|
def __call__(self, videos, return_tensors: str, device=None, **kwargs) -> dict[str, Tensor]:
|
||||||
assert return_tensors == "pt"
|
assert return_tensors == "pt"
|
||||||
if isinstance(videos, list):
|
if isinstance(videos, list):
|
||||||
pixel_values = torch.stack([torch.as_tensor(v) for v in videos])
|
pixel_values = torch.stack([torch.as_tensor(v) for v in videos])
|
||||||
else:
|
else:
|
||||||
pixel_values = torch.as_tensor(videos).unsqueeze(0)
|
pixel_values = torch.as_tensor(videos).unsqueeze(0)
|
||||||
|
if device is not None:
|
||||||
|
pixel_values = pixel_values.to(device)
|
||||||
return {"pixel_values_videos": pixel_values}
|
return {"pixel_values_videos": pixel_values}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -211,40 +211,42 @@ def test_reset_clears_action_queue(patch_vla_jepa_external_models: None) -> None
|
|||||||
|
|
||||||
|
|
||||||
def test_prepare_model_inputs_training_format(patch_vla_jepa_external_models: None) -> None:
|
def test_prepare_model_inputs_training_format(patch_vla_jepa_external_models: None) -> None:
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
policy = VLAJEPAPolicy(make_config())
|
policy = VLAJEPAPolicy(make_config())
|
||||||
examples = policy._prepare_model_inputs(make_train_batch())
|
inputs = policy._prepare_model_inputs(make_train_batch())
|
||||||
|
|
||||||
assert len(examples) == BATCH_SIZE
|
assert set(inputs) >= {"images", "instructions", "videos", "actions", "state"}
|
||||||
for ex in examples:
|
# images: per-sample, per-view [C, H, W] float tensors (kept as a list for Qwen messages)
|
||||||
assert set(ex) >= {"image", "video", "lang", "action", "state"}
|
assert len(inputs["images"]) == BATCH_SIZE and len(inputs["images"][0]) == 1
|
||||||
assert len(ex["image"]) == 1 and isinstance(ex["image"][0], Image.Image)
|
img = inputs["images"][0][0]
|
||||||
assert ex["video"].ndim == 5 and ex["video"].dtype == np.uint8 # [V,T,H,W,C]
|
assert isinstance(img, torch.Tensor) and img.dtype == torch.float32 and img.ndim == 3
|
||||||
assert ex["action"].shape == (ACTION_HORIZON, ACTION_DIM)
|
assert len(inputs["instructions"]) == BATCH_SIZE
|
||||||
assert ex["state"].shape == (1, STATE_DIM)
|
# videos: batched [B, V, T, C, H, W] float
|
||||||
|
assert inputs["videos"].ndim == 6 and inputs["videos"].shape[0] == BATCH_SIZE
|
||||||
|
assert inputs["videos"].dtype == torch.float32
|
||||||
|
assert inputs["actions"].shape == (BATCH_SIZE, ACTION_HORIZON, ACTION_DIM)
|
||||||
|
assert inputs["state"].shape == (BATCH_SIZE, 1, STATE_DIM)
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_model_inputs_inference_omits_action(patch_vla_jepa_external_models: None) -> None:
|
def test_prepare_model_inputs_inference_omits_action(patch_vla_jepa_external_models: None) -> None:
|
||||||
policy = VLAJEPAPolicy(make_config())
|
policy = VLAJEPAPolicy(make_config())
|
||||||
for ex in policy._prepare_model_inputs(make_inference_batch()):
|
inputs = policy._prepare_model_inputs(make_inference_batch())
|
||||||
assert "action" not in ex
|
assert "actions" not in inputs and "action_is_pad" not in inputs
|
||||||
assert "image" in ex and "video" in ex and "lang" in ex
|
assert {"images", "instructions", "state"} <= set(inputs)
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_model_inputs_missing_task_uses_default(patch_vla_jepa_external_models: None) -> None:
|
def test_prepare_model_inputs_missing_task_uses_default(patch_vla_jepa_external_models: None) -> None:
|
||||||
policy = VLAJEPAPolicy(make_config())
|
policy = VLAJEPAPolicy(make_config())
|
||||||
batch = make_inference_batch()
|
batch = make_inference_batch()
|
||||||
del batch["task"]
|
del batch["task"]
|
||||||
examples = policy._prepare_model_inputs(batch)
|
instructions = policy._prepare_model_inputs(batch)["instructions"]
|
||||||
assert all(isinstance(ex["lang"], str) and len(ex["lang"]) > 0 for ex in examples)
|
assert all(isinstance(s, str) and len(s) > 0 for s in instructions)
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_model_inputs_string_task_broadcast(patch_vla_jepa_external_models: None) -> None:
|
def test_prepare_model_inputs_string_task_broadcast(patch_vla_jepa_external_models: None) -> None:
|
||||||
policy = VLAJEPAPolicy(make_config())
|
policy = VLAJEPAPolicy(make_config())
|
||||||
batch = make_inference_batch()
|
batch = make_inference_batch()
|
||||||
batch["task"] = "open the drawer"
|
batch["task"] = "open the drawer"
|
||||||
assert all(ex["lang"] == "open the drawer" for ex in policy._prepare_model_inputs(batch))
|
assert policy._prepare_model_inputs(batch)["instructions"] == ["open the drawer"] * BATCH_SIZE
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_model_inputs_no_state_omitted(patch_vla_jepa_external_models: None) -> None:
|
def test_prepare_model_inputs_no_state_omitted(patch_vla_jepa_external_models: None) -> None:
|
||||||
@@ -253,7 +255,7 @@ def test_prepare_model_inputs_no_state_omitted(patch_vla_jepa_external_models: N
|
|||||||
policy = VLAJEPAPolicy(make_config())
|
policy = VLAJEPAPolicy(make_config())
|
||||||
batch = make_inference_batch()
|
batch = make_inference_batch()
|
||||||
del batch[OBS_STATE]
|
del batch[OBS_STATE]
|
||||||
assert all("state" not in ex for ex in policy._prepare_model_inputs(batch))
|
assert "state" not in policy._prepare_model_inputs(batch)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -446,14 +448,14 @@ def test_postprocessor_applied_after_predict_action_chunk(
|
|||||||
"""
|
"""
|
||||||
from lerobot.policies.vla_jepa.processor_vla_jepa import make_vla_jepa_pre_post_processors
|
from lerobot.policies.vla_jepa.processor_vla_jepa import make_vla_jepa_pre_post_processors
|
||||||
|
|
||||||
raw_actions = np.zeros((BATCH_SIZE, ACTION_HORIZON, ACTION_DIM), dtype=np.float32)
|
raw_actions = torch.zeros((BATCH_SIZE, ACTION_HORIZON, ACTION_DIM), dtype=torch.float32)
|
||||||
|
|
||||||
cfg = make_config()
|
cfg = make_config()
|
||||||
cfg.clip_normalized_actions = False
|
cfg.clip_normalized_actions = False
|
||||||
cfg.binarize_gripper_action = False
|
cfg.binarize_gripper_action = False
|
||||||
policy = VLAJEPAPolicy(cfg)
|
policy = VLAJEPAPolicy(cfg)
|
||||||
policy.eval()
|
policy.eval()
|
||||||
monkeypatch.setattr(policy.model, "predict_action", lambda *a, **kw: raw_actions.copy())
|
monkeypatch.setattr(policy.model, "predict_action", lambda *a, **kw: raw_actions.clone())
|
||||||
|
|
||||||
dataset_stats = _make_dataset_stats()
|
dataset_stats = _make_dataset_stats()
|
||||||
_, postprocessor = make_vla_jepa_pre_post_processors(cfg, dataset_stats)
|
_, postprocessor = make_vla_jepa_pre_post_processors(cfg, dataset_stats)
|
||||||
@@ -564,9 +566,9 @@ def test_single_view_is_duplicated_for_world_model(patch_vla_jepa_external_model
|
|||||||
original_processor = policy.model.video_processor
|
original_processor = policy.model.video_processor
|
||||||
|
|
||||||
class _CapturingProcessor:
|
class _CapturingProcessor:
|
||||||
def __call__(self, videos: list, return_tensors: str) -> dict:
|
def __call__(self, videos: list, return_tensors: str, **kwargs) -> dict:
|
||||||
captured_videos.extend(videos)
|
captured_videos.extend(videos)
|
||||||
return original_processor(videos=videos, return_tensors=return_tensors)
|
return original_processor(videos=videos, return_tensors=return_tensors, **kwargs)
|
||||||
|
|
||||||
policy.model.video_processor = _CapturingProcessor()
|
policy.model.video_processor = _CapturingProcessor()
|
||||||
policy.forward(_make_multiview_train_batch(num_views=1))
|
policy.forward(_make_multiview_train_batch(num_views=1))
|
||||||
@@ -587,9 +589,9 @@ def test_excess_views_trimmed_for_world_model(patch_vla_jepa_external_models: No
|
|||||||
original_processor = policy.model.video_processor
|
original_processor = policy.model.video_processor
|
||||||
|
|
||||||
class _CapturingProcessor:
|
class _CapturingProcessor:
|
||||||
def __call__(self, videos: list, return_tensors: str) -> dict:
|
def __call__(self, videos: list, return_tensors: str, **kwargs) -> dict:
|
||||||
captured_videos.extend(videos)
|
captured_videos.extend(videos)
|
||||||
return original_processor(videos=videos, return_tensors=return_tensors)
|
return original_processor(videos=videos, return_tensors=return_tensors, **kwargs)
|
||||||
|
|
||||||
policy.model.video_processor = _CapturingProcessor()
|
policy.model.video_processor = _CapturingProcessor()
|
||||||
policy.forward(_make_multiview_train_batch(num_views=3))
|
policy.forward(_make_multiview_train_batch(num_views=3))
|
||||||
|
|||||||
@@ -91,10 +91,11 @@ def test_get_observation_converts_to_degrees(follower):
|
|||||||
|
|
||||||
|
|
||||||
def test_send_action_clips_to_joint_limits(follower):
|
def test_send_action_clips_to_joint_limits(follower):
|
||||||
# shoulder_pan limit is (-145, 145); request beyond the upper bound.
|
# shoulder_pan limit is (-150, 150); request beyond the upper bound.
|
||||||
returned = follower.send_action({"shoulder_pan.pos": 999.0})
|
returned = follower.send_action({"shoulder_pan.pos": 999.0})
|
||||||
assert returned["shoulder_pan.pos"] == 145.0
|
assert returned["shoulder_pan.pos"] == 150.0
|
||||||
follower.motors["shoulder_pan"].send_pos_vel.assert_called_once()
|
# Default control_mode is "mit", so arm joints are driven via send_mit.
|
||||||
|
follower.motors["shoulder_pan"].send_mit.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_send_action_routes_gripper_to_force_pos(follower):
|
def test_send_action_routes_gripper_to_force_pos(follower):
|
||||||
@@ -103,6 +104,22 @@ def test_send_action_routes_gripper_to_force_pos(follower):
|
|||||||
follower.motors["gripper"].send_pos_vel.assert_not_called()
|
follower.motors["gripper"].send_pos_vel.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_gripper_mit_mode_routes_to_send_mit():
|
||||||
|
bus_mock = _make_bus_mock()
|
||||||
|
with (
|
||||||
|
patch(f"{_MODULE}.require_package", lambda *a, **kw: None),
|
||||||
|
patch(f"{_MODULE}.MotorBridgeController") as controller_cls,
|
||||||
|
patch(f"{_MODULE}.MotorBridgeMode", MagicMock()),
|
||||||
|
):
|
||||||
|
controller_cls.from_dm_serial.return_value = bus_mock
|
||||||
|
cfg = RebotB601FollowerRobotConfig(port="/dev/null", gripper_control_mode="mit")
|
||||||
|
robot = RebotB601Follower(cfg)
|
||||||
|
robot.connect(calibrate=False)
|
||||||
|
robot.send_action({"gripper.pos": -10.0})
|
||||||
|
robot.motors["gripper"].send_mit.assert_called_once()
|
||||||
|
robot.motors["gripper"].send_force_pos.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_bimanual_prefixes_features():
|
def test_bimanual_prefixes_features():
|
||||||
with patch(f"{_MODULE}.require_package", lambda *a, **kw: None):
|
with patch(f"{_MODULE}.require_package", lambda *a, **kw: None):
|
||||||
cfg = BiRebotB601FollowerConfig(
|
cfg = BiRebotB601FollowerConfig(
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import draccus
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Importing lerobot_train eagerly pulls in lerobot.datasets, which needs the
|
||||||
|
# `dataset` extra. The base CI tier runs without it, so skip the whole module there.
|
||||||
|
pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])")
|
||||||
|
|
||||||
|
from lerobot.configs.train import TrainPipelineConfig # noqa: E402
|
||||||
|
from lerobot.policies.act.configuration_act import (
|
||||||
|
ACTConfig, # noqa: E402, F401 (registers --policy.type act)
|
||||||
|
)
|
||||||
|
from lerobot.scripts.lerobot_train import _remote_target_in_argv, train # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _set_argv(monkeypatch, *args):
|
||||||
|
monkeypatch.setattr(sys, "argv", ["lerobot-train", *args])
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_target_detected_space_separated(monkeypatch):
|
||||||
|
_set_argv(monkeypatch, "--policy.type", "act", "--job.target", "a10g-small")
|
||||||
|
assert _remote_target_in_argv() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_target_detected_equals(monkeypatch):
|
||||||
|
_set_argv(monkeypatch, "--job.target=t4-small")
|
||||||
|
assert _remote_target_in_argv() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_string_is_not_remote(monkeypatch):
|
||||||
|
_set_argv(monkeypatch, "--job.target", "local")
|
||||||
|
assert _remote_target_in_argv() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_target_is_not_remote(monkeypatch):
|
||||||
|
_set_argv(monkeypatch, "--policy.type", "act")
|
||||||
|
assert _remote_target_in_argv() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_train_dispatches_to_submit_when_remote(monkeypatch):
|
||||||
|
"""A remote --job.target short-circuits train() to the HF Jobs submitter."""
|
||||||
|
import lerobot.scripts.lerobot_train as train_module
|
||||||
|
|
||||||
|
captured = []
|
||||||
|
monkeypatch.setattr(train_module, "submit_to_hf", lambda cfg: captured.append(cfg) or "submitted")
|
||||||
|
cfg = draccus.parse(
|
||||||
|
TrainPipelineConfig,
|
||||||
|
args=["--dataset.repo_id", "u/d", "--policy.type", "act", "--job.target", "a10g-small"],
|
||||||
|
)
|
||||||
|
# Returns the submitter's result and never enters the local training path.
|
||||||
|
assert train(cfg) == "submitted"
|
||||||
|
assert captured == [cfg]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 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 unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from lerobot.utils.hub import find_latest_hub_checkpoint
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_list_files(monkeypatch, files):
|
||||||
|
api = MagicMock()
|
||||||
|
api.list_repo_files.return_value = files
|
||||||
|
# HfApi is imported into lerobot.utils.hub at module load, so patch it there.
|
||||||
|
monkeypatch.setattr("lerobot.utils.hub.HfApi", lambda *a, **k: api)
|
||||||
|
return api
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_latest_hub_checkpoint_picks_highest_step(monkeypatch):
|
||||||
|
_patch_list_files(
|
||||||
|
monkeypatch,
|
||||||
|
[
|
||||||
|
"README.md",
|
||||||
|
"checkpoints/000500/pretrained_model/model.safetensors",
|
||||||
|
"checkpoints/000500/training_state/training_step.json",
|
||||||
|
"checkpoints/020000/pretrained_model/model.safetensors",
|
||||||
|
"checkpoints/001000/training_state/training_step.json",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Numeric max, not lexicographic — "020000" beats "001000"/"000500".
|
||||||
|
assert find_latest_hub_checkpoint("u/run") == "checkpoints/020000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_latest_hub_checkpoint_ignores_non_step_entries(monkeypatch):
|
||||||
|
_patch_list_files(
|
||||||
|
monkeypatch,
|
||||||
|
["checkpoints/last/pretrained_model/model.safetensors", "config.json"],
|
||||||
|
)
|
||||||
|
# "last" (a symlink target name) is not a numeric step → no resolvable checkpoint.
|
||||||
|
assert find_latest_hub_checkpoint("u/run") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_latest_hub_checkpoint_none_when_no_checkpoints(monkeypatch):
|
||||||
|
_patch_list_files(monkeypatch, ["config.json", "model.safetensors"])
|
||||||
|
assert find_latest_hub_checkpoint("u/run") is None
|
||||||
@@ -15,7 +15,9 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from lerobot.common.train_utils import (
|
from lerobot.common.train_utils import (
|
||||||
get_step_checkpoint_dir,
|
get_step_checkpoint_dir,
|
||||||
@@ -24,6 +26,7 @@ from lerobot.common.train_utils import (
|
|||||||
load_training_num_processes,
|
load_training_num_processes,
|
||||||
load_training_state,
|
load_training_state,
|
||||||
load_training_step,
|
load_training_step,
|
||||||
|
push_checkpoint_to_hub,
|
||||||
save_checkpoint,
|
save_checkpoint,
|
||||||
save_training_state,
|
save_training_state,
|
||||||
save_training_step,
|
save_training_step,
|
||||||
@@ -151,3 +154,72 @@ def test_load_training_state_skip_optimizer(tmp_path, optimizer, scheduler):
|
|||||||
assert loaded_step == 10
|
assert loaded_step == 10
|
||||||
assert loaded_optimizer is optimizer
|
assert loaded_optimizer is optimizer
|
||||||
assert loaded_scheduler is scheduler
|
assert loaded_scheduler is scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_checkpoint_to_hub_creates_repo_and_uploads(tmp_path, monkeypatch):
|
||||||
|
ckpt = tmp_path / "010000"
|
||||||
|
(ckpt / "pretrained_model").mkdir(parents=True)
|
||||||
|
api = MagicMock()
|
||||||
|
monkeypatch.setattr("lerobot.common.train_utils.HfApi", lambda *a, **k: api)
|
||||||
|
push_checkpoint_to_hub(ckpt, "user/run", private=True)
|
||||||
|
api.create_repo.assert_called_once()
|
||||||
|
assert api.create_repo.call_args.kwargs["private"] is True
|
||||||
|
assert api.create_repo.call_args.kwargs["repo_type"] == "model"
|
||||||
|
api.upload_folder.assert_called_once()
|
||||||
|
kwargs = api.upload_folder.call_args.kwargs
|
||||||
|
assert kwargs["repo_id"] == "user/run"
|
||||||
|
assert kwargs["repo_type"] == "model"
|
||||||
|
assert kwargs["path_in_repo"] == "checkpoints/010000"
|
||||||
|
assert kwargs["folder_path"] == str(ckpt)
|
||||||
|
assert kwargs["commit_message"] == "checkpoint 010000"
|
||||||
|
# A tag named after the checkpoint step is created so the checkpoint can be
|
||||||
|
# recovered with --policy.pretrained_revision instead of a commit sha.
|
||||||
|
api.create_tag.assert_called_once()
|
||||||
|
tag_kwargs = api.create_tag.call_args.kwargs
|
||||||
|
assert tag_kwargs["tag"] == "010000"
|
||||||
|
assert tag_kwargs["revision"] == api.upload_folder.return_value.oid
|
||||||
|
assert tag_kwargs["repo_type"] == "model"
|
||||||
|
assert tag_kwargs["exist_ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_checkpoint_to_hub_defaults_to_hub_default_visibility(tmp_path, monkeypatch):
|
||||||
|
ckpt = tmp_path / "010000"
|
||||||
|
(ckpt / "pretrained_model").mkdir(parents=True)
|
||||||
|
api = MagicMock()
|
||||||
|
monkeypatch.setattr("lerobot.common.train_utils.HfApi", lambda *a, **k: api)
|
||||||
|
push_checkpoint_to_hub(ckpt, "user/run")
|
||||||
|
api.create_repo.assert_called_once()
|
||||||
|
assert api.create_repo.call_args.kwargs["private"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_resume_checkpoint_downloads_latest_and_links(tmp_path, monkeypatch):
|
||||||
|
from lerobot.common import train_utils
|
||||||
|
|
||||||
|
out = tmp_path / "run"
|
||||||
|
|
||||||
|
def fake_snapshot_download(repo_id, repo_type, allow_patterns, local_dir):
|
||||||
|
# Mimic the Hub layout the real download materializes locally.
|
||||||
|
assert allow_patterns == "checkpoints/020000/*"
|
||||||
|
(Path(local_dir) / "checkpoints" / "020000" / "pretrained_model").mkdir(parents=True)
|
||||||
|
return local_dir
|
||||||
|
|
||||||
|
monkeypatch.setattr("lerobot.common.train_utils.snapshot_download", fake_snapshot_download)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"lerobot.common.train_utils.find_latest_hub_checkpoint", lambda repo_id: "checkpoints/020000"
|
||||||
|
)
|
||||||
|
|
||||||
|
checkpoint_dir = train_utils.resolve_resume_checkpoint("u/run", out)
|
||||||
|
|
||||||
|
assert checkpoint_dir == out / CHECKPOINTS_DIR / "020000"
|
||||||
|
last = out / CHECKPOINTS_DIR / LAST_CHECKPOINT_LINK
|
||||||
|
assert last.is_symlink()
|
||||||
|
# `last` points at the downloaded step dir.
|
||||||
|
assert (last.parent / last.readlink()).resolve() == checkpoint_dir.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_resume_checkpoint_raises_without_checkpoints(tmp_path, monkeypatch):
|
||||||
|
from lerobot.common import train_utils
|
||||||
|
|
||||||
|
monkeypatch.setattr("lerobot.common.train_utils.find_latest_hub_checkpoint", lambda repo_id: None)
|
||||||
|
with pytest.raises(FileNotFoundError, match="No checkpoint"):
|
||||||
|
train_utils.resolve_resume_checkpoint("u/run", tmp_path / "run")
|
||||||
|
|||||||
@@ -30,19 +30,25 @@ from lerobot.utils.constants import OBS_STATE
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_rerun(monkeypatch):
|
def mock_rerun(monkeypatch):
|
||||||
"""
|
"""
|
||||||
Provide a mock `rerun` module so tests don't depend on the real library.
|
Provide a mock `rerun` module (and `rerun.blueprint` submodule) so tests don't
|
||||||
Also reload the module-under-test so it binds to this mock `rr`.
|
depend on the real library. Also reload the module-under-test so it binds to
|
||||||
|
this mock `rr`.
|
||||||
"""
|
"""
|
||||||
calls = []
|
calls = []
|
||||||
|
blueprints = []
|
||||||
|
|
||||||
class DummyScalar:
|
class DummyScalar:
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
self.value = float(value)
|
# Scalars may be built from a single float or from a 1D array batch.
|
||||||
|
self.value = value
|
||||||
|
|
||||||
class DummyImage:
|
class DummyImage:
|
||||||
def __init__(self, arr):
|
def __init__(self, arr):
|
||||||
self.arr = arr
|
self.arr = arr
|
||||||
|
|
||||||
|
def compress(self, *a, **k):
|
||||||
|
return self
|
||||||
|
|
||||||
class DummyDepthImage:
|
class DummyDepthImage:
|
||||||
def __init__(self, arr, colormap=None):
|
def __init__(self, arr, colormap=None):
|
||||||
self.arr = arr
|
self.arr = arr
|
||||||
@@ -54,6 +60,21 @@ def mock_rerun(monkeypatch):
|
|||||||
obj = kwargs.pop("entity")
|
obj = kwargs.pop("entity")
|
||||||
calls.append((key, obj, kwargs))
|
calls.append((key, obj, kwargs))
|
||||||
|
|
||||||
|
def dummy_send_blueprint(blueprint, *a, **k):
|
||||||
|
blueprints.append(blueprint)
|
||||||
|
|
||||||
|
# Mock the `rerun.blueprint` submodule used to build the layout.
|
||||||
|
dummy_rrb = SimpleNamespace(
|
||||||
|
Spatial2DView=lambda origin=None, name=None: SimpleNamespace(
|
||||||
|
kind="Spatial2DView", origin=origin, name=name
|
||||||
|
),
|
||||||
|
TimeSeriesView=lambda name=None, contents=None: SimpleNamespace(
|
||||||
|
kind="TimeSeriesView", name=name, contents=contents
|
||||||
|
),
|
||||||
|
Grid=lambda *views: SimpleNamespace(kind="Grid", views=list(views)),
|
||||||
|
Blueprint=lambda root: SimpleNamespace(kind="Blueprint", root=root),
|
||||||
|
)
|
||||||
|
|
||||||
dummy_rr = SimpleNamespace(
|
dummy_rr = SimpleNamespace(
|
||||||
__name__="rerun",
|
__name__="rerun",
|
||||||
__package__="rerun",
|
__package__="rerun",
|
||||||
@@ -63,20 +84,23 @@ def mock_rerun(monkeypatch):
|
|||||||
DepthImage=DummyDepthImage,
|
DepthImage=DummyDepthImage,
|
||||||
components=SimpleNamespace(Colormap=SimpleNamespace(Viridis="viridis")),
|
components=SimpleNamespace(Colormap=SimpleNamespace(Viridis="viridis")),
|
||||||
log=dummy_log,
|
log=dummy_log,
|
||||||
|
send_blueprint=dummy_send_blueprint,
|
||||||
init=lambda *a, **k: None,
|
init=lambda *a, **k: None,
|
||||||
spawn=lambda *a, **k: None,
|
spawn=lambda *a, **k: None,
|
||||||
|
blueprint=dummy_rrb,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Inject fake module into sys.modules
|
# Inject fake modules into sys.modules (both `rerun` and `rerun.blueprint`).
|
||||||
monkeypatch.setitem(sys.modules, "rerun", dummy_rr)
|
monkeypatch.setitem(sys.modules, "rerun", dummy_rr)
|
||||||
|
monkeypatch.setitem(sys.modules, "rerun.blueprint", dummy_rrb)
|
||||||
|
|
||||||
# Now import and reload the module under test, to bind to our rerun mock
|
# Now import and reload the module under test, to bind to our rerun mock
|
||||||
import lerobot.utils.visualization_utils as vu
|
import lerobot.utils.visualization_utils as vu
|
||||||
|
|
||||||
importlib.reload(vu)
|
importlib.reload(vu)
|
||||||
|
|
||||||
# Expose both the reloaded module and the call recorder
|
# Expose the reloaded module, the call recorder and the captured blueprints
|
||||||
yield vu, calls
|
yield vu, calls, blueprints
|
||||||
|
|
||||||
|
|
||||||
def _keys(calls):
|
def _keys(calls):
|
||||||
@@ -99,8 +123,13 @@ def _kwargs_for(calls, key):
|
|||||||
raise KeyError(f"Key {key} not found in calls: {calls}")
|
raise KeyError(f"Key {key} not found in calls: {calls}")
|
||||||
|
|
||||||
|
|
||||||
|
def _views_by_kind(blueprint, kind):
|
||||||
|
"""Return the views of a given kind from the (single) blueprint's grid."""
|
||||||
|
return [v for v in blueprint.root.views if v.kind == kind]
|
||||||
|
|
||||||
|
|
||||||
def test_log_rerun_data_envtransition_scalars_and_image(mock_rerun):
|
def test_log_rerun_data_envtransition_scalars_and_image(mock_rerun):
|
||||||
vu, calls = mock_rerun
|
vu, calls, blueprints = mock_rerun
|
||||||
|
|
||||||
# Build EnvTransition dict
|
# Build EnvTransition dict
|
||||||
obs = {
|
obs = {
|
||||||
@@ -110,7 +139,7 @@ def test_log_rerun_data_envtransition_scalars_and_image(mock_rerun):
|
|||||||
}
|
}
|
||||||
act = {
|
act = {
|
||||||
"action.throttle": 0.7,
|
"action.throttle": 0.7,
|
||||||
# 1D array should log individual Scalars with suffix _i
|
# 1D array should be logged as a single Scalars batch under one entity path
|
||||||
"action.vector": np.array([1.0, 2.0], dtype=np.float32),
|
"action.vector": np.array([1.0, 2.0], dtype=np.float32),
|
||||||
}
|
}
|
||||||
transition = {
|
transition = {
|
||||||
@@ -127,31 +156,28 @@ def test_log_rerun_data_envtransition_scalars_and_image(mock_rerun):
|
|||||||
# - observation.state.temperature -> Scalars
|
# - observation.state.temperature -> Scalars
|
||||||
# - observation.camera -> Image (HWC) with static=True
|
# - observation.camera -> Image (HWC) with static=True
|
||||||
# - action.throttle -> Scalars
|
# - action.throttle -> Scalars
|
||||||
# - action.vector_0, action.vector_1 -> Scalars
|
# - action.vector -> single Scalars batch (no per-element suffix)
|
||||||
expected_keys = {
|
expected_keys = {
|
||||||
f"{OBS_STATE}.temperature",
|
f"{OBS_STATE}.temperature",
|
||||||
"observation.camera",
|
"observation.camera",
|
||||||
"action.throttle",
|
"action.throttle",
|
||||||
"action.vector_0",
|
"action.vector",
|
||||||
"action.vector_1",
|
|
||||||
}
|
}
|
||||||
assert set(_keys(calls)) == expected_keys
|
assert set(_keys(calls)) == expected_keys
|
||||||
|
|
||||||
# Check scalar types and values
|
# Check scalar types and values
|
||||||
temp_obj = _obj_for(calls, f"{OBS_STATE}.temperature")
|
temp_obj = _obj_for(calls, f"{OBS_STATE}.temperature")
|
||||||
assert type(temp_obj).__name__ == "DummyScalar"
|
assert type(temp_obj).__name__ == "DummyScalar"
|
||||||
assert temp_obj.value == pytest.approx(25.0)
|
assert float(temp_obj.value) == pytest.approx(25.0)
|
||||||
|
|
||||||
throttle_obj = _obj_for(calls, "action.throttle")
|
throttle_obj = _obj_for(calls, "action.throttle")
|
||||||
assert type(throttle_obj).__name__ == "DummyScalar"
|
assert type(throttle_obj).__name__ == "DummyScalar"
|
||||||
assert throttle_obj.value == pytest.approx(0.7)
|
assert float(throttle_obj.value) == pytest.approx(0.7)
|
||||||
|
|
||||||
v0 = _obj_for(calls, "action.vector_0")
|
# 1D vector logged as a single batched Scalars under one entity path
|
||||||
v1 = _obj_for(calls, "action.vector_1")
|
vec = _obj_for(calls, "action.vector")
|
||||||
assert type(v0).__name__ == "DummyScalar"
|
assert type(vec).__name__ == "DummyScalar"
|
||||||
assert type(v1).__name__ == "DummyScalar"
|
np.testing.assert_allclose(np.asarray(vec.value), [1.0, 2.0])
|
||||||
assert v0.value == pytest.approx(1.0)
|
|
||||||
assert v1.value == pytest.approx(2.0)
|
|
||||||
|
|
||||||
# Check image handling: CHW -> HWC
|
# Check image handling: CHW -> HWC
|
||||||
img_obj = _obj_for(calls, "observation.camera")
|
img_obj = _obj_for(calls, "observation.camera")
|
||||||
@@ -159,9 +185,24 @@ def test_log_rerun_data_envtransition_scalars_and_image(mock_rerun):
|
|||||||
assert img_obj.arr.shape == (10, 20, 3) # transposed
|
assert img_obj.arr.shape == (10, 20, 3) # transposed
|
||||||
assert _kwargs_for(calls, "observation.camera").get("static", False) is True # static=True for images
|
assert _kwargs_for(calls, "observation.camera").get("static", False) is True # static=True for images
|
||||||
|
|
||||||
|
# A blueprint should have been built and sent exactly once, and cached on the function.
|
||||||
|
assert len(blueprints) == 1
|
||||||
|
assert vu.log_rerun_data.blueprint is blueprints[0]
|
||||||
|
|
||||||
|
bp = blueprints[0]
|
||||||
|
# One spatial view per image path
|
||||||
|
spatial_views = _views_by_kind(bp, "Spatial2DView")
|
||||||
|
assert {v.origin for v in spatial_views} == {"observation.camera"}
|
||||||
|
|
||||||
|
# One time-series view each for observation and action scalars
|
||||||
|
ts_views = {v.name: v for v in _views_by_kind(bp, "TimeSeriesView")}
|
||||||
|
assert set(ts_views) == {"observation", "action"}
|
||||||
|
assert ts_views["observation"].contents == [f"{OBS_STATE}.temperature"]
|
||||||
|
assert ts_views["action"].contents == ["action.throttle", "action.vector"]
|
||||||
|
|
||||||
|
|
||||||
def test_log_rerun_data_plain_list_ordering_and_prefixes(mock_rerun):
|
def test_log_rerun_data_plain_list_ordering_and_prefixes(mock_rerun):
|
||||||
vu, calls = mock_rerun
|
vu, calls, blueprints = mock_rerun
|
||||||
|
|
||||||
# First dict without prefixes treated as observation
|
# First dict without prefixes treated as observation
|
||||||
# Second dict without prefixes treated as action
|
# Second dict without prefixes treated as action
|
||||||
@@ -180,14 +221,12 @@ def test_log_rerun_data_plain_list_ordering_and_prefixes(mock_rerun):
|
|||||||
# First dict was treated as observation, second as action
|
# First dict was treated as observation, second as action
|
||||||
vu.log_rerun_data(observation=obs_plain, action=act_plain)
|
vu.log_rerun_data(observation=obs_plain, action=act_plain)
|
||||||
|
|
||||||
# Expected keys with auto-prefixes
|
# Expected keys with auto-prefixes. The 1D vector is a single batched Scalars.
|
||||||
expected = {
|
expected = {
|
||||||
"observation.temp",
|
"observation.temp",
|
||||||
"observation.img",
|
"observation.img",
|
||||||
"action.throttle",
|
"action.throttle",
|
||||||
"action.vec_0",
|
"action.vec",
|
||||||
"action.vec_1",
|
|
||||||
"action.vec_2",
|
|
||||||
}
|
}
|
||||||
logged = set(_keys(calls))
|
logged = set(_keys(calls))
|
||||||
assert logged == expected
|
assert logged == expected
|
||||||
@@ -195,11 +234,11 @@ def test_log_rerun_data_plain_list_ordering_and_prefixes(mock_rerun):
|
|||||||
# Scalars
|
# Scalars
|
||||||
t = _obj_for(calls, "observation.temp")
|
t = _obj_for(calls, "observation.temp")
|
||||||
assert type(t).__name__ == "DummyScalar"
|
assert type(t).__name__ == "DummyScalar"
|
||||||
assert t.value == pytest.approx(1.5)
|
assert float(t.value) == pytest.approx(1.5)
|
||||||
|
|
||||||
throttle = _obj_for(calls, "action.throttle")
|
throttle = _obj_for(calls, "action.throttle")
|
||||||
assert type(throttle).__name__ == "DummyScalar"
|
assert type(throttle).__name__ == "DummyScalar"
|
||||||
assert throttle.value == pytest.approx(0.3)
|
assert float(throttle.value) == pytest.approx(0.3)
|
||||||
|
|
||||||
# Image stays HWC
|
# Image stays HWC
|
||||||
img = _obj_for(calls, "observation.img")
|
img = _obj_for(calls, "observation.img")
|
||||||
@@ -207,15 +246,23 @@ def test_log_rerun_data_plain_list_ordering_and_prefixes(mock_rerun):
|
|||||||
assert img.arr.shape == (5, 6, 3)
|
assert img.arr.shape == (5, 6, 3)
|
||||||
assert _kwargs_for(calls, "observation.img").get("static", False) is True
|
assert _kwargs_for(calls, "observation.img").get("static", False) is True
|
||||||
|
|
||||||
# Vectors
|
# Vector logged as a single batched Scalars under one entity path
|
||||||
for i, val in enumerate([9, 8, 7]):
|
vec = _obj_for(calls, "action.vec")
|
||||||
o = _obj_for(calls, f"action.vec_{i}")
|
assert type(vec).__name__ == "DummyScalar"
|
||||||
assert type(o).__name__ == "DummyScalar"
|
np.testing.assert_allclose(np.asarray(vec.value), [9, 8, 7])
|
||||||
assert o.value == pytest.approx(val)
|
|
||||||
|
# Blueprint sent once with the expected view layout
|
||||||
|
assert len(blueprints) == 1
|
||||||
|
bp = blueprints[0]
|
||||||
|
spatial_views = _views_by_kind(bp, "Spatial2DView")
|
||||||
|
assert {v.origin for v in spatial_views} == {"observation.img"}
|
||||||
|
ts_views = {v.name: v for v in _views_by_kind(bp, "TimeSeriesView")}
|
||||||
|
assert ts_views["observation"].contents == ["observation.temp"]
|
||||||
|
assert ts_views["action"].contents == ["action.throttle", "action.vec"]
|
||||||
|
|
||||||
|
|
||||||
def test_log_rerun_data_kwargs_only(mock_rerun):
|
def test_log_rerun_data_kwargs_only(mock_rerun):
|
||||||
vu, calls = mock_rerun
|
vu, calls, blueprints = mock_rerun
|
||||||
|
|
||||||
vu.log_rerun_data(
|
vu.log_rerun_data(
|
||||||
observation={"observation.temp": 10.0, "observation.gray": np.zeros((8, 8, 1), dtype=np.uint8)},
|
observation={"observation.temp": 10.0, "observation.gray": np.zeros((8, 8, 1), dtype=np.uint8)},
|
||||||
@@ -229,7 +276,7 @@ def test_log_rerun_data_kwargs_only(mock_rerun):
|
|||||||
|
|
||||||
temp = _obj_for(calls, "observation.temp")
|
temp = _obj_for(calls, "observation.temp")
|
||||||
assert type(temp).__name__ == "DummyScalar"
|
assert type(temp).__name__ == "DummyScalar"
|
||||||
assert temp.value == pytest.approx(10.0)
|
assert float(temp.value) == pytest.approx(10.0)
|
||||||
|
|
||||||
img = _obj_for(calls, "observation.gray")
|
img = _obj_for(calls, "observation.gray")
|
||||||
assert type(img).__name__ == "DummyDepthImage" # single-channel -> DepthImage
|
assert type(img).__name__ == "DummyDepthImage" # single-channel -> DepthImage
|
||||||
@@ -238,4 +285,26 @@ def test_log_rerun_data_kwargs_only(mock_rerun):
|
|||||||
|
|
||||||
a = _obj_for(calls, "action.a")
|
a = _obj_for(calls, "action.a")
|
||||||
assert type(a).__name__ == "DummyScalar"
|
assert type(a).__name__ == "DummyScalar"
|
||||||
assert a.value == pytest.approx(1.0)
|
assert float(a.value) == pytest.approx(1.0)
|
||||||
|
|
||||||
|
# Blueprint sent once, with a spatial view for the image and time-series views for scalars
|
||||||
|
assert len(blueprints) == 1
|
||||||
|
bp = blueprints[0]
|
||||||
|
assert {v.origin for v in _views_by_kind(bp, "Spatial2DView")} == {"observation.gray"}
|
||||||
|
ts_views = {v.name: v for v in _views_by_kind(bp, "TimeSeriesView")}
|
||||||
|
assert ts_views["observation"].contents == ["observation.temp"]
|
||||||
|
assert ts_views["action"].contents == ["action.a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_rerun_data_blueprint_sent_only_once(mock_rerun):
|
||||||
|
"""The blueprint is built from the first call and not resent on subsequent calls."""
|
||||||
|
vu, calls, blueprints = mock_rerun
|
||||||
|
|
||||||
|
vu.log_rerun_data(observation={"temp": 1.0}, action={"a": 2.0})
|
||||||
|
assert len(blueprints) == 1
|
||||||
|
first_blueprint = vu.log_rerun_data.blueprint
|
||||||
|
|
||||||
|
vu.log_rerun_data(observation={"temp": 3.0}, action={"a": 4.0})
|
||||||
|
# Still only one blueprint, and the cached one is unchanged.
|
||||||
|
assert len(blueprints) == 1
|
||||||
|
assert vu.log_rerun_data.blueprint is first_blueprint
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 2
|
revision = 3
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"(python_full_version >= '3.15' and platform_machine == 'AMD64' and sys_platform == 'linux') or (python_full_version >= '3.15' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
"(python_full_version >= '3.15' and platform_machine == 'AMD64' and sys_platform == 'linux') or (python_full_version >= '3.15' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||||
@@ -2955,6 +2955,10 @@ eo1 = [
|
|||||||
evaluation = [
|
evaluation = [
|
||||||
{ name = "av" },
|
{ name = "av" },
|
||||||
]
|
]
|
||||||
|
fastwam = [
|
||||||
|
{ name = "diffusers" },
|
||||||
|
{ name = "transformers" },
|
||||||
|
]
|
||||||
feetech = [
|
feetech = [
|
||||||
{ name = "deepdiff" },
|
{ name = "deepdiff" },
|
||||||
{ name = "feetech-servo-sdk" },
|
{ name = "feetech-servo-sdk" },
|
||||||
@@ -3261,11 +3265,13 @@ requires-dist = [
|
|||||||
{ name = "lerobot", extras = ["deepdiff-dep"], marker = "extra == 'hardware'" },
|
{ name = "lerobot", extras = ["deepdiff-dep"], marker = "extra == 'hardware'" },
|
||||||
{ name = "lerobot", extras = ["dev"], marker = "extra == 'all'" },
|
{ name = "lerobot", extras = ["dev"], marker = "extra == 'all'" },
|
||||||
{ name = "lerobot", extras = ["diffusers-dep"], marker = "extra == 'diffusion'" },
|
{ name = "lerobot", extras = ["diffusers-dep"], marker = "extra == 'diffusion'" },
|
||||||
|
{ name = "lerobot", extras = ["diffusers-dep"], marker = "extra == 'fastwam'" },
|
||||||
{ name = "lerobot", extras = ["diffusers-dep"], marker = "extra == 'groot'" },
|
{ name = "lerobot", extras = ["diffusers-dep"], marker = "extra == 'groot'" },
|
||||||
{ name = "lerobot", extras = ["diffusers-dep"], marker = "extra == 'multi-task-dit'" },
|
{ name = "lerobot", extras = ["diffusers-dep"], marker = "extra == 'multi-task-dit'" },
|
||||||
{ name = "lerobot", extras = ["diffusers-dep"], marker = "extra == 'vla-jepa'" },
|
{ name = "lerobot", extras = ["diffusers-dep"], marker = "extra == 'vla-jepa'" },
|
||||||
{ name = "lerobot", extras = ["diffusion"], marker = "extra == 'all'" },
|
{ name = "lerobot", extras = ["diffusion"], marker = "extra == 'all'" },
|
||||||
{ name = "lerobot", extras = ["dynamixel"], marker = "extra == 'all'" },
|
{ name = "lerobot", extras = ["dynamixel"], marker = "extra == 'all'" },
|
||||||
|
{ name = "lerobot", extras = ["fastwam"], marker = "extra == 'all'" },
|
||||||
{ name = "lerobot", extras = ["feetech"], marker = "extra == 'all'" },
|
{ name = "lerobot", extras = ["feetech"], marker = "extra == 'all'" },
|
||||||
{ name = "lerobot", extras = ["feetech"], marker = "extra == 'hopejr'" },
|
{ name = "lerobot", extras = ["feetech"], marker = "extra == 'hopejr'" },
|
||||||
{ name = "lerobot", extras = ["feetech"], marker = "extra == 'lekiwi'" },
|
{ name = "lerobot", extras = ["feetech"], marker = "extra == 'lekiwi'" },
|
||||||
@@ -3335,6 +3341,7 @@ requires-dist = [
|
|||||||
{ name = "lerobot", extras = ["training"], marker = "extra == 'all'" },
|
{ name = "lerobot", extras = ["training"], marker = "extra == 'all'" },
|
||||||
{ name = "lerobot", extras = ["transformers-dep"], marker = "extra == 'annotations'" },
|
{ name = "lerobot", extras = ["transformers-dep"], marker = "extra == 'annotations'" },
|
||||||
{ name = "lerobot", extras = ["transformers-dep"], marker = "extra == 'eo1'" },
|
{ name = "lerobot", extras = ["transformers-dep"], marker = "extra == 'eo1'" },
|
||||||
|
{ name = "lerobot", extras = ["transformers-dep"], marker = "extra == 'fastwam'" },
|
||||||
{ name = "lerobot", extras = ["transformers-dep"], marker = "extra == 'groot'" },
|
{ name = "lerobot", extras = ["transformers-dep"], marker = "extra == 'groot'" },
|
||||||
{ name = "lerobot", extras = ["transformers-dep"], marker = "extra == 'hilserl'" },
|
{ name = "lerobot", extras = ["transformers-dep"], marker = "extra == 'hilserl'" },
|
||||||
{ name = "lerobot", extras = ["transformers-dep"], marker = "extra == 'libero'" },
|
{ name = "lerobot", extras = ["transformers-dep"], marker = "extra == 'libero'" },
|
||||||
@@ -3395,7 +3402,7 @@ requires-dist = [
|
|||||||
{ name = "qwen-vl-utils", marker = "extra == 'qwen-vl-utils-dep'", specifier = ">=0.0.11,<0.1.0" },
|
{ name = "qwen-vl-utils", marker = "extra == 'qwen-vl-utils-dep'", specifier = ">=0.0.11,<0.1.0" },
|
||||||
{ name = "reachy2-sdk", marker = "extra == 'reachy2'", specifier = ">=1.0.15,<1.1.0" },
|
{ name = "reachy2-sdk", marker = "extra == 'reachy2'", specifier = ">=1.0.15,<1.1.0" },
|
||||||
{ name = "requests", specifier = ">=2.32.0,<3.0.0" },
|
{ name = "requests", specifier = ">=2.32.0,<3.0.0" },
|
||||||
{ name = "rerun-sdk", marker = "extra == 'viz'", specifier = ">=0.24.0,<0.27.0" },
|
{ name = "rerun-sdk", marker = "extra == 'viz'", specifier = ">=0.24.0,<0.34.0" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.1" },
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.1" },
|
||||||
{ name = "safetensors", specifier = ">=0.4.3,<1.0.0" },
|
{ name = "safetensors", specifier = ">=0.4.3,<1.0.0" },
|
||||||
{ name = "scikit-image", marker = "extra == 'video-benchmark'", specifier = ">=0.23.2,<0.26.0" },
|
{ name = "scikit-image", marker = "extra == 'video-benchmark'", specifier = ">=0.23.2,<0.26.0" },
|
||||||
@@ -3417,7 +3424,7 @@ requires-dist = [
|
|||||||
{ name = "transformers", marker = "extra == 'transformers-dep'", specifier = ">=5.4.0,<5.6.0" },
|
{ name = "transformers", marker = "extra == 'transformers-dep'", specifier = ">=5.4.0,<5.6.0" },
|
||||||
{ name = "wandb", marker = "extra == 'training'", specifier = ">=0.24.0,<0.28.0" },
|
{ name = "wandb", marker = "extra == 'training'", specifier = ">=0.24.0,<0.28.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dataset", "training", "hardware", "viz", "core-scripts", "evaluation", "dataset-viz", "av-dep", "pygame-dep", "placo-dep", "transformers-dep", "grpcio-dep", "accelerate-dep", "can-dep", "peft-dep", "scipy-dep", "diffusers-dep", "qwen-vl-utils-dep", "matplotlib-dep", "pyserial-dep", "deepdiff-dep", "pynput-dep", "pyzmq-dep", "motorbridge-dep", "motorbridge-smart-servo-dep", "feetech", "dynamixel", "damiao", "robstride", "openarms", "gamepad", "hopejr", "lekiwi", "unitree-g1", "reachy2", "rebot", "kinematics", "intelrealsense", "phone", "diffusion", "wallx", "pi", "molmoact2", "smolvla", "multi-task-dit", "groot", "sarm", "robometer", "topreward", "xvla", "eo1", "hilserl", "vla-jepa", "async", "peft", "annotations", "dev", "notebook", "test", "video-benchmark", "aloha", "pusht", "libero", "metaworld", "all"]
|
provides-extras = ["dataset", "training", "hardware", "viz", "core-scripts", "evaluation", "dataset-viz", "av-dep", "pygame-dep", "placo-dep", "transformers-dep", "grpcio-dep", "accelerate-dep", "can-dep", "peft-dep", "scipy-dep", "diffusers-dep", "qwen-vl-utils-dep", "matplotlib-dep", "pyserial-dep", "deepdiff-dep", "pynput-dep", "pyzmq-dep", "motorbridge-dep", "motorbridge-smart-servo-dep", "feetech", "dynamixel", "damiao", "robstride", "openarms", "gamepad", "hopejr", "lekiwi", "unitree-g1", "reachy2", "rebot", "kinematics", "intelrealsense", "phone", "diffusion", "wallx", "pi", "molmoact2", "smolvla", "multi-task-dit", "groot", "sarm", "robometer", "topreward", "xvla", "eo1", "fastwam", "hilserl", "vla-jepa", "async", "peft", "annotations", "dev", "notebook", "test", "video-benchmark", "aloha", "pusht", "libero", "metaworld", "all"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "librt"
|
name = "librt"
|
||||||
@@ -5803,21 +5810,21 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rerun-sdk"
|
name = "rerun-sdk"
|
||||||
version = "0.26.2"
|
version = "0.33.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "attrs" },
|
{ name = "attrs" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
|
{ name = "psutil" },
|
||||||
{ name = "pyarrow" },
|
{ name = "pyarrow" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/4a/767c20e1529d74d9be5b5e55c6c26b63a6918ef3c1709fc422d08a460114/rerun_sdk-0.26.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:3d4151c9a3484e112b53d1df90c8fa07397dc7b8bfbb420f09e011eff20f1ef2", size = 93349439, upload-time = "2025-10-27T11:34:10.745Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/07/380198590b194f1c17052d672865aa4a56e606eae47665f66edfb391999d/rerun_sdk-0.33.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c0115b710289022587bd2e9ecb715f98d1e87dd7d8ac48e053324131d4addc89", size = 125706253, upload-time = "2026-06-22T09:04:18.974Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/3d/d8dd0af9c287a85d51ec99d69406cc4b94a9feb1d6f192d3bbcaac9f0b81/rerun_sdk-0.26.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:03977d2aba4966d9a70b682eca196123fda11408fecd733441ede9916c6341e2", size = 86323042, upload-time = "2025-10-27T11:34:17.995Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/eb/6741bbf6868175ab126aff58d372066241c6cd2fc1c4f82ed64069728e73/rerun_sdk-0.33.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:14451d31bc7bd0f7c6bcc9c1213ed679ab81b65ecd8b36eae99f738219897dc5", size = 135278374, upload-time = "2026-06-22T09:04:26.768Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/29/53d8d98799ab32418fd4ba6834d6a5749c31f56160d3c87f52a7219887e9/rerun_sdk-0.26.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b6128c3c4f014cae5be18e4d37657c5932d1bcdb2ce5e9d4b488a6eed47f7437", size = 92677274, upload-time = "2025-10-27T11:34:22.601Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/28/fd3b832652900fe3415739e7411c8af8af4c44e9e1a9d55d79e37f7f9094/rerun_sdk-0.33.1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0f87da7a270614074aca37846350f9e257f65081345474748f578c7da64fdeba", size = 139565470, upload-time = "2026-06-22T09:04:34.941Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/86/0b9c8f56398b4fc85f8e99279907c258413a297e5603f8f2537fe5806e51/rerun_sdk-0.26.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a6f97b60aaa7d4e8c6124a3f6b97ce9dbd09520050955f0e0bdacb72b0eb106a", size = 98768129, upload-time = "2025-10-27T11:34:27.36Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/41/339920f5a6734054c07bcae543365a7ef3368ceee3eb67906e2e38bd1d67/rerun_sdk-0.33.1-cp310-abi3-win_amd64.whl", hash = "sha256:b2c2af67f3c2a85b282669d97b52596593fcfdd19bf57c423f18827c837a6e49", size = 120411717, upload-time = "2026-06-22T09:04:42.643Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/e7/99fc91c0f99f69d7d43e1db0a6f6cb8273ffc02111539bfc1fee43749bad/rerun_sdk-0.26.2-cp39-abi3-win_amd64.whl", hash = "sha256:a493ad6c8357022cba2ca6f8954a81d0faf984b0b22154eb1d976bfc7649df63", size = 84267089, upload-time = "2025-10-27T11:34:32.023Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user