ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

YOLOv11图像分类器C++部署实战:ONNX Runtime与预处理避坑指南

YOLOv11图像分类器C++部署实战:ONNX Runtime与预处理避坑指南 简介面向需要在 CPU/GPU 环境下快速落地 YOLOv11 图像分类任务、又希望避开 Python 依赖的 C 开发者这份部署代码包提供了完整的 ONNX Runtime 实现方案覆盖模型加载与格式验证、图像预处理、推理计算及分类结果后处理全流程并修复了 DEBUG_PRINT_NOEND 编译报错支持动态输入尺寸和 NaN/Inf 异常检测兼容 Windows 与 Linux 平台。压缩包共 623 个文件、约 842MB以 317 个 hpp 头文件及配置脚本为主另含 OpenCV/ONNX Runtime 依赖库、示例工程、CMake 构建文件与编译日志便于直接集成、二次开发或按需裁剪。工业质检、医学图像分类、嵌入式设备部署及边缘计算等场景均可参考。目前已有 252 人学习下载。代码内置调试日志系统与 GPU 自动回退机制读者可借此掌握 ONNX Runtime C 接口调用、动态尺寸预处理、跨平台编译及常见编译链接错误排查的完整工程实践。1. YOLOv11图像分类器为什么值得用ONNX Runtime跑C一个反直觉的结论如果你是在 CtrlF 里搜“YOLOv11图像分类器”大概率不是想跑目标检测而是手里已经有一个yolo11n-cls.pt或者自己训练好的分类权重想把它部署到没有 Python 环境的工控机、Jetson 或 ARM 盒子上用 C 完成每一次前向推理。先说一个反直觉的结论图像分类器虽然逻辑上比检测器简单但它比检测器更容易在 C 里翻车。原因不在模型结构而在预处理不一致和内存布局写错这两件小事上检测器还有置信度过滤兜底分类器只要 softmax 后 top-1 错一位整条产线结果就是错的。选择 ONNX Runtime C 而不是 TensorRT 或 OpenVINO是因为它不锁显卡、不锁 CPU 品牌x86、ARM、Jetson 一套代码都能跑缺点是静态编译和内存细节要自己抠。这篇文章从.pt导出.onnx讲起给你一套能直接编译运行的 C 代码骨架、预处理与 softmax 的关键写法以及我部署几台机器后踩出来的五个血泪坑。2. 把 YOLOv11 分类模型导出成 ONNX从 .pt 到 .onnx 的脚本与三个最易忽略的参数2.1 先分清分类模型和检测模型的网络结构差异YOLOv11 的yolo11n-cls.pt和后缀不带-cls的检测权重完全是两套网络。分类模型的主干提取特征后接的是一个ClassifierHead内部是全局平均池化加全连接或卷积映射输出形状固定为[1, num_classes]检测模型输出则是[1, 84, 8400]这种候选框张量。也就是说就算你用同一个export命令导出的 ONNX 输入输出也完全不同。这个差异会直接传导到 C 侧。很多人把yolo11n.pt误当成分类模型导出然后在 C 里按[1, num_classes]解析输出读取长度对不上轻则结果全乱重则数组越界崩溃。所以第一步不是写代码而是确认你手里的权重是什么任务分类权重的文件名通常带-cls比如yolo11n-cls.pt、yolo11s-cls.pt自己训练的模型则要回看训练配置里taskclassify还是taskdetect。2.2 用 Ultralytics 导出 ONNXopset、dynamic、half 该怎么选导出这一步在 Python 里完成命令很短但三个参数一旦选错后面 C 全白做。我一般用下面这个脚本from ultralytics import YOLO # 如果是从官网下的权重用 yolo11n-cls.pt # 如果是自己训练的用训练目录下的 best.pt model YOLO(yolo11n-cls.pt) model.export( formatonnx, imgsz224, # 分类任务固定一个输入尺寸官方默认 224 opset17, # 兼容 ONNX Runtime 1.14 以上版本 simplifyTrue, # 依赖 onnxsim融合 ConvBN 等冗余算子 dynamicFalse, # 图像分类固定 batch1固定尺寸不导出动态轴 halfFalse, # 先导出 FP32CPU 推理可靠FP16 后面单独说 )三个最容易被忽略的参数逐个说。第一个是opset。ONNX Runtime 每个版本支持的算子集版本有上限如果导出时opset21而 Jetson 上装的 ONNX Runtime 还是 1.10 左右加载模型时直接报“Unsupported model opset version”。保守选 17既能用上较新的算子优化老版本运行时也带得动。第二个是dynamic分类任务里我建议固定为False因为实际部署时输入都是统一尺寸的图片固定 shape 能让 ONNX Runtime 做更多静态内存优化。第三个是halfCPU 推理阶段不要开FP16 的 ONNX 在 CPU 端会退化成低精度计算甚至报算子不支持这一点到第 5 章避坑部分再展开。2.3 导出后用 Python ONNX Runtime 跑一遍留下对照结果写 C 之前一定要用 ONNX Runtime 在 Python 侧先跑通并保存一份输出向量。这份输出就是给 C 当“参考答案”的后面你能用余弦距离判断 C 端到底写没写对。验证脚本我习惯写成这样import cv2 import numpy as np import onnxruntime as ort onnx_path yolo11n-cls.onnx img cv2.imread(test.jpg) # BGR 读取 img cv2.resize(img, (224, 224), interpolationcv2.INTER_LINEAR) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 转成 RGB img img.astype(np.float32) / 255.0 # 只归一化不减均值 img np.transpose(img, (2, 0, 1))[None] # HWC - NCHW sess ort.InferenceSession(onnx_path) out sess.run(None, {sess.get_inputs()[0].name: img}) np.savetxt(python_out.txt, out[0].reshape(-1), fmt%.6f)这里有个关键细节YOLOv11 分类模型的预处理和检测模型不一样。检测模型做 letterbox 保持长宽比并填充灰边分类模型是直接把整张图resize到224x224不做 letterbox也不做减均值除方差只做 BGR 转 RGB 和除以 255。如果按检测模型的预处理习惯去写 Ctop-1 很容易对不上这个坑后面还会遇到。3. C 工程骨架与 ONNX Runtime 接入CMake、运行库和 session 初始化3.1 先搭目录结构模型、源码、CMake 分开摆放C 部署工程不建议把所有代码塞在一个文件里至少要区分出模型目录、图像目录和源码目录否则换模型、换测试图时容易误改。我常用的目录结构是这样的deploy_cls/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── infer.cpp │ └── infer.h ├── models/ │ └── yolo11n-cls.onnx └── images/ └── test.jpg对应的一份 CMakeLists.txt 可以这样写cmake_minimum_required(VERSION 3.16) project(yolo11_cls) find_package(OpenCV REQUIRED) # 根据你本机 ONNX Runtime 的实际路径修改 set(ORT_INCLUDE_DIR /opt/onnxruntime/include) set(ORT_LIB_DIR /opt/onnxruntime/lib) if(WIN32) set(ORT_LIB ${ORT_LIB_DIR}/onnxruntime.lib) else() set(ORT_LIB ${ORT_LIB_DIR}/libonnxruntime.so) endif() add_executable(cls_demo src/main.cpp src/infer.cpp) target_include_directories(cls_demo PRIVATE ${ORT_INCLUDE_DIR} ${OpenCV_INCLUDE_DIRS}) target_link_libraries(cls_demo PRIVATE ${ORT_LIB} ${OpenCV_LIBS})有两个容易被环境卡住的地方。第一Windows 下编译运行时如果报找不到onnxruntime.dll多半是缺少 Microsoft Visual C Redistributable 运行库ONNX Runtime 的预编译包依赖 VC 运行库机器上没装会在启动阶段直接失败而不是编译阶段报错。第二Linux 下链接libonnxruntime.so时如果系统 PATH 里找不到可以在 CMake 里用set(CMAKE_BUILD_RPATH ${ORT_LIB_DIR})把运行路径指过去避免每次都要设LD_LIBRARY_PATH。如果你用 VSCode 配置 C/C 环境调试把 CMake 的configure步骤跑通后F7能直接编译断点也建议下在session.Run调用前后。3.2 初始化推理会话从环境变量到线程数主程序入口第一步是创建推理环境、会话选项和 Session 对象。ONNX Runtime 的 C API 是基于Ort::前缀的封装类初始化代码各版本略有差异但整体稳定。我按较新的 API 写法给出一个可编译的 main.cpp#include opencv2/opencv.hpp #include onnxruntime_cxx_api.h #include iostream #include string int main() { std::string model_path models/yolo11n-cls.onnx; // 1. 创建环境。日志级别用 WARNING避免推理时刷大量无关日志 Ort::Env env(ORT_LOGGING_LEVEL_WARNING, yolo11_cls); // 2. 会话选项开图优化设置线程数 Ort::SessionOptions opts; opts.SetIntraOpNumThreads(4); opts.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); // 3. 加载模型构造函数内部会解析 ONNX 文件 Ort::Session session(env, model_path.c_str(), opts); // 4. 打印输入输出名方便排查模型是否加载正确 Ort::AllocatorWithDefaultOptions alloc; auto in_name session.GetInputNameAllocated(0, alloc); auto out_name session.GetOutputNameAllocated(0, alloc); std::cout input: in_name.get() std::endl; std::cout output: out_name.get() std::endl; return 0; }这段代码的逻辑说明SetIntraOpNumThreads控制算子内并行线程数不是越大越好。在 Jetson Nano 这种 4 核 ARM 设备上设 4 可能反而和系统调度打架设 2 到 3 更稳x86 工控机上设 4 到 6 一般没问题。ORT_ENABLE_ALL是最激进的一档图优化它会把模型里能合并的节点做常量折叠和算子融合推荐在正式部署时开启。GetInputNameAllocated返回的是带生命周期的字符串指针旧版 API 里它是GetInputName新版本加上Allocated后缀是为了避免内存泄漏用AllocatorWithDefaultOptions配合即可。3.3 读取输入输出维度先打印 shape 再写推理Session 初始化成功后我建议先打印输入输出的张量形状确认模型导出的确实是分类结构。输出维度应该是[1, num_classes]如果你看到[1, 84, 8400]之类说明手里的是检测模型继续写代码没有意义。打印 shape 的代码可以这样加#include onnxruntime_cxx_api.h auto print_shape [](const std::vectorint64_t shape) { for (auto d : shape) std::cout d ; std::cout std::endl; }; size_t num_input_nodes session.GetInputCount(); for (size_t i 0; i num_input_nodes; i) { auto type_info session.GetInputTypeInfo(i); auto tensor_info type_info.GetTensorTypeAndShapeInfo(); auto shape tensor_info.GetShape(); std::cout input shape: ; print_shape(shape); } auto out_type_info session.GetOutputTypeInfo(0); auto out_tensor_info out_type_info.GetTensorTypeAndShapeInfo(); std::cout output shape: ; print_shape(out_tensor_info.GetShape());这里有个黑匣子GetShape返回的维度和 Python 里session.get_inputs()[0].shape是同一个值但 C 里是int64_t数组不要和size_t混淆。分类模型固定 shape 时通常打印结果是1 3 224 224如果某个维度是-1说明导出时开了动态轴C 侧就要小心处理动态维度这也是我要求固定 shape 的原因。4. 预处理与后处理图像分类器最容易写错的两段 C 代码4.1 预处理YOLOv11 分类模型为什么不用 letterbox我在第 2 章已经强调过分类模型预处理是“直接拉伸 转 RGB 除以 255”和检测模型的 letterbox 完全不一样。这也是整个 C 部署里最容易翻车的一步。下面是一份可运行且和 Python 侧输出对齐的预处理函数#include opencv2/opencv.hpp #include cstring // data 指向一个已经分配好的 1*3*224*224 的 float 缓冲区 void preprocess(const cv::Mat bgr, int imgsz, float* data) { cv::Mat resized; cv::Mat rgb; cv::Mat f; // 1. 直接拉伸到目标尺寸插值方式和 Python 侧保持 INTER_LINEAR 一致 cv::resize(bgr, resized, cv::Size(imgsz, imgsz), 0, 0, cv::INTER_LINEAR); // 2. BGR 转 RGB与训练时图像通道顺序保持一致 cv::cvtColor(resized, rgb, cv::COLOR_BGR2RGB); // 3. 像素值归一化到 [0, 1]注意只做除法不做减均值 rgb.convertTo(f, CV_32FC3, 1.0 / 255.0); // 4. HWC 转 CHW这是 C 和 ONNX 交互最常出错的地方 std::vectorcv::Mat chs(3); cv::split(f, chs); for (int c 0; c 3; c) { std::memcpy(data c * imgsz * imgsz, chs[c].data, imgsz * imgsz * sizeof(float)); } }参数说明cv::resize的插值方式必须用INTER_LINEAR默认就是线性插值但如果有人为了“压图更清晰”改成INTER_CUBIC或INTER_AREA推理结果会和 Python 侧出现细小偏差。convertTo的缩放系数要放在参数里不要对每个像素单独除 255那样既慢又可能在 float 精度上引入微小误差。最后的memcpy按通道拷贝是因为cv::split已经把图像拆成三个单通道cv::Mat每个Mat的数据区是连续内存直接拷贝到data c * imgsz * imgsz就完成了 NCHW 的布局转换。提示如果部署机器上不支持COLOR_BGR2RGB这种枚举先确认 OpenCV 版本imread 读图默认是 BGR这一步不能省。4.2 前向推理Shape 不匹配就是 access violation预处理完成后把float*数据包成Ort::Value再调用session.Run。这段代码也是 C 部署里段错误的高发区CreateTensor的 shape 参数和实际数据长度不一致时ONNX Runtime 内部按 shape 计算读取范围越界读取就会触发内存访问违规。Windows 上常见报错是“access violation 0xC0000005”Linux 上是 segment fault极少数情况会被 ONNX Runtime 捕获并抛出“捕获到标准 C 异常”的提示但本质上都是内存布局没对齐。#include onnxruntime_cxx_api.h #include array #include vector // session 和输入输出名从第 3 章的 main 里传入 std::arrayconst char*, 1 input_names {in_name.get()}; std::arrayconst char*, 1 output_names {out_name.get()}; // 输入 shape 必须和 ONNX 输入完全一致 std::vectorint64_t input_shape {1, 3, imgsz, imgsz}; std::vectorfloat input_data(1 * 3 * imgsz * imgsz); preprocess(bgr_img, imgsz, input_data.data()); Ort::MemoryInfo mem_info Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat( mem_info, input_data.data(), input_data.size(), input_shape.data(), input_shape.size()); auto output_tensors session.Run( Ort::RunOptions{nullptr}, input_names.data(), input_tensor, 1, output_names.data(), 1);逻辑说明CreateCpu告诉 ONNX Runtime 输入张量放在 CPU 内存上这样省一次设备拷贝如果后面要接 CUDA这里的 CPU 和 GPU 之间的 H2D 拷贝会由运行时自动处理但决策器明确指定 CPU 更可控。session.Run的参数里1是输入输出张量个数分类模型只有一个输入一个输出所以都是 1。这里的input_data.size()必须和input_shape计算的元素总数相等别用std::vector::capacity()来算。4.3 softmax 与 top-k先减最大值再谈保存推理结果从output_tensors[0]拿到的原始数据是模型的 logits不是概率。直接对它做 argmax 虽然也能得到类别索引但如果你想输出置信度或者做 top-5 分析就绕不开 softmax。C 里写 softmax 最典型的错误是忘记先减去最大值当 logits 里的某个值偏大时expf会溢出成inf结果整条概率向量全是NaN。float* raw_outputs output_tensors[0].GetTensorDatafloat(); size_t num_classes output_tensors[0].GetTensorTypeAndShapeInfo().GetElementCount(); std::vectorfloat probs(raw_outputs, raw_outputs num_classes); // 1. 先减最大值避免 exp 溢出 float max_val *std::max_element(probs.begin(), probs.end()); double sum 0.0; for (size_t i 0; i num_classes; i) { probs[i] expf(probs[i] - max_val); sum probs[i]; } // 2. 归一化成概率 for (size_t i 0; i num_classes; i) { probs[i] / static_castfloat(sum); } // 3. top-5 索引排序 std::vectorint idx(num_classes); std::iota(idx.begin(), idx.end(), 0); std::partial_sort(idx.begin(), idx.begin() 5, idx.end(), [](int a, int b) { return probs[a] probs[b]; }); // 4. 保存推理结果方便和 Python 输出做离线对比 FILE* fp fopen(result.txt, w); for (int i 0; i 5; i) { fprintf(fp, %d %.6f\n, idx[i], probs[idx[i]]); } fclose(fp);说明两点。第一sum用double类型累加因为 1000 个float相加会累积舍入误差虽然影响不大但既然是对照着 Python 输出验证精度越高越好。第二GetElementCount()已经帮你把多维 tensor 展开成元素个数不需要手动乘各个维度这个设计对分类模型特别友好——即使你的模型输出不是[1,1000]而是[1,5]代码也不用改。4.4 和 Python 结果对齐怎么确认预处理没有翻车C 跑完一轮后把result.txt和 Python 侧保存的python_out.txt做一次对比。我常用的是余弦相似度两段向量的夹角越接近 1说明 C 端计算越和 Python 一致。如果余弦相似度大于0.999说明预处理、前向推理、后处理全部对齐了可以进入性能调优阶段。如果相似度只有0.9左右优先怀疑预处理里 BGR 转 RGB 没做如果是某个类别的概率对不上而其他类别正常大概率是插值方式不一致去检查cv::INTER_LINEAR。5. 避坑YOLOv11 分类器 C 部署的 5 个常见问题现象 / 原因 / 解决5.1 导出的根本不是分类权重shape 对不上现象C 程序在创建输入张量后调用Run时抛出维度不匹配异常或者输出元素数比预期的num_classes大几十倍。 原因错误地把yolo11n.pt检测权重或yolo11n-seg.pt分割权重导出成 ONNX然后按分类模型的输出解析。检测模型输出是[1, 84, 8400]展开是 705600 个元素而分类模型输出只有[1, 1000]或[1, num_classes]。 解决回到第 2 章用yolo11n-cls.pt或自己训练的分类任务best.pt重新导出。导出后先用 Python 打印 ONNX 输出 shape确认是二维[1, N]再写 C。5.2 access violation c0000005 与“捕获到标准 C 异常”现象进程在session.Run前后崩溃Windows 弹 access violationLinux 直接 segment fault有的环境会打印“捕获到标准 C 异常”以及一串无法定位到行号的调用栈。 原因输入张量的 shape 和实际input_data长度不一致或者把HWC顺序的数据直接当成CHW传给 ONNX Runtime。常见变体是把cv::Mat的data直接塞给CreateTensor但cv::Mat每行有内存对齐 padding数据不是严格连续的一整块。 解决预处理阶段用cv::split拆通道再memcpy保证传入的是NCHW连续内存。传给CreateTensor的 shape 用{1, 3, imgsz, imgsz}不要自己改顺序。另外检查input_data.size()计算3 * imgsz * imgsz优先用size_t类型避免整型乘法溢出变成负数。5.3 C 和 Python 的结果总差一两个百分点现象top-5 里有一部分类别和 Python 一致但概率数值每次都有细微偏差top-1 偶尔对不上。 原因预处理顺序或插值方式不一致。最典型的错误是 C 端先做convertTo再resize而 Python 侧是先resize再归一化另一类是 C 端用了INTER_AREAPython 端是INTER_LINEAR。 解决严格统一操作顺序读取 BGR →resize→cvtColor(BGR2RGB)→convertTo(1/255)→split到 CHW。任何一步换顺序都要重新做 diff 测试不要凭感觉对齐。5.4 导出的 FP16 ONNX 在 CPU 上全程输出 NaN现象C 加载 ONNX 后Run正常但 logits 全是NaN或infsoftmax 结果也是 NaN且每个输入图都一样。 原因导出时halfTrue生成了 FP16 权重而 CPU 上的 ONNX Runtime 默认执行 Provider 不支持 FP16 算子要么直接报算子错误要么低精度转换后数值溢出。Jetson 的 GPU 上如果没显式指定CUDAProvider也会退到 CPU 执行。 解决CPU 部署统一用halfFalse导出 FP32 模型。确实想用 FP16 压缩体积时保住一条路在 C 里session_options.AppendExecutionProvider_CUDA并且确认 CUDA 版本和 ONNX Runtime 的编译配置匹配否则这条加速路径是无效的。FP16 部署不是改一个导出参数就能成的事。5.5 Jetson Nano 上推理慢且多线程设置无效现象同样模型在 x86 上 10 毫秒搬到 Jetson Nano 上 200 毫秒甚至更慢SetIntraOpNumThreads(8)设了反而更卡。 原因Jetson Nano 是 4 核 ARM线程数开太大导致线程频繁抢锁和上下文切换另外 ONNX Runtime 默认的内存 arena 会占掉一大块板载内存图形界面和推理进程同时跑时产生额外内存抖动。 解决线程数设 2 到 3不要超过物理核数。内存策略上保留CreateCpu(OrtArenaAllocator, OrtMemTypeDefault)即可不要自行把默认分配器换成OrtMemTypeCPUInput。如果模型还要跑检测建议单独做一次性能 profiling用ORT_ENABLE_ALL图优化先把能融合的算子融合掉再谈线程。Jetson 上另一个隐性问题是没有安装匹配版本的 OpenCVcv::resize在 H 264 图像上耗时会偏高可以换用cv::UMat或把解码和 resize 分离成两个线程但这属于图像流水线优化不在 ONNX Runtime 范围内。6. 在 Jetson Nano 或工控机上做端到端验证一张随机图的差分测试与两个提效习惯换一台机器部署时我不建议直接跑真实业务图而是先做一轮“随机图差分测试”用 C 标准库的随机数生成 10 张固定种子的噪声图同时喂给 Python ONNX Runtime 和 C 推理程序对比两边输出的余弦相似度。固定随机种子的好处是每次结果可复现和真实图片无关能最快暴露预处理和内存布局的问题。我常写一个小的cos_sim函数两段向量点积除以模长结果大于 0.999 就判定为一致如果小于这个值不要继续调线程参数回头查预处理。另一个提效习惯是在保存推理结果时不只写 top-5 的索引和概率把时间戳 图像名 top1 top5 概率 单次推理耗时写入结构化文本。第 4 章的result.txt是临时文件正式部署时我会改成按帧追加的 CSV这样离线对比时不用重新跑一次推理。这里还有个小技巧耗时统计要包住整个preprocess Run softmax而不是只统计Run因为实际产线上预处理也是流水线的一部分。我个人的习惯是每换一次模型权重就把第 2 章的 Python 对照输出重新生成一次替换掉旧的python_out.txt然后跑一遍随机图差分测试再谈优化。之前我在 Jetson 上翻过一次车导出时开了halfTrueCPU 上所有输出全是 NaN排查了快一下午最后才意识到是精度问题而不是代码问题。现在无论换模型还是换板子先把这条验证链路跑通了再动性能参数。这套流程帮我少走了很多弯路也希望帮到你。本文还有配套的精品资源点击获取
返回列表