Skip to content

Commit 6007a22

Browse files
Add keyboard teleop device to control the end effector robot (#1289)
1 parent 35e6758 commit 6007a22

File tree

5 files changed

+215
-15
lines changed

5 files changed

+215
-15
lines changed
Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
from .configuration_keyboard import KeyboardTeleopConfig
2-
from .teleop_keyboard import KeyboardTeleop
1+
from .configuration_keyboard import KeyboardEndEffectorTeleopConfig, KeyboardTeleopConfig
2+
from .teleop_keyboard import KeyboardEndEffectorTeleop, KeyboardTeleop
33

4-
__all__ = ["KeyboardTeleopConfig", "KeyboardTeleop"]
4+
__all__ = [
5+
"KeyboardTeleopConfig",
6+
"KeyboardTeleop",
7+
"KeyboardEndEffectorTeleopConfig",
8+
"KeyboardEndEffectorTeleop",
9+
]

lerobot/common/teleoperators/keyboard/configuration_keyboard.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,9 @@
2424
class KeyboardTeleopConfig(TeleoperatorConfig):
2525
# TODO(Steven): Consider setting in here the keys that we want to capture/listen
2626
mock: bool = False
27+
28+
29+
@TeleoperatorConfig.register_subclass("keyboard_ee")
30+
@dataclass
31+
class KeyboardEndEffectorTeleopConfig(KeyboardTeleopConfig):
32+
use_gripper: bool = True

lerobot/common/teleoperators/keyboard/teleop_keyboard.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from lerobot.common.errors import DeviceAlreadyConnectedError, DeviceNotConnectedError
2525

2626
from ..teleoperator import Teleoperator
27-
from .configuration_keyboard import KeyboardTeleopConfig
27+
from .configuration_keyboard import KeyboardEndEffectorTeleopConfig, KeyboardTeleopConfig
2828

2929
PYNPUT_AVAILABLE = True
3030
try:
@@ -145,3 +145,93 @@ def disconnect(self) -> None:
145145
)
146146
if self.listener is not None:
147147
self.listener.stop()
148+
149+
150+
class KeyboardEndEffectorTeleop(KeyboardTeleop):
151+
"""
152+
Teleop class to use keyboard inputs for end effector control.
153+
Designed to be used with the `So100FollowerEndEffector` robot.
154+
"""
155+
156+
config_class = KeyboardEndEffectorTeleopConfig
157+
name = "keyboard_ee"
158+
159+
def __init__(self, config: KeyboardEndEffectorTeleopConfig):
160+
super().__init__(config)
161+
self.config = config
162+
self.misc_keys_queue = Queue()
163+
164+
@property
165+
def action_features(self) -> dict:
166+
if self.config.use_gripper:
167+
return {
168+
"dtype": "float32",
169+
"shape": (4,),
170+
"names": {"delta_x": 0, "delta_y": 1, "delta_z": 2, "gripper": 3},
171+
}
172+
else:
173+
return {
174+
"dtype": "float32",
175+
"shape": (3,),
176+
"names": {"delta_x": 0, "delta_y": 1, "delta_z": 2},
177+
}
178+
179+
def _on_press(self, key):
180+
if hasattr(key, "char"):
181+
key = key.char
182+
self.event_queue.put((key, True))
183+
184+
def _on_release(self, key):
185+
if hasattr(key, "char"):
186+
key = key.char
187+
self.event_queue.put((key, False))
188+
189+
def get_action(self) -> dict[str, Any]:
190+
if not self.is_connected:
191+
raise DeviceNotConnectedError(
192+
"KeyboardTeleop is not connected. You need to run `connect()` before `get_action()`."
193+
)
194+
195+
self._drain_pressed_keys()
196+
delta_x = 0.0
197+
delta_y = 0.0
198+
delta_z = 0.0
199+
200+
# Generate action based on current key states
201+
for key, val in self.current_pressed.items():
202+
if key == keyboard.Key.up:
203+
delta_x = int(val)
204+
elif key == keyboard.Key.down:
205+
delta_x = -int(val)
206+
elif key == keyboard.Key.left:
207+
delta_y = int(val)
208+
elif key == keyboard.Key.right:
209+
delta_y = -int(val)
210+
elif key == keyboard.Key.shift:
211+
delta_z = -int(val)
212+
elif key == keyboard.Key.shift_r:
213+
delta_z = int(val)
214+
elif key == keyboard.Key.ctrl_r:
215+
# Gripper actions are expected to be between 0 (close), 1 (stay), 2 (open)
216+
gripper_action = int(val) + 1
217+
elif key == keyboard.Key.ctrl_l:
218+
gripper_action = int(val) - 1
219+
elif val:
220+
# If the key is pressed, add it to the misc_keys_queue
221+
# this will record key presses that are not part of the delta_x, delta_y, delta_z
222+
# this is useful for retrieving other events like interventions for RL, episode success, etc.
223+
self.misc_keys_queue.put(key)
224+
225+
self.current_pressed.clear()
226+
227+
action_dict = {
228+
"delta_x": delta_x,
229+
"delta_y": delta_y,
230+
"delta_z": delta_z,
231+
}
232+
233+
gripper_action = 1 # default gripper action is to stay
234+
if self.config.use_gripper:
235+
action_dict["gripper"] = gripper_action
236+
237+
return action_dict

