Prhub

#56576 [Bugfix] tolerate malformed EXIF metadata during hashing (#56527)

原始 PR 作者 amasen02 合并时间 2026-09-16 15:56 文件变更 2 提交数 3 评论 9 代码增减 +49 / -11

执行摘要

多模态哈希容忍坏 EXIF,避免坏图像请求 500

Issue #56527 报告:某生产镜像的一张图片在开启多模态输入缓存后「每次尝试都 500,被重试到重试预算耗尽」,客户端只能自行重新编码图片绕过。根因是容错策略不一致——ImageMediaIO.load_bytes 走 normalize_image,其中 ImageOps.exif_transpose 被 contextlib.suppress(Exception) 包裹;但同一张图随后进入 MultiModalHasher.serialize_item 时,两个分支都裸调 obj.getexif(),对非法 TIFF 头抛出 SyntaxError: not a TIFF file (header b'…' not valid)。由于 SyntaxError 不是 ValueError,请求没有被判为客户端错误(400),而是以 500 结束,客户端把 5xx 当瞬时故障无限重试。Issue 给出的期望是「图片正常哈希并被服务」,并给出两种可选修法:容错回退,或至少返回 400;PR 选择了与 normalize_image 一致的容错路线。

值得花时间精读,重点是评审里 Hotragn 对 Pillow 惰性 EXIF 缓存机制的拆解——它解释了为什么「直接构造坏 EXIF 图片调 getexif()」不会复现问题,也是判断新增测试是否有效的关键。设计决策上关注两点:一是选择「容错回退」而非 Issue 中另一选项「返回 400 拒绝」的取舍(PR 明确对齐 normalize_image 的宽容语义);二是把重复逻辑抽成 _get_image_id_bytes 时用 exif.get() 保持哈希语义等价。如果需要继续推进,建议补一条 API 级回归用例,并处理 vllm/model_executor/models/molmo2.py:1308 的裸 exif_transpose。

讨论亮点
  • DarkLight1337(代码风格):对测试文件评论 Please move the imports to the top(指向 def test_hash_image_malformed_exif(): 内的 from PIL import ImageOps);作者回复 Done! Moved ImageOps to the module-level imports,并在 commit 4c588ca 中落实。这是本 PR 唯一的代码修改类评审意见。
  • Hotragn(测试有效性,最有价值的讨论):主动解释「这个新测试为什么不是空测试」。他用三步实测说明:[A] 对刚 open 的图片调 getexif() 不抛,返回 {};[B] 直接 ImageOps.exif_transpose() 也不抛;[C] 必须先经过被 suppress 的 exif_transpose,再 load(),getexif() 才会抛 SyntaxError: not a TIFF file (header b'XXXX\x00\x00\x00\x08' not valid)。他指出抛出需要前一步(被吞掉的)exif_transpose 预热 Pillow 的惰性 EXIF 状态,而这正是 ImageMediaIO.load_bytes(vllm/multimodal/media/image.py:90-92:normalize_image(image) → image.load() → _convert_image_mode(image))在生产中的真实形态,.convert() 也会把被污染状态带过去(Pillow 12.3.0)。
  • Hotragn(残留风险,未解决):同一段评论末尾指出 ImageOps.exif_transpose 在 Molmo2 处理器里仍是裸调用(vllm/model_executor/models/molmo2.py:1308),暗示同类坏 EXIF 仍可能在该模型路径上抛错;本 PR 未处理该点。
  • 流程性信息:claude[bot] 因 fork PR 默认关闭自动评审;DarkLight1337 触发 /ci run 与 /ci retry,最终以 APPROVED + Thanks 合入。

