Merge branch 'main' into feat/unitree_g1_sonic_rebased

This commit is contained in:
Martino Russi
2026-06-25 13:41:04 +02:00
committed by GitHub
51 changed files with 1593 additions and 372 deletions
+2 -1
View File
@@ -97,7 +97,7 @@ Training a policy is as simple as running a script configuration:
```bash ```bash
lerobot-train \ lerobot-train \
--policy=act \ --policy.type=act \
--dataset.repo_id=lerobot/aloha_mobile_cabinet --dataset.repo_id=lerobot/aloha_mobile_cabinet
``` ```
@@ -136,6 +136,7 @@ Learn how to implement your own simulation environment or benchmark and distribu
- **[X](https://x.com/LeRobotHF):** Follow us on X to stay up-to-date with the latest developments. - **[X](https://x.com/LeRobotHF):** Follow us on X to stay up-to-date with the latest developments.
- **[Robot Learning Tutorial](https://huggingface.co/spaces/lerobot/robot-learning-tutorial):** A free, hands-on course to learn robot learning using LeRobot. - **[Robot Learning Tutorial](https://huggingface.co/spaces/lerobot/robot-learning-tutorial):** A free, hands-on course to learn robot learning using LeRobot.
- **[T-Shirt Folding Experiment](https://huggingface.co/spaces/lerobot/robot-folding):** An end-to-end demonstration of folding t-shirts with LeRobot. - **[T-Shirt Folding Experiment](https://huggingface.co/spaces/lerobot/robot-folding):** An end-to-end demonstration of folding t-shirts with LeRobot.
- **[LeLab](https://github.com/huggingface/leLab):** A web interface for LeRobot — teleoperate, calibrate, record datasets, replay, and train your SO arm from the browser, no CLI required.
## Citation ## Citation
+12 -4
View File
@@ -390,9 +390,17 @@ Set the flow of data recording using command-line arguments:
Control the data recording flow using keyboard shortcuts: Control the data recording flow using keyboard shortcuts:
- Press **Right Arrow (`→`)**: Early stop the current episode or reset time and move to the next. - Press **Right Arrow (`→`)** or **`n`**: Early stop the current episode or reset time and move to the next.
- Press **Left Arrow (`←`)**: Cancel the current episode and re-record it. - Press **Left Arrow (`←`)** or **`r`**: Cancel the current episode and re-record it.
- Press **Escape (`ESC`)**: Immediately stop the session, encode videos, and upload the dataset. - Press **Escape (`ESC`)** or **`q`**: Immediately stop the session, encode videos, and upload the dataset.
<Tip>
These control-flow shortcuts work on **X11, Wayland, and headless/SSH** sessions. When a global keyboard backend isn't available (Wayland, a headless machine, or macOS without Accessibility permission), `lerobot-record` automatically reads the same keys from the terminal — launch it from an interactive terminal and keep it focused. You can also use the letter equivalents **`n`** (next, same as `→`), **`r`** (re-record, same as `←`) and **`q`** (quit, same as `ESC`). No `$DISPLAY` setup is required.
This applies to the recording control flow only. Keyboard **teleoperation** (driving the robot with the keyboard) still needs a global key backend, so it works only on an X11 session, a Windows desktop, or macOS with Accessibility/Input Monitoring granted — not on Wayland or headless sessions.
</Tip>
#### Tips for gathering data #### Tips for gathering data
@@ -406,7 +414,7 @@ If you want to dive deeper into this important topic, you can check out the [blo
#### Troubleshooting: #### Troubleshooting:
- On Linux, if the left and right arrow keys and escape key don't have any effect during data recording, make sure you've set the `$DISPLAY` environment variable. See [pynput limitations](https://pynput.readthedocs.io/en/latest/limitations.html#linux). - On Linux, the recording control-flow keys (arrow keys, Escape) work on X11, Wayland, and headless/SSH sessions as long as `lerobot-record` runs in an interactive terminal — no `$DISPLAY` setup is needed. If the keys have no effect, make sure you are in an interactive (TTY) terminal, not a piped/non-TTY session, and that it is focused; the letter equivalents `n` / `r` / `q` also work. Keyboard _teleoperation_ (as opposed to the recording control flow) still requires a global key backend — an X11 session, a Windows desktop, or macOS with Accessibility/Input Monitoring granted — and is unavailable on Wayland or headless machines. See [pynput limitations](https://pynput.readthedocs.io/en/latest/limitations.html#linux).
## Visualize a dataset ## Visualize a dataset
+1 -1
View File
@@ -319,7 +319,7 @@ If you want to dive deeper into this important topic, you can check out the [blo
#### Troubleshooting: #### Troubleshooting:
- On Linux, if the left and right arrow keys and escape key don't have any effect during data recording, make sure you've set the `$DISPLAY` environment variable. See [pynput limitations](https://pynput.readthedocs.io/en/latest/limitations.html#linux). - On Linux, the recording control-flow keys (arrow keys, Escape) work on X11, Wayland, and headless/SSH sessions as long as you run the recording from an interactive terminal (keep it focused) — no `$DISPLAY` setup is needed; the letter equivalents `n` / `r` / `q` also work. Note that **keyboard teleoperation of the LeKiwi base** is different: it relies on a global key backend and therefore works only on an X11 session, a Windows desktop, or macOS with Accessibility/Input Monitoring granted — not on Wayland or headless machines. See [pynput limitations](https://pynput.readthedocs.io/en/latest/limitations.html#linux).
## Replay an episode ## Replay an episode
+57 -2
View File
@@ -95,7 +95,7 @@ If you want to scale your hyperparameters when using multiple GPUs, you should d
accelerate launch --num_processes=2 $(which lerobot-train) \ accelerate launch --num_processes=2 $(which lerobot-train) \
--optimizer.lr=2e-4 \ --optimizer.lr=2e-4 \
--dataset.repo_id=lerobot/pusht \ --dataset.repo_id=lerobot/pusht \
--policy=act --policy.type=act
``` ```
**Training Steps Scaling:** **Training Steps Scaling:**
@@ -110,9 +110,64 @@ accelerate launch --num_processes=2 $(which lerobot-train) \
--batch_size=8 \ --batch_size=8 \
--steps=50000 \ --steps=50000 \
--dataset.repo_id=lerobot/pusht \ --dataset.repo_id=lerobot/pusht \
--policy=act --policy.type=act
``` ```
## Training Large Models with FSDP
DDP replicates the full model on every GPU, so a model that doesn't fit on one GPU won't fit under
DDP either. For large models, use **FSDP** (Fully Sharded Data Parallel), which shards parameters,
gradients, and optimizer state across GPUs. See the [accelerate FSDP guide](https://huggingface.co/docs/accelerate/usage_guides/fsdp) for background.
An example on how to launch LeRobot training with FSDP across 4 GPUs (1 machine):
```bash
accelerate launch --config_file fsdp.yaml --num_processes=4 $(which lerobot-train) \
--dataset.repo_id=${HF_USER}/my_dataset \
--policy.type=<your_policy> \
--output_dir=outputs/train/my_policy_fsdp
```
A minimal `fsdp.yaml` (FSDP1; shards params/grads/optimizer — ZeRO-3-equivalent):
```yaml
compute_environment: LOCAL_MACHINE
distributed_type: FSDP
mixed_precision: bf16
num_machines: 1
num_processes: 4
fsdp_config:
fsdp_version: 1
fsdp_sharding_strategy: FULL_SHARD # params + grads + optimizer (ZeRO-3)
fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP
fsdp_transformer_layer_cls_to_wrap: <YourTransformerBlock> # repeated block class to shard
fsdp_use_orig_params: true # required: optimizer is built pre-prepare
fsdp_state_dict_type: FULL_STATE_DICT
```
Set `fsdp_transformer_layer_cls_to_wrap` to your model's repeated transformer-block class so each
block is sharded as its own unit. `fsdp_use_orig_params: true` is required because LeRobot builds the
optimizer before `accelerator.prepare()`.
### FSDP checkpoints
LeRobot gathers the full state dict across all ranks and the main process writes it as a single
`model.safetensors`, loadable as usual with `Policy.from_pretrained(...)`. Two things to look out for:
- **Checkpoints store fp32 weights.** Under mixed precision (`bf16`/`fp16`) FSDP keeps an fp32 master
copy, and the checkpoint saves it (~2× the bf16 size on disk) so training can resume consistently
with the fp32 optimizer state; `from_pretrained` casts back to the policy dtype on load. FSDP-specific
caveat: an fp32 checkpoint is materialized in full precision on the target device _before_ casting,
so loading it for inference on a tight GPU can OOM even when the bf16 model would fit — load on CPU
first, or cast `model.safetensors` to the deployment dtype offline.
- The sharded optimizer state is gathered into a full (world-size-independent) state dict and saved
alongside the model in the same `optimizer_state.safetensors` / `optimizer_param_groups.json`
format as single-GPU training, so **resume-from-checkpoint is supported** with `--resume=true`.
Resume reshards both the model and the optimizer state to the _current_ FSDP topology, so you can
resume an FSDP checkpoint on a different number of GPUs. Note that the data sampler is only
sample-exact when the world size and batch size match the original run (a warning is logged
otherwise); the optimizer/model state itself is unaffected.
## Notes ## Notes
- The `--policy.use_amp` flag in `lerobot-train` is only used when **not** running with accelerate. When using accelerate, mixed precision is controlled by accelerate's configuration. - The `--policy.use_amp` flag in `lerobot-train` is only used when **not** running with accelerate. When using accelerate, mixed precision is controlled by accelerate's configuration.
+2 -2
View File
@@ -96,7 +96,7 @@ lerobot-train \
--policy.type=pi0_fast \ --policy.type=pi0_fast \
--output_dir=./outputs/pi0fast_training \ --output_dir=./outputs/pi0fast_training \
--job_name=pi0fast_training \ --job_name=pi0fast_training \
--policy.pretrained_path=lerobot/pi0_fast_base \ --policy.pretrained_path=lerobot/pi0fast-base \
--policy.dtype=bfloat16 \ --policy.dtype=bfloat16 \
--policy.gradient_checkpointing=true \ --policy.gradient_checkpointing=true \
--policy.chunk_size=10 \ --policy.chunk_size=10 \
@@ -187,7 +187,7 @@ lerobot-train \
--dataset.repo_id=lerobot/libero \ --dataset.repo_id=lerobot/libero \
--output_dir=outputs/libero_pi0fast \ --output_dir=outputs/libero_pi0fast \
--job_name=libero_pi0fast \ --job_name=libero_pi0fast \
--policy.path=lerobot/pi0fast_base \ --policy.path=lerobot/pi0fast-base \
--policy.dtype=bfloat16 \ --policy.dtype=bfloat16 \
--steps=100000 \ --steps=100000 \
--save_freq=20000 \ --save_freq=20000 \
+2 -1
View File
@@ -17,7 +17,7 @@
import logging import logging
import time import time
from lerobot.common.control_utils import init_keyboard_listener, predict_action from lerobot.common.control_utils import predict_action
from lerobot.datasets import LeRobotDataset from lerobot.datasets import LeRobotDataset
from lerobot.policies import make_pre_post_processors from lerobot.policies import make_pre_post_processors
from lerobot.policies.act import ACTPolicy from lerobot.policies.act import ACTPolicy
@@ -26,6 +26,7 @@ from lerobot.processor import make_default_processors
from lerobot.robots.lekiwi import LeKiwiClient, LeKiwiClientConfig from lerobot.robots.lekiwi import LeKiwiClient, LeKiwiClientConfig
from lerobot.utils.constants import ACTION, OBS_STR from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import build_dataset_frame, hw_to_dataset_features from lerobot.utils.feature_utils import build_dataset_frame, hw_to_dataset_features
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.robot_utils import precise_sleep from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data from lerobot.utils.visualization_utils import init_rerun, log_rerun_data
+1 -1
View File
@@ -14,7 +14,6 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from lerobot.common.control_utils import init_keyboard_listener
from lerobot.datasets import LeRobotDataset from lerobot.datasets import LeRobotDataset
from lerobot.processor import make_default_processors from lerobot.processor import make_default_processors
from lerobot.robots.lekiwi import LeKiwiClient, LeKiwiClientConfig from lerobot.robots.lekiwi import LeKiwiClient, LeKiwiClientConfig
@@ -23,6 +22,7 @@ from lerobot.teleoperators.keyboard import KeyboardTeleop, KeyboardTeleopConfig
from lerobot.teleoperators.so_leader import SO100Leader, SO100LeaderConfig from lerobot.teleoperators.so_leader import SO100Leader, SO100LeaderConfig
from lerobot.utils.constants import ACTION, OBS_STR from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import hw_to_dataset_features from lerobot.utils.feature_utils import hw_to_dataset_features
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun from lerobot.utils.visualization_utils import init_rerun
+2 -1
View File
@@ -18,7 +18,7 @@ import logging
import time import time
from lerobot.cameras.opencv import OpenCVCameraConfig from lerobot.cameras.opencv import OpenCVCameraConfig
from lerobot.common.control_utils import init_keyboard_listener, predict_action from lerobot.common.control_utils import predict_action
from lerobot.configs import FeatureType, PolicyFeature from lerobot.configs import FeatureType, PolicyFeature
from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features
from lerobot.model.kinematics import RobotKinematics from lerobot.model.kinematics import RobotKinematics
@@ -41,6 +41,7 @@ from lerobot.robots.so_follower.robot_kinematic_processor import (
from lerobot.types import RobotAction, RobotObservation from lerobot.types import RobotAction, RobotObservation
from lerobot.utils.constants import ACTION, OBS_STR from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import build_dataset_frame, combine_feature_dicts from lerobot.utils.feature_utils import build_dataset_frame, combine_feature_dicts
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.robot_utils import precise_sleep from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data from lerobot.utils.visualization_utils import init_rerun, log_rerun_data
+1 -1
View File
@@ -15,7 +15,6 @@
# limitations under the License. # limitations under the License.
from lerobot.cameras.opencv import OpenCVCameraConfig from lerobot.cameras.opencv import OpenCVCameraConfig
from lerobot.common.control_utils import init_keyboard_listener
from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features
from lerobot.model.kinematics import RobotKinematics from lerobot.model.kinematics import RobotKinematics
from lerobot.processor import ( from lerobot.processor import (
@@ -39,6 +38,7 @@ from lerobot.teleoperators.phone.config_phone import PhoneOS
from lerobot.teleoperators.phone.phone_processor import MapPhoneActionToRobotAction from lerobot.teleoperators.phone.phone_processor import MapPhoneActionToRobotAction
from lerobot.types import RobotAction, RobotObservation from lerobot.types import RobotAction, RobotObservation
from lerobot.utils.feature_utils import combine_feature_dicts from lerobot.utils.feature_utils import combine_feature_dicts
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun from lerobot.utils.visualization_utils import init_rerun
+2 -1
View File
@@ -18,7 +18,7 @@ import logging
import time import time
from lerobot.cameras.opencv import OpenCVCameraConfig from lerobot.cameras.opencv import OpenCVCameraConfig
from lerobot.common.control_utils import init_keyboard_listener, predict_action from lerobot.common.control_utils import predict_action
from lerobot.configs import FeatureType, PolicyFeature from lerobot.configs import FeatureType, PolicyFeature
from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features
from lerobot.model.kinematics import RobotKinematics from lerobot.model.kinematics import RobotKinematics
@@ -41,6 +41,7 @@ from lerobot.robots.so_follower.robot_kinematic_processor import (
from lerobot.types import RobotAction, RobotObservation from lerobot.types import RobotAction, RobotObservation
from lerobot.utils.constants import ACTION, OBS_STR from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import build_dataset_frame, combine_feature_dicts from lerobot.utils.feature_utils import build_dataset_frame, combine_feature_dicts
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.robot_utils import precise_sleep from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data from lerobot.utils.visualization_utils import init_rerun, log_rerun_data
+1 -1
View File
@@ -16,7 +16,6 @@
from lerobot.cameras.opencv import OpenCVCameraConfig from lerobot.cameras.opencv import OpenCVCameraConfig
from lerobot.common.control_utils import init_keyboard_listener
from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features from lerobot.datasets import LeRobotDataset, aggregate_pipeline_dataset_features, create_initial_features
from lerobot.model.kinematics import RobotKinematics from lerobot.model.kinematics import RobotKinematics
from lerobot.processor import ( from lerobot.processor import (
@@ -36,6 +35,7 @@ from lerobot.scripts.lerobot_record import record_loop
from lerobot.teleoperators.so_leader import SO100Leader, SO100LeaderConfig from lerobot.teleoperators.so_leader import SO100Leader, SO100LeaderConfig
from lerobot.types import RobotAction, RobotObservation from lerobot.types import RobotAction, RobotObservation
from lerobot.utils.feature_utils import combine_feature_dicts from lerobot.utils.feature_utils import combine_feature_dicts
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun from lerobot.utils.visualization_utils import init_rerun
+8 -1
View File
@@ -140,7 +140,14 @@ av-dep = ["av>=15.0.0,<16.0.0"]
pygame-dep = ["pygame>=2.5.1,<2.7.0"] pygame-dep = ["pygame>=2.5.1,<2.7.0"]
# NOTE: 0.9.16 links against liburdfdom_sensor.so.4, which is unavailable on Ubuntu 24.04 # NOTE: 0.9.16 links against liburdfdom_sensor.so.4, which is unavailable on Ubuntu 24.04
# (noble ships urdfdom 3.x). Cap below 0.9.16 until system urdfdom 4.x is broadly available. # (noble ships urdfdom 3.x). Cap below 0.9.16 until system urdfdom 4.x is broadly available.
placo-dep = ["placo>=0.9.6,<0.9.16"] #
# NOTE: placo pulls in pin (Pinocchio), whose binary wheels dlopen specific cmeel sonames
# (liburdfdom_sensor.so.4.0, libtinyxml2.so.10) but declare only `>=` floors on their cmeel
# packages. The 2026-05-21 major bumps (cmeel-urdfdom 6.0.0 -> .so.6, cmeel-tinyxml2 11.0.0
# -> .so.11) ship newer sonames, so left unpinned the resolver grabs them and `import placo`
# fails at load with "liburdfdom_sensor.so.4.0: cannot open shared object file" (see #3755).
# There is no cmeel-urdfdom 5.x; <5 selects the 4.x ABI the placo/pin wheels are built against.
placo-dep = ["placo>=0.9.6,<0.9.16", "cmeel-urdfdom>=4,<5", "cmeel-tinyxml2<11"]
transformers-dep = ["transformers>=5.4.0,<5.6.0"] transformers-dep = ["transformers>=5.4.0,<5.6.0"]
grpcio-dep = ["grpcio>=1.73.1,<2.0.0", "protobuf>=6.31.1,<8.0.0"] grpcio-dep = ["grpcio>=1.73.1,<2.0.0", "protobuf>=6.31.1,<8.0.0"]
accelerate-dep = ["accelerate>=1.14.0,<2.0.0"] accelerate-dep = ["accelerate>=1.14.0,<2.0.0"]
+3 -2
View File
@@ -442,11 +442,12 @@ class OpenCVCamera(Camera):
Stops on DeviceNotConnectedError, logs other errors and continues. Stops on DeviceNotConnectedError, logs other errors and continues.
""" """
if self.stop_event is None: stop_event = self.stop_event
if stop_event is None:
raise RuntimeError(f"{self}: stop_event is not initialized before starting read loop.") raise RuntimeError(f"{self}: stop_event is not initialized before starting read loop.")
failure_count = 0 failure_count = 0
while not self.stop_event.is_set(): while not stop_event.is_set():
try: try:
raw_frame = self._read_from_hardware() raw_frame = self._read_from_hardware()
processed_frame = self._postprocess_image(raw_frame) processed_frame = self._postprocess_image(raw_frame)
@@ -471,11 +471,12 @@ class RealSenseCamera(Camera):
Stops on DeviceNotConnectedError, logs other errors and continues. Stops on DeviceNotConnectedError, logs other errors and continues.
""" """
if self.stop_event is None: stop_event = self.stop_event
if stop_event is None:
raise RuntimeError(f"{self}: stop_event is not initialized before starting read loop.") raise RuntimeError(f"{self}: stop_event is not initialized before starting read loop.")
failure_count = 0 failure_count = 0
while not self.stop_event.is_set(): while not stop_event.is_set():
try: try:
frame = self._read_from_hardware() frame = self._read_from_hardware()
color_frame_raw = frame.get_color_frame() color_frame_raw = frame.get_color_frame()
+3 -2
View File
@@ -246,11 +246,12 @@ class ZMQCamera(Camera):
""" """
Internal loop run by the background thread for asynchronous reading. Internal loop run by the background thread for asynchronous reading.
""" """
if self.stop_event is None: stop_event = self.stop_event
if stop_event is None:
raise RuntimeError(f"{self}: stop_event is not initialized.") raise RuntimeError(f"{self}: stop_event is not initialized.")
failure_count = 0 failure_count = 0
while not self.stop_event.is_set(): while not stop_event.is_set():
try: try:
frame = self._read_from_hardware() frame = self._read_from_hardware()
capture_time = time.perf_counter() capture_time = time.perf_counter()
-84
View File
@@ -17,12 +17,9 @@ from __future__ import annotations
######################################################################################## ########################################################################################
# Utilities # Utilities
######################################################################################## ########################################################################################
import logging
import time import time
import traceback
from contextlib import nullcontext from contextlib import nullcontext
from copy import copy from copy import copy
from functools import cache
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import numpy as np import numpy as np
@@ -43,34 +40,6 @@ from lerobot.robots import Robot
from lerobot.types import PolicyAction from lerobot.types import PolicyAction
@cache
def is_headless():
"""
Detects if the Python script is running in a headless environment (e.g., without a display).
This function attempts to import `pynput`, a library that requires a graphical environment.
If the import fails, it assumes the environment is headless. The result is cached to avoid
re-running the check.
Returns:
True if the environment is determined to be headless, False otherwise.
"""
try:
import pynput # noqa
return False
except Exception:
print(
"Error trying to import pynput. Switching to headless mode. "
"As a result, the video stream from the cameras won't be shown, "
"and you won't be able to change the control flow with keyboards. "
"For more info, see traceback below.\n"
)
traceback.print_exc()
print()
return True
def predict_action( def predict_action(
observation: dict[str, np.ndarray], observation: dict[str, np.ndarray],
policy: PreTrainedPolicy, policy: PreTrainedPolicy,
@@ -122,59 +91,6 @@ def predict_action(
return action return action
def init_keyboard_listener():
"""
Initializes a non-blocking keyboard listener for real-time user interaction.
This function sets up a listener for specific keys (right arrow, left arrow, escape) to control
the program flow during execution, such as stopping recording or exiting loops. It gracefully
handles headless environments where keyboard listening is not possible.
Returns:
A tuple containing:
- The `pynput.keyboard.Listener` instance, or `None` if in a headless environment.
- A dictionary of event flags (e.g., `exit_early`) that are set by key presses.
"""
# Allow to exit early while recording an episode or resetting the environment,
# by tapping the right arrow key '->'. This might require a sudo permission
# to allow your terminal to monitor keyboard events.
events = {}
events["exit_early"] = False
events["rerecord_episode"] = False
events["stop_recording"] = False
if is_headless():
logging.warning(
"Headless environment detected. On-screen cameras display and keyboard inputs will not be available."
)
listener = None
return listener, events
# Only import pynput if not in a headless environment
from pynput import keyboard
def on_press(key):
try:
if key == keyboard.Key.right:
print("Right arrow key pressed. Exiting loop...")
events["exit_early"] = True
elif key == keyboard.Key.left:
print("Left arrow key pressed. Exiting loop and rerecord the last episode...")
events["rerecord_episode"] = True
events["exit_early"] = True
elif key == keyboard.Key.esc:
print("Escape key pressed. Stopping data recording...")
events["stop_recording"] = True
events["exit_early"] = True
except Exception as e:
print(f"Error handling key press: {e}")
listener = keyboard.Listener(on_press=on_press)
listener.start()
return listener, events
def sanity_check_dataset_name(repo_id, policy_cfg): def sanity_check_dataset_name(repo_id, policy_cfg):
""" """
Validates the dataset repository name against the presence of a policy configuration. Validates the dataset repository name against the presence of a policy configuration.
+83 -5
View File
@@ -21,6 +21,7 @@ from torch.optim.lr_scheduler import LRScheduler
from lerobot.configs.train import TrainPipelineConfig from lerobot.configs.train import TrainPipelineConfig
from lerobot.optim import ( from lerobot.optim import (
load_optimizer_state, load_optimizer_state,
load_optimizer_state_dict,
load_scheduler_state, load_scheduler_state,
save_optimizer_state, save_optimizer_state,
save_scheduler_state, save_scheduler_state,
@@ -98,6 +99,8 @@ def save_checkpoint(
postprocessor: PolicyProcessorPipeline | None = None, postprocessor: PolicyProcessorPipeline | None = None,
num_processes: int | None = None, num_processes: int | None = None,
batch_size: int | None = None, batch_size: int | None = None,
model_state_dict: dict | None = None,
optim_state_dict: dict | None = None,
) -> None: ) -> None:
"""This function creates the following directory structure: """This function creates the following directory structure:
@@ -127,9 +130,18 @@ def save_checkpoint(
resume. Defaults to None (not recorded). resume. Defaults to None (not recorded).
batch_size (int | None, optional): Per-process batch size to record for sample-exact batch_size (int | None, optional): Per-process batch size to record for sample-exact
resume. Defaults to None (not recorded). resume. Defaults to None (not recorded).
model_state_dict: Pre-gathered full (unsharded) model state dict. Required under FSDP,
where `policy.state_dict()` would return sharded tensors; the caller gathers it via a
cross-rank collective and passes it here so rank 0 can write it directly. It holds
FSDP's fp32 master weights and is saved as-is (the loader casts to the policy dtype on
read). When None (DDP / single-GPU), the model is saved the normal way. Defaults to None.
optim_state_dict: Pre-gathered full (unsharded) optimizer state dict. Required under FSDP
(gathered alongside `model_state_dict` via `gather_fsdp_state_dicts`); saved in the same
safetensors format as the single-GPU path. When None, `optimizer.state_dict()` is used.
Defaults to None.
""" """
pretrained_dir = checkpoint_dir / PRETRAINED_MODEL_DIR pretrained_dir = checkpoint_dir / PRETRAINED_MODEL_DIR
policy.save_pretrained(pretrained_dir) policy.save_pretrained(pretrained_dir, state_dict=model_state_dict)
cfg.save_pretrained(pretrained_dir) cfg.save_pretrained(pretrained_dir)
if cfg.peft is not None: if cfg.peft is not None:
# When using PEFT, policy.save_pretrained will only write the adapter weights + config, not the # When using PEFT, policy.save_pretrained will only write the adapter weights + config, not the
@@ -140,7 +152,13 @@ def save_checkpoint(
if postprocessor is not None: if postprocessor is not None:
postprocessor.save_pretrained(pretrained_dir) postprocessor.save_pretrained(pretrained_dir)
save_training_state( save_training_state(
checkpoint_dir, step, optimizer, scheduler, num_processes=num_processes, batch_size=batch_size checkpoint_dir,
step,
optimizer,
scheduler,
num_processes=num_processes,
batch_size=batch_size,
optim_state_dict=optim_state_dict,
) )
@@ -151,6 +169,7 @@ def save_training_state(
scheduler: LRScheduler | None = None, scheduler: LRScheduler | None = None,
num_processes: int | None = None, num_processes: int | None = None,
batch_size: int | None = None, batch_size: int | None = None,
optim_state_dict: dict | None = None,
) -> None: ) -> None:
""" """
Saves the training step, optimizer state, scheduler state, and rng state. Saves the training step, optimizer state, scheduler state, and rng state.
@@ -164,19 +183,21 @@ def save_training_state(
Defaults to None. Defaults to None.
num_processes (int | None, optional): Distributed world size to record. Defaults to None. num_processes (int | None, optional): Distributed world size to record. Defaults to None.
batch_size (int | None, optional): Per-process batch size to record. Defaults to None. batch_size (int | None, optional): Per-process batch size to record. Defaults to None.
optim_state_dict: Pre-gathered full optimizer state dict (for FSDP). Saved instead of
`optimizer.state_dict()` when provided. Defaults to None.
""" """
save_dir = checkpoint_dir / TRAINING_STATE_DIR save_dir = checkpoint_dir / TRAINING_STATE_DIR
save_dir.mkdir(parents=True, exist_ok=True) save_dir.mkdir(parents=True, exist_ok=True)
save_training_step(train_step, save_dir, num_processes=num_processes, batch_size=batch_size) save_training_step(train_step, save_dir, num_processes=num_processes, batch_size=batch_size)
save_rng_state(save_dir) save_rng_state(save_dir)
if optimizer is not None: if optimizer is not None:
save_optimizer_state(optimizer, save_dir) save_optimizer_state(optimizer, save_dir, optim_state_dict=optim_state_dict)
if scheduler is not None: if scheduler is not None:
save_scheduler_state(scheduler, save_dir) save_scheduler_state(scheduler, save_dir)
def load_training_state( def load_training_state(
checkpoint_dir: Path, optimizer: Optimizer, scheduler: LRScheduler | None checkpoint_dir: Path, optimizer: Optimizer, scheduler: LRScheduler | None, load_optimizer: bool = True
) -> tuple[int, Optimizer, LRScheduler | None]: ) -> tuple[int, Optimizer, LRScheduler | None]:
""" """
Loads the training step, optimizer state, scheduler state, and rng state. Loads the training step, optimizer state, scheduler state, and rng state.
@@ -186,6 +207,10 @@ def load_training_state(
checkpoint_dir (Path): The checkpoint directory. Should contain a 'training_state' dir. checkpoint_dir (Path): The checkpoint directory. Should contain a 'training_state' dir.
optimizer (Optimizer): The optimizer to load the state_dict to. optimizer (Optimizer): The optimizer to load the state_dict to.
scheduler (LRScheduler | None): The scheduler to load the state_dict to (can be None). scheduler (LRScheduler | None): The scheduler to load the state_dict to (can be None).
load_optimizer (bool, optional): Whether to load the optimizer state from disk. Defaults to
True. Set to False under FSDP, where the sharded optimizer state must be loaded after
`accelerator.prepare()` via `load_fsdp_optimizer_state` (the optimizer is returned
untouched here).
Raises: Raises:
NotADirectoryError: If 'checkpoint_dir' doesn't contain a 'training_state' dir NotADirectoryError: If 'checkpoint_dir' doesn't contain a 'training_state' dir
@@ -200,8 +225,61 @@ def load_training_state(
load_rng_state(training_state_dir) load_rng_state(training_state_dir)
step = load_training_step(training_state_dir) step = load_training_step(training_state_dir)
optimizer = load_optimizer_state(optimizer, training_state_dir) if load_optimizer:
optimizer = load_optimizer_state(optimizer, training_state_dir)
if scheduler is not None: if scheduler is not None:
scheduler = load_scheduler_state(scheduler, training_state_dir) scheduler = load_scheduler_state(scheduler, training_state_dir)
return step, optimizer, scheduler return step, optimizer, scheduler
def gather_fsdp_state_dicts(model, optimizer) -> tuple[dict, dict]:
"""Gather the full (unsharded) model and optimizer state dicts under FSDP.
`model.state_dict()` and `FSDP.optim_state_dict(...)` are cross-rank collectives, so this must be
called on *every* rank with the prepared (FSDP-wrapped) `model` and `optimizer`. With
`rank0_only=True` and `offload_to_cpu=True`, every rank runs the all-gather but only rank 0
materializes the full dicts (the others get empty dicts) and they are kept on CPU to bound GPU
memory. The returned optimizer state dict is keyed by parameter FQNs and is world-size
independent; `load_fsdp_optimizer_state` reshards it on resume.
Returns:
(model_state_dict, optim_state_dict): full dicts on rank 0, empty dicts on other ranks.
"""
from torch.distributed.fsdp import (
FullOptimStateDictConfig,
FullStateDictConfig,
FullyShardedDataParallel as FSDP, # noqa F401
StateDictType,
)
state_cfg = FullStateDictConfig(offload_to_cpu=True, rank0_only=True)
optim_cfg = FullOptimStateDictConfig(offload_to_cpu=True, rank0_only=True)
with FSDP.state_dict_type(model, StateDictType.FULL_STATE_DICT, state_cfg, optim_cfg):
model_state_dict = model.state_dict()
optim_state_dict = FSDP.optim_state_dict(model, optimizer)
return model_state_dict, optim_state_dict
def load_fsdp_optimizer_state(model, optimizer, checkpoint_dir: Path) -> None:
"""Load the FSDP optimizer state (saved as safetensors) and reshard it into the optimizer.
This is a cross-rank collective and must be called on every rank *after* `accelerator.prepare()`
with the prepared (FSDP-wrapped) `model` and `optimizer`. The saved state is the full,
world-size-independent optimizer state (keyed by parameter FQNs); `FSDP.optim_state_dict_to_load`
reshards it to the current FSDP topology, so resume on a different number of GPUs works.
"""
from torch.distributed.fsdp import (
FullOptimStateDictConfig,
FullStateDictConfig,
FullyShardedDataParallel as FSDP, # noqa F401
StateDictType,
)
# Every rank reads the same full state from the (shared) checkpoint dir, so rank0_only=False.
full_osd = load_optimizer_state_dict(checkpoint_dir / TRAINING_STATE_DIR)
state_cfg = FullStateDictConfig(rank0_only=False)
optim_cfg = FullOptimStateDictConfig(rank0_only=False)
with FSDP.state_dict_type(model, StateDictType.FULL_STATE_DICT, state_cfg, optim_cfg):
sharded_osd = FSDP.optim_state_dict_to_load(model=model, optim=optimizer, optim_state_dict=full_osd)
optimizer.load_state_dict(sharded_osd)
+11 -9
View File
@@ -180,24 +180,26 @@ class WandBLogger:
self._wandb_custom_step_key.add(new_custom_key) self._wandb_custom_step_key.add(new_custom_key)
self._wandb.define_metric(new_custom_key, hidden=True) self._wandb.define_metric(new_custom_key, hidden=True)
batch_data = {}
for k, v in d.items(): for k, v in d.items():
# Skip the custom step key here, it's added to the batch below.
if custom_step_key is not None and k == custom_step_key:
continue
if not isinstance(v, (int | float | str)): if not isinstance(v, (int | float | str)):
logging.warning( logging.warning(
f'WandB logging of key "{k}" was ignored as its type "{type(v)}" is not handled by this wrapper.' f'WandB logging of key "{k}" was ignored as its type "{type(v)}" is not handled by this wrapper.'
) )
continue continue
# Do not log the custom step key itself. batch_data[f"{mode}/{k}"] = v
if self._wandb_custom_step_key is not None and k in self._wandb_custom_step_key:
continue
if batch_data:
if custom_step_key is not None: if custom_step_key is not None:
value_custom_step = d[custom_step_key] batch_data[f"{mode}/{custom_step_key}"] = d[custom_step_key]
data = {f"{mode}/{k}": v, f"{mode}/{custom_step_key}": value_custom_step} self._wandb.log(batch_data)
self._wandb.log(data) else:
continue self._wandb.log(data=batch_data, step=step)
self._wandb.log(data={f"{mode}/{k}": v}, step=step)
def log_video(self, video_path: str, step: int, mode: str = "train"): def log_video(self, video_path: str, step: int, mode: str = "train"):
if mode not in {"train", "eval"}: if mode not in {"train", "eval"}:
+9
View File
@@ -73,8 +73,17 @@ class EvalConfig:
# `use_async_envs` specifies whether to use asynchronous environments (multiprocessing). # `use_async_envs` specifies whether to use asynchronous environments (multiprocessing).
# Defaults to True; automatically downgraded to SyncVectorEnv when batch_size=1. # Defaults to True; automatically downgraded to SyncVectorEnv when batch_size=1.
use_async_envs: bool = True use_async_envs: bool = True
# Whether to record eval rollouts as a LeRobot dataset on disk.
recording: bool = False
# If set, push recorded eval datasets to the Hub under this repo id (one repo per task,
# suffixed by task and env index). Requires recording=true.
recording_repo_id: str | None = None
# Whether the pushed recording repositories should be private.
recording_private: bool = False
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.recording_repo_id is not None and not self.recording:
raise ValueError("eval.recording_repo_id requires eval.recording=true.")
if self.batch_size == 0: if self.batch_size == 0:
self.batch_size = self._auto_batch_size() self.batch_size = self._auto_batch_size()
if self.batch_size > self.n_episodes: if self.batch_size > self.n_episodes:
+2
View File
@@ -79,6 +79,8 @@ class PreTrainedConfig(draccus.ChoiceRegistry, HubMixin, abc.ABC): # type: igno
# Either the repo ID of a model hosted on the Hub or a path to a directory containing weights # Either the repo ID of a model hosted on the Hub or a path to a directory containing weights
# saved using `Policy.save_pretrained`. If not provided, the policy is initialized from scratch. # saved using `Policy.save_pretrained`. If not provided, the policy is initialized from scratch.
pretrained_path: Path | None = None pretrained_path: Path | None = None
# Optional Hub revision (commit hash, branch, or tag) to pin the pretrained model version.
pretrained_revision: str | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not self.device or not is_torch_device_available(self.device): if not self.device or not is_torch_device_available(self.device):
+2
View File
@@ -56,6 +56,8 @@ class RewardModelConfig(draccus.ChoiceRegistry, HubMixin, abc.ABC):
device: str | None = None device: str | None = None
pretrained_path: str | None = None pretrained_path: str | None = None
# Optional Hub revision (commit hash, branch, or tag) to pin the pretrained reward model version.
pretrained_revision: str | None = None
push_to_hub: bool = False push_to_hub: bool = False
repo_id: str | None = None repo_id: str | None = None
+2 -1
View File
@@ -15,6 +15,7 @@
# limitations under the License. # limitations under the License.
import contextlib import contextlib
from collections.abc import Callable from collections.abc import Callable
from copy import deepcopy
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
@@ -709,7 +710,7 @@ class LeRobotDatasetMetadata:
obj.root.mkdir(parents=True, exist_ok=False) obj.root.mkdir(parents=True, exist_ok=False)
features = {**features, **DEFAULT_FEATURES} features = {**deepcopy(features), **DEFAULT_FEATURES}
_validate_feature_names(features) _validate_feature_names(features)
obj.tasks = None obj.tasks = None
+12
View File
@@ -74,6 +74,8 @@ class DatasetReader:
self.episodes = episodes self.episodes = episodes
self._tolerance_s = tolerance_s self._tolerance_s = tolerance_s
self._video_backend = video_backend self._video_backend = video_backend
if image_transforms is not None and not callable(image_transforms):
raise TypeError("image_transforms must be callable or None.")
self._image_transforms = image_transforms self._image_transforms = image_transforms
self._return_uint8 = return_uint8 self._return_uint8 = return_uint8
@@ -86,6 +88,16 @@ class DatasetReader:
check_delta_timestamps(delta_timestamps, meta.fps, tolerance_s) check_delta_timestamps(delta_timestamps, meta.fps, tolerance_s)
self.delta_indices = get_delta_indices(delta_timestamps, meta.fps) self.delta_indices = get_delta_indices(delta_timestamps, meta.fps)
def set_image_transforms(self, image_transforms: Callable | None) -> None:
"""Replace the transform applied to visual observations."""
if image_transforms is not None and not callable(image_transforms):
raise TypeError("image_transforms must be callable or None.")
self._image_transforms = image_transforms
def clear_image_transforms(self) -> None:
"""Remove the transform applied to visual observations."""
self._image_transforms = None
def try_load(self) -> bool: def try_load(self) -> bool:
"""Attempt to load from local cache. Returns True if data is sufficient.""" """Attempt to load from local cache. Returns True if data is sufficient."""
try: try:
+4 -1
View File
@@ -27,6 +27,7 @@ import logging
import shutil import shutil
from collections.abc import Callable from collections.abc import Callable
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
from copy import deepcopy
from pathlib import Path from pathlib import Path
import datasets import datasets
@@ -1101,7 +1102,9 @@ def _copy_episodes_metadata_and_stats(
if dst_meta.video_keys and src_dataset.meta.video_keys: if dst_meta.video_keys and src_dataset.meta.video_keys:
for key in dst_meta.video_keys: for key in dst_meta.video_keys:
if key in src_dataset.meta.features: if key in src_dataset.meta.features:
dst_meta.info.features[key]["info"] = src_dataset.meta.info.features[key].get("info", {}) dst_meta.info.features[key]["info"] = deepcopy(
src_dataset.meta.info.features[key].get("info", {})
)
write_info(dst_meta.info, dst_meta.root) write_info(dst_meta.info, dst_meta.root)
+1 -1
View File
@@ -154,7 +154,7 @@ def cast_stats_to_numpy(stats: dict) -> dict[str, dict[str, np.ndarray]]:
Returns: Returns:
dict: The statistics dictionary with values cast to numpy arrays. dict: The statistics dictionary with values cast to numpy arrays.
""" """
stats = {key: np.array(value) for key, value in flatten_dict(stats).items()} stats = {key: np.atleast_1d(np.array(value)) for key, value in flatten_dict(stats).items()}
return unflatten_dict(stats) return unflatten_dict(stats)
+5 -7
View File
@@ -201,8 +201,6 @@ class LeRobotDataset(torch.utils.data.Dataset):
super().__init__() super().__init__()
self.repo_id = repo_id self.repo_id = repo_id
self._requested_root = Path(root) if root else None self._requested_root = Path(root) if root else None
self.reader = None
self.set_image_transforms(image_transforms)
self.delta_timestamps = delta_timestamps self.delta_timestamps = delta_timestamps
self.tolerance_s = tolerance_s self.tolerance_s = tolerance_s
self.revision = revision if revision else CODEBASE_VERSION self.revision = revision if revision else CODEBASE_VERSION
@@ -249,6 +247,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
image_transforms=image_transforms, image_transforms=image_transforms,
return_uint8=self._return_uint8, return_uint8=self._return_uint8,
) )
self.image_transforms = image_transforms
# Load actual data # Load actual data
if force_cache_sync or not self.reader.try_load(): if force_cache_sync or not self.reader.try_load():
@@ -505,15 +504,14 @@ class LeRobotDataset(torch.utils.data.Dataset):
def set_image_transforms(self, image_transforms: Callable | None) -> None: def set_image_transforms(self, image_transforms: Callable | None) -> None:
"""Replace the transform applied to visual observations.""" """Replace the transform applied to visual observations."""
if image_transforms is not None and not callable(image_transforms): self._ensure_reader().set_image_transforms(image_transforms)
raise TypeError("image_transforms must be callable or None.")
self.image_transforms = image_transforms self.image_transforms = image_transforms
if self.reader is not None:
self.reader._image_transforms = image_transforms
def clear_image_transforms(self) -> None: def clear_image_transforms(self) -> None:
"""Remove the transform applied to visual observations.""" """Remove the transform applied to visual observations."""
self.set_image_transforms(None) if self.reader is not None:
self.reader.set_image_transforms(None)
self.image_transforms = None
# ── Hub methods (stay on facade) ────────────────────────────────── # ── Hub methods (stay on facade) ──────────────────────────────────
+20
View File
@@ -126,6 +126,26 @@ def preprocess_observation(observations: dict[str, np.ndarray]) -> dict[str, Ten
if "camera_obs" in observations: if "camera_obs" in observations:
return_observations[f"{OBS_STR}.camera_obs"] = observations["camera_obs"] return_observations[f"{OBS_STR}.camera_obs"] = observations["camera_obs"]
# Pass through any remaining ndarray/tensor keys not already handled above,
# so env plugins can expose extra observation keys via get_env_processors().
_handled = {"pixels", "environment_state", "agent_pos", "robot_state", "policy", "camera_obs"}
for key, value in observations.items():
if key in _handled:
continue
target = f"{OBS_STR}.{key}"
if target in return_observations:
continue
if isinstance(value, np.ndarray):
val = torch.from_numpy(value).float()
if val.dim() == 1:
val = val.unsqueeze(0)
return_observations[target] = val
elif isinstance(value, Tensor):
val = value.float()
if val.dim() == 1:
val = val.unsqueeze(0)
return_observations[target] = val
return return_observations return return_observations
+2
View File
@@ -20,6 +20,7 @@ from .optimizers import (
SGDConfig as SGDConfig, SGDConfig as SGDConfig,
XVLAAdamWConfig as XVLAAdamWConfig, XVLAAdamWConfig as XVLAAdamWConfig,
load_optimizer_state, load_optimizer_state,
load_optimizer_state_dict,
save_optimizer_state, save_optimizer_state,
) )
from .schedulers import ( from .schedulers import (
@@ -50,6 +51,7 @@ __all__ = [
"VQBeTSchedulerConfig", "VQBeTSchedulerConfig",
# State management # State management
"load_optimizer_state", "load_optimizer_state",
"load_optimizer_state_dict",
"load_scheduler_state", "load_scheduler_state",
"save_optimizer_state", "save_optimizer_state",
"save_scheduler_state", "save_scheduler_state",
+30 -5
View File
@@ -27,7 +27,7 @@ from lerobot.utils.constants import (
OPTIMIZER_PARAM_GROUPS, OPTIMIZER_PARAM_GROUPS,
OPTIMIZER_STATE, OPTIMIZER_STATE,
) )
from lerobot.utils.io_utils import deserialize_json_into_object, write_json from lerobot.utils.io_utils import deserialize_json_into_object, load_json, write_json
from lerobot.utils.utils import flatten_dict, unflatten_dict from lerobot.utils.utils import flatten_dict, unflatten_dict
# Type alias for parameters accepted by optimizer build() methods. # Type alias for parameters accepted by optimizer build() methods.
@@ -281,28 +281,37 @@ class MultiAdamConfig(OptimizerConfig):
def save_optimizer_state( def save_optimizer_state(
optimizer: torch.optim.Optimizer | dict[str, torch.optim.Optimizer], save_dir: Path optimizer: torch.optim.Optimizer | dict[str, torch.optim.Optimizer],
save_dir: Path,
optim_state_dict: dict | None = None,
) -> None: ) -> None:
"""Save optimizer state to disk. """Save optimizer state to disk.
Args: Args:
optimizer: Either a single optimizer or a dictionary of optimizers. optimizer: Either a single optimizer or a dictionary of optimizers.
save_dir: Directory to save the optimizer state. save_dir: Directory to save the optimizer state.
optim_state_dict: Pre-gathered optimizer state dict (for FSDP, where the sharded state must
be gathered across ranks first). If provided, it is saved directly instead of calling
``optimizer.state_dict()``. Only supported for a single optimizer. Defaults to None.
""" """
if isinstance(optimizer, dict): if isinstance(optimizer, dict):
# Handle dictionary of optimizers # Handle dictionary of optimizers
if optim_state_dict is not None:
raise ValueError("optim_state_dict is not supported for a dict of optimizers")
for name, opt in optimizer.items(): for name, opt in optimizer.items():
optimizer_dir = save_dir / name optimizer_dir = save_dir / name
optimizer_dir.mkdir(exist_ok=True, parents=True) optimizer_dir.mkdir(exist_ok=True, parents=True)
_save_single_optimizer_state(opt, optimizer_dir) _save_single_optimizer_state(opt, optimizer_dir)
else: else:
# Handle single optimizer # Handle single optimizer
_save_single_optimizer_state(optimizer, save_dir) _save_single_optimizer_state(optimizer, save_dir, optim_state_dict=optim_state_dict)
def _save_single_optimizer_state(optimizer: torch.optim.Optimizer, save_dir: Path) -> None: def _save_single_optimizer_state(
optimizer: torch.optim.Optimizer, save_dir: Path, optim_state_dict: dict | None = None
) -> None:
"""Save a single optimizer's state to disk.""" """Save a single optimizer's state to disk."""
state = optimizer.state_dict() state = dict(optim_state_dict) if optim_state_dict is not None else optimizer.state_dict()
param_groups = state.pop("param_groups") param_groups = state.pop("param_groups")
flat_state = flatten_dict(state) flat_state = flatten_dict(state)
save_file(flat_state, save_dir / OPTIMIZER_STATE) save_file(flat_state, save_dir / OPTIMIZER_STATE)
@@ -356,3 +365,19 @@ def _load_single_optimizer_state(optimizer: torch.optim.Optimizer, save_dir: Pat
optimizer.load_state_dict(loaded_state_dict) optimizer.load_state_dict(loaded_state_dict)
return optimizer return optimizer
def load_optimizer_state_dict(save_dir: Path) -> dict:
"""Read a saved optimizer state dict (safetensors + json) back into a plain dict.
Unlike `load_optimizer_state`, this does not load into an optimizer and preserves the original
``state`` keys verbatim (e.g. FSDP parameter FQNs, which are not integer-castable). It is used by
the FSDP resume path, where the full state must be resharded via `FSDP.optim_state_dict_to_load`
before being loaded into the (sharded) optimizer.
"""
flat_state = load_file(save_dir / OPTIMIZER_STATE)
state = unflatten_dict(flat_state)
return {
"state": state.get("state", {}),
"param_groups": load_json(save_dir / OPTIMIZER_PARAM_GROUPS),
}
+1 -1
View File
@@ -148,7 +148,7 @@ class ACTPolicy(PreTrainedPolicy):
l1_loss = (abs_err * valid_mask).sum() / num_valid.clamp_min(1) l1_loss = (abs_err * valid_mask).sum() / num_valid.clamp_min(1)
loss_dict = {"l1_loss": l1_loss.item()} loss_dict = {"l1_loss": l1_loss.item()}
if self.config.use_vae: if self.config.use_vae and log_sigma_x2_hat is not None:
# Calculate Dₖₗ(latent_pdf || standard_normal). Note: After computing the KL-divergence for # Calculate Dₖₗ(latent_pdf || standard_normal). Note: After computing the KL-divergence for
# each dimension independently, we sum over the latent dimension to get the total # each dimension independently, we sum over the latent dimension to get the total
# KL-divergence per batch element, then take the mean over the batch. # KL-divergence per batch element, then take the mean over the batch.
@@ -101,11 +101,23 @@ class DiffusionPolicy(PreTrainedPolicy):
@torch.no_grad() @torch.no_grad()
def predict_action_chunk(self, batch: dict[str, Tensor], noise: Tensor | None = None) -> Tensor: def predict_action_chunk(self, batch: dict[str, Tensor], noise: Tensor | None = None) -> Tensor:
"""Predict a chunk of actions given environment observations.""" """Predict a chunk of actions given environment observations.
# stack n latest observations from the queue
batch = {k: torch.stack(list(self._queues[k]), dim=1) for k in batch if k in self._queues}
actions = self.diffusion.generate_actions(batch, noise=noise)
Supports two modes:
- Online (queues populated via select_action): stacks observations from internal queues.
- Offline (empty queues, e.g. dataloader batch): uses the batch directly.
"""
queues_populated = any(len(q) > 0 for q in self._queues.values())
if queues_populated:
batch = {k: torch.stack(list(self._queues[k]), dim=1) for k in batch if k in self._queues}
else:
batch = dict(batch)
if self.config.image_features:
for key in self.config.image_features:
if batch[key].ndim == 4:
batch[key] = batch[key].unsqueeze(1)
batch[OBS_IMAGES] = torch.stack([batch[key] for key in self.config.image_features], dim=-4)
actions = self.diffusion.generate_actions(batch, noise=noise)
return actions return actions
@torch.no_grad() @torch.no_grad()
+4
View File
@@ -252,6 +252,7 @@ class ProcessorConfigKwargs(TypedDict, total=False):
def make_pre_post_processors( def make_pre_post_processors(
policy_cfg: PreTrainedConfig, policy_cfg: PreTrainedConfig,
pretrained_path: str | None = None, pretrained_path: str | None = None,
pretrained_revision: str | None = None,
**kwargs: Unpack[ProcessorConfigKwargs], **kwargs: Unpack[ProcessorConfigKwargs],
) -> tuple[ ) -> tuple[
PolicyProcessorPipeline[dict[str, Any], dict[str, Any]], PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
@@ -309,6 +310,7 @@ def make_pre_post_processors(
overrides=kwargs.get("preprocessor_overrides", {}), overrides=kwargs.get("preprocessor_overrides", {}),
to_transition=batch_to_transition, to_transition=batch_to_transition,
to_output=transition_to_batch, to_output=transition_to_batch,
revision=pretrained_revision,
) )
postprocessor = PolicyProcessorPipeline.from_pretrained( postprocessor = PolicyProcessorPipeline.from_pretrained(
pretrained_model_name_or_path=pretrained_path, pretrained_model_name_or_path=pretrained_path,
@@ -318,6 +320,7 @@ def make_pre_post_processors(
overrides=kwargs.get("postprocessor_overrides", {}), overrides=kwargs.get("postprocessor_overrides", {}),
to_transition=policy_action_to_transition, to_transition=policy_action_to_transition,
to_output=transition_to_policy_action, to_output=transition_to_policy_action,
revision=pretrained_revision,
) )
_reconnect_relative_absolute_steps(preprocessor, postprocessor) _reconnect_relative_absolute_steps(preprocessor, postprocessor)
return preprocessor, postprocessor return preprocessor, postprocessor
@@ -557,6 +560,7 @@ def make_policy(
# Load a pretrained policy and override the config if needed (for example, if there are inference-time # Load a pretrained policy and override the config if needed (for example, if there are inference-time
# hyperparameters that we want to vary). # hyperparameters that we want to vary).
kwargs["pretrained_name_or_path"] = cfg.pretrained_path kwargs["pretrained_name_or_path"] = cfg.pretrained_path
kwargs["revision"] = cfg.pretrained_revision
policy = policy_cls.from_pretrained(**kwargs) policy = policy_cls.from_pretrained(**kwargs)
elif cfg.pretrained_path and cfg.use_peft: elif cfg.pretrained_path and cfg.use_peft:
# Load a pretrained PEFT model on top of the policy. The pretrained path points to the folder/repo # Load a pretrained PEFT model on top of the policy. The pretrained path points to the folder/repo
+39 -4
View File
@@ -23,7 +23,7 @@ from typing import TypedDict, TypeVar, Unpack
import packaging import packaging
import safetensors import safetensors
from huggingface_hub import HfApi, ModelCard, ModelCardData, hf_hub_download from huggingface_hub import HfApi, ModelCard, ModelCardData, hf_hub_download, save_torch_state_dict
from huggingface_hub.constants import SAFETENSORS_SINGLE_FILE from huggingface_hub.constants import SAFETENSORS_SINGLE_FILE
from huggingface_hub.errors import HfHubHTTPError from huggingface_hub.errors import HfHubHTTPError
from safetensors.torch import load_model as load_model_as_safetensor, save_model as save_model_as_safetensor from safetensors.torch import load_model as load_model_as_safetensor, save_model as save_model_as_safetensor
@@ -129,10 +129,43 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
if not getattr(cls, "name", None): if not getattr(cls, "name", None):
raise TypeError(f"Class {cls.__name__} must define 'name'") raise TypeError(f"Class {cls.__name__} must define 'name'")
def _save_pretrained(self, save_directory: Path) -> None: def save_pretrained(
self,
save_directory: str | Path,
*,
state_dict: dict[str, Tensor] | None = None,
repo_id: str | None = None,
push_to_hub: bool = False,
card_kwargs: dict | None = None,
**push_to_hub_kwargs,
) -> str | None:
"""Save the policy to a directory (and optionally push to the Hub).
Overrides `HubMixin.save_pretrained` to add a `state_dict` argument (mirroring
`transformers.PreTrainedModel.save_pretrained`). Under FSDP, `self.state_dict()` would
return sharded tensors, so the caller gathers the full state dict via a cross-rank
collective and passes it here for `_save_pretrained` to write directly.
"""
save_directory = Path(save_directory)
save_directory.mkdir(parents=True, exist_ok=True)
self._save_pretrained(save_directory, state_dict=state_dict)
if push_to_hub:
if repo_id is None:
repo_id = save_directory.name
return self.push_to_hub(repo_id=repo_id, card_kwargs=card_kwargs, **push_to_hub_kwargs)
return None
def _save_pretrained(self, save_directory: Path, state_dict: dict[str, Tensor] | None = None) -> None:
self.config._save_pretrained(save_directory) self.config._save_pretrained(save_directory)
model_to_save = self.module if hasattr(self, "module") else self model_to_save = self.module if hasattr(self, "module") else self
save_model_as_safetensor(model_to_save, str(save_directory / SAFETENSORS_SINGLE_FILE)) if state_dict is None:
save_model_as_safetensor(model_to_save, str(save_directory / SAFETENSORS_SINGLE_FILE))
return
# A pre-gathered (e.g. FSDP full) state dict was supplied: write it directly.
# `save_torch_state_dict` discards shared-tensor duplicates just like `save_model` does;
# pin `max_shard_size` above the total size so the output stays a single `model.safetensors`
total_bytes = sum(t.numel() * t.element_size() for t in state_dict.values())
save_torch_state_dict(state_dict, str(save_directory), max_shard_size=max(total_bytes, 1))
@classmethod @classmethod
def from_pretrained( def from_pretrained(
@@ -270,6 +303,7 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
self, self,
cfg: TrainPipelineConfig, cfg: TrainPipelineConfig,
peft_model=None, peft_model=None,
state_dict: dict[str, Tensor] | None = None,
): ):
api = HfApi() api = HfApi()
repo_id = api.create_repo( repo_id = api.create_repo(
@@ -287,7 +321,8 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
peft_model.save_pretrained(saved_path) peft_model.save_pretrained(saved_path)
self.config.save_pretrained(saved_path) self.config.save_pretrained(saved_path)
else: else:
self.save_pretrained(saved_path) # Calls _save_pretrained and stores model tensors # Calls _save_pretrained and stores model tensors
self.save_pretrained(saved_path, state_dict=state_dict)
card = self.generate_model_card( card = self.generate_model_card(
cfg.dataset.repo_id, self.config.type, self.config.license, self.config.tags, cfg=cfg cfg.dataset.repo_id, self.config.type, self.config.license, self.config.tags, cfg=cfg
+1
View File
@@ -124,6 +124,7 @@ def make_reward_model(cfg: RewardModelConfig, **kwargs) -> PreTrainedRewardModel
if cfg.pretrained_path: if cfg.pretrained_path:
kwargs["pretrained_name_or_path"] = cfg.pretrained_path kwargs["pretrained_name_or_path"] = cfg.pretrained_path
kwargs["revision"] = cfg.pretrained_revision
reward_model = reward_cls.from_pretrained(**kwargs) reward_model = reward_cls.from_pretrained(**kwargs)
else: else:
reward_model = reward_cls(**kwargs) reward_model = reward_cls(**kwargs)
+23 -67
View File
@@ -47,8 +47,6 @@ from __future__ import annotations
import contextlib import contextlib
import enum import enum
import logging import logging
import os
import sys
import time import time
from concurrent.futures import Future, ThreadPoolExecutor from concurrent.futures import Future, ThreadPoolExecutor
from threading import Event, Lock from threading import Event, Lock
@@ -58,7 +56,6 @@ import numpy as np
from lerobot.common.control_utils import ( from lerobot.common.control_utils import (
follower_smooth_move_to, follower_smooth_move_to,
is_headless,
teleop_smooth_move_to, teleop_smooth_move_to,
teleop_supports_feedback, teleop_supports_feedback,
) )
@@ -66,7 +63,7 @@ from lerobot.datasets import VideoEncodingManager
from lerobot.datasets.utils import DEFAULT_VIDEO_FILE_SIZE_IN_MB from lerobot.datasets.utils import DEFAULT_VIDEO_FILE_SIZE_IN_MB
from lerobot.utils.constants import ACTION, OBS_STR from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import build_dataset_frame from lerobot.utils.feature_utils import build_dataset_frame
from lerobot.utils.import_utils import _pynput_available from lerobot.utils.keyboard_input import create_key_listener
from lerobot.utils.pedal import start_pedal_listener from lerobot.utils.pedal import start_pedal_listener
from lerobot.utils.robot_utils import precise_sleep from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
@@ -75,19 +72,6 @@ from ..configs import DAggerKeyboardConfig, DAggerPedalConfig, DAggerStrategyCon
from ..context import RolloutContext from ..context import RolloutContext
from .core import RolloutStrategy, estimate_max_episode_seconds, safe_push_to_hub, send_next_action from .core import RolloutStrategy, estimate_max_episode_seconds, safe_push_to_hub, send_next_action
PYNPUT_AVAILABLE = _pynput_available
keyboard = None
if PYNPUT_AVAILABLE:
try:
if ("DISPLAY" not in os.environ) and ("linux" in sys.platform):
logging.info("No DISPLAY set. Skipping pynput import.")
PYNPUT_AVAILABLE = False
else:
from pynput import keyboard
except Exception as e:
PYNPUT_AVAILABLE = False
logging.info(f"Could not import pynput: {e}")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -180,64 +164,36 @@ class DAggerEvents:
def _init_dagger_keyboard(events: DAggerEvents, cfg: DAggerKeyboardConfig): def _init_dagger_keyboard(events: DAggerEvents, cfg: DAggerKeyboardConfig):
"""Initialise keyboard listener with DAgger 3-key controls. """Initialise a keyboard listener for DAgger's 3 controls.
Returns the pynput Listener (or ``None`` in headless mode or when Backend selection (pynput on X11 / trusted-macOS / Windows, a terminal reader on
pynput is unavailable). Wayland / headless TTY) is delegated to :func:`create_key_listener`. Returns the
listener (exposing ``stop()``) or ``None`` when no keyboard backend is usable.
""" """
if not PYNPUT_AVAILABLE or is_headless(): # Map config key names to DAgger event names.
logger.warning("Headless environment or pynput unavailable — keyboard controls disabled")
return None
# Map config key names to pynput Key objects for special keys
special_keys = {
"space": keyboard.Key.space,
"tab": keyboard.Key.tab,
"enter": keyboard.Key.enter,
}
def _resolve_key(key) -> str | None:
"""Resolve a pynput key event to a config-comparable string."""
if key == keyboard.Key.esc:
return "esc"
for name, pynput_key in special_keys.items():
if key == pynput_key:
return name
if hasattr(key, "char") and key.char:
return key.char
return None
# Build mapping: resolved key string -> DAgger event name
key_to_event = { key_to_event = {
cfg.pause_resume: "pause_resume", cfg.pause_resume: "pause_resume",
cfg.correction: "correction", cfg.correction: "correction",
} }
def on_press(key): def dispatch(name: str) -> None:
try: """Apply a resolved key name to the DAgger events."""
resolved = _resolve_key(key) if name == "esc":
if resolved is None: logger.info("Stop recording...")
return events.stop_recording.set()
if resolved == "esc": return
logger.info("Stop recording...") if name in key_to_event:
events.stop_recording.set() events.request_transition(key_to_event[name])
return if name == cfg.upload:
if resolved in key_to_event: events.upload_requested.set()
events.request_transition(key_to_event[resolved])
if resolved == cfg.upload:
events.upload_requested.set()
except Exception as e:
logger.debug("Key error: %s", e)
listener = keyboard.Listener(on_press=on_press) return create_key_listener(
listener.start() dispatch,
logger.info( controls_help=(
"DAgger keyboard listener started (pause_resume='%s', correction='%s', upload='%s', ESC=stop)", f"pause_resume='{cfg.pause_resume}', correction='{cfg.correction}', "
cfg.pause_resume, f"upload='{cfg.upload}', ESC=stop"
cfg.correction, ),
cfg.upload,
) )
return listener
def _init_dagger_pedal(events: DAggerEvents, cfg: DAggerPedalConfig): def _init_dagger_pedal(events: DAggerEvents, cfg: DAggerPedalConfig):
@@ -328,7 +284,7 @@ class DAggerStrategy(RolloutStrategy):
logger.info("Stopping DAgger recording") logger.info("Stopping DAgger recording")
log_say("Stopping DAgger recording", play_sounds) log_say("Stopping DAgger recording", play_sounds)
if self._listener is not None and not is_headless(): if self._listener is not None:
logger.info("Stopping keyboard listener") logger.info("Stopping keyboard listener")
self._listener.stop() self._listener.stop()
+2 -3
View File
@@ -35,14 +35,13 @@ import time
from lerobot.common.control_utils import ( from lerobot.common.control_utils import (
follower_smooth_move_to, follower_smooth_move_to,
init_keyboard_listener,
is_headless,
teleop_smooth_move_to, teleop_smooth_move_to,
teleop_supports_feedback, teleop_supports_feedback,
) )
from lerobot.datasets import VideoEncodingManager from lerobot.datasets import VideoEncodingManager
from lerobot.utils.constants import ACTION, OBS_STR from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import build_dataset_frame from lerobot.utils.feature_utils import build_dataset_frame
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.robot_utils import precise_sleep from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import log_rerun_data from lerobot.utils.visualization_utils import log_rerun_data
@@ -307,7 +306,7 @@ class EpisodicStrategy(RolloutStrategy):
log_say("Stop recording", play_sounds, blocking=True) log_say("Stop recording", play_sounds, blocking=True)
if not is_headless() and self._listener is not None: if self._listener is not None:
self._listener.stop() self._listener.stop()
if ctx.data.dataset is not None: if ctx.data.dataset is not None:
+19 -39
View File
@@ -18,17 +18,14 @@ from __future__ import annotations
import contextlib import contextlib
import logging import logging
import os
import sys
import time import time
from concurrent.futures import Future, ThreadPoolExecutor from concurrent.futures import Future, ThreadPoolExecutor
from threading import Event as ThreadingEvent, Lock from threading import Event as ThreadingEvent, Lock
from lerobot.common.control_utils import is_headless
from lerobot.datasets import VideoEncodingManager from lerobot.datasets import VideoEncodingManager
from lerobot.utils.constants import ACTION, OBS_STR from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import build_dataset_frame from lerobot.utils.feature_utils import build_dataset_frame
from lerobot.utils.import_utils import _pynput_available, require_package from lerobot.utils.keyboard_input import create_key_listener
from lerobot.utils.robot_utils import precise_sleep from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import log_say from lerobot.utils.utils import log_say
@@ -37,19 +34,6 @@ from ..context import RolloutContext
from ..ring_buffer import RolloutRingBuffer from ..ring_buffer import RolloutRingBuffer
from .core import RolloutStrategy, safe_push_to_hub, send_next_action from .core import RolloutStrategy, safe_push_to_hub, send_next_action
PYNPUT_AVAILABLE = _pynput_available
keyboard = None
if PYNPUT_AVAILABLE:
try:
if ("DISPLAY" not in os.environ) and ("linux" in sys.platform):
logging.info("No DISPLAY set. Skipping pynput import.")
PYNPUT_AVAILABLE = False
else:
from pynput import keyboard
except Exception as e:
PYNPUT_AVAILABLE = False
logging.info(f"Could not import pynput: {e}")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -72,7 +56,6 @@ class HighlightStrategy(RolloutStrategy):
def __init__(self, config: HighlightStrategyConfig): def __init__(self, config: HighlightStrategyConfig):
super().__init__(config) super().__init__(config)
require_package("pynput", extra="pynput-dep")
self._ring: RolloutRingBuffer | None = None self._ring: RolloutRingBuffer | None = None
self._listener = None self._listener = None
self._save_requested = ThreadingEvent() self._save_requested = ThreadingEvent()
@@ -234,30 +217,27 @@ class HighlightStrategy(RolloutStrategy):
logger.info("Highlight strategy teardown complete") logger.info("Highlight strategy teardown complete")
def _setup_keyboard(self, shutdown_event: ThreadingEvent) -> None: def _setup_keyboard(self, shutdown_event: ThreadingEvent) -> None:
"""Set up keyboard listener for save and push keys.""" """Set up a keyboard listener for the save and push keys.
if is_headless():
logger.warning("Headless environment — highlight keys unavailable")
return
try: Backend selection (pynput on X11 / trusted-macOS / Windows, a terminal reader on
save_key = self.config.save_key Wayland / headless TTY) is delegated to :func:`create_key_listener`.
push_key = self.config.push_key """
save_key = self.config.save_key
push_key = self.config.push_key
def on_press(key): def dispatch(name: str) -> None:
with contextlib.suppress(Exception): """Apply a resolved key name to the highlight events."""
if hasattr(key, "char") and key.char == save_key: if name == save_key:
self._save_requested.set() self._save_requested.set()
elif hasattr(key, "char") and key.char == push_key: elif name == push_key:
self._push_requested.set() self._push_requested.set()
elif key == keyboard.Key.esc: elif name == "esc":
self._save_requested.clear() self._save_requested.clear()
shutdown_event.set() shutdown_event.set()
self._listener = keyboard.Listener(on_press=on_press) self._listener = create_key_listener(
self._listener.start() dispatch, controls_help=f"save='{save_key}', push='{push_key}', ESC=stop"
logger.info("Keyboard listener started (save='%s', push='%s', ESC=stop)", save_key, push_key) )
except ImportError:
logger.warning("pynput not available — keyboard listener disabled")
def _background_push(self, dataset, cfg) -> None: def _background_push(self, dataset, cfg) -> None:
"""Queue a Hub push on the single-worker executor.""" """Queue a Hub push on the single-worker executor."""
+239 -69
View File
@@ -72,8 +72,9 @@ from termcolor import colored
from torch import Tensor, nn from torch import Tensor, nn
from tqdm import trange from tqdm import trange
from lerobot.configs import parser from lerobot.configs import FeatureType, parser
from lerobot.configs.eval import EvalPipelineConfig from lerobot.configs.eval import EvalPipelineConfig
from lerobot.datasets.lerobot_dataset import LeRobotDataset
from lerobot.envs import ( from lerobot.envs import (
check_env_attributes_and_types, check_env_attributes_and_types,
close_envs, close_envs,
@@ -84,7 +85,7 @@ from lerobot.envs import (
from lerobot.policies import PreTrainedPolicy, make_policy, make_pre_post_processors from lerobot.policies import PreTrainedPolicy, make_policy, make_pre_post_processors
from lerobot.processor import PolicyProcessorPipeline from lerobot.processor import PolicyProcessorPipeline
from lerobot.types import PolicyAction from lerobot.types import PolicyAction
from lerobot.utils.constants import ACTION, DONE, OBS_STR, REWARD from lerobot.utils.constants import ACTION, DONE, OBS_IMAGE, OBS_IMAGES, OBS_STR, REWARD
from lerobot.utils.device_utils import get_safe_torch_device from lerobot.utils.device_utils import get_safe_torch_device
from lerobot.utils.import_utils import register_third_party_plugins from lerobot.utils.import_utils import register_third_party_plugins
from lerobot.utils.io_utils import write_video from lerobot.utils.io_utils import write_video
@@ -95,6 +96,65 @@ from lerobot.utils.utils import (
) )
def _env_features_to_dataset_features(env_features: dict) -> dict:
"""Convert EnvConfig.features to the dict format expected by LeRobotDataset.create()."""
features = {}
for key, ft in env_features.items():
shape = tuple(ft.shape)
if ft.type is FeatureType.VISUAL:
features[key] = {"dtype": "video", "shape": shape, "names": ["height", "width", "channel"]}
else:
features[key] = {"dtype": "float32", "shape": shape, "names": None}
features["next.reward"] = {"dtype": "float32", "shape": (1,), "names": None}
features["next.success"] = {"dtype": "bool", "shape": (1,), "names": None}
features["next.done"] = {"dtype": "bool", "shape": (1,), "names": None}
return features
def _build_raw_frame(
raw_obs: dict,
env_idx: int,
action: np.ndarray,
reward: float,
success: bool,
done: bool,
task: str,
env_features: dict,
) -> dict:
"""Build a dataset frame from raw env observations for one env index.
Keys in the frame match the keys in env_features so they align with the
dataset schema created by _env_features_to_dataset_features().
"""
frame: dict[str, Any] = {}
for key in env_features:
if key == ACTION:
continue
if key.startswith("next."):
continue
if "pixels" in raw_obs and isinstance(raw_obs["pixels"], dict):
for cam_name, img in raw_obs["pixels"].items():
candidate = f"{OBS_IMAGES}.{cam_name}"
if candidate == key:
frame[key] = img[env_idx]
if key in frame:
continue
if "pixels" in raw_obs and not isinstance(raw_obs["pixels"], dict) and key in ("pixels", OBS_IMAGE):
frame[key] = raw_obs["pixels"][env_idx]
continue
if key in raw_obs and isinstance(raw_obs[key], np.ndarray):
val = raw_obs[key][env_idx]
if val.dtype == np.float64:
val = val.astype(np.float32)
frame[key] = val
frame[ACTION] = action
frame["next.reward"] = np.atleast_1d(np.float32(reward))
frame["next.success"] = np.atleast_1d(np.bool_(success))
frame["next.done"] = np.atleast_1d(np.bool_(done))
frame["task"] = task
return frame
def rollout( def rollout(
env: gym.vector.VectorEnv, env: gym.vector.VectorEnv,
policy: PreTrainedPolicy, policy: PreTrainedPolicy,
@@ -105,6 +165,10 @@ def rollout(
seeds: list[int] | None = None, seeds: list[int] | None = None,
return_observations: bool = False, return_observations: bool = False,
render_callback: Callable[[gym.vector.VectorEnv], None] | None = None, render_callback: Callable[[gym.vector.VectorEnv], None] | None = None,
recording_dir: Path | None = None,
env_features: dict | None = None,
recording_repo_id: str | None = None,
recording_private: bool = False,
) -> dict: ) -> dict:
"""Run a batched policy rollout once through a batch of environments. """Run a batched policy rollout once through a batch of environments.
@@ -145,6 +209,33 @@ def rollout(
if render_callback is not None: if render_callback is not None:
render_callback(env) render_callback(env)
recording_datasets: list[LeRobotDataset] | None = None
raw_observation = None
task_desc = ""
if recording_dir is not None and env_features is not None:
features = _env_features_to_dataset_features(env_features)
fps = env.unwrapped.metadata.get("render_fps", 30)
recording_datasets = []
multi_env = env.num_envs > 1
base_repo_id = recording_repo_id or "eval_recording"
for i in range(env.num_envs):
root = str(recording_dir / f"env_{i}") if multi_env else str(recording_dir)
repo_id = f"{base_repo_id}_env_{i}" if multi_env else base_repo_id
recording_datasets.append(
LeRobotDataset.create(
repo_id=repo_id,
fps=fps,
features=features,
root=root,
use_videos=True,
)
)
raw_observation = deepcopy(observation)
try:
task_desc = list(env.call("task_description"))[0]
except (AttributeError, NotImplementedError):
task_desc = ""
all_observations = [] all_observations = []
all_actions = [] all_actions = []
all_rewards = [] all_rewards = []
@@ -162,80 +253,112 @@ def rollout(
leave=False, leave=False,
) )
check_env_attributes_and_types(env) check_env_attributes_and_types(env)
while not np.all(done) and step < max_steps: try:
# Numpy array to tensor and changing dictionary keys to LeRobot policy format. while not np.all(done) and step < max_steps:
observation = preprocess_observation(observation) # Numpy array to tensor and changing dictionary keys to LeRobot policy format.
if return_observations: observation = preprocess_observation(observation)
all_observations.append(deepcopy(observation)) if return_observations:
all_observations.append(deepcopy(observation))
# Infer "task" from sub-environments (prefer natural language description). # Infer "task" from sub-environments (prefer natural language description).
# env.call() works with both SyncVectorEnv and AsyncVectorEnv. # env.call() works with both SyncVectorEnv and AsyncVectorEnv.
try:
observation["task"] = list(env.call("task_description"))
except (AttributeError, NotImplementedError):
try: try:
observation["task"] = list(env.call("task")) observation["task"] = list(env.call("task_description"))
except (AttributeError, NotImplementedError): except (AttributeError, NotImplementedError):
observation["task"] = [""] * env.num_envs try:
observation["task"] = list(env.call("task"))
except (AttributeError, NotImplementedError):
observation["task"] = [""] * env.num_envs
# Apply environment-specific preprocessing (e.g., LiberoProcessorStep for LIBERO) # Apply environment-specific preprocessing (e.g., LiberoProcessorStep for LIBERO)
observation = env_preprocessor(observation) observation = env_preprocessor(observation)
observation = preprocessor(observation) observation = preprocessor(observation)
with torch.inference_mode(): with torch.inference_mode():
action = policy.select_action(observation) action = policy.select_action(observation)
action = postprocessor(action) action = postprocessor(action)
action_transition = {ACTION: action} action_transition = {ACTION: action}
action_transition = env_postprocessor(action_transition) action_transition = env_postprocessor(action_transition)
action = action_transition[ACTION] action = action_transition[ACTION]
# Convert to CPU / numpy. # Convert to CPU / numpy.
action_numpy: np.ndarray = action.to("cpu").numpy() action_numpy: np.ndarray = action.to("cpu").numpy()
assert action_numpy.ndim == 2, "Action dimensions should be (batch, action_dim)" assert action_numpy.ndim == 2, "Action dimensions should be (batch, action_dim)"
# Apply the next action. # Apply the next action.
observation, reward, terminated, truncated, info = env.step(action_numpy) observation, reward, terminated, truncated, info = env.step(action_numpy)
if render_callback is not None: if render_callback is not None:
render_callback(env) render_callback(env)
# VectorEnv stores is_success in `info["final_info"][env_index]["is_success"]`. "final_info" isn't # VectorEnv stores is_success in `info["final_info"][env_index]["is_success"]`. "final_info" isn't
# available if none of the envs finished. # available if none of the envs finished.
if "final_info" in info: if "final_info" in info:
final_info = info["final_info"] final_info = info["final_info"]
if not isinstance(final_info, dict): if not isinstance(final_info, dict):
raise RuntimeError( raise RuntimeError(
"Unsupported `final_info` format: expected dict (Gymnasium >= 1.0). " "Unsupported `final_info` format: expected dict (Gymnasium >= 1.0). "
"You're likely using an older version of gymnasium (< 1.0). Please upgrade." "You're likely using an older version of gymnasium (< 1.0). Please upgrade."
)
successes = final_info["is_success"].tolist()
elif "is_success" in info:
is_success = info["is_success"]
successes = (
is_success.tolist()
if hasattr(is_success, "tolist")
else [bool(is_success)] * env.num_envs
) )
successes = final_info["is_success"].tolist() else:
elif "is_success" in info: successes = [False] * env.num_envs
is_success = info["is_success"]
successes = ( if recording_datasets is not None and raw_observation is not None:
is_success.tolist() if hasattr(is_success, "tolist") else [bool(is_success)] * env.num_envs prev_done = done.copy()
for env_idx in range(env.num_envs):
if prev_done[env_idx]:
continue
frame = _build_raw_frame(
raw_observation,
env_idx,
action_numpy[env_idx],
reward[env_idx],
successes[env_idx],
bool(terminated[env_idx] | truncated[env_idx]),
task_desc,
recording_datasets[env_idx].features,
)
recording_datasets[env_idx].add_frame(frame)
if terminated[env_idx] or truncated[env_idx]:
recording_datasets[env_idx].save_episode()
raw_observation = deepcopy(observation)
# Keep track of which environments are done so far.
# Mark the episode as done if we reach the maximum step limit.
# This ensures that the rollout always terminates cleanly at `max_steps`,
# and allows logging/saving (e.g., videos) to be triggered consistently.
done = terminated | truncated | done
if step + 1 == max_steps:
done = np.ones_like(done, dtype=bool)
all_actions.append(torch.from_numpy(action_numpy))
all_rewards.append(torch.from_numpy(reward))
all_dones.append(torch.from_numpy(done))
all_successes.append(torch.tensor(successes))
step += 1
running_success_rate = (
einops.reduce(torch.stack(all_successes, dim=1), "b n -> b", "any").numpy().mean()
) )
else: progbar.set_postfix({"running_success_rate": f"{running_success_rate.item() * 100:.1f}%"})
successes = [False] * env.num_envs progbar.update()
finally:
# Keep track of which environments are done so far. if recording_datasets is not None:
# Mark the episode as done if we reach the maximum step limit. for ds in recording_datasets:
# This ensures that the rollout always terminates cleanly at `max_steps`, ds.finalize()
# and allows logging/saving (e.g., videos) to be triggered consistently. if recording_repo_id is not None:
done = terminated | truncated | done if ds.num_episodes > 0:
if step + 1 == max_steps: ds.push_to_hub(private=recording_private)
done = np.ones_like(done, dtype=bool) else:
logging.warning("No episodes recorded for %s — skipping push to hub.", ds.repo_id)
all_actions.append(torch.from_numpy(action_numpy))
all_rewards.append(torch.from_numpy(reward))
all_dones.append(torch.from_numpy(done))
all_successes.append(torch.tensor(successes))
step += 1
running_success_rate = (
einops.reduce(torch.stack(all_successes, dim=1), "b n -> b", "any").numpy().mean()
)
progbar.set_postfix({"running_success_rate": f"{running_success_rate.item() * 100:.1f}%"})
progbar.update()
# Track the final observation. # Track the final observation.
if return_observations: if return_observations:
@@ -273,6 +396,10 @@ def eval_policy(
videos_dir: Path | None = None, videos_dir: Path | None = None,
return_episode_data: bool = False, return_episode_data: bool = False,
start_seed: int | None = None, start_seed: int | None = None,
recording_dir: Path | None = None,
env_features: dict | None = None,
recording_repo_id: str | None = None,
recording_private: bool = False,
) -> dict: ) -> dict:
""" """
Args: Args:
@@ -361,6 +488,10 @@ def eval_policy(
seeds=list(seeds) if seeds else None, seeds=list(seeds) if seeds else None,
return_observations=return_episode_data, return_observations=return_episode_data,
render_callback=render_frame if max_episodes_rendered > 0 else None, render_callback=render_frame if max_episodes_rendered > 0 else None,
recording_dir=recording_dir,
env_features=env_features,
recording_repo_id=recording_repo_id,
recording_private=recording_private,
) )
# Figure out where in each rollout sequence the first done condition was encountered (results after # Figure out where in each rollout sequence the first done condition was encountered (results after
@@ -563,6 +694,10 @@ def eval_main(cfg: EvalPipelineConfig):
# Create environment-specific preprocessor and postprocessor (e.g., for LIBERO environments) # Create environment-specific preprocessor and postprocessor (e.g., for LIBERO environments)
env_preprocessor, env_postprocessor = make_env_pre_post_processors(env_cfg=cfg.env, policy_cfg=cfg.policy) env_preprocessor, env_postprocessor = make_env_pre_post_processors(env_cfg=cfg.env, policy_cfg=cfg.policy)
recording_dir = Path(cfg.output_dir) / "recordings" if cfg.eval.recording else None
max_episodes_rendered = 0 if cfg.eval.recording else 10
videos_dir = None if cfg.eval.recording else Path(cfg.output_dir) / "videos"
with torch.no_grad(), torch.autocast(device_type=device.type) if cfg.policy.use_amp else nullcontext(): with torch.no_grad(), torch.autocast(device_type=device.type) if cfg.policy.use_amp else nullcontext():
info = eval_policy_all( info = eval_policy_all(
envs=envs, envs=envs,
@@ -572,10 +707,15 @@ def eval_main(cfg: EvalPipelineConfig):
preprocessor=preprocessor, preprocessor=preprocessor,
postprocessor=postprocessor, postprocessor=postprocessor,
n_episodes=cfg.eval.n_episodes, n_episodes=cfg.eval.n_episodes,
max_episodes_rendered=10, max_episodes_rendered=max_episodes_rendered,
videos_dir=Path(cfg.output_dir) / "videos", videos_dir=videos_dir,
return_episode_data=False,
start_seed=cfg.seed, start_seed=cfg.seed,
max_parallel_tasks=cfg.env.max_parallel_tasks, max_parallel_tasks=cfg.env.max_parallel_tasks,
recording_dir=recording_dir,
env_features=cfg.env.features if cfg.eval.recording else None,
recording_repo_id=cfg.eval.recording_repo_id,
recording_private=cfg.eval.recording_private,
) )
print("Overall Aggregated Metrics:") print("Overall Aggregated Metrics:")
print(info["overall"]) print(info["overall"])
@@ -618,6 +758,10 @@ def eval_one(
videos_dir: Path | None, videos_dir: Path | None,
return_episode_data: bool, return_episode_data: bool,
start_seed: int | None, start_seed: int | None,
recording_dir: Path | None = None,
env_features: dict | None = None,
recording_repo_id: str | None = None,
recording_private: bool = False,
) -> TaskMetrics: ) -> TaskMetrics:
"""Evaluates one task_id of one suite using the provided vec env.""" """Evaluates one task_id of one suite using the provided vec env."""
@@ -635,6 +779,10 @@ def eval_one(
videos_dir=task_videos_dir, videos_dir=task_videos_dir,
return_episode_data=return_episode_data, return_episode_data=return_episode_data,
start_seed=start_seed, start_seed=start_seed,
recording_dir=recording_dir,
env_features=env_features,
recording_repo_id=recording_repo_id,
recording_private=recording_private,
) )
per_episode = task_result["per_episode"] per_episode = task_result["per_episode"]
@@ -661,6 +809,10 @@ def run_one(
videos_dir: Path | None, videos_dir: Path | None,
return_episode_data: bool, return_episode_data: bool,
start_seed: int | None, start_seed: int | None,
recording_dir: Path | None = None,
env_features: dict | None = None,
recording_repo_id: str | None = None,
recording_private: bool = False,
): ):
""" """
Run eval_one for a single (task_group, task_id, env). Run eval_one for a single (task_group, task_id, env).
@@ -672,7 +824,13 @@ def run_one(
task_videos_dir = videos_dir / f"{task_group}_{task_id}" task_videos_dir = videos_dir / f"{task_group}_{task_id}"
task_videos_dir.mkdir(parents=True, exist_ok=True) task_videos_dir.mkdir(parents=True, exist_ok=True)
# Call the existing eval_one (assumed to return TaskMetrics-like dict) task_recording_dir = None
task_repo_id = None
if recording_dir is not None and env_features is not None:
task_recording_dir = recording_dir / f"{task_group}_{task_id}"
if recording_repo_id is not None:
task_repo_id = f"{recording_repo_id}_{task_group}_{task_id}"
metrics = eval_one( metrics = eval_one(
env, env,
policy=policy, policy=policy,
@@ -685,8 +843,12 @@ def run_one(
videos_dir=task_videos_dir, videos_dir=task_videos_dir,
return_episode_data=return_episode_data, return_episode_data=return_episode_data,
start_seed=start_seed, start_seed=start_seed,
recording_dir=task_recording_dir,
env_features=env_features,
recording_repo_id=task_repo_id,
recording_private=recording_private,
) )
# ensure we always provide video_paths key to simplify accumulation
if max_episodes_rendered > 0: if max_episodes_rendered > 0:
metrics.setdefault("video_paths", []) metrics.setdefault("video_paths", [])
return task_group, task_id, metrics return task_group, task_id, metrics
@@ -702,6 +864,10 @@ def eval_policy_all(
n_episodes: int, n_episodes: int,
*, *,
max_episodes_rendered: int = 0, max_episodes_rendered: int = 0,
recording_dir: Path | None = None,
env_features: dict | None = None,
recording_repo_id: str | None = None,
recording_private: bool = False,
videos_dir: Path | None = None, videos_dir: Path | None = None,
return_episode_data: bool = False, return_episode_data: bool = False,
start_seed: int | None = None, start_seed: int | None = None,
@@ -761,6 +927,10 @@ def eval_policy_all(
videos_dir=videos_dir, videos_dir=videos_dir,
return_episode_data=return_episode_data, return_episode_data=return_episode_data,
start_seed=start_seed, start_seed=start_seed,
recording_dir=recording_dir,
env_features=env_features,
recording_repo_id=recording_repo_id,
recording_private=recording_private,
) )
if max_parallel_tasks <= 1: if max_parallel_tasks <= 1:
+3 -6
View File
@@ -96,11 +96,7 @@ from lerobot.cameras.opencv import OpenCVCameraConfig # noqa: F401
from lerobot.cameras.reachy2_camera import Reachy2CameraConfig # noqa: F401 from lerobot.cameras.reachy2_camera import Reachy2CameraConfig # noqa: F401
from lerobot.cameras.realsense import RealSenseCameraConfig # noqa: F401 from lerobot.cameras.realsense import RealSenseCameraConfig # noqa: F401
from lerobot.cameras.zmq import ZMQCameraConfig # noqa: F401 from lerobot.cameras.zmq import ZMQCameraConfig # noqa: F401
from lerobot.common.control_utils import ( from lerobot.common.control_utils import sanity_check_dataset_robot_compatibility
init_keyboard_listener,
is_headless,
sanity_check_dataset_robot_compatibility,
)
from lerobot.configs import parser from lerobot.configs import parser
from lerobot.configs.dataset import DatasetRecordConfig from lerobot.configs.dataset import DatasetRecordConfig
from lerobot.datasets import ( from lerobot.datasets import (
@@ -155,6 +151,7 @@ from lerobot.teleoperators.keyboard import KeyboardTeleop
from lerobot.utils.constants import ACTION, OBS_STR from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import build_dataset_frame, combine_feature_dicts from lerobot.utils.feature_utils import build_dataset_frame, combine_feature_dicts
from lerobot.utils.import_utils import register_third_party_plugins from lerobot.utils.import_utils import register_third_party_plugins
from lerobot.utils.keyboard_input import init_keyboard_listener
from lerobot.utils.robot_utils import precise_sleep from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import ( from lerobot.utils.utils import (
init_logging, init_logging,
@@ -508,7 +505,7 @@ def record(
if teleop and teleop.is_connected: if teleop and teleop.is_connected:
teleop.disconnect() teleop.disconnect()
if not is_headless() and listener: if listener is not None:
listener.stop() listener.stop()
if cfg.dataset.push_to_hub: if cfg.dataset.push_to_hub:
+29 -4
View File
@@ -34,8 +34,10 @@ from torch.optim import Optimizer
from tqdm import tqdm from tqdm import tqdm
from lerobot.common.train_utils import ( from lerobot.common.train_utils import (
gather_fsdp_state_dicts,
get_step_checkpoint_dir, get_step_checkpoint_dir,
get_step_identifier, get_step_identifier,
load_fsdp_optimizer_state,
load_training_batch_size, load_training_batch_size,
load_training_num_processes, load_training_num_processes,
load_training_state, load_training_state,
@@ -189,6 +191,7 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
require_package("accelerate", extra="training") require_package("accelerate", extra="training")
from accelerate import Accelerator from accelerate import Accelerator
from accelerate.utils import DistributedDataParallelKwargs, DistributedType
cfg.validate() cfg.validate()
@@ -197,8 +200,6 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
# We set step_scheduler_with_optimizer=False to prevent accelerate from adjusting the lr_scheduler steps based on the num_processes # We set step_scheduler_with_optimizer=False to prevent accelerate from adjusting the lr_scheduler steps based on the num_processes
# We set find_unused_parameters=True to handle models with conditional computation # We set find_unused_parameters=True to handle models with conditional computation
if accelerator is None: if accelerator is None:
from accelerate.utils import DistributedDataParallelKwargs
ddp_kwargs = DistributedDataParallelKwargs(find_unused_parameters=True) ddp_kwargs = DistributedDataParallelKwargs(find_unused_parameters=True)
# Accelerate auto-detects the device based on the available hardware and ignores the policy.device setting. # Accelerate auto-detects the device based on the available hardware and ignores the policy.device setting.
# Force the device to be CPU when the active config's device is set to CPU (works for both policy and reward model training). # Force the device to be CPU when the active config's device is set to CPU (works for both policy and reward model training).
@@ -345,6 +346,7 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
preprocessor, postprocessor = make_pre_post_processors( preprocessor, postprocessor = make_pre_post_processors(
policy_cfg=cfg.policy, policy_cfg=cfg.policy,
pretrained_path=processor_pretrained_path, pretrained_path=processor_pretrained_path,
pretrained_revision=getattr(cfg.policy, "pretrained_revision", None),
**processor_kwargs, **processor_kwargs,
) )
@@ -370,7 +372,12 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
step = 0 # number of policy updates (forward + backward + optim) step = 0 # number of policy updates (forward + backward + optim)
if cfg.resume: if cfg.resume:
step, optimizer, lr_scheduler = load_training_state(cfg.checkpoint_path, optimizer, lr_scheduler) # Under FSDP the optimizer state is sharded and must be loaded after `accelerator.prepare()`
# (see load_fsdp_optimizer_state below), so skip the optimizer here and load it then.
is_fsdp = accelerator.distributed_type == DistributedType.FSDP
step, optimizer, lr_scheduler = load_training_state(
cfg.checkpoint_path, optimizer, lr_scheduler, load_optimizer=not is_fsdp
)
num_learnable_params = sum(p.numel() for p in policy.parameters() if p.requires_grad) num_learnable_params = sum(p.numel() for p in policy.parameters() if p.requires_grad)
num_total_params = sum(p.numel() for p in policy.parameters()) num_total_params = sum(p.numel() for p in policy.parameters())
@@ -460,6 +467,12 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
policy, optimizer, dataloader, lr_scheduler = accelerator.prepare( policy, optimizer, dataloader, lr_scheduler = accelerator.prepare(
policy, optimizer, dataloader, lr_scheduler policy, optimizer, dataloader, lr_scheduler
) )
# FSDP optimizer state is sharded across ranks, so it can only be loaded once the optimizer and
# model are FSDP-wrapped (i.e. after `prepare`). Collective: every rank must participate.
if cfg.resume and accelerator.distributed_type == DistributedType.FSDP:
load_fsdp_optimizer_state(policy, optimizer, cfg.checkpoint_path)
dl_iter = cycle(dataloader) dl_iter = cycle(dataloader)
policy.train() policy.train()
@@ -558,6 +571,14 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
train_tracker.reset_averages() train_tracker.reset_averages()
if cfg.save_checkpoint and is_saving_step: if cfg.save_checkpoint and is_saving_step:
# Under FSDP, gathering the full model + optimizer state dicts is a cross-rank collective,
# so all ranks must participate; rank 0 then writes the materialized dicts. For DDP /
# single-GPU the state dicts are saved the normal way inside save_checkpoint.
is_fsdp = accelerator.distributed_type == DistributedType.FSDP
if is_fsdp:
model_state_dict, optim_state_dict = gather_fsdp_state_dicts(policy, optimizer)
else:
model_state_dict, optim_state_dict = None, None
if is_main_process: if is_main_process:
logging.info(f"Checkpoint policy after step {step}") logging.info(f"Checkpoint policy after step {step}")
checkpoint_dir = get_step_checkpoint_dir(cfg.output_dir, cfg.steps, step) checkpoint_dir = get_step_checkpoint_dir(cfg.output_dir, cfg.steps, step)
@@ -572,6 +593,8 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
postprocessor=postprocessor, postprocessor=postprocessor,
num_processes=accelerator.num_processes, num_processes=accelerator.num_processes,
batch_size=cfg.batch_size, batch_size=cfg.batch_size,
model_state_dict=model_state_dict,
optim_state_dict=optim_state_dict,
) )
update_last_checkpoint(checkpoint_dir) update_last_checkpoint(checkpoint_dir)
if wandb_logger: if wandb_logger:
@@ -634,6 +657,8 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
if eval_env: if eval_env:
close_envs(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: if is_main_process:
logging.info("End of training") logging.info("End of training")
@@ -643,7 +668,7 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
if not cfg.is_reward_model_training and cfg.policy.use_peft: if not cfg.is_reward_model_training and cfg.policy.use_peft:
unwrapped_model.push_model_to_hub(cfg, peft_model=unwrapped_model) unwrapped_model.push_model_to_hub(cfg, peft_model=unwrapped_model)
else: else:
unwrapped_model.push_model_to_hub(cfg) unwrapped_model.push_model_to_hub(cfg, state_dict=model_state_dict)
preprocessor.push_to_hub(active_cfg.repo_id) preprocessor.push_to_hub(active_cfg.repo_id)
postprocessor.push_to_hub(active_cfg.repo_id) postprocessor.push_to_hub(active_cfg.repo_id)
@@ -18,6 +18,7 @@ import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from lerobot.utils.import_utils import _hidapi_available, _pygame_available, require_package from lerobot.utils.import_utils import _hidapi_available, _pygame_available, require_package
from lerobot.utils.keyboard_input import pynput_can_capture
from ..utils import TeleopEvents from ..utils import TeleopEvents
@@ -123,6 +124,15 @@ class KeyboardController(InputController):
def start(self): def start(self):
"""Start the keyboard listener.""" """Start the keyboard listener."""
if not pynput_can_capture():
logging.warning(
"Keyboard control is unavailable in this environment. pynput cannot capture keys "
"on Wayland or headless machines, or on macOS without Accessibility / Input "
"Monitoring permission. Keyboard motion will be inactive."
)
self.running = False
return
from pynput import keyboard from pynput import keyboard
def on_press(key): def on_press(key):
@@ -15,8 +15,6 @@
# limitations under the License. # limitations under the License.
import logging import logging
import os
import sys
import time import time
from queue import Queue from queue import Queue
from typing import Any from typing import Any
@@ -24,6 +22,7 @@ from typing import Any
from lerobot.types import RobotAction from lerobot.types import RobotAction
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected
from lerobot.utils.import_utils import _pynput_available, require_package from lerobot.utils.import_utils import _pynput_available, require_package
from lerobot.utils.keyboard_input import pynput_can_capture
from ..teleoperator import Teleoperator from ..teleoperator import Teleoperator
from ..utils import TeleopEvents from ..utils import TeleopEvents
@@ -37,14 +36,10 @@ PYNPUT_AVAILABLE = _pynput_available
keyboard = None keyboard = None
if PYNPUT_AVAILABLE: if PYNPUT_AVAILABLE:
try: try:
if ("DISPLAY" not in os.environ) and ("linux" in sys.platform): from pynput import keyboard
logging.info("No DISPLAY set. Skipping pynput import.")
PYNPUT_AVAILABLE = False
else:
from pynput import keyboard
except Exception as e: except Exception as e:
PYNPUT_AVAILABLE = False PYNPUT_AVAILABLE = False
logging.info(f"Could not import pynput: {e}") logging.info("Could not import pynput keyboard backend: %s", e)
class KeyboardTeleop(Teleoperator): class KeyboardTeleop(Teleoperator):
@@ -88,7 +83,7 @@ class KeyboardTeleop(Teleoperator):
@check_if_already_connected @check_if_already_connected
def connect(self) -> None: def connect(self) -> None:
if PYNPUT_AVAILABLE: if PYNPUT_AVAILABLE and pynput_can_capture():
logging.info("pynput is available - enabling local keyboard listener.") logging.info("pynput is available - enabling local keyboard listener.")
self.listener = keyboard.Listener( self.listener = keyboard.Listener(
on_press=self._on_press, on_press=self._on_press,
@@ -96,7 +91,13 @@ class KeyboardTeleop(Teleoperator):
) )
self.listener.start() self.listener.start()
else: else:
logging.info("pynput not available - skipping local keyboard listener.") logging.warning(
"Keyboard teleoperation is unavailable in this environment. pynput can only "
"capture key events on an X11 session (Linux), a Windows desktop, or macOS with "
"Accessibility / Input Monitoring granted - not on Wayland or headless machines. "
"This keyboard teleoperator will produce no actions; use an X11 session, a "
"gamepad, or a leader-arm teleoperator instead."
)
self.listener = None self.listener = None
def calibrate(self) -> None: def calibrate(self) -> None:
+8 -2
View File
@@ -216,9 +216,15 @@ def register_third_party_plugins() -> None:
This function uses `importlib.metadata` to find packages installed in the environment This function uses `importlib.metadata` to find packages installed in the environment
(including editable installs) starting with 'lerobot_robot_', 'lerobot_camera_', (including editable installs) starting with 'lerobot_robot_', 'lerobot_camera_',
'lerobot_teleoperator_', or 'lerobot_policy_' and imports them. 'lerobot_teleoperator_', 'lerobot_policy_', or 'lerobot_env_' and imports them.
""" """
prefixes = ("lerobot_robot_", "lerobot_camera_", "lerobot_teleoperator_", "lerobot_policy_") prefixes = (
"lerobot_robot_",
"lerobot_camera_",
"lerobot_teleoperator_",
"lerobot_policy_",
"lerobot_env_",
)
imported: list[str] = [] imported: list[str] = []
failed: list[str] = [] failed: list[str] = []
+440
View File
@@ -0,0 +1,440 @@
# 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.
"""Display-independent keyboard input for interactive controls.
This module centralizes everything related to *discrete* keyboard controls
(end-episode-early, re-record, stop, and the rollout strategies' custom keys):
* environment detection :func:`is_headless`, :func:`is_wayland`,
:func:`pynput_can_capture` (the single predicate every call-site should use to
decide whether ``pynput`` can actually capture keys here);
* a shared key mapping :func:`apply_recording_control`; and
* two interchangeable backends behind one ``(listener, events)`` contract:
the ``pynput`` global listener (X11 / trusted-macOS / Windows) and a
standard-library :class:`TerminalKeyListener` that reads the controlling TTY
(Wayland / headless-SSH-with-TTY / macOS without Accessibility permission).
NOTE: *continuous* key-state teleoperation ("hold a key to keep moving") is
deliberately NOT served here. A terminal in cbreak mode delivers only key-down
bytes there is no key-release event so the held-key model cannot be
reproduced. Those teleoperators stay on ``pynput`` and use
:func:`pynput_can_capture` to warn instead of silently doing nothing.
"""
from __future__ import annotations
import atexit
import contextlib
import logging
import os
import platform
import select
import sys
import threading
import time
from collections.abc import Callable
from functools import cache
from typing import TYPE_CHECKING
from .import_utils import _pynput_available
logger = logging.getLogger(__name__)
# POSIX-only terminal modules (absent on Windows, where the pynput backend is used).
if TYPE_CHECKING:
import termios
import tty
_TERMIOS_AVAILABLE = True
else:
try:
import termios
import tty
_TERMIOS_AVAILABLE = True
except ImportError: # POSIX-only modules; unavailable on Windows
termios = tty = None
_TERMIOS_AVAILABLE = False
keyboard = None
if _pynput_available:
try:
from pynput import keyboard
except Exception as e: # e.g. no reachable X display on a headless Linux box
logger.info("Could not import pynput keyboard backend: %s", e)
@cache
def is_headless() -> bool:
"""Return ``True`` when no display server is available.
* Linux: headless when neither ``DISPLAY`` (X11) nor ``WAYLAND_DISPLAY`` is set.
* macOS / Windows: a display is always assumed to be present. A genuinely GUI-less
Mac/Windows CI host would be misclassified but it doesn't matter, because the
sys.stdin.isatty() gate returns None there regardless.
"""
if platform.system() == "Linux":
return not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
return False
@cache
def is_wayland() -> bool:
"""Return ``True`` when running under a Wayland session.
``pynput`` relies on an X11 backend. Under Wayland it still imports (XWayland
is usually present and ``$DISPLAY`` is set) but cannot capture *global*
hotkeys, so the documented arrow/Esc shortcuts silently do nothing. This case
is invisible to :func:`is_headless`, hence the dedicated check.
"""
return os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland" or bool(
os.environ.get("WAYLAND_DISPLAY")
)
@cache
def pynput_can_capture() -> bool:
"""Return ``True`` when a ``pynput`` global listener can actually capture keys.
This is the single predicate every keyboard call-site should use to choose
between the ``pynput`` backend and a fallback. It is intentionally
conservative:
* Linux: only a real X11 session (a display is present *and* it is not Wayland).
* macOS: ``True`` here Accessibility / Input-Monitoring permission
(``IS_TRUSTED``) can only be confirmed at runtime *after* starting a
listener, so :func:`init_keyboard_listener` refines this with
:func:`pynput_listener_is_trusted`.
* Windows: ``True`` (the low-level global hook needs no special permission).
Always ``False`` when ``pynput`` is not installed.
"""
if not _pynput_available:
return False
if platform.system() == "Linux":
return not is_headless() and not is_wayland()
return True
def pynput_listener_is_trusted(listener, timeout_s: float = 1.0) -> bool:
"""Best-effort check that a freshly started ``pynput`` listener can capture.
On macOS, ``pynput`` sets ``listener.IS_TRUSTED`` on its *listener thread*
once the Quartz event tap is created; the class default is ``False``. We
therefore wait for the thread to either flip it ``True`` (trusted) or for a
short timeout to elapse (untrusted it stays ``False`` forever). This biases
toward the common trusted case (returns as soon as the flag flips) and only
pays the full ``timeout_s`` on an already-broken untrusted machine.
On non-macOS backends the attribute is absent and capture is assumed to work.
"""
if platform.system() != "Darwin":
return True
deadline = time.perf_counter() + timeout_s
while time.perf_counter() < deadline:
if getattr(listener, "IS_TRUSTED", False):
return True
time.sleep(0.02)
return bool(getattr(listener, "IS_TRUSTED", False))
def apply_recording_control(control: str, events: dict) -> None:
"""Apply a recording control-flow key press to the shared ``events`` dict.
Centralizes the mapping so the ``pynput`` and terminal backends behave
identically. ``control`` is one of ``"right"`` (end the loop early), ``"left"``
(re-record the last episode), or ``"esc"`` (stop recording).
"""
if control == "right":
print("Right arrow key pressed. Exiting loop...")
events["exit_early"] = True
elif control == "left":
print("Left arrow key pressed. Exiting loop and rerecord the last episode...")
events["rerecord_episode"] = True
events["exit_early"] = True
elif control == "esc":
print("Escape key pressed. Stopping data recording...")
events["stop_recording"] = True
events["exit_early"] = True
# Terminal arrow keys arrive as a 3-byte escape sequence whose *final* byte identifies
# the direction. Two encodings exist depending on the terminal's cursor-key mode — CSI
# ("ESC [ X") and SS3 ("ESC O X", common over SSH/tmux) — but both share the same final
# byte, so this single table decodes either. Looked up by TerminalKeyListener._parse;
# an unknown final byte yields None (sequence ignored).
_ARROW_FINAL_BYTES = {"A": "up", "B": "down", "C": "right", "D": "left"}
class TerminalKeyListener:
"""Display-independent keyboard listener that reads keys from the controlling TTY.
Used as the Wayland / headless / macOS-untrusted equivalent of the ``pynput``
listener for *discrete* controls. It puts the terminal into cbreak mode with
echo disabled and reads bytes on a daemon thread, decoding them into logical
key names that are passed to ``on_key``:
* arrow keys (``ESC [ C`` / ``ESC O C`` ) -> ``"right"`` / ``"left"`` / ``"up"`` / ``"down"``
* a bare ``ESC`` -> ``"esc"``
* Enter / Tab / Space / Backspace -> ``"enter"`` / ``"tab"`` / ``"space"`` / ``"backspace"``
* any other printable byte -> that character (e.g. ``"n"``, ``"s"``)
Only key-down events are produced (terminals have no key-release), so this is
suitable for discrete commands but NOT for continuous "hold-to-move" teleop.
The terminal is restored on :meth:`stop` and also via an ``atexit`` hook, so a
crash or Ctrl-C never leaves the shell in a no-echo cbreak state. POSIX-only
(``termios`` / ``tty`` / ``select``); those modules are imported lazily so this
file stays importable on Windows (where ``pynput`` is used instead).
"""
def __init__(self, on_key: Callable[[str], None]):
self._on_key = on_key
self._running = False
self._thread: threading.Thread | None = None
self._fd: int | None = None
self._old_attrs = None
def _read_char(self, timeout: float) -> str | None:
"""Return one character from stdin within ``timeout`` seconds, or ``None``."""
if self._fd is None:
return None
ready, _, _ = select.select([self._fd], [], [], timeout)
if not ready:
return None
try:
data = os.read(self._fd, 1)
except OSError:
return None
if not data:
return None
return data.decode(errors="ignore")
def _parse(self, ch: str) -> str | None:
"""Decode one (possibly multi-byte) key starting at ``ch`` into a key name."""
if ch == "\x1b":
# Possible CSI / SS3 escape sequence (arrow keys) or a bare ESC. Use
# short follow-up reads so a lone ESC is not mistaken for a sequence.
ch2 = self._read_char(timeout=0.02)
if ch2 is None:
return "esc"
if ch2 in ("[", "O"):
ch3 = self._read_char(timeout=0.02)
return _ARROW_FINAL_BYTES.get(ch3 or "")
# Some other escape sequence (e.g. Alt+key); ignore it.
return None
if ch in ("\r", "\n"):
return "enter"
if ch == "\t":
return "tab"
if ch == " ":
return "space"
if ch in ("\x7f", "\x08"):
return "backspace"
if ch.isprintable():
return ch
return None
def _run(self) -> None:
while self._running:
ch = self._read_char(timeout=0.05)
if ch is None:
continue
name = self._parse(ch)
if name is None:
continue
try:
self._on_key(name)
except Exception as e: # never let a handler error kill the reader thread
logger.debug("Terminal key handler error: %s", e)
def start(self) -> None:
"""Switch the terminal to cbreak mode (echo off) and read keys on a daemon thread.
No-op when stdin is not a TTY (piped/redirected input) or on platforms
without ``termios`` (e.g. Windows), so non-interactive runs are unaffected.
"""
if not sys.stdin.isatty():
return
if not _TERMIOS_AVAILABLE: # POSIX-only modules (e.g. unavailable on Windows)
logger.warning("Terminal keyboard input is not supported on this platform.")
return
self._fd = sys.stdin.fileno()
self._old_attrs = termios.tcgetattr(self._fd)
tty.setcbreak(self._fd)
# Explicitly disable ECHO so arrow-key escape sequences (e.g. ^[[C) are not
# echoed as garbage into the recording terminal. (Independent of the
# version-specific behavior of tty.setcbreak.)
new_attrs = termios.tcgetattr(self._fd)
new_attrs[3] &= ~termios.ECHO # index 3 == lflags
termios.tcsetattr(self._fd, termios.TCSADRAIN, new_attrs)
# Safety net: restore the terminal even if stop() is never reached (crash).
atexit.register(self.stop)
self._running = True
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self) -> None:
"""Stop the reader thread and restore the original terminal attributes.
Idempotent: safe to call multiple times (e.g. explicitly and via atexit).
"""
self._running = False
thread = self._thread
if thread is not None:
thread.join(timeout=0.5)
self._thread = None
if self._fd is not None and self._old_attrs is not None and _TERMIOS_AVAILABLE:
try:
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs)
finally:
self._old_attrs = None
with contextlib.suppress(Exception):
atexit.unregister(self.stop)
# Map pynput key objects to the same canonical names TerminalKeyListener emits, so a
# single dispatch works across both backends. Empty when pynput is unavailable.
if keyboard is not None:
_PYNPUT_KEY_NAMES = {
keyboard.Key.right: "right",
keyboard.Key.left: "left",
keyboard.Key.up: "up",
keyboard.Key.down: "down",
keyboard.Key.esc: "esc",
keyboard.Key.enter: "enter",
keyboard.Key.tab: "tab",
keyboard.Key.space: "space",
keyboard.Key.backspace: "backspace",
}
else:
_PYNPUT_KEY_NAMES = {}
def _resolve_pynput_key(key) -> str | None:
"""Resolve a pynput key event to the canonical name TerminalKeyListener also emits.
Special keys map through :data:`_PYNPUT_KEY_NAMES`; character keys fall back to their
``.char`` (e.g. ``"n"``). Returns ``None`` for keys with no mapping and no character.
"""
name = _PYNPUT_KEY_NAMES.get(key)
if name is not None:
return name
# ``or None`` keeps the historical truthy-char semantics: an empty/None char is "no key".
return getattr(key, "char", None) or None
def create_key_listener(dispatch: Callable[[str], None], *, controls_help: str = ""):
"""Start a keyboard listener that routes resolved key names to ``dispatch``.
Shared backend selection used by recording and the rollout strategies:
* the ``pynput`` global listener on X11 / trusted-macOS / Windows (on macOS the
listener's ``IS_TRUSTED`` flag is checked after start, and an untrusted listener is
stopped so the terminal backend is used instead);
* the stdlib :class:`TerminalKeyListener` on Wayland / headless sessions with a TTY;
* ``None`` when no backend is usable (non-interactive / piped runs).
Both backends pass ``dispatch`` the same canonical key names ("right" / "left" / "up" /
"down" / "esc" / "enter" / "tab" / "space" / "backspace", or a character), so one
``dispatch`` works regardless of backend. ``controls_help`` is an optional hint
appended to the log messages.
Returns the listener (exposing ``.stop()``) or ``None``.
"""
suffix = f" ({controls_help})" if controls_help else ""
if pynput_can_capture() and keyboard is not None:
def on_press(key):
with contextlib.suppress(Exception):
name = _resolve_pynput_key(key)
if name is not None:
dispatch(name)
listener = keyboard.Listener(on_press=on_press)
listener.start()
if pynput_listener_is_trusted(listener):
logger.info("Keyboard listener started%s.", suffix)
return listener
# macOS without Accessibility / Input-Monitoring permission: the listener never
# fires. Stop it and fall through to the terminal backend.
logger.warning(
"pynput keyboard listener is not trusted (missing macOS Accessibility / "
"Input Monitoring permission); falling back to terminal keyboard input."
)
listener.stop()
if sys.stdin.isatty():
listener = TerminalKeyListener(dispatch)
listener.start()
logger.info("Using terminal keyboard input — keep this terminal focused%s.", suffix)
return listener
logger.warning(
"Keyboard controls unavailable: no usable display (Wayland/headless) and stdin is "
"not an interactive terminal%s.",
suffix,
)
return None
def init_keyboard_listener():
"""Initialize a non-blocking keyboard listener for interactive recording controls.
Backend selection:
* ``pynput`` global listener when :func:`pynput_can_capture` is true (real
X11, macOS, Windows). On macOS the listener's ``IS_TRUSTED`` flag is checked
after start; if the process lacks Accessibility / Input-Monitoring
permission, the listener is stopped and the terminal backend is used.
* a :class:`TerminalKeyListener` reading the controlling TTY when ``pynput``
cannot capture (Wayland / headless-SSH / macOS-untrusted) *and* stdin is a TTY.
* otherwise no listener (non-interactive / piped runs) recording relies on
the episode/reset timers (or Ctrl+C).
Both backends accept the same controls: Right/Left/Esc, plus the single-byte letter
equivalents ``n`` (next), ``r`` (re-record) and ``q`` (quit). The letters are the most
reliable choice over high-latency SSH/VNC links, where arrow-key escape sequences can
be split, delayed, or intercepted by the terminal.
Returns:
A tuple ``(listener, events)`` where ``listener`` exposes ``.stop()`` or is
``None``, and ``events`` is the dict of flags (``exit_early``,
``rerecord_episode``, ``stop_recording``) set by key presses.
"""
events = {
"exit_early": False,
"rerecord_episode": False,
"stop_recording": False,
}
# Accept the single-byte letter equivalents n/r/q alongside the arrow/Esc keys: the
# letters are immune to the escape-sequence split/delay/interception that affects arrows
# over laggy SSH/VNC links. Case-insensitive so Shift+letter still works.
def on_key(name: str) -> None:
key = name.lower()
if key in ("right", "n"):
apply_recording_control("right", events)
elif key in ("left", "r"):
apply_recording_control("left", events)
elif key in ("esc", "q"):
apply_recording_control("esc", events)
# other keys (incl. up/down) are intentionally ignored
listener = create_key_listener(on_key, controls_help="Right/Left/Esc, or n=next, r=re-record, q=quit")
return listener, events
+16 -1
View File
@@ -51,7 +51,7 @@ from lerobot.robots import make_robot_from_config
from lerobot.transforms import ImageTransforms, ImageTransformsConfig from lerobot.transforms import ImageTransforms, ImageTransformsConfig
from lerobot.utils.constants import ACTION, DONE, OBS_IMAGES, OBS_STATE, OBS_STR, REWARD from lerobot.utils.constants import ACTION, DONE, OBS_IMAGES, OBS_STATE, OBS_STR, REWARD
from lerobot.utils.feature_utils import hw_to_dataset_features from lerobot.utils.feature_utils import hw_to_dataset_features
from tests.fixtures.constants import DUMMY_CHW, DUMMY_HWC, DUMMY_REPO_ID from tests.fixtures.constants import DUMMY_CHW, DUMMY_HWC, DUMMY_MOTOR_FEATURES, DUMMY_REPO_ID
from tests.mocks.mock_robot import MockRobotConfig from tests.mocks.mock_robot import MockRobotConfig
from tests.utils import require_x86_64_kernel from tests.utils import require_x86_64_kernel
@@ -133,6 +133,21 @@ def test_dataset_feature_with_forward_slash_raises_error():
) )
def test_create_does_not_mutate_input_features(tmp_path, empty_lerobot_dataset_factory):
# ``create`` must deep-copy features so a dataset built from another's features stays independent.
dataset = empty_lerobot_dataset_factory(
root=tmp_path / "ds1", features=DUMMY_MOTOR_FEATURES, use_videos=False
)
dataset_copy = empty_lerobot_dataset_factory(
root=tmp_path / "ds2", features=dataset.meta.features, use_videos=False
)
original_shape = dataset.meta.info.features["state"]["shape"]
dataset_copy.meta.info.features["state"]["shape"] = (999,)
assert dataset.meta.info.features["state"]["shape"] == original_shape
def test_add_frame_missing_task(tmp_path, empty_lerobot_dataset_factory): def test_add_frame_missing_task(tmp_path, empty_lerobot_dataset_factory):
features = {"state": {"dtype": "float32", "shape": (1,), "names": None}} features = {"state": {"dtype": "float32", "shape": (1,), "names": None}}
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features) dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
+39
View File
@@ -20,6 +20,7 @@ from lerobot.optim.optimizers import (
MultiAdamConfig, MultiAdamConfig,
SGDConfig, SGDConfig,
load_optimizer_state, load_optimizer_state,
load_optimizer_state_dict,
save_optimizer_state, save_optimizer_state,
) )
from lerobot.utils.constants import ( from lerobot.utils.constants import (
@@ -65,6 +66,44 @@ def test_save_and_load_optimizer_state(model_params, optimizer, tmp_path):
torch.testing.assert_close(optimizer.state_dict(), loaded_optimizer.state_dict()) torch.testing.assert_close(optimizer.state_dict(), loaded_optimizer.state_dict())
def test_save_and_load_fsdp_optimizer_state_dict_roundtrip(tmp_path):
"""The FSDP full optimizer state dict is keyed by parameter FQNs (dotted strings), not the
integer indices of the single-GPU path. Verify it survives the safetensors save -> read
round-trip used by the FSDP save/resume path (save_optimizer_state(optim_state_dict=...) then
load_optimizer_state_dict), which the flatten/unflatten "/" separator must not corrupt."""
full_osd = {
"state": {
"model.layers.0.weight": {
"step": torch.tensor(3.0),
"exp_avg": torch.randn(4, 4),
"exp_avg_sq": torch.randn(4, 4),
},
"model.layers.0.bias": {
"step": torch.tensor(3.0),
"exp_avg": torch.randn(4),
"exp_avg_sq": torch.randn(4),
},
},
"param_groups": [
{"lr": 1e-4, "betas": [0.9, 0.999], "eps": 1e-8, "weight_decay": 0.0, "params": [0, 1]}
],
}
save_optimizer_state(
torch.optim.Adam([torch.nn.Parameter(torch.randn(1))]), tmp_path, optim_state_dict=full_osd
)
assert (tmp_path / OPTIMIZER_STATE).is_file()
assert (tmp_path / OPTIMIZER_PARAM_GROUPS).is_file()
loaded = load_optimizer_state_dict(tmp_path)
# FQN keys must be preserved verbatim (not int-cast, not split on their dots).
assert set(loaded["state"].keys()) == set(full_osd["state"].keys())
for fqn, sub in full_osd["state"].items():
for k, v in sub.items():
torch.testing.assert_close(loaded["state"][fqn][k], v)
assert loaded["param_groups"] == full_osd["param_groups"]
@pytest.fixture @pytest.fixture
def base_params_dict(): def base_params_dict():
return { return {
+24
View File
@@ -23,6 +23,7 @@ import torch
pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])") pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])")
from huggingface_hub.constants import SAFETENSORS_SINGLE_FILE
from packaging import version from packaging import version
from safetensors.torch import load_file from safetensors.torch import load_file
@@ -300,6 +301,29 @@ def test_save_and_load_pretrained(dummy_dataset_metadata, tmp_path, policy_name:
torch.testing.assert_close(list(policy.parameters()), list(loaded_policy.parameters()), rtol=0, atol=0) torch.testing.assert_close(list(policy.parameters()), list(loaded_policy.parameters()), rtol=0, atol=0)
def test_save_pretrained_with_state_dict(dummy_dataset_metadata, tmp_path):
"""Exercise the FSDP checkpoint path: save_pretrained with a pre-gathered state_dict."""
policy_cls = get_policy_class("act")
policy_cfg = make_policy_config("act")
features = dataset_to_policy_features(dummy_dataset_metadata.features)
policy_cfg.output_features = {key: ft for key, ft in features.items() if ft.type is FeatureType.ACTION}
policy_cfg.input_features = {
key: ft for key, ft in features.items() if key not in policy_cfg.output_features
}
policy = policy_cls(policy_cfg)
policy.to(policy_cfg.device)
save_dir = tmp_path / "fsdp_state_dict"
policy.save_pretrained(save_dir, state_dict=policy.state_dict())
# A single, unsharded safetensors file (no sharded set + index).
assert (save_dir / SAFETENSORS_SINGLE_FILE).is_file()
assert not (save_dir / f"{SAFETENSORS_SINGLE_FILE}.index.json").exists()
loaded_policy = policy_cls.from_pretrained(save_dir, config=policy_cfg)
torch.testing.assert_close(list(policy.parameters()), list(loaded_policy.parameters()), rtol=0, atol=0)
@pytest.mark.parametrize("multikey", [True, False]) @pytest.mark.parametrize("multikey", [True, False])
def test_multikey_construction(multikey: bool): def test_multikey_construction(multikey: bool):
""" """
+108 -13
View File
@@ -58,7 +58,46 @@ def download_dataset(repo_id, episodes):
print(f"Dataset {repo_id} downloaded successfully") print(f"Dataset {repo_id} downloaded successfully")
def run_accelerate_training(config_args, num_processes=4, temp_dir=None): def _write_multi_gpu_config(f, num_processes):
f.write("compute_environment: LOCAL_MACHINE\n")
f.write("distributed_type: MULTI_GPU\n")
f.write("mixed_precision: 'no'\n")
f.write(f"num_processes: {num_processes}\n")
f.write("use_cpu: false\n")
f.write("gpu_ids: all\n")
f.write("downcast_bf16: 'no'\n")
f.write("machine_rank: 0\n")
f.write("main_training_function: main\n")
f.write("num_machines: 1\n")
f.write("rdzv_backend: static\n")
f.write("same_network: true\n")
def _write_fsdp_config(f, num_processes):
# FSDP1 with FULL_SHARD (ZeRO-3-equivalent) and FULL_STATE_DICT, matching
# docs/source/multi_gpu_training.mdx. ACT's repeated transformer blocks are the wrap units;
# fsdp_use_orig_params is required because LeRobot builds the optimizer before prepare().
f.write("compute_environment: LOCAL_MACHINE\n")
f.write("distributed_type: FSDP\n")
f.write("mixed_precision: 'no'\n")
f.write(f"num_processes: {num_processes}\n")
f.write("use_cpu: false\n")
f.write("gpu_ids: all\n")
f.write("machine_rank: 0\n")
f.write("main_training_function: main\n")
f.write("num_machines: 1\n")
f.write("rdzv_backend: static\n")
f.write("same_network: true\n")
f.write("fsdp_config:\n")
f.write(" fsdp_version: 1\n")
f.write(" fsdp_sharding_strategy: FULL_SHARD\n")
f.write(" fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP\n")
f.write(" fsdp_transformer_layer_cls_to_wrap: ACTEncoderLayer,ACTDecoderLayer\n")
f.write(" fsdp_use_orig_params: true\n")
f.write(" fsdp_state_dict_type: FULL_STATE_DICT\n")
def run_accelerate_training(config_args, num_processes=4, temp_dir=None, distributed_type="MULTI_GPU"):
""" """
Helper function to run training with accelerate launch. Helper function to run training with accelerate launch.
@@ -66,6 +105,7 @@ def run_accelerate_training(config_args, num_processes=4, temp_dir=None):
config_args: List of config arguments to pass to lerobot_train.py config_args: List of config arguments to pass to lerobot_train.py
num_processes: Number of processes (GPUs) to use num_processes: Number of processes (GPUs) to use
temp_dir: Temporary directory for outputs temp_dir: Temporary directory for outputs
distributed_type: "MULTI_GPU" (DDP) or "FSDP" selects the generated accelerate config.
Returns: Returns:
subprocess.CompletedProcess result subprocess.CompletedProcess result
@@ -75,18 +115,10 @@ def run_accelerate_training(config_args, num_processes=4, temp_dir=None):
# Write YAML config # Write YAML config
with open(config_path, "w") as f: with open(config_path, "w") as f:
f.write("compute_environment: LOCAL_MACHINE\n") if distributed_type == "FSDP":
f.write("distributed_type: MULTI_GPU\n") _write_fsdp_config(f, num_processes)
f.write("mixed_precision: 'no'\n") else:
f.write(f"num_processes: {num_processes}\n") _write_multi_gpu_config(f, num_processes)
f.write("use_cpu: false\n")
f.write("gpu_ids: all\n")
f.write("downcast_bf16: 'no'\n")
f.write("machine_rank: 0\n")
f.write("main_training_function: main\n")
f.write("num_machines: 1\n")
f.write("rdzv_backend: static\n")
f.write("same_network: true\n")
cmd = [ cmd = [
"accelerate", "accelerate",
@@ -211,3 +243,66 @@ class TestMultiGPUTraining:
# Verify optimizer state exists # Verify optimizer state exists
optimizer_state = training_state_dir / "optimizer_state.safetensors" optimizer_state = training_state_dir / "optimizer_state.safetensors"
assert optimizer_state.exists(), f"No optimizer state in checkpoint {checkpoint_dir}" assert optimizer_state.exists(), f"No optimizer state in checkpoint {checkpoint_dir}"
def test_fsdp_optimizer_save_and_resume(self):
"""
Test that FSDP saves the (gathered) optimizer state and can resume from it.
Trains a few steps under FSDP, verifies the gathered optimizer state is written next to the
rest of the training state, then resumes from the checkpoint for more steps and checks it
completes without shape/key errors in the FSDP optimizer load path.
"""
# Pre-download dataset to avoid race conditions
download_dataset("lerobot/pusht", episodes=[0])
with tempfile.TemporaryDirectory() as temp_dir:
output_dir = Path(temp_dir) / "outputs"
config_args = [
"--dataset.repo_id=lerobot/pusht",
"--dataset.episodes=[0]",
"--policy.type=act",
"--policy.device=cuda",
"--policy.push_to_hub=false",
f"--output_dir={output_dir}",
"--batch_size=4",
"--steps=10",
"--eval_freq=-1",
"--log_freq=5",
"--save_freq=10",
"--seed=42",
"--num_workers=0",
]
result = run_accelerate_training(
config_args, num_processes=2, temp_dir=temp_dir, distributed_type="FSDP"
)
assert result.returncode == 0, (
f"FSDP training failed:\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
)
# The gathered optimizer state must be written under FSDP (proves the save collective ran),
# in the same safetensors format as single-GPU training.
training_state_dir = output_dir / "checkpoints" / "last" / "training_state"
optimizer_state = training_state_dir / "optimizer_state.safetensors"
optimizer_param_groups = training_state_dir / "optimizer_param_groups.json"
assert optimizer_state.exists(), f"FSDP optimizer state not saved in {training_state_dir}"
assert optimizer_param_groups.exists(), (
f"FSDP optimizer param groups not saved in {training_state_dir}"
)
# Resume from the checkpoint for more steps. A successful run proves load_fsdp_optimizer
# accepts the saved state and reshards it without shape/key errors.
resume_config = output_dir / "checkpoints" / "last" / "pretrained_model" / "train_config.json"
resume_args = [
f"--config_path={resume_config}",
"--resume=true",
"--steps=20",
]
resume_result = run_accelerate_training(
resume_args, num_processes=2, temp_dir=temp_dir, distributed_type="FSDP"
)
assert resume_result.returncode == 0, (
f"FSDP resume failed:\nSTDOUT:\n{resume_result.stdout}\n\nSTDERR:\n{resume_result.stderr}"
)
assert "End of training" in resume_result.stdout or "End of training" in resume_result.stderr
+228
View File
@@ -0,0 +1,228 @@
#!/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.
"""Unit tests for the display-independent keyboard input helpers.
These cover the parts most likely to regress: the environment-detection decision
table (the heart of the Wayland/headless fix), the macOS trust probe, the control
mapping, the terminal escape-sequence parsing, and backend selection. They require
neither ``pynput`` nor a real terminal.
"""
import io
import platform
import sys
import pytest
import lerobot.utils.keyboard_input as ki
from lerobot.utils.keyboard_input import (
TerminalKeyListener,
apply_recording_control,
create_key_listener,
init_keyboard_listener,
is_headless,
is_wayland,
pynput_can_capture,
pynput_listener_is_trusted,
)
@pytest.fixture(autouse=True)
def _clear_detection_caches():
"""The detection helpers are ``@cache``-decorated; clear around each test."""
for fn in (is_headless, is_wayland, pynput_can_capture):
fn.cache_clear()
yield
for fn in (is_headless, is_wayland, pynput_can_capture):
fn.cache_clear()
def _set_platform(monkeypatch, name):
monkeypatch.setattr(platform, "system", lambda: name)
def _set_tty(monkeypatch, is_tty):
stdin = io.StringIO("")
stdin.isatty = lambda: is_tty
monkeypatch.setattr(sys, "stdin", stdin)
# --- Environment detection (the core of the fix) ---------------------------
@pytest.mark.parametrize(
("system", "env", "expected"),
[
("Linux", {}, True), # no display server
("Linux", {"DISPLAY": ":0"}, False), # X11
("Linux", {"WAYLAND_DISPLAY": "wayland-0"}, False), # Wayland
("Darwin", {}, False), # display always assumed present
],
)
def test_is_headless(monkeypatch, system, env, expected):
_set_platform(monkeypatch, system)
monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
for key, value in env.items():
monkeypatch.setenv(key, value)
assert is_headless() is expected
@pytest.mark.parametrize(
("env", "expected"),
[
({"XDG_SESSION_TYPE": "wayland"}, True),
({"WAYLAND_DISPLAY": "wayland-0"}, True),
({"XDG_SESSION_TYPE": "x11"}, False),
({}, False),
],
)
def test_is_wayland(monkeypatch, env, expected):
monkeypatch.delenv("XDG_SESSION_TYPE", raising=False)
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
for key, value in env.items():
monkeypatch.setenv(key, value)
assert is_wayland() is expected
@pytest.mark.parametrize(
("system", "env", "pynput_available", "expected"),
[
("Linux", {"DISPLAY": ":0"}, True, True), # X11
("Linux", {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"}, True, False), # Wayland
("Linux", {}, True, False), # headless
("Darwin", {}, True, True),
("Linux", {"DISPLAY": ":0"}, False, False), # pynput not installed
],
)
def test_pynput_can_capture(monkeypatch, system, env, pynput_available, expected):
_set_platform(monkeypatch, system)
monkeypatch.setattr(ki, "_pynput_available", pynput_available)
for var in ("DISPLAY", "WAYLAND_DISPLAY", "XDG_SESSION_TYPE"):
monkeypatch.delenv(var, raising=False)
for key, value in env.items():
monkeypatch.setenv(key, value)
assert pynput_can_capture() is expected
# --- macOS trust probe ------------------------------------------------------
class _FakeListener:
def __init__(self, is_trusted):
self.IS_TRUSTED = is_trusted
def test_pynput_listener_is_trusted(monkeypatch):
_set_platform(monkeypatch, "Linux")
assert pynput_listener_is_trusted(_FakeListener(False)) is True # non-macOS: always assumed ok
_set_platform(monkeypatch, "Darwin")
assert pynput_listener_is_trusted(_FakeListener(False), timeout_s=0.05) is False
# --- Control mapping --------------------------------------------------------
def test_apply_recording_control():
events = {"exit_early": False, "rerecord_episode": False, "stop_recording": False}
apply_recording_control("left", events)
assert events == {"exit_early": True, "rerecord_episode": True, "stop_recording": False}
apply_recording_control("esc", events)
assert events["stop_recording"] is True
apply_recording_control("up", events) # unknown control -> no-op (no error)
# --- Terminal escape-sequence parsing (the tricky bit) ----------------------
def _drive(listener, byte_seq):
"""Run the listener's read loop over a scripted list of bytes (no real terminal)."""
script = list(byte_seq)
def fake_read(timeout):
if script:
return script.pop(0)
listener._running = False
return None
listener._read_char = fake_read
listener._running = True
listener._run()
@pytest.mark.parametrize(
("byte_seq", "expected"),
[
(["\x1b", "[", "C"], ["right"]), # CSI arrow
(["\x1b", "O", "D"], ["left"]), # SS3 arrow (e.g. over SSH/tmux)
(["\x1b"], ["esc"]), # bare ESC
(["\x1b", "[", "A"], ["up"]), # decoded even though the record handler ignores it
(["n"], ["n"]), # letter passthrough
],
)
def test_terminal_parsing(byte_seq, expected):
collected = []
_drive(TerminalKeyListener(collected.append), byte_seq)
assert collected == expected
# --- Backend selection ------------------------------------------------------
def test_init_selects_terminal_when_pynput_cannot_capture(monkeypatch):
monkeypatch.setattr(ki, "pynput_can_capture", lambda: False)
_set_tty(monkeypatch, is_tty=True)
monkeypatch.setattr(TerminalKeyListener, "start", lambda self: None) # avoid touching termios
listener, _ = init_keyboard_listener()
assert isinstance(listener, TerminalKeyListener)
def test_init_returns_none_without_tty(monkeypatch):
monkeypatch.setattr(ki, "pynput_can_capture", lambda: False)
_set_tty(monkeypatch, is_tty=False)
listener, _ = init_keyboard_listener()
assert listener is None
@pytest.mark.parametrize(
("key", "flag"),
[("right", "exit_early"), ("r", "rerecord_episode"), ("q", "stop_recording")],
)
def test_init_terminal_key_routing(monkeypatch, key, flag):
"""Arrows and their letter equivalents drive the same events (terminal backend)."""
monkeypatch.setattr(ki, "pynput_can_capture", lambda: False)
_set_tty(monkeypatch, is_tty=True)
monkeypatch.setattr(TerminalKeyListener, "start", lambda self: None)
listener, events = init_keyboard_listener()
listener._on_key(key)
assert events[flag] is True
# --- Shared factory + pynput key resolver -----------------------------------
def test_resolve_pynput_key_char_fallback():
"""Unmapped keys fall back to ``.char`` (and yield None when there is none)."""
assert ki._resolve_pynput_key(type("K", (), {"char": "s"})()) == "s"
assert ki._resolve_pynput_key(type("K", (), {"char": None})()) is None
assert ki._resolve_pynput_key(type("K", (), {"char": ""})()) is None # empty char -> no key
def test_create_key_listener_routes_to_dispatch(monkeypatch):
"""The terminal backend forwards canonical key names straight to ``dispatch``."""
monkeypatch.setattr(ki, "pynput_can_capture", lambda: False)
_set_tty(monkeypatch, is_tty=True)
monkeypatch.setattr(TerminalKeyListener, "start", lambda self: None)
seen = []
listener = create_key_listener(seen.append, controls_help="save='s'")
assert isinstance(listener, TerminalKeyListener)
listener._on_key("space")
assert seen == ["space"]
def test_create_key_listener_none_without_tty(monkeypatch):
monkeypatch.setattr(ki, "pynput_can_capture", lambda: False)
_set_tty(monkeypatch, is_tty=False)
assert create_key_listener(lambda name: None) is None
+15
View File
@@ -136,3 +136,18 @@ def test_save_load_training_state(tmp_path, optimizer, scheduler):
assert loaded_step == 10 assert loaded_step == 10
assert loaded_optimizer is optimizer assert loaded_optimizer is optimizer
assert loaded_scheduler is scheduler assert loaded_scheduler is scheduler
def test_load_training_state_skip_optimizer(tmp_path, optimizer, scheduler):
# FSDP loads optimizer separately (after accelerator.prepare)
# load_training_state(load_optimizer=False) must restore step + scheduler but leave the
# optimizer untouched and never touch the on-disk optimizer state.
save_training_state(tmp_path, 10, optimizer, scheduler)
with patch("lerobot.common.train_utils.load_optimizer_state") as mock_load_optimizer_state:
loaded_step, loaded_optimizer, loaded_scheduler = load_training_state(
tmp_path, optimizer, scheduler, load_optimizer=False
)
mock_load_optimizer_state.assert_not_called()
assert loaded_step == 10
assert loaded_optimizer is optimizer
assert loaded_scheduler is scheduler
Generated
+35 -13
View File
@@ -768,34 +768,46 @@ wheels = [
[[package]] [[package]]
name = "cmeel-tinyxml2" name = "cmeel-tinyxml2"
version = "11.0.0" version = "10.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cmeel" }, { name = "cmeel" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/9b/96/4311533fee0a364bb605b585762f04c249f47857b33548a8ea837a7eb860/cmeel_tinyxml2-11.0.0.tar.gz", hash = "sha256:85d9c7680b3369af4c6b40a0dce70bbd84aa67832755622e57eb260cd95abe40", size = 645900, upload-time = "2026-05-21T11:49:32.652Z" } sdist = { url = "https://files.pythonhosted.org/packages/28/9f/030eca702c485f7a641f975f167fa93164911b3329f005fb0730ff5e793f/cmeel_tinyxml2-10.0.0.tar.gz", hash = "sha256:00252aefc1c94a55b89f25ad08ee79fda2da8d1d94703e051598ddb52a9088fe", size = 645297, upload-time = "2025-02-06T10:29:00.106Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/f0/90c1640c53b623359d75ab1c70bdf19dc0afe82722bc5df57d09f8eaf83a/cmeel_tinyxml2-11.0.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b0bd974e549b8c444626671a8e645897603ebf5225734cbe04a9dd3461477754", size = 111719, upload-time = "2026-05-21T11:49:25.999Z" }, { url = "https://files.pythonhosted.org/packages/fe/5d/bc3a932eb7996a0a789979426a9bb8a3948bf57f3f17bab87dddbef62433/cmeel_tinyxml2-10.0.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:924499bb1b60b9a17bd001d12a9af88ddbee4ca888638ae684ba7f0f3ce49e87", size = 111913, upload-time = "2025-02-06T10:28:45.723Z" },
{ url = "https://files.pythonhosted.org/packages/56/40/166447150a31bc3b794ffb493d5a634f67ffbc75dd8b4c46373701b7ef15/cmeel_tinyxml2-11.0.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9a1406f408262c37ae7c4566b1d67801c4b10c4980903fb1ef0ba45fa4407072", size = 109146, upload-time = "2026-05-21T11:49:27.829Z" }, { url = "https://files.pythonhosted.org/packages/92/bf/67d11e123313c034712896e94038291fe506bb099bdb75a136392002ffd0/cmeel_tinyxml2-10.0.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:26a1eb30c2a00bfc172e89ed015a18b8efb2b383546252ca8859574aed684686", size = 109487, upload-time = "2025-02-06T10:28:47.546Z" },
{ url = "https://files.pythonhosted.org/packages/d8/ca/3cc665afe2d76999f15454bb3b2f7c05f0088ad7de35718648291a536fd9/cmeel_tinyxml2-11.0.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6f830007917c3e36f26b27d170ce84a619a62f46104d3cce435dff0125dd665f", size = 157109, upload-time = "2026-05-21T11:49:29.358Z" }, { url = "https://files.pythonhosted.org/packages/ca/48/d8c81ce19b4b278ed0e8f81f93ae8670209bf3a9ac20141b9c386bb40cc7/cmeel_tinyxml2-10.0.0-0-py3-none-manylinux_2_17_i686.whl", hash = "sha256:53d86e02864c712f51f9a9adfcd8b6046b2ed51d44a0c34a8438d93b72b48325", size = 160118, upload-time = "2025-02-06T10:28:49.627Z" },
{ url = "https://files.pythonhosted.org/packages/87/4e/dcc0d9756d93be734d824e2a570cc9ac68909a1d7d3b6fc87c2fb32726c0/cmeel_tinyxml2-11.0.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:18674156bd41f3993dc1d5199da04fa496674358daa6588090fb9f86c71917b0", size = 148825, upload-time = "2026-05-21T11:49:31.035Z" }, { url = "https://files.pythonhosted.org/packages/87/4e/62193e27c9581f8ba7aeaeca7805632a64f2f4a824b1db37ad02ee953e8a/cmeel_tinyxml2-10.0.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:74112e2e9473afbf6ee2d25c9942553e9f6a40465e714533db72db48bc7658e1", size = 158477, upload-time = "2025-02-06T10:28:51.667Z" },
{ url = "https://files.pythonhosted.org/packages/14/f9/d0420c39e9ade99beeec61cd3abc68880fe6e14d85e9df292af8fabe65c8/cmeel_tinyxml2-10.0.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:ecd6e99caa2a06ac0d4b333b740c20fca526d0ca426f99eb5c0a0039117afdb6", size = 147025, upload-time = "2025-02-06T10:28:53.944Z" },
{ url = "https://files.pythonhosted.org/packages/66/9e/df63147fc162ab487217fa5596778ab7a81a82d9b3ce4236fd3a1e48cecb/cmeel_tinyxml2-10.0.0-0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:30993fffb7032a45d5d3b1e5670cb879dad667a13144cd68c8f4e0371a8a3d2e", size = 150958, upload-time = "2025-02-06T10:28:55.301Z" },
{ url = "https://files.pythonhosted.org/packages/0e/a8/b03567275fd83f5af33ddb61de942689dec72c5b21bec01e6a5b11101aa5/cmeel_tinyxml2-10.0.0-0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8c09ede51784af54211a6225884dc7ddbb02ea1681656d173060c7ad2a5b9a3c", size = 160300, upload-time = "2025-02-06T10:28:57.189Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ec/2781635b66c1059ca1243ae0f5a0410e171a5d8b8a71be3e34cb172f9f2d/cmeel_tinyxml2-10.0.0-0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3bd511d6d0758224efdebc23d3ead6e94f0755b04141ebf7d5493377829e8332", size = 149184, upload-time = "2025-02-06T10:28:58.734Z" },
] ]
[[package]] [[package]]
name = "cmeel-urdfdom" name = "cmeel-urdfdom"
version = "6.0.0" version = "4.0.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cmeel" }, { name = "cmeel" },
{ name = "cmeel-console-bridge" }, { name = "cmeel-console-bridge" },
{ name = "cmeel-tinyxml2" }, { name = "cmeel-tinyxml2" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/21/75/4e8aff079e98582aeeb8e752805081da0c2dea405e79bafeefb555defe9f/cmeel_urdfdom-6.0.0.tar.gz", hash = "sha256:65c0fdc6021300fc55b2d0c03ab64dedc328034a74e40498e671bc894bb1dcf7", size = 303688, upload-time = "2026-05-21T12:08:56.663Z" } sdist = { url = "https://files.pythonhosted.org/packages/31/09/be81a5e7db56f34b6ccdbe7afe855c95a18c8439e173519e0146e9276a8c/cmeel_urdfdom-4.0.1.tar.gz", hash = "sha256:2e3f41e8483889e195b574acb326a4464cf11a3c0a8724031ac28bcda2223efc", size = 291511, upload-time = "2025-02-12T12:07:09.699Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/40/51ba667135f01631179eee1614557193f8453740f248302d1b8b7f9f693e/cmeel_urdfdom-6.0.0-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:53d55cebb137a6e4dac6c16fa53f2dc2b7b9b5cda644bd1637a5bb849cd96e52", size = 381501, upload-time = "2026-05-21T12:08:48.758Z" }, { url = "https://files.pythonhosted.org/packages/be/d0/20147dd6bb723afc44a58d89ea624df2bad1bed7b898a2df112aaca4a479/cmeel_urdfdom-4.0.1-0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:2fe56939c6b47f6ec57021aac154123da47ecdcd79a217f3a5e3c4b705a07dee", size = 300860, upload-time = "2025-02-12T12:06:58.536Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d1/2b49a8c940fa75abc13df9842c14e577e6a82d5854b6d52597ce3bb04894/cmeel_urdfdom-6.0.0-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0ef424735bd30f4afa4d1b4ddca9b297498c43005ddd775c080e55f62e9e0466", size = 377159, upload-time = "2026-05-21T12:08:50.485Z" }, { url = "https://files.pythonhosted.org/packages/8e/98/f832bca347e2d987c6b0ebb6930caf7b2c402535324aeed466b6aa2c4513/cmeel_urdfdom-4.0.1-0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:00a0aba78b68c428b27abeed1db58d73e65319ed966911a0e97b37367442e756", size = 300616, upload-time = "2025-02-12T12:07:00.556Z" },
{ url = "https://files.pythonhosted.org/packages/db/ac/0efde3a48220b55707bafb6d2e2dcca562f99dcd5c2c15311f7696eeacce/cmeel_urdfdom-6.0.0-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0436f5230f1484c8e583284ef48c7291b230ada3dc5fb2937941f582e72409ec", size = 506000, upload-time = "2026-05-21T12:08:52.273Z" }, { url = "https://files.pythonhosted.org/packages/cf/10/bf5765b6f388037cff166a754a0958ac2fee34ca3c0975ef64d0324e4647/cmeel_urdfdom-4.0.1-0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a701a8f9671331f11b18ecf37a6537db546a21e6a0e5d0ff53341fea0693ed7f", size = 385951, upload-time = "2025-02-12T12:07:02.556Z" },
{ url = "https://files.pythonhosted.org/packages/32/d4/dfd617e598100e4e53ae3d228a968facff80bae53038fb18e2dccb1ab03a/cmeel_urdfdom-6.0.0-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7ab1be680a8ec866d5422c617b641d1f0e38774061df28b8b426fb26edce6337", size = 530049, upload-time = "2026-05-21T12:08:54.224Z" }, { url = "https://files.pythonhosted.org/packages/c3/82/cb3f8f587d293a17bdbea15b50cdaa4a1e28e04583eb4cb4821685b89466/cmeel_urdfdom-4.0.1-0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:12e39fc388c077d79fc9b3841d3d972a1da90b90de754d3363194c1540e18abf", size = 399619, upload-time = "2025-02-12T12:07:04.388Z" },
{ url = "https://files.pythonhosted.org/packages/24/77/322d7ac92c692d8dfaeda9de2d937087d15e2b564dc457d656e5fde3991d/cmeel_urdfdom-4.0.1-0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c4a83925df1d5923c4485c3eb2b80b3a61b14f119ab724fb5bd04cec494690ee", size = 373969, upload-time = "2025-02-12T12:07:06.222Z" },
{ url = "https://files.pythonhosted.org/packages/9f/63/bdc6b55cc8bd99bb9dce6be801b30feffaa1c3841ecb7f4fe4d137424518/cmeel_urdfdom-4.0.1-0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4c4f44270971b3d05c45a4e21b1fb2df7e05a750363ae918f59532bff0bfe0e1", size = 388237, upload-time = "2025-02-12T12:07:08.326Z" },
{ url = "https://files.pythonhosted.org/packages/1d/2d/8463fc23230612daf4da1e31d3229f47708381f3ae4d1500f0f007ac0f92/cmeel_urdfdom-4.0.1-1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:f7535158f45992eb2ba79e90d9db1bf9adc3846d9c7ed3e7a8c1c4d5343afa37", size = 301006, upload-time = "2025-02-13T11:42:08.8Z" },
{ url = "https://files.pythonhosted.org/packages/0f/d5/c8cdf500e49300d85624cbc3ef804107ddcdc9c541b1d3f726bfb58a9fc1/cmeel_urdfdom-4.0.1-1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fef2a01a00d61d41b3d35dd4958bba973e9025c26eea1d3c9880932f4dba89a5", size = 300758, upload-time = "2025-02-13T11:42:10.449Z" },
{ url = "https://files.pythonhosted.org/packages/cf/b3/2f7bac1544113a7f8e0f6d8b1fab5e75c6a3d27ffbb584b03267251b2165/cmeel_urdfdom-4.0.1-1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7a52eb36950ce982014d99a55717ca29985da056e3705f20746f15d3244c1f7a", size = 386043, upload-time = "2025-02-13T11:42:11.923Z" },
{ url = "https://files.pythonhosted.org/packages/86/03/8bdeb36ba6a3e8125d523ecfc010403049e463fe589f9896858d4bdcaf1e/cmeel_urdfdom-4.0.1-1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9f3b9c80b10d7246821ff61c2573f799e3da23d483e6f7367ddcad8a48baf58f", size = 399719, upload-time = "2025-02-13T11:42:14.325Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ed/43f99e7512460294cd8acc5753ba25f8a20bdf28d62e143eaf3ec7a28bb6/cmeel_urdfdom-4.0.1-1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2de69f47e8312cc09157624802d5bdaad6406443f863fb4b9ec62a19b4de3c72", size = 374073, upload-time = "2025-02-13T11:42:17.907Z" },
{ url = "https://files.pythonhosted.org/packages/17/c6/2e9bde6d7c02c1cf203ea896f8ce1afd441412f09b44830f1ee4a96d77de/cmeel_urdfdom-4.0.1-1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7708c1402de450fbeab21f7ca264a9a4676ed4c1cdf8d84d840bc5d057aac920", size = 388337, upload-time = "2025-02-13T11:42:19.657Z" },
] ]
[[package]] [[package]]
@@ -1147,7 +1159,7 @@ name = "decord"
version = "0.6.0" version = "0.6.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ 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')" }, { name = "numpy", marker = "(platform_machine != 'arm64' and platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux')" },
] ]
wheels = [ 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" }, { 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" },
@@ -2788,6 +2800,8 @@ accelerate-dep = [
all = [ all = [
{ name = "accelerate" }, { name = "accelerate" },
{ name = "av" }, { name = "av" },
{ name = "cmeel-tinyxml2" },
{ name = "cmeel-urdfdom" },
{ name = "contourpy" }, { name = "contourpy" },
{ name = "datasets" }, { name = "datasets" },
{ name = "debugpy" }, { name = "debugpy" },
@@ -2971,6 +2985,8 @@ hardware = [
] ]
hilserl = [ hilserl = [
{ name = "av" }, { name = "av" },
{ name = "cmeel-tinyxml2" },
{ name = "cmeel-urdfdom" },
{ name = "datasets" }, { name = "datasets" },
{ name = "grpcio" }, { name = "grpcio" },
{ name = "gym-hil" }, { name = "gym-hil" },
@@ -2993,6 +3009,8 @@ intelrealsense = [
{ name = "pyrealsense2-macosx", marker = "sys_platform == 'darwin'" }, { name = "pyrealsense2-macosx", marker = "sys_platform == 'darwin'" },
] ]
kinematics = [ kinematics = [
{ name = "cmeel-tinyxml2" },
{ name = "cmeel-urdfdom" },
{ name = "placo" }, { name = "placo" },
] ]
lekiwi = [ lekiwi = [
@@ -3066,6 +3084,8 @@ pi = [
{ name = "transformers" }, { name = "transformers" },
] ]
placo-dep = [ placo-dep = [
{ name = "cmeel-tinyxml2" },
{ name = "cmeel-urdfdom" },
{ name = "placo" }, { name = "placo" },
] ]
pusht = [ pusht = [
@@ -3186,6 +3206,8 @@ requires-dist = [
{ name = "accelerate", marker = "extra == 'accelerate-dep'", specifier = ">=1.14.0,<2.0.0" }, { name = "accelerate", marker = "extra == 'accelerate-dep'", specifier = ">=1.14.0,<2.0.0" },
{ name = "av", marker = "extra == 'av-dep'", specifier = ">=15.0.0,<16.0.0" }, { name = "av", marker = "extra == 'av-dep'", specifier = ">=15.0.0,<16.0.0" },
{ name = "cmake", specifier = ">=3.29.0.1,<4.2.0" }, { name = "cmake", specifier = ">=3.29.0.1,<4.2.0" },
{ name = "cmeel-tinyxml2", marker = "extra == 'placo-dep'", specifier = "<11" },
{ name = "cmeel-urdfdom", marker = "extra == 'placo-dep'", specifier = ">=4,<5" },
{ name = "contourpy", marker = "extra == 'matplotlib-dep'", specifier = ">=1.3.0,<2.0.0" }, { name = "contourpy", marker = "extra == 'matplotlib-dep'", specifier = ">=1.3.0,<2.0.0" },
{ name = "datasets", marker = "extra == 'dataset'", specifier = ">=4.7.0,<5.0.0" }, { name = "datasets", marker = "extra == 'dataset'", specifier = ">=4.7.0,<5.0.0" },
{ name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.1,<1.9.0" }, { name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.1,<1.9.0" },