Skip to content

Commit effd2d6

Browse files
committed
WIP on MultiArmFollower and MultiArmLeader
1 parent 2b71789 commit effd2d6

File tree

10 files changed

+551
-1
lines changed

10 files changed

+551
-1
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .config_multi_arm_follower import MultiArmFollowerConfig
2+
from .multi_arm_follower import MultiArmFollower
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/usr/bin/env python
2+
3+
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
from dataclasses import dataclass, field
18+
19+
from lerobot.common.cameras import CameraConfig
20+
21+
from ..config import RobotConfig
22+
23+
24+
@RobotConfig.register_subclass("multi_arm_follower")
25+
@dataclass
26+
class MultiArmFollowerConfig(RobotConfig):
27+
arms: list[RobotConfig]
28+
29+
# cameras
30+
cameras: dict[str, CameraConfig] = field(default_factory=dict)
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
#!/usr/bin/env python
2+
3+
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
import logging
18+
import time
19+
from functools import cached_property
20+
from typing import Any
21+
22+
from lerobot.common.cameras.utils import make_cameras_from_configs
23+
from lerobot.common.robots.utils import make_robot_from_config
24+
25+
from ..robot import Robot
26+
from .config_multi_arm_follower import MultiArmFollowerConfig
27+
28+
logger = logging.getLogger(__name__)
29+
30+
31+
class MultiArmFollower(Robot):
32+
"""
33+
Multiple Arms Follower.
34+
35+
For example, how to run the teleoperate script with multi-arm leader and follower
36+
being composed of two SO101 arms:
37+
```bash
38+
export arm1="{type: so101_follower, port: /dev/ttyACM0}"
39+
export arm2="{type: so101_follower, port: /dev/ttyACM1}"
40+
export teleop1="{type: so101_leader, port: /dev/ttyACM2}"
41+
export teleop2="{type: so101_leader, port: /dev/ttyACM3}"
42+
43+
python -m lerobot.teleoperate \
44+
--robot.type=multi_arm_follower \
45+
--robot.arms=["$arm1","$arm2"] \
46+
--robot.id=two-so101-follower \
47+
--teleop.type=multi_arm_leader \
48+
--teleop.arms=["$teleop1","$teleop2"] \
49+
--teleop.id=two-so101-leader
50+
```
51+
"""
52+
53+
config_class = MultiArmFollowerConfig
54+
name = "multi_arm_follower"
55+
56+
def __init__(self, config: MultiArmFollowerConfig):
57+
super().__init__(config)
58+
self.config = config
59+
60+
self.arms = [make_robot_from_config(arm_config) for arm_config in config.arms]
61+
62+
self.cameras = make_cameras_from_configs(config.cameras)
63+
64+
def _encode_arm_index(self, key: str, index: int) -> str:
65+
return f"arm{index}__{key}"
66+
67+
def _decode_arm_index(self, key: str) -> int:
68+
arm_id, *remaining = key.split("__")
69+
assert arm_id.startswith("arm"), (arm_id, key)
70+
return int(arm_id[len("arm") :]), "__".join(remaining)
71+
72+
@cached_property
73+
def observation_features(self) -> dict[str, type | tuple]:
74+
# Get quickly all observation_features
75+
# assuming minimal latency due the loop
76+
all_observations = [arm.observation_features for arm in self.arms]
77+
# Post-process the results:
78+
all_observations = [
79+
{self._encode_arm_index(key, i): value for key, value in obs.items()}
80+
for i, obs in enumerate(all_observations)
81+
]
82+
return {k: v for obs_ft in all_observations for k, v in obs_ft.items()}
83+
84+
@cached_property
85+
def action_features(self) -> dict[str, type]:
86+
# Get quickly all action_features
87+
# assuming minimal latency due the loop
88+
all_actions = [arm.action_features for arm in self.arms]
89+
# Post-process the results:
90+
all_actions = [
91+
{self._encode_arm_index(key, i): value for key, value in actions.items()}
92+
for i, actions in enumerate(all_actions)
93+
]
94+
return {k: v for actions in all_actions for k, v in actions.items()}
95+
96+
@property
97+
def is_connected(self) -> bool:
98+
all_arms_connected = all(arm.is_connected for arm in self.arms)
99+
return all_arms_connected and all(cam.is_connected for cam in self.cameras.values())
100+
101+
def connect(self, calibrate: bool = True) -> None:
102+
"""
103+
We assume that at connection time, arms are in a rest position,
104+
and torque can be safely disabled to run calibration.
105+
"""
106+
for arm in self.arms:
107+
arm.connect(calibrate=calibrate)
108+
109+
for cam in self.cameras.values():
110+
cam.connect()
111+
112+
logger.info(f"{self} connected.")
113+
114+
@property
115+
def is_calibrated(self) -> bool:
116+
return all(arm.is_calibrated for arm in self.arms)
117+
118+
def calibrate(self) -> None:
119+
logger.info(f"\nRunning calibration of {self}")
120+
for arm in self.arms:
121+
arm.calibrate()
122+
123+
def configure(self) -> None:
124+
for arm in self.arms:
125+
arm.configure()
126+
127+
def setup_motors(self) -> None:
128+
for arm in self.arms:
129+
arm.setup_motors()
130+
131+
def get_observation(self) -> dict[str, Any]:
132+
# Get quickly all observations
133+
# assuming minimal latency due the loop
134+
all_observations = [arm.get_observation() for arm in self.arms]
135+
# Post-process the results:
136+
all_observations = [
137+
{self._encode_arm_index(key, i): value for key, value in obs.items()}
138+
for i, obs in enumerate(all_observations)
139+
]
140+
obs_dict = {k: v for obs in all_observations for k, v in obs.items()}
141+
142+
# Capture images from cameras
143+
for cam_key, cam in self.cameras.items():
144+
start = time.perf_counter()
145+
obs_dict[cam_key] = cam.async_read()
146+
dt_ms = (time.perf_counter() - start) * 1e3
147+
logger.debug(f"{self} read {cam_key}: {dt_ms:.1f}ms")
148+
149+
return obs_dict
150+
151+
def send_action(self, action: dict[str, Any]) -> dict[str, Any]:
152+
"""Command arm to move to a target joint configuration.
153+
154+
The relative action magnitude may be clipped depending on the configuration parameter
155+
`max_relative_target`. In this case, the action sent differs from original action.
156+
Thus, this function always returns the action actually sent.
157+
158+
Raises:
159+
RobotDeviceNotConnectedError: if robot is not connected.
160+
161+
Returns:
162+
the action sent to the motors, potentially clipped.
163+
"""
164+
action_per_arm = [None] * len(self.arms)
165+
for key in action:
166+
index, base_key = self._decode_arm_index(key)
167+
if action_per_arm[index] is None:
168+
action_per_arm[index] = {base_key: action[key]}
169+
else:
170+
action_per_arm[index][base_key] = action[key]
171+
172+
output = [
173+
arm.send_action(action_per_arm)
174+
for arm, action_per_arm in zip(self.arms, action_per_arm, strict=False)
175+
]
176+
output = [
177+
{self._encode_arm_index(key, i): value for key, value in action.items()}
178+
for i, action in enumerate(output)
179+
]
180+
return {k: v for action in output for k, v in action.items()}
181+
182+
def disconnect(self):
183+
for arm in self.arms:
184+
arm.disconnect()
185+
186+
for cam in self.cameras.values():
187+
cam.disconnect()
188+
189+
logger.info(f"{self} disconnected.")

