☰
YOLOv8+ONNXRuntime+OpenCV:CPU实现目标检测与实例分割
2026/10/1 12:54:53 网站建设 项目流程

简介:面向有一定深度学习基础的开发者,这套资源提供了基于YOLOv8的目标检测与实例分割实战项目,使用ONNXRuntime完成高效推理,借助OpenCV处理图像输入输出,覆盖模型加载、预处理、推理到结果可视化的完整链路。压缩包内共52个文件,大小约7.46MB,主要包含cpp源码、h头文件、sample示例、CMake构建脚本、README说明及测试图片;其中yolov8_onnx、yolov8_seg_onnx、yolov8_obb_onnx等模块可分别实现检测、实例分割和旋转框检测,main.cpp作为统一入口,工具类封装了前处理与后处理逻辑,便于按需裁剪或扩展。项目还附有模型放置说明与示例图像,用户只需放置对应ONNX模型即可运行示例,配合zidane.jpg、bus.jpg等图像快速验证效果;代码采用模块化设计,注释清晰,适合作为YOLOv8工程化入门的参考模板或二次开发基础。目前已有508人学习下载,能帮助读者理解C++环境下ONNXRuntime与OpenCV的协作方式,以及从模型部署到实际推理的完整流程。

1. 拿到这个项目实战包,先明白ONNXRuntime和OpenCV在这里各自站什么岗

这个标题把部署路线写得很直白:YOLOv8负责模型能力,ONNXRuntime负责推理引擎,OpenCV负责图像侧的一切杂活。把训练好的pt权重转成ONNX,再搭配ONNXRuntime和OpenCV实现目标检测与实例分割,是CPU机器、Windows工控机、边缘盒子上最干净的一套配合。部署机不需要装PyTorch,不需要GPU,模型导完后只剩一个onnx文件加一个推理动态库,无论做上位机还是嵌入式都容易集成。

本文面向两种情况:一是你已经用YOLOv8训练过模型,想把推理部分抽出来接到自己的图像处理程序里;二是正在Ubuntu 20.04这类环境里做CPU评估,想知道这条路线能不能跑通、快不快、坑在哪里。我会把目标检测和实例分割分开讲,因为它们的ONNX输出结构完全不同,后处理写错一个维度,结果就是框能出但mask全黑。

2. 把ONNXRuntime和OpenCV装到能跑模型:选型依据、版本配套和验证命令

2.1 为什么部署阶段要换掉PyTorch:一张对比表看清分工

常见做法是把模型权重视作一个黑匣子,部署机上只留推理引擎和图像库。PyTorch推理不是不行,而是它在产线机器上太重:装完torch、torchvision以及一堆传递依赖,体积大到让人头疼;如果现场机器没有GPU,CPU上的表现也未必比ONNXRuntime好。另一个更现实的问题是,你的采集程序、界面程序不一定用Python写,而ONNXRuntime有C++接口,OpenCV更是图像处理的事实标准,两者可以直接嵌进一套C++工程里。

OpenCV在这个标题里的定位是做推理之外的所有杂事:读图、缩放、填充、颜色通道转换、画框、画mask,甚至NMS都能用cv2.dnn.NMSBoxes解决。ONNXRuntime只负责把ONNX模型跑起来,两者不冲突。

方案部署机依赖CPU推理表现后处理适合场景
PyTorch直接推理torch全家桶,体积大算子没有专门优化自己写或用ultralytics内置函数训练机调试、快速验证
ONNXRuntime CPU只有一个动态库线程池、算子融合后表现务实OpenCV或numpy工控机、服务器、边缘盒
OpenCV DNNopencv自带dnn模块看版本,部分算子支持不全OpenCV一条龙简单demo,复杂模型容易受限

选ONNXRuntime还有个额外收益:模型一旦导出成ONNX,训练框架和推理框架就解耦了。今天用YOLOv8,明天换YOLOv5一样导成ONNX,后处理逻辑几乎不用动。

2.2 版本配套关系:onnxruntime、onnx、opencv之间别各装各的

这里最容易翻车的是onnx和onnxruntime的版本错位。ONNX文件里带了IR版本和opset版本,ONNXRuntime解析模型时对算子版本有要求;opset太低,新算子不认,opset太高,旧版runtime不认。我一般会把组合锁在一个区间里:

