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 / GPUC、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 预训练权重nc80那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( formatonnx, opset12, imgsz640, dynamicFalse, simplifyTrue, ) print(onnx_path)这里解释几个参数背后的原因。imgsz640是导出模型的固定输入尺寸。默认是 640x640如果你的业务图比例比较极端比如长条形的货架图也可以传imgsz(640, 960)但后续所有坐标计算都要对应调整。dynamicFalse是我强烈建议第一次先用的配置。动态输入虽然方便跑不同分辨率但会让输出 shape 变成None在后处理和调试阶段增加一层不必要的复杂度。先把固定尺寸跑通再考虑 dynamic。opset12是兼容性比较好的选择。onnxruntime 从 1.x 开始对 opset 12 支持成熟很多老旧推理框架也认这个版本。常用的opset13也能用但如果你的目标设备 SDK 版本较老建议先 12。simplifyTrue会尝试对计算图做常量折叠和结构精简。它需要额外依赖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主要检查图结构、节点输入输出、维度信息这些基础问题。如果这里报错多半是模型导出阶段就出了问题而不是推理代码的问题。第二打印输入输出名字和 shapeimport 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。要得到最终实例掩码需要把 160x160 的掩码缩放到 640x640。根据该检测框在 640 图上的位置把掩码裁剪到目标框内。再把目标框坐标映射回原始图像坐标。直接对整个 640 图做阈值化也可以但实际使用时裁剪到目标框效率更高而且不容易把邻近目标的掩码带进来。3.3 一次完整的后处理链路把整个逻辑串起来后处理步骤是这样从output0中取前 4 维做 xywh 转 xyxy。取类别概率得到每个候选的置信度和类别 id。用置信度阈值过滤低分候选比如0.25。对过滤后的候选做 NMS去掉重叠框。对每个保留下来的目标用对应的 32 个 mask 系数和 protos 生成掩码。对掩码做缩放、裁剪、坐标还原。这套流程里最容易出问题的就是 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_optionssess_options, providersproviders, )这里说一个小经验不要凭想象填providers[CUDAExecutionProvider, CPUExecutionProvider]因为 CUDA 版本不匹配时这个 provider 不存在初始化会直接报错。先用ort.get_available_providers()看一眼当前环境支持什么再决定要不要加。线程数intra_op_num_threads在 CPU 上影响很大。我实测同一个模型单线程跑要 80ms4 线程能压到 40ms 左右但再往上加收益就很小了。如果你的目标是单路视频流4~6 个线程是性价比比较高的区间。4.2 前处理letterbox 必须做对YOLO 系列部署有个不能被绕过的前处理letterbox。直接把任意尺寸图片 resize 到 640x640 会把长宽比拉变形模型训练时没见过这种输入精度会明显下降。这里给一个可以直接抄的 letterbox 函数import cv2 import numpy as np def letterbox(img, new_shape640, color114): 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), interpolationcv2.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 转成 RGBtranspose(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_thr0.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, 4nc32] 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(axis1) conf cls_scores.max(axis1) 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_thr0.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), interpolationcv2.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), interpolationcv2.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()不匹配就用 CPUNMS 之后检测框数量突然变少多类别之间互相抑制改成按类别分别做 NMS这些坑里最隐蔽的是第二个和第五个。因为它们不一定报错只会让精度变差。我建议导出后拿同一张图分别跑 PyTorch 模型和 ONNX 模型对比检测框和 mask 的数值。如果两者差很多优先检查前处理和坐标还原。5.2 几个容易埋雷的习惯先说固定尺寸这件事。很多人一上来就想把dynamicTrue打开想用任意分辨率输入。这个想法本身没问题但在 onnxruntime 后处理里output0的8400会随输入分辨率变化坐标还原、mask 裁剪都要跟着改。第一次做部署我强烈建议先用imgsz640固定住跑通之后再考虑动态输入。动态输入在 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推理就成功了一大半。剩下的事情就是不断拿真实图片去对比、校准直到输出稳定到你认它为止。
阅读完成 · 觉得有帮助?