树莓派5部署YOLOv8实战:OpenCV DNN + ONNX实现目标检测

发布时间:2026/9/15 21:53:14
树莓派5部署YOLOv8实战:OpenCV DNN + ONNX实现目标检测
把 YOLOv8 跑在树莓派 5 上听起来很有吸引力但很多人在第一关就被劝退了直接在树莓派上pip install ultralytics然后加载官方yolov8n.pt推理依赖体积变得很大Python 算子调度在 ARM CPU 上也很吃亏内存和 CPU 占用很快被拉满。这其实不是树莓派 5 性能不行而是推理方式选错了。更符合边缘端工程习惯的做法是先在 PC 上把 YOLOv8 导出成 ONNX 模型再把.onnx文件拷贝到树莓派 5用 OpenCV 的 DNN 模块加载推理。相比 PyTorch 推理这个方案依赖少得多、启动快得多内存占用也更可控还能把后处理逻辑完全掌握在自己手里。这篇文章就以“人员检测”为例从模型导出、树莓派 5 环境配置、OpenCV DNN 推理代码到常见坑和性能调优完整走一遍。读完你可以直接复用到图片、摄像头视频流或者替换成其他 YOLOv8 目标类别。1. 为什么在树莓派5上跑 YOLOv8首选 OpenCV DNN1.1 树莓派5的性能定位树莓派 5 使用的是四核 Cortex-A76 架构 CPU相比树莓派 4 的 Cortex-A72 有明显代际提升。对于纯 CPU 推理来说这个提升是实打实的但仍然无法和桌面级处理器或 GPU 相比。跑 YOLOv8 时我们要做的不是压榨出极限帧率而是在有限算力内找到一个可用的平衡点。很多人的第一反应是直接照搬 PC 上的 PyTorch 推理代码。这个方案在 PC 上没有任何问题因为 PyTorch 可以利用 CUDA 加速但在树莓派上PyTorch 的运行时开销和 Python 侧的调度成本会被放大一个十几兆的模型跑起来CPU 占用和内存占用都非常不好看。1.2 三种部署方式对比在树莓派 5 上跑 YOLOv8常见的有三条路方式依赖启动速度内存占用后处理适合阶段ultralytics PyTorch重安装包数百 MB慢高框架内置原型验证、训练调试OpenCV DNN ONNX轻只需 opencv-python快低自己写边缘端部署、摄像头场景ONNX Runtime CPU中等增加 onnxruntime较快中自己写需要更多算子优化时OpenCV DNN 和 ONNX Runtime 都属于“导出一次模型运行时只做前向推理”的思路。OpenCV DNN 的优势是依赖最轻树莓派上只要装一个opencv-python不需要安装和 PyTorch 相关的任何东西SD 卡空间和内存压力都小。对于长期运行的摄像头检测任务这是一个很关键的工程选择。1.3 DNN 版的代价OpenCV DNN 不是没有缺点。YOLOv8 官方输出的是 1 个大的特征张量NMS非极大值抑制需要自己实现或调用cv2.dnn.NMSBoxes比 ultralytics 的封装代码多。另外 OpenCV DNN 对个别新算子的支持可能滞后于 ONNX Runtime所以如果模型导出时把 opset 定得太高可能加载失败。结论很明确在树莓派 5 这种无 GPU、无 NPU 的通用主板场景下opencv-dnn onnx是投入产出比最高的部署方案。2. YOLOv8 检测模型与 DNN 推理原理2.1 YOLOv8 的检测头输出YOLOv8 是 Ultralytics 团队推出的目标检测模型它在模型结构上去掉了早期 YOLO 版本的 Anchor 机制改成了 Anchor-Free 设计。对部署者来说不需要深刻理解每个模块但必须知道 ONNX 模型的输出格式。以官方yolov8n.pt、COCO 80 类、输入尺寸 640x640 为例导出后的 ONNX 模型输出 shape 是[1, 84, 8400]其中1是 batch size84 4 80前 4 个值是边框的cx, cy, width, height后 80 个值是对应 COCO 类别的置信度8400是不同尺度下所有检测锚点的总数。8400这个数字来自三个检测层的锚点数量之和80x80 40x40 20x20 6400 1600 400 8400。输入尺寸越大这个数字越大如果导出时指定imgsz320锚点总数会相应减少。2.2 DNN 推理的完整流程用 OpenCV DNN 推理 YOLOv8完整链路可以拆成五步读取一帧图像做 letterbox 缩放填充到模型输入尺寸 640x640构建 blob 输入并执行一次net.forward()把输出从[1, 84, 8400]转成[8400, 84]然后做置信度过滤、坐标解码、NMS把检测框坐标从 640x640 空间映射回原始图像空间。这五步中最容易出问题的就是第 5 步。很多人会在 640x640 的图像上画出正确的框但一映射回原图就错位原因通常是 letterbox 的scale和pad没有正确传递到后处理。2.3 与 ultralytics 推理的差别使用 ultralytics 官方推理时很多步骤被封装掉了。例如from ultralytics import YOLO model YOLO(yolov8n.pt) results model(frame)这一行背后包含了图像缩放、前向推理、置信度过滤、NMS、坐标映射等全部逻辑。看起来方便但在边缘设备上这种“黑盒”会带来两个问题每次调用都要经过多层 Python 封装CPU 开销大如果模型文件较大PyTorch 的运行时内存很难控制。OpenCV DNN 方案虽然多写一些代码但每一步都透明可控这对树莓派这种资源敏感环境非常友好。3. 环境准备PC 导出 ONNX 树莓派5 部署3.1 PC 端安装 ultralytics 并导出模型不推荐在树莓派上做模型导出。ultralytics会附带torch在 ARM 平台安装慢、体积大而且导出本身是离线离线任务不需要在目标设备上做。先在 PC 上安装pip install ultralytics然后执行导出命令yolo export modelyolov8n.pt formatonnx opset12 imgsz640如果更习惯写 Python也可以这样from ultralytics import YOLO model YOLO(yolov8n.pt) model.export(formatonnx, opset12, imgsz640)执行完成后当前目录会出现yolov8n.onnx。如果你不需要训练自己的权重也可以直接下载官方发布好的yolov8n.onnx本质上是一样的。导出时建议指定opset12。opset 太高树莓派上较旧的 OpenCV 可能无法解析opset12 是兼容性和算子支持都比较稳的选择。3.2 树莓派5 端安装 OpenCV树莓派 5 推荐使用 64 位 Raspberry Pi OSPython 环境默认是 3.11 左右。安装 OpenCV 有两种方式# 方式一pip 安装版本较新 pip install opencv-python # 方式二apt 安装更稳妥但版本可能旧一些 sudo apt update sudo apt install -y python3-opencv建议优先用 pip 安装因为 OpenCV DNN 对新版 ONNX 算子的支持会更好。如果下载速度慢可以换国内镜像pip install opencv-python -i https://pypi.tuna.tsinghua.edu.cn/simple3.3 拷贝模型到树莓派把生成的yolov8n.onnx拷贝到树莓派最简单的方式是 scpscp yolov8n.onnx pi树莓派IP:~/rpi5_yolov8_dnn/如果树莓派上还没有项目目录先创建mkdir -p ~/rpi5_yolov8_dnn完成之后可以用下面的命令确认 OpenCV 已经可用python3 -c import cv2; print(cv2.__version__)只要输出正常的版本号环境准备就算完成了。整个过程不需要安装 PyTorch也不需要安装 ultralytics。4. 使用 OpenCV DNN 实现人员检测的完整代码4.1 代码设计思路下面的代码会实现一个完整的人员检测器支持三种输入摄像头python3 yolov8_dnn_person_detector.py 0图片python3 yolov8_dnn_person_detector.py test.jpg视频文件python3 yolov8_dnn_person_detector.py test.mp4代码拆成preprocess、postprocess、draw_person、main四个部分。COCO 数据集中 person 的类别 ID 是 0所以只检测人时直接在后处理阶段使用PERSON_CLASS_ID 0过滤即可。4.2 完整代码#!/usr/bin/env python3 # 文件路径rpi5_yolov8_dnn/yolov8_dnn_person_detector.py import sys import cv2 import numpy as np PERSON_CLASS_ID 0 def preprocess(img, input_size640): letterbox 缩放 填充并生成 DNN 需要的 blob。 返回 blob、缩放比例 scale、以及 padding 信息 (left, top)。 h, w img.shape[:2] scale min(input_size / h, input_size / w) new_w int(round(w * scale)) new_h int(round(h * scale)) pad_w (input_size - new_w) / 2 pad_h (input_size - new_h) / 2 resized cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_LINEAR) top int(round(pad_h - 0.1)) bottom int(round(pad_h 0.1)) left int(round(pad_w - 0.1)) right int(round(pad_w 0.1)) padded cv2.copyMakeBorder( resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value(114, 114, 114) ) blob cv2.dnn.blobFromImage( padded, scalefactor1.0 / 255.0, size(input_size, input_size), mean(0, 0, 0), swapRBTrue, cropFalse ) return blob, scale, (left, top) def postprocess(outs, scale, pad, conf_threshold0.5, nms_threshold0.45): 对 YOLOv8 的 ONNX 输出做后处理 1. 转置为 [8400, 84] 2. 置信度过滤 3. cxcywh 转 xyxy 4. 坐标从 640x640 空间映射回原图 5. NMS preds np.transpose(outs, (0, 2, 1))[0] boxes_cxcywh preds[:, :4] class_scores preds[:, 4:] class_ids np.argmax(class_scores, axis1) confidences class_scores[np.arange(len(class_ids)), class_ids] mask confidences conf_threshold if not np.any(mask): return [], [], [] boxes_cxcywh boxes_cxcywh[mask] confidences confidences[mask] class_ids class_ids[mask] x1 boxes_cxcywh[:, 0] - boxes_cxcywh[:, 2] / 2.0 y1 boxes_cxcywh[:, 1] - boxes_cxcywh[:, 3] / 2.0 x2 boxes_cxcywh[:, 0] boxes_cxcywh[:, 2] / 2.0 y2 boxes_cxcywh[:, 1] boxes_cxcywh[:, 3] / 2.0 boxes_xyxy np.stack([x1, y1, x2, y2], axis1) # 从 640x640 letterbox 空间映射回原图 boxes_xyxy[:, [0, 2]] (boxes_xyxy[:, [0, 2]] - pad[0]) / scale boxes_xyxy[:, [1, 3]] (boxes_xyxy[:, [1, 3]] - pad[1]) / scale # OpenCV NMSBoxes 需要 [x, y, w, h] 格式 boxes_xywh np.stack([ boxes_xyxy[:, 0], boxes_xyxy[:, 1], boxes_xyxy[:, 2] - boxes_xyxy[:, 0], boxes_xyxy[:, 3] - boxes_xyxy[:, 1] ], axis1) indices cv2.dnn.NMSBoxes( boxes_xywh.tolist(), confidences.tolist(), conf_threshold, nms_threshold ) if indices is None: return [], [], [] indices np.array(indices).reshape(-1).tolist() result_boxes [] result_scores [] result_ids [] for i in indices: result_boxes.append(boxes_xyxy[i]) result_scores.append(confidences[i]) result_ids.append(class_ids[i]) return result_boxes, result_scores, result_ids def draw_person(frame, boxes, scores, class_ids): for box, score, cls_id in zip(boxes, scores, class_ids): if cls_id ! PERSON_CLASS_ID: continue x1, y1, x2, y2 [int(v) for v in box] # 限制在画面范围内 x1 max(0, min(x1, frame.shape[1] - 1)) y1 max(0, min(y1, frame.shape[0] - 1)) x2 max(0, min(x2, frame.shape[1] - 1)) y2 max(0, min(y2, frame.shape[0] - 1)) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 0, 255), 2) label fperson {score:.2f} cv2.putText( frame, label, (x1, max(20, y1 - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2 ) def main(): model_path yolov8n.onnx if len(sys.argv) 2: source 0 else: source sys.argv[1] conf_th float(sys.argv[2]) if len(sys.argv) 2 else 0.5 net cv2.dnn.readNetFromONNX(model_path) net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV) net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # 树莓派 5 默认 4 核这里显式指定线程数 cv2.setNumThreads(4) # 图片模式 if isinstance(source, str) and source.lower().endswith( (.jpg, .jpeg, .png, .bmp) ): frame cv2.imread(source) if frame is None: print(无法读取图片:, source) return blob, scale, pad preprocess(frame) net.setInput(blob) outs net.forward() boxes, scores, ids postprocess(outs, scale, pad, conf_th) draw_person(frame, boxes, scores, ids) cv2.imwrite(result.jpg, frame) person_count len([i for i in ids if i PERSON_CLASS_ID]) print(检测到, person_count, 个人结果已保存为 result.jpg) return # 摄像头或视频模式 cap cv2.VideoCapture(source) if not cap.isOpened(): print(无法打开摄像头或视频文件) return while True: ret, frame cap.read() if not ret: break blob, scale, pad preprocess(frame) net.setInput(blob) outs net.forward() boxes, scores, ids postprocess(outs, scale, pad, conf_th) draw_person(frame, boxes, scores, ids