chore(robots): homogenize bi setups

This commit is contained in:
Steven Palma
2026-06-11 15:19:24 +02:00
parent 41166b39fb
commit 350d01b74d
13 changed files with 113 additions and 76 deletions
@@ -39,15 +39,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 +58,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 +77,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 +97,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]:
@@ -148,21 +147,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,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_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 @check_if_not_connected
def disconnect(self): def disconnect(self):
@@ -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)
@@ -41,6 +41,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 +61,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 +98,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]:
@@ -122,9 +136,11 @@ class BiRebotB601Follower(Robot):
@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
@@ -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)
@@ -39,6 +39,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 +58,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 +89,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]:
@@ -120,15 +131,15 @@ class BiSOFollower(Robot):
@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
@@ -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)
@@ -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
@@ -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"]
@@ -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 ..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(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 +35,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
@@ -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,6 +17,7 @@
import logging import logging
from functools import cached_property from functools import cached_property
from lerobot.types import RobotAction
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected
from ..so_leader import SOLeader, SOLeaderTeleopConfig from ..so_leader import SOLeader, SOLeaderTeleopConfig
@@ -93,7 +94,7 @@ class BiSOLeader(Teleoperator):
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
+2 -2
View File
@@ -104,9 +104,9 @@ def make_teleoperator_from_config(config: TeleoperatorConfig) -> "Teleoperator":
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))
+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