☰
用C++部署YOLOv8 ONNX模型:从PyTorch导出到NMS后处理全流程
2026/10/12 1:24:06 网站建设 项目流程

简介:这是基于C++与onnxruntime部署YOLOv8 ONNX模型的高分项目源码,面向需要进行毕业设计、期末大作业或课程设计的计算机视觉方向学生与开发者。工程同时提供OpenCV DNN与ONNXRuntime两种推理后端,覆盖目标检测、实例分割、姿态估计及旋转框检测等任务,代码注释完整,逻辑清晰,便于新手理解与二次开发。资源包共28个文件,约5.03MB,主要包括11个C++源文件、10个头文件、模型放置说明、测试图片与使用手册,目录结构简洁,下载后按提示放置模型即可快速运行。目前已有428人学习使用,适合希望以YOLOv8部署为项目核心并快速搭建可演示系统的人群。项目经严格调试,功能完整、界面直观且具备较强扩展性,可作为本科毕设或课程设计直接使用。

1. 用C++和ONNX Runtime部署YOLOv8的ONNX模型:为什么这条链路值得你亲手搭一遍

训练好的YOLOv8在Python里跑得再欢,一到交付环节,对方甩过来一个C++工程让你把检测模型接进去,瞬间就能体会到什么叫“模型好训,落地头大”。基于C++和onnxruntime部署yolov8的onnx模型源码,解决的就是这一公里:把PyTorch权重导出成ONNX,在C++里完成前处理、推理、解码和NMS,最终输出带坐标的检测框。这个方向适合两类人:一类是接实际项目的工程师,需要在没有Python环境的机器上稳定跑推理;另一类是毕设、课设里想让“完整可运行”成为加分项的同学。跑通只是起点,能讲清楚每一步为什么这么设计,才是高分和实战的区别。

2. 从PyTorch权重到ONNX:导出命令、opset取舍与预处理对齐

2.1 一条命令导出ONNX:opset版本和动态输入的取舍

常见做法是用ultralytics库自带的export接口,不用手写torch.onnx.export。这个接口会帮我们把模型结构里的Detect头、DFL解码一并处理成可直接推理的ONNX,C++端省掉很多重复工作。

from ultralytics import YOLO model = YOLO("yolov8n.pt") model.export( format="onnx", imgsz=640, opset=12, dynamic=False, simplify=True, )

导出后在同目录下会得到yolov8n.onnx。这里的参数值得逐一说清楚:imgsz=640是YOLOv8默认的输入分辨率,也是大多数开源权重训练时用的尺寸;opset=12对ONNX Runtime的支持非常成熟,不需要刻意追新;dynamic=False表示固定输入shape,这样C++端不用处理动态维度的内存分配;simplify=True会调用onnxsim对计算图做常量折叠和算子融合,减少冗余节点。

那什么时候需要dynamic=True?如果你要在一个模型上同时处理不同分辨率的输入,可以开。但代价是C++端拿到的输出shape不再是固定的[1, 84, 8400],而是会随输入变化的动态维度,NMS之前要做动态内存管理,排错难度直接上一个台阶。我一般建议:第一版部署固定640,跑通整条链路后再谈动态。

2.2 letterbox预处理:C++端必须复刻的训练参数

YOLOv8在训练时用letterbox把不同长宽比的图片统一成640×640,而不是简单粗暴地resize。如果C++端用cv::resize硬拉成正方形,图像里的目标会被拉伸变形,小目标的检测率会肉眼可见地掉。

letterbox的流程是:先把原图按比例缩放到短边或长边接近640,再把不足的部分用灰色像素(默认114)填充。Python侧的标准实现长这样:

import cv2 import numpy as np def letterbox(img, new_shape=640, color=(114, 114, 114)): shape = img.shape[:2] # (h, w) r = min(new_shape / shape[0], new_shape / shape[1]) new_unpad = (int(round(shape[1] * r)), int(round(shape[0] * r))) dw = (new_shape - new_unpad[0]) / 2 dh = (new_shape - new_unpad[1]) / 2 img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR) top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1)) left, right = int(round(dw - 0.1)), int(round(dw + 0.1)) img = cv2.copyMakeBorder( img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color ) return img, r, dw, dh

注意这里返回的不只是处理后的图,还有缩放比例r和填充尺寸dw、dh。这三个值后面做坐标还原时要用,一个都不能丢。

