# アノテーションパイプライン

`lerobot-annotate` は、ビジョン言語モデル (VLM) を用いて各エピソードの動画を監視し、自然言語によるアノテーションをデータセットに書き戻します。[Language Columns and Recipes](./language_and_recipes) ページで説明されている2つの言語カラム、`language_persistent` と `language_events` に、`data/chunk-*/file-*.parquet` へ直接値を設定します。

要するに: LeRobotデータセットを指定すると、ポリシーの学習に使用できるサブタスク、計画、メモリ、割り込み、音声、視覚Q&Aが追加されます。

## 全体像

```text
  your dataset                  lerobot-annotate
  (LeRobotDataset v3.0)
        │
        ▼
  ┌─────────────────────────────────────────────────────┐
  │                    read episodes                     │
  └──────────────────────────┬──────────────────────────┘
                             │
        ┌────────────────────┼────────────────────┐
        ▼                    ▼                     ▼
  ┌──────────┐      ┌───────────────┐        ┌──────────┐       one shared Qwen-VL
  │   plan   │      │ interjections │        │   vqa    │  ◀──   server (vLLM, OpenAI
  └────┬─────┘      └───────┬───────┘        └────┬─────┘        API) drives all three
       └────────────────────┼─────────────────────┘
                            │   each module stages raw JSONL
                            ▼   into .annotate_staging/
                  ┌─────────────────┐
                  │    validator    │  ◀──  checks everything
                  └────────┬────────┘
                           ▼
                  ┌─────────────────┐
                  │     writer      │
                  └────────┬────────┘
                           ▼
              data/chunk-*/file-*.parquet
              (+ meta/info.json tools)
```

3つのモジュール（`plan`、`interjections`、`vqa`）はすべて、**1つの**共有VLMと通信します。各モジュールは出力をディスクにステージングし、バリデータがそれを検証し、単一のライターがデータセットのシャードをその場で書き換えます。

## パイプラインが生成するもの

各モジュールは、いくつかの種類のアノテーション（「スタイル」）を出力し、それらは2つの言語カラムのいずれかにルーティングされます。

| スタイル / 原子                                | カラム                | モジュール          |
| ------------------------------------------- | --------------------- | --------------- |
| `subtask`（Pi0.7スタイルの「何をではなく、どうやって」）     | `language_persistent` | `plan`          |
| `plan`（初期化と割り込み時の更新）  | `language_persistent` | `plan`          |
| `memory`（MEMスタイルの圧縮）            | `language_persistent` | `plan`          |
| `task_aug`（タスクの言い換え）        | `language_persistent` | `plan`          |
| `interjection`                              | `language_events`     | `interjections` |
| speech tool-call atom (`style=null`、`say`) | `language_events`     | `interjections` |
| `vqa`（ユーザー / アシスタントのペア）               | `language_events`     | `vqa`           |

### サブタスクの生成方法

`plan`モジュールは、VLMにサブタスクを一度に要求しません。代わりに、**describe → segment**という2段階のフローを使用します。

1. **Describe** — VLMは選択したカメラで実際に確認できる内容のみを説明します（タスクに関する推測は行いません）。
2. **Segment** — その説明がフィードバックされ、VLM がエピソードを連続するアトミックなサブタスクに分割します。

両方のパスは、エピソードを**タイムスタンプ付きのコンタクトシート**として認識します。これは`frames_per_second`（デフォルトは0.5秒）でサンプリングされたフレームをJPEGグリッドにパックしたもので、各フレームの隅にその時刻が焼き込まれているため、VLMは境界の正確な時刻を直接引用できます。これは1フレーム1画像よりもビジョントークンの消費が大幅に少ないため、サンプリングを高密度に保つことができます。`max_frames_per_prompt`を超える長さのエピソードは、同じ密度でウィンドウに分割され、マージされます。また、両方のプロンプトには、カットの位置を明確にするための因果的な**イベント境界**の定義（オブジェクトが保持される/解放される/新しい位置に到達する/蓋の状態が変わる/内容物が移動するときに新しいイベントが始まる）が含まれています。

