Compare commits

..

2 Commits

Author SHA1 Message Date
Steven Palma 0371e99117 feat(policiy): fine tune vision encoder refactor completness 2026-07-29 18:35:24 +02:00
Functionhx 9e30807eeb feat: add fine_tune_vision_encoder flag to SmolVLA
Fixes #1774
2026-07-29 18:22:01 +02:00
26 changed files with 178 additions and 512 deletions
+10 -13
View File
@@ -61,20 +61,16 @@ Full details in [`docs/source/so101.mdx`](./docs/source/so101.mdx) and [`docs/so
**4.1 Install** **4.1 Install**
```bash ```bash
# uv (recommended — see AGENTS.md and CLAUDE.md) pip install 'lerobot[feetech]' # SO-100/SO-101 motor stack
uv sync --locked --extra feetech # SO-100/SO-101 motor stack # pip install 'lerobot[all]' # everything
# uv sync --locked --extra all # everything # pip install 'lerobot[aloha,pusht]' # specific features
# uv sync --locked --extra smolvla # add SmolVLA deps # pip install 'lerobot[smolvla]' # add SmolVLA deps
# pip (alternative, e.g. when not working from source)
# pip install 'lerobot[feetech]'
# pip install 'lerobot[all]'
# pip install 'lerobot[smolvla]'
git lfs install && git lfs pull git lfs install && git lfs pull
hf auth login # required to push datasets/policies hf auth login # required to push datasets/policies
``` ```
Contributors can alternatively use `uv sync --locked --extra feetech` (see `AGENTS.md`).
**4.2 Find USB ports** — run once per arm, unplug when prompted. **4.2 Find USB ports** — run once per arm, unplug when prompted.
```bash ```bash
@@ -325,10 +321,11 @@ SmolVLA ships with `freeze_vision_encoder=True`. Unfreezing usually **improves p
```bash ```bash
lerobot-train ... --policy.type=smolvla \ lerobot-train ... --policy.type=smolvla \
--policy.freeze_vision_encoder=false \ --policy.fine_tune_vision_encoder=true
--policy.train_expert_only=false
``` ```
This selectively trains the vision encoder and connector while leaving the language model frozen. Their learning rate defaults to `0.1 × optimizer_lr`; adjust it with `--policy.vision_encoder_lr_multiplier` if needed.
### 7.7 Signals to stop / keep going ### 7.7 Signals to stop / keep going
- Train loss plateaus → stop, save a Hub checkpoint. - Train loss plateaus → stop, save a Hub checkpoint.
+15 -2
View File
@@ -88,6 +88,20 @@ policy_preprocessor = NormalizerProcessorStep(stats=dataset_stats)
The same policy can work with different environment processors, and the same environment processor can work with different policies: The same policy can work with different environment processors, and the same environment processor can work with different policies:
````python
# Use SmolVLA policy with LIBERO environment
# Use SmolVLA policy with LIBERO environment
libero_preprocessor, libero_postprocessor = make_env_pre_post_processors(
env_cfg=libero_cfg,
policy_cfg=smolvla_cfg,
)
smolvla_preprocessor, smolvla_postprocessor = make_pre_post_processors(smolvla_cfg)
# Or use ACT policy with the same LIBERO environment
libero_preprocessor, libero_postprocessor = make_env_pre_post_processors(
env_cfg=libero_cfg,
policy_cfg=act_cfg,
)
act_preprocessor, act_postprocessor = make_pre_post_processors(act_cfg)
```python ```python
# Use SmolVLA policy with LIBERO environment # Use SmolVLA policy with LIBERO environment
libero_preprocessor, libero_postprocessor = make_env_pre_post_processors( libero_preprocessor, libero_postprocessor = make_env_pre_post_processors(
@@ -102,7 +116,6 @@ libero_preprocessor, libero_postprocessor = make_env_pre_post_processors(
policy_cfg=act_cfg, policy_cfg=act_cfg,
) )
act_preprocessor, act_postprocessor = make_pre_post_processors(act_cfg) act_preprocessor, act_postprocessor = make_pre_post_processors(act_cfg)
```
### 3. **Easier Experimentation** ### 3. **Easier Experimentation**
@@ -132,7 +145,7 @@ class LiberoVelocityProcessorStep(ObservationProcessorStep):
state = torch.cat([eef_pos, eef_axisangle, eef_vel, state = torch.cat([eef_pos, eef_axisangle, eef_vel,
gripper_pos, gripper_vel], dim=-1) # 14D gripper_pos, gripper_vel], dim=-1) # 14D
return state return state
``` ````
### 4. **Cleaner Environment Code** ### 4. **Cleaner Environment Code**
+1 -1
View File
@@ -211,7 +211,7 @@ Record, Replay and Train with Hope-JR is still experimental.
### Record ### Record
This step records the dataset, which can be seen as an example [here](https://huggingface.co/datasets/nepyope/hand_record_test_with_video_data). This step records the dataset, which can be seen as an example [here](https://huggingface.co/datasets/nepyope/hand_record_test_with_video_data/settings).
```bash ```bash
lerobot-record \ lerobot-record \
+1 -1
View File
@@ -18,7 +18,7 @@ If you're using Feetech or Dynamixel motors, LeRobot provides built-in bus inter
- [`DynamixelMotorsBus`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/motors/dynamixel/dynamixel.py) for controlling Dynamixel servos - [`DynamixelMotorsBus`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/motors/dynamixel/dynamixel.py) for controlling Dynamixel servos
Please refer to the [`MotorsBus`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/motors/motors_bus.py) abstract class to learn about its API. Please refer to the [`MotorsBus`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/motors/motors_bus.py) abstract class to learn about its API.
For a good example of how it can be used, you can have a look at our own [SO101 follower implementation](https://github.com/huggingface/lerobot/blob/main/src/lerobot/robots/so_follower/so_follower.py) For a good example of how it can be used, you can have a look at our own [SO101 follower implementation](https://github.com/huggingface/lerobot/blob/main/src/lerobot/robots/so_follower/so101_follower/so101_follower.py)
Use these if compatible. Otherwise, you'll need to find or write a Python interface (not covered in this tutorial): Use these if compatible. Otherwise, you'll need to find or write a Python interface (not covered in this tutorial):
+1 -1
View File
@@ -51,7 +51,7 @@ In addition to these instructions, you need to install the Feetech SDK & ZeroMQ
pip install -e ".[lekiwi]" pip install -e ".[lekiwi]"
``` ```
Great 🤗! You are now done installing LeRobot, and we can begin assembling the SO100/SO101 arms and the mobile base 🤖. Great :hugs:! You are now done installing LeRobot, and we can begin assembling the SO100/SO101 arms and the mobile base :robot:.
Every time you now want to use LeRobot, you can go to the `~/lerobot` folder where we installed LeRobot and run one of the commands. Every time you now want to use LeRobot, you can go to the `~/lerobot` folder where we installed LeRobot and run one of the commands.
# Step-by-Step Assembly Instructions # Step-by-Step Assembly Instructions
+1 -1
View File
@@ -174,7 +174,7 @@ The model takes images, text instructions, and robot state as input, and outputs
## Reproducing π₀Fast results ## Reproducing π₀Fast results
We reproduce the results of π₀Fast on the LIBERO benchmark using the LeRobot implementation. We take the LeRobot PiFast base model [lerobot/pi0fast-base](https://huggingface.co/lerobot/pi0fast-base) and finetune for an additional 40k steps in bfloat16, with batch size of 256 on 8 H100 GPUs using the [HuggingFace LIBERO dataset](https://huggingface.co/datasets/HuggingFaceVLA/libero). We reproduce the results of π₀Fast on the LIBERO benchmark using the LeRobot implementation. We take the LeRobot PiFast base model [lerobot/pi0fast-base](https://huggingface.co/lerobot/pi0fast-base) and finetune for an additional 40kk steps in bfloat16, with batch size of 256 on 8 H100 GPUs using the [HuggingFace LIBERO dataset](https://huggingface.co/datasets/HuggingFaceVLA/libero).
The finetuned model can be found here: The finetuned model can be found here:
+14 -1
View File
@@ -70,6 +70,19 @@ cd lerobot && lerobot-train \
GPU allows it, as long as loading times remain short. GPU allows it, as long as loading times remain short.
</Tip> </Tip>
For tasks that require adapting visual features, such as distinguishing new colors or shapes, selectively
fine-tune the vision encoder and its connector:
```bash
lerobot-train ... \
--policy.path=lerobot/smolvla_base \
--policy.fine_tune_vision_encoder=true
```
This keeps the language model frozen with the default `train_expert_only=true` setting and trains the vision
path at `0.1` times the main learning rate by default. Fine-tuning the vision encoder increases memory use and
can reduce the model's general visual knowledge, so enable it only when the frozen encoder is insufficient.
Fine-tuning is an art. For a complete overview of the options for finetuning, run Fine-tuning is an art. For a complete overview of the options for finetuning, run
```bash ```bash
@@ -93,7 +106,7 @@ lerobot-train --help
## Evaluate the finetuned model and run it in real-time ## Evaluate the finetuned model and run it in real-time
Similarly for when recording an episode, it is recommended that you are logged in to the HuggingFace Hub. You can follow the corresponding steps: [Record a dataset](./il_robots#record-a-dataset). Similarly for when recording an episode, it is recommended that you are logged in to the HuggingFace Hub. You can follow the corresponding steps: [Record a dataset](./il_robots).
Once you are logged in, you can run inference in your setup by doing: Once you are logged in, you can run inference in your setup by doing:
```bash ```bash
@@ -67,6 +67,8 @@ class SmolVLAConfig(PreTrainedConfig):
# Finetuning settings # Finetuning settings
freeze_vision_encoder: bool = True freeze_vision_encoder: bool = True
fine_tune_vision_encoder: bool = False # Fine-tune vision + connector; takes priority over freezing.
vision_encoder_lr_multiplier: float = 0.1
train_expert_only: bool = True train_expert_only: bool = True
train_state_proj: bool = True train_state_proj: bool = True
@@ -110,6 +112,12 @@ class SmolVLAConfig(PreTrainedConfig):
super().__post_init__() super().__post_init__()
"""Input validation (not exhaustive).""" """Input validation (not exhaustive)."""
if self.fine_tune_vision_encoder:
self.freeze_vision_encoder = False
if self.vision_encoder_lr_multiplier <= 0:
raise ValueError(
f"`vision_encoder_lr_multiplier` must be positive, got {self.vision_encoder_lr_multiplier}."
)
if self.n_action_steps > self.chunk_size: if self.n_action_steps > self.chunk_size:
raise ValueError( raise ValueError(
f"The chunk size is the upper bound for the number of action steps per model invocation. Got " f"The chunk size is the upper bound for the number of action steps per model invocation. Got "
@@ -186,8 +186,27 @@ class SmolVLAPolicy(PreTrainedPolicy):
if model_value is not None: if model_value is not None:
model_value.rtc_processor = self.rtc_processor model_value.rtc_processor = self.rtc_processor
def get_optim_params(self) -> dict: def get_optim_params(self):
return self.parameters() if not self.config.fine_tune_vision_encoder:
return self.parameters()
vision_params = []
other_params = []
for name, param in self.named_parameters():
if not param.requires_grad:
continue
if ".vision_model." in name or ".connector." in name:
vision_params.append(param)
else:
other_params.append(param)
return [
{"params": other_params},
{
"params": vision_params,
"lr": self.config.optimizer_lr * self.config.vision_encoder_lr_multiplier,
},
]
def _get_action_chunk( def _get_action_chunk(
self, batch: dict[str, Tensor], noise: Tensor | None = None, **kwargs: Unpack[ActionSelectKwargs] self, batch: dict[str, Tensor], noise: Tensor | None = None, **kwargs: Unpack[ActionSelectKwargs]
@@ -493,6 +512,7 @@ class VLAFlowMatching(nn.Module):
self.vlm_with_expert = SmolVLMWithExpertModel( self.vlm_with_expert = SmolVLMWithExpertModel(
model_id=self.config.vlm_model_name, model_id=self.config.vlm_model_name,
freeze_vision_encoder=self.config.freeze_vision_encoder, freeze_vision_encoder=self.config.freeze_vision_encoder,
fine_tune_vision_encoder=self.config.fine_tune_vision_encoder,
train_expert_only=self.config.train_expert_only, train_expert_only=self.config.train_expert_only,
load_vlm_weights=self.config.load_vlm_weights, load_vlm_weights=self.config.load_vlm_weights,
attention_mode=self.config.attention_mode, attention_mode=self.config.attention_mode,
@@ -78,6 +78,7 @@ class SmolVLMWithExpertModel(nn.Module):
load_vlm_weights: bool = True, load_vlm_weights: bool = True,
train_expert_only: bool = True, train_expert_only: bool = True,
freeze_vision_encoder: bool = False, freeze_vision_encoder: bool = False,
fine_tune_vision_encoder: bool = False,
attention_mode: str = "self_attn", attention_mode: str = "self_attn",
num_expert_layers: int = -1, num_expert_layers: int = -1,
num_vlm_layers: int = -1, num_vlm_layers: int = -1,
@@ -141,6 +142,7 @@ class SmolVLMWithExpertModel(nn.Module):
self.num_key_value_heads = self.config.text_config.num_key_value_heads self.num_key_value_heads = self.config.text_config.num_key_value_heads
self.freeze_vision_encoder = freeze_vision_encoder self.freeze_vision_encoder = freeze_vision_encoder
self.fine_tune_vision_encoder = fine_tune_vision_encoder
self.train_expert_only = train_expert_only self.train_expert_only = train_expert_only
self.attention_mode = attention_mode self.attention_mode = attention_mode
self.expert_hidden_size = lm_expert_config.hidden_size self.expert_hidden_size = lm_expert_config.hidden_size
@@ -150,10 +152,6 @@ class SmolVLMWithExpertModel(nn.Module):
return self.vlm.model return self.vlm.model
def set_requires_grad(self): def set_requires_grad(self):
if self.freeze_vision_encoder:
self.get_vlm_model().vision_model.eval()
for params in self.get_vlm_model().vision_model.parameters():
params.requires_grad = False
if self.train_expert_only: if self.train_expert_only:
self.vlm.eval() self.vlm.eval()
for params in self.vlm.parameters(): for params in self.vlm.parameters():
@@ -176,6 +174,18 @@ class SmolVLMWithExpertModel(nn.Module):
for name, params in self.vlm.named_parameters(): for name, params in self.vlm.named_parameters():
if any(k in name for k in frozen_layers): if any(k in name for k in frozen_layers):
params.requires_grad = False params.requires_grad = False
if self.freeze_vision_encoder:
self.get_vlm_model().vision_model.eval()
for params in self.get_vlm_model().vision_model.parameters():
params.requires_grad = False
if self.fine_tune_vision_encoder:
for params in self.get_vlm_model().vision_model.parameters():
params.requires_grad = True
for params in self.get_vlm_model().connector.parameters():
params.requires_grad = True
# To avoid unused params issue with distributed training # To avoid unused params issue with distributed training
for name, params in self.lm_expert.named_parameters(): for name, params in self.lm_expert.named_parameters():
if "lm_head" in name: if "lm_head" in name:
@@ -184,11 +194,15 @@ class SmolVLMWithExpertModel(nn.Module):
def train(self, mode: bool = True): def train(self, mode: bool = True):
super().train(mode) super().train(mode)
if self.train_expert_only:
self.vlm.eval()
if self.freeze_vision_encoder: if self.freeze_vision_encoder:
self.get_vlm_model().vision_model.eval() self.get_vlm_model().vision_model.eval()
if self.train_expert_only: if self.fine_tune_vision_encoder:
self.vlm.eval() self.get_vlm_model().vision_model.train(mode)
self.get_vlm_model().connector.train(mode)
def embed_image(self, image: torch.Tensor): def embed_image(self, image: torch.Tensor):
patch_attention_mask = None patch_attention_mask = None
+2 -2
View File
@@ -18,7 +18,7 @@ import functools
import threading import threading
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from contextlib import suppress from contextlib import suppress
from typing import NotRequired, TypedDict from typing import TypedDict
import torch import torch
import torch.nn.functional as F # noqa: N812 import torch.nn.functional as F # noqa: N812
@@ -36,7 +36,7 @@ class BatchTransition(TypedDict):
next_state: dict[str, torch.Tensor] next_state: dict[str, torch.Tensor]
done: torch.Tensor done: torch.Tensor
truncated: torch.Tensor truncated: torch.Tensor
complementary_info: NotRequired[dict[str, torch.Tensor | float | int] | None] complementary_info: dict[str, torch.Tensor | float | int] | None = None
def random_crop_vectorized(images: torch.Tensor, output_size: tuple) -> torch.Tensor: def random_crop_vectorized(images: torch.Tensor, output_size: tuple) -> torch.Tensor:
@@ -87,11 +87,6 @@ import tqdm
from lerobot.configs import DEPTH_MILLIMETER_UNIT from lerobot.configs import DEPTH_MILLIMETER_UNIT
from lerobot.datasets import LeRobotDataset from lerobot.datasets import LeRobotDataset
from lerobot.utils.constants import ACTION, DONE, OBS_STATE, REWARD, SUCCESS from lerobot.utils.constants import ACTION, DONE, OBS_STATE, REWARD, SUCCESS
from lerobot.utils.dataset_visualization_utils import (
get_extra_scalar_keys,
is_scalar_like,
scalar_to_float,
)
from lerobot.utils.utils import init_logging from lerobot.utils.utils import init_logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -158,8 +153,6 @@ def build_blueprint_from_dataset(dataset: LeRobotDataset):
for key in (DONE, REWARD, SUCCESS): for key in (DONE, REWARD, SUCCESS):
if key in dataset.features: if key in dataset.features:
views.append(rrb.TimeSeriesView(origin=key, name=key)) views.append(rrb.TimeSeriesView(origin=key, name=key))
for key in get_extra_scalar_keys(dataset):
views.append(rrb.TimeSeriesView(origin=key, name=key))
return rrb.Blueprint(rrb.Grid(*views)) return rrb.Blueprint(rrb.Grid(*views))
@@ -251,8 +244,6 @@ def visualize_dataset(
hi = stats["q99"] if "q99" in stats else stats["max"] hi = stats["q99"] if "q99" in stats else stats["max"]
depth_ranges[key] = (float(np.asarray(lo).item()), float(np.asarray(hi).item())) depth_ranges[key] = (float(np.asarray(lo).item()), float(np.asarray(hi).item()))
extra_scalar_keys = get_extra_scalar_keys(dataset)
first_index = None first_index = None
for batch in tqdm.tqdm(dataloader, total=len(dataloader)): for batch in tqdm.tqdm(dataloader, total=len(dataloader)):
if first_index is None: if first_index is None:
@@ -296,10 +287,6 @@ def visualize_dataset(
if SUCCESS in batch: if SUCCESS in batch:
rr.log(SUCCESS, rr.Scalars(batch[SUCCESS][i].item())) rr.log(SUCCESS, rr.Scalars(batch[SUCCESS][i].item()))
for key in extra_scalar_keys:
if key in batch and is_scalar_like(batch[key][i]):
rr.log(key, rr.Scalars(scalar_to_float(batch[key][i])))
# save .rrd locally # save .rrd locally
if mode == "local" and save: if mode == "local" and save:
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
+49 -38
View File
@@ -28,6 +28,7 @@ lerobot-find-cameras
# NOTE(Steven): macOS cameras sometimes report different FPS at init time, not an issue here as we don't specify FPS when opening the cameras, but the information displayed might not be truthful. # NOTE(Steven): macOS cameras sometimes report different FPS at init time, not an issue here as we don't specify FPS when opening the cameras, but the information displayed might not be truthful.
import argparse import argparse
import concurrent.futures
import logging import logging
import time import time
from pathlib import Path from pathlib import Path
@@ -132,7 +133,7 @@ def save_image(
camera_identifier: str | int, camera_identifier: str | int,
images_dir: Path, images_dir: Path,
camera_type: str, camera_type: str,
) -> None: ):
""" """
Saves a single image to disk using Pillow. Handles color conversion if necessary. Saves a single image to disk using Pillow. Handles color conversion if necessary.
""" """
@@ -151,7 +152,7 @@ def save_image(
logger.error(f"Failed to save image for camera {camera_identifier} (type {camera_type}): {e}") logger.error(f"Failed to save image for camera {camera_identifier} (type {camera_type}): {e}")
def create_camera_instance(cam_meta: dict[str, Any], *, warmup_s: int = 1) -> dict[str, Any] | None: def create_camera_instance(cam_meta: dict[str, Any]) -> dict[str, Any] | None:
"""Create and connect to a camera instance based on metadata.""" """Create and connect to a camera instance based on metadata."""
cam_type = cam_meta.get("type") cam_type = cam_meta.get("type")
cam_id = cam_meta.get("id") cam_id = cam_meta.get("id")
@@ -164,14 +165,12 @@ def create_camera_instance(cam_meta: dict[str, Any], *, warmup_s: int = 1) -> di
cv_config = OpenCVCameraConfig( cv_config = OpenCVCameraConfig(
index_or_path=cam_id, index_or_path=cam_id,
color_mode=ColorMode.RGB, color_mode=ColorMode.RGB,
warmup_s=warmup_s,
) )
instance = OpenCVCamera(cv_config) instance = OpenCVCamera(cv_config)
elif cam_type == "RealSense": elif cam_type == "RealSense":
rs_config = RealSenseCameraConfig( rs_config = RealSenseCameraConfig(
serial_number_or_name=cam_id, serial_number_or_name=cam_id,
color_mode=ColorMode.RGB, color_mode=ColorMode.RGB,
warmup_s=warmup_s,
) )
instance = RealSenseCamera(rs_config) instance = RealSenseCamera(rs_config)
else: else:
@@ -189,7 +188,9 @@ def create_camera_instance(cam_meta: dict[str, Any], *, warmup_s: int = 1) -> di
return None return None
def process_camera_image(cam_dict: dict[str, Any], output_dir: Path, current_time: float) -> None: def process_camera_image(
cam_dict: dict[str, Any], output_dir: Path, current_time: float
) -> concurrent.futures.Future | None:
"""Capture and process an image from a single camera.""" """Capture and process an image from a single camera."""
cam = cam_dict["instance"] cam = cam_dict["instance"]
meta = cam_dict["meta"] meta = cam_dict["meta"]
@@ -199,7 +200,7 @@ def process_camera_image(cam_dict: dict[str, Any], output_dir: Path, current_tim
try: try:
image_data = cam.read() image_data = cam.read()
save_image( return save_image(
image_data, image_data,
cam_id_str, cam_id_str,
output_dir, output_dir,
@@ -214,21 +215,21 @@ def process_camera_image(cam_dict: dict[str, Any], output_dir: Path, current_tim
return None return None
def cleanup_camera(cam_dict: dict[str, Any]) -> None: def cleanup_cameras(cameras_to_use: list[dict[str, Any]]):
"""Disconnect all cameras.""" """Disconnect all cameras."""
logger.info(f"Disconnecting camera with ID {cam_dict['meta'].get('id')}...") logger.info(f"Disconnecting {len(cameras_to_use)} cameras...")
try: for cam_dict in cameras_to_use:
if cam_dict["instance"] and cam_dict["instance"].is_connected: try:
cam_dict["instance"].disconnect() if cam_dict["instance"] and cam_dict["instance"].is_connected:
except Exception as e: cam_dict["instance"].disconnect()
logger.error(f"Error disconnecting camera {cam_dict['meta'].get('id')}: {e}") except Exception as e:
logger.error(f"Error disconnecting camera {cam_dict['meta'].get('id')}: {e}")
def save_images_from_all_cameras( def save_images_from_all_cameras(
output_dir: Path, output_dir: Path,
record_time_s: float = 2.0, record_time_s: float = 2.0,
camera_type: str | None = None, camera_type: str | None = None,
warmup_s: int = 1,
): ):
""" """
Connects to detected cameras (optionally filtered by type) and saves images from each. Connects to detected cameras (optionally filtered by type) and saves images from each.
@@ -239,7 +240,6 @@ def save_images_from_all_cameras(
record_time_s: Duration in seconds to record images. record_time_s: Duration in seconds to record images.
camera_type: Optional string to filter cameras ("realsense" or "opencv"). camera_type: Optional string to filter cameras ("realsense" or "opencv").
If None, uses all detected cameras. If None, uses all detected cameras.
warmup_s: Duration in seconds to warmup camera before recording images.
""" """
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Saving images to {output_dir}") logger.info(f"Saving images to {output_dir}")
@@ -249,24 +249,40 @@ def save_images_from_all_cameras(
logger.warning("No cameras detected matching the criteria. Cannot save images.") logger.warning("No cameras detected matching the criteria. Cannot save images.")
return return
logger.info( cameras_to_use = []
f"Starting image capture for {record_time_s} seconds from {len(all_camera_metadata)} cameras." for cam_meta in all_camera_metadata:
) camera_instance = create_camera_instance(cam_meta)
if camera_instance:
cameras_to_use.append(camera_instance)
try: if not cameras_to_use:
for cam_meta in all_camera_metadata: logger.warning("No cameras could be connected. Aborting image save.")
cam_dict = create_camera_instance(cam_meta, warmup_s=warmup_s) return
if cam_dict is None:
continue logger.info(f"Starting image capture for {record_time_s} seconds from {len(cameras_to_use)} cameras.")
start_time = time.perf_counter() start_time = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=len(cameras_to_use) * 2) as executor:
try:
while time.perf_counter() - start_time < record_time_s: while time.perf_counter() - start_time < record_time_s:
futures = []
current_capture_time = time.perf_counter() current_capture_time = time.perf_counter()
process_camera_image(cam_dict, output_dir, current_capture_time)
cleanup_camera(cam_dict) for cam_dict in cameras_to_use:
except KeyboardInterrupt: future = process_camera_image(cam_dict, output_dir, current_capture_time)
logger.info("Capture interrupted by user.") if future:
finally: futures.append(future)
print(f"Image capture finished. Images saved to {output_dir}")
if futures:
concurrent.futures.wait(futures)
except KeyboardInterrupt:
logger.info("Capture interrupted by user.")
finally:
print("\nFinalizing image saving...")
executor.shutdown(wait=True)
cleanup_cameras(cameras_to_use)
print(f"Image capture finished. Images saved to {output_dir}")
def main(): def main():
@@ -275,6 +291,7 @@ def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Unified camera utility script for listing cameras and capturing images." description="Unified camera utility script for listing cameras and capturing images."
) )
parser.add_argument( parser.add_argument(
"camera_type", "camera_type",
type=str, type=str,
@@ -292,14 +309,8 @@ def main():
parser.add_argument( parser.add_argument(
"--record-time-s", "--record-time-s",
type=float, type=float,
default=2.0, default=6.0,
help="Time duration to attempt capturing frames. Default: 2 seconds.", help="Time duration to attempt capturing frames. Default: 6 seconds.",
)
parser.add_argument(
"--warmup-s",
type=int,
default=1,
help="Time duration to warmup camera before attempting to capture frames. Default: 1 second.",
) )
args = parser.parse_args() args = parser.parse_args()
save_images_from_all_cameras(**vars(args)) save_images_from_all_cameras(**vars(args))
@@ -171,13 +171,7 @@ class IOSPhone(BasePhone, Teleoperator):
# HEBI provides orientation in w, x, y, z format. # HEBI provides orientation in w, x, y, z format.
# Scipy's Rotation expects x, y, z, w. # Scipy's Rotation expects x, y, z, w.
quat_xyzw = np.concatenate((ar_quat[1:], [ar_quat[0]])) # wxyz to xyzw quat_xyzw = np.concatenate((ar_quat[1:], [ar_quat[0]])) # wxyz to xyzw
# ARKit can emit zero/NaN quaternions before tracking is ready or on a rot = Rotation.from_quat(quat_xyzw)
# dropped packet. Rotation.from_quat now rejects those; degrade the same
# way as a missing pose so teleop stays alive mid-session.
try:
rot = Rotation.from_quat(quat_xyzw)
except ValueError:
return False, None, None, None
pos = ar_pos - rot.apply(self.config.camera_offset) pos = ar_pos - rot.apply(self.config.camera_offset)
return True, pos, rot, pose return True, pos, rot, pose
@@ -1,99 +0,0 @@
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared helpers for visualizing scalar features from a LeRobot dataset."""
from collections.abc import Iterable, Mapping
from typing import TYPE_CHECKING
import numpy as np
import torch
from .constants import ACTION, DEFAULT_FEATURES, DONE, OBS_STATE, REWARD, SUCCESS
if TYPE_CHECKING:
from lerobot.datasets import LeRobotDataset
METADATA_KEYS = {*DEFAULT_FEATURES, "task"}
KNOWN_SCALAR_KEYS = {DONE, REWARD, SUCCESS}
SCALAR_DTYPE_KINDS = {"b", "i", "u", "f"}
def is_scalar_feature(feature: Mapping) -> bool:
"""Return whether a feature schema describes a numeric or boolean scalar."""
dtype = feature.get("dtype")
if not isinstance(dtype, str):
return False
try:
dtype_kind = np.dtype(dtype).kind
except (TypeError, ValueError):
return False
if dtype_kind not in SCALAR_DTYPE_KINDS:
return False
shape = feature.get("shape")
if shape is None:
return True
if isinstance(shape, int):
return shape == 1
if not isinstance(shape, (list, tuple)):
return False
return len(shape) == 0 or (len(shape) == 1 and shape[0] == 1)
def get_extra_scalar_keys(dataset: "LeRobotDataset", additional_known_keys: Iterable[str] = ()) -> list[str]:
"""Return scalar feature keys not handled by the visualizer's standard paths."""
known_keys = {
ACTION,
OBS_STATE,
*KNOWN_SCALAR_KEYS,
*METADATA_KEYS,
*additional_known_keys,
*dataset.meta.camera_keys,
}
return [
key
for key, feature in dataset.features.items()
if key not in known_keys and is_scalar_feature(feature)
]
def is_scalar_like(value: object) -> bool:
"""Return whether a runtime value contains exactly one numeric or boolean scalar."""
if isinstance(value, torch.Tensor):
return value.numel() == 1 and not value.is_complex()
if isinstance(value, np.ndarray):
return value.size == 1 and value.dtype.kind in SCALAR_DTYPE_KINDS
return np.isscalar(value) and np.asarray(value).dtype.kind in SCALAR_DTYPE_KINDS
def scalar_to_float(value: object) -> float:
"""Convert a scalar-like tensor, array, or Python value to ``float``."""
return float(value.item() if hasattr(value, "item") else value)
def get_scalar_values(sample: Mapping, keys: Iterable[str]) -> dict[str, float]:
"""Select and convert scalar-like values from ``sample`` for the requested keys."""
values = {}
for key in keys:
value = sample.get(key)
if value is not None and is_scalar_like(value):
values[key] = scalar_to_float(value)
return values
+4 -13
View File
@@ -37,25 +37,16 @@ def auto_select_torch_device() -> torch.device:
# TODO(Steven): Remove log. log shouldn't be an argument, this should be handled by the logger level # TODO(Steven): Remove log. log shouldn't be an argument, this should be handled by the logger level
def get_safe_torch_device(try_device: str, log: bool = False) -> torch.device: def get_safe_torch_device(try_device: str, log: bool = False) -> torch.device:
"""Given a string, return a torch.device with checks on whether the device is available. """Given a string, return a torch.device with checks on whether the device is available."""
Raises:
ValueError: If the requested device family is known but not available on
this machine (``AssertionError`` was previously used and is easy to
mistake for a programmer bug under ``python -O`` where asserts vanish).
"""
try_device = str(try_device) try_device = str(try_device)
if try_device.startswith("cuda"): if try_device.startswith("cuda"):
if not torch.cuda.is_available(): assert torch.cuda.is_available()
raise ValueError(f"Requested device {try_device!r} but CUDA is not available.")
device = torch.device(try_device) device = torch.device(try_device)
elif try_device == "mps": elif try_device == "mps":
if not torch.backends.mps.is_available(): assert torch.backends.mps.is_available()
raise ValueError("Requested device 'mps' but MPS is not available.")
device = torch.device("mps") device = torch.device("mps")
elif try_device == "xpu": elif try_device == "xpu":
if not torch.xpu.is_available(): assert torch.xpu.is_available()
raise ValueError("Requested device 'xpu' but XPU is not available.")
device = torch.device("xpu") device = torch.device("xpu")
elif try_device == "cpu": elif try_device == "cpu":
device = torch.device("cpu") device = torch.device("cpu")
+10 -28
View File
@@ -23,7 +23,6 @@ importing from here directly. Requires the ``viz`` extra (``pip install 'lerobot
import logging import logging
import numbers import numbers
import time import time
from collections.abc import Iterable, Mapping
import cv2 import cv2
import numpy as np import numpy as np
@@ -42,7 +41,6 @@ from .constants import (
SUCCESS, SUCCESS,
TRUNCATED, TRUNCATED,
) )
from .dataset_visualization_utils import get_extra_scalar_keys, get_scalar_values
from .import_utils import require_package from .import_utils import require_package
# Static schema shared by all scalar topics. Each message carries a flat list of ``{label, value}`` # Static schema shared by all scalar topics. Each message carries a flat list of ``{label, value}``
@@ -407,24 +405,6 @@ def _frame_to_scalars(sample: dict, key: str, labels: list[str] | None = None) -
return _labeled_scalars(name, arr.flatten(), labels) return _labeled_scalars(name, arr.flatten(), labels)
def _dataset_frame_scalar_groups(
sample: Mapping, extra_scalar_keys: Iterable[str]
) -> tuple[dict[str, float], dict[str, float]]:
"""Return standard episode scalars and custom scalar features for one dataset frame."""
episode_scalars = {}
for feature, label in (
(DONE, "done"),
(TRUNCATED, "truncated"),
(REWARD, "reward"),
(SUCCESS, "success"),
):
value = sample.get(feature)
if value is not None:
episode_scalars[label] = float(value)
return episode_scalars, get_scalar_values(sample, extra_scalar_keys)
def serve_foxglove_dataset_playback( def serve_foxglove_dataset_playback(
dataset, dataset,
episode_index: int, episode_index: int,
@@ -472,7 +452,6 @@ def serve_foxglove_dataset_playback(
raise ValueError("Cannot visualize an empty episode.") raise ValueError("Cannot visualize an empty episode.")
first_ns, last_ns = times_ns[0], times_ns[-1] first_ns, last_ns = times_ns[0], times_ns[-1]
camera_keys = list(dataset.meta.camera_keys) camera_keys = list(dataset.meta.camera_keys)
extra_scalar_keys = get_extra_scalar_keys(dataset, additional_known_keys=(TRUNCATED,))
# Dataset-wide q01/q99 depth bounds (fallback min/max) used to normalize depth to [0, 1]. # Dataset-wide q01/q99 depth bounds (fallback min/max) used to normalize depth to [0, 1].
depth_ranges: dict[str, tuple[float, float]] = {} depth_ranges: dict[str, tuple[float, float]] = {}
for key in dataset.meta.depth_keys: for key in dataset.meta.depth_keys:
@@ -521,14 +500,17 @@ def serve_foxglove_dataset_playback(
channels=channels, channels=channels,
log_time=log_time, log_time=log_time,
) )
episode_scalars, extra_scalars = _dataset_frame_scalar_groups(sample, extra_scalar_keys) episode_scalars = {}
for feat, label in (
(DONE, "done"),
(TRUNCATED, "truncated"),
(REWARD, "reward"),
(SUCCESS, "success"),
):
v = sample.get(feat)
if v is not None:
episode_scalars[label] = float(v)
_log_foxglove_scalars("/episode/state", episode_scalars, channels=channels, log_time=log_time) _log_foxglove_scalars("/episode/state", episode_scalars, channels=channels, log_time=log_time)
_log_foxglove_scalars(
"/episode/extras",
extra_scalars,
channels=channels,
log_time=log_time,
)
lock = threading.Lock() lock = threading.Lock()
stop_event = threading.Event() stop_event = threading.Event()
+5 -5
View File
@@ -32,21 +32,21 @@ def load_json(fpath: Path) -> Any:
Returns: Returns:
Any: The data loaded from the JSON file. Any: The data loaded from the JSON file.
""" """
with open(fpath, encoding="utf-8") as f: with open(fpath) as f:
return json.load(f) return json.load(f)
def write_json(data: JsonLike, fpath: Path) -> None: def write_json(data: dict, fpath: Path) -> None:
"""Write JSON-serializable data to a file. """Write data to a JSON file.
Creates parent directories if they don't exist. Creates parent directories if they don't exist.
Args: Args:
data: JSON-serializable data to write. data (dict): The dictionary to write.
fpath (Path): The path to the output JSON file. fpath (Path): The path to the output JSON file.
""" """
fpath.parent.mkdir(exist_ok=True, parents=True) fpath.parent.mkdir(exist_ok=True, parents=True)
with open(fpath, "w", encoding="utf-8") as f: with open(fpath, "w") as f:
json.dump(data, f, indent=4, ensure_ascii=False) json.dump(data, f, indent=4, ensure_ascii=False)
-4
View File
@@ -30,10 +30,6 @@ def precise_sleep(seconds: float, spin_threshold: float = 0.010, sleep_margin: f
""" """
if seconds <= 0: if seconds <= 0:
return return
if spin_threshold < 0:
raise ValueError(f"spin_threshold must be >= 0, got {spin_threshold}")
if sleep_margin < 0:
raise ValueError(f"sleep_margin must be >= 0, got {sleep_margin}")
system = platform.system() system = platform.system()
# On macOS and Windows the scheduler / sleep granularity can make # On macOS and Windows the scheduler / sleep granularity can make
+3 -6
View File
@@ -29,13 +29,10 @@ class Rotation:
def __init__(self, quat: np.ndarray) -> None: def __init__(self, quat: np.ndarray) -> None:
"""Initialize rotation from quaternion [x, y, z, w].""" """Initialize rotation from quaternion [x, y, z, w]."""
self._quat = np.asarray(quat, dtype=float) self._quat = np.asarray(quat, dtype=float)
if self._quat.shape != (4,): # Normalize quaternion
raise ValueError(f"Quaternion must have shape (4,), got {self._quat.shape}")
# Normalize quaternion. Reject the zero vector — it has no orientation.
norm = np.linalg.norm(self._quat) norm = np.linalg.norm(self._quat)
if norm <= 0.0 or not np.isfinite(norm): if norm > 0:
raise ValueError(f"Quaternion must be a non-zero finite vector; got {self._quat} (norm={norm})") self._quat = self._quat / norm
self._quat = self._quat / norm
@classmethod @classmethod
def from_rotvec(cls, rotvec: np.ndarray) -> "Rotation": def from_rotvec(cls, rotvec: np.ndarray) -> "Rotation":
+2 -2
View File
@@ -14,7 +14,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from typing import NotRequired, TypedDict from typing import TypedDict
import torch import torch
@@ -28,7 +28,7 @@ class Transition(TypedDict):
next_state: dict[str, torch.Tensor] next_state: dict[str, torch.Tensor]
done: bool done: bool
truncated: bool truncated: bool
complementary_info: NotRequired[dict[str, torch.Tensor | float | int] | None] complementary_info: dict[str, torch.Tensor | float | int] | None = None
def move_transition_to_device(transition: Transition, device: str = "cpu") -> Transition: def move_transition_to_device(transition: Transition, device: str = "cpu") -> Transition:
+8 -9
View File
@@ -24,6 +24,7 @@ import sys
import time import time
from collections.abc import Iterator from collections.abc import Iterator
from copy import copy, deepcopy from copy import copy, deepcopy
from datetime import datetime
from pathlib import Path from pathlib import Path
from statistics import mean from statistics import mean
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -60,16 +61,14 @@ def init_logging(
accelerator: Optional Accelerator instance (for multi-GPU detection) accelerator: Optional Accelerator instance (for multi-GPU detection)
""" """
class LeRobotFormatter(logging.Formatter): def custom_format(record: logging.LogRecord) -> str:
def format(self, record: logging.LogRecord) -> str: dt = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
record.lerobot_location = f"{record.pathname}:{record.lineno}"[-15:] fnameline = f"{record.pathname}:{record.lineno}"
record.lerobot_pid = f"[PID: {os.getpid()}] " if display_pid else "" pid_str = f"[PID: {os.getpid()}] " if display_pid else ""
return super().format(record) return f"{record.levelname} {pid_str}{dt} {fnameline[-15:]:>15} {record.getMessage()}"
formatter = LeRobotFormatter( formatter = logging.Formatter()
"%(levelname)s %(lerobot_pid)s%(asctime)s %(lerobot_location)15s %(message)s", formatter.format = custom_format
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger() logger = logging.getLogger()
logger.setLevel(logging.NOTSET) logger.setLevel(logging.NOTSET)
-157
View File
@@ -13,78 +13,11 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import sys
from types import SimpleNamespace
import numpy as np
import pytest import pytest
import torch
pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])") pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])")
from lerobot.scripts.lerobot_dataset_viz import visualize_dataset from lerobot.scripts.lerobot_dataset_viz import visualize_dataset
from lerobot.utils import import_utils
from lerobot.utils.constants import ACTION, DONE, OBS_STATE, REWARD, SUCCESS, TRUNCATED
from lerobot.utils.dataset_visualization_utils import (
get_extra_scalar_keys,
is_scalar_feature,
is_scalar_like,
scalar_to_float,
)
class DummyMeta:
camera_keys = ["observation.images.front"]
class DummyFeatureDataset:
meta = DummyMeta()
features = {
"index": {"dtype": "int64", "shape": [1]},
"timestamp": {"dtype": "float32", "shape": [1]},
"episode_index": {"dtype": "int64", "shape": [1]},
"frame_index": {"dtype": "int64", "shape": [1]},
"task_index": {"dtype": "int64", "shape": [1]},
ACTION: {"dtype": "float32", "shape": [6]},
OBS_STATE: {"dtype": "float32", "shape": [6]},
DONE: {"dtype": "bool", "shape": [1]},
REWARD: {"dtype": "float32", "shape": [1]},
SUCCESS: {"dtype": "bool", "shape": [1]},
TRUNCATED: {"dtype": "bool", "shape": [1]},
"observation.images.front": {"dtype": "video", "shape": [3, 480, 640]},
"q_target": {"dtype": "float32", "shape": [1]},
"quality": {"dtype": "double", "shape": [1]},
"intervention": {"dtype": "bool", "shape": []},
"embedding": {"dtype": "float32", "shape": [32]},
"comment": {"dtype": "string", "shape": [1]},
}
class DummyVizDataset:
repo_id = "dummy/custom-scalars"
depth_output_unit = "m"
meta = SimpleNamespace(camera_keys=[], depth_keys=[], stats=None)
features = {
"index": {"dtype": "int64", "shape": [1]},
"timestamp": {"dtype": "float32", "shape": [1]},
ACTION: {"dtype": "float32", "shape": [2], "names": ["x", "y"]},
"q_target": {"dtype": "float32", "shape": [1]},
"intervention": {"dtype": "bool", "shape": [1]},
"embedding": {"dtype": "float32", "shape": [2]},
}
def __len__(self):
return 2
def __getitem__(self, index):
return {
"index": torch.tensor(index),
"timestamp": torch.tensor(index / 10),
ACTION: torch.tensor([index, index + 1], dtype=torch.float32),
"q_target": torch.tensor([index + 0.5]),
"intervention": torch.tensor([index == 0]),
"embedding": torch.tensor([index, index + 1], dtype=torch.float32),
}
@pytest.mark.skip("TODO: add dummy videos") @pytest.mark.skip("TODO: add dummy videos")
@@ -100,93 +33,3 @@ def test_visualize_local_dataset(tmp_path, lerobot_dataset_factory):
output_dir=output_dir, output_dir=output_dir,
) )
assert rrd_path.exists() assert rrd_path.exists()
def test_get_extra_scalar_keys_skips_known_metadata_and_non_scalars():
dataset = DummyFeatureDataset()
assert get_extra_scalar_keys(dataset) == [TRUNCATED, "q_target", "quality", "intervention"]
assert get_extra_scalar_keys(dataset, additional_known_keys=(TRUNCATED,)) == [
"q_target",
"quality",
"intervention",
]
@pytest.mark.parametrize(
("feature", "expected"),
[
({"dtype": "float32", "shape": (1,)}, True),
({"dtype": "double", "shape": [1]}, True),
({"dtype": "bool", "shape": []}, True),
({"dtype": "int64", "shape": 1}, True),
({"dtype": "float32", "shape": (3,)}, False),
({"dtype": "complex64", "shape": [1]}, False),
({"dtype": "datetime64[ns]", "shape": [1]}, False),
({"dtype": "string", "shape": [1]}, False),
({"shape": [1]}, False),
],
)
def test_is_scalar_feature(feature, expected):
assert is_scalar_feature(feature) is expected
@pytest.mark.parametrize(
("value", "expected"),
[
(torch.tensor(1.5), True),
(torch.tensor([1.5]), True),
(torch.tensor([1.5, 2.5]), False),
(np.array(2.0), True),
(np.array([2.0]), True),
(np.array([2.0, 3.0]), False),
(True, True),
("not numeric", False),
],
)
def test_is_scalar_like(value, expected):
assert is_scalar_like(value) is expected
def test_scalar_to_float():
assert scalar_to_float(torch.tensor(3.0)) == 3.0
assert scalar_to_float(np.array([4.0])) == 4.0
def test_visualize_dataset_logs_extra_scalars(monkeypatch):
logged = []
initialized = []
dummy_rrb = SimpleNamespace(
Spatial2DView=lambda origin=None, name=None: SimpleNamespace(
kind="spatial", origin=origin, name=name
),
TimeSeriesView=lambda origin=None, name=None, overrides=None: SimpleNamespace(
kind="time_series", origin=origin, name=name, overrides=overrides
),
Grid=lambda *views: SimpleNamespace(views=views),
Blueprint=lambda root: SimpleNamespace(root=root),
)
dummy_rr = SimpleNamespace(
SeriesLines=lambda names=None: SimpleNamespace(names=names),
Scalars=lambda value: SimpleNamespace(value=value),
init=lambda *args, **kwargs: initialized.append((args, kwargs)),
log=lambda key, entity: logged.append((key, entity)),
set_time=lambda *args, **kwargs: None,
blueprint=dummy_rrb,
)
monkeypatch.setitem(sys.modules, "rerun", dummy_rr)
monkeypatch.setitem(sys.modules, "rerun.blueprint", dummy_rrb)
monkeypatch.setattr(import_utils, "require_package", lambda *args, **kwargs: None)
visualize_dataset(DummyVizDataset(), episode_index=0, batch_size=2)
logged_keys = [key for key, _ in logged]
assert logged_keys.count("q_target") == 2
assert logged_keys.count("intervention") == 2
assert "embedding" not in logged_keys
blueprint = initialized[0][1]["default_blueprint"]
time_series_origins = {view.origin for view in blueprint.root.views if view.kind == "time_series"}
assert {"q_target", "intervention"} <= time_series_origins
assert "embedding" not in time_series_origins
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import patch
import pytest
import torch
from lerobot.utils.device_utils import get_safe_torch_device, is_torch_device_available
def test_cpu_always_available():
assert get_safe_torch_device("cpu") == torch.device("cpu")
assert is_torch_device_available("cpu")
def test_missing_cuda_raises_valueerror():
with patch("torch.cuda.is_available", return_value=False), pytest.raises(ValueError, match="CUDA"):
get_safe_torch_device("cuda")
def test_missing_mps_raises_valueerror():
with patch("torch.backends.mps.is_available", return_value=False), pytest.raises(ValueError, match="MPS"):
get_safe_torch_device("mps")
+1 -19
View File
@@ -24,7 +24,7 @@ the functions that talk to the server, so the helpers below run in the base test
import numpy as np import numpy as np
from lerobot.utils import foxglove_visualization as fv from lerobot.utils import foxglove_visualization as fv
from lerobot.utils.constants import ACTION, DONE, OBS_STATE, REWARD, SUCCESS from lerobot.utils.constants import ACTION, OBS_STATE
def test_foxglove_safe_name_collapses_dots(): def test_foxglove_safe_name_collapses_dots():
@@ -93,24 +93,6 @@ def test_feature_dim_names_formats():
assert fv._feature_dim_names({"shape": [2]}) is None assert fv._feature_dim_names({"shape": [2]}) is None
def test_dataset_frame_scalar_groups_include_custom_scalars():
sample = {
DONE: np.array(True),
REWARD: np.array(0.5),
SUCCESS: np.array(False),
"q_target": np.array([0.75]),
"intervention": np.array([True]),
"embedding": np.array([1.0, 2.0]),
}
episode_scalars, extra_scalars = fv._dataset_frame_scalar_groups(
sample, ("q_target", "intervention", "embedding")
)
assert episode_scalars == {"done": 1.0, "reward": 0.5, "success": 0.0}
assert extra_scalars == {"q_target": 0.75, "intervention": 1.0}
def test_is_scalar(): def test_is_scalar():
assert fv._is_scalar(1.0) assert fv._is_scalar(1.0)
assert fv._is_scalar(np.float32(2.0)) assert fv._is_scalar(np.float32(2.0))
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import numpy as np
import pytest
from lerobot.utils.rotation import Rotation
def test_zero_quaternion_rejected():
with pytest.raises(ValueError, match="non-zero"):
Rotation(np.zeros(4))
def test_non_finite_quaternion_rejected():
with pytest.raises(ValueError, match="non-zero|finite"):
Rotation(np.array([np.nan, 0.0, 0.0, 1.0]))
def test_wrong_shape_rejected():
with pytest.raises(ValueError, match="shape"):
Rotation(np.array([1.0, 0.0, 0.0]))
def test_identity_roundtrip():
r = Rotation.from_rotvec(np.zeros(3))
assert np.allclose(r.as_rotvec(), 0.0)
assert np.allclose(r.as_matrix(), np.eye(3))
def test_rotvec_roundtrip():
rotvec = np.array([0.1, -0.2, 0.3])
r = Rotation.from_rotvec(rotvec)
assert np.allclose(r.as_rotvec(), rotvec, atol=1e-6)