[Bugfix][Multimodal] Include media IO config in MM cache hash (#49975)

Signed-off-by: Guan-Ming (Wesley) Chiu <105915352+guan404ming@users.noreply.github.com>

Guan-Ming Chiu committed Jul 29, 2026 at 18:48 UTC aeaa50a71c4f53a8b38ae043ab5914fed2504d4a
5 files changed +54 -3
tests/multimodal/test_hasher.py
+36
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import uuid
4 +from io import BytesIO
5 from pathlib import Path
6
7 import numpy as np
@@ -10,6 +11,7 @@ from PIL import Image, ImageDraw
11
12 from vllm.multimodal.hasher import MultiModalHasher
13 from vllm.multimodal.media.base import MediaWithBytes
14 +from vllm.multimodal.media.image import ImageMediaIO
15 from vllm.multimodal.parse import MultiModalDataParser
16
17 pytestmark = pytest.mark.cpu_test
@@ -134,3 +136,37 @@ def test_hash_image_exif_id():
136 assert hasher.hash_kwargs(image=image1) == hasher.hash_kwargs(image=id.bytes)
137 # second image has non-UUID in ImageID, so it should hash to the image data
138 assert hasher.hash_kwargs(image=image2) == hasher.hash_kwargs(image=image2a)
139 +
140 +
141 +def _rgba_png_bytes() -> bytes:
142 + image = Image.new("RGBA", (8, 8), (255, 0, 0, 128))
143 + buf = BytesIO()
144 + image.save(buf, format="PNG")
145 + return buf.getvalue()
146 +
147 +
148 +def test_hash_collision_media_io_config():
149 + data = _rgba_png_bytes()
150 + white = ImageMediaIO(rgba_background_color=(255, 255, 255)).load_bytes(data)
151 + black = ImageMediaIO(rgba_background_color=(0, 0, 0)).load_bytes(data)
152 + white2 = ImageMediaIO(rgba_background_color=(255, 255, 255)).load_bytes(data)
153 + keep = ImageMediaIO(image_mode=None).load_bytes(data)
154 +
155 + hasher = MultiModalHasher
156 + assert hasher.hash_kwargs(image=white) != hasher.hash_kwargs(image=black)
157 + assert hasher.hash_kwargs(image=white) != hasher.hash_kwargs(image=keep)
158 + assert hasher.hash_kwargs(image=white) == hasher.hash_kwargs(image=white2)
159 +
160 +
161 +def test_hash_media_io_noop_config_preserves_hash():
162 + image = Image.new("RGB", (8, 8), (0, 128, 255))
163 + buf = BytesIO()
164 + image.save(buf, format="PNG")
165 + data = buf.getvalue()
166 +
167 + loaded = ImageMediaIO().load_bytes(data)
168 + assert loaded.io_config is None
169 +
170 + plain = MediaWithBytes(loaded.media, data)
171 + hasher = MultiModalHasher
172 + assert hasher.hash_kwargs(image=loaded) == hasher.hash_kwargs(image=plain)
vllm/multimodal/hasher.py
+5
@@ -81,6 +81,11 @@ class MultiModalHasher:
81 ):
82 return (exif[Image.ExifTags.Base.ImageID].bytes,)
83
84 + if obj.io_config:
85 + return cls.iter_item_to_bytes(
86 + "image",
87 + {"io_config": obj.io_config, "data": obj.original_bytes},
88 + )
89 return cls.iter_item_to_bytes("image", obj.original_bytes)
90
91 if isinstance(obj, MediaWithBytes) and isinstance(obj.media, np.ndarray):
vllm/multimodal/media/base.py
+3
@@ -29,6 +29,9 @@ class MediaWithBytes(Generic[_T]):
29
30 media: _T
31 original_bytes: bytes = field(repr=False)
32 + io_config: dict[str, Any] | None = None
33 + """Decode settings that altered the media relative to `original_bytes`
34 + (e.g. `image_mode` conversion), so they participate in cache hashing."""
35
36 def __array__(self, *args, **kwargs) -> np.ndarray:
37 """Allow np.array(obj) to return np.array(obj.media)."""
vllm/multimodal/media/image.py
+9 -2
@@ -86,10 +86,17 @@ class ImageMediaIO(MediaIO[Image.Image]):
86 )
87 image = normalize_image(image)
88 image.load()
89 - image = self._convert_image_mode(image)
89 + converted = self._convert_image_mode(image)
90 except (OSError, Image.UnidentifiedImageError) as e:
91 raise ValueError(f"Failed to load image: {e}") from e
92 - return MediaWithBytes(image, data)
92 +
93 + io_config = None
94 + if converted is not image:
95 + io_config = {
96 + "image_mode": self.image_mode,
97 + "rgba_background_color": self.rgba_background_color,
98 + }
99 + return MediaWithBytes(converted, data, io_config)
100
101 def load_base64(self, media_type: str, data: str) -> MediaWithBytes[Image.Image]:
102 return self.load_bytes(pybase64.b64decode(data, validate=True))
vllm/multimodal/processing/inputs.py
+1 -1
@@ -65,7 +65,7 @@ class ProcessorInputs:
65 **{modality: item},
66 **hf_processor_mm_kwargs,
67 )
68 - for item in data_items
68 + for item in data_items.get_all_items_for_hash()
69 ]
70
71 return mm_hashes