From 350d01b74d3c61df7fc3833df1b451d90f655290 Mon Sep 17 00:00:00 2001 From: Steven Palma Date: Thu, 11 Jun 2026 15:19:24 +0200 Subject: [PATCH] chore(robots): homogenize bi setups --- .../bi_openarm_follower.py | 65 +++++++++---------- .../config_bi_openarm_follower.py | 4 +- .../bi_rebot_b601_follower.py | 32 ++++++--- .../config_bi_rebot_b601_follower.py | 9 ++- .../robots/bi_so_follower/bi_so_follower.py | 39 +++++++---- .../bi_so_follower/config_bi_so_follower.py | 9 ++- .../config_bi_openarm_leader.py | 2 +- .../bi_rebot_102_leader/__init__.py | 6 +- .../bi_rebot_102_leader.py | 8 +-- .../config_bi_rebot_102_leader.py | 2 +- .../bi_so_leader/bi_so_leader.py | 3 +- src/lerobot/teleoperators/utils.py | 4 +- tests/teleoperators/test_rebot_102_leader.py | 6 +- 13 files changed, 113 insertions(+), 76 deletions(-) diff --git a/src/lerobot/robots/bi_openarm_follower/bi_openarm_follower.py b/src/lerobot/robots/bi_openarm_follower/bi_openarm_follower.py index b6f446d9c..6721cb367 100644 --- a/src/lerobot/robots/bi_openarm_follower/bi_openarm_follower.py +++ b/src/lerobot/robots/bi_openarm_follower/bi_openarm_follower.py @@ -39,15 +39,17 @@ class BiOpenArmFollower(Robot): super().__init__(config) self.config = config - # Top-level cameras are distributed evenly: each arm's OpenArmFollower - # will only open the cameras assigned to it. Per-arm cameras are used - # as fallback when top-level cameras are empty. - if config.cameras: - left_cameras = config.cameras - right_cameras = {} - else: - left_cameras = config.left_arm_config.cameras - right_cameras = config.right_arm_config.cameras + # 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 = OpenArmFollowerConfig( id=f"{config.id}_left" if config.id else None, @@ -56,7 +58,7 @@ class BiOpenArmFollower(Robot): disable_torque_on_disconnect=config.left_arm_config.disable_torque_on_disconnect, use_velocity_and_torque=config.left_arm_config.use_velocity_and_torque, max_relative_target=config.left_arm_config.max_relative_target, - cameras=left_cameras, + cameras=left_arm_cameras, side=config.left_arm_config.side, can_interface=config.left_arm_config.can_interface, use_can_fd=config.left_arm_config.use_can_fd, @@ -75,7 +77,7 @@ class BiOpenArmFollower(Robot): disable_torque_on_disconnect=config.right_arm_config.disable_torque_on_disconnect, use_velocity_and_torque=config.right_arm_config.use_velocity_and_torque, 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, can_interface=config.right_arm_config.can_interface, use_can_fd=config.right_arm_config.use_can_fd, @@ -95,22 +97,19 @@ class BiOpenArmFollower(Robot): @property 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 { - **{f"right_{k}": v for k, v in right_arm_motors_ft.items()}, - **{f"left_{k}": v for k, v in left_arm_motors_ft.items()}, + **{f"left_{k}": v for k, v in self.left_arm._motors_ft.items()}, + **{f"right_{k}": v for k, v in self.right_arm._motors_ft.items()}, } @property def _cameras_ft(self) -> dict[str, tuple]: - # Cameras already have unique user-chosen names (e.g. "left_wrist", "base", - # "right_wrist"), so we merge them directly — unlike motors which need the - # left_/right_ prefix to disambiguate identical per-arm joint names. - return {**self.left_arm._cameras_ft, **self.right_arm._cameras_ft} + out: dict[str, tuple] = {} + 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 + for k, v in self.right_arm._cameras_ft.items(): + out[f"right_{k}"] = v + return out @cached_property def observation_features(self) -> dict[str, type | tuple]: @@ -148,21 +147,15 @@ class BiOpenArmFollower(Robot): @check_if_not_connected def get_observation(self) -> RobotObservation: - obs_dict = {} + obs_dict: RobotObservation = {} - # Camera keys that should NOT get the arm prefix (they already have unique names) - left_cam_keys = set(self.left_arm.cameras.keys()) - right_cam_keys = set(self.right_arm.cameras.keys()) + # Add "left_" prefix to per-arm keys; keep top-level camera keys unprefixed. + for key, value in self.left_arm.get_observation().items(): + 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 - # and the dataset feature names recorded during data collection. - right_obs = self.right_arm.get_observation() - 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 + # Add "right_" prefix + for key, value in self.right_arm.get_observation().items(): + obs_dict[f"right_{key}"] = value return obs_dict @@ -189,7 +182,7 @@ class BiOpenArmFollower(Robot): 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()} - 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): diff --git a/src/lerobot/robots/bi_openarm_follower/config_bi_openarm_follower.py b/src/lerobot/robots/bi_openarm_follower/config_bi_openarm_follower.py index 9ed56aeac..d1c9335a0 100644 --- a/src/lerobot/robots/bi_openarm_follower/config_bi_openarm_follower.py +++ b/src/lerobot/robots/bi_openarm_follower/config_bi_openarm_follower.py @@ -32,5 +32,7 @@ class BiOpenArmFollowerConfig(RobotConfig): left_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) diff --git a/src/lerobot/robots/bi_rebot_b601_follower/bi_rebot_b601_follower.py b/src/lerobot/robots/bi_rebot_b601_follower/bi_rebot_b601_follower.py index bd19f1b62..bdf8bf4b2 100644 --- a/src/lerobot/robots/bi_rebot_b601_follower/bi_rebot_b601_follower.py +++ b/src/lerobot/robots/bi_rebot_b601_follower/bi_rebot_b601_follower.py @@ -41,6 +41,18 @@ class BiRebotB601Follower(Robot): super().__init__(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( id=f"{config.id}_left" if config.id else None, calibration_dir=config.calibration_dir, @@ -49,7 +61,7 @@ class BiRebotB601Follower(Robot): dm_serial_baud=config.left_arm_config.dm_serial_baud, disable_torque_on_disconnect=config.left_arm_config.disable_torque_on_disconnect, 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, pos_vel_velocity=config.left_arm_config.pos_vel_velocity, gripper_torque_ratio=config.left_arm_config.gripper_torque_ratio, @@ -86,10 +98,12 @@ class BiRebotB601Follower(Robot): @property def _cameras_ft(self) -> dict[str, tuple]: - return { - **{f"left_{k}": v 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: dict[str, tuple] = {} + 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 + for k, v in self.right_arm._cameras_ft.items(): + out[f"right_{k}"] = v + return out @cached_property def observation_features(self) -> dict[str, type | tuple]: @@ -122,9 +136,11 @@ class BiRebotB601Follower(Robot): @check_if_not_connected def get_observation(self) -> RobotObservation: - obs_dict = {} - obs_dict.update({f"left_{k}": v 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: RobotObservation = {} + for k, v in self.left_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 @check_if_not_connected diff --git a/src/lerobot/robots/bi_rebot_b601_follower/config_bi_rebot_b601_follower.py b/src/lerobot/robots/bi_rebot_b601_follower/config_bi_rebot_b601_follower.py index 079b7a355..fce2967a8 100644 --- a/src/lerobot/robots/bi_rebot_b601_follower/config_bi_rebot_b601_follower.py +++ b/src/lerobot/robots/bi_rebot_b601_follower/config_bi_rebot_b601_follower.py @@ -14,7 +14,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from dataclasses import dataclass +from dataclasses import dataclass, field + +from lerobot.cameras import CameraConfig from ..config import RobotConfig from ..rebot_b601_follower import RebotB601FollowerConfig @@ -27,3 +29,8 @@ class BiRebotB601FollowerConfig(RobotConfig): left_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) diff --git a/src/lerobot/robots/bi_so_follower/bi_so_follower.py b/src/lerobot/robots/bi_so_follower/bi_so_follower.py index f592150a6..9b599d9e8 100644 --- a/src/lerobot/robots/bi_so_follower/bi_so_follower.py +++ b/src/lerobot/robots/bi_so_follower/bi_so_follower.py @@ -39,6 +39,18 @@ class BiSOFollower(Robot): super().__init__(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( id=f"{config.id}_left" if config.id else None, calibration_dir=config.calibration_dir, @@ -46,7 +58,7 @@ class BiSOFollower(Robot): disable_torque_on_disconnect=config.left_arm_config.disable_torque_on_disconnect, max_relative_target=config.left_arm_config.max_relative_target, use_degrees=config.left_arm_config.use_degrees, - cameras=config.left_arm_config.cameras, + cameras=left_arm_cameras, ) right_arm_config = SOFollowerRobotConfig( @@ -77,13 +89,12 @@ class BiSOFollower(Robot): @property def _cameras_ft(self) -> dict[str, tuple]: - left_arm_cameras_ft = self.left_arm._cameras_ft - right_arm_cameras_ft = self.right_arm._cameras_ft - - return { - **{f"left_{k}": v for k, v in left_arm_cameras_ft.items()}, - **{f"right_{k}": v for k, v in right_arm_cameras_ft.items()}, - } + out: dict[str, tuple] = {} + 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 + for k, v in self.right_arm._cameras_ft.items(): + out[f"right_{k}"] = v + return out @cached_property def observation_features(self) -> dict[str, type | tuple]: @@ -120,15 +131,15 @@ class BiSOFollower(Robot): @check_if_not_connected def get_observation(self) -> RobotObservation: - obs_dict = {} + obs_dict: RobotObservation = {} - # Add "left_" prefix - left_obs = self.left_arm.get_observation() - obs_dict.update({f"left_{key}": value for key, value in left_obs.items()}) + # Add "left_" prefix to per-arm keys; keep top-level camera keys unprefixed. + for key, value in self.left_arm.get_observation().items(): + obs_dict[key if key in self._top_level_cam_keys else f"left_{key}"] = value # Add "right_" prefix - right_obs = self.right_arm.get_observation() - obs_dict.update({f"right_{key}": value for key, value in right_obs.items()}) + for key, value in self.right_arm.get_observation().items(): + obs_dict[f"right_{key}"] = value return obs_dict diff --git a/src/lerobot/robots/bi_so_follower/config_bi_so_follower.py b/src/lerobot/robots/bi_so_follower/config_bi_so_follower.py index 97afbab4f..0c68e7cb1 100644 --- a/src/lerobot/robots/bi_so_follower/config_bi_so_follower.py +++ b/src/lerobot/robots/bi_so_follower/config_bi_so_follower.py @@ -14,7 +14,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from dataclasses import dataclass +from dataclasses import dataclass, field + +from lerobot.cameras import CameraConfig from ..config import RobotConfig from ..so_follower import SOFollowerConfig @@ -27,3 +29,8 @@ class BiSOFollowerConfig(RobotConfig): left_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) diff --git a/src/lerobot/teleoperators/bi_openarm_leader/config_bi_openarm_leader.py b/src/lerobot/teleoperators/bi_openarm_leader/config_bi_openarm_leader.py index f7ec929ed..6425c179a 100644 --- a/src/lerobot/teleoperators/bi_openarm_leader/config_bi_openarm_leader.py +++ b/src/lerobot/teleoperators/bi_openarm_leader/config_bi_openarm_leader.py @@ -23,7 +23,7 @@ from ..openarm_leader import OpenArmLeaderConfigBase @TeleoperatorConfig.register_subclass("bi_openarm_leader") @dataclass class BiOpenArmLeaderConfig(TeleoperatorConfig): - """Configuration class for Bi OpenArm Follower robots.""" + """Configuration class for Bi OpenArm Leader teleoperators.""" left_arm_config: OpenArmLeaderConfigBase right_arm_config: OpenArmLeaderConfigBase diff --git a/src/lerobot/teleoperators/bi_rebot_102_leader/__init__.py b/src/lerobot/teleoperators/bi_rebot_102_leader/__init__.py index c15cf76d8..9b26d6e59 100644 --- a/src/lerobot/teleoperators/bi_rebot_102_leader/__init__.py +++ b/src/lerobot/teleoperators/bi_rebot_102_leader/__init__.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .bi_rebot_102_leader import BiRebotArm102Leader -from .config_bi_rebot_102_leader import BiRebotArm102LeaderConfig +from .bi_rebot_102_leader import BiRebot102Leader +from .config_bi_rebot_102_leader import BiRebot102LeaderConfig -__all__ = ["BiRebotArm102Leader", "BiRebotArm102LeaderConfig"] +__all__ = ["BiRebot102Leader", "BiRebot102LeaderConfig"] diff --git a/src/lerobot/teleoperators/bi_rebot_102_leader/bi_rebot_102_leader.py b/src/lerobot/teleoperators/bi_rebot_102_leader/bi_rebot_102_leader.py index a4e5fd8c6..6cf364640 100644 --- a/src/lerobot/teleoperators/bi_rebot_102_leader/bi_rebot_102_leader.py +++ b/src/lerobot/teleoperators/bi_rebot_102_leader/bi_rebot_102_leader.py @@ -22,12 +22,12 @@ from lerobot.utils.decorators import check_if_already_connected, check_if_not_co from ..rebot_102_leader import RebotArm102Leader, RebotArm102LeaderTeleopConfig from ..teleoperator import Teleoperator -from .config_bi_rebot_102_leader import BiRebotArm102LeaderConfig +from .config_bi_rebot_102_leader import BiRebot102LeaderConfig logger = logging.getLogger(__name__) -class BiRebotArm102Leader(Teleoperator): +class BiRebot102Leader(Teleoperator): """Bimanual Seeed Studio StarArm102 / reBot Arm 102 leader. Composes two single-arm :class:`RebotArm102Leader` instances. Action keys of @@ -35,10 +35,10 @@ class BiRebotArm102Leader(Teleoperator): leader can teleoperate a bimanual reBot B601 follower. """ - config_class = BiRebotArm102LeaderConfig + config_class = BiRebot102LeaderConfig name = "bi_rebot_102_leader" - def __init__(self, config: BiRebotArm102LeaderConfig): + def __init__(self, config: BiRebot102LeaderConfig): super().__init__(config) self.config = config diff --git a/src/lerobot/teleoperators/bi_rebot_102_leader/config_bi_rebot_102_leader.py b/src/lerobot/teleoperators/bi_rebot_102_leader/config_bi_rebot_102_leader.py index 265ae26c1..2503b102c 100644 --- a/src/lerobot/teleoperators/bi_rebot_102_leader/config_bi_rebot_102_leader.py +++ b/src/lerobot/teleoperators/bi_rebot_102_leader/config_bi_rebot_102_leader.py @@ -22,7 +22,7 @@ from ..rebot_102_leader import RebotArm102LeaderConfig @TeleoperatorConfig.register_subclass("bi_rebot_102_leader") @dataclass -class BiRebotArm102LeaderConfig(TeleoperatorConfig): +class BiRebot102LeaderConfig(TeleoperatorConfig): """Configuration class for the bimanual reBot Arm 102 leader teleoperator.""" left_arm_config: RebotArm102LeaderConfig diff --git a/src/lerobot/teleoperators/bi_so_leader/bi_so_leader.py b/src/lerobot/teleoperators/bi_so_leader/bi_so_leader.py index f2e88d20a..e87c5a30a 100644 --- a/src/lerobot/teleoperators/bi_so_leader/bi_so_leader.py +++ b/src/lerobot/teleoperators/bi_so_leader/bi_so_leader.py @@ -17,6 +17,7 @@ import logging from functools import cached_property +from lerobot.types import RobotAction from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected from ..so_leader import SOLeader, SOLeaderTeleopConfig @@ -93,7 +94,7 @@ class BiSOLeader(Teleoperator): self.right_arm.setup_motors() @check_if_not_connected - def get_action(self) -> dict[str, float]: + def get_action(self) -> RobotAction: action_dict = {} # Add "left_" prefix diff --git a/src/lerobot/teleoperators/utils.py b/src/lerobot/teleoperators/utils.py index 5a6d4ecde..f2711e182 100644 --- a/src/lerobot/teleoperators/utils.py +++ b/src/lerobot/teleoperators/utils.py @@ -104,9 +104,9 @@ def make_teleoperator_from_config(config: TeleoperatorConfig) -> "Teleoperator": return RebotArm102Leader(config) 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: try: return cast("Teleoperator", make_device_from_device_class(config)) diff --git a/tests/teleoperators/test_rebot_102_leader.py b/tests/teleoperators/test_rebot_102_leader.py index bea10e131..aaf986a9b 100644 --- a/tests/teleoperators/test_rebot_102_leader.py +++ b/tests/teleoperators/test_rebot_102_leader.py @@ -18,7 +18,7 @@ from unittest.mock import MagicMock, patch 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 ( RebotArm102Leader, RebotArm102LeaderConfig, @@ -91,11 +91,11 @@ def test_send_feedback_not_implemented(leader): def test_bimanual_prefixes_features(): with patch(f"{_MODULE}.require_package", lambda *a, **kw: None): - cfg = BiRebotArm102LeaderConfig( + cfg = BiRebot102LeaderConfig( left_arm_config=RebotArm102LeaderConfig(port="/dev/null0"), 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("right_") for k in teleop.action_features) assert "left_gripper.pos" in teleop.action_features