mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-23 01:41:54 +00:00
Merge branch 'main' into feat/row-group-per-episode
This commit is contained in:
@@ -57,11 +57,11 @@ The `lerobot-rollout --strategy.type=dagger` mode requires **teleoperators with
|
|||||||
|
|
||||||
**Compatible teleoperators:**
|
**Compatible teleoperators:**
|
||||||
|
|
||||||
- `openarm_mini` - OpenArm Mini
|
- `bi_openarm_mini` - Bimanual OpenArm Mini
|
||||||
- `so_leader` - SO100 / SO101 leader arm
|
- `so_leader` - SO100 / SO101 leader arm
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> The provided commands default to `bi_openarm_follower` + `openarm_mini`.
|
> The provided commands default to `bi_openarm_follower` + `bi_openarm_mini`.
|
||||||
> `so_follower` + `so_leader` configs are also registered and can be used via CLI flags.
|
> `so_follower` + `so_leader` configs are also registered and can be used via CLI flags.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -104,9 +104,9 @@ lerobot-rollout --strategy.type=dagger \
|
|||||||
--robot.right_arm_config.port=can0 \
|
--robot.right_arm_config.port=can0 \
|
||||||
--robot.right_arm_config.side=right \
|
--robot.right_arm_config.side=right \
|
||||||
--robot.cameras='{left_wrist: {type: opencv, index_or_path: "/dev/video0", width: 1280, height: 720, fps: 30}, right_wrist: {type: opencv, index_or_path: "/dev/video4", width: 1280, height: 720, fps: 30}, base: {type: opencv, index_or_path: "/dev/video2", width: 640, height: 480, fps: 30}}' \
|
--robot.cameras='{left_wrist: {type: opencv, index_or_path: "/dev/video0", width: 1280, height: 720, fps: 30}, right_wrist: {type: opencv, index_or_path: "/dev/video4", width: 1280, height: 720, fps: 30}, base: {type: opencv, index_or_path: "/dev/video2", width: 640, height: 480, fps: 30}}' \
|
||||||
--teleop.type=openarm_mini \
|
--teleop.type=bi_openarm_mini \
|
||||||
--teleop.port_left=/dev/ttyACM0 \
|
--teleop.left_arm_config.port=/dev/ttyACM0 \
|
||||||
--teleop.port_right=/dev/ttyACM1 \
|
--teleop.right_arm_config.port=/dev/ttyACM1 \
|
||||||
--policy.path=outputs/pretrain/checkpoints/last/pretrained_model \
|
--policy.path=outputs/pretrain/checkpoints/last/pretrained_model \
|
||||||
--dataset.repo_id=your-username/rollout_hil_dataset \
|
--dataset.repo_id=your-username/rollout_hil_dataset \
|
||||||
--dataset.single_task="Fold the T-shirt properly" \
|
--dataset.single_task="Fold the T-shirt properly" \
|
||||||
@@ -131,9 +131,9 @@ lerobot-rollout --strategy.type=dagger \
|
|||||||
--robot.right_arm_config.port=can0 \
|
--robot.right_arm_config.port=can0 \
|
||||||
--robot.right_arm_config.side=right \
|
--robot.right_arm_config.side=right \
|
||||||
--robot.cameras='{left_wrist: {type: opencv, index_or_path: "/dev/video0", width: 1280, height: 720, fps: 30}, right_wrist: {type: opencv, index_or_path: "/dev/video4", width: 1280, height: 720, fps: 30}, base: {type: opencv, index_or_path: "/dev/video2", width: 640, height: 480, fps: 30}}' \
|
--robot.cameras='{left_wrist: {type: opencv, index_or_path: "/dev/video0", width: 1280, height: 720, fps: 30}, right_wrist: {type: opencv, index_or_path: "/dev/video4", width: 1280, height: 720, fps: 30}, base: {type: opencv, index_or_path: "/dev/video2", width: 640, height: 480, fps: 30}}' \
|
||||||
--teleop.type=openarm_mini \
|
--teleop.type=bi_openarm_mini \
|
||||||
--teleop.port_left=/dev/ttyACM0 \
|
--teleop.left_arm_config.port=/dev/ttyACM0 \
|
||||||
--teleop.port_right=/dev/ttyACM1 \
|
--teleop.right_arm_config.port=/dev/ttyACM1 \
|
||||||
--policy.path=outputs/pretrain/checkpoints/last/pretrained_model \
|
--policy.path=outputs/pretrain/checkpoints/last/pretrained_model \
|
||||||
--dataset.repo_id=your-username/rollout_hil_rtc_dataset \
|
--dataset.repo_id=your-username/rollout_hil_rtc_dataset \
|
||||||
--dataset.single_task="Fold the T-shirt properly" \
|
--dataset.single_task="Fold the T-shirt properly" \
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ lerobot-rollout \
|
|||||||
--strategy.num_episodes=20 \
|
--strategy.num_episodes=20 \
|
||||||
--policy.path=outputs/pretrain/checkpoints/last/pretrained_model \
|
--policy.path=outputs/pretrain/checkpoints/last/pretrained_model \
|
||||||
--robot.type=bi_openarm_follower \
|
--robot.type=bi_openarm_follower \
|
||||||
--teleop.type=openarm_mini \
|
--teleop.type=bi_openarm_mini \
|
||||||
--dataset.repo_id=${HF_USER}/rollout_hil_data \
|
--dataset.repo_id=${HF_USER}/rollout_hil_data \
|
||||||
--dataset.single_task="Fold the T-shirt"
|
--dataset.single_task="Fold the T-shirt"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import logging
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from lerobot.types import RobotAction, RobotObservation
|
from lerobot.types import RobotAction, RobotObservation
|
||||||
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected
|
from lerobot.utils.bimanual import BimanualMixin
|
||||||
|
from lerobot.utils.decorators import check_if_not_connected
|
||||||
|
|
||||||
from ..openarm_follower import OpenArmFollower, OpenArmFollowerConfig
|
from ..openarm_follower import OpenArmFollower, OpenArmFollowerConfig
|
||||||
from ..robot import Robot
|
from ..robot import Robot
|
||||||
@@ -27,7 +28,7 @@ from .config_bi_openarm_follower import BiOpenArmFollowerConfig
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BiOpenArmFollower(Robot):
|
class BiOpenArmFollower(BimanualMixin, Robot):
|
||||||
"""
|
"""
|
||||||
Bimanual OpenArm Follower Arms
|
Bimanual OpenArm Follower Arms
|
||||||
"""
|
"""
|
||||||
@@ -39,15 +40,17 @@ class BiOpenArmFollower(Robot):
|
|||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
# Top-level cameras are distributed evenly: each arm's OpenArmFollower
|
# Top-level cameras are opened by `left_arm` for convenience, but their
|
||||||
# will only open the cameras assigned to it. Per-arm cameras are used
|
# keys stay unprefixed in observations (tracked via `_top_level_cam_keys`).
|
||||||
# as fallback when top-level cameras are empty.
|
self._top_level_cam_keys = set(config.cameras)
|
||||||
if config.cameras:
|
_collisions = self._top_level_cam_keys & set(
|
||||||
left_cameras = config.cameras
|
config.left_arm_config.cameras
|
||||||
right_cameras = {}
|
) | self._top_level_cam_keys & set(config.right_arm_config.cameras)
|
||||||
else:
|
if _collisions:
|
||||||
left_cameras = config.left_arm_config.cameras
|
raise ValueError(
|
||||||
right_cameras = config.right_arm_config.cameras
|
f"Top-level camera names collide with per-arm camera names: {sorted(_collisions)}"
|
||||||
|
)
|
||||||
|
left_arm_cameras = {**config.left_arm_config.cameras, **config.cameras}
|
||||||
|
|
||||||
left_arm_config = OpenArmFollowerConfig(
|
left_arm_config = OpenArmFollowerConfig(
|
||||||
id=f"{config.id}_left" if config.id else None,
|
id=f"{config.id}_left" if config.id else None,
|
||||||
@@ -56,7 +59,7 @@ class BiOpenArmFollower(Robot):
|
|||||||
disable_torque_on_disconnect=config.left_arm_config.disable_torque_on_disconnect,
|
disable_torque_on_disconnect=config.left_arm_config.disable_torque_on_disconnect,
|
||||||
use_velocity_and_torque=config.left_arm_config.use_velocity_and_torque,
|
use_velocity_and_torque=config.left_arm_config.use_velocity_and_torque,
|
||||||
max_relative_target=config.left_arm_config.max_relative_target,
|
max_relative_target=config.left_arm_config.max_relative_target,
|
||||||
cameras=left_cameras,
|
cameras=left_arm_cameras,
|
||||||
side=config.left_arm_config.side,
|
side=config.left_arm_config.side,
|
||||||
can_interface=config.left_arm_config.can_interface,
|
can_interface=config.left_arm_config.can_interface,
|
||||||
use_can_fd=config.left_arm_config.use_can_fd,
|
use_can_fd=config.left_arm_config.use_can_fd,
|
||||||
@@ -75,7 +78,7 @@ class BiOpenArmFollower(Robot):
|
|||||||
disable_torque_on_disconnect=config.right_arm_config.disable_torque_on_disconnect,
|
disable_torque_on_disconnect=config.right_arm_config.disable_torque_on_disconnect,
|
||||||
use_velocity_and_torque=config.right_arm_config.use_velocity_and_torque,
|
use_velocity_and_torque=config.right_arm_config.use_velocity_and_torque,
|
||||||
max_relative_target=config.right_arm_config.max_relative_target,
|
max_relative_target=config.right_arm_config.max_relative_target,
|
||||||
cameras=right_cameras,
|
cameras=config.right_arm_config.cameras,
|
||||||
side=config.right_arm_config.side,
|
side=config.right_arm_config.side,
|
||||||
can_interface=config.right_arm_config.can_interface,
|
can_interface=config.right_arm_config.can_interface,
|
||||||
use_can_fd=config.right_arm_config.use_can_fd,
|
use_can_fd=config.right_arm_config.use_can_fd,
|
||||||
@@ -95,22 +98,19 @@ class BiOpenArmFollower(Robot):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def _motors_ft(self) -> dict[str, type]:
|
def _motors_ft(self) -> dict[str, type]:
|
||||||
left_arm_motors_ft = self.left_arm._motors_ft
|
|
||||||
right_arm_motors_ft = self.right_arm._motors_ft
|
|
||||||
|
|
||||||
# Right first, then left — matches the teleoperator (OpenArmMini) ordering
|
|
||||||
# and the dataset feature names recorded during data collection.
|
|
||||||
return {
|
return {
|
||||||
**{f"right_{k}": v for k, v in right_arm_motors_ft.items()},
|
**{f"left_{k}": v for k, v in self.left_arm._motors_ft.items()},
|
||||||
**{f"left_{k}": v for k, v in left_arm_motors_ft.items()},
|
**{f"right_{k}": v for k, v in self.right_arm._motors_ft.items()},
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _cameras_ft(self) -> dict[str, tuple]:
|
def _cameras_ft(self) -> dict[str, tuple]:
|
||||||
# Cameras already have unique user-chosen names (e.g. "left_wrist", "base",
|
out: dict[str, tuple] = {}
|
||||||
# "right_wrist"), so we merge them directly — unlike motors which need the
|
for k, v in self.left_arm._cameras_ft.items():
|
||||||
# left_/right_ prefix to disambiguate identical per-arm joint names.
|
out[k if k in self._top_level_cam_keys else f"left_{k}"] = v
|
||||||
return {**self.left_arm._cameras_ft, **self.right_arm._cameras_ft}
|
for k, v in self.right_arm._cameras_ft.items():
|
||||||
|
out[f"right_{k}"] = v
|
||||||
|
return out
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def observation_features(self) -> dict[str, type | tuple]:
|
def observation_features(self) -> dict[str, type | tuple]:
|
||||||
@@ -120,27 +120,6 @@ class BiOpenArmFollower(Robot):
|
|||||||
def action_features(self) -> dict[str, type]:
|
def action_features(self) -> dict[str, type]:
|
||||||
return self._motors_ft
|
return self._motors_ft
|
||||||
|
|
||||||
@property
|
|
||||||
def is_connected(self) -> bool:
|
|
||||||
return self.left_arm.is_connected and self.right_arm.is_connected
|
|
||||||
|
|
||||||
@check_if_already_connected
|
|
||||||
def connect(self, calibrate: bool = True) -> None:
|
|
||||||
self.left_arm.connect(calibrate)
|
|
||||||
self.right_arm.connect(calibrate)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_calibrated(self) -> bool:
|
|
||||||
return self.left_arm.is_calibrated and self.right_arm.is_calibrated
|
|
||||||
|
|
||||||
def calibrate(self) -> None:
|
|
||||||
self.left_arm.calibrate()
|
|
||||||
self.right_arm.calibrate()
|
|
||||||
|
|
||||||
def configure(self) -> None:
|
|
||||||
self.left_arm.configure()
|
|
||||||
self.right_arm.configure()
|
|
||||||
|
|
||||||
def setup_motors(self) -> None:
|
def setup_motors(self) -> None:
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"Motor ID configuration is typically done via manufacturer tools for CAN motors."
|
"Motor ID configuration is typically done via manufacturer tools for CAN motors."
|
||||||
@@ -148,21 +127,15 @@ class BiOpenArmFollower(Robot):
|
|||||||
|
|
||||||
@check_if_not_connected
|
@check_if_not_connected
|
||||||
def get_observation(self) -> RobotObservation:
|
def get_observation(self) -> RobotObservation:
|
||||||
obs_dict = {}
|
obs_dict: RobotObservation = {}
|
||||||
|
|
||||||
# Camera keys that should NOT get the arm prefix (they already have unique names)
|
# Add "left_" prefix to per-arm keys; keep top-level camera keys unprefixed.
|
||||||
left_cam_keys = set(self.left_arm.cameras.keys())
|
for key, value in self.left_arm.get_observation().items():
|
||||||
right_cam_keys = set(self.right_arm.cameras.keys())
|
obs_dict[key if key in self._top_level_cam_keys else f"left_{key}"] = value
|
||||||
|
|
||||||
# Right first, then left — matches the teleoperator (OpenArmMini) ordering
|
# Add "right_" prefix
|
||||||
# and the dataset feature names recorded during data collection.
|
for key, value in self.right_arm.get_observation().items():
|
||||||
right_obs = self.right_arm.get_observation()
|
obs_dict[f"right_{key}"] = value
|
||||||
for key, value in right_obs.items():
|
|
||||||
obs_dict[key if key in right_cam_keys else f"right_{key}"] = value
|
|
||||||
|
|
||||||
left_obs = self.left_arm.get_observation()
|
|
||||||
for key, value in left_obs.items():
|
|
||||||
obs_dict[key if key in left_cam_keys else f"left_{key}"] = value
|
|
||||||
|
|
||||||
return obs_dict
|
return obs_dict
|
||||||
|
|
||||||
@@ -189,9 +162,4 @@ class BiOpenArmFollower(Robot):
|
|||||||
prefixed_sent_action_left = {f"left_{key}": value for key, value in sent_action_left.items()}
|
prefixed_sent_action_left = {f"left_{key}": value for key, value in sent_action_left.items()}
|
||||||
prefixed_sent_action_right = {f"right_{key}": value for key, value in sent_action_right.items()}
|
prefixed_sent_action_right = {f"right_{key}": value for key, value in sent_action_right.items()}
|
||||||
|
|
||||||
return {**prefixed_sent_action_right, **prefixed_sent_action_left}
|
return {**prefixed_sent_action_left, **prefixed_sent_action_right}
|
||||||
|
|
||||||
@check_if_not_connected
|
|
||||||
def disconnect(self):
|
|
||||||
self.left_arm.disconnect()
|
|
||||||
self.right_arm.disconnect()
|
|
||||||
|
|||||||
@@ -32,5 +32,7 @@ class BiOpenArmFollowerConfig(RobotConfig):
|
|||||||
left_arm_config: OpenArmFollowerConfigBase
|
left_arm_config: OpenArmFollowerConfigBase
|
||||||
right_arm_config: OpenArmFollowerConfigBase
|
right_arm_config: OpenArmFollowerConfigBase
|
||||||
|
|
||||||
# Top-level cameras shared across both arms.
|
# Top-level cameras not attached to a specific side. Keys are kept as-is in
|
||||||
|
# observations (no `left_`/`right_` prefix). Per-arm cameras (declared on
|
||||||
|
# `{left,right}_arm_config.cameras`) are prefixed.
|
||||||
cameras: dict[str, CameraConfig] = field(default_factory=dict)
|
cameras: dict[str, CameraConfig] = field(default_factory=dict)
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import logging
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from lerobot.types import RobotAction, RobotObservation
|
from lerobot.types import RobotAction, RobotObservation
|
||||||
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected
|
from lerobot.utils.bimanual import BimanualMixin
|
||||||
|
from lerobot.utils.decorators import check_if_not_connected
|
||||||
|
|
||||||
from ..rebot_b601_follower import RebotB601Follower, RebotB601FollowerRobotConfig
|
from ..rebot_b601_follower import RebotB601Follower, RebotB601FollowerRobotConfig
|
||||||
from ..robot import Robot
|
from ..robot import Robot
|
||||||
@@ -27,7 +28,7 @@ from .config_bi_rebot_b601_follower import BiRebotB601FollowerConfig
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BiRebotB601Follower(Robot):
|
class BiRebotB601Follower(BimanualMixin, Robot):
|
||||||
"""Bimanual Seeed Studio reBot B601-DM follower.
|
"""Bimanual Seeed Studio reBot B601-DM follower.
|
||||||
|
|
||||||
Composes two single-arm :class:`RebotB601Follower` instances. Observation and
|
Composes two single-arm :class:`RebotB601Follower` instances. Observation and
|
||||||
@@ -41,6 +42,18 @@ class BiRebotB601Follower(Robot):
|
|||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
|
# Top-level cameras are opened by `left_arm` for convenience, but their
|
||||||
|
# keys stay unprefixed in observations (tracked via `_top_level_cam_keys`).
|
||||||
|
self._top_level_cam_keys = set(config.cameras)
|
||||||
|
_collisions = self._top_level_cam_keys & set(
|
||||||
|
config.left_arm_config.cameras
|
||||||
|
) | self._top_level_cam_keys & set(config.right_arm_config.cameras)
|
||||||
|
if _collisions:
|
||||||
|
raise ValueError(
|
||||||
|
f"Top-level camera names collide with per-arm camera names: {sorted(_collisions)}"
|
||||||
|
)
|
||||||
|
left_arm_cameras = {**config.left_arm_config.cameras, **config.cameras}
|
||||||
|
|
||||||
left_arm_config = RebotB601FollowerRobotConfig(
|
left_arm_config = RebotB601FollowerRobotConfig(
|
||||||
id=f"{config.id}_left" if config.id else None,
|
id=f"{config.id}_left" if config.id else None,
|
||||||
calibration_dir=config.calibration_dir,
|
calibration_dir=config.calibration_dir,
|
||||||
@@ -49,7 +62,7 @@ class BiRebotB601Follower(Robot):
|
|||||||
dm_serial_baud=config.left_arm_config.dm_serial_baud,
|
dm_serial_baud=config.left_arm_config.dm_serial_baud,
|
||||||
disable_torque_on_disconnect=config.left_arm_config.disable_torque_on_disconnect,
|
disable_torque_on_disconnect=config.left_arm_config.disable_torque_on_disconnect,
|
||||||
max_relative_target=config.left_arm_config.max_relative_target,
|
max_relative_target=config.left_arm_config.max_relative_target,
|
||||||
cameras=config.left_arm_config.cameras,
|
cameras=left_arm_cameras,
|
||||||
motor_can_ids=config.left_arm_config.motor_can_ids,
|
motor_can_ids=config.left_arm_config.motor_can_ids,
|
||||||
pos_vel_velocity=config.left_arm_config.pos_vel_velocity,
|
pos_vel_velocity=config.left_arm_config.pos_vel_velocity,
|
||||||
gripper_torque_ratio=config.left_arm_config.gripper_torque_ratio,
|
gripper_torque_ratio=config.left_arm_config.gripper_torque_ratio,
|
||||||
@@ -86,10 +99,12 @@ class BiRebotB601Follower(Robot):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def _cameras_ft(self) -> dict[str, tuple]:
|
def _cameras_ft(self) -> dict[str, tuple]:
|
||||||
return {
|
out: dict[str, tuple] = {}
|
||||||
**{f"left_{k}": v for k, v in self.left_arm._cameras_ft.items()},
|
for k, v in self.left_arm._cameras_ft.items():
|
||||||
**{f"right_{k}": v for k, v in self.right_arm._cameras_ft.items()},
|
out[k if k in self._top_level_cam_keys else f"left_{k}"] = v
|
||||||
}
|
for k, v in self.right_arm._cameras_ft.items():
|
||||||
|
out[f"right_{k}"] = v
|
||||||
|
return out
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def observation_features(self) -> dict[str, type | tuple]:
|
def observation_features(self) -> dict[str, type | tuple]:
|
||||||
@@ -99,32 +114,13 @@ class BiRebotB601Follower(Robot):
|
|||||||
def action_features(self) -> dict[str, type]:
|
def action_features(self) -> dict[str, type]:
|
||||||
return self._motors_ft
|
return self._motors_ft
|
||||||
|
|
||||||
@property
|
|
||||||
def is_connected(self) -> bool:
|
|
||||||
return self.left_arm.is_connected and self.right_arm.is_connected
|
|
||||||
|
|
||||||
@check_if_already_connected
|
|
||||||
def connect(self, calibrate: bool = True) -> None:
|
|
||||||
self.left_arm.connect(calibrate)
|
|
||||||
self.right_arm.connect(calibrate)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_calibrated(self) -> bool:
|
|
||||||
return self.left_arm.is_calibrated and self.right_arm.is_calibrated
|
|
||||||
|
|
||||||
def calibrate(self) -> None:
|
|
||||||
self.left_arm.calibrate()
|
|
||||||
self.right_arm.calibrate()
|
|
||||||
|
|
||||||
def configure(self) -> None:
|
|
||||||
self.left_arm.configure()
|
|
||||||
self.right_arm.configure()
|
|
||||||
|
|
||||||
@check_if_not_connected
|
@check_if_not_connected
|
||||||
def get_observation(self) -> RobotObservation:
|
def get_observation(self) -> RobotObservation:
|
||||||
obs_dict = {}
|
obs_dict: RobotObservation = {}
|
||||||
obs_dict.update({f"left_{k}": v for k, v in self.left_arm.get_observation().items()})
|
for k, v in self.left_arm.get_observation().items():
|
||||||
obs_dict.update({f"right_{k}": v for k, v in self.right_arm.get_observation().items()})
|
obs_dict[k if k in self._top_level_cam_keys else f"left_{k}"] = v
|
||||||
|
for k, v in self.right_arm.get_observation().items():
|
||||||
|
obs_dict[f"right_{k}"] = v
|
||||||
return obs_dict
|
return obs_dict
|
||||||
|
|
||||||
@check_if_not_connected
|
@check_if_not_connected
|
||||||
@@ -143,8 +139,3 @@ class BiRebotB601Follower(Robot):
|
|||||||
**{f"left_{k}": v for k, v in sent_action_left.items()},
|
**{f"left_{k}": v for k, v in sent_action_left.items()},
|
||||||
**{f"right_{k}": v for k, v in sent_action_right.items()},
|
**{f"right_{k}": v for k, v in sent_action_right.items()},
|
||||||
}
|
}
|
||||||
|
|
||||||
@check_if_not_connected
|
|
||||||
def disconnect(self) -> None:
|
|
||||||
self.left_arm.disconnect()
|
|
||||||
self.right_arm.disconnect()
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
# 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 dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from lerobot.cameras import CameraConfig
|
||||||
|
|
||||||
from ..config import RobotConfig
|
from ..config import RobotConfig
|
||||||
from ..rebot_b601_follower import RebotB601FollowerConfig
|
from ..rebot_b601_follower import RebotB601FollowerConfig
|
||||||
@@ -27,3 +29,8 @@ class BiRebotB601FollowerConfig(RobotConfig):
|
|||||||
|
|
||||||
left_arm_config: RebotB601FollowerConfig
|
left_arm_config: RebotB601FollowerConfig
|
||||||
right_arm_config: RebotB601FollowerConfig
|
right_arm_config: RebotB601FollowerConfig
|
||||||
|
|
||||||
|
# Top-level cameras not attached to a specific side. Keys are kept as-is in
|
||||||
|
# observations (no `left_`/`right_` prefix). Per-arm cameras (declared on
|
||||||
|
# `{left,right}_arm_config.cameras`) are prefixed.
|
||||||
|
cameras: dict[str, CameraConfig] = field(default_factory=dict)
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import logging
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from lerobot.types import RobotAction, RobotObservation
|
from lerobot.types import RobotAction, RobotObservation
|
||||||
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected
|
from lerobot.utils.bimanual import BimanualMixin
|
||||||
|
from lerobot.utils.decorators import check_if_not_connected
|
||||||
|
|
||||||
from ..robot import Robot
|
from ..robot import Robot
|
||||||
from ..so_follower import SOFollower, SOFollowerRobotConfig
|
from ..so_follower import SOFollower, SOFollowerRobotConfig
|
||||||
@@ -27,7 +28,7 @@ from .config_bi_so_follower import BiSOFollowerConfig
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BiSOFollower(Robot):
|
class BiSOFollower(BimanualMixin, Robot):
|
||||||
"""
|
"""
|
||||||
[Bimanual SO Follower Arms](https://github.com/TheRobotStudio/SO-ARM100) designed by TheRobotStudio
|
[Bimanual SO Follower Arms](https://github.com/TheRobotStudio/SO-ARM100) designed by TheRobotStudio
|
||||||
"""
|
"""
|
||||||
@@ -39,6 +40,18 @@ class BiSOFollower(Robot):
|
|||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
|
# Top-level cameras are opened by `left_arm` for convenience, but their
|
||||||
|
# keys stay unprefixed in observations (tracked via `_top_level_cam_keys`).
|
||||||
|
self._top_level_cam_keys = set(config.cameras)
|
||||||
|
_collisions = self._top_level_cam_keys & set(
|
||||||
|
config.left_arm_config.cameras
|
||||||
|
) | self._top_level_cam_keys & set(config.right_arm_config.cameras)
|
||||||
|
if _collisions:
|
||||||
|
raise ValueError(
|
||||||
|
f"Top-level camera names collide with per-arm camera names: {sorted(_collisions)}"
|
||||||
|
)
|
||||||
|
left_arm_cameras = {**config.left_arm_config.cameras, **config.cameras}
|
||||||
|
|
||||||
left_arm_config = SOFollowerRobotConfig(
|
left_arm_config = SOFollowerRobotConfig(
|
||||||
id=f"{config.id}_left" if config.id else None,
|
id=f"{config.id}_left" if config.id else None,
|
||||||
calibration_dir=config.calibration_dir,
|
calibration_dir=config.calibration_dir,
|
||||||
@@ -46,7 +59,7 @@ class BiSOFollower(Robot):
|
|||||||
disable_torque_on_disconnect=config.left_arm_config.disable_torque_on_disconnect,
|
disable_torque_on_disconnect=config.left_arm_config.disable_torque_on_disconnect,
|
||||||
max_relative_target=config.left_arm_config.max_relative_target,
|
max_relative_target=config.left_arm_config.max_relative_target,
|
||||||
use_degrees=config.left_arm_config.use_degrees,
|
use_degrees=config.left_arm_config.use_degrees,
|
||||||
cameras=config.left_arm_config.cameras,
|
cameras=left_arm_cameras,
|
||||||
)
|
)
|
||||||
|
|
||||||
right_arm_config = SOFollowerRobotConfig(
|
right_arm_config = SOFollowerRobotConfig(
|
||||||
@@ -77,13 +90,12 @@ class BiSOFollower(Robot):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def _cameras_ft(self) -> dict[str, tuple]:
|
def _cameras_ft(self) -> dict[str, tuple]:
|
||||||
left_arm_cameras_ft = self.left_arm._cameras_ft
|
out: dict[str, tuple] = {}
|
||||||
right_arm_cameras_ft = self.right_arm._cameras_ft
|
for k, v in self.left_arm._cameras_ft.items():
|
||||||
|
out[k if k in self._top_level_cam_keys else f"left_{k}"] = v
|
||||||
return {
|
for k, v in self.right_arm._cameras_ft.items():
|
||||||
**{f"left_{k}": v for k, v in left_arm_cameras_ft.items()},
|
out[f"right_{k}"] = v
|
||||||
**{f"right_{k}": v for k, v in right_arm_cameras_ft.items()},
|
return out
|
||||||
}
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def observation_features(self) -> dict[str, type | tuple]:
|
def observation_features(self) -> dict[str, type | tuple]:
|
||||||
@@ -93,42 +105,21 @@ class BiSOFollower(Robot):
|
|||||||
def action_features(self) -> dict[str, type]:
|
def action_features(self) -> dict[str, type]:
|
||||||
return self._motors_ft
|
return self._motors_ft
|
||||||
|
|
||||||
@property
|
|
||||||
def is_connected(self) -> bool:
|
|
||||||
return self.left_arm.is_connected and self.right_arm.is_connected
|
|
||||||
|
|
||||||
@check_if_already_connected
|
|
||||||
def connect(self, calibrate: bool = True) -> None:
|
|
||||||
self.left_arm.connect(calibrate)
|
|
||||||
self.right_arm.connect(calibrate)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_calibrated(self) -> bool:
|
|
||||||
return self.left_arm.is_calibrated and self.right_arm.is_calibrated
|
|
||||||
|
|
||||||
def calibrate(self) -> None:
|
|
||||||
self.left_arm.calibrate()
|
|
||||||
self.right_arm.calibrate()
|
|
||||||
|
|
||||||
def configure(self) -> None:
|
|
||||||
self.left_arm.configure()
|
|
||||||
self.right_arm.configure()
|
|
||||||
|
|
||||||
def setup_motors(self) -> None:
|
def setup_motors(self) -> None:
|
||||||
self.left_arm.setup_motors()
|
self.left_arm.setup_motors()
|
||||||
self.right_arm.setup_motors()
|
self.right_arm.setup_motors()
|
||||||
|
|
||||||
@check_if_not_connected
|
@check_if_not_connected
|
||||||
def get_observation(self) -> RobotObservation:
|
def get_observation(self) -> RobotObservation:
|
||||||
obs_dict = {}
|
obs_dict: RobotObservation = {}
|
||||||
|
|
||||||
# Add "left_" prefix
|
# Add "left_" prefix to per-arm keys; keep top-level camera keys unprefixed.
|
||||||
left_obs = self.left_arm.get_observation()
|
for key, value in self.left_arm.get_observation().items():
|
||||||
obs_dict.update({f"left_{key}": value for key, value in left_obs.items()})
|
obs_dict[key if key in self._top_level_cam_keys else f"left_{key}"] = value
|
||||||
|
|
||||||
# Add "right_" prefix
|
# Add "right_" prefix
|
||||||
right_obs = self.right_arm.get_observation()
|
for key, value in self.right_arm.get_observation().items():
|
||||||
obs_dict.update({f"right_{key}": value for key, value in right_obs.items()})
|
obs_dict[f"right_{key}"] = value
|
||||||
|
|
||||||
return obs_dict
|
return obs_dict
|
||||||
|
|
||||||
@@ -151,8 +142,3 @@ class BiSOFollower(Robot):
|
|||||||
prefixed_sent_action_right = {f"right_{key}": value for key, value in sent_action_right.items()}
|
prefixed_sent_action_right = {f"right_{key}": value for key, value in sent_action_right.items()}
|
||||||
|
|
||||||
return {**prefixed_sent_action_left, **prefixed_sent_action_right}
|
return {**prefixed_sent_action_left, **prefixed_sent_action_right}
|
||||||
|
|
||||||
@check_if_not_connected
|
|
||||||
def disconnect(self):
|
|
||||||
self.left_arm.disconnect()
|
|
||||||
self.right_arm.disconnect()
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
# 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 dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from lerobot.cameras import CameraConfig
|
||||||
|
|
||||||
from ..config import RobotConfig
|
from ..config import RobotConfig
|
||||||
from ..so_follower import SOFollowerConfig
|
from ..so_follower import SOFollowerConfig
|
||||||
@@ -27,3 +29,8 @@ class BiSOFollowerConfig(RobotConfig):
|
|||||||
|
|
||||||
left_arm_config: SOFollowerConfig
|
left_arm_config: SOFollowerConfig
|
||||||
right_arm_config: SOFollowerConfig
|
right_arm_config: SOFollowerConfig
|
||||||
|
|
||||||
|
# Top-level cameras not attached to a specific side. Keys are kept as-is in
|
||||||
|
# observations (no `left_`/`right_` prefix). Per-arm cameras (declared on
|
||||||
|
# `{left,right}_arm_config.cameras`) are prefixed.
|
||||||
|
cameras: dict[str, CameraConfig] = field(default_factory=dict)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ from lerobot.teleoperators import ( # noqa: F401
|
|||||||
Teleoperator,
|
Teleoperator,
|
||||||
TeleoperatorConfig,
|
TeleoperatorConfig,
|
||||||
bi_openarm_leader,
|
bi_openarm_leader,
|
||||||
|
bi_openarm_mini,
|
||||||
bi_rebot_102_leader,
|
bi_rebot_102_leader,
|
||||||
bi_so_leader,
|
bi_so_leader,
|
||||||
homunculus,
|
homunculus,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ from lerobot.robots import ( # noqa: F401
|
|||||||
from lerobot.teleoperators import ( # noqa: F401
|
from lerobot.teleoperators import ( # noqa: F401
|
||||||
TeleoperatorConfig,
|
TeleoperatorConfig,
|
||||||
bi_openarm_leader,
|
bi_openarm_leader,
|
||||||
|
bi_openarm_mini,
|
||||||
bi_rebot_102_leader,
|
bi_rebot_102_leader,
|
||||||
bi_so_leader,
|
bi_so_leader,
|
||||||
gamepad,
|
gamepad,
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ from lerobot.teleoperators import ( # noqa: F401
|
|||||||
Teleoperator,
|
Teleoperator,
|
||||||
TeleoperatorConfig,
|
TeleoperatorConfig,
|
||||||
bi_openarm_leader,
|
bi_openarm_leader,
|
||||||
|
bi_openarm_mini,
|
||||||
bi_rebot_102_leader,
|
bi_rebot_102_leader,
|
||||||
bi_so_leader,
|
bi_so_leader,
|
||||||
homunculus,
|
homunculus,
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ from lerobot.teleoperators import ( # noqa: F401
|
|||||||
Teleoperator,
|
Teleoperator,
|
||||||
TeleoperatorConfig,
|
TeleoperatorConfig,
|
||||||
bi_openarm_leader,
|
bi_openarm_leader,
|
||||||
|
bi_openarm_mini,
|
||||||
bi_rebot_102_leader,
|
bi_rebot_102_leader,
|
||||||
bi_so_leader,
|
bi_so_leader,
|
||||||
homunculus,
|
homunculus,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from lerobot.robots import ( # noqa: F401
|
|||||||
)
|
)
|
||||||
from lerobot.teleoperators import ( # noqa: F401
|
from lerobot.teleoperators import ( # noqa: F401
|
||||||
TeleoperatorConfig,
|
TeleoperatorConfig,
|
||||||
|
bi_openarm_mini,
|
||||||
bi_rebot_102_leader,
|
bi_rebot_102_leader,
|
||||||
bi_so_leader,
|
bi_so_leader,
|
||||||
koch_leader,
|
koch_leader,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ from lerobot.teleoperators import ( # noqa: F401
|
|||||||
Teleoperator,
|
Teleoperator,
|
||||||
TeleoperatorConfig,
|
TeleoperatorConfig,
|
||||||
bi_openarm_leader,
|
bi_openarm_leader,
|
||||||
|
bi_openarm_mini,
|
||||||
bi_rebot_102_leader,
|
bi_rebot_102_leader,
|
||||||
bi_so_leader,
|
bi_so_leader,
|
||||||
gamepad,
|
gamepad,
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import logging
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
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.bimanual import BimanualMixin
|
||||||
|
from lerobot.utils.decorators import check_if_not_connected
|
||||||
|
|
||||||
from ..openarm_leader import OpenArmLeader, OpenArmLeaderConfig
|
from ..openarm_leader import OpenArmLeader, OpenArmLeaderConfig
|
||||||
from ..teleoperator import Teleoperator
|
from ..teleoperator import Teleoperator
|
||||||
@@ -27,7 +28,7 @@ from .config_bi_openarm_leader import BiOpenArmLeaderConfig
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BiOpenArmLeader(Teleoperator):
|
class BiOpenArmLeader(BimanualMixin, Teleoperator):
|
||||||
"""
|
"""
|
||||||
Bimanual OpenArm Leader Arms
|
Bimanual OpenArm Leader Arms
|
||||||
"""
|
"""
|
||||||
@@ -86,27 +87,6 @@ class BiOpenArmLeader(Teleoperator):
|
|||||||
def feedback_features(self) -> dict[str, type]:
|
def feedback_features(self) -> dict[str, type]:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@property
|
|
||||||
def is_connected(self) -> bool:
|
|
||||||
return self.left_arm.is_connected and self.right_arm.is_connected
|
|
||||||
|
|
||||||
@check_if_already_connected
|
|
||||||
def connect(self, calibrate: bool = True) -> None:
|
|
||||||
self.left_arm.connect(calibrate)
|
|
||||||
self.right_arm.connect(calibrate)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_calibrated(self) -> bool:
|
|
||||||
return self.left_arm.is_calibrated and self.right_arm.is_calibrated
|
|
||||||
|
|
||||||
def calibrate(self) -> None:
|
|
||||||
self.left_arm.calibrate()
|
|
||||||
self.right_arm.calibrate()
|
|
||||||
|
|
||||||
def configure(self) -> None:
|
|
||||||
self.left_arm.configure()
|
|
||||||
self.right_arm.configure()
|
|
||||||
|
|
||||||
def setup_motors(self) -> None:
|
def setup_motors(self) -> None:
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"Motor ID configuration is typically done via manufacturer tools for CAN motors."
|
"Motor ID configuration is typically done via manufacturer tools for CAN motors."
|
||||||
@@ -129,8 +109,3 @@ class BiOpenArmLeader(Teleoperator):
|
|||||||
def send_feedback(self, feedback: dict[str, float]) -> None:
|
def send_feedback(self, feedback: dict[str, float]) -> None:
|
||||||
# TODO: Implement force feedback
|
# TODO: Implement force feedback
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@check_if_not_connected
|
|
||||||
def disconnect(self) -> None:
|
|
||||||
self.left_arm.disconnect()
|
|
||||||
self.right_arm.disconnect()
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from ..openarm_leader import OpenArmLeaderConfigBase
|
|||||||
@TeleoperatorConfig.register_subclass("bi_openarm_leader")
|
@TeleoperatorConfig.register_subclass("bi_openarm_leader")
|
||||||
@dataclass
|
@dataclass
|
||||||
class BiOpenArmLeaderConfig(TeleoperatorConfig):
|
class BiOpenArmLeaderConfig(TeleoperatorConfig):
|
||||||
"""Configuration class for Bi OpenArm Follower robots."""
|
"""Configuration class for Bi OpenArm Leader teleoperators."""
|
||||||
|
|
||||||
left_arm_config: OpenArmLeaderConfigBase
|
left_arm_config: OpenArmLeaderConfigBase
|
||||||
right_arm_config: OpenArmLeaderConfigBase
|
right_arm_config: OpenArmLeaderConfigBase
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/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 .bi_openarm_mini import BiOpenArmMini
|
||||||
|
from .config_bi_openarm_mini import BiOpenArmMiniConfig
|
||||||
|
|
||||||
|
__all__ = ["BiOpenArmMini", "BiOpenArmMiniConfig"]
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#!/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 logging
|
||||||
|
from functools import cached_property
|
||||||
|
|
||||||
|
from lerobot.types import RobotAction
|
||||||
|
from lerobot.utils.bimanual import BimanualMixin
|
||||||
|
from lerobot.utils.decorators import check_if_not_connected
|
||||||
|
|
||||||
|
from ..openarm_mini import OpenArmMini, OpenArmMiniConfig
|
||||||
|
from ..teleoperator import Teleoperator
|
||||||
|
from .config_bi_openarm_mini import BiOpenArmMiniConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BiOpenArmMini(BimanualMixin, Teleoperator):
|
||||||
|
"""Bimanual OpenArm Mini teleoperator.
|
||||||
|
|
||||||
|
Composes two single-arm :class:`OpenArmMini` instances. Action and feedback
|
||||||
|
keys of each arm are namespaced with a ``left_`` / ``right_`` prefix, so a
|
||||||
|
bimanual leader can teleoperate a bimanual OpenArm follower.
|
||||||
|
"""
|
||||||
|
|
||||||
|
config_class = BiOpenArmMiniConfig
|
||||||
|
name = "bi_openarm_mini"
|
||||||
|
|
||||||
|
def __init__(self, config: BiOpenArmMiniConfig):
|
||||||
|
super().__init__(config)
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
# `side` is forced to match left/right regardless of what the user passed
|
||||||
|
# on the per-arm base config — the bimanual wrapper owns the side semantics.
|
||||||
|
left_arm_config = OpenArmMiniConfig(
|
||||||
|
id=f"{config.id}_left" if config.id else None,
|
||||||
|
calibration_dir=config.calibration_dir,
|
||||||
|
port=config.left_arm_config.port,
|
||||||
|
side="left",
|
||||||
|
use_degrees=config.left_arm_config.use_degrees,
|
||||||
|
)
|
||||||
|
|
||||||
|
right_arm_config = OpenArmMiniConfig(
|
||||||
|
id=f"{config.id}_right" if config.id else None,
|
||||||
|
calibration_dir=config.calibration_dir,
|
||||||
|
port=config.right_arm_config.port,
|
||||||
|
side="right",
|
||||||
|
use_degrees=config.right_arm_config.use_degrees,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.left_arm = OpenArmMini(left_arm_config)
|
||||||
|
self.right_arm = OpenArmMini(right_arm_config)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def action_features(self) -> dict[str, type]:
|
||||||
|
return {
|
||||||
|
**{f"left_{k}": v for k, v in self.left_arm.action_features.items()},
|
||||||
|
**{f"right_{k}": v for k, v in self.right_arm.action_features.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def feedback_features(self) -> dict[str, type]:
|
||||||
|
return {
|
||||||
|
**{f"left_{k}": v for k, v in self.left_arm.feedback_features.items()},
|
||||||
|
**{f"right_{k}": v for k, v in self.right_arm.feedback_features.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
def setup_motors(self) -> None:
|
||||||
|
self.left_arm.setup_motors()
|
||||||
|
self.right_arm.setup_motors()
|
||||||
|
|
||||||
|
@check_if_not_connected
|
||||||
|
def get_action(self) -> RobotAction:
|
||||||
|
action: RobotAction = {}
|
||||||
|
for k, v in self.left_arm.get_action().items():
|
||||||
|
action[f"left_{k}"] = v
|
||||||
|
for k, v in self.right_arm.get_action().items():
|
||||||
|
action[f"right_{k}"] = v
|
||||||
|
return action
|
||||||
|
|
||||||
|
@check_if_not_connected
|
||||||
|
def send_feedback(self, feedback: dict[str, float]) -> None:
|
||||||
|
left_fb = {k.removeprefix("left_"): v for k, v in feedback.items() if k.startswith("left_")}
|
||||||
|
right_fb = {k.removeprefix("right_"): v for k, v in feedback.items() if k.startswith("right_")}
|
||||||
|
if left_fb:
|
||||||
|
self.left_arm.send_feedback(left_fb)
|
||||||
|
if right_fb:
|
||||||
|
self.right_arm.send_feedback(right_fb)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/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 dataclasses import dataclass
|
||||||
|
|
||||||
|
from ..config import TeleoperatorConfig
|
||||||
|
from ..openarm_mini import OpenArmMiniConfigBase
|
||||||
|
|
||||||
|
|
||||||
|
@TeleoperatorConfig.register_subclass("bi_openarm_mini")
|
||||||
|
@dataclass
|
||||||
|
class BiOpenArmMiniConfig(TeleoperatorConfig):
|
||||||
|
"""Configuration class for Bi OpenArm Mini teleoperators."""
|
||||||
|
|
||||||
|
left_arm_config: OpenArmMiniConfigBase
|
||||||
|
right_arm_config: OpenArmMiniConfigBase
|
||||||
@@ -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 .bi_rebot_102_leader import BiRebotArm102Leader
|
from .bi_rebot_102_leader import BiRebot102Leader
|
||||||
from .config_bi_rebot_102_leader import BiRebotArm102LeaderConfig
|
from .config_bi_rebot_102_leader import BiRebot102LeaderConfig
|
||||||
|
|
||||||
__all__ = ["BiRebotArm102Leader", "BiRebotArm102LeaderConfig"]
|
__all__ = ["BiRebot102Leader", "BiRebot102LeaderConfig"]
|
||||||
|
|||||||
@@ -18,16 +18,17 @@ import logging
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
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.bimanual import BimanualMixin
|
||||||
|
from lerobot.utils.decorators import check_if_not_connected
|
||||||
|
|
||||||
from ..rebot_102_leader import RebotArm102Leader, RebotArm102LeaderTeleopConfig
|
from ..rebot_102_leader import RebotArm102Leader, RebotArm102LeaderTeleopConfig
|
||||||
from ..teleoperator import Teleoperator
|
from ..teleoperator import Teleoperator
|
||||||
from .config_bi_rebot_102_leader import BiRebotArm102LeaderConfig
|
from .config_bi_rebot_102_leader import BiRebot102LeaderConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BiRebotArm102Leader(Teleoperator):
|
class BiRebot102Leader(BimanualMixin, Teleoperator):
|
||||||
"""Bimanual Seeed Studio StarArm102 / reBot Arm 102 leader.
|
"""Bimanual Seeed Studio StarArm102 / reBot Arm 102 leader.
|
||||||
|
|
||||||
Composes two single-arm :class:`RebotArm102Leader` instances. Action keys of
|
Composes two single-arm :class:`RebotArm102Leader` instances. Action keys of
|
||||||
@@ -35,10 +36,10 @@ class BiRebotArm102Leader(Teleoperator):
|
|||||||
leader can teleoperate a bimanual reBot B601 follower.
|
leader can teleoperate a bimanual reBot B601 follower.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
config_class = BiRebotArm102LeaderConfig
|
config_class = BiRebot102LeaderConfig
|
||||||
name = "bi_rebot_102_leader"
|
name = "bi_rebot_102_leader"
|
||||||
|
|
||||||
def __init__(self, config: BiRebotArm102LeaderConfig):
|
def __init__(self, config: BiRebot102LeaderConfig):
|
||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
@@ -76,27 +77,6 @@ class BiRebotArm102Leader(Teleoperator):
|
|||||||
def feedback_features(self) -> dict[str, type]:
|
def feedback_features(self) -> dict[str, type]:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@property
|
|
||||||
def is_connected(self) -> bool:
|
|
||||||
return self.left_arm.is_connected and self.right_arm.is_connected
|
|
||||||
|
|
||||||
@check_if_already_connected
|
|
||||||
def connect(self, calibrate: bool = True) -> None:
|
|
||||||
self.left_arm.connect(calibrate)
|
|
||||||
self.right_arm.connect(calibrate)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_calibrated(self) -> bool:
|
|
||||||
return self.left_arm.is_calibrated and self.right_arm.is_calibrated
|
|
||||||
|
|
||||||
def calibrate(self) -> None:
|
|
||||||
self.left_arm.calibrate()
|
|
||||||
self.right_arm.calibrate()
|
|
||||||
|
|
||||||
def configure(self) -> None:
|
|
||||||
self.left_arm.configure()
|
|
||||||
self.right_arm.configure()
|
|
||||||
|
|
||||||
@check_if_not_connected
|
@check_if_not_connected
|
||||||
def get_action(self) -> RobotAction:
|
def get_action(self) -> RobotAction:
|
||||||
action_dict = {}
|
action_dict = {}
|
||||||
@@ -106,8 +86,3 @@ class BiRebotArm102Leader(Teleoperator):
|
|||||||
|
|
||||||
def send_feedback(self, feedback: dict[str, float]) -> None:
|
def send_feedback(self, feedback: dict[str, float]) -> None:
|
||||||
raise NotImplementedError("Feedback is not implemented for the reBot Arm 102 leader.")
|
raise NotImplementedError("Feedback is not implemented for the reBot Arm 102 leader.")
|
||||||
|
|
||||||
@check_if_not_connected
|
|
||||||
def disconnect(self) -> None:
|
|
||||||
self.left_arm.disconnect()
|
|
||||||
self.right_arm.disconnect()
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from ..rebot_102_leader import RebotArm102LeaderConfig
|
|||||||
|
|
||||||
@TeleoperatorConfig.register_subclass("bi_rebot_102_leader")
|
@TeleoperatorConfig.register_subclass("bi_rebot_102_leader")
|
||||||
@dataclass
|
@dataclass
|
||||||
class BiRebotArm102LeaderConfig(TeleoperatorConfig):
|
class BiRebot102LeaderConfig(TeleoperatorConfig):
|
||||||
"""Configuration class for the bimanual reBot Arm 102 leader teleoperator."""
|
"""Configuration class for the bimanual reBot Arm 102 leader teleoperator."""
|
||||||
|
|
||||||
left_arm_config: RebotArm102LeaderConfig
|
left_arm_config: RebotArm102LeaderConfig
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
import logging
|
import logging
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected
|
from lerobot.types import RobotAction
|
||||||
|
from lerobot.utils.bimanual import BimanualMixin
|
||||||
|
from lerobot.utils.decorators import check_if_not_connected
|
||||||
|
|
||||||
from ..so_leader import SOLeader, SOLeaderTeleopConfig
|
from ..so_leader import SOLeader, SOLeaderTeleopConfig
|
||||||
from ..teleoperator import Teleoperator
|
from ..teleoperator import Teleoperator
|
||||||
@@ -26,7 +28,7 @@ from .config_bi_so_leader import BiSOLeaderConfig
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BiSOLeader(Teleoperator):
|
class BiSOLeader(BimanualMixin, Teleoperator):
|
||||||
"""
|
"""
|
||||||
[Bimanual SO Leader Arms](https://github.com/TheRobotStudio/SO-ARM100) designed by TheRobotStudio
|
[Bimanual SO Leader Arms](https://github.com/TheRobotStudio/SO-ARM100) designed by TheRobotStudio
|
||||||
"""
|
"""
|
||||||
@@ -67,33 +69,12 @@ class BiSOLeader(Teleoperator):
|
|||||||
def feedback_features(self) -> dict[str, type]:
|
def feedback_features(self) -> dict[str, type]:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@property
|
|
||||||
def is_connected(self) -> bool:
|
|
||||||
return self.left_arm.is_connected and self.right_arm.is_connected
|
|
||||||
|
|
||||||
@check_if_already_connected
|
|
||||||
def connect(self, calibrate: bool = True) -> None:
|
|
||||||
self.left_arm.connect(calibrate)
|
|
||||||
self.right_arm.connect(calibrate)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_calibrated(self) -> bool:
|
|
||||||
return self.left_arm.is_calibrated and self.right_arm.is_calibrated
|
|
||||||
|
|
||||||
def calibrate(self) -> None:
|
|
||||||
self.left_arm.calibrate()
|
|
||||||
self.right_arm.calibrate()
|
|
||||||
|
|
||||||
def configure(self) -> None:
|
|
||||||
self.left_arm.configure()
|
|
||||||
self.right_arm.configure()
|
|
||||||
|
|
||||||
def setup_motors(self) -> None:
|
def setup_motors(self) -> None:
|
||||||
self.left_arm.setup_motors()
|
self.left_arm.setup_motors()
|
||||||
self.right_arm.setup_motors()
|
self.right_arm.setup_motors()
|
||||||
|
|
||||||
@check_if_not_connected
|
@check_if_not_connected
|
||||||
def get_action(self) -> dict[str, float]:
|
def get_action(self) -> RobotAction:
|
||||||
action_dict = {}
|
action_dict = {}
|
||||||
|
|
||||||
# Add "left_" prefix
|
# Add "left_" prefix
|
||||||
@@ -109,8 +90,3 @@ class BiSOLeader(Teleoperator):
|
|||||||
def send_feedback(self, feedback: dict[str, float]) -> None:
|
def send_feedback(self, feedback: dict[str, float]) -> None:
|
||||||
# TODO: Implement force feedback
|
# TODO: Implement force feedback
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@check_if_not_connected
|
|
||||||
def disconnect(self) -> None:
|
|
||||||
self.left_arm.disconnect()
|
|
||||||
self.right_arm.disconnect()
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
|
||||||
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
|
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -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 .config_openarm_mini import OpenArmMiniConfig
|
from .config_openarm_mini import OpenArmMiniConfig, OpenArmMiniConfigBase
|
||||||
from .openarm_mini import OpenArmMini
|
from .openarm_mini import OpenArmMini
|
||||||
|
|
||||||
__all__ = ["OpenArmMini", "OpenArmMiniConfig"]
|
__all__ = ["OpenArmMini", "OpenArmMiniConfig", "OpenArmMiniConfigBase"]
|
||||||
|
|||||||
@@ -19,12 +19,21 @@ from dataclasses import dataclass
|
|||||||
from ..config import TeleoperatorConfig
|
from ..config import TeleoperatorConfig
|
||||||
|
|
||||||
|
|
||||||
@TeleoperatorConfig.register_subclass("openarm_mini")
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class OpenArmMiniConfig(TeleoperatorConfig):
|
class OpenArmMiniConfigBase:
|
||||||
"""Configuration for OpenArm Mini teleoperator with Feetech motors (dual arms)."""
|
"""Base configuration for the OpenArm Mini teleoperator (Feetech STS3215, 7DOF + gripper)."""
|
||||||
|
|
||||||
port_right: str = "/dev/ttyUSB0"
|
# Serial port for the Feetech bus (e.g., "/dev/ttyUSB0").
|
||||||
port_left: str = "/dev/ttyUSB1"
|
port: str
|
||||||
|
|
||||||
|
# Side of the arm: "left" or "right". Controls per-joint direction flips applied
|
||||||
|
# during readout. If `None`, no flipping is applied.
|
||||||
|
side: str | None = None
|
||||||
|
|
||||||
use_degrees: bool = True
|
use_degrees: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@TeleoperatorConfig.register_subclass("openarm_mini")
|
||||||
|
@dataclass
|
||||||
|
class OpenArmMiniConfig(TeleoperatorConfig, OpenArmMiniConfigBase):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -31,22 +31,22 @@ from .config_openarm_mini import OpenArmMiniConfig
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Motors whose direction is inverted during readout
|
# Per-side motor direction flips applied during readout.
|
||||||
RIGHT_MOTORS_TO_FLIP = ["joint_1", "joint_2", "joint_3", "joint_4", "joint_5", "joint_7"]
|
SIDE_MOTORS_TO_FLIP: dict[str, list[str]] = {
|
||||||
LEFT_MOTORS_TO_FLIP = ["joint_1", "joint_3", "joint_4", "joint_5", "joint_6", "joint_7"]
|
"left": ["joint_1", "joint_3", "joint_4", "joint_5", "joint_6", "joint_7"],
|
||||||
|
"right": ["joint_1", "joint_2", "joint_3", "joint_4", "joint_5", "joint_7"],
|
||||||
|
}
|
||||||
|
|
||||||
# Leader joint 6 maps to follower joint 7 and vice versa
|
# Leader joint 6 ↔ follower joint 7 (symmetric — its own inverse).
|
||||||
JOINT_REMAP = {"joint_6": "joint_7", "joint_7": "joint_6"}
|
JOINT_REMAP = {"joint_6": "joint_7", "joint_7": "joint_6"}
|
||||||
JOINT_REMAP_REVERSE = {"joint_7": "joint_6", "joint_6": "joint_7"}
|
|
||||||
|
|
||||||
GRIPPER_TELEOP_TO_DEGREES = -0.65
|
GRIPPER_TELEOP_TO_DEGREES = -0.65
|
||||||
|
|
||||||
|
|
||||||
class OpenArmMini(Teleoperator):
|
class OpenArmMini(Teleoperator):
|
||||||
"""
|
"""OpenArm Mini single-arm teleoperator (Feetech STS3215, 7DOF + gripper).
|
||||||
OpenArm Mini Teleoperator with dual Feetech-based arms (8 motors per arm).
|
|
||||||
|
|
||||||
Each arm has 7 joints plus a gripper, using Feetech STS3215 servos.
|
For the bimanual setup, see :class:`BiOpenArmMini` which composes two of these.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
config_class = OpenArmMiniConfig
|
config_class = OpenArmMiniConfig
|
||||||
@@ -56,9 +56,12 @@ class OpenArmMini(Teleoperator):
|
|||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
|
if config.side is not None and config.side not in SIDE_MOTORS_TO_FLIP:
|
||||||
|
raise ValueError(f"Invalid side '{config.side}'; expected 'left', 'right', or None.")
|
||||||
|
self._motors_to_flip: list[str] = SIDE_MOTORS_TO_FLIP.get(config.side, []) if config.side else []
|
||||||
|
|
||||||
norm_mode_body = MotorNormMode.DEGREES
|
norm_mode_body = MotorNormMode.DEGREES
|
||||||
|
motors = {
|
||||||
motors_right = {
|
|
||||||
"joint_1": Motor(1, "sts3215", norm_mode_body),
|
"joint_1": Motor(1, "sts3215", norm_mode_body),
|
||||||
"joint_2": Motor(2, "sts3215", norm_mode_body),
|
"joint_2": Motor(2, "sts3215", norm_mode_body),
|
||||||
"joint_3": Motor(3, "sts3215", norm_mode_body),
|
"joint_3": Motor(3, "sts3215", norm_mode_body),
|
||||||
@@ -69,46 +72,15 @@ class OpenArmMini(Teleoperator):
|
|||||||
"gripper": Motor(8, "sts3215", MotorNormMode.RANGE_0_100),
|
"gripper": Motor(8, "sts3215", MotorNormMode.RANGE_0_100),
|
||||||
}
|
}
|
||||||
|
|
||||||
motors_left = {
|
self.bus = FeetechMotorsBus(
|
||||||
"joint_1": Motor(1, "sts3215", norm_mode_body),
|
port=self.config.port,
|
||||||
"joint_2": Motor(2, "sts3215", norm_mode_body),
|
motors=motors,
|
||||||
"joint_3": Motor(3, "sts3215", norm_mode_body),
|
calibration=self.calibration,
|
||||||
"joint_4": Motor(4, "sts3215", norm_mode_body),
|
|
||||||
"joint_5": Motor(5, "sts3215", norm_mode_body),
|
|
||||||
"joint_6": Motor(6, "sts3215", norm_mode_body),
|
|
||||||
"joint_7": Motor(7, "sts3215", norm_mode_body),
|
|
||||||
"gripper": Motor(8, "sts3215", MotorNormMode.RANGE_0_100),
|
|
||||||
}
|
|
||||||
|
|
||||||
cal_right = {
|
|
||||||
k.replace("right_", ""): v for k, v in (self.calibration or {}).items() if k.startswith("right_")
|
|
||||||
}
|
|
||||||
cal_left = {
|
|
||||||
k.replace("left_", ""): v for k, v in (self.calibration or {}).items() if k.startswith("left_")
|
|
||||||
}
|
|
||||||
|
|
||||||
self.bus_right = FeetechMotorsBus(
|
|
||||||
port=self.config.port_right,
|
|
||||||
motors=motors_right,
|
|
||||||
calibration=cal_right,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.bus_left = FeetechMotorsBus(
|
|
||||||
port=self.config.port_left,
|
|
||||||
motors=motors_left,
|
|
||||||
calibration=cal_left,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def action_features(self) -> dict[str, type]:
|
def action_features(self) -> dict[str, type]:
|
||||||
# Right first, then left — matches the robot (BiOpenArmFollower) ordering
|
return {f"{motor}.pos": float for motor in self.bus.motors}
|
||||||
# and the dataset feature names recorded during data collection.
|
|
||||||
features: dict[str, type] = {}
|
|
||||||
for motor in self.bus_right.motors:
|
|
||||||
features[f"right_{motor}.pos"] = float
|
|
||||||
for motor in self.bus_left.motors:
|
|
||||||
features[f"left_{motor}.pos"] = float
|
|
||||||
return features
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def feedback_features(self) -> dict[str, type]:
|
def feedback_features(self) -> dict[str, type]:
|
||||||
@@ -116,14 +88,12 @@ class OpenArmMini(Teleoperator):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
return self.bus_right.is_connected and self.bus_left.is_connected
|
return self.bus.is_connected
|
||||||
|
|
||||||
@check_if_already_connected
|
@check_if_already_connected
|
||||||
def connect(self, calibrate: bool = True) -> None:
|
def connect(self, calibrate: bool = True) -> None:
|
||||||
logger.info(f"Connecting right arm on {self.config.port_right}...")
|
logger.info(f"Connecting arm on {self.config.port}...")
|
||||||
self.bus_right.connect()
|
self.bus.connect()
|
||||||
logger.info(f"Connecting left arm on {self.config.port_left}...")
|
|
||||||
self.bus_left.connect()
|
|
||||||
|
|
||||||
if calibrate:
|
if calibrate:
|
||||||
self.calibrate()
|
self.calibrate()
|
||||||
@@ -133,14 +103,14 @@ class OpenArmMini(Teleoperator):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_calibrated(self) -> bool:
|
def is_calibrated(self) -> bool:
|
||||||
return self.bus_right.is_calibrated and self.bus_left.is_calibrated
|
return self.bus.is_calibrated
|
||||||
|
|
||||||
def calibrate(self) -> None:
|
def calibrate(self) -> None:
|
||||||
"""
|
"""
|
||||||
Run calibration procedure for OpenArm Mini.
|
Run calibration procedure for a single OpenArm Mini arm.
|
||||||
|
|
||||||
1. Disable torque
|
1. Disable torque
|
||||||
2. Ask user to position arms in hanging position with grippers closed
|
2. Ask user to position arm in hanging position with gripper closed
|
||||||
3. Set this as zero position via half-turn homing
|
3. Set this as zero position via half-turn homing
|
||||||
4. Interactive gripper calibration (open/close positions)
|
4. Interactive gripper calibration (open/close positions)
|
||||||
5. Save calibration
|
5. Save calibration
|
||||||
@@ -152,70 +122,51 @@ class OpenArmMini(Teleoperator):
|
|||||||
)
|
)
|
||||||
if user_input.strip().lower() != "c":
|
if user_input.strip().lower() != "c":
|
||||||
logger.info(f"Using existing calibration for {self.id}")
|
logger.info(f"Using existing calibration for {self.id}")
|
||||||
cal_right = {
|
self.bus.write_calibration(self.calibration)
|
||||||
k.replace("right_", ""): v for k, v in self.calibration.items() if k.startswith("right_")
|
|
||||||
}
|
|
||||||
cal_left = {
|
|
||||||
k.replace("left_", ""): v for k, v in self.calibration.items() if k.startswith("left_")
|
|
||||||
}
|
|
||||||
self.bus_right.write_calibration(cal_right)
|
|
||||||
self.bus_left.write_calibration(cal_left)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"\nRunning calibration for {self}")
|
logger.info(f"\nRunning calibration for {self}")
|
||||||
|
|
||||||
self._calibrate_arm("right", self.bus_right)
|
self.bus.disable_torque()
|
||||||
self._calibrate_arm("left", self.bus_left)
|
|
||||||
|
|
||||||
self._save_calibration()
|
logger.info("Setting Phase to 12 for all motors...")
|
||||||
print(f"\nCalibration complete and saved to {self.calibration_fpath}")
|
for motor in self.bus.motors:
|
||||||
|
self.bus.write("Phase", motor, 12)
|
||||||
|
|
||||||
def _calibrate_arm(self, arm_name: str, bus: FeetechMotorsBus) -> None:
|
for motor in self.bus.motors:
|
||||||
"""Calibrate a single arm with Feetech motors."""
|
self.bus.write("Operating_Mode", motor, OperatingMode.POSITION.value)
|
||||||
logger.info(f"\n=== Calibrating {arm_name.upper()} arm ===")
|
|
||||||
|
|
||||||
bus.disable_torque()
|
|
||||||
|
|
||||||
logger.info(f"Setting Phase to 12 for all motors in {arm_name.upper()} arm...")
|
|
||||||
for motor in bus.motors:
|
|
||||||
bus.write("Phase", motor, 12)
|
|
||||||
|
|
||||||
for motor in bus.motors:
|
|
||||||
bus.write("Operating_Mode", motor, OperatingMode.POSITION.value)
|
|
||||||
|
|
||||||
input(
|
input(
|
||||||
f"\nCalibration: Zero Position ({arm_name.upper()} arm)\n"
|
"\nCalibration: Zero Position\n"
|
||||||
"Position the arm in the following configuration:\n"
|
"Position the arm in the following configuration:\n"
|
||||||
" - Arm hanging straight down\n"
|
" - Arm hanging straight down\n"
|
||||||
" - Gripper closed\n"
|
" - Gripper closed\n"
|
||||||
"Press ENTER when ready..."
|
"Press ENTER when ready..."
|
||||||
)
|
)
|
||||||
|
|
||||||
homing_offsets = bus.set_half_turn_homings()
|
homing_offsets = self.bus.set_half_turn_homings()
|
||||||
logger.info(f"{arm_name.capitalize()} arm zero position set.")
|
logger.info("Arm zero position set.")
|
||||||
|
|
||||||
print(f"\nSetting motor ranges for {arm_name.upper()} arm\n")
|
print("\nSetting motor ranges\n")
|
||||||
|
|
||||||
if self.calibration is None:
|
if self.calibration is None:
|
||||||
self.calibration = {}
|
self.calibration = {}
|
||||||
|
|
||||||
motor_resolution = bus.model_resolution_table[list(bus.motors.values())[0].model]
|
motor_resolution = self.bus.model_resolution_table[list(self.bus.motors.values())[0].model]
|
||||||
max_res = motor_resolution - 1
|
max_res = motor_resolution - 1
|
||||||
|
|
||||||
for motor_name, motor in bus.motors.items():
|
for motor_name, motor in self.bus.motors.items():
|
||||||
prefixed_name = f"{arm_name}_{motor_name}"
|
|
||||||
|
|
||||||
if motor_name == "gripper":
|
if motor_name == "gripper":
|
||||||
input(
|
input(
|
||||||
f"\nGripper Calibration ({arm_name.upper()} arm)\n"
|
"\nGripper Calibration\n"
|
||||||
f"Step 1: CLOSE the gripper fully\n"
|
"Step 1: CLOSE the gripper fully\n"
|
||||||
f"Press ENTER when gripper is closed..."
|
"Press ENTER when gripper is closed..."
|
||||||
)
|
)
|
||||||
closed_pos = bus.read("Present_Position", motor_name, normalize=False)
|
closed_pos = self.bus.read("Present_Position", motor_name, normalize=False)
|
||||||
logger.info(f" Gripper closed position recorded: {closed_pos}")
|
logger.info(f" Gripper closed position recorded: {closed_pos}")
|
||||||
|
|
||||||
input("\nStep 2: OPEN the gripper fully\nPress ENTER when gripper is fully open...")
|
input("\nStep 2: OPEN the gripper fully\nPress ENTER when gripper is fully open...")
|
||||||
open_pos = bus.read("Present_Position", motor_name, normalize=False)
|
open_pos = self.bus.read("Present_Position", motor_name, normalize=False)
|
||||||
logger.info(f" Gripper open position recorded: {open_pos}")
|
logger.info(f" Gripper open position recorded: {open_pos}")
|
||||||
|
|
||||||
if closed_pos < open_pos:
|
if closed_pos < open_pos:
|
||||||
@@ -228,16 +179,16 @@ class OpenArmMini(Teleoperator):
|
|||||||
drive_mode = 1
|
drive_mode = 1
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f" {prefixed_name}: range set to [{range_min}, {range_max}] "
|
f" {motor_name}: range set to [{range_min}, {range_max}] "
|
||||||
f"(0=closed, 100=open, drive_mode={drive_mode})"
|
f"(0=closed, 100=open, drive_mode={drive_mode})"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
range_min = 0
|
range_min = 0
|
||||||
range_max = max_res
|
range_max = max_res
|
||||||
drive_mode = 0
|
drive_mode = 0
|
||||||
logger.info(f" {prefixed_name}: range set to [0, {max_res}] (full motor range)")
|
logger.info(f" {motor_name}: range set to [0, {max_res}] (full motor range)")
|
||||||
|
|
||||||
self.calibration[prefixed_name] = MotorCalibration(
|
self.calibration[motor_name] = MotorCalibration(
|
||||||
id=motor.id,
|
id=motor.id,
|
||||||
drive_mode=drive_mode,
|
drive_mode=drive_mode,
|
||||||
homing_offset=homing_offsets[motor_name],
|
homing_offset=homing_offsets[motor_name],
|
||||||
@@ -245,108 +196,68 @@ class OpenArmMini(Teleoperator):
|
|||||||
range_max=range_max,
|
range_max=range_max,
|
||||||
)
|
)
|
||||||
|
|
||||||
cal_for_bus = {
|
self.bus.write_calibration(self.calibration)
|
||||||
k.replace(f"{arm_name}_", ""): v
|
self._save_calibration()
|
||||||
for k, v in self.calibration.items()
|
print(f"\nCalibration complete and saved to {self.calibration_fpath}")
|
||||||
if k.startswith(f"{arm_name}_")
|
|
||||||
}
|
|
||||||
bus.write_calibration(cal_for_bus)
|
|
||||||
|
|
||||||
def configure(self) -> None:
|
def configure(self) -> None:
|
||||||
self.bus_right.disable_torque()
|
self.bus.disable_torque()
|
||||||
self.bus_right.configure_motors()
|
self.bus.configure_motors()
|
||||||
for motor in self.bus_right.motors:
|
for motor in self.bus.motors:
|
||||||
self.bus_right.write("Operating_Mode", motor, OperatingMode.POSITION.value)
|
self.bus.write("Operating_Mode", motor, OperatingMode.POSITION.value)
|
||||||
|
|
||||||
self.bus_left.disable_torque()
|
|
||||||
self.bus_left.configure_motors()
|
|
||||||
for motor in self.bus_left.motors:
|
|
||||||
self.bus_left.write("Operating_Mode", motor, OperatingMode.POSITION.value)
|
|
||||||
|
|
||||||
def setup_motors(self) -> None:
|
def setup_motors(self) -> None:
|
||||||
print("\nSetting up RIGHT arm motors...")
|
for motor in reversed(self.bus.motors):
|
||||||
for motor in reversed(self.bus_right.motors):
|
input(f"Connect the controller board to the '{motor}' motor only and press enter.")
|
||||||
input(f"Connect the controller board to the RIGHT '{motor}' motor only and press enter.")
|
self.bus.setup_motor(motor)
|
||||||
self.bus_right.setup_motor(motor)
|
print(f"'{motor}' motor id set to {self.bus.motors[motor].id}")
|
||||||
print(f"RIGHT '{motor}' motor id set to {self.bus_right.motors[motor].id}")
|
|
||||||
|
|
||||||
print("\nSetting up LEFT arm motors...")
|
|
||||||
for motor in reversed(self.bus_left.motors):
|
|
||||||
input(f"Connect the controller board to the LEFT '{motor}' motor only and press enter.")
|
|
||||||
self.bus_left.setup_motor(motor)
|
|
||||||
print(f"LEFT '{motor}' motor id set to {self.bus_left.motors[motor].id}")
|
|
||||||
|
|
||||||
@check_if_not_connected
|
@check_if_not_connected
|
||||||
def get_action(self) -> RobotAction:
|
def get_action(self) -> RobotAction:
|
||||||
"""Get current action from both arms (read positions from all motors)."""
|
"""Get current action (read positions from all motors)."""
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
|
|
||||||
right_positions = self.bus_right.sync_read("Present_Position")
|
positions = self.bus.sync_read("Present_Position")
|
||||||
left_positions = self.bus_left.sync_read("Present_Position")
|
|
||||||
|
|
||||||
# Right first, then left — matches the robot (BiOpenArmFollower) ordering
|
|
||||||
# and the dataset feature names recorded during data collection.
|
|
||||||
# Joint 6↔7 remap: leader joint_6 → follower joint_7 and vice versa.
|
# Joint 6↔7 remap: leader joint_6 → follower joint_7 and vice versa.
|
||||||
|
# Per-side direction flip is applied based on the configured `side`.
|
||||||
action: dict[str, Any] = {}
|
action: dict[str, Any] = {}
|
||||||
for motor, val in right_positions.items():
|
for motor, val in positions.items():
|
||||||
target = JOINT_REMAP.get(motor, motor)
|
target = JOINT_REMAP.get(motor, motor)
|
||||||
if motor == "gripper":
|
if motor == "gripper":
|
||||||
# Convert gripper from teleop 0-100 to openarms degrees: 0→0°, 100→-65°
|
# Convert gripper from teleop 0-100 to openarms degrees: 0→0°, 100→-65°
|
||||||
action[f"right_{target}.pos"] = val * GRIPPER_TELEOP_TO_DEGREES
|
action[f"{target}.pos"] = val * GRIPPER_TELEOP_TO_DEGREES
|
||||||
else:
|
else:
|
||||||
action[f"right_{target}.pos"] = -val if motor in RIGHT_MOTORS_TO_FLIP else val
|
action[f"{target}.pos"] = -val if motor in self._motors_to_flip else val
|
||||||
for motor, val in left_positions.items():
|
|
||||||
target = JOINT_REMAP.get(motor, motor)
|
|
||||||
if motor == "gripper":
|
|
||||||
action[f"left_{target}.pos"] = val * GRIPPER_TELEOP_TO_DEGREES
|
|
||||||
else:
|
|
||||||
action[f"left_{target}.pos"] = -val if motor in LEFT_MOTORS_TO_FLIP else val
|
|
||||||
|
|
||||||
dt_ms = (time.perf_counter() - start) * 1e3
|
dt_ms = (time.perf_counter() - start) * 1e3
|
||||||
logger.debug(f"{self} read action: {dt_ms:.1f}ms")
|
logger.debug(f"{self} read action: {dt_ms:.1f}ms")
|
||||||
return action
|
return action
|
||||||
|
|
||||||
def enable_torque(self) -> None:
|
def enable_torque(self) -> None:
|
||||||
"""Enable torque on both arms for position control."""
|
self.bus.enable_torque()
|
||||||
self.bus_right.enable_torque()
|
|
||||||
self.bus_left.enable_torque()
|
|
||||||
|
|
||||||
def disable_torque(self) -> None:
|
def disable_torque(self) -> None:
|
||||||
"""Disable torque on both arms for free movement."""
|
self.bus.disable_torque()
|
||||||
self.bus_right.disable_torque()
|
|
||||||
self.bus_left.disable_torque()
|
|
||||||
|
|
||||||
def write_goal_positions(self, positions: dict[str, float]) -> None:
|
def write_goal_positions(self, positions: dict[str, float]) -> None:
|
||||||
"""Write goal positions to motors (inverse of get_action flip/gripper/remap logic)."""
|
"""Write goal positions to motors (inverse of get_action flip/gripper/remap logic)."""
|
||||||
right_goals: dict[str, float] = {}
|
goals: dict[str, float] = {}
|
||||||
left_goals: dict[str, float] = {}
|
|
||||||
|
|
||||||
for key, val in positions.items():
|
for key, val in positions.items():
|
||||||
if not key.endswith(".pos"):
|
if not key.endswith(".pos"):
|
||||||
continue
|
continue
|
||||||
motor_name = key.removesuffix(".pos")
|
base = key.removesuffix(".pos")
|
||||||
if motor_name.startswith("right_"):
|
# JOINT_REMAP is symmetric (its own inverse).
|
||||||
base = motor_name.removeprefix("right_")
|
target = JOINT_REMAP.get(base, base)
|
||||||
# Reverse remap: follower joint_7 → leader joint_6 and vice versa
|
if base == "gripper":
|
||||||
target = JOINT_REMAP_REVERSE.get(base, base)
|
# Convert robot degrees to teleop 0-100: 0°→0, -65°→100
|
||||||
if base == "gripper":
|
goals[target] = val / GRIPPER_TELEOP_TO_DEGREES
|
||||||
# Convert robot degrees to teleop 0-100: 0°→0, -65°→100
|
else:
|
||||||
right_goals[target] = val / GRIPPER_TELEOP_TO_DEGREES
|
# Un-flip using the ORIGINAL motor name (target = leader motor)
|
||||||
else:
|
goals[target] = -val if target in self._motors_to_flip else val
|
||||||
# Un-flip using the ORIGINAL motor name (target = leader motor)
|
|
||||||
right_goals[target] = -val if target in RIGHT_MOTORS_TO_FLIP else val
|
|
||||||
elif motor_name.startswith("left_"):
|
|
||||||
base = motor_name.removeprefix("left_")
|
|
||||||
target = JOINT_REMAP_REVERSE.get(base, base)
|
|
||||||
if base == "gripper":
|
|
||||||
left_goals[target] = val / GRIPPER_TELEOP_TO_DEGREES
|
|
||||||
else:
|
|
||||||
left_goals[target] = -val if target in LEFT_MOTORS_TO_FLIP else val
|
|
||||||
|
|
||||||
if right_goals:
|
if goals:
|
||||||
self.bus_right.sync_write("Goal_Position", right_goals)
|
self.bus.sync_write("Goal_Position", goals)
|
||||||
if left_goals:
|
|
||||||
self.bus_left.sync_write("Goal_Position", left_goals)
|
|
||||||
|
|
||||||
@check_if_not_connected
|
@check_if_not_connected
|
||||||
def send_feedback(self, feedback: dict[str, float]) -> None:
|
def send_feedback(self, feedback: dict[str, float]) -> None:
|
||||||
@@ -354,6 +265,5 @@ class OpenArmMini(Teleoperator):
|
|||||||
|
|
||||||
@check_if_not_connected
|
@check_if_not_connected
|
||||||
def disconnect(self) -> None:
|
def disconnect(self) -> None:
|
||||||
self.bus_right.disconnect()
|
self.bus.disconnect()
|
||||||
self.bus_left.disconnect()
|
|
||||||
logger.info(f"{self} disconnected.")
|
logger.info(f"{self} disconnected.")
|
||||||
|
|||||||
@@ -99,14 +99,18 @@ def make_teleoperator_from_config(config: TeleoperatorConfig) -> "Teleoperator":
|
|||||||
from .openarm_mini import OpenArmMini
|
from .openarm_mini import OpenArmMini
|
||||||
|
|
||||||
return OpenArmMini(config)
|
return OpenArmMini(config)
|
||||||
|
elif config.type == "bi_openarm_mini":
|
||||||
|
from .bi_openarm_mini import BiOpenArmMini
|
||||||
|
|
||||||
|
return BiOpenArmMini(config)
|
||||||
elif config.type == "rebot_102_leader":
|
elif config.type == "rebot_102_leader":
|
||||||
from .rebot_102_leader import RebotArm102Leader
|
from .rebot_102_leader import RebotArm102Leader
|
||||||
|
|
||||||
return RebotArm102Leader(config)
|
return RebotArm102Leader(config)
|
||||||
elif config.type == "bi_rebot_102_leader":
|
elif config.type == "bi_rebot_102_leader":
|
||||||
from .bi_rebot_102_leader import BiRebotArm102Leader
|
from .bi_rebot_102_leader import BiRebot102Leader
|
||||||
|
|
||||||
return BiRebotArm102Leader(config)
|
return BiRebot102Leader(config)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
return cast("Teleoperator", make_device_from_device_class(config))
|
return cast("Teleoperator", make_device_from_device_class(config))
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/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 typing import Any
|
||||||
|
|
||||||
|
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected
|
||||||
|
|
||||||
|
|
||||||
|
class BimanualMixin:
|
||||||
|
"""Lifecycle delegation for bimanual robots and teleoperators.
|
||||||
|
|
||||||
|
Concrete subclasses must populate ``self.left_arm`` and ``self.right_arm`` in
|
||||||
|
their own ``__init__``. They retain ownership of feature dicts and the
|
||||||
|
data-routing methods (``get_action`` / ``send_action`` / ``get_observation`` /
|
||||||
|
``send_feedback``), which vary per-embodiment.
|
||||||
|
|
||||||
|
Inherit before the ``Robot`` / ``Teleoperator`` base so the mixin's methods
|
||||||
|
take precedence in the MRO::
|
||||||
|
|
||||||
|
class BiFooFollower(BimanualMixin, Robot): ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
left_arm: Any
|
||||||
|
right_arm: Any
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self.left_arm.is_connected and self.right_arm.is_connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_calibrated(self) -> bool:
|
||||||
|
return self.left_arm.is_calibrated and self.right_arm.is_calibrated
|
||||||
|
|
||||||
|
@check_if_already_connected
|
||||||
|
def connect(self, calibrate: bool = True) -> None:
|
||||||
|
self.left_arm.connect(calibrate)
|
||||||
|
self.right_arm.connect(calibrate)
|
||||||
|
|
||||||
|
def calibrate(self) -> None:
|
||||||
|
self.left_arm.calibrate()
|
||||||
|
self.right_arm.calibrate()
|
||||||
|
|
||||||
|
def configure(self) -> None:
|
||||||
|
self.left_arm.configure()
|
||||||
|
self.right_arm.configure()
|
||||||
|
|
||||||
|
@check_if_not_connected
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
self.left_arm.disconnect()
|
||||||
|
self.right_arm.disconnect()
|
||||||
@@ -18,7 +18,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from lerobot.teleoperators.bi_rebot_102_leader import BiRebotArm102Leader, BiRebotArm102LeaderConfig
|
from lerobot.teleoperators.bi_rebot_102_leader import BiRebot102Leader, BiRebot102LeaderConfig
|
||||||
from lerobot.teleoperators.rebot_102_leader import (
|
from lerobot.teleoperators.rebot_102_leader import (
|
||||||
RebotArm102Leader,
|
RebotArm102Leader,
|
||||||
RebotArm102LeaderConfig,
|
RebotArm102LeaderConfig,
|
||||||
@@ -91,11 +91,11 @@ def test_send_feedback_not_implemented(leader):
|
|||||||
|
|
||||||
def test_bimanual_prefixes_features():
|
def test_bimanual_prefixes_features():
|
||||||
with patch(f"{_MODULE}.require_package", lambda *a, **kw: None):
|
with patch(f"{_MODULE}.require_package", lambda *a, **kw: None):
|
||||||
cfg = BiRebotArm102LeaderConfig(
|
cfg = BiRebot102LeaderConfig(
|
||||||
left_arm_config=RebotArm102LeaderConfig(port="/dev/null0"),
|
left_arm_config=RebotArm102LeaderConfig(port="/dev/null0"),
|
||||||
right_arm_config=RebotArm102LeaderConfig(port="/dev/null1"),
|
right_arm_config=RebotArm102LeaderConfig(port="/dev/null1"),
|
||||||
)
|
)
|
||||||
teleop = BiRebotArm102Leader(cfg)
|
teleop = BiRebot102Leader(cfg)
|
||||||
assert any(k.startswith("left_") for k in teleop.action_features)
|
assert any(k.startswith("left_") for k in teleop.action_features)
|
||||||
assert any(k.startswith("right_") for k in teleop.action_features)
|
assert any(k.startswith("right_") for k in teleop.action_features)
|
||||||
assert "left_gripper.pos" in teleop.action_features
|
assert "left_gripper.pos" in teleop.action_features
|
||||||
|
|||||||
Reference in New Issue
Block a user