# Конвейер аннотации

`lerobot-annotate` анализирует видео каждого эпизода с помощью модели «зрение-язык» (VLM) и записывает аннотации на естественном языке обратно в ваш датасет. Он заполняет два языковых столбца со страницы [Language Columns and Recipes](./language_and_recipes) — `language_persistent` и `language_events` — непосредственно в файлы `data/chunk-*/file-*.parquet`.

Коротко: укажите ему датасет LeRobot, и он добавит подзадачи, планы, память, вставки, речь и визуальные вопросы и ответы, на которых можно обучать политику.

## Как всё это работает

```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. Каждый модуль записывает свой вывод на диск, валидатор проверяет его, а единый writer перезаписывает шарды датасета на месте.

## Что генерирует конвейер

Каждый модуль генерирует несколько типов аннотаций («стилей»), направляемых в один из двух языковых столбцов:

| Стиль / атом                                | Столбец                | Модуль          |
| ------------------------------------------- | --------------------- | --------------- |
| `subtask` (стиль Pi0.7: «как, а не что»)     | `language_persistent` | `plan`          |
| `plan` (начальный + обновление при вмешательстве)  | `language_persistent` | `plan`          |
| `memory` (сжатие в стиле MEM)            | `language_persistent` | `plan`          |
| `task_aug` (перефразировки задачи)        | `language_persistent` | `plan`          |
| `interjection`                              | `language_events`     | `interjections` |
| атом вызова инструмента речи (`style=null`, `say`) | `language_events`     | `interjections` |
| `vqa` (пара «пользователь / ассистент»)               | `language_events`     | `vqa`           |

### Как создаются подзадачи

Модуль `plan` не запрашивает у VLM подзадачи одним запросом. Вместо этого он использует двухэтапный процесс **describe → segment**:

1. **Описание** — VLM описывает только то, что действительно видит на выбранной камере (без догадок о задаче).
2. **Segment** — это описание возвращается обратно, и VLM разбивает эпизод на последовательные атомарные подзадачи.

Оба прохода воспринимают эпизод как **контактные листы с временными метками** — кадры, отобранные с частотой `frames_per_second` (по умолчанию 0,5 с) и упакованные в JPEG-сетки, где время каждого кадра вписано в его угол, благодаря чему VLM указывает точные времена границ напрямую. Это значительно дешевле по числу визуальных токенов, чем один кадр на изображение, поэтому выборку можно делать плотной; эпизоды длиннее `max_frames_per_prompt` разбиваются на окна с той же плотностью и затем объединяются. Оба промпта также содержат причинное определение **границы события** (новое событие начинается, когда объект берут в руки / отпускают / он достигает нового места / крышка меняет состояние / содержимое перемещается), чтобы точнее определить, где должны быть разрывы.

Необязательно, третий проход **seeded-relabel** (`--plan.subtask_seeded_relabel`) повторно обрабатывает каждый фрагмент с контактными листами предыдущего/текущего/следующего сегмента и минимально корректирует метку, используя первую метку как априорную — границы остаются фиксированными, уточняется только формулировка, что требует одного дополнительного вызова на подзадачу.

Полученные фрагменты затем склеиваются в бесшовное покрытие всего эпизода, так что **каждый кадр имеет ровно один активный подзадачу**. См. [Running on Hugging Face Jobs](#running-on-hugging-face-jobs) для настроек продакшена (одна камера, контактные листы с временными метками, автоматическая генерация подзадач с оконным разбиением).

### Инструменты

Записывающий модуль **не** добавляет столбец `tools` в parquet. Каталог инструментов хранится в `meta/info.json["tools"]` (см. [Tools](./tools)). После каждого запуска конвейер проверяет, что каноническая схема `say` присутствует в этом списке, сохраняя при этом все инструменты, объявленные вами ранее.

Хотите добавить собственный инструмент? Отредактируйте `meta/info.json["tools"]` напрямую — конвейер сохраняет всё, что уже там есть. Это делает инструмент видимым для шаблона чата, чтобы модель могла научиться _генерировать_ вызов. Слой рантайма, который фактически _выполняет_ сгенерированный вызов (протокол `Tool` / `TOOL_REGISTRY` в `src/lerobot/tools/`), пока не реализован — документация [Tools](./tools) помечает эти части как не реализованные.

## Запуск на Hugging Face Jobs

Аннотирование реального набора данных требует GPU достаточно большого размера для обслуживания VLM, поэтому `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. запускает один сервер vLLM на каждый GPU и управляет им через OpenAI-совместимый API,
3. запускает модули `plan` / `interjections` / `vqa` на наборе данных,
4. с `--push_to_hub=true` загружает результат в `--new_repo_id` (или обратно в `--repo_id` на месте, если вы оставите его не заданным).

