feat(runtime): RoboCasa sim backend + interactive controls

Drive a persistent RoboCasa kitchen with open-ended prompts and watch it live.

- runtime/sim_robocasa.py: single-scene RoboCasa backend (n_envs=2 for stable
  EGL rendering — single-worker rendering is broken), high-res multi-view
  compositing incl. wrist cam, annotated MP4 + rolling latest.png + MJPEG live
  viewer, and /reset scene re-roll.
- runtime/cli.py: --sim mode with a main-thread control loop (background-thread
  rendering corrupts EGL), clean chat-style prompt (a new command switches the
  task and regenerates the subtask immediately), plus --sim.render_size,
  --sim.views, --sim.stream_port, --sim.direct_subtask and --disable_memory.
- runtime/adapter.py: GenerationConfig.enable_memory / enable_subtask toggles.
- runtime/registry.py + policies/pi05/pi05_adapter.py: register pi05 (flat VLA,
  direct task-text conditioning; no subtask/memory head).
- policies/pi052/inference/pi052_adapter.py: condition the action expert on
  "{subtask}, State: {..}" to match eval/training.
- envs/robocasa.py + envs/configs.py: terminate_on_success + horizon options so
  the interactive kitchen persists across tasks (defaults preserve eval).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepijn223
2026-07-08 17:27:13 +02:00
parent 147b8f248d
commit cd15a66286
8 changed files with 918 additions and 7 deletions
+9 -1
View File
@@ -556,7 +556,13 @@ class RoboCasaEnv(EnvConfig):
kwargs["split"] = self.split
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
if self.task is None:
@@ -570,6 +576,8 @@ class RoboCasaEnv(EnvConfig):
env_cls=env_cls,
episode_length=self.episode_length,
obj_registries=tuple(self.obj_registries),
terminate_on_success=terminate_on_success,
horizon=horizon,
)
+20 -1
View File
@@ -137,9 +137,16 @@ class RoboCasaEnv(gym.Env):
episode_length: int | None = None,
obj_registries: Sequence[str] = DEFAULT_OBJ_REGISTRIES,
episode_index: int = 0,
terminate_on_success: bool = True,
horizon: int | None = None,
):
super().__init__()
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.render_mode = render_mode
self.observation_width = observation_width
@@ -211,12 +218,16 @@ class RoboCasaEnv(gym.Env):
# (only None/"all"/"pretrain"/"target" are valid). Always pass a
# valid value so we don't hit that default. Extra kwargs are
# 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(
env_name=self.task,
camera_widths=self.observation_width,
camera_heights=self.observation_height,
split=self.split if self.split is not None else "all",
obj_registries=self.obj_registries,
**extra_kwargs,
)
ep_meta = self._env.env.get_ep_meta()
@@ -283,7 +294,7 @@ class RoboCasaEnv(gym.Env):
raw_obs, reward, done, truncated, info = self._env.step(action_dict)
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})
observation = self._format_raw_obs(raw_obs)
@@ -316,6 +327,8 @@ def _make_env_fns(
split: str | None,
episode_length: int | None,
obj_registries: Sequence[str],
terminate_on_success: bool = True,
horizon: int | None = None,
) -> list[Callable[[], RoboCasaEnv]]:
"""Build n_envs factory callables for a single task.
@@ -338,6 +351,8 @@ def _make_env_fns(
episode_length=episode_length,
obj_registries=obj_registries,
episode_index=episode_index,
terminate_on_success=terminate_on_success,
horizon=horizon,
)
return [partial(_make_env, i) for i in range(n_envs)]
@@ -351,6 +366,8 @@ def create_robocasa_envs(
env_cls: Callable[[Sequence[Callable[[], Any]]], Any] | None = None,
episode_length: int | None = None,
obj_registries: Sequence[str] = DEFAULT_OBJ_REGISTRIES,
terminate_on_success: bool = True,
horizon: int | None = None,
) -> dict[str, dict[int, Any]]:
"""Create vectorized RoboCasa365 environments with a consistent return shape.
@@ -412,6 +429,8 @@ def create_robocasa_envs(
split=split,
episode_length=episode_length,
obj_registries=obj_registries,
terminate_on_success=terminate_on_success,
horizon=horizon,
)
if is_async:
+50
View File
@@ -0,0 +1,50 @@
# 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.
"""PI05 adapter for the language-conditioned runtime.
PI05 is a flat VLA: it conditions the action expert directly on the task text,
which its preprocessor tokenizes into ``observation.language.tokens``. It has no
subtask/memory generation head, so the runtime simply predicts an action chunk
from the already-tokenized observation. Text generation is unsupported — run
with ``--sim.direct_subtask`` so the runtime doesn't attempt subtask/memory
generation (what you type becomes the task the preprocessor tokenizes).
"""
from __future__ import annotations
from typing import Any
from lerobot.runtime import RuntimeState
from lerobot.runtime.adapter import BaseLanguageAdapter
class PI05PolicyAdapter(BaseLanguageAdapter):
"""Runtime bridge for flat PI05 policies (direct task-text conditioning)."""
def select_action(self, observation: dict[str, Any], state: RuntimeState) -> Any:
# The task text was tokenized into observation.language.* by the policy
# preprocessor (fed the current task by the observation provider), so we
# just predict the action chunk from it.
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:
# PI05 has no text-generation head; direct-subtask mode skips this path.
return ""
@@ -37,14 +37,31 @@ class PI052PolicyAdapter(BaseLanguageAdapter):
"""Runtime bridge for PI052 policies."""
def select_action(self, observation: dict[str, Any], state: RuntimeState) -> Any:
import torch # noqa: PLC0415
from lerobot.utils.constants import ( # noqa: PLC0415
OBS_LANGUAGE_ATTENTION_MASK,
OBS_LANGUAGE_TOKENS,
OBS_STATE,
)
subtask = state.language_context.get("subtask") or state.task or ""
# Condition the action expert on subtask + discretized state, matching
# training and lerobot-eval's low-level prompt ("{subtask}, State: {..};").
# Without the state the action expert is off-distribution.
content = subtask
obs_state = observation.get(OBS_STATE)
if isinstance(obs_state, torch.Tensor) and obs_state.numel() > 0:
from lerobot.policies.pi052.text_processor_pi052 import discretize_state_str # noqa: PLC0415
state_row = obs_state[0] if obs_state.ndim > 1 else obs_state
content = f"{subtask}, State: {discretize_state_str(state_row)};"
text_batch = _build_text_batch(
self.policy,
[{"role": "user", "content": subtask}],
[{"role": "user", "content": content}],
add_generation_prompt=False,
)
from lerobot.utils.constants import OBS_LANGUAGE_ATTENTION_MASK, OBS_LANGUAGE_TOKENS # noqa: PLC0415
batch = dict(observation)
batch[OBS_LANGUAGE_TOKENS] = text_batch["lang_tokens"]
batch[OBS_LANGUAGE_ATTENTION_MASK] = text_batch["lang_masks"]
+8
View File
@@ -51,6 +51,8 @@ class GenerationConfig:
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
@@ -127,6 +129,10 @@ class BaseLanguageAdapter(ABC):
Override for a policy with a different language hierarchy.
"""
if not self.gen.enable_subtask:
# Direct-subtask mode: the operator supplies the subtask; don't
# generate (and thus don't overwrite) it.
return
subtask = self._generate_filtered("subtask", observation, state)
if subtask is None:
return
@@ -137,6 +143,8 @@ class BaseLanguageAdapter(ABC):
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")
+331 -2
View File
@@ -238,6 +238,97 @@ def _parse_args(argv: list[str] | None = None, *, prog: str | None = None) -> ar
"wrong robot, robot not at home pose)."
),
)
# --- RoboCasa simulation mode args -------------------------------
# Setting ``--sim`` flips the runtime into simulation mode: instead of
# a real robot it drives a single RoboCasa mujoco scene, feeding the
# eval observation/action pipeline. The operator still types prompts
# (/action <prompt>) that the policy executes inside the chosen scene.
# Mutually exclusive with ``--robot.type``.
p.add_argument(
"--sim",
action="store_true",
help=(
"Run the policy in the RoboCasa simulator instead of on a real "
"robot. Select the scene with --sim.task; type prompts with "
"/action <prompt> to have the policy execute them in that scene."
),
)
p.add_argument(
"--sim.task",
dest="sim_task",
type=str,
default="CloseFridge",
help="RoboCasa task/scene to instantiate (e.g. OpenDrawer, LoadDishwasher).",
)
p.add_argument(
"--sim.split",
dest="sim_split",
type=str,
default="pretrain",
help="RoboCasa scene split (all/pretrain/target). Default: pretrain.",
)
p.add_argument(
"--sim.obj_registries",
dest="sim_obj_registries",
type=str,
default="objaverse,lightwheel",
help="Comma-separated object-mesh registries. Default: objaverse,lightwheel.",
)
p.add_argument(
"--sim.seed",
dest="sim_seed",
type=int,
default=1000,
help="Seed for RoboCasa scene reset (default: 1000, matches eval).",
)
p.add_argument(
"--sim.record",
dest="sim_record",
type=str,
choices=["mp4", "off"],
default="mp4",
help="Record an annotated mp4 (task/subtask/memory overlay) of the sim session. Default: mp4.",
)
p.add_argument(
"--sim.output_dir",
dest="sim_output_dir",
type=str,
default="outputs/runtime_sim",
help="Directory for the recorded sim video (default: outputs/runtime_sim).",
)
p.add_argument(
"--sim.render_size",
dest="sim_render_size",
type=int,
default=384,
help=(
"Resolution (px) of the observation cameras used for the display "
"(default 384; try 512 for sharper, 256 for faster). The policy is "
"unaffected — it resizes to 224 internally."
),
)
p.add_argument(
"--sim.views",
dest="sim_views",
type=str,
default="robot0_agentview_left,robot0_eye_in_hand,robot0_agentview_right",
help=(
"Comma-separated camera views to show side by side. Default shows "
"left, wrist (eye-in-hand), right. Use e.g. 'robot0_eye_in_hand' "
"for wrist-only."
),
)
p.add_argument(
"--sim.stream_port",
dest="sim_stream_port",
type=int,
default=8010,
help=(
"Port for the live MJPEG viewer (default: 8010; 0 disables). "
"Open http://localhost:<port> in a browser; over SSH forward it with "
"ssh -L <port>:localhost:<port> <host>."
),
)
p.add_argument(
"--chunk_hz",
type=float,
@@ -257,6 +348,25 @@ def _parse_args(argv: list[str] | None = None, *, prog: str | None = None) -> ar
default=1.0,
help="High-level subtask generation rate.",
)
p.add_argument(
"--sim.direct_subtask",
dest="sim_direct_subtask",
action="store_true",
help=(
"Direct-subtask mode: what you type IS the subtask fed to the action "
"expert (no LM subtask generation). Good when the model's subtask "
"head is weak — you steer the policy with exact imperatives."
),
)
p.add_argument(
"--disable_memory",
action="store_true",
help=(
"Skip the memory-note generation on subtask change. Use for "
"subtask-only checkpoints (no memory head) — avoids a wasted LM "
"decode and a meaningless memory line."
),
)
p.add_argument(
"--subtask_chunks_per_gen",
type=int,
@@ -333,6 +443,8 @@ def _select_observation_to_device(sample: dict, device: Any) -> dict:
def _load_policy_and_preprocessor(
policy_path: str,
dataset_repo_id: str | None,
*,
load_processors_from_checkpoint: bool = False,
) -> tuple[Any, Any, Any, Any]:
"""Load a policy checkpoint (local path or Hub repo id).
@@ -340,6 +452,12 @@ def _load_policy_and_preprocessor(
``preprocessor`` / ``postprocessor`` / ``ds_meta`` are ``None``
when no dataset is provided (rare — needed for autonomous robot
mode to have action-denormalisation stats).
When ``load_processors_from_checkpoint`` is set and no dataset is
given, the pre/post processors are loaded from the checkpoint exactly
like ``lerobot-eval`` (normalizer stats from the saved safetensors,
recipe from ``cfg.recipe_path``). This is what the RoboCasa sim
backend uses so it needs no dataset to match eval-time processing.
"""
from lerobot.configs import PreTrainedConfig # noqa: PLC0415
from lerobot.policies.factory import make_policy, make_pre_post_processors # noqa: PLC0415
@@ -370,6 +488,12 @@ def _load_policy_and_preprocessor(
policy_cls = get_policy_class(cfg.type)
policy = policy_cls.from_pretrained(policy_path, config=cfg)
policy.to(cfg.device)
if load_processors_from_checkpoint:
# Eval-matching processors: stats from the checkpoint safetensors,
# recipe from cfg.recipe_path. No dataset needed.
preprocessor, postprocessor = make_pre_post_processors(
cfg, pretrained_path=cfg.pretrained_path
)
policy.eval()
return policy, preprocessor, postprocessor, ds_meta
@@ -1305,7 +1429,15 @@ def run(
)
_silence_noisy_loggers()
sim_mode = bool(getattr(args, "sim", False)) and not args.no_robot
autonomous_mode = bool(args.robot_type) and not args.no_robot
if sim_mode and autonomous_mode:
print(
"[runtime] ERROR: --sim and --robot.type are mutually exclusive "
"(pick a simulator scene OR a real robot).",
file=sys.stderr,
)
return 2
if autonomous_mode and not args.dataset_repo_id:
print(
"[runtime] ERROR: autonomous robot mode requires --dataset.repo_id "
@@ -1315,9 +1447,40 @@ def run(
)
return 2
# Create the sim env subprocess BEFORE the policy initialises CUDA — the
# env worker inherits a corrupt EGL/GL context if forked from a CUDA parent
# (dark/garbled renders). This mirrors eval's make_env-before-make_policy.
sim_env = None
sim_obs = None
sim_stream_server = None
sim_holder: dict[str, Any] = {"backend": None}
if sim_mode:
from lerobot.runtime.sim_robocasa import create_sim_env, start_mjpeg_server # noqa: PLC0415
# Start the live viewer first so the port listens during the ~60s model
# load (browsers get a loading page instead of connection-refused).
if args.sim_stream_port:
sim_stream_server = start_mjpeg_server(
args.sim_stream_port,
lambda: sim_holder["backend"]._latest_frame if sim_holder["backend"] else None,
)
print(
f"[runtime] starting RoboCasa sim scene={args.sim_task!r} split={args.sim_split!r}",
flush=True,
)
sim_env, sim_obs = create_sim_env(
task=args.sim_task,
split=args.sim_split,
obj_registries=[r.strip() for r in args.sim_obj_registries.split(",") if r.strip()],
seed=args.sim_seed,
render_size=args.sim_render_size,
)
print(f"[runtime] loading policy from {args.policy_path}", flush=True)
policy, preprocessor, postprocessor, ds_meta = _load_policy_and_preprocessor(
args.policy_path, args.dataset_repo_id
args.policy_path,
args.dataset_repo_id,
load_processors_from_checkpoint=sim_mode,
)
policy_type = getattr(policy.config, "type", None)
@@ -1365,8 +1528,32 @@ def run(
observation_provider: Callable[[], dict | None] | None = None
robot_executor: Callable[[Any], None] | None = None
robot = None
sim_backend = None
if autonomous_mode:
if sim_mode:
from lerobot.runtime.sim_robocasa import RoboCasaSimBackend # noqa: PLC0415
sim_backend = RoboCasaSimBackend(
env=sim_env,
last_obs=sim_obs,
task=args.sim_task,
seed=args.sim_seed,
device=str(getattr(policy.config, "device", "cpu")),
preprocessor=preprocessor,
postprocessor=postprocessor,
record=(args.sim_record == "mp4"),
output_dir=args.sim_output_dir,
view_cams=[v.strip() for v in args.sim_views.split(",") if v.strip()],
)
observation_provider = sim_backend.observation_provider
robot_executor = sim_backend.action_executor
robot = sim_backend # reuse _run_autonomous cleanup (calls .disconnect())
# Point the already-running live viewer at the backend and hand it the
# server so disconnect() shuts it down cleanly.
sim_holder["backend"] = sim_backend
if sim_stream_server is not None:
sim_backend.attach_stream_server(sim_stream_server)
elif autonomous_mode:
print(
f"[runtime] connecting to robot.type={args.robot_type} port={args.robot_port}",
flush=True,
@@ -1416,6 +1603,8 @@ def run(
temperature=float(args.text_temperature or 0.0),
top_p=float(args.text_top_p or 1.0),
chunks_per_regen=max(1, int(args.subtask_chunks_per_gen or 1)),
enable_memory=not bool(getattr(args, "disable_memory", False)),
enable_subtask=not bool(getattr(args, "sim_direct_subtask", False)),
)
runtime = LanguageConditionedRuntime(
policy_adapter=adapter_factory(policy, gen_config),
@@ -1444,6 +1633,20 @@ def run(
if bootstrap_state.get("subtask"):
runtime.state["current_subtask"] = bootstrap_state["subtask"]
# Let the sim backend read live task/subtask/memory for the video overlay.
if sim_backend is not None:
sim_backend.bind_runtime(runtime)
# Sim runs its control/render loop in the MAIN thread (see
# _run_sim_interactive) — background-thread rendering corrupts EGL.
return _run_sim_interactive(
runtime,
sim_backend,
initial_task=args.task,
max_ticks=args.max_ticks,
panel_label=panel_label,
direct_subtask=bool(args.sim_direct_subtask),
)
if autonomous_mode:
return _run_autonomous(
runtime,
@@ -1471,6 +1674,132 @@ def run(
return _run_repl(runtime, initial_task=args.task, max_ticks=args.max_ticks, panel_label=panel_label)
def _run_sim_interactive(
runtime: Any,
sim_backend: Any,
*,
initial_task: str | None,
max_ticks: int | None,
panel_label: str = "Runtime",
direct_subtask: bool = False,
) -> int:
"""Main-thread control loop for the RoboCasa sim backend.
Unlike ``_run_autonomous`` (which runs ``runtime.run()`` in a daemon
thread), the tick loop — and therefore MuJoCo's EGL rendering — runs in the
MAIN thread. Driving the sim render from a background thread intermittently
corrupts the offscreen GL context (dark/garbled frames); main-thread
stepping matches ``lerobot-eval`` and renders cleanly. Stdin is polled
non-blockingly so typed commands still work while the sim runs.
"""
import select # noqa: PLC0415
import time # noqa: PLC0415
import torch # noqa: PLC0415
if initial_task:
runtime.set_task(initial_task)
# In direct-subtask mode the typed text IS the subtask; otherwise clear
# it so the model generates one.
runtime.state["current_subtask"] = initial_task if direct_subtask else None
runtime.state["mode"] = "action"
# Clean chat-style prompt. The control loop steps in the MAIN thread (clean
# EGL rendering); the browser live-view shows the rollout, so the terminal
# stays a quiet command line. Nothing is printed mid-step, so typing is never
# clobbered — you can queue the next command any time.
_mode_line = (
" Mode: DIRECT subtask (your text drives the action expert as-is)\n"
if direct_subtask
else " Mode: task (the model generates a subtask from your text)\n"
)
print(
f"\n{'=' * 64}\n"
f" {panel_label} — RoboCasa interactive sim (one persistent kitchen)\n"
f"{_mode_line}"
f" Type a command + Enter to run it, e.g. open the fridge\n"
f" Commands: /pause · /resume · /reset (new kitchen) · stop\n"
f"{'=' * 64}",
flush=True,
)
def _prompt() -> None:
print("\n> ", end="", flush=True)
_prompt()
ticks_done = 0
stdin_open = True
try:
while True:
# Non-blocking stdin: a full line (canonical-mode terminal) is read
# only when Enter is pressed, so line editing works normally.
if stdin_open and select.select([sys.stdin], [], [], 0)[0]:
line = sys.stdin.readline()
if line == "": # EOF — keep running the sim, stop reading stdin
stdin_open = False
else:
cmd = line.strip()
if cmd:
low = cmd.lower()
if low in {"stop", "quit", "exit"}:
break
elif low in {"/pause", "pause", "/p"}:
runtime.state["mode"] = "paused"
_clear_action_queue(runtime)
print("[paused] robot holding", flush=True)
elif low in {"/resume", "resume", "/run"}:
runtime.state["mode"] = "action"
print("[running]", flush=True)
elif low in {"/reset", "reset"}:
sim_backend.reset_scene()
_clear_action_queue(runtime)
runtime.state["current_subtask"] = None
if hasattr(runtime.policy, "reset"):
runtime.policy.reset()
print("[reset] new kitchen scene", flush=True)
else:
# A bare line is a new command: switch the robot to it
# immediately (clear the in-flight chunk + subtask) and
# force the subtask to regenerate on the very next tick
# (reset the adapter throttle + high-level rate gate).
runtime.set_task(cmd)
# Direct mode: the typed text is the subtask itself;
# otherwise clear it so the model regenerates one.
runtime.state["current_subtask"] = cmd if direct_subtask else None
_clear_action_queue(runtime)
adapter = getattr(runtime, "policy_adapter", None)
if adapter is not None and hasattr(adapter, "_chunks_until_regen"):
adapter._chunks_until_regen = 0
gate = getattr(runtime, "_language_gate", None)
if gate is not None and hasattr(gate, "rearm"):
gate.rearm()
runtime.state["mode"] = "action"
print(f"[running] {cmd}", flush=True)
_prompt()
# One tick in the MAIN thread: subtask/action gen + env.step + render.
# inference_mode matches lerobot-eval's forward context.
if runtime.state.get("mode", "paused") == "action":
with torch.inference_mode():
runtime.step_once()
ticks_done += 1
else:
time.sleep(0.05) # idle only while paused (robot not moving)
if runtime.state.stop:
break
if max_ticks is not None and ticks_done >= max_ticks:
break
except KeyboardInterrupt:
print("\n[stopping]", flush=True)
finally:
runtime.stop()
try:
sim_backend.disconnect()
except Exception as exc: # noqa: BLE001
print(f"[runtime] WARNING: sim disconnect raised {exc}", flush=True)
return 0
def _run_repl(
runtime: Any, *, initial_task: str | None, max_ticks: int | None, panel_label: str = "Runtime"
) -> int:
+1
View File
@@ -27,6 +27,7 @@ from typing import Any
_ADAPTERS: dict[str, str] = {
"pi052": "lerobot.policies.pi052.inference.pi052_adapter:PI052PolicyAdapter",
"pi05": "lerobot.policies.pi05.pi05_adapter:PI05PolicyAdapter",
}
+479
View File
@@ -0,0 +1,479 @@
"""RoboCasa simulation backend for the interactive language runtime.
Lets an operator type open-ended prompts (``/action <prompt>``) and have a
language-conditioned policy (e.g. PI052) execute them inside a RoboCasa mujoco
kitchen scene. The observation/action pipeline mirrors ``lerobot-eval`` exactly
so behaviour matches offline evaluation; only the *source* of observations and
the *sink* of actions differ from the real-robot backend, which is left
untouched.
A RoboCasa episode always instantiates a concrete scene (objects + layout) from
its task name, so ``--sim.task`` selects the scene while the prompt typed at the
prompt drives what the policy is asked to do inside it.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from pathlib import Path
from typing import Any
import numpy as np
import torch
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
def _overlay_text(
frame: np.ndarray, task: str | None, subtask: str | None, memory: str | None
) -> np.ndarray:
"""Draw task / subtask / memory lines onto an (H, W, 3) uint8 frame.
Best-effort: returns the frame unchanged if OpenCV is unavailable.
"""
try:
import cv2 # noqa: PLC0415
except ImportError:
return frame
lines = [f"{label}: {val}" for label, val in
(("Task", task), ("Subtask", subtask), ("Memory", memory)) if val]
if not lines:
return frame
img = np.ascontiguousarray(frame)
font, scale, margin = cv2.FONT_HERSHEY_SIMPLEX, 0.5, 6
max_width = img.shape[1] - 2 * margin
y = 18
for text in lines:
# naive width-based wrap so long memory strings stay on-frame
words, cur = text.split(), ""
wrapped: list[str] = []
for w in words:
cand = f"{cur} {w}".strip()
if cv2.getTextSize(cand, font, scale, 1)[0][0] > max_width and cur:
wrapped.append(cur)
cur = w
else:
cur = cand
wrapped.append(cur)
for line in wrapped:
cv2.putText(img, line, (margin, y), font, scale, (0, 0, 0), 3, cv2.LINE_AA)
cv2.putText(img, line, (margin, y), font, scale, (255, 255, 255), 1, cv2.LINE_AA)
y += 20
return img
# RoboCasa's MuJoCo EGL offscreen renderer produces garbled/static frames when
# only ONE worker env is running (reproducible with lerobot-eval --batch_size=1).
# With >=2 workers the renderer is stable. We therefore run the interactive sim
# with a small vec env, drive env 0 with the policy, and ignore the rest.
_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 + reset a RoboCasa AsyncVectorEnv (n_envs=_SIM_N_ENVS), return (env, obs).
MUST be called BEFORE the policy initialises CUDA in the parent process, so
the forkserver workers don't inherit a CUDA context (which corrupts EGL).
Uses >=2 workers because single-worker EGL rendering is broken on this stack
(garbled frames) — the same reason lerobot-eval renders cleanly only at
batch_size>=2. Only env 0 is driven/displayed.
"""
from lerobot.envs.configs import RoboCasaEnv as RoboCasaEnvConfig # noqa: PLC0415
# Higher-res observation cameras => higher-quality display. The policy is
# unaffected: its preprocessor resizes images to 224 and VISUAL norm is
# identity, so only render cost (not behaviour) changes with render_size.
env_cfg = RoboCasaEnvConfig(
task=task,
split=split,
obj_registries=list(obj_registries),
observation_height=render_size,
observation_width=render_size,
)
# Persistent kitchen: never end/reset on task success, and use a huge horizon
# so the scene doesn't truncate. The user drives it with 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 serving frames from ``get_frame()`` on ``port``.
Started early (before the ~60s policy load) so the port listens immediately
and browsers get a page instead of connection-refused. ``get_frame`` returns
the latest annotated frame or None (a "waiting" placeholder is shown until
frames arrive). The server thread only reads/encodes frames — no CUDA/EGL —
so it never affects rendering. Returns the server (for shutdown) or None.
"""
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:
server = ThreadingHTTPServer(("0.0.0.0", port), _Handler)
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:
"""Drive a single RoboCasa gym env from the language runtime.
Exposes ``observation_provider`` / ``action_executor`` closures matching the
runtime's injected-callable contract, plus ``disconnect`` so the shared
``_run_autonomous`` cleanup path can close the env (and flush the video).
The env must be created via :func:`create_sim_env` *before* the policy
touches CUDA (see that function's note on the EGL/CUDA fork hazard).
"""
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
# Camera views to composite into the display frame (order = left→right).
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._frames: list[np.ndarray] = []
self._live_counter = 0
self._latest_frame: np.ndarray | None = None
self._stream_server: Any = None
self._reset_count = 0
# State getters wired after the runtime exists (bind_runtime), so the
# video overlay can show the live task/subtask/memory.
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.get("current_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
# ``task`` feeds the recipe RenderMessagesStep; the PI052 adapter
# overwrites the language tokens with its generated subtask before the
# action forward pass, so this only needs to be present, not exact.
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()
# Only env 0 is policy-driven; tile its action across all workers so
# env.step gets a full (n_envs, action_dim) batch. The extra workers
# exist only to keep MuJoCo's EGL renderer stable (single-worker
# rendering is broken); their rollouts are ignored.
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
if self.record:
self._capture_frame()
# AsyncVectorEnv auto-resets a sub-env after it terminates, so the
# scene continues on its own — no manual reset needed here.
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 _frontal_obs_image(self) -> np.ndarray | None:
"""Return the current front agent-view camera image (H, W, 3) uint8.
Uses the observation the policy already consumes rather than a separate
``env.render()`` call: the render path's camera is intermittently
corrupted by the offscreen EGL context, whereas the policy's obs images
come straight through the eval pipeline and stay clean.
"""
pixels = (self._last_obs or {}).get("pixels")
if not isinstance(pixels, dict) or not pixels:
return None
cam = "robot0_agentview_left" if "robot0_agentview_left" in pixels else next(iter(pixels))
img = np.asarray(pixels[cam])
if img.ndim == 4: # vec env batches to (1, H, W, C)
img = img[0]
if img.ndim != 3 or img.shape[-1] != 3:
return None
return img.astype(np.uint8)
def _multiview_frame(self) -> np.ndarray | None:
"""Composite the configured camera views (env 0) side by side, labeled.
Uses the policy's own high-res observation images (env.step already
rendered them), so there's no extra render cost and orientation matches.
"""
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 = _overlay_text(frame, self._current_task(), subtask, memory)
self._frames.append(annotated)
self._latest_frame = annotated # served by the live MJPEG stream
self._write_live_frame(annotated)
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.
"""
if not self.record:
return
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 not self.record or not self._frames:
return
from datetime import datetime # noqa: PLC0415
from lerobot.utils.io_utils import write_video # noqa: PLC0415
self.output_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
path = self.output_dir / f"sim_{stamp}.mp4"
fps = int((getattr(self.env, "metadata", None) or {}).get("render_fps", 20))
try:
write_video(str(path), np.stack(self._frames), fps)
logger.info("[sim] wrote video (%d frames) to %s", len(self._frames), path)
print(f"[runtime] sim video saved to {path}", flush=True)
except Exception as exc: # noqa: BLE001
logger.warning("[sim] write_video 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 (called by _run_autonomous)."""
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)