Skip to content

Commit 730e968

Browse files
Add leader support plus some fixes. (#40)
Signed-off-by: Franco Cipollone <franco.c@ekumenlabs.com>
1 parent 4e0b7e8 commit 730e968

File tree

14 files changed

+141
-32
lines changed

14 files changed

+141
-32
lines changed

.devcontainer/lekiwi-dev/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ FROM mcr.microsoft.com/devcontainers/base:${BASE_IMAGE}
99
# Configuration
1010
################################################################################
1111

12-
ARG RUST_VERSION=1.86.0
12+
ARG RUST_VERSION=1.88.0
1313

1414
################################################################################
1515
# User 'dev'

.devcontainer/lekiwi-dev/devcontainer.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@
4040
"--network=host",
4141
"--runtime=nvidia", // Use NVIDIA runtime for GPU access
4242
"--gpus=all", // Use all available GPUs
43-
"--name=lekiwi-dora"
43+
"--name=lekiwi-dev"
44+
],
45+
// 9090: This is a web server that serves the frontend for the Rerun application.
46+
// 9876: This is the grpc server to transmit info for the items to be drawn.
47+
"forwardPorts": [
48+
9090,
49+
9876
4450
]
4551
}

README.md

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ Comprehensive monorepo for LeKiwi robot software development, combining simulati
1818
## :package: Project Structure
1919

