metrale_model_layers/
vision_preprocess.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: CPU-side image preprocessing for the vision encoder. Decodes a
4//! base64 image, resizes it onto a grid of `patch_size × spatial_merge_size`
5//! cells, normalises it with the checkpoint's mean and std, and lays the
6//! patches out as a flat `f32` tensor.
7//!
8//! Owner: model-layers (vision input).
9//! Invariants: none beyond the types.
10
11use anyhow::{Context, Result, bail};
12use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits};
13use metrale_config::VisionConfig;
14
15/// 2026-09-25: Normalisation stats of the video path (`video_preprocess.rs`).
16/// The image path reads `VisionConfig::image_mean` / `image_std` instead.
17pub(crate) const MEAN: [f32; 3] = [0.5, 0.5, 0.5];
18pub(crate) const STD: [f32; 3] = [0.5, 0.5, 0.5];
19
20/// 2026-09-25: Long-side cap applied only when the caller passes no `max_pixels`
21/// bound.
22const FALLBACK_MAX_DIM: u32 = 1280;
23
24/// 2026-09-25: Long-side ceiling applied with or without a `max_pixels` bound:
25/// `max_pixels` is an area, so on its own it admits an unbounded long side (a
26/// 1×N strip). The ceiling is applied before grid snapping.
27const ABS_MAX_DIM: u32 = 4096;
28
29/// 2026-09-25: Decoder limit on either side, checked against the image header
30/// before pixels are decoded.
31const DECODE_MAX_SIDE: u32 = 16_384;
32
33/// 2026-09-25: Decoder allocation limit for one image, below the `image` crate's
34/// default of 512 MiB (which the crate documents as non-strict). 192 MiB admits
35/// an 8000×8000 RGB8 buffer (183 MiB).
36const DECODE_MAX_ALLOC: u64 = 192 * 1024 * 1024;
37
38/// 2026-09-25: Split a base64 `data:` URI (or a bare base64 string) into its
39/// declared MIME type and decoded bytes. The MIME is empty when the input has
40/// no `data:` header. The video path shares it and names the MIME in its
41/// errors.
42pub(crate) fn decode_data_uri_bytes(data_uri: &str) -> Result<(String, Vec<u8>)> {
43    let (mime, b64) = if let Some(pos) = data_uri.find(",base64,") {
44        (
45            data_uri[..pos].trim_start_matches("data:").to_string(),
46            &data_uri[pos + 8..],
47        )
48    } else if let Some(rest) = data_uri.strip_prefix("data:") {
49        match rest.find(',') {
50            Some(p) => (
51                rest[..p].trim_end_matches(";base64").to_string(),
52                &rest[p + 1..],
53            ),
54            None => (String::new(), data_uri),
55        }
56    } else {
57        (String::new(), data_uri)
58    };
59
60    let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64.trim())
61        .context("base64 decode failed")?;
62    Ok((mime, bytes))
63}
64
65/// 2026-09-25: Decode a base64 data URI or raw base64 string into a `DynamicImage`.
66fn decode_image(data_uri: &str) -> Result<DynamicImage> {
67    let (_mime, bytes) = decode_data_uri_bytes(data_uri)?;
68
69    let fmt = image::guess_format(&bytes).unwrap_or(ImageFormat::Jpeg);
70    // 2026-09-25: Decode through `ImageReader` so the limits are ours. The free
71    // function `load_from_memory_with_format` applies `Limits::default()`: a
72    // non-strict 512 MiB allocation limit and no dimension cap. The dimension
73    // limit rejects a header that declares, say, 65535×65535 before any buffer
74    // is reserved.
75    let mut reader = ImageReader::new(std::io::Cursor::new(&bytes));
76    reader.set_format(fmt);
77    let mut limits = Limits::default();
78    limits.max_image_width = Some(DECODE_MAX_SIDE);
79    limits.max_image_height = Some(DECODE_MAX_SIDE);
80    limits.max_alloc = Some(DECODE_MAX_ALLOC);
81    reader.limits(limits);
82
83    // 2026-09-25: Apply EXIF orientation. A camera often stores the sensor's raw
84    // pixels and records the upright rotation in a tag, so decoding without it
85    // hands the model a rotated image. The tag is read from the decoder, hence
86    // `into_decoder`; a format without orientation reports `NoTransforms` and
87    // the image is left as decoded.
88    let mut decoder = reader.into_decoder().context("image decode failed")?;
89    let orientation = decoder
90        .orientation()
91        .unwrap_or(image::metadata::Orientation::NoTransforms);
92    let mut img = DynamicImage::from_decoder(decoder).context("image decode failed")?;
93    if orientation != image::metadata::Orientation::NoTransforms {
94        tracing::debug!("applying EXIF orientation {orientation:?}");
95        img.apply_orientation(orientation);
96    }
97    Ok(img)
98}
99
100/// 2026-09-25: Reject a vision config whose geometry cannot drive the
101/// preprocessor.
102///
103/// `parse_vision_config` reports a missing key as `0`. A zero `patch_size` or
104/// `spatial_merge_size` would reach a division by zero or a 0×0 target, and a
105/// zero `temporal_patch_size` an empty patch. This fails with a named error
106/// and assumes no default.
107fn validate_geometry(vcfg: &VisionConfig) -> Result<()> {
108    if vcfg.patch_size == 0 {
109        bail!("vision_config.patch_size is 0 (missing or invalid in the checkpoint's config.json)");
110    }
111    if vcfg.spatial_merge_size == 0 {
112        bail!("vision_config.spatial_merge_size is 0 (missing or invalid in config.json)");
113    }
114    if vcfg.temporal_patch_size == 0 {
115        bail!("vision_config.temporal_patch_size is 0 (missing or invalid in config.json)");
116    }
117    Ok(())
118}
119
120/// 2026-09-25: Compute the target (H, W):
121/// - Both sides are positive multiples of `grid_unit = patch_size × spatial_merge_size`.
122/// - With a `max_pixels` bound, the target area is at most
123///   `max(max_pixels, grid_unit²)`; the bound replaces [`FALLBACK_MAX_DIM`]
124///   rather than combining with it. Without one, the long side is scaled to
125///   at most [`FALLBACK_MAX_DIM`] before snapping.
126/// - The long side is scaled to at most [`ABS_MAX_DIM`] before snapping.
127/// - Aspect ratio is kept up to rounding to the grid.
128/// - The continuous scale never upscales; snapping may round a side up by
129///   less than half a grid unit.
130///
131/// `max_pixels` is an area in pixels. The serve resolves it from
132/// `--vision-max-pixels`, else `METRALE_VISION_MAX_PIXELS`, else the
133/// checkpoint's processor config. The video path calls this too.
134pub(crate) fn target_size_for(
135    orig_h: u32,
136    orig_w: u32,
137    grid_unit: u32,
138    max_pixels: Option<usize>,
139) -> (u32, u32) {
140    target_size_with_max_pixels(orig_h, orig_w, grid_unit, max_pixels)
141}
142
143fn target_size_with_max_pixels(
144    orig_h: u32,
145    orig_w: u32,
146    grid_unit: u32,
147    max_pixels: Option<usize>,
148) -> (u32, u32) {
149    let long_side = orig_h.max(orig_w) as f32;
150    let area = (orig_h as f32) * (orig_w as f32);
151    let bound_scale = match max_pixels.filter(|&p| p > 0) {
152        Some(p) => ((p as f32) / area).sqrt(),
153        None => (FALLBACK_MAX_DIM as f32) / long_side,
154    };
155    let abs_scale = (ABS_MAX_DIM as f32) / long_side;
156    let scale = bound_scale.min(abs_scale).min(1.0);
157    let mut target_h =
158        ((orig_h as f32 * scale / grid_unit as f32).round() as u32).max(1) * grid_unit;
159    let mut target_w =
160        ((orig_w as f32 * scale / grid_unit as f32).round() as u32).max(1) * grid_unit;
161
162    // 2026-09-25: Nearest-grid rounding can push the area past the bound. Shrink
163    // one grid unit at a time, on the axis that keeps the closer source aspect
164    // ratio, stopping at one grid cell, the smallest target.
165    if let Some(max_pixels) = max_pixels.filter(|&p| p > 0) {
166        let grid_area = u64::from(grid_unit) * u64::from(grid_unit);
167        let max_area = (max_pixels as u64).max(grid_area);
168        let area = |h: u32, w: u32| u64::from(h) * u64::from(w);
169        let aspect_error = |h: u32, w: u32| {
170            if orig_h == 0 {
171                0.0
172            } else {
173                ((w as f64 / h as f64) - (orig_w as f64 / orig_h as f64)).abs()
174            }
175        };
176
177        while area(target_h, target_w) > max_area {
178            let shorter_h = target_h.checked_sub(grid_unit).filter(|&h| h >= grid_unit);
179            let shorter_w = target_w.checked_sub(grid_unit).filter(|&w| w >= grid_unit);
180            match (shorter_h, shorter_w) {
181                (Some(h), Some(w)) => {
182                    let h_error = aspect_error(h, target_w);
183                    let w_error = aspect_error(target_h, w);
184                    if h_error < w_error
185                        || (h_error == w_error && area(h, target_w) >= area(target_h, w))
186                    {
187                        target_h = h;
188                    } else {
189                        target_w = w;
190                    }
191                }
192                (Some(h), None) => target_h = h,
193                (None, Some(w)) => target_w = w,
194                (None, None) => break,
195            }
196        }
197    }
198    (target_h, target_w)
199}
200
201/// 2026-09-25: Preprocess one base64-encoded image with no `max_pixels` bound.
202///
203/// Returns:
204/// - `pixels`: flat `f32` tensor shaped `[P, C × T × H_p × W_p]` where:
205///   - `P = (H/patch_size) × (W/patch_size)` — number of patches
206///   - `C = 3` channels, `T = temporal_patch_size` (image duplicated), `H_p = W_p = patch_size`
207/// - `grid_h`: number of patches along height
208/// - `grid_w`: number of patches along width
209pub fn preprocess_image(data_uri: &str, vcfg: &VisionConfig) -> Result<(Vec<f32>, usize, usize)> {
210    preprocess_image_with_max_pixels(data_uri, vcfg, None)
211}
212
213/// 2026-09-25: [`preprocess_image`] with an optional area bound `max_pixels`
214/// (see `target_size_for`).
215pub fn preprocess_image_with_max_pixels(
216    data_uri: &str,
217    vcfg: &VisionConfig,
218    max_pixels: Option<usize>,
219) -> Result<(Vec<f32>, usize, usize)> {
220    // 2026-09-25: Before anything divides by them.
221    validate_geometry(vcfg)?;
222    let img = decode_image(data_uri)?;
223    let img = img.to_rgb8();
224    let (orig_w, orig_h) = (img.width(), img.height());
225
226    let grid_unit = (vcfg.patch_size * vcfg.spatial_merge_size) as u32;
227
228    // 2026-09-25: The resize policy follows `VisionConfig::block_major_patches`.
229    // GLM fits the content inside a `smart_resize` canvas and zero-pads the
230    // remainder; otherwise the image is resized onto the snapped canvas.
231    let img = if vcfg.block_major_patches {
232        crate::vision_preprocess_glm::resize_and_pad(&img, vcfg, max_pixels)
233    } else {
234        let (th, tw) = target_size_with_max_pixels(orig_h, orig_w, grid_unit, max_pixels);
235        // 2026-09-25: CatmullRom is the `image` crate's bicubic filter.
236        image::imageops::resize(&img, tw, th, image::imageops::FilterType::CatmullRom)
237    };
238    let (th, tw) = (img.height(), img.width());
239
240    let ps = vcfg.patch_size;
241    let tp = vcfg.temporal_patch_size;
242    let grid_h = (th as usize) / ps;
243    let grid_w = (tw as usize) / ps;
244    let num_patches = grid_h * grid_w;
245    let patch_dim = 3 * tp * ps * ps;
246    let mut pixels = vec![0.0f32; num_patches * patch_dim];
247
248    // 2026-09-25: Mean and std come from the checkpoint's `VisionConfig`: 0.5 on
249    // every channel for the Qwen family (`VisionConfig::default`), CLIP values
250    // for GLM (`parse_glm5_next`).
251    let (mean, std) = (vcfg.image_mean, vcfg.image_std);
252
253    // 2026-09-25: The temporal axis is filled by repeating the image `tp` times.
254    // Layout: `[P, C, T, Hp, Wp]`, stored as `[P, C*T*Hp*Wp]` row-major.
255    //
256    // Patch order is 2×2-block-major when `block_major_patches` (GLM), whose
257    // downsample folds four consecutive tokens into one block; raster order
258    // otherwise.
259    for ph in 0..grid_h {
260        for pw in 0..grid_w {
261            let patch_idx = if vcfg.block_major_patches {
262                crate::vision_preprocess_glm::block_major_patch_index(
263                    ph,
264                    pw,
265                    grid_w,
266                    vcfg.spatial_merge_size,
267                )
268            } else {
269                ph * grid_w + pw
270            };
271            for c in 0..3usize {
272                for t in 0..tp {
273                    for py in 0..ps {
274                        for px in 0..ps {
275                            let pixel_y = ph * ps + py;
276                            let pixel_x = pw * ps + px;
277                            let raw =
278                                img.get_pixel(pixel_x as u32, pixel_y as u32)[c] as f32 / 255.0;
279                            let norm = (raw - mean[c]) / std[c];
280                            let off = c * (tp * ps * ps) + t * (ps * ps) + py * ps + px;
281                            pixels[patch_idx * patch_dim + off] = norm;
282                        }
283                    }
284                }
285            }
286        }
287    }
288
289    Ok((pixels, grid_h, grid_w))
290}
291
292#[cfg(test)]
293#[path = "vision_preprocess_tests.rs"]
294mod tests;