组件建议区间原因
Python3.9~3.11onnxruntime对新版本Python的预编译包发布速度慢半拍
onnxruntime1.16~1.18CPU版直接pip安装,aarch64也有官方包
onnx1.14~1.16和onnxruntime的IR解析配套
opencv-python4.8~4.10新版本NMSBoxes返回值有变化,后面专门讲
ultralytics8.0~8.2训练和导出保持同版本最省事

注意onnxruntime和onnxruntime-gpu是两个包,标题里这套方案大概率不需要GPU。如果你在GPU机器上导出、在CPU机器上推理,完全没有问题,甚至推荐这么做:导出机器跑一次就行,部署机只装runtime。

Ubuntu 20.04上只用CPU搭这套环境,和Windows没有本质区别,pip安装即可。如果遇到鲲鹏920这类ARM服务器,优先看onnxruntime官方有没有对应的aarch64 wheel;没有的话再走源码编译,编译时用CMAKE_BUILD_TYPE=Release,这一步不是必须的,多数场景下官方预编译包够用。

2.3 最小依赖安装和验证命令:先确认三个库能一起工作

依赖清单里不需要装ultralytics,除非你要在部署机上导出模型。给一份我常用的requirements.txt:

onnxruntime==1.17.3 onnx==1.16.1 opencv-python==4.9.0.80 numpy==1.26.4

安装命令:

pip install -r requirements.txt

装完先跑一个就绪检查,确认onnxruntime能正常创建会话、OpenCV能正常读图:

import onnxruntime as ort import cv2 import numpy as np print("onnxruntime:", ort.__version__) print("opencv:", cv2.__version__) print("providers:", ort.get_available_providers())

get_available_providers()会打印CPUExecutionProvider,如果有GPU机器还会看到CUDAExecutionProvider。这一步的意义在于提前暴露动态库冲突问题;很多人装完opencv后import报错,多半是之前装过opencv-contrib-python或老版本残留,建议在虚拟环境里重装一遍。

2.4 拿到onnx文件后先做一次空跑:把输入输出shape打出来

从模型包或训练目录拿到yolov8.onnx后,先别急着写完整推理代码,写个几行脚本把模型的输入输出结构打出来:

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

这一步能直接回答两个问题:输入是不是[1,3,640,640],输出是几个分支。目标检测模型通常只有一个输出,实例分割模型会有两个输出,形状大概像[1,116,8400]和[1,32,160,160]。看到这两个数字,后面后处理就好写了。

3. 目标检测从模型文件到可信框:导出ONNX、letterbox和输出解码

3.1 导出ONNX和第一步检查:输入输出的shape暴露了后处理细节

如果你手上还没有onnx文件,在装有ultralytics的训练机上执行导出:

yolo export model=yolov8n.pt format=onnx opset=12 imgsz=640 dynamic=False

导出参数里最值得留意的是imgsz=640和dynamic=False。固定输入尺寸能帮ONNXRuntime在加载模型时做更多的图优化,推理省心很多。opset=12是兼容性较好的档位,ONNXRuntime 1.17对opset 12的支持很成熟。

导出完成后,用2.4节的脚本打印shape。YOLOv8检测模型的输出一般是[1,84,8400],这里的84由4个框坐标加80个COCO类别得分组成,8400是三个尺度特征图上的候选框总数。如果打印出来是[1,8400,84]也不用慌,只是不同版本导出时转置差异,后面代码里统一转一下就好。

3.2 letterbox预处理:等比例缩放加灰边,一个函数把坐标映射也带出来

YOLOv8训练时会把输入图等比缩放到640x640,多余部分用灰色114填充。推理时也必须做同样的操作,否则物体比例变了,检测框会跟着偏。这里有个关键点:直接用cv2.resize把图片拉到640x640会破坏长宽比,必须用letterbox。

import cv2 import numpy as np def letterbox(img, new_shape=(640, 640), color=114): h, w = img.shape[:2] r = min(new_shape[0] / h, new_shape[1] / w) new_w, new_h = int(round(w * r)), int(round(h * r)) dw = (new_shape[1] - new_w) / 2 dh = (new_shape[0] - 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)) if (new_w, new_h) != (w, h): img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR) img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color) return img, r, left, top

