mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-20 16:31:55 +00:00
196 lines
7.8 KiB
Python
196 lines
7.8 KiB
Python
#!/usr/bin/env python
|
|
|
|
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Behavior-pinning tests for the shared VLA helpers.
|
|
|
|
These helpers are the canonical versions of functions that used to be copy-pasted across
|
|
the openpi-derived policies (pi0, pi05, pi0_fast, smolvla, eo1, xvla). The expected
|
|
values below encode the historical per-policy behavior exactly; a failure here means a
|
|
behavior change that would silently affect released checkpoints.
|
|
"""
|
|
|
|
import math
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from lerobot.policies.common.vla_utils import (
|
|
create_sinusoidal_pos_embedding,
|
|
make_att_2d_masks,
|
|
pad_vector,
|
|
prepare_attention_masks_4d,
|
|
resize_with_pad,
|
|
resize_with_pad_torch,
|
|
)
|
|
from lerobot.utils.constants import OPENPI_ATTENTION_MASK_VALUE
|
|
|
|
|
|
def test_create_sinusoidal_pos_embedding_matches_openpi_formula():
|
|
time = torch.tensor([0.0, 0.25, 1.0])
|
|
dim, min_period, max_period = 8, 4e-3, 4.0
|
|
emb = create_sinusoidal_pos_embedding(time, dim, min_period, max_period, device=torch.device("cpu"))
|
|
|
|
assert emb.shape == (3, dim)
|
|
# Independent recomputation of the openpi formula in float64.
|
|
fraction = torch.linspace(0.0, 1.0, dim // 2, dtype=torch.float64)
|
|
period = min_period * (max_period / min_period) ** fraction
|
|
scaling = 1.0 / period * 2 * math.pi
|
|
sin_input = scaling[None, :] * time.to(torch.float64)[:, None]
|
|
expected = torch.cat([torch.sin(sin_input), torch.cos(sin_input)], dim=1)
|
|
torch.testing.assert_close(emb, expected, rtol=1e-9, atol=1e-9)
|
|
|
|
|
|
def test_create_sinusoidal_pos_embedding_validation():
|
|
with pytest.raises(ValueError, match="divisible by 2"):
|
|
create_sinusoidal_pos_embedding(torch.zeros(2), 7, 4e-3, 4.0, device=torch.device("cpu"))
|
|
with pytest.raises(ValueError, match="batch_size"):
|
|
create_sinusoidal_pos_embedding(torch.zeros(2, 2), 8, 4e-3, 4.0, device=torch.device("cpu"))
|
|
|
|
|
|
def test_make_att_2d_masks_docstring_cases():
|
|
# Pure causal attention: [[1 1 1]]
|
|
pad = torch.ones(1, 3, dtype=torch.bool)
|
|
att = torch.tensor([[1, 1, 1]], dtype=torch.int32)
|
|
expected = torch.tensor([[[1, 0, 0], [1, 1, 0], [1, 1, 1]]], dtype=torch.bool)
|
|
assert torch.equal(make_att_2d_masks(pad, att), expected)
|
|
|
|
# Prefix-LM: [[0 0 1 1]] -> first two tokens attend bidirectionally, rest causal.
|
|
att = torch.tensor([[0, 0, 1, 1]], dtype=torch.int32)
|
|
pad = torch.ones(1, 4, dtype=torch.bool)
|
|
expected = torch.tensor([[[1, 1, 0, 0], [1, 1, 0, 0], [1, 1, 1, 0], [1, 1, 1, 1]]], dtype=torch.bool)
|
|
assert torch.equal(make_att_2d_masks(pad, att), expected)
|
|
|
|
# Padding removes rows and columns.
|
|
pad = torch.tensor([[True, True, False]])
|
|
att = torch.tensor([[0, 1, 1]], dtype=torch.int32)
|
|
out = make_att_2d_masks(pad, att)
|
|
assert not out[0, :, 2].any() and not out[0, 2, :].any()
|
|
|
|
|
|
def test_make_att_2d_masks_validation():
|
|
with pytest.raises(ValueError):
|
|
make_att_2d_masks(torch.ones(3, dtype=torch.bool), torch.ones(1, 3, dtype=torch.int32))
|
|
with pytest.raises(ValueError):
|
|
make_att_2d_masks(torch.ones(1, 3, dtype=torch.bool), torch.ones(3, dtype=torch.int32))
|
|
|
|
|
|
def test_prepare_attention_masks_4d():
|
|
masks = torch.tensor([[[True, False], [False, True]]])
|
|
out = prepare_attention_masks_4d(masks)
|
|
assert out.shape == (1, 1, 2, 2)
|
|
expected = torch.tensor([[[[0.0, OPENPI_ATTENTION_MASK_VALUE], [OPENPI_ATTENTION_MASK_VALUE, 0.0]]]])
|
|
assert torch.equal(out, expected)
|
|
|
|
out_bf16 = prepare_attention_masks_4d(masks, dtype=torch.bfloat16)
|
|
assert out_bf16.dtype == torch.bfloat16
|
|
assert torch.equal(out_bf16, expected.to(torch.bfloat16))
|
|
|
|
|
|
def test_pad_vector_openpi_semantics():
|
|
v = torch.arange(6.0).reshape(2, 3)
|
|
padded = pad_vector(v, 5)
|
|
assert padded.shape == (2, 5)
|
|
assert torch.equal(padded[:, :3], v) and not padded[:, 3:].any()
|
|
# Already large enough (>=): returned unchanged, same object.
|
|
assert pad_vector(v, 3) is v
|
|
assert pad_vector(v, 2) is v
|
|
# 3D input.
|
|
v3 = torch.ones(2, 4, 3)
|
|
assert pad_vector(v3, 7).shape == (2, 4, 7)
|
|
|
|
|
|
def test_pad_vector_truncate_semantics():
|
|
v = torch.arange(6.0).reshape(2, 3)
|
|
out = pad_vector(v, 2, truncate=True)
|
|
assert out.shape == (2, 2) and torch.equal(out, v[:, :2])
|
|
out = pad_vector(v, 5, truncate=True)
|
|
assert out.shape == (2, 5) and torch.equal(out[:, :3], v) and not out[:, 3:].any()
|
|
assert pad_vector(v, 0, truncate=True).shape == (2, 0)
|
|
assert pad_vector(v, 3, truncate=True) is v
|
|
|
|
|
|
@pytest.mark.parametrize("channels_last", [True, False])
|
|
def test_resize_with_pad_torch_centered(channels_last):
|
|
img = torch.rand(2, 3, 30, 60) if not channels_last else torch.rand(2, 30, 60, 3)
|
|
out = resize_with_pad_torch(img, 64, 64)
|
|
if channels_last:
|
|
assert out.shape == (2, 64, 64, 3)
|
|
# Aspect ratio preserved: 30x60 -> 32x64, padded 16 top and 16 bottom (centered).
|
|
assert not out[:, :16].any() and not out[:, -16:].any()
|
|
assert out[:, 16:48].abs().sum() > 0
|
|
else:
|
|
assert out.shape == (2, 3, 64, 64)
|
|
assert not out[:, :, :16].any() and not out[:, :, -16:].any()
|
|
|
|
|
|
def test_resize_with_pad_torch_uint8_roundtrip():
|
|
img = (torch.rand(1, 3, 20, 20) * 255).to(torch.uint8)
|
|
out = resize_with_pad_torch(img, 40, 40)
|
|
assert out.dtype == torch.uint8 and out.shape == (1, 3, 40, 40)
|
|
with pytest.raises(ValueError, match="Unsupported image dtype"):
|
|
resize_with_pad_torch(torch.rand(1, 3, 8, 8, dtype=torch.float64), 16, 16)
|
|
|
|
|
|
def test_resize_with_pad_top_left():
|
|
img = torch.rand(2, 3, 30, 60)
|
|
out = resize_with_pad(img, 64, 64, pad_value=-1.0)
|
|
assert out.shape == (2, 3, 64, 64)
|
|
# 30x60 -> 32x64; this variant pads on the TOP only (32 rows of pad_value).
|
|
assert torch.equal(out[:, :, :32], torch.full((2, 3, 32, 64), -1.0))
|
|
assert out[:, :, 32:].min() >= 0
|
|
# No-op fast path returns the same object.
|
|
assert resize_with_pad(img, 30, 60, pad_value=0.0) is img
|
|
with pytest.raises(ValueError, match="expected"):
|
|
resize_with_pad(torch.rand(3, 8, 8), 16, 16, pad_value=0.0)
|
|
|
|
|
|
def test_clone_past_key_values():
|
|
pytest.importorskip("transformers")
|
|
from transformers import DynamicCache
|
|
|
|
from lerobot.policies.common.vla_utils import clone_past_key_values
|
|
|
|
cache = DynamicCache()
|
|
keys, values = torch.rand(1, 2, 4, 8), torch.rand(1, 2, 4, 8)
|
|
cache.update(keys, values, 0)
|
|
cloned = clone_past_key_values(cache)
|
|
(ck, cv, _), (ok, ov, _) = next(iter(cloned)), next(iter(cache))
|
|
assert torch.equal(ck, ok) and torch.equal(cv, ov)
|
|
# Deep copy: mutating the clone must not touch the original.
|
|
ck.zero_()
|
|
assert not torch.equal(ck, ok)
|
|
|
|
|
|
def test_clone_past_key_values_is_fullgraph_compilable():
|
|
pytest.importorskip("transformers")
|
|
from transformers import DynamicCache
|
|
|
|
from lerobot.policies.common.vla_utils import clone_past_key_values
|
|
|
|
cache = DynamicCache()
|
|
keys, values = torch.rand(1, 2, 4, 8), torch.rand(1, 2, 4, 8)
|
|
cache.update(keys, values, 0)
|
|
|
|
compiled_clone = torch.compile(clone_past_key_values, backend="eager", fullgraph=True)
|
|
cloned = compiled_clone(cache)
|
|
|
|
(cloned_keys, cloned_values, _), (original_keys, original_values, _) = (
|
|
next(iter(cloned)),
|
|
next(iter(cache)),
|
|
)
|
|
assert torch.equal(cloned_keys, original_keys)
|
|
assert torch.equal(cloned_values, original_values)
|