简介:本资源是一套面向Linux开发者与边缘部署工程师的YOLOv8 C++推理实战方案,专为Ubuntu平台低配置设备(如工控机、嵌入式终端)优化设计,解决Python部署开销大、实时性差、依赖繁杂等痛点。资源共363个文件,涵盖180个hpp头文件、69个h系统头文件、42张JPG/BMP测试图像、2个ONNX模型文件、3个核心CPP源码及配套config.txt等配置文件,辅以lib目录中onnxruntime、OpenCV等关键动态库(含libonnxruntime.so.1.12.1等),整体压缩包120.57MB,结构清晰,bin/src/include/models等模块划分明确。目前已有159人学习下载。用户可直接编译运行,快速完成ONNX模型加载、图像预处理、推理执行、边界框解析与NMS后处理全流程;配套说明文档详述环境变量设置、库路径配置及type.names类别映射规则,显著降低C++部署门槛,并提供多模型配置管理(config0.txt)与阈值灵活调节能力。
1. 项目概述:在低配机器上跑通YOLOv8推理
最近在折腾一个挺有意思的事儿:手头有几台配置不高的旧机器,比如一台老款的笔记本,显卡还是GTX 1660 Ti,甚至还有一台只用CPU的迷你主机。想在这些设备上跑一下最新的YOLOv8模型,看看目标检测的效果。直接用官方的PyTorch版本或者Ultralytics的库,对资源要求不低,尤其是在没有独立显卡或者显存很小的机器上,体验很卡顿。
于是,我就琢磨着能不能用C++,配合ONNX Runtime和OpenCV这些轻量级的库,把训练好的YOLOv8模型(导出为ONNX格式)部署起来。目标很明确:第一,要脱离沉重的Python深度学习框架依赖,让程序本身更轻;第二,要充分利用ONNX Runtime的跨平台和性能优化,特别是在CPU和低端GPU上的推理能力;第三,整个流程要清晰,从模型加载、预处理、推理到后处理,全部用C++实现,最终封装成一个可以方便调用的模块或者可执行文件。
这个思路对于嵌入式设备、边缘计算盒子或者只是想低成本体验深度学习应用的开发者来说,应该挺有吸引力的。毕竟不是每个人都有RTX 4090,但学习的热情和动手的欲望是一样的。接下来,我就把自己从环境搭建、代码编写到最终跑通的全过程,包括踩过的坑和总结的经验,详细分享一下。
2. 环境准备与核心工具链选型
要在Ubuntu下用C++搞深度学习推理,第一步就是把“厨房”收拾好。这里的选择直接决定了后续开发的顺畅度和最终程序的性能。
2.1 操作系统与编译器
我选择的是Ubuntu 22.04 LTS。LTS版本长期支持,社区资源丰富,遇到问题容易找到解决方案。系统安装过程就不赘述了,无论是物理机、VMware虚拟机还是WSL2,都可以。不过这里有个小建议:如果你用WSL2,并且希望使用GPU进行推理,需要安装WSL2的GPU驱动支持,步骤会稍微多一步。对于纯粹CPU推理,WSL2非常方便。
编译器自然是GCC/G++。Ubuntu 22.04 默认的版本(通常是g++-11)就完全够用。确保安装开发工具包:
sudo apt update sudo apt install build-essential cmakebuild-essential包含了gcc, g++, make等核心工具。cmake是现代C++项目管理的标配,我们后面编译第三方库全靠它。
2.2 核心库:ONNX Runtime与OpenCV
这是整个项目的两大支柱。
ONNX Runtime:微软开源的跨平台推理引擎。它负责加载我们导出的.onnx模型文件,并在指定的硬件后端(CPU、CUDA、TensorRT等)上高效执行推理。我们不需要关心模型内部复杂的计算图,只需要喂给它输入数据,它就能给出输出。
为什么选它?
- 性能优异:针对不同硬件有深度优化,CPU推理可以用MKL-DNN或OpenMP,GPU支持CUDA和TensorRT。
- 接口统一:C++ API稳定,一套代码稍作修改就能切换推理设备。
- 生态成熟:作为ONNX模型的“官方”运行时之一,兼容性好,更新活跃。
安装方式我推荐从源码编译,虽然耗时,但能获得最适合自己系统的优化版本,也方便调试。可以从GitHub仓库下载源码,编译时指定--config Release、--build_shared_lib(生成动态库)以及像--use_cuda(如果你有N卡并安装了CUDA)这样的参数。
OpenCV:计算机视觉的“瑞士军刀”。在我们的流程里,它主要负责图像处理部分:读取图片、颜色空间转换(BGR到RGB)、尺寸缩放、归一化,以及最后将推理得到的检测框和类别画到原图上。
为什么必不可少?
- 图像I/O与处理:
imread,resize,cvtColor等函数极其高效且稳定。 - 矩阵运算:OpenCV的
Mat对象是处理图像数据的天然容器,与ONNX Runtime的输入输出Tensor可以方便地进行数据转换。 - 可视化:
rectangle,putText函数能轻松完成结果标注。
同样建议从源码编译OpenCV(4.x版本),开启WITH_OPENMP以支持多线程,能加速一些预处理操作。
2.3 辅助工具:VSCode与CMake
VSCode:轻量级但功能强大的代码编辑器。通过安装C/C++扩展、CMake Tools扩展,可以获得接近IDE的体验,包括代码补全、跳转定义、编译和调试。它的配置文件(如c_cpp_properties.json,tasks.json,launch.json)可以很好地管理包含路径和库路径,特别是当我们自编译了多个第三方库时。
CMake:项目构建的核心。写一个清晰的CMakeLists.txt文件,能自动找到本机安装的OpenCV和ONNX Runtime,链接正确的库文件。这对于项目在不同机器间的移植至关重要。一个基本的CMakeLists需要包含:
cmake_minimum_required(VERSION 3.16) project(YOLOv8_CPP_Inference) set(CMAKE_CXX_STANDARD 17) find_package(OpenCV REQUIRED) # 假设ONNX Runtime头文件和库文件放在自定义目录 include_directories(/path/to/onnxruntime/include) link_directories(/path/to/onnxruntime/lib) add_executable(yolov8_inference main.cpp preprocess.cpp postprocess.cpp) target_link_libraries(yolov8_inference ${OpenCV_LIBS} onnxruntime)注意:ONNX Runtime的
find_package支持可能不完善,所以经常需要手动指定include_directories和link_directories。务必链接正确的库文件,例如在CPU版本下是onnxruntime,在GPU版本下可能是onnxruntime_providers_cuda等。
3. YOLOv8 ONNX模型解析与预处理
拿到一个从Ultralytics YOLOv8导出的.onnx文件后,别急着跑。先把它“解剖”一下,搞清楚它的输入输出格式,这是正确调用它的前提。
3.1 模型输入输出探秘
使用Netron(一个可视化的神经网络模型查看工具)打开你的ONNX文件。你会发现,YOLOv8的输入输出和YOLOv5等前代有所不同。
输入节点:
- 名称:通常是
images或input0。 - 形状:
[1, 3, 640, 640]。这表示模型期望的输入是:- 批量大小 (Batch Size) = 1(一次推理一张图)
- 通道数 (Channels) = 3(RGB三通道)
- 高度 (Height) = 640
- 宽度 (Width) = 640
- 数据类型:
float32。
输出节点:
- YOLOv8的检测头是“解耦”的,它的输出不再是
[1, 25200, 85]这样的格式。你可能会看到两个输出:- 一个形状为
[1, 84, 8400]的输出。这是核心。1:批大小。84:每个预测框的属性数量。对于COCO数据集(80类),它是4(框坐标)+ 80(类别概率) = 84。8400:预测框的总数。这来自于模型三个不同尺度特征图(80x80, 40x40, 20x20)的锚点总和:80*80 + 40*40 + 20*20 = 8400。
- 可能还有一个额外的输出,但主要信息在第一个里。
- 一个形状为
理解这个[1, 84, 8400]的形状是后续后处理的关键。它意味着模型直接输出了8400个候选框,每个框有84个数值。
3.2 图像预处理流程(C++实现)
预处理的目标是把一张任意尺寸的图片,转换成符合模型输入要求的1x3x640x640的float32数组。
步骤拆解:
- 读取与颜色转换:用OpenCV的
imread读取图片,得到BGR格式的Mat。YOLOv8训练时通常使用RGB格式,所以需要cvtColor(img, img, cv::COLOR_BGR2RGB)。 - 保持宽高比的缩放(LetterBox):这是关键一步。简单粗暴地
resize到640x640会导致图像变形。YOLO系列常用的方法是LetterBox:将图像等比缩放,直到最长边等于640,短边不足的部分用灰色(如114)填充,从而保持物体比例不变。
同时,需要记录下缩放比例cv::Mat letterbox(const cv::Mat& src, int target_width, int target_height) { int src_w = src.cols; int src_h = src.rows; float scale = std::min((float)target_width / src_w, (float)target_height / src_h); int new_w = int(src_w * scale); int new_h = int(src_h * scale); cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h)); cv::Mat dst = cv::Mat::zeros(target_height, target_width, src.type()); // 填充值通常为114 dst.setTo(cv::Scalar(114, 114, 114)); // 将缩放后的图像拷贝到目标图像中央 resized.copyTo(dst(cv::Rect((target_width - new_w) / 2, (target_height - new_h) / 2, new_w, new_h))); return dst; }scale和填充的偏移量(dx, dy),用于后续将推理出的框坐标映射回原图。 - 归一化与通道转换:将像素值从
[0, 255]归一化到[0, 1](/255.0)。有些模型可能还需要进一步的标准化(减均值除标准差),但YOLOv8官方导出的一般只需要除255。然后,OpenCV的Mat是HxWxC(行x列x通道)的内存布局,而ONNX模型需要CxHxW。我们需要做一次转置。 - 转换为连续数组:将处理好的
Mat数据拷贝到一个一维的float数组中,准备喂给模型。
实操心得:
- 预处理的所有参数(尺寸640、归一化方式、填充色)必须与模型训练和导出时的设置严格一致。最好查看原训练代码或导出脚本确认。
- LetterBox的填充色不一定是114,但114是YOLOv5/v8常用的默认值。保持一致即可。
- 数据从
Mat到float数组的拷贝可以用指针遍历,也可以使用Mat.ptr<T>()和memcpy,注意内存对齐和效率。
4. ONNX Runtime推理引擎的集成与调用
预处理准备好了数据,现在轮到ONNX Runtime登场,执行核心的神经网络前向传播。
4.1 初始化推理会话
首先,需要创建一个Ort::Session(推理会话),它是模型在内存中的代表。
#include <onnxruntime/core/session/onnxruntime_cxx_api.h> Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "YOLOv8Inference"); Ort::SessionOptions session_options; // 配置会话选项 session_options.SetIntraOpNumThreads(4); // 设置并行线程数,根据CPU核心数调整 session_options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); // 指定执行提供商(Execution Provider, EP) // 情况1:使用CPU推理(最通用) // Ort::Session session(env, "yolov8n.onnx", session_options); // 情况2:如果有NVIDIA GPU和CUDA,使用CUDA EP加速 OrtCUDAProviderOptions cuda_options; cuda_options.device_id = 0; // 使用第0块GPU session_options.AppendExecutionProvider_CUDA(cuda_options); // 创建会话 Ort::Session session(env, "path/to/your_model.onnx", session_options);关键点在于Execution Provider的选择。对于低配机器:
- CPU:默认选项。可以通过
SetIntraOpNumThreads和SetInterOpNumThreads来利用多核。在旧CPU上,这是唯一选择。 - CUDA:如果你有哪怕是一块像GTX 1660 Ti这样的“老将”,CUDA EP也能带来巨大的速度提升。记得在编译ONNX Runtime时开启
--use_cuda。 - TensorRT:如果模型转换成了TensorRT引擎,可以获得极致优化,但流程更复杂。
- CoreML (macOS), OpenVINO (Intel)等:针对特定硬件平台的优化。
4.2 准备输入与获取输出
创建好会话后,需要按照模型输入输出的名字和形状来准备数据。
// 1. 获取模型输入输出信息 Ort::AllocatorWithDefaultOptions allocator; auto input_name = session.GetInputNameAllocated(0, allocator); auto output_name = session.GetOutputNameAllocated(0, allocator); // 注意:GetInputNameAllocated是较新API,如果编译报错,可能是版本问题,可尝试GetInputName // 2. 定义输入输出Tensor的形状 std::vector<int64_t> input_shape = {1, 3, 640, 640}; std::vector<const char*> input_names = {input_name.get()}; std::vector<const char*> output_names = {output_name.get()}; // 3. 准备输入数据(假设preprocessed_data是预处理得到的float数组) std::vector<float> input_tensor_values(preprocessed_data, preprocessed_data + 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()); // 4. 运行推理 auto output_tensors = session.Run(Ort::RunOptions{nullptr}, input_names.data(), &input_tensor, 1, output_names.data(), 1); // 5. 解析输出 Ort::Value& output_tensor = output_tensors.front(); float* output_data = output_tensor.GetTensorMutableData<float>(); auto output_shape = output_tensor.GetTensorTypeAndShapeInfo().GetShape(); // output_shape 应该等于 [1, 84, 8400]踩坑记录:ONNX Runtime的C++ API在不同版本间可能有细微变动,特别是内存管理和名称获取部分。我用的版本是1.16左右。如果遇到
GetInputName相关错误,请务必查阅你所使用版本的官方文档或示例代码。一个稳妥的方法是使用session.GetInputNameAllocated(如果可用),它自动管理内存。
4.3 性能调优小技巧
在低配机器上,每一分性能都值得争取:
- 会话选项:
SetIntraOpNumThreads设置为CPU物理核心数(非超线程数)通常效果较好。对于简单模型,也可以尝试关闭并行SetIntraOpNumThreads(1),有时开销更小。 - 内存复用:如果进行视频流连续推理,可以复用
input_tensor_values和Ort::Value对象,避免反复分配内存。 - 预热:在正式推理前,先用一张小图或随机数据跑几次
session.Run,让运行时完成初始化、图优化等操作,后续推理会更稳定。 - 批处理:虽然我们这里批大小是1,但ONNX Runtime支持批处理。如果你的应用场景需要同时处理多张图,且机器内存允许,使用更大的批大小(如4, 8)可以更充分地利用GPU并行计算能力,提升吞吐量。需要修改输入形状和预处理逻辑。
5. 推理结果的后处理与解析
从ONNX Runtime拿到[1, 84, 8400]的输出数组后,这才是“万里长征第一步”。我们需要从这8400个候选框中,筛选出那些真正有物体的、位置准确的框。
5.1 解码与筛选流程
后处理可以分解为以下几个步骤,我将其封装在一个postprocess函数中:
- 遍历所有候选框:循环
i从 0 到 8399,每个框对应输出数据中第i列(共8400列),每列有84个值。前4个值[cx, cy, w, h]是框的中心点坐标和宽高,但它们是相对于640x640输入网格的,需要转换。后面的80个值是类别概率。 - 计算置信度:对每个框,从80个类别概率中找出最大值
max_class_score及其对应的类别IDclass_id。这个最大值代表了模型认为该框包含此类物体的置信度。 - 应用置信度阈值:设定一个阈值(如
confidence_threshold = 0.25)。如果max_class_score < confidence_threshold,直接丢弃这个框。这一步可以过滤掉绝大部分(可能超过95%)的无效预测。 - 解码框坐标:
- 模型输出的
cx, cy是相对于该框所在网格左上角的偏移量(经过sigmoid激活)。w, h是相对于锚点(anchor)宽高的缩放值(经过指数运算)。但YOLOv8的官方导出模型通常已经做了一些简化,输出可能已经是归一化到[0,1]的坐标,或者是直接可用的值。这里需要根据你导出模型时的具体参数来定。一个常见的做法是,假设输出已经是相对于640x640的归一化坐标。 - 计算框的左上角和右下角坐标:
float x_center = output_data[i * 84 + 0]; // 假设已是归一化坐标 float y_center = output_data[i * 84 + 1]; float width = output_data[i * 84 + 2]; float height = output_data[i * 84 + 3]; float x1 = (x_center - width / 2.0f); float y1 = (y_center - height / 2.0f); float x2 = (x_center + width / 2.0f); float y2 = (y_center + height / 2.0f);
- 模型输出的
- 映射回原图尺寸:将上述在640x640坐标系下的坐标
(x1, y1, x2, y2),根据之前LetterBox预处理时记录的缩放比例scale和填充偏移(dx, dy),映射回原始图像的坐标系。// scale = 640 / max(原图高,原图宽) // dx, dy 是填充在两侧/上下的像素数 x1 = (x1 * 640 - dx) / scale; y1 = (y1 * 640 - dy) / scale; x2 = (x2 * 640 - dx) / scale; y2 = (y2 * 640 - dy) / scale; // 确保坐标不超出原图边界 x1 = std::max(0.0f, std::min(x1, (float)src_img.cols)); y1 = std::max(0.0f, std::min(y1, (float)src_img.rows)); ... // 对x2, y2做同样处理 - 收集有效检测框:将经过阈值筛选、坐标解码和映射后的框(包含坐标、置信度、类别ID)存入一个临时向量。
5.2 非极大值抑制
经过置信度筛选后,可能还会有多个框检测到同一个物体。NMS的作用就是去除这些冗余框。
#include <algorithm> #include <vector> struct Detection { cv::Rect box; float conf; int class_id; }; void nms(std::vector<Detection>& detections, float nms_threshold) { if (detections.empty()) return; // 按置信度从高到低排序 std::sort(detections.begin(), detections.end(), [](const Detection& a, const Detection& b) { return a.conf > b.conf; }); std::vector<Detection> keep; std::vector<bool> suppressed(detections.size(), false); for (size_t i = 0; i < detections.size(); ++i) { if (suppressed[i]) continue; keep.push_back(detections[i]); for (size_t j = i + 1; j < detections.size(); ++j) { if (suppressed[j]) continue; // 计算IoU(交并比) cv::Rect intersection = detections[i].box & detections[j].box; float inter_area = intersection.area(); float union_area = detections[i].box.area() + detections[j].box.area() - inter_area; float iou = inter_area / union_area; // 如果IoU超过阈值,抑制置信度较低的框 if (iou > nms_threshold) { suppressed[j] = true; } } } detections = std::move(keep); }调用nms(detections, 0.45);即可。经过NMS,detections向量里剩下的就是最终、最精简的检测结果了。
后处理心得:
- 阈值调参:
confidence_threshold和nms_threshold需要根据实际场景微调。阈值太高会漏检,太低则杂框多、计算慢。通常从0.25和0.45开始尝试。 - 性能瓶颈:在CPU上,后处理(尤其是NMS)可能比模型推理本身更耗时,特别是当置信度阈值设得较低,产生大量候选框时。优化方法包括:使用更快的NMS实现(如带索引排序的)、尝试将部分后处理移到GPU上(较复杂)、或者适当提高置信度阈值。
- 坐标映射:这是最容易出错的一步。务必用一张简单的测试图,打印出预处理前后的坐标,并可视化检查映射是否正确。一个框在缩放填充后的图像上看起来位置正确,映射回原图后可能就偏了。
6. 工程化封装与性能优化实战
把各个模块跑通后,我们需要把它变成一个整洁、可复用、高效的项目。
6.1 类设计与接口封装
我设计了一个YOLOv8Infer类,将整个流程封装起来:
class YOLOv8Infer { public: YOLOv8Infer(const std::string& model_path, const std::string& class_names_file, bool use_gpu = false, int intra_op_threads = 4); ~YOLOv8Infer(); bool init(); // 初始化模型,加载类别名 std::vector<Detection> infer(const cv::Mat& src_img); // 执行推理 void draw_results(cv::Mat& img, const std::vector<Detection>& detections); // 绘制结果 private: cv::Mat preprocess(const cv::Mat& src, float& scale, int& dx, int& dy); std::vector<Detection> postprocess(const std::vector<float>& output_data, float scale, int dx, int dy, float conf_thresh, float nms_thresh); Ort::Env env_; Ort::Session session_{nullptr}; std::vector<std::string> input_names_; std::vector<std::string> output_names_; std::vector<std::string> class_names_; // ... 其他成员变量,如图像尺寸、阈值等 };这样,在主函数中,使用就非常清晰了:
YOLOv8Infer inferer("yolov8n.onnx", "coco.names"); if (inferer.init()) { cv::Mat img = cv::imread("test.jpg"); auto results = inferer.infer(img); inferer.draw_results(img, results); cv::imwrite("result.jpg", img); }6.2 多线程与流水线优化
对于视频流或批量图片处理,单线程顺序执行“读图->预处理->推理->后处理->显示”效率低下。可以采用生产者-消费者模型:
- 线程A(生产者):负责读取视频帧或图片,放入一个预处理队列。
- 线程B(预处理):从队列取图,进行LetterBox等预处理,放入推理队列。
- 线程C(推理):从推理队列取预处理后的数据,调用ONNX Runtime进行推理,结果放入后处理队列。
- 线程D(后处理与渲染):进行NMS、坐标映射,并绘制结果或保存。
使用std::queue配合std::mutex和std::condition_variable可以实现简单的线程安全队列。这样,当线程C在进行GPU推理(计算密集型)时,线程A和B可以并行地准备下一帧的数据,充分利用系统资源。
6.3 针对低配置机器的特别优化
- 模型选择:优先使用YOLOv8的纳米(n)或小(s)型号。
yolov8n.onnx文件只有几MB,对内存和计算压力小得多,在CPU上也能达到较快的帧率。 - 输入分辨率:不一定非要640x640。如果场景中物体较大,可以尝试更小的输入尺寸,如320x320,能显著降低计算量。但需要重新导出模型或确认模型支持动态输入。
- 量化:如果性能仍不满足,可以考虑模型量化。ONNX Runtime支持将FP32模型量化为INT8,推理速度能提升2-4倍,精度损失通常很小。可以使用ONNX Runtime的量化工具,但这需要一部分校准数据,流程稍复杂。
- CPU推理优化:
- 在编译ONNX Runtime时,启用
--use_openmp和--use_mkldnn(针对Intel CPU)或--use_dnnl。 - 在代码中,尝试不同的线程数设置(
SetIntraOpNumThreads),并非越多越好,有时设置为核心数的一半性能最佳。 - 确保你的程序以高性能模式运行,避免被操作系统节能策略限制。
- 在编译ONNX Runtime时,启用
7. 常见问题排查与解决实录
在实际部署过程中,我遇到了不少问题,这里把典型的几个列出来,供大家参考。
7.1 编译与链接问题
问题1:找不到onnxruntime库或头文件。
- 表现:编译时报错
fatal error: onnxruntime_cxx_api.h: No such file or directory或链接时报错undefined reference to Ort::xxx。 - 排查:
- 检查CMakeLists.txt中
include_directories和link_directories的路径是否正确指向了ONNX Runtime的安装位置。 - 确认链接的库文件名是否正确。在Linux下,动态库文件通常是
libonnxruntime.so。使用target_link_libraries(your_target onnxruntime)。 - 如果ONNX Runtime是自己编译的,确保编译时指定了
--build_shared_lib以生成动态库。
- 检查CMakeLists.txt中
- 解决:最稳妥的方法是使用
find_library和find_path,或者将ONNX Runtime的lib和include目录加入到系统的环境变量LD_LIBRARY_PATH和CPLUS_INCLUDE_PATH中。
问题2:OpenCV版本冲突或找不到。
- 表现:
find_package(OpenCV REQUIRED)失败,或链接时出现大量未定义符号。 - 排查:系统可能安装了多个版本的OpenCV(如通过apt安装的
opencv和手动编译的opencv4)。 - 解决:在CMake中指定版本和路径:
find_package(OpenCV 4 REQUIRED PATHS /your/custom/opencv/build)。或者,卸载不需要的版本,确保系统只有一个主要的OpenCV。
7.2 运行时推理错误
问题3:模型输入输出形状不匹配。
- 表现:
session.Run时抛出异常,提示输入输出形状或类型错误。 - 排查:
- 用Netron再次确认模型的输入输出名称和形状。
- 在代码中打印出你准备的
input_tensor的形状和数据类型,与模型期望的进行对比。 - 检查预处理最后一步,从
Mat到float数组的数据排布(HWC转CHW)和数值范围(归一化)是否正确。
- 解决:严格按照模型要求准备数据。一个常见的错误是忘记做BGR到RGB的转换,或者归一化参数不对。
问题4:推理结果全为零或毫无意义。
- 表现:程序能跑通,但检测不到任何物体,或者框的位置完全错误。
- 排查:
- 预处理不一致:这是最大的嫌疑。确认LetterBox的填充色、缩放算法、归一化方式(/255.0)是否与训练时完全一致。可以打印出预处理后数组的几个值,与用Python脚本处理同一张图片的结果对比。
- 后处理解码错误:确认从
[1, 84, 8400]数组中解析坐标和置信度的逻辑是否正确。特别是坐标映射回原图的公式。 - 模型问题:确认导出的ONNX模型本身是正确的。可以用ONNX Runtime的Python API跑一下同一张图片,看结果是否正常。
- 解决:建议编写一个简单的Python脚本,使用ONNX Runtime Python包对同一张图片进行推理,并将预处理后的数据、推理原始输出、后处理结果都保存下来。然后在C++程序中,在关键节点(如预处理后、推理后、后处理后)将数据与Python脚本的结果进行逐元素对比,这是定位问题最有效的方法。
7.3 性能相关问题
问题5:CPU推理速度非常慢,帧率只有个位数。
- 排查:
- 使用
top或htop命令查看进程的CPU占用率。如果不到100%,可能没有充分利用多核。 - 检查ONNX Runtime会话的线程设置
SetIntraOpNumThreads。 - 使用性能分析工具(如
perf)找出热点函数。很可能时间花在了后处理的NMS上。
- 使用
- 解决:
- 尝试调整
SetIntraOpNumThreads和SetInterOpNumThreads。 - 优化后处理代码,比如使用更高效的循环,或者尝试提高置信度阈值以减少进入NMS的框数量。
- 考虑换用更小的模型(如YOLOv8n)。
- 尝试调整
问题6:GPU推理没有加速效果,甚至比CPU还慢。
- 排查:
- 确认CUDA和cuDNN已正确安装,并且ONNX Runtime是支持CUDA的版本。
- 在代码中检查是否成功创建了CUDA执行提供商的会话。可以添加日志。
- 使用
nvidia-smi命令查看GPU是否被调用,以及利用率如何。如果利用率很低,可能是数据在CPU和GPU之间拷贝的开销太大,或者批处理大小太小,无法掩盖启动开销。
- 解决:
- 确保使用
session_options.AppendExecutionProvider_CUDA(cuda_options)。 - 对于低端GPU,尝试增大推理的批处理大小(如果支持),以提升GPU利用率。
- 对于非常小的模型,GPU加速可能不明显,因为内核启动和数据传输的固定开销占比太高。
- 确保使用
整个项目从环境搭建到最终跑通,是一个典型的深度学习模型C++部署流程。它剥离了Python训练框架的厚重,得到了一个轻量、高效、可独立分发的推理程序。这对于资源受限的边缘设备、需要高并发的服务器端应用,或者仅仅是希望深入理解模型推理每一个细节的开发者来说,价值巨大。最关键的是,通过亲手实现一遍,你对YOLO的后处理、ONNX Runtime的工作机制、以及C++下的高性能计算会有更深刻的认识,这是单纯调包所无法比拟的。
本文还有配套的精品资源,点击获取