☰
YOLOv8seg转ONNX:导出流程、输出解析与后处理实战指南
2026/10/18 3:49:46 网站建设 项目流程

1. yolov8seg 转 ONNX:先把部署链路想清楚

做图像分割部署,第一个绕不开的坎就是模型格式。最近我在做一个人形轮廓分割的小项目,训练阶段用的 YOLOv8seg,模型精度满意之后要放到一台只有 CPU 的机器上做打分和裁剪,自然走到了“yolov8seg 转 onnx推理”这一步。整个过程不算复杂,但 ONNX 里输出通道的含义、坐标还原、mask 组合这些细节,如果没搞清楚,很容易被几个形状奇怪的输出张量劝退。

这篇文章会从导出、输出结构、后处理、问题排查几个角度完整过一遍。适合正在做 YOLOv8 实例分割部署的同学,也适合想把手头 .pt 模型塞进 C++、C#、边缘盒子的朋友。读完你至少能回答这几个问题:为什么导出出来有两个输出、116 是怎么来的、protos 和 mask 系数到底怎么用、onnxruntime 推理时后处理代码怎么写。

1.1 为什么非要转 ONNX,而不是直接用 PyTorch 推理

PyTorch 训练很好用,但部署不是它的主场。训练机上装着一整套 CUDA、cuDNN、torch 全家桶,生产环境往往没有这些,尤其是轻量级 CPU 服务和边缘设备,装一个 PyTorch 无论体积还是内存都不划算。

ONNX 本质上是一个中间表示格式,它把 PyTorch 的模型图、权重、算子统一描述成一个静态计算图。导出之后,推理不再依赖 PyTorch,只需要一个 runtime 就能跑。onnxruntime 是其中最主流的推理引擎,支持 CPU / GPU,C++、C#、Java、Python 都有接口,很多边缘设备的 SDK 也认 ONNX。也就是说,导出一个 onnx,等于把模型从“训练生态”切换到了“部署生态”。

我个人一直建议先把 ONNX 跑通,再考虑 TensorRT、OpenVINO、CoreML 这些更垂直的加速方案。原因很简单:ONNX 是中间态,如果 ONNX 都解析不对,后面任何加速后端都可能继承同样的坑。

1.2 Segment 模型输出和普通检测模型的差异

YOLOv8seg 比起普通 YOLOv8 检测模型,最大的变化在检测头后面多了一个分割分支。普通检测模型输出通常是1 x (4 + nc) x 8400,对应每个候选框的坐标加类别概率。而分割模型输出的是两个张量:

  • output0:形状[1, 4 + nc + 32, 8400]
  • output1:形状[1, 32, 160, 160]

其中8400是 YOLO 三尺度特征图在 640x640 输入下预测的候选位置数量,具体是80x80 + 40x40 + 20x20,分别对应 stride 8、16、32。nc是类别数,如果你用的是 COCO 预训练权重,nc=80,那4 + 80 + 32 = 116,这就是你经常看到1x116x8400的原因。如果是单类别模型,则是1x37x8400。

output1的32指 mask 原型通道数,160x160是 mask 分支在输出特征图上的分辨率,也就是输入尺寸 640 除以 stride 4 得到的结果。实例分割的最终掩码,不是网络直接输出的二值图,而是由每个检测框对应的 32 个 mask 系数和这个 160x160 原型图线性组合出来的。

记住一个关键点:只要看到 ONNX 输出是[1, 116, 8400]这种带8400的,就要明白这是 nms 之前的一整份候选预测,不是最终目标。真正要用的目标,需要自己做阈值过滤和 NMS。

2. 导出实操:从 .pt 到 .onnx 的命令与参数

2.1 先装环境,再做首次导出

导出工具链很直接,核心就是 yolov8 官方那条 export 链路:

pip install ultralytics onnx onnxruntime

如果你的训练环境里已经装了 ultralytics,一般不需要重装。第一次导出时建议用 Python 接口,因为可以同时拿到返回的 onnx 路径,方便下一步检查:

from ultralytics import YOLO model = YOLO("runs/segment/train/weights/best.pt") onnx_path = model.export( format="onnx", opset=12, imgsz=640, dynamic=False, simplify=True, ) print(onnx_path)

