【端侧AI 与 C++】8. 使用更通用的推理引擎 ONNX Runtime (ORT) 跑通本地模型加载 - 手撕图片预处理 2

上文我们自己用C++手写代码,真实加载一张图片,运行了Yolo模型,一个图片的预处理流程:加载图片、等比例缩放、格式转换。我们其中用到了最近邻插值来获取缩放后的RGB数据。

但实际使用中,一般使用双线性插值。本文我们来实现双线性插值。

0. 系列文章

1. 理论

为什么最近邻不够好?
  • 最近邻 (Nearest Neighbor):

    • 逻辑:坐标算出是 (10.2, 5.7),四舍五入变成 (10, 6),直接取像素值。

    • 问题:丢失了小数部分的精度,线条会断裂或呈锯齿状。

  • 双线性 (Bilinear):

    • 逻辑:坐标算出是 (10.2, 5.7)。它落在 (10, 5), (11, 5), (10, 6), (11, 6) 这四个点围成的矩形里。

    • 计算:根据距离这四个点的远近,进行加权平均。距离越近,权重越大。

2. 实践:手写双线性插值 C++ 代码

回到 src/YoloDetector.cpp,找到 preprocess 函数。
我们需要修改那个双重 for 循环的内容。

2.1 辅助函数 (放在 preprocess 之前)

为了代码整洁,我们先写一个 helper,用于获取像素值并防止越界。

// 辅助:安全获取像素值 (HWC 格式)
// data: 原始图片数据
// w, h, c: 原图宽、高、通道
// x, y: 坐标
// channel: 当前要取的通道 (0=R, 1=G, 2=B)
inline float get_pixel(unsigned char* data, int w, int h, int c, int x, int y, int channel) {
    // 边界钳制 (Clamp)
    if (x < 0) x = 0;
    if (x >= w) x = w-1;
    if (y < 0) y = 0;
    if (y >= h) y = h-1;
    
    // 计算内存偏移量
    return data[(y * w + x) * c + channel];
}

2.2 修改 preprocess 循环逻辑

(1)映射回原图坐标 (浮点数)

// 1. 映射回原图坐标 (浮点数)
// (j - dx) 是相对于填充区域起点的坐标
// + 0.5f 是为了几何中心对齐 (Center Aligned),这是 CV 里的标准做法
float src_x = (j - dx + 0.5f) / scale - 0.5f;
float src_y = (i - dy + 0.5f) / scale - 0.5f;
  • 为什么要加 0.5f?

如果直接用 (j - dx) / scale 行不行?

行,但是会有 Pixel Shift (像素偏移)

原因:

像素不是一个点,而是一个小方块。像素的中心应该在 0.5 的位置。

如果不加: 缩放后的图像中心会往左上角偏一点点。

后果就是在目标检测中,如果你把图缩放了,框的位置也会偏。虽然人眼看不出来,但计算 IoU 时会掉点。

这是 OpenCV 和 TensorFlow 等框架的标准对齐方式。

更具体一点解释

第 0 个像素的范围是 [0.0, 1.0]。

它的几何中心在哪里?在 0.5。

第 1 个像素的中心在 1.5,第 2 个在 2.5…

假设我们要把一个 2x2 的图片放大成 4x4 (Scale = 2.0)。

我们希望这两个网格的中心点重合,而不是左上角重合。

如果不加 0.5 (左上角对齐):

目标图第 0 个像素 (index 0) 对应原图 0 / 2.0 = 0。

目标图第 3 个像素 (index 3) 对应原图 3 / 2.0 = 1.5。

结果:图像会整体向左上角发生微小的偏移(Pixel Shift)。在多次卷积或深层网络中,这个误差会累积,导致检测框歪掉。

如果加了 0.5 (中心对齐):

(dst_x + 0.5): 先找到目标像素的中心点坐标。

/ scale: 把这个中心点坐标映射回原图尺度。

  • 0.5: 映射回原图后,我们拿到的是原图的中心点坐标,但我们需要的是数组索引 (左上角坐标),所以要把中心点那个 0.5 减掉。

直观总结:

+0.5 是为了从“格子左上角”走到“格子中心”。

-0.5 是为了从“原图格子中心”走回“原图格子左上角”以便计算索引。

(2)找到左上角的整数坐标 (Floor)

// 2. 找到左上角的整数坐标 (Floor)
int x0 = (int)std::floor(src_x);
int y0 = (int)std::floor(src_y);
int x1 = x0 + 1;
int y1 = y0 + 1;

