187 lines
6.1 KiB
Python
187 lines
6.1 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Regression tests for Qwen3-VL processor.
|
|
|
|
Covers the fix for num_frames-based timestamp calculation
|
|
(issue vllm-project/vllm#35909).
|
|
"""
|
|
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from vllm.multimodal import MULTIMODAL_REGISTRY
|
|
|
|
from ...utils import build_model_context
|
|
|
|
MODEL_ID = "Qwen/Qwen3-VL-4B-Instruct"
|
|
|
|
|
|
def _build_video_mm_data(
|
|
num_frames: int,
|
|
width: int = 128,
|
|
height: int = 128,
|
|
original_fps: float = 30.0,
|
|
) -> dict[str, Any]:
|
|
"""Create synthetic video data with metadata indicating that
|
|
HF processor should re-sample frames (do_sample_frames=True).
|
|
|
|
``total_num_frames`` is set equal to the ndarray frame count so
|
|
that HF's ``sample_frames`` indices stay within bounds of the
|
|
actual tensor that is passed."""
|
|
video = np.zeros((num_frames, height, width, 3), dtype=np.uint8)
|
|
metadata = {
|
|
"fps": original_fps,
|
|
"duration": num_frames / original_fps,
|
|
"total_num_frames": num_frames,
|
|
"frames_indices": list(range(num_frames)),
|
|
"video_backend": "opencv",
|
|
"do_sample_frames": True,
|
|
}
|
|
return {"video": [(video, metadata)]}
|
|
|
|
|
|
@pytest.mark.parametrize("model_id", [MODEL_ID])
|
|
@pytest.mark.parametrize(
|
|
"num_frames",
|
|
[8, 16],
|
|
)
|
|
def test_processor_num_frames_timestamp(
|
|
model_id: str,
|
|
num_frames: int,
|
|
) -> None:
|
|
"""Regression test: using ``num_frames`` (without ``fps``) must not
|
|
cause a timestamp / token-count mismatch.
|
|
|
|
Before the fix, ``_get_video_second_idx`` ignored the explicit
|
|
``num_frames`` and fell back to an fps-based calculation, which
|
|
produced a different number of timestamp entries and ultimately led
|
|
to shape mismatches in downstream token construction.
|
|
|
|
We deliberately choose ``num_frames`` values (8, 16) that differ
|
|
from what the default fps-based path would compute (which clamps
|
|
to ``min_frames=4`` for a short video at 30 fps), so this test
|
|
would fail without the fix.
|
|
"""
|
|
ctx = build_model_context(
|
|
model_id,
|
|
limit_mm_per_prompt={"image": 0, "video": 1},
|
|
)
|
|
processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
|
|
|
|
prompt = "<|vision_start|><|video_pad|><|vision_end|>"
|
|
mm_data = _build_video_mm_data(num_frames=num_frames)
|
|
|
|
# Process with explicit num_frames (no fps) -- this is the path
|
|
# that was broken before the fix.
|
|
hf_mm_kwargs: dict[str, Any] = {"num_frames": num_frames}
|
|
processed = processor(
|
|
prompt,
|
|
mm_items=processor.info.parse_mm_data(mm_data),
|
|
hf_processor_mm_kwargs=hf_mm_kwargs,
|
|
)
|
|
|
|
# Basic sanity: the processor must produce video tokens.
|
|
token_ids = processed["prompt_token_ids"]
|
|
assert len(token_ids) > 0, "Processor produced empty token list"
|
|
|
|
# Verify that video placeholders were actually inserted.
|
|
assert "mm_placeholders" in processed
|
|
video_phs = processed["mm_placeholders"].get("video", [])
|
|
assert len(video_phs) == 1, (
|
|
f"Expected exactly 1 video placeholder, got {len(video_phs)}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("model_id", [MODEL_ID])
|
|
@pytest.mark.parametrize("num_videos", [2, 4])
|
|
def test_processor_multi_video(
|
|
model_id: str,
|
|
num_videos: int,
|
|
) -> None:
|
|
"""Verify that multi-video processing produces correct placeholders.
|
|
|
|
This exercises the token-level replacement path in
|
|
``_call_hf_processor`` which avoids the quadratic text-level
|
|
prompt expansion.
|
|
"""
|
|
ctx = build_model_context(
|
|
model_id,
|
|
limit_mm_per_prompt={"image": 0, "video": num_videos},
|
|
)
|
|
processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
|
|
|
|
prompt = "<|vision_start|><|video_pad|><|vision_end|>" * num_videos
|
|
mm_data = {"video": [_build_video_mm_data(num_frames=8)["video"][0]] * num_videos}
|
|
|
|
processed = processor(
|
|
prompt,
|
|
mm_items=processor.info.parse_mm_data(mm_data),
|
|
hf_processor_mm_kwargs={"num_frames": 8},
|
|
)
|
|
|
|
token_ids = processed["prompt_token_ids"]
|
|
assert len(token_ids) > 0
|
|
|
|
video_phs = processed["mm_placeholders"].get("video", [])
|
|
assert len(video_phs) == num_videos, (
|
|
f"Expected {num_videos} video placeholders, got {len(video_phs)}"
|
|
)
|
|
|
|
# All placeholders should have the same length (same video params)
|
|
# and must not overlap.
|
|
lengths = {ph.length for ph in video_phs}
|
|
assert len(lengths) == 1, f"Placeholder lengths differ: {lengths}"
|
|
for i in range(1, len(video_phs)):
|
|
prev_end = video_phs[i - 1].offset + video_phs[i - 1].length
|
|
assert video_phs[i].offset >= prev_end, (
|
|
f"Placeholder {i} overlaps with placeholder {i - 1}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("model_id", [MODEL_ID])
|
|
@pytest.mark.parametrize(
|
|
"hf_mm_kwargs",
|
|
[{"num_frames": [8, 16]}, {"fps": [2.0, 4.0]}],
|
|
)
|
|
def test_processor_multi_video_list_kwargs(
|
|
model_id: str,
|
|
hf_mm_kwargs: dict[str, Any],
|
|
) -> None:
|
|
"""Regression test: a multi-video request with list-valued per-video
|
|
``mm_processor_kwargs`` (one ``fps``/``num_frames`` per video) must not
|
|
crash.
|
|
|
|
Before the fix, ``_call_hf_processor`` copied the whole kwargs to every
|
|
video without slicing, so ``_get_video_second_idx`` received the list
|
|
where a scalar was expected and raised ``TypeError``.
|
|
"""
|
|
ctx = build_model_context(
|
|
model_id,
|
|
limit_mm_per_prompt={"image": 0, "video": 2},
|
|
)
|
|
processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
|
|
|
|
prompt = (
|
|
"<|vision_start|><|video_pad|><|vision_end|>"
|
|
"<|vision_start|><|video_pad|><|vision_end|>"
|
|
)
|
|
mm_data = {
|
|
"video": [
|
|
_build_video_mm_data(num_frames=16)["video"][0],
|
|
_build_video_mm_data(num_frames=32)["video"][0],
|
|
]
|
|
}
|
|
|
|
processed = processor(
|
|
prompt,
|
|
mm_items=processor.info.parse_mm_data(mm_data),
|
|
hf_processor_mm_kwargs=hf_mm_kwargs,
|
|
)
|
|
|
|
video_phs = processed["mm_placeholders"].get("video", [])
|
|
assert len(video_phs) == 2, (
|
|
f"Expected exactly 2 video placeholders, got {len(video_phs)}"
|
|
)
|