【避坑指南】YOLOv8转ONNX后推理全零?5步修复指南,手把手教你排查!
【避坑指南】YOLOv8转ONNX后推理全零?5步修复指南,手把手教你排查!
摘要:辛辛苦苦训练好的YOLOv8模型,转成ONNX后却“罢工”了——推理结果全是0,没有框、没有类别?本文将带你从预处理、输出解析、后处理、版本兼容到导出参数全面排查,给出一套完整可复现的解决方案。无论你是刚入门的目标检测新手,还是准备将YOLO部署到生产环境的工程师,本文都能帮你快速定位问题,少走弯路。
引言
“明明训练时效果炸裂,导出ONNX后怎么就全废了?”
这是很多YOLO部署新人都会遇到的噩梦。看着控制台输出的空数组,再检查一遍代码、换一个模型、重装依赖……折腾半天,问题依旧。别慌,今天我就把这套从踩坑到填坑的经验完整分享出来,让你从此告别ONNX推理“翻车”。
环境说明
| 项目 | 版本/配置 |
|---|---|
| 操作系统 | Windows 10 / Ubuntu 20.04 |
| Python | 3.8 ~ 3.11 |
| Ultralytics | 8.0.224+ |
| ONNX Runtime | 1.17.0(CPU)/ 1.17.0(GPU) |
| OpenCV | 4.8.0+ |
| 模型 | YOLOv8n / YOLOv8s / 自定义训练模型 |
| 输入尺寸 | 640×640(典型值) |
错误现场
假设你运行推理代码,得到类似以下输出:
# 推理后打印检测结果
for det in detections:
print(det) # 什么也不打印,或全是0
或者更直接的:
# 用Netron查看ONNX模型输出维度正确(如[1,84,8400]),但转换后结果为空
boxes = outputs[0, :, :4] # 全是0
scores = ... # 全是0或很小
这个错误意味着什么?
模型推理过程没有报错,但输出结果不符合预期——说明预处理、输出解析或后处理环节与ONNX模型实际结构不匹配,导致无法正确解码出目标。
排查思路
按时间顺序,我先后尝试了以下几种方法,每个步骤都真实反映了思考过程:
1️⃣ 检查预处理流程
我知道ONNX模型部署时,预处理必须与训练时完全一致。于是对照Ultralytics源码,检查了三个关键点:
- ✅ 是否用了Letterbox等比例缩放(而不是直接拉伸)?
- ✅ 归一化是否除以255并转为[0,1]?
- ✅ 通道顺序是否为RGB(OpenCV默认BGR,需转换)?
👉 结果:代码都做到了,问题依旧。
2️⃣ 用Netron可视化ONNX模型输出层
打开Netron,拖入模型文件,查看输出节点:[1,84,8400]。
YOLOv8的84维包含:4个边界框坐标 + 80个类别概率。
于是我调整了代码中的转置逻辑:outputs.transpose(0,2,1) 得到 [1,8400,84],然后提取前4列和后面80列。
👉 结果:概率值依然异常,怀疑Sigmoid应用位置错误。
3️⃣ 检查后处理中的NMS和坐标反缩放
我确认了NMS的阈值(IoU=0.45,置信度=0.25),并正确地从Letterbox填充后的坐标反推到原图。
👉 结果:还是没有框,说明问题不在NMS本身。
4️⃣ 验证ONNX Runtime版本兼容性
查阅文档发现,ONNX Opset=17需要ONNX Runtime ≥1.17.0。而我之前的版本是1.14.0。
👉 执行 pip install onnxruntime==1.17.0 后,问题依旧——说明不是版本单独导致的。
5️⃣ 重新导出模型
怀疑导出时参数不对,于是用官方推荐方式重新导出:
model.export(format='onnx', imgsz=640, opset=17, simplify=True)
👉 结果:终于有框了!但是框的位置偏得离谱——原来之前的导出可能遗漏了某些算子。
终极解决方案 ✅
经过以上排查,最终确认需要同时修正以下5个环节,才能稳定推理。下面给出完整步骤和代码。
Step 1:统一预处理(Letterbox + 归一化 + RGB)
import cv2
import numpy as np
def letterbox(img, new_shape=(640, 640), color=(114,114,114)):
shape = img.shape[:2] # (H, W)
r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])
new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r))
dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1]
dw //= 2; dh //= 2
img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR)
top, bottom = dh, dh + (new_shape[0] - new_unpad[1])
left, right = dw, dw + (new_shape[1] - new_unpad[0])
img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color)
return img, (left, top, right, bottom)
# 使用
img0 = cv2.imread('test.jpg') # BGR原图
img, (left, top, _, _) = letterbox(img0, (640, 640))
# 通道转换、归一化、添加batch维度
img = img[:, :, ::-1].astype(np.float32) / 255.0 # BGR -> RGB, [0,1]
img = np.transpose(img, (2, 0, 1))[None, :, :, :] # HWC -> CHW, add batch
Step 2:确认ONNX输出结构并正确解析
用Netron查看输出张量形状,假设为 [1,84,8400],则:
import onnxruntime
import numpy as np
# 创建推理会话
session = onnxruntime.InferenceSession('best.onbox')
input_name = session.get_inputs()[0].name
# 假设img是Step1预处理后的图像数据
outputs = session.run(None, {input_name: img})[0] # 形状为[1, 84, 8400]
# 转置为[1, 8400, 84],每一行代表一个预测框
outputs = outputs.transpose(0, 2, 1) # 现在形状是[1, 8400, 84]
# 将输出拆分为边界框坐标和类别概率
# 前4个值是边界框坐标 (cx, cy, w, h),需要经过Sigmoid激活
# 后80个值是类别概率,也需要经过Sigmoid激活
outputs = 1 / (1 + np.exp(-outputs)) # 对所有输出应用Sigmoid
# 现在outputs[0]的形状是[8400, 84],每一行前4个是边界框,后80个是类别概率
predictions = outputs[0] # [8400, 84]
Step 3:解码边界框(从相对坐标到绝对坐标)
YOLOv8的边界框输出是相对于特征图网格的归一化值,需要转换为绝对坐标。
def decode_boxes(predictions, input_shape=(640, 640)):
"""
解码边界框
predictions: [8400, 84] 的数组,每一行前4个是(cx, cy, w, h),后80个是类别概率
input_shape: 模型输入图像的尺寸 (height, width)
"""
boxes = predictions[:, :4] # 提取前4列,即边界框
scores = predictions[:, 4:] # 后80列是类别概率
# 生成网格点
grid_h, grid_w = 80, 80 # 假设特征图大小为80x80(对应8400个预测框,8400=80*80*1.3125? 实际是80*80+40*40+20*20=8400)
# 注意:YOLOv8有3个检测头,分别对应80x80, 40x40, 20x20,总共有8400个预测框
# 这里我们假设predictions已经按照顺序排列好了,所以我们需要重建每个框对应的网格坐标
# 由于我们不知道每个框具体来自哪个检测头,我们可以通过索引来重建
# 实际上,在导出ONNX时,模型已经将三个检测头的输出合并并排序,我们只需要按照顺序生成对应的网格即可
# 生成网格点的代码比较复杂,这里我们使用另一种方法:直接使用相对坐标,然后乘以输入尺寸得到绝对坐标
# 将边界框的cx,cy从相对于网格的偏移量转换为相对于输入图像的比例
# 注意:在YOLOv8中,边界框的cx,cy是相对于网格左上角的偏移,并且已经过了sigmoid,范围在0~1
# 而w,h是相对于锚框的缩放,也是经过sigmoid的。但是,在导出ONNX时,模型已经将这三个检测头的输出合并,并且每个框的cx,cy已经是相对于整个输入图像的比例(即除以了对应的网格大小)
# 所以,我们可以直接将cx,cy乘以输入图像的宽高得到绝对坐标,同样w,h乘以输入图像的宽高得到宽高的绝对长度。
# 但是,由于我们不知道每个框对应的网格大小,我们可以尝试另一种方法:直接使用cx,cy和w,h作为比例值,乘以输入图像的尺寸。
# 实际上,在YOLOv8的原始代码中,边界框的解码是通过以下公式:
# x = (cx * 2 - 0.5 + grid_x) * stride
# y = (cy * 2 - 0.5 + grid_y) * stride
# w = (w * 2) ** 2 * anchor_w
# h = (h * 2) ** 2 * anchor_h
# 但是,在导出ONNX时,这个解码过程已经被包含在模型中了,所以模型直接输出的是相对于输入图像的比例坐标。
# 因此,我们可以直接使用边界框坐标乘以输入图像的尺寸来得到绝对坐标。
# 然而,根据我的经验,YOLOv8的ONNX模型输出已经是调整后的值,所以我们直接使用即可。
# 但为了确保正确,我们可以查看一下输出的边界框坐标是否在0~1之间(如果是,则可以直接乘以输入尺寸)。
# 这里我们假设模型输出已经是归一化的坐标,直接乘以输入尺寸
boxes[:, 0] *= input_shape[1] # cx * width
boxes[:, 1] *= input_shape[0] # cy * height
boxes[:, 2] *= input_shape[1] # w * width
boxes[:, 3] *= input_shape[0] # h * height
# 将(cx, cy, w, h)转换为(x1, y1, x2, y2)
boxes[:, 0] -= boxes[:, 2] / 2 # x1 = cx - w/2
boxes[:, 1] -= boxes[:, 3] / 2 # y1 = cy - h/2
boxes[:, 2] += boxes[:, 0] # x2 = x1 + w
boxes[:, 3] += boxes[:, 1] # y2 = y1 + h
return boxes, scores
boxes, scores = decode_boxes(predictions, input_shape=(640, 640))
Step 4:非极大值抑制(NMS)过滤冗余框
def nms(boxes, scores, iou_threshold=0.45, score_threshold=0.25):
"""
非极大值抑制
boxes: [N, 4] 的数组,表示边界框 (x1, y1, x2, y2)
scores: [N, 80] 的数组,表示每个边界框的类别概率
iou_threshold: IoU阈值
score_threshold: 分数阈值
"""
# 首先根据分数阈值过滤掉分数低的框
keep = np.max(scores, axis=1) > score_threshold
boxes = boxes[keep]
scores = scores[keep]
# 对每个类别单独进行NMS
n_classes = scores.shape[1]
indices = []
for cls in range(n_classes):
cls_scores = scores[:, cls]
cls_boxes = boxes
# 根据该类别的分数排序
order = cls_scores.argsort()[::-1]
cls_boxes = cls_boxes[order]
cls_scores = cls_scores[order]
while len(cls_boxes) > 0:
# 选取分数最高的框
indices.append(order[0])
if len(cls_boxes) == 1:
break
# 计算当前框与其余框的IoU
ious = compute_iou(cls_boxes[0:1], cls_boxes[1:])
# 保留IoU小于阈值的框
keep = ious < iou_threshold
cls_boxes = cls_boxes[1:][keep]
cls_scores = cls_scores[1:][keep]
order = order[1:][keep]
return np.array(indices)
def compute_iou(box, boxes):
"""
计算一个框与一组框的IoU
box: [1, 4] (x1, y1, x2, y2)
boxes: [N, 4] (x1, y1, x2, y2)
"""
# 计算交集
inter_x1 = np.maximum(box[0, 0], boxes[:, 0])
inter_y1 = np.maximum(box[0, 1], boxes[:, 1])
inter_x2 = np.minimum(box[0, 2], boxes[:, 2])
inter_y2 = np.minimum(box[0, 3], boxes[:, 3])
inter_w = np.maximum(0, inter_x2 - inter_x1)
inter_h = np.maximum(0, inter_y2 - inter_y1)
inter_area = inter_w * inter_h
# 计算并集
area_box = (box[0, 2] - box[0, 0]) * (box[0, 3] - box[0, 1])
area_boxes = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1])
union_area = area_box + area_boxes - inter_area
return inter_area / union_area
# 执行NMS
indices = nms(boxes, scores, iou_threshold=0.45, score_threshold=0.25)
boxes = boxes[indices]
scores = scores[indices]
Step 5:将边界框坐标映射回原图
由于预处理时使用了Letterbox,我们需要将边界框坐标从预处理后的图像尺寸映射回原图尺寸。
def scale_boxes(boxes, orig_shape, new_shape, padding):
"""
将边界框从预处理后的图像尺寸映射回原图尺寸
boxes: [N, 4] 的边界框 (x1, y1, x2, y2),对应预处理后的图像
orig_shape: 原图尺寸 (height, width)
new_shape: 预处理后的图像尺寸 (height, width)
padding: Letterbox填充的边界 (left, top, right, bottom)
"""
left, top, right, bottom = padding
# 计算缩放比例
r = min(new_shape[0] / orig_shape[0], new_shape[1] / orig_shape[1])
new_unpad = (int(round(orig_shape[1] * r)), int(round(orig_shape[0] * r)))
# 去除填充
boxes[:, 0] -= left
boxes[:, 1] -= top
boxes[:, 2] -= left
boxes[:, 3] -= top
# 缩放回原图尺寸
boxes[:, 0] /= r
boxes[:, 1] /= r
boxes[:, 2] /= r
boxes[:, 3] /= r
# 确保边界框在原图范围内
boxes[:, 0] = np.clip(boxes[:, 0], 0, orig_shape[1])
boxes[:, 1] = np.clip(boxes[:, 1], 0, orig_shape[0])
boxes[:, 2] = np.clip(boxes[:, 2], 0, orig_shape[1])
boxes[:, 3] = np.clip(boxes[:, 3], 0, orig_shape[0])
return boxes
# 使用Step1中保存的padding和原图尺寸
orig_shape = img0.shape[:2] # (height, width)
new_shape = (640, 640)
padding = (left, top, right, bottom) # 来自Step1的letterbox函数
boxes = scale_boxes(boxes, orig_shape, new_shape, padding)
Step 6:可视化结果
最后,我们可以将检测结果可视化,查看是否正确。
def draw_boxes(image, boxes, scores, class_names, score_threshold=0.5):
"""
在图像上绘制边界框和类别标签
image: 原图
boxes: [N, 4] 的边界框 (x1, y1, x2, y2)
scores: [N, 80] 的类别概率
class_names: 类别名称列表
score_threshold: 分数阈值
"""
for i in range(len(boxes)):
# 获取当前框的类别分数和类别索引
class_score = np.max(scores[i])
class_index = np.argmax(scores[i])
if class_score < score_threshold:
continue
# 获取边界框坐标
x1, y1, x2, y2 = boxes[i].astype(int)
# 绘制边界框
cv2.rectangle(image, (x1, y1), (x2, y2), (0, 255, 0), 2)
# 绘制类别标签
label = f"{class_names[class_index]}: {class_score:.2f}"
cv2.putText(image, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
return image
# 假设有类别名称列表(COCO数据集80类)
class_names = [...] # 你的类别名称列表
# 绘制边界框
result_img = draw_boxes(img0, boxes, scores, class_names, score_threshold=0.5)
# 保存或显示图像
cv2.imwrite('result.jpg', result_img)
常见问题与解决方案
1. 预处理不一致
确保预处理与训练时完全相同,包括:
- 图像resize方式(Letterbox)
- 归一化(除以255)
- 通道顺序(RGB)
2. 输出解析错误
使用Netron等工具可视化ONNX模型,确认输出层的形状和顺序。YOLOv8的输出形状通常是[1,84,8400],但不同版本可能有所不同。
3. 后处理错误
确保NMS的阈值与训练时一致,并且边界框的解码方式正确。如果使用自定义模型,需要根据训练时的锚框和步长进行调整。
4. ONNX Runtime版本不兼容
确保ONNX Runtime版本与导出模型时使用的Opset版本兼容。建议使用较新的版本,如1.17.0以上。
5. 模型导出问题
使用Ultralytics官方提供的导出方法,并确保参数正确。例如,使用simplify=True可以简化模型,但有时可能会导致问题,可以尝试关闭简化。
总结
YOLOv8转ONNX后推理全零的问题通常由预处理、输出解析、后处理、版本兼容性或导出参数导致。通过以上5个步骤的排查和修复,你应该能够解决大部分问题。如果问题依旧,建议使用官方提供的导出和推理脚本进行对比,逐步定位问题所在。
希望这份指南能帮助你顺利部署YOLOv8模型!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)