| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | # mypy: ignore-errors |
| 4 | """MiMo-Omni multimodal processor for vLLM. |
| 5 | |
| 6 | Ported from SGLang's MiMoV2OmniProcessor / MiMoVLProcessor implementations. |
| 7 | """ |
| 8 | |
| 9 | import contextlib |
| 10 | import logging |
| 11 | import math |
| 12 | from collections import OrderedDict |
| 13 | from concurrent.futures import ThreadPoolExecutor, as_completed |
| 14 | from dataclasses import dataclass, field |
| 15 | from typing import Any, Literal |
| 16 | |
| 17 | import numpy as np |
| 18 | import regex as re |
| 19 | import torch |
| 20 | import torch.nn.functional as F |
| 21 | from PIL import Image |
| 22 | from transformers import BatchFeature, TensorType |
| 23 | from transformers.processing_utils import ProcessorMixin |
| 24 | |
| 25 | try: |
| 26 | import torchaudio |
| 27 | from torchaudio.transforms import MelSpectrogram as _MelSpectrogram |
| 28 | |
| 29 | _HAS_TORCHAUDIO = True |
| 30 | except ImportError: |
| 31 | torchaudio = None # type: ignore[assignment] |
| 32 | _MelSpectrogram = None # type: ignore[assignment,misc] |
| 33 | _HAS_TORCHAUDIO = False |
| 34 | |
| 35 | logger = logging.getLogger(__name__) |
| 36 | |
| 37 | # --------------------------------------------------------------------------- |
| 38 | # Constants |
| 39 | # --------------------------------------------------------------------------- |
| 40 | |
| 41 | _PIXEL_MEAN = [123.675, 116.28, 103.53] |
| 42 | _PIXEL_STD = [58.395, 57.12, 57.375] |
| 43 | _mean_std_cache: dict[str, tuple[torch.Tensor, torch.Tensor]] = {} |
| 44 | |
| 45 | |
| 46 | # --------------------------------------------------------------------------- |
| 47 | # Data classes |
| 48 | # --------------------------------------------------------------------------- |
| 49 | |
| 50 | |
| 51 | @dataclass |
| 52 | class ImageInput: |
| 53 | # PIL.Image | torch.Tensor (C,H,W) |
| 54 | image: Any |
| 55 | max_pixels: int | None = None |
| 56 | min_pixels: int | None = None |
| 57 | |
| 58 | |
| 59 | @dataclass |
| 60 | class VideoInput: |
| 61 | # tuple[frames_TCHW: torch.Tensor, timestamps_T: torch.Tensor] |
| 62 | video: Any |
| 63 | min_pixels: int | None = None |
| 64 | max_pixels: int | None = None |
| 65 | total_max_pixels: int | None = None |
| 66 | fps: float | None = None |
| 67 | num_frames: int | None = None |
| 68 | max_frames: int | None = None |
| 69 | min_frames: int | None = None |
| 70 | do_include_last_frame: bool | None = False |
| 71 | start_time: float | None = None |
| 72 | end_time: float | None = None |
| 73 | segment_type: Literal["individual", "partial"] = "individual" |
| 74 | |
| 75 | |
| 76 | @dataclass |
| 77 | class AudioInput: |
| 78 | # tuple[waveform_1D, sr] |
| 79 | # | np.ndarray | torch.Tensor (T,n_vq) |
| 80 | audio: Any |
| 81 | |
| 82 | |
| 83 | @dataclass |
| 84 | class VideoAudioInput: |
| 85 | video: Any # same as VideoInput.video |
| 86 | audio: Any # same as AudioInput.audio |
| 87 | min_pixels: int | None = None |
| 88 | max_pixels: int | None = None |
| 89 | total_max_pixels: int | None = None |
| 90 | fps: float | None = None |
| 91 | num_frames: int | None = None |
| 92 | max_frames: int | None = None |
| 93 | min_frames: int | None = None |
| 94 | do_include_last_frame: bool | None = False |
| 95 | start_time: float | None = None |
| 96 | end_time: float | None = None |
| 97 | segment_type: Literal["individual", "partial"] = "individual" |
| 98 | |
| 99 | |
| 100 | @dataclass |
| 101 | class Content: |
| 102 | type: Literal["text", "image", "video", "audio", "video_audio"] |
| 103 | content: Any |
| 104 | is_target: bool | None = None |
| 105 | |
| 106 | |
| 107 | @dataclass |
| 108 | class MiMoVLInputSample: |
| 109 | input_ids: torch.Tensor |
| 110 | labels: torch.Tensor | None |
| 111 | pixel_values: list[torch.Tensor] |
| 112 | pixel_values_videos: list[torch.Tensor] |
| 113 | image_thw_grids: list[torch.Tensor] |
| 114 | video_thw_grids: list[torch.Tensor] |
| 115 | audio_inputs: list[torch.Tensor] |
| 116 | second_per_grid_ts: list[float] = field(default_factory=list) |
| 117 | video_start_times: list[float] = field(default_factory=list) |
| 118 | audio_token_lens: list[int] = field(default_factory=list) |
| 119 | va_audio_inputs: list[torch.Tensor] = field(default_factory=list) |
| 120 | video_audio_n_segs: list[int] = field(default_factory=list) |
| 121 | video_audio_seg_lens: list[int] = field(default_factory=list) |
| 122 | position_ids: torch.Tensor | None = None |
| 123 | rope_deltas: torch.Tensor | None = None |
| 124 | extra: dict = field(default_factory=dict) |
| 125 | |
| 126 | |
| 127 | # --------------------------------------------------------------------------- |
| 128 | # Vision utilities |
| 129 | # --------------------------------------------------------------------------- |
| 130 | |
| 131 | |
| 132 | def _format_timestamp(ts: float) -> str: |
| 133 | return f"{int(ts // 60):02d}:{int(ts % 60):02d}" |
| 134 | |
| 135 | |
| 136 | def _smart_resize( |
| 137 | h: int, w: int, factor: int, min_px: int, max_px: int |
| 138 | ) -> tuple[int, int]: |
| 139 | if min(h, w) < factor: |
| 140 | if h < w: |
| 141 | h, w = factor, int(w * factor / h) |
| 142 | else: |
| 143 | w, h = factor, int(h * factor / w) |
| 144 | elif max(h, w) / min(h, w) > 200: |
| 145 | raise ValueError(f"Aspect ratio > 200 not allowed: {h}x{w}") |
| 146 | h_bar = round(h / factor) * factor |
| 147 | w_bar = round(w / factor) * factor |
| 148 | if h_bar * w_bar > max_px: |
| 149 | beta = math.sqrt((h * w) / max_px) |
| 150 | h_bar = math.floor(h / beta / factor) * factor |
| 151 | w_bar = math.floor(w / beta / factor) * factor |
| 152 | elif h_bar * w_bar < min_px: |
| 153 | beta = math.sqrt(min_px / (h * w)) |
| 154 | h_bar = math.ceil(h * beta / factor) * factor |
| 155 | w_bar = math.ceil(w * beta / factor) * factor |
| 156 | return int(h_bar), int(w_bar) |
| 157 | |
| 158 | |
| 159 | def _standardize(images: torch.Tensor) -> torch.Tensor: |
| 160 | key = str(images.device) |
| 161 | if key not in _mean_std_cache: |
| 162 | mean = torch.tensor(_PIXEL_MEAN, device=images.device).view(1, -1, 1, 1) |
| 163 | std = torch.tensor(_PIXEL_STD, device=images.device).view(1, -1, 1, 1) |
| 164 | _mean_std_cache[key] = (mean, std) |
| 165 | mean, std = _mean_std_cache[key] |
| 166 | return (images - mean) / std |
| 167 | |
| 168 | |
| 169 | def _transform_batch( |
| 170 | frames: torch.Tensor, |
| 171 | factor: int, |
| 172 | min_px: int, |
| 173 | max_px: int, |
| 174 | device: torch.device | None = None, |
| 175 | ) -> tuple[torch.Tensor, int, int]: |
| 176 | if device is not None: |
| 177 | frames = frames.to(device) |
| 178 | _, _, h, w = frames.shape |
| 179 | h_bar, w_bar = _smart_resize(h, w, factor, min_px, max_px) |
| 180 | resized = F.interpolate( |
| 181 | frames.float(), (h_bar, w_bar), mode="bilinear", align_corners=False |
| 182 | ) |
| 183 | return _standardize(resized), w_bar, h_bar |
| 184 | |
| 185 | |
| 186 | def _transform_single( |
| 187 | img: Any, |
| 188 | factor: int, |
| 189 | min_px: int, |
| 190 | max_px: int, |
| 191 | device: torch.device | None = None, |
| 192 | ) -> tuple[torch.Tensor, int, int]: |
| 193 | if isinstance(img, torch.Tensor): |
| 194 | t = img.float() |
| 195 | _, h, w = t.shape |
| 196 | elif isinstance(img, Image.Image): |
| 197 | img = img.convert("RGB") |
| 198 | w, h = img.size |
| 199 | t = torch.from_numpy(np.array(img)).permute(2, 0, 1).float() |
| 200 | else: |
| 201 | raise TypeError(f"Expected Tensor or PIL.Image, got {type(img)}") |
| 202 | if device is not None: |
| 203 | t = t.to(device) |
| 204 | h_bar, w_bar = _smart_resize(h, w, factor, min_px, max_px) |
| 205 | out = F.interpolate( |
| 206 | t.unsqueeze(0), (h_bar, w_bar), mode="bilinear", align_corners=False |
| 207 | ) |
| 208 | return _standardize(out).squeeze(0), w_bar, h_bar |
| 209 | |
| 210 | |
| 211 | # --------------------------------------------------------------------------- |
| 212 | # Core processor |
| 213 | # --------------------------------------------------------------------------- |
| 214 | |
| 215 | |
| 216 | class MiMoVLProcessor: |
| 217 | """Core MiMo-VL multimodal processor. |
| 218 | |
| 219 | Handles image/video/audio preprocessing and token sequence construction. |
| 220 | Ported from SGLang's MiMoVLProcessor. |
| 221 | |
| 222 | Media strings (URL / path / data:) are resolved upstream in vLLM's media |
| 223 | pipeline; this processor accepts only already-decoded inputs (``PIL.Image`` |
| 224 | for images, ``(waveform, sr)`` tuples for audio). |
| 225 | """ |
| 226 | |
| 227 | def __init__( |
| 228 | self, |
| 229 | tokenizer: Any, |
| 230 | patch_size: int = 14, |
| 231 | merge_size: int = 2, |
| 232 | temporal_patch_size: int = 2, |
| 233 | temporal_compression_ratio: int = 1, |
| 234 | use_video_timestamps: bool = True, |
| 235 | video_audio_interleave_length: int = 0, |
| 236 | audio_kernel_size: int = 3, |
| 237 | audio_stride_size: int = 2, |
| 238 | audio_avg_pooler: int = 2, |
| 239 | audio_sampling_rate: int = 24000, |
| 240 | audio_nfft: int = 960, |
| 241 | audio_hop_length: int = 240, |
| 242 | audio_window_size: int = 960, |
| 243 | audio_fmin: float = 0.0, |
| 244 | audio_fmax: float | None = None, |
| 245 | audio_n_mels: int = 128, |
| 246 | audio_segment_size: int = 6000, |
| 247 | audio_channels: int = 8, |
| 248 | audio_group_size: int = 4, |
| 249 | audio_input_id_per_second: float = 25.0, |
| 250 | audio_zeroemb_idx: int = 4096, |
| 251 | image_min_pixels: int | None = None, |
| 252 | image_max_pixels: int | None = None, |
| 253 | video_min_pixels: int | None = None, |
| 254 | video_max_pixels: int | None = None, |
| 255 | video_total_max_pixels: int | None = None, |
| 256 | fps: float | None = None, |
| 257 | num_frames: int | None = None, |
| 258 | max_frames: int | None = None, |
| 259 | min_frames: int | None = None, |
| 260 | image_token_id: int | None = None, |
| 261 | video_token_id: int | None = None, |
| 262 | audio_token_id: int | None = None, |
| 263 | vision_start_token_id: int | None = None, |
| 264 | vision_end_token_id: int | None = None, |
| 265 | audio_start_token_id: int | None = None, |
| 266 | audio_end_token_id: int | None = None, |
| 267 | video_start_token_id: int | None = None, |
| 268 | video_end_token_id: int | None = None, |
| 269 | pad_token_id: int | None = None, |
| 270 | rope_type: str = "rope", |
| 271 | video_process_num_threads: int = 16, |
| 272 | device: Any | None = None, |
| 273 | **kwargs: Any, |
| 274 | ) -> None: |
| 275 | self.tokenizer = tokenizer |
| 276 | self.video_process_num_threads = video_process_num_threads |
| 277 | self.device = torch.device(device) if isinstance(device, str) else device |
| 278 | |
| 279 | self.rope_type = "rope" if rope_type == "1d" else rope_type |
| 280 | assert self.rope_type in ("rope", "mrope"), ( |
| 281 | f"Unknown rope_type: {self.rope_type}" |
| 282 | ) |
| 283 | |
| 284 | # video timestamps require 1-D rope |
| 285 | assert use_video_timestamps, "use_video_timestamps must be True" |
| 286 | assert self.rope_type == "rope", ( |
| 287 | "use_video_timestamps requires rope_type='rope'" |
| 288 | ) |
| 289 | self.use_video_timestamps = use_video_timestamps |
| 290 | self.video_audio_interleave_length = video_audio_interleave_length |
| 291 | |
| 292 | self.image_token_id = image_token_id |
| 293 | self.video_token_id = video_token_id |
| 294 | self.audio_token_id = audio_token_id |
| 295 | self.vision_start_token_id = vision_start_token_id |
| 296 | self.vision_end_token_id = vision_end_token_id |
| 297 | self.audio_start_token_id = audio_start_token_id |
| 298 | self.audio_end_token_id = audio_end_token_id |
| 299 | self.video_start_token_id = video_start_token_id |
| 300 | self.video_end_token_id = video_end_token_id |
| 301 | self.pad_token_id = pad_token_id |
| 302 | |
| 303 | self.patch_size = patch_size |
| 304 | self.merge_size = merge_size |
| 305 | self.temporal_patch_size = temporal_patch_size |
| 306 | self.temporal_compression_ratio = temporal_compression_ratio |
| 307 | |
| 308 | self.audio_sampling_rate = audio_sampling_rate |
| 309 | self.audio_nfft = audio_nfft |
| 310 | self.audio_hop_length = audio_hop_length |
| 311 | self.audio_window_size = audio_window_size |
| 312 | self.audio_fmin = audio_fmin |
| 313 | self.audio_fmax = audio_fmax |
| 314 | self.audio_n_mels = audio_n_mels |
| 315 | self.audio_segment_size = audio_segment_size |
| 316 | self.audio_kernel_size = audio_kernel_size |
| 317 | self.audio_stride_size = audio_stride_size |
| 318 | self.audio_avg_pooler = audio_avg_pooler |
| 319 | self.audio_channels = audio_channels |
| 320 | self.audio_group_size = audio_group_size |
| 321 | self.audio_input_id_per_second = audio_input_id_per_second |
| 322 | |
| 323 | self._mel_spec_kwargs = dict( |
| 324 | sample_rate=audio_sampling_rate, |
| 325 | n_fft=audio_nfft, |
| 326 | hop_length=audio_hop_length, |
| 327 | win_length=audio_window_size, |
| 328 | f_min=audio_fmin, |
| 329 | f_max=audio_fmax, |
| 330 | n_mels=audio_n_mels, |
| 331 | power=1.0, |
| 332 | center=True, |
| 333 | ) |
| 334 | self._mel_spectrogram: Any | None = None |
| 335 | self._resamplers: OrderedDict = OrderedDict() |
| 336 | self._resamplers_max = 16 |
| 337 | |
| 338 | if isinstance(audio_zeroemb_idx, int): |
| 339 | self.audio_zeroemb_idxs = torch.tensor( |
| 340 | [audio_zeroemb_idx] * audio_channels, dtype=torch.int32 |
| 341 | ) |
| 342 | else: |
| 343 | self.audio_zeroemb_idxs = torch.tensor(audio_zeroemb_idx, dtype=torch.int32) |
| 344 | |
| 345 | assert image_min_pixels is not None, "image_min_pixels must be set" |
| 346 | assert image_max_pixels is not None, "image_max_pixels must be set" |
| 347 | assert video_min_pixels is not None, "video_min_pixels must be set" |
| 348 | assert video_max_pixels is not None, "video_max_pixels must be set" |
| 349 | assert video_total_max_pixels is not None, "video_total_max_pixels must be set" |
| 350 | assert fps is not None or num_frames is not None, ( |
| 351 | "fps or num_frames must be set" |
| 352 | ) |
| 353 | |
| 354 | self._img_kw = {"min_pixels": image_min_pixels, "max_pixels": image_max_pixels} |
| 355 | self._vid_kw = { |
| 356 | "min_pixels": video_min_pixels, |
| 357 | "max_pixels": video_max_pixels, |
| 358 | "total_max_pixels": video_total_max_pixels, |
| 359 | "fps": fps, |
| 360 | "num_frames": num_frames, |
| 361 | "max_frames": max_frames, |
| 362 | "min_frames": min_frames, |
| 363 | } |
| 364 | |
| 365 | @property |
| 366 | def mel_spectrogram(self) -> Any: |
| 367 | if self._mel_spectrogram is None: |
| 368 | if _MelSpectrogram is None: |
| 369 | raise RuntimeError( |
| 370 | "torchaudio is required for audio. " |
| 371 | "Install with: pip install torchaudio" |
| 372 | ) |
| 373 | self._mel_spectrogram = _MelSpectrogram(**self._mel_spec_kwargs) |
| 374 | return self._mel_spectrogram |
| 375 | |
| 376 | def _resolve_img_kw(self, img: ImageInput) -> dict: |
| 377 | return { |
| 378 | "min_px": ( |
| 379 | img.min_pixels |
| 380 | if img.min_pixels is not None |
| 381 | else self._img_kw["min_pixels"] |
| 382 | ), |
| 383 | "max_px": ( |
| 384 | img.max_pixels |
| 385 | if img.max_pixels is not None |
| 386 | else self._img_kw["max_pixels"] |
| 387 | ), |
| 388 | } |
| 389 | |
| 390 | def _resolve_vid_kw(self, vid: VideoInput) -> dict: |
| 391 | kw: dict = {} |
| 392 | for k in ("min_pixels", "max_pixels", "total_max_pixels"): |
| 393 | kw[k] = getattr(vid, k) or self._vid_kw[k] |
| 394 | if vid.num_frames is not None: |
| 395 | kw["num_frames"] = vid.num_frames |
| 396 | elif vid.fps is not None: |
| 397 | kw["fps"] = vid.fps |
| 398 | if vid.max_frames is not None: |
| 399 | kw["max_frames"] = vid.max_frames |
| 400 | if vid.min_frames is not None: |
| 401 | kw["min_frames"] = vid.min_frames |
| 402 | elif self._vid_kw["num_frames"] is not None: |
| 403 | kw["num_frames"] = self._vid_kw["num_frames"] |
| 404 | elif self._vid_kw["fps"] is not None: |
| 405 | kw["fps"] = self._vid_kw["fps"] |
| 406 | if self._vid_kw["max_frames"] is not None: |
| 407 | kw["max_frames"] = self._vid_kw["max_frames"] |
| 408 | if self._vid_kw["min_frames"] is not None: |
| 409 | kw["min_frames"] = self._vid_kw["min_frames"] |
| 410 | else: |
| 411 | raise ValueError( |
| 412 | "No video sampling strategy specified (fps or num_frames)." |
| 413 | ) |
| 414 | return kw |
| 415 | |
| 416 | def preprocess_audio(self, audio: Any) -> tuple[torch.Tensor, int]: |
| 417 | """Convert a pre-loaded ``(waveform, sr)`` tuple into (mel_spec, token_len).""" |
| 418 | if not isinstance(audio, tuple): |
| 419 | raise ValueError( |
| 420 | f"Unsupported audio source type: {type(audio)}. Audio must be a " |
| 421 | "pre-decoded (waveform, sample_rate) tuple; URL/path/bytes " |
| 422 | "resolution is handled upstream in vLLM's media pipeline." |
| 423 | ) |
| 424 | waveform, original_sr = audio |
| 425 | |
| 426 | if original_sr != self.audio_sampling_rate: |
| 427 | if original_sr not in self._resamplers: |
| 428 | if len(self._resamplers) >= self._resamplers_max: |
| 429 | self._resamplers.popitem(last=False) |
| 430 | self._resamplers[original_sr] = torchaudio.transforms.Resample( |
| 431 | orig_freq=original_sr, new_freq=self.audio_sampling_rate |
| 432 | ) |
| 433 | self._resamplers.move_to_end(original_sr) |
| 434 | waveform = self._resamplers[original_sr](waveform) |
| 435 | |
| 436 | if waveform.ndim == 2: |
| 437 | waveform = waveform.mean(dim=0) |
| 438 | spec = self.mel_spectrogram(waveform[None, :]) |
| 439 | spec = torch.log(torch.clip(spec, min=1e-7)).squeeze().transpose(0, 1) |
| 440 | |
| 441 | n = spec.shape[0] |
| 442 | n = n + 3 - self.audio_kernel_size |
| 443 | n = (n + 2 - self.audio_kernel_size) // self.audio_stride_size + 1 |
| 444 | n = n // self.audio_avg_pooler + int(n % self.audio_avg_pooler != 0) |
| 445 | token_len = math.ceil(n / self.audio_group_size) |
| 446 | return spec, token_len |
| 447 | |
| 448 | def process_image(self, image: ImageInput) -> torch.Tensor: |
| 449 | kw = self._resolve_img_kw(image) |
| 450 | src = image.image |
| 451 | tensor, _, _ = _transform_single( |
| 452 | src, |
| 453 | factor=self.patch_size * self.merge_size, |
| 454 | device=self.device, |
| 455 | **kw, |
| 456 | ) |
| 457 | return tensor |
| 458 | |
| 459 | def process_video( |
| 460 | self, video_input: VideoInput |
| 461 | ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, dict]: |
| 462 | kw = self._resolve_vid_kw(video_input) |
| 463 | video = video_input.video |
| 464 | if not isinstance(video, tuple): |
| 465 | raise ValueError( |
| 466 | f"video must be a (frames_TCHW, timestamps_T) tuple, " |
| 467 | f"got {type(video)}. " |
| 468 | "Decode the video before calling the processor." |
| 469 | ) |
| 470 | frames, timestamps = video |
| 471 | |
| 472 | fps = ( |
| 473 | 1.0 |
| 474 | if len(timestamps) < 2 |
| 475 | else float(1.0 / (float(timestamps[1]) - float(timestamps[0]))) |
| 476 | ) |
| 477 | start = ( |
| 478 | video_input.start_time |
| 479 | if video_input.start_time is not None |
| 480 | else float(timestamps[0]) |
| 481 | ) |
| 482 | end = ( |
| 483 | video_input.end_time |
| 484 | if video_input.end_time is not None |
| 485 | else float(timestamps[-1]) + 1.0 / fps |
| 486 | ) |
| 487 | |
| 488 | if video_input.segment_type != "individual": |
| 489 | mask = (timestamps >= start) & (timestamps < end) |
| 490 | idxs = torch.where(mask)[0] |
| 491 | if len(idxs) == 0: |
| 492 | idxs = torch.where(timestamps <= start)[0][-1:] |
| 493 | frames, timestamps = frames[idxs], timestamps[idxs] |
| 494 | |
| 495 | tp = self.temporal_patch_size * self.temporal_compression_ratio |
| 496 | n = frames.shape[0] |
| 497 | total_px = kw["total_max_pixels"] |
| 498 | max_px = max( |
| 499 | kw["min_pixels"], min(total_px * tp // max(n, 1), kw["max_pixels"]) |
| 500 | ) |
| 501 | |
| 502 | if n % tp != 0: |
| 503 | pad = tp - n % tp |
| 504 | frames = torch.cat( |
| 505 | [frames, frames[-1:].repeat(pad, *([1] * (frames.ndim - 1)))], |
| 506 | dim=0, |
| 507 | ) |
| 508 | timestamps = torch.cat([timestamps, timestamps[-1:].repeat(pad)], dim=0) |
| 509 | |
| 510 | transformed, _, _ = _transform_batch( |
| 511 | frames, |
| 512 | factor=self.patch_size * self.merge_size, |
| 513 | min_px=kw["min_pixels"], |
| 514 | max_px=max_px, |
| 515 | device=self.device, |
| 516 | ) |
| 517 | patches, thw = self._flatten_visual(transformed, "video") |
| 518 | meta = { |
| 519 | "fps_sampled": fps, |
| 520 | "segment_start_time": start, |
| 521 | "segment_end_time": end, |
| 522 | } |
| 523 | return patches, thw, timestamps, meta |
| 524 | |
| 525 | def process_audio(self, audio: AudioInput) -> Any: |
| 526 | src = audio.audio |
| 527 | if isinstance(src, np.ndarray): |
| 528 | src = (torch.from_numpy(src).float(), self.audio_sampling_rate) |
| 529 | if isinstance(src, tuple): |
| 530 | return self.preprocess_audio(src) |
| 531 | # Pre-tokenized tensor (T, n_vq) |
| 532 | assert isinstance(src, torch.Tensor) and src.ndim == 2 |
| 533 | T = src.shape[0] |
| 534 | src = src[:, : self.audio_channels].to(torch.long) |
| 535 | pad_T = ( |
| 536 | (T + self.audio_group_size - 1) |
| 537 | // self.audio_group_size |
| 538 | * self.audio_group_size |
| 539 | ) |
| 540 | padding = ( |
| 541 | torch.zeros(pad_T - T, self.audio_channels, dtype=torch.long) + src[-1] |
| 542 | ) |
| 543 | src = torch.cat([src, padding], dim=0) |
| 544 | return src.reshape( |
| 545 | pad_T // self.audio_group_size, self.audio_group_size, self.audio_channels |
| 546 | ) |
| 547 | |
| 548 | def _flatten_visual( |
| 549 | self, visual: torch.Tensor, kind: str |
| 550 | ) -> tuple[torch.Tensor, torch.Tensor]: |
| 551 | if kind == "image": |
| 552 | h, w = visual.shape[-2:] |
| 553 | patches = visual.unsqueeze(0).repeat(self.temporal_patch_size, 1, 1, 1) |
| 554 | else: # video / video_audio |
| 555 | temporal_stride = self.temporal_compression_ratio * self.temporal_patch_size |
| 556 | assert visual.shape[0] % temporal_stride == 0 |
| 557 | patches = visual |
| 558 | h, w = patches.shape[-2:] |
| 559 | |
| 560 | C = patches.shape[1] |
| 561 | grid_t = patches.shape[0] // self.temporal_patch_size |
| 562 | grid_h, grid_w = h // self.patch_size, w // self.patch_size |
| 563 | |
| 564 | patches = ( |
| 565 | patches.contiguous() |
| 566 | .view( |
| 567 | grid_t, |
| 568 | self.temporal_patch_size, |
| 569 | C, |
| 570 | grid_h // self.merge_size, |
| 571 | self.merge_size, |
| 572 | self.patch_size, |
| 573 | grid_w // self.merge_size, |
| 574 | self.merge_size, |
| 575 | self.patch_size, |
| 576 | ) |
| 577 | .permute(0, 3, 6, 4, 7, 2, 1, 5, 8) |
| 578 | .contiguous() |
| 579 | .view( |
| 580 | grid_t * grid_h * grid_w, |
| 581 | C * self.temporal_patch_size * self.patch_size * self.patch_size, |
| 582 | ) |
| 583 | ) |
| 584 | thw = torch.tensor([grid_t, grid_h, grid_w], dtype=torch.int32) |
| 585 | return patches, thw |
| 586 | |
| 587 | def process( |
| 588 | self, contents: list[Content], verbose: bool = False |
| 589 | ) -> MiMoVLInputSample: |
| 590 | input_ids: list[int] = [] |
| 591 | labels: list[int] = [] |
| 592 | img_pv: list[torch.Tensor] = [] |
| 593 | img_grids: list[torch.Tensor] = [] |
| 594 | vid_pv: list[torch.Tensor] = [] |
| 595 | vid_grids: list[torch.Tensor] = [] |
| 596 | audio_inputs: list[torch.Tensor] = [] |
| 597 | is_audio_tokenized: list[bool] = [] |
| 598 | audio_token_lens: list[int] = [] |
| 599 | second_per_grid_ts: list[float] = [] |
| 600 | video_start_times: list[float] = [] |
| 601 | va_audio_inputs: list[torch.Tensor] = [] |
| 602 | video_audio_n_segs: list[int] = [] |
| 603 | video_audio_seg_lens: list[int] = [] |
| 604 | |
| 605 | # Pre-decode videos in parallel |
| 606 | vid_info = [ |
| 607 | (i, c.content, c.type == "video_audio") |
| 608 | for i, c in enumerate(contents) |
| 609 | if c.type in ("video", "video_audio") |
| 610 | ] |
| 611 | vid_results: dict[int, tuple] = {} |
| 612 | if vid_info: |
| 613 | n_t = min(self.video_process_num_threads, len(vid_info)) |
| 614 | if n_t > 1 and len(vid_info) > 1: |
| 615 | with ThreadPoolExecutor(max_workers=n_t) as ex: |
| 616 | fut_map = { |
| 617 | ex.submit(self.process_video, vi): idx |
| 618 | for idx, vi, _ in vid_info |
| 619 | } |
| 620 | for fut in as_completed(fut_map): |
| 621 | vid_results[fut_map[fut]] = fut.result() |
| 622 | else: |
| 623 | for idx, vi, _ in vid_info: |
| 624 | vid_results[idx] = self.process_video(vi) |
| 625 | |
| 626 | for ci, content in enumerate(contents): |
| 627 | _ids: list[int] = [] |
| 628 | _lbls: list[int] | None = None |
| 629 | |
| 630 | if content.type == "text": |
| 631 | _ids = ( |
| 632 | self.tokenizer.encode(content.content) |
| 633 | if isinstance(content.content, str) |
| 634 | else list(content.content) |
| 635 | ) |
| 636 | if content.is_target: |
| 637 | _lbls = _ids |
| 638 | |
| 639 | elif content.type == "image": |
| 640 | tensor = self.process_image(content.content) |
| 641 | patches, thw = self._flatten_visual(tensor, "image") |
| 642 | t, h, w = thw.tolist() |
| 643 | n_tok = (t * h * w) // (self.merge_size**2) |
| 644 | img_pv.append(patches) |
| 645 | img_grids.append(thw) |
| 646 | _ids = ( |
| 647 | [self.vision_start_token_id] |
| 648 | + [self.image_token_id] * n_tok |
| 649 | + [self.vision_end_token_id] |
| 650 | ) |
| 651 | |
| 652 | elif content.type == "video": |
| 653 | patches, thw, ts, meta = vid_results[ci] |
| 654 | t, h, w = thw.tolist() |
| 655 | n_per_grid = h * w // (self.merge_size**2) |
| 656 | vid_pv.append(patches) |
| 657 | vid_grids.append(thw) |
| 658 | second_per_grid_ts.append( |
| 659 | self.temporal_patch_size / meta["fps_sampled"] |
| 660 | ) |
| 661 | video_start_times.append(float(ts[0])) |
| 662 | video_audio_n_segs.append(0) |
| 663 | |
| 664 | stride = self.temporal_patch_size * self.temporal_compression_ratio |
| 665 | ts_texts = [_format_timestamp(float(x)) for x in ts[::stride]] |
| 666 | ts_ids_list = [self.tokenizer.encode(s) for s in ts_texts] |
| 667 | |
| 668 | _ids = [self.video_start_token_id] |
| 669 | for ts_ids in ts_ids_list: |
| 670 | _ids += ( |
| 671 | ts_ids |
| 672 | + [self.vision_start_token_id] |
| 673 | + [self.video_token_id] * n_per_grid |
| 674 | + [self.vision_end_token_id] |
| 675 | ) |
| 676 | _ids += [self.video_end_token_id] |
| 677 | |
| 678 | elif content.type == "audio": |
| 679 | processed = self.process_audio(content.content) |
| 680 | if isinstance(processed, tuple): |
| 681 | is_audio_tokenized.append(False) |
| 682 | spec, tok_len = processed |
| 683 | audio_inputs.append(spec) |
| 684 | else: |
| 685 | is_audio_tokenized.append(True) |
| 686 | tok_len = processed.shape[0] |
| 687 | audio_inputs.append(processed) |
| 688 | audio_token_lens.append(tok_len) |
| 689 | _ids = ( |
| 690 | [self.audio_start_token_id] |
| 691 | + [self.audio_token_id] * tok_len |
| 692 | + [self.audio_end_token_id] |
| 693 | ) |
| 694 | |
| 695 | elif content.type == "video_audio": |
| 696 | patches, thw, ts, meta = vid_results[ci] |
| 697 | second_per_grid_ts.append( |
| 698 | self.temporal_patch_size / meta["fps_sampled"] |
| 699 | ) |
| 700 | video_start_times.append(float(ts[0])) |
| 701 | processed_audio = self.process_audio(content.content) |
| 702 | tok_per_sec = self.audio_input_id_per_second / self.audio_group_size |
| 703 | |
| 704 | t, h, w = thw.tolist() |
| 705 | vid_pv.append(patches) |
| 706 | vid_grids.append(thw) |
| 707 | |
| 708 | if isinstance(processed_audio, tuple): |
| 709 | # Mel spec (not pre-tokenized): store in va_audio_inputs separately |
| 710 | spec, total_atok = processed_audio |
| 711 | va_audio_inputs.append(spec) |
| 712 | _va_is_tokenized = False |
| 713 | else: |
| 714 | # Pre-tokenized: not expected in vLLM, but handle defensively |
| 715 | total_atok = processed_audio.shape[0] |
| 716 | _va_is_tokenized = True |
| 717 | |
| 718 | n_per_grid = h * w // (self.merge_size**2) |
| 719 | stride = self.temporal_patch_size * self.temporal_compression_ratio |
| 720 | grid_ts = ts[::stride] |
| 721 | ts_texts = [_format_timestamp(float(x)) for x in grid_ts] |
| 722 | ts_ids_list = [self.tokenizer.encode(s) for s in ts_texts] |
| 723 | |
| 724 | units: list[tuple] = [] |
| 725 | for i in range(len(grid_ts)): |
| 726 | a_start = int(float(grid_ts[i]) * tok_per_sec) |
| 727 | a_end = ( |
| 728 | int(float(grid_ts[i + 1]) * tok_per_sec) |
| 729 | if i < len(grid_ts) - 1 |
| 730 | else int(meta["segment_end_time"] * tok_per_sec) |
| 731 | ) |
| 732 | seg_len = min(a_end, total_atok) - a_start |
| 733 | assert seg_len > 0, f"Zero-length audio segment at grid index {i}" |
| 734 | seg = ( |
| 735 | processed_audio[a_start : a_start + seg_len] |
| 736 | if _va_is_tokenized |
| 737 | else None |
| 738 | ) |
| 739 | units.append( |
| 740 | ( |
| 741 | float(grid_ts[i]), |
| 742 | ts_texts[i], |
| 743 | ts_ids_list[i], |
| 744 | n_per_grid, |
| 745 | seg_len, |
| 746 | seg, |
| 747 | ) |
| 748 | ) |
| 749 | |
| 750 | il = self.video_audio_interleave_length |
| 751 | if il == -1: |
| 752 | groups: list[list] = [list(enumerate(units))] |
| 753 | elif il == 0: |
| 754 | groups = [[(i, u)] for i, u in enumerate(units)] |
| 755 | else: |
| 756 | groups, cur, t_ptr = [], [], 0.0 |
| 757 | for i, u in enumerate(units): |
| 758 | while u[0] >= t_ptr + il: |
| 759 | if cur: |
| 760 | groups.append(cur) |
| 761 | cur = [] |
| 762 | t_ptr += il |
| 763 | cur.append((i, u)) |
| 764 | if cur: |
| 765 | groups.append(cur) |
| 766 | |
| 767 | # Track n_segs (= num groups) and per-group audio token counts |
| 768 | video_audio_n_segs.append(len(groups)) |
| 769 | for group in groups: |
| 770 | group_seg_len = sum(u[4] for _, u in group) |
| 771 | video_audio_seg_lens.append(group_seg_len) |
| 772 | |
| 773 | _ids = [self.video_start_token_id] |
| 774 | for group in groups: |
| 775 | _ids += group[0][1][2] # first-unit timestamp token ids |
| 776 | _vid_tok: list[int] = [] |
| 777 | _aud_tok: list[int] = [] |
| 778 | for _, u in group: |
| 779 | _, _, _, vid_n, seg_n, seg_audio = u |
| 780 | _vid_tok += ( |
| 781 | [self.vision_start_token_id] |
| 782 | + [self.video_token_id] * vid_n |
| 783 | + [self.vision_end_token_id] |
| 784 | ) |
| 785 | _aud_tok += [self.audio_token_id] * seg_n |
| 786 | if seg_audio is not None: |
| 787 | # Pre-tokenized per-frame segments (rare in vLLM) |
| 788 | audio_inputs.append(seg_audio) |
| 789 | _ids += ( |
| 790 | _vid_tok |
| 791 | + [self.audio_start_token_id] |
| 792 | + _aud_tok |
| 793 | + [self.audio_end_token_id] |
| 794 | ) |
| 795 | _ids += [self.video_end_token_id] |
| 796 | |
| 797 | input_ids.extend(_ids) |
| 798 | labels.extend( |
| 799 | _lbls if _lbls is not None else [self.pad_token_id] * len(_ids) |
| 800 | ) |
| 801 | |
| 802 | ids_t = torch.tensor(input_ids) |
| 803 | lbl_arr = np.roll(labels, shift=-1) |
| 804 | lbl_arr[-1] = self.pad_token_id |
| 805 | lbl_t = torch.tensor(lbl_arr) |
| 806 | |
| 807 | extra: dict = {} |
| 808 | if is_audio_tokenized: |
| 809 | assert all(is_audio_tokenized) or not any(is_audio_tokenized) |
| 810 | extra["is_audio_tokenized"] = is_audio_tokenized[0] |
| 811 | |
| 812 | position_ids = torch.arange(ids_t.shape[0]).expand(3, -1) |
| 813 | rope_deltas = torch.zeros((1, 1), dtype=torch.int32) |
| 814 | |
| 815 | return MiMoVLInputSample( |
| 816 | input_ids=ids_t, |
| 817 | labels=lbl_t, |
| 818 | pixel_values=img_pv, |
| 819 | pixel_values_videos=vid_pv, |
| 820 | image_thw_grids=img_grids, |
| 821 | video_thw_grids=vid_grids, |
| 822 | audio_inputs=audio_inputs, |
| 823 | second_per_grid_ts=second_per_grid_ts, |
| 824 | video_start_times=video_start_times, |
| 825 | audio_token_lens=audio_token_lens, |
| 826 | va_audio_inputs=va_audio_inputs, |
| 827 | video_audio_n_segs=video_audio_n_segs, |
| 828 | video_audio_seg_lens=video_audio_seg_lens, |
| 829 | position_ids=position_ids, |
| 830 | rope_deltas=rope_deltas, |
| 831 | extra=extra, |
| 832 | ) |
| 833 | |
| 834 | |
| 835 | # --------------------------------------------------------------------------- |
| 836 | # vLLM ProcessorMixin wrapper |
| 837 | # --------------------------------------------------------------------------- |
| 838 | |
| 839 | |
| 840 | class MiMoOmniProcessor(ProcessorMixin): |
| 841 | """HuggingFace-compatible ProcessorMixin wrapper for MiMo-Omni. |
| 842 | |
| 843 | Accepts PIL images, pre-decoded video tuples (frames_TCHW, timestamps_T), |
| 844 | and audio (file path / (waveform, sr) tuple / numpy array). |
| 845 | """ |
| 846 | |
| 847 | attributes = ["tokenizer"] |
| 848 | tokenizer_class = "AutoTokenizer" |
| 849 | |
| 850 | # Single or multi-pad placeholders produced by the chat template / prior expansion |
| 851 | _IMG_RE = re.compile(r"<\|vision_start\|>(?:<\|image_pad\|>)+<\|vision_end\|>") |
| 852 | _VID_RE = re.compile(r"<\|vision_start\|>(?:<\|video_pad\|>)+<\|vision_end\|>") |
| 853 | _AUD_RE = re.compile( |
| 854 | r"<\|mimo_audio_start\|>(?:<\|audio_pad\|>)+<\|mimo_audio_end\|>" |
| 855 | ) |
| 856 | |
| 857 | _MM_RE = re.compile( |
| 858 | r"(<\|vision_start\|>(?:<\|image_pad\|>)+<\|vision_end\|>" |
| 859 | r"|<\|vision_start\|>(?:<\|video_pad\|>)+<\|vision_end\|>" |
| 860 | r"|<\|mimo_audio_start\|>(?:<\|audio_pad\|>)+<\|mimo_audio_end\|>)" |
| 861 | ) |
| 862 | |
| 863 | def __init__( |
| 864 | self, |
| 865 | tokenizer: Any, |
| 866 | *, |
| 867 | patch_size: int = 14, |
| 868 | merge_size: int = 2, |
| 869 | temporal_patch_size: int = 2, |
| 870 | temporal_compression_ratio: int = 1, |
| 871 | image_min_pixels: int | None = None, |
| 872 | image_max_pixels: int | None = None, |
| 873 | video_min_pixels: int | None = None, |
| 874 | video_max_pixels: int | None = None, |
| 875 | video_total_max_pixels: int | None = None, |
| 876 | fps: float = 2.0, |
| 877 | num_frames: int | None = None, |
| 878 | max_frames: int = 256, |
| 879 | min_frames: int = 8, |
| 880 | video_audio_interleave_length: int = 0, |
| 881 | audio_sampling_rate: int = 24000, |
| 882 | audio_nfft: int = 960, |
| 883 | audio_hop_length: int = 240, |
| 884 | audio_window_size: int = 960, |
| 885 | audio_fmin: float = 0.0, |
| 886 | audio_fmax: float | None = None, |
| 887 | audio_n_mels: int = 128, |
| 888 | audio_segment_size: int = 6000, |
| 889 | audio_kernel_size: int = 3, |
| 890 | audio_stride_size: int = 2, |
| 891 | audio_avg_pooler: int = 2, |
| 892 | audio_channels: int = 8, |
| 893 | audio_group_size: int = 4, |
| 894 | audio_input_id_per_second: float = 25.0, |
| 895 | audio_zeroemb_idx: int = 4096, |
| 896 | image_token_id: int | None = None, |
| 897 | video_token_id: int | None = None, |
| 898 | audio_token_id: int | None = None, |
| 899 | vision_start_token_id: int | None = None, |
| 900 | vision_end_token_id: int | None = None, |
| 901 | audio_start_token_id: int | None = None, |
| 902 | audio_end_token_id: int | None = None, |
| 903 | video_start_token_id: int | None = None, |
| 904 | video_end_token_id: int | None = None, |
| 905 | rope_type: str = "rope", |
| 906 | ) -> None: |
| 907 | self.tokenizer = tokenizer |
| 908 | |
| 909 | unit = patch_size * merge_size |
| 910 | self.mimo_processor = MiMoVLProcessor( |
| 911 | tokenizer=tokenizer, |
| 912 | patch_size=patch_size, |
| 913 | merge_size=merge_size, |
| 914 | temporal_patch_size=temporal_patch_size, |
| 915 | temporal_compression_ratio=temporal_compression_ratio, |
| 916 | use_video_timestamps=True, |
| 917 | video_audio_interleave_length=video_audio_interleave_length, |
| 918 | audio_sampling_rate=audio_sampling_rate, |
| 919 | audio_nfft=audio_nfft, |
| 920 | audio_hop_length=audio_hop_length, |
| 921 | audio_window_size=audio_window_size, |
| 922 | audio_fmin=audio_fmin, |
| 923 | audio_fmax=audio_fmax, |
| 924 | audio_n_mels=audio_n_mels, |
| 925 | audio_segment_size=audio_segment_size, |
| 926 | audio_kernel_size=audio_kernel_size, |
| 927 | audio_stride_size=audio_stride_size, |
| 928 | audio_avg_pooler=audio_avg_pooler, |
| 929 | audio_channels=audio_channels, |
| 930 | audio_group_size=audio_group_size, |
| 931 | audio_input_id_per_second=audio_input_id_per_second, |
| 932 | audio_zeroemb_idx=audio_zeroemb_idx, |
| 933 | image_min_pixels=image_min_pixels or (4 * unit * unit), |
| 934 | image_max_pixels=image_max_pixels or (4096 * unit * unit), |
| 935 | video_min_pixels=video_min_pixels or (4 * unit * unit), |
| 936 | video_max_pixels=video_max_pixels or (4096 * unit * unit), |
| 937 | video_total_max_pixels=video_total_max_pixels or (16384 * unit * unit), |
| 938 | fps=fps, |
| 939 | num_frames=num_frames, |
| 940 | max_frames=max_frames, |
| 941 | min_frames=min_frames, |
| 942 | image_token_id=image_token_id, |
| 943 | video_token_id=video_token_id, |
| 944 | audio_token_id=audio_token_id, |
| 945 | vision_start_token_id=vision_start_token_id, |
| 946 | vision_end_token_id=vision_end_token_id, |
| 947 | audio_start_token_id=audio_start_token_id, |
| 948 | audio_end_token_id=audio_end_token_id, |
| 949 | video_start_token_id=video_start_token_id, |
| 950 | video_end_token_id=video_end_token_id, |
| 951 | pad_token_id=tokenizer.pad_token_id, |
| 952 | rope_type=rope_type, |
| 953 | ) |
| 954 | |
| 955 | @classmethod |
| 956 | def from_hf_config( |
| 957 | cls, |
| 958 | tokenizer: Any, |
| 959 | hf_config: Any, |
| 960 | ) -> "MiMoOmniProcessor": |
| 961 | """Instantiate directly from an HF model config object.""" |
| 962 | vc = hf_config.vision_config |
| 963 | if isinstance(vc, dict): |
| 964 | patch_size = vc.get("patch_size", 14) |
| 965 | merge_size = vc.get("spatial_merge_size", 2) |
| 966 | temporal_patch_size = vc.get("temporal_patch_size", 2) |
| 967 | else: |
| 968 | patch_size = getattr(vc, "patch_size", 14) |
| 969 | merge_size = getattr(vc, "spatial_merge_size", 2) |
| 970 | temporal_patch_size = getattr(vc, "temporal_patch_size", 2) |
| 971 | |
| 972 | pc: dict = getattr(hf_config, "processor_config", {}) or {} |
| 973 | ac = getattr(hf_config, "audio_config", None) |
| 974 | audio_sr: int | None = pc.get("audio_sampling_rate") |
| 975 | if audio_sr is None and ac is not None: |
| 976 | if isinstance(ac, dict): |
| 977 | audio_sr = ac.get("sampling_rate") or ac.get("sample_rate") |
| 978 | else: |
| 979 | audio_sr = getattr(ac, "sampling_rate", None) or getattr( |
| 980 | ac, "sample_rate", None |
| 981 | ) |
| 982 | |
| 983 | rope_type = "rope" |
| 984 | rs = getattr(hf_config, "rope_scaling", None) |
| 985 | if rs and rs.get("type") == "default" and rs.get("mrope_section") is not None: |
| 986 | rope_type = "mrope" |
| 987 | |
| 988 | unit = patch_size * merge_size |
| 989 | return cls( |
| 990 | tokenizer, |
| 991 | patch_size=patch_size, |
| 992 | merge_size=merge_size, |
| 993 | temporal_patch_size=temporal_patch_size, |
| 994 | image_min_pixels=pc.get("image_min_pixels") or (4 * unit * unit), |
| 995 | image_max_pixels=pc.get("image_max_pixels") or (4096 * unit * unit), |
| 996 | video_min_pixels=pc.get("video_min_pixels") or (4 * unit * unit), |
| 997 | video_max_pixels=pc.get("video_max_pixels") or (4096 * unit * unit), |
| 998 | video_total_max_pixels=( |
| 999 | pc.get("video_total_max_pixels") or (16384 * unit * unit) |
| 1000 | ), |
| 1001 | fps=pc.get("fps") or 2.0, |
| 1002 | num_frames=pc.get("num_frames"), |
| 1003 | max_frames=pc.get("max_frames") or 256, |
| 1004 | min_frames=pc.get("min_frames") or 8, |
| 1005 | video_audio_interleave_length=pc.get("video_audio_interleave_length", 0), |
| 1006 | audio_sampling_rate=audio_sr or 24000, |
| 1007 | image_token_id=pc.get("image_token_id"), |
| 1008 | video_token_id=pc.get("video_token_id"), |
| 1009 | audio_token_id=pc.get("audio_token_id"), |
| 1010 | vision_start_token_id=pc.get("vision_start_token_id"), |
| 1011 | vision_end_token_id=pc.get("vision_end_token_id"), |
| 1012 | audio_start_token_id=pc.get("audio_start_token_id"), |
| 1013 | audio_end_token_id=pc.get("audio_end_token_id"), |
| 1014 | video_start_token_id=pc.get("video_start_token_id"), |
| 1015 | video_end_token_id=pc.get("video_end_token_id"), |
| 1016 | rope_type=rope_type, |
| 1017 | ) |
| 1018 | |
| 1019 | @property |
| 1020 | def image_token(self) -> str: |
| 1021 | """Token string used as image placeholder (for vLLM integration).""" |
| 1022 | return "<|image_pad|>" |
| 1023 | |
| 1024 | @property |
| 1025 | def video_token(self) -> str: |
| 1026 | """Token string used as video placeholder (for vLLM integration).""" |
| 1027 | return "<|video_pad|>" |
| 1028 | |
| 1029 | @property |
| 1030 | def image_processor(self) -> Any: |
| 1031 | """Minimal image-processor-like object for vLLM processing-info compat.""" |
| 1032 | p = self.mimo_processor |
| 1033 | |
| 1034 | class _ImageProcessor: |
| 1035 | merge_size = p.merge_size |
| 1036 | size = { |
| 1037 | "shortest_edge": p._img_kw["min_pixels"], |
| 1038 | "longest_edge": p._img_kw["max_pixels"], |
| 1039 | } |
| 1040 | |
| 1041 | return _ImageProcessor() |
| 1042 | |
| 1043 | def _modality(self, token: str) -> str: |
| 1044 | if self._IMG_RE.fullmatch(token): |
| 1045 | return "image" |
| 1046 | if self._VID_RE.fullmatch(token): |
| 1047 | return "video" |
| 1048 | if self._AUD_RE.fullmatch(token): |
| 1049 | return "audio" |
| 1050 | return "unknown" |
| 1051 | |
| 1052 | def __call__( |
| 1053 | self, |
| 1054 | text: str | list[str] | None = None, |
| 1055 | images: Any = None, |
| 1056 | videos: Any = None, |
| 1057 | audio: Any = None, |
| 1058 | video_audio: Any = None, |
| 1059 | return_tensors: str | TensorType | None = None, |
| 1060 | **kwargs: Any, |
| 1061 | ) -> BatchFeature: |
| 1062 | """Process multimodal inputs into model-ready tensors. |
| 1063 | |
| 1064 | Args: |
| 1065 | text: Prompt string(s) containing multimodal placeholders |
| 1066 | ``<|vision_start|><|image_pad|><|vision_end|>``, |
| 1067 | ``<|vision_start|><|video_pad|><|vision_end|>``, or |
| 1068 | ``<|mimo_audio_start|><|audio_pad|><|mimo_audio_end|>``. |
| 1069 | images: PIL.Image or list[PIL.Image]. |
| 1070 | videos: list of ``(frames_TCHW: torch.Tensor, timestamps_T: torch.Tensor)`` |
| 1071 | tuples (pre-decoded). |
| 1072 | audio: list of ``str`` (path/url/base64), ``bytes``, |
| 1073 | ``(waveform_1D, sample_rate)`` tuples, or ``np.ndarray``. |
| 1074 | video_audio: audio track extracted from ``videos``, in the same |
| 1075 | formats accepted by ``audio``. |
| 1076 | return_tensors: Passed to :class:`BatchFeature`. |
| 1077 | |
| 1078 | Returns: |
| 1079 | :class:`BatchFeature` with keys: |
| 1080 | - ``input_ids`` |
| 1081 | - ``pixel_values`` + ``image_grid_thw`` |
| 1082 | - ``pixel_values_videos`` + ``video_grid_thw`` + ``second_per_grid_ts`` |
| 1083 | - ``audio_features`` |
| 1084 | |
| 1085 | """ |
| 1086 | if isinstance(text, list): |
| 1087 | text = text[0] if len(text) == 1 else "\n".join(text) |
| 1088 | |
| 1089 | imgs: list = ( |
| 1090 | ([images] if isinstance(images, Image.Image) else list(images)) |
| 1091 | if images is not None |
| 1092 | else [] |
| 1093 | ) |
| 1094 | vids: list = list(videos) if videos is not None else [] |
| 1095 | auds: list = list(audio) if audio is not None else [] |
| 1096 | va_items: list = list(video_audio) if video_audio is not None else [] |
| 1097 | |
| 1098 | # If audio exists but text has no audio placeholder, prepend one |
| 1099 | _aud_placeholder = "<|mimo_audio_start|><|audio_pad|><|mimo_audio_end|>" |
| 1100 | if auds and text is not None and not self._AUD_RE.search(text): |
| 1101 | text = _aud_placeholder + text |
| 1102 | |
| 1103 | # Build Content list |
| 1104 | contents: list[Content] = [] |
| 1105 | |
| 1106 | if text and (imgs or vids or auds or va_items): |
| 1107 | parts = self._MM_RE.split(text) |
| 1108 | img_it = iter(imgs) |
| 1109 | vid_it = iter(vids) |
| 1110 | aud_it = iter(auds) |
| 1111 | va_it = iter(va_items) |
| 1112 | for part in parts: |
| 1113 | if self._MM_RE.fullmatch(part): |
| 1114 | mod = self._modality(part) |
| 1115 | if mod == "image": |
| 1116 | with contextlib.suppress(StopIteration): |
| 1117 | contents.append( |
| 1118 | Content( |
| 1119 | type="image", |
| 1120 | content=ImageInput(image=next(img_it)), |
| 1121 | ) |
| 1122 | ) |
| 1123 | elif mod == "video": |
| 1124 | # Try regular video first, fall back to video_audio |
| 1125 | vid_item = None |
| 1126 | vid_type = "video" |
| 1127 | with contextlib.suppress(StopIteration): |
| 1128 | vid_item = next(vid_it) |
| 1129 | if vid_item is None: |
| 1130 | with contextlib.suppress(StopIteration): |
| 1131 | vid_item = next(va_it) |
| 1132 | vid_type = "video_audio" |
| 1133 | if vid_item is not None: |
| 1134 | if vid_type == "video": |
| 1135 | contents.append( |
| 1136 | Content( |
| 1137 | type="video", |
| 1138 | content=VideoInput(video=vid_item), |
| 1139 | ) |
| 1140 | ) |
| 1141 | else: |
| 1142 | contents.append( |
| 1143 | Content( |
| 1144 | type="video_audio", |
| 1145 | content=vid_item, |
| 1146 | ) |
| 1147 | ) |
| 1148 | elif mod == "audio": |
| 1149 | with contextlib.suppress(StopIteration): |
| 1150 | contents.append( |
| 1151 | Content( |
| 1152 | type="audio", |
| 1153 | content=AudioInput(audio=next(aud_it)), |
| 1154 | ) |
| 1155 | ) |
| 1156 | elif part: |
| 1157 | contents.append(Content(type="text", content=part)) |
| 1158 | elif text: |
| 1159 | contents.append(Content(type="text", content=text)) |
| 1160 | else: |
| 1161 | for img in imgs: |
| 1162 | contents.append(Content(type="image", content=ImageInput(image=img))) |
| 1163 | for vid in vids: |
| 1164 | contents.append(Content(type="video", content=VideoInput(video=vid))) |
| 1165 | for aud in auds: |
| 1166 | contents.append(Content(type="audio", content=AudioInput(audio=aud))) |
| 1167 | for va_item in va_items: |
| 1168 | contents.append(Content(type="video_audio", content=va_item)) |
| 1169 | |
| 1170 | if not contents: |
| 1171 | ids = self.tokenizer(text or "", return_tensors=return_tensors)["input_ids"] |
| 1172 | return BatchFeature(data={"input_ids": ids}, tensor_type=return_tensors) |
| 1173 | |
| 1174 | sample = self.mimo_processor.process(contents, verbose=False) |
| 1175 | |
| 1176 | # vLLM expects input_ids to have a batch dimension [1, seq_len]. |
| 1177 | data: dict = {"input_ids": sample.input_ids.unsqueeze(0)} |
| 1178 | |
| 1179 | if sample.pixel_values: |
| 1180 | data["pixel_values"] = torch.cat(sample.pixel_values, dim=0) |
| 1181 | data["image_grid_thw"] = torch.stack(sample.image_thw_grids) |
| 1182 | |
| 1183 | if sample.pixel_values_videos: |
| 1184 | data["pixel_values_videos"] = torch.cat(sample.pixel_values_videos, dim=0) |
| 1185 | data["video_grid_thw"] = torch.stack(sample.video_thw_grids) |
| 1186 | if sample.second_per_grid_ts: |
| 1187 | data["second_per_grid_ts"] = torch.tensor( |
| 1188 | sample.second_per_grid_ts, dtype=torch.float32 |
| 1189 | ) |
| 1190 | if sample.video_start_times: |
| 1191 | data["video_start_times"] = torch.tensor( |
| 1192 | sample.video_start_times, dtype=torch.float32 |
| 1193 | ) |
| 1194 | if sample.video_audio_n_segs: |
| 1195 | data["video_audio_n_segs"] = torch.tensor( |
| 1196 | sample.video_audio_n_segs, dtype=torch.long |
| 1197 | ) |
| 1198 | # video_audio_seg_lens: 2D padded tensor (num_videos, max_T). |
| 1199 | # Row i has the per-group audio token lengths for video i |
| 1200 | # (zeros for regular videos; valid values for video_audio videos). |
| 1201 | n_segs_list = sample.video_audio_n_segs |
| 1202 | max_segs = max(n_segs_list) if n_segs_list else 0 |
| 1203 | if max_segs > 0: |
| 1204 | seg_lens_2d = torch.zeros(len(n_segs_list), max_segs, dtype=torch.long) |
| 1205 | flat_cursor = 0 |
| 1206 | for vi, n in enumerate(n_segs_list): |
| 1207 | if n > 0: |
| 1208 | seg_lens_2d[vi, :n] = torch.tensor( |
| 1209 | sample.video_audio_seg_lens[flat_cursor : flat_cursor + n], |
| 1210 | dtype=torch.long, |
| 1211 | ) |
| 1212 | flat_cursor += n |
| 1213 | data["video_audio_seg_lens"] = seg_lens_2d |
| 1214 | |
| 1215 | # audio_features is a list of variable-length mel-spec tensors; pop it |
| 1216 | # before BatchFeature conversion to avoid "batched tensors of the same |
| 1217 | # length" errors, then re-attach it after. |
| 1218 | audio_features = None |
| 1219 | if sample.audio_inputs: |
| 1220 | audio_features = sample.audio_inputs |
| 1221 | if "is_audio_tokenized" in sample.extra: |
| 1222 | data["is_audio_tokenized"] = sample.extra["is_audio_tokenized"] |
| 1223 | if sample.audio_token_lens: |
| 1224 | data["audio_token_lens"] = torch.tensor( |
| 1225 | sample.audio_token_lens, dtype=torch.long |
| 1226 | ) |
| 1227 | |
| 1228 | bf = BatchFeature(data=data, tensor_type=return_tensors) |
| 1229 | if audio_features is not None: |
| 1230 | bf["audio_features"] = audio_features |
| 1231 | # va_audio_features: list of mel-spec tensors (one per video_audio item) |
| 1232 | if sample.va_audio_inputs: |
| 1233 | bf["va_audio_features"] = sample.va_audio_inputs |
| 1234 | return bf |