ABEJAでデータサイエンスしています、瀧田です。本記事はABEJAアドベントカレンダー2025の21日目の記事です。
はじめに
最近LeRobotというHugging Faceが公開しているOSSと比較的安価なアームSO-101のおかげで、アームを使った模倣学習が各所で行われています。 LeRobotの中に、以下のようなものが実装されており、基本的には実行するだけで模倣学習ができるようになっています。
- ロボットアームのキャリブレーション
- 模倣学習のデータ取得
- SmolVLAなどのVLAモデルの学習(模倣学習)
- 学習済みモデルを使ったロボットアームでの推論
これらが簡単に実施できるのがLeRobotですが、簡単に実行できるが故に中でどう動いているのかが分かりずらい構成になっています。 これからPhysicalAIをやっていく上で上記内容は基礎として押さえていく必要があると考えています。
本記事では、「模倣学習のデータ収集」にフォーカスします。特に、LeRobotの中でどのようなデータをどのような形式で保存しているかを紐解きます。 昨今PhysicalAIがどんどん発展していっていますが、データはかなり重要なファクターと考えており、どのようなデータをどのような形式で保存しているかを知ることで今後いろんなロボットやカメラを増やしたり独自でデータを取る時の参考として利用したいです。
テレオペレーションによるデータ収集環境
まずはテレオペレーション(遠隔操作)でデータを収集したときのハードウェアの構成について説明します。
使用したロボットアーム
- SO-101
- リーダーアーム:人が操作するアーム
- フォロワーアーム:テレオペされる側
カメラ構成(3台)
- Top-downカメラ: 上から見ているカメラ。タスク全体を俯瞰
- Side-viewカメラ: 横から見ているカメラ。側面の動きを捉える
- Eye-in-Hand(Wristカメラ): アームの手首に取り付けられたカメラ。エンドエフェクタ視点
模倣学習のタスク
右側に置いた緑のブロックを左側の黒い枠にpick&placeするタスクでデータを取っていきました。

LeRobotにおけるデータセット
前提条件(バージョン情報)
本記事では以下のバージョンで検証しています:
- LeRobotバージョン:
0.4.0 - データセットバージョン(
codebase_version):v3.0
データセットのバージョンはmeta/info.jsonに記載されています。古いバージョンのLeRobotで作成されたデータセットは互換性がない場合があるため注意が必要です。
模倣学習用のコードを実行するとHugging FaceのDatasetに保存されます。 それをローカルにダウンロードして中身を確認していきます。
ディレクトリのツリー図
dataset_root_dir/
├── .gitattributes
├── README.md
├── data/
│ └── chunk-000/
│ └── file-000.parquet
├── meta/
│ ├── episodes/
│ │ └── chunk-000/
│ │ └── file-000.parquet
│ ├── info.json
│ ├── stats.json
│ └── tasks.parquet
└── videos/
├── observation.images.side/
│ └── chunk-000/
│ └── file-000.mp4
├── observation.images.top/
│ └── chunk-000/
│ └── file-000.mp4
└── observation.images.wrist/
└── chunk-000/
└── file-000.mp4
それぞれのファイルの役割
.gitattributes: Git LFS(Large File Storage)の設定ファイル。動画やParquetファイルなどの大容量ファイルを効率的に管理するための設定が記載されていますREADME.md: データセットの説明文書。データセットの概要、使い方、タスクの説明などが記載されていますdata/chunk-000/file-000.parquet: 実際の学習データ本体が格納されたParquetファイル。観測データ(画像インデックス、状態など)、アクション、タイムスタンプなどが含まれていますmeta/episodes/chunk-000/file-000.parquet: エピソード情報を格納したParquetファイル。各エピソードの開始・終了フレームインデックス、長さ、タスク情報などのメタデータが含まれていますmeta/info.json: データセット全体の設定情報。ロボットの種類、カメラ設定、fps、データ形式のバージョン(codebase_version)などが記載されていますmeta/stats.json: データセットの統計情報。各観測値やアクションの最大値・最小値・平均値・標準偏差などが含まれており、データの正規化に使用されますmeta/tasks.parquet: タスク情報を格納したParquetファイル。各タスクのID、タスク名などが記載されていますvideos/observation.images.side/chunk-000/file-000.mp4: サイドビューカメラで撮影された動画ファイル。実際の画像データは圧縮されて動画形式で保存され、学習時にフレームごとに展開されますvideos/observation.images.top/chunk-000/file-000.mp4: トップダウンカメラで撮影された動画ファイル。上からの視点でタスクの全体像を捉えていますvideos/observation.images.wrist/chunk-000/file-000.mp4: Eye-in-Handカメラ(アームの手首に取り付けられたカメラ)で撮影された動画ファイル。エンドエフェクタに近い視点からの画像を提供します
データセットの可視化
LeRobotにはデータセットの内容を可視化するツールも用意されており、実行することで以下のようなデータを確認できます。 この可視化にはrerun.ioというマルチモーダルの可視化ツールが使われています。

