Compare commits

...

3 Commits

Author SHA1 Message Date
Pepijn bd9bd1575b feat(runtime): add grounded VQA command 2026-07-30 11:42:12 +02:00
Pepijn f2b90e3ad6 feat(runtime): add interactive language rollouts 2026-07-28 10:05:27 +02:00
Pepijn 3f093d8927 feat(data): add recipe-driven language supervision 2026-07-28 10:04:28 +02:00
42 changed files with 3915 additions and 106 deletions
+156
View File
@@ -191,6 +191,162 @@ def make_my_policy_pre_post_processors(
--- ---
## Adding high- and low-level language control
The policy API above is sufficient for training and standard evaluation. To use a language-conditioned policy with interactive `lerobot-rollout`, also register a runtime adapter. The adapter keeps policy-specific prompting and tokenization out of the generic control loop.
The runtime supports two policy shapes:
| Policy shape | Behavior | Adapter |
| ---------------- | ----------------------------------------------------------------------- | ---------------------------------------------- |
| Low-level / flat | The operator's task or subtask directly conditions action prediction. | Reuse `DirectTaskPolicyAdapter`. |
| High + low level | The policy generates subtasks or memory, then conditions actions on it. | Subclass `BaseLanguageAdapter`, as PI052 does. |
During a rollout, `RuntimeState` stores the high-level task and the active language context:
```text
task ──> adapter.generate_text("subtask", ...) ──> state.language_context["subtask"]
observation ──> processors ──> adapter.select_action() ─┴─> action chunk ──> robot
```
The generic runtime handles generation frequency, pause/resume, prompt replacement, action queues, and dispatch. The adapter only translates between that runtime contract and your policy.
### Low-level policies
If your policy already consumes the live task through its normal preprocessor and implements `predict_action_chunk`, register the shared direct adapter. PI0.5 and MolmoAct2 use this path:
```python
# src/lerobot/runtime/registry.py
_ADAPTERS = {
# ...
"my_policy": "lerobot.runtime.adapter:DirectTaskPolicyAdapter",
}
```
Run it with direct-subtask mode so the operator supplies the instruction used by the action policy:
```bash
lerobot-rollout \
--language \
--policy.path=user/my_policy_checkpoint \
--robot.type=so101_follower \
--robot.port=/dev/ttyACM0 \
--direct_subtask
```
The rollout context builds the observation batch with the current instruction before `DirectTaskPolicyAdapter` calls `policy.predict_action_chunk(observation)`. No text-generation method is required.
### Hierarchical policies
For a policy that generates language and actions, subclass [`BaseLanguageAdapter`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/runtime/adapter.py) and implement two methods:
- `generate_text(kind, observation, state, user_text=None) -> str` generates a `subtask`, `memory`, or interjection response.
- `select_action(observation, state)` builds the low-level prompt from the active context and returns an action chunk.
This abbreviated adapter follows [`PI052PolicyAdapter`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/pi052/inference/pi052_adapter.py):
```python
# inference/my_policy_adapter.py
from typing import Any
from lerobot.runtime import RuntimeState
from lerobot.runtime.adapter import BaseLanguageAdapter
from lerobot.utils.constants import (
OBS_LANGUAGE_ATTENTION_MASK,
OBS_LANGUAGE_TOKENS,
)
class MyPolicyAdapter(BaseLanguageAdapter):
def select_action(self, observation: dict[str, Any], state: RuntimeState):
instruction = state.language_context.get("subtask") or state.task or ""
tokens, attention_mask = tokenize_instruction(instruction)
batch = dict(observation)
batch[OBS_LANGUAGE_TOKENS] = tokens
batch[OBS_LANGUAGE_ATTENTION_MASK] = attention_mask
return self.policy.predict_action_chunk(batch)
def generate_text(
self,
kind: str,
observation: dict[str, Any] | None,
state: RuntimeState,
user_text: str | None = None,
) -> str:
messages = self.build_messages(kind, state, user_text)
batch, tokenizer = tokenize_messages(messages, observation)
return self.policy.select_message(
batch,
tokenizer=tokenizer,
min_new_tokens=self.gen.min_new_tokens,
temperature=self.gen.temperature,
top_p=self.gen.top_p,
)
def build_messages(
self, kind: str, state: RuntimeState, user_text: str | None
) -> list[dict[str, str]]:
if kind == "subtask":
return [{"role": "user", "content": state.task or ""}]
if kind == "memory":
return [
{"role": "user", "content": state.task or ""},
{
"role": "user",
"content": f"Completed subtask: {state.extra.get('prior_subtask', '')}",
},
]
if kind == "interjection":
return [
{"role": "user", "content": state.task or ""},
{"role": "user", "content": user_text or ""},
]
raise ValueError(f"Unsupported text kind: {kind}")
```
`tokenize_instruction` and `tokenize_messages` are policy-specific helpers. They must reproduce the prompt format used during training; PI052, for example, adds the discretized robot state to its low-level subtask prompt and uses the same PaliGemma formatting for `select_message`.
`BaseLanguageAdapter` provides the default hierarchy: regenerate a subtask at action-chunk boundaries, update memory when the subtask changes, and handle user interjections. Override `_regenerate_context` only if your policy uses a different hierarchy.
Register the adapter with a lazy import so importing LeRobot does not load the model or its optional dependencies:
```python
# src/lerobot/runtime/registry.py
_ADAPTERS = {
# ...
"my_policy": "lerobot.policies.my_policy.inference.my_policy_adapter:MyPolicyAdapter",
}
```
The key must match the policy's registered type. Once registered, the same checkpoint works through the shared entry point:
```bash
lerobot-rollout \
--language \
--policy.path=user/my_hierarchical_checkpoint \
--robot.type=so101_follower \
--robot.port=/dev/ttyACM0 \
--task="put the cup in the sink"
```
For RoboCasa-compatible policies, replace the robot arguments with `--sim --sim.task=<task>`. Without `--direct_subtask`, the adapter generates the low-level subtask; with it, the operator bypasses high-level generation and supplies each subtask.
### Keep training and deployment aligned
The adapter is intentionally small, but its prompts are part of the model contract:
- Use the same tokenizer, role formatting, special tokens, image ordering, and state encoding as training.
- Condition `select_action` on `state.language_context["subtask"]`, falling back to `state.task` for direct or not-yet-generated prompts.
- Return a full action chunk from `select_action`; the runtime handles control-rate dispatch.
- Keep optional model dependencies inside lazy imports.
- Test adapter selection, generated-message routing, action-batch construction, and direct-subtask behavior with a lightweight fake policy.
PI052 is the complete in-tree reference: its [processor](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/pi052/processor_pi052.py) renders the training recipe, its [policy](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/pi052/modeling_pi052.py) exposes text and action generation, and its [adapter](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/pi052/inference/pi052_adapter.py) reconstructs those same prompts at deployment.
---
## Path A: Out-of-tree plugin ## Path A: Out-of-tree plugin
The fastest way to ship a policy: package it as a standalone Python distribution and install it alongside LeRobot. No PR required, you own the release cycle, and you can publish to PyPI under your own namespace. The fastest way to ship a policy: package it as a standalone Python distribution and install it alongside LeRobot. No PR required, you own the release cycle, and you can publish to PyPI under your own namespace.
+47 -1
View File
@@ -1,6 +1,6 @@
# Policy Deployment (lerobot-rollout) # Policy Deployment (lerobot-rollout)
`lerobot-rollout` is the single CLI for deploying trained policies on real robots. It supports multiple execution strategies and inference backends, from quick evaluation to continuous recording and human-in-the-loop data collection. `lerobot-rollout` is the single CLI for deploying trained policies on real robots or in an interactive simulator. It supports multiple execution strategies and inference backends, from quick evaluation to continuous recording, language-driven control, and human-in-the-loop data collection.
## Quick Start ## Quick Start
@@ -197,6 +197,52 @@ Teleop is optional — if omitted the robot holds its position during the reset
--- ---
## Interactive language control
Language-conditioned policies can expose a high-level text head in addition to
their action head. Add `--language` to open-prompt one of these policies on a
real robot. Language-only flags such as `--direct_subtask` select this mode
automatically.
MolmoAct2 has no high-level planner, so use direct-subtask mode and type each
next low-level instruction yourself:
```bash
lerobot-rollout \
--policy.path=lerobot/MolmoAct2-SO100_101-LeRobot \
--policy.device=cuda \
--robot.type=so101_follower \
--robot.port=/dev/ttyACM1 \
--robot.cameras='{"cam0":{"type":"opencv","index_or_path":"/dev/video0","width":640,"height":480,"fps":30,"fourcc":"MJPG","backend":200},"cam1":{"type":"opencv","index_or_path":"/dev/video2","width":640,"height":480,"fps":30,"fourcc":"MJPG","backend":200}}' \
--direct_subtask \
--robot.max_relative_target='{"shoulder_pan":5,"shoulder_lift":5,"elbow_flex":5,"wrist_flex":5,"wrist_roll":5,"gripper":5}'
```
The robot starts paused. Type a subtask, then use `/resume` and `/pause` to
control action dispatch. Check the workspace and motion limits before resuming.
Without `--direct_subtask`, a policy such as PI052 generates its active subtask
from the high-level `--task` itself.
RoboCasa uses the same runtime and processor path. `--sim` selects it
automatically, so no robot configuration is needed:
```bash
MUJOCO_GL=egl lerobot-rollout \
--policy.path=lerobot/pi052_robocasa \
--sim --sim.task=CloseFridge --sim.split=pretrain \
--task="close the fridge" \
--disable_memory \
--sim.render_size=384 \
--sim.views=robot0_agentview_left,robot0_eye_in_hand,robot0_agentview_right \
--mode=action --ctrl_hz=20
```
Open `http://localhost:8010` for the live simulator view. Add
`--sim.direct_subtask` to bypass the language planner and make each typed prompt
the action policy's current subtask.
---
## Inference Backends ## Inference Backends
Select a backend with `--inference.type=<name>`. All strategies work with both backends. Select a backend with `--inference.type=<name>`. All strategies work with both backends.
+11
View File
@@ -141,6 +141,17 @@ sample["target_message_indices"]
The renderer does not apply a tokenizer chat template. Policy processors decide how to serialize the messages for their backbone, which keeps the same dataset usable across SmolVLA, Pi0.5, and any future VLM that expects OpenAI-style chat messages. The renderer does not apply a tokenizer chat template. Policy processors decide how to serialize the messages for their backbone, which keeps the same dataset usable across SmolVLA, Pi0.5, and any future VLM that expects OpenAI-style chat messages.
## Blends
Blend recipes select one weighted sub-recipe deterministically from the sample index.
`recipes/subtask_mem.yaml` trains the compact core blend — high-level subtask prediction, low-level execution, and memory. `recipes/subtask_mem_vqa_speech.yaml` is the fuller variant that also adds VQA and spoken interjection responses.
A message recipe with a supervised assistant turn on the `low_level` stream trains
the π0.5 paper's joint sequence instead of a blend: the target span gets text CE
while also conditioning the action losses in the same forward.
`recipes/subtask_joint.yaml` is the provided example; pair it with
`--policy.joint_subtask_conditioning=true` at inference.
## Graceful absence ## Graceful absence
If both language columns are missing, `None`, or empty, `RenderMessagesStep` is a no-op. If both language columns are missing, `None`, or empty, `RenderMessagesStep` is a no-op.
+6
View File
@@ -33,6 +33,8 @@ class DatasetConfig:
# looked up under $HF_LEROBOT_HOME/repo_id and Hub downloads use a revision-safe cache under $HF_LEROBOT_HOME/hub. # looked up under $HF_LEROBOT_HOME/repo_id and Hub downloads use a revision-safe cache under $HF_LEROBOT_HOME/hub.
root: str | None = None root: str | None = None
episodes: list[int] | None = None episodes: list[int] | None = None
# Episode indices to drop (e.g. corrupt or heterogeneous ones). Applied on top of `episodes`.
exclude_episodes: list[int] | None = None
image_transforms: ImageTransformsConfig = field(default_factory=ImageTransformsConfig) image_transforms: ImageTransformsConfig = field(default_factory=ImageTransformsConfig)
revision: str | None = None revision: str | None = None
use_imagenet_stats: bool = True use_imagenet_stats: bool = True
@@ -62,6 +64,10 @@ class DatasetConfig:
if len(self.episodes) != len(set(self.episodes)): if len(self.episodes) != len(set(self.episodes)):
duplicates = sorted({ep for ep in self.episodes if self.episodes.count(ep) > 1}) duplicates = sorted({ep for ep in self.episodes if self.episodes.count(ep) > 1})
raise ValueError(f"Episode indices contain duplicates: {duplicates}") raise ValueError(f"Episode indices contain duplicates: {duplicates}")
if self.exclude_episodes is not None and any(ep < 0 for ep in self.exclude_episodes):
raise ValueError(
f"exclude_episodes must be non-negative, got: {[ep for ep in self.exclude_episodes if ep < 0]}"
)
@dataclass @dataclass
+10 -4
View File
@@ -78,7 +78,7 @@ class MessageTurn:
raise ValueError(f"Unsupported message stream: {self.stream!r}") raise ValueError(f"Unsupported message stream: {self.stream!r}")
if self.content is None and self.tool_calls_from is None: if self.content is None and self.tool_calls_from is None:
raise ValueError("MessageTurn.content is required unless tool_calls_from is set.") raise ValueError("MessageTurn.content is required unless tool_calls_from is set.")
if self.content is not None and not isinstance(self.content, (str, list)): if self.content is not None and not isinstance(self.content, str | list):
raise TypeError("MessageTurn.content must be a string, a list of HF-style blocks, or None.") raise TypeError("MessageTurn.content must be a string, a list of HF-style blocks, or None.")
if isinstance(self.content, list): if isinstance(self.content, list):
for block in self.content: for block in self.content:
@@ -147,7 +147,7 @@ class TrainingRecipe:
return cls.from_dict(data) return cls.from_dict(data)
def _validate_message_recipe(self) -> None: def _validate_message_recipe(self) -> None:
"""Ensure every templated binding is known and at least one turn is a target.""" """Validate bindings and require text or low-level action supervision."""
assert self.messages is not None assert self.messages is not None
known_bindings = set(DEFAULT_BINDINGS) | set(self.bindings or {}) | {"task"} known_bindings = set(DEFAULT_BINDINGS) | set(self.bindings or {}) | {"task"}
@@ -156,8 +156,14 @@ class TrainingRecipe:
if missing: if missing:
raise ValueError(f"MessageTurn references unknown binding(s): {sorted(missing)}") raise ValueError(f"MessageTurn references unknown binding(s): {sorted(missing)}")
if not any(turn.target for turn in self.messages): has_target = any(turn.target for turn in self.messages)
raise ValueError("Message recipes must contain at least one target turn.") has_low_level = any(turn.stream == "low_level" for turn in self.messages)
if not (has_target or has_low_level):
raise ValueError(
"Message recipes must contain at least one supervised turn — "
"either ``target: true`` (text CE) or ``stream: low_level`` "
"(flow/action loss)."
)
def _validate_blend_recipe(self) -> None: def _validate_blend_recipe(self) -> None:
"""Ensure each blend component is a non-empty, weighted message recipe.""" """Ensure each blend component is a non-empty, weighted message recipe."""
+16
View File
@@ -0,0 +1,16 @@
# Predicts subtasks from tasks and trains subtask-conditioned action flow without memory or plans.
# Requires `subtask` annotations; samples with missing `if_present` bindings do not render.
blend:
high_level_subtask:
weight: 0.30
messages:
- {role: user, content: "${task}", stream: high_level}
- {role: assistant, content: "${subtask}", stream: high_level, target: true, if_present: subtask}
low_level_execution:
weight: 0.70
messages:
# The low-level stream trains action flow on the generated or annotated subtask.
- {role: user, content: "${subtask}", stream: low_level, if_present: subtask}
@@ -0,0 +1,13 @@
# Paper-style joint sequence (pi0.5 §IV-B): one sample supervises the subtask
# text with CE and, because the assistant turn is part of the prefix, conditions
# the FAST and flow action losses on the same annotated subtask in one forward.
# The supervised span is attended causally; the action losses see task + subtask.
#
# Pair with `--policy.joint_subtask_conditioning=true` at inference so the flow
# prefix reproduces this layout (task turn with state + causal generated subtask).
# Samples without a `subtask` annotation fall back to a plain task-prompt
# low-level sample via `if_present`.
messages:
- {role: user, content: "${task}", stream: low_level}
- {role: assistant, content: "${subtask}", stream: low_level, target: true, if_present: subtask}
@@ -0,0 +1,30 @@
# Trains subtask prediction, subtask-conditioned action flow, and memory updates without plans.
# Requires `subtask` and `memory`; missing `if_present` bindings skip the affected sub-recipe.
blend:
high_level_subtask:
weight: 0.25
messages:
- {role: user, content: "${task}", stream: high_level}
- {role: assistant, content: "${subtask}", stream: high_level, target: true, if_present: subtask}
low_level_execution:
weight: 0.60
messages:
# The low-level stream trains action flow on the generated or annotated subtask.
- {role: user, content: "${subtask}", stream: low_level, if_present: subtask}
memory_update:
# `active_at` densifies sparse boundaries while preserving the prior-memory/subtask mapping.
# Inference controls update timing through `subtask_change` events.
weight: 0.15
bindings:
prior_memory: "nth_prev(style=memory, offset=1)"
current_memory: "active_at(t, style=memory)"
completed_subtask: "nth_prev(style=subtask, offset=1)"
messages:
- {role: user, content: "${task}", stream: high_level}
- {role: assistant, content: "Previous memory: ${prior_memory}", stream: high_level, if_present: prior_memory}
- {role: user, content: "Completed subtask: ${completed_subtask}", stream: high_level, if_present: completed_subtask}
- {role: assistant, content: "${current_memory}", stream: high_level, target: true, if_present: current_memory}
@@ -0,0 +1,70 @@
# Adds memory, spoken interjection responses, and camera-grounded VQA to subtask/action training.
# Missing optional annotations skip only their sub-recipe; `say` tool calls tokenize as `<say>...</say>`.
blend:
high_level_subtask:
weight: 0.25
messages:
- {role: user, content: "${task}", stream: high_level}
- {role: assistant, content: "${subtask}", stream: high_level, target: true, if_present: subtask}
low_level_execution:
weight: 0.40
messages:
# The low-level stream trains action flow on the generated or annotated subtask.
- {role: user, content: "${subtask}", stream: low_level, if_present: subtask}
memory_update:
# `active_at` densifies sparse boundaries while preserving the prior-memory/subtask mapping.
# Inference controls update timing through `subtask_change` events.
weight: 0.10
bindings:
prior_memory: "nth_prev(style=memory, offset=1)"
current_memory: "active_at(t, style=memory)"
completed_subtask: "nth_prev(style=subtask, offset=1)"
messages:
- {role: user, content: "${task}", stream: high_level}
- {role: assistant, content: "Previous memory: ${prior_memory}", stream: high_level, if_present: prior_memory}
- {role: user, content: "Completed subtask: ${completed_subtask}", stream: high_level, if_present: completed_subtask}
- {role: assistant, content: "${current_memory}", stream: high_level, target: true, if_present: current_memory}
user_interjection_response:
weight: 0.10
bindings:
interjection: "emitted_at(t, style=interjection)"
speech: "emitted_at(t, role=assistant, tool_name=say)"
messages:
- {role: user, content: "${task}", stream: high_level}
- {role: user, content: "${interjection}", stream: high_level, if_present: interjection}
# The assistant target is a `say` tool call flattened to a `<say>...</say>` marker.
- {role: assistant, stream: high_level, target: true, if_present: speech, tool_calls_from: speech}
# Each camera uses a separate VQA sub-recipe for view-specific binding.
ask_vqa_top:
weight: 0.075
bindings:
vqa_query: "emitted_at(t, style=vqa, role=user, camera=observation.images.front)"
vqa: "emitted_at(t, style=vqa, role=assistant, camera=observation.images.front)"
messages:
- role: user
stream: high_level
if_present: vqa_query
content:
- {type: image, feature: observation.images.front}
- {type: text, text: "${vqa_query}"}
- {role: assistant, content: "${vqa}", stream: high_level, target: true, if_present: vqa}
ask_vqa_wrist:
weight: 0.075
bindings:
vqa_query: "emitted_at(t, style=vqa, role=user, camera=observation.images.wrist)"
vqa: "emitted_at(t, style=vqa, role=assistant, camera=observation.images.wrist)"
messages:
- role: user
stream: high_level
if_present: vqa_query
content:
- {type: image, feature: observation.images.wrist}
- {type: text, text: "${vqa_query}"}
- {role: assistant, content: "${vqa}", stream: high_level, target: true, if_present: vqa}
+30
View File
@@ -163,10 +163,40 @@ class DatasetReader:
def _load_hf_dataset(self) -> datasets.Dataset: def _load_hf_dataset(self) -> datasets.Dataset:
"""hf_dataset contains all the observations, states, actions, rewards, etc.""" """hf_dataset contains all the observations, states, actions, rewards, etc."""
features = get_hf_features_from_features(self._meta.features) features = get_hf_features_from_features(self._meta.features)
# Annotated datasets may have language columns absent from metadata.
# Extend the schema before the strict Parquet cast.
features = self._extend_features_with_language_columns(features)
hf_dataset = load_nested_dataset(self.root / "data", features=features, episodes=self.episodes) hf_dataset = load_nested_dataset(self.root / "data", features=features, episodes=self.episodes)
hf_dataset.set_transform(hf_transform_to_torch) hf_dataset.set_transform(hf_transform_to_torch)
return hf_dataset return hf_dataset
def _extend_features_with_language_columns(self, features: datasets.Features) -> datasets.Features:
"""Register language columns found in Parquet but missing from metadata."""
# Leave empty datasets to fail through the normal loading path.
try:
sample = next((self.root / "data").glob("*/*.parquet"))
except StopIteration:
return features
from pyarrow import parquet as _pq # noqa: PLC0415
schema_names = set(_pq.read_schema(sample).names)
from .language import ( # noqa: PLC0415
LANGUAGE_EVENTS,
LANGUAGE_PERSISTENT,
language_events_column_feature,
language_persistent_column_feature,
)
extra: dict[str, object] = {}
if LANGUAGE_PERSISTENT in schema_names and LANGUAGE_PERSISTENT not in features:
extra[LANGUAGE_PERSISTENT] = language_persistent_column_feature()
if LANGUAGE_EVENTS in schema_names and LANGUAGE_EVENTS not in features:
extra[LANGUAGE_EVENTS] = language_events_column_feature()
if not extra:
return features
return datasets.Features({**features, **extra})
def _check_cached_episodes_sufficient(self) -> bool: def _check_cached_episodes_sufficient(self) -> bool:
"""Check if the cached dataset contains all requested episodes and their video files.""" """Check if the cached dataset contains all requested episodes and their video files."""
if self.hf_dataset is None or len(self.hf_dataset) == 0: if self.hf_dataset is None or len(self.hf_dataset) == 0:
+16 -2
View File
@@ -66,6 +66,17 @@ def resolve_delta_timestamps(
return delta_timestamps return delta_timestamps
def _resolve_episodes(
episodes: list[int] | None, exclude_episodes: list[int] | None, total_episodes: int
) -> list[int] | None:
"""Apply an episode exclusion list on top of an optional allowlist."""
if not exclude_episodes:
return episodes
base = episodes if episodes is not None else list(range(total_episodes))
excluded = set(exclude_episodes)
return [episode for episode in base if episode not in excluded]
def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDataset: def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDataset:
"""Handles the logic of setting up delta timestamps and image transforms before creating a dataset. """Handles the logic of setting up delta timestamps and image transforms before creating a dataset.
@@ -87,11 +98,14 @@ def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDatas
cfg.dataset.repo_id, root=cfg.dataset.root, revision=cfg.dataset.revision cfg.dataset.repo_id, root=cfg.dataset.root, revision=cfg.dataset.revision
) )
delta_timestamps = resolve_delta_timestamps(cfg.trainable_config, ds_meta) delta_timestamps = resolve_delta_timestamps(cfg.trainable_config, ds_meta)
episodes = _resolve_episodes(
cfg.dataset.episodes, cfg.dataset.exclude_episodes, ds_meta.total_episodes
)
if not cfg.dataset.streaming: if not cfg.dataset.streaming:
dataset = LeRobotDataset( dataset = LeRobotDataset(
cfg.dataset.repo_id, cfg.dataset.repo_id,
root=cfg.dataset.root, root=cfg.dataset.root,
episodes=cfg.dataset.episodes, episodes=episodes,
delta_timestamps=delta_timestamps, delta_timestamps=delta_timestamps,
image_transforms=image_transforms, image_transforms=image_transforms,
revision=cfg.dataset.revision, revision=cfg.dataset.revision,
@@ -104,7 +118,7 @@ def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDatas
dataset = StreamingLeRobotDataset( dataset = StreamingLeRobotDataset(
cfg.dataset.repo_id, cfg.dataset.repo_id,
root=cfg.dataset.root, root=cfg.dataset.root,
episodes=cfg.dataset.episodes, episodes=episodes,
delta_timestamps=delta_timestamps, delta_timestamps=delta_timestamps,
image_transforms=image_transforms, image_transforms=image_transforms,
revision=cfg.dataset.revision, revision=cfg.dataset.revision,
+73 -10
View File
@@ -162,14 +162,28 @@ def render_sample(
task: str | None = None, task: str | None = None,
dataset_ctx: Any | None = None, dataset_ctx: Any | None = None,
) -> RenderedMessages | None: ) -> RenderedMessages | None:
"""Render the chat-style messages for a single dataset sample. """Resolve one sample's bindings and render its message recipe.
Resolves the recipe's bindings against ``persistent`` and ``events`` rows Returns ``None`` when no text or low-level action supervision applies.
at frame timestamp ``t``, then expands the recipe's message templates.
Returns ``None`` if the resolved sample contains no target message.
""" """
persistent_rows = _normalize_rows(persistent or []) persistent_rows = _normalize_rows(persistent or [])
event_rows = _normalize_rows(events or []) event_rows = _normalize_rows(events or [])
# Route sparse VQA frames to a matching view-specific component before weighted selection.
# This avoids dropping annotated frames or selecting VQA without annotations.
if recipe.blend is not None:
vqa_rendered = _render_vqa_if_present(
recipe,
persistent=persistent_rows,
events=event_rows,
t=t,
sample_idx=sample_idx,
task=task,
dataset_ctx=dataset_ctx,
)
if vqa_rendered is not None:
return vqa_rendered
selected_recipe = _select_recipe(recipe, sample_idx) selected_recipe = _select_recipe(recipe, sample_idx)
bindings = _resolve_bindings( bindings = _resolve_bindings(
selected_recipe, selected_recipe,
@@ -183,6 +197,55 @@ def render_sample(
return _render_message_recipe(selected_recipe, bindings) return _render_message_recipe(selected_recipe, bindings)
def _render_vqa_if_present(
recipe: TrainingRecipe,
*,
persistent: Sequence[LanguageRow],
events: Sequence[LanguageRow],
t: float,
sample_idx: int,
task: str | None,
dataset_ctx: Any | None,
) -> RenderedMessages | None:
"""Render a matching VQA component, or return ``None`` for normal selection.
Multiple matching views are selected deterministically by relative weight.
"""
assert recipe.blend is not None
renderable: list[tuple[float, RenderedMessages]] = []
for name, component in recipe.blend.items():
if not name.startswith("ask_vqa"):
continue
bindings = _resolve_bindings(
component,
persistent=persistent,
events=events,
t=t,
sample_idx=sample_idx,
task=task,
dataset_ctx=dataset_ctx,
)
rendered = _render_message_recipe(component, bindings)
if rendered is not None:
renderable.append((float(component.weight or 0.0), rendered))
if not renderable:
return None
if len(renderable) == 1:
return renderable[0][1]
# Choose among matching cameras by relative weight, or uniformly when all weights are zero.
total = sum(w for w, _ in renderable) or float(len(renderable))
digest = hashlib.blake2b(f"vqa:{sample_idx}".encode(), digest_size=8).digest()
draw = int.from_bytes(digest, "big") / 2**64 * total
cumulative = 0.0
for w, rendered in renderable:
cumulative += w or (total / len(renderable))
if draw < cumulative:
return rendered
return renderable[-1][1]
def _select_recipe(recipe: TrainingRecipe, sample_idx: int) -> TrainingRecipe: def _select_recipe(recipe: TrainingRecipe, sample_idx: int) -> TrainingRecipe:
"""Pick a deterministic blend component for ``sample_idx`` (or return ``recipe``).""" """Pick a deterministic blend component for ``sample_idx`` (or return ``recipe``)."""
if recipe.blend is None: if recipe.blend is None:
@@ -346,7 +409,9 @@ def _render_message_recipe(
if turn.target: if turn.target:
target_indices.append(message_idx) target_indices.append(message_idx)
if not target_indices: # Keep samples with either text targets or low-level action supervision.
has_low_level = any(stream == "low_level" for stream in streams)
if not target_indices and not has_low_level:
return None return None
rendered = { rendered = {
@@ -403,14 +468,12 @@ def _validate_rendered(rendered: RenderedMessages) -> None:
if len(streams) != len(messages): if len(streams) != len(messages):
raise ValueError("message_streams must be aligned with messages.") raise ValueError("message_streams must be aligned with messages.")
if not target_indices: # Require text or low-level action supervision.
raise ValueError("Rendered samples must contain at least one target message.") if not target_indices and not any(s == "low_level" for s in streams):
raise ValueError("Rendered samples must contain a target message or a low_level-stream message.")
for idx in target_indices: for idx in target_indices:
if idx < 0 or idx >= len(messages): if idx < 0 or idx >= len(messages):
raise ValueError(f"Target message index {idx} is out of bounds.") raise ValueError(f"Target message index {idx} is out of bounds.")
# ``stream`` is enforced non-None at MessageTurn construction time
# (see ``MessageTurn.__post_init__``), so a missing stream here would
# mean the dataclass invariant was bypassed; no need to re-check.
def _nth_relative( def _nth_relative(
+9 -1
View File
@@ -560,7 +560,13 @@ class RoboCasaEnv(EnvConfig):
kwargs["split"] = self.split kwargs["split"] = self.split
return kwargs return kwargs
def create_envs(self, n_envs: int, use_async_envs: bool = False): def create_envs(
self,
n_envs: int,
use_async_envs: bool = False,
terminate_on_success: bool = True,
horizon: int | None = None,
):
from .robocasa import create_robocasa_envs from .robocasa import create_robocasa_envs
if self.task is None: if self.task is None:
@@ -574,6 +580,8 @@ class RoboCasaEnv(EnvConfig):
env_cls=env_cls, env_cls=env_cls,
episode_length=self.episode_length, episode_length=self.episode_length,
obj_registries=tuple(self.obj_registries), obj_registries=tuple(self.obj_registries),
terminate_on_success=terminate_on_success,
horizon=horizon,
) )
+33 -11
View File
@@ -33,8 +33,8 @@ logger = logging.getLogger(__name__)
# Dimensions for the flat action/state vectors used by the LeRobot wrapper. # Dimensions for the flat action/state vectors used by the LeRobot wrapper.
# These correspond to the PandaOmron robot in RoboCasa365. # These correspond to the PandaOmron robot in RoboCasa365.
OBS_STATE_DIM = 16 # base_pos(3) + base_quat(4) + ee_pos_rel(3) + ee_quat_rel(4) + gripper_qpos(2) OBS_STATE_DIM = 16 # ee_pos_rel(3) + ee_quat_rel(4) + base_pos(3) + base_quat(4) + gripper_qpos(2)
ACTION_DIM = 12 # base_motion(4) + control_mode(1) + ee_pos(3) + ee_rot(3) + gripper(1) ACTION_DIM = 12 # ee_pos(3) + ee_rot(3) + gripper(1) + base_motion(4) + control_mode(1)
ACTION_LOW = -1.0 ACTION_LOW = -1.0
ACTION_HIGH = 1.0 ACTION_HIGH = 1.0
@@ -101,14 +101,15 @@ def _resolve_tasks(task: str) -> tuple[list[str], str | None]:
def convert_action(flat_action: np.ndarray) -> dict[str, Any]: def convert_action(flat_action: np.ndarray) -> dict[str, Any]:
"""Split a flat (12,) action vector into a RoboCasa action dict. """Split a flat (12,) action vector into a RoboCasa action dict.
Layout: base_motion(4) + control_mode(1) + ee_pos(3) + ee_rot(3) + gripper(1) Layout (openpi / robocasa.utils.env_utils.convert_action order):
ee_pos(3) + ee_rot(3) + gripper(1) + base_motion(4) + control_mode(1)
""" """
return { return {
"action.base_motion": flat_action[0:4], "action.end_effector_position": flat_action[0:3],
"action.control_mode": flat_action[4:5], "action.end_effector_rotation": flat_action[3:6],
"action.end_effector_position": flat_action[5:8], "action.gripper_close": flat_action[6:7],
"action.end_effector_rotation": flat_action[8:11], "action.base_motion": flat_action[7:11],
"action.gripper_close": flat_action[11:12], "action.control_mode": flat_action[11:12],
} }
@@ -136,9 +137,16 @@ class RoboCasaEnv(gym.Env):
episode_length: int | None = None, episode_length: int | None = None,
obj_registries: Sequence[str] = DEFAULT_OBJ_REGISTRIES, obj_registries: Sequence[str] = DEFAULT_OBJ_REGISTRIES,
episode_index: int = 0, episode_index: int = 0,
terminate_on_success: bool = True,
horizon: int | None = None,
): ):
super().__init__() super().__init__()
self.task = task self.task = task
# When False, a task-success does NOT end/reset the episode — used by the
# interactive sim so one kitchen persists across sequential prompts.
self.terminate_on_success = terminate_on_success
# Underlying robosuite horizon (steps before truncation). None -> default.
self.horizon = horizon
self.obs_type = obs_type self.obs_type = obs_type
self.render_mode = render_mode self.render_mode = render_mode
self.observation_width = observation_width self.observation_width = observation_width
@@ -210,12 +218,16 @@ class RoboCasaEnv(gym.Env):
# (only None/"all"/"pretrain"/"target" are valid). Always pass a # (only None/"all"/"pretrain"/"target" are valid). Always pass a
# valid value so we don't hit that default. Extra kwargs are # valid value so we don't hit that default. Extra kwargs are
# forwarded to the underlying kitchen env via create_env/robosuite.make. # forwarded to the underlying kitchen env via create_env/robosuite.make.
extra_kwargs: dict[str, Any] = {}
if self.horizon is not None:
extra_kwargs["horizon"] = int(self.horizon)
self._env = RoboCasaGymEnv( self._env = RoboCasaGymEnv(
env_name=self.task, env_name=self.task,
camera_widths=self.observation_width, camera_widths=self.observation_width,
camera_heights=self.observation_height, camera_heights=self.observation_height,
split=self.split if self.split is not None else "all", split=self.split if self.split is not None else "all",
obj_registries=self.obj_registries, obj_registries=self.obj_registries,
**extra_kwargs,
) )
ep_meta = self._env.env.get_ep_meta() ep_meta = self._env.env.get_ep_meta()
@@ -230,12 +242,14 @@ class RoboCasaEnv(gym.Env):
return {"pixels": images} return {"pixels": images}
# `state.*` keys come from PandaOmronKeyConverter inside the wrapper. # `state.*` keys come from PandaOmronKeyConverter inside the wrapper.
# openpi state order: ee first, then base, then gripper (matches the
# openpi robocasa pipeline / examples/robocasa/main.py state layout).
agent_pos = np.concatenate( agent_pos = np.concatenate(
[ [
raw_obs.get("state.base_position", np.zeros(3)),
raw_obs.get("state.base_rotation", np.zeros(4)),
raw_obs.get("state.end_effector_position_relative", np.zeros(3)), raw_obs.get("state.end_effector_position_relative", np.zeros(3)),
raw_obs.get("state.end_effector_rotation_relative", np.zeros(4)), raw_obs.get("state.end_effector_rotation_relative", np.zeros(4)),
raw_obs.get("state.base_position", np.zeros(3)),
raw_obs.get("state.base_rotation", np.zeros(4)),
raw_obs.get("state.gripper_qpos", np.zeros(2)), raw_obs.get("state.gripper_qpos", np.zeros(2)),
], ],
axis=-1, axis=-1,
@@ -280,7 +294,7 @@ class RoboCasaEnv(gym.Env):
raw_obs, reward, done, truncated, info = self._env.step(action_dict) raw_obs, reward, done, truncated, info = self._env.step(action_dict)
is_success = bool(info.get("success", False)) is_success = bool(info.get("success", False))
terminated = done or is_success terminated = done or (is_success and self.terminate_on_success)
info.update({"task": self.task, "done": done, "is_success": is_success}) info.update({"task": self.task, "done": done, "is_success": is_success})
observation = self._format_raw_obs(raw_obs) observation = self._format_raw_obs(raw_obs)
@@ -313,6 +327,8 @@ def _make_env_fns(
split: str | None, split: str | None,
episode_length: int | None, episode_length: int | None,
obj_registries: Sequence[str], obj_registries: Sequence[str],
terminate_on_success: bool = True,
horizon: int | None = None,
) -> list[Callable[[], RoboCasaEnv]]: ) -> list[Callable[[], RoboCasaEnv]]:
"""Build n_envs factory callables for a single task. """Build n_envs factory callables for a single task.
@@ -335,6 +351,8 @@ def _make_env_fns(
episode_length=episode_length, episode_length=episode_length,
obj_registries=obj_registries, obj_registries=obj_registries,
episode_index=episode_index, episode_index=episode_index,
terminate_on_success=terminate_on_success,
horizon=horizon,
) )
return [partial(_make_env, i) for i in range(n_envs)] return [partial(_make_env, i) for i in range(n_envs)]
@@ -348,6 +366,8 @@ def create_robocasa_envs(
env_cls: Callable[[Sequence[Callable[[], Any]]], Any] | None = None, env_cls: Callable[[Sequence[Callable[[], Any]]], Any] | None = None,
episode_length: int | None = None, episode_length: int | None = None,
obj_registries: Sequence[str] = DEFAULT_OBJ_REGISTRIES, obj_registries: Sequence[str] = DEFAULT_OBJ_REGISTRIES,
terminate_on_success: bool = True,
horizon: int | None = None,
) -> dict[str, dict[int, Any]]: ) -> dict[str, dict[int, Any]]:
"""Create vectorized RoboCasa365 environments with a consistent return shape. """Create vectorized RoboCasa365 environments with a consistent return shape.
@@ -409,6 +429,8 @@ def create_robocasa_envs(
split=split, split=split,
episode_length=episode_length, episode_length=episode_length,
obj_registries=obj_registries, obj_registries=obj_registries,
terminate_on_success=terminate_on_success,
horizon=horizon,
) )
if is_async: if is_async:
-3
View File
@@ -175,9 +175,6 @@ class AddBatchDimensionComplementaryDataStep(ComplementaryDataProcessorStep):
if isinstance(task_index_value, Tensor) and task_index_value.dim() == 0: if isinstance(task_index_value, Tensor) and task_index_value.dim() == 0:
complementary_data["task_index"] = task_index_value.unsqueeze(0) complementary_data["task_index"] = task_index_value.unsqueeze(0)
complementary_data.pop("language_persistent", None)
complementary_data.pop("language_events", None)
if "messages" in complementary_data: if "messages" in complementary_data:
messages = complementary_data["messages"] messages = complementary_data["messages"]
if isinstance(messages, list) and (not messages or isinstance(messages[0], dict)): if isinstance(messages, list) and (not messages or isinstance(messages[0], dict)):
+85 -4
View File
@@ -41,7 +41,7 @@ from pathlib import Path
from typing import Any, TypedDict, TypeVar, cast from typing import Any, TypedDict, TypeVar, cast
import torch import torch
from huggingface_hub import hf_hub_download from huggingface_hub import hf_hub_download, snapshot_download
from safetensors.torch import load_file, save_file from safetensors.torch import load_file, save_file
from lerobot.configs import PipelineFeatureType, PolicyFeature from lerobot.configs import PipelineFeatureType, PolicyFeature
@@ -205,6 +205,10 @@ class ProcessorStep(ABC):
""" """
return None return None
def save_artifacts(self, save_directory: Path) -> dict[str, str]:
"""Save non-tensor assets and map constructor arguments to relative paths."""
return {}
def reset(self) -> None: def reset(self) -> None:
"""Resets the internal state of the processor step, if any.""" """Resets the internal state of the processor step, if any."""
return None return None
@@ -549,6 +553,22 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
pipeline_config = self.get_config() pipeline_config = self.get_config()
pipeline_state_dict = self.state_dict() pipeline_state_dict = self.state_dict()
for processor_step, step_entry in zip(self.steps, pipeline_config["steps"], strict=True):
artifacts = processor_step.save_artifacts(save_directory)
if artifacts:
for config_key, relative_path in artifacts.items():
artifact_path = Path(relative_path)
if artifact_path.is_absolute() or ".." in artifact_path.parts:
raise ValueError(
f"Processor artifact path must be relative to the checkpoint: {relative_path!r}"
)
if not (save_directory / artifact_path).exists():
raise FileNotFoundError(
f"Processor step did not save declared artifact '{relative_path}'"
)
step_entry["config"][config_key] = artifact_path.as_posix()
step_entry["artifacts"] = artifacts
for state_key, step_state_dict in pipeline_state_dict.items(): for state_key, step_state_dict in pipeline_state_dict.items():
state_filename = f"{state_key}.safetensors" state_filename = f"{state_key}.safetensors"
save_file(step_state_dict, save_directory / state_filename) save_file(step_state_dict, save_directory / state_filename)
@@ -733,7 +753,13 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
# 3. Build steps with overrides # 3. Build steps with overrides
steps, validated_overrides = cls._build_steps_with_overrides( steps, validated_overrides = cls._build_steps_with_overrides(
loaded_config, overrides or {}, model_id, base_path, hub_download_kwargs, is_local_source loaded_config,
overrides or {},
model_id,
base_path,
config_filename,
hub_download_kwargs,
is_local_source,
) )
# 4. Validate that all overrides were used # 4. Validate that all overrides were used
@@ -922,6 +948,7 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
overrides: dict[str, Any], overrides: dict[str, Any],
model_id: str, model_id: str,
base_path: Path | None, base_path: Path | None,
config_filename: str,
hub_download_kwargs: dict[str, Any], hub_download_kwargs: dict[str, Any],
is_local_source: bool = False, is_local_source: bool = False,
) -> tuple[list[ProcessorStep], set[str]]: ) -> tuple[list[ProcessorStep], set[str]]:
@@ -976,15 +1003,68 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
ImportError: If a step class cannot be imported or found in registry ImportError: If a step class cannot be imported or found in registry
ValueError: If a step cannot be instantiated with its configuration ValueError: If a step cannot be instantiated with its configuration
""" """
loaded_config = deepcopy(loaded_config)
cls._resolve_artifact_paths(
loaded_config,
model_id,
base_path,
config_filename,
hub_download_kwargs,
)
steps, remaining_override_keys = cls._build_steps_from_config(loaded_config, overrides) steps, remaining_override_keys = cls._build_steps_from_config(loaded_config, overrides)
for step_instance, step_entry in zip(steps, loaded_config["steps"], strict=True): for step_instance, step_entry in zip(steps, loaded_config["steps"], strict=True):
cls._load_step_state( cls._load_step_state(
step_instance, step_entry, model_id, base_path, hub_download_kwargs, is_local_source step_instance,
step_entry,
model_id,
base_path,
config_filename,
hub_download_kwargs,
is_local_source,
) )
return steps, remaining_override_keys return steps, remaining_override_keys
@classmethod
def _resolve_artifact_paths(
cls,
loaded_config: dict[str, Any],
model_id: str,
base_path: Path | None,
config_filename: str,
hub_download_kwargs: dict[str, Any],
) -> None:
"""Resolve declared relative processor artifacts before step construction."""
is_local = Path(model_id).is_dir() or Path(model_id).is_file()
for step_entry in loaded_config["steps"]:
artifacts = step_entry.get("artifacts", {})
for config_key, relative_path in artifacts.items():
artifact_path = Path(relative_path)
if artifact_path.is_absolute() or ".." in artifact_path.parts:
raise ValueError(
f"Processor artifact path must be relative to the checkpoint: {relative_path!r}"
)
resolved_path = base_path / artifact_path if base_path is not None else artifact_path
if not resolved_path.exists() and not is_local:
repository_path = Path(config_filename).parent / artifact_path
snapshot_download(
repo_id=model_id,
repo_type="model",
allow_patterns=f"{repository_path.as_posix()}/**",
**hub_download_kwargs,
)
if not resolved_path.exists():
step_name = step_entry.get("registry_name", step_entry.get("class", "unknown"))
raise FileNotFoundError(
f"Missing processor artifact '{relative_path}' for step '{step_name}' "
f"next to '{config_filename}'. Checkpoint artifacts are incomplete."
)
step_entry["config"][config_key] = str(resolved_path)
@classmethod @classmethod
def _build_steps_from_config( def _build_steps_from_config(
cls, cls,
@@ -1144,6 +1224,7 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
step_entry: dict[str, Any], step_entry: dict[str, Any],
model_id: str, model_id: str,
base_path: Path | None, base_path: Path | None,
config_filename: str,
hub_download_kwargs: dict[str, Any], hub_download_kwargs: dict[str, Any],
is_local_source: bool = False, is_local_source: bool = False,
) -> None: ) -> None:
@@ -1209,7 +1290,7 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
# Download from Hub # Download from Hub
state_path = hf_hub_download( state_path = hf_hub_download(
repo_id=model_id, repo_id=model_id,
filename=state_filename, filename=(Path(config_filename).parent / state_filename).as_posix(),
repo_type="model", repo_type="model",
**hub_download_kwargs, **hub_download_kwargs,
) )
@@ -16,7 +16,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import asdict, dataclass
from typing import Any from typing import Any
from lerobot.configs import PipelineFeatureType, PolicyFeature from lerobot.configs import PipelineFeatureType, PolicyFeature
@@ -32,17 +32,18 @@ from .pipeline import ProcessorStep, ProcessorStepRegistry
@dataclass @dataclass
@ProcessorStepRegistry.register(name="render_messages_processor") @ProcessorStepRegistry.register(name="render_messages_processor")
class RenderMessagesStep(ProcessorStep): class RenderMessagesStep(ProcessorStep):
"""Processor step that turns raw language columns into rendered chat messages. """Render language columns into recipe-defined messages and supervision metadata."""
Reads ``language_persistent`` and ``language_events`` from the transition's
complementary data, renders them through ``recipe`` at the sample timestamp,
and replaces the raw columns with the resulting ``messages`` /
``message_streams`` / ``target_message_indices`` keys.
"""
recipe: TrainingRecipe recipe: TrainingRecipe
dataset_ctx: Any | None = None dataset_ctx: Any | None = None
def __post_init__(self) -> None:
if isinstance(self.recipe, dict):
self.recipe = TrainingRecipe.from_dict(self.recipe)
def get_config(self) -> dict[str, Any]:
return {"recipe": asdict(self.recipe)}
def __call__(self, transition: EnvTransition) -> EnvTransition | None: def __call__(self, transition: EnvTransition) -> EnvTransition | None:
"""Render messages for a single transition; return ``None`` to drop it.""" """Render messages for a single transition; return ``None`` to drop it."""
complementary_data = transition.get(TransitionKey.COMPLEMENTARY_DATA) or {} complementary_data = transition.get(TransitionKey.COMPLEMENTARY_DATA) or {}
@@ -50,7 +51,17 @@ class RenderMessagesStep(ProcessorStep):
events = complementary_data.get(LANGUAGE_EVENTS) or [] events = complementary_data.get(LANGUAGE_EVENTS) or []
if not persistent and not events: if not persistent and not events:
return transition rendered = _fallback_low_level_render(complementary_data.get("task"))
if rendered is None:
return transition
new_transition = transition.copy()
new_complementary_data = dict(new_transition.get(TransitionKey.COMPLEMENTARY_DATA) or {})
new_complementary_data.update(rendered)
new_transition[TransitionKey.COMPLEMENTARY_DATA] = new_complementary_data
return new_transition
if _is_batched_language(persistent) or _is_batched_language(events):
return self._call_batch(transition, complementary_data, persistent, events)
timestamp = complementary_data.get("timestamp") timestamp = complementary_data.get("timestamp")
if timestamp is None: if timestamp is None:
@@ -67,18 +78,147 @@ class RenderMessagesStep(ProcessorStep):
dataset_ctx=self.dataset_ctx, dataset_ctx=self.dataset_ctx,
) )
if rendered is None: if rendered is None:
return None rendered = _fallback_low_level_render(complementary_data.get("task"))
if rendered is None:
return None
new_transition = transition.copy() new_transition = transition.copy()
new_complementary_data = dict(complementary_data) new_complementary_data = dict(new_transition.get(TransitionKey.COMPLEMENTARY_DATA) or {})
new_complementary_data.pop(LANGUAGE_PERSISTENT, None) new_complementary_data.pop(LANGUAGE_PERSISTENT, None)
new_complementary_data.pop(LANGUAGE_EVENTS, None) new_complementary_data.pop(LANGUAGE_EVENTS, None)
new_complementary_data.update(rendered) new_complementary_data.update(rendered)
new_transition[TransitionKey.COMPLEMENTARY_DATA] = new_complementary_data new_transition[TransitionKey.COMPLEMENTARY_DATA] = new_complementary_data
return new_transition return new_transition
def _call_batch(
self,
transition: EnvTransition,
complementary_data: dict[str, Any],
persistent_batch: list,
events_batch: list,
) -> EnvTransition | None:
timestamp = complementary_data.get("timestamp")
if timestamp is None:
raise KeyError("RenderMessagesStep requires sample timestamp in complementary data.")
batch_size = max(len(persistent_batch), len(events_batch))
messages: list[list[dict[str, Any]]] = []
message_streams: list[list[str | None]] = []
target_message_indices: list[list[int]] = []
keep_indices: list[int] = []
for i in range(batch_size):
rendered = render_sample(
recipe=self.recipe,
persistent=persistent_batch[i] if i < len(persistent_batch) else [],
events=events_batch[i] if i < len(events_batch) else [],
t=_batch_value(timestamp, i),
sample_idx=int(_batch_value(complementary_data.get("index", 0), i)),
task=_batch_value(complementary_data.get("task"), i),
dataset_ctx=self.dataset_ctx,
)
if rendered is None:
rendered = _fallback_low_level_render(_batch_value(complementary_data.get("task"), i))
if rendered is None:
continue
keep_indices.append(i)
messages.append(rendered["messages"])
message_streams.append(rendered["message_streams"])
target_message_indices.append(rendered["target_message_indices"])
if not messages:
return None
new_transition = (
_select_batch_indices(transition, keep_indices)
if len(keep_indices) != batch_size
else transition.copy()
)
new_complementary_data = dict(new_transition.get(TransitionKey.COMPLEMENTARY_DATA) or {})
new_complementary_data.pop(LANGUAGE_PERSISTENT, None)
new_complementary_data.pop(LANGUAGE_EVENTS, None)
new_complementary_data["messages"] = messages
new_complementary_data["message_streams"] = message_streams
new_complementary_data["target_message_indices"] = target_message_indices
new_transition[TransitionKey.COMPLEMENTARY_DATA] = new_complementary_data
return new_transition
def transform_features( def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]] self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]: ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
"""Pass features through unchanged; rendering only touches complementary data.""" """Pass features through unchanged; rendering only touches complementary data."""
return features return features
def _scalar(value: Any) -> float | int:
"""Unwrap a tensor/array/single-element list into a Python scalar."""
if hasattr(value, "item"):
return value.item()
if isinstance(value, list):
if len(value) != 1:
raise ValueError(f"Expected a scalar, got list of length {len(value)}: {value!r}")
return _scalar(value[0])
return value
def _is_batched_language(value: Any) -> bool:
return isinstance(value, list) and bool(value) and isinstance(value[0], list)
def _batch_value(value: Any, index: int) -> Any:
if value is None:
return None
if isinstance(value, list):
return value[index]
if hasattr(value, "ndim") and value.ndim > 0:
return _scalar(value[index])
return _scalar(value)
def _select_batch_indices(transition: EnvTransition, indices: list[int]) -> EnvTransition:
selected = transition.copy()
for key in (TransitionKey.OBSERVATION, TransitionKey.COMPLEMENTARY_DATA):
data = selected.get(key)
if isinstance(data, dict):
selected[key] = {k: _select_value(v, indices) for k, v in data.items()}
action = selected.get(TransitionKey.ACTION)
if action is not None:
selected[TransitionKey.ACTION] = _select_value(action, indices)
return selected
def _select_value(value: Any, indices: list[int]) -> Any:
if isinstance(value, list) and len(value) >= len(indices):
return [value[i] for i in indices]
if hasattr(value, "index_select") and hasattr(value, "new_tensor") and getattr(value, "ndim", 0) > 0:
return value.index_select(0, value.new_tensor(indices).long())
return value
def _fallback_low_level_render(task: Any) -> dict[str, Any] | None:
"""Keep action-only samples trainable when no recipe branch matches."""
if hasattr(task, "item"):
task = task.item()
if isinstance(task, list):
messages = []
message_streams = []
target_message_indices = []
for t in task:
rendered = _fallback_low_level_render(t)
if rendered is None:
return None
messages.append(rendered["messages"])
message_streams.append(rendered["message_streams"])
target_message_indices.append(rendered["target_message_indices"])
return {
"messages": messages,
"message_streams": message_streams,
"target_message_indices": target_message_indices,
}
if not isinstance(task, str) or not task:
return None
return {
"messages": [{"role": "user", "content": task}],
"message_streams": ["low_level"],
"target_message_indices": [],
}
+50 -17
View File
@@ -25,6 +25,7 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import torch import torch
@@ -32,6 +33,7 @@ import torch
from lerobot.configs import FeatureType, PipelineFeatureType, PolicyFeature from lerobot.configs import FeatureType, PipelineFeatureType, PolicyFeature
from lerobot.types import EnvTransition, RobotObservation, TransitionKey from lerobot.types import EnvTransition, RobotObservation, TransitionKey
from lerobot.utils.constants import ( from lerobot.utils.constants import (
ACTION_CODE_TOKEN_MASK,
ACTION_TOKEN_MASK, ACTION_TOKEN_MASK,
ACTION_TOKENS, ACTION_TOKENS,
OBS_LANGUAGE_ATTENTION_MASK, OBS_LANGUAGE_ATTENTION_MASK,
@@ -136,7 +138,7 @@ class TokenizerProcessorStep(ObservationProcessorStep):
# Standardize to a list of strings for the tokenizer # Standardize to a list of strings for the tokenizer
if isinstance(task, str): if isinstance(task, str):
return [task] return [task]
elif isinstance(task, (list, tuple)) and all(isinstance(t, str) for t in task): elif isinstance(task, list | tuple) and all(isinstance(t, str) for t in task):
return list(task) return list(task)
return None return None
@@ -349,6 +351,7 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
max_action_tokens: int = 256 max_action_tokens: int = 256
fast_skip_tokens: int = 128 fast_skip_tokens: int = 128
paligemma_tokenizer_name: str = "google/paligemma-3b-pt-224" paligemma_tokenizer_name: str = "google/paligemma-3b-pt-224"
allow_truncation: bool = True
# Internal tokenizer instance (not part of the config) # Internal tokenizer instance (not part of the config)
action_tokenizer: Any = field(default=None, init=False, repr=False) action_tokenizer: Any = field(default=None, init=False, repr=False)
_paligemma_tokenizer: Any = field(default=None, init=False, repr=False) _paligemma_tokenizer: Any = field(default=None, init=False, repr=False)
@@ -412,14 +415,15 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
# During inference, no action is available, skip tokenization # During inference, no action is available, skip tokenization
return new_transition return new_transition
# Tokenize and get both tokens and mask # Tokenize and get masks for the full formatted sequence and the discrete action codes.
tokens, mask = self._tokenize_action(action) tokens, mask, code_mask = self._tokenize_action(action)
# Store mask in complementary data # Store mask in complementary data
complementary_data = new_transition.get(TransitionKey.COMPLEMENTARY_DATA, {}) complementary_data = new_transition.get(TransitionKey.COMPLEMENTARY_DATA, {})
if complementary_data is None: if complementary_data is None:
complementary_data = {} complementary_data = {}
complementary_data[ACTION_TOKEN_MASK] = mask complementary_data[ACTION_TOKEN_MASK] = mask
complementary_data[ACTION_CODE_TOKEN_MASK] = code_mask
complementary_data[ACTION_TOKENS] = tokens complementary_data[ACTION_TOKENS] = tokens
new_transition[TransitionKey.COMPLEMENTARY_DATA] = complementary_data new_transition[TransitionKey.COMPLEMENTARY_DATA] = complementary_data
return new_transition return new_transition
@@ -430,7 +434,7 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
""" """
return self._paligemma_tokenizer.vocab_size - 1 - self.fast_skip_tokens - tokens return self._paligemma_tokenizer.vocab_size - 1 - self.fast_skip_tokens - tokens
def _tokenize_action(self, action: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: def _tokenize_action(self, action: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
""" """
Tokenizes the action tensor and creates a mask. Tokenizes the action tensor and creates a mask.
@@ -459,6 +463,7 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
# The fast tokenizer expects action data and returns token IDs # The fast tokenizer expects action data and returns token IDs
tokens_list = [] tokens_list = []
masks_list = [] masks_list = []
code_masks_list = []
for i in range(batch_size): for i in range(batch_size):
# Tokenize single action (move to CPU first as tokenizer uses scipy which requires numpy) # Tokenize single action (move to CPU first as tokenizer uses scipy which requires numpy)
@@ -476,65 +481,82 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
if tokens.dim() > 1: if tokens.dim() > 1:
tokens = tokens.flatten() tokens = tokens.flatten()
action_code_tokens = self._act_tokens_to_paligemma_tokens(tokens)
bos_id = self._paligemma_tokenizer.bos_token_id bos_id = self._paligemma_tokenizer.bos_token_id
# add bos prompt_tokens = torch.tensor(
self._paligemma_tokenizer.encode("Action: ", add_special_tokens=False),
device=action.device,
)
end_tokens = torch.tensor(self._paligemma_tokenizer.encode("|"), device=action.device)
code_start = 1 + len(prompt_tokens)
code_end = code_start + len(action_code_tokens)
tokens = torch.cat( tokens = torch.cat(
[ [
torch.tensor([bos_id], device=action.device), torch.tensor([bos_id], device=action.device),
torch.tensor( prompt_tokens,
self._paligemma_tokenizer.encode("Action: ", add_special_tokens=False), action_code_tokens,
device=action.device, end_tokens,
),
self._act_tokens_to_paligemma_tokens(tokens),
torch.tensor(self._paligemma_tokenizer.encode("|"), device=action.device),
] ]
) )
code_mask = torch.zeros(len(tokens), dtype=torch.bool, device=action.device)
code_mask[code_start:code_end] = True
# Truncate or pad to max_action_tokens # Truncate or pad to max_action_tokens
if len(tokens) > self.max_action_tokens: if len(tokens) > self.max_action_tokens:
if not self.allow_truncation:
raise ValueError(
f"FAST action sequence has {len(tokens)} tokens, exceeding "
f"max_action_tokens={self.max_action_tokens}."
)
logging.warning( logging.warning(
f"Token length ({len(tokens)}) exceeds max length ({self.max_action_tokens}), truncating. " f"Token length ({len(tokens)}) exceeds max length ({self.max_action_tokens}), truncating. "
"Consider increasing the `max_action_tokens` in your model config if this happens frequently." "Consider increasing the `max_action_tokens` in your model config if this happens frequently."
) )
tokens = tokens[: self.max_action_tokens] tokens = tokens[: self.max_action_tokens]
code_mask = code_mask[: self.max_action_tokens]
mask = torch.ones(self.max_action_tokens, dtype=torch.bool, device=action.device) mask = torch.ones(self.max_action_tokens, dtype=torch.bool, device=action.device)
else: else:
pad_len = self.max_action_tokens - len(tokens)
mask = torch.cat( mask = torch.cat(
[ [
torch.ones(len(tokens), dtype=torch.bool, device=action.device), torch.ones(len(tokens), dtype=torch.bool, device=action.device),
torch.zeros( torch.zeros(pad_len, dtype=torch.bool, device=action.device),
self.max_action_tokens - len(tokens), dtype=torch.bool, device=action.device
),
] ]
) )
code_mask = torch.nn.functional.pad(code_mask, (0, pad_len), value=False)
# Pad tokens with zeros # Pad tokens with zeros
tokens = torch.nn.functional.pad(tokens, (0, self.max_action_tokens - len(tokens)), value=0) tokens = torch.nn.functional.pad(tokens, (0, pad_len), value=0)
tokens_list.append(tokens) tokens_list.append(tokens)
masks_list.append(mask) masks_list.append(mask)
code_masks_list.append(code_mask)
# Stack into batched tensors # Stack into batched tensors
tokens_batch = torch.stack(tokens_list, dim=0) # (B, max_action_tokens) tokens_batch = torch.stack(tokens_list, dim=0) # (B, max_action_tokens)
masks_batch = torch.stack(masks_list, dim=0) # (B, max_action_tokens) masks_batch = torch.stack(masks_list, dim=0) # (B, max_action_tokens)
code_masks_batch = torch.stack(code_masks_list, dim=0) # (B, max_action_tokens)
# Remove batch dimension if input was single sample # Remove batch dimension if input was single sample
if single_sample: if single_sample:
tokens_batch = tokens_batch.squeeze(0) tokens_batch = tokens_batch.squeeze(0)
masks_batch = masks_batch.squeeze(0) masks_batch = masks_batch.squeeze(0)
code_masks_batch = code_masks_batch.squeeze(0)
# Move to the same device as the input # Move to the same device as the input
if device is not None: if device is not None:
tokens_batch = tokens_batch.to(device) tokens_batch = tokens_batch.to(device)
masks_batch = masks_batch.to(device) masks_batch = masks_batch.to(device)
code_masks_batch = code_masks_batch.to(device)
return tokens_batch, masks_batch return tokens_batch, masks_batch, code_masks_batch
def action(self, action: torch.Tensor) -> torch.Tensor: def action(self, action: torch.Tensor) -> torch.Tensor:
""" """
This method is not used since we override __call__. This method is not used since we override __call__.
Required by ActionProcessorStep ABC. Required by ActionProcessorStep ABC.
""" """
tokens, _ = self._tokenize_action(action) tokens, _, _ = self._tokenize_action(action)
return tokens return tokens
def get_config(self) -> dict[str, Any]: def get_config(self) -> dict[str, Any]:
@@ -550,6 +572,9 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
config = { config = {
"trust_remote_code": self.trust_remote_code, "trust_remote_code": self.trust_remote_code,
"max_action_tokens": self.max_action_tokens, "max_action_tokens": self.max_action_tokens,
"fast_skip_tokens": self.fast_skip_tokens,
"paligemma_tokenizer_name": self.paligemma_tokenizer_name,
"allow_truncation": self.allow_truncation,
} }
# Only save tokenizer_name if it was used to create the tokenizer # Only save tokenizer_name if it was used to create the tokenizer
@@ -558,6 +583,14 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
return config return config
def save_artifacts(self, save_directory: Path) -> dict[str, str]:
artifact_path = Path("action_tokenizer")
save_pretrained = getattr(self.action_tokenizer, "save_pretrained", None)
if save_pretrained is None:
raise TypeError("Action tokenizer must implement save_pretrained() to save a portable pipeline.")
save_pretrained(save_directory / artifact_path)
return {"action_tokenizer_name": artifact_path.as_posix()}
def transform_features( def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]] self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]: ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
+3 -1
View File
@@ -21,6 +21,8 @@ from lerobot.utils.import_utils import make_device_from_device_class
from .config import RobotConfig from .config import RobotConfig
from .robot import Robot from .robot import Robot
logger = logging.getLogger(__name__)
def make_robot_from_config(config: RobotConfig) -> Robot: def make_robot_from_config(config: RobotConfig) -> Robot:
# TODO(Steven): Consider just using the make_device_from_device_class for all types # TODO(Steven): Consider just using the make_device_from_device_class for all types
@@ -118,7 +120,7 @@ def ensure_safe_goal_position(
} }
if warnings_dict: if warnings_dict:
logging.warning( logger.warning(
"Relative goal position magnitude had to be clamped to be safe.\n" "Relative goal position magnitude had to be clamped to be safe.\n"
f"{pformat(warnings_dict, indent=4)}" f"{pformat(warnings_dict, indent=4)}"
) )
+38
View File
@@ -0,0 +1,38 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Policy-agnostic runtime for language-conditioned policies.
Adapters registered in :mod:`lerobot.runtime.registry` are served by ``lerobot-rollout --language``.
"""
from .adapter import BaseLanguageAdapter, GenerationConfig, LanguageDiagnostics
from .language_runtime import (
LanguageConditionedPolicyAdapter,
LanguageConditionedRuntime,
RuntimeState,
Tick,
TickClock,
)
__all__ = [
"BaseLanguageAdapter",
"GenerationConfig",
"LanguageConditionedPolicyAdapter",
"LanguageConditionedRuntime",
"LanguageDiagnostics",
"RuntimeState",
"Tick",
"TickClock",
]
+165
View File
@@ -0,0 +1,165 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Policy adapters for the language runtime.
The base adapter owns generation control and diagnostics while subclasses provide policy-specific actions and text.
"""
from __future__ import annotations
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
from .language_runtime import RuntimeState
_SAY_RE = re.compile(r"<\s*say\s*>(.*?)<\s*/\s*say\s*>", re.IGNORECASE | re.DOTALL)
@dataclass
class GenerationConfig:
"""Text-generation settings fixed for the adapter's lifetime."""
min_new_tokens: int = 0
temperature: float = 0.0
top_p: float = 1.0
chunks_per_regen: int = 1 # regenerate the language context every N action chunks
enable_memory: bool = True # generate a running memory note on subtask change
enable_subtask: bool = True # generate the low-level subtask (off => use the given text directly)
@dataclass
class LanguageDiagnostics:
"""Runtime-panel generation counters keyed by text kind."""
last_raw: dict[str, str] = field(default_factory=dict)
empty: dict[str, int] = field(default_factory=dict)
repeat: int = 0
def _bump(self, table: dict[str, int], kind: str) -> int:
table[kind] = table.get(kind, 0) + 1
return table[kind]
class BaseLanguageAdapter(ABC):
"""Batteries-included adapter: generic high-level control, policy primitives abstract."""
def __init__(self, policy: Any, gen: GenerationConfig | None = None) -> None:
self.policy = policy
self.gen = gen or GenerationConfig()
self.diag = LanguageDiagnostics()
self._chunks_until_regen = 0
@abstractmethod
def select_action(self, observation: dict[str, Any], state: RuntimeState) -> Any:
"""Produce an action chunk from the observation + current language context."""
@abstractmethod
def generate_text(
self,
kind: str,
observation: dict[str, Any] | None,
state: RuntimeState,
user_text: str | None = None,
) -> str:
"""Generate one text stream (``kind``) and return the decoded string."""
def update_language_state(self, observation: dict[str, Any] | None, state: RuntimeState) -> None:
"""Throttled regeneration of the language context (subtask / memory / ...)."""
if self._chunks_until_regen > 0:
self._chunks_until_regen -= 1
return
self._chunks_until_regen = max(1, self.gen.chunks_per_regen) - 1
self._regenerate_context(observation, state)
def handle_interjection(
self, user_text: str, observation: dict[str, Any] | None, state: RuntimeState
) -> None:
"""React to a mid-run user message by regenerating the plan."""
out = self.generate_text("interjection", observation, state, user_text=user_text)
plan = self.plan_from_text(out)
if plan:
state.set_context("plan", plan, label="plan")
def plan_from_text(self, text: str) -> str:
"""Strip ``<say>`` speech markers from a generated plan."""
plan, _speech = split_plan_and_say(text)
return plan
def _regenerate_context(self, observation: dict[str, Any] | None, state: RuntimeState) -> None:
"""Default hierarchy: regenerate the subtask, then memory when it changes.
Override for a policy with a different language hierarchy.
"""
if not self.gen.enable_subtask:
# Preserve operator-provided subtasks in direct mode.
return
subtask = self._generate_filtered("subtask", observation, state)
if subtask is None:
return
previous = state.language_context.get("subtask")
if not state.set_context("subtask", subtask, label="subtask"):
self.diag.repeat += 1
return
self.diag.repeat = 0
if previous:
state.extra["prior_subtask"] = previous
if not self.gen.enable_memory:
return
memory = self._generate_filtered("memory", observation, state)
if memory is not None:
state.set_context("memory", memory, label="memory")
def _generate_filtered(
self, kind: str, observation: dict[str, Any] | None, state: RuntimeState
) -> str | None:
"""Generate one ``kind``, record diagnostics, and drop empty output."""
text = self.generate_text(kind, observation, state)
self.diag.last_raw[kind] = text or ""
if not text:
count = self.diag._bump(self.diag.empty, kind)
if count == 1 or count % 5 == 0:
state.log(f" [info] {kind} gen returned empty (x{count})")
return None
return text
class DirectTaskPolicyAdapter(BaseLanguageAdapter):
"""Adapter for flat policies whose preprocessors condition actions on the operator's task."""
def select_action(self, observation: dict[str, Any], state: RuntimeState) -> Any:
return self.policy.predict_action_chunk(observation)
def generate_text(
self,
kind: str,
observation: dict[str, Any] | None,
state: RuntimeState,
user_text: str | None = None,
) -> str:
return ""
def split_plan_and_say(text: str) -> tuple[str, str]:
"""Split ``plan <say>speech</say>`` into ``(plan, speech)``."""
if not text:
return "", ""
match = _SAY_RE.search(text)
if not match:
return text.strip(), ""
speech = match.group(1).strip().strip('"').strip("'")
plan = (text[: match.start()] + text[match.end() :]).strip()
return plan, speech
File diff suppressed because it is too large Load Diff
+349
View File
@@ -0,0 +1,349 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Small reusable runtime for language-conditioned robot policies."""
from __future__ import annotations
import logging
import threading
import time
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, Protocol
logger = logging.getLogger(__name__)
@dataclass
class RuntimeState:
"""Explicit state shared by the runtime and policy adapter."""
task: str = ""
language_context: dict[str, str] = field(default_factory=dict)
action_queue: deque[Any] = field(default_factory=deque)
events: set[str] = field(default_factory=set)
log_lines: list[str] = field(default_factory=list)
mode: str = "action"
stop: bool = False
tick: Tick | None = None
actions_dispatched: int = 0
action_deadline: float | None = None
extra: dict[str, Any] = field(default_factory=dict)
revision: int = 0
lock: Any = field(default_factory=threading.RLock, repr=False)
def emit(self, event_name: str) -> None:
self.events.add(event_name)
def take_event(self, event_name: str) -> bool:
if event_name not in self.events:
return False
self.events.remove(event_name)
return True
def log(self, line: str) -> None:
self.log_lines.append(line)
def set_context(self, key: str, value: str | None, *, label: str | None = None) -> bool:
with self.lock:
previous = self.language_context.get(key)
if previous == value:
return False
if value is None:
self.language_context.pop(key, None)
else:
self.language_context[key] = value
self.revision += 1
if label is not None and value:
self.log(f" {label}: {value}")
return True
def get(self, key: str, default: Any = None) -> Any:
try:
return self[key]
except KeyError:
return default
def setdefault(self, key: str, default: Any = None) -> Any:
current = self.get(key, None)
if current is not None:
return current
self[key] = default
return default
def __getitem__(self, key: str) -> Any:
if hasattr(self, key):
return getattr(self, key)
if key in self.extra:
return self.extra[key]
raise KeyError(key)
def __setitem__(self, key: str, value: Any) -> None:
with self.lock:
if hasattr(self, key):
if key == "mode" and self.mode != value:
self.revision += 1
setattr(self, key, value)
else:
self.extra[key] = value
class LanguageConditionedPolicyAdapter(Protocol):
"""Runtime policy contract, implemented directly or through ``BaseLanguageAdapter``."""
def select_action(self, observation: dict[str, Any], state: RuntimeState) -> Any: ...
def update_language_state(self, observation: dict[str, Any] | None, state: RuntimeState) -> None: ...
def handle_interjection(
self, user_text: str, observation: dict[str, Any] | None, state: RuntimeState
) -> None: ...
@dataclass
class Tick:
index: int
monotonic_seconds: float
@dataclass
class TickClock:
max_rate_hz: float = 50.0
_index: int = field(default=0, init=False)
_last_seconds: float | None = field(default=None, init=False)
def advance(self) -> Tick:
period = 1.0 / max(self.max_rate_hz, 0.1)
now = time.monotonic()
if self._last_seconds is not None:
sleep_for = (self._last_seconds + period) - now
if sleep_for > 0:
time.sleep(sleep_for)
now = time.monotonic()
self._last_seconds = now
self._index += 1
return Tick(index=self._index, monotonic_seconds=now)
@dataclass
class _RateGate:
hz: float
_last_seconds: float | None = None
def due(self, tick: Tick, *, force: bool = False) -> bool:
if force:
self._last_seconds = tick.monotonic_seconds
return True
period = 1.0 / max(self.hz, 1e-6)
if self._last_seconds is None or tick.monotonic_seconds - self._last_seconds >= period:
self._last_seconds = tick.monotonic_seconds
return True
return False
def rearm(self) -> None:
self._last_seconds = None
@dataclass
class LanguageConditionedRuntime:
"""Generic tick loop for language-conditioned robot policies."""
policy_adapter: LanguageConditionedPolicyAdapter
observation_provider: Callable[[], dict[str, Any] | None] | None = None
action_executor: Callable[[Any], None] | None = None
event_collector: Callable[[RuntimeState], None] | None = None
chunk_hz: float = 4.0
ctrl_hz: float = 50.0
high_level_hz: float = 1.0
max_rate_hz: float = 50.0
state: RuntimeState = field(default_factory=RuntimeState)
_chunk_gate: _RateGate = field(init=False)
_ctrl_gate: _RateGate = field(init=False)
_language_gate: _RateGate = field(init=False)
_stop: bool = field(default=False, init=False)
_last_dispatch_seconds: float | None = field(default=None, init=False)
def __post_init__(self) -> None:
self._chunk_gate = _RateGate(self.chunk_hz)
self._ctrl_gate = _RateGate(self.ctrl_hz)
self._language_gate = _RateGate(self.high_level_hz)
@property
def policy(self) -> Any:
return getattr(self.policy_adapter, "policy", self.policy_adapter)
def set_task(self, task: str) -> None:
with self.state.lock:
if self.state.task != task:
self.state.revision += 1
self.state.task = task
self.state.log(f"Task: {task}")
def stop(self) -> None:
self._stop = True
self.state.stop = True
def run(self, *, max_ticks: int | None = None) -> None:
clock = TickClock(max_rate_hz=self.max_rate_hz)
while not self._stop:
tick = clock.advance()
self._run_tick(tick)
self._flush_logs()
if self.state.stop:
self._stop = True
if max_ticks is not None and tick.index >= max_ticks:
break
self._on_shutdown()
def step_once(self) -> list[str]:
previous = self.state.tick.index if self.state.tick is not None else 0
tick = Tick(index=previous + 1, monotonic_seconds=time.monotonic())
self._run_tick(tick, force_rates=True)
return list(self.state.log_lines)
def _run_tick(self, tick: Tick, *, force_rates: bool = False) -> None:
self.state.tick = tick
self.state.log_lines = []
if self.event_collector is not None:
self.event_collector(self.state)
self._handle_action_deadline()
if self.state.stop:
return
self.maybe_update_language_state(force=force_rates)
self.maybe_handle_user_events()
self.maybe_enqueue_action_chunk(force=force_rates)
self.dispatch_action(force=force_rates)
self.state.events.clear()
def _current_observation(self) -> dict[str, Any] | None:
if self.observation_provider is None:
return None
try:
return self.observation_provider()
except Exception as exc: # noqa: BLE001
logger.debug("observation_provider failed: %s", exc)
return None
def maybe_update_language_state(self, *, force: bool = False) -> None:
if self.state.mode != "action" or not self.state.task:
return
if self.state.action_queue:
self._language_gate.rearm()
return
if self.state.tick is None or not self._language_gate.due(self.state.tick, force=force):
return
observation = self._current_observation()
try:
self.policy_adapter.update_language_state(observation, self.state)
except Exception as exc: # noqa: BLE001
logger.warning("language update failed: %s", exc, exc_info=logger.isEnabledFor(logging.DEBUG))
self.state.log(f" [warn] language update failed: {type(exc).__name__}: {exc}")
def maybe_handle_user_events(self) -> None:
if self.state.take_event("user_interjection"):
self._handle_user_interjection()
def _handle_user_interjection(self) -> None:
text = str(self.state.extra.get("recent_interjection") or "")
if not text:
return
observation = self._current_observation()
self.policy_adapter.handle_interjection(text, observation, self.state)
self.state.extra["recent_interjection"] = None
def maybe_enqueue_action_chunk(self, *, force: bool = False) -> None:
with self.state.lock:
if self.state.mode != "action" or not self.state.task:
return
if self.state.action_queue:
return
if self.state.tick is None or not self._chunk_gate.due(self.state.tick, force=force):
return
revision = self.state.revision
observation = self._current_observation()
if observation is None:
return
try:
chunk = self.policy_adapter.select_action(observation, self.state)
except Exception as exc: # noqa: BLE001
logger.warning("select_action failed: %s", exc, exc_info=logger.isEnabledFor(logging.DEBUG))
self.state.log(f" [warn] select_action failed: {type(exc).__name__}: {exc}")
return
with self.state.lock:
if (
self.state.revision != revision
or self.state.mode != "action"
or self.state.stop
or self._stop
):
logger.info("Discarded an action chunk invalidated during inference.")
return
self._enqueue_chunk(chunk)
def _enqueue_chunk(self, chunk: Any) -> None:
if chunk is None:
return
chunk_iter = chunk[0] if getattr(chunk, "ndim", None) == 3 else chunk
if getattr(chunk_iter, "ndim", None) == 1:
chunk_iter = chunk_iter.unsqueeze(0)
for step in chunk_iter:
self.state.action_queue.append(step.unsqueeze(0) if hasattr(step, "unsqueeze") else step)
try:
self.state.extra["last_chunk_size"] = int(chunk_iter.shape[0])
except Exception: # noqa: BLE001
self.state.extra["last_chunk_size"] = len(self.state.action_queue)
def dispatch_action(self, *, force: bool = False) -> None:
if self.state.mode != "action":
self._last_dispatch_seconds = None
return
if self.state.tick is None or not self._ctrl_gate.due(self.state.tick, force=force):
return
queue = self.state.action_queue
if not queue:
self._last_dispatch_seconds = None
return
now = time.monotonic()
if self._last_dispatch_seconds is None or self.ctrl_hz <= 0:
n_to_pop = 1
else:
n_to_pop = max(1, min(len(queue), int(round((now - self._last_dispatch_seconds) * self.ctrl_hz))))
self._last_dispatch_seconds = now
latest = None
for _ in range(n_to_pop):
if not queue:
break
latest = queue.popleft()
self.state.actions_dispatched += 1
if latest is not None and self.action_executor is not None:
self.action_executor(latest)
def _handle_action_deadline(self) -> None:
deadline = self.state.action_deadline
if self.state.mode == "action" and deadline is not None and time.monotonic() >= deadline:
self.state.mode = "paused"
self.state.action_deadline = None
self.state.action_queue.clear()
self.state.log("timed action elapsed — paused")
def _flush_logs(self) -> None:
for line in self.state.log_lines:
print(f"[runtime] {line}", flush=True)
def _on_shutdown(self) -> None:
self.state.action_queue.clear()
print("[runtime] stopped", flush=True)
+39
View File
@@ -0,0 +1,39 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Lazy mapping from policy types to language-runtime adapters."""
from __future__ import annotations
import importlib
from collections.abc import Callable
from typing import Any
_ADAPTERS: dict[str, str] = {
"pi052": "lerobot.policies.pi052.inference.pi052_adapter:PI052PolicyAdapter",
"pi05": "lerobot.runtime.adapter:DirectTaskPolicyAdapter",
"molmoact2": "lerobot.runtime.adapter:DirectTaskPolicyAdapter",
}
def get_language_adapter_factory(policy_type: str) -> Callable[..., Any]:
"""Return the adapter class registered for ``policy_type``."""
spec = _ADAPTERS.get(policy_type)
if spec is None:
raise ValueError(
f"No language-runtime adapter registered for policy type {policy_type!r}. "
f"Registered: {sorted(_ADAPTERS)}. Add an entry to lerobot.runtime.registry."
)
module_path, class_name = spec.split(":")
return getattr(importlib.import_module(module_path), class_name)
+406
View File
@@ -0,0 +1,406 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""RoboCasa backend for interactive language-conditioned rollouts.
It reuses the eval observation/action pipeline while prompts control a persistent selected scene.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any
import numpy as np
import torch
from lerobot.utils.io_utils import StreamingVideoWriter
from lerobot.utils.video_annotation import annotate_frame
logger = logging.getLogger(__name__)
def _short_cam_name(cam: str) -> str:
"""Human-friendly view label for a RoboCasa camera name."""
c = cam.replace("robot0_", "")
return {
"agentview_left": "left",
"agentview_right": "right",
"eye_in_hand": "wrist",
}.get(c, c)
def _label_panel(img: np.ndarray, label: str) -> np.ndarray:
"""Draw a small camera-view label in the bottom-left corner of a panel."""
try:
import cv2 # noqa: PLC0415
except ImportError:
return img
y = img.shape[0] - 6
cv2.putText(img, label, (5, y), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 0), 3, cv2.LINE_AA)
cv2.putText(img, label, (5, y), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 0), 1, cv2.LINE_AA)
return img
# Two workers avoid broken single-worker EGL rendering; only env 0 is displayed.
_SIM_N_ENVS = 2
def create_sim_env(
*,
task: str,
split: str | None,
obj_registries: list[str],
seed: int | None,
render_size: int = 384,
) -> tuple[Any, dict]:
"""Create and reset the vectorized RoboCasa environment before CUDA initializes.
Two workers keep EGL stable, while only env 0 is driven and displayed.
"""
from lerobot.envs.configs import RoboCasaEnv as RoboCasaEnvConfig # noqa: PLC0415
# The policy resizes inputs, so render_size only affects display quality and cost.
env_cfg = RoboCasaEnvConfig(
task=task,
split=split,
obj_registries=list(obj_registries),
observation_height=render_size,
observation_width=render_size,
)
# Keep one kitchen alive across sequential prompts.
envs = env_cfg.create_envs(
n_envs=_SIM_N_ENVS,
use_async_envs=True,
terminate_on_success=False,
horizon=100_000,
)
env = envs[next(iter(envs))][0]
logger.info("[sim] resetting RoboCasa scene task=%r split=%r (n_envs=%d)", task, split, _SIM_N_ENVS)
seeds = None if seed is None else [seed + i for i in range(_SIM_N_ENVS)]
obs, _ = env.reset(seed=seeds)
return env, obs
def start_mjpeg_server(port: int, get_frame: Callable[[], np.ndarray | None]) -> Any:
"""Start an MJPEG server that shows a placeholder until ``get_frame`` returns frames."""
import io # noqa: PLC0415
import threading # noqa: PLC0415
import time # noqa: PLC0415
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # noqa: PLC0415
from PIL import Image # noqa: PLC0415
_placeholder = Image.new("RGB", (256, 256), (17, 17, 17))
class _Handler(BaseHTTPRequestHandler):
def log_message(self, *args): # silence per-request logging
pass
def do_GET(self): # noqa: N802
if self.path in ("/", "/index.html"):
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body style='margin:0;background:#111;text-align:center'>"
b"<img src='/stream' style='max-width:100vw;max-height:100vh;"
b"image-rendering:pixelated'></body></html>"
)
return
if self.path != "/stream":
self.send_response(404)
self.end_headers()
return
self.send_response(200)
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
self.end_headers()
try:
while True:
frame = get_frame()
buf = io.BytesIO()
img = Image.fromarray(frame) if frame is not None else _placeholder
img.save(buf, format="JPEG", quality=80)
data = buf.getvalue()
self.wfile.write(
b"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
+ str(len(data)).encode()
+ b"\r\n\r\n"
+ data
+ b"\r\n"
)
time.sleep(0.05)
except (BrokenPipeError, ConnectionResetError):
pass
try:
# Bind all interfaces intentionally so the viewer remains reachable
# through the documented SSH port-forwarding workflow.
server = ThreadingHTTPServer(("0.0.0.0", port), _Handler) # nosec B104
except OSError as exc:
logger.warning("[sim] could not start live stream on port %d: %s", port, exc)
print(f"[runtime] WARNING: live stream port {port} unavailable ({exc})", flush=True)
return None
threading.Thread(target=server.serve_forever, daemon=True, name="sim-mjpeg").start()
print(
f"[runtime] live view: http://localhost:{port} "
f"(over SSH: ssh -L {port}:localhost:{port} <host>) — loading until scene is ready",
flush=True,
)
return server
class RoboCasaSimBackend:
"""Expose a RoboCasa environment through the runtime observation/action contract.
The environment must be created before the policy initializes CUDA.
"""
def __init__(
self,
*,
env: Any,
last_obs: dict,
task: str,
seed: int | None,
device: str,
preprocessor: Any,
postprocessor: Any,
record: bool = True,
output_dir: str | None = None,
view_cams: list[str] | None = None,
) -> None:
self.env = env
self._last_obs = last_obs
self._scene_task = task
self._view_cams = view_cams or [
"robot0_agentview_left",
"robot0_eye_in_hand",
"robot0_agentview_right",
]
self.device = torch.device(device) if isinstance(device, str) else device
self.preprocessor = preprocessor
self.postprocessor = postprocessor
self.seed = seed
self.record = record
self.output_dir = Path(output_dir) if output_dir else Path("outputs/runtime_sim")
self._video_writer: StreamingVideoWriter | None = None
self._video_path: Path | None = None
self._live_counter = 0
self._latest_frame: np.ndarray | None = None
self._stream_server: Any = None
self._reset_count = 0
# Bind these after runtime construction for live annotations.
self._task_getter: Callable[[], str | None] | None = None
self._subtask_getter: Callable[[], str | None] | None = None
self._memory_getter: Callable[[], str | None] | None = None
logger.info("[sim] scene ready — task_description=%r", self._scene_description())
def bind_runtime(self, runtime: Any) -> None:
"""Wire live task/subtask/memory getters from the runtime state."""
self._task_getter = lambda: runtime.state.get("task")
self._subtask_getter = lambda: runtime.state.language_context.get("subtask")
self._memory_getter = lambda: (runtime.state.get("language_context") or {}).get("memory")
def _scene_description(self) -> str:
try:
return str(self.env.get_attr("task_description")[0]) or self._scene_task
except Exception: # noqa: BLE001
return self._scene_task
def _current_task(self) -> str:
task = self._task_getter() if self._task_getter else None
return task or self._scene_description() or self._scene_task
def reset_scene(self) -> None:
"""Re-roll the kitchen: reset the env to a fresh scene (new layout/style).
Uses a new seed each call so ``/reset`` explores different kitchens.
"""
self._reset_count += 1
n = self.env.num_envs
if self.seed is None:
seeds = None
else:
base = self.seed + self._reset_count * 1000
seeds = [base + i for i in range(n)]
obs, _ = self.env.reset(seed=seeds)
self._last_obs = obs
logger.info("[sim] scene reset (#%d)", self._reset_count)
def _env0_obs(self) -> dict:
"""Slice env 0 out of the batched vec-env observation (batch of 1)."""
raw = self._last_obs or {}
pixels = raw.get("pixels")
out: dict[str, Any] = {}
if isinstance(pixels, dict):
out["pixels"] = {k: np.asarray(v)[0:1] for k, v in pixels.items()}
agent_pos = raw.get("agent_pos")
if agent_pos is not None:
out["agent_pos"] = np.asarray(agent_pos)[0:1]
return out
def observation_provider(self) -> dict | None:
from lerobot.envs.utils import preprocess_observation # noqa: PLC0415
try:
obs = preprocess_observation(self._env0_obs())
except Exception as exc: # noqa: BLE001
logger.warning("[sim] preprocess_observation failed: %s", exc)
return None
# The adapter later replaces this recipe input with its generated subtask.
obs["task"] = [self._current_task()]
if self.preprocessor is not None:
try:
obs = self.preprocessor(obs)
except Exception as exc: # noqa: BLE001
logger.warning("[sim] preprocessor failed: %s", exc)
return None
return {
k: (v.to(self.device) if isinstance(v, torch.Tensor) else v)
for k, v in obs.items()
if isinstance(k, str) and k.startswith("observation.")
}
def action_executor(self, action: Any) -> None:
try:
if self.postprocessor is not None:
action = self.postprocessor(action)
if isinstance(action, torch.Tensor):
if action.ndim > 1 and action.shape[0] == 1:
action = action.squeeze(0)
action = action.detach().to("cpu").numpy()
# Tile env 0's action because the extra workers exist only for EGL stability.
action_row = np.asarray(action, dtype=np.float32).reshape(-1)
action_np = np.tile(action_row, (self.env.num_envs, 1))
obs, _reward, terminated, truncated, _info = self.env.step(action_np)
self._last_obs = obs
self._capture_frame()
# AsyncVectorEnv resets terminated sub-environments automatically.
if bool(np.any(terminated)) or bool(np.any(truncated)):
logger.info("[sim] episode ended — scene auto-reset")
except Exception as exc: # noqa: BLE001
logger.error("[sim] env.step failed: %s", exc, exc_info=True)
def _multiview_frame(self) -> np.ndarray | None:
"""Label and compose env 0's existing observation views without extra rendering."""
pixels = (self._last_obs or {}).get("pixels")
if not isinstance(pixels, dict) or not pixels:
return None
panels: list[np.ndarray] = []
for cam in self._view_cams:
v = pixels.get(cam)
if v is None:
continue
img = np.asarray(v)
if img.ndim == 4: # (n_envs, H, W, C) -> env 0
img = img[0]
if img.ndim != 3 or img.shape[-1] != 3:
continue
panels.append(_label_panel(np.ascontiguousarray(img.astype(np.uint8)), _short_cam_name(cam)))
if not panels:
return None
h = min(p.shape[0] for p in panels)
panels = [p[:h] for p in panels]
return np.concatenate(panels, axis=1)
def _capture_frame(self) -> None:
frame = self._multiview_frame()
if frame is None: # fallback to single env.render()
try:
rendered = self.env.call("render")[0]
if isinstance(rendered, np.ndarray) and rendered.ndim == 3:
frame = rendered
except Exception as exc: # noqa: BLE001
logger.debug("[sim] render failed: %s", exc)
if frame is None:
return
subtask = self._subtask_getter() if self._subtask_getter else None
memory = self._memory_getter() if self._memory_getter else None
annotated = annotate_frame(
frame,
(("Task", self._current_task()), ("Subtask", subtask), ("Memory", memory)),
)
self._latest_frame = annotated # served by the live MJPEG stream
self._write_live_frame(annotated)
if self.record:
self._write_recording_frame(annotated)
def _write_recording_frame(self, frame: np.ndarray) -> None:
try:
if self._video_writer is None:
self.output_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self._video_path = self.output_dir / f"sim_{stamp}.mp4"
fps = int((getattr(self.env, "metadata", None) or {}).get("render_fps", 20))
self._video_writer = StreamingVideoWriter(self._video_path, fps)
self._video_writer.add_frame(frame)
except Exception as exc: # noqa: BLE001
logger.warning("[sim] video encoding failed: %s", exc)
self.record = False
def _write_live_frame(self, frame: np.ndarray) -> None:
"""Write a rolling latest.png every few frames for live viewing over SSH.
Open ``{output_dir}/latest.png`` in an editor/viewer and refresh to watch
the rollout in near-real-time without a GUI window. Written atomically
(temp + replace) so a reader never sees a half-written file.
"""
self._live_counter += 1
if self._live_counter % 3 != 0:
return
try:
import os # noqa: PLC0415
from PIL import Image # noqa: PLC0415
self.output_dir.mkdir(parents=True, exist_ok=True)
tmp = self.output_dir / ".latest.tmp.png"
Image.fromarray(frame).save(tmp)
os.replace(tmp, self.output_dir / "latest.png")
except Exception as exc: # noqa: BLE001
logger.debug("[sim] live frame write failed: %s", exc)
def _flush_video(self) -> None:
if self._video_writer is None:
return
writer = self._video_writer
self._video_writer = None
try:
writer.close()
logger.info("[sim] wrote video (%d frames) to %s", writer.frames_written, self._video_path)
print(f"[runtime] sim video saved to {self._video_path}", flush=True)
except Exception as exc: # noqa: BLE001
logger.warning("[sim] video close failed: %s", exc)
def attach_stream_server(self, server: Any) -> None:
"""Attach an already-running MJPEG server so disconnect() can stop it."""
self._stream_server = server
def disconnect(self) -> None:
"""Match the robot backend's cleanup contract."""
if self._stream_server is not None:
try:
self._stream_server.shutdown()
except Exception as exc: # noqa: BLE001
logger.debug("[sim] stream server shutdown raised %s", exc)
self._flush_video()
try:
self.env.close()
except Exception as exc: # noqa: BLE001
logger.debug("[sim] env.close raised %s", exc)
+40 -2
View File
@@ -94,6 +94,19 @@ from lerobot.utils.utils import (
init_logging, init_logging,
inside_slurm, inside_slurm,
) )
from lerobot.utils.video_annotation import annotate_frame
def _annotate_eval_frames(frames: np.ndarray, task: str | None, subtask: str | None) -> np.ndarray:
"""Overlay the high-level task and predicted subtask onto rendered frames.
``frames`` is ``(n_envs, H, W, C)`` uint8. Best-effort: if OpenCV isn't
available the frames are returned unchanged so eval never fails over a
visualization concern.
"""
if frames.ndim != 4 or frames.shape[-1] != 3:
return frames
return np.stack([annotate_frame(frame, (("Task", task), ("Subtask", subtask))) for frame in frames])
def _env_features_to_dataset_features(env_features: dict) -> dict: def _env_features_to_dataset_features(env_features: dict) -> dict:
@@ -477,11 +490,36 @@ def eval_policy(
return return
n_to_render_now = min(max_episodes_rendered - n_episodes_rendered, env.num_envs) n_to_render_now = min(max_episodes_rendered - n_episodes_rendered, env.num_envs)
if isinstance(env, gym.vector.SyncVectorEnv): if isinstance(env, gym.vector.SyncVectorEnv):
ep_frames.append(np.stack([env.envs[i].render() for i in range(n_to_render_now)])) # noqa: B023 frames = np.stack([env.envs[i].render() for i in range(n_to_render_now)]) # noqa: B023
elif hasattr(env, "call"): elif hasattr(env, "call"):
# Here we must render all frames and discard any we don't need. # Here we must render all frames and discard any we don't need.
# Covers AsyncVectorEnv and _LazyAsyncVectorEnv (which wraps one). # Covers AsyncVectorEnv and _LazyAsyncVectorEnv (which wraps one).
ep_frames.append(np.stack(env.call("render")[:n_to_render_now])) frames = np.stack(env.call("render")[:n_to_render_now])
else:
return
# Overlay the high-level task and (for hierarchical policies like
# pi052) the predicted low-level subtask onto each frame. Both are
# best-effort: missing values just skip that line.
try:
tasks = list(env.call("task_description"))
except (AttributeError, NotImplementedError):
try:
tasks = list(env.call("task"))
except (AttributeError, NotImplementedError):
tasks = None
subtasks = getattr(policy, "last_subtasks", None)
annotated = []
for i in range(frames.shape[0]):
subtask_i = subtasks[i] if subtasks is not None and i < len(subtasks) else None
annotated.append(
_annotate_eval_frames(
frames[i : i + 1],
tasks[i] if tasks is not None and i < len(tasks) else None,
subtask_i,
)[0]
)
ep_frames.append(np.stack(annotated))
if max_episodes_rendered > 0: if max_episodes_rendered > 0:
video_paths: list[str] = [] video_paths: list[str] = []
+63 -3
View File
@@ -151,6 +151,7 @@ Usage examples
""" """
import logging import logging
import sys
from lerobot.cameras.opencv import OpenCVCameraConfig # noqa: F401 from lerobot.cameras.opencv import OpenCVCameraConfig # noqa: F401
from lerobot.cameras.realsense import RealSenseCameraConfig # noqa: F401 from lerobot.cameras.realsense import RealSenseCameraConfig # noqa: F401
@@ -241,10 +242,69 @@ def rollout(cfg: RolloutConfig):
logger.info("Rollout finished") logger.info("Rollout finished")
def main(): _LANGUAGE_RUNTIME_FLAGS = {
"""CLI entry point for ``lerobot-rollout``.""" "--language",
"--no_robot",
"--sim",
"--direct_subtask",
"--sim.direct_subtask",
"--disable_memory",
"--fp8",
}
_LANGUAGE_RUNTIME_PREFIXES = (
"--sim.",
"--chunk_hz",
"--ctrl_hz",
"--high_level_hz",
"--subtask_chunks_per_gen",
"--text_min_new_tokens",
"--text_temperature",
"--text_top_p",
)
def _uses_language_runtime(argv: list[str]) -> bool:
"""Return whether *argv* selects the interactive language runtime.
``--language`` is the explicit selector for real-robot runs whose other
options overlap with the standard rollout CLI. Language-only options also
select it automatically, which keeps the former language-runtime examples
working after replacing their command name with ``lerobot-rollout``.
"""
return any(
arg.split("=", 1)[0] in _LANGUAGE_RUNTIME_FLAGS or arg.startswith(_LANGUAGE_RUNTIME_PREFIXES)
for arg in argv
)
def main(argv: list[str] | None = None):
"""CLI entry point for ``lerobot-rollout``.
Standard policy deployment continues through :class:`RolloutConfig`.
Interactive language-conditioned and RoboCasa runs share this entry point
and are selected with ``--language`` or any language-runtime-only option.
"""
register_third_party_plugins() register_third_party_plugins()
rollout() cli_args = list(sys.argv[1:] if argv is None else argv)
if _uses_language_runtime(cli_args):
from lerobot.runtime.cli import run as run_language_runtime
# ``--language`` is a dispatcher flag, not part of the runtime's own
# argparse surface. All other arguments pass through unchanged.
runtime_args = [arg for arg in cli_args if arg != "--language"]
return run_language_runtime(runtime_args, prog="lerobot-rollout")
if argv is None:
return rollout()
# draccus reads sys.argv. Supporting an explicit argv keeps this entry
# point easy to smoke-test and mirrors the language-runtime branch above.
previous_argv = sys.argv
try:
sys.argv = [previous_argv[0], *cli_args]
return rollout()
finally:
sys.argv = previous_argv
if __name__ == "__main__": if __name__ == "__main__":
+1 -1
View File
@@ -22,7 +22,7 @@ from torch.utils.data._utils.collate import default_collate
from lerobot.datasets.language import LANGUAGE_COLUMNS from lerobot.datasets.language import LANGUAGE_COLUMNS
_PYTHON_LIST_KEYS = {"messages", "message_streams", "target_message_indices"} _PYTHON_LIST_KEYS = {"messages", "message_streams", "target_message_indices", *LANGUAGE_COLUMNS}
def lerobot_collate_fn(batch: list[dict[str, Any] | None]) -> dict[str, Any] | None: def lerobot_collate_fn(batch: list[dict[str, Any] | None]) -> dict[str, Any] | None:
+2
View File
@@ -26,6 +26,7 @@ OBS_IMAGES = OBS_IMAGE + "s"
OBS_LANGUAGE = OBS_STR + ".language" OBS_LANGUAGE = OBS_STR + ".language"
OBS_LANGUAGE_TOKENS = OBS_LANGUAGE + ".tokens" OBS_LANGUAGE_TOKENS = OBS_LANGUAGE + ".tokens"
OBS_LANGUAGE_ATTENTION_MASK = OBS_LANGUAGE + ".attention_mask" OBS_LANGUAGE_ATTENTION_MASK = OBS_LANGUAGE + ".attention_mask"
OBS_LANGUAGE_CAUSAL_MARKS = OBS_LANGUAGE + ".causal_marks"
OBS_LANGUAGE_SUBTASK = OBS_STR + ".subtask" OBS_LANGUAGE_SUBTASK = OBS_STR + ".subtask"
OBS_LANGUAGE_SUBTASK_TOKENS = OBS_LANGUAGE_SUBTASK + ".tokens" OBS_LANGUAGE_SUBTASK_TOKENS = OBS_LANGUAGE_SUBTASK + ".tokens"
OBS_LANGUAGE_SUBTASK_ATTENTION_MASK = OBS_LANGUAGE_SUBTASK + ".attention_mask" OBS_LANGUAGE_SUBTASK_ATTENTION_MASK = OBS_LANGUAGE_SUBTASK + ".attention_mask"
@@ -34,6 +35,7 @@ ACTION = "action"
ACTION_PREFIX = ACTION + "." ACTION_PREFIX = ACTION + "."
ACTION_TOKENS = ACTION + ".tokens" ACTION_TOKENS = ACTION + ".tokens"
ACTION_TOKEN_MASK = ACTION + ".token_mask" ACTION_TOKEN_MASK = ACTION + ".token_mask"
ACTION_CODE_TOKEN_MASK = ACTION + ".code_token_mask"
REWARD = "next.reward" REWARD = "next.reward"
TRUNCATED = "next.truncated" TRUNCATED = "next.truncated"
DONE = "next.done" DONE = "next.done"
+45 -29
View File
@@ -23,6 +23,46 @@ logger = logging.getLogger(__name__)
JsonLike = str | int | float | bool | None | list["JsonLike"] | dict[str, "JsonLike"] | tuple["JsonLike", ...] JsonLike = str | int | float | bool | None | list["JsonLike"] | dict[str, "JsonLike"] | tuple["JsonLike", ...]
class StreamingVideoWriter:
"""Incrementally encode RGB frames to an MP4 without retaining them in memory."""
def __init__(self, video_path: str | Path, fps: int) -> None:
from .import_utils import require_package
require_package("av", extra="av-dep")
import av
self._av = av
self._container = av.open(str(video_path), mode="w")
self._stream = self._container.add_stream("libx264", rate=fps)
self._shape: tuple[int, int] | None = None
self.frames_written = 0
def add_frame(self, frame_array) -> None:
orig_height, orig_width = frame_array.shape[:2]
height = orig_height - orig_height % 2
width = orig_width - orig_width % 2
if self._shape is None:
self._shape = (height, width)
self._stream.width = width
self._stream.height = height
self._stream.pix_fmt = "yuv420p"
elif self._shape != (height, width):
raise ValueError(f"Video frame shape changed from {self._shape} to {(height, width)}")
frame = self._av.VideoFrame.from_ndarray(frame_array[:height, :width], format="rgb24")
for packet in self._stream.encode(frame):
self._container.mux(packet)
self.frames_written += 1
def close(self) -> None:
if self._container is None:
return
for packet in self._stream.encode():
self._container.mux(packet)
self._container.close()
self._container = None
def load_json(fpath: Path) -> Any: def load_json(fpath: Path) -> Any:
"""Load data from a JSON file. """Load data from a JSON file.
@@ -58,36 +98,12 @@ def write_video(video_path: str | Path, stacked_frames: list, fps: int) -> None:
stacked_frames: List of HWC uint8 numpy arrays (RGB). stacked_frames: List of HWC uint8 numpy arrays (RGB).
fps: Frames per second for the output video. fps: Frames per second for the output video.
""" """
from .import_utils import require_package writer = StreamingVideoWriter(video_path, fps)
try:
require_package("av", extra="av-dep")
import av
with av.open(str(video_path), mode="w") as container:
orig_height, orig_width = stacked_frames[0].shape[:2]
# yuv420p requires even dimensions; crop by one pixel if needed
height = orig_height if orig_height % 2 == 0 else orig_height - 1
width = orig_width if orig_width % 2 == 0 else orig_width - 1
if height != orig_height or width != orig_width:
logger.warning(
"Frame dimensions %dx%d are not even; cropping to %dx%d for yuv420p compatibility.",
orig_width,
orig_height,
width,
height,
)
stream = container.add_stream("libx264", rate=fps)
stream.width = width
stream.height = height
stream.pix_fmt = "yuv420p"
for frame_array in stacked_frames: for frame_array in stacked_frames:
if height != orig_height or width != orig_width: writer.add_frame(frame_array)
frame_array = frame_array[:height, :width] finally:
frame = av.VideoFrame.from_ndarray(frame_array, format="rgb24") writer.close()
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
def deserialize_json_into_object[T: JsonLike](fpath: Path, obj: T) -> T: def deserialize_json_into_object[T: JsonLike](fpath: Path, obj: T) -> T:
+9 -1
View File
@@ -38,7 +38,10 @@ def _is_scalar(x):
def init_rerun( def init_rerun(
session_name: str = "lerobot_control_loop", ip: str | None = None, port: int | None = None session_name: str = "lerobot_control_loop",
ip: str | None = None,
port: int | None = None,
web_port: int | None = None,
) -> None: ) -> None:
""" """
Initializes the Rerun SDK for visualizing the control loop. Initializes the Rerun SDK for visualizing the control loop.
@@ -47,6 +50,7 @@ def init_rerun(
session_name: Name of the Rerun session. session_name: Name of the Rerun session.
ip: Optional IP for connecting to a Rerun server. ip: Optional IP for connecting to a Rerun server.
port: Optional port for connecting to a Rerun server. port: Optional port for connecting to a Rerun server.
web_port: Serve a headless web viewer on this port, using ``port`` for gRPC.
""" """
require_package("rerun-sdk", extra="viz", import_name="rerun") require_package("rerun-sdk", extra="viz", import_name="rerun")
@@ -60,6 +64,10 @@ def init_rerun(
memory_limit = os.getenv("LEROBOT_RERUN_MEMORY_LIMIT", "10%") memory_limit = os.getenv("LEROBOT_RERUN_MEMORY_LIMIT", "10%")
if ip and port: if ip and port:
rr.connect_grpc(url=f"rerun+http://{ip}:{port}/proxy") rr.connect_grpc(url=f"rerun+http://{ip}:{port}/proxy")
elif web_port is not None:
grpc_port = port or 9876
url = rr.serve_grpc(grpc_port=grpc_port)
rr.serve_web_viewer(web_port=web_port, open_browser=False, connect_to=url)
else: else:
rr.spawn(memory_limit=memory_limit) rr.spawn(memory_limit=memory_limit)
+71
View File
@@ -0,0 +1,71 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Best-effort text overlays shared by evaluation and interactive rollouts."""
from __future__ import annotations
from collections.abc import Iterable
import numpy as np
def annotate_frame(frame: np.ndarray, fields: Iterable[tuple[str, str | None]]) -> np.ndarray:
"""Return an RGB frame annotated with the non-empty labeled ``fields``."""
if frame.ndim != 3 or frame.shape[-1] != 3:
return frame
try:
import cv2 # noqa: PLC0415
except ImportError:
return frame
text_rows = [f"{label}: {value}" for label, value in fields if value]
if not text_rows:
return frame
image = np.ascontiguousarray(frame).copy()
font, scale, thickness, margin = cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1, 6
max_width = image.shape[1] - 2 * margin
lines: list[str] = []
for text in text_rows:
current = ""
for word in text.split():
candidate = f"{current} {word}".strip()
width = cv2.getTextSize(candidate, font, scale, thickness)[0][0]
if width > max_width and current:
lines.append(current)
current = word
else:
current = candidate
if current:
lines.append(current)
line_height = 20
header_height = min(image.shape[0], len(lines) * line_height + 6)
backdrop = image.copy()
cv2.rectangle(backdrop, (0, 0), (image.shape[1], header_height), (0, 0, 0), -1)
cv2.addWeighted(backdrop, 0.55, image, 0.45, 0, dst=image)
for index, line in enumerate(lines):
cv2.putText(
image,
line,
(margin, 18 + index * line_height),
font,
scale,
(255, 255, 255),
thickness,
cv2.LINE_AA,
)
return image
+7
View File
@@ -29,6 +29,13 @@ def test_message_recipe_validates_unknown_binding():
) )
def test_canonical_recipe_loads():
"""The canonical PI052 blend YAML loads + validates."""
recipe = TrainingRecipe.from_yaml(Path("src/lerobot/configs/recipes/subtask_mem_vqa_speech.yaml"))
assert recipe.blend is not None
assert sum(c.weight for c in recipe.blend.values()) == pytest.approx(1.0)
def test_message_turn_requires_a_stream(): def test_message_turn_requires_a_stream():
"""Every turn must declare a stream — None is rejected at construction. """Every turn must declare a stream — None is rejected at construction.
+78
View File
@@ -343,6 +343,84 @@ def test_resolve_task_explicit_override_beats_rephrasings():
assert rendered["messages"][0]["content"] == "explicit override wins" assert rendered["messages"][0]["content"] == "explicit override wins"
def test_flow_only_low_level_recipe_renders_without_target():
"""Regression: a flow-only ``low_level`` recipe has no ``target`` turn —
its supervision is the action-expert flow loss, not text-CE. It must
still render (not ``None``), otherwise every blend draw of it is dropped
and the action expert never receives a flow loss."""
recipe = TrainingRecipe(
messages=[
MessageTurn(
role="user",
content="${subtask}",
stream="low_level",
if_present="subtask",
),
],
bindings={"subtask": "active_at(t, style=subtask)"},
)
rendered = render_sample(
recipe=recipe,
persistent=PERSISTENT,
events=[],
t=0.5,
sample_idx=0,
task="clean kitchen",
)
assert rendered is not None
assert rendered["messages"] == [{"role": "user", "content": "subtask 0"}]
assert rendered["message_streams"] == ["low_level"]
assert rendered["target_message_indices"] == []
def test_vqa_frame_is_consumed_over_the_weighted_blend():
"""A frame carrying a VQA annotation renders the ``ask_vqa*`` sub-recipe
even when its blend weight is tiny VQA annotations are sparse and must
never be wasted on a subtask/action draw."""
recipe = TrainingRecipe(
blend={
"high_level_subtask": TrainingRecipe(
weight=0.99,
messages=[
MessageTurn(role="user", content="${task}", stream="high_level"),
MessageTurn(role="assistant", content="a subtask", stream="high_level", target=True),
],
),
"ask_vqa_top": TrainingRecipe(
weight=0.01,
bindings={
"vqa_query": "emitted_at(t, style=vqa, role=user, camera=observation.images.top)",
"vqa": "emitted_at(t, style=vqa, role=assistant, camera=observation.images.top)",
},
messages=[
MessageTurn(
role="user", content="${vqa_query}", stream="high_level", if_present="vqa_query"
),
MessageTurn(
role="assistant",
content="${vqa}",
stream="high_level",
target=True,
if_present="vqa",
),
],
),
}
)
# A frame WITH a vqa event renders VQA on every sample_idx, despite the
# ask_vqa weight being only 0.01.
for sample_idx in range(20):
rendered = render_sample(
recipe=recipe, persistent=PERSISTENT, events=EVENTS_AT_1, t=1.0, sample_idx=sample_idx, task="x"
)
assert rendered["messages"][-1]["content"] == '{"count": 2}', sample_idx
# A frame WITHOUT a vqa event falls back to the normal weighted blend.
rendered = render_sample(recipe=recipe, persistent=PERSISTENT, events=[], t=1.0, sample_idx=0, task="x")
assert rendered["messages"][-1]["content"] == "a subtask"
def test_emitted_at_persistent_tolerates_small_timestamp_drift(): def test_emitted_at_persistent_tolerates_small_timestamp_drift():
"""Persistent ``emitted_at`` should match within EMITTED_AT_TOLERANCE_S """Persistent ``emitted_at`` should match within EMITTED_AT_TOLERANCE_S
so callers that derive ``t`` arithmetically (``frame_idx / fps``) still so callers that derive ``t`` arithmetically (``frame_idx / fps``) still
+1 -3
View File
@@ -25,7 +25,7 @@ from datasets import Dataset # noqa: E402
from lerobot.datasets.io_utils import ( from lerobot.datasets.io_utils import (
hf_transform_to_torch, hf_transform_to_torch,
) )
from lerobot.datasets.sampler import EpisodeAwareSampler from lerobot.datasets.sampler import EpisodeAwareSampler, compute_sampler_state
def calculate_episode_data_index(hf_dataset: Dataset) -> dict[str, torch.Tensor]: def calculate_episode_data_index(hf_dataset: Dataset) -> dict[str, torch.Tensor]:
@@ -154,8 +154,6 @@ def test_partial_episode_drop_warns(caplog):
# --- seeded (seed, epoch) shuffling, resume, and state --- # --- seeded (seed, epoch) shuffling, resume, and state ---
from lerobot.datasets.sampler import compute_sampler_state # noqa: E402
EPISODE_BOUNDS = ([0, 2, 3], [2, 3, 6]) # episodes of 2, 1 and 3 frames EPISODE_BOUNDS = ([0, 2, 3], [2, 3, 6]) # episodes of 2, 1 and 3 frames
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import numpy as np
from lerobot.envs.robocasa import RoboCasaEnv, convert_action
def test_robocasa_action_uses_openpi_checkpoint_order():
action = np.arange(12, dtype=np.float32)
converted = convert_action(action)
np.testing.assert_array_equal(converted["action.end_effector_position"], [0, 1, 2])
np.testing.assert_array_equal(converted["action.end_effector_rotation"], [3, 4, 5])
np.testing.assert_array_equal(converted["action.gripper_close"], [6])
np.testing.assert_array_equal(converted["action.base_motion"], [7, 8, 9, 10])
np.testing.assert_array_equal(converted["action.control_mode"], [11])
def test_robocasa_state_uses_openpi_checkpoint_order():
env = object.__new__(RoboCasaEnv)
env.obs_type = "pixels_agent_pos"
env.camera_name = []
raw_observation = {
"state.end_effector_position_relative": np.arange(0, 3),
"state.end_effector_rotation_relative": np.arange(3, 7),
"state.base_position": np.arange(7, 10),
"state.base_rotation": np.arange(10, 14),
"state.gripper_qpos": np.arange(14, 16),
}
observation = env._format_raw_obs(raw_observation)
np.testing.assert_array_equal(observation["agent_pos"], np.arange(16, dtype=np.float32))
@@ -12,7 +12,9 @@ from lerobot.processor.render_messages_processor import RenderMessagesStep # no
from lerobot.types import TransitionKey # noqa: E402 from lerobot.types import TransitionKey # noqa: E402
def test_render_messages_step_noops_without_language_columns(): def test_render_messages_step_renders_task_fallback_without_language_columns():
"""No language columns + a task string → low-level task fallback render,
matching what the policy sees at eval time on unannotated observations."""
recipe = TrainingRecipe( recipe = TrainingRecipe(
messages=[ messages=[
MessageTurn(role="user", content="${task}", stream="high_level"), MessageTurn(role="user", content="${task}", stream="high_level"),
@@ -21,6 +23,24 @@ def test_render_messages_step_noops_without_language_columns():
) )
transition = create_transition(complementary_data={"task": "do it"}) transition = create_transition(complementary_data={"task": "do it"})
out = RenderMessagesStep(recipe)(transition)
data = out[TransitionKey.COMPLEMENTARY_DATA]
assert data["messages"] == [{"role": "user", "content": "do it"}]
assert data["message_streams"] == ["low_level"]
assert data["target_message_indices"] == []
assert data["task"] == "do it"
def test_render_messages_step_noops_without_language_columns_or_task():
recipe = TrainingRecipe(
messages=[
MessageTurn(role="user", content="${task}", stream="high_level"),
MessageTurn(role="assistant", content="${subtask}", stream="low_level", target=True),
]
)
transition = create_transition(complementary_data={})
assert RenderMessagesStep(recipe)(transition) == transition assert RenderMessagesStep(recipe)(transition) == transition
@@ -58,3 +78,70 @@ def test_render_messages_step_renders_and_drops_raw_language():
assert data["messages"][-1]["content"] == "reach carefully" assert data["messages"][-1]["content"] == "reach carefully"
assert data["message_streams"] == ["high_level", "low_level"] assert data["message_streams"] == ["high_level", "low_level"]
assert data["target_message_indices"] == [1] assert data["target_message_indices"] == [1]
def test_render_messages_step_falls_back_to_low_level_task_when_recipe_misses():
recipe = TrainingRecipe(
messages=[
MessageTurn(
role="assistant",
content="${subtask}",
stream="high_level",
target=True,
if_present="subtask",
),
]
)
transition = create_transition(
complementary_data={
"task": "pick the cube",
"timestamp": torch.tensor(0.0),
"index": torch.tensor(7),
"language_persistent": [],
"language_events": [{"style": "unmatched", "timestamp": 0.0}],
}
)
out = RenderMessagesStep(recipe)(transition)
data = out[TransitionKey.COMPLEMENTARY_DATA]
assert data["messages"] == [{"role": "user", "content": "pick the cube"}]
assert data["message_streams"] == ["low_level"]
assert data["target_message_indices"] == []
def test_render_messages_step_falls_back_per_sample_in_batched_language():
recipe = TrainingRecipe(
messages=[
MessageTurn(
role="assistant",
content="${subtask}",
stream="high_level",
target=True,
if_present="subtask",
),
]
)
transition = create_transition(
action=torch.arange(4).reshape(2, 2),
complementary_data={
"task": ["pick the cube", "open the drawer"],
"timestamp": torch.tensor([0.0, 1.0]),
"index": torch.tensor([7, 8]),
"language_persistent": [[], []],
"language_events": [
[{"style": "unmatched", "timestamp": 0.0}],
[{"style": "unmatched", "timestamp": 1.0}],
],
},
)
out = RenderMessagesStep(recipe)(transition)
data = out[TransitionKey.COMPLEMENTARY_DATA]
assert data["messages"] == [
[{"role": "user", "content": "pick the cube"}],
[{"role": "user", "content": "open the drawer"}],
]
assert data["message_streams"] == [["low_level"], ["low_level"]]
assert data["target_message_indices"] == [[], []]
+41 -1
View File
@@ -25,7 +25,7 @@ import pytest
import torch import torch
from lerobot.configs.types import FeatureType, PipelineFeatureType, PolicyFeature from lerobot.configs.types import FeatureType, PipelineFeatureType, PolicyFeature
from lerobot.processor import DataProcessorPipeline, TokenizerProcessorStep from lerobot.processor import ActionTokenizerProcessorStep, DataProcessorPipeline, TokenizerProcessorStep
from lerobot.processor.converters import create_transition, identity_transition from lerobot.processor.converters import create_transition, identity_transition
from lerobot.types import TransitionKey from lerobot.types import TransitionKey
from lerobot.utils.constants import ( from lerobot.utils.constants import (
@@ -88,6 +88,46 @@ class MockTokenizer:
return result return result
def test_action_tokenizer_config_preserves_token_mapping():
processor = object.__new__(ActionTokenizerProcessorStep)
processor.trust_remote_code = True
processor.max_action_tokens = 384
processor.fast_skip_tokens = 64
processor.paligemma_tokenizer_name = "custom/paligemma"
processor.allow_truncation = False
processor.action_tokenizer_name = "custom/fast"
processor.action_tokenizer_input_object = None
assert processor.get_config() == {
"trust_remote_code": True,
"max_action_tokens": 384,
"fast_skip_tokens": 64,
"paligemma_tokenizer_name": "custom/paligemma",
"allow_truncation": False,
"action_tokenizer_name": "custom/fast",
}
def test_action_tokenizer_can_reject_truncated_sequences():
processor = object.__new__(ActionTokenizerProcessorStep)
processor.max_action_tokens = 4
processor.fast_skip_tokens = 128
processor.allow_truncation = False
processor.action_tokenizer = lambda _actions: [1, 2, 3]
processor._paligemma_tokenizer = type(
"Tokenizer",
(),
{
"vocab_size": 1000,
"bos_token_id": 2,
"encode": lambda _self, text, **_kwargs: [10, 11] if text == "Action: " else [12, 1],
},
)()
with pytest.raises(ValueError, match="max_action_tokens=4"):
processor._tokenize_action(torch.zeros(1, 2, 1))
@pytest.fixture @pytest.fixture
def mock_tokenizer(): def mock_tokenizer():
"""Provide a mock tokenizer for testing.""" """Provide a mock tokenizer for testing."""
+105
View File
@@ -0,0 +1,105 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from lerobot.runtime import RuntimeState
from lerobot.runtime.adapter import (
BaseLanguageAdapter,
DirectTaskPolicyAdapter,
GenerationConfig,
)
class ScriptedAdapter(BaseLanguageAdapter):
"""Base adapter whose text generation returns queued strings per kind."""
def __init__(self, scripts, gen=None):
super().__init__(policy=object(), gen=gen)
self.scripts = {k: list(v) for k, v in scripts.items()}
self.calls = []
def select_action(self, observation, state):
return None
def generate_text(self, kind, observation, state, user_text=None):
self.calls.append(kind)
queue = self.scripts.get(kind, [])
return queue.pop(0) if queue else ""
def test_cascade_sets_subtask_then_memory():
adapter = ScriptedAdapter({"subtask": ["pick the red cup"], "memory": ["the cup is grasped"]})
state = RuntimeState(task="clean")
adapter.update_language_state(None, state)
assert state.language_context["subtask"] == "pick the red cup"
assert state.language_context["memory"] == "the cup is grasped"
assert adapter.calls == ["subtask", "memory"]
def test_nonempty_generation_is_used_verbatim():
adapter = ScriptedAdapter({"subtask": [":::: ::"], "memory": ["memory"]})
state = RuntimeState(task="clean")
adapter.update_language_state(None, state)
assert state.language_context["subtask"] == ":::: ::"
assert state.language_context["memory"] == "memory"
assert adapter.calls == ["subtask", "memory"]
def test_throttle_regenerates_every_n_chunks():
adapter = ScriptedAdapter(
{
"subtask": ["pick the first cup", "pick the second cup"],
"memory": ["memory one two three", "memory four five six"],
},
gen=GenerationConfig(chunks_per_regen=2),
)
state = RuntimeState(task="clean")
adapter.update_language_state(None, state) # generates
assert state.language_context["subtask"] == "pick the first cup"
adapter.update_language_state(None, state) # throttled — no generation
assert state.language_context["subtask"] == "pick the first cup"
adapter.update_language_state(None, state) # generates again
assert state.language_context["subtask"] == "pick the second cup"
def test_handle_interjection_sets_plan_and_strips_say():
adapter = ScriptedAdapter({"interjection": ["turn to the left now <say>heading left</say>"]})
state = RuntimeState(task="clean")
adapter.handle_interjection("turn", None, state)
assert state.language_context["plan"] == "turn to the left now"
def test_direct_task_adapter_delegates_action_chunk():
class Policy:
def predict_action_chunk(self, observation):
return ("chunk", observation)
observation = {"task": "pick up the cube"}
adapter = DirectTaskPolicyAdapter(Policy())
assert adapter.select_action(observation, RuntimeState()) == ("chunk", observation)
assert adapter.generate_text("subtask", observation, RuntimeState()) == ""
def test_flat_policy_registry_reuses_direct_task_adapter():
from lerobot.runtime.registry import get_language_adapter_factory
assert get_language_adapter_factory("pi05") is DirectTaskPolicyAdapter
assert get_language_adapter_factory("molmoact2") is DirectTaskPolicyAdapter
+100
View File
@@ -0,0 +1,100 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import torch
from lerobot.runtime.cli import _ask_runtime, _build_rollout_runtime_io, _parse_args
from lerobot.runtime.language_runtime import RuntimeState
def test_parse_args_preserves_rollout_robot_overrides():
args = _parse_args(
[
"--policy.path=checkpoint",
"--robot.type=so101_follower",
"--robot.calibration_dir=/tmp/calibration",
]
)
assert args.robot_type == "so101_follower"
assert "--robot.calibration_dir=/tmp/calibration" in args.raw_argv
def test_parse_args_rejects_removed_dataset_replay_flags():
with pytest.raises(SystemExit):
_parse_args(["--policy.path=checkpoint", "--dataset.repo_id=dataset"])
def test_rollout_runtime_io_uses_context_processors():
robot = MagicMock()
robot.robot_type = "mock_robot"
robot.cameras = {}
robot.get_observation.return_value = {"joint.pos": 1.5}
ctx = SimpleNamespace(
hardware=SimpleNamespace(robot_wrapper=robot),
runtime=SimpleNamespace(cfg=SimpleNamespace(device="cpu")),
processors=SimpleNamespace(
robot_observation_processor=lambda observation: observation,
robot_action_processor=lambda pair: pair[0],
),
policy=SimpleNamespace(
preprocessor=lambda observation: observation,
postprocessor=lambda action: action,
),
data=SimpleNamespace(
dataset_features={
"observation.state": {
"dtype": "float32",
"shape": (1,),
"names": ["joint.pos"],
},
"action": {"dtype": "float32", "shape": (1,), "names": ["joint.pos"]},
}
),
)
provider, executor = _build_rollout_runtime_io(ctx, rerun_log=False, get_task=lambda: "move")
observation = provider()
executor(torch.tensor([[2.0]]))
assert observation["observation.state"].shape == (1, 1)
robot.send_action.assert_called_once_with({"joint.pos": 2.0})
def test_ask_runtime_pauses_and_routes_current_observation(capsys):
adapter = MagicMock()
adapter.generate_text.return_value = "The mug is beside the bowl."
runtime = SimpleNamespace(
state=RuntimeState(mode="action"),
policy_adapter=adapter,
_current_observation=lambda: {"image": "current"},
)
runtime.state.action_queue.extend([1, 2])
answer = _ask_runtime(runtime, "What is beside the bowl?")
assert answer == "The mug is beside the bowl."
assert runtime.state.mode == "paused"
assert not runtime.state.action_queue
adapter.generate_text.assert_called_once_with(
"vqa",
{"image": "current"},
runtime.state,
user_text="What is beside the bowl?",
)
assert "[policy] The mug is beside the bowl." in capsys.readouterr().out
+100
View File
@@ -0,0 +1,100 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import threading
import time
from lerobot.runtime import LanguageConditionedRuntime, Tick
class FakeAdapter:
def __init__(self):
self.updated = False
self.interjections = []
def select_action(self, observation, state):
assert observation == {"observation.state": 1}
assert state.task == "clean"
return ["a0", "a1"]
def update_language_state(self, observation, state):
self.updated = True
state.set_context("subtask", "pick cup", label="subtask")
def handle_interjection(self, user_text, observation, state):
self.interjections.append(user_text)
state.set_context("plan", "new plan", label="plan")
def test_runtime_tick_updates_language_enqueues_and_dispatches_action():
adapter = FakeAdapter()
executed = []
runtime = LanguageConditionedRuntime(
policy_adapter=adapter,
observation_provider=lambda: {"observation.state": 1},
action_executor=executed.append,
)
runtime.set_task("clean")
logs = runtime.step_once()
assert adapter.updated
assert runtime.state.language_context["subtask"] == "pick cup"
assert executed == ["a0"]
assert list(runtime.state.action_queue) == ["a1"]
assert " subtask: pick cup" in logs
def test_runtime_handles_user_interjection():
adapter = FakeAdapter()
runtime = LanguageConditionedRuntime(
policy_adapter=adapter,
observation_provider=lambda: {"observation.state": 1},
)
runtime.set_task("clean")
runtime.state.extra["recent_interjection"] = "please say ok"
runtime.state.emit("user_interjection")
runtime.step_once()
assert "please say ok" in adapter.interjections
assert runtime.state.language_context["plan"] == "new plan"
def test_prompt_change_discards_in_flight_action_chunk():
started = threading.Event()
release = threading.Event()
class BlockingAdapter(FakeAdapter):
def select_action(self, observation, state):
started.set()
assert release.wait(timeout=2)
return ["stale"]
runtime = LanguageConditionedRuntime(
policy_adapter=BlockingAdapter(),
observation_provider=lambda: {"observation.state": 1},
)
runtime.set_task("old task")
runtime.state.tick = Tick(index=1, monotonic_seconds=time.monotonic())
inference = threading.Thread(target=runtime.maybe_enqueue_action_chunk, kwargs={"force": True})
inference.start()
assert started.wait(timeout=2)
runtime.set_task("new task")
release.set()
inference.join(timeout=2)
assert not inference.is_alive()
assert list(runtime.state.action_queue) == []
+82
View File
@@ -0,0 +1,82 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import sys
from types import SimpleNamespace
import numpy as np
from lerobot.runtime.sim_robocasa import RoboCasaSimBackend
from lerobot.utils.video_annotation import annotate_frame
def test_overlay_draws_each_label_once(monkeypatch):
put_text_calls = []
rectangle_calls = []
def put_text(image, text, origin, font, scale, color, thickness, line_type):
put_text_calls.append((text, color, thickness))
return image
def rectangle(image, start, end, color, thickness):
rectangle_calls.append((start, end, color, thickness))
return image
def add_weighted(src1, alpha, src2, beta, gamma, *, dst):
dst[:] = src1 * alpha + src2 * beta + gamma
return dst
fake_cv2 = SimpleNamespace(
FONT_HERSHEY_SIMPLEX=0,
LINE_AA=16,
getTextSize=lambda text, font, scale, thickness: ((len(text) * 7, 10), 0),
putText=put_text,
rectangle=rectangle,
addWeighted=add_weighted,
)
monkeypatch.setitem(sys.modules, "cv2", fake_cv2)
frame = np.full((120, 480, 3), 200, dtype=np.uint8)
annotated = annotate_frame(
frame,
(("Task", "close the fridge"), ("Subtask", "reach for the handle"), ("Memory", None)),
)
assert [call[0] for call in put_text_calls] == [
"Task: close the fridge",
"Subtask: reach for the handle",
]
assert all(color == (255, 255, 255) and thickness == 1 for _, color, thickness in put_text_calls)
assert len(rectangle_calls) == 1
assert not np.shares_memory(annotated, frame)
def test_capture_updates_live_frame_when_recording_is_disabled(monkeypatch):
backend = object.__new__(RoboCasaSimBackend)
frame = np.full((8, 8, 3), 42, dtype=np.uint8)
written = []
backend.record = False
backend.runtime_state = None
backend._multiview_frame = lambda: frame
backend._current_task = lambda: "task"
backend._subtask_getter = None
backend._memory_getter = None
backend._latest_frame = None
backend._write_live_frame = written.append
monkeypatch.setattr("lerobot.runtime.sim_robocasa.annotate_frame", lambda image, labels: image)
backend._capture_frame()
assert backend._latest_frame is frame
assert written == [frame]