lerobot/common/robots/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ def make_robot_from_config(config: RobotConfig) -> Robot:
5353
from tests.mocks.mock_robot import MockRobot
5454

5555
return MockRobot(config)
56+
elif config.type == "multi_arm_follower":
57+
from .multi_arm_follower import MultiArmFollower
58+
59+
return MultiArmFollower(config)
5660
else:
5761
raise ValueError(config.type)
5862

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .config_multi_arm_leader import MultiArmLeaderConfig
2+
from .multi_arm_leader import MultiArmLeader
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/usr/bin/env python
2+
3+
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
from dataclasses import dataclass
18+
19+
from ..config import TeleoperatorConfig
20+
21+
22+
@TeleoperatorConfig.register_subclass("multi_arm_leader")
23+
@dataclass
24+
class MultiArmLeaderConfig(TeleoperatorConfig):
25+
arms: list[TeleoperatorConfig]
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env python
2+
3+
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
import logging
18+
19+
from lerobot.common.teleoperators.utils import make_teleoperator_from_config
20+
21+
from ..teleoperator import Teleoperator
22+
from .config_multi_arm_leader import MultiArmLeaderConfig
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
class MultiArmLeader(Teleoperator):
28+
"""
29+
Multiple Arms Leader.
30+
31+
For example, how to run the teleoperate script with multi-arm leader and follower
32+
being composed of two SO101 arms:
33+
```bash
34+
export arm1="{type: so101_follower, port: /dev/ttyACM0}"
35+
export arm2="{type: so101_follower, port: /dev/ttyACM1}"
36+
export teleop1="{type: so101_leader, port: /dev/ttyACM2}"
37+
export teleop2="{type: so101_leader, port: /dev/ttyACM3}"
38+
39+
python -m lerobot.teleoperate \
40+
--robot.type=multi_arm_follower \
41+
--robot.arms=["$arm1","$arm2"] \
42+
--robot.id=two-so101-follower \
43+
--teleop.type=multi_arm_leader \
44+
--teleop.arms=["$teleop1","$teleop2"] \
45+
--teleop.id=two-so101-leader
46+
```
47+
48+
"""
49+
50+
config_class = MultiArmLeaderConfig
51+
name = "multi_arm_leader"
52+
53+
def __init__(self, config: MultiArmLeaderConfig):
54+
super().__init__(config)
55+
self.config = config
56+
57+
self.arms = [make_teleoperator_from_config(arm_config) for arm_config in config.arms]
58+
59+
def _encode_arm_index(self, key: str, index: int) -> str:
60+
return f"arm{index}__{key}"
61+
62+
def _decode_arm_index(self, key: str) -> int:
63+
arm_id, *remaining = key.split("__")
64+
assert arm_id.startswith("arm"), (arm_id, key)
65+
return int(arm_id[len("arm") :]), "__".join(remaining)
66+
67+
@property
68+
def action_features(self) -> dict[str, type]:
69+
# Get quickly all action_features
70+
# assuming minimal latency due the loop
71+
all_actions = [arm.action_features for arm in self.arms]
72+
# Post-process the results:
73+
all_actions = [
74+
{self._encode_arm_index(key, i): value for key, value in action.items()}
75+
for i, action in enumerate(all_actions)
76+
]
77+
return {k: v for action_fts in all_actions for k, v in action_fts.items()}
78+
79+
@property
80+
def feedback_features(self) -> dict[str, type]:
81+
# Get quickly all action_features
82+
# assuming minimal latency due the loop
83+
all_feedback_fts = [arm.feedback_features for arm in self.arms]
84+
# Post-process the results:
85+
all_feedback_fts = [
86+
{self._encode_arm_index(key, i): value for key, value in feedback_ft.items()}
87+
for i, feedback_ft in enumerate(all_feedback_fts)
88+
]
89+
return {k: v for feedback_fts in all_feedback_fts for k, v in feedback_fts.items()}
90+
91+
@property
92+
def is_connected(self) -> bool:
93+
return all(arm.is_connected for arm in self.arms)
94+
95+
def connect(self, calibrate: bool = True) -> None:
96+
for arm in self.arms:
97+
arm.connect(calibrate=calibrate)
98+
99+
logger.info(f"{self} connected.")
100+
101+
@property
102+
def is_calibrated(self) -> bool:
103+
return all(arm.is_calibrated for arm in self.arms)
104+
105+
def calibrate(self) -> None:
106+
logger.info(f"\nRunning calibration of {self}")
107+
for arm in self.arms:
108+
arm.calibrate()
109+
110+
def configure(self) -> None:
111+
for arm in self.arms:
112+
arm.configure()
113+
114+
def setup_motors(self) -> None:
115+
for arm in self.arms:
116+
arm.setup_motors()
117+
118+
def get_action(self) -> dict[str, float]:
119+
all_actions = [arm.get_action() for arm in self.arms]
120+
all_actions = [
121+
{self._encode_arm_index(key, i): value for key, value in actions.items()}
122+
for i, actions in enumerate(all_actions)
123+
]
124+
return {k: v for actions in all_actions for k, v in actions.items()}
125+
126+
def send_feedback(self, feedback: dict[str, float]) -> None:
127+
# TODO(rcadene, aliberts): Implement force feedback
128+
raise NotImplementedError
129+
130+
def disconnect(self) -> None:
131+
for arm in self.arms:
132+
arm.disconnect()
133+
logger.info(f"{self} disconnected.")

0 commit comments

Comments
 (0)