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/// smart_resize (HF): round each side to a multiple of 32 preserving aspect ratio,
40/// then scale into the [MIN_PIXELS, MAX_PIXELS] area budget.
41pub fn smart_resize(h: usize, w: usize) -> Result<(usize, usize), String> {
42    if h < 2 || w < 2 {
43        return Err(format!("image too small: {w}x{h}"));
44    }
45    let ar = h.max(w) as f64 / h.min(w) as f64;
46    if ar > 200.0 {
47        return Err(format!("aspect ratio {ar:.0} exceeds 200"));
48    }
49    let f = FACTOR as f64;
50    let (hf, wf) = (h as f64, w as f64);
51    let mut h_bar = ((hf / f).round() * f).max(f);
52    let mut w_bar = ((wf / f).round() * f).max(f);
53    if h_bar * w_bar > MAX_PIXELS as f64 {
54        let beta = (hf * wf / MAX_PIXELS as f64).sqrt();
55        h_bar = ((hf / beta / f).floor() * f).max(f);
56        w_bar = ((wf / beta / f).floor() * f).max(f);
57    } else if h_bar * w_bar < MIN_PIXELS as f64 {
58        let beta = (MIN_PIXELS as f64 / (hf * wf)).sqrt();
59        h_bar = (hf * beta / f).ceil() * f;
60        w_bar = (wf * beta / f).ceil() * f;
61    }
62    Ok((h_bar as usize, w_bar as usize))
63}
64
65/// Decode + resize one image to its target grid. Returns the resized RGB frame and
66/// the patch grid (gh, gw) in 16px patches (both even — factor 32 guarantees it).
67fn decode_frame(bytes: &[u8]) -> Result<(RgbImage, usize, usize), String> {
68    let img = image::load_from_memory(bytes).map_err(|e| format!("image decode: {e}"))?;
69    let rgb = img.to_rgb8();
70    let (w, h) = (rgb.width() as usize, rgb.height() as usize);
71    let (rh, rw) = smart_resize(h, w)?;
72    let resized = image::imageops::resize(&rgb, rw as u32, rh as u32, FilterType::CatmullRom);
73    Ok((resized, rh / V_PATCH, rw / V_PATCH))
74}
75
76/// Fill patch rows for one temporal slot `t` from a frame. Rows are row-major over
77/// the (gh, gw) grid; inner order (c, t, ph, pw).
78fn fill_slot(rows: &mut [f32], frame: &RgbImage, gh: usize, gw: usize, t: usize) {
79    let inv = 1.0f32 / 127.5;
80    for py in 0..gh {
81        for px in 0..gw {
82            let row = &mut rows[(py * gw + px) * V_PATCH_IN..(py * gw + px + 1) * V_PATCH_IN];
83            for c in 0..3 {
84                let base = c * V_TEMPORAL * V_PATCH * V_PATCH + t * V_PATCH * V_PATCH;
85                for ph in 0..V_PATCH {
86                    for pw in 0..V_PATCH {
87                        let p =
88                            frame.get_pixel((px * V_PATCH + pw) as u32, (py * V_PATCH + ph) as u32);
89                        row[base + ph * V_PATCH + pw] = p.0[c] as f32 * inv - 1.0;
90                    }
91                }
92            }
93        }
94    }
95}
96
97/// Image bytes (png/jpeg/webp/gif/bmp) -> patch rows. The single frame fills both
98/// temporal slots (HF: images are tiled to temporal_patch_size).
99pub fn prep_image_bytes(bytes: &[u8]) -> Result<PreppedImage, String> {
100    let (frame, gh, gw) = decode_frame(bytes)?;
101    let mut patches = vec![0f32; gh * gw * V_PATCH_IN];
102    for t in 0..V_TEMPORAL {
103        fill_slot(&mut patches, &frame, gh, gw, t);
104    }
105    Ok(PreppedImage { patches, gh, gw })
106}
107
108/// `data:image/...;base64,<payload>` -> patch rows.
109pub fn prep_data_uri(uri: &str) -> Result<PreppedImage, String> {
110    let bytes = decode_data_uri(uri)?;
111    prep_image_bytes(&bytes)
112}
113
114/// Parse a base64 data URI into raw bytes (any `data:*;base64,` media type).
115pub fn decode_data_uri(uri: &str) -> Result<Vec<u8>, String> {
116    let rest = uri
117        .strip_prefix("data:")
118        .ok_or_else(|| "expected data: URI (http fetch requires MEMRA_FETCH_URLS=1)".to_string())?;
119    let (meta, payload) = rest
120        .split_once(',')
121        .ok_or_else(|| "malformed data URI: no comma".to_string())?;
122    if !meta.ends_with(";base64") {
123        return Err("data URI must be base64-encoded".into());
124    }
125    base64::engine::general_purpose::STANDARD
126        .decode(payload.trim())
127        .map_err(|e| format!("base64 decode: {e}"))
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn smart_resize_multiples_and_budget() {
136        // typical photo
137        let (h, w) = smart_resize(1080, 1920).unwrap();
138        assert_eq!(h % 32, 0);
139        assert_eq!(w % 32, 0);
140        assert!(h * w >= MIN_PIXELS && h * w <= MAX_PIXELS);
141        // tiny icon scales UP to the floor
142        let (h, w) = smart_resize(64, 64).unwrap();
143        assert!(h * w >= MIN_PIXELS);
144        // huge pano scales DOWN under the cap
145        let (h, w) = smart_resize(8000, 12000).unwrap();
146        assert!(h * w <= MAX_PIXELS);
147        assert!(smart_resize(10, 4000).is_err()); // ar > 200
148    }
149
150    #[test]
151    fn patchify_shape_and_order() {
152        // 2x2-patch (32x32 px) synthetic image, distinct channel values
153        let mut img = RgbImage::new(64, 64);
154        for (x, y, p) in img.enumerate_pixels_mut() {
155            *p = image::Rgb([x as u8, y as u8, 200]);
156        }
157        let mut buf = std::io::Cursor::new(Vec::new());
158        img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
159        let prep = prep_image_bytes(buf.get_ref()).unwrap();
160        assert_eq!(prep.patches.len(), prep.gh * prep.gw * V_PATCH_IN);
161        assert_eq!(prep.gh % V_MERGE, 0);
162        assert_eq!(prep.gw % V_MERGE, 0);
163        // temporal slots identical for still images
164        let row = &prep.patches[0..V_PATCH_IN];
165        let slot = V_PATCH * V_PATCH;
166        for c in 0..3 {
167            let b = c * V_TEMPORAL * slot;
168            assert_eq!(row[b..b + slot], row[b + slot..b + 2 * slot]);
169        }
170        // values in [-1, 1]
171        assert!(prep.patches.iter().all(|v| (-1.0..=1.0).contains(v)));
172    }
173
174    #[test]
175    fn data_uri_roundtrip() {
176        let png = {
177            let img = RgbImage::new(32, 32);
178            let mut buf = std::io::Cursor::new(Vec::new());
179            img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
180            buf.into_inner()
181        };
182        let uri = format!(
183            "data:image/png;base64,{}",
184            base64::engine::general_purpose::STANDARD.encode(&png)
185        );
186        let prep = prep_data_uri(&uri).unwrap();
187        assert_eq!(prep.n_tokens(), prep.gh * prep.gw / 4);
188        assert!(decode_data_uri("http://x/y.png").is_err());
189    }
190}