[Model] Add LLaVA-OneVision-2 (LlavaOnevision2ForConditionalGeneration) (#44785)

Signed-off-by: chengzheng345 <209475443+chengzheng345@users.noreply.github.com> Co-authored-by: chengzheng345 <209475443+chengzheng345@users.noreply.github.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

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