| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | from io import BytesIO |
| 5 | from pathlib import Path |
| 6 | |
| 7 | import numpy as np |
| 8 | import pybase64 |
| 9 | import torch |
| 10 | from PIL import Image |
| 11 | |
| 12 | import vllm.envs as envs |
| 13 | from vllm.utils.serial_utils import tensor2base64 |
| 14 | from vllm.utils.sparse_utils import ( |
| 15 | check_sparse_tensor_invariants_threadsafe, |
| 16 | safe_to_dense, |
| 17 | ) |
| 18 | |
| 19 | from ..image import convert_image_mode, normalize_image, rgba_to_rgb |
| 20 | from .base import MediaIO, MediaWithBytes |
| 21 | |
| 22 | MAGIC_NUMPY_PREFIX = b"\x93NUMPY" # https://numpy.org/devdocs/reference/generated/numpy.lib.format.html#format-version-1-0 |
| 23 | |
| 24 | |
| 25 | class ImageMediaIO(MediaIO[Image.Image]): |
| 26 | """Configuration values can be user-provided either by --media-io-kwargs or |
| 27 | by the runtime API field "media_io_kwargs". Ensure proper validation and |
| 28 | error handling. |
| 29 | """ |
| 30 | |
| 31 | def __init__(self, image_mode: str | None = "RGB", **kwargs) -> None: |
| 32 | super().__init__() |
| 33 | |
| 34 | # Target mode for loaded images; `None` keeps the original mode |
| 35 | # (i.e. no conversion, alpha channel is preserved as-is). |
| 36 | self.image_mode = image_mode |
| 37 | # `kwargs` contains custom arguments from |
| 38 | # --media-io-kwargs for this modality, merged with |
| 39 | # per-request runtime media_io_kwargs via merge_kwargs(). |
| 40 | # They can be passed to the underlying |
| 41 | # media loaders (e.g. custom implementations) |
| 42 | # for flexible control. |
| 43 | self.kwargs = kwargs |
| 44 | |
| 45 | # Extract RGBA background color from kwargs if provided |
| 46 | # Default to white background for backward compatibility |
| 47 | rgba_bg = kwargs.get("rgba_background_color", (255, 255, 255)) |
| 48 | # Convert list to tuple for consistency |
| 49 | if isinstance(rgba_bg, list): |
| 50 | rgba_bg = tuple(rgba_bg) |
| 51 | |
| 52 | # Validate rgba_background_color format |
| 53 | if not ( |
| 54 | isinstance(rgba_bg, tuple) |
| 55 | and len(rgba_bg) == 3 |
| 56 | and all(isinstance(c, int) and 0 <= c <= 255 for c in rgba_bg) |
| 57 | ): |
| 58 | raise ValueError( |
| 59 | "rgba_background_color must be a list or tuple of 3 integers " |
| 60 | "in the range [0, 255]." |
| 61 | ) |
| 62 | self.rgba_background_color = rgba_bg |
| 63 | |
| 64 | def _convert_image_mode( |
| 65 | self, image: Image.Image | MediaWithBytes[Image.Image] |
| 66 | ) -> Image.Image: |
| 67 | """Convert image mode with custom background color.""" |
| 68 | if isinstance(image, MediaWithBytes): |
| 69 | image = image.media |
| 70 | if self.image_mode is None or image.mode == self.image_mode: |
| 71 | return image |
| 72 | elif image.mode == "RGBA" and self.image_mode == "RGB": |
| 73 | return rgba_to_rgb(image, self.rgba_background_color) |
| 74 | else: |
| 75 | return convert_image_mode( |
| 76 | image, self.image_mode, self.rgba_background_color |
| 77 | ) |
| 78 | |
| 79 | def load_bytes(self, data: bytes) -> MediaWithBytes[Image.Image]: |
| 80 | try: |
| 81 | image = Image.open(BytesIO(data)) |
| 82 | w, h = image.size |
| 83 | max_pixels = envs.VLLM_MAX_IMAGE_PIXELS |
| 84 | if max_pixels > 0 and w * h > max_pixels: |
| 85 | raise ValueError( |
| 86 | f"Image dimensions {w}x{h} ({w * h} pixels) exceed " |
| 87 | f"the maximum of {max_pixels} pixels. Set " |
| 88 | f"VLLM_MAX_IMAGE_PIXELS to increase this limit." |
| 89 | ) |
| 90 | image = normalize_image(image) |
| 91 | image.load() |
| 92 | converted = self._convert_image_mode(image) |
| 93 | except (OSError, Image.UnidentifiedImageError) as e: |
| 94 | raise ValueError(f"Failed to load image: {e}") from e |
| 95 | |
| 96 | io_config = None |
| 97 | if converted is not image: |
| 98 | io_config = { |
| 99 | "image_mode": self.image_mode, |
| 100 | "rgba_background_color": self.rgba_background_color, |
| 101 | } |
| 102 | return MediaWithBytes(converted, data, io_config) |
| 103 | |
| 104 | def load_base64(self, media_type: str, data: str) -> MediaWithBytes[Image.Image]: |
| 105 | return self.load_bytes(pybase64.b64decode(data, validate=True)) |
| 106 | |
| 107 | def load_file(self, filepath: Path) -> MediaWithBytes[Image.Image]: |
| 108 | return self.load_bytes(filepath.read_bytes()) |
| 109 | |
| 110 | def encode_base64( |
| 111 | self, |
| 112 | media: Image.Image, |
| 113 | *, |
| 114 | image_format: str = "PNG", |
| 115 | ) -> str: |
| 116 | image = media |
| 117 | |
| 118 | with BytesIO() as buffer: |
| 119 | image = self._convert_image_mode(image) |
| 120 | image.save(buffer, image_format) |
| 121 | data = buffer.getvalue() |
| 122 | |
| 123 | return pybase64.b64encode(data).decode("utf-8") |
| 124 | |
| 125 | |
| 126 | class ImageEmbeddingMediaIO(MediaIO[torch.Tensor]): |
| 127 | """Image embedding MediaIO implementation. |
| 128 | |
| 129 | Configuration values can be user-provided either by --media-io-kwargs or |
| 130 | by the runtime API field "media_io_kwargs". Ensure proper validation and |
| 131 | error handling. |
| 132 | """ |
| 133 | |
| 134 | def __init__(self) -> None: |
| 135 | super().__init__() |
| 136 | |
| 137 | def _load_pickled_torch(self, data: bytes) -> torch.Tensor: |
| 138 | buffer = BytesIO(data) |
| 139 | with check_sparse_tensor_invariants_threadsafe(): |
| 140 | tensor = torch.load(buffer, weights_only=True) |
| 141 | return safe_to_dense(tensor, parameter="image_embeds") |
| 142 | |
| 143 | def _load_numpy(self, data: bytes) -> torch.Tensor: |
| 144 | with BytesIO(data) as buffer: |
| 145 | return torch.from_numpy(np.load(buffer)) |
| 146 | |
| 147 | def load_bytes(self, data: bytes) -> torch.Tensor: |
| 148 | if data[:6] == MAGIC_NUMPY_PREFIX: |
| 149 | return self._load_numpy(data) |
| 150 | |
| 151 | return self._load_pickled_torch(data) |
| 152 | |
| 153 | def load_base64(self, media_type: str, data: str) -> torch.Tensor: |
| 154 | return self.load_bytes(pybase64.b64decode(data, validate=True)) |
| 155 | |
| 156 | def load_file(self, filepath: Path) -> torch.Tensor: |
| 157 | if filepath.suffix == ".npy": |
| 158 | return torch.from_numpy(np.load(filepath)) |
| 159 | |
| 160 | with check_sparse_tensor_invariants_threadsafe(): |
| 161 | tensor = torch.load(filepath, weights_only=True) |
| 162 | return safe_to_dense(tensor, parameter="image_embeds") |
| 163 | |
| 164 | def encode_base64(self, media: torch.Tensor) -> str: |
| 165 | return tensor2base64(media) |