函数返回三个值:letterbox后的图、缩放比例r、左边的padding像素数left和上边的padding像素数top。为什么要返回padding?因为模型输出的框坐标是在letterbox后的图像坐标系里算的,回到原图时必须减去padding再除以r。这个映射关系留着,后面画框和画mask都要用。

注意:round(dh - 0.1)和round(dh + 0.1)是为了处理奇数像素填充时的对称问题,属于YOLO系列一贯的写法,照用即可。

3.3 推理解码:YOLOv8没有objectness,别照着旧教程找错列

旧版YOLOv5的ONNX输出第一列是objectness置信度,许多网上教程会教你取pred[:, 4]当物体得分。YOLOv8把anchor-based结构改成了anchor-free,输出里没有objectness这一列,84列就是4个坐标加80个类别得分,直接取类别维度的最大值作为置信度。

def detect_infer(sess, img_path, conf_thres=0.25, iou_thres=0.45): img0 = cv2.imread(img_path) img, r, left, top = letterbox(img0) blob = cv2.dnn.blobFromImage(img, 1/255.0, (640, 640), (0, 0, 0), swapRB=True) outputs = sess.run(None, {sess.get_inputs()[0].name: blob}) pred = outputs[0] if pred.shape[1] < pred.shape[2]: pred = pred.transpose((0, 2, 1)) pred = pred[0] # (8400, 84) boxes = pred[:, :4] # cx, cy, w, h cls_scores = pred[:, 4:] # 80类得分 scores = cls_scores.max(axis=1) class_ids = cls_scores.argmax(axis=1) keep = scores > conf_thres boxes, scores, class_ids = boxes[keep], scores[keep], class_ids[keep] 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 indices = cv2.dnn.NMSBoxes(boxes_xyxy.tolist(), scores.tolist(), conf_thres, iou_thres) if len(indices) == 0: return [] indices = np.array(indices).reshape(-1) result = [] for i in indices: x1, y1, x2, y2 = boxes_xyxy[i] x1 = (x1 - left) / r y1 = (y1 - top) / r x2 = (x2 - left) / r y2 = (y2 - top) / r result.append((int(x1), int(y1), int(x2), int(y2), int(class_ids[i]), float(scores[i]))) return result

这段代码里blobFromImage一步完成了三件事:缩放、BGR转RGB、除以255归一化。缩放尺寸传640x640,但前面letterbox已经把图处理成640x640,所以这里不会二次变形。

坐标还原公式x = (x_letter - left) / r,必须和letterbox一一对应。如果导出时用了imgsz=1280,代码里的640都要同步改,最稳的做法是从session.get_inputs()[0].shape动态读出来。

4. 实例分割的后处理:116维的mask系数和160x160的proto怎么用对

4.1 输出结构:检测模型的84维和分割模型的116维差在哪里

实例分割模型导出后,打印shape会看到两个输出。以yolov8n-seg为例,输出通常是:

output0: (1, 116, 8400) output1: (1, 32, 160, 160)

output0的前84维含义和检测模型完全一样:4个坐标加80个类别得分。后面多出来的32维是每个候选框对应的mask系数。output1是原型mask,可以把它理解成一张特征图,32个通道存储了图像区域的基础分割信息。最终某个框的mask等于它的32个系数和原型mask做线性组合,再经过sigmoid激活。

打印shape这一步非常值得做,因为不同版本导出的维度顺序可能变。只要看到116这个数字,就知道要用84到116之间的数据。

4.2 先做NMS再算mask,分割后处理顺序决定你用CPU还是干等

新手常见的写法是先把8400个候选框全部和proto做矩阵乘法,跑完再筛框。这意味着8400次32x25600的矩阵运算,在CPU上会卡到怀疑人生。正确顺序是:先按置信度过滤,再NMS,最后只对保留下来的框算mask。

def seg_postprocess(pred, proto, boxes_xyxy, class_ids, scores, img_size=640, conf_thres=0.25): mask_coeffs = pred[:, 84:116] keep = scores > conf_thres boxes_xyxy, class_ids, scores = boxes_xyxy[keep], class_ids[keep], scores[keep] mask_coeffs = mask_coeffs[keep] indices = cv2.dnn.NMSBoxes(boxes_xyxy.tolist(), scores.tolist(), conf_thres, 0.45) if len(indices) == 0: return [], [], [], [] indices = np.array(indices).reshape(-1) boxes = boxes_xyxy[indices] class_ids = class_ids[indices] scores = scores[indices] coeffs = mask_coeffs[indices] proto = proto[0] # (32, 160, 160) proto = proto.reshape(32, -1) # (32, 25600) masks = coeffs @ proto # (N, 25600) masks = 1.0 / (1.0 + np.exp(-masks)) masks = masks.reshape(-1, 160, 160) return boxes, class_ids, scores, masks