还有一个容易忽略的细节:YOLOv8的ONNX模型输入是RGB顺序,而OpenCV读取图像默认是BGR。所以letterbox之后还要做一次通道翻转,再转成CHW布局并归一化到0~1:

img = cv2.imread("demo.jpg") img, r, dw, dh = letterbox(img) img = img[:, :, ::-1] # BGR to RGB img = img.transpose(2, 0, 1) # HWC to CHW img = np.ascontiguousarray(img).astype(np.float32) / 255.0 img = np.expand_dims(img, 0)

如果漏掉BGR转RGB这一步,不会报错,但检测结果会非常奇怪,尤其是对颜色敏感的目标,置信度普遍偏低。这个坑在C++端同样存在,后面避坑部分会再展开。

2.3 导出验证:先跑一个ONNX Runtime的Python基线

导出ONNX后别急着写C++,先用Python侧的ONNX Runtime把推理结果跑出来,作为后续C++代码的对照基线。这一步能省下大量联调时间。

import onnxruntime as ort sess = ort.InferenceSession( "yolov8n.onnx", providers=["CPUExecutionProvider"] ) input_name = sess.get_inputs()[0].name outputs = sess.run(None, {input_name: img}) print(outputs[0].shape) # 期望输出 (1, 84, 8400)

输出shape是[1, 84, 8400],表示一个batch、84维特征、8400个候选位置。8400来自三个特征层:80×80、40×40、20×20,加起来正好是8400。这个数字是固定的,后面C++端很多数组尺寸都跟它挂钩。

这个Python脚本建议保留。后续C++工程写完,用同一张图对比两边的检测框,如果框不一致,就能快速定位是预处理、解码还是NMS的问题。

3. C++工程里的ONNX Runtime:引用库、配置Session与读取输出张量

3.1 依赖准备:预编译库、动态库路径与VC运行库

ONNX Runtime官方提供预编译的release包,里面有include、lib和bin三个目录。下载时选对平台和架构,Windows下要选x64版本,Linux下选对应的.so。常见的稳定版本比如1.16、1.17、1.18都可以,API层面差异不大。

Unified方式把include目录加进工程,lib目录加进链接器。动态库是相对省事的选择:ONNX Runtime的dll体积在几十MB到上百MB不等,但不用每次编译都去链接庞大的静态库。如果对部署目录有洁癖,也可以用静态库链接,但这意味着编译期会明显变慢,而且一旦ONNX Runtime版本升级,整个工程要重新编译,后悔药都没有。

另外一个老生常谈的问题:把程序部署到一台新机器上,exe启动时提示找不到DLL或直接闪退,多半是目标机器缺了VC++运行库。这类程序大概率依赖microsoft visual c++ 2015-2022 redistributable (x64),部署时提前装好,或者在安装脚本里带上。这个坑看起来小,但在实际交付现场能把人磨到没脾气。

3.2 最小推理骨架:从构造Environment到拿到输出张量

ONNX Runtime的C++ API风格跟Python的InferenceSession类似,但对象生命周期要自己管理。一个最小可用的推理骨架如下:

#include <onnxruntime_cxx_api.h> #include <vector> #include <iostream> int main() { // Environment是全局级对象,负责日志和线程池管理 Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "yolov8-onnx"); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); Ort::Session session(env, "yolov8n.onnx", session_options); Ort::AllocatorWithDefaultOptions allocator; auto input_name = session.GetInputNameAllocated(0, allocator); auto output_name = session.GetOutputNameAllocated(0, allocator); std::vector<int64_t> input_shape = {1, 3, 640, 640}; size_t input_len = 1 * 3 * 640 * 640; std::vector<float> input_data(input_len, 0.0f); Ort::MemoryInfo memory_info = Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor = Ort::Value::CreateTensor<float>( memory_info, input_data.data(), input_data.size(), input_shape.data(), input_shape.size()); const char* input_names[] = {input_name.get()}; const char* output_names[] = {output_name.get()}; Ort::RunOptions run_options; std::vector<Ort::Value> outputs = session.Run( run_options, input_names, &input_tensor, 1, output_names, 1); auto output_info = outputs[0].GetTensorTypeAndShapeInfo(); auto output_shape = output_info.GetShape(); float* out_data = outputs[0].GetTensorMutableData<float>(); std::cout << "output channels: " << output_shape[1] << ", anchors: " << output_shape[2] << std::endl; return 0; }