2020
```
21-
lekiwi-dora/
21+
lekiwi/
2222
├── 📁 packages/ # Python packages
2323
│ ├── 🎮 lekiwi_sim/ # MuJoCo simulation environment
2424
│ ├── 🤖 lekiwi_lerobot/ # LeRobot integration scripts
2525
│ └── 🕹️ lekiwi_teleoperate/ # Teleoperation interface
2626
├── 📁 dora/ # Dora Integration
27-
│ └── 📁 graphs/ # Dora dataflows
27+
│ └── 📁 lekiwi/graphs/ # Dora dataflows
2828
│ └── 📁 node_hub/ # Dora nodes
2929
│ ├── 🔗 dora_lekiwi_client/ # Robot interface node
3030
│ ├── 🧠 dora_run_policy/ # Policy execution node
@@ -166,9 +166,18 @@ Manual control interface using the LeRobot API:
166166
# Start simulation or real robot first
167167
uv run lekiwi_host_sim # For simulation
168168

169-
# Then teleoperate
169+
# Then teleoperate the simulated or real robot.
170170
uv run lekiwi_teleoperate
171171
```
172+
By default it will allow you to teleoperate the Lekiwi completely using the keyboard.
173+
If you have a leader arm you can use it to teleoperate the arm part in the simulation as well:
174+
175+
```bash
176+
uv run lekiwi_teleoperate --leader-arm
177+
```
178+
179+
[lekiwi_sim_leader_LOW.webm](https://github.com/user-attachments/assets/76e565cd-93d2-42ae-976d-3d25091039a4)
180+
172181

173182
[lekiwi_sim_pick_cube.webm](https://github.com/user-attachments/assets/32af6eca-834b-4ba4-8609-33bc428cb75f)
174183

@@ -182,7 +191,7 @@ uv run lekiwi_lerobot_record --repo-id your_username/dataset_name --episodes 50
182191
uv run lekiwi_lerobot_replay --repo-id your_username/dataset_name --episode 0
183192

184193
# Train a policy (see lekiwi_lerobot README for full training options)
185-
python -m lerobot.scripts.train \
194+
uv run lerobot-train \
186195
--dataset.repo_id=your_username/dataset_name \
187196
--policy.type=act \
188197
--output_dir=outputs/my_policy
@@ -202,9 +211,9 @@ See [packages/lekiwi_lerobot/README.md](packages/lekiwi_lerobot/README.md) for d
202211

203212
### Available Dataflows
204213

205-
The repository includes pre-configured dataflow graphs in `dora/lekiwi_sim/graphs/`:
214+
The repository includes pre-configured dataflow graphs in `dora/lekiwi/graphs/`:
206215

207-
**1. Policy Execution Dataflow** (`mujoco_sim.yml`):
216+
**1. Policy Execution Dataflow** (`dataflow.yml`):
208217
- Complete pipeline for running trained policies on simulation
209218
- Connects robot observations → policy inference → robot actions
210219
- Includes camera feeds and state observations
@@ -214,21 +223,25 @@ The repository includes pre-configured dataflow graphs in `dora/lekiwi_sim/graph
214223

215224
**Prerequisites:**
216225
```bash
217-
# Start simulation in separate terminal
226+
227+
# Start simulation (or alternatively the real robot).
218228
uv run lekiwi_host_sim
219229
```
220230

221231
**Run the policy execution dataflow:**
222232
```bash
223233
# Navigate to dataflow directory
224-
cd dora/lekiwi_sim/graphs/
234+
cd dora/lekiwi/graphs/
235+
236+
# Build if not built already
237+
dora build dataflow.yml
225238

226239
# Start the dataflow
227-
dora run mujoco_sim.yml --uv
240+
dora run dataflow.yml --uv
228241

229242
```
230243

231-
**Optional features** (uncomment in `mujoco_sim.yml`):
244+
**Optional features** (uncomment in `dataflow.yml`):
232245
- **Visualization**: Enable `rerun-viz` node for real-time 3D visualization
233246
- **Data Recording**: Enable `dora-record` node to save observations to Parquet files
234247
- **Testing Mode**: Use `dora_lekiwi_action_publisher` instead of policy for hardcoded actions

dora/lekiwi/.gitkeep

Whitespace-only changes.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ nodes:
1010
- observation_state
1111
- image_front
1212
- image_wrist
13+
env:
14+
LEKIWI_IP: 192.168.1.108 # Use the appropriate IP for real robot
1315

1416
######################################################
1517
# Example of a hard-coded action publisher.

packages/lekiwi_lerobot/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Once you have a dataset you can start training a model. For this, we can rely di
7979
Train imitation learning policies using collected data:
8080

8181
```bash
82-
uv run python -m lerobot.scripts.train \
82+
uv run lerobot-train \
8383
--dataset.repo_id=<username/my_dataset> \
8484
--policy.type=act \
8585
--output_dir=outputs/train/username/my_policy \

packages/lekiwi_lerobot/lekiwi_lerobot/record.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from lekiwi_lerobot.utils import record_loop
55
from lekiwi_teleoperate.teleoperate.arm import ArmTeleop
6+
from lerobot.cameras.configs import CameraConfig
67
from lerobot.datasets.lerobot_dataset import LeRobotDataset
78
from lerobot.datasets.utils import hw_to_dataset_features
89
from lerobot.robots.lekiwi.config_lekiwi import LeKiwiClientConfig
@@ -11,6 +12,7 @@
1112
KeyboardTeleop,
1213
KeyboardTeleopConfig,
1314
)
15+
from lerobot.teleoperators.so101_leader import SO101Leader, SO101LeaderConfig
1416
from lerobot.utils.constants import ACTION, OBS_STR
1517
from lerobot.utils.control_utils import (
1618
init_keyboard_listener,
@@ -32,6 +34,13 @@ def main() -> None:
3234
default="INFO",
3335
help="Set the logging level (default: INFO). Case-insensitive.",
3436
)
37+
parser.add_argument(
38+
"-i",
39+
"--ip",
40+
type=str,
41+
default="127.0.0.1",
42+
help="IP address of the robot (default: 127.0.0.1).",
43+
)
3544
parser.add_argument(
3645
"-r",
3746
"--repo-id",
@@ -59,6 +68,18 @@ def main() -> None:
5968
dest="visualize",
6069
help="Disable Rerun visualization during recording.",
6170
)
71+
parser.add_argument(
72+
"-la",
73+
"--leader-arm",
74+
action="store_true",
75+
help="Use the leader arm for teleoperation (default: False).",
76+
)
77+
parser.add_argument(
78+
"--leader-arm-port",
79+
type=str,
80+
default="/dev/ttyACM0",
81+
help="Serial port for the leader arm (default: /dev/ttyACM0).",
82+
)
6283

6384
args = parser.parse_args()
6485
if args.repo_id is None:
@@ -72,13 +93,28 @@ def main() -> None:
7293
level=log_level, format="%(asctime)s | %(levelname)-8s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
7394
)
7495

