ARTICLE DETAIL

资讯详情

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

YOLOv11-CLS图像分类C++部署实战:ONNX Runtime推理链路完整指南

YOLOv11-CLS图像分类C++部署实战:ONNX Runtime推理链路完整指南 简介一份以C和ONNX Runtime为核心的YOLOv11-CLS图像分类模型部署文档面向具备C与深度学习基础、从事计算机视觉或图像识别项目开发的工程师。文档为单个docx文件约37KB结构紧凑依次介绍项目背景、数据准备、完整C代码示例、代码逐行解释、运行步骤与项目总结同时列出相关参考资料和注意事项便于查阅。推理流程覆盖模型加载、输入图像预处理、置信度阈值动态调整、类别统计与最终分类结果输出代码中预设输入尺寸640、置信度阈值0.5等常用配置并采用随机裁剪、旋转、亮度调整等数据增强手段提升模型泛化能力。通过该文档可重点掌握ONNX Runtime的模型加载、图片预处理、API调用等关键操作以及如何用OpenCV完成图像缩放和归一化。文档还讨论了量化剪枝、可视化工具、RESTful API等后续优化方向为扩展部署架构提供思路。目前已有1508人学习下载适合需要快速搭建本地化图像分类环境用于自动化图像检测、实时视频流监控或安防系统分类任务的技术人员。1. YOLOv11-CLS 为什么要用 C 接 ONNX Runtime一次部署失败换来的一课做过一次图像分类模型部署你就知道真正卡人的往往不是模型本身而是“训练时好好的一进 C 就不知道哪里出了问题”。YOLOv11-CLS 是 Ultralytics 在 YOLO11 系列里提供的图像分类模型社区习惯叫 YOLOv11-CLS官网仓库里文件名通常是 yolo11n-cls.pt 这种格式。很多人以为分类模型比检测简单不用配 anchor、不用解码候选框直接拿输出取 argmax 就行结果在 C 侧被预处理不一致、输出解析错误、内存访问违例折腾到怀疑人生。用 C 和 ONNX Runtime 部署 YOLOv11-CLS核心不是“把 onnx 文件加载起来跑一次”而是把图像预处理、模型推理、输出后处理这三段完整地封装成可复用的推理链路并且保证 C 的结果和 Python 验证结果逐位对齐。这个方案最适合两类人一是要把分类器嵌进现有 C 服务端或桌面程序的开发者二是在边缘设备上做图像分类、需要脱离 Python 环境独立运行的嵌入式工程师。下面按我自己的落地路径来拆先导出并验证 ONNX再搭 C 工程最后把坑和验证手段都摊开讲。2. 从 .pt 到 .onnx导出、形状确认和 Python 端先行验证2.1 导出 ONNX 的两种方式命令行和 Python APIUltralytics 的模型导出本质上是把 PyTorch 权重转换成 ONNX 计算图这一步不需要自己写 torch.onnx.export官方封装已经处理好了动态轴、算符映射这些细节。我一般直接用 Python API因为可以顺手打印导出的文件路径和输入形状方便后面 C 端对照。命令行的方式适合批量操作yolo export modelyolo11n-cls.pt formatonnx imgsz224跑完会在同目录生成 yolo11n-cls.onnx。from ultralytics import YOLO # 加载分类模型权重n 是 nano 版本资源紧张的设备也能跑 model YOLO(yolo11n-cls.pt) # imgsz 必须和训练时一致分类模型默认 224 # opset 用 12 以上ONNX Runtime 全系列版本都支持 model.export( formatonnx, imgsz224, opset12, simplifyTrue, # 用 onnxslim 做计算图精简 dynamicFalse # 分类模型固定输入尺寸不要开动态轴 )这里的几个参数值得解释一下。simplify会合并一些冗余算符比如把连续的 reshape 和 transpose 压缩掉文件更小C 端加载也更快如果遇到导出报错可以先把 simplify 关掉确认是不是精简环节出的问题。dynamicFalse对分类模型来说是合理的因为输入永远是[1, 3, H, W]不需要像检测那样支持任意尺寸。输出文件是单输出的形状为[1, class_num]这一点和检测模型的多输出完全不同C 端后处理要按这个形状来写。2.2 ONNX 文件里到底有什么用 onnx 库确认输入输出拿到 onnx 文件后别急着写 C先用 onnx 库把输入输出的名称、形状、数据类型打出来。这一步非常关键因为 ONNX Runtime C API 要求你按名称取输入输出名称写错一个字符就报错。而且不同版本的 Ultralytics 导出的输入节点名可能是images也可能被简化成input必须以实际文件为准不能靠猜。import onnx model onnx.load(yolo11n-cls.onnx) graph model.graph for inp in graph.input: # 打印输入名称比如 images以及形状 [1,3,224,224] print(input:, inp.name, [d.dim_value for d in inp.type.tensor_type.shape.dim]) for out in graph.output: # 打印输出名称和形状分类模型通常是 [1,1000] # 如果不是 1000说明你训练时的类别数不是 ImageNet 的 1000 print(output:, out.name, [d.dim_value for d in out.type.tensor_type.shape.dim])输入名称和输出名称要记下来后面 C 里会用session.GetInputName动态获取但代码里最好还是留一个常量字符串作为 fallback 对照。另外确认一下输出形状里的第二个维度比如1000这就是你最终的类别总数。如果这里是[1, 5]说明你用了一个 5 类的自定义数据集后处理的 Top-5 就要按实际类别数去截断不能写死。2.3 Python onnxruntime 先跑通预处理、推理、softmax、argmaxC 部署最怕没有参照物。所以我一直坚持任何模型进 C 之前先用 Python onnxruntime 跑通一条完整链路把输入预处理、推理、后处理写成一个可重复执行的脚本这个脚本的输出就是后面 C 工程必须对齐的“黄金参照”。不要拿 Ultralytics 的 predict 结果当参照因为 predict 内部还包含它自己的预处理细节直接和 onnxruntime 输出比反而容易对不上。import cv2 import numpy as np import onnxruntime as ort # 读取图片注意 cv2 读出来是 BGR后面必须转 RGB img cv2.imread(test_cat.jpg) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (224, 224), interpolationcv2.INTER_LINEAR) # Ultralytics 分类训练的预处理只做 /255 归一化不做 mean/std img img.astype(np.float32) / 255.0 # HWC 转 CHW再补 batch 维度 img np.transpose(img, (2, 0, 1)) input_tensor np.expand_dims(img, axis0).astype(np.float32) # ONNX Runtime 推理 sess ort.InferenceSession(yolo11n-cls.onnx, providers[CPUExecutionProvider]) input_name sess.get_inputs()[0].name output_name sess.get_outputs()[0].name logits sess.run([output_name], {input_name: input_tensor})[0] logits logits[0] # 去掉 batch 维 # 输出是 logits不是概率必须自己做 softmax exp_logits np.exp(logits - np.max(logits)) probs exp_logits / np.sum(exp_logits) top5_idx np.argsort(probs)[::-1][:5] for idx in top5_idx: # idx 就是类别 ID映射到类别名后可以直接对比 C 结果 print(idx, probs[idx])这段代码里有几个必须讲清楚的细节。cv2.resize默认插值算法是 INTER_LINEAR而 Ultralytics 训练时用的是它自己封装的 Resize两者对同一张图的计算结果在像素级上可能有细微差异但一般不会导致 Top-1 变化。真正会导致结果翻车的是忘了 BGR 转 RGB那会让模型把猫识别成狗。输出端为什么一定要自己 softmax因为 ONNX 导出的分类模型输出的是未归一化的 logits直接取 argmax 虽然类别 ID 一般不会错但当你需要拿概率做阈值过滤时logits 是不可比的。3. 用 C 搭出一个可复现的 ONNX Runtime 部署工程3.1 工程结构和依赖Visual Studio CMakeONNX Runtime 怎么放C 侧我常用的组合是 Visual Studio 2022 生成器加 CMake开发时用 VSCode 配 CMake Tools 插件调试两者共用同一套 CMakeLists。ONNX Runtime 官方不提供 vcpkg 里的一等支持最常见做法是直接从 GitHub Releases 下载对应平台的压缩包解压后得到一个包含 include、lib、bin 三个目录的结构。Windows 下如果不想自己管 DLL也可以改用 NuGet 包 Microsoft.ML.OnnxRuntime它会自动把 dll 拷到输出目录但版本更新频率高团队协作时版本锁定要额外注意。onnxruntime-win-x64/ ├── include/ │ └── onnxruntime_cxx_api.h # C API 头文件 ├── lib/ │ └── onnxruntime.lib # 链接时用到的导入库 └── bin/ └── onnxruntime.dll # 运行时必须拷到 exe 旁边我一般把解压后的目录放在第三方库目录下通过环境变量或 CMake 变量传给工程不提交到代码仓库。下面是 CMakeLists 的关键部分重点是把 include 目录和 lib 目录指对同时把 onnxruntime.dll 在构建后自动复制到输出目录否则一运行就报“找不到 onnxruntime.dll”。cmake_minimum_required(VERSION 3.20) project(yolo11_cls_deploy CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 这里通过 -DORT_DIRxxx 传入 ONNX Runtime 解压路径 set(ORT_DIR $ENV{ORT_DIR} CACHE PATH ONNX Runtime root) # OpenCV 用于图像读取和预处理Windows 下用预编译包即可 find_package(OpenCV REQUIRED) add_executable(yolo11_cls_deploy main.cpp classifier.cpp ) target_include_directories(yolo11_cls_deploy PRIVATE ${ORT_DIR}/include ${OpenCV_INCLUDE_DIRS} ) target_link_libraries(yolo11_cls_deploy PRIVATE ${ORT_DIR}/lib/onnxruntime.lib ${OpenCV_LIBS} ) # 把 dll 复制到 exe 目录避免运行时找不到 add_custom_command(TARGET yolo11_cls_deploy POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different ${ORT_DIR}/bin/onnxruntime.dll $TARGET_FILE_DIR:yolo11_cls_deploy )这里有个容易踩的细节ONNX Runtime 的 C API 要求 C17低于这个标准编译会报一堆模板错误。另外如果你用 MinGW 而不是 MSVC链接时常常遇到符号不一致的问题因为官方发布的 lib 是针对 MSVC 生成的导入库MinGW 下最好改用动态加载方式或者换用 MSVC 工具链别在编译期死磕。3.2 推理类加载 session、预处理、推理、softmax 一起封装写好一个分类任务绝不能在主程序里散落各种 ONNX Runtime API 调用。我习惯把加载、预处理、推理、后处理全部封装进一个类主程序只负责读图、调用接口、打印结果。这样既方便单元测试也方便以后换模型时只改一处。下面是这个类的核心实现注释里标明了每个环节对应的 Python 验证逻辑。// classifier.h #include opencv2/opencv.hpp #include onnxruntime_cxx_api.h #include string #include vector class Classifier { public: explicit Classifier(const std::string model_path); // 输入 BGR 图片输出按概率降序排列的 类别ID, 概率 std::vectorstd::pairint, float Predict(const cv::Mat bgr_img, int topk 5); private: Ort::Env env_{ORT_LOGGING_LEVEL_WARNING}; Ort::SessionOptions session_options_; Ort::Session session_; std::vectorstd::string input_names_; std::vectorstd::string output_names_; int input_h_ 224; int input_w_ 224; };// classifier.cpp #include classifier.h #include algorithm Classifier::Classifier(const std::string model_path) : env_(ORT_LOGGING_LEVEL_WARNING), session_(env_, model_path.c_str(), session_options_) { // 日志级别设 WARNING避免每次推理都刷 INFO 日志 session_options_.SetIntraOpNumThreads(4); session_options_.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); // 一次性读取输入输出名称不要在每次推理时重复查询 Ort::AllocatorWithDefaultOptions allocator; size_t num_inputs session_.GetInputCount(); for (size_t i 0; i num_inputs; i) { auto name session_.GetInputNameAllocated(i, allocator); input_names_.push_back(name.get()); } size_t num_outputs session_.GetOutputCount(); for (size_t i 0; i num_outputs; i) { auto name session_.GetOutputNameAllocated(i, allocator); output_names_.push_back(name.get()); } } std::vectorstd::pairint, float Classifier::Predict(const cv::Mat bgr_img, int topk) { // 预处理BGR 转 RGB、缩放、转 float、除以 255、HWC 转 CHW cv::Mat rgb_img; cv::cvtColor(bgr_img, rgb_img, cv::COLOR_BGR2RGB); cv::Mat resized; cv::resize(rgb_img, resized, cv::Size(input_w_, input_h_), 0, 0, cv::INTER_LINEAR); cv::Mat float_img; resized.convertTo(float_img, CV_32FC3, 1.0 / 255.0); // 构建 batch1 的连续内存张量注意 ONNX Runtime 要求输入是连续内存 std::vectorfloat input_tensor(input_w_ * input_h_ * 3); int channel_size input_w_ * input_h_; for (int c 0; c 3; c) { for (int i 0; i input_h_; i) { for (int j 0; j input_w_; j) { // OpenCV Mat 的内存布局是 HWC这里转成 CHW input_tensor[c * channel_size i * input_w_ j] float_img.atcv::Vec3f(i, j)[c]; } } } // 构造输入输出张量 std::vectorint64_t input_shape {1, 3, input_h_, input_w_}; Ort::MemoryInfo memory_info Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor_value Ort::Value::CreateTensorfloat( memory_info, input_tensor.data(), input_tensor.size(), input_shape.data(), input_shape.size()); // 推理输出是 logits auto output_tensors session_.Run( Ort::RunOptions{nullptr}, input_names_.data(), input_tensor_value, 1, output_names_.data(), output_names_.size()); float* logits output_tensors[0].GetTensorMutableDatafloat(); size_t num_classes output_tensors[0].GetTensorTypeAndShapeInfo() .GetShape()[1]; // softmax用最大值平移保证数值稳定性 float max_val *std::max_element(logits, logits num_classes); std::vectorfloat probs(num_classes); float sum 0.0f; for (size_t i 0; i num_classes; i) { probs[i] std::exp(logits[i] - max_val); sum probs[i]; } for (size_t i 0; i num_classes; i) { probs[i] / sum; } // 取 topk注意只排前 k 个不需要完整排序 std::vectorstd::pairint, float results; for (size_t i 0; i num_classes; i) { results.emplace_back(static_castint(i), probs[i]); } std::partial_sort(results.begin(), results.begin() topk, results.end(), [](const auto a, const auto b) { return a.second b.second; }); results.resize(topk); return results; }几个参数值得单独说。SetIntraOpNumThreads(4)控制的是算子内部并行线程数不是总线程数如果你的程序同时有多个推理请求这个值不要设置成 CPU 核心数否则每个请求都把所有核占满线程切换开销会吃掉吞吐。ORT_ENABLE_ALL是图形优化等级ONNX Runtime 会尝试融合算符比如把 Conv 和 ReLU 合并对延迟有明显收益。预处理部分convertTo的缩放系数 1.0/255.0 是直接对应 Python 里的astype(np.float32) / 255.0两边必须一致这是最容易出精度偏差的位置。3.3 主程序读图、跑推理、打印 Top-5主程序要解决两件事一是把图片喂进去二是把结果按可读的方式打出来。如果你有类别名文件就按 ID 查表没有就先用数字 ID但务必在日志里输出完整概率分布方便调试时和 Python 输出对照。下面是一个最小可运行的主程序。// main.cpp #include classifier.h #include iostream int main(int argc, char* argv[]) { if (argc 3) { std::cerr Usage: yolo11_cls_deploy model.onnx image.jpg std::endl; return 1; } try { Classifier classifier(argv[1]); cv::Mat img cv::imread(argv[2]); if (img.empty()) { std::cerr Failed to load image: argv[2] std::endl; return 1; } auto results classifier.Predict(img, 5); for (size_t i 0; i results.size(); i) { std::cout top (i 1) : class results[i].first prob results[i].second std::endl; } } catch (const Ort::Exception e) { // ONNX Runtime 的异常都带错误码这里打印出来便于定位 std::cerr ONNX Runtime error: e.what() std::endl; return 1; } return 0; }跑通标准不是“能运行”而是“输出和第 2 章的 Python 结果完全一致”同一个模型文件、同一张图Top-5 的类别 ID 和概率排序必须一模一样。如果类别 ID 一致但概率在小数点后三位有差异属于浮点累加误差可以接受如果概率差异超过 0.01几乎可以断定预处理或代码逻辑里有 bug别急着往下走先回头排查。4. 部署中常见的五个坑现象、原因、解决4.1 输出值加起来不是 1第一个碰到的坑往往是输出的数值范围看起来不对比如最大值是 3.2最小值是 -2.1加起来也不等于 1。原因在于 ONNX 导出的分类模型输出的是 logits不是概率你需要自己套 softmax。千万不要在训练时或者 Python 验证时习惯了直接拿输出做 argmax就认为 C 里也可以这么做。argmax 结果一般不会错但一旦涉及阈值判断、多标签过滤logits 和概率完全是两套尺度。解决办法是在 C 的推理类里显式实现 softmax并且用 Python onnxruntime 的输出做一次数值对照。4.2 C 精度和 Python 对不齐而且差得离谱这是部署分类模型时最典型的问题。现象是同一张图Python 端 Top-1 是正确的C 端 Top-1 变成了另一个完全不相关的类别。原因几乎都出在预处理上最常见的是忘了 BGR 转 RGB其次是缩放尺寸和插值算法不一致。OpenCV 的cv::imread读出来是 BGR而模型训练时用的是 RGB不转换的话整个颜色通道全乱了模型表现必然雪崩。另外检查一下是否把缩放系数写成了 1.0 而不是 1.0/255.0或者误加了 ImageNet 的 mean/std 归一化——Ultralytics 分类模型训练时只做除以 255不做均值减法。解决方法是把 C 预处理后的 float 张量 dump 成二进制文件和 Python 端预处理后的 numpy 数组对比逐元素检查差异。4.3 崩溃 C0000005 / Access Violation而且只在发布版出现如果你从 C# 或其他语言通过 P/Invoke 调 C 推理库更容易撞上 Access Violation C0000005但纯 C 程序里也会出现。现象是 Debug 版正常Release 版偶尔崩溃或者连续跑几百张图后必崩。原因往往是Ort::Value的生命周期管理出了问题你把Session定义在局部作用域里或者输入 tensor 底层的std::vector在session.Run还没结束时就被释放了。ONNX Runtime 的Run是异步语义输出张量内部可能仍引用输入内存块。解决办法是把Session作为类成员常驻输入 tensor 用成员变量持有Run返回后立刻把输出数据拷贝到自己的容器里不要长时间保留Ort::Value。4.4 onnxruntime.dll 找不到或者报“无法定位程序输入点”换了一台电脑运行程序直接起不来报找不到 onnxruntime.dll或者能起来但加载 session 时弹窗说无法定位程序输入点。前者好解决把 release 包里的 onnxruntime.dll 复制到 exe 同级目录或者加入系统 PATH后者比较隐蔽是你用的 onnxruntime.dll 和你链接的 onnxruntime.lib 不是同一个版本的产物。如果你是从 NuGet 更新了包但没重新生成导入库或者自己手动混用了两个版本的二进制就会出现这种情况。解决办法是确保 lib 和 dll 来自同一个压缩包或同一个 NuGet 版本另外留意平台x86 的 exe 加载 x64 的 dll 也会报类似错误把整个工程切到 x64 再编译。4.5 换了个 CPU 后推理速度反而变慢CPU 占用率却不高我把模型从 i7 台式机搬到一台至强服务器上一跑延迟从 8ms 变成了 25ms而且 CPU 占用率只有 30% 左右看起来像“没用满”。原因是 ONNX Runtime 的算子内核会针对不同 CPU 指令集做分发比如 AVX-512 和 AVX2 的性能差异很大你在自己机器上编译时开了-marchnative或者 ONNX Runtime 里做了 CPU 特性检测换机器后指令集能力不同走的算子实现路径也不同。另外一个容易被忽略的点是 BIOS 或虚拟机配置把 CPU 频率锁死了或者程序被限制在单 NUMA 节点上。解决办法是先查 CPU 支持的指令集和当前频率再用SetIntraOpNumThreads配合Ort::SessionOptions的SetEnableCpuMemArena做一轮参数扫描找到这台机器上延迟最小的线程数。5. 上生产前的验证手段与进阶技巧5.1 延迟和吞吐怎么测才可信分类模型的性能评估最忌讳跑一次就报数字。CPU 推理受频率调度影响极大同一台机器不同时刻测得的结果可能相差 30%。我习惯在程序里写三段计时分别统计预处理、推理、后处理耗时并且在正式测试前先跑 20 张图“热身”让 CPU 频率和缓存状态稳定下来然后再连续测 200 张取 P50 和 P95 两个指标。P95 比平均值更能反映真实体验因为用户能感知到的是尾延迟。// 计时代码片段配合 Classifier 使用 auto t0 std::chrono::high_resolution_clock::now(); auto results classifier.Predict(img, 5); auto t1 std::chrono::high_resolution_clock::now(); double elapsed_ms std::chrono::durationdouble, std::milli(t1 - t0).count();如果必须把预处理时间和推理时间分开就在Predict内部埋时间戳外部只取总耗时。这样测出来的数据可以支持你做决策瓶颈在预处理说明图像解码和缩放环节要优化比如换成cv::dnn的缩放或者 GPU 上的 resize瓶颈在推理说明要考虑模型压缩或换硬件。5.2 多线程并发一个 session 还是多个 sessionONNX Runtime 的Ort::Session不是完全线程安全的并发调用同一个 session 的Run在多数版本里是允许的但内部存在锁竞争并发一上来延迟会明显恶化。我的建议是如果每个请求都很重、QPS 要求高就用线程池每个线程持有一个独立的Session加载同一个 onnx 文件如果只是偶尔来一个请求单 session 就够了。注意多个 session 会各自持有模型参数的内存拷贝nano 模型还好x 模型可能多占用几百 MB 内存要根据硬件量力而行。线程数可以从 1 开始往上加观察延迟不再下降反而上升时那个点就是这台机器的饱和并发。5.3 模型压缩方向怎么选如果 CPU 推理延迟仍然压不下来优先考虑模型量化而不是换更小的模型。ONNX Runtime 支持动态量化但直接用Ort::SessionOptions的量化 API 往往精度损失不小更稳的做法是在导出环节用 onnx 的 QDQ 格式配合校准数据集做静态量化让 ONNX Runtime 在加载时使用整数内核。FP16 在 CPU 上没有收益在支持 FP16 的 GPU 上才有意义。如果你是在 RK3588 这类板卡上部署ONNX Runtime 通常不是最终选择更常见的是把 ONNX 转成 RKNN 再跑但前面在 C 里验证过的预处理逻辑和后处理逻辑可以原样复用这也是先在本机把 C 部署跑通的价值所在。这些年做模型部署我最大的教训就是“永远不要相信两边的输出应该差不多”。只要 C 和 Python 的结果有差异我就去逐元素对比输入张量而不是先去怀疑模型文件或 ONNX Runtime 本身。预处理对了推理框架一般不会辜负你。希望你也能养成这个习惯希望帮到你。本文还有配套的精品资源点击获取
返回列表