几个参数说明:SetIntraOpNumThreads控制算子内部并行线程数,对CPU推理影响最大,一般设成物理核数或略低于核数;SetGraphOptimizationLevel(ORT_ENABLE_ALL)会开启图优化,包括算子融合和内存复用,这个必须开。GetInputNameAllocated返回的是智能指针,通过.get()拿到const char*给Run接口用,Run执行完后不能释放这两个名字指针,但这里作用域结束会自动释放。

Run返回的std::vector Ort::Value 里只有一个元素,就是模型输出张量。GetTensorMutableData 拿到的是连续内存指针,直接按shape去索引即可。注意这里用MutableData是因为ONNX Runtime允许你修改输出缓冲区,实际不修改也没关系,只是API这么命名。

3.3 输出张量怎么读:理解[1,84,8400]的内存布局

拿到输出指针后,最关键的认知是数据在内存里怎么排的。ONNX Runtime输出是标准的NCHW连续布局,但这里第四维是8400,不是宽高。数组排布是:对于第i个anchor(0到8399),它的cx、cy、w、h、cls0、cls1...cls79分别存放在:

int num_anchors = (int)output_shape[2]; // 8400 int num_classes = (int)output_shape[1] - 4; // 80 // 第i个anchor的第k个通道值 float val = out_data[k * num_anchors + i];

也就是说,同一类特征的所有anchor在内存中是连续的一段。访问cx时用out_data[0 * num_anchors + i],访问cy用out_data[1 * num_anchors + i],访问第c类置信度用out_data[(4 + c) * num_anchors + i]。这个布局跟PyTorch的[1, 84, 8400]一致,就是在通道维上做了摊平。

很多新手第一次写会按“第i个anchor的第k个通道”的方式去访问,也就是out_data[i * 84 + k],这样拿到的数据完全是乱的,因为内存布局不是这个顺序。这个细节可以算是整个C++部署里最容易踩的坑之一,没有明显报错,但解码出来的检测框全是噪声。

4. 输出张量转检测框:坐标解码、letterbox逆变换与NMS的纯C++实现

4.1 解码84维特征:中心坐标、宽高与类别置信度

YOLOv8的输出头不像YOLOv5那样带objectness分支,它把类别置信度和框质量绑定在一起。每个候选位置输出4个坐标值和80个类别分数,取类别分数最大的那个作为当前anchor的类别,分数本身作为置信度。

#include <vector> #include <algorithm> struct Detection { float x1, y1, x2, y2; float score; int class_id; }; std::vector<Detection> decode( float* data, int num_anchors, int num_classes, float conf_thres, float scale, float pad_x, float pad_y) { std::vector<Detection> detections; for (int i = 0; i < num_anchors; i++) { float cx = data[0 * num_anchors + i]; float cy = data[1 * num_anchors + i]; float w = data[2 * num_anchors + i]; float h = data[3 * num_anchors + i]; float max_score = 0.0f; int max_class_id = -1; for (int c = 0; c < num_classes; c++) { float score = data[(4 + c) * num_anchors + i]; if (score > max_score) { max_score = score; max_class_id = c; } } if (max_score >= conf_thres) { Detection det; det.x1 = (cx - w / 2.0f - pad_x) / scale; det.y1 = (cy - h / 2.0f - pad_y) / scale; det.x2 = (cx + w / 2.0f - pad_x) / scale; det.y2 = (cy + h / 2.0f - pad_y) / scale; det.score = max_score; det.class_id = max_class_id; detections.push_back(det); } } return detections; }

这里的坐标还原公式是核心:ONNX输出坐标是以640×640的输入图为参照系的像素值,不是归一化的比例。但对一张长宽比不是1:1的原图,它在进入模型前经历了缩放和平移,所以要先把坐标减掉letterbox填充量pad_x、pad_y,再除以缩放比例scale,才能映射回原图的像素坐标。

conf_thres一般取0.25,这是ultralytics的默认置信度阈值。取太高会漏检,取太低会让NMS输入一堆低质量框,性能白白消耗。

4.2 坐标映射回原图:pad和scale的还原计算

C++端的letterbox参数计算要和Python完全一致。常见做法是先算缩放比例,再根据缩放后的尺寸算填充偏移:

