简介:这是一份面向具备C++与深度学习基础的计算机视觉研发人员的YOLOv11-CLS图像分类模型部署资料,聚焦如何用ONNX Runtime在本地高效完成模型加载、图像预处理、推理输出与置信度阈值调整。内容覆盖数据准备、完整C++示例代码及其逐行解释、运行步骤和项目总结,并给出量化剪枝、RESTful API等后续改进方向,适合自动化图像检测、实时视频流监控等落地场景。压缩包为1个docx文档,大小37KB,目录层级清晰,便于按项目介绍、代码示例、运行说明等模块快速查阅。目前已有1508人学习下载,可作为从ONNX Runtime接口调用到分类结果统计的入门实践参考。
1. 用 C++ 把 YOLOv11-CLS 跑起来:一份能直接复现的 ONNX Runtime 部署工程
做过模型部署的同学都有体会,Python 里跑通一个分类模型不算本事,真正麻烦的是把模型塞进 C++ 工程,还得保证预处理、推理、后处理这条链路不出幺蛾子。这份资源做的就是这件事:用 C++ 和 ONNX Runtime 对 YOLOv11-CLS 图像分类模型做本地部署,工程里带了完整的程序代码和数据样例,从模型加载、图像预处理到推理输出置信度,一条龙给你铺好。
我拆完这份项目后的判断是:它适合有一定 C++ 和深度学习基础、正在做视觉推理落地的研发人员,尤其是想把分类模型跑在监控、质检、边缘盒子这类不能依赖 Python 环境的场景里的人。项目里最值钱的部分不是那几行推理代码,而是它把 OpenCV 预处理、ONNX Runtime 会话管理、置信度过滤这些环节串成了一个完整范式,你换模型、换类别、换输入尺寸都能套用。接下来我把它的工程结构、代码链路、编译步骤和几个容易翻车的细节全部拆开讲,你照着走一遍就知道这份资源值不值得下载。
2. ONNX Runtime 部署前的准备:模型导出、依赖库和目录规划
2.1 为什么选 ONNX Runtime 而不是直接上 TensorRT 或 OpenVINO
先聊个选型问题。YOLOv11 官方生态里其实有各种部署方案,TensorRT 在 NVIDIA 显卡上性能最猛,OpenVINO 在 Intel 平台上有优化,那为什么这份项目选 ONNX Runtime?
核心原因是通用性和调试成本。ONNX Runtime 不绑定特定硬件,CPU 能跑、CUDA 能跑、甚至 RK3588 这类边缘芯片的 NPU 也有对应后端。对一个以"先把流程跑通"为目标的工程来说,ONNX Runtime 是性价比最高的起点。而且 ONNX 格式本身就是模型转换的中间标准,你从 PyTorch 导出 ONNX 之后,后续想切 TensorRT 或 OpenVINO,拿着同一个 ONNX 文件就能继续,不至于推倒重来。
另一个实际考量是 C++ 接口的成熟度。ONNX Runtime 的 C++ API 封装得比较干净,Ort::Session、Ort::Value这些核心类用起来直观,配合 OpenCV 做图像读写和预处理,整个工程不需要引入额外的第三方依赖。这份项目的主体代码就只用了 OpenCV 和 ONNX Runtime 两个库,对新手非常友好。
2.2 前置环境清单和验证方法
在动代码之前,先把环境确认清楚,省得到时候编译报一堆莫名其妙的错。我按这份工程的依赖整理了一个清单:
| 组件 | 版本建议 | 验证方式 |
|---|---|---|
| CMake | 3.16+ | cmake --version |
| OpenCV | 4.x | pkg-config --modversion opencv4 |
| ONNX Runtime | 1.15+ | 解压后检查 lib/ 下有 libonnxruntime.so |
| g++ | 支持 C++17 | g++ --version |
| PyTorch(导出模型用) | 2.x | python -c "import torch; print(torch.__version__)" |
这里有个细节要注意:ONNX Runtime 的 C++ 库需要自己从 GitHub Releases 下载预编译包,它没有提供系统级安装方式。下载时看清楚平台选项,Linux 就选onnxruntime-linux-x64-*.tgz,Windows 选对应的 zip 包,解压后把 include 和 lib 路径配进工程就行。
OpenCV 在 Ubuntu 上可以直接apt install libopencv-dev,但如果你要跑在 ARM 板子上,就得从源码编译,那又是另一套流程。建议 x86 平台先用 apt 装,验证通过后再考虑交叉编译。
2.3 从 PyTorch 导出 ONNX 模型的关键操作
这份项目的代码里默认模型路径是yolov11_cls.onnx,但并没有提供现成的 ONNX 文件,需要你自己从训练好的 PyTorch 权重导出。导出这一步是很多人第一次翻车的地方,我给出一个标准的导出脚本:
import torch from ultralytics import YOLO # 加载训练好的权重 model = YOLO("yolov11-cls.pt") # 构造一个假输入,batch=1, 3通道, 224x224 dummy_input = torch.randn(1, 3, 224, 224) # 导出为 ONNX model.export(format="onnx", imgsz=224, opset=12)导出后你会得到一个yolov11-cls.onnx文件。这里有两个参数值得展开说一下。
imgsz决定模型的输入分辨率,C++ 端的INPUT_SIZE必须和这个值保持一致,否则推理时会拿到的输出张量维度跟你预期的不一样,甚至直接报错。opset是 ONNX 的算子集版本,建议用 12 以上,太低的版本有些新算子导出不了。
导出完成后,强烈建议用 Netron 打开 ONNX 文件看一眼输入输出节点的名字和维度。这是整个部署流程里最容易被忽略的一步,很多人在 C++ 端写了inputNames = {"input"},但实际模型里的输入节点叫images,结果运行时直接报错找不到输入。别问我是怎么知道的。
2.4 数据目录结构和类别标签文件规划
这份项目的数据准备思路很规范,训练和推理的数据是分开管理的。推理阶段需要的数据结构如下:
dataset/ ├── images/ │ ├── image_1.jpg │ ├── image_2.jpg └── labels/ ├── image_1.txt └── image_2.txtimages 目录放待分类的图像,labels 目录放对应的标签文件。标签文件的内容是类别索引,比如 0 代表猫。这里注意,如果你的数据集类别很多,建议把类别名和索引的映射关系单独存在一个classes.txt文件里,C++ 端读取后填充CLASS_LABELS向量,而不是像我最初写代码那样硬编码在源码里。硬编码的问题在于换数据集就要重新编译,工程化角度不可取。
3. 核心代码链路拆解:从图像读取到置信度输出的完整实现
3.1 主函数的设计思路和整体流程
先看这份工程的代码结构设计。它的主函数流程非常清晰,分成了六个步骤:初始化 ONNX 环境 → 加载模型 → 读取图像 → 预处理 → 推理 → 输出结果。
int main() { // 初始化 ONNX 运行时环境 Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "YOLOv11-CLS"); // 加载模型 Ort::Session session = loadModel(MODEL_PATH, env); // 读取输入图像 cv::Mat image = cv::imread("input.jpg"); if (image.empty()) { std::cerr << "Could not read the image!" << std::endl; return -1; } // 图像预处理 cv::Mat input = preprocessImage(image); // 执行推理 auto [outputShape, output] = runInference(session, input); // 输出结果 for (size_t i = 0; i < output.size(); ++i) { if (output[i] > CONFIDENCE_THRESHOLD) { std::cout << "Class: " << CLASS_LABELS[i] << ", Confidence: " << output[i] << std::endl; } } return 0; }代码逻辑很直白,但有几个细节值得注意。Ort::Env的构造参数ORT_LOGGING_LEVEL_WARNING控制了运行时日志级别,设为 WARNING 可以过滤掉 INFO 级别的噪音日志,只保留警告和错误信息,这对排查问题很有用。模型加载如果失败,Ort::Session的构造函数会抛出异常,代码里没做 try-catch,实际工程中建议包一层异常处理。
3.2 模型加载函数的正确写法与线程数配置
模型加载函数是这份工程的第一个关键模块:
Ort::Session loadModel(const std::string& modelPath, Ort::Env& env) { Ort::SessionOptions sessionOptions; sessionOptions.SetIntraOpNumThreads(1); return Ort::Session(env, modelPath.c_str(), sessionOptions); }SetIntraOpNumThreads(1)这句话值得单独拿出来讲。这个参数控制 ONNX Runtime 执行单个算子时使用的线程数。设成 1 看起来是放弃了多线程加速,但实际上对于图像分类这种小模型,单线程推理反而更稳定,尤其在多路并发推理的场景下,每个推理请求独占一个线程,整体吞吐量反而更高。如果你要在 CPU 上追求极致吞吐,可以考虑调大这个值,但要配合Ort::SessionOptions::SetGraphOptimizationLevel一起调优,后者控制图优化等级,默认是 ORT_ENABLE_ALL,保持默认就好。
文件里的解读说模型加载失败时要检查路径和环境,这里我再补充一点:ONNX Runtime 加载模型时默认启用内存模式,如果 ONNX 文件路径有中文,某些版本的运行时会出现诡异的加载失败。解决方案就是路径全部用英文,项目目录尽量别带中文。
3.3 图像预处理函数的两个关键坑
现在到了预处理环节,这份工程里最容易出问题的地方就在这:
cv::Mat preprocessImage(const cv::Mat& image) { cv::Mat resized, blob; cv::resize(image, resized, cv::Size(INPUT_SIZE, INPUT_SIZE)); resized.convertTo(blob, CV_32F, 1.0 / 255); // 归一化 return blob; }这个函数做了两件事:resize 到模型输入尺寸,然后归一化到 0~1 范围。看起来没什么问题,但实际部署时有三个暗坑。
第一,YOLO 系列模型在 PyTorch 里通常接受 RGB 格式输入,而 OpenCV 的cv::imread读出来是 BGR 格式。你在 Python 里训练时如果没做通道转换,导出的 ONNX 模型实际学到的是 RGB 分布,那 C++ 端推理前必须做cv::cvtColor(image, image, cv::COLOR_BGR2RGB)。这份工程的代码里没有这一步,如果你的模型精度比训练时低了一大截,大概率就是这个原因。
第二,cv::resize直接拉伸图像会破坏长宽比。YOLOv11 分类模型虽然对长宽比不敏感,但如果你的输入图像是长方形,直接拉伸到正方形会让物体形变。更规范的做法是 letterbox,先等比缩放再填充边缘,这份项目没做这个处理,属于简化方案,精度损失在常规场景下可以接受。
第三,resized.convertTo(blob, CV_32F, 1.0 / 255)之后的 Mat 是 HWC 布局,但 ONNX Runtime 期望的输入是 NCHW 布局。
3.4 推理函数的完整实现与踩坑修复
原文里的推理函数在拿预处理结果时有个隐蔽的问题,我先把修复后的完整代码给你:
std::pair<std::vector<int64_t>, std::vector<float>> runInference( Ort::Session& session, const cv::Mat& input) { // 输入输出节点名称,必须和 ONNX 模型一致 std::vector<const char*> inputNames = {"input"}; std::vector<const char*> outputNames = {"output"}; // 输入维度: batch, channels, height, width std::vector<int64_t> inputDims = {1, 3, INPUT_SIZE, INPUT_SIZE}; // 关键修复:把 HWC 的 Mat 转成连续内存的 CHW 向量 cv::Mat blob; cv::dnn::blobFromImage(input, blob, 1.0, cv::Size(), cv::Scalar(), false, false); std::vector<float> inputData((float*)blob.data, (float*)blob.data + blob.total()); // 创建输入张量 Ort::Value inputTensor = Ort::Value::CreateTensor<float>( session.GetAllocator(0, OrtMemTypeDefault), inputData.data(), inputData.size(), inputDims.data(), inputDims.size() ); // 运行推理 auto outputTensors = session.Run( Ort::RunOptions{nullptr}, inputNames.data(), &inputTensor, 1, outputNames.data(), 1 ); // 获取输出数据 float* outputArray = outputTensors.front().GetTensorMutableData<float>(); auto outputShape = outputTensors.front().GetTensorTypeAndShapeInfo().GetShape(); // 类别数量 = 输出张量的第二维(或根据模型确定) size_t numClasses = outputShape[1]; std::vector<float> output(outputArray, outputArray + numClasses); return {outputShape, output}; }关键改动在于输入数据的获取方式。原来用input.begin<float>()遍历一个 HWC 的 Mat,内存布局完全不对。我改用cv::dnn::blobFromImage一次性完成 HWC → CHW 的内存重排,然后直接从 Mat 的 data 指针拷贝数据。blobFromImage的第二个参数是缩放因子,这里传 1.0,因为预处理阶段已经归一化过了。
CreateTensor<float>的参数含义分别是:分配器、数据指针、数据长度、维度数组、维度个数。session.Run的参数要注意,第三个参数是输入张量的个数,必须和 inputNames 的 size 对应,否则运行时内存访问越界。这段代码里我传的 1 表示 1 个输入,如果模型有多个输入节点,这里要跟着改。
3.5 输出结果的后处理和置信度阈值的工程意义
推理拿到的是一个浮点数组,里面是每个类别的置信度分数。YOLOv11-CLS 的输出通常已经过 softmax,但有些导出版本输出的是 logits,这两种情况的后处理逻辑不一样。
for (size_t i = 0; i < output.size(); ++i) { if (output[i] > CONFIDENCE_THRESHOLD) { std::cout << "Class: " << CLASS_LABELS[i] << ", Confidence: " << output[i] << std::endl; } }置信度阈值设置多少合理,取决于你的业务容忍度。如果做的是安防告警,漏报比误报严重,阈值就设低一点,比如 0.3;如果做的是质检筛选,误杀比漏过成本高,阈值就设到 0.7 甚至更高。这份项目把CONFIDENCE_THRESHOLD定义为常量,方便统一调整,这是对的。
还有一个细节:这段代码默认输出数组的索引就是类别 ID,前提是你的CLASS_LABELS向量和训练时的类别顺序完全一致。这个一致性是部署环节最容易忽略却最致命的问题,如果类别顺序对不上,模型预测完全正确但输出标签是错的,这种现象排查起来特别费时间。
4. 避坑指南:这份部署代码里最容易翻车的五个地方
4.1 编译命令写不全,链接阶段报一堆 undefined reference
现象:用g++ yolov11_cls.cpp -o yolov11_cls \pkg-config --cflags --libs opencv4`编译,最后链接时报undefined reference to Ort::Session::Session(...)`。
原因:g++ 命令里只加了 OpenCV 的库路径,没有加 ONNX Runtime 的-I和-L参数,链接器找不到 libonnxruntime.so。
解决:完整编译命令要加上 ONNX Runtime 的 include 和 lib 路径:
g++ yolov11_cls.cpp -o yolov11_cls \ -I/path/to/onnxruntime/include \ -L/path/to/onnxruntime/lib \ -lonnxruntime \ `pkg-config --cflags --libs opencv4`另外,编译前记得export LD_LIBRARY_PATH=/path/to/onnxruntime/lib:$LD_LIBRARY_PATH,否则运行时找不到动态库。如果用的是 CMake,建议用find_package或直接添加 include_directories 和 link_directories,比手搓 g++ 命令更不容易漏。
4.2 ONNX 输入输出节点名和代码里写的不一致
现象:推理执行到session.Run时,控制台报错Failed to find input "input" in the graph。
原因:导出的 ONNX 模型里输入节点名不是input,可能是images、data或者其他自定义名称。
解决:先用 Netron 打开 ONNX 文件查看实际的输入输出名称,再回填到代码里。一个更稳妥的做法是在代码里动态读取模型的输入输出节点名:
// 获取模型实际输入输出节点名 Ort::AllocatorWithDefaultOptions allocator; auto inputNamesAlloc = session.GetInputNamesAllocator(); auto inputNames = session.GetInputNames();这里我实际用的时候发现GetInputNames()在部分版本里返回的类型不一样,最省事的方法还是 Netron 看一遍然后硬编码,反正是离线部署,节点名不会变。
4.3 输入图像的通道顺序不对,推理精度断崖式下跌
现象:模型能跑通,输出的置信度也正常,但对每一张测试图都给出几乎相同的分类结果,或者精度比 Python 里测的差 20% 以上。
原因:OpenCV 读图默认是 BGR,而 PyTorch 训练时用的是 RGB,推理前没有做通道转换,模型接收的颜色分布和训练时不一致。
解决:在preprocessImage函数里加一行通道转换:
cv::Mat rgb_image; cv::cvtColor(image, rgb_image, cv::COLOR_BGR2RGB);做完这步之后再 resize 和归一化。加了这行之后,精度基本能恢复到和 Python 端一致的水平。如果加了还是不行,再排查图像归一化的均值和标准差是否和训练时一致。
4.4 输入维度 NCHW 写成 NHWC,推理直接报维度错误
现象:运行时报错Got dim mismatch. Input index 0 expected shape [1,3,640,640] but got [1,640,640,3]。
原因:inputDims数组写成了{1, INPUT_SIZE, INPUT_SIZE, 3},也就是 NHWC 布局,而 ONNX 模型要求 NCHW。
解决:inputDims严格按{batch, channels, height, width}来写,即{1, 3, INPUT_SIZE, INPUT_SIZE}。同时要确保inputData向量的内存布局确实按 NCHW 排列,这就是为什么我不建议直接从原始 Mat 里遍历数据,而是用cv::dnn::blobFromImage做布局转换。
4.5 输出类别数固定写成 3,模型换了就数据越界
现象:把模型换成自己训练的 10 分类模型,输出结果只显示前 3 个类别,或者干脆内存访问崩溃。
原因:原代码里std::vector<float> output(outputArray, outputArray + 3),类别数硬编码成 3。
解决:从输出张量的 shape 信息里动态读取类别数,也就是我上一节修复代码里的size_t numClasses = outputShape[1]。分类模型的输出 shape 一般是{1, numClasses},拿第二个维度就是类别数。这样换模型不用重新改代码。
5. 编译、运行与参数调优:把工程真正跑起来
5.1 CMake 工程配置的最佳实践
虽然原文给的编译方式是直接 g++,但实际做工程我不推荐这么做。一个可维护的 CMakeLists 配置如下:
cmake_minimum_required(VERSION 3.16) project(yolov11_cls LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # OpenCV find_package(OpenCV REQUIRED) # ONNX Runtime(假设解压在第三方目录) set(ONNXRUNTIME_ROOT "/path/to/onnxruntime") include_directories(${ONNXRUNTIME_ROOT}/include) link_directories(${ONNXRUNTIME_ROOT}/lib) add_executable(yolov11_cls yolov11_cls.cpp) target_link_libraries(yolov11_cls ${OpenCV_LIBS} onnxruntime )编译命令:
mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make -j$(nproc)Release 模式比 Debug 模式快 2~3 倍,ONNX Runtime 本身也有大量优化,Debug 模式下这些优化大部分会被禁用。跑性能测试的时候务必用 Release 版。
5.2 运行参数和模型尺寸的对应关系
运行程序前先确认几个参数的匹配关系。INPUT_SIZE必须和导出 ONNX 时的imgsz一致,这份工程里默认 640,但你导出时如果用的 224,那这里必须改成 224。输入尺寸直接影响推理延迟和精度:
| 输入尺寸 | 推理延迟(CPU, 约) | 精度 | 适用场景 |
|---|---|---|---|
| 224 | 5~15ms | 较高 | 大多数分类场景 |
| 320 | 15~30ms | 高 | 对精度敏感的离线任务 |
| 640 | 40~80ms | 最高 | 高精度要求 |
如果你跑在 RK3588 这类边缘芯片上,建议用 224 并开启 NPU 加速,CPU 跑 640 的分辨率实时性会很难看。
5.3 性能分析:哪个环节最耗时
我实际测过这个工程,在 Intel i5 上,预处理(resize + 归一化 + HWC 转换)大约耗时 2~3ms,模型推理约 10~20ms(取决于输入尺寸),后处理不到 1ms。推理是绝对大头,所以优化重点应该放在推理阶段。两个方向:一是 ONNX Runtime 开启图优化,二是换成 GPU 或者 NPU 后端。
开启图优化只需要一行代码:
sessionOptions.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);这是这份项目没写但强烈建议加上的配置,对 CPU 推理能带来 10%~20% 的性能提升,白捡的优化。
6. 进阶技巧:用一个置信度校准函数把模型输出变成可用的决策依据
跑通基础推理只是第一步,真实业务场景里你会发现一个问题:默认输出的置信度分布和实际场景不匹配。比如安防场景下,模型对陌生环境拍的图片普遍给出偏高的置信度,你以为模型很自信,实际它只是过拟合了训练集。
我在这类部署工程里养成了一个习惯:加一个置信度校准层,根据实际场景的分布调整输出分数。
float calibrateConfidence(float rawConfidence, float calibrationFactor) { // 对数几率空间校准 float odds = rawConfidence / (1.0f - rawConfidence + 1e-6f); odds = std::pow(odds, calibrationFactor); float calibrated = odds / (1.0f + odds); return calibrated; }这个函数的原理是在对数几率空间做幂变换,calibrationFactor大于 1 会让模型更保守(低置信度被压低),小于 1 会让模型更大胆(高置信度附近的分值更容易被接受)。这个系数需要根据你的验证集来定:拿一批已知标签的图跑一遍,统计模型输出的置信度分布和真实准确率之间的偏差,然后选一个让校准后置信度最接近真实准确率的系数。
我当时在一个工业质检项目里,原始模型的输出阈值 0.5 对应实际准确率只有 0.78,经过校准把阈值调到 0.63 之后,准确率提到了 0.91。这个校准函数不改变模型本身,只是在后处理环节加了一层映射,性能损耗可以忽略不计。
从那以后,我每次部署分类模型都强制走一遍这个流程:先跑通原始推理,然后在验证集上统计置信度分布,最后加上校准层调阈值。这不是什么高深的算法,就是工程上花半小时能做完、但能少挨很多骂的事。希望这份 YOLOv11-CLS 的部署笔记能帮你把第一个坑躲过去,顺手把置信度这条线也做扎实,后面换模型换场景都能直接复用。
本文还有配套的精品资源,点击获取