proto的160x160分辨率对应输入图的1/4尺度,所以mask的空间分辨率天然比检测框低。后面还要做一次resize把它还原到原图框大小。

批量矩阵运算coeffs @ proto一次把N个候选框的mask全算出来,比for循环逐个算快得多。这也是ONNXRuntime CPU推理时很值得保留的优化习惯。

注意:这里没有在proto上加sigmoid,而是在coeffs @ proto之后对整个mask做sigmoid。顺序错了mask数值范围就不对。

4.3 把mask贴回原图:crop、resize和阈值化,附最小可视化代码

每个mask是160x160,对应整张letterbox后的画布。要把它贴回原图,需要先按检测框在640坐标系下的位置裁出区域,再缩放回原图框的实际尺寸,最后以0.5为阈值转成二值mask:

def paste_masks(masks, boxes_xyxy, left, top, r, orig_shape): # boxes_xyxy 是 640 画布坐标系下的框 h, w = orig_shape[:2] canvas = np.zeros((h, w), dtype=np.uint8) mask_h, mask_w = masks.shape[1:] scale_w = mask_w / 640.0 scale_h = mask_h / 640.0 for i in range(len(masks)): x1, y1, x2, y2 = boxes_xyxy[i].astype(int) x1 = max(0, min(mask_w, int(x1 * scale_w))) y1 = max(0, min(mask_h, int(y1 * scale_h))) x2 = max(0, min(mask_w, int(x2 * scale_w))) y2 = max(0, min(mask_h, int(y2 * scale_h))) if x2 - x1 < 1 or y2 - y1 < 1: continue crop = masks[i, y1:y2, x1:x2] # 转回原图坐标系 ox1 = int((boxes_xyxy[i][0] - left) / r) oy1 = int((boxes_xyxy[i][1] - top) / r) ox2 = int((boxes_xyxy[i][2] - left) / r) oy2 = int((boxes_xyxy[i][3] - top) / r) bw, bh = max(1, ox2 - ox1), max(1, oy2 - oy1) crop = cv2.resize(crop, (bw, bh), interpolation=cv2.INTER_LINEAR) crop = (crop > 0.5).astype(np.uint8) * 255 roi = canvas[oy1:oy2, ox1:ox2] canvas[oy1:oy2, ox1:ox2] = np.maximum(roi, crop) return canvas

这段代码的关键在于两套坐标别混:boxes_xyxy是letterbox画布坐标,裁mask时直接用;贴回原图时先减去left/top再除以r。如果忘了减padding,物体位置会整体向右下偏移。

mask值在resize之后用0.5做阈值,是为了容忍插值产生的过渡像素。叠加可视化时可以用cv2.addWeighted或直接按位赋值,canvas是单通道灰度,转成BGR后再与原图合成即可。

5. 避坑手册:ONNX推理不出框、mask全黑和CPU慢的五个现场

5.1 onnxruntime报Unsupported operator,模型加载直接失败

现象:InferenceSession创建时报Unsupported operator或No Op registered for ...。

原因:导出ONNX时opset版本太高,低版本onnxruntime不认新算子;或者模型里带了一些自定义算子,没有注册对应kernel。

解决:导出时固定opset=12,低版本runtime配低opset最省事。如果模型来自别人,先用onnx.version_converter降级或重新导出。我也遇到过因为onnxsimplify过度优化导致算子碎片化的情况,这类问题可以回到原始onnx文件,保留一份没有simplify的版本作对照。

5.2 检测框整体偏移,物体位置不对但分类正确

现象:框能框住东西,但整体往右下或左上偏,框得不准。

原因:坐标映射时忘了减letterbox的padding,或者预处理用了blobFromImage但后处理还按手动归一化的偏移量算。

解决:回到3.3节的公式(x_letter - left) / r,并且确认letterbox返回的left、top和真正fill的像素一致。可以在调试时把letterbox后的图直接保存下来看一眼,检查原图目标在画布上的位置和框的位置关系。