float scale = std::min(640.0f / src_h, 640.0f / src_w); float new_w = std::round(src_w * scale); float new_h = std::round(src_h * scale); float pad_x = (640.0f - new_w) / 2.0f; float pad_y = (640.0f - new_h) / 2.0f;

注意这个pad_x和Python侧letterbox里的dw、dh是一回事。但因为图像尺寸和缩放都是按短边对齐的,实际填充量在小数位上有细微差别,比如某个方向是5.2像素。你在Python里用round,C++里也要round,否则边界上的检测框会偏移一到两个像素。这种偏移在单张图上不显眼,但如果用来做视频流,框会明显抖动。

原图坐标算出来后,还需要做一步clip操作:

det.x1 = std::max(0.0f, det.x1); det.y1 = std::max(0.0f, det.y1); det.x2 = std::min((float)(src_w - 1), det.x2); det.y2 = std::min((float)(src_h - 1), det.y2);

否则靠近图像边缘的目标,还原后可能超出图像边界,画框或做跟踪时会出现负坐标。

4.3 纯C++的NMS:按类别抑制与IoU阈值的选择

解码后同一类目标可能出现大量重叠框,NMS负责把冗余的框去掉,保留每个目标最可信的那个。实现方式很多,这里给出最直白的一种:先把所有候选框按置信度降序排,然后从头开始遍历,保留当前框,并把后面与它IoU超过阈值的同类别框全部标记为删除。

float iou(const Detection& a, const Detection& b) { float x1 = std::max(a.x1, b.x1); float y1 = std::max(a.y1, b.y1); float x2 = std::min(a.x2, b.x2); float y2 = std::min(a.y2, b.y2); float inter_w = std::max(0.0f, x2 - x1); float inter_h = std::max(0.0f, y2 - y1); float inter_area = inter_w * inter_h; float union_area = (a.x2 - a.x1) * (a.y2 - a.y1) + (b.x2 - b.x1) * (b.y2 - b.y1) - inter_area; return inter_area / std::max(union_area, 1e-6f); } std::vector<Detection> nms( std::vector<Detection>& detections, float iou_thres) { std::sort(detections.begin(), detections.end(), [](const Detection& a, const Detection& b) { return a.score > b.score; }); std::vector<bool> removed(detections.size(), false); std::vector<Detection> result; for (size_t i = 0; i < detections.size(); i++) { if (removed[i]) continue; result.push_back(detections[i]); for (size_t j = i + 1; j < detections.size(); j++) { if (removed[j]) continue; if (detections[i].class_id != detections[j].class_id) continue; if (iou(detections[i], detections[j]) > iou_thres) { removed[j] = true; } } } return result; }

iou_thres默认取0.45或0.5,对应YOLOv8官方后处理的NMS阈值。这个值的调节逻辑是:目标密集且小,适当调低到0.4;目标稀疏且大,可以调高到0.6。另外注意类别判断放在IoU计算之前,这意味着不同类别的框即使完全重叠也不会被抑制,这是YOLO系列一贯的class-aware策略。

这个NMS实现的时间复杂度是O(n²),解码后如果候选框数量动辄几千,会有点吃力。实际工程中可以先按score排序后只取前300个框进NMS,效果基本不变,速度能快不少。我自己的习惯是decode时就把conf_thres提到0.3以上,进一步压缩候选框数量。

5. 部署避坑记录:版本差异、多线程翻车与INT8量化框漂移

5.1 CPU和GPU推理结果不一致,先查预处理而不是算子

现象:同一个onnx文件,在GPU上推理的检测框位置明显偏左,置信度也低,CPU上则正常。代码逻辑完全一样,让人怀疑是不是ONNX Runtime的GPU版本有问题。

原因:绝大多数情况不是推理框架的锅,而是输入数据不一致。比如导出onnx时把预处理部分也导进了图里,而C++端前置又做了一遍归一化;或者在不同机器上测试时,有人用了BGR输入,有人用了RGB输入。YOLOv8的onnx输入要求是RGB、0~1归一化的float数据,如果C++端直接从OpenCV读BGR图塞进模型,检测结果会全部错乱。

解决:统一从一个入口函数做预处理,别在多个地方各写一份。对照Python基线脚本,用同一张图逐步打log对比输入tensor的数值,前5个像素值差多少一眼就能看出来。经验是:先锁预处理,再去怀疑算子和框架。

5.2 动态尺寸输入导致C++侧Shape错误:固定shape要省心得多

