# Annotation Pipeline `lerobot-annotate` watches each episode's video with a vision-language model (VLM) and writes natural-language annotations back into your dataset. It fills the two language columns from the [Language Columns and Recipes](./language_and_recipes) page — `language_persistent` and `language_events` — straight into `data/chunk-*/file-*.parquet`. In short: point it at a LeRobot dataset, and it adds subtasks, plans, memory, interjections, speech, and visual Q&A that a policy can be trained on. ## How it fits together ```text your dataset lerobot-annotate (LeRobot v3.1) │ ▼ ┌─────────────────────────────────────────────────────┐ │ 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) ``` Three modules (`plan`, `interjections`, `vqa`) all talk to **one** shared VLM. Each module stages its output to disk, a validator checks it, and a single writer rewrites the dataset shards in place. ## What the pipeline produces Each module emits a few kinds of annotation ("styles"), routed to one of the two language columns: | Style / atom | Column | Module | | ------------------------------------------- | --------------------- | --------------- | | `subtask` (Pi0.7-style "how, not what") | `language_persistent` | `plan` | | `plan` (initial + refresh on interjection) | `language_persistent` | `plan` | | `memory` (MEM-style compression) | `language_persistent` | `plan` | | `task_aug` (rephrasings of the task) | `language_persistent` | `plan` | | `interjection` | `language_events` | `interjections` | | speech tool-call atom (`style=null`, `say`) | `language_events` | `interjections` | | `vqa` (user / assistant pair) | `language_events` | `vqa` | ### How subtasks are generated The `plan` module doesn't ask the VLM for subtasks in one shot. Instead it uses a two-step **describe → segment** flow: 1. **Describe** — the VLM narrates only what it actually sees in the chosen camera (no guessing about the task). 2. **Segment** — that description is fed back in, and the VLM splits the episode into consecutive atomic subtasks. Both passes see the episode as **timestamped contact sheets** — frames sampled at `frames_per_second` (0.5s by default) and packed into JPEG grids with each frame's time burned into its corner, so the VLM cites exact boundary times directly. This is far cheaper in vision tokens than one image per frame, so the sampling can stay dense; episodes longer than `max_frames_per_prompt` are split into windows at the same density and merged. Both prompts also carry a causal **event-boundary** definition (a new event starts when an object becomes held / is released / reaches a new location / a lid changes state / contents move) to sharpen where cuts land. Optionally, a third **seeded-relabel** pass (`--plan.subtask_seeded_relabel`) revisits each span with its previous/current/next segment contact sheets and minimally corrects the label, using the first label as a prior — it keeps the boundaries fixed and only sharpens wording, at the cost of one extra call per subtask. The resulting spans are then stitched into a gap-free, full-episode cover, so **every frame has exactly one active subtask**. See [Running on Hugging Face Jobs](#running-on-hugging-face-jobs) for the production settings (single camera, timestamped contact sheets, auto-windowed subtask generation). ### Tools The writer does **not** add a `tools` column to the parquet. The tool catalog lives in `meta/info.json["tools"]` instead (see [Tools](./tools)). After every run, the pipeline makes sure the canonical `say` schema is in that list, keeping any tools you declared beforehand. Want to add your own tool? Edit `meta/info.json["tools"]` directly — the pipeline preserves whatever is already there. That makes the tool visible to the chat template, so the model can learn to _generate_ the call. The runtime layer that actually _executes_ a generated call (the `Tool` protocol / `TOOL_REGISTRY` under `src/lerobot/tools/`) is not part of this PR — the [Tools](./tools) doc marks those pieces as not-yet-implemented. ## Running on Hugging Face Jobs Annotating a real dataset needs a GPU big enough to serve the VLM, so `lerobot-annotate` can dispatch itself to [Hugging Face Jobs](https://huggingface.co/docs/hub/en/jobs) — same as `lerobot-train`. Add `--job.target=` to the exact command you'd run locally and it runs on that hardware instead: ```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 ``` That submits a single-GPU `h200` job that: 1. starts from the `vllm/vllm-openai` image and installs `lerobot` on top, 2. boots one vLLM server per GPU and drives it over the OpenAI-compatible API, 3. runs the `plan` / `interjections` / `vqa` modules across the dataset, 4. with `--push_to_hub=true`, uploads the result to `--new_repo_id` (or back to `--repo_id` in place if you leave that unset). The command streams the job's logs; `Ctrl-C` detaches without cancelling it. List the available flavors and their pricing with `hf jobs hardware`. Qwen3.6 ships with thinking enabled, which eats the token budget the annotator needs for its JSON answer — `--vlm.chat_template_kwargs='{"enable_thinking": false}'` turns it off. Without `--push_to_hub=true` the annotated dataset is discarded when the pod exits. ### Job options | Flag | Default | What it does | | ------------------- | ------------------------- | ------------------------------------------------------------------------------- | | `--job.target` | `local` | HF Jobs flavor to run on (e.g. `h200`, `h200x4`). Omitted/`local` runs here. | | `--job.image` | `vllm/vllm-openai:latest` | Runtime image for the pod. | | `--job.timeout` | `2h` | Wall-clock cap. Raise it for large datasets. | | `--job.detach` | `false` | Submit and exit instead of streaming logs. | | `--job.lerobot_ref` | `main` | Git ref of lerobot installed on the pod — point it at a branch to test changes. | | `--job.tags` | `[]` | Extra tags on the job and on any dataset it pushes (`lerobot` is always added). | For a bigger dataset, scale to `h200x4` and raise `--vlm.parallel_servers` / `--vlm.num_gpus` to match, and give the job more headroom with e.g. `--job.timeout=8h`. Remote runs need `--repo_id` (the pod pulls the dataset from the Hub; `--root` names a directory only your machine has). A dataset that exists only in your local cache is pushed to a **private** repo first. ## Key options These are the flags you'll reach for most often. Run `lerobot-annotate --help` for everything else; the defaults are tuned for short manipulation episodes. ### Dataset in / out | Flag | Default | What it does | | ----------------- | ------- | ----------------------------------------------------------------------- | | `--repo_id` | — | Hub dataset to annotate (downloaded if `--root` unset). | | `--root` | — | Annotate a local dataset directory instead. | | `--new_repo_id` | — | Push the result to a new repo (leaves the source repo untouched). | | `--push_to_hub` | `false` | Upload after annotating (to `--new_repo_id`, else back to `--repo_id`). | | `--only_episodes` | all | Annotate just these episode indices (handy for a test run). | | `--seed` | `1729` | Seeds the RNGs that pick interjection timestamps + VQA question types. | ### Which modules run Every module is on by default and can be toggled independently (set to `false` to skip it, e.g. to iterate on one module at a time): | Flag | Default | Turns off | | ------------------------- | ------- | ----------------------------------- | | `--plan.enabled` | `true` | subtasks + plan + memory + task_aug | | `--interjections.enabled` | `true` | interjections + speech atoms | | `--vqa.enabled` | `true` | the VQA pairs | ### The VLM (`--vlm.*`) | Flag | Default | What it does | | -------------------------- | ------------------ | ------------------------------------------------------------------------------------ | | `--vlm.model_id` | `Qwen/Qwen3.6-27B` | The model to serve and prompt. | | `--vlm.camera_key` | first `images.*` | Which camera every prompt is grounded on. | | `--vlm.serve_command` | auto | The exact `vllm serve …` command (set TP size, GPU memory, `--max-model-len` here). | | `--vlm.parallel_servers` | `1` | Independent servers for round-robin routing (one per GPU). | | `--vlm.num_gpus` | `0` | GPUs per server (`0` = one each). | | `--vlm.client_concurrency` | `16` | In-flight requests across all servers. | | `--vlm.max_new_tokens` | `512` | Generation cap per call. | | `--vlm.temperature` | `0.2` | Sampling temperature. | | `--vlm.reasoning_effort` | `null` | Thinking-budget hint (`low`/`medium`/`high`) forwarded to OpenAI-compatible servers. | ### Subtasks / plan / memory (`--plan.*`) | Flag | Default | What it does | | ------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- | | `--plan.frames_per_second` | `2.0` | Frame sampling rate for the contact sheets (`2.0` = one frame every 0.5s). | | `--plan.max_frames_per_prompt` | `60` | Frame budget per VLM call. Episodes whose sampling exceeds this are auto-windowed at the same density, then stitched. | | `--plan.contact_sheet_columns` | `5` | Columns per contact-sheet grid (`contact_sheet_frames_per_sheet` tiles, time row-major). | | `--plan.plan_max_steps` | `8` | Upper bound on subtasks per episode. | | `--plan.subtask_describe_first` | `true` | Run the describe→segment grounding pass (best subtask quality; +1 call/episode). | | `--plan.subtask_seeded_relabel` | `false` | Second pass: re-label each subtask from its prev/current/next contact sheets, seeded with the first label (+1 call/subtask). | | `--plan.subtask_relabel_frames` | `5` | Frames sampled uniformly per segment sheet in the relabel pass (only used when `subtask_seeded_relabel=true`). | | `--plan.emit_plan` | `true` | Emit the numbered `plan` rows (`false` = subtasks + memory only). | | `--plan.emit_memory` | `true` | Emit the `memory` rows (`false` = subtasks + plan only); symmetric to `emit_plan`. | | `--plan.n_task_rephrasings` | `10` | How many `task_aug` rephrasings to emit (`0` disables). | | `--plan.derive_task_from_video` | `if_short` | Use the dataset task as-is (`off`), only when it's missing/short (`if_short`), or always re-derive from video (`always`). | ### Interjections + VQA | Flag | Default | What it does | | ----------------------------------------------- | ------- | ---------------------------------------------------------- | | `--interjections.max_interjections_per_episode` | `3` | Cap on interjection/speech pairs per episode. | | `--vqa.vqa_emission_hz` | `1.0` | How often VQA pairs are emitted. | | `--vqa.restrict_to_default_camera` | `false` | Ground VQA only on `--vlm.camera_key` (else every camera). | | `--executor.episode_parallelism` | `16` | Episodes processed concurrently within each phase. | ## Camera-view curation `lerobot-curate-cameras` is a separate, lightweight command that uses the same VLM backend for a **dataset-filtering / curation** pass. It downloads only the **first episode**, then for each camera view asks the VLM to: 1. **flag** whether the view is blurry / unusable, and 2. **label** the view with a canonical name from a closed vocabulary (`top`, `wrist`, `front`, `bottom`, `left`, `right`, plus two-word combos like `left_wrist`). It runs in one of two modes: - `--mode=report` (default) — write the labels + verdicts into `meta/` (`meta/camera_curation.json` and a `curation` block on each camera in `meta/info.json`). Nothing is moved; this is the cheap triage pass and works for any dataset. - `--mode=rename` — apply the labels by renaming each camera key to `observation.images.