forked from tangger/lerobot
Add keyboard teleop device to control the end effector robot (#1289)
This commit is contained in:
@@ -1,4 +1,9 @@
|
|||||||
from .configuration_keyboard import KeyboardTeleopConfig
|
from .configuration_keyboard import KeyboardEndEffectorTeleopConfig, KeyboardTeleopConfig
|
||||||
from .teleop_keyboard import KeyboardTeleop
|
from .teleop_keyboard import KeyboardEndEffectorTeleop, KeyboardTeleop
|
||||||
|
|
||||||
__all__ = ["KeyboardTeleopConfig", "KeyboardTeleop"]
|
__all__ = [
|
||||||
|
"KeyboardTeleopConfig",
|
||||||
|
"KeyboardTeleop",
|
||||||
|
"KeyboardEndEffectorTeleopConfig",
|
||||||
|
"KeyboardEndEffectorTeleop",
|
||||||
|
]
|
||||||
|
|||||||
@@ -24,3 +24,9 @@ from ..config import TeleoperatorConfig
|
|||||||
class KeyboardTeleopConfig(TeleoperatorConfig):
|
class KeyboardTeleopConfig(TeleoperatorConfig):
|
||||||
# TODO(Steven): Consider setting in here the keys that we want to capture/listen
|
# TODO(Steven): Consider setting in here the keys that we want to capture/listen
|
||||||
mock: bool = False
|
mock: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@TeleoperatorConfig.register_subclass("keyboard_ee")
|
||||||
|
@dataclass
|
||||||
|
class KeyboardEndEffectorTeleopConfig(KeyboardTeleopConfig):
|
||||||
|
use_gripper: bool = True
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from typing import Any
|
|||||||
from lerobot.common.errors import DeviceAlreadyConnectedError, DeviceNotConnectedError
|
from lerobot.common.errors import DeviceAlreadyConnectedError, DeviceNotConnectedError
|
||||||
|
|
||||||
from ..teleoperator import Teleoperator
|
from ..teleoperator import Teleoperator
|
||||||
from .configuration_keyboard import KeyboardTeleopConfig
|
from .configuration_keyboard import KeyboardEndEffectorTeleopConfig, KeyboardTeleopConfig
|
||||||
|
|
||||||
PYNPUT_AVAILABLE = True
|
PYNPUT_AVAILABLE = True
|
||||||
try:
|
try:
|
||||||
@@ -145,3 +145,93 @@ class KeyboardTeleop(Teleoperator):
|
|||||||
)
|
)
|
||||||
if self.listener is not None:
|
if self.listener is not None:
|
||||||
self.listener.stop()
|
self.listener.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class KeyboardEndEffectorTeleop(KeyboardTeleop):
|
||||||
|
"""
|
||||||
|
Teleop class to use keyboard inputs for end effector control.
|
||||||
|
Designed to be used with the `So100FollowerEndEffector` robot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
config_class = KeyboardEndEffectorTeleopConfig
|
||||||
|
name = "keyboard_ee"
|
||||||
|
|
||||||
|
def __init__(self, config: KeyboardEndEffectorTeleopConfig):
|
||||||
|
super().__init__(config)
|
||||||
|
self.config = config
|
||||||
|
self.misc_keys_queue = Queue()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_features(self) -> dict:
|
||||||
|
if self.config.use_gripper:
|
||||||
|
return {
|
||||||
|
"dtype": "float32",
|
||||||
|
"shape": (4,),
|
||||||
|
"names": {"delta_x": 0, "delta_y": 1, "delta_z": 2, "gripper": 3},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"dtype": "float32",
|
||||||
|
"shape": (3,),
|
||||||
|
"names": {"delta_x": 0, "delta_y": 1, "delta_z": 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _on_press(self, key):
|
||||||
|
if hasattr(key, "char"):
|
||||||
|
key = key.char
|
||||||
|
self.event_queue.put((key, True))
|
||||||
|
|
||||||
|
def _on_release(self, key):
|
||||||
|
if hasattr(key, "char"):
|
||||||
|
key = key.char
|
||||||
|
self.event_queue.put((key, False))
|
||||||
|
|
||||||
|
def get_action(self) -> dict[str, Any]:
|
||||||
|
if not self.is_connected:
|
||||||
|
raise DeviceNotConnectedError(
|
||||||
|
"KeyboardTeleop is not connected. You need to run `connect()` before `get_action()`."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._drain_pressed_keys()
|
||||||
|
delta_x = 0.0
|
||||||
|
delta_y = 0.0
|
||||||
|
delta_z = 0.0
|
||||||
|
|
||||||
|
# Generate action based on current key states
|
||||||
|
for key, val in self.current_pressed.items():
|
||||||
|
if key == keyboard.Key.up:
|
||||||
|
delta_x = int(val)
|
||||||
|
elif key == keyboard.Key.down:
|
||||||
|
delta_x = -int(val)
|
||||||
|
elif key == keyboard.Key.left:
|
||||||
|
delta_y = int(val)
|
||||||
|
elif key == keyboard.Key.right:
|
||||||
|
delta_y = -int(val)
|
||||||
|
elif key == keyboard.Key.shift:
|
||||||
|
delta_z = -int(val)
|
||||||
|
elif key == keyboard.Key.shift_r:
|
||||||
|
delta_z = int(val)
|
||||||
|
elif key == keyboard.Key.ctrl_r:
|
||||||
|
# Gripper actions are expected to be between 0 (close), 1 (stay), 2 (open)
|
||||||
|
gripper_action = int(val) + 1
|
||||||
|
elif key == keyboard.Key.ctrl_l:
|
||||||
|
gripper_action = int(val) - 1
|
||||||
|
elif val:
|
||||||
|
# If the key is pressed, add it to the misc_keys_queue
|
||||||
|
# this will record key presses that are not part of the delta_x, delta_y, delta_z
|
||||||
|
# this is useful for retrieving other events like interventions for RL, episode success, etc.
|
||||||
|
self.misc_keys_queue.put(key)
|
||||||
|
|
||||||
|
self.current_pressed.clear()
|
||||||
|
|
||||||
|
action_dict = {
|
||||||
|
"delta_x": delta_x,
|
||||||
|
"delta_y": delta_y,
|
||||||
|
"delta_z": delta_z,
|
||||||
|
}
|
||||||
|
|
||||||
|
gripper_action = 1 # default gripper action is to stay
|
||||||
|
if self.config.use_gripper:
|
||||||
|
action_dict["gripper"] = gripper_action
|
||||||
|
|
||||||
|
return action_dict
|
||||||
|
|||||||
@@ -49,5 +49,9 @@ def make_teleoperator_from_config(config: TeleoperatorConfig) -> Teleoperator:
|
|||||||
from .gamepad.teleop_gamepad import GamepadTeleop
|
from .gamepad.teleop_gamepad import GamepadTeleop
|
||||||
|
|
||||||
return GamepadTeleop(config)
|
return GamepadTeleop(config)
|
||||||
|
elif config.type == "keyboard_ee":
|
||||||
|
from .keyboard.teleop_keyboard import KeyboardEndEffectorTeleop
|
||||||
|
|
||||||
|
return KeyboardEndEffectorTeleop(config)
|
||||||
else:
|
else:
|
||||||
raise ValueError(config.type)
|
raise ValueError(config.type)
|
||||||
|
|||||||
@@ -58,10 +58,12 @@ from lerobot.common.robots import ( # noqa: F401
|
|||||||
)
|
)
|
||||||
from lerobot.common.teleoperators import (
|
from lerobot.common.teleoperators import (
|
||||||
gamepad, # noqa: F401
|
gamepad, # noqa: F401
|
||||||
|
keyboard, # noqa: F401
|
||||||
make_teleoperator_from_config,
|
make_teleoperator_from_config,
|
||||||
so101_leader, # noqa: F401
|
so101_leader, # noqa: F401
|
||||||
)
|
)
|
||||||
from lerobot.common.teleoperators.gamepad.teleop_gamepad import GamepadTeleop
|
from lerobot.common.teleoperators.gamepad.teleop_gamepad import GamepadTeleop
|
||||||
|
from lerobot.common.teleoperators.keyboard.teleop_keyboard import KeyboardEndEffectorTeleop
|
||||||
from lerobot.common.utils.robot_utils import busy_wait
|
from lerobot.common.utils.robot_utils import busy_wait
|
||||||
from lerobot.common.utils.utils import log_say
|
from lerobot.common.utils.utils import log_say
|
||||||
from lerobot.configs import parser
|
from lerobot.configs import parser
|
||||||
@@ -1191,7 +1193,7 @@ class BaseLeaderControlWrapper(gym.Wrapper):
|
|||||||
"rerecord_episode": False,
|
"rerecord_episode": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _handle_key_press(self, key, keyboard):
|
def _handle_key_press(self, key, keyboard_device):
|
||||||
"""
|
"""
|
||||||
Handle key press events.
|
Handle key press events.
|
||||||
|
|
||||||
@@ -1202,10 +1204,10 @@ class BaseLeaderControlWrapper(gym.Wrapper):
|
|||||||
This method should be overridden in subclasses for additional key handling.
|
This method should be overridden in subclasses for additional key handling.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if key == keyboard.Key.esc:
|
if key == keyboard_device.Key.esc:
|
||||||
self.keyboard_events["episode_end"] = True
|
self.keyboard_events["episode_end"] = True
|
||||||
return
|
return
|
||||||
if key == keyboard.Key.left:
|
if key == keyboard_device.Key.left:
|
||||||
self.keyboard_events["rerecord_episode"] = True
|
self.keyboard_events["rerecord_episode"] = True
|
||||||
return
|
return
|
||||||
if hasattr(key, "char") and key.char == "s":
|
if hasattr(key, "char") and key.char == "s":
|
||||||
@@ -1221,13 +1223,13 @@ class BaseLeaderControlWrapper(gym.Wrapper):
|
|||||||
|
|
||||||
This method sets up keyboard event handling if not in headless mode.
|
This method sets up keyboard event handling if not in headless mode.
|
||||||
"""
|
"""
|
||||||
from pynput import keyboard
|
from pynput import keyboard as keyboard_device
|
||||||
|
|
||||||
def on_press(key):
|
def on_press(key):
|
||||||
with self.event_lock:
|
with self.event_lock:
|
||||||
self._handle_key_press(key, keyboard)
|
self._handle_key_press(key, keyboard_device)
|
||||||
|
|
||||||
self.listener = keyboard.Listener(on_press=on_press)
|
self.listener = keyboard_device.Listener(on_press=on_press)
|
||||||
self.listener.start()
|
self.listener.start()
|
||||||
|
|
||||||
def _check_intervention(self):
|
def _check_intervention(self):
|
||||||
@@ -1403,7 +1405,7 @@ class GearedLeaderControlWrapper(BaseLeaderControlWrapper):
|
|||||||
super()._init_keyboard_events()
|
super()._init_keyboard_events()
|
||||||
self.keyboard_events["human_intervention_step"] = False
|
self.keyboard_events["human_intervention_step"] = False
|
||||||
|
|
||||||
def _handle_key_press(self, key, keyboard):
|
def _handle_key_press(self, key, keyboard_device):
|
||||||
"""
|
"""
|
||||||
Handle key presses including space for intervention toggle.
|
Handle key presses including space for intervention toggle.
|
||||||
|
|
||||||
@@ -1413,8 +1415,8 @@ class GearedLeaderControlWrapper(BaseLeaderControlWrapper):
|
|||||||
|
|
||||||
Extends the base handler to respond to space key for toggling intervention.
|
Extends the base handler to respond to space key for toggling intervention.
|
||||||
"""
|
"""
|
||||||
super()._handle_key_press(key, keyboard)
|
super()._handle_key_press(key, keyboard_device)
|
||||||
if key == keyboard.Key.space:
|
if key == keyboard_device.Key.space:
|
||||||
if not self.keyboard_events["human_intervention_step"]:
|
if not self.keyboard_events["human_intervention_step"]:
|
||||||
logging.info(
|
logging.info(
|
||||||
"Space key pressed. Human intervention required.\n"
|
"Space key pressed. Human intervention required.\n"
|
||||||
@@ -1574,7 +1576,7 @@ class GamepadControlWrapper(gym.Wrapper):
|
|||||||
print(" Y/Triangle button: End episode (SUCCESS)")
|
print(" Y/Triangle button: End episode (SUCCESS)")
|
||||||
print(" B/Circle button: Exit program")
|
print(" B/Circle button: Exit program")
|
||||||
|
|
||||||
def get_gamepad_action(
|
def get_teleop_commands(
|
||||||
self,
|
self,
|
||||||
) -> tuple[bool, np.ndarray, bool, bool, bool]:
|
) -> tuple[bool, np.ndarray, bool, bool, bool]:
|
||||||
"""
|
"""
|
||||||
@@ -1643,7 +1645,7 @@ class GamepadControlWrapper(gym.Wrapper):
|
|||||||
terminate_episode,
|
terminate_episode,
|
||||||
success,
|
success,
|
||||||
rerecord_episode,
|
rerecord_episode,
|
||||||
) = self.get_gamepad_action()
|
) = self.get_teleop_commands()
|
||||||
|
|
||||||
# Update episode ending state if requested
|
# Update episode ending state if requested
|
||||||
if terminate_episode:
|
if terminate_episode:
|
||||||
@@ -1700,6 +1702,90 @@ class GamepadControlWrapper(gym.Wrapper):
|
|||||||
return self.env.close()
|
return self.env.close()
|
||||||
|
|
||||||
|
|
||||||
|
class KeyboardControlWrapper(GamepadControlWrapper):
|
||||||
|
"""
|
||||||
|
Wrapper that allows controlling a gym environment with a keyboard.
|
||||||
|
|
||||||
|
This wrapper intercepts the step method and allows human input via keyboard
|
||||||
|
to override the agent's actions when desired.
|
||||||
|
|
||||||
|
Inherits from GamepadControlWrapper to avoid code duplication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
env,
|
||||||
|
teleop_device, # Accepts an instantiated teleoperator
|
||||||
|
use_gripper=False, # This should align with teleop_device's config
|
||||||
|
auto_reset=False,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the gamepad controller wrapper.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env: The environment to wrap.
|
||||||
|
teleop_device: The instantiated teleoperation device (e.g., GamepadTeleop).
|
||||||
|
use_gripper: Whether to include gripper control (should match teleop_device.config.use_gripper).
|
||||||
|
auto_reset: Whether to auto reset the environment when episode ends.
|
||||||
|
"""
|
||||||
|
super().__init__(env, teleop_device, use_gripper, auto_reset)
|
||||||
|
|
||||||
|
self.is_intervention_active = False
|
||||||
|
|
||||||
|
logging.info("Keyboard control wrapper initialized with provided teleop_device.")
|
||||||
|
print("Keyboard controls:")
|
||||||
|
print(" Arrow keys: Move in X-Y plane")
|
||||||
|
print(" Shift and Shift_R: Move in Z axis")
|
||||||
|
print(" Right Ctrl and Left Ctrl: Open and close gripper")
|
||||||
|
print(" f: End episode with FAILURE")
|
||||||
|
print(" s: End episode with SUCCESS")
|
||||||
|
print(" r: End episode with RERECORD")
|
||||||
|
print(" i: Start/Stop Intervention")
|
||||||
|
|
||||||
|
def get_teleop_commands(
|
||||||
|
self,
|
||||||
|
) -> tuple[bool, np.ndarray, bool, bool, bool]:
|
||||||
|
action_dict = self.teleop_device.get_action()
|
||||||
|
episode_end_status = None
|
||||||
|
|
||||||
|
# Unroll the misc_keys_queue to check for events related to intervention, episode success, etc.
|
||||||
|
while not self.teleop_device.misc_keys_queue.empty():
|
||||||
|
key = self.teleop_device.misc_keys_queue.get()
|
||||||
|
if key == "i":
|
||||||
|
self.is_intervention_active = not self.is_intervention_active
|
||||||
|
elif key == "f":
|
||||||
|
episode_end_status = "failure"
|
||||||
|
elif key == "s":
|
||||||
|
episode_end_status = "success"
|
||||||
|
elif key == "r":
|
||||||
|
episode_end_status = "rerecord_episode"
|
||||||
|
|
||||||
|
terminate_episode = episode_end_status is not None
|
||||||
|
success = episode_end_status == "success"
|
||||||
|
rerecord_episode = episode_end_status == "rerecord_episode"
|
||||||
|
|
||||||
|
# Convert action_dict to numpy array based on expected structure
|
||||||
|
# Order: delta_x, delta_y, delta_z, gripper (if use_gripper)
|
||||||
|
action_list = [action_dict["delta_x"], action_dict["delta_y"], action_dict["delta_z"]]
|
||||||
|
if self.use_gripper:
|
||||||
|
# GamepadTeleop returns gripper action as 0 (close), 1 (stay), 2 (open)
|
||||||
|
# This needs to be consistent with what EEActionWrapper expects if it's used downstream
|
||||||
|
# EEActionWrapper for gripper typically expects 0.0 (closed) to 2.0 (open)
|
||||||
|
# For now, we pass the direct value from GamepadTeleop, ensure downstream compatibility.
|
||||||
|
gripper_val = action_dict.get("gripper", 1.0) # Default to 1.0 (stay) if not present
|
||||||
|
action_list.append(float(gripper_val))
|
||||||
|
|
||||||
|
gamepad_action_np = np.array(action_list, dtype=np.float32)
|
||||||
|
|
||||||
|
return (
|
||||||
|
self.is_intervention_active,
|
||||||
|
gamepad_action_np,
|
||||||
|
terminate_episode,
|
||||||
|
success,
|
||||||
|
rerecord_episode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GymHilDeviceWrapper(gym.Wrapper):
|
class GymHilDeviceWrapper(gym.Wrapper):
|
||||||
def __init__(self, env, device="cpu"):
|
def __init__(self, env, device="cpu"):
|
||||||
super().__init__(env)
|
super().__init__(env)
|
||||||
@@ -1843,6 +1929,15 @@ def make_robot_env(cfg: EnvConfig) -> gym.Env:
|
|||||||
teleop_device=teleop_device,
|
teleop_device=teleop_device,
|
||||||
use_gripper=cfg.wrapper.use_gripper,
|
use_gripper=cfg.wrapper.use_gripper,
|
||||||
)
|
)
|
||||||
|
elif control_mode == "keyboard_ee":
|
||||||
|
assert isinstance(teleop_device, KeyboardEndEffectorTeleop), (
|
||||||
|
"teleop_device must be an instance of KeyboardEndEffectorTeleop for keyboard control mode"
|
||||||
|
)
|
||||||
|
env = KeyboardControlWrapper(
|
||||||
|
env=env,
|
||||||
|
teleop_device=teleop_device,
|
||||||
|
use_gripper=cfg.wrapper.use_gripper,
|
||||||
|
)
|
||||||
elif control_mode == "leader":
|
elif control_mode == "leader":
|
||||||
env = GearedLeaderControlWrapper(
|
env = GearedLeaderControlWrapper(
|
||||||
env=env,
|
env=env,
|
||||||
|
|||||||
Reference in New Issue
Block a user