1. 摄像头接到RK3588之前先想清楚这三件事1.1 接口选型USB、MIPI还是RTSP网络摄像头这个教程系列写到第9篇前面的工作基本都聚焦在模型本身——训练、转换、部署。现在要给YOLOv5s示例接上摄像头很多朋友第一反应是插上摄像头然后OpenCV一把梭但你手上那块香橙派5用的RK3588摄像头接入方式跟普通x86开发板不太一样选型错了后面全是坑。RK3588的摄像头接口有三条路USB摄像头UVC协议最省心插上就能用OpenCV直接读。缺点是分辨率高时带宽占用大到4K30帧基本就到顶了再高容易掉帧。MIPI CSI摄像头比如树莓派OV5647模块或者官方RK3588 Camera Module走的是板载MIPI CSI接口延迟低、带宽大但驱动适配是个大问题——RK3588的CSI驱动需要dts设备树配置官方固件对某些第三方MIPI模块支持不好经常出现认不到设备的情况。RTSP网络摄像头海康、大华这类摄像头走RTSP协议通过网线或Wi-Fi传输OpenCV的VideoCapture也能直接读RTSP流。好处是部署灵活坏处是网络抖动可能导致丢帧而且首帧延迟通常在几百毫秒以上。我做这个教程时的建议是先用手头最容易接通的USB摄像头跑通整个链路等推理管线稳定了再考虑换MIPI或者RTSP。原因很简单——RK3588的NPU驱动、yolov5s的rknn模型、OpenCV的摄像头读取三个环节互相依赖你一次只引入一个变量出问题才好定位。顺带说一句如果是用OV5647这种MIPI模块务必确认你的固件内核版本和dts里是否已经包含了对应的camera节点。可以用下面的命令检查dmesg | grep -i camera ls /dev/video* # 看是否有video0/video1节点生成如果/dev/video0都没出现那说明驱动层就没起来光调OpenCV是永远打不开摄像头的。1.2 推理引擎的铺垫为什么教程一路走到这里都在讲rknn如果你是从第1篇一路跟过来的应该已经习惯了RK3588的NPU部署流程先把PyTorch模型的.pt权重导出为.onnx然后用rknn-toolkit2工具链转成RK3588专用的.rknn格式最后用librknnrt运行时库加载推理。这一步绝不是绕远路。RK3588的NPU算力标称6 TOPS虽然跟现在动辄几十TOPS的旗舰平台比不了但跑一个yolov5s的640x640输入INT8量化之后单帧推理大概只要30~50毫秒也就是20~30 FPS的水平。而如果你把PyTorch模型原封不动扔到CPU上跑A76大核全开也就2~3 FPS差距接近十倍。所以本篇教程的核心思路很简单摄像头负责抓帧NPU负责推理两者通过一帧图片交接。我们要做的就是把抓帧这一步从图片文件换成摄像头实时画面其余推理链路完全复用之前已经验证过的rknn推理流程。1.3 明确本教程要解决的问题边界在动手之前把范围划清楚能省掉大量无意义的调试时间。本篇要做的核心目标是初始化摄像头设备获取视频流从视频流中取出一帧图像存成内存中的帧数据将这一帧送入已转换好的yolov5s rknn模型做前向推理拿到检测结果后画框标注、打印目标类别和置信度。刻意不做的事包括连续视频流推理、多线程性能优化、摄像头自动重连、RTSP拉流推流。这些内容后面单独开一篇讲本篇先把抓一帧→推理→画框这条主链跑通建立整个系统的基线。注意这个系列所有示例都是基于香橙派5RK3588系统是官方提供的Ubuntu 20.04镜像Python环境用的3.8推理运行时用的是rknn-toolkit2配套的librknnrt版本。不同固件、不同rknn版本之间的兼容性确实会有差异如果跑起来报错建议先对照一下环境版本。2. 从打开摄像头到拿到一帧图OpenCV V4L2踩坑记录2.1 最小可用代码三行打开摄像头但细节全在参数里在RK3588上读USB摄像头最基础的OpenCV代码如下import cv2 cap cv2.VideoCapture(0) # 0表示第一个video设备 if not cap.isOpened(): print(Failed to open camera) exit(1) ret, frame cap.read() if not ret: print(Failed to grab frame) exit(1) cv2.imwrite(test_frame.jpg, frame) cap.release()很多新手在这一步就会栽跟头。cv2.VideoCapture(0)打不开最常见的原因不是代码问题而是设备节点冲突或权限不足检查设备节点是否被占用比如你开了VLC或者gstreamer测试工具设备会被独占检查用户是否在video组里如果不在执行sudo usermod -aG video $USER然后重新登录检查V4L2后端是否加载了OpenCV编译时如果只带了FFmpeg后端对V4L2设备的支持会有些微妙的问题。我建议在跑Python之前先用命令行工具验证一遍摄像头本身能不能出图# 查看设备节点和格式支持 v4l2-ctl --list-devices v4l2-ctl --list-formats-ext -d /dev/video0这条命令会列出摄像头支持的所有分辨率和帧率比如常见的640x48030、1920x108030。强烈建议先用v4l2-ctl确认摄像头支持的分辨率再把它填到代码里避免OpenCV设置时悄悄失败却不报错。2.2 设置分辨率和帧率摄像头参数请求是个尽力而为的过程有朋友会问上面的代码直接read()不就行了为什么还要特意设置分辨率原因是默认分辨率可能不是你想要的。很多USB摄像头默认输出是640x480或320x240画质很糊。而yolov5s训练默认输入是640x640如果源图像分辨率太低小目标完全没法检测。设置参数的正确姿势import cv2 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_FPS, 30) # 设置完之后要读出来确认一下 print(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) print(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) print(cap.get(cv2.CAP_PROP_FPS))这里有一个真实的坑很多摄像头驱动对分辨率请求不会直接拒绝而是悄悄选择一个最接近的格式。你请求1280x720它可能返回1920x1080或者640x480而不报任何错误。所以设置之后立刻get()读回确认是必要操作。对于yolov5s推理来说我实测下来1280x72030是一个不错的平衡点——图像细节足够带宽占用不大而且转成640x640时下采样倍数整齐2倍letterbox填充量小。如果你的摄像头只有1080p也行一样能跑只是预处理时缩放系数会不同。2.3 最隐蔽的坑VideoCapture的帧缓冲导致读到的永远不是当前帧这是本篇踩过最深的坑单独拿出来讲。OpenCV的VideoCapture在读取USB摄像头时内部会维护一个环形帧缓冲默认可能有1~4帧的积压。你调用cap.read()时拿到的是缓冲队列里最老的那一帧而不是摄像头传感器当前的画面。换句话说如果现场有一个行人从画面中走过你按了抓帧但拿到的可能是行人进入画面之前的那一帧。对于静态场景无所谓但对于实时检测场景这一下延迟可能就是致命的。解决办法是手动清空缓冲import cv2 def grab_fresh_frame(cap): 丢弃缓冲帧拿最新一帧 for _ in range(5): ret, frame cap.read() return frame更严谨的做法是配合grab()和retrieve()def grab_fresh_frame(cap): for _ in range(5): cap.grab() # 只解码到缓冲不返回图像 ret, frame cap.retrieve() # 取最新帧 return frame实测在香橙派5上清5帧缓冲后拿到的图像延迟比直接read()低一帧左右目标位置更接近实时状态。2.4 抓帧频率和推理耗时的关系一帧一帧读还是持续抓既然是抓一帧并推理最简单的方式就是循环执行抓帧推理看起来逻辑也没有问题while True: frame grab_fresh_frame(cap) results detect(frame) # 显示或处理但如果你单独跑一次抓帧推理想测单帧性能注意第一次cap.read()的耗时可能异常高——摄像头驱动初始化、帧缓冲预热都算在这第一帧里后面帧的读取会稳定在5~20毫秒。所以做性能测试时先抓10帧暖机从第11帧开始计时这样数据才可信。3. YOLOv5s推理链路拆解从内存帧到检测框的完整数据流3.1 预处理不要直接resize先letterbox保比例yolov5系列训练时的输入是640x640的正方形但摄像头输出是16:9或4:3的矩形图像。如果直接把1280x720的图像cv2.resize()成640x640画面会被拉伸变形检测框的坐标映射也会出错。yolov5的标准做法是letterbox保持原始宽高比把图像缩放到640x640的框内剩余部分用灰色填充通常是114,114,114。import cv2 import numpy as np def letterbox(img, new_shape(640, 640), color(114, 114, 114)): shape img.shape[:2] # H, W r min(new_shape[0] / shape[0], new_shape[1] / shape[1]) new_unpad int(round(shape[1] * r)), int(round(shape[0] * r)) dw (new_shape[1] - new_unpad[0]) / 2 dh (new_shape[0] - new_unpad[1]) / 2 if shape[::-1] ! new_unpad: img cv2.resize(img, new_unpad, interpolationcv2.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, valuecolor) return img, r, dw, dh注意返回三个值变换后的图像、缩放比例r、填充偏移dw/dh。后面画框时要把检测框坐标反算回原始图像坐标这三个值缺一不可。这也是很多新手容易漏的地方——在letterbox后的图上画框看起来是准的但映射回原图就错位了。3.2 NPU推理HWC还是CHWRBG还是BGR差一点结果全错yolov5s在RK3588 NPU上推理时rknn模型对输入张量的布局有严格要求。和PyTorch常用的NCHW不同RKNN的输入默认是NHWC格式而且通道顺序要看你在转模型时指定的是RGB还是BGR。以rknn-toolkit2生成的RK3588模型为例常见的加载和推理代码如下from rknn.api import RKNN rknn RKNN() rknn.load_rknn(yolov5s.rknn) rknn.init_runtime(targetrk3588) # 假设模型输入是 RGB, NHWC img_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) img_resized, ratio, dw, dh letterbox(img_rgb) img_input img_resized.astype(np.uint8)[None, ...] # (1, H, W, 3) outputs rknn.inference(inputs[img_input])注意这里把BGR转成RGB——OpenCV读出的图像是BGR而训练时的图像是RGB如果省略这一行检测准确率会急剧下降而且看起来模型没坏就是检测不到目标非常迷惑。另一个容易踩的坑是输入尺寸匹配。有些RKNN转换工具在导出时会把输入固定成640x640有的支持动态形状。如果调用inference()时报shape mismatch检查两个地方一是模型转换时填的mean_values和std_values二是letterbox后的实际尺寸。如果转换时输入尺寸写的是640那你送入的必须是640x640差一个像素都不行。3.3 后处理rknn的yolov5输出不是你想要的框rknn推理输出的原始outputs是一个列表包含多个特征图。对于yolov5s通常是三个尺度的输出每个是一张(1, 255, 80, 80)或(1, 255, 40, 40)、(1, 255, 20, 20)NHWC下对应(1, 80, 80, 255)其中255 3锚点 × (5 80类)。你需要做的是解析每个尺度的输出把网格坐标、锚点信息还原为候选框坐标对候选框做置信度过滤通常conf_thres0.25做NMS非极大值抑制去掉重叠框把NMS后的框从letterbox坐标反算到原图坐标。这一步如果完全从零写代码量不小。更省事的方案是直接用rknn-toolkit2自带的rknn.api示例代码或者复用yolov5官方仓库的non_max_suppression函数改一改输入格式。我个人在香橙派5上实测的结果是python后处理部分大约耗时5~8ms比NPU推理还慢一点。如果想提速可以把后处理换成C扩展或用numpy向量化。但本篇先不优化先把链路跑通。3.4 完整抓帧推理代码从摄像头到画框一条龙下面给出一个可直接运行的最小完整示例。假设你已经有一个转换好的yolov5s.rknn模型。import cv2 import numpy as np from rknn.api import RKNN def letterbox(img, new_shape(640, 640), color(114, 114, 114)): # 前面给的实现 pass def xywh2xyxy(x): y x.clone() if hasattr(x, clone) else np.copy(x) y[..., 0] x[..., 0] - x[..., 2] / 2 y[..., 1] x[..., 1] - x[..., 3] / 2 y[..., 2] x[..., 0] x[..., 2] / 2 y[..., 3] x[..., 1] x[..., 3] / 2 return y def main(): # 1. 初始化RKNN rknn RKNN() rknn.load_rknn(yolov5s.rknn) rknn.init_runtime(targetrk3588) # 2. 打开摄像头 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_FPS, 30) # 暖机 清空缓冲 for _ in range(10): cap.grab() ret, frame cap.retrieve() if not ret: print(Camera error) return # 3. 预处理 img_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) img_input, ratio, dw, dh letterbox(img_rgb, (640, 640)) # 4. NPU推理 outputs rknn.inference(inputs[img_input[None, ...].astype(np.uint8)]) # 5. 后处理这里省略了详细的NMS实现按你的模型输出格式处理 # boxes, scores, class_ids postprocess(outputs, conf0.25, iou0.45) # 6. 映射回原图坐标 # boxes_real (boxes - [dw, dh, dw, dh]) / ratio # 7. 画框 # for box, score, cls_id in zip(boxes_real, scores, class_ids): # cv2.rectangle(frame, (int(box[0]), int(box[1])), (int(box[2]), int(box[3])), (0, 255, 0), 2) # cv2.putText(frame, f{classes[cls_id]} {score:.2f}, ...) cv2.imwrite(result.jpg, frame) rknn.release() cap.release() if __name__ __main__: main()代码里的后处理部分我故意留了注释——因为postprocess函数要跟你的RKNN模型输出布局强绑定不同转换参数出来的输出格式有微妙差别。这里建议直接参考你转换YOLOv5模型时对应的rknn-toolkit2官方demo里的后处理实现把它贴过来改改坐标映射就行。4. 实测指标、边界情况与性能优化方向4.1 单帧端到端延迟一次真实的计时结果我手上的环境香橙派5RK3588 8核Officail Ubuntu 20.04USB摄像头1280x72030yolov5s INT8 RKNN模型推理程序中打开NPU。单从摄像头读取一帧到拿到推理结果的耗时分布大致如下环节典型耗时说明VideoCapture读取一帧10~20 ms暖机后稳定在10ms左右受USB带宽影响预处理(转RGBletterbox)3~5 msnumpy操作性能开销很小NPU推理(640x640 INT8)20~35 ms看RKNN版本和NPU频率实测差别较大后处理(NMS等)5~8 msPython实现较慢可优化画框并输出1~2 msOpenCV画框本身很轻量合计大约40~70 ms一帧取平均的话大约15~25 FPS。这基本就是RK3588上yolov5s纯Python管线的底线水平。别被这个数字吓到——后面做性能优化时有很大的提升空间。4.2 最容易遇到的三个边界情况摄像头断线后不会自动恢复。USB摄像头偶尔会掉线尤其是热插拔过之后。而cv2.VideoCapture一旦进入断线状态后续read()会一直返回False但不会抛异常程序看起来像是卡死了。务必要在抓帧循环里检查ret值如果连续N帧失败就重新初始化摄像头。低光环境检测率骤降。我实测在室内正常照度下人物检测的置信度能到0.8以上但到了傍晚或者灯光昏暗时置信度掉到0.3以下加上conf_thres0.25的过滤很多目标直接消失了。这是模型本身的局限不是部署问题。如果你的场景光照不理想可以考虑调低置信度阈值或者加补光灯。摄像头分辨率太高导致预处理耗时上升。如果摄像头工作在4K分辨率cvtColor和letterbox的耗时可能从3ms涨到15ms以上。在RK3588这类嵌入式平台上读取4K帧的成本甚至可能超过NPU推理本身。所以选720p或1080p的摄像头输出通常是更务实的选择。4.3 优化方向从这条抓一帧链路延伸到持续视频流如果你满足了于单帧抓取推理教程到这里就可以收官了。但实际项目肯定要往连续视频流的方向走。根据我的经验接下来的优化方向按性价比排序是这样多线程分离抓帧和推理摄像头读取在CPU上NPU推理不吃CPU它们天然可以并行。用一个线程持续抓帧放到队列另一个线程从队列取帧推理吞吐量能提升50%以上。帧跳过策略如果推理速度跟不上摄像头帧率可以每抓2帧只推理1帧牺牲少量实时性换取CPU释放。减少缓冲帧连续推理时把cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)开了能显著降低延迟。后处理加速用rknn-toolkit2里面自带的rknn_yolov5_demo的C后处理实现替代Python版或者把NMS换成朴素的去掉换低效循环的写法。我在实际项目里把这些优化都做上之后端到端帧率大概能稳定到28~30 FPS跟摄像头的输出帧率基本持平。4.4 本期教程和下一期的衔接保存推理结果时别忽略坐标映射既然这一期完成了抓一帧并推理很自然会想保存检测结果。但每次保存结果图时建议把摄像头原始帧、推理后的画框帧、以及检测框的原始坐标三部分信息都存在一起方便后面做数据集积累或者调试分析。比如这样output/ 0001_20250412_123605_raw.jpg 0001_20250412_123605_det.jpg 0001_20250412_123605.jsonJSON里保存的是归一化或原始像素坐标的检测框后面无论做视频流分析、目标计数还是回放测试这些数据都非常有用。很多朋友跑通了单帧之后直接进入下一期没有考虑数据留存等后面想回头验证模型精度时只能重新跑一遍纯浪费时间。我个人在实际操作中的习惯是在推理的入口处打一个时间戳文件名加上帧序号以保证每一条数据都可以回溯到源图像和模型输出。这个习惯看着不起眼但当你调试模型、对比不同版本效果时价值就体现出来了。接下来的教程里我们可以在这一步基础上继续做视频流推理、目标追踪和结果上报到时候你用已经抓好的帧数据和检测结果做真值比对会省掉大量重跑的时间。