Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 5292f96b8b chore(deps): bump the uv group across 1 directory with 2 updates
Bumps the uv group with 2 updates in the / directory: [setuptools](https://github.com/pypa/setuptools) and [pytest](https://github.com/pytest-dev/pytest).


Updates `setuptools` from 80.10.2 to 83.0.0
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](https://github.com/pypa/setuptools/compare/v80.10.2...v83.0.0)

Updates `pytest` from 8.4.2 to 9.0.3
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.4.2...9.0.3)

---
updated-dependencies:
- dependency-name: setuptools
  dependency-version: 83.0.0
  dependency-type: direct:development
  dependency-group: uv
- dependency-name: pytest
  dependency-version: 9.0.3
  dependency-type: direct:production
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 14:29:26 +00:00
57 changed files with 379 additions and 2524 deletions
+7 -11
View File
@@ -61,20 +61,16 @@ Full details in [`docs/source/so101.mdx`](./docs/source/so101.mdx) and [`docs/so
**4.1 Install**
```bash
# uv (recommended — see AGENTS.md and CLAUDE.md)
uv sync --locked --extra feetech # SO-100/SO-101 motor stack
# uv sync --locked --extra all # everything
# uv sync --locked --extra smolvla # add SmolVLA deps
# pip (alternative, e.g. when not working from source)
# pip install 'lerobot[feetech]'
# pip install 'lerobot[all]'
# pip install 'lerobot[smolvla]'
pip install 'lerobot[feetech]' # SO-100/SO-101 motor stack
# pip install 'lerobot[all]' # everything
# pip install 'lerobot[aloha,pusht]' # specific features
# pip install 'lerobot[smolvla]' # add SmolVLA deps
git lfs install && git lfs pull
hf auth login # required to push datasets/policies
hf auth login # required to push datasets/policies
```
Contributors can alternatively use `uv sync --locked --extra feetech` (see `AGENTS.md`).
**4.2 Find USB ports** — run once per arm, unplug when prompted.
```bash
+5 -4
View File
@@ -68,16 +68,17 @@ ENV HOME=/home/user_lerobot \
# issues with MuJoCo and OpenGL drivers.
RUN uv venv --python python${PYTHON_VERSION}
# Install third-party dependencies separately for layer caching
# Install Python dependencies for caching
COPY --chown=user_lerobot:user_lerobot setup.py pyproject.toml uv.lock README.md MANIFEST.in ./
RUN uv sync --locked --extra all --no-install-project --no-cache
COPY --chown=user_lerobot:user_lerobot src/ src/
RUN uv sync --locked --extra all --no-cache
RUN chmod +x /lerobot/.venv/lib/python${PYTHON_VERSION}/site-packages/triton/backends/nvidia/bin/ptxas
# Copy the application source code and install the local project
# Copy the rest of the application source code
# Make sure to have the git-LFS files for testing
COPY --chown=user_lerobot:user_lerobot . .
RUN uv sync --locked --extra all --no-cache
# Set the default command
CMD ["/bin/bash"]
+5 -4
View File
@@ -60,14 +60,15 @@ ENV HOME=/home/user_lerobot \
# run other Python projects in the same container without dependency conflicts.
RUN uv venv
# Install third-party dependencies separately for layer caching
# Install Python dependencies for caching
COPY --chown=user_lerobot:user_lerobot setup.py pyproject.toml uv.lock README.md MANIFEST.in ./
RUN uv sync --locked --extra all --no-install-project --no-cache
COPY --chown=user_lerobot:user_lerobot src/ src/
# Copy the application code and install the local project
RUN uv sync --locked --extra all --no-cache
# Copy the rest of the application code
# Make sure to have the git-LFS files for testing
COPY --chown=user_lerobot:user_lerobot . .
RUN uv sync --locked --extra all --no-cache
# Set the default command
CMD ["/bin/bash"]
-50
View File
@@ -239,56 +239,6 @@ Every module is on by default and can be toggled independently (set to
| `--vqa.restrict_to_default_camera` | `false` | Ground VQA only on `--vlm.camera_key` (else every camera). |
| `--executor.episode_parallelism` | `16` | Episodes processed concurrently within each phase. |
## Camera-view curation
`lerobot-curate-cameras` is a separate, lightweight command that uses the same
VLM backend for a **dataset-filtering / curation** pass. It downloads only the
**first episode**, then for each camera view asks the VLM to:
1. **flag** whether the view is blurry / unusable, and
2. **label** the view with a canonical name from a closed vocabulary
(`top`, `wrist`, `front`, `bottom`, `left`, `right`, plus two-word combos
like `left_wrist`).
It runs in one of two modes:
- `--mode=report` (default) — write the labels + verdicts into `meta/`
(`meta/camera_curation.json` and a `curation` block on each camera in
`meta/info.json`). Nothing is moved; this is the cheap triage pass and works
for any dataset.
- `--mode=rename` — apply the labels by renaming each camera key to
`observation.images.<label>`. For **video** datasets this is a
**download-free, server-side Hub commit**: the `videos/<key>/` files are moved
with the Hub's LFS copy/delete (no video is downloaded or re-encoded), and only
the small `meta/` files are edited.
```bash
# Cheap, mutation-free triage (writes meta/camera_curation.json):
uv run lerobot-curate-cameras --repo_id=user/dataset --mode=report
# Apply the labels by renaming camera keys on a new branch (keeps `main` intact):
uv run lerobot-curate-cameras --repo_id=user/dataset --mode=rename --branch=curated
# Run the VLM decision on a GPU via HF Jobs (same --job.* flags as above):
uv run lerobot-curate-cameras --repo_id=user/dataset --mode=rename --job.target=h200
```
Notes:
- The Hub rename is **in place** on the source repo — the Hub does not support
cross-repo LFS copies. Use `--branch` to commit to a branch so `main` is
preserved.
- **Image** datasets store frames inside the data parquet, so their rename can't
avoid touching the data; the rename falls back to a local rewrite (via
[`rename_features`](./using_dataset_tools#rename-features)). Prefer `--mode=report`
for image datasets.
- Views judged unusable are only flagged by default (still renamed). Pass
`--drop_unusable=true` (local path) to remove them.
Key options: `--mode`, `--branch`, `--n_frames`, `--view_vocabulary`,
`--allow_combos`, `--on_collision`, `--drop_unusable`, and the shared
`--vlm.*` / `--job.*` flags documented above.
## Contributing new modules
The pipeline is built to grow, and **contributions are very welcome** —
+3 -3
View File
@@ -58,7 +58,7 @@ final_action = postprocessor(action)
## Hardware API redesign
PR [#777](https://github.com/huggingface/lerobot/pull/777) improves the LeRobot calibration but is **not backward-compatible**. Below is an overview of what changed and how you can continue to work with datasets created before this pull request.
PR [#777](https://github.com/huggingface/lerobot/pull/777) improves the LeRobot calibration but is **not backward-compatible**. Below is a overview of what changed and how you can continue to work with datasets created before this pull request.
### What changed?
@@ -129,8 +129,8 @@ python examples/backward_compatibility/replay.py \
Policies output actions in the same format as the datasets (`torch.Tensors`). Therefore, the same transformations should be applied.
To find these transformations, we recommend first replaying an episode of the dataset your policy was trained on using the section above.
Then, add these same transformations to your inference script (shown here in the `record.py` script):
To find these transformations, we recommend to first try and and replay an episode of the dataset your policy was trained on using the section above.
Then, add these same transformations on your inference script (shown here in the `record.py` script):
```diff
action_values = predict_action(
+15 -2
View File
@@ -88,6 +88,20 @@ policy_preprocessor = NormalizerProcessorStep(stats=dataset_stats)
The same policy can work with different environment processors, and the same environment processor can work with different policies:
````python
# Use SmolVLA policy with LIBERO environment
# Use SmolVLA policy with LIBERO environment
libero_preprocessor, libero_postprocessor = make_env_pre_post_processors(
env_cfg=libero_cfg,
policy_cfg=smolvla_cfg,
)
smolvla_preprocessor, smolvla_postprocessor = make_pre_post_processors(smolvla_cfg)
# Or use ACT policy with the same LIBERO environment
libero_preprocessor, libero_postprocessor = make_env_pre_post_processors(
env_cfg=libero_cfg,
policy_cfg=act_cfg,
)
act_preprocessor, act_postprocessor = make_pre_post_processors(act_cfg)
```python
# Use SmolVLA policy with LIBERO environment
libero_preprocessor, libero_postprocessor = make_env_pre_post_processors(
@@ -102,7 +116,6 @@ libero_preprocessor, libero_postprocessor = make_env_pre_post_processors(
policy_cfg=act_cfg,
)
act_preprocessor, act_postprocessor = make_pre_post_processors(act_cfg)
```
### 3. **Easier Experimentation**
@@ -132,7 +145,7 @@ class LiberoVelocityProcessorStep(ObservationProcessorStep):
state = torch.cat([eef_pos, eef_axisangle, eef_vel,
gripper_pos, gripper_vel], dim=-1) # 14D
return state
```
````
### 4. **Cleaner Environment Code**
+4 -4
View File
@@ -40,10 +40,10 @@ This tutorial guides you through updating the firmware of Feetech motors using t
For each motor you want to update:
1. **Select the motor** from the list by clicking on it
2. **Click the Upgrade tab**:
3. **Click the Online button**:
- If a potential firmware update is found, it will be displayed in the box
4. **Click the Upgrade button**:
2. **Click on Upgrade tab**:
3. **Click on Online button**:
- If an potential firmware update is found, it will be displayed in the box
4. **Click on Upgrade button**:
- The update progress will be displayed
## Step 6: Verify Update
+1 -1
View File
@@ -211,7 +211,7 @@ Record, Replay and Train with Hope-JR is still experimental.
### Record
This step records the dataset, which can be seen as an example [here](https://huggingface.co/datasets/nepyope/hand_record_test_with_video_data).
This step records the dataset, which can be seen as an example [here](https://huggingface.co/datasets/nepyope/hand_record_test_with_video_data/settings).
```bash
lerobot-record \
+1 -1
View File
@@ -18,7 +18,7 @@ If you're using Feetech or Dynamixel motors, LeRobot provides built-in bus inter
- [`DynamixelMotorsBus`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/motors/dynamixel/dynamixel.py) for controlling Dynamixel servos
Please refer to the [`MotorsBus`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/motors/motors_bus.py) abstract class to learn about its API.
For a good example of how it can be used, you can have a look at our own [SO101 follower implementation](https://github.com/huggingface/lerobot/blob/main/src/lerobot/robots/so_follower/so_follower.py)
For a good example of how it can be used, you can have a look at our own [SO101 follower implementation](https://github.com/huggingface/lerobot/blob/main/src/lerobot/robots/so_follower/so101_follower/so101_follower.py)
Use these if compatible. Otherwise, you'll need to find or write a Python interface (not covered in this tutorial):
+1 -1
View File
@@ -51,7 +51,7 @@ In addition to these instructions, you need to install the Feetech SDK & ZeroMQ
pip install -e ".[lekiwi]"
```
Great 🤗! You are now done installing LeRobot, and we can begin assembling the SO100/SO101 arms and the mobile base 🤖.
Great :hugs:! You are now done installing LeRobot, and we can begin assembling the SO100/SO101 arms and the mobile base :robot:.
Every time you now want to use LeRobot, you can go to the `~/lerobot` folder where we installed LeRobot and run one of the commands.
# Step-by-Step Assembly Instructions
+1 -1
View File
@@ -174,7 +174,7 @@ The model takes images, text instructions, and robot state as input, and outputs
## Reproducing π₀Fast results
We reproduce the results of π₀Fast on the LIBERO benchmark using the LeRobot implementation. We take the LeRobot PiFast base model [lerobot/pi0fast-base](https://huggingface.co/lerobot/pi0fast-base) and finetune for an additional 40k steps in bfloat16, with batch size of 256 on 8 H100 GPUs using the [HuggingFace LIBERO dataset](https://huggingface.co/datasets/HuggingFaceVLA/libero).
We reproduce the results of π₀Fast on the LIBERO benchmark using the LeRobot implementation. We take the LeRobot PiFast base model [lerobot/pi0fast-base](https://huggingface.co/lerobot/pi0fast-base) and finetune for an additional 40kk steps in bfloat16, with batch size of 256 on 8 H100 GPUs using the [HuggingFace LIBERO dataset](https://huggingface.co/datasets/HuggingFaceVLA/libero).
The finetuned model can be found here:
+4 -4
View File
@@ -22,7 +22,7 @@ With processors, you choose the learning features you want to use for your polic
## Three pipelines
We often compose three pipelines. Depending on your setup, some can be empty if action and observation spaces already match.
Each of these pipelines handles different conversions between different action and observation spaces. Below is a quick explanation of each pipeline.
Each of these pipelines handle different conversions between different action and observation spaces. Below is a quick explanation of each pipeline.
1. Pipeline 1: Teleop action space → dataset action space (phone pose → EE targets)
2. Pipeline 2: Dataset action space → robot command space (EE targets → joints)
@@ -74,15 +74,15 @@ In the phone to SO-100 follower examples we use the following adapters:
- `robot_action_to_transition`: transforms the teleop action dict to a pipeline transition.
- `transition_to_robot_action`: transforms the pipeline transition to a robot action dict.
- `observation_to_transition`: transforms the robot observation dict to a pipeline transition.
- `transition_to_observation`: transforms the pipeline transition to an observation dict.
- `transition_to_observation`: transforms the pipeline transition to a observation dict.
Check out [src/lerobot/processor/converters.py](https://github.com/huggingface/lerobot/blob/main/src/lerobot/processor/converters.py) for more details.
Checkout [src/lerobot/processor/converters.py](https://github.com/huggingface/lerobot/blob/main/src/lerobot/processor/converters.py) for more details.
## Dataset feature contracts
Dataset features are determined by the keys saved in the dataset. Each step can declare what features it modifies in a contract called `transform_features(...)`. Once you build a processor, the processor can then aggregate all of these features with `aggregate_pipeline_dataset_features()` and merge multiple feature dicts with `combine_feature_dicts(...)`.
Below is an example of how we declare features with the `transform_features` method in the phone to SO-100 follower examples:
Below is and example of how we declare features with the `transform_features` method in the phone to SO-100 follower examples:
```python
def transform_features(
+2 -2
View File
@@ -57,7 +57,7 @@ policy_cfg.rtc_config = RTCConfig(
policy = PI0Policy.from_pretrained("lerobot/pi0_base", policy_cfg=policy_cfg, device="cuda")
# Now use predict_action_chunk with RTC parameters
inference_delay = 4 # How many steps of inference latency, this value should be calculated based on the inference latency of the policy
inference_delay = 4 # How many steps of inference latency, this values should be calculated based on the inference latency of the policy
# Initialize the action queue
action_queue = ActionQueue(policy_cfg.rtc_config)
@@ -100,7 +100,7 @@ Typical values: 8-12 steps
RTCConfig(execution_horizon=10)
```
**`max_guidance_weight`**: How strongly to enforce consistency with the previous chunk. This is a hyperparameter that can be tuned to balance the smoothness of the transitions and the reactivity of the policy. For 10 steps flow matching (SmolVLA, Pi0, Pi0.5), a value of 10.0 is an optimal value.
**`max_guidance_weight`**: How strongly to enforce consistency with the previous chunk. This is a hyperparameter that can be tuned to balance the smoothness of the transitions and the reactivity of the policy. For 10 steps flow matching (SmolVLA, Pi0, Pi0.5), a value of 10.0 is a optimal value.
**`prefix_attention_schedule`**: How to weight consistency across the overlap region.
+1 -1
View File
@@ -93,7 +93,7 @@ lerobot-train --help
## Evaluate the finetuned model and run it in real-time
Similarly for when recording an episode, it is recommended that you are logged in to the HuggingFace Hub. You can follow the corresponding steps: [Record a dataset](./il_robots#record-a-dataset).
Similarly for when recording an episode, it is recommended that you are logged in to the HuggingFace Hub. You can follow the corresponding steps: [Record a dataset](./il_robots).
Once you are logged in, you can run inference in your setup by doing:
```bash
+2 -24
View File
@@ -50,11 +50,11 @@ lerobot-edit-dataset \
Divide a dataset into multiple subsets.
```bash
# Split by fractions (e.g. 60% train, 20% val, 20% test)
# Split by fractions (e.g. 80% train, 20% test, 20% val)
lerobot-edit-dataset \
--repo_id lerobot/pusht \
--operation.type split \
--operation.splits '{"train": 0.6, "val": 0.2, "test": 0.2}'
--operation.splits '{"train": 0.8, "test": 0.2, "val": 0.2}'
# Split by specific episode indices
lerobot-edit-dataset \
@@ -89,28 +89,6 @@ lerobot-edit-dataset \
--operation.feature_names "['observation.images.top']"
```
#### Rename Features
Rename feature keys — typically to canonicalize camera views (e.g.
`observation.images.cam_0` → `observation.images.left_wrist`). A rename changes
no pixel data, so it is a cheap key-remap: it rewrites `meta/` (info features,
episode `videos/*` and `stats/*` columns, `stats.json`), moves the
`videos/<key>/` directory, and — for image datasets — renames the embedded
image column. Videos are **not** re-encoded.
```bash
# Rename one or more camera keys
lerobot-edit-dataset \
--repo_id lerobot/pusht \
--operation.type rename_features \
--operation.name_mapping '{"observation.images.cam_0": "observation.images.left_wrist"}'
```
If two targets collide (e.g. two cameras both labeled `top`), the operation
raises by default; pass `--operation.on_collision suffix` to disambiguate
deterministically (`top`, `top_2`, …). To label camera views automatically with
a VLM, see the [Annotation Pipeline](./annotation_pipeline#camera-view-curation).
#### Convert to Video
Convert an image-based dataset to video format, creating a new LeRobotDataset where images are stored as videos. This is useful for reducing storage requirements and improving data loading performance. The new dataset will have the exact same structure as the original, but with images encoded as MP4 videos in the proper LeRobot format.
+3 -4
View File
@@ -14,7 +14,7 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
build-backend = "setuptools*"
[project.urls]
homepage = "https://huggingface.co/lerobot"
@@ -87,7 +87,7 @@ dependencies = [
# Build tools (required by opencv-python-headless on some platforms)
"cmake>=3.29.0.1,<4.2.0",
"setuptools>=71.0.0,<81.0.0",
"setuptools>=71.0.0,<84.0.0",
]
# Optional dependencies
@@ -261,7 +261,7 @@ annotations = [
# Development
dev = ["pre-commit>=3.7.0,<5.0.0", "debugpy>=1.8.1,<1.9.0", "lerobot[grpcio-dep]", "grpcio-tools>=1.73.1,<2.0.0", "mypy>=1.19.1", "ruff>=0.14.1", "lerobot[notebook]"]
notebook = ["jupyter>=1.0.0,<2.0.0", "ipykernel>=6.0.0,<7.0.0"]
test = ["pytest>=8.1.0,<9.0.0", "pytest-timeout>=2.4.0,<3.0.0", "pytest-cov>=5.0.0,<8.0.0", "mock-serial>=0.0.1,<0.1.0 ; sys_platform != 'win32'"]
test = ["pytest>=8.1.0,<10.0.0", "pytest-timeout>=2.4.0,<3.0.0", "pytest-cov>=5.0.0,<8.0.0", "mock-serial>=0.0.1,<0.1.0 ; sys_platform != 'win32'"]
video_benchmark = ["scikit-image>=0.23.2,<0.26.0", "pandas>=2.2.2,<2.4.0"]
# Simulation
@@ -356,7 +356,6 @@ lerobot-imgtransform-viz="lerobot.scripts.lerobot_imgtransform_viz:main"
lerobot-edit-dataset="lerobot.scripts.lerobot_edit_dataset:main"
lerobot-setup-can="lerobot.scripts.lerobot_setup_can:main"
lerobot-annotate="lerobot.scripts.lerobot_annotate:main"
lerobot-curate-cameras="lerobot.scripts.lerobot_curate_cameras:main"
lerobot-rollout="lerobot.scripts.lerobot_rollout:main"
# ---------------- Tool Configurations ----------------
@@ -1,46 +0,0 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""VLM camera-view curation for LeRobot datasets.
For each dataset, the first episode is inspected by a vision-language model to
(1) judge whether each camera view is blurry/unusable and (2) assign a canonical
view label (``top``/``wrist``/``front``/…). The labels can then be applied by
renaming the camera keys — for video datasets via a download-free, server-side
Hub commit. Exposed as the ``lerobot-curate-cameras`` CLI.
"""
from .config import DEFAULT_VIEW_VOCABULARY, CameraCurationConfig
from .curator import (
CameraVerdict,
build_name_mapping,
build_report,
curate_cameras,
is_valid_view_label,
rename_camera_keys_on_hub,
write_report,
)
__all__ = [
"DEFAULT_VIEW_VOCABULARY",
"CameraCurationConfig",
"CameraVerdict",
"build_name_mapping",
"build_report",
"curate_cameras",
"is_valid_view_label",
"rename_camera_keys_on_hub",
"write_report",
]
@@ -1,80 +0,0 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Config for ``lerobot-curate-cameras`` (VLM camera-view curation)."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from lerobot.annotations.steerable_pipeline.config import AnnotationJobConfig, VlmConfig
# The closed vocabulary of canonical camera-view labels. Combos are formed by
# joining two of these with ``_`` (e.g. ``left_wrist``).
DEFAULT_VIEW_VOCABULARY: tuple[str, ...] = ("top", "wrist", "front", "bottom", "left", "right")
@dataclass
class CameraCurationConfig:
"""Top-level config for ``lerobot-curate-cameras``.
The VLM decision only ever reads the first episode (a cheap partial
download). ``mode="report"`` writes the labels + quality verdicts into
``meta/`` and moves nothing (works for any dataset). ``mode="rename"``
additionally renames the camera keys to ``observation.images.<label>`` —
for video datasets this is a server-side, download-free Hub commit.
"""
# Hub dataset id (downloaded when ``root`` is unset) — also the rename target.
repo_id: str | None = None
# Local dataset directory (skips the Hub download).
root: Path | None = None
# "report": write mapping + verdicts into meta/, no file moves.
# "rename": physically rename camera keys to observation.images.<label>.
mode: str = "report"
# Commit target branch for the Hub rename; keeps ``main`` intact when set.
# None commits to the default branch.
branch: str | None = None
# Episode inspected by the VLM (first episode by default).
episode_index: int = 0
# Frames sampled from that episode per camera and shown to the VLM.
n_frames: int = 4
# Closed label vocabulary and whether two-token combos (left_wrist) are allowed.
view_vocabulary: tuple[str, ...] = DEFAULT_VIEW_VOCABULARY
allow_combos: bool = True
# "error" raises on colliding target labels; "suffix" disambiguates (top -> top_2).
on_collision: str = "error"
# Remove cameras judged unusable (default: only flag them, still rename).
drop_unusable: bool = False
# Where to write the machine-readable report (default <root>/meta/camera_curation.json).
report_path: Path | None = None
vlm: VlmConfig = field(default_factory=VlmConfig)
job: AnnotationJobConfig = field(default_factory=AnnotationJobConfig)
seed: int = 1729
# Keyframe decode backend forwarded to ``decode_video_frames`` (None = default).
video_backend: str | None = None
# Upload the result (rename mode). Kept off by default so runs are dry.
push_to_hub: bool = False
push_commit_message: str | None = None
@@ -1,349 +0,0 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Camera-view curation: per-camera VLM quality + label judgments and the
lightweight (download-free) Hub rename that applies the chosen labels.
The decision (:func:`curate_cameras`) is a pure function of a
``{camera_key: [frames]}`` map and a VLM client, so it unit-tests with a stub
VLM and no dataset. The orchestrating CLI (``lerobot-curate-cameras``) samples
those frames from the dataset's first episode.
"""
from __future__ import annotations
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from lerobot.annotations.steerable_pipeline.frames import to_image_blocks
from lerobot.datasets.dataset_tools import _remap_camera_key_in_meta, _resolve_rename_collisions
from lerobot.datasets.io_utils import load_info, write_info
from lerobot.utils.io_utils import write_json
from .config import CameraCurationConfig
logger = logging.getLogger(__name__)
_PROMPT_PATH = Path(__file__).parent / "prompts" / "camera_curation.txt"
# The canonical prefix every curated camera key gets.
OBS_IMAGE_PREFIX = "observation.images."
@dataclass
class CameraVerdict:
"""One camera's VLM verdict."""
camera_key: str
usable: bool
view_label: str | None
blur_reason: str | None = None
confidence: float | None = None
# Populated by ``build_name_mapping`` once collisions are resolved.
proposed_new_key: str | None = None
def _load_prompt() -> str:
return _PROMPT_PATH.read_text(encoding="utf-8")
def is_valid_view_label(label: str, vocabulary: tuple[str, ...], allow_combos: bool) -> bool:
"""True if ``label`` is a single vocab word, or (when allowed) an underscore
combo of at most two distinct vocab words."""
if not label:
return False
tokens = label.split("_")
if not allow_combos:
return len(tokens) == 1 and tokens[0] in vocabulary
if not (1 <= len(tokens) <= 2):
return False
return all(tok in vocabulary for tok in tokens) and len(set(tokens)) == len(tokens)
def _build_messages(frames: list[Any], cfg: CameraCurationConfig) -> list[dict[str, Any]]:
if cfg.allow_combos:
combo_rule = (
"You may combine at most two of these words with an underscore when "
"one word is not precise enough (e.g. \"left_wrist\"). "
)
else:
combo_rule = "Use exactly one of these words (no combinations). "
prompt = _load_prompt().format(
vocabulary=", ".join(cfg.view_vocabulary),
combo_rule=combo_rule,
)
content = [*to_image_blocks(frames), {"type": "text", "text": prompt}]
return [{"role": "user", "content": content}]
def _parse_verdict(camera_key: str, result: Any, cfg: CameraCurationConfig) -> CameraVerdict:
"""Turn a parsed VLM JSON object into a :class:`CameraVerdict` (defensively)."""
if not isinstance(result, dict):
return CameraVerdict(camera_key=camera_key, usable=True, view_label=None, blur_reason=None)
usable = bool(result.get("usable", True))
blur_reason = result.get("blur_reason")
blur_reason = str(blur_reason) if blur_reason else None
raw_label = result.get("view_label")
label = str(raw_label).strip().lower().replace(" ", "_") if raw_label else ""
view_label = label if is_valid_view_label(label, cfg.view_vocabulary, cfg.allow_combos) else None
if raw_label and view_label is None:
logger.warning(
"camera %s: VLM returned view_label=%r which is not in the vocabulary %s; leaving unlabeled",
camera_key,
raw_label,
cfg.view_vocabulary,
)
confidence = result.get("confidence")
try:
confidence = float(confidence) if confidence is not None else None
except (TypeError, ValueError):
confidence = None
return CameraVerdict(
camera_key=camera_key,
usable=usable,
view_label=view_label,
blur_reason=blur_reason,
confidence=confidence,
)
def curate_cameras(
frames_by_camera: dict[str, list[Any]],
cfg: CameraCurationConfig,
vlm: Any,
) -> list[CameraVerdict]:
"""Judge each camera's quality + view label from a few sampled frames.
``frames_by_camera`` maps a camera key to a list of decoded frames (torch
tensors or PIL images). One batched ``generate_json`` call is issued across
all cameras. Cameras with no frames are still reported (usable, unlabeled)
so the caller sees the full camera set.
"""
ordered_keys = list(frames_by_camera)
callable_keys = [k for k in ordered_keys if frames_by_camera[k]]
verdicts: dict[str, CameraVerdict] = {
k: CameraVerdict(camera_key=k, usable=True, view_label=None) for k in ordered_keys
}
if callable_keys:
messages_batch = [_build_messages(frames_by_camera[k], cfg) for k in callable_keys]
results = vlm.generate_json(messages_batch)
for key, result in zip(callable_keys, results, strict=True):
verdicts[key] = _parse_verdict(key, result, cfg)
return [verdicts[k] for k in ordered_keys]
def build_name_mapping(
verdicts: list[CameraVerdict],
existing_features: dict[str, dict],
cfg: CameraCurationConfig,
) -> dict[str, str]:
"""Compute ``{old_key: observation.images.<label>}`` for labeled cameras.
Cameras without a valid label (or already at their canonical name) are
skipped. Collisions are resolved with ``cfg.on_collision`` and the resolved
target is written back onto each verdict's ``proposed_new_key``.
"""
desired: dict[str, str] = {}
for v in verdicts:
if v.view_label is None:
continue
target = f"{OBS_IMAGE_PREFIX}{v.view_label}"
if target != v.camera_key:
desired[v.camera_key] = target
if not desired:
return {}
resolved = _resolve_rename_collisions(desired, existing_features, cfg.on_collision)
by_key = {v.camera_key: v for v in verdicts}
for old, new in resolved.items():
by_key[old].proposed_new_key = new
return resolved
def build_report(
verdicts: list[CameraVerdict],
mapping: dict[str, str],
cfg: CameraCurationConfig,
) -> dict[str, Any]:
"""Assemble the machine-readable curation report."""
return {
"repo_id": cfg.repo_id,
"episode_index": cfg.episode_index,
"view_vocabulary": list(cfg.view_vocabulary),
"cameras": {
v.camera_key: {
"view_label": v.view_label,
"usable": v.usable,
"blur_reason": v.blur_reason,
"confidence": v.confidence,
"proposed_new_key": mapping.get(v.camera_key),
}
for v in verdicts
},
}
def write_report(
root: Path,
verdicts: list[CameraVerdict],
mapping: dict[str, str],
cfg: CameraCurationConfig,
) -> Path:
"""Write ``meta/camera_curation.json`` and stamp verdicts into ``info.json``.
Stamping goes into each camera's ``features[key]["info"]["curation"]`` so the
verdict travels with the dataset. Returns the report path.
"""
report = build_report(verdicts, mapping, cfg)
default_report_path = root / "meta" / "camera_curation.json"
report_path = Path(cfg.report_path) if cfg.report_path is not None else default_report_path
report_path.parent.mkdir(parents=True, exist_ok=True)
write_json(report, report_path)
info = load_info(root)
changed = False
for v in verdicts:
feature = info.features.get(v.camera_key)
if feature is None:
continue
feature.setdefault("info", {})
if feature["info"] is None:
feature["info"] = {}
feature["info"]["curation"] = {
"view_label": v.view_label,
"usable": v.usable,
"blur_reason": v.blur_reason,
"confidence": v.confidence,
}
changed = True
if changed:
write_info(info, root)
return report_path
def _swap_key_in_path(path: str, old_key: str, new_key: str) -> str:
"""Rewrite the ``<old_key>`` path segment of a ``videos/<key>/...`` repo path."""
prefix = f"videos/{old_key}/"
return f"videos/{new_key}/{path[len(prefix):]}" if path.startswith(prefix) else path
def rename_camera_keys_on_hub(
repo_id: str,
name_mapping: dict[str, str],
local_root: Path,
*,
revision: str | None = None,
branch: str | None = None,
token: str | None = None,
commit_message: str | None = None,
) -> Any:
"""Rename camera keys on the Hub without downloading video data.
Edits the small ``meta/`` files locally (under ``local_root``, which must be
a writable dataset root whose ``meta/`` is already present), then commits, in
one atomic ``create_commit``: ``CommitOperationCopy`` + ``CommitOperationDelete``
to move each ``videos/<old>/*`` LFS file server-side, and ``CommitOperationAdd``
for the edited meta files. Renames in place on ``repo_id`` (cross-repo copies
are unsupported); pass ``branch`` to commit to a branch and keep ``main`` intact.
Only video keys can be moved this way reject swaps/cycles and image keys
(handled by the local ``rename_features`` path instead).
"""
from huggingface_hub import CommitOperationAdd, CommitOperationCopy, CommitOperationDelete, HfApi
# A swap/cycle (a target that is also a source) cannot be expressed in a
# single base-revision commit; defer to the local rename path.
swaps = set(name_mapping.values()) & set(name_mapping)
if swaps:
raise NotImplementedError(
f"Hub rename cannot swap keys in one commit (offending: {sorted(swaps)}); "
"use the local rename_features path for swaps/cycles."
)
# Determine which OLD keys are video-stored (only those have a videos/ tree)
# BEFORE remapping the metadata.
info = load_info(local_root)
video_old_keys = {
old for old in name_mapping if info.features.get(old, {}).get("dtype") == "video"
}
image_old_keys = {
old for old in name_mapping if info.features.get(old, {}).get("dtype") == "image"
}
if image_old_keys:
raise NotImplementedError(
f"Hub rename cannot move image data stored in the data parquet (keys: {sorted(image_old_keys)}); "
"use --mode report (metadata mapping) or the local rename_features path for image datasets."
)
# 1. Rewrite meta/ locally (info features, episodes columns, stats keys).
_remap_camera_key_in_meta(local_root, name_mapping)
api = HfApi(token=token)
operations: list[Any] = []
# 2. Add the (small) meta files we just edited.
meta_dir = local_root / "meta"
meta_files = [meta_dir / "info.json"]
stats_file = meta_dir / "stats.json"
if stats_file.exists():
meta_files.append(stats_file)
meta_files.extend(sorted((meta_dir / "episodes").glob("*/*.parquet")))
for fpath in meta_files:
rel = fpath.relative_to(local_root).as_posix()
operations.append(CommitOperationAdd(path_in_repo=rel, path_or_fileobj=str(fpath)))
# 3. Move video LFS files server-side (copy + delete), no download.
repo_files = api.list_repo_files(repo_id, repo_type="dataset", revision=revision)
n_moved = 0
for old in video_old_keys:
new = name_mapping[old]
prefix = f"videos/{old}/"
for f in repo_files:
if f.startswith(prefix):
operations.append(
CommitOperationCopy(src_path_in_repo=f, path_in_repo=_swap_key_in_path(f, old, new))
)
operations.append(CommitOperationDelete(path_in_repo=f))
n_moved += 1
logger.info(
"hub rename: moving %d video file(s) server-side across %d camera(s)",
n_moved,
len(video_old_keys),
)
commit_info = api.create_commit(
repo_id=repo_id,
repo_type="dataset",
operations=operations,
revision=branch or revision,
commit_message=commit_message or "curate: rename camera views (lerobot-curate-cameras)",
)
return commit_info
def as_report_dict(verdicts: list[CameraVerdict]) -> list[dict[str, Any]]:
"""Convenience: verdicts as plain dicts (for logging/JSON)."""
return [asdict(v) for v in verdicts]
@@ -1,28 +0,0 @@
You are inspecting frames from ONE camera of a robot manipulation dataset. All
frames come from the same fixed camera during a single episode; use them
together to judge the camera, not any single moment.
Do two things and return them as one JSON object.
1. QUALITY. Decide whether this camera view is usable for training a policy.
Mark it UNUSABLE if it is blurry / out of focus, badly over- or
under-exposed, mostly occluded, static/frozen, corrupted, or otherwise does
not clearly show the scene. Otherwise it is usable.
2. VIEW LABEL. Choose the single best label for where this camera is mounted /
what it looks at, using ONLY this closed vocabulary:
{vocabulary}
{combo_rule}Pick the label that best matches the viewpoint (e.g. a
downward overhead shot is "top"; a camera on the robot's gripper/hand that
moves with the arm is "wrist"). Do not invent words outside the vocabulary.
Output strictly valid JSON, no prose, no code fences, with exactly these keys:
{{
"usable": true or false,
"blur_reason": "<short reason if unusable, else null>",
"view_label": "<one label from the vocabulary, combos joined by '_'>",
"confidence": <number between 0 and 1>
}}
-2
View File
@@ -33,7 +33,6 @@ from .dataset_tools import (
recompute_stats,
reencode_dataset,
remove_feature,
rename_features,
split_dataset,
)
from .factory import make_dataset, make_train_eval_datasets, resolve_delta_timestamps
@@ -97,7 +96,6 @@ __all__ = [
"recompute_stats",
"reencode_dataset",
"remove_feature",
"rename_features",
"resolve_delta_timestamps",
"safe_stop_image_writer",
"split_dataset",
+58 -114
View File
@@ -19,7 +19,6 @@ import copy
import logging
import shutil
from pathlib import Path
from typing import Any, NotRequired, TypedDict
import datasets
import pandas as pd
@@ -50,32 +49,8 @@ from .utils import (
)
from .video_utils import concatenate_video_files, get_video_duration_in_s
logger = logging.getLogger(__name__)
type FeatureDict = dict[str, dict[str, Any]]
type ChunkFile = tuple[int, int]
class IndexState(TypedDict):
chunk: int
file: int
src_to_dst: NotRequired[dict[ChunkFile, ChunkFile]]
class VideoIndex(TypedDict):
chunk: int
file: int
latest_duration: float
episode_duration: float
src_to_offset: NotRequired[dict[ChunkFile, float]]
src_to_dst: NotRequired[dict[ChunkFile, ChunkFile]]
dst_file_durations: NotRequired[dict[ChunkFile, float]]
type VideoIndexState = dict[str, VideoIndex]
def merge_video_feature_info_for_aggregate(all_metadata: list[LeRobotDatasetMetadata]) -> FeatureDict:
def merge_video_feature_info_for_aggregate(all_metadata: list[LeRobotDatasetMetadata]) -> dict[str, dict]:
"""Create a merged video feature info dictionary for aggregation. The video encoder info is merged field-by-field: each key is kept only when every source agrees; otherwise that key is set to ``null`` (or ``{}`` for ``video.extra_options``) and a warning is logged.
Args:
@@ -84,14 +59,14 @@ def merge_video_feature_info_for_aggregate(all_metadata: list[LeRobotDatasetMeta
Returns:
dict: A dictionary of merged video feature info.
"""
merged_info: FeatureDict = copy.deepcopy(all_metadata[0].features)
merged_info = copy.deepcopy(all_metadata[0].features)
video_keys = [k for k in merged_info if merged_info[k].get("dtype") == "video"]
for vk in video_keys:
video_infos = [m.features.get(vk, {}).get("info") or {} for m in all_metadata]
base_video_info = video_infos[0]
merged_encoder_info: dict[str, Any] = {}
merged_encoder_info: dict = {}
fallback_keys: list[str] = []
for info_key in VIDEO_ENCODER_INFO_KEYS:
values = [info.get(info_key, None) for info in video_infos]
@@ -105,7 +80,7 @@ def merge_video_feature_info_for_aggregate(all_metadata: list[LeRobotDatasetMeta
merged_encoder_info[info_key] = {} if info_key == "video.extra_options" else None
if fallback_keys:
logger.warning(
logging.warning(
f"Merging heterogeneous or incomplete video encoder metadata for feature {vk}. "
f"Setting these keys to null: {fallback_keys}.",
)
@@ -117,7 +92,7 @@ def merge_video_feature_info_for_aggregate(all_metadata: list[LeRobotDatasetMeta
return merged_info
def validate_all_metadata(all_metadata: list[LeRobotDatasetMetadata]) -> tuple[int, str | None, FeatureDict]:
def validate_all_metadata(all_metadata: list[LeRobotDatasetMetadata]):
"""Validates that all dataset metadata have consistent properties.
Ensures all datasets have the same fps, robot_type, and features to guarantee
@@ -154,9 +129,7 @@ def validate_all_metadata(all_metadata: list[LeRobotDatasetMetadata]) -> tuple[i
return fps, robot_type, features
def update_data_df(
df: pd.DataFrame, src_meta: LeRobotDatasetMetadata, dst_meta: LeRobotDatasetMetadata
) -> pd.DataFrame:
def update_data_df(df, src_meta, dst_meta):
"""Updates a data DataFrame with new indices and task mappings for aggregation.
Adjusts episode indices, frame indices, and task indices to account for
@@ -181,12 +154,12 @@ def update_data_df(
def update_meta_data(
df: pd.DataFrame,
dst_meta: LeRobotDatasetMetadata,
meta_idx: IndexState,
data_idx: IndexState,
videos_idx: VideoIndexState,
) -> pd.DataFrame:
df,
dst_meta,
meta_idx,
data_idx,
videos_idx,
):
"""Updates metadata DataFrame with new chunk, file, and timestamp indices.
Adjusts all indices and timestamps to account for previously aggregated
@@ -316,7 +289,7 @@ def aggregate_datasets(
chunk_size: int | None = None,
concatenate_videos: bool = True,
concatenate_data: bool = True,
) -> None:
):
"""Aggregates multiple LeRobot datasets into a single unified dataset.
This is the main function that orchestrates the aggregation process by:
@@ -336,7 +309,7 @@ def aggregate_datasets(
concatenate_videos: When False, keep one mp4 per source file instead of packing into shards.
concatenate_data: When False, keep one parquet per source file instead of packing into shards.
"""
logger.info("Start aggregate_datasets")
logging.info("Start aggregate_datasets")
if data_files_size_in_mb is None:
data_files_size_in_mb = DEFAULT_DATA_FILE_SIZE_IN_MB
@@ -368,15 +341,15 @@ def aggregate_datasets(
video_files_size_in_mb=video_files_size_in_mb,
)
logger.info("Find all tasks")
logging.info("Find all tasks")
unique_tasks = pd.concat([m.tasks for m in all_metadata]).index.unique()
dst_meta.tasks = pd.DataFrame(
{"task_index": range(len(unique_tasks))}, index=pd.Index(unique_tasks, name="task")
)
meta_idx: IndexState = {"chunk": 0, "file": 0}
data_idx: IndexState = {"chunk": 0, "file": 0}
videos_idx: VideoIndexState = {
meta_idx = {"chunk": 0, "file": 0}
data_idx = {"chunk": 0, "file": 0}
videos_idx = {
key: {"chunk": 0, "file": 0, "latest_duration": 0, "episode_duration": 0} for key in video_keys
}
@@ -400,17 +373,12 @@ def aggregate_datasets(
dst_meta.info.total_frames += src_meta.total_frames
finalize_aggregation(dst_meta, all_metadata)
logger.info("Aggregation complete.")
logging.info("Aggregation complete.")
def aggregate_videos(
src_meta: LeRobotDatasetMetadata,
dst_meta: LeRobotDatasetMetadata,
videos_idx: VideoIndexState,
video_files_size_in_mb: float,
chunk_size: int,
concatenate_videos: bool = True,
) -> VideoIndexState:
src_meta, dst_meta, videos_idx, video_files_size_in_mb, chunk_size, concatenate_videos=True
):
"""Aggregates video chunks from a source dataset into the destination dataset.
Handles video file concatenation and rotation based on file size limits.
@@ -438,16 +406,15 @@ def aggregate_videos(
videos_idx[key]["dst_file_durations"] = {}
for key, video_idx in videos_idx.items():
unique_chunk_file_pairs: list[ChunkFile] = sorted(
{
(chunk, file)
for chunk, file in zip(
src_meta.episodes[f"videos/{key}/chunk_index"],
src_meta.episodes[f"videos/{key}/file_index"],
strict=False,
)
}
)
unique_chunk_file_pairs = {
(chunk, file)
for chunk, file in zip(
src_meta.episodes[f"videos/{key}/chunk_index"],
src_meta.episodes[f"videos/{key}/file_index"],
strict=False,
)
}
unique_chunk_file_pairs = sorted(unique_chunk_file_pairs)
chunk_idx = video_idx["chunk"]
file_idx = video_idx["file"]
@@ -522,14 +489,7 @@ def aggregate_videos(
return videos_idx
def aggregate_data(
src_meta: LeRobotDatasetMetadata,
dst_meta: LeRobotDatasetMetadata,
data_idx: IndexState,
data_files_size_in_mb: float,
chunk_size: int,
concatenate_data: bool = True,
) -> IndexState:
def aggregate_data(src_meta, dst_meta, data_idx, data_files_size_in_mb, chunk_size, concatenate_data=True):
"""Aggregates data chunks from a source dataset into the destination dataset.
Reads source data files, updates indices to match the aggregated dataset,
@@ -550,16 +510,14 @@ def aggregate_data(
Returns:
dict: Updated data_idx with current chunk and file indices.
"""
unique_chunk_file_ids: list[ChunkFile] = sorted(
{
(c, f)
for c, f in zip(
src_meta.episodes["data/chunk_index"],
src_meta.episodes["data/file_index"],
strict=False,
)
}
)
unique_chunk_file_ids = {
(c, f)
for c, f in zip(
src_meta.episodes["data/chunk_index"], src_meta.episodes["data/file_index"], strict=False
)
}
unique_chunk_file_ids = sorted(unique_chunk_file_ids)
contains_images = len(dst_meta.image_keys) > 0
# retrieve features schema for proper image typing in parquet
@@ -567,7 +525,7 @@ def aggregate_data(
# Track source to destination file mapping for metadata update
# This is critical for handling datasets that are already results of a merge
src_to_dst: dict[ChunkFile, ChunkFile] = {}
src_to_dst: dict[tuple[int, int], tuple[int, int]] = {}
for src_chunk_idx, src_file_idx in unique_chunk_file_ids:
src_path = src_meta.root / DEFAULT_DATA_PATH.format(
@@ -606,13 +564,7 @@ def aggregate_data(
return data_idx
def aggregate_metadata(
src_meta: LeRobotDatasetMetadata,
dst_meta: LeRobotDatasetMetadata,
meta_idx: IndexState,
data_idx: IndexState,
videos_idx: VideoIndexState,
) -> IndexState:
def aggregate_metadata(src_meta, dst_meta, meta_idx, data_idx, videos_idx):
"""Aggregates metadata from a source dataset into the destination dataset.
Reads source metadata files, updates all indices and timestamps,
@@ -628,16 +580,16 @@ def aggregate_metadata(
Returns:
dict: Updated meta_idx with current chunk and file indices.
"""
chunk_file_ids: list[ChunkFile] = sorted(
{
(c, f)
for c, f in zip(
src_meta.episodes["meta/episodes/chunk_index"],
src_meta.episodes["meta/episodes/file_index"],
strict=False,
)
}
)
chunk_file_ids = {
(c, f)
for c, f in zip(
src_meta.episodes["meta/episodes/chunk_index"],
src_meta.episodes["meta/episodes/file_index"],
strict=False,
)
}
chunk_file_ids = sorted(chunk_file_ids)
for chunk_idx, file_idx in chunk_file_ids:
src_path = src_meta.root / DEFAULT_EPISODES_PATH.format(chunk_index=chunk_idx, file_index=file_idx)
df = pd.read_parquet(src_path)
@@ -670,16 +622,16 @@ def aggregate_metadata(
def append_or_create_parquet_file(
df: pd.DataFrame,
src_path: Path,
idx: IndexState,
idx: dict[str, int],
max_mb: float,
chunk_size: int,
default_path: str,
contains_images: bool = False,
aggr_root: Path | None = None,
aggr_root: Path = None,
hf_features: datasets.Features | None = None,
concatenate: bool = True,
one_row_group_per_episode: bool = False,
) -> tuple[IndexState, ChunkFile]:
) -> tuple[dict[str, int], tuple[int, int]]:
"""Appends data to an existing parquet file or creates a new one based on size constraints.
Manages file rotation when size limits are exceeded to prevent individual files
@@ -702,13 +654,7 @@ def append_or_create_parquet_file(
Returns:
tuple: (updated_idx, (dst_chunk, dst_file)) where updated_idx is the index dict
and (dst_chunk, dst_file) is the actual destination file the data was written to.
Raises:
ValueError: If aggr_root is not provided.
"""
if aggr_root is None:
raise ValueError("aggr_root must be provided.")
dst_chunk, dst_file = idx["chunk"], idx["file"]
dst_path = aggr_root / default_path.format(chunk_index=dst_chunk, file_index=dst_file)
@@ -752,9 +698,7 @@ def append_or_create_parquet_file(
return idx, (dst_chunk, dst_file)
def finalize_aggregation(
aggr_meta: LeRobotDatasetMetadata, all_metadata: list[LeRobotDatasetMetadata]
) -> None:
def finalize_aggregation(aggr_meta, all_metadata):
"""Finalizes the dataset aggregation by writing summary files and statistics.
Writes the tasks file, info file with total counts and splits, and
@@ -764,16 +708,16 @@ def finalize_aggregation(
aggr_meta: Aggregated dataset metadata.
all_metadata: List of all source dataset metadata objects.
"""
logger.info("write tasks")
logging.info("write tasks")
write_tasks(aggr_meta.tasks, aggr_meta.root)
logger.info("write info")
logging.info("write info")
aggr_meta.info.total_tasks = len(aggr_meta.tasks)
aggr_meta.info.total_episodes = sum(m.total_episodes for m in all_metadata)
aggr_meta.info.total_frames = sum(m.total_frames for m in all_metadata)
aggr_meta.info.splits = {"train": f"0:{sum(m.total_episodes for m in all_metadata)}"}
write_info(aggr_meta.info, aggr_meta.root)
logger.info("write stats")
logging.info("write stats")
aggr_meta.stats = aggregate_stats([m.stats for m in all_metadata])
write_stats(aggr_meta.stats, aggr_meta.root)
-249
View File
@@ -47,7 +47,6 @@ from lerobot.configs import (
)
from lerobot.configs.video import DEPTH_ENCODER_INFO_FIELD_NAMES
from lerobot.utils.constants import ACTION, HF_LEROBOT_HOME, OBS_IMAGE, OBS_STATE
from lerobot.utils.io_utils import load_json, write_json
from lerobot.utils.utils import flatten_dict
from .aggregate import aggregate_datasets
@@ -61,8 +60,6 @@ from .image_writer import write_image
from .io_utils import (
get_parquet_file_size_in_mb,
load_episodes,
load_info,
to_parquet_one_row_group_per_episode,
write_info,
write_stats,
write_tasks,
@@ -75,9 +72,7 @@ from .utils import (
DEFAULT_DATA_PATH,
DEFAULT_EPISODES_PATH,
DEPTH_FILE_PATTERN,
EPISODES_DIR,
IMAGE_FILE_PATTERN,
STATS_PATH,
VIDEO_DIR,
update_chunk_file_indices,
)
@@ -489,250 +484,6 @@ def remove_feature(
)
# Columns in ``meta/episodes/*.parquet`` are namespaced by feature key under
# these prefixes (e.g. ``videos/observation.images.top/from_timestamp`` and
# ``stats/observation.images.top/mean``). Renaming a feature means rewriting the
# middle ``<key>`` segment of every such column. Note ``stats/*`` columns are
# invisible via ``meta.episodes`` (``load_episodes`` drops them), so we operate
# on the raw parquet.
_EPISODE_KEY_PREFIXES = ("videos", "stats")
# Features that must never be renamed (or become a rename target): the dataset
# indexing/bookkeeping columns.
_REQUIRED_FEATURES = frozenset({"timestamp", "frame_index", "episode_index", "index", "task_index"})
def _resolve_rename_collisions(
name_mapping: dict[str, str],
existing_features: dict[str, dict],
on_collision: str,
) -> dict[str, str]:
"""Validate/disambiguate a ``{old_key: new_key}`` mapping against collisions.
The post-rename key set is ``(features \\ sources) targets``. A collision is
either two sources mapping to the same target, or a target equal to an
untouched existing key. Swaps/cycles between sources are *not* collisions
(handled downstream). ``on_collision="error"`` raises listing every offending
pair; ``"suffix"`` disambiguates deterministically (``top`` ``top_2`` )
in sorted-source order.
"""
if on_collision not in ("error", "suffix"):
raise ValueError(f"on_collision must be 'error' or 'suffix', got {on_collision!r}")
sources = set(name_mapping)
untouched = set(existing_features) - sources
targets = list(name_mapping.values())
duplicate_targets = {t for t in targets if targets.count(t) > 1}
untouched_collisions = set(targets) & untouched
if on_collision == "error":
problems = []
if duplicate_targets:
problems.append(f"multiple cameras map to the same target(s): {sorted(duplicate_targets)}")
if untouched_collisions:
problems.append(
f"target(s) collide with existing feature(s) not being renamed: "
f"{sorted(untouched_collisions)}"
)
if problems:
raise ValueError(
"rename_features collision(s): "
+ "; ".join(problems)
+ ". Resolve the labels (e.g. use combos like 'left_wrist') or pass "
"on_collision='suffix'."
)
return dict(name_mapping)
# suffix mode: greedily de-collide in a deterministic (sorted) order.
used = set(untouched)
resolved: dict[str, str] = {}
for src in sorted(name_mapping):
target = name_mapping[src]
if target in used:
base, i = target, 2
while target in used:
target = f"{base}_{i}"
i += 1
resolved[src] = target
used.add(target)
return resolved
def _remap_camera_key_in_meta(root: Path, name_mapping: dict[str, str]) -> None:
"""Rename feature keys across the dataset's ``meta/`` files (no file moves).
Touches: ``meta/info.json`` ``features`` (key renamed, feature dict carried
verbatim so codec ``info`` / depth params survive), every
``meta/episodes/*/*.parquet`` (``videos/<old>/*`` and ``stats/<old>/*``
columns), and ``meta/stats.json`` (top-level ``<old>`` key). All three are
simultaneous relabels, so swaps/cycles are safe here.
"""
# info.json — rebuild features preserving insertion order.
info = load_info(root)
info.features = {name_mapping.get(key, key): ft for key, ft in info.features.items()}
write_info(info, root)
# episodes parquet — rename namespaced columns by prefix.
def _rename_column(col: str) -> str:
for prefix in _EPISODE_KEY_PREFIXES:
head = f"{prefix}/"
if col.startswith(head):
rest = col[len(head) :]
for old, new in name_mapping.items():
if rest == old or rest.startswith(f"{old}/"):
return f"{head}{new}{rest[len(old) :]}"
return col
for path in sorted((root / EPISODES_DIR).glob("*/*.parquet")):
df = pd.read_parquet(path)
col_map = {c: _rename_column(c) for c in df.columns if _rename_column(c) != c}
if col_map:
df = df.rename(columns=col_map)
to_parquet_one_row_group_per_episode(df, path)
# stats.json — remap top-level feature keys.
stats_path = root / STATS_PATH
if stats_path.exists():
stats = load_json(stats_path)
if isinstance(stats, dict):
stats = {name_mapping.get(key, key): value for key, value in stats.items()}
write_json(stats, stats_path)
def _move_camera_key_dirs(root: Path, name_mapping: dict[str, str]) -> None:
"""Move ``videos/<old>`` and ``images/<old>`` trees to their new key names.
Two-phase (source sentinel target) so a swap like ``{a: b, b: a}`` cannot
clobber. Missing source dirs are skipped (a key may be stored one way only).
"""
for subdir in (VIDEO_DIR, "images"):
base = root / subdir
if not base.exists():
continue
# Phase 1: move every source to a unique sentinel.
sentinels: dict[str, Path] = {}
for i, old in enumerate(name_mapping):
src = base / old
if src.exists():
sentinel = base / f".__rename_tmp_{i}__"
shutil.move(str(src), str(sentinel))
sentinels[old] = sentinel
# Phase 2: sentinel → final target.
for old, sentinel in sentinels.items():
shutil.move(str(sentinel), str(base / name_mapping[old]))
def _rename_image_data_columns(root: Path, name_mapping: dict[str, str]) -> None:
"""Rename image-feature columns inside ``data/*.parquet`` at the Arrow level.
Image datasets embed frames as HF ``Image()`` columns in the data parquet.
We rename the Arrow field *and* the matching key in the schema-level
``huggingface`` metadata (which references columns by name), so no pixel
bytes are decoded or re-embedded and ``datasets`` still types the column as
an image after the rename.
"""
import json
data_dir = root / DATA_DIR
if not data_dir.exists():
return
for path in sorted(data_dir.glob("*/*.parquet")):
table = pq.read_table(path)
col_map = {c: name_mapping[c] for c in table.column_names if c in name_mapping}
if not col_map:
continue
table = table.rename_columns([col_map.get(c, c) for c in table.column_names])
metadata = dict(table.schema.metadata or {})
hf_key = b"huggingface"
if hf_key in metadata:
hf_meta = json.loads(metadata[hf_key])
features = hf_meta.get("info", {}).get("features")
if isinstance(features, dict):
for old, new in col_map.items():
if old in features:
features[new] = features.pop(old)
metadata[hf_key] = json.dumps(hf_meta).encode()
table = table.replace_schema_metadata(metadata)
pq.write_table(table, str(path))
def rename_features(
dataset: LeRobotDataset,
name_mapping: dict[str, str],
output_dir: str | Path | None = None,
repo_id: str | None = None,
*,
on_collision: str = "error",
) -> LeRobotDataset:
"""Rename dataset feature keys without re-encoding any pixel data.
A rename changes zero frame content, so this does a cheap key-remap rather
than the full-copy ``modify_features`` path (which would re-embed images and
byte-copy videos). It rewrites ``meta/`` (info features, episodes
``videos/*``+``stats/*`` columns, stats.json keys), moves the physical
``videos/<key>/`` (and ``images/<key>/``) directories, and for image
datasets renames the embedded ``data/*.parquet`` image column at the Arrow
level. Feature ``info`` dicts (video codec params, depth ``is_depth_map``) are
carried verbatim.
Args:
dataset: The source LeRobotDataset.
name_mapping: ``{old_feature_key: new_feature_key}``. Identity pairs are
ignored. Typically used to canonicalize camera keys, e.g.
``{"observation.images.cam_0": "observation.images.left_wrist"}``.
output_dir: Where the renamed dataset is written. Defaults to
``$HF_LEROBOT_HOME/repo_id``. When it equals ``dataset.root`` the
rename is applied in place.
repo_id: Identifier for the renamed dataset (default ``<repo_id>_renamed``).
on_collision: ``"error"`` (default) raises on colliding targets;
``"suffix"`` disambiguates deterministically (``top`` ``top_2``).
Returns:
The renamed LeRobotDataset.
"""
if not name_mapping:
raise ValueError("name_mapping must be a non-empty {old_key: new_key} dict")
features = dataset.meta.features
mapping = {old: new for old, new in name_mapping.items() if old != new}
if not mapping:
raise ValueError("name_mapping only contains identity renames (old == new); nothing to do")
missing = [old for old in mapping if old not in features]
if missing:
raise ValueError(f"Feature(s) not found in dataset: {missing}")
bad_required = sorted(
{name for pair in mapping.items() for name in pair if name in _REQUIRED_FEATURES}
)
if bad_required:
raise ValueError(f"Cannot rename to/from required features: {bad_required}")
bad_names = [new for new in mapping.values() if "/" in new]
if bad_names:
raise ValueError(f"Target feature name(s) cannot contain '/': {bad_names}")
mapping = _resolve_rename_collisions(mapping, features, on_collision)
if repo_id is None:
repo_id = f"{dataset.repo_id}_renamed"
output_dir = Path(output_dir) if output_dir is not None else HF_LEROBOT_HOME / repo_id
in_place = output_dir.resolve() == Path(dataset.root).resolve()
if not in_place:
shutil.copytree(dataset.root, output_dir)
image_keys = set(dataset.meta.image_keys)
_remap_camera_key_in_meta(output_dir, mapping)
_move_camera_key_dirs(output_dir, mapping)
image_mapping = {old: new for old, new in mapping.items() if old in image_keys}
if image_mapping:
_rename_image_data_columns(output_dir, image_mapping)
return LeRobotDataset(repo_id=repo_id, root=output_dir)
def _fractions_to_episode_indices(
total_episodes: int,
splits: dict[str, float],
+1 -6
View File
@@ -384,12 +384,7 @@ class LiberoEnv(gym.Env):
def close(self):
if self._env is not None:
try:
self._env.close()
finally:
# LIBERO deletes its inner env on close, so this wrapper must
# be recreated before the next reset.
self._env = None
self._env.close()
def _make_env_fns(
-112
View File
@@ -1,112 +0,0 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Run ``lerobot-curate-cameras`` on HF Jobs (HuggingFace GPUs).
Same shape as the annotation submitter (``lerobot.jobs.annotate``): the VLM
decision needs a GPU, so the pod boots the ``vllm/vllm-openai`` image, installs
lerobot on top, and replays the user's CLI with ``lerobot-curate-cameras``. The
``--mode=rename`` commit runs from the pod (which holds ``HF_TOKEN``); a bare
``--mode=report`` run leaves its output only on the pod, so we warn.
"""
from __future__ import annotations
import shlex
import sys
from dataclasses import is_dataclass
from typing import TYPE_CHECKING
from huggingface_hub import HfApi, get_token, run_job
from .annotate import build_pod_setup
from .dataset import ensure_dataset_available
from .hf import _pod_forwarded_args, follow_job, resolve_job_tags
if TYPE_CHECKING:
from lerobot.annotations.camera_curation.config import CameraCurationConfig
# Same rationale as the annotate submitter: --root is host-local, --repo_id is
# re-emitted, config files can't be read on the pod, and --job could smuggle a
# remote target back onto the pod.
_SUBMITTER_OWNED_ARGS = ("--root", "--repo_id", "--config_path", "--job")
def _local_config_file_args(cfg: CameraCurationConfig) -> list[str]:
return ["--config_path", *(f"--{name}" for name in vars(cfg) if is_dataclass(getattr(cfg, name)))]
def build_pod_command(repo_id: str, lerobot_ref: str, argv: list[str]) -> list[str]:
"""``bash -c`` command the pod runs: setup prelude, then curate-cameras."""
forwarded = _pod_forwarded_args(argv, drop_names=_SUBMITTER_OWNED_ARGS, drop_prefixes=("--job.",))
curate = shlex.join(
["lerobot-curate-cameras", f"--repo_id={repo_id}", *forwarded, "--job.target=local"]
)
return ["bash", "-c", f"{build_pod_setup(lerobot_ref)} && {curate}"]
def submit_curate_to_hf(cfg: CameraCurationConfig) -> None:
"""Submit a camera-curation run to HF Jobs and tail its logs."""
token = get_token()
if not token:
raise RuntimeError("Not logged in to Hugging Face. Run `hf auth login` first.")
if cfg.repo_id is None:
raise ValueError(
"Remote curation requires --repo_id: the pod downloads the dataset from the Hub, "
"and --root only names a directory on this machine."
)
argv = sys.argv[1:]
passed = {tok.split("=", 1)[0] for tok in argv}
used_config_files = sorted(passed.intersection(_local_config_file_args(cfg)))
if used_config_files:
raise ValueError(
f"{', '.join(used_config_files)} cannot be used with a remote --job.target: the pod "
"cannot read config files from this machine. Pass the settings as CLI flags instead."
)
if cfg.mode == "report":
print(
"WARNING: --mode=report writes its result into the pod's local copy, which is discarded "
"when the job ends. Use --mode=rename to commit the result to the Hub."
)
api = HfApi(token=token)
tags = resolve_job_tags(cfg.job.tags)
ensure_dataset_available(cfg.repo_id, api=api, tags=tags)
command = build_pod_command(cfg.repo_id, cfg.job.lerobot_ref, argv)
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={"HF_TOKEN": token},
timeout=cfg.job.timeout,
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" Dataset repo: https://huggingface.co/datasets/{cfg.repo_id}")
print(f" Monitor: hf jobs logs {job_id}")
print(f" Cancel: hf jobs cancel {job_id}")
if not follow_job(job_id, detach=cfg.job.detach):
return
print("\nCuration complete.")
+5 -35
View File
@@ -302,33 +302,6 @@ def _pad_evo1_stats(
return padded_stats
def _refresh_evo1_normalization_steps(
config: Evo1Config,
preprocessor: PolicyProcessorPipeline,
postprocessor: PolicyProcessorPipeline,
) -> None:
"""Re-pad checkpoint-loaded (un)normalizer stats/features to EVO1's fixed widths.
Loading a checkpoint injects the raw dataset stats (unpadded to max_state_dim/max_action_dim)
into the (un)normalizer via the generic override path in make_pre_post_processors. Those stats
and their declared features must be re-padded/reshaped to EVO1's fixed widths, otherwise
normalization fails against the padded state/action tensors (e.g. state padded to 24 vs. 8-dim
LIBERO stats). Padding is a no-op when stats are already at the target width.
"""
normalization_features = _evo1_normalization_features(config)
action_features = _evo1_action_features(config)
for step in preprocessor.steps:
if isinstance(step, NormalizerProcessorStep):
step.features = normalization_features
step.stats = _pad_evo1_stats(config, step.stats)
step.to(device=step.device, dtype=step.dtype)
for step in postprocessor.steps:
if isinstance(step, UnnormalizerProcessorStep):
step.features = action_features
step.stats = _pad_evo1_stats(config, step.stats)
step.to(device=step.device, dtype=step.dtype)
def reconcile_evo1_processors(
config: Evo1Config,
preprocessor: PolicyProcessorPipeline,
@@ -336,19 +309,16 @@ def reconcile_evo1_processors(
) -> tuple[PolicyProcessorPipeline, PolicyProcessorPipeline]:
"""Reconcile checkpoint-loaded pipelines with the current EVO1 config.
Three things cannot be restored from a serialized pipeline alone: the EVO1 batch converter
(converters are plain functions and are never serialized), eval-time CLI overrides of the
action postprocessing flags (`postprocess_action_dim`, `binarize_gripper`, `gripper_*`), and the
(un)normalizer stats/features when the generic override path injects raw, unpadded dataset
stats. This restores the converter, re-pads the normalization stats to EVO1's fixed widths, and
rebuilds the action step from the current config so those overrides take effect.
Two things cannot be restored from a serialized pipeline alone: the EVO1 batch converter
(converters are plain functions and are never serialized), and eval-time CLI overrides of the
action postprocessing flags (`postprocess_action_dim`, `binarize_gripper`, `gripper_*`). This
restores the converter and rebuilds the action step from the current config so those overrides
take effect.
"""
# Pipelines reloaded from a checkpoint come back with the default batch converter, which drops
# non-observation extras (embodiment_id, state_mask, custom task fields) needed by EVO1.
preprocessor.to_transition = evo1_batch_to_transition
_refresh_evo1_normalization_steps(config, preprocessor, postprocessor)
action_step = Evo1ActionProcessorStep(
action_dim=_evo1_action_dim(config),
binarize_gripper=config.binarize_gripper,
+2 -2
View File
@@ -18,7 +18,7 @@ import functools
import threading
from collections.abc import Callable, Sequence
from contextlib import suppress
from typing import NotRequired, TypedDict
from typing import TypedDict
import torch
import torch.nn.functional as F # noqa: N812
@@ -36,7 +36,7 @@ class BatchTransition(TypedDict):
next_state: dict[str, torch.Tensor]
done: torch.Tensor
truncated: torch.Tensor
complementary_info: NotRequired[dict[str, torch.Tensor | float | int] | None]
complementary_info: dict[str, torch.Tensor | float | int] | None = None
def random_crop_vectorized(images: torch.Tensor, output_size: tuple) -> torch.Tensor:
@@ -46,12 +46,6 @@ class SOFollowerConfig:
position_i_coefficient: int = 0
position_d_coefficient: int = 32
# Number of extra attempts when a `sync_read` of the motors fails. Feetech buses can occasionally
# return a corrupted status packet ("Incorrect status packet!"), especially when several joints move
# at once, which otherwise aborts the control loop. Retries are immediate (no sleep) and only happen on
# failure, so the steady-state read cost is unchanged.
num_read_retries: int = 2
@RobotConfig.register_subclass("so101_follower")
@RobotConfig.register_subclass("so100_follower")
@@ -510,10 +510,10 @@ class ForwardKinematicsJointsToEEAction(RobotActionProcessorStep):
# We only use the ee pose in the dataset, so we don't need the joint positions
for n in self.motor_names:
features[PipelineFeatureType.ACTION].pop(f"{n}.pos", None)
# Store end-effector features as actions in the dataset schema
# We specify the dataset features of this step that we want to be stored in the dataset
for k in ["x", "y", "z", "wx", "wy", "wz", "gripper_pos"]:
features[PipelineFeatureType.ACTION][f"ee.{k}"] = PolicyFeature(
type=FeatureType.ACTION, shape=(1,)
type=FeatureType.STATE, shape=(1,)
)
return features
@@ -180,7 +180,7 @@ class SOFollower(Robot):
def get_observation(self) -> RobotObservation:
# Read arm position
start = time.perf_counter()
obs_dict = self.bus.sync_read("Present_Position", num_retry=self.config.num_read_retries)
obs_dict = self.bus.sync_read("Present_Position")
obs_dict = {f"{motor}.pos": val for motor, val in obs_dict.items()}
dt_ms = (time.perf_counter() - start) * 1e3
logger.debug(f"{self} read state: {dt_ms:.1f}ms")
@@ -221,7 +221,7 @@ class SOFollower(Robot):
# Cap goal position when too far away from present position.
# /!\ Slower fps expected due to reading from the follower.
if self.config.max_relative_target is not None:
present_pos = self.bus.sync_read("Present_Position", num_retry=self.config.num_read_retries)
present_pos = self.bus.sync_read("Present_Position")
goal_present_pos = {key: (g_pos, present_pos[key]) for key, g_pos in goal_pos.items()}
goal_pos = ensure_safe_goal_position(goal_present_pos, self.config.max_relative_target)
@@ -36,7 +36,6 @@ python src/lerobot/scripts/augment_dataset_quantile_stats.py \
import argparse
import concurrent.futures
import logging
import os
from pathlib import Path
import numpy as np
@@ -53,7 +52,6 @@ from lerobot.datasets import (
get_feature_stats,
write_stats,
)
from lerobot.datasets.compute_stats import sample_indices
from lerobot.utils.utils import init_logging
@@ -79,14 +77,12 @@ def has_quantile_stats(stats: dict[str, dict] | None, quantile_list_keys: list[s
return False
def process_single_episode(dataset: LeRobotDataset, episode_idx: int, use_sampling: bool = True) -> dict:
def process_single_episode(dataset: LeRobotDataset, episode_idx: int) -> dict:
"""Process a single episode and return its statistics.
Args:
dataset: The LeRobot dataset
episode_idx: Index of the episode to process
use_sampling: If True, sub-sample image/video frames per episode to bound
memory. If False, use every frame (exact, higher memory).
Returns:
Dictionary containing episode statistics
@@ -96,31 +92,16 @@ def process_single_episode(dataset: LeRobotDataset, episode_idx: int, use_sampli
start_idx = dataset.meta.episodes[episode_idx]["dataset_from_index"]
end_idx = dataset.meta.episodes[episode_idx]["dataset_to_index"]
episode_len = end_idx - start_idx
# Images/video are the memory hog, so sub-sample those frames per episode;
# numeric columns are cheap, so read them in full (exact).
image_keys = [k for k in dataset.features if dataset.features[k]["dtype"] in ("image", "video")]
numeric_keys = [
k for k in dataset.features if dataset.features[k]["dtype"] not in ("image", "video", "string")
]
collected_data: dict[str, list] = {}
for idx in range(start_idx, end_idx):
item = dataset[idx]
for key, value in item.items():
if key not in dataset.features:
continue
# Numeric features: every frame, read directly from the underlying table.
if numeric_keys:
numeric_cols = dataset.hf_dataset.select_columns(numeric_keys)[start_idx:end_idx]
for key in numeric_keys:
collected_data[key] = [torch.as_tensor(v) for v in numeric_cols[key]]
# Image/video features: decode only a sampled subset of frames.
if image_keys:
sampled_offsets = sample_indices(episode_len) if use_sampling else list(range(episode_len))
for offset in sampled_offsets:
item = dataset[start_idx + offset]
for key in image_keys:
if key in item:
collected_data.setdefault(key, []).append(item[key])
if key not in collected_data:
collected_data[key] = []
collected_data[key].append(value)
ep_stats = {}
for key, data_list in collected_data.items():
@@ -150,13 +131,11 @@ def process_single_episode(dataset: LeRobotDataset, episode_idx: int, use_sampli
return ep_stats
def compute_quantile_stats_for_dataset(dataset: LeRobotDataset, use_sampling: bool = True) -> dict[str, dict]:
def compute_quantile_stats_for_dataset(dataset: LeRobotDataset) -> dict[str, dict]:
"""Compute quantile statistics for all episodes in the dataset.
Args:
dataset: The LeRobot dataset to compute statistics for
use_sampling: If True, sub-sample image/video frames per episode to bound
memory. If False, use every frame (exact, higher memory).
Returns:
Dictionary containing aggregated statistics with quantiles
@@ -174,15 +153,15 @@ def compute_quantile_stats_for_dataset(dataset: LeRobotDataset, use_sampling: bo
if has_videos:
logging.info("Dataset contains video keys - using sequential processing for thread safety")
for episode_idx in tqdm(range(dataset.num_episodes), desc="Processing episodes"):
ep_stats = process_single_episode(dataset, episode_idx, use_sampling)
ep_stats = process_single_episode(dataset, episode_idx)
episode_stats_list.append(ep_stats)
else:
logging.info("Dataset has no video keys - using parallel processing for better performance")
max_workers = min(dataset.num_episodes, int(os.environ.get("LEROBOT_STATS_MAX_WORKERS", 16)))
max_workers = min(dataset.num_episodes, 16)
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_episode = {
executor.submit(process_single_episode, dataset, episode_idx, use_sampling): episode_idx
executor.submit(process_single_episode, dataset, episode_idx): episode_idx
for episode_idx in range(dataset.num_episodes)
}
@@ -209,7 +188,6 @@ def augment_dataset_with_quantile_stats(
repo_id: str,
root: str | Path | None = None,
overwrite: bool = False,
use_sampling: bool = True,
) -> None:
"""Augment a dataset with quantile statistics if they are missing.
@@ -217,8 +195,6 @@ def augment_dataset_with_quantile_stats(
repo_id: Repository ID of the dataset
root: Local root directory for the dataset
overwrite: Overwrite existing quantile statistics if they already exist
use_sampling: If True, sub-sample image/video frames per episode to bound
memory. If False, use every frame (exact, higher memory).
"""
logging.info(f"Loading dataset: {repo_id}")
dataset = LeRobotDataset(
@@ -232,7 +208,7 @@ def augment_dataset_with_quantile_stats(
logging.info("Dataset does not contain quantile statistics. Computing them now...")
new_stats = compute_quantile_stats_for_dataset(dataset, use_sampling=use_sampling)
new_stats = compute_quantile_stats_for_dataset(dataset)
logging.info("Updating dataset metadata with new quantile statistics")
dataset.meta.stats = new_stats
@@ -272,14 +248,6 @@ def main():
action="store_true",
help="Overwrite existing quantile statistics if they already exist",
)
parser.add_argument(
"--no-sampling",
action="store_true",
help=(
"Compute stats over every frame (exact, higher memory). By default, "
"image/video frames are sub-sampled per episode to bound memory."
),
)
args = parser.parse_args()
root = Path(args.root) if args.root else None
@@ -290,7 +258,6 @@ def main():
repo_id=args.repo_id,
root=root,
overwrite=args.overwrite,
use_sampling=not args.no_sampling,
)
@@ -1,244 +0,0 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""``lerobot-curate-cameras`` — VLM camera-view curation for a LeRobot dataset.
Downloads only the first episode, asks a VLM to (1) flag blurry/unusable views
and (2) label each view (``top``/``wrist``/``front``/), then either records the
result in ``meta/`` (``--mode=report``) or renames the camera keys to
``observation.images.<label>`` (``--mode=rename``). For video datasets the
rename is a download-free, server-side Hub commit.
Examples:
# Cheap, mutation-free triage (writes meta/camera_curation.json):
uv run lerobot-curate-cameras --repo_id=user/dataset --mode=report
# Apply the labels by renaming camera keys on a new branch (video datasets):
uv run lerobot-curate-cameras --repo_id=user/dataset --mode=rename --branch=curated
# Run the VLM decision on a GPU via HF Jobs:
uv run lerobot-curate-cameras --repo_id=user/dataset --mode=rename --job.target=h200
"""
import logging
import shutil
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any
from lerobot.annotations.camera_curation import curator
from lerobot.annotations.camera_curation.config import CameraCurationConfig
from lerobot.annotations.steerable_pipeline.frames import make_frame_provider
from lerobot.annotations.steerable_pipeline.reader import iter_episodes
from lerobot.annotations.steerable_pipeline.vlm_client import make_vlm_client
from lerobot.configs import parser
from lerobot.utils.constants import HF_LEROBOT_HOME
from lerobot.utils.import_utils import _datasets_available, require_package
if TYPE_CHECKING or _datasets_available:
from lerobot.datasets.lerobot_dataset import LeRobotDataset
logger = logging.getLogger(__name__)
def _resolve_root(cfg: CameraCurationConfig) -> Path:
"""Concrete, writable root for the dataset (never the symlinked snapshot cache)."""
if cfg.root is not None:
return Path(cfg.root)
if cfg.repo_id is not None:
return HF_LEROBOT_HOME / cfg.repo_id
raise ValueError("Either --repo_id or --root must be provided.")
def _uniform_indices(n: int, k: int) -> list[int]:
if n <= 0 or k <= 0:
return []
if k >= n:
return list(range(n))
step = (n - 1) / (k - 1) if k > 1 else 0.0
return sorted({round(i * step) for i in range(k)})
def _to_uint8_frame(frame: Any) -> Any:
"""Scale a float [0,1] image tensor to uint8; pass uint8/PIL through."""
import torch
if isinstance(frame, torch.Tensor) and torch.is_floating_point(frame):
return (frame.clamp(0, 1) * 255).to(torch.uint8)
return frame
def _sample_frames(dataset: "LeRobotDataset", cfg: CameraCurationConfig) -> dict[str, list[Any]]:
"""Sample ``n_frames`` from the inspected episode for each (non-depth) camera.
Video cameras go through the annotation frame provider (uint8 frames); image
cameras are read straight from the dataset rows and scaled to uint8.
"""
meta = dataset.meta
depth_keys = set(meta.depth_keys)
video_keys = set(meta.video_keys)
image_keys = set(meta.image_keys)
cameras = [k for k in meta.camera_keys if k not in depth_keys]
frames: dict[str, list[Any]] = {k: [] for k in cameras}
video_cameras = [k for k in cameras if k in video_keys]
if video_cameras:
provider = make_frame_provider(dataset.root, video_backend=cfg.video_backend)
records = list(iter_episodes(dataset.root, only_episodes=(cfg.episode_index,)))
record = records[0] if records else None
if record is not None:
for key in video_cameras:
frames[key] = provider.video_for_episode(record, cfg.n_frames, camera_key=key)
image_cameras = [k for k in cameras if k in image_keys]
if image_cameras:
n = len(dataset)
for i in _uniform_indices(n, cfg.n_frames):
item = dataset[i]
for key in image_cameras:
if key in item:
frames[key].append(_to_uint8_frame(item[key]))
return frames
def _apply_rename(
root: Path,
dataset: "LeRobotDataset",
cfg: CameraCurationConfig,
mapping: dict[str, str],
verdicts: list["curator.CameraVerdict"],
) -> None:
"""Apply the computed ``{old: new}`` camera-key mapping.
Video datasets on the Hub download-free server-side rename commit.
Otherwise (image dataset, local-only, or a swap/cycle) local
``rename_features`` over a full copy of the dataset.
"""
video_keys = set(dataset.meta.video_keys)
all_video = set(mapping) <= video_keys
has_swap = bool(set(mapping.values()) & set(mapping))
if cfg.repo_id is not None and all_video and not has_swap:
if cfg.drop_unusable:
logger.warning(
"--drop_unusable is only applied via the local rename path; the Hub rename keeps "
"flagged views (they are still recorded in meta/). Re-run with a local --root to drop."
)
# Edit a throwaway copy of meta/ so the local cached copy stays pristine
# and only the intended files land in the commit.
work = Path(tempfile.mkdtemp(prefix="lerobot_curate_"))
try:
shutil.copytree(root / "meta", work / "meta")
commit = curator.rename_camera_keys_on_hub(
cfg.repo_id,
mapping,
work,
branch=cfg.branch,
commit_message=cfg.push_commit_message,
)
oid = getattr(commit, "oid", None)
ref = cfg.branch or "main"
logger.info("Hub rename committed to %s@%s (%s)", cfg.repo_id, ref, oid)
finally:
shutil.rmtree(work, ignore_errors=True)
return
# Local path: needs the full dataset, so re-load without the episode filter.
require_package("datasets", "dataset")
from lerobot.datasets import LeRobotDataset as _LeRobotDataset
from lerobot.datasets import remove_feature, rename_features
logger.info("Local rename path (image/local/swap): loading the full dataset from %s", root)
full = _LeRobotDataset(cfg.repo_id or "local", root=root)
renamed = rename_features(
full, mapping, output_dir=root, repo_id=full.repo_id, on_collision=cfg.on_collision
)
if cfg.drop_unusable:
unusable_new_keys = [
mapping[v.camera_key] for v in verdicts if not v.usable and v.camera_key in mapping
]
if unusable_new_keys:
logger.info("Dropping unusable views: %s", unusable_new_keys)
renamed = remove_feature(renamed, unusable_new_keys, output_dir=root, repo_id=renamed.repo_id)
if cfg.push_to_hub:
logger.info("Pushing renamed dataset to %s", renamed.repo_id)
renamed.push_to_hub()
@parser.wrap()
def curate_cameras(cfg: CameraCurationConfig) -> None:
"""Run the camera-view curation pipeline over a dataset's first episode."""
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
if cfg.mode not in ("report", "rename"):
raise ValueError(f"--mode must be 'report' or 'rename', got {cfg.mode!r}")
if cfg.job.is_remote:
from lerobot.jobs.curate import submit_curate_to_hf
return submit_curate_to_hf(cfg)
require_package("datasets", "dataset")
from lerobot.datasets.lerobot_dataset import LeRobotDataset
root = _resolve_root(cfg)
logger.info("curate-cameras: repo_id=%s root=%s mode=%s", cfg.repo_id, root, cfg.mode)
# Only episode ``cfg.episode_index`` is fetched (a cheap partial download).
dataset = LeRobotDataset(
cfg.repo_id or "local",
root=root,
episodes=[cfg.episode_index],
download_videos=True,
)
frames = _sample_frames(dataset, cfg)
n_with_frames = sum(1 for v in frames.values() if v)
logger.info("curate-cameras: %d camera(s), %d with sampled frames", len(frames), n_with_frames)
vlm = make_vlm_client(cfg.vlm)
verdicts = curator.curate_cameras(frames, cfg, vlm)
for v in verdicts:
logger.info(
" %s -> label=%s usable=%s%s",
v.camera_key,
v.view_label,
v.usable,
"" if v.usable else f" (blur_reason={v.blur_reason!r})",
)
mapping = curator.build_name_mapping(verdicts, dataset.meta.features, cfg)
report_path = curator.write_report(dataset.root, verdicts, mapping, cfg)
logger.info("curate-cameras: report written to %s", report_path)
logger.info("curate-cameras: proposed rename mapping: %s", mapping or "(none)")
if cfg.mode == "rename":
if not mapping:
logger.info("curate-cameras: nothing to rename (no confident labels differ from current keys)")
return
_apply_rename(dataset.root, dataset, cfg, mapping, verdicts)
def main() -> None:
curate_cameras()
if __name__ == "__main__":
main()
@@ -108,12 +108,6 @@ Remove camera feature:
--operation.type remove_feature \
--operation.feature_names "['observation.image']"
Rename features/camera keys (no pixel data is re-encoded):
lerobot-edit-dataset \
--repo_id lerobot/pusht \
--operation.type rename_features \
--operation.name_mapping '{"observation.images.cam_0": "observation.images.left_wrist"}'
Modify tasks - set a single task for all episodes (WARNING: modifies in-place):
lerobot-edit-dataset \
--repo_id lerobot/pusht \
@@ -263,7 +257,6 @@ from lerobot.datasets import (
recompute_stats,
reencode_dataset,
remove_feature,
rename_features,
split_dataset,
)
from lerobot.utils.constants import HF_LEROBOT_HOME
@@ -305,16 +298,6 @@ class RemoveFeatureConfig(OperationConfig):
feature_names: list[str] | None = None
@OperationConfig.register_subclass("rename_features")
@dataclass
class RenameFeaturesConfig(OperationConfig):
# Mapping of {old_feature_key: new_feature_key}, e.g.
# {"observation.images.cam_0": "observation.images.left_wrist"}.
name_mapping: dict[str, str] | None = None
# "error" raises on colliding targets; "suffix" disambiguates (top -> top_2).
on_collision: str = "error"
@OperationConfig.register_subclass("modify_tasks")
@dataclass
class ModifyTasksConfig(OperationConfig):
@@ -562,42 +545,6 @@ def handle_remove_feature(cfg: EditDatasetConfig) -> None:
LeRobotDataset(output_repo_id, root=output_dir).push_to_hub()
def handle_rename_features(cfg: EditDatasetConfig) -> None:
if not isinstance(cfg.operation, RenameFeaturesConfig):
raise ValueError("Operation config must be RenameFeaturesConfig")
if not cfg.operation.name_mapping:
raise ValueError("name_mapping must be specified for rename_features operation")
dataset = LeRobotDataset(cfg.repo_id, root=cfg.root)
output_repo_id, output_dir = get_output_path(
cfg.repo_id,
new_repo_id=cfg.new_repo_id,
root=cfg.root,
new_root=cfg.new_root,
)
# In case of in-place modification, make the dataset point to the backup directory
if output_dir == dataset.root:
dataset.root = dataset.root.with_name(dataset.root.name + "_old")
logging.info(f"Renaming features {cfg.operation.name_mapping} in {cfg.repo_id}")
new_dataset = rename_features(
dataset,
name_mapping=cfg.operation.name_mapping,
output_dir=output_dir,
repo_id=output_repo_id,
on_collision=cfg.operation.on_collision,
)
logging.info(f"Dataset saved to {output_dir}")
logging.info(f"Features: {list(new_dataset.meta.features.keys())}")
if cfg.push_to_hub:
logging.info(f"Pushing to hub as {output_repo_id}")
LeRobotDataset(output_repo_id, root=output_dir).push_to_hub()
def handle_modify_tasks(cfg: EditDatasetConfig) -> None:
if not isinstance(cfg.operation, ModifyTasksConfig):
raise ValueError("Operation config must be ModifyTasksConfig")
@@ -883,8 +830,6 @@ def edit_dataset(cfg: EditDatasetConfig) -> None:
handle_merge(cfg)
elif operation_type == "remove_feature":
handle_remove_feature(cfg)
elif operation_type == "rename_features":
handle_rename_features(cfg)
elif operation_type == "modify_tasks":
handle_modify_tasks(cfg)
elif operation_type == "convert_image_to_video":
+1 -1
View File
@@ -564,7 +564,7 @@ def eval_policy(
if seeds:
all_seeds.extend(seeds)
else:
all_seeds.extend([None] * env.num_envs)
all_seeds.append(None)
# FIXME: episode_data is either None or it doesn't exist
if return_episode_data:
+49 -38
View File
@@ -28,6 +28,7 @@ lerobot-find-cameras
# NOTE(Steven): macOS cameras sometimes report different FPS at init time, not an issue here as we don't specify FPS when opening the cameras, but the information displayed might not be truthful.
import argparse
import concurrent.futures
import logging
import time
from pathlib import Path
@@ -132,7 +133,7 @@ def save_image(
camera_identifier: str | int,
images_dir: Path,
camera_type: str,
) -> None:
):
"""
Saves a single image to disk using Pillow. Handles color conversion if necessary.
"""
@@ -151,7 +152,7 @@ def save_image(
logger.error(f"Failed to save image for camera {camera_identifier} (type {camera_type}): {e}")
def create_camera_instance(cam_meta: dict[str, Any], *, warmup_s: int = 1) -> dict[str, Any] | None:
def create_camera_instance(cam_meta: dict[str, Any]) -> dict[str, Any] | None:
"""Create and connect to a camera instance based on metadata."""
cam_type = cam_meta.get("type")
cam_id = cam_meta.get("id")
@@ -164,14 +165,12 @@ def create_camera_instance(cam_meta: dict[str, Any], *, warmup_s: int = 1) -> di
cv_config = OpenCVCameraConfig(
index_or_path=cam_id,
color_mode=ColorMode.RGB,
warmup_s=warmup_s,
)
instance = OpenCVCamera(cv_config)
elif cam_type == "RealSense":
rs_config = RealSenseCameraConfig(
serial_number_or_name=cam_id,
color_mode=ColorMode.RGB,
warmup_s=warmup_s,
)
instance = RealSenseCamera(rs_config)
else:
@@ -189,7 +188,9 @@ def create_camera_instance(cam_meta: dict[str, Any], *, warmup_s: int = 1) -> di
return None
def process_camera_image(cam_dict: dict[str, Any], output_dir: Path, current_time: float) -> None:
def process_camera_image(
cam_dict: dict[str, Any], output_dir: Path, current_time: float
) -> concurrent.futures.Future | None:
"""Capture and process an image from a single camera."""
cam = cam_dict["instance"]
meta = cam_dict["meta"]
@@ -199,7 +200,7 @@ def process_camera_image(cam_dict: dict[str, Any], output_dir: Path, current_tim
try:
image_data = cam.read()
save_image(
return save_image(
image_data,
cam_id_str,
output_dir,
@@ -214,21 +215,21 @@ def process_camera_image(cam_dict: dict[str, Any], output_dir: Path, current_tim
return None
def cleanup_camera(cam_dict: dict[str, Any]) -> None:
def cleanup_cameras(cameras_to_use: list[dict[str, Any]]):
"""Disconnect all cameras."""
logger.info(f"Disconnecting camera with ID {cam_dict['meta'].get('id')}...")
try:
if cam_dict["instance"] and cam_dict["instance"].is_connected:
cam_dict["instance"].disconnect()
except Exception as e:
logger.error(f"Error disconnecting camera {cam_dict['meta'].get('id')}: {e}")
logger.info(f"Disconnecting {len(cameras_to_use)} cameras...")
for cam_dict in cameras_to_use:
try:
if cam_dict["instance"] and cam_dict["instance"].is_connected:
cam_dict["instance"].disconnect()
except Exception as e:
logger.error(f"Error disconnecting camera {cam_dict['meta'].get('id')}: {e}")
def save_images_from_all_cameras(
output_dir: Path,
record_time_s: float = 2.0,
camera_type: str | None = None,
warmup_s: int = 1,
):
"""
Connects to detected cameras (optionally filtered by type) and saves images from each.
@@ -239,7 +240,6 @@ def save_images_from_all_cameras(
record_time_s: Duration in seconds to record images.
camera_type: Optional string to filter cameras ("realsense" or "opencv").
If None, uses all detected cameras.
warmup_s: Duration in seconds to warmup camera before recording images.
"""
output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Saving images to {output_dir}")
@@ -249,24 +249,40 @@ def save_images_from_all_cameras(
logger.warning("No cameras detected matching the criteria. Cannot save images.")
return
logger.info(
f"Starting image capture for {record_time_s} seconds from {len(all_camera_metadata)} cameras."
)
cameras_to_use = []
for cam_meta in all_camera_metadata:
camera_instance = create_camera_instance(cam_meta)
if camera_instance:
cameras_to_use.append(camera_instance)
try:
for cam_meta in all_camera_metadata:
cam_dict = create_camera_instance(cam_meta, warmup_s=warmup_s)
if cam_dict is None:
continue
start_time = time.perf_counter()
if not cameras_to_use:
logger.warning("No cameras could be connected. Aborting image save.")
return
logger.info(f"Starting image capture for {record_time_s} seconds from {len(cameras_to_use)} cameras.")
start_time = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=len(cameras_to_use) * 2) as executor:
try:
while time.perf_counter() - start_time < record_time_s:
futures = []
current_capture_time = time.perf_counter()
process_camera_image(cam_dict, output_dir, current_capture_time)
cleanup_camera(cam_dict)
except KeyboardInterrupt:
logger.info("Capture interrupted by user.")
finally:
print(f"Image capture finished. Images saved to {output_dir}")
for cam_dict in cameras_to_use:
future = process_camera_image(cam_dict, output_dir, current_capture_time)
if future:
futures.append(future)
if futures:
concurrent.futures.wait(futures)
except KeyboardInterrupt:
logger.info("Capture interrupted by user.")
finally:
print("\nFinalizing image saving...")
executor.shutdown(wait=True)
cleanup_cameras(cameras_to_use)
print(f"Image capture finished. Images saved to {output_dir}")
def main():
@@ -275,6 +291,7 @@ def main():
parser = argparse.ArgumentParser(
description="Unified camera utility script for listing cameras and capturing images."
)
parser.add_argument(
"camera_type",
type=str,
@@ -292,14 +309,8 @@ def main():
parser.add_argument(
"--record-time-s",
type=float,
default=2.0,
help="Time duration to attempt capturing frames. Default: 2 seconds.",
)
parser.add_argument(
"--warmup-s",
type=int,
default=1,
help="Time duration to warmup camera before attempting to capture frames. Default: 1 second.",
default=6.0,
help="Time duration to attempt capturing frames. Default: 6 seconds.",
)
args = parser.parse_args()
save_images_from_all_cameras(**vars(args))
+13 -17
View File
@@ -22,8 +22,7 @@ import dataclasses
import logging
import sys
import time
from collections.abc import Iterator
from contextlib import contextmanager, nullcontext
from contextlib import nullcontext
from pprint import pformat
from typing import TYPE_CHECKING, Any
@@ -77,20 +76,6 @@ else:
from .lerobot_eval import eval_policy_all
@contextmanager
def _make_eval_envs(cfg: TrainPipelineConfig) -> Iterator[dict[str, dict[int, Any]]]:
"""Create evaluation environments for one run and always dispose of them."""
envs = make_env(
cfg.env,
n_envs=cfg.eval.batch_size,
use_async_envs=cfg.eval.use_async_envs,
)
try:
yield envs
finally:
close_envs(envs)
def _dataloader_worker_kwargs(cfg: TrainPipelineConfig) -> dict[str, Any]:
"""Return worker-only DataLoader options, disabling them for single-process loading."""
workers_enabled = cfg.num_workers > 0
@@ -295,6 +280,14 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
if not is_main_process:
dataset, eval_dataset = make_train_eval_datasets(cfg)
# Create environment used for evaluating checkpoints during training on simulation data.
# On real-world data, no need to create an environment as evaluations are done outside train.py,
# using the eval.py instead, with gym_dora environment and dora-rs.
eval_env = None
if cfg.env_eval_freq > 0 and cfg.env is not None and is_main_process:
logging.info("Creating env")
eval_env = make_env(cfg.env, n_envs=cfg.eval.batch_size, use_async_envs=cfg.eval.use_async_envs)
if cfg.is_reward_model_training:
if is_main_process:
logging.info("Creating reward model")
@@ -702,7 +695,7 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
if is_main_process:
step_id = get_step_identifier(step, cfg.steps)
logging.info(f"Eval policy at step {step}")
with _make_eval_envs(cfg) as eval_env, torch.no_grad(), accelerator.autocast():
with torch.no_grad(), accelerator.autocast():
eval_info = eval_policy_all(
envs=eval_env, # dict[suite][task_id] -> vec_env
policy=accelerator.unwrap_model(policy),
@@ -750,6 +743,9 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
if is_main_process:
progbar.close()
if eval_env:
close_envs(eval_env)
is_fsdp = accelerator.distributed_type == DistributedType.FSDP
model_state_dict = accelerator.get_state_dict(policy) if is_fsdp else None
if is_main_process:
@@ -171,13 +171,7 @@ class IOSPhone(BasePhone, Teleoperator):
# HEBI provides orientation in w, x, y, z format.
# Scipy's Rotation expects x, y, z, w.
quat_xyzw = np.concatenate((ar_quat[1:], [ar_quat[0]])) # wxyz to xyzw
# ARKit can emit zero/NaN quaternions before tracking is ready or on a
# dropped packet. Rotation.from_quat now rejects those; degrade the same
# way as a missing pose so teleop stays alive mid-session.
try:
rot = Rotation.from_quat(quat_xyzw)
except ValueError:
return False, None, None, None
rot = Rotation.from_quat(quat_xyzw)
pos = ar_pos - rot.apply(self.config.camera_offset)
return True, pos, rot, pose
@@ -29,12 +29,6 @@ class SOLeaderConfig:
# Whether to use degrees for angles
use_degrees: bool = True
# Number of extra attempts when a `sync_read` of the motors fails. Feetech buses can occasionally
# return a corrupted status packet ("Incorrect status packet!"), especially when several joints move
# at once, which otherwise aborts the teleoperation loop. Retries are immediate (no sleep) and only
# happen on failure, so the steady-state read cost is unchanged.
num_read_retries: int = 2
@TeleoperatorConfig.register_subclass("so101_leader")
@TeleoperatorConfig.register_subclass("so100_leader")
@@ -145,7 +145,7 @@ class SOLeader(Teleoperator):
@check_if_not_connected
def get_action(self) -> dict[str, float]:
start = time.perf_counter()
action = self.bus.sync_read("Present_Position", num_retry=self.config.num_read_retries)
action = self.bus.sync_read("Present_Position")
action = {f"{motor}.pos": val for motor, val in action.items()}
dt_ms = (time.perf_counter() - start) * 1e3
logger.debug(f"{self} read action: {dt_ms:.1f}ms")
+4 -13
View File
@@ -37,25 +37,16 @@ def auto_select_torch_device() -> torch.device:
# TODO(Steven): Remove log. log shouldn't be an argument, this should be handled by the logger level
def get_safe_torch_device(try_device: str, log: bool = False) -> torch.device:
"""Given a string, return a torch.device with checks on whether the device is available.
Raises:
ValueError: If the requested device family is known but not available on
this machine (``AssertionError`` was previously used and is easy to
mistake for a programmer bug under ``python -O`` where asserts vanish).
"""
"""Given a string, return a torch.device with checks on whether the device is available."""
try_device = str(try_device)
if try_device.startswith("cuda"):
if not torch.cuda.is_available():
raise ValueError(f"Requested device {try_device!r} but CUDA is not available.")
assert torch.cuda.is_available()
device = torch.device(try_device)
elif try_device == "mps":
if not torch.backends.mps.is_available():
raise ValueError("Requested device 'mps' but MPS is not available.")
assert torch.backends.mps.is_available()
device = torch.device("mps")
elif try_device == "xpu":
if not torch.xpu.is_available():
raise ValueError("Requested device 'xpu' but XPU is not available.")
assert torch.xpu.is_available()
device = torch.device("xpu")
elif try_device == "cpu":
device = torch.device("cpu")
+5 -5
View File
@@ -32,21 +32,21 @@ def load_json(fpath: Path) -> Any:
Returns:
Any: The data loaded from the JSON file.
"""
with open(fpath, encoding="utf-8") as f:
with open(fpath) as f:
return json.load(f)
def write_json(data: JsonLike, fpath: Path) -> None:
"""Write JSON-serializable data to a file.
def write_json(data: dict, fpath: Path) -> None:
"""Write data to a JSON file.
Creates parent directories if they don't exist.
Args:
data: JSON-serializable data to write.
data (dict): The dictionary to write.
fpath (Path): The path to the output JSON file.
"""
fpath.parent.mkdir(exist_ok=True, parents=True)
with open(fpath, "w", encoding="utf-8") as f:
with open(fpath, "w") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
-4
View File
@@ -30,10 +30,6 @@ def precise_sleep(seconds: float, spin_threshold: float = 0.010, sleep_margin: f
"""
if seconds <= 0:
return
if spin_threshold < 0:
raise ValueError(f"spin_threshold must be >= 0, got {spin_threshold}")
if sleep_margin < 0:
raise ValueError(f"sleep_margin must be >= 0, got {sleep_margin}")
system = platform.system()
# On macOS and Windows the scheduler / sleep granularity can make
+3 -6
View File
@@ -29,13 +29,10 @@ class Rotation:
def __init__(self, quat: np.ndarray) -> None:
"""Initialize rotation from quaternion [x, y, z, w]."""
self._quat = np.asarray(quat, dtype=float)
if self._quat.shape != (4,):
raise ValueError(f"Quaternion must have shape (4,), got {self._quat.shape}")
# Normalize quaternion. Reject the zero vector — it has no orientation.
# Normalize quaternion
norm = np.linalg.norm(self._quat)
if norm <= 0.0 or not np.isfinite(norm):
raise ValueError(f"Quaternion must be a non-zero finite vector; got {self._quat} (norm={norm})")
self._quat = self._quat / norm
if norm > 0:
self._quat = self._quat / norm
@classmethod
def from_rotvec(cls, rotvec: np.ndarray) -> "Rotation":
+2 -2
View File
@@ -14,7 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import NotRequired, TypedDict
from typing import TypedDict
import torch
@@ -28,7 +28,7 @@ class Transition(TypedDict):
next_state: dict[str, torch.Tensor]
done: bool
truncated: bool
complementary_info: NotRequired[dict[str, torch.Tensor | float | int] | None]
complementary_info: dict[str, torch.Tensor | float | int] | None = None
def move_transition_to_device(transition: Transition, device: str = "cpu") -> Transition:
+12 -16
View File
@@ -24,6 +24,7 @@ import sys
import time
from collections.abc import Iterator
from copy import copy, deepcopy
from datetime import datetime
from pathlib import Path
from statistics import mean
from typing import TYPE_CHECKING, Any
@@ -60,16 +61,14 @@ def init_logging(
accelerator: Optional Accelerator instance (for multi-GPU detection)
"""
class LeRobotFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
record.lerobot_location = f"{record.pathname}:{record.lineno}"[-15:]
record.lerobot_pid = f"[PID: {os.getpid()}] " if display_pid else ""
return super().format(record)
def custom_format(record: logging.LogRecord) -> str:
dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
fnameline = f"{record.pathname}:{record.lineno}"
pid_str = f"[PID: {os.getpid()}] " if display_pid else ""
return f"{record.levelname} {pid_str}{dt} {fnameline[-15:]:>15} {record.getMessage()}"
formatter = LeRobotFormatter(
"%(levelname)s %(lerobot_pid)s%(asctime)s %(lerobot_location)15s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
formatter = logging.Formatter()
formatter.format = custom_format
logger = logging.getLogger()
logger.setLevel(logging.NOTSET)
@@ -134,13 +133,10 @@ def say(text: str, blocking: bool = False):
else:
raise RuntimeError("Unsupported operating system for text-to-speech.")
try:
if blocking:
subprocess.run(cmd, check=True, timeout=5)
else:
subprocess.Popen(cmd, creationflags=subprocess.CREATE_NO_WINDOW if system == "Windows" else 0)
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
logging.warning("Text-to-speech command failed: %s | Error: %s", cmd, e)
if blocking:
subprocess.run(cmd, check=True)
else:
subprocess.Popen(cmd, creationflags=subprocess.CREATE_NO_WINDOW if system == "Windows" else 0)
def log_say(text: str, play_sounds: bool = True, blocking: bool = False):
-226
View File
@@ -1,226 +0,0 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for the camera-view curation pipeline (stubbed VLM, mocked Hub)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import PIL.Image
import pytest
pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])")
pytest.importorskip("pandas", reason="pandas is required (install lerobot[dataset])")
import pandas as pd # noqa: E402
from lerobot.annotations.camera_curation.config import CameraCurationConfig # noqa: E402
from lerobot.annotations.camera_curation.curator import ( # noqa: E402
CameraVerdict,
build_name_mapping,
curate_cameras,
is_valid_view_label,
rename_camera_keys_on_hub,
write_report,
)
from lerobot.annotations.steerable_pipeline.vlm_client import StubVlmClient # noqa: E402
from lerobot.datasets.io_utils import load_info, write_info # noqa: E402
from lerobot.datasets.utils import DatasetInfo # noqa: E402
from lerobot.utils.io_utils import load_json, write_json # noqa: E402
VOCAB = ("top", "wrist", "front", "bottom", "left", "right")
def _queued_vlm(responses: list) -> StubVlmClient:
"""Stub VLM that returns queued responses in batch order."""
state = {"i": 0}
def responder(_messages):
r = responses[state["i"]]
state["i"] += 1
return r
return StubVlmClient(responder=responder)
def _tiny_image() -> PIL.Image.Image:
return PIL.Image.new("RGB", (16, 12))
def _make_min_meta(root: Path, camera_key: str, dtype: str = "video") -> None:
"""Write a minimal ``meta/`` tree with one camera + one action feature."""
(root / "meta" / "episodes" / "chunk-000").mkdir(parents=True, exist_ok=True)
features = {
camera_key: {
"dtype": dtype,
"shape": (64, 96, 3),
"names": ["height", "width", "channels"],
"info": {"video.fps": 10.0} if dtype == "video" else None,
},
"action": {"dtype": "float32", "shape": (2,), "names": None},
}
write_info(DatasetInfo(codebase_version="v3.0", fps=10, features=features), root)
write_json({camera_key: {"mean": [0.0]}, "action": {"mean": [0.0]}}, root / "meta" / "stats.json")
df = pd.DataFrame(
{
"episode_index": [0],
f"videos/{camera_key}/from_timestamp": [0.0],
f"videos/{camera_key}/to_timestamp": [1.0],
f"videos/{camera_key}/chunk_index": [0],
f"videos/{camera_key}/file_index": [0],
f"stats/{camera_key}/mean": [[0.0]],
}
)
df.to_parquet(root / "meta" / "episodes" / "chunk-000" / "file-000.parquet")
# ------------------------------ pure logic ------------------------------
def test_is_valid_view_label():
assert is_valid_view_label("top", VOCAB, allow_combos=True)
assert is_valid_view_label("left_wrist", VOCAB, allow_combos=True)
assert not is_valid_view_label("left_wrist", VOCAB, allow_combos=False)
assert not is_valid_view_label("banana", VOCAB, allow_combos=True)
assert not is_valid_view_label("left_left", VOCAB, allow_combos=True) # duplicate token
assert not is_valid_view_label("top_wrist_front", VOCAB, allow_combos=True) # 3 tokens
assert not is_valid_view_label("", VOCAB, allow_combos=True)
def test_curate_cameras_parses_and_validates(tmp_path):
cfg = CameraCurationConfig(view_vocabulary=VOCAB)
frames = {
"observation.images.a": [_tiny_image()],
"observation.images.b": [_tiny_image()],
"observation.images.c": [], # no frames -> reported, not sent to the VLM
}
vlm = _queued_vlm(
[
{"usable": True, "blur_reason": None, "view_label": "Left Wrist", "confidence": 0.9},
{"usable": False, "blur_reason": "out of focus", "view_label": "banana", "confidence": 0.2},
]
)
verdicts = {v.camera_key: v for v in curate_cameras(frames, cfg, vlm)}
assert verdicts["observation.images.a"].view_label == "left_wrist" # normalized
assert verdicts["observation.images.a"].usable is True
assert verdicts["observation.images.b"].usable is False
assert verdicts["observation.images.b"].blur_reason == "out of focus"
assert verdicts["observation.images.b"].view_label is None # invalid label dropped
assert verdicts["observation.images.c"].view_label is None # no frames
def test_build_name_mapping_and_collision():
cfg = CameraCurationConfig(view_vocabulary=VOCAB)
existing = {"observation.images.cam_0": {}, "observation.images.cam_1": {}, "observation.images.top": {}}
verdicts = [
CameraVerdict("observation.images.cam_0", usable=True, view_label="left_wrist"),
CameraVerdict("observation.images.cam_1", usable=True, view_label="front"),
# already canonical -> skipped by build_name_mapping
CameraVerdict("observation.images.top", usable=True, view_label="top"),
]
mapping = build_name_mapping(verdicts, existing, cfg)
assert mapping == {
"observation.images.cam_0": "observation.images.left_wrist",
"observation.images.cam_1": "observation.images.front",
}
# proposed_new_key stamped back onto the verdicts
assert verdicts[0].proposed_new_key == "observation.images.left_wrist"
# two cameras wanting the same label collide under the default policy
clash = [
CameraVerdict("observation.images.cam_0", usable=True, view_label="top"),
CameraVerdict("observation.images.cam_1", usable=True, view_label="top"),
]
with pytest.raises(ValueError, match="collision"):
build_name_mapping(clash, {"observation.images.cam_0": {}, "observation.images.cam_1": {}}, cfg)
def test_write_report(tmp_path):
_make_min_meta(tmp_path, "observation.images.cam_0", dtype="video")
cfg = CameraCurationConfig(repo_id="user/ds", view_vocabulary=VOCAB)
verdicts = [
CameraVerdict("observation.images.cam_0", usable=True, view_label="left_wrist", confidence=0.9)
]
mapping = {"observation.images.cam_0": "observation.images.left_wrist"}
report_path = write_report(tmp_path, verdicts, mapping, cfg)
report = load_json(report_path)
cam = report["cameras"]["observation.images.cam_0"]
assert cam["view_label"] == "left_wrist"
assert cam["proposed_new_key"] == "observation.images.left_wrist"
# verdict stamped into info.json so it travels with the dataset
info = load_info(tmp_path)
assert info.features["observation.images.cam_0"]["info"]["curation"]["view_label"] == "left_wrist"
# ------------------------- lightweight Hub rename -------------------------
def test_rename_camera_keys_on_hub_builds_ops(tmp_path):
from huggingface_hub import CommitOperationAdd, CommitOperationCopy, CommitOperationDelete
camera_key = "observation.images.cam_0"
new_key = "observation.images.left_wrist"
_make_min_meta(tmp_path, camera_key, dtype="video")
old_mp4 = f"videos/{camera_key}/chunk-000/file-000.mp4"
fake_api = MagicMock()
fake_api.list_repo_files.return_value = [old_mp4, "meta/info.json", "data/chunk-000/file-000.parquet"]
fake_api.create_commit.return_value = MagicMock(oid="deadbeef")
with patch("huggingface_hub.HfApi", return_value=fake_api):
rename_camera_keys_on_hub("user/ds", {camera_key: new_key}, tmp_path, branch="curated")
kwargs = fake_api.create_commit.call_args.kwargs
ops = kwargs["operations"]
copies = [o for o in ops if isinstance(o, CommitOperationCopy)]
deletes = [o for o in ops if isinstance(o, CommitOperationDelete)]
adds = [o for o in ops if isinstance(o, CommitOperationAdd)]
new_mp4 = f"videos/{new_key}/chunk-000/file-000.mp4"
assert any(o.src_path_in_repo == old_mp4 and o.path_in_repo == new_mp4 for o in copies)
assert any(o.path_in_repo == old_mp4 for o in deletes)
assert any(o.path_in_repo == "meta/info.json" for o in adds)
assert kwargs["revision"] == "curated"
# meta on disk was actually remapped
info = load_info(tmp_path)
assert new_key in info.features and camera_key not in info.features
def test_rename_camera_keys_on_hub_rejects_image_keys(tmp_path):
camera_key = "observation.images.cam_0"
_make_min_meta(tmp_path, camera_key, dtype="image")
with patch("huggingface_hub.HfApi", return_value=MagicMock()):
with pytest.raises(NotImplementedError, match="image data"):
rename_camera_keys_on_hub("user/ds", {camera_key: "observation.images.top"}, tmp_path)
def test_rename_camera_keys_on_hub_rejects_swaps(tmp_path):
_make_min_meta(tmp_path, "observation.images.a", dtype="video")
with patch("huggingface_hub.HfApi", return_value=MagicMock()):
with pytest.raises(NotImplementedError, match="swap"):
rename_camera_keys_on_hub(
"user/ds",
{
"observation.images.a": "observation.images.b",
"observation.images.b": "observation.images.a",
},
tmp_path,
)
@@ -1,104 +0,0 @@
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import numpy as np
import pytest
pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])")
from lerobot.scripts.augment_dataset_quantile_stats import (
compute_quantile_stats_for_dataset,
has_quantile_stats,
)
def _numeric_keys(dataset):
return [k for k, v in dataset.features.items() if v["dtype"] not in ("image", "video", "string")]
def _image_keys(dataset):
return [k for k, v in dataset.features.items() if v["dtype"] in ("image", "video")]
def test_numeric_stats_are_unaffected_by_sampling(tmp_path, lerobot_dataset_factory):
"""Sampling only touches image/video frames; numeric features are read in
full either way, so their stats must be identical with and without sampling."""
dataset = lerobot_dataset_factory(
root=tmp_path / "ds", total_episodes=2, total_frames=400, use_videos=False
)
exact = compute_quantile_stats_for_dataset(dataset, use_sampling=False)
sampled = compute_quantile_stats_for_dataset(dataset, use_sampling=True)
numeric_keys = _numeric_keys(dataset)
assert numeric_keys, "fixture should expose numeric features"
for key in numeric_keys:
if key not in exact:
continue
for stat in ("mean", "std", "q01", "q50", "q99"):
if stat in exact[key]:
np.testing.assert_allclose(
sampled[key][stat],
exact[key][stat],
rtol=1e-6,
atol=1e-6,
err_msg=f"numeric feature '{key}' stat '{stat}' changed under sampling",
)
def test_image_sampling_reduces_data_but_keeps_stats_close(tmp_path, lerobot_dataset_factory):
"""For images, sampling should reduce the number of samples considered while
keeping the resulting statistics close to the exact ones."""
dataset = lerobot_dataset_factory(
root=tmp_path / "ds", total_episodes=2, total_frames=400, use_videos=False
)
exact = compute_quantile_stats_for_dataset(dataset, use_sampling=False)
sampled = compute_quantile_stats_for_dataset(dataset, use_sampling=True)
image_keys = _image_keys(dataset)
assert image_keys, "fixture should expose at least one image feature"
for key in image_keys:
# sampling actually looked at fewer pixels
assert sampled[key]["count"][0] < exact[key]["count"][0]
# but per-channel mean stays close
np.testing.assert_allclose(
sampled[key]["mean"],
exact[key]["mean"],
rtol=0.15,
err_msg=f"image feature '{key}' mean drifted too far under sampling",
)
def test_short_episodes_use_all_frames(tmp_path, lerobot_dataset_factory):
"""With episodes shorter than the sampling floor, sampling is a no-op and
must produce exactly the same stats as the exact path."""
dataset = lerobot_dataset_factory(
root=tmp_path / "ds", total_episodes=2, total_frames=40, use_videos=False
)
exact = compute_quantile_stats_for_dataset(dataset, use_sampling=False)
sampled = compute_quantile_stats_for_dataset(dataset, use_sampling=True)
for key in _image_keys(dataset):
assert sampled[key]["count"][0] == exact[key]["count"][0]
def test_quantile_stats_present_after_compute(tmp_path, lerobot_dataset_factory):
"""The computed stats should contain quantile keys for the dataset."""
dataset = lerobot_dataset_factory(
root=tmp_path / "ds", total_episodes=2, total_frames=200, use_videos=False
)
stats = compute_quantile_stats_for_dataset(dataset, use_sampling=True)
assert has_quantile_stats(stats)
+1 -163
View File
@@ -23,7 +23,6 @@ import torch
pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])")
import pandas as pd # noqa: E402
from lerobot.configs import DepthEncoderConfig, RGBEncoderConfig
from lerobot.datasets.dataset_tools import (
@@ -35,11 +34,9 @@ from lerobot.datasets.dataset_tools import (
modify_tasks,
reencode_dataset,
remove_feature,
rename_features,
split_dataset,
)
from lerobot.datasets.dataset_tools import _resolve_rename_collisions
from lerobot.datasets.io_utils import load_info, load_stats
from lerobot.datasets.io_utils import load_info
from tests.datasets.test_video_encoding import require_h264, require_hevc, require_libsvtav1
from tests.fixtures.constants import DUMMY_DEPTH_FEATURES, DUMMY_DEPTH_KEY
from tests.fixtures.dataset_factories import add_frames
@@ -1495,162 +1492,3 @@ def test_reencode_dataset_multi_key_multiprocessing(
for vk in dataset.meta.video_keys:
persisted_encoder = RGBEncoderConfig.from_video_info(persisted_info.features[vk].get("info", {}))
assert persisted_encoder == target_cfg
# ----------------------------- rename_features -----------------------------
def _mock_hub(tmp_path):
"""Context managers that stop dataset reload from hitting the Hub."""
return (
patch("lerobot.datasets.dataset_metadata.get_safe_version", return_value="v3.0"),
patch(
"lerobot.datasets.dataset_metadata.snapshot_download",
side_effect=lambda repo_id, **kwargs: str(kwargs.get("local_dir", tmp_path)),
),
)
@pytest.fixture
def two_camera_image_dataset(tmp_path, empty_lerobot_dataset_factory):
"""An image dataset with two camera views (for collision tests)."""
features = {
"action": {"dtype": "float32", "shape": (6,), "names": None},
"observation.images.cam_0": {"dtype": "image", "shape": (32, 32, 3), "names": None},
"observation.images.cam_1": {"dtype": "image", "shape": (32, 32, 3), "names": None},
}
dataset = empty_lerobot_dataset_factory(root=tmp_path / "two_cam", features=features)
for _ in range(2):
for _ in range(4):
dataset.add_frame(
{
"action": np.random.randn(6).astype(np.float32),
"observation.images.cam_0": np.random.randint(0, 255, (32, 32, 3), dtype=np.uint8),
"observation.images.cam_1": np.random.randint(0, 255, (32, 32, 3), dtype=np.uint8),
"task": "t",
}
)
dataset.save_episode()
dataset.finalize()
return dataset
def test_resolve_rename_collisions_error_and_suffix():
features = {"a": {}, "b": {}, "c": {}}
# many-to-one
with pytest.raises(ValueError, match="same target"):
_resolve_rename_collisions({"a": "top", "b": "top"}, features, "error")
# target collides with an untouched key
with pytest.raises(ValueError, match="existing feature"):
_resolve_rename_collisions({"a": "c"}, features, "error")
# suffix disambiguates deterministically
resolved = _resolve_rename_collisions({"a": "top", "b": "top"}, features, "suffix")
assert set(resolved.values()) == {"top", "top_2"}
assert resolved["a"] == "top" # sorted-source order keeps the first
def test_rename_image_feature(sample_dataset, tmp_path):
old, new = "observation.images.top", "observation.images.wrist"
m1, m2 = _mock_hub(tmp_path)
with m1, m2:
renamed = rename_features(sample_dataset, {old: new}, output_dir=tmp_path / "renamed")
assert new in renamed.meta.features
assert old not in renamed.meta.features
assert renamed.meta.features[new]["dtype"] == "image"
# the frame still decodes under the new key
item = renamed[0]
assert new in item and old not in item
# stats moved to the new key
stats = load_stats(renamed.root)
assert new in stats and old not in stats
@require_h264
def test_rename_video_feature_no_reencode(tmp_path, empty_lerobot_dataset_factory, features_factory):
features = features_factory(use_videos=True) # observation.images.{laptop,phone}
dataset = empty_lerobot_dataset_factory(root=tmp_path / "vid", features=features, use_videos=True)
add_frames(dataset, num_frames=4)
dataset.save_episode()
dataset.finalize()
old, new = "laptop", "observation.images.top" # features_factory uses bare camera keys
old_bytes = (dataset.root / dataset.meta.get_video_file_path(0, old)).read_bytes()
m1, m2 = _mock_hub(tmp_path)
with m1, m2:
renamed = rename_features(dataset, {old: new}, output_dir=tmp_path / "renamed")
assert new in renamed.meta.features and old not in renamed.meta.features
new_mp4 = renamed.root / renamed.meta.get_video_file_path(0, new)
assert new_mp4.exists()
# a rename must not re-encode: the mp4 is byte-identical.
assert new_mp4.read_bytes() == old_bytes
# episodes metadata columns were remapped.
ep_parquet = next((renamed.root / "meta" / "episodes").glob("*/*.parquet"))
cols = pd.read_parquet(ep_parquet).columns
assert f"videos/{new}/from_timestamp" in cols
assert f"videos/{old}/from_timestamp" not in cols
# the video still decodes under the new key.
assert new in renamed[0]
def test_rename_collision_raises(two_camera_image_dataset, tmp_path):
m1, m2 = _mock_hub(tmp_path)
with m1, m2, pytest.raises(ValueError, match="collision"):
rename_features(
two_camera_image_dataset,
{
"observation.images.cam_0": "observation.images.top",
"observation.images.cam_1": "observation.images.top",
},
output_dir=tmp_path / "out",
)
def test_rename_collision_suffix(two_camera_image_dataset, tmp_path):
m1, m2 = _mock_hub(tmp_path)
with m1, m2:
renamed = rename_features(
two_camera_image_dataset,
{
"observation.images.cam_0": "observation.images.top",
"observation.images.cam_1": "observation.images.top",
},
output_dir=tmp_path / "out",
on_collision="suffix",
)
keys = set(renamed.meta.features)
assert {"observation.images.top", "observation.images.top_2"} <= keys
def test_rename_identity_only_raises(sample_dataset, tmp_path):
with pytest.raises(ValueError, match="identity"):
rename_features(
sample_dataset,
{"observation.images.top": "observation.images.top"},
output_dir=tmp_path / "out",
)
def test_rename_missing_key_raises(sample_dataset, tmp_path):
with pytest.raises(ValueError, match="not found"):
rename_features(
sample_dataset,
{"observation.images.nope": "observation.images.top"},
output_dir=tmp_path / "out",
)
def test_rename_required_feature_raises(sample_dataset, tmp_path):
with pytest.raises(ValueError, match="required"):
rename_features(sample_dataset, {"timestamp": "t2"}, output_dir=tmp_path / "out")
def test_rename_slash_in_target_raises(sample_dataset, tmp_path):
with pytest.raises(ValueError, match="'/'"):
rename_features(
sample_dataset,
{"observation.images.top": "observation/images/top"},
output_dir=tmp_path / "out",
)
-13
View File
@@ -294,19 +294,6 @@ def test__sync_read(addr, length, ids_values, mock_motors, dummy_motors):
assert read_values == ids_values
def test__sync_read_retries_after_transient_failure(mock_motors, dummy_motors):
addr, length, ids_values = (10, 4, {1: 1337})
stub = mock_motors.build_sync_read_stub(addr, length, ids_values, num_invalid_try=1)
bus = FeetechMotorsBus(port=mock_motors.port, motors=dummy_motors)
bus.connect(handshake=False)
read_values, read_comm = bus._sync_read(addr, length, list(ids_values), num_retry=1)
assert read_comm == scs.COMM_SUCCESS
assert read_values == ids_values
assert mock_motors.stubs[stub].calls == 2
@pytest.mark.parametrize("raise_on_error", (True, False))
def test__sync_read_comm(raise_on_error, mock_motors, dummy_motors):
addr, length, ids_values = (10, 4, {1: 1337})
-54
View File
@@ -496,60 +496,6 @@ def test_evo1_processor_save_load_round_trip_applies_config_overrides(tmp_path):
assert "embodiment_id" in processed
def test_reconcile_evo1_processors_repads_overridden_stats(tmp_path):
"""Loading a checkpoint and injecting raw (unpadded) dataset stats must be re-padded.
Regression test: lerobot-train passes the raw dataset stats as normalizer/unnormalizer
overrides when resuming from a checkpoint (e.g. stage2 from a stage1 checkpoint). Those stats
are at the dataset dims (e.g. LIBERO state=8/action=7), but EVO1 pads state/action to
max_state_dim/max_action_dim before normalization, so reconcile_evo1_processors must re-pad the
stats or normalization crashes with a shape mismatch.
"""
config = make_config()
preprocessor, postprocessor = make_evo1_pre_post_processors(config, dataset_stats=make_stats())
preprocessor.save_pretrained(tmp_path)
postprocessor.save_pretrained(tmp_path)
# Reload with the generic override path injecting raw, unpadded dataset stats.
raw_stats = make_stats()
loaded_pre = PolicyProcessorPipeline.from_pretrained(
tmp_path,
config_filename=f"{POLICY_PREPROCESSOR_DEFAULT_NAME}.json",
overrides={"normalizer_processor": {"stats": raw_stats}},
to_transition=batch_to_transition,
to_output=transition_to_batch,
)
loaded_post = PolicyProcessorPipeline.from_pretrained(
tmp_path,
config_filename=f"{POLICY_POSTPROCESSOR_DEFAULT_NAME}.json",
overrides={"unnormalizer_processor": {"stats": raw_stats}},
to_transition=policy_action_to_transition,
to_output=transition_to_policy_action,
)
# Sanity: the override really injected unpadded stats before reconciliation.
normalizer = next(step for step in loaded_pre.steps if isinstance(step, NormalizerProcessorStep))
assert normalizer._tensor_stats[OBS_STATE]["min"].shape == (STATE_DIM,)
loaded_pre, loaded_post = reconcile_evo1_processors(config, loaded_pre, loaded_post)
normalizer = next(step for step in loaded_pre.steps if isinstance(step, NormalizerProcessorStep))
unnormalizer = next(step for step in loaded_post.steps if isinstance(step, UnnormalizerProcessorStep))
assert normalizer._tensor_stats[OBS_STATE]["min"].shape == (MAX_STATE_DIM,)
assert normalizer._tensor_stats[ACTION]["min"].shape == (MAX_ACTION_DIM,)
assert unnormalizer._tensor_stats[ACTION]["min"].shape == (MAX_ACTION_DIM,)
# Normalizing a padded state must not raise (this is the exact runtime path that crashed).
processed = loaded_pre(
{
"task": "pick the block",
OBS_STATE: torch.zeros(STATE_DIM),
f"{OBS_IMAGES}.front": torch.rand(3, 16, 16),
}
)
assert processed[OBS_STATE].shape == (1, MAX_STATE_DIM)
def test_evo1_policy_forward_and_inference_use_batched_embedding(monkeypatch):
monkeypatch.setattr(modeling_evo1, "Evo1Model", DummyEvo1Model)
policy = modeling_evo1.Evo1Policy(make_config())
+8 -12
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -46,23 +47,18 @@ def test_make_policy_keeps_peft_adapter_and_base_revisions_separate(monkeypatch)
peft_config_from_pretrained = MagicMock(return_value=peft_config)
adapted_policy = torch.nn.Linear(1, 1)
peft_model_from_pretrained = MagicMock(return_value=adapted_policy)
require_package = MagicMock()
monkeypatch.setattr(policy_factory, "require_package", require_package)
monkeypatch.setattr(
policy_factory,
"PeftConfig",
SimpleNamespace(from_pretrained=peft_config_from_pretrained),
)
monkeypatch.setattr(
policy_factory,
"PeftModel",
SimpleNamespace(from_pretrained=peft_model_from_pretrained),
monkeypatch.setitem(
sys.modules,
"peft",
SimpleNamespace(
PeftConfig=SimpleNamespace(from_pretrained=peft_config_from_pretrained),
PeftModel=SimpleNamespace(from_pretrained=peft_model_from_pretrained),
),
)
policy = policy_factory.make_policy(cfg, ds_meta=dataset_meta)
assert policy is adapted_policy
require_package.assert_called_once_with("peft", extra="peft")
peft_config_from_pretrained.assert_called_once_with(
"user/adapter",
revision="adapter-sha",
@@ -1,45 +0,0 @@
#!/usr/bin/env python
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pytest
from lerobot.configs import FeatureType, PipelineFeatureType, PolicyFeature
from lerobot.robots.so_follower.robot_kinematic_processor import (
ForwardKinematicsJointsToEEAction,
ForwardKinematicsJointsToEEObservation,
)
MOTOR_NAMES = ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper"]
EE_KEYS = {f"ee.{k}" for k in ["x", "y", "z", "wx", "wy", "wz", "gripper_pos"]}
def _joint_bucket(feature_type: FeatureType) -> dict[str, PolicyFeature]:
return {f"{n}.pos": PolicyFeature(type=feature_type, shape=(1,)) for n in MOTOR_NAMES}
@pytest.mark.parametrize(
("step_cls", "bucket", "feature_type"),
[
(ForwardKinematicsJointsToEEAction, PipelineFeatureType.ACTION, FeatureType.ACTION),
(ForwardKinematicsJointsToEEObservation, PipelineFeatureType.OBSERVATION, FeatureType.STATE),
],
)
def test_fk_feature_schema(step_cls, bucket, feature_type):
features = {PipelineFeatureType.ACTION: {}, PipelineFeatureType.OBSERVATION: {}}
features[bucket] = _joint_bucket(feature_type)
out = step_cls(kinematics=None, motor_names=MOTOR_NAMES).transform_features(features)[bucket]
assert set(out) == EE_KEYS
assert {feature.type for feature in out.values()} == {feature_type}
+2 -23
View File
@@ -49,7 +49,7 @@ def _make_bus_mock() -> MagicMock:
@pytest.fixture
def follower(tmp_path):
def follower():
bus_mock = _make_bus_mock()
def _bus_side_effect(*_args, **kwargs):
@@ -71,7 +71,7 @@ def follower(tmp_path):
),
patch.object(SO100Follower, "configure", lambda self: None),
):
cfg = SO100FollowerConfig(port="/dev/null", calibration_dir=tmp_path)
cfg = SO100FollowerConfig(port="/dev/null")
robot = SO100Follower(cfg)
yield robot
if robot.is_connected:
@@ -99,27 +99,6 @@ def test_get_observation(follower):
assert obs[f"{motor}.pos"] == idx
def test_get_observation_uses_read_retries(follower):
# Feetech buses can intermittently fail a sync_read; the follower should forward the configured
# retry count so transient failures don't abort the control loop (see #3131).
follower.config.num_read_retries = 7
follower.connect()
follower.get_observation()
follower.bus.sync_read.assert_called_once_with("Present_Position", num_retry=7)
def test_send_action_uses_read_retries(follower):
follower.config.max_relative_target = 10.0
follower.config.num_read_retries = 7
follower.connect()
action = {f"{motor}.pos": value * 10 for value, motor in enumerate(follower.bus.motors, 1)}
follower.send_action(action)
follower.bus.sync_read.assert_called_once_with("Present_Position", num_retry=7)
def test_send_action(follower):
follower.connect()
@@ -1,52 +0,0 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pytest
import torch
# The script imports ``lerobot.datasets`` (via the annotation frame provider),
# which only ships under the ``dataset`` extra.
pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])")
from lerobot.scripts.lerobot_curate_cameras import _to_uint8_frame, _uniform_indices # noqa: E402
@pytest.mark.parametrize(
"n,k,expected",
[
(0, 4, []),
(5, 0, []),
(3, 5, [0, 1, 2]), # k >= n -> all frames
(1, 4, [0]),
(10, 1, [0]),
(10, 4, [0, 3, 6, 9]), # evenly spaced, endpoints included
],
)
def test_uniform_indices(n, k, expected):
assert _uniform_indices(n, k) == expected
def test_to_uint8_frame_scales_floats():
frame = torch.ones(3, 4, 4, dtype=torch.float32) # [0,1] float
out = _to_uint8_frame(frame)
assert out.dtype == torch.uint8
assert int(out.max()) == 255
def test_to_uint8_frame_passthrough_uint8():
frame = torch.zeros(3, 4, 4, dtype=torch.uint8)
out = _to_uint8_frame(frame)
assert out is frame # uint8 passes through untouched
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import patch
import pytest
import torch
from lerobot.utils.device_utils import get_safe_torch_device, is_torch_device_available
def test_cpu_always_available():
assert get_safe_torch_device("cpu") == torch.device("cpu")
assert is_torch_device_available("cpu")
def test_missing_cuda_raises_valueerror():
with patch("torch.cuda.is_available", return_value=False), pytest.raises(ValueError, match="CUDA"):
get_safe_torch_device("cuda")
def test_missing_mps_raises_valueerror():
with patch("torch.backends.mps.is_available", return_value=False), pytest.raises(ValueError, match="MPS"):
get_safe_torch_device("mps")
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import numpy as np
import pytest
from lerobot.utils.rotation import Rotation
def test_zero_quaternion_rejected():
with pytest.raises(ValueError, match="non-zero"):
Rotation(np.zeros(4))
def test_non_finite_quaternion_rejected():
with pytest.raises(ValueError, match="non-zero|finite"):
Rotation(np.array([np.nan, 0.0, 0.0, 1.0]))
def test_wrong_shape_rejected():
with pytest.raises(ValueError, match="shape"):
Rotation(np.array([1.0, 0.0, 0.0]))
def test_identity_roundtrip():
r = Rotation.from_rotvec(np.zeros(3))
assert np.allclose(r.as_rotvec(), 0.0)
assert np.allclose(r.as_matrix(), np.eye(3))
def test_rotvec_roundtrip():
rotvec = np.array([0.1, -0.2, 0.3])
r = Rotation.from_rotvec(rotvec)
assert np.allclose(r.as_rotvec(), rotvec, atol=1e-6)
Generated
+138 -138
View File
@@ -402,10 +402,10 @@ name = "bddl"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jupytext" },
{ name = "networkx" },
{ name = "numpy" },
{ name = "pytest" },
{ name = "jupytext", marker = "sys_platform == 'linux'" },
{ name = "networkx", marker = "sys_platform == 'linux'" },
{ name = "numpy", marker = "sys_platform == 'linux'" },
{ name = "pytest", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/37/0211f82891a9f14efcfd2b2096f8d9e4351398ad637fdd1ee59cfc580b0e/bddl-1.0.1.tar.gz", hash = "sha256:1fa4e6e5050b93888ff6fd8455c39bfb29d3864ce06b4c37c0f781f513a2ae26", size = 164809, upload-time = "2022-03-08T01:48:23.564Z" }
@@ -1010,7 +1010,7 @@ name = "cuda-bindings"
version = "12.9.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder" },
{ name = "cuda-pathfinder", marker = "sys_platform == 'linux'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/45/557d4ed1fa54f0c7db8aee083229f624990d69f7d00f55477eed5c7e169a/cuda_bindings-12.9.7-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0666d3c082ef8f4b2d670950589373550e9f3bf564d635dd883f24a0b40402ff", size = 7071026, upload-time = "2026-05-27T18:44:13.356Z" },
@@ -1043,37 +1043,37 @@ wheels = [
[package.optional-dependencies]
cublas = [
{ name = "nvidia-cublas-cu12" },
{ name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux'" },
]
cudart = [
{ name = "nvidia-cuda-runtime-cu12" },
{ name = "nvidia-cuda-runtime-cu12", marker = "sys_platform == 'linux'" },
]
cufft = [
{ name = "nvidia-cufft-cu12" },
{ name = "nvidia-cufft-cu12", marker = "sys_platform == 'linux'" },
]
cufile = [
{ name = "nvidia-cufile-cu12" },
{ name = "nvidia-cufile-cu12", marker = "sys_platform == 'linux'" },
]
cupti = [
{ name = "nvidia-cuda-cupti-cu12" },
{ name = "nvidia-cuda-cupti-cu12", marker = "sys_platform == 'linux'" },
]
curand = [
{ name = "nvidia-curand-cu12" },
{ name = "nvidia-curand-cu12", marker = "sys_platform == 'linux'" },
]
cusolver = [
{ name = "nvidia-cusolver-cu12" },
{ name = "nvidia-cusolver-cu12", marker = "sys_platform == 'linux'" },
]
cusparse = [
{ name = "nvidia-cusparse-cu12" },
{ name = "nvidia-cusparse-cu12", marker = "sys_platform == 'linux'" },
]
nvjitlink = [
{ name = "nvidia-nvjitlink-cu12" },
{ name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux'" },
]
nvrtc = [
{ name = "nvidia-cuda-nvrtc-cu12" },
{ name = "nvidia-cuda-nvrtc-cu12", marker = "sys_platform == 'linux'" },
]
nvtx = [
{ name = "nvidia-nvtx-cu12" },
{ name = "nvidia-nvtx-cu12", marker = "sys_platform == 'linux'" },
]
[[package]]
@@ -1145,7 +1145,7 @@ name = "decord"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "numpy", marker = "(platform_machine != 'arm64' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/79/936af42edf90a7bd4e41a6cac89c913d4b47fa48a26b042d5129a9242ee3/decord-0.6.0-py3-none-manylinux2010_x86_64.whl", hash = "sha256:51997f20be8958e23b7c4061ba45d0efcd86bffd5fe81c695d0befee0d442976", size = 13602299, upload-time = "2021-06-14T21:30:55.486Z" },
@@ -1283,10 +1283,10 @@ resolution-markers = [
"python_full_version == '3.14.*' and sys_platform == 'win32'",
]
dependencies = [
{ name = "absl-py" },
{ name = "attrs" },
{ name = "numpy" },
{ name = "wrapt" },
{ name = "absl-py", marker = "python_full_version >= '3.14'" },
{ name = "attrs", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "wrapt", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/83/ce29720ccf934c6cfa9b9c95ebbe96558386e66886626066632b5e44afed/dm_tree-0.1.9.tar.gz", hash = "sha256:a4c7db3d3935a5a2d5e4b383fc26c6b0cd6f78c6d4605d3e7b518800ecd5342b", size = 35623, upload-time = "2025-01-30T20:45:37.13Z" }
wheels = [
@@ -1324,10 +1324,10 @@ resolution-markers = [
"python_full_version < '3.13' and sys_platform == 'win32'",
]
dependencies = [
{ name = "absl-py" },
{ name = "attrs" },
{ name = "numpy" },
{ name = "wrapt" },
{ name = "absl-py", marker = "python_full_version < '3.14'" },
{ name = "attrs", marker = "python_full_version < '3.14'" },
{ name = "numpy", marker = "python_full_version < '3.14'" },
{ name = "wrapt", marker = "python_full_version < '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/66/a3ec619d22b6baffa5ab853e8dc6ec9d0c837127948af59bb15b988d7312/dm_tree-0.1.10.tar.gz", hash = "sha256:22f37b599e01cc3402a17f79c257a802aebd8d326de05b54657650845956208a", size = 35748, upload-time = "2026-03-31T17:35:39.03Z" }
wheels = [
@@ -1912,7 +1912,7 @@ name = "h5py"
version = "3.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "numpy", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/33/acd0ce6863b6c0d7735007df01815403f5589a21ff8c2e1ee2587a38f548/h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738", size = 446526, upload-time = "2026-03-06T13:49:08.07Z" }
wheels = [
@@ -1956,23 +1956,23 @@ name = "hf-libero"
version = "0.1.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bddl" },
{ name = "cloudpickle" },
{ name = "easydict" },
{ name = "einops" },
{ name = "future" },
{ name = "gymnasium" },
{ name = "hf-egl-probe" },
{ name = "hydra-core" },
{ name = "matplotlib" },
{ name = "mujoco" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "robomimic" },
{ name = "robosuite" },
{ name = "thop" },
{ name = "transformers" },
{ name = "wandb" },
{ name = "bddl", marker = "sys_platform == 'linux'" },
{ name = "cloudpickle", marker = "sys_platform == 'linux'" },
{ name = "easydict", marker = "sys_platform == 'linux'" },
{ name = "einops", marker = "sys_platform == 'linux'" },
{ name = "future", marker = "sys_platform == 'linux'" },
{ name = "gymnasium", marker = "sys_platform == 'linux'" },
{ name = "hf-egl-probe", marker = "sys_platform == 'linux'" },
{ name = "hydra-core", marker = "sys_platform == 'linux'" },
{ name = "matplotlib", marker = "sys_platform == 'linux'" },
{ name = "mujoco", marker = "sys_platform == 'linux'" },
{ name = "numpy", marker = "sys_platform == 'linux'" },
{ name = "opencv-python", marker = "sys_platform == 'linux'" },
{ name = "robomimic", marker = "sys_platform == 'linux'" },
{ name = "robosuite", marker = "sys_platform == 'linux'" },
{ name = "thop", marker = "sys_platform == 'linux'" },
{ name = "transformers", marker = "sys_platform == 'linux'" },
{ name = "wandb", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/aa/4e9eb8715e0bff9cb6553db563a35d253393097d446f82bd53575e8b253d/hf_libero-0.1.4.tar.gz", hash = "sha256:c058d67ad5a2b589529c14d614282ef4cca3a7763dafa134f58a6c9039657e34", size = 2961319, upload-time = "2026-06-10T09:56:13.994Z" }
wheels = [
@@ -2123,9 +2123,9 @@ name = "hydra-core"
version = "1.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "antlr4-python3-runtime" },
{ name = "omegaconf" },
{ name = "packaging" },
{ name = "antlr4-python3-runtime", marker = "sys_platform == 'linux'" },
{ name = "omegaconf", marker = "sys_platform == 'linux'" },
{ name = "packaging", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/dd/220f0e91743136725352497e98540772a01fc7c3ab96ff16c3c74424e984/hydra_core-1.3.4.tar.gz", hash = "sha256:ad0f7b05a0242255a8984d5a4ed2f6847f7b783ed727368a2c0155ec52d6c34c", size = 3263348, upload-time = "2026-07-04T16:25:38.891Z" }
wheels = [
@@ -2678,11 +2678,11 @@ name = "jupytext"
version = "1.19.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "mdit-py-plugins" },
{ name = "nbformat" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "markdown-it-py", marker = "sys_platform == 'linux'" },
{ name = "mdit-py-plugins", marker = "sys_platform == 'linux'" },
{ name = "nbformat", marker = "sys_platform == 'linux'" },
{ name = "packaging", marker = "sys_platform == 'linux'" },
{ name = "pyyaml", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/473f8ebb101553fb2ea6ab1d34324d6677844c968947ac050c759d539f2c/jupytext-1.19.5.tar.gz", hash = "sha256:605026446d605aa54fd7f7fc69df6ae51c7a46053d4cebf05afdc64d66de3df0", size = 4600916, upload-time = "2026-07-21T22:00:29.198Z" }
wheels = [
@@ -3472,7 +3472,7 @@ requires-dist = [
{ name = "pyrealsense2", marker = "sys_platform != 'darwin' and extra == 'intelrealsense'", specifier = ">=2.55.1.6486,<2.57.0" },
{ name = "pyrealsense2-macosx", marker = "sys_platform == 'darwin' and extra == 'intelrealsense'", specifier = ">=2.54,<2.57.0" },
{ name = "pyserial", marker = "extra == 'pyserial-dep'", specifier = ">=3.5,<4.0" },
{ name = "pytest", marker = "extra == 'test'", specifier = ">=8.1.0,<9.0.0" },
{ name = "pytest", marker = "extra == 'test'", specifier = ">=8.1.0,<10.0.0" },
{ name = "pytest-cov", marker = "extra == 'test'", specifier = ">=5.0.0,<8.0.0" },
{ name = "pytest-timeout", marker = "extra == 'test'", specifier = ">=2.4.0,<3.0.0" },
{ name = "python-can", marker = "extra == 'can-dep'", specifier = ">=4.2.0,<5.0.0" },
@@ -3486,7 +3486,7 @@ requires-dist = [
{ name = "scikit-image", marker = "extra == 'video-benchmark'", specifier = ">=0.23.2,<0.26.0" },
{ name = "scipy", marker = "extra == 'all'", specifier = ">=1.14.0,<2.0.0" },
{ name = "scipy", marker = "extra == 'scipy-dep'", specifier = ">=1.14.0,<2.0.0" },
{ name = "setuptools", specifier = ">=71.0.0,<81.0.0" },
{ name = "setuptools", specifier = ">=71.0.0,<84.0.0" },
{ name = "teleop", marker = "extra == 'phone'", specifier = ">=0.1.0,<0.2.0" },
{ name = "termcolor", specifier = ">=2.4.0,<4.0.0" },
{ name = "timm", marker = "extra == 'timm-dep'", specifier = ">=1.0.0,<1.1.0" },
@@ -3817,7 +3817,7 @@ name = "mdit-py-plugins"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "markdown-it-py", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
wheels = [
@@ -4296,8 +4296,8 @@ name = "numba"
version = "0.66.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "llvmlite" },
{ name = "numpy" },
{ name = "llvmlite", marker = "sys_platform == 'linux'" },
{ name = "numpy", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/a0/570e3dc53e5602b49108f62a13e529f1eec8bfc7ef37d49c825924dcf546/numba-0.66.0.tar.gz", hash = "sha256:b900e63a0e26c05ea9a6d5a3a5a0a177cb64c5011887bf43edb8c3ed2c38d363", size = 2806181, upload-time = "2026-07-01T23:12:46.36Z" }
wheels = [
@@ -4390,7 +4390,7 @@ name = "nvidia-cudnn-cu12"
version = "9.19.0.56"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas-cu12" },
{ name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/b8/277c51962ee46fa3e5b203ac5f76107c650f781d6891e681e28e6f3e9fe6/nvidia_cudnn_cu12-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:08caaf27fe556aca82a3ee3b5aa49a77e7de0cfcb7ff4e5c29da426387a8267e", size = 656910700, upload-time = "2026-02-03T20:40:25.508Z" },
@@ -4402,7 +4402,7 @@ name = "nvidia-cufft-cu12"
version = "11.3.3.83"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink-cu12" },
{ name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/bc/7771846d3a0272026c416fbb7e5f4c1f146d6d80704534d0b187dd6f4800/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:848ef7224d6305cdb2a4df928759dca7b1201874787083b6e7550dd6765ce69a", size = 193109211, upload-time = "2025-03-07T01:44:56.873Z" },
@@ -4432,9 +4432,9 @@ name = "nvidia-cusolver-cu12"
version = "11.7.3.90"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas-cu12" },
{ name = "nvidia-cusparse-cu12" },
{ name = "nvidia-nvjitlink-cu12" },
{ name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cusparse-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/32/f7cd6ce8a7690544d084ea21c26e910a97e077c9b7f07bf5de623ee19981/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:db9ed69dbef9715071232caa9b69c52ac7de3a95773c2db65bdba85916e4e5c0", size = 267229841, upload-time = "2025-03-07T01:46:54.356Z" },
@@ -4446,7 +4446,7 @@ name = "nvidia-cusparse-cu12"
version = "12.5.8.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink-cu12" },
{ name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/f7/cd777c4109681367721b00a106f491e0d0d15cfa1fd59672ce580ce42a97/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b6c161cb130be1a07a27ea6923df8141f3c295852f4b260c65f18f3e0a091dc", size = 288117129, upload-time = "2025-03-07T01:47:40.407Z" },
@@ -4503,8 +4503,8 @@ name = "omegaconf"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "antlr4-python3-runtime" },
{ name = "pyyaml" },
{ name = "antlr4-python3-runtime", marker = "sys_platform == 'linux'" },
{ name = "pyyaml", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/3d/e4b57b8d9008c6ebe0d5eff901f91d5700cf7bdb8c8863df817463a7fd5e/omegaconf-2.3.1.tar.gz", hash = "sha256:e5e7de64aeebeddaf8e6d3f7a783b32ac2a01c0fbd9c878012caecb891a1f42a", size = 3298472, upload-time = "2026-06-11T05:05:12.885Z" }
wheels = [
@@ -4743,7 +4743,7 @@ name = "pexpect"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ptyprocess" },
{ name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [
@@ -5317,10 +5317,10 @@ name = "pyobjc-framework-applicationservices"
version = "12.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-coretext" },
{ name = "pyobjc-framework-quartz" },
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'" },
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'" },
{ name = "pyobjc-framework-coretext", marker = "sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'" },
{ name = "pyobjc-framework-quartz", marker = "sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/4d/0ebdd8144aba94b8fe9828ccee5616a4bf53d1f8bc51cff55f3cce86d695/pyobjc_framework_applicationservices-12.2.1.tar.gz", hash = "sha256:048ea663c9ac75c44a15dc7d5b8d78cbb4c97bf1c76e83835e8d5498e184001f", size = 109342, upload-time = "2026-06-19T16:19:46.149Z" }
wheels = [
@@ -5338,7 +5338,7 @@ name = "pyobjc-framework-cocoa"
version = "12.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" }
wheels = [
@@ -5356,9 +5356,9 @@ name = "pyobjc-framework-coretext"
version = "12.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-quartz" },
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'" },
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'" },
{ name = "pyobjc-framework-quartz", marker = "sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/9c/4c7f452059dc1d3845b8e627b9113c247a997b9b07518e848c2ab7ff3149/pyobjc_framework_coretext-12.2.1.tar.gz", hash = "sha256:af740e784d7c592c34025ec7165f4f6c1a69b5a2d9075f06e41e4f77c212aed2", size = 97349, upload-time = "2026-06-19T16:20:22.508Z" }
wheels = [
@@ -5376,8 +5376,8 @@ name = "pyobjc-framework-quartz"
version = "12.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'" },
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" }
wheels = [
@@ -5454,7 +5454,7 @@ wheels = [
[[package]]
name = "pytest"
version = "8.4.2"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -5463,9 +5463,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
@@ -5952,18 +5952,18 @@ name = "robomimic"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "egl-probe" },
{ name = "h5py" },
{ name = "imageio" },
{ name = "imageio-ffmpeg" },
{ name = "numpy" },
{ name = "psutil" },
{ name = "tensorboard" },
{ name = "tensorboardx" },
{ name = "termcolor" },
{ name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" } },
{ name = "torchvision", version = "0.26.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" } },
{ name = "tqdm" },
{ name = "egl-probe", marker = "sys_platform == 'linux'" },
{ name = "h5py", marker = "sys_platform == 'linux'" },
{ name = "imageio", marker = "sys_platform == 'linux'" },
{ name = "imageio-ffmpeg", marker = "sys_platform == 'linux'" },
{ name = "numpy", marker = "sys_platform == 'linux'" },
{ name = "psutil", marker = "sys_platform == 'linux'" },
{ name = "tensorboard", marker = "sys_platform == 'linux'" },
{ name = "tensorboardx", marker = "sys_platform == 'linux'" },
{ name = "termcolor", marker = "sys_platform == 'linux'" },
{ name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux'" },
{ name = "torchvision", version = "0.26.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux'" },
{ name = "tqdm", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/c3/44b1d1ea4bcb4bbed43d19e09505f4142714451ded74020d4f679cdc89fb/robomimic-0.2.0.tar.gz", hash = "sha256:ee3bb5cf9c3e1feead6b57b43c5db738fd0a8e0c015fdf6419808af8fffdc463", size = 192919, upload-time = "2021-12-17T19:00:33.279Z" }
@@ -5972,12 +5972,12 @@ name = "robosuite"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mujoco" },
{ name = "numba" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
{ name = "scipy" },
{ name = "mujoco", marker = "sys_platform == 'linux'" },
{ name = "numba", marker = "sys_platform == 'linux'" },
{ name = "numpy", marker = "sys_platform == 'linux'" },
{ name = "opencv-python", marker = "sys_platform == 'linux'" },
{ name = "pillow", marker = "sys_platform == 'linux'" },
{ name = "scipy", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/a1/9dd07a9a5e09c6aa032faf531da985808b34437cbf6c8f358fe8f7c47118/robosuite-1.4.0.tar.gz", hash = "sha256:a8a6233d7458dbd91bf00a86cab15aa1c178bd9d1b28d515db2cf3d152cb48e6", size = 192182147, upload-time = "2022-12-01T07:31:55.791Z" }
wheels = [
@@ -6398,16 +6398,16 @@ name = "tensorboard"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "absl-py" },
{ name = "grpcio" },
{ name = "markdown" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pillow" },
{ name = "protobuf" },
{ name = "setuptools" },
{ name = "tensorboard-data-server" },
{ name = "werkzeug" },
{ name = "absl-py", marker = "sys_platform == 'linux'" },
{ name = "grpcio", marker = "sys_platform == 'linux'" },
{ name = "markdown", marker = "sys_platform == 'linux'" },
{ name = "numpy", marker = "sys_platform == 'linux'" },
{ name = "packaging", marker = "sys_platform == 'linux'" },
{ name = "pillow", marker = "sys_platform == 'linux'" },
{ name = "protobuf", marker = "sys_platform == 'linux'" },
{ name = "setuptools", marker = "sys_platform == 'linux'" },
{ name = "tensorboard-data-server", marker = "sys_platform == 'linux'" },
{ name = "werkzeug", marker = "sys_platform == 'linux'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/d9/a5db55f88f258ac669a92858b70a714bbbd5acd993820b41ec4a96a4d77f/tensorboard-2.20.0-py3-none-any.whl", hash = "sha256:9dc9f978cb84c0723acf9a345d96c184f0293d18f166bb8d59ee098e6cfaaba6", size = 5525680, upload-time = "2025-07-17T19:20:49.638Z" },
@@ -6427,9 +6427,9 @@ name = "tensorboardx"
version = "2.6.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "packaging" },
{ name = "protobuf" },
{ name = "numpy", marker = "sys_platform == 'linux'" },
{ name = "packaging", marker = "sys_platform == 'linux'" },
{ name = "protobuf", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/a9/fc520ea91ab1f3ba51cbf3fe24f2b6364ed3b49046969e0868d46d6da372/tensorboardx-2.6.5.tar.gz", hash = "sha256:ca176db3997ee8c07d2eb77381225956a3fd1c10c91beafab1f17069adc47017", size = 4770195, upload-time = "2026-04-03T15:40:23.803Z" }
wheels = [
@@ -6464,7 +6464,7 @@ name = "thop"
version = "0.1.1.post2209072238"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" } },
{ name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/0f/72beeab4ff5221dc47127c80f8834b4bcd0cb36f6ba91c0b1d04a1233403/thop-0.1.1.post2209072238-py3-none-any.whl", hash = "sha256:01473c225231927d2ad718351f78ebf7cffe6af3bed464c4f1ba1ef0f7cdda27", size = 15443, upload-time = "2022-09-07T14:38:37.211Z" },
@@ -6570,13 +6570,13 @@ resolution-markers = [
"python_full_version < '3.13' and sys_platform == 'win32'",
]
dependencies = [
{ name = "filelock" },
{ name = "fsspec" },
{ name = "jinja2" },
{ name = "networkx" },
{ name = "setuptools" },
{ name = "sympy" },
{ name = "typing-extensions" },
{ name = "filelock", marker = "sys_platform != 'linux'" },
{ name = "fsspec", marker = "sys_platform != 'linux'" },
{ name = "jinja2", marker = "sys_platform != 'linux'" },
{ name = "networkx", marker = "sys_platform != 'linux'" },
{ name = "setuptools", marker = "sys_platform != 'linux'" },
{ name = "sympy", marker = "sys_platform != 'linux'" },
{ name = "typing-extensions", marker = "sys_platform != 'linux'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/8b/69e3008d78e5cee2b30183340cc425081b78afc5eff3d080daab0adda9aa/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b5866312ee6e52ea625cd211dcb97d6a2cdc1131a5f15cc0d87eec948f6dd34", size = 80606338, upload-time = "2026-03-23T18:11:34.781Z" },
@@ -6610,20 +6610,20 @@ resolution-markers = [
"python_full_version < '3.13' and platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and sys_platform == 'linux'",
]
dependencies = [
{ name = "cuda-bindings" },
{ name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"] },
{ name = "filelock" },
{ name = "fsspec" },
{ name = "jinja2" },
{ name = "networkx" },
{ name = "nvidia-cudnn-cu12" },
{ name = "nvidia-cusparselt-cu12" },
{ name = "nvidia-nccl-cu12" },
{ name = "nvidia-nvshmem-cu12" },
{ name = "setuptools" },
{ name = "sympy" },
{ name = "triton" },
{ name = "typing-extensions" },
{ name = "cuda-bindings", marker = "sys_platform == 'linux'" },
{ name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" },
{ name = "filelock", marker = "sys_platform == 'linux'" },
{ name = "fsspec", marker = "sys_platform == 'linux'" },
{ name = "jinja2", marker = "sys_platform == 'linux'" },
{ name = "networkx", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu12", marker = "sys_platform == 'linux'" },
{ name = "setuptools", marker = "sys_platform == 'linux'" },
{ name = "sympy", marker = "sys_platform == 'linux'" },
{ name = "triton", marker = "sys_platform == 'linux'" },
{ name = "typing-extensions", marker = "sys_platform == 'linux'" },
]
wheels = [
{ url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9c8f38efee365cb9d334de8a83ce52fc7e5fc9e5a7b0853285efa1b69e00b0f2", upload-time = "2026-04-27T17:41:30Z" },
@@ -6694,9 +6694,9 @@ resolution-markers = [
"python_full_version < '3.13' and sys_platform == 'win32'",
]
dependencies = [
{ name = "numpy" },
{ name = "pillow" },
{ name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" } },
{ name = "numpy", marker = "sys_platform != 'linux'" },
{ name = "pillow", marker = "sys_platform != 'linux'" },
{ name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux'" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/e7/56b47cc3b132aea90ccce22bcb8975dec688b002150012acc842846039d0/torchvision-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c409e1c3fdebec7a3834465086dbda8bf7680eff79abf7fd2f10c6b59520a7a4", size = 1863502, upload-time = "2026-03-23T18:12:57.326Z" },
@@ -6730,9 +6730,9 @@ resolution-markers = [
"python_full_version < '3.13' and platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and sys_platform == 'linux'",
]
dependencies = [
{ name = "numpy" },
{ name = "pillow" },
{ name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" } },
{ name = "numpy", marker = "sys_platform == 'linux'" },
{ name = "pillow", marker = "sys_platform == 'linux'" },
{ name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux'" },
]
wheels = [
{ url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:63e35234aed13b6edda37056f417b5c281249669db631e706811917af36b21d7", upload-time = "2026-04-09T23:21:35Z" },
@@ -7222,7 +7222,7 @@ name = "werkzeug"
version = "3.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
{ name = "markupsafe", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
wheels = [