| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| """Robometer pre/post processing pipelines.""" |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass, field |
| from typing import TYPE_CHECKING, Any |
|
|
| import numpy as np |
| import torch |
| from PIL import Image |
| from torch import Tensor |
|
|
| from lerobot.configs import PipelineFeatureType, PolicyFeature |
| from lerobot.processor import ( |
| AddBatchDimensionProcessorStep, |
| DeviceProcessorStep, |
| PolicyAction, |
| PolicyProcessorPipeline, |
| ProcessorStep, |
| ProcessorStepRegistry, |
| policy_action_to_transition, |
| ) |
| from lerobot.rewards.robometer.configuration_robometer import ( |
| ROBOMETER_SPECIAL_TOKENS, |
| RobometerConfig, |
| ) |
| from lerobot.rewards.robometer.modeling_robometer import ROBOMETER_FEATURE_PREFIX |
| from lerobot.types import EnvTransition, TransitionKey |
| from lerobot.utils.constants import ( |
| OBS_IMAGES, |
| POLICY_POSTPROCESSOR_DEFAULT_NAME, |
| POLICY_PREPROCESSOR_DEFAULT_NAME, |
| ) |
| from lerobot.utils.import_utils import _transformers_available, require_package |
|
|
| if TYPE_CHECKING or _transformers_available: |
| from transformers import AutoProcessor |
| else: |
| AutoProcessor = None |
|
|
| PROGRESS_PROMPT = ( |
| "The task for the robot is '{task}'. Given the trajectory video, predict " |
| "the task progress at each frame, how far along the robot is towards " |
| "completing the task, a float between 0 and 1, where 0 is the starting " |
| "state and 1 is when the task is completed. If the robot is not " |
| "performing the same task, predict 0 progress." |
| ) |
|
|
|
|
| def _frames_to_pil(frames: np.ndarray) -> list[Image.Image]: |
| """Convert ``(T, H, W, C)`` uint8 frames to a list of PIL images.""" |
| if frames.ndim != 4: |
| raise ValueError(f"Expected (T,H,W,C) frames; got shape {frames.shape}") |
| if frames.dtype != np.uint8: |
| frames = np.clip(frames, 0, 255).astype(np.uint8) |
| return [Image.fromarray(frames[i]) for i in range(frames.shape[0])] |
|
|
|
|
| def _video_to_numpy(video: Tensor, *, max_frames: int | None) -> np.ndarray: |
| """Convert one trajectory tensor to a ``(T, H, W, C) uint8`` numpy array.""" |
| if max_frames is not None: |
| video = video[-max_frames:] |
| if video.shape[1] in (1, 3): |
| video = video.permute(0, 2, 3, 1) |
| elif video.shape[-1] not in (1, 3): |
| raise ValueError(f"Expected channel dim of size 1 or 3, got shape {tuple(video.shape)}") |
|
|
| array = video.detach().cpu().numpy() |
| if np.issubdtype(array.dtype, np.floating) and array.size > 0 and array.max() <= 1.0: |
| array = array * 255.0 |
| return np.clip(array, 0, 255).astype(np.uint8) |
|
|
|
|
| def _expand_tasks(task: Any, *, batch_size: int, default: str | None) -> list[str]: |
| if task is None: |
| task = default |
| if task is None: |
| raise KeyError("Robometer expected a task description in complementary data") |
| if isinstance(task, str): |
| return [task] * batch_size |
| if isinstance(task, tuple): |
| task = list(task) |
| if not (isinstance(task, list) and all(isinstance(item, str) for item in task)): |
| raise TypeError(f"Robometer task must be a string or list of strings, got {type(task)}") |
| if len(task) == 1 and batch_size > 1: |
| return task * batch_size |
| if len(task) != batch_size: |
| raise ValueError(f"Expected {batch_size} tasks, got {len(task)}") |
| return task |
|
|
|
|
| @dataclass |
| @ProcessorStepRegistry.register(name="robometer_encoder") |
| class RobometerEncoderProcessorStep(ProcessorStep): |
| """Encode raw frames + task into Qwen-VL tensors for the Robometer model. |
| |
| Loads a :class:`~transformers.AutoProcessor` matching ``base_model_id`` and |
| registers Robometer's special tokens on the tokenizer. The matching |
| embedding resize happens model-side in |
| :meth:`RobometerRewardModel.__init__`. |
| |
| At call time the step reads: |
| |
| - ``observation[image_key]``: ``(B, T, C, H, W)`` or ``(B, C, H, W)`` frames. |
| - ``complementary_data[task_key]``: a string or list of strings. |
| |
| and writes ``observation[f"{ROBOMETER_FEATURE_PREFIX}<name>"]`` for: |
| |
| - the Qwen-VL processor outputs: ``input_ids``, ``attention_mask``, |
| ``pixel_values``, ``image_grid_thw``, ``video_grid_thw``, ... |
| - Robometer-specific token ids consumed by the model heads: |
| ``prog_token_id``, ``vision_start_token_id``, ``vision_end_token_id``, |
| ``video_merge_size``. |
| """ |
|
|
| base_model_id: str = "Qwen/Qwen3-VL-4B-Instruct" |
| image_key: str = OBS_IMAGES + ".top" |
| task_key: str = "task" |
| default_task: str | None = None |
| max_frames: int | None = 8 |
| use_multi_image: bool = True |
| use_per_frame_progress_token: bool = True |
| max_length: int = 1024 |
|
|
| _processor: Any = field(default=None, init=False, repr=False) |
|
|
| def __post_init__(self) -> None: |
| require_package("transformers", extra="robometer") |
| require_package("qwen-vl-utils", extra="robometer", import_name="qwen_vl_utils") |
|
|
| self._processor = AutoProcessor.from_pretrained( |
| self.base_model_id, |
| trust_remote_code=True, |
| do_sample_frames=False, |
| padding_side="right", |
| ) |
|
|
| |
| |
| tokenizer = self._processor.tokenizer |
| |
| |
| if tokenizer.pad_token is None: |
| tokenizer.pad_token = tokenizer.eos_token |
| for token in ROBOMETER_SPECIAL_TOKENS: |
| if token not in tokenizer.get_vocab(): |
| tokenizer.add_special_tokens({"additional_special_tokens": [token]}) |
|
|
| def __call__(self, transition: EnvTransition) -> EnvTransition: |
| observation = transition.get(TransitionKey.OBSERVATION) |
| complementary = transition.get(TransitionKey.COMPLEMENTARY_DATA) or {} |
| if not isinstance(observation, dict): |
| raise ValueError("RobometerEncoderProcessorStep requires an observation dict") |
|
|
| if self.image_key not in observation: |
| raise KeyError(f"Robometer expected image key {self.image_key!r} in observation") |
|
|
| frames = observation[self.image_key] |
| tensor = frames.detach().cpu() if isinstance(frames, Tensor) else torch.as_tensor(frames) |
| if tensor.ndim == 4: |
| tensor = tensor.unsqueeze(1) |
| elif tensor.ndim != 5: |
| raise ValueError( |
| f"Expected Robometer frames with shape (B,C,H,W) or (B,T,C,H,W); got {tuple(tensor.shape)}" |
| ) |
|
|
| batch_size = tensor.shape[0] |
| tasks = _expand_tasks( |
| complementary.get(self.task_key, self.default_task), |
| batch_size=batch_size, |
| default=self.default_task, |
| ) |
|
|
| samples = [ |
| (_video_to_numpy(tensor[i], max_frames=self.max_frames), tasks[i]) for i in range(batch_size) |
| ] |
| encoded = self.encode_samples(samples) |
|
|
| new_observation = dict(observation) |
| for key, value in encoded.items(): |
| new_observation[f"{ROBOMETER_FEATURE_PREFIX}{key}"] = value |
|
|
| new_transition = transition.copy() |
| new_transition[TransitionKey.OBSERVATION] = new_observation |
| return new_transition |
|
|
| def encode_samples(self, samples: list[tuple[np.ndarray, str]]) -> dict[str, Tensor]: |
| """Run the Qwen-VL processor on a list of ``(frames, task)`` samples.""" |
| from qwen_vl_utils import process_vision_info |
|
|
| conversations = [self._build_conversation(frames, task) for frames, task in samples] |
|
|
| texts = [ |
| self._processor.apply_chat_template( |
| msg, |
| tokenize=False, |
| add_generation_prompt=False, |
| add_vision_id=True, |
| enable_thinking=False, |
| fps=1, |
| ) |
| for msg in conversations |
| ] |
|
|
| process_kwargs: dict[str, Any] = { |
| "return_video_kwargs": True, |
| "return_video_metadata": True, |
| } |
| image_processor = getattr(self._processor, "image_processor", None) |
| if image_processor is not None and hasattr(image_processor, "patch_size"): |
| process_kwargs["image_patch_size"] = image_processor.patch_size |
|
|
| image_inputs, video_inputs, video_kwargs = process_vision_info(conversations, **process_kwargs) |
|
|
| videos: list[Any] | None = None |
| video_metadatas: list[Any] | None = None |
| if video_inputs: |
| if isinstance(video_inputs[0], tuple) and len(video_inputs[0]) == 2: |
| videos_seq, metadatas_seq = zip(*video_inputs, strict=False) |
| videos = list(videos_seq) |
| video_metadatas = list(metadatas_seq) |
| else: |
| videos = list(video_inputs) |
|
|
| processor_kwargs: dict[str, Any] = { |
| "text": texts, |
| "images": image_inputs, |
| "padding": True, |
| "truncation": False, |
| "max_length": self.max_length, |
| "return_tensors": "pt", |
| "do_resize": False, |
| } |
| if videos is not None: |
| processor_kwargs["videos"] = videos |
| if video_metadatas is not None: |
| processor_kwargs["video_metadata"] = video_metadatas |
| if video_kwargs: |
| processor_kwargs.update(video_kwargs) |
|
|
| encoded = self._processor(**processor_kwargs) |
|
|
| |
| |
| |
| |
| tokenizer = self._processor.tokenizer |
| encoded["prog_token_id"] = tokenizer.convert_tokens_to_ids("<|prog_token|>") |
| encoded["vision_start_token_id"] = tokenizer.convert_tokens_to_ids("<|vision_start|>") |
| encoded["vision_end_token_id"] = tokenizer.convert_tokens_to_ids("<|vision_end|>") |
| video_processor = getattr(self._processor, "video_processor", None) |
| encoded["video_merge_size"] = int(getattr(video_processor, "merge_size", 14)) |
| return encoded |
|
|
| def _build_conversation(self, frames: np.ndarray, task: str) -> list[dict[str, Any]]: |
| pil_frames = _frames_to_pil(frames) |
| prompt = PROGRESS_PROMPT.format(task=task) |
| content: list[dict[str, Any]] = [{"type": "text", "text": prompt}] |
|
|
| if self.use_multi_image: |
| for image in pil_frames: |
| content.append({"type": "image", "image": image}) |
| if self.use_per_frame_progress_token: |
| content.append({"type": "text", "text": "<|prog_token|>"}) |
| else: |
| content.append({"type": "video", "video": pil_frames, "sample_fps": 1.0}) |
|
|
| return [{"role": "user", "content": content}] |
|
|
| def transform_features( |
| self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]] |
| ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]: |
| return features |
|
|
| def get_config(self) -> dict[str, Any]: |
| return { |
| "base_model_id": self.base_model_id, |
| "image_key": self.image_key, |
| "task_key": self.task_key, |
| "default_task": self.default_task, |
| "max_frames": self.max_frames, |
| "use_multi_image": self.use_multi_image, |
| "use_per_frame_progress_token": self.use_per_frame_progress_token, |
| "max_length": self.max_length, |
| } |
|
|
|
|
| def make_robometer_pre_post_processors( |
| config: RobometerConfig, |
| dataset_stats: dict[str, dict[str, Any]] | None = None, |
| ) -> tuple[ |
| PolicyProcessorPipeline[dict[str, Any], dict[str, Any]], |
| PolicyProcessorPipeline[PolicyAction, PolicyAction], |
| ]: |
| """Pipeline that pre-encodes frames + task into Qwen-VL tensors. |
| |
| The preprocessor adds a batch dimension if needed, runs Robometer's |
| encoder, and moves everything to the configured device. The |
| postprocessor is the identity since Robometer outputs a single reward |
| tensor. |
| """ |
| del dataset_stats |
|
|
| preprocessor = PolicyProcessorPipeline[dict[str, Any], dict[str, Any]]( |
| steps=[ |
| AddBatchDimensionProcessorStep(), |
| RobometerEncoderProcessorStep( |
| base_model_id=config.base_model_id, |
| image_key=config.image_key, |
| task_key=config.task_key, |
| default_task=config.default_task, |
| max_frames=config.max_frames, |
| use_multi_image=config.use_multi_image, |
| use_per_frame_progress_token=config.use_per_frame_progress_token, |
| ), |
| DeviceProcessorStep(device=config.device or "cpu"), |
| ], |
| name=POLICY_PREPROCESSOR_DEFAULT_NAME, |
| ) |
| postprocessor = PolicyProcessorPipeline( |
| name=POLICY_POSTPROCESSOR_DEFAULT_NAME, |
| to_transition=policy_action_to_transition, |
| ) |
| return preprocessor, postprocessor |
|
|