spark_model/vision_preprocess.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! CPU-side image preprocessing for Qwen3-VL vision inputs.
4//!
5//! Decodes base64 JPEG/PNG images, resizes to a grid snapped to
6//! `patch_size × spatial_merge_size`, normalizes with ImageNet stats,
7//! and produces a flat `f32` tensor ready for the GPU vision encoder.
8
9use anyhow::{Context, Result, bail};
10use atlas_core::config::VisionConfig;
11use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits};
12
13/// SigLIP normalization — matches HF's Qwen2VLImageProcessor
14/// (`image_mean = image_std = (0.5, 0.5, 0.5)` → pixels mapped to [-1, 1]).
15/// `pub(crate)` because the video path normalizes with the identical stats —
16/// a video frame is not a different kind of pixel, and two copies of these
17/// numbers is two places for them to drift apart.
18pub(crate) const MEAN: [f32; 3] = [0.5, 0.5, 0.5];
19pub(crate) const STD: [f32; 3] = [0.5, 0.5, 0.5];
20
21/// Long-side cap used ONLY when nothing else bounds the image — i.e. the
22/// caller passed no `max_pixels` because the checkpoint shipped no
23/// `preprocessor_config.json` and the operator set no `--vision-max-pixels`.
24///
25/// This was an UNCONDITIONAL ceiling until 2026-08-14, which silently threw
26/// away most of the resolution such checkpoints allow. Qwen3.8-27B declares
27/// `size = {longest_edge: 16777216, shortest_edge: 65536}` — pixel AREAS, so
28/// up to 4096² — while this constant clamped every image to 1280 on the long
29/// side, roughly a tenth of the permitted area. Measured before the change:
30/// a 1344×1344 input came back as 1600 merged tokens (1280×1280), and
31/// 1920×1080 as ~900 (1280×720). Detail-bearing inputs — documents, charts,
32/// dense screenshots — paid for that directly, and nothing logged it.
33const FALLBACK_MAX_DIM: u32 = 1280;
34
35/// Absolute long-side ceiling that applies even when a `max_pixels` bound is
36/// in force. `max_pixels` is an AREA, so on a pathological aspect ratio it
37/// alone permits an unbounded long side (a 1×N strip). This is the safety
38/// net [`FALLBACK_MAX_DIM`] was informally providing before it became a
39/// fallback; it is deliberately far above any sane vision input.
40const ABS_MAX_DIM: u32 = 4096;
41
42/// Decoder limit: reject a header declaring more than this on either side
43/// before a single pixel is allocated. Everything is resized down to at most
44/// [`ABS_MAX_DIM`] anyway, so this only has to be above any real camera
45/// image; 16384 is ~4× the long side of a 50 MP photo.
46const DECODE_MAX_SIDE: u32 = 16_384;
47
48/// Decoder limit: bytes the decoder may hold at once for one image. The
49/// `image` crate's own default is 512 MiB, which on GB10's UNIFIED 121 GB
50/// CPU+GPU memory is a per-request budget competing directly with the KV
51/// cache — and the request body arrives over HTTP from an unauthenticated
52/// caller. 192 MiB still admits an 8000×8000 RGB image.
53const DECODE_MAX_ALLOC: u64 = 192 * 1024 * 1024;
54
55/// Split a base64 `data:` URI (or a bare base64 string) into its declared
56/// MIME type and decoded bytes.
57///
58/// Shared with the video path, which needs the SAME unwrapping but a
59/// different decoder — and needs the MIME string, because "this is an mp4"
60/// is worth saying by name rather than discovering as a parse failure.
61/// The MIME is empty when the input carried no `data:` header.
62pub(crate) fn decode_data_uri_bytes(data_uri: &str) -> Result<(String, Vec<u8>)> {
63 // Strip optional "data:<mime>;base64," prefix.
64 let (mime, b64) = if let Some(pos) = data_uri.find(",base64,") {
65 (
66 data_uri[..pos].trim_start_matches("data:").to_string(),
67 &data_uri[pos + 8..],
68 )
69 } else if let Some(rest) = data_uri.strip_prefix("data:") {
70 // "data:image/jpeg;base64,..." — the common, well-formed shape.
71 match rest.find(',') {
72 Some(p) => (
73 rest[..p].trim_end_matches(";base64").to_string(),
74 &rest[p + 1..],
75 ),
76 None => (String::new(), data_uri),
77 }
78 } else {
79 (String::new(), data_uri)
80 };
81
82 let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64.trim())
83 .context("base64 decode failed")?;
84 Ok((mime, bytes))
85}
86
87/// Decode a base64 data URI or raw base64 string into a `DynamicImage`.
88fn decode_image(data_uri: &str) -> Result<DynamicImage> {
89 let (_mime, bytes) = decode_data_uri_bytes(data_uri)?;
90
91 // Probe format from magic bytes.
92 let fmt = image::guess_format(&bytes).unwrap_or(ImageFormat::Jpeg);
93 // Decode through `ImageReader` rather than `load_from_memory_with_format`
94 // so the limits are ours. (The free function is not unlimited — it applies
95 // `Limits::default()`, i.e. 512 MiB alloc — but it sets NO dimension cap,
96 // and the alloc cap is documented as non-strict.) A 40-byte PNG header can
97 // declare 65535×65535; the dimension limit rejects that from the header,
98 // before any buffer is reserved.
99 let mut reader = ImageReader::new(std::io::Cursor::new(&bytes));
100 reader.set_format(fmt);
101 let mut limits = Limits::default();
102 limits.max_image_width = Some(DECODE_MAX_SIDE);
103 limits.max_image_height = Some(DECODE_MAX_SIDE);
104 limits.max_alloc = Some(DECODE_MAX_ALLOC);
105 reader.limits(limits);
106
107 // ★ EXIF ORIENTATION IS APPLIED. A camera writes the sensor's raw pixels
108 // and records how to turn them upright in an EXIF tag rather than rotating
109 // the data, so a phone photo is very often stored sideways with
110 // `Orientation = 6` ("rotate 90° CW to display"). Decoding without that
111 // tag hands the model an image rotated a quarter turn — and it does not
112 // error or look broken, it simply answers about a sideways picture, which
113 // is the failure mode this whole benchmark family exists to catch.
114 //
115 // Measured on 2026-08-14: Atlas ignored the tag entirely. Every viewer the
116 // user compares against — their phone, their browser, their file manager —
117 // honours it, so "what the model saw" and "what the user saw" silently
118 // disagreed on a large fraction of real photographs.
119 //
120 // `into_decoder` rather than `decode`, because the tag lives on the
121 // DECODER and is gone once the pixels are out. A format that carries no
122 // orientation reports `NoTransforms`, so this is a no-op for PNG and for
123 // any JPEG without the tag — the earlier behaviour, preserved exactly
124 // where there is nothing to apply.
125 let mut decoder = reader.into_decoder().context("image decode failed")?;
126 let orientation = decoder
127 .orientation()
128 .unwrap_or(image::metadata::Orientation::NoTransforms);
129 let mut img = DynamicImage::from_decoder(decoder).context("image decode failed")?;
130 if orientation != image::metadata::Orientation::NoTransforms {
131 tracing::debug!("applying EXIF orientation {orientation:?}");
132 img.apply_orientation(orientation);
133 }
134 Ok(img)
135}
136
137/// Reject a vision config whose geometry cannot drive the preprocessor.
138///
139/// Every field here comes from a third-party `config.json` via
140/// `parse_vision_config`, which reports a MISSING key as `0` — so an absent or
141/// malformed `patch_size` reaches `preprocess_image` as a divisor of zero, and
142/// `grid_unit = patch_size * spatial_merge_size` reaches the scale computation
143/// as `0.0`, producing a 0×0 target and then a division by zero. Fail with a
144/// named error instead. Deliberately no fallback default: silently assuming
145/// `patch_size = 16` would let a mismatched checkpoint produce a wrongly-shaped
146/// pixel buffer, which is the hazard the encoder's own length check exists for.
147fn validate_geometry(vcfg: &VisionConfig) -> Result<()> {
148 if vcfg.patch_size == 0 {
149 bail!("vision_config.patch_size is 0 (missing or invalid in the checkpoint's config.json)");
150 }
151 if vcfg.spatial_merge_size == 0 {
152 bail!("vision_config.spatial_merge_size is 0 (missing or invalid in config.json)");
153 }
154 if vcfg.temporal_patch_size == 0 {
155 bail!("vision_config.temporal_patch_size is 0 (missing or invalid in config.json)");
156 }
157 Ok(())
158}
159
160/// Compute the target (H, W) so that:
161/// - The area bound is respected after grid snapping: `max_pixels` when the
162/// caller supplies one, otherwise the long side is clamped to
163/// [`FALLBACK_MAX_DIM`]. A bound below one grid cell uses that minimum cell.
164/// - The long side never exceeds [`ABS_MAX_DIM`], bound or not.
165/// - Both sides are multiples of `grid_unit = patch_size × spatial_merge_size`.
166/// - Aspect ratio is preserved (rounded to nearest grid_unit).
167/// - The continuous scale never upscales. Grid snapping may round a side up by
168/// less than half a grid unit, and every target contains at least one cell.
169///
170/// `max_pixels` is an area, matching the `size.longest_edge` /
171/// `shortest_edge` convention HF's Qwen2VL/Qwen3VL processors use (both are
172/// pixel counts, not edge lengths, despite the names). It comes from the
173/// checkpoint's `preprocessor_config.json` or the operator's
174/// `--vision-max-pixels`; the operator's value wins.
175///
176/// ★ `max_pixels` REPLACES the long-side clamp rather than combining with it.
177/// Combining was the bug: `dim_scale.min(pixel_scale)` meant a checkpoint
178/// permitting 4096² could never exceed 1280 on the long side, so the model's
179/// own declared bound could only ever lower the resolution, never raise it.
180/// `pub(crate)` alias name used by the video path — see [`target_size_for`].
181pub(crate) fn target_size_for(
182 orig_h: u32,
183 orig_w: u32,
184 grid_unit: u32,
185 max_pixels: Option<usize>,
186) -> (u32, u32) {
187 target_size_with_max_pixels(orig_h, orig_w, grid_unit, max_pixels)
188}
189
190fn target_size_with_max_pixels(
191 orig_h: u32,
192 orig_w: u32,
193 grid_unit: u32,
194 max_pixels: Option<usize>,
195) -> (u32, u32) {
196 let long_side = orig_h.max(orig_w) as f32;
197 let area = (orig_h as f32) * (orig_w as f32);
198 let bound_scale = match max_pixels.filter(|&p| p > 0) {
199 // Model- or operator-declared AREA bound governs.
200 Some(p) => ((p as f32) / area).sqrt(),
201 // Nothing declared: fall back to the historical long-side clamp.
202 None => (FALLBACK_MAX_DIM as f32) / long_side,
203 };
204 // Safety net, always applied.
205 let abs_scale = (ABS_MAX_DIM as f32) / long_side;
206 let scale = bound_scale.min(abs_scale).min(1.0); // never upscale
207 let mut target_h =
208 ((orig_h as f32 * scale / grid_unit as f32).round() as u32).max(1) * grid_unit;
209 let mut target_w =
210 ((orig_w as f32 * scale / grid_unit as f32).round() as u32).max(1) * grid_unit;
211
212 // Nearest-grid rounding can raise BOTH axes past the continuous area
213 // scale. A declared hard cap must survive that quantisation step. Shrink
214 // one grid unit at a time, choosing the axis that leaves the closer source
215 // aspect ratio. One grid cell is the smallest representable target, so a
216 // smaller declared bound is normalized to that unavoidable minimum.
217 if let Some(max_pixels) = max_pixels.filter(|&p| p > 0) {
218 let grid_area = u64::from(grid_unit) * u64::from(grid_unit);
219 let max_area = (max_pixels as u64).max(grid_area);
220 let area = |h: u32, w: u32| u64::from(h) * u64::from(w);
221 let aspect_error = |h: u32, w: u32| {
222 if orig_h == 0 {
223 0.0
224 } else {
225 ((w as f64 / h as f64) - (orig_w as f64 / orig_h as f64)).abs()
226 }
227 };
228
229 while area(target_h, target_w) > max_area {
230 let shorter_h = target_h.checked_sub(grid_unit).filter(|&h| h >= grid_unit);
231 let shorter_w = target_w.checked_sub(grid_unit).filter(|&w| w >= grid_unit);
232 match (shorter_h, shorter_w) {
233 (Some(h), Some(w)) => {
234 let h_error = aspect_error(h, target_w);
235 let w_error = aspect_error(target_h, w);
236 if h_error < w_error
237 || (h_error == w_error && area(h, target_w) >= area(target_h, w))
238 {
239 target_h = h;
240 } else {
241 target_w = w;
242 }
243 }
244 (Some(h), None) => target_h = h,
245 (None, Some(w)) => target_w = w,
246 (None, None) => break,
247 }
248 }
249 }
250 (target_h, target_w)
251}
252
253/// Preprocess a single base64-encoded image for the Qwen3-VL encoder.
254///
255/// Returns:
256/// - `pixels`: flat `f32` tensor shaped `[P, C × T × H_p × W_p]` where:
257/// - `P = (H/patch_size) × (W/patch_size)` — number of patches
258/// - `C = 3` channels, `T = temporal_patch_size` (image duplicated), `H_p = W_p = patch_size`
259/// - `grid_h`: number of patches along height
260/// - `grid_w`: number of patches along width
261pub fn preprocess_image(data_uri: &str, vcfg: &VisionConfig) -> Result<(Vec<f32>, usize, usize)> {
262 preprocess_image_with_max_pixels(data_uri, vcfg, None)
263}
264
265/// Preprocess an image with an optional max-pixels cap, matching vLLM-style
266/// multimodal processor controls. `None` preserves Atlas' historical 1280px
267/// long-side cap.
268pub fn preprocess_image_with_max_pixels(
269 data_uri: &str,
270 vcfg: &VisionConfig,
271 max_pixels: Option<usize>,
272) -> Result<(Vec<f32>, usize, usize)> {
273 // Before anything divides by them.
274 validate_geometry(vcfg)?;
275 let img = decode_image(data_uri)?;
276 let img = img.to_rgb8();
277 let (orig_w, orig_h) = (img.width(), img.height());
278
279 let grid_unit = (vcfg.patch_size * vcfg.spatial_merge_size) as u32;
280 let (th, tw) = target_size_with_max_pixels(orig_h, orig_w, grid_unit, max_pixels);
281
282 // Resize with CatmullRom — closest BICUBIC match in the `image` crate,
283 // matching HF's `Qwen2VLImageProcessor` which uses PIL resample=3 (BICUBIC).
284 let img = image::imageops::resize(&img, tw, th, image::imageops::FilterType::CatmullRom);
285
286 let ps = vcfg.patch_size;
287 let tp = vcfg.temporal_patch_size;
288 let grid_h = (th as usize) / ps;
289 let grid_w = (tw as usize) / ps;
290 let num_patches = grid_h * grid_w;
291 // Flattened patch dim: C × temporal_patch_size × patch_size × patch_size
292 let patch_dim = 3 * tp * ps * ps;
293 let mut pixels = vec![0.0f32; num_patches * patch_dim];
294
295 // Build patches. The temporal dimension is handled by duplicating the image `tp` times.
296 // Layout: [P, C, T, Hp, Wp] → stored as [P, C*T*Hp*Wp] in row-major order.
297 for ph in 0..grid_h {
298 for pw in 0..grid_w {
299 let patch_idx = ph * grid_w + pw;
300 for c in 0..3usize {
301 for t in 0..tp {
302 for py in 0..ps {
303 for px in 0..ps {
304 let pixel_y = ph * ps + py;
305 let pixel_x = pw * ps + px;
306 let raw =
307 img.get_pixel(pixel_x as u32, pixel_y as u32)[c] as f32 / 255.0;
308 let norm = (raw - MEAN[c]) / STD[c];
309 // Offset into patch_dim: c*(T*Hp*Wp) + t*(Hp*Wp) + py*Wp + px
310 let off = c * (tp * ps * ps) + t * (ps * ps) + py * ps + px;
311 pixels[patch_idx * patch_dim + off] = norm;
312 }
313 }
314 }
315 }
316 }
317 }
318
319 Ok((pixels, grid_h, grid_w))
320}
321
322#[cfg(test)]
323#[path = "vision_preprocess_tests.rs"]
324mod tests;