use kopitiam_tensor::Tensor;
pub const SIGLIP_MEAN: [f32; 3] = [0.5, 0.5, 0.5];
pub const SIGLIP_STD: [f32; 3] = [0.5, 0.5, 0.5];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rgb8 {
pub width: usize,
pub height: usize,
pub data: Vec<u8>,
}
impl Rgb8 {
pub fn new(width: usize, height: usize, data: Vec<u8>) -> std::result::Result<Self, String> {
let want = width
.checked_mul(height)
.and_then(|n| n.checked_mul(3))
.ok_or_else(|| format!("image dimensions {width}x{height} overflow"))?;
if data.len() != want {
return Err(format!(
"RGB8 buffer is {} bytes, but {width}x{height}x3 needs {want}",
data.len()
));
}
Ok(Self { width, height, data })
}
fn pixel_clamped(&self, x: usize, y: usize) -> [u8; 3] {
let x = x.min(self.width.saturating_sub(1));
let y = y.min(self.height.saturating_sub(1));
let i = (y * self.width + x) * 3;
[self.data[i], self.data[i + 1], self.data[i + 2]]
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PreprocessConfig {
pub image_size: usize,
pub patch_size: usize,
pub mean: [f32; 3],
pub std: [f32; 3],
}
impl Default for PreprocessConfig {
fn default() -> Self {
Self { image_size: 512, patch_size: 16, mean: SIGLIP_MEAN, std: SIGLIP_STD }
}
}
impl PreprocessConfig {
pub fn grid(&self) -> usize {
self.image_size / self.patch_size
}
pub fn n_patches(&self) -> usize {
self.grid() * self.grid()
}
pub fn patch_dim(&self) -> usize {
self.patch_size * self.patch_size * 3
}
fn validate(&self) -> std::result::Result<(), String> {
if self.image_size == 0 || self.patch_size == 0 {
return Err("image_size and patch_size must both be non-zero".to_string());
}
if !self.image_size.is_multiple_of(self.patch_size) {
return Err(format!(
"patch_size {} must divide image_size {} exactly — the tower has one \
position embedding per patch slot, so a partial patch has no slot",
self.patch_size, self.image_size
));
}
if self.std.contains(&0.0) {
return Err("std must be non-zero in every channel (division by zero)".to_string());
}
Ok(())
}
}
pub fn resize_square(img: &Rgb8, size: usize) -> std::result::Result<Rgb8, String> {
if size == 0 {
return Err("target size must be non-zero".to_string());
}
if img.width == 0 || img.height == 0 {
return Err("cannot resize an image with a zero dimension".to_string());
}
let mut out = vec![0u8; size * size * 3];
for y in 0..size {
let src_y = (((y as f64 + 0.5) * img.height as f64) / size as f64) as usize;
for x in 0..size {
let src_x = (((x as f64 + 0.5) * img.width as f64) / size as f64) as usize;
let [r, g, b] = img.pixel_clamped(src_x, src_y);
let i = (y * size + x) * 3;
out[i] = r;
out[i + 1] = g;
out[i + 2] = b;
}
}
Ok(Rgb8 { width: size, height: size, data: out })
}
pub fn preprocess(img: &Rgb8, cfg: &PreprocessConfig) -> std::result::Result<Tensor, String> {
cfg.validate()?;
let canvas = resize_square(img, cfg.image_size)?;
let grid = cfg.grid();
let patch = cfg.patch_size;
let patch_dim = cfg.patch_dim();
let mut rows = vec![0f32; cfg.n_patches() * patch_dim];
for gy in 0..grid {
for gx in 0..grid {
let row_base = (gy * grid + gx) * patch_dim;
for c in 0..3 {
let plane = c * patch * patch;
for py in 0..patch {
for px in 0..patch {
let sx = gx * patch + px;
let sy = gy * patch + py;
let s = (sy * cfg.image_size + sx) * 3 + c;
let v = f32::from(canvas.data[s]) / 255.0;
rows[row_base + plane + py * patch + px] =
(v - cfg.mean[c]) / cfg.std[c];
}
}
}
}
}
Tensor::from_f32(rows, vec![cfg.n_patches(), patch_dim]).map_err(|e| e.to_string())
}
pub fn gray_to_rgb8(width: usize, height: usize, gray: &[u8]) -> std::result::Result<Rgb8, String> {
if gray.len() != width * height {
return Err(format!(
"grayscale buffer is {} bytes, but {width}x{height} needs {}",
gray.len(),
width * height
));
}
let mut data = Vec::with_capacity(gray.len() * 3);
for &g in gray {
data.extend_from_slice(&[g, g, g]);
}
Rgb8::new(width, height, data)
}
#[cfg(test)]
mod tests {
use super::*;
fn ramp(w: usize, h: usize) -> Rgb8 {
let mut data = Vec::with_capacity(w * h * 3);
for y in 0..h {
for x in 0..w {
data.extend_from_slice(&[x as u8, y as u8, 7]);
}
}
Rgb8::new(w, h, data).unwrap()
}
fn solid(w: usize, h: usize, rgb: [u8; 3]) -> Rgb8 {
Rgb8::new(w, h, rgb.iter().cycle().take(w * h * 3).copied().collect()).unwrap()
}
#[test]
fn rgb8_rejects_a_buffer_that_does_not_match_its_dimensions() {
assert!(Rgb8::new(2, 2, vec![0; 11]).is_err());
assert!(Rgb8::new(2, 2, vec![0; 12]).is_ok());
}
#[test]
fn config_rejects_a_patch_size_that_does_not_divide_the_canvas() {
let bad = PreprocessConfig { patch_size: 17, ..Default::default() };
assert!(bad.validate().is_err());
assert!(PreprocessConfig::default().validate().is_ok());
}
#[test]
fn config_rejects_zero_std_before_it_divides_by_it() {
let bad = PreprocessConfig { std: [0.5, 0.0, 0.5], ..Default::default() };
assert!(bad.validate().is_err());
}
#[test]
fn resize_is_identity_when_the_size_already_matches() {
let img = ramp(8, 8);
assert_eq!(resize_square(&img, 8).unwrap(), img);
}
#[test]
fn resize_preserves_a_solid_colour_exactly() {
let out = resize_square(&solid(3, 7, [9, 200, 30]), 16).unwrap();
assert!(out.data.chunks(3).all(|p| p == [9, 200, 30]));
}
#[test]
fn resize_samples_pixel_centres_rather_than_corners() {
let out = resize_square(&ramp(4, 4), 2).unwrap();
let px = |i: usize| [out.data[i * 3], out.data[i * 3 + 1]];
assert_eq!(px(0), [1, 1], "top-left should sample source (1,1)");
assert_eq!(px(1), [3, 1], "top-right should sample source (3,1)");
assert_eq!(px(2), [1, 3], "bottom-left should sample source (1,3)");
assert_eq!(px(3), [3, 3], "bottom-right should sample source (3,3)");
}
#[test]
fn preprocess_produces_the_shape_the_patch_embedding_matmul_wants() {
let cfg = PreprocessConfig { image_size: 32, patch_size: 16, ..Default::default() };
let t = preprocess(&ramp(40, 20), &cfg).unwrap();
assert_eq!(t.shape().dims(), &[4, 768]);
assert_eq!(cfg.n_patches(), 4);
assert_eq!(cfg.patch_dim(), 768);
}
#[test]
fn normalization_maps_black_and_white_to_minus_one_and_one() {
let cfg = PreprocessConfig { image_size: 16, patch_size: 16, ..Default::default() };
let black = preprocess(&solid(4, 4, [0, 0, 0]), &cfg).unwrap().to_vec_f32().unwrap();
assert!(black.iter().all(|v| (*v + 1.0).abs() < 1e-6), "black must map to -1");
let white = preprocess(&solid(4, 4, [255, 255, 255]), &cfg).unwrap().to_vec_f32().unwrap();
assert!(white.iter().all(|v| (*v - 1.0).abs() < 1e-6), "white must map to +1");
}
#[test]
fn each_patch_row_is_channel_planar_not_interleaved() {
let cfg = PreprocessConfig {
image_size: 2,
patch_size: 2,
mean: [0.0; 3],
std: [1.0; 3],
};
let img = solid(2, 2, [255, 0, 0]);
let v = preprocess(&img, &cfg).unwrap().to_vec_f32().unwrap();
assert_eq!(v.len(), 12);
assert!(v[0..4].iter().all(|x| (*x - 1.0).abs() < 1e-6), "first plane is R (all 1.0)");
assert!(v[4..12].iter().all(|x| x.abs() < 1e-6), "G and B planes are 0.0");
}
#[test]
fn patch_rows_are_in_row_major_grid_order() {
let cfg = PreprocessConfig { image_size: 2, patch_size: 1, mean: [0.0; 3], std: [1.0; 3] };
let img = Rgb8::new(2, 1, vec![255, 0, 0, 0, 0, 255]).unwrap();
let v = preprocess(&img, &cfg).unwrap().to_vec_f32().unwrap();
assert_eq!(&v[0..3], &[1.0, 0.0, 0.0], "patch 0 must be the LEFT half");
assert_eq!(&v[3..6], &[0.0, 0.0, 1.0], "patch 1 must be the RIGHT half");
}
#[test]
fn preprocessing_is_deterministic_for_the_same_input() {
let cfg = PreprocessConfig { image_size: 32, patch_size: 8, ..Default::default() };
let img = ramp(37, 19);
let a = preprocess(&img, &cfg).unwrap().to_vec_f32().unwrap();
let b = preprocess(&img, &cfg).unwrap().to_vec_f32().unwrap();
assert_eq!(a, b);
}
#[test]
fn gray_promotes_to_three_identical_channels() {
let rgb = gray_to_rgb8(2, 1, &[10, 200]).unwrap();
assert_eq!(rgb.data, vec![10, 10, 10, 200, 200, 200]);
assert!(gray_to_rgb8(2, 1, &[10]).is_err(), "wrong-length buffer must be caught");
}
}