实现拆解

  1. 新增容错读取辅助函数(vllm/multimodal/hasher.py):引入模块级私有函数 _get_image_id_bytes(image) -> bytes | None,内部用 try ... except Exception 包裹 image.getexif(),再通过 exif.get(Image.ExifTags.Base.ImageID) + isinstance(image_id, uuid.UUID) 判断,命中则返回 UUID 的 .bytes,否则(含任何解析异常)返回 None。用 .get() 取代原来的「in 判断 + 下标访问」语义等价,因此对能正常解析 EXIF 的图片哈希结果与缓存键保持不变。

  2. 收敛两个调用点(同文件 MultiModalHasher.serialize_item):Image.Image 分支和 MediaWithBytes(media=Image.Image) 分支都改为 image_id = _get_image_id_bytes(...) / if image_id is not None: return (image_id,),删除了原先各自手写的 getexif() 逻辑。异常路径此后落到既有的内容哈希:前者用 mode + np.asarray(obj) + 可选 palette / palette.rawmode,后者用 obj.original_bytes(若配置了 obj.io_config 则把它一起并入),保证不同预处理配置仍产生不同哈希、不引入缓存串扰。

  3. 补齐回归测试(tests/multimodal/test_hasher.py):新增 test_hash_image_malformed_exif,先保存一张正常 JPEG,剥掉 JFIF APP0 后在 SOI 后插入带非法 TIFF 头的 APP1 段,再按 ImageMediaIO.load_bytes 的顺序(被 suppress 的 exif_transpose → load())复现 Pillow 惰性 EXIF 状态被「污染」后的场景,分别对裸 Image.Image 与 MediaWithBytes 断言 hash_kwargs 能返回非空字符串。原有 test_hash_image_exif_id 继续覆盖合法 UUID ImageID 路径。

  4. 评审跟进:from PIL import ImageOps 从测试函数内部移到模块顶部(commit 4c588ca),并同步调整 contextlib / struct / ImageDraw 的导入;后续 Merge branch 'main' 无功能改动。

  5. 无配置 / schema / 部署配套改动:不涉及 envs、配置项、CI 或文档,属于纯源码 + 单测的窄面修复。

文件 模块 状态 重要度
vllm/multimodal/hasher.py 多模态哈希 modified 6.94
tests/multimodal/test_hasher.py 哈希测试 modified 5.44

关键符号

_get_image_id_bytes MultiModalHasher.serialize_item test_hash_image_malformed_exif

关键源码片段

vllm/multimodal/hasher.py core-logic

核心修复文件:新增 `_get_image_id_bytes()` 并在 `MultiModalHasher.serialize_item` 的 `Image.Image` 与 `MediaWithBytes` 两个分支统一使用,消除坏 EXIF 导致的未捕获 `SyntaxError`(HTTP 500)。

# vllm/multimodal/hasher.py
​
​
def _get_image_id_bytes(image: Image.Image) -> bytes | None:
    # 统一在这里读 EXIF 里的 ImageID UUID 标签:
    # 任何解析异常(例如 APP1 段里 TIFF 头非法抛出的 SyntaxError)
    # 都退化为 None,由调用方回落到像素数据或 original_bytes,
    # 与 normalize_image 的容错语义保持一致,避免 500。
    try:
        exif = image.getexif()
        # 用 .get() 取代旧的 `in` + 下标访问,语义等价,
        # 因此正常 EXIF 图片的哈希值与缓存键不变。
        image_id = exif.get(Image.ExifTags.Base.ImageID)
        if isinstance(image_id, uuid.UUID):
            return image_id.bytes
    except Exception:
        # Tolerate malformed EXIF metadata (e.g. invalid TIFF header)
        # and fall back to serializing raw image data or bytes.
        pass
    return None
​
​
class MultiModalHasher:
    @classmethod
    def serialize_item(cls, obj: object) -> Iterable[bytes | memoryview]:
        # ... 省略 bytes/str/int 等简单分支 ...
​
        if isinstance(obj, Image.Image):
            image_id = _get_image_id_bytes(obj)
            if image_id is not None:
                return (image_id,)
​
            # 无可用 ImageID(或 EXIF 解析失败)时,退回内容哈希:
            # mode + 像素 + 调色板,保证不同图像内容哈希不同。
            data = {"mode": obj.mode, "data": np.asarray(obj)}
            palette = obj.palette
            if palette is not None:
                data["palette"] = palette.palette
                if palette.rawmode is not None:
                    data["palette_rawmode"] = palette.rawmode
