
简介这份资源面向具备一定 C 与深度学习基础的研发人员和工程师聚焦计算机视觉与图像识别领域的本地化部署需求讲解如何用 C 结合 ONNX Runtime 框架部署 YOLOv11-CLS 图像分类模型。内容覆盖数据准备、模型加载、图片预处理到调用 API 获取分类置信度输出的完整链路并涉及置信度阈值调整、类别统计输出等可配置项可应用于自动化图像检测、实时视频流监控与安防分类等场景。资源包为 1 个 docx 文档约 37KB内含完整示例代码、逐行代码解释、运行步骤与项目总结目录结构清晰便于按模块查阅。目前已有 1508 人学习。读者可借此掌握 ONNX Runtime 的 C 推理接口用法、图像预处理与结果解析技巧并了解量化、剪枝、可视化工具与 RESTful API 等后续优化方向快速搭建高效本地化分类部署架构。1. 从 PyTorch 权重到 C 推理YOLOv11-CLS 部署到底在解决什么问题训练完一个 YOLOv11-CLS 图像分类模型best.pt在 Python 里跑得挺好但一到产线就卡住了目标机器上没装 PyTorch装了的推理延迟又下不来C 主程序想调它还得跨语言。这就是部署要解决的事——把训练产物变成一台普通 x64 机器上双击就能跑、不依赖 Python 环境的推理程序。ONNX Runtime 在这里扮演的角色是推理引擎它吃 ONNX 格式的模型提供 C APICPU 上单张 224×224 图片推理通常在几毫秒到几十毫秒量级具体取决于模型规模和线程配置。这套方案适合做桌面端质检、嵌入式上位机、工业相机配套软件的人也适合想把分类模型塞进已有 C 工程的开发者。整条链路是PyTorch 导出 ONNX → C 侧用 ONNX Runtime 加载 → 前处理对齐 → 推理 → 后处理取 top-k。下面按这条链路拆开讲。2. 导出 ONNX 与 C 工程搭建把模型和环境先立住2.1 YOLOv11-CLS 导出 ONNX 的关键参数YOLOv11 的分类模型yolo11n-cls.pt这类导出方式和检测模型不同分类模型输出的是类别 logits没有 NMS 那套后处理。导出时最容易翻车的是输入尺寸和动态轴。常见做法是固定 batch1、固定输入 224×224这样 ONNX Runtime 侧最省心。from ultralytics import YOLO # 加载训练好的分类权重 model YOLO(runs/classify/train/weights/best.pt) # 导出 ONNX固定 batch1输入 224x224opset 12 model.export( formatonnx, imgsz224, # 必须和训练/推理前处理一致 batch1, # 固定 batch避免动态轴带来的 shape 推断问题 opset12, # 12 兼容性好ONNX Runtime 1.16 都支持 simplifyTrue, # 用 onnx-simplifier 去掉冗余算子 dynamicFalse, # 关闭动态轴C 侧 shape 固定 )导出后得到best.onnx。这里几个参数值得说清楚imgsz224决定了模型输入张量形状是[1,3,224,224]C 前处理必须把图片 resize 到这个尺寸否则 shape 对不上直接报错opset12是稳妥选择opset 太高比如 17在旧版 ONNX Runtime 上会加载失败dynamicFalse意味着 batch 和空间维度都固定换来的是推理时不需要处理动态 shape性能也更稳。导出完建议用onnx.checker验一下模型完整性再用 Netron 看一眼输入输出名字后面 C 里要按名字取。2.2 C 工程依赖ONNX Runtime 与 OpenCV 怎么配C 侧需要两个库ONNX Runtime推理和 OpenCV读图 resize。ONNX Runtime 官方提供预编译包Windows 下下载onnxruntime-win-x64-1.x.x.zipLinux 下下载对应.tgz。解压后目录结构是include/头文件和lib/库文件。OpenCV 用 4.x 即可负责imread和resize。工程组织建议这样project/ ├── include/ │ └── classifier.h ├── src/ │ ├── main.cpp │ └── classifier.cpp ├── models/ │ └── best.onnx ├── third_party/ │ ├── onnxruntime/ # 解压后的 ORT │ └── opencv/ # OpenCV 安装目录 └── CMakeLists.txtCMake 里链接 ORT 和 OpenCVcmake_minimum_required(VERSION 3.15) project(yolo11_cls_deploy CXX) set(CMAKE_CXX_STANDARD 17) # ONNX Runtime set(ORT_ROOT ${CMAKE_SOURCE_DIR}/third_party/onnxruntime) include_directories(${ORT_ROOT}/include) # OpenCV find_package(OpenCV REQUIRED) add_executable(classify src/main.cpp src/classifier.cpp) target_include_directories(classify PRIVATE include) target_link_libraries(classify ${ORT_ROOT}/lib/onnxruntime.lib # Linux 下换成 libonnxruntime.so ${OpenCV_LIBS} )Windows 下链接的是.lib运行时需要把onnxruntime.dll拷到 exe 同目录Linux 下链接.so运行时用LD_LIBRARY_PATH或rpath指过去。这一步的坑在于ORT 的 Debug 和 Release 库不能混用MSVC 下如果工程是 Debug 而链接了 Release 版 ORT会出现一堆链接错误。统一用 Release 编译最省事。提示ONNX Runtime 版本和 opset 有对应关系导出时用的 opset 不要超过 ORT 支持的上限否则加载模型时报Unsupported model IR version。3. C 推理核心会话创建、前处理与 top-k 后处理3.1 创建 Ort::Session 与输入输出绑定ONNX Runtime 的 C API 核心对象是Ort::Env、Ort::Session、Ort::SessionOptions。Env全局一个即可Session加载模型。下面是一个封装好的分类器类骨架// classifier.h #pragma once #include onnxruntime_cxx_api.h #include opencv2/opencv.hpp #include vector #include string class Classifier { public: Classifier(const std::string model_path, int num_threads 4); // 返回 top-k 的 {类别索引, 置信度} std::vectorstd::pairint, float predict(const cv::Mat bgr, int topk 5); private: Ort::Env env_; Ort::SessionOptions session_options_; Ort::Session session_; std::vectorstd::string input_names_; std::vectorstd::string output_names_; std::vectorconst char* input_names_c_; std::vectorconst char* output_names_c_; int input_h_ 224; int input_w_ 224; };// classifier.cpp #include classifier.h Classifier::Classifier(const std::string model_path, int num_threads) : env_(ORT_LOGGING_LEVEL_WARNING, yolo11_cls), session_(nullptr) { // 线程数CPU 推理时设成物理核心数通常最优 session_options_.SetIntraOpNumThreads(num_threads); session_options_.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); // Windows 下用宽字符路径Linux 直接用 char* #ifdef _WIN32 std::wstring wpath(model_path.begin(), model_path.end()); session_ Ort::Session(env_, wpath.c_str(), session_options_); #else session_ Ort::Session(env_, model_path.c_str(), session_options_); #endif Ort::AllocatorWithDefaultOptions allocator; // 取输入名 size_t num_input session_.GetInputCount(); for (size_t i 0; i num_input; i) { auto name session_.GetInputNameAllocated(i, allocator); input_names_.push_back(name.get()); } // 取输出名 size_t num_output session_.GetOutputCount(); for (size_t i 0; i num_output; i) { auto name session_.GetOutputNameAllocated(i, allocator); output_names_.push_back(name.get()); } for (auto n : input_names_) input_names_c_.push_back(n.c_str()); for (auto n : output_names_) output_names_c_.push_back(n.c_str()); }这里SetIntraOpNumThreads控制算子内部并行线程数CPU 上一般设成物理核心数ORT_ENABLE_ALL开启图优化包括算子融合对分类模型提升明显。输入输出名字从 session 里动态取避免硬编码——不同版本 ultralytics 导出的名字可能不一样硬编码是血泪教训。3.2 前处理letterbox 还是直接 resize分类模型和检测模型不同检测用 letterbox 保持长宽比分类模型通常直接 resize 到 224×224 就行因为训练时 ultralytics 的分类 pipeline 也是直接 resize除非你训练时改了。前处理三步BGR→RGB、归一化到 [0,1]、按 ImageNet 均值方差标准化。std::vectorfloat Classifier::preprocess(const cv::Mat bgr) { cv::Mat rgb, resized, float_img; cv::cvtColor(bgr, rgb, cv::COLOR_BGR2RGB); cv::resize(rgb, resized, cv::Size(input_w_, input_h_)); resized.convertTo(float_img, CV_32FC3, 1.0 / 255.0); // ImageNet 均值方差必须和训练时一致 const float mean[3] {0.485f, 0.456f, 0.406f}; const float std_[3] {0.229f, 0.224f, 0.225f}; // HWC - CHW std::vectorfloat tensor(3 * input_h_ * input_w_); for (int c 0; c 3; c) { for (int h 0; h input_h_; h) { for (int w 0; w input_w_; w) { float v float_img.atcv::Vec3f(h, w)[c]; tensor[c * input_h_ * input_w_ h * input_w_ w] (v - mean[c]) / std_[c]; } } } return tensor; }均值方差这三个数是最容易踩的坑ultralytics 分类模型默认用的是 ImageNet 统计量但如果你训练时自定义了mean/std这里必须跟着改否则精度掉得莫名其妙。另外 HWC→CHW 的循环顺序别写反写反了模型输出全是乱的但不会报错属于玄学级 bug。3.3 推理与 top-k 输出推理部分把 tensor 包成Ort::Value跑session_.Run拿到输出后做 softmax 取 top-k。std::vectorstd::pairint, float Classifier::predict( const cv::Mat bgr, int topk) { auto input_tensor preprocess(bgr); // 输入 shape: [1, 3, 224, 224] std::arrayint64_t, 4 input_shape{1, 3, input_h_, input_w_}; auto memory_info Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_ort Ort::Value::CreateTensorfloat( memory_info, input_tensor.data(), input_tensor.size(), input_shape.data(), input_shape.size()); auto outputs session_.Run( Ort::RunOptions{nullptr}, input_names_c_.data(), input_ort, 1, output_names_c_.data(), output_names_c_.size()); // 分类模型输出 shape: [1, num_classes] float* logits outputs[0].GetTensorMutableDatafloat(); auto out_shape outputs[0].GetTensorTypeAndShapeInfo().GetShape(); int num_classes static_castint(out_shape[1]); // softmax float max_logit *std::max_element(logits, logits num_classes); float sum_exp 0.f; std::vectorfloat probs(num_classes); for (int i 0; i num_classes; i) { probs[i] std::exp(logits[i] - max_logit); sum_exp probs[i]; } for (int i 0; i num_classes; i) probs[i] / sum_exp; // top-k std::vectorint idx(num_classes); std::iota(idx.begin(), idx.end(), 0); std::partial_sort(idx.begin(), idx.begin() topk, idx.end(), [](int a, int b) { return probs[a] probs[b]; }); std::vectorstd::pairint, float result; for (int i 0; i topk; i) result.emplace_back(idx[i], probs[idx[i]]); return result; }Ort::Value::CreateTensor用的是外部内存input_tensor的生命周期要覆盖整个Run调用别在传进去之前就析构了。softmax 里减max_logit是防指数溢出标准操作。partial_sort比全排序快top-5 场景够用。输出 shape 取out_shape[1]拿类别数别写死。4. 避坑与排查部署 YOLOv11-CLS 时最容易翻车的 5 个点4.1 现象模型加载报Invalid GraphProto原因opset 或 IR 版本不匹配导出时 opset 设太高或者 ONNX Runtime 版本太旧加载时报Invalid GraphProto或Unsupported model IR version。解决导出时把 opset 降到 12或者升级 ONNX Runtime 到 1.16 以上。用onnxruntimePython 包先验一下能不能加载再上 C能省很多时间。4.2 现象推理结果全是同一类原因前处理归一化参数不对模型输出恒定指向某一类置信度还很高。九成是均值方差和训练时不一致或者 BGR/RGB 通道顺序搞反了。排查方法用同一张图Python 侧跑model.predict和 C 侧跑对比 logits。如果 Python 对 C 错逐项检查 resize 尺寸、通道顺序、归一化。这个坑最隐蔽因为程序不报错只是结果错。4.3 现象Debug 编译链接报一堆LNK2019原因ORT 库的 Debug/Release 混用MSVC 下工程是 Debug链接的却是 Release 版onnxruntime.lib符号对不上。解决统一 Release或者去 ORT 发布页找 Debug 版库。另一个常见原因是没把onnxruntime.dll放到 exe 同目录运行时报找不到 dll。4.4 现象单张推理正常批量跑内存持续上涨原因Ort::Value 未及时释放循环里反复创建Ort::Value和Ort::Session没让它们出作用域内存只涨不降。解决Session全局只创建一次Ort::Value放在循环内让它自动析构。如果用了Ort::AllocatorWithDefaultOptions拿名字注意GetInputNameAllocated返回的是AllocatedStringPtr要接住别泄漏。4.5 现象CPU 推理比 Python 还慢原因线程数和图优化没配默认SessionOptions线程数是 0由 ORT 自决图优化等级也可能是ORT_DISABLE_ALL。解决显式设SetIntraOpNumThreads(物理核心数)和ORT_ENABLE_ALL。另外确认编译的是 ReleaseDebug 版 ORT 慢好几倍是常态。如果还慢用onnxruntime_perf_test工具单独测模型本身排除前处理开销。5. 进阶技巧用 IoBinding 减少拷贝与多线程推理的取舍5.1 IoBinding把输入输出绑到固定设备内存默认session_.Run每次都会做输入输出的内存拷贝。如果做视频流推理每秒几十帧拷贝开销不可忽略。Ort::IoBinding可以把输入输出绑定到预分配的内存上减少重复分配和拷贝。Ort::IoBinding binding(session_); auto memory_info Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); // 预分配输入 buffer复用 std::vectorfloat input_buffer(3 * 224 * 224); std::arrayint64_t, 4 shape{1, 3, 224, 224}; Ort::Value input_tensor Ort::Value::CreateTensorfloat( memory_info, input_buffer.data(), input_buffer.size(), shape.data(), shape.size()); binding.BindInput(input_names_c_[0], input_tensor); binding.BindOutput(output_names_c_[0], memory_info); binding.SynchronizeInputs(); session_.Run(Ort::RunOptions{nullptr}, binding); binding.SynchronizeOutputs();BindOutput不指定 buffer 时ORT 会自己分配输出内存SynchronizeOutputs之后可以取。如果输出 shape 固定也可以自己预分配输出 buffer 绑上去进一步省分配。IoBinding 的收益在批量或高频推理时明显单张偶尔跑一次没必要上。5.2 多线程推理一个 Session 还是多个 Session多线程场景下有两种做法一个Session多线程共享或者每个线程一个Session。ONNX Runtime 的Session::Run是线程安全的可以多线程共享一个 SessionORT 内部会调度。但共享时SetIntraOpNumThreads的线程池是共用的线程多了会争抢。经验做法如果并发度不高比如 4 线程以内共享一个 SessionIntraOpNumThreads设成 1让外层线程并行如果并发度高每个线程独立 Session各自设IntraOpNumThreads为 1避免线程池嵌套。// 每个线程独立 Session 的写法 std::vectorstd::thread workers; for (int t 0; t num_threads; t) { workers.emplace_back([, t]() { Classifier cls(models/best.onnx, 1); // 每线程一个 Session for (auto img : thread_images[t]) { auto res cls.predict(img, 5); // 处理结果 } }); } for (auto w : workers) w.join();5.3 验证部署是否正确的三个检查点第一用同一张图Python 和 C 的 top-1 类别必须一致top-5 置信度差异在 1e-3 以内。第二用一批测试集图片跑 C统计准确率和 Python 侧对比掉点超过 1% 就要查前处理。第三用onnxruntime_perf_test测纯模型推理耗时和 C 端到端耗时对比差值就是前处理和后处理的开销如果差值过大优化前处理比如用 OpenCV 的dnn::blobFromImage替代手写循环。我自己的习惯是每次换模型或换 ORT 版本先跑一遍这三个检查点再上产线。部署这事模型导出和前处理对齐占八成工作量推理本身反而是最简单的。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。