@@ -37,10 +37,18 @@ use ndarray::{Array3, Array4};
3737/// Default letterbox padding color (gray).
3838pub const LETTERBOX_COLOR : [ u8 ; 3 ] = [ 114 , 114 , 114 ] ;
3939
40- /// Fixed-point scale factor for integer bilinear interpolation (2^11 = 2048).
40+ /// Fixed-point scale factor for bilinear interpolation (2^11 = 2048).
41+ /// Matches `OpenCV`'s `INTER_RESIZE_COEF_BITS = 11` for `INTER_LINEAR`.
4142const SCALE_BITS : i32 = 11 ;
4243const SCALE_INT : i32 = 1 << SCALE_BITS ;
4344
45+ /// Double scale bits for single-pass bilinear interpolation.
46+ const SCALE_BITS_2X : i32 = 2 * SCALE_BITS ;
47+
48+ /// Rounding bias for fixed-point bilinear, added before the final right-shift
49+ /// to achieve round-to-nearest behavior matching `OpenCV`'s `saturate_cast`.
50+ const ROUND_BIAS : i32 = 1 << ( SCALE_BITS_2X - 1 ) ;
51+
4452/// Normalized letterbox padding color (114/255 ≈ 0.447).
4553const LETTERBOX_NORM : f32 = 114.0 / 255.0 ;
4654
@@ -54,6 +62,8 @@ const LUT_CACHE_SIZE: usize = 8;
5462// Type Aliases
5563// ================================================================================================
5664
65+ /// X LUT entry: (`x0_byte_offset`, `x1_byte_offset`, 1-fx, fx) using 11-bit fixed-point
66+ /// weights matching `OpenCV`'s `INTER_LINEAR` coordinate mapping.
5767type XLutEntry = ( usize , usize , i32 , i32 ) ;
5868type XLutKey = ( u32 , u32 ) ;
5969
@@ -204,6 +214,12 @@ pub fn preprocess_image_with_precision(
204214// ================================================================================================
205215
206216/// Get or compute the X coordinate LUT for bilinear interpolation.
217+ ///
218+ /// Uses 11-bit fixed-point weights matching `OpenCV`'s `INTER_LINEAR` coordinate mapping:
219+ /// `src_x = (dst_x + 0.5) * (src_w / dst_w) - 0.5`
220+ ///
221+ /// Weight computation matches `OpenCV`'s `resize.cpp`:
222+ /// `cbuf[0] = saturate_cast<short>((1-fx) * 2048); cbuf[1] = 2048 - cbuf[0];`
207223fn get_or_compute_x_lut ( src_w : u32 , dst_w : u32 ) -> Vec < XLutEntry > {
208224 let key = ( src_w, dst_w) ;
209225
@@ -221,10 +237,14 @@ fn get_or_compute_x_lut(src_w: u32, dst_w: u32) -> Vec<XLutEntry> {
221237 . map ( |dx| {
222238 let sx = ( ( dx as f32 + 0.5 ) * scale_x - 0.5 ) . max ( 0.0 ) ;
223239 let x0 = sx. floor ( ) as i32 ;
224- let fx = ( ( sx - x0 as f32 ) * SCALE_INT as f32 ) as i32 ;
240+ // Match OpenCV: cbuf[0] = saturate_cast<short>((1-fx)*SCALE),
241+ // cbuf[1] = SCALE - cbuf[0]
242+ let fx_f = sx - x0 as f32 ;
243+ let fx_inv = ( ( 1.0 - fx_f) * SCALE_INT as f32 + 0.5 ) as i32 ;
244+ let fx = SCALE_INT - fx_inv;
225245 let x0c = x0. clamp ( 0 , src_w_max) as usize * 3 ;
226246 let x1c = ( x0 + 1 ) . clamp ( 0 , src_w_max) as usize * 3 ;
227- ( x0c, x1c, SCALE_INT - fx , fx)
247+ ( x0c, x1c, fx_inv , fx)
228248 } )
229249 . collect ( ) ;
230250
@@ -250,7 +270,6 @@ fn fused_zerocopy_preprocess(
250270 use rayon:: prelude:: * ;
251271 use std:: mem:: MaybeUninit ;
252272 use std:: sync:: atomic:: { AtomicPtr , Ordering } ;
253- use wide:: f32x4;
254273
255274 let ( dst_h, dst_w) = target_size;
256275 let channel_size = dst_h * dst_w;
@@ -266,7 +285,6 @@ fn fused_zerocopy_preprocess(
266285 let x_lut = get_or_compute_x_lut ( src_w, new_width) ;
267286 let scale_y = src_h as f32 / new_height as f32 ;
268287 let src_h_max = ( src_h - 1 ) as i32 ;
269- let inv_255_vec = f32x4:: splat ( INV_255 ) ;
270288
271289 let pad_top_usize = pad_top as usize ;
272290 let pad_left_usize = pad_left as usize ;
@@ -293,12 +311,14 @@ fn fused_zerocopy_preprocess(
293311 return ;
294312 }
295313
296- // Image row calculations
314+ // Image row calculations - 11-bit fixed-point bilinear matching
315+ // OpenCV's INTER_LINEAR (INTER_RESIZE_COEF_BITS = 11).
297316 let img_dy = dy - pad_top_usize;
298317 let sy = ( ( img_dy as f32 + 0.5 ) * scale_y - 0.5 ) . max ( 0.0 ) ;
299318 let y0 = sy. floor ( ) as i32 ;
300- let fy = ( ( sy - y0 as f32 ) * SCALE_INT as f32 ) as i32 ;
301- let fy_inv = SCALE_INT - fy;
319+ let fy_f = sy - y0 as f32 ;
320+ let fy_inv = ( ( 1.0 - fy_f) * SCALE_INT as f32 + 0.5 ) as i32 ;
321+ let fy = SCALE_INT - fy_inv;
302322
303323 let y0c = y0. clamp ( 0 , src_h_max) as usize ;
304324 let y1c = ( y0 + 1 ) . clamp ( 0 , src_h_max) as usize ;
@@ -312,69 +332,20 @@ fn fused_zerocopy_preprocess(
312332 * b_row. add ( dx) = LETTERBOX_NORM ;
313333 }
314334
315- // Inner image - SIMD loop (4 pixels at a time)
335+ // Inner image pixels - fixed-point bilinear with rounding.
336+ // Uses untruncated weights (w = fx * fy, range [0, 2048^2]) and a
337+ // single shift with rounding bias, matching OpenCV's saturate_cast:
338+ // result = (sum + ROUND_BIAS) >> 22
339+ // Max intermediate: 255 * 2048^2 + 2^21 ≈ 1.07B < i32::MAX.
316340 let mut img_dx = 0usize ;
317341 let src_ptr = src_raw. as_ptr ( ) ;
318342
319- while img_dx + 4 <= new_width_usize {
320- let mut r_vals = [ 0.0f32 ; 4 ] ;
321- let mut g_vals = [ 0.0f32 ; 4 ] ;
322- let mut b_vals = [ 0.0f32 ; 4 ] ;
323-
324- for i in 0 ..4 {
325- let ( x0_off, x1_off, fx_inv, fx) = * x_lut. get_unchecked ( img_dx + i) ;
326- let w00 = ( fx_inv * fy_inv) >> SCALE_BITS ;
327- let w10 = ( fx * fy_inv) >> SCALE_BITS ;
328- let w01 = ( fx_inv * fy) >> SCALE_BITS ;
329- let w11 = ( fx * fy) >> SCALE_BITS ;
330-
331- let p00 = src_ptr. add ( row0_off + x0_off) ;
332- let p10 = src_ptr. add ( row0_off + x1_off) ;
333- let p01 = src_ptr. add ( row1_off + x0_off) ;
334- let p11 = src_ptr. add ( row1_off + x1_off) ;
335-
336- r_vals[ i] = ( ( * p00 as i32 * w00
337- + * p10 as i32 * w10
338- + * p01 as i32 * w01
339- + * p11 as i32 * w11)
340- >> SCALE_BITS ) as f32 ;
341- g_vals[ i] = ( ( * p00. add ( 1 ) as i32 * w00
342- + * p10. add ( 1 ) as i32 * w10
343- + * p01. add ( 1 ) as i32 * w01
344- + * p11. add ( 1 ) as i32 * w11)
345- >> SCALE_BITS ) as f32 ;
346- b_vals[ i] = ( ( * p00. add ( 2 ) as i32 * w00
347- + * p10. add ( 2 ) as i32 * w10
348- + * p01. add ( 2 ) as i32 * w01
349- + * p11. add ( 2 ) as i32 * w11)
350- >> SCALE_BITS ) as f32 ;
351- }
352-
353- // SIMD normalize
354- let r_simd = f32x4:: new ( r_vals) * inv_255_vec;
355- let g_simd = f32x4:: new ( g_vals) * inv_255_vec;
356- let b_simd = f32x4:: new ( b_vals) * inv_255_vec;
357-
358- let out_x = pad_left_usize + img_dx;
359- let r_arr: [ f32 ; 4 ] = r_simd. into ( ) ;
360- let g_arr: [ f32 ; 4 ] = g_simd. into ( ) ;
361- let b_arr: [ f32 ; 4 ] = b_simd. into ( ) ;
362-
363- // Direct raw pointer writes (no bounds checks)
364- std:: ptr:: copy_nonoverlapping ( r_arr. as_ptr ( ) , r_row. add ( out_x) , 4 ) ;
365- std:: ptr:: copy_nonoverlapping ( g_arr. as_ptr ( ) , g_row. add ( out_x) , 4 ) ;
366- std:: ptr:: copy_nonoverlapping ( b_arr. as_ptr ( ) , b_row. add ( out_x) , 4 ) ;
367-
368- img_dx += 4 ;
369- }
370-
371- // Scalar tail
372343 while img_dx < new_width_usize {
373344 let ( x0_off, x1_off, fx_inv, fx) = * x_lut. get_unchecked ( img_dx) ;
374- let w00 = ( fx_inv * fy_inv) >> SCALE_BITS ;
375- let w10 = ( fx * fy_inv) >> SCALE_BITS ;
376- let w01 = ( fx_inv * fy) >> SCALE_BITS ;
377- let w11 = ( fx * fy) >> SCALE_BITS ;
345+ let w00 = fx_inv * fy_inv;
346+ let w10 = fx * fy_inv;
347+ let w01 = fx_inv * fy;
348+ let w11 = fx * fy;
378349
379350 let p00 = src_ptr. add ( row0_off + x0_off) ;
380351 let p10 = src_ptr. add ( row0_off + x1_off) ;
@@ -385,20 +356,23 @@ fn fused_zerocopy_preprocess(
385356 * r_row. add ( out_x) = ( ( * p00 as i32 * w00
386357 + * p10 as i32 * w10
387358 + * p01 as i32 * w01
388- + * p11 as i32 * w11)
389- >> SCALE_BITS ) as f32
359+ + * p11 as i32 * w11
360+ + ROUND_BIAS )
361+ >> SCALE_BITS_2X ) as f32
390362 * INV_255 ;
391363 * g_row. add ( out_x) = ( ( * p00. add ( 1 ) as i32 * w00
392364 + * p10. add ( 1 ) as i32 * w10
393365 + * p01. add ( 1 ) as i32 * w01
394- + * p11. add ( 1 ) as i32 * w11)
395- >> SCALE_BITS ) as f32
366+ + * p11. add ( 1 ) as i32 * w11
367+ + ROUND_BIAS )
368+ >> SCALE_BITS_2X ) as f32
396369 * INV_255 ;
397370 * b_row. add ( out_x) = ( ( * p00. add ( 2 ) as i32 * w00
398371 + * p10. add ( 2 ) as i32 * w10
399372 + * p01. add ( 2 ) as i32 * w01
400- + * p11. add ( 2 ) as i32 * w11)
401- >> SCALE_BITS ) as f32
373+ + * p11. add ( 2 ) as i32 * w11
374+ + ROUND_BIAS )
375+ >> SCALE_BITS_2X ) as f32
402376 * INV_255 ;
403377
404378 img_dx += 1 ;
@@ -510,7 +484,6 @@ fn calculate_letterbox_params(
510484 #[ allow( clippy:: cast_possible_truncation, clippy:: cast_sign_loss) ]
511485 let new_h = ( orig_h * scale) . round ( ) as u32 ;
512486
513- // Calculate padding to center the image (matching Python Ultralytics default)
514487 #[ allow( clippy:: cast_possible_truncation) ]
515488 let pad_w = ( target_size. 1 as u32 ) . saturating_sub ( new_w) ;
516489 #[ allow( clippy:: cast_possible_truncation) ]
@@ -520,13 +493,13 @@ fn calculate_letterbox_params(
520493 let pad_left = pad_w / 2 ;
521494 let pad_top = pad_h / 2 ;
522495
523- // Scale factors for coordinate conversion back to original
524- # [ allow ( clippy :: cast_precision_loss ) ]
525- let scale_x = new_w as f32 / orig_w;
526- # [ allow ( clippy :: cast_precision_loss ) ]
527- let scale_y = new_h as f32 / orig_h ;
528-
529- ( new_w, new_h, pad_left, pad_top, ( scale_y , scale_x ) )
496+ // Use a uniform gain for coordinate back-projection.
497+ // This matches Ultralytics `scale_boxes()`, which applies a single
498+ // `gain = min(target_h / orig_h, target_w / orig_w)` to both axes.
499+ // Per-axis gains (`new_w / orig_w`, `new_h / orig_h`) can diverge slightly
500+ // after rounding `new_w`/` new_h`, leading to small box shifts and
501+ // different NMS results.
502+ ( new_w, new_h, pad_left, pad_top, ( scale , scale ) )
530503}
531504
532505/// Convert an RGB image to a normalized NCHW tensor (FP32).
0 commit comments