| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Inference-only LLaVA-OneVision-2 (OV2) model for vLLM. |
| 4 | |
| 5 | Architecture notes: |
| 6 | |
| 7 | * LLM backbone is plain Qwen3-8B with 1-D position_ids (no M-RoPE). |
| 8 | * Vision tower removes the CLS token (no class_embedding/class_pos_emb). |
| 9 | * Vision RoPE is 3-D (T:H:W) with a 4:6:6 head_dim split and uses |
| 10 | ``patch_positions`` instead of grid_thw to compute per-token freqs. |
| 11 | * ``rotate_half`` is *interleaved* (``(::2, 1::2)``) rather than |
| 12 | split-half. |
| 13 | * Vision attention uses windowed ``cu_seqlens`` (``frame_windows_size`` |
| 14 | in T-dim); two backends implemented (SDPA + flash_attn varlen). |
| 15 | * ``patch_positions: [total_patches, 3]`` is plumbed as a first-class |
| 16 | MM kwarg alongside ``pixel_values`` / ``image_grid_thw``. |
| 17 | * Video frame-backend and codec-backend both alias to the image path |
| 18 | inside the HF processor, so the model implements a single visual |
| 19 | code path. |
| 20 | """ |
| 21 | |
| 22 | from __future__ import annotations |
| 23 | |
| 24 | import hashlib |
| 25 | import importlib |
| 26 | from collections.abc import Callable, Iterable, Mapping, Sequence, Sized |
| 27 | from functools import lru_cache |
| 28 | from typing import ( |
| 29 | Annotated, |
| 30 | Any, |
| 31 | Literal, |
| 32 | SupportsIndex, |
| 33 | SupportsInt, |
| 34 | TypedDict, |
| 35 | ) |
| 36 | |
| 37 | import numpy as np |
| 38 | import regex as re |
| 39 | import torch |
| 40 | import torch.nn as nn |
| 41 | import torch.nn.functional as F |
| 42 | from PIL import Image |
| 43 | from transformers import AutoProcessor, AutoTokenizer, BatchFeature |
| 44 | from transformers.dynamic_module_utils import ( |
| 45 | get_class_from_dynamic_module, |
| 46 | resolve_trust_remote_code, |
| 47 | ) |
| 48 | from transformers.models.qwen2_vl import Qwen2VLImageProcessor |
| 49 | from transformers.models.qwen2_vl.image_processing_qwen2_vl import smart_resize |
| 50 | from typing_extensions import Buffer |
| 51 | |
| 52 | from vllm.compilation.decorators import ( |
| 53 | should_torch_compile_mm_encoder, |
| 54 | support_torch_compile, |
| 55 | ) |
| 56 | from vllm.config import VllmConfig |
| 57 | from vllm.config.multimodal import MultiModalDummyOptions |
| 58 | from vllm.distributed import parallel_state |
| 59 | from vllm.distributed import utils as dist_utils |
| 60 | from vllm.inputs import ModalityData, MultiModalDataDict |
| 61 | from vllm.logger import init_logger |
| 62 | from vllm.model_executor.layers.attention import MMEncoderAttention |
| 63 | from vllm.model_executor.layers.linear import ( |
| 64 | ColumnParallelLinear, |
| 65 | QKVParallelLinear, |
| 66 | RowParallelLinear, |
| 67 | ) |
| 68 | from vllm.model_executor.layers.quantization import QuantizationConfig |
| 69 | from vllm.model_executor.models.interfaces import ( |
| 70 | MultiModalEmbeddings, |
| 71 | SupportsMultiModal, |
| 72 | SupportsPP, |
| 73 | ) |
| 74 | from vllm.model_executor.models.module_mapping import MultiModelKeys |
| 75 | from vllm.model_executor.models.utils import ( |
| 76 | AutoWeightsLoader, |
| 77 | WeightsMapper, |
| 78 | init_vllm_registered_model, |
| 79 | maybe_prefix, |
| 80 | ) |
| 81 | from vllm.model_executor.models.utils import ( |
| 82 | _merge_multimodal_embeddings as merge_multimodal_embeddings, |
| 83 | ) |
| 84 | from vllm.model_executor.models.vision import get_vit_attn_backend |
| 85 | from vllm.multimodal import MULTIMODAL_REGISTRY |
| 86 | from vllm.multimodal.inputs import ( |
| 87 | ImageItem, |
| 88 | MultiModalFieldConfig, |
| 89 | MultiModalKwargsItems, |
| 90 | ) |
| 91 | from vllm.multimodal.parse import ( |
| 92 | DictEmbeddingItems, |
| 93 | ImageSize, |
| 94 | ModalityDataItems, |
| 95 | MultiModalDataItems, |
| 96 | MultiModalDataParser, |
| 97 | ) |
| 98 | from vllm.multimodal.processing import ( |
| 99 | BaseMultiModalProcessor, |
| 100 | BaseProcessingInfo, |
| 101 | PromptReplacement, |
| 102 | PromptUpdate, |
| 103 | PromptUpdateDetails, |
| 104 | cached_encode, |
| 105 | ) |
| 106 | from vllm.multimodal.processing.dummy_inputs import BaseDummyInputsBuilder |
| 107 | from vllm.multimodal.video import ( |
| 108 | VIDEO_LOADER_REGISTRY, |
| 109 | VideoBackend, |
| 110 | VideoSourceMetadata, |
| 111 | VideoTargetMetadata, |
| 112 | ) |
| 113 | from vllm.sequence import IntermediateTensors |
| 114 | from vllm.transformers_utils.processor import _merge_mm_kwargs |
| 115 | from vllm.transformers_utils.repo_utils import get_hf_file_to_dict |
| 116 | from vllm.transformers_utils.utils import convert_model_repo_to_path |
| 117 | from vllm.utils.tensor_schema import TensorSchema, TensorShape |
| 118 | |
| 119 | logger = init_logger(__name__) |
| 120 | |
| 121 | |
| 122 | @lru_cache |
| 123 | def _load_ov2_processor( |
| 124 | model: str, |
| 125 | revision: str | None, |
| 126 | trust_remote_code: bool, |
| 127 | **kwargs: Any, |
| 128 | ): |
| 129 | # OV2's trust_remote_code processor is a bare class (not a ProcessorMixin), |
| 130 | # so the shared type-checked get_hf_processor rejects it. We also can't use |
| 131 | # AutoProcessor.from_pretrained: OV2's remote from_pretrained drops |
| 132 | # trust_remote_code before building its nested tokenizer, which makes that |
| 133 | # nested load fall back to an interactive stdin prompt that hangs in |
| 134 | # non-interactive CI. Instead, assemble the processor here with |
| 135 | # trust_remote_code threaded through every component explicitly. |
| 136 | path = convert_model_repo_to_path(model) |
| 137 | revision = revision or "main" |
| 138 | |
| 139 | resolve_trust_remote_code( |
| 140 | trust_remote_code, |
| 141 | model, |
| 142 | has_local_code=False, |
| 143 | has_remote_code=True, |
| 144 | ) |
| 145 | |
| 146 | processor_cls = get_class_from_dynamic_module( |
| 147 | "processing_llava_onevision2.LlavaOnevision2Processor", |
| 148 | path, |
| 149 | revision=revision, |
| 150 | ) |
| 151 | video_processor_cls = get_class_from_dynamic_module( |
| 152 | "video_processing_llava_onevision2.LlavaOnevision2VideoProcessor", |
| 153 | path, |
| 154 | revision=revision, |
| 155 | ) |
| 156 | |
| 157 | # Slow Qwen2VLImageProcessor mirrors the remote processor (the Fast variant |
| 158 | # has normalization rounding differences that change pixel_values). |
| 159 | image_processor = Qwen2VLImageProcessor.from_pretrained( |
| 160 | path, revision=revision, **kwargs |
| 161 | ) |
| 162 | tokenizer = AutoTokenizer.from_pretrained( |
| 163 | path, revision=revision, trust_remote_code=trust_remote_code, **kwargs |
| 164 | ) |
| 165 | video_processor = video_processor_cls( |
| 166 | image_processor=image_processor, |
| 167 | min_pixels=getattr(image_processor, "min_pixels", 256 * 28 * 28), |
| 168 | max_pixels=getattr(image_processor, "max_pixels", 1605632), |
| 169 | patch_size=getattr(image_processor, "patch_size", 14), |
| 170 | spatial_merge_size=getattr(image_processor, "merge_size", 2), |
| 171 | ) |
| 172 | |
| 173 | # Codec defaults live under the "codec" key of preprocessor_config.json, |
| 174 | # which Qwen2VLImageProcessor does not preserve; read them directly so the |
| 175 | # codec video backend keeps its configured defaults. |
| 176 | codec_config: dict = {} |
| 177 | try: |
| 178 | preprocessor_config = get_hf_file_to_dict( |
| 179 | "preprocessor_config.json", path, revision |
| 180 | ) |
| 181 | codec_config = (preprocessor_config or {}).get("codec", {}) or {} |
| 182 | except Exception: |
| 183 | logger.debug("OV2: no codec defaults found in preprocessor_config.json") |
| 184 | |
| 185 | return processor_cls( |
| 186 | image_processor=image_processor, |
| 187 | tokenizer=tokenizer, |
| 188 | video_processor=video_processor, |
| 189 | codec_config=codec_config, |
| 190 | ) |
| 191 | |
| 192 | |
| 193 | # Upper bound on frames used when profiling the worst-case video item, mirroring |
| 194 | # Qwen2-VL. The real frame count is decided by the HF VideoProcessor at apply |
| 195 | # time; this only sizes the memory-profiling estimate. |
| 196 | _MAX_FRAMES_PER_VIDEO = 14 |
| 197 | |
| 198 | |
| 199 | def _pack_timestamps(per_video: list[list[float]]) -> torch.Tensor: |
| 200 | if not per_video: |
| 201 | return torch.empty((0, 0), dtype=torch.float32) |
| 202 | t_max = max((len(ts) for ts in per_video), default=0) |
| 203 | padded = torch.zeros((len(per_video), t_max), dtype=torch.float32) |
| 204 | for i, ts in enumerate(per_video): |
| 205 | padded[i, : len(ts)] = torch.tensor(ts, dtype=torch.float32) |
| 206 | return padded |
| 207 | |
| 208 | |
| 209 | def _validate_video_source(path: str, model_config) -> str: |
| 210 | """Confine a codec video path to ``--allowed-local-media-path``. |
| 211 | |
| 212 | The codec backend keeps the raw path string alive past vLLM's |
| 213 | ``MultiModalDataParser`` and hands it to the trust-remote-code codec |
| 214 | module, which opens it directly via ``cv2.VideoCapture`` / ffmpeg. That |
| 215 | bypasses both ``MediaConnector``'s access controls and its redirect |
| 216 | handling (``VLLM_MEDIA_URL_ALLOW_REDIRECTS``), so we restrict the codec |
| 217 | backend to **local files only**: remote ``http(s)`` / ``data`` URLs are |
| 218 | rejected here and must instead go through the frame backend (a registered |
| 219 | ``VIDEO_LOADER_REGISTRY`` loader), which rides vLLM's connector and its |
| 220 | domain/redirect gates. |
| 221 | |
| 222 | Returns the *resolved* absolute path so the codec module opens exactly the |
| 223 | file that was validated, closing the validate-vs-open (symlink-retarget) |
| 224 | window. Mirrors the confinement in ``MediaConnector._load_file_url``. |
| 225 | """ |
| 226 | from pathlib import Path |
| 227 | from urllib.request import url2pathname |
| 228 | |
| 229 | from urllib3.util import parse_url |
| 230 | |
| 231 | allowed_local = getattr(model_config, "allowed_local_media_path", "") or "" |
| 232 | |
| 233 | parsed = parse_url(str(path)) |
| 234 | scheme = (parsed.scheme or "").lower() |
| 235 | |
| 236 | if scheme in ("http", "https", "data"): |
| 237 | raise ValueError( |
| 238 | f"The codec video backend does not support remote {scheme!r} URLs: " |
| 239 | f"its trust-remote-code decoder fetches them outside vLLM's domain " |
| 240 | f"and redirect controls. Use a local file path, or the frame " |
| 241 | f"backend for remote videos." |
| 242 | ) |
| 243 | if scheme not in ("", "file"): |
| 244 | raise ValueError( |
| 245 | f"Unsupported codec video URL scheme {scheme!r}; only local file " |
| 246 | f"paths or file:// URLs are supported." |
| 247 | ) |
| 248 | |
| 249 | # Local file access is opt-in: require --allowed-local-media-path and |
| 250 | # confine the resolved path to that directory (connector.py:253-271). |
| 251 | if not allowed_local: |
| 252 | raise ValueError( |
| 253 | "Local video file access is disabled. Set " |
| 254 | "--allowed-local-media-path to enable reading local videos." |
| 255 | ) |
| 256 | if scheme == "file": |
| 257 | # Decode percent-encoding (mirrors MediaConnector._load_file_url), |
| 258 | # including the netloc so file://host/path is handled identically. |
| 259 | local = Path(url2pathname((parsed.netloc or "") + (parsed.path or ""))) |
| 260 | else: |
| 261 | local = Path(str(path)) |
| 262 | # Require an absolute path: resolving a relative path against an ambiguous |
| 263 | # CWD before the confinement check is brittle/unsafe. |
| 264 | if not local.is_absolute(): |
| 265 | raise ValueError( |
| 266 | f"Local video path {str(path)!r} must be absolute; " |
| 267 | f"relative paths are not supported." |
| 268 | ) |
| 269 | allowed_root = Path(allowed_local).resolve() |
| 270 | resolved = local.resolve() |
| 271 | if resolved != allowed_root and allowed_root not in resolved.parents: |
| 272 | raise ValueError( |
| 273 | f"Video path {str(path)!r} is outside the allowed local media " |
| 274 | f"directory {allowed_local!r}." |
| 275 | ) |
| 276 | return str(resolved) |
| 277 | |
| 278 | |
| 279 | def _validate_video_sources(paths, model_config) -> list[str]: |
| 280 | # Return the resolved paths so the codec module opens exactly what was |
| 281 | # validated (no validate-vs-open / symlink-retarget differential). |
| 282 | return [_validate_video_source(path, model_config) for path in paths] |
| 283 | |
| 284 | |
| 285 | # Design note: the two video backends take deliberately different paths. |
| 286 | # |
| 287 | # * frame backend: a normal vLLM VIDEO_LOADER_REGISTRY loader |
| 288 | # (``LlavaOnevision2VideoBackend`` below). Decoding to RGB |
| 289 | # ``(frames, metadata)`` is exactly what loaders are for, so frame sampling |
| 290 | # participates in the standard decode-stage pipeline. |
| 291 | # |
| 292 | # * codec backend: NOT a loader. OV2's codec path needs the video path string |
| 293 | # to survive into ``_apply_hf_processor_main``, where the HF processor builds the |
| 294 | # codec canvas + smart_resize + patchify |
| 295 | # (pixel_values/image_grid_thw/patch_positions). That transform is |
| 296 | # path-level and inseparable; it cannot be reconstructed from pre-decoded |
| 297 | # RGB frames, so it must run at the processor stage rather than the decode |
| 298 | # stage. The small marker/parser machinery below keeps the path alive for |
| 299 | # codec; ``_validate_video_source`` then confines it to a local file (codec |
| 300 | # does not support remote URLs -- see its docstring). |
| 301 | _CODEC_VIDEO_MARKER = "ov2_codec_video" |
| 302 | |
| 303 | |
| 304 | def prepare_codec_video_input(video_path: str) -> tuple: |
| 305 | """Wrap a video path for vLLM's MultiModalDataParser + OV2 codec backend. |
| 306 | |
| 307 | Returns ``(dummy_ndarray, metadata)`` where the ndarray satisfies the |
| 308 | parser's 4-D shape check and the metadata carries the actual path to |
| 309 | our ``_apply_hf_processor_main``. Use as:: |
| 310 | |
| 311 | multi_modal_data = {"video": prepare_codec_video_input("foo.mp4")} |
| 312 | |
| 313 | The dummy ndarray bytes encode a hash of ``video_path`` so distinct codec |
| 314 | videos get distinct mm_hashes: the parser drops the metadata dict before |
| 315 | hashing (only the ndarray reaches MultiModalHasher), so without this |
| 316 | variance every video after the first would collide and skip the encoder. |
| 317 | """ |
| 318 | path_str = str(video_path) |
| 319 | digest = hashlib.blake2b(path_str.encode("utf-8"), digest_size=16).digest() |
| 320 | dummy = np.frombuffer(digest, dtype=np.uint8).reshape(1, 1, 16, 1) |
| 321 | dummy = np.broadcast_to(dummy, (1, 1, 16, 3)).copy() |
| 322 | return (dummy, {_CODEC_VIDEO_MARKER: str(video_path)}) |
| 323 | |
| 324 | |
| 325 | def _extract_codec_video_paths(videos: Any) -> list[str] | None: |
| 326 | # vLLM's parser yields list-of-(ndarray, metadata-dict) for tuple inputs. |
| 327 | # We accept either that shape or a single raw tuple (pre-parser cases). |
| 328 | def _path_from(item): |
| 329 | if ( |
| 330 | isinstance(item, tuple) |
| 331 | and len(item) == 2 |
| 332 | and isinstance(item[1], dict) |
| 333 | and _CODEC_VIDEO_MARKER in item[1] |
| 334 | ): |
| 335 | return item[1][_CODEC_VIDEO_MARKER] |
| 336 | return None |
| 337 | |
| 338 | if isinstance(videos, list): |
| 339 | paths: list[str] = [] |
| 340 | for item in videos: |
| 341 | p = _path_from(item) |
| 342 | if p is None: |
| 343 | return None |
| 344 | paths.append(p) |
| 345 | return paths if paths else None |
| 346 | p = _path_from(videos) |
| 347 | return [p] if p is not None else None |
| 348 | |
| 349 | |
| 350 | _CODEC_FPS_CACHE: dict[str, float] = {} |
| 351 | |
| 352 | |
| 353 | def _codec_fps_for(video_path: str, hf_processor) -> float: |
| 354 | if video_path in _CODEC_FPS_CACHE: |
| 355 | return _CODEC_FPS_CACHE[video_path] |
| 356 | # The codec module is shipped inside the HF transformers_modules package |
| 357 | # for OV2, so an absolute import does not resolve. Locate it relative to |
| 358 | # the processor module (which lives in the same package). |
| 359 | proc_module_name = type(hf_processor).__module__ |
| 360 | pkg = proc_module_name.rsplit(".", 1)[0] if "." in proc_module_name else "" |
| 361 | codec_mod = importlib.import_module( |
| 362 | f"{pkg}.codec_video_processing_llava_onevision2" |
| 363 | if pkg |
| 364 | else "codec_video_processing_llava_onevision2" |
| 365 | ) |
| 366 | CodecConfig = codec_mod.CodecConfig |
| 367 | process_codec_video = codec_mod.process_codec_video |
| 368 | cfg_defaults = dict(getattr(hf_processor, "_codec_config_defaults", {})) |
| 369 | cfg = CodecConfig(**cfg_defaults) |
| 370 | payload = process_codec_video(video_path, cfg) |
| 371 | fps = float(payload["fps"]) |
| 372 | _CODEC_FPS_CACHE[video_path] = fps |
| 373 | return fps |
| 374 | |
| 375 | |
| 376 | def _codec_timestamp_runs( |
| 377 | patch_positions: torch.Tensor, |
| 378 | fps: float, |
| 379 | spatial_merge_size: int, |
| 380 | ) -> list[tuple[float, int]]: |
| 381 | # Mirrors HF's _timestamp_runs (codec_video_processing_llava_onevision2.py) |
| 382 | # exactly: same column, same merge factor, same negative-t skip, same |
| 383 | # zero-token-count skip. Keeping the logic local avoids importing the |
| 384 | # private helper from transformers_modules. |
| 385 | t_values = patch_positions[:, 0] |
| 386 | unique_t, counts = torch.unique_consecutive(t_values, return_counts=True) |
| 387 | merge_factor = int(spatial_merge_size) ** 2 |
| 388 | runs: list[tuple[float, int]] = [] |
| 389 | for t_val, count in zip(unique_t.tolist(), counts.tolist()): |
| 390 | if int(t_val) < 0: |
| 391 | continue |
| 392 | token_count = int(count) // merge_factor |
| 393 | if token_count <= 0: |
| 394 | continue |
| 395 | runs.append((float(t_val) / float(fps), token_count)) |
| 396 | return runs |
| 397 | |
| 398 | |
| 399 | def _create_field_factory( |
| 400 | spatial_merge_size: int, |
| 401 | ) -> Callable[[Mapping[str, torch.Tensor]], Mapping[str, MultiModalFieldConfig]]: |
| 402 | """Build the per-batch field-config callback. |
| 403 | |
| 404 | OV2-specific: also exposes ``patch_positions`` as a flat-from-sizes |
| 405 | field, sized by the total per-image patch count (T*H*W). The merger and |
| 406 | the 3-D RoPE both consume it. |
| 407 | """ |
| 408 | |
| 409 | def _field_config(hf_inputs: Mapping[str, torch.Tensor]): |
| 410 | image_grid_thw = hf_inputs.get("image_grid_thw", torch.empty((0, 3))) |
| 411 | image_pixel_grid_sizes = image_grid_thw.prod(-1) |
| 412 | image_embed_grid_sizes = ( |
| 413 | image_pixel_grid_sizes // spatial_merge_size // spatial_merge_size |
| 414 | ) |
| 415 | |
| 416 | video_grid_thw = hf_inputs.get("video_grid_thw", torch.empty((0, 3))) |
| 417 | # OV2 emits one grid_thw row per frame, so vLLM's per-video sharding |
| 418 | # requires explicit frame counts. video_patch_sizes sums H*W over the |
| 419 | # frames that belong to each video; video_grid_thw uses the frame |
| 420 | # count directly (one row per frame). |
| 421 | video_num_frames = hf_inputs.get( |
| 422 | "video_num_frames", torch.empty((0,), dtype=torch.long) |
| 423 | ) |
| 424 | if video_num_frames.numel() > 0: |
| 425 | per_row_patches = video_grid_thw.prod(-1) |
| 426 | offsets = torch.cumsum( |
| 427 | torch.cat([torch.zeros(1, dtype=torch.long), video_num_frames[:-1]]), 0 |
| 428 | ).tolist() |
| 429 | video_patch_sizes = torch.tensor( |
| 430 | [ |
| 431 | int(per_row_patches[int(s) : int(s) + int(n)].sum()) |
| 432 | for s, n in zip(offsets, video_num_frames.tolist()) |
| 433 | ], |
| 434 | dtype=torch.long, |
| 435 | ) |
| 436 | else: |
| 437 | video_patch_sizes = torch.empty((0,), dtype=torch.long) |
| 438 | |
| 439 | return dict( |
| 440 | pixel_values=MultiModalFieldConfig.flat_from_sizes( |
| 441 | "image", image_pixel_grid_sizes |
| 442 | ), |
| 443 | image_embeds=MultiModalFieldConfig.flat_from_sizes( |
| 444 | "image", image_embed_grid_sizes |
| 445 | ), |
| 446 | image_grid_thw=MultiModalFieldConfig.batched("image", keep_on_cpu=True), |
| 447 | # OV2 first-class MM kwarg: per-patch (t,h,w) |
| 448 | # positions required by the 3-D vision RoPE. |
| 449 | patch_positions=MultiModalFieldConfig.flat_from_sizes( |
| 450 | "image", image_pixel_grid_sizes |
| 451 | ), |
| 452 | pixel_values_videos=MultiModalFieldConfig.flat_from_sizes( |
| 453 | "video", video_patch_sizes |
| 454 | ), |
| 455 | video_grid_thw=MultiModalFieldConfig.flat_from_sizes( |
| 456 | "video", video_num_frames, keep_on_cpu=True |
| 457 | ), |
| 458 | patch_positions_videos=MultiModalFieldConfig.flat_from_sizes( |
| 459 | "video", video_patch_sizes |
| 460 | ), |
| 461 | video_num_frames=MultiModalFieldConfig.batched("video", keep_on_cpu=True), |
| 462 | frame_timestamps=MultiModalFieldConfig.batched("video", keep_on_cpu=True), |
| 463 | # Per-video flag: 0 = frame-sampling backend, 1 = codec backend. |
| 464 | # Drives codec-aware ``\n`` insertion in ``get_video_replacement``. |
| 465 | video_is_codec=MultiModalFieldConfig.batched("video", keep_on_cpu=True), |
| 466 | # Codec backend: per-video source-frame fps. Needed at |
| 467 | # replacement time to convert patch_positions t-indices into |
| 468 | # the timestamp tags HF writes (``<sec seconds>``). |
| 469 | codec_fps=MultiModalFieldConfig.batched("video", keep_on_cpu=True), |
| 470 | ) |
| 471 | |
| 472 | return _field_config |
| 473 | |
| 474 | |
| 475 | # --------------------------------------------------------------------------- |
| 476 | # Frame backend helpers |
| 477 | # --------------------------------------------------------------------------- |
| 478 | # The default video pathway materialises each video as a series of PIL frames |
| 479 | # (decoded + sampled by the registered ``LlavaOnevision2VideoBackend``) and |
| 480 | # feeds them to the HF processor through the *image* branch (per-frame timestamp |
| 481 | # marker + ``<|image_pad|>``). This mirrors the validated lmms-eval |
| 482 | # ``vllm_hf_chat`` adapter, and empirically scores higher on Video-MME than |
| 483 | # OV2's native VideoProcessor frame extractor. |
| 484 | _DEFAULT_TIMESTAMP_DECIMALS = 1 |
| 485 | _DEFAULT_FPS = 1.0 |
| 486 | _DEFAULT_MAX_FRAMES = 32 |
| 487 | # OV2 vision tower has spatial_merge_size=2 -> temporal frame count must be |
| 488 | # even. The hf-chat reference pads by repeating the last frame; same here. |
| 489 | _TEMPORAL_MERGE_SIZE = 2 |
| 490 | |
| 491 | # Token sequence emitted by the OV2 vLLM dummy inputs builder for each video |
| 492 | # item. The frame backend expands each marker into per-frame image markers |
| 493 | # (timestamp + image_pad block). |
| 494 | _VIDEO_MARKER = "<|vision_start|><|video_pad|><|vision_end|>" |
| 495 | _IMAGE_MARKER = "<|vision_start|><|image_pad|><|vision_end|>" |
| 496 | |
| 497 | |
| 498 | def _frame_video_to_pil_and_timestamps( |
| 499 | item: Any, |
| 500 | ) -> tuple[list[Image.Image], list[float]]: |
| 501 | """Convert a ``(frames_ndarray, metadata)`` video item into |
| 502 | ``(pil_frames, timestamps_seconds)``. |
| 503 | |
| 504 | Both real ``video_url`` inputs (decoded + sampled by the registered |
| 505 | ``LlavaOnevision2VideoBackend``) and dummy profiling videos arrive here as a |
| 506 | ``(frames, metadata)`` tuple because the data parser runs with |
| 507 | ``video_needs_metadata=True``. ``frames`` is a ``(T, H, W, C)`` uint8 array; |
| 508 | ``metadata`` carries ``frames_indices`` and the source ``fps``. |
| 509 | |
| 510 | Timestamps follow the qwen_vl_utils policy: ``frame_index / original_fps``. |
| 511 | The frame count is padded up to ``_TEMPORAL_MERGE_SIZE`` (repeating the last |
| 512 | frame) because OV2's vision tower merges frames temporally in pairs. |
| 513 | """ |
| 514 | if not (isinstance(item, (tuple, list)) and len(item) == 2): |
| 515 | raise ValueError( |
| 516 | "LlavaOnevision2 frame backend expects each video as a " |
| 517 | f"(frames_ndarray, metadata) tuple; got {type(item).__name__}. " |
| 518 | "Pass videos via `video_url` so the registered backend can decode " |
| 519 | "and sample them." |
| 520 | ) |
| 521 | frames, metadata = item |
| 522 | if isinstance(frames, torch.Tensor): |
| 523 | frames_np = frames.cpu().numpy() |
| 524 | else: |
| 525 | frames_np = np.asarray(frames) |
| 526 | |
| 527 | pil_frames = [Image.fromarray(f.astype(np.uint8)) for f in frames_np] |
| 528 | |
| 529 | indices = metadata.get("frames_indices") if isinstance(metadata, Mapping) else None |
| 530 | if indices is None: |
| 531 | indices = list(range(len(pil_frames))) |
| 532 | elif not isinstance(indices, list): |
| 533 | indices = list(indices) |
| 534 | # Keep indices aligned with the actual frame count. |
| 535 | if len(indices) != len(pil_frames): |
| 536 | if len(indices) > len(pil_frames): |
| 537 | indices = indices[: len(pil_frames)] |
| 538 | else: |
| 539 | indices = list(indices) + [indices[-1] if indices else 0] * ( |
| 540 | len(pil_frames) - len(indices) |
| 541 | ) |
| 542 | |
| 543 | fps = _DEFAULT_FPS |
| 544 | if isinstance(metadata, Mapping) and metadata.get("fps"): |
| 545 | fps = float(metadata["fps"]) |
| 546 | if fps <= 0: |
| 547 | fps = _DEFAULT_FPS |
| 548 | |
| 549 | # OV2 vision tower: temporal merge=2 -> frame count must be even. |
| 550 | if len(pil_frames) % _TEMPORAL_MERGE_SIZE != 0: |
| 551 | pad = _TEMPORAL_MERGE_SIZE - len(pil_frames) % _TEMPORAL_MERGE_SIZE |
| 552 | pil_frames = pil_frames + [pil_frames[-1]] * pad |
| 553 | indices = indices + [indices[-1]] * pad |
| 554 | |
| 555 | timestamps = [idx / fps for idx in indices] |
| 556 | return pil_frames, timestamps |
| 557 | |
| 558 | |
| 559 | def _expand_video_markers_in_prompt( |
| 560 | prompt: str, |
| 561 | per_video_timestamps: list[list[float]], |
| 562 | *, |
| 563 | timestamp_decimals: int, |
| 564 | ) -> str: |
| 565 | """Replace each ``<|vision_start|><|video_pad|><|vision_end|>`` with a |
| 566 | sequence of ``<{t:.Nf} seconds><|vision_start|><|image_pad|><|vision_end|>`` |
| 567 | blocks -- one per frame -- matching ``vllm_hf_chat._build_prompt``. |
| 568 | |
| 569 | Replacement is positional: the *i*-th marker consumes |
| 570 | ``per_video_timestamps[i]``. |
| 571 | """ |
| 572 | parts: list[str] = [] |
| 573 | cursor = 0 |
| 574 | idx = 0 |
| 575 | pattern = re.escape(_VIDEO_MARKER) |
| 576 | for m in re.finditer(pattern, prompt): |
| 577 | parts.append(prompt[cursor : m.start()]) |
| 578 | if idx >= len(per_video_timestamps): |
| 579 | raise ValueError( |
| 580 | f"Prompt has more video markers than supplied timestamp " |
| 581 | f"groups ({len(per_video_timestamps)})" |
| 582 | ) |
| 583 | timestamps = per_video_timestamps[idx] |
| 584 | expanded = "".join( |
| 585 | f"<{t:.{timestamp_decimals}f} seconds>{_IMAGE_MARKER}" for t in timestamps |
| 586 | ) |
| 587 | parts.append(expanded) |
| 588 | cursor = m.end() |
| 589 | idx += 1 |
| 590 | parts.append(prompt[cursor:]) |
| 591 | if idx != len(per_video_timestamps): |
| 592 | raise ValueError( |
| 593 | f"Prompt has {idx} video markers but {len(per_video_timestamps)} " |
| 594 | f"timestamp groups were supplied" |
| 595 | ) |
| 596 | return "".join(parts) |
| 597 | |
| 598 | |
| 599 | # --------------------------------------------------------------------------- |
| 600 | # Registered video loader backend (frame sampling) |
| 601 | # --------------------------------------------------------------------------- |
| 602 | # OV2 frame-sampling policy, expressed as a vLLM ``VideoBackend`` so videos |
| 603 | # entering through the standard ``video_url`` -> MediaConnector -> VideoMediaIO |
| 604 | # path are decoded + sampled inside vLLM (no qwen_vl_utils dependency, and the |
| 605 | # connector's SSRF / local-file gates apply automatically). |
| 606 | # |
| 607 | # Parity note: ``compute_frames_index_to_sample`` replicates |
| 608 | # ``qwen_vl_utils.smart_nframes`` (the policy the OV2 hf-chat recipe validated) |
| 609 | # -- frame count and index selection are byte-identical to qwen. The one |
| 610 | # residual difference is the source frame *count*: qwen decodes via decord |
| 611 | # whereas vLLM uses OpenCV/PyAV, whose ``CAP_PROP_FRAME_COUNT`` (a duration x |
| 612 | # fps estimate) can differ by +/-1 frame on some containers, shifting sampled |
| 613 | # indices by one frame on those files. Downstream metrics stay within noise. |
| 614 | _OV2_FRAME_FACTOR = 2 |
| 615 | _OV2_FPS_MIN_FRAMES = 4 |
| 616 | |
| 617 | |
| 618 | def _ceil_by_factor(n: float, factor: int) -> int: |
| 619 | import math as _math |
| 620 | |
| 621 | return _math.ceil(n / factor) * factor |
| 622 | |
| 623 | |
| 624 | def _floor_by_factor(n: float, factor: int) -> int: |
| 625 | import math as _math |
| 626 | |
| 627 | return _math.floor(n / factor) * factor |
| 628 | |
| 629 | |
| 630 | def _ov2_smart_nframes( |
| 631 | total_frames: int, |
| 632 | video_fps: float, |
| 633 | *, |
| 634 | fps: float, |
| 635 | min_frames: int, |
| 636 | max_frames: int, |
| 637 | ) -> int: |
| 638 | """Replicate ``qwen_vl_utils.smart_nframes`` (fps branch). |
| 639 | |
| 640 | Returns an even frame count in ``[min_frames, min(max_frames, total)]``. |
| 641 | """ |
| 642 | min_frames = _ceil_by_factor(min_frames, _OV2_FRAME_FACTOR) |
| 643 | max_frames = _floor_by_factor(max_frames, _OV2_FRAME_FACTOR) |
| 644 | nframes = total_frames / video_fps * fps if video_fps > 0 else total_frames |
| 645 | nframes = min(min(max(nframes, min_frames), max_frames), total_frames) |
| 646 | nframes = _floor_by_factor(nframes, _OV2_FRAME_FACTOR) |
| 647 | return max(int(nframes), _OV2_FRAME_FACTOR) |
| 648 | |
| 649 | |
| 650 | @VIDEO_LOADER_REGISTRY.register( |
| 651 | "llava_onevision2", |
| 652 | video_processor="LlavaOnevision2VideoProcessor", |
| 653 | ) |
| 654 | class LlavaOnevision2VideoBackend(VideoBackend): |
| 655 | """Frame-sampling backend for LLaVA-OneVision-2. |
| 656 | |
| 657 | Selected automatically for OV2 via the ``video_processor`` binding |
| 658 | (``video_processor_type == "LlavaOnevision2VideoProcessor"`` in the model's |
| 659 | ``video_preprocessor_config.json``). Decoding uses the inherited OpenCV / |
| 660 | PyAV codecs; only the sampling index policy is overridden to match qwen. |
| 661 | """ |
| 662 | |
| 663 | _sampling_suffix = "_llava_onevision2" |
| 664 | |
| 665 | # OV2 hf-chat reference sampling constants (mirror the validated adapter). |
| 666 | _FPS = _DEFAULT_FPS |
| 667 | _MAX_FRAMES = _DEFAULT_MAX_FRAMES |
| 668 | _MIN_FRAMES = _OV2_FPS_MIN_FRAMES |
| 669 | |
| 670 | @classmethod |
| 671 | def compute_frames_index_to_sample( |
| 672 | cls, |
| 673 | source: VideoSourceMetadata, |
| 674 | target: VideoTargetMetadata, |
| 675 | **kwargs, |
| 676 | ) -> list[int]: |
| 677 | total = int(source.total_frames_num) |
| 678 | if total <= 0: |
| 679 | return [] |
| 680 | video_fps = float(source.original_fps) |
| 681 | # Honor caller-provided sampling targets (via ``--media-io-kwargs`` → |
| 682 | # ``VideoTargetMetadata``) so benchmarks can override the conservative |
| 683 | # defaults (e.g. VSI-Bench needs max_frames=128). Fall back to the OV2 |
| 684 | # hf-chat reference constants when the target leaves a field unset |
| 685 | # (sentinel ``<= 0``). |
| 686 | target_fps = float(target.fps) if target.fps > 0 else cls._FPS |
| 687 | target_max_frames = ( |
| 688 | int(target.num_frames) if target.num_frames > 0 else cls._MAX_FRAMES |
| 689 | ) |
| 690 | n = _ov2_smart_nframes( |
| 691 | total, |
| 692 | video_fps, |
| 693 | fps=target_fps, |
| 694 | min_frames=cls._MIN_FRAMES, |
| 695 | max_frames=target_max_frames, |
| 696 | ) |
| 697 | # qwen uses linspace().round() (NOT the floor cast used by the base |
| 698 | # uniform backend), so replicate the rounding exactly. |
| 699 | idx = np.linspace(0, total - 1, n).round().astype(int).tolist() |
| 700 | # smart_nframes floors to FRAME_FACTOR so ``n`` is even; guard anyway |
| 701 | # since OV2's vision tower (temporal merge = 2) requires even counts. |
| 702 | if len(idx) % _OV2_FRAME_FACTOR != 0: |
| 703 | idx.append(idx[-1]) |
| 704 | return idx |
| 705 | |
| 706 | |
| 707 | class LlavaOnevision2ImagePixelInputs(TensorSchema): |
| 708 | type: Literal["pixel_values"] |
| 709 | |
| 710 | pixel_values: Annotated[torch.Tensor, TensorShape("np", "cps")] |
| 711 | image_grid_thw: Annotated[torch.Tensor, TensorShape("ni", 3)] |
| 712 | patch_positions: Annotated[torch.Tensor, TensorShape("np", 3)] |
| 713 | |
| 714 | |
| 715 | class LlavaOnevision2ImageEmbeddingInputs(TensorSchema): |
| 716 | type: Literal["image_embeds"] |
| 717 | |
| 718 | image_embeds: Annotated[torch.Tensor, TensorShape("nf", "hs")] |
| 719 | image_grid_thw: Annotated[torch.Tensor, TensorShape("ni", 3)] |
| 720 | |
| 721 | |
| 722 | class LlavaOnevision2VideoPixelInputs(TensorSchema): |
| 723 | type: Literal["pixel_values_videos"] |
| 724 | |
| 725 | pixel_values_videos: Annotated[torch.Tensor, TensorShape("np", "cps")] |
| 726 | video_grid_thw: Annotated[torch.Tensor, TensorShape("nf", 3)] |
| 727 | patch_positions_videos: Annotated[torch.Tensor, TensorShape("np", 3)] |
| 728 | video_num_frames: Annotated[torch.Tensor, TensorShape("nv")] |
| 729 | |
| 730 | |
| 731 | LlavaOnevision2ImageInputs = ( |
| 732 | LlavaOnevision2ImagePixelInputs | LlavaOnevision2ImageEmbeddingInputs |
| 733 | ) |
| 734 | |
| 735 | |
| 736 | class LlavaOnevision2InputsByModality(TypedDict, total=False): |
| 737 | images: LlavaOnevision2ImageInputs | None |
| 738 | videos: LlavaOnevision2VideoPixelInputs | None |
| 739 | |
| 740 | |
| 741 | class LlavaOnevision2VisionRotaryEmbedding(nn.Module): |
| 742 | """3-D rotary frequency constructor with 4:6:6 (T:H:W) split. |
| 743 | |
| 744 | Mirrors ``VisionRotaryEmbedding`` in the HF reference |
| 745 | (``modeling_llava_onevision2.py`` L79-L210). The three ``inv_freq_*`` |
| 746 | buffers are non-persistent — they are *not* in the checkpoint and must |
| 747 | be reconstructed at module init time (which we do here). |
| 748 | |
| 749 | Public entry points used by the vision tower: |
| 750 | * ``forward_from_positions(patch_positions)`` — per-patch (t,h,w) |
| 751 | positions → per-token freqs [N, half]. |
| 752 | """ |
| 753 | |
| 754 | def __init__(self, head_dim: int, theta: float = 10000.0) -> None: |
| 755 | super().__init__() |
| 756 | assert head_dim % 2 == 0, "head_dim must be even" |
| 757 | assert head_dim % 16 == 0, "head_dim must be divisible by 16 (4:6:6)" |
| 758 | half = head_dim // 2 |
| 759 | assert half % 16 == 0, "head_dim//2 must be divisible by 16" |
| 760 | |
| 761 | self.head_dim = head_dim |
| 762 | self.half = half |
| 763 | self.base = float(theta) |
| 764 | |
| 765 | unit = half // 16 |
| 766 | self.t_size = 4 * unit |
| 767 | self.h_size = 6 * unit |
| 768 | self.w_size = 6 * unit |
| 769 | assert self.t_size + self.h_size + self.w_size == half |
| 770 | |
| 771 | self.register_buffer( |
| 772 | "inv_freq_t", |
| 773 | 1.0 |
| 774 | / ( |
| 775 | self.base |
| 776 | ** (torch.arange(self.t_size, dtype=torch.float32) / self.t_size) |
| 777 | ), |
| 778 | persistent=False, |
| 779 | ) |
| 780 | self.register_buffer( |
| 781 | "inv_freq_h", |
| 782 | 1.0 |
| 783 | / ( |
| 784 | self.base |
| 785 | ** (torch.arange(self.h_size, dtype=torch.float32) / self.h_size) |
| 786 | ), |
| 787 | persistent=False, |
| 788 | ) |
| 789 | self.register_buffer( |
| 790 | "inv_freq_w", |
| 791 | 1.0 |
| 792 | / ( |
| 793 | self.base |
| 794 | ** (torch.arange(self.w_size, dtype=torch.float32) / self.w_size) |
| 795 | ), |
| 796 | persistent=False, |
| 797 | ) |
| 798 | |
| 799 | def forward_from_positions(self, patch_positions: torch.Tensor) -> torch.Tensor: |
| 800 | """[N, 3] (t,h,w) int → [N, half] float frequencies.""" |
| 801 | device = patch_positions.device |
| 802 | inv_t = self.inv_freq_t.to(device=device) |
| 803 | inv_h = self.inv_freq_h.to(device=device) |
| 804 | inv_w = self.inv_freq_w.to(device=device) |
| 805 | |
| 806 | t_pos = patch_positions[:, 0].float() |
| 807 | h_pos = patch_positions[:, 1].float() |
| 808 | w_pos = patch_positions[:, 2].float() |
| 809 | |
| 810 | ft = torch.outer(t_pos, inv_t) |
| 811 | fh = torch.outer(h_pos, inv_h) |
| 812 | fw = torch.outer(w_pos, inv_w) |
| 813 | return torch.cat([ft, fh, fw], dim=-1) |
| 814 | |
| 815 | |
| 816 | class LlavaOnevision2VisionEmbeddings(nn.Module): |
| 817 | def __init__( |
| 818 | self, patch_size: int = 14, in_channels: int = 3, embed_dim: int = 1024 |
| 819 | ) -> None: |
| 820 | super().__init__() |
| 821 | self.patch_size = patch_size |
| 822 | self.in_channels = in_channels |
| 823 | self.embed_dim = embed_dim |
| 824 | self.patch_embedding = nn.Conv2d( |
| 825 | in_channels, |
| 826 | embed_dim, |
| 827 | kernel_size=(patch_size, patch_size), |
| 828 | stride=(patch_size, patch_size), |
| 829 | bias=False, |
| 830 | ) |
| 831 | |
| 832 | def forward(self, x: torch.Tensor) -> torch.Tensor: |
| 833 | x = x.view(-1, self.in_channels, self.patch_size, self.patch_size) |
| 834 | x = self.patch_embedding(x).view(-1, self.embed_dim) |
| 835 | return x |
| 836 | |
| 837 | |
| 838 | class LlavaOnevision2VisionMLP(nn.Module): |
| 839 | def __init__( |
| 840 | self, |
| 841 | in_features: int, |
| 842 | hidden_features: int, |
| 843 | bias: bool = True, |
| 844 | quant_config: QuantizationConfig | None = None, |
| 845 | prefix: str = "", |
| 846 | use_data_parallel: bool = False, |
| 847 | ) -> None: |
| 848 | super().__init__() |
| 849 | if quant_config is not None: |
| 850 | raise RuntimeError("LLaVAOneVision2 does not support quantization") |
| 851 | self.fc1 = ColumnParallelLinear( |
| 852 | in_features, |
| 853 | hidden_features, |
| 854 | bias=bias, |
| 855 | quant_config=quant_config, |
| 856 | prefix=f"{prefix}.fc1", |
| 857 | disable_tp=use_data_parallel, |
| 858 | ) |
| 859 | self.fc2 = RowParallelLinear( |
| 860 | hidden_features, |
| 861 | in_features, |
| 862 | bias=bias, |
| 863 | quant_config=quant_config, |
| 864 | prefix=f"{prefix}.fc2", |
| 865 | disable_tp=use_data_parallel, |
| 866 | ) |
| 867 | self.act_fn = F.gelu |
| 868 | |
| 869 | def forward(self, x: torch.Tensor) -> torch.Tensor: |
| 870 | x, _ = self.fc1(x) |
| 871 | x = self.act_fn(x) |
| 872 | x, _ = self.fc2(x) |
| 873 | return x |
| 874 | |
| 875 | |
| 876 | class LlavaOnevision2VisionAttn(nn.Module): |
| 877 | """Vision self-attention with windowed cu_seqlens. |
| 878 | |
| 879 | The HF checkpoint ships a *fused* qkv linear (``self_attn.qkv``), so |
| 880 | we load directly into ``QKVParallelLinear`` with no stacked_params |
| 881 | mapping. (Compare OV1.5, whose checkpoint had separate q/k/v.) |
| 882 | """ |
| 883 | |
| 884 | def __init__( |
| 885 | self, |
| 886 | embed_dim: int, |
| 887 | num_heads: int, |
| 888 | projection_size: int, |
| 889 | quant_config: QuantizationConfig | None = None, |
| 890 | prefix: str = "", |
| 891 | use_data_parallel: bool = False, |
| 892 | ) -> None: |
| 893 | super().__init__() |
| 894 | if quant_config is not None: |
| 895 | raise RuntimeError("LLaVAOneVision2 does not support quantization") |
| 896 | |
| 897 | self.tp_size = ( |
| 898 | 1 |
| 899 | if use_data_parallel |
| 900 | else parallel_state.get_tensor_model_parallel_world_size() |
| 901 | ) |
| 902 | self.tp_rank = parallel_state.get_tensor_model_parallel_rank() |
| 903 | self.num_heads = num_heads |
| 904 | self.hidden_size_per_attn_head = dist_utils.divide(projection_size, num_heads) |
| 905 | self.num_attn_heads_per_partition = dist_utils.divide(num_heads, self.tp_size) |
| 906 | |
| 907 | self.qkv = QKVParallelLinear( |
| 908 | hidden_size=embed_dim, |
| 909 | head_size=self.hidden_size_per_attn_head, |
| 910 | total_num_heads=num_heads, |
| 911 | total_num_kv_heads=num_heads, |
| 912 | bias=True, |
| 913 | quant_config=quant_config, |
| 914 | prefix=f"{prefix}.qkv", |
| 915 | disable_tp=use_data_parallel, |
| 916 | ) |
| 917 | |
| 918 | self.proj = RowParallelLinear( |
| 919 | input_size=projection_size, |
| 920 | output_size=embed_dim, |
| 921 | quant_config=quant_config, |
| 922 | prefix=f"{prefix}.proj", |
| 923 | disable_tp=use_data_parallel, |
| 924 | ) |
| 925 | |
| 926 | self.attn = MMEncoderAttention( |
| 927 | num_heads=self.num_attn_heads_per_partition, |
| 928 | head_size=self.hidden_size_per_attn_head, |
| 929 | scale=self.hidden_size_per_attn_head**-0.5, |
| 930 | prefix=f"{prefix}.attn", |
| 931 | ) |
| 932 | |
| 933 | @staticmethod |
| 934 | def _rotate_half_interleaved(x: torch.Tensor) -> torch.Tensor: |
| 935 | """OV2-specific interleaved rotate_half. |
| 936 | |
| 937 | Pairs adjacent dims: (x[::2], x[1::2]) -> (-x[1::2], x[::2]). |
| 938 | NOT compatible with the split-half rotate used in OV1.5/LLaMA. |
| 939 | """ |
| 940 | x_even = x[..., 0::2] |
| 941 | x_odd = x[..., 1::2] |
| 942 | out = torch.stack((-x_odd, x_even), dim=-1) |
| 943 | return out.flatten(-2) |
| 944 | |
| 945 | def _apply_rotary_pos_embed( |
| 946 | self, t: torch.Tensor, freqs: torch.Tensor |
| 947 | ) -> torch.Tensor: |
| 948 | # freqs is [seq_len, half]; cat([f,f],-1) pair-repeat layout |
| 949 | # matches the interleaved rotate above. |
| 950 | orig_dtype = t.dtype |
| 951 | t = t.float() |
| 952 | emb = torch.cat((freqs, freqs), dim=-1) |
| 953 | cos = emb.cos().unsqueeze(-2).float() |
| 954 | sin = emb.sin().unsqueeze(-2).float() |
| 955 | t = (t * cos) + (self._rotate_half_interleaved(t) * sin) |
| 956 | return t.to(orig_dtype) |
| 957 | |
| 958 | def forward( |
| 959 | self, |
| 960 | x: torch.Tensor, |
| 961 | cu_seqlens: torch.Tensor, |
| 962 | rotary_pos_emb: torch.Tensor, |
| 963 | max_seqlen: torch.Tensor | None = None, |
| 964 | sequence_lengths: torch.Tensor | None = None, |
| 965 | ) -> torch.Tensor: |
| 966 | x, _ = self.qkv(x) |
| 967 | seq_len = x.shape[0] |
| 968 | # QKVParallelLinear packs q/k/v along the last dim as |
| 969 | # [q_heads, k_heads, v_heads]; view splits the three sections, each |
| 970 | # holding this partition's heads (no all-gather: MMEncoderAttention |
| 971 | # runs per-partition and ``proj`` reduces across TP ranks). |
| 972 | qkv = x.view( |
| 973 | seq_len, |
| 974 | 3, |
| 975 | self.num_attn_heads_per_partition, |
| 976 | self.hidden_size_per_attn_head, |
| 977 | ) |
| 978 | q, k, v = qkv.unbind(1) |
| 979 | |
| 980 | if rotary_pos_emb is not None: |
| 981 | # OV2 uses interleaved RoPE on the raw freqs, applied outside |
| 982 | # MMEncoderAttention (which is rotary-agnostic). |
| 983 | q = self._apply_rotary_pos_embed(q, rotary_pos_emb) |
| 984 | k = self._apply_rotary_pos_embed(k, rotary_pos_emb) |
| 985 | |
| 986 | # Add a leading batch dim (b=1) for MMEncoderAttention, which expects |
| 987 | # (batch, seq_len, num_heads, head_size) and windows via cu_seqlens. |
| 988 | output = self.attn( |
| 989 | query=q.unsqueeze(0), |
| 990 | key=k.unsqueeze(0), |
| 991 | value=v.unsqueeze(0), |
| 992 | cu_seqlens=cu_seqlens, |
| 993 | max_seqlen=max_seqlen, |
| 994 | sequence_lengths=sequence_lengths, |
| 995 | ) |
| 996 | output = output.reshape(seq_len, -1) |
| 997 | |
| 998 | output, _ = self.proj(output) |
| 999 | return output |
| 1000 | |
| 1001 | |
| 1002 | @support_torch_compile( |
| 1003 | dynamic_arg_dims={ |
| 1004 | "x": 0, |
| 1005 | "cu_seqlens": 0, |
| 1006 | "rotary_pos_emb": 0, |
| 1007 | "sequence_lengths": 0, |
| 1008 | }, |
| 1009 | enable_if=should_torch_compile_mm_encoder, |
| 1010 | is_encoder=True, |
| 1011 | ) |
| 1012 | class LlavaOnevision2VisionTowerBlock(nn.Module): |
| 1013 | def __init__( |
| 1014 | self, |
| 1015 | dim: int, |
| 1016 | num_heads: int, |
| 1017 | mlp_hidden_dim: int, |
| 1018 | norm_eps: float = 1e-6, |
| 1019 | quant_config: QuantizationConfig | None = None, |
| 1020 | prefix: str = "", |
| 1021 | use_data_parallel: bool = False, |
| 1022 | ) -> None: |
| 1023 | super().__init__() |
| 1024 | self.layer_norm1 = nn.LayerNorm(dim, eps=norm_eps) |
| 1025 | self.layer_norm2 = nn.LayerNorm(dim, eps=norm_eps) |
| 1026 | self.self_attn = LlavaOnevision2VisionAttn( |
| 1027 | embed_dim=dim, |
| 1028 | num_heads=num_heads, |
| 1029 | projection_size=dim, |
| 1030 | quant_config=quant_config, |
| 1031 | prefix=f"{prefix}.self_attn", |
| 1032 | use_data_parallel=use_data_parallel, |
| 1033 | ) |
| 1034 | self.mlp = LlavaOnevision2VisionMLP( |
| 1035 | dim, |
| 1036 | mlp_hidden_dim, |
| 1037 | bias=True, |
| 1038 | quant_config=quant_config, |
| 1039 | prefix=f"{prefix}.mlp", |
| 1040 | use_data_parallel=use_data_parallel, |
| 1041 | ) |
| 1042 | |
| 1043 | def forward( |
| 1044 | self, |
| 1045 | x: torch.Tensor, |
| 1046 | cu_seqlens: torch.Tensor, |
| 1047 | rotary_pos_emb: torch.Tensor, |
| 1048 | max_seqlen: torch.Tensor | None = None, |
| 1049 | sequence_lengths: torch.Tensor | None = None, |
| 1050 | ) -> torch.Tensor: |
| 1051 | x = x + self.self_attn( |
| 1052 | self.layer_norm1(x), |
| 1053 | cu_seqlens=cu_seqlens, |
| 1054 | rotary_pos_emb=rotary_pos_emb, |
| 1055 | max_seqlen=max_seqlen, |
| 1056 | sequence_lengths=sequence_lengths, |
| 1057 | ) |
| 1058 | x = x + self.mlp(self.layer_norm2(x)) |
| 1059 | return x |
| 1060 | |
| 1061 | |
| 1062 | class LlavaOnevision2PatchMerger(nn.Module): |
| 1063 | def __init__( |
| 1064 | self, |
| 1065 | d_model: int, |
| 1066 | context_dim: int, |
| 1067 | spatial_merge_size: int = 2, |
| 1068 | norm_eps: float = 1e-6, |
| 1069 | quant_config: QuantizationConfig | None = None, |
| 1070 | prefix: str = "", |
| 1071 | use_data_parallel: bool = False, |
| 1072 | ) -> None: |
| 1073 | super().__init__() |
| 1074 | self.hidden_size = context_dim * (spatial_merge_size**2) |
| 1075 | self.ln_q = nn.LayerNorm(context_dim, eps=norm_eps) |
| 1076 | self.mlp = nn.ModuleList( |
| 1077 | [ |
| 1078 | ColumnParallelLinear( |
| 1079 | self.hidden_size, |
| 1080 | self.hidden_size, |
| 1081 | bias=True, |
| 1082 | quant_config=quant_config, |
| 1083 | prefix=f"{prefix}.mlp.0", |
| 1084 | disable_tp=use_data_parallel, |
| 1085 | ), |
| 1086 | nn.GELU(), |
| 1087 | RowParallelLinear( |
| 1088 | self.hidden_size, |
| 1089 | d_model, |
| 1090 | bias=True, |
| 1091 | quant_config=quant_config, |
| 1092 | prefix=f"{prefix}.mlp.2", |
| 1093 | disable_tp=use_data_parallel, |
| 1094 | ), |
| 1095 | ] |
| 1096 | ) |
| 1097 | |
| 1098 | def forward( |
| 1099 | self, x: torch.Tensor, patch_positions: torch.Tensor | None = None |
| 1100 | ) -> torch.Tensor: |
| 1101 | # patch_positions accepted for API symmetry with the HF impl, |
| 1102 | # but unused: pixel_values already arrive in spatial-merge block |
| 1103 | # order (Qwen2VLImageProcessor handles that across image / video- |
| 1104 | # frames / video-codec backends). See codec_video_processing for |
| 1105 | # the codec backend's ``codec_positions_for_processor`` call. |
| 1106 | del patch_positions |
| 1107 | x = self.ln_q(x) |
| 1108 | x = x.view(-1, self.hidden_size) |
| 1109 | fc1, act, fc2 = self.mlp |
| 1110 | x, _ = fc1(x) |
| 1111 | x = act(x) |
| 1112 | x, _ = fc2(x) |
| 1113 | return x |
| 1114 | |
| 1115 | |
| 1116 | class LlavaOnevision2VisionTower(nn.Module): |
| 1117 | """OV2 vision tower (no CLS token, 3-D RoPE, windowed attention). |
| 1118 | |
| 1119 | Module attribute names mirror HF checkpoint names verbatim so the |
| 1120 | WeightsMapper only needs prefix rewrites (no substring rules, which would |
| 1121 | otherwise collide with the Qwen3 text-path ``self_attn`` modules): |
| 1122 | visual.embeddings.patch_embedding |
| 1123 | visual.layernorm_pre |
| 1124 | visual.encoder.layers.{i}.self_attn.{qkv,proj} |
| 1125 | visual.encoder.layers.{i}.layer_norm{1,2} |
| 1126 | visual.encoder.layers.{i}.mlp.fc{1,2} |
| 1127 | visual.merger.{ln_q, mlp.{0,2}} |
| 1128 | visual.rotary_pos_emb (non-persistent inv_freq buffers) |
| 1129 | """ |
| 1130 | |
| 1131 | def __init__( |
| 1132 | self, |
| 1133 | vision_config, |
| 1134 | text_hidden_size: int, |
| 1135 | norm_eps: float = 1e-6, |
| 1136 | quant_config: QuantizationConfig | None = None, |
| 1137 | prefix: str = "", |
| 1138 | use_data_parallel: bool = False, |
| 1139 | ) -> None: |
| 1140 | super().__init__() |
| 1141 | if quant_config is not None: |
| 1142 | raise RuntimeError("LLaVAOneVision2 does not support quantization") |
| 1143 | |
| 1144 | patch_size = vision_config.patch_size |
| 1145 | spatial_merge_size = vision_config.spatial_merge_size |
| 1146 | in_channels = getattr(vision_config, "num_channels", 3) |
| 1147 | hidden_size = vision_config.hidden_size |
| 1148 | embed_dim = hidden_size |
| 1149 | depth = vision_config.num_hidden_layers |
| 1150 | num_heads = vision_config.num_attention_heads |
| 1151 | mlp_hidden_dim = vision_config.intermediate_size |
| 1152 | frame_windows_size = getattr(vision_config, "frame_windows_size", 4) |
| 1153 | rope_theta = getattr(vision_config, "rope_theta", 10000.0) |
| 1154 | |
| 1155 | self.spatial_merge_size = spatial_merge_size |
| 1156 | self.frame_windows_size = int(frame_windows_size) |
| 1157 | self.num_heads = num_heads |
| 1158 | self.embed_dim = embed_dim |
| 1159 | self.head_dim = embed_dim // num_heads |
| 1160 | self.use_data_parallel = use_data_parallel |
| 1161 | self.tp_size = ( |
| 1162 | 1 |
| 1163 | if use_data_parallel |
| 1164 | else parallel_state.get_tensor_model_parallel_world_size() |
| 1165 | ) |
| 1166 | |
| 1167 | self.embeddings = LlavaOnevision2VisionEmbeddings( |
| 1168 | patch_size=patch_size, in_channels=in_channels, embed_dim=embed_dim |
| 1169 | ) |
| 1170 | self.layernorm_pre = nn.LayerNorm(embed_dim, eps=norm_eps) |
| 1171 | |
| 1172 | self.rotary_pos_emb = LlavaOnevision2VisionRotaryEmbedding( |
| 1173 | self.head_dim, theta=rope_theta |
| 1174 | ) |
| 1175 | |
| 1176 | self.encoder = nn.Module() |
| 1177 | self.encoder.layers = nn.ModuleList( |
| 1178 | [ |
| 1179 | LlavaOnevision2VisionTowerBlock( |
| 1180 | dim=embed_dim, |
| 1181 | num_heads=num_heads, |
| 1182 | mlp_hidden_dim=mlp_hidden_dim, |
| 1183 | norm_eps=norm_eps, |
| 1184 | quant_config=quant_config, |
| 1185 | prefix=f"{prefix}.encoder.layers.{i}", |
| 1186 | use_data_parallel=use_data_parallel, |
| 1187 | ) |
| 1188 | for i in range(depth) |
| 1189 | ] |
| 1190 | ) |
| 1191 | |
| 1192 | self.merger = LlavaOnevision2PatchMerger( |
| 1193 | d_model=text_hidden_size, |
| 1194 | context_dim=embed_dim, |
| 1195 | spatial_merge_size=spatial_merge_size, |
| 1196 | norm_eps=norm_eps, |
| 1197 | quant_config=quant_config, |
| 1198 | prefix=f"{prefix}.merger", |
| 1199 | use_data_parallel=use_data_parallel, |
| 1200 | ) |
| 1201 | |
| 1202 | # Vision attention backend; mirrors the one MMEncoderAttention picks |
| 1203 | # internally so the cu_seqlens / max_seqlen metadata computed below |
| 1204 | # matches the kernel actually used. |
| 1205 | self.attn_backend = get_vit_attn_backend( |
| 1206 | head_size=self.head_dim, dtype=torch.get_default_dtype() |
| 1207 | ) |
| 1208 | |
| 1209 | @property |
| 1210 | def dtype(self) -> torch.dtype: |
| 1211 | return self.embeddings.patch_embedding.weight.dtype |
| 1212 | |
| 1213 | @property |
| 1214 | def device(self) -> torch.device: |
| 1215 | return self.embeddings.patch_embedding.weight.device |
| 1216 | |
| 1217 | def _build_window_cu_seqlens(self, grid_thw: torch.Tensor) -> np.ndarray: |
| 1218 | """Build cu_seqlens that chunk each sample's T-axis into windows of |
| 1219 | ``frame_windows_size`` frames. |
| 1220 | |
| 1221 | Returns an int32 ``np.ndarray`` of shape [num_windows+1] (the |
| 1222 | canonical prefix-sum format). Backend-specific transforms are applied |
| 1223 | afterwards via ``MMEncoderAttention.maybe_recompute_cu_seqlens``. |
| 1224 | """ |
| 1225 | win = self.frame_windows_size |
| 1226 | chunk_lengths: list[int] = [] |
| 1227 | for row in grid_thw.tolist(): |
| 1228 | t, h, w = int(row[0]), int(row[1]), int(row[2]) |
| 1229 | per_frame = h * w |
| 1230 | t_remaining = t |
| 1231 | while t_remaining > 0: |
| 1232 | this_t = min(win, t_remaining) |
| 1233 | chunk_lengths.append(this_t * per_frame) |
| 1234 | t_remaining -= this_t |
| 1235 | cu = np.concatenate( |
| 1236 | [ |
| 1237 | np.zeros(1, dtype=np.int32), |
| 1238 | np.array(chunk_lengths, dtype=np.int32).cumsum(dtype=np.int32), |
| 1239 | ] |
| 1240 | ) |
| 1241 | return cu |
| 1242 | |
| 1243 | def forward( |
| 1244 | self, |
| 1245 | pixel_values: torch.Tensor, |
| 1246 | grid_thw: torch.Tensor, |
| 1247 | patch_positions: torch.Tensor, |
| 1248 | ) -> torch.Tensor: |
| 1249 | x = pixel_values.to(device=self.device, dtype=self.dtype) |
| 1250 | x = self.embeddings(x) |
| 1251 | x = self.layernorm_pre(x) |
| 1252 | |
| 1253 | rotary_pos_emb = self.rotary_pos_emb.forward_from_positions( |
| 1254 | patch_positions.to(self.device) |
| 1255 | ) |
| 1256 | |
| 1257 | # Build window cu_seqlens, then derive backend-specific attention |
| 1258 | # metadata (passthrough for FA/SDPA; transformed for FlashInfer). |
| 1259 | cu_seqlens_np = self._build_window_cu_seqlens(grid_thw) |
| 1260 | sequence_lengths = MMEncoderAttention.maybe_compute_seq_lens( |
| 1261 | self.attn_backend, cu_seqlens_np, self.device |
| 1262 | ) |
| 1263 | max_seqlen = torch.tensor( |
| 1264 | MMEncoderAttention.compute_max_seqlen(self.attn_backend, cu_seqlens_np), |
| 1265 | dtype=torch.int32, |
| 1266 | ) |
| 1267 | cu_seqlens = MMEncoderAttention.maybe_recompute_cu_seqlens( |
| 1268 | self.attn_backend, |
| 1269 | cu_seqlens_np, |
| 1270 | self.embed_dim, |
| 1271 | self.tp_size, |
| 1272 | self.device, |
| 1273 | ) |
| 1274 | |
| 1275 | for blk in self.encoder.layers: |
| 1276 | x = blk( |
| 1277 | x, |
| 1278 | cu_seqlens=cu_seqlens, |
| 1279 | rotary_pos_emb=rotary_pos_emb, |
| 1280 | max_seqlen=max_seqlen, |
| 1281 | sequence_lengths=sequence_lengths, |
| 1282 | ) |
| 1283 | |
| 1284 | return self.merger(x, patch_positions=patch_positions) |
| 1285 | |
| 1286 | |
| 1287 | class LlavaOnevision2ProcessingInfo(BaseProcessingInfo): |
| 1288 | def get_hf_config(self): |
| 1289 | return self.ctx.get_hf_config() |
| 1290 | |
| 1291 | def get_data_parser(self) -> MultiModalDataParser: |
| 1292 | # ``video_needs_metadata=True`` makes the parser preserve both the |
| 1293 | # ``(frames, metadata)`` tuples from the frame backend and the |
| 1294 | # ``(dummy, {marker: path})`` tuples from prepare_codec_video_input; |
| 1295 | # both are dispatched by metadata content in ``_apply_hf_processor_main``. |
| 1296 | return LlavaOnevision2MultiModalDataParser( |
| 1297 | self.get_hf_config().vision_config.spatial_merge_size, |
| 1298 | video_needs_metadata=True, |
| 1299 | allow_missing_mm_embeddings=self.allow_missing_mm_embeddings, |
| 1300 | ) |
| 1301 | |
| 1302 | def get_hf_processor(self, **kwargs: object): |
| 1303 | # OV2's trust_remote_code processor is a bare class (not a |
| 1304 | # ProcessorMixin), so the shared get_hf_processor cannot load it; load |
| 1305 | # via AutoProcessor here (as other trust_remote_code models do). |
| 1306 | model_config = self.ctx.model_config |
| 1307 | # ``_merge_mm_kwargs`` restricts caller ``mm_processor_kwargs`` to known |
| 1308 | # processor args and wraps values as hashable for the lru_cache. |
| 1309 | merged = _merge_mm_kwargs(model_config, AutoProcessor, **kwargs) |
| 1310 | return _load_ov2_processor( |
| 1311 | model_config.model, |
| 1312 | model_config.revision, |
| 1313 | model_config.trust_remote_code, |
| 1314 | **merged, |
| 1315 | ) |
| 1316 | |
| 1317 | def get_image_processor(self, **kwargs: object) -> Qwen2VLImageProcessor: |
| 1318 | return self.get_hf_processor(**kwargs).image_processor |
| 1319 | |
| 1320 | def get_mm_max_tokens_per_item( |
| 1321 | self, |
| 1322 | seq_len: int, |
| 1323 | mm_counts: Mapping[str, int], |
| 1324 | ) -> Mapping[str, int]: |
| 1325 | return { |
| 1326 | "image": self.get_max_image_tokens(), |
| 1327 | "video": self.get_max_video_tokens(seq_len, mm_counts), |
| 1328 | } |
| 1329 | |
| 1330 | def _get_vision_info( |
| 1331 | self, |
| 1332 | *, |
| 1333 | image_width: int, |
| 1334 | image_height: int, |
| 1335 | num_frames: int = 1, |
| 1336 | do_resize: bool = True, |
| 1337 | image_processor: Qwen2VLImageProcessor | None, |
| 1338 | ) -> tuple[ImageSize, int]: |
| 1339 | if image_processor is None: |
| 1340 | image_processor = self.get_image_processor() |
| 1341 | hf_config = self.get_hf_config() |
| 1342 | vision_config = hf_config.vision_config |
| 1343 | patch_size = vision_config.patch_size |
| 1344 | merge_size = vision_config.spatial_merge_size |
| 1345 | temporal_patch_size = getattr(vision_config, "temporal_patch_size", 1) |
| 1346 | if do_resize: |
| 1347 | min_pixels = getattr(image_processor, "min_pixels", None) |
| 1348 | max_pixels = getattr(image_processor, "max_pixels", None) |
| 1349 | if min_pixels is None or max_pixels is None: |
| 1350 | size = image_processor.size |
| 1351 | min_pixels = ( |
| 1352 | getattr(size, "shortest_edge", None) or size["shortest_edge"] |
| 1353 | ) |
| 1354 | max_pixels = getattr(size, "longest_edge", None) or size["longest_edge"] |
| 1355 | rh, rw = smart_resize( |
| 1356 | height=image_height, |
| 1357 | width=image_width, |
| 1358 | factor=patch_size * merge_size, |
| 1359 | min_pixels=min_pixels, |
| 1360 | max_pixels=max_pixels, |
| 1361 | ) |
| 1362 | preprocessed = ImageSize(width=rw, height=rh) |
| 1363 | else: |
| 1364 | preprocessed = ImageSize(width=image_width, height=image_height) |
| 1365 | padded_frames = num_frames + (-num_frames % temporal_patch_size) |
| 1366 | grid_t = max(padded_frames // temporal_patch_size, 1) |
| 1367 | grid_h = preprocessed.height // patch_size |
| 1368 | grid_w = preprocessed.width // patch_size |
| 1369 | num_patches = grid_t * grid_h * grid_w |
| 1370 | return preprocessed, num_patches // (merge_size**2) |
| 1371 | |
| 1372 | def get_num_image_tokens( |
| 1373 | self, |
| 1374 | *, |
| 1375 | image_width: int, |
| 1376 | image_height: int, |
| 1377 | image_processor: Qwen2VLImageProcessor | None, |
| 1378 | ) -> int: |
| 1379 | _, n = self._get_vision_info( |
| 1380 | image_width=image_width, |
| 1381 | image_height=image_height, |
| 1382 | image_processor=image_processor, |
| 1383 | ) |
| 1384 | return n |
| 1385 | |
| 1386 | def get_image_size_with_most_features(self) -> ImageSize: |
| 1387 | sz, _ = self._get_vision_info( |
| 1388 | image_width=1800, image_height=1800, image_processor=None |
| 1389 | ) |
| 1390 | return sz |
| 1391 | |
| 1392 | def get_max_image_tokens(self) -> int: |
| 1393 | w, h = self.get_image_size_with_most_features() |
| 1394 | return self.get_num_image_tokens( |
| 1395 | image_width=w, image_height=h, image_processor=None |
| 1396 | ) |
| 1397 | |
| 1398 | def get_num_video_tokens( |
| 1399 | self, |
| 1400 | *, |
| 1401 | image_width: int, |
| 1402 | image_height: int, |
| 1403 | num_frames: int, |
| 1404 | image_processor: Qwen2VLImageProcessor | None = None, |
| 1405 | ) -> int: |
| 1406 | _, n = self._get_vision_info( |
| 1407 | image_width=image_width, |
| 1408 | image_height=image_height, |
| 1409 | num_frames=num_frames, |
| 1410 | image_processor=image_processor, |
| 1411 | ) |
| 1412 | return n |
| 1413 | |
| 1414 | def _get_max_video_frames(self, max_tokens: int, start_num_frames: int = 1) -> int: |
| 1415 | w, h = self.get_image_size_with_most_features() |
| 1416 | num_frames = start_num_frames |
| 1417 | while True: |
| 1418 | next_num_frames = num_frames + 1 |
| 1419 | next_max_tokens = self.get_num_video_tokens( |
| 1420 | image_width=w, |
| 1421 | image_height=h, |
| 1422 | num_frames=next_num_frames, |
| 1423 | ) |
| 1424 | if next_max_tokens > max_tokens: |
| 1425 | break |
| 1426 | num_frames = next_num_frames |
| 1427 | return num_frames |
| 1428 | |
| 1429 | def get_num_frames_with_most_features( |
| 1430 | self, |
| 1431 | seq_len: int, |
| 1432 | mm_counts: Mapping[str, int], |
| 1433 | max_frames_per_video: int = _MAX_FRAMES_PER_VIDEO, |
| 1434 | ) -> int: |
| 1435 | max_videos = mm_counts.get("video", 0) |
| 1436 | max_total_frames = self._get_max_video_frames(seq_len) |
| 1437 | max_frames_per_video = min( |
| 1438 | max_total_frames // max(max_videos, 1), max_frames_per_video |
| 1439 | ) |
| 1440 | return max(max_frames_per_video, 1) |
| 1441 | |
| 1442 | def get_max_video_tokens( |
| 1443 | self, |
| 1444 | seq_len: int, |
| 1445 | mm_counts: Mapping[str, int], |
| 1446 | ) -> int: |
| 1447 | w, h = self.get_image_size_with_most_features() |
| 1448 | return self.get_num_video_tokens( |
| 1449 | image_width=w, |
| 1450 | image_height=h, |
| 1451 | num_frames=self.get_num_frames_with_most_features(seq_len, mm_counts), |
| 1452 | ) |
| 1453 | |
| 1454 | def get_supported_mm_limits(self) -> Mapping[str, int | None]: |
| 1455 | return {"image": None, "video": None} |
| 1456 | |
| 1457 | |
| 1458 | class LlavaOnevision2DummyInputsBuilder( |
| 1459 | BaseDummyInputsBuilder[LlavaOnevision2ProcessingInfo] |
| 1460 | ): |
| 1461 | def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str: |
| 1462 | n_img = mm_counts.get("image", 0) |
| 1463 | n_vid = mm_counts.get("video", 0) |
| 1464 | return ( |
| 1465 | "<|vision_start|><|image_pad|><|vision_end|>" * n_img |
| 1466 | + "<|vision_start|><|video_pad|><|vision_end|>" * n_vid |
| 1467 | ) |
| 1468 | |
| 1469 | def get_dummy_mm_data( |
| 1470 | self, |
| 1471 | seq_len: int, |
| 1472 | mm_counts: Mapping[str, int], |
| 1473 | mm_options: MultiModalDummyOptions | None = None, |
| 1474 | ) -> MultiModalDataDict: |
| 1475 | n_img = mm_counts.get("image", 0) |
| 1476 | n_vid = mm_counts.get("video", 0) |
| 1477 | w, h = self.info.get_image_size_with_most_features() |
| 1478 | out: dict[str, object] = {} |
| 1479 | if n_img: |
| 1480 | out["image"] = self._get_dummy_images(width=w, height=h, num_images=n_img) |
| 1481 | if n_vid: |
| 1482 | # 4 frames per dummy video keeps profiling cheap; the real frame |
| 1483 | # count is decided by the HF VideoProcessor at apply time. |
| 1484 | out["video"] = self._get_dummy_videos( |
| 1485 | width=w, height=h, num_frames=4, num_videos=n_vid |
| 1486 | ) |
| 1487 | return out |
| 1488 | |
| 1489 | def _get_dummy_videos( |
| 1490 | self, |
| 1491 | *, |
| 1492 | width: int, |
| 1493 | height: int, |
| 1494 | num_frames: int, |
| 1495 | num_videos: int, |
| 1496 | overrides=None, |
| 1497 | ): |
| 1498 | # ``video_needs_metadata=True`` (see ProcessingInfo.get_data_parser) |
| 1499 | # makes the parser require a metadata dict on every video item, so the |
| 1500 | # dummy profiling videos must carry one too. ``do_sample_frames=False`` |
| 1501 | # plus ``frames_indices=range(T)`` tells the frame path to consume the |
| 1502 | # pre-built frames verbatim (no resampling) -- mirrors GLM-4V. |
| 1503 | # OV2's vision tower (temporal merge = 2) needs an even frame count. |
| 1504 | num_frames = max(num_frames, _OV2_FRAME_FACTOR) |
| 1505 | if num_frames % _OV2_FRAME_FACTOR != 0: |
| 1506 | num_frames += 1 |
| 1507 | videos = super()._get_dummy_videos( |
| 1508 | width=width, |
| 1509 | height=height, |
| 1510 | num_frames=num_frames, |
| 1511 | num_videos=num_videos, |
| 1512 | overrides=overrides, |
| 1513 | ) |
| 1514 | video_items = [] |
| 1515 | for video in videos: |
| 1516 | t = video.shape[0] |
| 1517 | metadata = { |
| 1518 | "fps": 1.0, |
| 1519 | "duration": float(t), |
| 1520 | "total_num_frames": int(t), |
| 1521 | "frames_indices": list(range(t)), |
| 1522 | "video_backend": "llava_onevision2", |
| 1523 | "do_sample_frames": False, |
| 1524 | } |
| 1525 | video_items.append((video, metadata)) |
| 1526 | return video_items |
| 1527 | |
| 1528 | |
| 1529 | class LlavaOnevision2MultiModalDataParser(MultiModalDataParser): |
| 1530 | # The patch grid is what sizes the placeholder range. |
| 1531 | embedding_fields = { |
| 1532 | "image": {"image_embeds": "values", "image_grid_thw": "metadata"}, |
| 1533 | } |
| 1534 | |
| 1535 | def __init__(self, spatial_merge_size: int, *args, **kwargs): |
| 1536 | self._spatial_merge_size = spatial_merge_size |
| 1537 | super().__init__(*args, **kwargs) |
| 1538 | |
| 1539 | def _parse_image_data( |
| 1540 | self, |
| 1541 | data: dict[str, torch.Tensor] | ModalityData[ImageItem], |
| 1542 | ) -> ModalityDataItems[Any, Any] | None: |
| 1543 | if isinstance(data, dict): |
| 1544 | required, optional = self.embedding_field_sets("image") |
| 1545 | return DictEmbeddingItems( |
| 1546 | data, |
| 1547 | modality="image", |
| 1548 | required_fields=required, |
| 1549 | optional_fields=optional, |
| 1550 | fields_factory=_create_field_factory(self._spatial_merge_size), |
| 1551 | ) |
| 1552 | return super()._parse_image_data(data) |
| 1553 | |
| 1554 | |
| 1555 | class LlavaOnevision2MultiModalProcessor( |
| 1556 | BaseMultiModalProcessor[LlavaOnevision2ProcessingInfo] |
| 1557 | ): |
| 1558 | def _get_hf_mm_text(self, mm_counts: Mapping[str, int]) -> str: |
| 1559 | return self.dummy_inputs.get_dummy_text(mm_counts) |
| 1560 | |
| 1561 | def _apply_hf_processor_main( |
| 1562 | self, |
| 1563 | mm_items: MultiModalDataItems, |
| 1564 | hf_kwargs: Mapping[str, object], |
| 1565 | ) -> BatchFeature: |
| 1566 | hf_data, hf_kwargs, passthrough_data = self._get_hf_mm_inputs( |
| 1567 | mm_items, hf_kwargs |
| 1568 | ) |
| 1569 | |
| 1570 | if not hf_data: |
| 1571 | return self._finalize_hf_mm_data(hf_data, hf_kwargs, passthrough_data) |
| 1572 | |
| 1573 | prompt_text = hf_data.pop("text") |
| 1574 | assert isinstance(prompt_text, str) |
| 1575 | |
| 1576 | hf_processor = self.info.get_hf_processor(**hf_kwargs) |
| 1577 | merged_kwargs = self.info.ctx.get_merged_mm_kwargs(hf_kwargs) |
| 1578 | merged_kwargs.setdefault("return_tensors", "pt") |
| 1579 | call_kwargs = { |
| 1580 | k: v |
| 1581 | for k, v in merged_kwargs.items() |
| 1582 | if k |
| 1583 | in { |
| 1584 | "return_tensors", |
| 1585 | "padding", |
| 1586 | "num_frames", |
| 1587 | "max_frames", |
| 1588 | "target_fps", |
| 1589 | "video_backend", |
| 1590 | "max_pixels", |
| 1591 | "codec_config", |
| 1592 | } |
| 1593 | } |
| 1594 | |
| 1595 | # Explicit None + length checks: ``mm_data[...]`` may be a list, numpy |
| 1596 | # array, or tensor, and ``and <array>`` would raise on the ambiguous |
| 1597 | # truth value of a multi-element array. |
| 1598 | _videos = hf_data.get("videos") |
| 1599 | if _videos is not None and not isinstance(_videos, Sized): |
| 1600 | raise TypeError(f"object of type '{type(_videos).__name__}' has no len()") |
| 1601 | videos_present = _videos is not None and len(_videos) > 0 |
| 1602 | |
| 1603 | codec_video_paths = ( |
| 1604 | _extract_codec_video_paths(hf_data["videos"]) if videos_present else None |
| 1605 | ) |
| 1606 | is_codec_marker = codec_video_paths is not None |
| 1607 | # Fallback: caller passed video_backend=codec via mm_processor_kwargs |
| 1608 | # without wrapping paths through prepare_codec_video_input (e.g. |
| 1609 | # lmms-eval's chat/vllm.py ov2_path_video=True). Recover the path |
| 1610 | # strings directly from mm_data["videos"] so the codec rename |
| 1611 | # branch still fires and video-modality fields get populated. |
| 1612 | is_codec_kwarg = ( |
| 1613 | not is_codec_marker |
| 1614 | and videos_present |
| 1615 | and call_kwargs.get("video_backend") == "codec" |
| 1616 | ) |
| 1617 | if is_codec_kwarg: |
| 1618 | raw = hf_data["videos"] |
| 1619 | if isinstance(raw, str): |
| 1620 | codec_video_paths = [raw] |
| 1621 | elif isinstance(raw, (list, tuple)) and all( |
| 1622 | isinstance(x, str) for x in raw |
| 1623 | ): |
| 1624 | codec_video_paths = list(raw) |
| 1625 | else: |
| 1626 | # Non-string payload (PIL/ndarray/etc.) - codec backend |
| 1627 | # cannot consume pre-decoded frames; fall through to frame |
| 1628 | # path. |
| 1629 | is_codec_kwarg = False |
| 1630 | is_codec = is_codec_marker or is_codec_kwarg |
| 1631 | |
| 1632 | if is_codec: |
| 1633 | # Confine codec paths to --allowed-local-media-path (local-only; |
| 1634 | # SSRF / local-file-read protection) and use the *resolved* paths |
| 1635 | # downstream so the codec module opens exactly the file that was |
| 1636 | # validated (no symlink-retarget / validate-vs-open gap). |
| 1637 | codec_video_paths = _validate_video_sources( |
| 1638 | codec_video_paths, self.info.ctx.model_config |
| 1639 | ) |
| 1640 | # Codec backend: HF processor consumes the path string directly and |
| 1641 | # performs decode + canvas-packing internally. The dummy ndarray |
| 1642 | # we attached during prepare_codec_video_input is discarded here. |
| 1643 | hf_data["videos"] = ( |
| 1644 | codec_video_paths |
| 1645 | if len(codec_video_paths) > 1 |
| 1646 | else codec_video_paths[0] |
| 1647 | ) |
| 1648 | # Route through ``ctx.call_hf_processor`` so float-tensor |
| 1649 | # dtype postprocessing is applied automatically; inject |
| 1650 | # ``video_backend="codec"`` via mm_kwargs so the wrapped processor |
| 1651 | # dispatches to its codec branch. |
| 1652 | output = self.info.ctx.call_hf_processor( |
| 1653 | self.info.get_hf_processor(**{**hf_kwargs, "video_backend": "codec"}), |
| 1654 | dict(text=prompt_text, **hf_data), |
| 1655 | {**hf_kwargs, "video_backend": "codec"}, |
| 1656 | ) |
| 1657 | data = dict(output) |
| 1658 | processed_data = BatchFeature( |
| 1659 | self._rename_codec_outputs_to_video( |
| 1660 | data, codec_video_paths, hf_processor |
| 1661 | ) |
| 1662 | ) |
| 1663 | return self._finalize_hf_mm_data( |
| 1664 | hf_data, hf_kwargs, passthrough_data, processed_data |
| 1665 | ) |
| 1666 | |
| 1667 | # ---- Frame backend (registered LlavaOnevision2VideoBackend) ------ |
| 1668 | # Every non-codec video reaches here as a ``(frames_ndarray, metadata)`` |
| 1669 | # tuple (``video_needs_metadata=True``): the connector decoded + sampled |
| 1670 | # it through ``LlavaOnevision2VideoBackend`` for real ``video_url`` |
| 1671 | # inputs, or the dummy-inputs builder attached synthetic metadata during |
| 1672 | # profiling. We materialise the frames as PIL images + per-frame |
| 1673 | # timestamp markers and feed them through the HF processor's *image* |
| 1674 | # branch (smart_resize + patchify), then re-tag the image-series outputs |
| 1675 | # as video-series so vLLM's ``<|video_pad|>`` placeholder replacement |
| 1676 | # finds them. Sampling parity with the original qwen_vl_utils policy is |
| 1677 | # provided by the backend's ``compute_frames_index_to_sample``; SSRF / |
| 1678 | # local-file gating is enforced by the connector before decoding. |
| 1679 | if videos_present: |
| 1680 | timestamp_decimals = hf_kwargs.get( |
| 1681 | "timestamp_decimals", _DEFAULT_TIMESTAMP_DECIMALS |
| 1682 | ) |
| 1683 | if not isinstance( |
| 1684 | timestamp_decimals, (str, Buffer, SupportsInt, SupportsIndex) |
| 1685 | ): |
| 1686 | raise TypeError( |
| 1687 | "int() argument must be a string, a bytes-like object " |
| 1688 | f"or a real number, not '{type(timestamp_decimals).__name__}'" |
| 1689 | ) |
| 1690 | timestamp_decimals = int(timestamp_decimals) |
| 1691 | |
| 1692 | per_video_frames: list[list[Image.Image]] = [] |
| 1693 | per_video_timestamps: list[list[float]] = [] |
| 1694 | videos = hf_data["videos"] |
| 1695 | if not isinstance(videos, Iterable): |
| 1696 | raise TypeError(f"Unsupported videos input: {type(videos)}") |
| 1697 | for item in videos: |
| 1698 | pil_frames, timestamps = _frame_video_to_pil_and_timestamps(item) |
| 1699 | per_video_frames.append(pil_frames) |
| 1700 | per_video_timestamps.append(timestamps) |
| 1701 | |
| 1702 | # Rewrite the prompt so each video marker becomes a sequence of |
| 1703 | # ``<{t} seconds><|vision_start|><|image_pad|><|vision_end|>`` |
| 1704 | # blocks (matches the OV2 hf-chat reference exactly). |
| 1705 | new_prompt = _expand_video_markers_in_prompt( |
| 1706 | prompt_text, |
| 1707 | per_video_timestamps, |
| 1708 | timestamp_decimals=timestamp_decimals, |
| 1709 | ) |
| 1710 | |
| 1711 | # Build the merged ``images`` list the wrapped HF processor will |
| 1712 | # consume. The processor binds the merged list *positionally* to the |
| 1713 | # ``<|image_pad|>`` slots in prompt order, so it must follow the |
| 1714 | # interleaved marker order of the prompt (not a fixed "videos first" |
| 1715 | # order) -- otherwise mixed image+video requests bind frames to the |
| 1716 | # wrong placeholder. ``row_is_video`` labels each grid row so outputs |
| 1717 | # can be split back into per-modality keys below. |
| 1718 | merged_mm_data = dict(hf_data) |
| 1719 | existing_images = merged_mm_data.pop("images", None) |
| 1720 | caller_images: list[Image.Image] = [] |
| 1721 | if existing_images: |
| 1722 | if isinstance(existing_images, list): |
| 1723 | caller_images.extend(existing_images) |
| 1724 | else: |
| 1725 | caller_images.append(existing_images) |
| 1726 | |
| 1727 | marker_pattern = re.compile( |
| 1728 | "(?P<image>" + re.escape(_IMAGE_MARKER) + ")" |
| 1729 | "|(?P<video>" + re.escape(_VIDEO_MARKER) + ")" |
| 1730 | ) |
| 1731 | flat_frames: list[Image.Image] = [] |
| 1732 | row_is_video: list[bool] = [] |
| 1733 | vid_idx = 0 |
| 1734 | img_idx = 0 |
| 1735 | for marker in marker_pattern.finditer(prompt_text): |
| 1736 | if marker.lastgroup == "video": |
| 1737 | frames = per_video_frames[vid_idx] |
| 1738 | vid_idx += 1 |
| 1739 | flat_frames.extend(frames) |
| 1740 | row_is_video.extend([True] * len(frames)) |
| 1741 | else: |
| 1742 | flat_frames.append(caller_images[img_idx]) |
| 1743 | img_idx += 1 |
| 1744 | row_is_video.append(False) |
| 1745 | # Fallback: if the prompt carried no parseable markers (defensive -- |
| 1746 | # should not happen for well-formed inputs), preserve the legacy |
| 1747 | # "video frames first, then caller images" ordering. |
| 1748 | if vid_idx == 0 and img_idx == 0: |
| 1749 | for frames in per_video_frames: |
| 1750 | flat_frames.extend(frames) |
| 1751 | row_is_video.extend([True] * len(frames)) |
| 1752 | flat_frames.extend(caller_images) |
| 1753 | row_is_video.extend([False] * len(caller_images)) |
| 1754 | |
| 1755 | merged_mm_data.pop("videos", None) |
| 1756 | merged_mm_data["images"] = flat_frames |
| 1757 | |
| 1758 | # Route through ``ctx.call_hf_processor`` (applies float-tensor |
| 1759 | # dtype postprocessing automatically). The wrapped processor's image |
| 1760 | # branch ignores video/codec-only kwargs and does not forward extra |
| 1761 | # **kwargs to the image processor, so passing the full merged kwarg |
| 1762 | # set here is a no-op beyond return_tensors/padding. |
| 1763 | output = self.info.ctx.call_hf_processor( |
| 1764 | self.info.get_hf_processor(**hf_kwargs), |
| 1765 | dict(text=new_prompt, **merged_mm_data), |
| 1766 | hf_kwargs, |
| 1767 | ) |
| 1768 | data = dict(output) |
| 1769 | |
| 1770 | # Split the image-series processor outputs back into video rows and |
| 1771 | # genuine-image rows (one grid row per frame/image, in flat_frames |
| 1772 | # order). Video rows become video-series keys; caller-image rows |
| 1773 | # stay under image-series keys. Without this split a mixed |
| 1774 | # image+video request silently drops the image modality. |
| 1775 | grid = data.get("image_grid_thw") |
| 1776 | has_caller_images = any(not v for v in row_is_video) |
| 1777 | if grid is not None and has_caller_images: |
| 1778 | pixel_values = data.pop("pixel_values") |
| 1779 | patch_positions = data.pop("patch_positions") |
| 1780 | |
| 1781 | per_row_patches = grid.prod(-1).tolist() |
| 1782 | row_offsets = [0] |
| 1783 | for p in per_row_patches: |
| 1784 | row_offsets.append(row_offsets[-1] + int(p)) |
| 1785 | |
| 1786 | def _gather_rows(tensor, rows): |
| 1787 | if not rows: |
| 1788 | return tensor[:0] |
| 1789 | return torch.cat( |
| 1790 | [tensor[row_offsets[i] : row_offsets[i + 1]] for i in rows], |
| 1791 | dim=0, |
| 1792 | ) |
| 1793 | |
| 1794 | video_rows = [i for i, v in enumerate(row_is_video) if v] |
| 1795 | image_rows = [i for i, v in enumerate(row_is_video) if not v] |
| 1796 | |
| 1797 | data["pixel_values_videos"] = _gather_rows(pixel_values, video_rows) |
| 1798 | data["patch_positions_videos"] = _gather_rows( |
| 1799 | patch_positions, video_rows |
| 1800 | ) |
| 1801 | data["video_grid_thw"] = grid[video_rows] |
| 1802 | |
| 1803 | data["pixel_values"] = _gather_rows(pixel_values, image_rows) |
| 1804 | data["patch_positions"] = _gather_rows(patch_positions, image_rows) |
| 1805 | data["image_grid_thw"] = grid[image_rows] |
| 1806 | else: |
| 1807 | # Video-only (no caller images): re-tag every image-series |
| 1808 | # output as video-series. |
| 1809 | if "pixel_values" in data: |
| 1810 | data["pixel_values_videos"] = data.pop("pixel_values") |
| 1811 | if "image_grid_thw" in data: |
| 1812 | data["video_grid_thw"] = data.pop("image_grid_thw") |
| 1813 | if "patch_positions" in data: |
| 1814 | data["patch_positions_videos"] = data.pop("patch_positions") |
| 1815 | data["frame_timestamps"] = _pack_timestamps(per_video_timestamps) |
| 1816 | data["video_num_frames"] = torch.tensor( |
| 1817 | [len(ts) for ts in per_video_timestamps], dtype=torch.long |
| 1818 | ) |
| 1819 | data["video_is_codec"] = torch.zeros( |
| 1820 | (len(per_video_timestamps),), dtype=torch.long |
| 1821 | ) |
| 1822 | |
| 1823 | processed_data = BatchFeature(data) |
| 1824 | return self._finalize_hf_mm_data( |
| 1825 | hf_data, hf_kwargs, passthrough_data, processed_data |
| 1826 | ) |
| 1827 | |
| 1828 | # ---- Image-only / text-only call -------------------------------- |
| 1829 | # No videos present: delegate to ``ctx.call_hf_processor``, which |
| 1830 | # runs the wrapped processor over the (possibly empty) image set and |
| 1831 | # applies float-tensor dtype postprocessing automatically. |
| 1832 | processed_data = self.info.ctx.call_hf_processor( |
| 1833 | self.info.get_hf_processor(**hf_kwargs), |
| 1834 | dict(text=prompt_text, **hf_data), |
| 1835 | hf_kwargs, |
| 1836 | ) |
| 1837 | return self._finalize_hf_mm_data( |
| 1838 | hf_data, hf_kwargs, passthrough_data, processed_data |
| 1839 | ) |
| 1840 | |
| 1841 | def _rename_codec_outputs_to_video( |
| 1842 | self, |
| 1843 | data: dict, |
| 1844 | codec_video_paths: list[str], |
| 1845 | hf_processor, |
| 1846 | ) -> dict: |
| 1847 | # Codec branch emits image-series keys; vLLM expects video-series. |
| 1848 | # Timestamps are NOT synthesized here: the codec format packs multiple |
| 1849 | # source-frame timestamps into one canvas, so a per-canvas list is |
| 1850 | # ill-defined. We ship per-video fps and let get_video_replacement |
| 1851 | # reconstruct (timestamp, token_count) runs from patch_positions_videos. |
| 1852 | data["pixel_values_videos"] = data.pop("pixel_values") |
| 1853 | video_grid_thw = data.pop("image_grid_thw") |
| 1854 | data["video_grid_thw"] = video_grid_thw |
| 1855 | patch_positions = data.pop("patch_positions") |
| 1856 | data["patch_positions_videos"] = patch_positions |
| 1857 | |
| 1858 | # Read per-video fps from processor output (order matches |
| 1859 | # codec_video_paths); fall back to _codec_fps_for only if the processor |
| 1860 | # didn't populate codec_fps (older model snapshots). |
| 1861 | processor_fps = data.pop("codec_fps", None) |
| 1862 | per_video_canvas_counts: list[int] = [] |
| 1863 | per_video_fps: list[float] = [] |
| 1864 | grid_offset = 0 |
| 1865 | for idx, video_path in enumerate(codec_video_paths): |
| 1866 | num_canvases = self._count_canvases_for_video( |
| 1867 | patch_positions, video_grid_thw, 0, grid_offset |
| 1868 | ) |
| 1869 | per_video_canvas_counts.append(num_canvases) |
| 1870 | if processor_fps is not None and idx < len(processor_fps): |
| 1871 | per_video_fps.append(float(processor_fps[idx])) |
| 1872 | else: |
| 1873 | per_video_fps.append(_codec_fps_for(video_path, hf_processor)) |
| 1874 | grid_offset += num_canvases |
| 1875 | |
| 1876 | data["video_num_frames"] = torch.tensor( |
| 1877 | per_video_canvas_counts, dtype=torch.long |
| 1878 | ) |
| 1879 | data["video_is_codec"] = torch.ones( |
| 1880 | (len(per_video_canvas_counts),), dtype=torch.long |
| 1881 | ) |
| 1882 | # Stored as scaled int64 (fps * 1e9 rounded) because vLLM casts |
| 1883 | # all float MM fields to model dtype (bfloat16), which rounds |
| 1884 | # 29.97003 fps to 30.0 and shifts every timestamp tag by ~0.1s. |
| 1885 | # Integer storage round-trips losslessly through the field-config |
| 1886 | # pipeline; we divide back to float at replacement time. |
| 1887 | data["codec_fps"] = torch.tensor( |
| 1888 | [int(round(f * 1_000_000_000)) for f in per_video_fps], dtype=torch.int64 |
| 1889 | ) |
| 1890 | # frame_timestamps is required by the field-config schema; emit an |
| 1891 | # empty per-video list (frames-backend populates it, codec ignores it). |
| 1892 | data["frame_timestamps"] = _pack_timestamps([[] for _ in codec_video_paths]) |
| 1893 | return data |
| 1894 | |
| 1895 | def _count_canvases_for_video( |
| 1896 | self, |
| 1897 | patch_positions: torch.Tensor, |
| 1898 | video_grid_thw: torch.Tensor, |
| 1899 | canvas_offset: int, |
| 1900 | grid_offset: int, |
| 1901 | ) -> int: |
| 1902 | # Single-video case: every remaining grid row belongs to this video. |
| 1903 | # HF merges per-canvas rows into one [N,H,W] row per video, so the |
| 1904 | # canvas count lives in the t-dim, not shape[0]. |
| 1905 | return int(video_grid_thw[grid_offset:, 0].sum().item()) |
| 1906 | |
| 1907 | def _get_prompt_updates( |
| 1908 | self, |
| 1909 | mm_items: MultiModalDataItems, |
| 1910 | hf_processor_mm_kwargs: Mapping[str, Any], |
| 1911 | out_mm_kwargs: MultiModalKwargsItems, |
| 1912 | ) -> Sequence[PromptUpdate]: |
| 1913 | image_processor = self.info.get_image_processor(**hf_processor_mm_kwargs) |
| 1914 | tokenizer = self.info.get_tokenizer() |
| 1915 | vocab = tokenizer.get_vocab() |
| 1916 | image_pad_id = vocab["<|image_pad|>"] |
| 1917 | video_pad_id = vocab["<|video_pad|>"] |
| 1918 | vision_start_id = vocab["<|vision_start|>"] |
| 1919 | vision_end_id = vocab["<|vision_end|>"] |
| 1920 | newline_ids = cached_encode(tokenizer, "\n", add_special_tokens=False) |
| 1921 | merge_length = image_processor.merge_size**2 |
| 1922 | decimals = int(hf_processor_mm_kwargs.get("timestamp_decimals", 1)) |
| 1923 | |
| 1924 | def get_image_replacement(item_idx: int): |
| 1925 | out_item = out_mm_kwargs["image"][item_idx] |
| 1926 | grid_thw = out_item["image_grid_thw"].data |
| 1927 | assert isinstance(grid_thw, torch.Tensor) |
| 1928 | n = int(grid_thw.prod(-1).sum()) // merge_length |
| 1929 | return [image_pad_id] * n |
| 1930 | |
| 1931 | def get_video_replacement(item_idx: int): |
| 1932 | out_item = out_mm_kwargs["video"][item_idx] |
| 1933 | grid_thw = out_item["video_grid_thw"].data |
| 1934 | assert isinstance(grid_thw, torch.Tensor) |
| 1935 | is_codec_field = out_item.get("video_is_codec") |
| 1936 | if is_codec_field is not None: |
| 1937 | is_codec_data = is_codec_field.data |
| 1938 | assert isinstance(is_codec_data, torch.Tensor) |
| 1939 | is_codec = bool(int(is_codec_data.item())) |
| 1940 | else: |
| 1941 | is_codec = False |
| 1942 | tokens: list[int] = [] |
| 1943 | |
| 1944 | if is_codec: |
| 1945 | # Codec packs multiple source-frame timestamps into one canvas, |
| 1946 | # so timestamps are per-run (consecutive patches sharing the |
| 1947 | # same source-frame t), not per-canvas. Mirrors HF's |
| 1948 | # rewrite_text_with_codec_positions: group patch_positions by |
| 1949 | # run, emit ``<sec seconds><|vision_start|><pad*N><|vision_end|>\n``. |
| 1950 | patch_positions = out_item["patch_positions_videos"].data |
| 1951 | fps_t = out_item["codec_fps"].data |
| 1952 | assert isinstance(patch_positions, torch.Tensor) |
| 1953 | assert isinstance(fps_t, torch.Tensor) |
| 1954 | fps = float(int(fps_t.item())) / 1_000_000_000.0 |
| 1955 | runs = _codec_timestamp_runs( |
| 1956 | patch_positions, fps, image_processor.merge_size |
| 1957 | ) |
| 1958 | for sec, token_count in runs: |
| 1959 | tag = f"<{sec:.{decimals}f} seconds>" |
| 1960 | tag_ids = tokenizer.encode(tag, add_special_tokens=False) |
| 1961 | tokens.extend(tag_ids) |
| 1962 | tokens.append(vision_start_id) |
| 1963 | tokens.extend([image_pad_id] * token_count) |
| 1964 | tokens.append(vision_end_id) |
| 1965 | tokens.extend(newline_ids) |
| 1966 | else: |
| 1967 | timestamps = out_item["frame_timestamps"].data |
| 1968 | assert isinstance(timestamps, torch.Tensor) |
| 1969 | T_total = int(grid_thw.shape[0]) |
| 1970 | for t in range(T_total): |
| 1971 | sec = float(timestamps[t].item()) |
| 1972 | tag = f"<{sec:.{decimals}f} seconds>" |
| 1973 | tag_ids = tokenizer.encode(tag, add_special_tokens=False) |
| 1974 | n_per_frame = int(grid_thw[t].prod()) // merge_length |
| 1975 | tokens.extend(tag_ids) |
| 1976 | tokens.append(vision_start_id) |
| 1977 | tokens.extend([image_pad_id] * n_per_frame) |
| 1978 | tokens.append(vision_end_id) |
| 1979 | # Replacement mixes timestamp/marker tokens with image_pad |
| 1980 | # placeholders; only image_pad positions carry vision embeddings, |
| 1981 | # so a partial-embed mask is emitted via select_token_id. |
| 1982 | return PromptUpdateDetails.select_token_id(tokens, image_pad_id) |
| 1983 | |
| 1984 | return [ |
| 1985 | PromptReplacement( |
| 1986 | modality="image", |
| 1987 | target=[image_pad_id], |
| 1988 | replacement=get_image_replacement, |
| 1989 | ), |
| 1990 | PromptReplacement( |
| 1991 | modality="video", |
| 1992 | target=[vision_start_id, video_pad_id, vision_end_id], |
| 1993 | replacement=get_video_replacement, |
| 1994 | ), |
| 1995 | ] |
| 1996 | |
| 1997 | def _get_mm_fields_config( |
| 1998 | self, |
| 1999 | hf_inputs: BatchFeature, |
| 2000 | hf_processor_mm_kwargs: Mapping[str, object], |
| 2001 | ) -> Mapping[str, MultiModalFieldConfig]: |
| 2002 | return _create_field_factory( |
| 2003 | self.info.get_hf_config().vision_config.spatial_merge_size, |
| 2004 | )(hf_inputs) |
| 2005 | |
| 2006 | |
| 2007 | @MULTIMODAL_REGISTRY.register_processor( |
| 2008 | LlavaOnevision2MultiModalProcessor, |
| 2009 | info=LlavaOnevision2ProcessingInfo, |
| 2010 | dummy_inputs=LlavaOnevision2DummyInputsBuilder, |
| 2011 | ) |
| 2012 | class LlavaOnevision2ForConditionalGeneration( |
| 2013 | nn.Module, SupportsMultiModal, SupportsPP |
| 2014 | ): |
| 2015 | """vLLM-side OV2 top-level model. |
| 2016 | |
| 2017 | Weight name rewriting (HF checkpoint → vLLM module tree): |
| 2018 | Prefix rewrites only (longest match wins). Vision tower attribute names |
| 2019 | mirror HF names verbatim, so no substring rules are needed. Substring |
| 2020 | rules would otherwise collide with the Qwen3 text-path ``self_attn`` |
| 2021 | modules and break the language-model loader. |
| 2022 | ``model.language_model.`` → ``language_model.model.`` |
| 2023 | ``model.visual.`` → ``visual.`` |
| 2024 | ``lm_head.`` → ``language_model.lm_head.`` |
| 2025 | ``model.`` (fallback) → ``language_model.model.`` |
| 2026 | """ |
| 2027 | |
| 2028 | hf_to_vllm_mapper = WeightsMapper( |
| 2029 | orig_to_new_prefix={ |
| 2030 | "model.language_model.": "language_model.model.", |
| 2031 | "model.visual.": "visual.", |
| 2032 | "lm_head.": "language_model.lm_head.", |
| 2033 | "model.": "language_model.model.", |
| 2034 | } |
| 2035 | ) |
| 2036 | |
| 2037 | @classmethod |
| 2038 | def get_placeholder_str(cls, modality: str, i: int) -> str | None: |
| 2039 | if modality.startswith("image"): |
| 2040 | return "<|vision_start|><|image_pad|><|vision_end|>" |
| 2041 | if modality.startswith("video"): |
| 2042 | return "<|vision_start|><|video_pad|><|vision_end|>" |
| 2043 | raise ValueError("Only image or video modality is supported") |
| 2044 | |
| 2045 | def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None: |
| 2046 | super().__init__() |
| 2047 | config = vllm_config.model_config.hf_config |
| 2048 | multimodal_config = vllm_config.model_config.multimodal_config |
| 2049 | |
| 2050 | self.config = config |
| 2051 | self.multimodal_config = multimodal_config |
| 2052 | |
| 2053 | # Build the vision tower under the tower marker so it is shared by the |
| 2054 | # image, video-frame and video-codec backends (all routed through |
| 2055 | # ``self.visual``). When both modalities are disabled via |
| 2056 | # ``--limit-mm-per-prompt`` the marker turns the tower into a skipped |
| 2057 | # placeholder whose weights are dropped automatically by the loader. |
| 2058 | with self._mark_tower_model(vllm_config, {"image", "video"}): |
| 2059 | self.visual = LlavaOnevision2VisionTower( |
| 2060 | config.vision_config, |
| 2061 | text_hidden_size=config.text_config.hidden_size, |
| 2062 | norm_eps=getattr(config.vision_config, "layer_norm_eps", 1e-6), |
| 2063 | quant_config=None, |
| 2064 | prefix=maybe_prefix(prefix, "visual"), |
| 2065 | ) |
| 2066 | |
| 2067 | # OV2 LLM is plain Qwen3 -- 1-D positions, no M-RoPE. The wrapper |
| 2068 | # LlavaOnevision2Config keeps text/vision configs nested (unlike OV1.5 |
| 2069 | # which promoted text fields), so explicitly hand text_config to the |
| 2070 | # Qwen3 init path or qwen3.py will hit AttributeError on vocab_size. |
| 2071 | self.language_model = init_vllm_registered_model( |
| 2072 | vllm_config=vllm_config, |
| 2073 | hf_config=config.text_config, |
| 2074 | prefix=maybe_prefix(prefix, "language_model"), |
| 2075 | architectures=["Qwen3ForCausalLM"], |
| 2076 | ) |
| 2077 | |
| 2078 | self.make_empty_intermediate_tensors = ( |
| 2079 | self.language_model.make_empty_intermediate_tensors |
| 2080 | ) |
| 2081 | |
| 2082 | def _validate_and_reshape_mm_tensor( |
| 2083 | self, mm_input: object, name: str |
| 2084 | ) -> torch.Tensor: |
| 2085 | if not isinstance(mm_input, (torch.Tensor, list)): |
| 2086 | raise ValueError(f"Incorrect type of {name}: {type(mm_input)}") |
| 2087 | if isinstance(mm_input, torch.Tensor): |
| 2088 | if mm_input.ndim == 2: |
| 2089 | return mm_input |
| 2090 | if mm_input.ndim != 3: |
| 2091 | raise ValueError( |
| 2092 | f"{name} must be 2D or batched-3D, got shape={mm_input.shape}" |
| 2093 | ) |
| 2094 | # Flatten the leading batch dim into the patch dim: (b, n, d) -> |
| 2095 | # (b*n, d), avoiding a Python list of row tensors. |
| 2096 | return mm_input.flatten(0, 1) |
| 2097 | return torch.concat(mm_input) |
| 2098 | |
| 2099 | def _parse_and_validate_image_input( |
| 2100 | self, **kwargs: object |
| 2101 | ) -> LlavaOnevision2ImageInputs | None: |
| 2102 | pixel_values = kwargs.pop("pixel_values", None) |
| 2103 | image_embeds = kwargs.pop("image_embeds", None) |
| 2104 | image_grid_thw = kwargs.pop("image_grid_thw", None) |
| 2105 | patch_positions = kwargs.pop("patch_positions", None) |
| 2106 | |
| 2107 | if pixel_values is None and image_embeds is None: |
| 2108 | return None |
| 2109 | |
| 2110 | if pixel_values is not None: |
| 2111 | pixel_values = self._validate_and_reshape_mm_tensor( |
| 2112 | pixel_values, "image pixel values" |
| 2113 | ) |
| 2114 | image_grid_thw = self._validate_and_reshape_mm_tensor( |
| 2115 | image_grid_thw, "image grid_thw" |
| 2116 | ) |
| 2117 | if patch_positions is None: |
| 2118 | raise ValueError( |
| 2119 | "OV2 requires patch_positions alongside pixel_values; " |
| 2120 | "ensure the HF processor produces it (image, video-" |
| 2121 | "frames, and video-codec backends all do)." |
| 2122 | ) |
| 2123 | patch_positions = self._validate_and_reshape_mm_tensor( |
| 2124 | patch_positions, "image patch_positions" |
| 2125 | ) |
| 2126 | return LlavaOnevision2ImagePixelInputs( |
| 2127 | type="pixel_values", |
| 2128 | pixel_values=pixel_values, |
| 2129 | image_grid_thw=image_grid_thw, |
| 2130 | patch_positions=patch_positions, |
| 2131 | ) |
| 2132 | |
| 2133 | # image_embeds path |
| 2134 | image_embeds = self._validate_and_reshape_mm_tensor( |
| 2135 | image_embeds, "image embeds" |
| 2136 | ) |
| 2137 | image_grid_thw = self._validate_and_reshape_mm_tensor( |
| 2138 | image_grid_thw, "image grid_thw" |
| 2139 | ) |
| 2140 | return LlavaOnevision2ImageEmbeddingInputs( |
| 2141 | type="image_embeds", |
| 2142 | image_embeds=image_embeds, |
| 2143 | image_grid_thw=image_grid_thw, |
| 2144 | ) |
| 2145 | |
| 2146 | def _process_image_input( |
| 2147 | self, image_input: LlavaOnevision2ImageInputs |
| 2148 | ) -> tuple[torch.Tensor, ...]: |
| 2149 | grid_thw = image_input["image_grid_thw"] |
| 2150 | assert grid_thw.ndim == 2 |
| 2151 | |
| 2152 | if isinstance(image_input, LlavaOnevision2ImageEmbeddingInputs): |
| 2153 | image_embeds = image_input["image_embeds"] |
| 2154 | else: |
| 2155 | image_embeds = self.visual( |
| 2156 | image_input["pixel_values"], |
| 2157 | grid_thw=grid_thw, |
| 2158 | patch_positions=image_input["patch_positions"], |
| 2159 | ) |
| 2160 | |
| 2161 | merge_size = self.visual.spatial_merge_size |
| 2162 | sizes = grid_thw.prod(-1) // merge_size // merge_size |
| 2163 | return image_embeds.split(sizes.tolist()) |
| 2164 | |
| 2165 | def _parse_and_validate_video_input( |
| 2166 | self, **kwargs: object |
| 2167 | ) -> LlavaOnevision2VideoPixelInputs | None: |
| 2168 | pixel_values_videos = kwargs.pop("pixel_values_videos", None) |
| 2169 | video_grid_thw = kwargs.pop("video_grid_thw", None) |
| 2170 | patch_positions_videos = kwargs.pop("patch_positions_videos", None) |
| 2171 | video_num_frames = kwargs.pop("video_num_frames", None) |
| 2172 | kwargs.pop("frame_timestamps", None) |
| 2173 | kwargs.pop("video_is_codec", None) |
| 2174 | |
| 2175 | if pixel_values_videos is None: |
| 2176 | return None |
| 2177 | |
| 2178 | pixel_values_videos = self._validate_and_reshape_mm_tensor( |
| 2179 | pixel_values_videos, "video pixel values" |
| 2180 | ) |
| 2181 | video_grid_thw = self._validate_and_reshape_mm_tensor( |
| 2182 | video_grid_thw, "video grid_thw" |
| 2183 | ) |
| 2184 | if patch_positions_videos is None: |
| 2185 | raise ValueError( |
| 2186 | "OV2 requires patch_positions_videos alongside pixel_values_videos." |
| 2187 | ) |
| 2188 | patch_positions_videos = self._validate_and_reshape_mm_tensor( |
| 2189 | patch_positions_videos, "video patch_positions" |
| 2190 | ) |
| 2191 | if video_num_frames is None: |
| 2192 | raise ValueError( |
| 2193 | "OV2 requires video_num_frames alongside pixel_values_videos." |
| 2194 | ) |
| 2195 | if isinstance(video_num_frames, list): |
| 2196 | video_num_frames = torch.cat([v.flatten() for v in video_num_frames]) |
| 2197 | elif isinstance(video_num_frames, torch.Tensor): |
| 2198 | video_num_frames = video_num_frames.flatten() |
| 2199 | else: |
| 2200 | raise TypeError( |
| 2201 | "video_num_frames must be a tensor or a list of tensors, " |
| 2202 | f"got {type(video_num_frames)}" |
| 2203 | ) |
| 2204 | return LlavaOnevision2VideoPixelInputs( |
| 2205 | type="pixel_values_videos", |
| 2206 | pixel_values_videos=pixel_values_videos, |
| 2207 | video_grid_thw=video_grid_thw, |
| 2208 | patch_positions_videos=patch_positions_videos, |
| 2209 | video_num_frames=video_num_frames, |
| 2210 | ) |
| 2211 | |
| 2212 | def _process_video_input( |
| 2213 | self, |
| 2214 | video_input: LlavaOnevision2VideoPixelInputs, |
| 2215 | ) -> tuple[torch.Tensor, ...]: |
| 2216 | # OV2 encodes each video frame independently through the same |
| 2217 | # vision stack as still images, then splits the resulting token |
| 2218 | # stream into per-video chunks using video_num_frames. |
| 2219 | grid_thw = video_input["video_grid_thw"] |
| 2220 | assert grid_thw.ndim == 2 |
| 2221 | video_embeds = self.visual( |
| 2222 | video_input["pixel_values_videos"], |
| 2223 | grid_thw=grid_thw, |
| 2224 | patch_positions=video_input["patch_positions_videos"], |
| 2225 | ) |
| 2226 | merge_size = self.visual.spatial_merge_size |
| 2227 | per_frame_tokens = grid_thw.prod(-1) // merge_size // merge_size |
| 2228 | # Aggregate per-frame token counts into per-video token counts. |
| 2229 | num_frames = video_input["video_num_frames"].tolist() |
| 2230 | sizes: list[int] = [] |
| 2231 | cursor = 0 |
| 2232 | for n in num_frames: |
| 2233 | n = int(n) |
| 2234 | sizes.append(int(per_frame_tokens[cursor : cursor + n].sum())) |
| 2235 | cursor += n |
| 2236 | return video_embeds.split(sizes) |
| 2237 | |
| 2238 | def _parse_and_validate_multimodal_inputs( |
| 2239 | self, **kwargs: object |
| 2240 | ) -> LlavaOnevision2InputsByModality: |
| 2241 | modalities = LlavaOnevision2InputsByModality() |
| 2242 | for key in kwargs: |
| 2243 | if key in ("pixel_values", "image_embeds") and "images" not in modalities: |
| 2244 | modalities["images"] = self._parse_and_validate_image_input(**kwargs) |
| 2245 | if key == "pixel_values_videos" and "videos" not in modalities: |
| 2246 | modalities["videos"] = self._parse_and_validate_video_input(**kwargs) |
| 2247 | return modalities |
| 2248 | |
| 2249 | def get_language_model(self) -> torch.nn.Module: |
| 2250 | return self.language_model |
| 2251 | |
| 2252 | def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings: |
| 2253 | modalities = self._parse_and_validate_multimodal_inputs(**kwargs) |
| 2254 | if not modalities: |
| 2255 | return [] |
| 2256 | multimodal_embeddings: tuple[torch.Tensor, ...] = () |
| 2257 | for modality in modalities: |
| 2258 | if modality == "images": |
| 2259 | image_input = modalities["images"] |
| 2260 | assert image_input is not None |
| 2261 | multimodal_embeddings += self._process_image_input(image_input) |
| 2262 | elif modality == "videos": |
| 2263 | video_input = modalities["videos"] |
| 2264 | assert video_input is not None |
| 2265 | multimodal_embeddings += self._process_video_input(video_input) |
| 2266 | return multimodal_embeddings |
| 2267 | |
| 2268 | def get_input_embeddings( |
| 2269 | self, |
| 2270 | input_ids: torch.Tensor, |
| 2271 | multimodal_embeddings: MultiModalEmbeddings | None = None, |
| 2272 | ) -> torch.Tensor: |
| 2273 | inputs_embeds = self.language_model.get_input_embeddings(input_ids) |
| 2274 | if multimodal_embeddings is not None and len(multimodal_embeddings) != 0: |
| 2275 | inputs_embeds = merge_multimodal_embeddings( |
| 2276 | inputs_embeds, |
| 2277 | multimodal_embeddings, |
| 2278 | input_ids == self.config.image_token_id, |
| 2279 | ) |
| 2280 | return inputs_embeds |
| 2281 | |
| 2282 | def forward( |
| 2283 | self, |
| 2284 | input_ids: torch.Tensor, |
| 2285 | positions: torch.Tensor, |
| 2286 | intermediate_tensors: IntermediateTensors | None = None, |
| 2287 | inputs_embeds: torch.Tensor | None = None, |
| 2288 | **kwargs: object, |
| 2289 | ) -> torch.Tensor | IntermediateTensors: |
| 2290 | # V1 precomputes multimodal ``inputs_embeds`` via ``embed_multimodal`` / |
| 2291 | # ``get_input_embeddings``, so the model forward only threads them into |
| 2292 | # the language model (image + video share the same embedding merge). |
| 2293 | if intermediate_tensors is not None: |
| 2294 | inputs_embeds = None |
| 2295 | |
| 2296 | return self.language_model.model( |
| 2297 | input_ids=input_ids, |
| 2298 | positions=positions, |
| 2299 | intermediate_tensors=intermediate_tensors, |
| 2300 | inputs_embeds=inputs_embeds, |
| 2301 | ) |
| 2302 | |
| 2303 | def compute_logits(self, hidden_states: torch.Tensor): |
| 2304 | return self.language_model.compute_logits(hidden_states) |
| 2305 | |
| 2306 | def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: |
| 2307 | loader = AutoWeightsLoader(self) |
| 2308 | return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper) |
| 2309 | |
| 2310 | def get_mm_mapping(self) -> MultiModelKeys: |
| 2311 | return MultiModelKeys.from_string_field( |
| 2312 | language_model="language_model", |
| 2313 | connector="visual.merger.", |
| 2314 | tower_model="visual.", |
| 2315 | ) |