Merge branch 'main' into feat/row-group-per-episode

This commit is contained in:
Pepijn
2026-06-15 17:49:41 +02:00
committed by GitHub
30 changed files with 1405 additions and 1327 deletions
+8 -8
View File
@@ -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" \
+1 -1
View File
@@ -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)
+1
View File
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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.")
+6 -2
View File
@@ -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))
+63
View File
@@ -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()
+3 -3
View File
@@ -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
Generated
+949 -900
View File
File diff suppressed because it is too large Load Diff