spark_model/
vision_preprocess_glm.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GLM-5.3 (`Glm5NextImageProcessor`) image geometry: `smart_resize` to a
4//! `patch × merge`-aligned canvas, aspect-preserving fit, zero pad.
5//!
6//! Split from `vision_preprocess.rs` for the file-size cap, and because the
7//! resize POLICY differs from the Qwen family's in a way that is not a
8//! parameter: Qwen resizes straight onto the snapped canvas (accepting a
9//! sub-grid-unit aspect distortion), GLM fits the content inside it and pads
10//! the remainder. Two policies, two functions.
11
12use avarok_core::config::VisionConfig;
13use image::RgbImage;
14
15/// Qwen2-VL-family `smart_resize`, which GLM inherits: snap each side to the
16/// nearest multiple of `factor`, then rescale by the area bound if the snapped
17/// canvas violates it.
18///
19/// `factor = patch_size * merge_size` (28 for GLM), and the bounds are AREAS in
20/// pixels. Returns `(h_bar, w_bar)`, both multiples of `factor` and at least
21/// one factor each.
22///
23/// Note the asymmetry, which is the reference's and not a simplification: the
24/// initial snap ROUNDS (so a 360-pixel side at factor 28 becomes 364, i.e. the
25/// canvas may be slightly larger than the input), while the over-budget branch
26/// FLOORS and the under-budget branch CEILS.
27pub fn smart_resize(h: u32, w: u32, factor: u32, min_pixels: u64, max_pixels: u64) -> (u32, u32) {
28    let f = factor.max(1) as f64;
29    let (hf, wf) = (h.max(1) as f64, w.max(1) as f64);
30    let snap = |v: f64| ((v / f).round() as u32).max(1) * factor;
31    let (mut h_bar, mut w_bar) = (snap(hf), snap(wf));
32
33    let area = |a: u32, b: u32| u64::from(a) * u64::from(b);
34    if area(h_bar, w_bar) > max_pixels && max_pixels > 0 {
35        let beta = (hf * wf / max_pixels as f64).sqrt();
36        h_bar = (((hf / beta) / f).floor() as u32).max(1) * factor;
37        w_bar = (((wf / beta) / f).floor() as u32).max(1) * factor;
38    } else if area(h_bar, w_bar) < min_pixels {
39        let beta = (min_pixels as f64 / (hf * wf)).sqrt();
40        h_bar = (((hf * beta) / f).ceil() as u32).max(1) * factor;
41        w_bar = (((wf * beta) / f).ceil() as u32).max(1) * factor;
42    }
43    (h_bar, w_bar)
44}
45
46/// The area bounds this checkpoint permits, in pixels.
47///
48/// GLM states its budget in MERGED TOKENS (`min_image_tokens = 16`,
49/// `max_image_tokens = 8000`) where the Qwen family states a pixel area, so the
50/// conversion is `tokens × factor²` — one merged token covers exactly one
51/// `factor × factor` pixel block. An operator `--vision-max-pixels` still wins
52/// when it is the tighter of the two: the flag exists to bound memory, and a
53/// checkpoint may not raise its way past it.
54///
55/// ★ The ENCODER'S OWN CAPACITY is the third bound, and it is not optional.
56/// `GlmVit` sizes every buffer from `derive_max_patches(vcfg.max_pixels,
57/// patch_size)`, so a host-side budget derived only from the checkpoint's token
58/// count would hand it images it cannot hold: GLM declares 8000 merged tokens,
59/// which is 32000 unmerged patches, against an encoder that allocates 6400 rows
60/// when nothing else bounds it. That produces a refused request, not a wrong
61/// answer — but it is the same class of defect the `max_pixels` field exists to
62/// remove, two sides of the pipeline deriving a bound independently and
63/// agreeing only by luck. Both now call the SAME function.
64pub fn pixel_bounds(vcfg: &VisionConfig, operator_max: Option<usize>) -> (u64, u64) {
65    let factor = (vcfg.patch_size * vcfg.spatial_merge_size).max(1) as u64;
66    let per_token = factor * factor;
67    let min_pixels = vcfg.min_image_tokens as u64 * per_token;
68    let model_max = vcfg.max_image_tokens as u64 * per_token;
69    let (encoder_patches, _) = crate::layers::vision_encoder::enc_impl::init::derive_max_patches(
70        vcfg.max_pixels.or(operator_max),
71        vcfg.patch_size,
72    );
73    let encoder_max =
74        encoder_patches as u64 * (vcfg.patch_size.max(1) * vcfg.patch_size.max(1)) as u64;
75    let mut max_pixels = encoder_max;
76    if model_max > 0 {
77        max_pixels = max_pixels.min(model_max);
78    }
79    if let Some(p) = operator_max.filter(|&p| p > 0) {
80        max_pixels = max_pixels.min(p as u64);
81    }
82    (min_pixels, max_pixels.max(per_token))
83}
84
85/// Fit `img` inside the `smart_resize` canvas preserving aspect, then zero-pad
86/// right/bottom to the canvas exactly. Returns the padded canvas.
87///
88/// ★ VERIFIED against the reference's own `pixel_values` (2026-09-22), not
89/// assumed. Two properties were read straight out of the golden for image c
90/// (640x360 -> a 644x364 canvas), and both had a plausible alternative:
91///
92/// 1. **The pad is BLACK PIXELS, i.e. it precedes normalization.** Padded
93///    cells in the golden hold exactly `(0 - mean)/std` =
94///    `(-1.7926, -1.7520, -1.4801)`, not `0.0`. `REFERENCE.md` §5 recorded
95///    `tvF.pad(..., fill=0)` without pinning it relative to
96///    `rescale`/`normalize`; this settles it.
97/// 2. **The fit NEVER UPSCALES.** The golden's content occupies rows 0..360
98///    and columns 0..640 — the source dimensions, unresampled — with the
99///    remaining 4 rows and 4 columns padded. An aspect-fit that scaled up to
100///    fill the canvas (scale 1.00625 here) would have produced a 362x644
101///    content box and only 2 pad rows. Every sampled pixel of the golden
102///    matches the raw PNG normalized in place, to 4e-4 (float32 storage).
103///
104/// `tests/glm_vision_preprocess_pin.rs` pins both against the fixtures.
105pub fn resize_and_pad(
106    img: &RgbImage,
107    vcfg: &VisionConfig,
108    operator_max: Option<usize>,
109) -> RgbImage {
110    let factor = (vcfg.patch_size * vcfg.spatial_merge_size) as u32;
111    let (min_pixels, max_pixels) = pixel_bounds(vcfg, operator_max);
112    let (target_h, target_w) =
113        smart_resize(img.height(), img.width(), factor, min_pixels, max_pixels);
114
115    // Largest scale that keeps BOTH sides inside the canvas, and never above
116    // 1.0 — `smart_resize` ROUNDS to the grid, so its canvas is routinely a
117    // few pixels LARGER than the input, and filling it would resample an image
118    // that needs no resampling at all.
119    let scale = (target_h as f64 / img.height().max(1) as f64)
120        .min(target_w as f64 / img.width().max(1) as f64)
121        .min(1.0);
122    let content_h = ((img.height() as f64 * scale).round() as u32).clamp(1, target_h);
123    let content_w = ((img.width() as f64 * scale).round() as u32).clamp(1, target_w);
124
125    // At scale 1.0 the content IS the source: return its pixels untouched
126    // rather than running them through a filter that is only approximately an
127    // identity. CatmullRom is the `image` crate's closest match to PIL BICUBIC,
128    // which is what the HF processors resample with.
129    let fitted = if (content_h, content_w) == (img.height(), img.width()) {
130        img.clone()
131    } else {
132        image::imageops::resize(
133            img,
134            content_w,
135            content_h,
136            image::imageops::FilterType::CatmullRom,
137        )
138    };
139    if content_h == target_h && content_w == target_w {
140        return fitted;
141    }
142    let mut canvas = RgbImage::new(target_w, target_h);
143    image::imageops::replace(&mut canvas, &fitted, 0, 0);
144    canvas
145}
146
147/// Index of the patch at `(patch_row, patch_col)` in the 2×2-block-major
148/// stream the GLM tower expects.
149///
150/// `((h_block * w_blocks + w_block) * merge + row_in_block) * merge +
151/// col_in_block` — the flattening of `reshape(h/m, m, w/m, m).transpose(1, 2)`.
152/// The 2×2 conv downsample later folds four CONSECUTIVE tokens into one output
153/// row, which is correct only under exactly this order.
154pub fn block_major_patch_index(
155    patch_row: usize,
156    patch_col: usize,
157    grid_w: usize,
158    merge: usize,
159) -> usize {
160    let m = merge.max(1);
161    let w_blocks = grid_w / m;
162    (((patch_row / m) * w_blocks + patch_col / m) * m + patch_row % m) * m + patch_col % m
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    /// The golden's third image, whose numbers came out of the reference run:
170    /// 640×360 at factor 28 becomes a 644×364 canvas → a 46×26 patch grid →
171    /// 23×13 = 299 merged tokens.
172    #[test]
173    fn smart_resize_reproduces_the_golden_non_square_canvas() {
174        let (h, w) = smart_resize(360, 640, 28, 16 * 784, 8000 * 784);
175        assert_eq!((h, w), (364, 644));
176        assert_eq!((h / 14) * (w / 14), 26 * 46);
177        assert_eq!((h / 28) * (w / 28), 299);
178    }
179
180    /// A square 448 input is already aligned: the canvas is itself, 32×32
181    /// patches, 256 merged tokens — the other two golden images.
182    #[test]
183    fn an_aligned_square_is_left_alone() {
184        assert_eq!(smart_resize(448, 448, 28, 16 * 784, 8000 * 784), (448, 448));
185    }
186
187    /// Over budget FLOORS onto the grid, so the result is inside the bound —
188    /// a rounding step that went the other way would hand the encoder more
189    /// patches than its buffers hold.
190    #[test]
191    fn an_over_budget_image_is_floored_inside_the_bound() {
192        let max = 100u64 * 784; // 100 merged tokens
193        let (h, w) = smart_resize(4000, 4000, 28, 16 * 784, max);
194        assert_eq!(h % 28, 0);
195        assert_eq!(w % 28, 0);
196        assert!(
197            u64::from(h) * u64::from(w) <= max,
198            "{h}x{w} exceeds the {max}px budget"
199        );
200    }
201
202    /// Under budget CEILS up to the minimum, and the minimum is expressed in
203    /// tokens, so a 1×1 image still becomes a legal multi-patch grid.
204    #[test]
205    fn a_tiny_image_is_raised_to_the_minimum_token_count() {
206        let min = 16u64 * 784;
207        let (h, w) = smart_resize(1, 1, 28, min, 8000 * 784);
208        assert!(u64::from(h) * u64::from(w) >= min, "{h}x{w} under {min}");
209        assert_eq!((h % 28, w % 28), (0, 0));
210    }
211
212    fn glm_cfg() -> VisionConfig {
213        VisionConfig {
214            patch_size: 14,
215            spatial_merge_size: 2,
216            min_image_tokens: 16,
217            max_image_tokens: 8000,
218            ..VisionConfig::default()
219        }
220    }
221
222    /// A tighter operator flag lowers the budget; a looser one raises it only
223    /// as far as the ENCODER can follow, never to the checkpoint's declared
224    /// 8000 merged tokens.
225    ///
226    /// 🪤 The flag moves the encoder too — `vcfg.max_pixels` is what
227    /// `GlmVit::new` allocates from — so "looser flag, unchanged bound" would
228    /// be the WRONG assertion here. What must hold is that the resolved bound
229    /// never exceeds what the encoder built for that same flag can hold.
230    #[test]
231    fn the_operator_bound_moves_the_budget_only_as_far_as_the_encoder_follows() {
232        let vcfg = glm_cfg();
233        let (_, tight) = pixel_bounds(&vcfg, Some(512 * 512));
234        assert!(
235            tight <= 512 * 512,
236            "a tighter flag must lower the bound: {tight}"
237        );
238        assert_eq!(tight / (14 * 14), 1337, "512x512 at patch 14 is 1337 rows");
239
240        let (_, loose) = pixel_bounds(&vcfg, Some(64 * 1024 * 1024));
241        let (_, none) = pixel_bounds(&vcfg, None);
242        assert!(
243            loose > none,
244            "a looser flag may raise the bound: {loose} vs {none}"
245        );
246        // ...but only to the encoder's ceiling, not to the checkpoint's ask.
247        assert_eq!(
248            loose / (14 * 14),
249            16_384,
250            "the encoder's CEILING_MAX_PATCHES"
251        );
252        assert!(
253            loose < 8000 * 784,
254            "never as far as the declared token budget"
255        );
256    }
257
258    /// The bound the checkpoint DECLARES is bigger than any encoder Atlas
259    /// allocates, so the host budget has to come down to the encoder's — this
260    /// is the one that would otherwise refuse large images at the GPU instead
261    /// of resizing them on the host.
262    #[test]
263    fn the_encoder_capacity_caps_the_checkpoints_own_token_budget() {
264        let vcfg = glm_cfg();
265        let (_, resolved) = pixel_bounds(&vcfg, None);
266        let declared = 8000u64 * 784;
267        assert!(
268            resolved < declared,
269            "GLM declares {declared}px (8000 merged tokens); the encoder's default \
270             allocation cannot hold that, so the host bound must be lower, got {resolved}"
271        );
272        // 6400 unmerged patches at patch 14 — exactly what `derive_max_patches`
273        // allocates when nothing else bounds the image.
274        assert_eq!(resolved / (14 * 14), 6400);
275        // And the resolved area really does fit: a square image at that bound
276        // produces no more patches than the encoder holds.
277        let side = (resolved as f64).sqrt() as u64;
278        assert!((side / 14) * (side / 14) <= 6400);
279    }
280
281    /// A checkpoint that declares no token budget still gets the encoder's.
282    #[test]
283    fn a_checkpoint_without_a_token_budget_still_gets_a_bound() {
284        let vcfg = VisionConfig {
285            max_image_tokens: 0,
286            ..glm_cfg()
287        };
288        let (_, resolved) = pixel_bounds(&vcfg, None);
289        assert_eq!(resolved / (14 * 14), 6400);
290        let (_, with_flag) = pixel_bounds(&vcfg, Some(512 * 512));
291        assert!(with_flag <= 512 * 512);
292        assert_eq!(with_flag / (14 * 14), 1337);
293    }
294
295    /// The canvas may be LARGER than the input (smart_resize rounds up), and
296    /// the content must not be stretched to fill it — the golden's image c has
297    /// its 360x640 pixels unresampled inside a 364x644 canvas.
298    #[test]
299    fn a_canvas_larger_than_the_input_pads_rather_than_upscales() {
300        let vcfg = glm_cfg();
301        let img = RgbImage::new(640, 360);
302        let out = resize_and_pad(&img, &vcfg, None);
303        assert_eq!(
304            (out.height(), out.width()),
305            (364, 644),
306            "the smart_resize canvas"
307        );
308        // Nothing to assert about pixel values on a blank image; the geometry
309        // claim is pinned end-to-end against the reference fixtures in
310        // `tests/glm_vision_preprocess_pin.rs`.
311        assert_eq!((out.height() / 14) * (out.width() / 14), 26 * 46);
312    }
313
314    /// Block-major is a PERMUTATION of raster over the same grid, and the
315    /// first four indices are one 2×2 block rather than one row of four.
316    #[test]
317    fn block_major_index_is_a_permutation_grouping_2x2_blocks() {
318        let (gh, gw, m) = (26usize, 46usize, 2usize);
319        let mut seen = vec![usize::MAX; gh * gw];
320        for ph in 0..gh {
321            for pw in 0..gw {
322                let i = block_major_patch_index(ph, pw, gw, m);
323                assert!(i < gh * gw, "index {i} out of range");
324                assert_eq!(seen[i], usize::MAX, "index {i} used twice");
325                seen[i] = ph * gw + pw;
326            }
327        }
328        // Tokens 0..4 are the 2×2 block at the origin, in (row, col) order.
329        assert_eq!(&seen[0..4], &[0, 1, gw, gw + 1]);
330        // Token 4 starts the NEXT block along w, not the next column.
331        assert_eq!(seen[4], 2);
332    }
333
334    /// The whole reason the order is declared: raster and block-major agree
335    /// only on a grid ONE block wide. Any real image is wider, and there the
336    /// two orders disagree from the third patch onward — which is why a port
337    /// that picks the wrong one produces a plausible embedding, not an error.
338    #[test]
339    fn block_major_differs_from_raster_on_any_real_grid() {
340        // One block wide (grid_w = 2): the two orders coincide.
341        for (row, col) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
342            assert_eq!(
343                block_major_patch_index(row, col, 2, 2),
344                row * 2 + col,
345                "1 block wide must equal raster at ({row}, {col})"
346            );
347        }
348        // Two blocks wide: patch (0, 2) is raster index 2 but block-major 4,
349        // because tokens 2 and 3 belong to the FIRST block's second row.
350        assert_eq!(block_major_patch_index(0, 2, 4, 2), 4);
351        assert_eq!(block_major_patch_index(1, 0, 4, 2), 2);
352    }
353}