(3)计算权重 (即小数部分)

// 3. 计算权重 (即小数部分)
// u, v 是距离左上角的距离 (0.0 ~ 1.0)
float u = src_x - x0;
float v = src_y - y0;

// 权重系数
float w00 = (1 - u) * (1 - v); // 左上
float w01 = (1 - u) * v;       // 左下
float w10 = u * (1 - v);       // 右上
float w11 = u * v;             // 右下

权重计算公式是怎么出来的?

这是双线性插值的核心。

我们把 src_x = 3.7 拆解一下:

整数部分 x0 = 3

小数部分 u = 0.7 (这就是距离左边的距离)

同理 src_y = 5.2:

整数部分 y0 = 5

小数部分 v = 0.2 (这就是距离上边的距离)

面积法(最直观的理解)

想象一个正方形,边长是 1。点 P 把它切成了 4 个小矩形。

双线性插值的规则是:点 P 离哪个角越近,那个角的权重就越大。

神奇的数学规律是:某个角的权重 = 该角【对角】那个小矩形的面积

P 点坐标 (u, v) = (0.7, 0.2)。

左上角 (Top-Left) 的权重 w00:

P 离左上角比较远 (水平远,垂直近)。

它对应的面积是右下角的那块空白。

右下角的宽是 1 - u (即 0.3),高是 1 - v (即 0.8)。
w00 = (1 - u) * (1 - v) = 0.3 * 0.8 = 0.24

右上角 (Top-Right) 的权重 w10:

P 离右上角很近 (u=0.7 大,v=0.2 小)。

它对应的面积是左下角的那块。

左下角的宽是 u (0.7),高是 1 - v (0.8)。
w10 = u * (1 - v) = 0.7 * 0.8 = 0.56 (权重最大,符合直觉!)

左下角 (Bottom-Left) 的权重 w01:

对应的面积是右上角那块。

宽 1 - u (0.3),高 v (0.2)。
w01 = (1 - u) * v = 0.3 * 0.2 = 0.06

右下角 (Bottom-Right) 的权重 w11:

对应的面积是左上角那块。

宽 u (0.7),高 v (0.2)。
w11 = u * v = 0.7 * 0.2 = 0.14

在这里插入图片描述

(4)对三个通道分别进行插值

// 4. 对三个通道分别进行插值
// 归一化 (/255.0f) 也可以放在最后做,这里为了清晰先做

// --- R Channel ---
float r00 = get_pixel(img_data, w, h, channels, x0, y0, 0);
float r01 = get_pixel(img_data, w, h, channels, x0, y1, 0);
float r10 = get_pixel(img_data, w, h, channels, x1, y0, 0);
float r11 = get_pixel(img_data, w, h, channels, x1, y1, 0);
float val_r = w00*r00 + w01*r01 + w10*r10 + w11*r11;

// --- G Channel ---
float g00 = get_pixel(img_data, w, h, channels, x0, y0, 1);
float g01 = get_pixel(img_data, w, h, channels, x0, y1, 1);
float g10 = get_pixel(img_data, w, h, channels, x1, y0, 1);
float g11 = get_pixel(img_data, w, h, channels, x1, y1, 1);
float val_g = w00*g00 + w01*g01 + w10*g10 + w11*g11;

// --- B Channel ---
float b00 = get_pixel(img_data, w, h, channels, x0, y0, 2);
float b01 = get_pixel(img_data, w, h, channels, x0, y1, 2);
float b10 = get_pixel(img_data, w, h, channels, x1, y0, 2);
float b11 = get_pixel(img_data, w, h, channels, x1, y1, 2);
float val_b = w00*b00 + w01*b01 + w10*b10 + w11*b11;

(5)填入 Tensor 并归一化

// 5. 填入 Tensor 并归一化
input_tensor[idx_r] = val_r / 255.0f;
input_tensor[idx_g] = val_g / 255.0f;
input_tensor[idx_b] = val_b / 255.0f;

3. 总结

双线性插值的详细公式可以参考:https://blog.csdn.net/xbinworld/article/details/65660665

你现在可能会想:“双线性插值要算 4 次取值、4 次乘法、3 次加法,而且还要做 3 个通道。这比最近邻慢多了!”

没错,这是要优化的,后面我们一点一点来。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

同学小张

如果觉得有帮助,欢迎给我鼓励!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值