5.3 mask全黑或整片错位

现象:检测框正常,但分割mask输出全0,或者mask贴在完全错的位置。

原因:把检测模型的84维输出当成分割模型的输出,没截取116维后边的32维mask系数;或者proto的通道维度和mask_coeffs的乘法方向搞反,维度对不上,计算结果成了噪声。

解决:打印outputs[0].shape确认是(1,116,8400)而不是(1,84,8400)。mask_coeffs取pred[:, 84:116],proto取outputs[1][0].reshape(32, -1),两者做矩阵乘法时注意把proto放到右边。输出结果先可视化中间态,确认sigmoid后mask最大值在0到1之间。

5.4 CPU推理慢到怀疑人生,五六帧都跑不动

现象:同样的pt模型在PyTorch里跑视频还能忍,转成ONNX反而更慢。

原因:导出时dynamic=True导致ONNXRuntime无法做shape相关的图优化;或者创建Session时没开线程池;也可能模型保留了大量NMS以外的冗余节点。

解决:导出固定dynamic=False和imgsz=640。创建Session时加配置:

sess_options = ort.SessionOptions() sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess_options.intra_op_num_threads = 8 sess = ort.InferenceSession("yolov8n.onnx", sess_options, providers=["CPUExecutionProvider"])

intra_op_num_threads根据实际CPU核数设,不是越大越好,超过物理核反而因线程切换掉性能。

5.5 OpenCV的NMSBoxes返回值时好时坏,解包时常报错

现象:cv2.dnn.NMSBoxes返回的indices有时是一维数组,有时是[[0]]这种二维嵌套,直接flatten有时报错,有时又是空元组。

原因:OpenCV 4.5到4.10之间改过NMSBoxes的返回类型,不同版本对空结果的表现也不一样。

解决:做一层防御式处理,统一转成numpy数组再reshape:

indices = cv2.dnn.NMSBoxes(boxes.tolist(), scores.tolist(), 0.25, 0.45) if indices is None or len(indices) == 0: detections = [] else: indices = np.array(indices).reshape(-1).tolist()

这套写法在4.8和4.10里都能跑,不会因为版本差异翻车。

6. 验证与提速:拿ultralytics结果当标尺,再谈批量推理和量化

6.1 与官方predict结果对比IoU,确认后处理没跑偏

后处理代码写完,第一件事不是接摄像头,而是拿一张验证集图片和ultralytics的官方predict结果对比。写一个简单的IoU函数,把自己的检测框和官方结果逐对比较,IoU大于0.7就算对。

def iou(a, b): x1 = max(a[0], b[0]) y1 = max(a[1], b[1]) x2 = min(a[2], b[2]) y2 = min(a[3], b[3]) inter = max(0, x2 - x1) * max(0, y2 - y1) area_a = (a[2] - a[0]) * (a[3] - a[1]) area_b = (b[2] - b[0]) * (b[3] - b[1]) return inter / (area_a + area_b - inter + 1e-6)

对比时要注意两边用同一张原图、同一个输入尺寸。如果IoU整体都低,先检查letterbox参数是否一致;如果只有个别框对不上,多半是NMS阈值没对齐。

6.2 动态量化、批量推理和线程数:从单张图提速到可接视频流

CPU上最实惠的提速手段是动态量化。用onnxruntime自带的量化工具,不需要准备校准数据集:

from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic("yolov8n.onnx", "yolov8n_quant.onnx", weight_type=QuantType.QUInt8)

量化后的模型体积大约缩到四分之一,CPU推理延迟通常能降20%到40%,代价是mAP轻微下降。实例分割模型也可以量化,但mask分支的输出精度敏感一些,建议量化后专门跑一遍mask可视化确认没退化。

批量推理是另一个方向。把多帧图合成一个batch送给ONNXRuntime,线程利用率更充分,但前提是导出时dynamic=False只固定了输入尺寸,batch维度可以用动态。

我自己的教训是:第一次接视频流时贪图省事,导出开了dynamic=True,结果单帧延迟翻了一倍,排查了两天才意识到是动态shape破坏了图优化。从那以后我固定imgsz,只在最后一个版本确认效果后再提量化。目标检测和实例分割这套ONNX推理链路,只要你把输出维度弄明白、letterbox坐标公式写对,剩下的就是不断量化、对比IoU、调整线程数的体力活。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询