main
py 330 lines 12.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for Mistral3's multimodal preprocessing kwargs."""
4
5 import pytest
6 import torch
7 import torch.nn.functional as F
8 from PIL import Image
9 from transformers import AutoProcessor, BatchFeature
10 from transformers.models.pixtral import PixtralProcessor
11
12 from vllm.model_executor.layers.fusion.mm_input_norm import (
13 FusedMMInputNorm,
14 IdentityInputNorm,
15 build_mm_input_norm,
16 )
17 from vllm.model_executor.models.lightonocr import (
18 LightOnOCRForConditionalGeneration,
19 LightOnOCRProcessingInfo,
20 )
21 from vllm.model_executor.models.mistral3 import Mistral3HFEncoderInfo
22 from vllm.model_executor.models.pixtral import (
23 PixtralHFEncoderInfo,
24 pixtral_patch_embed,
25 )
26 from vllm.multimodal import MULTIMODAL_REGISTRY
27 from vllm.multimodal.inputs import MultiModalKwargsItems
28 from vllm.platforms import current_platform
29 from vllm.triton_utils import HAS_TRITON
30
31 from ...utils import build_model_context
32
33 # This repo ships both params.json (Mistral) and config.json (HF). Auto config
34 # selects PixtralForConditionalGeneration; force HF to exercise Mistral3.
35 _MODEL_CONFIG_KWARGS = {"config_format": "hf"}
36 _MODEL_ID = "mistralai/Mistral-Small-3.1-24B-Instruct-2503"
37 _LIGHTON_MODEL_ID = "lightonai/LightOnOCR-1B-1025"
38
39 _RGB_MEAN = [0.48145466, 0.4578275, 0.40821073]
40 _RGB_STD = [0.26862954, 0.26130258, 0.27577711]
41 _RGB_RESCALE = 1.0 / 255.0
42
43
44 @pytest.mark.usefixtures("default_vllm_config")
45 @pytest.mark.skipif(
46 not HAS_TRITON or not current_platform.is_cuda(), reason="CUDA Triton kernel"
47 )
48 @pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
49 @pytest.mark.parametrize("normalize", [False, True])
50 @pytest.mark.parametrize(("channels", "patch_shape"), [(3, (4, 4)), (2, (2, 4))])
51 def test_pixtral_patch_embed_preserves_image_order_and_strides(
52 dtype: torch.dtype,
53 normalize: bool,
54 channels: int,
55 patch_shape: tuple[int, int],
56 ):
57 """Packed projection matches CHW normalization and patch convolution."""
58 device = torch.device(current_platform.device_type)
59 norm = (
60 FusedMMInputNorm(
61 _RGB_MEAN[:channels],
62 _RGB_STD[:channels],
63 _RGB_RESCALE,
64 channel=channels,
65 ).to(device)
66 if normalize
67 else IdentityInputNorm()
68 )
69 weight = torch.randn(32, channels, *patch_shape, device=device, dtype=dtype)
70 images = [
71 torch.randint(0, 256, (channels, 17, 18), device=device, dtype=torch.uint8),
72 torch.randint(0, 256, (channels, 25, 17), device=device, dtype=torch.uint8)[
73 :, 1:17, 1:17
74 ],
75 torch.randint(0, 256, (channels, 19, 20), device=device, dtype=torch.uint8)[
76 :, 1:17, 2:18
77 ],
78 ]
79 reference = []
80 for image in images:
81 if normalize:
82 normalized = (
83 image.float() * norm.weight[:, None, None] + norm.bias[:, None, None]
84 )
85 else:
86 normalized = image
87 reference.append(
88 F.conv2d(normalized.to(dtype).unsqueeze(0), weight, stride=patch_shape)
89 )
90 packed, actual = pixtral_patch_embed(images, weight, norm)
91 for expected, result in zip(reference, actual):
92 torch.testing.assert_close(result, expected, rtol=0.01, atol=0.02)
93 expected_packed = torch.cat(
94 [result.flatten(2).transpose(1, 2) for result in reference], dim=1
95 )
96 torch.testing.assert_close(packed, expected_packed, rtol=0.01, atol=0.02)
97
98
99 def _process_images_with_hf(
100 hf_processor,
101 images: list[Image.Image],
102 mm_processor_kwargs: dict[str, object],
103 ) -> tuple[list[torch.Tensor], list[int]]:
104 """Process images and placeholders through the public HF processor."""
105 hf_out = hf_processor(
106 text=hf_processor.image_token * len(images),
107 images=images,
108 return_tensors="pt",
109 **mm_processor_kwargs,
110 )
111 pixel_values = hf_out["pixel_values"]
112 image_sizes = hf_out["image_sizes"]
113 unpadded = [p[:, :h, :w] for p, (h, w) in zip(pixel_values, image_sizes)]
114
115 special_token_ids = {
116 hf_processor.image_token_id,
117 hf_processor.image_break_token_id,
118 hf_processor.image_end_token_id,
119 }
120 placeholder_tokens = [
121 token_id
122 for token_id in hf_out["input_ids"][0].tolist()
123 if token_id in special_token_ids
124 ]
125 return unpadded, placeholder_tokens
126
127
128 def _placeholder_tokens_from_prompt_updates(
129 processor,
130 images: list[Image.Image],
131 pixel_values: list[torch.Tensor],
132 mm_processor_kwargs: dict[str, object],
133 ) -> list[int]:
134 hf_inputs = BatchFeature({"pixel_values": pixel_values})
135 fields_config = processor._get_mm_fields_config(hf_inputs, mm_processor_kwargs)
136 out_mm_kwargs = MultiModalKwargsItems.from_hf_inputs(hf_inputs, fields_config)
137 # Prompt updates use raw PIL sizes and must predict the processed grid.
138 mm_items = processor.info.parse_mm_data({"image": images})
139 updates = processor._get_prompt_updates(
140 mm_items, mm_processor_kwargs, out_mm_kwargs
141 )
142 placeholder_tokens: list[int] = []
143 for item_idx in range(len(images)):
144 details = updates[0].resolve(item_idx).content
145 placeholder_tokens.extend(details.full)
146 return placeholder_tokens
147
148
149 def _expected_placeholder_tokens_per_image(
150 hf_processor,
151 pixel_values: torch.Tensor,
152 ) -> int:
153 """Count projected tokens from the actual HF-processed H×W."""
154 image_h, image_w = pixel_values.shape[-2:]
155 patch_size = hf_processor.image_processor.patch_size
156 if isinstance(patch_size, dict):
157 patch_h = patch_size["height"]
158 patch_w = patch_size["width"]
159 else:
160 patch_h = patch_w = int(patch_size)
161
162 spatial_merge_size = getattr(hf_processor, "spatial_merge_size", 1)
163 merged_patch_h = patch_h * spatial_merge_size
164 merged_patch_w = patch_w * spatial_merge_size
165 assert image_h % merged_patch_h == 0
166 assert image_w % merged_patch_w == 0
167
168 return (image_h // merged_patch_h) * (image_w // merged_patch_w)
169
170
171 @pytest.mark.parametrize("model_id", [_MODEL_ID])
172 @pytest.mark.parametrize(
173 ("mm_processor_kwargs", "image_size", "expected_toks_per_img"),
174 [
175 ({}, (448, 448), 256),
176 ({"size": {"longest_edge": 1008}}, (1540, 1540), 1296),
177 (
178 {"images_kwargs": {"size": {"longest_edge": 1008}}},
179 (1540, 1540),
180 1296,
181 ),
182 ({"size": {"longest_edge": 1288}}, (1536, 1187), 1656),
183 ({"size": {"longest_edge": 1008}}, (29, 29), 4),
184 ({"size": {"longest_edge": 1000}}, (1540, 1700), 1188),
185 ],
186 )
187 @pytest.mark.parametrize("num_imgs", [1, 2])
188 @pytest.mark.parametrize("kwargs_on_init", [True, False])
189 def test_processor_size_override(
190 model_id: str,
191 mm_processor_kwargs: dict[str, object],
192 image_size: tuple[int, int],
193 expected_toks_per_img: int,
194 num_imgs: int,
195 kwargs_on_init: bool,
196 ):
197 ctx = build_model_context(
198 model_id,
199 mm_processor_kwargs=mm_processor_kwargs if kwargs_on_init else None,
200 limit_mm_per_prompt={"image": num_imgs},
201 model_config_kwargs=_MODEL_CONFIG_KWARGS,
202 )
203 processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
204 hf_processor_mm_kwargs = {} if kwargs_on_init else mm_processor_kwargs
205 vllm_hf_processor = processor.info.get_hf_processor(**hf_processor_mm_kwargs)
206 assert isinstance(vllm_hf_processor, PixtralProcessor)
207
208 hf_processor = AutoProcessor.from_pretrained(
209 model_id,
210 fix_mistral_regex=True,
211 )
212
213 dummy_image = Image.new("RGB", image_size, color=(127, 127, 127))
214 images = [dummy_image] * num_imgs
215 merged_mm_kwargs = processor.info.ctx.get_merged_mm_kwargs(hf_processor_mm_kwargs)
216 pixel_values, hf_placeholder_tokens = _process_images_with_hf(
217 hf_processor, images, merged_mm_kwargs
218 )
219
220 prompt_update_tokens = _placeholder_tokens_from_prompt_updates(
221 processor, images, pixel_values, hf_processor_mm_kwargs
222 )
223 expected_from_pixel_values = _expected_placeholder_tokens_per_image(
224 hf_processor, pixel_values[0]
225 )
226 assert expected_from_pixel_values == expected_toks_per_img
227 assert hf_placeholder_tokens.count(hf_processor.image_token_id) == (
228 expected_from_pixel_values * num_imgs
229 )
230 assert prompt_update_tokens == hf_placeholder_tokens
231
232
233 @pytest.mark.usefixtures("default_vllm_config")
234 def test_mm_device_do_normalize():
235 ctx = build_model_context(
236 _MODEL_ID,
237 limit_mm_per_prompt={"image": 2},
238 model_config_kwargs=_MODEL_CONFIG_KWARGS,
239 )
240 assert ctx.model_config.multimodal_config.mm_device_do_normalize
241
242 ctx.model_config.multimodal_config.mm_device_do_normalize = False
243 processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
244 images = [
245 Image.new("RGB", (31, 47), color=(17, 89, 231)),
246 Image.new("RGB", (48, 32), color=(201, 13, 127)),
247 ]
248 prompt = [processor.info.get_hf_config().image_token_index] * len(images)
249 mm_items = processor.info.parse_mm_data({"image": images})
250
251 normalized_inputs = processor(prompt, mm_items=mm_items)
252 normalized_values = normalized_inputs["mm_kwargs"].get_data()["pixel_values"]
253
254 ctx.model_config.multimodal_config.mm_device_do_normalize = True
255 raw_inputs = processor(prompt, mm_items=mm_items)
256 raw_values = raw_inputs["mm_kwargs"].get_data()["pixel_values"]
257 assert all(value.dtype == torch.uint8 for value in raw_values)
258
259 input_norm = build_mm_input_norm(ctx.model_config)
260 patch_size = processor.info.get_hf_config().vision_config.patch_size
261
262 def pack_patches(image: torch.Tensor) -> torch.Tensor:
263 channels, height, width = image.shape
264 rows = height // patch_size
265 cols = width // patch_size
266 return (
267 image[:, : rows * patch_size, : cols * patch_size]
268 .reshape(channels, rows, patch_size, cols, patch_size)
269 .permute(1, 3, 0, 2, 4)
270 .reshape(-1, channels * patch_size**2)
271 )
272
273 for raw, normalized in zip(raw_values, normalized_values):
274 output = input_norm(pack_patches(raw), normalized.dtype)
275 torch.testing.assert_close(
276 output, pack_patches(normalized), rtol=1e-5, atol=1e-6
277 )
278
279
280 def test_scoped_request_size_overrides_configured_flat_size():
281 ctx = build_model_context(
282 _MODEL_ID,
283 mm_processor_kwargs={"size": {"longest_edge": 1008}},
284 limit_mm_per_prompt={"image": 1},
285 model_config_kwargs=_MODEL_CONFIG_KWARGS,
286 )
287 processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
288 hf_processor = AutoProcessor.from_pretrained(
289 _MODEL_ID,
290 fix_mistral_regex=True,
291 )
292
293 hf_processor_mm_kwargs: dict[str, object] = {
294 "images_kwargs": {"size": {"longest_edge": 1288}}
295 }
296 image = Image.new("RGB", (1536, 1187), color=(127, 127, 127))
297
298 merged_mm_kwargs = processor.info.ctx.get_merged_mm_kwargs(hf_processor_mm_kwargs)
299 pixel_values, hf_placeholder_tokens = _process_images_with_hf(
300 hf_processor, [image], merged_mm_kwargs
301 )
302 prompt_update_tokens = _placeholder_tokens_from_prompt_updates(
303 processor, [image], pixel_values, hf_processor_mm_kwargs
304 )
305
306 expected_from_pixel_values = _expected_placeholder_tokens_per_image(
307 hf_processor, pixel_values[0]
308 )
309 assert expected_from_pixel_values == 1656
310 assert hf_placeholder_tokens.count(hf_processor.image_token_id) == 1656
311 assert prompt_update_tokens == hf_placeholder_tokens
312
313
314 def test_lightonocr_keeps_vision_config_image_size():
315 assert not LightOnOCRForConditionalGeneration.supports_mm_device_do_normalize
316
317 ctx = build_model_context(
318 _LIGHTON_MODEL_ID,
319 mm_processor_kwargs={"size": {"longest_edge": 1008}},
320 model_config_kwargs=_MODEL_CONFIG_KWARGS,
321 )
322 processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
323
324 assert isinstance(processor.info, LightOnOCRProcessingInfo)
325 encoder_info = processor.info.get_vision_encoder_info()
326 assert isinstance(encoder_info, PixtralHFEncoderInfo)
327 assert not isinstance(encoder_info, Mistral3HFEncoderInfo)
328 assert encoder_info.get_image_size() == (
329 processor.info.get_hf_config().vision_config.image_size
330 )