この記事は、ABEJAアドベントカレンダー2025の9日目の記事です。 こんにちは。 最近、(私の周囲だけかもしれませんが)フィジカルAIという単語を耳にするようになりました。 そんな中で2か月ほど前にLeRobotの更新で自作ロボットをLeRobotのワークフローで学習可能なフレームワークが登場したことを知りました。 余談ですが、この間SO-101を動かしていたらディスプレイを強打して大変だったので、反省も含めノットフィジカルな実装をしていきます。 詳細は公式のドキュメントを参考にしてください。 というわけでプラグイン拡張がなんとなく分かったところで実装を見て理解を深めましょう! 今回はロボットがなくても動作するように下記を作っていきます。 上記は平面移動のロボットをキーボードで操作するテレオペレーションのイメージ実装です。 基本的には公式のドキュメントをベースに実装していきます。 今回実装するソースコードはこちらに配置しているので、適宜参考にしてください。 これからの説明では宗教上の理由でcondaではなくuvを使用しています。 実装前にTeleoperator/Robotのクラス実装について簡単に見ておきましょう。 Teleoperator/Robotのクラスで実装する関数/プロパティは下記です。 皆様の興味があるのは多分実装だと思うので、パッケージ配置を先に行いましょう。 要点は下記です。 その他、下記にも注意する必要がありますが、上記配置と今回の実装手順に従えばあまり気にする必要はありません。 それではRobotの実装を行っていきましょう。 まずはRobotのConfigクラスを作成しましょう。 Configクラスではコマンドライン使用時のロボットのtypeとパラメータを定義します。 次にRobotクラスを作成しましょう。 RobotクラスはLeRobotのRobotクラスを継承して実装します。 それぞれの関数を少し見ていきましょう。 こちらでは初期化を行います。 こちらではロボットの観測情報の型を定義します。 こちらではロボットのアクションの型を定義します。 次に接続処理を行いましょう。 今回のRobotクラスではロボット等への接続を行いません。 下記は公式のSO-101の接続処理です。
こちらでは、モーターの接続とキャリブレーション、カメラの接続、設定を行っています。 次にキャリブレーション処理と設定処理を行いましょう。 今回のRobotクラスでは特にキャリブレーションや設定を行いません。 下記は公式のSO-101の設定処理です。
こちらでは、モーターのトルクを無効化した上で、モーターのPID制御を行っています。 次にアクションの送信を行いましょう。 今回のRobotクラスでは実際にロボットにアクションを送信するわけではありませんが、
Teleoperatorから受信したアクションによってX軸方向とY軸方向に移動したこととしましょう。 実際の動作時には、受信したアクションによってロボットを動かす処理をここに記述します。 また、こちらの関数ではアクションを返却しますが、データセット作成時には返却した値を使用するのではなく引数の方の値を使用するようです。 次に観測情報の取得を行いましょう。 今回のRobotクラスではTeleoperatorからのアクションによって移動したことにするため、こちらの関数では位置情報を返却します。 ここまででRobotクラスの実装は終了です。 次にTeleoperatorクラスの実装を行っていきましょう。 基本はRobotクラスの実装と同じです。
こちらとこちらのソースコードを適宜参考にしてください。 まずは先ほどと同様にConfigクラスを作成します。 基本的にRobotと同じなので少し変更してみましょう。 次にTeleoperatorクラスを作成しましょう。 こちらも基本はRobotと同じなので少し変更してみましょう。 次に接続処理を行いましょう。 今回はpygameを使用してキーボードの入力を受信するため、 今回もキャリブレーション、設定を行いません。 次にアクションの取得を行いましょう。 アクションの取得はキーボードの入力を受信するための処理を行っています。 こちらでは大まかに下記処理を行っています。 プラグイン拡張ではこちらの関数で返却したキー状態をRobotクラスの こちらでは画面にキー状態を表示するための処理を行っています。 次にフィードバックの送信を行いましょう。 フィードバックの送信はサポートしていないため実装しません。 ここまででTeleoperatorクラスの実装は終了です。 では実際に動作確認を行ってみましょう。 既に ここまでで動作確認の準備ができたのでテレオペレーションを行いましょう。 ※ 最初の起動には少し時間がかかるのでお待ちください。 少し待つと下記のようなウインドウを表示します。 ウインドウをクリックしてからキーボードの矢印キーを押下しましょう。 ここまでで動作確認は終了です。 今回は簡単な実装でしたが、プラグイン拡張を使うことでLeRobotのコマンドに自分のソースコードを組み込むことができました。 また、ここまで読まれた方はプラグイン拡張のTeleoperator/Robotの実装方法が完全に理解していただいたかと思います。 ABEJAは、テクノロジーの社会実装に取り組んでいます。 技術はもちろん、技術をどのようにして社会やビジネスに組み込んでいくかを考えるのが好きな方は、下記採用ページからエントリーください! (新卒の方やインターンシップのエントリーもお待ちしております!)
careers.abejainc.com 特に下記ポジションの募集を強化しています!ぜひ御覧ください!
はじめに

