简介面向需要在 Windows CPU 环境快速部署 YOLO 图像分类模型的开发者该压缩包提供了一套基于 YOLOv11 的纯 C ONNX 推理方案无需 GPU 即可运行覆盖预处理、模型加载、推理、后处理全流程支持直接替换自定义 ONNX 模型兼容 YOLOv8/v11 等架构。针对 CPU 做了多线程加速实测 Intel i5-12400F 单帧推理约 120ms比 Python 版明显高效。压缩包共 365 个文件、约 363MB以 197 个 hpp 和 74 个 h 头文件为核心源码包含 15 个 exe 运行程序、11 个 dll 动态库、6 个 lib 库文件以及 cmake 构建配置、txt 说明、md 文档等便于工程编译与二次开发。包内提供预训练分类模型1000 类 ImageNet 标签、OpenCVONNX Runtime 环境配置指引、API 接口说明和常见问题排查适合边缘设备工业摄像头、Raspberry Pi或 C 项目集成深度学习模型。已有 104 人学习对于想要快速验证 CPU 推理性能并替换为检测/分割模型的开发者来说是一份可直接上手的完整示例工程。1. cppYolo11OnnxPredict.zip 是什么在 Windows CPU 上跑通 YOLO11 图像分类的最小闭环你有没有接过这种需求客户一台 Windows 办公机没装 GPU也不愿意装 Python 环境却要把 YOLO11 图像分类模型做成一个双击就能跑的桌面工具。cppYolo11OnnxPredict.zip 这个项目方案就是朝这个方向搭的C 读图、ONNX Runtime 在 Windows CPU 上完成推理最后输出置信度最高的若干个类别。标题里那句「可直接替换模型」才是关键价值——换一个 onnx 文件和 labels不用重新编译就能换分类业务。这套方案适合两类人一是做产线质检、图片自动归档、批量筛图这类小工具的工程师客户机器往往老旧且权限受限二是想绕开 Python 运行时、PyInstaller 打包又嫌太大的人。CPU 部署的代价是推理速度不如 GPU但对单张图片分类这种任务多数场景根本喂不满 CPU。接下来我按自己落地这类项目的顺序从选型、工程骨架、预处理、推理封装再到换模型的坑完整过一遍。2. 为什么是 C ONNX Runtime选型逻辑与 CMake 工程骨架2.1 CPU 部署的选型Python 方案与 C 方案的边界先说选型。模型训练阶段用 Python 一点问题没有但部署到客户机器上Python 方案的痛点很快暴露要装解释器、装依赖、处理不同机器上的版本冲突启动还慢。用打包工具把 Python 脚本包成 exe 是条路但产物体积大、启动慢在某些机器上还会被杀毒软件误报。对于「交付一个能跑的 exe 模型文件」这种诉求C 原生程序是更稳的底座。推理引擎选型上我一般只比较两个ONNX Runtime 和 OpenCV DNN。OpenCV DNN 确实能读 ONNX胜在少一个依赖但算子覆盖不全遇到新算子还得升级 OpenCV 版本图优化能力也偏弱。ONNX Runtime CPU 版是专门跑 ONNX 的推理引擎算子覆盖全还有图优化开关和线程控制Windows 下就是 onnxruntime.dll 一个文件的事。下面是三个方向的实际对比方案环境依赖启动与内存算子兼容线程控制Python 推理解释器解释器 一堆包启动慢、内存高最好基本不可控C ONNX Runtime CPU几个 dll秒开、内存低好可调C OpenCV DNNOpenCV 自身秒开、内存低部分算子缺失不可调结论是明确的标题限定在 Windows CPUONNX Runtime 是最不折腾的路线既不用处理显卡驱动也不用担心客户机器缺 CUDA 组件。性能不够时先调线程和输入尺寸而不是急着上 GPU。2.2 工程目录与最小 CMake 配置我习惯把工程拆成四块源码、模型、依赖、构建文件。模型单独放一个目录是为了兑现「可直接替换模型」——换模型只动 models 目录不动代码。cppYolo11OnnxPredict/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── classifier.h │ └── classifier.cpp ├── models/ │ ├── classifier.onnx │ └── labels.txt └── third_party/ ├── onnxruntime/ └── opencv/labels.txt 是容易忽略的配置每行一个类别名顺序必须和模型训练时的 class index 完全一致。它不参与推理只负责把输出下标翻译成人类能读的名字顺序错了会出现「预测很准但名字全错位」的诡异现场。依赖管理方面Windows 上我推荐 vcpkg一条命令把 onnxruntime 和 opencv 一起装掉CMake 集成也干净。如果团队里没人用 vcpkg手工下载两个预编译包、把 include 和 lib 路径传给 CMake 变量也可以效果一样。# 用 vcpkg 安装 CPU 版推理依赖onnxruntime 默认就是 CPU 版 vcpkg install onnxruntime opencv4 # 配置工程时传入 toolchain 文件即可 cmake -B build -S . -DCMAKE_TOOLCHAIN_FILED:/vcpkg/scripts/buildsystems/vcpkg.cmake cmake --build build --config Release对应手写 CMakeLists.txt 时onnxruntime 的 include 和 lib 目录用两个变量接收这样手工下载二进制和 vcpkg 两种方式都能适配cmake_minimum_required(VERSION 3.20) project(cppYolo11OnnxPredict) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(OpenCV REQUIRED) # 手工下载 onnxruntime 时通过 -D 传入两个路径vcpkg 集成可忽略 set(ONNXRUNTIME_INCLUDE_DIR CACHE PATH onnxruntime include dir) set(ONNXRUNTIME_LIB_DIR CACHE PATH onnxruntime lib dir) include_directories(${ONNXRUNTIME_INCLUDE_DIR}) link_directories(${ONNXRUNTIME_LIB_DIR}) add_executable(${PROJECT_NAME} src/main.cpp src/classifier.cpp src/classifier.h ) target_link_libraries(${PROJECT_NAME} PRIVATE ${OpenCV_LIBS} onnxruntime ) # 把 onnxruntime.dll 自动复制到 exe 同目录避免手动拷 add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different ${ONNXRUNTIME_LIB_DIR}/onnxruntime.dll $TARGET_FILE_DIR:${PROJECT_NAME})这段配置有两个细节要注意。第一onnxruntime 链接名写 onnxruntime 即可CMake 会自动找 onnxruntime.lib 导入库手工下载时 lib 目录里就是这个名字。第二POST_BUILD 复制 dll 是 Windows 部署的常规操作不然每次换机器都要手动补 dll早晚漏一次。CMake 配置完工程骨架就立住了下面进入真正决定结果的部分预处理。3. 图像分类预处理从 cv::Mat 到 ONNX 张量的关键几步3.1 分类模型和检测模型的预处理差异很多人第一次在 C 里跑 YOLO11 分类模型会下意识沿用检测模型的预处理流程这是最常见的翻车点。检测模型通常要做 letterbox保持长宽比、填充灰边因为检测要定位目标位置变形会影响框的坐标。分类模型没有这个约束直接把整张图 resize 到模型输入尺寸即可常见的是 224×224导出的 onnx 也可能固定为 160 或 320。另一个差异是归一化。YOLO 系列检测模型跑 ONNX 时输入普遍是 0~1 的 float也就是原始像素除以 255而分类模型训练时有的用了 ImageNet 的 mean/std 归一化有的只用除以 255。这组参数写错最典型的症状是 top-1 类别偶尔对、但置信度分布很奇怪。我的做法是先在 Python 端用训练仓库的预处理方式跑通一张图记录它的 top-5 输出再用 C 复现同样的结果两边对上了再固化代码。通道顺序也是必查项。OpenCV 的 imread 读出来是 BGR而 YOLO11 训练时用的是 RGB。漏掉 BGR2RGB 这一步模型不会报错但精度会明显劣化尤其对颜色敏感的分类任务影响是致命的。这部分属于典型的黑匣子问题模型内部不会提示你通道反了只能靠对比验证发现。3.2 分类预处理代码实现与参数说明下面这段函数是我在 CPU 分类部署里一直沿用的版本输入任意尺寸图片输出 3×h×w 的 NCHW float 数据直接对接 ONNX Runtime 的张量要求// 从 cv::Mat 到 ONNX 输入张量输出 NCHW 布局的 float 数据 void preprocess_to_tensor(const cv::Mat img, int input_w, int input_h, std::vectorfloat tensor) { cv::Mat rgb; if (img.channels() 3) { cv::cvtColor(img, rgb, cv::COLOR_BGR2RGB); } else if (img.channels() 4) { cv::cvtColor(img, rgb, cv::COLOR_BGRA2RGB); } else { cv::cvtColor(img, rgb, cv::COLOR_GRAY2RGB); } // 分类模型直接拉伸到模型输入尺寸不做 letterbox cv::Mat resized; cv::resize(rgb, resized, cv::Size(input_w, input_h), 0, 0, cv::INTER_LINEAR); // 归一化YOLO11 分类最常见的是除以 255 resized.convertTo(resized, CV_32FC3, 1.0 / 255.0); // HWC - CHWONNX 分类模型输入布局固定是 NCHW tensor.resize(static_castsize_t(3) * input_w * input_h); const int hw input_w * input_h; const float* src resized.ptrfloat(0); for (int c 0; c 3; c) { for (int p 0; p hw; p) { tensor[c * hw p] src[p * 3 c]; } } }逻辑说明cvtColor 解决 BGR/RGB 问题分类任务里这步不能省resize 用 INTER_LINEAR 是因为大部分训练仓库的默认插值就是线性插值两者对齐能减少差异最后的双层循环是布局转换ONNX 的输入是 NCHW而 Mat 是 HWC这个顺序不换模型看到的就是一堆乱序像素。参数方面最需要关注的是三个地方。第一input_w 和 input_h 必须和 onnx 模型输入维度一致不能想当然当 224×224有的模型导出成 160×160写死就会收到维度报错。第二除以 255 只适用于训练时仅做缩放归一化的模型如果训练代码里用了 mean/std要换成下面的逐通道计算// 使用 ImageNet mean/std 时替换上面的 convertTo 步骤 const float mean[3] {0.485f, 0.456f, 0.406f}; const float std[3] {0.229f, 0.224f, 0.225f}; for (int p 0; p hw; p) { for (int c 0; c 3; c) { tensor[c * hw p] (src[p * 3 c] / 255.0f - mean[c]) / std[c]; } }第三如果你的 onnx 文件在导出时把归一化一并打包进了模型外部再做一遍除以 255 就是双重归一化结果会整个错掉。判断方法很简单用 Python 的 onnxruntime 直接喂 0~255 的原始像素运行一次如果输出概率正常说明归一化在模型内部如果输出异常说明需要外部做。这一步验证花五分钟能省掉后面一整天的排查。4. 封装 Ort::Session线程配置、推理循环与 Top-K 输出解析4.1 Session 初始化线程数与优化级别预处理搞定后推理侧的核心就是一个 Ort::Session。CPU 部署时Session 的配置直接决定你能跑多快。两个最值得调的项一是图优化级别二是线程数。#include onnxruntime_cxx_api.h // 全局只保留一个 Env 和 Session不要在推理循环里反复创建 Ort::Env env(ORT_LOGGING_LEVEL_WARNING, yolo11-cls); Ort::SessionOptions opts; opts.SetIntraOpNumThreads(4); // 先给物理核心数跑压测再微调 opts.SetGraphOptimizationLevel(ORT_ENABLE_ALL); opts.SetLogSeverityLevel(3); // 3 表示只打印 error Ort::Session session(env, model_path.c_str(), opts);逻辑说明ORT_ENABLE_ALL 是 CPU 推理提速贡献最大的开关ONNX Runtime 会做算子融合、常量折叠这类优化对分类模型这种计算图结构收益很明显。线程数我一般先取物理核心数因为超线程的逻辑核在 CPU 推理里并不总是加分项线程开太满反而会因为上下文切换变慢性能压测后如果有余量再逐个往上试探。Session 建好之后第一件事是读出模型的输入输出名称和输入 shape而不是硬编码// 输入输出名称与维度全部从模型动态读取 auto alloc Ort::AllocatorWithDefaultOptions(); std::string input_name session.GetInputNameAllocated(0, alloc).get(); std::string output_name session.GetOutputNameAllocated(0, alloc).get(); auto shape_info session.GetInputTypeInfo(0).GetTensorTypeAndShapeInfo(); std::vectorint64_t in_dims shape_info.GetShape(); // 分类模型 NCHWin_dims[0] 是 batchin_dims[1] 是通道in_dims[2] 是高in_dims[3] 是宽 int input_h static_castint(in_dims[2]); int input_w static_castint(in_dims[3]);这里有个版本坑需要提醒不同版本的 ONNX Runtime C API 里GetInputName 的返回值签名改过几次老版本返回 char*新版本返回带析构的 AllocatedStringPtr。编译报错时不要硬改代码直接看头文件里这个函数的声明按返回值类型调整即可。动态读取名字和 shape 的意义在于换模型时这个函数不需要改这是后面「直接替换模型」能成立的基础。4.2 推理执行与 Top-K 输出解析推理循环本身代码不多但有两个生命周期细节容易踩。一是 CreateTensor 默认不拷贝数据tensor vector 必须活得比 Run 调用久二是 Run 返回的 output_tensors 一旦析构scores 指针就悬空了复制出来再解析比较稳妥。// 组装输入张量data 来自 preprocess_to_tensor 的结果 std::vectorint64_t input_shape{1, 3, input_h, input_w}; Ort::MemoryInfo mem_info Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat( mem_info, tensor.data(), static_castsize_t(tensor.size()), input_shape.data(), input_shape.size()); // 推理 std::vectorconst char* input_names{input_name.c_str()}; std::vectorconst char* output_names{output_name.c_str()}; auto output_tensors session.Run( Ort::RunOptions{nullptr}, input_names.data(), input_tensor, 1, output_names.data(), 1); // 从输出 value 中读取类别数不要写死 1000 const float* scores output_tensors[0].GetTensorDatafloat(); auto out_shape output_tensors[0].GetTensorTypeAndShapeInfo().GetShape(); int class_count static_castint(out_shape[1]);分类模型的输出通常是一个一维概率数组形状可能是 1×N 或 1×N×1×1但数据在内存里就是连续排列的 N 个 float。class_count 从 out_shape 里取换模型后类别数变了这段代码依然成立。接着按需求打印 Top-1 或 Top-5// Top-K 排序输出K 取 5 足够覆盖绝大多数场景 std::vectorstd::pairfloat, int ranked; ranked.reserve(static_castsize_t(class_count)); for (int i 0; i class_count; i) { ranked.emplace_back(scores[i], i); } const int k std::min(5, class_count); std::partial_sort(ranked.begin(), ranked.begin() k, ranked.end(), [](const auto a, const auto b) { return a.first b.first; }); for (int i 0; i k; i) { std::cout labels[ranked[i].second] ranked[i].first std::endl; }逻辑说明partial_sort 只排前 k 个比全量 sort 少一点开销分类类别数常见 1000 或几千时差距不大但养成好习惯。labels 在这里就是第 2 章说的类别名列表按下标取名字。这里还有一个认知盲区值得单独提一下如果模型输出层的 softmax 被导出工具去掉了scores 就不是 0~1 的概率而是大小悬殊的 logits。这种事我遇过argmax 的结果是对的但你想拿置信度做阈值过滤时发现数值漫无边际。判断办法很简单打印一张图的全部 scores如果都在 0~1 且加起来接近 1模型带 softmax否则就是裸 logits需要自己在输出侧补一个 softmax或者只用 argmax不依赖置信度。5. 直接替换模型5 个必调参数与常见问题排查5.1 替换模型的最小改动清单哪些动、哪些别动标题里写「可直接替换模型」它在工程上意味着换一个 onnx 文件、换一份 labels.txt、代码重新编译一次甚至不用编译推理逻辑继续可用。但「直接替换」不等于什么都不用核对。换模型文件时我会按下面的清单过一遍短路任何一个都可能让结果翻车。核对项检查方法处理动作输入尺寸读 GetInputTypeInfo().GetShape()同步改 preprocess 的 input_w/input_h输入名/输出名打印 GetInputNameAllocated 结果不同导出工具可能改名更新配置归一化方式Python 端跑一张图对照/255 和 mean/std 二选一labels 顺序与训练时的 class index 对照换模型必须同步换 labels.txt输出是否带 softmax看 scores 是否都在 0~1不带就补 softmax或只看 argmax这五项里输入尺寸和标签顺序是最容易出事的。输入尺寸写死 224 是重灾区换成 160×160 的模型后代码不会崩但预处理把图缩成 224再被模型内部强改成 160等于经历了两次缩放精度掉得悄无声息。我一般会把模型配置抽成一个函数程序启动时动态读一遍从根上消灭写死问题// 从 ONNX 模型读取输入输出配置替换模型后自动适配 void read_model_config(Ort::Session session, int in_w, int in_h, std::string in_name, std::string out_name) { auto alloc Ort::AllocatorWithDefaultOptions(); in_name session.GetInputNameAllocated(0, alloc).get(); out_name session.GetOutputNameAllocated(0, alloc).get(); auto shape session.GetInputTypeInfo(0) .GetTensorTypeAndShapeInfo().GetShape(); in_h static_castint(shape[2]); in_w static_castint(shape[3]); }哪些不用动BGR2RGB 通道转换保留YOLO11 系训练统一是 RGBSessionOptions 的线程和图优化配置保留换模型不影响argmax/Top-K 逻辑保留它从模型动态读取类别数天然适配。这样限定「动与不动」的边界替换工作就变成改配置、跑验证两步而不是把推理代码重写一遍。5.2 高频问题与排查记录现象、原因、解决下面五条是我在这类项目中实际踩过或帮人救过的场景按现象到解决的顺序整理供替换模型时对照。第一条替换模型后所有类别概率均匀分布top-1 置信度极低。 现象1000 类模型的概率每类都在 0.001 附近模型像在「猜」。 原因两个最可能的诱因一是外部做了除以 255但新模型导出时内部已经包含归一化等于重复归一化二是通道顺序反了BGR 直接喂给了 RGB 训练出来的模型。 解决在 preprocess 里加两个开关一个控制是否做外部归一化一个控制是否交换 R/B 通道四种组合各跑一张标准图和 Python 端结果对比命中哪一组就锁定哪一组配置。第二条中文路径或带空格的图片读出来是空 Mat。 现象cv::imread 返回空图后续推理直接崩溃。 原因Windows 上 OpenCV 的 imread 对本地编码敏感中文路径是经典失效场景。 解决改用 ifstream 读字节流再交给 imdecode 解码绕开路径编码问题std::ifstream f(path, std::ios::binary); std::vectorchar buf((std::istreambuf_iteratorchar(f)), std::istreambuf_iteratorchar()); cv::Mat img cv::imdecode(buf, cv::IMREAD_COLOR);第三条Debug 配置下编译通过运行到 Session 构造时崩溃。 现象exe 一启动就崩断点定位在创建 Session 附近。 原因onnxruntime 和 OpenCV 的二进制是 Release 版工程却用 Debug 构建。MSVC 下 Debug 和 Release 使用不同的 CRT 运行时混连不报编译错跑起来就崩。 解决整个工程统一 Release /MD 编译这是 Windows 依赖链上最典型的坑没有之一。想调试逻辑就用 Release 加调试符号别混配依赖库。第四条分类类别是对的但置信度要么全是负数要么接近 1。 现象argmax 结果与预期一致但 scores 范围异常。 原因模型导出时去掉了 softmax输出的是 logits没有映射成概率。 解决先打印原始 scores 范围判断模型输出类型需要置信度阈值时在输出侧自己加一个 softmax 转换。第五条换模型后程序访问越界或输出乱码。 现象偶尔崩溃偶尔输出类别名明显对不上。 原因旧 labels.txt 是旧模型的类别顺序新模型类别数或排序变了下标索引错位。 解决labels.txt 和新模型一起替换并在代码里加一道防线如果 labels.size() 和 class_count 不相等直接终止并提示避免静默错下去。旧模型 class index 是从 0 还是从 1 开始也一并确认个别导出工具会把这个细节改掉。6. CPU 推理再快一点线程设置、模型尺寸与结果验证技巧6.1 线程数与输入尺寸的取舍CPU 推理提速第一优先级是测量而不是猜。把 Run 单独包一层循环 100 次算平均耗时线程数分别试 1、2、4、8画一条耗时曲线。多数机器上物理核心数附近是拐点再往上收益极小甚至倒退。线程数是典型的「看着像玄学实际是测量问题」一测便知。第二优先级是输入尺寸。分类任务对分辨率敏感度低于检测如果业务允许把模型重新导出一版 160×160 的 onnx推理耗时通常能比 224×224 降低一半左右。注意这步要在导出时做ONNX 的输入 shape 是静态的不能在推理代码里临时改尺寸。批量跑图时不要盲目开大 batchCPU 推理对 batch 的加速远不如 GPU多线程处理多张图往往更实在。6.2 与 Python 端对拍验证换模型后最可靠的验收方式是拿一张标准图和 Python 推理结果对拍。Python 端的 onnxruntime 和 C 端用的是同一个引擎预处理一致的话输出应该接近完全一致import onnxruntime as ort import numpy as np from PIL import Image sess ort.InferenceSession(models/classifier.onnx, providers[CPUExecutionProvider]) # PIL 读出来就是 RGB不要再翻转通道 img np.array(Image.open(test.jpg).resize((224, 224))).astype(np.float32) / 255.0 x img.transpose(2, 0, 1)[None] # HWC - NCHW out sess.run(None, {sess.get_inputs()[0].name: x})[0] print(out.flatten()[:10])C 端打印同一个文件的 top-5两边类别顺序一致、概率差异在千分位以内说明预处理和推理链路都对了。如果概率对不上优先怀疑预处理这是这条链路里唯一由人控制的环节。我现在的习惯是新接手的这类项目第一件事永远是打印输入输出名称和 shape把它们写进配置文件而不是在代码里写死。曾经因为写死 1000 类换了个 3 类模型直接越界崩溃那次之后所有分类部署项目都改成动态读取。希望你部署时少走这段弯路希望帮到你。本文还有配套的精品资源点击获取