mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-22 01:11:57 +00:00
18eee1b477
* refactor(vla-jepa): removing gpu roundtrip for the preprocessing part * major refactor of the forward pass and model input conversion * linting * adressing suggestions from reviews * removing redundant state dtype conversion * avoiding recreating the same tensor each foward pass * api simplification of `_encode_qwen` * avoiding useless video assembly during inference * guard against video=None for the wm loss
601 lines
23 KiB
Python
601 lines
23 KiB
Python
#!/usr/bin/env python
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from copy import deepcopy
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import torch
|
|
from torch import Tensor
|
|
|
|
pytest.importorskip("transformers")
|
|
pytest.importorskip("diffusers")
|
|
|
|
pytestmark = pytest.mark.filterwarnings(
|
|
"ignore:In CPU autocast, but the target dtype is not supported:UserWarning"
|
|
)
|
|
|
|
from conftest import ( # noqa: E402
|
|
ACTION_DIM,
|
|
ACTION_HORIZON,
|
|
BATCH_SIZE,
|
|
EXPECTED_ACTION_CHUNK_SHAPE,
|
|
EXPECTED_SELECT_ACTION_SHAPE,
|
|
IMAGE_SIZE,
|
|
N_ACTION_STEPS,
|
|
QWEN_HIDDEN_SIZE,
|
|
STATE_DIM,
|
|
make_config,
|
|
make_inference_batch,
|
|
make_train_batch,
|
|
set_seed_all,
|
|
)
|
|
|
|
from lerobot.policies.vla_jepa.configuration_vla_jepa import VLAJEPAConfig # noqa: E402
|
|
from lerobot.policies.vla_jepa.modeling_vla_jepa import VLAJEPAPolicy # noqa: E402
|
|
from lerobot.utils.constants import ACTION # noqa: E402
|
|
|
|
PRETRAINED_REPO_ID = "ginwind/VLA-JEPA"
|
|
PRETRAINED_SUBFOLDER = "LIBERO"
|
|
|
|
# extended hub tests load the full converted safetensors checkpoints (~5 GB) and are
|
|
# skipped by default. Set VLA_JEPA_EXTENDED=1 to opt in.
|
|
_VLA_JEPA_EXTENDED = os.environ.get("VLA_JEPA_EXTENDED", "0") != "0"
|
|
extended_test = pytest.mark.skipif(not _VLA_JEPA_EXTENDED, reason="Set VLA_JEPA_EXTENDED=1 to run hub tests")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core training / inference tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_training_forward_pass(patch_vla_jepa_external_models: None) -> None:
|
|
set_seed_all(42)
|
|
policy = VLAJEPAPolicy(make_config())
|
|
policy.train()
|
|
|
|
batch = make_train_batch()
|
|
batch_before = deepcopy(batch)
|
|
|
|
loss, logs = policy.forward(batch)
|
|
|
|
assert loss.shape == ()
|
|
assert torch.isfinite(loss)
|
|
assert set(logs) == {"action_loss", "wm_loss", "loss"}
|
|
assert logs["action_loss"] > 0
|
|
assert logs["wm_loss"] >= 0
|
|
|
|
loss.backward()
|
|
assert any(p.grad is not None for p in policy.model.action_model.parameters() if p.requires_grad)
|
|
# Batch must not be mutated.
|
|
assert set(batch) == set(batch_before)
|
|
for key, value in batch.items():
|
|
if isinstance(value, Tensor):
|
|
assert torch.equal(value, batch_before[key])
|
|
else:
|
|
assert value == batch_before[key]
|
|
|
|
|
|
@pytest.mark.parametrize("batch_size", [1, 2, 4])
|
|
def test_training_forward_various_batch_sizes(patch_vla_jepa_external_models: None, batch_size: int) -> None:
|
|
set_seed_all(42)
|
|
policy = VLAJEPAPolicy(make_config())
|
|
policy.train()
|
|
loss, logs = policy.forward(make_train_batch(batch_size=batch_size))
|
|
assert torch.isfinite(loss) and loss > 0
|
|
assert set(logs) == {"action_loss", "wm_loss", "loss"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"action_dim,state_dim,action_horizon",
|
|
[
|
|
(3, 4, 4),
|
|
(7, 0, 16),
|
|
(6, 8, 8),
|
|
],
|
|
)
|
|
def test_training_forward_various_dims(
|
|
patch_vla_jepa_external_models: None,
|
|
action_dim: int,
|
|
state_dim: int,
|
|
action_horizon: int,
|
|
) -> None:
|
|
set_seed_all(42)
|
|
config = make_config(action_dim=action_dim, state_dim=state_dim, action_horizon=action_horizon)
|
|
policy = VLAJEPAPolicy(config)
|
|
policy.train()
|
|
batch = make_train_batch(action_dim=action_dim, state_dim=state_dim, action_horizon=action_horizon)
|
|
loss, _ = policy.forward(batch)
|
|
assert torch.isfinite(loss) and loss > 0
|
|
|
|
|
|
@torch.no_grad()
|
|
def test_action_generation_shape(patch_vla_jepa_external_models: None) -> None:
|
|
set_seed_all(42)
|
|
policy = VLAJEPAPolicy(make_config())
|
|
policy.eval()
|
|
batch = make_inference_batch()
|
|
|
|
chunk = policy.predict_action_chunk(batch)
|
|
assert tuple(chunk.shape) == EXPECTED_ACTION_CHUNK_SHAPE
|
|
assert chunk.device.type == "cpu"
|
|
assert torch.isfinite(chunk).all()
|
|
|
|
a1 = policy.select_action(batch)
|
|
a2 = policy.select_action(batch)
|
|
assert tuple(a1.shape) == EXPECTED_SELECT_ACTION_SHAPE
|
|
assert tuple(a2.shape) == EXPECTED_SELECT_ACTION_SHAPE
|
|
assert torch.isfinite(a1).all() and torch.isfinite(a2).all()
|
|
|
|
|
|
@torch.no_grad()
|
|
@pytest.mark.parametrize("action_dim,state_dim", [(3, 4), (7, 0), (6, 8)])
|
|
def test_action_generation_various_dims(
|
|
patch_vla_jepa_external_models: None, action_dim: int, state_dim: int
|
|
) -> None:
|
|
set_seed_all(42)
|
|
config = make_config(action_dim=action_dim, state_dim=state_dim)
|
|
policy = VLAJEPAPolicy(config)
|
|
policy.eval()
|
|
batch = make_inference_batch(state_dim=state_dim)
|
|
chunk = policy.predict_action_chunk(batch)
|
|
assert chunk.shape[-1] == action_dim
|
|
assert torch.isfinite(chunk).all()
|
|
|
|
|
|
@torch.no_grad()
|
|
def test_inference_reproducibility(patch_vla_jepa_external_models: None) -> None:
|
|
set_seed_all(42)
|
|
policy = VLAJEPAPolicy(make_config())
|
|
policy.eval()
|
|
batch = make_inference_batch()
|
|
|
|
set_seed_all(123)
|
|
actions_1 = policy.predict_action_chunk(batch)
|
|
set_seed_all(123)
|
|
actions_2 = policy.predict_action_chunk(batch)
|
|
|
|
assert tuple(actions_1.shape) == EXPECTED_ACTION_CHUNK_SHAPE
|
|
assert torch.allclose(actions_1, actions_2, atol=1e-6)
|
|
|
|
|
|
@torch.no_grad()
|
|
def test_predict_action_chunk_always_finite(patch_vla_jepa_external_models: None) -> None:
|
|
policy = VLAJEPAPolicy(make_config())
|
|
policy.eval()
|
|
for seed in [0, 42, 123]:
|
|
set_seed_all(seed)
|
|
chunk = policy.predict_action_chunk(make_inference_batch())
|
|
assert torch.isfinite(chunk).all(), f"non-finite actions with seed={seed}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action queue behaviour
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@torch.no_grad()
|
|
def test_select_action_queue_drains_before_refill(patch_vla_jepa_external_models: None) -> None:
|
|
set_seed_all(42)
|
|
policy = VLAJEPAPolicy(make_config())
|
|
policy.eval()
|
|
batch = make_inference_batch()
|
|
|
|
# First call fills the queue (n_action_steps items) and pops one.
|
|
a1 = policy.select_action(batch)
|
|
assert len(policy._queues[ACTION]) == N_ACTION_STEPS - 1
|
|
|
|
# Second call pops from the existing queue without calling predict_action_chunk.
|
|
a2 = policy.select_action(batch)
|
|
assert tuple(a1.shape) == EXPECTED_SELECT_ACTION_SHAPE
|
|
assert tuple(a2.shape) == EXPECTED_SELECT_ACTION_SHAPE
|
|
|
|
|
|
@torch.no_grad()
|
|
def test_reset_clears_action_queue(patch_vla_jepa_external_models: None) -> None:
|
|
set_seed_all(42)
|
|
policy = VLAJEPAPolicy(make_config())
|
|
policy.eval()
|
|
policy.select_action(make_inference_batch())
|
|
assert len(policy._queues[ACTION]) > 0
|
|
|
|
policy.reset()
|
|
assert len(policy._queues[ACTION]) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Format conversion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_prepare_model_inputs_training_format(patch_vla_jepa_external_models: None) -> None:
|
|
policy = VLAJEPAPolicy(make_config())
|
|
inputs = policy._prepare_model_inputs(make_train_batch())
|
|
|
|
assert set(inputs) >= {"images", "instructions", "videos", "actions", "state"}
|
|
# images: per-sample, per-view [C, H, W] float tensors (kept as a list for Qwen messages)
|
|
assert len(inputs["images"]) == BATCH_SIZE and len(inputs["images"][0]) == 1
|
|
img = inputs["images"][0][0]
|
|
assert isinstance(img, torch.Tensor) and img.dtype == torch.float32 and img.ndim == 3
|
|
assert len(inputs["instructions"]) == BATCH_SIZE
|
|
# videos: batched [B, V, T, C, H, W] float
|
|
assert inputs["videos"].ndim == 6 and inputs["videos"].shape[0] == BATCH_SIZE
|
|
assert inputs["videos"].dtype == torch.float32
|
|
assert inputs["actions"].shape == (BATCH_SIZE, ACTION_HORIZON, ACTION_DIM)
|
|
assert inputs["state"].shape == (BATCH_SIZE, 1, STATE_DIM)
|
|
|
|
|
|
def test_prepare_model_inputs_inference_omits_action(patch_vla_jepa_external_models: None) -> None:
|
|
policy = VLAJEPAPolicy(make_config())
|
|
inputs = policy._prepare_model_inputs(make_inference_batch())
|
|
assert "actions" not in inputs and "action_is_pad" not in inputs
|
|
assert {"images", "instructions", "state"} <= set(inputs)
|
|
|
|
|
|
def test_prepare_model_inputs_missing_task_uses_default(patch_vla_jepa_external_models: None) -> None:
|
|
policy = VLAJEPAPolicy(make_config())
|
|
batch = make_inference_batch()
|
|
del batch["task"]
|
|
instructions = policy._prepare_model_inputs(batch)["instructions"]
|
|
assert all(isinstance(s, str) and len(s) > 0 for s in instructions)
|
|
|
|
|
|
def test_prepare_model_inputs_string_task_broadcast(patch_vla_jepa_external_models: None) -> None:
|
|
policy = VLAJEPAPolicy(make_config())
|
|
batch = make_inference_batch()
|
|
batch["task"] = "open the drawer"
|
|
assert policy._prepare_model_inputs(batch)["instructions"] == ["open the drawer"] * BATCH_SIZE
|
|
|
|
|
|
def test_prepare_model_inputs_no_state_omitted(patch_vla_jepa_external_models: None) -> None:
|
|
from lerobot.utils.constants import OBS_STATE
|
|
|
|
policy = VLAJEPAPolicy(make_config())
|
|
batch = make_inference_batch()
|
|
del batch[OBS_STATE]
|
|
assert "state" not in policy._prepare_model_inputs(batch)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pretrained checkpoint
|
|
# Hub tests (opt-in: VLA_JEPA_EXTENDED=1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_hub_train_batch(policy: VLAJEPAPolicy, batch_size: int = 1) -> dict:
|
|
"""Build a training batch whose keys/shapes match a hub-loaded policy config."""
|
|
cfg = policy.config
|
|
batch: dict = {"task": ["pick up the cube"] * batch_size}
|
|
for key, feat in cfg.image_features.items():
|
|
h, w = feat.shape[-2], feat.shape[-1]
|
|
batch[key] = torch.rand(batch_size, cfg.num_video_frames, 3, h, w)
|
|
if cfg.robot_state_feature is not None:
|
|
batch["observation.state"] = torch.randn(batch_size, 1, cfg.robot_state_feature.shape[0])
|
|
batch[ACTION] = torch.randn(batch_size, cfg.chunk_size, cfg.action_dim)
|
|
return batch
|
|
|
|
|
|
def _make_hub_inference_batch(policy: VLAJEPAPolicy, batch_size: int = 1) -> dict:
|
|
"""Build an inference batch whose keys/shapes match a hub-loaded policy config."""
|
|
cfg = policy.config
|
|
batch: dict = {"task": ["pick up the cube"] * batch_size}
|
|
for key, feat in cfg.image_features.items():
|
|
h, w = feat.shape[-2], feat.shape[-1]
|
|
batch[key] = torch.rand(batch_size, 3, h, w)
|
|
if cfg.robot_state_feature is not None:
|
|
batch["observation.state"] = torch.randn(batch_size, cfg.robot_state_feature.shape[0])
|
|
return batch
|
|
|
|
|
|
_CP_ROOT = "lerobot"
|
|
|
|
# Each tuple: (repo_id, enable_world_model)
|
|
_HUB_VARIANTS = [
|
|
(f"{_CP_ROOT}/VLA-JEPA-LIBERO", True),
|
|
(f"{_CP_ROOT}/VLA-JEPA-Pretrain", True),
|
|
(f"{_CP_ROOT}/VLA-JEPA-SimplerEnv", False),
|
|
]
|
|
|
|
|
|
@extended_test
|
|
@pytest.mark.parametrize("repo_id,enable_world_model", _HUB_VARIANTS)
|
|
def test_hub_checkpoint_loads(repo_id: str, enable_world_model: bool) -> None:
|
|
"""Policy loads from the converted safetensors checkpoint on the Hub."""
|
|
policy = VLAJEPAPolicy.from_pretrained(repo_id)
|
|
assert policy.config.enable_world_model == enable_world_model
|
|
assert sum(p.numel() for p in policy.parameters()) > 0
|
|
|
|
|
|
@extended_test
|
|
@pytest.mark.parametrize("repo_id,enable_world_model", _HUB_VARIANTS)
|
|
def test_hub_checkpoint_forward_pass(repo_id: str, enable_world_model: bool) -> None:
|
|
"""Policy loaded from hub produces finite losses with a correctly-shaped batch."""
|
|
policy = VLAJEPAPolicy.from_pretrained(repo_id)
|
|
policy.train()
|
|
|
|
batch = _make_hub_train_batch(policy)
|
|
loss, logs = policy.forward(batch)
|
|
assert torch.isfinite(loss)
|
|
assert "action_loss" in logs
|
|
if enable_world_model:
|
|
assert "wm_loss" in logs
|
|
|
|
|
|
@extended_test
|
|
def test_hub_freeze_qwen_disables_world_model() -> None:
|
|
"""freeze_qwen=True (via cli_overrides) freezes qwen and disables the world model."""
|
|
policy = VLAJEPAPolicy.from_pretrained(f"{_CP_ROOT}/VLA-JEPA-LIBERO", cli_overrides=["freeze_qwen=true"])
|
|
assert not policy.config.enable_world_model
|
|
assert policy.model.video_predictor is None
|
|
qwen_params = list(policy.model.qwen.parameters())
|
|
assert all(not p.requires_grad for p in qwen_params)
|
|
assert any(p.requires_grad for p in policy.model.action_model.parameters())
|
|
|
|
|
|
@extended_test
|
|
def test_hub_disable_world_model_loads_simpler_env() -> None:
|
|
"""SimplerEnv checkpoint (world model disabled) loads cleanly and runs inference."""
|
|
policy = VLAJEPAPolicy.from_pretrained(f"{_CP_ROOT}/VLA-JEPA-SimplerEnv")
|
|
assert not policy.config.enable_world_model
|
|
assert policy.model.video_predictor is None
|
|
assert policy.model.video_encoder is None
|
|
|
|
|
|
@extended_test
|
|
def test_hub_libero_inference_shape() -> None:
|
|
"""select_action returns the expected shape using the LIBERO hub checkpoint."""
|
|
policy = VLAJEPAPolicy.from_pretrained(f"{_CP_ROOT}/VLA-JEPA-LIBERO")
|
|
policy.eval()
|
|
batch = _make_hub_inference_batch(policy)
|
|
action = policy.select_action(batch)
|
|
assert action.shape[-1] == policy.config.action_dim
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Postprocessor unnormalization tests
|
|
#
|
|
# These tests verify that the postprocessor pipeline (clip → unnorm → binarize)
|
|
# correctly applies MIN_MAX unnormalization after predict_action_chunk.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_dataset_stats(action_dim: int = ACTION_DIM) -> dict:
|
|
"""Returns sample dataset_stats with a simple [i, i+10] range per action dim."""
|
|
from lerobot.utils.constants import ACTION
|
|
|
|
return {
|
|
ACTION: {
|
|
"min": torch.tensor([float(i) for i in range(action_dim)], dtype=torch.float32),
|
|
"max": torch.tensor([float(i) + 10.0 for i in range(action_dim)], dtype=torch.float32),
|
|
}
|
|
}
|
|
|
|
|
|
@torch.no_grad()
|
|
def test_postprocessor_unnormalizes_actions(patch_vla_jepa_external_models: None) -> None:
|
|
"""UnnormalizerProcessorStep with MIN_MAX produces the correct inverse of MIN_MAX normalization."""
|
|
from lerobot.configs.types import FeatureType, NormalizationMode, PolicyFeature
|
|
from lerobot.processor import UnnormalizerProcessorStep
|
|
from lerobot.processor.converters import policy_action_to_transition, transition_to_policy_action
|
|
from lerobot.utils.constants import ACTION
|
|
|
|
dataset_stats = _make_dataset_stats()
|
|
|
|
rng = np.random.default_rng(7)
|
|
actions_np = rng.uniform(-1.0, 1.0, (2, ACTION_HORIZON, ACTION_DIM)).astype(np.float32)
|
|
|
|
a_min = dataset_stats[ACTION]["min"].numpy()
|
|
a_max = dataset_stats[ACTION]["max"].numpy()
|
|
expected = (actions_np + 1.0) / 2.0 * (a_max - a_min) + a_min
|
|
|
|
features = {ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(ACTION_DIM,))}
|
|
unnorm_step = UnnormalizerProcessorStep(
|
|
features=features,
|
|
norm_map={FeatureType.ACTION: NormalizationMode.MIN_MAX},
|
|
stats=dataset_stats,
|
|
)
|
|
|
|
actions_tensor = torch.from_numpy(actions_np)
|
|
transition = policy_action_to_transition(actions_tensor)
|
|
result = transition_to_policy_action(unnorm_step(transition)).numpy()
|
|
|
|
np.testing.assert_allclose(result, expected, rtol=1e-5, atol=1e-6)
|
|
|
|
|
|
@torch.no_grad()
|
|
def test_postprocessor_clip_clamps_before_unnorm(patch_vla_jepa_external_models: None) -> None:
|
|
"""ClipActionsProcessorStep clamps to [-1, 1] before unnormalization."""
|
|
from lerobot.configs.types import FeatureType, NormalizationMode, PolicyFeature
|
|
from lerobot.policies.vla_jepa.processor_vla_jepa import ClipActionsProcessorStep
|
|
from lerobot.processor import UnnormalizerProcessorStep
|
|
from lerobot.processor.converters import policy_action_to_transition, transition_to_policy_action
|
|
from lerobot.utils.constants import ACTION
|
|
|
|
dataset_stats = _make_dataset_stats()
|
|
a_min = dataset_stats[ACTION]["min"].numpy()
|
|
a_max = dataset_stats[ACTION]["max"].numpy()
|
|
|
|
# Deliberately out-of-range inputs
|
|
actions_np = np.array([[[2.0] * ACTION_DIM, [-3.0] * ACTION_DIM]], dtype=np.float32)
|
|
clipped = np.clip(actions_np, -1.0, 1.0)
|
|
expected = (clipped + 1.0) / 2.0 * (a_max - a_min) + a_min
|
|
|
|
features = {ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(ACTION_DIM,))}
|
|
clip_step = ClipActionsProcessorStep()
|
|
unnorm_step = UnnormalizerProcessorStep(
|
|
features=features,
|
|
norm_map={FeatureType.ACTION: NormalizationMode.MIN_MAX},
|
|
stats=dataset_stats,
|
|
)
|
|
|
|
transition = policy_action_to_transition(torch.from_numpy(actions_np))
|
|
transition = clip_step(transition)
|
|
result = transition_to_policy_action(unnorm_step(transition)).numpy()
|
|
|
|
np.testing.assert_allclose(result, expected, rtol=1e-5, atol=1e-6)
|
|
|
|
|
|
@torch.no_grad()
|
|
def test_postprocessor_applied_after_predict_action_chunk(
|
|
patch_vla_jepa_external_models: None, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""predict_action_chunk returns raw actions; the postprocessor applies unnormalization.
|
|
|
|
Verifies the split: predict_action_chunk returns normalized actions, and calling the
|
|
postprocessor on them produces the correctly unnormalized result.
|
|
"""
|
|
from lerobot.policies.vla_jepa.processor_vla_jepa import make_vla_jepa_pre_post_processors
|
|
|
|
raw_actions = torch.zeros((BATCH_SIZE, ACTION_HORIZON, ACTION_DIM), dtype=torch.float32)
|
|
|
|
cfg = make_config()
|
|
cfg.clip_normalized_actions = False
|
|
cfg.binarize_gripper_action = False
|
|
policy = VLAJEPAPolicy(cfg)
|
|
policy.eval()
|
|
monkeypatch.setattr(policy.model, "predict_action", lambda *a, **kw: raw_actions.clone())
|
|
|
|
dataset_stats = _make_dataset_stats()
|
|
_, postprocessor = make_vla_jepa_pre_post_processors(cfg, dataset_stats)
|
|
|
|
batch = make_inference_batch()
|
|
chunk = policy.predict_action_chunk(batch)
|
|
|
|
# predict_action_chunk returns raw (normalized) actions
|
|
assert torch.allclose(chunk, torch.zeros_like(chunk), atol=1e-6), (
|
|
"predict_action_chunk should return raw actions without unnormalization applied."
|
|
)
|
|
|
|
# Postprocessor applies unnormalization: 0 → (0+1)/2 * (max-min) + min = 5 + i
|
|
unnormed = postprocessor(chunk)
|
|
from lerobot.utils.constants import ACTION
|
|
|
|
a_min = dataset_stats[ACTION]["min"].numpy()
|
|
a_max = dataset_stats[ACTION]["max"].numpy()
|
|
expected_first = 0.5 * (0.0 + 1.0) * (a_max[0] - a_min[0]) + a_min[0]
|
|
assert unnormed[0, 0, 0].item() == pytest.approx(expected_first, abs=1e-5)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# World-model view adjustment (padding / trimming) tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_MULTIVIEW_NUM_FRAMES = 4 # must be >= 2 * jepa_tubelet_size (=2) for world-model tests
|
|
|
|
|
|
def _make_multiview_config(num_views: int, jepa_tubelet_size: int = 2) -> VLAJEPAConfig:
|
|
from lerobot.configs.types import FeatureType, PolicyFeature
|
|
from lerobot.utils.constants import OBS_IMAGES, OBS_STATE
|
|
|
|
config = VLAJEPAConfig(
|
|
input_features={
|
|
**{
|
|
f"{OBS_IMAGES}.cam{i}": PolicyFeature(
|
|
type=FeatureType.VISUAL, shape=(3, IMAGE_SIZE, IMAGE_SIZE)
|
|
)
|
|
for i in range(num_views)
|
|
},
|
|
OBS_STATE: PolicyFeature(type=FeatureType.STATE, shape=(STATE_DIM,)),
|
|
},
|
|
output_features={ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(ACTION_DIM,))},
|
|
device="cpu",
|
|
chunk_size=ACTION_HORIZON,
|
|
n_action_steps=N_ACTION_STEPS,
|
|
action_dim=ACTION_DIM,
|
|
state_dim=STATE_DIM,
|
|
num_video_frames=_MULTIVIEW_NUM_FRAMES,
|
|
num_action_tokens_per_timestep=2,
|
|
num_embodied_action_tokens_per_instruction=3,
|
|
num_inference_timesteps=2,
|
|
action_hidden_size=QWEN_HIDDEN_SIZE,
|
|
action_model_type="DiT-test",
|
|
action_num_layers=1,
|
|
predictor_depth=1,
|
|
predictor_num_heads=2,
|
|
predictor_mlp_ratio=2.0,
|
|
jepa_tubelet_size=jepa_tubelet_size,
|
|
)
|
|
config.validate_features()
|
|
return config
|
|
|
|
|
|
def _make_multiview_train_batch(num_views: int, batch_size: int = BATCH_SIZE) -> dict:
|
|
from lerobot.utils.constants import OBS_IMAGES, OBS_STATE
|
|
|
|
batch = {
|
|
f"{OBS_IMAGES}.cam{i}": torch.rand(batch_size, _MULTIVIEW_NUM_FRAMES, 3, IMAGE_SIZE, IMAGE_SIZE)
|
|
for i in range(num_views)
|
|
}
|
|
batch[OBS_STATE] = torch.randn(batch_size, 1, STATE_DIM)
|
|
batch[ACTION] = torch.randn(batch_size, ACTION_HORIZON, ACTION_DIM)
|
|
batch["task"] = ["pick up the cube"] * batch_size
|
|
return batch
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"num_views",
|
|
[
|
|
1, # fewer views than jepa_tubelet_size → first view duplicated
|
|
2, # exact match → unchanged
|
|
3, # more views than jepa_tubelet_size → trimmed to first two
|
|
],
|
|
)
|
|
def test_training_forward_world_model_view_adjustment(
|
|
patch_vla_jepa_external_models: None,
|
|
num_views: int,
|
|
) -> None:
|
|
"""World-model view padding/trimming must not break the training forward pass."""
|
|
set_seed_all(42)
|
|
policy = VLAJEPAPolicy(_make_multiview_config(num_views=num_views, jepa_tubelet_size=2))
|
|
policy.train()
|
|
loss, logs = policy.forward(_make_multiview_train_batch(num_views=num_views))
|
|
assert torch.isfinite(loss)
|
|
assert logs["wm_loss"] >= 0
|
|
|
|
|
|
def test_single_view_is_duplicated_for_world_model(patch_vla_jepa_external_models: None) -> None:
|
|
"""With one dataset view and jepa_tubelet_size=2, the view must be duplicated before encoding."""
|
|
set_seed_all(42)
|
|
policy = VLAJEPAPolicy(_make_multiview_config(num_views=1, jepa_tubelet_size=2))
|
|
policy.train()
|
|
|
|
captured_videos: list = []
|
|
original_processor = policy.model.video_processor
|
|
|
|
class _CapturingProcessor:
|
|
def __call__(self, videos: list, return_tensors: str, **kwargs) -> dict:
|
|
captured_videos.extend(videos)
|
|
return original_processor(videos=videos, return_tensors=return_tensors, **kwargs)
|
|
|
|
policy.model.video_processor = _CapturingProcessor()
|
|
policy.forward(_make_multiview_train_batch(num_views=1))
|
|
|
|
# reshape is batch-major: (b0v0, b0v1, b1v0, b1v1, …)
|
|
assert len(captured_videos) == BATCH_SIZE * 2
|
|
for i in range(BATCH_SIZE):
|
|
np.testing.assert_array_equal(captured_videos[2 * i], captured_videos[2 * i + 1])
|
|
|
|
|
|
def test_excess_views_trimmed_for_world_model(patch_vla_jepa_external_models: None) -> None:
|
|
"""With three dataset views and jepa_tubelet_size=2, only the first two views reach the encoder."""
|
|
set_seed_all(42)
|
|
policy = VLAJEPAPolicy(_make_multiview_config(num_views=3, jepa_tubelet_size=2))
|
|
policy.train()
|
|
|
|
captured_videos: list = []
|
|
original_processor = policy.model.video_processor
|
|
|
|
class _CapturingProcessor:
|
|
def __call__(self, videos: list, return_tensors: str, **kwargs) -> dict:
|
|
captured_videos.extend(videos)
|
|
return original_processor(videos=videos, return_tensors=return_tensors, **kwargs)
|
|
|
|
policy.model.video_processor = _CapturingProcessor()
|
|
policy.forward(_make_multiview_train_batch(num_views=3))
|
|
|
|
# Only B*2 items must reach the encoder, not B*3.
|
|
assert len(captured_videos) == BATCH_SIZE * 2
|