metrale_model_layers/
vision_preprocess_glm.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: GLM image geometry: `smart_resize` to a canvas aligned to
4//! `patch_size × spatial_merge_size`, an aspect-preserving fit that never
5//! upscales, a black pad to the canvas, and the 2×2-block-major patch order.
6//! `vision_preprocess` uses it when `VisionConfig::block_major_patches` is set.
7//!
8//! Owner: model-layers (vision input).
9//! Invariants: none beyond the types.
10
11use image::RgbImage;
12use metrale_config::VisionConfig;
13
14/// 2026-09-25: Snap each side to the nearest multiple of `factor`, then rescale
15/// by the area bounds if the snapped canvas violates them.
16///
17/// `factor = patch_size * merge_size`, and the bounds are areas in pixels.
18/// Returns `(h_bar, w_bar)`, both multiples of `factor` and at least one
19/// factor each. The initial snap rounds (so a 360-pixel side at factor 28
20/// becomes 364, and the canvas may be larger than the input), the over-budget
21/// branch floors, and the under-budget branch ceils.
22pub fn smart_resize(h: u32, w: u32, factor: u32, min_pixels: u64, max_pixels: u64) -> (u32, u32) {
23    let f = factor.max(1) as f64;
24    let (hf, wf) = (h.max(1) as f64, w.max(1) as f64);
25    let snap = |v: f64| ((v / f).round() as u32).max(1) * factor;
26    let (mut h_bar, mut w_bar) = (snap(hf), snap(wf));
27
28    let area = |a: u32, b: u32| u64::from(a) * u64::from(b);
29    if area(h_bar, w_bar) > max_pixels && max_pixels > 0 {
30        let beta = (hf * wf / max_pixels as f64).sqrt();
31        h_bar = (((hf / beta) / f).floor() as u32).max(1) * factor;
32        w_bar = (((wf / beta) / f).floor() as u32).max(1) * factor;
33    } else if area(h_bar, w_bar) < min_pixels {
34        let beta = (min_pixels as f64 / (hf * wf)).sqrt();
35        h_bar = (((hf * beta) / f).ceil() as u32).max(1) * factor;
36        w_bar = (((wf * beta) / f).ceil() as u32).max(1) * factor;
37    }
38    (h_bar, w_bar)
39}
40
41/// 2026-09-25: `(min_pixels, max_pixels)` for `smart_resize`, as pixel areas.
42///
43/// GLM states its budget in merged tokens (`min_image_tokens`,
44/// `max_image_tokens`), converted as `tokens × factor²`: one merged token
45/// covers one `factor × factor` pixel block. `max_pixels` is the least of:
46/// - the encoder's capacity, from `derive_max_patches`, the function `GlmVit`
47///   sizes its buffers with;
48/// - the declared token budget, when nonzero;
49/// - `operator_max`, when given;
50///
51/// and never below one merged token. With no bound the encoder holds 6400
52/// patches (`FALLBACK_MAX_PATCHES`), while GLM's default budget of 8000 merged
53/// tokens is 32000 patches, so the encoder term binds.
54pub fn pixel_bounds(vcfg: &VisionConfig, operator_max: Option<usize>) -> (u64, u64) {
55    let factor = (vcfg.patch_size * vcfg.spatial_merge_size).max(1) as u64;
56    let per_token = factor * factor;
57    let min_pixels = vcfg.min_image_tokens as u64 * per_token;
58    let model_max = vcfg.max_image_tokens as u64 * per_token;
59    let (encoder_patches, _) = crate::layers::vision_encoder::enc_impl::init::derive_max_patches(
60        vcfg.max_pixels.or(operator_max),
61        vcfg.patch_size,
62    );
63    let encoder_max =
64        encoder_patches as u64 * (vcfg.patch_size.max(1) * vcfg.patch_size.max(1)) as u64;
65    let mut max_pixels = encoder_max;
66    if model_max > 0 {
67        max_pixels = max_pixels.min(model_max);
68    }
69    if let Some(p) = operator_max.filter(|&p| p > 0) {
70        max_pixels = max_pixels.min(p as u64);
71    }
72    (min_pixels, max_pixels.max(per_token))
73}
74
75/// 2026-09-25: Fit `img` inside the `smart_resize` canvas preserving aspect,
76/// then zero-pad right/bottom to the canvas exactly. Returns the padded canvas.
77///
78/// - The pad is black pixels applied before normalisation, so padded cells
79///   normalise to `(0 - mean)/std`, not `0.0`.
80/// - The fit never upscales: image c of the reference fixtures (640x360) sits
81///   unresampled in a 644x364 canvas.
82///
83/// `tests/glm_vision_preprocess_pin.rs` compares the output with the
84/// reference's `pixel_values` for the fixture images.
85pub fn resize_and_pad(
86    img: &RgbImage,
87    vcfg: &VisionConfig,
88    operator_max: Option<usize>,
89) -> RgbImage {
90    let factor = (vcfg.patch_size * vcfg.spatial_merge_size) as u32;
91    let (min_pixels, max_pixels) = pixel_bounds(vcfg, operator_max);
92    let (target_h, target_w) =
93        smart_resize(img.height(), img.width(), factor, min_pixels, max_pixels);
94
95    // 2026-09-25: The largest scale that keeps both sides inside the canvas,
96    // capped at 1.0: `smart_resize` rounds to the grid, so its canvas is often a
97    // few pixels larger than the input.
98    let scale = (target_h as f64 / img.height().max(1) as f64)
99        .min(target_w as f64 / img.width().max(1) as f64)
100        .min(1.0);
101    let content_h = ((img.height() as f64 * scale).round() as u32).clamp(1, target_h);
102    let content_w = ((img.width() as f64 * scale).round() as u32).clamp(1, target_w);
103
104    // 2026-09-25: When the content size equals the source size, the source
105    // pixels are used as they are rather than passed through the filter.
106    let fitted = if (content_h, content_w) == (img.height(), img.width()) {
107        img.clone()
108    } else {
109        image::imageops::resize(
110            img,
111            content_w,
112            content_h,
113            image::imageops::FilterType::CatmullRom,
114        )
115    };
116    if content_h == target_h && content_w == target_w {
117        return fitted;
118    }
119    let mut canvas = RgbImage::new(target_w, target_h);
120    image::imageops::replace(&mut canvas, &fitted, 0, 0);
121    canvas
122}
123
124/// 2026-09-25: Index of the patch at `(patch_row, patch_col)` in the
125/// 2×2-block-major stream the GLM tower expects.
126///
127/// `((h_block * w_blocks + w_block) * merge + row_in_block) * merge +
128/// col_in_block`. The tower's 2×2 downsample folds four consecutive tokens
129/// into one block, which is correct only in this order.
130pub fn block_major_patch_index(
131    patch_row: usize,
132    patch_col: usize,
133    grid_w: usize,
134    merge: usize,
135) -> usize {
136    let m = merge.max(1);
137    let w_blocks = grid_w / m;
138    (((patch_row / m) * w_blocks + patch_col / m) * m + patch_row % m) * m + patch_col % m
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    /// 2026-09-25: Fixture image c: 640×360 at factor 28 becomes a 644×364
146    /// canvas, a 46×26 patch grid, and 23×13 = 299 merged tokens.
147    #[test]
148    fn smart_resize_reproduces_the_golden_non_square_canvas() {
149        let (h, w) = smart_resize(360, 640, 28, 16 * 784, 8000 * 784);
150        assert_eq!((h, w), (364, 644));
151        assert_eq!((h / 14) * (w / 14), 26 * 46);
152        assert_eq!((h / 28) * (w / 28), 299);
153    }
154
155    /// 2026-09-25: A square 448 input, like fixture images a and b, is already
156    /// aligned: the canvas is itself, 32×32 patches.
157    #[test]
158    fn an_aligned_square_is_left_alone() {
159        assert_eq!(smart_resize(448, 448, 28, 16 * 784, 8000 * 784), (448, 448));
160    }
161
162    /// 2026-09-25: Over budget floors onto the grid, so the result is inside the
163    /// bound.
164    #[test]
165    fn an_over_budget_image_is_floored_inside_the_bound() {
166        let max = 100u64 * 784;
167        let (h, w) = smart_resize(4000, 4000, 28, 16 * 784, max);
168        assert_eq!(h % 28, 0);
169        assert_eq!(w % 28, 0);
170        assert!(
171            u64::from(h) * u64::from(w) <= max,
172            "{h}x{w} exceeds the {max}px budget"
173        );
174    }
175
176    /// 2026-09-25: Under budget ceils up to the minimum, which is in tokens, so a
177    /// 1×1 image becomes a multi-patch grid.
178    #[test]
179    fn a_tiny_image_is_raised_to_the_minimum_token_count() {
180        let min = 16u64 * 784;
181        let (h, w) = smart_resize(1, 1, 28, min, 8000 * 784);
182        assert!(u64::from(h) * u64::from(w) >= min, "{h}x{w} under {min}");
183        assert_eq!((h % 28, w % 28), (0, 0));
184    }
185
186    fn glm_cfg() -> VisionConfig {
187        VisionConfig {
188            patch_size: 14,
189            spatial_merge_size: 2,
190            min_image_tokens: 16,
191            max_image_tokens: 8000,
192            ..VisionConfig::default()
193        }
194    }
195
196    /// 2026-09-25: A tighter operator bound lowers the budget; a looser one raises
197    /// it only to the encoder's ceiling (`CEILING_MAX_PATCHES`), never to the
198    /// declared 8000 merged tokens. `pixel_bounds` sizes the encoder term from
199    /// the same operator bound here (`vcfg.max_pixels` is `None`).
200    #[test]
201    fn the_operator_bound_moves_the_budget_only_as_far_as_the_encoder_follows() {
202        let vcfg = glm_cfg();
203        let (_, tight) = pixel_bounds(&vcfg, Some(512 * 512));
204        assert!(
205            tight <= 512 * 512,
206            "a tighter flag must lower the bound: {tight}"
207        );
208        assert_eq!(tight / (14 * 14), 1337, "512x512 at patch 14 is 1337 rows");
209
210        let (_, loose) = pixel_bounds(&vcfg, Some(64 * 1024 * 1024));
211        let (_, none) = pixel_bounds(&vcfg, None);
212        assert!(
213            loose > none,
214            "a looser flag may raise the bound: {loose} vs {none}"
215        );
216        // 2026-09-25: Only to the encoder's ceiling, not to the declared budget.
217        assert_eq!(
218            loose / (14 * 14),
219            16_384,
220            "the encoder's CEILING_MAX_PATCHES"
221        );
222        assert!(
223            loose < 8000 * 784,
224            "never as far as the declared token budget"
225        );
226    }
227
228    /// 2026-09-25: The declared budget exceeds the encoder's default capacity, so
229    /// the host bound comes down to the encoder's.
230    #[test]
231    fn the_encoder_capacity_caps_the_checkpoints_own_token_budget() {
232        let vcfg = glm_cfg();
233        let (_, resolved) = pixel_bounds(&vcfg, None);
234        let declared = 8000u64 * 784;
235        assert!(
236            resolved < declared,
237            "GLM declares {declared}px (8000 merged tokens); the encoder's default \
238             allocation cannot hold that, so the host bound must be lower, got {resolved}"
239        );
240        // 2026-09-25: `FALLBACK_MAX_PATCHES`, what `derive_max_patches` returns
241        // when nothing bounds the image.
242        assert_eq!(resolved / (14 * 14), 6400);
243        // 2026-09-25: A square image at that bound yields no more patches than
244        // the encoder holds.
245        let side = (resolved as f64).sqrt() as u64;
246        assert!((side / 14) * (side / 14) <= 6400);
247    }
248
249    /// 2026-09-25: A checkpoint that declares no token budget still gets the
250    /// encoder's.
251    #[test]
252    fn a_checkpoint_without_a_token_budget_still_gets_a_bound() {
253        let vcfg = VisionConfig {
254            max_image_tokens: 0,
255            ..glm_cfg()
256        };
257        let (_, resolved) = pixel_bounds(&vcfg, None);
258        assert_eq!(resolved / (14 * 14), 6400);
259        let (_, with_flag) = pixel_bounds(&vcfg, Some(512 * 512));
260        assert!(with_flag <= 512 * 512);
261        assert_eq!(with_flag / (14 * 14), 1337);
262    }
263
264    /// 2026-09-25: The canvas may be larger than the input, and the content is
265    /// padded, not stretched, to fill it.
266    #[test]
267    fn a_canvas_larger_than_the_input_pads_rather_than_upscales() {
268        let vcfg = glm_cfg();
269        let img = RgbImage::new(640, 360);
270        let out = resize_and_pad(&img, &vcfg, None);
271        assert_eq!(
272            (out.height(), out.width()),
273            (364, 644),
274            "the smart_resize canvas"
275        );
276        // 2026-09-25: A blank image has no pixel values to check;
277        // `tests/glm_vision_preprocess_pin.rs` checks them against the reference.
278        assert_eq!((out.height() / 14) * (out.width() / 14), 26 * 46);
279    }
280
281    /// 2026-09-25: Block-major is a permutation of raster over the same grid, and
282    /// the first four indices are one 2×2 block rather than one row of four.
283    #[test]
284    fn block_major_index_is_a_permutation_grouping_2x2_blocks() {
285        let (gh, gw, m) = (26usize, 46usize, 2usize);
286        let mut seen = vec![usize::MAX; gh * gw];
287        for ph in 0..gh {
288            for pw in 0..gw {
289                let i = block_major_patch_index(ph, pw, gw, m);
290                assert!(i < gh * gw, "index {i} out of range");
291                assert_eq!(seen[i], usize::MAX, "index {i} used twice");
292                seen[i] = ph * gw + pw;
293            }
294        }
295        // 2026-09-25: Tokens 0..4 are the 2×2 block at the origin, in (row, col)
296        // order.
297        assert_eq!(&seen[0..4], &[0, 1, gw, gw + 1]);
298        // 2026-09-25: Token 4 starts the next block along w, not the next column.
299        assert_eq!(seen[4], 2);
300    }
301
302    /// 2026-09-25: Raster and block-major agree only on a grid one block wide;
303    /// on a wider grid they differ from the third patch on.
304    #[test]
305    fn block_major_differs_from_raster_on_any_real_grid() {
306        // 2026-09-25: One block wide (grid_w = 2): the two orders coincide.
307        for (row, col) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
308            assert_eq!(
309                block_major_patch_index(row, col, 2, 2),
310                row * 2 + col,
311                "1 block wide must equal raster at ({row}, {col})"
312            );
313        }
314        // 2026-09-25: Two blocks wide: patch (0, 2) is raster index 2 but
315        // block-major 4, because tokens 2 and 3 are the first block's second row.
316        assert_eq!(block_major_patch_index(0, 2, 4, 2), 4);
317        assert_eq!(block_major_patch_index(1, 0, 4, 2), 2);
318    }
319}