feat(devices): add lazy loading for 3rd party robots cameras and teleoperators (#2123)
* feat(devices): add lazy loading for 3rd party robots cameras and teleoperators Co-authored-by: Darko Lukić <lukicdarkoo@gmail.com> * feat(devices): load device class based on assumptions in naming * docs(devices): instructions for using 3rd party devices * docs: address review feedback * chore(docs): add example for 3rd party devices --------- Co-authored-by: Darko Lukić <lukicdarkoo@gmail.com>
This commit is contained in:
@@ -335,6 +335,134 @@ For implementing teleoperation devices, we also provide a [`Teleoperator`](https
|
|||||||
|
|
||||||
The main differences are in the I/O functions: a teleoperator allows you to produce action via `get_action` and can receive feedback actions via `send_feedback`. Feedback could be anything controllable on the teleoperation device that could help the person controlling it understand the consequences of the actions sent. Think motion/force feedback on a leader arm, vibrations on a gamepad controller for example. To implement a teleoperator, you can follow this same tutorial and adapt it for these two methods.
|
The main differences are in the I/O functions: a teleoperator allows you to produce action via `get_action` and can receive feedback actions via `send_feedback`. Feedback could be anything controllable on the teleoperation device that could help the person controlling it understand the consequences of the actions sent. Think motion/force feedback on a leader arm, vibrations on a gamepad controller for example. To implement a teleoperator, you can follow this same tutorial and adapt it for these two methods.
|
||||||
|
|
||||||
|
## Using Your Own `LeRobot` Devices 🔌
|
||||||
|
|
||||||
|
You can easily extend `lerobot` with your own custom hardware—be it a camera, robot, or teleoperation device—by creating a separate, installable Python package. If you follow a few simple conventions, the `lerobot` command-line tools (like `lerobot-teleop` and `lerobot-record`) will **automatically discover and integrate your creations** without requiring any changes to the `lerobot` source code.
|
||||||
|
|
||||||
|
This guide outlines the conventions your plugin must follow.
|
||||||
|
|
||||||
|
### The 4 Core Conventions
|
||||||
|
|
||||||
|
To ensure your custom device is discoverable, you must adhere to the following four rules.
|
||||||
|
|
||||||
|
#### 1\. Create an Installable Package with a Specific Prefix
|
||||||
|
|
||||||
|
Your project must be a standard, installable Python package. Crucially, the name of your package (as defined in `pyproject.toml` or `setup.py`) must begin with one of these prefixes:
|
||||||
|
|
||||||
|
- `lerobot_robot_` for a robot.
|
||||||
|
- `lerobot_camera_` for a camera.
|
||||||
|
- `lerobot_teleoperator_` for a teleoperation device.
|
||||||
|
|
||||||
|
This prefix system is how `lerobot` automatically finds your plugin in the Python environment.
|
||||||
|
|
||||||
|
#### 2\. Follow the `SomethingConfig`/`Something` Naming Pattern
|
||||||
|
|
||||||
|
Your device's implementation class must be named after its configuration class, simply by removing the `Config` suffix.
|
||||||
|
|
||||||
|
- **Config Class:** `MyAwesomeTeleopConfig`
|
||||||
|
- **Device Class:** `MyAwesomeTeleop`
|
||||||
|
|
||||||
|
#### 3\. Place Your Files in a Predictable Structure
|
||||||
|
|
||||||
|
The device class (`MyAwesomeTeleop`) must be located in a predictable module relative to its configuration class (`MyAwesomeTeleopConfig`). `lerobot` will automatically search in these locations:
|
||||||
|
|
||||||
|
- In the **same module** as the config class.
|
||||||
|
- In a **submodule named after the device** (e.g., `my_awesome_teleop.py`).
|
||||||
|
|
||||||
|
The recommended and simplest structure is to place them in separate, clearly named files within the same directory.
|
||||||
|
|
||||||
|
#### 4\. Expose Classes in `__init__.py`
|
||||||
|
|
||||||
|
Your package's `__init__.py` file should import and expose both the configuration and the device classes, making them easily accessible.
|
||||||
|
|
||||||
|
### Putting It All Together: A Complete Example
|
||||||
|
|
||||||
|
Let's create a new teleoperator called `my_awesome_teleop`.
|
||||||
|
|
||||||
|
#### Directory Structure
|
||||||
|
|
||||||
|
Here is what the project folder should look like. The package name, `lerobot_teleoperator_my_awesome_teleop`, follows **Convention \#1**.
|
||||||
|
|
||||||
|
```
|
||||||
|
lerobot_teleoperator_my_awesome_teleop/
|
||||||
|
├── pyproject.toml # (or setup.py) lists lerobot as a dependency
|
||||||
|
└── lerobot_teleoperator_my_awesome_teleop/
|
||||||
|
├── __init__.py
|
||||||
|
├── config_my_awesome_teleop.py
|
||||||
|
└── my_awesome_teleop.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### File Contents
|
||||||
|
|
||||||
|
- **`config_my_awesome_teleop.py`**: Defines the configuration class. Note the `Config` suffix (**Convention \#2**).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from lerobot.teleoperators.config import TeleoperatorConfig
|
||||||
|
|
||||||
|
@TeleoperatorConfig.register_subclass("my_awesome_teleop")
|
||||||
|
@dataclass
|
||||||
|
class MyAwesomeTeleopConfig(TeleoperatorConfig):
|
||||||
|
# Your configuration fields go here
|
||||||
|
port: str = "192.168.1.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`my_awesome_teleop.py`**: Implements the device. The class name `MyAwesomeTeleop` matches its config class name (**Convention \#2**). This file structure adheres to **Convention \#3**.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from lerobot.teleoperators.teleoperator import Teleoperator
|
||||||
|
|
||||||
|
from .config_my_awesome_teleop import MyAwesomeTeleopConfig
|
||||||
|
|
||||||
|
class MyAwesomeTeleop(Teleoperator):
|
||||||
|
config_class = MyAwesomeTeleopConfig
|
||||||
|
name = "my_awesome_teleop"
|
||||||
|
|
||||||
|
def __init__(self, config: MyAwesomeTeleopConfig):
|
||||||
|
super().__init__(config)
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
# Your device logic (e.g., connect) goes here
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`__init__.py`**: Exposes the key classes (**Convention \#4**).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .config_my_awesome_teleop import MyAwesomeTeleopConfig
|
||||||
|
from .my_awesome_teleop import MyAwesomeTeleop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installation and Usage
|
||||||
|
|
||||||
|
1. **Install your new plugin in your Python environment.** You can install your local plugin package using `pip`'s editable mode or from PyPi.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Locally
|
||||||
|
# Navigate to your plugin's root directory and install it
|
||||||
|
cd lerobot_teleoperator_my_awesome_teleop
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# From PyPi
|
||||||
|
pip install lerobot_teleoperator_my_awesome_teleop
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use it directly from the command line.** Now, you can use your custom device by referencing its type.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lerobot-teleoperate --teleop.type=my_awesome_teleop \
|
||||||
|
# other arguments
|
||||||
|
```
|
||||||
|
|
||||||
|
And that's it\! Your custom device is now fully integrated.
|
||||||
|
|
||||||
|
### Looking for an example ?
|
||||||
|
|
||||||
|
Check out these two packages from the community:
|
||||||
|
|
||||||
|
- https://github.com/SpesRobotics/lerobot-robot-xarm
|
||||||
|
- https://github.com/SpesRobotics/lerobot-teleoperator-teleop
|
||||||
|
|
||||||
## Wrapping Up
|
## Wrapping Up
|
||||||
|
|
||||||
Once your robot class is complete, you can leverage the LeRobot ecosystem:
|
Once your robot class is complete, you can leverage the LeRobot ecosystem:
|
||||||
|
|||||||
@@ -15,15 +15,19 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import platform
|
import platform
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from lerobot.utils.import_utils import make_device_from_device_class
|
||||||
|
|
||||||
from .camera import Camera
|
from .camera import Camera
|
||||||
from .configs import CameraConfig, Cv2Rotation
|
from .configs import CameraConfig, Cv2Rotation
|
||||||
|
|
||||||
|
|
||||||
def make_cameras_from_configs(camera_configs: dict[str, CameraConfig]) -> dict[str, Camera]:
|
def make_cameras_from_configs(camera_configs: dict[str, CameraConfig]) -> dict[str, Camera]:
|
||||||
cameras = {}
|
cameras: dict[str, Camera] = {}
|
||||||
|
|
||||||
for key, cfg in camera_configs.items():
|
for key, cfg in camera_configs.items():
|
||||||
|
# TODO(Steven): Consider just using the make_device_from_device_class for all types
|
||||||
if cfg.type == "opencv":
|
if cfg.type == "opencv":
|
||||||
from .opencv import OpenCVCamera
|
from .opencv import OpenCVCamera
|
||||||
|
|
||||||
@@ -40,7 +44,10 @@ def make_cameras_from_configs(camera_configs: dict[str, CameraConfig]) -> dict[s
|
|||||||
cameras[key] = Reachy2Camera(cfg)
|
cameras[key] = Reachy2Camera(cfg)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"The camera type '{cfg.type}' is not valid.")
|
try:
|
||||||
|
cameras[key] = cast(Camera, make_device_from_device_class(cfg))
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Error creating camera {key} with config {cfg}: {e}") from e
|
||||||
|
|
||||||
return cameras
|
return cameras
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,16 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from lerobot.robots import RobotConfig
|
from lerobot.utils.import_utils import make_device_from_device_class
|
||||||
|
|
||||||
|
from .config import RobotConfig
|
||||||
from .robot import Robot
|
from .robot import Robot
|
||||||
|
|
||||||
|
|
||||||
def make_robot_from_config(config: RobotConfig) -> Robot:
|
def make_robot_from_config(config: RobotConfig) -> Robot:
|
||||||
|
# TODO(Steven): Consider just using the make_device_from_device_class for all types
|
||||||
if config.type == "koch_follower":
|
if config.type == "koch_follower":
|
||||||
from .koch_follower import KochFollower
|
from .koch_follower import KochFollower
|
||||||
|
|
||||||
@@ -66,7 +69,10 @@ def make_robot_from_config(config: RobotConfig) -> Robot:
|
|||||||
|
|
||||||
return MockRobot(config)
|
return MockRobot(config)
|
||||||
else:
|
else:
|
||||||
raise ValueError(config.type)
|
try:
|
||||||
|
return cast(Robot, make_device_from_device_class(config))
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Error creating robot with config {config}: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
# TODO(pepijn): Move to pipeline step to make sure we don't have to do this in the robot code and send action to robot is clean for use in dataset
|
# TODO(pepijn): Move to pipeline step to make sure we don't have to do this in the robot code and send action to robot is clean for use in dataset
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from lerobot.teleoperators import ( # noqa: F401
|
|||||||
so100_leader,
|
so100_leader,
|
||||||
so101_leader,
|
so101_leader,
|
||||||
)
|
)
|
||||||
|
from lerobot.utils.import_utils import register_third_party_devices
|
||||||
from lerobot.utils.utils import init_logging
|
from lerobot.utils.utils import init_logging
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ def calibrate(cfg: CalibrateConfig):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
register_third_party_devices()
|
||||||
calibrate()
|
calibrate()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ from lerobot.utils.control_utils import (
|
|||||||
sanity_check_dataset_name,
|
sanity_check_dataset_name,
|
||||||
sanity_check_dataset_robot_compatibility,
|
sanity_check_dataset_robot_compatibility,
|
||||||
)
|
)
|
||||||
|
from lerobot.utils.import_utils import register_third_party_devices
|
||||||
from lerobot.utils.robot_utils import busy_wait
|
from lerobot.utils.robot_utils import busy_wait
|
||||||
from lerobot.utils.utils import (
|
from lerobot.utils.utils import (
|
||||||
get_safe_torch_device,
|
get_safe_torch_device,
|
||||||
@@ -513,6 +514,7 @@ def record(cfg: RecordConfig) -> LeRobotDataset:
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
register_third_party_devices()
|
||||||
record()
|
record()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ from lerobot.robots import ( # noqa: F401
|
|||||||
so101_follower,
|
so101_follower,
|
||||||
)
|
)
|
||||||
from lerobot.utils.constants import ACTION
|
from lerobot.utils.constants import ACTION
|
||||||
|
from lerobot.utils.import_utils import register_third_party_devices
|
||||||
from lerobot.utils.robot_utils import busy_wait
|
from lerobot.utils.robot_utils import busy_wait
|
||||||
from lerobot.utils.utils import (
|
from lerobot.utils.utils import (
|
||||||
init_logging,
|
init_logging,
|
||||||
@@ -126,6 +127,7 @@ def replay(cfg: ReplayConfig):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
register_third_party_devices()
|
||||||
replay()
|
replay()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ from lerobot.teleoperators import ( # noqa: F401
|
|||||||
so100_leader,
|
so100_leader,
|
||||||
so101_leader,
|
so101_leader,
|
||||||
)
|
)
|
||||||
|
from lerobot.utils.import_utils import register_third_party_devices
|
||||||
from lerobot.utils.robot_utils import busy_wait
|
from lerobot.utils.robot_utils import busy_wait
|
||||||
from lerobot.utils.utils import init_logging, move_cursor_up
|
from lerobot.utils.utils import init_logging, move_cursor_up
|
||||||
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data
|
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data
|
||||||
@@ -215,6 +216,7 @@ def teleoperate(cfg: TeleoperateConfig):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
register_third_party_devices()
|
||||||
teleoperate()
|
teleoperate()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from lerobot.utils.import_utils import make_device_from_device_class
|
||||||
|
|
||||||
from .config import TeleoperatorConfig
|
from .config import TeleoperatorConfig
|
||||||
from .teleoperator import Teleoperator
|
from .teleoperator import Teleoperator
|
||||||
@@ -29,6 +32,7 @@ class TeleopEvents(Enum):
|
|||||||
|
|
||||||
|
|
||||||
def make_teleoperator_from_config(config: TeleoperatorConfig) -> Teleoperator:
|
def make_teleoperator_from_config(config: TeleoperatorConfig) -> Teleoperator:
|
||||||
|
# TODO(Steven): Consider just using the make_device_from_device_class for all types
|
||||||
if config.type == "keyboard":
|
if config.type == "keyboard":
|
||||||
from .keyboard import KeyboardTeleop
|
from .keyboard import KeyboardTeleop
|
||||||
|
|
||||||
@@ -82,4 +86,7 @@ def make_teleoperator_from_config(config: TeleoperatorConfig) -> Teleoperator:
|
|||||||
|
|
||||||
return Reachy2Teleoperator(config)
|
return Reachy2Teleoperator(config)
|
||||||
else:
|
else:
|
||||||
raise ValueError(config.type)
|
try:
|
||||||
|
return cast(Teleoperator, make_device_from_device_class(config))
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Error creating robot with config {config}: {e}") from e
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
|
import pkgutil
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from draccus.choice_types import ChoiceRegistry
|
||||||
|
|
||||||
|
|
||||||
def is_package_available(pkg_name: str, return_version: bool = False) -> tuple[bool, str] | bool:
|
def is_package_available(pkg_name: str, return_version: bool = False) -> tuple[bool, str] | bool:
|
||||||
@@ -58,3 +62,93 @@ def is_package_available(pkg_name: str, return_version: bool = False) -> tuple[b
|
|||||||
|
|
||||||
|
|
||||||
_transformers_available = is_package_available("transformers")
|
_transformers_available = is_package_available("transformers")
|
||||||
|
|
||||||
|
|
||||||
|
def make_device_from_device_class(config: ChoiceRegistry) -> Any:
|
||||||
|
"""
|
||||||
|
Dynamically instantiates an object from its `ChoiceRegistry` configuration.
|
||||||
|
|
||||||
|
This factory uses the module path and class name from the `config` object's
|
||||||
|
type to locate and instantiate the corresponding device class (not the config).
|
||||||
|
It derives the device class name by removing a trailing 'Config' from the config
|
||||||
|
class name and tries a few candidate modules where the device implementation is
|
||||||
|
commonly located.
|
||||||
|
"""
|
||||||
|
if not isinstance(config, ChoiceRegistry):
|
||||||
|
raise ValueError(f"Config should be an instance of `ChoiceRegistry`, got {type(config)}")
|
||||||
|
|
||||||
|
config_cls = config.__class__
|
||||||
|
module_path = config_cls.__module__ # typical: lerobot_teleop_mydevice.config_mydevice
|
||||||
|
config_name = config_cls.__name__ # typical: MyDeviceConfig
|
||||||
|
|
||||||
|
# Derive device class name (strip "Config")
|
||||||
|
if not config_name.endswith("Config"):
|
||||||
|
raise ValueError(f"Config class name '{config_name}' does not end with 'Config'")
|
||||||
|
|
||||||
|
device_class_name = config_name[:-6] # typical: MyDeviceConfig -> MyDevice
|
||||||
|
|
||||||
|
# Build candidate modules to search for the device class
|
||||||
|
parts = module_path.split(".")
|
||||||
|
parent_module = ".".join(parts[:-1]) if len(parts) > 1 else module_path
|
||||||
|
candidates = [
|
||||||
|
parent_module, # typical: lerobot_teleop_mydevice
|
||||||
|
parent_module + "." + device_class_name.lower(), # typical: lerobot_teleop_mydevice.mydevice
|
||||||
|
]
|
||||||
|
|
||||||
|
# handle modules named like "config_xxx" -> try replacing that piece with "xxx"
|
||||||
|
last = parts[-1] if parts else ""
|
||||||
|
if last.startswith("config_"):
|
||||||
|
candidates.append(".".join(parts[:-1] + [last.replace("config_", "")]))
|
||||||
|
|
||||||
|
# de-duplicate while preserving order
|
||||||
|
seen: set[str] = set()
|
||||||
|
candidates = [c for c in candidates if not (c in seen or seen.add(c))]
|
||||||
|
|
||||||
|
tried: list[str] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
tried.append(candidate)
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(candidate)
|
||||||
|
except ImportError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if hasattr(module, device_class_name):
|
||||||
|
cls = getattr(module, device_class_name)
|
||||||
|
if callable(cls):
|
||||||
|
try:
|
||||||
|
return cls(config)
|
||||||
|
except TypeError as e:
|
||||||
|
raise TypeError(
|
||||||
|
f"Failed to instantiate '{device_class_name}' from module '{candidate}': {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
raise ImportError(
|
||||||
|
f"Could not locate device class '{device_class_name}' for config '{config_name}'. "
|
||||||
|
f"Tried modules: {tried}. Ensure your device class name is the config class name without "
|
||||||
|
f"'Config' and that it's importable from one of those modules."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_third_party_devices() -> None:
|
||||||
|
"""
|
||||||
|
Discover and import third-party lerobot_* plugins so they can register themselves.
|
||||||
|
|
||||||
|
Scans top-level modules on sys.path for packages starting with
|
||||||
|
'lerobot_robot_', 'lerobot_camera_' or 'lerobot_teleoperator_' and imports them.
|
||||||
|
"""
|
||||||
|
prefixes = ("lerobot_robot_", "lerobot_camera_", "lerobot_teleoperator_")
|
||||||
|
imported: list[str] = []
|
||||||
|
failed: list[str] = []
|
||||||
|
|
||||||
|
for module_info in pkgutil.iter_modules():
|
||||||
|
name = module_info.name
|
||||||
|
if name.startswith(prefixes):
|
||||||
|
try:
|
||||||
|
importlib.import_module(name)
|
||||||
|
imported.append(name)
|
||||||
|
logging.info("Imported third-party plugin: %s", name)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Could not import third-party plugin: %s", name)
|
||||||
|
failed.append(name)
|
||||||
|
|
||||||
|
logging.debug("Third-party plugin import summary: imported=%s failed=%s", imported, failed)
|
||||||
|
|||||||
Reference in New Issue
Block a user