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]