1. 项目概述最近在折腾一个图像分类的项目需要把最新的YOLOv11-CLS模型集成到一个C的桌面应用里。一开始考虑过直接用OpenCV的DNN模块但实测下来发现在追求极致推理速度的场景下ONNX Runtime的表现往往更胜一筹。特别是当你的应用需要同时兼容CPU和GPU或者部署环境比较“干净”不想引入太多重型依赖时ONNX Runtime的轻量化和高性能优势就体现出来了。这个项目就是基于这个思路用C和ONNX Runtime框架完整走一遍YOLOv11-CLS图像分类模型的部署流程。整个过程会涉及到环境搭建、模型准备、预处理对齐、推理执行以及结果后处理我会把每个环节的细节和踩过的坑都捋清楚。无论你是想给自己的C应用加个AI视觉能力还是单纯想学习一下现代AI模型在边缘端的部署实战这篇内容应该都能给你提供一份可以直接“抄作业”的参考。2. 环境准备与工具链选型部署的第一步就是把“战场”打扫干净准备好趁手的工具。这里的选择会直接影响到后续开发的效率和最终部署的便捷性。2.1 开发环境与编译工具我的主力开发环境是Windows集成开发环境IDE用的是Visual Studio 2019。选择VS2019主要是考虑到其对新旧C标准的良好支持以及成熟的CMake集成能力社区资源也丰富遇到问题容易找到解决方案。当然如果你习惯用VS2022或者纯粹的CLion MinGW也完全没问题核心思路是相通的。编译工具链上我强烈推荐使用CMake。它能让你的项目摆脱对特定IDE的依赖实现跨平台Windows/Linux/macOS的构建。我的CMake版本是3.24.3。这里有个小技巧在Windows上你可以通过Visual Studio自带的“开发者命令提示符”来使用CMake和MSVC编译器这样环境变量都是配置好的非常省心。你也可以选择使用Ninja作为生成器构建速度会更快。2.2 核心库的安装与配置接下来是三个核心库OpenCV、ONNX Runtime和YOLOv11模型本身。OpenCV我用的版本是4.8.0。它主要负责图像的读取、缩放、颜色空间转换等预处理操作以及最终结果的可视化。安装OpenCV最稳妥的方式是从官网下载源码自己用CMake编译。编译时记得勾选BUILD_opencv_world这个选项它会将所有模块打包成一个大的opencv_world480.dll版本号可能不同和对应的lib文件。这样做的好处是在部署时你只需要携带一个动态库管理起来非常方便避免了链接时缺少某个模块库的烦恼。编译完成后你会得到include头文件夹、lib库文件夹和bin包含dll文件夹记下它们的路径。ONNX Runtime这是本次部署的推理引擎核心版本为1.16.3。ONNX Runtime提供了预编译的二进制包这是最推荐的方式。你需要根据你的平台和需求选择下载CPU版本例如onnxruntime-win-x64-1.16.3.zip。如果你的应用只运行在CPU上这个就够了。GPU版本CUDA例如onnxruntime-win-x64-gpu-1.16.3.zip。如果你想利用NVIDIA GPU加速需要下载这个并且确保系统已安装对应版本的CUDA和cuDNN。GPU版本DirectML针对Windows平台如果你有AMD/Intel/NVIDIA的显卡但不想装CUDA可以用这个后端它通过DirectX接口调用GPU。下载后解压里面同样有include、lib、bin目录。我们需要的就是头文件和链接库。YOLOv11-CLS模型你需要从Ultralytics的官方仓库或相关渠道获取YOLOv11的预训练分类模型权重.pt文件然后将其导出为ONNX格式。可以使用官方的Python脚本from ultralytics import YOLO model YOLO(yolo11n-cls.pt) # 假设你下载的是nano版本的分类模型 model.export(formatonnx, imgsz224) # 指定导出为ONNX输入尺寸为224x224导出的yolo11n-cls.onnx文件就是我们需要部署的模型。同时记得准备一个class_names.txt文件里面按行存放分类的类别名称这个文件需要和模型训练时使用的类别顺序一致。2.3 项目工程结构规划一个清晰的项目结构能让后续的开发和维护事半功倍。我建议的目录结构如下yolov11_cls_cpp_deploy/ ├── CMakeLists.txt # 项目根CMake配置文件 ├── src/ │ ├── main.cpp # 主程序入口 │ ├── inference.h # 推理封装类的头文件 │ └── inference.cpp # 推理封装类的实现 ├── models/ │ ├── yolo11n-cls.onnx # ONNX模型文件 │ └── class_names.txt # 类别标签文件 ├── lib/ # 放置第三方库的lib文件 (可选也可用系统路径) ├── include/ # 放置第三方库的头文件 (可选) └── images/ # 用于测试的图片在CMakeLists.txt中我们将使用find_package或直接指定路径的方式来定位OpenCV和ONNX Runtime。对于ONNX Runtime由于它不提供标准的CMake配置文件我们通常采用指定include_directories和link_directories的方式。3. 模型推理类的封装与设计直接在主函数里堆砌所有推理代码会显得很乱也不利于复用。最好的做法是封装一个专门的推理类把模型加载、预处理、推理、后处理这些逻辑都隐藏起来对外提供简洁的接口。3.1 类接口设计我们先在inference.h中定义这个类的接口。核心目标是用户只需要提供模型路径、标签路径和必要的参数然后调用一个Inference函数传入图片就能得到分类结果。// inference.h #pragma once #include opencv2/opencv.hpp #include onnxruntime_cxx_api.h #include vector #include string #include memory // 定义分类结果的结构体 struct ClassificationResult { int classId; std::string className; float confidence; }; // 定义模型类型和初始化参数 enum ModelType { YOLO_CLS_V11 }; struct InitParams { std::string modelPath; std::string labelPath; ModelType modelType; cv::Size imgSize; // 模型要求的输入尺寸如 {224, 224} bool useCuda; // 是否使用CUDA加速 // 对于分类任务以下阈值可能用不到但为保持接口扩展性可保留 float scoreThreshold; float nmsThreshold; }; class YOLOv11ClsInference { public: YOLOv11ClsInference(); ~YOLOv11ClsInference(); // 初始化会话加载模型和标签 bool Initialize(const InitParams params); // 执行推理 std::vectorClassificationResult Infer(const cv::Mat inputImage); // 获取模型要求的输入尺寸 cv::Size GetInputSize() const { return m_inputSize; } private: // 预处理将cv::Mat转换为模型需要的输入张量 std::vectorfloat Preprocess(const cv::Mat image, cv::Size targetSize); // 后处理将模型输出张量解析为分类结果 std::vectorClassificationResult Postprocess(const std::vectorOrt::Value outputTensors); // 加载类别标签 bool LoadLabels(const std::string labelPath); private: std::unique_ptrOrt::Env m_env; std::unique_ptrOrt::Session m_session; Ort::SessionOptions m_sessionOptions; Ort::AllocatorWithDefaultOptions m_allocator; std::vectorstd::string m_inputNames; std::vectorstd::string m_outputNames; std::vectorconst char* m_inputNamePtrs; std::vectorconst char* m_outputNamePtrs; std::vectorstd::string m_labels; cv::Size m_inputSize; bool m_isInitialized; };这个头文件定义了类的骨架。我们使用了ONNX Runtime的C API (onnxruntime_cxx_api.h)。注意我们用了std::unique_ptr来管理Ort::Env和Ort::Session的生命周期这是现代C的推荐做法可以避免内存泄漏。InitParams结构体集中了所有初始化参数未来如果要支持其他模型只需扩展这个结构体和ModelType枚举即可。3.2 核心实现细节接下来在inference.cpp中实现关键函数。初始化函数Initialize 这是最复杂的一步需要创建ONNX Runtime环境、配置会话选项、加载模型、获取输入输出信息。bool YOLOv11ClsInference::Initialize(const InitParams params) { if (m_isInitialized) { std::cerr Session already initialized. std::endl; return true; } // 1. 加载标签 if (!LoadLabels(params.labelPath)) { std::cerr Failed to load labels from: params.labelPath std::endl; return false; } // 2. 创建ONNX Runtime环境 m_env std::make_uniqueOrt::Env(ORT_LOGGING_LEVEL_WARNING, YOLOv11CLS); // 3. 配置会话选项 m_sessionOptions.SetIntraOpNumThreads(1); // 设置线程数根据实际情况调整 m_sessionOptions.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); // 4. 配置执行提供商 (CPU/GPU) if (params.useCuda) { // 注意需要链接onnxruntime_providers_cuda.lib并确保CUDA环境正确 Ort::ThrowOnError(OrtSessionOptionsAppendExecutionProvider_CUDA(m_sessionOptions, 0)); std::cout [INFO] Using CUDA execution provider. std::endl; } // 如果不指定默认使用CPU执行提供商 // 5. 创建会话加载模型 try { m_session std::make_uniqueOrt::Session(*m_env, params.modelPath.c_str(), m_sessionOptions); } catch (const Ort::Exception e) { std::cerr [ERROR] Failed to load model: e.what() std::endl; return false; } // 6. 获取模型输入输出信息 Ort::AllocatorWithDefaultOptions allocator; size_t numInputNodes m_session-GetInputCount(); size_t numOutputNodes m_session-GetOutputCount(); // 通常分类模型只有一个输入和一个输出 if (numInputNodes ! 1 || numOutputNodes ! 1) { std::cerr [ERROR] Unsupported model: expects 1 input and 1 output, but got numInputNodes inputs and numOutputNodes outputs. std::endl; return false; } // 获取输入信息 Ort::TypeInfo inputTypeInfo m_session-GetInputTypeInfo(0); auto inputTensorInfo inputTypeInfo.GetTensorTypeAndShapeInfo(); m_inputNames.push_back(m_session-GetInputName(0, allocator)); m_inputNamePtrs.push_back(m_inputNames[0].c_str()); auto inputShape inputTensorInfo.GetShape(); // 模型输入形状通常是 [batch, channel, height, width] if (inputShape.size() 4) { m_inputSize.height static_castint(inputShape[2]); m_inputSize.width static_castint(inputShape[3]); } else { // 如果模型不是标准的4维输入使用用户指定的尺寸 m_inputSize params.imgSize; std::cout [WARN] Model input shape is not 4D, using user specified size: m_inputSize std::endl; } // 获取输出信息 Ort::TypeInfo outputTypeInfo m_session-GetOutputTypeInfo(0); auto outputTensorInfo outputTypeInfo.GetTensorTypeAndShapeInfo(); m_outputNames.push_back(m_session-GetOutputName(0, allocator)); m_outputNamePtrs.push_back(m_outputNames[0].c_str()); m_isInitialized true; std::cout [INFO] Model initialized successfully. Input size: m_inputSize std::endl; return true; }这里有几个关键点执行提供商Execution Provider通过OrtSessionOptionsAppendExecutionProvider_CUDA可以启用GPU加速。如果你下载的是CPU版本的ONNX Runtime库这个调用会失败。确保你链接的库版本和你的选择匹配。输入输出名ONNX模型在导出时输入输出节点有名字。我们需要获取这些名字在推理时用于指定数据喂给哪个节点从哪个节点取结果。使用GetInputName和GetOutputName获取。输入形状我们从模型元数据中读取期望的输入尺寸如[1, 3, 224, 224]这比硬编码更可靠。params.imgSize作为备用。预处理函数Preprocess YOLOv11-CLS模型的预处理通常包括BGR到RGB转换、调整大小、归一化如除以255、以及转换为NCHW格式Number, Channel, Height, Width。std::vectorfloat YOLOv11ClsInference::Preprocess(const cv::Mat srcImage, cv::Size targetSize) { cv::Mat image; // 1. 转换颜色空间 BGR - RGB cv::cvtColor(srcImage, image, cv::COLOR_BGR2RGB); // 2. 调整尺寸 cv::Mat resized; cv::resize(image, resized, targetSize); // 3. 将图像数据转换为浮点型并归一化到 [0, 1] resized.convertTo(resized, CV_32FC3, 1.0 / 255.0); // 4. 从HWC排列转换为CHW排列并展平为一维向量 // OpenCV的Mat数据是连续的按行存储形状为 (H, W, C) // 我们需要转换为 (C, H, W) std::vectorcv::Mat channels(3); cv::split(resized, channels); // 分离出R、G、B三个通道 std::vectorfloat inputTensor; // 先放所有R通道的像素再放G再放B for (int c 0; c 3; c) { // channels[c] 是一个单通道的Mat数据是连续的 inputTensor.insert(inputTensor.end(), (float*)channels[c].data, (float*)channels[c].data targetSize.area()); } return inputTensor; }这里有一个非常重要的细节归一化均值和标准差。很多预训练模型包括YOLO系列在训练时会对输入进行特定的归一化即减去均值再除以标准差。常见的均值是[0.485, 0.456, 0.406]标准差是[0.229, 0.224, 0.225]这是ImageNet数据集上的统计值。你需要确认你使用的YOLOv11-CLS模型是否需要这样的归一化。如果需要上面的归一化步骤1.0/255.0之后还需要对每个通道分别进行(pixel - mean) / std的操作。最准确的方式是查看模型训练时的预处理代码。如果模型导出时已经包含了归一化操作有些框架支持那么我们在部署时就不需要做了。这是一个常见的坑点预处理不匹配会导致推理结果完全错误。推理函数Infer 这是串联预处理、执行会话、后处理的枢纽。std::vectorClassificationResult YOLOv11ClsInference::Infer(const cv::Mat inputImage) { if (!m_isInitialized) { std::cerr [ERROR] Session not initialized. Call Initialize() first. std::endl; return {}; } // 1. 预处理 auto inputTensorValues Preprocess(inputImage, m_inputSize); size_t inputTensorSize inputTensorValues.size(); // 2. 创建输入Tensor // 输入形状: [1, 3, height, width] std::vectorint64_t inputShape {1, 3, m_inputSize.height, m_inputSize.width}; Ort::MemoryInfo memoryInfo Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value inputTensor Ort::Value::CreateTensorfloat(memoryInfo, inputTensorValues.data(), inputTensorSize, inputShape.data(), inputShape.size()); // 3. 运行推理 std::vectorOrt::Value outputTensors; try { outputTensors m_session-Run(Ort::RunOptions{nullptr}, m_inputNamePtrs.data(), inputTensor, 1, m_outputNamePtrs.data(), m_outputNamePtrs.size()); } catch (const Ort::Exception e) { std::cerr [ERROR] Inference failed: e.what() std::endl; return {}; } // 4. 后处理 return Postprocess(outputTensors); }这里Ort::Value::CreateTensor创建了一个包装了我们的数据的内存张量。注意我们传递的是inputTensorValues.data()这是一个指向std::vector内部数据的指针必须确保在Run方法执行期间这个vector的生命周期是有效的不能是临时对象。后处理函数Postprocess 对于分类模型输出通常是一个形状为[1, num_classes]的张量表示每个类别的得分logits或经过softmax的概率。std::vectorClassificationResult YOLOv11ClsInference::Postprocess(const std::vectorOrt::Value outputTensors) { std::vectorClassificationResult results; if (outputTensors.empty()) return results; const auto outputTensor outputTensors[0]; auto tensorInfo outputTensor.GetTensorTypeAndShapeInfo(); auto shape tensorInfo.GetShape(); // 应该是 [1, num_classes] if (shape.size() ! 2 || shape[0] ! 1) { std::cerr [ERROR] Unexpected output shape. std::endl; return results; } size_t numClasses shape[1]; if (numClasses ! m_labels.size()) { std::cerr [WARN] Number of classes in model output ( numClasses ) does not match number of labels ( m_labels.size() ). std::endl; } // 获取输出数据指针 const float* outputData outputTensor.GetTensorDatafloat(); // 找到得分最高的类别 int maxIdx 0; float maxScore outputData[0]; for (size_t i 1; i numClasses; i) { if (outputData[i] maxScore) { maxScore outputData[i]; maxIdx i; } } // 注意输出可能是logits未经过softmax也可能是概率。 // 如果是logitsmaxScore可能很大或为负。这里我们假设输出已经是概率softmax后。 // 一个更健壮的做法是判断所有值是否在[0,1]区间且和接近1如果不是则手动计算softmax。 float confidence maxScore; // 可选手动计算softmax以确保是概率值 // std::vectorfloat probs(numClasses); // float sumExp 0.0f; // for(size_t i0; inumClasses; i) sumExp std::exp(outputData[i]); // for(size_t i0; inumClasses; i) probs[i] std::exp(outputData[i]) / sumExp; // confidence probs[maxIdx]; ClassificationResult res; res.classId maxIdx; res.confidence confidence; res.className (maxIdx m_labels.size()) ? m_labels[maxIdx] : Class_ std::to_string(maxIdx); results.push_back(res); return results; }后处理的关键在于理解模型输出的格式。YOLOv11-CLS的输出通常是[batch_size, num_classes]。我们取batch_size1的那一行找到值最大的那个索引就是预测的类别ID。这里有一个非常重要的点模型输出的是logits原始得分还是经过softmax后的概率这决定了我们是否需要对输出值进行softmax计算。有些框架在导出ONNX模型时默认在模型末尾添加了softmax层有些则没有。你需要通过Netron等工具打开ONNX模型查看输出节点的名称和其前一个操作来判断。如果不确定最稳妥的做法是像上面注释的那样自己实现一个softmax函数对logits进行转换得到概率值。4. 主程序与CMake构建封装好推理类后主程序就变得非常简洁了。4.1 主程序实现// main.cpp #include iostream #include chrono #include inference.h int main(int argc, char* argv[]) { if (argc 2) { std::cout Usage: argv[0] image_path [model_path] [label_path] std::endl; std::cout Example: argv[0] test.jpg models/yolo11n-cls.onnx models/class_names.txt std::endl; return -1; } std::string imagePath argv[1]; std::string modelPath (argc 2) ? argv[2] : models/yolo11n-cls.onnx; std::string labelPath (argc 3) ? argv[3] : models/class_names.txt; // 读取图片 cv::Mat image cv::imread(imagePath); if (image.empty()) { std::cerr [ERROR] Could not read image: imagePath std::endl; return -1; } // 配置初始化参数 InitParams params; params.modelPath modelPath; params.labelPath labelPath; params.modelType YOLO_CLS_V11; params.imgSize cv::Size(224, 224); // 备用尺寸如果从模型读不到就用这个 params.useCuda false; // 根据你的ONNX Runtime版本和硬件设置 params.scoreThreshold 0.25f; params.nmsThreshold 0.45f; // 创建推理器并初始化 YOLOv11ClsInference inferencer; auto startInit std::chrono::high_resolution_clock::now(); if (!inferencer.Initialize(params)) { std::cerr [ERROR] Failed to initialize inferencer. std::endl; return -1; } auto endInit std::chrono::high_resolution_clock::now(); auto initDuration std::chrono::duration_caststd::chrono::milliseconds(endInit - startInit); std::cout [INFO] Model initialization time: initDuration.count() ms std::endl; // 预热可选第一次推理可能较慢 std::cout [INFO] Warming up... std::endl; inferencer.Infer(cv::Mat(224, 224, CV_8UC3, cv::Scalar(128, 128, 128))); // 正式推理 auto startInfer std::chrono::high_resolution_clock::now(); auto results inferencer.Infer(image); auto endInfer std::chrono::high_resolution_clock::now(); auto inferDuration std::chrono::duration_caststd::chrono::milliseconds(endInfer - startInfer); std::cout [INFO] Inference time: inferDuration.count() ms std::endl; // 输出结果 for (const auto res : results) { std::cout Prediction: res.className (ID: res.classId ), Confidence: res.confidence std::endl; // 在图片上绘制结果 std::string labelText res.className : std::to_string(res.confidence).substr(0, 5); cv::putText(image, labelText, cv::Point(20, 40), cv::FONT_HERSHEY_SIMPLEX, 1.0, cv::Scalar(0, 255, 0), 2); } // 显示或保存结果图片 cv::imshow(Classification Result, image); cv::waitKey(0); return 0; }主程序逻辑很清晰解析参数、读取图片、配置并初始化推理器、执行推理、输出结果。这里我加了一个“预热”步骤因为第一次运行会话时ONNX Runtime可能会进行一些即时编译或内存分配耗时较长预热一次可以让后续的推理时间更稳定。4.2 CMakeLists.txt 配置一个正确的CMake配置是项目成功编译的保障。cmake_minimum_required(VERSION 3.15) project(YOLOv11ClsDeploy) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 寻找OpenCV包 find_package(OpenCV REQUIRED) if(OpenCV_FOUND) message(STATUS Found OpenCV: ${OpenCV_DIR}) include_directories(${OpenCV_INCLUDE_DIRS}) else() message(FATAL_ERROR OpenCV not found. Please set OpenCV_DIR.) endif() # 设置ONNX Runtime路径 (假设你将解压的onnxruntime放在项目根目录的third_party下) set(ONNXRUNTIME_ROOT_DIR ${CMAKE_SOURCE_DIR}/third_party/onnxruntime) set(ONNXRUNTIME_INCLUDE_DIR ${ONNXRUNTIME_ROOT_DIR}/include) set(ONNXRUNTIME_LIB_DIR ${ONNXRUNTIME_ROOT_DIR}/lib) # 检查路径是否存在 if(NOT EXISTS ${ONNXRUNTIME_INCLUDE_DIR}/onnxruntime_cxx_api.h) message(FATAL_ERROR ONNX Runtime include not found at ${ONNXRUNTIME_INCLUDE_DIR}) endif() include_directories(${ONNXRUNTIME_INCLUDE_DIR}) link_directories(${ONNXRUNTIME_LIB_DIR}) # 根据平台和配置选择链接库 if(WIN32) if(CMAKE_BUILD_TYPE STREQUAL Debug) set(ONNXRUNTIME_LIB onnxruntime.lib) else() set(ONNXRUNTIME_LIB onnxruntime.lib) endif() else() set(ONNXRUNTIME_LIB onnxruntime) endif() # 添加可执行文件 add_executable(${PROJECT_NAME} src/main.cpp src/inference.cpp src/inference.h) # 链接库 target_link_libraries(${PROJECT_NAME} ${OpenCV_LIBS} ${ONNXRUNTIME_LIB}) # 在Windows下需要将DLL复制到可执行文件目录 if(WIN32) add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different ${ONNXRUNTIME_ROOT_DIR}/bin/onnxruntime.dll $TARGET_FILE_DIR:${PROJECT_NAME}) # 同样复制OpenCV的DLL get_filename_component(OPENCV_DLL_PATH ${OpenCV_LIBS} DIRECTORY) add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different ${OPENCV_DLL_PATH}/../bin/opencv_world480.dll $TARGET_FILE_DIR:${PROJECT_NAME}) endif()这个CMakeLists.txt做了几件事使用find_package查找OpenCV。你需要确保OpenCV的安装路径在系统的CMAKE_PREFIX_PATH中或者通过-DOpenCV_DIR参数指定。手动指定ONNX Runtime的路径。我假设你把解压的ONNX Runtime放到了项目下的third_party/onnxruntime目录。根据平台Windows和构建类型Debug/Release链接正确的库文件。Windows下是.lib文件Linux/macOS下是.so或.dylib文件。添加了一个POST_BUILD命令在编译完成后自动将必要的动态链接库DLL复制到可执行文件旁边这样你直接运行exe就不会因为找不到DLL而报错了。5. 编译、运行与性能优化5.1 编译与运行步骤准备目录按照前面的工程结构创建好目录把CMakeLists.txt、源代码、模型文件、标签文件都放好。准备第三方库将下载的ONNX Runtime库解压到third_party/onnxruntime目录。确保你的OpenCV安装正确并且CMake能找到它。生成构建系统# 在项目根目录下 mkdir build cd build cmake .. -G Visual Studio 16 2019 -A x64 # 或者如果你用Ninja: cmake .. -G Ninja编译项目cmake --build . --config Release如果一切顺利在build/Release或build目录下会生成YOLOv11ClsDeploy.exe。运行测试cd Release # 进入exe所在目录 YOLOv11ClsDeploy.exe ../images/cat.jpg ../models/yolo11n-cls.onnx ../models/class_names.txt程序会加载模型对图片进行分类并显示带标签的结果窗口。5.2 性能优化与常见问题排查部署上线性能是关键。这里分享几个实测有效的优化点和常见问题的排查思路。性能优化点会话选项调优Ort::SessionOptions sessionOptions; sessionOptions.SetIntraOpNumThreads(4); // 设置内部操作线程数通常设为物理核心数 sessionOptions.SetInterOpNumThreads(2); // 设置并行操作线程数对于多输入流模型有用 sessionOptions.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_EXTENDED); // 启用扩展图优化 // 对于固定输入尺寸的模型可以启用静态形状优化 sessionOptions.SetOptimizedModelFilePath(optimized_model.onnx); // 可选保存优化后的模型SetIntraOpNumThreads对CPU推理性能影响显著。可以尝试设置为你的CPU核心数。输入张量内存复用如果需要进行连续的视频流推理可以预先分配好输入Ort::Value在循环中只更新其数据指针指向新的预处理数据避免反复创建和销毁张量对象带来的开销。批处理Batch Inference如果应用场景需要对多张图片同时分类尽量使用批处理。将多张图片预处理后拼接成一个[batch_size, 3, H, W]的大张量一次送入模型。这能极大提升GPU的利用率。你需要确保导出的ONNX模型支持动态批次即输入形状为[-1, 3, 224, 224]。选择合适的执行提供商这是提升性能最有效的手段。在支持CUDA的机器上务必使用GPU版本的ONNX Runtime并启用CUDA执行提供商。对于Intel CPU可以尝试使用OpenVINO执行提供商。在Windows AMD显卡上可以尝试DirectML执行提供商。需要链接对应的库如onnxruntime_providers_cuda.lib。常见问题与排查模型加载失败错误信息Failed to load model可能原因模型文件路径错误模型文件损坏ONNX Runtime版本与模型算子不兼容尝试使用更新版本的ONNX Runtime使用了GPU库但未安装CUDA。排查检查文件路径用Netron工具打开ONNX模型确认其完整性尝试使用CPU版本。推理结果完全不对全零或随机值可能原因预处理不匹配。这是最常见的原因。模型训练时的归一化方式减均值除标准差与部署代码不一致颜色通道顺序BGR/RGB不对输入数据范围0-1或0-255不对。排查仔细核对训练代码中的预处理流程确保完全复现。可以先用Python和ONNX Runtime跑通同一个模型和图片对比中间预处理后的张量数值与C代码的输出是否一致。内存泄漏表现程序运行一段时间后内存持续增长。可能原因ONNX Runtime C API中通过GetInputName等函数获取的字符串指针需要手动释放使用allocator.Free但我们在封装类中使用了std::string来接管并在析构时通过Ort::AllocatorWithDefaultOptions来释放原始指针这是一个易错点。确保所有通过allocator分配的资源都被正确释放。排查使用ValgrindLinux或Visual Studio的诊断工具Windows检测内存泄漏。GPU推理速度反而比CPU慢可能原因对于非常小的模型如YOLOv11n-cls数据在CPU和GPU之间传输的开销可能超过了GPU计算带来的收益GPU没有完全发挥性能功率限制、 thermal throttling。排查增大批处理大小Batch Size以摊薄传输开销使用nvprof或Nsight Systems工具分析GPU内核执行情况确保没有其他程序大量占用GPU。链接错误LNK2019等可能原因没有正确链接ONNX Runtime的库文件Debug和Release版本的库混用。排查确认CMake中link_directories的路径正确并且该路径下存在对应的.lib文件。在Windows上确保项目属性中“代码生成”下的“运行库”设置如/MD或/MT与所使用的ONNX Runtime库的编译选项一致。最省心的办法是全部使用Release配置。6. 进阶动态输入与多后端支持在实际产品中需求往往更复杂。这里再探讨两个进阶话题。6.1 支持动态输入尺寸上面的例子我们假设输入是固定的224x224。但有些场景下我们希望模型能处理任意尺寸的输入保持宽高比缩放或填充。ONNX模型可以支持动态尺寸通常导出时输入形状为[-1, 3, -1, -1]。在C代码中我们需要做以下调整在Initialize函数中从模型获取输入形状时-1会被解析为0。我们需要处理这种动态维度。在每次推理的Preprocess函数中根据当前输入图片的尺寸和模型要求如保持长宽比resize到长边为224短边填充动态计算预处理后的尺寸和填充值。创建输入Tensor时形状需要根据本次预处理的实际结果来设定例如[1, 3, actual_height, actual_width]。这要求预处理和后处理逻辑更复杂因为模型输出的空间维度也可能随之变化。对于分类任务全连接层通常要求固定尺寸所以动态输入在分类模型中不常见但在目标检测或分割模型中很关键。6.2 运行时选择执行后端一个健壮的部署程序应该能根据用户配置或硬件环境自动选择最优的后端。我们可以改造初始化函数使其能接受一个后端类型的参数。enum class BackendType { CPU, CUDA, DirectML, OpenVINO }; bool YOLOv11ClsInference::Initialize(const InitParams params, BackendType backend) { // ... 创建Env ... switch (backend) { case BackendType::CPU: // 默认就是CPU无需额外设置 break; case BackendType::CUDA: { OrtCUDAProviderOptions cuda_options; cuda_options.device_id 0; // 可以设置更多CUDA选项如cudnn_conv_algo_search, arena_extend_strategy等 sessionOptions.AppendExecutionProvider_CUDA(cuda_options); } break; case BackendType::DirectML: { Ort::ThrowOnError(OrtSessionOptionsAppendExecutionProvider_DML(sessionOptions, 0)); } break; case BackendType::OpenVINO: { // 需要链接onnxruntime_providers_openvino.lib OrtOpenVINOProviderOptions openvino_options; // 配置OpenVINO选项如设备类型CPU_FP32, GPU_FP32等 sessionOptions.AppendExecutionProvider_OpenVINO(openvino_options); } break; default: break; } // ... 创建会话 ... }这样我们就可以在运行时根据用户的配置或自动检测的硬件情况选择最合适的后端。例如检测到有NVIDIA GPU且CUDA可用就优先使用CUDA后端。部署的最后一个环节是如何将你的C程序交付给用户。对于Windows你可以使用静态链接将OpenCV和ONNX Runtime都静态编译进去生成一个独立的exe但体积会很大。更常见的方式是动态链接然后创建一个安装包将exe、所有必要的DLLonnxruntime.dll, opencv_world480.dll, cudart64_11.dll等、模型文件、标签文件打包在一起。可以使用Inno Setup或NSIS这样的工具制作安装程序。对于Linux可以制作成AppImage、Snap包或者简单的tar.gz归档并写好依赖说明。记得在文档中明确说明系统环境要求例如需要特定版本的Visual C RedistributableWindows或CUDA驱动。
C++集成YOLOv11-CLS图像分类模型:基于ONNX Runtime的部署实战
发布时间:2026/7/11 5:07:23
1. 项目概述最近在折腾一个图像分类的项目需要把最新的YOLOv11-CLS模型集成到一个C的桌面应用里。一开始考虑过直接用OpenCV的DNN模块但实测下来发现在追求极致推理速度的场景下ONNX Runtime的表现往往更胜一筹。特别是当你的应用需要同时兼容CPU和GPU或者部署环境比较“干净”不想引入太多重型依赖时ONNX Runtime的轻量化和高性能优势就体现出来了。这个项目就是基于这个思路用C和ONNX Runtime框架完整走一遍YOLOv11-CLS图像分类模型的部署流程。整个过程会涉及到环境搭建、模型准备、预处理对齐、推理执行以及结果后处理我会把每个环节的细节和踩过的坑都捋清楚。无论你是想给自己的C应用加个AI视觉能力还是单纯想学习一下现代AI模型在边缘端的部署实战这篇内容应该都能给你提供一份可以直接“抄作业”的参考。2. 环境准备与工具链选型部署的第一步就是把“战场”打扫干净准备好趁手的工具。这里的选择会直接影响到后续开发的效率和最终部署的便捷性。2.1 开发环境与编译工具我的主力开发环境是Windows集成开发环境IDE用的是Visual Studio 2019。选择VS2019主要是考虑到其对新旧C标准的良好支持以及成熟的CMake集成能力社区资源也丰富遇到问题容易找到解决方案。当然如果你习惯用VS2022或者纯粹的CLion MinGW也完全没问题核心思路是相通的。编译工具链上我强烈推荐使用CMake。它能让你的项目摆脱对特定IDE的依赖实现跨平台Windows/Linux/macOS的构建。我的CMake版本是3.24.3。这里有个小技巧在Windows上你可以通过Visual Studio自带的“开发者命令提示符”来使用CMake和MSVC编译器这样环境变量都是配置好的非常省心。你也可以选择使用Ninja作为生成器构建速度会更快。2.2 核心库的安装与配置接下来是三个核心库OpenCV、ONNX Runtime和YOLOv11模型本身。OpenCV我用的版本是4.8.0。它主要负责图像的读取、缩放、颜色空间转换等预处理操作以及最终结果的可视化。安装OpenCV最稳妥的方式是从官网下载源码自己用CMake编译。编译时记得勾选BUILD_opencv_world这个选项它会将所有模块打包成一个大的opencv_world480.dll版本号可能不同和对应的lib文件。这样做的好处是在部署时你只需要携带一个动态库管理起来非常方便避免了链接时缺少某个模块库的烦恼。编译完成后你会得到include头文件夹、lib库文件夹和bin包含dll文件夹记下它们的路径。ONNX Runtime这是本次部署的推理引擎核心版本为1.16.3。ONNX Runtime提供了预编译的二进制包这是最推荐的方式。你需要根据你的平台和需求选择下载CPU版本例如onnxruntime-win-x64-1.16.3.zip。如果你的应用只运行在CPU上这个就够了。GPU版本CUDA例如onnxruntime-win-x64-gpu-1.16.3.zip。如果你想利用NVIDIA GPU加速需要下载这个并且确保系统已安装对应版本的CUDA和cuDNN。GPU版本DirectML针对Windows平台如果你有AMD/Intel/NVIDIA的显卡但不想装CUDA可以用这个后端它通过DirectX接口调用GPU。下载后解压里面同样有include、lib、bin目录。我们需要的就是头文件和链接库。YOLOv11-CLS模型你需要从Ultralytics的官方仓库或相关渠道获取YOLOv11的预训练分类模型权重.pt文件然后将其导出为ONNX格式。可以使用官方的Python脚本from ultralytics import YOLO model YOLO(yolo11n-cls.pt) # 假设你下载的是nano版本的分类模型 model.export(formatonnx, imgsz224) # 指定导出为ONNX输入尺寸为224x224导出的yolo11n-cls.onnx文件就是我们需要部署的模型。同时记得准备一个class_names.txt文件里面按行存放分类的类别名称这个文件需要和模型训练时使用的类别顺序一致。2.3 项目工程结构规划一个清晰的项目结构能让后续的开发和维护事半功倍。我建议的目录结构如下yolov11_cls_cpp_deploy/ ├── CMakeLists.txt # 项目根CMake配置文件 ├── src/ │ ├── main.cpp # 主程序入口 │ ├── inference.h # 推理封装类的头文件 │ └── inference.cpp # 推理封装类的实现 ├── models/ │ ├── yolo11n-cls.onnx # ONNX模型文件 │ └── class_names.txt # 类别标签文件 ├── lib/ # 放置第三方库的lib文件 (可选也可用系统路径) ├── include/ # 放置第三方库的头文件 (可选) └── images/ # 用于测试的图片在CMakeLists.txt中我们将使用find_package或直接指定路径的方式来定位OpenCV和ONNX Runtime。对于ONNX Runtime由于它不提供标准的CMake配置文件我们通常采用指定include_directories和link_directories的方式。3. 模型推理类的封装与设计直接在主函数里堆砌所有推理代码会显得很乱也不利于复用。最好的做法是封装一个专门的推理类把模型加载、预处理、推理、后处理这些逻辑都隐藏起来对外提供简洁的接口。3.1 类接口设计我们先在inference.h中定义这个类的接口。核心目标是用户只需要提供模型路径、标签路径和必要的参数然后调用一个Inference函数传入图片就能得到分类结果。// inference.h #pragma once #include opencv2/opencv.hpp #include onnxruntime_cxx_api.h #include vector #include string #include memory // 定义分类结果的结构体 struct ClassificationResult { int classId; std::string className; float confidence; }; // 定义模型类型和初始化参数 enum ModelType { YOLO_CLS_V11 }; struct InitParams { std::string modelPath; std::string labelPath; ModelType modelType; cv::Size imgSize; // 模型要求的输入尺寸如 {224, 224} bool useCuda; // 是否使用CUDA加速 // 对于分类任务以下阈值可能用不到但为保持接口扩展性可保留 float scoreThreshold; float nmsThreshold; }; class YOLOv11ClsInference { public: YOLOv11ClsInference(); ~YOLOv11ClsInference(); // 初始化会话加载模型和标签 bool Initialize(const InitParams params); // 执行推理 std::vectorClassificationResult Infer(const cv::Mat inputImage); // 获取模型要求的输入尺寸 cv::Size GetInputSize() const { return m_inputSize; } private: // 预处理将cv::Mat转换为模型需要的输入张量 std::vectorfloat Preprocess(const cv::Mat image, cv::Size targetSize); // 后处理将模型输出张量解析为分类结果 std::vectorClassificationResult Postprocess(const std::vectorOrt::Value outputTensors); // 加载类别标签 bool LoadLabels(const std::string labelPath); private: std::unique_ptrOrt::Env m_env; std::unique_ptrOrt::Session m_session; Ort::SessionOptions m_sessionOptions; Ort::AllocatorWithDefaultOptions m_allocator; std::vectorstd::string m_inputNames; std::vectorstd::string m_outputNames; std::vectorconst char* m_inputNamePtrs; std::vectorconst char* m_outputNamePtrs; std::vectorstd::string m_labels; cv::Size m_inputSize; bool m_isInitialized; };这个头文件定义了类的骨架。我们使用了ONNX Runtime的C API (onnxruntime_cxx_api.h)。注意我们用了std::unique_ptr来管理Ort::Env和Ort::Session的生命周期这是现代C的推荐做法可以避免内存泄漏。InitParams结构体集中了所有初始化参数未来如果要支持其他模型只需扩展这个结构体和ModelType枚举即可。3.2 核心实现细节接下来在inference.cpp中实现关键函数。初始化函数Initialize 这是最复杂的一步需要创建ONNX Runtime环境、配置会话选项、加载模型、获取输入输出信息。bool YOLOv11ClsInference::Initialize(const InitParams params) { if (m_isInitialized) { std::cerr Session already initialized. std::endl; return true; } // 1. 加载标签 if (!LoadLabels(params.labelPath)) { std::cerr Failed to load labels from: params.labelPath std::endl; return false; } // 2. 创建ONNX Runtime环境 m_env std::make_uniqueOrt::Env(ORT_LOGGING_LEVEL_WARNING, YOLOv11CLS); // 3. 配置会话选项 m_sessionOptions.SetIntraOpNumThreads(1); // 设置线程数根据实际情况调整 m_sessionOptions.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); // 4. 配置执行提供商 (CPU/GPU) if (params.useCuda) { // 注意需要链接onnxruntime_providers_cuda.lib并确保CUDA环境正确 Ort::ThrowOnError(OrtSessionOptionsAppendExecutionProvider_CUDA(m_sessionOptions, 0)); std::cout [INFO] Using CUDA execution provider. std::endl; } // 如果不指定默认使用CPU执行提供商 // 5. 创建会话加载模型 try { m_session std::make_uniqueOrt::Session(*m_env, params.modelPath.c_str(), m_sessionOptions); } catch (const Ort::Exception e) { std::cerr [ERROR] Failed to load model: e.what() std::endl; return false; } // 6. 获取模型输入输出信息 Ort::AllocatorWithDefaultOptions allocator; size_t numInputNodes m_session-GetInputCount(); size_t numOutputNodes m_session-GetOutputCount(); // 通常分类模型只有一个输入和一个输出 if (numInputNodes ! 1 || numOutputNodes ! 1) { std::cerr [ERROR] Unsupported model: expects 1 input and 1 output, but got numInputNodes inputs and numOutputNodes outputs. std::endl; return false; } // 获取输入信息 Ort::TypeInfo inputTypeInfo m_session-GetInputTypeInfo(0); auto inputTensorInfo inputTypeInfo.GetTensorTypeAndShapeInfo(); m_inputNames.push_back(m_session-GetInputName(0, allocator)); m_inputNamePtrs.push_back(m_inputNames[0].c_str()); auto inputShape inputTensorInfo.GetShape(); // 模型输入形状通常是 [batch, channel, height, width] if (inputShape.size() 4) { m_inputSize.height static_castint(inputShape[2]); m_inputSize.width static_castint(inputShape[3]); } else { // 如果模型不是标准的4维输入使用用户指定的尺寸 m_inputSize params.imgSize; std::cout [WARN] Model input shape is not 4D, using user specified size: m_inputSize std::endl; } // 获取输出信息 Ort::TypeInfo outputTypeInfo m_session-GetOutputTypeInfo(0); auto outputTensorInfo outputTypeInfo.GetTensorTypeAndShapeInfo(); m_outputNames.push_back(m_session-GetOutputName(0, allocator)); m_outputNamePtrs.push_back(m_outputNames[0].c_str()); m_isInitialized true; std::cout [INFO] Model initialized successfully. Input size: m_inputSize std::endl; return true; }这里有几个关键点执行提供商Execution Provider通过OrtSessionOptionsAppendExecutionProvider_CUDA可以启用GPU加速。如果你下载的是CPU版本的ONNX Runtime库这个调用会失败。确保你链接的库版本和你的选择匹配。输入输出名ONNX模型在导出时输入输出节点有名字。我们需要获取这些名字在推理时用于指定数据喂给哪个节点从哪个节点取结果。使用GetInputName和GetOutputName获取。输入形状我们从模型元数据中读取期望的输入尺寸如[1, 3, 224, 224]这比硬编码更可靠。params.imgSize作为备用。预处理函数Preprocess YOLOv11-CLS模型的预处理通常包括BGR到RGB转换、调整大小、归一化如除以255、以及转换为NCHW格式Number, Channel, Height, Width。std::vectorfloat YOLOv11ClsInference::Preprocess(const cv::Mat srcImage, cv::Size targetSize) { cv::Mat image; // 1. 转换颜色空间 BGR - RGB cv::cvtColor(srcImage, image, cv::COLOR_BGR2RGB); // 2. 调整尺寸 cv::Mat resized; cv::resize(image, resized, targetSize); // 3. 将图像数据转换为浮点型并归一化到 [0, 1] resized.convertTo(resized, CV_32FC3, 1.0 / 255.0); // 4. 从HWC排列转换为CHW排列并展平为一维向量 // OpenCV的Mat数据是连续的按行存储形状为 (H, W, C) // 我们需要转换为 (C, H, W) std::vectorcv::Mat channels(3); cv::split(resized, channels); // 分离出R、G、B三个通道 std::vectorfloat inputTensor; // 先放所有R通道的像素再放G再放B for (int c 0; c 3; c) { // channels[c] 是一个单通道的Mat数据是连续的 inputTensor.insert(inputTensor.end(), (float*)channels[c].data, (float*)channels[c].data targetSize.area()); } return inputTensor; }这里有一个非常重要的细节归一化均值和标准差。很多预训练模型包括YOLO系列在训练时会对输入进行特定的归一化即减去均值再除以标准差。常见的均值是[0.485, 0.456, 0.406]标准差是[0.229, 0.224, 0.225]这是ImageNet数据集上的统计值。你需要确认你使用的YOLOv11-CLS模型是否需要这样的归一化。如果需要上面的归一化步骤1.0/255.0之后还需要对每个通道分别进行(pixel - mean) / std的操作。最准确的方式是查看模型训练时的预处理代码。如果模型导出时已经包含了归一化操作有些框架支持那么我们在部署时就不需要做了。这是一个常见的坑点预处理不匹配会导致推理结果完全错误。推理函数Infer 这是串联预处理、执行会话、后处理的枢纽。std::vectorClassificationResult YOLOv11ClsInference::Infer(const cv::Mat inputImage) { if (!m_isInitialized) { std::cerr [ERROR] Session not initialized. Call Initialize() first. std::endl; return {}; } // 1. 预处理 auto inputTensorValues Preprocess(inputImage, m_inputSize); size_t inputTensorSize inputTensorValues.size(); // 2. 创建输入Tensor // 输入形状: [1, 3, height, width] std::vectorint64_t inputShape {1, 3, m_inputSize.height, m_inputSize.width}; Ort::MemoryInfo memoryInfo Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value inputTensor Ort::Value::CreateTensorfloat(memoryInfo, inputTensorValues.data(), inputTensorSize, inputShape.data(), inputShape.size()); // 3. 运行推理 std::vectorOrt::Value outputTensors; try { outputTensors m_session-Run(Ort::RunOptions{nullptr}, m_inputNamePtrs.data(), inputTensor, 1, m_outputNamePtrs.data(), m_outputNamePtrs.size()); } catch (const Ort::Exception e) { std::cerr [ERROR] Inference failed: e.what() std::endl; return {}; } // 4. 后处理 return Postprocess(outputTensors); }这里Ort::Value::CreateTensor创建了一个包装了我们的数据的内存张量。注意我们传递的是inputTensorValues.data()这是一个指向std::vector内部数据的指针必须确保在Run方法执行期间这个vector的生命周期是有效的不能是临时对象。后处理函数Postprocess 对于分类模型输出通常是一个形状为[1, num_classes]的张量表示每个类别的得分logits或经过softmax的概率。std::vectorClassificationResult YOLOv11ClsInference::Postprocess(const std::vectorOrt::Value outputTensors) { std::vectorClassificationResult results; if (outputTensors.empty()) return results; const auto outputTensor outputTensors[0]; auto tensorInfo outputTensor.GetTensorTypeAndShapeInfo(); auto shape tensorInfo.GetShape(); // 应该是 [1, num_classes] if (shape.size() ! 2 || shape[0] ! 1) { std::cerr [ERROR] Unexpected output shape. std::endl; return results; } size_t numClasses shape[1]; if (numClasses ! m_labels.size()) { std::cerr [WARN] Number of classes in model output ( numClasses ) does not match number of labels ( m_labels.size() ). std::endl; } // 获取输出数据指针 const float* outputData outputTensor.GetTensorDatafloat(); // 找到得分最高的类别 int maxIdx 0; float maxScore outputData[0]; for (size_t i 1; i numClasses; i) { if (outputData[i] maxScore) { maxScore outputData[i]; maxIdx i; } } // 注意输出可能是logits未经过softmax也可能是概率。 // 如果是logitsmaxScore可能很大或为负。这里我们假设输出已经是概率softmax后。 // 一个更健壮的做法是判断所有值是否在[0,1]区间且和接近1如果不是则手动计算softmax。 float confidence maxScore; // 可选手动计算softmax以确保是概率值 // std::vectorfloat probs(numClasses); // float sumExp 0.0f; // for(size_t i0; inumClasses; i) sumExp std::exp(outputData[i]); // for(size_t i0; inumClasses; i) probs[i] std::exp(outputData[i]) / sumExp; // confidence probs[maxIdx]; ClassificationResult res; res.classId maxIdx; res.confidence confidence; res.className (maxIdx m_labels.size()) ? m_labels[maxIdx] : Class_ std::to_string(maxIdx); results.push_back(res); return results; }后处理的关键在于理解模型输出的格式。YOLOv11-CLS的输出通常是[batch_size, num_classes]。我们取batch_size1的那一行找到值最大的那个索引就是预测的类别ID。这里有一个非常重要的点模型输出的是logits原始得分还是经过softmax后的概率这决定了我们是否需要对输出值进行softmax计算。有些框架在导出ONNX模型时默认在模型末尾添加了softmax层有些则没有。你需要通过Netron等工具打开ONNX模型查看输出节点的名称和其前一个操作来判断。如果不确定最稳妥的做法是像上面注释的那样自己实现一个softmax函数对logits进行转换得到概率值。4. 主程序与CMake构建封装好推理类后主程序就变得非常简洁了。4.1 主程序实现// main.cpp #include iostream #include chrono #include inference.h int main(int argc, char* argv[]) { if (argc 2) { std::cout Usage: argv[0] image_path [model_path] [label_path] std::endl; std::cout Example: argv[0] test.jpg models/yolo11n-cls.onnx models/class_names.txt std::endl; return -1; } std::string imagePath argv[1]; std::string modelPath (argc 2) ? argv[2] : models/yolo11n-cls.onnx; std::string labelPath (argc 3) ? argv[3] : models/class_names.txt; // 读取图片 cv::Mat image cv::imread(imagePath); if (image.empty()) { std::cerr [ERROR] Could not read image: imagePath std::endl; return -1; } // 配置初始化参数 InitParams params; params.modelPath modelPath; params.labelPath labelPath; params.modelType YOLO_CLS_V11; params.imgSize cv::Size(224, 224); // 备用尺寸如果从模型读不到就用这个 params.useCuda false; // 根据你的ONNX Runtime版本和硬件设置 params.scoreThreshold 0.25f; params.nmsThreshold 0.45f; // 创建推理器并初始化 YOLOv11ClsInference inferencer; auto startInit std::chrono::high_resolution_clock::now(); if (!inferencer.Initialize(params)) { std::cerr [ERROR] Failed to initialize inferencer. std::endl; return -1; } auto endInit std::chrono::high_resolution_clock::now(); auto initDuration std::chrono::duration_caststd::chrono::milliseconds(endInit - startInit); std::cout [INFO] Model initialization time: initDuration.count() ms std::endl; // 预热可选第一次推理可能较慢 std::cout [INFO] Warming up... std::endl; inferencer.Infer(cv::Mat(224, 224, CV_8UC3, cv::Scalar(128, 128, 128))); // 正式推理 auto startInfer std::chrono::high_resolution_clock::now(); auto results inferencer.Infer(image); auto endInfer std::chrono::high_resolution_clock::now(); auto inferDuration std::chrono::duration_caststd::chrono::milliseconds(endInfer - startInfer); std::cout [INFO] Inference time: inferDuration.count() ms std::endl; // 输出结果 for (const auto res : results) { std::cout Prediction: res.className (ID: res.classId ), Confidence: res.confidence std::endl; // 在图片上绘制结果 std::string labelText res.className : std::to_string(res.confidence).substr(0, 5); cv::putText(image, labelText, cv::Point(20, 40), cv::FONT_HERSHEY_SIMPLEX, 1.0, cv::Scalar(0, 255, 0), 2); } // 显示或保存结果图片 cv::imshow(Classification Result, image); cv::waitKey(0); return 0; }主程序逻辑很清晰解析参数、读取图片、配置并初始化推理器、执行推理、输出结果。这里我加了一个“预热”步骤因为第一次运行会话时ONNX Runtime可能会进行一些即时编译或内存分配耗时较长预热一次可以让后续的推理时间更稳定。4.2 CMakeLists.txt 配置一个正确的CMake配置是项目成功编译的保障。cmake_minimum_required(VERSION 3.15) project(YOLOv11ClsDeploy) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 寻找OpenCV包 find_package(OpenCV REQUIRED) if(OpenCV_FOUND) message(STATUS Found OpenCV: ${OpenCV_DIR}) include_directories(${OpenCV_INCLUDE_DIRS}) else() message(FATAL_ERROR OpenCV not found. Please set OpenCV_DIR.) endif() # 设置ONNX Runtime路径 (假设你将解压的onnxruntime放在项目根目录的third_party下) set(ONNXRUNTIME_ROOT_DIR ${CMAKE_SOURCE_DIR}/third_party/onnxruntime) set(ONNXRUNTIME_INCLUDE_DIR ${ONNXRUNTIME_ROOT_DIR}/include) set(ONNXRUNTIME_LIB_DIR ${ONNXRUNTIME_ROOT_DIR}/lib) # 检查路径是否存在 if(NOT EXISTS ${ONNXRUNTIME_INCLUDE_DIR}/onnxruntime_cxx_api.h) message(FATAL_ERROR ONNX Runtime include not found at ${ONNXRUNTIME_INCLUDE_DIR}) endif() include_directories(${ONNXRUNTIME_INCLUDE_DIR}) link_directories(${ONNXRUNTIME_LIB_DIR}) # 根据平台和配置选择链接库 if(WIN32) if(CMAKE_BUILD_TYPE STREQUAL Debug) set(ONNXRUNTIME_LIB onnxruntime.lib) else() set(ONNXRUNTIME_LIB onnxruntime.lib) endif() else() set(ONNXRUNTIME_LIB onnxruntime) endif() # 添加可执行文件 add_executable(${PROJECT_NAME} src/main.cpp src/inference.cpp src/inference.h) # 链接库 target_link_libraries(${PROJECT_NAME} ${OpenCV_LIBS} ${ONNXRUNTIME_LIB}) # 在Windows下需要将DLL复制到可执行文件目录 if(WIN32) add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different ${ONNXRUNTIME_ROOT_DIR}/bin/onnxruntime.dll $TARGET_FILE_DIR:${PROJECT_NAME}) # 同样复制OpenCV的DLL get_filename_component(OPENCV_DLL_PATH ${OpenCV_LIBS} DIRECTORY) add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different ${OPENCV_DLL_PATH}/../bin/opencv_world480.dll $TARGET_FILE_DIR:${PROJECT_NAME}) endif()这个CMakeLists.txt做了几件事使用find_package查找OpenCV。你需要确保OpenCV的安装路径在系统的CMAKE_PREFIX_PATH中或者通过-DOpenCV_DIR参数指定。手动指定ONNX Runtime的路径。我假设你把解压的ONNX Runtime放到了项目下的third_party/onnxruntime目录。根据平台Windows和构建类型Debug/Release链接正确的库文件。Windows下是.lib文件Linux/macOS下是.so或.dylib文件。添加了一个POST_BUILD命令在编译完成后自动将必要的动态链接库DLL复制到可执行文件旁边这样你直接运行exe就不会因为找不到DLL而报错了。5. 编译、运行与性能优化5.1 编译与运行步骤准备目录按照前面的工程结构创建好目录把CMakeLists.txt、源代码、模型文件、标签文件都放好。准备第三方库将下载的ONNX Runtime库解压到third_party/onnxruntime目录。确保你的OpenCV安装正确并且CMake能找到它。生成构建系统# 在项目根目录下 mkdir build cd build cmake .. -G Visual Studio 16 2019 -A x64 # 或者如果你用Ninja: cmake .. -G Ninja编译项目cmake --build . --config Release如果一切顺利在build/Release或build目录下会生成YOLOv11ClsDeploy.exe。运行测试cd Release # 进入exe所在目录 YOLOv11ClsDeploy.exe ../images/cat.jpg ../models/yolo11n-cls.onnx ../models/class_names.txt程序会加载模型对图片进行分类并显示带标签的结果窗口。5.2 性能优化与常见问题排查部署上线性能是关键。这里分享几个实测有效的优化点和常见问题的排查思路。性能优化点会话选项调优Ort::SessionOptions sessionOptions; sessionOptions.SetIntraOpNumThreads(4); // 设置内部操作线程数通常设为物理核心数 sessionOptions.SetInterOpNumThreads(2); // 设置并行操作线程数对于多输入流模型有用 sessionOptions.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_EXTENDED); // 启用扩展图优化 // 对于固定输入尺寸的模型可以启用静态形状优化 sessionOptions.SetOptimizedModelFilePath(optimized_model.onnx); // 可选保存优化后的模型SetIntraOpNumThreads对CPU推理性能影响显著。可以尝试设置为你的CPU核心数。输入张量内存复用如果需要进行连续的视频流推理可以预先分配好输入Ort::Value在循环中只更新其数据指针指向新的预处理数据避免反复创建和销毁张量对象带来的开销。批处理Batch Inference如果应用场景需要对多张图片同时分类尽量使用批处理。将多张图片预处理后拼接成一个[batch_size, 3, H, W]的大张量一次送入模型。这能极大提升GPU的利用率。你需要确保导出的ONNX模型支持动态批次即输入形状为[-1, 3, 224, 224]。选择合适的执行提供商这是提升性能最有效的手段。在支持CUDA的机器上务必使用GPU版本的ONNX Runtime并启用CUDA执行提供商。对于Intel CPU可以尝试使用OpenVINO执行提供商。在Windows AMD显卡上可以尝试DirectML执行提供商。需要链接对应的库如onnxruntime_providers_cuda.lib。常见问题与排查模型加载失败错误信息Failed to load model可能原因模型文件路径错误模型文件损坏ONNX Runtime版本与模型算子不兼容尝试使用更新版本的ONNX Runtime使用了GPU库但未安装CUDA。排查检查文件路径用Netron工具打开ONNX模型确认其完整性尝试使用CPU版本。推理结果完全不对全零或随机值可能原因预处理不匹配。这是最常见的原因。模型训练时的归一化方式减均值除标准差与部署代码不一致颜色通道顺序BGR/RGB不对输入数据范围0-1或0-255不对。排查仔细核对训练代码中的预处理流程确保完全复现。可以先用Python和ONNX Runtime跑通同一个模型和图片对比中间预处理后的张量数值与C代码的输出是否一致。内存泄漏表现程序运行一段时间后内存持续增长。可能原因ONNX Runtime C API中通过GetInputName等函数获取的字符串指针需要手动释放使用allocator.Free但我们在封装类中使用了std::string来接管并在析构时通过Ort::AllocatorWithDefaultOptions来释放原始指针这是一个易错点。确保所有通过allocator分配的资源都被正确释放。排查使用ValgrindLinux或Visual Studio的诊断工具Windows检测内存泄漏。GPU推理速度反而比CPU慢可能原因对于非常小的模型如YOLOv11n-cls数据在CPU和GPU之间传输的开销可能超过了GPU计算带来的收益GPU没有完全发挥性能功率限制、 thermal throttling。排查增大批处理大小Batch Size以摊薄传输开销使用nvprof或Nsight Systems工具分析GPU内核执行情况确保没有其他程序大量占用GPU。链接错误LNK2019等可能原因没有正确链接ONNX Runtime的库文件Debug和Release版本的库混用。排查确认CMake中link_directories的路径正确并且该路径下存在对应的.lib文件。在Windows上确保项目属性中“代码生成”下的“运行库”设置如/MD或/MT与所使用的ONNX Runtime库的编译选项一致。最省心的办法是全部使用Release配置。6. 进阶动态输入与多后端支持在实际产品中需求往往更复杂。这里再探讨两个进阶话题。6.1 支持动态输入尺寸上面的例子我们假设输入是固定的224x224。但有些场景下我们希望模型能处理任意尺寸的输入保持宽高比缩放或填充。ONNX模型可以支持动态尺寸通常导出时输入形状为[-1, 3, -1, -1]。在C代码中我们需要做以下调整在Initialize函数中从模型获取输入形状时-1会被解析为0。我们需要处理这种动态维度。在每次推理的Preprocess函数中根据当前输入图片的尺寸和模型要求如保持长宽比resize到长边为224短边填充动态计算预处理后的尺寸和填充值。创建输入Tensor时形状需要根据本次预处理的实际结果来设定例如[1, 3, actual_height, actual_width]。这要求预处理和后处理逻辑更复杂因为模型输出的空间维度也可能随之变化。对于分类任务全连接层通常要求固定尺寸所以动态输入在分类模型中不常见但在目标检测或分割模型中很关键。6.2 运行时选择执行后端一个健壮的部署程序应该能根据用户配置或硬件环境自动选择最优的后端。我们可以改造初始化函数使其能接受一个后端类型的参数。enum class BackendType { CPU, CUDA, DirectML, OpenVINO }; bool YOLOv11ClsInference::Initialize(const InitParams params, BackendType backend) { // ... 创建Env ... switch (backend) { case BackendType::CPU: // 默认就是CPU无需额外设置 break; case BackendType::CUDA: { OrtCUDAProviderOptions cuda_options; cuda_options.device_id 0; // 可以设置更多CUDA选项如cudnn_conv_algo_search, arena_extend_strategy等 sessionOptions.AppendExecutionProvider_CUDA(cuda_options); } break; case BackendType::DirectML: { Ort::ThrowOnError(OrtSessionOptionsAppendExecutionProvider_DML(sessionOptions, 0)); } break; case BackendType::OpenVINO: { // 需要链接onnxruntime_providers_openvino.lib OrtOpenVINOProviderOptions openvino_options; // 配置OpenVINO选项如设备类型CPU_FP32, GPU_FP32等 sessionOptions.AppendExecutionProvider_OpenVINO(openvino_options); } break; default: break; } // ... 创建会话 ... }这样我们就可以在运行时根据用户的配置或自动检测的硬件情况选择最合适的后端。例如检测到有NVIDIA GPU且CUDA可用就优先使用CUDA后端。部署的最后一个环节是如何将你的C程序交付给用户。对于Windows你可以使用静态链接将OpenCV和ONNX Runtime都静态编译进去生成一个独立的exe但体积会很大。更常见的方式是动态链接然后创建一个安装包将exe、所有必要的DLLonnxruntime.dll, opencv_world480.dll, cudart64_11.dll等、模型文件、标签文件打包在一起。可以使用Inno Setup或NSIS这样的工具制作安装程序。对于Linux可以制作成AppImage、Snap包或者简单的tar.gz归档并写好依赖说明。记得在文档中明确说明系统环境要求例如需要特定版本的Visual C RedistributableWindows或CUDA驱动。