# Annotation Pipeline

`lerobot-annotate`는 각 에피소드의 비디오를 비전-언어 모델(VLM)로 분석하여 자연어 주석을 데이터셋에 기록합니다. [Language Columns and Recipes](./language_and_recipes) 페이지의 두 언어 열인 `language_persistent`와 `language_events`를 `data/chunk-*/file-*.parquet`에 직접 채워 넣습니다.

요약하면: LeRobot 데이터셋을 지정하면, 정책(policy)을 학습할 수 있는 서브태스크, 계획, 메모리, 삽화, 음성, 시각 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)
```

세 개의 모듈(`plan`, `interjections`, `vqa`)이 모두 **하나의** 공유 VLM과 통신합니다. 각 모듈은 출력을 디스크에 스테이징하고, 검증기가 이를 확인한 후, 단일 작성기가 데이터셋 샤드를 원자리에 다시 작성합니다.

## 파이프라인이 생성하는 것

각 모듈은 몇 가지 유형의 주석("스타일")을 출력하며, 이는 두 언어 열 중 하나로 라우팅됩니다:

| 스타일 / 원자                                | 열                | 모듈          |
| ------------------------------------------- | --------------------- | --------------- |
| `subtask`(Pi0.7 스타일의 "무엇이 아니라 어떻게")     | `language_persistent` | `plan`          |
| `plan` (초기 + 중재 시 새로고침)  | `language_persistent` | `plan`          |
| `memory` (MEM 스타일 압축)            | `language_persistent` | `plan`          |
| `task_aug` (작업의 재구성)        | `language_persistent` | `plan`          |
| `interjection`                              | `language_events`     | `interjections` |
| 음성 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 그리드로 패키징되며, 각 프레임의 시간이 모서리에 굽힘(burn-in) 처리되어 VLM이 정확한 경계 시간을 직접 인용할 수 있습니다. 이는 프레임당 하나의 이미지보다 비전 토큰 비용이 훨씬 저렴하므로 샘플링을 밀도 있게 유지할 수 있으며, `max_frames_per_prompt`를 초과하는 길이의 에피소드는 동일한 밀도로 윈도우로 분할된 후 병합됩니다. 두 프롬프트 모두 인과적 **이벤트 경계** 정의(개체가 잡히거나/놓이거나/새로운 위치에 도달하거나/뚜껑 상태가 변경되거나/내용물이 이동할 때 새로운 이벤트가 시작됨)를 포함하여 컷이 위치하는 지점을 명확히 합니다.

선택적으로, 세 번째 **seeded-relabel** 패스(`--plan.subtask_seeded_relabel`)는 각 스팬에 대해 이전/현재/다음 세그먼트의 컨택트 시트를 다시 검토하고, 첫 번째 라벨을 사전 지식으로 사용하여 라벨을 최소한으로 수정합니다. 이 과정에서 경계는 고정된 상태로 유지되며, 서술 표현만 더 명확하게 다듬어지는데, 하위 작업당 호출이 한 번 더 발생하는 비용이 발생합니다.

결과 스팬은 이어짐이 없는 전체 에피소드 커버로 이어지며, **모든 프레임에 정확히 하나의 활성 서브태스크가 할당됩니다**. 프로덕션 설정(단일 카메라, 타임스탬프가 있는 컨택트 시트, 자동 윈도우 기반 서브태스크 생성)은 [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`는 [Hugging Face Jobs](https://huggingface.co/docs/hub/en/jobs)로 자신을 디스패치할 수 있습니다. `lerobot-train`와 동일한 방식입니다. 로컬에서 실행할 정확한 명령에 `--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당 vLLM 서버를 하나씩 시작하고 OpenAI 호환 API를 통해 제어합니다.
3. 데이터셋 전체에서 `plan` / `interjections` / `vqa` 모듈을 실행합니다.
4. `--push_to_hub=true`를 사용하면 결과를 `--new_repo_id`에 업로드합니다(`--repo_id`를 설정하지 않으면 해당 위치에 다시 업로드합니다).

이 명령은 작업의 로그를 스트리밍합니다. `Ctrl-C`를 누르면 취소하지 않고 디스택합니다. `hf jobs hardware`를 사용하여 사용 가능한 flavor 목록과 가격을 확인하세요.

Qwen3.6은 기본적으로 thinking이 활성화된 상태로 제공되며, 이는 annotator가 JSON 답변에 필요한 토큰 예산을 소모합니다. `--vlm.chat_template_kwargs='{"enable_thinking": false}'`를 사용하면 이를 비활성화할 수 있습니다. `--push_to_hub=true`가 없으면 pod이 종료될 때 annotator가 생성한 데이터셋이 삭제됩니다.

### 작업 옵션

| 플래그                | 기본값                   | 기능                                                                    |
| ------------------- | ------------------------- | ------------------------------------------------------------------------------- |
| `--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`                    | 에 설치된 의 Git ref — 변경 사항을 테스트하려면 브랜치를 지정하세요. |
| `--job.tags`        | `[]`                      | 및 해당 이 푸시하는 모든 에 추가 태그(`lerobot`는 항상 추가됩니다). |

더 큰 데이터셋의 경우, `h200x4`로 확장하고 `--vlm.parallel_servers` / `--vlm.num_gpus`를 그에 맞게 높이며, 예를 들어 `--job.timeout=8h`를 사용하여 작업에 더 많은 여유를 확보하세요.

Remote 실행에는 `--repo_id`가 필요합니다(pod가 Hub에서 데이터를 가져오기 때문이며, `--root`는 사용자의 로컬 머신에만 존재하는 디렉토리를 지정합니다). 로컬 캐시에만 존재하는 데이터셋은 먼저 **private** 저장소에 push됩니다.

## 주요 옵션

가장 자주 사용하는 플래그입니다. 그 외의 모든 항목은 `lerobot-annotate --help`를 실행하여 확인하세요. 기본값은 짧은 조작 에피소드에 맞게 조정되어 있습니다.

### 데이터셋 입출력

| 플래그              | 기본값 | 기능                                                            |
| ----------------- | ------- | ----------------------------------------------------------------------- |
| `--repo_id`       | —       | `--root`이 설정되지 않은 경우 다운로드되는) 주석을 달 Hub 데이터셋.                 |
| `--root`          | —       | 대신 로컬 데이터셋 디렉토리를 어노테이션하세요.                             |
| `--new_repo_id`   | —       | 결과를 새 저장소에 푸시합니다(소스 저장소는 그대로 유지됩니다).       |
| `--push_to_hub`   | `false` | `--new_repo_id`로, 그렇지 않으면 `--repo_id`로 업로드 |
| `--only_episodes` | 전체     | 이 에피소드 인덱스만 주석 처리합니다(테스트 실행에 유용합니다).             |
| `--seed`          | `1729`  | Seeds는 중간 삽입 타임스탬프와 VQA 질문 유형을 선택하는 RNG를 초기화합니다.  |

