chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
from typing import List, Union
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.clip import CLIPModel
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
class ClipImageProcessor(BaseMultimodalProcessor):
models = [CLIPModel]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.mm_tokens = MultimodalSpecialTokens(image_token="<image>").build(
_processor
)
async def process_mm_data_async(
self, image_data: List[Union[str, bytes]], input_text, *args, **kwargs
):
base_output = await self.load_mm_data(
prompt=input_text,
multimodal_tokens=self.mm_tokens,
image_data=image_data,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids.tolist(),
)
@@ -0,0 +1,69 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 SGLang Team
"""SGLang multimodal processor for Cohere2Vision (Command-A-Vision)."""
from typing import Dict, List, Union
from sglang.srt.managers.multimodal_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.cohere2_vision import Cohere2VisionForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import MultimodalSpecialTokens
class Cohere2VisionSGLangImageProcessor(SGLangBaseProcessor):
models = [Cohere2VisionForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
# Cohere2Vision wraps each image as:
# <|START_OF_IMG|> [<|IMG_PATCH|> * P^2 + <|IMG_LINE_BREAK|>] * N <|END_OF_IMG|>
# (N = patch count, P = patch_size). The HF processor expands the single
# <|IMG_PATCH|> placeholder into that block.
proc = _processor
boi_token = proc.boi_token
eoi_token = proc.eoi_token
image_token = proc.image_token # "<|IMG_PATCH|>"
line_break_token = proc.img_line_break_token
self.image_token_id = proc.image_token_id
self.boi_token_id = proc.tokenizer.convert_tokens_to_ids(boi_token)
self.eoi_token_id = proc.tokenizer.convert_tokens_to_ids(eoi_token)
self.img_line_break_token_id = proc.tokenizer.convert_tokens_to_ids(
line_break_token
)
# Match the unexpanded <|IMG_PATCH|> placeholder so SGLang pairs each
# one with its image_data entry before the HF processor expands it.
self.mm_tokens = MultimodalSpecialTokens(
image_token=image_token,
image_token_id=self.image_token_id,
).build(_processor)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes, Dict]],
input_text,
request_obj,
*args,
**kwargs,
):
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
discard_alpha_channel=True,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.image_token_id,
im_start_id=self.boi_token_id,
im_end_id=self.eoi_token_id,
)
@@ -0,0 +1,46 @@
from typing import List, Union
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.deepseek_ocr import DeepseekOCRForCausalLM
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
class DeepseekOCRProcessor(BaseMultimodalProcessor):
models = [DeepseekOCRForCausalLM]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
_processor.image_size = 640
_processor.ocr2_mode = (
str(
getattr(getattr(hf_config, "vision_config", None), "model_name", "")
).lower()
== "deepencoderv2"
or getattr(getattr(hf_config, "projector_config", None), "input_dim", None)
== 896
)
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.mm_tokens = MultimodalSpecialTokens(
image_token="<image>", image_token_id=self._processor.image_token_id
).build(_processor)
async def process_mm_data_async(
self, image_data: List[Union[str, bytes]], input_text, *args, **kwargs
):
base_output = await self.load_mm_data(
prompt=input_text,
multimodal_tokens=self.mm_tokens,
image_data=image_data,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids.tolist(),
im_token_id=self.mm_tokens.image_token_id,
)
@@ -0,0 +1,63 @@
# Copyright (c) 2023-2024 DeepSeek.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
# the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
from typing import List, Union
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.deepseek_vl2 import DeepseekVL2ForCausalLM
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
class DeepseekVL2ImageProcessor(BaseMultimodalProcessor):
models = [DeepseekVL2ForCausalLM]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.mm_tokens = MultimodalSpecialTokens(
image_token="<image>", image_token_id=self._processor.image_token_id
).build(_processor)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
input_text,
request_obj,
max_req_input_len,
*args,
**kwargs,
):
base_output = await self.load_mm_data(
input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output,
self.mm_tokens,
max_req_input_len=max_req_input_len,
conversations=base_output.input_text,
)
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids.tolist(),
im_token_id=self._processor.image_token_id,
)
@@ -0,0 +1,90 @@
import re
from typing import Dict, List, Union
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.dots_ocr import DotsOCRForCausalLM
from sglang.srt.models.dots_vlm import DotsVLMForCausalLM
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
class DotsVLMImageProcessor(BaseMultimodalProcessor):
models = [DotsVLMForCausalLM, DotsOCRForCausalLM]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
# The single, pre-expanded image token.
self.IMAGE_TOKEN = "<|img|><|imgpad|><|endofimg|>"
# The regex that matches expanded image tokens.
self.IMAGE_TOKEN_REGEX = re.compile(r"<\|img\|>(?:<\|imgpad\|>)+<\|endofimg\|>")
assert len(_processor.tokenizer.encode("<|img|>")) == 1
self.im_start_id = _processor.tokenizer.encode("<|img|>")[0]
self.im_end_id = _processor.tokenizer.encode("<|endofimg|>")[0]
self.image_token_id = _processor.tokenizer.encode("<|imgpad|>")[0]
self.IM_TOKEN_ID = self.image_token_id
self.IM_START_TOKEN_ID = self.im_start_id
self.IM_END_TOKEN_ID = self.im_end_id
vision_config = hf_config.vision_config
patch_size = vision_config.patch_size
merge_size = vision_config.spatial_merge_size
self.IMAGE_FACTOR = patch_size * merge_size
self.MIN_PIXELS = getattr(
_processor.image_processor,
"min_pixels",
getattr(_processor.image_processor, "size", {}).get("shortest_edge"),
)
self.MAX_PIXELS = getattr(
_processor.image_processor,
"max_pixels",
getattr(_processor.image_processor, "size", {}).get("longest_edge"),
)
self.MAX_RATIO = 200
self.mm_tokens = MultimodalSpecialTokens(
image_token=self.IMAGE_TOKEN,
image_token_id=self.image_token_id,
image_token_regex=self.IMAGE_TOKEN_REGEX,
).build(_processor)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes, Dict]],
input_text,
request_obj,
max_req_input_len,
*args,
**kwargs,
):
if isinstance(image_data, str):
image_data = [image_data]
if (
isinstance(image_data, list)
and image_data
and isinstance(image_data[0], list)
):
image_data = sum(image_data, [])
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
)
combined_mm_item, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
if combined_mm_item is None:
return None
return MultimodalProcessorOutput(
mm_items=combined_mm_item,
input_ids=input_ids.tolist(),
im_token_id=self.image_token_id,
im_start_id=self.im_start_id,
im_end_id=self.im_end_id,
)
@@ -0,0 +1,440 @@
import math
import os
from typing import List, Union
import numpy as np
import torch
import torchvision
from PIL import Image
from torchvision.transforms import InterpolationMode
from transformers import BaseImageProcessor
from sglang.srt.environ import envs
from sglang.srt.layers.rotary_embedding import MRotaryEmbedding
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.ernie45_vl import Ernie4_5_VLMoeForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.multimodal.processors.base_processor import (
MultimodalSpecialTokens,
)
from sglang.srt.utils import get_bool_env_var, is_npu, logger
_is_npu = is_npu()
SGL_USE_CUDA_IPC = get_bool_env_var("SGLANG_USE_CUDA_IPC_TRANSPORT")
IMAGE_FACTOR = 28
MIN_PIXELS = 4 * 28 * 28
# MAX_PIXELS = envs.SGLANG_IMAGE_MAX_PIXELS.get()
MAX_PIXELS = 16384 * 28 * 28
MAX_RATIO = 200
RESIZE_RESAMPLE = getattr(Image, envs.SGLANG_RESIZE_RESAMPLE.get(), None)
if envs.SGLANG_RESIZE_RESAMPLE.is_set() and RESIZE_RESAMPLE is None:
logger.warning(
f"Invalid RESIZE_RESAMPLE value: '{envs.SGLANG_RESIZE_RESAMPLE.get()}'. "
f"Ignoring and using default."
)
VIDEO_TOTAL_PIXELS = int(
float(os.environ.get("VIDEO_MAX_PIXELS", 128000 * 28 * 28 * 0.9))
)
VIDEO_MIN_PIXELS = 299 * 28 * 28
VIDEO_MAX_PIXELS = 1196 * 28 * 28
FRAME_FACTOR = 2
FPS = 2.0
FPS_MIN_FRAMES = 16
FPS_MAX_FRAMES = 180
def smart_resize(
height: int,
width: int,
factor: int = IMAGE_FACTOR,
min_pixels: int = MIN_PIXELS,
max_pixels: int = MAX_PIXELS,
):
if max(height, width) / min(height, width) > MAX_RATIO:
if height > width:
new_width = max(factor, round_by_factor(width, factor))
new_height = floor_by_factor(new_width * MAX_RATIO, factor)
else:
new_height = max(factor, round_by_factor(height, factor))
new_width = floor_by_factor(new_height * MAX_RATIO, factor)
height = new_height
width = new_width
h_bar = max(factor, round_by_factor(height, factor))
w_bar = max(factor, round_by_factor(width, factor))
if h_bar * w_bar > max_pixels:
beta = math.sqrt((height * width) / max_pixels)
h_bar = floor_by_factor(height / beta, factor)
w_bar = floor_by_factor(width / beta, factor)
elif h_bar * w_bar < min_pixels:
beta = math.sqrt(min_pixels / (height * width))
h_bar = ceil_by_factor(height * beta, factor)
w_bar = ceil_by_factor(width * beta, factor)
if min_pixels > h_bar * w_bar or h_bar * w_bar > max_pixels:
raise ValueError(f"encounter invalid h_bar: {h_bar}, w_bar: {w_bar}")
return h_bar, w_bar
def resize_image(
image,
min_pixels: int = MIN_PIXELS,
max_pixels: int = MAX_PIXELS,
size_factor: int = IMAGE_FACTOR,
) -> Image.Image:
width, height = image.size
min_pixels = min_pixels
max_pixels = max_pixels
resized_height, resized_width = smart_resize(
height,
width,
factor=size_factor,
min_pixels=min_pixels,
max_pixels=max_pixels,
)
image = image.resize((resized_width, resized_height), resample=RESIZE_RESAMPLE)
return image
def round_by_factor(number: int | float, factor: int) -> int:
return round(number / factor) * factor
def ceil_by_factor(number: int | float, factor: int) -> int:
return math.ceil(number / factor) * factor
def floor_by_factor(number: int | float, factor: int) -> int:
return math.floor(number / factor) * factor
async def resize_image_async(
image,
min_pixels: int = MIN_PIXELS,
max_pixels: int = MAX_PIXELS,
size_factor: int = IMAGE_FACTOR,
):
return resize_image(image, min_pixels, max_pixels, size_factor)
def smart_nframes(
ele: dict,
total_frames: int,
video_fps: int | float,
) -> int:
"""calculate the number of frames for video used for model inputs.
Args:
ele (dict): a dict contains the configuration of video.
support either `fps` or `nframes`:
- nframes: the number of frames to extract for model inputs.
- fps: the fps to extract frames for model inputs.
- min_frames: the minimum number of frames of the video, only used when fps is provided.
- max_frames: the maximum number of frames of the video, only used when fps is provided.
total_frames (int): the original total number of frames of the video.
video_fps (int | float): the original fps of the video.
Raises:
ValueError: nframes should in interval [FRAME_FACTOR, total_frames].
Returns:
int: the number of frames for video used for model inputs.
"""
assert not (
"fps" in ele and "nframes" in ele
), "Only accept either `fps` or `nframes`"
if "nframes" in ele:
nframes = round_by_factor(ele["nframes"], FRAME_FACTOR)
else:
fps = ele.get("fps", FPS)
min_frames = ceil_by_factor(ele.get("min_frames", FPS_MIN_FRAMES), FRAME_FACTOR)
max_frames = floor_by_factor(
ele.get("max_frames", min(FPS_MAX_FRAMES, total_frames)), FRAME_FACTOR
)
nframes = total_frames / video_fps * fps
if nframes > total_frames:
logger.warning(
f"smart_nframes: nframes[{nframes}] > total_frames[{total_frames}]"
)
nframes = min(min(max(nframes, min_frames), max_frames), total_frames)
nframes = floor_by_factor(nframes, FRAME_FACTOR)
if not (FRAME_FACTOR <= nframes and nframes <= total_frames):
raise ValueError(
f"nframes should in interval [{FRAME_FACTOR}, {total_frames}], but got {nframes}."
)
return nframes
# process video, qwen-specific
async def preprocess_video(
vr,
image_factor: int = IMAGE_FACTOR,
) -> torch.Tensor:
total_frames, video_fps = len(vr), vr.get_avg_fps()
nframes = smart_nframes({}, total_frames=total_frames, video_fps=video_fps)
idx = np.linspace(0, total_frames - 1, num=nframes, dtype=np.int64)
idx = np.unique(idx)
video_np = vr.get_batch(idx).asnumpy()
video = torch.from_numpy(video_np).pin_memory()
video = video.permute(0, 3, 1, 2) # Convert to TCHW format
nframes, _, height, width = video.shape
min_pixels = VIDEO_MIN_PIXELS
total_pixels = VIDEO_TOTAL_PIXELS
max_pixels = max(
min(VIDEO_MAX_PIXELS, total_pixels / nframes * FRAME_FACTOR),
int(min_pixels * 1.05),
)
resized_height, resized_width = smart_resize(
height,
width,
factor=image_factor,
min_pixels=min_pixels,
max_pixels=max_pixels,
)
video = torchvision.transforms.functional.resize(
video,
[resized_height, resized_width],
interpolation=InterpolationMode.BILINEAR,
)
video = video.permute(0, 2, 3, 1)
video = video.pin_memory()
video_metadata = {
"fps": video_fps,
"duration": total_frames / video_fps,
"total_num_frames": total_frames,
"frames_indices": idx,
"video_backend": "torchvision",
}
return video, video_metadata
# Compatible with Ernie-VL Series
class Ernie4_5_VLImageProcessor(SGLangBaseProcessor):
models = [Ernie4_5_VLMoeForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.hf_config = hf_config
self.model_type = hf_config.model_type
self.image_start_token_id = hf_config.image_start_token_id
self.image_end_token_id = hf_config.image_end_token_id
self.video_start_token_id = hf_config.video_start_token_id
self.video_end_token_id = hf_config.video_end_token_id
self.IMAGE_FACTOR = 28
self.MIN_PIXELS = 4 * 28 * 28
self.MAX_PIXELS = 16384 * 28 * 28
self.MAX_RATIO = 200
self.mm_tokens = MultimodalSpecialTokens(
image_token="<|IMAGE_START|><|image@placeholder|><|IMAGE_END|>",
video_token="<|VIDEO_START|><|video@placeholder|><|VIDEO_END|>",
image_token_id=hf_config.im_patch_id,
video_token_id=hf_config.im_patch_id, # image and video use the same token_id
).build(_processor)
self.tokenizer = self._processor.tokenizer
self.image_processor = self._processor.image_processor
def _pixel_values_norm(
self,
pixel_values: torch.Tensor,
mm_kwargs: object,
) -> torch.Tensor:
hf_config = self.hf_config
vision_config = hf_config.vision_config
image_processor = self.image_processor
image_mean_tensor = torch.tensor(
image_processor.image_mean, dtype=torch.float32
).reshape([1, 3, 1, 1])
image_std_tensor = torch.tensor(
image_processor.image_std, dtype=torch.float32
).reshape([1, 3, 1, 1])
rescale_factor = torch.tensor(
image_processor.rescale_factor, dtype=torch.float32
)
patch_size_squared = vision_config.patch_size**2
image_mean_tensor = image_mean_tensor.squeeze([-2, -1]).repeat_interleave(
patch_size_squared, -1
)
image_std_tensor = image_std_tensor.squeeze([-2, -1]).repeat_interleave(
patch_size_squared, -1
)
if not image_mean_tensor.is_contiguous():
image_mean_tensor = image_mean_tensor.contiguous()
if not image_std_tensor.is_contiguous():
image_std_tensor = image_std_tensor.contiguous()
pixel_values = (
rescale_factor * pixel_values.to(torch.float32) - image_mean_tensor
) / image_std_tensor
pixel_values = pixel_values.to(hf_config.dtype)
return pixel_values
def process_mm_data(
self, input_text, images=None, videos=None, audios=None, **kwargs
) -> dict:
"""
process multimodal data with transformers AutoProcessor
"""
if images:
kwargs["images"] = images
if self.image_config:
kwargs.setdefault("images_kwargs", {}).update(self.image_config)
if videos:
kwargs["videos"] = videos
if self.video_config:
kwargs.setdefault("videos_kwargs", {}).update(self.video_config)
processor = self._processor
if (
hasattr(processor, "image_processor")
and isinstance(processor.image_processor, BaseImageProcessor)
and not self.disable_fast_image_processor
):
if not _is_npu:
kwargs["device"] = "cuda"
result = processor.__call__(
text=[input_text],
padding=True,
return_tensors="pt",
**kwargs,
)
# Divide the processor_output into two modalities: image and video.
if result is not None:
pixel_values = result["images"]
if pixel_values is not None:
result["images"] = self._pixel_values_norm(pixel_values, kwargs)
for key in list(result.keys()):
if result[key] is None:
del result[key]
continue
if key == "grid_thw":
grid_thw = result["grid_thw"]
pixel_values_all = result["images"]
# Identify elements where the first
# dimension is greater than 1 and
# treat them as the video modality
mask = grid_thw[:, 0] > 1
result["video_grid_thw"] = grid_thw[mask]
result["image_grid_thw"] = grid_thw[~mask]
image_patch_num = result["image_grid_thw"].prod(dim=1).sum()
result["pixel_values"] = pixel_values_all[:image_patch_num]
result["pixel_values_videos"] = pixel_values_all[image_patch_num:]
del result["images"]
del result["grid_thw"]
# del empty result
if result["image_grid_thw"].numel() == 0:
del result["image_grid_thw"]
if result["pixel_values"].numel() == 0:
del result["pixel_values"]
if result["video_grid_thw"].numel() == 0:
del result["video_grid_thw"]
if result["pixel_values_videos"].numel() == 0:
del result["pixel_values_videos"]
if not self.keep_mm_feature_on_device:
# move feature tensors to cpu
for feature_name in self.FEATURE_NAMES:
if SGL_USE_CUDA_IPC:
pass
else:
if feature_name in result and isinstance(
result[feature_name], torch.Tensor
):
result[feature_name] = result[feature_name].to("cpu")
return result
def compute_mrope_positions(self, input_ids, mm_items):
image_grid_thw = None
video_grid_thw = None
for item in mm_items:
if "image_grid_thw" in item.model_specific_data:
image_grid_thw = item.model_specific_data["image_grid_thw"]
if "video_grid_thw" in item.model_specific_data:
video_grid_thw = item.model_specific_data["video_grid_thw"]
input_ids_tensor = torch.tensor(input_ids, dtype=torch.long).unsqueeze(0)
mrope_positions, mrope_position_delta = MRotaryEmbedding.get_rope_index_ernie45(
input_ids=input_ids_tensor,
hf_config=self.hf_config,
image_grid_thw=image_grid_thw,
video_grid_thw=video_grid_thw,
)
return mrope_positions.squeeze(1), mrope_position_delta
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
input_text,
request_obj,
*args,
**kwargs,
):
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
video_data=request_obj.video_data,
audio_data=request_obj.audio_data,
multimodal_tokens=self.mm_tokens,
)
# resize images if they are raw Image objects
resized_images = []
if base_output.images and isinstance(base_output.images[0], Image.Image):
for image in base_output.images:
resized_image = resize_image(image)
resized_images.append(resized_image)
base_output.images = resized_images
if base_output.videos:
videos_processed = [
await preprocess_video(video) for video in base_output.videos
]
base_output.videos, _ = map(list, zip(*videos_processed))
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
input_ids = input_ids.flatten()
mrope_positions, mrope_position_delta = MRotaryEmbedding.get_rope_index_ernie45(
input_ids=input_ids.unsqueeze(0),
hf_config=self.hf_config,
image_grid_thw=getattr(ret, "image_grid_thw", None),
video_grid_thw=getattr(ret, "video_grid_thw", None),
)
mrope_positions = mrope_positions.squeeze(1)
assert (
input_ids.shape[0] == mrope_positions.shape[-1]
), "input_ids and mrope_positions should have the same length"
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_start_id=self.image_start_token_id,
im_end_id=self.image_end_token_id,
im_token_id=self.mm_tokens.image_token_id,
video_token_id=self.mm_tokens.video_token_id,
mrope_positions=mrope_positions,
mrope_position_delta=mrope_position_delta,
)
@@ -0,0 +1,55 @@
import re
from typing import Dict, List, Union
from sglang.srt.managers.multimodal_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.gemma3_mm import Gemma3ForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import MultimodalSpecialTokens
# Copied from: https://github.com/huggingface/transformers/blob/main/src/transformers/models/gemma3/image_processing_gemma3_fast.py
# will be removed in the future
class Gemma3SGLangImageProcessor(SGLangBaseProcessor):
models = [Gemma3ForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.IM_START_TOKEN_ID = hf_config.boi_token_index
self.IM_END_TOKEN_ID = hf_config.eoi_token_index
self.mm_tokens = MultimodalSpecialTokens(
# The single, pre-expanded image token.
image_token="<start_of_image>",
image_token_id=hf_config.image_token_index,
# The regex that matches expanded image tokens.
image_token_regex=re.compile(
r"<start_of_image>(?:(?:<image_soft_token>)*<end_of_image>)?"
),
).build(_processor)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes, Dict]],
input_text,
request_obj,
*args,
**kwargs,
):
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
discard_alpha_channel=True,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_start_id=self.IM_START_TOKEN_ID,
im_end_id=self.IM_END_TOKEN_ID,
)
@@ -0,0 +1,71 @@
# Copyright 2025 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
from typing import Dict, List, Optional, Union
from sglang.srt.managers.multimodal_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.gemma3n_mm import Gemma3nForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import MultimodalSpecialTokens
class Gemma3nSGLangProcessor(SGLangBaseProcessor):
"""Multimodal processor for Gemma3n supporting image and audio inputs."""
models = [Gemma3nForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.IM_START_TOKEN_ID = hf_config.boi_token_id
self.IM_END_TOKEN_ID = hf_config.eoi_token_id
self.AUDIO_START_TOKEN_ID = hf_config.boa_token_id
self.AUDIO_END_TOKEN_ID = hf_config.eoa_token_id
self.mm_tokens = MultimodalSpecialTokens(
image_token="<image_soft_token>",
image_token_id=hf_config.image_token_id,
audio_token="<audio_soft_token>",
audio_token_id=hf_config.audio_token_id,
).build(_processor)
async def process_mm_data_async(
self,
image_data: Optional[List[Union[str, bytes, Dict]]] = None,
audio_data: Optional[List[Union[str, bytes, Dict]]] = None,
input_text: str = "",
request_obj=None,
*args,
**kwargs,
):
"""Process multimodal data including images and audio."""
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
audio_data=audio_data,
multimodal_tokens=self.mm_tokens,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.mm_tokens.image_token_id,
audio_token_id=self.mm_tokens.audio_token_id,
)
@@ -0,0 +1,158 @@
# Copyright 2025 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
import re
from typing import Dict, List, Optional, Union
import numpy as np
import torch
from sglang.srt.managers.multimodal_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.managers.schedule_batch import Modality, MultimodalProcessorOutput
from sglang.srt.models.gemma4_audio import _SSCP_CONV_STRIDE_SIZES
from sglang.srt.models.gemma4_mm import Gemma4ForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import MultimodalSpecialTokens
from sglang.srt.utils.video_decoder import VideoDecoderWrapper
class Gemma4SGLangProcessor(SGLangBaseProcessor):
"""Multimodal processor for Gemma4 supporting image, video, and audio inputs."""
models = [Gemma4ForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.IM_START_TOKEN_ID = hf_config.boi_token_id
self.IM_END_TOKEN_ID = hf_config.eoi_token_id
self.AUDIO_START_TOKEN_ID = hf_config.boa_token_id
self.AUDIO_END_TOKEN_ID = hf_config.eoa_token_id
self.mm_tokens = MultimodalSpecialTokens(
image_token="<|image|>",
image_token_id=hf_config.image_token_id,
image_token_regex=re.compile(
r"<\|image>(?:<\|image\|>)+<image\|>|<\|image\|>"
),
video_token="<|video|>",
video_token_id=hf_config.video_token_id,
video_token_regex=re.compile(
r"<\|image>(?:<\|video\|>)+<image\|>|<\|video\|>"
),
audio_token="<|audio|>",
audio_token_id=hf_config.audio_token_id,
audio_token_regex=re.compile(
r"<\|audio>(?:<\|audio\|>)+<audio\|>|<\|audio\|>"
),
).build(_processor)
# Register image-processor and video-processor outputs so they are stored on
# MultimodalDataItem via collect_mm_items_from_processor_output.
self.ATTR_NAME_TO_MODALITY["image_position_ids"] = Modality.IMAGE
self.ATTR_NAME_TO_MODALITY["video_position_ids"] = Modality.VIDEO
def _get_audio_pad_multiple(self) -> int:
"""Derive the waveform padding alignment from processor config.
The HF processor's ceil(duration_ms / audio_ms_per_token) formula can
overshoot by 1 token relative to what the SSCP convolutions produce.
Padding waveforms to a multiple of (hop_length * first_conv_stride)
aligns the two calculations.
See: gemma-4-eap-extras/examples/gemma-4-audio-examples.ipynb
"""
fe = getattr(self._processor, "feature_extractor", None)
hop = getattr(fe, "hop_length", 160)
first_stride = _SSCP_CONV_STRIDE_SIZES[0][0]
return hop * first_stride
def _video_decoder_to_tensor(self, vdw: VideoDecoderWrapper) -> torch.Tensor:
"""Convert a VideoDecoderWrapper to a (sampled_frames, C, H, W) uint8 tensor.
SGLang's load_video returns VideoDecoderWrapper which the HF
Gemma4VideoProcessor does not recognise (expects torch.Tensor or
np.ndarray). We replicate HF's uniform frame sampling here to
avoid materialising the entire video in memory, then delegate the
rest (resize, patchify, position IDs) to the HF video processor.
"""
total = len(vdw)
num_frames = getattr(
getattr(self._processor, "video_processor", None),
"num_frames",
32,
)
if total <= num_frames:
indices = list(range(total))
else:
indices = torch.arange(0, total, total / num_frames).int().tolist()
frames_np = vdw.get_frames_at(indices) # (N, H, W, C)
return torch.from_numpy(frames_np).permute(0, 3, 1, 2).contiguous()
def process_mm_data(
self, input_text, images=None, videos=None, audios=None, **kwargs
):
if audios:
pad_multiple = self._get_audio_pad_multiple()
padded = []
for a in audios:
a = np.asarray(a)
remainder = len(a) % pad_multiple
if remainder != 0:
a = np.pad(a, (0, pad_multiple - remainder), mode="constant")
padded.append(a)
audios = padded
if videos:
videos = [
(
self._video_decoder_to_tensor(v)
if isinstance(v, VideoDecoderWrapper)
else v
)
for v in videos
]
kwargs.setdefault("do_sample_frames", False)
return super().process_mm_data(
input_text, images=images, videos=videos, audios=audios, **kwargs
)
async def process_mm_data_async(
self,
image_data: Optional[List[Union[str, bytes, Dict]]] = None,
audio_data: Optional[List[Union[str, bytes, Dict]]] = None,
input_text: str = "",
request_obj=None,
*args,
**kwargs,
):
"""Process multimodal data including images, video, and audio."""
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
video_data=request_obj.video_data if request_obj else None,
audio_data=audio_data,
multimodal_tokens=self.mm_tokens,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.mm_tokens.image_token_id,
video_token_id=self.mm_tokens.video_token_id,
audio_token_id=self.mm_tokens.audio_token_id,
)
@@ -0,0 +1,33 @@
# Copyright 2026 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
from sglang.srt.models.gemma4_unified import Gemma4UnifiedForConditionalGeneration
from sglang.srt.multimodal.processors.gemma4 import Gemma4SGLangProcessor
class Gemma4UnifiedSGLangProcessor(Gemma4SGLangProcessor):
"""Multimodal processor for the encoder-free unified Gemma4 (image/video/audio).
Identical to :class:`Gemma4SGLangProcessor` except for audio padding: the
unified model has no SSCP conformer, so the waveform is simply chunked into
fixed ``audio_samples_per_token`` (640) frames. Padding the waveform up to a
multiple of that frame size keeps ``ceil(num_samples / spt)`` consistent with
the number of valid frames the feature extractor emits.
"""
models = [Gemma4UnifiedForConditionalGeneration]
def _get_audio_pad_multiple(self) -> int:
fe = getattr(self._processor, "feature_extractor", None)
return getattr(fe, "audio_samples_per_token", 640)
@@ -0,0 +1,123 @@
from typing import List, Union
from sglang.srt.layers.rotary_embedding import MRotaryEmbedding
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.glm4v import Glm4vForConditionalGeneration
from sglang.srt.models.glm4v_moe import Glm4vMoeForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.multimodal.processors.base_processor import (
MultimodalSpecialTokens,
)
try:
from sglang.srt.models.glm_ocr import GlmOcrForConditionalGeneration
except ImportError:
GlmOcrForConditionalGeneration = None
class Glm4vImageProcessor(SGLangBaseProcessor):
models = [
m
for m in [
Glm4vForConditionalGeneration,
Glm4vMoeForConditionalGeneration,
GlmOcrForConditionalGeneration,
]
if m is not None
]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
# GLM-V specific tokens
self.IMAGE_TOKEN = "<|image|>"
self.VIDEO_TOKEN = "<|video|>"
self.IMAGE_START_TOKEN = "<|begin_of_image|>"
self.IMAGE_END_TOKEN = "<|end_of_image|>"
self.VIDEO_START_TOKEN = "<|begin_of_video|>"
self.VIDEO_END_TOKEN = "<|end_of_video|>"
# Token IDs
self.IM_TOKEN_ID = hf_config.image_token_id
self.VIDEO_TOKEN_ID = hf_config.video_token_id
self.IMAGE_START_TOKEN_ID = hf_config.image_start_token_id
self.IMAGE_END_TOKEN_ID = hf_config.image_end_token_id
self.VIDEO_START_TOKEN_ID = hf_config.video_start_token_id
self.VIDEO_END_TOKEN_ID = hf_config.video_end_token_id
# Vision config
self.IMAGE_FACTOR = 28
self.MIN_PIXELS = 112 * 112
self.MAX_PIXELS = 30000 * 28 * 28 * 2
self.mm_tokens = MultimodalSpecialTokens(
image_token=self.IMAGE_TOKEN,
image_token_id=self.IM_TOKEN_ID,
video_token=self.VIDEO_TOKEN,
# Note: For GLM4v videos, it uses the video token before tokenization but uses image token after tokenization
video_token_id=self.IM_TOKEN_ID,
).build(_processor)
def compute_mrope_positions(self, input_ids, mm_items):
image_grid_thw = None
video_grid_thw = None
for item in mm_items:
if "image_grid_thw" in item.model_specific_data:
image_grid_thw = item.model_specific_data["image_grid_thw"]
if "video_grid_thw" in item.model_specific_data:
video_grid_thw = item.model_specific_data["video_grid_thw"]
import torch
input_ids_tensor = torch.tensor(input_ids, dtype=torch.long).unsqueeze(0)
attention_mask = torch.ones_like(input_ids_tensor)
mrope_positions, mrope_position_delta = MRotaryEmbedding.get_rope_index_glm4v(
input_ids=input_ids_tensor,
hf_config=self.hf_config,
image_grid_thw=image_grid_thw,
video_grid_thw=video_grid_thw,
attention_mask=attention_mask,
)
return mrope_positions.squeeze(1), mrope_position_delta
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
input_text,
request_obj,
*args,
**kwargs,
):
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
video_data=request_obj.video_data,
multimodal_tokens=self.mm_tokens,
)
if base_output.videos:
base_output.videos = request_obj.video_data
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
input_ids = input_ids.flatten()
mrope_positions, mrope_position_delta = MRotaryEmbedding.get_rope_index_glm4v(
input_ids=input_ids.unsqueeze(0),
hf_config=self.hf_config,
image_grid_thw=getattr(ret, "image_grid_thw", None),
video_grid_thw=getattr(ret, "video_grid_thw", None),
attention_mask=getattr(ret, "attention_mask", None),
)
mrope_positions = mrope_positions.squeeze(1)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.mm_tokens.image_token_id,
video_token_id=self.mm_tokens.video_token_id,
mrope_positions=mrope_positions,
mrope_position_delta=mrope_position_delta,
)
@@ -0,0 +1,316 @@
import logging
from typing import List, Union
import torch
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.glm_image_vl import GlmImageForConditionalGeneration
logger = logging.getLogger(__name__)
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.multimodal.processors.base_processor import (
MultimodalSpecialTokens,
)
class GlmImageProcessor(SGLangBaseProcessor):
models = [GlmImageForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.IMAGE_TOKEN = "<|image|>"
self.IMAGE_START_TOKEN = "<|begin_of_image|>"
self.IMAGE_END_TOKEN = "<|end_of_image|>"
self.IM_TOKEN_ID = hf_config.image_token_id
self.IMAGE_START_TOKEN_ID = hf_config.image_start_token_id
self.IMAGE_END_TOKEN_ID = hf_config.image_end_token_id
self.mm_tokens = MultimodalSpecialTokens(
image_token=self.IMAGE_TOKEN,
image_token_id=self.IM_TOKEN_ID,
).build(_processor)
def _compute_glm_image_mrope_positions(
self,
input_ids: torch.Tensor,
image_grid_thw: torch.Tensor,
):
"""Compute MRoPE positions for GlmImage (image generation model).
For source images (prefill), creates 2D spatial encoding.
For target image grids (decode), pre-computes 2D spatial positions
so each generated token gets proper (temporal, height, width) coordinates.
For text tokens, uses sequential positions across all 3 dims.
The returned position_ids has shape (3, prefill_len + decode_len) where
decode_len covers the target grid tokens. During decode, the model looks
up positions by index (seq_len - 1) to get proper 2D spatial encoding.
"""
seq_len = input_ids.shape[0]
device = input_ids.device
image_start_token_id = self.IMAGE_START_TOKEN_ID
image_end_token_id = self.IMAGE_END_TOKEN_ID
text_positions = torch.arange(seq_len, device=device).unsqueeze(0).repeat(3, 1)
# Find image boundaries
image_end_positions = torch.where(input_ids == image_end_token_id)[0]
image_start_positions = torch.where(input_ids == image_start_token_id)[0] + 1
current_pos = 0
prev_image_end = 0
position_id_parts = []
num_complete_images = len(image_end_positions)
for img_idx in range(min(num_complete_images, len(image_start_positions))):
start = image_start_positions[img_idx].item()
end = image_end_positions[img_idx].item()
if image_grid_thw is None or img_idx >= len(image_grid_thw):
break
_, height, width = image_grid_thw[img_idx].tolist()
height = int(height)
width = int(width)
# Text tokens before this image
llm_pos_length = start - prev_image_end
llm_position_ids = text_positions[
:, current_pos : current_pos + llm_pos_length
]
current_pos += llm_pos_length
# Image tokens with 2D spatial encoding
image_seq_length = height * width
position_width = torch.arange(
current_pos, current_pos + width, device=device
).repeat(height)
position_height = torch.arange(
current_pos, current_pos + height, device=device
).repeat_interleave(width)
position_temporal = torch.full(
(image_seq_length,), current_pos, device=device, dtype=torch.long
)
vision_position_ids = torch.stack(
[position_temporal, position_height, position_width], dim=0
)
current_pos += max(height, width)
prev_image_end = end
position_id_parts.append(
torch.cat([llm_position_ids, vision_position_ids], dim=-1)
)
# Remaining text tokens
end_length = seq_len - prev_image_end
llm_position_ids = text_positions[:, current_pos : current_pos + end_length]
current_pos += end_length
position_id_parts.append(llm_position_ids)
# Prefill positions
position_ids = torch.cat(position_id_parts, dim=-1)
# --- Decode positions for target (incomplete) image grids ---
# Target grids are those in image_grid_thw beyond the complete images.
# These correspond to the image tokens the model will generate autoregressively.
# Each generated token needs a 2D spatial position based on its row/col
# in the target grid, matching HF's _cached_decode_position_ids logic.
if image_grid_thw is not None:
total_grids = len(image_grid_thw)
num_decode_grids = total_grids - num_complete_images
if num_decode_grids > 0:
decode_pos = current_pos
decode_parts = []
# Iterate in reverse order to match HF's get_rope_index:
# for i in range(1, num_decode_grids + 1): grid_idx = -i
for i in range(1, num_decode_grids + 1):
grid_idx = -i
_, h, w = image_grid_thw[grid_idx].tolist()
h, w = int(h), int(w)
total_tokens = h * w
h_indices = (
torch.arange(h, device=device)
.unsqueeze(1)
.expand(h, w)
.flatten()
)
w_indices = (
torch.arange(w, device=device)
.unsqueeze(0)
.expand(h, w)
.flatten()
)
decode_temporal = torch.full(
(total_tokens,), decode_pos, device=device, dtype=torch.long
)
decode_height = decode_pos + h_indices
decode_width = decode_pos + w_indices
decode_parts.append(
torch.stack(
[decode_temporal, decode_height, decode_width], dim=0
)
)
decode_pos += max(h, w)
# End marker for tokens after target grid
end_marker = torch.full(
(3, 1), decode_pos, device=device, dtype=torch.long
)
decode_parts.append(end_marker)
decode_positions = torch.cat(decode_parts, dim=1)
position_ids = torch.cat([position_ids, decode_positions], dim=1)
mrope_position_delta = torch.zeros([1], dtype=torch.long, device=device)
return position_ids, mrope_position_delta
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
input_text,
request_obj,
*args,
**kwargs,
):
image_grid_thw = None
# When input_text is a list of ints (pre-tokenized input_ids passed
# directly via engine.generate(input_ids=...)), preserve them as-is
# to avoid lossy decode→re-tokenize roundtrip.
if (
isinstance(input_text, list)
and len(input_text)
and isinstance(input_text[0], int)
):
input_ids = torch.tensor(input_text, dtype=torch.long)
mm_items = []
if image_data:
for img in image_data:
if not isinstance(img, dict):
continue
# Create proper mm_items from processor_output dicts
# so pixel_values reach the vision encoder.
# Only create items when actual pixel features are present.
if "pixel_values" in img:
items = self.collect_mm_items_from_processor_output(img)
for item in items:
if img.get("format") == "processor_output":
from sglang.srt.managers.schedule_batch import (
MultimodalInputFormat,
)
item.format = MultimodalInputFormat.PROCESSOR_OUTPUT
# Filter image_grid_thw on mm_item to only include
# source grids that have corresponding pixel_values.
# Target generation grids (no pixels) must NOT go to
# vision encoder — they are only for MRoPE positions.
pv = getattr(item, "feature", None)
grid = getattr(item, "image_grid_thw", None)
if pv is not None and grid is not None:
total_pixels = pv.shape[0]
source_patches = 0
source_grid_count = 0
for gi in range(len(grid)):
patches = int(grid[gi].prod().item())
if source_patches + patches <= total_pixels:
source_patches += patches
source_grid_count += 1
else:
break
if source_grid_count < len(grid):
item.image_grid_thw = grid[:source_grid_count]
mm_items.extend(items)
# Extract full image_grid_thw for MRoPE position computation
# (includes both source and target grids)
if "image_grid_thw" in img:
grid = img["image_grid_thw"]
if isinstance(grid, torch.Tensor):
image_grid_thw = grid
if isinstance(grid, list):
image_grid_thw = torch.tensor(grid)
# Add offsets to all mm_items (matching base_processor behavior).
# Offsets tell the chunked prefill where image tokens are in input_ids.
for mm_item in mm_items:
mm_token_id = self.mm_tokens.get_token_id_by_modality(mm_item.modality)
if mm_token_id is not None:
mm_item.offsets = self.get_mm_items_offset(
input_ids=input_ids,
mm_token_id=mm_token_id,
)
else:
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
)
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
input_ids = input_ids.flatten()
# Get full image_grid_thw for MRoPE (includes target grids)
image_grid_thw = getattr(ret, "image_grid_thw", None)
# Filter mm_item grids to only source grids (with pixel_values).
# Target generation grids must NOT go to vision encoder.
for item in mm_items:
pv = getattr(item, "feature", None)
grid = getattr(item, "image_grid_thw", None)
if pv is not None and grid is not None:
total_pixels = pv.shape[0]
source_patches = 0
source_grid_count = 0
for gi in range(len(grid)):
patches = int(grid[gi].prod().item())
if source_patches + patches <= total_pixels:
source_patches += patches
source_grid_count += 1
else:
break
if source_grid_count < len(grid):
item.image_grid_thw = grid[:source_grid_count]
# Fallback: get image_grid_thw from mm_items or image_data dicts
if image_grid_thw is None:
grids = []
for item in mm_items:
g = getattr(item, "image_grid_thw", None)
if g is not None:
grids.append(g if g.dim() == 2 else g.unsqueeze(0))
if grids:
image_grid_thw = torch.cat(grids, dim=0)
if image_grid_thw is None and image_data:
for img in image_data:
if isinstance(img, dict) and "image_grid_thw" in img:
image_grid_thw = img["image_grid_thw"]
if isinstance(image_grid_thw, torch.Tensor):
break
mrope_positions, mrope_position_delta = self._compute_glm_image_mrope_positions(
input_ids=input_ids,
image_grid_thw=image_grid_thw,
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.mm_tokens.image_token_id,
mrope_positions=mrope_positions,
mrope_position_delta=mrope_position_delta,
)
@@ -0,0 +1,54 @@
import re
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.glmasr import GlmAsrForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
class GlmAsrProcessor(BaseMultimodalProcessor):
models = [GlmAsrForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.AUDIO_TOKEN = "<|begin_of_audio|><|pad|><|end_of_audio|>"
self.AUDIO_TOKEN_REGEX = re.compile(
r"<\|begin_of_audio\|><\|pad\|><\|end_of_audio\|>"
)
# Collect special token ids
tokenizer = self._processor.tokenizer
self.audio_start_id = tokenizer.convert_tokens_to_ids("<|begin_of_audio|>")
self.audio_token_id = tokenizer.convert_tokens_to_ids("<|pad|>")
self.audio_end_id = tokenizer.convert_tokens_to_ids("<|end_of_audio|>")
self.mm_tokens = MultimodalSpecialTokens(
audio_token=self.AUDIO_TOKEN,
audio_token_regex=self.AUDIO_TOKEN_REGEX,
audio_token_id=self.audio_token_id,
).build(_processor)
async def process_mm_data_async(
self,
audio_data,
input_text,
**kwargs,
):
base_output = await self.load_mm_data(
prompt=input_text,
audio_data=audio_data,
multimodal_tokens=self.mm_tokens,
)
if base_output is None:
return None
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids.tolist(),
audio_start_id=self.audio_start_id,
audio_token_id=self.audio_token_id,
audio_end_id=self.audio_end_id,
)
@@ -0,0 +1,122 @@
import time
from typing import List, Union
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.models.interns1pro import InternS1ProForConditionalGeneration
from sglang.srt.multimodal.processors.qwen_vl import (
QwenVLImageProcessor,
preprocess_video,
)
from sglang.utils import logger
class InternS1_1ImageProcessor(QwenVLImageProcessor):
models = [
InternS1ProForConditionalGeneration,
]
def get_mm_data(self, prompt, embeddings, img_grid_thw):
input_ids, offsets = self.build_input_ids(prompt, img_grid_thw)
mm_items = [
MultimodalDataItem(
modality=Modality.IMAGE,
offsets=offsets,
precomputed_embeddings=embeddings,
)
]
return MultimodalProcessorOutput(
input_ids=input_ids,
mm_items=mm_items,
im_start_id=self.IM_START_TOKEN_ID,
im_end_id=self.IM_END_TOKEN_ID,
im_token_id=self.mm_tokens.image_token_id,
video_token_id=self.mm_tokens.video_token_id,
audio_token_id=self.mm_tokens.audio_token_id,
)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
input_text,
request_obj,
*args,
**kwargs,
):
entry_time = time.perf_counter()
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
video_data=request_obj.video_data,
audio_data=request_obj.audio_data,
multimodal_tokens=self.mm_tokens,
)
load_time = time.perf_counter()
rid = getattr(request_obj, "rid", "anonymous_rid")
video_metadata = None
if base_output.videos:
videos_processed = [
await preprocess_video(video, video_config=self.video_config)
for video in base_output.videos
]
base_output.videos, video_metadata = map(list, zip(*videos_processed))
preprocess_time = time.perf_counter()
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output,
self.mm_tokens,
video_metadata=video_metadata,
do_sample_frames=False,
)
second_per_grid_ts = getattr(ret, "second_per_grid_ts", None)
if second_per_grid_ts is None:
second_per_grid_ts = getattr(ret, "video_second_per_grid", None)
process_time = time.perf_counter()
input_ids = input_ids.flatten()
image_grid_thw = None
if hasattr(ret, "image_grid_thw"):
image_grid_thw = ret.image_grid_thw
if image_grid_thw is None and image_data and isinstance(image_data[0], dict):
image_grid_thw = image_data[0].get("image_grid_thw")
video_grid_thw = None
if hasattr(ret, "video_grid_thw"):
video_grid_thw = ret.video_grid_thw
if video_grid_thw is None and request_obj.video_data:
first_video = request_obj.video_data[0]
if isinstance(first_video, dict):
video_grid_thw = first_video.get("video_grid_thw")
get_rope_index_time = time.perf_counter()
logger.debug(
f"[QwenVLProcessor Perf] {rid=}, "
f"load_time: {(load_time - entry_time) * 1000:.2f} ms, "
f"preprocess_time: {(preprocess_time - load_time) * 1000:.2f} ms, "
f"process_time: {(process_time - preprocess_time) * 1000:.2f} ms, "
f"get_rope_index_time: {(get_rope_index_time - process_time) * 1000:.2f} ms, "
f"total_time: {(get_rope_index_time - entry_time) * 1000:.2f} ms"
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_start_id=self.vision_start_token_id,
im_end_id=self.vision_end_token_id,
im_token_id=self.mm_tokens.image_token_id,
video_token_id=self.mm_tokens.video_token_id,
audio_token_id=self.mm_tokens.audio_token_id,
)
@@ -0,0 +1,739 @@
# Adapted from https://huggingface.co/OpenGVLab/InternVL2-4B/blob/main/modeling_intern_vit.py
import logging
from functools import lru_cache
from typing import List
import numpy as np
import torch
from PIL import Image
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.models.interns1 import InternS1ForConditionalGeneration
from sglang.srt.models.internvl import InternVLChatModel
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
BaseMultiModalProcessorOutput,
MultimodalSpecialTokens,
)
from sglang.srt.utils import get_device
from sglang.srt.utils.video_decoder import VideoDecoderWrapper
logger = logging.getLogger(__name__)
class InternVLProcessor(BaseMultimodalProcessor):
models = [InternVLChatModel, InternS1ForConditionalGeneration]
gpu_image_decode = False # InternVL HF processor does not support tensor inputs
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]
IMAGE_MAX_NUM = 12
DEFAULT_VIDEO_NUM_FRAMES = 32
VIDEO_MAX_NUM = 1
VIDEO_USE_THUMBNAIL = False
CONTEXT_FALLBACK = 40960
CONTEXT_RESERVED = 256
# OpenAI multimodal placeholder tokens
IMAGE_PLACEHOLDER_TOKEN = "<image>"
VIDEO_PLACEHOLDER_TOKEN = "<video>"
IMG_START = "<img>"
IMG_END = "</img>"
IMG_CONTEXT = "<IMG_CONTEXT>"
@staticmethod
@lru_cache(maxsize=1)
def _get_normalize_tensors(device="cuda", dtype=torch.float32):
mean = torch.tensor(
InternVLProcessor.IMAGENET_MEAN, device=device, dtype=dtype
).view(-1, 1, 1)
std = torch.tensor(
InternVLProcessor.IMAGENET_STD, device=device, dtype=dtype
).view(-1, 1, 1)
return mean, std
def __init__(self, hf_config, server_args, _image_processor, *args, **kwargs):
super().__init__(hf_config, server_args, _image_processor, *args, **kwargs)
image_size = (
getattr(hf_config, "force_image_size", None)
or hf_config.vision_config.image_size
)
patch_size = hf_config.vision_config.patch_size
if isinstance(image_size, list):
image_size = image_size[0]
if isinstance(patch_size, list):
patch_size = patch_size[0]
if hasattr(self._processor, "tokenizer"):
tokenizer = self._processor.tokenizer
else:
tokenizer = self._processor
self.tokenizer = tokenizer
# Support both InternVL (llm_config) and InternS1 (text_config).
# Different multimodal models use different field names for the text backbone:
# - InternVL uses: hf_config.llm_config
# - InternS1 uses: hf_config.text_config
# - Some store architectures at top-level
text_cfg = (
getattr(hf_config, "llm_config", None)
or getattr(hf_config, "text_config", None)
or hf_config
)
llm_arch = (getattr(text_cfg, "architectures", []) or [None])[0]
self.llm_arch = llm_arch
video_token_map = {
"Qwen2ForCausalLM": "<|video_pad|>",
"Qwen3ForCausalLM": "<|video_pad|>",
"Qwen3MoeForCausalLM": "<|video_pad|>",
"GptOssForCausalLM": "<|reserved_200000|>",
}
self.VIDEO_CONTEXT_TOKEN = video_token_map.get(llm_arch, None)
self.video_token_id = (
tokenizer.convert_tokens_to_ids(self.VIDEO_CONTEXT_TOKEN)
if self.VIDEO_CONTEXT_TOKEN
else None
)
self.image_token_id = (
tokenizer.convert_tokens_to_ids(self.IMG_CONTEXT)
if self.IMG_CONTEXT
else None
)
self.num_image_token = int(
(image_size // patch_size) ** 2 * (hf_config.downsample_ratio**2)
)
self.img_start_token_id = tokenizer.convert_tokens_to_ids(self.IMG_START)
self.img_end_token_id = tokenizer.convert_tokens_to_ids(self.IMG_END)
# Placeholder token use <image>/<video>
# Offset token id use IMG_CONTEXT / VIDEO_CONTEXT
self.mm_tokens = MultimodalSpecialTokens(
image_token=self.IMAGE_PLACEHOLDER_TOKEN,
image_token_id=self.image_token_id,
video_token=self.VIDEO_PLACEHOLDER_TOKEN,
video_token_id=self.video_token_id,
).build(_image_processor)
# Cache token id for IMG_CONTEXT (used by both branches)
self.img_context_token_id = tokenizer.convert_tokens_to_ids(self.IMG_CONTEXT)
# InternLM2 legacy multimodal tokens: use <IMG_CONTEXT> as placeholder
self.mm_tokens_internlm2 = MultimodalSpecialTokens(
image_token=self.IMG_CONTEXT,
image_token_id=self.img_context_token_id,
).build(_image_processor)
self.max_context_len = (
getattr(server_args, "context_length", None)
or getattr(server_args, "max_context_len", None)
or getattr(hf_config, "max_position_embeddings", None)
or getattr(text_cfg, "max_position_embeddings", None)
or self.CONTEXT_FALLBACK
)
@staticmethod
def dynamic_preprocess(
tensor, image_size=448, max_num=IMAGE_MAX_NUM, use_thumbnail=False
):
# Tensor: (C,H,W) float on GPU
C, H, W = tensor.shape
aspect_ratio = W / H
# Generate all possible aspect ratios
target_ratios = set(
(i, j)
for n in range(1, max_num + 1)
for i in range(1, n + 1)
for j in range(1, n + 1)
if i * j <= max_num
)
target_ratios = sorted(target_ratios, key=lambda x: x[0] * x[1])
# Find closest ratio
best_ratio_diff = float("inf")
best_ratio = (1, 1)
for x, y in target_ratios:
target_ar = x / y
diff = abs(aspect_ratio - target_ar)
blocks = x * y
best_blocks = best_ratio[0] * best_ratio[1]
if diff < best_ratio_diff:
best_ratio_diff = diff
best_ratio = (x, y)
elif diff == best_ratio_diff and blocks > best_blocks:
best_ratio = (x, y)
target_w, target_h = image_size * best_ratio[0], image_size * best_ratio[1]
blocks = best_ratio[0] * best_ratio[1]
# Resize on GPU
resized = torch.nn.functional.interpolate(
tensor.unsqueeze(0),
size=(target_h, target_w),
mode="bicubic",
align_corners=False,
).squeeze(0)
# Split into tiles
tiles = []
for i in range(blocks):
x = (i % best_ratio[0]) * image_size
y = (i // best_ratio[0]) * image_size
tile = resized[:, y : y + image_size, x : x + image_size]
tiles.append(tile)
# Add thumbnail if needed
if use_thumbnail and len(tiles) > 1:
thumb = torch.nn.functional.interpolate(
tensor.unsqueeze(0),
size=(image_size, image_size),
mode="bicubic",
align_corners=False,
).squeeze(0)
tiles.append(thumb)
return torch.stack(tiles).to(torch.bfloat16)
@staticmethod
def _open_video_reader(path: str):
return VideoDecoderWrapper(path)
def _ensure_placeholders_before_assistant(
self, prompt: str, placeholder: str, want: int
) -> str:
if want <= 0:
return prompt
have = (prompt or "").count(placeholder)
missing = want - have
if missing <= 0:
return prompt
insert = "\n" + "\n".join([placeholder] * missing) + "\n"
marker = "<|im_start|>assistant"
idx = (prompt or "").rfind(marker)
if idx != -1:
return (prompt or "")[:idx] + insert + (prompt or "")[idx:]
return (prompt or "") + insert
def _token_len(self, text: str) -> int:
try:
ids = self.tokenizer(text, return_tensors="pt")["input_ids"].flatten()
return int(ids.numel())
except Exception:
return 0
def _resolve_video_num_frames(
self, *, requested: int, num_videos: int, text_len: int, image_tile_cnt: int
) -> int:
if num_videos <= 0:
return 0
if not self.VIDEO_CONTEXT_TOKEN or not self.video_token_id:
return 0
image_tokens = image_tile_cnt * self.num_image_token
budget = (
int(self.max_context_len)
- int(text_len)
- int(image_tokens)
- int(self.CONTEXT_RESERVED)
)
if budget <= 0:
return 1
max_total_frames = max(1, budget // self.num_image_token)
frames_per_video = max(1, max_total_frames // max(num_videos, 1))
return max(1, min(int(requested), int(frames_per_video)))
@staticmethod
def _has_special_format(image_data, video_data):
"""Check if any input items use processor_output or precomputed_embedding format."""
for data in list(image_data or []) + list(video_data or []):
if isinstance(data, dict) and data.get("format") in (
"processor_output",
"precomputed_embedding",
):
return True
return False
async def _process_special_format(
self, image_data, video_data, input_text, request_obj, **kwargs
):
"""Handle processor_output and precomputed_embedding input formats.
Delegates to the base class process_and_combine_mm_data which has
built-in support for these formats.
"""
# When user provides input_ids directly, input_text may be a list of ints
if isinstance(input_text, list):
user_input_ids = input_text
prompt = ""
else:
user_input_ids = None
prompt = input_text or ""
# When the prompt is empty (user provided input_ids directly),
# load_mm_data can't match multimodal tokens to data items.
# Build BaseMultiModalProcessorOutput directly from the dict items.
if not prompt and (image_data or video_data):
images = [d for d in (image_data or []) if isinstance(d, dict)]
videos = [d for d in (video_data or []) if isinstance(d, dict)]
# Raise if raw (non-dict) images/videos were silently filtered out.
# InternVL cannot process raw images without a text prompt because
# dynamic tiling and placeholder expansion require the prompt string.
raw_img_dropped = len(image_data or []) - len(images)
raw_vid_dropped = len(video_data or []) - len(videos)
if raw_img_dropped > 0 or raw_vid_dropped > 0:
raise ValueError(
f"[internvl] Cannot process raw images/videos with pre-tokenized "
f"input_ids. Provide multimodal data in 'processor_output' or "
f"'precomputed_embedding' format, or use a text prompt instead. "
f"(raw images dropped: {raw_img_dropped}, "
f"raw videos dropped: {raw_vid_dropped})"
)
base_output = BaseMultiModalProcessorOutput(
input_text=prompt,
images=images,
videos=videos,
)
else:
base_output = await self.load_mm_data(
prompt=prompt,
image_data=image_data,
video_data=video_data,
multimodal_tokens=self.mm_tokens,
discard_alpha_channel=True,
)
mm_items, input_ids_tensor, ret = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
# If user provided input_ids directly, use those and recompute offsets
if user_input_ids is not None:
input_ids_tensor = torch.tensor(user_input_ids, dtype=torch.long)
for mm_item in mm_items:
if (
mm_item.modality == Modality.VIDEO
and self.video_token_id is not None
):
mm_token_id = self.video_token_id
else:
mm_token_id = self.img_context_token_id
mm_item.offsets = self.get_mm_items_offset(
input_ids=input_ids_tensor,
mm_token_id=mm_token_id,
)
return MultimodalProcessorOutput(
input_ids=input_ids_tensor.flatten().tolist(),
mm_items=mm_items,
im_start_id=self.img_start_token_id,
im_end_id=self.img_end_token_id,
im_token_id=self.img_context_token_id,
video_token_id=self.video_token_id,
)
async def process_mm_data_async(
self, image_data, input_text, request_obj, **kwargs
):
video_data = getattr(request_obj, "video_data", None) or []
# Handle processor_output and precomputed_embedding formats
if isinstance(input_text, list) or self._has_special_format(
image_data, video_data
):
return await self._process_special_format(
image_data=image_data,
video_data=video_data,
input_text=input_text,
request_obj=request_obj,
**kwargs,
)
is_internlm2 = self.llm_arch == "InternLM2ForCausalLM"
if is_internlm2:
return await self.process_internlm2_mm_data_async(
image_data=image_data,
input_text=input_text,
request_obj=request_obj,
**kwargs,
)
else:
# Default branch uses OpenAI-style placeholders
return await self.process_qwen_mm_data_async(
image_data=image_data,
input_text=input_text,
request_obj=request_obj,
**kwargs,
)
async def process_qwen_mm_data_async(
self, image_data, input_text, request_obj, **kwargs
):
img_max_num = (
getattr(request_obj, "image_max_dynamic_patch", None)
or getattr(request_obj, "max_dynamic_patch", None)
or kwargs.get("image_max_dynamic_patch")
or kwargs.get("max_dynamic_patch")
or self.IMAGE_MAX_NUM
)
img_max_num = max(1, int(img_max_num))
vid_max_num = (
getattr(request_obj, "video_max_dynamic_patch", None)
or getattr(request_obj, "max_dynamic_patch", None)
or kwargs.get("video_max_dynamic_patch")
or kwargs.get("max_dynamic_patch")
or self.VIDEO_MAX_NUM
)
vid_max_num = max(1, int(vid_max_num))
# Qwen/Qwen3 branch: OpenAI-style placeholders <image>/<video>
prompt = input_text or ""
video_data = getattr(request_obj, "video_data", None) or []
if image_data:
prompt = self._ensure_placeholders_before_assistant(
prompt, self.IMAGE_PLACEHOLDER_TOKEN, len(image_data)
)
if video_data:
prompt = self._ensure_placeholders_before_assistant(
prompt, self.VIDEO_PLACEHOLDER_TOKEN, len(video_data)
)
logger.info(
"[internvl][qwen] placeholders image=%d video=%d",
prompt.count(self.IMAGE_PLACEHOLDER_TOKEN),
prompt.count(self.VIDEO_PLACEHOLDER_TOKEN),
)
base_output = await self.load_mm_data(
prompt=prompt,
image_data=image_data,
video_data=video_data,
multimodal_tokens=self.mm_tokens, # expects <image>/<video>
discard_alpha_channel=True,
)
logger.info(
"[internvl][qwen] loaded images=%d videos=%d",
len(base_output.images),
len(base_output.videos),
)
mean, std = self._get_normalize_tensors(device=get_device())
# ----- Images -> tiles -----
num_patches_list: List[int] = []
pixel_values_list: List[torch.Tensor] = []
for image in base_output.images:
if isinstance(image, Image.Image):
img_np = np.array(image.convert("RGB"))
tensor = (
torch.from_numpy(img_np).permute(2, 0, 1).to(get_device()).float()
/ 255.0
)
else:
tensor = image.to(get_device())
tensor = (tensor - mean) / std
tiles = self.dynamic_preprocess(
tensor, image_size=448, max_num=img_max_num, use_thumbnail=True
)
pixel_values_list.append(tiles)
num_patches_list.append(int(tiles.shape[0]))
if image_data and not pixel_values_list:
raise ValueError(
"[internvl][qwen] image_data provided but no images parsed from prompt placeholders"
)
image_tensor = (
torch.cat(pixel_values_list, dim=0) if pixel_values_list else None
)
# ----- Videos -> frame tiles (optional) -----
video_tensor = None
video_patch_lists = []
video_pixel_values = []
requested_frames = int(
kwargs.get("video_num_frames", self.DEFAULT_VIDEO_NUM_FRAMES)
)
num_frames = self._resolve_video_num_frames(
requested=requested_frames,
num_videos=len(base_output.videos),
text_len=self._token_len(base_output.input_text or prompt),
image_tile_cnt=int(sum(num_patches_list)) if num_patches_list else 0,
)
if base_output.videos and num_frames > 0 and self.video_token_id is not None:
for video in base_output.videos:
is_video_obj = isinstance(video, VideoDecoderWrapper)
vr = video if is_video_obj else self._open_video_reader(str(video))
max_frame = len(vr) - 1
frame_indices = (
[0]
if num_frames == 1
else np.linspace(0, max_frame, num=num_frames, dtype=int).tolist()
)
per_video_tiles = []
per_video_patch_cnt = []
for fi in frame_indices:
img_np = vr[int(fi)]
frame_t = (
torch.from_numpy(img_np)
.permute(2, 0, 1)
.to(get_device())
.float()
/ 255.0
)
frame_t = (frame_t - mean) / std
tiles = self.dynamic_preprocess(
frame_t,
image_size=448,
max_num=vid_max_num,
use_thumbnail=self.VIDEO_USE_THUMBNAIL,
)
per_video_tiles.append(tiles)
per_video_patch_cnt.append(int(tiles.shape[0]))
pv = torch.cat(per_video_tiles, dim=0)
video_pixel_values.append(pv)
video_patch_lists.append(per_video_patch_cnt)
video_tensor = (
torch.cat(video_pixel_values, dim=0) if video_pixel_values else None
)
# ----- Build prompt text with <img> + CONTEXT*n + </img> -----
img_ph = "<<<__IMG_PLACEHOLDER__>>>"
vid_ph = "<<<__VID_PLACEHOLDER__>>>"
input_text_mid = base_output.input_text or prompt
input_text_mid = input_text_mid.replace(self.IMAGE_PLACEHOLDER_TOKEN, img_ph)
input_text_mid = input_text_mid.replace(self.IMG_CONTEXT, img_ph)
if self.VIDEO_CONTEXT_TOKEN and self.video_token_id is not None:
input_text_mid = input_text_mid.replace(
self.VIDEO_PLACEHOLDER_TOKEN, vid_ph
)
else:
input_text_mid = input_text_mid.replace(self.VIDEO_PLACEHOLDER_TOKEN, "")
input_text_updated = input_text_mid
# Expand images
for num_patches in num_patches_list:
image_tokens = (
self.IMG_START
+ (self.IMG_CONTEXT * (self.num_image_token * int(num_patches)))
+ self.IMG_END
)
input_text_updated = input_text_updated.replace(img_ph, image_tokens, 1)
# Expand videos (each frame is one <img>...</img>)
if video_patch_lists and self.VIDEO_CONTEXT_TOKEN:
for frame_patch_list in video_patch_lists:
frame_lines = []
for i, patch_cnt in enumerate(frame_patch_list):
ctx_cnt = int(self.num_image_token) * int(patch_cnt)
frame_tokens = (
self.IMG_START
+ (self.VIDEO_CONTEXT_TOKEN * ctx_cnt)
+ self.IMG_END
)
frame_lines.append(f"Frame {i+1}: {frame_tokens}")
video_tokens = "\n".join(frame_lines) + "\n"
input_text_updated = input_text_updated.replace(vid_ph, video_tokens, 1)
# Tokenize
input_ids_tensor = self.tokenizer(input_text_updated, return_tensors="pt")[
"input_ids"
].flatten()
input_ids = input_ids_tensor.tolist()
# Offsets
image_offsets = []
if image_tensor is not None:
image_offsets = self.get_mm_items_offset(
input_ids=input_ids_tensor.to(get_device()),
mm_token_id=self.img_context_token_id,
)
video_offsets = []
if video_tensor is not None and self.video_token_id is not None:
video_offsets = self.get_mm_items_offset(
input_ids=input_ids_tensor.to(get_device()),
mm_token_id=self.video_token_id,
)
items = []
if image_tensor is not None:
# Split per-image for better cache granularity
assert len(num_patches_list) == len(image_offsets), (
f"InternVL: num_patches_list ({len(num_patches_list)}) != "
f"image_offsets ({len(image_offsets)})"
)
cumulative = 0
for i, num_patches in enumerate(num_patches_list):
items.append(
MultimodalDataItem(
feature=image_tensor[cumulative : cumulative + num_patches],
modality=Modality.IMAGE,
offsets=[image_offsets[i]],
)
)
cumulative += num_patches
if video_tensor is not None:
items.append(
MultimodalDataItem(
feature=video_tensor, modality=Modality.VIDEO, offsets=video_offsets
)
)
return MultimodalProcessorOutput(
input_ids=input_ids,
mm_items=items,
im_start_id=self.img_start_token_id,
im_end_id=self.img_end_token_id,
im_token_id=self.img_context_token_id,
video_token_id=self.video_token_id,
)
async def process_internlm2_mm_data_async(
self, image_data, input_text, request_obj, **kwargs
):
# InternLM2 branch: legacy placeholder <IMG_CONTEXT> (stable for InternLM2 prompt behavior)
prompt = input_text or ""
video_data = getattr(request_obj, "video_data", None) or []
if video_data:
logger.warning(
"[internvl][internlm2] video input ignored for InternLM2 branch"
)
# Convert any OpenAI-style <image> into <IMG_CONTEXT>
prompt = prompt.replace(self.IMAGE_PLACEHOLDER_TOKEN, self.IMG_CONTEXT)
if image_data:
prompt = self._ensure_placeholders_before_assistant(
prompt, self.IMG_CONTEXT, len(image_data)
)
logger.info(
"[internvl][internlm2] placeholders img_context=%d",
prompt.count(self.IMG_CONTEXT),
)
base_output = await self.load_mm_data(
prompt=prompt,
image_data=image_data,
multimodal_tokens=self.mm_tokens_internlm2, # expects <IMG_CONTEXT>
discard_alpha_channel=True,
)
mean, std = self._get_normalize_tensors(device=get_device())
num_patches_list: List[int] = []
pixel_values_list: List[torch.Tensor] = []
for image in base_output.images:
if isinstance(image, Image.Image):
img_np = np.array(image.convert("RGB"))
tensor = (
torch.from_numpy(img_np).permute(2, 0, 1).to(get_device()).float()
/ 255.0
)
else:
tensor = image.to(get_device())
tensor = (tensor - mean) / std
tiles = self.dynamic_preprocess(
tensor, image_size=448, max_num=12, use_thumbnail=True
)
pixel_values_list.append(tiles)
num_patches_list.append(int(tiles.shape[0]))
if image_data and not pixel_values_list:
raise ValueError(
"[internvl][internlm2] image_data provided but no images parsed from prompt placeholders"
)
pixel_values = (
torch.cat(pixel_values_list, dim=0) if pixel_values_list else None
)
# Expand each <IMG_CONTEXT> into <img> + <IMG_CONTEXT>*N + </img>
ph = "<<<__IMG_CONTEXT_PLACEHOLDER__>>>"
input_text_base = (base_output.input_text or prompt).replace(
self.IMG_CONTEXT, ph
)
input_text_updated = input_text_base
for num_patches in num_patches_list:
image_tokens = (
self.IMG_START
+ (self.IMG_CONTEXT * (self.num_image_token * int(num_patches)))
+ self.IMG_END
)
input_text_updated = input_text_updated.replace(ph, image_tokens, 1)
# Tokenize
input_ids_tensor = self.tokenizer(input_text_updated, return_tensors="pt")[
"input_ids"
].flatten()
input_ids = input_ids_tensor.tolist()
# Offsets
image_offsets = []
if pixel_values is not None:
image_offsets = self.get_mm_items_offset(
input_ids=input_ids_tensor.to(get_device()),
mm_token_id=self.img_context_token_id,
)
items = []
if pixel_values is not None:
# Split per-image for better cache granularity
assert len(num_patches_list) == len(image_offsets), (
f"InternVL: num_patches_list ({len(num_patches_list)}) != "
f"image_offsets ({len(image_offsets)})"
)
cumulative = 0
for i, num_patches in enumerate(num_patches_list):
items.append(
MultimodalDataItem(
feature=pixel_values[cumulative : cumulative + num_patches],
modality=Modality.IMAGE,
offsets=[image_offsets[i]],
)
)
cumulative += num_patches
return MultimodalProcessorOutput(
input_ids=input_ids,
mm_items=items,
im_start_id=self.img_start_token_id,
im_end_id=self.img_end_token_id,
im_token_id=self.img_context_token_id,
video_token_id=self.video_token_id,
)
@@ -0,0 +1,45 @@
from typing import List, Union
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.deepseek_janus_pro import MultiModalityCausalLM
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
class JanusProImageProcessor(BaseMultimodalProcessor):
models = [MultiModalityCausalLM]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.mm_tokens = MultimodalSpecialTokens(
image_token=_processor.image_token,
image_token_id=_processor.image_id,
).build(_processor)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
input_text,
request_obj,
**kwargs,
):
base_out = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_out, self.mm_tokens, prompt=base_out.input_text
)
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids.tolist(),
im_start_id=self._processor.image_start_id,
im_end_id=self._processor.image_end_id,
im_token_id=self.mm_tokens.image_token_id,
)
@@ -0,0 +1,128 @@
"""Kimi-specific grid-based multimodal data helpers.
Shared by KimiVLImageProcessor and KimiK2_5VLImageProcessor.
"""
from typing import Union
import numpy as np
import torch
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
class KimiGridMMDataMixin:
"""Mixin providing Kimi-specific grid-based multimodal data helpers.
Expects the concrete class to supply:
- self.hf_config (with vision_config.merge_kernel_size)
- self._tokenizer (with .encode())
"""
def resolve_image_token_counts(self, images):
"""Kimi's processor is remote-code and does not implement the
transformers ``_get_num_multimodal_tokens`` convention; use its
``media_tokens_calculator`` instead.
"""
assert images is not None
media_tokens_calculator = (
self._processor.media_processor.media_tokens_calculator
)
return [
int(media_tokens_calculator({"type": "image", "image": image}))
for image in images
]
def _num_image_tokens_from_grid(
self, grid_thw: Union[torch.Tensor, np.ndarray, list, tuple]
) -> int:
"""Compute Kimi-style image token count from 2D/3D grid metadata."""
merge_h, merge_w = self.hf_config.vision_config.merge_kernel_size
if isinstance(grid_thw, torch.Tensor):
vals = grid_thw.flatten().tolist()
elif isinstance(grid_thw, np.ndarray):
vals = grid_thw.reshape(-1).tolist()
elif isinstance(grid_thw, (list, tuple)):
vals = list(np.array(grid_thw).reshape(-1).tolist())
else:
raise TypeError(
f"Unsupported grid type for kimi image tokens: {type(grid_thw)}"
)
if len(vals) >= 3:
_t, h, w = vals[-3], vals[-2], vals[-1]
elif len(vals) == 2:
_t, h, w = 1, vals[0], vals[1]
else:
raise ValueError(
f"Invalid grid metadata for kimi image tokens: {vals} "
"(expected [t,h,w] or [h,w])"
)
h, w = int(h), int(w)
return (h * w) // (merge_h * merge_w)
def _build_kimi_mm_data_from_grids(
self, prompt, embeddings, **kwargs
) -> MultimodalProcessorOutput:
image_token_id = kwargs.get("image_token_id", 0)
img_grid_thw = kwargs.get("img_grid_thw", None)
if not isinstance(prompt, list):
prompt = self._tokenizer.encode(prompt)
image_token_counts = [
self._num_image_tokens_from_grid(grid) for grid in img_grid_thw
]
input_ids = []
offsets = []
img_idx = 0
for token in prompt:
if token != image_token_id:
input_ids.append(token)
continue
if img_idx >= len(image_token_counts):
raise ValueError(
"The number of image placeholders exceeds img_grid_thw entries."
)
num_tokens = image_token_counts[img_idx]
start = len(input_ids)
input_ids.extend([image_token_id] * num_tokens)
offsets.append((start, len(input_ids) - 1))
img_idx += 1
if img_idx != len(image_token_counts):
raise ValueError(
"The number of image placeholders does not match img_grid_thw entries."
)
image_embeddings = embeddings[Modality.IMAGE]
mm_items = []
consumed = 0
for start, end in offsets:
num_tokens = end - start + 1
embedding_slice = image_embeddings[consumed : consumed + num_tokens]
consumed += num_tokens
mm_items.append(
MultimodalDataItem(
modality=Modality.IMAGE,
offsets=[(start, end)],
precomputed_embeddings=embedding_slice,
)
)
return MultimodalProcessorOutput(
input_ids=input_ids,
mm_items=mm_items,
im_token_id=image_token_id,
)
@@ -0,0 +1,429 @@
import math
import re
from collections import defaultdict
from typing import Dict, List, Union
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image
from sglang.srt.managers.schedule_batch import (
MultimodalProcessorOutput,
)
from sglang.srt.models.kimi_k25 import KimiK25ForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.multimodal.processors.base_processor import (
MultimodalSpecialTokens,
)
from sglang.srt.multimodal.processors.kimi_common import KimiGridMMDataMixin
# ---------------------------------------------------------------------------
# GPU image preprocessing utilities (resize, pad, normalize, patchify on CUDA)
# ---------------------------------------------------------------------------
def navit_resize_config(
width: int,
height: int,
patch_size: int,
merge_kernel_size: int,
in_patch_limit: int,
patch_limit_on_one_side: int,
fixed_output_tokens: int | None = None,
) -> dict:
"""Compute NaViT resize target dimensions and token count.
Pure math -- no image data needed, only (width, height).
"""
s1 = math.sqrt(
in_patch_limit
/ (max(1.0, width // patch_size) * max(1.0, height // patch_size))
)
s2 = patch_limit_on_one_side * patch_size / width
s3 = patch_limit_on_one_side * patch_size / height
scale = min(1.0, s1, s2, s3)
new_w = min(max(1, int(width * scale)), patch_limit_on_one_side * patch_size)
new_h = min(max(1, int(height * scale)), patch_limit_on_one_side * patch_size)
factor = merge_kernel_size * patch_size
pad_height = (factor - new_h % factor) % factor
pad_width = (factor - new_w % factor) % factor
if fixed_output_tokens is not None:
num_tokens = fixed_output_tokens
else:
token_height = (new_h + pad_height) // factor
token_width = (new_w + pad_width) // factor
num_tokens = token_height * token_width
return {
"num_tokens": num_tokens,
"new_width": new_w,
"new_height": new_h,
"pad_width": pad_width,
"pad_height": pad_height,
}
def _get_image_dimensions(image: Union[torch.Tensor, Image.Image]) -> tuple[int, int]:
"""Get (width, height) from a CUDA tensor or PIL Image."""
if isinstance(image, torch.Tensor):
# nvJPEG returns (C, H, W) uint8
return image.shape[2], image.shape[1]
return image.size # PIL returns (width, height)
def _pil_to_cuda_chw(image: Image.Image) -> torch.Tensor:
"""Convert PIL Image to (C, H, W) uint8 CUDA tensor."""
arr = np.asarray(image.convert("RGB"))
return torch.from_numpy(arr).permute(2, 0, 1).cuda()
def _ensure_chw_rgb(image: torch.Tensor) -> torch.Tensor:
"""Coerce an already-decoded (C, H, W) image tensor to 3-channel RGB.
PIL inputs are RGB-normalized by _pil_to_cuda_chw, but pre-decoded
tensor inputs (e.g. nvJPEG / cached CUDA tensors) keep their native
channel count. Grayscale (1ch) or RGBA (4ch) images then break the
downstream torch.cat over a batch of images, which requires a
consistent channel dimension. Normalize every tensor to 3 channels.
Also move the tensor to the GPU (matching _pil_to_cuda_chw) so a CPU
input does not trip a device mismatch against the CUDA image_mean /
image_std_inv normalization constants downstream. No-op if already on
the device.
"""
image = image.cuda()
if image.dim() == 2: # (H, W) grayscale -> (1, H, W)
image = image.unsqueeze(0)
c = image.shape[0]
if c == 3:
return image
if c == 1:
return image.repeat(3, 1, 1)
# RGBA or other multi-channel layouts: keep the first 3 channels.
return image[:3]
def _process_single_image(
image: Union[torch.Tensor, Image.Image],
config: dict,
image_mean: torch.Tensor,
image_std_inv: torch.Tensor,
patch_size: int,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Process a single image on GPU: resize -> pad -> normalize -> patchify."""
if isinstance(image, Image.Image):
image = _pil_to_cuda_chw(image)
else:
image = _ensure_chw_rgb(image)
new_h, new_w = config["new_height"], config["new_width"]
pad_h, pad_w = config["pad_height"], config["pad_width"]
x = image.unsqueeze(0).float()
x = F.interpolate(x, size=(new_h, new_w), mode="bicubic", align_corners=False)
if pad_h > 0 or pad_w > 0:
x = F.pad(x, (0, pad_w, 0, pad_h), value=0.0)
x = x / 255.0
x = (x - image_mean) * image_std_inv
_, C, H, W = x.shape
T = 1
gh, gw = H // patch_size, W // patch_size
x = x.view(T, C, gh, patch_size, gw, patch_size)
x = x.permute(0, 2, 4, 1, 3, 5).reshape(-1, C, patch_size, patch_size)
grid_thw = torch.tensor([T, gh, gw], dtype=torch.int64, device=x.device)
return x, grid_thw
def _gpu_preprocess_images(
images: list[Union[torch.Tensor, Image.Image]],
resize_configs: list[dict],
image_mean: torch.Tensor,
image_std_inv: torch.Tensor,
patch_size: int,
) -> tuple[torch.Tensor, torch.Tensor]:
"""GPU preprocessing pipeline for a batch of images.
Groups images with the same target padded size for batch processing.
"""
n = len(images)
if n == 0:
device = image_mean.device
return (
torch.empty(0, 3, patch_size, patch_size, device=device),
torch.empty(0, 3, dtype=torch.int64, device=device),
)
groups = defaultdict(list)
for idx, (image, config) in enumerate(zip(images, resize_configs)):
padded_h = config["new_height"] + config["pad_height"]
padded_w = config["new_width"] + config["pad_width"]
target_h = config["new_height"]
target_w = config["new_width"]
groups[(target_h, target_w, padded_h, padded_w)].append((idx, image, config))
all_patches = [None] * n
all_grids = [None] * n
for (target_h, target_w, padded_h, padded_w), group in groups.items():
if len(group) == 1:
idx, image, config = group[0]
patches, grid = _process_single_image(
image, config, image_mean, image_std_inv, patch_size
)
all_patches[idx] = patches
all_grids[idx] = grid
else:
tensors = []
for _, image, _ in group:
if isinstance(image, Image.Image):
image = _pil_to_cuda_chw(image)
else:
image = _ensure_chw_rgb(image)
tensors.append(image.unsqueeze(0).float())
resized = []
for t in tensors:
r = F.interpolate(
t, size=(target_h, target_w), mode="bicubic", align_corners=False
)
resized.append(r)
batch = torch.cat(resized, dim=0)
pad_h = padded_h - target_h
pad_w = padded_w - target_w
if pad_h > 0 or pad_w > 0:
batch = F.pad(batch, (0, pad_w, 0, pad_h), value=0.0)
batch = batch / 255.0
batch = (batch - image_mean) * image_std_inv
B, C, H, W = batch.shape
T = 1
gh, gw = H // patch_size, W // patch_size
batch = batch.view(B, C, gh, patch_size, gw, patch_size)
batch = batch.permute(0, 2, 4, 1, 3, 5).reshape(
B, -1, C, patch_size, patch_size
)
grid = torch.tensor([T, gh, gw], dtype=torch.int64, device=batch.device)
for i, (idx, _, _) in enumerate(group):
all_patches[idx] = batch[i]
all_grids[idx] = grid
pixel_values = torch.cat(all_patches, dim=0)
grid_thws = torch.stack(all_grids, dim=0)
return pixel_values, grid_thws
# ---------------------------------------------------------------------------
# Kimi K2.5 GPU processor wrapper
# ---------------------------------------------------------------------------
class KimiGPUProcessorWrapper:
"""Wraps Kimi's HF processor to do GPU image preprocessing.
GPU path: nvJPEG CUDA tensor / PIL -> _gpu_preprocess_images()
CPU fallback: PIL -> medias kwarg -> original HF KimiK25Processor.__call__
Exposes attributes that base class's process_mm_data needs so it behaves
like a normal HF processor from the outside.
"""
def __init__(
self,
hf_processor,
image_token,
patch_size,
merge_kernel_size,
in_patch_limit,
patch_limit_on_one_side,
fixed_output_tokens,
image_mean,
image_std,
):
self._hf_processor = hf_processor
self._image_token = image_token
self._patch_size = patch_size
self._merge_kernel_size = merge_kernel_size
self._in_patch_limit = in_patch_limit
self._patch_limit_on_one_side = patch_limit_on_one_side
self._fixed_output_tokens = fixed_output_tokens
self._image_mean = image_mean
self._image_std = image_std
self._gpu_norm_tensors = None
# Explicitly expose attributes that base class process_mm_data needs:
# - image_processor: checked via isinstance(..., BaseImageProcessor)
# - tokenizer: used for tokenization
# - media_processor: used by CPU fallback path
self.image_processor = hf_processor.image_processor
self.tokenizer = hf_processor.tokenizer
self.media_processor = hf_processor.media_processor
def __call__(self, text=None, images=None, **kwargs):
# process_mm_data passes images via kwargs["images"]
images = images or kwargs.pop("images", None)
if images and torch.cuda.is_available():
return self._gpu_call(text, images)
return self._cpu_call(text, images, **kwargs)
def _gpu_call(self, text, images):
"""Bypass HF KimiK25VisionProcessor.preprocess entirely -- use GPU ops."""
input_text = text[0] if isinstance(text, list) else text
# 1. Compute resize configs (CPU math)
resize_configs = []
for image in images:
w, h = _get_image_dimensions(image)
resize_configs.append(
navit_resize_config(
w,
h,
self._patch_size,
self._merge_kernel_size,
self._in_patch_limit,
self._patch_limit_on_one_side,
self._fixed_output_tokens,
)
)
# 2. Expand image tokens
parts = input_text.split(self._image_token)
result = [parts[0]]
for config, part in zip(resize_configs, parts[1:]):
result.append(self._image_token * config["num_tokens"] + part)
input_text = "".join(result)
# 3. Tokenize
text_inputs = self._hf_processor.tokenizer(input_text, return_tensors="pt")
# 4. GPU image preprocessing
image_mean, image_std_inv = self._get_gpu_norm_tensors()
pixel_values, grid_thws = _gpu_preprocess_images(
images, resize_configs, image_mean, image_std_inv, self._patch_size
)
grid_thws = grid_thws.cpu()
return {
"input_ids": text_inputs["input_ids"],
"pixel_values": pixel_values,
# Use SGL-standard key so get_new_expanded_mm_items() can split
# per-image for cache granularity (it looks up 'image_grid_thw').
"image_grid_thw": grid_thws,
}
def _cpu_call(self, text, images, **kwargs):
"""Fallback: token expansion + medias kwarg -> original HF processor."""
input_text = text[0] if isinstance(text, list) else text
if images:
# Token expansion via media_tokens_calculator
parts = input_text.split(self._image_token)
result = [parts[0]]
for image, part in zip(images, parts[1:]):
num_tokens = self._hf_processor.media_processor.media_tokens_calculator(
{"type": "image", "image": image}
)
result.append(self._image_token * num_tokens + part)
input_text = "".join(result)
# Convert to medias format for Kimi's HF processor
kwargs["medias"] = [{"type": "image", "image": img} for img in images]
out = self._hf_processor(text=[input_text], **kwargs)
grid_thws = out.pop("grid_thws", None)
if grid_thws is not None:
out["image_grid_thw"] = grid_thws
return out
def _get_gpu_norm_tensors(self, device="cuda"):
if self._gpu_norm_tensors is None:
image_mean = torch.tensor(
self._image_mean, device=device, dtype=torch.float32
).view(1, 3, 1, 1)
image_std_inv = (
1.0 / torch.tensor(self._image_std, device=device, dtype=torch.float32)
).view(1, 3, 1, 1)
self._gpu_norm_tensors = (image_mean, image_std_inv)
return self._gpu_norm_tensors
# ---------------------------------------------------------------------------
# Kimi K2.5 SGLang multimodal processor
# ---------------------------------------------------------------------------
# Compatible with KimiVLForConditionalGeneration
class KimiK2_5VLImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
models = [KimiK25ForConditionalGeneration]
gpu_image_decode = True # nvJPEG for JPEG, PIL fallback for others
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.mm_tokens = MultimodalSpecialTokens(
image_token="<|media_pad|>",
# TODO: could we convert in MultimodalSpecialTokens?
image_token_id=hf_config.media_placeholder_token_id,
image_token_regex=re.compile(r"(?:<\|media_pad\|>)+"),
).build(_processor)
# Extract media processing config from HF processor
media_proc_cfg = _processor.media_processor.media_proc_cfg
# Replace with GPU-capable wrapper
self._processor = KimiGPUProcessorWrapper(
_processor,
image_token=self.mm_tokens.image_token,
patch_size=media_proc_cfg["patch_size"],
merge_kernel_size=media_proc_cfg["merge_kernel_size"],
in_patch_limit=media_proc_cfg["in_patch_limit"],
patch_limit_on_one_side=media_proc_cfg["patch_limit_on_one_side"],
fixed_output_tokens=media_proc_cfg.get("fixed_output_tokens"),
image_mean=media_proc_cfg["image_mean"],
image_std=media_proc_cfg["image_std"],
)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes, Dict]],
input_text,
request_obj,
*args,
**kwargs,
):
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.mm_tokens.image_token_id,
)
def get_mm_data(self, prompt, embeddings, **kwargs):
img_grid_thw = kwargs.get("img_grid_thw", None)
return self._build_kimi_mm_data_from_grids(
prompt=prompt,
embeddings=embeddings,
image_token_id=self.mm_tokens.image_token_id,
img_grid_thw=img_grid_thw,
)
@@ -0,0 +1,60 @@
import re
from typing import Dict, List, Union
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.kimi_vl import KimiVLForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.multimodal.processors.base_processor import (
MultimodalSpecialTokens,
)
from sglang.srt.multimodal.processors.kimi_common import KimiGridMMDataMixin
# Compatible with KimiVLForConditionalGeneration
class KimiVLImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
models = [KimiVLForConditionalGeneration]
gpu_image_decode = False # KimiVL HF processor does not support tensor inputs
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.mm_tokens = MultimodalSpecialTokens(
image_token="<|media_pad|>",
# TODO: could we convert in MultimodalSpecialTokens?
image_token_id=hf_config.media_placeholder_token_id,
image_token_regex=re.compile(r"(?:<\|media_pad\|>)+"),
).build(_processor)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes, Dict]],
input_text,
request_obj,
*args,
**kwargs,
):
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.mm_tokens.image_token_id,
)
def get_mm_data(self, prompt, embeddings, **kwargs):
img_grid_thw = kwargs.get("img_grid_thw", None)
return self._build_kimi_mm_data_from_grids(
prompt=prompt,
embeddings=embeddings,
image_token_id=self.mm_tokens.image_token_id,
img_grid_thw=img_grid_thw,
)
@@ -0,0 +1,85 @@
# Copyright 2026 Liquid AI. All rights reserved.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Multimodal processor for LFM2-VL models with SigLip2 NaFlex support."""
from typing import List, Union
from sglang.srt.managers.schedule_batch import Modality, MultimodalProcessorOutput
from sglang.srt.models.lfm2_vl import Lfm2VlForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.multimodal.processors.base_processor import (
MultimodalSpecialTokens,
)
class Lfm2VlImageProcessor(SGLangBaseProcessor):
"""Multimodal processor for LFM2-VL vision-language models.
Uses the base class load_mm_data + process_and_combine_mm_data flow.
The HF processor handles NaFlex variable-resolution tiling internally.
"""
models = [Lfm2VlForConditionalGeneration]
gpu_image_decode = False
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.IMAGE_TOKEN_ID = hf_config.image_token_id
self.IMAGE_TOKEN = "<image>"
self.mm_tokens = MultimodalSpecialTokens(
image_token=self.IMAGE_TOKEN,
image_token_id=hf_config.image_token_id,
).build(_processor)
# Register NaFlex-specific HF processor outputs so
# collect_mm_items_from_processor_output picks them up
self.ATTR_NAME_TO_MODALITY["pixel_attention_mask"] = Modality.IMAGE
self.ATTR_NAME_TO_MODALITY["spatial_shapes"] = Modality.IMAGE
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
audio_data,
input_text: str,
request_obj,
**kwargs,
):
if not image_data:
input_ids = self._tokenizer(
input_text, return_tensors="pt", add_special_tokens=False
).input_ids
return {
"input_ids": input_ids.squeeze(0).tolist(),
"mm_items": [],
"im_token_id": self.IMAGE_TOKEN_ID,
}
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
)
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.IMAGE_TOKEN_ID,
)
@@ -0,0 +1,110 @@
# Copyright 2025 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""
Multimodal processor for lightonai/LightOnOCR-2-1B.
Key difference from Pixtral: LightOnOCR does NOT use image break/end tokens.
The parent PixtralProcessor inserts row-break and image-end tokens between
image patch rows. This processor removes them after the parent processing
to produce a single contiguous range of image tokens per image.
"""
from typing import List, Union
from sglang.srt.models.lightonocr import LightOnOCRForConditionalGeneration
from sglang.srt.multimodal.processors.pixtral import PixtralProcessor
class LightOnOCRProcessor(PixtralProcessor):
"""Processor for LightOnOCR model."""
models = [LightOnOCRForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
# LightOnOCR uses image_token_id instead of image_token_index
if not hasattr(hf_config, "image_token_index"):
hf_config.image_token_index = getattr(hf_config, "image_token_id", 151655)
# Propagate spatial_merge_size from root config to vision_config
spatial_merge_size = getattr(hf_config, "spatial_merge_size", 2)
if hasattr(hf_config, "vision_config"):
vc = hf_config.vision_config
if not hasattr(vc, "spatial_merge_size") or vc.spatial_merge_size is None:
vc.spatial_merge_size = spatial_merge_size
if hasattr(_processor, "patch_size"):
_processor.spatial_merge_size = spatial_merge_size
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
# Identify break/end token IDs for removal
self._break_token_ids = set()
for attr in ("image_break_token_id", "image_break_id"):
tid = getattr(_processor, attr, None)
if tid is not None:
self._break_token_ids.add(tid)
for attr in ("image_end_token_id", "image_end_id"):
tid = getattr(_processor, attr, None)
if tid is not None:
self._break_token_ids.add(tid)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
input_text,
request_obj,
*args,
**kwargs,
):
result = await super().process_mm_data_async(
image_data=image_data,
input_text=input_text,
request_obj=request_obj,
*args,
**kwargs,
)
if not result or not self._break_token_ids:
return result
# Remove break/end tokens and fix multimodal item offsets
input_ids = result.input_ids or []
mm_items = result.mm_items or []
new_input_ids = []
old_to_new = {}
for old_idx, token_id in enumerate(input_ids):
if token_id not in self._break_token_ids:
old_to_new[old_idx] = len(new_input_ids)
new_input_ids.append(token_id)
if len(new_input_ids) == len(input_ids):
return result
# Remap multimodal item offsets to account for removed tokens
for mm_item in mm_items:
if not mm_item.offsets:
continue
new_indices = sorted(
old_to_new[idx]
for start, end in mm_item.offsets
for idx in range(start, end + 1)
if idx in old_to_new
)
if new_indices:
mm_item.offsets = [(new_indices[0], new_indices[-1])]
result.input_ids = new_input_ids
return result
@@ -0,0 +1,268 @@
import asyncio
import os
from typing import Dict, List, Optional, Union
import numpy as np
from transformers.models.auto.processing_auto import (
PROCESSOR_MAPPING_NAMES as HF_MAPPING_NAMES,
)
import sglang.srt.managers.multimodal_processor as sgl_mm_processor_utils
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.models.llava import (
LlavaForConditionalGeneration,
LlavaLlamaForCausalLM,
LlavaMistralForCausalLM,
LlavaQwenForCausalLM,
)
from sglang.srt.models.llavavid import LlavaVidForCausalLM
from sglang.srt.models.mistral import Mistral3ForConditionalGeneration
from sglang.srt.multimodal.mm_utils import (
ensure_numpy,
expand2square,
process_anyres_image,
)
from sglang.srt.multimodal.processors.base_processor import BaseMultimodalProcessor
from sglang.srt.utils import ImageData, load_image, logger
from sglang.utils import get_exception_traceback
class LlavaImageProcessor(BaseMultimodalProcessor):
models = [
LlavaLlamaForCausalLM,
LlavaVidForCausalLM,
LlavaQwenForCausalLM,
LlavaMistralForCausalLM,
]
gpu_image_decode = False # Llava processes loaded image as PIL image explicitly
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
@staticmethod
def _process_single_image_task(
image_data: Union[str, bytes, ImageData],
image_aspect_ratio: Optional[str] = None,
image_grid_pinpoints: Optional[str] = None,
processor=None,
):
image_processor = processor.image_processor
try:
url = image_data.url if isinstance(image_data, ImageData) else image_data
image, image_size = load_image(url, False)
if image_size is not None:
# It is a video with multiple images
image_hash = hash(url)
pixel_values = image_processor(image)["pixel_values"]
for i in range(len(pixel_values)):
pixel_values[i] = ensure_numpy(pixel_values[i]).astype(np.float16)
pixel_values = np.stack(pixel_values, axis=0)
return pixel_values, image_hash, image_size
else:
# It is an image
image_hash = hash(url)
if image_aspect_ratio == "pad":
image = expand2square(
image,
tuple(int(x * 255) for x in image_processor.image_mean),
)
pixel_values = image_processor(image.convert("RGB"))[
"pixel_values"
][0]
elif image_aspect_ratio == "anyres" or (
image_aspect_ratio is not None
and "anyres_max" in image_aspect_ratio
):
pixel_values = process_anyres_image(
image, image_processor, image_grid_pinpoints
)
else:
pixel_values = image_processor(image)["pixel_values"][0]
pixel_values = ensure_numpy(pixel_values)
if isinstance(pixel_values, np.ndarray):
pixel_values = pixel_values.astype(np.float16)
return pixel_values, image_hash, image.size
except Exception:
logger.error("Exception in TokenizerManager:\n" + get_exception_traceback())
async def _process_single_image(
self,
image_data: Union[bytes, str, ImageData],
aspect_ratio: str,
grid_pinpoints: str,
):
if self.cpu_executor is not None:
loop = asyncio.get_running_loop()
fut = loop.run_in_executor(
self.cpu_executor,
LlavaImageProcessor._process_single_image_task,
image_data,
aspect_ratio,
grid_pinpoints,
self._processor,
)
timeout = int(os.environ.get("REQUEST_TIMEOUT", "10"))
return await asyncio.wait_for(fut, timeout=timeout)
else:
return self._process_single_image_task(
image_data,
aspect_ratio,
grid_pinpoints,
self._processor.image_processor,
)
def _process_precomputed_image_data(self, image_data: List[Dict]) -> Dict:
mm_items = []
for item in image_data:
# Infer size logic...
if "image_sizes" not in item:
if "pixel_values" in item:
pv = item["pixel_values"]
# Handle simplified if/else
h, w = (
(pv.shape[2], pv.shape[3])
if len(pv.shape) == 4
else (pv.shape[1], pv.shape[2])
)
item["image_sizes"] = [(w, h)]
else:
item["image_sizes"] = [(336, 336)]
mm_items.append(
MultimodalDataItem(
feature=item["feature"],
modality=Modality.IMAGE,
model_specific_data=item,
)
)
return MultimodalProcessorOutput(mm_items=mm_items)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes, ImageData]],
input_text,
request_obj,
*args,
**kwargs,
):
# FIX: Handle precomputed embeddings (dictionaries)
# If the input is already a dictionary, we skip the CPU image processor.
# We also need to infer 'image_sizes' from 'pixel_values' if missing,
# because pad_input_ids requires it.
if (
isinstance(image_data, list)
and len(image_data) > 0
and isinstance(image_data[0], dict)
):
return self._process_precomputed_image_data(image_data)
modalities = request_obj.modalities or ["image"]
aspect_ratio = getattr(self.hf_config, "image_aspect_ratio", None)
grid_pinpoints = (
self.hf_config.image_grid_pinpoints
if hasattr(self.hf_config, "image_grid_pinpoints")
and "anyres" in aspect_ratio
else None
)
if isinstance(image_data, list) and len(image_data) > 0:
if "multi-images" in modalities or "video" in modalities:
# Multiple images
aspect_ratio = "pad" # LLaVA OneVision Handling: more than one image --> interleaved image mode or video mode. We do not use anyres
pixel_values, data_hashes, image_sizes = [], [], []
res = []
for img_data in image_data:
res.append(
self._process_single_image(
img_data, aspect_ratio, grid_pinpoints
)
)
res = await asyncio.gather(*res)
for pixel_v, image_h, image_s in res:
pixel_values.append(pixel_v)
data_hashes.append(image_h)
image_sizes.append(image_s)
else:
# A single image
pixel_values, image_hash, image_size = await self._process_single_image(
image_data[0], aspect_ratio, grid_pinpoints
)
pixel_values = [pixel_values]
image_sizes = [image_size]
else:
raise ValueError(f"Invalid image data: {image_data}")
modality = Modality.IMAGE
if isinstance(request_obj.modalities, list):
if request_obj.modalities[0] == "video":
modality = Modality.VIDEO
# Create one item per image for better cache granularity
mm_items = []
for pixel_v, image_s in zip(pixel_values, image_sizes):
# Ensure ndim=4 so the model forward takes the correct encode branch
if isinstance(pixel_v, np.ndarray) and pixel_v.ndim == 3:
pixel_v = np.expand_dims(pixel_v, 0)
mm_items.append(
MultimodalDataItem(
feature=pixel_v,
model_specific_data={
"image_sizes": [image_s],
"image_aspect_ratio": aspect_ratio,
},
modality=modality,
)
)
return MultimodalProcessorOutput(
mm_items=mm_items,
)
class LlavaMultimodalProcessor(BaseMultimodalProcessor):
"""
This is a wrapper class used to identify the multimodal processor for Llava architectures' vision model.
"""
models = [LlavaForConditionalGeneration, Mistral3ForConditionalGeneration]
def _get_sgl_processor_cls(self, model_type: str):
if model_type == "clip_vision_model":
return LlavaImageProcessor
if hf_name := HF_MAPPING_NAMES.get(model_type):
sgl_mm_processor_set = sgl_mm_processor_utils.PROCESSOR_MAPPING.values()
sgl_processor_cls = list(
filter(lambda p: p.__name__ == hf_name, sgl_mm_processor_set)
)
if sgl_processor_cls:
return sgl_processor_cls[0]
raise ValueError(
f"Cannot find corresponding multimodal processor registered in sglang for model type `{model_type}`"
)
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
assert hasattr(hf_config, "vision_config")
assert hasattr(hf_config, "text_config")
self.vision_config = hf_config.vision_config
self.text_config = hf_config.text_config
self.hf_config = hf_config
if vision_type := getattr(self.vision_config, "model_type"):
self.inner = self._get_sgl_processor_cls(vision_type)(
hf_config, server_args, _processor, *args, **kwargs
)
else:
raise ValueError(
f"Required `vision_config.model_type` is not found in hf_config: `{hf_config}`"
)
async def process_mm_data_async(self, *args, **kwargs):
return await self.inner.process_mm_data_async(*args, **kwargs)
@@ -0,0 +1,56 @@
# SPDX-License-Identifier: Apache-2.0
import re
from typing import Dict, List, Union
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.locate_anything import LocateAnythingForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.multimodal.processors.base_processor import (
MultimodalSpecialTokens,
)
# Compatible with LocateAnythingForConditionalGeneration
class LocateAnythingImageProcessor(SGLangBaseProcessor):
models = [LocateAnythingForConditionalGeneration]
# The LocateAnything HF processor is remote-code and does not support tensor inputs.
gpu_image_decode = False
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
# The model's chat template emits numbered ``<image-N>`` placeholders.
# The HF LocateAnythingProcessor expands each into
# ``<img>`` + N×``<IMG_CONTEXT>`` + ``</img>`` and only the
# ``<IMG_CONTEXT>`` (id 151665) run carries vision embeddings, so the
# offset/embedding token id is image_token_index while the prompt-level
# placeholder we split on is ``<image-N>``.
self.mm_tokens = MultimodalSpecialTokens(
image_token_id=hf_config.image_token_index,
image_token_regex=re.compile(r"<image-\d+>"),
).build(_processor)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes, Dict]],
input_text,
request_obj,
*args,
**kwargs,
):
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.mm_tokens.image_token_id,
)
@@ -0,0 +1,167 @@
import logging
import re
import torch
from sglang.srt.managers.schedule_batch import Modality, MultimodalProcessorOutput
from sglang.srt.models.midashenglm import MiDashengLMModel
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
logger = logging.getLogger(__name__)
class MiDashengLMMultimodalProcessor(BaseMultimodalProcessor):
"""Multimodal processor for MiDashengLM audio-language model."""
models = [MiDashengLMModel]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.AUDIO_TOKEN = "<|audio_bos|><|AUDIO|><|audio_eos|>"
self.AUDIO_TOKEN_REGEX = re.compile(
r"<\|audio_bos\|>(?:<\|AUDIO\|>)+<\|audio_eos\|>"
)
tokenizer = self._processor.tokenizer
self.audio_start_id = tokenizer.convert_tokens_to_ids("<|audio_bos|>")
self.audio_token_id = tokenizer.convert_tokens_to_ids("<|AUDIO|>")
self.audio_end_id = tokenizer.convert_tokens_to_ids("<|audio_eos|>")
self.mm_tokens = MultimodalSpecialTokens(
audio_token=self.AUDIO_TOKEN,
audio_token_regex=self.AUDIO_TOKEN_REGEX,
audio_token_id=self.audio_token_id,
).build(_processor)
self.ATTR_NAME_TO_MODALITY.update(
{
"input_values": Modality.AUDIO,
"audio_length": Modality.AUDIO,
}
)
if "input_values" not in self.FEATURE_NAMES:
self.FEATURE_NAMES.append("input_values")
def process_mm_data(
self, input_text, images=None, videos=None, audios=None, **kwargs
):
"""Override to use correct audio parameter name for MiDashengLM processor."""
if images:
kwargs["images"] = images
if videos:
kwargs["videos"] = videos
if audios:
kwargs["audio"] = audios
kwargs.setdefault("audio_kwargs", {})
kwargs["audio_kwargs"].setdefault("truncation", False)
if self.audio_config:
kwargs["audio_kwargs"].update(self.audio_config)
processor = self._processor
result = processor.__call__(
text=[input_text],
padding=True,
return_tensors="pt",
**kwargs,
)
if not getattr(self.server_args, "keep_mm_feature_on_device", False):
for feature_name in ["input_values"]:
if feature_name in result:
result[feature_name] = result[feature_name].cpu()
return result
async def process_mm_data_async(
self,
audio_data,
input_text,
**kwargs,
):
"""Process audio data for MiDashengLM model.
Args:
audio_data: Audio input data
input_text: Text prompt
**kwargs: Additional arguments
Returns:
Dictionary containing processed multimodal data
"""
logger.info("=" * 80)
logger.info("process_mm_data_async called")
logger.info(f"audio_data is not None: {audio_data is not None}")
logger.info(f"input_text: {input_text}")
logger.info("=" * 80)
if audio_data and not self.AUDIO_TOKEN_REGEX.search(input_text):
input_text = f"{self.AUDIO_TOKEN}{input_text}"
logger.info("Auto-prepended audio token")
base_output = await self.load_mm_data(
prompt=input_text,
audio_data=audio_data,
multimodal_tokens=self.mm_tokens,
)
if base_output is None:
logger.info("base_output is None")
return None
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
logger.info(f"mm_items count: {len(mm_items)}")
logger.info(f"ret keys: {list(ret.keys())}")
logger.info(f"input_ids shape: {input_ids.shape}")
logger.info(
f"audio_token_id={self.audio_token_id}, audio_start_id={self.audio_start_id}, audio_end_id={self.audio_end_id}"
)
logger.info(
f"Count of audio_token_id in input_ids: {(input_ids == self.audio_token_id).sum().item()}"
)
for i, item in enumerate(mm_items):
logger.info(f"mm_item[{i}] modality: {item.modality}")
logger.info(
f"mm_item[{i}] pad_value: {getattr(item, 'pad_value', 'NOT SET')}"
)
logger.info(f"mm_item[{i}] offsets: {getattr(item, 'offsets', 'NOT SET')}")
logger.info(f"mm_item[{i}] has feature: {hasattr(item, 'feature')}")
if hasattr(item, "feature") and item.feature is not None:
logger.info(f"mm_item[{i}] feature shape: {item.feature.shape}")
if "audio_length" in ret and len(mm_items) > 0:
audio_length = ret["audio_length"]
if isinstance(audio_length, torch.Tensor):
audio_length = (
audio_length.item()
if audio_length.numel() == 1
else audio_length[0].item()
)
mm_items[0].audio_length = audio_length
logger.info(
f"Set audio_length={audio_length} (from processor, mel frame count)"
)
elif "input_values" in ret and len(mm_items) > 0:
input_values = ret["input_values"]
audio_length = (
input_values.shape[-1]
if input_values.ndim >= 2
else input_values.shape[0]
)
mm_items[0].audio_length = audio_length
logger.info(f"Set audio_length={audio_length} (fallback, waveform length)")
result = MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids.tolist(),
audio_start_id=self.audio_start_id,
audio_token_id=self.audio_token_id,
audio_end_id=self.audio_end_id,
)
logger.info(f"Returning {len(result.mm_items)} mm_items")
return result
@@ -0,0 +1,316 @@
"""Stateful audio preprocessing pipeline shared by MiMo multimodal and ASR processors."""
import io
import math
import os
import time
from collections import OrderedDict
from dataclasses import dataclass
from typing import Optional
import numpy as np
import pybase64
import torch
from sglang.srt.utils import common
from sglang.utils import logger
try:
from torchcodec.decoders import AudioDecoder
except ImportError:
logger.warning(
"torchcodec is not installed; audio inputs will fail at request time"
)
AudioDecoder = None
try:
import torchaudio
from torchaudio.transforms import MelSpectrogram
except ImportError:
logger.warning(
"torchaudio is not installed; audio inputs will fail at request time"
)
torchaudio = None
MelSpectrogram = None
@dataclass
class AudioInput:
"""
if audio is str or bytes, only load it as mel spectrogram.
if audio is tuple, it is (waveform, original_sr)
if audio is torch.Tensor, it is tokenized input ids with shape (T, n_vq+).
if audio is np.ndarray, it is a pre-loaded waveform (1D, already resampled).
"""
audio: str | bytes | tuple | torch.Tensor | np.ndarray
def __post_init__(self):
if not isinstance(self.audio, (str, bytes, tuple, torch.Tensor, np.ndarray)):
raise ValueError(
f"audio must be a str, bytes, tuple, torch.Tensor, or np.ndarray, but got {type(self.audio)}"
)
if isinstance(self.audio, tuple):
if (
len(self.audio) != 2
or not isinstance(self.audio[0], torch.Tensor)
or not isinstance(self.audio[1], (int, float))
):
raise ValueError(
f"audio must be a tuple of (waveform-T, original_sr-int/float), but got {len(self.audio)} elements and {type(self.audio[0])} and {type(self.audio[1])}"
)
if self.audio[0].ndim != 1:
raise ValueError(
f"waveform must be a 1D tensor, but got {self.audio[0].ndim}D tensor"
)
if self.audio[1] <= 0:
raise ValueError(
f"original_sr must be a positive number, but got {self.audio[1]}"
)
if isinstance(self.audio, torch.Tensor) and self.audio.ndim != 2:
raise ValueError(
f"audio must be a 2D tensor, but got {self.audio.ndim}D tensor"
)
class MiMoAudioPipeline:
"""Stateful audio preprocessing pipeline.
Composable: held by both MiMoProcessor (multimodal) and MiMoV2ASRProcessor.
Owns the mel spectrogram, resampler cache, http session, and the special
token ids for ``<|sosp|> <|empty|>* <|eosp|>`` placeholders.
"""
def __init__(
self,
*,
audio_token_id: int,
audio_start_token_id: int,
audio_end_token_id: int,
audio_kernel_size: int = 3,
audio_stride_size: int = 2,
audio_avg_pooler: int = 2,
audio_group_size: int = 4,
audio_channels: int = 8,
audio_sampling_rate: int = 24000,
audio_nfft: int = 960,
audio_hop_length: int = 240,
audio_window_size: int = 960,
audio_fmin: int = 0,
audio_fmax: Optional[int] = None,
audio_n_mels: int = 128,
audio_input_id_per_second: int = 25,
max_resamplers: int = 16,
) -> None:
self.audio_token_id = audio_token_id
self.audio_start_token_id = audio_start_token_id
self.audio_end_token_id = audio_end_token_id
self.audio_kernel_size = audio_kernel_size
self.audio_stride_size = audio_stride_size
self.audio_avg_pooler = audio_avg_pooler
self.audio_group_size = audio_group_size
self.audio_channels = audio_channels
self.audio_sampling_rate = audio_sampling_rate
self.audio_nfft = audio_nfft
self.audio_hop_length = audio_hop_length
self.audio_window_size = audio_window_size
self.audio_fmin = audio_fmin
self.audio_fmax = audio_fmax
self.audio_n_mels = audio_n_mels
self.audio_input_id_per_second = audio_input_id_per_second
self.mel_spectrogram_kwargs = dict(
sample_rate=audio_sampling_rate,
n_fft=audio_nfft,
hop_length=audio_hop_length,
win_length=audio_window_size,
f_min=audio_fmin,
f_max=audio_fmax,
n_mels=audio_n_mels,
power=1.0,
center=True,
)
self._mel_spectrogram = None
self._resamplers: OrderedDict[int, torchaudio.transforms.Resample] = (
OrderedDict()
)
self._resamplers_max = max_resamplers
@property
def audio_token_per_second(self) -> float:
return self.audio_input_id_per_second / self.audio_group_size
@staticmethod
def _ensure_audio_dependencies() -> None:
if torchaudio is None or MelSpectrogram is None:
raise RuntimeError(
"torchaudio is required for audio inputs; install torchaudio"
)
@property
def mel_spectrogram(self):
self._ensure_audio_dependencies()
if self._mel_spectrogram is None:
self._mel_spectrogram = MelSpectrogram(**self.mel_spectrogram_kwargs)
return self._mel_spectrogram
def compute_audio_token_len(self, mel_len: int) -> int:
n = mel_len + 3 - self.audio_kernel_size
n = (n + 2 - self.audio_kernel_size) // self.audio_stride_size + 1
n = n // self.audio_avg_pooler + int(n % self.audio_avg_pooler != 0)
return math.ceil(n / self.audio_group_size)
def preprocess_audio(self, audio):
"""Load audio source → log-mel spectrogram + token length.
Input: filename string, bytes, or tuple of (waveform, original_sr).
Output: (mel-spectrogram tensor [T, n_mels], audio_token_len int).
"""
self._ensure_audio_dependencies()
assert isinstance(
audio, (str, bytes, tuple)
), f"audio must be a str, bytes or tuple, but got {type(audio)}"
if isinstance(audio, tuple):
waveform, original_sr = audio
else:
if isinstance(audio, bytes):
file = io.BytesIO(audio)
elif isinstance(audio, str):
if audio.startswith("data:"):
file = io.BytesIO(
pybase64.b64decode(audio.split(",")[1], validate=True)
)
elif audio.startswith("http://") or audio.startswith("https://"):
dl_start = time.perf_counter()
timeout = int(os.getenv("REQUEST_TIMEOUT", "5"))
try:
with common.get_mm_http_session().get(
audio, stream=True, timeout=timeout
) as response:
response.raise_for_status()
dl_elapsed_ms = (time.perf_counter() - dl_start) * 1000
if dl_elapsed_ms > 1000.0:
content_len = len(response.content)
logger.warning(
f"Slow audio download: {dl_elapsed_ms:.2f}ms, "
f"size={content_len / 1024:.1f}KB, url={audio}"
)
file = io.BytesIO(response.content)
except Exception as e:
dl_elapsed_ms = (time.perf_counter() - dl_start) * 1000
logger.error(
f"Failed to download audio: {dl_elapsed_ms:.2f}ms, "
f"error={type(e).__name__}: {e}, url={audio}"
)
raise
else:
file = audio
if AudioDecoder is None:
raise RuntimeError(
"torchcodec is required for audio decoding; install with `pip install torchcodec`."
)
try:
samples = AudioDecoder(file).get_all_samples()
except RuntimeError as e:
audio_source = (
audio
if isinstance(audio, str)
and (audio.startswith("http://") or audio.startswith("https://"))
else "<bytes or base64>"
)
logger.error(f"Failed to decode audio: {e}, source={audio_source}")
raise ValueError(
f"Invalid audio format: source={audio_source}, detail={e}"
) from e
waveform = samples.data
original_sr = samples.sample_rate
if original_sr != self.audio_sampling_rate:
if original_sr in self._resamplers:
self._resamplers.move_to_end(original_sr)
else:
if len(self._resamplers) >= self._resamplers_max:
self._resamplers.popitem(last=False)
self._resamplers[original_sr] = torchaudio.transforms.Resample(
orig_freq=original_sr, new_freq=self.audio_sampling_rate
)
waveform = self._resamplers[original_sr](waveform)
if waveform.ndim == 2:
waveform = waveform.mean(dim=0)
spec = self.mel_spectrogram(waveform[None, :])
spec = torch.log(torch.clip(spec, min=1e-7)).squeeze()
spec = spec.transpose(0, 1)
audio_token_len = self.compute_audio_token_len(spec.shape[0])
return spec, audio_token_len
def process_audio(self, audio_input: AudioInput):
"""Dispatch on the underlying audio payload.
- str/bytes/tuple/np.ndarray waveform → returns (mel-spec, token_len) tuple
- 2D tensor of pre-tokenized audio codes → returns padded codes tensor
shaped [T//group, group, channels]
"""
audio = audio_input.audio
if isinstance(audio, np.ndarray):
waveform = torch.from_numpy(audio).float()
audio = (waveform, self.audio_sampling_rate)
if isinstance(audio, (str, bytes, tuple)):
return self.preprocess_audio(audio)
assert (
audio.shape[1] >= self.audio_channels
), f"audio must have at least {self.audio_channels} channels, but got {audio.shape[1]}"
T = audio.shape[0]
audio = audio[:, : self.audio_channels].to(torch.long)
padded_T = (
(T + self.audio_group_size - 1)
// self.audio_group_size
* self.audio_group_size
)
padded_audio = torch.cat(
[
audio,
torch.zeros(padded_T - T, self.audio_channels, dtype=torch.long)
+ audio[-1, :],
],
dim=0,
)
padded_audio = padded_audio.reshape(
padded_T // self.audio_group_size,
self.audio_group_size,
self.audio_channels,
)
return padded_audio
def build_audio_placeholder_ids(self, audio_token_len: int) -> list[int]:
return (
[self.audio_start_token_id]
+ [self.audio_token_id] * audio_token_len
+ [self.audio_end_token_id]
)
def process_audio_input(self, audio_input: AudioInput) -> dict:
"""Run process_audio and produce the placeholder input_ids.
Replaces the duplicated _process_audio_content bodies in both processors.
Returns dict with input_ids, audio_input (mel or codes), and is_tokenized.
"""
processed = self.process_audio(audio_input)
if isinstance(processed, tuple):
is_tokenized = False
audio_spec, audio_token_len = processed
payload = audio_spec
else:
is_tokenized = True
audio_token_len = processed.shape[0]
payload = processed
return {
"input_ids": self.build_audio_placeholder_ids(audio_token_len),
"audio_input": payload,
"audio_token_len": audio_token_len,
"is_tokenized": is_tokenized,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,286 @@
"""MiMo-V2-ASR multimodal processor.
Audio preprocessing is delegated to :class:`MiMoAudioPipeline`; this
processor only handles the special-token contract and content interleaving.
"""
import asyncio
import re
from dataclasses import dataclass
from typing import List, Literal, Union
import numpy as np
import torch
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.models.mimo_v2_asr import MiMoV2ASRForCausalLM
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
from sglang.srt.multimodal.processors.mimo_audio import (
AudioInput,
MiMoAudioPipeline,
)
from sglang.utils import logger
TextInput = str | list[int]
@dataclass
class _Content:
type: Literal["text", "audio"]
content: TextInput | AudioInput
class MiMoV2ASRProcessor(BaseMultimodalProcessor):
"""ASR-only MiMo processor.
Wires three special tokens into the input id stream around each audio
span: ``<|sosp|> <|empty|>* <|eosp|>``. The actual mel/codec preparation
is owned by :class:`MiMoAudioPipeline`, which is shared with the
multimodal MiMo-V2 processor.
"""
models = [MiMoV2ASRForCausalLM]
AUDIO_PAD_TOKEN = "<|empty|>"
AUDIO_START_TOKEN = "<|sosp|>"
AUDIO_END_TOKEN = "<|eosp|>"
AUDIO_REGEX = re.compile(r"<\|sosp\|>(?:<\|empty\|>)+<\|eosp\|>")
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.tokenizer = _processor
self.audio_pipeline = MiMoAudioPipeline(
audio_token_id=self._resolve_special_token_id(self.AUDIO_PAD_TOKEN),
audio_start_token_id=self._resolve_special_token_id(self.AUDIO_START_TOKEN),
audio_end_token_id=self._resolve_special_token_id(self.AUDIO_END_TOKEN),
audio_sampling_rate=24000,
)
self.mm_tokens = MultimodalSpecialTokens(
audio_token=f"{self.AUDIO_START_TOKEN}{self.AUDIO_PAD_TOKEN}{self.AUDIO_END_TOKEN}",
audio_token_id=self.audio_token_id,
audio_token_regex=self.AUDIO_REGEX,
).build(_processor)
def __getattr__(self, name):
# Delegate audio_pipeline fields so callers can use self.audio_token_id
# etc. directly. Only triggers when normal attribute lookup fails;
# __dict__.get avoids recursion before audio_pipeline is assigned.
pipeline = self.__dict__.get("audio_pipeline")
if pipeline is not None and hasattr(pipeline, name):
return getattr(pipeline, name)
raise AttributeError(name)
def _resolve_special_token_id(self, name: str) -> int:
tid = self.tokenizer.convert_tokens_to_ids(name)
if tid is None or tid == self.tokenizer.unk_token_id:
raise ValueError(
f"tokenizer missing required special token {name!r}; "
"checkpoint vocab does not match MiMo-V2-ASR"
)
return int(tid)
def _process_contents(self, contents: List[_Content]):
"""Run pipeline + tokenizer over an interleaved content list.
Returns ``(input_ids: Tensor[L], audio_inputs: list[Tensor],
position_ids: Tensor[3,L], rope_deltas: Tensor[1,1])``.
"""
input_ids: List[int] = []
audio_inputs: List[torch.Tensor] = []
for content in contents:
if content.type == "text":
if isinstance(content.content, str):
input_ids.extend(self.tokenizer.encode(content.content))
else:
input_ids.extend(content.content)
elif content.type == "audio":
result = self.audio_pipeline.process_audio_input(content.content)
audio_inputs.append(result["audio_input"])
input_ids.extend(result["input_ids"])
ids = torch.as_tensor(input_ids)
position_ids = torch.arange(ids.shape[0]).expand(3, -1)
rope_deltas = torch.zeros((1, 1), dtype=torch.int32)
return ids, audio_inputs, position_ids, rope_deltas
def process_mm_data(
self, input_text, images=None, videos=None, audios=None, **kwargs
) -> dict:
if audios and not self.AUDIO_REGEX.search(input_text or ""):
input_text = f"{self.mm_tokens.audio_token}{input_text or ''}"
processed_audios: List[Union[tuple, torch.Tensor]] = []
if audios:
for audio in audios:
if isinstance(audio, np.ndarray):
audio_tensor = torch.from_numpy(audio).float()
elif isinstance(audio, torch.Tensor):
audio_tensor = audio.float()
else:
processed_audios.append(audio)
continue
if audio_tensor.ndim == 1:
processed_audios.append(
(audio_tensor.cpu().contiguous(), self.audio_sampling_rate)
)
else:
processed_audios.append(audio_tensor.cpu().contiguous())
contents: List[_Content] = []
if input_text and processed_audios:
multimodal_tokens_pattern = self.mm_tokens.get_combined_regex()
text_parts = re.split(multimodal_tokens_pattern, input_text)
audio_iter = iter(processed_audios)
for text_part in text_parts:
if multimodal_tokens_pattern.match(text_part):
modality = self.mm_tokens.get_modality_of_token(text_part)
if modality == Modality.AUDIO:
try:
audio = next(audio_iter)
contents.append(
_Content(type="audio", content=AudioInput(audio=audio))
)
except StopIteration:
pass
else:
if text_part:
contents.append(_Content(type="text", content=text_part))
else:
contents.extend(
_Content(type="audio", content=AudioInput(audio=audio))
for audio in processed_audios
)
if not contents:
ids = self.tokenizer(
input_text or "",
return_tensors="pt",
add_special_tokens=True,
).input_ids
return {"input_ids": ids}
input_ids, audio_inputs, position_ids, rope_deltas = self._process_contents(
contents
)
ret: dict = {
"input_ids": input_ids,
"mrope_positions": position_ids,
"mrope_position_delta": rope_deltas,
}
if audio_inputs:
ret["audio_features"] = audio_inputs
return ret
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
audio_data: List[Union[str, bytes]],
input_text,
request_obj,
*args,
**kwargs,
):
if audio_data is None:
audio_data = getattr(request_obj, "audio_data", [])
if not audio_data:
return None
if not self.AUDIO_REGEX.search(input_text):
input_text = f"{self.mm_tokens.audio_token}{input_text}"
base_output = await self.load_mm_data(
prompt=input_text,
image_data=[],
video_data=[],
audio_data=audio_data,
multimodal_tokens=self.mm_tokens,
audio_sample_rate=self.audio_sampling_rate,
)
multimodal_tokens_pattern = self.mm_tokens.get_combined_regex()
raw_audio_data = audio_data or []
loaded_audio_iter = iter(base_output.audios)
raw_audio_iter = iter(raw_audio_data)
text_parts = re.split(multimodal_tokens_pattern, base_output.input_text)
contents: List[_Content] = []
for text_part in text_parts:
if multimodal_tokens_pattern.match(text_part):
modality = self.mm_tokens.get_modality_of_token(text_part)
assert modality is not None
if modality == Modality.AUDIO:
loaded_audio = next(loaded_audio_iter)
raw_audio_item = next(raw_audio_iter)
if isinstance(loaded_audio, np.ndarray):
audio_source = loaded_audio
elif isinstance(raw_audio_item, dict):
audio_source = raw_audio_item.get("url", loaded_audio)
elif isinstance(raw_audio_item, (str, bytes, torch.Tensor)):
audio_source = raw_audio_item
else:
raise ValueError(
f"unsupported audio item: loaded={type(loaded_audio).__name__}, "
f"raw={type(raw_audio_item).__name__}"
)
contents.append(
_Content(
type="audio",
content=AudioInput(audio=audio_source),
)
)
else:
if text_part:
contents.append(_Content(type="text", content=text_part))
loop = asyncio.get_running_loop()
try:
input_ids, audio_inputs, position_ids, rope_deltas = (
await loop.run_in_executor(
self.io_executor,
lambda: self._process_contents(contents),
)
)
except RuntimeError as e:
logger.error(f"MiMo ASR processor failed in process_mm_data_async: {e}")
raise ValueError(f"Multimodal data is corrupted or cannot be decoded: {e}")
input_ids_flat = input_ids.flatten()
if audio_inputs:
mm_items = [
MultimodalDataItem(
modality=Modality.AUDIO,
feature=audio_inputs,
offsets=self.get_mm_items_offset(
input_ids=input_ids_flat,
mm_token_id=self.audio_token_id,
),
)
]
else:
mm_items = []
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids_flat.tolist(),
audio_token_id=self.audio_token_id,
audio_start_id=self.audio_start_token_id,
audio_end_id=self.audio_end_token_id,
mrope_positions=position_ids,
mrope_position_delta=rope_deltas,
)
@@ -0,0 +1,305 @@
from typing import List, Union
import torch
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.models.minicpmo import MiniCPMO
from sglang.srt.models.minicpmv import MiniCPMV
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
BaseMultiModalProcessorOutput,
MultimodalSpecialTokens,
)
# Compatible with both 'O' and 'V'
class MiniCPMMultimodalProcessor(BaseMultimodalProcessor):
models = [MiniCPMV, MiniCPMO]
support_dynamic_frame_expansion = True
gpu_image_decode = False # MiniCPM HF processor does not support tensor inputs
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
# Collect special token ids
tokenizer = self._processor.tokenizer
self.slice_start_id = getattr(tokenizer, "slice_start_id", None)
self.slice_end_id = getattr(tokenizer, "slice_end_id", None)
self.audio_start_id = getattr(tokenizer, "audio_start_id", None)
self.audio_end_id = getattr(tokenizer, "audio_end_id", None)
self.im_start_id = getattr(tokenizer, "im_start_id", None)
self.im_end_id = getattr(tokenizer, "im_end_id", None)
self.im_token_id = getattr(tokenizer, "unk_id", None)
self.mm_tokens = MultimodalSpecialTokens(
image_token="(<image>./</image>)",
audio_token="(<audio>./</audio>)",
video_token="(<video>./</video>)",
image_token_id=self.im_token_id,
).build(_processor)
@staticmethod
def _has_special_format(image_data, audio_data):
"""Check if any input items use processor_output or precomputed_embedding format."""
for data in list(image_data or []) + list(audio_data or []):
if isinstance(data, dict) and data.get("format") in (
"processor_output",
"precomputed_embedding",
):
return True
return False
async def _process_special_format(
self, image_data, audio_data, input_text, request_obj, **kwargs
):
"""Handle processor_output and precomputed_embedding input formats.
Delegates to the base class process_and_combine_mm_data which has
built-in support for these formats.
"""
if isinstance(input_text, list):
user_input_ids = input_text
prompt = ""
else:
user_input_ids = None
prompt = input_text or ""
# Normalize dicts: the HF MiniCPM processor returns "tgt_sizes" (plural)
# but the base class ATTR_NAME_TO_MODALITY maps "tgt_size" (singular).
# Also flatten the nested batch dimension so the structure matches
# what the NORMAL path produces (flat list of per-patch tensors).
normalized_images = []
for d in image_data or []:
if isinstance(d, dict):
d = dict(d)
if "tgt_sizes" in d and "tgt_size" not in d:
d["tgt_size"] = d.pop("tgt_sizes")
if d.get("format") == "processor_output":
pixel_values = d.get("pixel_values")
tgt_size = d.get("tgt_size")
if pixel_values is not None and tgt_size is not None:
pv_flat, ts_flat = [], []
for pixel_b, tgt_b in zip(pixel_values, tgt_size):
if isinstance(pixel_b, (list, tuple)):
for pixel_n, tgt_n in zip(pixel_b, tgt_b):
pv_flat.append(pixel_n)
ts_flat.append(tgt_n)
else:
pv_flat.append(pixel_b)
ts_flat.append(tgt_b)
d["pixel_values"] = pv_flat
d["tgt_size"] = ts_flat
normalized_images.append(d)
else:
normalized_images.append(d)
normalized_audios = list(audio_data or [])
if not prompt and (normalized_images or normalized_audios):
images = [d for d in normalized_images if isinstance(d, dict)]
audios = [d for d in normalized_audios if isinstance(d, dict)]
raw_img_dropped = len(normalized_images) - len(images)
raw_aud_dropped = len(normalized_audios) - len(audios)
if raw_img_dropped > 0 or raw_aud_dropped > 0:
raise ValueError(
f"[minicpm] Cannot process raw media with pre-tokenized "
f"input_ids. Provide multimodal data in 'processor_output' or "
f"'precomputed_embedding' format, or use a text prompt instead. "
f"(raw images dropped: {raw_img_dropped}, "
f"raw audios dropped: {raw_aud_dropped})"
)
base_output = BaseMultiModalProcessorOutput(
input_text=prompt,
images=images,
audios=audios,
)
else:
base_output = await self.load_mm_data(
prompt=prompt,
image_data=normalized_images,
audio_data=audio_data,
multimodal_tokens=self.mm_tokens,
)
if base_output is None:
return None
mm_items, input_ids_tensor, ret = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
if user_input_ids is not None:
input_ids_tensor = torch.tensor(user_input_ids, dtype=torch.long)
for mm_item in mm_items:
if mm_item.modality == Modality.IMAGE:
image_offsets = self.get_mm_items_offset_by_pair(
input_ids=input_ids_tensor,
mm_start_id=self.im_start_id,
mm_end_id=self.im_end_id,
)
slice_offsets = self.get_mm_items_offset_by_pair(
input_ids=input_ids_tensor,
mm_start_id=self.slice_start_id,
mm_end_id=self.slice_end_id,
)
image_offsets.extend(slice_offsets)
mm_item.offsets = sorted(image_offsets)
elif mm_item.modality == Modality.AUDIO:
if (
self.audio_start_id is not None
and self.audio_end_id is not None
):
mm_item.offsets = self.get_mm_items_offset_by_pair(
input_ids=input_ids_tensor,
mm_start_id=self.audio_start_id,
mm_end_id=self.audio_end_id,
)
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids_tensor.flatten().tolist(),
audio_start_id=self.audio_start_id,
audio_end_id=self.audio_end_id,
im_token_id=self.im_token_id,
im_start_id=self.im_start_id,
im_end_id=self.im_end_id,
slice_start_id=self.slice_start_id,
slice_end_id=self.slice_end_id,
)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
audio_data: List[Union[str, bytes]],
input_text,
request_obj,
**kwargs,
):
if isinstance(input_text, list) or self._has_special_format(
image_data, audio_data
):
return await self._process_special_format(
image_data=image_data,
audio_data=audio_data,
input_text=input_text,
request_obj=request_obj,
**kwargs,
)
base_output = await self.load_mm_data(
prompt=input_text,
audio_data=audio_data,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
)
if base_output is None:
return None
res = self.process_mm_data(
input_text=base_output.input_text,
images=base_output.images,
audios=base_output.audios,
)
pixel_values = res["pixel_values"]
tgt_sizes = res["tgt_sizes"]
if not isinstance(pixel_values, (torch.Tensor, list)):
raise ValueError(
"Incorrect type of pixel values. " f"Got type: {type(pixel_values)}"
)
if not isinstance(tgt_sizes, (torch.Tensor, list)):
raise ValueError(
"Incorrect type of target sizes. " f"Got type: {type(tgt_sizes)}"
)
if len(pixel_values) != len(tgt_sizes):
raise ValueError(
"Inconsistent batch lengths, found: "
f"{len(pixel_values)} vs. {len(tgt_sizes)}"
)
# Track slices per image (like vLLM's num_slices)
slices_per_image: List[int] = []
pixel_values_flat: List[torch.Tensor] = []
tgt_sizes_flat: List[torch.Tensor] = []
for pixel_b, tgt_b in zip(pixel_values, tgt_sizes):
# per image
if len(pixel_b) != len(tgt_b):
raise ValueError(
"Inconsistent N lengths, found: " f"{len(pixel_b)} vs {len(tgt_b)}"
)
slices_per_image.append(len(pixel_b))
for pixel_n, tgt_n in zip(pixel_b, tgt_b):
pixel_values_flat += [pixel_n]
tgt_sizes_flat += [tgt_n]
pixel_values = pixel_values_flat
items = []
input_ids = res["input_ids"].flatten()
image_offsets = self.get_mm_items_offset_by_pair(
input_ids=input_ids, mm_start_id=self.im_start_id, mm_end_id=self.im_end_id
)
slice_offsets = self.get_mm_items_offset_by_pair(
input_ids=input_ids,
mm_start_id=self.slice_start_id,
mm_end_id=self.slice_end_id,
)
image_offsets.extend(slice_offsets)
image_offsets = sorted(image_offsets)
# Create one item per image, each with its own slices and offsets
if len(pixel_values) != 0:
pv_idx = 0
offset_idx = 0
for num_slices in slices_per_image:
items.append(
MultimodalDataItem(
feature=pixel_values[pv_idx : pv_idx + num_slices],
offsets=image_offsets[offset_idx : offset_idx + num_slices],
model_specific_data={
"tgt_size": tgt_sizes_flat[pv_idx : pv_idx + num_slices]
},
modality=Modality.IMAGE,
)
)
pv_idx += num_slices
offset_idx += num_slices
if (
"audio_features" in res
and res["audio_features"] is not None
and len(res["audio_features"]) != 0
):
if self.audio_start_id is not None and self.audio_end_id is not None:
audio_offsets = self.get_mm_items_offset_by_pair(
input_ids=input_ids,
mm_start_id=self.audio_start_id,
mm_end_id=self.audio_end_id,
)
else:
audio_offsets = None
item = MultimodalDataItem(
feature=[res["audio_features"]],
model_specific_data={"audio_feature_lens": res["audio_feature_lens"]},
offsets=audio_offsets,
modality=Modality.AUDIO,
)
items += [item]
return MultimodalProcessorOutput(
mm_items=items,
input_ids=input_ids.tolist(),
audio_start_id=self.audio_start_id,
audio_end_id=self.audio_end_id,
im_token_id=self.im_token_id,
im_start_id=self.im_start_id,
im_end_id=self.im_end_id,
slice_start_id=self.slice_start_id,
slice_end_id=self.slice_end_id,
)
@@ -0,0 +1,548 @@
# Copyright 2026 The SGLang team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
"""sglang multimodal processor for MiniCPM-V 4.6.
Ports per-image preprocessing + chat-template expansion sglang-side because
no working HF ``MiniCPMV4_6Processor`` is reachable yet: transformers main
does not ship one until 5.7+, and the released 4.6 checkpoints ship only a
tokenizer (no remote-code processor), so ``AutoProcessor.from_pretrained``
falls through to a bare tokenizer. Once a real processor is loadable, this
module collapses to a thin wrapper that delegates to it.
"""
from __future__ import annotations
import math
from itertools import chain
from typing import Any, List, Optional, Sequence, Tuple, Union
import torch
import torchvision.transforms.functional as F
from PIL import Image
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.models.minicpmv import MiniCPMV4_6ForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
IMAGENET_STANDARD_MEAN = (0.5, 0.5, 0.5)
IMAGENET_STANDARD_STD = (0.5, 0.5, 0.5)
# Inner per-feature pad sentinel: prevents the next per-image
# ``replace(image_token, ...)`` from clobbering a previous expansion's inner
# pads. Swapped back to the real pad token once per modality after splicing.
_PAD_PLACEHOLDER = "<|placeholder|>"
def _ensure_divide(length: int, divisor: int) -> int:
return max(round(length / divisor) * divisor, divisor)
def _to_chw_tensor(image) -> torch.Tensor:
"""PIL / torch / numpy -> ``(C, H, W)`` float32 in ``[0, 255]``.
Image inputs from ``load_mm_data`` are PIL; video frames from sglang's
video decoder come back as numpy arrays.
"""
if isinstance(image, torch.Tensor):
if image.dim() == 4:
image = image.squeeze(0)
if image.dim() != 3:
raise ValueError(f"expected 3-D image tensor, got {image.shape}")
if image.shape[0] not in (1, 3, 4):
image = image.permute(2, 0, 1).contiguous()
if image.shape[0] == 4:
image = image[:3]
if image.shape[0] == 1:
image = image.repeat(3, 1, 1)
return image.float()
if isinstance(image, Image.Image):
if image.mode != "RGB":
image = image.convert("RGB")
return F.pil_to_tensor(image).float()
import numpy as np
if isinstance(image, np.ndarray):
t = torch.from_numpy(image)
if t.dim() == 3 and t.shape[-1] in (1, 3, 4):
t = t.permute(2, 0, 1).contiguous()
if t.shape[0] == 4:
t = t[:3]
if t.shape[0] == 1:
t = t.repeat(3, 1, 1)
return t.float()
raise TypeError(f"Unsupported image type: {type(image)!r}")
def _resize(image: torch.Tensor, height: int, width: int) -> torch.Tensor:
return F.resize(
image,
size=[height, width],
interpolation=F.InterpolationMode.BICUBIC,
antialias=True,
)
def _divide_to_patches(
image: torch.Tensor, patch_h: int, patch_w: int
) -> List[torch.Tensor]:
_, H, W = image.shape
if H % patch_h != 0 or W % patch_w != 0:
raise ValueError(f"image ({H}, {W}) not divisible by ({patch_h}, {patch_w})")
rows = H // patch_h
cols = W // patch_w
patches: List[torch.Tensor] = []
for r in range(rows):
for c in range(cols):
patches.append(
image[
:, r * patch_h : (r + 1) * patch_h, c * patch_w : (c + 1) * patch_w
]
)
return patches
def _reshape_by_patch(image: torch.Tensor, patch_size: int) -> torch.Tensor:
"""``(C, H, W) -> (C, P, H*W/P)`` NaViT packing."""
C = image.shape[0]
patches = torch.nn.functional.unfold(
image.unsqueeze(0), (patch_size, patch_size), stride=(patch_size, patch_size)
)
patches = patches.reshape(C, patch_size, patch_size, -1)
patches = patches.permute(0, 1, 3, 2).reshape(C, patch_size, -1)
return patches
def _flatten_patches(
per_item_pv: List[List[torch.Tensor]],
per_item_ts: List[List[List[int]]],
) -> Tuple[List[torch.Tensor], List[torch.Tensor]]:
"""Per-item per-patch -> flat per-patch (source first, slices row-major)."""
flat_pv = list(chain.from_iterable(per_item_pv))
flat_ts = [
torch.tensor(ts, dtype=torch.int32) for ts in chain.from_iterable(per_item_ts)
]
return flat_pv, flat_ts
class MiniCPMV4_6ImageProcessor:
"""Per-image preprocessing.
Pipeline: pick a slice grid (rows x cols, up to ``max_slice_nums``); resize
source and (optionally) tiles to multiples of ``patch_size * 4`` (factor 4
= the two successive 2x2 spatial merges: mid-ViT merger + DownsampleMLP);
rescale, normalize, and NaViT-pack each tile into ``(C, P, H*W/P)``.
"""
def __init__(
self,
max_slice_nums: int = 9,
scale_resolution: int = 448,
patch_size: int = 14,
slice_mode: bool = True,
downsample_mode: str = "16x",
use_image_id: bool = True,
image_mean: Sequence[float] = IMAGENET_STANDARD_MEAN,
image_std: Sequence[float] = IMAGENET_STANDARD_STD,
rescale_factor: float = 1.0 / 255.0,
) -> None:
self.max_slice_nums = max_slice_nums
self.scale_resolution = scale_resolution
self.patch_size = patch_size
self.slice_mode = slice_mode
self.downsample_mode = downsample_mode
self.use_image_id = use_image_id
self.image_mean = torch.tensor(image_mean, dtype=torch.float32).view(3, 1, 1)
self.image_std = torch.tensor(image_std, dtype=torch.float32).view(3, 1, 1)
self.rescale_factor = rescale_factor
def _find_best_resize(
self,
image_size: Tuple[int, int],
allow_upscale: bool = False,
) -> Tuple[int, int]:
height, width = image_size
scale = self.scale_resolution
# factor 4 = two successive 2x2 spatial merges (mid-ViT + DownsampleMLP)
divisor = self.patch_size * 4
if (height * width > scale * scale) or allow_upscale:
aspect_ratio = width / height
height = int(scale / math.sqrt(aspect_ratio))
width = int(height * aspect_ratio)
best_w = _ensure_divide(width, divisor)
best_h = _ensure_divide(height, divisor)
return best_h, best_w
def _get_refine_size(
self,
image_size: Tuple[int, int],
grid: Tuple[int, int],
allow_upscale: bool = False,
) -> Tuple[int, int]:
height, width = image_size
grid_y, grid_x = grid
refine_w = _ensure_divide(width, grid_x)
refine_h = _ensure_divide(height, grid_y)
bh, bw = self._find_best_resize(
(refine_h // grid_y, refine_w // grid_x),
allow_upscale=allow_upscale,
)
return bh * grid_y, bw * grid_x
def _get_sliced_grid(
self, image_size: Tuple[int, int]
) -> Optional[Tuple[int, int]]:
original_h, original_w = image_size
scale = self.scale_resolution
log_ratio = math.log(original_w / original_h)
ratio = original_w * original_h / (scale * scale)
multiple = min(math.ceil(ratio), self.max_slice_nums)
if multiple <= 1:
return None
best_grid = (1, 1)
min_error = float("inf")
for num_slices in (multiple - 1, multiple, multiple + 1):
if num_slices == 1 or num_slices > self.max_slice_nums:
continue
for num_rows in range(1, num_slices + 1):
if num_slices % num_rows != 0:
continue
num_cols = num_slices // num_rows
error = abs(log_ratio - math.log(num_rows / num_cols))
if error < min_error:
# Ref returns ``[cols, rows]``; preserve the convention so
# downstream code matches HF.
best_grid = (num_cols, num_rows)
min_error = error
return best_grid
def _normalize(self, t: torch.Tensor) -> torch.Tensor:
t = t * self.rescale_factor
return (t - self.image_mean.to(t.dtype)) / self.image_std.to(t.dtype)
def __call__(self, images: List) -> dict:
return self.preprocess(images)
def preprocess(self, images: List) -> dict:
"""Returns ``{pixel_values, tgt_sizes, grids, num_patches_per_image}``.
Per image, ``pixel_values[i]`` is a list whose first entry is the
source patch and remaining entries are slice tiles in row-major grid
order. ``grids[i]`` is ``[cols, rows]`` (zeros if no slicing).
"""
per_image_pv: List[List[torch.Tensor]] = []
per_image_ts: List[List[List[int]]] = []
all_grids: List[List[int]] = []
num_patches_per_image: List[int] = []
for image in images:
chw = _to_chw_tensor(image)
H0, W0 = chw.shape[-2], chw.shape[-1]
best_grid = self._get_sliced_grid((H0, W0)) if self.slice_mode else None
allow_upscale_src = best_grid is None
src_h, src_w = self._find_best_resize(
(H0, W0), allow_upscale=allow_upscale_src
)
source = _resize(chw, src_h, src_w)
patches: List[torch.Tensor] = [source]
patch_h = patch_w = 0
if best_grid is not None:
refine_h, refine_w = self._get_refine_size(
(H0, W0), best_grid, allow_upscale=True
)
refined = _resize(chw, refine_h, refine_w)
grid_y, grid_x = best_grid
patch_h = refine_h // grid_y
patch_w = refine_w // grid_x
patches.extend(_divide_to_patches(refined, patch_h, patch_w))
patches = [self._normalize(p) for p in patches]
pv = [_reshape_by_patch(patches[0], self.patch_size)]
ts = [[src_h // self.patch_size, src_w // self.patch_size]]
for p in patches[1:]:
pv.append(_reshape_by_patch(p, self.patch_size))
ts.append([patch_h // self.patch_size, patch_w // self.patch_size])
per_image_pv.append(pv)
per_image_ts.append(ts)
all_grids.append(list(best_grid) if best_grid is not None else [0, 0])
num_patches_per_image.append(len(pv))
return {
"pixel_values": per_image_pv,
"tgt_sizes": per_image_ts,
"grids": all_grids,
"num_patches_per_image": num_patches_per_image,
}
class MiniCPMV4_6MultimodalProcessor(BaseMultimodalProcessor):
"""4.6-only mm processor.
The legacy ``MiniCPMMultimodalProcessor`` stays for 2.6/4.0/4.5 because its
``_processor.tokenizer`` shape and ``(<image>./</image>)`` placeholder
format don't fit 4.6.
"""
models = [MiniCPMV4_6ForConditionalGeneration]
support_dynamic_frame_expansion = False
gpu_image_decode = False
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
# ``_processor`` is either the bare tokenizer (current state — no
# ``MiniCPMV4_6Processor`` shipped) or a real processor whose
# ``.tokenizer`` exposes the same.
self.tokenizer = getattr(_processor, "tokenizer", _processor)
vision_cfg = getattr(hf_config, "vision_config", None)
patch_size = (
getattr(vision_cfg, "patch_size", 14) if vision_cfg is not None else 14
)
downsample_mode = getattr(hf_config, "downsample_mode", "16x")
# Per-image preprocessor; reused for video frames (HF ref's
# video slicing geometry matches image slicing exactly).
self.image_processor = MiniCPMV4_6ImageProcessor(
max_slice_nums=9,
scale_resolution=448,
patch_size=patch_size,
slice_mode=True,
downsample_mode=downsample_mode,
use_image_id=True,
)
self.image_token = "<|image_pad|>"
self.video_token = "<|video_pad|>"
self.image_token_id = getattr(hf_config, "image_token_id", None)
if self.image_token_id is None:
self.image_token_id = self._token_id(self.image_token)
self.video_token_id = getattr(hf_config, "video_token_id", None)
if self.video_token_id is None:
self.video_token_id = self._token_id(self.video_token)
# ``<image>``/``<slice>`` wrap the expanded regions for both images and
# video frames; only the inner per-feature pad token differs.
self.image_start_token = "<image>"
self.image_end_token = "</image>"
self.slice_start_token = "<slice>"
self.slice_end_token = "</slice>"
self.image_id_start_token = "<image_id>"
self.image_id_end_token = "</image_id>"
self.image_start_id = self._token_id(self.image_start_token)
self.image_end_id = self._token_id(self.image_end_token)
self.slice_start_id = self._token_id(self.slice_start_token)
self.slice_end_id = self._token_id(self.slice_end_token)
self.pad_divisor = 16 if downsample_mode != "4x" else 4
self.mm_tokens = MultimodalSpecialTokens(
image_token=self.image_token,
image_token_id=self.image_token_id,
video_token=self.video_token,
video_token_id=self.video_token_id,
).build(_processor)
def _token_id(self, token: str):
try:
ids = self.tokenizer.convert_tokens_to_ids([token])
if ids and ids[0] is not None:
return int(ids[0])
except Exception:
pass
return None
def _expand_frame(
self,
tgt_sizes: List[List[int]],
grid: List[int],
) -> str:
"""``<image>...</image>`` (+ optional ``<slice>...</slice>`` rows) for
one image or video frame; inner pads are ``_PAD_PLACEHOLDER`` (caller
swaps back after splicing).
"""
h0, w0 = tgt_sizes[0]
n_src = (h0 * w0) // self.pad_divisor
out = self.image_start_token + _PAD_PLACEHOLDER * n_src + self.image_end_token
if len(tgt_sizes) > 1 and grid and grid[0] > 0 and grid[1] > 0:
grid_y, grid_x = int(grid[0]), int(grid[1])
h_s, w_s = tgt_sizes[1]
n_slice = (h_s * w_s) // self.pad_divisor
slice_chunk = (
self.slice_start_token
+ _PAD_PLACEHOLDER * n_slice
+ self.slice_end_token
)
row_chunks = [slice_chunk * grid_x for _ in range(grid_y)]
out += "\n".join(row_chunks)
return out
def _expand_media(
self,
index: int,
frames: Sequence[Tuple[List[List[int]], List[int]]],
) -> str:
"""One image or one video. Image is a single-frame video."""
body = "".join(self._expand_frame(ts, grid) for ts, grid in frames)
return f"{self.image_id_start_token}{index}{self.image_id_end_token}" + body
async def process_mm_data_async(
self,
image_data: Sequence[Union[str, bytes]],
audio_data: Sequence[Union[str, bytes]],
input_text,
request_obj,
**kwargs: Any,
):
# ``TokenizerManager`` does not pass ``video_data`` through the
# processor signature; read it off the request the way qwen_vl does.
video_data = getattr(request_obj, "video_data", None) or kwargs.get(
"video_data"
)
base = await self.load_mm_data(
prompt=input_text,
audio_data=audio_data,
image_data=image_data,
video_data=video_data,
multimodal_tokens=self.mm_tokens,
)
if base is None:
return None
prompt: str = base.input_text or ""
images = base.images or []
videos = base.videos or []
# Image: one "frame" per image. Video: per-frame nesting kept so each
# frame becomes its own ``<image>...</image>`` block in the expansion.
img_per_pv, img_per_ts, img_grids = self._preprocess_images(images)
vid_per_pv, vid_per_ts, vid_grids = self._preprocess_videos(videos)
prompt = self._splice_expansions(
prompt,
(
self._expand_media(i, [(ts, gd)])
for i, (ts, gd) in enumerate(zip(img_per_ts, img_grids))
),
(
self._expand_media(i, list(zip(fts, fgd)))
for i, (fts, fgd) in enumerate(zip(vid_per_ts, vid_grids))
),
)
input_ids: List[int] = self.tokenizer.encode(prompt, add_special_tokens=False)
input_ids_tensor = torch.tensor(input_ids, dtype=torch.long)
# Each patch's pad tokens are guaranteed contiguous (the expansion
# functions wrap them in ``<image>...</image>`` / ``<slice>...</slice>``
# with nothing else in between), so a per-token-id contiguous-run scan
# — base's ``get_mm_items_offset`` — gives one (start, end) per patch.
mm_items: List[MultimodalDataItem] = []
mm_items.extend(
self._build_items(
input_ids_tensor,
self.image_token_id,
_flatten_patches(img_per_pv, img_per_ts),
Modality.IMAGE,
)
)
# Video: extra ``per-frame -> per-patch`` nesting; pre-flatten one
# level so ``_flatten_patches`` sees the same shape as image.
vid_pv_flat = [list(chain.from_iterable(v)) for v in vid_per_pv]
vid_ts_flat = [list(chain.from_iterable(v)) for v in vid_per_ts]
mm_items.extend(
self._build_items(
input_ids_tensor,
self.video_token_id,
_flatten_patches(vid_pv_flat, vid_ts_flat),
Modality.VIDEO,
)
)
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids,
im_token_id=self.image_token_id,
im_start_id=self.image_start_id,
im_end_id=self.image_end_id,
slice_start_id=self.slice_start_id,
slice_end_id=self.slice_end_id,
)
def _preprocess_images(self, images):
if not images:
return [], [], []
out = self.image_processor.preprocess(images)
return out["pixel_values"], out["tgt_sizes"], out["grids"]
def _preprocess_videos(self, videos):
per_video_pv: List[List[List[torch.Tensor]]] = []
per_video_ts: List[List[List[List[int]]]] = []
per_video_grids: List[List[List[int]]] = []
for frames in videos:
out = self.image_processor.preprocess(list(frames))
per_video_pv.append(out["pixel_values"])
per_video_ts.append(out["tgt_sizes"])
per_video_grids.append(out["grids"])
return per_video_pv, per_video_ts, per_video_grids
def _splice_expansions(self, prompt, image_expansions, video_expansions):
# The chat template emits exactly one marker per media item; a
# sequential ``replace(..., n=1)`` walk lines them up by left-to-right
# order. Expansions carry ``_PAD_PLACEHOLDER`` for inner pads so the
# next replace doesn't trip on a previous expansion's pads — we swap
# placeholders back to the real pad token in one pass per modality.
for token, expansions in (
(self.image_token, image_expansions),
(self.video_token, video_expansions),
):
for expansion in expansions:
if token not in prompt:
break
prompt = prompt.replace(token, expansion, 1)
prompt = prompt.replace(_PAD_PLACEHOLDER, token)
return prompt
def _build_items(
self,
input_ids: torch.Tensor,
pad_token_id: int,
flat: Tuple[List[torch.Tensor], List[torch.Tensor]],
modality: Modality,
) -> List[MultimodalDataItem]:
flat_pv, flat_ts = flat
runs = self.get_mm_items_offset(input_ids, pad_token_id)
if len(runs) != len(flat_pv):
raise RuntimeError(
f"[minicpmv4_6] {modality} pad run / feature count mismatch: "
f"{len(runs)} runs vs {len(flat_pv)} patches"
)
return [
MultimodalDataItem(
feature=[pv],
offsets=[run],
model_specific_data={"tgt_size": [ts]},
modality=modality,
)
for run, pv, ts in zip(runs, flat_pv, flat_ts)
]
@@ -0,0 +1,283 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
"""HF processor classes live in sglang.srt.configs.minimax_vl_processor to avoid circular imports with model classes."""
import math
import re
from typing import Dict, List, Optional, Tuple, Union
import torch
import torchvision
from torchvision.transforms import InterpolationMode
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.minimax_m3_vl import MiniMaxM3SparseForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
from sglang.srt.utils import round_up
def get_hw_multiple_of(
image_size: Tuple[int, int],
multiple: int,
max_size: Union[None, int, Tuple[int, int]] = None,
) -> Tuple[int, int]:
w, h = image_size
if isinstance(max_size, int):
ratio = 1.0
max_dim = max(w, h)
if max_dim > max_size:
ratio = max_size / max_dim
new_w = round_up(round(w * ratio), multiple)
new_h = round_up(round(h * ratio), multiple)
return new_w, new_h
new_w = round_up(w, multiple)
new_h = round_up(h, multiple)
if max_size is not None:
assert isinstance(max_size, (list, tuple)) and len(max_size) == 2
max_w, max_h = max_size
assert max_w % multiple == 0 and max_h % multiple == 0
if new_w > max_w or new_h > max_h:
new_w_ = min((new_w * max_w) // new_w, (new_w * max_h) // new_h)
new_h_ = min((new_h * max_w) // new_w, (new_h * max_h) // new_h)
new_w = new_w_
new_h = new_h_
new_w = (
new_w
if new_w % multiple == 0
else new_w + (multiple - new_w % multiple)
)
new_h = (
new_h
if new_h % multiple == 0
else new_h + (multiple - new_h % multiple)
)
assert new_w % multiple == 0 and new_h % multiple == 0
assert new_w <= max_w and new_h <= max_h
return new_w, new_h
def _compute_sampled_frame_indices(
total_frames: int,
video_fps: float,
fps: float,
max_frames: Optional[int] = None,
) -> List[int]:
"""Frame indices must match SFT extract_frame.py constant-mode sampling (>=1/fps apart, always keep last) or eval diverges from training."""
if total_frames <= 0 or video_fps <= 0 or fps <= 0:
return [0] if total_frames > 0 else []
read_time_interval = 1.0 / fps
eps = 1e-4
indices: List[int] = []
prev_kept_ts = -float("inf")
while True:
if not indices:
target_frame = 0
else:
target_ts = prev_kept_ts + read_time_interval - eps
target_frame = math.ceil(target_ts * video_fps)
target_frame = max(target_frame, indices[-1] + 1)
if target_frame >= total_frames:
break
indices.append(target_frame)
prev_kept_ts = target_frame / video_fps
last_frame_idx = total_frames - 1
last_ts = last_frame_idx / video_fps
if indices and indices[-1] != last_frame_idx and last_ts - prev_kept_ts > eps:
indices.append(last_frame_idx)
if not indices:
indices = [0]
if max_frames is not None and len(indices) > max_frames > 0:
last = indices[-1]
if max_frames == 1:
# max_frames == 1 would divide by (max_frames - 1) == 0 below; keep only the last frame.
indices = [last]
else:
step = len(indices) / (max_frames - 1)
indices = [indices[int(i * step)] for i in range(max_frames - 1)]
indices.append(last)
return indices
async def get_video_tensor(
vr,
image_factor: int,
max_size: Tuple[int, int],
fps: Optional[float] = None,
frame_max_size: Optional[int] = None,
max_frames: Optional[int] = None,
) -> Tuple[torch.Tensor, dict]:
if fps is None:
fps = 1.0
if frame_max_size is None:
frame_max_size = max_size[0]
if fps <= 0:
raise ValueError(f"video fps must be > 0, got {fps}")
if isinstance(vr, torch.Tensor):
video_tchw = vr
_, _, height, width = video_tchw.shape
resized_width, resized_height = get_hw_multiple_of(
(width, height), image_factor, max_size
)
resized = torchvision.transforms.functional.resize(
video_tchw,
[resized_height, resized_width],
interpolation=InterpolationMode.BICUBIC,
)
return resized, {
"total_num_frames": resized.shape[0],
"fps": None,
"frames_indices": None,
}
total_frames = len(vr)
video_fps = vr.avg_fps
if video_fps <= 0 or total_frames <= 0:
raise ValueError(
f"Invalid video metadata: fps={video_fps}, frames={total_frames}"
)
indices = _compute_sampled_frame_indices(total_frames, video_fps, fps, max_frames)
video_tchw = vr.get_frames_as_tensor(indices)
video_tchw = video_tchw.permute(0, 3, 1, 2).float()
_, _, height, width = video_tchw.shape
resized_width, resized_height = get_hw_multiple_of(
(width, height), image_factor, frame_max_size
)
resized = torchvision.transforms.functional.resize(
video_tchw,
[resized_height, resized_width],
interpolation=InterpolationMode.BICUBIC,
)
return resized, {
"total_num_frames": total_frames,
"fps": video_fps,
"frames_indices": indices,
}
class MiniMaxM3VLProcessor(BaseMultimodalProcessor):
models = [
MiniMaxM3SparseForConditionalGeneration,
]
gpu_image_decode = False
# M3's tokenizer has no pad_token.
tokenizer_padding = False
IMAGE_TOKEN = "]<]image[>["
VIDEO_TOKEN = "]<]video[>["
IMAGE_START_TOKEN = "]<]start of image[>["
IMAGE_END_TOKEN = "]<]end of image[>["
@staticmethod
def _token_id(tokenizer, token):
token_id = tokenizer.convert_tokens_to_ids(token)
assert token_id is not None, f"token id for {token!r} not found"
return token_id
@property
def spatial_merge_size(self):
return self._processor.image_processor.merge_size
def _video_resize_config(self):
video_processor = self._processor.video_processor
image_factor = video_processor.patch_size * video_processor.merge_size
# Newer M3 video processors expose max_pixels (area) instead of max_size; derive an equivalent (max_w, max_h) cap.
max_size = getattr(video_processor, "max_size", None)
if max_size is None:
max_pixels = getattr(video_processor, "max_pixels", None)
if max_pixels is not None:
side = int(math.isqrt(int(max_pixels)))
side -= side % image_factor
max_size = (side, side)
else:
max_size = video_processor._max_size_from_size(video_processor.size)
assert max_size is not None, "video processor max_size is required"
return image_factor, max_size
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
tokenizer = _processor.tokenizer
assert tokenizer is not None, "tokenizer is required"
self.IM_TOKEN_ID = self._token_id(tokenizer, self.IMAGE_TOKEN)
self.VIDEO_TOKEN_ID = self._token_id(tokenizer, self.VIDEO_TOKEN)
self.IM_START_TOKEN_ID = self._token_id(tokenizer, self.IMAGE_START_TOKEN)
self.IM_END_TOKEN_ID = self._token_id(tokenizer, self.IMAGE_END_TOKEN)
self.video_fps = self.video_config.pop("fps", None)
self.video_frame_max_size = self.video_config.pop("frame_max_size", None)
self.video_max_frames = self.video_config.pop("max_frames", None)
self.mm_tokens = MultimodalSpecialTokens(
image_token=self.IMAGE_TOKEN,
image_token_id=self.IM_TOKEN_ID,
image_token_regex=re.compile(
r"<image>|<\|image\|>|<\|image_pad\|>|\]\<\]image\[\>\["
),
video_token=self.VIDEO_TOKEN,
video_token_id=self.VIDEO_TOKEN_ID,
video_token_regex=re.compile(r"<video>|<\|video\|>|\]\<\]video\[\>\["),
).build(_processor)
async def process_mm_data_async(
self,
image_data: Optional[List],
audio_data: Optional[List],
input_text: str,
request_obj,
**kwargs,
) -> Dict:
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
video_data=request_obj.video_data,
multimodal_tokens=self.mm_tokens,
)
video_metadata = None
if base_output.videos:
image_factor, max_size = self._video_resize_config()
videos_processed = [
await get_video_tensor(
video,
image_factor=image_factor,
max_size=max_size,
fps=self.video_fps,
frame_max_size=self.video_frame_max_size,
max_frames=self.video_max_frames,
)
for video in base_output.videos
]
base_output.videos, video_metadata = map(list, zip(*videos_processed))
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output=base_output,
mm_tokens=self.mm_tokens,
video_metadata=video_metadata,
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist() if hasattr(input_ids, "tolist") else input_ids,
mm_items=mm_items,
im_start_id=self.IM_START_TOKEN_ID,
im_end_id=self.IM_END_TOKEN_ID,
im_token_id=self.IM_TOKEN_ID,
video_token_id=self.VIDEO_TOKEN_ID,
)
@@ -0,0 +1,38 @@
from typing import List, Union
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.mllama import MllamaForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
class MllamaImageProcessor(BaseMultimodalProcessor):
models = [MllamaForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.mm_tokens = MultimodalSpecialTokens(
image_token=self._processor.image_token,
image_token_id=self._processor.image_token_id,
).build(_processor)
async def process_mm_data_async(
self, image_data: List[Union[str, bytes]], input_text, *args, **kwargs
):
base_out = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_out, self.mm_tokens
)
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids.tolist(),
im_token_id=self.mm_tokens.image_token_id,
)
@@ -0,0 +1,50 @@
from typing import List, Union
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.mllama4 import Llama4ForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
class Mllama4ImageProcessor(BaseMultimodalProcessor):
models = [Llama4ForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.vision_config = hf_config.vision_config
self.text_config = hf_config.text_config
self.IM_START_TOKEN_ID = hf_config.boi_token_index
self.IM_END_TOKEN_ID = hf_config.eoi_token_index
self.IM_TOKEN_ID = hf_config.image_token_index
self.mm_tokens = MultimodalSpecialTokens(
image_token=_processor.image_token,
image_token_id=self.IM_TOKEN_ID,
).build(_processor)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
input_text,
*args,
**kwargs,
):
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
)
# Process the prompt and images
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_start_id=self.IM_START_TOKEN_ID,
im_end_id=self.IM_END_TOKEN_ID,
im_token_id=self.IM_TOKEN_ID,
)
@@ -0,0 +1,612 @@
import asyncio
import os
import re
import tempfile
from typing import Dict, List, Optional, Tuple, Union
from urllib.parse import unquote, urlparse
import pybase64
import requests
import torch
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.models.moss_vl import MossVLForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
SGL_USE_CUDA_IPC,
)
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.multimodal.processors.base_processor import (
MultimodalSpecialTokens,
)
from sglang.srt.utils.cuda_ipc_transport_utils import CudaIpcTensorTransportProxy
class MossVLImageProcessor(SGLangBaseProcessor):
models = [MossVLForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.image_only_mm_tokens = MultimodalSpecialTokens(
image_token="<|image|>",
image_token_regex=re.compile(re.escape("<|image|>")),
).build(_processor)
self.image_token_id = getattr(hf_config, "image_token_id", None)
self.vision_seq_pad_multiple = 1
def _build_mm_items(
self, processor_output: Dict, input_ids: torch.Tensor
) -> List[MultimodalDataItem]:
pixel_values = processor_output.get("pixel_values")
if pixel_values is None:
return []
item = MultimodalDataItem(
modality=Modality.IMAGE,
feature=pixel_values,
model_specific_data={},
)
grid_thw = processor_output.get("grid_thw")
if grid_thw is not None:
item.set("grid_thw", grid_thw)
return [item]
def _build_vision_token_info(
self,
grid_thw: Optional[torch.Tensor],
media_nums_per_sample: Optional[List[int]],
) -> List[dict]:
if grid_thw is None:
return []
grid_thw = torch.as_tensor(grid_thw, dtype=torch.long)
if grid_thw.ndim == 1:
grid_thw = grid_thw.unsqueeze(0)
if grid_thw.numel() == 0:
return []
tokens_per_media = (grid_thw[:, 0] * grid_thw[:, 1] * grid_thw[:, 2]) // (
self.spatial_merge_size**2
)
if media_nums_per_sample is None:
media_nums_per_sample = [grid_thw.shape[0]]
batch_size = len(media_nums_per_sample)
if batch_size == 1:
total_len = 0
for i in range(grid_thw.shape[0]):
num_tokens = tokens_per_media[i].item()
num_frames = grid_thw[i, 0].item()
total_len += num_tokens + num_frames
if total_len % self.vision_seq_pad_multiple != 0:
max_seq_len = (
(total_len + self.vision_seq_pad_multiple - 1)
// self.vision_seq_pad_multiple
* self.vision_seq_pad_multiple
)
else:
max_seq_len = total_len
sample_info = {
"medias": [],
"total_length": total_len,
"pad_start": total_len,
"pad_end": max_seq_len,
}
current_seq_len = 0
for media_idx in range(grid_thw.shape[0]):
num_tokens = tokens_per_media[media_idx].item()
t, h, w = grid_thw[media_idx].tolist()
num_frames = t
tokens_per_frame = num_tokens // num_frames
chunk_len = num_frames * (tokens_per_frame + 1)
sample_info["medias"].append(
{
"start": current_seq_len,
"end": current_seq_len + chunk_len,
"length": chunk_len,
"num_frames": num_frames,
"grid_h": h,
"grid_w": w,
"vision_tokens_per_frame": tokens_per_frame,
"has_separator": True,
}
)
current_seq_len += chunk_len
return [sample_info]
tokens_per_sample = []
media_idx = 0
for num_medias_in_sample in media_nums_per_sample:
sample_tokens = 0
for i in range(num_medias_in_sample):
num_tokens = tokens_per_media[media_idx + i].item()
num_frames = grid_thw[media_idx + i, 0].item()
sample_tokens += num_tokens + num_frames
tokens_per_sample.append(sample_tokens)
media_idx += num_medias_in_sample
max_seq_len = max(tokens_per_sample)
if max_seq_len % self.vision_seq_pad_multiple != 0:
max_seq_len = (
(max_seq_len + self.vision_seq_pad_multiple - 1)
// self.vision_seq_pad_multiple
* self.vision_seq_pad_multiple
)
vision_token_info = []
media_idx = 0
for sample_idx, num_medias_in_sample in enumerate(media_nums_per_sample):
sample_info = {
"medias": [],
"total_length": tokens_per_sample[sample_idx],
"pad_start": tokens_per_sample[sample_idx],
"pad_end": max_seq_len,
}
seq_offset = 0
for _ in range(num_medias_in_sample):
num_tokens = tokens_per_media[media_idx].item()
t, h, w = grid_thw[media_idx].tolist()
num_frames = t
tokens_per_frame = num_tokens // num_frames
media_length = num_tokens + num_frames
sample_info["medias"].append(
{
"start": seq_offset,
"end": seq_offset + media_length,
"length": media_length,
"num_frames": num_frames,
"grid_h": h,
"grid_w": w,
"vision_tokens_per_frame": tokens_per_frame,
"has_separator": True,
}
)
seq_offset += media_length
media_idx += 1
vision_token_info.append(sample_info)
return vision_token_info
def _compute_position_ids(
self,
input_ids: torch.Tensor,
attention_mask: Optional[torch.Tensor] = None,
) -> torch.Tensor:
is_image_token = input_ids == self.image_token_id
if attention_mask is not None:
is_padding = attention_mask == 0
else:
is_padding = torch.zeros_like(input_ids, dtype=torch.bool)
is_regular_token = ~(is_image_token | is_padding)
cumulative_regular = is_regular_token.long().cumsum(dim=1)
base_position_ids = cumulative_regular - is_regular_token.long()
base_position_ids = base_position_ids.masked_fill(is_padding, 0)
return base_position_ids.unsqueeze(0).expand(3, -1, -1).clone()
def _compute_vision_position_ids(
self,
input_ids: torch.Tensor,
position_ids: torch.Tensor,
vision_token_info: List[dict],
max_vision_seq_len: int,
attention_mask: Optional[torch.Tensor],
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
batch_size = input_ids.shape[0]
device = input_ids.device
image_token_indices = (input_ids == self.image_token_id).nonzero()
flat_eff_h = []
flat_eff_w = []
flat_vis_starts = []
for info in vision_token_info:
medias = info.get("medias", [])
for media in medias:
num_frames = media["num_frames"]
h, w = media["grid_h"], media["grid_w"]
eh, ew = h // self.spatial_merge_size, w // self.spatial_merge_size
start = media["start"]
tok_per_frame = media["vision_tokens_per_frame"]
stride = tok_per_frame + 1
for f in range(num_frames):
flat_eff_h.append(eh)
flat_eff_w.append(ew)
flat_vis_starts.append(start + f * stride)
vision_pos_ids = torch.zeros(
(3, batch_size, max_vision_seq_len),
dtype=torch.long,
device=device,
)
if len(flat_eff_h) == 0 or len(image_token_indices) == 0:
rope_deltas = (
position_ids.max(dim=0).values.max(dim=-1).values
+ 1
- input_ids.shape[1]
)
return vision_pos_ids, position_ids, rope_deltas
num_matches = min(len(flat_eff_h), len(image_token_indices))
flat_eff_h = torch.tensor(
flat_eff_h[:num_matches], device=device, dtype=torch.long
)
flat_eff_w = torch.tensor(
flat_eff_w[:num_matches], device=device, dtype=torch.long
)
flat_vis_starts = torch.tensor(
flat_vis_starts[:num_matches], device=device, dtype=torch.long
)
target_indices = image_token_indices[:num_matches]
batch_rows = target_indices[:, 0]
text_cols = target_indices[:, 1]
max_hw = torch.maximum(flat_eff_h, flat_eff_w)
shifts = max_hw + 1
shift_map = torch.zeros(
(batch_size, input_ids.shape[1]), dtype=torch.long, device=device
)
shift_map[batch_rows, text_cols] = shifts
cum_shifts = shift_map.cumsum(dim=1)
orig_pos = position_ids[0, batch_rows, text_cols]
shifts_before = cum_shifts[batch_rows, text_cols] - shifts
t_vals = orig_pos + shifts_before
new_pos_ids = position_ids + cum_shifts.unsqueeze(0)
img_token_mask = torch.zeros_like(input_ids, dtype=torch.bool)
img_token_mask[batch_rows, text_cols] = True
new_pos_ids[:, img_token_mask] -= 1
if attention_mask is not None:
padding_mask = (attention_mask == 0).unsqueeze(0)
new_pos_ids.masked_fill_(padding_mask, 0)
position_ids = new_pos_ids
unique_shapes = torch.unique(
torch.stack([flat_eff_h, flat_eff_w], dim=1), dim=0
)
for shape in unique_shapes:
eh, ew = shape[0].item(), shape[1].item()
mask = (flat_eff_h == eh) & (flat_eff_w == ew)
sub_t_vals = t_vals[mask]
sub_batch_rows = batch_rows[mask]
sub_vis_starts = flat_vis_starts[mask]
num_frames_sub = sub_t_vals.shape[0]
if num_frames_sub == 0:
continue
y_grid = (
torch.arange(eh, device=device)
.view(1, eh, 1)
.expand(num_frames_sub, -1, ew)
)
x_grid = (
torch.arange(ew, device=device)
.view(1, 1, ew)
.expand(num_frames_sub, eh, -1)
)
t_grid = sub_t_vals.view(-1, 1, 1).expand(-1, eh, ew)
h_grid = t_grid + y_grid
w_grid = t_grid + x_grid
flat_t = t_grid.reshape(-1)
flat_h = h_grid.reshape(-1)
flat_w = w_grid.reshape(-1)
tokens_per_frame = eh * ew
seq_offsets = torch.arange(tokens_per_frame, device=device).unsqueeze(0)
abs_seq_offsets = seq_offsets + sub_vis_starts.unsqueeze(1)
flat_seq_inds = abs_seq_offsets.reshape(-1)
flat_batch_inds = (
sub_batch_rows.unsqueeze(1).expand(-1, tokens_per_frame).reshape(-1)
)
valid_mask = flat_seq_inds < max_vision_seq_len
if valid_mask.any():
final_b = flat_batch_inds[valid_mask]
final_s = flat_seq_inds[valid_mask]
vision_pos_ids[0, final_b, final_s] = flat_t[valid_mask]
vision_pos_ids[1, final_b, final_s] = flat_h[valid_mask]
vision_pos_ids[2, final_b, final_s] = flat_w[valid_mask]
sep_vals = t_vals + max_hw
sep_indices = flat_vis_starts + (flat_eff_h * flat_eff_w)
valid_sep_mask = sep_indices < max_vision_seq_len
if valid_sep_mask.any():
final_b = batch_rows[valid_sep_mask]
final_s = sep_indices[valid_sep_mask]
vals = sep_vals[valid_sep_mask]
vision_pos_ids[0, final_b, final_s] = vals
vision_pos_ids[1, final_b, final_s] = vals
vision_pos_ids[2, final_b, final_s] = vals
max_pos = position_ids.max(dim=0).values.max(dim=-1).values
rope_deltas = max_pos + 1 - input_ids.shape[1]
return vision_pos_ids, position_ids, rope_deltas
def _compute_position_metadata(
self,
input_ids: torch.Tensor,
attention_mask: Optional[torch.Tensor],
grid_thw: Optional[torch.Tensor],
media_nums_per_sample: Optional[List[int]],
) -> Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor], List[dict]]:
position_ids = self._compute_position_ids(input_ids, attention_mask)
if grid_thw is None:
max_pos = position_ids.max(dim=0).values.max(dim=-1).values
rope_deltas = (max_pos + 1 - input_ids.shape[1]).unsqueeze(1)
return position_ids, rope_deltas, None, []
vision_token_info = self._build_vision_token_info(
grid_thw, media_nums_per_sample
)
max_vision_seq_len = 0
if vision_token_info:
max_vision_seq_len = max(
info.get("pad_end", 0) for info in vision_token_info
)
if max_vision_seq_len == 0:
max_pos = position_ids.max(dim=0).values.max(dim=-1).values
rope_deltas = (max_pos + 1 - input_ids.shape[1]).unsqueeze(1)
return position_ids, rope_deltas, None, vision_token_info
vision_position_ids, position_ids, rope_deltas = (
self._compute_vision_position_ids(
input_ids=input_ids,
position_ids=position_ids,
vision_token_info=vision_token_info,
max_vision_seq_len=max_vision_seq_len,
attention_mask=attention_mask,
)
)
return (
position_ids,
rope_deltas.unsqueeze(1),
vision_position_ids,
vision_token_info,
)
def _compute_visible_frame_counts(
self, cross_attention_mask: Optional[Union[torch.Tensor, List]]
) -> Optional[torch.Tensor]:
if cross_attention_mask is None:
return None
# HF Moss-VL processor outputs a bool mask with shape
# (batch_size, 1, text_len, num_frames), where True means masked.
cross_attention_mask = torch.as_tensor(cross_attention_mask, dtype=torch.bool)
visible_frame_counts = (~cross_attention_mask).sum(dim=-1, dtype=torch.int32)
return visible_frame_counts.reshape(-1)
def _resolve_file_url(self, value: str) -> str:
parsed = urlparse(value)
path = unquote(parsed.path or "")
if parsed.netloc and not path.startswith("/"):
path = f"/{path}"
return path
def _write_video_bytes_to_tempfile(
self, video_bytes: bytes, suffix: str = ".mp4"
) -> str:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
f.write(video_bytes)
return f.name
def _normalize_video_string(self, value: str) -> Tuple[str, Optional[str]]:
if value.startswith("file://"):
return self._resolve_file_url(value), None
if os.path.isfile(value):
return value, None
if value.startswith(("http://", "https://")):
timeout = int(os.getenv("REQUEST_TIMEOUT", "10"))
response = requests.get(value, stream=True, timeout=timeout)
response.raise_for_status()
suffix = os.path.splitext(urlparse(value).path)[1] or ".mp4"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
return f.name, f.name
if value.startswith("data:"):
header, encoded = value.split(",", 1)
mime = header.split(";", 1)[0]
suffix = ".mp4"
if "/" in mime:
ext = mime.rsplit("/", 1)[-1]
if ext:
suffix = f".{ext}"
temp_path = self._write_video_bytes_to_tempfile(
pybase64.b64decode(encoded, validate=True),
suffix=suffix,
)
return temp_path, temp_path
temp_path = self._write_video_bytes_to_tempfile(
pybase64.b64decode(value, validate=True)
)
return temp_path, temp_path
def _normalize_single_video_input(
self, video_input: Union[str, Dict]
) -> Tuple[Union[str, Dict], List[str]]:
temp_paths: List[str] = []
if isinstance(video_input, dict):
normalized = dict(video_input)
video_path, temp_path = self._normalize_video_string(
normalized["video_path"]
)
normalized["video_path"] = video_path
if temp_path is not None:
temp_paths.append(temp_path)
return normalized, temp_paths
normalized_path, temp_path = self._normalize_video_string(video_input)
if temp_path is not None:
temp_paths.append(temp_path)
return normalized_path, temp_paths
async def _normalize_video_inputs_async(
self, video_data: Optional[List[Union[str, Dict]]]
) -> Tuple[Optional[List[Union[str, Dict]]], List[str]]:
if not video_data:
return video_data, []
loop = asyncio.get_running_loop()
futures = [
loop.run_in_executor(
self.io_executor, self._normalize_single_video_input, v
)
for v in video_data
]
results = await asyncio.gather(*futures)
normalized_inputs: List[Union[str, Dict]] = []
temp_paths: List[str] = []
for normalized_input, created_paths in results:
normalized_inputs.append(normalized_input)
temp_paths.extend(created_paths)
return normalized_inputs, temp_paths
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes, Dict]],
input_text,
request_obj,
*args,
**kwargs,
):
normalized_video_data, temp_video_paths = (
await self._normalize_video_inputs_async(request_obj.video_data)
)
try:
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.image_only_mm_tokens,
)
processor_output = self.process_mm_data(
input_text=base_output.input_text,
images=base_output.images,
videos=normalized_video_data,
)
input_ids = torch.as_tensor(processor_output["input_ids"], dtype=torch.long)
attention_mask = processor_output.get("attention_mask")
if attention_mask is not None:
attention_mask = torch.as_tensor(attention_mask, dtype=torch.long)
grid_thw = processor_output.get("grid_thw")
if grid_thw is not None:
grid_thw = torch.as_tensor(grid_thw, dtype=torch.long)
media_nums_per_sample = processor_output.get("media_nums_per_sample")
visible_frame_counts = self._compute_visible_frame_counts(
processor_output.get("cross_attention_mask")
)
(
mrope_positions,
mrope_position_delta,
vision_position_ids,
vision_token_info,
) = self._compute_position_metadata(
input_ids=input_ids,
attention_mask=attention_mask,
grid_thw=grid_thw,
media_nums_per_sample=media_nums_per_sample,
)
input_ids = input_ids.flatten()
mm_items = self._build_mm_items(processor_output, input_ids)
if mm_items and vision_token_info:
mm_items[0].set("vision_token_info", vision_token_info[0])
if SGL_USE_CUDA_IPC:
for item in mm_items:
if isinstance(item.feature, torch.Tensor) and item.feature.is_cuda:
sync_flag, available_slice = (
self.cudaipc_mmfeature_pool.return_a_slice_tensor_with_flag(
item.feature
)
)
if isinstance(available_slice, torch.Tensor):
available_slice.copy_(
item.feature.reshape(-1).view(torch.int8),
non_blocking=True,
)
item.feature = CudaIpcTensorTransportProxy(
data=available_slice,
info_data=item.feature,
sync_buffer_meta=sync_flag,
)
elif (
isinstance(item.precomputed_embeddings, torch.Tensor)
and item.precomputed_embeddings.is_cuda
):
sync_flag, available_slice = (
self.cudaipc_mmfeature_pool.return_a_slice_tensor_with_flag(
item.precomputed_embeddings
)
)
if isinstance(available_slice, torch.Tensor):
flattened = item.precomputed_embeddings.reshape(-1)
available_slice.copy_(
flattened.view(torch.int8),
non_blocking=True,
)
item.precomputed_embeddings = CudaIpcTensorTransportProxy(
data=available_slice,
info_data=item.precomputed_embeddings,
sync_buffer_meta=sync_flag,
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.image_token_id,
mrope_positions=mrope_positions.squeeze(1),
mrope_position_delta=mrope_position_delta,
media_nums_per_sample=media_nums_per_sample,
vision_position_ids=(
vision_position_ids.squeeze(1)
if vision_position_ids is not None
else None
),
visible_frame_counts=visible_frame_counts,
)
finally:
for temp_path in temp_video_paths:
try:
os.unlink(temp_path)
except FileNotFoundError:
pass
@@ -0,0 +1,509 @@
# Copyright 2025 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import math
from math import sqrt
import numpy as np
import torch
from PIL import Image
from sglang.srt.configs.nano_nemotron_vl import (
NemotronH_Nano_Omni_Reasoning_V3_Config,
NemotronH_Nano_VL_V2_Config,
)
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.models.nano_nemotron_vl import (
NemotronH_Nano_Omni_Reasoning_V3,
NemotronH_Nano_VL_V2,
)
from sglang.srt.models.parakeet import ParakeetExtractor
from sglang.srt.multimodal.audio_from_video import extract_audio_from_video_bytes
from sglang.srt.multimodal.evs import EVSProcessor
from sglang.srt.multimodal.internvl_utils import (
compute_budgeted_image_sizes,
get_video_target_size_and_feature_size,
image_to_pixel_values,
resize_image_to_pixels,
video_to_pixel_values,
)
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
from sglang.srt.utils.common import sample_video_frames
logger = logging.getLogger(__name__)
DEFAULT_NUM_TILES = 12
NUM_VIDEO_TILES = 1
DESIRED_FPS = 2 # TODO: allow desired fps/num frames to be configurable
MAX_FRAMES = 128
class NanoNemotronVLImageProcessor(BaseMultimodalProcessor):
models = [NemotronH_Nano_VL_V2, NemotronH_Nano_Omni_Reasoning_V3]
gpu_image_decode = (
False # NanoNemotronVL processes loaded image as PIL image explicitly
)
def __init__(self, hf_config, server_args, _image_processor, *args, **kwargs):
super().__init__(hf_config, server_args, _image_processor, *args, **kwargs)
self.evs = EVSProcessor(
hf_config,
{
NemotronH_Nano_VL_V2_Config: NemotronH_Nano_VL_V2,
NemotronH_Nano_Omni_Reasoning_V3_Config: NemotronH_Nano_Omni_Reasoning_V3,
},
)
Image.MAX_IMAGE_PIXELS = None
self.image_size = hf_config.image_size
self.VIDEO_CONTEXT_TOKEN = hf_config.video_context_token
self.IMG_CONTEXT_TOKEN = hf_config.img_context_token
self.IMG_START_TOKEN = hf_config.img_start_token
self.IMG_END_TOKEN = hf_config.img_end_token
self.num_image_token = int(
(self.image_size // hf_config.patch_size) ** 2
* (hf_config.downsample_ratio**2)
)
if hasattr(self._processor, "tokenizer"):
tokenizer = self._processor.tokenizer
else:
tokenizer = self._processor
self.tokenizer = tokenizer
self.img_start_token_id = tokenizer.convert_tokens_to_ids(self.IMG_START_TOKEN)
self.img_end_token_id = tokenizer.convert_tokens_to_ids(self.IMG_END_TOKEN)
# Audio support: initialize Parakeet extractor if sound_config is present
self.audio_extractor: ParakeetExtractor | None = None
self.AUDIO_CONTEXT_TOKEN = getattr(
hf_config, "audio_context_token", "<so_embedding>"
)
self.AUDIO_START_TOKEN = getattr(hf_config, "audio_start_token", "<so_start>")
self.AUDIO_END_TOKEN = getattr(hf_config, "audio_end_token", "<so_end>")
audio_token_str = None
audio_token_id = None
if getattr(hf_config, "sound_config", None) is not None:
self.audio_extractor = ParakeetExtractor(hf_config.sound_config)
audio_token_str = self.AUDIO_CONTEXT_TOKEN
audio_token_id = tokenizer.convert_tokens_to_ids(self.AUDIO_CONTEXT_TOKEN)
self.audio_start_token_id = tokenizer.convert_tokens_to_ids(
self.AUDIO_START_TOKEN
)
self.audio_end_token_id = tokenizer.convert_tokens_to_ids(
self.AUDIO_END_TOKEN
)
self.mm_tokens = MultimodalSpecialTokens(
image_token=self.IMG_CONTEXT_TOKEN,
image_token_id=tokenizer.convert_tokens_to_ids(self.IMG_CONTEXT_TOKEN),
video_token=self.VIDEO_CONTEXT_TOKEN,
video_token_id=tokenizer.convert_tokens_to_ids(self.VIDEO_CONTEXT_TOKEN),
audio_token=audio_token_str,
audio_token_id=audio_token_id,
).build(_image_processor)
# Normalization config (mean/std) and tiling behavior
self.norm_mean = hf_config.norm_mean
self.norm_std = hf_config.norm_std
self.use_thumbnail = hf_config.use_thumbnail
# Dynamic resolution config
self.dynamic_resolution = getattr(hf_config, "dynamic_resolution", False)
self.min_num_patches = getattr(hf_config, "min_num_patches", 0)
self.max_num_patches = getattr(hf_config, "max_num_patches", 0)
self.patch_size = hf_config.patch_size
self.downsample_ratio = hf_config.downsample_ratio
# Video temporal compression config
self.video_temporal_patch_size = getattr(
hf_config, "video_temporal_patch_size", 1
)
self.video_target_num_patches = getattr(
hf_config, "video_target_num_patches", 0
)
self.video_maintain_aspect_ratio = getattr(
hf_config, "video_maintain_aspect_ratio", True
)
self.max_model_len = getattr(server_args, "context_length", None) or 8192
self.PLACEHOLDER = self.tokenizer.unk_token
assert isinstance(self.PLACEHOLDER, str)
self.PLACEHOLDER_ID = tokenizer.convert_tokens_to_ids(self.PLACEHOLDER)
assert isinstance(self.PLACEHOLDER_ID, int)
def preprocess_image(
self, image: Image.Image, *, max_num_tiles: int = DEFAULT_NUM_TILES
) -> torch.Tensor:
return image_to_pixel_values(
image,
input_size=self.image_size,
max_num_tiles=max_num_tiles,
use_thumbnail=self.use_thumbnail,
mean=self.norm_mean,
std=self.norm_std,
).to(dtype=torch.bfloat16)
def render_image(self, *, num_tiles: int):
return f"{self.IMG_START_TOKEN}{self.IMG_CONTEXT_TOKEN * self.num_image_token * num_tiles}{self.IMG_END_TOKEN}"
def render_image_dynamic(self, *, num_tokens: int):
return f"{self.IMG_START_TOKEN}{self.IMG_CONTEXT_TOKEN * num_tokens}{self.IMG_END_TOKEN}"
def render_tubelet(
self,
tubelet_index: int,
frame_indices: list[int],
timestamps: list[float],
num_tokens: int,
):
"""Render a tubelet (group of T frames) for temporal compression."""
if len(frame_indices) == 1:
return self.render_frame(
frame_indices[0], timestamp=timestamps[0], num_tokens=num_tokens
)
parts = " and ".join(
f"frame {fi + 1} sampled at {ts:.2f} seconds"
for fi, ts in zip(frame_indices, timestamps)
)
return f"{parts}: {self.PLACEHOLDER}{self.IMG_CONTEXT_TOKEN * num_tokens}{self.IMG_END_TOKEN}"
def render_frame(self, frame_index: int, *, timestamp: float, num_tokens: int):
return f"Frame {frame_index + 1} sampled at {timestamp:.2f} seconds: {self.PLACEHOLDER}{self.IMG_CONTEXT_TOKEN * num_tokens}{self.IMG_END_TOKEN}"
@staticmethod
def parse_video(video) -> tuple[np.ndarray, list[float]]:
frames = sample_video_frames(
video, desired_fps=DESIRED_FPS, max_frames=MAX_FRAMES
)
video_array = video.get_frames_at(frames)
avg_fps = video.avg_fps
if avg_fps > 0:
frame_duration_ms = int(1000 / avg_fps)
else:
frame_duration_ms = 0
timestamps = [i * frame_duration_ms / 1000.0 for i in frames]
return video_array, timestamps
def render_audio(self, *, num_tokens: int):
return (
f"{self.AUDIO_START_TOKEN}"
f"{self.AUDIO_CONTEXT_TOKEN * num_tokens}"
f"{self.AUDIO_END_TOKEN}"
)
async def process_mm_data_async(
self, image_data, audio_data, input_text, request_obj, **kwargs
):
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
video_data=request_obj.video_data,
audio_data=audio_data if self.audio_extractor else None,
multimodal_tokens=self.mm_tokens,
discard_alpha_channel=True,
audio_sample_rate=(
self.audio_extractor.sampling_rate if self.audio_extractor else None
),
)
videos = [self.parse_video(video) for video in base_output.videos]
T = self.video_temporal_patch_size
if T > 1:
tubelets_per_video = [math.ceil(len(frames) / T) for frames, _ in videos]
if self.video_target_num_patches > 0 and videos:
frame_h, frame_w = videos[0][0][0].shape[:2]
target_w, target_h, tokens_per_tubelet = (
get_video_target_size_and_feature_size(
frame_w,
frame_h,
self.video_target_num_patches,
self.video_maintain_aspect_ratio,
self.patch_size,
self.downsample_ratio,
)
)
ds = int(1 / self.downsample_ratio)
rows = target_h // self.patch_size // ds
cols = target_w // self.patch_size // ds
else:
tokens_per_tubelet = self.num_image_token
rows = cols = int(sqrt(tokens_per_tubelet))
create_data_items, tokens_per_frame = self.evs.static_size_data_items(
frames_per_video=tubelets_per_video,
num_images=len(base_output.images),
rows=rows,
cols=cols,
)
else:
rows = cols = int(sqrt(self.num_image_token))
create_data_items, tokens_per_frame = self.evs.static_size_data_items(
frames_per_video=[len(frames) for frames, _ in videos],
num_images=len(base_output.images),
rows=rows,
cols=cols,
)
prompt = input_text
image_is_dynamic = False
num_tokens_per_image = []
image_feature = None
if base_output.images and self.dynamic_resolution:
image_is_dynamic = True
image_sizes = [(img.width, img.height) for img in base_output.images]
text_only = input_text.replace(self.IMG_CONTEXT_TOKEN, "")
text_tokens = len(
self.tokenizer(text_only, add_special_tokens=False)["input_ids"]
)
total_token_budget = self.max_model_len - text_tokens
budgeted_sizes = compute_budgeted_image_sizes(
image_sizes,
total_token_budget,
self.patch_size,
self.downsample_ratio,
self.min_num_patches,
self.max_num_patches,
)
preprocessed_images = []
for image, (target_w, target_h, n_tokens) in zip(
base_output.images, budgeted_sizes
):
pv = resize_image_to_pixels(
image,
target_w,
target_h,
mean=self.norm_mean,
std=self.norm_std,
)
preprocessed_images.append(pv.to(dtype=torch.bfloat16))
num_tokens_per_image.append(n_tokens)
rendered_images = [
self.render_image_dynamic(num_tokens=nt) for nt in num_tokens_per_image
]
prompt = prompt.replace(self.IMG_CONTEXT_TOKEN, "".join(rendered_images), 1)
image_feature = preprocessed_images
elif base_output.images:
preprocessed_images = [
self.preprocess_image(image) for image in base_output.images
]
rendered_images = [
self.render_image(num_tiles=image.shape[0])
for image in preprocessed_images
]
prompt = prompt.replace(self.IMG_CONTEXT_TOKEN, "".join(rendered_images), 1)
image_feature = torch.cat(preprocessed_images, dim=0)
video_feature = None
T = self.video_temporal_patch_size
if base_output.videos:
preprocessed_videos = []
for (video_array, timestamps), tpf in zip(
videos, tokens_per_frame, strict=True
):
if self.video_target_num_patches > 0:
frames_tensors = []
for frame in video_array:
pv, _ = video_to_pixel_values(
Image.fromarray(frame, mode="RGB"),
patch_size=self.patch_size,
downsample_ratio=self.downsample_ratio,
target_num_patches=self.video_target_num_patches,
maintain_aspect_ratio=self.video_maintain_aspect_ratio,
mean=self.norm_mean,
std=self.norm_std,
)
frames_tensors.append(pv.to(dtype=torch.bfloat16))
else:
frames_tensors = [
self.preprocess_image(
Image.fromarray(frame, mode="RGB"),
max_num_tiles=NUM_VIDEO_TILES,
)
for frame in video_array
]
preprocessed_video = torch.cat(frames_tensors, dim=0)
preprocessed_videos.append(preprocessed_video)
if T > 1:
num_frames = len(video_array)
num_tubelets = math.ceil(num_frames / T)
rendered_parts = []
for ti in range(num_tubelets):
start_fi = ti * T
end_fi = min(start_fi + T, num_frames)
fi_list = list(range(start_fi, end_fi))
ts_list = [timestamps[fi] for fi in fi_list]
rendered_parts.append(
self.render_tubelet(
ti, fi_list, ts_list, num_tokens=tpf[ti]
)
)
prompt = prompt.replace(
self.VIDEO_CONTEXT_TOKEN, "\n".join(rendered_parts), 1
)
else:
rendered_frames = [
self.render_frame(
i,
timestamp=timestamp,
num_tokens=num_tokens,
)
for i, (timestamp, num_tokens) in enumerate(
zip(timestamps, tpf, strict=True)
)
]
prompt = prompt.replace(
self.VIDEO_CONTEXT_TOKEN, "".join(rendered_frames), 1
)
video_feature = torch.cat(preprocessed_videos, dim=0)
# Extract audio from video if requested and no explicit audio provided
use_audio_in_video = getattr(request_obj, "use_audio_in_video", False)
extracted_audios: list[np.ndarray] = []
if (
use_audio_in_video
and base_output.videos
and not base_output.audios
and self.audio_extractor is not None
):
for video_wrapper in base_output.videos:
video_bytes = video_wrapper.source_bytes
if video_bytes is not None:
audio_array = extract_audio_from_video_bytes(
video_bytes,
target_sr=self.audio_extractor.sampling_rate,
)
if audio_array is not None:
extracted_audios.append(audio_array)
all_audios: list[np.ndarray] = (
list(base_output.audios) if base_output.audios else []
)
all_audios.extend(extracted_audios)
# Process audio data through the Parakeet feature extractor
audio_items: list[MultimodalDataItem] = []
if all_audios and self.audio_extractor is not None:
extractor = self.audio_extractor
for audio in all_audios:
num_tokens = extractor.audio_token_count(len(audio))
rendered = self.render_audio(num_tokens=num_tokens)
if self.AUDIO_CONTEXT_TOKEN in prompt:
prompt = prompt.replace(self.AUDIO_CONTEXT_TOKEN, rendered, 1)
else:
prompt = prompt + rendered
extracted = extractor(
all_audios,
sampling_rate=extractor.sampling_rate,
return_tensors="pt",
)
input_features = extracted.input_features
attention_mask = extracted.attention_mask
clip_counts = extracted.audio_num_clips
clip_offset = 0
for audio_idx, num_clips in enumerate(clip_counts):
audio_features = input_features[clip_offset : clip_offset + num_clips]
audio_mask = attention_mask[clip_offset : clip_offset + num_clips]
clip_offset += num_clips
audio_items.append(
MultimodalDataItem(
modality=Modality.AUDIO,
feature=audio_features,
model_specific_data={
"feature_attention_mask": audio_mask,
"audio_num_clips": num_clips,
},
)
)
prompt_ids = self.tokenizer(
prompt, add_special_tokens=False, return_tensors="pt"
)["input_ids"].flatten()
offsets = self.get_mm_items_offset(prompt_ids, self.mm_tokens.image_token_id)
img_offsets = [
(start, end)
for start, end in offsets
if prompt_ids[start - 1] == self.img_start_token_id
]
video_offsets = [
(start, end)
for start, end in offsets
if prompt_ids[start - 1] == self.PLACEHOLDER_ID
]
# Cleanup:
prompt_ids[prompt_ids == self.PLACEHOLDER_ID] = self.img_start_token_id
# Compute audio offsets
if audio_items:
audio_token_id = self.mm_tokens.audio_token_id
audio_offsets_list = self.get_mm_items_offset(prompt_ids, audio_token_id)
for item, offset in zip(audio_items, audio_offsets_list):
item.offsets = [offset]
prompt_ids_list = prompt_ids.tolist()
if image_is_dynamic and image_feature is not None:
items = []
for i, (pv, offset) in enumerate(zip(image_feature, img_offsets)):
items.append(
MultimodalDataItem(
modality=Modality.IMAGE,
feature=pv,
offsets=[offset],
model_specific_data={
"num_tokens": num_tokens_per_image[i],
"is_dynamic": True,
},
)
)
if video_feature is not None:
items.append(
MultimodalDataItem(
modality=Modality.VIDEO,
feature=video_feature,
offsets=video_offsets,
)
)
else:
items = create_data_items(
image=image_feature,
image_offsets=img_offsets,
video=video_feature,
video_offsets=video_offsets,
input_ids_list=prompt_ids_list,
)
items.extend(audio_items)
return MultimodalProcessorOutput(
input_ids=prompt_ids_list,
mm_items=items,
im_start_id=self.img_start_token_id,
im_end_id=self.img_end_token_id,
im_token_id=self.mm_tokens.image_token_id,
video_token_id=self.mm_tokens.image_token_id,
audio_token_id=self.mm_tokens.audio_token_id if audio_items else None,
audio_start_id=(self.audio_start_token_id if audio_items else None),
audio_end_id=(self.audio_end_token_id if audio_items else None),
)
@@ -0,0 +1,80 @@
from typing import Any
import torch.nn as nn
from transformers.configuration_utils import PretrainedConfig
from transformers.processing_utils import ProcessorMixin
from transformers.tokenization_utils_base import PreTrainedTokenizerBase
from sglang.srt.managers.io_struct import GenerateReqInput
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.jet_vlm import JetVLMForConditionalGeneration
from sglang.srt.models.nvila import NVILAForConditionalGeneration
from sglang.srt.models.nvila_lite import NVILALiteForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
from sglang.srt.server_args import ServerArgs
NUM_VIDEO_FRAMES = 8
class NVILAMultimodalProcessor(BaseMultimodalProcessor):
models: list[type[nn.Module]] = [
NVILAForConditionalGeneration,
NVILALiteForConditionalGeneration,
JetVLMForConditionalGeneration,
]
def __init__(
self,
hf_config: PretrainedConfig,
server_args: ServerArgs,
_processor: ProcessorMixin,
*args,
**kwargs,
) -> None:
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self._processor: ProcessorMixin
tokenizer: PreTrainedTokenizerBase = getattr(self._processor, "tokenizer")
self.mm_tokens = MultimodalSpecialTokens(
image_token=tokenizer.image_token,
image_token_id=hf_config.image_token_id,
video_token=tokenizer.video_token,
video_token_id=hf_config.video_token_id,
).build(_processor)
async def process_mm_data_async(
self,
image_data,
audio_data,
input_text,
request_obj: GenerateReqInput,
**kwargs,
) -> dict[str, Any] | None:
base_output = await self.load_mm_data(
prompt=input_text,
multimodal_tokens=self.mm_tokens,
image_data=request_obj.image_data, # type: ignore
video_data=request_obj.video_data, # type: ignore
)
for i, video in enumerate(base_output.videos): # type: ignore
base_output.videos[i] = [x.asnumpy() for x in video] # type: ignore
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output,
self.mm_tokens,
do_sample_frames=True,
num_frames=NUM_VIDEO_FRAMES,
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.mm_tokens.image_token_id,
video_token_id=self.mm_tokens.video_token_id,
)
@@ -0,0 +1,30 @@
# Reference: ccr-2vdh3abv-pub.cnc.bj.baidubce.com/paddlepaddle/paddleocr-genai-vllm-server:latest
# Copyright (c) 2025 PaddlePaddle Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from sglang.srt.models.paddleocr_vl import PaddleOCRVLForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import MultimodalSpecialTokens
from sglang.srt.multimodal.processors.qwen_vl import QwenVLImageProcessor
class PaddleOCRVLImageProcessor(QwenVLImageProcessor):
models = [PaddleOCRVLForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.mm_tokens = MultimodalSpecialTokens(
image_token="<|IMAGE_START|><|IMAGE_PLACEHOLDER|><|IMAGE_END|>",
image_token_id=hf_config.image_token_id,
video_token_id=hf_config.video_token_id,
).build(_processor)
@@ -0,0 +1,101 @@
import logging
from typing import List, Union
from transformers.processing_utils import ProcessorMixin
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.phi4mm import Phi4MMForCausalLM
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
logger = logging.getLogger(__name__)
# It is an adapter of hf phi4 mm processor to make it work for sglang
# Ref: https://huggingface.co/microsoft/Phi-4-multimodal-instruct/blob/main/processing_phi4mm.py#L693
class Phi4MMProcessorAdapter(ProcessorMixin):
def __init__(self, _processor) -> None:
self._processor = _processor
def __call__(self, **kwargs):
result = self._processor(**kwargs)
# Map HuggingFace output keys to sglang standard keys
key_mapping = {
"input_image_embeds": "pixel_values",
"input_audio_embeds": "audio_features",
"audio_embed_sizes": "audio_feature_lens",
}
for hf_key, sglang_key in key_mapping.items():
if hf_key in result:
result[sglang_key] = result[hf_key]
del result[hf_key]
# Filter out None or empty tensors from the result.
# This prevents the sglang function base_processor.collect_mm_items_from_processor_output()
# from misclassifying audio content as image content, and vice versa.
filtered_result = {
k: v
for k, v in result.items()
if v is not None and (not hasattr(v, "numel") or v.numel() > 0)
}
return filtered_result
class Phi4MMMultimodalProcessor(BaseMultimodalProcessor):
models = [Phi4MMForCausalLM]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
self.processor = Phi4MMProcessorAdapter(_processor)
super().__init__(hf_config, server_args, self.processor, *args, **kwargs)
# the following CONSTANTS come from hugging-face microsoft/Phi-4-multimodal-instruct's processing_phi4mm.py file
# ref: https://huggingface.co/microsoft/Phi-4-multimodal-instruct/blob/main/processing_phi4mm.py
self.IMAGE_TOKEN = "<|endoftext10|>"
self.AUDIO_TOKEN = "<|endoftext11|>"
self.IM_TOKEN_ID = 200010
self.AUDIO_TOKEN_ID = 200011
self.AUDIO_SAMPLE_RATE = 16000
self.mm_tokens = MultimodalSpecialTokens(
image_token=self.IMAGE_TOKEN,
image_token_id=self.IM_TOKEN_ID,
audio_token=self.AUDIO_TOKEN,
audio_token_id=self.AUDIO_TOKEN_ID,
).build(self.processor)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
audio_data,
input_text,
request_obj,
**kwargs,
):
base_output = await self.load_mm_data(
prompt=input_text,
audio_data=audio_data,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
audio_sample_rate=self.AUDIO_SAMPLE_RATE,
)
if base_output.audios is not None:
# hugging-face microsoft/Phi-4-multimodal-instruct's processing_phi4mm.py file requires the audio input to be tuple of (audio, sample_rate)
# ref: https://huggingface.co/microsoft/Phi-4-multimodal-instruct/blob/main/processing_phi4mm.py
base_output.audios = [
(audio, self.AUDIO_SAMPLE_RATE) for audio in base_output.audios
]
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.mm_tokens.image_token_id,
audio_token_id=self.mm_tokens.audio_token_id,
)
@@ -0,0 +1,134 @@
import math
from typing import List, Union
from transformers import PreTrainedTokenizerBase
from transformers.models.pixtral.image_processing_pixtral import (
_num_image_tokens as _get_pixtral_hf_num_image_tokens,
)
from sglang.srt.managers.schedule_batch import Modality, MultimodalProcessorOutput
from sglang.srt.models.pixtral import (
PixtralForConditionalGeneration,
PixtralVisionModel,
)
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
class PixtralProcessor(BaseMultimodalProcessor):
models = [PixtralVisionModel, PixtralForConditionalGeneration]
gpu_image_decode = False # Pixtral processes loaded image as PIL image explicitly
PAD_TOKEN = "<pad>"
DEFAULT_IMAGE_TOKEN = "[IMG]"
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.IM_TOKEN_ID = getattr(
hf_config, "image_token_index", PixtralVisionModel.DEFAULT_IMAGE_TOKEN_ID
)
self.vision_config = hf_config.vision_config
self.image_size = self.vision_config.image_size
self.patch_size = self.vision_config.patch_size
# spatial_merge_size may live on vision_config (Mistral native) or
# on the top-level config (HF native Mistral3Config).
self._spatial_merge_size = getattr(
self.vision_config,
"spatial_merge_size",
getattr(hf_config, "spatial_merge_size", 1),
)
self._processor.patch_size = self.patch_size
if self._spatial_merge_size > 1:
self._processor.spatial_merge_size = self._spatial_merge_size
tokenizer = (
_processor
if isinstance(_processor, PreTrainedTokenizerBase)
else _processor.tokenizer
)
self.image_token = getattr(_processor, "image_token", self.DEFAULT_IMAGE_TOKEN)
self.mm_tokens = MultimodalSpecialTokens(
image_token=self.image_token,
image_token_id=self.IM_TOKEN_ID,
).build(_processor)
tokenizer.add_special_tokens(
{
"pad_token": getattr(hf_config, "pad_token", self.PAD_TOKEN),
}
)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
input_text,
request_obj,
*args,
**kwargs,
):
mm_data = await self.load_mm_data(
prompt=input_text,
multimodal_tokens=self.mm_tokens,
image_data=image_data,
return_text=True,
)
if mm_data.images:
effective_patch = self.patch_size * self._spatial_merge_size
image_nrows = []
for img in mm_data.images:
w, h = img.size
ratio = max(w / self.image_size, h / self.image_size)
if ratio > 1:
w = int(math.floor(w / ratio))
h = int(math.floor(h / ratio))
nrows, _ = _get_pixtral_hf_num_image_tokens(
(h, w), (effective_patch, effective_patch)
)
image_nrows.append(nrows)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
mm_data, self.mm_tokens
)
# For multi-image: split single IMAGE mm_item into per-image items
if len(mm_data.images) > 1:
from sglang.srt.managers.schedule_batch import MultimodalDataItem
old_item = next(
item for item in mm_items if item.modality == Modality.IMAGE
)
all_offsets = old_item.offsets
old_feature = old_item.feature
old_image_sizes = getattr(old_item, "image_sizes", None)
mm_items = [
item for item in mm_items if item.modality != Modality.IMAGE
]
offset_idx = 0
for i, img in enumerate(mm_data.images):
nr = image_nrows[i]
item_offsets = all_offsets[offset_idx : offset_idx + nr]
offset_idx += nr
new_item = MultimodalDataItem(modality=Modality.IMAGE)
new_item.feature = old_feature[i : i + 1]
new_item.offsets = item_offsets
if old_image_sizes is not None:
new_item.model_specific_data["image_sizes"] = old_image_sizes[
i : i + 1
]
mm_items.append(new_item)
else:
mm_items, input_ids, _ = self.process_and_combine_mm_data(
mm_data, self.mm_tokens
)
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids.tolist(),
im_token_id=self.IM_TOKEN_ID,
)
@@ -0,0 +1,43 @@
# Copy from qwen_vl.py, adapted for points-v15-chat
from typing import List, Union
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.points_v15_chat import POINTSV15ChatModel
from sglang.srt.multimodal.processors.qwen_vl import QwenVLImageProcessor
class POINTSV15ChatProcessor(QwenVLImageProcessor):
models = [POINTSV15ChatModel]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
# Compatible with POINTSV15Chat
hf_config.vision_start_token_id = None
hf_config.vision_end_token_id = None
hf_config.video_token_id = None
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
input_text,
request_obj,
*args,
**kwargs,
):
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.mm_tokens.image_token_id,
)
@@ -0,0 +1,98 @@
import re
from typing import Union
import torch
from sglang.srt.managers.schedule_batch import Modality, MultimodalProcessorOutput
from sglang.srt.models.qwen3_asr import Qwen3ASRForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
AUDIO_PLACEHOLDER = "<|audio_start|><|audio_pad|><|audio_end|>"
DEFAULT_ASR_PROMPT = (
f"<|im_start|>user\n"
f"{AUDIO_PLACEHOLDER}"
f"<|im_end|>\n"
f"<|im_start|>assistant\n"
)
class Qwen3ASRMultimodalProcessor(BaseMultimodalProcessor):
models = [Qwen3ASRForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.AUDIO_TOKEN = AUDIO_PLACEHOLDER
self.AUDIO_TOKEN_REGEX = re.compile(
r"<\|audio_start\|>(?:<\|audio_pad\|>)+<\|audio_end\|>"
)
tokenizer = self._processor.tokenizer
self.audio_start_id = tokenizer.convert_tokens_to_ids("<|audio_start|>")
self.audio_token_id = tokenizer.convert_tokens_to_ids("<|audio_pad|>")
self.audio_end_id = tokenizer.convert_tokens_to_ids("<|audio_end|>")
self.mm_tokens = MultimodalSpecialTokens(
audio_token=self.AUDIO_TOKEN,
audio_token_regex=self.AUDIO_TOKEN_REGEX,
audio_token_id=self.audio_token_id,
).build(_processor)
self.ATTR_NAME_TO_MODALITY.update({"feature_attention_mask": Modality.AUDIO})
def _build_transcription_prompt(self, input_text: Union[str, list]) -> str:
# TODO: support `force_language`
if isinstance(input_text, list):
input_text = self._tokenizer.decode(input_text)
if not input_text or not input_text.strip():
return DEFAULT_ASR_PROMPT
return input_text
def compute_mrope_positions(self, input_ids, mm_items):
if isinstance(input_ids, list):
seq_len = len(input_ids)
else:
seq_len = input_ids.shape[-1] if input_ids.dim() > 1 else input_ids.shape[0]
positions = torch.arange(seq_len, dtype=torch.long)
mrope_positions = positions.unsqueeze(0).expand(3, -1).clone()
return mrope_positions, torch.tensor([0], dtype=torch.long)
async def process_mm_data_async(
self,
audio_data=None,
input_text=None,
request_obj=None,
**kwargs,
):
if not audio_data:
return None
prompt = self._build_transcription_prompt(input_text)
base_output = await self.load_mm_data(
prompt=prompt,
audio_data=audio_data,
multimodal_tokens=self.mm_tokens,
)
if base_output is None:
return None
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
mrope_positions, mrope_position_delta = self.compute_mrope_positions(
input_ids, mm_items
)
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids.tolist(),
audio_start_id=self.audio_start_id,
audio_token_id=self.audio_token_id,
audio_end_id=self.audio_end_id,
mrope_positions=mrope_positions,
mrope_position_delta=mrope_position_delta,
)
@@ -0,0 +1,117 @@
import re
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.models.qwen2_audio import Qwen2AudioForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
class Qwen2AudioMultimodalProcessor(BaseMultimodalProcessor):
models = [Qwen2AudioForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.AUDIO_TOKEN = "<|audio_bos|><|AUDIO|><|audio_eos|>"
self.AUDIO_TOKEN_REGEX = re.compile(
r"<\|audio_bos\|>(?:<\|AUDIO\|>)+<\|audio_eos\|>"
)
# Collect special token ids
tokenizer = self._processor.tokenizer
self.audio_start_id = tokenizer.convert_tokens_to_ids("<|audio_bos|>")
self.audio_token_id = tokenizer.convert_tokens_to_ids("<|AUDIO|>")
self.audio_end_id = tokenizer.convert_tokens_to_ids("<|audio_eos|>")
self.mm_tokens = MultimodalSpecialTokens(
audio_token=self.AUDIO_TOKEN,
audio_token_regex=self.AUDIO_TOKEN_REGEX,
audio_token_id=self.audio_token_id,
).build(_processor)
self.ATTR_NAME_TO_MODALITY.update({"feature_attention_mask": Modality.AUDIO})
def get_mm_data(self, prompt, embeddings, **kwargs):
audio_feature_lens = kwargs.get("audio_feature_lens", None)
# Convert audio_feature_lens to token counts for build_input_ids
output_lengths = None
input_lengths = None
if audio_feature_lens is not None:
if audio_feature_lens.dim() > 1:
audio_feature_lens = audio_feature_lens.flatten()
input_lengths = (audio_feature_lens - 1) // 2 + 1
output_lengths = (input_lengths - 2) // 2 + 1
input_ids, offsets, modality_list = self.build_input_ids(
prompt,
audio_seq_lens=output_lengths,
)
mm_items = []
consumed_per_modality = {}
for modality, offset in zip(modality_list, offsets):
num_tokens = offset[1] - offset[0] + 1
embedding_start = consumed_per_modality.get(modality, 0)
embedding_slice = embeddings[modality][
embedding_start : embedding_start + num_tokens
]
consumed_per_modality[modality] = embedding_start + num_tokens
mm_items.append(
MultimodalDataItem(
modality=modality,
offsets=[offset],
precomputed_embeddings=embedding_slice,
)
)
if mm_items:
mm_items[0].audio_feature_lens = output_lengths
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids,
audio_start_id=self.audio_start_id,
audio_token_id=self.audio_token_id,
audio_end_id=self.audio_end_id,
)
async def process_mm_data_async(
self,
audio_data,
input_text,
**kwargs,
):
base_output = await self.load_mm_data(
prompt=input_text,
audio_data=audio_data,
multimodal_tokens=self.mm_tokens,
)
if base_output is None:
return None
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
assert (
"feature_attention_mask" in ret
), "feature_attention_mask not found in processor output"
input_lengths = ret["feature_attention_mask"].sum(dim=-1)
input_lengths = (input_lengths - 1) // 2 + 1
output_lengths = (input_lengths - 2) // 2 + 1
mm_items[0].audio_feature_lens = output_lengths
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids.tolist(),
audio_start_id=self.audio_start_id,
audio_token_id=self.audio_token_id,
audio_end_id=self.audio_end_id,
)
@@ -0,0 +1,835 @@
import math
import os
import re
import time
from typing import List, Optional, Union
import numpy as np
import torch
import torchvision
from PIL import Image
from torchvision.transforms import InterpolationMode
from sglang.srt.environ import envs
from sglang.srt.layers.rotary_embedding import MRotaryEmbedding
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.models.interns2preview import InternS2PreviewForConditionalGeneration
from sglang.srt.models.qwen2_5_vl import Qwen2_5_VLForConditionalGeneration
from sglang.srt.models.qwen2_vl import Qwen2VLForConditionalGeneration
from sglang.srt.models.qwen3_5 import (
Qwen3_5ForConditionalGeneration,
Qwen3_5MoeForConditionalGeneration,
)
from sglang.srt.models.qwen3_5_mtp import Qwen3_5ForCausalLMMTP
from sglang.srt.models.qwen3_omni_moe import Qwen3OmniMoeForConditionalGeneration
from sglang.srt.models.qwen3_vl import Qwen3VLForConditionalGeneration
from sglang.srt.models.qwen3_vl_moe import Qwen3VLMoeForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.multimodal.processors.base_processor import (
MultimodalSpecialTokens,
)
from sglang.srt.utils import cpu_has_amx_support, is_cpu
from sglang.srt.utils.video_decoder import VideoDecoderWrapper
from sglang.utils import logger
IMAGE_FACTOR = 28
MIN_PIXELS = 4 * 28 * 28
MAX_PIXELS = envs.SGLANG_IMAGE_MAX_PIXELS.get()
MAX_RATIO = 200
RESIZE_RESAMPLE = getattr(Image, envs.SGLANG_RESIZE_RESAMPLE.get(), None)
if envs.SGLANG_RESIZE_RESAMPLE.is_set() and RESIZE_RESAMPLE is None:
logger.warning(
f"Invalid RESIZE_RESAMPLE value: '{envs.SGLANG_RESIZE_RESAMPLE.get()}'. "
f"Ignoring and using default."
)
VIDEO_TOTAL_PIXELS = int(
float(os.environ.get("VIDEO_MAX_PIXELS", 128000 * 28 * 28 * 0.9))
)
VIDEO_MIN_PIXELS = 128 * 28 * 28
VIDEO_MAX_PIXELS = 768 * 28 * 28
FRAME_FACTOR = 2
FPS = 2.0
FPS_MIN_FRAMES = 4
FPS_MAX_FRAMES = 768
_is_cpu_amx_available = cpu_has_amx_support()
_is_cpu = is_cpu()
if _is_cpu and _is_cpu_amx_available:
try:
import transformers
from sglang.srt.layers.amx_utils import fast_preprocess_cpu
transformers.models.qwen2_vl.image_processing_qwen2_vl_fast.Qwen2VLImageProcessorFast._preprocess = (
fast_preprocess_cpu
)
except Exception as e:
logger.warning(
f"Failed to hack Qwen2VLImageProcessorFast with AMX optimization: {e}"
)
def smart_resize(
height: int,
width: int,
factor: int = IMAGE_FACTOR,
min_pixels: int = MIN_PIXELS,
max_pixels: int = MAX_PIXELS,
) -> tuple[int, int]:
"""
Rescales the image so that the following conditions are met:
1. Both dimensions (height and width) are divisible by 'factor'.
2. The total number of pixels is within the range ['min_pixels', 'max_pixels'].
3. The aspect ratio of the image is maintained as closely as possible.
"""
if max(height, width) / min(height, width) > MAX_RATIO:
raise ValueError(
f"absolute aspect ratio must be smaller than {MAX_RATIO}, got {max(height, width) / min(height, width)}"
)
h_bar = max(factor, round_by_factor(height, factor))
w_bar = max(factor, round_by_factor(width, factor))
if h_bar * w_bar > max_pixels:
beta = math.sqrt((height * width) / max_pixels)
h_bar = floor_by_factor(height / beta, factor)
w_bar = floor_by_factor(width / beta, factor)
elif h_bar * w_bar < min_pixels:
beta = math.sqrt(min_pixels / (height * width))
h_bar = ceil_by_factor(height * beta, factor)
w_bar = ceil_by_factor(width * beta, factor)
return h_bar, w_bar
def round_by_factor(number: int, factor: int) -> int:
"""Returns the closest integer to 'number' that is divisible by 'factor'."""
return round(number / factor) * factor
def ceil_by_factor(number: int, factor: int) -> int:
"""Returns the smallest integer greater than or equal to 'number' that is divisible by 'factor'."""
return math.ceil(number / factor) * factor
def floor_by_factor(number: int, factor: int) -> int:
"""Returns the largest integer less than or equal to 'number' that is divisible by 'factor'."""
return math.floor(number / factor) * factor
def smart_nframes(
ele: dict,
total_frames: int,
video_fps: int | float,
) -> int:
"""calculate the number of frames for video used for model inputs.
Args:
ele (dict): a dict contains the configuration of video.
support either `fps` or `nframes`:
- nframes: the number of frames to extract for model inputs.
- fps: the fps to extract frames for model inputs.
- min_frames: the minimum number of frames of the video, only used when fps is provided.
- max_frames: the maximum number of frames of the video, only used when fps is provided.
total_frames (int): the original total number of frames of the video.
video_fps (int | float): the original fps of the video.
Raises:
ValueError: nframes should in interval [FRAME_FACTOR, total_frames].
Returns:
int: the number of frames for video used for model inputs.
"""
assert not (
"fps" in ele and "nframes" in ele
), "Only accept either `fps` or `nframes`"
if "nframes" in ele:
nframes = round_by_factor(ele["nframes"], FRAME_FACTOR)
else:
fps = ele.get("fps", FPS)
min_frames = ceil_by_factor(ele.get("min_frames", FPS_MIN_FRAMES), FRAME_FACTOR)
max_frames = floor_by_factor(
ele.get("max_frames", min(FPS_MAX_FRAMES, total_frames)), FRAME_FACTOR
)
nframes = total_frames / video_fps * fps
if nframes > total_frames:
logger.warning(
f"smart_nframes: nframes[{nframes}] > total_frames[{total_frames}]"
)
nframes = min(min(max(nframes, min_frames), max_frames), total_frames)
nframes = floor_by_factor(nframes, FRAME_FACTOR)
if not (FRAME_FACTOR <= nframes and nframes <= total_frames):
raise ValueError(
f"nframes should in interval [{FRAME_FACTOR}, {total_frames}], but got {nframes}."
)
return nframes
# process video, qwen-specific
async def preprocess_video(
vr,
image_factor: int = IMAGE_FACTOR,
video_config: dict = {},
) -> torch.Tensor:
# preprocessed video
is_video_obj = isinstance(vr, VideoDecoderWrapper)
if not is_video_obj:
return vr, None
entry_time = time.perf_counter()
total_frames, video_fps = len(vr), vr.avg_fps
nframes = smart_nframes(
video_config, total_frames=total_frames, video_fps=video_fps
)
idx = np.linspace(0, total_frames - 1, num=nframes, dtype=np.int64)
idx = np.unique(idx)
video = vr.get_frames_as_tensor(idx.tolist())
video = video.permute(0, 3, 1, 2) # NHWC -> TCHW
nframes, _, height, width = video.shape
min_pixels = video_config.get("min_pixels", VIDEO_MIN_PIXELS)
total_pixels = video_config.get("total_pixels", VIDEO_TOTAL_PIXELS)
max_pixels = max(
min(
video_config.get("max_pixels", VIDEO_MAX_PIXELS),
total_pixels / nframes * FRAME_FACTOR,
),
int(min_pixels * 1.05),
)
get_batch_time = time.perf_counter()
max_pixels_supposed = video_config.get("max_pixels", max_pixels)
if max_pixels_supposed > max_pixels:
logger.warning(
f"The given max_pixels[{max_pixels_supposed}] exceeds limit[{max_pixels}]."
)
max_pixels = min(max_pixels_supposed, max_pixels)
if "resized_height" in video_config and "resized_width" in video_config:
resized_height, resized_width = smart_resize(
video_config["resized_height"],
video_config["resized_width"],
factor=image_factor,
)
else:
resized_height, resized_width = smart_resize(
height,
width,
factor=image_factor,
min_pixels=min_pixels,
max_pixels=max_pixels,
)
smart_resize_time = time.perf_counter()
video = torchvision.transforms.functional.resize(
video,
[resized_height, resized_width],
interpolation=InterpolationMode.BILINEAR,
)
video = video.pin_memory()
video_metadata = {
"fps": video_fps,
"duration": total_frames / video_fps,
"total_num_frames": total_frames,
"frames_indices": idx,
"video_backend": "torchvision",
}
torchvision_resize_time = time.perf_counter()
logger.debug(
f"[preprocess_video Perf], "
f"get_batch_time: {(get_batch_time - entry_time) * 1000:.2f} ms, "
f"smart_resize_time: {(smart_resize_time - get_batch_time) * 1000:.2f} ms, "
f"torchvision_resize_time: {(torchvision_resize_time - smart_resize_time) * 1000:.2f} ms, "
f"total_time: {(torchvision_resize_time - entry_time) * 1000:.2f} ms"
)
return video, video_metadata
# Compatible with Qwen-VL & Qwen-Omni Series
class QwenVLImageProcessor(SGLangBaseProcessor):
supports_transformers_backend = True
models = [
Qwen2VLForConditionalGeneration,
Qwen2_5_VLForConditionalGeneration,
Qwen3VLForConditionalGeneration,
Qwen3VLMoeForConditionalGeneration,
Qwen3_5ForConditionalGeneration,
Qwen3_5MoeForConditionalGeneration,
Qwen3_5ForCausalLMMTP,
InternS2PreviewForConditionalGeneration,
Qwen3OmniMoeForConditionalGeneration,
]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
self.model_type = hf_config.model_type
if hf_config.model_type == "qwen3_omni_moe":
hf_config = hf_config.thinker_config
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.IM_START_TOKEN_ID = hf_config.vision_start_token_id
self.IM_END_TOKEN_ID = hf_config.vision_end_token_id
self.IM_TOKEN_ID = hf_config.image_token_id
self.VIDEO_TOKEN_ID = hf_config.video_token_id
self.vision_start_token_id = hf_config.vision_start_token_id
self.vision_end_token_id = getattr(hf_config, "vision_end_token_id", None)
self.audio_start_token_id = getattr(hf_config, "audio_start_token_id", None)
self.audio_token_id = getattr(hf_config, "audio_token_id", None)
self._spatial_merge_size = self.hf_config.vision_config.spatial_merge_size
self._tokens_per_second = getattr(
self.hf_config.vision_config, "tokens_per_second", None
)
self.mm_tokens = MultimodalSpecialTokens(
image_token="<|vision_start|><|image_pad|><|vision_end|>",
image_token_id=hf_config.image_token_id,
# The regex that matches expanded image tokens.
image_token_regex=re.compile(
r"<\|vision_start\|>(?:<\|image_pad\|>)+<\|vision_end\|>"
),
video_token_id=self.VIDEO_TOKEN_ID,
audio_token_id=self.audio_token_id,
).build(_processor)
@property
def spatial_merge_size(self):
return self._spatial_merge_size
def build_input_ids_with_timestamps(
self, prompt, embeddings, img_grid_thw, video_grid_thw, video_timestamps
):
"""
Build input_ids with timestamps for qwen3_vl models.
"""
if not isinstance(prompt, list):
prompt = self._processor.tokenizer.encode(prompt)
img_token_id = getattr(self, "IM_TOKEN_ID", None)
video_token_id = getattr(self, "VIDEO_TOKEN_ID", None)
spatial_merge_size = self.spatial_merge_size
vision_start_token_id = getattr(self, "vision_start_token_id", None)
vision_end_token_id = getattr(self, "vision_end_token_id", None)
input_ids = []
offsets = []
modality_list = []
cur_idx = 0
vision_start_indices = []
for i in range(len(prompt) - 1):
if img_token_id is not None and prompt[i + 1] == img_token_id:
vision_start_indices.append((i, Modality.IMAGE))
elif video_token_id is not None and prompt[i + 1] == video_token_id:
vision_start_indices.append((i, Modality.VIDEO))
img_idx = 0
video_idx = 0
for mm_start_idx, modality in vision_start_indices:
modality_list.append(modality)
video_tokens = None
if modality == Modality.IMAGE:
mm_token_num = img_grid_thw[img_idx].prod() // (spatial_merge_size**2)
mm_token_id = img_token_id
img_idx += 1
elif modality == Modality.VIDEO:
curr_timestamps = video_timestamps[video_idx]
num_frames = video_grid_thw[video_idx][0]
frame_seqlen = video_grid_thw[video_idx][1:].prod().item() // (
spatial_merge_size**2
)
video_tokens = []
_current_offset = len(input_ids) + mm_start_idx + 1 - cur_idx
# take single frame as one mm_item
for frame_idx in range(num_frames):
if frame_idx > 0:
modality_list.append(Modality.VIDEO)
curr_time = curr_timestamps[frame_idx]
timestamp_text = f"<{curr_time:.1f} seconds>"
timestamp_tokens = self._processor.tokenizer.encode(
timestamp_text, add_special_tokens=False
)
video_tokens.extend(timestamp_tokens)
_current_offset += len(timestamp_tokens)
if vision_start_token_id is not None:
video_tokens.append(vision_start_token_id)
_current_offset += 1
video_tokens.extend([video_token_id] * frame_seqlen)
if vision_end_token_id is not None:
video_tokens.append(vision_end_token_id)
offsets.append(
(_current_offset, _current_offset + frame_seqlen - 1)
)
_current_offset += (
frame_seqlen + 1
if vision_end_token_id is not None
else frame_seqlen
) # for vision_end_token_id
mm_token_num = len(video_tokens)
mm_token_id = None
video_idx += 1
else:
logger.warning(
f"{modality} modality is not supported for qwen3_vl models with timestamps."
)
continue
assert cur_idx <= mm_start_idx
input_ids.extend(prompt[cur_idx : mm_start_idx + 1])
if modality == Modality.VIDEO:
input_ids.extend(video_tokens)
else:
mm_offset_start = len(input_ids)
input_ids.extend([mm_token_id] * mm_token_num)
offsets.append((mm_offset_start, len(input_ids) - 1))
cur_idx = mm_start_idx + 2 # jump to vision_end_id
else:
input_ids.extend(prompt[cur_idx:])
return input_ids, offsets, modality_list
def compute_mrope_positions(self, input_ids, mm_items):
image_grid_thw = self._concat_mm_item_grid(
mm_items, "image_grid_thw", Modality.IMAGE
)
video_grid_thw = self._concat_mm_item_grid(
mm_items, "video_grid_thw", Modality.VIDEO
)
input_ids_tensor = torch.tensor(input_ids, dtype=torch.long).unsqueeze(0)
mrope_positions, mrope_position_delta = MRotaryEmbedding.get_rope_index(
spatial_merge_size=self._spatial_merge_size,
image_token_id=self.mm_tokens.image_token_id,
video_token_id=self.mm_tokens.video_token_id,
vision_start_token_id=self.vision_start_token_id,
model_type=self.model_type,
tokens_per_second=self._tokens_per_second,
input_ids=input_ids_tensor,
image_grid_thw=image_grid_thw,
video_grid_thw=video_grid_thw,
)
return mrope_positions.squeeze(1), mrope_position_delta
@staticmethod
def _get_processor_output_value(ret, key):
if ret is None:
return None
return ret.get(key) if hasattr(ret, "get") else getattr(ret, key, None)
def _get_precomputed_mrope_from_output(self, ret):
mrope_positions = self._get_processor_output_value(ret, "mrope_positions")
mrope_position_delta = self._get_processor_output_value(
ret, "mrope_position_delta"
)
if mrope_positions is None or mrope_position_delta is None:
return None
mrope_positions = torch.as_tensor(mrope_positions)
if mrope_positions.ndim == 3:
if mrope_positions.shape[1] != 1:
return None
mrope_positions = mrope_positions.squeeze(1)
if mrope_positions.ndim != 2 or mrope_positions.shape[0] != 3:
return None
mrope_position_delta = torch.as_tensor(mrope_position_delta)
if mrope_position_delta.ndim <= 1:
mrope_position_delta = mrope_position_delta.reshape(-1, 1)
return mrope_positions, mrope_position_delta
@staticmethod
def _as_grid_batch(value):
if value is None:
return None
if isinstance(value, torch.Tensor):
return value.unsqueeze(0) if value.ndim == 1 else value
tensor = torch.as_tensor(value, dtype=torch.long)
return tensor.unsqueeze(0) if tensor.ndim == 1 else tensor
def _compute_image_only_mrope_positions_from_offsets(
self,
input_len: int,
mm_items: List[MultimodalDataItem],
dtype: torch.dtype,
device: torch.device,
) -> Optional[tuple[torch.Tensor, torch.Tensor]]:
"""instead of calling get_rope_index, build mrope position from mm_items.offsets and image_grid_thw of each image
basically a simplified version of get_rope_index for image-only reqs
"""
if self.model_type not in (
"qwen3_vl",
"qwen3_vl_moe",
"qwen3_5",
"qwen3_5_moe",
"intern_s2_preview",
):
return None
image_items = [item for item in mm_items if item.is_image()]
if not image_items or len(image_items) != len(mm_items):
return None
spatial_merge_size = self._spatial_merge_size
sorted_items = sorted(image_items, key=lambda item: item.offsets[0][0])
position_segments = []
st = 0
next_pos = 0
for item in sorted_items:
if item.offsets is None or len(item.offsets) != 1:
return None
start, end = item.offsets[0]
if start < st or end >= input_len:
return None
text_len = start - st
if text_len > 0:
position_segments.append(
torch.arange(text_len, dtype=dtype, device=device)
.view(1, -1)
.expand(3, -1)
+ next_pos
)
next_pos += text_len
grid = self._as_grid_batch(item.model_specific_data.get("image_grid_thw"))
if grid is None or grid.shape[0] != 1:
return None
t, h, w = [int(x) for x in grid[0].tolist()]
llm_grid_t = t
llm_grid_h = h // spatial_merge_size
llm_grid_w = w // spatial_merge_size
num_image_tokens = llm_grid_t * llm_grid_h * llm_grid_w
if num_image_tokens != end - start + 1:
return None
t_index = (
torch.arange(llm_grid_t, dtype=dtype, device=device)
.view(-1, 1)
.expand(llm_grid_t, llm_grid_h * llm_grid_w)
.reshape(-1)
)
h_index = (
torch.arange(llm_grid_h, dtype=dtype, device=device)
.view(1, -1, 1)
.expand(llm_grid_t, llm_grid_h, llm_grid_w)
.reshape(-1)
)
w_index = (
torch.arange(llm_grid_w, dtype=dtype, device=device)
.view(1, 1, -1)
.expand(llm_grid_t, llm_grid_h, llm_grid_w)
.reshape(-1)
)
position_segments.append(
torch.stack([t_index, h_index, w_index]) + next_pos
)
next_pos += max(llm_grid_t, llm_grid_h, llm_grid_w)
st = end + 1
if st < input_len:
text_len = input_len - st
position_segments.append(
torch.arange(text_len, dtype=dtype, device=device)
.view(1, -1)
.expand(3, -1)
+ next_pos
)
mrope_positions = torch.cat(position_segments, dim=1).unsqueeze(1)
mrope_position_delta = (mrope_positions.max() + 1 - input_len).reshape(1, 1)
return mrope_positions, mrope_position_delta
@classmethod
def _concat_mm_item_grid(cls, mm_items: list[MultimodalDataItem], key, modality):
grids = []
for item in mm_items:
if not item.is_modality(modality):
continue
grid = cls._as_grid_batch(item.model_specific_data.get(key))
if grid is not None:
grids.append(grid)
if not grids:
return None
if len(grids) == 1:
return grids[0]
return torch.cat(grids, dim=0)
@classmethod
def _get_grid_from_output_or_items(
cls, ret, mm_items, key, modality, input_data=None
):
grid = cls._get_processor_output_value(ret, key)
if grid is None:
grid = cls._concat_mm_item_grid(mm_items, key, modality)
if grid is None and input_data and isinstance(input_data[0], dict):
grid = input_data[0].get(key)
return grid
def get_mm_data(self, prompt, embeddings, **kwargs):
img_grid_thw = kwargs.get("img_grid_thw", None)
video_grid_thw = kwargs.get("video_grid_thw", None)
audio_feature_lens = kwargs.get("audio_feature_lens", None)
video_timestamps = kwargs.get("video_timestamps", None)
second_per_grid_ts = kwargs.get("second_per_grid_ts", None)
audio_seq_lens = None
if audio_feature_lens is not None:
if self.model_type == "qwen3_omni_moe":
# apply _get_feat_extract_lengths to get seq_lens
input_lengths_leave = audio_feature_lens % 100
feat_lengths = (input_lengths_leave - 1) // 2 + 1
audio_seq_lens = (
((feat_lengths - 1) // 2 + 1 - 1) // 2
+ 1
+ (audio_feature_lens // 100) * 13
)
elif self.model_type == "qwen2_5_omni":
audio_seq_lens = (audio_feature_lens - 1) // 2 + 1
audio_seq_lens = (audio_seq_lens - 2) // 2 + 1
if (
self.model_type
in [
"qwen3_vl",
"qwen3_vl_moe",
"qwen3_5",
"qwen3_5_moe",
"intern_s2_preview",
]
and video_timestamps is not None
):
input_ids, offsets, modality_list = self.build_input_ids_with_timestamps(
prompt, embeddings, img_grid_thw, video_grid_thw, video_timestamps
)
else:
input_ids, offsets, modality_list = self.build_input_ids(
prompt, img_grid_thw, video_grid_thw, audio_seq_lens=audio_seq_lens
)
assert all(isinstance(modality, Modality) for modality in modality_list)
mrope_positions, mrope_position_delta = MRotaryEmbedding.get_rope_index(
spatial_merge_size=self._spatial_merge_size,
image_token_id=self.mm_tokens.image_token_id,
video_token_id=self.mm_tokens.video_token_id,
vision_start_token_id=self.vision_start_token_id,
model_type=self.model_type,
input_ids=torch.tensor(input_ids, dtype=torch.long).unsqueeze(0),
image_grid_thw=img_grid_thw,
video_grid_thw=video_grid_thw,
second_per_grid_ts=second_per_grid_ts,
use_audio_in_video=False,
audio_seqlens=(
audio_feature_lens if self.model_type == "qwen3_omni_moe" else None
),
audio_token_id=getattr(self.hf_config, "audio_token_id", None),
audio_start_token_id=self.audio_start_token_id,
position_id_per_seconds=getattr(
self.hf_config, "position_id_per_seconds", None
),
tokens_per_second=self._tokens_per_second,
)
mrope_positions = mrope_positions.squeeze(1)
mm_items = []
consumed_per_modality = {}
for modality, offset in zip(modality_list, offsets):
num_tokens = offset[1] - offset[0] + 1
embedding_start = consumed_per_modality.get(modality, 0)
embedding_slice = embeddings[modality][
embedding_start : embedding_start + num_tokens
]
consumed_per_modality[modality] = embedding_start + num_tokens
mm_items.append(
MultimodalDataItem(
modality=modality,
offsets=[offset],
precomputed_embeddings=embedding_slice,
)
)
return MultimodalProcessorOutput(
input_ids=input_ids,
mm_items=mm_items,
im_start_id=self.IM_START_TOKEN_ID,
im_end_id=self.IM_END_TOKEN_ID,
im_token_id=self.mm_tokens.image_token_id,
video_token_id=self.mm_tokens.video_token_id,
audio_token_id=self.mm_tokens.audio_token_id,
mrope_positions=mrope_positions,
mrope_position_delta=mrope_position_delta,
)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
input_text,
request_obj,
*args,
**kwargs,
):
entry_time = time.perf_counter()
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
video_data=request_obj.video_data,
audio_data=request_obj.audio_data,
multimodal_tokens=self.mm_tokens,
)
load_time = time.perf_counter()
rid = getattr(request_obj, "rid", "anonymous_rid")
video_metadata = None
if base_output.videos and not isinstance(base_output.videos[0], dict):
videos_processed = [
await preprocess_video(video, video_config=self.video_config)
for video in base_output.videos
]
base_output.videos, video_metadata = map(list, zip(*videos_processed))
preprocess_time = time.perf_counter()
# NOTE: for qwen3-vl, video_meta need to be passed in, since do_sample_frames is already done in preprocess_video
if self.hf_config.model_type in (
"qwen3_vl",
"qwen3_vl_moe",
"qwen3_5",
"qwen3_5_moe",
"intern_s2_preview",
):
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output,
self.mm_tokens,
video_metadata=video_metadata,
do_sample_frames=False,
)
else:
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
audio_feature_lengths = None
if self.model_type == "qwen3_omni_moe":
audio_item = next((mm for mm in mm_items if mm.is_audio()), None)
if audio_item:
audio_feature_lengths = torch.sum(
audio_item.feature_attention_mask, dim=1
)
second_per_grid_ts = self._get_processor_output_value(ret, "second_per_grid_ts")
if second_per_grid_ts is None:
second_per_grid_ts = self._get_processor_output_value(
ret, "video_second_per_grid"
)
process_time = time.perf_counter()
input_ids = input_ids.flatten()
base_input_ids = getattr(base_output, "input_ids", None)
if (
isinstance(base_input_ids, list)
and len(base_input_ids) == input_ids.numel()
):
# reuse preprocess input if it already carries list of input_ids
input_ids_list = base_input_ids
else:
input_ids_list = input_ids.tolist()
# look for if padded_input_ids already exists before computing
padded_input_ids = self._get_processor_output_value(ret, "padded_input_ids")
if padded_input_ids is None:
padded_input_ids = MultimodalProcessorOutput.build_padded_input_ids(
input_ids_list, mm_items
)
elif isinstance(padded_input_ids, torch.Tensor):
# reuse existing padded_input_ids
padded_input_ids = padded_input_ids.flatten().tolist()
else:
padded_input_ids = list(padded_input_ids)
image_grid_thw = self._get_grid_from_output_or_items(
ret, mm_items, "image_grid_thw", Modality.IMAGE, image_data
)
video_grid_thw = self._get_grid_from_output_or_items(
ret,
mm_items,
"video_grid_thw",
Modality.VIDEO,
request_obj.video_data,
)
mrope_result = self._get_precomputed_mrope_from_output(ret)
if mrope_result is None:
if (
video_grid_thw is None
and second_per_grid_ts is None
and audio_feature_lengths is None
):
mrope_result = self._compute_image_only_mrope_positions_from_offsets(
input_len=input_ids.numel(),
mm_items=mm_items,
dtype=input_ids.dtype,
device=input_ids.device,
)
if mrope_result is None:
mrope_result = MRotaryEmbedding.get_rope_index(
spatial_merge_size=self._spatial_merge_size,
image_token_id=self.mm_tokens.image_token_id,
video_token_id=self.mm_tokens.video_token_id,
vision_start_token_id=self.vision_start_token_id,
model_type=self.model_type,
tokens_per_second=self._tokens_per_second,
# use the expanded token ids
input_ids=input_ids.unsqueeze(0),
image_grid_thw=image_grid_thw,
video_grid_thw=video_grid_thw,
second_per_grid_ts=second_per_grid_ts,
use_audio_in_video=False,
audio_seqlens=audio_feature_lengths,
audio_token_id=getattr(self.hf_config, "audio_token_id", None),
audio_start_token_id=self.audio_start_token_id,
position_id_per_seconds=getattr(
self.hf_config, "position_id_per_seconds", None
),
)
mrope_positions, mrope_position_delta = mrope_result
if mrope_positions.ndim == 3:
mrope_positions = mrope_positions.squeeze(1)
get_rope_index_time = time.perf_counter()
logger.debug(
f"[QwenVLProcessor Perf] {rid=}, "
f"load_time: {(load_time - entry_time) * 1000:.2f} ms, "
f"preprocess_time: {(preprocess_time - load_time) * 1000:.2f} ms, "
f"process_time: {(process_time - preprocess_time) * 1000:.2f} ms, "
f"get_rope_index_time: {(get_rope_index_time - process_time) * 1000:.2f} ms, "
f"total_time: {(get_rope_index_time - entry_time) * 1000:.2f} ms"
)
return MultimodalProcessorOutput(
input_ids=input_ids_list,
padded_input_ids=padded_input_ids,
mm_items=mm_items,
im_start_id=self.vision_start_token_id,
im_end_id=self.vision_end_token_id,
im_token_id=self.mm_tokens.image_token_id,
video_token_id=self.mm_tokens.video_token_id,
audio_token_id=self.mm_tokens.audio_token_id,
mrope_positions=mrope_positions,
mrope_position_delta=mrope_position_delta,
)
@@ -0,0 +1,82 @@
from typing import List, Union
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.sarashina2_vision import Sarashina2VisionForCausalLM
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
class Sarashina2VisionProcessor(BaseMultimodalProcessor):
models = [Sarashina2VisionForCausalLM]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
# Sarashina2Vision specific tokens (default is <|file|>)
self.IMAGE_TOKEN = "<|file|>"
self.IM_TOKEN_ID = getattr(hf_config, "image_token_index", 14)
self.IM_START_ID = getattr(hf_config, "start_image_token_index", 102397)
self.IM_END_ID = getattr(hf_config, "end_image_token_index", 102398)
self.mm_tokens = MultimodalSpecialTokens(
image_token=self.IMAGE_TOKEN,
image_token_id=self.IM_TOKEN_ID,
).build(_processor)
# Patch the processor's image processor to handle parameter compatibility
if hasattr(_processor, "image_processor") and hasattr(
_processor.image_processor, "_preprocess"
):
original_preprocess = _processor.image_processor._preprocess
def patched_preprocess(*args, **kwargs):
# Filter kwargs to only include parameters that the custom _preprocess method accepts
# Based on Sarashina2VisionImageProcessor._preprocess signature
allowed_params = {
"do_resize",
"resample",
"do_rescale",
"rescale_factor",
"do_normalize",
"image_mean",
"image_std",
"do_convert_rgb",
"data_format",
"input_data_format",
}
filtered_kwargs = {
k: v for k, v in kwargs.items() if k in allowed_params
}
return original_preprocess(*args, **filtered_kwargs)
_processor.image_processor._preprocess = patched_preprocess
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
input_text,
request_obj,
*args,
**kwargs,
):
"""Process image data for Sarashina2Vision model using standard SGLang pattern."""
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
multimodal_tokens=self.mm_tokens,
)
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output=base_output,
mm_tokens=self.mm_tokens,
)
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids.tolist(),
im_token_id=self.mm_tokens.image_token_id,
im_start_id=self.IM_START_ID,
im_end_id=self.IM_END_ID,
)
@@ -0,0 +1,574 @@
import math
import re
from itertools import product
from typing import List, Optional, Union
import numpy as np
import torch
from PIL import Image
from torchvision import transforms
from torchvision.transforms import InterpolationMode
from torchvision.transforms import functional as F
from transformers import BatchFeature, ProcessorMixin, TensorType
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.step3_vl import Step3VLForConditionalGeneration
from sglang.srt.models.step3_vl_10b import StepVLForConditionalGeneration
from sglang.srt.models.step3p7 import Step3p7ForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor as SGLangBaseProcessor,
)
from sglang.srt.multimodal.processors.base_processor import (
MultimodalSpecialTokens,
)
Step3Image = Union[Image.Image, torch.Tensor]
ImageWithPatches = tuple[Step3Image, list[Step3Image], list[int] | None]
class GPUToTensor(torch.nn.Module):
def forward(
self, raw_image: Union[np.ndarray, Image.Image, torch.Tensor]
) -> torch.Tensor:
if isinstance(raw_image, torch.Tensor):
image_tensor = raw_image
if image_tensor.ndim != 3:
raise TypeError(
f"Expected CHW image tensor, got shape {tuple(image_tensor.shape)}"
)
if image_tensor.shape[0] == 1:
image_tensor = image_tensor.repeat(3, 1, 1)
elif image_tensor.shape[0] != 3:
raise TypeError(
f"Expected CHW image tensor with 1 or 3 channels, got shape {tuple(image_tensor.shape)}"
)
if image_tensor.dtype == torch.uint8:
image_tensor = image_tensor.to(torch.float32).div(255)
elif not image_tensor.is_floating_point():
image_tensor = image_tensor.to(torch.float32)
return image_tensor.contiguous()
if isinstance(raw_image, Image.Image):
image_tensor = transforms.ToTensor()(raw_image)
if torch.cuda.is_available():
image_tensor = image_tensor.to(torch.device("cuda"))
return image_tensor
if raw_image.ndim == 2:
raw_image = raw_image[:, :, None].repeat(3, -1)
if torch.cuda.is_available():
device = torch.device("cuda")
else:
device = torch.device("cpu")
image_tensor = torch.from_numpy(raw_image).to(device)
image_tensor = torch.permute(image_tensor, (2, 0, 1)).contiguous()
if image_tensor.dtype == torch.uint8:
image_tensor = image_tensor.to(torch.float32).div(255)
return image_tensor
class Step3VisionProcessor:
def __init__(self, size, interpolation_mode="bicubic", patch_size=None):
mean = [0.48145466, 0.4578275, 0.40821073]
std = [0.26862954, 0.26130258, 0.27577711]
patch_size = patch_size if patch_size is not None else size
self.transform = transforms.Compose(
[
GPUToTensor(),
transforms.Normalize(mean, std),
transforms.Resize(
(size, size),
interpolation=(
InterpolationMode.BICUBIC
if interpolation_mode == "bicubic"
else InterpolationMode.BILINEAR
),
antialias=True,
),
]
)
self.patch_transform = (
transforms.Compose(
[
GPUToTensor(),
transforms.Normalize(mean, std),
transforms.Resize(
(patch_size, patch_size),
interpolation=(
InterpolationMode.BICUBIC
if interpolation_mode == "bicubic"
else InterpolationMode.BILINEAR
),
antialias=True,
),
]
)
if patch_size is not None
else None
)
def __call__(self, image, is_patch=False):
if is_patch:
return {"pixel_values": self.patch_transform(image).unsqueeze(0)}
else:
return {"pixel_values": self.transform(image).unsqueeze(0)}
class ImagePatcher:
def get_image_size(self, img: Step3Image) -> tuple[int, int]:
if isinstance(img, Image.Image):
return img.size
if isinstance(img, torch.Tensor):
if img.ndim != 3:
raise TypeError(
f"Expected CHW image tensor, got shape {tuple(img.shape)}"
)
return int(img.shape[-1]), int(img.shape[-2])
raise TypeError(f"Unsupported image type: {type(img)}")
def determine_window_size(self, long: int, short: int) -> int:
if long <= 728:
return short if long / short > 1.5 else 0
return min(short, 504) if long / short > 4 else 504
def slide_window(
self,
width: int,
height: int,
sizes: list[tuple[int, int]],
steps: list[tuple[int, int]],
img_rate_thr: float = 0.6,
) -> tuple[list[tuple[int, int, int, int]], tuple[int, int]]:
assert 1 >= img_rate_thr >= 0, "The `img_rate_thr` should lie in 0~1"
windows = []
# Sliding windows.
for size, step in zip(sizes, steps):
size_w, size_h = size
step_w, step_h = step
x_num = 1 if width <= size_w else math.ceil((width - size_w) / step_w + 1)
x_start = [step_w * i for i in range(x_num)]
if len(x_start) > 1 and x_start[-1] + size_w > width:
x_start[-1] = width - size_w
y_num = 1 if height <= size_h else math.ceil((height - size_h) / step_h + 1)
y_start = [step_h * i for i in range(y_num)]
if len(y_start) > 1 and y_start[-1] + size_h > height:
y_start[-1] = height - size_h
start = np.array(list(product(y_start, x_start)), dtype=int)
start[:, [0, 1]] = start[:, [1, 0]]
windows.append(np.concatenate([start, start + size], axis=1))
windows = np.concatenate(windows, axis=0)
return [
(int(box[0]), int(box[1]), int(box[2] - box[0]), int(box[3] - box[1]))
for box in windows
], (x_num, y_num)
def square_pad(self, img: Step3Image) -> Step3Image:
w, h = self.get_image_size(img)
if w == h:
return img
size = max(w, h)
if isinstance(img, Image.Image):
padded = Image.new(img.mode, (size, size), 0)
padded.paste(img, (0, 0))
return padded
return torch.nn.functional.pad(img, (0, size - w, 0, size - h), value=0)
def get_image_size_for_padding(
self, img_width: int, img_height: int
) -> tuple[int, int]:
ratio = img_width / img_height
if min(img_height, img_width) < 32 and (ratio > 4 or ratio < 1 / 4):
new_size = max(img_height, img_width)
return new_size, new_size
return img_width, img_height
def get_image_size_for_preprocess(
self, img_width: int, img_height: int
) -> tuple[int, int]:
if max(img_height, img_width) > 3024:
scale_factor = 3024 / max(img_height, img_width)
img_width = int(img_width * scale_factor)
img_height = int(img_height * scale_factor)
return img_width, img_height
else:
return img_width, img_height
def get_image_size_for_crop(
self, img_width: int, img_height: int, window_size: int
):
w_ratio = img_width / window_size
h_ratio = img_height / window_size
if w_ratio < 1:
width_new = img_width
else:
decimal_w = w_ratio - img_width // window_size
w_ratio = int(w_ratio) + 1 if decimal_w > 0.2 else int(w_ratio)
width_new = window_size * w_ratio
if h_ratio < 1:
height_new = img_height
else:
decimal_h = h_ratio - img_height // window_size
h_ratio = int(h_ratio) + 1 if decimal_h > 0.2 else int(h_ratio)
height_new = window_size * h_ratio
return int(width_new), int(height_new)
def resize(self, img: Step3Image, size: tuple[int, int]) -> Step3Image:
if isinstance(img, Image.Image):
return img.resize(size, Image.Resampling.BILINEAR)
return F.resize(
img,
[size[1], size[0]],
interpolation=InterpolationMode.BILINEAR,
antialias=True,
).contiguous()
def patch_crop(
self, img: Step3Image, i: int, j: int, th: int, tw: int
) -> Step3Image:
if isinstance(img, Image.Image):
return img.crop((j, i, j + tw, i + th))
return img[:, i : i + th, j : j + tw].contiguous()
def get_num_patches(self, img_width: int, img_height: int) -> tuple[int, int]:
img_width, img_height = self.get_image_size_for_padding(img_width, img_height)
img_width, img_height = self.get_image_size_for_preprocess(
img_width, img_height
)
window_size = self.determine_window_size(
max(img_height, img_width), min(img_height, img_width)
)
if window_size == 0:
return 0, 0
else:
img_width, img_height = self.get_image_size_for_crop(
img_width, img_height, window_size
)
center_list, (x_num, y_num) = self.slide_window(
img_width,
img_height,
[(window_size, window_size)],
[(window_size, window_size)],
)
full_rows = (len(center_list) - 1) // x_num + 1
if len(center_list) > 0 and len(center_list) % x_num == 0:
full_rows -= 1
return len(center_list), full_rows
def __call__(
self, img: Step3Image
) -> tuple[Step3Image, list[Step3Image], list[bool] | None]:
img_width, img_height = self.get_image_size(img)
new_img_width, new_img_height = self.get_image_size_for_padding(
img_width, img_height
)
if new_img_width != img_width or new_img_height != img_height:
img = self.square_pad(img)
img_width, img_height = self.get_image_size(img)
new_img_width, new_img_height = self.get_image_size_for_preprocess(
img_width, img_height
)
img = self.resize(img, (new_img_width, new_img_height))
window_size = self.determine_window_size(
max(new_img_height, new_img_width), min(new_img_height, new_img_width)
)
if window_size == 0:
return img, [], None
else:
new_img_width, new_img_height = self.get_image_size_for_crop(
new_img_width, new_img_height, window_size
)
if (new_img_width, new_img_height) != (img_width, img_height):
img_for_crop = self.resize(img, (new_img_width, new_img_height))
else:
img_for_crop = img
patches = []
newlines = []
center_list, (x_num, y_num) = self.slide_window(
new_img_width,
new_img_height,
[(window_size, window_size)],
[(window_size, window_size)],
)
for patch_id, center_lf_point in enumerate(center_list):
x, y, patch_w, patch_h = center_lf_point
big_patch = self.patch_crop(img_for_crop, y, x, patch_h, patch_w)
patches.append(big_patch)
if (patch_id + 1) % x_num == 0:
newlines.append(patch_id)
if newlines and newlines[-1] == len(patches) - 1:
newlines.pop()
return (
img,
patches,
(
[i in newlines for i in range(len(patches))]
if len(patches) > 0
else None
),
)
class Step3VLProcessor:
def __init__(
self,
config,
tokenizer,
) -> None:
super().__init__()
self.config = config
if isinstance(tokenizer, ProcessorMixin):
tokenizer = tokenizer.tokenizer
self.tokenizer = tokenizer
self.image_size = 728
self.patch_size = 504
self.image_preprocessor = Step3VisionProcessor(
self.image_size, "bilinear", self.patch_size
)
self.num_image_feature_size = 169
self.num_patch_feature_size = 81
self.image_token = "<im_patch>"
self.image_feature_placeholder = self.image_token * self.num_image_feature_size
self.patch_feature_placeholder = self.image_token * self.num_patch_feature_size
self.patcher = ImagePatcher()
@property
def image_token_id(self) -> int:
return self.tokenizer.get_vocab()[self.image_token]
def get_num_image_tokens(self, img_width: int, img_height: int) -> int:
num_patches, num_newlines = self.patcher.get_num_patches(img_width, img_height)
return (
num_patches * (self.num_patch_feature_size + 2)
+ self.num_image_feature_size
+ 2
+ num_newlines
)
def _split_images(self, images: list[Image.Image]) -> list[ImageWithPatches]:
result = []
for img in images:
result.append(self.patcher(img))
return result
def _convert_images_to_pixel_values(
self,
images: list[Step3Image],
is_patch: bool = False,
) -> list[torch.Tensor]:
return [
self.image_preprocessor(img, is_patch=is_patch)["pixel_values"]
for img in images
]
def _get_patch_repl(
self,
num_patches: int,
patch_newline_mask: list[bool] | None,
) -> tuple[str, list[int]]:
text = ""
token_ids = []
for i in range(num_patches):
assert len(patch_newline_mask) == num_patches
text += f"<patch_start>{self.patch_feature_placeholder}<patch_end>"
token_ids.extend(
[self.tokenizer.convert_tokens_to_ids("<patch_start>")]
+ [self.image_token_id] * self.num_patch_feature_size
+ [self.tokenizer.convert_tokens_to_ids("<patch_end>")]
)
if patch_newline_mask and patch_newline_mask[i]:
text += "<patch_newline>"
token_ids.append(
self.tokenizer.convert_tokens_to_ids("<patch_newline>")
)
return text, token_ids
def _get_image_repl(
self,
num_images: int,
) -> tuple[str, list[int]]:
text = f"<im_start>{self.image_feature_placeholder}<im_end>"
token_ids = (
[self.tokenizer.convert_tokens_to_ids("<im_start>")]
+ [self.image_token_id] * self.num_image_feature_size
+ [self.tokenizer.convert_tokens_to_ids("<im_end>")]
)
return text * num_images, token_ids * num_images
def _get_image_repl_features(
self,
num_images: int,
num_patches: int,
patch_new_line_idx: Optional[list[bool]],
) -> tuple[str, list[int]]:
if num_patches > 0:
patch_repl, patch_repl_ids = self._get_patch_repl(
num_patches, patch_new_line_idx
)
else:
patch_repl = ""
patch_repl_ids = []
image_repl, image_repl_ids = self._get_image_repl(num_images)
return patch_repl + image_repl, patch_repl_ids + image_repl_ids
def replace_placeholder(self, text: str, placeholder: str, repls: list[str]) -> str:
parts = text.split(placeholder)
if len(parts) - 1 != len(repls):
raise ValueError(
"The number of placeholders does not match the number of replacements." # noqa: E501
)
result = [parts[0]]
for i, repl in enumerate(repls):
result.append(repl)
result.append(parts[i + 1])
return "".join(result)
def __call__(
self,
text: Optional[Union[str, list[str]]] = None,
images: Optional[Union[Image.Image, list[Image.Image]]] = None,
return_tensors: Optional[Union[str, TensorType]] = None,
*args,
**kwargs,
) -> BatchFeature:
if text is None:
text = []
if not isinstance(text, list):
text = [text]
if images is None:
images = []
if not isinstance(images, list):
images = [images]
if len(images) == 0:
image_inputs = {}
text_inputs = self.tokenizer(text)
else:
splitted_images_data = self._split_images(images)
pixel_values_lst = []
patch_pixel_values_lst = []
patch_newline_mask_lst = []
image_repl_str_lst = []
image_repl_ids_lst = []
num_patches = []
for (
raw_img,
img_patches,
patch_newline_mask,
) in splitted_images_data: # noqa: E501
pixel_values_lst.extend(self._convert_images_to_pixel_values([raw_img]))
if len(img_patches) > 0:
patch_pixel_values_lst.extend(
self._convert_images_to_pixel_values(img_patches, is_patch=True)
)
num_patches.append(len(img_patches))
image_repl_str, image_repl_ids = self._get_image_repl_features(
1, len(img_patches), patch_newline_mask
)
image_repl_str_lst.append(image_repl_str)
image_repl_ids_lst.extend(image_repl_ids)
if patch_newline_mask is not None:
patch_newline_mask_lst.extend(patch_newline_mask)
image_inputs = {
"pixel_values": torch.cat(pixel_values_lst),
"num_patches": num_patches,
}
if patch_pixel_values_lst:
image_inputs["patch_pixel_values"] = torch.cat(patch_pixel_values_lst)
if patch_newline_mask_lst:
image_inputs["patch_newline_mask"] = torch.tensor(
patch_newline_mask_lst, dtype=torch.bool
)
text = [
self.replace_placeholder(t, self.image_token, image_repl_str_lst)
for t in text
]
text_inputs = self.tokenizer(text)
return BatchFeature(
{
**text_inputs,
**image_inputs,
},
tensor_type=return_tensors,
)
################################################
class Step3VLImageProcessor(SGLangBaseProcessor):
models = [
Step3VLForConditionalGeneration,
StepVLForConditionalGeneration,
Step3p7ForConditionalGeneration,
]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
# TODO, check _processor is tokenizer or processor.
processor = Step3VLProcessor(hf_config, _processor)
super().__init__(hf_config, server_args, processor, *args, **kwargs)
self.IM_TOKEN = "<im_patch>"
self.IM_TOKEN_ID = self._processor.tokenizer.get_vocab()[self.IM_TOKEN]
self.mm_tokens = MultimodalSpecialTokens(
image_token=self.IM_TOKEN,
image_token_id=self.IM_TOKEN_ID,
image_token_regex=re.compile(r"(?:<im_patch>)"),
).build(_processor)
mean = [0.48145466, 0.4578275, 0.40821073]
std = [0.26862954, 0.26130258, 0.27577711]
def preprocess(self, image):
return {"pixel_values": self.transform(image).unsqueeze(0)}
def __call__(self, image):
return self.preprocess(image)
async def process_mm_data_async(
self,
image_data: List[Union[str, bytes]],
input_text: str | List[int],
request_obj,
*args,
**kwargs,
):
base_output = await self.load_mm_data(
prompt=input_text,
image_data=image_data,
video_data=request_obj.video_data,
multimodal_tokens=self.mm_tokens,
)
mm_items, input_ids, ret = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
im_token_id=self.mm_tokens.image_token_id,
)
@@ -0,0 +1,218 @@
from typing import Optional
import torch
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
from sglang.srt.utils import load_image
def _first_attr(obj, names: tuple[str, ...], default=None):
for name in names:
value = getattr(obj, name, None)
if value is not None:
return value
return default
def _uses_mrope(hf_config) -> bool:
text_config = getattr(hf_config, "text_config", hf_config)
rope_scaling = getattr(text_config, "rope_scaling", None) or {}
if isinstance(rope_scaling, dict) and "mrope_section" in rope_scaling:
return True
rope_type = str(getattr(text_config, "rope_type", "")).lower()
return "mrope" in rope_type
class TransformersAutoMultimodalProcessor(BaseMultimodalProcessor):
"""Generic multimodal processor for the Transformers backend.
Unlike model-specific processors that rely on regex-based token matching
in the raw prompt, this processor applies the HF processor directly to
the prompt text + raw media. This handles models like Gemma3 where the
chat template uses a marker (``<start_of_image>``) that the HF processor
internally expands into placeholder tokens.
"""
models = []
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.mm_tokens = MultimodalSpecialTokens(
image_token=getattr(_processor, "image_token", None),
video_token=getattr(_processor, "video_token", None),
audio_token=getattr(_processor, "audio_token", None),
image_token_id=_first_attr(
hf_config,
("image_token_id", "image_token_index", "im_token_id"),
),
video_token_id=_first_attr(
hf_config,
("video_token_id",),
),
audio_token_id=_first_attr(
hf_config,
("audio_token_id",),
),
).build(_processor)
self._is_mrope = _uses_mrope(hf_config)
if self._is_mrope:
vision_config = getattr(hf_config, "vision_config", None)
self._spatial_merge_size = getattr(vision_config, "spatial_merge_size", 2)
self._tokens_per_second = getattr(vision_config, "tokens_per_second", None)
self._vision_start_token_id = _first_attr(
hf_config, ("vision_start_token_id",)
)
self._model_type = getattr(hf_config, "model_type", "")
def _compute_mrope_positions(
self,
input_ids: list[int],
image_grid_thw: Optional[torch.Tensor] = None,
video_grid_thw: Optional[torch.Tensor] = None,
):
from sglang.srt.layers.rotary_embedding import MRotaryEmbedding
input_ids_tensor = torch.tensor(input_ids, dtype=torch.long).unsqueeze(0)
mrope_positions, mrope_position_delta = MRotaryEmbedding.get_rope_index(
spatial_merge_size=self._spatial_merge_size,
image_token_id=self.mm_tokens.image_token_id,
video_token_id=self.mm_tokens.video_token_id or -1,
vision_start_token_id=self._vision_start_token_id,
model_type=self._model_type,
input_ids=input_ids_tensor,
image_grid_thw=image_grid_thw,
video_grid_thw=video_grid_thw,
tokens_per_second=self._tokens_per_second,
)
return mrope_positions.squeeze(1), mrope_position_delta
def _load_images(self, image_data) -> list:
"""Download / decode images from URLs, file paths, or base64."""
if not image_data:
return []
images = []
for data in image_data:
img, _ = load_image(data)
if img.mode != "RGB":
img = img.convert("RGB")
images.append(img)
return images
def _apply_hf_processor(self, text: str, images=None, videos=None):
"""Run the HF processor on text + media and return the full output.
This is the key method that makes the generic processor work for
models with non-trivial token expansion (Gemma3, PaliGemma, etc.).
The HF processor handles chat-template expansion, image token
insertion, and tokenization in one shot.
"""
kwargs = {}
if images:
kwargs["images"] = images
if videos:
kwargs["videos"] = videos
return self._processor(text=text, return_tensors="pt", **kwargs)
def _build_mm_items(
self, processor_output: dict, input_ids: torch.Tensor
) -> list[MultimodalDataItem]:
"""Extract MultimodalDataItem objects from the HF processor output."""
items = self.collect_mm_items_from_processor_output(processor_output)
modality_to_token_id = {
Modality.IMAGE: self.mm_tokens.image_token_id,
Modality.VIDEO: self.mm_tokens.video_token_id,
Modality.AUDIO: self.mm_tokens.audio_token_id,
}
for item in items:
token_id = modality_to_token_id.get(item.modality)
if token_id is not None:
item.offsets = self.get_mm_items_offset(input_ids, token_id)
return items
async def process_mm_data_async(
self,
image_data,
audio_data,
input_text,
request_obj,
**kwargs,
):
video_data = getattr(request_obj, "video_data", None)
if video_data is not None and not isinstance(video_data, list):
video_data = [video_data]
# Load raw media
images = self._load_images(image_data)
# TODO: video / audio loading when needed
# Apply HF processor — handles token expansion internally
processor_output = self._apply_hf_processor(
text=input_text,
images=images or None,
videos=video_data or None,
)
input_ids = processor_output["input_ids"].flatten()
# Build mm_items from processor output
mm_items = self._build_mm_items(processor_output, input_ids)
ret = MultimodalProcessorOutput(
input_ids=input_ids.tolist(),
mm_items=mm_items,
)
# Propagate token_type_ids for models that need it (Gemma3, PaliGemma)
token_type_key = (
"mm_token_type_ids"
if "mm_token_type_ids" in processor_output
else "token_type_ids"
)
if token_type_key in processor_output:
ret.token_type_ids = processor_output[token_type_key].flatten().tolist()
if self.mm_tokens.image_token_id is not None:
ret.im_token_id = self.mm_tokens.image_token_id
if self.mm_tokens.video_token_id is not None:
ret.video_token_id = self.mm_tokens.video_token_id
if self.mm_tokens.audio_token_id is not None:
ret.audio_token_id = self.mm_tokens.audio_token_id
image_start_id = _first_attr(
self.hf_config,
("image_start_token_id", "vision_start_token_id", "im_start_id"),
)
image_end_id = _first_attr(
self.hf_config,
("image_end_token_id", "vision_end_token_id", "im_end_id"),
)
if image_start_id is not None:
ret.im_start_id = image_start_id
if image_end_id is not None:
ret.im_end_id = image_end_id
# M-RoPE positions (Qwen2.5-VL, Qwen3-VL)
if self._is_mrope:
image_grid_thw = processor_output.get("image_grid_thw")
video_grid_thw = processor_output.get("video_grid_thw")
mrope_positions, mrope_position_delta = self._compute_mrope_positions(
ret.input_ids,
image_grid_thw=image_grid_thw,
video_grid_thw=video_grid_thw,
)
ret.mrope_positions = mrope_positions
ret.mrope_position_delta = mrope_position_delta
return ret
@@ -0,0 +1,119 @@
"""Standalone UNLIMITED-OCR processor."""
import hashlib
import logging
from typing import List, Union
import torch
logger = logging.getLogger(__name__)
from sglang.srt.managers.schedule_batch import MultimodalProcessorOutput
from sglang.srt.models.unlimited_ocr import UnlimitedOCRForCausalLM
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
_IMAGE_MODE_PRESETS = {
"tiny": (512, 512, False),
"small": (640, 640, False),
"base": (1024, 1024, False),
"large": (1280, 1280, False),
"gundam": (1024, 640, True),
}
_DEFAULT_MODE = "gundam"
def _resolve_mode(images_config, num_images: int = 1) -> dict:
"""Return processor kwargs from images_config (or default)."""
mode = _DEFAULT_MODE
if images_config:
mode = images_config.get("image_mode", _DEFAULT_MODE)
key = mode.strip().lower()
preset = _IMAGE_MODE_PRESETS.get(key)
if preset is None:
logger.error(
f"Unknown image_mode '{mode}'. Supported: {', '.join(_IMAGE_MODE_PRESETS)}"
)
raise ValueError(
f"Unknown image_mode '{mode}'. "
f"Supported: {', '.join(_IMAGE_MODE_PRESETS)}"
)
_MULTI_IMAGE_ALLOWED = ("tiny", "small", "base")
base_size, image_size, crop_mode = preset
if num_images > 1 and key not in _MULTI_IMAGE_ALLOWED:
raise ValueError(
f"image_mode='{mode}' is not supported with multiple images "
f"(got {num_images} images). "
f"Please use one of: {list(_MULTI_IMAGE_ALLOWED)}"
)
return dict(zip(("base_size", "image_size", "crop_mode"), preset))
class UnlimitedOCRProcessor(BaseMultimodalProcessor):
"""Multimodal processor for UNLIMITED-OCR model."""
models = [UnlimitedOCRForCausalLM]
gpu_image_decode = False
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
"""Initialize UnlimitedOCRProcessor."""
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.mm_tokens = MultimodalSpecialTokens(
image_token="<image>", image_token_id=self._processor.image_token_id
).build(_processor)
@staticmethod
def _mix_config_into_hash(mm_items, processor_kwargs):
"""Mix images_config into mm_item hashes so that different configs
produce different pad_values, avoiding radix/embedding cache collisions."""
from sglang.srt.managers.mm_utils import hash_feature
config_bytes = str(sorted(processor_kwargs.items())).encode()
for item in mm_items:
if item.feature is not None:
base_hash = hash_feature(item.feature)
elif item.precomputed_embeddings is not None:
base_hash = hash_feature(item.precomputed_embeddings)
else:
continue
combined = hashlib.sha256(
base_hash.to_bytes(8, byteorder="big") + config_bytes
).digest()[:8]
item.hash = int.from_bytes(combined, byteorder="big", signed=False)
async def process_mm_data_async(
self, image_data: List[Union[str, bytes]], input_text, *args, **kwargs
):
"""Process multimodal data asynchronously."""
request_obj = kwargs.get("request_obj")
images_config = (
getattr(request_obj, "images_config", None) if request_obj else None
)
processor_kwargs = _resolve_mode(images_config, num_images=len(image_data))
prefix = images_config.get("prefix", "") if images_config else ""
base_output = await self.load_mm_data(
prompt=input_text,
multimodal_tokens=self.mm_tokens,
image_data=image_data,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens, **processor_kwargs
)
if prefix:
prefix_ids = self._tokenizer.encode(prefix, add_special_tokens=False)
input_ids = torch.cat(
[input_ids, torch.tensor(prefix_ids, dtype=input_ids.dtype)]
)
self._mix_config_into_hash(mm_items, processor_kwargs)
return MultimodalProcessorOutput(
mm_items=mm_items,
input_ids=input_ids.tolist(),
im_token_id=self.mm_tokens.image_token_id,
)
@@ -0,0 +1,217 @@
"""Multimodal processor for Voxtral (speech-to-text) models."""
import math
import re
from typing import Dict, List, Optional
import torch
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.models.voxtral import VoxtralForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import (
BaseMultimodalProcessor,
MultimodalSpecialTokens,
)
# Special token IDs for Voxtral audio (from tekken.json vocabulary)
AUDIO_TOKEN_ID = 24 # [AUDIO]
BEGIN_AUDIO_TOKEN_ID = 25 # [BEGIN_AUDIO]
INST_TOKEN_ID = 3 # [INST]
# Placeholder for load_mm_data regex matching.
# encode("[AUDIO]") does NOT produce token 24; actual token insertion
# is handled in _build_input_ids_with_audio.
AUDIO_PLACEHOLDER = "[AUDIO]"
AUDIO_PLACEHOLDER_REGEX = re.compile(r"\[AUDIO\]")
class VoxtralMultimodalProcessor(BaseMultimodalProcessor):
models = [VoxtralForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
audio_config = getattr(hf_config, "audio_config", None)
self.audio_token_id = getattr(hf_config, "audio_token_id", AUDIO_TOKEN_ID)
self.sampling_rate = getattr(audio_config, "sampling_rate", 16000)
self.hop_length = getattr(audio_config, "hop_length", 160)
self.max_source_positions = getattr(audio_config, "max_source_positions", 1500)
self.conv_downsample = 2 # conv1 stride=1 * conv2 stride=2
self.downsample_factor = getattr(
audio_config,
"downsample_factor",
getattr(audio_config, "intermediate_size", 5120)
// getattr(audio_config, "hidden_size", 1280),
)
self.mm_tokens = MultimodalSpecialTokens(
audio_token=AUDIO_PLACEHOLDER,
audio_token_regex=AUDIO_PLACEHOLDER_REGEX,
audio_token_id=self.audio_token_id,
).build(_processor)
def _compute_audio_token_count(self, n_samples: int) -> int:
"""Compute the number of [AUDIO] tokens for a given audio length."""
mel_frames = n_samples / self.hop_length
chunk_size = self.max_source_positions * self.conv_downsample
n_chunks = math.ceil(mel_frames / chunk_size) if mel_frames > 0 else 1
tokens_per_chunk = self.max_source_positions // self.downsample_factor
return n_chunks * tokens_per_chunk
async def process_mm_data_async(
self,
image_data,
audio_data,
input_text,
request_obj,
**kwargs,
) -> Optional[MultimodalProcessorOutput]:
if not audio_data:
return None
# Insert [AUDIO] placeholders into prompt for load_mm_data's regex
prompt_with_placeholders = self._insert_audio_placeholders(
input_text, len(audio_data)
)
# load_mm_data handles async loading, format detection, resampling.
# process_and_combine_mm_data cannot be used: HF VoxtralProcessor.__call__
# does not support audio (only apply_chat_template does).
base_output = await self.load_mm_data(
prompt=prompt_with_placeholders,
audio_data=audio_data,
multimodal_tokens=self.mm_tokens,
audio_sample_rate=self.sampling_rate,
)
if base_output is None:
return None
# Convert loaded audio to tensors
waveforms: List[torch.Tensor] = []
for audio in base_output.audios:
wav = torch.as_tensor(audio, dtype=torch.float32)
if wav.dim() > 1:
wav = wav.mean(dim=0)
waveforms.append(wav)
# Compute audio token counts and build input_ids with audio tokens
audio_token_counts = [
self._compute_audio_token_count(wav.shape[-1]) for wav in waveforms
]
tokenizer = getattr(self._processor, "tokenizer", self._processor)
input_ids = self._build_input_ids_with_audio(
tokenizer, input_text, audio_token_counts
)
# Find offsets of [AUDIO] token runs and build mm_items
audio_offsets = self._find_audio_offsets(input_ids, self.audio_token_id)
mm_items = []
for i, wav in enumerate(waveforms):
item = MultimodalDataItem(feature=wav, modality=Modality.AUDIO)
if i < len(audio_offsets):
item.offsets = [audio_offsets[i]]
mm_items.append(item)
return MultimodalProcessorOutput(
input_ids=input_ids,
mm_items=mm_items,
audio_token_id=self.audio_token_id,
)
@staticmethod
def _insert_audio_placeholders(prompt: str, n_audio: int) -> str:
"""Insert [AUDIO] placeholder texts into the prompt for load_mm_data."""
placeholders = AUDIO_PLACEHOLDER * n_audio
# Insert after the last [INST] marker if present
last_inst = prompt.rfind("[INST]")
if last_inst >= 0:
insert_pos = last_inst + len("[INST]")
return prompt[:insert_pos] + placeholders + prompt[insert_pos:]
return placeholders + prompt
@staticmethod
def _find_audio_offsets(input_ids: List[int], audio_token_id: int) -> List[tuple]:
"""Find consecutive runs of audio_token_id in input_ids."""
offsets = []
start = None
for i, tok_id in enumerate(input_ids):
if tok_id == audio_token_id:
if start is None:
start = i
elif start is not None:
offsets.append((start, i - 1))
start = None
if start is not None:
offsets.append((start, len(input_ids) - 1))
return offsets
def _build_input_ids_with_audio(
self,
tokenizer,
input_text: str,
audio_token_counts: List[int],
) -> List[int]:
"""Build input_ids by tokenizing text and inserting audio tokens.
The input_text is a decoded Mistral prompt (from text-only
apply_chat_template). We re-tokenize to get proper special tokens
(BOS, [INST], [/INST]), then insert [BEGIN_AUDIO] + [AUDIO]*N after
the last [INST].
"""
messages = self._parse_mistral_prompt(input_text)
try:
input_ids = tokenizer.apply_chat_template(messages, tokenize=True)
except (ValueError, KeyError):
# Fallback if prompt parsing produces malformed messages
input_ids = tokenizer.encode(input_text)
# Insert audio tokens after the last [INST]
inst_positions = [i for i, t in enumerate(input_ids) if t == INST_TOKEN_ID]
insert_pos = (inst_positions[-1] + 1) if inst_positions else 1
audio_tokens = []
for count in audio_token_counts:
audio_tokens.append(BEGIN_AUDIO_TOKEN_ID)
audio_tokens.extend([AUDIO_TOKEN_ID] * count)
return input_ids[:insert_pos] + audio_tokens + input_ids[insert_pos:]
@staticmethod
def _parse_mistral_prompt(prompt: str) -> List[Dict[str, str]]:
"""Parse a Mistral-formatted prompt into a list of messages."""
messages = []
text = prompt.strip()
for marker in ["<s>", "</s>"]:
text = text.replace(marker, "")
text = text.strip()
# Extract system prompt
system_match = re.search(
r"\[SYSTEM_PROMPT\]\s*(.*?)\s*\[/SYSTEM_PROMPT\]", text, re.DOTALL
)
if system_match:
messages.append(
{"role": "system", "content": system_match.group(1).strip()}
)
text = text[: system_match.start()] + text[system_match.end() :]
text = text.strip()
# Split by [INST] / [/INST]
parts = re.split(r"\[/?INST\]", text)
for i, part in enumerate(parts):
part = part.strip()
if not part:
continue
if i % 2 == 1:
messages.append({"role": "user", "content": part})
elif i > 0:
messages.append({"role": "assistant", "content": part})
if not messages:
messages.append({"role": "user", "content": text})
return messages
@@ -0,0 +1,255 @@
import logging
from typing import Any, Dict, Optional
from sglang.srt.entrypoints.openai.transcription_adapters.whisper import (
FUSED_AUTODETECT_FLAG,
)
from sglang.srt.managers.schedule_batch import (
Modality,
MultimodalDataItem,
MultimodalProcessorOutput,
)
from sglang.srt.models.whisper import WhisperForConditionalGeneration
from sglang.srt.multimodal.processors.base_processor import BaseMultimodalProcessor
from sglang.srt.utils import load_audio
logger = logging.getLogger(__name__)
# ISO 639-1 supported languages for Whisper
# From https://platform.openai.com/docs/guides/speech-to-text/supported-languages
# Maps ISO 639-1 code -> Full language name
ISO639_1_SUPPORTED_LANGS = {
"af": "Afrikaans",
"ar": "Arabic",
"hy": "Armenian",
"az": "Azerbaijani",
"be": "Belarusian",
"bs": "Bosnian",
"bg": "Bulgarian",
"ca": "Catalan",
"zh": "Chinese",
"hr": "Croatian",
"cs": "Czech",
"da": "Danish",
"nl": "Dutch",
"en": "English",
"et": "Estonian",
"fi": "Finnish",
"fr": "French",
"gl": "Galician",
"de": "German",
"el": "Greek",
"he": "Hebrew",
"hi": "Hindi",
"hu": "Hungarian",
"is": "Icelandic",
"id": "Indonesian",
"it": "Italian",
"ja": "Japanese",
"kn": "Kannada",
"kk": "Kazakh",
"ko": "Korean",
"lv": "Latvian",
"lt": "Lithuanian",
"mk": "Macedonian",
"ms": "Malay",
"mr": "Marathi",
"mi": "Maori",
"ne": "Nepali",
"no": "Norwegian",
"fa": "Persian",
"pl": "Polish",
"pt": "Portuguese",
"ro": "Romanian",
"ru": "Russian",
"sr": "Serbian",
"sk": "Slovak",
"sl": "Slovenian",
"es": "Spanish",
"sw": "Swahili",
"sv": "Swedish",
"tl": "Tagalog",
"ta": "Tamil",
"th": "Thai",
"tr": "Turkish",
"uk": "Ukrainian",
"ur": "Urdu",
"vi": "Vietnamese",
"cy": "Welsh",
}
# Reverse mapping: Full language name (lowercase) -> ISO 639-1 code
LANG_NAME_TO_CODE = {
name.lower(): code for code, name in ISO639_1_SUPPORTED_LANGS.items()
}
def normalize_language_to_code(language: Optional[str]) -> Optional[str]:
"""Convert a language input (full name or code) to ISO 639-1 code.
Args:
language: Language as full name (e.g., 'English', 'Spanish') or
ISO 639-1 code (e.g., 'en', 'es'). Three-letter Whisper
codes the model supports but that aren't in
ISO639_1_SUPPORTED_LANGS (e.g., 'yue', 'haw', 'jw') are
also accepted so that a code returned by fused autodetect
round-trips cleanly when reused as ``language=`` later.
Returns:
Whisper language code or None if input is None
"""
if language is None:
return None
language_lower = language.lower().strip()
# Check if it's already a valid ISO code
if language_lower in ISO639_1_SUPPORTED_LANGS:
return language_lower
# Check if it's a full language name
if language_lower in LANG_NAME_TO_CODE:
return LANG_NAME_TO_CODE[language_lower]
# Fused autodetect's FSM regex covers the full Whisper language-token
# vocab (see WHISPER_LANG_TOKEN_CODES), which is wider than the
# English-name-keyed ISO639_1_SUPPORTED_LANGS dict. Accept any code in
# that wider set too so that detection -> reuse-as-input round-trips.
# Lazy import to avoid top-level cycle with the openai entrypoint.
from sglang.srt.entrypoints.openai.transcription_adapters.whisper import (
WHISPER_LANG_TOKEN_CODES,
)
if language_lower in WHISPER_LANG_TOKEN_CODES:
return language_lower
# Not recognized
raise ValueError(
f"Language '{language}' not recognized. "
f"Use full name (e.g., 'English') or ISO 639-1 code (e.g., 'en')."
)
class WhisperProcessor(BaseMultimodalProcessor):
models = [WhisperForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
# Cache tokenizer for language token lookup
self._tokenizer = getattr(self._processor, "tokenizer", None)
def _pop_sampling_param(self, request_obj, key: str):
sampling_params = getattr(request_obj, "sampling_params", None) or {}
return sampling_params.pop(key, None)
def _get_language_token_id(self, language: Optional[str]) -> int:
# Default to English if not specified
if language is None:
language = "en" # Default to English
language_token = f"<|{language}|>"
token_id = self._tokenizer.convert_tokens_to_ids(language_token)
# normalize_language_to_code accepts the full Whisper language-token
# vocab (including yue/haw/jw) so fused autodetect output round-trips.
# Older checkpoints (v1/v2) don't have every newer token in their
# vocab, in which case convert_tokens_to_ids returns the unk id.
# Raise a clean error here instead of silently feeding unk into the
# decoder and producing garbage.
unk_id = getattr(self._tokenizer, "unk_token_id", None)
if token_id is None or (unk_id is not None and token_id == unk_id):
raise ValueError(
f"Language '{language}' is not in this Whisper model's vocabulary. "
f"The '{language_token}' token may have been added in a later "
f"Whisper version than the loaded checkpoint."
)
return token_id
async def process_mm_data_async(
self,
image_data,
audio_data,
input_text,
request_obj,
**kwargs,
) -> Optional[Dict[str, Any]]:
if not audio_data:
return None
if len(audio_data) != 1:
raise ValueError(
f"Whisper expects exactly 1 audio input, got {len(audio_data)}"
)
# Check if this is a fused auto-detect request (decoder prompt = [SOT] only,
# structured generation handles the rest via regex constraint).
detect_language = self._pop_sampling_param(request_obj, FUSED_AUTODETECT_FLAG)
# timestamp_granularities is a transcription-level field; it must be
# popped in both branches or it leaks into SamplingParams(**kwargs)
# downstream and TypeErrors. In the fused branch the FSM regex was
# already picked in build_fused_autodetect_params based on this value,
# so we only need to keep it here to pick the timestamp_token_id for
# the explicit-language branch.
timestamp_granularities = self._pop_sampling_param(
request_obj, "timestamp_granularities"
)
audios = [load_audio(audio) for audio in audio_data]
# Whisper expects input features padded to max_length (3000 frames = 30 seconds)
# This is the standard context length for Whisper
input_features = self._processor.feature_extractor(
audios[0],
sampling_rate=16000,
padding="max_length", # Pad to 3000 frames
return_tensors="pt",
)["input_features"][0]
# Whisper is a pure speech-to-text model; text prompts are ignored.
# The full decoder sequence is:
# <|startoftranscript|> <|lang|> <|transcribe|> [<|notimestamps|> | <|0.00|>]
#
# When language is known, we build this prefix explicitly below.
# When auto-detecting (_detect_language=True), we feed only <|startoftranscript|>
# and let SGLang's structured generation (regex) constrain the model to produce
# <|lang|><|transcribe|><|notimestamps|> as the first 3 decode tokens — this is
# equivalent to HuggingFace's forced_decoder_ids but uses SGLang's native API.
decoder_start_token_id = getattr(
self.hf_config, "decoder_start_token_id", 50258
)
if detect_language:
input_ids = [decoder_start_token_id]
else:
language = normalize_language_to_code(
self._pop_sampling_param(request_obj, "language")
)
language_token_id = self._get_language_token_id(language)
transcribe_token_id = self._tokenizer.convert_tokens_to_ids(
"<|transcribe|>"
)
# Use <|0.00|> to enable timestamp generation, or <|notimestamps|> to disable
if timestamp_granularities:
timestamp_token_id = self._tokenizer.convert_tokens_to_ids("<|0.00|>")
else:
timestamp_token_id = self._tokenizer.convert_tokens_to_ids(
"<|notimestamps|>"
)
input_ids = [
decoder_start_token_id,
language_token_id,
transcribe_token_id,
timestamp_token_id,
]
return MultimodalProcessorOutput(
input_ids=input_ids,
mm_items=[
MultimodalDataItem(
feature=input_features,
modality=Modality.AUDIO,
)
],
)