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;