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(orig_w: u32, orig_h: u32, target: (usize, usize)) -> (Self, f32) {
125 let (target_h, target_w) = (target.0 as f32, target.1 as f32);
126 let (orig_hf, orig_wf) = (orig_h as f32, orig_w as f32);
127 let scale = (target_h / orig_hf).min(target_w / orig_wf);
128 let extent = |orig_extent: f32, orig: u32| {
132 if orig == 0 {
133 0
134 } else {
135 ((orig_extent * scale).round() as u32).max(1)
136 }
137 };
138 let new_w = extent(orig_wf, orig_w);
139 let new_h = extent(orig_hf, orig_h);
140 let pad_left = (target.1 as u32).saturating_sub(new_w) / 2;
141 let pad_top = (target.0 as u32).saturating_sub(new_h) / 2;
142 (
143 Self {
144 new_w,
145 new_h,
146 pad_left,
147 pad_top,
148 },
149 scale,
150 )
151 }
152}
153
154#[allow(clippy::cast_precision_loss)]
159fn build_preprocess_result(
160 image: &DynamicImage,
161 target_size: (usize, usize),
162 geom: LetterboxGeometry,
163 scale: (f32, f32),
164 orig_shape: (u32, u32),
165 fp16: bool,
166) -> PreprocessResult {
167 let (orig_width, orig_height) = image.dimensions();
168
169 let tensor = match image {
170 DynamicImage::ImageRgb8(rgb) => {
171 fused_zerocopy_preprocess(rgb.as_raw(), orig_width, orig_height, target_size, &geom)
172 }
173 _ => {
174 let src_rgb = image.to_rgb8();
175 fused_zerocopy_preprocess(
176 src_rgb.as_raw(),
177 orig_width,
178 orig_height,
179 target_size,
180 &geom,
181 )
182 }
183 };
184
185 let tensor_f16 = if fp16 {
186 Some(tensor_f32_to_f16(&tensor))
187 } else {
188 None
189 };
190
191 PreprocessResult {
192 tensor,
193 tensor_f16,
194 orig_shape,
195 scale,
196 padding: (geom.pad_top as f32, geom.pad_left as f32),
197 }
198}
199
200#[must_use]
215pub fn preprocess_image(
216 image: &DynamicImage,
217 target_size: (usize, usize),
218 stride: u32,
219) -> PreprocessResult {
220 preprocess_image_with_precision(image, target_size, stride, None)
221}
222
223#[must_use]
236pub fn preprocess_image_with_precision(
237 image: &DynamicImage,
238 target_size: (usize, usize),
239 stride: u32,
240 quantize: impl IntoQuantization,
241) -> PreprocessResult {
242 let quantize = quantize.into_quantization();
243 let (orig_width, orig_height) = image.dimensions();
244 let orig_shape = (orig_height, orig_width);
245
246 let (geom, scale) = calculate_letterbox_params(orig_width, orig_height, target_size, stride);
247 build_preprocess_result(
248 image,
249 target_size,
250 geom,
251 scale,
252 orig_shape,
253 quantize == Some(Quantization::Fp16),
254 )
255}
256
257fn get_or_compute_x_lut(src_w: u32, dst_w: u32) -> Arc<Vec<XLutEntry>> {
265 let key = (src_w, dst_w);
266
267 X_LUT_CACHE.with(|cache| {
268 let mut cache = cache.borrow_mut();
269
270 if let Some(lut) = cache.get(&key) {
271 return Arc::clone(lut);
272 }
273
274 let scale_x = src_w as f32 / dst_w as f32;
275 let src_w_max = (src_w - 1) as i32;
276
277 let lut: Arc<Vec<XLutEntry>> = Arc::new(
278 (0..dst_w)
279 .map(|dx| {
280 let sx = ((dx as f32 + 0.5) * scale_x - 0.5).max(0.0);
281 let x0 = sx.floor() as i32;
282 let fx_f = sx - x0 as f32;
285 let fx_inv = ((1.0 - fx_f) * SCALE_INT as f32 + 0.5) as i32;
286 let fx = SCALE_INT - fx_inv;
287 let x0c = x0.clamp(0, src_w_max) as usize * 3;
288 let x1c = (x0 + 1).clamp(0, src_w_max) as usize * 3;
289 (x0c, x1c, fx_inv, fx)
290 })
291 .collect(),
292 );
293
294 cache.put(key, Arc::clone(&lut));
295 lut
296 })
297}
298
299fn fused_zerocopy_preprocess(
304 src_raw: &[u8],
305 src_w: u32,
306 src_h: u32,
307 target_size: (usize, usize),
308 geom: &LetterboxGeometry,
309) -> Array4<f32> {
310 #[allow(clippy::wildcard_imports)] use crate::parallel::*;
312 use std::mem::MaybeUninit;
313 use std::sync::atomic::{AtomicPtr, Ordering};
314
315 let LetterboxGeometry {
316 new_w: new_width,
317 new_h: new_height,
318 pad_left,
319 pad_top,
320 } = *geom;
321
322 let (dst_h, dst_w) = target_size;
323
324 if src_w == 0 || src_h == 0 || new_width == 0 || new_height == 0 {
328 return Array4::from_elem((1, 3, dst_h, dst_w), LETTERBOX_NORM);
329 }
330
331 let channel_size = dst_h * dst_w;
332 let src_stride = (src_w * 3) as usize;
333
334 let mut tensor: Array4<MaybeUninit<f32>> = Array4::uninit((1, 3, dst_h, dst_w));
336 let out_ptr = tensor.as_mut_ptr() as *mut f32;
337
338 let atomic_ptr = AtomicPtr::new(out_ptr);
340
341 let x_lut = get_or_compute_x_lut(src_w, new_width);
342 let scale_y = src_h as f32 / new_height as f32;
343 let src_h_max = (src_h - 1) as i32;
344
345 let pad_top_usize = pad_top as usize;
346 let pad_left_usize = pad_left as usize;
347 let new_height_usize = new_height as usize;
348 let new_width_usize = new_width as usize;
349
350 (0..dst_h).into_par_iter().for_each(|dy| {
352 let data_ptr = atomic_ptr.load(Ordering::Relaxed);
353 unsafe {
354 let r_row = data_ptr.add(dy * dst_w);
357 let g_row = data_ptr.add(channel_size + dy * dst_w);
358 let b_row = data_ptr.add(2 * channel_size + dy * dst_w);
359
360 if dy < pad_top_usize || dy >= pad_top_usize + new_height_usize {
362 for dx in 0..dst_w {
363 *r_row.add(dx) = LETTERBOX_NORM;
364 *g_row.add(dx) = LETTERBOX_NORM;
365 *b_row.add(dx) = LETTERBOX_NORM;
366 }
367 return;
368 }
369
370 let img_dy = dy - pad_top_usize;
373 let sy = ((img_dy as f32 + 0.5) * scale_y - 0.5).max(0.0);
374 let y0 = sy.floor() as i32;
375 let fy_f = sy - y0 as f32;
376 let fy_inv = ((1.0 - fy_f) * SCALE_INT as f32 + 0.5) as i32;
377 let fy = SCALE_INT - fy_inv;
378
379 let y0c = y0.clamp(0, src_h_max) as usize;
380 let y1c = (y0 + 1).clamp(0, src_h_max) as usize;
381 let row0_off = y0c * src_stride;
382 let row1_off = y1c * src_stride;
383
384 for dx in 0..pad_left_usize {
386 *r_row.add(dx) = LETTERBOX_NORM;
387 *g_row.add(dx) = LETTERBOX_NORM;
388 *b_row.add(dx) = LETTERBOX_NORM;
389 }
390
391 let mut img_dx = 0usize;
397 let src_ptr = src_raw.as_ptr();
398
399 while img_dx < new_width_usize {
400 let (x0_off, x1_off, fx_inv, fx) = *x_lut.get_unchecked(img_dx);
401 let w00 = fx_inv * fy_inv;
402 let w10 = fx * fy_inv;
403 let w01 = fx_inv * fy;
404 let w11 = fx * fy;
405
406 let p00 = src_ptr.add(row0_off + x0_off);
407 let p10 = src_ptr.add(row0_off + x1_off);
408 let p01 = src_ptr.add(row1_off + x0_off);
409 let p11 = src_ptr.add(row1_off + x1_off);
410
411 let out_x = pad_left_usize + img_dx;
412 *r_row.add(out_x) = ((*p00 as i32 * w00
413 + *p10 as i32 * w10
414 + *p01 as i32 * w01
415 + *p11 as i32 * w11
416 + ROUND_BIAS)
417 >> SCALE_BITS_2X) as f32
418 * INV_255;
419 *g_row.add(out_x) = ((*p00.add(1) as i32 * w00
420 + *p10.add(1) as i32 * w10
421 + *p01.add(1) as i32 * w01
422 + *p11.add(1) as i32 * w11
423 + ROUND_BIAS)
424 >> SCALE_BITS_2X) as f32
425 * INV_255;
426 *b_row.add(out_x) = ((*p00.add(2) as i32 * w00
427 + *p10.add(2) as i32 * w10
428 + *p01.add(2) as i32 * w01
429 + *p11.add(2) as i32 * w11
430 + ROUND_BIAS)
431 >> SCALE_BITS_2X) as f32
432 * INV_255;
433
434 img_dx += 1;
435 }
436
437 for dx in (pad_left_usize + new_width_usize)..dst_w {
439 *r_row.add(dx) = LETTERBOX_NORM;
440 *g_row.add(dx) = LETTERBOX_NORM;
441 *b_row.add(dx) = LETTERBOX_NORM;
442 }
443 }
444 });
445
446 unsafe { tensor.assume_init() }
448}
449
450fn tensor_f32_to_f16(tensor: &Array4<f32>) -> Array4<half::f16> {
452 use half::slice::HalfFloatSliceExt;
453 let Some(src) = tensor.as_slice() else {
456 return tensor.mapv(half::f16::from_f32);
457 };
458 let mut out = vec![half::f16::ZERO; src.len()];
459 out.convert_from_f32_slice(src);
460 Array4::from_shape_vec(tensor.raw_dim(), out).expect("shape matches the source tensor")
461}
462
463#[must_use]
479pub fn calculate_rect_size(
480 orig_width: u32,
481 orig_height: u32,
482 target_size: (usize, usize),
483 stride: u32,
484) -> (usize, usize) {
485 let (target_h, target_w) = target_size;
486
487 #[allow(clippy::cast_precision_loss)]
488 let orig_h = orig_height as f32;
489 #[allow(clippy::cast_precision_loss)]
490 let orig_w = orig_width as f32;
491 #[allow(clippy::cast_precision_loss)]
492 let target_h_f = target_h as f32;
493 #[allow(clippy::cast_precision_loss)]
494 let target_w_f = target_w as f32;
495
496 let scale = (target_h_f / orig_h).min(target_w_f / orig_w);
498
499 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
501 let new_h = (orig_h * scale).round() as usize;
502 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
503 let new_w = (orig_w * scale).round() as usize;
504
505 let stride = stride as usize;
510 let rect_h = (((new_h + stride - 1) / stride) * stride).max(stride);
511 let rect_w = (((new_w + stride - 1) / stride) * stride).max(stride);
512
513 (rect_h, rect_w)
514}
515
516fn calculate_letterbox_params(
536 orig_width: u32,
537 orig_height: u32,
538 target_size: (usize, usize),
539 _stride: u32,
540) -> (LetterboxGeometry, (f32, f32)) {
541 let (geom, scale) = LetterboxGeometry::compute(orig_width, orig_height, target_size);
544 (geom, (scale, scale))
545}
546
547fn image_to_tensor<T: Clone>(
554 image: &RgbImage,
555 zero: T,
556 mut convert: impl FnMut(u8) -> T,
557) -> Array4<T> {
558 let (width, height) = image.dimensions();
559 let (w, h) = (width as usize, height as usize);
560 let pixels = image.as_raw();
561
562 let mut tensor = Array4::from_elem((1, 3, h, w), zero);
563
564 let (r_slice, rest) = tensor.as_slice_mut().unwrap().split_at_mut(h * w);
566 let (g_slice, b_slice) = rest.split_at_mut(h * w);
567
568 for (i, chunk) in pixels.as_chunks::<3>().0.iter().enumerate() {
569 r_slice[i] = convert(chunk[0]);
570 g_slice[i] = convert(chunk[1]);
571 b_slice[i] = convert(chunk[2]);
572 }
573
574 tensor
575}
576
577#[must_use]
583pub fn image_to_array(image: &DynamicImage) -> Array3<u8> {
584 let rgb = image.to_rgb8();
585 let (width, height) = rgb.dimensions();
586 let pixels = rgb.into_raw();
587
588 Array3::from_shape_vec((height as usize, width as usize, 3), pixels)
589 .expect("Failed to create array from image pixels")
590}
591
592#[must_use]
604pub fn scale_coords(coords: &[f32; 4], scale: (f32, f32), padding: (f32, f32)) -> [f32; 4] {
605 let (scale_y, scale_x) = scale;
606 let (pad_top, pad_left) = padding;
607
608 [
609 (coords[0] - pad_left) / scale_x, (coords[1] - pad_top) / scale_y, (coords[2] - pad_left) / scale_x, (coords[3] - pad_top) / scale_y, ]
614}
615
616#[must_use]
627pub const fn clip_coords(coords: &[f32; 4], shape: (u32, u32)) -> [f32; 4] {
628 #[allow(clippy::cast_precision_loss)]
629 let (h, w) = (shape.0 as f32, shape.1 as f32);
630 [
631 coords[0].clamp(0.0, w),
632 coords[1].clamp(0.0, h),
633 coords[2].clamp(0.0, w),
634 coords[3].clamp(0.0, h),
635 ]
636}
637
638#[must_use]
656pub fn preprocess_image_center_crop(
657 image: &DynamicImage,
658 target_size: (usize, usize),
659 quantize: impl IntoQuantization,
660) -> PreprocessResult {
661 let quantize = quantize.into_quantization();
662 let (orig_width, orig_height) = image.dimensions();
663 let orig_shape = (orig_height, orig_width);
664
665 let (cropped, scale) = center_crop_image(image, target_size);
667
668 let tensor = image_to_tensor(&cropped, 0.0, |v| f32::from(v) / 255.0);
670
671 let tensor_f16 = (quantize == Some(Quantization::Fp16)).then(|| {
673 let scale = f16::from_f32(1.0 / 255.0);
674 image_to_tensor(&cropped, f16::ZERO, move |v| {
675 f16::from_f32(f32::from(v)) * scale
676 })
677 });
678
679 let padding = (0.0, 0.0);
683
684 PreprocessResult {
685 tensor,
686 tensor_f16,
687 orig_shape,
688 scale,
689 padding,
690 }
691}
692
693#[allow(clippy::similar_names)]
709fn center_crop_image(image: &DynamicImage, target_size: (usize, usize)) -> (RgbImage, (f32, f32)) {
710 use fast_image_resize::{
711 PixelType, ResizeAlg, ResizeOptions, Resizer,
712 images::{Image, ImageRef},
713 };
714
715 let (src_w, src_h) = image.dimensions();
716 #[allow(clippy::cast_possible_truncation)]
717 let (target_h, target_w) = (target_size.0 as u32, target_size.1 as u32);
718
719 if src_w == 0 || src_h == 0 {
722 let blank = RgbImage::from_pixel(target_w, target_h, image::Rgb(LETTERBOX_COLOR));
723 return (blank, (1.0, 1.0));
724 }
725
726 #[allow(clippy::cast_precision_loss)]
729 let scale_x = target_w as f32 / src_w as f32;
730 #[allow(clippy::cast_precision_loss)]
731 let scale_y = target_h as f32 / src_h as f32;
732 let scale = scale_x.max(scale_y);
733
734 let (new_w, new_h) = if scale_x >= scale_y {
735 #[allow(
736 clippy::cast_possible_truncation,
737 clippy::cast_sign_loss,
738 clippy::cast_precision_loss
739 )]
740 (target_w, (src_h as f32 * scale_x) as u32)
741 } else {
742 #[allow(
743 clippy::cast_possible_truncation,
744 clippy::cast_sign_loss,
745 clippy::cast_precision_loss
746 )]
747 ((src_w as f32 * scale_y) as u32, target_h)
748 };
749
750 let owned_rgb;
753 let src_bytes: &[u8] = match image {
754 DynamicImage::ImageRgb8(rgb) => rgb.as_raw(),
755 other => {
756 owned_rgb = other.to_rgb8();
757 owned_rgb.as_raw()
758 }
759 };
760 let src_image = ImageRef::new(src_w, src_h, src_bytes, PixelType::U8x3)
761 .expect("Failed to create source image");
762
763 let safe_new_w = new_w.max(1);
765 let safe_new_h = new_h.max(1);
766
767 let mut dst_image = Image::new(safe_new_w, safe_new_h, PixelType::U8x3);
768
769 let mut resizer = Resizer::new();
770 let options = ResizeOptions::new().resize_alg(ResizeAlg::Convolution(
771 fast_image_resize::FilterType::Bilinear,
772 ));
773 resizer
774 .resize(&src_image, &mut dst_image, Some(&options))
775 .expect("Failed to resize image");
776
777 let resized_buffer = dst_image.into_vec();
779 let resized_rgb = RgbImage::from_raw(safe_new_w, safe_new_h, resized_buffer)
780 .expect("Failed to create resized buffer");
781
782 #[allow(clippy::cast_precision_loss)]
784 let crop_x_float = (new_w.saturating_sub(target_w)) as f32 / 2.0;
785 #[allow(clippy::cast_precision_loss)]
786 let crop_y_float = (new_h.saturating_sub(target_h)) as f32 / 2.0;
787
788 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
789 let crop_x = bankers_round(crop_x_float) as u32;
790 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
791 let crop_y = bankers_round(crop_y_float) as u32;
792
793 let cropped =
794 image::imageops::crop_imm(&resized_rgb, crop_x, crop_y, target_w, target_h).to_image();
795
796 (cropped, (scale, scale))
797}
798
799fn bankers_round(v: f32) -> f32 {
801 let n = v.floor();
802 let d = v - n;
803 if (d - 0.5).abs() < 1e-6 {
804 if n % 2.0 == 0.0 { n } else { n + 1.0 }
805 } else {
806 v.round()
807 }
808}
809
810#[allow(clippy::similar_names)]
811#[cfg(test)]
812mod tests {
813 use super::*;
814
815 #[test]
818 fn test_extreme_aspect_ratio_keeps_image_content() {
819 let (geom, _) = calculate_letterbox_params(10000, 1, (640, 640), 32);
820 assert!(geom.new_h >= 1, "height collapsed to {}", geom.new_h);
821 assert!(geom.new_w >= 1);
822
823 let img = DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
824 10000,
825 1,
826 image::Rgb([255, 0, 0]),
827 ));
828 let res = preprocess_image(&img, (640, 640), 32);
829 let tensor = res.tensor;
830 assert!(
831 tensor.iter().any(|&v| (v - LETTERBOX_NORM).abs() > 1e-6),
832 "every pixel is letterbox fill, so the image was thrown away"
833 );
834 }
835
836 #[test]
839 fn test_zero_dimension_image_does_not_panic() {
840 for (w, h) in [(0, 0), (0, 64), (64, 0)] {
841 let img = DynamicImage::ImageRgb8(image::RgbImage::new(w, h));
842 let res = preprocess_image(&img, (640, 640), 32);
843 assert_eq!(res.tensor.shape(), &[1, 3, 640, 640]);
844 assert!(
845 res.tensor
846 .iter()
847 .all(|&v| (v - LETTERBOX_NORM).abs() < 1e-6),
848 "an empty source has no pixels, so the tensor is all letterbox fill"
849 );
850
851 let rect = calculate_rect_size(w, h, (640, 640), 32);
855 assert!(
856 rect.0 >= 32 && rect.1 >= 32,
857 "rect target {rect:?} collapsed for a {w}x{h} source"
858 );
859 let res = preprocess_image(&img, rect, 32);
860 assert_eq!(res.tensor.shape(), &[1, 3, rect.0, rect.1]);
861 }
862 }
863
864 #[test]
865 fn test_letterbox_params() {
866 let (geom, _scale) = calculate_letterbox_params(640, 640, (640, 640), 32);
868 assert_eq!((geom.new_w, geom.new_h), (640, 640));
869 assert_eq!((geom.pad_left, geom.pad_top), (0, 0));
870
871 let (geom, _) = calculate_letterbox_params(1280, 720, (640, 640), 32);
873 assert!(geom.new_w <= 640 && geom.new_h <= 640);
874 assert_eq!(geom.pad_left, 0);
875
876 let (geom, _) = calculate_letterbox_params(480, 640, (640, 640), 32);
878 assert!(geom.pad_left > 0);
879 assert_eq!(geom.pad_top, 0);
880 }
881
882 #[test]
883 fn test_scale_coords() {
884 let coords = [100.0, 100.0, 200.0, 200.0];
885 let scale = (1.0, 1.0);
886 let padding = (10.0, 10.0);
887
888 let scaled = scale_coords(&coords, scale, padding);
889
890 assert!((scaled[0] - 90.0).abs() < 1e-6);
891 assert!((scaled[1] - 90.0).abs() < 1e-6);
892 assert!((scaled[2] - 190.0).abs() < 1e-6);
893 assert!((scaled[3] - 190.0).abs() < 1e-6);
894 }
895
896 #[test]
897 fn test_clip_coords() {
898 let coords = [-10.0, -20.0, 700.0, 500.0];
899 let clipped = clip_coords(&coords, (480, 640));
900
901 assert!((clipped[0] - 0.0).abs() < 1e-6);
902 assert!((clipped[1] - 0.0).abs() < 1e-6);
903 assert!((clipped[2] - 640.0).abs() < 1e-6);
904 assert!((clipped[3] - 480.0).abs() < 1e-6);
905 }
906
907 #[test]
908 fn test_preprocess_image_center_crop() {
909 let img = image::DynamicImage::new_rgb8(400, 300);
912 for quantize in [None, Some(Quantization::Fp16)] {
913 let res = preprocess_image_center_crop(&img, (224, 224), quantize);
914 assert_eq!(res.tensor.dim(), (1, 3, 224, 224));
915 assert_eq!(res.orig_shape, (300, 400));
916 assert_eq!(res.padding, (0.0, 0.0));
917 assert!(res.tensor.iter().all(|v| (0.0..=1.0).contains(v)));
918 assert_eq!(res.tensor_f16.is_some(), quantize.is_some());
919 if let Some(t16) = &res.tensor_f16 {
920 assert_eq!(t16.dim(), res.tensor.dim());
921 }
922 }
923 }
924
925 #[test]
926 fn test_preprocess_image_static_centered_letterbox() {
927 let img = image::DynamicImage::new_rgb8(640, 480);
930 let res = preprocess_image_with_precision(&img, (1024, 1024), 32, None);
931 let (_, _, h, w) = res.tensor.dim();
932 assert_eq!(h, 1024);
933 assert_eq!(w, 1024);
934 assert!(res.padding.1.abs() < 1e-6, "wide image: no left padding");
936 assert!(res.padding.0 > 0.0, "wide image: top padding expected");
937 }
938
939 #[test]
940 fn test_preprocess_image_rect_uses_centered_letterbox() {
941 let img = image::DynamicImage::new_rgb8(640, 333);
944 let rect_size = calculate_rect_size(640, 333, (1024, 1024), 32);
945 assert_eq!(rect_size, (544, 1024));
946 let res = preprocess_image_with_precision(&img, rect_size, 32, None);
947 let (_, _, h, w) = res.tensor.dim();
948 assert_eq!((h, w), rect_size);
949 assert_eq!(res.padding, (5.0, 0.0));
950 }
951
952 #[test]
953 fn test_preprocess_image_public_wrapper() {
954 let img = image::DynamicImage::new_rgb8(320, 240);
955 let res = preprocess_image(&img, (640, 640), 32);
956 let (_, c, h, w) = res.tensor.dim();
957 assert_eq!((c, h, w), (3, 640, 640));
958 assert!(res.tensor_f16.is_none());
959 }
960
961 #[test]
962 fn test_preprocess_image_fp16_path() {
963 let img = image::DynamicImage::new_rgb8(320, 240);
964 let res = preprocess_image_with_precision(&img, (640, 640), 32, Some(Quantization::Fp16));
965 let f16 = res.tensor_f16.expect("fp16 tensor present");
967 assert_eq!(f16.dim(), res.tensor.dim());
968 }
969
970 #[test]
971 fn test_preprocess_various_aspect_ratios() {
972 for (w, h) in [(100u32, 400u32), (400, 100), (1, 1), (640, 640)] {
974 let img = image::DynamicImage::new_rgb8(w, h);
975 let res = preprocess_image(&img, (320, 320), 32);
976 let (_, c, th, tw) = res.tensor.dim();
977 assert_eq!((c, th, tw), (3, 320, 320));
978 assert_eq!(res.orig_shape, (h, w));
979 }
980 }
981
982 #[test]
983 fn test_x_lut_cache_reuse() {
984 let img = image::DynamicImage::new_rgb8(200, 150);
986 let a = preprocess_image(&img, (320, 320), 32);
987 let b = preprocess_image(&img, (320, 320), 32);
988 assert_eq!(a.tensor.dim(), b.tensor.dim());
989 }
990
991 #[test]
992 fn test_calculate_rect_size() {
993 assert_eq!(calculate_rect_size(640, 640, (640, 640), 32), (640, 640));
995
996 for (w, h) in [(400u32, 1000u32), (1000, 400), (800, 600)] {
999 let (rh, rw) = calculate_rect_size(w, h, (640, 640), 32);
1000 assert_eq!((rh % 32, rw % 32), (0, 0));
1001 assert!(rh <= 640 && rw <= 640);
1002 }
1003 }
1004}