摘要:YOLOv8 凭借其强大的精度和速度,正在逐渐取代 YOLOv5 成为工业界的新宠。然而,许多工程师直接复用 V5 的 C++ 推理代码时,却遭遇了"检测框全无"或"坐标乱飞"的滑铁卢。本文将揭秘 YOLOv8 与 V5 在 ONNX 输出层面的本质差异(Anchor-Free 与 解耦头),并演示如何通过简单的"矩阵转置"手术,让你的老代码焕发新生。
关键词:YOLOv8, C++, OnnxRuntime, 矩阵转置, Anchor-Free, 工业部署
关于一些基础的可以参考文章,两者之间可共用大部分功能,只是后处理的方式发生了改变【硬核实战】C++ YOLOv5 OnnxRuntime 加速部署(源码深度解析:动态Batch+CUDA加速+预处理对齐)-CSDN博客
具体的配置可参考
YOLO C++ Onnx Opencv项目配置指南-CSDN博客
注意Cuda和Onnx 版本适配的问题
🚀 0. 前言:为什么 V5 的代码跑不动 V8 了?
在上一篇《YOLOv5 C++ 部署实战》中,我们封装了一套稳健的推理类。很多朋友私信我:"博主,我把模型路径换成 yolov8n.onnx,为什么程序不报错但什么都画不出来?"
这不是你的代码有 Bug,而是 YOLOv8 的输出逻辑变天了。
YOLOv8 采用了 Anchor-Free 和 Decoupled Head(解耦头) 设计,导致导出的 ONNX 模型输出维度发生了根本性的翻转。本文将带你通过仅修改 PostProcessing 核心模块,实现 V5 到 V8 的平滑升级。
注 :本文的基础框架(初始化、Letterbox 预处理、OpenMP 加速)与上一篇 V5 部署保持一致,源码请参考文末链接。本文着重讲解后处理的适配。
🛠️ 1. 核心陷阱:输出维度的"乾坤大挪移"
这是所有从 V5 迁移到 V8 的 C++ 开发者遇到的第一个拦路虎。
1.1 YOLOv5 的输出:直观的行优先
YOLOv5 (v6.0+) 的 ONNX 输出通常是 [Batch, 25200, 85]。
-
25200:代表 3 个尺度的锚框总数 (80x80 + 40x40 + 20x20) * 3。
-
85 :代表
[cx, cy, w, h, obj_conf, class0, class1, ...]。 -
特点:每一行就是一个完整的检测框信息,处理起来非常符合直觉。
1.2 YOLOv8 的输出:反直觉的列优先
YOLOv8 的 ONNX 输出通常是 [Batch, 84, 8400]。
-
84 :代表
4个坐标 +80个类别 (注意:没有 obj_conf 了!)。 -
8400:代表锚框总数 (80x80 + 40x40 + 20x20)。
-
特点:它把通道(Channel)放在了前面,锚框(Anchor)放在了后面。
-
第 0 行:所有 8400 个框的
cx。 -
第 1 行:所有 8400 个框的
cy。 -
...
-
第 4~83 行:所有 8400 个框的类别概率。
-
结论 :如果你还按 V5 的方式一行一行读,读出来的完全是乱码!我们需要对这个矩阵进行转置(Transpose)。
⚙️ 2. 代码手术:适配 YOLOv8 后处理
我们需要重写 PostProcessing 函数。为了保持代码的易读性和通用性,我们利用 OpenCV 强大的矩阵操作能力来处理维度变换。
2.1 步骤一:维度转置 (The Transpose)
我们拿到的 float 指针是 84 x 8400 的布局,为了方便遍历,我们先把它转置成 8400 x 84。
C++
// 假设 output_data 指向当前 Batch 的推理结果
// num_classes = 80, num_anchors = 8400
// 原始形状: [4 + 80, 8400]
cv::Mat output_buffer(num_classes + 4, num_anchors, CV_32F, output_data);
// 核心操作:转置!变为 [8400, 84]
// 这样每一行就又变回了一个检测框的所有信息
cv::Mat output_t = output_buffer.t();
// 获取转置后的数据指针,后续操作和 V5 就很像了
float* pdata = (float*)output_t.data;
2.2 步骤二:置信度计算 (Score Calculation)
这是第二个坑。
-
YOLOv5 :
Score = obj_conf * class_conf。 -
YOLOv8 : 取消了
obj_conf分支。检测框的置信度直接就是类别概率中的最大值。
C++
for (int i = 0; i < num_anchors; ++i) {
float* row_ptr = pdata + i * (num_classes + 4);
// 1. 寻找最大类别分数
// row_ptr 前 4 个是坐标,从第 5 个开始是类别分数
float* class_scores = row_ptr + 4;
cv::Point classIdPoint;
double max_score;
// 使用 OpenCV 提供的 minMaxLoc 快速寻找最大值(也可以手写循环,量大时 OpenMP 加速)
// 注意:这里仅处理一行 80 个类别数据
cv::Mat scores_row(1, num_classes, CV_32F, class_scores);
cv::minMaxLoc(scores_row, 0, &max_score, 0, &classIdPoint);
// 2. 阈值筛选
if (max_score > _classThreshold) {
// 3. 解析坐标 (cx, cy, w, h)
float cx = row_ptr[0];
float cy = row_ptr[1];
float w = row_ptr[2];
float h = row_ptr[3];
// 还原为左上角坐标
int left = (int)(cx - 0.5 * w);
int top = (int)(cy - 0.5 * h);
boxes.push_back(cv::Rect(left, top, (int)w, (int)h));
confidences.push_back((float)max_score);
classIds.push_back(classIdPoint.x);
}
}
⚡ 3. 完整实现:PostProcessing (V8 版)
为了方便大家直接 Copy,这里贴出适配 V8 的完整后处理函数。它替换了原 V5 代码中的对应部分。
C++
void Yolov8OnnxDeploy::PostProcessing(float* output_data, int64_t output_size,
const std::vector<LetterBoxParams>& params,
std::vector<std::vector<DetectResult>>& output) {
// 1. 动态获取维度信息 (在 Init 阶段已从模型获取)
// V8 输出: [Batch, 84, Anchors]
int num_channels = _outputTensorShape[1]; // 84
int num_anchors = _outputTensorShape[2]; // 8400
int num_classes = num_channels - 4; // 80
int batch_size = params.size();
// V8 的 output_size 是总元素个数,我们需要按 Batch 步进
// 步长 = 84 * 8400
int64_t one_batch_len = num_channels * num_anchors;
for (int b = 0; b < batch_size; ++b) {
float* pdata = output_data + b * one_batch_len;
std::vector<int> classIds;
std::vector<float> confidences;
std::vector<cv::Rect> boxes;
// --- 核心修改开始 ---
// 利用 cv::Mat 进行转置:[84, 8400] -> [8400, 84]
cv::Mat raw_data(num_channels, num_anchors, CV_32F, pdata);
cv::Mat transposed_data = raw_data.t();
float* pdata_t = (float*)transposed_data.data;
for (int i = 0; i < num_anchors; ++i) {
float* row_ptr = pdata_t + i * num_channels;
// 类别分数指针 (跳过 cx, cy, w, h)
float* score_ptr = row_ptr + 4;
// 找最大分类概率
float max_score = -FLT_MAX;
int class_id = -1;
for(int k=0; k<num_classes; k++){
if(score_ptr[k] > max_score){
max_score = score_ptr[k];
class_id = k;
}
}
if (max_score > _classThreshold) {
float cx = row_ptr[0];
float cy = row_ptr[1];
float w = row_ptr[2];
float h = row_ptr[3];
int left = (int)(cx - 0.5 * w);
int top = (int)(cy - 0.5 * h);
boxes.push_back(cv::Rect(left, top, (int)w, (int)h));
confidences.push_back(max_score);
classIds.push_back(class_id);
}
}
// --- 核心修改结束 ---
// 下面的 NMS 和坐标还原逻辑与 V5 完全一致
std::vector<int> nms_result;
cv::dnn::NMSBoxes(boxes, confidences, _classThreshold, _nmsThreshold, nms_result);
std::vector<DetectResult> temp_output;
for (int idx : nms_result) {
DetectResult result;
result.classId = classIds[idx];
result.score = confidences[idx];
// Letterbox 逆变换
float r = params[b].ratioX;
float dw = params[b].dw;
float dh = params[b].dh;
float x = (boxes[idx].x - dw) / r;
float y = (boxes[idx].y - dh) / r;
float w = boxes[idx].width / r;
float h = boxes[idx].height / r;
result.box = cv::Rect(round(x), round(y), round(w), round(h));
temp_output.push_back(result);
}
output.push_back(temp_output);
}
}
🔍 4. 常见问题 (Q&A)
Q1: 为什么我的 V8 模型输出是 [1, 84, 8400] 而不是 V5 的 [1, 25200, 85]? A: 这是 YOLOv8 模型结构的特性。V8 使用了 Anchor-Free 策略,减少了预测框数量(8400 vs 25200),同时为了适应 PyTorch 的某些算子优化,默认输出通道在前。
Q2: 性能会有影响吗?转置操作耗时吗? A : cv::Mat::t() 确实涉及内存拷贝,但在 8400 这个数量级下,CPU 耗时通常在 0.5ms 以内,相对于模型推理(几十毫秒)几乎可以忽略不计。如果追求极致性能,可以直接通过指针步长(Stride)访问原始数据,避免转置拷贝,但代码可读性会变差。
Q3: 预处理 (Letterbox) 需要改吗? A: 不需要!YOLOv8 的图片预处理逻辑(Resize, Pad 114, Normalize 0-1, RGB)与 V5 是一模一样的。这也是为什么我们能复用大部分代码的原因。

📂 5. 总结与源码
从 YOLOv5 到 YOLOv8 的 C++ 部署,最大的变化在于对输出 Tensor 的理解和解析。只要掌握了"转置"和"无 Objectness 分支"这两个关键点,剩下的就是简单的代码替换。
完整的工程代码(包含 .h, .cpp )已上传。