iannedev's picture
v1.1-images
80c945e verified
Raw History Blame Contribute Delete
11.5 kB
# 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]