lerobot/common/teleoperators/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,9 @@ def make_teleoperator_from_config(config: TeleoperatorConfig) -> Teleoperator:
4949
from .gamepad.teleop_gamepad import GamepadTeleop
5050

5151
return GamepadTeleop(config)
52+
elif config.type == "keyboard_ee":
53+
from .keyboard.teleop_keyboard import KeyboardEndEffectorTeleop
54+
55+
return KeyboardEndEffectorTeleop(config)
5256
else:
5357
raise ValueError(config.type)

lerobot/scripts/rl/gym_manipulator.py

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,12 @@
5858
)
5959
from lerobot.common.teleoperators import (
6060
gamepad, # noqa: F401
61+
keyboard, # noqa: F401
6162
make_teleoperator_from_config,
6263
so101_leader, # noqa: F401
6364
)
6465
from lerobot.common.teleoperators.gamepad.teleop_gamepad import GamepadTeleop
66+
from lerobot.common.teleoperators.keyboard.teleop_keyboard import KeyboardEndEffectorTeleop
6567
from lerobot.common.utils.robot_utils import busy_wait
6668
from lerobot.common.utils.utils import log_say
6769
from lerobot.configs import parser
@@ -1191,7 +1193,7 @@ def _init_keyboard_events(self):
11911193
"rerecord_episode": False,
11921194
}
11931195

1194-
def _handle_key_press(self, key, keyboard):
1196+
def _handle_key_press(self, key, keyboard_device):
11951197
"""
11961198
Handle key press events.
11971199
@@ -1202,10 +1204,10 @@ def _handle_key_press(self, key, keyboard):
12021204
This method should be overridden in subclasses for additional key handling.
12031205
"""
12041206
try:
1205-
if key == keyboard.Key.esc:
1207+
if key == keyboard_device.Key.esc:
12061208
self.keyboard_events["episode_end"] = True
12071209
return
1208-
if key == keyboard.Key.left:
1210+
if key == keyboard_device.Key.left:
12091211
self.keyboard_events["rerecord_episode"] = True
12101212
return
12111213
if hasattr(key, "char") and key.char == "s":
@@ -1221,13 +1223,13 @@ def _init_keyboard_listener(self):
12211223
12221224
This method sets up keyboard event handling if not in headless mode.
12231225
"""
1224-
from pynput import keyboard
1226+
from pynput import keyboard as keyboard_device
12251227

12261228
def on_press(key):
12271229
with self.event_lock:
1228-
self._handle_key_press(key, keyboard)
1230+
self._handle_key_press(key, keyboard_device)
12291231

1230-
self.listener = keyboard.Listener(on_press=on_press)
1232+
self.listener = keyboard_device.Listener(on_press=on_press)
12311233
self.listener.start()
12321234

12331235
def _check_intervention(self):
@@ -1403,7 +1405,7 @@ def _init_keyboard_events(self):
14031405
super()._init_keyboard_events()
14041406
self.keyboard_events["human_intervention_step"] = False
14051407

1406-
def _handle_key_press(self, key, keyboard):
1408+
def _handle_key_press(self, key, keyboard_device):
14071409
"""
14081410
Handle key presses including space for intervention toggle.
14091411
@@ -1413,8 +1415,8 @@ def _handle_key_press(self, key, keyboard):
14131415
14141416
Extends the base handler to respond to space key for toggling intervention.
14151417
"""
1416-
super()._handle_key_press(key, keyboard)
1417-
if key == keyboard.Key.space:
1418+
super()._handle_key_press(key, keyboard_device)
1419+
if key == keyboard_device.Key.space:
14181420
if not self.keyboard_events["human_intervention_step"]:
14191421
logging.info(
14201422
"Space key pressed. Human intervention required.\n"
@@ -1574,7 +1576,7 @@ def __init__(
15741576
print(" Y/Triangle button: End episode (SUCCESS)")
15751577
print(" B/Circle button: Exit program")
15761578

1577-
def get_gamepad_action(
1579+
def get_teleop_commands(
15781580
self,
15791581
) -> tuple[bool, np.ndarray, bool, bool, bool]:
15801582
"""
@@ -1643,7 +1645,7 @@ def step(self, action):
16431645
terminate_episode,
16441646
success,
16451647
rerecord_episode,
1646-
) = self.get_gamepad_action()
1648+
) = self.get_teleop_commands()
16471649

16481650
# Update episode ending state if requested
16491651
if terminate_episode:
@@ -1700,6 +1702,90 @@ def close(self):
17001702
return self.env.close()
17011703