Команда выводит логи задания в режиме потока; `Ctrl-C` отключает вывод, не отменяя задание. Список доступных конфигураций и их стоимость можно получить с помощью `hf jobs hardware`.

Qwen3.6 поставляется с включённым режимом размышлений, который расходует бюджет токенов, необходимый аннотатору для формирования ответа в формате JSON, — `--vlm.chat_template_kwargs='{"enable_thinking": false}'` отключает его. Без `--push_to_hub=true` аннотированный набор данных удаляется при завершении работы пода.

### Варианты заданий

| Флаг                | По умолчанию                   | Что он делает                                                                    |
| ------------------- | ------------------------- | ------------------------------------------------------------------------------- |
| `--job.target`      | `local`                   | Вариант HF Jobs для запуска (например, `h200`, `h200x4`). Пропущено/`local` выполняется здесь.    |
| `--job.image`       | `vllm/vllm-openai:latest` | Образ среды выполнения для пода.                                                      |
| `--job.timeout`     | `2h`                      | Ограничение по фактическому времени. Увеличьте его для больших наборов данных.                                    |
| `--job.detach`      | `false`                   | Отправить и выйти, а не транслировать журналы.                                      |
| `--job.lerobot_ref` | `main`                    | Git-ссылка на установленную на поде версию lerobot — укажите ветку для тестирования изменений. |
| `--job.tags`        | `[]`                      | Дополнительные теги для задания и для любого набора данных, который оно публикует (`lerobot` добавляется всегда). |

Для более крупного набора данных масштабируйте до `h200x4` и увеличьте значения `--vlm.parallel_servers` / `--vlm.num_gpus` соответственно, а также предоставьте задаче больше запаса, например, с помощью `--job.timeout=8h`.

Для удалённых запусков требуется `--repo_id` (под забирает датасет с Hub; `--root` указывает на каталог, который есть только на вашей машине). Датасет, существующий только в вашем локальном кэше, сначала выгружается в **приватный** репозиторий.

## Ключевые параметры

Это флаги, которые вы будете использовать чаще всего. Запустите `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.  |

### Какие модули выполняются

Каждый модуль включён по умолчанию и может быть переключён независимо (установите `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`             | Подсказка thinking-budget (`low`/`medium`/`high`), передаваемая на серверы, совместимые с OpenAI. |

### Подзадачи / план / память (`--plan.*`)

| Флаг                            | По умолчанию    | Что он делает                                                                                                                 |
| ------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `--plan.frames_per_second`      | `2.0`      | Частота выборки кадров для контактных листов (`2.0` = один кадр каждые 0,5 с).                                                   |
| `--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`    | Второй проход: переметить каждый подзадачу по её контактным листам prev/current/next, используя начальную метку (+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` | Основайте VQA только на `--vlm.camera_key` (иначе на каждой камере). |
| `--vqa.coordinate_scale`                        | `1000`  | Сетка bbox/ключевых точек VLM, хранится в диапазоне [0, 1] (`none` = px).    |
| `--executor.episode_parallelism`                | `16`    | Эпизоды обрабатываются параллельно в рамках каждой фазы.         |

## Добавление новых модулей