现象:用dynamic=True导出的onnx在Python里正常,但C++端传一个非方的图进去,比如1280×720,直接抛“shape mismatch”或者输出的anchor数不是8400。

原因:动态shape的模型在Run之前需要根据输入shape推导整张计算图的中间维度。ONNX Runtime有这个能力,但前提是C++端要把正确的输入shape设置进input_tensor,并且Session允许动态shape。很多人的坑在于:input_tensor建的时候已经是固定640×640,但onnx导出的dynamic轴又允许变化,两边没对齐。

解决:第一版部署直接用dynamic=False导出,shape写死。如果要支持多种输入分辨率,不要靠改输入shape来实现,而是在每一个分辨率下单独导出对应onnx,或者在预处理里统一resize到640。ONNX Runtime虽然能跑动态模型,但对内存管理和算子选择都不够优化,性能反而更差。

5.3 多线程共享Session崩溃:Run到底是不是线程安全的

现象:程序在单线程推理时稳如老狗,一开四个线程各跑一个视频流,过一会儿就Segmentation Fault或者输出全为0。

原因:ONNX Runtime的Ort::Session对象本身允许从多个线程调用Run,但不是没有限制。问题经常出现在共享同一个Ort::Env、或者两个线程同时调用了session.Run且共享同一个输入输出缓冲区。Run内部不是没有锁,但如果你自己在外面复用Ort::Value对象,内存会被多个线程同时改写。

解决:最省事的方案是每个线程创建独立的Session和Env,反正模型加载一次权重,内存也只是多几份Session副本,CPU场景下开销可接受。或者保证每个线程的输入输出是独立分配的内存,不要共享同一个Ort::Value。另外OpenMP和SetIntraOpNumThreads的组合有时会引入额外竞争,线程数设成1先验证一把,定位是不是并行库冲突。

5.4 INT8量化后检测框偏移明显:校准集和量化参数怎么调

现象:yolov8n.onnx转成INT8量化版本后,同一张图的检测框要么丢失,要么框位置偏出目标一大截,置信度普遍掉到0.3以下。

原因:YOLOv8对量化比较敏感,尤其是检测头部分的输出分布幅度差异大。常见做法是在导出onnx后用onnxruntime的quantization工具做PTQ,如果校准集跟实际使用场景差异过大,量化后的激活值范围估计不准,误差就集中爆发。

解决:一是校准集要从真实业务场景里抽,最好覆盖不同光照、不同距离的目标,数量一般200到500张足够;二是优先用per-channel量化而不是per-tensor,对检测模型来说per-channel的精度损失小很多;三是可以尝试只量化卷积层,保持残差连接和检测头的浮点精度,边界效果会好不少。INT8量化这个方向值得做,但必须留出量化后精度验证的环节,别只盯着推理速度。

6. 跑通之后的事:延迟基准、预热与RK3588的NPU迁移

6.1 先做一次带预热的延迟基准

很多人跑完推理就急着往下走,忽略了性能基准这一步。ONNX Runtime第一次Run会因为图优化、内存分配和算子编译慢上很多,这时的耗时没有参考价值。正确做法是先跑20次预热,再统计连续100次推理的耗时分位数。CPU上yolov8n的单帧延迟通常在30到80毫秒之间,取决于机器和线程数;如果延迟超过100毫秒,优先检查是不是没有开图优化,或者线程数设置不合理。

6.2 从CPU到NPU:RK3588部署YOLOv8的迁移点

如果你手里的目标平台是RK3588这类带NPU的边缘设备,ONNX Runtime的CPU推理只能算权宜之计。RK3588上的常规路线是把ONNX再转成rknn格式,用NPU跑。迁移时要注意:rknn转换工具对算子的支持比ONNX Runtime窄,YOLOv8的DFL解码在NPU上通常要拆到CPU端做,或者换成C++后处理自己实现。好在rknn的C++接口思路和ONNX Runtime非常像,也是先初始化环境、加载模型、绑定输入输出,后处理代码可以直接复用。

这个方向的延伸空间很大:性能测完、NPU迁移踩完,你手里的这整套C++部署代码不管是换模型还是换平台,核心逻辑都不用推翻重来。我自己每次写部署代码,都会把预处理、解码、NMS三个模块强制拆开,确保每一段都能独立替换。遇到新模型,先跑通Python基线,再逐段换成C++,翻车的概率会小很多。希望能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询