mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-24 18:26:11 +00:00
feat(annotate): improve VLM subtask annotation (legible contact sheets, seeded relabeling, self-hosted vLLM recipe) (#3896)
* feat(annotate): WGO-tuned subtask prompt (atomic completed-events + duration prior)
Rework the plan-module subtask segmentation prompt toward the WGO-Bench
atomic annotation protocol: segment by completed world-state changes
(grasp/place/open/close/pour/insert), fold approach+retreat into their
event, keep separate events separate, and add a 2-10s duration prior.
Drops the pi0.7 "fewer larger composites preferred" bias that drove
under-segmentation on the benchmark. Output JSON shape unchanged.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(annotate): seeded-relabeling second pass for subtasks
Add an opt-in relabel pass (plan.subtask_seeded_relabel) that, after
segmentation, re-labels each span using previous/current/next segment
contact sheets and the seed label as a strong prior, minimally correcting
it. Mirrors macrodata's best end-to-end labeling step. Boundaries are
untouched; one extra VLM call per span. Off by default.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(annotate): robust OpenAI-compat client for hosted VLMs
Guard against a choice with no message (safety filter or a thinking model
that spends its whole budget before emitting content) so one empty reply
no longer crashes the whole annotation run; treat it as an empty response
and let the existing JSON-retry path handle it.
Add an optional `reasoning_effort` knob on VlmConfig, forwarded to the
server when set, to cap a thinking model's reasoning (needed for Gemini
via its OpenAI-compatible endpoint).
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(annotate): legible tile-scaled timestamp on contact sheets
The burned-in timestamp used the ~10px bitmap default font, which blurs
once the model downsamples a full contact sheet into 768px tiles, so the
VLM can no longer read the exact source time a boundary depends on. Scale
the timestamp to the tile height (with a graceful fallback on older
Pillow) so the visual time cue stays readable at sheet resolution.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(annotate): lean GEPA-aligned subtask segmentation prompt
Replace the verbose, label-heavy segmentation prompt with a lean
adaptation of the blog's GEPA-found completed_events_duration_prior
recipe: focus on completed manipulation events, explicit no-split /
no-merge rules, a 2-10s duration prior, and an instruction to prioritize
temporally correct boundaries over label wording. The previous prompt
over-weighted label guidance, which traded away boundary precision.
Co-authored-by: Cursor <cursoragent@cursor.com>
* revert: restore original subtask segmentation prompt
The lean GEPA-aligned paraphrase (dd4b0110d) regressed Gemini on the
30-ep subset: Seg F1 0.259 -> 0.189 and E2E 0.184 -> 0.135, driven by
worse under-segmentation (224 -> 188 preds). The blog's 0.306 came from
the actual GEPA-search artifact, which a hand paraphrase does not
reproduce. Restore the original prompt, which remains our best config.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(annotate): env-var override for prompt templates
Allow LEROBOT_PROMPT_OVERRIDE_<name> to supersede the packaged prompt
file at load time. Enables prompt search (GEPA) to inject candidate
segmentation prompts into a remote annotate job via an env secret,
without committing a branch per candidate.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(annotate): genericize hosted-VLM comments (no model name)
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(annotate): document seeded-relabel and reasoning_effort flags
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(annotate): update subtask-prompt marker to match WGO-tuned prompt
The three plan-module tests keyed the canned VLM responder on the
literal 'atomic subtasks', which the WGO-tuned segmentation prompt no
longer contains (it now segments 'COMPLETED manipulation events'). Point
the fixture markers at the current wording so the subtask call is matched
again.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -81,6 +81,12 @@ merged. Both prompts also carry a causal **event-boundary** definition (a
|
|||||||
new event starts when an object becomes held / is released / reaches a new
|
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.
|
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
|
The resulting spans are then stitched into a gap-free, full-episode
|
||||||
cover, so **every frame has exactly one active subtask**. See
|
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)
|
||||||
@@ -158,7 +164,7 @@ Every module is on by default and can be toggled independently (set to
|
|||||||
### The VLM (`--vlm.*`)
|
### The VLM (`--vlm.*`)
|
||||||
|
|
||||||
| Flag | Default | What it does |
|
| Flag | Default | What it does |
|
||||||
| -------------------------- | ------------------ | ----------------------------------------------------------------------------------- |
|
| -------------------------- | ------------------ | ------------------------------------------------------------------------------------ |
|
||||||
| `--vlm.model_id` | `Qwen/Qwen3.6-27B` | The model to serve and prompt. |
|
| `--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.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.serve_command` | auto | The exact `vllm serve …` command (set TP size, GPU memory, `--max-model-len` here). |
|
||||||
@@ -167,16 +173,19 @@ Every module is on by default and can be toggled independently (set to
|
|||||||
| `--vlm.client_concurrency` | `16` | In-flight requests across all servers. |
|
| `--vlm.client_concurrency` | `16` | In-flight requests across all servers. |
|
||||||
| `--vlm.max_new_tokens` | `512` | Generation cap per call. |
|
| `--vlm.max_new_tokens` | `512` | Generation cap per call. |
|
||||||
| `--vlm.temperature` | `0.2` | Sampling temperature. |
|
| `--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.*`)
|
### Subtasks / plan / memory (`--plan.*`)
|
||||||
|
|
||||||
| Flag | Default | What it does |
|
| 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.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.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.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.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_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_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.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.n_task_rephrasings` | `10` | How many `task_aug` rephrasings to emit (`0` disables). |
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ class PlanConfig:
|
|||||||
# invented from the task text (+1 VLM call/episode).
|
# invented from the task text (+1 VLM call/episode).
|
||||||
subtask_describe_first: bool = True
|
subtask_describe_first: bool = True
|
||||||
|
|
||||||
|
# Seeded relabeling: after segmentation, re-label each span with a focused
|
||||||
|
# pass that sees the previous / current / next segment contact sheets and
|
||||||
|
# minimally corrects the seed label (macrodata's best end-to-end labeling
|
||||||
|
# step). Costs +1 VLM call per subtask; off by default.
|
||||||
|
subtask_seeded_relabel: bool = False
|
||||||
|
# Frames sampled uniformly per segment sheet in the relabel pass.
|
||||||
|
subtask_relabel_frames: int = 5
|
||||||
|
|
||||||
# Emit ``style="plan"`` rows at each boundary; False = subtasks + memory only.
|
# Emit ``style="plan"`` rows at each boundary; False = subtasks + memory only.
|
||||||
emit_plan: bool = True
|
emit_plan: bool = True
|
||||||
|
|
||||||
@@ -160,6 +168,11 @@ class VlmConfig:
|
|||||||
# Forwarded as extra_body.chat_template_kwargs (e.g. {"enable_thinking": false}).
|
# Forwarded as extra_body.chat_template_kwargs (e.g. {"enable_thinking": false}).
|
||||||
chat_template_kwargs: dict[str, Any] | None = None
|
chat_template_kwargs: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
# OpenAI-style thinking budget hint ("low"/"medium"/"high"); forwarded to
|
||||||
|
# the server when set. Used to cap a thinking model's reasoning so it
|
||||||
|
# leaves tokens for the actual JSON answer on OpenAI-compatible endpoints.
|
||||||
|
reasoning_effort: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ExecutorConfig:
|
class ExecutorConfig:
|
||||||
|
|||||||
@@ -413,6 +413,15 @@ def _draw_timestamp_badge(image: PIL.Image.Image, timestamp: float) -> PIL.Image
|
|||||||
|
|
||||||
result = image.copy()
|
result = image.copy()
|
||||||
draw = ImageDraw.Draw(result)
|
draw = ImageDraw.Draw(result)
|
||||||
|
# Scale the timestamp to the tile so it stays legible after the model
|
||||||
|
# downsamples the full sheet into 768px tiles — a tiny bitmap font blurs
|
||||||
|
# at contact-sheet resolution and the VLM can no longer read the exact
|
||||||
|
# source time, which is what the boundary score depends on. ``size=`` is
|
||||||
|
# supported by Pillow's bitmap default since 10.1; fall back otherwise.
|
||||||
|
badge_px = max(14, round(image.height * 0.12))
|
||||||
|
try:
|
||||||
|
font = ImageFont.load_default(size=badge_px)
|
||||||
|
except TypeError:
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
label = f"{timestamp:06.2f}s"
|
label = f"{timestamp:06.2f}s"
|
||||||
left, top, right, bottom = draw.textbbox((0, 0), label, font=font)
|
left, top, right, bottom = draw.textbbox((0, 0), label, font=font)
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ class PlanSubtasksMemoryModule:
|
|||||||
rows.extend(self._task_aug_rows([effective_task, *variants], t0))
|
rows.extend(self._task_aug_rows([effective_task, *variants], t0))
|
||||||
|
|
||||||
subtask_spans = self._generate_subtasks(record, task=effective_task)
|
subtask_spans = self._generate_subtasks(record, task=effective_task)
|
||||||
|
if self.config.subtask_seeded_relabel and subtask_spans:
|
||||||
|
subtask_spans = self._seeded_relabel(record, subtask_spans, effective_task)
|
||||||
|
|
||||||
# subtask rows
|
# subtask rows
|
||||||
for span in subtask_spans:
|
for span in subtask_spans:
|
||||||
@@ -509,6 +511,51 @@ class PlanSubtasksMemoryModule:
|
|||||||
|
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
def _seeded_relabel(
|
||||||
|
self, record: EpisodeRecord, spans: list[dict[str, Any]], task: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Re-label each span using prev/current/next segment contact sheets.
|
||||||
|
|
||||||
|
Boundaries are kept fixed; only ``text`` is refined. The original
|
||||||
|
("seed") label is passed as a strong prior so the model verifies and
|
||||||
|
minimally corrects it rather than re-describing from scratch — the
|
||||||
|
macrodata seeded-relabeling step. One VLM call per span.
|
||||||
|
"""
|
||||||
|
n = len(spans)
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for i, span in enumerate(spans):
|
||||||
|
content: list[dict[str, Any]] = []
|
||||||
|
if i > 0:
|
||||||
|
content += self._segment_sheet(record, spans[i - 1])
|
||||||
|
content += self._segment_sheet(record, span)
|
||||||
|
if i < n - 1:
|
||||||
|
content += self._segment_sheet(record, spans[i + 1])
|
||||||
|
prompt = load_prompt("plan_subtask_relabel").format(
|
||||||
|
episode_task=task,
|
||||||
|
seed_label=span["text"],
|
||||||
|
segment_index=i + 1,
|
||||||
|
segment_count=n,
|
||||||
|
start=float(span["start"]),
|
||||||
|
end=float(span["end"]),
|
||||||
|
)
|
||||||
|
content.append({"type": "text", "text": prompt})
|
||||||
|
label = self._vlm_field([{"role": "user", "content": content}], "label")
|
||||||
|
text = label.strip() if isinstance(label, str) and label.strip() else span["text"]
|
||||||
|
out.append({**span, "text": text})
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _segment_sheet(self, record: EpisodeRecord, span: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
"""Contact-sheet block(s) for one span: up to N frames sampled uniformly."""
|
||||||
|
s, e = float(span["start"]), float(span["end"])
|
||||||
|
n = max(1, int(self.config.subtask_relabel_frames))
|
||||||
|
if e <= s or n == 1:
|
||||||
|
timestamps = [s]
|
||||||
|
else:
|
||||||
|
step = (e - s) / (n - 1)
|
||||||
|
timestamps = [s + i * step for i in range(n)]
|
||||||
|
frames = self.frame_provider.frames_at(record, timestamps)
|
||||||
|
return self._contact_sheet_blocks(frames, timestamps[: len(frames)])
|
||||||
|
|
||||||
def _generate_subtasks_windowed(
|
def _generate_subtasks_windowed(
|
||||||
self, record: EpisodeRecord, task: str, window_s: float
|
self, record: EpisodeRecord, task: str, window_s: float
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -22,12 +22,23 @@ plain editors and roundtrip cleanly through ``ruff format``.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_DIR = Path(__file__).parent
|
_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
def load(name: str) -> str:
|
def load(name: str) -> str:
|
||||||
"""Read prompt template ``name.txt`` from the ``prompts/`` directory."""
|
"""Read prompt template ``name.txt`` from the ``prompts/`` directory.
|
||||||
|
|
||||||
|
A ``LEROBOT_PROMPT_OVERRIDE_<name>`` environment variable, when set to a
|
||||||
|
non-empty value, takes precedence over the packaged file. This lets prompt
|
||||||
|
search (e.g. GEPA) inject candidate templates into a remote job without
|
||||||
|
rebuilding the package; the override must keep the same ``{placeholder}``
|
||||||
|
fields the call site formats in.
|
||||||
|
"""
|
||||||
|
override = os.environ.get(f"LEROBOT_PROMPT_OVERRIDE_{name}")
|
||||||
|
if override and override.strip():
|
||||||
|
return override
|
||||||
path = _DIR / f"{name}.txt"
|
path = _DIR / f"{name}.txt"
|
||||||
return path.read_text(encoding="utf-8")
|
return path.read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
Annotate one fixed segment from a longer robot demonstration.
|
||||||
|
|
||||||
|
Return only JSON:
|
||||||
|
{{"label": "<short descriptive subtask label>"}}
|
||||||
|
|
||||||
|
You are shown up to three timestamped contact sheets, in order:
|
||||||
|
- The FIRST sheet is the PREVIOUS segment (context only); it may be absent.
|
||||||
|
- The SECOND sheet is the CURRENT target segment.
|
||||||
|
- The THIRD sheet is the NEXT segment (context only); it may be absent.
|
||||||
|
Each tile has its timestamp (seconds, absolute video time) burned into its
|
||||||
|
top-left corner.
|
||||||
|
|
||||||
|
Episode instruction: "{episode_task}"
|
||||||
|
Target segment: {segment_index} of {segment_count}
|
||||||
|
Target time: {start:.2f}s to {end:.2f}s
|
||||||
|
Original predicted label for this exact segment: "{seed_label}"
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Label ONLY the current target segment (the second sheet). Use the
|
||||||
|
previous/next sheets only to disambiguate what changed.
|
||||||
|
- Treat the original predicted label as a STRONG PRIOR, not ground truth:
|
||||||
|
verify it against the current segment and correct it minimally.
|
||||||
|
- If it already names the right action and main object, keep it; only fix
|
||||||
|
grammar or add a clearly visible essential detail.
|
||||||
|
- If it is vague but directionally correct, make it more specific.
|
||||||
|
- If it describes the previous/next segment, the wrong action, wrong
|
||||||
|
object, wrong destination, or a wrong state change, replace it.
|
||||||
|
- Do not describe the previous or next segment, and do not split, merge,
|
||||||
|
or move the fixed segment.
|
||||||
|
- Do not introduce an action that is not clearly visible in the current
|
||||||
|
target segment.
|
||||||
|
- Use one concise imperative phrase. Name the manipulated object and the
|
||||||
|
action / state change. Include source, destination, side, direction,
|
||||||
|
final placement, or opened/closed state when visible and central.
|
||||||
|
- Do not mention timestamps, frame numbers, uncertainty, or intent.
|
||||||
@@ -1,112 +1,68 @@
|
|||||||
You are labeling a teleoperated robot demonstration.
|
You are annotating a teleoperated robot demonstration shown as
|
||||||
|
timestamped contact sheets (each tile has its time in seconds burned
|
||||||
|
into the top-left corner). The operator's goal was: "{episode_task}"
|
||||||
|
|
||||||
The user originally asked: "{episode_task}"
|
{observation_block}Reconstruct the sequence of COMPLETED manipulation events the robot
|
||||||
|
performs, in chronological order. Output one segment per event with a
|
||||||
|
[start, end] time in seconds and a short action label.
|
||||||
|
|
||||||
You are shown the entire demonstration as a single video. Watch the
|
GROUNDING — read first, it overrides everything below:
|
||||||
whole clip, then segment it into a list of consecutive atomic subtasks
|
- Label ONLY events you can SEE in the frames. The instruction is the
|
||||||
the robot performs.
|
goal; the VIDEO is the ground truth for what actually happened.
|
||||||
|
- Do NOT invent, anticipate, or pad steps that are not shown.
|
||||||
|
|
||||||
{observation_block}GROUNDING — read this first, it overrides everything below:
|
Granularity — segment by completed events, not by motion:
|
||||||
- Label ONLY what the robot actually does in the video. Every subtask
|
- Start a NEW segment whenever the world state changes: an object is
|
||||||
you emit must correspond to motion you can SEE in specific frames.
|
grasped, lifted, transported, placed, or released; a held object
|
||||||
- Do NOT invent, anticipate, or pad. If the robot only does one thing
|
changes; a drawer/door/lid/container opens or closes; contents move
|
||||||
(e.g. it just navigates to a location and the clip ends), emit
|
between containers (poured); a tool starts or stops acting on a
|
||||||
EXACTLY ONE subtask. Many demonstrations are a single atomic skill.
|
surface. Watch the gripper open/close transitions — they usually mark
|
||||||
- ``max_steps`` below is a hard CEILING, not a target. Emitting fewer
|
boundaries.
|
||||||
subtasks than the ceiling is not just allowed, it is expected for
|
- Do NOT split approach, reach, grasp adjustment, small repositioning,
|
||||||
short / atomic demonstrations. One correct subtask is far better
|
hesitation, or retreat into their own segments. Fold each into the
|
||||||
than several invented ones.
|
event it belongs to (the approach is part of the pick; the retreat is
|
||||||
- If the video does not clearly show the action implied by the task,
|
part of the place).
|
||||||
describe what you actually see — do NOT fabricate the task's steps
|
- Do NOT merge separate completed events. Each distinct pick, place,
|
||||||
from the instruction text. The instruction tells you the goal; the
|
open, close, pour, push, wipe, or insert is its own segment, even when
|
||||||
VIDEO is the ground truth for what happened.
|
they repeat on different objects or locations.
|
||||||
|
- Most segments last 2-10 seconds. Shorter segments are okay ONLY for
|
||||||
|
fast pick / place / open / close / release events. Never emit a
|
||||||
|
segment shorter than {min_subtask_seconds} seconds; merge a too-short
|
||||||
|
candidate into its neighbour instead.
|
||||||
|
- Skip idle time, pure camera motion, and tiny hand jitter.
|
||||||
|
|
||||||
Authoring rules — Hi Robot atom granularity, pi0.7-style short prompts:
|
Labels — short imperative phrases:
|
||||||
|
- One concise command naming the action and the manipulated object, e.g.
|
||||||
|
"pick up the red cup", "put the cup on the shelf", "open the top
|
||||||
|
drawer", "pour water into the glass", "insert the plug into the
|
||||||
|
socket".
|
||||||
|
- Include source, destination, side, direction, or the final
|
||||||
|
open/closed state when it is visible and central to the event.
|
||||||
|
- Prefer these verbs (extend only when none fits): pick up, put, place,
|
||||||
|
push, pull, turn, press, open, close, pour, insert, wipe, stack.
|
||||||
|
Disambiguate by what you SEE:
|
||||||
|
* STACK vs PUT: object placed ON TOP OF another object -> "stack".
|
||||||
|
* INSERT vs PUT: object pushed INTO a fitted slot/hole/socket -> "insert".
|
||||||
|
* PICK UP vs PUT (direction): gripper CLOSES and object moves WITH
|
||||||
|
the hand -> "pick up"; gripper OPENS and object stays -> "put".
|
||||||
|
* POUR vs PUT: source is tilted and contents flow -> "pour".
|
||||||
|
- Use the exact object nouns implied by the task; stay consistent across
|
||||||
|
the episode (don't switch "cube" to "block").
|
||||||
|
- Write imperative commands, never third person ("the robot ..."), and
|
||||||
|
drop articles/adverbs.
|
||||||
|
|
||||||
- Each subtask = one COMPOSITE atomic skill the low-level policy can
|
Timing:
|
||||||
execute end-to-end. A "skill" bundles its own approach motion with
|
- Use the burned-in timestamps to set start and end. Boundaries should
|
||||||
its terminal action — do NOT split the approach off as its own
|
land on or near a printed time, and every [start, end] must lie within
|
||||||
subtask. The whole-arm policy already learns to reach as part of
|
[0.0, {episode_duration}] seconds, be non-overlapping, and cover the
|
||||||
every manipulation primitive.
|
episode in order.
|
||||||
- Write each subtask as an IMPERATIVE COMMAND, starting with one of
|
- Emit at most {max_steps} segments.
|
||||||
these verbs (extend only when none fits):
|
|
||||||
pick up <obj> — approach + grasp + lift in one subtask
|
|
||||||
put <obj> on/in <loc> — transport + release in one subtask
|
|
||||||
place <obj> on/in <loc> — synonym of "put"; pick one and stay consistent
|
|
||||||
push <obj> — contact + linear shove
|
|
||||||
pull <obj> — contact + linear retract
|
|
||||||
turn <knob/dial/handle> — rotary actuation
|
|
||||||
press <button> — single-press contact
|
|
||||||
open <drawer/door/lid> — full open motion
|
|
||||||
close <drawer/door/lid> — full close motion
|
|
||||||
pour <src> into <dst> — tilt + flow
|
|
||||||
insert <obj> into <slot>— alignment + push-fit
|
|
||||||
go to <loc> — ONLY when no grasp / actuation follows
|
|
||||||
(e.g. a pure relocation between phases).
|
|
||||||
If the next subtask grasps something at
|
|
||||||
that location, drop "go to ..." and just
|
|
||||||
write "pick up ..." instead.
|
|
||||||
- Forbidden ultra-fine splits — the VLM is NOT allowed to emit these
|
|
||||||
as standalone subtasks; fold them into the parent composite:
|
|
||||||
"move to X" → fold into "pick up X" (or whatever follows)
|
|
||||||
"reach for X" → fold into "pick up X"
|
|
||||||
"grasp X" → fold into "pick up X"
|
|
||||||
"lift X" → fold into "pick up X" (or "put X on Y" if it's
|
|
||||||
the transport phase of a place)
|
|
||||||
"release X" → fold into "put X on Y" (or "place X in Y")
|
|
||||||
- Keep it SHORT — a verb phrase, not a sentence. Drop articles
|
|
||||||
("the", "a") and adverbs ("carefully", "slowly"). Add a "how"
|
|
||||||
detail (which hand, which grasp point) ONLY when it is needed to
|
|
||||||
disambiguate. Every subtask must begin with one of the verbs
|
|
||||||
above (no leading nouns, no "then", no "first").
|
|
||||||
- NEVER use third person. Never write "the robot", "the arm", "the
|
|
||||||
gripper moves", "it picks up" — the robot is implied. Command it,
|
|
||||||
do not describe it.
|
|
||||||
- Use the exact object nouns from the task above. If the task says
|
|
||||||
"cube", every subtask says "cube" — never switch to "block". If it
|
|
||||||
says "box", never switch to "bin"/"container". Keep vocabulary
|
|
||||||
consistent across the whole episode.
|
|
||||||
- Good: "pick up blue cube", "put blue cube in box", "open drawer",
|
|
||||||
"turn red knob", "press start button", "go to sink".
|
|
||||||
- Bad: "move to blue cube" (approach as its own subtask — forbidden,
|
|
||||||
must be folded into "pick up blue cube"); "the robot arm moves
|
|
||||||
towards the blue cube" (third person, too long); "carefully pick
|
|
||||||
up the cube" (adverb, article); "release the yellow block"
|
|
||||||
("block" when the task said "cube", and "release" must be folded
|
|
||||||
into a "put"/"place" subtask).
|
|
||||||
- Subtasks are non-overlapping and cover the full episode in order.
|
|
||||||
Choose the cut points yourself based on what you see in the video
|
|
||||||
(gripper open/close events, contact, regrasps, transitions).
|
|
||||||
- Each subtask spans at least {min_subtask_seconds} seconds. If a
|
|
||||||
candidate span would be shorter, merge it into its neighbour
|
|
||||||
rather than emitting it.
|
|
||||||
- Do not exceed {max_steps} subtasks total. Fewer, larger composites
|
|
||||||
are preferred over many micro-steps.
|
|
||||||
- Every subtask's [start_time, end_time] must lie within
|
|
||||||
[0.0, {episode_duration}] seconds.
|
|
||||||
|
|
||||||
SPECIAL CASES — verb disambiguation (each rule is narrowly visual and
|
|
||||||
fires ONLY on the spatial situation it names; it must not change how you
|
|
||||||
label any other situation):
|
|
||||||
- STACK vs PUT: if an object is placed ON TOP OF another specific object
|
|
||||||
(not on a flat table / shelf / counter), use "stack ... on ...", not
|
|
||||||
"put". "stack blue book on green book", NOT "put blue book on table".
|
|
||||||
- INSERT vs PUT: if an object goes INTO a fitted slot / hole / socket /
|
|
||||||
receptacle (push-fit), use "insert ... into ...", not "put".
|
|
||||||
- RETRIEVE/PICK-UP vs PUT (direction): watch the gripper. If it CLOSES
|
|
||||||
on the object and the object moves WITH the hand, it is "pick up" /
|
|
||||||
"retrieve" (object leaves its location). If the gripper OPENS and the
|
|
||||||
object stays where the hand left it, it is "put" / "place" (object
|
|
||||||
arrives at a location). Decide by which way the object moves, not by
|
|
||||||
where the hand ends up.
|
|
||||||
- POUR vs PUT: only use "pour" when the source is tilted and contents
|
|
||||||
flow out; moving a full container without tilting is "put"/"place".
|
|
||||||
|
|
||||||
Output strictly valid JSON of shape:
|
Output strictly valid JSON of shape:
|
||||||
|
|
||||||
{{
|
{{
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{{"text": "<short imperative verb phrase>", "start": <float>, "end": <float>}},
|
{{"text": "<short imperative action label>", "start": <float>, "end": <float>}},
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -285,6 +285,8 @@ def _make_openai_client(config: VlmConfig) -> VlmClient:
|
|||||||
"max_tokens": max_tok,
|
"max_tokens": max_tok,
|
||||||
"temperature": temp,
|
"temperature": temp,
|
||||||
}
|
}
|
||||||
|
if config.reasoning_effort:
|
||||||
|
kwargs["reasoning_effort"] = config.reasoning_effort
|
||||||
extra_body: dict[str, Any] = {}
|
extra_body: dict[str, Any] = {}
|
||||||
if send_mm_kwargs and mm_kwargs:
|
if send_mm_kwargs and mm_kwargs:
|
||||||
extra_body["mm_processor_kwargs"] = {**mm_kwargs, "do_sample_frames": True}
|
extra_body["mm_processor_kwargs"] = {**mm_kwargs, "do_sample_frames": True}
|
||||||
@@ -296,7 +298,13 @@ def _make_openai_client(config: VlmConfig) -> VlmClient:
|
|||||||
chosen = clients[rr_counter["i"] % len(clients)]
|
chosen = clients[rr_counter["i"] % len(clients)]
|
||||||
rr_counter["i"] += 1
|
rr_counter["i"] += 1
|
||||||
response = chosen.chat.completions.create(**kwargs)
|
response = chosen.chat.completions.create(**kwargs)
|
||||||
return response.choices[0].message.content or ""
|
# Some OpenAI-compatible servers can return a choice with no message
|
||||||
|
# (safety filter, or a "thinking" model that spends the whole budget
|
||||||
|
# before emitting content). Treat that as an empty reply so the
|
||||||
|
# JSON-retry path handles it instead of crashing the run.
|
||||||
|
choice = response.choices[0] if response.choices else None
|
||||||
|
message = choice.message if choice is not None else None
|
||||||
|
return (message.content if message is not None else None) or ""
|
||||||
|
|
||||||
def _gen(batch: Sequence[Sequence[dict[str, Any]]], max_tok: int, temp: float) -> list[str]:
|
def _gen(batch: Sequence[Sequence[dict[str, Any]]], max_tok: int, temp: float) -> list[str]:
|
||||||
if len(batch) <= 1 or config.client_concurrency <= 1:
|
if len(batch) <= 1 or config.client_concurrency <= 1:
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ def _spy_responder(captured: list[list[dict[str, Any]]], reply: Any):
|
|||||||
def test_module1_plan_memory_subtask_smoke(fixture_dataset_root: Path, tmp_path: Path) -> None:
|
def test_module1_plan_memory_subtask_smoke(fixture_dataset_root: Path, tmp_path: Path) -> None:
|
||||||
vlm = make_canned_responder(
|
vlm = make_canned_responder(
|
||||||
{
|
{
|
||||||
"atomic subtasks": {
|
"COMPLETED manipulation events": {
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{"text": "grasp the handle of the sponge", "start": 0.0, "end": 0.4},
|
{"text": "grasp the handle of the sponge", "start": 0.0, "end": 0.4},
|
||||||
{"text": "wipe the counter from left to right", "start": 0.4, "end": 0.8},
|
{"text": "wipe the counter from left to right", "start": 0.4, "end": 0.8},
|
||||||
@@ -126,7 +126,7 @@ def test_module1_emit_memory_false_skips_memory_keeps_subtasks_and_plan(
|
|||||||
leaving subtask + plan generation intact — symmetric to ``emit_plan``."""
|
leaving subtask + plan generation intact — symmetric to ``emit_plan``."""
|
||||||
vlm = make_canned_responder(
|
vlm = make_canned_responder(
|
||||||
{
|
{
|
||||||
"atomic subtasks": {
|
"COMPLETED manipulation events": {
|
||||||
"subtasks": [
|
"subtasks": [
|
||||||
{"text": "grasp the handle of the sponge", "start": 0.0, "end": 0.4},
|
{"text": "grasp the handle of the sponge", "start": 0.0, "end": 0.4},
|
||||||
{"text": "wipe the counter from left to right", "start": 0.4, "end": 0.8},
|
{"text": "wipe the counter from left to right", "start": 0.4, "end": 0.8},
|
||||||
@@ -318,7 +318,7 @@ def test_module1_attaches_contact_sheets_to_subtask_prompt(
|
|||||||
return block.get("text", "")
|
return block.get("text", "")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
subtask_calls = [m for m in captured if "atomic subtasks" in _prompt_text(m)]
|
subtask_calls = [m for m in captured if "COMPLETED manipulation events" in _prompt_text(m)]
|
||||||
assert len(subtask_calls) == 1, "expected exactly one subtask-prompt VLM call"
|
assert len(subtask_calls) == 1, "expected exactly one subtask-prompt VLM call"
|
||||||
content = subtask_calls[0][0]["content"]
|
content = subtask_calls[0][0]["content"]
|
||||||
video_blocks = [b for b in content if isinstance(b, dict) and b.get("type") == "video"]
|
video_blocks = [b for b in content if isinstance(b, dict) and b.get("type") == "video"]
|
||||||
|
|||||||
Reference in New Issue
Block a user