株式会社ABEJAのシステム開発部でエンジニアをしている中島です。
本記事を執筆中にも隣を見れば、
国際ロボット展なるもので人型のロボットが踊ったり、大型のロボットが車を軽々振り回しているようで個人的にはワクワクしながら拝見させていただいております。
私もSO-101等を組み立てたりしたことはあるのですが、まだまだ浅学でロボット周りはキャッチアップをしなくてはと思っております。
キャッチアップも含め、本記事ではこのフレームワークを使用してノットフィジカルなAIを実装していきます。先にまとめ

プラグイン拡張とは
実装について
今回の記事では行いませんが、Robotに実機と接続して操作する実装を加えれば、LeRobotのワークフローで平面移動ロボットの学習ができるようになる想定です。
公式のままだとSO-101用の実装なので適宜修正しながら進めていきます。
下記の環境で動作確認していますが、おそらく他の環境でも動作すると思います。
事前準備
特別なこだわりがなければuvをインストールしましょう。
こちらの手順に従ってインストールしてください。全体
クラス
関数 / プロパティ
説明
共通
__init__ 初期化
共通
connect ロボットとの接続を行います。接続時にキャリブレーションを行うかどうかも指定可能です。例として、操作するロボットとUSBの接続処理が必要な場合こちらに記述します。
共通
is_connected ロボットが接続しているかどうかを取得するためのプロパティです。
共通
disconnect 接続しているロボットとの接続を切断します。
共通
calibrate ロボットのキャリブレーションを行います。
共通
is_calibrated ロボットのキャリブレーションが行われているかどうかを取得するためのプロパティです。
共通
configure ロボットの初期設定を行います。
Robot
observation_features ロボットの観測情報の型を定義するためのプロパティです。
Robot
action_features ロボットのアクションの型を定義するためのプロパティです。
Robot
get_observation ロボットの観測情報を取得します。例として、ロボットのセンサー情報を取得します。
Robot
send_action ロボットにアクションを送信します。例として、ロボットのモーターを回転させるアクションを行います。
Teleoperator
action_features テレオペレーターのアクションの型を定義するためのプロパティです。
Teleoperator
feedback_features テレオペレーターのフィードバックの型を定義するためのプロパティです。
Teleoperator
get_action テレオペレーターのアクションを取得します。例として、キーボードの入力を受信します。
Teleoperator
send_feedback テレオペレーターにフィードバックを送信します。現在は使用されていないようです。
パッケージ配置
開発を行いながら動作確認を行ったりするため、Robot/Teleoperatorパッケージを配下のフォルダに作成します。
こちらのリポジトリを参考に、下記のようなフォルダ構成になるようファイルを作成します。├── lerobot_robots: robotパッケージを作成するフォルダ
│ └── test
│ ├── lerobot_robot_test
│ │ ├── __init__.py
│ │ ├── config_test.py
│ │ └── test.py
│ └── pyproject.toml
├── lerobot_teleoperators: Teleoperatorパッケージを作成するフォルダ
│ └── test
│ ├── lerobot_teleoperator_test
│ │ ├── __init__.py
│ │ ├── config_test.py
│ │ └── test.py
│ └── pyproject.toml
└── pyproject.toml
__init__.pyでクラスを公開すること
次回以降の参考としていただけると幸いです。
ロボットの実装
Configクラスの作成
lerobot_robots/test/lerobot_robot_test/config_test.pyを記述します。
こちらのソースコードを参考にしてください。from dataclasses import dataclass
from lerobot.robots import RobotConfig
@RobotConfig.register_subclass("test") # コマンドのrobot.typeで指定する値
@dataclass
class TestRobotConfig(RobotConfig):
robot_id: str = "test_robot_1" # コマンドラインで指定可能な値。デフォルトを指定しているため、何も指定しないと"test_robot_1"を使用します。
例として、上記の定義をした場合にはテレオペレーションを行う際に下記のような指定が可能です。
この指定により、TestRobotConfigのロボットを参照してrobot_idにtest_robot_1_1を使用することができます。uv run lerobot-teleoperate
--robot.type test
--robot.robot_id test_robot_1_1
... # 他のパラメータも指定可能
Robotクラスの初期化 & 型定義
lerobot_robots/test/lerobot_robot_test/test.pyを記述します。
こちらのソースコードを参考にしてください。from typing import Any
from lerobot.robots import Robot
from .config_test import TestRobotConfig
class TestRobot(Robot):
config_class = TestRobotConfig # Configクラスを指定します。
name = "test_robot" # ロボットの名前を指定します。
# 初期化を行います。
def __init__(self, config: TestRobotConfig):
super().__init__(config)
self.id = config.robot_id
self.x = 0
self.y = 0
# 観測情報の型を定義します。
@property
def observation_features(self) -> dict:
return {
"sensor1.pos": float,
"sensor2.pos": float,
}
# アクションの型を定義します。
@property
def action_features(self) -> dict:
return {
"key.left": bool,
"key.right": bool,
"key.up": bool,
"key.down": bool,
}
また、config_classを指定してConfigクラスを指定します。
nameはロボットの名前を指定するのですが、自分が見た感じキャリブレーションファイルの保存先のパス名等に使用しているようです。__init__
初期化時にConfigクラスを受け取るので、
ロボットに必要な情報があればConfigクラスのパラメータで設定できるようにしましょう。
今回のRobotクラスでは位置情報を保持するため、Configクラスのパラメータ以外にxとyを初期化時に設定しています。
Robotクラスでは触れませんが、
super().__init__でキャリブレーションのロードも行っているので、キャリブレーションした結果は自然と設定されています。observation_features
実際の動作時には、ロボットのセンサーで受信した値等をここで定義した型に合わせて返却します。action_features
実際の動作時には、Teleoperatorはここで定義した型に合わせてロボットのアクションを送信します。接続処理
# ロボットの接続処理を行います。
def connect(self, calibrate: bool = True) -> None:
pass
# ロボットの切断処理を行います。
def disconnect(self) -> None:
pass
# ロボットの接続状態を取得してboolを返却します。
@property
def is_connected(self) -> bool:
return True
そのため、こちらの関数では特に何も行いません。
実際の動作時には、ロボットとの接続を行うための処理をここに記述します。 def connect(self, calibrate: bool = True) -> None:
"""
We assume that at connection time, arm is in a rest position,
and torque can be safely disabled to run calibration.
"""
if self.is_connected:
raise DeviceAlreadyConnectedError(f"{self} already connected")
self.bus.connect()
if not self.is_calibrated and calibrate:
logger.info(
"Mismatch between calibration values in the motor and the calibration file or no calibration file found"
)
self.calibrate()
for cam in self.cameras.values():
cam.connect()
self.configure()
logger.info(f"{self} connected.")
キャリブレーション & 設定
# ロボットのキャリブレーションを行います。
def calibrate(self) -> None:
pass
# ロボットのキャリブレーション状態を取得してboolを返却します。
@property
def is_calibrated(self) -> bool:
return True
# ロボットの設定を行います。
def configure(self) -> None:
pass
そのため、こちらの関数では特に何も行いません。
※ 接続のたびに書き込みが発生することについてはこちらを参照してください。 def configure(self) -> None:
with self.bus.torque_disabled():
self.bus.configure_motors()
for motor in self.bus.motors:
self.bus.write("Operating_Mode", motor, OperatingMode.POSITION.value)
# Set P_Coefficient to lower value to avoid shakiness (Default is 32)
self.bus.write("P_Coefficient", motor, 16)
# Set I_Coefficient and D_Coefficient to default value 0 and 32
self.bus.write("I_Coefficient", motor, 0)
self.bus.write("D_Coefficient", motor, 32)
アクションの送信
# アクションを送信します。
def send_action(self, action: dict[str, Any]) -> dict[str, Any]:
left = action.get("key.left", False)
right = action.get("key.right", False)
up = action.get("key.up", False)
down = action.get("key.down", False)
# ロボットの位置を更新
if right:
self.x += 1
if left:
self.x -= 1
if up:
self.y += 1
if down:
self.y -= 1
# ロボットの位置を表示
print(f"TestRobot: x={self.x}, y={self.y} : ")
return action
受信したアクションによって、クラス変数のxとyを更新します。
最後に更新した位置を表示します。
例としてSO-101では受信したアクション(関節角度)に基づいてモーターを制御しています。
データセット作成を見据えている方は留意いただければ幸いです。観測情報の取得
# 観測情報を取得します。
def get_observation(self) -> dict:
return {
"sensor1.pos": self.x,
"sensor2.pos": self.y,
}
実際の動作時には、センサーで受信した値等をここで定義した型に合わせて返却します。
お疲れ様でした。Teleoperatorクラスの実装
Configクラスの作成
import random
import string
from dataclasses import dataclass, field
from lerobot.teleoperators.config import TeleoperatorConfig
@TeleoperatorConfig.register_subclass("test") # コマンドのteleop.typeで指定する値
@dataclass
class TestTeleoperatorConfig(TeleoperatorConfig):
teleoperator_id: str # コマンドラインで指定可能な値。デフォルトを指定していないため、何も指定しないとエラーになります。
teleoperator_name: str = field(
default_factory=lambda: "".join(random.choices(string.ascii_letters + string.digits, k=8))
) # コマンドラインで指定可能な値。デフォルトを指定しているため、何も指定しないとランダムな名前を使用します。
teleoperator_idはデフォルト値を設定しないこととします。
こちらによって、コマンドラインで何も指定しないとエラーになります。
引数から必須の情報を受け取る必要がある場合は、必須パラメータとして定義しましょう。teleoperator_nameは先ほどと同じくデフォルト値を設定しているため、指定しないでも大丈夫です。
しかし今回はdefault_factoryを使ってランダムな名前を生成するようにしています。
固定値でない値を生成する場合は、こちらのように設定しましょう。Teleoperatorクラスの初期化 & 型定義
lerobot_teleoperators/test/lerobot_teleoperator_test/test.pyを記述します。
こちらのソースコードを参考にしてください。from typing import Any
import pygame
from lerobot.teleoperators.teleoperator import Teleoperator
from .config_test import TestTeleoperatorConfig
class TestTeleoperator(Teleoperator):
config_class = TestTeleoperatorConfig
name = "test_teleoperator"
def __init__(self, config: TestTeleoperatorConfig):
super().__init__(config)
self.id = config.teleoperator_id
self.name = config.teleoperator_name
# pygame関連の初期化
self.screen = None
self.font = None
self.clock = None
self._is_connected = False
# キー状態の管理
self.key_states = {
"left": False,
"right": False,
"up": False,
"down": False,
}
# ウインドウ設定
self.window_width = 400
self.window_height = 300
self.bg_color = (30, 30, 30)
self.text_color = (255, 255, 255)
self.active_color = (100, 200, 100)
# テレオペレーターのアクションの型を定義
@property
def action_features(self) -> dict:
return {
"key.left": bool,
"key.right": bool,
"key.up": bool,
"key.down": bool,
}
# テレオペレーターのフィードバックの型を定義
@property
def feedback_features(self) -> dict:
return {}
Teleoperatorクラスではキーボードの入力を受信するためにpygameを使用します。
※ キーボード取得のライブラリは他にもありますが、OS間の互換性やOSのアクセシビリティの許可が必要になったりするので今回はpygameを使用します。
__init__ではRobotで行った初期化以外にpygameのための変数を定義しています。action_featuresはキー状態を取得するための型を定義しています。
feedback_featuresは使用しないため、空の辞書を返却しています。接続処理
# テレオペレーターの接続処理を行います。
def connect(self, calibrate: bool = True) -> None:
pygame.init()
self.screen = pygame.display.set_mode((self.window_width, self.window_height))
pygame.display.set_caption("Test Teleoperator - Arrow Keys")
self.font = pygame.font.Font(None, 36)
self.clock = pygame.time.Clock()
self._is_connected = True
# テレオペレーターの切断処理を行います。
def disconnect(self) -> None:
if self._is_connected:
pygame.quit()
self._is_connected = False
self.screen = None
self.font = None
self.clock = None
# テレオペレーターの接続状態を取得してboolを返却します。
@property
def is_connected(self) -> bool:
return self._is_connected
connectではpygameの初期化を行っています。
pygameの初期化が完了したら、接続状態_is_connectedをTrueにしています。disconnectでは、接続状態がTrueの場合にpygameの終了処理を行っています。
pygameの終了処理が完了したら、接続状態をFalseにしています。is_connectedでは、上記で設定した接続状態を返却しています。キャリブレーション & 設定
# テレオペレーターのキャリブレーションを行います。
def calibrate(self) -> None:
pass
# テレオペレーターのキャリブレーション状態を取得してboolを返却します。
@property
def is_calibrated(self) -> bool:
return True
# テレオペレーターの設定を行います。
def configure(self) -> None:
pass
そのため、説明をスキップします。アクションの取得
# テレオペレーターのアクションを取得します。
def get_action(self) -> dict[str, Any]:
if not self.is_connected:
raise ConnectionError(f"{self} is not connected.")
# pygameイベントを処理
for event in pygame.event.get():
if event.type == pygame.QUIT:
self._is_connected = False
return {
"key.left": False,
"key.right": False,
"key.up": False,
"key.down": False,
}
# 現在のキー状態を取得
keys = pygame.key.get_pressed()
self.key_states["left"] = keys[pygame.K_LEFT]
self.key_states["right"] = keys[pygame.K_RIGHT]
self.key_states["up"] = keys[pygame.K_UP]
self.key_states["down"] = keys[pygame.K_DOWN]
# 画面を描画
self._render_display()
return {
"key.left": self.key_states["left"],
"key.right": self.key_states["right"],
"key.up": self.key_states["up"],
"key.down": self.key_states["down"],
}
def _render_display(self) -> None:
"""画面に現在のキー状態を表示"""
self.screen.fill(self.bg_color)
# タイトル
title = self.font.render("Arrow Keys", True, self.text_color)
title_x = self.window_width // 2 - title.get_width() // 2
self.screen.blit(title, (title_x, 20))
# 画面を4分割した各領域の中心
left_center_x = self.window_width // 4
right_center_x = self.window_width * 3 // 4
top_center_y = self.window_height // 4
bottom_center_y = self.window_height * 3 // 4
# 上キー(上半分の中心)
up_color = self.active_color if self.key_states["up"] else self.text_color
up_text = self.font.render("UP", True, up_color)
up_x = self.window_width // 2 - up_text.get_width() // 2
up_y = top_center_y - up_text.get_height() // 2
self.screen.blit(up_text, (up_x, up_y))
# 下キー(下半分の中心)
down_color = self.active_color if self.key_states["down"] else self.text_color
down_text = self.font.render("DOWN", True, down_color)
down_x = self.window_width // 2 - down_text.get_width() // 2
down_y = bottom_center_y - down_text.get_height() // 2
self.screen.blit(down_text, (down_x, down_y))
# 左キー(左半分の中心)
left_color = self.active_color if self.key_states["left"] else self.text_color
left_text = self.font.render("LEFT", True, left_color)
left_x = left_center_x - left_text.get_width() // 2
left_y = self.window_height // 2 - left_text.get_height() // 2
self.screen.blit(left_text, (left_x, left_y))
# 右キー(右半分の中心)
right_color = self.active_color if self.key_states["right"] else self.text_color
right_text = self.font.render("RIGHT", True, right_color)
right_x = right_center_x - right_text.get_width() // 2
right_y = self.window_height // 2 - right_text.get_height() // 2
self.screen.blit(right_text, (right_x, right_y))
pygame.display.flip()
self.clock.tick(60) # 60 FPS
少し処理が煩雑になってしまったので、関数を分けて解説します。get_action
Falseにして、キー状態をFalseにして返却key_statesに保存send_actionに送信します。
そのため、LeRobotのコマンドを使用する場合にはTeleoperatorクラスで定義するアクションの型定義とRobotクラスで定義するアクションの型定義が一致している必要があります。
自分でPythonスクリプトを作成する場合には、processorで変換処理を行ってTeleoperatorクラスで取得したアクションをRobotクラスで使用することが可能です。_render_display
画面描画を行っているだけで今回の実装にはそこまで関わらないため説明をスキップします。フィードバックの送信
# テレオペレーターのフィードバックを送信します。
def send_feedback(self, feedback: dict[str, Any]) -> None:
pass
本来的にはRobotクラスからTeleoperatorクラスにフィードバックを送信することができるようになっています。
お疲れ様でした!動作確認
まずはパッケージをインストールしましょう。uv sync
uv syncを行っている場合は下記を実行してパッケージを更新しましょう。uv sync --reinstall-package lerobot-robot-test --reinstall-package lerobot-teleoperator-test
uv run lerobot-teleoperate
--robot.type test
--teleop.type test
--teleop.teleoperator_id test_teleoperator_1
--robot.robot_id test_robot_1

矢印キーに合わせてCLIのロボットの位置が更新していきます。
お疲れ様でした!まとめ
LeRobotのワークフローをそのまま使用して実装できるのが嬉しいところです。
次の段階としては、こちらを利用してデータセットを作成していくのが面白いかもしれません。
皆様の自作ロボットでの活用はもちろんのこと、様々な実装で本記事が少しでも役立つことを祈ります。
もしも何か実装されましたら、きっと見つけに行くので、どこか(Twitter(新: X), 等)でアウトプットしていただけると嬉しいです!We Are Hiring!
