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

前面我们跑通了流程,但用的是假数据。现在我们要玩真的了。我们将引入一张真实的 JPG/PNG 图片,并亲手写代码把它变成模型能“吃”下的格式。

本文在上文封装代码的基础上,我们自己用C++手写代码,真实加载一张图片,运行Yolo模型。这将迫使你理解像素在内存中是如何搬运的。

0. 系列文章

1. 核心理论

在写代码前,必须搞懂我们要对图片做什么。YOLO 模型对输入图片有非常严格的要求,我们必须把任意尺寸的图片(比如手机拍的 4032x3024)变成标准格式。

1.1 Letterbox (保持比例缩放)

模型训练时通常输入是 640x640 的正方形。

  • 暴力缩放: 如果把一张长方形图片直接拉伸成正方形,物体会变形(人变瘦或变胖),导致模型识别率下降。

  • Letterbox: 保持原图长宽比缩放,短边不足的地方用灰色(通常是 RGB=114)填充。就像看电影时的上下黑边。

1.2 HWC 转 NCHW + 归一化

我们在前面的文章中提到的,如下图:

在这里插入图片描述

一般我们读到的图片 Raw Data: unsigned char (0~255), 排列为 RGB RGB … (HWC)

我们需要将其转换为:

Model Input: float (0.0~1.0), 排列为 RRR… GGG… BBB… (NCHW)。

才能给模型输入。

归一化公式:

在这里插入图片描述

2. 资源准备

2.1 下载 STB 库 (图像读写神器)

STB 是 C++ 领域最著名的单头文件图像库,不需要编译,把 .h 扔进去就能用。

新建一个 third_party/stb/ 目录。

可以直接在 third_party/stb/ 目录下运行下面的命令下载:

wget https://raw.githubusercontent.com/nothings/stb/master/stb_image.h

或者点击下面的链接,将文字复制下来保存成 stb_image.h 文件即可。

https://raw.githubusercontent.com/nothings/stb/master/stb_image.h

2.2 修改 CMakeLists.txt

cmake_minimum_required(VERSION 3.10)
project(YoloOnnxRunner)

# 设置 C++ 标准为 C++17 (现代 C++ 写法)
set(CMAKE_CXX_STANDARD 17)

# 1. 定义 ONNX Runtime 的路径
# ${CMAKE_SOURCE_DIR} 代表当前项目根目录
set(ORT_HOME ${CMAKE_SOURCE_DIR}/third_party/onnxruntime)
set(INCLUDE_DIR ${CMAKE_SOURCE_DIR}/include)
set(SRC_DIR ${CMAKE_SOURCE_DIR}/src)
set(STB_HOME ${CMAKE_SOURCE_DIR}/third_party/stb) # 新增

# 2. 告诉编译器去哪里找头文件 (.h)
include_directories(
    ${ORT_HOME}/include
    ${INCLUDE_DIR}
    ${STB_HOME}
)

# 3. 告诉链接器去哪里找库文件 (.dylib)
link_directories(${ORT_HOME}/lib)

# 4. 收集 src 目录下所有的 .cpp 文件
file(GLOB SOURCES "${SRC_DIR}/*.cpp")

# 5. 生成可执行文件 main
add_executable(main ${SOURCES})

# 6. 链接动态库
# 这里的名字对应 libonnxruntime.1.20.0.dylib (去掉前缀 lib 和后缀 .dylib)
target_link_libraries(main onnxruntime)


3. 代码实现(手写核心逻辑)

3.1 先定接口

对外接口就一个,输入一个图片路径,执行推理

// 核心功能:推理一张图片
// image_path: 图片路径
// 返回: 输出张量的数据(我们先输出直接结果,未处理的,本文的重点是加载图片)
std::vector<float> detect(const std::string& image_path);

3.2 detect接口实现

在这个接口中,要完成图片的加载与推理。这个过程主要有以下步骤:

(1)使用 STB 读取图片