Пайплайн создан для развития, и **вклад сообщества очень приветствуется** — будь то совершенно новый модуль (например, трассировка траекторий или affordances), новый шаблон промпта, более умный процесс grounding или исправления качества в существующих модулях `plan` / `interjections` / `vqa`.

Каждый модуль находится в `src/lerobot/annotations/steerable_pipeline/modules/`, использует общий VLM-клиент и кэш ключевых кадров, записывает свои необработанные результаты в staging-дерево и подключается к исполнителю как отдельная фаза. Есть идея? Создайте issue или PR в [репозитории](https://github.com/huggingface/lerobot).

## Как рецепты используют выход

Аннотации предназначены для чтения рецептами (см. [Языковые столбцы и рецепты](./language_and_recipes)). Как правило:

- Ветви low-level / high-level / memory-update читают `subtask` / `plan` / `memory` из `language_persistent`.
- ветвь «вставка-ответ» читает события `interjection` вместе с соответствующим атомом речи (объединёнными в один ход ассистента через `tool_calls_from`) и обновлением `plan` с тем же временным меткой.
- ветвь VQA считывает пары `(vqa, user)` и `(vqa, assistant)` из `language_events`.

## Почему состояние и события разделены

Две идеи определяют дизайн:

1. **Состояние и точные события.** Строки состояния (`subtask`, `plan`, `memory`) действуют на протяжении всего эпизода и отвечают на вопрос «что верно прямо сейчас?». Строки событий (`interjection`, `vqa`, речь) появляются только в том кадре, чье время совпадает с меткой времени. Метки времени копируются напрямую из исходного parquet — они никогда не пересчитываются в формате с плавающей точкой.
2. **Один проход VLM.** Все три модуля используют один и тот же клиент VLM (OpenAI-совместимый клиент, работающий с сервером vLLM задачи), поэтому загрузка модели происходит один раз на датасет, а не три.

## Повторный запуск одного модуля

Каждый модуль записывает свой сырой вывод в `<root>/.annotate_staging/episode_{N:06d}/<module>.jsonl`. Это делает итерации промптов дешёвыми: повторный запуск одного модуля перезаписывает только его JSONL, после чего writer собирает итоговый parquet. Модули, которые не нужны, можно отключить с помощью `--plan.enabled=false` (аналогично `--interjections.enabled` / `--vqa.enabled`), чтобы тестировать их по одному.

## Что проверяет валидатор

Перед запуском writer `StagingValidator` проверяет:

- каждая строка события точно соответствует реальному временному метке кадра;
- не остаётся сиротских пар «речь / междометие»;
- `plan` обновляется в каждой временной метке прерывания;
- `memory` строки попадают на границы подзадач (предупреждение, а не ошибка);
- каждый ответ VQA-ассистента `content` представляет собой корректный JSON в одном из форматов bbox / keypoint / count / attribute / spatial;
- каждая строка попадает в столбец, выбранный с помощью `column_for_style(style)`.

Любая ошибка прерывает работу писателя. Для отладки передайте `--skip_validation=true`, чтобы отключить проверку.

## Откуда берутся идеи каждого модуля

- **`plan` — подзадачи.** 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: отрицательная задача, ситуативная коррекция, конкретное ограничение, предпочтение. Речь — это атом, содержащий только вызов инструмента (`tool_calls=[{type:function, function:{name:"say", arguments:{text:...}}}]`).
- **`vqa`.** ECoT ([Zawalski 2024](https://arxiv.org/abs/2407.08693)) для привязанных к изображению признаков (ограничивающих прямоугольников `[x_min, y_min, x_max, y_max]` и ключевых точек, хранящихся в виде долей изображения в диапазоне [0, 1]) и 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 Гц × 3 пары) на 30-секундном эпизоде это составляет около 50 вызовов VLM.

Объём хранилища остаётся небольшим: `language_persistent` занимает не более десятков КБ на эпизод (parquet использует словарное кодирование для одной записи, повторяющейся во всех кадрах), а `language_events` пуст на большинстве кадров — его размер зависит от количества событий, а не от `num_frames × num_emissions`.

