Skip to main content

ultralytics_inference/
preprocessing.rs

1// Ultralytics πŸš€ AGPL-3.0 License - https://ultralytics.com/license
2
3//! Image preprocessing for YOLO inference.
4//!
5//! This module handles all image preprocessing operations needed before
6//! running YOLO model inference, including resizing, padding, and normalization.
7
8#![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
32/// Default letterbox padding color (gray).
33pub const LETTERBOX_COLOR: [u8; 3] = [114, 114, 114];
34
35/// Fixed-point scale factor for bilinear interpolation (2^11 = 2048).
36/// Matches `OpenCV`'s `INTER_RESIZE_COEF_BITS = 11` for `INTER_LINEAR`.
37const SCALE_BITS: i32 = 11;
38const SCALE_INT: i32 = 1 << SCALE_BITS;
39
40/// Double scale bits for single-pass bilinear interpolation.
41const SCALE_BITS_2X: i32 = 2 * SCALE_BITS;
42
43/// Rounding bias for fixed-point bilinear, added before the final right-shift
44/// to achieve round-to-nearest behavior matching `OpenCV`'s `saturate_cast`.
45const ROUND_BIAS: i32 = 1 << (SCALE_BITS_2X - 1);
46
47/// Normalized letterbox padding color (114/255 β‰ˆ 0.447).
48const LETTERBOX_NORM: f32 = 114.0 / 255.0;
49
50/// Reciprocal of 255 for normalization.
51const INV_255: f32 = 1.0 / 255.0;
52
53/// Maximum LRU cache size for X coordinate LUTs.
54const LUT_CACHE_SIZE: usize = 8;
55
56/// X LUT entry: (`x0_byte_offset`, `x1_byte_offset`, 1-fx, fx) using 11-bit fixed-point
57/// weights matching `OpenCV`'s `INTER_LINEAR` coordinate mapping.
58type XLutEntry = (usize, usize, i32, i32);
59type XLutKey = (u32, u32);
60
61thread_local! {
62    static X_LUT_CACHE: RefCell<LruCache<XLutKey, Arc<Vec<XLutEntry>>>> =
63        RefCell::new(LruCache::new(NonZeroUsize::new(LUT_CACHE_SIZE).unwrap()));
64}
65
66/// Result of preprocessing an image, containing the tensor and transform info.
67#[derive(Debug, Clone)]
68pub struct PreprocessResult {
69    /// Preprocessed image tensor in NCHW format, normalized to [0, 1].
70    pub tensor: Array4<f32>,
71    /// Preprocessed FP16 tensor (if requested).
72    pub tensor_f16: Option<Array4<f16>>,
73    /// Original image dimensions (height, width).
74    pub orig_shape: (u32, u32),
75    /// Scale factors applied (`scale_y`, `scale_x`).
76    pub scale: (f32, f32),
77    /// Padding applied (`pad_top`, `pad_left`).
78    pub padding: (f32, f32),
79}
80
81/// Geometry parameters resolved by letterbox layout.
82#[derive(Clone, Copy)]
83pub(crate) struct LetterboxGeometry {
84    pub(crate) new_w: u32,
85    pub(crate) new_h: u32,
86    pub(crate) pad_left: u32,
87    pub(crate) pad_top: u32,
88}
89
90impl LetterboxGeometry {
91    /// Letterbox geometry for fitting `(orig_w, orig_h)` into `target` = `(height, width)`:
92    /// a uniform scale (`min` over both axes), rounded resized extents, and centered
93    /// padding. The returned `scale` is that uniform gain, used for coordinate
94    /// back-projection.
95    ///
96    /// The single source of this math for both the CPU letterbox and the `cuda-preprocess`
97    /// kernel, so the two can never disagree on where a pixel lands.
98    #[allow(
99        clippy::cast_precision_loss,
100        clippy::cast_possible_truncation,
101        clippy::cast_sign_loss
102    )]
103    pub(crate) fn compute(orig_w: u32, orig_h: u32, target: (usize, usize)) -> (Self, f32) {
104        let (target_h, target_w) = (target.0 as f32, target.1 as f32);
105        let (orig_hf, orig_wf) = (orig_h as f32, orig_w as f32);
106        let scale = (target_h / orig_hf).min(target_w / orig_wf);
107        let new_w = (orig_wf * scale).round() as u32;
108        let new_h = (orig_hf * scale).round() as u32;
109        let pad_left = (target.1 as u32).saturating_sub(new_w) / 2;
110        let pad_top = (target.0 as u32).saturating_sub(new_h) / 2;
111        (
112            Self {
113                new_w,
114                new_h,
115                pad_left,
116                pad_top,
117            },
118            scale,
119        )
120    }
121}
122
123/// Build a `PreprocessResult` from a resolved letterbox geometry.
124///
125/// Runs the fused zero-copy resize/pad/normalize, optionally produces an FP16 tensor,
126/// and packages the transform metadata.
127#[allow(clippy::cast_precision_loss)]
128fn build_preprocess_result(
129    image: &DynamicImage,
130    target_size: (usize, usize),
131    geom: LetterboxGeometry,
132    scale: (f32, f32),
133    orig_shape: (u32, u32),
134    half: bool,
135) -> PreprocessResult {
136    let (orig_width, orig_height) = image.dimensions();
137
138    let tensor = match image {
139        DynamicImage::ImageRgb8(rgb) => {
140            fused_zerocopy_preprocess(rgb.as_raw(), orig_width, orig_height, target_size, &geom)
141        }
142        _ => {
143            let src_rgb = image.to_rgb8();
144            fused_zerocopy_preprocess(
145                src_rgb.as_raw(),
146                orig_width,
147                orig_height,
148                target_size,
149                &geom,
150            )
151        }
152    };
153
154    let tensor_f16 = if half {
155        Some(tensor_f32_to_f16(&tensor))
156    } else {
157        None
158    };
159
160    PreprocessResult {
161        tensor,
162        tensor_f16,
163        orig_shape,
164        scale,
165        padding: (geom.pad_top as f32, geom.pad_left as f32),
166    }
167}
168
169/// Preprocess an image for YOLO inference.
170///
171/// Performs letterbox resizing, BGR to RGB conversion (if needed),
172/// normalization to [0, 1], and conversion to NCHW tensor format.
173///
174/// # Arguments
175///
176/// * `image` - Input image.
177/// * `target_size` - Target size as (height, width).
178/// * `stride` - Model stride for padding alignment (typically 32).
179///
180/// # Returns
181///
182/// Preprocessed tensor and transform information for post-processing.
183#[must_use]
184pub fn preprocess_image(
185    image: &DynamicImage,
186    target_size: (usize, usize),
187    stride: u32,
188) -> PreprocessResult {
189    preprocess_image_with_precision(image, target_size, stride, false)
190}
191
192/// Preprocess an image for YOLO inference with optional FP16 output.
193///
194/// # Arguments
195///
196/// * `image` - Input image.
197/// * `target_size` - Target size as (height, width).
198/// * `stride` - Model stride for padding alignment (typically 32).
199/// * `half` - If true, also generate FP16 tensor for FP16 models.
200///
201/// # Returns
202///
203/// Preprocessed tensor and transform information for post-processing.
204#[must_use]
205pub fn preprocess_image_with_precision(
206    image: &DynamicImage,
207    target_size: (usize, usize),
208    stride: u32,
209    half: bool,
210) -> PreprocessResult {
211    let (orig_width, orig_height) = image.dimensions();
212    let orig_shape = (orig_height, orig_width);
213
214    let (geom, scale) = calculate_letterbox_params(orig_width, orig_height, target_size, stride);
215    build_preprocess_result(image, target_size, geom, scale, orig_shape, half)
216}
217
218// ================================================================================================
219// Public API Functions
220// ================================================================================================
221
222/// Get or compute the X coordinate LUT for bilinear interpolation.
223///
224/// Uses 11-bit fixed-point weights matching `OpenCV`'s `INTER_LINEAR` coordinate mapping:
225/// `src_x = (dst_x + 0.5) * (src_w / dst_w) - 0.5`
226///
227/// Weight computation matches `OpenCV`'s `resize.cpp`:
228/// `cbuf[0] = saturate_cast<short>((1-fx) * 2048); cbuf[1] = 2048 - cbuf[0];`
229fn get_or_compute_x_lut(src_w: u32, dst_w: u32) -> Arc<Vec<XLutEntry>> {
230    let key = (src_w, dst_w);
231
232    X_LUT_CACHE.with(|cache| {
233        let mut cache = cache.borrow_mut();
234
235        if let Some(lut) = cache.get(&key) {
236            return Arc::clone(lut);
237        }
238
239        let scale_x = src_w as f32 / dst_w as f32;
240        let src_w_max = (src_w - 1) as i32;
241
242        let lut: Arc<Vec<XLutEntry>> = Arc::new(
243            (0..dst_w)
244                .map(|dx| {
245                    let sx = ((dx as f32 + 0.5) * scale_x - 0.5).max(0.0);
246                    let x0 = sx.floor() as i32;
247                    // Match OpenCV: cbuf[0] = saturate_cast<short>((1-fx)*SCALE),
248                    //               cbuf[1] = SCALE - cbuf[0]
249                    let fx_f = sx - x0 as f32;
250                    let fx_inv = ((1.0 - fx_f) * SCALE_INT as f32 + 0.5) as i32;
251                    let fx = SCALE_INT - fx_inv;
252                    let x0c = x0.clamp(0, src_w_max) as usize * 3;
253                    let x1c = (x0 + 1).clamp(0, src_w_max) as usize * 3;
254                    (x0c, x1c, fx_inv, fx)
255                })
256                .collect(),
257        );
258
259        cache.put(key, Arc::clone(&lut));
260        lut
261    })
262}
263
264/// Zero-copy fused preprocessing for maximum performance.
265///
266/// Combines bilinear resize, letterbox padding, and NCHW normalization
267/// in a single memory pass with parallel row processing.
268fn fused_zerocopy_preprocess(
269    src_raw: &[u8],
270    src_w: u32,
271    src_h: u32,
272    target_size: (usize, usize),
273    geom: &LetterboxGeometry,
274) -> Array4<f32> {
275    #[allow(clippy::wildcard_imports)] // native: rayon prelude; wasm: sequential shims
276    use crate::parallel::*;
277    use std::mem::MaybeUninit;
278    use std::sync::atomic::{AtomicPtr, Ordering};
279
280    let LetterboxGeometry {
281        new_w: new_width,
282        new_h: new_height,
283        pad_left,
284        pad_top,
285    } = *geom;
286
287    let (dst_h, dst_w) = target_size;
288    let channel_size = dst_h * dst_w;
289    let src_stride = (src_w * 3) as usize;
290
291    // ALLOCATE UNINITIALIZED: Saves ~0.2ms by not zeroing memory
292    let mut tensor: Array4<MaybeUninit<f32>> = Array4::uninit((1, 3, dst_h, dst_w));
293    let out_ptr = tensor.as_mut_ptr() as *mut f32;
294
295    // Use AtomicPtr for thread-safe pointer sharing (each thread writes to disjoint rows)
296    let atomic_ptr = AtomicPtr::new(out_ptr);
297
298    let x_lut = get_or_compute_x_lut(src_w, new_width);
299    let scale_y = src_h as f32 / new_height as f32;
300    let src_h_max = (src_h - 1) as i32;
301
302    let pad_top_usize = pad_top as usize;
303    let pad_left_usize = pad_left as usize;
304    let new_height_usize = new_height as usize;
305    let new_width_usize = new_width as usize;
306
307    // Parallel row processing with raw pointers (no bounds checks)
308    (0..dst_h).into_par_iter().for_each(|dy| {
309        let data_ptr = atomic_ptr.load(Ordering::Relaxed);
310        unsafe {
311            // Calculate row pointers for R, G, B channels
312
313            let r_row = data_ptr.add(dy * dst_w);
314            let g_row = data_ptr.add(channel_size + dy * dst_w);
315            let b_row = data_ptr.add(2 * channel_size + dy * dst_w);
316
317            // Vertical padding (top/bottom rows)
318            if dy < pad_top_usize || dy >= pad_top_usize + new_height_usize {
319                for dx in 0..dst_w {
320                    *r_row.add(dx) = LETTERBOX_NORM;
321                    *g_row.add(dx) = LETTERBOX_NORM;
322                    *b_row.add(dx) = LETTERBOX_NORM;
323                }
324                return;
325            }
326
327            // Image row calculations - 11-bit fixed-point bilinear matching
328            // OpenCV's INTER_LINEAR (INTER_RESIZE_COEF_BITS = 11).
329            let img_dy = dy - pad_top_usize;
330            let sy = ((img_dy as f32 + 0.5) * scale_y - 0.5).max(0.0);
331            let y0 = sy.floor() as i32;
332            let fy_f = sy - y0 as f32;
333            let fy_inv = ((1.0 - fy_f) * SCALE_INT as f32 + 0.5) as i32;
334            let fy = SCALE_INT - fy_inv;
335
336            let y0c = y0.clamp(0, src_h_max) as usize;
337            let y1c = (y0 + 1).clamp(0, src_h_max) as usize;
338            let row0_off = y0c * src_stride;
339            let row1_off = y1c * src_stride;
340
341            // Left padding
342            for dx in 0..pad_left_usize {
343                *r_row.add(dx) = LETTERBOX_NORM;
344                *g_row.add(dx) = LETTERBOX_NORM;
345                *b_row.add(dx) = LETTERBOX_NORM;
346            }
347
348            // Inner image pixels - fixed-point bilinear with rounding.
349            // Uses untruncated weights (w = fx * fy, range [0, 2048^2]) and a
350            // single shift with rounding bias, matching OpenCV's saturate_cast:
351            //   result = (sum + ROUND_BIAS) >> 22
352            // Max intermediate: 255 * 2048^2 + 2^21 β‰ˆ 1.07B < i32::MAX.
353            let mut img_dx = 0usize;
354            let src_ptr = src_raw.as_ptr();
355
356            while img_dx < new_width_usize {
357                let (x0_off, x1_off, fx_inv, fx) = *x_lut.get_unchecked(img_dx);
358                let w00 = fx_inv * fy_inv;
359                let w10 = fx * fy_inv;
360                let w01 = fx_inv * fy;
361                let w11 = fx * fy;
362
363                let p00 = src_ptr.add(row0_off + x0_off);
364                let p10 = src_ptr.add(row0_off + x1_off);
365                let p01 = src_ptr.add(row1_off + x0_off);
366                let p11 = src_ptr.add(row1_off + x1_off);
367
368                let out_x = pad_left_usize + img_dx;
369                *r_row.add(out_x) = ((*p00 as i32 * w00
370                    + *p10 as i32 * w10
371                    + *p01 as i32 * w01
372                    + *p11 as i32 * w11
373                    + ROUND_BIAS)
374                    >> SCALE_BITS_2X) as f32
375                    * INV_255;
376                *g_row.add(out_x) = ((*p00.add(1) as i32 * w00
377                    + *p10.add(1) as i32 * w10
378                    + *p01.add(1) as i32 * w01
379                    + *p11.add(1) as i32 * w11
380                    + ROUND_BIAS)
381                    >> SCALE_BITS_2X) as f32
382                    * INV_255;
383                *b_row.add(out_x) = ((*p00.add(2) as i32 * w00
384                    + *p10.add(2) as i32 * w10
385                    + *p01.add(2) as i32 * w01
386                    + *p11.add(2) as i32 * w11
387                    + ROUND_BIAS)
388                    >> SCALE_BITS_2X) as f32
389                    * INV_255;
390
391                img_dx += 1;
392            }
393
394            // Right padding
395            for dx in (pad_left_usize + new_width_usize)..dst_w {
396                *r_row.add(dx) = LETTERBOX_NORM;
397                *g_row.add(dx) = LETTERBOX_NORM;
398                *b_row.add(dx) = LETTERBOX_NORM;
399            }
400        }
401    });
402
403    // SAFETY: All elements have been initialized
404    unsafe { tensor.assume_init() }
405}
406
407/// Convert f32 tensor to f16 tensor.
408fn tensor_f32_to_f16(tensor: &Array4<f32>) -> Array4<half::f16> {
409    use half::slice::HalfFloatSliceExt;
410    // `mapv` converts one element at a time; `half`'s slice conversion is vectorized where
411    // the target supports it. Falls back to `mapv` if the tensor is not contiguous.
412    let Some(src) = tensor.as_slice() else {
413        return tensor.mapv(half::f16::from_f32);
414    };
415    let mut out = vec![half::f16::ZERO; src.len()];
416    out.convert_from_f32_slice(src);
417    Array4::from_shape_vec(tensor.raw_dim(), out).expect("shape matches the source tensor")
418}
419
420/// Calculate target size for rectangular inference mode.
421///
422/// Adjusts `target_size` such that the image's aspect ratio is preserved,
423/// and both dimensions are multiples of `stride`.
424///
425/// # Arguments
426///
427/// * `orig_width` - Original image width.
428/// * `orig_height` - Original image height.
429/// * `target_size` - Base target size (e.g. 640x640).
430/// * `stride` - Model stride for alignment.
431///
432/// # Returns
433///
434/// Adjusted target size as (height, width).
435#[must_use]
436pub fn calculate_rect_size(
437    orig_width: u32,
438    orig_height: u32,
439    target_size: (usize, usize),
440    stride: u32,
441) -> (usize, usize) {
442    let (target_h, target_w) = target_size;
443
444    #[allow(clippy::cast_precision_loss)]
445    let orig_h = orig_height as f32;
446    #[allow(clippy::cast_precision_loss)]
447    let orig_w = orig_width as f32;
448    #[allow(clippy::cast_precision_loss)]
449    let target_h_f = target_h as f32;
450    #[allow(clippy::cast_precision_loss)]
451    let target_w_f = target_w as f32;
452
453    // Calculate scale to fit within target while maintaining aspect ratio
454    let scale = (target_h_f / orig_h).min(target_w_f / orig_w);
455
456    // New dimensions after scaling
457    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
458    let new_h = (orig_h * scale).round() as usize;
459    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
460    let new_w = (orig_w * scale).round() as usize;
461
462    // Round up to nearest multiple of stride
463    let stride = stride as usize;
464    let rect_h = ((new_h + stride - 1) / stride) * stride;
465    let rect_w = ((new_w + stride - 1) / stride) * stride;
466
467    (rect_h, rect_w)
468}
469
470/// Calculate letterbox parameters for resizing.
471///
472/// Computes new dimensions and padding to fit the image within the target size while maintaining aspect ratio.
473///
474/// # Arguments
475///
476/// * `orig_width` - Original image width.
477/// * `orig_height` - Original image height.
478/// * `target_size` - Target size as (height, width).
479/// * `stride` - Model stride for alignment (unused in calculation but kept for API compatibility).
480///
481/// # Returns
482///
483/// Tuple containing:
484/// 1. `new_width`: Scaled width.
485/// 2. `new_height`: Scaled height.
486/// 3. `pad_left`: Left padding.
487/// 4. `pad_top`: Top padding.
488/// 5. `(scale_y, scale_x)`: Scale factors.
489fn calculate_letterbox_params(
490    orig_width: u32,
491    orig_height: u32,
492    target_size: (usize, usize),
493    _stride: u32,
494) -> (LetterboxGeometry, (f32, f32)) {
495    // A single uniform `gain` on both axes for coordinate back-projection; per-axis gains
496    // from the rounded extents can diverge slightly, shifting boxes and changing NMS.
497    let (geom, scale) = LetterboxGeometry::compute(orig_width, orig_height, target_size);
498    (geom, (scale, scale))
499}
500
501/// Convert an RGB image to a normalized NCHW tensor, planar (CHW) layout.
502///
503/// Allocates the `(1, 3, H, W)` tensor, splits it into per-channel slices, and fills each
504/// pixel with `convert` applied to the source byte. The caller supplies the element type's
505/// zero value and the per-byte conversion, so the f32 and f16 callers each keep their exact
506/// arithmetic (the f16 one hoists its scale constant into the closure).
507fn image_to_tensor<T: Clone>(
508    image: &RgbImage,
509    zero: T,
510    mut convert: impl FnMut(u8) -> T,
511) -> Array4<T> {
512    let (width, height) = image.dimensions();
513    let (w, h) = (width as usize, height as usize);
514    let pixels = image.as_raw();
515
516    let mut tensor = Array4::from_elem((1, 3, h, w), zero);
517
518    // Get mutable slices for each channel for faster access
519    let (r_slice, rest) = tensor.as_slice_mut().unwrap().split_at_mut(h * w);
520    let (g_slice, b_slice) = rest.split_at_mut(h * w);
521
522    for (i, chunk) in pixels.as_chunks::<3>().0.iter().enumerate() {
523        r_slice[i] = convert(chunk[0]);
524        g_slice[i] = convert(chunk[1]);
525        b_slice[i] = convert(chunk[2]);
526    }
527
528    tensor
529}
530
531/// Convert a `DynamicImage` to an HWC ndarray.
532///
533/// # Panics
534///
535/// Panics if the array cannot be created from the image pixels (e.g. dimension mismatch).
536#[must_use]
537pub fn image_to_array(image: &DynamicImage) -> Array3<u8> {
538    let rgb = image.to_rgb8();
539    let (width, height) = rgb.dimensions();
540    let pixels = rgb.into_raw();
541
542    Array3::from_shape_vec((height as usize, width as usize, 3), pixels)
543        .expect("Failed to create array from image pixels")
544}
545
546/// Scale coordinates from model output space back to original image space.
547///
548/// # Arguments
549///
550/// * `coords` - Coordinates in model space (after letterbox).
551/// * `scale` - Scale factors (`scale_y`, `scale_x`) from preprocessing.
552/// * `padding` - Padding (`pad_top`, `pad_left`) from preprocessing.
553///
554/// # Returns
555///
556/// Coordinates in original image space.
557#[must_use]
558pub fn scale_coords(coords: &[f32; 4], scale: (f32, f32), padding: (f32, f32)) -> [f32; 4] {
559    let (scale_y, scale_x) = scale;
560    let (pad_top, pad_left) = padding;
561
562    [
563        (coords[0] - pad_left) / scale_x, // x1
564        (coords[1] - pad_top) / scale_y,  // y1
565        (coords[2] - pad_left) / scale_x, // x2
566        (coords[3] - pad_top) / scale_y,  // y2
567    ]
568}
569
570/// Clip coordinates to image bounds.
571///
572/// # Arguments
573///
574/// * `coords` - Box coordinates [x1, y1, x2, y2].
575/// * `shape` - Image shape (height, width).
576///
577/// # Returns
578///
579/// Clipped coordinates.
580#[must_use]
581pub const fn clip_coords(coords: &[f32; 4], shape: (u32, u32)) -> [f32; 4] {
582    #[allow(clippy::cast_precision_loss)]
583    let (h, w) = (shape.0 as f32, shape.1 as f32);
584    [
585        coords[0].clamp(0.0, w),
586        coords[1].clamp(0.0, h),
587        coords[2].clamp(0.0, w),
588        coords[3].clamp(0.0, h),
589    ]
590}
591
592/// Preprocess an image for YOLO classification (Center Crop).
593///
594/// Resizes the image so the shortest side matches `target_size`, then center crops.
595///
596/// # Arguments
597///
598/// * `image` - Input image.
599/// * `target_size` - Target size as (height, width).
600/// * `half` - If true, also generate FP16 tensor.
601///
602/// # Returns
603///
604/// Preprocessed tensor and transform information.
605#[must_use]
606pub fn preprocess_image_center_crop(
607    image: &DynamicImage,
608    target_size: (usize, usize),
609    half: bool,
610) -> PreprocessResult {
611    let (orig_width, orig_height) = image.dimensions();
612    let orig_shape = (orig_height, orig_width);
613
614    // Perform center crop resize
615    let (cropped, scale) = center_crop_image(image, target_size);
616
617    // Convert to normalized NCHW tensor
618    let tensor = image_to_tensor(&cropped, 0.0, |v| f32::from(v) / 255.0);
619
620    // Optionally compute FP16 tensor, converting u8 straight to f16 with a hoisted 1/255.
621    let tensor_f16 = half.then(|| {
622        let scale = f16::from_f32(1.0 / 255.0);
623        image_to_tensor(&cropped, f16::ZERO, move |v| {
624            f16::from_f32(f32::from(v)) * scale
625        })
626    });
627
628    // For classification, we don't need complex coordinate mapping back to original
629    // But we provide approximate scale/padding to satisfy strict types if needed.
630    // In classification, we rarely map bounding boxes back, so these are less critical.
631    let padding = (0.0, 0.0);
632
633    PreprocessResult {
634        tensor,
635        tensor_f16,
636        orig_shape,
637        scale,
638        padding,
639    }
640}
641
642/// Resize and center crop image.
643///
644/// Resizes the image such that the shortest side equals the target dimension,
645/// maintaining aspect ratio, then crops the center `target_size`.
646///
647/// # Arguments
648///
649/// * `image` - Source dynamic image.
650/// * `target_size` - Desired output dimensions (height, width).
651///
652/// # Returns
653///
654/// Tuple containing:
655/// 1. `cropped`: The processed `RgbImage`.
656/// 2. `scale`: Scale factors applied (same for x and y).
657#[allow(clippy::similar_names)]
658fn center_crop_image(image: &DynamicImage, target_size: (usize, usize)) -> (RgbImage, (f32, f32)) {
659    use fast_image_resize::{
660        PixelType, ResizeAlg, ResizeOptions, Resizer,
661        images::{Image, ImageRef},
662    };
663
664    let (src_w, src_h) = image.dimensions();
665    #[allow(clippy::cast_possible_truncation)]
666    let (target_h, target_w) = (target_size.0 as u32, target_size.1 as u32);
667
668    // Calculate scale to "cover" the target area
669    // scale = max(target_w / src_w, target_h / src_h)
670    #[allow(clippy::cast_precision_loss)]
671    let scale_x = target_w as f32 / src_w as f32;
672    #[allow(clippy::cast_precision_loss)]
673    let scale_y = target_h as f32 / src_h as f32;
674    let scale = scale_x.max(scale_y);
675
676    let (new_w, new_h) = if scale_x >= scale_y {
677        #[allow(
678            clippy::cast_possible_truncation,
679            clippy::cast_sign_loss,
680            clippy::cast_precision_loss
681        )]
682        (target_w, (src_h as f32 * scale_x) as u32)
683    } else {
684        #[allow(
685            clippy::cast_possible_truncation,
686            clippy::cast_sign_loss,
687            clippy::cast_precision_loss
688        )]
689        ((src_w as f32 * scale_y) as u32, target_h)
690    };
691
692    // Resize first. Borrow the source samples when the image is already RGB8 (the common
693    // case): `to_rgb8` would clone the full frame just to hand it straight to the resizer.
694    let owned_rgb;
695    let src_bytes: &[u8] = match image {
696        DynamicImage::ImageRgb8(rgb) => rgb.as_raw(),
697        other => {
698            owned_rgb = other.to_rgb8();
699            owned_rgb.as_raw()
700        }
701    };
702    let src_image = ImageRef::new(src_w, src_h, src_bytes, PixelType::U8x3)
703        .expect("Failed to create source image");
704
705    // Valid dimensions check
706    let safe_new_w = new_w.max(1);
707    let safe_new_h = new_h.max(1);
708
709    let mut dst_image = Image::new(safe_new_w, safe_new_h, PixelType::U8x3);
710
711    let mut resizer = Resizer::new();
712    let options = ResizeOptions::new().resize_alg(ResizeAlg::Convolution(
713        fast_image_resize::FilterType::Bilinear,
714    ));
715    resizer
716        .resize(&src_image, &mut dst_image, Some(&options))
717        .expect("Failed to resize image");
718
719    // Convert back to RgbImage to crop
720    let resized_buffer = dst_image.into_vec();
721    let resized_rgb = RgbImage::from_raw(safe_new_w, safe_new_h, resized_buffer)
722        .expect("Failed to create resized buffer");
723
724    // Calculate crop offsets using Banker's Rounding (round half to even).
725    #[allow(clippy::cast_precision_loss)]
726    let crop_x_float = (new_w.saturating_sub(target_w)) as f32 / 2.0;
727    #[allow(clippy::cast_precision_loss)]
728    let crop_y_float = (new_h.saturating_sub(target_h)) as f32 / 2.0;
729
730    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
731    let crop_x = bankers_round(crop_x_float) as u32;
732    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
733    let crop_y = bankers_round(crop_y_float) as u32;
734
735    let cropped =
736        image::imageops::crop_imm(&resized_rgb, crop_x, crop_y, target_w, target_h).to_image();
737
738    (cropped, (scale, scale))
739}
740
741/// Round float to nearest integer, rounding half to even (Banker's Rounding).
742fn bankers_round(v: f32) -> f32 {
743    let n = v.floor();
744    let d = v - n;
745    if (d - 0.5).abs() < 1e-6 {
746        if n % 2.0 == 0.0 { n } else { n + 1.0 }
747    } else {
748        v.round()
749    }
750}
751
752#[allow(clippy::similar_names)]
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    #[test]
758    fn test_letterbox_params() {
759        // Square input into a square target: exact fit, no padding.
760        let (geom, _scale) = calculate_letterbox_params(640, 640, (640, 640), 32);
761        assert_eq!((geom.new_w, geom.new_h), (640, 640));
762        assert_eq!((geom.pad_left, geom.pad_top), (0, 0));
763
764        // Wide input is scaled down to fit and padded top/bottom, not left/right.
765        let (geom, _) = calculate_letterbox_params(1280, 720, (640, 640), 32);
766        assert!(geom.new_w <= 640 && geom.new_h <= 640);
767        assert_eq!(geom.pad_left, 0);
768
769        // Tall input is the mirror case: padded left/right, not top/bottom.
770        let (geom, _) = calculate_letterbox_params(480, 640, (640, 640), 32);
771        assert!(geom.pad_left > 0);
772        assert_eq!(geom.pad_top, 0);
773    }
774
775    #[test]
776    fn test_scale_coords() {
777        let coords = [100.0, 100.0, 200.0, 200.0];
778        let scale = (1.0, 1.0);
779        let padding = (10.0, 10.0);
780
781        let scaled = scale_coords(&coords, scale, padding);
782
783        assert!((scaled[0] - 90.0).abs() < 1e-6);
784        assert!((scaled[1] - 90.0).abs() < 1e-6);
785        assert!((scaled[2] - 190.0).abs() < 1e-6);
786        assert!((scaled[3] - 190.0).abs() < 1e-6);
787    }
788
789    #[test]
790    fn test_clip_coords() {
791        let coords = [-10.0, -20.0, 700.0, 500.0];
792        let clipped = clip_coords(&coords, (480, 640));
793
794        assert!((clipped[0] - 0.0).abs() < 1e-6);
795        assert!((clipped[1] - 0.0).abs() < 1e-6);
796        assert!((clipped[2] - 640.0).abs() < 1e-6);
797        assert!((clipped[3] - 480.0).abs() < 1e-6);
798    }
799
800    #[test]
801    fn test_preprocess_image_center_crop() {
802        // Classification path: center-crop to the model size, normalize to [0, 1], and
803        // emit the FP16 tensor only when asked. A non-square source exercises the crop.
804        let img = image::DynamicImage::new_rgb8(400, 300);
805        for half in [false, true] {
806            let res = preprocess_image_center_crop(&img, (224, 224), half);
807            assert_eq!(res.tensor.dim(), (1, 3, 224, 224));
808            assert_eq!(res.orig_shape, (300, 400));
809            assert_eq!(res.padding, (0.0, 0.0));
810            assert!(res.tensor.iter().all(|v| (0.0..=1.0).contains(v)));
811            assert_eq!(res.tensor_f16.is_some(), half);
812            if let Some(t16) = &res.tensor_f16 {
813                assert_eq!(t16.dim(), res.tensor.dim());
814            }
815        }
816    }
817
818    #[test]
819    fn test_preprocess_image_static_centered_letterbox() {
820        // 480Γ—640 wide image, target=1024Γ—1024, is_dynamic=false.
821        // Scale = min(1024/480, 1024/640) = 1024/640 = 1.6; nh=768, nw=1024, pad_topβ‰ˆ128.
822        let img = image::DynamicImage::new_rgb8(640, 480);
823        let res = preprocess_image_with_precision(&img, (1024, 1024), 32, false);
824        let (_, _, h, w) = res.tensor.dim();
825        assert_eq!(h, 1024);
826        assert_eq!(w, 1024);
827        // Wide image: horizontal padding is 0, vertical padding is non-zero.
828        assert!(res.padding.1.abs() < 1e-6, "wide image: no left padding");
829        assert!(res.padding.0 > 0.0, "wide image: top padding expected");
830    }
831
832    #[test]
833    fn test_preprocess_image_rect_uses_centered_letterbox() {
834        // Mirrors Ultralytics LetterBox((1024, 1024), auto=True, stride=32) for a 333Γ—640 image:
835        // resized content is 533Γ—1024 and centered in a 544Γ—1024 rect with 5/6 vertical padding.
836        let img = image::DynamicImage::new_rgb8(640, 333);
837        let rect_size = calculate_rect_size(640, 333, (1024, 1024), 32);
838        assert_eq!(rect_size, (544, 1024));
839        let res = preprocess_image_with_precision(&img, rect_size, 32, false);
840        let (_, _, h, w) = res.tensor.dim();
841        assert_eq!((h, w), rect_size);
842        assert_eq!(res.padding, (5.0, 0.0));
843    }
844
845    #[test]
846    fn test_preprocess_image_public_wrapper() {
847        let img = image::DynamicImage::new_rgb8(320, 240);
848        let res = preprocess_image(&img, (640, 640), 32);
849        let (_, c, h, w) = res.tensor.dim();
850        assert_eq!((c, h, w), (3, 640, 640));
851        assert!(res.tensor_f16.is_none());
852    }
853
854    #[test]
855    fn test_preprocess_image_fp16_path() {
856        let img = image::DynamicImage::new_rgb8(320, 240);
857        let res = preprocess_image_with_precision(&img, (640, 640), 32, true);
858        // half=true also produces the FP16 tensor mirroring the FP32 one.
859        let f16 = res.tensor_f16.expect("fp16 tensor present");
860        assert_eq!(f16.dim(), res.tensor.dim());
861    }
862
863    #[test]
864    fn test_preprocess_various_aspect_ratios() {
865        // Tall, wide, square, and tiny inputs all produce the requested tensor size.
866        for (w, h) in [(100u32, 400u32), (400, 100), (1, 1), (640, 640)] {
867            let img = image::DynamicImage::new_rgb8(w, h);
868            let res = preprocess_image(&img, (320, 320), 32);
869            let (_, c, th, tw) = res.tensor.dim();
870            assert_eq!((c, th, tw), (3, 320, 320));
871            assert_eq!(res.orig_shape, (h, w));
872        }
873    }
874
875    #[test]
876    fn test_x_lut_cache_reuse() {
877        // Two preprocess calls at the same width reuse the cached x-LUT (cache-hit path).
878        let img = image::DynamicImage::new_rgb8(200, 150);
879        let a = preprocess_image(&img, (320, 320), 32);
880        let b = preprocess_image(&img, (320, 320), 32);
881        assert_eq!(a.tensor.dim(), b.tensor.dim());
882    }
883
884    #[test]
885    fn test_calculate_rect_size() {
886        // A square input at the target size is returned unchanged.
887        assert_eq!(calculate_rect_size(640, 640, (640, 640), 32), (640, 640));
888
889        // Portrait, landscape, and non-aligned inputs all round to stride multiples
890        // and stay within the target.
891        for (w, h) in [(400u32, 1000u32), (1000, 400), (800, 600)] {
892            let (rh, rw) = calculate_rect_size(w, h, (640, 640), 32);
893            assert_eq!((rh % 32, rw % 32), (0, 0));
894            assert!(rh <= 640 && rw <= 640);
895        }
896    }
897}