96+
# Camera config should match the one used in the robot config
97+
# when starting the robot host or simulation.
98+
#
99+
# Based on: --robot.cameras="{ front: {type: opencv, index_or_path: /dev/video0, width: 640, height: 480, fps: 30},
100+
# wrist: {type: opencv, index_or_path: /dev/video2, width: 640, height: 480, fps: 30}}"
101+
camera_config: dict[str, CameraConfig] = {
102+
"front": CameraConfig(width=640, height=480, fps=30),
103+
"wrist": CameraConfig(width=640, height=480, fps=30),
104+
}
105+
75106
# Create the robot and teleoperator configurations
76-
robot_config = LeKiwiClientConfig(remote_ip="127.0.0.1", id="lekiwi")
107+
robot_config = LeKiwiClientConfig(remote_ip=args.ip, id="lekiwi", cameras=camera_config)
77108
keyboard_config = KeyboardTeleopConfig()
109+
if args.leader_arm:
110+
teleop_arm_config = SO101LeaderConfig(port=args.leader_arm_port, id="lekiwi_leader_arm")
78111

79112
robot = LeKiwiClient(robot_config)
80113
keyboard = KeyboardTeleop(keyboard_config)
81-
arm_keyboard_handler = ArmTeleop()
114+
if args.leader_arm:
115+
leader_arm = SO101Leader(teleop_arm_config)
116+
else:
117+
arm_keyboard_handler = ArmTeleop()
82118
# Configure the dataset features
83119
action_features = hw_to_dataset_features(robot.action_features, ACTION)
84120
obs_features = hw_to_dataset_features(robot.observation_features, OBS_STR)
@@ -102,6 +138,8 @@ def main() -> None:
102138
# - Sim robot: this script running on LeKiwi sim: `uv run lekiwi_sim --robot.id=my_awesome_kiwi`
103139
robot.connect()
104140
keyboard.connect()
141+
if args.leader_arm:
142+
leader_arm.connect()
105143

106144
if args.visualize:
107145
logging.info("Initializing Rerun for visualization.")
@@ -113,10 +151,13 @@ def main() -> None:
113151

