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}