Files
vllm-omni/vllm_omni/diffusion/models/pi0/processor_pi0.py
T

222 lines
9.4 KiB
Python
Raw Normal View History

2026-08-16 23:29:17 -07:00
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Preprocessing for the π0 VLA model.
Converts a raw robot observation (multi-camera images + language instruction +
proprioceptive state) into the tensors that ``Pi0ForActionPrediction.sample_actions``
consumes:
* images : list of ``(1, 3, 224, 224)`` float tensors in ``[-1, 1]``,
one per camera slot (real cameras first, then ``-1``-filled
padding) in the checkpoint's ``image_feature_keys`` order
* image_masks : list of ``(1,)`` bool tensors, ``True`` = real camera
* lang_tokens : ``(1, L)`` long, PaliGemma tokenizer, ``\\n``-terminated
* lang_masks : ``(1, L)`` bool
* state : ``(1, max_state_dim)`` float32, zero-padded
Image/prompt preprocessing matches OpenPI and LeRobot bit-for-bit — see the
parity test (``test_pi0_parity.py``). A set of stateless helpers the diffusion
pipeline calls directly (the DreamZero contract: the pipeline owns its
preprocessing).
Reference:
- OpenPI: openpi/src/openpi/shared/image_tools.py (resize_with_pad)
- OpenPI: openpi/src/openpi/models_pytorch/preprocessing_pytorch.py
- LeRobot: lerobot/src/lerobot/policies/pi0/modeling_pi0.py (Pi0NewLineProcessor)
"""
from __future__ import annotations
import logging
from typing import Any
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image
logger = logging.getLogger(__name__)
# Defaults straight from the π0 reference configs.
PI0_IMAGE_SIZE = 224 # openpi/models/model.py IMAGE_RESOLUTION
PI0_NUM_IMAGE_TOKENS = 256 # SigLIP So400m/14 on 224×224 → (224/14)**2
PI0_IMAGE_TOKEN_INDEX = 257152 # openpi/models_pytorch/gemma_pytorch.py
PI0_MAX_CAMERAS = 3 # openpi/models/model.py (3 camera slots)
PI0_MAX_TOKEN_LEN = 48 # openpi/models/pi0_config.py
def resize_with_pad(
images: torch.Tensor,
target_height: int,
target_width: int,
mode: str = "bilinear",
) -> torch.Tensor:
"""Resize ``(B, C, H, W)`` images to the target shape, preserving aspect
ratio with -1 padding on the short side.
Matches openpi ``image_tools.resize_with_pad_torch`` — the clamp to
[-1, 1] is what lets the padded region blend with SigLIP-normalized
pixels without adding signal at the boundary.
"""
if images.ndim != 4:
raise ValueError(f"Expected 4-D (B,C,H,W), got {images.ndim}-D")
_, _, cur_h, cur_w = images.shape
ratio = max(cur_w / target_width, cur_h / target_height)
rh, rw = int(cur_h / ratio), int(cur_w / ratio)
align_corners = False if mode == "bilinear" else None
resized = F.interpolate(images, size=(rh, rw), mode=mode, align_corners=align_corners)
resized = resized.clamp(-1.0, 1.0)
ph, rem_h = divmod(target_height - rh, 2)
pw, rem_w = divmod(target_width - rw, 2)
return F.pad(resized, (pw, pw + rem_w, ph, ph + rem_h), value=-1.0)
def pil_image_to_tensor(image: Image.Image) -> torch.Tensor:
"""PIL → ``(1, C, H, W)`` float32 in ``[-1, 1]`` (SigLIP normalization)."""
if image.mode != "RGB":
image = image.convert("RGB")
arr = np.array(image, dtype=np.float32) / 255.0 * 2.0 - 1.0
return torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0)
class Pi0ImageProcessor:
"""Minimal image preprocessor: image → normalized + padded ``[-1,1]`` tensor."""
def __init__(self, image_size: int = PI0_IMAGE_SIZE):
self.image_size = image_size
def preprocess_single(self, image: Any) -> torch.Tensor:
"""Accept a PIL image, an HWC uint8/float ndarray, or a CHW tensor and
return a ``(1, 3, image_size, image_size)`` float tensor in ``[-1, 1]``."""
t = self._to_tensor(image)
if t.shape[2] != self.image_size or t.shape[3] != self.image_size:
t = resize_with_pad(t, self.image_size, self.image_size)
return t
def _to_tensor(self, image: Any) -> torch.Tensor:
if isinstance(image, Image.Image):
return pil_image_to_tensor(image)
if isinstance(image, np.ndarray):
arr = image
# HWC → normalize uint8/[0,255] to [-1,1]; assume already [-1,1] if float.
if arr.ndim == 3 and arr.shape[-1] in (1, 3):
if np.issubdtype(arr.dtype, np.integer) or arr.max() > 1.0:
arr = arr.astype(np.float32) / 255.0 * 2.0 - 1.0
else:
arr = arr.astype(np.float32)
return torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0)
return torch.as_tensor(arr, dtype=torch.float32)
if isinstance(image, torch.Tensor):
t = image
if t.ndim == 3: # (C, H, W) → (1, C, H, W)
t = t.unsqueeze(0)
return t.to(dtype=torch.float32)
raise TypeError(f"Unsupported image type for π0 preprocessing: {type(image)}")
def make_empty_image(self) -> torch.Tensor:
"""Fill tensor for an unused camera slot — pure -1, matches OpenPI/LeRobot."""
return torch.full((1, 3, self.image_size, self.image_size), -1.0)
def tokenize_prompt(tokenizer, text: str, max_token_len: int = PI0_MAX_TOKEN_LEN):
"""Return ``(input_ids, attention_mask)`` lists, length ``max_token_len``.
Matches LeRobot's ``Pi0NewLineProcessor``: append ``\\n`` if the caller
didn't, then run the PaliGemma tokenizer with right-padding.
"""
prompt = text or ""
if not prompt.endswith("\n"):
prompt = prompt + "\n"
enc = tokenizer(
prompt,
padding="max_length",
max_length=max_token_len,
truncation=True,
add_special_tokens=True,
return_tensors=None,
)
return list(enc["input_ids"]), list(enc["attention_mask"])
def _extract_images(robot_obs: dict, config) -> dict[str, Any]:
"""Pull a ``{feature_key: image}`` map out of a raw robot obs.
Accepts two shapes:
* ``robot_obs["images"]`` is a dict keyed by camera name; or
* flat ``observation/<cam>`` style keys on ``robot_obs`` itself.
Keys are translated through ``config.image_key_map`` (raw obs key →
checkpoint feature key) so serving wire names map onto ``input_features``.
"""
images = robot_obs.get("images")
if not isinstance(images, dict):
# Fall back to flat keys (anything that isn't a known scalar field).
images = {k: v for k, v in robot_obs.items() if k not in ("state", "prompt", "session_id", "reset", "images")}
key_map = getattr(config, "image_key_map", None) or {}
return {key_map.get(k, k): v for k, v in images.items()}
def build_model_inputs(robot_obs: dict, config, tokenizer, device: torch.device):
"""Convert a raw robot observation into ``sample_actions`` inputs.
Reproduces the LeRobot camera ordering exactly: iterate
``config.image_feature_keys`` (the ordered camera identities from the
checkpoint's ``input_features``); for each, use the supplied image (mask
``True``) or a ``-1``-filled empty image (mask ``False``). Then tokenize
the prompt and zero-pad/truncate the state to ``max_state_dim``.
Returns ``(images, image_masks, lang_tokens, lang_masks, state)`` with a
leading batch dim of 1 on the tensors.
"""
image_size = int(config.image_resolution[0])
img_proc = Pi0ImageProcessor(image_size=image_size)
feature_keys = config.image_feature_keys or []
if not feature_keys:
# No declared camera order — fall back to whatever the obs provides,
# capped at max_cameras (preserves insertion order).
provided = _extract_images(robot_obs, config)
feature_keys = list(provided.keys())[: config.max_cameras]
obs_images = _extract_images(robot_obs, config)
images: list[torch.Tensor] = []
image_masks: list[torch.Tensor] = []
for key in feature_keys[: config.max_cameras]:
img = obs_images.get(key)
if img is not None:
tensor = img_proc.preprocess_single(img).to(device=device)
mask = True
else:
tensor = img_proc.make_empty_image().to(device=device)
mask = False
images.append(tensor)
image_masks.append(torch.tensor([mask], dtype=torch.bool, device=device))
# Pad up to max_cameras with empty slots if the checkpoint declares fewer
# cameras than the model attends to.
while len(images) < config.max_cameras:
images.append(img_proc.make_empty_image().to(device=device))
image_masks.append(torch.tensor([False], dtype=torch.bool, device=device))
prompt = robot_obs.get("prompt", "") or ""
ids, attn = tokenize_prompt(tokenizer, prompt, config.tokenizer_max_length)
lang_tokens = torch.tensor([ids], dtype=torch.long, device=device)
lang_masks = torch.tensor([attn], dtype=torch.bool, device=device)
state = _build_state(robot_obs.get("state"), config.max_state_dim, device)
return images, image_masks, lang_tokens, lang_masks, state
def _build_state(raw_state, max_state_dim: int, device: torch.device) -> torch.Tensor:
"""Zero-pad / truncate the raw state to ``(1, max_state_dim)`` float32."""
if raw_state is None:
return torch.zeros(1, max_state_dim, dtype=torch.float32, device=device)
state = torch.as_tensor(raw_state, dtype=torch.float32, device=device)
if state.dim() == 1:
state = state[None, :]
if state.shape[-1] < max_state_dim:
state = F.pad(state, (0, max_state_dim - state.shape[-1]))
elif state.shape[-1] > max_state_dim:
state = state[..., :max_state_dim]
return state