# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
from __future__ import annotations
import torch
from tensordict import NonTensorStack, TensorDict, TensorDictBase
from torchrl.data.tensor_specs import (
Bounded,
Categorical,
Composite,
NonTensor,
Unbounded,
)
from torchrl.envs.common import EnvBase
__all__ = ["ToyVLAEnv"]
_DEFAULT_INSTRUCTION = "push the T-shaped block onto the target"
[docs]
class ToyVLAEnv(EnvBase):
"""A minimal synthetic environment speaking the canonical VLA TensorDict schema.
Observations follow the canonical VLA layout (see :ref:`the VLA reference
page <ref_vla>`): a random camera ``image`` and a proprioceptive ``state``
under ``observation``, plus a constant ``language_instruction`` at the
root. The state's first ``action_dim`` entries echo the action executed at
the previous step, which makes execution machinery directly observable:
the cadence of a chunk-executing policy (e.g.
:class:`~torchrl.modules.tensordict_module.MultiStepActorWrapper`) can be
read off ``("next", "observation", "state")``.
Two modes are available:
- **Echo mode** (default, ``success_steps=None``): the reward is the
negative action norm (an effort penalty) and episodes never terminate
on their own. This is a smoke-test mode for VLA plumbing -- tutorials,
tests, pipeline checks without simulator dependencies -- not a
learnable task.
- **Tracking mode** (``success_steps=k``): a per-episode target action is
sampled at reset and exposed in the state at
``state[..., action_dim:2 * action_dim]`` (requires
``state_dim >= 2 * action_dim``). A boolean ``success`` entry turns
``True`` -- and the episode terminates -- once the executed action stays
within ``success_tol`` (infinity-norm) of the target for ``k``
consecutive steps. The reward is the negative tracking error
``-||action - target||``. An oracle that reads the target back from the
state succeeds with certainty, while a uniform random policy almost
never does (per-step hit probability ``success_tol ** action_dim``),
which makes "success rate climbs" a meaningful learning signal for
sparse-reward RL recipes (pair with
:class:`~torchrl.envs.transforms.SuccessReward` for a binary
success-only reward).
Args:
action_dim (int, optional): size of the continuous action, bounded in
``[-1, 1]``. Defaults to ``4``.
state_dim (int, optional): size of the proprioceptive state; must be
at least ``action_dim`` (``2 * action_dim`` in tracking mode).
Defaults to ``6``.
image_shape (tuple of int, optional): ``(C, H, W)`` shape of the
``uint8`` camera image. Defaults to ``(3, 16, 16)``.
instruction (str, optional): the constant language instruction.
Defaults to ``"push the T-shaped block onto the target"``.
Keyword Args:
from_pixels (bool, optional): if ``True``, add a root ``pixels`` entry
(a ``(render_size, render_size, 3)`` ``uint8`` HWC frame) rendering
the scene: the executed action is drawn as a red marker and, in
tracking mode, the target as a green one, both mapped from the
``[-1, 1]`` action plane. This is the canonical torchrl
pixels-rendering hook (matching :class:`~torchrl.envs.GymEnv` and
others) and feeds :class:`~torchrl.record.VideoRecorder` directly.
Unlike the always-present ``("observation", "image")`` (random
noise, a stand-in for a camera feed), ``pixels`` visualizes the
task, so an eval video shows the policy learning to track. Defaults
to ``False``.
render_size (int, optional): side length of the square ``pixels``
frame. Only used when ``from_pixels=True``. Defaults to ``64``.
success_steps (int, optional): number of consecutive in-tolerance
steps required for success. ``None`` (default) selects the echo
mode (no ``success`` entry, never done).
success_tol (float, optional): per-dimension tolerance around the
target action. Defaults to ``0.25``. Targets are sampled
uniformly in ``[-0.5, 0.5]`` so the tolerance ball always fits
inside the action bounds.
group_repeats (int, optional): grouped-rollout mode (tracking mode
only, single environment only): the same target is replayed for
``group_repeats`` consecutive episodes before a new one is
sampled, and an integer ``group_id`` observation entry identifies
the group. This is the init-state control GRPO-style group
advantages require (n rollouts per initial state, e.g. grouped by
:class:`~torchrl.objectives.llm.MCAdvantage`). Defaults to
``None`` (a fresh target every episode, no ``group_id`` entry).
group_id_offset (int, optional): offset added to grouped rollout ids.
This lets several single ToyVLAEnv instances collect grouped
rollouts in parallel without mixing unrelated initial states in the
downstream group-advantage transform. Defaults to ``0``.
batch_size (torch.Size, optional): number of vectorized copies.
Defaults to ``torch.Size([])`` (a single environment).
device (torch.device, optional): device of the specs.
seed (int, optional): seed for the random images and targets.
Examples:
>>> import torch
>>> from tensordict.nn import TensorDictModule
>>> from torchrl.envs import TransformedEnv
>>> from torchrl.envs.custom import ToyVLAEnv
>>> from torchrl.envs.transforms import InitTracker
>>> from torchrl.modules import MultiStepActorWrapper
>>> env = ToyVLAEnv(batch_size=[2])
>>> td = env.reset()
>>> td["observation", "image"].shape, td["observation", "image"].dtype
(torch.Size([2, 3, 16, 16]), torch.uint8)
>>> td["language_instruction"][0]
'push the T-shaped block onto the target'
>>> # the state echoes the executed action
>>> td["action"] = 0.5 * torch.ones(2, 4)
>>> env.step(td)["next", "observation", "state"][:, :4].unique()
tensor([0.5000])
>>> # pair it with a chunk-executing policy: the actor predicts 3
>>> # actions per call and is only re-queried when the cache is empty
>>> chunk_actor = TensorDictModule(
... lambda state: state[..., :4].unsqueeze(-2).expand(2, 3, 4) + 0.1,
... in_keys=[("observation", "state")],
... out_keys=["action"],
... )
>>> policy = MultiStepActorWrapper(chunk_actor, n_steps=3)
>>> env = TransformedEnv(ToyVLAEnv(batch_size=[2]), InitTracker())
>>> env.rollout(4, policy)["action"].shape
torch.Size([2, 4, 4])
>>> # tracking mode: an oracle reading the target off the state succeeds
>>> env = ToyVLAEnv(action_dim=2, state_dim=4, success_steps=2, seed=0)
>>> td = env.reset()
>>> for _ in range(2):
... td["action"] = td["observation", "state"][..., 2:4]
... td = env.step(td)["next"]
>>> bool(td["success"]), bool(td["terminated"])
(True, True)
"""
def __init__(
self,
action_dim: int = 4,
state_dim: int = 6,
image_shape: tuple[int, int, int] = (3, 16, 16),
instruction: str = _DEFAULT_INSTRUCTION,
*,
from_pixels: bool = False,
render_size: int = 64,
success_steps: int | None = None,
success_tol: float = 0.25,
group_repeats: int | None = None,
group_id_offset: int = 0,
batch_size: torch.Size | None = None,
device: torch.device | None = None,
seed: int | None = None,
) -> None:
if success_steps is None and state_dim < action_dim:
raise ValueError(
f"state_dim ({state_dim}) must be at least action_dim "
f"({action_dim}): the state echoes the executed action."
)
if success_steps is not None:
if success_steps < 1:
raise ValueError(f"success_steps must be >= 1, got {success_steps}.")
if state_dim < 2 * action_dim:
raise ValueError(
f"state_dim ({state_dim}) must be at least 2 * action_dim "
f"({2 * action_dim}) in tracking mode: the state holds the "
"executed action followed by the target action."
)
if not 0.0 < success_tol <= 0.5:
raise ValueError(
"success_tol must be in (0, 0.5] so the tolerance ball "
f"around a target in [-0.5, 0.5] stays reachable, got {success_tol}."
)
if group_repeats is not None:
if success_steps is None:
raise ValueError(
"group_repeats requires the tracking mode: set success_steps."
)
if group_repeats < 1:
raise ValueError(f"group_repeats must be >= 1, got {group_repeats}.")
super().__init__(
batch_size=torch.Size(batch_size) if batch_size is not None else None,
device=device,
)
if render_size < 1:
raise ValueError(f"render_size must be >= 1, got {render_size}.")
self.action_dim = int(action_dim)
self.state_dim = int(state_dim)
self.image_shape = tuple(int(dim) for dim in image_shape)
self.instruction = str(instruction)
self.from_pixels = bool(from_pixels)
self.render_size = int(render_size)
self.success_steps = int(success_steps) if success_steps is not None else None
self.success_tol = float(success_tol)
self.group_repeats = int(group_repeats) if group_repeats is not None else None
self.group_id_offset = int(group_id_offset)
if self.group_repeats is not None and self.batch_size.numel() > 1:
raise ValueError(
"group_repeats only supports a single environment "
f"(batch_size () or (1,)), got batch_size={tuple(self.batch_size)}. "
"General init-state control belongs to the environment adapters."
)
batch = self.batch_size
observation = Composite(
image=Unbounded(
shape=(*batch, *self.image_shape),
dtype=torch.uint8,
device=self.device,
),
state=Unbounded(shape=(*batch, self.state_dim), device=self.device),
shape=batch,
)
self.observation_spec = Composite(
observation=observation,
language_instruction=NonTensor(
shape=batch, example_data=self.instruction, device=self.device
),
shape=batch,
)
if self.success_steps is not None:
self.observation_spec["success"] = Categorical(
2, dtype=torch.bool, shape=(*batch, 1), device=self.device
)
if self.group_repeats is not None:
self.observation_spec["group_id"] = Unbounded(
shape=(*batch, 1), dtype=torch.int64, device=self.device
)
if self.from_pixels:
self.observation_spec["pixels"] = Unbounded(
shape=(*batch, self.render_size, self.render_size, 3),
dtype=torch.uint8,
device=self.device,
)
self.action_spec = Bounded(
-1.0, 1.0, shape=(*batch, self.action_dim), device=self.device
)
self.reward_spec = Unbounded(shape=(*batch, 1), device=self.device)
self.done_spec = Categorical(
2, dtype=torch.bool, shape=(*batch, 1), device=self.device
)
# Tracking-mode episode state: per-env target and in-tolerance streak.
# Registered as (non-persistent) buffers so env.to(device) moves them.
self.register_buffer(
"_target",
torch.zeros(*batch, self.action_dim, device=self.device),
persistent=False,
)
self.register_buffer(
"_streak",
torch.zeros(*batch, 1, dtype=torch.int64, device=self.device),
persistent=False,
)
self.register_buffer(
"_group_id",
torch.zeros(*batch, 1, dtype=torch.int64, device=self.device),
persistent=False,
)
self._episode_count = 0
self._rng = torch.Generator()
self._set_seed(seed)
def _instruction_stack(self):
if not self.batch_size:
return self.instruction
stack = [self.instruction] * self.batch_size.numel()
return NonTensorStack(*stack).reshape(self.batch_size)
def _sample_target(self) -> torch.Tensor:
target = (
torch.rand(
*self.batch_size,
self.action_dim,
generator=self._rng,
)
- 0.5
)
return target.to(self.device)
def _obs(self, state: torch.Tensor) -> TensorDict:
image = torch.randint(
0,
256,
(*self.batch_size, *self.image_shape),
dtype=torch.uint8,
generator=self._rng,
).to(self.device)
out = TensorDict(
{
"observation": {"image": image, "state": state},
"language_instruction": self._instruction_stack(),
},
batch_size=self.batch_size,
device=self.device,
)
if self.from_pixels:
out["pixels"] = self._render(state)
return out
def _render(self, state: torch.Tensor) -> torch.Tensor:
"""Render the scene to a ``(*batch, S, S, 3)`` uint8 HWC frame.
The executed action (``state[..., :action_dim]``) is a red marker and,
in tracking mode, the target (``state[..., action_dim:2*action_dim]``)
a green one, both mapped from the ``[-1, 1]`` plane onto the canvas.
Off the hot path: only reached when ``from_pixels=True`` (recording).
"""
size = self.render_size
canvas = torch.full(
(*self.batch_size, size, size, 3),
30,
dtype=torch.uint8,
device=self.device,
)
def to_px(coord: torch.Tensor) -> torch.Tensor:
return (((coord.clamp(-1.0, 1.0) + 1.0) * 0.5) * (size - 1)).round().long()
action = state[..., : self.action_dim]
ax = to_px(action[..., 0])
ay = to_px(action[..., 1]) if self.action_dim > 1 else torch.zeros_like(ax)
self._draw_marker(canvas, ay, ax, (220, 60, 60))
if self.success_steps is not None:
target = state[..., self.action_dim : 2 * self.action_dim]
tx = to_px(target[..., 0])
ty = to_px(target[..., 1]) if self.action_dim > 1 else torch.zeros_like(tx)
self._draw_marker(canvas, ty, tx, (60, 200, 60))
return canvas
def _draw_marker(self, canvas, y, x, color, radius: int = 2) -> None:
# square marker; loops over the (tiny, recording-only) batch so the
# per-env top-left corner can index a contiguous block
size = canvas.shape[-2]
flat = canvas.reshape(-1, size, size, 3)
ys = y.reshape(-1)
xs = x.reshape(-1)
col = torch.tensor(color, dtype=torch.uint8, device=canvas.device)
for i in range(flat.shape[0]):
yi = int(ys[i])
xi = int(xs[i])
flat[
i,
max(0, yi - radius) : yi + radius + 1,
max(0, xi - radius) : xi + radius + 1,
:,
] = col
def _make_state(self, action: torch.Tensor | None) -> torch.Tensor:
state = torch.zeros(*self.batch_size, self.state_dim, device=self.device)
if action is not None:
state[..., : self.action_dim] = action
if self.success_steps is not None:
state[..., self.action_dim : 2 * self.action_dim] = self._target
return state
def _reset(self, tensordict: TensorDictBase | None = None, **kwargs) -> TensorDict:
if self.success_steps is not None:
reset = None
if tensordict is not None:
reset = tensordict.get("_reset")
if reset is None:
reset = torch.ones(
*self.batch_size, 1, dtype=torch.bool, device=self.device
)
if self.group_repeats is not None:
# grouped rollouts (single env): replay the same target for
# group_repeats consecutive episodes and stamp the group id
if self._episode_count % self.group_repeats == 0:
self._target = self._sample_target()
self._group_id = torch.full_like(
self._group_id,
self.group_id_offset + self._episode_count // self.group_repeats,
)
self._episode_count += 1
else:
self._target = torch.where(reset, self._sample_target(), self._target)
self._streak = torch.where(
reset, torch.zeros_like(self._streak), self._streak
)
out = self._obs(self._make_state(None))
if self.success_steps is not None:
out["success"] = torch.zeros(
*self.batch_size, 1, dtype=torch.bool, device=self.device
)
if self.group_repeats is not None:
out["group_id"] = self._group_id.clone()
out.update(self.full_done_spec.zero())
return out
def _step(self, tensordict: TensorDictBase) -> TensorDict:
action = tensordict.get("action")
out = self._obs(self._make_state(action))
if self.success_steps is None:
out["reward"] = -action.norm(dim=-1, keepdim=True)
out.update(self.full_done_spec.zero())
return out
error = action - self._target
reward = -error.norm(dim=-1, keepdim=True)
in_tol = (error.abs() <= self.success_tol).all(-1, keepdim=True)
streak = torch.where(in_tol, self._streak + 1, 0)
step_mask = tensordict.get("_step", None)
if step_mask is not None:
# partial-step contract (see EnvBase.step): batch-locked envs are
# trusted to handle the "_step" mask themselves. Masked-out envs
# (e.g. done inside a MultiAction chunk) keep their streak frozen
# - so success/done persist - and emit a zero reward.
step_mask = step_mask.view(self._streak.shape)
streak = torch.where(step_mask, streak, self._streak)
reward = torch.where(step_mask, reward, torch.zeros_like(reward))
self._streak = streak
success = self._streak >= self.success_steps
out["reward"] = reward
out["success"] = success
if self.group_repeats is not None:
out["group_id"] = self._group_id.clone()
out["terminated"] = success
out["done"] = success.clone()
return out
def _set_seed(self, seed: int | None) -> None:
if seed is not None:
self._rng.manual_seed(seed)