Compare commits

...

6 Commits

Author SHA1 Message Date
Maxime Ellerbach 379890cd4a precommit 2026-07-17 15:49:32 +00:00
Maxime Ellerbach fb8c6ac8b9 cleaned up, added docs and a few tests 2026-07-17 15:42:03 +00:00
Maxime Ellerbach 4ae2fbca36 draft for unifying prediction visualization 2026-07-10 16:29:58 +00:00
Maxime Ellerbach 3d3f594623 Merge branch 'main' into feat/modifying-policy-contract 2026-07-10 13:47:24 +02:00
Maximellerbach 811727d462 renaming to return_intermediate_predictions 2026-06-10 13:50:59 +02:00
Maxime Ellerbach d1a8910f60 feat(policy): adding return_extra to policy contracts 2026-06-10 11:23:30 +00:00
18 changed files with 474 additions and 147 deletions
+24 -21
View File
@@ -39,11 +39,11 @@ lerobot-rollout \
--duration=60 --duration=60
``` ```
| Flag | Description | | Flag | Description |
| ---------------- | ------------------------------------------------------ | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `--duration` | Run time in seconds (0 = infinite) | | `--duration` | Run time in seconds (0 = infinite) |
| `--task` | Task description passed to the policy | | `--task` | Task description passed to the policy |
| `--display_data` | Stream observations/actions to Rerun for visualization | | `--display_data` | Stream observations/actions to the visualization backend (`--display_mode`; add `--display_extra_data` for a policy's imagined predictions) |
### Sentry (`--strategy.type=sentry`) ### Sentry (`--strategy.type=sentry`)
@@ -241,22 +241,25 @@ See the [Real-Time Chunking](./rtc) guide for details on tuning RTC parameters.
## Common Flags ## Common Flags
| Flag | Description | Default | | Flag | Description | Default |
| --------------------------------- | ----------------------------------------------------------------- | ------- | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `--policy.path` | **Required.** HF Hub model ID or local checkpoint path | -- | | `--policy.path` | **Required.** HF Hub model ID or local checkpoint path | -- |
| `--robot.type` | **Required.** Robot type (e.g. `so100_follower`, `koch_follower`) | -- | | `--robot.type` | **Required.** Robot type (e.g. `so100_follower`, `koch_follower`) | -- |
| `--robot.port` | Serial port for the robot | -- | | `--robot.port` | Serial port for the robot | -- |
| `--robot.cameras` | Camera configuration (JSON dict) | -- | | `--robot.cameras` | Camera configuration (JSON dict) | -- |
| `--fps` | Control loop frequency | 30 | | `--fps` | Control loop frequency | 30 |
| `--duration` | Run time in seconds (0 = infinite) | 0 | | `--duration` | Run time in seconds (0 = infinite) | 0 |
| `--device` | Torch device (`cpu`, `cuda`, `mps`) | auto | | `--device` | Torch device (`cpu`, `cuda`, `mps`) | auto |
| `--task` | Task description (used when no dataset is provided) | -- | | `--task` | Task description (used when no dataset is provided) | -- |
| `--display_data` | Stream telemetry to Rerun visualization | false | | `--display_data` | Stream telemetry to the visualization backend | false |
| `--display_ip` / `--display_port` | Remote Rerun server address | -- | | `--display_mode` | Visualization backend: `rerun` or `foxglove` | rerun |
| `--interpolation_multiplier` | Action interpolation factor | 1 | | `--display_extra_data` | Also stream a policy's intermediate predictions (e.g. a world model's imagined video) on a dedicated channel; implies `--display_data`, sync inference only | false |
| `--use_torch_compile` | Enable `torch.compile` for inference | false | | `--display_compressed_images` | JPEG-compress images before streaming (less bandwidth, more CPU) | false |
| `--resume` | Resume a previous recording session | false | | `--display_ip` / `--display_port` | Remote Rerun server address (or bind interface/port for foxglove) | -- |
| `--play_sounds` | Vocal synthesis for events | true | | `--interpolation_multiplier` | Action interpolation factor (upsamples the control rate) | 1 |
| `--use_torch_compile` | Enable `torch.compile` for inference | false |
| `--resume` | Resume a previous recording session | false |
| `--play_sounds` | Vocal synthesis for events | true |
--- ---
+3
View File
@@ -93,6 +93,9 @@ class EvalConfig:
recording_repo_id: str | None = None recording_repo_id: str | None = None
# Whether the pushed recording repositories should be private. # Whether the pushed recording repositories should be private.
recording_private: bool = False recording_private: bool = False
# Whether to save the policy's imagined/predicted video (world-model policies only) as mp4s.
# Requests intermediate predictions from the policy each step; policies that produce none are unaffected.
save_predicted_video: bool = False
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.recording_repo_id is not None and not self.recording: if self.recording_repo_id is not None and not self.recording:
@@ -92,9 +92,6 @@ class LingBotVAConfig(PreTrainedConfig):
# (un)normalization quantiles live in the checkpoint's ``policy_postprocessor.json``, not here. # (un)normalization quantiles live in the checkpoint's ``policy_postprocessor.json``, not here.
used_action_channel_ids: list[int] = field(default_factory=lambda: list(range(7))) used_action_channel_ids: list[int] = field(default_factory=lambda: list(range(7)))
# Opt-in: VAE-decode predicted video latents to ``self.last_predicted_frames`` for saving MP4s.
save_predicted_video: bool = False
# Normalization: IDENTITY here; images are scaled + VAE-encoded and actions are # Normalization: IDENTITY here; images are scaled + VAE-encoded and actions are
# quantile-(un)normalized inside the policy / dedicated processor steps. # quantile-(un)normalized inside the policy / dedicated processor steps.
normalization_mapping: dict[str, NormalizationMode] = field( normalization_mapping: dict[str, NormalizationMode] = field(
@@ -38,7 +38,7 @@ import torch.nn.functional as F # noqa: N812
from einops import rearrange from einops import rearrange
from torch import Tensor from torch import Tensor
from lerobot.policies.pretrained import PreTrainedPolicy from lerobot.policies.pretrained import PreTrainedPolicy, unpack_action_output
from lerobot.utils.constants import ACTION from lerobot.utils.constants import ACTION
from lerobot.utils.import_utils import require_package from lerobot.utils.import_utils import require_package
@@ -99,8 +99,6 @@ class LingBotVAPolicy(PreTrainedPolicy):
# from ``config.wan_pretrained_path`` the first time inference runs. # from ``config.wan_pretrained_path`` the first time inference runs.
self._frozen: dict = {} self._frozen: dict = {}
self.last_predicted_frames: Tensor | None = None
self.last_predicted_latents: Tensor | None = None
self.reset() self.reset()
# Frozen-module lazy loading (VAE + UMT5 + tokenizer) # Frozen-module lazy loading (VAE + UMT5 + tokenizer)
@@ -170,8 +168,6 @@ class LingBotVAPolicy(PreTrainedPolicy):
self._prompt: str | None = None self._prompt: str | None = None
self._prompt_embeds = None self._prompt_embeds = None
self._negative_prompt_embeds = None self._negative_prompt_embeds = None
self.last_predicted_frames = None
self.last_predicted_latents = None
self._use_cfg = (cfg.guidance_scale > 1) or (cfg.action_guidance_scale > 1) self._use_cfg = (cfg.guidance_scale > 1) or (cfg.action_guidance_scale > 1)
# Two independent flow-matching schedulers (video latent + action streams). # Two independent flow-matching schedulers (video latent + action streams).
self._scheduler = FlowMatchScheduler(shift=cfg.snr_shift, sigma_min=0.0, extra_one_step=True) self._scheduler = FlowMatchScheduler(shift=cfg.snr_shift, sigma_min=0.0, extra_one_step=True)
@@ -400,22 +396,33 @@ class LingBotVAPolicy(PreTrainedPolicy):
return torch.cat(per_cam, dim=-1).to(self.config.device) return torch.cat(per_cam, dim=-1).to(self.config.device)
@torch.no_grad() @torch.no_grad()
def select_action(self, batch: dict[str, Tensor], **kwargs) -> Tensor: def select_action(
self, batch: dict[str, Tensor], return_intermediate_predictions: bool = False, **kwargs
) -> Tensor | tuple[Tensor, dict[str, Tensor]]:
"""Return one action, refilling the chunk (and feeding back observed keyframes) as needed. """Return one action, refilling the chunk (and feeding back observed keyframes) as needed.
Mirrors the upstream LIBERO client loop (``evaluation/libero/client.py``): the first obs is Mirrors the upstream LIBERO client loop (``evaluation/libero/client.py``): the first obs is
the conditioning frame; every observation produced afterwards is buffered as a keyframe and, the conditioning frame; every observation produced afterwards is buffered as a keyframe and,
once the chunk's actions are exhausted, the buffered frames + executed actions are fed back once the chunk's actions are exhausted, the buffered frames + executed actions are fed back
into the KV cache before the next chunk is predicted. into the KV cache before the next chunk is predicted.
When ``return_intermediate_predictions=True`` returns ``(action, predictions)``. Predictions
are produced only on the ticks that predict a fresh chunk (first tick and each chunk refill);
on the intermediate ticks that just pop a cached action, ``predictions`` is an empty dict.
""" """
self.eval() self.eval()
self._ensure_frozen_modules() self._ensure_frozen_modules()
self._maybe_init_prompt(batch) self._maybe_init_prompt(batch)
predictions: dict[str, Tensor] = {}
if not self._started: if not self._started:
# First call: this observation conditions the first chunk (it is *not* a keyframe). # First call: this observation conditions the first chunk (it is *not* a keyframe).
self._started = True self._started = True
actions = self.predict_action_chunk(batch) # [B, chunk_size, n_used] actions, predictions = unpack_action_output(
self.predict_action_chunk(
batch, return_intermediate_predictions=return_intermediate_predictions
)
) # [B, chunk_size, n_used]
self._action_queue.extend(actions.transpose(0, 1)) # [chunk_size, B, n_used] self._action_queue.extend(actions.transpose(0, 1)) # [chunk_size, B, n_used]
self._obs_buffer = [] self._obs_buffer = []
self._exec_step = 0 self._exec_step = 0
@@ -427,17 +434,31 @@ class LingBotVAPolicy(PreTrainedPolicy):
if len(self._action_queue) == 0: if len(self._action_queue) == 0:
# All actions for the current chunk have been executed; feed the observed # All actions for the current chunk have been executed; feed the observed
# keyframes + executed actions back and predict the next chunk. # keyframes + executed actions back and predict the next chunk.
actions = self.predict_action_chunk(None) actions, predictions = unpack_action_output(
self.predict_action_chunk(
None, return_intermediate_predictions=return_intermediate_predictions
)
)
self._action_queue.extend(actions.transpose(0, 1)) self._action_queue.extend(actions.transpose(0, 1))
self._exec_step = 0 self._exec_step = 0
self._prev_j = self._exec_step % self.config.action_per_frame self._prev_j = self._exec_step % self.config.action_per_frame
self._exec_step += 1 self._exec_step += 1
return self._action_queue.popleft() action = self._action_queue.popleft()
if return_intermediate_predictions:
return action, predictions
return action
@torch.no_grad() @torch.no_grad()
def predict_action_chunk(self, batch: dict[str, Tensor], **kwargs) -> Tensor: def predict_action_chunk(
"""Run one autoregressive chunk and return actions ``[B, chunk_size, n_used]`` (normalized).""" self, batch: dict[str, Tensor], return_intermediate_predictions: bool = False, **kwargs
) -> Tensor | tuple[Tensor, dict[str, Tensor]]:
"""Run one autoregressive chunk and return actions ``[B, chunk_size, n_used]`` (normalized).
When ``return_intermediate_predictions=True`` returns ``(actions, predictions)`` where
``predictions`` holds this chunk's VAE-decoded imagined video under ``"images.predicted"``
(``[T, H, W, 3]`` uint8 on CPU).
"""
self.eval() self.eval()
self._ensure_frozen_modules() self._ensure_frozen_modules()
self._maybe_init_prompt(batch) self._maybe_init_prompt(batch)
@@ -459,12 +480,6 @@ class LingBotVAPolicy(PreTrainedPolicy):
# actions: [B, action_dim, F, action_per_frame, 1] (model-normalized). Keep for KV feedback. # actions: [B, action_dim, F, action_per_frame, 1] (model-normalized). Keep for KV feedback.
self._executed_actions = actions self._executed_actions = actions
if self.config.save_predicted_video:
# Match upstream LingBot-VA visualization: collect chunk latents and decode the
# concatenated latent sequence once after the rollout finishes.
self.last_predicted_frames = None
self.last_predicted_latents = latents.detach().to("cpu")
# On the first chunk, frame 0 is the conditioning frame (already "known"): the upstream # On the first chunk, frame 0 is the conditioning frame (already "known"): the upstream
# LIBERO client skips it (start_idx=1), so we drop the first frame's actions here. # LIBERO client skips it (start_idx=1), so we drop the first frame's actions here.
used = self.config.used_action_channel_ids used = self.config.used_action_channel_ids
@@ -473,7 +488,15 @@ class LingBotVAPolicy(PreTrainedPolicy):
a = a[:, :, 1:] # drop frame 0 -> (F-1) frames of actions a = a[:, :, 1:] # drop frame 0 -> (F-1) frames of actions
a = a.squeeze(-1).flatten(2) # [B, n_used, n_steps] a = a.squeeze(-1).flatten(2) # [B, n_used, n_steps]
a = a.transpose(1, 2).contiguous() # [B, n_steps, n_used] a = a.transpose(1, 2).contiguous() # [B, n_steps, n_used]
return a.to(torch.float32) a = a.to(torch.float32)
if return_intermediate_predictions:
# Decode this chunk's imagined video for visualization / eval. Per-chunk decode (the VAE
# has no streaming decoder) may differ slightly at chunk boundaries from a single decode
# over the whole concatenated latent sequence; acceptable for monitoring/inspection.
frames = self._decode_predicted_video(latents) # [T, H, W, 3] uint8, CPU
return a, {"images.predicted": frames}
return a
# Prompt / text encoding # Prompt / text encoding
def _maybe_init_prompt(self, batch): def _maybe_init_prompt(self, batch):
@@ -834,11 +857,6 @@ class LingBotVAPolicy(PreTrainedPolicy):
return actions, latents return actions, latents
# Predicted-video decoding (opt-in) # Predicted-video decoding (opt-in)
@torch.no_grad()
def decode_predicted_latents(self, latents) -> Tensor:
"""Decode a concatenated predicted-latent sequence into ``[T, H, W, 3]`` uint8 frames."""
return self._decode_predicted_video(latents)
@torch.no_grad() @torch.no_grad()
def _decode_predicted_video(self, latents) -> Tensor: def _decode_predicted_video(self, latents) -> Tensor:
"""VAE-decode predicted latents into a uint8 frame stack ``[T, H, W, 3]`` on CPU.""" """VAE-decode predicted latents into a uint8 frame stack ``[T, H, W, 3]`` on CPU."""
+28 -2
View File
@@ -93,6 +93,18 @@ def _build_card_context(
class ActionSelectKwargs(TypedDict, total=False): class ActionSelectKwargs(TypedDict, total=False):
noise: Tensor | None noise: Tensor | None
return_intermediate_predictions: bool
def unpack_action_output(out: Tensor | tuple[Tensor, dict[str, Tensor]]) -> tuple[Tensor, dict[str, Tensor]]:
"""Normalize a ``select_action`` / ``predict_action_chunk`` return to ``(action, predictions)``.
These methods return a bare action ``Tensor`` by default, or a ``(action, predictions)`` tuple when
called with ``return_intermediate_predictions=True``. A bare tensor becomes ``(tensor, {})``.
"""
if isinstance(out, tuple):
return out[0], out[1]
return out, {}
class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC): class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
@@ -273,20 +285,34 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
raise NotImplementedError raise NotImplementedError
@abc.abstractmethod @abc.abstractmethod
def predict_action_chunk(self, batch: dict[str, Tensor], **kwargs: Unpack[ActionSelectKwargs]) -> Tensor: def predict_action_chunk(
self, batch: dict[str, Tensor], **kwargs: Unpack[ActionSelectKwargs]
) -> Tensor | tuple[Tensor, dict[str, Tensor]]:
"""Returns the action chunk (for action chunking policies) for a given observation, potentially in batch mode. """Returns the action chunk (for action chunking policies) for a given observation, potentially in batch mode.
Child classes using action chunking should use this method within `select_action` to form the action chunk Child classes using action chunking should use this method within `select_action` to form the action chunk
cached for selection. cached for selection.
By default returns just the action `Tensor`. If `return_intermediate_predictions=True`,
returns `(action, predictions)` where `predictions` is a (possibly empty) `dict[str, Tensor]`
of additional model predictions a policy may expose (e.g. world-model predicted frames).
Policies that produce nothing extra may ignore the kwarg.
""" """
raise NotImplementedError raise NotImplementedError
@abc.abstractmethod @abc.abstractmethod
def select_action(self, batch: dict[str, Tensor], **kwargs: Unpack[ActionSelectKwargs]) -> Tensor: def select_action(
self, batch: dict[str, Tensor], **kwargs: Unpack[ActionSelectKwargs]
) -> Tensor | tuple[Tensor, dict[str, Tensor]]:
"""Return one action to run in the environment (potentially in batch mode). """Return one action to run in the environment (potentially in batch mode).
When the model uses a history of observations, or outputs a sequence of actions, this method deals When the model uses a history of observations, or outputs a sequence of actions, this method deals
with caching. with caching.
By default returns just the action `Tensor`. If `return_intermediate_predictions=True`,
returns `(action, predictions)` where `predictions` is a (possibly empty) `dict[str, Tensor]`
of additional model predictions a policy may expose (e.g. world-model predicted frames).
Policies that produce nothing extra may ignore the kwarg.
""" """
raise NotImplementedError raise NotImplementedError
+19
View File
@@ -226,6 +226,10 @@ class RolloutConfig:
device: str | None = None device: str | None = None
task: str = "" task: str = ""
display_data: bool = False display_data: bool = False
# Also visualize model "extras" (e.g. a world model's imagined video) alongside observations.
# Off by default: requesting predictions forces per-chunk decoding on the control thread and only
# world-model policies produce anything. Implies display_data. Sync inference only.
display_extra_data: bool = False
# Visualization backend used when display_data is True: "rerun" or "foxglove". # Visualization backend used when display_data is True: "rerun" or "foxglove".
display_mode: str = "rerun" display_mode: str = "rerun"
# For "rerun": IP of a remote server to send to. For "foxglove": interface to bind the WebSocket # For "rerun": IP of a remote server to send to. For "foxglove": interface to bind the WebSocket
@@ -255,6 +259,21 @@ class RolloutConfig:
def __post_init__(self): def __post_init__(self):
"""Validate config invariants and load the policy config from ``--policy.path``.""" """Validate config invariants and load the policy config from ``--policy.path``."""
# --- Visualization validation ---
# Extra-data visualization piggybacks on the display_data path (backend init + telemetry
# logging are both gated on display_data), so enabling it implies display_data.
if self.display_extra_data and not self.display_data:
logger.info("display_extra_data=True implies display_data=True; enabling display_data")
self.display_data = True
# Only the sync engine surfaces intermediate predictions (RTC runs the policy in a background
# thread); warn and let it be ignored rather than fail.
if self.display_extra_data and not isinstance(self.inference, SyncInferenceConfig):
logger.warning(
"display_extra_data is only supported with sync inference (--inference.type=sync); "
"it will be ignored for inference type '%s'",
self.inference.type,
)
# --- Strategy-specific validation --- # --- Strategy-specific validation ---
if isinstance(self.strategy, DAggerStrategyConfig) and self.teleop is None: if isinstance(self.strategy, DAggerStrategyConfig) and self.teleop is None:
raise ValueError("DAgger strategy requires --teleop.type to be set") raise ValueError("DAgger strategy requires --teleop.type to be set")
+1
View File
@@ -429,6 +429,7 @@ def build_rollout_context(
use_torch_compile=cfg.use_torch_compile, use_torch_compile=cfg.use_torch_compile,
compile_warmup_inferences=cfg.compile_warmup_inferences, compile_warmup_inferences=cfg.compile_warmup_inferences,
shutdown_event=shutdown_event, shutdown_event=shutdown_event,
visualize_predictions=cfg.display_extra_data,
) )
# --- 8. Assemble --------------------------------------------------- # --- 8. Assemble ---------------------------------------------------
+9
View File
@@ -69,6 +69,15 @@ class InferenceEngine(abc.ABC):
def get_action(self, obs_frame: dict | None) -> torch.Tensor | None: def get_action(self, obs_frame: dict | None) -> torch.Tensor | None:
"""Return the next action tensor, or ``None`` if unavailable.""" """Return the next action tensor, or ``None`` if unavailable."""
def get_intermediate_predictions(self) -> dict | None:
"""Extra display-ready model outputs to visualize this tick, or ``None``.
Lets a backend surface a world model's intermediate predictions (e.g. imagined video
frames) into the rollout visualization path, keyed by ``"<datatype>.<name>"`` (mirroring
observation feature keys). Default: nothing extra.
"""
return None
def notify_observation(self, obs: dict) -> None: # noqa: B027 def notify_observation(self, obs: dict) -> None: # noqa: B027
"""Publish the latest processed observation. Default: no-op.""" """Publish the latest processed observation. Default: no-op."""
+2
View File
@@ -95,6 +95,7 @@ def create_inference_engine(
use_torch_compile: bool = False, use_torch_compile: bool = False,
compile_warmup_inferences: int = 2, compile_warmup_inferences: int = 2,
shutdown_event: Event | None = None, shutdown_event: Event | None = None,
visualize_predictions: bool = False,
) -> InferenceEngine: ) -> InferenceEngine:
"""Instantiate the appropriate inference engine from a config object.""" """Instantiate the appropriate inference engine from a config object."""
logger.info("Creating inference engine: %s", config.type) logger.info("Creating inference engine: %s", config.type)
@@ -108,6 +109,7 @@ def create_inference_engine(
task=task, task=task,
device=device, device=device,
robot_type=robot_wrapper.robot_type, robot_type=robot_wrapper.robot_type,
visualize_predictions=visualize_predictions,
) )
if isinstance(config, RTCInferenceConfig): if isinstance(config, RTCInferenceConfig):
return RTCInferenceEngine( return RTCInferenceEngine(
@@ -0,0 +1,79 @@
# Copyright 2025 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.
"""Paced playback of a policy's temporal predictions for visualization.
Intermediate predictions (e.g. a world model's imagined clip) arrive as a ``{key: sequence}`` stack
with time on axis 0, one fresh stack per predicted chunk. Advancement is kept separate from reading
so the loop's two clocks don't get conflated: :meth:`advance` steps the playhead at policy cadence
(one new prediction per chunk), while :meth:`current` is a side-effect-free read for the display,
which may run faster (e.g. action-interpolated). Between policy ticks the viewer just holds the last
frame. Modality-agnostic: a step may be an image, vector, or scalar — only axis 0 is assumed to be
time.
"""
from __future__ import annotations
class PacedPredictionBuffer:
"""Maps a temporal prediction stack onto a chunk's policy ticks for paced visualization.
Drive from policy inference (:meth:`load` a fresh chunk, :meth:`advance` on its later ticks);
read from the display loop (:meth:`current`).
"""
def __init__(self, ticks_per_chunk: int | None = None) -> None:
# Policy ticks per predicted chunk; the T steps of each stack are spread across this span.
# ``None`` falls back to one step per policy tick (clamped to the stack length).
self._ticks_per_chunk = ticks_per_chunk
self._stacks: dict = {} # key -> sequence with time on axis 0, for the current chunk
self._cursor = 0 # policy ticks elapsed since the current chunk's stacks were loaded
def reset(self) -> None:
"""Drop the current chunk's stacks and rewind the playhead."""
self._stacks = {}
self._cursor = 0
def load(self, stacks: dict) -> None:
"""Store a freshly predicted chunk's stacks and rewind the playhead to its first step."""
self._stacks = stacks
self._cursor = 0
def advance(self) -> None:
"""Move the playhead forward one policy tick (no-op until a chunk is loaded)."""
if self._stacks:
self._cursor += 1
def current(self) -> dict | None:
"""Current playhead step per key (``None`` until a chunk is loaded); no side effects.
Maps each stack's ``T`` steps onto the ``ticks_per_chunk`` span (one step/tick, clamped, when
the span is unknown).
"""
if not self._stacks:
return None
tick = self._cursor
span = self._ticks_per_chunk
out: dict = {}
for key, stack in self._stacks.items():
n = len(stack)
if n == 0:
continue
idx = round(tick / (span - 1) * (n - 1)) if span and span > 1 else tick
idx = min(max(idx, 0), n - 1)
step = stack[idx]
if hasattr(step, "detach"): # torch tensor -> numpy for the visualization backend
step = step.detach().cpu().numpy()
out[key] = step
return out or None
+29 -3
View File
@@ -22,11 +22,12 @@ from copy import copy
import torch import torch
from lerobot.policies.pretrained import PreTrainedPolicy from lerobot.policies.pretrained import PreTrainedPolicy, unpack_action_output
from lerobot.policies.utils import make_robot_action, prepare_observation_for_inference from lerobot.policies.utils import make_robot_action, prepare_observation_for_inference
from lerobot.processor import PolicyProcessorPipeline from lerobot.processor import PolicyProcessorPipeline
from .base import InferenceEngine from .base import InferenceEngine
from .prediction_buffer import PacedPredictionBuffer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -64,6 +65,7 @@ class SyncInferenceEngine(InferenceEngine):
task: str, task: str,
device: str | None, device: str | None,
robot_type: str, robot_type: str,
visualize_predictions: bool = False,
) -> None: ) -> None:
self._policy = policy self._policy = policy
self._preprocessor = preprocessor self._preprocessor = preprocessor
@@ -73,10 +75,18 @@ class SyncInferenceEngine(InferenceEngine):
self._task = task self._task = task
self._device = torch.device(device or "cpu") self._device = torch.device(device or "cpu")
self._robot_type = robot_type self._robot_type = robot_type
# Intermediate-prediction visualization (e.g. a world model's imagined future). ``get_action``
# (once per policy tick) loads/advances the buffer; ``get_intermediate_predictions`` reads it
# for display. Pacing on policy ticks keeps playback aligned with execution regardless of
# action-interpolation upsampling.
self._visualize_predictions = visualize_predictions
self._predictions = PacedPredictionBuffer(getattr(policy.config, "chunk_size", None))
logger.info( logger.info(
"SyncInferenceEngine initialized (device=%s, action_keys=%d)", "SyncInferenceEngine initialized (device=%s, action_keys=%d, visualize_predictions=%s)",
self._device, self._device,
len(ordered_action_keys), len(ordered_action_keys),
self._visualize_predictions,
) )
def start(self) -> None: def start(self) -> None:
@@ -93,6 +103,11 @@ class SyncInferenceEngine(InferenceEngine):
self._policy.reset() self._policy.reset()
self._preprocessor.reset() self._preprocessor.reset()
self._postprocessor.reset() self._postprocessor.reset()
self._predictions.reset()
def get_intermediate_predictions(self) -> dict | None:
"""Read the current chunk's paced prediction frame for display (``None`` if none)."""
return self._predictions.current()
def get_action(self, obs_frame: dict | None) -> torch.Tensor | None: def get_action(self, obs_frame: dict | None) -> torch.Tensor | None:
"""Run the full inference pipeline on ``obs_frame`` and return an action tensor.""" """Run the full inference pipeline on ``obs_frame`` and return an action tensor."""
@@ -112,7 +127,18 @@ class SyncInferenceEngine(InferenceEngine):
observation, self._device, self._task, self._robot_type observation, self._device, self._task, self._robot_type
) )
observation = self._preprocessor(observation) observation = self._preprocessor(observation)
action = self._policy.select_action(observation) if self._visualize_predictions:
action, predictions = unpack_action_output(
self._policy.select_action(observation, return_intermediate_predictions=True)
)
if predictions:
# A fresh chunk was predicted this tick — load its stacks and rewind the playhead.
self._predictions.load(predictions)
else:
# Same chunk, later policy tick — step the playhead through the imagined frames.
self._predictions.advance()
else:
action = self._policy.select_action(observation)
action = self._postprocessor(action) action = self._postprocessor(action)
action_tensor = action.squeeze(0).cpu() action_tensor = action.squeeze(0).cpu()
+7 -1
View File
@@ -156,8 +156,8 @@ class RolloutStrategy(abc.ABC):
except Exception as e: except Exception as e:
logger.warning("Could not return to initial position: %s", e) logger.warning("Could not return to initial position: %s", e)
@staticmethod
def _log_telemetry( def _log_telemetry(
self,
obs_processed: dict | None, obs_processed: dict | None,
action_dict: dict | None, action_dict: dict | None,
runtime_ctx: RuntimeContext, runtime_ctx: RuntimeContext,
@@ -166,10 +166,16 @@ class RolloutStrategy(abc.ABC):
cfg = runtime_ctx.cfg cfg = runtime_ctx.cfg
if not cfg.display_data: if not cfg.display_data:
return return
# When extra-data visualization is on, pull any display-ready model predictions from the
# engine (e.g. a world model's imagined video) and log them on the dedicated prediction channel.
prediction = None
if cfg.display_extra_data and self._engine is not None:
prediction = self._engine.get_intermediate_predictions()
log_visualization_data( log_visualization_data(
cfg.display_mode, cfg.display_mode,
observation=obs_processed, observation=obs_processed,
action=action_dict, action=action_dict,
prediction=prediction,
compress_images=cfg.display_compressed_images, compress_images=cfg.display_compressed_images,
) )
+58 -52
View File
@@ -83,6 +83,7 @@ from lerobot.envs import (
preprocess_observation, preprocess_observation,
) )
from lerobot.policies import PreTrainedPolicy, make_policy, make_pre_post_processors from lerobot.policies import PreTrainedPolicy, make_policy, make_pre_post_processors
from lerobot.policies.pretrained import unpack_action_output
from lerobot.processor import PolicyProcessorPipeline from lerobot.processor import PolicyProcessorPipeline
from lerobot.types import PolicyAction from lerobot.types import PolicyAction
from lerobot.utils.constants import ACTION, DONE, OBS_IMAGE, OBS_IMAGES, OBS_STR, REWARD from lerobot.utils.constants import ACTION, DONE, OBS_IMAGE, OBS_IMAGES, OBS_STR, REWARD
@@ -169,7 +170,7 @@ def rollout(
env_features: dict | None = None, env_features: dict | None = None,
recording_repo_id: str | None = None, recording_repo_id: str | None = None,
recording_private: bool = False, recording_private: bool = False,
predicted_latents_callback: Callable[[PreTrainedPolicy], None] | None = None, save_predicted_video: bool = False,
) -> dict: ) -> dict:
"""Run a batched policy rollout once through a batch of environments. """Run a batched policy rollout once through a batch of environments.
@@ -199,9 +200,10 @@ def rollout(
are returned optionally because they typically take more memory to cache. Defaults to False. are returned optionally because they typically take more memory to cache. Defaults to False.
render_callback: Optional rendering callback to be used after the environments are reset, and after render_callback: Optional rendering callback to be used after the environments are reset, and after
every step. every step.
predicted_latents_callback: Optional callback invoked after every ``select_action`` with the policy save_predicted_video: When True, request intermediate predictions from the policy each step
itself. World-model policies (e.g. LingBot-VA) stash predicted video latents on (``select_action(..., return_intermediate_predictions=True)``) and collect any imagined
``policy.last_predicted_latents``; this lets the caller concatenate chunks and decode once. video frames a world-model policy returns. Collected per image key in
``ret["predicted_frames"]`` as a list of ``[T, H, W, 3]`` uint8 chunk stacks.
Returns: Returns:
The dictionary described above. The dictionary described above.
""" """
@@ -245,6 +247,9 @@ def rollout(
all_rewards = [] all_rewards = []
all_successes = [] all_successes = []
all_dones = [] all_dones = []
# Imagined-video frames returned by world-model policies, collected per image key. Each entry is
# a chunk stack [T, H, W, 3] uint8; concatenated on the time axis by the caller.
predicted_frames: dict[str, list] = {}
step = 0 step = 0
# Keep track of which environments are done. # Keep track of which environments are done.
@@ -279,9 +284,13 @@ def rollout(
observation = preprocessor(observation) observation = preprocessor(observation)
with torch.inference_mode(): with torch.inference_mode():
action = policy.select_action(observation) extra = {"return_intermediate_predictions": True} if save_predicted_video else {}
if predicted_latents_callback is not None: action, predictions = unpack_action_output(policy.select_action(observation, **extra))
predicted_latents_callback(policy) # World-model policies return imagined frames only on chunk-boundary ticks; collect them.
for key, frames in predictions.items():
if hasattr(frames, "detach"):
frames = frames.detach().to("cpu")
predicted_frames.setdefault(key, []).append(frames)
action = postprocessor(action) action = postprocessor(action)
action_transition = {ACTION: action} action_transition = {ACTION: action}
@@ -394,6 +403,9 @@ def rollout(
stacked_observations[key] = torch.stack([obs[key] for obs in all_observations], dim=1) stacked_observations[key] = torch.stack([obs[key] for obs in all_observations], dim=1)
ret[OBS_STR] = stacked_observations ret[OBS_STR] = stacked_observations
if save_predicted_video:
ret["predicted_frames"] = predicted_frames
if hasattr(policy, "use_original_modules"): if hasattr(policy, "use_original_modules"):
policy.use_original_modules() policy.use_original_modules()
@@ -435,11 +447,6 @@ def eval_policy(
if max_episodes_rendered > 0 and not videos_dir: if max_episodes_rendered > 0 and not videos_dir:
raise ValueError("If max_episodes_rendered > 0, videos_dir must be provided.") raise ValueError("If max_episodes_rendered > 0, videos_dir must be provided.")
# World-model policies (e.g. LingBot-VA) opt into predicted-video saving via their config.
save_predicted_video = save_predicted_video or bool(
getattr(getattr(policy, "config", None), "save_predicted_video", False)
)
if not isinstance(policy, PreTrainedPolicy): if not isinstance(policy, PreTrainedPolicy):
exc = ValueError( exc = ValueError(
f"Policy of type 'PreTrainedPolicy' is expected, but type '{type(policy)}' was provided." f"Policy of type 'PreTrainedPolicy' is expected, but type '{type(policy)}' was provided."
@@ -489,16 +496,6 @@ def eval_policy(
predicted_video_paths: list[str] = [] predicted_video_paths: list[str] = []
n_predicted_rendered = 0 n_predicted_rendered = 0
# Collect predicted-video latents across a rollout (world-model policies only). The latents are
# concatenated and decoded once after the rollout, matching upstream LingBot-VA's visualization path.
def collect_predicted_latents(policy: PreTrainedPolicy):
latents = getattr(policy, "last_predicted_latents", None)
if latents is not None:
pred_latents.append(
latents.detach().to("cpu") if hasattr(latents, "detach") else torch.as_tensor(latents).cpu()
)
policy.last_predicted_latents = None
if return_episode_data: if return_episode_data:
episode_data: dict | None = None episode_data: dict | None = None
@@ -510,9 +507,6 @@ def eval_policy(
if max_episodes_rendered > 0: if max_episodes_rendered > 0:
ep_frames: list[np.ndarray] = [] ep_frames: list[np.ndarray] = []
if save_predicted_video:
pred_latents: list[torch.Tensor] = []
if start_seed is None: if start_seed is None:
seeds = None seeds = None
else: else:
@@ -533,7 +527,7 @@ def eval_policy(
env_features=env_features, env_features=env_features,
recording_repo_id=recording_repo_id, recording_repo_id=recording_repo_id,
recording_private=recording_private, recording_private=recording_private,
predicted_latents_callback=collect_predicted_latents if save_predicted_video else None, save_predicted_video=save_predicted_video,
) )
# Figure out where in each rollout sequence the first done condition was encountered (results after # Figure out where in each rollout sequence the first done condition was encountered (results after
@@ -599,33 +593,33 @@ def eval_policy(
threads.append(thread) threads.append(thread)
n_episodes_rendered += 1 n_episodes_rendered += 1
# Maybe save the policy's predicted (imagined) video for this batch's rollout. # Maybe save the policy's predicted (imagined) video for this batch's rollout. The policy
if save_predicted_video and len(pred_latents) > 0: # returns display-ready frame stacks per image key; concatenate them on the time axis and
predicted_latent = torch.cat(pred_latents, dim=2) # write one mp4 per key (no decoding here — the policy already decoded).
decoder = getattr(policy, "decode_predicted_latents", None) or getattr( pred_frames = rollout_data.get("predicted_frames", {}) if save_predicted_video else {}
policy, "_decode_predicted_video", None if save_predicted_video and any(len(stacks) > 0 for stacks in pred_frames.values()):
)
if decoder is None:
raise AttributeError(
"Policy config requested predicted-video saving, but the policy does not expose "
"`decode_predicted_latents` or `_decode_predicted_video`."
)
predicted_video = decoder(predicted_latent)
if hasattr(predicted_video, "detach"):
predicted_video = predicted_video.detach().to("cpu").numpy()
videos_dir.mkdir(parents=True, exist_ok=True) videos_dir.mkdir(parents=True, exist_ok=True)
predicted_video_path = videos_dir / f"pred_episode_{n_predicted_rendered}.mp4" multi_key = len(pred_frames) > 1
predicted_video_paths.append(str(predicted_video_path)) for key, stacks in pred_frames.items():
thread = threading.Thread( if len(stacks) == 0:
target=write_video, continue
args=( predicted_video = torch.cat(
str(predicted_video_path), [s if hasattr(s, "dim") else torch.as_tensor(s) for s in stacks], dim=0
predicted_video, )
env.unwrapped.metadata["render_fps"], predicted_video = predicted_video.detach().to("cpu").numpy() # [T, H, W, 3] uint8
), suffix = f"_{key.replace('.', '_')}" if multi_key else ""
) predicted_video_path = videos_dir / f"pred_episode_{n_predicted_rendered}{suffix}.mp4"
thread.start() predicted_video_paths.append(str(predicted_video_path))
threads.append(thread) thread = threading.Thread(
target=write_video,
args=(
str(predicted_video_path),
predicted_video,
env.unwrapped.metadata["render_fps"],
),
)
thread.start()
threads.append(thread)
n_predicted_rendered += 1 n_predicted_rendered += 1
progbar.set_postfix( progbar.set_postfix(
@@ -771,6 +765,11 @@ def eval_main(cfg: EvalPipelineConfig):
recording_dir = Path(cfg.output_dir) / "recordings" if cfg.eval.recording else None recording_dir = Path(cfg.output_dir) / "recordings" if cfg.eval.recording else None
max_episodes_rendered = 0 if cfg.eval.recording else 10 max_episodes_rendered = 0 if cfg.eval.recording else 10
videos_dir = None if cfg.eval.recording else Path(cfg.output_dir) / "videos" videos_dir = None if cfg.eval.recording else Path(cfg.output_dir) / "videos"
# Predicted-video saving needs a directory to write mp4s into; recording mode leaves videos_dir
# unset, so provide one explicitly.
save_predicted_video = cfg.eval.save_predicted_video
if save_predicted_video and videos_dir is None:
videos_dir = Path(cfg.output_dir) / "videos"
with torch.no_grad(), torch.autocast(device_type=device.type) if cfg.policy.use_amp else nullcontext(): with torch.no_grad(), torch.autocast(device_type=device.type) if cfg.policy.use_amp else nullcontext():
info = eval_policy_all( info = eval_policy_all(
@@ -790,6 +789,7 @@ def eval_main(cfg: EvalPipelineConfig):
env_features=cfg.env.features if cfg.eval.recording else None, env_features=cfg.env.features if cfg.eval.recording else None,
recording_repo_id=cfg.eval.recording_repo_id, recording_repo_id=cfg.eval.recording_repo_id,
recording_private=cfg.eval.recording_private, recording_private=cfg.eval.recording_private,
save_predicted_video=save_predicted_video,
) )
print("Overall Aggregated Metrics:") print("Overall Aggregated Metrics:")
print(info["overall"]) print(info["overall"])
@@ -837,6 +837,7 @@ def eval_one(
env_features: dict | None = None, env_features: dict | None = None,
recording_repo_id: str | None = None, recording_repo_id: str | None = None,
recording_private: bool = False, recording_private: bool = False,
save_predicted_video: bool = False,
) -> TaskMetrics: ) -> TaskMetrics:
"""Evaluates one task_id of one suite using the provided vec env.""" """Evaluates one task_id of one suite using the provided vec env."""
@@ -858,6 +859,7 @@ def eval_one(
env_features=env_features, env_features=env_features,
recording_repo_id=recording_repo_id, recording_repo_id=recording_repo_id,
recording_private=recording_private, recording_private=recording_private,
save_predicted_video=save_predicted_video,
) )
per_episode = task_result["per_episode"] per_episode = task_result["per_episode"]
@@ -889,6 +891,7 @@ def run_one(
env_features: dict | None = None, env_features: dict | None = None,
recording_repo_id: str | None = None, recording_repo_id: str | None = None,
recording_private: bool = False, recording_private: bool = False,
save_predicted_video: bool = False,
): ):
""" """
Run eval_one for a single (task_group, task_id, env). Run eval_one for a single (task_group, task_id, env).
@@ -923,6 +926,7 @@ def run_one(
env_features=env_features, env_features=env_features,
recording_repo_id=task_repo_id, recording_repo_id=task_repo_id,
recording_private=recording_private, recording_private=recording_private,
save_predicted_video=save_predicted_video,
) )
if max_episodes_rendered > 0: if max_episodes_rendered > 0:
@@ -949,6 +953,7 @@ def eval_policy_all(
return_episode_data: bool = False, return_episode_data: bool = False,
start_seed: int | None = None, start_seed: int | None = None,
max_parallel_tasks: int = 1, max_parallel_tasks: int = 1,
save_predicted_video: bool = False,
) -> dict: ) -> dict:
""" """
Evaluate a nested `envs` dict: {task_group: {task_id: vec_env}}. Evaluate a nested `envs` dict: {task_group: {task_id: vec_env}}.
@@ -1008,6 +1013,7 @@ def eval_policy_all(
env_features=env_features, env_features=env_features,
recording_repo_id=recording_repo_id, recording_repo_id=recording_repo_id,
recording_private=recording_private, recording_private=recording_private,
save_predicted_video=save_predicted_video,
) )
if max_parallel_tasks <= 1: if max_parallel_tasks <= 1:
+3
View File
@@ -30,6 +30,9 @@ 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"
PREDICTION_STR = "prediction"
PREDICTION_PREFIX = PREDICTION_STR + "."
ACTION = "action" ACTION = "action"
ACTION_PREFIX = ACTION + "." ACTION_PREFIX = ACTION + "."
ACTION_TOKENS = ACTION + ".tokens" ACTION_TOKENS = ACTION + ".tokens"
+30 -1
View File
@@ -37,6 +37,8 @@ from .constants import (
OBS_PREFIX, OBS_PREFIX,
OBS_STATE, OBS_STATE,
OBS_STR, OBS_STR,
PREDICTION_PREFIX,
PREDICTION_STR,
REWARD, REWARD,
SUCCESS, SUCCESS,
TRUNCATED, TRUNCATED,
@@ -283,10 +285,11 @@ def _log_foxglove_image(
def log_foxglove_data( def log_foxglove_data(
observation: RobotObservation | None = None, observation: RobotObservation | None = None,
action: RobotAction | None = None, action: RobotAction | None = None,
prediction: dict | None = None,
compress_images: bool = False, compress_images: bool = False,
) -> None: ) -> None:
""" """
Logs observation and action data to a Foxglove WebSocket server for real-time visualization. Logs observation, action and prediction data to a Foxglove WebSocket server for real-time visualization.
Mirrors ``log_rerun_data`` but emits Foxglove messages over the server started by Mirrors ``log_rerun_data`` but emits Foxglove messages over the server started by
:func:`init_foxglove`. Data is mapped as follows: :func:`init_foxglove`. Data is mapped as follows:
@@ -302,6 +305,8 @@ def log_foxglove_data(
Args: Args:
observation: An optional dictionary containing observation data to log. observation: An optional dictionary containing observation data to log.
action: An optional dictionary containing action data to log. action: An optional dictionary containing action data to log.
prediction: An optional dictionary of display-ready model outputs (e.g. a world model's
imagined video), keyed "<datatype>.<name>", logged on ``/prediction/...`` topics.
compress_images: Whether to JPEG-compress images before logging to save bandwidth in exchange compress_images: Whether to JPEG-compress images before logging to save bandwidth in exchange
for CPU and quality. for CPU and quality.
""" """
@@ -334,6 +339,30 @@ def log_foxglove_data(
) )
_log_foxglove_scalars(_foxglove_topic(OBS_STATE), obs_scalars, log_time=now) _log_foxglove_scalars(_foxglove_topic(OBS_STATE), obs_scalars, log_time=now)
if prediction:
# Predicted outputs are keyed "<datatype>.<name>" (e.g. "images.predicted"); route images to
# /prediction/images/<name> and any scalars to an aggregate /prediction/state topic.
pred_scalars: dict[str, float] = {}
for k, v in prediction.items():
if v is None:
continue
key = k[len(PREDICTION_PREFIX) :] if str(k).startswith(PREDICTION_PREFIX) else str(k)
if _is_scalar(v):
pred_scalars[key] = float(v)
elif isinstance(v, np.ndarray):
if v.ndim == 1:
pred_scalars.update(_labeled_scalars(key, v))
else:
name = key[len("images.") :] if key.startswith("images.") else key
_log_foxglove_image(
f"/{PREDICTION_STR}/images/{_foxglove_safe_name(name)}",
name,
v,
compress_images=compress_images,
log_time=now,
)
_log_foxglove_scalars(f"/{PREDICTION_STR}/state", pred_scalars, log_time=now)
if action: if action:
action_scalars: dict[str, float] = {} action_scalars: dict[str, float] = {}
for k, v in action.items(): for k, v in action.items():
+73 -38
View File
@@ -27,7 +27,7 @@ import numpy as np
from lerobot.configs import DEPTH_MILLIMETER_UNIT, infer_depth_unit from lerobot.configs import DEPTH_MILLIMETER_UNIT, infer_depth_unit
from lerobot.types import RobotAction, RobotObservation from lerobot.types import RobotAction, RobotObservation
from .constants import ACTION, ACTION_PREFIX, OBS_PREFIX, OBS_STR from .constants import ACTION, ACTION_PREFIX, OBS_PREFIX, PREDICTION_PREFIX
from .import_utils import require_package from .import_utils import require_package
@@ -37,6 +37,43 @@ def _is_scalar(x):
) )
def _log_scalar_or_image_mapping(rr, data, prefix, scalar_paths, image_paths, compress_images):
"""Log a mapping of scalars/images (observation- or prediction-style) under ``prefix``.
Scalars and 1D arrays go to ``scalar_paths`` (time-series); 2D/3D arrays are treated as images
(CHW->HWC as needed, depth for single-channel) and go to ``image_paths`` (spatial views).
"""
for k, v in data.items():
if v is None:
continue
key = str(k) if str(k).startswith(prefix) else f"{prefix}{k}"
if _is_scalar(v):
rr.log(key, rr.Scalars(float(v)))
scalar_paths.add(key)
elif isinstance(v, np.ndarray):
arr = v
# Convert CHW -> HWC when needed
if arr.ndim == 3 and arr.shape[0] in (1, 3, 4) and arr.shape[-1] not in (1, 3, 4):
arr = np.transpose(arr, (1, 2, 0))
if arr.ndim == 1:
rr.log(key, rr.Scalars(arr.astype(float)))
scalar_paths.add(key)
else:
if arr.shape[-1] == 1:
# At record time, the depth unit is inferred from the frame type.
depth_unit = infer_depth_unit(arr.dtype)
img_entity = rr.DepthImage(
arr,
meter=1000.0 if depth_unit == DEPTH_MILLIMETER_UNIT else 1.0,
colormap=rr.components.Colormap.Viridis,
)
else:
img_entity = rr.Image(arr).compress() if compress_images else rr.Image(arr)
rr.log(key, entity=img_entity, static=True)
image_paths.add(key)
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
) -> None: ) -> None:
@@ -73,10 +110,16 @@ def shutdown_rerun() -> None:
rr.rerun_shutdown() rr.rerun_shutdown()
def _build_blueprint(observation_paths: set[str], action_paths: set[str], image_paths: set[str]): def _build_blueprint(
"""Build a Rerun blueprint laying out camera images, observation and action scalars in separate views. observation_paths: set[str],
action_paths: set[str],
image_paths: set[str],
prediction_paths: set[str],
):
"""Build a Rerun blueprint laying out camera/predicted images and scalar series in separate views.
Camera images, observation and action scalars are arranged in a grid. Images (observation and prediction) each get a spatial view; observation, action, and prediction
scalars each get their own time-series view. All arranged in a grid.
""" """
# Safe + zero-overhead: `log_rerun_data` already ran the `require_package` guard and imported rerun. # Safe + zero-overhead: `log_rerun_data` already ran the `require_package` guard and imported rerun.
@@ -88,22 +131,29 @@ def _build_blueprint(observation_paths: set[str], action_paths: set[str], image_
views.append(rrb.TimeSeriesView(name="observation", contents=sorted(observation_paths))) views.append(rrb.TimeSeriesView(name="observation", contents=sorted(observation_paths)))
if action_paths: if action_paths:
views.append(rrb.TimeSeriesView(name="action", contents=sorted(action_paths))) views.append(rrb.TimeSeriesView(name="action", contents=sorted(action_paths)))
if prediction_paths:
views.append(rrb.TimeSeriesView(name="prediction", contents=sorted(prediction_paths)))
return rrb.Blueprint(rrb.Grid(*views)) return rrb.Blueprint(rrb.Grid(*views))
def _ensure_blueprint(observation_paths: set[str], action_paths: set[str], image_paths: set[str]) -> None: def _ensure_blueprint(
"""Build and send the blueprint once, from the first observation and action data.""" observation_paths: set[str],
action_paths: set[str],
image_paths: set[str],
prediction_paths: set[str],
) -> None:
"""Build and send the blueprint once, from the first observation/action/prediction data."""
if getattr(log_rerun_data, "blueprint", None) is not None: if getattr(log_rerun_data, "blueprint", None) is not None:
return return
if not (observation_paths or action_paths or image_paths): if not (observation_paths or action_paths or image_paths or prediction_paths):
return return
# Safe + zero-overhead: `log_rerun_data` already ran the `require_package` guard and imported rerun. # Safe + zero-overhead: `log_rerun_data` already ran the `require_package` guard and imported rerun.
import rerun as rr import rerun as rr
blueprint = _build_blueprint(observation_paths, action_paths, image_paths) blueprint = _build_blueprint(observation_paths, action_paths, image_paths, prediction_paths)
log_rerun_data.blueprint = blueprint log_rerun_data.blueprint = blueprint
rr.send_blueprint(blueprint) rr.send_blueprint(blueprint)
@@ -111,10 +161,11 @@ def _ensure_blueprint(observation_paths: set[str], action_paths: set[str], image
def log_rerun_data( def log_rerun_data(
observation: RobotObservation | None = None, observation: RobotObservation | None = None,
action: RobotAction | None = None, action: RobotAction | None = None,
prediction: dict | None = None,
compress_images: bool = False, compress_images: bool = False,
) -> None: ) -> None:
""" """
Logs observation and action data to Rerun for real-time visualization. Logs observation, action and prediction data to Rerun for real-time visualization.
This function iterates through the provided observation and action dictionaries and sends their contents This function iterates through the provided observation and action dictionaries and sends their contents
to the Rerun viewer. It handles different data types appropriately: to the Rerun viewer. It handles different data types appropriately:
@@ -133,6 +184,8 @@ def log_rerun_data(
Args: Args:
observation: An optional dictionary containing observation data to log. observation: An optional dictionary containing observation data to log.
action: An optional dictionary containing action data to log. action: An optional dictionary containing action data to log.
prediction: An optional dictionary of display-ready model outputs (e.g. a world model's
imagined video), keyed "<datatype>.<name>", logged on a dedicated "prediction." channel.
compress_images: Whether to compress images before logging to save bandwidth & memory in exchange for cpu and quality. compress_images: Whether to compress images before logging to save bandwidth & memory in exchange for cpu and quality.
""" """
@@ -142,37 +195,19 @@ def log_rerun_data(
observation_paths: set[str] = set() observation_paths: set[str] = set()
action_paths: set[str] = set() action_paths: set[str] = set()
image_paths: set[str] = set() image_paths: set[str] = set()
prediction_paths: set[str] = set()
if observation: if observation:
for k, v in observation.items(): _log_scalar_or_image_mapping(
if v is None: rr, observation, OBS_PREFIX, observation_paths, image_paths, compress_images
continue )
key = k if str(k).startswith(OBS_PREFIX) else f"{OBS_STR}.{k}"
if _is_scalar(v): if prediction:
rr.log(key, rr.Scalars(float(v))) # Predicted images share the spatial-view set (their "prediction." names keep them distinct);
observation_paths.add(key) # predicted scalars get their own time-series view.
elif isinstance(v, np.ndarray): _log_scalar_or_image_mapping(
arr = v rr, prediction, PREDICTION_PREFIX, prediction_paths, image_paths, compress_images
# Convert CHW -> HWC when needed )
if arr.ndim == 3 and arr.shape[0] in (1, 3, 4) and arr.shape[-1] not in (1, 3, 4):
arr = np.transpose(arr, (1, 2, 0))
if arr.ndim == 1:
rr.log(key, rr.Scalars(arr.astype(float)))
observation_paths.add(key)
else:
if arr.shape[-1] == 1:
# At record time, the depth unit is inferred from the frame type.
depth_unit = infer_depth_unit(arr.dtype)
img_entity = rr.DepthImage(
arr,
meter=1000.0 if depth_unit == DEPTH_MILLIMETER_UNIT else 1.0,
colormap=rr.components.Colormap.Viridis,
)
else:
img_entity = rr.Image(arr).compress() if compress_images else rr.Image(arr)
rr.log(key, entity=img_entity, static=True)
image_paths.add(key)
if action: if action:
for k, v in action.items(): for k, v in action.items():
@@ -188,4 +223,4 @@ def log_rerun_data(
rr.log(key, rr.Scalars(v.reshape(-1).astype(float))) rr.log(key, rr.Scalars(v.reshape(-1).astype(float)))
action_paths.add(key) action_paths.add(key)
_ensure_blueprint(observation_paths, action_paths, image_paths) _ensure_blueprint(observation_paths, action_paths, image_paths, prediction_paths)
+8 -3
View File
@@ -56,14 +56,19 @@ def log_visualization_data(
display_mode: str, display_mode: str,
observation: RobotObservation | None = None, observation: RobotObservation | None = None,
action: RobotAction | None = None, action: RobotAction | None = None,
prediction: dict | None = None,
compress_images: bool = False, compress_images: bool = False,
) -> None: ) -> None:
"""Logs observation/action data to the backend selected by ``display_mode``.""" """Logs observation/action/prediction data to the backend selected by ``display_mode``."""
if display_mode == "rerun": if display_mode == "rerun":
log_rerun_data(observation=observation, action=action, compress_images=compress_images) log_rerun_data(
observation=observation, action=action, prediction=prediction, compress_images=compress_images
)
elif display_mode == "foxglove": elif display_mode == "foxglove":
log_foxglove_data(observation=observation, action=action, compress_images=compress_images) log_foxglove_data(
observation=observation, action=action, prediction=prediction, compress_images=compress_images
)
else: else:
raise ValueError(f"Unknown display_mode '{display_mode}'. Expected one of {VISUALIZATION_MODES}.") raise ValueError(f"Unknown display_mode '{display_mode}'. Expected one of {VISUALIZATION_MODES}.")
+60
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import dataclasses import dataclasses
from unittest.mock import MagicMock from unittest.mock import MagicMock
import numpy as np
import pytest import pytest
import torch import torch
@@ -348,3 +349,62 @@ def test_rollout_context_fields():
field_names = {f.name for f in dataclasses.fields(RolloutContext)} field_names = {f.name for f in dataclasses.fields(RolloutContext)}
assert field_names == {"runtime", "hardware", "policy", "processors", "data"} assert field_names == {"runtime", "hardware", "policy", "processors", "data"}
# ---------------------------------------------------------------------------
# Paced prediction buffer
# ---------------------------------------------------------------------------
def _walk_policy_ticks(buffer, key, n_ticks):
"""Read the buffer over ``n_ticks`` policy ticks (load counts as the first)."""
seen = [buffer.current()[key]]
for _ in range(n_ticks - 1):
buffer.advance()
seen.append(buffer.current()[key])
return seen
def test_prediction_buffer_paces_over_chunk():
from lerobot.rollout.inference.prediction_buffer import PacedPredictionBuffer
buffer = PacedPredictionBuffer(ticks_per_chunk=4)
assert buffer.current() is None # nothing until a chunk is loaded
# 2 steps spread over a 4-policy-tick chunk: first half -> step 0, second half -> step 1.
buffer.load({"x": ["a", "b"]})
assert _walk_policy_ticks(buffer, "x", 4) == ["a", "a", "b", "b"]
buffer.reset()
assert buffer.current() is None
# Unknown span -> one step per policy tick, clamped at the last step.
unpaced = PacedPredictionBuffer(ticks_per_chunk=None)
unpaced.load({"x": ["a", "b"]})
assert _walk_policy_ticks(unpaced, "x", 3) == ["a", "b", "b"]
def test_prediction_buffer_read_is_independent_of_display_rate():
from lerobot.rollout.inference.prediction_buffer import PacedPredictionBuffer
# Pacing fix: the playhead advances per policy tick; the display may read it many times per tick
# (e.g. interpolation multiplier=3), each read returning the same frame with no side effects — so
# playback never races to the end and freezes.
buffer = PacedPredictionBuffer(ticks_per_chunk=2)
buffer.load({"x": ["a", "b"]})
assert [buffer.current()["x"] for _ in range(3)] == ["a", "a", "a"] # policy tick 0
buffer.advance()
assert [buffer.current()["x"] for _ in range(3)] == ["b", "b", "b"] # policy tick 1
def test_prediction_buffer_is_modality_agnostic():
from lerobot.rollout.inference.prediction_buffer import PacedPredictionBuffer
# Non-image temporal predictions pass through; torch tensors are detached to numpy on the way out.
buffer = PacedPredictionBuffer(ticks_per_chunk=2)
states = torch.arange(6).reshape(2, 3) # [T=2, D=3]
buffer.load({"observation.state": states})
out = buffer.current()["observation.state"]
assert isinstance(out, np.ndarray) and np.array_equal(out, states[0].numpy())
buffer.advance()
assert np.array_equal(buffer.current()["observation.state"], states[1].numpy())