Files
vllm-project--vllm/vllm/transformers_utils/processors/minimax_m3.py
T
wehub-resource-sync 7ce4c8e27e
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:55:37 +08:00

737 lines
28 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""MiniMax M3 VL HuggingFace-compatible Processor / ImageProcessor /
VideoProcessor, vendored into vLLM so the model loads without
``--trust-remote-code`` (the released checkpoint only ships these classes as
remote code via ``auto_map``).
Adapted verbatim from the ``MiniMaxAI/Minimax-M3-preview`` repository files
``image_processor.py``, ``video_processor.py`` and ``processing_minimax.py``
(revision ``db01c0fe``). Both image and video processors use Qwen-style
``smart_resize`` (bound by total pixels). The original async frame-sampling
helpers are intentionally omitted: vLLM performs its own frame loading and
feeds decoded frames to the processor.
"""
import math
import regex as re
import torch
from torchvision.transforms import InterpolationMode
from transformers import AutoTokenizer, BatchFeature
from transformers.image_processing_utils_fast import (
BaseImageProcessorFast,
group_images_by_shape,
reorder_images,
)
from transformers.image_utils import PILImageResampling, SizeDict
from transformers.processing_utils import (
ImagesKwargs,
ProcessingKwargs,
ProcessorMixin,
Unpack,
VideosKwargs,
)
from transformers.utils import TensorType
from transformers.video_processing_utils import BaseVideoProcessor
from transformers.video_utils import group_videos_by_shape, reorder_videos
# Maximum allowed aspect ratio before smart_resize rejects the input.
MAX_RATIO = 200
# Fixed (non-configurable) bounds for the long-side resize logic, per the
# MiniMax-M3 size spec. ``min_short_side_pixel`` is the floor the short edge is
# enlarged to; ``*_MAX_TOTAL_PIXELS`` is the hard area cap that, once exceeded,
# aborts processing instead of downscaling.
MIN_SHORT_SIDE_PIXEL = 112
IMAGE_MAX_TOTAL_PIXELS = 12_845_056 # 3584 ** 2 (width * height)
VIDEO_MAX_TOTAL_PIXELS = 301_056_000 # width * height * frames
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
def _smart_resize_by_long_side(
height: int,
width: int,
factor: int,
max_long_side_pixel: int,
min_short_side_pixel: int,
max_total_pixels: int | None,
) -> tuple[int, int]:
"""Long-side based resize (MiniMax-M3 size spec).
(a) if the long side exceeds ``max_long_side_pixel`` → shrink so the long
side equals ``max_long_side_pixel``;
(b) else if the short side is below ``min_short_side_pixel`` → enlarge so the
short side equals ``min_short_side_pixel``;
(c) if the resulting area still exceeds ``max_total_pixels`` → raise.
(a) and (b) are mutually exclusive (they branch on the *original* long side).
Both sides are then rounded to a multiple of ``factor``. For videos the
``max_total_pixels`` cap is volumetric (width * height * frames) and is
enforced by the caller, so pass ``max_total_pixels=None`` here.
"""
long_side = max(height, width)
short_side = min(height, width)
scaled_height: float = height
scaled_width: float = width
if long_side > max_long_side_pixel:
beta = max_long_side_pixel / long_side
scaled_height = height * beta
scaled_width = width * beta
elif short_side < min_short_side_pixel:
beta = min_short_side_pixel / short_side
scaled_height = height * beta
scaled_width = width * beta
h_bar = max(factor, round_by_factor(scaled_height, factor))
w_bar = max(factor, round_by_factor(scaled_width, factor))
if max_total_pixels is not None and h_bar * w_bar > max_total_pixels:
raise ValueError(
f"image area {h_bar * w_bar} exceeds max_total_pixels "
f"{max_total_pixels} after resizing"
)
return h_bar, w_bar
def smart_resize(
height: int,
width: int,
factor: int = 28,
min_pixels: int = 4 * 28 * 28,
max_pixels: int = 451584,
max_long_side_pixel: int | None = None,
min_short_side_pixel: int = MIN_SHORT_SIDE_PIXEL,
max_total_pixels: int | None = None,
) -> tuple[int, int]:
"""Rescale (height, width) so each side is a multiple of ``factor``.
When ``max_long_side_pixel`` is set, use the MiniMax-M3 long-side resize
spec (see :func:`_smart_resize_by_long_side`). Otherwise fall back to the
Qwen-VL area bound, keeping the total area within ``[min_pixels, max_pixels]``.
"""
if max(height, width) / min(height, width) > MAX_RATIO:
raise ValueError(
f"absolute aspect ratio must be smaller than {MAX_RATIO}, "
f"got {max(height, width) / min(height, width)}"
)
if max_long_side_pixel is not None:
return _smart_resize_by_long_side(
height,
width,
factor=factor,
max_long_side_pixel=max_long_side_pixel,
min_short_side_pixel=min_short_side_pixel,
max_total_pixels=max_total_pixels,
)
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
class MiniMaxM3VLImageProcessorKwargs(ImagesKwargs, total=False): # type: ignore[call-arg]
patch_size: int
temporal_patch_size: int
merge_size: int
max_pixels: int
max_long_side_pixel: int
class MiniMaxM3VLImageProcessor(BaseImageProcessorFast):
do_resize = True
resample = PILImageResampling.BICUBIC
# required by base-class validation, not used as the resize bound
size = {"height": 672, "width": 672}
default_to_square = False
do_rescale = True
rescale_factor = 1 / 255
do_normalize = True
image_mean = [0.48145466, 0.4578275, 0.40821073]
image_std = [0.26862954, 0.26130258, 0.27577711]
do_convert_rgb = True
patch_size = 14
temporal_patch_size = 2
merge_size = 2
max_pixels = 451584 # 672 * 672
# Long-side resize spec (opt-in via ``max_long_side_pixel``). The latter two
# are fixed per the spec and are not exposed as configurable kwargs.
max_long_side_pixel = None
min_short_side_pixel = MIN_SHORT_SIDE_PIXEL
max_total_pixels = IMAGE_MAX_TOTAL_PIXELS
valid_kwargs = MiniMaxM3VLImageProcessorKwargs
model_input_names = ["pixel_values", "image_grid_thw"]
def __init__(self, **kwargs: Unpack[MiniMaxM3VLImageProcessorKwargs]):
super().__init__(**kwargs)
def preprocess(
self, images, **kwargs: Unpack[MiniMaxM3VLImageProcessorKwargs]
) -> BatchFeature:
return super().preprocess(images, **kwargs)
def _preprocess(
self,
images: list[torch.Tensor],
do_resize: bool,
size: SizeDict,
resample: "PILImageResampling | InterpolationMode | int | None",
do_rescale: bool,
rescale_factor: float,
do_normalize: bool,
image_mean: "float | list[float] | None",
image_std: "float | list[float] | None",
patch_size: int,
temporal_patch_size: int,
merge_size: int,
max_pixels: int,
max_long_side_pixel: "int | None",
disable_grouping: "bool | None",
return_tensors: "str | TensorType | None",
**kwargs,
) -> BatchFeature:
grouped_images, grouped_images_index = group_images_by_shape(
images, disable_grouping=disable_grouping
)
resized_images_grouped = {}
factor = patch_size * merge_size
for shape, stacked_images in grouped_images.items():
height, width = stacked_images.shape[-2:]
if do_resize:
resized_height, resized_width = smart_resize(
height,
width,
factor=factor,
max_pixels=max_pixels,
max_long_side_pixel=max_long_side_pixel,
min_short_side_pixel=self.min_short_side_pixel,
max_total_pixels=self.max_total_pixels,
)
stacked_images = self.resize(
stacked_images,
size=SizeDict(height=resized_height, width=resized_width),
resample=resample,
)
resized_images_grouped[shape] = stacked_images
resized_images = reorder_images(resized_images_grouped, grouped_images_index)
grouped_images, grouped_images_index = group_images_by_shape(
resized_images, disable_grouping=disable_grouping
)
processed_images_grouped = {}
processed_grids = {}
for shape, stacked_images in grouped_images.items():
resized_height, resized_width = stacked_images.shape[-2:]
patches = self.rescale_and_normalize(
stacked_images,
do_rescale,
rescale_factor,
do_normalize,
image_mean,
image_std,
)
if patches.ndim == 4:
patches = patches.unsqueeze(1)
if patches.shape[1] % temporal_patch_size != 0:
repeats = patches[:, -1:].repeat(
1,
temporal_patch_size - (patches.shape[1] % temporal_patch_size),
1,
1,
1,
)
patches = torch.cat([patches, repeats], dim=1)
batch_size, grid_t, channel = patches.shape[:3]
grid_t = grid_t // temporal_patch_size
grid_h, grid_w = resized_height // patch_size, resized_width // patch_size
patches = patches.view(
batch_size,
grid_t,
temporal_patch_size,
channel,
grid_h // merge_size,
merge_size,
patch_size,
grid_w // merge_size,
merge_size,
patch_size,
)
patches = patches.permute(0, 1, 4, 7, 5, 8, 3, 2, 6, 9)
flatten_patches = patches.reshape(
batch_size,
grid_t * grid_h * grid_w,
channel * temporal_patch_size * patch_size * patch_size,
)
processed_images_grouped[shape] = flatten_patches
processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size
processed_images = reorder_images(
processed_images_grouped, grouped_images_index
)
processed_grids = reorder_images(processed_grids, grouped_images_index)
pixel_values = torch.cat(processed_images, dim=0)
image_grid_thw = torch.tensor(processed_grids, dtype=torch.long)
return BatchFeature(
data={"pixel_values": pixel_values, "image_grid_thw": image_grid_thw},
tensor_type=return_tensors,
)
def get_number_of_image_patches(self, height: int, width: int, images_kwargs=None):
images_kwargs = images_kwargs or {}
patch_size = images_kwargs.get("patch_size", self.patch_size)
merge_size = images_kwargs.get("merge_size", self.merge_size)
max_pixels = images_kwargs.get("max_pixels", self.max_pixels)
max_long_side_pixel = images_kwargs.get(
"max_long_side_pixel", self.max_long_side_pixel
)
resized_height, resized_width = smart_resize(
height,
width,
factor=patch_size * merge_size,
max_pixels=max_pixels,
max_long_side_pixel=max_long_side_pixel,
min_short_side_pixel=self.min_short_side_pixel,
max_total_pixels=self.max_total_pixels,
)
grid_h, grid_w = resized_height // patch_size, resized_width // patch_size
return grid_h * grid_w
class MiniMaxM3VLVideoProcessorKwargs(VideosKwargs, total=False): # type: ignore[call-arg]
patch_size: int
temporal_patch_size: int
merge_size: int
min_pixels: int
max_pixels: int
max_long_side_pixel: int
total_pixels: int
min_frames: int
max_frames: int
fps: "float | int"
class MiniMaxM3VLVideoProcessor(BaseVideoProcessor):
do_resize = True
resample = PILImageResampling.BICUBIC
size = {"height": 672, "width": 672}
default_to_square = False
do_rescale = True
rescale_factor = 1 / 255
do_normalize = True
image_mean = [0.48145466, 0.4578275, 0.40821073]
image_std = [0.26862954, 0.26130258, 0.27577711]
do_convert_rgb = True
do_sample_frames = False
patch_size = 14
temporal_patch_size = 2
merge_size = 2
min_pixels = 4 * 28 * 28
max_pixels = 768 * 28 * 28 # 602,112
total_pixels = int(64000 * 28 * 28 * 0.9) # ~45M, ~64k tokens budget
# Long-side resize spec (opt-in via ``max_long_side_pixel``). The video
# ``max_total_pixels`` cap is volumetric (width * height * frames) and is
# enforced in ``_preprocess`` once the frame count is known.
max_long_side_pixel = None
min_short_side_pixel = MIN_SHORT_SIDE_PIXEL
max_total_pixels = VIDEO_MAX_TOTAL_PIXELS
fps = 1.0
min_frames = 4
max_frames = 768
valid_kwargs = MiniMaxM3VLVideoProcessorKwargs
model_input_names = ["pixel_values_videos", "video_grid_thw"]
def __init__(self, **kwargs: Unpack[MiniMaxM3VLVideoProcessorKwargs]):
super().__init__(**kwargs)
def _preprocess(
self,
videos: list[torch.Tensor],
do_convert_rgb: bool,
do_resize: bool,
size: SizeDict,
resample: "PILImageResampling | InterpolationMode | int | None",
do_rescale: bool,
rescale_factor: float,
do_normalize: bool,
image_mean: "float | list[float] | None",
image_std: "float | list[float] | None",
patch_size: int,
temporal_patch_size: int,
merge_size: int,
min_pixels: int,
max_pixels: int,
max_long_side_pixel: "int | None" = None,
return_tensors: "str | TensorType | None" = None,
**kwargs,
) -> BatchFeature:
grouped_videos, grouped_videos_index = group_videos_by_shape(videos)
resized_videos_grouped = {}
factor = patch_size * merge_size
for shape, stacked_videos in grouped_videos.items():
batch_size, num_frames, channels, height, width = stacked_videos.shape
resized_height, resized_width = height, width
if do_resize:
resized_height, resized_width = smart_resize(
height,
width,
factor=factor,
min_pixels=min_pixels,
max_pixels=max_pixels,
max_long_side_pixel=max_long_side_pixel,
min_short_side_pixel=self.min_short_side_pixel,
# Per-frame raise disabled; the video cap is volumetric and
# is enforced below once num_frames is known.
max_total_pixels=None,
)
if (
max_long_side_pixel is not None
and resized_height * resized_width * num_frames
> self.max_total_pixels
):
raise ValueError(
f"video area {resized_height * resized_width * num_frames} "
f"(width * height * frames) exceeds max_total_pixels "
f"{self.max_total_pixels} after resizing"
)
stacked_videos = stacked_videos.view(
batch_size * num_frames, channels, height, width
)
stacked_videos = self.resize(
stacked_videos,
size=SizeDict(height=resized_height, width=resized_width),
resample=resample,
)
stacked_videos = stacked_videos.view(
batch_size,
num_frames,
channels,
resized_height,
resized_width,
)
resized_videos_grouped[shape] = stacked_videos
resized_videos = reorder_videos(resized_videos_grouped, grouped_videos_index)
grouped_videos, grouped_videos_index = group_videos_by_shape(resized_videos)
processed_videos_grouped = {}
processed_grids = {}
for shape, stacked_videos in grouped_videos.items():
resized_height, resized_width = stacked_videos.shape[-2:]
patches = self.rescale_and_normalize(
stacked_videos,
do_rescale,
rescale_factor,
do_normalize,
image_mean,
image_std,
)
if pad := -patches.shape[1] % temporal_patch_size:
repeats = patches[:, -1:].expand(-1, pad, -1, -1, -1)
patches = torch.cat([patches, repeats], dim=1)
batch_size, grid_t, channels = patches.shape[:3]
grid_t = grid_t // temporal_patch_size
grid_h, grid_w = resized_height // patch_size, resized_width // patch_size
patches = patches.view(
batch_size,
grid_t,
temporal_patch_size,
channels,
grid_h // merge_size,
merge_size,
patch_size,
grid_w // merge_size,
merge_size,
patch_size,
)
patches = patches.permute(0, 1, 4, 7, 5, 8, 3, 2, 6, 9)
flatten_patches = patches.reshape(
batch_size,
grid_t * grid_h * grid_w,
channels * temporal_patch_size * patch_size * patch_size,
)
processed_videos_grouped[shape] = flatten_patches
processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size
processed_videos = reorder_videos(
processed_videos_grouped, grouped_videos_index
)
processed_grids = reorder_videos(processed_grids, grouped_videos_index)
pixel_values_videos = torch.cat(processed_videos, dim=0)
video_grid_thw = torch.tensor(processed_grids, dtype=torch.long)
return BatchFeature(
data={
"pixel_values_videos": pixel_values_videos,
"video_grid_thw": video_grid_thw,
},
tensor_type=return_tensors,
)
class MiniMaxVLProcessorKwargs(ProcessingKwargs, total=False): # type: ignore[call-arg]
_defaults = {
"videos_kwargs": {
"do_resize": False,
"return_metadata": True,
},
}
class MiniMaxVLProcessor(ProcessorMixin):
IMAGE_TOKEN = "]<]image[>["
VIDEO_TOKEN = "]<]video[>["
VISION_START_TOKEN = "]<]start of image[>["
VISION_END_TOKEN = "]<]end of image[>["
@classmethod
def from_pretrained(cls, pretrained_model_name_or_path, **kwargs):
# Bypass ProcessorMixin's dynamic module lookup, which breaks in
# transformers >= 5.9 when image_processor_class is a string: the
# register() API now stores classes as {"pil": cls} dicts in
# _extra_content, but get_possibly_dynamic_module() still calls
# .__name__ on the raw value, crashing with AttributeError on dicts.
tokenizer = AutoTokenizer.from_pretrained(
pretrained_model_name_or_path, **kwargs
)
image_processor = MiniMaxM3VLImageProcessor.from_pretrained(
pretrained_model_name_or_path, **kwargs
)
video_processor = MiniMaxM3VLVideoProcessor.from_pretrained(
pretrained_model_name_or_path, **kwargs
)
return cls(
image_processor=image_processor,
tokenizer=tokenizer,
video_processor=video_processor,
)
def __init__(
self, image_processor=None, tokenizer=None, video_processor=None, **kwargs
):
self.image_token_id = tokenizer.convert_tokens_to_ids(self.IMAGE_TOKEN)
self.video_token_id = tokenizer.convert_tokens_to_ids(self.VIDEO_TOKEN)
super().__init__(image_processor, tokenizer, video_processor)
# Video expansion also uses image start/end tokens. Separate video
# start/end tokens exist in the tokenizer, but the original MiniMax
# serving path did not use them; keep that behavior for compatibility.
self.vision_start_token_id = tokenizer.convert_tokens_to_ids(
self.VISION_START_TOKEN
)
self.vision_end_token_id = tokenizer.convert_tokens_to_ids(
self.VISION_END_TOKEN
)
def _prune_video_tokens(
self,
input_text: str,
video_segments: list[int],
video_token: str,
) -> str:
"""Prune video tokens by temporal_patch_size (e.g., 2:1).
Expects the prompt to carry exactly sum(video_segments) video tokens
— i.e. one token per *sampled* frame — then drops tokens.
"""
# If no videos or temporal_patch_size <= 1, no pruning needed
if not video_segments or self.video_processor.temporal_patch_size <= 1:
return input_text
# Split while keeping delimiters
special_tokens = [video_token]
pattern = "|".join(map(re.escape, special_tokens))
parts = re.split(f"({pattern})", input_text)
def is_timestamp(text: str) -> bool:
"""Check if text ends with timestamp format like ']<]0.0 seconds[>['"""
return (
text.endswith("seconds[>[")
or text.endswith("seconds[>[ ")
or text.endswith("seconds [>[")
or text.endswith("seconds [>[ ")
)
def extract_timestamp(text: str) -> str:
"""Extract timestamp text from the end, starting from ']<]'"""
start_index = text.rfind("]<]")
if start_index == -1:
raise ValueError(f"Failed to extract timestamp: {text}")
return text[start_index:]
# Build new text with pruned video tokens
final_parts = []
current_seg_idx = 0 # Which video segment we're in
frame_in_seg = 0 # Frame index within current segment
last_timestamp_len = 0 # Length of timestamp to potentially remove
for part in parts:
if part == video_token:
if current_seg_idx < len(video_segments):
if frame_in_seg % self.video_processor.temporal_patch_size == 0:
# Keep this video token
final_parts.append(part)
frame_in_seg += 1
if frame_in_seg >= video_segments[current_seg_idx]:
current_seg_idx += 1
frame_in_seg = 0
last_timestamp_len = 0
else:
# Skip this video token
frame_in_seg += 1
if frame_in_seg >= video_segments[current_seg_idx]:
current_seg_idx += 1
frame_in_seg = 0
# Remove the timestamp that was already appended
if last_timestamp_len > 0:
assert len(final_parts) > 0
final_parts[-1] = final_parts[-1][:-last_timestamp_len]
last_timestamp_len = 0
else:
# No more video segments, keep as is
final_parts.append(part)
last_timestamp_len = 0
else:
# Text part
final_parts.append(part)
# Check if this text ends with a timestamp
if is_timestamp(part):
last_timestamp_len = len(extract_timestamp(part))
else:
last_timestamp_len = 0
return "".join(final_parts)
def __call__(
self,
images=None,
text=None,
videos=None,
**kwargs: Unpack[MiniMaxVLProcessorKwargs],
) -> BatchFeature:
output_kwargs = self._merge_kwargs(
MiniMaxVLProcessorKwargs,
tokenizer_init_kwargs=self.tokenizer.init_kwargs,
**kwargs,
)
if images is not None:
images_kwargs = output_kwargs["images_kwargs"]
image_inputs = self.image_processor(images=images, **images_kwargs)
image_grid_thw = image_inputs["image_grid_thw"]
else:
image_inputs = {}
image_grid_thw = None
if videos is not None:
videos_kwargs = output_kwargs["videos_kwargs"]
video_inputs = self.video_processor(videos=videos, **videos_kwargs)
video_grid_thw = video_inputs["video_grid_thw"]
if not kwargs.get("return_metadata"):
video_metadata = video_inputs.pop("video_metadata")
else:
video_metadata = video_inputs["video_metadata"]
else:
video_inputs = {}
video_grid_thw = None
if not isinstance(text, list):
text = [text]
text = text.copy()
# Expand image tokens
if image_grid_thw is not None:
merge_length = self.image_processor.merge_size**2
placeholder = "]<]placeholder[>["
index = 0
for i in range(len(text)):
while self.IMAGE_TOKEN in text[i]:
num_tokens = image_grid_thw[index].prod() // merge_length
text[i] = text[i].replace(
self.IMAGE_TOKEN,
self.VISION_START_TOKEN
+ placeholder * num_tokens
+ self.VISION_END_TOKEN,
1,
)
index += 1
text[i] = text[i].replace(placeholder, self.IMAGE_TOKEN)
# Expand video tokens
if video_grid_thw is not None:
merge_length = self.image_processor.merge_size**2
placeholder = "]<]placeholder[>["
index = 0
for i in range(len(text)):
while self.VIDEO_TOKEN in text[i]:
metadata = video_metadata[index]
grid_t = video_grid_thw[index][0]
frame_seqlen = video_grid_thw[index][1:].prod() // merge_length
video_placeholder = ""
for frame_idx in range(grid_t):
if (
metadata.fps is not None
and metadata.frames_indices is not None
):
ts = (
metadata.frames_indices[
min(
frame_idx
* self.video_processor.temporal_patch_size,
len(metadata.frames_indices) - 1,
)
]
/ metadata.fps
)
video_placeholder += f"]<]{ts:.1f} seconds[>["
video_placeholder += (
self.VISION_START_TOKEN
+ placeholder * frame_seqlen
+ self.VISION_END_TOKEN
)
text[i] = text[i].replace(self.VIDEO_TOKEN, video_placeholder, 1)
index += 1
text[i] = text[i].replace(placeholder, self.VIDEO_TOKEN)
# Tokenize
return_tensors = output_kwargs["text_kwargs"].pop("return_tensors", None)
text_inputs = self.tokenizer(text, **output_kwargs["text_kwargs"])
return BatchFeature(
data={**text_inputs, **image_inputs, **video_inputs},
tensor_type=return_tensors,
)