☰
RK3588实战:YOLOv5s接入USB与MIPI CSI摄像头实现单帧推理
2026/9/29 4:56:11 网站建设 项目流程

1. 从模型推理到真实画面:为什么这一步是分水岭

前面几篇我们把 YOLOv5s 在香橙派 RK3588 上跑通了,模型能加载、能推理、能输出检测框,但用的都是本地图片或者构造的假数据。说实话,这种跑通只能算“环境验证”,离真正能用的东西还差一步——让模型看到真实世界的画面。这一步就是把摄像头接进来,抓一帧,送进推理管线,拿到检测结果。

为什么说这一步是分水岭?因为一旦摄像头通了,你后面能做的事情就完全不一样了:可以做实时目标检测、可以做视频流分析、可以做边缘端的智能监控原型。而且摄像头这条链路涉及的东西比纯推理多得多——V4L2 设备节点、MIPI CSI 或 USB 的枚举方式、OpenCV 的采集后端、像素格式转换、分辨率与帧率的权衡,每一个环节都可能让你卡住。

这篇内容适合谁看?如果你已经跟着前面的教程把 YOLOv5s 在 RK3588 上跑起来了,现在想接摄像头做真实推理,那这篇就是给你写的。如果你还没跑通模型推理,建议先回去把前面的环境搭好,不然接上摄像头你也验证不了结果。另外,如果你用的是香橙派 5 或者其他 RK3588 开发板,操作逻辑基本一致,只是设备节点名称和摄像头模组可能有差异。

我实测用的是香橙派 5(RK3588),系统是 Ubuntu 20.04,摄像头用的是 USB 免驱摄像头和 MIPI CSI 的 OV5647 模组各试了一遍。两种方式各有坑,后面会分别讲。OpenCV 用的是系统自带的 Python 版本,YOLOv5 是官方仓库的 v7.0 分支,推理后端走的是 RKNN。

提示:这篇的核心目标是“抓一帧并推理”,不是做实时视频流。先把单帧链路打通,再扩展到连续帧会稳很多。很多人一上来就搞实时流,结果采集、推理、显示三个环节互相干扰,出了问题根本不知道是哪一层的事。

2. 摄像头接入方案选型:USB 还是 MIPI CSI

2.1 两种接口的本质差异

RK3588 支持多种摄像头输入方式,最常见的就是 USB 摄像头和 MIPI CSI 摄像头。这两者在硬件层面完全不同,软件层面的枚举方式和设备节点也不一样。

USB 摄像头走的是 USB 协议,插上之后内核通过 UVC(USB Video Class)驱动自动识别,通常不需要额外配置。设备节点一般是/dev/video*,具体是哪个号取决于你插了几个摄像头以及系统启动时的枚举顺序。优点是即插即用,兼容性好,随便拿一个几十块的 USB 摄像头就能用。缺点是带宽受 USB 限制,高分辨率下帧率上不去,而且延迟相对较大。

MIPI CSI 摄像头走的是 MIPI 接口,直接连到 RK3588 的 ISP 或 VICAP 控制器上。这种方式的带宽高、延迟低,适合高分辨率高帧率场景。但问题是驱动配置复杂,不同模组的设备树(DTS)配置不一样,内核里要有对应的 sensor 驱动。OV5647 是比较常见的 MIPI CSI 模组,树莓派上用得很多,但在 RK3588 上需要确认内核是否已经使能了对应的驱动。

我个人的建议是:如果你只是想快速验证推理链路,先用 USB 摄像头。等链路通了,再换成 MIPI CSI 做性能优化。这样排错成本最低。

2.2 设备节点确认与权限处理

不管用哪种摄像头,第一步都是确认设备节点。插上 USB 摄像头后,执行:

ls /dev/video*

你会看到类似/dev/video0、/dev/video1这样的节点。有些 USB 摄像头会枚举出两个节点,一个是视频采集节点,一个是元数据节点。通常video0是采集节点,但也不绝对。

用v4l2-ctl可以查看设备能力:

v4l2-ctl --device=/dev/video0 --info

输出里会显示Device Caps,如果看到Video Capture就说明这个节点支持采集。还可以列出支持的格式:

v4l2-ctl --device=/dev/video0 --list-formats-ext