なお、Hugging Faceにあるデータではなく、ローカルファイルを可視化しようとすると標準のスクリプトではエラーになったため、自前で可視化を呼び出すコードを作成しました。
可視化の実行方法
実行にはLeRobotのインストールやffmpegが必要です。
LeRobotのインストール:
# LeRobotリポジトリをクローン git clone <https://github.com/huggingface/lerobot.git> cd lerobot # 仮想環境を作成(uvを使用) uv venv source .venv/bin/activate # macOS/Linux # LeRobotをインストール uv pip install -e .
FFmpegのインストール(macOSの場合):
brew install ffmpeg
可視化コード:
#!/usr/bin/env python3 """ Visualize local LeRobot dataset """ from pathlib import Path from LeRobot.datasets.LeRobot_dataset import LeRobotDataset from LeRobot.scripts.LeRobot_dataset_viz import visualize_dataset # データセットのパスを設定 dataset_path = Path("./dataset/green_block_20251031_merged") dataset_name = "green_block_20251031_merged" # データセットを読み込む print(f"Loading dataset: {dataset_name}") print(f"Dataset path: {dataset_path}") dataset = LeRobotDataset( repo_id=dataset_name, root=dataset_path, episodes=[0], # 最初のエピソードのみ video_backend="pyav" # PyAVバックエンドを使用 ) print(f"Dataset loaded successfully!") print(f"Number of episodes: {dataset.num_episodes}") print(f"Number of frames: {dataset.num_frames}") print(f"Robot type: {dataset.meta.robot_type}") print(f"FPS: {dataset.meta.fps}") # ビジュアライズ print("\\nVisualizing episode 0...") visualize_dataset(dataset, episode_index=0, mode="local")
収集データの構造詳細分析(今回のメイン)
ではそれぞれのファイルにどのようなデータが入っているのかを詳しく見ていきます。 全て紹介しているとかなり長くなってしまうので、個人的に重要そうなところをピックアップしていきます。
meta/info.json
取得したデータのバージョンや、使用したロボットアームの名前、取得エピソード数、fpsなどが記載されています。
そしてfeaturesタグでは、ロボットアームから取得した波形情報であるモーター軸の名前、数値型、次元数がactionとstateに分かれて格納されています。
{ "codebase_version": "v3.0", "robot_type": "so101_follower", "total_episodes": 89, "total_frames": 51613, "total_tasks": 1, "chunks_size": 1000, "data_files_size_in_mb": 100, "video_files_size_in_mb": 500, "fps": 30, #途中省略 "features": { "action": { "dtype": "float32", "names": [ "shoulder_pan.pos", "shoulder_lift.pos", "elbow_flex.pos", "wrist_flex.pos", "wrist_roll.pos", "gripper.pos" ], "shape": [ 6 ] }, "observation.state": { "dtype": "float32", "names": [ "shoulder_pan.pos", "shoulder_lift.pos", "elbow_flex.pos", "wrist_flex.pos", "wrist_roll.pos", "gripper.pos" ], "shape": [ 6 ] }, #以下省略
observation.state と action の違い
ここでactionとstateとは何かを確認していきます。
重要なのは、observation.stateとactionの違いです。両者とも同じ6次元(各関節の角度)のデータですが、意味が異なります。
observation.state: 現在のロボットの状態(現在の各関節の角度)action: 次に動かす目標位置(次のフレームで目指すべき各関節の角度)
模倣学習では、「現在の状態(observation)を見て、次にどう動くべきか(action)を学習する」ため、この2つが必要になります。
実際のデータを見てみると:
# Episode 0の中盤の例 frame | observation.state[0:3] (フォロワーアームの位置) | action[0:3] (リーダーアームの位置) 290 | [10.46 -4.32 32.79] | [ 0.55 -14.88 37.62] 291 | [ 8.19 -6.63 34.24] | [ -4.55 -18.20 39.68] 292 | [ 3.80 -9.88 36.51] | [ -7.10 -19.37 40.30]
frame 290では、フォロワーアーム(実際に動くロボット)の位置が[10.46, -4.32, 32.79](observation.state)で、同じタイミングでのリーダーアーム(人間が操作)の位置が[0.55, -14.88, 37.62](action)となっています。
重要なポイント:
- 各フレーム(30fps)で、リーダーアームとフォロワーアームの位置を同時に記録しています
actionは「次のフレームの状態」ではなく、同じフレームでのリーダーアームの位置です
つまり、テレオペレーションで人間が操作したリーダーアームの位置がactionとして記録され、それを追従しようとしているフォロワーアームの実際の位置がobservation.stateとして記録されているという構造になっています。
なので模倣学習時には、モデルは「現在の画像(observation.images)と現在のロボット状態(observation.state)」から「次に動くべき目標(action)」を予測するように学習していると考えられます。
stateとactionのグラフ化
Episode 0のstateとactionをグラフ化して確認してみましょう。

stateとactionに若干のずれがあることがわかりますが、これは現在のフォロワーアームに対してリーダーアームがどう動こうとしているかの差分と考えられます。
特にgripper.posに注目すると、約8秒から11秒にかけてstateとactionに他では見られない大きな差分があります。これはグリッパーがものを強く掴んでいるために、物理的な抵抗によってstateとactionに差が生じていると考えられます。
meta/tasks.parquet
tasks.parquetには、データセットで実施したタスクの情報が格納されています。
# tasks.parquetの内容 task_index Move the green cube into the black square. 0
このファイルは以下のような構造になっています:
- インデックス(行名): タスクの説明文(例: "Move the green cube into the black square.")
- task_index列: タスクに割り当てられた数値ID(0から始まる整数)
今回のデータセットでは、タスクが1つだけ(緑のキューブを黒い枠に移動させる)なので、task_index=0のみが存在します。
複数の異なるタスクを収集する場合は、以下のように複数のタスクが記録されます:
# 複数タスクの例(仮想例) task_index Move the green cube into the black square. 0 Pick up the red ball and place it in the box. 1 Stack three blocks vertically. 2
実際のデータ(data/chunk-000/file-000.parquet)の各フレームにはtask_index列があり、そのフレームがどのタスクに属するかを示していました。
meta/episodes/chunk-000/file-000.parquet
meta/episodes/の中に入っているchunk-000/file-000.parquetは、エピソードごとのメタデータが格納されています。
今回確認したデータは88エピソードで、chunk-000/file-000.parquetは89行 × 121列ありました。
入っている概要としては以下の通りです:
- エピソードごとのメタデータ(1行 = 1エピソード全体の情報)
- 含まれるデータ:
length: エピソードの長さ(フレーム数)dataset_from_index~dataset_to_index:data/ファイル内のどの範囲のフレームかstats/*: エピソード全体の統計情報(min, max, mean, std)videos/*: 動画ファイルへの参照
data/chunk-000/file-000.parquet
ここのparquetファイルはテーブルデータ構造になっており、以下のようなデータが格納されていました。
- action: ロボットへの指令値(6次元配列)であり、各関節の目標位置
- observation.state: ロボットの現在の状態(6次元配列)であり、各関節の実際の位置
- timestamp: タイムスタンプ(秒)エピソード開始からの経過時間
- frame_index: フレームインデックス、エピソード内での順序番号
- episode_index: エピソードインデックス、どのエピソードのデータか
- index: グローバルインデックス、データセット全体での一意なID
- task_index: タスクインデックス、どのタスクのデータか
データの中身を確認すると以下でした。
| action | observation.state | timestamp | frame_index | episode_index | index | task_index | |
|---|---|---|---|---|---|---|---|
| 0 | [ 8.18678 -99.002495 99.37444 54.731457 -3.692066 1.4265336] | [ 7.661559 -98.11724 99.45504 54.50644 -3.576983 1.7955801] | 0 | 0 | 0 | 0 | 0 |
| 1 | [ 8.18678 -99.002495 99.37444 54.731457 -3.692066 1.4265336] | [ 7.661559 -98.11724 99.45504 54.50644 -3.576983 1.7955801] | 0.0333333 | 1 | 0 | 1 | 0 |
| 2 | [ 8.18678 -99.002495 99.37444 54.731457 -3.692066 1.4265336] | [ 7.661559 -98.11724 99.45504 54.50644 -3.576983 1.7955801] | 0.0666667 | 2 | 0 | 2 | 0 |
| 3 | [ 8.18678 -99.002495 99.37444 54.731457 -3.692066 1.4265336] | [ 7.661559 -98.11724 99.45504 54.50644 -3.576983 1.7955801] | 0.1 | 3 | 0 | 3 | 0 |
| 4 | [ 8.18678 -99.002495 99.37444 54.731457 -3.692066 1.4265336] | [ 7.661559 -98.11724 99.45504 54.50644 -3.576983 1.7955801] | 0.133333 | 4 | 0 | 4 | 0 |
つまり、data/ディレクトリにあるものは実データ、meta/episodes/はインデックス&カタログという関係性になっています。
videos/*
videos/ディレクトリには、各カメラで撮影された動画ファイル(MP4形式)が格納されています。今回のデータセットでは3つのカメラ(side、top、wrist)の動画が記録されています。
動画ファイルはvideos/observation.images.{カメラ名}/chunk-{番号}/file-{番号}.mp4という構造で保存されており、meta/episodes/chunk-000/file-000.parquetには各エピソードがどの動画ファイルのどの時間範囲に対応しているかの情報が格納されています。
具体的には、各カメラごとに以下の情報があります:
- chunk_index: 動画ファイルのチャンク番号
- file_index: 動画ファイルのファイル番号
- from_timestamp: そのエピソードの動画開始時刻(秒)
- to_timestamp: そのエピソードの動画終了時刻(秒)
例えば、Episode 0の場合:
カメラ: observation.images.side chunk_index: 0 file_index: 0 from_timestamp: 0.0 to_timestamp: 19.37 → videos/observation.images.side/chunk-000/file-000.mp4 の 0.0秒 〜 19.37秒
この情報により、data/chunk-000/file-000.parquetの各フレームデータと対応する動画フレームを正確に紐付けることができます。
おわりに
本記事では、LeRobotのテレオペレーションで収集した学習データの内部構造を詳しく解説しました。
- LeRobotのデータセットは、Parquet形式のテーブルデータとMP4形式の動画ファイルで構成
data/ディレクトリには実データ(state、action、タイムスタンプなど)が格納され、meta/ディレクトリにはメタデータ(エピソード情報、統計情報など)が格納- データは30fpsで記録され、各フレームにはロボットの状態、アクション、カメラ画像(動画として保存)、タイムスタンプなどが含まれています
- 動画はMP4形式で保存されており、学習時にフレームごとに展開している
このデータ構造の理解は、以下のような応用に役立つと考えています。
- カスタムデータセットの作成: 追加でカメラを増やしたり、単腕ではなく多腕ロボットでのデータ収集
- 独自学習パイプラインの作成: 独自でVLAモデルの学習コードにおけるDataset Classの作成
- 複数ロボットへの対応: 異なるロボットアームのデータを同じ形式で収集する場合
この知見を使ってみなさんデータ取得したり、独自学習パイプラインを作ってみてはいかがでしょうか!? それでは!!
We Are Hiring!
ABEJAは、テクノロジーの社会実装に取り組んでいます。 技術はもちろん、技術をどのようにして社会やビジネスに組み込んでいくかを考えるのが好きな方は、下記採用ページからエントリーください! (新卒の方やインターンシップのエントリーもお待ちしております!) careers.abejainc.com
特に下記ポジションの募集を強化しています!ぜひ御覧ください!