这里解释几个参数背后的原因。

  • imgsz=640是导出模型的固定输入尺寸。默认是 640x640,如果你的业务图比例比较极端,比如长条形的货架图,也可以传imgsz=(640, 960),但后续所有坐标计算都要对应调整。
  • dynamic=False是我强烈建议第一次先用的配置。动态输入虽然方便跑不同分辨率,但会让输出 shape 变成None,在后处理和调试阶段增加一层不必要的复杂度。先把固定尺寸跑通,再考虑 dynamic。
  • opset=12是兼容性比较好的选择。onnxruntime 从 1.x 开始对 opset 12 支持成熟,很多老旧推理框架也认这个版本。常用的opset=13也能用,但如果你的目标设备 SDK 版本较老,建议先 12。
  • simplify=True会尝试对计算图做常量折叠和结构精简。它需要额外依赖onnxslim,如果环境里没有,导出时会有提示,装一下就行。

导出后的 onnx 文件通常会比原 .pt 小一些,因为只保留了权重和图结构。常见情况是 5~20MB,取决于模型大小。

2.2 导出后先别急着写代码,检查一下 ONNX

不少人的习惯是导完直接拿 onnxruntime 跑,结果报错之后才回头排查,效率很低。我建议导出后先做两件事。

第一,用 onnx 库做完整性检查:

import onnx model_path = "runs/segment/train/weights/best.onnx" onnx_model = onnx.load(model_path) onnx.checker.check_model(onnx_model) print("check pass")

check_model主要检查图结构、节点输入输出、维度信息这些基础问题。如果这里报错,多半是模型导出阶段就出了问题,而不是推理代码的问题。

第二,打印输入输出名字和 shape:

import onnxruntime as ort session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"]) for i, inp in enumerate(session.get_inputs()): print("input", i, inp.name, inp.shape, inp.type) for i, out in enumerate(session.get_outputs()): print("output", i, out.name, out.shape, out.type)

对 yolov8seg 来说,正常输出会类似:

input 0 images ['batch_size', 3, 640, 640] float32 output 0 output0 ['batch_size', 116, 8400] float32 output 1 output1 ['batch_size', 32, 160, 160] float32

注意不同 ultralytics 版本导出的输出名字可能不一样,有的叫output0、output1,有的叫/model.22/Concat_output_0。所以代码里尽量不要硬编码输出名,用session.get_outputs()[i].name动态获取。

3. ONNX 输出到底在说什么:从张量到掩码的完整推导

3.1 第一个输出:116 个通道怎么拆

output0是 CHW 布局,也就是 channel 维度在第二维。用起来之前需要先转置成候选行,方便按候选框处理:

outs = session.run(None, {input_name: blob}) pred = outs[0][0].T # [8400, 116]

转置之后,每一行代表一个候选位置,116 个通道可以拆成三段:

  • 前 4 个通道:预测框的x, y, w, h。注意这是 YOLO 格式的中心点坐标加宽高,而且是相对 640x640 输入图像的像素坐标,不是 0~1 的归一化值。
  • 中间 80 个通道:类别概率,COCO 是 80 类。这里官方推理逻辑一般已经做了 sigmoid 归一化,数值应该落在 0~1 之间。如果你自己改过模型结构,看到有负数或者大于 1 的值,说明输出的是 logits,后处理要手动补一层 sigmoid。
  • 最后 32 个通道:这个候选位置对应的 mask 系数,用来和output1的 protos 做矩阵乘。

坐标是像素坐标这一点很多人会忽略。比如你输入的是 640x640,模型预测某个目标的中心点在(500.5, 320.2),宽高是(120.4, 80.3),那这四个数直接参与计算就是基于 640 输入图的。后处理时一定要考虑 letterbox 的 padding 和缩放,否则坐标会整体偏移。

3.2 第二个输出:protos 和 mask 系数怎么组合

output1是 mask 原型,形状[1, 32, 160, 160]。它相当于 32 张基础掩码图,每张 160x160。每个候选框预测出的 32 个系数,就是这 32 张基础图的权重。

组合过程非常简单,本质上就是一个矩阵乘:

proto = outs[1][0] # [32, 160, 160] proto_flat = proto.reshape(32, -1) # [32, 25600] coef = pred[i, 4 + nc: 4 + nc + 32] # 第 i 个候选的 32 个系数 mask_logit = coef @ proto_flat # [25600] mask = 1.0 / (1.0 + np.exp(-mask_logit)) mask = mask.reshape(160, 160)

