File size: 7,364 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
// 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); see ../../../NOTICE.
//
// Host-side preparation of the `vision_2520` inputs: target size (aspect-preserving, multiples of 48 px, at most
// 2520 patches of 16x16), the exact resize, 16x16x3 patches of INTEGER pixel values 0..255 (exact in Float16; the
// graph computes P - 127.5 with upstream's 2/255 scale folded into its first projection) in CELL-MAJOR order (the
// 9 patches of each 3x3 pooling cell contiguous, cells row-major), (x, y) patch positions, the axial-RoPE cos/sin
// tables and the patch mask. Mirrors python/eg2_image.py.

import Foundation

public struct VisionInputs: Sendable {
    public static let patch = 16
    public static let pool = 3
    public static let cellPx = 48
    public static let maxSoftTokens = 280
    public static let maxPatches = 2520
    public static let patchDim = 768          // 16 * 16 * 3
    public static let headDim = 64
    public static let ropeTheta = 100.0

    public var patches: [Float16]      // [2520 * 768], pixel values 0..255, zero padded
    public var posX: [Int32]           // [2520], 0 for padding
    public var posY: [Int32]           // [2520]
    public var cos: [Float16]          // [2520 * 64]
    public var sin: [Float16]          // [2520 * 64]
    public var patchMask: [Float16]    // [2520], 1 = patch, 0 = padding
    public var softTokenCount: Int     // n: valid rows of the vision output
    public var gridRows: Int           // patch rows
    public var gridCols: Int           // patch cols
    public var resizedHeight: Int
    public var resizedWidth: Int
}

public enum ImagePreprocessor {
    /// Gemma4 `get_aspect_ratio_preserving_size` (height, width), verbatim logic.
    public static func targetSize(height: Int, width: Int, maxPatches: Int = VisionInputs.maxPatches) throws -> (height: Int, width: Int) {
        let patch = VisionInputs.patch, cell = VisionInputs.cellPx
        let targetPx = Double(maxPatches * patch * patch)
        let factor = (targetPx / Double(height * width)).squareRoot()
        var th = Int((factor * Double(height) / Double(cell)).rounded(.down)) * cell
        var tw = Int((factor * Double(width) / Double(cell)).rounded(.down)) * cell
        let maxSide = (maxPatches / (VisionInputs.pool * VisionInputs.pool)) * cell
        if th == 0 && tw == 0 { throw EmbedderError(description: "image would resize to 0x0") }
        if th == 0 {
            th = cell
            tw = min(Int((Double(width) / Double(height)).rounded(.down)) * cell, maxSide)
        } else if tw == 0 {
            tw = cell
            th = min(Int((Double(height) / Double(width)).rounded(.down)) * cell, maxSide)
        }
        if Double(th * tw) > targetPx { throw EmbedderError(description: "size exceeds the patch budget") }
        return (th, tw)
    }

    /// Resized 8-bit RGB (interleaved, [height][width][3]) of a decoded image, exactly as the upstream processor
    /// produces it (bicubic antialiased on uint8, width pass then height pass).
    public static func resized(_ image: DecodedImage) throws -> DecodedImage {
        let (th, tw) = try targetSize(height: image.height, width: image.width)
        let px = ImageResampler.resize(image.rgb, width: image.width, height: image.height, channels: 3, toWidth: tw, toHeight: th)
        return DecodedImage(width: tw, height: th, rgb: px)
    }

    /// Decode (no EXIF orientation unless asked, alpha dropped, no colour management) + resize + patches.
    public static func prepare(url: URL, applyEXIFOrientation: Bool = false) throws -> VisionInputs {
        try prepare(try ImageDecoder.decodeRGB(url: url, applyEXIFOrientation: applyEXIFOrientation))
    }

    public static func prepare(_ decoded: DecodedImage) throws -> VisionInputs {
        let r = try resized(decoded)
        return patchify(resizedRGB: r.rgb, width: r.width, height: r.height)
    }

    /// `rgb`: [height][width][3] uint8 with height, width multiples of 48.
    public static func patchify(resizedRGB rgb: [UInt8], width W: Int, height H: Int) -> VisionInputs {
        let P = VisionInputs.patch, K = VisionInputs.pool, N = VisionInputs.maxPatches, D = VisionInputs.patchDim
        let ph = H / P, pw = W / P
        let kh = ph / K, kw = pw / K
        let n = kh * kw
        var patches = [Float16](repeating: 0, count: N * D)
        var posX = [Int32](repeating: 0, count: N)
        var posY = [Int32](repeating: 0, count: N)
        var mask = [Float16](repeating: 0, count: N)
        var idx = 0
        rgb.withUnsafeBufferPointer { sp in
            patches.withUnsafeMutableBufferPointer { pp in
                for ky in 0..<kh {
                    for kx in 0..<kw {
                        for dy in 0..<K {
                            for dx in 0..<K {
                                let x = kx * K + dx, y = ky * K + dy
                                posX[idx] = Int32(x)
                                posY[idx] = Int32(y)
                                mask[idx] = 1
                                let base = idx * D
                                var o = 0
                                for py in 0..<P {
                                    let row = sp.baseAddress! + ((y * P + py) * W + x * P) * 3
                                    for e in 0..<(P * 3) {
                                        pp[base + o] = Float16(row[e])   // 0...255, exact
                                        o += 1
                                    }
                                }
                                idx += 1
                            }
                        }
                    }
                }
            }
        }
        let (cos, sin) = ropeTables(posX: posX, posY: posY)
        return VisionInputs(patches: patches, posX: posX, posY: posY, cos: cos, sin: sin, patchMask: mask,
                            softTokenCount: n, gridRows: ph, gridCols: pw, resizedHeight: H, resizedWidth: W)
    }

    /// Axial RoPE tables [N, 64]: channels 0..31 rotate by x (16 frequencies, duplicated), 32..63 by y.
    /// float32 maths like upstream, cast to fp16.
    public static func ropeTables(posX: [Int32], posY: [Int32]) -> (cos: [Float16], sin: [Float16]) {
        let F = VisionInputs.headDim / 4   // 16
        var inv = [Float](repeating: 0, count: F)
        for i in 0..<F { inv[i] = Float(1.0 / pow(VisionInputs.ropeTheta, Double(2 * i) / Double(2 * F))) }
        let N = posX.count
        var cos = [Float16](repeating: 0, count: N * 64)
        var sin = [Float16](repeating: 0, count: N * 64)
        for p in 0..<N {
            let fx = Float(posX[p]), fy = Float(posY[p])
            for i in 0..<F {
                let ax = fx * inv[i], ay = fy * inv[i]
                let cx = Float16(cosf(ax)), sx = Float16(sinf(ax)), cy = Float16(cosf(ay)), sy = Float16(sinf(ay))
                let b = p * 64
                cos[b + i] = cx; cos[b + F + i] = cx; cos[b + 2 * F + i] = cy; cos[b + 3 * F + i] = cy
                sin[b + i] = sx; sin[b + F + i] = sx; sin[b + 2 * F + i] = sy; sin[b + 3 * F + i] = sy
            }
        }
        return (cos, sin)
    }
}