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}