mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-24 02:06:15 +00:00
docs(annotate): friendlier rewrite + architecture diagram; drop reproducibility section
Rewrite annotation_pipeline.mdx in plainer, easier-to-read language (shorter sentences, active voice, a plain-text intro), add an ASCII 'How it fits together' architecture diagram, and remove the 'Reproducibility via seed and prompt hashes' section. Content/links are preserved; only wording and structure change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+148
-119
@@ -1,177 +1,206 @@
|
|||||||
# Annotation Pipeline
|
# Annotation Pipeline
|
||||||
|
|
||||||
`lerobot-annotate` populates the two language columns introduced by the
|
`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 Columns and Recipes](./language_and_recipes) page —
|
||||||
`language_persistent` and `language_events` — directly into
|
`language_persistent` and `language_events` — straight into
|
||||||
`data/chunk-*/file-*.parquet`.
|
`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 │ ┌──────┐ ┌─────────────┐ ┌─────┐ │ each module writes
|
||||||
|
Qwen-VL server ────▶│ │ plan │ │interjections│ │ vqa │ │ raw JSONL into
|
||||||
|
(vLLM, OpenAI API) │ └──┬───┘ └──────┬──────┘ └──┬──┘ │ .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
|
## What the pipeline produces
|
||||||
|
|
||||||
Three modules write into a per-episode staging tree, then a single writer
|
Each module emits a few kinds of annotation ("styles"), routed to one of
|
||||||
rewrites the data shards in place:
|
the two language columns:
|
||||||
|
|
||||||
| Style / atom | Column | Module |
|
| Style / atom | Column | Module |
|
||||||
| ------------------------------------------- | --------------------- | --------------- |
|
| ------------------------------------------- | --------------------- | --------------- |
|
||||||
| `subtask` (Pi0.7-style "how, not what") | `language_persistent` | `plan` |
|
| `subtask` (Pi0.7-style "how, not what") | `language_persistent` | `plan` |
|
||||||
| `plan` (initial + refresh on interjection) | `language_persistent` | `plan` |
|
| `plan` (initial + refresh on interjection) | `language_persistent` | `plan` |
|
||||||
| `memory` (MEM-style compression) | `language_persistent` | `plan` |
|
| `memory` (MEM-style compression) | `language_persistent` | `plan` |
|
||||||
| `task_aug` (rephrasings of canonical task) | `language_persistent` | `plan` |
|
| `task_aug` (rephrasings of the task) | `language_persistent` | `plan` |
|
||||||
| `interjection` | `language_events` | `interjections` |
|
| `interjection` | `language_events` | `interjections` |
|
||||||
| speech tool-call atom (`style=null`, `say`) | `language_events` | `interjections` |
|
| speech tool-call atom (`style=null`, `say`) | `language_events` | `interjections` |
|
||||||
| `vqa` (user / assistant pair) | `language_events` | `vqa` |
|
| `vqa` (user / assistant pair) | `language_events` | `vqa` |
|
||||||
|
|
||||||
The `plan` module generates subtasks per episode with a **describe → segment**
|
### How subtasks are generated
|
||||||
grounding flow: a first pass narrates only what is visible in the chosen
|
|
||||||
camera, and its description is fed into a second pass that segments the
|
The `plan` module doesn't ask the VLM for subtasks in one shot. Instead
|
||||||
episode into consecutive atomic subtasks. The resulting spans are then
|
it uses a two-step **describe → segment** flow:
|
||||||
deterministically stitched into a contiguous full-episode cover so every
|
|
||||||
frame has exactly one active subtask. See
|
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.
|
||||||
|
|
||||||
|
The resulting spans are then stitched into a gap-free, full-episode
|
||||||
|
cover, so **every frame has exactly one active subtask**. See
|
||||||
[`run_hf_job.py`](https://github.com/huggingface/lerobot/blob/main/examples/annotations/run_hf_job.py)
|
[`run_hf_job.py`](https://github.com/huggingface/lerobot/blob/main/examples/annotations/run_hf_job.py)
|
||||||
for the production flag set (single camera, embedded frames, windowed
|
for the production settings (single camera, embedded frames, windowed
|
||||||
subtask generation).
|
subtask generation).
|
||||||
|
|
||||||
The writer does **not** add a `tools` column to the parquet — the tool
|
### Tools
|
||||||
catalog lives at `meta/info.json["tools"]` instead (see
|
|
||||||
[Tools](./tools)). After every annotation run the pipeline ensures the
|
|
||||||
canonical `say` schema is present in that list, preserving any tools the
|
|
||||||
user pre-declared.
|
|
||||||
|
|
||||||
If you want to declare additional tools for a dataset before annotation
|
The writer does **not** add a `tools` column to the parquet. The tool
|
||||||
runs, edit `meta/info.json["tools"]` directly — the pipeline preserves
|
catalog lives in `meta/info.json["tools"]` instead (see [Tools](./tools)).
|
||||||
anything already there. That makes the tool visible to the chat template
|
After every run, the pipeline makes sure the canonical `say` schema is in
|
||||||
so the model can learn to _generate_ the call. The runtime layer that
|
that list, keeping any tools you declared beforehand.
|
||||||
_executes_ a generated call (the `Tool` protocol / `TOOL_REGISTRY` under
|
|
||||||
`src/lerobot/tools/`) is not part of this PR — see the
|
Want to add your own tool? Edit `meta/info.json["tools"]` directly — the
|
||||||
[Tools](./tools) doc, which marks those pieces as not-yet-implemented.
|
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
|
## Running on Hugging Face Jobs
|
||||||
|
|
||||||
Distributed annotation is delegated to
|
Annotation runs on [Hugging Face Jobs](https://huggingface.co/docs/hub/en/jobs).
|
||||||
[Hugging Face Jobs](https://huggingface.co/docs/hub/en/jobs). The repo
|
The repo ships a launcher script you copy and tweak for your dataset:
|
||||||
ships a launcher script you copy and edit for your dataset:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
HF_TOKEN=hf_... uv run python examples/annotations/run_hf_job.py
|
HF_TOKEN=hf_... uv run python examples/annotations/run_hf_job.py
|
||||||
```
|
```
|
||||||
|
|
||||||
[`examples/annotations/run_hf_job.py`](https://github.com/huggingface/lerobot/blob/main/examples/annotations/run_hf_job.py)
|
[`run_hf_job.py`](https://github.com/huggingface/lerobot/blob/main/examples/annotations/run_hf_job.py)
|
||||||
spawns a single-GPU `h200` job (scale up to `h200x4` for larger datasets) that:
|
starts a single-GPU `h200` job (bump it to `h200x4` for big datasets)
|
||||||
|
that:
|
||||||
|
|
||||||
1. installs `lerobot` (from `main`) plus the annotation extras,
|
1. installs `lerobot` (from `main`) plus the annotation extras,
|
||||||
2. boots one vLLM server per GPU (in the `vllm/vllm-openai` image) for the
|
2. boots one vLLM server per GPU (using the `vllm/vllm-openai` image) and
|
||||||
chosen model, which the pipeline drives over the OpenAI-compatible API,
|
drives it over the OpenAI-compatible API,
|
||||||
3. runs the `plan` / `interjections` / `vqa` modules across the dataset
|
3. runs the `plan` / `interjections` / `vqa` modules across the dataset
|
||||||
via `lerobot-annotate`,
|
with `lerobot-annotate`,
|
||||||
4. with `--push_to_hub=true`, uploads the annotated dataset to
|
4. with `--push_to_hub=true`, uploads the result to `--new_repo_id` (or
|
||||||
`--new_repo_id` (or back to `--repo_id` in place when that is unset).
|
back to `--repo_id` in place if you leave that unset).
|
||||||
|
|
||||||
To target a different dataset, model, or hub repo, edit the `CMD` block
|
To use a different dataset, model, or hub repo, edit the `CMD` block in
|
||||||
inside the script — every flag in there maps directly onto a CLI flag of
|
the script. Every flag there maps directly to a `lerobot-annotate` flag
|
||||||
`lerobot-annotate` (see `lerobot-annotate --help` for the full list).
|
(run `lerobot-annotate --help` for the full list).
|
||||||
|
|
||||||
## Contributing new modules
|
## Contributing new modules
|
||||||
|
|
||||||
The pipeline is built to be extended, and **contributions are very
|
The pipeline is built to grow, and **contributions are very welcome** —
|
||||||
welcome** — whether that's a brand-new annotation module (e.g. a
|
a brand-new module (say, trajectory traces or affordances), a new prompt
|
||||||
trajectory-trace or affordance module), a new prompt template, a better
|
template, a smarter grounding flow, or quality fixes to the existing
|
||||||
grounding flow, or quality improvements to the existing `plan` /
|
`plan` / `interjections` / `vqa` modules.
|
||||||
`interjections` / `vqa` modules. Each module lives under
|
|
||||||
|
Every module lives under
|
||||||
`src/lerobot/annotations/steerable_pipeline/modules/`, shares the VLM
|
`src/lerobot/annotations/steerable_pipeline/modules/`, shares the VLM
|
||||||
client and keyframe cache, writes its raw output to the per-episode
|
client and the keyframe cache, writes its raw output to the staging
|
||||||
staging tree, and is wired into the executor as an independent phase.
|
tree, and plugs into the executor as its own phase. Got an idea? Open an
|
||||||
If you have an idea for a module or an improvement, open an issue or PR
|
issue or PR on [the repo](https://github.com/huggingface/lerobot).
|
||||||
on [the repo](https://github.com/huggingface/lerobot).
|
|
||||||
|
|
||||||
## Style-to-recipe consumer mapping
|
## How recipes consume the output
|
||||||
|
|
||||||
The pipeline's outputs are designed to be consumed by recipes (see
|
The annotations are meant to be read by recipes (see
|
||||||
[Language Columns and Recipes](./language_and_recipes)) — typically:
|
[Language Columns and Recipes](./language_and_recipes)). Typically:
|
||||||
|
|
||||||
- low-level / high-level / memory-update branches consume
|
- low-level / high-level / memory-update branches read
|
||||||
`subtask`/`plan`/`memory` from `language_persistent`.
|
`subtask` / `plan` / `memory` from `language_persistent`.
|
||||||
- An interjection-response branch consumes `interjection` events plus
|
- an interjection-response branch reads `interjection` events plus the
|
||||||
the paired speech atom (merged into one assistant target turn via
|
paired speech atom (merged into one assistant turn via `tool_calls_from`)
|
||||||
`tool_calls_from`) and the same-timestamp `plan` refresh.
|
and the matching `plan` refresh at the same timestamp.
|
||||||
- A VQA branch consumes the `(vqa, user)` and `(vqa, assistant)` pairs
|
- a VQA branch reads the `(vqa, user)` and `(vqa, assistant)` pairs from
|
||||||
from `language_events`.
|
`language_events`.
|
||||||
|
|
||||||
## Why the design splits state from events
|
## Why state and events are split
|
||||||
|
|
||||||
Two things drive the scope:
|
Two ideas shape the design:
|
||||||
|
|
||||||
1. **Persistent state vs exact-event split.** Persistent rows
|
1. **Persistent state vs. exact events.** Persistent rows (`subtask`,
|
||||||
(`subtask`, `plan`, `memory`) broadcast per episode and answer "what
|
`plan`, `memory`) apply to the whole episode and answer "what's true
|
||||||
state is in force at this frame?". Event rows (`interjection`, `vqa`,
|
right now?". Event rows (`interjection`, `vqa`, speech) appear only on
|
||||||
speech) only appear on the exact frame whose timestamp matches the
|
the one frame whose timestamp matches. Timestamps are copied straight
|
||||||
emission. The pipeline writes timestamps taken straight from the
|
from the source parquet — never recomputed in floating point.
|
||||||
source parquet — no floating-point recomputation.
|
2. **One VLM pass.** All three modules share a single VLM client (the
|
||||||
2. **One Qwen-VL pass.** All three modules share a single VLM client (the
|
OpenAI-compatible client talking to the job's vLLM server), so you pay
|
||||||
OpenAI-compatible client talking to the job's vLLM server) so the cost
|
for one model load per dataset, not three.
|
||||||
is one model load per dataset, not three.
|
|
||||||
|
|
||||||
## Module independence and staged reruns
|
## Re-running a single module
|
||||||
|
|
||||||
Each module writes its raw output to
|
Each module stages its raw output to
|
||||||
`<root>/.annotate_staging/episode_{N:06d}/<module>.jsonl`. That makes
|
`<root>/.annotate_staging/episode_{N:06d}/<module>.jsonl`. This makes
|
||||||
prompt iteration cheap — re-running one module overwrites only its own
|
prompt iteration cheap: re-running one module overwrites only its own
|
||||||
JSONL file before the writer composes the final parquet. Modules can be
|
JSONL, then the writer recomposes the final parquet. Disable modules you
|
||||||
disabled via `--plan.enabled=false` (and likewise `--interjections.enabled`
|
don't want with `--plan.enabled=false` (and likewise
|
||||||
/ `--vqa.enabled`) to
|
`--interjections.enabled` / `--vqa.enabled`) to test one at a time.
|
||||||
test them in isolation.
|
|
||||||
|
|
||||||
## Validation/report checks before final write
|
## What the validator checks
|
||||||
|
|
||||||
Before the writer runs, `StagingValidator` checks:
|
Before the writer runs, `StagingValidator` confirms:
|
||||||
|
|
||||||
- exact frame-timestamp alignment for every event row;
|
- every event row lands exactly on a real frame timestamp;
|
||||||
- no orphan speech / interjection pairs;
|
- no speech / interjection pairs are left orphaned;
|
||||||
- `plan` is refreshed at every interjection timestamp;
|
- `plan` is refreshed at every interjection timestamp;
|
||||||
- `memory` rows fall on subtask boundaries (warning, not error);
|
- `memory` rows fall on subtask boundaries (a warning, not an error);
|
||||||
- VQA assistant `content` parses as JSON in one of the
|
- each VQA assistant `content` is valid JSON in one of the
|
||||||
bbox / keypoint / count / attribute / spatial shapes;
|
bbox / keypoint / count / attribute / spatial shapes;
|
||||||
- every row routes to the column dictated by `column_for_style(style)`.
|
- every row goes to the column chosen by `column_for_style(style)`.
|
||||||
|
|
||||||
Errors abort the writer (`--skip_validation=true` overrides for debugging).
|
Any error aborts the writer. Pass `--skip_validation=true` to override
|
||||||
|
while debugging.
|
||||||
|
|
||||||
## Paper inspirations per module
|
## Where each module's ideas come from
|
||||||
|
|
||||||
- **`plan` module — subtasks.** Hi Robot ([Shi 2025](https://arxiv.org/abs/2502.19417))
|
- **`plan` — subtasks.** Hi Robot ([Shi 2025](https://arxiv.org/abs/2502.19417))
|
||||||
atom granularity ("pick up one piece of lettuce", "place bowl to box");
|
for atom granularity ("pick up one piece of lettuce", "place bowl to
|
||||||
Pi0.7 ([Physical Intelligence 2025](https://pi.website/pi07)) "how, not
|
box"); Pi0.7 ([Physical Intelligence 2025](https://pi.website/pi07))
|
||||||
what" detail.
|
for "how, not what" detail.
|
||||||
- **`plan` module — memory.** MEM ([Torne 2026](https://arxiv.org/abs/2603.03596))
|
- **`plan` — memory.** MEM ([Torne 2026](https://arxiv.org/abs/2603.03596)):
|
||||||
compression directive: keep only minimal relevant information; functional
|
keep only the minimal relevant information — preserve outcomes, drop
|
||||||
outcomes preserved, specific attributes dropped.
|
specific attributes.
|
||||||
- **`interjections` module.** Hi Robot scenario taxonomy: negative task,
|
- **`interjections`.** Hi Robot's scenario taxonomy: negative task,
|
||||||
situated correction, specific constraint, preference. Speech is a
|
situated correction, specific constraint, preference. Speech is a
|
||||||
tool-call-only atom (`tool_calls=[{type:function, function:{name:"say",
|
tool-call-only atom
|
||||||
arguments:{text:...}}}]`).
|
(`tool_calls=[{type:function, function:{name:"say", arguments:{text:...}}}]`).
|
||||||
- **`vqa` module.** ECoT ([Zawalski 2024](https://arxiv.org/abs/2407.08693))
|
- **`vqa`.** ECoT ([Zawalski 2024](https://arxiv.org/abs/2407.08693)) for
|
||||||
grounded features (bounding boxes in pixel `[x_min, y_min, x_max, y_max]`,
|
grounded features (pixel bounding boxes `[x_min, y_min, x_max, y_max]`,
|
||||||
keypoints) and Steerable VLA Policies ([Zhao 2025](https://arxiv.org/abs/2509.07626))
|
keypoints) and Steerable VLA Policies
|
||||||
multi-abstraction grounding. Pi0.7 also grounds answers across
|
([Zhao 2025](https://arxiv.org/abs/2509.07626)) for multi-abstraction
|
||||||
multiple abstraction levels.
|
grounding. Pi0.7 also grounds answers across abstraction levels.
|
||||||
|
|
||||||
Future maintainers should adjust the prompt templates in
|
When improving a module, tweak its prompt template in
|
||||||
`src/lerobot/annotations/steerable_pipeline/prompts/` against these
|
`src/lerobot/annotations/steerable_pipeline/prompts/` rather than
|
||||||
references rather than rewriting from scratch.
|
rewriting from scratch.
|
||||||
|
|
||||||
## Compute and list-size estimates
|
## Roughly how much it costs
|
||||||
|
|
||||||
Per episode, the pipeline issues O(`max_steps`) `plan`-module calls,
|
Per episode, the pipeline makes about `max_steps` plan calls,
|
||||||
O(`max_interjections_per_episode`) `interjections`-module calls, and
|
`max_interjections_per_episode` interjection calls, and
|
||||||
O(`vqa_emission_hz × episode_seconds`) `vqa`-module calls. With defaults
|
`vqa_emission_hz × episode_seconds` VQA calls. With the defaults (8
|
||||||
(8 subtasks, 1 interjection, 1 Hz × 3 pairs) and 30-second episodes, that
|
subtasks, 1 interjection, 1 Hz × 3 pairs) on a 30-second episode, that's
|
||||||
is ~50 VLM calls per episode. `language_persistent` per episode is ~10s of
|
~50 VLM calls.
|
||||||
KB at most (parquet dictionary-encodes one entry per episode);
|
|
||||||
`language_events` is empty on most frames and is bounded by the number of
|
|
||||||
emissions, not `num_frames × num_emissions`.
|
|
||||||
|
|
||||||
## Reproducibility via seed and prompt hashes
|
Storage stays small: `language_persistent` is at most tens of KB per
|
||||||
|
episode (parquet dictionary-encodes the one entry that repeats across
|
||||||
`--seed` (default 1729) feeds the per-episode RNGs that select interjection
|
frames), and `language_events` is empty on most frames — its size scales
|
||||||
timestamps and VQA question types. Combined with the deterministic prompt
|
with the number of emissions, not `num_frames × num_emissions`.
|
||||||
templates checked into `prompts/`, two runs at the same seed against the
|
|
||||||
same dataset and the same model checkpoint produce byte-identical staging
|
|
||||||
artifacts. Prompt edits are recorded by file hash; future tooling can pin
|
|
||||||
expected `(seed, prompt_hash)` pairs into the dataset card.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user