File size: 11,506 Bytes
80c945e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 | # Copyright 2026 Everpaper. Licensed under the Apache License, Version 2.0 (see ../LICENSE).
#
# Ports the logic of the Gemma 4 image processor (image_processing_gemma4.py in Hugging Face transformers 5.19,
# Copyright 2026 The HuggingFace Team, Apache-2.0), and the resize arithmetic of ATen's uint8
# `_upsample_bicubic2d_aa` kernel (PyTorch, BSD-3-Clause; itself modelled on Pillow's Resample.c, MIT-CMU/HPND).
# See ../NOTICE for the attributions and the PyTorch and Pillow license notices.
"""Exact host-side image preprocessing for the EmbeddingGemma 2 vision encoder (numpy + Pillow, no torch).
Reproduces what `Gemma4ImageProcessor` (transformers 5.19, torchvision backend) does to an image, bit for bit:
1. decode to 8-bit RGB: PIL `convert("RGB")` (alpha dropped, not composited; palette expanded; EXIF orientation
NOT applied; no colour management) - identical to torchvision `decode_image(..., mode=RGB)` for 8-bit files.
2. target size: keep the aspect ratio, each side a multiple of 48 px, at most 2520 patches of 16x16 (budget of
280 soft tokens); tiny images are upscaled (1x1 -> 768x768), extreme aspect ratios go up to 48x13440.
3. resize: antialiased bicubic (a = -0.5) on uint8, the ATen `_upsample_bicubic2d_aa` uint8 kernel: separable,
WIDTH pass first then HEIGHT, weights computed in float64 then rounded to int16 with a dynamic precision, each
pass accumulated in int32, rounded (+half, arithmetic shift) and clamped to 0..255. Verified bit-identical to
torchvision 0.29.1 on 239 images of 1x1 .. 8000x2000 px (tests/check_preprocess.py).
4. 16x16x3 patches flattened in (py, px, c) order. Upstream then multiplies by 1/255 (no mean/std) and the
encoder computes 2p - 1; here the patches stay INTEGER pixel values 0..255 (exact in float16) and the graph
computes P - 127.5 with the 2/255 scale folded into its first projection (same maths, no input rounding).
Upstream's pixel_values == patches * RESCALE.
The vision graph wants the patches in CELL-MAJOR order (the 9 patches of each 3x3 pooling cell contiguous, cells
row-major), padded to 2520, plus per-patch (x, y) positions, the axial-RoPE cos/sin table and a validity mask; see
`VisionInputs` / `prepare_vision_inputs`. The model is otherwise unchanged (see ../NOTICE).
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
import numpy as np
PATCH = 16
POOL = 3
CELL_PX = PATCH * POOL # 48
MAX_SOFT_TOKENS = 280
MAX_PATCHES = MAX_SOFT_TOKENS * POOL * POOL # 2520
MAX_SIDE_PATCHES = MAX_SOFT_TOKENS * POOL # 840: an image can be at most 840 patches wide or high (48 x 13440 px)
HEAD_DIM = 64
ROPE_THETA = 100.0
ROPE_FREQS = HEAD_DIM // 4 # 16 frequencies per axis
# ---------------------------------------------------------------------------------------------- decode
def load_rgb(image, apply_exif_orientation: bool = False) -> np.ndarray:
"""`image`: a path or a PIL image. Returns HWC uint8 RGB exactly like the upstream processor (8-bit files).
apply_exif_orientation=True rotates camera photos upright first (NOT what upstream does; the embedding of a
rotated photo differs from the upright one by cosine ~0.97, see README)."""
from PIL import Image, ImageOps
im = image if isinstance(image, Image.Image) else Image.open(Path(image))
if apply_exif_orientation:
im = ImageOps.exif_transpose(im)
if im.mode in ("I;16", "I;16B", "I;16L", "I;16N", "I"):
# 16-bit images: upstream divides the uint16 samples by 255 and produces garbage, PIL's convert clips at
# 255; both runners take the high byte instead (documented divergence, same in Swift).
a = np.asarray(im)
a = (a >> 8).astype(np.uint8) if a.dtype == np.uint16 else np.clip(a // 256, 0, 255).astype(np.uint8)
return np.repeat(a[..., None], 3, axis=-1)
return np.asarray(im.convert("RGB"), dtype=np.uint8)
# ---------------------------------------------------------------------------------------------- size
def target_size(height: int, width: int, max_patches: int = MAX_PATCHES) -> tuple[int, int]:
"""Gemma4 `get_aspect_ratio_preserving_size`, verbatim logic. Returns (height, width)."""
target_px = max_patches * PATCH * PATCH
factor = math.sqrt(target_px / (height * width))
th = int(math.floor(factor * height / CELL_PX)) * CELL_PX
tw = int(math.floor(factor * width / CELL_PX)) * CELL_PX
max_side = (max_patches // (POOL * POOL)) * CELL_PX
if th == 0 and tw == 0:
raise ValueError("image would resize to 0x0")
if th == 0:
th, tw = CELL_PX, min(int(math.floor(width / height)) * CELL_PX, max_side)
elif tw == 0:
tw, th = CELL_PX, min(int(math.floor(height / width)) * CELL_PX, max_side)
if th * tw > target_px:
raise ValueError("size exceeds the patch budget")
return th, tw
# ---------------------------------------------------------------------------------------------- resize
def _cubic(x: float, a: float = -0.5) -> float:
x = abs(x)
if x < 1.0:
return ((a + 2.0) * x - (a + 3.0)) * x * x + 1.0
if x < 2.0:
return (((x - 5.0) * x + 8.0) * x - 4.0) * a
return 0.0
def aa_int16_weights(in_size: int, out_size: int) -> tuple[np.ndarray, np.ndarray, int]:
"""Antialiased bicubic taps for one axis, as ATen computes them for uint8 input.
Returns (xmin [out] int, weights [out, taps] int32 (0-padded), precision)."""
scale = in_size / out_size # float64, align_corners=False, no explicit scale factor
support = 2.0 * scale if scale >= 1.0 else 2.0
invscale = 1.0 / scale if scale >= 1.0 else 1.0
taps = int(math.ceil(support)) * 2 + 1
xmin = np.zeros(out_size, dtype=np.int64)
wts = np.zeros((out_size, taps), dtype=np.float64)
for i in range(out_size):
center = scale * (i + 0.5)
lo = max(int(center - support + 0.5), 0)
size = min(int(center + support + 0.5), in_size) - lo
w = [_cubic((j + lo - center + 0.5) * invscale) for j in range(size)]
tot = sum(w)
if tot != 0.0:
w = [v / tot for v in w]
xmin[i] = lo
wts[i, :size] = w
wt_max = float(wts.max())
precision = 0
while precision < 22:
if int(0.5 + wt_max * (1 << (precision + 1))) >= (1 << 15):
break
precision += 1
s = float(1 << precision)
# round half away from zero (what both std::round and Pillow do; all three rounding conventions agree on
# every weight we have seen, this is the documented one)
wi = np.where(wts >= 0, np.floor(wts * s + 0.5), np.ceil(wts * s - 0.5)).astype(np.int32)
return xmin, wi, precision
def _resize_axis_u8(img: np.ndarray, axis: int, out_size: int) -> np.ndarray:
in_size = img.shape[axis]
xmin, wts, precision = aa_int16_weights(in_size, out_size)
src = np.moveaxis(img, axis, 0).astype(np.int32) # [in, ...]
acc = np.full((out_size,) + src.shape[1:], 1 << (precision - 1), dtype=np.int32)
taps = wts.shape[1]
for t in range(taps):
w = wts[:, t]
if not w.any():
continue
idx = np.minimum(xmin + t, in_size - 1) # taps beyond the range have weight 0
acc += src[idx] * w.reshape((-1,) + (1,) * (src.ndim - 1))
out = np.clip(acc >> precision, 0, 255).astype(np.uint8)
return np.moveaxis(out, 0, axis)
def resize_bicubic_aa_u8(img: np.ndarray, height: int, width: int) -> np.ndarray:
"""img HWC uint8 -> [height, width, C] uint8, bit-identical to torchvision's uint8 antialiased bicubic."""
if img.shape[0] == height and img.shape[1] == width:
return img
out = img
if img.shape[1] != width:
out = _resize_axis_u8(out, 1, width) # width first
if img.shape[0] != height:
out = _resize_axis_u8(out, 0, height) # then height
return out
# ---------------------------------------------------------------------------------------------- patches
@dataclass
class VisionInputs:
"""Everything the `vision_2520` function wants, plus what the text side needs."""
patches: np.ndarray # [1, 2520, 768] float16, integer pixel values 0..255, cell-major, zero padded
pos_x: np.ndarray # [1, 2520] int32, padding = 0
pos_y: np.ndarray # [1, 2520] int32, padding = 0
cos: np.ndarray # [1, 1, 2520, 64] float16 axial RoPE
sin: np.ndarray # [1, 1, 2520, 64] float16
patch_mask: np.ndarray # [1, 2520] float16, 1 = real patch, 0 = padding
n_soft: int # number of valid soft tokens (cells)
grid: tuple[int, int] # (patch rows, patch cols)
resized: tuple[int, int] # (height, width) px
def rope_tables(pos_x: np.ndarray, pos_y: np.ndarray, dtype=np.float16) -> tuple[np.ndarray, np.ndarray]:
"""Axial RoPE: channels 0..31 rotate by x (16 freqs, duplicated), 32..63 by y. float32 maths, cast to dtype."""
inv = 1.0 / (ROPE_THETA ** (np.arange(0, ROPE_FREQS * 2, 2, dtype=np.float32) / (ROPE_FREQS * 2))) # [16]
fx = pos_x.astype(np.float32)[:, None] * inv[None]
fy = pos_y.astype(np.float32)[:, None] * inv[None]
f = np.concatenate([fx, fx, fy, fy], -1) # [N, 64]
return np.cos(f).astype(dtype), np.sin(f).astype(dtype)
def patchify_cell_major(rgb: np.ndarray, max_patches: int = MAX_PATCHES, rope_dtype=np.float16,
patch_dtype=np.float16) -> VisionInputs:
"""rgb: [H, W, 3] uint8 (pixel values 0..255) with H, W multiples of 48. The patches keep the integer values
(the graph takes 0..255). dtypes: float16 for the Core AI functions, float32 for the fp32 correctness gates."""
if rgb.dtype != np.uint8:
raise ValueError("patchify_cell_major takes uint8 pixels (0..255)")
H, W = rgb.shape[:2]
ph, pw = H // PATCH, W // PATCH
kh, kw = ph // POOL, pw // POOL
n = kh * kw
p = rgb.reshape(kh, POOL, PATCH, kw, POOL, PATCH, 3) # ky dy py kx dx px c
p = p.transpose(0, 3, 1, 4, 2, 5, 6).reshape(n * POOL * POOL, PATCH * PATCH * 3) # (ky kx dy dx) x (py px c)
ky, kx, dy, dx = np.meshgrid(np.arange(kh), np.arange(kw), np.arange(POOL), np.arange(POOL), indexing="ij")
x = (kx * POOL + dx).ravel()
y = (ky * POOL + dy).ravel()
patches = np.zeros((1, max_patches, PATCH * PATCH * 3), patch_dtype)
patches[0, : n * 9] = p
pos_x = np.zeros((1, max_patches), np.int32)
pos_y = np.zeros((1, max_patches), np.int32)
pos_x[0, : n * 9] = x
pos_y[0, : n * 9] = y
mask = np.zeros((1, max_patches), np.float16)
mask[0, : n * 9] = 1
cos, sin = rope_tables(pos_x[0], pos_y[0], rope_dtype)
return VisionInputs(patches, pos_x, pos_y, cos[None, None], sin[None, None], mask, n, (ph, pw), (H, W))
RESCALE = np.float32(1.0 / 255.0) # upstream pixel_values = patches * RESCALE (the graph folds this scale in)
def prepare_vision_inputs(image, apply_exif_orientation: bool = False, dtype=np.float16) -> VisionInputs:
"""path or PIL image -> VisionInputs (decode, size, exact resize, cell-major patches of pixel values 0..255)."""
rgb = load_rgb(image, apply_exif_orientation)
th, tw = target_size(rgb.shape[0], rgb.shape[1])
rgb = resize_bicubic_aa_u8(rgb, th, tw)
return patchify_cell_major(rgb, rope_dtype=dtype, patch_dtype=dtype)
def image_sequence(n_soft: int, bos: int, boi: int, image: int, eoi: int, eos: int) -> list[int]:
"""Token ids of a single image input, as the upstream processor builds them (no prompt)."""
return [bos, boi] + [image] * n_soft + [eoi, eos]
|