114152
if not robot.is_connected or not keyboard.is_connected:
115153
raise ValueError("Robot or keyboard is not connected!")
154+
if args.leader_arm and not leader_arm.is_connected:
155+
raise ValueError("Leader arm is not connected!")
116156
logging.info("Robot and keyboard are connected.")
117157
recorded_episodes = 0
118158
while recorded_episodes < args.episodes and not events["stop_recording"]:
119-
arm_keyboard_handler = ArmTeleop()
159+
if not args.leader_arm:
160+
arm_keyboard_handler = ArmTeleop()
120161
logging.info(f"Recording episode {recorded_episodes}")
121162
# Run the record loop
122163
record_loop(
@@ -125,7 +166,7 @@ def main() -> None:
125166
fps=FPS,
126167
dataset=dataset,
127168
keyboard_handler=keyboard,
128-
arm_keyboard_handler=arm_keyboard_handler,
169+
arm_keyboard_handler=leader_arm if args.leader_arm else arm_keyboard_handler,
129170
control_time_s=EPISODE_TIME_SEC,
130171
single_task=args.task,
131172
display_data=args.visualize,
@@ -140,7 +181,7 @@ def main() -> None:
140181
fps=FPS,
141182
dataset=None, # Don't record during reset phase
142183
keyboard_handler=keyboard,
143-
arm_keyboard_handler=arm_keyboard_handler,
184+
arm_keyboard_handler=leader_arm if args.leader_arm else arm_keyboard_handler,
144185
control_time_s=RESET_TIME_SEC,
145186
single_task=args.task,
146187
display_data=args.visualize,
@@ -163,6 +204,8 @@ def main() -> None:
163204

164205
robot.disconnect()
165206
keyboard.disconnect()
207+
if args.leader_arm:
208+
leader_arm.disconnect()
166209
listener.stop()
167210

168211

packages/lekiwi_lerobot/lekiwi_lerobot/replay.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ def main() -> None:
4545
"""Main function to run the LeKiwi replay client."""
4646
parser = argparse.ArgumentParser(description="Run the LeKiwi replay client.")
4747

48+
parser.add_argument(
49+
"-i",
50+
"--ip",
51+
type=str,
52+
default="127.0.0.1",
53+
help="IP address of the robot (default: 127.0.0.1).",
54+
)
4855
parser.add_argument(
4956
"-l",
5057
"--level",
@@ -79,7 +86,7 @@ def main() -> None:
7986
level=log_level, format="%(asctime)s | %(levelname)-8s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
8087
)
8188

82-
robot_config = LeKiwiClientConfig(remote_ip="127.0.0.1", id="lekiwi")
89+
robot_config = LeKiwiClientConfig(remote_ip=args.ip, id="lekiwi")
8390
robot = LeKiwiClient(robot_config)
8491

8592
logging.info(f"Downloading dataset from {args.repo_id} into {args.directory}")

packages/lekiwi_lerobot/lekiwi_lerobot/run_policy.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import time
44

55
from lerobot.datasets.utils import build_dataset_frame, hw_to_dataset_features
6-
from lerobot.policies.act.modeling_act import ACTPolicy
76
from lerobot.policies.factory import make_pre_post_processors
87
from lerobot.robots.lekiwi.config_lekiwi import LeKiwiClientConfig
98
from lerobot.robots.lekiwi.lekiwi_client import LeKiwiClient
@@ -63,7 +62,13 @@ def main() -> None:
6362

6463
logging.info(f"Loading policy from '{args.policy}'")
6564
if args.policy_type == "act":
65+
from lerobot.policies.act.modeling_act import ACTPolicy
66+
6667
policy = ACTPolicy.from_pretrained(args.policy)
68+
elif args.policy_type == "smolvla":
69+
from lerobot.policies.smolvla.modeling_smolvla import SmolVLAPolicy
70+
71+
policy = SmolVLAPolicy.from_pretrained(args.policy)
6772
else:
6873
raise ValueError(f"Policy type '{args.policy_type}' not supported.")
6974
policy.reset()

packages/lekiwi_lerobot/lekiwi_lerobot/utils.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
import time
3-
from typing import Any
3+
from typing import Any, Union
44

55
from lekiwi_teleoperate.teleoperate.arm import ArmTeleop
66
from lerobot.datasets.image_writer import safe_stop_image_writer
@@ -15,6 +15,7 @@
1515
from lerobot.teleoperators.keyboard import (
1616
KeyboardTeleop,
1717
)
18+
from lerobot.teleoperators.so101_leader import SO101Leader
1819
from lerobot.utils.control_utils import (
1920
predict_action,
2021
)
@@ -32,7 +33,7 @@ def record_loop(
3233
fps: int,
3334
dataset: LeRobotDataset | None = None,
3435
keyboard_handler: KeyboardTeleop | None = None,
35-
arm_keyboard_handler: ArmTeleop | None = None,
36+
arm_keyboard_handler: Union[ArmTeleop, SO101Leader, None] = None,
3637
policy: PreTrainedPolicy | None = None,
3738
preprocessor: PolicyProcessorPipeline[dict[str, Any], dict[str, Any]] | None = None,
3839
postprocessor: PolicyProcessorPipeline[PolicyAction, PolicyAction] | None = None,
@@ -101,7 +102,13 @@ def record_loop(
101102
elif policy is None and keyboard_handler is not None and arm_keyboard_handler is not None:
102103
pressed_keys = keyboard_handler.get_action()
103104
base_action = robot._from_keyboard_to_base_action(pressed_keys)
104-
arm_action = arm_keyboard_handler.from_keyboard_to_arm_action(pressed_keys)
105+
106+
# Handle both ArmTeleop (keyboard-based) and SO101Leader (physical arm)
107+
if isinstance(arm_keyboard_handler, SO101Leader):
108+
arm_action = arm_keyboard_handler.get_action()
109+
arm_action = {f"arm_{k}": v for k, v in arm_action.items()}
110+
else:
111+
arm_action = arm_keyboard_handler.from_keyboard_to_arm_action(pressed_keys)
105112

106113
action = {**base_action, **arm_action} # Merge base and arm actions
107114
# TODO(francocipollone): We would probably want to use the teleop_action_processor here.

0 commit comments

Comments
 (0)