feat: import_lerobot に LeRobotConverter を追加(取り込みのカスタマイズ) - #285
Conversation
Client.import_lerobot に LeRobot v3 → FastLabel 取り込みのカスタマイズ点を
フックとして開放する LeRobotConverter を追加。
- LeRobotConverter(新規, pure strategy)。SDK が meta/info.json 付きで
__init__ 生成(クラス/ファクトリを渡す)。全体フロー(ループ・タスク作成・
アップロード・後処理)は Client が保持し、converter は hook のみ:
- select_observation_state_names / select_action_names: 残す feature 名を
選ぶ(name→index 変換は SDK が __init__ で1回だけ解決)
- build_observation_state / build_action / build_telemetry_frame:
telemetry の値・項目(gripper 等)を構築
- select_cameras: 登録動画を選別(Camera: path/key/content_name)
- build_task_kwargs: create_robotics_task へ渡す tags/metadatas 等
- select_episodes: episode 選別(index→frame数のみ, v3 非依存)
- build_episode_name: タスク/成果物の命名
- import_lerobot(converter=LeRobotConverter)。一時ディレクトリは
with tempfile.TemporaryDirectory で管理。ZIP はタスク作成前に生成し
orphan task を回避。想定内 (FastLabelException) は per-episode 記録、
想定外は伝播。
- レイヤ疎結合化: converter は v3 非依存 / v3 は version 固有データアクセス /
common は version 非依存の共有カーネル。info.json の feature 名で name
ベース選別が可能(例: observation.state を ".pos" のみ、カメラを主要4つ)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| def format_episode_name(episode_index: int) -> str: | ||
| return f"episode_{episode_index:06d}" |
There was a problem hiding this comment.
episode 名を変更できるように、converter に移動しました。
| def get_camera_dirs(lerobot_data_path: Path) -> list: | ||
| """Get camera directories and their content names. | ||
| Returns [(camera_dir, content_name), ...]. | ||
| e.g. observation.images.top -> content_name = "images_top" | ||
| """ | ||
| videos_dir = lerobot_data_path / "videos" | ||
| if not videos_dir.exists(): | ||
| return [] | ||
|
|
||
| results = [] | ||
| for obs_dir in sorted(videos_dir.iterdir()): | ||
| if not obs_dir.is_dir(): | ||
| continue | ||
| parts = obs_dir.name.split(".") | ||
| if parts[0] != "observation": | ||
| raise FastLabelInvalidException( | ||
| f"Unexpected camera dir name: {obs_dir.name}" | ||
| ) | ||
|
|
||
| content_name = "_".join(parts[1:]) | ||
| results.append((obs_dir, content_name)) | ||
| return results |
There was a problem hiding this comment.
camera のディレクトリの置き場所は v3 に依存していたので、v3 側に移動させました。
| def load_info(lerobot_data_path: Path) -> dict[str, Any]: | ||
| """Load meta/info.json for a LeRobot dataset. | ||
|
|
||
| Returns the parsed dict, or {} when the file is absent (older datasets), | ||
| so name-based selection is opt-in and default behavior is unaffected. | ||
| """ | ||
| info_path = lerobot_data_path / "meta" / "info.json" | ||
| if not info_path.exists(): | ||
| return {} | ||
| return json.loads(info_path.read_text()) |
There was a problem hiding this comment.
meta/info.json は v2, v3 共通で同じ箇所なため、common においています。
There was a problem hiding this comment.
v2 は実装はしていませんが、v2, v3 が実装された時の、バージョンを意識しなくていいように内部で分岐させるためのインターフェース群、として定義しています。
| # ---- declarative selection (static configuration) ---- | ||
| OBSERVATION_STATE_NAMES: ClassVar[tuple[str, ...] | None] = None | ||
| ACTION_NAMES: ClassVar[tuple[str, ...] | None] = None | ||
| CAMERA_KEYS: ClassVar[tuple[str, ...] | None] = None |
There was a problem hiding this comment.
基本的にはクラス変数で静的に設定できるようにしています。大体はこの設定で observation.state, action, cameras を指定する想定です。
| def select_observation_state_names(self, names: list[str]) -> list[str]: | ||
| """Return the ``observation.state`` feature names to keep (default: all). | ||
|
|
||
| ``names`` is the full ordered name list from ``meta/info.json``. Override | ||
| for dynamic selection, e.g. ``[n for n in names if n.endswith(".pos")]``. | ||
| """ | ||
| return ( | ||
| list(self.OBSERVATION_STATE_NAMES) | ||
| if self.OBSERVATION_STATE_NAMES | ||
| else names | ||
| ) |
There was a problem hiding this comment.
observation.state をロジックで指定したい場合のフックです。
regext などの可能性があると思っています。
| def select_action_names(self, names: list[str]) -> list[str]: | ||
| """Return the ``action`` feature names to keep (default: all).""" | ||
| return list(self.ACTION_NAMES) if self.ACTION_NAMES else names |
There was a problem hiding this comment.
observation.state と同様に、action も動的にできるようにしています。
| def build_observation_state(self, frame: Frame) -> list[Any]: | ||
| values = frame["observation.state"] | ||
| return [values[i] for i in self._state_index] |
There was a problem hiding this comment.
センサー名の list[str] から、実際のデータを取得する箇所です。
| def build_telemetry_frame(self, frame: Frame) -> dict[str, Any]: | ||
| """Build one telemetry frame written to the episode JSON. | ||
|
|
||
| Override and call ``super()`` to add extra items (e.g. gripper). | ||
| """ | ||
| return { | ||
| "observation.state": self.build_observation_state(frame), | ||
| "action": self.build_action(frame), | ||
| "frame_index": int(frame["frame_index"]), | ||
| "timestamp": float(frame["timestamp"]), | ||
| } |
There was a problem hiding this comment.
この4つを暗黙的に登録していましたが、拡張できるようにしています。
| def select_cameras(self, cameras: list[Camera]) -> list[Camera]: | ||
| """Return the cameras to include (default: all — the given list). | ||
|
|
||
| Each ``Camera`` has ``path`` / ``key`` / ``content_name``; ``key`` matches | ||
| ``self.meta["features"][key]`` for looking up resolution etc. When | ||
| ``CAMERA_KEYS`` is set, keep cameras whose key suffix (e.g. ``cam_high`` | ||
| of ``observation.images.cam_high``) is in the set. | ||
| """ | ||
| if self.CAMERA_KEYS is None: | ||
| return cameras | ||
| return [ | ||
| camera | ||
| for camera in cameras | ||
| if camera.key.split(".")[-1] in self.CAMERA_KEYS | ||
| ] |
There was a problem hiding this comment.
カメラも、不要なものは送らないようにすることが想定できるためフックを作っています。たとえば、センサー系のカメラは送らない、などを想定しています。
| def build_task_kwargs( | ||
| self, | ||
| *, | ||
| episode_index: int, | ||
| episode_name: str, | ||
| frames: list[Frame], | ||
| ) -> dict[str, Any]: | ||
| """Return keyword args forwarded to ``create_robotics_task``. | ||
|
|
||
| Keyword-only: | ||
| episode_index is the episode index. | ||
| episode_name is the task name (e.g. ``episode_000001``). | ||
| frames is the list of raw native frames for the episode. | ||
| Dataset-level metadata is available via ``self.meta``. | ||
| e.g. return ``{"tags": [...], "metadatas": [...]}``. | ||
| """ | ||
| return {} |
There was a problem hiding this comment.
FastLabel に送る tags, metadatas を動的に設定できるフックです。
| if parts[0] != "observation": | ||
| raise FastLabelInvalidException( | ||
| f"Unexpected camera dir name: {obs_dir.name}" | ||
| ) |
| self._state_index: list[int] = self._names_to_index( | ||
| "observation.state", self.select_observation_state_names | ||
| ) | ||
| self._action_index: list[int] = self._names_to_index( | ||
| "action", self.select_action_names | ||
| ) |
There was a problem hiding this comment.
[要確認] デフォルト converter が info.json(feature names)を必須化しており、「後方互換:従来通り動作」と食い違います。
__init__ は override の有無に関わらず observation.state と action の両方で _names_to_index を呼ぶため、デフォルトの LeRobotConverter(hook 無し)でも meta["features"]["observation.state"]["names"] / ["action"]["names"] が必須になります(無い場合は L82 で FastLabelInvalidException)。
Client.import_lerobot はこれを episode ループ・try の外で構築する(fastlabel/__init__.py:2286 の conv = converter(load_info(data_path)))ため、info.json が無い / names を欠くデータセットはループ開始前に import 全体が即失敗します。
旧 _convert_episode_frames は info.json を参照せず parquet 列だけからテレメトリを生成していたため、これまで info.json 無しで通っていたデータセットが通らなくなります(test_no_meta_raises、v3 fixture への info.json 追加、test_missing_required_columns_returns_empty 削除が裏付け)。
これは PR 本文「後方互換:従来通り動作(converter 省略時は全件)」および common.py の load_info docstring("default behavior is unaffected")と矛盾します。
対応案(どちらか):
- (a) 「info.json + feature names は今後必須」と PR 本文 / README /
load_infodocstring を修正 - (b) meta が空のときはデフォルトで全列フォールバックし旧挙動を維持
LeRobot v3 は通常 info.json を持つので (a) で十分だと思いますが、意図の確認をお願いします。
| def build_observation_state(self, frame: Frame) -> list[Any]: | ||
| values = frame["observation.state"] | ||
| return [values[i] for i in self._state_index] | ||
|
|
||
| def build_action(self, frame: Frame) -> list[Any]: | ||
| values = frame["action"] | ||
| return [values[i] for i in self._action_index] |
There was a problem hiding this comment.
[Medium] 想定外例外(非 FastLabelException)が1エピソードで import 全体を中断させ、旧のグレースフル劣化が失われています。
build_observation_state(L96)/ build_action(L100)はフレームの列を直接参照するため、フレームに当該列が無い、または宣言 names より配列が短い場合に KeyError / IndexError が発生します。
これらは FastLabelException のサブクラスではないため、Client.import_lerobot の per-episode ハンドラ except FastLabelException(fastlabel/__init__.py:2337)で捕捉されず、1エピソードの不整合で残りのエピソードごと import 全体が中断します。
旧 _convert_episode_frames は必須列が無ければ [] を返して per-episode で継続していたため、堅牢性が後退しています。
対応案: per-episode ループで想定外例外も広めに捕捉して記録・継続する、あるいは fail-fast を意図するなら docstring にその旨を明記してください。
概要
Client.import_lerobot(LeRobot v3 → FastLabel 取り込み)に、カスタマイズ点をフックとして開放するLeRobotConverterを追加しました。全体フロー(episode ループ・タスク作成・アップロード・後処理)はClientが保持し、ユーザーは必要な hook だけを override します。追加した hook(
LeRobotConverter)OBSERVATION_STATE_NAMES/ACTION_NAMES/CAMERA_KEYS(class var, tuple)select_observation_state_names/select_action_names__init__で1回解決)build_observation_state/build_action/build_telemetry_frameselect_camerasCamera:path/key/content_name)build_task_kwargscreate_robotics_taskへ渡すtags/metadatas等select_episodesindex→frame数のみ受け取り、dataset version 非依存)build_episode_nameconverterはクラス/ファクトリで渡し、SDK がmeta/info.json付きで生成します(デフォルトはLeRobotConverter=全 feature・全カメラ・全 episode を取り込み)。設計上のポイント
converter(ポリシー, version 非依存) /v3(version 固有のデータアクセス) /common(version 非依存の共有カーネル) /Client(オーケストレーション) を分離。converterはv3に依存しません。meta/info.jsonの feature 名で選別できるため、巨大なobservation.state(例 12014 次元)を関節値のみへ、カメラを主要4つのみへ、といった絞り込みが可能。with tempfile.TemporaryDirectoryで管理。ZIP はタスク作成前に生成し orphan task を回避。FastLabelException) は per-episode 記録して継続、想定外(接続エラー等)は伝播。互換性・破壊的変更
import_lerobot(project, lerobot_data_path, episode_indices=...)は従来通り呼び出せます(converter省略時は全 feature・全カメラ・全 episode)。meta/info.jsonが必須になりました。 telemetry の値は feature 名で選別するため、features["observation.state"]["names"]とfeatures["action"]["names"]を持たないデータセットは、converter 構築時(episode ループ開始前)にFastLabelInvalidExceptionで失敗します。デフォルトのLeRobotConverterでも同様です。旧実装は parquet の列のみからテレメトリを生成していたため、info.jsonの無いデータセットはこれまで通っていました。LeRobot v3 は通常meta/info.jsonを持つため、必須化する方針としています(README / docstring も同様に記載)。fastlabel.lerobot.format_episode_nameを削除(命名はLeRobotConverter.build_episode_nameに統合)。テスト
tests/test_lerobot_converter.py(新規, 純ロジック)+tests/test_lerobot_v3_parquet.py更新。pytest tests/→ 72 passed、flake8 / black / isort 通過。案件を想定した実装例
🤖 Generated with Claude Code