use image::{DynamicImage, ImageReader, RgbImage};
use std::io::Cursor;
use super::VisionError;
pub const QWEN3_VL_IMAGE_MEAN: [f32; 3] = [0.481_454_6, 0.457_827_5, 0.408_210_7];
pub const QWEN3_VL_IMAGE_STD: [f32; 3] = [0.268_629_5, 0.261_287_7, 0.275_777];
#[derive(Debug, Clone)]
pub struct PreprocessConfig {
pub image_size: u32,
pub patch_size: u32,
pub mean: [f32; 3],
pub std: [f32; 3],
}
impl PreprocessConfig {
pub fn qwen3_vl() -> Self {
Self {
image_size: 448,
patch_size: 16,
mean: QWEN3_VL_IMAGE_MEAN,
std: QWEN3_VL_IMAGE_STD,
}
}
}
#[derive(Debug, Clone)]
pub struct ImageTensor {
pub patches: Vec<f32>,
pub n_patches: usize,
pub patch_hw: usize,
}
impl ImageTensor {
pub fn patch_len(&self) -> usize {
self.patch_hw * self.patch_hw * 3
}
}
pub fn preprocess(image_bytes: &[u8], cfg: &PreprocessConfig) -> Result<ImageTensor, VisionError> {
if cfg.patch_size == 0 {
return Err(VisionError::InvalidConfig("patch_size must be > 0".into()));
}
if !cfg.image_size.is_multiple_of(cfg.patch_size) {
return Err(VisionError::InvalidConfig(format!(
"image_size {} must be divisible by patch_size {}",
cfg.image_size, cfg.patch_size
)));
}
for (i, &s) in cfg.std.iter().enumerate() {
if s <= 0.0 {
return Err(VisionError::InvalidConfig(format!(
"std[{i}]={s} must be > 0"
)));
}
}
let reader = ImageReader::new(Cursor::new(image_bytes))
.with_guessed_format()
.map_err(|e| VisionError::ImageDecode(format!("format detection failed: {e}")))?;
let img: DynamicImage = reader
.decode()
.map_err(|e| VisionError::ImageDecode(format!("decode failed: {e}")))?;
let size = cfg.image_size;
let resized: RgbImage = img
.resize_exact(size, size, image::imageops::FilterType::Triangle)
.into_rgb8();
let patch_size = cfg.patch_size as usize;
let image_size = cfg.image_size as usize;
let patches_per_side = image_size / patch_size;
let n_patches = patches_per_side * patches_per_side;
let patch_len = patch_size * patch_size * 3;
let mut patches = vec![0.0f32; n_patches * patch_len];
for py in 0..patches_per_side {
for px in 0..patches_per_side {
let patch_idx = py * patches_per_side + px;
let patch_slice = &mut patches[patch_idx * patch_len..(patch_idx + 1) * patch_len];
let pixel_start_y = py * patch_size;
let pixel_start_x = px * patch_size;
let mut out_idx = 0usize;
for dy in 0..patch_size {
for dx in 0..patch_size {
let px_coord = (pixel_start_x + dx) as u32;
let py_coord = (pixel_start_y + dy) as u32;
let pixel = resized.get_pixel(px_coord, py_coord);
for c in 0..3usize {
let raw = pixel[c] as f32 / 255.0;
patch_slice[out_idx] = (raw - cfg.mean[c]) / cfg.std[c];
out_idx += 1;
}
}
}
}
}
Ok(ImageTensor {
patches,
n_patches,
patch_hw: patch_size,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_png(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
let mut img = RgbImage::new(width, height);
for y in 0..height {
for x in 0..width {
img.put_pixel(x, y, image::Rgb([r, g, b]));
}
}
let mut buf = Vec::new();
let mut cursor = std::io::Cursor::new(&mut buf);
img.write_to(&mut cursor, image::ImageFormat::Png)
.expect("test PNG encode");
buf
}
#[test]
fn preprocess_patch_count_448_16() {
let cfg = PreprocessConfig::qwen3_vl();
let png = make_test_png(64, 64, 128, 64, 32);
let tensor = preprocess(&png, &cfg).expect("preprocess");
assert_eq!(tensor.n_patches, 784); assert_eq!(tensor.patch_hw, 16);
assert_eq!(tensor.patches.len(), 784 * 16 * 16 * 3);
}
#[test]
fn preprocess_patch_len_helper() {
let cfg = PreprocessConfig::qwen3_vl();
let png = make_test_png(32, 32, 0, 0, 0);
let tensor = preprocess(&png, &cfg).expect("preprocess");
assert_eq!(tensor.patch_len(), 16 * 16 * 3); }
#[test]
fn preprocess_normalization_is_applied() {
let cfg = PreprocessConfig::qwen3_vl();
let png = make_test_png(32, 32, 255, 255, 255);
let tensor = preprocess(&png, &cfg).expect("preprocess");
let v_r = tensor.patches[0];
let expected_r = (1.0_f32 - QWEN3_VL_IMAGE_MEAN[0]) / QWEN3_VL_IMAGE_STD[0];
assert!(
(v_r - expected_r).abs() < 1e-4,
"channel R: got {v_r}, expected {expected_r}"
);
}
#[test]
fn preprocess_black_image_normalized() {
let cfg = PreprocessConfig::qwen3_vl();
let png = make_test_png(32, 32, 0, 0, 0);
let tensor = preprocess(&png, &cfg).expect("preprocess");
let v_r = tensor.patches[0];
let expected_r = (0.0_f32 - QWEN3_VL_IMAGE_MEAN[0]) / QWEN3_VL_IMAGE_STD[0];
assert!(
(v_r - expected_r).abs() < 1e-4,
"black image channel R: got {v_r}, expected {expected_r}"
);
}
#[test]
fn preprocess_invalid_config_indivisible() {
let mut cfg = PreprocessConfig::qwen3_vl();
cfg.image_size = 449; let png = make_test_png(32, 32, 0, 0, 0);
assert!(preprocess(&png, &cfg).is_err());
}
#[test]
fn preprocess_invalid_bytes() {
let cfg = PreprocessConfig::qwen3_vl();
let result = preprocess(b"not an image", &cfg);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), VisionError::ImageDecode(_)));
}
#[test]
fn preprocess_dimensions_custom_config() {
let cfg = PreprocessConfig {
image_size: 64,
patch_size: 8,
mean: [0.5, 0.5, 0.5],
std: [0.5, 0.5, 0.5],
};
let png = make_test_png(32, 32, 128, 128, 128);
let tensor = preprocess(&png, &cfg).expect("preprocess 64/8");
assert_eq!(tensor.n_patches, 64); assert_eq!(tensor.patch_hw, 8);
assert_eq!(tensor.patches.len(), 64 * 8 * 8 * 3);
}
}