From c15672cfb49152ebf65cfd188e71b84593ec5686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20P=C3=A9rez=20de=20Algaba?= <124347725+jperezdealgaba@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:17:12 +0200 Subject: [PATCH] [Security] Accept zero-sum DeepSeek-OCR pixel tensors (#59417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Juan Pérez de Algaba --- .../processing/test_deepseek_ocr.py | 91 ++++++++++++++++++- vllm/model_executor/models/deepseek_ocr.py | 2 - vllm/model_executor/models/deepseek_ocr2.py | 2 +- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/tests/models/multimodal/processing/test_deepseek_ocr.py b/tests/models/multimodal/processing/test_deepseek_ocr.py index 2bcf5e05d3..0523c34de0 100644 --- a/tests/models/multimodal/processing/test_deepseek_ocr.py +++ b/tests/models/multimodal/processing/test_deepseek_ocr.py @@ -12,12 +12,22 @@ Run with: pytest tests/models/multimodal/processing/test_deepseek_ocr.py -v """ +from types import SimpleNamespace + import pytest +import torch from PIL import Image from transformers import AutoTokenizer -from vllm.model_executor.models.deepseek_ocr import DeepseekOCRImagePixelInputs -from vllm.transformers_utils.processors.deepseek_ocr import DeepseekOCRProcessor +from vllm.model_executor.models.deepseek_ocr import ( + DeepseekOCRForCausalLM, + DeepseekOCRImagePixelInputs, +) +from vllm.model_executor.models.deepseek_ocr2 import DeepseekOCR2ForCausalLM +from vllm.transformers_utils.processors.deepseek_ocr import ( + DeepseekOCRProcessor, + ImageTransform, +) MODEL_ID = "deepseek-ai/DeepSeek-OCR" @@ -155,3 +165,80 @@ class TestDeepseekOCRInputValidation: def test_missing_images_raises_value_error(self, processor): with pytest.raises(ValueError, match="prompt and images"): processor(prompt="\nDescribe this image.", images=None) + + +def _balanced_normalized_pixels( + batch: int = 1, channels: int = 3, height: int = 64, width: int = 64 +) -> torch.Tensor: + """Build a pixel tensor whose values cancel to an exact sum of 0. + + Matches the normalized half-black / half-white case from the DeepSeek-OCR + processor (Normalize(0.5, 0.5) maps 0→-1 and 255→+1). + """ + assert width % 2 == 0 + pixel_values = torch.ones(batch, channels, height, width) + pixel_values[..., : width // 2] = -1.0 + assert pixel_values.sum().item() == 0.0 + return pixel_values + + +class TestDeepseekOCRZeroSumPixelsAccepted: + """Zero-sum normalized pixels must still produce validated image inputs. + + Returning None from ``_parse_and_validate_image_input`` makes + ``embed_multimodal`` return None and trips the worker encoder-output + sanity check, killing EngineCore. The zero-sum shortcut is therefore + removed; balanced images must parse like any other valid tensor. + """ + + def test_ocr_accepts_balanced_normalized_pixels(self): + pixel_values = _balanced_normalized_pixels() + images_crop = torch.zeros(0, 3, 40, 40) + images_spatial_crop = torch.tensor([[1, 1]]) + + image_input = DeepseekOCRForCausalLM._parse_and_validate_image_input( + None, + pixel_values=pixel_values, + images_crop=images_crop, + images_spatial_crop=images_spatial_crop, + ) + + assert image_input is not None + assert image_input.data is pixel_values + + def test_ocr2_accepts_balanced_normalized_pixels(self): + pixel_values = _balanced_normalized_pixels() + images_crop = torch.zeros(0, 3, 40, 40) + images_spatial_crop = torch.tensor([[1, 1]]) + model = SimpleNamespace(vision_config=SimpleNamespace(image_size=64)) + + image_input = DeepseekOCR2ForCausalLM._parse_and_validate_image_input( + model, + pixel_values=pixel_values, + images_crop=images_crop, + images_spatial_crop=images_spatial_crop, + ) + + assert image_input is not None + assert image_input.data is pixel_values + + def test_image_transform_half_black_half_white_sums_to_zero(self): + """A 1024×1024 half-black/half-white PNG through ImageTransform + (ToTensor + Normalize(0.5, 0.5)) yields an exact zero sum, and parse + must still accept it. + """ + image = Image.new("RGB", (1024, 1024), (0, 0, 0)) + image.paste(Image.new("RGB", (512, 1024), (255, 255, 255)), (512, 0)) + pixel_values = ImageTransform()(image).unsqueeze(0) + assert pixel_values.sum().item() == 0.0 + + images_crop = torch.zeros(0, 3, 640, 640) + images_spatial_crop = torch.tensor([[1, 1]]) + image_input = DeepseekOCRForCausalLM._parse_and_validate_image_input( + None, + pixel_values=pixel_values, + images_crop=images_crop, + images_spatial_crop=images_spatial_crop, + ) + assert image_input is not None + assert torch.equal(image_input.data, pixel_values) diff --git a/vllm/model_executor/models/deepseek_ocr.py b/vllm/model_executor/models/deepseek_ocr.py index 62a46fc498..3aef695d06 100644 --- a/vllm/model_executor/models/deepseek_ocr.py +++ b/vllm/model_executor/models/deepseek_ocr.py @@ -457,8 +457,6 @@ class DeepseekOCRForCausalLM( return None assert isinstance(pixel_values, torch.Tensor) assert images_crop is None or isinstance(images_crop, torch.Tensor) - if torch.sum(pixel_values).item() == 0: - return None # Use actual tensor spatial dim instead of hardcoded # vision_config.image_size (1024). The vision encoders (SAM & CLIP) diff --git a/vllm/model_executor/models/deepseek_ocr2.py b/vllm/model_executor/models/deepseek_ocr2.py index 938598a68b..18de7503c7 100644 --- a/vllm/model_executor/models/deepseek_ocr2.py +++ b/vllm/model_executor/models/deepseek_ocr2.py @@ -318,7 +318,7 @@ class DeepseekOCR2ForCausalLM(nn.Module, SupportsMultiModal, SupportsPP, Support images_spatial_crop = kwargs.pop("images_spatial_crop", None) images_crop = kwargs.pop("images_crop", None) - if pixel_values is None or torch.sum(pixel_values).item() == 0: + if pixel_values is None: return None base_size = self.vision_config.image_size