简介:面向需要在C++工程中集成YOLOv8模型进行实时目标检测的开发者,该部署示例包提供了开箱即用的OnnxRuntime调用方案。压缩包共含3个文件,包括两个YOLOv8的ONNX权重文件(分别适用于常规检测与分割任务)以及一份C++推理源码,整体大小约21.87MB。示例代码演示了从加载模型、创建会话,到图像预处理、运行推理,再到输出张量后处理的完整流程,并涉及内存分配、CUDA/cuDNN加速等关键配置,可以帮助读者快速理清OnnxRuntime C++ API的调用逻辑。针对模型加载失败、硬件加速不生效、内存管理不当等常见部署问题,该工程也提供了相应的错误排查思路与处理参考。资源目前已有858人学习下载,适合具备一定C++基础、希望快速掌握模型部署思路并迁移到自身项目的算法工程与嵌入式开发人员查阅。
1. 为什么用 C++ 接 OnnxRuntime 跑 YOLOv8
YOLOv8 在 Python 里训练和验证都很顺手,可一旦进入生产环境,问题就来了:PyTorch 的启动时间动辄几秒,内存占用随 batch 和输入尺寸非线性上涨,多线程推理时 GIL 又卡住并发。把模型导成 ONNX 再用 C++ 调用 OnnxRuntime 推理,是把训练成果变成低延迟、可嵌入服务的常见路径,也是 C++ 面试里被反复问到的工程细节之一。这篇文章按一条能落地的路线走:从 YOLOv8 导出 ONNX 开始,搭好 C++ 工程,分别处理预处理、推理调用、NMS 后处理这几个环节,最后把 Session 配置和内存复用这些影响性能的细节讲透。适合两类人:一是用 C++ 做桌面或嵌入式视觉应用的开发,二是在 vscode 里配 C/C++ 环境想跑通模型部署的新手。
2. 从 YOLOv8 到 ONNX,再准备 C++ 工程依赖
2.1 用官方导出脚本生成推理用 ONNX 模型
YOLOv8 的推理模型和训练模型不是一个东西。训练完成后,需用 ultralytics 包里的 export 功能把模型转成 ONNX 格式。常见做法是直接在训练环境里执行:
yolo export model=yolov8n.pt format=onnx opset=12 simplify=True参数说明:
model:支持 .pt 权重路径或已训练好的模型文件,yolov8n.pt 是官方轻量模型,导出后的 ONNX 约 12MB,适合先跑通流程。opset:ONNX 算子集版本,OnnxRuntime 1.15 以上对 opset 12 兼容性最稳,opset 太高在旧版推理引擎上可能报未知算子。simplify:用 onnxsim 做常量折叠和图优化,能去掉一部分冗余 reshape 和 transpose 节点。
导出完成后,用 Python 快速验证输出形状:
import onnxruntime as ort import numpy as np session = ort.InferenceSession("yolov8n.onnx", providers=["CPUExecutionProvider"]) for info in session.get_inputs(): print(info.name, info.shape, info.type) for info in session.get_outputs(): print(info.name, info.shape, info.type)这段代码会打印输入输出张量的名称和维度,和 C++ 端要拿到的信息一一对应(后面读输入输出名时会用到)。YOLOv8 的 OnnxRuntime 导出版本输出通常是 1x84x8400 或者 1x84x8400+1x4x8400 的组合,取决于模型是否带 NMS 模块——建议导出不带 NMS 的版本,把后处理放回 C++ 自己控制,否则布局和类名映射受导出工具约束。
2.2 下载 OnnxRuntime 库并配置 CMake 工程
C++ 侧依赖只有 OnnxRuntime 一个 SDK。官方为 Windows 和 Linux 发布预编译动态库,解压后目录里包含 include/ 和 lib/,既有 .lib 导入库也有 .dll/.so 运行时。Windows 上常见的坑是运行时不带 Visual C++ Redistributable 导致启动报错,直接把 vcruntime140.dll 对应版本装好即可。
CMakeLists.txt 的最小写法:
cmake_minimum_required(VERSION 3.16) project(yolov8_onnx) set(CMAKE_CXX_STANDARD 17) find_library(ONNXRUNTIME_LIB onnxruntime PATHS ${ONNXRUNTIME_ROOT}/lib) add_executable(yolov8_onnx main.cpp) target_include_directories(yolov8_onnx PRIVATE ${ONNXRUNTIME_ROOT}/include) target_link_libraries(yolov8_onnx PRIVATE ${ONNXRUNTIME_LIB})核心是find_library把 OnnxRuntime 的库路径指给链接器,ONNXRUNTIME_ROOT作为外部变量传进来。还需要把头文件目录暴露给编译器。注意在 Windows 上,OnnxRuntime 的动态库和导入库同名,链接 .lib 后运行时仍需 onnxruntime.dll 在可执行文件目录或系统 PATH 中;Linux 上需要用ldd确认 .so 被找到。
3. C++ 端 OnnxRuntime 推理管线搭建
3.1 初始化 Session 并绑定输入输出
C++ 推理入口先创建 Ort::Env 和 Session。Env 负责线程池和日志等级,Session 负责加载模型、分配内存:
#include <onnxruntime_cxx_api.h> #include <opencv2/opencv.hpp> Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "yolov8_engine"); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); Ort::Session session(env, L"yolov8n.onnx", session_options);逻辑说明:SetIntraOpNumThreads(4)限制单次推理内算子并行线程数;ORT_ENABLE_ALL打开图优化,把相邻算子融合掉,这对 CNN 类模型提升明显。注意 Windows 下 Session 构造函数第二个参数是宽字符串L"...",Linux 用普通字符串或std::string均可。
拿到输入输出信息的代码:
auto input_name = session.GetInputNameAllocated(0, Ort::Allocator::Default()); auto output_name = session.GetOutputNameAllocated(0, Ort::Allocator::Default()); Ort::TypeInfo input_info = session.GetInputTypeInfo(0); auto input_shape = input_info.GetTensorTypeAndShapeInfo().GetShape(); std::cout << "input: " << input_name.get() << " shape: " << input_shape[0] << "x" << input_shape[1] << "x" << input_shape[2] << "x" << input_shape[3] << std::endl;GetInputNameAllocated的返回值是分配器管理的字符串指针,用get()访问。拿到 shape 是为了在预处理时显式确认输入布局为 NCHW,常见的 YOLOv8 输入是 1x3x640x640。
3.2 预处理:letterbox 等比缩放与归一化
YOLOv8 训练时用 640x640 正方形输入,但真实图片宽高比不固定。直接把图片拉伸到 640x640 会改变目标形状,影响检测精度。正确做法是 letterbox——等比缩放并补灰边:
cv::Mat letterbox(const cv::Mat& src, cv::Mat& pad, int target_size = 640) { float scale = std::min(target_size * 1.0f / src.cols, target_size * 1.0f / src.rows); int new_w = static_cast<int>(src.cols * scale); int new_h = static_cast<int>(src.rows * scale); cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h)); pad = cv::Mat::zeros(target_size, target_size, CV_8UC3); pad.setTo(cv::Scalar(114, 114, 114)); int dx = (target_size - new_w) / 2; int dy = (target_size - new_h) / 2; resized.copyTo(pad(cv::Rect(dx, dy, new_w, new_h))); return pad; }参数说明:pad矩阵使用 114 作为填充色,和 YOLOv8 训练时的默认一致; 如果模型是 .pt 训练时输入 1280,则把 target_size 改成 1280,同时后处理中的坐标缩放比例也要相应调整。这个函数返回的是 BGR 图像,ONNX 模型的输入约定也是 BGR,不需要额外通道转换。
下一步把 cv::Mat 转成连续内存的 float 数组,并执行 HWC→CHW 和归一化:
std::vector<float> input_tensor_data(1 * 3 * 640 * 640); float* data_ptr = input_tensor_data.data(); for (int c = 0; c < 3; ++c) { for (int h = 0; h < 640; ++h) { for (int w = 0; w < 640; ++w) { cv::Vec3b pixel = padded.at<cv::Vec3b>(h, w); data_ptr[c * 640 * 640 + h * 640 + w] = pixel[c] / 255.0f; } } }三段循环把像素从 BGR 交错内存排布读出来,按通道连续排布写入输入缓冲区,同时除以 255 归一化到 [0,1]。
3.3 构造 Ort 输入张量并执行推理
把预处理后的数据包装成 OnnxRuntime 能认的张量:
std::vector<int64_t> 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_data.data(), input_tensor_data.size(), input_shape.data(), input_shape.size()); std::vector<const char*> input_names = {input_name.get()}; std::vector<const char*> output_names = {output_name.get()}; auto output_tensors = session.Run(Ort::RunOptions{nullptr}, input_names.data(), &input_tensor, 1, output_names.data(), output_names.size());逻辑说明:CreateTensor<float>的第一个参数指定 CPU 内存分配器;如果后续要跑 CUDA EP,这一步得改用 GPU 分配的缓冲区。session.Run的第三个参数是输入 Value 数组指针,&input_tensor取出第一个张量的地址传入。输出结果是std::vector<Ort::Value>,每条 Value 对应一个输出张量。
拿到输出后把它转成二维数组方便解析:
float* output_data = output_tensors[0].GetTensorMutableData<float>(); auto output_shape = output_tensors[0].GetTensorTypeAndShapeInfo().GetShape(); int num_boxes = static_cast<int>(output_shape[2]);这里以输出维度 1x84x8400 为例,84 是 4 个坐标 + 80 个类别,8400 是三个特征图堆叠出的检测框数量。注意GetTensorMutableData<float>直接拿到内部内存指针,读取前确认模型输出类型是 float,否则需要转换为 float。
4. 输出解析与 NMS 后处理
4.1 从 84x8400 输出中解码检测框
YOLOv8 的输出格式和 YOLOv5 不同。YOLOv5 输出的是 cx, cy, w, h 加上类别概率,而 YOLOv8 输出的是每个检测框的 4 个坐标(中心点 x、中心点 y、宽、高)和 80 个类别得分。解码核心是先找最高类别分,再过滤低置信度框:
struct Detection { float x1, y1, x2, y2; int class_id; float confidence; }; std::vector<Detection> decode_output(float* data, float conf_threshold = 0.25) { std::vector<Detection> detections; int num_channels = 84; for (int i = 0; i < 8400; ++i) { float* row = data + i * num_channels; int class_id = 0; float max_score = 0.0f; for (int j = 4; j < 84; ++j) { if (row[j] > max_score) { max_score = row[j]; class_id = j - 4; } } if (max_score < conf_threshold) continue; float cx = row[0], cy = row[1]; float w = row[2], h = row[3]; Detection det; det.x1 = cx - w / 2; det.y1 = cy - h / 2; det.x2 = cx + w / 2; det.y2 = cy + h / 2; det.class_id = class_id; det.confidence = max_score; detections.push_back(det); } return detections; }逻辑说明:row按行取第 i 个检测框的 84 个值,前 4 位是坐标,后 80 位是类别概率,找到最大值对应的索引即为 class_id。坐标是模型输入图坐标系下的数值,需按 letterbox 的缩放和 padding 换算回原图。
4.2 手写 NMS 过滤重叠框
同一目标会被多个检测框覆盖,NMS 是必做步骤。用普通数组实现即可,不需要额外依赖:
std::vector<Detection> nms(std::vector<Detection>& detections, float iou_threshold = 0.5) { std::vector<Detection> result; std::sort(detections.begin(), detections.end(), [](const Detection& a, const Detection& b) { return a.confidence > b.confidence; }); std::vector<bool> suppressed(detections.size(), false); for (size_t i = 0; i < detections.size(); ++i) { if (suppressed[i]) continue; result.push_back(detections[i]); for (size_t j = i + 1; j < detections.size(); ++j) { float inter_x1 = std::max(detections[i].x1, detections[j].x1); float inter_y1 = std::max(detections[i].y1, detections[j].y1); float inter_x2 = std::min(detections[i].x2, detections[j].x2); float inter_y2 = std::min(detections[i].y2, detections[j].y2); float inter_area = std::max(0.0f, inter_x2 - inter_x1) * std::max(0.0f, inter_y2 - inter_y1); float union_area = (detections[i].x2 - detections[i].x1) * (detections[i].y2 - detections[i].y1) + (detections[j].x2 - detections[j].x1) * (detections[j].y2 - detections[j].y1) - inter_area; if (inter_area / union_area > iou_threshold) { suppressed[j] = true; } } } return result; }参数说明:
iou_threshold=0.5是 COCO 评测的默认值;追求高召回时可调到 0.7。suppressed数组用布尔标记是否保留,避免反复删除 vector 元素带来的拷贝开销。- 排序按置信度降序,保证高置信度框先参与抑制。
4.3 把检测框映射回原图坐标
letterbox 后坐标在原图上需要还原。假设原图为src_w x src_h,缩放 scale 和 padding 偏移 dx、dy 来自之前预处理函数:
void remap_detections(std::vector<Detection>& dets, float scale, int dx, int dy) { for (auto& d : dets) { d.x1 = (d.x1 - dx) / scale; d.y1 = (d.y1 - dy) / scale; d.x2 = (d.x2 - dx) / scale; d.y2 = (d.y2 - dy) / scale; } }这一步漏掉的话,画框位置会整体偏移,尤其非正方形图片上会出现检测框贴合目标但位置偏下的现象。
4.4 类别映射与目标数量变化
如果用的是官方 COCO 版本,80 个类别按 YOLOv8 默认顺序排列——第 0 类是 person,第 39 类是 bottle。用自己的数据集训练的模型,导出 ONNX 后类别顺序和训练时的 data.yaml 中 names 字段一致。常见错误是拿 COCO 的顺序套自定义模型,导致框画对但标签全错。
可以用一个 vector 做映射:
std::vector<std::string> class_names = { "person", "bicycle", "car", /* ... 按模型实际类别填 ... */ }; std::cout << "class: " << class_names[d.class_id] << " conf: " << d.confidence << " bbox: " << d.x1 << "," << d.y1 << "," << d.x2 << "," << d.y2 << std::endl;输出的框坐标单位是原图像素;如果想做成没有外部依赖的控制台检测程序,到这里已经可以把坐标写到文件供后端消费。画框到图片上仍需 OpenCV 的cv::rectangle和cv::putText。
5. OnnxRuntime 性能调参与部署边界
5.1 Session 线程数与执行模式选择
OnnxRuntime 默认会占满所有 CPU 核心,但实际推理模型的延迟并不随线程数线性下降。CNN 的前几层是空间卷积,并行度高,后几层是 1x1 卷积和全局池化,线程多了反而在同步上浪费时间。常见做法是把SetIntraOpNumThreads设为物理核心数的一半,并配合运行环境测试:
session_options.SetIntraOpNumThreads(4); session_options.SetExecutionMode(ExecutionMode::ORT_SEQUENTIAL);ORT_SEQUENTIAL表示算子按图顺序逐个执行,适合单模型严格按顺序推理的场景; 如果同一进程里多个模型实例并行,考虑ORT_PARALLEL,但这时需要SetInterOpNumThreads控制模型间并行度,轻易别开大,线程调度开销会吞掉收益。
5.2 输入输出内存复用避免反复分配
推理一次就创建一次Ort::Value是个隐蔽的性能陷阱。把输入缓冲区复用起来:
std::vector<float> input_data(1 * 3 * 640 * 640); std::vector<int64_t> input_shape = {1, 3, 640, 640}; Ort::Value input_tensor = Ort::Value::CreateTensor<float>( memory_info, input_data.data(), input_data.size(), input_shape.data(), input_shape.size()); while (true) { // 读帧, letterbox, 填入 input_data auto output_tensors = session.Run(...); // 解析结果 }input_data是外部 vector,CreateTensor 只是包了一层接口,真正干活时把数据写进这个 vector 即可。注意每次session.Run后输出 Value 持有的内存不能跨迭代保存指针,因为下一次 Run 可能复用底层内存区,取出的GetTensorMutableData指针只对当次输出有效。
5.3 减少预处理拷贝的若干技巧
cv::resize默认线性插值对缩小图片没必要,改成cv::INTER_AREA在降采样时更平滑,对检测精度无负面影响。- letterbox 中
setTo(114)的填充和前三次归一化循环可以合并:先申请 CV_32FC3 的 padded 矩阵,把归一化和填充一次性做完,内存写入少一半。 - 多路视频流场景下,预处理可以用独立线程池并行做,但注意 OnnxRuntime 推理线程和内存在同一进程内共享,预处理线程数加推理线程数不要超过物理核心数,不然切换开销反而增大。
5.4 部署前的检查清单与典型错误
做一个快速验证:
# Linux 下查动态库依赖 ldd yolov8_onnx | grep onnx # Windows 下可在 PowerShell 执行 dumpbin /dependents yolov8_onnx.exe确认 onnxruntime.dll/.so 被正确加载。还有一类问题与模型文件有关:用 Python 导出模型时没有装 onnxsim,输出的 ONNX 里混着不少 Identity 和 Cast 节点,在 C++ 里ORT_ENABLE_ALL能吃掉一部分,但最好在导出时就把 simplify 打开。
下面用表格汇总几个高频报错和应对方向:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 启动即崩,报找不到 onnxruntime | 动态库不在 PATH | 把 dll/so 复制到可执行文件目录,或设置 LD_LIBRARY_PATH |
| 推理输出全是 0 | 输入归一化范围错误或通道顺序不对 | 确认像素除以 255,BGR 排布符合模型要求 |
| 检测框整体向右下偏移 | letterbox 的 dx/dy 未传给坐标还原 | 把预处理阶段的 pad 量存起来,在后处理中减掉 |
| 输出维度是 1x84x8400 但解析越界 | 模型导出了带 NMS 的版本 | 重新导出不带 NMS 的 ONNX,或用session.GetOutputCount()检查输出个数 |
| GPU 版本无法加载 | CUDA/cuDNN 与 OnnxRuntime 版本不匹配 | 查看官方版本兼容表; 先用 CPU EP 跑通逻辑再切 GPU |
如果追求更低延迟,可以用 onnxruntime 提供的内存池接口,在创建 Session 时传入Ort::MemoryInfo指定为 CUDA Pinned Memory,CPU 端预处理后把数据拷贝到 pinned 内存再送 GPU,省去一次 PCIe 传输。这个优化在 1080Ti/1660Ti 这类老显卡上收益明显,但推理帧率本身已接近实时时,瓶颈往往在cv::resize而不是推理,先把预处理放到另一个线程再做这个优化。
YOLOv8 的 C++ 部署做到这里已经能跑通完整链路:导出模型、初始化 Session、预处理、推理、NMS、坐标还原。剩下的工作按业务场景接输出即可。
(全文完)
本文还有配套的精品资源,点击获取