这一步很关键,因为不同摄像头支持的像素格式不一样。常见的有YUYV、MJPG、NV12等。OpenCV 采集时如果格式不匹配,可能会拿到花屏或者直接失败。

权限方面,普通用户默认可能没有访问/dev/video*的权限。你可以把自己加到video组:

sudo usermod -aG video $USER

然后重新登录生效。或者临时用sudo跑,但长期来看加组更规范。

注意:如果你用的是 MIPI CSI 摄像头,设备节点可能不是/dev/video0,而是类似/dev/video11这样的高位节点。而且需要确认内核加载了对应的 sensor 驱动,可以用dmesg | grep -i ov5647或者dmesg | grep -i csi来看内核日志。

3. OpenCV 采集环境搭建与验证

3.1 OpenCV 安装方式选择

在 RK3588 的 Ubuntu 20.04 上装 OpenCV,有几种方式:apt 直接装、pip 装、源码编译。我推荐先用 apt 装系统包,因为最省事,而且和系统库的兼容性最好:

sudo apt update sudo apt install python3-opencv

装完之后验证:

import cv2 print(cv2.__version__)

如果输出了版本号,比如4.2.0,就说明装好了。apt 版本的 OpenCV 可能不是最新的,但对于我们抓帧推理来说完全够用。

如果你需要更新的版本或者特定的功能(比如 CUDA 支持,虽然 RK3588 上用不上),可以考虑 pip 装:

pip3 install opencv-python

但 pip 版本在某些 ARM 平台上可能会有依赖问题,比如缺少libGL之类的。遇到的话补一下:

sudo apt install libgl1-mesa-glx libglib2.0-0

源码编译是最灵活的,但也是最耗时的,RK3588 上编译一次 OpenCV 可能要一两个小时。除非你有特殊需求,否则没必要。

3.2 用 OpenCV 抓一帧并保存

装好 OpenCV 之后,先别急着接 YOLOv5,单独验证摄像头采集是否正常。写一个最简单的脚本:

import cv2 cap = cv2.VideoCapture(0) if not cap.isOpened(): print("摄像头打开失败") exit() ret, frame = cap.read() if ret: cv2.imwrite("test_frame.jpg", frame) print("抓帧成功,尺寸:", frame.shape) else: print("抓帧失败") cap.release()

这里cv2.VideoCapture(0)里的0对应/dev/video0。如果你的是video1,就改成1。跑通之后你会得到一个test_frame.jpg,打开看看画面是否正常。

这一步看起来简单,但实际踩坑的人不少。常见问题包括:摄像头被其他进程占用、权限不足、像素格式不匹配导致花屏、曝光没调好导致全黑或全白。后面会专门讲排查。

3.3 指定分辨率与像素格式

默认情况下 OpenCV 会用自己的默认参数打开摄像头,可能是 640x480 的 YUYV 格式。但有些摄像头默认输出 MJPG,OpenCV 如果不指定可能会拿到异常帧。你可以显式设置:

cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc('M', 'J', 'P', 'G'))

设置完之后最好读一下实际生效的值:

print(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) print(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))

因为摄像头不一定支持你设的分辨率,可能会回退到最接近的值。这个习惯很重要,不然你以为采的是 1080p,实际是 640x480,后面推理的输入尺寸对不上。

提示:YOLOv5s 的默认输入是 640x640,所以采集分辨率不需要太高。1280x720 足够了,再高只是浪费带宽和内存。如果你做的是小目标检测,可以适当提高采集分辨率,但推理时还是要 resize 到模型输入尺寸。

4. 把采集帧送进 YOLOv5 推理管线

4.1 从 OpenCV 帧到模型输入的转换

OpenCV 抓到的帧是 BGR 格式的 numpy 数组,形状是(H, W, 3)。YOLOv5 的推理管线通常期望 RGB 格式,而且需要做归一化和维度变换。如果你用的是 RKNN 的推理接口,还需要把数据转成 NHWC 或 NCHW 的格式,具体取决于模型转换时的配置。

一个典型的转换流程是这样的:

import cv2 import numpy as np def preprocess(frame, input_size=640): # BGR 转 RGB img = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) # resize 到模型输入尺寸 img = cv2.resize(img, (input_size, input_size)) # 归一化到 0-1 img = img.astype(np.float32) / 255.0 # 增加 batch 维度 img = np.expand_dims(img, axis=0) return img

如果你用的是 RKNN 的 Python 接口,可能还需要把数据转成uint8并且保持 NHWC 格式,因为 RKNN 的inference接口对输入格式有要求。具体要看你的模型转换时用的是哪个配置。

这里有个容易忽略的点:YOLOv5 官方仓库的推理代码默认用的是 letterbox 缩放,不是直接 resize。letterbox 会保持宽高比,用灰边填充到正方形。如果你直接 resize,检测框的位置会有偏差。所以更严谨的做法是:

def letterbox(img, new_shape=640, color=(114, 114, 114)): shape = img.shape[:2] r = min(new_shape / shape[0], new_shape / shape[1]) new_unpad = (int(round(shape[1] * r)), int(round(shape[0] * r))) dw = new_shape - new_unpad[0] dh = new_shape - new_unpad[1] dw /= 2 dh /= 2 img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR) 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(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color) return img

这段代码看起来有点绕,但核心思想很简单:按比例缩放,然后补边到正方形。这样检测框映射回原图时只需要反向计算缩放比例和偏移量,不会变形。

4.2 推理结果的后处理与坐标映射

模型输出的是归一化的检测框坐标(cx, cy, w, h)、置信度和类别概率。后处理要做的事情包括:置信度过滤、NMS(非极大值抑制)、坐标反归一化、映射回原图尺寸。

RKNN 的输出格式和 ONNX 可能略有不同,具体取决于你转换模型时的配置。一般来说,YOLOv5 的输出是三个尺度的特征图,每个位置有(5 + num_classes)个值,其中 5 是(cx, cy, w, h, obj_conf)。

后处理的核心步骤:

def postprocess(outputs, conf_thres=0.25, iou_thres=0.45): # 解码检测框 # 过滤低置信度 # NMS # 返回检测框列表 pass

这部分代码比较长,建议直接参考 YOLOv5 官方仓库的utils/general.py里的non_max_suppression函数。如果你用的是 RKNN 的示例代码,通常也会带一个后处理脚本,可以直接拿来改。

坐标映射的关键是记住 letterbox 时的缩放比例和填充量。假设原图是(H, W),letterbox 之后是(640, 640),缩放比例是r,填充量是(dw, dh)。那么检测框映射回原图的公式是:

x_orig = (x_letterbox - dw) / r y_orig = (y_letterbox - dh) / r

这个计算一定要做对,不然画出来的框会偏。

4.3 完整链路串起来

把采集、预处理、推理、后处理、画框串起来,大概是这样:

import cv2 import numpy as np cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) ret, frame = cap.read() if not ret: print("抓帧失败") exit() # 预处理 img = letterbox(frame, 640) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = img.astype(np.float32) / 255.0 img = np.expand_dims(img, axis=0) # 推理(这里用你的 RKNN 推理接口) # outputs = rknn.inference(inputs=[img]) # 后处理 # detections = postprocess(outputs) # 画框 # for det in detections: # x1, y1, x2, y2, conf, cls = det # cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite("result.jpg", frame) cap.release()

实际跑的时候,推理那一步会替换成你的 RKNN 调用。如果你前面已经把 YOLOv5s 的 RKNN 推理跑通了,这里只需要把输入从图片换成摄像头帧就行。

注意:RKNN 的推理接口通常要求输入是uint8或者int8,具体取决于模型量化时的配置。如果你在预处理时做了/255.0归一化,但模型期望的是uint8输入,结果会完全错误。这个一定要对照模型转换时的配置来。

5. 常见问题与排查技巧实录

5.1 摄像头打开失败或抓帧返回空

这是最常见的问题。排查顺序如下:

现象可能原因排查方法
cap.isOpened()返回 False设备节点不对ls /dev/video*确认节点号
打开成功但read()返回 False权限不足ls -l /dev/video0看权限
打开成功但画面全黑曝光未生效或镜头盖没开用手电筒照一下镜头
画面花屏或条纹像素格式不匹配v4l2-ctl --list-formats-ext查看支持格式
画面卡顿或延迟高分辨率过高或 USB 带宽不足降低分辨率或换 USB 3.0 口