(2)预处理图片,也就是缩放、数据格式转换 与 归一化。这是本文最重要的部分。

(3)构造 ORT 输入,执行推理

(4)获取结果

3.2.1 使用 STB 读取图片

int w, h, c;
// 强制读取为 3 通道 (RGB),忽略 Alpha 通道
unsigned char* img_data = stbi_load(image_path.c_str(), &w, &h, &c, 3);

if (!img_data) {
    std::cerr << "Failed to load image: " << image_path << std::endl;
    return {};
}
std::cout << "Image loaded: " << w << "x" << h << ", Channels: " << c << std::endl;

3.2.2 预处理

3.2.2.1 定义与实现

接口定义如下:

std::vector<float> preprocess(unsigned char* img_data, int w, int h, int channels)

实现如下:

std::vector<float> YoloDetector::preprocess(unsigned char* img_data, int w, int h, int channels) {
    // 1. 准备输入容器 [1, 3, 640, 640]
    // 这里的顺序是 NCHW,所以大小是 3 * 640 * 640
    std::vector<float> input_tensor(1 * 3 * input_w * input_h);

    // 2. 计算缩放比例 (Letterbox Logic)
    // 我们要让图片能塞进 640x640,同时保持长宽比
    float scale = std::min((float)input_w / w, (float)input_h / h);
    
    // 缩放后的新宽和新高
    int new_w = (int)(w * scale);
    int new_h = (int)(h * scale);

    // 计算留白偏移量 (为了居中)
    int dx = (input_w - new_w) / 2;
    int dy = (input_h - new_h) / 2;

    // 3. 双重循环遍历目标图像的每一个像素 (640x640)
    // 这种写法虽然不是性能最极致的,但是最容易理解原理
    for (int i = 0; i < input_h; ++i) {      // 行 (y)
        for (int j = 0; j < input_w; ++j) {  // 列 (x)
            
            // 计算当前像素在 input_tensor 中的索引 (NCHW)
            // R 通道起始位置: 0
            // G 通道起始位置: 640 * 640
            // B 通道起始位置: 2 * 640 * 640
            int idx_r = 0 * input_h * input_w + i * input_w + j;
            int idx_g = 1 * input_h * input_w + i * input_w + j;
            int idx_b = 2 * input_h * input_w + i * input_w + j;

            // 判断当前点 (j, i) 是否在图片区域内
            if (j >= dx && j < dx + new_w && i >= dy && i < dy + new_h) {
                //如果在图片区,需要找到它对应原图的哪个像素 (Nearest Neighbor 最近邻插值)
                // 逆向映射: 目标坐标 -> 原图坐标
                int src_x = (int)((j - dx) / scale);
                int src_y = (int)((i - dy) / scale);

                // 边界保护 (防止越界)
                src_x = std::max(0, std::min(src_x, w - 1));
                src_y = std::max(0, std::min(src_y, h - 1));

                // 找到原图该像素在内存中的位置 (HWC 格式)
                // 原图 stride = w * channels
                int src_idx = (src_y * w + src_x) * channels;

                // 归一化 (0-255 -> 0.0-1.0) 并赋值
                input_tensor[idx_r] = img_data[src_idx + 0] / 255.0f;
                input_tensor[idx_g] = img_data[src_idx + 1] / 255.0f;
                input_tensor[idx_b] = img_data[src_idx + 2] / 255.0f;
            } else {
                // 如果在留白区 (Padding),填充灰色 (114/255.0 = 0.447)
                float gray_val = 114.0f / 255.0f;
                input_tensor[idx_r] = gray_val;
                input_tensor[idx_g] = gray_val;
                input_tensor[idx_b] = gray_val;
            }
        }
    }
    return input_tensor;
}
3.2.2.2 拆解

(1)容器准备

我们是要将该函数的输出当作模型的输入,之前我们的假数据:

size_t input_tensor_size = 1 * 3 * 640 * 640;
std::vector<float> input_tensor_values(input_tensor_size);
// 填充 0.5
for (size_t i = 0; i < input_tensor_size; i++) input_tensor_values[i] = 0.5f;

所以我们这里准备的容器也是

// 1. 准备输入容器 [1, 3, 640, 640]
// 这里的顺序是 NCHW,所以大小是 3 * 640 * 640
std::vector<float> input_tensor(1 * 3 * input_w * input_h);

(2)计算缩放比例

// 我们要让图片能塞进 640x640,同时保持长宽比
float scale = std::min((float)input_w / w, (float)input_h / h);

// 缩放后的新宽和新高
int new_w = (int)(w * scale);
int new_h = (int)(h * scale);

// 计算留白偏移量 (为了居中)
int dx = (input_w - new_w) / 2;
int dy = (input_h - new_h) / 2;

缩放完成后类似下面这样,将原图等比例缩放后,填充进需求的尺寸中,填不满的地方填充灰色。
在这里插入图片描述

(3)RGB图转为NCHW格式

这里有几个重要的点:

  • 按行按列遍历像素等待填充
for (int i = 0; i < input_h; ++i) {      // 行 (y)
    for (int j = 0; j < input_w; ++j) {  // 列 (x)
  • 一次填充 RGB 三个数据,这三个数据的索引
// 计算当前像素在 input_tensor 中的索引 (NCHW)
// R 通道起始位置: 0
// G 通道起始位置: 640 * 640
// B 通道起始位置: 2 * 640 * 640
int idx_r = 0 * input_h * input_w + i * input_w + j;
int idx_g = 1 * input_h * input_w + i * input_w + j;
int idx_b = 2 * input_h * input_w + i * input_w + j;

input_h * input_w 是 640 * 640 = 409600。

  • R 通道的第 0 个像素在 input_tensor[0]。

  • G 通道的第 0 个像素在 input_tensor[409600]。

这就是所谓的 Planar (平面) 格式。

我们在一个循环里同时填了 R、G、B 三个平面的数据。

  • 判断当前点是否在图像范围内,如果不在,填充灰色。
// 判断当前点 (j, i) 是否在图片区域内
if (j >= dx && j < dx + new_w && i >= dy && i < dy + new_h) {

如果你的图片是 1920x1080 (宽屏):

缩放到 640 宽时,高度只有 360。 剩下的 640 - 360 = 280 高度就是空的。 else 分支里的 114/255.0 就是把这些空的地方填成灰色。这能避免模型把黑色背景误判为物体特征

  • 找到当前像素数据对应的原图中像素的位置
//如果在图片区,需要找到它对应原图的哪个像素 (Nearest Neighbor 最近邻插值)
// 逆向映射: 目标坐标 -> 原图坐标
int src_x = (int)((j - dx) / scale);
int src_y = (int)((i - dy) / scale);

// 边界保护 (防止越界)
src_x = std::max(0, std::min(src_x, w - 1));
src_y = std::max(0, std::min(src_y, h - 1));

// 找到原图该像素在内存中的位置 (HWC 格式)
// 原图 stride = w * channels
int src_idx = (src_y * w + src_x) * channels;

在这里插入图片描述

这部分我们用了 Nearest Neighbor 最近邻插值。

src_x = (int)((j - dx) / scale) 这行代码里,我们直接把浮点数坐标强制转为了整数 int。

  • 原理: 找离目标点最近的那个像素,直接取它的颜色。

  • 优点: 代码极简,速度最快。

  • 缺点: 图像会有锯齿。

  • 进阶: 真正的生产环境通常使用 双线性插值 (Bilinear Interpolation),它会取周围 4 个点的加权平均值。

  • 归一化
// 归一化 (0-255 -> 0.0-1.0) 并赋值
input_tensor[idx_r] = img_data[src_idx + 0] / 255.0f;
input_tensor[idx_g] = img_data[src_idx + 1] / 255.0f;
input_tensor[idx_b] = img_data[src_idx + 2] / 255.0f;

3.2.3 构造 ORT 输入

这部分与前文一致,就不展开讲了。

    Ort::AllocatorWithDefaultOptions allocator;
    // 获取输入名
    auto input_name_ptr = session.GetInputNameAllocated(0, allocator);
    std::string input_name = input_name_ptr.get();
    // 获取输出名
    auto output_name_ptr = session.GetOutputNameAllocated(0, allocator);
    std::string output_name = output_name_ptr.get();

    std::vector<int64_t> input_shape = {1, 3, input_w, input_h};
    auto memory_info = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
    std::vector<const char*> input_node_names = { input_name.c_str() };
    std::vector<const char*> output_node_names = { output_name.c_str() };

    std::vector<int64_t> input_dims = {1, 3, input_w, input_h};
    size_t input_tensor_size = 1 * 3 * input_w * input_h;
    
    std::vector<Ort::Value> input_tensors;
    input_tensors.push_back(Ort::Value::CreateTensor<float>(
        memory_info, 
        input_tensor_values.data(), 
        input_tensor_size, 
        input_dims.data(), 
        input_dims.size()
    ));

3.2.4 执行推理

这部分与前文一致,就不展开讲了。

    std::cout << "⚡ Running inference with image data..." << std::endl;
    auto output_tensors = session.Run(
        Ort::RunOptions{nullptr}, 
        input_node_names.data(), 
        input_tensors.data(), 
        1, 
        output_node_names.data(), 
        1
    );

3.2.5 获取结果

auto output_info = output_tensors[0].GetTensorTypeAndShapeInfo();
std::vector<int64_t> output_dims = output_info.GetShape();

std::cout << "✅ Inference finished!" << std::endl;
std::cout << "Output Shape: [";
for (size_t i = 0; i < output_dims.size(); i++) {
    std::cout << output_dims[i] << (i < output_dims.size() - 1 ? ", " : "");
}
std::cout << "]" << std::endl;

float* floatarr = output_tensors[0].GetTensorMutableData<float>();
size_t output_size = output_tensors[0].GetTensorTypeAndShapeInfo().GetElementCount();

// 拷贝结果返回
return std::vector<float>(floatarr, floatarr + output_size);

3.3 main 函数实现

#include <iostream>
#include "YoloDetector.hpp"

int main() {
    // 1. 创建检测器
    YoloDetector detector;

    // 2. 加载模型 (注意路径,相对于 build 目录)
    detector.loadModel("../model/yolov8n.onnx");

    // 3. 准备一张测试图片
    // 请在网上随便找一张 jpg 图片,命名为 test.jpg 放在 Week2_Yolo 根目录
    std::string img_path = "../model/cat.png";

    // 4. 推理
    auto result = detector.detect(img_path);

    // 5. 简单验证
    if (!result.empty()) {
        std::cout << "✅ Inference Success!" << std::endl;
        std::cout << "Output Tensor Size: " << result.size() << std::endl;
        // 这里的 84 * 8400 = 705600
        // 打印前几个数值看看是不是乱码
        std::cout << "First 5 output values: ";
        for(int i=0; i<5; i++) std::cout << result[i] << " ";
        std::cout << std::endl;
    }

    return 0;
}

运行成功!

在这里插入图片描述
你现在拿到了 output0 的数据,它是一个 1x84x8400 的大数组。

  • 84 代表什么?: cx, cy, w, h (4个坐标) + 80 个类别的概率 = 84。

  • 8400 代表什么?: YOLOv8 生成了 8400 个潜在的检测框。

本文到此,我们亲手从零到一写了一个图片的预处理流程,加载图片、等比例缩放、格式转换。我们其中用到了最近邻插值来获取缩放后的RGB数据。

但实际使用中,一般使用双线性插值。下文我们优化一下这部分,掌握双线性插值。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

同学小张

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

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

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

打赏作者

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

抵扣说明:

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

余额充值