1#![allow(
9 unsafe_code,
10 clippy::similar_names,
11 clippy::cast_precision_loss,
12 clippy::cast_possible_wrap,
13 clippy::cast_sign_loss,
14 clippy::cast_possible_truncation,
15 clippy::wildcard_imports,
16 clippy::ptr_as_ptr,
17 clippy::cast_lossless,
18 clippy::single_match_else,
19 clippy::suboptimal_flops,
20 clippy::manual_div_ceil
21)]
22
23use std::cell::RefCell;
24use std::num::NonZeroUsize;
25use std::sync::Arc;
26
27use half::f16;
28use image::{DynamicImage, GenericImageView, RgbImage};
29use lru::LruCache;
30use ndarray::{Array3, Array4};
31
32use crate::inference::Quantization;
33
34#[doc(hidden)]
36pub trait IntoQuantization {
37 fn into_quantization(self) -> Option<Quantization>;
39}
40
41impl IntoQuantization for Option<Quantization> {
42 fn into_quantization(self) -> Option<Quantization> {
43 self
44 }
45}
46
47impl IntoQuantization for bool {
48 fn into_quantization(self) -> Option<Quantization> {
49 crate::inference::handle_deprecated_precision(None, Some(self))
50 }
51}
52
53pub const LETTERBOX_COLOR: [u8; 3] = [114, 114, 114];
55
56const SCALE_BITS: i32 = 11;
59const SCALE_INT: i32 = 1 << SCALE_BITS;
60
61const SCALE_BITS_2X: i32 = 2 * SCALE_BITS;
63
64const ROUND_BIAS: i32 = 1 << (SCALE_BITS_2X - 1);
67
68const LETTERBOX_NORM: f32 = 114.0 / 255.0;
70
71const INV_255: f32 = 1.0 / 255.0;
73
74const LUT_CACHE_SIZE: usize = 8;
76
77type XLutEntry = (usize, usize, i32, i32);
80type XLutKey = (u32, u32);
81
82thread_local! {
83 static X_LUT_CACHE: RefCell<LruCache<XLutKey, Arc<Vec<XLutEntry>>>> =
84 RefCell::new(LruCache::new(NonZeroUsize::new(LUT_CACHE_SIZE).unwrap()));
85}
86
87#[derive(Debug, Clone)]
89pub struct PreprocessResult {
90 pub tensor: Array4<f32>,
92 pub tensor_f16: Option<Array4<f16>>,
94 pub orig_shape: (u32, u32),
96 pub scale: (f32, f32),
98 pub padding: (f32, f32),
100}
101
102#[derive(Clone, Copy)]
104pub(crate) struct LetterboxGeometry {
105 pub(crate) new_w: u32,
106 pub(crate) new_h: u32,
107 pub(crate) pad_left: u32,
108 pub(crate) pad_top: u32,
109}
110
111impl LetterboxGeometry {
112 #[allow(
120 clippy::cast_precision_loss,
121 clippy::cast_possible_truncation,
122 clippy::cast_sign_loss
123 )]
124 pub(crate) fn compute(
125 orig_w: u32,
126 orig_h: u32,
127 target: (usize, usize),
128 stretch: bool,
129 ) -> (Self, (f32, f32)) {
130 if stretch {
134 let gain = |extent: usize, orig: u32| {
135 if orig == 0 {
136 1.0
137 } else {
138 extent as f32 / orig as f32
139 }
140 };
141 let geom = Self {
142 new_w: target.1 as u32,
143 new_h: target.0 as u32,
144 pad_left: 0,
145 pad_top: 0,
146 };
147 return (geom, (gain(target.0, orig_h), gain(target.1, orig_w)));
148 }
149 let (target_h, target_w) = (target.0 as f32, target.1 as f32);
150 let (orig_hf, orig_wf) = (orig_h as f32, orig_w as f32);
151 let scale = (target_h / orig_hf).min(target_w / orig_wf);
152 let extent = |orig_extent: f32, orig: u32| {
156 if orig == 0 {
157 0
158 } else {
159 ((orig_extent * scale).round() as u32).max(1)
160 }
161 };
162 let new_w = extent(orig_wf, orig_w);
163 let new_h = extent(orig_hf, orig_h);
164 let pad_left = (target.1 as u32).saturating_sub(new_w) / 2;
165 let pad_top = (target.0 as u32).saturating_sub(new_h) / 2;
166 (
167 Self {
168 new_w,
169 new_h,
170 pad_left,
171 pad_top,
172 },
173 (scale, scale),
174 )
175 }
176}
177
178#[allow(clippy::cast_precision_loss)]
183fn build_preprocess_result(
184 image: &DynamicImage,
185 target_size: (usize, usize),
186 geom: LetterboxGeometry,
187 scale: (f32, f32),
188 orig_shape: (u32, u32),
189 fp16: bool,
190) -> PreprocessResult {
191 let (orig_width, orig_height) = image.dimensions();
192
193 let tensor = match image {
194 DynamicImage::ImageRgb8(rgb) => {
195 fused_zerocopy_preprocess(rgb.as_raw(), orig_width, orig_height, target_size, &geom)
196 }
197 _ => {
198 let src_rgb = image.to_rgb8();
199 fused_zerocopy_preprocess(
200 src_rgb.as_raw(),
201 orig_width,
202 orig_height,
203 target_size,
204 &geom,
205 )
206 }
207 };
208
209 let tensor_f16 = if fp16 {
210 Some(tensor_f32_to_f16(&tensor))
211 } else {
212 None
213 };
214
215 PreprocessResult {
216 tensor,
217 tensor_f16,
218 orig_shape,
219 scale,
220 padding: (geom.pad_top as f32, geom.pad_left as f32),
221 }
222}
223
224#[must_use]
239pub fn preprocess_image(
240 image: &DynamicImage,
241 target_size: (usize, usize),
242 stride: u32,
243) -> PreprocessResult {
244 preprocess_image_with_precision(image, target_size, stride, None)
245}
246
247#[must_use]
260pub fn preprocess_image_with_precision(
261 image: &DynamicImage,
262 target_size: (usize, usize),
263 stride: u32,
264 quantize: impl IntoQuantization,
265) -> PreprocessResult {
266 let quantize = quantize.into_quantization();
267 let (orig_width, orig_height) = image.dimensions();
268 let orig_shape = (orig_height, orig_width);
269
270 let (geom, scale) = calculate_letterbox_params(orig_width, orig_height, target_size, stride);
271 build_preprocess_result(
272 image,
273 target_size,
274 geom,
275 scale,
276 orig_shape,
277 quantize == Some(Quantization::Fp16),
278 )
279}
280
281#[must_use]
297pub fn preprocess_image_stretch(
298 image: &DynamicImage,
299 target_size: (usize, usize),
300 quantize: impl IntoQuantization,
301) -> PreprocessResult {
302 let quantize = quantize.into_quantization();
303 let (orig_width, orig_height) = image.dimensions();
304 let (geom, scale) = LetterboxGeometry::compute(orig_width, orig_height, target_size, true);
305 build_preprocess_result(
306 image,
307 target_size,
308 geom,
309 scale,
310 (orig_height, orig_width),
311 quantize == Some(Quantization::Fp16),
312 )
313}
314
315fn get_or_compute_x_lut(src_w: u32, dst_w: u32) -> Arc<Vec<XLutEntry>> {
323 let key = (src_w, dst_w);
324
325 X_LUT_CACHE.with(|cache| {
326 let mut cache = cache.borrow_mut();
327
328 if let Some(lut) = cache.get(&key) {
329 return Arc::clone(lut);
330 }
331
332 let scale_x = src_w as f32 / dst_w as f32;
333 let src_w_max = (src_w - 1) as i32;
334
335 let lut: Arc<Vec<XLutEntry>> = Arc::new(
336 (0..dst_w)
337 .map(|dx| {
338 let sx = ((dx as f32 + 0.5) * scale_x - 0.5).max(0.0);
339 let x0 = sx.floor() as i32;
340 let fx_f = sx - x0 as f32;
343 let fx_inv = ((1.0 - fx_f) * SCALE_INT as f32 + 0.5) as i32;
344 let fx = SCALE_INT - fx_inv;
345 let x0c = x0.clamp(0, src_w_max) as usize * 3;
346 let x1c = (x0 + 1).clamp(0, src_w_max) as usize * 3;
347 (x0c, x1c, fx_inv, fx)
348 })
349 .collect(),
350 );
351
352 cache.put(key, Arc::clone(&lut));
353 lut
354 })
355}
356
357fn fused_zerocopy_preprocess(
362 src_raw: &[u8],
363 src_w: u32,
364 src_h: u32,
365 target_size: (usize, usize),
366 geom: &LetterboxGeometry,
367) -> Array4<f32> {
368 #[allow(clippy::wildcard_imports)] use crate::parallel::*;
370 use std::mem::MaybeUninit;
371 use std::sync::atomic::{AtomicPtr, Ordering};
372
373 let LetterboxGeometry {
374 new_w: new_width,
375 new_h: new_height,
376 pad_left,
377 pad_top,
378 } = *geom;
379
380 let (dst_h, dst_w) = target_size;
381
382 if src_w == 0 || src_h == 0 || new_width == 0 || new_height == 0 {
386 return Array4::from_elem((1, 3, dst_h, dst_w), LETTERBOX_NORM);
387 }
388
389 let channel_size = dst_h * dst_w;
390 let src_stride = (src_w * 3) as usize;
391
392 let mut tensor: Array4<MaybeUninit<f32>> = Array4::uninit((1, 3, dst_h, dst_w));
394 let out_ptr = tensor.as_mut_ptr() as *mut f32;
395
396 let atomic_ptr = AtomicPtr::new(out_ptr);
398
399 let x_lut = get_or_compute_x_lut(src_w, new_width);
400 let scale_y = src_h as f32 / new_height as f32;
401 let src_h_max = (src_h - 1) as i32;
402
403 let pad_top_usize = pad_top as usize;
404 let pad_left_usize = pad_left as usize;
405 let new_height_usize = new_height as usize;
406 let new_width_usize = new_width as usize;
407
408 (0..dst_h).into_par_iter().for_each(|dy| {
410 let data_ptr = atomic_ptr.load(Ordering::Relaxed);
411 unsafe {
412 let r_row = data_ptr.add(dy * dst_w);
415 let g_row = data_ptr.add(channel_size + dy * dst_w);
416 let b_row = data_ptr.add(2 * channel_size + dy * dst_w);
417
418 if dy < pad_top_usize || dy >= pad_top_usize + new_height_usize {
420 for dx in 0..dst_w {
421 *r_row.add(dx) = LETTERBOX_NORM;
422 *g_row.add(dx) = LETTERBOX_NORM;
423 *b_row.add(dx) = LETTERBOX_NORM;
424 }
425 return;
426 }
427
428 let img_dy = dy - pad_top_usize;
431 let sy = ((img_dy as f32 + 0.5) * scale_y - 0.5).max(0.0);
432 let y0 = sy.floor() as i32;
433 let fy_f = sy - y0 as f32;
434 let fy_inv = ((1.0 - fy_f) * SCALE_INT as f32 + 0.5) as i32;
435 let fy = SCALE_INT - fy_inv;
436
437 let y0c = y0.clamp(0, src_h_max) as usize;
438 let y1c = (y0 + 1).clamp(0, src_h_max) as usize;
439 let row0_off = y0c * src_stride;
440 let row1_off = y1c * src_stride;
441
442 for dx in 0..pad_left_usize {
444 *r_row.add(dx) = LETTERBOX_NORM;
445 *g_row.add(dx) = LETTERBOX_NORM;
446 *b_row.add(dx) = LETTERBOX_NORM;
447 }
448
449 let mut img_dx = 0usize;
455 let src_ptr = src_raw.as_ptr();
456
457 while img_dx < new_width_usize {
458 let (x0_off, x1_off, fx_inv, fx) = *x_lut.get_unchecked(img_dx);
459 let w00 = fx_inv * fy_inv;
460 let w10 = fx * fy_inv;
461 let w01 = fx_inv * fy;
462 let w11 = fx * fy;
463
464 let p00 = src_ptr.add(row0_off + x0_off);
465 let p10 = src_ptr.add(row0_off + x1_off);
466 let p01 = src_ptr.add(row1_off + x0_off);
467 let p11 = src_ptr.add(row1_off + x1_off);
468
469 let out_x = pad_left_usize + img_dx;
470 *r_row.add(out_x) = ((*p00 as i32 * w00
471 + *p10 as i32 * w10
472 + *p01 as i32 * w01
473 + *p11 as i32 * w11
474 + ROUND_BIAS)
475 >> SCALE_BITS_2X) as f32
476 * INV_255;
477 *g_row.add(out_x) = ((*p00.add(1) as i32 * w00
478 + *p10.add(1) as i32 * w10
479 + *p01.add(1) as i32 * w01
480 + *p11.add(1) as i32 * w11
481 + ROUND_BIAS)
482 >> SCALE_BITS_2X) as f32
483 * INV_255;
484 *b_row.add(out_x) = ((*p00.add(2) as i32 * w00
485 + *p10.add(2) as i32 * w10
486 + *p01.add(2) as i32 * w01
487 + *p11.add(2) as i32 * w11
488 + ROUND_BIAS)
489 >> SCALE_BITS_2X) as f32
490 * INV_255;
491
492 img_dx += 1;
493 }
494
495 for dx in (pad_left_usize + new_width_usize)..dst_w {
497 *r_row.add(dx) = LETTERBOX_NORM;
498 *g_row.add(dx) = LETTERBOX_NORM;
499 *b_row.add(dx) = LETTERBOX_NORM;
500 }
501 }
502 });
503
504 unsafe { tensor.assume_init() }
506}
507
508fn tensor_f32_to_f16(tensor: &Array4<f32>) -> Array4<half::f16> {
510 use half::slice::HalfFloatSliceExt;
511 let Some(src) = tensor.as_slice() else {
514 return tensor.mapv(half::f16::from_f32);
515 };
516 let mut out = vec![half::f16::ZERO; src.len()];
517 out.convert_from_f32_slice(src);
518 Array4::from_shape_vec(tensor.raw_dim(), out).expect("shape matches the source tensor")
519}
520
521#[must_use]
537pub fn calculate_rect_size(
538 orig_width: u32,
539 orig_height: u32,
540 target_size: (usize, usize),
541 stride: u32,
542) -> (usize, usize) {
543 let (target_h, target_w) = target_size;
544
545 #[allow(clippy::cast_precision_loss)]
546 let orig_h = orig_height as f32;
547 #[allow(clippy::cast_precision_loss)]
548 let orig_w = orig_width as f32;
549 #[allow(clippy::cast_precision_loss)]
550 let target_h_f = target_h as f32;
551 #[allow(clippy::cast_precision_loss)]
552 let target_w_f = target_w as f32;
553
554 let scale = (target_h_f / orig_h).min(target_w_f / orig_w);
556
557 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
559 let new_h = (orig_h * scale).round() as usize;
560 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
561 let new_w = (orig_w * scale).round() as usize;
562
563 let stride = stride as usize;
568 let rect_h = (((new_h + stride - 1) / stride) * stride).max(stride);
569 let rect_w = (((new_w + stride - 1) / stride) * stride).max(stride);
570
571 (rect_h, rect_w)
572}
573
574fn calculate_letterbox_params(
594 orig_width: u32,
595 orig_height: u32,
596 target_size: (usize, usize),
597 _stride: u32,
598) -> (LetterboxGeometry, (f32, f32)) {
599 LetterboxGeometry::compute(orig_width, orig_height, target_size, false)
602}
603
604fn image_to_tensor<T: Clone>(
611 image: &RgbImage,
612 zero: T,
613 mut convert: impl FnMut(u8) -> T,
614) -> Array4<T> {
615 let (width, height) = image.dimensions();
616 let (w, h) = (width as usize, height as usize);
617 let pixels = image.as_raw();
618
619 let mut tensor = Array4::from_elem((1, 3, h, w), zero);
620
621 let (r_slice, rest) = tensor.as_slice_mut().unwrap().split_at_mut(h * w);
623 let (g_slice, b_slice) = rest.split_at_mut(h * w);
624
625 for (i, chunk) in pixels.as_chunks::<3>().0.iter().enumerate() {
626 r_slice[i] = convert(chunk[0]);
627 g_slice[i] = convert(chunk[1]);
628 b_slice[i] = convert(chunk[2]);
629 }
630
631 tensor
632}
633
634#[must_use]
640pub fn image_to_array(image: &DynamicImage) -> Array3<u8> {
641 let rgb = image.to_rgb8();
642 let (width, height) = rgb.dimensions();
643 let pixels = rgb.into_raw();
644
645 Array3::from_shape_vec((height as usize, width as usize, 3), pixels)
646 .expect("Failed to create array from image pixels")
647}
648
649#[must_use]
661pub fn scale_coords(coords: &[f32; 4], scale: (f32, f32), padding: (f32, f32)) -> [f32; 4] {
662 let (scale_y, scale_x) = scale;
663 let (pad_top, pad_left) = padding;
664
665 [
666 (coords[0] - pad_left) / scale_x, (coords[1] - pad_top) / scale_y, (coords[2] - pad_left) / scale_x, (coords[3] - pad_top) / scale_y, ]
671}
672
673#[must_use]
684pub const fn clip_coords(coords: &[f32; 4], shape: (u32, u32)) -> [f32; 4] {
685 #[allow(clippy::cast_precision_loss)]
686 let (h, w) = (shape.0 as f32, shape.1 as f32);
687 [
688 coords[0].clamp(0.0, w),
689 coords[1].clamp(0.0, h),
690 coords[2].clamp(0.0, w),
691 coords[3].clamp(0.0, h),
692 ]
693}
694
695#[must_use]
713pub fn preprocess_image_center_crop(
714 image: &DynamicImage,
715 target_size: (usize, usize),
716 quantize: impl IntoQuantization,
717) -> PreprocessResult {
718 let quantize = quantize.into_quantization();
719 let (orig_width, orig_height) = image.dimensions();
720 let orig_shape = (orig_height, orig_width);
721
722 let (cropped, scale) = center_crop_image(image, target_size);
724
725 let tensor = image_to_tensor(&cropped, 0.0, |v| f32::from(v) / 255.0);
727
728 let tensor_f16 = (quantize == Some(Quantization::Fp16)).then(|| {
730 let scale = f16::from_f32(1.0 / 255.0);
731 image_to_tensor(&cropped, f16::ZERO, move |v| {
732 f16::from_f32(f32::from(v)) * scale
733 })
734 });
735
736 let padding = (0.0, 0.0);
740
741 PreprocessResult {
742 tensor,
743 tensor_f16,
744 orig_shape,
745 scale,
746 padding,
747 }
748}
749
750#[allow(clippy::similar_names)]
766fn center_crop_image(image: &DynamicImage, target_size: (usize, usize)) -> (RgbImage, (f32, f32)) {
767 use fast_image_resize::{
768 PixelType, ResizeAlg, ResizeOptions, Resizer,
769 images::{Image, ImageRef},
770 };
771
772 let (src_w, src_h) = image.dimensions();
773 #[allow(clippy::cast_possible_truncation)]
774 let (target_h, target_w) = (target_size.0 as u32, target_size.1 as u32);
775
776 let blank = || {
778 (
779 RgbImage::from_pixel(target_w, target_h, image::Rgb(LETTERBOX_COLOR)),
780 (1.0, 1.0),
781 )
782 };
783
784 if src_w == 0 || src_h == 0 {
787 return blank();
788 }
789
790 #[allow(clippy::cast_precision_loss)]
793 let scale_x = target_w as f32 / src_w as f32;
794 #[allow(clippy::cast_precision_loss)]
795 let scale_y = target_h as f32 / src_h as f32;
796 let scale = scale_x.max(scale_y);
797
798 let (new_w, new_h) = if scale_x >= scale_y {
799 #[allow(
800 clippy::cast_possible_truncation,
801 clippy::cast_sign_loss,
802 clippy::cast_precision_loss
803 )]
804 (target_w, (src_h as f32 * scale_x) as u32)
805 } else {
806 #[allow(
807 clippy::cast_possible_truncation,
808 clippy::cast_sign_loss,
809 clippy::cast_precision_loss
810 )]
811 ((src_w as f32 * scale_y) as u32, target_h)
812 };
813
814 let owned_rgb;
817 let src_bytes: &[u8] = match image {
818 DynamicImage::ImageRgb8(rgb) => rgb.as_raw(),
819 other => {
820 owned_rgb = other.to_rgb8();
821 owned_rgb.as_raw()
822 }
823 };
824 let Ok(src_image) = ImageRef::new(src_w, src_h, src_bytes, PixelType::U8x3) else {
825 return blank();
826 };
827
828 let safe_new_w = new_w.max(1);
830 let safe_new_h = new_h.max(1);
831
832 let mut dst_image = Image::new(safe_new_w, safe_new_h, PixelType::U8x3);
833
834 let mut resizer = Resizer::new();
835 let options = ResizeOptions::new().resize_alg(ResizeAlg::Convolution(
836 fast_image_resize::FilterType::Bilinear,
837 ));
838 if resizer
839 .resize(&src_image, &mut dst_image, Some(&options))
840 .is_err()
841 {
842 return blank();
843 }
844
845 let resized_buffer = dst_image.into_vec();
847 let Some(resized_rgb) = RgbImage::from_raw(safe_new_w, safe_new_h, resized_buffer) else {
848 return blank();
849 };
850
851 #[allow(clippy::cast_precision_loss)]
853 let crop_x_float = (new_w.saturating_sub(target_w)) as f32 / 2.0;
854 #[allow(clippy::cast_precision_loss)]
855 let crop_y_float = (new_h.saturating_sub(target_h)) as f32 / 2.0;
856
857 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
858 let crop_x = bankers_round(crop_x_float) as u32;
859 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
860 let crop_y = bankers_round(crop_y_float) as u32;
861
862 let cropped =
863 image::imageops::crop_imm(&resized_rgb, crop_x, crop_y, target_w, target_h).to_image();
864
865 (cropped, (scale, scale))
866}
867
868fn bankers_round(v: f32) -> f32 {
870 let n = v.floor();
871 let d = v - n;
872 if (d - 0.5).abs() < 1e-6 {
873 if n % 2.0 == 0.0 { n } else { n + 1.0 }
874 } else {
875 v.round()
876 }
877}
878
879#[allow(clippy::similar_names)]
880#[cfg(test)]
881mod tests {
882 use super::*;
883
884 #[test]
887 fn test_extreme_aspect_ratio_keeps_image_content() {
888 let (geom, _) = calculate_letterbox_params(10000, 1, (640, 640), 32);
889 assert!(geom.new_h >= 1, "height collapsed to {}", geom.new_h);
890 assert!(geom.new_w >= 1);
891
892 let img = DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
893 10000,
894 1,
895 image::Rgb([255, 0, 0]),
896 ));
897 let res = preprocess_image(&img, (640, 640), 32);
898 let tensor = res.tensor;
899 assert!(
900 tensor.iter().any(|&v| (v - LETTERBOX_NORM).abs() > 1e-6),
901 "every pixel is letterbox fill, so the image was thrown away"
902 );
903 }
904
905 #[test]
908 fn test_center_crop_degenerate_input_does_not_panic() {
909 for (w, h) in [(0, 0), (0, 64), (64, 0)] {
910 let img = DynamicImage::ImageRgb8(image::RgbImage::new(w, h));
911 let res = preprocess_image_center_crop(&img, (224, 224), false);
912 assert_eq!(res.tensor.shape(), &[1, 3, 224, 224]);
913 }
914 }
915
916 #[test]
917 fn test_zero_dimension_image_does_not_panic() {
918 for (w, h) in [(0, 0), (0, 64), (64, 0)] {
919 let img = DynamicImage::ImageRgb8(image::RgbImage::new(w, h));
920 let res = preprocess_image(&img, (640, 640), 32);
921 assert_eq!(res.tensor.shape(), &[1, 3, 640, 640]);
922 assert!(
923 res.tensor
924 .iter()
925 .all(|&v| (v - LETTERBOX_NORM).abs() < 1e-6),
926 "an empty source has no pixels, so the tensor is all letterbox fill"
927 );
928
929 let rect = calculate_rect_size(w, h, (640, 640), 32);
933 assert!(
934 rect.0 >= 32 && rect.1 >= 32,
935 "rect target {rect:?} collapsed for a {w}x{h} source"
936 );
937 let res = preprocess_image(&img, rect, 32);
938 assert_eq!(res.tensor.shape(), &[1, 3, rect.0, rect.1]);
939 }
940 }
941
942 #[test]
943 fn test_letterbox_params() {
944 let (geom, _scale) = calculate_letterbox_params(640, 640, (640, 640), 32);
946 assert_eq!((geom.new_w, geom.new_h), (640, 640));
947 assert_eq!((geom.pad_left, geom.pad_top), (0, 0));
948
949 let (geom, _) = calculate_letterbox_params(1280, 720, (640, 640), 32);
951 assert!(geom.new_w <= 640 && geom.new_h <= 640);
952 assert_eq!(geom.pad_left, 0);
953
954 let (geom, _) = calculate_letterbox_params(480, 640, (640, 640), 32);
956 assert!(geom.pad_left > 0);
957 assert_eq!(geom.pad_top, 0);
958 }
959
960 #[test]
961 fn test_scale_coords() {
962 let coords = [100.0, 100.0, 200.0, 200.0];
963 let scale = (1.0, 1.0);
964 let padding = (10.0, 10.0);
965
966 let scaled = scale_coords(&coords, scale, padding);
967
968 assert!((scaled[0] - 90.0).abs() < 1e-6);
969 assert!((scaled[1] - 90.0).abs() < 1e-6);
970 assert!((scaled[2] - 190.0).abs() < 1e-6);
971 assert!((scaled[3] - 190.0).abs() < 1e-6);
972 }
973
974 #[test]
975 fn test_clip_coords() {
976 let coords = [-10.0, -20.0, 700.0, 500.0];
977 let clipped = clip_coords(&coords, (480, 640));
978
979 assert!((clipped[0] - 0.0).abs() < 1e-6);
980 assert!((clipped[1] - 0.0).abs() < 1e-6);
981 assert!((clipped[2] - 640.0).abs() < 1e-6);
982 assert!((clipped[3] - 480.0).abs() < 1e-6);
983 }
984
985 #[test]
986 fn test_preprocess_image_center_crop() {
987 let img = image::DynamicImage::new_rgb8(400, 300);
990 for quantize in [None, Some(Quantization::Fp16)] {
991 let res = preprocess_image_center_crop(&img, (224, 224), quantize);
992 assert_eq!(res.tensor.dim(), (1, 3, 224, 224));
993 assert_eq!(res.orig_shape, (300, 400));
994 assert_eq!(res.padding, (0.0, 0.0));
995 assert!(res.tensor.iter().all(|v| (0.0..=1.0).contains(v)));
996 assert_eq!(res.tensor_f16.is_some(), quantize.is_some());
997 if let Some(t16) = &res.tensor_f16 {
998 assert_eq!(t16.dim(), res.tensor.dim());
999 }
1000 }
1001 }
1002
1003 #[test]
1004 fn test_preprocess_image_static_centered_letterbox() {
1005 let img = image::DynamicImage::new_rgb8(640, 480);
1008 let res = preprocess_image_with_precision(&img, (1024, 1024), 32, None);
1009 let (_, _, h, w) = res.tensor.dim();
1010 assert_eq!(h, 1024);
1011 assert_eq!(w, 1024);
1012 assert!(res.padding.1.abs() < 1e-6, "wide image: no left padding");
1014 assert!(res.padding.0 > 0.0, "wide image: top padding expected");
1015 }
1016
1017 #[test]
1018 fn test_preprocess_image_rect_uses_centered_letterbox() {
1019 let img = image::DynamicImage::new_rgb8(640, 333);
1022 let rect_size = calculate_rect_size(640, 333, (1024, 1024), 32);
1023 assert_eq!(rect_size, (544, 1024));
1024 let res = preprocess_image_with_precision(&img, rect_size, 32, None);
1025 let (_, _, h, w) = res.tensor.dim();
1026 assert_eq!((h, w), rect_size);
1027 assert_eq!(res.padding, (5.0, 0.0));
1028 }
1029
1030 #[test]
1031 fn test_preprocess_image_stretch_fills_target() {
1032 let img = image::DynamicImage::new_rgb8(810, 1080);
1035 let res = preprocess_image_stretch(&img, (640, 640), None);
1036 let (_, c, h, w) = res.tensor.dim();
1037 assert_eq!((c, h, w), (3, 640, 640));
1038 assert_eq!(res.padding, (0.0, 0.0));
1039 assert_eq!(res.scale, (640.0 / 1080.0, 640.0 / 810.0));
1040 let full = scale_coords(&[0.0, 0.0, 640.0, 640.0], res.scale, res.padding);
1041 assert!((full[2] - 810.0).abs() < 1e-3 && (full[3] - 1080.0).abs() < 1e-3);
1042
1043 let (geom, scale) = LetterboxGeometry::compute(0, 0, (640, 640), true);
1046 assert_eq!(scale, (1.0, 1.0));
1047 assert_eq!((geom.new_w, geom.new_h), (640, 640));
1048 }
1049
1050 #[test]
1051 fn test_preprocess_image_public_wrapper() {
1052 let img = image::DynamicImage::new_rgb8(320, 240);
1053 let res = preprocess_image(&img, (640, 640), 32);
1054 let (_, c, h, w) = res.tensor.dim();
1055 assert_eq!((c, h, w), (3, 640, 640));
1056 assert!(res.tensor_f16.is_none());
1057 }
1058
1059 #[test]
1060 fn test_preprocess_image_fp16_path() {
1061 let img = image::DynamicImage::new_rgb8(320, 240);
1062 let res = preprocess_image_with_precision(&img, (640, 640), 32, Some(Quantization::Fp16));
1063 let f16 = res.tensor_f16.expect("fp16 tensor present");
1065 assert_eq!(f16.dim(), res.tensor.dim());
1066 }
1067
1068 #[test]
1069 fn test_preprocess_various_aspect_ratios() {
1070 for (w, h) in [(100u32, 400u32), (400, 100), (1, 1), (640, 640)] {
1072 let img = image::DynamicImage::new_rgb8(w, h);
1073 let res = preprocess_image(&img, (320, 320), 32);
1074 let (_, c, th, tw) = res.tensor.dim();
1075 assert_eq!((c, th, tw), (3, 320, 320));
1076 assert_eq!(res.orig_shape, (h, w));
1077 }
1078 }
1079
1080 #[test]
1081 fn test_x_lut_cache_reuse() {
1082 let img = image::DynamicImage::new_rgb8(200, 150);
1084 let a = preprocess_image(&img, (320, 320), 32);
1085 let b = preprocess_image(&img, (320, 320), 32);
1086 assert_eq!(a.tensor.dim(), b.tensor.dim());
1087 }
1088
1089 #[test]
1090 fn test_calculate_rect_size() {
1091 assert_eq!(calculate_rect_size(640, 640, (640, 640), 32), (640, 640));
1093
1094 for (w, h) in [(400u32, 1000u32), (1000, 400), (800, 600)] {
1097 let (rh, rw) = calculate_rect_size(w, h, (640, 640), 32);
1098 assert_eq!((rh % 32, rw % 32), (0, 0));
1099 assert!(rh <= 640 && rw <= 640);
1100 }
1101 }
1102}