### 어떤 모듈이 실행되는지

모든 모듈은 기본적으로 활성화되어 있으며 독립적으로 전환할 수 있습니다(예: 한 번에 하나의 모듈만 반복하려면 `false`로 설정하여 해당 모듈을 건너뜁니다):

| 플래그                      | 기본값 | 끄기                           |
| ------------------------- | ------- | ----------------------------------- |
| `--plan.enabled`          | `true`  | subtasks + plan + memory + 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당 하나).                           |
| `--vlm.num_gpus`           | `0`                | 서버당 GPU 수(`0` = 각각 하나).                                                    |
| `--vlm.client_concurrency` | `16`               | 모든 서버에 걸친 진행 중인 요청.                                               |
| `--vlm.max_new_tokens`     | `512`              | 호출당 생성 한도.                                                             |
| `--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 grounding 패스를 실행합니다(최상의 하위 작업 품질; 에피소드당 호출 +1회).                                             |
| `--plan.subtask_seeded_relabel` | `false`    | 두 번째 패스: 각 하위 작업에 대해 이전/현재/다음 컨택트 시트를 기반으로, 첫 번째 라벨을 시드로 사용하여 다시 라벨링합니다(하위 작업당 +1 호출). |
| `--plan.subtask_relabel_frames` | `5`        | `subtask_seeded_relabel=true`일 때만 사용되는 relabel pass에서 각 segment sheet마다 균등하게 샘플링된 프레임 수               |
| `--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`    | 각 단계 내에서 병렬로 처리되는 에피소드 수.         |

## 새 모듈 기여

이 파이프라인은 확장되도록 설계되어 있으며 **기여를 매우 환영합니다** — 완전히 새로운 모듈(예: trajectory traces 또는 affordances), 새로운 prompt 템플릿, 더 지능적인 grounding 플로우, 또는 기존 `plan` / `interjections` / `vqa` 모듈에 대한 품질 개선 등이 해당됩니다.

모든 모듈은 `src/lerobot/annotations/steerable_pipeline/modules/` 아래에 위치하며, VLM 클라이언트와 keyframe 캐시를 공유하고, 원시 출력을 스테이징 트리에 기록하며, 실행기에 자체 페이즈로 연결됩니다. 아이디어가 있으신가요? [레포지토리](https://github.com/huggingface/lerobot)에서 이슈나 PR을 열어 주세요.

## 레시피가 출력을 소비하는 방식

주석은 레시피가 읽도록 작성됩니다(자세한 내용은 [Language Columns and Recipes](./language_and_recipes)를 참조). 일반적으로:

- low-level / high-level / memory-update 브랜치는 `language_persistent`에서 `subtask` / `plan` / `memory`를 읽습니다.
- interjection-response 분기는 `interjection` 이벤트와 짝을 이루는 음성 원자(`tool_calls_from`를 통해 하나의 어시스턴트 턴으로 병합됨) 및 동일한 타임스탬프의 매칭 `plan` 리프레시를 읽습니다.
- VQA 브랜치는 `language_events`에서 `(vqa, user)` 및 `(vqa, assistant)` 쌍을 읽습니다.

## 상태와 이벤트가 분리되는 이유

설계를 형성하는 두 가지 아이디어가 있습니다:

1. **영속 상태와 정확한 이벤트.** 영속 행(`subtask`, `plan`, `memory`)은 에피소드 전체에 적용되며 "지금 무엇이 참인가?"라는 질문에 답합니다. 이벤트 행(`interjection`, `vqa`, 음성)은 타임스탬프가 일치하는 프레임에서만 나타납니다. 타임스탬프는 소스 parquet에서 그대로 복사되며, 부동 소수점으로 다시 계산하지 않습니다.
2. **VLM 패스 1회.** 세 모듈 모두 단일 VLM 클라이언트(작업의 vLLM 서버와 통신하는 OpenAI 호환 클라이언트)를 공유하므로, 세 번이 아닌 데이터셋당 모델 로드 비용이 1회만 발생합니다.

## 단일 모듈 재실행

각 모듈은 원시 출력을 `<root>/.annotate_staging/episode_{N:06d}/<module>.jsonl`에 스테이징합니다. 이를 통해 프롬프트 반복 작업의 비용이 낮아집니다. 하나의 모듈을 다시 실행하면 해당 모듈의 JSONL만 덮어쓰고, 이후 writer가 최종 parquet을 다시 구성합니다. `--plan.enabled=false`(그리고 마찬가지로 `--interjections.enabled` / `--vqa.enabled`)를 사용하여 원하지 않는 모듈을 비활성화하고 하나씩 테스트할 수 있습니다.

## 검증기가 검사하는 항목

writer가 실행되기 전에, `StagingValidator`는 다음을 확인합니다:

- 모든 이벤트 행이 실제 프레임 타임스탬프에 정확히 매핑됩니다;
- no speech / interjection pairs are left orphaned;
- `plan`은 모든 interjection 타임스탬프에서 새로고침됩니다;
- `memory` 행이 서브태스크 경계에 위치합니다(경고이며 오류가 아닙니다).
- 각 VQA 어시스턴트 `content`은 bbox / keypoint / count / attribute / spatial 형식 중 하나인 유효한 JSON입니다.
- 각 행은 `column_for_style(style)`에 의해 선택된 열로 이동합니다.

오류가 발생하면 writer가 중단됩니다. 디버깅 중에는 `--skip_validation=true`를 전달하여 이 동작을 무시할 수 있습니다.

## 각 모듈의 아이디어 출처

- **`plan` — 하위 작업.** 원자 단위 세분화("양배추 한 조각 집기", "그릇을 상자에 놓기")는 Hi Robot([Shi 2025](https://arxiv.org/abs/2502.19417)), "무엇이 아니라 어떻게"에 대한 세부 사항은 Pi0.7([Physical Intelligence 2025](https://pi.website/pi07))를 참고하세요.
- **`plan` — memory.** MEM([Torne 2026](https://arxiv.org/abs/2603.03596)): 최소한의 관련 정보만 유지 — 결과를 보존하고 구체적인 속성은 제거합니다.
- **`interjections`.** Hi Robot의 시나리오 분류 체계: negative task, situated correction, specific constraint, preference. Speech는 tool-call-only 원자입니다(`tool_calls=[{type:function, function:{name:"say", arguments:{text:...}}}]`).
- **`vqa`.** grounded features(bounding box `[x_min, y_min, x_max, y_max]` 및 keypoint, [0, 1] 이미지 비율로 저장)에는 ECoT([Zawalski 2024](https://arxiv.org/abs/2407.08693))를, multi-abstraction grounding에는 Steerable VLA Policies([Zhao 2025](https://arxiv.org/abs/2509.07626))를 사용합니다. Pi0.7도 추상화 수준에 걸쳐 답변을 grounding합니다.

모듈을 개선할 때는 처음부터 다시 작성하기보다 `src/lerobot/annotations/steerable_pipeline/prompts/`의 프롬프트 템플릿을 조정하세요.

## 대략적인 비용

에피소드당 파이프라인은 약 `plan_max_steps`개의 plan 호출, `max_interjections_per_episode`개의 interjection 호출, `vqa_emission_hz × episode_seconds`개의 VQA 호출을 수행합니다. 기본값(8개 서브태스크, 3개 interjection, 1 Hz × 3 쌍)을 30초 에피소드에 적용하면 약 50개의 VLM 호출이 발생합니다.

저장 용량은 작게 유지됩니다: `language_persistent`는 에피소드당 최대 수십 KB 수준이며(parquet는 프레임 간에 반복되는 단일 항목을 사전 인코딩합니다), `language_events`는 대부분의 프레임에서 비어 있습니다. 그 크기는 `num_frames × num_emissions`가 아니라 방출(emission)의 수에 따라 비례합니다.

