| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | import numpy as np |
| 4 | import pytest |
| 5 | import torch |
| 6 | from PIL import Image |
| 7 | |
| 8 | from vllm.multimodal.parse import ( |
| 9 | AudioProcessorItems, |
| 10 | ImageProcessorItems, |
| 11 | MultiModalDataParser, |
| 12 | VideoProcessorItems, |
| 13 | ) |
| 14 | |
| 15 | H, W = 480, 640 |
| 16 | |
| 17 | |
| 18 | class AudioMetadataParser(MultiModalDataParser): |
| 19 | embedding_fields = { |
| 20 | "audio": {"audio_embeds": "values", "audio_num_tokens": "metadata"}, |
| 21 | } |
| 22 | |
| 23 | |
| 24 | @pytest.mark.parametrize("allow_missing", [False, True]) |
| 25 | def test_audio_metadata_requires_ec_consumer(allow_missing): |
| 26 | parser = AudioMetadataParser(allow_missing_mm_embeddings=allow_missing) |
| 27 | data = {"audio_num_tokens": torch.tensor([[3], [5]])} |
| 28 | if not allow_missing: |
| 29 | with pytest.raises(ValueError, match="audio_embeds"): |
| 30 | parser.parse_mm_data({"audio": data}) |
| 31 | else: |
| 32 | items = parser.parse_mm_data({"audio": data})["audio"] |
| 33 | assert len(items) == 2 |
| 34 | assert items.get(1)["audio_num_tokens"].item() == 5 |
| 35 | assert items.get_processor_data() == {} |
| 36 | |
| 37 | |
| 38 | @pytest.mark.parametrize("counts", [[0], [-1], [1.5], [True], [[1, 2]]]) |
| 39 | def test_audio_metadata_rejects_invalid_token_counts(counts): |
| 40 | parser = AudioMetadataParser(allow_missing_mm_embeddings=True) |
| 41 | with pytest.raises(ValueError, match="positive integer"): |
| 42 | parser.parse_mm_data({"audio": {"audio_num_tokens": torch.tensor(counts)}}) |
| 43 | |
| 44 | |
| 45 | def test_audio_metadata_checks_supplied_embedding_lengths(): |
| 46 | parser = AudioMetadataParser() |
| 47 | data = { |
| 48 | "audio_num_tokens": torch.tensor([3, 5]), |
| 49 | "audio_embeds": [torch.zeros(3, 8), torch.zeros(5, 8)], |
| 50 | } |
| 51 | assert len(parser.parse_mm_data({"audio": data})["audio"]) == 2 |
| 52 | data["audio_num_tokens"] = torch.tensor([3, 4]) |
| 53 | with pytest.raises(ValueError, match="does not match"): |
| 54 | parser.parse_mm_data({"audio": data}) |
| 55 | |
| 56 | |
| 57 | @pytest.mark.parametrize( |
| 58 | "image", |
| 59 | [ |
| 60 | Image.new("RGB", (W, H)), |
| 61 | # HWC, e.g. from np.array(PIL.Image) |
| 62 | np.zeros((H, W, 3), dtype=np.uint8), |
| 63 | torch.zeros((H, W, 3), dtype=torch.uint8), |
| 64 | # CHW, standard PyTorch / numpy convention |
| 65 | np.zeros((3, H, W), dtype=np.uint8), |
| 66 | torch.zeros((3, H, W), dtype=torch.uint8), |
| 67 | ], |
| 68 | ) |
| 69 | def test_image_size_hwc_chw(image): |
| 70 | """Image sizes must be channel-layout agnostic. |
| 71 | |
| 72 | `get_image_size` determines the multimodal placeholder count; reading an |
| 73 | HWC array (the layout `np.array(PIL.Image)` produces) as CHW yields a |
| 74 | bogus size and a placeholder/embedding count mismatch at inference time. |
| 75 | """ |
| 76 | items = ImageProcessorItems([image]) |
| 77 | |
| 78 | assert items.get_image_size(0) == (W, H) |
| 79 | |
| 80 | |
| 81 | @pytest.mark.parametrize( |
| 82 | "frame", |
| 83 | [ |
| 84 | Image.new("RGB", (W, H)), |
| 85 | np.zeros((H, W, 3), dtype=np.uint8), |
| 86 | torch.zeros((H, W, 3), dtype=torch.uint8), |
| 87 | np.zeros((3, H, W), dtype=np.uint8), |
| 88 | torch.zeros((3, H, W), dtype=torch.uint8), |
| 89 | ], |
| 90 | ) |
| 91 | def test_frame_size_hwc_chw(frame): |
| 92 | """`get_frame_size` must stay consistent with `get_image_size`.""" |
| 93 | items = VideoProcessorItems([[frame]]) |
| 94 | |
| 95 | assert items.get_frame_size(0) == (W, H) |
| 96 | |
| 97 | |
| 98 | def test_video_with_metadata_tensor_passthrough(): |
| 99 | """Tensor frames pass through unchanged regardless of device: HF video |
| 100 | processors accept tensors, and device-resident frames (e.g. NVDEC-decoded) |
| 101 | must not be copied back to host.""" |
| 102 | frames = torch.zeros((4, H, W, 3), dtype=torch.uint8) |
| 103 | video, metadata = MultiModalDataParser()._get_video_with_metadata(frames) |
| 104 | |
| 105 | assert video is frames |
| 106 | assert metadata is None |
| 107 | |
| 108 | |
| 109 | @pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA") |
| 110 | def test_video_with_metadata_keeps_device_tensor(): |
| 111 | """Device-resident frames (e.g. NVDEC-decoded) pass through as tensors, |
| 112 | so a device-side HF processor can consume them without a D2H copy.""" |
| 113 | frames = torch.zeros((4, H, W, 3), dtype=torch.uint8, device="cuda") |
| 114 | video, metadata = MultiModalDataParser()._get_video_with_metadata(frames) |
| 115 | |
| 116 | assert video is frames |
| 117 | assert metadata is None |
| 118 | |
| 119 | |
| 120 | @pytest.mark.parametrize( |
| 121 | "frames", |
| 122 | [ |
| 123 | [np.zeros((H, W, 3), dtype=np.uint8) for _ in range(2)], |
| 124 | [torch.zeros((H, W, 3), dtype=torch.uint8) for _ in range(2)], |
| 125 | ], |
| 126 | ) |
| 127 | def test_parse_video_frame_list_as_single_video(frames): |
| 128 | """A list of decoded frames must represent one video item.""" |
| 129 | items = MultiModalDataParser().parse_mm_data({"video": frames})["video"] |
| 130 | |
| 131 | assert items.get_count() == 1 |
| 132 | video = items.get(0) |
| 133 | assert isinstance(video, np.ndarray) |
| 134 | np.testing.assert_array_equal(video, np.stack([np.asarray(f) for f in frames])) |
| 135 | |
| 136 | |
| 137 | @pytest.mark.parametrize( |
| 138 | "modality,processor_cls", |
| 139 | [ |
| 140 | ("audio", AudioProcessorItems), |
| 141 | ("image", ImageProcessorItems), |
| 142 | ("video", VideoProcessorItems), |
| 143 | ], |
| 144 | ) |
| 145 | def test_parse_mm_data_accepts_none_cached_item(modality, processor_cls): |
| 146 | mm_items = MultiModalDataParser().parse_mm_data({modality: [None]}) |
| 147 | items = mm_items[modality] |
| 148 | assert isinstance(items, processor_cls) |
| 149 | assert len(items) == 1 |
| 150 | assert items.get(0) is None |
| 151 | |
| 152 | |
| 153 | def test_cached_audio_items_preserve_positions_during_resampling(): |
| 154 | waveform = np.arange(16, dtype=np.float32) |
| 155 | parser = MultiModalDataParser( |
| 156 | target_sr=16000, target_channels=1, audio_resample_method="scipy" |
| 157 | ) |
| 158 | items = parser.parse_mm_data( |
| 159 | {"audio": [None, (waveform, 8000), None, (waveform, 16000)]} |
| 160 | )["audio"] |
| 161 | |
| 162 | assert len(items) == 4 |
| 163 | assert items.get(0) is None |
| 164 | assert items.get(2) is None |
| 165 | assert len(items.get(1)) == 32 |
| 166 | np.testing.assert_array_equal(items.get(3), waveform) |