fix: nanoVLM processing

This commit is contained in:
Khalil Meftah
2026-07-23 22:50:29 +02:00
parent 08953c3a9e
commit 02f67a9f54
6 changed files with 298 additions and 46 deletions
@@ -52,9 +52,14 @@ lerobot-train \
--dataset.repo_id=<dataset_repo_id> \ --dataset.repo_id=<dataset_repo_id> \
--output_dir=outputs/vf_nanovlm_probe \ --output_dir=outputs/vf_nanovlm_probe \
--steps=5000 \ --steps=5000 \
--batch_size=16 --batch_size=1
``` ```
The released checkpoint's native preprocessing resizes the long image side to
2048 and creates 512px global/split views. A 480x640 camera therefore produces
13 vision inputs and roughly 832 image placeholders; use batch size 1 initially
for a three-camera setup.
Then load the probe checkpoint and selectively fine-tune the projector/decoder Then load the probe checkpoint and selectively fine-tune the projector/decoder
at a lower learning rate. at a lower learning rate.
@@ -107,5 +112,8 @@ uv run python scripts/overfit_vf_variant.py \
--steps=500 --steps=500
``` ```
For `nanovlm_value_function`, start with `--num_samples=2` because all overfit
samples are held in one batch and native image tiling is memory intensive.
Compare runs using held-out episode NLL/MAE, per-episode return rank correlation, Compare runs using held-out episode NLL/MAE, per-episode return rank correlation,
terminal success/failure separation, and the matched-versus-shuffled image loss gap. terminal success/failure separation, and the matched-versus-shuffled image loss gap.
+22
View File
@@ -20,6 +20,11 @@ from lerobot.rewards.factory import make_reward_model, make_reward_pre_post_proc
from lerobot.rewards.nanovlm_value_function.configuration_nanovlm_value_function import ( from lerobot.rewards.nanovlm_value_function.configuration_nanovlm_value_function import (
NanoVLMVFConfig, NanoVLMVFConfig,
) )
from lerobot.rewards.nanovlm_value_function.processor_nanovlm_value_function import (
NANOVLM_ATTENTION_MASK,
NANOVLM_IMAGES,
NANOVLM_INPUT_IDS,
)
from lerobot.rewards.temporal_siglip_value_function.configuration_temporal_siglip_value_function import ( from lerobot.rewards.temporal_siglip_value_function.configuration_temporal_siglip_value_function import (
TemporalSiglipVFConfig, TemporalSiglipVFConfig,
) )
@@ -132,6 +137,20 @@ def main():
def _collate_processed(samples): def _collate_processed(samples):
if NANOVLM_IMAGES in samples[0]:
max_length = max(sample[NANOVLM_INPUT_IDS].shape[1] for sample in samples)
input_ids = []
attention_masks = []
for sample in samples:
padding = max_length - sample[NANOVLM_INPUT_IDS].shape[1]
input_ids.append(torch.nn.functional.pad(sample[NANOVLM_INPUT_IDS], (padding, 0)))
attention_masks.append(torch.nn.functional.pad(sample[NANOVLM_ATTENTION_MASK], (padding, 0)))
return {
NANOVLM_IMAGES: [sample[NANOVLM_IMAGES][0] for sample in samples],
NANOVLM_INPUT_IDS: torch.cat(input_ids),
NANOVLM_ATTENTION_MASK: torch.cat(attention_masks),
}
keys = { keys = {
*[key for key in samples[0] if key.startswith("observation.images.")], *[key for key in samples[0] if key.startswith("observation.images.")],
OBS_LANGUAGE_TOKENS, OBS_LANGUAGE_TOKENS,
@@ -192,7 +211,10 @@ def _image_shuffle_diagnostic(model, batch, camera_keys):
matched_loss, _ = model(batch) matched_loss, _ = model(batch)
shuffled = dict(batch) shuffled = dict(batch)
permutation = torch.roll(torch.arange(batch["mc_return"].shape[0], device=matched_loss.device), 1) permutation = torch.roll(torch.arange(batch["mc_return"].shape[0], device=matched_loss.device), 1)
if NANOVLM_IMAGES in batch:
shuffled[NANOVLM_IMAGES] = [batch[NANOVLM_IMAGES][index] for index in permutation.cpu().tolist()]
for key in camera_keys: for key in camera_keys:
if key in batch:
shuffled[key] = batch[key][permutation] shuffled[key] = batch[key][permutation]
shuffled_loss, _ = model(shuffled) shuffled_loss, _ = model(shuffled)
print( print(
@@ -12,9 +12,9 @@ from lerobot.optim import AdamWConfig, CosineDecayWithWarmupSchedulerConfig
class NanoVLMVFConfig(RewardModelConfig): class NanoVLMVFConfig(RewardModelConfig):
nanovlm_pretrained_path: str = "lusxvr/nanoVLM-460M-8k" nanovlm_pretrained_path: str = "lusxvr/nanoVLM-460M-8k"
nanovlm_code_path: str = "third_party/nanoVLM" nanovlm_code_path: str = "third_party/nanoVLM"
tokenizer_path: str = "HuggingFaceTB/SmolLM2-360M-Instruct" # The checkpoint was aligned with an 8k context. Native image tiling can
image_resolution: tuple[int, int] = (512, 512) # require thousands of placeholder tokens for several robot cameras.
tokenizer_max_length: int = 256 tokenizer_max_length: int = 8192
num_value_bins: int = 201 num_value_bins: int = 201
value_support_min: float = -1.0 value_support_min: float = -1.0
value_support_max: float = 0.0 value_support_max: float = 0.0
@@ -9,14 +9,14 @@ from typing import Any
import torch import torch
from torch import Tensor, nn from torch import Tensor, nn
from lerobot.configs.types import FeatureType
from lerobot.rewards.distributional_value_function.common import DistributionalValueMixin from lerobot.rewards.distributional_value_function.common import DistributionalValueMixin
from lerobot.rewards.distributional_value_function.modeling_distributional_value_function import ValueHead from lerobot.rewards.distributional_value_function.modeling_distributional_value_function import ValueHead
from lerobot.rewards.distributional_value_function.processor_distributional_value_function import ( from lerobot.rewards.nanovlm_value_function.processor_nanovlm_value_function import (
IMAGE_MASK_SUFFIX, NANOVLM_ATTENTION_MASK,
NANOVLM_IMAGES,
NANOVLM_INPUT_IDS,
) )
from lerobot.rewards.pretrained import PreTrainedRewardModel from lerobot.rewards.pretrained import PreTrainedRewardModel
from lerobot.utils.constants import OBS_LANGUAGE_ATTENTION_MASK, OBS_LANGUAGE_TOKENS
from .configuration_nanovlm_value_function import NanoVLMVFConfig from .configuration_nanovlm_value_function import NanoVLMVFConfig
@@ -31,9 +31,6 @@ class NanoVLMVFRewardModel(DistributionalValueMixin, PreTrainedRewardModel):
super().__init__(config) super().__init__(config)
self.config = config self.config = config
config.validate_features() config.validate_features()
self.image_keys = [
key for key, feature in config.input_features.items() if feature.type == FeatureType.VISUAL
]
code_path = Path(config.nanovlm_code_path) code_path = Path(config.nanovlm_code_path)
if not code_path.is_absolute(): if not code_path.is_absolute():
code_path = Path(__file__).resolve().parents[4] / code_path code_path = Path(__file__).resolve().parents[4] / code_path
@@ -80,25 +77,33 @@ class NanoVLMVFRewardModel(DistributionalValueMixin, PreTrainedRewardModel):
return self._distributional_forward(batch) return self._distributional_forward(batch)
def _get_value_readout(self, batch: dict[str, Tensor]) -> Tensor: def _get_value_readout(self, batch: dict[str, Tensor]) -> Tensor:
batch_size = batch[OBS_LANGUAGE_TOKENS].shape[0] input_ids = batch[NANOVLM_INPUT_IDS]
image_tokens = [] attention_mask = batch[NANOVLM_ATTENTION_MASK].bool()
image_masks = [] batch_size = input_ids.shape[0]
for key in self.image_keys: images = self.nanovlm._process_images(batch[NANOVLM_IMAGES], input_ids.device)
image = batch[key] text_tokens = self.nanovlm.decoder.token_embedding(input_ids)
mask = batch[key + IMAGE_MASK_SUFFIX].bool() if images is not None:
features = self.nanovlm.MP(self.nanovlm.vision_encoder(image)) image_tokens = self.nanovlm.MP(self.nanovlm.vision_encoder(images))
image_tokens.append(features * mask[:, None, None].to(features.dtype)) placeholder_count = (input_ids == self.nanovlm.tokenizer.image_token_id).sum().item()
image_masks.append(mask[:, None].expand(batch_size, features.shape[1])) image_token_count = image_tokens.shape[0] * image_tokens.shape[1]
if placeholder_count != image_token_count:
text_tokens = self.nanovlm.decoder.token_embedding(batch[OBS_LANGUAGE_TOKENS]) raise ValueError(
"nanoVLM image placeholders do not match projected image tokens: "
f"{placeholder_count} placeholders versus {image_token_count} tokens. "
"The prompt may have been truncated; increase tokenizer_max_length."
)
text_tokens = self.nanovlm._replace_img_tokens_with_embd(
input_ids,
text_tokens,
image_tokens,
)
query = self.value_query(torch.zeros(batch_size, 1, dtype=torch.long, device=text_tokens.device)).to( query = self.value_query(torch.zeros(batch_size, 1, dtype=torch.long, device=text_tokens.device)).to(
text_tokens.dtype text_tokens.dtype
) )
inputs = torch.cat([*image_tokens, text_tokens, query], dim=1) inputs = torch.cat([text_tokens, query], dim=1)
attention_mask = torch.cat( attention_mask = torch.cat(
[ [
*image_masks, attention_mask,
batch[OBS_LANGUAGE_ATTENTION_MASK].bool(),
torch.ones(batch_size, 1, dtype=torch.bool, device=text_tokens.device), torch.ones(batch_size, 1, dtype=torch.bool, device=text_tokens.device),
], ],
dim=1, dim=1,
@@ -1,30 +1,180 @@
"""Processor for the nanoVLM value-function experiment.""" """Processor using nanoVLM's native image splitting and chat-token layout."""
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any from typing import Any
import torch import torch
from torchvision.transforms.functional import to_pil_image
from lerobot.configs import FeatureType from lerobot.configs import FeatureType, PipelineFeatureType, PolicyFeature
from lerobot.processor import ( from lerobot.processor import (
AddBatchDimensionProcessorStep, AddBatchDimensionProcessorStep,
ComplementaryDataProcessorStep,
DeviceProcessorStep, DeviceProcessorStep,
NormalizerProcessorStep, NormalizerProcessorStep,
PolicyAction, PolicyAction,
PolicyProcessorPipeline, PolicyProcessorPipeline,
ProcessorStepRegistry,
RenameObservationsProcessorStep, RenameObservationsProcessorStep,
TokenizerProcessorStep,
batch_to_transition, batch_to_transition,
policy_action_to_transition, policy_action_to_transition,
transition_to_batch, transition_to_batch,
) )
from lerobot.rewards.distributional_value_function.processor_distributional_value_function import ( from lerobot.types import TransitionKey
DistributionalVFImagePreprocessorStep,
DistributionalVFPrepareTaskPromptStep,
)
from lerobot.utils.constants import POLICY_POSTPROCESSOR_DEFAULT_NAME, POLICY_PREPROCESSOR_DEFAULT_NAME from lerobot.utils.constants import POLICY_POSTPROCESSOR_DEFAULT_NAME, POLICY_PREPROCESSOR_DEFAULT_NAME
from .configuration_nanovlm_value_function import NanoVLMVFConfig from .configuration_nanovlm_value_function import NanoVLMVFConfig
NANOVLM_IMAGES = "observation.nanovlm.images"
NANOVLM_INPUT_IDS = "observation.nanovlm.input_ids"
NANOVLM_ATTENTION_MASK = "observation.nanovlm.attention_mask"
@ProcessorStepRegistry.register(name="nanovlm_native_processor")
@dataclass
class NanoVLMNativeProcessorStep(ComplementaryDataProcessorStep):
pretrained_path: str
code_path: str
image_keys: tuple[str, ...]
max_length: int
_tokenizer: Any = field(default=None, init=False, repr=False)
_image_processor: Any = field(default=None, init=False, repr=False)
_get_image_string: Any = field(default=None, init=False, repr=False)
_mp_image_token_length: int = field(default=64, init=False, repr=False)
def __post_init__(self):
code_path = Path(self.code_path)
if not code_path.is_absolute():
code_path = Path(__file__).resolve().parents[4] / code_path
if str(code_path) not in sys.path:
sys.path.insert(0, str(code_path))
from data.processors import get_image_processor, get_image_string, get_tokenizer
config_path = _resolve_checkpoint_file(self.pretrained_path, "config.json")
config = json.loads(Path(config_path).read_text())
if self.max_length > config["lm_max_length"]:
raise ValueError(
f"tokenizer_max_length={self.max_length} exceeds nanoVLM's "
f"lm_max_length={config['lm_max_length']}"
)
self._tokenizer = get_tokenizer(
config["lm_tokenizer"],
config["vlm_extra_tokens"],
config["lm_chat_template"],
)
self._image_processor = get_image_processor(
config["max_img_size"],
config["vit_img_size"],
config["resize_to_max_side_len"],
)
self._get_image_string = get_image_string
self._mp_image_token_length = config["mp_image_token_length"]
def complementary_data(self, complementary_data):
raw_tasks = complementary_data.get("task")
if raw_tasks is None:
raise ValueError("Task is required for nanoVLM value processing")
observation = self.transition[TransitionKey.OBSERVATION]
present_image_keys = [key for key in self.image_keys if key in observation]
if not present_image_keys:
raise ValueError("No configured nanoVLM image key is present in the observation")
batch_size = observation[present_image_keys[0]].shape[0]
tasks = [raw_tasks] * batch_size if isinstance(raw_tasks, str) else list(raw_tasks)
if len(tasks) != batch_size:
raise ValueError(f"Received {len(tasks)} tasks for an image batch of size {batch_size}")
processed_batch = []
input_rows = []
attention_rows = []
for batch_index in range(batch_size):
processed_images = []
split_counts = []
for key in self.image_keys:
if key not in observation:
continue
image = observation[key][batch_index]
if image.ndim != 3:
raise ValueError(f"nanoVLM expects CHW images, got {tuple(image.shape)} for {key}")
if image.shape[0] not in (1, 3, 4) and image.shape[-1] in (1, 3, 4):
image = image.permute(2, 0, 1)
if image.dtype != torch.uint8:
image = image.float()
if image.min() < -1e-6 or image.max() > 1.0 + 1e-6:
raise ValueError(
f"nanoVLM expects uint8 [0,255] or float [0,1] images; "
f"{key} has range [{image.min().item()}, {image.max().item()}]"
)
image = image.clamp(0, 1)
pil_image = to_pil_image(image.cpu()).convert("RGB")
processed, split_count = self._image_processor(pil_image)
processed_images.append(processed)
split_counts.append(split_count)
image_string = self._get_image_string(
self._tokenizer,
split_counts,
self._mp_image_token_length,
)
prompt = self._tokenizer.apply_chat_template(
[{"role": "user", "content": image_string + f"Task: {tasks[batch_index]}."}],
tokenize=False,
add_generation_prompt=True,
)
tokenized = self._tokenizer(
prompt,
truncation=False,
add_special_tokens=False,
)
if len(tokenized["input_ids"]) > self.max_length:
raise ValueError(
f"nanoVLM prompt has {len(tokenized['input_ids'])} tokens, exceeding "
f"tokenizer_max_length={self.max_length}. The native nanoVLM collator "
"discards over-length examples instead of truncating image placeholders."
)
input_rows.append(tokenized["input_ids"])
attention_rows.append(tokenized.get("attention_mask", [1] * len(tokenized["input_ids"])))
processed_batch.append(processed_images)
max_length = max(map(len, input_rows))
for input_ids, attention_mask in zip(input_rows, attention_rows, strict=True):
padding = max_length - len(input_ids)
input_ids[:0] = [self._tokenizer.pad_token_id] * padding
attention_mask[:0] = [0] * padding
observation = dict(observation)
observation[NANOVLM_IMAGES] = processed_batch
observation[NANOVLM_INPUT_IDS] = torch.tensor(input_rows, dtype=torch.long)
observation[NANOVLM_ATTENTION_MASK] = torch.tensor(attention_rows, dtype=torch.bool)
self.transition[TransitionKey.OBSERVATION] = observation
return complementary_data
def transform_features(
self,
features: dict[PipelineFeatureType, dict[str, PolicyFeature]],
):
return features
def get_config(self):
return {
"pretrained_path": self.pretrained_path,
"code_path": self.code_path,
"image_keys": self.image_keys,
"max_length": self.max_length,
}
def _resolve_checkpoint_file(repo_id_or_path: str, filename: str) -> str:
local_path = Path(repo_id_or_path) / filename
if local_path.exists():
return str(local_path)
from huggingface_hub import hf_hub_download
return hf_hub_download(repo_id=repo_id_or_path, filename=filename)
def make_nanovlm_vf_pre_post_processors( def make_nanovlm_vf_pre_post_processors(
config: NanoVLMVFConfig, config: NanoVLMVFConfig,
@@ -45,16 +195,11 @@ def make_nanovlm_vf_pre_post_processors(
norm_map=config.normalization_mapping, norm_map=config.normalization_mapping,
stats=dataset_stats, stats=dataset_stats,
), ),
DistributionalVFImagePreprocessorStep( NanoVLMNativeProcessorStep(
image_resolution=config.image_resolution, pretrained_path=config.nanovlm_pretrained_path,
code_path=config.nanovlm_code_path,
image_keys=image_keys, image_keys=image_keys,
),
DistributionalVFPrepareTaskPromptStep(),
TokenizerProcessorStep(
tokenizer_name=config.tokenizer_path,
max_length=config.tokenizer_max_length, max_length=config.tokenizer_max_length,
padding_side="right",
padding="max_length",
), ),
DeviceProcessorStep(device=config.device or "cpu"), DeviceProcessorStep(device=config.device or "cpu"),
], ],
+77 -5
View File
@@ -1,7 +1,9 @@
import json
import sys import sys
from types import ModuleType, SimpleNamespace from types import ModuleType, SimpleNamespace
import torch import torch
from PIL import Image
from torch import nn from torch import nn
from lerobot.configs import FeatureType, PolicyFeature from lerobot.configs import FeatureType, PolicyFeature
@@ -9,7 +11,13 @@ from lerobot.rewards.factory import get_reward_model_class, make_reward_model_co
from lerobot.rewards.nanovlm_value_function.configuration_nanovlm_value_function import ( from lerobot.rewards.nanovlm_value_function.configuration_nanovlm_value_function import (
NanoVLMVFConfig, NanoVLMVFConfig,
) )
from lerobot.utils.constants import OBS_LANGUAGE_ATTENTION_MASK, OBS_LANGUAGE_TOKENS from lerobot.rewards.nanovlm_value_function.processor_nanovlm_value_function import (
NANOVLM_ATTENTION_MASK,
NANOVLM_IMAGES,
NANOVLM_INPUT_IDS,
NanoVLMNativeProcessorStep,
)
from lerobot.types import TransitionKey
CAMERA = "observation.images.top" CAMERA = "observation.images.top"
@@ -17,6 +25,7 @@ CAMERA = "observation.images.top"
def test_config_and_factory_registration(): def test_config_and_factory_registration():
config = make_reward_model_config("nanovlm_value_function") config = make_reward_model_config("nanovlm_value_function")
assert isinstance(config, NanoVLMVFConfig) assert isinstance(config, NanoVLMVFConfig)
assert config.tokenizer_max_length == 8192
assert get_reward_model_class("nanovlm_value_function").__name__ == "NanoVLMVFRewardModel" assert get_reward_model_class("nanovlm_value_function").__name__ == "NanoVLMVFRewardModel"
@@ -56,6 +65,15 @@ def test_nanovlm_model_forward(monkeypatch):
self.vision_encoder = FakeVision() self.vision_encoder = FakeVision()
self.MP = FakeProjector() self.MP = FakeProjector()
self.decoder = FakeDecoder() self.decoder = FakeDecoder()
self.tokenizer = SimpleNamespace(image_token_id=99)
def _process_images(self, images, device):
return torch.cat([image for sample in images for image in sample]).to(device)
def _replace_img_tokens_with_embd(self, input_ids, token_embd, image_embd):
token_embd = token_embd.clone()
token_embd[input_ids == self.tokenizer.image_token_id] = image_embd.flatten(0, 1)
return token_embd
@classmethod @classmethod
def from_pretrained(cls, path): def from_pretrained(cls, path):
@@ -72,13 +90,67 @@ def test_nanovlm_model_forward(monkeypatch):
config.input_features = {CAMERA: PolicyFeature(type=FeatureType.VISUAL, shape=(3, 16, 16))} config.input_features = {CAMERA: PolicyFeature(type=FeatureType.VISUAL, shape=(3, 16, 16))}
model = NanoVLMVFRewardModel(config) model = NanoVLMVFRewardModel(config)
batch = { batch = {
CAMERA: torch.rand(1, 3, 16, 16), NANOVLM_IMAGES: [[torch.rand(1, 3, 16, 16)]],
CAMERA + ".mask": torch.ones(1, dtype=torch.bool), NANOVLM_INPUT_IDS: torch.tensor([[99, 99, 99, 99, 1]]),
OBS_LANGUAGE_TOKENS: torch.ones(1, 4, dtype=torch.long), NANOVLM_ATTENTION_MASK: torch.ones(1, 5, dtype=torch.bool),
OBS_LANGUAGE_ATTENTION_MASK: torch.ones(1, 4, dtype=torch.bool),
"mc_return": torch.tensor([-0.5]), "mc_return": torch.tensor([-0.5]),
"is_terminal": torch.tensor([False]), "is_terminal": torch.tensor([False]),
} }
loss, metrics = model(batch) loss, metrics = model(batch)
assert torch.isfinite(loss) assert torch.isfinite(loss)
assert -1.0 <= metrics["predicted_value_mean"] <= 0.0 assert -1.0 <= metrics["predicted_value_mean"] <= 0.0
def test_native_processor_uses_checkpoint_layout_and_left_padding(monkeypatch, tmp_path):
config = {
"lm_tokenizer": "fake",
"vlm_extra_tokens": {},
"lm_chat_template": "fake",
"lm_max_length": 8192,
"max_img_size": 2048,
"vit_img_size": 512,
"resize_to_max_side_len": True,
"mp_image_token_length": 4,
}
(tmp_path / "config.json").write_text(json.dumps(config))
class FakeTokenizer:
pad_token_id = 0
image_token_id = 99
def apply_chat_template(self, messages, tokenize, add_generation_prompt):
assert not tokenize and add_generation_prompt
return messages[0]["content"]
def __call__(self, prompt, truncation, add_special_tokens):
assert not truncation and not add_special_tokens
suffix = [1, 2] if "long" in prompt else [1]
return {"input_ids": [99] * 4 + suffix, "attention_mask": [1] * (4 + len(suffix))}
def fake_image_processor(image):
assert isinstance(image, Image.Image) and image.mode == "RGB"
return torch.rand(1, 3, 512, 512), (1, 1)
processors = ModuleType("data.processors")
processors.get_tokenizer = lambda *args: FakeTokenizer()
processors.get_image_processor = lambda *args: fake_image_processor
processors.get_image_string = lambda *args: "<image>"
monkeypatch.setitem(sys.modules, "data.processors", processors)
step = NanoVLMNativeProcessorStep(
pretrained_path=str(tmp_path),
code_path="third_party/nanoVLM",
image_keys=(CAMERA,),
max_length=8192,
)
transition = {
TransitionKey.OBSERVATION: {CAMERA: torch.rand(2, 3, 16, 16)},
TransitionKey.COMPLEMENTARY_DATA: {"task": ["short", "long"]},
}
output = step(transition)[TransitionKey.OBSERVATION]
assert len(output[NANOVLM_IMAGES]) == 2
assert output[NANOVLM_INPUT_IDS].shape == (2, 6)
assert output[NANOVLM_INPUT_IDS][0, 0] == 0
assert not output[NANOVLM_ATTENTION_MASK][0, 0]
assert output[NANOVLM_ATTENTION_MASK][1].all()