简介:本资源面向希望在Windows CPU环境下部署图像分类模型的C++开发者与边缘计算实践者,提供基于YOLOv11的轻量级ONNX推理完整工程。核心代码采用纯C++编写,覆盖预处理、模型加载、推理与后处理全流程,并针对CPU进行多线程加速,实测Intel i5-12400F单帧推理约120ms,效率优于Python版本。工程支持直接替换自定义ONNX模型,兼容YOLOv8/v11等架构,可一键切换为检测或分割任务,适用于工业摄像头、树莓派等边缘设备及C++项目集成深度学习模型。压缩包共365个文件,约363.02MB,以hpp与h头文件为主,辅以cmake构建脚本、dll动态库、exe可执行文件及onnx模型与标签文件,目录结构清晰。资源内含环境配置指南、API接口说明与常见问题排查文档,新手可快速上手。目前已有104人学习下载,适合需要验证CPU端推理性能或集成模型的中高级开发者参考。
1. cppYolo11OnnxPredict:在 Windows CPU 上跑通 YOLO11 分类推理
很多做工业质检、边缘设备或者桌面工具的朋友,手里只有一台普通 Windows 办公机,没有独立显卡,却想用 YOLO11 做图像分类推理。这时候第一反应往往是装 PyTorch、配 CUDA,结果被环境折腾一整天,最后发现 CPU 版本跑得慢、依赖还冲突。cppYolo11OnnxPredict 这个方向解决的正是这件事:把 YOLO11 分类模型导出成 ONNX,用 C++ 配合 ONNX Runtime 在 Windows CPU 上做推理,并且代码结构支持直接替换模型文件。它适合两类人:一类是想把 Python 训练好的模型落地成独立 exe 的工程师,另一类是需要在无 GPU 的 Windows 机器上做批量图片分类的开发者。整条链路的核心是「导出 ONNX → C++ 加载 → 预处理 → 推理 → 后处理」,每一步都有明确的参数和坑点,下面按落地顺序拆开讲。
2. 为什么选 ONNX Runtime + C++ 而不是 Python 部署
2.1 CPU 推理场景下 ONNX Runtime 的实际优势
在 Windows CPU 上做推理,可选方案有 PyTorch C++(LibTorch)、OpenCV DNN、ONNX Runtime 几种。LibTorch 的包体积大,一个 release 版本解压后动辄 1GB 以上,而且和 PyTorch 版本强绑定,升级模型时容易连带升级整个运行时。OpenCV DNN 对 YOLO 系列的支持依赖版本,YOLO11 的新算子不一定能直接吃进去。ONNX Runtime 的优势在于:运行时体积小(CPU 版核心 dll 约十几 MB),算子覆盖跟得上 ONNX opset 更新,而且 C++ API 稳定,模型换版本时只要重新导出 ONNX 即可,不用动 C++ 代码。
另一个实际考量是部署环境。很多工厂现场的 Windows 机器是 Win10 LTSC 或者 Win7 升级上来的,装 Python 环境本身就有风险,更别说 pip 装 torch。用 C++ 编译出一个 exe,配合几个 dll,拷贝过去就能跑,这是最省心的交付方式。ONNX Runtime 官方提供预编译的 Windows 包,直接下载解压就能链接,不需要自己编译。
从性能上看,YOLO11 分类模型(比如 yolo11n-cls)输入 224x224,在普通 i5 上单张推理大约 20-40ms,用 ONNX Runtime 的 CPU EP 加上合理的线程数设置,能跑到接近理论值。如果换成 PyTorch CPU 版本,同样的模型往往要多花 30% 以上的时间,因为 PyTorch 的 CPU 推理路径没有 ONNX Runtime 那么针对推理做过图优化。
2.2 从 PyTorch 导出 YOLO11 分类 ONNX 的完整命令
导出这一步决定了后面 C++ 能不能顺利加载。YOLO11 分类模型用 ultralytics 库导出时,要注意 opset 版本和动态轴设置。下面是我常用的导出脚本:
from ultralytics import YOLO # 加载训练好的分类模型,这里以 yolo11n-cls 为例 model = YOLO("yolo11n-cls.pt") # 导出 ONNX,指定输入尺寸和 opset model.export( format="onnx", imgsz=224, # 分类模型常用 224,也可用 640 opset=12, # opset 12 兼容性好,ONNX Runtime 1.10+ 都支持 simplify=True, # 用 onnx-simplifier 简化图结构 dynamic=False, # CPU 部署固定 batch 更稳 half=False # CPU 不支持 fp16,必须关掉 )导出后会在同目录生成yolo11n-cls.onnx。这里几个参数值得展开说:opset=12是保守选择,如果你用的 ONNX Runtime 版本较新,可以用 17,但 12 能覆盖绝大多数 Windows 部署环境。simplify=True会调用 onnx-simplifier 做常量折叠和冗余节点消除,对推理速度有 5%-10% 的提升,但偶尔会引入兼容问题,如果加载报错可以先关掉试试。dynamic=False表示固定输入尺寸,CPU 推理时固定 shape 能让 ONNX Runtime 做更充分的内存规划。half=False必须强调,CPU 上 fp16 要么不支持要么被转成 fp32,开了反而多一层转换。
导出完成后,建议用 Python 的 onnxruntime 先验证一遍,确认模型能加载且输出 shape 符合预期:
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("yolo11n-cls.onnx", providers=["CPUExecutionProvider"]) input_name = sess.get_inputs()[0].name # 构造一个假输入,NCHW 格式 dummy = np.random.randn(1, 3, 224, 224).astype(np.float32) outputs = sess.run(None, {input_name: dummy}) print("输出 shape:", outputs[0].shape) # 应该是 (1, 类别数)这一步能跑通,说明 ONNX 文件本身没问题,后面 C++ 加载失败就大概率是环境或代码问题,而不是模型问题。
2.3 C++ 工程里 ONNX Runtime 的引入方式
Windows 上用 C++ 调 ONNX Runtime,有两种引入方式:一种是下载官方预编译包,手动配置 include 和 lib 路径;另一种是用 vcpkg 安装。我一般推荐第一种,因为可控性强,不依赖包管理器。
从 ONNX Runtime 官方 release 页面下载onnxruntime-win-x64-1.x.x.zip,解压后目录结构是:
onnxruntime-win-x64-1.17.0/ ├── include/ # 头文件 ├── lib/ # onnxruntime.lib 等 └── bin/ # onnxruntime.dll 等运行时在 Visual Studio 工程里,需要做三件事:在「附加包含目录」加上include路径;在「附加库目录」加上lib路径;在「附加依赖项」里加上onnxruntime.lib。编译完成后,把bin目录下的 dll 拷贝到 exe 同目录,否则运行时会报找不到 dll。
如果用 CMake,可以这样写:
cmake_minimum_required(VERSION 3.15) project(Yolo11ClsCpp) set(ONNXRUNTIME_DIR "C:/onnxruntime-win-x64-1.17.0") include_directories(${ONNXRUNTIME_DIR}/include) link_directories(${ONNXRUNTIME_DIR}/lib) add_executable(yolo11_cls main.cpp) target_link_libraries(yolo11_cls onnxruntime)这里ONNXRUNTIME_DIR换成你实际解压的路径。注意路径里不要有中文和空格,否则 CMake 解析可能出问题,这是 Windows 上很常见的翻车点。
3. C++ 推理代码的四个核心环节
3.1 图像预处理:从 cv::Mat 到模型输入张量
YOLO11 分类模型的预处理流程是:resize 到 224x224、BGR 转 RGB、归一化到 [0,1]、按 ImageNet 均值方差标准化、HWC 转 CHW、加 batch 维度。用 OpenCV 读图后,代码大致如下:
#include <opencv2/opencv.hpp> #include <vector> // 输入:BGR 的 cv::Mat,输出:float 数组,形状 1x3x224x224 std::vector<float> preprocess(const cv::Mat& img) { cv::Mat resized, rgb, float_img; // 1. resize 到 224x224 cv::resize(img, resized, cv::Size(224, 224)); // 2. BGR -> RGB cv::cvtColor(resized, rgb, cv::COLOR_BGR2RGB); // 3. 转 float 并归一化到 [0,1] rgb.convertTo(float_img, CV_32FC3, 1.0 / 255.0); // 4. 按 ImageNet 均值方差标准化 cv::Scalar mean(0.485, 0.456, 0.406); cv::Scalar std(0.229, 0.224, 0.225); std::vector<cv::Mat> channels(3); cv::split(float_img, channels); for (int i = 0; i < 3; i++) { channels[i] = (channels[i] - mean[i]) / std[i]; } cv::merge(channels, float_img); // 5. HWC -> CHW,并展平 std::vector<float> input_tensor(1 * 3 * 224 * 224); for (int c = 0; c < 3; c++) { for (int h = 0; h < 224; h++) { for (int w = 0; w < 224; w++) { input_tensor[c * 224 * 224 + h * 224 + w] = float_img.at<cv::Vec3f>(h, w)[c]; } } } return input_tensor; }这段代码里最容易出错的是均值方差和通道顺序。YOLO11 分类模型训练时用的是 ImageNet 的 mean/std,如果你导出时用了half=True或者自定义了归一化,这里必须对应改。另外cv::cvtColor之后float_img的通道顺序已经是 RGB,后面 split 出来的 channels[0] 就是 R 通道,顺序不能乱。如果预处理错了,模型输出会完全乱掉,但不会报错,这是最隐蔽的坑。
3.2 创建会话与运行推理:Ort::Session 的正确用法
ONNX Runtime 的 C++ API 用起来比 Python 啰嗦,但结构清晰。核心对象是Ort::Env、Ort::Session、Ort::SessionOptions。下面是一个完整的推理函数:
#include <onnxruntime_cxx_api.h> // 全局或类成员 Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "yolo11_cls"); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); // 设置线程数 session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); // 加载模型 Ort::Session session(env, L"yolo11n-cls.onnx", session_options); // 获取输入输出信息 Ort::AllocatorWithDefaultOptions allocator; auto input_name = session.GetInputNameAllocated(0, allocator); auto output_name = session.GetOutputNameAllocated(0, allocator); // 构造输入张量 std::vector<int64_t> input_shape = {1, 3, 224, 224}; std::vector<float> input_data = preprocess(img); auto memory_info = Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor = Ort::Value::CreateTensor<float>( memory_info, input_data.data(), input_data.size(), input_shape.data(), input_shape.size()); // 运行推理 const char* input_names[] = {input_name.get()}; const char* output_names[] = {output_name.get()}; auto outputs = session.Run(Ort::RunOptions{nullptr}, input_names, &input_tensor, 1, output_names, 1); // 取输出 float* output_data = outputs[0].GetTensorMutableData<float>(); auto output_shape = outputs[0].GetTensorTypeAndShapeInfo().GetShape(); int num_classes = output_shape[1];几个关键点:SetIntraOpNumThreads控制单次推理内部的并行线程数,CPU 上一般设成物理核心数,设太大反而因为线程切换变慢。ORT_ENABLE_ALL会启用所有图优化,包括算子融合,对 CPU 推理有实际收益。Ort::Session的模型路径在 Windows 上要用宽字符L"...",这是 ONNX Runtime C++ API 在 Windows 上的一个约定,用窄字符会编译报错或者运行时找不到文件。
3.3 后处理:从输出向量到分类结果
YOLO11 分类模型的输出是未经 softmax 的 logits,形状[1, num_classes]。要得到最终类别,需要做 softmax 然后取 argmax。代码:
#include <cmath> #include <algorithm> // 对 logits 做 softmax std::vector<float> softmax(const float* logits, int n) { std::vector<float> probs(n); float max_val = *std::max_element(logits, logits + n); float sum = 0.0f; for (int i = 0; i < n; i++) { probs[i] = std::exp(logits[i] - max_val); sum += probs[i]; } for (int i = 0; i < n; i++) { probs[i] /= sum; } return probs; } // 取 top-1 auto probs = softmax(output_data, num_classes); int best_idx = std::distance(probs.begin(), std::max_element(probs.begin(), probs.end())); float confidence = probs[best_idx];这里减去max_val是为了数值稳定性,防止 exp 溢出。分类模型不需要 NMS,所以后处理比检测模型简单很多。如果你要 top-5,可以用 partial_sort 或者维护一个大小为 5 的小顶堆。实际部署时,类别名称通常存在一个 txt 文件里,按行读取,索引对应 best_idx 即可。
3.4 模型替换:只改一个路径就能换模型
这个方案支持直接替换模型,关键在于 C++ 代码里不要硬编码类别数。类别数从输出 shape 动态获取,类别名称从外部 txt 读取。替换模型时只需要:把新的 ONNX 文件放到指定目录,修改代码里的模型路径,如果类别数变了,更新类别名称文件。不需要重新编译代码。
我一般会把模型路径和类别文件路径做成命令行参数或者配置文件:
// 从命令行读取模型路径 int main(int argc, char** argv) { std::string model_path = "yolo11n-cls.onnx"; std::string label_path = "labels.txt"; if (argc >= 3) { model_path = argv[1]; label_path = argv[2]; } // ... 加载模型和标签 }这样交付时,用户拿到 exe 和 dll,自己换 onnx 和 labels.txt 就行,不用碰代码。注意 ONNX Runtime 的Ort::Session构造函数在 Windows 上需要宽字符路径,如果从std::string转,要用std::wstring转换函数,比如std::wstring(model_path.begin(), model_path.end()),但这对中文路径不生效,所以模型路径最好全英文。
4. 避坑与排查:Windows CPU 部署的五个血泪教训
4.1 加载模型报「找不到指定模块」
现象:编译通过,运行 exe 时弹窗或控制台报「找不到 onnxruntime.dll」或者「无法加载 onnxruntime.dll」。
原因:ONNX Runtime 的 dll 没有放到 exe 同目录,或者放错了版本(x64 的 exe 配了 x86 的 dll)。
解决:把onnxruntime-win-x64-1.x.x/bin目录下的所有 dll 拷贝到 exe 所在目录。如果还报错,用 Dependency Walker 或者dumpbin /dependents检查 exe 依赖了哪些 dll,缺哪个补哪个。注意 Visual Studio 调试时工作目录可能是工程目录而不是 exe 目录,要在项目属性里把「调试 → 工作目录」设成$(OutDir)。
4.2 推理结果全是同一个类别
现象:不管输入什么图片,输出都是第 0 类或者某个固定类别,置信度还很高。
原因:预处理和训练时不一致。最常见的是归一化参数错了,或者 BGR/RGB 没转,或者 resize 的插值方式不同。
解决:先用 Python 的 onnxruntime 跑同一张图,确认 Python 端结果正常。然后把 C++ 预处理后的张量前几个值打印出来,和 Python 端对比。如果对不上,逐项检查 resize 尺寸、颜色通道、归一化系数。YOLO11 分类默认用 ImageNet 的 mean/std,但如果你训练时改了,导出 ONNX 后 C++ 也要跟着改。
4.3 多线程推理时结果错乱
现象:单张推理正常,多线程并发调用同一个Ort::Session时结果随机错乱或者崩溃。
原因:Ort::Session本身是线程安全的,但如果你把输入输出张量做成全局变量,多个线程会互相覆盖。
解决:每个线程创建自己的输入张量和输出缓冲区,Ort::Session可以共享。或者用线程池,每个线程独立调用session.Run。ONNX Runtime 的Run方法是线程安全的,但前提是输入输出内存不共享。
4.4 模型文件路径含中文导致加载失败
现象:模型放在中文目录下,Ort::Session构造时抛异常,提示找不到文件。
原因:ONNX Runtime 在 Windows 上使用宽字符 API,但如果你用std::string转std::wstring的简单方式,中文会乱码。
解决:模型路径和类别文件路径全部用英文,不要放在中文目录下。如果必须支持中文路径,用MultiByteToWideChar做正确的编码转换,代码会多几行,但能避免玄学问题。
4.5 Debug 模式推理速度极慢
现象:Release 模式单张 30ms,Debug 模式要 300ms 甚至更久。
原因:Debug 模式下编译器不优化,ONNX Runtime 的图优化也可能被禁用,而且 Debug 版 dll 本身性能就差。
解决:部署和性能测试一律用 Release 模式。如果需要在 Debug 下调试,可以接受速度慢,但不要用 Debug 的耗时去评估方案可行性。另外,Release 模式下要确保链接的是 Release 版的 onnxruntime.lib,混用 Debug/Release 会导致运行时崩溃。
5. 进阶技巧:用 OpenMP 和批处理把 CPU 吃满
单张推理在 CPU 上很难吃满所有核心,因为预处理和后处理是串行的。如果要做批量图片分类,可以把 batch size 设成 4 或 8,一次推理多张图,这样 ONNX Runtime 能更好地利用多核。导出 ONNX 时把dynamic设为 True,输入 shape 写成[batch, 3, 224, 224],C++ 端构造对应大小的输入张量即可。
另一个技巧是用 OpenMP 并行预处理。读图和 resize 是 CPU 密集型操作,用#pragma omp parallel for把预处理并行化,能显著缩短批量处理的总时间。下面是一个批量推理的骨架:
#include <omp.h> std::vector<std::string> image_paths = /* ... */; int batch_size = 8; int num_batches = (image_paths.size() + batch_size - 1) / batch_size; for (int b = 0; b < num_batches; b++) { std::vector<cv::Mat> batch_imgs(batch_size); #pragma omp parallel for for (int i = 0; i < batch_size; i++) { int idx = b * batch_size + i; if (idx < image_paths.size()) { batch_imgs[i] = cv::imread(image_paths[idx]); } } // 构造 batch 输入张量,形状 [batch_size, 3, 224, 224] // ... 调用 session.Run }这里batch_size要根据内存和 CPU 核心数调,一般设成核心数的 1-2 倍。设太大内存占用高,设太小并行度不够。我一般会在 i5 上设 8,i7 上设 16,实测吞吐量能比单张循环提升 2-3 倍。
验证方法很简单:准备 100 张测试图,分别用单张循环和批处理跑一遍,用std::chrono计时,对比总耗时和 CPU 占用率。如果批处理没有明显提升,检查 ONNX 导出时dynamic是否开启,以及SetIntraOpNumThreads是否设成了物理核心数。
最后说一个我自己的习惯:每次换模型或者换机器,先跑一个 10 张图的小测试集,把每张图的 top-1 类别和置信度打印出来,和 Python 端的结果逐张对比。只要有一张对不上,就停下来查预处理,不要急着上批量。这个习惯帮我省了很多次返工。希望帮到你。
本文还有配套的精品资源,点击获取