​
            return cls.iter_item_to_bytes("image", data)
​
        if isinstance(obj, MediaWithBytes) and isinstance(obj.media, Image.Image):
            image_id = _get_image_id_bytes(obj.media)
            if image_id is not None:
                return (image_id,)
​
            # MediaWithBytes 分支保留 io_config 参与哈希,
            # 确保不同预处理配置(如不同背景色)不会产生哈希碰撞。
            if obj.io_config:
                return cls.iter_item_to_bytes(
                    "image",
                    {"io_config": obj.io_config, "data": obj.original_bytes},
                )
            return cls.iter_item_to_bytes("image", obj.original_bytes)
tests/multimodal/test_hasher.py test-coverage

新增 `test_hash_image_malformed_exif` 回归测试,构造非法 TIFF 头 APP1 段并复刻 `load_bytes` 的调用顺序,分别覆盖 `Image.Image` 与 `MediaWithBytes` 两条序列化路径;同时把 `ImageOps` 等 import 提升到模块顶部。

# tests/multimodal/test_hasher.py
​
​
def test_hash_image_malformed_exif():
    # 构造带非法 TIFF 头的 APP1 EXIF 段:
    # 先存一张正常 JPEG,剥掉 JFIF APP0,再把伪造的 APP1 插到 SOI 之后,
    # 模拟线上真实出现的坏元数据图片。
    buf = BytesIO()
    Image.new("RGB", (64, 48)).save(buf, "JPEG")
    jpg = buf.getvalue()
    rest = jpg[2:]
    rest = rest[2 + struct.unpack(">H", rest[2:4])[0]:]
    payload = b"Exif\x00\x00XXXX\x00\x00\x00\x08" + bytes(32)
    data = b"\xff\xd8\xff\xe1" + struct.pack(">H", len(payload) + 2) + payload + rest
​
    # 关键:复刻 ImageMediaIO.load_bytes 的顺序。
    # 被 suppress 的 exif_transpose 会先「污染」Pillow 的惰性 EXIF 状态,
    # 之后的 getexif() 才会真正抛 SyntaxError;直接 open 后调用则不会。
    image = Image.open(BytesIO(data))
    with contextlib.suppress(Exception):
        image = ImageOps.exif_transpose(image)
    image.load()
​
    hasher = MultiModalHasher
    # 裸 Image.Image 路径:应回退到像素哈希而不是抛异常
    hash_val = hasher.hash_kwargs("blake3", image=image)
    assert isinstance(hash_val, str) and len(hash_val) > 0
​
    # MediaWithBytes 包装路径同样要能安全序列化并产出哈希
    media_item = MediaWithBytes(image, data)
    hash_media = hasher.hash_kwargs("blake3", image=media_item)
    assert isinstance(hash_media, str) and len(hash_media) > 0

评论区精华

新测试是否真能触发 SyntaxError(是否会退化成空测试) 测试

Hotragn 用 A/B/C 三步实测说明:刚 `open` 的图片调 `getexif()` 不抛(返回 `{}`),直接调 `ImageOps.exif_transpose()` 也不抛,只有先经过被 suppress 的 `exif_transpose` 再 `load()` 后 `getexif()` 才会抛 `SyntaxError: not a TIFF file (header b'XXXX\x00\x00\x00\x08' not valid)`;抛出依赖 Pillow 惰性 EXIF 状态被预热,`.convert()` 也会把该状态带过去(Pillow 12.3.0)。

结论:确认测试有效,且精确复刻了 `ImageMediaIO.load_bytes`(`vllm/multimodal/media/image.py:90-92`)在生产中交给 `serialize_item` 的对象形态;但测试本身未断言“确实走到了回退分支”,仍依赖 Pillow 内部行为。 · 已解决

Molmo2 中仍有未加保护的 exif_transpose 调用 正确性

