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;