在我的文章YOLOv8 实战指南(二):模型导出与结构验证(PT → ONNX → RKNN)_onnx 转 rknn-CSDN博客里我强调过:导出 ONNX 只是“格式转换”,并不能保证推理结果一定正确。尤其是我们在做 PT → ONNX → RKNN 的链路时,一旦 ONNX 本身结构或数值出现偏差,后续 RKNN 转换、端侧部署会把问题放大,最后你会陷入“到底是 RKNN、后处理还是模型的问题”的无效排查。
因此这篇文章只做一件事:用代码把 ONNX 验证清楚。验证分两类:
第一类是“原始 ONNX”(Ultralytics/官方导出,结构通常已经拼接并做过解码); 第二类是“修改过的 ONNX”(为了适配 RKNN,把输出拆分、保留多尺度)。
两类 ONNX 的输出形态完全不同,所以验证方式也要对应变化。下面按这两种模型分别展开。
同时编写代码,直接用导出的onnx模型去测试。
1 原始onnx模型
1.1 查看模型
原始 ONNX 指你直接从 YOLOv8 导出的 ONNX(例如 yolo export format=onnx 或者 Python API 导出)。这类模型的典型输出是“单输出张量”,常见形状类似:
-
输入:[1, 3, 640, 640]
-
输出:[1, 4 + nc, 8400]
使用Netron查看导出onnx模型的输入输出:

其中 8400 = 80×80 + 40×40 + 20×20,对应三层特征图的预测点拼接结果;nc 是类别数。也就是说:原始 ONNX 通常已经完成了多尺度拼接,并且很多导出版本里回归部分已经是“可直接使用”的形式(不再能看到 reg(64) / obj(1) / cls(nc) 这类拆分输出)。
1.2 模型验证
验证代码如下:
import cv2
import numpy as np
import onnxruntime as ort
IMG_SIZE = (640, 640)
OBJ_THRESH = 0.25
NMS_THRESH = 0.45
CLASSES = ["AVS", "handle"]
# ————————————————–
# letterbox
# ————————————————–
def letterbox(im, new_shape=(640, 640), color=(114, 114, 114)):
h, w = im.shape[:2]
new_w, new_h = new_shape
r = min(new_w / w, new_h / h)
nw, nh = int(round(w * r)), int(round(h * r))
pad_w, pad_h = new_w – nw, new_h – nh
pad_w /= 2
pad_h /= 2
im_resized = cv2.resize(im, (nw, nh), interpolation=cv2.INTER_LINEAR)
top, bottom = int(round(pad_h – 0.1)), int(round(pad_h + 0.1))
left, right = int(round(pad_w – 0.1)), int(round(pad_w + 0.1))
im_padded = cv2.copyMakeBorder(im_resized, top, bottom, left, right,
cv2.BORDER_CONSTANT, value=color)
return im_padded, r, (left, top)
def scale_coords(img_shape, coords, ratio_pad):
gain = ratio_pad[0]
pad = ratio_pad[1]
coords[:, [0, 2]] -= pad[0]
coords[:, [1, 3]] -= pad[1]
coords[:, :4] /= gain
coords[:, 0::2] = np.clip(coords[:, 0::2], 0, img_shape[1])
coords[:, 1::2] = np.clip(coords[:, 1::2], 0, img_shape[0])
return coords
# ————————————————–
# NMS
# ————————————————–
def nms_boxes(boxes, scores):
x1 = boxes[:, 0]
y1 = boxes[:, 1]
x2 = boxes[:, 2]
y2 = boxes[:, 3]
areas = (x2 – x1) * (y2 – y1)
order = scores.argsort()[::-1]
keep = []
while order.size > 0:
i = order[0]
keep.append(i)
xx1 = np.maximum(x1[i], x1[order[1:]])
yy1 = np.maximum(y1[i], y1[order[1:]])
xx2 = np.minimum(x2[i], x2[order[1:]])
yy2 = np.minimum(y2[i], y2[order[1:]])
w = np.maximum(0.0, xx2 – xx1)
h = np.maximum(0.0, yy2 – yy1)
inter = w * h
ovr = inter / (areas[i] + areas[order[1:]] – inter + 1e-9)
inds = np.where(ovr <= NMS_THRESH)[0]
order = order[inds + 1]
return np.array(keep)
# ————————————————–
# 主流程
# ————————————————–
def main():
model_path = r"D:\\Pycharm_project\\Yolov8\\runs\\text\\weights\\best.onnx"
image_path = r"D:\\Pycharm_project\\Yolov8\\57054985.png"
sess = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
inp_name = sess.get_inputs()[0].name
img0 = cv2.imread(image_path)
if img0 is None:
raise FileNotFoundError(image_path)
img, gain, pad = letterbox(img0, new_shape=IMG_SIZE)
ratio_pad = (gain, pad)
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
x = (img_rgb.astype(np.float32) / 255.0).transpose(2, 0, 1)[None]
outputs = sess.run(None, {inp_name: x})
pred = outputs[0] # [1,6,8400]
pred = np.squeeze(pred, axis=0).T # [8400,6]
boxes = pred[:, :4]
scores = pred[:, 4:]
class_ids = np.argmax(scores, axis=1)
class_scores = np.max(scores, axis=1)
mask = class_scores > OBJ_THRESH
boxes = boxes[mask]
class_ids = class_ids[mask]
class_scores = class_scores[mask]
# xywh -> xyxy
boxes_xyxy = np.zeros_like(boxes)
boxes_xyxy[:, 0] = boxes[:, 0] – boxes[:, 2] / 2
boxes_xyxy[:, 1] = boxes[:, 1] – boxes[:, 3] / 2
boxes_xyxy[:, 2] = boxes[:, 0] + boxes[:, 2] / 2
boxes_xyxy[:, 3] = boxes[:, 1] + boxes[:, 3] / 2
# 映射回原图
boxes_xyxy = scale_coords(img0.shape[:2], boxes_xyxy, ratio_pad)
final_boxes = []
final_scores = []
final_classes = []
for c in np.unique(class_ids):
inds = np.where(class_ids == c)[0]
b = boxes_xyxy[inds]
s = class_scores[inds]
keep = nms_boxes(b, s)
final_boxes.append(b[keep])
final_scores.append(s[keep])
final_classes.append(np.full(len(keep), c))
if final_boxes:
final_boxes = np.concatenate(final_boxes)
final_scores = np.concatenate(final_scores)
final_classes = np.concatenate(final_classes)
for box, score, cl in zip(final_boxes, final_scores, final_classes):
x1, y1, x2, y2 = box.astype(int)
cv2.rectangle(img0, (x1, y1), (x2, y2), (0, 255, 0), 2)
text = f"{CLASSES[int(cl)]} {score:.2f}"
cv2.putText(img0, text, (x1, y1 – 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
cv2.imwrite("infer-det.jpg", img0)
cv2.imshow("Result", img0)
cv2.waitKey(0)
cv2.destroyAllWindows()
print("Saved: infer-det.jpg")
if __name__ == "__main__":
main()
1.3 代码架构解析
从工程结构看,分为五段式推理架构:
预处理层(Preprocess)
推理层(Inference)
输出解析层(Decode)
后处理层(Filter + NMS)
反变换与可视化层(Scale back + Draw)
完整数据流路径
图像从输入到最终结果,实际经历以下流程:
原始图像
↓
letterbox 等比例缩放 + padding
↓
BGR → RGB
↓
归一化 /255
↓
HWC → CHW
↓
加 batch 维度
↓
ONNX 推理
↓
输出 [1,6,8400]
↓
reshape → [8400,6]
↓
拆分 box 和 cls
↓
阈值过滤
↓
xywh → xyxy
↓
映射回原图尺寸
↓
按类别做 NMS
↓
画框
↓
输出最终图像
1.4 逐模块代码逻辑解释
1.4.1 预处理层
核心函数:
letterbox()
作用:
-
等比例缩放
-
padding 到 640×640
-
记录:
-
gain(缩放比例)
-
pad(左右上下填充)
-
这一步的本质是:
保持长宽比不变,同时满足模型输入尺寸
随后:
BGR → RGB
/255
HWC → CHW
加 batch 维度
最终输入模型的数据格式为:
[1,3,640,640]
1.4.2 推理层
sess.run(…)
模型输出:
[1,6,8400]
这里:
6 = 4 + 2类 8400 = 三个尺度拼接
说明:
-
已经做了 DFL
-
已经做了 grid
-
已经做了多尺度 concat
得到的是“最终检测张量”(上述三点详细解释放在附录)。
1.4.3 输出解析层
这一段非常关键:
pred = np.squeeze(pred, axis=0).T
变为:
[8400,6]
每一行表示一个预测点:
[x, y, w, h, cls0, cls1]
然后:
boxes = pred[:, :4]
scores = pred[:, 4:]
这里逻辑是:
-
前4列是 bbox(xywh)
-
后2列是类别得分
1.4.4 置信度筛选
class_ids = argmax(scores)
class_scores = max(scores)
得到:
-
每个预测点的类别
-
该类别的置信度
然后:
mask = class_scores > OBJ_THRESH
去除低置信度预测。
这一步极大减少候选框数量。
1.4.5 坐标格式转换
模型输出是:
xywh(中心点 + 宽高)
而 NMS 需要:
xyxy(左上 + 右下)
所以:
x1 = x – w/2
y1 = y – h/2
x2 = x + w/2
y2 = y + h/2
得到标准框格式。
1.4.6 映射回原图
之前做了:
-
缩放
-
padding
所以现在要反向操作:
减去 pad
除以 gain
这一步是:
坐标从 640×640 输入空间,映射回原始图像尺寸
1.4.7 NMS
按类别分别执行:
IoU 过滤
逻辑:
-
选最大 score
-
抑制 IoU > 阈值的框
-
迭代
这是标准 NMS 实现(上述三点详细解释放在附录)。
1.4.8 可视化输出
最终:
cv2.rectangle
cv2.putText
画框,保存图像。
1.5 代码的架构本质
它属于:
已解码 YOLOv8 单输出 ONNX 推理架构
特点:
-
无多尺度拆分
-
无 DFL 解码
-
无 grid 计算
-
无 stride 计算
-
无 obj 分支
它依赖模型已经把这些算完。
1.6 代码的预测结果

2 修改过的onnx模型
2.1 查看模型
修改过的 ONNX 指为了 RKNN 做结构适配后的版本。常见改法是:把原本的单输出拆回多输出,或保留多尺度分支,把 reg/cls/obj 分离出来,甚至把某些算子替换成 RKNN 更容易支持的形式。
这类 ONNX 的典型输出会变成 9 个张量(3 个尺度 × 3 个分支),例如:
-
reg: [1, 64, 80, 80], [1, 64, 40, 40], [1, 64, 20, 20]
-
cls: [1, nc, 80, 80], [1, nc, 40, 40], [1, nc, 20, 20]
-
obj: [1, 1, 80, 80], [1, 1, 40, 40], [1, 1, 20, 20]
模型输出不再是“最终检测结果”,而是“中间头部特征”。因此验证必须同时覆盖结构和后处理一致性。
使用Netron查看导出onnx模型的输入输出:

验证“3 个尺度 + reg/cls/obj 三分支 + 通道数正确”
必须检查:
-
是否确实存在三个空间尺度(80/40/20,或与你输入尺寸对应的尺度)
-
reg 分支通道是否为 4 * (reg_max+1)(常见 64=4×16)
-
cls 分支通道是否等于 nc(与 CLASSES 长度一致)
-
obj 分支通道是否为 1
2.2 模型验证
import cv2
import numpy as np
import onnxruntime as ort
IMG_SIZE = (640, 640) # (w, h)
OBJ_THRESH = 0.25
NMS_THRESH = 0.45
CLASSES = ["AVS", "handle"]
# ————————-
# preprocess
# ————————-
def letterbox(im, new_shape=(640, 640), color=(114, 114, 114)):
h, w = im.shape[:2]
new_w, new_h = new_shape
r = min(new_w / w, new_h / h)
nw, nh = int(round(w * r)), int(round(h * r))
pad_w, pad_h = new_w – nw, new_h – nh
pad_w /= 2
pad_h /= 2
im_resized = cv2.resize(im, (nw, nh), interpolation=cv2.INTER_LINEAR)
top, bottom = int(round(pad_h – 0.1)), int(round(pad_h + 0.1))
left, right = int(round(pad_w – 0.1)), int(round(pad_w + 0.1))
im_padded = cv2.copyMakeBorder(im_resized, top, bottom, left, right,
cv2.BORDER_CONSTANT, value=color)
return im_padded, r, (left, top)
def scale_coords(img_shape, coords, input_shape, ratio_pad):
# coords: (N,4) xyxy in input space
gain = ratio_pad[0]
pad = ratio_pad[1]
coords[:, [0, 2]] -= pad[0] # x pad
coords[:, [1, 3]] -= pad[1] # y pad
coords[:, :4] /= gain
coords[:, 0::2] = np.clip(coords[:, 0::2], 0, img_shape[1])
coords[:, 1::2] = np.clip(coords[:, 1::2], 0, img_shape[0])
return coords
# ————————-
# postprocess
# ————————-
def filter_boxes(boxes, box_confidences, box_class_probs):
box_confidences = box_confidences.reshape(-1)
class_max_score = np.max(box_class_probs, axis=-1)
classes = np.argmax(box_class_probs, axis=-1)
_pos = np.where(class_max_score * box_confidences >= OBJ_THRESH)
scores = (class_max_score * box_confidences)[_pos]
boxes = boxes[_pos]
classes = classes[_pos]
return boxes, classes, scores
def nms_boxes(boxes, scores):
x1 = boxes[:, 0]
y1 = boxes[:, 1]
x2 = boxes[:, 2]
y2 = boxes[:, 3]
w = x2 – x1
h = y2 – y1
areas = w * h
order = scores.argsort()[::-1]
keep = []
while order.size > 0:
i = order[0]
keep.append(i)
xx1 = np.maximum(x1[i], x1[order[1:]])
yy1 = np.maximum(y1[i], y1[order[1:]])
xx2 = np.minimum(x2[i], x2[order[1:]])
yy2 = np.minimum(y2[i], y2[order[1:]])
iw = np.maximum(0.0, xx2 – xx1 + 1e-5)
ih = np.maximum(0.0, yy2 – yy1 + 1e-5)
inter = iw * ih
ovr = inter / (areas[i] + areas[order[1:]] – inter + 1e-9)
inds = np.where(ovr <= NMS_THRESH)[0]
order = order[inds + 1]
return np.array(keep, dtype=np.int32)
def dfl(position):
# position: [1, 64, H, W]
x = position
n, c, h, w = x.shape
p_num = 4
mc = c // p_num # 16
y = x.reshape(n, p_num, mc, h, w)
# softmax on mc
y = y – np.max(y, axis=2, keepdims=True)
y = np.exp(y)
y = y / np.sum(y, axis=2, keepdims=True)
acc = np.arange(mc, dtype=np.float32).reshape(1, 1, mc, 1, 1)
y = (y * acc).sum(2) # [1,4,H,W]
return y
def box_process(position):
# position: [1,64,H,W]
grid_h, grid_w = position.shape[2:4]
col, row = np.meshgrid(np.arange(0, grid_w), np.arange(0, grid_h))
col = col.reshape(1, 1, grid_h, grid_w)
row = row.reshape(1, 1, grid_h, grid_w)
grid = np.concatenate((col, row), axis=1) # [1,2,H,W]
stride = np.array([IMG_SIZE[1] // grid_h, IMG_SIZE[0] // grid_w], dtype=np.float32).reshape(1, 2, 1, 1)
position = dfl(position) # [1,4,H,W]
box_xy = grid + 0.5 – position[:, 0:2, :, :]
box_xy2 = grid + 0.5 + position[:, 2:4, :, :]
xyxy = np.concatenate((box_xy * stride, box_xy2 * stride), axis=1) # [1,4,H,W]
return xyxy
def sp_flatten(_in):
ch = _in.shape[1]
_in = _in.transpose(0, 2, 3, 1) # [1,H,W,C]
return _in.reshape(-1, ch)
def post_process_rkstyle(output_list):
# 只取 4D 输出,且按空间尺寸分组
outs = [o for o in output_list if isinstance(o, np.ndarray) and o.ndim == 4]
groups = {}
for o in outs:
_, c, h, w = o.shape
groups.setdefault((h, w), []).append(o)
# 期待 3 个尺度
# 每个尺度里:找 C=64 作为 reg,找 C=2 作为 cls,其他忽略
boxes_all = []
cls_all = []
score_all = []
for (h, w), arrs in groups.items():
reg = None
cls = None
for a in arrs:
if a.shape[1] == 64:
reg = a
elif a.shape[1] == len(CLASSES):
cls = a
if reg is None or cls is None:
continue
b = box_process(reg) # [1,4,h,w]
cprob = cls # [1,2,h,w] 注意:不做 sigmoid
s = np.ones_like(cls[:, :1, :, :], np.float32) # [1,1,h,w]
boxes_all.append(sp_flatten(b)) # [-1,4]
cls_all.append(sp_flatten(cprob)) # [-1,2]
score_all.append(sp_flatten(s)) # [-1,1]
if not boxes_all:
return None, None, None
boxes = np.concatenate(boxes_all, axis=0)
classes_conf = np.concatenate(cls_all, axis=0)
scores = np.concatenate(score_all, axis=0)
# filter
boxes, classes, scores = filter_boxes(boxes, scores, classes_conf)
# nms per class
nboxes, nclasses, nscores = [], [], []
for c in set(classes.tolist()):
inds = np.where(classes == c)
b = boxes[inds]
s = scores[inds]
keep = nms_boxes(b, s)
if keep.size > 0:
nboxes.append(b[keep])
nclasses.append(np.full((keep.size,), c, dtype=np.int32))
nscores.append(s[keep])
if not nboxes:
return None, None, None
boxes = np.concatenate(nboxes, axis=0)
classes = np.concatenate(nclasses, axis=0)
scores = np.concatenate(nscores, axis=0)
return boxes, classes, scores
def draw(image, boxes, scores, classes):
for box, score, cl in zip(boxes, scores, classes):
x1, y1, x2, y2 = [int(v) for v in box]
cv2.rectangle(image, (x1, y1), (x2, y2), (0, 255, 0), 2)
text = f"{CLASSES[int(cl)]} {float(score):.2f}"
(tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2)
cv2.rectangle(image, (x1, y1 – th – 6), (x1 + tw + 6, y1), (0, 255, 0), -1)
cv2.putText(image, text, (x1 + 3, y1 – 4), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 2)
# ————————-
# main
# ————————-
def main():
model_path = "best.onnx"
image_path = r"D:\\Pycharm_project\\Yolov8\\1751872429.png"
sess = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
inp_name = sess.get_inputs()[0].name
img0 = cv2.imread(image_path)
if img0 is None:
raise FileNotFoundError(image_path)
# letterbox
img, gain, pad = letterbox(img0, new_shape=IMG_SIZE, color=(114,114,114))
ratio_pad = (gain, pad)
# RGB + normalize + NCHW
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
x = (img_rgb.astype(np.float32) / 255.0).transpose(2, 0, 1)[None, …]
# inference
outputs = sess.run(None, {inp_name: x})
# postprocess (RK style)
boxes, classes, scores = post_process_rkstyle(outputs)
vis = img0.copy()
if boxes is not None:
# map back to original image
boxes = scale_coords(img0.shape[:2], boxes.copy(), (IMG_SIZE[1], IMG_SIZE[0]), ratio_pad)
draw(vis, boxes, scores, classes)
cv2.imwrite("infer-det1.jpg", vis)
cv2.imshow("Result", vis)
cv2.waitKey(0)
cv2.destroyAllWindows()
print("Saved: infer-det1.jpg")
if __name__ == "__main__":
main()
2.3 代码结构解析
这段代码属于:
多输出 YOLOv8 ONNX(未解码版本)的完整后处理实现
它假设模型输出的是:
3 个尺度 × 3 个分支
reg(64)
cls(nc)
obj(1)
而不是单输出 [1,6,8400]。
整体数据流结构
原始图像
↓
letterbox
↓
归一化 + NCHW
↓
ONNX 推理
↓
得到 9 个输出张量
↓
按 (H,W) 分组
↓
每个尺度:
reg(64) → DFL → 4通道
+ grid + stride → xyxy
cls(nc)
obj(1)
↓
flatten
↓
拼接三个尺度
↓
阈值过滤
↓
NMS
↓
映射回原图
↓
绘制结果
2.4 核心模块逻辑解析
2.4.1 预处理(和原始onnx模型处理方式一致)
-
letterbox
-
RGB
-
/255
-
HWC → CHW
-
加 batch
得到:
[1,3,640,640]
2.4.2 输出结构整理
ONNX 输出不是一个张量,而是多个:
典型为:
[1,64,80,80]
[1,nc,80,80]
[1,1,80,80]
[1,64,40,40]
[1,nc,40,40]
[1,1,40,40]
[1,64,20,20]
[1,nc,20,20]
[1,1,20,20]
代码做了两步关键操作:
第一步:筛选 4D 张量
outs = [o for o in output_list if o.ndim == 4]
作用是:
只保留形状为 [N,C,H,W] 的特征图输出。
为什么这么做?
因为修改过的 ONNX 有可能包含:
-
reshape 之后的 2D 张量
-
常量节点
-
其他辅助输出
-
debug 输出
这一步是:
保守过滤,只处理“特征图形式”的张量。
如果确定一定是 9 个 4D 输出,这一步可以省略。 但写成这样是为了“结构健壮”。
第二步:按空间尺寸分组
groups[(h,w)] = […]
YOLOv8 有 3 个尺度:
80×80
40×40
20×20
每个尺度会输出 3 个分支:
reg
cls
obj
也就是说,输出顺序可能是:
[1,64,80,80] [1,2,80,80] [1,1,80,80]
[1,64,40,40] [1,2,40,40] [1,1,40,40]
[1,64,20,20] [1,2,20,20] [1,1,20,20]
但是:
ONNX 不保证输出顺序一定是按尺度排好。
所以你不能写死:
outputs[0] 是 80
outputs[1] 是 cls …
因此代码做了:
groups[(h,w)] = […]
也就是说:
把空间尺寸一样的放到同一个组里。
结果:
(80,80) → [reg, cls, obj]
(40,40) → [reg, cls, obj]
(20,20) → [reg, cls, obj]
这样就自动识别了三个尺度。
2.4.3 reg(64) → DFL 解码
这一段是整套逻辑的核心。
输入:
[1,64,H,W]
处理步骤:
(1) reshape
[1,64,H,W]
→
[1,4,16,H,W]
解释:
-
4 个边
-
每个边 16 个离散分布桶
(2) softmax
对 16 维做 softmax。
(3) 加权求期望

得到:
[1,4,H,W]
这就是每个特征点预测的:
left, top, right, bottom
(4)张量维度变化总结
原始:
[1,64,H,W]
reshape:
[1,4,16,H,W]
softmax:
[1,4,16,H,W]
乘acc:
[1,4,16,H,W]
sum(2):
[1,4,H,W]
2.4.4 grid + stride 恢复真实坐标
这一段是“从特征图坐标恢复到图像坐标”的关键。
grid 构造
col,row = meshgrid(…)
得到:
每个特征点的整数坐标
stride 计算
stride = 640 / H
例如:
80 → stride=8
真实框计算
得到:
[1,4,H,W]
这一步完成:
特征图坐标 → 原图坐标
2.4.5 flatten
[1,4,H,W]
→
[H×W,4]
三个尺度分别 flatten。
2.4.6 拼接三个尺度
80×80
40×40
20×20
总计:
8400 个候选框
此时已经等价于单输出版本的 8400 结构。
2.4.7 置信度计算
当前逻辑:
score = class_score × 1
忽略 obj。
这在某些 RKNN 版本中是合理的(obj 已融合)。
2.4.8 阈值过滤
class_max_score >= OBJ_THRESH
去掉低置信度框。
2.4.9 按类别做 NMS
每个类别单独做:
-
选最大
-
IoU > 阈值的删除
-
迭代
保留最终框。
2.4.10 映射回原图
减 pad 除 gain
恢复原图尺寸。
2.5 代码的架构本质
它本质是:
手动复刻 YOLOv8 Detect Head 的后半部分计算图
也就是说:
把 ONNX 图里的一部分逻辑移到了 Python 里实现。
这就是:
PT → ONNX → 修改 → RKNN 这条链路中“结构可控”的核心思想。
图像如何变成最终框?
总结:
模型在三个尺度特征图上预测“相对边界距离分布”
DFL 把分布转换为连续距离
grid + stride 把特征图坐标映射到图像坐标
拼接所有候选框
置信度筛选
NMS 去重
反 letterbox
得到最终框
2.6 代码的预测结果

总结
| 输出数量 | 1 | 9 |
| 是否已DFL | 是 | 否 |
| 是否已grid还原 | 是 | 否 |
| 是否已concat | 是 | 否 |
| 后处理复杂度 | 低 | 高 |
| 可控性 | 低 | 高 |
原始 ONNX 模型输出的是已经完成 DFL 解码、grid 与 stride 坐标还原以及多尺度拼接后的最终候选框张量,本质上已经是图像坐标系下的检测结果形式,后处理只需进行阈值筛选与 NMS,结构简单,适合 PC 端快速验证与数值对齐。而修改后的 ONNX 模型则保留了检测头的原始多尺度分支输出(reg/cls/obj),尚未完成 DFL 解码与坐标还原,需要在代码中手动完成分布解码、grid 映射与尺度拼接等步骤,结构更复杂但可控性更强,更适合 RKNN 等端侧部署与结构调试。从本质上看,两者的数学结果是一致的,只是计算过程是在 ONNX 图内部完成,还是在后处理代码中显式实现的区别。
## ****************************************************** ##
有些问题可能存在疑惑,我在这里开个附录,希望对大家有帮助,也便于自己后来翻看。
附录:
问题1: 1.4.2 中为什么 [1, 6, 8400] 可以说明:
-
已经做了 DFL
-
已经做了 grid
-
已经做了多尺度 concat
-
是“最终检测张量”
① 先看 6 = 4 + 2
模型是 2 类:
CLASSES = ["AVS", "handle"]
如果模型仍然保留 DFL 原始回归输出,那么回归分支应该是:
4 × 16 = 64
也就是说你应该看到:
[1, 64, H, W]
但现在没有 64。
说明:
DFL 的 64 通道已经被压缩成 4 个实数。
DFL 的本质是:
64 → reshape → softmax → 加权求和 → 4
现在只看到 4,说明这一步已经在 ONNX 图里完成。
② 为什么说明已经做了 grid?
在多尺度结构中,原始 head 输出的是:
每个特征点预测的是“相对偏移量”
这些偏移量必须结合:
grid 坐标 + stride
才能得到真实图像坐标。
如果没有 grid 计算,你拿到的只是:
特征图上的局部偏移
而现在拿到的 [x,y,w,h]:
-
直接可以画在 640×640 上
-
直接是绝对坐标
说明:
grid + stride 运算已经做过
否则必须手动写:
box_xy = grid + 0.5 – offset
box_xy *= stride
③ 为什么 8400 说明已经 concat?
YOLOv8 有三个尺度:
80×80 = 6400
40×40 = 1600
20×20 = 400
总和:
6400 + 1600 + 400 = 8400
如果模型还保留多尺度输出,你会看到:
[1, 6, 80,80]
[1, 6, 40,40]
[1, 6, 20,20]
但现在看到:
[1, 6, 8400]
说明:
-
三个尺度已经 flatten
-
已经 concat
-
已经合并成单一输出
④ 为什么说是“最终检测张量”?
因为它已经完成:
DFL 解码
grid + stride 还原
多尺度拼接
它现在只是:
8400 个候选框
每个框 4 个坐标 + nc 个类别概率
只需要做:
-
阈值过滤
-
NMS
就可以得到最终结果。
问题2:1.4.7中NMS 是什么?为什么“按类别分别执行”?
现在有:
8400 个候选框
很多框是重复的。
例如:
同一个物体,在 80×80、40×40、20×20 都可能预测到。
如果不处理,你会看到:
-
一个目标周围画十几个框。
NMS 的目标
NMS = Non-Maximum Suppression
核心思想:
同一个目标,只保留置信度最高的那个框。
NMS 的算法逻辑
假设我们只看一个类别:
步骤如下:
第一步:按 score 排序
选置信度最高的框 A
第二步:计算 IoU
把 A 和其他框做 IoU 计算:
IoU = 交集面积 / 并集面积
如果:
IoU > 阈值(例如 0.45)
说明:
这个框和 A 表示的是同一个物体。
于是删除它。
第三步:迭代
从剩下的框中:
-
再选 score 最大的
-
再做 IoU 抑制
直到没有框。
为什么“按类别分别做”?
因为:
不同类别之间不应该互相抑制。
例如:
一个人手里拿着一个 handle。
两个框:
-
人
-
handle
它们 IoU 可能很大。
如果你不分类别做 NMS:
其中一个可能被错误删除。
所以流程是:
for 每个类别: 单独做 NMS
更直观的理解
NMS 的本质是:
用“几何重叠程度”去判断“是不是同一个物体”。
如果两个框:
-
IoU 很高
-
类别相同
那几乎可以确定是重复预测。
问题3:2.4.4 中坐标的计算
(left, top, right, bottom)(即l, t, r, b) 到底是相对于谁?
它们是:
相对于当前特征图 grid “中心点”的距离。
不是相对于图像左上角。 不是相对于框中心。 而是相对于“当前负责预测的那个 grid 单元”。
先明确:grid 点是什么?
假设输入 640×640。
某个尺度是 80×80。
stride = 640 / 80 = 8。
特征图上的一个点 (i, j):
它在原图中对应一个 8×8 的区域。
如果你只用 (i, j)×stride:
(j*8, i*8)
得到的是这个 8×8 区域的左上角。
但 YOLO 认为:
每个特征点代表这个区域的“中心”。
所以真正的中心坐标是:

为什么必须是“中心”,而不是左上角?
想象一个 8×8 的格子,如果用左上角作为参考点:
-
框会偏移
-
对称性不好
-
小目标回归不稳定
如果用中心:
-
框围绕中心展开
-
回归更自然
-
梯度更稳定
这就是 +0.5 的原因。
现在解释 (l, t, r, b)
假设 grid 中心在:

reg 预测:
-
l = 到左边界的距离
-
t = 到上边界的距离
-
r = 到右边界的距离
-
b = 到下边界的距离
注意:
这些是“距离”,不是坐标。
它们如何变成两个点?
目标框左上角是:

目标框右下角是:

总结一遍对应关系
| grid | 特征图坐标 |
| +0.5 | 格子中心 |
| stride | 放大到原图 |
| l,t,r,b | 中心到边界的距离 |
| x1,y1 | 左上角 |
| x2,y2 | 右下角 |
问题4: 80×80、40×40、20×20 是什么?
它们是:
三个不同尺度的特征图大小。
假设输入是:
640 × 640
YOLOv8 backbone 会不断下采样:
第一层尺度(P3)
下采样 8 倍:
640/8=80
得到:
80 × 80
stride = 8
第二层尺度(P4)
下采样 16 倍:
640/16=40
得到:
40 × 40
stride = 16
第三层尺度(P5)
下采样 32 倍:
640/32=20
得到:
20 × 20
stride = 32
为什么要三个尺度?
因为不同大小的目标适合不同分辨率的特征图。
-
小目标 → 80×80(高分辨率)
-
中目标 → 40×40
-
大目标 → 20×20(低分辨率)
这就是多尺度检测。
每个尺度在干什么?
以 80×80 为例:
-
有 80×80 = 6400 个 grid 点
-
每个 grid 点都会预测一个候选框
同理:
-
40×40 → 1600 个候选框
-
20×20 → 400 个候选框
总计:
6400+1600+400=8400
重要:80×80 不是框的大小
很多人误解这里。
80×80 只是:
特征图的分辨率
它表示:
有 80×80 个“预测中心点”。
并不代表:
预测的框是 80×80 或正方形。
框的大小由:
l
r
t
b
决定,完全可以是任意长宽比。
80×80、40×40、20×20 表示:
三个不同分辨率的特征图,用来检测不同大小的目标。
它们是“预测点的数量”, 不是“框的形状”。
欢迎大家关注我,我后续将在专栏计算机视觉_再一次等风来的博客-CSDN博客更新更多的内容。