这里的mask是 160x160 的浮点概率图,范围 0~1。要得到最终实例掩码,需要:

  1. 把 160x160 的掩码缩放到 640x640。
  2. 根据该检测框在 640 图上的位置把掩码裁剪到目标框内。
  3. 再把目标框坐标映射回原始图像坐标。

直接对整个 640 图做阈值化也可以,但实际使用时裁剪到目标框效率更高,而且不容易把邻近目标的掩码带进来。

3.3 一次完整的后处理链路

把整个逻辑串起来,后处理步骤是这样:

  1. 从output0中取前 4 维做 xywh 转 xyxy。
  2. 取类别概率,得到每个候选的置信度和类别 id。
  3. 用置信度阈值过滤低分候选,比如0.25。
  4. 对过滤后的候选做 NMS,去掉重叠框。
  5. 对每个保留下来的目标,用对应的 32 个 mask 系数和 protos 生成掩码。
  6. 对掩码做缩放、裁剪、坐标还原。

这套流程里最容易出问题的就是 NMS 和 mask 生成之间的索引对应关系。你过滤掉了一部分候选,又在 NMS 里打乱了顺序,如果索引没有同步,mask 就会张冠李戴。所以写代码时最好把keep的索引完整保留下来,不要另起炉灶重新拷贝一份候选。

4. 基于 onnxruntime 的推理实现(可直接改)

4.1 初始化 session 和读取模型

onnxruntime 的 session 初始化有几个细节。CPU 部署直接用默认即可,但如果机器上有 GPU,可以设置 provider 优先级:

import onnxruntime as ort providers = [] if "CUDAExecutionProvider" in ort.get_available_providers(): providers.append("CUDAExecutionProvider") providers.append("CPUExecutionProvider") sess_options = ort.SessionOptions() sess_options.intra_op_num_threads = 4 session = ort.InferenceSession( model_path, sess_options=sess_options, providers=providers, )

这里说一个小经验:不要凭想象填providers=["CUDAExecutionProvider", "CPUExecutionProvider"],因为 CUDA 版本不匹配时这个 provider 不存在,初始化会直接报错。先用ort.get_available_providers()看一眼当前环境支持什么,再决定要不要加。

线程数intra_op_num_threads在 CPU 上影响很大。我实测同一个模型,单线程跑要 80ms,4 线程能压到 40ms 左右,但再往上加收益就很小了。如果你的目标是单路视频流,4~6 个线程是性价比比较高的区间。

4.2 前处理:letterbox 必须做对

YOLO 系列部署有个不能被绕过的前处理:letterbox。直接把任意尺寸图片 resize 到 640x640 会把长宽比拉变形,模型训练时没见过这种输入,精度会明显下降。

这里给一个可以直接抄的 letterbox 函数:

import cv2 import numpy as np def letterbox(img, new_shape=640, color=114): h, w = img.shape[:2] r = min(new_shape / h, new_shape / w) new_w, new_h = int(round(w * r)), int(round(h * r)) resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR) dw = (new_shape - new_w) / 2 dh = (new_shape - new_h) / 2 top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1)) left, right = int(round(dw - 0.1)), int(round(dw + 0.1)) img = cv2.copyMakeBorder( resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(color, color, color), ) return img, r, (left, top)

模型的输入图是加成灰边之后的长方形或正方形。训练时 letterbox 用的是 OpenCV 读进来的 BGR 图,但 YOLO 系列内部转成 RGB 做归一化。所以推理时同样要做:

img0 = cv2.imread("test.jpg") img, scale, (pad_left, pad_top) = letterbox(img0, 640) blob = img[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0 blob = np.ascontiguousarray(blob)

img[:, :, ::-1]是把 BGR 转成 RGB,transpose(2, 0, 1)是把 HWC 转成 CHW,/255.0是归一化。最后再套一个np.ascontiguousarray,避免因为切片和转置产生非连续内存,导致 onnxruntime 内部额外做一次 copy。

4.3 推理、NMS、掩码生成完整代码

下面是一段能直接跑的完整示例,假设模型是单类别或者多类别都可以,nc会根据输出通道自动算出来。

def sigmoid(x): return 1.0 / (1.0 + np.exp(-x)) def xywh_to_xyxy(x): boxes = x.copy() boxes[:, 0] = x[:, 0] - x[:, 2] / 2 # x1 boxes[:, 1] = x[:, 1] - x[:, 3] / 2 # y1 boxes[:, 2] = x[:, 0] + x[:, 2] / 2 # x2 boxes[:, 3] = x[:, 1] + x[:, 3] / 2 # y2 return boxes def nms_boxes(boxes, scores, iou_thr=0.45): order = scores.argsort()[::-1] keep = [] while order.size > 0: i = order[0] keep.append(i) if order.size == 1: break rest = order[1:] xx1 = np.maximum(boxes[i, 0], boxes[rest, 0]) yy1 = np.maximum(boxes[i, 1], boxes[rest, 1]) xx2 = np.minimum(boxes[i, 2], boxes[rest, 2]) yy2 = np.minimum(boxes[i, 3], boxes[rest, 3]) inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1) area1 = (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1]) area2 = (boxes[rest, 2] - boxes[rest, 0]) * (boxes[rest, 3] - boxes[rest, 1]) union = area1 + area2 - inter iou = inter / np.maximum(union, 1e-6) order = rest[iou <= iou_thr] return keep session = ort.InferenceSession("best.onnx", providers=["CPUExecutionProvider"]) input_name = session.get_inputs()[0].name img0 = cv2.imread("test.jpg") img, scale, (pad_left, pad_top) = letterbox(img0, 640) blob = img[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0 blob = np.ascontiguousarray(blob) outs = session.run(None, {input_name: blob}) pred = outs[0][0].T # [8400, 4+nc+32] proto = outs[1][0] # [32, 160, 160] nc = pred.shape[1] - 4 - 32 cls_scores = pred[:, 4:4 + nc] # 如果模型输出不是 sigmoid 后的分数,补一层 if cls_scores.min() < 0 or cls_scores.max() > 1: cls_scores = sigmoid(cls_scores) cls_ids = cls_scores.argmax(axis=1) conf = cls_scores.max(axis=1) conf_thres = 0.25 keep = conf > conf_thres boxes = xywh_to_xyxy(pred[keep, :4]) scores = conf[keep] cls_ids = cls_ids[keep] pred_keep = pred[keep] if len(boxes) > 0: nms_idx = nms_boxes(boxes, scores, iou_thr=0.45) result_list = [] for idx in nms_idx: box = boxes[idx] class_id = cls_ids[idx] score = float(scores[idx]) coef = pred_keep[idx, 4 + nc: 4 + nc + 32] mask_logit = coef @ proto.reshape(32, -1) mask = sigmoid(mask_logit).reshape(160, 160) mask_big = cv2.resize(mask, (640, 640), interpolation=cv2.INTER_LINEAR) x1, y1, x2, y2 = box.astype(int) x1 = max(x1, 0); y1 = max(y1, 0) x2 = min(x2, 639); y2 = min(y2, 639) mask_crop = mask_big[y1:y2, x1:x2] # 坐标还原到原始图像 x1_orig = int((x1 - pad_left) / scale) y1_orig = int((y1 - pad_top) / scale) x2_orig = int((x2 - pad_left) / scale) y2_orig = int((y2 - pad_top) / scale) w_orig = x2_orig - x1_orig h_orig = y2_orig - y1_orig mask_orig = cv2.resize(mask_crop, (w_orig, h_orig), interpolation=cv2.INTER_LINEAR) mask_bool = mask_orig > 0.5 result_list.append({ "box": (x1_orig, y1_orig, x2_orig, y2_orig), "class_id": int(class_id), "score": score, "mask": mask_bool, })

这段代码没有做多类别 NMS,也就是不同类别之间的框也互相抑制。如果项目里同时检测多个类别,且两个类别重叠区域很大,需要改成按cls_ids分别做 NMS,避免把不同类别的目标误删。单类别场景直接用这个版本就行。

4.4 结果坐标还原与可视化

拿到mask_bool和box之后,可以直接在原图上画轮廓:

mask_u8 = (mask_bool * 255).astype(np.uint8) contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cv2.drawContours(img0, contours, -1, (0, 255, 0), 2) x1, y1, x2, y2 = result["box"] cv2.rectangle(img0, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img0, f"{result['score']:.2f}", (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)

这里提醒一点:掩码阈值0.5是针对神经网络的概率输出,不是二值图。如果你发现目标边缘明显偏大,可以把阈值提高到0.6;如果偏小,降一点到0.4。这个参数非常影响视觉感受,导到业务系统前最好先用几张有代表性的图肉眼调一下。

5. 常见问题与排查技巧(避坑速查)

5.1 现象速查表

我自己在这条路上踩过的坑,整理成一张表,遇到问题直接对号入座。

现象常见原因处理方式
导出时报onnxslim相关错误缺少 simplify 依赖pip install onnxslim后重新导出
session.run报输入维度不对输入 blob 的 BHWC / CHW、RGB / BGR、dtype 其中一个不对统一用[1,3,640,640]float32,并做 BGR 转 RGB
输出的坐标全偏到左上角忘了减去 letterbox 的 padding还原到原图时先减(pad_left, pad_top)再除以 scale
坐标是反的宽高不对xywh 和 xyxy 理解错了前 4 维是中心点+宽高,需要手动转成 x1y1x2y2
类别置信度全在 0.5 附近可能对已经 sigmoid 的分数又做了一次 sigmoid先打印置信度范围,确认在 0~1 之间就不要处理
mask 像雪花噪声mask 系数和 proto 没有对应上,或没做 sigmoid确认使用的是第 i 个保留候选自己的系数,不是全部候选的均值
mask 比例和检测框不匹配mask 从 160x160 放大时没有按照检测框裁剪先放大到 640,再按 xyxy 裁剪,最后映射回原图
CUDAExecutionProvider初始化失败CUDA 版本和 onnxruntime-gpu 不匹配检查ort.get_available_providers(),不匹配就用 CPU
NMS 之后检测框数量突然变少多类别之间互相抑制改成按类别分别做 NMS

这些坑里最隐蔽的是第二个和第五个。因为它们不一定报错,只会让精度变差。我建议导出后拿同一张图分别跑 PyTorch 模型和 ONNX 模型,对比检测框和 mask 的数值。如果两者差很多,优先检查前处理和坐标还原。

5.2 几个容易埋雷的习惯

先说固定尺寸这件事。很多人一上来就想把dynamic=True打开,想用任意分辨率输入。这个想法本身没问题,但在 onnxruntime 后处理里,output0的8400会随输入分辨率变化,坐标还原、mask 裁剪都要跟着改。第一次做部署,我强烈建议先用imgsz=640固定住,跑通之后再考虑动态输入。动态输入在 C++ 端尤其容易踩坑,很多设备端封装并不支持 fully dynamic。

然后是输出名字。onnxruntime 的 session 输出列表顺序和导出时的顺序不一定稳定,不同 ultralytics 版本可能不一样。代码里一律用session.get_outputs()[i].name作为 run 的返回顺序依据,不要凭记忆写死索引。

还有一个习惯是不要盲目改 dtype。YOLOv8 训练时内部很多地方是 float16 / mixed precision,但导出 onnx 时权重和输入默认是 float32。你在推理代码里用 float16 输入,反而可能触发额外的 cast 操作,速度未必更快,性能还可能下降。真想加速,应该先把 float32 的 onnx 跑稳,再考虑半精度导出。

5.3 后续还可以这样扩展

onnxruntime 跑通之后,再想提速就看你部署平台了。同样一个 yolov8seg onnx,在 CPU 上如果对延迟不满意,可以先把图像分辨率降到 512,或者把intra_op_num_threads调到 6~8,通常能压一截延迟。

如果目标是 GPU,可以尝试把 ONNX 再转成 TensorRT engine,或者用 onnxruntime-gpu 跑半精度。但这些都是基于一个前提:你已经清楚 yolov8seg 在 ONNX 里的输出结构,并且后处理逻辑已经稳定。我见过太多人连 ONNX 输出都没厘清,就急着转 TensorRT,最后花了一整天排一个 NMS 索引错位的问题。

还有一个很实用的扩展方向是把后处理搬到 C++ 里。Python 推理本身不算慢,但 NMS、np 操作在服务端高并发时会成为瓶颈。把前处理、session.run、后处理全部用 C++ 重写之后,单帧耗时能再降不少。只要 Python 端的推导逻辑是对的,C++ 端照着翻译即可,不需要重新研究模型结构。

我个人实际操作中的体会是,部署这件事真正花时间的不是导出,而是把“模型输出到底代表什么”想清楚。只要你把116、32、8400、160这几个数字的含义吃透,yolov8seg 转 onnx推理就成功了一大半。剩下的事情,就是不断拿真实图片去对比、校准,直到输出稳定到你认它为止。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询