简介:这份源码资源面向计算机视觉方向的学生与开发者,聚焦于用C++结合ONNXRuntime推理引擎部署YOLOv8的ONNX模型,可解决毕业设计、期末大作业或课程设计中目标检测类项目落地难的问题。包内共28个文件,以cpp与h源码为主,涵盖检测、分割、姿态、OBB旋转框及RT-DETR等多种任务的推理实现,另附少量jpg、png、bmp测试图片与docx说明手册,压缩包约5.03MB,结构清晰、注释完整,新手也能看懂。目前已有427人学习下载,经过严格调试可稳定运行。读者可直接获得一套可复用的C++推理工程,理解ONNXRuntime加载模型、预处理与后处理的完整链路,并借助示例图片快速验证检测、分割、姿态等效果,为二次开发或答辩展示提供扎实基础。
1. C++ 与 onnxruntime 部署 YOLOv8:为什么这条路线值得走
训练完 YOLOv8,拿到一个.onnx文件,接下来才是真正见功夫的地方。Python 里model.predict()一行就能出框,但到了产线、边缘盒子、工控机、游戏辅助工具这类场景,Python 解释器加 GIL 的启动开销和内存占用往往直接劝退。这时候把推理塞进 C++,用 onnxruntime 做后端,是很多团队最终收敛到的方案:编译出来一个可执行文件,拷到目标机器上就能跑,不依赖 Python 环境,延迟可控,内存可控。
这条路线解决的核心问题是「模型怎么脱离训练框架独立运行」。ONNX 是中间表示,onnxruntime 是执行引擎,C++ 是宿主语言。三者拼起来,你得到的是一个跨平台、可静态链接、能嵌进现有 C++ 工程的推理模块。适合谁?做嵌入式部署的、做桌面端 AI 功能的、做高性能服务端的,以及被 Python 部署的启动时间和内存折磨过的工程师。下面从环境、加载、预处理、后处理一路讲到踩坑,都是能直接抄的代码。
2. 环境搭建与 onnxruntime 的选型:别一上来就编译源码
2.1 为什么优先用预编译动态库而不是自己编
很多人第一反应是去 GitHub 拉 onnxruntime 源码自己编译,觉得这样「可控」。血泪经验是:除非你要交叉编译到特定架构(比如鲲鹏 920、RK3588 这类 ARM 平台),否则直接用官方预编译包。自己编一个 CPU 版本,光是 CMake 配置加依赖下载就能耗掉半天,编出来的东西还不一定比官方包快。
选型上分三种情况。第一,x86 桌面或服务器,直接下onnxruntime-win-x64-xxx.zip或 Linux 对应的.tgz,里面有include和lib,链接进去就行。第二,ARM 平台如鲲鹏 920,官方也提供 aarch64 的预编译包,优先试。第三,实在没有对应架构的包,才考虑源码编译,这时候重点开--cmake_extra_defines onnxruntime_BUILD_SHARED_LIB=ON。
版本选择上,onnxruntime 1.16 以上对 YOLOv8 导出的 ONNX 支持比较稳,opset 建议在导出时锁 12 或 13。别用太老的版本,老版本对Resize、Split这些算子的实现有差异,容易出现输出对不上的玄学问题。
2.2 用 CMake 把 onnxruntime 接进 C++ 工程
工程组织建议这样:项目根目录下放third_party/onnxruntime,里面是解压后的 include 和 lib。CMakeLists 里用target_include_directories和target_link_libraries引进来,不要用全局的include_directories,否则工程一大就乱。
cmake_minimum_required(VERSION 3.15) project(yolov8_ort_demo CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # onnxruntime 预编译包解压后的路径 set(ORT_ROOT ${CMAKE_SOURCE_DIR}/third_party/onnxruntime) add_executable(yolov8_ort_demo src/main.cpp src/yolov8_engine.cpp) target_include_directories(yolov8_ort_demo PRIVATE ${ORT_ROOT}/include ${CMAKE_SOURCE_DIR}/include ) # Windows 链接 onnxruntime.lib,Linux 链接 libonnxruntime.so if(WIN32) target_link_libraries(yolov8_ort_demo PRIVATE ${ORT_ROOT}/lib/onnxruntime.lib) else() target_link_libraries(yolov8_ort_demo PRIVATE ${ORT_ROOT}/lib/libonnxruntime.so) endif()这段 CMake 的关键点有三个。CMAKE_CXX_STANDARD 17是因为 onnxruntime 的头文件用了不少 C++17 特性,用 C++11 编会报一堆错。ORT_ROOT集中管理路径,换机器只改这一处。链接库分平台处理,Windows 下是.lib导入库,运行时还需要把onnxruntime.dll拷到 exe 同目录,Linux 下是.so,运行时用LD_LIBRARY_PATH或rpath指定。
提示:Windows 上如果运行时报「找不到 onnxruntime.dll」,不是链接问题,是运行时动态库没放对位置。把 dll 和 exe 放同一目录最省事。
2.3 验证环境是否通的第一个最小程序
别急着写完整推理,先写个最小程序确认头文件能编、库能链、运行时能加载。
#include <onnxruntime_cxx_api.h> #include <iostream> int main() { // 创建环境,日志级别设为 WARNING,避免刷屏 Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "yolov8_demo"); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); // 单算子内部并行线程数 session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); // 开启全部图优化 try { Ort::Session session(env, "yolov8n.onnx", session_options); std::cout << "model loaded, inputs: " << session.GetInputCount() << ", outputs: " << session.GetOutputCount() << std::endl; } catch (const Ort::Exception& e) { std::cerr << "load failed: " << e.what() << std::endl; return -1; } return 0; }逻辑说明:Ort::Env全局一个就够,不要每个函数里都建。SetIntraOpNumThreads控制单个算子内部的并行度,CPU 上一般设成物理核心数,设太大反而因为线程切换变慢。ORT_ENABLE_ALL会做算子融合和常量折叠,对 YOLOv8 这种结构收益明显。Ort::Session构造时如果模型路径错或 opset 不支持,会抛Ort::Exception,一定要 try-catch,否则程序直接崩,连错误信息都看不到。
参数说明:ORT_LOGGING_LEVEL_WARNING是日志级别,调试阶段可以调成VERBOSE看算子分配情况,上线改回WARNING或ERROR。模型路径用相对路径时注意工作目录,IDE 里跑和命令行跑的工作目录可能不一样,这是新手最常见的翻车点。
3. 从 ONNX 加载到张量:输入输出名字和形状怎么拿
3.1 动态获取输入输出信息,别硬编码
YOLOv8 导出的 ONNX,输入名通常是images,输出名通常是output0,但这不是保证。不同导出脚本、不同版本可能变。硬编码名字的后果是换一个模型就崩。正确做法是运行时查。
Ort::AllocatorWithDefaultOptions allocator; // 输入信息 size_t num_inputs = session.GetInputCount(); for (size_t i = 0; i < num_inputs; ++i) { auto name = session.GetInputNameAllocated(i, allocator); auto shape = session.GetInputTypeInfo(i) .GetTensorTypeAndShapeInfo() .GetShape(); std::cout << "input[" << i << "] name=" << name.get() << " shape="; for (auto d : shape) std::cout << d << " "; std::cout << std::endl; } // 输出信息 size_t num_outputs = session.GetOutputCount(); for (size_t i = 0; i < num_outputs; ++i) { auto name = session.GetOutputNameAllocated(i, allocator); auto shape = session.GetOutputTypeInfo(i) .GetTensorTypeAndShapeInfo() .GetShape(); std::cout << "output[" << i << "] name=" << name.get() << " shape="; for (auto d : shape) std::cout << d << " "; std::cout << std::endl; }逻辑说明:GetInputNameAllocated返回的是Ort::AllocatedStringPtr,用.get()拿const char*。形状里如果出现-1,说明那一维是动态的,YOLOv8 导出时 batch 维经常是动态的,部署时要么固定成 1,要么在SessionOptions里做维度绑定。输出形状一般是[1, 84, 8400],84 是 4 个框坐标加 80 类分数,8400 是三个尺度特征图展平后的锚点数。
参数说明:allocator用默认的就行,不需要自定义。如果模型有多个输出(比如导出时带了辅助头),要遍历所有输出,别只取第一个。
3.2 构造输入张量:预处理必须和训练时对齐
YOLOv8 训练时的预处理是 letterbox 缩放加归一化到 0-1,通道顺序 RGB,布局 NCHW。这四点在 C++ 里必须一模一样,差一个都会导致框位置偏移或者置信度异常。
// 假设原图 cv::Mat img 是 BGR,已读入 int inp_w = 640, inp_h = 640; float scale = std::min(inp_w / (float)img.cols, inp_h / (float)img.rows); int new_w = (int)(img.cols * scale); int new_h = (int)(img.rows * scale); int pad_x = (inp_w - new_w) / 2; int pad_y = (inp_h - new_h) / 2; cv::Mat resized; cv::resize(img, resized, cv::Size(new_w, new_h)); cv::Mat canvas(inp_h, inp_w, CV_8UC3, cv::Scalar(114, 114, 114)); resized.copyTo(canvas(cv::Rect(pad_x, pad_y, new_w, new_h))); // BGR -> RGB, HWC -> CHW, 归一化 std::vector<float> input_tensor(1 * 3 * inp_h * inp_w); for (int c = 0; c < 3; ++c) { for (int y = 0; y < inp_h; ++y) { for (int x = 0; x < inp_w; ++x) { cv::Vec3b pixel = canvas.at<cv::Vec3b>(y, x); // c=0 取 R(原 BGR 的 index 2),c=1 取 G,c=2 取 B float v = pixel[2 - c] / 255.0f; input_tensor[c * inp_h * inp_w + y * inp_w + x] = v; } } }逻辑说明:letterbox 的填充值 114 是 YOLO 系列的惯例,训练时用的就是这个灰值,推理时必须一致。通道变换这里用pixel[2 - c]完成 BGR 到 RGB,同时按 CHW 布局写入一维数组。归一化除以 255 是 YOLOv8 默认行为,如果你训练时改了归一化方式,这里要跟着改。
参数说明:inp_w、inp_h必须和导出 ONNX 时的输入尺寸一致,YOLOv8 默认 640。scale取宽高缩放比的较小值,保证整图能放进画布。pad_x、pad_y是居中填充的偏移,后处理还原框坐标时要用到,必须存下来。
3.3 跑一次推理并取回输出
// 输入输出名从前面动态获取的结果里取 const char* input_names[] = {"images"}; const char* output_names[] = {"output0"}; std::vector<int64_t> input_shape = {1, 3, inp_h, inp_w}; Ort::MemoryInfo mem_info = Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor_ort = Ort::Value::CreateTensor<float>( mem_info, input_tensor.data(), input_tensor.size(), input_shape.data(), input_shape.size()); auto outputs = session.Run(Ort::RunOptions{nullptr}, input_names, &input_tensor_ort, 1, output_names, 1); float* out_data = outputs[0].GetTensorMutableData<float>(); auto out_shape = outputs[0].GetTensorTypeAndShapeInfo().GetShape(); // out_shape 预期为 [1, 84, 8400]逻辑说明:CreateTensor把已有的std::vector<float>内存包装成 Ort 张量,不拷贝,所以input_tensor在Run期间不能释放。Run的输入输出名数组要和实际模型对上,数量也要对。输出张量的内存由 onnxruntime 管理,GetTensorMutableData拿到指针后直接读,不要试图 free。
参数说明:OrtArenaAllocator是内存分配器类型,CPU 上用默认的就行。OrtMemTypeDefault表示默认内存类型。如果后面要上 GPU,这里换成 CUDA 的 MemoryInfo,但那是另一个话题。
4. 后处理:把 [1,84,8400] 变成能画的框
4.1 解码逻辑与置信度过滤
输出[1, 84, 8400]里,前 4 行是cx, cy, w, h,后 80 行是类别分数。注意 YOLOv8 的输出没有单独的 objectness,类别分数直接就是最终置信度。解码时先转置成[8400, 84]更好遍历,或者直接按列访问。
int num_classes = 80; int num_anchors = 8400; float conf_thres = 0.25f; float iou_thres = 0.45f; std::vector<cv::Rect> boxes; std::vector<float> scores; std::vector<int> class_ids; for (int i = 0; i < num_anchors; ++i) { float* row = out_data + i; // 注意:这里按 [84,8400] 布局,行是类别 // 实际访问应为 out_data[c * num_anchors + i] float cx = out_data[0 * num_anchors + i]; float cy = out_data[1 * num_anchors + i]; float w = out_data[2 * num_anchors + i]; float h = out_data[3 * num_anchors + i]; int best_class = -1; float best_score = 0.0f; for (int c = 0; c < num_classes; ++c) { float s = out_data[(4 + c) * num_anchors + i]; if (s > best_score) { best_score = s; best_class = c; } } if (best_score < conf_thres) continue; // 还原到原图坐标:先减 padding,再除以 scale float x = (cx - w / 2 - pad_x) / scale; float y = (cy - h / 2 - pad_y) / scale; float bw = w / scale; float bh = h / scale; boxes.emplace_back(cv::Rect((int)x, (int)y, (int)bw, (int)bh)); scores.push_back(best_score); class_ids.push_back(best_class); }逻辑说明:输出布局是[1, 84, 8400],所以第c个通道第i个锚点的值在out_data[c * 8400 + i]。前四个通道是框,后面是类别。取最大类别分数作为该框的置信度,低于阈值直接丢。坐标还原是 letterbox 的逆操作:先减 padding 偏移,再除以缩放比。
参数说明:conf_thres0.25 是常用起点,误检多就调高,漏检多就调低。iou_thres0.45 用于 NMS,密集场景可以调到 0.5 以上。pad_x、pad_y、scale必须用预处理时存下来的值,不能重新算。
4.2 NMS 与结果绘制
std::vector<int> indices; cv::dnn::NMSBoxes(boxes, scores, conf_thres, iou_thres, indices); for (int idx : indices) { cv::Rect box = boxes[idx]; cv::rectangle(img, box, cv::Scalar(0, 255, 0), 2); std::string label = std::to_string(class_ids[idx]) + ":" + std::to_string(scores[idx]).substr(0, 4); cv::putText(img, label, cv::Point(box.x, box.y - 5), cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(0, 255, 0), 1); }逻辑说明:cv::dnn::NMSBoxes是 OpenCV 自带的 NMS,输入框、分数、阈值,输出保留的索引。绘制时用绿色框加类别和分数标签。如果不想依赖 OpenCV 的 dnn 模块,自己写 NMS 也就二十行,按分数排序后逐个算 IoU 抑制。
参数说明:NMSBoxes的conf_thres和iou_thres与前面过滤保持一致。substr(0, 4)只是截断显示,别在正式输出里这么干,会丢精度。
4.3 性能上值得做的两件事
第一,预处理里的三重循环是性能热点。640x640x3 接近 123 万次迭代,用cv::Mat的at访问带边界检查,慢。改成指针遍历或者用cv::dnn::blobFromImage会快不少,但blobFromImage默认做的是减均值缩放,要确认和训练预处理一致。第二,session.Run每次调用都有开销,如果做视频流,考虑用Ort::IoBinding绑定输入输出内存,减少拷贝。
5. 避坑与排查:那些让框画不出来的原因
5.1 框位置整体偏移或缩放不对
现象:框能出来,但位置系统性偏左上或偏右下,或者框比实际物体大一圈小一圈。原因:letterbox 的pad_x、pad_y、scale在预处理和后处理之间不一致,常见于把预处理封装成函数后这些值没传出来,后处理里重新算了一遍但用了不同的取整方式。解决:把这三个值放进一个结构体,预处理填充,后处理读取,中间不重算。取整统一用int截断,别一处round一处floor。
5.2 置信度全是 0 或者全是 1
现象:输出张量里数值异常,要么接近 0,要么饱和。原因:归一化没做或做了两次。YOLOv8 导出 ONNX 时如果带了归一化层,C++ 里再除 255 就变成两次。解决:用 Netron 打开 ONNX 看输入节点后面有没有Div或Mul常量,有的话 C++ 里就不要再归一化。另一个原因是通道顺序反了,BGR 当 RGB 喂进去,分数会普遍偏低但不至于全 0。
5.3 换模型后输出形状对不上
现象:之前跑 80 类模型正常,换成一个自定义 3 类模型后程序读越界或框全乱。原因:类别数变了,输出从[1, 84, 8400]变成[1, 7, 8400],但代码里num_classes还写死 80。解决:num_classes从输出形状动态算,out_shape[1] - 4就是类别数。锚点数out_shape[2]也动态取,别写死 8400,换输入尺寸或换模型结构都会变。
5.4 Windows 下 Debug 能跑 Release 崩
现象:VS 里 Debug 模式推理正常,切 Release 直接 access violation。原因:onnxruntime 的预编译库通常是 Release 版,Debug 版程序链接 Release 库时,STL 容器和内存分配器的 ABI 可能不匹配。解决:要么统一用 Release 编译自己的程序,要么找 Debug 版的 onnxruntime 库。这个坑在c#调用c++出现access violation c0000005这类场景里也常见,本质是跨模块内存管理不一致。
5.5 多线程下结果错乱
现象:单线程跑正常,开多线程后框随机错乱或崩溃。原因:Ort::Session本身线程安全,但Ort::Env和共享的输入输出缓冲区不是。多个线程共用一个input_tensor向量会互相覆盖。解决:每个线程独立的输入缓冲和Ort::Value,Session可以共享。或者用IoBinding给每个线程绑自己的内存。
6. 进阶:把推理封装成可复用引擎与量化提速
走到这里,你已经能跑通单张图。但工程上要的是可复用、可配置、能上量的东西。我一般会把整个流程封成一个YoloV8Engine类,构造时传模型路径、输入尺寸、阈值,infer方法接收cv::Mat返回框列表。这样换模型只改构造参数,业务代码不动。
class YoloV8Engine { public: YoloV8Engine(const std::string& model_path, int inp_size = 640, float conf = 0.25f, float iou = 0.45f); std::vector<Detection> infer(const cv::Mat& img); private: Ort::Env env_; Ort::Session session_; int inp_size_; float conf_thres_, iou_thres_; std::string input_name_, output_name_; int num_classes_, num_anchors_; };构造时把输入输出名和形状查出来存成成员,infer里不再重复查询。Detection结构体放rect、score、class_id。这样封装后,主程序里就是读图、调infer、画框三步。
量化方面,.onnx转 int8 能明显降内存和提速,但精度会掉。CPU 上用 onnxruntime 的量化工具:
python -m onnxruntime.quantization.preprocess \ --input yolov8n.onnx --output yolov8n_prep.onnx python -m onnxruntime.quantization.quantize_static \ --input yolov8n_prep.onnx --output yolov8n_int8.onnx \ --calibrate_dataset calib_data/ --quant_format QDQpreprocess先做图优化,quantize_static做静态量化,需要一批校准图。QDQ格式兼容性好,QOperator在某些后端上更快但支持面窄。量化后务必用同一批测试图对比 float 和 int8 的框,IoU 掉超过 5% 就要考虑是不是校准集不具代表性。我自己的习惯是:先跑通 float,再量化,量化后如果精度掉得厉害,宁可不上 int8,用 float16 或者干脆保持 float32,别为了省那点内存把检测质量搭进去。
验证方法上,除了肉眼看框,建议写个脚本把 C++ 输出和 Pythonmodel.predict()的输出做数值对比,同一张图,框坐标差在 1 像素内、分数差在 0.01 内,才算对齐。这个对齐步骤能帮你把预处理和后处理的偏差一次性揪出来,比在 C++ 里瞎调参数高效得多。
希望帮到你。
本文还有配套的精品资源,点击获取