任意として、3番目の**seeded-relabel**パス（`--plan.subtask_seeded_relabel`）では、各スパンについて、その前/現在/次のセグメントのコンタクトシートを参照し、最初のラベルを事前情報として用いてラベルを最小限に修正します。この処理では境界は固定され、表現の明確化のみが行われますが、サブタスクごとに1回の追加呼び出しが発生します。

生成されたスパンは、ギャップのないエピソード全体のカバーに縫い合わされ、**すべてのフレームにちょうど1つのアクティブなサブタスクが割り当てられます**。本番環境の設定（単一カメラ、タイムスタンプ付きコンタクトシート、自動ウィンドウ化されたサブタスク生成）については、[Hugging Face Jobsでの実行](#running-on-hugging-face-jobs)を参照してください。

### ツール

writer は parquet に `tools` 列を**追加しません**。ツールカタログは代わりに `meta/info.json["tools"]` に格納されます（[Tools](./tools) を参照）。各実行のたびに、パイプラインは正規の `say` スキーマがそのリストに含まれていることを確認し、事前に宣言したツールを保持します。

独自のツールを追加したい場合は、`meta/info.json["tools"]`を直接編集してください。パイプラインは、そこに既に存在する内容を保持します。これによりツールがチャットテンプレートから見えるようになり、モデルが呼び出しを_生成_することを学習できます。生成された呼び出しを実際に_実行_するランタイムレイヤー（`src/lerobot/tools/`配下の`Tool`プロトコル / `TOOL_REGISTRY`）はまだ実装されていません。[Tools](./tools)ドキュメントでは、これらの部分が未実装と明記されています。

## Hugging Face Jobsでの実行

実際のデータセットのアノテーションには、VLMをサーブするのに十分な大きさのGPUが必要です。そのため、`lerobot-annotate`は、`lerobot-train`と同様に、[Hugging Face Jobs](https://huggingface.co/docs/hub/en/jobs)に自身をディスパッチできます。ローカルで実行する予定の正確なコマンドに`--job.target=<flavor>`を追加すると、代わりにそのハードウェア上で実行されます:

```bash
hf auth login   # once

uv run lerobot-annotate \
    --repo_id=user/my_dataset \
    --new_repo_id=user/my_dataset_annotated \
    --push_to_hub=true \
    --vlm.model_id=Qwen/Qwen3.6-27B \
    --vlm.num_gpus=1 \
    --vlm.serve_command="vllm serve Qwen/Qwen3.6-27B --tensor-parallel-size 1 \
        --max-model-len 32768 --gpu-memory-utilization 0.8 \
        --uvicorn-log-level warning --port {port}" \
    --vlm.serve_ready_timeout_s=1800 \
    --vlm.chat_template_kwargs='{"enable_thinking": false}' \
    --job.target=h200
```

単一GPUの`h200`ジョブを送信します。このジョブは以下を実行します:

1. `vllm/vllm-openai`イメージを起点として、その上に`lerobot`をインストールします。
2. GPUごとに1つのvLLMサーバーを起動し、OpenAI互換APIを介して操作します。
3. データセット全体で`plan` / `interjections` / `vqa`モジュールを実行します。
4. `--push_to_hub=true` を指定すると、結果を `--new_repo_id` にアップロードします（`--repo_id` を設定しない場合、元の `--repo_id` に上書きしてアップロードします）。

このコマンドはジョブのログをストリーミング表示します。`Ctrl-C`でキャンセルせずにデタッチできます。`hf jobs hardware`で利用可能なフレーバーとその価格一覧を表示できます。

Qwen3.6 はデフォルトで thinking が有効になっていますが、これによりアノテーターが JSON 回答に必要とするトークン予算を消費してしまいます。`--vlm.chat_template_kwargs='{"enable_thinking": false}'` を指定することでこれを無効にできます。`--push_to_hub=true` を指定しないと、ポッドが終了した際にアノテーション済みデータセットは破棄されます。

### ジョブのオプション

| フラグ                | デフォルト                   | 機能                                                                    |
| ------------------- | ------------------------- | ------------------------------------------------------------------------------- |
| `--job.target`      | `local`                   | 実行するHF Jobsのflavor（例: `h200`、`h200x4`）。省略または`local`の場合は、ここで実行されます。    |
| `--job.image`       | `vllm/vllm-openai:latest` | Podのランタイムイメージ。                                                      |
| `--job.timeout`     | `2h`                      | 実時間上限。大規模なデータセットの場合は、これを引き上げてください。                                    |
| `--job.detach`      | `false`                   | ログをストリーミングする代わりに、送信して終了します。                                      |
| `--job.lerobot_ref` | `main`                    | PodにインストールされているlerobotのGit ref — 変更をテストするためにブランチを指定します。 |
| `--job.tags`        | `[]`                      | ジョブおよびそのジョブがプッシュするすべてのデータセットに付与される追加のタグ（`lerobot`は常に追加されます）。 |

より大きなデータセットを使用する場合は、`h200x4` にスケールアップし、`--vlm.parallel_servers` / `--vlm.num_gpus` をそれに合わせて引き上げ、例えば `--job.timeout=8h` のようにしてジョブに余裕を持たせてください。

リモート実行には`--repo_id`が必要です（ポッドはHubからデータセットを取得します。`--root`は、あなたのマシンにのみ存在するディレクトリを指定します）。ローカルキャッシュにのみ存在するデータセットは、まず**private**リポジトリにプッシュされます。

## 主要なオプション

これらは最も頻繁に使用するフラグです。その他のオプションについては`lerobot-annotate --help`を実行してください。デフォルト値は、短い操作エピソード向けに調整されています。

### 入出力用データセット

| フラグ              | デフォルト | 機能                                                            |
| ----------------- | ------- | ----------------------------------------------------------------------- |
| `--repo_id`       | —       | アノテーション対象のHubデータセット（`--root`が設定されていない場合はダウンロードされます）。                 |
| `--root`          | —       | ローカルのデータセットディレクトリをアノテーションします。                             |
| `--new_repo_id`   | —       | 結果を新しいリポジトリにプッシュします（ソースリポジトリは変更されません）。       |
| `--push_to_hub`   | `false` | アノテーション後にアップロードします（`--new_repo_id`に、それ以外の場合は`--repo_id`に）。 |
| `--only_episodes` | すべて     | これらのエピソードのインデックスのみをアノテーションします（テスト実行に便利です）。             |
| `--seed`          | `1729`  | 割り込みのタイムスタンプとVQAの質問タイプを選択するRNGをシードします。  |

### どのモジュールが実行されるか

すべてのモジュールはデフォルトで有効になっており、独立して切り替えることができます（`false`に設定するとスキップされます。例えば、モジュールを1つずつ反復処理する場合など）：

| フラグ                      | デフォルト | オフにします                           |
| ------------------------- | ------- | ----------------------------------- |
| `--plan.enabled`          | `true`  | サブタスク + プラン + メモリ + task_aug |
| `--interjections.enabled` | `true`  | 間投詞 + 発話アトム        |
| `--vqa.enabled`           | `true`  | VQAペア                       |

### VLM(`--vlm.*`)

| フラグ                       | デフォルト            | 機能                                                                         |
| -------------------------- | ------------------ | ------------------------------------------------------------------------------------ |
| `--vlm.model_id`           | `Qwen/Qwen3.6-27B` | サーブするモデルとプロンプト。                                                       |
| `--vlm.camera_key`         | `images.*` 最初   | 各プロンプトが基づくカメラ。                                            |
| `--vlm.serve_command`      | auto               | 正確な`vllm serve …`コマンド（ここでTPサイズ、GPUメモリ、`--max-model-len`を設定します）。  |
| `--vlm.parallel_servers`   | `1`                | ラウンドロビンルーティング用の独立したサーバー（GPUごとに1つ）。                           |
| `--vlm.num_gpus`           | `0`                | サーバーあたりのGPU数(`0` = 1台ずつ)。                                                    |
| `--vlm.client_concurrency` | `16`               | すべてのサーバーにわたるフライト中のリクエスト                                               |
| `--vlm.max_new_tokens`     | `512`              | 1回の呼び出しあたりの生成上限。                                                             |
| `--vlm.temperature`        | `0.2`              | サンプリング温度。                                                                |
| `--vlm.reasoning_effort`   | `null`             | OpenAI互換サーバーに転送される思考予算のヒント（`low`/`medium`/`high`）。 |

### サブタスク / 計画 / メモリ (`--plan.*`)

| フラグ                            | デフォルト    | 機能                                                                                                                 |
| ------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `--plan.frames_per_second`      | `2.0`      | コンタクトシート用のフレームサンプリングレート（`2.0` = 0.5秒ごとに1フレーム）。                                                   |
| `--plan.max_frames_per_prompt`  | `60`       | VLM呼び出しあたりのフレーム予算。サンプリングがこの値を超えるエピソードは、同じ密度で自動ウィンドウ化され、その後結合されます。        |
| `--plan.contact_sheet_columns`  | `5`        | コンタクトシートグリッドの列数（`contact_sheet_frames_per_sheet`タイル、時間方向を先行とする行優先）。                                     |
| `--plan.plan_max_steps`         | `8`        | エピソードあたりのサブタスクの上限。                                                                                         |
| `--plan.subtask_describe_first` | `true`     | describe→segment グラウンディングパスを実行します（サブタスクの品質が最も高く、エピソードあたり+1回の呼び出し）。                                             |
| `--plan.subtask_seeded_relabel` | `false`    | 2回目のパス: 各サブタスクを、その前/現在/次のコンタクトシートから、最初のラベルをシードとして再ラベル付けします（+1コール/サブタスク）。 |
| `--plan.subtask_relabel_frames` | `5`        | リレーベルパスの各セグメントシートで均等にサンプリングされるフレーム数（`subtask_seeded_relabel=true`の場合のみ使用されます）。               |
| `--plan.emit_plan`              | `true`     | `plan` の番号付き行を出力します（`false` = サブタスクとメモリのみ）。                                                            |
| `--plan.emit_memory`            | `true`     | `memory`行を出力します（`false` = サブタスクとプランのみ）。`emit_plan`と対称です。                                           |
| `--plan.n_task_rephrasings`     | `10`       | `task_aug` の言い換えを何件出力するか（`0` で無効化）。                                                                      |
| `--plan.derive_task_from_video` | `if_short` | データセットのタスクをそのまま使用する（`off`）、欠落または短い場合のみ使用する（`if_short`）、または常に動画から再導出する（`always`）。    |

### 間投詞 + VQA

| フラグ                                            | デフォルト | 機能                                               |
| ----------------------------------------------- | ------- | ---------------------------------------------------------- |
| `--interjections.max_interjections_per_episode` | `3`     | エピソードごとの割り込み/発話ペアの上限。              |
| `--vqa.vqa_emission_hz`                         | `1.0`   | VQAペアが出力される頻度。                           |
| `--vqa.restrict_to_default_camera`              | `false` | `--vlm.camera_key` のカメラのみで VQA を実行します（指定しない場合はすべてのカメラを使用します）。 |
| `--vqa.coordinate_scale`                        | `1000`  | VLMのbbox/keypointグリッド。[0, 1]で保存されます（`none` = px）。    |
| `--executor.episode_parallelism`                | `16`    | 各フェーズ内で並列処理されるエピソード数。         |

## 新しいモジュールの貢献

このパイプラインは拡張を前提として構築されており、**貢献を大歓迎しています**。例えば、トラジェクトリー・トレースやアフォーダンスといった全く新しいモジュール、新しいプロンプト・テンプレート、より賢いグラウンディング・フロー、あるいは既存の`plan` / `interjections` / `vqa`モジュールへの品質改善などが挙げられます。

すべてのモジュールは`src/lerobot/annotations/steerable_pipeline/modules/`の下に配置され、VLMクライアントとキーフレームキャッシュを共有し、生出力をステージングツリーに書き込み、独自のフェーズとしてエグゼキュータに組み込まれます。アイデアがあれば、[リポジトリ](https://github.com/huggingface/lerobot)でIssueまたはPRを作成してください。

## レシピが出力を消費する方法

アノテーションはレシピによって読み取られることを想定しています（[言語カラムとレシピ](./language_and_recipes)を参照）。一般的には、以下のようになります。

- low-level / high-level / memory-update ブランチは、`language_persistent` から `subtask` / `plan` / `memory` を読み取ります。
- an interjection-response branchは、`interjection`イベントと、対応する発話アトム（`tool_calls_from`を介して1つのアシスタントターンにマージされる）および、同一タイムスタンプの対応する`plan`リフレッシュを読み取ります。
- VQAブランチは`language_events`から`(vqa, user)`および`(vqa, assistant)`のペアを読み取ります。

## ステートとイベントが分離されている理由

設計を形作る2つの考え:

1. **永続状態と正確なイベント。** 永続行（`subtask`、`plan`、`memory`）はエピソード全体に適用され、「今、何が真実か？」という問いに答えます。イベント行（`interjection`、`vqa`、発話）は、タイムスタンプが一致する1フレームのみに表示されます。タイムスタンプはソースの parquet からそのままコピーされ、浮動小数点で再計算されることはありません。
2. **VLMパスは1回のみ。** 3つのモジュールはすべて単一のVLMクライアント（ジョブのvLLMサーバーと通信するOpenAI互換クライアント）を共有するため、データセットごとにモデルの読み込みコストは1回分のみで済みます。

## 単一モジュールの再実行

各モジュールは、生の出力を`<root>/.annotate_staging/episode_{N:06d}/<module>.jsonl`にステージングします。これにより、プロンプトの反復が低コストになります。1つのモジュールを再実行すると、そのモジュール自身のJSONLのみが上書きされ、その後、ライターが最終的なparquetを再構成します。テストを1つずつ行うには、不要なモジュールを`--plan.enabled=false`（同様に`--interjections.enabled` / `--vqa.enabled`）で無効にしてください。

## バリデータがチェックする内容

ライターの実行前に、`StagingValidator`が以下を確認します:

- すべてのイベント行が実際のフレームのタイムスタンプに正確に一致します。
- 孤立した発話/間投詞のペアが残っていないこと;
- `plan` は、各割り込みタイムスタンプで更新されます。
- `memory` の行がサブタスクの境界に位置する場合（警告であり、エラーではありません）;
- 各VQAアシスタントの`content`は、bbox / keypoint / count / attribute / spatial のいずれかの形式で、有効なJSONです。
- 各行は`column_for_style(style)`によって選択された列に格納されます。

エラーが発生すると、writer は中断されます。デバッグ中は `--skip_validation=true` を渡して上書きできます。

## 各モジュールのアイデアの由来

- **`plan` — サブタスク。**アトムの粒度（「レタスの1枚を拾う」「ボウルを箱に置く」）については Hi Robot（[Shi 2025](https://arxiv.org/abs/2502.19417)）を、「何を」ではなく「どのように」行うかという詳細については Pi0.7（[Physical Intelligence 2025](https://pi.website/pi07)）を参照してください。
- **`plan` — メモリ。** MEM（[Torne 2026](https://arxiv.org/abs/2603.03596)）：関連する最小限の情報のみを保持し、結果は残しつつ、具体的な属性は削除します。
- **`interjections`。**Hi Robot のシナリオ分類体系: negative task、situated correction、specific constraint、preference。発話はツール呼び出し専用のアトムです（`tool_calls=[{type:function, function:{name:"say", arguments:{text:...}}}]`）。
- **`vqa`.** 接地された特徴（バウンディングボックス `[x_min, y_min, x_max, y_max]` とキーポイント、[0, 1] の画像分数として保存）には ECoT（[Zawalski 2024](https://arxiv.org/abs/2407.08693)）、マルチ抽象レベルの接地には Steerable VLA Policies（[Zhao 2025](https://arxiv.org/abs/2509.07626)）を使用します。Pi0.7 もまた、抽象レベルをまたいで回答を接地します。

モジュールを改善する際は、ゼロから書き直すのではなく、`src/lerobot/annotations/steerable_pipeline/prompts/`内のプロンプトテンプレートを調整してください。

## おおよそのコスト

エピソードごとに、パイプラインは約`plan_max_steps`回のプラン呼び出し、`max_interjections_per_episode`回の割り込み呼び出し、および`vqa_emission_hz × episode_seconds`回のVQA呼び出しを行います。デフォルト設定（サブタスク8、割り込み3、1 Hz × 3ペア）で30秒のエピソードの場合、VLMの呼び出しは約50回になります。

ストレージは小さく保たれます: `language_persistent` はエピソードあたり最大数十 KB 程度です（parquet はフレーム間で繰り返し出現する 1 つのエントリを辞書符号化します）。また、`language_events` はほとんどのフレームで空です。そのサイズは `num_frames × num_emissions` ではなく、エミッションの数に比例して増加します。