17021704

1705+
class KeyboardControlWrapper(GamepadControlWrapper):
1706+
"""
1707+
Wrapper that allows controlling a gym environment with a keyboard.
1708+
1709+
This wrapper intercepts the step method and allows human input via keyboard
1710+
to override the agent's actions when desired.
1711+
1712+
Inherits from GamepadControlWrapper to avoid code duplication.
1713+
"""
1714+
1715+
def __init__(
1716+
self,
1717+
env,
1718+
teleop_device, # Accepts an instantiated teleoperator
1719+
use_gripper=False, # This should align with teleop_device's config
1720+
auto_reset=False,
1721+
):
1722+
"""
1723+
Initialize the gamepad controller wrapper.
1724+
1725+
Args:
1726+
env: The environment to wrap.
1727+
teleop_device: The instantiated teleoperation device (e.g., GamepadTeleop).
1728+
use_gripper: Whether to include gripper control (should match teleop_device.config.use_gripper).
1729+
auto_reset: Whether to auto reset the environment when episode ends.
1730+
"""
1731+
super().__init__(env, teleop_device, use_gripper, auto_reset)
1732+
1733+
self.is_intervention_active = False
1734+
1735+
logging.info("Keyboard control wrapper initialized with provided teleop_device.")
1736+
print("Keyboard controls:")
1737+
print(" Arrow keys: Move in X-Y plane")
1738+
print(" Shift and Shift_R: Move in Z axis")
1739+
print(" Right Ctrl and Left Ctrl: Open and close gripper")
1740+
print(" f: End episode with FAILURE")
1741+
print(" s: End episode with SUCCESS")
1742+
print(" r: End episode with RERECORD")
1743+
print(" i: Start/Stop Intervention")
1744+
1745+
def get_teleop_commands(
1746+
self,
1747+
) -> tuple[bool, np.ndarray, bool, bool, bool]:
1748+
action_dict = self.teleop_device.get_action()
1749+
episode_end_status = None
1750+
1751+
# Unroll the misc_keys_queue to check for events related to intervention, episode success, etc.
1752+
while not self.teleop_device.misc_keys_queue.empty():
1753+
key = self.teleop_device.misc_keys_queue.get()
1754+
if key == "i":
1755+
self.is_intervention_active = not self.is_intervention_active
1756+
elif key == "f":
1757+
episode_end_status = "failure"
1758+
elif key == "s":
1759+
episode_end_status = "success"
1760+
elif key == "r":
1761+
episode_end_status = "rerecord_episode"
1762+
1763+
terminate_episode = episode_end_status is not None
1764+
success = episode_end_status == "success"
1765+
rerecord_episode = episode_end_status == "rerecord_episode"
1766+
1767+
# Convert action_dict to numpy array based on expected structure
1768+
# Order: delta_x, delta_y, delta_z, gripper (if use_gripper)
1769+
action_list = [action_dict["delta_x"], action_dict["delta_y"], action_dict["delta_z"]]
1770+
if self.use_gripper:
1771+
# GamepadTeleop returns gripper action as 0 (close), 1 (stay), 2 (open)
1772+
# This needs to be consistent with what EEActionWrapper expects if it's used downstream
1773+
# EEActionWrapper for gripper typically expects 0.0 (closed) to 2.0 (open)
1774+
# For now, we pass the direct value from GamepadTeleop, ensure downstream compatibility.
1775+
gripper_val = action_dict.get("gripper", 1.0) # Default to 1.0 (stay) if not present
1776+
action_list.append(float(gripper_val))
1777+
1778+
gamepad_action_np = np.array(action_list, dtype=np.float32)
1779+
1780+
return (
1781+
self.is_intervention_active,
1782+
gamepad_action_np,
1783+
terminate_episode,
1784+
success,
1785+
rerecord_episode,
1786+
)
1787+
1788+
17031789
class GymHilDeviceWrapper(gym.Wrapper):
17041790
def __init__(self, env, device="cpu"):
17051791
super().__init__(env)
@@ -1843,6 +1929,15 @@ def make_robot_env(cfg: EnvConfig) -> gym.Env:
18431929
teleop_device=teleop_device,
18441930
use_gripper=cfg.wrapper.use_gripper,
18451931
)
1932+
elif control_mode == "keyboard_ee":
1933+
assert isinstance(teleop_device, KeyboardEndEffectorTeleop), (
1934+
"teleop_device must be an instance of KeyboardEndEffectorTeleop for keyboard control mode"
1935+
)
1936+
env = KeyboardControlWrapper(
1937+
env=env,
1938+
teleop_device=teleop_device,
1939+
use_gripper=cfg.wrapper.use_gripper,
1940+
)
18461941
elif control_mode == "leader":
18471942
env = GearedLeaderControlWrapper(
18481943
env=env,

0 commit comments

Comments
 (0)