Skip to main content

kopitiam_runtime/vision/
preprocess.rs

1//! Turning a rasterized page into the tensor a vision tower expects.
2//!
3//! This is step 1 of the vision path (see [`super`]): everything between "we
4//! have pixels" and "the patch-embedding matmul can run". It is deliberately
5//! **pure arithmetic over pixel buffers** — no model, no weights, no GGUF — so
6//! it is fully testable today, before any vision-capable runtime exists.
7//!
8//! # The pipeline, and why each step is where it is
9//!
10//! ```text
11//! RGB8 pixels ──resize──▶ square canvas ──normalize──▶ CHW f32 ──patchify──▶ [n_patches, patch_dim]
12//! ```
13//!
14//! * **Resize** to a fixed square. Vision towers have a *fixed* input geometry
15//!   baked into their learned position embeddings — there is one embedding per
16//!   patch slot, so the patch grid cannot vary at inference without also
17//!   interpolating those embeddings. Fixing the canvas keeps that whole problem
18//!   out of scope.
19//! * **Normalize** to the mean/std the tower was *trained* with. Getting these
20//!   constants wrong does not error — it silently shifts every activation and
21//!   quietly degrades the output, which is why they are named constants with
22//!   their provenance recorded rather than magic numbers inline.
23//! * **Patchify** into non-overlapping `patch × patch` tiles, each flattened to
24//!   a row. That row-major matrix is exactly the operand a patch-embedding
25//!   matmul wants.
26//!
27//! # Why there is no `conv2d` here
28//!
29//! ViT patch embedding is conventionally written as a `conv2d` with
30//! `kernel = stride = patch_size`. With **non-overlapping** patches that
31//! convolution is *identical* to flattening each tile and doing one matmul
32//! against the reshaped kernel — every input element lands in exactly one
33//! output window, so there is no sliding-window reuse for a convolution to
34//! exploit. `kopitiam-tensor` has `matmul` but no `conv2d`, and this identity is
35//! why that costs us nothing. (It stops being true for overlapping patches; a
36//! tower using `stride < kernel` would genuinely need a convolution.)
37//!
38//! # Channel order: CHW, not HWC
39//!
40//! Pixels arrive interleaved (`RGBRGBRGB…`, i.e. HWC) and leave planar
41//! (`RRR…GGG…BBB…`, i.e. CHW), because that is the layout the tower's weights
42//! are stored against. Mixing these up is the classic silent bug in this area:
43//! the shapes match, nothing errors, and the model sees colour noise.
44
45use kopitiam_tensor::Tensor;
46
47/// Per-channel mean used by SigLIP-family vision towers (the encoder SmolVLM
48/// uses), in the 0..1 range, RGB order.
49///
50/// Provenance: SigLIP's published preprocessing normalizes with mean 0.5 and
51/// std 0.5 per channel — i.e. it maps 0..1 to −1..1 — rather than the ImageNet
52/// statistics that CLIP-family towers use. Recorded as a constant because a
53/// wrong value here is invisible: no error, just degraded output.
54pub const SIGLIP_MEAN: [f32; 3] = [0.5, 0.5, 0.5];
55
56/// Per-channel standard deviation matching [`SIGLIP_MEAN`].
57pub const SIGLIP_STD: [f32; 3] = [0.5, 0.5, 0.5];
58
59/// An 8-bit RGB image, row-major and interleaved (`RGBRGB…`), as a rasterizer
60/// hands it over.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Rgb8 {
63    pub width: usize,
64    pub height: usize,
65    /// `width · height · 3` bytes, row-major, channels interleaved.
66    pub data: Vec<u8>,
67}
68
69impl Rgb8 {
70    /// Wraps a buffer, checking it is exactly the size the dimensions imply.
71    ///
72    /// Checked rather than assumed because the alternative is an out-of-bounds
73    /// panic deep inside resampling, where the message tells you nothing about
74    /// the real mistake (a stride miscalculation at the call site).
75    pub fn new(width: usize, height: usize, data: Vec<u8>) -> std::result::Result<Self, String> {
76        let want = width
77            .checked_mul(height)
78            .and_then(|n| n.checked_mul(3))
79            .ok_or_else(|| format!("image dimensions {width}x{height} overflow"))?;
80        if data.len() != want {
81            return Err(format!(
82                "RGB8 buffer is {} bytes, but {width}x{height}x3 needs {want}",
83                data.len()
84            ));
85        }
86        Ok(Self { width, height, data })
87    }
88
89    /// Reads the pixel at `(x, y)` as `[r, g, b]`, clamping coordinates into
90    /// range so edge sampling never goes out of bounds.
91    fn pixel_clamped(&self, x: usize, y: usize) -> [u8; 3] {
92        let x = x.min(self.width.saturating_sub(1));
93        let y = y.min(self.height.saturating_sub(1));
94        let i = (y * self.width + x) * 3;
95        [self.data[i], self.data[i + 1], self.data[i + 2]]
96    }
97}
98
99/// How an image is prepared for a specific vision tower.
100#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct PreprocessConfig {
102    /// Side length of the square canvas the tower expects, in pixels.
103    pub image_size: usize,
104    /// Side length of one square patch. Must divide [`Self::image_size`] — a
105    /// tower's position-embedding table has exactly `(image_size / patch_size)²`
106    /// entries, so a non-dividing pair describes a model that cannot exist.
107    pub patch_size: usize,
108    pub mean: [f32; 3],
109    pub std: [f32; 3],
110}
111
112impl Default for PreprocessConfig {
113    /// SigLIP-style 512px canvas with 16px patches — SmolVLM's shape.
114    ///
115    /// A default is offered only so tests and callers have a sane starting
116    /// point; a real run must take these from the loaded model's metadata,
117    /// because guessing them wrong produces plausible-looking garbage rather
118    /// than an error.
119    fn default() -> Self {
120        Self { image_size: 512, patch_size: 16, mean: SIGLIP_MEAN, std: SIGLIP_STD }
121    }
122}
123
124impl PreprocessConfig {
125    /// Patches per side of the canvas.
126    pub fn grid(&self) -> usize {
127        self.image_size / self.patch_size
128    }
129
130    /// Total patches — the sequence length the vision tower sees.
131    pub fn n_patches(&self) -> usize {
132        self.grid() * self.grid()
133    }
134
135    /// Flattened length of one patch: `patch · patch · 3`.
136    pub fn patch_dim(&self) -> usize {
137        self.patch_size * self.patch_size * 3
138    }
139
140    /// Rejects a configuration that cannot describe a real tower.
141    fn validate(&self) -> std::result::Result<(), String> {
142        if self.image_size == 0 || self.patch_size == 0 {
143            return Err("image_size and patch_size must both be non-zero".to_string());
144        }
145        if !self.image_size.is_multiple_of(self.patch_size) {
146            return Err(format!(
147                "patch_size {} must divide image_size {} exactly — the tower has one \
148                 position embedding per patch slot, so a partial patch has no slot",
149                self.patch_size, self.image_size
150            ));
151        }
152        if self.std.contains(&0.0) {
153            return Err("std must be non-zero in every channel (division by zero)".to_string());
154        }
155        Ok(())
156    }
157}
158
159/// Resizes to a square `size × size` canvas by nearest-neighbour sampling.
160///
161/// # Why nearest-neighbour, and when that stops being good enough
162///
163/// Nearest-neighbour is exactly reproducible (integer arithmetic, no rounding
164/// drift across platforms or float modes), which matters more than fidelity for
165/// a first cut: the same page must yield the same tensor on Termux and on a
166/// desktop, or nothing downstream is comparable.
167///
168/// It is genuinely worse than bilinear/Lanczos when **downsampling** a
169/// high-DPI page render, because it point-samples and so aliases thin strokes —
170/// exactly the strokes that carry text. A tower deciding *layout* (which is
171/// what routing needs) tolerates that; a tower asked to *read* would not. Revisit
172/// with an area-average resample before ever trusting this path for recognition.
173pub fn resize_square(img: &Rgb8, size: usize) -> std::result::Result<Rgb8, String> {
174    if size == 0 {
175        return Err("target size must be non-zero".to_string());
176    }
177    if img.width == 0 || img.height == 0 {
178        return Err("cannot resize an image with a zero dimension".to_string());
179    }
180    let mut out = vec![0u8; size * size * 3];
181    for y in 0..size {
182        // Sample at the CENTRE of each destination pixel (`+ 0.5`), not its
183        // corner. Corner sampling biases the whole image up-left by half a
184        // destination pixel — invisible on a photo, but a systematic shift on a
185        // rendered page, and it compounds with the patch grid.
186        let src_y = (((y as f64 + 0.5) * img.height as f64) / size as f64) as usize;
187        for x in 0..size {
188            let src_x = (((x as f64 + 0.5) * img.width as f64) / size as f64) as usize;
189            let [r, g, b] = img.pixel_clamped(src_x, src_y);
190            let i = (y * size + x) * 3;
191            out[i] = r;
192            out[i + 1] = g;
193            out[i + 2] = b;
194        }
195    }
196    Ok(Rgb8 { width: size, height: size, data: out })
197}
198
199/// Full preprocessing: resize, normalize, and patchify into the
200/// `[n_patches, patch_dim]` matrix a patch-embedding matmul consumes.
201///
202/// Rows are patches in **row-major grid order** (left-to-right, then top-to-
203/// bottom), which must match the order of the tower's position-embedding table;
204/// column-major here would pair every patch with the wrong position and degrade
205/// silently.
206///
207/// Within a row, values are ordered **channel-plane first** (all R of the patch,
208/// then all G, then all B) — the CHW convention the weights are stored against.
209pub fn preprocess(img: &Rgb8, cfg: &PreprocessConfig) -> std::result::Result<Tensor, String> {
210    cfg.validate()?;
211    let canvas = resize_square(img, cfg.image_size)?;
212
213    let grid = cfg.grid();
214    let patch = cfg.patch_size;
215    let patch_dim = cfg.patch_dim();
216    let mut rows = vec![0f32; cfg.n_patches() * patch_dim];
217
218    for gy in 0..grid {
219        for gx in 0..grid {
220            let row_base = (gy * grid + gx) * patch_dim;
221            for c in 0..3 {
222                // Channel-plane offset within the row: CHW inside each patch.
223                let plane = c * patch * patch;
224                for py in 0..patch {
225                    for px in 0..patch {
226                        let sx = gx * patch + px;
227                        let sy = gy * patch + py;
228                        let s = (sy * cfg.image_size + sx) * 3 + c;
229                        let v = f32::from(canvas.data[s]) / 255.0;
230                        rows[row_base + plane + py * patch + px] =
231                            (v - cfg.mean[c]) / cfg.std[c];
232                    }
233                }
234            }
235        }
236    }
237
238    Tensor::from_f32(rows, vec![cfg.n_patches(), patch_dim]).map_err(|e| e.to_string())
239}
240
241/// Convenience for the OCR/routing path: a grayscale page raster promoted to
242/// RGB by replicating the single channel.
243///
244/// Vision towers are trained on three-channel input; handing them one channel
245/// would mismatch the patch-embedding weight's shape. Replication is the
246/// standard promotion and is what the towers saw for greyscale training data.
247pub fn gray_to_rgb8(width: usize, height: usize, gray: &[u8]) -> std::result::Result<Rgb8, String> {
248    if gray.len() != width * height {
249        return Err(format!(
250            "grayscale buffer is {} bytes, but {width}x{height} needs {}",
251            gray.len(),
252            width * height
253        ));
254    }
255    let mut data = Vec::with_capacity(gray.len() * 3);
256    for &g in gray {
257        data.extend_from_slice(&[g, g, g]);
258    }
259    Rgb8::new(width, height, data)
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    /// A `w × h` image whose pixel `(x, y)` is a distinct, predictable colour,
267    /// so a geometry mistake shows up as the wrong VALUE rather than needing a
268    /// visual check.
269    fn ramp(w: usize, h: usize) -> Rgb8 {
270        let mut data = Vec::with_capacity(w * h * 3);
271        for y in 0..h {
272            for x in 0..w {
273                data.extend_from_slice(&[x as u8, y as u8, 7]);
274            }
275        }
276        Rgb8::new(w, h, data).unwrap()
277    }
278
279    fn solid(w: usize, h: usize, rgb: [u8; 3]) -> Rgb8 {
280        Rgb8::new(w, h, rgb.iter().cycle().take(w * h * 3).copied().collect()).unwrap()
281    }
282
283    #[test]
284    fn rgb8_rejects_a_buffer_that_does_not_match_its_dimensions() {
285        assert!(Rgb8::new(2, 2, vec![0; 11]).is_err());
286        assert!(Rgb8::new(2, 2, vec![0; 12]).is_ok());
287    }
288
289    #[test]
290    fn config_rejects_a_patch_size_that_does_not_divide_the_canvas() {
291        // 512/17 leaves a partial patch, which has no position-embedding slot —
292        // a model shaped like that cannot exist, so this must fail loudly rather
293        // than silently truncate the last row and column.
294        let bad = PreprocessConfig { patch_size: 17, ..Default::default() };
295        assert!(bad.validate().is_err());
296        assert!(PreprocessConfig::default().validate().is_ok());
297    }
298
299    #[test]
300    fn config_rejects_zero_std_before_it_divides_by_it() {
301        let bad = PreprocessConfig { std: [0.5, 0.0, 0.5], ..Default::default() };
302        assert!(bad.validate().is_err());
303    }
304
305    #[test]
306    fn resize_is_identity_when_the_size_already_matches() {
307        let img = ramp(8, 8);
308        assert_eq!(resize_square(&img, 8).unwrap(), img);
309    }
310
311    #[test]
312    fn resize_preserves_a_solid_colour_exactly() {
313        // Any resampling scheme must leave a constant image constant; if this
314        // fails the indexing is wrong, not the filter.
315        let out = resize_square(&solid(3, 7, [9, 200, 30]), 16).unwrap();
316        assert!(out.data.chunks(3).all(|p| p == [9, 200, 30]));
317    }
318
319    #[test]
320    fn resize_samples_pixel_centres_rather_than_corners() {
321        // Downscaling 4x4 -> 2x2 must take one pixel from each quadrant. Corner
322        // sampling would take (0,0),(2,0),(0,2),(2,2) — biased up-left by half a
323        // destination pixel. Centre sampling takes (1,1),(3,1),(1,3),(3,3).
324        let out = resize_square(&ramp(4, 4), 2).unwrap();
325        let px = |i: usize| [out.data[i * 3], out.data[i * 3 + 1]];
326        assert_eq!(px(0), [1, 1], "top-left should sample source (1,1)");
327        assert_eq!(px(1), [3, 1], "top-right should sample source (3,1)");
328        assert_eq!(px(2), [1, 3], "bottom-left should sample source (1,3)");
329        assert_eq!(px(3), [3, 3], "bottom-right should sample source (3,3)");
330    }
331
332    #[test]
333    fn preprocess_produces_the_shape_the_patch_embedding_matmul_wants() {
334        let cfg = PreprocessConfig { image_size: 32, patch_size: 16, ..Default::default() };
335        let t = preprocess(&ramp(40, 20), &cfg).unwrap();
336        // 32/16 = 2 -> 4 patches; each 16*16*3 = 768 long.
337        assert_eq!(t.shape().dims(), &[4, 768]);
338        assert_eq!(cfg.n_patches(), 4);
339        assert_eq!(cfg.patch_dim(), 768);
340    }
341
342    #[test]
343    fn normalization_maps_black_and_white_to_minus_one_and_one() {
344        // With mean = std = 0.5 the 0..1 range maps to -1..1. A wrong constant
345        // here never errors — it just shifts every activation — so pin it.
346        let cfg = PreprocessConfig { image_size: 16, patch_size: 16, ..Default::default() };
347        let black = preprocess(&solid(4, 4, [0, 0, 0]), &cfg).unwrap().to_vec_f32().unwrap();
348        assert!(black.iter().all(|v| (*v + 1.0).abs() < 1e-6), "black must map to -1");
349        let white = preprocess(&solid(4, 4, [255, 255, 255]), &cfg).unwrap().to_vec_f32().unwrap();
350        assert!(white.iter().all(|v| (*v - 1.0).abs() < 1e-6), "white must map to +1");
351    }
352
353    #[test]
354    fn each_patch_row_is_channel_planar_not_interleaved() {
355        // CHW inside the row: all R, then all G, then all B. Interleaving here is
356        // the classic silent bug — shapes still match, model sees colour noise.
357        let cfg = PreprocessConfig {
358            image_size: 2,
359            patch_size: 2,
360            mean: [0.0; 3],
361            std: [1.0; 3],
362        };
363        let img = solid(2, 2, [255, 0, 0]);
364        let v = preprocess(&img, &cfg).unwrap().to_vec_f32().unwrap();
365        assert_eq!(v.len(), 12);
366        assert!(v[0..4].iter().all(|x| (*x - 1.0).abs() < 1e-6), "first plane is R (all 1.0)");
367        assert!(v[4..12].iter().all(|x| x.abs() < 1e-6), "G and B planes are 0.0");
368    }
369
370    #[test]
371    fn patch_rows_are_in_row_major_grid_order() {
372        // Row order must match the tower's position-embedding table. Getting it
373        // column-major pairs every patch with the wrong position — no error, just
374        // quietly wrong. Two horizontally-split halves, distinct colours.
375        let cfg = PreprocessConfig { image_size: 2, patch_size: 1, mean: [0.0; 3], std: [1.0; 3] };
376        let img = Rgb8::new(2, 1, vec![255, 0, 0, /**/ 0, 0, 255]).unwrap();
377        let v = preprocess(&img, &cfg).unwrap().to_vec_f32().unwrap();
378        // 4 patches of dim 3. Row 0 = grid (0,0) = left = red.
379        assert_eq!(&v[0..3], &[1.0, 0.0, 0.0], "patch 0 must be the LEFT half");
380        assert_eq!(&v[3..6], &[0.0, 0.0, 1.0], "patch 1 must be the RIGHT half");
381    }
382
383    #[test]
384    fn preprocessing_is_deterministic_for_the_same_input() {
385        // The whole path must be reproducible: same page, same tensor, every run
386        // and every platform. Integer-arithmetic resampling is chosen for this.
387        let cfg = PreprocessConfig { image_size: 32, patch_size: 8, ..Default::default() };
388        let img = ramp(37, 19);
389        let a = preprocess(&img, &cfg).unwrap().to_vec_f32().unwrap();
390        let b = preprocess(&img, &cfg).unwrap().to_vec_f32().unwrap();
391        assert_eq!(a, b);
392    }
393
394    #[test]
395    fn gray_promotes_to_three_identical_channels() {
396        let rgb = gray_to_rgb8(2, 1, &[10, 200]).unwrap();
397        assert_eq!(rgb.data, vec![10, 10, 10, 200, 200, 200]);
398        assert!(gray_to_rgb8(2, 1, &[10]).is_err(), "wrong-length buffer must be caught");
399    }
400}