Skip to main content

memra_engine/
vision_pre.rs

1//! Host preprocessor for vision input (lane/vision): bytes -> ViT patch rows.
2//!
3//! Qwen2VLImageProcessorFast semantics: smart_resize to multiples of
4//! factor = patch(16) * merge(2) = 32 with the pixel-area budget, rescale 1/255,
5//! normalize mean/std 0.5 -> [-1, 1], patchify to [gh*gw, 3*2*16*16 = 1536] rows in
6//! row-major grid order with (c, t, ph, pw) inner order — the flatten of the conv
7//! weight [1152, 3, 2, 16, 16], so `VisionTower::forward` consumes rows directly.
8//! Images duplicate their frame across temporal_patch 2; videos fill the pair with
9//! consecutive sampled frames.
10//!
11//! Resize filter: CatmullRom (Keys bicubic a=-0.5, PIL-BICUBIC family). The HF fast
12//! processor runs torch bicubic (a=-0.75) antialias — close but not bit-equal; the
13//! merger-cosine parity gate arbitrates whether the difference matters.
14
15use crate::vision::{V_MERGE, V_PATCH, V_PATCH_IN, V_TEMPORAL};
16use base64::Engine as _;
17use image::RgbImage;
18use image::imageops::FilterType;
19
20/// Area budget (pixels) from preprocessor_config: shortest_edge / longest_edge.
21pub const MIN_PIXELS: usize = 65536;
22pub const MAX_PIXELS: usize = 16_777_216;
23const FACTOR: usize = V_PATCH * V_MERGE; // 32
24
25pub struct PreppedImage {
26    /// [gh*gw, 1536] row-major grid order.
27    pub patches: Vec<f32>,
28    pub gh: usize,
29    pub gw: usize,
30}
31
32impl PreppedImage {
33    /// Trunk tokens this image occupies (after 2x2 merge).
34    pub fn n_tokens(&self) -> usize {
35        self.gh * self.gw / (V_MERGE * V_MERGE)
36    }
37}
38
39/// HF's smart_resize uses Python round(), which is round-half-EVEN: a side landing
40/// exactly on .5 factors (e.g. 336/32 = 10.5) rounds to the even multiple (320, not
41/// 352). Rust's f64::round is half-away-from-zero and diverged there — caught by the
42/// ornith15 parity gate on a 448x336 probe (grid 22x28 vs HF 20x28).
43fn round_half_even(x: f64) -> f64 {
44    let r = x.round();
45    if (x - x.trunc()).abs() == 0.5 && r % 2.0 != 0.0 {
46        r - x.signum()
47    } else {
48        r
49    }
50}
51
52/// smart_resize (HF): round each side to a multiple of 32 preserving aspect ratio,
53/// then scale into the [MIN_PIXELS, MAX_PIXELS] area budget.
54pub fn smart_resize(h: usize, w: usize) -> Result<(usize, usize), String> {
55    if h < 2 || w < 2 {
56        return Err(format!("image too small: {w}x{h}"));
57    }
58    let ar = h.max(w) as f64 / h.min(w) as f64;
59    if ar > 200.0 {
60        return Err(format!("aspect ratio {ar:.0} exceeds 200"));
61    }
62    let f = FACTOR as f64;
63    let (hf, wf) = (h as f64, w as f64);
64    let mut h_bar = (round_half_even(hf / f) * f).max(f);
65    let mut w_bar = (round_half_even(wf / f) * f).max(f);
66    if h_bar * w_bar > MAX_PIXELS as f64 {
67        let beta = (hf * wf / MAX_PIXELS as f64).sqrt();
68        h_bar = ((hf / beta / f).floor() * f).max(f);
69        w_bar = ((wf / beta / f).floor() * f).max(f);
70    } else if h_bar * w_bar < MIN_PIXELS as f64 {
71        let beta = (MIN_PIXELS as f64 / (hf * wf)).sqrt();
72        h_bar = (hf * beta / f).ceil() * f;
73        w_bar = (wf * beta / f).ceil() * f;
74    }
75    Ok((h_bar as usize, w_bar as usize))
76}
77
78/// Decode + resize one image to its target grid. Returns the resized RGB frame and
79/// the patch grid (gh, gw) in 16px patches (both even — factor 32 guarantees it).
80fn decode_frame(bytes: &[u8]) -> Result<(RgbImage, usize, usize), String> {
81    let img = image::load_from_memory(bytes).map_err(|e| format!("image decode: {e}"))?;
82    let rgb = img.to_rgb8();
83    let (w, h) = (rgb.width() as usize, rgb.height() as usize);
84    let (rh, rw) = smart_resize(h, w)?;
85    let resized = image::imageops::resize(&rgb, rw as u32, rh as u32, FilterType::CatmullRom);
86    Ok((resized, rh / V_PATCH, rw / V_PATCH))
87}
88
89/// Fill patch rows for one temporal slot `t` from a frame. Rows are row-major over
90/// the (gh, gw) grid; inner order (c, t, ph, pw).
91fn fill_slot(rows: &mut [f32], frame: &RgbImage, gh: usize, gw: usize, t: usize) {
92    let inv = 1.0f32 / 127.5;
93    for py in 0..gh {
94        for px in 0..gw {
95            let row = &mut rows[(py * gw + px) * V_PATCH_IN..(py * gw + px + 1) * V_PATCH_IN];
96            for c in 0..3 {
97                let base = c * V_TEMPORAL * V_PATCH * V_PATCH + t * V_PATCH * V_PATCH;
98                for ph in 0..V_PATCH {
99                    for pw in 0..V_PATCH {
100                        let p =
101                            frame.get_pixel((px * V_PATCH + pw) as u32, (py * V_PATCH + ph) as u32);
102                        row[base + ph * V_PATCH + pw] = p.0[c] as f32 * inv - 1.0;
103                    }
104                }
105            }
106        }
107    }
108}
109
110/// Image bytes (png/jpeg/webp/gif/bmp) -> patch rows. The single frame fills both
111/// temporal slots (HF: images are tiled to temporal_patch_size).
112pub fn prep_image_bytes(bytes: &[u8]) -> Result<PreppedImage, String> {
113    let (frame, gh, gw) = decode_frame(bytes)?;
114    let mut patches = vec![0f32; gh * gw * V_PATCH_IN];
115    for t in 0..V_TEMPORAL {
116        fill_slot(&mut patches, &frame, gh, gw, t);
117    }
118    Ok(PreppedImage { patches, gh, gw })
119}
120
121/// `data:image/...;base64,<payload>` -> patch rows.
122pub fn prep_data_uri(uri: &str) -> Result<PreppedImage, String> {
123    let bytes = decode_data_uri(uri)?;
124    prep_image_bytes(&bytes)
125}
126
127/// One pad-run unit crossing the API boundary: a standalone image, or one temporal
128/// group of a video. Units with the same `video` index are consecutive and forward
129/// TOGETHER through `forward_seq` (one attention span per video).
130pub struct VisionUnit {
131    pub prep: PreppedImage,
132    /// Some(video_idx) for video groups; None for standalone images.
133    pub video: Option<usize>,
134}
135
136/// One prepared VIDEO: temporal groups as PreppedImage units (each = one pad run of
137/// `gh*gw/4` tokens) + per-group timestamps for the HF placeholder format
138/// (`<t.t seconds>` before each group's pad run). Groups forward TOGETHER through
139/// `VisionTower::forward_seq` — one attention span per video, the HF cu_seqlens law.
140pub struct PreppedVideo {
141    pub groups: Vec<PreppedImage>,
142    pub timestamps: Vec<f32>,
143}
144
145/// Serving cap on total video patches (groups*gh*gw): sdpa_naive keys the whole span in
146/// shared memory, so the pixel budget stays well under the HF default. Env-tunable.
147pub fn video_max_pixels() -> usize {
148    std::env::var("MEMRA_VIDEO_MAX_PIXELS")
149        .ok()
150        .and_then(|v| v.parse().ok())
151        .unwrap_or(2_097_152)
152}
153pub const VID_MIN_PIXELS: usize = 4096;
154/// Sampled frame cap (2 frames per temporal group).
155pub const VID_MAX_FRAMES: usize = 32;
156
157/// Decode ceilings for `prep_video_gif` (hermes finding, fixed 2026-08-19): the loop used
158/// to expand EVERY frame to full-canvas RGB in host RAM before the `VID_MAX_FRAMES`
159/// sample — and it runs in the HTTP handler pre-admission, so a small crafted GIF (big
160/// canvas x many frames; LZW expands ~1000x) allocated GBs per request. The canvas
161/// dimensions come from the GIF header, so `frames x canvas pixels` is checked against
162/// the pixel ceiling AS DECODE PROCEEDS and the request is refused (clean 4xx at the
163/// handler) the moment the budget would cross — retained RAM is bounded by
164/// `GIF_MAX_TOTAL_PIXELS` RGB (192 MiB) plus at most one transient canvas, no matter
165/// what the stream claims. 512 frames / 67.1M px comfortably cover legitimate clips
166/// (a 480p GIF may run ~370 frames, ~12 s at 30 fps) — the serve path samples down to
167/// 32 frames and ~2M px right after this anyway.
168pub const GIF_MAX_FRAMES: usize = 512;
169pub const GIF_MAX_TOTAL_PIXELS: usize = 1 << 26; // 67.1M px = 192 MiB retained RGB
170
171/// HF Qwen3VL video smart_resize: the pixel budget covers t_bar*h*w — ALL frames.
172fn smart_resize_video(frames: usize, h: usize, w: usize) -> Result<(usize, usize), String> {
173    if h < 2 || w < 2 {
174        return Err(format!("frame too small: {w}x{h}"));
175    }
176    let ar = h.max(w) as f64 / h.min(w) as f64;
177    if ar > 200.0 {
178        return Err(format!("aspect ratio {ar:.0} exceeds 200"));
179    }
180    let f = FACTOR as f64;
181    let (hf, wf) = (h as f64, w as f64);
182    let t_bar = ((frames as f64 / V_TEMPORAL as f64).round() * V_TEMPORAL as f64).max(2.0);
183    let mut h_bar = (round_half_even(hf / f) * f).max(f);
184    let mut w_bar = (round_half_even(wf / f) * f).max(f);
185    let (min_px, max_px) = (VID_MIN_PIXELS as f64, video_max_pixels() as f64);
186    if t_bar * h_bar * w_bar > max_px {
187        let beta = (frames as f64 * hf * wf / max_px).sqrt();
188        h_bar = ((hf / beta / f).floor() * f).max(f);
189        w_bar = ((wf / beta / f).floor() * f).max(f);
190    } else if t_bar * h_bar * w_bar < min_px {
191        let beta = (min_px / (frames as f64 * hf * wf)).sqrt();
192        h_bar = (hf * beta / f).ceil() * f;
193        w_bar = (wf * beta / f).ceil() * f;
194    }
195    Ok((h_bar as usize, w_bar as usize))
196}
197
198/// Animated GIF -> prepared video: decode frames + delays, uniform-sample to an even
199/// count <= VID_MAX_FRAMES, resize on the total-pixel budget, patchify CONSECUTIVE
200/// frame pairs into temporal groups (frame 2g fills t=0, 2g+1 fills t=1). Timestamps
201/// come from the GIF's own delays at the sampled indices (HF `_calculate_timestamps`).
202pub fn prep_video_gif(bytes: &[u8]) -> Result<PreppedVideo, String> {
203    use image::AnimationDecoder;
204    use image::ImageDecoder as _;
205    let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes))
206        .map_err(|e| format!("gif decode: {e}"))?;
207    // Decode budget from the HEADER, before any frame expands: every decoded frame
208    // composites to the full canvas, so canvas pixels bound the per-frame cost and
209    // frames x canvas is checked against the ceiling as decode proceeds (at most one
210    // transient frame past the cap ever exists). Over-limit refuses cleanly — the
211    // handler surfaces it as a 4xx — instead of expanding the whole stream in host RAM.
212    let (cw, ch) = dec.dimensions();
213    let canvas_px = (cw as usize) * (ch as usize);
214    if canvas_px == 0 {
215        return Err("gif has an empty canvas".into());
216    }
217    let max_frames = GIF_MAX_FRAMES.min(GIF_MAX_TOTAL_PIXELS / canvas_px);
218    if max_frames == 0 {
219        return Err(format!(
220            "gif canvas {cw}x{ch} exceeds the decode budget ({GIF_MAX_TOTAL_PIXELS} px)"
221        ));
222    }
223    let mut frames: Vec<(RgbImage, f32)> = Vec::new(); // (frame, start_seconds)
224    let mut t = 0f32;
225    for fr in dec.into_frames() {
226        if frames.len() >= max_frames {
227            return Err(format!(
228                "gif exceeds the decode budget: more than {max_frames} frames at {cw}x{ch} \
229                 (ceiling {GIF_MAX_FRAMES} frames / {GIF_MAX_TOTAL_PIXELS} total px)"
230            ));
231        }
232        let fr = fr.map_err(|e| format!("gif frame: {e}"))?;
233        let (num, den) = fr.delay().numer_denom_ms();
234        let dt = if den == 0 {
235            100.0
236        } else {
237            num as f32 / den as f32
238        } / 1000.0;
239        frames.push((
240            image::DynamicImage::ImageRgba8(fr.into_buffer()).to_rgb8(),
241            t,
242        ));
243        t += dt.max(0.01);
244    }
245    if frames.is_empty() {
246        return Err("gif has no frames".into());
247    }
248    // still gif: duplicate the frame so one temporal group forms
249    if frames.len() == 1 {
250        let f0 = frames[0].clone();
251        frames.push((f0.0, f0.1));
252    }
253    // uniform sample to an even count <= VID_MAX_FRAMES
254    let total = frames.len();
255    let take = total.min(VID_MAX_FRAMES) & !1;
256    let picked: Vec<usize> = (0..take)
257        .map(|i| i * total / take) // floor spacing, strictly increasing for take <= total
258        .collect();
259    let (h, w) = (frames[0].0.height() as usize, frames[0].0.width() as usize);
260    let (rh, rw) = smart_resize_video(take, h, w)?;
261    let (gh, gw) = (rh / V_PATCH, rw / V_PATCH);
262    let mut groups = Vec::with_capacity(take / 2);
263    let mut timestamps = Vec::with_capacity(take / 2);
264    for g in 0..take / 2 {
265        let (a, b) = (picked[2 * g], picked[2 * g + 1]);
266        let mut patches = vec![0f32; gh * gw * V_PATCH_IN];
267        for (slot, idx) in [(0usize, a), (1usize, b)] {
268            let resized = image::imageops::resize(
269                &frames[idx].0,
270                rw as u32,
271                rh as u32,
272                FilterType::CatmullRom,
273            );
274            fill_slot(&mut patches, &resized, gh, gw, slot);
275        }
276        groups.push(PreppedImage { patches, gh, gw });
277        timestamps.push(frames[a].1);
278    }
279    Ok(PreppedVideo { groups, timestamps })
280}
281
282/// Parse a base64 data URI into raw bytes (any `data:*;base64,` media type).
283pub fn decode_data_uri(uri: &str) -> Result<Vec<u8>, String> {
284    let rest = uri
285        .strip_prefix("data:")
286        .ok_or_else(|| "expected data: URI (http fetch requires MEMRA_FETCH_URLS=1)".to_string())?;
287    let (meta, payload) = rest
288        .split_once(',')
289        .ok_or_else(|| "malformed data URI: no comma".to_string())?;
290    if !meta.ends_with(";base64") {
291        return Err("data URI must be base64-encoded".into());
292    }
293    base64::engine::general_purpose::STANDARD
294        .decode(payload.trim())
295        .map_err(|e| format!("base64 decode: {e}"))
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn smart_resize_multiples_and_budget() {
304        // typical photo
305        let (h, w) = smart_resize(1080, 1920).unwrap();
306        assert_eq!(h % 32, 0);
307        assert_eq!(w % 32, 0);
308        assert!(h * w >= MIN_PIXELS && h * w <= MAX_PIXELS);
309        // tiny icon scales UP to the floor
310        let (h, w) = smart_resize(64, 64).unwrap();
311        assert!(h * w >= MIN_PIXELS);
312        // huge pano scales DOWN under the cap
313        let (h, w) = smart_resize(8000, 12000).unwrap();
314        assert!(h * w <= MAX_PIXELS);
315        assert!(smart_resize(10, 4000).is_err()); // ar > 200
316    }
317
318    #[test]
319    fn patchify_shape_and_order() {
320        // 2x2-patch (32x32 px) synthetic image, distinct channel values
321        let mut img = RgbImage::new(64, 64);
322        for (x, y, p) in img.enumerate_pixels_mut() {
323            *p = image::Rgb([x as u8, y as u8, 200]);
324        }
325        let mut buf = std::io::Cursor::new(Vec::new());
326        img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
327        let prep = prep_image_bytes(buf.get_ref()).unwrap();
328        assert_eq!(prep.patches.len(), prep.gh * prep.gw * V_PATCH_IN);
329        assert_eq!(prep.gh % V_MERGE, 0);
330        assert_eq!(prep.gw % V_MERGE, 0);
331        // temporal slots identical for still images
332        let row = &prep.patches[0..V_PATCH_IN];
333        let slot = V_PATCH * V_PATCH;
334        for c in 0..3 {
335            let b = c * V_TEMPORAL * slot;
336            assert_eq!(row[b..b + slot], row[b + slot..b + 2 * slot]);
337        }
338        // values in [-1, 1]
339        assert!(prep.patches.iter().all(|v| (-1.0..=1.0).contains(v)));
340    }
341
342    /// Hand-crafted minimal GIF: `frames` one-black-pixel frames on a `w x h` canvas.
343    /// Each frame is the canonical 35-byte smallest-GIF image block (LZW: clear, index 0,
344    /// end -> bytes 0x44 0x01), so a high frame count costs ~18 bytes/frame on the wire
345    /// while every DECODED frame composites to the full canvas — the decode-bomb shape.
346    fn crafted_gif(w: u16, h: u16, frames: usize) -> Vec<u8> {
347        let mut b = Vec::new();
348        b.extend_from_slice(b"GIF89a");
349        b.extend_from_slice(&w.to_le_bytes());
350        b.extend_from_slice(&h.to_le_bytes());
351        b.push(0x80); // global color table, 2 entries
352        b.push(0); // background color index
353        b.push(0); // aspect ratio
354        b.extend_from_slice(&[0, 0, 0, 0xFF, 0xFF, 0xFF]); // GCT: black, white
355        for _ in 0..frames {
356            b.push(0x2C); // image descriptor
357            b.extend_from_slice(&0u16.to_le_bytes()); // left
358            b.extend_from_slice(&0u16.to_le_bytes()); // top
359            b.extend_from_slice(&1u16.to_le_bytes()); // width 1
360            b.extend_from_slice(&1u16.to_le_bytes()); // height 1
361            b.push(0); // no local color table
362            b.push(0x02); // LZW min code size
363            b.extend_from_slice(&[0x02, 0x44, 0x01]); // sub-block: clear, idx 0, end
364            b.push(0x00); // block terminator
365        }
366        b.push(0x3B); // trailer
367        b
368    }
369
370    #[test]
371    fn gif_decode_bomb_is_refused_before_full_expansion() {
372        // 2000x2000 canvas = 4M px/frame -> the 67.1M px budget admits 16 frames; a
373        // 64-frame stream (~1.3 KB on the wire, ~1 GiB decoded) must refuse at the
374        // budget, not expand: pre-fix this test allocated 64 x 16 MB RGBA canvases.
375        fn expect_err(bytes: &[u8]) -> String {
376            match prep_video_gif(bytes) {
377                Err(e) => e,
378                Ok(_) => panic!("decode-bomb GIF was accepted"),
379            }
380        }
381        let bomb = crafted_gif(2000, 2000, 64);
382        assert!(bomb.len() < 2048, "the bomb itself is tiny on the wire");
383        let err = expect_err(&bomb);
384        assert!(err.contains("decode budget"), "{err}");
385
386        // same canvas, frame count within budget: decodes fine.
387        let ok = crafted_gif(2000, 2000, 4);
388        let vid = prep_video_gif(&ok).unwrap();
389        assert_eq!(vid.groups.len(), 2); // 4 frames -> 2 temporal groups
390
391        // frame-count bomb on a tiny canvas: trips the flat frame ceiling.
392        let err = expect_err(&crafted_gif(8, 8, GIF_MAX_FRAMES + 8));
393        assert!(err.contains("decode budget"), "{err}");
394
395        // canvas alone past the pixel budget: refused straight from the header.
396        let err = expect_err(&crafted_gif(0xFFFF, 0xFFFF, 1));
397        assert!(err.contains("exceeds the decode budget"), "{err}");
398    }
399
400    #[test]
401    fn data_uri_roundtrip() {
402        let png = {
403            let img = RgbImage::new(32, 32);
404            let mut buf = std::io::Cursor::new(Vec::new());
405            img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
406            buf.into_inner()
407        };
408        let uri = format!(
409            "data:image/png;base64,{}",
410            base64::engine::general_purpose::STANDARD.encode(&png)
411        );
412        let prep = prep_data_uri(&uri).unwrap();
413        assert_eq!(prep.n_tokens(), prep.gh * prep.gw / 4);
414        assert!(decode_data_uri("http://x/y.png").is_err());
415    }
416}