YOLOv8seg 这个模型在边缘端落地最让人头疼的从来不是训练而是把它塞进 RKNN 之后那一堆对不齐的算子、莫名其妙的输出形状以及后处理里那些“看起来对、跑起来错”的坐标。我前后在 RK3588 和 RK3568 上折腾过好几个分割模型从最早的 YOLOv5seg 到现在的 v8seg踩的坑足够写一本小册子。这篇就把从 ONNX 导出、RKNN 转换、量化校准到后处理里 mask 系数与原型图相乘的完整链路拆开讲清楚尤其是那些官方文档一笔带过、但实际一跑就报错的地方。不管你是刚拿到开发板的新手还是已经跑通检测但卡在分割分支的老手下面这些内容应该都能帮你少熬几个晚上。1. 为什么 YOLOv8seg 上板比检测模型麻烦得多1.1 分割头带来的额外输出分支YOLOv8 的检测头输出的是一个[1, 4nc, 8400]的张量结构非常干净。但 seg 版本在检测头之外还挂了一个 mask 分支最终输出两个东西一个是检测结果张量[1, 4nc32, 8400]另一个是原型图[1, 32, 160, 160]以 640 输入为例。多出来的这 32 个通道就是 mask 系数它们要和原型图做矩阵乘法才能还原出每个实例的分割掩码。问题就出在这里。RKNN 在转换时对多输出模型的处理逻辑和单输出完全不同尤其是当两个输出的量化尺度差异很大时量化校准稍有不慎就会导致 mask 系数整体偏移最后分割出来的区域要么糊成一团要么直接跑到图像外面去。1.2 RKNN 对动态 shape 和算子融合的取舍RKNN 工具链在转换时会对计算图做一轮算子融合比如把 SiLU 激活和卷积合并、把 Concat 和 Resize 做重排。检测分支因为这些操作比较规整融合后精度损失很小。但 mask 分支里涉及Conv2d加Interpolate再加MatMul的组合RKNN 对Interpolate的支持在不同版本里差异很大。我实测下来rknn-toolkit2 1.5.0 之前对nearest和bilinear的处理结果不一致某些版本会把bilinear静默降级成nearest导致原型图分辨率对不上后处理里乘出来的 mask 边缘全是锯齿。提示转换完成后一定要用rknn.eval_perf()和rknn.eval_memory()看每一层的耗时和内存占用重点确认原型图那一层的输出 shape 是否和 ONNX 一致。如果发现 shape 被改了基本可以判定是算子融合出了问题。1.3 量化校准集的选择直接决定分割质量检测模型对量化误差的容忍度相对高因为框的回归是连续的稍微偏一点还能靠 NMS 兜住。但分割不一样mask 系数是逐像素参与运算的量化误差会被放大到每个像素上。我试过用纯 COCO 的通用图片做校准结果分割边缘毛刺非常严重后来换成从实际业务场景里抽的 200 张图做校准边缘质量立刻上了一个台阶。校准集的选择原则很简单分布要覆盖你实际推理时可能遇到的所有场景。如果你的模型是做人像分割校准集里就得多放人像如果是做工业缺陷分割那就得放缺陷样本。数量上 100 到 300 张足够太多反而会让校准过程变慢收益递减。2. 从 PyTorch 到 ONNX导出阶段的隐藏陷阱2.1 导出参数里最容易忽略的两个开关Ultralytics 官方给的导出命令通常是这样的yolo export modelyolov8n-seg.pt formatonnx imgsz640 opset12 simplifyTrue大部分人直接复制粘贴就跑了但有两个地方需要特别注意。第一是opset版本RKNN 对 opset 12 到 17 的支持比较稳但如果你用了 opset 18 以上某些算子会被 RKNN 拒绝。第二是simplify这个开关会调用 onnx-simplifier 做一轮图优化好处是能去掉冗余节点坏处是某些版本会把 mask 分支里的Reshape和Transpose合并掉导致 RKNN 转换时报维度不匹配。我的建议是先用simplifyFalse导出一版确认 RKNN 能正常转换后再试simplifyTrue看精度有没有提升。如果简化版报错直接回退到未简化版不要在这上面浪费时间。2.2 输出节点的命名与顺序导出后的 ONNX 默认输出节点名是output0和output1其中output0是检测张量output1是原型图。但有些版本的 Ultralytics 会把顺序反过来或者把名字改成detect_output和mask_output。RKNN 转换时是按输出顺序来绑定输出的如果你在后处理里假设outputs[0]是检测、outputs[1]是原型但实际顺序反了结果就是全黑或者全白。一个稳妥的做法是在导出后用 Netron 打开 ONNX 文件手动确认两个输出的 shape 和名字。检测输出的最后一维应该是 8400640 输入下原型图的 shape 应该是[1, 32, 160, 160]。确认无误后再进行下一步。2.3 输入归一化的处理位置YOLOv8 在训练时输入是归一化到 0 到 1 的但 RKNN 在推理时默认接受的是 uint8 输入。这里有两种处理方式一种是在 ONNX 里保留归一化操作让 RKNN 在 NPU 上做除法另一种是把归一化挪到后处理里RKNN 直接吃 uint8。我推荐第二种。原因是 NPU 做浮点除法效率很低而且 RKNN 的量化校准本身就会处理输入尺度。具体做法是在导出 ONNX 时把输入层的归一化去掉然后在 RKNN 转换配置里设置mean_values和std_values。以 0 到 1 归一化为例mean_values[[0, 0, 0]]std_values[[255, 255, 255]]这样 RKNN 会自动把 uint8 输入映射到 0 到 1 的范围。3. RKNN 转换与量化参数怎么设才不翻车3.1 转换脚本的完整配置下面是我在 RK3588 上跑通 v8seg 的转换脚本核心部分from rknn.api import RKNN rknn RKNN(verboseTrue) # 配置归一化输入为 uint8 时映射到 0-1 rknn.config( mean_values[[0, 0, 0]], std_values[[255, 255, 255]], target_platformrk3588, quantized_dtypeasymmetric_quantized-8, optimization_level3 ) # 加载 ONNX ret rknn.load_onnx(modelyolov8n-seg.onnx) if ret ! 0: print(Load ONNX failed) exit(ret) # 构建指定量化校准集 ret rknn.build(do_quantizationTrue, datasetcalib_list.txt) if ret ! 0: print(Build failed) exit(ret) # 导出 RKNN 模型 ret rknn.export_rknn(yolov8n-seg.rknn)这里有几个参数值得展开说。quantized_dtype选asymmetric_quantized-8是因为分割任务对零点偏移比较敏感对称量化在某些通道上会把小数值直接压成零。optimization_level3会开启最激进的算子融合如果发现精度掉得厉害可以降到 2 试试。3.2 校准集文件的格式与常见错误calib_list.txt里每行是一张图片的路径路径必须是绝对路径或者相对于脚本运行目录的正确路径。我遇到过最常见的问题是路径里有中文或者空格RKNN 读取时会静默跳过导致实际参与校准的图片数量远少于预期。另一个坑是图片格式虽然文档说支持 jpg 和 png但某些版本的 RKNN 对 png 的 alpha 通道处理有问题建议统一转成 jpg。校准集的数量我一般控制在 200 张左右并且会先跑一遍rknn.build看日志里有没有calibration image load failed的警告。如果有说明路径或格式有问题必须先解决再继续。3.3 量化精度验证的实操方法转换完成后不要急着上板先在 PC 上用 RKNN 的模拟器跑一遍推理和 ONNX Runtime 的结果做对比。具体做法是拿一张测试图分别用 ONNX Runtime 和 RKNN 模拟器推理然后对比检测框的坐标和 mask 的 IoU。我通常会用这样一个对比脚本import numpy as np # onnx_result 和 rknn_result 分别是两个模型的输出 # 对比检测框 box_diff np.abs(onnx_result[0][:, :4] - rknn_result[0][:, :4]).max() print(fMax box diff: {box_diff}) # 对比 mask 系数 mask_diff np.abs(onnx_result[0][:, 4nc:] - rknn_result[0][:, 4nc:]).max() print(fMax mask coeff diff: {mask_diff})如果 box diff 超过 2 个像素或者 mask 系数差异超过 0.1说明量化损失过大需要调整校准集或者降低optimization_level。实测下来box diff 控制在 1 个像素以内、mask 系数差异在 0.05 以内上板后的效果基本和 PC 端一致。4. 后处理全解析从输出张量到分割掩码4.1 检测分支的解码逻辑RKNN 输出的检测张量 shape 是[1, 116, 8400]80 类 4 坐标 32 mask 系数。第一步是把它转置成[8400, 116]然后分离出坐标、类别分数和 mask 系数。坐标部分是cx, cy, w, h格式需要转换成x1, y1, x2, y2。这里有个细节YOLOv8 的坐标是相对于 640 输入的但 RKNN 输出的坐标可能已经被量化到了整数。我在 RK3588 上实测发现坐标的小数部分会被截断导致框的位置有 1 到 2 个像素的偏移。解决办法是在后处理里对坐标做一次浮点还原或者直接用float32做后续计算。类别分数需要做 sigmoid 激活因为导出时没有把 sigmoid 融合进去。这一步很多人会忘结果就是分数全是负数或者大于 1。4.2 mask 系数与原型图的矩阵乘法这是整个后处理里最容易出错的地方。原型图的 shape 是[1, 32, 160, 160]mask 系数的 shape 是[n, 32]其中 n 是检测到的实例数。两者相乘得到[n, 160, 160]的掩码。具体操作是先把原型图 reshape 成[32, 160*160]然后和 mask 系数做矩阵乘法proto proto.reshape(32, -1) # [32, 25600] masks mask_coeff proto # [n, 25600] masks masks.reshape(-1, 160, 160)乘完之后需要做 sigmoid 激活因为 mask 系数在训练时是经过 sigmoid 的。然后要把 160x160 的掩码裁剪到检测框的范围内再 resize 回原图尺寸。这里有个坑裁剪时用的框坐标必须是相对于 160x160 的而不是相对于 640 的。因为原型图是 160x160而检测框是 640 尺度的需要先除以 4 再裁剪。我见过不少人直接拿 640 的框去裁 160 的掩码结果就是掩码全在左上角一小块区域。4.3 掩码阈值与边缘优化裁剪后的掩码是浮点数需要二值化。阈值一般取 0.5但在实际场景里可以调。如果是人像分割阈值可以降到 0.3 让边缘更完整如果是工业缺陷分割阈值可以提到 0.6 减少误检。二值化之后还可以做一轮形态学操作比如开运算去掉小噪点闭运算填补空洞。OpenCV 的morphologyEx就能搞定。不过要注意形态学操作会增加耗时如果对帧率要求高可以只在后处理阶段对面积大于一定阈值的掩码做处理。4.4 多实例掩码的合并与输出如果一张图里有多个同类实例它们的掩码可能会重叠。这时候需要决定是保留所有掩码还是做合并。如果是实例分割通常保留每个实例的独立掩码如果是语义分割就需要把同类别的掩码合并成一个。合并的逻辑很简单用一个累积数组每次把新掩码按位或进去。但要注意顺序先处理的实例会被后处理的覆盖。如果业务上需要区分实例就不要合并直接输出掩码列表。5. 上板实测性能数据与调优经验5.1 RK3588 上的实测帧率以 640x640 输入、YOLOv8n-seg 为例RK3588 单核 NPU 的推理耗时大约在 28 到 35 毫秒之间后处理包括矩阵乘法和掩码生成在 CPU 上耗时约 15 到 20 毫秒。整体帧率能跑到 18 到 22 FPS。如果开启 RK3588 的三核 NPU 并行推理部分可以压到 12 毫秒左右但后处理会成为瓶颈。优化的方向有两个一是把后处理里的矩阵乘法用 NPU 做但这需要把 mask 分支也放进模型里二是用多线程把后处理拆开检测框解码和掩码生成并行跑。我试过第二种帧率能提升到 28 FPS 左右。5.2 内存占用与模型大小YOLOv8n-seg 的 RKNN 模型大约 8 到 10 MB推理时峰值内存占用在 150 MB 左右。如果换成 YOLOv8s-seg模型会涨到 25 MB内存占用到 300 MB。RK3588 的 4GB 版本跑 s 版没问题但 2GB 版本就要小心了尤其是同时跑多个模型的时候。5.3 常见报错与排查路径上板后最常见的报错是rknn_init失败通常是因为 RKNN 运行时库版本和转换时用的 toolkit 版本不匹配。解决办法是确认板子上的librknnrt.so版本和 PC 上rknn-toolkit2的版本一致。另一个高频问题是推理结果全零这多半是输入数据没有正确填充。RKNN 的输入是 NHWC 格式而 OpenCV 读出来是 BGR需要先转成 RGB 再填充。如果忘了转检测框会全部错位。还有一个隐蔽的坑是rknn_outputs_get时没有指定want_float1导致拿到的输出是量化后的整数后处理里直接当浮点用结果完全不对。这个错误不会报错只会让结果莫名其妙。6. 几个让我印象深刻的踩坑记录6.1 原型图输出被静默裁剪有一次转换完模型后检测框完全正常但掩码全是零。排查了半天才发现RKNN 在转换时把原型图的输出通道从 32 裁到了 16原因是optimization_level3触发了某个融合规则把认为冗余的通道去掉了。解决办法是把optimization_level降到 2或者在转换配置里显式指定输出节点。6.2 校准集里的图片尺寸不一致RKNN 的校准集要求所有图片尺寸一致但文档里没有明确说。我一开始混用了 640x640 和 1280x720 的图片结果校准过程直接报错但错误信息很模糊只说是dataset load failed。后来统一 resize 到 640x640 才通过。6.3 后处理里的坐标偏移在 RK3568 上跑的时候发现检测框整体向右下偏移了大约 3 个像素。查了很久才发现是 RKNN 在量化时对坐标做了零点偏移而我在后处理里没有做反量化。解决办法是在解码坐标时加上zero_point的补偿或者直接用want_float1拿浮点输出。6.4 多线程推理时的资源竞争为了提升帧率我试过用两个线程同时跑推理。结果发现 RKNN 的上下文不是线程安全的两个线程同时调用rknn_run会导致结果错乱。后来改成每个线程独立初始化一个 RKNN 上下文问题才解决。但这样内存占用会翻倍需要权衡。7. 一些可以复用的代码片段7.1 完整的后处理函数import numpy as np import cv2 def postprocess(outputs, conf_thres0.25, iou_thres0.45, mask_thres0.5): # outputs[0]: [1, 116, 8400], outputs[1]: [1, 32, 160, 160] det outputs[0][0].T # [8400, 116] proto outputs[1][0] # [32, 160, 160] # 分离坐标、分数、mask系数 boxes det[:, :4] scores det[:, 4:84] mask_coeff det[:, 84:] # 类别分数取最大值 class_ids np.argmax(scores, axis1) confidences scores[np.arange(len(scores)), class_ids] # 置信度过滤 keep confidences conf_thres boxes boxes[keep] confidences confidences[keep] class_ids class_ids[keep] mask_coeff mask_coeff[keep] # 坐标转换 cxcywh - 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 # NMS indices cv2.dnn.NMSBoxes( boxes_xyxy.tolist(), confidences.tolist(), conf_thres, iou_thres ) if len(indices) 0: return None indices indices.flatten() boxes_xyxy boxes_xyxy[indices] mask_coeff mask_coeff[indices] class_ids class_ids[indices] # 生成掩码 proto_flat proto.reshape(32, -1) # [32, 25600] masks mask_coeff proto_flat # [n, 25600] masks masks.reshape(-1, 160, 160) masks 1 / (1 np.exp(-masks)) # sigmoid # 裁剪到检测框 results [] for i in range(len(boxes_xyxy)): x1, y1, x2, y2 boxes_xyxy[i] / 4 # 缩放到160尺度 x1, y1 max(0, int(x1)), max(0, int(y1)) x2, y2 min(160, int(x2)), min(160, int(y2)) mask np.zeros((160, 160), dtypenp.float32) mask[y1:y2, x1:x2] masks[i, y1:y2, x1:x2] mask (mask mask_thres).astype(np.uint8) results.append({ box: boxes_xyxy[i], class_id: class_ids[i], mask: mask }) return results7.2 输入预处理的正确姿势def preprocess(image, input_size640): # BGR - RGB img cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # resize 保持比例 h, w img.shape[:2] scale min(input_size / h, input_size / w) new_h, new_w int(h * scale), int(w * scale) img_resized cv2.resize(img, (new_w, new_h)) # padding canvas np.zeros((input_size, input_size, 3), dtypenp.uint8) canvas[:new_h, :new_w] img_resized return canvas, scale这两个函数基本覆盖了从输入到输出的完整链路可以直接拿去用。需要注意的是preprocess里的 padding 是左上角对齐的如果你的业务场景里目标集中在图像中心可以改成居中 padding但后处理里的坐标还原也要相应调整。8. 关于模型选型的一点个人看法YOLOv8n-seg 在 RK3588 上的表现已经能满足大部分实时分割的需求但如果你的场景对精度要求更高可以考虑 YOLOv8s-seg。不过 s 版的推理耗时大约是 n 版的 2.5 倍后处理耗时也会增加因为检测到的实例更多。我的建议是先用 n 版跑通全流程确认精度不够再换 s 版。另外如果你用的是 RK3568 而不是 RK3588建议把输入尺寸降到 416 或者 320否则推理耗时会超过 100 毫秒基本没法做实时。RK3568 的 NPU 算力只有 RK3588 的三分之一左右分割任务对算力的要求又比检测高所以尺寸上的妥协是必要的。最后说一个容易被忽略的点RKNN 的量化模型在第一次推理时会有一个预热过程耗时比后续推理长 2 到 3 倍。如果你在测帧率记得先跑 10 次预热再取平均值否则数据会偏悲观。这个预热过程在 RK3588 上大约需要 200 毫秒在 RK3568 上可能到 500 毫秒。