Hotragn 在核对「是否只有这一处」时发现 `ImageOps.exif_transpose` 在 Molmo2 处理器里被裸调用:`vllm/model_executor/models/molmo2.py:1308`(该模块自己的 exif 处理路径)。

结论:本 PR 未覆盖该调用点,坏 EXIF 在该模型链路上仍可能抛错,属于明确遗留项,需要后续单独修复或统一收敛到容错工具函数。 · unresolved

容错回退 vs 返回 400 的策略选择 设计

Issue #56527 给出两种可选修法:像 `normalize_image` 一样吞掉异常并回退到像素 /`original_bytes` 哈希,或至少把 5xx 改成 400 提示客户端输入有错;并指出客户端会把 5xx 当瞬时故障无限重试。

结论:PR 采纳容错方案,使哈希行为与 `ImageMediaIO.load_bytes` → `normalize_image` 的宽容语义一致,坏 EXIF 图片「正常加载也就能正常哈希」。 · 已解决

测试文件 import 位置 style

DarkLight1337 在 `tests/multimodal/test_hasher.py` 上评论 `Please move the imports to the top`,当时 `from PIL import ImageOps` 写在测试函数体内。

结论:作者回复 `Done! Moved ImageOps to the module-level imports`,在 commit 4c588ca 中把 `ImageOps` 移入模块级 import 并同步整理 `contextlib` / `struct` / `ImageDraw`。 · 已解决

风险与影响

  1. 异常捕获过宽(except Exception):_get_image_id_bytes 会吞掉所有异常,包括未来 Pillow 版本新增的异常类型或非元数据原因导致的失败。一旦 getexif() 因非 EXIF 原因失败,同一张图会从「UUID 哈希」静默切到「像素哈希」,从而改变 MultiModalHasher 产出的缓存键。考虑到该分支此前是直接 500,收益仍明显大于风险,但缺少日志/计数器,运维侧无法观测回退发生了多少次。
  2. 同类未保护调用点仍在:vllm/model_executor/models/molmo2.py:1308 的裸 ImageOps.exif_transpose 由 Hotragn 指出,本 PR 未修,Molmo2 链路遇到坏 EXIF 仍可能抛错。
  3. 测试脆弱且可能静默失效:test_hash_image_malformed_exif 依赖 Pillow 12.3.0 的惰性 EXIF 缓存行为;若 Pillow 改变缓存语义使 getexif() 不再抛错,测试仍会通过(只是变成空测试),不会失败告警。测试也没有断言「确实走了 except 分支」(例如断言哈希等于像素回退结果,或对 _get_image_id_bytes 做 monkeypatch 计数)。
  4. 缺少端到端覆盖:没有针对「坏 EXIF 图片经 OpenAI 兼容服务返回 200 而非 500」的 API 级用例,单测只覆盖 hashing 层不抛异常。
  5. 兼容性:无 API、配置项、schema 或序列化格式变更,对合法 EXIF 图片的哈希值与缓存键不变,属低兼容风险。
  • 用户侧:上传含损坏 EXIF 元数据图片的客户端不再收到 HTTP 500,消除了「客户端把 5xx 当瞬时故障反复重试同一张图直到预算耗尽」的死循环;Issue 报告方可以移除自己临时加的重新编码规避逻辑。
  • 系统侧:MultiModalHasher.serialize_item 是多模态输入内容哈希/缓存键的统一入口,修复后其容错边界与 normalize_image 对齐,坏元数据图片降级为按像素或原始字节参与哈希,不影响正常图片的哈希稳定性与缓存命中。
  • 团队侧:改动面仅 2 个文件、约 30 行,评审成本低;但留下了 Molmo2 同类调用点这一明确的技术债,需要后续跟进。
except Exception 捕获过宽 Molmo2 残留未保护 exif_transpose 测试依赖 Pillow 惰性 EXIF 行为,可能静默失效 缺少 API 级 500 回归用例 回退路径无日志可观测

关联 Issue

#56527 [Bug]: malformed EXIF in an image fails the request with a 500 from `MultiModalHasher.serialize_item`

完整报告

参与讨论