247 lines
10 KiB
Python
247 lines
10 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||
"""Inference-only Unlimited-OCR model compatible with HuggingFace weights.
|
||
|
||
Unlimited-OCR (``baidu/Unlimited-OCR``) shares
|
||
the exact DeepSeek-OCR (gundam, ``base_size=1024`` / ``image_size=640`` / crop)
|
||
vision stack: a DeepEncoder (SAM-ViT-B + CLIP-L) followed by a linear MLP
|
||
projector, with the same image-token tiling layout. The only difference is the
|
||
language backbone, which is a DeepSeek-V2 *MoE* (64 routed + 2 shared experts,
|
||
``first_k_dense_replace=1``) that uses plain multi-head attention
|
||
(``use_mla=False``, ``qk_nope_head_dim == qk_rope_head_dim == 0``) instead of
|
||
the dense MLA decoder used by DeepSeek-OCR.
|
||
|
||
vLLM's ``DeepseekV2DecoderLayer`` already dispatches to the plain-MHA
|
||
``DeepseekAttention`` whenever ``qk_nope_head_dim == qk_rope_head_dim == 0`` and
|
||
builds the MoE blocks straight from the config, so the whole DeepSeek-OCR
|
||
multimodal wrapper can be reused verbatim. Model-specific config (language
|
||
backbone architecture, FlexAttention for R-SWA, vision encoder backend, and
|
||
``rswa_window``) is applied in ``UnlimitedOCRForCausalLMConfig``.
|
||
|
||
Attention backend: the reference applies Reference Sliding Window Attention
|
||
(R-SWA) -- the prompt/image tokens form a globally-visible prefix while the
|
||
*generated* tokens additionally attend only a fixed sliding window (128) of
|
||
recent tokens. We reproduce this with backend-specific custom masks: FA4 uses a
|
||
``mask_mod``, FlexAttention uses a Triton block mask, and TritonAttention uses a
|
||
unified-attention decode mask. FlashInfer's paged decode exposes no custom mask.
|
||
The window size is published via ``model_config.rswa_window``, which the model
|
||
runner reads to plumb per-request prefix lengths into the attention metadata.
|
||
|
||
The *vision encoder* (DeepEncoder's CLIP stage, head_dim 64) is unaffected and
|
||
does not use R-SWA: it runs a single full-attention prefill pass. FlashAttention,
|
||
Triton and torch SDPA all produce correct, equally fast results; only FlashInfer
|
||
is incompatible (its ViT path asserts on the varlen cu_seqlens metadata that this
|
||
CLIP encoder never builds). We default the encoder to FlashAttention and
|
||
transparently fall back to it if FlashInfer is requested.
|
||
|
||
To suppress repetition on long documents, use ``NGramPerReqLogitsProcessor`` from
|
||
this module (same request-level processor as DeepSeek-OCR) with::
|
||
|
||
SamplingParams(
|
||
temperature=0.0,
|
||
max_tokens=8192,
|
||
extra_args={"ngram_size": 35, "window_size": 128},
|
||
)
|
||
|
||
Image processing
|
||
----------------
|
||
Unlimited-OCR supports up to 32 local crops (vs 6 for DeepSeek-OCR), i.e.
|
||
``dynamic_preprocess`` runs with ``max_num=32``.
|
||
|
||
Multi-image requests fall back to non-crop mode: crop ("gundam") mode is only
|
||
used for single-image input. DeepSeek-OCR does *not* have this restriction.
|
||
|
||
Because that fallback makes the per-image processor output depend on *how many*
|
||
images are in the request, it breaks the assumption behind vLLM's per-item
|
||
multimodal processing cache (``MultiModalProcessorOnlyCache``). We handle this
|
||
the same way ``DeepseekVL2MultiModalProcessor`` does: only the single-image case
|
||
(which always crops) is cached, while multi-image requests bypass the cache and
|
||
are recomputed fresh -- see ``_cached_apply_hf_processor`` below. This keeps the
|
||
processing cache consistent (verified by ``test_processing_correctness``).
|
||
"""
|
||
|
||
import math
|
||
from collections.abc import Mapping, Sequence
|
||
|
||
from vllm.config import VllmConfig
|
||
from vllm.multimodal import MULTIMODAL_REGISTRY
|
||
from vllm.multimodal.inputs import MultiModalKwargsItems
|
||
from vllm.multimodal.parse import (
|
||
ImageEmbeddingItems,
|
||
ImageProcessorItems,
|
||
ImageSize,
|
||
MultiModalDataItems,
|
||
)
|
||
from vllm.multimodal.processing import PromptReplacement, PromptUpdate
|
||
from vllm.multimodal.processing.context import TimingContext
|
||
from vllm.multimodal.processing.inputs import ProcessorInputs
|
||
from vllm.multimodal.processing.processor import MultiModalProcessingInfo
|
||
from vllm.transformers_utils.processors.deepseek_ocr import (
|
||
BASE_SIZE,
|
||
CROP_MODE,
|
||
IMAGE_SIZE,
|
||
count_tiles,
|
||
)
|
||
|
||
from .deepseek_ocr import (
|
||
DeepseekOCRDummyInputsBuilder,
|
||
DeepseekOCRForCausalLM,
|
||
DeepseekOCRMultiModalProcessor,
|
||
DeepseekOCRProcessingInfo,
|
||
NGramPerReqLogitsProcessor,
|
||
)
|
||
|
||
__all__ = [
|
||
"NGramPerReqLogitsProcessor",
|
||
"UnlimitedOCRForCausalLM",
|
||
]
|
||
|
||
# Unlimited-OCR supports up to 32 local crops (vs 6 for DeepSeek-OCR).
|
||
_UNLIMITED_OCR_MAX_CROPS = 32
|
||
|
||
|
||
class UnlimitedOCRProcessingInfo(DeepseekOCRProcessingInfo):
|
||
"""ProcessingInfo for Unlimited-OCR: same as DeepSeek-OCR but with
|
||
max_crops=32 instead of 6. The higher crop count allows tiling very large
|
||
document pages into up to 32 640×640 patches (dynamic_preprocess max_num=32).
|
||
"""
|
||
|
||
def get_hf_config(self):
|
||
from vllm.transformers_utils.configs.unlimited_ocr import UnlimitedOCRConfig
|
||
|
||
return self.ctx.get_hf_config(UnlimitedOCRConfig)
|
||
|
||
def get_hf_processor(self, **kwargs: object):
|
||
from vllm.transformers_utils.processors.unlimited_ocr import (
|
||
UnlimitedOCRProcessor,
|
||
)
|
||
|
||
v1_processor_config = dict(
|
||
image_size=IMAGE_SIZE,
|
||
base_size=BASE_SIZE,
|
||
crop_mode=CROP_MODE,
|
||
strategy="v1",
|
||
max_crops=_UNLIMITED_OCR_MAX_CROPS,
|
||
)
|
||
return self.ctx.get_hf_processor(
|
||
UnlimitedOCRProcessor,
|
||
**{**v1_processor_config, **kwargs},
|
||
)
|
||
|
||
def get_num_image_tokens(
|
||
self, *, image_width: int, image_height: int, cropping: bool = True
|
||
) -> int:
|
||
patch_size = 16
|
||
downsample_ratio = 4
|
||
|
||
# Honour the caller-supplied `cropping` flag: multi-image callers pass
|
||
# cropping=False to match UnlimitedOCRProcessor.tokenize_with_images.
|
||
if cropping:
|
||
if image_width <= IMAGE_SIZE and image_height <= IMAGE_SIZE:
|
||
crop_ratio = [1, 1]
|
||
else:
|
||
crop_ratio = count_tiles(
|
||
image_width,
|
||
image_height,
|
||
max_num=_UNLIMITED_OCR_MAX_CROPS,
|
||
image_size=IMAGE_SIZE,
|
||
)
|
||
num_width_tiles, num_height_tiles = crop_ratio
|
||
else:
|
||
num_width_tiles = num_height_tiles = 1
|
||
|
||
h = w = math.ceil((BASE_SIZE // patch_size) / downsample_ratio)
|
||
h2 = w2 = math.ceil((IMAGE_SIZE // patch_size) / downsample_ratio)
|
||
|
||
global_views_tokens = h * (w + 1)
|
||
if num_width_tiles > 1 or num_height_tiles > 1:
|
||
local_views_tokens = (num_height_tiles * h2) * (num_width_tiles * w2 + 1)
|
||
else:
|
||
local_views_tokens = 0
|
||
|
||
return global_views_tokens + local_views_tokens + 1
|
||
|
||
def get_image_size_with_most_features(self) -> ImageSize:
|
||
# With max_crops=32, the widest possible grid is 4×8 (aspect ratio 1:2).
|
||
# A 2560×5120 image (4×640 × 8×640) selects exactly 4×8=32 tiles and
|
||
# produces the maximum token count.
|
||
return ImageSize(width=640 * 4, height=640 * 8)
|
||
|
||
|
||
class UnlimitedOCRMultiModalProcessor(DeepseekOCRMultiModalProcessor):
|
||
"""Multimodal processor for Unlimited-OCR.
|
||
|
||
Disables crop mode for multi-image requests (to stay consistent with
|
||
``UnlimitedOCRProcessor.tokenize_with_images``), and -- since that makes the
|
||
per-image output depend on the request's image count -- bypasses the
|
||
per-item processing cache for multi-image requests, exactly like
|
||
``DeepseekVL2MultiModalProcessor``.
|
||
|
||
DeepSeek-OCR does *not* apply either of these.
|
||
"""
|
||
|
||
def _get_prompt_updates(
|
||
self,
|
||
mm_items: MultiModalDataItems,
|
||
hf_processor_mm_kwargs: Mapping[str, object],
|
||
out_mm_kwargs: MultiModalKwargsItems,
|
||
) -> Sequence[PromptUpdate]:
|
||
hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
|
||
|
||
image_token_id = hf_processor.image_token_id
|
||
assert isinstance(image_token_id, int)
|
||
|
||
def get_replacement_unlimited_ocr(item_idx: int):
|
||
images = mm_items.get_items(
|
||
"image", (ImageEmbeddingItems, ImageProcessorItems)
|
||
)
|
||
|
||
if isinstance(images, ImageEmbeddingItems):
|
||
num_image_tokens = images.get_feature_size(item_idx)
|
||
else:
|
||
size = images.get_image_size(item_idx)
|
||
|
||
# Disable crop mode for multi-image input.
|
||
# UnlimitedOCRProcessor.tokenize_with_images applies the same
|
||
# fallback, so both paths must agree on the effective crop flag.
|
||
effective_cropping = CROP_MODE and len(images) == 1
|
||
|
||
num_image_tokens = self.info.get_num_image_tokens(
|
||
image_width=size.width,
|
||
image_height=size.height,
|
||
cropping=effective_cropping,
|
||
)
|
||
return [image_token_id] * num_image_tokens
|
||
|
||
return [
|
||
PromptReplacement(
|
||
modality="image",
|
||
target=[image_token_id],
|
||
replacement=get_replacement_unlimited_ocr,
|
||
)
|
||
]
|
||
|
||
def _cached_apply_hf_processor(
|
||
self,
|
||
inputs: ProcessorInputs,
|
||
timing_ctx: TimingContext,
|
||
) -> tuple[list[int], MultiModalProcessingInfo, bool]:
|
||
# The processor logic differs for single-image (crop) vs multi-image
|
||
# (no crop) requests. The processing cache assumes per-item output is
|
||
# invariant of how many images are passed per prompt, so we only cache
|
||
# the single-image case and recompute multi-image requests fresh.
|
||
if inputs.mm_data_items.get_count("image", strict=False) > 1:
|
||
return self._apply_hf_processor(inputs, timing_ctx)
|
||
|
||
return super()._cached_apply_hf_processor(inputs, timing_ctx)
|
||
|
||
|
||
@MULTIMODAL_REGISTRY.register_processor(
|
||
UnlimitedOCRMultiModalProcessor,
|
||
info=UnlimitedOCRProcessingInfo,
|
||
dummy_inputs=DeepseekOCRDummyInputsBuilder,
|
||
)
|
||
class UnlimitedOCRForCausalLM(DeepseekOCRForCausalLM):
|
||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||
super().__init__(vllm_config=vllm_config, prefix=prefix)
|