Rate this Page

Source code for torchrl.data.vla.preprocessing

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
"""Image preprocessing helpers for Vision-Language-Action policies."""
from __future__ import annotations

import importlib.util
import io
from collections.abc import Sequence
from functools import lru_cache
from typing import Literal

import numpy as np
import torch
import torch.nn.functional as F

from torchrl._utils import implement_for, is_compiling

_has_pil = importlib.util.find_spec("PIL") is not None
_has_tensorflow = importlib.util.find_spec("tensorflow") is not None
_has_torchvision = importlib.util.find_spec("torchvision") is not None

__all__ = ["OpenVLAImagePreprocessor"]

_CROP_AREA_SCALE = 0.9
_CROP_LINEAR_SCALE = _CROP_AREA_SCALE**0.5
_LANCZOS_RADIUS = 3
_REFERENCE_RESIZE_BATCH_SIZE = 8


def _make_lanczos3_spans(
    input_size: int,
    output_size: int,
    device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor]:
    """Build the sampling spans used by TensorFlow's ScaleAndTranslate op."""
    scale = torch.tensor(
        output_size, dtype=torch.float32, device=device
    ) / torch.tensor(input_size, dtype=torch.float32, device=device)
    inv_scale = scale.reciprocal()
    kernel_scale = inv_scale.clamp_min(1.0)
    radius = torch.tensor(_LANCZOS_RADIUS, dtype=torch.float32, device=device)
    # ceil(3 * max(input_size / output_size, 1)) using integer arithmetic.
    # The extra sample on each side matches TensorFlow's span allocation.
    kernel_radius = max(
        (_LANCZOS_RADIUS * input_size + output_size - 1) // output_size,
        _LANCZOS_RADIUS,
    )
    span_size = min(
        2 * kernel_radius + 1,
        input_size,
    )
    samples = (
        torch.arange(output_size, dtype=torch.float32, device=device) + 0.5
    ) * inv_scale
    span_starts = torch.ceil(samples - radius * kernel_scale - 0.5)
    span_starts = span_starts.to(torch.long).clamp(0, input_size - 1)
    span_ends = torch.floor(samples + radius * kernel_scale - 0.5)
    span_ends = span_ends.to(torch.long).clamp(0, input_size - 1) + 1

    span_offsets = torch.arange(span_size, device=device)
    source_indices = span_starts.unsqueeze(1) + span_offsets
    valid = source_indices < span_ends.unsqueeze(1)
    indices = source_indices.masked_fill(~valid, 0)

    kernel_positions = (
        (source_indices.to(torch.float32) + 0.5 - samples.unsqueeze(1)) / kernel_scale
    ).abs()
    near_zero = kernel_positions <= 1e-3
    safe_positions = kernel_positions.masked_fill(near_zero, 1.0)
    pi = torch.tensor(3.14159265359, dtype=torch.float32, device=device)
    weights = (
        radius
        * torch.sin(pi * safe_positions)
        * torch.sin(pi * safe_positions / radius)
        / (pi * pi * safe_positions * safe_positions)
    )
    weights = torch.where(near_zero, 1.0, weights)
    weights = weights.masked_fill(~valid | (kernel_positions > radius), 0.0)
    total_weight = weights.sum(-1, keepdim=True)
    normalizable = total_weight.abs() >= 1000.0 * torch.finfo(torch.float32).tiny
    normalizer = torch.where(normalizable, total_weight.reciprocal(), 1.0)
    return indices, weights * normalizer


@lru_cache(maxsize=32)
def _cached_lanczos3_spans(
    input_size: int,
    output_size: int,
    device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor]:
    return _make_lanczos3_spans(input_size, output_size, device)


