main
py 484 lines 15.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from copy import deepcopy
5 from types import SimpleNamespace
6 from typing import Any
7
8 import numpy as np
9 import pytest
10 import torch
11 from PIL import Image
12
13 from vllm.config.multimodal import MultiModalConfig
14 from vllm.model_executor.layers.fusion.mm_input_norm import build_mm_input_norm
15 from vllm.model_executor.models.qwen2_vl import Qwen2VLProcessingInfo
16 from vllm.multimodal import MULTIMODAL_REGISTRY
17 from vllm.multimodal.cache import MultiModalProcessorOnlyCache
18 from vllm.multimodal.inputs import batched_tensors_equal
19 from vllm.multimodal.processing.context import InputProcessingContext
20 from vllm.platforms import current_platform
21
22 from ....conftest import ImageTestAssets
23 from ...utils import build_model_context
24
25
26 @pytest.mark.parametrize(
27 ("mm_kwargs", "expected"),
28 [
29 (
30 {"size": {"shortest_edge": 64, "longest_edge": 1024}},
31 {
32 "size": {"shortest_edge": 64, "longest_edge": 1024},
33 "min_pixels": 64,
34 "max_pixels": 1024,
35 },
36 ),
37 (
38 {"min_pixels": 64, "max_pixels": 1024},
39 {
40 "min_pixels": 64,
41 "max_pixels": 1024,
42 "size": {"shortest_edge": 64, "longest_edge": 1024},
43 },
44 ),
45 (
46 {
47 "size": {"shortest_edge": 64, "longest_edge": 1024},
48 "min_pixels": 128,
49 "max_pixels": 2048,
50 },
51 {
52 "size": {"shortest_edge": 64, "longest_edge": 1024},
53 "min_pixels": 128,
54 "max_pixels": 2048,
55 },
56 ),
57 (
58 {
59 "min_pixels": None,
60 "size": {"shortest_edge": 0},
61 "images_kwargs": {"max_pixels": 4096},
62 "videos_kwargs": {"size": {"longest_edge": 8192}},
63 },
64 {
65 "min_pixels": None,
66 "size": {"shortest_edge": 0},
67 "images_kwargs": {
68 "max_pixels": 4096,
69 "size": {"longest_edge": 4096},
70 },
71 "videos_kwargs": {
72 "size": {"longest_edge": 8192},
73 "max_pixels": 8192,
74 },
75 },
76 ),
77 (
78 {"min_pixels": 0},
79 {"min_pixels": 0, "size": {"shortest_edge": 0}},
80 ),
81 (
82 {"size": None, "min_pixels": 64},
83 {"size": {"shortest_edge": 64}, "min_pixels": 64},
84 ),
85 ],
86 )
87 def test_complete_mm_processor_size_aliases(
88 mm_kwargs: dict[str, object],
89 expected: dict[str, object],
90 ) -> None:
91 original = deepcopy(mm_kwargs)
92
93 actual = Qwen2VLProcessingInfo._complete_mm_processor_size_aliases(mm_kwargs)
94
95 assert actual == expected
96 assert mm_kwargs == original
97
98
99 def test_merge_and_resolve_mm_processor_kwargs_preserves_request_alias_precedence(
100 monkeypatch: pytest.MonkeyPatch,
101 ) -> None:
102 mm_config = MultiModalConfig(
103 mm_processor_kwargs={
104 "images_kwargs": {"min_pixels": 64},
105 },
106 mm_device_do_normalize=False,
107 )
108 model_config = SimpleNamespace(get_multimodal_config=lambda: mm_config)
109 ctx = InputProcessingContext(model_config, tokenizer=None)
110 info = Qwen2VLProcessingInfo(ctx)
111 monkeypatch.setattr(
112 info,
113 "get_supported_mm_processor_kwargs",
114 lambda: {"images_kwargs": {"size", "min_pixels"}},
115 )
116
117 assert info._merge_and_resolve_mm_processor_kwargs(
118 {"size": {"shortest_edge": 128}}
119 ) == {
120 "images_kwargs": {
121 "size": {"shortest_edge": 128},
122 "min_pixels": 128,
123 },
124 }
125
126
127 @pytest.mark.parametrize(
128 ("mm_kwargs", "default_size", "expected"),
129 [
130 (
131 {},
132 {"shortest_edge": 32, "longest_edge": 1024},
133 {"shortest_edge": 32, "longest_edge": 1024},
134 ),
135 (
136 {"size": {"shortest_edge": 64}},
137 {"shortest_edge": 32, "longest_edge": 1024},
138 {"shortest_edge": 64, "longest_edge": 1024},
139 ),
140 (
141 {
142 "size": {"shortest_edge": 64, "longest_edge": 2048},
143 "min_pixels": 128,
144 "max_pixels": 4096,
145 },
146 {"shortest_edge": 32, "longest_edge": 1024},
147 {"shortest_edge": 128, "longest_edge": 4096},
148 ),
149 (
150 {
151 "size": {"shortest_edge": 64},
152 "max_pixels": 4096,
153 },
154 None,
155 {"shortest_edge": 64, "longest_edge": 4096},
156 ),
157 (
158 {
159 "size": {"shortest_edge": 64, "longest_edge": 2048},
160 "min_pixels": None,
161 "max_pixels": 0,
162 },
163 {"shortest_edge": 32, "longest_edge": 1024},
164 {"shortest_edge": 64, "longest_edge": 0},
165 ),
166 ],
167 )
168 def test_get_vision_size(
169 mm_kwargs: dict[str, object],
170 default_size: dict[str, object] | None,
171 expected: dict[str, object],
172 ) -> None:
173 original_mm_kwargs = {
174 key: dict(value) if isinstance(value, dict) else value
175 for key, value in mm_kwargs.items()
176 }
177 original_default_size = dict(default_size) if default_size is not None else None
178
179 actual = Qwen2VLProcessingInfo._get_vision_size(
180 mm_kwargs,
181 default_size=default_size,
182 )
183
184 assert actual == expected
185 assert mm_kwargs == original_mm_kwargs
186 assert default_size == original_default_size
187
188
189 @pytest.mark.parametrize(
190 ("modality", "scope"),
191 [
192 ("image", "images_kwargs"),
193 ("video", "videos_kwargs"),
194 ],
195 )
196 def test_get_vision_info_reads_merged_modality_scope(
197 monkeypatch: pytest.MonkeyPatch,
198 modality: str,
199 scope: str,
200 ) -> None:
201 vision_config = SimpleNamespace(
202 patch_size=14,
203 spatial_merge_size=2,
204 temporal_patch_size=2,
205 )
206 ctx = SimpleNamespace(
207 get_hf_config=lambda *_: SimpleNamespace(vision_config=vision_config)
208 )
209 info = Qwen2VLProcessingInfo(ctx)
210 merged = {
211 "images_kwargs": {"size": {"shortest_edge": 111}},
212 "videos_kwargs": {"size": {"shortest_edge": 222}},
213 }
214 monkeypatch.setattr(
215 info,
216 "_merge_and_resolve_mm_processor_kwargs",
217 lambda _: merged,
218 )
219
220 seen: list[dict[str, object]] = []
221
222 def get_size(
223 mm_kwargs: dict[str, object],
224 default_size: dict[str, object] | None = None,
225 ) -> dict[str, object]:
226 seen.append(mm_kwargs)
227 return dict(default_size or {})
228
229 monkeypatch.setattr(info, "_get_vision_size", get_size)
230 image_processor = SimpleNamespace(size={"shortest_edge": 32, "longest_edge": 1024})
231
232 info._get_vision_info(
233 image_width=28,
234 image_height=28,
235 num_frames=2,
236 do_resize=False,
237 image_processor=image_processor,
238 mm_kwargs={},
239 modality=modality,
240 )
241
242 assert seen == [merged[scope]]
243
244
245 def test_jina_vl_processing_order() -> None:
246 """Jina's document-first prompt keeps cached features and hashes aligned."""
247 ctx = build_model_context(
248 "jinaai/jina-reranker-m0",
249 runner="pooling",
250 limit_mm_per_prompt={"image": 2},
251 mm_processor_cache_gb=1,
252 )
253 cache = MultiModalProcessorOnlyCache(ctx.model_config)
254 processor = MULTIMODAL_REGISTRY.create_processor(
255 ctx.model_config,
256 tokenizer=ctx.tokenizer,
257 )
258
259 placeholder = "<|vision_start|><|image_pad|><|vision_end|>"
260 query_image = Image.new("RGB", (128, 160), color=(255, 0, 0))
261 document_image = Image.new("RGB", (192, 128), color=(0, 255, 0))
262
263 def process(images: list[Image.Image]):
264 return processor(
265 placeholder * len(images),
266 mm_items=processor.info.parse_mm_data({"image": images}),
267 cache=cache,
268 )
269
270 query = process([query_image])
271 document = process([document_image])
272 pair = process([query_image, document_image])
273
274 pair_items = pair["mm_kwargs"]["image"]
275 assert pair["mm_hashes"]["image"] == [
276 document["mm_hashes"]["image"][0],
277 query["mm_hashes"]["image"][0],
278 ]
279 assert batched_tensors_equal(
280 pair_items[0].get_data(),
281 document["mm_kwargs"]["image"][0].get_data(),
282 )
283 assert batched_tensors_equal(
284 pair_items[1].get_data(),
285 query["mm_kwargs"]["image"][0].get_data(),
286 )
287 assert [item.length for item in pair["mm_placeholders"]["image"]] == [
288 document["mm_placeholders"]["image"][0].length,
289 query["mm_placeholders"]["image"][0].length,
290 ]
291
292
293 @pytest.mark.parametrize("model_id", ["Qwen/Qwen2-VL-2B-Instruct"])
294 @pytest.mark.parametrize(
295 ("mm_processor_kwargs", "expected_toks_per_img", "expected_pixels_shape"),
296 [
297 ({}, 1426, (5704, 1176)),
298 ({"min_pixels": 64**2, "max_pixels": 512**2}, 330, (1320, 1176)),
299 (
300 {
301 "size": {
302 "shortest_edge": 64**2,
303 "longest_edge": 512**2,
304 },
305 },
306 330,
307 (1320, 1176),
308 ),
309 ],
310 )
311 @pytest.mark.parametrize("num_imgs", [1, 2])
312 @pytest.mark.parametrize("kwargs_on_init", [True, False])
313 def test_processor_override(
314 image_assets: ImageTestAssets,
315 model_id: str,
316 mm_processor_kwargs: dict[str, object],
317 expected_toks_per_img: int,
318 expected_pixels_shape: tuple[int, int],
319 num_imgs: int,
320 kwargs_on_init: bool,
321 ):
322 """Ensure Qwen2VLMultiModalProcessor handles min/max pixels properly."""
323 ctx = build_model_context(
324 model_id,
325 mm_processor_kwargs=mm_processor_kwargs if kwargs_on_init else None,
326 limit_mm_per_prompt={"image": num_imgs},
327 )
328 processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
329 tokenizer = processor.info.get_tokenizer()
330 hf_processor_mm_kwargs = {} if kwargs_on_init else mm_processor_kwargs
331
332 # Build the image str / prompt based on the number of images we pass
333 prompt = "<|vision_start|><|image_pad|><|vision_end|>" * num_imgs
334 mm_data = {"image": [image_assets[0].pil_image] * num_imgs}
335
336 processed_inputs = processor(
337 prompt,
338 mm_items=processor.info.parse_mm_data(mm_data),
339 hf_processor_mm_kwargs=hf_processor_mm_kwargs,
340 )
341
342 # Ensure we have the right number of placeholders per num_crops size
343 hf_processor = processor.info.get_hf_processor(**hf_processor_mm_kwargs)
344 image_token_id = tokenizer.convert_tokens_to_ids(hf_processor.image_token)
345 img_tok_count = processed_inputs["prompt_token_ids"].count(image_token_id)
346 pixel_shape = processed_inputs["mm_kwargs"].get_data()["pixel_values"].shape
347
348 assert img_tok_count == expected_toks_per_img * num_imgs
349 assert pixel_shape[0] == expected_pixels_shape[0] * num_imgs
350 assert pixel_shape[1] == expected_pixels_shape[1]
351
352
353 @pytest.mark.parametrize("model_id", ["Qwen/Qwen2-VL-2B-Instruct"])
354 @pytest.mark.parametrize(
355 "mm_processor_kwargs",
356 [
357 {"min_pixels": 28 * 28, "max_pixels": 1280 * 28 * 28},
358 {"min_pixels": 28 * 28, "max_pixels": 1283 * 28 * 28},
359 {"size": {"shortest_edge": 28 * 28, "longest_edge": 1280 * 28 * 28}},
360 {"size": {"shortest_edge": 28 * 28, "longest_edge": 1283 * 28 * 28}},
361 ],
362 )
363 def test_get_image_size_with_most_features(
364 image_assets: ImageTestAssets,
365 model_id: str,
366 mm_processor_kwargs: dict[str, object],
367 ):
368 ctx = build_model_context(
369 model_id,
370 mm_processor_kwargs=mm_processor_kwargs,
371 limit_mm_per_prompt={"image": 1},
372 )
373 processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
374
375 hf_processor = processor.info.get_hf_processor(**mm_processor_kwargs)
376 merge_size = processor.info.get_hf_config().vision_config.spatial_merge_size
377
378 max_image_size = processor.info.get_image_size_with_most_features()
379 max_tokens = processor.info.get_num_image_tokens(
380 image_width=max_image_size.width,
381 image_height=max_image_size.height,
382 image_processor=hf_processor.image_processor,
383 mm_kwargs=mm_processor_kwargs,
384 )
385
386 prompt = "<|vision_start|><|image_pad|><|vision_end|>"
387 for asset in image_assets:
388 mm_data = {"image": [asset.pil_image]}
389 processed_inputs = processor(
390 prompt,
391 mm_items=processor.info.parse_mm_data(mm_data),
392 hf_processor_mm_kwargs=mm_processor_kwargs,
393 )
394 grid_thw = processed_inputs["mm_kwargs"].get_data()["image_grid_thw"].tolist()
395 t, h, w = grid_thw[0]
396 tokens = (t * h * w) // (merge_size**2)
397 assert tokens < max_tokens
398
399
400 def _build_qwen2_5_vl_video_mm_data(num_frames: int, fps: float) -> dict[str, Any]:
401 video = np.zeros((num_frames, 56, 56, 3), dtype=np.uint8)
402 metadata = {
403 "fps": fps,
404 "duration": num_frames / fps,
405 "total_num_frames": num_frames,
406 "frames_indices": list(range(num_frames)),
407 "video_backend": "opencv",
408 "do_sample_frames": False,
409 }
410 return {"video": [(video, metadata)]}
411
412
413 @pytest.mark.parametrize("model_id", ["Qwen/Qwen2.5-VL-3B-Instruct"])
414 def test_qwen2_5_vl_video_fps_to_second_per_grid_ts(model_id: str) -> None:
415 ctx = build_model_context(model_id, limit_mm_per_prompt={"image": 0, "video": 1})
416 processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
417 temporal_patch_size = (
418 processor.info.get_hf_processor().video_processor.temporal_patch_size
419 )
420
421 prompt = "<|vision_start|><|video_pad|><|vision_end|>"
422 for fps in (1.0, 30.0):
423 mm_data = _build_qwen2_5_vl_video_mm_data(num_frames=32, fps=fps)
424 processed = processor(prompt, mm_items=processor.info.parse_mm_data(mm_data))
425 spg = processed["mm_kwargs"]["video"][0].get_data()["second_per_grid_ts"]
426 assert float(spg) == pytest.approx(temporal_patch_size / fps, rel=2e-2)
427
428
429 @pytest.mark.usefixtures("default_vllm_config")
430 @pytest.mark.parametrize(
431 "model_id", ["Qwen/Qwen2-VL-2B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct"]
432 )
433 @pytest.mark.parametrize("num_imgs", [1, 2])
434 def test_mm_device_do_normalize(
435 image_assets: ImageTestAssets, model_id: str, num_imgs: int
436 ) -> None:
437 """Device-side normalisation must reproduce the on-CPU processor result.
438
439 Runs on any platform: the CPU platform exercises the ``forward_native``
440 fallback, accelerators exercise the fused kernel.
441 """
442 device = current_platform.device_type
443 ctx = build_model_context(
444 model_id,
445 limit_mm_per_prompt={"image": num_imgs},
446 )
447 ctx.model_config.multimodal_config.mm_device_do_normalize = False
448 processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
449
450 # Build the image str / prompt based on the number of images we pass
451 prompt = "<|vision_start|><|image_pad|><|vision_end|>" * num_imgs
452 mm_data = {"image": [image_assets[0].pil_image] * num_imgs}
453
454 processed_inputs_with_normalize = processor(
455 prompt,
456 mm_items=processor.info.parse_mm_data(mm_data),
457 )
458 pixel_values_with_normalize = processed_inputs_with_normalize[
459 "mm_kwargs"
460 ].get_data()["pixel_values"]
461 dtype = pixel_values_with_normalize.dtype
462
463 processed_inputs_without_normalize = processor(
464 prompt,
465 mm_items=processor.info.parse_mm_data(mm_data),
466 hf_processor_mm_kwargs={"do_normalize": False, "do_rescale": False},
467 )
468 pixel_values_without_normalize = processed_inputs_without_normalize[
469 "mm_kwargs"
470 ].get_data()["pixel_values"]
471
472 ctx.model_config.multimodal_config.mm_device_do_normalize = True
473 input_norm = build_mm_input_norm(ctx.model_config).to(device)
474
475 # With normalisation disabled, the processor emits raw uint8 pixels,
476 # matching the production mm_device_do_normalize path.
477 assert pixel_values_without_normalize.dtype == torch.uint8
478 pixel_values_do_input_norm = input_norm(
479 pixel_values_without_normalize.to(device), dtype
480 )
481
482 torch.testing.assert_close(
483 pixel_values_with_normalize.to(device), pixel_values_do_input_norm
484 )