使用 C++、YOLO 和 ONNX Runtime 实现实时目标检测的完整教程整理与代码实现指南。
https://github.com/ataffe/YoloOnnxRuntimeCPPC 实时目标检测教程YOLO ONNX Runtime本教程旨在展示如何在 C 环境中部署 YOLO 模型以 YOLO11 为例利用 ONNX Runtime 进行加速推理并使用 OpenCV 进行图像预处理和后处理。0. 环境准备与依赖安装在开始之前请确保你的系统已安装以下组件操作系统: Linux (Ubuntu 24.04 推荐) 或 Windows编译器: GCC/G 或 MSVC (支持 C17/20)构建工具: CMake (3.10)核心库:ONNX Runtime: 下载预编译包CPU 版或 GPU/CUDA 版。注意: 如果使用 NVIDIA GPU务必下载带有gpu标识的版本并确保 CUDA Toolkit 和 cuDNN 版本匹配。OpenCV: 版本 4.x (需包含dnn模块)。CUDA Toolkit cuDNN(仅限 GPU 加速)。CMakeLists.txt 配置示例cmake_minimum_required(VERSION 3.10 FATAL_ERROR) project(YoloOnnxCppTutorial) # 设置 ONNX Runtime 路径 (请修改为你的实际路径) set(ONNXRUNTIME_ROOT /path/to/onnxruntime-linux-x64-gpu-1.22.0) include_directories(${ONNXRUNTIME_ROOT}/include) link_directories(${ONNXRUNTIME_ROOT}/lib) find_package(OpenCV REQUIRED) add_executable(object_detection object_detection.cpp) # 链接库 target_link_libraries(object_detection PUBLIC onnxruntime ${OpenCV_LIBS}) # 设置 C 标准 set_property(TARGET object_detection PROPERTY CXX_STANDARD 20) # 设置运行时库路径 (Linux 下防止找不到 .so 文件) set_target_properties(object_detection PROPERTIES BUILD_RPATH ${ONNXRUNTIME_ROOT}/lib INSTALL_RPATH ${ONNXRUNTIME_ROOT}/lib )1. 模型转换 (Python)首先需要使用 Python 将 PyTorch 格式的 YOLO 模型转换为 ONNX 格式。前置条件: 安装ultralytics库 (pip install ultralytics)。fromultralyticsimportYOLO# 加载预训练模型 (例如 yolo11n.pt)modelYOLO(yolo11n.pt)# 导出为 ONNX 格式# simplifyTrue 会简化模型结构有助于提高推理速度model.export(formatonnx,simplifyTrue)输出文件:yolo11n.onnx模型输入输出分析 (使用 Netron 查看):输入:[1, 3, 640, 640](Batch, Channels, Height, Width)输出:[1, 84, 8400]84: 4 个坐标 (x, y, w, h) 80 个类别分数 (COCO 数据集)8400: 预测框的数量2. C 完整实现代码以下是整合了模型加载、预处理、推理和后处理的完整 C 代码。主要功能模块LoadYoloModel: 初始化 ONNX Session (支持 CUDA)。ImageToBlob: OpenCV 图像预处理 (Letterbox, 归一化, BGR-RGB)。BlobToONNXTensor: 创建 ONNX 输入张量。ProcessYoloOutput: 解析输出、置信度过滤、坐标还原、NMS。#includeiostream#includevector#includestring#includealgorithm#includecmath// ONNX Runtime#includeonnxruntime_cxx_api.h// OpenCV#includeopencv2/opencv.hpp#includeopencv2/dnn.hpp// 定义边界框结构体structYoloBoundingBox{cv::Rect bounding_box;floatconfidence;intclass_id;};// --- 辅助函数Letterbox 预处理 (保持宽高比填充) ---// 注意实际项目中建议实现完整的 Letterbox 逻辑此处简化调用或需自行实现// 这里为了演示完整性假设有一个 LetterBox 函数或者直接使用 cv::resize 填充逻辑// 在实际代码中你需要实现类似 Ultralytics 的 letterbox 逻辑来计算 paddingvoidLetterBox(constcv::Matimage,cv::Matout,constcv::Sizenew_shape,constcv::Scalarcolorcv::Scalar(114,114,114)){floatratiostd::min((float)new_shape.width/image.cols,(float)new_shape.height/image.rows);intnew_unpad_wint(image.cols*ratio);intnew_unpad_hint(image.rows*ratio);cv::Mat resized;cv::resize(image,resized,cv::Size(new_unpad_w,new_unpad_h),0,0,cv::INTER_LINEAR);intdwnew_shape.width-new_unpad_w;intdhnew_shape.height-new_unpad_h;inttopint(dh/2.0);intbottomdh-top;intleftint(dw/2.0);intrightdw-left;cv::copyMakeBorder(resized,resized,top,bottom,left,right,cv::BORDER_CONSTANT,color);outresized;}// --- 1. 加载 YOLO 模型 ---Ort::SessionLoadYoloModel(constOrt::Envenv,conststd::stringmodel_path,booluse_cudafalse){Ort::SessionOptions session_options;session_options.SetIntraOpNumThreads(1);session_options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);if(use_cuda){OrtCUDAProviderOptionsV2*cuda_optionsnullptr;Ort::ThrowOnError(Ort::GetApi().CreateCUDAProviderOptions(cuda_options));// 可以在此处设置 cuda_options 的具体参数Ort::ThrowOnError(Ort::SessionOptionsAppendExecutionProvider_CUDA_V2(session_options,cuda_options));Ort::GetApi().ReleaseCUDAProviderOptions(cuda_options);std::coutUsing CUDA Execution Providerstd::endl;}else{std::coutUsing CPU Execution Providerstd::endl;}returnOrt::Session(env,model_path.c_str(),session_options);}// --- 2. 图像预处理 ---cv::MatImageToBlob(constcv::Matimage,cv::Size input_sizecv::Size(640,640)){cv::Mat resized_image;// 应用 Letterbox 保持宽高比LetterBox(image,resized_image,input_size);// 转换为 Blob: 缩放至 0-1, BGR-RGB, HWC-CHWcv::Mat blobcv::dnn::blobFromImage(resized_image,1.0/255.0,input_size,cv::Scalar(0,0,0),true,// swap RBfalse,// cropCV_32F);returnblob;}// --- 3. 创建 ONNX 张量 ---Ort::ValueBlobToONNXTensor(constcv::Matblob){std::vectorint64_ttensor_shape{1,3,blob.rows,blob.cols};// 注意blobFromImage 输出是 [1, 3, H, W]// 修正blobFromImage 返回的 Mat 维度是 [1, 3, H, W]但 data 是连续的// 这里的 rows 实际上是 channels (3) 如果直接取 blob.rows 可能不对取决于 blobFromImage 的实现// 标准 blobFromImage 返回的是 4 通道矩阵不它是单通道矩阵但包含多维数据或者直接访问 size// 更安全的做法是手动指定形状因为 blobFromImage 保证输出是 [1, 3, 640, 640]// 修正形状获取逻辑inthblob.size[2];intwblob.size[3];std::vectorint64_tshape{1,3,h,w};size_t input_size3*h*w;Ort::MemoryInfo memory_infoOrt::MemoryInfo::CreateCpu(OrtArenaAllocator,OrtMemTypeDefault);// 强制转换为 float*returnOrt::Value::CreateTensorfloat(memory_info,(float*)blob.data,input_size,shape.data(),shape.size());}// --- 4. 后处理坐标还原与裁剪 ---voidClipBox(cv::Rectbox,constcv::Sizeshape){box.xstd::max(0,std::min(box.x,shape.width));box.ystd::max(0,std::min(box.y,shape.height));box.widthstd::max(0,std::min(box.width,shape.width-box.x));box.heightstd::max(0,std::min(box.height,shape.height-box.y));}voidScaleYoloBoundingBox(YoloBoundingBoxbox,constcv::Sizeoriginal_shape,constcv::Sizeinput_shapecv::Size(640,640)){floatgainstd::min((float)input_shape.width/original_shape.width,(float)input_shape.height/original_shape.height);intpad_xint((input_shape.width-original_shape.width*gain)/2.0);intpad_yint((input_shape.height-original_shape.height*gain)/2.0);// 移除 paddingbox.bounding_box.x-pad_x;box.bounding_box.y-pad_y;// 缩放回原图box.bounding_box.x/gain;box.bounding_box.y/gain;box.bounding_box.width/gain;box.bounding_box.height/gain;ClipBox(box.bounding_box,original_shape);}// --- 5. 解析输出与 NMS ---std::vectorYoloBoundingBoxProcessYoloOutput(std::vectorOrt::Valueoutput,constcv::Sizeoriginal_image_size){std::vectorYoloBoundingBoxprocessed_boxes;// 获取输出张量数据// 输出形状通常为 [1, 84, 8400] - 转置后处理更方便或者直接按行读取// ONNX 输出是 [1, 84, 8400], 即 8400 个框每个框 84 维数据auto*output_dataoutput[0].GetTensorMutableDatafloat();autotype_infooutput[0].GetTensorTypeAndShapeInfo();autoshapetype_info.GetShape();// [1, 84, 8400]intnum_classes80;intnum_boxesshape[2];// 8400intfeaturesshape[1];// 84// 遍历所有预测框for(inti0;inum_boxes;i){// 指针偏移第 i 个框的数据起始位置// 数据布局是 [batch, features, boxes]所以第 i 个框的第 j 个特征索引是: 0 * (84*8400) j * 8400 i// 这种布局是列优先的 (Column-major in terms of boxes)Ultralytics 导出通常如此// 为了简化我们通常转置或者直接计算索引data[class_offset * num_boxes i]float*box_ptroutput_datai;// 步长是 num_boxesfloatx_centerbox_ptr[0*num_boxes];floaty_centerbox_ptr[1*num_boxes];floatwidthbox_ptr[2*num_boxes];floatheightbox_ptr[3*num_boxes];// 寻找最大类别分数floatmax_score0;intmax_class_idx-1;for(intc0;cnum_classes;c){floatscorebox_ptr[(4c)*num_boxes];if(scoremax_score){max_scorescore;max_class_idxc;}}// 阈值过滤if(max_score0.25f){// 转换为中心点格式到左上角格式 (x, y, w, h)floatxx_center-0.5f*width;floatyy_center-0.5f*height;YoloBoundingBox box;box.bounding_boxcv::Rect(int(x),int(y),int(width),int(height));box.confidencemax_score;box.class_idmax_class_idx;processed_boxes.push_back(box);}}// 坐标还原 (从 640x640 映射回原图)for(autobox:processed_boxes){ScaleYoloBoundingBox(box,original_image_size);}// 非极大值抑制 (NMS)std::vectorcv::Rectbboxes;std::vectorfloatscores;std::vectorintindices;for(constautobox:processed_boxes){bboxes.push_back(box.bounding_box);scores.push_back(box.confidence);}// NMS 阈值: 置信度 0.25, IoU 0.45 (可根据需求调整)cv::dnn::NMSBoxes(bboxes,scores,0.25f,0.45f,indices);std::vectorYoloBoundingBoxfinal_boxes;for(intidx:indices){final_boxes.push_back(processed_boxes[idx]);}returnfinal_boxes;}intmain(){// 1. 初始化环境Ort::Envenv(ORT_LOGGING_LEVEL_WARNING,YoloCppTutorial);// 2. 加载模型 (设置 use_cuda true 启用 GPU)std::string model_pathyolo11n.onnx;Ort::Session sessionLoadYoloModel(env,model_path,true);// 3. 读取图像std::string image_pathtest_image.jpg;cv::Mat imagecv::imread(image_path);if(image.empty()){std::cerrFailed to load image!std::endl;return-1;}// 4. 预处理cv::Mat blobImageToBlob(image);// 5. 创建输入张量Ort::Value input_tensorBlobToONNXTensor(blob);// 6. 获取输入输出名称Ort::AllocatorWithDefaultOptions allocator;std::string input_namesession.GetInputNameAllocated(0,allocator).get();std::string output_namesession.GetOutputNameAllocated(0,allocator).get();constchar*input_names[]{input_name.c_str()};constchar*output_names[]{output_name.c_str()};// 7. 推理autooutput_tensorssession.Run(Ort::RunOptions{nullptr},input_names,input_tensor,1,output_names,1);// 8. 后处理std::vectorYoloBoundingBoxresultsProcessYoloOutput(output_tensors,image.size());// 9. 绘制结果for(constautobox:results){cv::rectangle(image,box.bounding_box,cv::Scalar(0,255,0),2);// 绘制标签std::string labelcv::format(Class %d: %.2f,box.class_id,box.confidence);intbaseline;cv::Size textSizecv::getTextSize(label,cv::FONT_HERSHEY_SIMPLEX,0.6,2,baseline);cv::rectangle(image,cv::Point(box.bounding_box.x,box.bounding_box.y-textSize.height),cv::Point(box.bounding_box.xtextSize.width,box.bounding_box.y),cv::Scalar(0,255,0),-1);cv::putText(image,label,cv::Point(box.bounding_box.x,box.bounding_box.y),cv::FONT_HERSHEY_SIMPLEX,0.6,cv::Scalar(0,0,0),2);}cv::imshow(Detection Result,image);cv::waitKey(0);cv::imwrite(output.jpg,image);return0;}关键点解析内存管理 (RAII):ONNX Runtime 的 C API 遵循 RAII 原则。Ort::Session,Ort::Value等对象在超出作用域时会自动释放资源。唯独OrtCUDAProviderOptionsV2需要手动创建和释放如代码中所示因为它是一个 C 风格的结构体指针。数据布局陷阱:输入: OpenCV 的blobFromImage默认生成[N, C, H, W]且数据连续这与 ONNX 模型期望的格式一致。输出: YOLO 的输出通常是[1, 84, 8400]。这意味着数据在内存中是按“特征”连续存储的而不是按“框”连续存储的。错误写法认为data[i * 84]是第i个框。正确写法第i个框的x坐标位于data[0 * 8400 i]y坐标位于data[1 * 8400 i]。代码中的ProcessYoloOutput函数已正确处理此逻辑。Letterbox (黑边填充):YOLO 训练时使用了保持宽高比的填充。推理时必须完全复现这一过程否则检测框的位置会发生偏移。在后处理阶段必须根据填充的大小 (pad_x,pad_y) 和缩放比例 (gain) 将检测框映射回原始图像分辨率。性能优化:使用ORT_ENABLE_ALL图优化。如果是 GPU 部署确保链接了正确的 CUDA 版本的 ONNX Runtime。对于视频流处理可以将Session和MemoryInfo设为全局或类成员避免每帧重复创建。下一步视频处理: 将cv::imread替换为cv::VideoCapture循环读取帧。多线程: 使用生产者 - 消费者模型一个线程负责采集视频另一个线程负责推理。实例分割: 如果需要实现教程提到的第二部分分割需要解析模型输出的第二个头Mask Prototypes并在后处理中进行掩码解码。这个实现提供了一个坚实的基础可以直接用于生产环境的实时检测任务。