def _lanczos3_spans(
    input_size: int,
    output_size: int,
    device: torch.device | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
    if device is None:
        device = torch.device("cpu")
    if is_compiling():
        return _make_lanczos3_spans(input_size, output_size, device)
    return _cached_lanczos3_spans(input_size, output_size, device)


def _apply_spans(
    values: torch.Tensor, indices: torch.Tensor, weights: torch.Tensor
) -> torch.Tensor:
    output_size, span_size = indices.shape
    sampled = values.index_select(-1, indices.flatten()).reshape(
        *values.shape[:-1], output_size, span_size
    )
    weight_shape = (1,) * (values.ndim - 1) + (output_size, span_size)
    return (sampled * weights.reshape(weight_shape)).sum(-1)


def _lanczos3_resize(images: torch.Tensor, size: tuple[int, int]) -> torch.Tensor:
    """Resize NCHW float images with TensorFlow-compatible Lanczos3 spans."""
    output_height, output_width = size
    height_indices, height_weights = _lanczos3_spans(
        images.shape[-2], output_height, images.device
    )
    width_indices, width_weights = _lanczos3_spans(
        images.shape[-1], output_width, images.device
    )

    resized = _apply_spans(images, width_indices, width_weights)
    resized = _apply_spans(
        resized.transpose(-2, -1), height_indices, height_weights
    ).transpose(-2, -1)
    return resized


def _make_crop_axis(
    input_size: int, output_size: int, device: torch.device
) -> tuple[torch.Tensor, ...]:
    crop_size = torch.tensor(
        _CROP_AREA_SCALE, dtype=torch.float32, device=device
    ).sqrt()
    offset = (1.0 - crop_size) / 2.0
    end = offset + crop_size
    if output_size > 1:
        scale = (end - offset) * (input_size - 1) / (output_size - 1)
        coordinates = (
            offset * (input_size - 1)
            + torch.arange(output_size, dtype=torch.float32, device=device) * scale
        )
    else:
        coordinates = (0.5 * (offset + end) * (input_size - 1)).unsqueeze(0)
    lower = coordinates.floor().to(torch.long)
    upper = coordinates.ceil().to(torch.long)
    return lower, upper, coordinates - lower.to(torch.float32)


@lru_cache(maxsize=32)
def _cached_crop_axis(
    input_size: int, output_size: int, device: torch.device
) -> tuple[torch.Tensor, ...]:
    return _make_crop_axis(input_size, output_size, device)


def _crop_axis(
    input_size: int,
    output_size: int,
    device: torch.device | None = None,
) -> tuple[torch.Tensor, ...]:
    if device is None:
        device = torch.device("cpu")
    if is_compiling():
        return _make_crop_axis(input_size, output_size, device)
    return _cached_crop_axis(input_size, output_size, device)


def _fractional_center_crop(
    images: torch.Tensor, size: tuple[int, int]
) -> torch.Tensor:
    """Apply TensorFlow CropAndResize's 0.9-area centered bilinear crop."""
    output_height, output_width = size
    top, bottom, y_lerp = _crop_axis(images.shape[-2], output_height, images.device)
    left, right, x_lerp = _crop_axis(images.shape[-1], output_width, images.device)

    values = images.to(torch.float32).div(255.0)
    left_values = values.index_select(-1, left)
    right_values = values.index_select(-1, right)
    x_lerp = x_lerp.reshape((1,) * (values.ndim - 1) + (output_width,))
    values = left_values + (right_values - left_values) * x_lerp

    top_values = values.index_select(-2, top)
    bottom_values = values.index_select(-2, bottom)
    y_lerp = y_lerp.reshape((1,) * (values.ndim - 2) + (output_height, 1))
    values = top_values + (bottom_values - top_values) * y_lerp
    # TensorFlow's float-to-uint8 conversion multiplies by max + 0.5 before
    # truncating, rather than rounding after multiplying by max.
    return values.mul_(255.5).clamp_(0, 255).to(torch.uint8)


@implement_for("torchvision", None, "0.20")
def _torchvision_jpeg_roundtrip(
    images: torch.Tensor, jpeg_quality: int, device: torch.device
) -> torch.Tensor:
    from torchvision.io import decode_jpeg, encode_jpeg, ImageReadMode

    encoded = [
        encode_jpeg(image.cpu(), quality=jpeg_quality) for image in images.unbind(0)
    ]
    decoded = [
        decode_jpeg(image, mode=ImageReadMode.RGB, device=device) for image in encoded
    ]
    return torch.stack(decoded, 0)


@implement_for("torchvision", "0.20")
def _torchvision_jpeg_roundtrip(  # noqa: F811
    images: torch.Tensor, jpeg_quality: int, device: torch.device
) -> torch.Tensor:
    from torchvision.io import decode_jpeg, encode_jpeg, ImageReadMode

    encoded = encode_jpeg(list(images.unbind(0)), quality=jpeg_quality)
    decoded = decode_jpeg(encoded, mode=ImageReadMode.RGB, device=device)
    if isinstance(decoded, list):
        decoded = torch.stack(decoded, 0)
    return decoded


[docs] class OpenVLAImagePreprocessor: """OpenVLA-style image resize, JPEG round-trip and optional center crop. The ``"tensorflow"`` backend mirrors the OpenVLA-OFT evaluation path: JPEG encode/decode at the requested quality, resize with Lanczos3, optionally apply a 0.9-area center crop, and resize back. The ``"torch_reference"`` backend follows the same order and interpolation semantics using PyTorch and ``torchvision`` only, and is the default. The ``"torchvision"`` backend keeps data as tensors but uses a faster bicubic path; ``"pil"`` is a lightweight debugging backend. Args: size (int): Square output size. Defaults to ``224``. jpeg_quality (int): JPEG quality. Defaults to ``95``. center_crop (bool): Whether to apply the OpenVLA 0.9-area center crop. Defaults to ``False``. backend (str): ``"torchvision"``, ``"torch_reference"``, ``"pil"`` or ``"tensorflow"``. Defaults to ``"torch_reference"``. mean (torch.Tensor | sequence, optional): Per-channel normalization mean. A two-dimensional sequence applies multiple normalizations to the same image and concatenates the results along the channel axis, as required by fused OpenVLA vision backbones. std (torch.Tensor | sequence, optional): Per-channel normalization std. .. note:: Floating-point inputs are ambiguous: this helper treats float images with maximum value at most ``1`` as normalized ``[0, 1]`` data and rescales them to uint8; other float images are interpreted as ``[0, 255]`` data. Examples: >>> import torch >>> from torchrl.data.vla import OpenVLAImagePreprocessor >>> proc = OpenVLAImagePreprocessor(backend="pil") >>> out = proc(torch.zeros(2, 3, 32, 32, dtype=torch.uint8)) >>> out.shape torch.Size([2, 3, 224, 224]) """ def __init__( self, *, size: int = 224, jpeg_quality: int = 95, center_crop: bool = False, backend: Literal[ "torchvision", "torch_reference", "pil", "tensorflow" ] = "torch_reference", mean: torch.Tensor | Sequence[float] | Sequence[Sequence[float]] | None = None, std: torch.Tensor | Sequence[float] | Sequence[Sequence[float]] | None = None, ) -> None: if size < 1: raise ValueError(f"size must be >= 1, got {size}.") if not 1 <= jpeg_quality <= 100: raise ValueError( f"jpeg_quality must be between 1 and 100, got {jpeg_quality}." ) if backend not in ("torchvision", "torch_reference", "pil", "tensorflow"): raise ValueError( "backend must be 'torchvision', 'torch_reference', 'pil' or " f"'tensorflow', got {backend!r}." ) if backend in ("torchvision", "torch_reference") and not _has_torchvision: raise ImportError( f"OpenVLAImagePreprocessor backend={backend!r} requires torchvision. " "Install the TorchRL vla extra or pass backend='pil'." ) if backend == "pil" and not _has_pil: raise ImportError("OpenVLAImagePreprocessor backend='pil' requires Pillow.") if backend == "tensorflow" and not _has_tensorflow: raise ImportError( "OpenVLAImagePreprocessor backend='tensorflow' requires TensorFlow." ) if (mean is None) != (std is None): raise ValueError("mean and std must be provided together.") self.size = int(size) self.jpeg_quality = int(jpeg_quality) self.center_crop = bool(center_crop) self.backend = backend self.mean = None if mean is None else torch.as_tensor(mean, dtype=torch.float32) self.std = None if std is None else torch.as_tensor(std, dtype=torch.float32) if self.mean is not None: if self.mean.shape != self.std.shape: raise ValueError( "mean and std must have matching shapes, got " f"{tuple(self.mean.shape)} and {tuple(self.std.shape)}." ) if self.mean.ndim not in (1, 2) or self.mean.shape[-1] != 3: raise ValueError( "mean and std must have shape [3] or [N, 3], got " f"{tuple(self.mean.shape)}." ) def __call__(self, images: torch.Tensor) -> torch.Tensor: """Preprocess one image ``[C, H, W]`` or a batch ``[..., C, H, W]``.""" if images.ndim < 3: raise ValueError( f"images must have shape [..., C, H, W], got {tuple(images.shape)}." ) if images.shape[-3] not in (1, 3): raise ValueError( "images must have one or three channels in the third-to-last " f"dimension, got shape {tuple(images.shape)}." ) original_shape = images.shape[:-3] flat = images.reshape(-1, *images.shape[-3:]) flat = self._to_uint8(flat) if self.backend == "torchvision": processed = self._torchvision(flat) elif self.backend == "torch_reference": processed = self._torch_reference(flat) elif self.backend == "tensorflow": processed = self._tensorflow(flat) else: processed = self._pil(flat) processed = processed.reshape(*original_shape, *processed.shape[-3:]) if self.mean is not None: processed = processed.to(torch.float32).div_(255.0) means = self.mean.reshape(-1, self.mean.shape[-1]).to( device=processed.device, dtype=processed.dtype ) stds = self.std.reshape(-1, self.std.shape[-1]).to( device=processed.device, dtype=processed.dtype ) view_shape = *((1,) * (processed.ndim - 3)), -1, 1, 1 processed = torch.cat( [ processed.sub(mean.view(view_shape)).div(std.view(view_shape)) for mean, std in zip(means, stds, strict=True) ], dim=-3, ) return processed @staticmethod def _to_uint8(images: torch.Tensor) -> torch.Tensor: if images.dtype == torch.uint8: return images.contiguous() if images.is_floating_point(): scale = 1.0 # Float inputs are accepted either as normalized [0, 1] images or # as [0, 255] images; use the max value to disambiguate. if images.numel() and float(images.detach().max()) <= 1.0: scale = 255.0 return images.mul(scale).clamp(0, 255).to(torch.uint8).contiguous() return images.clamp(0, 255).to(torch.uint8).contiguous() def _resize(self, images: torch.Tensor, size: int | None = None) -> torch.Tensor: size = self.size if size is None else int(size) if images.shape[-2:] == (size, size): return images resized = F.interpolate( images.to(torch.float32), size=(size, size), mode="bicubic", align_corners=False, antialias=True, ) return resized.round_().clamp_(0, 255).to(torch.uint8) def _center_crop(self, images: torch.Tensor) -> torch.Tensor: if not self.center_crop: return images height, width = images.shape[-2:] crop_h = int(round(height * _CROP_LINEAR_SCALE)) crop_w = int(round(width * _CROP_LINEAR_SCALE)) top = (height - crop_h) // 2 left = (width - crop_w) // 2 return images[..., top : top + crop_h, left : left + crop_w] def _torchvision(self, images: torch.Tensor) -> torch.Tensor: device = images.device images = self._resize(images) decoded = _torchvision_jpeg_roundtrip(images, self.jpeg_quality, device) decoded = self._center_crop(decoded) decoded = self._resize(decoded) return decoded def _torch_reference(self, images: torch.Tensor) -> torch.Tensor: device = images.device # torchvision's reference JPEG codec is CPU-only. Keep the subsequent # interpolation on its decoded CPU output and restore the caller's # device only after preprocessing is complete. images = images.cpu() if images.shape[1] == 1: images = images.expand(-1, 3, -1, -1).contiguous() decoded = _torchvision_jpeg_roundtrip( images, self.jpeg_quality, torch.device("cpu") ) resized = [] for chunk in decoded.split(_REFERENCE_RESIZE_BATCH_SIZE): chunk = _lanczos3_resize(chunk.to(torch.float32), (self.size, self.size)) resized.append(chunk.round_().clamp_(0, 255).to(torch.uint8)) decoded = torch.cat(resized, 0) if self.center_crop: decoded = _fractional_center_crop(decoded, (self.size, self.size)) return decoded.to(device) def _tensorflow(self, images: torch.Tensor) -> torch.Tensor: import tensorflow as tf out = [] resize_size = (self.size, self.size) for image in images.cpu(): array = image.permute(1, 2, 0).numpy().astype("uint8") if array.shape[-1] == 1: array = np.repeat(array, 3, axis=-1) tf_image = tf.convert_to_tensor(array) tf_image = tf.image.encode_jpeg(tf_image, quality=self.jpeg_quality) tf_image = tf.io.decode_image( tf_image, expand_animations=False, dtype=tf.uint8 ) tf_image = tf.image.resize( tf_image, resize_size, method="lanczos3", antialias=True ) tf_image = tf.cast(tf.clip_by_value(tf.round(tf_image), 0, 255), tf.uint8) if self.center_crop: expanded = tf.expand_dims( tf.image.convert_image_dtype(tf_image, tf.float32), axis=0 ) crop_size = tf.reshape( tf.clip_by_value(tf.sqrt(tf.constant(_CROP_AREA_SCALE)), 0, 1), shape=(1,), ) offsets = (1 - crop_size) / 2 boxes = tf.stack( [ offsets, offsets, offsets + crop_size, offsets + crop_size, ], axis=1, ) tf_image = tf.image.crop_and_resize( expanded, boxes, tf.range(1), resize_size )[0] tf_image = tf.clip_by_value(tf_image, 0, 1) tf_image = tf.image.convert_image_dtype( tf_image, tf.uint8, saturate=True ) out.append( torch.from_numpy( np.asarray(tf_image.numpy(), dtype="uint8").copy() ).permute(2, 0, 1) ) return torch.stack(out, 0).to(images.device) def _pil(self, images: torch.Tensor) -> torch.Tensor: from PIL import Image out = [] for image in images.cpu(): array = image.permute(1, 2, 0).numpy().astype("uint8") pil = Image.fromarray(array.squeeze(-1) if array.shape[-1] == 1 else array) pil = pil.convert("RGB") if pil.size != (self.size, self.size): pil = pil.resize((self.size, self.size), Image.LANCZOS) buffer = io.BytesIO() pil.save(buffer, format="JPEG", quality=self.jpeg_quality) buffer.seek(0) pil = Image.open(buffer).convert("RGB") if self.center_crop: width, height = pil.size crop_w = int(round(width * _CROP_LINEAR_SCALE)) crop_h = int(round(height * _CROP_LINEAR_SCALE)) left = (width - crop_w) // 2 top = (height - crop_h) // 2 pil = pil.crop((left, top, left + crop_w, top + crop_h)).resize( (width, height), Image.LANCZOS ) out.append( torch.from_numpy(np.asarray(pil, dtype="uint8").copy()).permute(2, 0, 1) ) return torch.stack(out, 0).to(images.device)