Skip to content

Commit fbb5d2c

Browse files
authored
fix: match Python Ultralytics detection output for YOLO ONNX inference (#97)
Signed-off-by: Onuralp SEZER <onuralp@ultralytics.com>
1 parent 6f382fd commit fbb5d2c

3 files changed

Lines changed: 212 additions & 133 deletions

File tree

src/cli/predict.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ pub fn run_prediction(args: &PredictArgs) {
5656
.map(|d| d.parse().expect("Invalid device"));
5757
#[cfg(feature = "visualize")]
5858
let show = args.show;
59-
// Warn if using default model (like Python does)
59+
// Warn if using default model
6060
if model_is_default && verbose {
6161
warn!(
6262
"'model' argument is missing. Using default '--model={}'.",

src/preprocessing.rs

Lines changed: 53 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,18 @@ use ndarray::{Array3, Array4};
3737
/// Default letterbox padding color (gray).
3838
pub 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`.
4142
const SCALE_BITS: i32 = 11;
4243
const 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).
4553
const 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.
5767
type XLutEntry = (usize, usize, i32, i32);
5868
type 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];`
207223
fn 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

Comments
 (0)