import math import os import re import time from typing import List, 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.models.qwen2_5_vl import Qwen2_5_VLForConditionalGeneration from sglang.srt.models.qwen2_vl import Qwen2VLForConditionalGeneration 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.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 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: entry_time = time.perf_counter() total_frames, video_fps = len(vr), vr.get_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_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_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): models = [ Qwen2VLForConditionalGeneration, Qwen2_5_VLForConditionalGeneration, Qwen3VLForConditionalGeneration, Qwen3VLMoeForConditionalGeneration, 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.vision_start_token_id = hf_config.vision_start_token_id self.vision_end_token_id = hf_config.vision_end_token_id 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.image_config = server_args.mm_process_config.get("image", {}) self.video_config = server_args.mm_process_config.get("video", {}) 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=hf_config.video_token_id, audio_token_id=self.audio_token_id, ).build(_processor) 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 = 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() # 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"): 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 = getattr(ret, "second_per_grid_ts", None) or getattr( ret, "video_second_per_grid", None ) process_time = time.perf_counter() input_ids = input_ids.flatten() mrope_positions, mrope_position_delta = MRotaryEmbedding.get_rope_index( spatial_merge_size=self.hf_config.vision_config.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=getattr( self.hf_config.vision_config, "tokens_per_second", None ), input_ids=input_ids.unsqueeze(0), image_grid_thw=getattr(ret, "image_grid_thw", None), video_grid_thw=getattr(ret, "video_grid_thw", None), 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_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 { "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.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, }