"""Official DeepSeek-V4-Flash-Vision-Exp image preprocessor. Adapted from deepseek-ai/DeepSeek-V4-Flash-Vision-Exp ``inference/image_processor.py`` (MIT). Adds a PIL entry point so vLLM can feed decoded images without going back through URLs. Video is not part of this checkpoint: GIF is decoded as a still RGB frame (PIL's first frame). """ from __future__ import annotations import base64 import io import math from dataclasses import dataclass from types import SimpleNamespace from typing import Any, TYPE_CHECKING from urllib.request import urlopen if TYPE_CHECKING: import torch from PIL import Image as PILImage def _pil(): """Load Pillow on first image decode. CPU CI does not install it.""" try: from PIL import Image, ImageOps except ImportError as exc: raise ModuleNotFoundError( "Pillow is required to decode Vision-Exp images" ) from exc return Image, ImageOps IMAGE_START, IMAGE_PAD, IMAGE, IMAGE_NEW_LINE, IMAGE_END = range(5) COMPRESS_PAD_TO = 4 IMAGE_PLACEHOLDER = "<|deepseek_image|>" def is_vision_exp_weight_name(name: str) -> bool: """Checkpoint keys for the ViT/Aligner/image tokens. Anemll ``load_weights`` treats any name containing ``w1`` as a stacked ``gate_up_proj`` shard. Aligner's ``w1`` and ViT MLP ``w1`` are full Linear layers, so those keys must bypass the stacked mapping. """ return ( name.startswith("aligner.") or name.startswith("vision.") or name.startswith("image_") or name.startswith("model.aligner.") or name.startswith("model.vision.") or name.startswith("model.image_") ) def is_unregistered_router_bias(name: str, param_names: Any) -> bool: """True when a gate bias has no Parameter. Hash MoE (layers 0–2) does not register ``e_score_correction_bias``. 0731 omitted those tensors; Vision-Exp dumps ``ffn.gate.bias`` / ``bias_vl`` there anyway. Anemll's mapper rewrites them to ``e_score_correction_bias`` / ``_vl``, then ``load_weights`` KeyErrors. The DSpark draft loader uses ``endswith(".ffn.gate.bias")``, which misses ``bias_vl``, so unmapped ``.ffn.gate.bias_vl`` is also unused until that remap is patched. """ if name in param_names: return False return name.endswith( ( ".ffn.gate.e_score_correction_bias", ".ffn.gate.e_score_correction_bias_vl", ".ffn.gate.bias_vl", ".ffn.gate.bias", ) ) def as_pil(item: Any) -> PILImage.Image: """Normalize vLLM image items (PIL, HWC/CHW array, tensor, dict) to RGB PIL.""" Image, _ImageOps = _pil() if isinstance(item, Image.Image): return item.convert("RGB") if isinstance(item, dict): for key in ("image_pil", "pil", "image"): value = item.get(key) if value is None or isinstance(value, dict): continue return as_pil(value) raise TypeError(f"Unsupported image dict keys: {sorted(item)!r}") array = item try: import torch except ImportError: torch = None # type: ignore[assignment] if torch is not None and isinstance(array, torch.Tensor): array = array.detach().cpu().numpy() try: import numpy as np except ImportError as exc: raise TypeError(f"Unsupported image item type: {type(item)!r}") from exc if not hasattr(array, "ndim"): raise TypeError(f"Unsupported image item type: {type(item)!r}") arr = np.asarray(array) if arr.ndim == 2: arr = np.stack([arr, arr, arr], axis=-1) elif arr.ndim == 3 and arr.shape[-1] not in (1, 3, 4) and arr.shape[0] in (1, 3, 4): arr = np.transpose(arr, (1, 2, 0)) if arr.ndim != 3: raise TypeError(f"Unsupported image array shape: {arr.shape!r}") if arr.shape[-1] == 1: arr = np.repeat(arr, 3, axis=-1) if np.issubdtype(arr.dtype, np.floating): peak = float(arr.max()) if arr.size else 0.0 if peak <= 1.0: arr = arr * 255.0 arr = np.clip(arr, 0, 255).astype("uint8") else: arr = np.clip(arr, 0, 255).astype("uint8") if arr.shape[-1] == 4: return Image.fromarray(arr, mode="RGBA").convert("RGB") return Image.fromarray(arr[..., :3], mode="RGB") @dataclass class ImageInput: start: int patches: Any n_vit_h: int n_vit_w: int types: Any perm: Any def vision_args_from_config(config: Any) -> SimpleNamespace: return SimpleNamespace( vision_patch_size=int(getattr(config, "vision_patch_size", 14)), vision_downsample_ratio=int(getattr(config, "vision_downsample_ratio", 3)), vision_max_n_token=int(getattr(config, "vision_max_n_token", 384)), vision_min_pixels=int(getattr(config, "vision_min_pixels", 147456)), vision_max_wh_ratio=getattr(config, "vision_max_wh_ratio", 8), vision_n_layers=int(getattr(config, "vision_n_layers", 0)), vision_dim=int(getattr(config, "vision_dim", 1024)), vision_n_heads=int(getattr(config, "vision_n_heads", 16)), vision_inter_dim=int(getattr(config, "vision_inter_dim", 2816)), vision_rope_theta=float(getattr(config, "vision_rope_theta", 10000.0)), vocab_size=int(getattr(config, "vocab_size", 129280)), dim=int(getattr(config, "hidden_size", getattr(config, "dim", 4096))), hidden_size=int(getattr(config, "hidden_size", getattr(config, "dim", 4096))), ) def grid_tokens(best_height, best_width, patch_size, downsample_ratio): """Number of LLM tokens the aligner grid occupies (N-layout, incl. row/align padding).""" n_llm_h = math.ceil((best_height // patch_size) / downsample_ratio) n_llm_w = math.ceil((best_width // patch_size) / downsample_ratio) num_tokens = n_llm_h * (n_llm_w + 1) + 2 if n_llm_h % 2 == 1: num_tokens += n_llm_w + 1 num_tokens += (n_llm_h + 1) // 2 * (n_llm_w + 1) % 2 * 2 return n_llm_h, n_llm_w, num_tokens def solve_resize_ratio(height, width, patch_size, downsample_ratio, max_n_token): r = height / width max_w_float = math.sqrt((max_n_token - 2) / r + 0.25) - 0.5 max_h_float = max_w_float * r if max_w_float < 1.0: max_w = 1 max_h = (max_n_token - 2) // (max_w + 1) if max_h % 2 == 1: max_h -= 1 best_width = max_w * patch_size * downsample_ratio best_height = max_h * patch_size * downsample_ratio elif max_h_float < 2.0: max_h = 2 max_w = ((max_n_token - 2) // max_h) - 1 assert max_w > 1 best_width = max_w * patch_size * downsample_ratio best_height = max_h * patch_size * downsample_ratio else: max_w = math.floor(max_w_float) max_h = math.floor(max_h_float) if max_h % 2 == 1: max_h -= 1 beta = min( max_w * patch_size * downsample_ratio / width, max_h * patch_size * downsample_ratio / height, ) best_width = math.floor(width * beta / patch_size) * patch_size best_height = math.floor(height * beta / patch_size) * patch_size n_llm_h, n_llm_w, num_tokens = grid_tokens( best_height, best_width, patch_size, downsample_ratio ) return n_llm_h, n_llm_w, best_height, best_width, num_tokens def safe_resize(height, width, best_height, best_width, patch_size, downsample_ratio, max_n_token): max_n_token -= COMPRESS_PAD_TO - 1 n_llm_h, n_llm_w, num_tokens = grid_tokens( best_height, best_width, patch_size, downsample_ratio ) budget = max_n_token while num_tokens > max_n_token: n_llm_h, n_llm_w, best_height, best_width, num_tokens = solve_resize_ratio( height, width, patch_size, downsample_ratio, budget ) budget -= 1 return n_llm_h, n_llm_w, best_height, best_width def load_image_bytes(record) -> bytes: """Load image bytes from raw/base64 data, an Anthropic source, URL, or path.""" data = record.get("data") if isinstance(data, bytes): return data if isinstance(data, str): return base64.b64decode(data) source = record.get("source") if isinstance(source, dict): if source.get("data") is not None: return base64.b64decode(source["data"]) if source.get("url"): return load_image_bytes({"url": source["url"]}) url = record.get("url") if isinstance(url, str) and url: if url.startswith("data:"): header, _, payload = url.partition(",") if ";base64" not in header: raise ValueError(f"Unsupported data URL encoding: {header}") return base64.b64decode(payload) if url.startswith(("http://", "https://")): with urlopen(url, timeout=30) as response: return response.read() with open(url, "rb") as file: return file.read() raise ValueError(f"Cannot load image from record: {list(record.keys())}") def pil_to_patches(image: PILImage.Image, args) -> tuple[Any, int, int, int, int]: """Resize/pad one RGB image and return ViT patches plus LLM grid sizes.""" import torch _Image, ImageOps = _pil() p = args.vision_patch_size image = image.convert("RGB") width, height = image.size if args.vision_max_wh_ratio is not None and width > height * args.vision_max_wh_ratio: width = height * args.vision_max_wh_ratio if 0 < width * height < args.vision_min_pixels: ratio = (args.vision_min_pixels / (width * height)) ** 0.5 width = int(width * ratio) height = int(height * ratio) best_width = math.ceil(width / p) * p best_height = math.ceil(height / p) * p n_llm_h, n_llm_w, best_height, best_width = safe_resize( height, width, best_height, best_width, p, args.vision_downsample_ratio, args.vision_max_n_token, ) n_vit_h, n_vit_w = best_height // p, best_width // p src_w, src_h = image.size if args.vision_max_wh_ratio is not None and src_w >= args.vision_max_wh_ratio * src_h: image = image.resize((best_width, best_height)) else: image = ImageOps.pad(image, (best_width, best_height), color=(127, 127, 127)) try: import numpy as np x = torch.from_numpy(np.asarray(image, dtype=np.float32)).permute(2, 0, 1) / 255 except ImportError: pixels = ( image.get_flattened_data() if hasattr(image, "get_flattened_data") else image.getdata() ) x = torch.tensor(pixels, dtype=torch.float32) x = x.view(image.height, image.width, 3).permute(2, 0, 1) / 255 x = ((x - 0.5) / 0.5).to(torch.bfloat16) patches = ( x.reshape(3, n_vit_h, p, n_vit_w, p) .permute(1, 3, 0, 2, 4) .reshape(n_vit_h * n_vit_w, 3, p, p) ) return patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w def load_image(record, args): """Load and transform one image record into ViT patches.""" Image, _ImageOps = _pil() with Image.open(io.BytesIO(load_image_bytes(record))) as source: image = source.convert("RGB") return pil_to_patches(image, args) def build_image_block(n_llm_h: int, n_llm_w: int, start_pos: int): """Builds the N-layout token types (final order) and the aligner-row order for IMAGE slots.""" import torch compress_pad = COMPRESS_PAD_TO - 1 - start_pos % COMPRESS_PAD_TO pad_h = n_llm_h % 2 rows = n_llm_h + pad_h row_len = n_llm_w + 1 pad_last = rows // 2 * row_len % 2 * 2 types = torch.tensor( ([IMAGE] * n_llm_w + [IMAGE_NEW_LINE]) * n_llm_h + [IMAGE_PAD] * (row_len * pad_h), dtype=torch.int64, ) order = torch.arange(rows * row_len).view(rows // 2, 2, row_len).transpose(1, 2).reshape(-1) image_idx = torch.full((rows * row_len,), -1, dtype=torch.int64) image_idx.view(rows, row_len)[:n_llm_h, :n_llm_w] = torch.arange(n_llm_h * n_llm_w).view( n_llm_h, n_llm_w ) perm = image_idx[order] perm = perm[perm >= 0] types = torch.cat( [ torch.full((compress_pad,), IMAGE_PAD, dtype=torch.int64), torch.tensor([IMAGE_START]), types[order], torch.full((pad_last,), IMAGE_PAD, dtype=torch.int64), torch.tensor([IMAGE_END]), ] ) return types, perm