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
32use crate::inference::Quantization;
33
34/// Converts current and legacy preprocessing precision arguments to `quantize`.
35#[doc(hidden)]
36pub trait IntoQuantization {
37    /// Convert to the unified precision representation.
38    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
53/// Default letterbox padding color (gray).
54pub const LETTERBOX_COLOR: [u8; 3] = [114, 114, 114];
55
56/// Fixed-point scale factor for bilinear interpolation (2^11 = 2048).
57/// Matches `OpenCV`'s `INTER_RESIZE_COEF_BITS = 11` for `INTER_LINEAR`.
58const SCALE_BITS: i32 = 11;
59const SCALE_INT: i32 = 1 << SCALE_BITS;
60
61/// Double scale bits for single-pass bilinear interpolation.
62const SCALE_BITS_2X: i32 = 2 * SCALE_BITS;
63
64/// Rounding bias for fixed-point bilinear, added before the final right-shift
65/// to achieve round-to-nearest behavior matching `OpenCV`'s `saturate_cast`.
66const ROUND_BIAS: i32 = 1 << (SCALE_BITS_2X - 1);
67
68/// Normalized letterbox padding color (114/255 β‰ˆ 0.447).
69const LETTERBOX_NORM: f32 = 114.0 / 255.0;
70
71/// Reciprocal of 255 for normalization.
72const INV_255: f32 = 1.0 / 255.0;
73
74/// Maximum LRU cache size for X coordinate LUTs.
75const LUT_CACHE_SIZE: usize = 8;
76
77/// X LUT entry: (`x0_byte_offset`, `x1_byte_offset`, 1-fx, fx) using 11-bit fixed-point
78/// weights matching `OpenCV`'s `INTER_LINEAR` coordinate mapping.
79type 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/// Result of preprocessing an image, containing the tensor and transform info.
88#[derive(Debug, Clone)]
89pub struct PreprocessResult {
90    /// Preprocessed image tensor in NCHW format, normalized to [0, 1].
91    pub tensor: Array4<f32>,
92    /// Preprocessed FP16 tensor (if requested).
93    pub tensor_f16: Option<Array4<f16>>,
94    /// Original image dimensions (height, width).
95    pub orig_shape: (u32, u32),
96    /// Scale factors applied (`scale_y`, `scale_x`).
97    pub scale: (f32, f32),
98    /// Padding applied (`pad_top`, `pad_left`).
99    pub padding: (f32, f32),
100}
101
102/// Geometry parameters resolved by letterbox layout.
103#[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    /// Letterbox geometry for fitting `(orig_w, orig_h)` into `target` = `(height, width)`:
113    /// a uniform scale (`min` over both axes), rounded resized extents, and centered
114    /// padding. The returned `scale` is that uniform gain, used for coordinate
115    /// back-projection.
116    ///
117    /// The single source of this math for both the CPU letterbox and the `cuda-preprocess`
118    /// kernel, so the two can never disagree on where a pixel lands.
119    #[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        // Rounding can collapse an extreme aspect ratio to zero: a 10000x1 source at imgsz 640
129        // scales its height to 0.064, which would letterbox the entire tensor and throw the
130        // image away. Keep at least one pixel per axis, leaving a genuinely empty source at 0.
131        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/// Build a `PreprocessResult` from a resolved letterbox geometry.
155///
156/// Runs the fused zero-copy resize/pad/normalize, optionally produces an FP16 tensor,
157/// and packages the transform metadata.
158#[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/// Preprocess an image for YOLO inference.
201///
202/// Performs letterbox resizing, BGR to RGB conversion (if needed),
203/// normalization to [0, 1], and conversion to NCHW tensor format.
204///
205/// # Arguments
206///
207/// * `image` - Input image.
208/// * `target_size` - Target size as (height, width).
209/// * `stride` - Model stride for padding alignment (typically 32).
210///
211/// # Returns
212///
213/// Preprocessed tensor and transform information for post-processing.
214#[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/// Preprocess an image for YOLO inference with optional FP16 output.
224///
225/// # Arguments
226///
227/// * `image` - Input image.
228/// * `target_size` - Target size as (height, width).
229/// * `stride` - Model stride for padding alignment (typically 32).
230/// * `quantize` - Requested precision. FP16 also generates an FP16 tensor.
231///
232/// # Returns
233///
234/// Preprocessed tensor and transform information for post-processing.
235#[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
257/// Get or compute the X coordinate LUT for bilinear interpolation.
258///
259/// Uses 11-bit fixed-point weights matching `OpenCV`'s `INTER_LINEAR` coordinate mapping:
260/// `src_x = (dst_x + 0.5) * (src_w / dst_w) - 0.5`
261///
262/// Weight computation matches `OpenCV`'s `resize.cpp`:
263/// `cbuf[0] = saturate_cast<short>((1-fx) * 2048); cbuf[1] = 2048 - cbuf[0];`
264fn 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                    // Match OpenCV: cbuf[0] = saturate_cast<short>((1-fx)*SCALE),
283                    //               cbuf[1] = SCALE - cbuf[0]
284                    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
299/// Zero-copy fused preprocessing for maximum performance.
300///
301/// Combines bilinear resize, letterbox padding, and NCHW normalization
302/// in a single memory pass with parallel row processing.
303fn 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)] // native: rayon prelude; wasm: sequential shims
311    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    // A zero-extent source or image region has no pixels to sample. Every row would take the
325    // padding branch below anyway, but the LUT and scale setup would first underflow on
326    // `src_w - 1` / `src_h - 1` and divide by zero, so return the letterbox fill directly.
327    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    // ALLOCATE UNINITIALIZED: Saves ~0.2ms by not zeroing memory
335    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    // Use AtomicPtr for thread-safe pointer sharing (each thread writes to disjoint rows)
339    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    // Parallel row processing with raw pointers (no bounds checks)
351    (0..dst_h).into_par_iter().for_each(|dy| {
352        let data_ptr = atomic_ptr.load(Ordering::Relaxed);
353        unsafe {
354            // Calculate row pointers for R, G, B channels
355
356            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            // Vertical padding (top/bottom rows)
361            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            // Image row calculations - 11-bit fixed-point bilinear matching
371            // OpenCV's INTER_LINEAR (INTER_RESIZE_COEF_BITS = 11).
372            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            // Left padding
385            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            // Inner image pixels - fixed-point bilinear with rounding.
392            // Uses untruncated weights (w = fx * fy, range [0, 2048^2]) and a
393            // single shift with rounding bias, matching OpenCV's saturate_cast:
394            //   result = (sum + ROUND_BIAS) >> 22
395            // Max intermediate: 255 * 2048^2 + 2^21 β‰ˆ 1.07B < i32::MAX.
396            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            // Right padding
438            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    // SAFETY: All elements have been initialized
447    unsafe { tensor.assume_init() }
448}
449
450/// Convert f32 tensor to f16 tensor.
451fn tensor_f32_to_f16(tensor: &Array4<f32>) -> Array4<half::f16> {
452    use half::slice::HalfFloatSliceExt;
453    // `mapv` converts one element at a time; `half`'s slice conversion is vectorized where
454    // the target supports it. Falls back to `mapv` if the tensor is not contiguous.
455    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/// Calculate target size for rectangular inference mode.
464///
465/// Adjusts `target_size` such that the image's aspect ratio is preserved,
466/// and both dimensions are multiples of `stride`.
467///
468/// # Arguments
469///
470/// * `orig_width` - Original image width.
471/// * `orig_height` - Original image height.
472/// * `target_size` - Base target size (e.g. 640x640).
473/// * `stride` - Model stride for alignment.
474///
475/// # Returns
476///
477/// Adjusted target size as (height, width).
478#[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    // Calculate scale to fit within target while maintaining aspect ratio
497    let scale = (target_h_f / orig_h).min(target_w_f / orig_w);
498
499    // New dimensions after scaling
500    #[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    // Round up to nearest multiple of stride. A degenerate source rounds an extent to zero
506    // (a zero-width image, or an aspect ratio past what the target can represent), which would
507    // hand the model a zero-width input and the CUDA preprocess a zero-sized kernel grid, so
508    // keep at least one stride step on each axis.
509    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
516/// Calculate letterbox parameters for resizing.
517///
518/// Computes new dimensions and padding to fit the image within the target size while maintaining aspect ratio.
519///
520/// # Arguments
521///
522/// * `orig_width` - Original image width.
523/// * `orig_height` - Original image height.
524/// * `target_size` - Target size as (height, width).
525/// * `stride` - Model stride for alignment (unused in calculation but kept for API compatibility).
526///
527/// # Returns
528///
529/// Tuple containing:
530/// 1. `new_width`: Scaled width.
531/// 2. `new_height`: Scaled height.
532/// 3. `pad_left`: Left padding.
533/// 4. `pad_top`: Top padding.
534/// 5. `(scale_y, scale_x)`: Scale factors.
535fn calculate_letterbox_params(
536    orig_width: u32,
537    orig_height: u32,
538    target_size: (usize, usize),
539    _stride: u32,
540) -> (LetterboxGeometry, (f32, f32)) {
541    // A single uniform `gain` on both axes for coordinate back-projection; per-axis gains
542    // from the rounded extents can diverge slightly, shifting boxes and changing NMS.
543    let (geom, scale) = LetterboxGeometry::compute(orig_width, orig_height, target_size);
544    (geom, (scale, scale))
545}
546
547/// Convert an RGB image to a normalized NCHW tensor, planar (CHW) layout.
548///
549/// Allocates the `(1, 3, H, W)` tensor, splits it into per-channel slices, and fills each
550/// pixel with `convert` applied to the source byte. The caller supplies the element type's
551/// zero value and the per-byte conversion, so the f32 and f16 callers each keep their exact
552/// arithmetic (the f16 one hoists its scale constant into the closure).
553fn 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    // Get mutable slices for each channel for faster access
565    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/// Convert a `DynamicImage` to an HWC ndarray.
578///
579/// # Panics
580///
581/// Panics if the array cannot be created from the image pixels (e.g. dimension mismatch).
582#[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/// Scale coordinates from model output space back to original image space.
593///
594/// # Arguments
595///
596/// * `coords` - Coordinates in model space (after letterbox).
597/// * `scale` - Scale factors (`scale_y`, `scale_x`) from preprocessing.
598/// * `padding` - Padding (`pad_top`, `pad_left`) from preprocessing.
599///
600/// # Returns
601///
602/// Coordinates in original image space.
603#[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, // x1
610        (coords[1] - pad_top) / scale_y,  // y1
611        (coords[2] - pad_left) / scale_x, // x2
612        (coords[3] - pad_top) / scale_y,  // y2
613    ]
614}
615
616/// Clip coordinates to image bounds.
617///
618/// # Arguments
619///
620/// * `coords` - Box coordinates [x1, y1, x2, y2].
621/// * `shape` - Image shape (height, width).
622///
623/// # Returns
624///
625/// Clipped coordinates.
626#[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/// Preprocess an image for YOLO classification (Center Crop).
639///
640/// Resizes the image so the shortest side matches `target_size`, then center crops.
641///
642/// A zero-width or zero-height source yields a `target_size` frame of the letterbox
643/// padding color, matching [`preprocess_image`]. `target_size` is returned as requested,
644/// so a zero target yields a zero-extent tensor there too.
645///
646/// # Arguments
647///
648/// * `image` - Input image.
649/// * `target_size` - Target size as (height, width).
650/// * `quantize` - Requested precision. FP16 also generates an FP16 tensor.
651///
652/// # Returns
653///
654/// Preprocessed tensor and transform information.
655#[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    // Perform center crop resize
666    let (cropped, scale) = center_crop_image(image, target_size);
667
668    // Convert to normalized NCHW tensor
669    let tensor = image_to_tensor(&cropped, 0.0, |v| f32::from(v) / 255.0);
670
671    // Optionally compute FP16 tensor, converting u8 straight to f16 with a hoisted 1/255.
672    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    // For classification, we don't need complex coordinate mapping back to original
680    // But we provide approximate scale/padding to satisfy strict types if needed.
681    // In classification, we rarely map bounding boxes back, so these are less critical.
682    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/// Resize and center crop image.
694///
695/// Resizes the image such that the shortest side equals the target dimension,
696/// maintaining aspect ratio, then crops the center `target_size`.
697///
698/// # Arguments
699///
700/// * `image` - Source dynamic image.
701/// * `target_size` - Desired output dimensions (height, width).
702///
703/// # Returns
704///
705/// Tuple containing:
706/// 1. `cropped`: The processed `RgbImage`.
707/// 2. `scale`: Scale factors applied (same for x and y).
708#[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    // The cover scale below divides by each source extent, so `target / 0` is infinite: the
720    // resized extents come out garbage and the allocator is asked for terabytes.
721    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    // Calculate scale to "cover" the target area
727    // scale = max(target_w / src_w, target_h / src_h)
728    #[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    // Resize first. Borrow the source samples when the image is already RGB8 (the common
751    // case): `to_rgb8` would clone the full frame just to hand it straight to the resizer.
752    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    // Valid dimensions check
764    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    // Convert back to RgbImage to crop
778    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    // Calculate crop offsets using Banker's Rounding (round half to even).
783    #[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
799/// Round float to nearest integer, rounding half to even (Banker's Rounding).
800fn 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    /// An aspect ratio worse than the target size rounds one extent to zero, which used to
816    /// discard the image and hand the model a uniformly gray tensor.
817    #[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    /// A zero-dimension image underflowed `src_w - 1` in the LUT builder, which panics under
837    /// the overflow checks the dev profile enables.
838    #[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            // `rect` mode picks its own target before preprocessing, so that target has to
852            // stay nonzero too: a zero extent would hand the model an empty input and the
853            // CUDA preprocess a zero-sized kernel grid.
854            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        // Square input into a square target: exact fit, no padding.
867        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        // Wide input is scaled down to fit and padded top/bottom, not left/right.
872        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        // Tall input is the mirror case: padded left/right, not top/bottom.
877        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        // Classification path: center-crop to the model size, normalize to [0, 1], and
910        // emit the FP16 tensor only when asked. A non-square source exercises the crop.
911        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        // 480Γ—640 wide image, target=1024Γ—1024, is_dynamic=false.
928        // Scale = min(1024/480, 1024/640) = 1024/640 = 1.6; nh=768, nw=1024, pad_topβ‰ˆ128.
929        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        // Wide image: horizontal padding is 0, vertical padding is non-zero.
935        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        // Mirrors Ultralytics LetterBox((1024, 1024), auto=True, stride=32) for a 333Γ—640 image:
942        // resized content is 533Γ—1024 and centered in a 544Γ—1024 rect with 5/6 vertical padding.
943        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        // quantize=16 also produces the FP16 tensor mirroring the FP32 one.
966        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        // Tall, wide, square, and tiny inputs all produce the requested tensor size.
973        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        // Two preprocess calls at the same width reuse the cached x-LUT (cache-hit path).
985        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        // A square input at the target size is returned unchanged.
994        assert_eq!(calculate_rect_size(640, 640, (640, 640), 32), (640, 640));
995
996        // Portrait, landscape, and non-aligned inputs all round to stride multiples
997        // and stay within the target.
998        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}