简介:C++ 配合 ONNXRuntime 部署 YOLOv8 目标检测模型的完整资源包,面向有一定 C++ 基础、希望在工业或边缘场景中实现低延迟推理的开发者。YOLOv8 作为新一代目标检测模型,在检测精度与推理速度之间取得了更好平衡;ONNXRuntime 则提供跨平台的高性能推理能力,两者结合可充分利用硬件资源,适合嵌入式设备与服务器端部署。包内共 3 个文件,包括两个 ONNX 模型文件(分别对应常规检测与实例分割)以及一个完整的 C++ 示例程序,压缩包整体约 21.66MB。资源覆盖从模型加载、推理运行到结果后处理的完整部署流程,具体包括预处理、引擎初始化、张量形状转换、输出解析与置信度过滤等核心步骤,代码结构清晰,关键接口均有注释,便于对照学习。目前已有 2746 人学习下载,对需要跳出 Python 环境、追求极致推理性能的工程人员具有直接参考价值。
1. 为什么在C++里部署yolov8:模型推理的最后一公里
一个yolov8模型在Python里跑得再好,上了生产环境也绕不开用C++重写推理这一关;OnnxRuntime正是把导出后的onnx模型无缝接到C++工程里的那座桥。很多人在训练阶段顺风顺水,却在部署时卡住:Python脚本冷启动要几秒,内存开销大,小并发下帧率还行,一旦接入业务服务就失控。换C++部署不是炫技,而是让模型真正跑在业务链路里的必经之路。这篇文章要做的,就是把一整条部署链路拆开讲清楚:导出onnx、搭建C++工程、编写推理主流程、完成后处理、填掉常见的坑,最后给出验证和优化方向。适合训练过yolov8但没写过C++部署的从业者,也适合从其他推理框架迁移过来的熟手。
2. 前置准备:导出onnx模型与C++工程环境搭建
部署的第一步不是写代码,而是把模型从PyTorch训练格式转成onnx,同时把C++工程要依赖的动态库、运行库准备齐全。顺序反了很容易做无用功:模型导出了动态尺寸输入,代码里却写死了640×640;或者库位数不匹配,链接到一半才发现问题。这一章把这两件事一次做对,后续代码才不会反复返工。
2.1 从pt模型导出onnx:yolo export参数与输出结构解读
如果你用的是ultralytics框架,导出onnx只需要一条命令,但如果自己训练过数据集,有几个参数值得单独说明:
yolo export model=yolov8n.pt format=onnx opset=12 dynamic=True simplify=True自己训练过模型的话,把model指向你的best.pt即可。opset=12是算子集版本,OnnxRuntime对12的支持已经很成熟;设得太高(比如17或18)在旧版OnnxRuntime上会直接报“Unsupported ONNX opset version”,设得太低有些新算子又导不出来。dynamic=True允许输入尺寸动态变化,如果服务要处理不同分辨率的图片,必须打开;如果统一缩放成640×640,可以不开,代码里维度写死,省掉不少麻烦。simplify=True会调用onnx-simplifier做常量折叠和冗余算子合并,模型文件更小,推理时内存开销也能低一点。
导出得到的yolov8n.onnx在Netron里打开后,重点关注输出层信息。yolov8检测模型的原始输出shape是[1, 84, 8400]:1是batch,84是4个边框坐标加上80个COCO类别的分数,8400是三个尺度特征图展平后拼接的预测框总数。后处理代码里你会反复用到这三个数字。
这里有一个高频翻车点:类别数不是80。比如你的数据集只有三类安全帽,输出就是[1, 7, 8400](4+3),84不再适用,后处理里的类别循环次数、标签数组全部要跟着改。训练时改了data.yaml里的nc,导出后一定要验证下输出维度,别想当然按84写死。
补充一点:如果导出时加了nms=True,模型会把NMS也编进计算图,输出变成[1, 300, 6]这种紧凑格式。这看起来省事,但C++端做多线程推理时灵活性差,不同版本yolo对NMS节点的实现差异也大。我一般导出不带NMS的版本,后处理自己写,可控性反而更高。
2.2 C++工程依赖:OnnxRuntime动态库、OpenCV与运行库的搭配
C++端推理最常用的组合是:OnnxRuntime动态库 + OpenCV + CMake。OpenCV负责读图和基础图像运算,OnnxRuntime负责跑模型,CMake把两者串起来。OnnxRuntime的C++库可以从GitHub Release页面下载预编译包,Windows选onnxruntime-win-x64-*.zip,Linux选onnxruntime-linux-x64-*.tgz,解压后有include和lib两个目录。下载时注意两个坑:一是CPU版和GPU版的包不在一起,要用CUDA加速得选带gpu的包并额外装CUDA和cuDNN;二是有个DirectML版本,适合Windows下没有CUDA的N卡加速,但收益不如CUDA明显,新手别混用。
OpenCV安装相对省心。Windows下装官方预编译包,装完务必把opencv_world4xx.dll所在的bin目录加到系统PATH。这个不做,程序在开发机上跑得好好的,拷到目标机器上启动就报“找不到opencv_world401.dll”。
还有一类运行库问题:目标机上必须装Microsoft Visual C++ 2015-2022 Redistributable (x64),否则程序一启动就提示缺少VCRUNTIME140.dll。这不是OnnxRuntime特有的坑,是所有用Visual Studio编译的C++程序都会遇到的问题,但部署yolov8时目标机往往不是开发机,特别容易踩到。
工程构建用CMake最简单,下面这份CMakeLists.txt是我常用的最小配置:
cmake_minimum_required(VERSION 3.18) project(yolo_cpp) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # OpenCV find_package(OpenCV REQUIRED) # OnnxRuntime set(ONNXRUNTIME_ROOT "D:/libs/onnxruntime-win-x64-1.16.3") include_directories(${ONNXRUNTIME_ROOT}/include) link_directories(${ONNXRUNTIME_ROOT}/lib) add_executable(yolo_cpp main.cpp) target_link_libraries(yolo_cpp PRIVATE ${OpenCV_LIBS} onnxruntime )ONNXRUNTIME_ROOT改成你实际解压的路径;Linux下ONNXRUNTIME_ROOT指向解压目录,链接名同样是onnxruntime。如果Windows链接报LNK1104 cannot open file 'onnxruntime.lib',十有八九是lib路径指到了lib而不是lib/x64——OnnxRuntime Release包内部目录结构在不同版本有点差异,多一层x64子目录很常见。
工程能在命令行编译跑通后,如果你习惯用VSCode写C++,建议顺手配置一下c/c++插件环境:在.vscode/c_cpp_properties.json里把includePath指向OnnxRuntime和OpenCV的头文件目录。这样编辑器里的IntelliSense不会把Ort::Session标红报错,调试时也能直接F5跑起来,断点打在推理代码里,排查问题效率比printf高很多。
3. OnnxRuntime C++推理主流程:Session创建、预处理与张量绑定
工程骨架搭好之后,进入推理核心。这一章的三节对应三次关键代码:先建Session,再做letterbox和归一化,最后把图像数据塞进Ort::Value并执行一次Run。三步缺一不可,每一步的参数错误都会让结果变得莫名其妙。
3.1 创建推理Session:Ort::Env与SessionOptions关键配置
推理会话的创建是整个C++部署的起点。代码量不多,但几个配置参数直接决定性能和稳定性:
#include <onnxruntime_cxx_api.h> #include <opencv2/opencv.hpp> #include <vector> #include <memory> #include <iostream> class YoloDetector { public: YoloDetector(const std::string& model_path, bool use_cuda = false) { // 全局环境:整个进程只创建一个 env_ = std::make_unique<Ort::Env>(ORT_LOGGING_LEVEL_WARNING, "yolo_cpp"); Ort::SessionOptions session_options; // 算子内线程数,0表示用系统物理核数 session_options.SetIntraOpNumThreads(4); // 开启全部图优化,包括算子融合与常量折叠 session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); // 复用推理过程中的内存分配,长时间服务有用 session_options.SetMemoryPatternOptimization(true); if (use_cuda) { OrtCUDAProviderOptions cuda_options{}; session_options.AppendExecutionProvider_CUDA(cuda_options); } session_ = std::make_unique<Ort::Session>( *env_, model_path.c_str(), session_options); } private: std::unique_ptr<Ort::Env> env_; std::unique_ptr<Ort::Session> session_; };Ort::Env是全局执行环境,进程内保持一个实例即可;重复创建Env在较新版本里已经被标记为不推荐,因为内部会重复分配线程池和日志资源。SetIntraOpNumThreads(4)在多核CPU上值得手动调一调:设置太低浪费核心,设置太高会因为线程切换开销导致延迟反而上升,4到8之间通常是不错的选择。SetGraphOptimizationLevel(ORT_ENABLE_ALL)是OnnxRuntime默认推荐做法,开启算子融合后,yolov8里的卷积和BatchNorm会被融合成更少的大算子,推理时间能缩短不少。
CUDA EP的配置在1.16之后用OrtCUDAProviderOptions结构体,旧代码里常见的OrtSessionOptionsAppendExecutionProvider_CUDA宏新版本已标记为deprecated。CPU推理时跳过这段就行。Session的创建通常耗时几百毫秒,取决于模型大小和设备状态,所以生产环境下Session一定要复用,不要在每帧推理时重新创建,否则性能直接被打回原形。
3.2 图像预处理:letterbox缩放与归一化细节
yolov8训练时默认把输入图缩放到640×640,但直接resize会破坏长宽比,导致目标形变、检测精度明显下降。标准做法是letterbox:等比缩放后补边到640×640。
cv::Mat letterbox(const cv::Mat& src, int target_size) { int w = src.cols; int h = src.rows; float scale = std::min(static_cast<float>(target_size) / w, static_cast<float>(target_size) / h); int new_w = static_cast<int>(w * scale); int new_h = static_cast<int>(h * scale); cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h)); cv::Mat out = cv::Mat::zeros(target_size, target_size, CV_8UC3); int top = (target_size - new_h) / 2; int left = (target_size - new_w) / 2; resized.copyTo(out(cv::Rect(left, top, new_w, new_h))); return out; }scale取宽高缩放比例的较小值,保证原图完整落在画布内。补边用(target_size - new_w) / 2实现居中,填充颜色用114,这是ultralytics训练时的默认边框色。如果团队训练时改过填充色,这里要一并改,否则等于向模型输入了分布外的数据,精度会掉。补边位置在奇数尺寸下会差一个像素,后处理做坐标还原时也要用相同的整数除法,细节放在避坑章节展开。
拿到letterbox后的图,还要转成float并归一化到0-1,同时把OpenCV的HWC布局改成模型要求的CHW:
cv::Mat blob_from_image(const cv::Mat& letterboxed) { cv::Mat rgb; cv::cvtColor(letterboxed, rgb, cv::COLOR_BGR2RGB); cv::Mat float_img; rgb.convertTo(float_img, CV_32FC3, 1.0 / 255.0); int target_size = letterboxed.cols; cv::Mat chw(target_size * target_size * 3, 1, CV_32F); for (int c = 0; c < 3; ++c) { for (int i = 0; i < target_size; ++i) { for (int j = 0; j < target_size; ++j) { chw.at<float>((c * target_size + i) * target_size + j) = float_img.at<cv::Vec3f>(i, j)[c]; } } } return chw; }cvtColor把BGR转RGB,因为OpenCV默认读图是BGR,而yolov8在PyTorch里用的是RGB。convertTo除以255完成归一化,注意这里没有减均值、没有除方差,yolov8的预处理和ImageNet的标准化不是一回事,别直接套用mean=[0.485,0.456,0.406]那套参数。CHW循环虽然看起来笨重,但逻辑直观,640×640单图转换大约1到2毫秒,可接受;追求极致可以用cv::dnn::blobFromImage一条命令完成缩放、通道转换和归一化,但要注意它不做letterbox,填充逻辑得自己控制。
3.3 输入输出张量绑定:从cv::Mat到Ort::Value
OnnxRuntime的C++ API要求输入输出用Ort::Value封装。这一步把上面准备好的float数据搬进张量,然后调用Run:
std::vector<Ort::Value> run_inference(const std::vector<float>& input_data) { Ort::AllocatorWithDefaultOptions allocator; Ort::AllocatedStringPtr input_name = session_->GetInputNameAllocated(0, allocator); Ort::AllocatedStringPtr output_name = session_->GetOutputNameAllocated(0, allocator); 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, const_cast<float*>(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()}; std::vector<Ort::Value> outputs = session_->Run( Ort::RunOptions{nullptr}, input_names, &input_tensor, 1, output_names, 1); return outputs; }input_shape必须和导出onnx时的输入维度一致。如果导出时开了dynamic,这里可以填{-1, 3, -1, -1},运行时再把实际宽高填进去;没开dynamic就写死640。Ort::MemoryInfo::CreateCpu告诉OnnxRuntime输入数据位于CPU内存,不需要跨设备拷贝。Run的第二个参数是输入张量数组,想一次推理多张图就把input_shape[0]改成batch数,并传入一个Value数组。
GetInputNameAllocated是较新版本C++ API的接口,旧代码里的GetInputName()返回裸char*,新版本已标记为deprecated,而且涉及字符串释放问题。建议直接用新接口。
推理结束后的outputs[0]就是模型输出张量,可以进入后处理了。这里提醒一下:如果模型导出了带NMS版本,输出张量可能不止一个,拿到新模型的第一时间打印session_->GetOutputCount(),心里有个数再动手。
4. 后处理:从模型输出张量到目标框
模型输出的只是一堆浮点数,要变成能画在图像上的框,必须经过解析、阈值过滤、NMS和坐标还原。这一章把后处理拆成两段:先在8400个候选框里筛出有效目标,再把640×640坐标系映射回原图。yolov8的COCO模型输出shape是[1, 84, 8400],但自己训练的数据集可能只有几个类别,代码里把类别数字做成参数,不要写死。
4.1 输出张量解析:内存布局与置信度过滤
输出张量的内存布局是[1, 84, 8400],84个维度在外层,8400个锚点在最内层。如果直接把裸指针当成[8400, 84]去读,读出来的全是错位数据。正确做法是记住一个规律:第a个锚点的第f个特征,位于output[a + f * 8400]。
struct Detection { int class_id; float confidence; float x1, y1, x2, y2; }; std::vector<Detection> parse_output( const float* output, int num_classes, float conf_threshold) { int num_anchors = 8400; int num_feats = 4 + num_classes; std::vector<Detection> detections; for (int a = 0; a < num_anchors; ++a) { float max_score = 0.0f; int max_class = -1; for (int c = 0; c < num_classes; ++c) { float score = output[a + num_anchors * (4 + c)]; if (score > max_score) { max_score = score; max_class = c; } } if (max_class == -1 || max_score < conf_threshold) { continue; } Detection det; det.class_id = max_class; det.confidence = max_score; // yolo输出是cx, cy, w, h,需要转成x1,y1,x2,y2 float cx = output[a]; float cy = output[a + num_anchors]; float w = output[a + num_anchors * 2]; float h = output[a + num_anchors * 3]; det.x1 = cx - w / 2.0f; det.y1 = cy - h / 2.0f; det.x2 = cx + w / 2.0f; det.y2 = cy + h / 2.0f; detections.push_back(det); } return detections; }这段代码没有做数据重排,直接按列访问,省掉一次O(84×8400)级的拷贝。max_score先用0作为初始值,再和每个类别的分数比较,遇到负数分数也只要大于0才保留。许多C++初学迁移者会惯性思维写成output[f + num_feats * a],那对应的是[8400,84]布局,得到的框位置会完全错乱。拿到输出后先用打印shape的方式确认布局,再写对应代码。
4.2 NMS去重与坐标还原到原图
阈值过滤后,同一个目标可能对应多个重叠框。NMS非极大值抑制用于去掉冗余框。OnnxRuntime本身不管这件事,得自己实现:
float iou(const Detection& a, const Detection& b) { float inter_x1 = std::max(a.x1, b.x1); float inter_y1 = std::max(a.y1, b.y1); float inter_x2 = std::min(a.x2, b.x2); float inter_y2 = std::min(a.y2, b.y2); float inter_w = std::max(0.0f, inter_x2 - inter_x1); float inter_h = std::max(0.0f, inter_y2 - inter_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 union_area <= 0.0f ? 0.0f : inter_area / union_area; } std::vector<Detection> nms( const std::vector<Detection>& dets, float iou_threshold) { std::vector<Detection> result; std::vector<bool> removed(dets.size(), false); std::vector<int> idx(dets.size()); for (size_t i = 0; i < dets.size(); ++i) idx[i] = i; std::sort(idx.begin(), idx.end(), [&](int l, int r) { return dets[l].confidence > dets[r].confidence; }); for (int i : idx) { if (removed[i]) continue; result.push_back(dets[i]); for (int j : idx) { if (removed[j]) continue; if (iou(dets[i], dets[j]) > iou_threshold) { removed[j] = true; } } } return result; }这个NMS实现对所有类别共用同一个IoU阈值。严格说不同类别的目标重叠时不应该互相抑制,比如人和自行车大概率重叠,按类别分组做NMS会更合理。将removed拆成按class_id分组的数组,每组单独跑一遍,代码量不大,但在行人+车辆混合场景里效果更正确。
NMS之后,把640×640坐标系下的框还原到原图尺寸。letterbox时做过等比缩放和居中补边,还原公式是:
cv::Rect scale_box(const Detection& det, const cv::Size& orig_size, int target_size) { float scale = std::min(static_cast<float>(target_size) / orig_size.width, static_cast<float>(target_size) / orig_size.height); float new_w = orig_size.width * scale; float new_h = orig_size.height * scale; float left = (target_size - new_w) / 2.0f; float top = (target_size - new_h) / 2.0f; float x1 = (det.x1 - left) / scale; float y1 = (det.y1 - top) / scale; float x2 = (det.x2 - left) / scale; float y2 = (det.y2 - top) / scale; x1 = std::max(0.0f, x1); y1 = std::max(0.0f, y1); x2 = std::min(static_cast<float>(orig_size.width), x2); y2 = std::min(static_cast<float>(orig_size.height), y2); return cv::Rect(static_cast<int>(x1), static_cast<int>(y1), static_cast<int>(x2 - x1), static_cast<int>(y2 - y1)); }left和top必须和预处理时的letterbox参数完全一致,包括奇数尺寸下的取整方向。预处理用整数除法,这里也要用相同的逻辑,否则差1像素,目标框会整体偏移,小目标特别明显。
后处理写完,如何验证对不对?最简单的方法:拿同一个模型在Python里跑同一张图,导出预处理后的输入数组和输出张量,在C++端也导出一次,逐元素比对。只要预处理一致、推理一致,后处理bug基本能被肉眼发现,因为框的位置错得一定很离谱。
conf_threshold和iou_threshold的取值在真实部署中不是固定的。0.25和0.45是YOLO系列论文里的默认值,但实际场景里误检多就把置信度提到0.4到0.5,漏检多就降到0.15试一试。建议把这两个值做成配置项或命令行参数,不要写死在代码里,因为不同摄像头角度、光照条件下的最优值差异很大。轻量蒸馏版模型的置信度分布可能整体偏低,照搬默认阈值会导致召回率暴跌。
5. 避坑指南:C++部署yolov8的高频问题与排查
代码能跑通只是起点。部署的常态是:开发机一切正常,换到目标机、换模型版本、换硬件后就翻车。下面这些坑都是我实际踩过或被别人反复问过的,按现象、原因、解决的顺序记录。
5.1 现象:全部框被过滤,输出为空
程序运行正常,耗时正常,但返回的检测框永远是0。原因多半在预处理。最常见是忘了做BGR到RGB的转换,模型输入通道顺序错乱,特征空间完全走样;另一个是归一化用了ImageNet的mean/std参数,而yolov8用的是单纯的除以255。
解决:确认预处理代码确实是COLOR_BGR2RGB加上1.0/255.0缩放。如果还不能定位,把C++端预处理后的float数组导出成二进制文件,和Python端用同一张图生成的输入做逐元素对比,误差超过1e-5就逐段排查差异。
5.2 现象:LNK2019无法解析的外部符号
编译通过,链接时报一堆LNK2019无法解析的外部符号,符号名都是Ort::Session::...。原因是OnnxRuntime库没链接上,或链接的库位数与工程不匹配。常见细节:工程是Win32但下的是x64库;链接的onnxruntime.lib是Release版但工程在Debug模式。
解决:确认工程是x64;确认link_directories指向真实存在的onnxruntime.lib;如果用vcpkg集成,检查find_package和target_link_libraries的名字是否一致。这类问题排查路径很固定,不要东改西改,按三个方向查一遍十分钟能定位。
5.3 现象:输出shape和预期不符,索引越界崩溃
推理后访问输出数据时程序崩溃或读到乱值。原因是输出布局理解错了。onnx导出后可能是[1, 84, 8400]也可能是[1, 8400, 84],取决于导出时是否保留PyTorch的permute节点。我确实遇到过自训练模型导出后直接是[1, 8400, 84]的情况,后处理还按84在前写,越界访问直接崩。
解决:拿到outputs[0]后先调用GetTensorTypeAndShapeInfo().GetShape()打印shape,不要靠猜。针对实际shape写后处理,或者统一在导出配置里固定布局,但每个模型版本都要重新验证一遍。
5.4 现象:在rk3588等ARM设备上加载失败或推理极慢
同样的onnx在PC上正常,拷到rk3588上要么加载报错,要么推理速度慢到不可用。原因是这类边缘设备CPU算力有限,纯CPU推理yolov8s基本在几百毫秒到一秒以上,而且部分ONNX算子(某些Resize模式或高版本Gather)在ARM版OnnxRuntime上支持不全。
解决:如果坚持用OnnxRuntime,先关掉图优化试试能否加载:SetGraphOptimizationLevel(ORT_DISABLE_ALL),同时把输入从640降到416或320,推理时间能降一截;但更常用的做法是转成RKNN格式,走Rockchip的RKNN-Toolkit2工具链。所以在rk3588上部署yolov8,正确路径通常是评估RKNN转换的算子兼容性,而不是调OnnxRuntime参数。
5.5 现象:内存只涨不降,疑似泄漏
长时间运行后内存持续增长,重启后恢复,但增速慢,短时间测试暴露不了。原因通常是Session或Ort::Value生命周期管理不当:比如在循环里重复创建Session,或把Ort::Value放进成员变量后没有正确释放;另一个隐蔽来源是在循环里反复构造Ort::AllocatorWithDefaultOptions,某些版本会伴随额外分配。
解决:Session只创建一次;推理函数里的Ort::Value用栈对象接收,出作用域自动释放;展开CUDA分支时确认OrtCUDAProviderOptions没有在循环里重复构造。用valgrind或Visual Studio诊断工具跑一轮长测试,很快能定位到内存增长来自哪个模块。
6. 验证与优化:从能跑到跑得好的三个技巧
6.1 进程预热与多实例线程池
Session创建后第一次推理通常比后续慢不少,因为图优化、线程池初始化和内存分配都发生在第一次调用。生产服务启动时,找一两张普通图片先跑一次推理让Session热身,后续请求的延迟曲线会平滑很多。这个操作简单到一句话,但线上很多抖动其实是没预热导致的。
6.2 用对比验证代替肉眼确认
把C++推理的结果导出成和Python端一致的JSON格式,跑同一批测试图做对比。两边检测框的重合度达到IoU 0.9以上,基本可以确定性部署正确。我每次换模型版本或改优化参数,都会跑一遍这个对比流程,确实挡掉过好几次“看起来正常但精度降了”的翻车。另一个技巧是善用FP16:OnnxRuntime在CUDA EP上对FP16支持明显,GTX1660Ti这类图灵卡上FP16推理耗时能比FP32少三分之一左右。导出时用half=True或者在C++端转换,但注意FP16在CPU上不划算,先确认部署目标再说。
最后落到一个习惯:改任何参数前先记录基线,改完用同一组测试图验证。这个习惯让我在多次调优中不至于陷入“调了哪里都不对”的泥潭。希望帮到你。
本文还有配套的精品资源,点击获取