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(
125        orig_w: u32,
126        orig_h: u32,
127        target: (usize, usize),
128        stretch: bool,
129    ) -> (Self, (f32, f32)) {
130        // Scale-fill: the source covers the target exactly, so each axis keeps its own gain
131        // and there is no padding. A zero-extent axis has no gain to report and yields 1.0,
132        // keeping back-projection finite instead of dividing coordinates by infinity.
133        if stretch {
134            let gain = |extent: usize, orig: u32| {
135                if orig == 0 {
136                    1.0
137                } else {
138                    extent as f32 / orig as f32
139                }
140            };
141            let geom = Self {
142                new_w: target.1 as u32,
143                new_h: target.0 as u32,
144                pad_left: 0,
145                pad_top: 0,
146            };
147            return (geom, (gain(target.0, orig_h), gain(target.1, orig_w)));
148        }
149        let (target_h, target_w) = (target.0 as f32, target.1 as f32);
150        let (orig_hf, orig_wf) = (orig_h as f32, orig_w as f32);
151        let scale = (target_h / orig_hf).min(target_w / orig_wf);
152        // Rounding can collapse an extreme aspect ratio to zero: a 10000x1 source at imgsz 640
153        // scales its height to 0.064, which would letterbox the entire tensor and throw the
154        // image away. Keep at least one pixel per axis, leaving a genuinely empty source at 0.
155        let extent = |orig_extent: f32, orig: u32| {
156            if orig == 0 {
157                0
158            } else {
159                ((orig_extent * scale).round() as u32).max(1)
160            }
161        };
162        let new_w = extent(orig_wf, orig_w);
163        let new_h = extent(orig_hf, orig_h);
164        let pad_left = (target.1 as u32).saturating_sub(new_w) / 2;
165        let pad_top = (target.0 as u32).saturating_sub(new_h) / 2;
166        (
167            Self {
168                new_w,
169                new_h,
170                pad_left,
171                pad_top,
172            },
173            (scale, scale),
174        )
175    }
176}
177
178/// Build a `PreprocessResult` from a resolved letterbox geometry.
179///
180/// Runs the fused zero-copy resize/pad/normalize, optionally produces an FP16 tensor,
181/// and packages the transform metadata.
182#[allow(clippy::cast_precision_loss)]
183fn build_preprocess_result(
184    image: &DynamicImage,
185    target_size: (usize, usize),
186    geom: LetterboxGeometry,
187    scale: (f32, f32),
188    orig_shape: (u32, u32),
189    fp16: bool,
190) -> PreprocessResult {
191    let (orig_width, orig_height) = image.dimensions();
192
193    let tensor = match image {
194        DynamicImage::ImageRgb8(rgb) => {
195            fused_zerocopy_preprocess(rgb.as_raw(), orig_width, orig_height, target_size, &geom)
196        }
197        _ => {
198            let src_rgb = image.to_rgb8();
199            fused_zerocopy_preprocess(
200                src_rgb.as_raw(),
201                orig_width,
202                orig_height,
203                target_size,
204                &geom,
205            )
206        }
207    };
208
209    let tensor_f16 = if fp16 {
210        Some(tensor_f32_to_f16(&tensor))
211    } else {
212        None
213    };
214
215    PreprocessResult {
216        tensor,
217        tensor_f16,
218        orig_shape,
219        scale,
220        padding: (geom.pad_top as f32, geom.pad_left as f32),
221    }
222}
223
224/// Preprocess an image for YOLO inference.
225///
226/// Performs letterbox resizing, normalization to [0, 1], and conversion to
227/// NCHW tensor format.
228///
229/// # Arguments
230///
231/// * `image` - Input image.
232/// * `target_size` - Target size as (height, width).
233/// * `stride` - Model stride for padding alignment (typically 32).
234///
235/// # Returns
236///
237/// Preprocessed tensor and transform information for post-processing.
238#[must_use]
239pub fn preprocess_image(
240    image: &DynamicImage,
241    target_size: (usize, usize),
242    stride: u32,
243) -> PreprocessResult {
244    preprocess_image_with_precision(image, target_size, stride, None)
245}
246
247/// Preprocess an image for YOLO inference with optional FP16 output.
248///
249/// # Arguments
250///
251/// * `image` - Input image.
252/// * `target_size` - Target size as (height, width).
253/// * `stride` - Model stride for padding alignment (typically 32).
254/// * `quantize` - Requested precision. FP16 also generates an FP16 tensor.
255///
256/// # Returns
257///
258/// Preprocessed tensor and transform information for post-processing.
259#[must_use]
260pub fn preprocess_image_with_precision(
261    image: &DynamicImage,
262    target_size: (usize, usize),
263    stride: u32,
264    quantize: impl IntoQuantization,
265) -> PreprocessResult {
266    let quantize = quantize.into_quantization();
267    let (orig_width, orig_height) = image.dimensions();
268    let orig_shape = (orig_height, orig_width);
269
270    let (geom, scale) = calculate_letterbox_params(orig_width, orig_height, target_size, stride);
271    build_preprocess_result(
272        image,
273        target_size,
274        geom,
275        scale,
276        orig_shape,
277        quantize == Some(Quantization::Fp16),
278    )
279}
280
281/// Preprocess an image for RT-DETR inference.
282///
283/// Stretches the image onto `target_size` so it fills the tensor exactly, with no
284/// aspect-ratio preservation and no padding. RT-DETR expects this layout; feeding it a
285/// padded letterbox shifts every prediction.
286///
287/// # Arguments
288///
289/// * `image` - Input image.
290/// * `target_size` - Target size as (height, width).
291/// * `quantize` - Requested precision. FP16 also generates an FP16 tensor.
292///
293/// # Returns
294///
295/// Preprocessed tensor and transform information for post-processing.
296#[must_use]
297pub fn preprocess_image_stretch(
298    image: &DynamicImage,
299    target_size: (usize, usize),
300    quantize: impl IntoQuantization,
301) -> PreprocessResult {
302    let quantize = quantize.into_quantization();
303    let (orig_width, orig_height) = image.dimensions();
304    let (geom, scale) = LetterboxGeometry::compute(orig_width, orig_height, target_size, true);
305    build_preprocess_result(
306        image,
307        target_size,
308        geom,
309        scale,
310        (orig_height, orig_width),
311        quantize == Some(Quantization::Fp16),
312    )
313}
314
315/// Get or compute the X coordinate LUT for bilinear interpolation.
316///
317/// Uses 11-bit fixed-point weights matching `OpenCV`'s `INTER_LINEAR` coordinate mapping:
318/// `src_x = (dst_x + 0.5) * (src_w / dst_w) - 0.5`
319///
320/// Weight computation matches `OpenCV`'s `resize.cpp`:
321/// `cbuf[0] = saturate_cast<short>((1-fx) * 2048); cbuf[1] = 2048 - cbuf[0];`
322fn get_or_compute_x_lut(src_w: u32, dst_w: u32) -> Arc<Vec<XLutEntry>> {
323    let key = (src_w, dst_w);
324
325    X_LUT_CACHE.with(|cache| {
326        let mut cache = cache.borrow_mut();
327
328        if let Some(lut) = cache.get(&key) {
329            return Arc::clone(lut);
330        }
331
332        let scale_x = src_w as f32 / dst_w as f32;
333        let src_w_max = (src_w - 1) as i32;
334
335        let lut: Arc<Vec<XLutEntry>> = Arc::new(
336            (0..dst_w)
337                .map(|dx| {
338                    let sx = ((dx as f32 + 0.5) * scale_x - 0.5).max(0.0);
339                    let x0 = sx.floor() as i32;
340                    // Match OpenCV: cbuf[0] = saturate_cast<short>((1-fx)*SCALE),
341                    //               cbuf[1] = SCALE - cbuf[0]
342                    let fx_f = sx - x0 as f32;
343                    let fx_inv = ((1.0 - fx_f) * SCALE_INT as f32 + 0.5) as i32;
344                    let fx = SCALE_INT - fx_inv;
345                    let x0c = x0.clamp(0, src_w_max) as usize * 3;
346                    let x1c = (x0 + 1).clamp(0, src_w_max) as usize * 3;
347                    (x0c, x1c, fx_inv, fx)
348                })
349                .collect(),
350        );
351
352        cache.put(key, Arc::clone(&lut));
353        lut
354    })
355}
356
357/// Zero-copy fused preprocessing for maximum performance.
358///
359/// Combines bilinear resize, letterbox padding, and NCHW normalization
360/// in a single memory pass with parallel row processing.
361fn fused_zerocopy_preprocess(
362    src_raw: &[u8],
363    src_w: u32,
364    src_h: u32,
365    target_size: (usize, usize),
366    geom: &LetterboxGeometry,
367) -> Array4<f32> {
368    #[allow(clippy::wildcard_imports)] // native: rayon prelude; wasm: sequential shims
369    use crate::parallel::*;
370    use std::mem::MaybeUninit;
371    use std::sync::atomic::{AtomicPtr, Ordering};
372
373    let LetterboxGeometry {
374        new_w: new_width,
375        new_h: new_height,
376        pad_left,
377        pad_top,
378    } = *geom;
379
380    let (dst_h, dst_w) = target_size;
381
382    // A zero-extent source or image region has no pixels to sample. Every row would take the
383    // padding branch below anyway, but the LUT and scale setup would first underflow on
384    // `src_w - 1` / `src_h - 1` and divide by zero, so return the letterbox fill directly.
385    if src_w == 0 || src_h == 0 || new_width == 0 || new_height == 0 {
386        return Array4::from_elem((1, 3, dst_h, dst_w), LETTERBOX_NORM);
387    }
388
389    let channel_size = dst_h * dst_w;
390    let src_stride = (src_w * 3) as usize;
391
392    // ALLOCATE UNINITIALIZED: Saves ~0.2ms by not zeroing memory
393    let mut tensor: Array4<MaybeUninit<f32>> = Array4::uninit((1, 3, dst_h, dst_w));
394    let out_ptr = tensor.as_mut_ptr() as *mut f32;
395
396    // Use AtomicPtr for thread-safe pointer sharing (each thread writes to disjoint rows)
397    let atomic_ptr = AtomicPtr::new(out_ptr);
398
399    let x_lut = get_or_compute_x_lut(src_w, new_width);
400    let scale_y = src_h as f32 / new_height as f32;
401    let src_h_max = (src_h - 1) as i32;
402
403    let pad_top_usize = pad_top as usize;
404    let pad_left_usize = pad_left as usize;
405    let new_height_usize = new_height as usize;
406    let new_width_usize = new_width as usize;
407
408    // Parallel row processing with raw pointers (no bounds checks)
409    (0..dst_h).into_par_iter().for_each(|dy| {
410        let data_ptr = atomic_ptr.load(Ordering::Relaxed);
411        unsafe {
412            // Calculate row pointers for R, G, B channels
413
414            let r_row = data_ptr.add(dy * dst_w);
415            let g_row = data_ptr.add(channel_size + dy * dst_w);
416            let b_row = data_ptr.add(2 * channel_size + dy * dst_w);
417
418            // Vertical padding (top/bottom rows)
419            if dy < pad_top_usize || dy >= pad_top_usize + new_height_usize {
420                for dx in 0..dst_w {
421                    *r_row.add(dx) = LETTERBOX_NORM;
422                    *g_row.add(dx) = LETTERBOX_NORM;
423                    *b_row.add(dx) = LETTERBOX_NORM;
424                }
425                return;
426            }
427
428            // Image row calculations - 11-bit fixed-point bilinear matching
429            // OpenCV's INTER_LINEAR (INTER_RESIZE_COEF_BITS = 11).
430            let img_dy = dy - pad_top_usize;
431            let sy = ((img_dy as f32 + 0.5) * scale_y - 0.5).max(0.0);
432            let y0 = sy.floor() as i32;
433            let fy_f = sy - y0 as f32;
434            let fy_inv = ((1.0 - fy_f) * SCALE_INT as f32 + 0.5) as i32;
435            let fy = SCALE_INT - fy_inv;
436
437            let y0c = y0.clamp(0, src_h_max) as usize;
438            let y1c = (y0 + 1).clamp(0, src_h_max) as usize;
439            let row0_off = y0c * src_stride;
440            let row1_off = y1c * src_stride;
441
442            // Left padding
443            for dx in 0..pad_left_usize {
444                *r_row.add(dx) = LETTERBOX_NORM;
445                *g_row.add(dx) = LETTERBOX_NORM;
446                *b_row.add(dx) = LETTERBOX_NORM;
447            }
448
449            // Inner image pixels - fixed-point bilinear with rounding.
450            // Uses untruncated weights (w = fx * fy, range [0, 2048^2]) and a
451            // single shift with rounding bias, matching OpenCV's saturate_cast:
452            //   result = (sum + ROUND_BIAS) >> 22
453            // Max intermediate: 255 * 2048^2 + 2^21 β‰ˆ 1.07B < i32::MAX.
454            let mut img_dx = 0usize;
455            let src_ptr = src_raw.as_ptr();
456
457            while img_dx < new_width_usize {
458                let (x0_off, x1_off, fx_inv, fx) = *x_lut.get_unchecked(img_dx);
459                let w00 = fx_inv * fy_inv;
460                let w10 = fx * fy_inv;
461                let w01 = fx_inv * fy;
462                let w11 = fx * fy;
463
464                let p00 = src_ptr.add(row0_off + x0_off);
465                let p10 = src_ptr.add(row0_off + x1_off);
466                let p01 = src_ptr.add(row1_off + x0_off);
467                let p11 = src_ptr.add(row1_off + x1_off);
468
469                let out_x = pad_left_usize + img_dx;
470                *r_row.add(out_x) = ((*p00 as i32 * w00
471                    + *p10 as i32 * w10
472                    + *p01 as i32 * w01
473                    + *p11 as i32 * w11
474                    + ROUND_BIAS)
475                    >> SCALE_BITS_2X) as f32
476                    * INV_255;
477                *g_row.add(out_x) = ((*p00.add(1) as i32 * w00
478                    + *p10.add(1) as i32 * w10
479                    + *p01.add(1) as i32 * w01
480                    + *p11.add(1) as i32 * w11
481                    + ROUND_BIAS)
482                    >> SCALE_BITS_2X) as f32
483                    * INV_255;
484                *b_row.add(out_x) = ((*p00.add(2) as i32 * w00
485                    + *p10.add(2) as i32 * w10
486                    + *p01.add(2) as i32 * w01
487                    + *p11.add(2) as i32 * w11
488                    + ROUND_BIAS)
489                    >> SCALE_BITS_2X) as f32
490                    * INV_255;
491
492                img_dx += 1;
493            }
494
495            // Right padding
496            for dx in (pad_left_usize + new_width_usize)..dst_w {
497                *r_row.add(dx) = LETTERBOX_NORM;
498                *g_row.add(dx) = LETTERBOX_NORM;
499                *b_row.add(dx) = LETTERBOX_NORM;
500            }
501        }
502    });
503
504    // SAFETY: All elements have been initialized
505    unsafe { tensor.assume_init() }
506}
507
508/// Convert f32 tensor to f16 tensor.
509fn tensor_f32_to_f16(tensor: &Array4<f32>) -> Array4<half::f16> {
510    use half::slice::HalfFloatSliceExt;
511    // `mapv` converts one element at a time; `half`'s slice conversion is vectorized where
512    // the target supports it. Falls back to `mapv` if the tensor is not contiguous.
513    let Some(src) = tensor.as_slice() else {
514        return tensor.mapv(half::f16::from_f32);
515    };
516    let mut out = vec![half::f16::ZERO; src.len()];
517    out.convert_from_f32_slice(src);
518    Array4::from_shape_vec(tensor.raw_dim(), out).expect("shape matches the source tensor")
519}
520
521/// Calculate target size for rectangular inference mode.
522///
523/// Adjusts `target_size` such that the image's aspect ratio is preserved,
524/// and both dimensions are multiples of `stride`.
525///
526/// # Arguments
527///
528/// * `orig_width` - Original image width.
529/// * `orig_height` - Original image height.
530/// * `target_size` - Base target size (e.g. 640x640).
531/// * `stride` - Model stride for alignment.
532///
533/// # Returns
534///
535/// Adjusted target size as (height, width).
536#[must_use]
537pub fn calculate_rect_size(
538    orig_width: u32,
539    orig_height: u32,
540    target_size: (usize, usize),
541    stride: u32,
542) -> (usize, usize) {
543    let (target_h, target_w) = target_size;
544
545    #[allow(clippy::cast_precision_loss)]
546    let orig_h = orig_height as f32;
547    #[allow(clippy::cast_precision_loss)]
548    let orig_w = orig_width as f32;
549    #[allow(clippy::cast_precision_loss)]
550    let target_h_f = target_h as f32;
551    #[allow(clippy::cast_precision_loss)]
552    let target_w_f = target_w as f32;
553
554    // Calculate scale to fit within target while maintaining aspect ratio
555    let scale = (target_h_f / orig_h).min(target_w_f / orig_w);
556
557    // New dimensions after scaling
558    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
559    let new_h = (orig_h * scale).round() as usize;
560    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
561    let new_w = (orig_w * scale).round() as usize;
562
563    // Round up to nearest multiple of stride. A degenerate source rounds an extent to zero
564    // (a zero-width image, or an aspect ratio past what the target can represent), which would
565    // hand the model a zero-width input and the CUDA preprocess a zero-sized kernel grid, so
566    // keep at least one stride step on each axis.
567    let stride = stride as usize;
568    let rect_h = (((new_h + stride - 1) / stride) * stride).max(stride);
569    let rect_w = (((new_w + stride - 1) / stride) * stride).max(stride);
570
571    (rect_h, rect_w)
572}
573
574/// Calculate letterbox parameters for resizing.
575///
576/// Computes new dimensions and padding to fit the image within the target size while maintaining aspect ratio.
577///
578/// # Arguments
579///
580/// * `orig_width` - Original image width.
581/// * `orig_height` - Original image height.
582/// * `target_size` - Target size as (height, width).
583/// * `stride` - Model stride for alignment (unused in calculation but kept for API compatibility).
584///
585/// # Returns
586///
587/// Tuple containing:
588/// 1. `new_width`: Scaled width.
589/// 2. `new_height`: Scaled height.
590/// 3. `pad_left`: Left padding.
591/// 4. `pad_top`: Top padding.
592/// 5. `(scale_y, scale_x)`: Scale factors.
593fn calculate_letterbox_params(
594    orig_width: u32,
595    orig_height: u32,
596    target_size: (usize, usize),
597    _stride: u32,
598) -> (LetterboxGeometry, (f32, f32)) {
599    // A single uniform `gain` on both axes for coordinate back-projection; per-axis gains
600    // from the rounded extents can diverge slightly, shifting boxes and changing NMS.
601    LetterboxGeometry::compute(orig_width, orig_height, target_size, false)
602}
603
604/// Convert an RGB image to a normalized NCHW tensor, planar (CHW) layout.
605///
606/// Allocates the `(1, 3, H, W)` tensor, splits it into per-channel slices, and fills each
607/// pixel with `convert` applied to the source byte. The caller supplies the element type's
608/// zero value and the per-byte conversion, so the f32 and f16 callers each keep their exact
609/// arithmetic (the f16 one hoists its scale constant into the closure).
610fn image_to_tensor<T: Clone>(
611    image: &RgbImage,
612    zero: T,
613    mut convert: impl FnMut(u8) -> T,
614) -> Array4<T> {
615    let (width, height) = image.dimensions();
616    let (w, h) = (width as usize, height as usize);
617    let pixels = image.as_raw();
618
619    let mut tensor = Array4::from_elem((1, 3, h, w), zero);
620
621    // Get mutable slices for each channel for faster access
622    let (r_slice, rest) = tensor.as_slice_mut().unwrap().split_at_mut(h * w);
623    let (g_slice, b_slice) = rest.split_at_mut(h * w);
624
625    for (i, chunk) in pixels.as_chunks::<3>().0.iter().enumerate() {
626        r_slice[i] = convert(chunk[0]);
627        g_slice[i] = convert(chunk[1]);
628        b_slice[i] = convert(chunk[2]);
629    }
630
631    tensor
632}
633
634/// Convert a `DynamicImage` to an HWC ndarray.
635///
636/// # Panics
637///
638/// Panics if the array cannot be created from the image pixels (e.g. dimension mismatch).
639#[must_use]
640pub fn image_to_array(image: &DynamicImage) -> Array3<u8> {
641    let rgb = image.to_rgb8();
642    let (width, height) = rgb.dimensions();
643    let pixels = rgb.into_raw();
644
645    Array3::from_shape_vec((height as usize, width as usize, 3), pixels)
646        .expect("Failed to create array from image pixels")
647}
648
649/// Scale coordinates from model output space back to original image space.
650///
651/// # Arguments
652///
653/// * `coords` - Coordinates in model space (after letterbox).
654/// * `scale` - Scale factors (`scale_y`, `scale_x`) from preprocessing.
655/// * `padding` - Padding (`pad_top`, `pad_left`) from preprocessing.
656///
657/// # Returns
658///
659/// Coordinates in original image space.
660#[must_use]
661pub fn scale_coords(coords: &[f32; 4], scale: (f32, f32), padding: (f32, f32)) -> [f32; 4] {
662    let (scale_y, scale_x) = scale;
663    let (pad_top, pad_left) = padding;
664
665    [
666        (coords[0] - pad_left) / scale_x, // x1
667        (coords[1] - pad_top) / scale_y,  // y1
668        (coords[2] - pad_left) / scale_x, // x2
669        (coords[3] - pad_top) / scale_y,  // y2
670    ]
671}
672
673/// Clip coordinates to image bounds.
674///
675/// # Arguments
676///
677/// * `coords` - Box coordinates [x1, y1, x2, y2].
678/// * `shape` - Image shape (height, width).
679///
680/// # Returns
681///
682/// Clipped coordinates.
683#[must_use]
684pub const fn clip_coords(coords: &[f32; 4], shape: (u32, u32)) -> [f32; 4] {
685    #[allow(clippy::cast_precision_loss)]
686    let (h, w) = (shape.0 as f32, shape.1 as f32);
687    [
688        coords[0].clamp(0.0, w),
689        coords[1].clamp(0.0, h),
690        coords[2].clamp(0.0, w),
691        coords[3].clamp(0.0, h),
692    ]
693}
694
695/// Preprocess an image for YOLO classification (Center Crop).
696///
697/// Resizes the image so the shortest side matches `target_size`, then center crops.
698///
699/// A zero-width or zero-height source yields a `target_size` frame of the letterbox
700/// padding color, matching [`preprocess_image`]. `target_size` is returned as requested,
701/// so a zero target yields a zero-extent tensor there too.
702///
703/// # Arguments
704///
705/// * `image` - Input image.
706/// * `target_size` - Target size as (height, width).
707/// * `quantize` - Requested precision. FP16 also generates an FP16 tensor.
708///
709/// # Returns
710///
711/// Preprocessed tensor and transform information.
712#[must_use]
713pub fn preprocess_image_center_crop(
714    image: &DynamicImage,
715    target_size: (usize, usize),
716    quantize: impl IntoQuantization,
717) -> PreprocessResult {
718    let quantize = quantize.into_quantization();
719    let (orig_width, orig_height) = image.dimensions();
720    let orig_shape = (orig_height, orig_width);
721
722    // Perform center crop resize
723    let (cropped, scale) = center_crop_image(image, target_size);
724
725    // Convert to normalized NCHW tensor
726    let tensor = image_to_tensor(&cropped, 0.0, |v| f32::from(v) / 255.0);
727
728    // Optionally compute FP16 tensor, converting u8 straight to f16 with a hoisted 1/255.
729    let tensor_f16 = (quantize == Some(Quantization::Fp16)).then(|| {
730        let scale = f16::from_f32(1.0 / 255.0);
731        image_to_tensor(&cropped, f16::ZERO, move |v| {
732            f16::from_f32(f32::from(v)) * scale
733        })
734    });
735
736    // For classification, we don't need complex coordinate mapping back to original
737    // But we provide approximate scale/padding to satisfy strict types if needed.
738    // In classification, we rarely map bounding boxes back, so these are less critical.
739    let padding = (0.0, 0.0);
740
741    PreprocessResult {
742        tensor,
743        tensor_f16,
744        orig_shape,
745        scale,
746        padding,
747    }
748}
749
750/// Resize and center crop image.
751///
752/// Resizes the image such that the shortest side equals the target dimension,
753/// maintaining aspect ratio, then crops the center `target_size`.
754///
755/// # Arguments
756///
757/// * `image` - Source dynamic image.
758/// * `target_size` - Desired output dimensions (height, width).
759///
760/// # Returns
761///
762/// Tuple containing:
763/// 1. `cropped`: The processed `RgbImage`.
764/// 2. `scale`: Scale factors applied (same for x and y).
765#[allow(clippy::similar_names)]
766fn center_crop_image(image: &DynamicImage, target_size: (usize, usize)) -> (RgbImage, (f32, f32)) {
767    use fast_image_resize::{
768        PixelType, ResizeAlg, ResizeOptions, Resizer,
769        images::{Image, ImageRef},
770    };
771
772    let (src_w, src_h) = image.dimensions();
773    #[allow(clippy::cast_possible_truncation)]
774    let (target_h, target_w) = (target_size.0 as u32, target_size.1 as u32);
775
776    // Any failure below yields no usable pixels; return a blank frame rather than panicking.
777    let blank = || {
778        (
779            RgbImage::from_pixel(target_w, target_h, image::Rgb(LETTERBOX_COLOR)),
780            (1.0, 1.0),
781        )
782    };
783
784    // The cover scale below divides by each source extent, so `target / 0` is infinite: the
785    // resized extents come out garbage and the allocator is asked for terabytes.
786    if src_w == 0 || src_h == 0 {
787        return blank();
788    }
789
790    // Calculate scale to "cover" the target area
791    // scale = max(target_w / src_w, target_h / src_h)
792    #[allow(clippy::cast_precision_loss)]
793    let scale_x = target_w as f32 / src_w as f32;
794    #[allow(clippy::cast_precision_loss)]
795    let scale_y = target_h as f32 / src_h as f32;
796    let scale = scale_x.max(scale_y);
797
798    let (new_w, new_h) = if scale_x >= scale_y {
799        #[allow(
800            clippy::cast_possible_truncation,
801            clippy::cast_sign_loss,
802            clippy::cast_precision_loss
803        )]
804        (target_w, (src_h as f32 * scale_x) as u32)
805    } else {
806        #[allow(
807            clippy::cast_possible_truncation,
808            clippy::cast_sign_loss,
809            clippy::cast_precision_loss
810        )]
811        ((src_w as f32 * scale_y) as u32, target_h)
812    };
813
814    // Resize first. Borrow the source samples when the image is already RGB8 (the common
815    // case): `to_rgb8` would clone the full frame just to hand it straight to the resizer.
816    let owned_rgb;
817    let src_bytes: &[u8] = match image {
818        DynamicImage::ImageRgb8(rgb) => rgb.as_raw(),
819        other => {
820            owned_rgb = other.to_rgb8();
821            owned_rgb.as_raw()
822        }
823    };
824    let Ok(src_image) = ImageRef::new(src_w, src_h, src_bytes, PixelType::U8x3) else {
825        return blank();
826    };
827
828    // Valid dimensions check
829    let safe_new_w = new_w.max(1);
830    let safe_new_h = new_h.max(1);
831
832    let mut dst_image = Image::new(safe_new_w, safe_new_h, PixelType::U8x3);
833
834    let mut resizer = Resizer::new();
835    let options = ResizeOptions::new().resize_alg(ResizeAlg::Convolution(
836        fast_image_resize::FilterType::Bilinear,
837    ));
838    if resizer
839        .resize(&src_image, &mut dst_image, Some(&options))
840        .is_err()
841    {
842        return blank();
843    }
844
845    // Convert back to RgbImage to crop
846    let resized_buffer = dst_image.into_vec();
847    let Some(resized_rgb) = RgbImage::from_raw(safe_new_w, safe_new_h, resized_buffer) else {
848        return blank();
849    };
850
851    // Calculate crop offsets using Banker's Rounding (round half to even).
852    #[allow(clippy::cast_precision_loss)]
853    let crop_x_float = (new_w.saturating_sub(target_w)) as f32 / 2.0;
854    #[allow(clippy::cast_precision_loss)]
855    let crop_y_float = (new_h.saturating_sub(target_h)) as f32 / 2.0;
856
857    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
858    let crop_x = bankers_round(crop_x_float) as u32;
859    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
860    let crop_y = bankers_round(crop_y_float) as u32;
861
862    let cropped =
863        image::imageops::crop_imm(&resized_rgb, crop_x, crop_y, target_w, target_h).to_image();
864
865    (cropped, (scale, scale))
866}
867
868/// Round float to nearest integer, rounding half to even (Banker's Rounding).
869fn bankers_round(v: f32) -> f32 {
870    let n = v.floor();
871    let d = v - n;
872    if (d - 0.5).abs() < 1e-6 {
873        if n % 2.0 == 0.0 { n } else { n + 1.0 }
874    } else {
875        v.round()
876    }
877}
878
879#[allow(clippy::similar_names)]
880#[cfg(test)]
881mod tests {
882    use super::*;
883
884    /// An aspect ratio worse than the target size rounds one extent to zero, which used to
885    /// discard the image and hand the model a uniformly gray tensor.
886    #[test]
887    fn test_extreme_aspect_ratio_keeps_image_content() {
888        let (geom, _) = calculate_letterbox_params(10000, 1, (640, 640), 32);
889        assert!(geom.new_h >= 1, "height collapsed to {}", geom.new_h);
890        assert!(geom.new_w >= 1);
891
892        let img = DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
893            10000,
894            1,
895            image::Rgb([255, 0, 0]),
896        ));
897        let res = preprocess_image(&img, (640, 640), 32);
898        let tensor = res.tensor;
899        assert!(
900            tensor.iter().any(|&v| (v - LETTERBOX_NORM).abs() > 1e-6),
901            "every pixel is letterbox fill, so the image was thrown away"
902        );
903    }
904
905    /// A zero-dimension image underflowed `src_w - 1` in the LUT builder, which panics under
906    /// the overflow checks the dev profile enables.
907    #[test]
908    fn test_center_crop_degenerate_input_does_not_panic() {
909        for (w, h) in [(0, 0), (0, 64), (64, 0)] {
910            let img = DynamicImage::ImageRgb8(image::RgbImage::new(w, h));
911            let res = preprocess_image_center_crop(&img, (224, 224), false);
912            assert_eq!(res.tensor.shape(), &[1, 3, 224, 224]);
913        }
914    }
915
916    #[test]
917    fn test_zero_dimension_image_does_not_panic() {
918        for (w, h) in [(0, 0), (0, 64), (64, 0)] {
919            let img = DynamicImage::ImageRgb8(image::RgbImage::new(w, h));
920            let res = preprocess_image(&img, (640, 640), 32);
921            assert_eq!(res.tensor.shape(), &[1, 3, 640, 640]);
922            assert!(
923                res.tensor
924                    .iter()
925                    .all(|&v| (v - LETTERBOX_NORM).abs() < 1e-6),
926                "an empty source has no pixels, so the tensor is all letterbox fill"
927            );
928
929            // `rect` mode picks its own target before preprocessing, so that target has to
930            // stay nonzero too: a zero extent would hand the model an empty input and the
931            // CUDA preprocess a zero-sized kernel grid.
932            let rect = calculate_rect_size(w, h, (640, 640), 32);
933            assert!(
934                rect.0 >= 32 && rect.1 >= 32,
935                "rect target {rect:?} collapsed for a {w}x{h} source"
936            );
937            let res = preprocess_image(&img, rect, 32);
938            assert_eq!(res.tensor.shape(), &[1, 3, rect.0, rect.1]);
939        }
940    }
941
942    #[test]
943    fn test_letterbox_params() {
944        // Square input into a square target: exact fit, no padding.
945        let (geom, _scale) = calculate_letterbox_params(640, 640, (640, 640), 32);
946        assert_eq!((geom.new_w, geom.new_h), (640, 640));
947        assert_eq!((geom.pad_left, geom.pad_top), (0, 0));
948
949        // Wide input is scaled down to fit and padded top/bottom, not left/right.
950        let (geom, _) = calculate_letterbox_params(1280, 720, (640, 640), 32);
951        assert!(geom.new_w <= 640 && geom.new_h <= 640);
952        assert_eq!(geom.pad_left, 0);
953
954        // Tall input is the mirror case: padded left/right, not top/bottom.
955        let (geom, _) = calculate_letterbox_params(480, 640, (640, 640), 32);
956        assert!(geom.pad_left > 0);
957        assert_eq!(geom.pad_top, 0);
958    }
959
960    #[test]
961    fn test_scale_coords() {
962        let coords = [100.0, 100.0, 200.0, 200.0];
963        let scale = (1.0, 1.0);
964        let padding = (10.0, 10.0);
965
966        let scaled = scale_coords(&coords, scale, padding);
967
968        assert!((scaled[0] - 90.0).abs() < 1e-6);
969        assert!((scaled[1] - 90.0).abs() < 1e-6);
970        assert!((scaled[2] - 190.0).abs() < 1e-6);
971        assert!((scaled[3] - 190.0).abs() < 1e-6);
972    }
973
974    #[test]
975    fn test_clip_coords() {
976        let coords = [-10.0, -20.0, 700.0, 500.0];
977        let clipped = clip_coords(&coords, (480, 640));
978
979        assert!((clipped[0] - 0.0).abs() < 1e-6);
980        assert!((clipped[1] - 0.0).abs() < 1e-6);
981        assert!((clipped[2] - 640.0).abs() < 1e-6);
982        assert!((clipped[3] - 480.0).abs() < 1e-6);
983    }
984
985    #[test]
986    fn test_preprocess_image_center_crop() {
987        // Classification path: center-crop to the model size, normalize to [0, 1], and
988        // emit the FP16 tensor only when asked. A non-square source exercises the crop.
989        let img = image::DynamicImage::new_rgb8(400, 300);
990        for quantize in [None, Some(Quantization::Fp16)] {
991            let res = preprocess_image_center_crop(&img, (224, 224), quantize);
992            assert_eq!(res.tensor.dim(), (1, 3, 224, 224));
993            assert_eq!(res.orig_shape, (300, 400));
994            assert_eq!(res.padding, (0.0, 0.0));
995            assert!(res.tensor.iter().all(|v| (0.0..=1.0).contains(v)));
996            assert_eq!(res.tensor_f16.is_some(), quantize.is_some());
997            if let Some(t16) = &res.tensor_f16 {
998                assert_eq!(t16.dim(), res.tensor.dim());
999            }
1000        }
1001    }
1002
1003    #[test]
1004    fn test_preprocess_image_static_centered_letterbox() {
1005        // 480Γ—640 wide image, target=1024Γ—1024, is_dynamic=false.
1006        // Scale = min(1024/480, 1024/640) = 1024/640 = 1.6; nh=768, nw=1024, pad_topβ‰ˆ128.
1007        let img = image::DynamicImage::new_rgb8(640, 480);
1008        let res = preprocess_image_with_precision(&img, (1024, 1024), 32, None);
1009        let (_, _, h, w) = res.tensor.dim();
1010        assert_eq!(h, 1024);
1011        assert_eq!(w, 1024);
1012        // Wide image: horizontal padding is 0, vertical padding is non-zero.
1013        assert!(res.padding.1.abs() < 1e-6, "wide image: no left padding");
1014        assert!(res.padding.0 > 0.0, "wide image: top padding expected");
1015    }
1016
1017    #[test]
1018    fn test_preprocess_image_rect_uses_centered_letterbox() {
1019        // Mirrors Ultralytics LetterBox((1024, 1024), auto=True, stride=32) for a 333Γ—640 image:
1020        // resized content is 533Γ—1024 and centered in a 544Γ—1024 rect with 5/6 vertical padding.
1021        let img = image::DynamicImage::new_rgb8(640, 333);
1022        let rect_size = calculate_rect_size(640, 333, (1024, 1024), 32);
1023        assert_eq!(rect_size, (544, 1024));
1024        let res = preprocess_image_with_precision(&img, rect_size, 32, None);
1025        let (_, _, h, w) = res.tensor.dim();
1026        assert_eq!((h, w), rect_size);
1027        assert_eq!(res.padding, (5.0, 0.0));
1028    }
1029
1030    #[test]
1031    fn test_preprocess_image_stretch_fills_target() {
1032        // RT-DETR scale-fill: a 810x1080 source stretches onto 640x640 with no padding and
1033        // a separate gain per axis, so back-projection returns the original extents.
1034        let img = image::DynamicImage::new_rgb8(810, 1080);
1035        let res = preprocess_image_stretch(&img, (640, 640), None);
1036        let (_, c, h, w) = res.tensor.dim();
1037        assert_eq!((c, h, w), (3, 640, 640));
1038        assert_eq!(res.padding, (0.0, 0.0));
1039        assert_eq!(res.scale, (640.0 / 1080.0, 640.0 / 810.0));
1040        let full = scale_coords(&[0.0, 0.0, 640.0, 640.0], res.scale, res.padding);
1041        assert!((full[2] - 810.0).abs() < 1e-3 && (full[3] - 1080.0).abs() < 1e-3);
1042
1043        // A zero-extent source has no gain to report: 1.0 keeps the back-projection finite
1044        // instead of dividing coordinates by infinity.
1045        let (geom, scale) = LetterboxGeometry::compute(0, 0, (640, 640), true);
1046        assert_eq!(scale, (1.0, 1.0));
1047        assert_eq!((geom.new_w, geom.new_h), (640, 640));
1048    }
1049
1050    #[test]
1051    fn test_preprocess_image_public_wrapper() {
1052        let img = image::DynamicImage::new_rgb8(320, 240);
1053        let res = preprocess_image(&img, (640, 640), 32);
1054        let (_, c, h, w) = res.tensor.dim();
1055        assert_eq!((c, h, w), (3, 640, 640));
1056        assert!(res.tensor_f16.is_none());
1057    }
1058
1059    #[test]
1060    fn test_preprocess_image_fp16_path() {
1061        let img = image::DynamicImage::new_rgb8(320, 240);
1062        let res = preprocess_image_with_precision(&img, (640, 640), 32, Some(Quantization::Fp16));
1063        // quantize=16 also produces the FP16 tensor mirroring the FP32 one.
1064        let f16 = res.tensor_f16.expect("fp16 tensor present");
1065        assert_eq!(f16.dim(), res.tensor.dim());
1066    }
1067
1068    #[test]
1069    fn test_preprocess_various_aspect_ratios() {
1070        // Tall, wide, square, and tiny inputs all produce the requested tensor size.
1071        for (w, h) in [(100u32, 400u32), (400, 100), (1, 1), (640, 640)] {
1072            let img = image::DynamicImage::new_rgb8(w, h);
1073            let res = preprocess_image(&img, (320, 320), 32);
1074            let (_, c, th, tw) = res.tensor.dim();
1075            assert_eq!((c, th, tw), (3, 320, 320));
1076            assert_eq!(res.orig_shape, (h, w));
1077        }
1078    }
1079
1080    #[test]
1081    fn test_x_lut_cache_reuse() {
1082        // Two preprocess calls at the same width reuse the cached x-LUT (cache-hit path).
1083        let img = image::DynamicImage::new_rgb8(200, 150);
1084        let a = preprocess_image(&img, (320, 320), 32);
1085        let b = preprocess_image(&img, (320, 320), 32);
1086        assert_eq!(a.tensor.dim(), b.tensor.dim());
1087    }
1088
1089    #[test]
1090    fn test_calculate_rect_size() {
1091        // A square input at the target size is returned unchanged.
1092        assert_eq!(calculate_rect_size(640, 640, (640, 640), 32), (640, 640));
1093
1094        // Portrait, landscape, and non-aligned inputs all round to stride multiples
1095        // and stay within the target.
1096        for (w, h) in [(400u32, 1000u32), (1000, 400), (800, 600)] {
1097            let (rh, rw) = calculate_rect_size(w, h, (640, 640), 32);
1098            assert_eq!((rh % 32, rw % 32), (0, 0));
1099            assert!(rh <= 640 && rw <= 640);
1100        }
1101    }
1102}