#![cfg(feature = "vl-preprocess")]
use crate::model::vision_encoder::VisionEncoderConfig;
use crate::session::CeraError;
#[derive(Debug, Clone, PartialEq)]
pub struct PreprocessedImage {
pub pixels: Vec<f32>,
pub target_w: usize,
pub target_h: usize,
pub grid_w: usize,
pub grid_h: usize,
}
pub fn calc_size_preserved_ratio(
width: usize,
height: usize,
align_size: usize,
min_pixels: usize,
max_pixels: usize,
) -> (usize, usize) {
debug_assert!(align_size > 0);
debug_assert!(min_pixels <= max_pixels);
if width == 0 || height == 0 {
return (align_size, align_size);
}
let round_by = |x: f64| ((x / align_size as f64).round() as usize) * align_size;
let floor_by = |x: f64| ((x / align_size as f64).floor() as usize) * align_size;
let ceil_by = |x: f64| ((x / align_size as f64).ceil() as usize) * align_size;
let mut w_bar = align_size.max(round_by(width as f64));
let mut h_bar = align_size.max(round_by(height as f64));
let area = (width as f64) * (height as f64);
let area_check = h_bar.saturating_mul(w_bar);
if area_check > max_pixels {
let beta = (area / max_pixels as f64).sqrt();
w_bar = align_size.max(floor_by((width as f64) / beta));
h_bar = align_size.max(floor_by((height as f64) / beta));
} else if area_check < min_pixels {
let beta = (min_pixels as f64 / area).sqrt();
w_bar = ceil_by((width as f64) * beta);
h_bar = ceil_by((height as f64) * beta);
}
(w_bar, h_bar)
}
const MAX_DECODE_DIM: u32 = 16_384;
pub fn preprocess_image(
bytes: &[u8],
cfg: &VisionEncoderConfig,
) -> Result<PreprocessedImage, CeraError> {
preprocess_image_with_opts(bytes, cfg, None)
}
pub fn preprocess_image_with_opts(
bytes: &[u8],
cfg: &VisionEncoderConfig,
max_long_size: Option<u32>,
) -> Result<PreprocessedImage, CeraError> {
if bytes.is_empty() {
return Err(CeraError::EmptyInput);
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.map_err(|e| CeraError::Backend(format!("image format detection failed: {e}")))?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_DECODE_DIM);
limits.max_image_height = Some(MAX_DECODE_DIM);
reader.limits(limits);
let img = reader
.decode()
.map_err(|e| CeraError::Backend(format!("image decode failed: {e}")))?;
let align = cfg.patch_size * cfg.scale_factor;
let (mut target_w, mut target_h) = calc_size_preserved_ratio(
img.width() as usize,
img.height() as usize,
align,
cfg.image_min_pixels,
cfg.image_max_pixels,
);
if let Some(cap) = max_long_size.filter(|&c| c > 0).map(|c| c as usize) {
let long = target_w.max(target_h);
if long > cap {
let beta = cap as f64 / long as f64; let floor_align = |x: f64| align.max(((x / align as f64).floor() as usize) * align);
target_w = floor_align(target_w as f64 * beta);
target_h = floor_align(target_h as f64 * beta);
}
}
debug_assert_eq!(target_w % cfg.patch_size, 0);
debug_assert_eq!(target_h % cfg.patch_size, 0);
let rgb = if img.width() == target_w as u32 && img.height() == target_h as u32 {
img.into_rgb8()
} else {
img.resize_exact(
target_w as u32,
target_h as u32,
image::imageops::FilterType::Triangle,
)
.into_rgb8()
};
let h = rgb.height() as usize;
let w = rgb.width() as usize;
debug_assert_eq!(h, target_h);
debug_assert_eq!(w, target_w);
let mut pixels = vec![0f32; 3 * h * w];
let raw = rgb.as_raw(); for c in 0..3 {
let mean = cfg.image_mean[c];
let std_inv = 1.0 / cfg.image_std[c];
for y in 0..h {
for x in 0..w {
let src = (y * w + x) * 3 + c;
let dst = c * h * w + y * w + x;
let pixel = raw[src] as f32 / 255.0;
pixels[dst] = (pixel - mean) * std_inv;
}
}
}
Ok(PreprocessedImage {
pixels,
target_w: w,
target_h: h,
grid_w: w / cfg.patch_size,
grid_h: h / cfg.patch_size,
})
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgb};
fn synth_cfg() -> VisionEncoderConfig {
VisionEncoderConfig {
n_layer: 12,
n_embd: 768,
n_ff: 3072,
n_head: 12,
eps: 1e-6,
image_size: 4,
patch_size: 2,
n_trained_patches: 4,
projection_dim: 1024,
scale_factor: 2,
image_mean: [0.5, 0.4, 0.3],
image_std: [0.2, 0.25, 0.5],
image_min_pixels: 16,
image_max_pixels: 16,
}
}
#[test]
fn calc_size_preserved_ratio_pug_shape() {
let (w, h) = calc_size_preserved_ratio(1024, 771, 32, 65_536, 262_144);
assert_eq!((w, h), (576, 416));
assert_eq!((w / 16) * (h / 16), 936);
}
#[test]
fn calc_size_preserved_ratio_scales_up_small_input() {
let (w, h) = calc_size_preserved_ratio(100, 100, 32, 65_536, 262_144);
assert!(
w * h >= 65_536,
"scaled-up area {w}×{h} = {} should ≥ min_pixels (65 536)",
w * h
);
assert_eq!(w % 32, 0);
assert_eq!(h % 32, 0);
}
#[test]
fn calc_size_preserved_ratio_clamps_huge_input() {
let (w, h) = calc_size_preserved_ratio(4096, 1024, 32, 65_536, 262_144);
assert!(
w * h <= 262_144,
"scaled-down area {w}×{h} = {} should ≤ max_pixels (262 144)",
w * h
);
assert_eq!(w % 32, 0);
assert_eq!(h % 32, 0);
let aspect = w as f32 / h as f32;
assert!(
(3.5..=4.5).contains(&aspect),
"expected ~4:1 aspect, got {aspect}"
);
}
#[test]
fn calc_size_preserved_ratio_passes_through_when_in_band() {
let (w, h) = calc_size_preserved_ratio(256, 256, 32, 65_536, 262_144);
assert_eq!((w, h), (256, 256));
}
#[test]
fn calc_size_preserved_ratio_zero_dims_returns_align() {
assert_eq!(
calc_size_preserved_ratio(0, 100, 32, 65_536, 262_144),
(32, 32)
);
assert_eq!(
calc_size_preserved_ratio(100, 0, 32, 65_536, 262_144),
(32, 32)
);
assert_eq!(
calc_size_preserved_ratio(0, 0, 32, 65_536, 262_144),
(32, 32)
);
}
#[test]
fn preprocess_solid_red_normalises_per_channel() {
let cfg = synth_cfg();
let img = ImageBuffer::<Rgb<u8>, _>::from_fn(4, 4, |_, _| Rgb([255u8, 0, 0]));
let mut bytes = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.expect("encode test png");
let pre = preprocess_image(&bytes, &cfg).expect("preprocess");
assert_eq!(pre.target_w, 4);
assert_eq!(pre.target_h, 4);
assert_eq!(pre.grid_w, 2);
assert_eq!(pre.grid_h, 2);
let out = pre.pixels;
assert_eq!(out.len(), 3 * 4 * 4);
let n = 4 * 4;
for &v in &out[0..n] {
assert!((v - 2.5).abs() < 1e-5, "R channel: {v}");
}
for &v in &out[n..2 * n] {
assert!((v - (-1.6)).abs() < 1e-5, "G channel: {v}");
}
for &v in &out[2 * n..3 * n] {
assert!((v - (-0.6)).abs() < 1e-5, "B channel: {v}");
}
}
#[test]
fn preprocess_empty_bytes_errors() {
let cfg = synth_cfg();
match preprocess_image(&[], &cfg) {
Err(CeraError::EmptyInput) => {}
other => panic!("expected EmptyInput, got {other:?}"),
}
}
#[test]
fn preprocess_max_long_size_caps_long_side() {
let cfg = VisionEncoderConfig {
patch_size: 2,
scale_factor: 1,
image_min_pixels: 4,
image_max_pixels: 1_000_000,
..synth_cfg()
};
let img = ImageBuffer::<Rgb<u8>, _>::from_fn(800, 400, |_, _| Rgb([128u8, 64, 32]));
let mut bytes = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.expect("encode test png");
let uncapped = preprocess_image_with_opts(&bytes, &cfg, None).expect("uncapped");
assert_eq!((uncapped.target_w, uncapped.target_h), (800, 400));
let capped = preprocess_image_with_opts(&bytes, &cfg, Some(100)).expect("capped");
assert_eq!((capped.target_w, capped.target_h), (100, 50));
let big_cap = preprocess_image_with_opts(&bytes, &cfg, Some(4000)).expect("big cap");
assert_eq!((big_cap.target_w, big_cap.target_h), (800, 400));
let zero_cap = preprocess_image_with_opts(&bytes, &cfg, Some(0)).expect("zero cap");
assert_eq!((zero_cap.target_w, zero_cap.target_h), (800, 400));
}
#[test]
fn preprocess_max_long_size_takes_precedence_over_min_pixels() {
let cfg = VisionEncoderConfig {
patch_size: 16,
scale_factor: 2,
image_min_pixels: 65_536,
image_max_pixels: 262_144,
..synth_cfg()
};
let img = ImageBuffer::<Rgb<u8>, _>::from_fn(256, 256, |_, _| Rgb([200u8, 100, 50]));
let mut bytes = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Png,
)
.expect("encode test png");
let uncapped = preprocess_image_with_opts(&bytes, &cfg, None).expect("uncapped");
assert_eq!((uncapped.target_w, uncapped.target_h), (256, 256));
let capped = preprocess_image_with_opts(&bytes, &cfg, Some(128)).expect("capped");
assert_eq!((capped.target_w, capped.target_h), (128, 128));
assert!(
capped.target_w * capped.target_h < cfg.image_min_pixels,
"cap must take precedence over min_pixels (no upscale-back); got {}×{}",
capped.target_w,
capped.target_h,
);
}
#[test]
fn preprocess_jpeg_resizes_to_target() {
let cfg = synth_cfg();
let img = ImageBuffer::<Rgb<u8>, _>::from_fn(8, 8, |_, _| Rgb([255u8, 0, 0]));
let mut bytes = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(
&mut std::io::Cursor::new(&mut bytes),
image::ImageFormat::Jpeg,
)
.expect("encode test jpeg");
let pre = preprocess_image(&bytes, &cfg).expect("preprocess");
assert_eq!(pre.target_w, 4);
assert_eq!(pre.target_h, 4);
assert_eq!(pre.pixels.len(), 3 * 4 * 4);
let n = 4 * 4;
let r_avg = pre.pixels[0..n].iter().sum::<f32>() / (n as f32);
assert!((r_avg - 2.5).abs() < 0.1, "R channel mean: {r_avg}");
}
}