简介:面向C++开发者的YOLOv8目标检测部署资源,完整演示如何借助ONNXRuntime加载并运行YOLOv8模型。YOLOv8是YOLO系列最新迭代,在保持快速检测速度的同时提升精度;ONNXRuntime则作为跨平台高性能推理引擎,支持CPU/GPU等多种硬件后端,二者的搭配非常适合生产环境下的实时目标检测。压缩包共3个文件,包含yolov8n.onnx与yolov8n-seg.onnx两个模型文件,分别对应检测与分割任务,另有一个main.cpp示例源码,整体仅21.66MB,便于快速下载和验证。内容从依赖安装到模型加载、推理输出解析均有完整步骤,同时说明了YOLOv8在主干网络、损失函数与多尺度训练等方面的改进,代码注释清晰,适合已有一定C++基础、希望把Python训练好的模型迁移到C++生产环境的开发者,也适合初探ONNX Runtime的读者作入门参考。目前已有2746人学习,参考示例可减少环境配置和推理环节的常见踩坑,快速实现实时目标检测。
1. 用C++把yolov8模型部署到生产环境:OnnxRuntime这条路为什么值得走
手上有训练好的yolov8模型,要交到C++工程里跑推理,我第一个想到的部署方案就是OnnxRuntime。和libtorch比,它不需要把整个PyTorch运行时拖着走;和TensorRT比,它对CUDA版本和显卡型号的要求低很多,CPU也能跑。这个方案解决的核心问题很明确:把yolov8的pt模型导出成onnx之后,用C++在Windows或Linux上把推理流程完整串起来,包括环境配置、CMake工程、图像预处理、模型前向、坐标解码和NMS后处理。适合手里已有自训练模型、想在真实项目里把它当组件用的工程师,也适合往RK3588这类边缘设备走的前期验证——先把C++侧的推理链路跑通,后面换NPU工具链只是换后端的问题。下面按我实际跑通一套流程的顺序,把这些步骤完整拆开写。
2. 部署前置环境:VS2022、CMake与OnnxRuntime动态库的准备
OnnxRuntime部署翻车最多的地方往往不是模型,而是环境。库位数对不上、运行库缺失、动态库路径不对,任何一个问题都能让程序在启动阶段就崩掉。所以第一步不是写代码,而是把工具链和依赖理清楚。常见的组合是Visual Studio 2022做编译环境,CMake做工程构建,OpenCV负责图像读取和画框,OnnxRuntime的Windows x64动态库做推理引擎。这套组合在Windows上最省心,Linux下把VS换成g++即可,代码不用大改。
2.1 Visual C++ Redistributable和MSVC工具集:装错一个后面全崩
很多人分不清编译期工具集和运行期运行库。VS2022里勾选“使用C++的桌面开发”工作负载后,本机能编译出exe,但如果目标机器没有安装对应版本的运行库,程序一启动就会提示缺少vcruntime140.dll或msvcp140.dll。这里的对应关系是:MSVC工具集负责编译,Microsoft Visual C++ 2015-2022 Redistributable (x64)负责在运行期提供标准库实现。我一般会把这些检查写成一个checklist,部署到新机器时先过一遍。
| 依赖项 | 作用 | 检查方式 |
|---|---|---|
| VS2022 C++工作负载 | 编译期工具集 | 命令行执行 cl,能输出版本号即可 |
| Visual C++ 2015-2022 Redistributable (x64) | 运行期标准库 | 控制面板查看已安装程序列表 |
| CMake 3.16+ | 工程构建 | cmake --version |
| OpenCV | 图像读取、预处理、画框 | 检查 opencv_world4xx.dll 所在目录 |
| OnnxRuntime 动态库 | 推理引擎 | 解压后确认 onnxruntime.dll 与 onnxruntime.lib 同时存在 |
另外注意一点:VS编译时如果选了x86平台,链接x64的onnxruntime.lib,链接阶段就会报LNK2019。这类报错看起来像代码问题,其实只是目标平台选错。CMake生成项目时用-A x64参数,VS里手动建工程时把解决方案平台切到x64,这个坑就能绕开。
提示:Debug和Release混用也会出问题。OnnxRuntime官方发行包没有单独区分Debug/Release,但你的工程CRT模式要统一,不要在一个进程里混用MT和MD,否则大概率在dll边界崩溃。
2.2 CMakeLists.txt:链接OnnxRuntime和OpenCV的标准写法
OnnxRuntime的Windows发行包解压后目录结构是:include目录放着onnxruntime_cxx_api.h,lib目录放着onnxruntime.lib(导入库)和onnxruntime.dll(动态库)。CMake里最直接的方式是把根目录配成一个变量,然后include和link分别指向对应子目录,这是我自己一直在用的写法。
cmake_minimum_required(VERSION 3.16) project(yolov8_ort) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(OpenCV REQUIRED) set(ONNXRUNTIME_ROOT "D:/thirdparty/onnxruntime-win-x64") include_directories(${ONNXRUNTIME_ROOT}/include) link_directories(${ONNXRUNTIME_ROOT}/lib) add_executable(yolov8_ort main.cpp) target_link_libraries(yolov8_ort PRIVATE ${OpenCV_LIBS} onnxruntime) add_custom_command(TARGET yolov8_ort POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different "${ONNXRUNTIME_ROOT}/lib/onnxruntime.dll" "$<TARGET_FILE_DIR:yolov8_ort>")这段配置里有几个关键点。ONNXRUNTIME_ROOT指向解压后的OnnxRuntime根目录,路径最好不要带空格,Windows下CMake对带空格的路径处理起来会有各种奇怪问题。link_directories写在add_executable之前,因为target_link_libraries解析onnxruntime库名时需要在链接目录里找到它,这个顺序错了会出现“找不到onnxruntime.lib”的报错。最后用add_custom_command在编译完成后自动把onnxruntime.dll拷贝到exe同级目录,这比手动拖文件可靠得多,也避免了运行时“找不到DLL”的尴尬。
2.3 下载OnnxRuntime动态库:zip包比NuGet包好控制
获取OnnxRuntime有两条路:NuGet包或GitHub Releases的zip包。NuGet的优势是VS里直接引用,依赖自动带进来,但缺点是你对具体版本的掌控变弱,公司内网如果NuGet源不通,反而要给VS配代理,成本更高。我更倾向直接下载zip包,解压到一个固定的thirdparty目录,CMake里写死路径。这样整个工程是自包含的,换机器部署时连thirdparty一起拷走,不依赖外网,也不依赖NuGet源。
一个容易被忽略的细节是:OnnxRuntime官方发布包分为CPU版和GPU版。如果你的程序里用了CUDA provider,却下载了CPU版动态库,运行时会直接报找不到onnxruntime_providers_cuda.dll。所以下载前先确认自己的目标环境——纯CPU推理就下CPU包,有独立显卡且环境里装好了CUDA和cuDNN,才考虑GPU包。我一般会在thirdparty目录下保留两个子目录,比如onnxruntime-win-x64和onnxruntime-win-x64-gpu,CMake里用开关切换,后面想从CPU切到GPU时不用改代码结构。
3. 从yolov8的PyTorch模型导出Onnx:固定输入形状与简化模型的取舍
PyTorch模型不能直接喂给OnnxRuntime,中间必须经过onnx导出这一步。这个步骤看起来简单,但导出的参数选择直接决定C++端的代码复杂度和运行稳定性。我的建议是:固定输入尺寸、固定batch为1、开启算子简化,用这三条把模型导出成一个“笨但可靠”的onnx文件。
3.1 用ultralytics的export命令导出:固定640、不设动态维度、opset设为12以上
yolov8训练完成后得到的是best.pt,导出onnx可以用ultralytics自带的命令行工具,也可以写Python脚本调用。命令行方式最简洁,参数也够用。
yolo export model=best.pt format=onnx imgsz=640 dynamic=False simplify=True opset=12这段命令里,imgsz=640把输入固定成640x640,送进来的图片会被letterbox到该尺寸;dynamic=False关闭动态轴,导出后的输入shape就是固定的[1,3,640,640],C++侧不需要处理维度变化;simplify=True会调用onnx-simplifier清理计算图中的冗余算子,去掉一些PyTorch导出的残留结构,这个参数对减少C++侧报错很有帮助;opset=12是OnnxRuntime支持较好的算子集版本,太低可能缺算子,太高则要求OnnxRuntime版本足够新。
如果不用命令行,Python脚本调model.export()是同样的参数,只是包了一层。但有一点要留意:导出前确认模型处于eval模式,BatchNorm和Dropout层在训练和推理时的行为不同,PyTorch导出onnx时如果忘了切eval,导出的模型跑出来的结果会和训练时对不上。
3.2 导出后必须做的一步:打印输入输出名字和shape
很多C++端的问题是模型导出后没有检查导致的。C++代码里配输入输出名时,写错一个字符串就会报“Invalid Feed Input Name”,而这类字符串错误在Python脚本里反而不容易暴露。所以我导出后第一件事,是用Python快速加载onnx文件,把输入输出的名字和shape打印出来。
import onnxruntime as ort sess = ort.InferenceSession("best.onnx", providers=["CPUExecutionProvider"]) for inp in sess.get_inputs(): print("input:", inp.name, inp.shape, inp.type) for out in sess.get_outputs(): print("output:", out.name, out.shape, out.type)输出通常长这样:输入名为images,shape是[1,3,640,640];输出名为output0,shape是[1,84,8400]或[1,8400,84]。这个84的含义是4个坐标参数加80个类别得分,4对应中心点x、中心点y、宽度、高度;8400是三个尺度特征图的预测框总数,来自640分辨率下80x80加40x40加20x20的网格累加。打印出来记到程序注释里,后面写C++后处理时,所有下标公式都从这里推,不要靠猜。
3.3 固定batch导出还是动态batch导出:C++侧的复杂度差别很大
有一种做法是导出时设置dynamic=True,让模型接受任意batch的输入。这在Python里做批量推理时很灵活,但C++侧要付出代价:每次推理都要根据实际batch数动态创建Tensor对象,还要处理输出shape在不同batch下的变化。而固定batch=1后,输入输出shape全部是编译期常量,后处理循环的上界写死就行,内存分配也能提前做好。对绝大多数部署场景来说,单张图片推理已经是常态,batch=1足够。
我在生产环境里从不用动态batch。如果确实有多张图同时推理的需求,更推荐的做法是保底设置batch=1,上层用线程池并发处理多路请求,而不是把一个batch塞给模型。这样做的好处是推理延迟更稳定,同时也避免动态shape触发图优化失效。部署不是做实验,追求的是稳定性,固定shape是最省心的选择。
注意:导出后最好用onnxruntime的Python接口跑一次同样的输入,确认输出非零且shape正确。这一步能筛掉一半以上的部署期问题。
4. C++端推理实现:从OpenCV的Mat到输入张量、模型前向、坐标解码
到这一步,环境准备好了,onnx也导出了,接下来的核心是把C++推理代码写对。整个流程可以拆成四段:Session初始化、前处理、推理、后处理。每段都有容易写错的细节,尤其是内存排布和坐标映射,错一个环节结果就跑偏。下面按顺序给出一套可用的最小实现,代码里注释标明了每个参数的含义。
4.1 初始化Session:SessionOptions里值得调的三个参数
OnnxRuntime的C++ API启动一个推理会话很简单,但几个选项对性能和稳定性影响很大。我只调三个:图优化级别、线程数、执行后端。图优化级别设成ORT_ENABLE_ALL,让OnnxRuntime在加载模型时做融合和算子替换;线程数按机器核数和并发路数来定,不是越大越好;后端在CPU和CUDA之间选一个,默认走CPU。
#include <onnxruntime_cxx_api.h> Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "yolov8_deploy"); Ort::SessionOptions opts; // 图优化级别:ALL 会做算子融合,对推理速度提升明显 opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); // CPU线程数:4线程在大多数x86机器上比默认值更稳 opts.SetIntraOpNumThreads(4); #ifdef USE_CUDA // 启用CUDA执行后端,需要链接onnxruntime的GPU版本 OrtCUDAProviderOptions cuda_options; opts.AppendExecutionProvider_CUDA(cuda_options); #endif Ort::Session session(env, L"best.onnx", opts);这里有两个容易踩的坑。第一,Session对象构造时就完成了模型加载和内存分配,所以Session的生命周期应该覆盖整个程序运行期,不要每次推理都创建一个新Session——构造开销不小,而且重复加载模型会让内存碎片化。第二,如果启用了USE_CUDA但链接的是CPU版OnnxRuntime,程序会在AppendExecutionProvider_CUDA这行抛异常,原因是CPU版dll里根本没有CUDA相关的provider符号。CMake里做开关时,要把编译宏和链接库绑在一起切换。
4.2 前处理:letterbox缩放加padding,直接用resize是错的
yolov8训练时用的是letterbox预处理:图片等比缩放到640x640的短边,剩余区域用114灰度值填充。如果直接cv::resize把任意尺寸图片拉成640x640,目标会被拉伸变形,检测精度会肉眼可见地下降。这一步的代码要同时输出缩放比例和padding偏移量,因为后处理要把检测框坐标映射回原图,没有这两个值等于白做。
#include <opencv2/opencv.hpp> #include <vector> struct LetterBoxResult { float scale; int pad_left; int pad_top; }; cv::Mat letterbox(const cv::Mat& src, int target_size, LetterBoxResult* info) { int h = src.rows, w = src.cols; float r = std::min((float)target_size / w, (float)target_size / h); int new_w = std::round(w * r); int new_h = std::round(h * r); cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h), 0, 0, cv::INTER_LINEAR); int dw = target_size - new_w; int dh = target_size - new_h; int left = dw / 2; int right = dw - left; int top = dh / 2; int bottom = dh - top; cv::Mat out; cv::copyMakeBorder(resized, out, top, bottom, left, right, cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114)); info->scale = r; info->pad_left = left; info->pad_top = top; return out; }这段实现里有两个细节值得解释。dw - left和dh - top的写法是为了处理奇数像素的padding:640减去奇数尺寸的缩放结果,差值如果是奇数,左右两侧的padding会差1像素,直接各自除以2会让总尺寸不对。另外,padding值用114是有依据的,yolov8训练代码里用的就是114这个灰度值,训练和推理的预处理要保持一致,用其他值虽然不一定崩,但精度会受影响。
4.3 推理:HWC转CHW的连续内存,这是最容易出错的环节
推理这一步,OpenCV读进来的图像是HWC排布,而OnnxRuntime要求NCHW连续内存。转换逻辑不复杂,但漏掉这一步,模型输出直接是噪声。转换时还要把颜色通道从BGR换到RGB,因为yolov8训练时用的是RGB顺序。
std::vector<float> input_tensor_values(1 * 3 * 640 * 640); // HWC -> CHW,同时 BGR -> RGB,顺便做归一化到 [0,1] cv::Mat rgb; cv::cvtColor(letter, rgb, cv::COLOR_BGR2RGB); for (int c = 0; c < 3; ++c) { for (int i = 0; i < 640 * 640; ++i) { input_tensor_values[c * 640 * 640 + i] = rgb.data[i * 3 + c] / 255.0f; } } std::array<int64_t, 4> input_shape{1, 3, 640, 640}; Ort::MemoryInfo memory_info = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor = Ort::Value::CreateTensor<float>( memory_info, input_tensor_values.data(), input_tensor_values.size(), input_shape.data(), input_shape.size()); std::array<const char*, 1> input_names{"images"}; std::array<const char*, 1> output_names{"output0"}; auto output_tensors = session.Run(Ort::RunOptions{nullptr}, input_names.data(), &input_tensor, 1, output_names.data(), 1); float* raw_output = output_tensors[0].GetTensorMutableData<float>();这段代码里,input_tensor_values这个vector的生命周期必须覆盖到session.Run调用结束。因为CreateTensor只是包装了这块内存,没有做拷贝,vector在Run之前被释放的话,相当于给模型喂了一块野指针。另外,rgb.data[i * 3 + c]这个表达式是从HWC数据里连续取像素,外层按通道c循环,内层按像素i循环,这种写法才能正确生成CHW排布。输出拿到了raw_output,是一个原始的float数组,后续所有解码逻辑都要基于前面打印出来的shape[1,84,8400]来定位数据。
4.4 后处理:解码8400个预测框、sigmoid、NMS一步到位
yolov8的输出不是已经解码好的坐标框,需要自己从84x8400的矩阵里把坐标和类别得分读出来。这里的关键是理解内存布局。以[1,84,8400]为例,8400个框按列排列,第一行所有元素是第一个坐标参数在所有框上的值,第二行是第二个参数,以此类推。所以读框i的中心点x时,要取raw_output[0 * 8400 + i],而不是raw_output[i * 84 + 0]。如果导出的模型输出是[1,8400,84],两张布局对应的方法正好相反,后者要按框优先读取。
const int num_classes = 80; const int num_boxes = 8400; std::vector<cv::Rect> boxes; std::vector<float> scores; std::vector<int> class_ids; for (int i = 0; i < num_boxes; ++i) { float cx = raw_output[0 * num_boxes + i]; float cy = raw_output[1 * num_boxes + i]; float w = raw_output[2 * num_boxes + i]; float h = raw_output[3 * num_boxes + i]; float best_score = 0.0f; int best_class = -1; for (int j = 0; j < num_classes; ++j) { float score = 1.0f / (1.0f + std::exp(-raw_output[(4 + j) * num_boxes + i])); if (score > best_score) { best_score = score; best_class = j; } } if (best_score < 0.25f) continue; float x1 = cx - w / 2.0f; float y1 = cy - h / 2.0f; float x2 = cx + w / 2.0f; float y2 = cy + h / 2.0f; boxes.emplace_back(x1, y1, x2 - x1, y2 - y1); scores.push_back(best_score); class_ids.push_back(best_class); } std::vector<int> keep_indices; cv::dnn::NMSBoxes(boxes, scores, 0.25f, 0.45f, keep_indices);这段代码包含两层筛选。第一层是置信度阈值0.25,低于阈值的框直接丢弃,这个值对应yolov8训练时默认的conf阈值,你可以在自己的验证集上调参。第二层是NMS,IoU阈值0.45,用于抑制同一个目标上的重复框。NMS里传进的是letterbox坐标系下的矩形,最后输出到原图时还要做坐标逆变换。
坐标逆变换的公式是:x_orig = (x_box - pad_left) / scale,y_orig = (y_box - pad_top) / scale。scale和pad来自前处理时记录的值。如果画框时发现框的位置偏了但类别是对的,九成是这一步的映射写错了。
注意:sigmoid这一步要看情况取舍。如果在导出onnx前模型已经用推理模式导出,部分版本的ultralytics会把sigmoid融合进输出里;如果输出值范围已经落在[0,1]区间,就不用再做sigmoid。保险做法是打印一下原始输出的min/max再决定。
5. OnnxRuntime部署yolov8的避坑记录:5个让新手翻车的真实案例
部署过程中遇到的问题,绝大多数不是模型问题,而是程序和数据之间的细节错位。下面这五条是我自己踩过的坑,按“现象、原因、解决”的方式记录,希望你能直接绕开。
5.1 现象:推理结果全是负数或全为0
第一次跑通C++部署时,打印出的980000个输出值没有一个像正常概率。原因有两类:一是模型输出的是logits,没做sigmoid就直接拿去做阈值筛选;二是输入图像的预处理没有归一化,或者BGR和RGB的顺序搞反了,导致模型输出完全乱套。
解决:在解码前先写一行打印逻辑,把raw_output的min、max、mean打出来。如果最大值为几位数而不是介于0和1之间,就在类别得分处补上sigmoid。同时检查输入张量的值是不是已经是[0,1]区间,把归一化放到HWC转CHW的循环里一起做掉。
5.2 现象:链接错误LNK2019,无法解析的外部符号
这个报错出现在编译阶段,错误信息指向某个OnnxRuntime函数无法解析。原因基本可以锁定在库位数不匹配或导入库版本不一致。最常见的情况是CMake没指定-A x64,默认生成了Win32工程,链接x64的onnxruntime.lib当然找不到符号。
解决:重新生成CMake工程时加上-A x64,VS里手动建工程则检查解决方案平台。还有一类情况是下载的onnxruntime.zip解压后不完整,lib目录里只有onnxruntime.dll没有onnxruntime.lib,导入库缺失也会报同样的错。解压后先确认这两个文件都在。
5.3 现象:检测框错位但类别得分是对的
画出来的框比目标大了一圈或者偏到角落,但框里的分类结果完全正确。原因出在坐标参考系混用:后处理输出的坐标是letterbox后的640x640坐标系,画框时直接画到了原图上,没有做缩放和平移的逆变换。
解决:把前处理记录的scale、pad_left、pad_top保留下来,画框前统一做映射。映射代码我一般这样写:
cv::Rect orig_rect; orig_rect.x = (box.x - pad_left) / scale; orig_rect.y = (box.y - pad_top) / scale; orig_rect.width = box.width / scale; orig_rect.height = box.height / scale;注意这里x和y都要先减padding再除scale,顺序反过来结果也是错的。
5.4 现象:运行时提示找不到onnxruntime.dll
exe编译成功了,双击运行却弹窗说找不到onnxruntime.dll。原因是程序启动时会在exe所在目录和环境变量PATH里找这个dll,而你的dll只存在于thirdparty目录,Windows根本没去那里找。
解决:用CMake里的add_custom_command在链接后把dll拷贝到exe目录,这是最省心的方式。如果已经生成了工程,手动把onnxruntime.dll复制到exe同级目录也有效。不要为了省事直接把dll放到C:\Windows\System32,污染系统目录,后面机器上多个版本冲突时你会后悔。
5.5 现象:装好了CUDA推理却依然走CPU
代码里启用了CUDA provider,程序也正常运行,但推理速度没提升,一看日志才发现执行后端是CPU。原因通常是链接的onnxruntime还是CPU版动态库,或者CUDA版dll的依赖不完整,缺少onnxruntime_providers_cuda.dll和对应的cuDNN库。
解决:在代码里打印实际执行后端。OnnxRuntime C++ API可以在Session构造后调用GetExecutionProviders()获取当前启用的provider列表。如果列表中只有CPUExecutionProvider,就去检查CMake链接的是不是onnxruntime-gpu的lib目录,并把GPU包目录下所有dll都拷贝到程序目录。
6. 验证部署结果与进阶技巧:从“能跑”到“能上线”
代码跑通只是第一步,验证结果是否正确、性能是否达标,才是真正决定能不能上线的东西。我习惯的做法是拿同一张图分别跑PyTorch和C++部署,比较类别得分和坐标,允许浮点误差但不能有量级差异。具体操作是:把PyTorch的输出导出成txt,再在C++侧打印同样格式的结果,一行一行对比。如果没有py文件想对比,也可以直接拿C++的检测框和标定好的真实框做IoU计算,IoU大于0.5基本可以认为部署正确。
进阶方向上,值得做的是把模型转成FP16后用GPU推理。onnx模型转FP16可以直接调用onnxruntime的转换工具,或者用onnxmltools这类库,推理时显存占用大约减半,速度在TensorCore上有提升,但精度会略微下降。如果业务场景对精度极其敏感,转完FP16后要在验证集上重新评估mAP。
另一个要注意的是Session复用。OnnxRuntime的Session是线程安全的,多路摄像头或并发请求场景下,所有线程共用同一个Session实例就可以,不要每个线程新建一个。每路推理只需要独立的前处理缓冲区和后处理结果区,Session本身不保存中间状态。
最后说一个我的习惯:拿到新导出的onnx后,先写一个简单的dump工具,把输入输出名、shape、类型打印出来存成json,后面C++接口配错了直接对着json查。这比每次翻代码找字符串靠谱得多。部署这个方向没有银弹,把每一层细节做扎实,跑通只是开始,能稳定处理极端图片才是上线标准。希望帮到你。
本文还有配套的精品资源,点击获取