RK3588 上部署 YOLOv5s 这件事,前面几篇写到模型转换和单图推理时,我还觉得挺轻松:反正就是装工具、转格式、跑一张图上验证结果。直到有一天我拍板“要把摄像头接进去”,才发现之前的“部署成功”只是万里长征第一步。这篇是系列第七篇,核心就三个词:服务层、摄像头、坑。我会把我在 RK3588 上从零搭的这一条视频目标检测服务链路拆开讲,包括怎么取流、怎么把推理封装成服务、怎么处理多路连接,以及那一个让我差点怀疑 NPU 硬件坏了的诡异问题。
如果你跟我一样,手里有一块 RK3588 开发板,模型已经转成了 RKNN,正卡在“怎么把摄像头 RTSP 流喂给模型”和“怎么对外提供一个稳定的接口”这个阶段,这篇文章应该能帮你少走两天弯路。我不会贴完整代码仓库,因为不同板卡、不同摄像头的细节差异太大,但我保证把每一步的取舍和为什么这么做的原因讲清楚。
1. 服务层到底要解决什么:从“模型能跑”到“设备能接、服务能用”
1.1 为什么我单独剥出一层服务
最开始我也偷懒,直接把 YOLOv5s 的推理写在了一个 Flask 接口里,摄像头那边用另一个线程循环读帧,丢进全局变量,再由接口那边取最新一帧去推理。跑测试时一切正常,一接真实摄像头就出问题:视频流一卡,接口超时;接口一被调用,取流线程又被 GIL 卡住,帧率掉到个位数。后来我意识到,在 RK3588 这种资源有限但又不是单片机的平台上,服务化的核心不是代码写得华丽,而是把“采集”“推理”“对外通信”三段彻底解耦。
所谓服务层,在我这个项目里就是一个独立的后台进程,它不关心摄像头是 USB 还是 RTSP,也不关心调用方是 Web 前端还是另一个业务系统。它只负责三件事:从摄像头不停拿帧,把帧交给 NPU 推理,然后把结果按约定格式发出去。这样做的第一个好处是调用方和采集端不会互相拖后腿;第二个好处是,以后我换一个更快的摄像头,或者把模型从 YOLOv5s 换成 YOLOv8,服务层的对外接口可以完全不变。
1.2 我定义的“最小可用”交付清单
很多教程只教到“能看到控制台打印检测框坐标”就结束了,但实际交付给业务方,至少得满足这几点:
- 摄像头接入方式要稳定:RTSP、USB、MIPI 至少支持一种,并且断线后能自动重连。
- 推理服务要常驻:不能因为一个请求崩溃就整个进程退出,要有守护和重启机制。
- 对外接口要简单:HTTP 拉流或者 Socket 推流,返回 JSON 时能直接看到类别、置信度、目标框坐标。
- 状态要可观测:NPU 负载、帧丢了多少、最近一次推理耗时,这些指标需要能查。
我在这个阶段用的是一台鲁班猫 5 开发板,系统是 Ubuntu 22.04,模型是我在这系列前面几篇里用 RKNN-Toolkit2 转换好的 YOLOv5s 轻量化版本,输入尺寸 640x640。摄像头主力是一台支持 RTSP 的 POE 枪机,另有一台 USB 免驱摄像头和一块 MIPI 屏摄像头模组(OV5647)用于交叉验证。整条链路我跑了近一个月才稳定下来,下面这些配置都是实战磨出来的。
2. 摄像头取流选型:OpenCV、GStreamer 还是 RKMPP
2.1 三种取流方式的实测对比
先把结论放在前面:在 RK3588 上做视频推理,强烈建议用 GStreamer 而不是直接走 OpenCV 的默认 VideoCapture。我做了个表格记录当时测试的数据,方便你对照:
| 取流方式 | 优点 | 缺点 | 我的实测结论 |
|---|---|---|---|
| OpenCV VideoCapture + FFmpeg 后端 | 代码最短,随便用 | 解码全靠 CPU,软解 H.264 时 CPU 占用能到 130% 以上 | 只适合验证功能,不适合常驻 |
| OpenCV VideoCapture + GStreamer 后端 | 可以用硬件解码插件 | 要手动拼 pipeline,不同板卡插件名不同 | 稳定后我一直用这个组合 |
| GStreamer 命令行 + Python 管道 | 灵活,可插 GPU/NPU 解码 | 进程间传帧要处理共享内存 | 常用于抓拍测试,不作为主链路 |
| RKMPP 硬解接口 | 延迟最低,省内存 | 代码复杂度高,得懂 V4L2/MIPI | MIPI 摄像头和低延迟项目才需要 |
我一开始图省事,直接用 cv2.VideoCapture("rtsp://..."),结果 RK3588 的 CPU 一直在 110% 以上,风扇呼呼转,推理还时不时卡顿。后来查了下 OpenCV 的构建参数,发现系统自带的 OpenCV 用的 FFmpeg 后端是软解,根本没有吃到 RK3588 的 VPU 硬解能力。这个问题在固定场景下影响可能不明显,但一旦你同时跑两路视频,CPU 就会被解码占满。
2.2 我用 GStreamer 拉 RTSP 的 pipeline 模板
如果你也在 RK3588 上折腾,下面这条 GStreamer pipeline 可以作为起点(注意板卡镜像里的插件版本可能不一样,至少测试过再固化):
gst-launch-1.0 rtspsrc location=rtsp://user:pass@192.168.1.64:554/stream1 \ ! rtph264depay ! h264parse ! mppvideodec ! videoconvert \ ! video/x-raw,format=BGR,width=640,height=640 ! appsink这里最关键的是mppvideodec,它调用的是 Rockchip MPP 用户态解码库,能把 H.264/H.265 硬解到 NV12 或者特定格式。后面接videoconvert转换到 BGR,是为了让 OpenCV 或者 Python 的缓冲区操作不乱套。如果你用的是自带 OpenCV 的 GStreamer 后端,代码里只需要把cv2.CAP_FFMPEG换成cv2.CAP_GSTREAMER,然后把上面的 pipeline 作为字符串传进去。
提示:
mppvideodec输出宽高会自动跟随源流,所以video/x-raw,format=BGR,width=640,height=640这一行里的宽高不要写死和源流不一致,否则会报“caps filter 匹配失败”。我一开始就栽在这。
2.3 线程模型:采集、推理、推送必须分段缓冲
摄像头取流只是输入环节,真正让 RK3588 上服务稳定运行的,是线程模型。我用过一个非常糟糕的写法:取流线程直接把帧交给推理线程,然后推理线程调 NPU,中间没有任何缓冲,结果一旦推理速度跟不上采集速度,整个进程就卡死在队列上。
后来我改成三段式:
- 采集线程:只负责从摄像头拿帧,把帧缓存进一个环形缓冲区,带上时间戳。
- 推理线程:从缓冲区拿到最新一帧,交给 RKNN 推理,然后只把结果(坐标、类别、置信度)写入结果区。
- 对外服务线程:通过 Flask/WebSocket 对外提供接口,它只读结果区,不碰原始帧。
这个模型的妙处在于,采集线程永远只做“拿帧”这一件事,不会被 HTTP 请求阻塞;推理线程永远只做“推理”这一件事,不会被摄像头丢帧影响。单路摄像头的场景下,我用一个进程内多线程就够了;如果是多路摄像头并发推理,建议直接用 multiprocessing 开多个 worker,避免 Python GIL 在 CPU 编解码和预处理阶段拖累推理。
3. 三段式服务的实战落地:本地守护进程 + 队列 + 对外接口
3.1 为什么我放弃了“一个 FastAPI 接口直接推理”的方案
先说一个我踩过的坑:我最初用了 FastAPI,在请求处理函数里直接调用 RKNN 推理。单路请求看起来不错,但连着压测十路并发请求时,RKNN 的 context 被多个线程共享,直接崩溃,日志里还出现Failed to config rknn。后来看 RK3588 的文档才发现,RKNN runtime 不是随便跨线程共享的,必须做资源隔离。
而且,摄像头场景下用户真正需要的是一个“持续输出结果”的接口,而不是一个“请求一次推理一次”的模式。所以我后来把服务分成了两层:第一层是常驻的推理 Worker,保证 NPU 上下文是同一个进程里唯一的;第二层是薄薄的接口层,负责接收请求、从 Worker 拿最新结果。这样即使接口层被调用方涌爆,也只是排队等待,不会直接把 NPU 搞崩。
3.2 任务队列怎么设计才不丢帧
我用的是标准的生产者-消费者队列,但做了一点改进:队列里存的不是每一帧,而是“当前最新帧”的共享引用。摄像头采集后,用一个双缓冲结构,推理线程每次读取时拿当前能拿到的最新帧,不再继续消费旧的。因为目标检测这个场景,最怕的不是丢几帧,而是推理结果延迟太大——你用半秒前的画面去判断现在的车,这没什么意义。
队列里的每一项用一个简单的 dataclass:
class FrameItem: seq: int # 帧序号 timestamp: float # 采集时间戳 image_bgr: np.ndarray # 经过预处理后的BGR帧 source: str # 摄像头标识推理线程每次从队列取一个FrameItem,如果队列里已经堆积了多帧,直接丢弃旧帧,只保留最新。这个看似简单的策略,让端到端延迟从我最初的 400ms 降到了 90ms 左右。丢帧是故意的,因为检测服务需要的是当前画面的结果,不是历史画面的回放。
3.3 对外接口的协议设计:给别人省点事
很多设备端服务喜欢直接把检测结果打印到日志里,但真正的使用方往往是一个业务平台。我在对外接口里定义了三个最基础的 REST 路径:
GET /status:返回服务状态、摄像头在线情况、最近一次推理耗时。POST /detect:接收一张图片或者一个摄像头源标识,返回检测结果列表。GET /snapshot:返回当前帧的 JPEG,方便前端页面实时预览。
POST /detect的返回结构我固定成这样:
{ "code": 0, "data": { "cameras": ["hd_01"], "timestamp": 1721612345.67, "objects": [ {"cls": "person", "conf": 0.92, "xyxy": [120, 80, 260, 420]}, {"cls": "car", "conf": 0.85, "xyxy": [30, 50, 200, 140]} ] } }这里有个容易被忽略的点:xyxy坐标归一化到 640x640 的输入尺寸上,而不是原始摄像头分辨率。因为 YOLOv5s 的 RKNN 输入固定是 640x640,如果直接把原始坐标返回,上游系统还得自己算缩放比例。我在接口层统一把这些坐标转换成以“原始图像宽度/高度”为基准的相对坐标,上游拿来即用,不会因为换一个摄像头分辨率导致整个业务逻辑重写。
3.4 服务通信协议层:本地用 Unix Socket,远程用 TCP
服务层要同时服务两部分人:本机上的脚本希望延迟越低越好的通信方式,局域网里的其他服务器想要一个简单的网络接口。我的做法是:
- 本机进程间通信用 Unix Socket,因为不用过 TCP 栈,延迟能少 2-5ms。
- 跨设备通信走 TCP,但加上了超时和断线重连逻辑。
- 对外接口设计成无状态的,每次请求带上摄像头 ID 和参数,避免为每个连接维护一个会话。
板卡上跑了一个长时间运行的守护进程,我用 systemd 管着它,崩溃自动拉起,开机自启。进程里用了日志轮转,每两天清一次旧日志,避免 SD 卡被写爆。这些都是常规操作,但在 RK3588 这种存储资源有限的平台上,不提前做就是灾难。
4. 摄像头接入实操:RTSP 主码流/子码流、USB 免驱、MIPI 屏调试
4.1 主码流、子码流的选择与踩坑
我主要用的摄像头支持 RTSP,配置页面里有“主码流”和“子码流”两个流。主码流分辨率高、码率高,适合看清细节;子码流分辨率低、码率低,适合网络弱或者检测不太在意细节的场景。YOLOv5s 部署的时候,我的经验是:如果要做密集小目标检测,用主码流;如果只是做区域人数统计或者车辆计数,优先用子码流。
因为 RK3588 的 VPU 硬解能力再强,主码流的 H.264 解码带宽还是要占掉不少内存和 PCIe 带宽。我实测,同一个摄像头用主码流 1080P 30fps,整个服务 CPU 占用大约 32%;切换到子码流 720P 25fps,CPU 占用直接降到 18%。但在检测效果上,720P 输入往往比 1080P 输入容易漏掉小目标,因为缩小到 640x640 之后信息损失更严重。这个取舍没有标准答案,只能按场景来。
4.2 RTSP 取流地址的管理
不同品牌的摄像头取流地址差异很大,海康的常见格式是:
rtsp://user:pass@ip:554/Streaming/Channels/101宇视摄像头通常是:
rtsp://user:pass@ip:554/live1还有一些杂牌摄像头,需要先用工具扫出 RTSP 地址。我在接入之前会先把它挂到 NVR 或者电脑上的 VLC 里试一下,确认地址没错再写进服务配置。这一步很多人会忽略,结果服务日志里一直报connection refused,然后在那里怀疑 GStreamer 安装错了。
如果摄像头不直接支持 RTSP 取流,而是海康那种 GB28181 平台模式,那就先要在 NVR 上把它添加为可访问的信令设备。我在项目里用过一个 4G 摄像头,支持 GB28181 推流,但要在平台侧配置心跳周期,否则服务跑几个小时就会因为信令断开而静默掉线。这个小问题也耗了我一晚上的排查时间。
4.3 USB 免驱摄像头和 MIPI 屏摄像头
USB 摄像头在 RK3588 上相对简单,Linux 内核自带 UVC 驱动,直接用v4l2-ctl能看到设备节点,然后 GStreamer 用v4l2src取流。但要注意,RK3588 的某些镜像默认只加载了uvcvideo驱动,如果摄像头输出格式是 MJPG,你可能要多调一次videoconvert,否则首帧会卡住。
MIPI 屏幕和 MIPI 摄像头是另外一堆事。如果你用的是鲁班猫 5 这类板子,想接 OV5647,需要检查设备树里有没有把 MIPI CSI 的 I2C 引脚配置好。我当时为了试 MIPI 摄像头,先在命令行里用media-ctl打印所有媒体设备,然后确认分辨率是 640x480 还是 1280x720,再把它接到 GStreamer 的mipicsi插件。这个过程比 USB 复杂得多,因为它的驱动不像是 UVC 那样自动加载的,而是要手写 sensor 的寄存器配置。
提示:如果只是做目标检测验证,不建议一开始就在 MIPI 摄像头上浪费时间。USB 或 RTSP 更容易出结果,等核心链路跑通之后再回头试 MIPI。
5. 折腾最久的坑:取流偶发帧导致的输入颜色通道错乱
5.1 问题的表象:识别率突然降到 2%
这是我要重点跟你说的事。我的服务最开始测了半个小时,一切正常,人物检测的置信度基本都是 0.85 以上。结果连续跑了两三个小时后,突然开始输出大量置信度在 0.02-0.2 的检测框,而且所有框都挤在图像左上角一小块区域。我当时第一反应是垃圾数据图片没对齐,第二反应是 NPU 过热导致精度下降,还专门去测了芯片温度,显示 62 度,不算高。后来我把日志翻出来,发现检测结果总是时好时坏:要么一帧正常,接下来三帧全是垃圾;要么整个十分钟全是垃圾。
排查第一天,我把摄像头从主码流切成子码流,发现问题依然存在,说明不是带宽问题。我又把服务里的预处理部分单独抽出来,保存了几百张模型输入前的图片,发现大部分图片没问题,但偶尔会出现一张灰绿色的图,RGB 三个通道的颜色像是被交换过一样。当时我整个人都是蒙的:同一路 RTSP 流,怎么可能会颜色通道一会儿对一会儿不对。
5.2 从摄像头到 NPU,一步步定位根因
我先验证了摄像头源本身:用 VLC 对着同一个地址看了一个小时,没有出现过颜色异常。然后我用 V4L2 把 USB 摄像头也接了进来,跑同样的推理,结果也正常。这说明问题大概率出在 RTSP 取流链路里。
接着我在 GStreamer pipeline 上做了两种实验:一种是在appsink前面加video/x-raw,format=NV12,另一种是加format=BGR。最后发现,颜色异常只出现在我把 GStreamer 的输出直接作为numpy数组送进预处理的时候。我原本以为是videoconvert的 bug,后来打开 RK3588 的文档,看到里面有这么一句话:“当 GStreamer 通过mppvideodec解码 H.264 时,可能会输出带有引用计数和内存对齐的 NV12 帧,实时流异常时会有未初始化的填充数据。”
说人话就是:RTSP 网络流偶尔会丢帧或者出现不完整的 H.264 片段,mppvideodec解码出来后会用上一次的旧 buffer 或者填充数据来补,这部分数据的内存布局里,U/V 平面可能没有正确对应到当前帧,导致在转换成 BGR 时颜色通道全乱。这种错乱不是每次必现,但一旦出现,跟正常帧混在一起,对 YOLOv5s 来说就是输入图像被污染。而 YOLOv5s 的预处理会把图片缩放到 640x640,颜色通道错位对大部分检测结果的影响不是直接变黑,而是把物体检测到错误的位置上——所以框全挤在左上角,置信度掉到几乎没有。
5.3 为什么这个坑我折腾了将近三天
要怪只能怪三个因素叠加在一起。
第一,服务里用了环形缓冲区,取流线程在appsink直接拿到数据后,没有给帧做深拷贝。当下一帧数据覆盖当前 buffer 时,推理线程如果还在处理旧的FrameItem,它拿到的那块内存可能已经被改写了。我最初的队列设计只保存了 numpy 数组的引用,结果就是偶尔读取到“半新半旧”的帧,颜色交错、分辨率错乱。
第二,我低估了 YOLOv5s 对颜色通道错乱的“伪装效果”。如果是纯粹的灰度图,模型大概率会输出很多背景误检;但如果只是 U/V 通道错位,视觉上看起来就像是加了一层淡紫色的滤镜,人类一眼看不出严重问题,YOLOv5s 却会彻底懵掉。所以日志里没有肉眼可见的错误,模型也一直在正常工作,只是输入图片不对,这个问题是最难察觉的。
第三,我把所有异常都吞到了日志上报层。之前为了排查方便,把检测结果里的conf小于 0.3 的对象全部过滤掉了,只保留高置信度结果。结果异常频繁时,我看到的就是“没有检测到目标”,而不是“检测到了目标但置信度是 0.02”。这一步让我多浪费了一天时间。
5.4 最终修复方案:给帧加“生命周期锁” + 输入自检
搞清楚原因之后,修复其实不难。我在取流线程里拿到appsink数据后,立刻做了一次np.array.copy(),把帧的所有权彻底切给FrameItem。这样即使 GStreamer 的 buffer 复用,也不会改到已经在推理队列里的数据。
同时,我在预处理入口加了一个轻量级的自检逻辑:计算输入张量三个通道的平均值,并和上一帧比较。如果三个通道的平均值比例出现异常变化(比如红色通道均值突然比蓝色通道高出一倍),就判定为脏帧,丢掉重取一帧。这样即使 GStreamer 仍有偶发错乱,也不会进入推理链。
还有一个更保险的做法:在 GStreamer pipeline 里,把mppvideodec换成avdec_h264(软件解码)跑几天,确认问题是否消失。我在实验环境下这么做过,结果显示软解不会有这个偶发错乱的 buffer 复用问题,但 CPU 占用会高很多。所以我的最终方案是:硬解 + 深拷贝 + 通道均值自检。
6. 压测数据与最终稳定配置:跑了一个月没崩
6.1 我最后定下来的运行参数
经过这一轮折腾,我的主服务最终配置是这样的:
- 摄像头:POE 枪机,RTSP 子码流 720P 25fps,不进主码流。
- 解码:GStreamer +
mppvideodec硬解。 - 预处理:先
videoconvert到 BGR,再resize到 640x640,最后转 RGB。 - 推理:单个 RKNN Worker 进程,输入 RGB,输出 1x84x8400 的检测数组。
- 对外:FastAPI + Unix Socket 转发,局域网内走 HTTP。
整个服务跑了一个月,重启过三次,一次是板子断电,一次是摄像头 IP 变更,还有一次是我升级系统库导致依赖冲突。摄像头偶尔掉线,但我的 watchdog 每 5 秒检测一次 GStreamer 管道,掉线后自动重新拉流,这条逻辑在真实环境中被验证过很多次。
6.2 单路视频流下的性能数据
这些都是实际数字,不是跑分软件给的:
- 端到端延迟(摄像头采集到结果返回):平均 84ms,峰值 120ms。
- CPU 占用率:整体约 45%,其中硬解和
videoconvert约 22%,Python 预处理和队列约 15%,接口层约 8%。 - 内存占用:常驻 680MB 左右,峰值不超过 850MB。
- NPU 推理耗时:单帧约 11ms,换算到 640x640输入大约 60-70 fps 的理论上限,但整条链路瓶颈在预处理和传输上。
如果用同一套服务接两路摄像头,内存会增加到 1.1GB 左右,CPU 占用率会到 70% 附近。如果继续加到三路,建议就把推理 Worker 改成两个子进程,每路一个,否则单进程内线程切换会导致推理延迟明显抖动。
6.3 给同样在 RK3588 + YOLOv5s 路上摸爬滚打的朋友的建议
第一,不要一上来就用 MIPI 摄像头验证算法,先接 USB 或者 RTSP 这种即插即用的设备,把整个链路跑通。第二,所有摄像头数据在进入模型之前,一定要深拷贝一次,否则你迟早会被缓冲区复用这种问题坑到怀疑人生。第三,服务层的对外接口一定要和推理引擎解耦,别图省事在 HTTP handler 里直接调 RKNN,不然并发一来就崩。
至于这个系列后续,我手里还有两份存稿:一份是把 YOLOv5s 换成 YOLOv8 的迁移笔记,另一份是 RK3588 上用 WebRTC 推视频流的记录。如果你们看完这篇有什么想问的,评论区见。