Add vLLM v0.18.1 source tree with KV transfer abort fix
third_party/vllm/ now tracked in git for direct patch management.
Based on vLLM v0.18.1 release with one patch applied:
vllm/v1/core/sched/scheduler.py:
Replace fatal assert with graceful skip when KV transfer callback
arrives for an already-aborted request during PD disaggregated serving.
Future vLLM modifications should be made directly in third_party/vllm/
and committed normally. The patches/ directory is kept as documentation
of what changed from upstream.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
0
third_party/vllm/tests/multimodal/__init__.py
vendored
Normal file
0
third_party/vllm/tests/multimodal/__init__.py
vendored
Normal file
BIN
third_party/vllm/tests/multimodal/assets/corrupted.mp4
vendored
Normal file
BIN
third_party/vllm/tests/multimodal/assets/corrupted.mp4
vendored
Normal file
Binary file not shown.
BIN
third_party/vllm/tests/multimodal/assets/image1.png
vendored
Normal file
BIN
third_party/vllm/tests/multimodal/assets/image1.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
third_party/vllm/tests/multimodal/assets/image2.png
vendored
Normal file
BIN
third_party/vllm/tests/multimodal/assets/image2.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
third_party/vllm/tests/multimodal/assets/rgba.png
vendored
Normal file
BIN
third_party/vllm/tests/multimodal/assets/rgba.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 219 KiB |
0
third_party/vllm/tests/multimodal/media/__init__.py
vendored
Normal file
0
third_party/vllm/tests/multimodal/media/__init__.py
vendored
Normal file
84
third_party/vllm/tests/multimodal/media/test_audio.py
vendored
Normal file
84
third_party/vllm/tests/multimodal/media/test_audio.py
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vllm.multimodal.media import AudioMediaIO
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
ASSETS_DIR = Path(__file__).parent.parent / "assets"
|
||||
assert ASSETS_DIR.exists()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_audio():
|
||||
return np.array([0.0, 0.1, 0.2, 0.3, 0.4], dtype=float)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_audio_bytes():
|
||||
return b"FAKEAUDIOBYTES"
|
||||
|
||||
|
||||
def test_audio_media_io_load_bytes(dummy_audio_bytes):
|
||||
audio_io = AudioMediaIO()
|
||||
with patch("librosa.load") as mock_load:
|
||||
mock_load.return_value = (np.array([0.1, 0.2]), 16000)
|
||||
out = audio_io.load_bytes(dummy_audio_bytes)
|
||||
mock_load.assert_called_once()
|
||||
assert isinstance(out[0], np.ndarray)
|
||||
assert out[1] == 16000
|
||||
|
||||
|
||||
def test_audio_media_io_load_base64(dummy_audio_bytes):
|
||||
audio_io = AudioMediaIO()
|
||||
encoded = base64.b64encode(dummy_audio_bytes).decode("utf-8")
|
||||
with patch.object(AudioMediaIO, "load_bytes") as mock_load_bytes:
|
||||
mock_load_bytes.return_value = (np.array([0.1, 0.2]), 16000)
|
||||
out = audio_io.load_base64("audio/wav", encoded)
|
||||
mock_load_bytes.assert_called_once()
|
||||
assert isinstance(out[0], np.ndarray)
|
||||
assert out[1] == 16000
|
||||
|
||||
|
||||
def test_audio_media_io_load_file():
|
||||
audio_io = AudioMediaIO()
|
||||
path = Path("/fake/path.wav")
|
||||
with patch("librosa.load") as mock_load:
|
||||
mock_load.return_value = (np.array([0.1, 0.2]), 16000)
|
||||
out = audio_io.load_file(path)
|
||||
mock_load.assert_called_once_with(path, sr=None)
|
||||
assert isinstance(out[0], np.ndarray)
|
||||
assert out[1] == 16000
|
||||
|
||||
|
||||
def test_audio_media_io_encode_base64(dummy_audio):
|
||||
audio_io = AudioMediaIO()
|
||||
media = (dummy_audio, 16000)
|
||||
with patch("soundfile.write") as mock_write:
|
||||
|
||||
def write_to_buffer(buffer, *_args, **_kwargs):
|
||||
buffer.write(b"dummy_wav_data")
|
||||
|
||||
mock_write.side_effect = write_to_buffer
|
||||
|
||||
out = audio_io.encode_base64(media)
|
||||
decoded = base64.b64decode(out)
|
||||
assert decoded == b"dummy_wav_data"
|
||||
mock_write.assert_called_once()
|
||||
|
||||
|
||||
def test_audio_media_io_from_video(video_assets):
|
||||
audio_io = AudioMediaIO()
|
||||
video_path = video_assets[0].video_path
|
||||
with open(video_path, "rb") as f:
|
||||
audio, sr = audio_io.load_bytes(f.read())
|
||||
audio_ref, sr_ref = librosa.load(video_path, sr=None)
|
||||
assert sr == sr_ref
|
||||
np.testing.assert_allclose(audio_ref, audio, atol=1e-4)
|
||||
45
third_party/vllm/tests/multimodal/media/test_base.py
vendored
Normal file
45
third_party/vllm/tests/multimodal/media/test_base.py
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import pickle
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from vllm.multimodal.media import MediaWithBytes
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
ASSETS_DIR = Path(__file__).parent.parent / "assets"
|
||||
assert ASSETS_DIR.exists()
|
||||
|
||||
|
||||
def test_media_with_bytes_pickle_roundtrip():
|
||||
"""Regression test for pickle/unpickle of MediaWithBytes.
|
||||
|
||||
Verifies that MediaWithBytes can be pickled and unpickled without
|
||||
RecursionError. See: https://github.com/vllm-project/vllm/issues/30818
|
||||
"""
|
||||
original_image = Image.open(ASSETS_DIR / "image1.png").convert("RGB")
|
||||
original_bytes = b"test_bytes_data"
|
||||
|
||||
wrapper = MediaWithBytes(media=original_image, original_bytes=original_bytes)
|
||||
|
||||
# Verify attribute delegation works before pickling
|
||||
assert wrapper.width == original_image.width
|
||||
assert wrapper.height == original_image.height
|
||||
assert wrapper.mode == original_image.mode
|
||||
|
||||
# Pickle and unpickle (this would cause RecursionError before the fix)
|
||||
pickled = pickle.dumps(wrapper)
|
||||
unpickled = pickle.loads(pickled)
|
||||
|
||||
# Verify the unpickled object works correctly
|
||||
assert unpickled.original_bytes == original_bytes
|
||||
assert unpickled.media.width == original_image.width
|
||||
assert unpickled.media.height == original_image.height
|
||||
|
||||
# Verify attribute delegation works after unpickling
|
||||
assert unpickled.width == original_image.width
|
||||
assert unpickled.height == original_image.height
|
||||
assert unpickled.mode == original_image.mode
|
||||
377
third_party/vllm/tests/multimodal/media/test_connector.py
vendored
Normal file
377
third_party/vllm/tests/multimodal/media/test_connector.py
vendored
Normal file
@@ -0,0 +1,377 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import mimetypes
|
||||
import os
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
|
||||
import aiohttp
|
||||
import numpy as np
|
||||
import pytest
|
||||
import requests
|
||||
import torch
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
from vllm.multimodal.image import convert_image_mode
|
||||
from vllm.multimodal.inputs import PlaceholderRange
|
||||
from vllm.multimodal.media import MediaConnector
|
||||
|
||||
# Test different image extensions (JPG/PNG) and formats (gray/RGB/RGBA)
|
||||
TEST_IMAGE_ASSETS = [
|
||||
"2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"
|
||||
"Grayscale_8bits_palette_sample_image.png", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/Grayscale_8bits_palette_sample_image.png",
|
||||
"1280px-Venn_diagram_rgb.svg.png", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/1280px-Venn_diagram_rgb.svg.png",
|
||||
"RGBA_comp.png", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/RGBA_comp.png",
|
||||
]
|
||||
|
||||
TEST_VIDEO_URLS = [
|
||||
"https://www.bogotobogo.com/python/OpenCV_Python/images/mean_shift_tracking/slow_traffic_small.mp4",
|
||||
"https://github.com/opencv/opencv/raw/refs/tags/4.12.0/samples/data/vtest.avi",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def url_images(local_asset_server) -> dict[str, Image.Image]:
|
||||
return {
|
||||
image_url: local_asset_server.get_image_asset(image_url)
|
||||
for image_url in TEST_IMAGE_ASSETS
|
||||
}
|
||||
|
||||
|
||||
def get_supported_suffixes() -> tuple[str, ...]:
|
||||
# We should at least test the file types mentioned in GPT-4 with Vision
|
||||
OPENAI_SUPPORTED_SUFFIXES = (".png", ".jpeg", ".jpg", ".webp", ".gif")
|
||||
|
||||
# Additional file types that are supported by us
|
||||
EXTRA_SUPPORTED_SUFFIXES = (".bmp", ".tiff")
|
||||
|
||||
return OPENAI_SUPPORTED_SUFFIXES + EXTRA_SUPPORTED_SUFFIXES
|
||||
|
||||
|
||||
def _image_equals(a: Image.Image, b: Image.Image) -> bool:
|
||||
return (np.asarray(a) == np.asarray(convert_image_mode(b, a.mode))).all()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("image_url", TEST_IMAGE_ASSETS, indirect=True)
|
||||
async def test_fetch_image_http(image_url: str):
|
||||
connector = MediaConnector()
|
||||
|
||||
image_sync = connector.fetch_image(image_url)
|
||||
image_async = await connector.fetch_image_async(image_url)
|
||||
assert _image_equals(image_sync, image_async)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("raw_image_url", TEST_IMAGE_ASSETS)
|
||||
@pytest.mark.parametrize("suffix", get_supported_suffixes())
|
||||
async def test_fetch_image_base64(
|
||||
url_images: dict[str, Image.Image], raw_image_url: str, suffix: str
|
||||
):
|
||||
connector = MediaConnector(
|
||||
# Domain restriction should not apply to data URLs.
|
||||
allowed_media_domains=[
|
||||
"www.bogotobogo.com",
|
||||
"github.com",
|
||||
]
|
||||
)
|
||||
url_image = url_images[raw_image_url]
|
||||
|
||||
try:
|
||||
mime_type = Image.MIME[Image.registered_extensions()[suffix]]
|
||||
except KeyError:
|
||||
try:
|
||||
mime_type = mimetypes.types_map[suffix]
|
||||
except KeyError:
|
||||
pytest.skip("No MIME type")
|
||||
|
||||
with NamedTemporaryFile(suffix=suffix) as f:
|
||||
try:
|
||||
url_image.save(f.name)
|
||||
except Exception as e:
|
||||
if e.args[0] == "cannot write mode RGBA as JPEG":
|
||||
pytest.skip("Conversion not supported")
|
||||
|
||||
raise
|
||||
|
||||
base64_image = base64.b64encode(f.read()).decode("utf-8")
|
||||
data_url = f"data:{mime_type};base64,{base64_image}"
|
||||
|
||||
data_image_sync = connector.fetch_image(data_url)
|
||||
if _image_equals(url_image, Image.open(f)):
|
||||
assert _image_equals(url_image, data_image_sync)
|
||||
else:
|
||||
pass # Lossy format; only check that image can be opened
|
||||
|
||||
data_image_async = await connector.fetch_image_async(data_url)
|
||||
assert _image_equals(data_image_sync, data_image_async)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("image_url", TEST_IMAGE_ASSETS, indirect=True)
|
||||
async def test_fetch_image_local_files(image_url: str):
|
||||
connector = MediaConnector()
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
local_connector = MediaConnector(allowed_local_media_path=temp_dir)
|
||||
|
||||
origin_image = connector.fetch_image(image_url)
|
||||
origin_image.save(
|
||||
os.path.join(temp_dir, os.path.basename(image_url)),
|
||||
quality=100,
|
||||
icc_profile=origin_image.info.get("icc_profile"),
|
||||
)
|
||||
|
||||
image_async = await local_connector.fetch_image_async(
|
||||
f"file://{temp_dir}/{os.path.basename(image_url)}"
|
||||
)
|
||||
image_sync = local_connector.fetch_image(
|
||||
f"file://{temp_dir}/{os.path.basename(image_url)}"
|
||||
)
|
||||
# Check that the images are equal
|
||||
assert not ImageChops.difference(image_sync, image_async).getbbox()
|
||||
|
||||
with pytest.raises(ValueError, match="must be a subpath"):
|
||||
await local_connector.fetch_image_async(
|
||||
f"file://{temp_dir}/../{os.path.basename(image_url)}"
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Cannot load local files"):
|
||||
await connector.fetch_image_async(
|
||||
f"file://{temp_dir}/../{os.path.basename(image_url)}"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must be a subpath"):
|
||||
local_connector.fetch_image(
|
||||
f"file://{temp_dir}/../{os.path.basename(image_url)}"
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Cannot load local files"):
|
||||
connector.fetch_image(f"file://{temp_dir}/../{os.path.basename(image_url)}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("image_url", [TEST_IMAGE_ASSETS[0]], indirect=True)
|
||||
async def test_fetch_image_local_files_with_space_in_name(image_url: str):
|
||||
connector = MediaConnector()
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
local_connector = MediaConnector(allowed_local_media_path=temp_dir)
|
||||
|
||||
origin_image = connector.fetch_image(image_url)
|
||||
filename = "file name with space.jpg"
|
||||
origin_image.save(
|
||||
os.path.join(temp_dir, filename),
|
||||
quality=100,
|
||||
icc_profile=origin_image.info.get("icc_profile"),
|
||||
)
|
||||
|
||||
try:
|
||||
image_async = await local_connector.fetch_image_async(
|
||||
f"file://{temp_dir}/{filename}"
|
||||
)
|
||||
image_sync = local_connector.fetch_image(f"file://{temp_dir}/{filename}")
|
||||
except FileNotFoundError as e:
|
||||
pytest.fail("Failed to fetch image with space in name: {}".format(e))
|
||||
# Check that the images are equal
|
||||
assert not ImageChops.difference(image_sync, image_async).getbbox()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_image_error_conversion():
|
||||
connector = MediaConnector()
|
||||
broken_img = "data:image/png;base64,aGVsbG9fdmxsbV9jb21tdW5pdHkK"
|
||||
|
||||
# PIL.UnidentifiedImageError should be converted to ValueError
|
||||
with pytest.raises(ValueError):
|
||||
await connector.fetch_image_async(broken_img)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
connector.fetch_image(broken_img)
|
||||
|
||||
|
||||
@pytest.mark.flaky(reruns=3, reruns_delay=5)
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("video_url", TEST_VIDEO_URLS)
|
||||
@pytest.mark.parametrize("num_frames", [-1, 32, 1800])
|
||||
async def test_fetch_video_http(video_url: str, num_frames: int):
|
||||
connector = MediaConnector(
|
||||
media_io_kwargs={
|
||||
"video": {
|
||||
"num_frames": num_frames,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
video_sync, metadata_sync = connector.fetch_video(video_url)
|
||||
video_async, metadata_async = await connector.fetch_video_async(video_url)
|
||||
except (TimeoutError, asyncio.TimeoutError) as e:
|
||||
pytest.skip(f"Timeout fetching video (CI network flakiness): {e}")
|
||||
|
||||
assert np.array_equal(video_sync, video_async)
|
||||
assert metadata_sync == metadata_async
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("video_url", TEST_VIDEO_URLS)
|
||||
@pytest.mark.parametrize("max_duration", [1, 60, 1800])
|
||||
@pytest.mark.parametrize("requested_fps", [2, 24])
|
||||
async def test_fetch_video_http_with_dynamic_loader(
|
||||
video_url: str,
|
||||
max_duration: int,
|
||||
requested_fps: int,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
with monkeypatch.context() as m:
|
||||
m.setenv("VLLM_VIDEO_LOADER_BACKEND", "opencv_dynamic")
|
||||
connector = MediaConnector(
|
||||
media_io_kwargs={
|
||||
"video": {
|
||||
"max_duration": max_duration,
|
||||
"requested_fps": requested_fps,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
video_sync, metadata_sync = connector.fetch_video(video_url)
|
||||
video_async, metadata_async = await connector.fetch_video_async(video_url)
|
||||
|
||||
assert np.array_equal(video_sync, video_async)
|
||||
assert metadata_sync == metadata_async
|
||||
assert metadata_sync["video_backend"] == "opencv_dynamic"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"is_embed,start_idx,end_idx,expected",
|
||||
[
|
||||
(None, 2, 4, (2, 4)),
|
||||
(
|
||||
torch.tensor([False, True, False, True, True]),
|
||||
3,
|
||||
5,
|
||||
(1, 3),
|
||||
),
|
||||
(
|
||||
torch.tensor([False, True, False, True, True]),
|
||||
0,
|
||||
2,
|
||||
(0, 1),
|
||||
),
|
||||
(
|
||||
torch.tensor([True, False, True, False]),
|
||||
2,
|
||||
2,
|
||||
(1, 1),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_placeholder_range_get_embeds_indices_in_range(
|
||||
is_embed, start_idx, end_idx, expected
|
||||
):
|
||||
length = len(is_embed) if is_embed is not None else 5
|
||||
pr = PlaceholderRange(offset=0, length=length, is_embed=is_embed)
|
||||
assert pr.get_embeds_indices_in_range(start_idx, end_idx) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"offset,is_embed,expected",
|
||||
[
|
||||
(0, None, [(0, 4)]),
|
||||
(
|
||||
2,
|
||||
torch.tensor([False, True, False, True, True]),
|
||||
[(3, 3), (5, 6)],
|
||||
),
|
||||
(0, torch.tensor([True, True, True, True]), [(0, 3)]),
|
||||
(0, torch.tensor([False, False, False, False]), []),
|
||||
],
|
||||
)
|
||||
def test_placeholder_range_extract_embeds_range(offset, is_embed, expected):
|
||||
length = len(is_embed) if is_embed is not None else 5
|
||||
pr = PlaceholderRange(offset=offset, length=length, is_embed=is_embed)
|
||||
assert pr.extract_embeds_range() == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("video_url", TEST_VIDEO_URLS)
|
||||
@pytest.mark.parametrize("num_frames", [-1, 32, 1800])
|
||||
async def test_allowed_media_domains(video_url: str, num_frames: int):
|
||||
connector = MediaConnector(
|
||||
media_io_kwargs={
|
||||
"video": {
|
||||
"num_frames": num_frames,
|
||||
}
|
||||
},
|
||||
allowed_media_domains=[
|
||||
"www.bogotobogo.com",
|
||||
"github.com",
|
||||
],
|
||||
)
|
||||
|
||||
video_sync, metadata_sync = connector.fetch_video(video_url)
|
||||
video_async, metadata_async = await connector.fetch_video_async(video_url)
|
||||
assert np.array_equal(video_sync, video_async)
|
||||
assert metadata_sync == metadata_async
|
||||
|
||||
disallowed_url = "https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png"
|
||||
with pytest.raises(ValueError):
|
||||
_, _ = connector.fetch_video(disallowed_url)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_, _ = await connector.fetch_video_async(disallowed_url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssrf_bypass_backslash_in_url(local_asset_server):
|
||||
"""Verify that backslash-@ URL parsing confusion cannot bypass the
|
||||
allowed_media_domains check (GHSA-v359-jj2v-j536).
|
||||
|
||||
urllib3.parse_url() and aiohttp/yarl disagree on how to parse a
|
||||
backslash before ``@``. urllib3 treats ``\\`` as part of the path
|
||||
(encoding it as ``%5C``), while yarl treats it as a userinfo
|
||||
separator, changing the effective host. The fix normalises the URL
|
||||
through urllib3 *before* handing it to aiohttp so both layers agree.
|
||||
"""
|
||||
port = local_asset_server.port
|
||||
asset = TEST_IMAGE_ASSETS[0]
|
||||
|
||||
# Craft the bypass payload: urllib3 sees host=127.0.0.1, but an
|
||||
# un-patched aiohttp would see host=example.com.
|
||||
bypass_url = f"http://127.0.0.1:{port}\\@example.com/{asset}"
|
||||
|
||||
connector = MediaConnector(
|
||||
allowed_media_domains=["127.0.0.1"],
|
||||
)
|
||||
|
||||
# After the fix the request is made to 127.0.0.1 (the local asset
|
||||
# server) using the normalised URL. The normalised path will be
|
||||
# /%5C@example.com/<asset> which won't match any file the server
|
||||
# knows about, so we expect an HTTP error — but crucially NOT a
|
||||
# successful fetch from example.com.
|
||||
with pytest.raises(requests.exceptions.HTTPError):
|
||||
connector.fetch_image(bypass_url)
|
||||
|
||||
with pytest.raises(aiohttp.ClientResponseError):
|
||||
await connector.fetch_image_async(bypass_url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssrf_bypass_backslash_disallowed_domain():
|
||||
"""The reverse direction: even when the *attacker-controlled* host
|
||||
appears in the urllib3-parsed hostname position the allowlist must
|
||||
still block it.
|
||||
"""
|
||||
# urllib3.parse_url sees host=example.com which is NOT in the
|
||||
# allowlist, so this must be rejected before any request is made.
|
||||
bypass_url = "https://example.com\\@safe.example.org/image.png"
|
||||
|
||||
connector = MediaConnector(
|
||||
allowed_media_domains=["safe.example.org"],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="allowed domains"):
|
||||
connector.fetch_image(bypass_url)
|
||||
|
||||
with pytest.raises(ValueError, match="allowed domains"):
|
||||
await connector.fetch_image_async(bypass_url)
|
||||
133
third_party/vllm/tests/multimodal/media/test_image.py
vendored
Normal file
133
third_party/vllm/tests/multimodal/media/test_image.py
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from vllm.multimodal.media import ImageMediaIO
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
ASSETS_DIR = Path(__file__).parent.parent / "assets"
|
||||
assert ASSETS_DIR.exists()
|
||||
|
||||
|
||||
def test_image_media_io_rgba_custom_background(tmp_path):
|
||||
"""Test RGBA to RGB conversion with custom background colors."""
|
||||
# Create a simple RGBA image with transparent and opaque pixels
|
||||
rgba_image = Image.new("RGBA", (10, 10), (255, 0, 0, 255)) # Red with full opacity
|
||||
|
||||
# Make top-left quadrant transparent
|
||||
for i in range(5):
|
||||
for j in range(5):
|
||||
rgba_image.putpixel((i, j), (0, 0, 0, 0)) # Fully transparent
|
||||
|
||||
# Save the test image to tmp_path
|
||||
test_image_path = tmp_path / "test_rgba.png"
|
||||
rgba_image.save(test_image_path)
|
||||
|
||||
# Test 1: Default white background (backward compatibility)
|
||||
image_io_default = ImageMediaIO()
|
||||
converted_default = image_io_default.load_file(test_image_path)
|
||||
default_numpy = np.array(converted_default)
|
||||
|
||||
# Check transparent pixels are white
|
||||
assert default_numpy[0][0][0] == 255 # R
|
||||
assert default_numpy[0][0][1] == 255 # G
|
||||
assert default_numpy[0][0][2] == 255 # B
|
||||
# Check opaque pixels remain red
|
||||
assert default_numpy[5][5][0] == 255 # R
|
||||
assert default_numpy[5][5][1] == 0 # G
|
||||
assert default_numpy[5][5][2] == 0 # B
|
||||
|
||||
# Test 2: Custom black background via kwargs
|
||||
image_io_black = ImageMediaIO(rgba_background_color=(0, 0, 0))
|
||||
converted_black = image_io_black.load_file(test_image_path)
|
||||
black_numpy = np.array(converted_black)
|
||||
|
||||
# Check transparent pixels are black
|
||||
assert black_numpy[0][0][0] == 0 # R
|
||||
assert black_numpy[0][0][1] == 0 # G
|
||||
assert black_numpy[0][0][2] == 0 # B
|
||||
# Check opaque pixels remain red
|
||||
assert black_numpy[5][5][0] == 255 # R
|
||||
assert black_numpy[5][5][1] == 0 # G
|
||||
assert black_numpy[5][5][2] == 0 # B
|
||||
|
||||
# Test 3: Custom blue background via kwargs (as list)
|
||||
image_io_blue = ImageMediaIO(rgba_background_color=[0, 0, 255])
|
||||
converted_blue = image_io_blue.load_file(test_image_path)
|
||||
blue_numpy = np.array(converted_blue)
|
||||
|
||||
# Check transparent pixels are blue
|
||||
assert blue_numpy[0][0][0] == 0 # R
|
||||
assert blue_numpy[0][0][1] == 0 # G
|
||||
assert blue_numpy[0][0][2] == 255 # B
|
||||
|
||||
# Test 4: Test with load_bytes method
|
||||
with open(test_image_path, "rb") as f:
|
||||
image_data = f.read()
|
||||
|
||||
image_io_green = ImageMediaIO(rgba_background_color=(0, 255, 0))
|
||||
converted_green = image_io_green.load_bytes(image_data)
|
||||
green_numpy = np.array(converted_green)
|
||||
|
||||
# Check transparent pixels are green
|
||||
assert green_numpy[0][0][0] == 0 # R
|
||||
assert green_numpy[0][0][1] == 255 # G
|
||||
assert green_numpy[0][0][2] == 0 # B
|
||||
|
||||
|
||||
def test_image_media_io_rgba_background_color_validation():
|
||||
"""Test that invalid rgba_background_color values are properly rejected."""
|
||||
|
||||
# Test invalid types
|
||||
with pytest.raises(
|
||||
ValueError, match="rgba_background_color must be a list or tuple"
|
||||
):
|
||||
ImageMediaIO(rgba_background_color="255,255,255")
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="rgba_background_color must be a list or tuple"
|
||||
):
|
||||
ImageMediaIO(rgba_background_color=255)
|
||||
|
||||
# Test wrong number of elements
|
||||
with pytest.raises(
|
||||
ValueError, match="rgba_background_color must be a list or tuple"
|
||||
):
|
||||
ImageMediaIO(rgba_background_color=(255, 255))
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="rgba_background_color must be a list or tuple"
|
||||
):
|
||||
ImageMediaIO(rgba_background_color=(255, 255, 255, 255))
|
||||
|
||||
# Test non-integer values
|
||||
with pytest.raises(
|
||||
ValueError, match="rgba_background_color must be a list or tuple"
|
||||
):
|
||||
ImageMediaIO(rgba_background_color=(255.0, 255.0, 255.0))
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="rgba_background_color must be a list or tuple"
|
||||
):
|
||||
ImageMediaIO(rgba_background_color=(255, "255", 255))
|
||||
|
||||
# Test out of range values
|
||||
with pytest.raises(
|
||||
ValueError, match="rgba_background_color must be a list or tuple"
|
||||
):
|
||||
ImageMediaIO(rgba_background_color=(256, 255, 255))
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="rgba_background_color must be a list or tuple"
|
||||
):
|
||||
ImageMediaIO(rgba_background_color=(255, -1, 255))
|
||||
|
||||
# Test that valid values work
|
||||
ImageMediaIO(rgba_background_color=(0, 0, 0)) # Should not raise
|
||||
ImageMediaIO(rgba_background_color=[255, 255, 255]) # Should not raise
|
||||
ImageMediaIO(rgba_background_color=(128, 128, 128)) # Should not raise
|
||||
237
third_party/vllm/tests/multimodal/media/test_video.py
vendored
Normal file
237
third_party/vllm/tests/multimodal/media/test_video.py
vendored
Normal file
@@ -0,0 +1,237 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from vllm.assets.base import get_vllm_public_assets
|
||||
from vllm.assets.video import video_to_ndarrays, video_to_pil_images_list
|
||||
from vllm.multimodal.media import ImageMediaIO, VideoMediaIO
|
||||
from vllm.multimodal.video import VIDEO_LOADER_REGISTRY, VideoLoader
|
||||
|
||||
from ..utils import cosine_similarity, create_video_from_image, normalize_image
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
ASSETS_DIR = Path(__file__).parent.parent / "assets"
|
||||
assert ASSETS_DIR.exists()
|
||||
|
||||
|
||||
@VIDEO_LOADER_REGISTRY.register("assert_10_frames_1_fps")
|
||||
class Assert10Frames1FPSVideoLoader(VideoLoader):
|
||||
@classmethod
|
||||
def load_bytes(
|
||||
cls, data: bytes, num_frames: int = -1, fps: float = -1.0, **kwargs
|
||||
) -> npt.NDArray:
|
||||
assert num_frames == 10, "bad num_frames"
|
||||
assert fps == 1.0, "bad fps"
|
||||
return FAKE_OUTPUT_2
|
||||
|
||||
|
||||
def test_video_media_io_kwargs(monkeypatch: pytest.MonkeyPatch):
|
||||
with monkeypatch.context() as m:
|
||||
m.setenv("VLLM_VIDEO_LOADER_BACKEND", "assert_10_frames_1_fps")
|
||||
imageio = ImageMediaIO()
|
||||
|
||||
# Verify that different args pass/fail assertions as expected.
|
||||
videoio = VideoMediaIO(imageio, **{"num_frames": 10, "fps": 1.0})
|
||||
_ = videoio.load_bytes(b"test")
|
||||
|
||||
videoio = VideoMediaIO(
|
||||
imageio, **{"num_frames": 10, "fps": 1.0, "not_used": "not_used"}
|
||||
)
|
||||
_ = videoio.load_bytes(b"test")
|
||||
|
||||
with pytest.raises(AssertionError, match="bad num_frames"):
|
||||
videoio = VideoMediaIO(imageio, **{})
|
||||
_ = videoio.load_bytes(b"test")
|
||||
|
||||
with pytest.raises(AssertionError, match="bad num_frames"):
|
||||
videoio = VideoMediaIO(imageio, **{"num_frames": 9, "fps": 1.0})
|
||||
_ = videoio.load_bytes(b"test")
|
||||
|
||||
with pytest.raises(AssertionError, match="bad fps"):
|
||||
videoio = VideoMediaIO(imageio, **{"num_frames": 10, "fps": 2.0})
|
||||
_ = videoio.load_bytes(b"test")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_color", [True, False])
|
||||
@pytest.mark.parametrize("fourcc, ext", [("mp4v", "mp4"), ("XVID", "avi")])
|
||||
def test_opencv_video_io_colorspace(tmp_path, is_color: bool, fourcc: str, ext: str):
|
||||
"""
|
||||
Test all functions that use OpenCV for video I/O return RGB format.
|
||||
Both RGB and grayscale videos are tested.
|
||||
"""
|
||||
image_path = get_vllm_public_assets(
|
||||
filename="stop_sign.jpg", s3_prefix="vision_model_images"
|
||||
)
|
||||
image = Image.open(image_path)
|
||||
|
||||
if not is_color:
|
||||
image_path = f"{tmp_path}/test_grayscale_image.png"
|
||||
image = image.convert("L")
|
||||
image.save(image_path)
|
||||
# Convert to gray RGB for comparison
|
||||
image = image.convert("RGB")
|
||||
video_path = f"{tmp_path}/test_RGB_video.{ext}"
|
||||
create_video_from_image(
|
||||
image_path,
|
||||
video_path,
|
||||
num_frames=2,
|
||||
is_color=is_color,
|
||||
fourcc=fourcc,
|
||||
)
|
||||
|
||||
frames = video_to_ndarrays(video_path)
|
||||
for frame in frames:
|
||||
sim = cosine_similarity(
|
||||
normalize_image(np.array(frame)), normalize_image(np.array(image))
|
||||
)
|
||||
assert np.sum(np.isnan(sim)) / sim.size < 0.001
|
||||
assert np.nanmean(sim) > 0.99
|
||||
|
||||
pil_frames = video_to_pil_images_list(video_path)
|
||||
for frame in pil_frames:
|
||||
sim = cosine_similarity(
|
||||
normalize_image(np.array(frame)), normalize_image(np.array(image))
|
||||
)
|
||||
assert np.sum(np.isnan(sim)) / sim.size < 0.001
|
||||
assert np.nanmean(sim) > 0.99
|
||||
|
||||
io_frames, _ = VideoMediaIO(ImageMediaIO()).load_file(Path(video_path))
|
||||
for frame in io_frames:
|
||||
sim = cosine_similarity(
|
||||
normalize_image(np.array(frame)), normalize_image(np.array(image))
|
||||
)
|
||||
assert np.sum(np.isnan(sim)) / sim.size < 0.001
|
||||
assert np.nanmean(sim) > 0.99
|
||||
|
||||
|
||||
NUM_FRAMES = 10
|
||||
FAKE_OUTPUT_1 = np.random.rand(NUM_FRAMES, 1280, 720, 3)
|
||||
FAKE_OUTPUT_2 = np.random.rand(NUM_FRAMES, 1280, 720, 3)
|
||||
|
||||
|
||||
@VIDEO_LOADER_REGISTRY.register("test_video_backend_override_1")
|
||||
class TestVideoBackendOverride1(VideoLoader):
|
||||
"""Test loader that returns FAKE_OUTPUT_1 to verify backend selection."""
|
||||
|
||||
@classmethod
|
||||
def load_bytes(
|
||||
cls, data: bytes, num_frames: int = -1, **kwargs
|
||||
) -> tuple[npt.NDArray, dict]:
|
||||
return FAKE_OUTPUT_1, {"video_backend": "test_video_backend_override_1"}
|
||||
|
||||
|
||||
@VIDEO_LOADER_REGISTRY.register("test_video_backend_override_2")
|
||||
class TestVideoBackendOverride2(VideoLoader):
|
||||
"""Test loader that returns FAKE_OUTPUT_2 to verify backend selection."""
|
||||
|
||||
@classmethod
|
||||
def load_bytes(
|
||||
cls, data: bytes, num_frames: int = -1, **kwargs
|
||||
) -> tuple[npt.NDArray, dict]:
|
||||
return FAKE_OUTPUT_2, {"video_backend": "test_video_backend_override_2"}
|
||||
|
||||
|
||||
def test_video_media_io_backend_kwarg_override(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that video_backend kwarg can override the VLLM_VIDEO_LOADER_BACKEND
|
||||
environment variable.
|
||||
|
||||
This allows users to dynamically select a different video backend
|
||||
via --media-io-kwargs without changing the global env var, which is
|
||||
useful when plugins set a default backend but a specific request
|
||||
needs a different one.
|
||||
"""
|
||||
with monkeypatch.context() as m:
|
||||
# Set the env var to one backend
|
||||
m.setenv("VLLM_VIDEO_LOADER_BACKEND", "test_video_backend_override_1")
|
||||
|
||||
imageio = ImageMediaIO()
|
||||
|
||||
# Without video_backend kwarg, should use env var backend
|
||||
videoio_default = VideoMediaIO(imageio, num_frames=10)
|
||||
frames_default, metadata_default = videoio_default.load_bytes(b"test")
|
||||
np.testing.assert_array_equal(frames_default, FAKE_OUTPUT_1)
|
||||
assert metadata_default["video_backend"] == "test_video_backend_override_1"
|
||||
|
||||
# With video_backend kwarg, should override env var
|
||||
videoio_override = VideoMediaIO(
|
||||
imageio, num_frames=10, video_backend="test_video_backend_override_2"
|
||||
)
|
||||
frames_override, metadata_override = videoio_override.load_bytes(b"test")
|
||||
np.testing.assert_array_equal(frames_override, FAKE_OUTPUT_2)
|
||||
assert metadata_override["video_backend"] == "test_video_backend_override_2"
|
||||
|
||||
|
||||
def test_video_media_io_backend_kwarg_not_passed_to_loader(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""
|
||||
Test that video_backend kwarg is consumed by VideoMediaIO and NOT passed
|
||||
through to the underlying video loader's load_bytes method.
|
||||
|
||||
This ensures the kwarg is properly popped from kwargs before forwarding.
|
||||
"""
|
||||
|
||||
@VIDEO_LOADER_REGISTRY.register("test_reject_video_backend_kwarg")
|
||||
class RejectVideoBackendKwargLoader(VideoLoader):
|
||||
"""Test loader that fails if video_backend is passed through."""
|
||||
|
||||
@classmethod
|
||||
def load_bytes(
|
||||
cls, data: bytes, num_frames: int = -1, **kwargs
|
||||
) -> tuple[npt.NDArray, dict]:
|
||||
# This should never receive video_backend in kwargs
|
||||
if "video_backend" in kwargs:
|
||||
raise AssertionError(
|
||||
"video_backend should be consumed by VideoMediaIO, "
|
||||
"not passed to loader"
|
||||
)
|
||||
return FAKE_OUTPUT_1, {"received_kwargs": list(kwargs.keys())}
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
m.setenv("VLLM_VIDEO_LOADER_BACKEND", "test_reject_video_backend_kwarg")
|
||||
|
||||
imageio = ImageMediaIO()
|
||||
|
||||
# Even when video_backend is provided, it should NOT be passed to loader
|
||||
videoio = VideoMediaIO(
|
||||
imageio,
|
||||
num_frames=10,
|
||||
video_backend="test_reject_video_backend_kwarg",
|
||||
other_kwarg="should_pass_through",
|
||||
)
|
||||
|
||||
# This should NOT raise AssertionError
|
||||
frames, metadata = videoio.load_bytes(b"test")
|
||||
np.testing.assert_array_equal(frames, FAKE_OUTPUT_1)
|
||||
# Verify other kwargs are still passed through
|
||||
assert "other_kwarg" in metadata["received_kwargs"]
|
||||
|
||||
|
||||
def test_video_media_io_backend_env_var_fallback(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that when video_backend kwarg is None or not provided,
|
||||
VideoMediaIO falls back to VLLM_VIDEO_LOADER_BACKEND env var.
|
||||
"""
|
||||
with monkeypatch.context() as m:
|
||||
m.setenv("VLLM_VIDEO_LOADER_BACKEND", "test_video_backend_override_2")
|
||||
|
||||
imageio = ImageMediaIO()
|
||||
|
||||
# Explicit None should fall back to env var
|
||||
videoio_none = VideoMediaIO(imageio, num_frames=10, video_backend=None)
|
||||
frames_none, metadata_none = videoio_none.load_bytes(b"test")
|
||||
np.testing.assert_array_equal(frames_none, FAKE_OUTPUT_2)
|
||||
assert metadata_none["video_backend"] == "test_video_backend_override_2"
|
||||
|
||||
# Not providing video_backend should also fall back to env var
|
||||
videoio_missing = VideoMediaIO(imageio, num_frames=10)
|
||||
frames_missing, metadata_missing = videoio_missing.load_bytes(b"test")
|
||||
np.testing.assert_array_equal(frames_missing, FAKE_OUTPUT_2)
|
||||
assert metadata_missing["video_backend"] == "test_video_backend_override_2"
|
||||
770
third_party/vllm/tests/multimodal/test_audio.py
vendored
Normal file
770
third_party/vllm/tests/multimodal/test_audio.py
vendored
Normal file
@@ -0,0 +1,770 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# test_audio.py
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm.multimodal.audio import (
|
||||
MONO_AUDIO_SPEC,
|
||||
PASSTHROUGH_AUDIO_SPEC,
|
||||
AudioResampler,
|
||||
AudioSpec,
|
||||
ChannelReduction,
|
||||
normalize_audio,
|
||||
resample_audio_librosa,
|
||||
resample_audio_scipy,
|
||||
split_audio,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_audio():
|
||||
return np.array([0.0, 0.1, 0.2, 0.3, 0.4], dtype=float)
|
||||
|
||||
|
||||
def test_resample_audio_librosa(dummy_audio):
|
||||
with patch("vllm.multimodal.audio.librosa.resample") as mock_resample:
|
||||
mock_resample.return_value = dummy_audio * 2
|
||||
out = resample_audio_librosa(dummy_audio, orig_sr=44100, target_sr=22050)
|
||||
mock_resample.assert_called_once_with(
|
||||
dummy_audio, orig_sr=44100, target_sr=22050
|
||||
)
|
||||
assert np.all(out == dummy_audio * 2)
|
||||
|
||||
|
||||
def test_resample_audio_scipy(dummy_audio):
|
||||
out_down = resample_audio_scipy(dummy_audio, orig_sr=4, target_sr=2)
|
||||
out_up = resample_audio_scipy(dummy_audio, orig_sr=2, target_sr=4)
|
||||
out_same = resample_audio_scipy(dummy_audio, orig_sr=4, target_sr=4)
|
||||
|
||||
assert len(out_down) == 3
|
||||
assert len(out_up) == 10
|
||||
assert np.all(out_same == dummy_audio)
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="resample_audio_scipy is buggy for non-integer ratios")
|
||||
def test_resample_audio_scipy_non_integer_ratio(dummy_audio):
|
||||
out = resample_audio_scipy(dummy_audio, orig_sr=5, target_sr=3)
|
||||
|
||||
expected_len = int(round(len(dummy_audio) * 3 / 5))
|
||||
assert len(out) == expected_len
|
||||
|
||||
assert isinstance(out, np.ndarray)
|
||||
assert np.isfinite(out).all()
|
||||
|
||||
|
||||
def test_audio_resampler_librosa_calls_resample(dummy_audio):
|
||||
resampler = AudioResampler(target_sr=22050, method="librosa")
|
||||
with patch("vllm.multimodal.audio.resample_audio_librosa") as mock_resample:
|
||||
mock_resample.return_value = dummy_audio
|
||||
out = resampler.resample(dummy_audio, orig_sr=44100)
|
||||
mock_resample.assert_called_once_with(
|
||||
dummy_audio, orig_sr=44100, target_sr=22050
|
||||
)
|
||||
assert np.all(out == dummy_audio)
|
||||
|
||||
|
||||
def test_audio_resampler_scipy_calls_resample(dummy_audio):
|
||||
resampler = AudioResampler(target_sr=22050, method="scipy")
|
||||
with patch("vllm.multimodal.audio.resample_audio_scipy") as mock_resample:
|
||||
mock_resample.return_value = dummy_audio
|
||||
out = resampler.resample(dummy_audio, orig_sr=44100)
|
||||
mock_resample.assert_called_once_with(
|
||||
dummy_audio, orig_sr=44100, target_sr=22050
|
||||
)
|
||||
assert np.all(out == dummy_audio)
|
||||
|
||||
|
||||
def test_audio_resampler_invalid_method(dummy_audio):
|
||||
resampler = AudioResampler(target_sr=22050, method="invalid")
|
||||
with pytest.raises(ValueError):
|
||||
resampler.resample(dummy_audio, orig_sr=44100)
|
||||
|
||||
|
||||
def test_audio_resampler_no_target_sr(dummy_audio):
|
||||
resampler = AudioResampler(target_sr=None)
|
||||
with pytest.raises(RuntimeError):
|
||||
resampler.resample(dummy_audio, orig_sr=44100)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Tests for normalize_audio function
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestNormalizeAudio:
|
||||
"""Tests for normalize_audio function with different specs."""
|
||||
|
||||
def test_passthrough_preserves_audio(self):
|
||||
"""Passthrough spec should not modify audio."""
|
||||
stereo = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float32)
|
||||
result = normalize_audio(stereo, PASSTHROUGH_AUDIO_SPEC)
|
||||
np.testing.assert_array_equal(result, stereo)
|
||||
|
||||
def test_mono_spec_with_numpy_stereo(self):
|
||||
"""Mono spec should reduce stereo numpy array to 1D."""
|
||||
stereo = np.array([[1.0, 2.0], [-1.0, 0.0]], dtype=np.float32)
|
||||
result = normalize_audio(stereo, MONO_AUDIO_SPEC)
|
||||
assert result.ndim == 1
|
||||
np.testing.assert_array_almost_equal(result, [0.0, 1.0])
|
||||
|
||||
def test_mono_spec_with_torch_stereo(self):
|
||||
"""Mono spec should reduce stereo torch tensor to 1D."""
|
||||
stereo = torch.tensor([[1.0, 2.0], [-1.0, 0.0]])
|
||||
result = normalize_audio(stereo, MONO_AUDIO_SPEC)
|
||||
assert result.ndim == 1
|
||||
torch.testing.assert_close(result, torch.tensor([0.0, 1.0]))
|
||||
|
||||
def test_mono_passthrough_for_1d_numpy(self):
|
||||
"""1D numpy array should pass through unchanged with mono spec."""
|
||||
mono = np.array([1.0, 2.0, 3.0], dtype=np.float32)
|
||||
result = normalize_audio(mono, MONO_AUDIO_SPEC)
|
||||
assert result.ndim == 1
|
||||
np.testing.assert_array_equal(result, mono)
|
||||
|
||||
def test_mono_passthrough_for_1d_torch(self):
|
||||
"""1D torch tensor should pass through unchanged with mono spec."""
|
||||
mono = torch.tensor([1.0, 2.0, 3.0])
|
||||
result = normalize_audio(mono, MONO_AUDIO_SPEC)
|
||||
assert result.ndim == 1
|
||||
torch.testing.assert_close(result, mono)
|
||||
|
||||
def test_first_channel_reduction(self):
|
||||
"""FIRST reduction should take only the first channel."""
|
||||
spec = AudioSpec(target_channels=1, channel_reduction=ChannelReduction.FIRST)
|
||||
stereo = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32)
|
||||
result = normalize_audio(stereo, spec)
|
||||
np.testing.assert_array_equal(result, [1.0, 2.0])
|
||||
|
||||
def test_max_channel_reduction(self):
|
||||
"""MAX reduction should take max across channels."""
|
||||
spec = AudioSpec(target_channels=1, channel_reduction=ChannelReduction.MAX)
|
||||
stereo = np.array([[1.0, 4.0], [3.0, 2.0]], dtype=np.float32)
|
||||
result = normalize_audio(stereo, spec)
|
||||
np.testing.assert_array_equal(result, [3.0, 4.0])
|
||||
|
||||
def test_sum_channel_reduction(self):
|
||||
"""SUM reduction should sum across channels."""
|
||||
spec = AudioSpec(target_channels=1, channel_reduction=ChannelReduction.SUM)
|
||||
stereo = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32)
|
||||
result = normalize_audio(stereo, spec)
|
||||
np.testing.assert_array_equal(result, [4.0, 6.0])
|
||||
|
||||
def test_invalid_3d_array_raises(self):
|
||||
"""3D arrays should raise ValueError."""
|
||||
audio_3d = np.random.randn(2, 3, 4).astype(np.float32)
|
||||
with pytest.raises(ValueError, match="Unsupported audio"):
|
||||
normalize_audio(audio_3d, MONO_AUDIO_SPEC)
|
||||
|
||||
def test_channel_expansion_raises(self):
|
||||
"""Expanding from mono to stereo should raise ValueError."""
|
||||
mono = np.array([1.0, 2.0, 3.0], dtype=np.float32)
|
||||
spec = AudioSpec(target_channels=2)
|
||||
with pytest.raises(ValueError, match="Cannot expand"):
|
||||
normalize_audio(mono, spec)
|
||||
|
||||
def test_time_channels_format_numpy(self):
|
||||
"""Audio in (time, channels) format should be transposed to (channels, time).
|
||||
|
||||
This handles the case where audio loaders like soundfile return
|
||||
(time, channels) format instead of (channels, time) like torchaudio.
|
||||
"""
|
||||
# Create audio in (time, channels) format: 1000 samples, 2 channels
|
||||
audio_time_channels = np.array(
|
||||
[[1.0, -1.0]] * 1000, # 1000 time steps, 2 channels
|
||||
dtype=np.float32,
|
||||
)
|
||||
assert audio_time_channels.shape == (1000, 2) # (time, channels)
|
||||
|
||||
result = normalize_audio(audio_time_channels, MONO_AUDIO_SPEC)
|
||||
|
||||
# Should be reduced to mono 1D
|
||||
assert result.ndim == 1
|
||||
assert result.shape == (1000,)
|
||||
# Mean of [1.0, -1.0] at each time step should be 0.0
|
||||
np.testing.assert_array_almost_equal(result, np.zeros(1000))
|
||||
|
||||
def test_time_channels_format_torch(self):
|
||||
"""Torch tensor in (time, channels) format should be transposed."""
|
||||
# Create audio in (time, channels) format: 1000 samples, 2 channels
|
||||
audio_time_channels = torch.tensor(
|
||||
[[1.0, -1.0]] * 1000, # 1000 time steps, 2 channels
|
||||
)
|
||||
assert audio_time_channels.shape == (1000, 2) # (time, channels)
|
||||
|
||||
result = normalize_audio(audio_time_channels, MONO_AUDIO_SPEC)
|
||||
|
||||
# Should be reduced to mono 1D
|
||||
assert result.ndim == 1
|
||||
assert result.shape == (1000,)
|
||||
# Mean of [1.0, -1.0] at each time step should be 0.0
|
||||
torch.testing.assert_close(result, torch.zeros(1000))
|
||||
|
||||
def test_channels_time_format_preserved(self):
|
||||
"""Audio already in (channels, time) format should work correctly."""
|
||||
# Create audio in standard (channels, time) format: 2 channels, 1000 samples
|
||||
audio_channels_time = np.array(
|
||||
[[1.0] * 1000, [-1.0] * 1000], # 2 channels, 1000 time steps
|
||||
dtype=np.float32,
|
||||
)
|
||||
assert audio_channels_time.shape == (2, 1000) # (channels, time)
|
||||
|
||||
result = normalize_audio(audio_channels_time, MONO_AUDIO_SPEC)
|
||||
|
||||
# Should be reduced to mono 1D
|
||||
assert result.ndim == 1
|
||||
assert result.shape == (1000,)
|
||||
# Mean of [1.0, -1.0] at each time step should be 0.0
|
||||
np.testing.assert_array_almost_equal(result, np.zeros(1000))
|
||||
|
||||
def test_ambiguous_square_audio_numpy(self):
|
||||
"""Square audio arrays (N, N) should use shape[0] > shape[1] heuristic.
|
||||
|
||||
For a square array, shape[0] == shape[1], so no transpose happens
|
||||
and we assume (channels, time) format.
|
||||
"""
|
||||
# Create square audio: 4 channels, 4 samples
|
||||
audio_square = np.array(
|
||||
[
|
||||
[1.0, 2.0, 3.0, 4.0],
|
||||
[5.0, 6.0, 7.0, 8.0],
|
||||
[9.0, 10.0, 11.0, 12.0],
|
||||
[13.0, 14.0, 15.0, 16.0],
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
assert audio_square.shape == (4, 4)
|
||||
|
||||
result = normalize_audio(audio_square, MONO_AUDIO_SPEC)
|
||||
|
||||
# Should be reduced to mono 1D with mean across channels (axis 0)
|
||||
assert result.ndim == 1
|
||||
assert result.shape == (4,)
|
||||
# Mean across 4 channels: [1+5+9+13, 2+6+10+14, ...] / 4
|
||||
expected = np.array([7.0, 8.0, 9.0, 10.0])
|
||||
np.testing.assert_array_almost_equal(result, expected)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Tests for MultiModalDataParser integration with target_channels
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestMultiModalDataParserChannelNormalization:
|
||||
"""Tests for MultiModalDataParser.target_channels integration.
|
||||
|
||||
These tests verify that the target_channels parameter is properly used
|
||||
in the _parse_audio_data method to normalize audio channels.
|
||||
"""
|
||||
|
||||
def test_parser_normalizes_stereo_to_mono(self):
|
||||
"""Parser should normalize stereo to mono when target_channels=1."""
|
||||
from vllm.multimodal.parse import MultiModalDataParser
|
||||
|
||||
# Create parser with mono normalization enabled
|
||||
parser = MultiModalDataParser(
|
||||
target_sr=16000,
|
||||
target_channels=1,
|
||||
)
|
||||
|
||||
# Create stereo audio (simulating torchaudio output)
|
||||
stereo_audio = np.array(
|
||||
[[1.0, 1.0, 1.0], [-1.0, -1.0, -1.0]], # 2 channels, 3 samples
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
# Parse audio data
|
||||
result = parser._parse_audio_data((stereo_audio, 16000))
|
||||
|
||||
# Check that result is mono (1D)
|
||||
audio_item = result.get(0)
|
||||
assert audio_item.ndim == 1, f"Expected 1D mono audio, got {audio_item.ndim}D"
|
||||
assert audio_item.shape == (3,), f"Expected shape (3,), got {audio_item.shape}"
|
||||
# Channel average of [1, 1, 1] and [-1, -1, -1] should be [0, 0, 0]
|
||||
np.testing.assert_array_almost_equal(audio_item, np.zeros(3))
|
||||
|
||||
def test_parser_preserves_stereo_when_target_channels_none(self):
|
||||
"""Parser should preserve stereo when target_channels=None."""
|
||||
from vllm.multimodal.parse import MultiModalDataParser
|
||||
|
||||
# Create parser without channel normalization
|
||||
parser = MultiModalDataParser(
|
||||
target_sr=16000,
|
||||
target_channels=None,
|
||||
)
|
||||
|
||||
# Create stereo audio
|
||||
stereo_audio = np.array(
|
||||
[[1.0, 1.0, 1.0], [-1.0, -1.0, -1.0]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
# Parse audio data
|
||||
result = parser._parse_audio_data((stereo_audio, 16000))
|
||||
|
||||
# Check that result preserves original shape (after resampling)
|
||||
audio_item = result.get(0)
|
||||
# When target_channels=None, stereo audio should be preserved
|
||||
assert audio_item.ndim == 2, f"Expected 2D stereo audio, got {audio_item.ndim}D"
|
||||
|
||||
def test_parser_mono_passthrough_when_target_channels_1(self):
|
||||
"""Parser should pass through mono audio unchanged when target_channels=1."""
|
||||
from vllm.multimodal.parse import MultiModalDataParser
|
||||
|
||||
# Create parser with mono normalization enabled
|
||||
parser = MultiModalDataParser(
|
||||
target_sr=16000,
|
||||
target_channels=1,
|
||||
)
|
||||
|
||||
# Create mono audio (already 1D)
|
||||
mono_audio = np.random.randn(16000).astype(np.float32)
|
||||
|
||||
# Parse audio data
|
||||
result = parser._parse_audio_data((mono_audio, 16000))
|
||||
|
||||
# Check that result is still mono (1D)
|
||||
audio_item = result.get(0)
|
||||
assert audio_item.ndim == 1
|
||||
assert audio_item.shape == (16000,)
|
||||
|
||||
def test_parser_with_target_channels_2(self):
|
||||
"""Parser should reduce 6-channel to 2-channel when target_channels=2."""
|
||||
from vllm.multimodal.parse import MultiModalDataParser
|
||||
|
||||
# Create parser with stereo target
|
||||
parser = MultiModalDataParser(
|
||||
target_sr=16000,
|
||||
target_channels=2,
|
||||
)
|
||||
|
||||
# Create 6-channel audio (5.1 surround)
|
||||
surround_audio = np.random.randn(6, 1000).astype(np.float32)
|
||||
|
||||
# Parse audio data
|
||||
result = parser._parse_audio_data((surround_audio, 16000))
|
||||
|
||||
# Check that result is stereo (2 channels)
|
||||
audio_item = result.get(0)
|
||||
assert audio_item.ndim == 2
|
||||
assert audio_item.shape[0] == 2 # 2 channels
|
||||
|
||||
|
||||
# ============================================================
|
||||
# End-to-End Audio Pipeline Tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestAudioPipelineE2E:
|
||||
"""End-to-end tests for audio normalization in the full pipeline.
|
||||
|
||||
These tests verify the complete flow from raw audio input through
|
||||
the MultiModalDataParser, simulating different audio loader formats.
|
||||
"""
|
||||
|
||||
def test_stereo_audio_normalized_to_mono_e2e(self):
|
||||
"""Full pipeline: stereo audio (torchaudio format) → mono output."""
|
||||
from vllm.multimodal.parse import MultiModalDataParser
|
||||
|
||||
# Simulate torchaudio output: (channels, time) format
|
||||
# Stereo audio with left channel = 1.0, right channel = -1.0
|
||||
stereo_torchaudio = np.array(
|
||||
[[1.0] * 16000, [-1.0] * 16000], # 2 channels, 1 second at 16kHz
|
||||
dtype=np.float32,
|
||||
)
|
||||
assert stereo_torchaudio.shape == (2, 16000)
|
||||
|
||||
# Create parser with mono normalization (like Whisper models)
|
||||
parser = MultiModalDataParser(
|
||||
target_sr=16000,
|
||||
target_channels=1,
|
||||
)
|
||||
|
||||
# Process audio through the parser
|
||||
result = parser._parse_audio_data((stereo_torchaudio, 16000))
|
||||
audio_output = result.get(0)
|
||||
|
||||
# Verify output is mono 1D
|
||||
assert audio_output.ndim == 1, f"Expected 1D, got {audio_output.ndim}D"
|
||||
assert audio_output.shape == (16000,)
|
||||
|
||||
# Verify channel averaging: mean of [1.0, -1.0] = 0.0
|
||||
np.testing.assert_array_almost_equal(audio_output, np.zeros(16000), decimal=5)
|
||||
|
||||
def test_soundfile_format_normalized_to_mono_e2e(self):
|
||||
"""Full pipeline: soundfile format (time, channels) → mono output."""
|
||||
from vllm.multimodal.parse import MultiModalDataParser
|
||||
|
||||
# Simulate soundfile output: (time, channels) format
|
||||
# 16000 samples, 2 channels
|
||||
stereo_soundfile = np.array(
|
||||
[[0.5, -0.5]] * 16000, # Each row is [left, right]
|
||||
dtype=np.float32,
|
||||
)
|
||||
assert stereo_soundfile.shape == (16000, 2)
|
||||
|
||||
# Create parser with mono normalization
|
||||
parser = MultiModalDataParser(
|
||||
target_sr=16000,
|
||||
target_channels=1,
|
||||
)
|
||||
|
||||
# Process audio through the parser
|
||||
result = parser._parse_audio_data((stereo_soundfile, 16000))
|
||||
audio_output = result.get(0)
|
||||
|
||||
# Verify output is mono 1D
|
||||
assert audio_output.ndim == 1, f"Expected 1D, got {audio_output.ndim}D"
|
||||
assert audio_output.shape == (16000,)
|
||||
|
||||
# Verify channel averaging: mean of [0.5, -0.5] = 0.0
|
||||
np.testing.assert_array_almost_equal(audio_output, np.zeros(16000), decimal=5)
|
||||
|
||||
def test_librosa_mono_passthrough_e2e(self):
|
||||
"""Full pipeline: librosa mono format → preserved as mono."""
|
||||
from vllm.multimodal.parse import MultiModalDataParser
|
||||
|
||||
# Simulate librosa output: already mono (time,) format
|
||||
mono_librosa = np.random.randn(16000).astype(np.float32)
|
||||
assert mono_librosa.shape == (16000,)
|
||||
|
||||
# Create parser with mono normalization
|
||||
parser = MultiModalDataParser(
|
||||
target_sr=16000,
|
||||
target_channels=1,
|
||||
)
|
||||
|
||||
# Process audio through the parser
|
||||
result = parser._parse_audio_data((mono_librosa, 16000))
|
||||
audio_output = result.get(0)
|
||||
|
||||
# Verify output is still mono 1D
|
||||
assert audio_output.ndim == 1
|
||||
assert audio_output.shape == (16000,)
|
||||
|
||||
# Verify audio content is preserved
|
||||
np.testing.assert_array_almost_equal(audio_output, mono_librosa)
|
||||
|
||||
def test_multichannel_5_1_surround_to_mono_e2e(self):
|
||||
"""Full pipeline: 5.1 surround (6 channels) → mono output."""
|
||||
from vllm.multimodal.parse import MultiModalDataParser
|
||||
|
||||
# Simulate 5.1 surround audio: 6 channels
|
||||
surround_audio = np.array(
|
||||
[
|
||||
[1.0] * 8000, # Front Left
|
||||
[2.0] * 8000, # Front Right
|
||||
[3.0] * 8000, # Center
|
||||
[4.0] * 8000, # LFE (subwoofer)
|
||||
[5.0] * 8000, # Rear Left
|
||||
[6.0] * 8000, # Rear Right
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
assert surround_audio.shape == (6, 8000)
|
||||
|
||||
# Create parser with mono normalization
|
||||
parser = MultiModalDataParser(
|
||||
target_sr=16000,
|
||||
target_channels=1,
|
||||
)
|
||||
|
||||
# Process audio through the parser
|
||||
result = parser._parse_audio_data((surround_audio, 16000))
|
||||
audio_output = result.get(0)
|
||||
|
||||
# Verify output is mono 1D
|
||||
assert audio_output.ndim == 1
|
||||
|
||||
# Verify channel averaging: mean of [1,2,3,4,5,6] = 3.5
|
||||
expected_value = (1.0 + 2.0 + 3.0 + 4.0 + 5.0 + 6.0) / 6
|
||||
np.testing.assert_array_almost_equal(
|
||||
audio_output, np.full(8000, expected_value), decimal=5
|
||||
)
|
||||
|
||||
def test_torch_tensor_input_e2e(self):
|
||||
"""Full pipeline: torch.Tensor stereo input → mono numpy output."""
|
||||
from vllm.multimodal.parse import MultiModalDataParser
|
||||
|
||||
# Simulate torch tensor input (from torchaudio)
|
||||
stereo_torch = torch.tensor(
|
||||
[[1.0] * 8000, [-1.0] * 8000], # 2 channels
|
||||
dtype=torch.float32,
|
||||
)
|
||||
assert stereo_torch.shape == (2, 8000)
|
||||
|
||||
# Create parser with mono normalization
|
||||
parser = MultiModalDataParser(
|
||||
target_sr=16000,
|
||||
target_channels=1,
|
||||
)
|
||||
|
||||
# Process audio through the parser
|
||||
# Note: Parser expects numpy, so we convert first (simulating real usage)
|
||||
result = parser._parse_audio_data((stereo_torch.numpy(), 16000))
|
||||
audio_output = result.get(0)
|
||||
|
||||
# Verify output is mono 1D numpy array
|
||||
assert audio_output.ndim == 1
|
||||
assert isinstance(audio_output, np.ndarray)
|
||||
|
||||
# Verify channel averaging
|
||||
np.testing.assert_array_almost_equal(audio_output, np.zeros(8000), decimal=5)
|
||||
|
||||
def test_passthrough_preserves_stereo_e2e(self):
|
||||
"""Full pipeline: stereo with target_channels=None → stereo preserved."""
|
||||
from vllm.multimodal.parse import MultiModalDataParser
|
||||
|
||||
# Stereo audio
|
||||
stereo_audio = np.array(
|
||||
[[1.0] * 8000, [-1.0] * 8000],
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
# Create parser WITHOUT mono normalization (passthrough)
|
||||
parser = MultiModalDataParser(
|
||||
target_sr=16000,
|
||||
target_channels=None, # Passthrough - no normalization
|
||||
)
|
||||
|
||||
# Process audio through the parser
|
||||
result = parser._parse_audio_data((stereo_audio, 16000))
|
||||
audio_output = result.get(0)
|
||||
|
||||
# Verify output preserves stereo (2D)
|
||||
assert audio_output.ndim == 2
|
||||
assert audio_output.shape == (2, 8000)
|
||||
|
||||
def test_resampling_with_channel_normalization_e2e(self):
|
||||
"""Full pipeline: resample + channel normalize in single pass."""
|
||||
from vllm.multimodal.parse import MultiModalDataParser
|
||||
|
||||
# Stereo audio at 48kHz (common recording rate)
|
||||
stereo_48k = np.array(
|
||||
[[1.0] * 48000, [-1.0] * 48000], # 1 second at 48kHz
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
# Create parser with both resampling and mono normalization
|
||||
parser = MultiModalDataParser(
|
||||
target_sr=16000, # Resample to 16kHz
|
||||
target_channels=1, # Normalize to mono
|
||||
)
|
||||
|
||||
# Process audio through the parser
|
||||
result = parser._parse_audio_data((stereo_48k, 48000))
|
||||
audio_output = result.get(0)
|
||||
|
||||
# Verify output is mono 1D at target sample rate
|
||||
assert audio_output.ndim == 1
|
||||
# After resampling from 48kHz to 16kHz, length should be ~16000
|
||||
assert audio_output.shape[0] == 16000
|
||||
|
||||
def test_very_short_audio_e2e(self):
|
||||
"""Full pipeline: very short audio (< 1 frame) handled correctly."""
|
||||
from vllm.multimodal.parse import MultiModalDataParser
|
||||
|
||||
# Very short stereo audio (10 samples)
|
||||
short_stereo = np.array(
|
||||
[[1.0] * 10, [-1.0] * 10],
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
parser = MultiModalDataParser(
|
||||
target_sr=16000,
|
||||
target_channels=1,
|
||||
)
|
||||
|
||||
result = parser._parse_audio_data((short_stereo, 16000))
|
||||
audio_output = result.get(0)
|
||||
|
||||
# Should still produce mono output
|
||||
assert audio_output.ndim == 1
|
||||
assert audio_output.shape == (10,)
|
||||
np.testing.assert_array_almost_equal(audio_output, np.zeros(10))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Tests for Audio Chunking Utilities
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestAudioChunking:
|
||||
"""Tests for split_audio and find_split_point utilities in vllm.multimodal.audio."""
|
||||
|
||||
def test_split_audio_short_clip(self):
|
||||
"""Audio shorter than max_clip_duration_s should not be split."""
|
||||
|
||||
# 10 seconds of audio at 16kHz
|
||||
audio = np.linspace(-1.0, 1.0, 160000, dtype=np.float32)
|
||||
|
||||
chunks = split_audio(
|
||||
audio_data=audio,
|
||||
sample_rate=16000,
|
||||
max_clip_duration_s=30.0,
|
||||
overlap_duration_s=1.0,
|
||||
min_energy_window_size=1600,
|
||||
)
|
||||
|
||||
assert len(chunks) == 1
|
||||
np.testing.assert_array_equal(chunks[0], audio)
|
||||
|
||||
def test_split_audio_exact_length(self):
|
||||
"""Audio exactly at max_clip_duration_s should not be split."""
|
||||
|
||||
# Exactly 30 seconds at 16kHz
|
||||
audio = np.linspace(-1.0, 1.0, 480000, dtype=np.float32)
|
||||
|
||||
chunks = split_audio(
|
||||
audio_data=audio,
|
||||
sample_rate=16000,
|
||||
max_clip_duration_s=30.0,
|
||||
overlap_duration_s=1.0,
|
||||
min_energy_window_size=1600,
|
||||
)
|
||||
|
||||
assert len(chunks) == 1
|
||||
np.testing.assert_array_equal(chunks[0], audio)
|
||||
|
||||
def test_split_audio_long_clip(self):
|
||||
"""Long audio should be split into multiple chunks."""
|
||||
|
||||
# 65 seconds of audio at 16kHz
|
||||
audio = np.linspace(-1.0, 1.0, 1040000, dtype=np.float32)
|
||||
|
||||
chunks = split_audio(
|
||||
audio_data=audio,
|
||||
sample_rate=16000,
|
||||
max_clip_duration_s=30.0,
|
||||
overlap_duration_s=1.0,
|
||||
min_energy_window_size=1600,
|
||||
)
|
||||
|
||||
assert len(chunks) > 1
|
||||
# First sample preserved
|
||||
assert chunks[0][0] == audio[0]
|
||||
# Last sample preserved
|
||||
assert chunks[-1][-1] == audio[-1]
|
||||
|
||||
def test_split_audio_chunks_have_correct_length(self):
|
||||
"""Each chunk (except last) should be approximately max_clip_duration_s."""
|
||||
|
||||
# 65 seconds of audio at 16kHz
|
||||
audio = np.linspace(-1.0, 1.0, 1040000, dtype=np.float32)
|
||||
|
||||
chunks = split_audio(
|
||||
audio_data=audio,
|
||||
sample_rate=16000,
|
||||
max_clip_duration_s=30.0,
|
||||
overlap_duration_s=1.0,
|
||||
min_energy_window_size=1600,
|
||||
)
|
||||
|
||||
max_samples = int(30.0 * 16000)
|
||||
overlap_samples = int(1.0 * 16000)
|
||||
|
||||
for chunk in chunks[:-1]:
|
||||
assert chunk.shape[0] >= max_samples - overlap_samples
|
||||
assert chunk.shape[0] <= max_samples
|
||||
|
||||
def test_find_split_point_finds_quiet_region(self):
|
||||
"""find_split_point should identify low-energy regions."""
|
||||
from vllm.multimodal.audio import find_split_point
|
||||
|
||||
# Create audio with a quiet section in the middle
|
||||
segment = np.ones(32000, dtype=np.float32)
|
||||
# Insert quiet region at sample 16000-17600 (100ms)
|
||||
segment[16000:17600] = 0.01
|
||||
|
||||
split_idx = find_split_point(
|
||||
wav=segment,
|
||||
start_idx=0,
|
||||
end_idx=32000,
|
||||
min_energy_window=1600,
|
||||
)
|
||||
|
||||
# Split should be in or near the quiet region
|
||||
assert 16000 <= split_idx <= 17600
|
||||
|
||||
def test_find_split_point_handles_uniform_audio(self):
|
||||
"""find_split_point should handle uniform energy audio gracefully."""
|
||||
from vllm.multimodal.audio import find_split_point
|
||||
|
||||
segment = np.ones(32000, dtype=np.float32) * 0.5
|
||||
|
||||
split_idx = find_split_point(
|
||||
wav=segment,
|
||||
start_idx=0,
|
||||
end_idx=32000,
|
||||
min_energy_window=1600,
|
||||
)
|
||||
|
||||
assert 0 <= split_idx <= 32000
|
||||
|
||||
def test_find_split_point_silence(self):
|
||||
"""find_split_point should prefer the quietest scanned window."""
|
||||
from vllm.multimodal.audio import find_split_point
|
||||
|
||||
# Deterministic signal: constant energy everywhere except silence.
|
||||
segment = np.ones(32000, dtype=np.float32)
|
||||
# Complete silence at 20000-21600.
|
||||
segment[20000:21600] = 0.0
|
||||
|
||||
split_idx = find_split_point(
|
||||
wav=segment,
|
||||
start_idx=16000,
|
||||
end_idx=28000,
|
||||
min_energy_window=1600,
|
||||
)
|
||||
|
||||
# Current implementation evaluates non-overlapping 1600-sample windows
|
||||
# from start_idx, so the quietest scanned window starts at 19200.
|
||||
assert split_idx == 19200
|
||||
|
||||
def test_split_audio_preserves_boundaries(self):
|
||||
"""Verify first and last samples are preserved when chunking."""
|
||||
|
||||
audio = np.arange(1120000, dtype=np.float32) # 70s at 16kHz
|
||||
|
||||
chunks = split_audio(
|
||||
audio_data=audio,
|
||||
sample_rate=16000,
|
||||
max_clip_duration_s=30.0,
|
||||
overlap_duration_s=1.0,
|
||||
min_energy_window_size=1600,
|
||||
)
|
||||
|
||||
assert chunks[0][0] == audio[0]
|
||||
assert chunks[-1][-1] == audio[-1]
|
||||
|
||||
def test_split_audio_with_different_sample_rates(self):
|
||||
"""Test chunking works with different sample rates."""
|
||||
|
||||
# 40 seconds at 8kHz
|
||||
audio_8k = np.linspace(-1.0, 1.0, 320000, dtype=np.float32)
|
||||
|
||||
chunks = split_audio(
|
||||
audio_data=audio_8k,
|
||||
sample_rate=8000,
|
||||
max_clip_duration_s=30.0,
|
||||
overlap_duration_s=1.0,
|
||||
min_energy_window_size=800,
|
||||
)
|
||||
|
||||
assert len(chunks) >= 2
|
||||
|
||||
# 40 seconds at 48kHz
|
||||
audio_48k = np.linspace(-1.0, 1.0, 1920000, dtype=np.float32)
|
||||
|
||||
chunks_48k = split_audio(
|
||||
audio_data=audio_48k,
|
||||
sample_rate=48000,
|
||||
max_clip_duration_s=30.0,
|
||||
overlap_duration_s=1.0,
|
||||
min_energy_window_size=4800,
|
||||
)
|
||||
|
||||
assert len(chunks_48k) >= 2
|
||||
551
third_party/vllm/tests/multimodal/test_cache.py
vendored
Normal file
551
third_party/vllm/tests/multimodal/test_cache.py
vendored
Normal file
@@ -0,0 +1,551 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import multiprocessing as mp
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm.config import ModelConfig, ParallelConfig, VllmConfig
|
||||
from vllm.multimodal import MULTIMODAL_REGISTRY
|
||||
from vllm.multimodal.cache import (
|
||||
BaseMultiModalProcessorCache,
|
||||
BaseMultiModalReceiverCache,
|
||||
MultiModalCache,
|
||||
MultiModalProcessorCacheInItem,
|
||||
MultiModalProcessorCacheItem,
|
||||
MultiModalProcessorCacheItemMetadata,
|
||||
MultiModalProcessorSenderCache,
|
||||
MultiModalReceiverCache,
|
||||
ShmObjectStoreReceiverCache,
|
||||
ShmObjectStoreSenderCache,
|
||||
)
|
||||
from vllm.multimodal.hasher import MultiModalHasher
|
||||
from vllm.multimodal.inputs import (
|
||||
MultiModalFeatureSpec,
|
||||
MultiModalFieldElem,
|
||||
MultiModalKwargsItem,
|
||||
MultiModalKwargsItems,
|
||||
MultiModalSharedField,
|
||||
PlaceholderRange,
|
||||
)
|
||||
from vllm.multimodal.processing import PromptInsertion
|
||||
from vllm.utils.mem_constants import GiB_bytes, MiB_bytes
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
|
||||
def _dummy_elem(
|
||||
size: int,
|
||||
*,
|
||||
rng: np.random.RandomState | None = None,
|
||||
):
|
||||
if rng is None:
|
||||
data = torch.empty((size,), dtype=torch.int8)
|
||||
else:
|
||||
data = torch.from_numpy(rng.randint(4, size=(size,), dtype=np.int8))
|
||||
|
||||
return MultiModalFieldElem(
|
||||
data=data,
|
||||
field=MultiModalSharedField(batch_size=1),
|
||||
)
|
||||
|
||||
|
||||
def _dummy_item(
|
||||
size_by_key: dict[str, int],
|
||||
*,
|
||||
rng: np.random.RandomState | None = None,
|
||||
):
|
||||
return MultiModalKwargsItem(
|
||||
{key: _dummy_elem(size, rng=rng) for key, size in size_by_key.items()}
|
||||
)
|
||||
|
||||
|
||||
def _dummy_items(
|
||||
size_by_key_modality: dict[str, dict[str, int]],
|
||||
*,
|
||||
rng: np.random.RandomState | None = None,
|
||||
):
|
||||
return MultiModalKwargsItems(
|
||||
{
|
||||
modality: [_dummy_item(size_by_key, rng=rng)]
|
||||
for modality, size_by_key in size_by_key_modality.items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("item", "expected_size"),
|
||||
[
|
||||
(_dummy_item({"a1": 100}), 100),
|
||||
(_dummy_item({"a1": 100, "a2": 110}), 210),
|
||||
(_dummy_items({"a": {"a1": 100, "a2": 110}, "b": {"b1": 120, "b2": 130}}), 460), # noqa: E501
|
||||
],
|
||||
)
|
||||
def test_cache_item_size(item, expected_size):
|
||||
cache = MultiModalCache.get_lru_cache(2048, type(item))
|
||||
|
||||
cache[""] = item
|
||||
assert cache.currsize == expected_size
|
||||
|
||||
prompt_update = PromptInsertion("dummy", "target", "insertion").resolve(0)
|
||||
|
||||
cache[""] = MultiModalProcessorCacheItem(item, [prompt_update])
|
||||
assert cache.currsize == expected_size
|
||||
|
||||
cache[""] = MultiModalProcessorCacheItemMetadata(item, [prompt_update])
|
||||
assert cache.currsize == expected_size
|
||||
|
||||
cache[""] = item.get_data()
|
||||
assert cache.currsize == expected_size
|
||||
|
||||
|
||||
def _create_vllm_config(
|
||||
*,
|
||||
mm_processor_cache_gb: float,
|
||||
enable_ipc: bool,
|
||||
):
|
||||
return VllmConfig(
|
||||
model_config=ModelConfig(
|
||||
model="llava-hf/llava-onevision-qwen2-0.5b-ov-hf",
|
||||
mm_processor_cache_gb=mm_processor_cache_gb,
|
||||
),
|
||||
parallel_config=ParallelConfig(data_parallel_size=1 if enable_ipc else 2),
|
||||
)
|
||||
|
||||
|
||||
def _compare_caches(
|
||||
config_0: VllmConfig,
|
||||
config_1: VllmConfig,
|
||||
*,
|
||||
item_capacity: int = 8,
|
||||
hit_rate: float = 0.5,
|
||||
max_items_per_iter: int = 3,
|
||||
is_cached_calls_per_iter: int,
|
||||
n_iter: int = 100,
|
||||
seed: int = 0,
|
||||
):
|
||||
cache_0_p0 = MULTIMODAL_REGISTRY.processor_cache_from_config(config_0)
|
||||
cache_0_p1 = MULTIMODAL_REGISTRY.engine_receiver_cache_from_config(config_0)
|
||||
cache_1_p0 = MULTIMODAL_REGISTRY.processor_cache_from_config(config_1)
|
||||
cache_1_p1 = MULTIMODAL_REGISTRY.engine_receiver_cache_from_config(config_1)
|
||||
|
||||
cache_size_gb = max(
|
||||
config_0.model_config.multimodal_config.mm_processor_cache_gb,
|
||||
config_1.model_config.multimodal_config.mm_processor_cache_gb,
|
||||
)
|
||||
item_size_gb = int(cache_size_gb / item_capacity)
|
||||
|
||||
rng = np.random.RandomState(seed)
|
||||
all_items = [
|
||||
_dummy_item({"key": item_size_gb}, rng=rng)
|
||||
for _ in range(int(item_capacity / hit_rate))
|
||||
]
|
||||
all_hashes = [
|
||||
MultiModalHasher.hash_kwargs(item=item.get_data()) for item in all_items
|
||||
]
|
||||
|
||||
prompt_update = PromptInsertion("dummy", "target", "insertion").resolve(0)
|
||||
|
||||
for it in range(n_iter):
|
||||
num_items_to_select = rng.randint(0, max_items_per_iter)
|
||||
item_idxs_to_select = rng.choice(len(all_items), num_items_to_select)
|
||||
|
||||
selected_items = [all_items[idx] for idx in item_idxs_to_select]
|
||||
selected_hashes = [all_hashes[idx] for idx in item_idxs_to_select]
|
||||
|
||||
if cache_0_p0 is None:
|
||||
cache_0_p0_out = selected_items
|
||||
else:
|
||||
for _ in range(is_cached_calls_per_iter):
|
||||
cache_0_p0.is_cached(selected_hashes)
|
||||
|
||||
cache_0_p0_out = [
|
||||
item
|
||||
for item, _ in cache_0_p0.get_and_update(
|
||||
[(item, [prompt_update]) for item in selected_items],
|
||||
selected_hashes,
|
||||
)
|
||||
]
|
||||
|
||||
if cache_1_p0 is None:
|
||||
cache_1_p0_out = selected_items
|
||||
else:
|
||||
for _ in range(is_cached_calls_per_iter):
|
||||
cache_1_p0.is_cached(selected_hashes)
|
||||
|
||||
cache_1_p0_out = [
|
||||
item
|
||||
for item, _ in cache_1_p0.get_and_update(
|
||||
[(item, [prompt_update]) for item in selected_items],
|
||||
selected_hashes,
|
||||
)
|
||||
]
|
||||
|
||||
if cache_0_p1 is None:
|
||||
cache_0_p1_out = cache_0_p0_out
|
||||
else:
|
||||
cache_0_p1_out = cache_0_p1.get_and_update(cache_0_p0_out, selected_hashes)
|
||||
|
||||
if cache_1_p1 is None:
|
||||
cache_1_p1_out = cache_1_p0_out
|
||||
else:
|
||||
cache_1_p1_out = cache_1_p1.get_and_update(cache_1_p0_out, selected_hashes)
|
||||
|
||||
assert cache_0_p1_out == cache_1_p1_out, f"Failed at {it=}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_cached_calls_per_iter", [1, 2, 3])
|
||||
def test_ipc_enable_disable_consistency(is_cached_calls_per_iter):
|
||||
cache_size_gb = 1 / (1 << 20)
|
||||
|
||||
vllm_config_ipc_enabled = _create_vllm_config(
|
||||
mm_processor_cache_gb=cache_size_gb,
|
||||
enable_ipc=True,
|
||||
)
|
||||
vllm_config_ipc_disabled = _create_vllm_config(
|
||||
mm_processor_cache_gb=0,
|
||||
enable_ipc=False,
|
||||
)
|
||||
vllm_config_cache_disabled = _create_vllm_config(
|
||||
mm_processor_cache_gb=cache_size_gb,
|
||||
enable_ipc=True,
|
||||
)
|
||||
|
||||
_compare_caches(
|
||||
vllm_config_ipc_enabled,
|
||||
vllm_config_ipc_disabled,
|
||||
is_cached_calls_per_iter=is_cached_calls_per_iter,
|
||||
)
|
||||
_compare_caches(
|
||||
vllm_config_ipc_disabled,
|
||||
vllm_config_cache_disabled,
|
||||
is_cached_calls_per_iter=is_cached_calls_per_iter,
|
||||
)
|
||||
_compare_caches(
|
||||
vllm_config_cache_disabled,
|
||||
vllm_config_ipc_enabled,
|
||||
is_cached_calls_per_iter=is_cached_calls_per_iter,
|
||||
)
|
||||
|
||||
|
||||
def _run_test_cache_eviction_lru(
|
||||
p0_cache: BaseMultiModalProcessorCache,
|
||||
p1_cache: BaseMultiModalReceiverCache,
|
||||
base_item_size: int,
|
||||
):
|
||||
request1_hashes = [
|
||||
"image_A",
|
||||
"image_B",
|
||||
"image_C",
|
||||
]
|
||||
request1_items = {
|
||||
h: MultiModalKwargsItem.dummy(nbytes=2 * base_item_size)
|
||||
for h in request1_hashes
|
||||
}
|
||||
|
||||
request2_hashes = ["image_D", "image_E", "image_A", "image_C"]
|
||||
request2_items = {
|
||||
h: MultiModalKwargsItem.dummy(nbytes=1 * base_item_size)
|
||||
for h in request2_hashes
|
||||
}
|
||||
|
||||
##########################
|
||||
# STEP 1: Request 1 send
|
||||
##########################
|
||||
sender_is_cached_item_req1 = p0_cache.is_cached(request1_hashes)
|
||||
# Cache is empty
|
||||
assert sender_is_cached_item_req1 == [False, False, False]
|
||||
|
||||
# Touch all mm hash for P0 Cache before process
|
||||
for mm_hash in request1_hashes:
|
||||
p0_cache.touch_sender_cache_item(mm_hash)
|
||||
|
||||
###########################
|
||||
# Process request 1 for P0 Cache
|
||||
###########################
|
||||
item_tuple: MultiModalProcessorCacheInItem
|
||||
for i, h in enumerate(request1_hashes):
|
||||
# Use precomputed cache state
|
||||
is_cached = sender_is_cached_item_req1[i]
|
||||
item_tuple = (request1_items[h], []) if not is_cached else None
|
||||
print(f"Request 1: key={h} | cached={is_cached}")
|
||||
|
||||
p0_cache.get_and_update_item(item_tuple, h)
|
||||
|
||||
###########################
|
||||
# Process request 1 for P1 Cache
|
||||
###########################
|
||||
# Touch all mm hash for P1 Cache before process
|
||||
for mm_hash in request1_hashes:
|
||||
p1_cache.touch_receiver_cache_item(mm_hash)
|
||||
|
||||
for h in request1_hashes:
|
||||
p1_cache.get_and_update_item(request1_items[h], h)
|
||||
|
||||
expected_hashes = ["image_A", "image_B", "image_C"]
|
||||
assert list(p0_cache._cache.order) == expected_hashes
|
||||
|
||||
##########################
|
||||
# STEP 2: Request 2 send
|
||||
##########################
|
||||
sender_is_cached_item_req2 = p0_cache.is_cached(request2_hashes)
|
||||
assert sender_is_cached_item_req2 == [False, False, True, True]
|
||||
|
||||
# Touch all mm hash for P0 Cache before process
|
||||
for mm_hash in request2_hashes:
|
||||
p0_cache.touch_sender_cache_item(mm_hash)
|
||||
|
||||
###########################
|
||||
# Process request 2 for P0 Cache
|
||||
###########################
|
||||
for i, h in enumerate(request2_hashes):
|
||||
# Use precomputed cache state again
|
||||
is_cached = sender_is_cached_item_req2[i]
|
||||
item_tuple = (request2_items[h], []) if not is_cached else None
|
||||
print(f"Request 2: key={h} | cached={is_cached}")
|
||||
|
||||
p0_cache.get_and_update_item(item_tuple, h)
|
||||
|
||||
###########################
|
||||
# Process request 2 for P1 Cache
|
||||
###########################
|
||||
|
||||
# Touch all mm hash for P1 Cache before process
|
||||
for mm_hash in request2_hashes:
|
||||
p1_cache.touch_receiver_cache_item(mm_hash)
|
||||
|
||||
for h in request2_hashes:
|
||||
p1_cache.get_and_update_item(request2_items[h], h)
|
||||
|
||||
expected_hashes = ["image_D", "image_E", "image_A", "image_C"]
|
||||
assert list(p0_cache._cache.order) == expected_hashes
|
||||
|
||||
|
||||
def test_cache_eviction_lru_cache():
|
||||
model_config = ModelConfig(
|
||||
model="llava-hf/llava-onevision-qwen2-0.5b-ov-hf",
|
||||
mm_processor_cache_gb=6 / GiB_bytes,
|
||||
)
|
||||
sender_cache = MultiModalProcessorSenderCache(model_config)
|
||||
receiver_cache = MultiModalReceiverCache(model_config)
|
||||
|
||||
_run_test_cache_eviction_lru(sender_cache, receiver_cache, base_item_size=1)
|
||||
|
||||
|
||||
# This test verifies shared-memory cache eviction behavior across processor (p0)
|
||||
# and receiver (p1) caches.
|
||||
# Flow summary:
|
||||
# 1. Request 1 adds images A, B, C — completely filling the cache.
|
||||
# 2. Request 2 tries to add image_G and image_A, but image_G cannot be added because
|
||||
# cache is full and A is protected from eviction — cache remains unchanged.
|
||||
# 3. Request 3 adds image_G, image_H, image_I and image_B
|
||||
# this time, image_A is evicted, freeing 5MB space
|
||||
# and image_G, image_H successfully fits,
|
||||
# image_B is protected from eviction then image_i cannot be added.
|
||||
# This proving normal eviction and reuse behavior.
|
||||
def _run_test_cache_eviction_shm(
|
||||
p0_cache: BaseMultiModalProcessorCache,
|
||||
p1_cache: BaseMultiModalReceiverCache,
|
||||
base_item_size: int,
|
||||
):
|
||||
request1_hashes = ["image_A", "image_B", "image_C"]
|
||||
request1_items = {
|
||||
h: MultiModalKwargsItem.dummy(5 * base_item_size) for h in request1_hashes
|
||||
}
|
||||
request1_items_p0_result = []
|
||||
|
||||
request2_hashes = ["image_G", "image_A"]
|
||||
request2_items = {
|
||||
h: MultiModalKwargsItem.dummy(
|
||||
(5 if h in request1_hashes else 2) * base_item_size
|
||||
)
|
||||
for h in request2_hashes
|
||||
}
|
||||
request2_items_p0_result = []
|
||||
|
||||
request3_hashes = ["image_G", "image_H", "image_I", "image_B"]
|
||||
request3_items = {
|
||||
h: MultiModalKwargsItem.dummy(
|
||||
(5 if h in request1_hashes else 2) * base_item_size
|
||||
)
|
||||
for h in request3_hashes
|
||||
}
|
||||
request3_items_p0_result = []
|
||||
|
||||
##########################
|
||||
# STEP 1: Request 1 send
|
||||
# This will fill up the cache
|
||||
##########################
|
||||
sender_is_cached_item_req1 = p0_cache.is_cached(request1_hashes)
|
||||
# Cache is empty
|
||||
assert sender_is_cached_item_req1 == [False, False, False]
|
||||
|
||||
# Touch all mm hash for P0 Cache before process
|
||||
for mm_hash in request1_hashes:
|
||||
p0_cache.touch_sender_cache_item(mm_hash)
|
||||
|
||||
###########################
|
||||
# Process request 1 for P0 Cache
|
||||
###########################
|
||||
item_tuple: MultiModalProcessorCacheInItem
|
||||
for i, h in enumerate(request1_hashes):
|
||||
# Use precomputed cache state
|
||||
is_cached = sender_is_cached_item_req1[i]
|
||||
item_tuple = (request1_items[h], []) if not is_cached else None
|
||||
print(f"Request 1: key={h} | cached={is_cached}")
|
||||
|
||||
p0_result = p0_cache.get_and_update_item(item_tuple, h)
|
||||
# Only get mm item, ignore prompt update result
|
||||
request1_items_p0_result.append(p0_result[0])
|
||||
|
||||
###########################
|
||||
# Process request 1 for P1 Cache
|
||||
###########################
|
||||
# Touch all mm hash for P1 Cache before process
|
||||
for mm_hash, mm_item in zip(request1_hashes, request1_items_p0_result):
|
||||
p1_cache.touch_receiver_cache_item(mm_hash, mm_item)
|
||||
|
||||
for mm_hash, mm_item in zip(request1_hashes, request1_items_p0_result):
|
||||
p1_cache.get_and_update_item(mm_item, mm_hash)
|
||||
|
||||
expected_hashes = ["image_A", "image_B", "image_C"]
|
||||
assert list(p0_cache._shm_cache.key_index.keys()) == expected_hashes
|
||||
|
||||
##########################
|
||||
# STEP 2: Request 2 send
|
||||
# There is no eviction because image_A is protected
|
||||
# No new item can add to cache
|
||||
##########################
|
||||
sender_is_cached_item_req2 = p0_cache.is_cached(request2_hashes)
|
||||
assert sender_is_cached_item_req2 == [False, True]
|
||||
|
||||
# Touch all mm hash for P0 Cache before process
|
||||
for mm_hash in request2_hashes:
|
||||
p0_cache.touch_sender_cache_item(mm_hash)
|
||||
|
||||
###########################
|
||||
# Process request 2 for P0 Cache
|
||||
###########################
|
||||
for i, h in enumerate(request2_hashes):
|
||||
# Use precomputed cache state again
|
||||
is_cached = sender_is_cached_item_req2[i]
|
||||
item_tuple = (request2_items[h], []) if not is_cached else None
|
||||
print(f"Request 2: key={h} | cached={is_cached}")
|
||||
|
||||
p0_result = p0_cache.get_and_update_item(item_tuple, h)
|
||||
# Only get mm item, ignore prompt update result
|
||||
request2_items_p0_result.append(p0_result[0])
|
||||
|
||||
# image_A cannot be evict then
|
||||
# image_G will fail to allocate anyway and image_A still in cache
|
||||
assert p0_cache.is_cached(request2_hashes) == [False, True]
|
||||
|
||||
###########################
|
||||
# Process request 2 for P1 Cache
|
||||
###########################
|
||||
|
||||
# Touch all mm hash for P1 Cache before process
|
||||
for mm_hash, mm_item in zip(request2_hashes, request2_items_p0_result):
|
||||
p1_cache.touch_receiver_cache_item(mm_hash, mm_item)
|
||||
|
||||
for mm_hash, mm_item in zip(request2_hashes, request2_items_p0_result):
|
||||
p1_cache.get_and_update_item(mm_item, mm_hash)
|
||||
|
||||
# Prove that cache state is unchanged
|
||||
expected_hashes = ["image_A", "image_B", "image_C"]
|
||||
assert list(p0_cache._shm_cache.key_index.keys()) == expected_hashes
|
||||
|
||||
##########################
|
||||
# STEP 3: Request 3 send
|
||||
##########################
|
||||
##### Prove that cache eviction work normally
|
||||
sender_is_cached_item_req3 = p0_cache.is_cached(request3_hashes)
|
||||
assert sender_is_cached_item_req3 == [False, False, False, True]
|
||||
|
||||
# Touch all mm hash for P0 Cache before process
|
||||
for mm_hash in request3_hashes:
|
||||
p0_cache.touch_sender_cache_item(mm_hash)
|
||||
|
||||
###########################
|
||||
# Process request 3 for P0 Cache
|
||||
###########################
|
||||
for i, h in enumerate(request3_hashes):
|
||||
# Use precomputed cache state again
|
||||
is_cached = sender_is_cached_item_req3[i]
|
||||
item_tuple = (request3_items[h], []) if not is_cached else None
|
||||
print(f"Request 3: key={h} | cached={is_cached}")
|
||||
p0_result = p0_cache.get_and_update_item(item_tuple, h)
|
||||
# Only get mm item, ignore prompt update result
|
||||
request3_items_p0_result.append(p0_result[0])
|
||||
|
||||
# image_A got evict and image_G add to cache
|
||||
# image_B is still protected
|
||||
# image_G, image_H fit but image_I cannot fit
|
||||
assert p0_cache.is_cached(request3_hashes) == [True, True, False, True]
|
||||
|
||||
###########################
|
||||
# Process request 3 for P1 Cache
|
||||
###########################
|
||||
|
||||
# Touch all mm hash for P1 Cache before process
|
||||
for mm_hash, mm_item in zip(request3_hashes, request3_items_p0_result):
|
||||
p1_cache.touch_receiver_cache_item(mm_hash, mm_item)
|
||||
|
||||
for mm_hash, mm_item in zip(request3_hashes, request3_items_p0_result):
|
||||
p1_cache.get_and_update_item(mm_item, mm_hash)
|
||||
|
||||
expected_hashes = ["image_B", "image_C", "image_G", "image_H"]
|
||||
assert list(p0_cache._shm_cache.key_index.keys()) == expected_hashes
|
||||
|
||||
|
||||
def test_cache_eviction_shm_cache():
|
||||
vllm_config = VllmConfig(
|
||||
model_config=ModelConfig(
|
||||
model="llava-hf/llava-onevision-qwen2-0.5b-ov-hf",
|
||||
mm_processor_cache_type="shm",
|
||||
mm_shm_cache_max_object_size_mb=6,
|
||||
mm_processor_cache_gb=15.2 * MiB_bytes / GiB_bytes,
|
||||
),
|
||||
)
|
||||
sender_cache = ShmObjectStoreSenderCache(vllm_config)
|
||||
receiver_cache = ShmObjectStoreReceiverCache(vllm_config, mp.Lock())
|
||||
|
||||
_run_test_cache_eviction_shm(sender_cache, receiver_cache, base_item_size=MiB_bytes)
|
||||
|
||||
|
||||
def test_processor_cache_shared_across_loras():
|
||||
"""Test that processor cache uses mm_hash to share data across LoRAs."""
|
||||
model_config = ModelConfig(
|
||||
model="llava-hf/llava-onevision-qwen2-0.5b-ov-hf",
|
||||
mm_processor_cache_gb=1,
|
||||
)
|
||||
receiver_cache = MultiModalReceiverCache(model_config)
|
||||
|
||||
base_mm_hash = "image_hash_abc123"
|
||||
lora_a_identifier = f"12345:{base_mm_hash}"
|
||||
lora_b_identifier = f"67890:{base_mm_hash}"
|
||||
|
||||
item_data = MultiModalKwargsItem.dummy(1024)
|
||||
|
||||
feature_lora_a = MultiModalFeatureSpec(
|
||||
data=item_data,
|
||||
modality="image",
|
||||
identifier=lora_a_identifier,
|
||||
mm_position=PlaceholderRange(offset=0, length=100),
|
||||
mm_hash=base_mm_hash,
|
||||
)
|
||||
|
||||
receiver_cache.get_and_update_features([feature_lora_a])
|
||||
assert base_mm_hash in receiver_cache._cache
|
||||
|
||||
feature_lora_b = MultiModalFeatureSpec(
|
||||
data=None,
|
||||
modality="image",
|
||||
identifier=lora_b_identifier,
|
||||
mm_position=PlaceholderRange(offset=0, length=100),
|
||||
mm_hash=base_mm_hash,
|
||||
)
|
||||
|
||||
receiver_cache.get_and_update_features([feature_lora_b])
|
||||
assert feature_lora_b.data == item_data
|
||||
249
third_party/vllm/tests/multimodal/test_embedding_shape_validation_unit.py
vendored
Normal file
249
third_party/vllm/tests/multimodal/test_embedding_shape_validation_unit.py
vendored
Normal file
@@ -0,0 +1,249 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""
|
||||
Unit tests for embedding shape validation.
|
||||
|
||||
Simple, fast unit tests that can run without server fixtures.
|
||||
Run with: pytest tests/multimodal/test_embedding_shape_validation_unit.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm.multimodal.parse import (
|
||||
AudioEmbeddingItems,
|
||||
ImageEmbeddingItems,
|
||||
)
|
||||
|
||||
|
||||
class TestImageEmbedBasicValidation:
|
||||
"""Test basic ndim validation in image embeddings via ImageEmbeddingItems."""
|
||||
|
||||
def test_valid_2d_tensor_accepted(self):
|
||||
"""Baseline: 2D tensors should be accepted."""
|
||||
valid_tensor = torch.randn(10, 768, dtype=torch.float32)
|
||||
|
||||
# Should not raise - 2D is valid
|
||||
items = ImageEmbeddingItems(valid_tensor)
|
||||
assert items.get_count() == 10
|
||||
|
||||
def test_valid_3d_tensor_accepted(self):
|
||||
"""Baseline: 3D tensors should be accepted."""
|
||||
valid_tensor = torch.randn(2, 10, 768, dtype=torch.float32)
|
||||
|
||||
# Should not raise - 3D is valid
|
||||
items = ImageEmbeddingItems(valid_tensor)
|
||||
assert items.get_count() == 2
|
||||
|
||||
def test_valid_list_of_2d_tensors_accepted(self):
|
||||
"""Baseline: List of 2D tensors should be accepted."""
|
||||
tensors = [
|
||||
torch.randn(10, 768, dtype=torch.float32),
|
||||
torch.randn(15, 768, dtype=torch.float32),
|
||||
]
|
||||
|
||||
# Should not raise
|
||||
items = ImageEmbeddingItems(tensors)
|
||||
assert items.get_count() == 2
|
||||
|
||||
def test_1d_tensor_rejected(self):
|
||||
"""Security: 1D tensors should be rejected (invalid ndim)."""
|
||||
invalid_tensor = torch.randn(768, dtype=torch.float32) # 1D
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
ImageEmbeddingItems(invalid_tensor)
|
||||
|
||||
assert "must be 2D" in str(exc_info.value) or "3D" in str(exc_info.value)
|
||||
|
||||
def test_4d_tensor_rejected(self):
|
||||
"""Security: 4D tensors should be rejected (invalid ndim)."""
|
||||
invalid_tensor = torch.randn(1, 2, 10, 768, dtype=torch.float32) # 4D
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
ImageEmbeddingItems(invalid_tensor)
|
||||
|
||||
assert "must be 2D" in str(exc_info.value) or "3D" in str(exc_info.value)
|
||||
|
||||
def test_hidden_size_validation_correct_size(self):
|
||||
"""Embeddings with correct hidden size should be accepted."""
|
||||
expected_hidden_size = 768
|
||||
valid_tensor = torch.randn(10, expected_hidden_size, dtype=torch.float32)
|
||||
|
||||
# Should not raise
|
||||
items = ImageEmbeddingItems(
|
||||
valid_tensor, expected_hidden_size=expected_hidden_size
|
||||
)
|
||||
assert items.get_count() == 10
|
||||
|
||||
def test_hidden_size_validation_wrong_size_rejected(self):
|
||||
"""Embeddings with wrong hidden size should be rejected."""
|
||||
expected_hidden_size = 768
|
||||
wrong_hidden_size = 4096
|
||||
invalid_tensor = torch.randn(10, wrong_hidden_size, dtype=torch.float32)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
ImageEmbeddingItems(
|
||||
invalid_tensor, expected_hidden_size=expected_hidden_size
|
||||
)
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "hidden dimension mismatch" in error_msg.lower()
|
||||
assert str(wrong_hidden_size) in error_msg
|
||||
assert str(expected_hidden_size) in error_msg
|
||||
|
||||
|
||||
class TestAudioEmbedBasicValidation:
|
||||
"""Test basic ndim validation in audio embeddings via AudioEmbeddingItems."""
|
||||
|
||||
def test_valid_2d_tensor_accepted(self):
|
||||
"""Baseline: 2D tensors should be accepted."""
|
||||
valid_tensor = torch.randn(10, 768, dtype=torch.float32)
|
||||
|
||||
# Should not raise - 2D is valid
|
||||
items = AudioEmbeddingItems(valid_tensor)
|
||||
assert items.get_count() == 10
|
||||
|
||||
def test_valid_3d_tensor_accepted(self):
|
||||
"""Baseline: 3D tensors should be accepted."""
|
||||
valid_tensor = torch.randn(2, 10, 768, dtype=torch.float32)
|
||||
|
||||
# Should not raise - 3D is valid
|
||||
items = AudioEmbeddingItems(valid_tensor)
|
||||
assert items.get_count() == 2
|
||||
|
||||
def test_valid_list_of_2d_tensors_accepted(self):
|
||||
"""Baseline: List of 2D tensors should be accepted."""
|
||||
tensors = [
|
||||
torch.randn(10, 768, dtype=torch.float32),
|
||||
torch.randn(15, 768, dtype=torch.float32),
|
||||
]
|
||||
|
||||
# Should not raise
|
||||
items = AudioEmbeddingItems(tensors)
|
||||
assert items.get_count() == 2
|
||||
|
||||
def test_1d_tensor_rejected(self):
|
||||
"""Security: 1D tensors should be rejected (invalid ndim)."""
|
||||
invalid_tensor = torch.randn(768, dtype=torch.float32) # 1D
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
AudioEmbeddingItems(invalid_tensor)
|
||||
|
||||
assert "must be 2D" in str(exc_info.value) or "3D" in str(exc_info.value)
|
||||
|
||||
def test_scalar_rejected(self):
|
||||
"""Security: Scalar tensors should be rejected."""
|
||||
invalid_tensor = torch.tensor(1.0) # 0D (scalar)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
AudioEmbeddingItems(invalid_tensor)
|
||||
|
||||
def test_hidden_size_validation_correct_size(self):
|
||||
"""Embeddings with correct hidden size should be accepted."""
|
||||
expected_hidden_size = 768
|
||||
valid_tensor = torch.randn(10, expected_hidden_size, dtype=torch.float32)
|
||||
|
||||
# Should not raise
|
||||
items = AudioEmbeddingItems(
|
||||
valid_tensor, expected_hidden_size=expected_hidden_size
|
||||
)
|
||||
assert items.get_count() == 10
|
||||
|
||||
def test_hidden_size_validation_wrong_size_rejected(self):
|
||||
"""Embeddings with wrong hidden size should be rejected."""
|
||||
expected_hidden_size = 768
|
||||
wrong_hidden_size = 4096
|
||||
invalid_tensor = torch.randn(10, wrong_hidden_size, dtype=torch.float32)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
AudioEmbeddingItems(
|
||||
invalid_tensor, expected_hidden_size=expected_hidden_size
|
||||
)
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "hidden dimension mismatch" in error_msg.lower()
|
||||
assert str(wrong_hidden_size) in error_msg
|
||||
assert str(expected_hidden_size) in error_msg
|
||||
|
||||
|
||||
class TestShapeValidationDoSPrevention:
|
||||
"""
|
||||
Tests for DoS prevention through shape validation.
|
||||
|
||||
Verifies that embeddings with incorrect shapes are rejected early,
|
||||
preventing crashes during model inference.
|
||||
"""
|
||||
|
||||
def test_prevent_crash_from_wrong_shape_image_embeds(self):
|
||||
"""
|
||||
Prevent crash scenario: wrong hidden size in image embeddings.
|
||||
|
||||
Without validation, this would pass initial checks but crash later
|
||||
during model forward pass when dimensions don't match.
|
||||
"""
|
||||
expected_hidden_size = 768 # Typical model hidden size
|
||||
wrong_hidden_size = 4096 # Wrong size (e.g., Llama-sized)
|
||||
|
||||
wrong_embedding = torch.randn(100, wrong_hidden_size, dtype=torch.float32)
|
||||
|
||||
# Should be rejected at instantiation time, not during inference
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
ImageEmbeddingItems(
|
||||
wrong_embedding, expected_hidden_size=expected_hidden_size
|
||||
)
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "hidden dimension mismatch" in error_msg.lower()
|
||||
assert str(expected_hidden_size) in error_msg # Expected
|
||||
assert str(wrong_hidden_size) in error_msg # Received
|
||||
|
||||
def test_prevent_crash_from_wrong_shape_audio_embeds(self):
|
||||
"""
|
||||
Prevent crash scenario: wrong hidden size in audio embeddings.
|
||||
"""
|
||||
expected_hidden_size = 768
|
||||
wrong_hidden_size = 4096
|
||||
|
||||
wrong_embedding = torch.randn(100, wrong_hidden_size, dtype=torch.float32)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
AudioEmbeddingItems(
|
||||
wrong_embedding, expected_hidden_size=expected_hidden_size
|
||||
)
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "hidden dimension mismatch" in error_msg.lower()
|
||||
|
||||
def test_extremely_large_hidden_size_rejected(self):
|
||||
"""Security: Prevent DoS from extremely large embeddings."""
|
||||
expected_hidden_size = 768
|
||||
huge_hidden_size = 100000 # Large but not extreme to avoid test OOM
|
||||
|
||||
invalid_tensor = torch.randn(10, huge_hidden_size, dtype=torch.float32)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
ImageEmbeddingItems(
|
||||
invalid_tensor, expected_hidden_size=expected_hidden_size
|
||||
)
|
||||
|
||||
assert "hidden dimension mismatch" in str(exc_info.value).lower()
|
||||
|
||||
def test_batch_with_mixed_hidden_sizes_rejected(self):
|
||||
"""All embeddings in a list must have the same hidden size."""
|
||||
expected_hidden_size = 768
|
||||
|
||||
# One correct, one wrong
|
||||
batch = [
|
||||
torch.randn(10, expected_hidden_size, dtype=torch.float32),
|
||||
torch.randn(10, expected_hidden_size + 100, dtype=torch.float32), # Wrong!
|
||||
]
|
||||
|
||||
# Should fail on the second one
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
ImageEmbeddingItems(batch, expected_hidden_size=expected_hidden_size)
|
||||
|
||||
assert "hidden dimension mismatch" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
111
third_party/vllm/tests/multimodal/test_hasher.py
vendored
Normal file
111
third_party/vllm/tests/multimodal/test_hasher.py
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from vllm.multimodal.hasher import MultiModalHasher
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
ASSETS_DIR = Path(__file__).parent / "assets"
|
||||
assert ASSETS_DIR.exists()
|
||||
|
||||
|
||||
def test_hash_single_item_different_shape():
|
||||
x1 = torch.zeros(())
|
||||
x2 = torch.zeros((1,))
|
||||
|
||||
hasher = MultiModalHasher
|
||||
assert hasher.hash_kwargs(x=x1) != hasher.hash_kwargs(x=x2)
|
||||
|
||||
|
||||
def test_hash_key_order_invariant():
|
||||
x = torch.zeros((5, 10))
|
||||
y = torch.ones((5, 10))
|
||||
|
||||
hasher = MultiModalHasher
|
||||
assert hasher.hash_kwargs(x=x, y=y) == hasher.hash_kwargs(y=y, x=x)
|
||||
|
||||
|
||||
# NOTE: Images that are the same visually are allowed to have the same hash
|
||||
@pytest.mark.parametrize("mode_pair", [("1", "L"), ("RGBA", "CMYK")])
|
||||
def test_hash_collision_image_mode(mode_pair):
|
||||
mode1, mode2 = mode_pair
|
||||
image1 = Image.new(mode1, size=(10, 10), color=1)
|
||||
image2 = Image.new(mode2, size=(10, 10), color=1)
|
||||
|
||||
hasher = MultiModalHasher
|
||||
assert hasher.hash_kwargs(image=image1) != hasher.hash_kwargs(image=image2)
|
||||
|
||||
|
||||
def test_hash_collision_image_palette():
|
||||
# These images differ only in Image.palette._palette
|
||||
image1 = Image.open(ASSETS_DIR / "image1.png")
|
||||
image2 = Image.open(ASSETS_DIR / "image2.png")
|
||||
|
||||
hasher = MultiModalHasher
|
||||
assert hasher.hash_kwargs(image=image1) != hasher.hash_kwargs(image=image2)
|
||||
|
||||
|
||||
def test_hash_collision_image_transpose():
|
||||
image1 = Image.new("1", size=(10, 20))
|
||||
ImageDraw.Draw(image1).line([(0, 0), (10, 0)])
|
||||
|
||||
image2 = Image.new("1", size=(20, 10))
|
||||
ImageDraw.Draw(image2).line([(0, 0), (0, 10)])
|
||||
|
||||
hasher = MultiModalHasher
|
||||
assert hasher.hash_kwargs(image=image1) != hasher.hash_kwargs(image=image2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
|
||||
def test_hash_collision_tensor_shape(dtype):
|
||||
# The hash should be different though the data is the same when flattened
|
||||
arr1 = torch.zeros((5, 10, 20, 3), dtype=dtype)
|
||||
arr2 = torch.zeros((10, 20, 5, 3), dtype=dtype)
|
||||
|
||||
hasher = MultiModalHasher
|
||||
assert hasher.hash_kwargs(data=arr1) != hasher.hash_kwargs(data=arr2)
|
||||
|
||||
|
||||
def test_hash_collision_array_shape():
|
||||
# The hash should be different though the data is the same when flattened
|
||||
arr1 = np.zeros((5, 10, 20, 3))
|
||||
arr2 = np.zeros((10, 20, 5, 3))
|
||||
|
||||
hasher = MultiModalHasher
|
||||
assert hasher.hash_kwargs(data=arr1) != hasher.hash_kwargs(data=arr2)
|
||||
|
||||
|
||||
def test_hash_non_contiguous_array():
|
||||
arr = np.arange(24).reshape(4, 6).T
|
||||
assert not arr.flags.c_contiguous
|
||||
|
||||
arr_c = np.ascontiguousarray(arr)
|
||||
assert arr_c.flags.c_contiguous
|
||||
|
||||
hasher = MultiModalHasher
|
||||
# Both should be hashable and produce the same hashes
|
||||
assert hasher.hash_kwargs(data=arr) == hasher.hash_kwargs(data=arr_c)
|
||||
|
||||
|
||||
def test_hash_image_exif_id():
|
||||
# Test that EXIF ImageId tag can be used to store UUID
|
||||
# and the hasher will use that instead of the image data.
|
||||
image1 = image2 = Image.new("1", size=(10, 20))
|
||||
id = uuid.uuid4()
|
||||
image1.getexif()[Image.ExifTags.Base.ImageID] = id
|
||||
image2 = Image.open(ASSETS_DIR / "image1.png")
|
||||
image2.getexif()[Image.ExifTags.Base.ImageID] = "Not a UUID"
|
||||
image2a = Image.open(ASSETS_DIR / "image1.png")
|
||||
|
||||
hasher = MultiModalHasher
|
||||
# first image has UUID in ImageID, so it should hash to that UUID
|
||||
assert hasher.hash_kwargs(image=image1) == hasher.hash_kwargs(image=id.bytes)
|
||||
# second image has non-UUID in ImageID, so it should hash to the image data
|
||||
assert hasher.hash_kwargs(image=image2) == hasher.hash_kwargs(image=image2a)
|
||||
40
third_party/vllm/tests/multimodal/test_image.py
vendored
Normal file
40
third_party/vllm/tests/multimodal/test_image.py
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
from vllm.multimodal.image import convert_image_mode
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
ASSETS_DIR = Path(__file__).parent / "assets"
|
||||
assert ASSETS_DIR.exists()
|
||||
|
||||
|
||||
def test_rgb_to_rgb():
|
||||
# Start with an RGB image.
|
||||
original_image = Image.open(ASSETS_DIR / "image1.png").convert("RGB")
|
||||
converted_image = convert_image_mode(original_image, "RGB")
|
||||
|
||||
# RGB to RGB should be a no-op.
|
||||
diff = ImageChops.difference(original_image, converted_image)
|
||||
assert diff.getbbox() is None
|
||||
|
||||
|
||||
def test_rgba_to_rgb():
|
||||
original_image = Image.open(ASSETS_DIR / "rgba.png")
|
||||
original_image_numpy = np.array(original_image)
|
||||
|
||||
converted_image = convert_image_mode(original_image, "RGB")
|
||||
converted_image_numpy = np.array(converted_image)
|
||||
|
||||
for i in range(original_image_numpy.shape[0]):
|
||||
for j in range(original_image_numpy.shape[1]):
|
||||
# Verify that all transparent pixels are converted to white.
|
||||
if original_image_numpy[i][j][3] == 0:
|
||||
assert converted_image_numpy[i][j][0] == 255
|
||||
assert converted_image_numpy[i][j][1] == 255
|
||||
assert converted_image_numpy[i][j][2] == 255
|
||||
46
third_party/vllm/tests/multimodal/test_inputs.py
vendored
Normal file
46
third_party/vllm/tests/multimodal/test_inputs.py
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm.multimodal.inputs import PlaceholderRange
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"is_embed,expected",
|
||||
[
|
||||
(None, 5),
|
||||
(torch.tensor([True, True, True, True, True]), 5),
|
||||
(torch.tensor([False, False, False, False, False]), 0),
|
||||
(torch.tensor([True, False, True, False, True]), 3),
|
||||
(torch.tensor([True]), 1),
|
||||
],
|
||||
)
|
||||
def test_placeholder_range_get_num_embeds(is_embed, expected):
|
||||
length = len(is_embed) if is_embed is not None else 5
|
||||
pr = PlaceholderRange(offset=0, length=length, is_embed=is_embed)
|
||||
assert pr.get_num_embeds() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"is_embed,expected",
|
||||
[
|
||||
(None, None),
|
||||
(
|
||||
torch.tensor([False, True, False, True, True]),
|
||||
torch.tensor([0, 1, 1, 2, 3]),
|
||||
),
|
||||
(torch.tensor([True, True, True]), torch.tensor([1, 2, 3])),
|
||||
],
|
||||
)
|
||||
def test_placeholder_range_embeds_cumsum(is_embed, expected):
|
||||
length = len(is_embed) if is_embed is not None else 5
|
||||
pr = PlaceholderRange(offset=0, length=length, is_embed=is_embed)
|
||||
|
||||
if expected is None:
|
||||
assert pr.embeds_cumsum is None
|
||||
return
|
||||
|
||||
assert torch.equal(pr.embeds_cumsum, expected)
|
||||
# cached_property should return the same object on repeated access
|
||||
assert pr.embeds_cumsum is pr.embeds_cumsum
|
||||
1094
third_party/vllm/tests/multimodal/test_processing.py
vendored
Normal file
1094
third_party/vllm/tests/multimodal/test_processing.py
vendored
Normal file
File diff suppressed because it is too large
Load Diff
34
third_party/vllm/tests/multimodal/test_registry.py
vendored
Normal file
34
third_party/vllm/tests/multimodal/test_registry.py
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""
|
||||
Unit tests for MultiModalRegistry.supports_multimodal_inputs and
|
||||
Qwen2.5-VL visual component loading behavior.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.multimodal import MULTIMODAL_REGISTRY
|
||||
|
||||
from ..models.utils import build_model_context
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id,limit_mm_per_prompt,expected",
|
||||
[
|
||||
("Qwen/Qwen2-0.5B-Instruct", {}, False),
|
||||
("Qwen/Qwen2.5-VL-3B-Instruct", {}, True),
|
||||
("Qwen/Qwen2.5-VL-3B-Instruct", {"image": 0, "video": 0}, False),
|
||||
("Qwen/Qwen2.5-VL-3B-Instruct", {"image": 0}, True),
|
||||
],
|
||||
)
|
||||
@pytest.mark.core_model
|
||||
def test_supports_multimodal_inputs(model_id, limit_mm_per_prompt, expected):
|
||||
"""Test supports_multimodal_inputs returns correct boolean for various
|
||||
configs."""
|
||||
ctx = build_model_context(
|
||||
model_id,
|
||||
limit_mm_per_prompt=limit_mm_per_prompt,
|
||||
)
|
||||
assert MULTIMODAL_REGISTRY.supports_multimodal_inputs(ctx.model_config) is expected
|
||||
134
third_party/vllm/tests/multimodal/test_sparse_tensor_validation_unit.py
vendored
Normal file
134
third_party/vllm/tests/multimodal/test_sparse_tensor_validation_unit.py
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""
|
||||
Unit tests for sparse tensor validation.
|
||||
|
||||
Simple, fast unit tests that can run without server fixtures.
|
||||
Run with: pytest tests/multimodal/test_sparse_tensor_validation_unit.py -v
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
|
||||
class TestSparseTensorValidationContextManager:
|
||||
"""Test that torch.sparse.check_sparse_tensor_invariants() works as expected."""
|
||||
|
||||
def test_valid_sparse_tensor_passes(self):
|
||||
"""Valid sparse tensors should pass validation."""
|
||||
indices = torch.tensor([[0, 1], [0, 1]])
|
||||
values = torch.tensor([1.0, 2.0])
|
||||
shape = (2, 2)
|
||||
|
||||
with torch.sparse.check_sparse_tensor_invariants():
|
||||
tensor = torch.sparse_coo_tensor(indices, values, shape)
|
||||
dense = tensor.to_dense()
|
||||
|
||||
assert dense.shape == shape
|
||||
|
||||
def test_out_of_bounds_indices_rejected(self):
|
||||
"""Sparse tensors with out-of-bounds indices should be rejected."""
|
||||
indices = torch.tensor([[5], [5]]) # Out of bounds for 2x2
|
||||
values = torch.tensor([1.0])
|
||||
shape = (2, 2)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info: # noqa: SIM117
|
||||
with torch.sparse.check_sparse_tensor_invariants():
|
||||
tensor = torch.sparse_coo_tensor(indices, values, shape)
|
||||
tensor.to_dense()
|
||||
|
||||
assert (
|
||||
"index" in str(exc_info.value).lower()
|
||||
or "bound" in str(exc_info.value).lower()
|
||||
)
|
||||
|
||||
def test_negative_indices_rejected(self):
|
||||
"""Sparse tensors with negative indices should be rejected."""
|
||||
indices = torch.tensor([[-1], [0]])
|
||||
values = torch.tensor([1.0])
|
||||
shape = (2, 2)
|
||||
|
||||
with pytest.raises(RuntimeError): # noqa: SIM117
|
||||
with torch.sparse.check_sparse_tensor_invariants():
|
||||
tensor = torch.sparse_coo_tensor(indices, values, shape)
|
||||
tensor.to_dense()
|
||||
|
||||
def test_without_context_manager_allows_invalid(self):
|
||||
"""
|
||||
WITHOUT validation, invalid tensors may not immediately error.
|
||||
|
||||
This demonstrates the vulnerability: PyTorch 2.8.0+ doesn't validate
|
||||
by default, which can lead to memory corruption.
|
||||
"""
|
||||
indices = torch.tensor([[100], [100]]) # Way out of bounds
|
||||
values = torch.tensor([1.0])
|
||||
shape = (2, 2)
|
||||
|
||||
# Without validation context, this might create an invalid tensor
|
||||
# (actual behavior depends on PyTorch version)
|
||||
tensor = torch.sparse_coo_tensor(indices, values, shape)
|
||||
|
||||
# The tensor object is created, but it's invalid
|
||||
assert tensor.is_sparse
|
||||
|
||||
|
||||
class TestTorchLoadWithValidation:
|
||||
"""Test torch.load() with sparse tensor validation."""
|
||||
|
||||
def test_load_valid_sparse_tensor_with_validation(self):
|
||||
"""Valid sparse tensors should load successfully with validation."""
|
||||
# Create and save a valid sparse tensor
|
||||
indices = torch.tensor([[0, 1], [0, 1]])
|
||||
values = torch.tensor([1.0, 2.0])
|
||||
tensor = torch.sparse_coo_tensor(indices, values, (2, 2))
|
||||
|
||||
buffer = io.BytesIO()
|
||||
torch.save(tensor, buffer)
|
||||
buffer.seek(0)
|
||||
|
||||
# Load with validation
|
||||
with torch.sparse.check_sparse_tensor_invariants():
|
||||
loaded = torch.load(buffer, weights_only=True)
|
||||
dense = loaded.to_dense()
|
||||
|
||||
assert dense.shape == (2, 2)
|
||||
|
||||
def test_load_invalid_sparse_tensor_rejected(self):
|
||||
"""Invalid sparse tensors should be caught when loaded with validation."""
|
||||
# Create an invalid sparse tensor (out of bounds)
|
||||
indices = torch.tensor([[10], [10]])
|
||||
values = torch.tensor([1.0])
|
||||
tensor = torch.sparse_coo_tensor(indices, values, (2, 2))
|
||||
|
||||
buffer = io.BytesIO()
|
||||
torch.save(tensor, buffer)
|
||||
buffer.seek(0)
|
||||
|
||||
# Load with validation - should fail on to_dense()
|
||||
with pytest.raises(RuntimeError): # noqa: SIM117
|
||||
with torch.sparse.check_sparse_tensor_invariants():
|
||||
loaded = torch.load(buffer, weights_only=True)
|
||||
loaded.to_dense()
|
||||
|
||||
def test_load_dense_tensor_unaffected(self):
|
||||
"""Dense tensors should work normally with the validation context."""
|
||||
# Create and save a dense tensor
|
||||
tensor = torch.randn(10, 20)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
torch.save(tensor, buffer)
|
||||
buffer.seek(0)
|
||||
|
||||
# Load with validation (should have no effect on dense tensors)
|
||||
with torch.sparse.check_sparse_tensor_invariants():
|
||||
loaded = torch.load(buffer, weights_only=True)
|
||||
|
||||
assert loaded.shape == (10, 20)
|
||||
assert not loaded.is_sparse
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Allow running directly for quick testing
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
218
third_party/vllm/tests/multimodal/test_utils.py
vendored
Normal file
218
third_party/vllm/tests/multimodal/test_utils.py
vendored
Normal file
@@ -0,0 +1,218 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm.multimodal.inputs import (
|
||||
MultiModalBatchedField,
|
||||
MultiModalFieldElem,
|
||||
MultiModalKwargsItem,
|
||||
MultiModalSharedField,
|
||||
PlaceholderRange,
|
||||
)
|
||||
from vllm.multimodal.utils import argsort_mm_positions, group_and_batch_mm_items
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
# Single modality
|
||||
## Internally sorted
|
||||
dict(
|
||||
mm_positions={
|
||||
"image": [
|
||||
PlaceholderRange(offset=0, length=2),
|
||||
PlaceholderRange(offset=3, length=2),
|
||||
]
|
||||
},
|
||||
expected_modality_idxs=[
|
||||
("image", 0),
|
||||
("image", 1),
|
||||
],
|
||||
),
|
||||
## Internally unsorted
|
||||
dict(
|
||||
mm_positions={
|
||||
"image": [
|
||||
PlaceholderRange(offset=3, length=2),
|
||||
PlaceholderRange(offset=0, length=2),
|
||||
]
|
||||
},
|
||||
expected_modality_idxs=[
|
||||
("image", 1),
|
||||
("image", 0),
|
||||
],
|
||||
),
|
||||
# Two modalities
|
||||
## Internally sorted
|
||||
dict(
|
||||
mm_positions={
|
||||
"image": [
|
||||
PlaceholderRange(offset=7, length=4),
|
||||
PlaceholderRange(offset=11, length=5),
|
||||
],
|
||||
"audio": [
|
||||
PlaceholderRange(offset=0, length=2),
|
||||
PlaceholderRange(offset=2, length=3),
|
||||
],
|
||||
},
|
||||
expected_modality_idxs=[
|
||||
("audio", 0),
|
||||
("audio", 1),
|
||||
("image", 0),
|
||||
("image", 1),
|
||||
],
|
||||
),
|
||||
## Interleaved, internally sorted
|
||||
dict(
|
||||
mm_positions={
|
||||
"image": [
|
||||
PlaceholderRange(offset=0, length=4),
|
||||
PlaceholderRange(offset=8, length=2),
|
||||
],
|
||||
"audio": [
|
||||
PlaceholderRange(offset=5, length=2),
|
||||
PlaceholderRange(offset=11, length=4),
|
||||
],
|
||||
},
|
||||
expected_modality_idxs=[
|
||||
("image", 0),
|
||||
("audio", 0),
|
||||
("image", 1),
|
||||
("audio", 1),
|
||||
],
|
||||
),
|
||||
## Interleaved, internally unsorted
|
||||
dict(
|
||||
mm_positions={
|
||||
"image": [
|
||||
PlaceholderRange(offset=8, length=2),
|
||||
PlaceholderRange(offset=0, length=4),
|
||||
],
|
||||
"audio": [
|
||||
PlaceholderRange(offset=11, length=4),
|
||||
PlaceholderRange(offset=5, length=2),
|
||||
],
|
||||
},
|
||||
expected_modality_idxs=[
|
||||
("image", 1),
|
||||
("audio", 1),
|
||||
("image", 0),
|
||||
("audio", 0),
|
||||
],
|
||||
),
|
||||
# Three modalities
|
||||
## Internally sorted
|
||||
dict(
|
||||
mm_positions={
|
||||
"image": [
|
||||
PlaceholderRange(offset=15, length=7),
|
||||
PlaceholderRange(offset=22, length=8),
|
||||
],
|
||||
"audio": [
|
||||
PlaceholderRange(offset=0, length=2),
|
||||
],
|
||||
"video": [
|
||||
PlaceholderRange(offset=3, length=4),
|
||||
PlaceholderRange(offset=7, length=5),
|
||||
PlaceholderRange(offset=12, length=6),
|
||||
],
|
||||
},
|
||||
expected_modality_idxs=[
|
||||
("audio", 0),
|
||||
("video", 0),
|
||||
("video", 1),
|
||||
("video", 2),
|
||||
("image", 0),
|
||||
("image", 1),
|
||||
],
|
||||
),
|
||||
## Interleaved, internally sorted
|
||||
dict(
|
||||
mm_positions={
|
||||
"image": [
|
||||
PlaceholderRange(offset=0, length=2),
|
||||
PlaceholderRange(offset=2, length=3),
|
||||
PlaceholderRange(offset=20, length=4),
|
||||
],
|
||||
"audio": [
|
||||
PlaceholderRange(offset=5, length=2),
|
||||
],
|
||||
"video": [
|
||||
PlaceholderRange(offset=8, length=5),
|
||||
],
|
||||
},
|
||||
expected_modality_idxs=[
|
||||
("image", 0),
|
||||
("image", 1),
|
||||
("audio", 0),
|
||||
("video", 0),
|
||||
("image", 2),
|
||||
],
|
||||
),
|
||||
## Interleaved, internally unsorted
|
||||
dict(
|
||||
mm_positions={
|
||||
"image": [
|
||||
PlaceholderRange(offset=0, length=2),
|
||||
PlaceholderRange(offset=20, length=4),
|
||||
PlaceholderRange(offset=2, length=3),
|
||||
],
|
||||
"audio": [
|
||||
PlaceholderRange(offset=5, length=2),
|
||||
],
|
||||
"video": [
|
||||
PlaceholderRange(offset=8, length=5),
|
||||
],
|
||||
},
|
||||
expected_modality_idxs=[
|
||||
("image", 0),
|
||||
("image", 2),
|
||||
("audio", 0),
|
||||
("video", 0),
|
||||
("image", 1),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_argsort_mm_positions(case):
|
||||
mm_positions = case["mm_positions"]
|
||||
expected_modality_idxs = case["expected_modality_idxs"]
|
||||
|
||||
modality_idxs = argsort_mm_positions(mm_positions)
|
||||
|
||||
assert modality_idxs == expected_modality_idxs
|
||||
|
||||
|
||||
def test_group_and_batch_mm_items_split_by_fieldset():
|
||||
elem = MultiModalFieldElem(
|
||||
data=torch.empty(1, dtype=torch.uint8),
|
||||
field=MultiModalBatchedField(),
|
||||
)
|
||||
item1 = MultiModalKwargsItem({"x": elem, "y": elem})
|
||||
item2 = MultiModalKwargsItem({"y": elem, "x": elem})
|
||||
item3 = MultiModalKwargsItem({"x": elem, "y": elem, "z": elem})
|
||||
item4 = MultiModalKwargsItem({"x": elem})
|
||||
item5 = MultiModalKwargsItem({"x": elem, "y": elem})
|
||||
|
||||
res = group_and_batch_mm_items([item1, item2, item3, item4, item5])
|
||||
assert [num_items for num_items, _ in res] == [2, 1, 1, 1]
|
||||
|
||||
|
||||
def test_group_and_batch_mm_items_split_by_shared_data():
|
||||
elem1 = MultiModalFieldElem(
|
||||
data=torch.zeros(1, dtype=torch.uint8),
|
||||
field=MultiModalSharedField(batch_size=1),
|
||||
)
|
||||
elem2 = MultiModalFieldElem(
|
||||
data=torch.zeros(2, dtype=torch.uint8),
|
||||
field=MultiModalSharedField(batch_size=1),
|
||||
)
|
||||
item1 = MultiModalKwargsItem({"x": elem1})
|
||||
item2 = MultiModalKwargsItem({"x": elem1})
|
||||
item3 = MultiModalKwargsItem({"x": elem2})
|
||||
item4 = MultiModalKwargsItem({"x": elem1})
|
||||
item5 = MultiModalKwargsItem({"x": elem2})
|
||||
|
||||
res = group_and_batch_mm_items([item1, item2, item3, item4, item5])
|
||||
assert [num_items for num_items, _ in res] == [2, 1, 1, 1]
|
||||
372
third_party/vllm/tests/multimodal/test_video.py
vendored
Normal file
372
third_party/vllm/tests/multimodal/test_video.py
vendored
Normal file
@@ -0,0 +1,372 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import pytest
|
||||
|
||||
from vllm.assets.base import get_vllm_public_assets
|
||||
from vllm.multimodal.video import (
|
||||
VIDEO_LOADER_REGISTRY,
|
||||
VideoLoader,
|
||||
)
|
||||
|
||||
from .utils import create_video_from_image
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
ASSETS_DIR = Path(__file__).parent / "assets"
|
||||
assert ASSETS_DIR.exists()
|
||||
|
||||
NUM_FRAMES = 10
|
||||
FAKE_OUTPUT_1 = np.random.rand(NUM_FRAMES, 1280, 720, 3)
|
||||
FAKE_OUTPUT_2 = np.random.rand(NUM_FRAMES, 1280, 720, 3)
|
||||
|
||||
|
||||
@VIDEO_LOADER_REGISTRY.register("test_video_loader_1")
|
||||
class TestVideoLoader1(VideoLoader):
|
||||
@classmethod
|
||||
def load_bytes(cls, data: bytes, num_frames: int = -1) -> npt.NDArray:
|
||||
return FAKE_OUTPUT_1
|
||||
|
||||
|
||||
@VIDEO_LOADER_REGISTRY.register("test_video_loader_2")
|
||||
class TestVideoLoader2(VideoLoader):
|
||||
@classmethod
|
||||
def load_bytes(cls, data: bytes, num_frames: int = -1) -> npt.NDArray:
|
||||
return FAKE_OUTPUT_2
|
||||
|
||||
|
||||
def test_video_loader_registry():
|
||||
custom_loader_1 = VIDEO_LOADER_REGISTRY.load("test_video_loader_1")
|
||||
output_1 = custom_loader_1.load_bytes(b"test")
|
||||
np.testing.assert_array_equal(output_1, FAKE_OUTPUT_1)
|
||||
|
||||
custom_loader_2 = VIDEO_LOADER_REGISTRY.load("test_video_loader_2")
|
||||
output_2 = custom_loader_2.load_bytes(b"test")
|
||||
np.testing.assert_array_equal(output_2, FAKE_OUTPUT_2)
|
||||
|
||||
|
||||
def test_video_loader_type_doesnt_exist():
|
||||
with pytest.raises(AssertionError):
|
||||
VIDEO_LOADER_REGISTRY.load("non_existing_video_loader")
|
||||
|
||||
|
||||
def test_video_backend_handles_broken_frames(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Regression test for handling videos with broken frames.
|
||||
This test uses a pre-corrupted video file (assets/corrupted.mp4) that
|
||||
contains broken frames to verify the video loader handles
|
||||
them gracefully without crashing and returns accurate metadata.
|
||||
"""
|
||||
with monkeypatch.context() as m:
|
||||
m.setenv("VLLM_VIDEO_LOADER_BACKEND", "opencv")
|
||||
|
||||
# Load the pre-corrupted video file that contains broken frames
|
||||
corrupted_video_path = ASSETS_DIR / "corrupted.mp4"
|
||||
|
||||
with open(corrupted_video_path, "rb") as f:
|
||||
video_data = f.read()
|
||||
|
||||
loader = VIDEO_LOADER_REGISTRY.load("opencv")
|
||||
frames, metadata = loader.load_bytes(video_data, num_frames=-1)
|
||||
|
||||
# Verify metadata consistency:
|
||||
# frames_indices must match actual loaded frames
|
||||
assert frames.shape[0] == len(metadata["frames_indices"]), (
|
||||
f"Frames array size must equal frames_indices length. "
|
||||
f"Got {frames.shape[0]} frames but "
|
||||
f"{len(metadata['frames_indices'])} indices"
|
||||
)
|
||||
|
||||
# Verify that broken frames were skipped:
|
||||
# loaded frames should be less than total
|
||||
assert frames.shape[0] < metadata["total_num_frames"], (
|
||||
f"Should load fewer frames than total due to broken frames. "
|
||||
f"Expected fewer than {metadata['total_num_frames']} frames, "
|
||||
f"but loaded {frames.shape[0]} frames"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Frame Recovery Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_video_recovery_simulated_failures(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that frame recovery correctly uses the next valid frame when
|
||||
target frames fail to load.
|
||||
|
||||
Uses corrupted.mp4 and mocks VideoCapture.grab() to fail on specific
|
||||
frame indices (in addition to the real corruption at frame 17), then
|
||||
verifies recovery produces more frames.
|
||||
"""
|
||||
import cv2
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
m.setenv("VLLM_VIDEO_LOADER_BACKEND", "opencv")
|
||||
|
||||
# Load corrupted.mp4 (26 frames, frame 17 is genuinely corrupted)
|
||||
video_path = ASSETS_DIR / "corrupted.mp4"
|
||||
with open(video_path, "rb") as f:
|
||||
video_data = f.read()
|
||||
|
||||
# Simulate additional failures on frames 3 and 10
|
||||
# (in addition to the real corruption at frame 17)
|
||||
fail_on_frames = {3, 10}
|
||||
|
||||
# Store original VideoCapture class
|
||||
original_video_capture = cv2.VideoCapture
|
||||
|
||||
class MockVideoCapture:
|
||||
"""Wrapper that simulates grab() failures on specific frames."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._cap = original_video_capture(*args, **kwargs)
|
||||
self._current_frame = -1
|
||||
|
||||
def grab(self):
|
||||
self._current_frame += 1
|
||||
if self._current_frame in fail_on_frames:
|
||||
return False # Simulate failure
|
||||
return self._cap.grab()
|
||||
|
||||
def retrieve(self):
|
||||
return self._cap.retrieve()
|
||||
|
||||
def get(self, prop):
|
||||
return self._cap.get(prop)
|
||||
|
||||
def isOpened(self):
|
||||
return self._cap.isOpened()
|
||||
|
||||
def release(self):
|
||||
return self._cap.release()
|
||||
|
||||
# Patch cv2.VideoCapture
|
||||
m.setattr(cv2, "VideoCapture", MockVideoCapture)
|
||||
|
||||
loader = VIDEO_LOADER_REGISTRY.load("opencv")
|
||||
|
||||
# Use num_frames=8 which samples: [0, 3, 7, 10, 14, 17, 21, 25]
|
||||
# Frame 3: mocked failure, recovery window [3, 7) -> use frame 4
|
||||
# Frame 10: mocked failure, recovery window [10, 14) -> use frame 11
|
||||
# Frame 17: real corruption, recovery window [17, 21) -> use frame 18
|
||||
|
||||
# Test WITHOUT recovery - should have fewer frames due to failures
|
||||
frames_no_recovery, meta_no = loader.load_bytes(
|
||||
video_data, num_frames=8, frame_recovery=False
|
||||
)
|
||||
|
||||
# Test WITH recovery - should recover using next valid frames
|
||||
frames_with_recovery, meta_yes = loader.load_bytes(
|
||||
video_data, num_frames=8, frame_recovery=True
|
||||
)
|
||||
|
||||
# With recovery should have MORE frames than without
|
||||
# Without: 5 frames (3, 10, 17 all fail)
|
||||
# With: 8 frames (all recovered)
|
||||
assert frames_with_recovery.shape[0] > frames_no_recovery.shape[0], (
|
||||
f"Recovery should produce more frames. "
|
||||
f"Without: {frames_no_recovery.shape[0]}, "
|
||||
f"With: {frames_with_recovery.shape[0]}"
|
||||
)
|
||||
|
||||
# Verify metadata consistency
|
||||
assert frames_no_recovery.shape[0] == len(meta_no["frames_indices"])
|
||||
assert frames_with_recovery.shape[0] == len(meta_yes["frames_indices"])
|
||||
|
||||
# Verify temporal order is preserved
|
||||
assert meta_yes["frames_indices"] == sorted(meta_yes["frames_indices"])
|
||||
|
||||
|
||||
def test_video_recovery_with_corrupted_file(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test frame recovery with an actual corrupted video file using sparse sampling.
|
||||
|
||||
This test uses corrupted.mp4 which has genuine H.264 codec errors on
|
||||
frame 17. With num_frames=8, the target frames are [0, 3, 7, 10, 14, 17, 21, 25].
|
||||
Frame 17 is corrupted but frames 18-20 are readable, so recovery can use
|
||||
frame 18 to fill in for the failed frame 17.
|
||||
|
||||
This test verifies:
|
||||
1. Without recovery: frame 17 is skipped (7 frames loaded)
|
||||
2. With recovery: frame 18 fills in for frame 17 (8 frames loaded)
|
||||
3. Recovery produces MORE frames than without recovery
|
||||
4. Metadata is consistent with loaded frames
|
||||
"""
|
||||
with monkeypatch.context() as m:
|
||||
m.setenv("VLLM_VIDEO_LOADER_BACKEND", "opencv")
|
||||
|
||||
corrupted_video_path = ASSETS_DIR / "corrupted.mp4"
|
||||
|
||||
with open(corrupted_video_path, "rb") as f:
|
||||
video_data = f.read()
|
||||
|
||||
loader = VIDEO_LOADER_REGISTRY.load("opencv")
|
||||
|
||||
# Use num_frames=8 which makes frame 17 a target with recovery window [17, 21)
|
||||
# Target frames: [0, 3, 7, 10, 14, 17, 21, 25]
|
||||
# Frame 17 is corrupted, but frames 18-20 are readable for recovery
|
||||
|
||||
# Test without recovery - frame 17 will be skipped
|
||||
frames_no_recovery, meta_no_recovery = loader.load_bytes(
|
||||
video_data, num_frames=8, frame_recovery=False
|
||||
)
|
||||
|
||||
# Test with recovery - frame 18 should fill in for frame 17
|
||||
frames_with_recovery, meta_with_recovery = loader.load_bytes(
|
||||
video_data, num_frames=8, frame_recovery=True
|
||||
)
|
||||
|
||||
# Verify metadata consistency for both modes
|
||||
assert frames_no_recovery.shape[0] == len(meta_no_recovery["frames_indices"]), (
|
||||
"Frame count must match indices without recovery"
|
||||
)
|
||||
assert frames_with_recovery.shape[0] == len(
|
||||
meta_with_recovery["frames_indices"]
|
||||
), "Frame count must match indices with recovery"
|
||||
|
||||
# KEY ASSERTION: Recovery should produce MORE frames than without recovery
|
||||
# Without recovery: 7 frames (frame 17 skipped)
|
||||
# With recovery: 8 frames (frame 18 used for frame 17)
|
||||
assert frames_with_recovery.shape[0] > frames_no_recovery.shape[0], (
|
||||
f"Recovery should produce more frames with sparse sampling. "
|
||||
f"Got {frames_with_recovery.shape[0]} with recovery vs "
|
||||
f"{frames_no_recovery.shape[0]} without"
|
||||
)
|
||||
|
||||
# Verify we got all 8 requested frames with recovery
|
||||
assert frames_with_recovery.shape[0] == 8, (
|
||||
f"With recovery, should load all 8 requested frames. "
|
||||
f"Got {frames_with_recovery.shape[0]}"
|
||||
)
|
||||
|
||||
# Verify the video metadata is correct
|
||||
expected_total_frames = 26
|
||||
assert meta_with_recovery["total_num_frames"] == expected_total_frames, (
|
||||
f"Expected {expected_total_frames} total frames in metadata"
|
||||
)
|
||||
|
||||
|
||||
def test_video_recovery_dynamic_backend(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Test that frame_recovery works with the dynamic video backend.
|
||||
|
||||
The dynamic backend samples frames based on fps/duration rather than
|
||||
loading all frames. This test verifies recovery works in that context.
|
||||
"""
|
||||
with monkeypatch.context() as m:
|
||||
m.setenv("VLLM_VIDEO_LOADER_BACKEND", "opencv_dynamic")
|
||||
|
||||
corrupted_video_path = ASSETS_DIR / "corrupted.mp4"
|
||||
|
||||
with open(corrupted_video_path, "rb") as f:
|
||||
video_data = f.read()
|
||||
|
||||
loader = VIDEO_LOADER_REGISTRY.load("opencv_dynamic")
|
||||
|
||||
# Test without recovery
|
||||
frames_no_recovery, meta_no = loader.load_bytes(
|
||||
video_data, fps=2, max_duration=10, frame_recovery=False
|
||||
)
|
||||
|
||||
# Test with frame_recovery enabled
|
||||
frames_with_recovery, meta_with = loader.load_bytes(
|
||||
video_data, fps=2, max_duration=10, frame_recovery=True
|
||||
)
|
||||
|
||||
# Verify basic properties
|
||||
assert frames_no_recovery.shape[0] > 0, (
|
||||
"Should load some frames without recovery"
|
||||
)
|
||||
assert frames_with_recovery.shape[0] > 0, (
|
||||
"Should load some frames with recovery"
|
||||
)
|
||||
assert "do_sample_frames" in meta_with
|
||||
assert meta_with["do_sample_frames"] is False # Dynamic backend always False
|
||||
assert frames_with_recovery.shape[0] == len(meta_with["frames_indices"])
|
||||
|
||||
# Key assertion: recovery should help when corrupted frames are sampled
|
||||
# We expect recovery to produce >= frames than without recovery
|
||||
assert frames_with_recovery.shape[0] >= frames_no_recovery.shape[0], (
|
||||
f"Recovery should produce at least as many frames. "
|
||||
f"Got {frames_with_recovery.shape[0]} with recovery vs "
|
||||
f"{frames_no_recovery.shape[0]} without"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_video_path(tmp_path):
|
||||
image_path = get_vllm_public_assets(
|
||||
filename="stop_sign.jpg", s3_prefix="vision_model_images"
|
||||
)
|
||||
|
||||
video_path = tmp_path / "test_RGB_video.mp4"
|
||||
create_video_from_image(str(image_path), str(video_path), num_frames=1800, fps=30)
|
||||
return video_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"backend, kwargs, expected_num_frames",
|
||||
[
|
||||
# opencv: num_frames directly controls count
|
||||
pytest.param("opencv", {"num_frames": 32}, 32, id="opencv-num_frames"),
|
||||
pytest.param("opencv", {"fps": 2}, 120, id="opencv-fps"),
|
||||
pytest.param(
|
||||
"opencv",
|
||||
{"num_frames": 500, "fps": 2},
|
||||
120,
|
||||
id="opencv-num_frames_wins_fps",
|
||||
),
|
||||
pytest.param(
|
||||
"opencv_dynamic",
|
||||
{"fps": 1, "max_duration": 60},
|
||||
60,
|
||||
id="opencv_dynamic-within_max_duration",
|
||||
),
|
||||
pytest.param(
|
||||
"opencv_dynamic",
|
||||
{"fps": 2, "max_duration": 30},
|
||||
60,
|
||||
id="opencv_dynamic-exceeds_max_duration",
|
||||
),
|
||||
pytest.param(
|
||||
"openpangu", {"num_frames": 32, "fps": -1}, 32, id="openpangu-num_frames"
|
||||
),
|
||||
pytest.param(
|
||||
"molmo2",
|
||||
{"num_frames": 32, "frame_sample_mode": "uniform_last_frame"},
|
||||
32,
|
||||
id="molmo2-uniform_last_frame",
|
||||
),
|
||||
pytest.param(
|
||||
"molmo2",
|
||||
{"fps": 2, "frame_sample_mode": "fps"},
|
||||
119,
|
||||
id="molmo2-fps",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_video_loader_frames_sampling(
|
||||
dummy_video_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
backend: str,
|
||||
kwargs: dict,
|
||||
expected_num_frames: int,
|
||||
):
|
||||
"""Test video loader frames sampling functionality."""
|
||||
monkeypatch.setenv("VLLM_VIDEO_LOADER_BACKEND", backend)
|
||||
loader = VIDEO_LOADER_REGISTRY.load(backend)
|
||||
|
||||
with open(dummy_video_path, "rb") as f:
|
||||
long_video_bytes = f.read()
|
||||
|
||||
frames, _ = loader.load_bytes(long_video_bytes, **kwargs)
|
||||
|
||||
assert frames.ndim == 4
|
||||
assert frames.shape[3] == 3 # RGB
|
||||
assert frames.shape[0] == expected_num_frames
|
||||
78
third_party/vllm/tests/multimodal/utils.py
vendored
Normal file
78
third_party/vllm/tests/multimodal/utils.py
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def random_image(rng: np.random.RandomState, min_wh: int, max_wh: int):
|
||||
w, h = rng.randint(min_wh, max_wh, size=(2,))
|
||||
arr = rng.randint(0, 255, size=(w, h, 3), dtype=np.uint8)
|
||||
return Image.fromarray(arr)
|
||||
|
||||
|
||||
def random_video(
|
||||
rng: np.random.RandomState,
|
||||
min_frames: int,
|
||||
max_frames: int,
|
||||
min_wh: int,
|
||||
max_wh: int,
|
||||
):
|
||||
num_frames = rng.randint(min_frames, max_frames)
|
||||
w, h = rng.randint(min_wh, max_wh, size=(2,))
|
||||
return rng.randint(0, 255, size=(num_frames, w, h, 3), dtype=np.uint8)
|
||||
|
||||
|
||||
def random_audio(
|
||||
rng: np.random.RandomState,
|
||||
min_len: int,
|
||||
max_len: int,
|
||||
sr: int,
|
||||
):
|
||||
audio_len = rng.randint(min_len, max_len)
|
||||
return rng.rand(audio_len), sr
|
||||
|
||||
|
||||
def create_video_from_image(
|
||||
image_path: str,
|
||||
video_path: str,
|
||||
num_frames: int = 10,
|
||||
fps: float = 1.0,
|
||||
is_color: bool = True,
|
||||
fourcc: str = "mp4v",
|
||||
):
|
||||
image = cv2.imread(image_path)
|
||||
if not is_color:
|
||||
# Convert to grayscale if is_color is False
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
height, width = image.shape
|
||||
else:
|
||||
height, width, _ = image.shape
|
||||
|
||||
video_writer = cv2.VideoWriter(
|
||||
video_path,
|
||||
cv2.VideoWriter_fourcc(*fourcc),
|
||||
fps,
|
||||
(width, height),
|
||||
isColor=is_color,
|
||||
)
|
||||
|
||||
for _ in range(num_frames):
|
||||
video_writer.write(image)
|
||||
|
||||
video_writer.release()
|
||||
return video_path
|
||||
|
||||
|
||||
def cosine_similarity(A: npt.NDArray, B: npt.NDArray, axis: int = -1) -> npt.NDArray:
|
||||
"""Compute cosine similarity between two vectors."""
|
||||
return np.sum(A * B, axis=axis) / (
|
||||
np.linalg.norm(A, axis=axis) * np.linalg.norm(B, axis=axis)
|
||||
)
|
||||
|
||||
|
||||
def normalize_image(image: npt.NDArray) -> npt.NDArray:
|
||||
"""Normalize image to [0, 1] range."""
|
||||
return image.astype(np.float32) / 255.0
|
||||
Reference in New Issue
Block a user