我遇到过一次抓帧一直失败,最后发现是摄像头被另一个进程占用了。用fuser /dev/video0可以查看哪个进程在占用。杀掉之后就好了。

还有一个坑是:有些 USB 摄像头在 OpenCV 里默认走的是 YUYV 格式,但实际输出的是 MJPG,导致花屏。解决办法是显式设置 FOURCC:

cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc('M', 'J', 'P', 'G'))

5.2 MIPI CSI 摄像头不识别

MIPI CSI 摄像头的问题通常出在驱动和设备树。先确认内核有没有加载 sensor 驱动:

dmesg | grep -i ov5647 dmesg | grep -i csi dmesg | grep -i rkisp

如果没有任何输出,说明驱动没加载。可能是内核配置里没使能对应的 sensor 驱动,或者设备树里没有正确配置。这种情况需要重新编译内核或修改 DTS,门槛比较高。

另一个常见问题是设备节点不对。MIPI CSI 摄像头可能枚举成/dev/video11或更高的号,而不是video0。可以用v4l2-ctl --list-devices列出所有设备,找到对应的节点。

5.3 推理结果坐标偏移

如果你发现画出来的框位置不对,大概率是 letterbox 的坐标映射算错了。检查两点:一是缩放比例r是否用了正确的原图尺寸,二是填充量dw和dh是否除了 2。这两个地方最容易出错。

还有一个可能是模型输出的坐标格式和你以为的不一样。有些 RKNN 转换后的模型输出的是(x1, y1, x2, y2),有些是(cx, cy, w, h)。这个要看模型转换时的配置和导出脚本。

5.4 推理速度慢

单帧推理如果超过 500ms,说明有问题。RK3588 的 NPU 跑 YOLOv5s 应该在几十毫秒级别。慢的原因可能是:模型没跑在 NPU 上(跑在 CPU 上了)、输入尺寸太大、后处理用了 Python 循环导致效率低。

检查模型是否跑在 NPU 上,可以看 RKNN 初始化时的日志,或者用rknn.query查一下。后处理如果太慢,可以考虑用 numpy 向量化操作替代循环,或者用 C++ 写后处理。

提示:如果你只是验证链路,单帧推理慢一点无所谓。但如果要做实时检测,后处理的优化和采集、推理的流水线设计就很重要了。可以考虑用多线程:一个线程采集,一个线程推理,一个线程显示。

6. 从单帧到连续帧的扩展思路

单帧跑通之后,扩展到连续帧其实不难,核心就是把采集和推理放到循环里。但直接串行跑会有问题:采集一帧、推理一帧、显示一帧,帧率会被最慢的环节拖累。如果推理要 50ms,采集要 30ms,显示要 10ms,那整体帧率就只有 11fps 左右。

更好的做法是用生产者-消费者模型:一个线程专门采集,把帧放进队列;另一个线程从队列取帧做推理;主线程负责显示。这样采集和推理可以并行,帧率能提升不少。

import threading import queue frame_queue = queue.Queue(maxsize=2) def capture_thread(): cap = cv2.VideoCapture(0) while True: ret, frame = cap.read() if ret: if frame_queue.full(): frame_queue.get() frame_queue.put(frame) def inference_thread(): while True: frame = frame_queue.get() # 推理并画框 # ... threading.Thread(target=capture_thread, daemon=True).start() threading.Thread(target=inference_thread, daemon=True).start()

队列大小设为 2 是为了避免积压太多旧帧。如果推理跟不上采集,队列会满,这时候丢弃旧帧比堆积更好,因为实时检测关心的是当前画面,不是几秒前的画面。

另外,显示环节如果不需要实时预览,可以只保存结果或者通过网络发送出去。RK3588 上有硬件编码器,可以用 FFmpeg 或者 GStreamer 做推流,把检测结果实时传出去。这个后面可以单独展开讲。

我在实际使用中的体会是:先把单帧链路跑通,再考虑性能优化。很多人一上来就搞多线程、搞流水线,结果出了问题根本不知道是哪一层的事。单帧跑通了,至少证明采集、预处理、推理、后处理、画框这条链路是通的,后面优化才有基础。

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

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

立即咨询