C++部署YOLO11分类模型:ONNX Runtime CPU推理全流程解析 简介面向需要在Windows CPU环境部署YOLO11图像分类模型的开发者这份资料提供了一套纯C实现的ONNX Runtime推理方案无需GPU即可完成模型加载、预处理、推理与后处理全流程。工程代码支持直接替换自定义ONNX模型兼容YOLOv8/v11等架构并针对多线程进行加速优化实测Intel i5-12400F单帧推理约120ms适合边缘设备、性能验证及C项目集成等场景。包体共365个文件以C头文件hpp/h为主配合可执行程序、动态链接库、CMake构建脚本及ONNX模型文件压缩包约363MB。同时附有环境配置指南、API接口说明与常见问题排查文档源码目录结构清晰便于直接移植或二次开发。目前已有104人学习适合希望绕过Python依赖、快速验证模型CPU端性能或将其嵌入现有C系统的开发者。1. cppYolo11OnnxPredict.zip 解决什么问题Windows 无 GPU 机器上的 yolo11 图像分类落地给已有的 Windows 桌面程序加一个「看一眼图片就给出类别」的能力最省事的路径就是标题里这套组合模型用 yolo11 分类头训练好的权重导出成 ONNX再由 C 调用 ONNX Runtime 在纯 CPU 上完成推理。这个 zip 工程的价值在于它把 PyTorch 侧的事做完之后Windows 侧只剩一个 C 可执行程序和几个资源文件不带 Python 运行时、不挑显卡放到工控机和普通办公机上都能跑。适合两类人一类是想给 C 产品快速加图像分类能力的开发另一类是已经在 Python 里训好模型、正头疼怎么交付给客户现场的人。下面从导出 ONNX 开始一路讲到 C 推理链路的每个参数和替换模型时的坑。2. 先把导出链路立住从 yolo11 网络结构到 pytorch 转 onnx 的关键参数2.1 yolo11 分类网络结构与 CPU 部署的匹配点yolo11 网络结构里分类任务-cls和检测任务共享骨干网络的思路但去掉了检测头里一大堆输出分支最后接的是全局平均池化加一个全连接分类头。这意味着它的输出就是一个形状为[batch, num_classes]的二维张量不需要做 NMS、不需要解析 anchor推理后处理可以简化为一次 softmax。这个结构对 CPU 部署非常友好。检测模型在 CPU 上跑瓶颈通常不在骨干网络而在多尺度输出和候选框后处理分类模型把这些都省了一次前向就完事。以 yolo11n-cls 这种轻量级权重为例224×224 输入在普通 i5 上单线程推理大约几十毫秒多线程可以压到十几毫秒完全能满足图片分类这种非实时的交付场景。如果换更大规模的 yolo11m-cls 甚至 x-clsCPU 推理就会明显吃力这时候要考虑降低输入尺寸或者压缩到 ONNX 后做量化。还有一个容易被忽略的匹配点分类模型在训练时用的是 ImageNet 那套归一化参数mean 0.485/0.456/0.406std 0.229/0.224/0.225而不是检测常用的 0-1 缩放。C 侧复刻预处理时必须和训练侧严格一致这是后面所有精度问题的根源。2.2 导出 ONNXopset、动态轴和输入输出命名拿到训练好的.pt权重后第一步是把它转成 ONNX。这里不建议直接用 ultralytics 自带的 export 接口虽然它也能导出但输入输出名字和一些细节自己控制不了后面 C 侧对接时容易来回改。我一般直接在导出脚本里用torch.onnx.export把名字、opset、动态轴都写清楚# export_cls_onnx.py —— 把 yolo11 分类模型导出为 ONNX # 依赖pip install ultralytics onnxruntime import torch from ultralytics import YOLO model YOLO(yolo11n-cls.pt) model.model.eval() # 分类头输出 logits不是 softmax 后的概率 dummy torch.randn(1, 3, 224, 224) torch.onnx.export( model.model, # 注意是 model.model绕开 ultralytics 的导出封装 dummy, yolo11n-cls.onnx, input_names[images], # 这个名字后面 C 代码里要一字不差地复用 output_names[output0], opset_version17, # 覆盖 CPU 上常见算子足够了 dynamic_axes{images: {0: batch}, output0: {0: batch}}, ) print(export done)执行完python export_cls_onnx.py后先别急着写 C用 ONNX Runtime 的 Python 接口读一遍模型确认输入输出的形状和名字# check_model.py —— 打印 ONNX 的输入输出信息 import onnxruntime as ort sess ort.InferenceSession(yolo11n-cls.onnx, providers[CPUExecutionProvider]) for i in sess.get_inputs(): print(input:, i.name, i.shape, i.type) for o in sess.get_outputs(): print(output:, o.name, o.shape, o.type)opset 参数说明opset 17 在较新的 ONNX Runtime 上都能无脑支持如果导出时报某些算子不支持优先降到 15 或 16而不是去升级 ONNX Runtime。dynamic_axes 里只把 batch 维度设为动态就够了宽高保持静态这样 C 侧不用每次重新分配推理内存CPU 上的速度和内存占用都可控。注意导出的模型内部如果混入了 float64 节点比如某些自定义 loss 留下的痕迹Session 创建时会直接报类型错误导出前用model.model.float()统一转一次。2.3 ONNX Runtime 的 CPU 执行链线程数、优化级别与首帧预热模型落地到 C 之前先理解 ONNX Runtime 在 CPU 上是怎么跑的。它默认的 CPUExecutionProvider 会把图做常量折叠、算子融合再分派给底层算子库。对单模型推理来说真正要调的只有几个 SessionOptions 参数// init_session.cpp —— Session 初始化的固定写法 #include onnxruntime_cxx_api.h Ort::Env env(ORT_LOGGING_LEVEL_WARNING, yolo11-cls); Ort::SessionOptions so; so.SetGraphOptimizationLevel(GRAPH_OPTIMIZATION_LEVEL_LEVEL3); // 全量图优化 so.SetIntraOpNumThreads(4); // 每个算子内部并行线程数 so.SetInterOpNumThreads(1); // 单模型单张图不需要算子间并行参数怎么定我给出常用的取值和理由参数推荐值说明SetGraphOptimizationLevelLEVEL3等价于 ORT_ENABLE_ALL包含算子融合、常量折叠CPU 上收益明显SetIntraOpNumThreads物理核数的一半到全部工控机建议 4注意 ONNX Runtime 会抢占 CPU影响同机 GUISetInterOpNumThreads1单模型场景设大了反而带来线程切换开销SetMemPattern默认保持默认即可CPU 内存带宽是瓶颈时再考虑关闭这里有个实战经验第一次调用session.Run时ORT 才真正执行图优化和算子编译所以第一张图的耗时往往是后面的几倍。工程里应该在Init结束时用一张全灰的 dummy 图跑一次预热把几百毫秒的初始化开销从用户感知的首次识别里挪走。这个坑在后面的避坑章节还会出现一次因为它最隐蔽。3. C 推理代码怎么搭Session 初始化、预处理与 Top-5 后处理3.1 工程骨架目录、依赖和 Session 初始化一个能交付的 cpp 工程目录里至少要有这几样predictor.h/predictor.cpp放推理类main.cpp做调用演示models/yolo11n-cls.onnx放模型labels.txt放类别名还有onnxruntime.dll和opencv_world*.dll跟着 exe 走。编译链接时引入onnxruntime.lib运行时 dll 放 exe 同目录这是最不容易出问题的布局。推理类的头文件我一般这样写// predictor.h —— 分类推理类的对外接口 #pragma once #include onnxruntime_cxx_api.h #include opencv2/opencv.hpp #include vector #include string class ClsPredictor { public: bool Init(const std::string model_path, const std::string labels_path); int Predict(const cv::Mat bgr_img, float* confidence); private: std::vectorfloat Preprocess(const cv::Mat bgr_img); std::vectorstd::pairint, float TopKSoftmax(const float* logits, int n, int k); Ort::Env env{nullptr}; Ort::Session session{nullptr}; std::vectorstd::string labels_; int input_h_ 224; int input_w_ 224; bool use_letterbox_ true; float mean_[3] {0.485f, 0.456f, 0.406f}; float std_[3] {0.229f, 0.224f, 0.225f}; };Init 里除了建 Session还要把输入尺寸从模型里读出来而不是写死这是后面「可直接替换模型」的前提// predictor.cpp —— Session 初始化与输入尺寸读取 #include fstream #include numeric bool ClsPredictor::Init(const std::string model_path, const std::string labels_path) { env Ort::Env(ORT_LOGGING_LEVEL_WARNING, yolo11-cls); Ort::SessionOptions so; so.SetGraphOptimizationLevel(GRAPH_OPTIMIZATION_LEVEL_LEVEL3); so.SetIntraOpNumThreads(4); session Ort::Session(env, model_path.c_str(), so); // 从模型元信息里读取输入尺寸不依赖写死的 224 auto in_info session.GetInputTypeInfo(0); auto in_shape in_info.GetTensorTypeAndShapeInfo().GetShape(); if (in_shape.size() 4) { // NCHW input_h_ static_castint(in_shape[2]); input_w_ static_castint(in_shape[3]); } // labels 文件每一行一个类别名行号就是类别 id std::ifstream f(labels_path); std::string line; while (std::getline(f, line)) { if (!line.empty()) labels_.push_back(line); } if (labels_.empty()) return false; return true; }两个容易踩的点补充一下。第一GetInputShape返回的宽高在导出时如果用了动态轴可能是 -1这时要回退到成员变量默认值不能直接拿去分配内存。第二Windows 上模型路径带中文时const char*的 Session 构造重载按 UTF-8 解析会失败改用它提供的wchar_t*重载传宽字符串或者在上游先把路径转码。3.2 预处理letterbox、BGR 转 RGB 与归一化的一次性实现预处理是整条链路里翻车率最高的环节而且翻车方式不是报错是精度悄悄掉。yolo11 分类训练时用的是「等比缩放 补边」还是「直接拉伸」取决于训练脚本里的 transforms如果训练时用了 letterbox推理时改成直接 resizeTop-1 会掉得让你怀疑模型是坏的。C 侧我把两个开关都做出来用配置文件控制// predictor.cpp —— 预处理letterbox、通道转换、归一化 std::vectorfloat ClsPredictor::Preprocess(const cv::Mat bgr_img) { cv::Mat rgb; cv::cvtColor(bgr_img, rgb, cv::COLOR_BGR2RGB); cv::Mat canvas; if (use_letterbox_) { float scale std::min((float)input_w_ / rgb.cols, (float)input_h_ / rgb.rows); int nw (int)std::round(rgb.cols * scale); int nh (int)std::round(rgb.rows * scale); cv::Mat resized; cv::resize(rgb, resized, cv::Size(nw, nh), 0, 0, cv::INTER_LINEAR); canvas cv::Mat::zeros(input_h_, input_w_, CV_8UC3); resized.copyTo(canvas(cv::Rect((input_w_ - nw) / 2, (input_h_ - nh) / 2, nw, nh))); } else { cv::resize(rgb, canvas, cv::Size(input_w_, input_h_), 0, 0, cv::INTER_LINEAR); } // HWC 转 CHW同时做归一化一次遍历完成 std::vectorfloat tensor(3 * input_h_ * input_w_); for (int c 0; c 3; c) { for (int i 0; i input_h_; i) { for (int j 0; j input_w_; j) { cv::Vec3b p canvas.atcv::Vec3b(i, j); float v p[c]; // 因为前面转了 RGBc0 是红通道 tensor[c * input_h_ * input_w_ i * input_w_ j] (v / 255.0f - mean_[c]) / std_[c]; } } } return tensor; }这段代码的逻辑说明先把 OpenCV 读进来的 BGR 转成 RGB 亮序再做空间缩放最后在填充张量时按c * h * w i * w j的下标把数据摆成 ONNX 期望的 NCHW 布局。mean 和 std 的取值必须和导出的训练脚本一致如果你的模型是用 0-1 归一化训练的把 mean 设为 0、std 设为 1 即可。三重循环在 224×224 下大约 15 万次浮点运算耗时可以忽略但如果你要压到极致可以改成按行指针顺序扫描并配合霓虹指令性能瓶颈不在这里。3.3 后处理softmax 与 Top-5 的稳定写法模型输出的是 logits不是概率所以要先做 softmax再取前 k 个类别。数值稳定性上先减最大值再 exp可以避免大 logits 时 exp 溢出// predictor.cpp —— softmax Top-5 std::vectorstd::pairint, float ClsPredictor::TopKSoftmax(const float* logits, int n, int k) { float maxv *std::max_element(logits, logits n); std::vectorfloat expv(n); float sum 0.f; for (int i 0; i n; i) { expv[i] std::exp(logits[i] - maxv); sum expv[i]; } std::vectorstd::pairint, float items(n); for (int i 0; i n; i) items[i] {i, expv[i] / sum}; std::partial_sort(items.begin(), items.begin() k, items.end(), [](const auto a, const auto b) { return a.second b.second; }); items.resize(k); return items; }std::partial_sort只保证前 k 个有序复杂度是 O(n log k)对 1000 类输出来说比全排序省不少时间。拿到结果后用items[0].first去查labels_就是类别名。整个 Predict 函数把前面三件事串起来调用session.Run时输入张量的名字必须和导出时保持一致我们导出用的是 images 和 output0int ClsPredictor::Predict(const cv::Mat bgr_img, float* confidence) { auto input Preprocess(bgr_img); Ort::MemoryInfo mi Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); std::vectorint64_t shape{1, 3, input_h_, input_w_}; Ort::Value tensor Ort::Value::CreateTensorfloat( mi, input.data(), input.size(), shape.data(), shape.size()); const char* in_names[] {images}; const char* out_names[] {output0}; auto outputs session.Run(Ort::RunOptions{nullptr}, in_names, tensor, 1, out_names, 1); float* logits outputs[0].GetTensorMutableDatafloat(); auto shape_out outputs[0].GetTensorTypeAndShapeInfo().GetShape(); int n_class (int)shape_out[1]; auto top TopKSoftmax(logits, n_class, 5); *confidence top[0].second; return top[0].first; }注意input这个 vector 的生命周期要覆盖到session.Run返回CreateTensor不会拷贝数据只是持有指针。如果你在函数里把 vector 提前析构了会读到野指针这种偶发崩溃很难查。4. 可直接替换模型换 ONNX 之前的三个校验步骤与两处联动改动4.1 替换前校验用 Python 打印 ONNX 的输入输出信息标题里「可直接替换模型」是这套代码的卖点但「直接」是有边界的只要新模型满足输入名images、输出名output0、布局 NCHW、类别数与 labels 文件行数一致就能做到只换文件不编译。替换前先跑一遍上一章写过的check_model.py把下面三点对着看输入名是否叫images形状是不是[batch, 3, 224, 224]或[-1, 3, 224, 224]输出名是否叫output0形状第二维是不是你要的类别数输入类型是不是tensor(float)如果显示double说明导出时模型权重没转 float32。任何一个不满足处理方式都不同输入名不对就在新模型重新导出时改名或者在 C 上下文里把in_names改掉类别数不对就去检查 labels 文件类型是 double 的话必须回 PyTorch 重新导出不能在 C 侧强行转。4.2 两处联动改动输入尺寸读取与 labels 行数对齐新模型如果输入尺寸不是 224比如用了 320代码里的处理已经做了Init 时从模型元信息读取input_h_和input_w_预处理和后处理自动跟随。但有一个前提导出时不要把宽高也设置成动态轴只动 batch 就行。如果新模型尺寸超出 CPU 能接受的延迟预算优先改的是训练侧的输入尺寸而不是代码。labels 行数对齐是「能跑但结果全错」的高发区。比如原模型是 1000 类 ImageNet你换了 10 类的微调模型忘了更新 labels.txt代码不报错但 Top-1 返回的类别 id 会指向错误的类别名。Init 里读 labels 时我建议加一道校验读完后如果labels_.size()和 Session 输出的第二维对不上直接 Init 失败并打印日志把静默错误变成显式错误。这个习惯能省掉后面排障的好几个小时。除了行数还有预处理一致性。不同训练脚本对前处理的设定可能完全不同有人对输入先做 0-1 缩放有人直接减均值除方差有人用 letterbox有人用 resize。替换模型后第一件事不是看 C 跑得快不快而是拿一张训练集里没见过的图把 Python 侧 ONNX Runtime 的结果和 C 的结果并排打出来确认 Top-5 一致再谈性能。4.3 替换后的基准验证把 Python 输出当标尺我每次换模型都会先跑一个基准脚本把模型的参考输出保存下来当作回归测试的标尺。这个脚本和 C 共用同一份预处理逻辑用 numpy 实现# verify_baseline.py —— 生成参考输出用来验收 C 侧 import numpy as np, cv2 import onnxruntime as ort def preprocess(path, size224): img cv2.imread(path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (size, size)) x img.astype(np.float32) / 255.0 mean np.array([0.485, 0.456, 0.406], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.float32) x (x - mean) / std return x.transpose(2, 0, 1)[None] sess ort.InferenceSession(new_model.onnx, providers[CPUExecutionProvider]) x preprocess(test_sample.jpg) ref sess.run([output0], {images: x})[0] np.save(ref_output.npy, ref) print(top5:, np.sort(ref[0])[::-1][:5])把ref_output.npy里的 top-5 和 C 打印的 top-5 对比前 3 个类别 id 一致、概率差在 1e-2 以内就算验收通过。如果 C 和 Python 结果对不上基本不用怀疑推理引擎先查预处理通道顺序、letterbox 开关、mean/std 三件事逐一对。这个脚本记得留在工程里它是后面做 INT8 量化时唯一可信的评估工具。5. 常见问题与排查CPU 部署 yolo11 分类最容易翻车的 5 个点5.1 现象一精度掉得离谱——预处理不一致现象模型在 Python 里验证 Top-1 有 98%C 部署后只有 60%而且网络结构、模型文件都是同一份。原因预处理不一致。最常见的是 OpenCV 读图默认 BGR而模型训练时是 RGB漏了cvtColor其次是训练用了 letterboxC 侧直接 resize再就是归一化参数写错。这三种错误都不会报错只会让精度悄悄崩。解决用 4.3 的 verify_baseline.py 做二分定位。把 Python 侧预处理改成和 C 完全一致如果 Python 精度也掉下来说明问题在前处理然后把 C 侧逐步改成和训练一致。我遇到最多的场景是别人给我的模型是用官方 ImageNet 均值训练的而我看他代码以为是 0-1参数一对问题立现。5.2 现象二C 与 Python 结果差一个类别现象Top-1 相同Top-5 顺序略有不同或者概率相差几个百分点主类别恰好卡在边界上。原因float32 累加顺序不同加上 resize 插值在 OpenCV C 和 Python 底层实现有细微差异导致 logits 有 1e-4 量级的浮动。这是正常的不是 bug。但如果概率差超过 1e-2就要回查预处理是否逐像素一致。解决先做浮点容忍度评估——两个结果概率差小于 1e-3 就放行差异大就把 CPU 线程数临时设成 1、固定INTER_LINEAR排除算子并行带来的乱序求和。还有一个经验分类边界样本本来就接近两类概率平分这时候 Top-1 变化不代表部署出错看 Top-5 是否稳定。5.3 现象三Session 创建抛 BadCast / opset 异常现象session.Run或 Session 构造时报Bad cast、Unsupported opset version、Input does not have type float之类错误。原因三种可能——模型用了比你本地 ONNX Runtime 更高的 opset模型图里有自定义算子或 float64 节点onnxruntime.dll 版本和 lib 版本不匹配。最后一种在 Windows 上最常见dll 更新了但代码链接的还是旧 lib。解决先用 Python 的 ORT 加载同一个模型能加载就不是模型问题报 opset 就把导出的opset_version降到 15 或 16 重新导出报类型就回 PyTorchmodel.model model.model.float()后重新导。dll/lib 版本不一致去 NuGet 拉同一个版本号搭配引用不要混。5.4 现象四第一张图特别慢卡顿感明显现象程序启动后第一次识别要 300~800ms后续每张只要 20ms用户觉得首帧卡。原因ONNX Runtime 的图优化和算子分配是懒加载的第一次Run才真正执行。再加上 Windows Defender 首次扫描新 dll 的开销首帧慢是双重叠加。解决Init 结束前用一张 224×224 的全灰图跑一次 Predict预热 Session。这不算浪费——整个初始化成本从「用户等待第一次识别」转移到「程序启动过程」体验好得多。另外把 onnxruntime.dll 和 exe 放同目录、排除杀软扫描目录也能去掉一部分首帧抖动。5.5 现象五中文路径读不到模型或图片现象模型路径或图片路径含中文时Session 创建失败或imread返回空路径全是英文时一切正常。原因Ort::Session的const char*重载按 UTF-8 解释路径Windows 下传进去的是本地代码页GBK字节流就找不到文件。OpenCV 的imread同理它不接受宽字符。解决模型路径用wchar_t*重载传宽字符串图片读取改用std::ifstream把文件读成字节再cv::imdecode。我在工程里统一加了一个PathToWstring的封装所有文件操作都走它中英文路径通吃。这个坑在交付给国内客户时几乎必踩因为客户现场的文件路径大概率带中文。6. 验证与提速Top-1 对齐之后再做 INT8 量化的近路6.1 性能基线怎么测跑通不代表能交付我习惯先测一个稳定的性能基线再谈优化。测法是用std::chrono::steady_clock包住Predict对同一张图循环 100 次跳过前 10 次预热取后 90 次的 P95 和平均值。注意别在 Debug 配置下测ORT 在 Debug 下性能会打折扣Release /O2才是真实水平。我自己的经验数据yolo11n-cls224×2244 线程i5-8500 上 P95 大约在 15~25ms换 1000 类输出时 softmax 那部分多出来的开销可以忽略。如果 P95 超过 50ms先看线程数再考虑降输入尺寸或换更小的权重。这种单模型场景用不到 cpp std execution 那套并行设施线程数交给 ORT 内部调度更省心。6.2 从 FP32 到 INT8一条近路和它的检查清单CPU 上最直接的提速手段是做 ONNX 动态量化把权重从 float32 压到 int8模型体积缩小到四分之一推理常有 1.5~2 倍收益。做法极其简单# quantize_int8.py —— 动态量化不需要校准数据 from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic(yolo11n-cls.onnx, yolo11n-cls-int8.onnx, weight_typeQuantType.QInt8)量化完成后把它当普通模型替换进 C 工程代码一行不用改。但有两个前提必须确认一是量化前先跑 6.1 的基线量化后跑同样的验证Top-1 掉点超过 0.5% 就回退二是如果你要同时对激活值做量化降低内存带宽压力需要准备几十张代表图做校准动态量化不做校准静态量化才需要别混淆。我自己的交付习惯是工程目录里永远放一个 verify_baseline.py、一份 ref_output.npy、一份 labels.txt 和 FP32/INT8 两个模型文件。换模型、换量化、换输入尺寸都先把参考输出重算一遍再进 C 验收。这个习惯帮我挡掉了好几次「客户现场报错、回来一查是模型放错」的血泪教训。希望帮到你把你的第一版 yolo11 分类部署尽快跑在客户那台旧电脑上。本文还有配套的精品资源点击获取