欢迎光临
我们一直在努力

YOLOv8 实战指南(四):两种ONNX模型验证

在我的文章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) 加权求期望

    distance = \\sum p_i \\cdot i

    得到:

    [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

    真实框计算

    x1 = (grid_x + 0.5 - left) \\times stride \\\\ y1 = (grid_y + 0.5 - top) \\times stride \\\\ x2 = (grid_x + 0.5 + right) \\times stride \\\\ y2 = (grid_y + 0.5 + bottom) \\times stride

    得到:

    [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 代码的预测结果

    总结

    对比项原始 ONNX修改后 ONNX
    输出数量 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 认为:

    每个特征点代表这个区域的“中心”。

    所以真正的中心坐标是:

    x_c = (j + 0.5) \\times stride \\\\ y_c =(i+0.5) \\times stride

    为什么必须是“中心”,而不是左上角?

    想象一个 8×8 的格子,如果用左上角作为参考点:

    • 框会偏移

    • 对称性不好

    • 小目标回归不稳定

    如果用中心:

    • 框围绕中心展开

    • 回归更自然

    • 梯度更稳定

    这就是 +0.5 的原因。

    现在解释 (l, t, r, b)

    假设 grid 中心在:

    (x_c, y_c)

    reg 预测:

    • l = 到左边界的距离

    • t = 到上边界的距离

    • r = 到右边界的距离

    • b = 到下边界的距离

    注意:

    这些是“距离”,不是坐标。

    它们如何变成两个点?

    目标框左上角是:

    x_1 = x_c - l \\times stride \\\\ y_1 = y_c - t \\times stride

    目标框右下角是:

    x_2 = x_c + r \\times stride \\\\ y_2 = y_c + b \\times stride

    总结一遍对应关系

    量含义
    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博客更新更多的内容。

    赞(0)
    未经允许不得转载:171主机测评 » YOLOv8 实战指南(四):两种ONNX模型验证
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址