Compare commits

..

2 Commits

Author SHA1 Message Date
Steven Palma 0d788abd85 chore(robots): drive mode calibration gripper 2026-07-29 17:49:41 +02:00
johnnynunez 7b78e751a6 fix(calibration): detect inverted gripper drive_mode on SO follower/leader calibration
The SO follower/leader calibration hardcoded drive_mode=0 for all motors.
When the gripper motor is mounted mirrored relative to the other arm's
(raw position increases when closing instead of decreasing), the
0=closed/100=open normalization convention flips: a 'closed' (0%)
command from the leader unnormalizes to the follower's fully-open hard
stop, slamming the gripper open and triggering the Feetech overload
protection (torque off until power cycle).

FeetechMotorsBus already supports per-motor inversion via drive_mode
(apply_drive_mode=True); calibration just never set it. This adds a
step after range recording that asks the user to fully close the
gripper, reads the raw position, and sets drive_mode=1 when the closed
position sits at range_max. No calibration file format change.

Verified against the exact calibration values reported in #3942 using
the real FeetechMotorsBus normalization: with drive_mode=0 a 0% goal
maps to raw 2035 (open, bug reproduced); with drive_mode=1 it maps to
raw 3528 (closed, expected).

Fixes #3942
2026-07-29 17:34:54 +02:00
20 changed files with 174 additions and 245 deletions
+7 -11
View File
@@ -61,20 +61,16 @@ Full details in [`docs/source/so101.mdx`](./docs/source/so101.mdx) and [`docs/so
**4.1 Install** **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
+2 -2
View File
@@ -164,8 +164,8 @@ includes the range reported by the sensor. Requesting an unsupported control als
Omitted controls leave the sensor's existing automatic or manual setting unchanged. These options Omitted controls leave the sensor's existing automatic or manual setting unchanged. These options
require `use_rgb=True`. require `use_rgb=True`.
Manual color controls require a dedicated RGB module. Cameras without one, such as the RealSense On the RealSense D405, the color stream is provided by the Stereo Module, so changing manual
D405, do not support them and raise an error at connection time. exposure or gain also affects the depth stream.
</hfoption> </hfoption>
</hfoptions> </hfoptions>
@@ -365,12 +365,11 @@ class RealSenseCamera(Camera):
return self._async_read(timeout_ms=10000, read_depth=read_depth) return self._async_read(timeout_ms=10000, read_depth=read_depth)
def _get_color_sensor(self) -> "rs.sensor": def _get_color_sensor(self) -> "rs.sensor":
"""Returns the dedicated "RGB Camera" sensor that controls the color stream. """Returns the sensor that controls the color stream.
Manual color controls are only applied to a dedicated RGB module. Cameras Most RealSense cameras expose "RGB Camera" for color. The D405 has no
without one (e.g. the D405, whose color stream comes from the shared separate RGB module — its color stream comes from "Stereo Module".
"Stereo Module") are unsupported, so we never fall back to another sensor We try RGB Camera first, then fall back to Stereo Module.
to avoid altering the depth stream.
""" """
if self.rs_profile is None: if self.rs_profile is None:
raise RuntimeError(f"{self}: rs_profile must be initialized before use.") raise RuntimeError(f"{self}: rs_profile must be initialized before use.")
@@ -378,14 +377,12 @@ class RealSenseCamera(Camera):
device = self.rs_profile.get_device() device = self.rs_profile.get_device()
sensors = {s.get_info(rs.camera_info.name): s for s in device.query_sensors()} sensors = {s.get_info(rs.camera_info.name): s for s in device.query_sensors()}
if "RGB Camera" in sensors: for name in ("RGB Camera", "Stereo Module"):
return sensors["RGB Camera"] if name in sensors:
return sensors[name]
available = list(sensors.keys()) available = list(sensors.keys())
raise RuntimeError( raise RuntimeError(f"{self}: no color sensor found. Available sensors: {available}")
f"{self}: manual color controls require a dedicated 'RGB Camera' module, which this camera does not have. ",
f"Available sensors: {available}.",
)
def _set_sensor_option(self, sensor: "rs.sensor", option: "rs.option", value: float, label: str) -> None: def _set_sensor_option(self, sensor: "rs.sensor", option: "rs.option", value: float, label: str) -> None:
"""Sets a sensor option, re-raising range errors with actionable diagnostics.""" """Sets a sensor option, re-raising range errors with actionable diagnostics."""
+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:
@@ -510,10 +510,10 @@ class ForwardKinematicsJointsToEEAction(RobotActionProcessorStep):
# We only use the ee pose in the dataset, so we don't need the joint positions # We only use the ee pose in the dataset, so we don't need the joint positions
for n in self.motor_names: for n in self.motor_names:
features[PipelineFeatureType.ACTION].pop(f"{n}.pos", None) features[PipelineFeatureType.ACTION].pop(f"{n}.pos", None)
# Store end-effector features as actions in the dataset schema # We specify the dataset features of this step that we want to be stored in the dataset
for k in ["x", "y", "z", "wx", "wy", "wz", "gripper_pos"]: for k in ["x", "y", "z", "wx", "wy", "wz", "gripper_pos"]:
features[PipelineFeatureType.ACTION][f"ee.{k}"] = PolicyFeature( features[PipelineFeatureType.ACTION][f"ee.{k}"] = PolicyFeature(
type=FeatureType.ACTION, shape=(1,) type=FeatureType.STATE, shape=(1,)
) )
return features return features
+15 -1
View File
@@ -142,11 +142,25 @@ class SOFollower(Robot):
range_mins[full_turn_motor] = 0 range_mins[full_turn_motor] = 0
range_maxes[full_turn_motor] = 4095 range_maxes[full_turn_motor] = 4095
drive_modes = dict.fromkeys(self.bus.motors, 0)
input(f"Fully close the gripper of {self} and press ENTER....")
gripper_closed_pos = self.bus.read(
"Present_Position", "gripper", normalize=False, num_retry=self.config.num_read_retries
)
distance_to_min = abs(gripper_closed_pos - range_mins["gripper"])
distance_to_max = abs(gripper_closed_pos - range_maxes["gripper"])
if min(distance_to_min, distance_to_max) > (range_maxes["gripper"] - range_mins["gripper"]) * 0.2:
raise ValueError("Gripper is not fully closed. Run calibration again.")
drive_modes["gripper"] = int(distance_to_max < distance_to_min)
if drive_modes["gripper"]:
logger.info("Gripper motor is inverted, setting drive_mode=1 to compensate.")
self.calibration = {} self.calibration = {}
for motor, m in self.bus.motors.items(): for motor, m in self.bus.motors.items():
self.calibration[motor] = MotorCalibration( self.calibration[motor] = MotorCalibration(
id=m.id, id=m.id,
drive_mode=0, drive_mode=drive_modes[motor],
homing_offset=homing_offsets[motor], homing_offset=homing_offsets[motor],
range_min=range_mins[motor], range_min=range_mins[motor],
range_max=range_maxes[motor], range_max=range_maxes[motor],
+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
@@ -110,11 +110,25 @@ class SOLeader(Teleoperator):
range_mins[full_turn_motor] = 0 range_mins[full_turn_motor] = 0
range_maxes[full_turn_motor] = 4095 range_maxes[full_turn_motor] = 4095
drive_modes = dict.fromkeys(self.bus.motors, 0)
input(f"Fully close the gripper of {self} and press ENTER....")
gripper_closed_pos = self.bus.read(
"Present_Position", "gripper", normalize=False, num_retry=self.config.num_read_retries
)
distance_to_min = abs(gripper_closed_pos - range_mins["gripper"])
distance_to_max = abs(gripper_closed_pos - range_maxes["gripper"])
if min(distance_to_min, distance_to_max) > (range_maxes["gripper"] - range_mins["gripper"]) * 0.2:
raise ValueError("Gripper is not fully closed. Run calibration again.")
drive_modes["gripper"] = int(distance_to_max < distance_to_min)
if drive_modes["gripper"]:
logger.info("Gripper motor is inverted, setting drive_mode=1 to compensate.")
self.calibration = {} self.calibration = {}
for motor, m in self.bus.motors.items(): for motor, m in self.bus.motors.items():
self.calibration[motor] = MotorCalibration( self.calibration[motor] = MotorCalibration(
id=m.id, id=m.id,
drive_mode=0, drive_mode=drive_modes[motor],
homing_offset=homing_offsets[motor], homing_offset=homing_offsets[motor],
range_min=range_mins[motor], range_min=range_mins[motor],
range_max=range_maxes[motor], range_max=range_maxes[motor],
+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")
+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)
+3 -4
View File
@@ -322,16 +322,15 @@ def test_get_color_sensor_prefers_rgb_camera():
assert camera._get_color_sensor() is rgb assert camera._get_color_sensor() is rgb
def test_get_color_sensor_raises_without_dedicated_rgb_module(): def test_get_color_sensor_falls_back_to_stereo_module():
"""D405 has no separate RGB module; we refuse to touch the shared Stereo Module.""" """D405 has no separate RGB module; color comes from Stereo Module."""
config = RealSenseCameraConfig(serial_number_or_name="042") config = RealSenseCameraConfig(serial_number_or_name="042")
camera = RealSenseCamera(config) camera = RealSenseCamera(config)
stereo = _make_mock_sensor("Stereo Module") stereo = _make_mock_sensor("Stereo Module")
_attach_mock_color_sensor(camera, stereo) _attach_mock_color_sensor(camera, stereo)
with pytest.raises(RuntimeError, match="dedicated 'RGB Camera' module"): assert camera._get_color_sensor() is stereo
camera._get_color_sensor()
def test_get_color_sensor_raises_with_available_sensors(): def test_get_color_sensor_raises_with_available_sensors():
@@ -1,45 +0,0 @@
#!/usr/bin/env python
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pytest
from lerobot.configs import FeatureType, PipelineFeatureType, PolicyFeature
from lerobot.robots.so_follower.robot_kinematic_processor import (
ForwardKinematicsJointsToEEAction,
ForwardKinematicsJointsToEEObservation,
)
MOTOR_NAMES = ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper"]
EE_KEYS = {f"ee.{k}" for k in ["x", "y", "z", "wx", "wy", "wz", "gripper_pos"]}
def _joint_bucket(feature_type: FeatureType) -> dict[str, PolicyFeature]:
return {f"{n}.pos": PolicyFeature(type=feature_type, shape=(1,)) for n in MOTOR_NAMES}
@pytest.mark.parametrize(
("step_cls", "bucket", "feature_type"),
[
(ForwardKinematicsJointsToEEAction, PipelineFeatureType.ACTION, FeatureType.ACTION),
(ForwardKinematicsJointsToEEObservation, PipelineFeatureType.OBSERVATION, FeatureType.STATE),
],
)
def test_fk_feature_schema(step_cls, bucket, feature_type):
features = {PipelineFeatureType.ACTION: {}, PipelineFeatureType.OBSERVATION: {}}
features[bucket] = _joint_bucket(feature_type)
out = step_cls(kinematics=None, motor_names=MOTOR_NAMES).transform_features(features)[bucket]
assert set(out) == EE_KEYS
assert {feature.type for feature in out.values()} == {feature_type}
+48
View File
@@ -149,3 +149,51 @@ def test_configure_writes_position_pid_coefficients():
bus_mock.write.assert_any_call("P_Coefficient", "shoulder_pan", 32) bus_mock.write.assert_any_call("P_Coefficient", "shoulder_pan", 32)
bus_mock.write.assert_any_call("I_Coefficient", "shoulder_pan", 1) bus_mock.write.assert_any_call("I_Coefficient", "shoulder_pan", 1)
bus_mock.write.assert_any_call("D_Coefficient", "shoulder_pan", 16) bus_mock.write.assert_any_call("D_Coefficient", "shoulder_pan", 16)
@pytest.mark.parametrize(
"gripper_closed_pos, expected_drive_mode",
[
(2035, 0), # closed position at range_min -> raw increases when opening -> not inverted
(3528, 1), # closed position at range_max -> raw increases when closing -> inverted
(2781, None), # not near either end stop -> unsafe to infer
],
)
def test_calibrate_detects_gripper_drive_mode(follower, gripper_closed_pos, expected_drive_mode):
"""Regression test for #3942: the follower gripper can be mounted mirrored with respect to the
leader's, in which case its raw position increases when closing. Calibration must detect this
and set drive_mode=1 so that normalized values follow the 0=closed/100=open convention."""
follower.connect()
motors = list(follower.bus.motors)
follower.bus.set_half_turn_homings.return_value = dict.fromkeys(motors, 0)
follower.bus.record_ranges_of_motion.return_value = (
dict.fromkeys(motors, 2035),
dict.fromkeys(motors, 3528),
)
follower.bus.read.return_value = gripper_closed_pos
with (
patch("builtins.input", return_value=""),
patch.object(type(follower), "_save_calibration", lambda self: None),
):
follower.calibration = {}
if expected_drive_mode is None:
with pytest.raises(ValueError, match="Gripper is not fully closed"):
follower.calibrate()
else:
follower.calibrate()
follower.bus.read.assert_called_with(
"Present_Position",
"gripper",
normalize=False,
num_retry=follower.config.num_read_retries,
)
if expected_drive_mode is None:
return
assert follower.calibration["gripper"].drive_mode == expected_drive_mode
for motor in motors:
if motor != "gripper":
assert follower.calibration[motor].drive_mode == 0
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from unittest.mock import patch
import pytest
import torch
from lerobot.utils.device_utils import get_safe_torch_device, is_torch_device_available
def test_cpu_always_available():
assert get_safe_torch_device("cpu") == torch.device("cpu")
assert is_torch_device_available("cpu")
def test_missing_cuda_raises_valueerror():
with patch("torch.cuda.is_available", return_value=False), pytest.raises(ValueError, match="CUDA"):
get_safe_torch_device("cuda")
def test_missing_mps_raises_valueerror():
with patch("torch.backends.mps.is_available", return_value=False), pytest.raises(ValueError, match="MPS"):
get_safe_torch_device("mps")
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import numpy as np
import pytest
from lerobot.utils.rotation import Rotation
def test_zero_quaternion_rejected():
with pytest.raises(ValueError, match="non-zero"):
Rotation(np.zeros(4))
def test_non_finite_quaternion_rejected():
with pytest.raises(ValueError, match="non-zero|finite"):
Rotation(np.array([np.nan, 0.0, 0.0, 1.0]))
def test_wrong_shape_rejected():
with pytest.raises(ValueError, match="shape"):
Rotation(np.array([1.0, 0.0, 0.0]))
def test_identity_roundtrip():
r = Rotation.from_rotvec(np.zeros(3))
assert np.allclose(r.as_rotvec(), 0.0)
assert np.allclose(r.as_matrix(), np.eye(3))
def test_rotvec_roundtrip():
rotvec = np.array([0.1, -0.2, 0.3])
r = Rotation.from_rotvec(rotvec)
assert np.allclose(r.as_rotvec(), rotvec, atol=1e-6)