
RK3588 上部署 YOLOv5s 这个系列写到第七篇前面模型转换、RKNN 量化、推理验证这些硬骨头都啃完了这篇把镜头转向服务层和摄像头。说实话接入服务层本身不算难难的是把整个链路在端侧稳定跑起来。我前前后后踩了一个折腾最久的坑——RTSP 取流的时延漂移排查链路拉得很长最后结果却出乎意料地简单。这篇就把服务层的架构取舍、摄像头接入姿势以及这个坑的完整复盘写清楚给即将走到这一步的后来人省点时间。1. 服务层到底在解决什么问题1.1 demo 能跑和能持续可靠运行是两回事刚把 YOLOv5s 在 RK3588 上跑通那会儿我的代码就是最典型的 demo 工程一个 main 循环抓一帧、推理一帧、画框一帧、写回一帧。单看推理速度int8 量化后的 YOLOv5s 在 NPU 上跑 640x640 输入差不多 30ms 一次听起来完全够用。但一旦接入真实摄像头需求马上就变了。你要同时做检测结果的回传、录像保存、异常告警、状态上报甚至多个摄像头轮流取流。如果把所有这些逻辑全部写死在同一个循环里后面每加一个功能都是在给本来就紧绷的循环线程埋雷。我见过太多人把这个阶段的项目做成只能演示、不能上线的状态问题不是模型不行而是服务结构没有提前分层。所以第七篇的第一件事就是把推理能力从 demo 工程里拆出来做成一个独立的常驻服务进程。这个进程负责三件事从摄像头取流、跑 YOLOv5s 推理、把结果分发给上层调用方。上层业务不管视频流怎么来、NPU 怎么调度只向服务层要结果。1.2 我采用的端侧服务架构RK3588 的优势是 8 核 CPU4 个 A76 大核加 4 个 A55 小核NPU 有 6 TOPS 算力。这种异构芯片如果在服务层里把所有事情都塞到一个线程等于浪费整个芯片的并发能力。我最终采用的结构是三个线程加两个有界队列采集线程从 RTSP 或本地摄像头读帧打上时间戳后塞进原始帧队列推理线程只做一件事从原始帧队列取最新帧调用 RKNN 接口推理把输出封装成检测结果放进结果队列分发线程从结果队列拿结果通过 HTTP/WebSocket 推给上层同时负责日志落盘两个队列都是有界的容量一般设 3 到 5 帧。满了怎么办直接丢最老的帧。这个策略很关键后面第三章讲的坑就跟这个有关——如果队列是无界的帧只会越积越多内存和延迟一起失控。为什么用时间戳贯穿全部环节因为在端侧多线程架构里单纯看FPS 多少根本不足以判断系统健康度。你必须能回答一个问题当前屏幕上显示的检测结果对应的是哪个时刻的摄像头画面这个时间戳差额就是端到端延迟是服务层最重要的健康指标。那对外接口用 HTTP 还是别的我选了 FastAPI主要原因是它天然支持异步而且自带的 OpenAPI 文档在调试时候很方便。板端部署不需要花哨的框架简单、稳定、能跨进程访问就够了。像 gRPC 这种协议在端侧反而显得重尤其在 RK3588 这种资源有限的板子上没必要为了协议上的先进付出额外内存和依赖成本。2. 摄像头接入协议选型与双码流2.1 USB、MIPI-CSI、RTSP 网络摄像头怎么选RK3588 开发板可以接的摄像头大体分三类我逐个说下自己的使用感受。USB UVC 摄像头最简单插上就能用/dev/video0直接出流OpenCV 直接读。但 UVC 在 ARM 平台上 CPU 占用偏高尤其是 1080p30fps 的 YUYV 格式解包加格式转换吃的 CPU 不容小觑。而且很多 USB 摄像头的自动曝光在弱光环境下飘得厉害后面对检测精度影响很大。MIPI-CSI 摄像头是 RK3588 的原配走专用接口带宽固定同时支持多路输入。但这玩意在 Linux 下的适配是出了名的折腾dts 设备树、驱动模块、sensor 型号匹配一个对不上就白屏。我在之前的调试里花了不少时间才把 imx219 调通。如果不是项目硬性要求贴片摄像头我会建议优先考虑网络摄像头。这次接入的是一路海康的网络摄像头走 RTSP 取流。网络摄像头最大的好处是物理位置灵活、多路扩展方便而且 RK3588 板子只需要一个网口就能把所有视频流拉回来。缺点就是延迟链路变长网络抖动、解码缓冲都会影响实时性——这也是第三章那个坑的伏笔。2.2 RTSP 地址与主码流/子码流RTSP 取流的第一个动作是拿到正确的流地址。这里有个好习惯不同厂商地址格式不同直接查文档比瞎猜效率高得多。海康摄像头常见格式rtsp://username:passwordip:554/Streaming/Channels/101末尾的 101 表示通道 1 的主码流102 就是通道 1 的子码流。主码流分辨率高、码率高适合做精确检测子码流分辨率低适合长时间预览和带宽受限的巡检场景。大华则不一样rtsp://username:passwordip:554/cam/realmonitor?channel1subtype0subtype0是主码流subtype1是子码流。我在服务层里同时拉了两路子码流用于常驻检测主码流只在需要高清抓拍时启用。这样做的直接好处是推理线程的压力稳定不至于因为高清码流瞬时码率波动让队列剧烈抖动。2.3 OpenCV 读 RTSP 的隐藏问题很多人的第一反应是cv2.VideoCapture(rtsp://...)这确实最省事。但在 RK3588 上这个省事背后藏了几个问题。首先是缓冲。OpenCV 的 FFmpeg 后端在拉 RTSP 时会维护一个内部缓存这个缓存的具体大小你很难从上层 API 完全控制。结果就是你调用read()拿到的帧可能是几百毫秒甚至几秒之前的数据。短时间看不出来服务跑一晚上之后延迟会越来越离谱。其次是解码。系统源里装的 OpenCV 多半没有启用 RK3588 的硬件解码插件H.264 流的解码完全靠 CPU 软解。在 1080p 场景下软解一路流就要占掉一到两个核心服务层性能余量直接被吃掉。所以我最后的方案是绕开 OpenCV 直接上 GStreamer 管道把解码交给 RK3588 的 MPP 硬件解码器。后面第四章会给出具体管道这里先记住一个结论在 RK3588 上做常驻视频服务GStreamer 是比 OpenCV 更值得投入时间的取流方式。3. 折腾最久的坑RTSP 取流延迟漂移3.1 现象为什么 FPS 正常但画面越来越慢这个坑发生在服务层刚上线的第二天。第一天晚上一切正常FPS 稳定在 20 左右检测结果正常。第二天早上业务同事反馈画面变成慢动作了人走过去半天屏幕上才出框。第一反应是模型推理变慢了。我查了 NPU 频率曲线正常查了 CPU 占用甚至比第一天还低查了内存没有泄漏。看起来整个板子都很健康但画面就是延迟得厉害。这时候我才意识到光看 FPS 和 CPU 占用是不够的必须测端到端延迟。我给每帧加时间戳后很快得到一个触目惊心的数据从摄像头取到帧到推理结果输出端到端延迟已经涨到 2 秒多而且还在持续增长。FPS 没掉但每一帧都是过去的画面——系统一直在处理旧帧。3.2 排查链路一层一层往下剥这个坑折磨我最久的地方在于它表面症状很像算力不足实际却跟算力毫无关系。我把完整排查链路写出来大家可以对照这个思路复用。第一步排除推理线程问题。我在推理线程的入口和出口分别打了毫秒级时间戳连续统计 1000 次。结果显示单次推理稳定在 55ms 到 90ms 之间没有随时间恶化。推理线程本身是健康的。第二步检查原始帧队列的积压情况。因为队列是有界的容量只有 4即使推满也不会无限制增长所以队列长度看起来永远是正常的。这里容易误判。正确的做法是看队列里每帧的时间戳——队列里最老帧的frame_time和当前时刻的差是多少。一查就发现问题这个差值在持续增大说明等待被处理的帧本身就带着越来越大的历史债。第三步问题收敛到了取流源。我去抓摄像头侧的 RTP 报文发现网络传输本身是流畅的RTP 时间戳是连续的丢包率也很低。那问题就出在取流端收到数据之后的缓冲策略上——OpenCV 的 FFmpeg 后端缓冲了大量已解码帧我的服务层拿到的永远是最老的那批。第四步验证修复方向。我用 GStreamer 重写取流管道显式把缓冲行为改成消费不过来就丢帧而不是排队等消费端到端延迟在几分钟内就恢复到 200ms 以内。3.3 根因RTP 抖动缓冲与 OpenCV 后端的叠加效应为什么这个坑在 RK3588 上尤其阴险因为它不是某一个环节的问题而是两个机制叠加的结果。RTP 流在网络传输中天然存在抖动GStreamer 里的rtpjitterbuffer会缓存一定量的包来平滑抖动这个缓冲区如果初始值设得太大会引入可观的固定延迟。而 OpenCV 的 FFmpeg 后端在read()循环之外还会额外维持一个解码帧队列这个队列的行为在上层 API 里几乎没有控制权。两者叠加的效果是当推理速度略慢于码流帧率时系统不会直接丢帧而是默默地把所有来不及处理的帧都缓冲起来。CPU 和内存看起来都正常FPS 看起来也没有掉但每一帧从采集到被处理的间隔在无限递增。这就是延迟漂移——它不像崩溃那样让你立刻看到错误而是温水煮青蛙一样慢慢腐蚀整个系统的实时性。3.4 修复方案与长期监控我的修复方案是彻底放弃 OpenCV 取流改用 GStreamer 管道并显式控制缓冲行为。关键管道如下gst-launch-1.0 rtspsrc locationrtsp://user:pass192.168.1.64:554/Streaming/Channels/102 latency100 ! \ rtph264depay ! h264parse ! mpph264dec ! \ videoconvert ! video/x-raw,formatBGR ! \ appsink droptrue max-buffers1 syncfalse几个参数逐个解释latency100告诉 rtspsrc 初始的抖动缓冲窗口只有 100ms不要把 RTP 包囤太多droptrue当 appsink 消费不过来时直接丢新帧不允许排队堆积max-buffers1appsink 内部最多保留 1 帧缓冲保证你拿到的永远是最新帧syncfalse关闭 GStreamer 的时钟同步避免因为上游的时间戳跳变导致取帧阻塞改完之后我再也没遇到过延迟漂移的问题。但我必须强调这个修复的真正价值不只是换了个取流工具而是让我建立了延迟监控机制。我现在在采集线程里给每帧打上单调时钟time.monotonic()时间戳在推理线程计算age now - frame_ts每 10 秒上报一次。一旦age超过 500ms 就触发告警。有了这个指标之后服务层是不是健康一眼就能看出结论。4. 服务层实现关键细节4.1 模型加载与 RKNN 上下文管理服务层和 demo 的另一个重大区别是模型常驻内存推理服务需要长时间运行不能每次推理都重新加载模型。我这里用 RKNN Python 接口做推理模型加载和初始化只做一次import numpy as np from rknnlite.api import RKNNLite rknn RKNNLite() rknn.load_rknn(yolov5s.rknn) rknn.init_runtime(core_maskRKNNLite.NPU_CORE_0_1_2)NPU 的core_mask选择值得单独说一句。RK3588 的 NPU 有三个核NPU_CORE_0、NPU_CORE_0_1、NPU_CORE_0_1_2。单路推理我用NPU_CORE_0_1留一个核给后面可能的第二路任务。实测跑满三个核并不会有明显收益反而让 NPU 功耗和温度上得更快。推理线程是我整个服务里唯一允许调用rknn.run()的地方。为什么因为 RKNN 上下文对不同线程并发调的兼容性在 Python 接口层并不好在长稳场景下暴露问题。你可以用自己的锁硬去并但一旦遇到异常分支上下文的状态就说不清楚了。服务层追求的是确定性我宁愿牺牲那一点点并发吞吐也要保证推理线程的单消费者模型绝对稳定。4.2 采集队列与推理循环采集线程把帧放入队列前必须做一次格式确认。RKNN 的输入要求是NHWC布局的uint8数据尺寸固定为640x640。所以 GStreamer 管道里我已经把videoconvert之后的输出格式指定为 BGR、分辨率由后面的videoscale或直接由 caps 限制在 640x640。推理循环的核心代码不复杂while not stop_event.is_set(): frame frame_queue.get() if frame is None: continue img frame.data # 640x640x3 uint8 BGR ts frame.timestamp outputs rknn.inference(inputs[img], data_formatnhwc) # 后处理NMS 坐标缩放这里省略之前的系列文章写过 dets postprocess_yolov5(outputs, conf_thres0.25, iou_thres0.45) result_queue.put({ts: ts, dets: dets, process_ms: now_ms() - ts_ms})这里的后处理postprocess_yolov5是阻性操作包含解码和 NMS跑在 CPU 上。我统计过640x640 输入下大约要额外吃 20ms 到 40ms 的 CPU 时间。如果你要求的 FPS 更高可以考虑把 NMS 换成更轻量的实现或者接受置信度阈值上调带来的一点点召回损失。4.3 对外接口的取舍服务层对外暴露的是 FastAPI 接口但我不建议用 POST JSON 这种问询式接口做持续业务输出。原因很实际连续检测场景下上层业务要的是持续流式结果而不是每次主动请求。我用的是 WebSocket 通道推理线程每帧产生结果就推给所有已连接的客户端。from fastapi import FastAPI, WebSocket app FastAPI() app.websocket(/ws/detections) async def ws_detections(websocket: WebSocket): await websocket.accept() clients.add(websocket) try: while True: await websocket.receive_text() # 心跳 except Exception: clients.remove(websocket)这个设计的好处是把检测结果推送和采集/推理彻底解耦。上层业务哪怕断线重连服务层的采集和推理线程完全不受影响。这对长时间运行的端侧服务来说非常重要。5. 部署后的实测数据与备选方案5.1 我的板子实测结果板子是 RK3588 配 8GB 内存视频源是海康 1080p 摄像头取子码流推理输入 640x640YOLOv5s int8 量化。服务层修复 RTSP 延迟漂移后的稳定数据端到端延迟平均 180ms最大 300ms主要集中在网络自身延迟和解码耗时推理单帧耗时约 65ms包含后处理实际吞吐约 18 FPSCPU 占用约 40%主要消耗在 GStreamer 软解和 Python 后处理NPU 占用约 35%常驻内存约 1.3GB之前用 OpenCV 取流时FPS 看着差不多但端到端延迟在 2 秒以上。所以如果你看完这篇只记住一件事那就记住端到端延迟才是服务层唯一应该盯死的指标FPS 会骗人。5.2 如果不想用 GStreamer 怎么办我知道不是每个人都愿意折腾 GStreamer 管道。备选方案有两个。第一个是用 OpenCV 的cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)。这个方法在部分 x86 机器上有效但在我手里的 RK3588 系统源 OpenCV 上完全无效——属性设置了但没有真正作用到底层 FFmpeg 后端。你可以在自己的环境上先验证如果有效能省不少事。第二个是在采集线程里只grab()不retrieve()连续丢几帧隔一段时间再取一帧有效帧。这个土办法能缓解问题但不够优雅本质上还是靠降低有效帧率换取延迟收敛。如果项目对实时性要求特别高终极方案是彻底放弃 RTSP 中间层改用摄像头 SDK 直连或 MIPI-CSI 接入。代价就是你得面对厂商 SDK 的接口稳定性和设备树调试问题。没有完美的方案只有适合当前场景的取舍。5.3 踩完这个坑之后的服务层演进延迟监控搭好之后我后续的扩展就有底气了。现在这个 RK3588 服务层可以稳定支撑多路检测我会做的下一件事是给服务层加上录像回放接口和告警推送。具体思路是检测线程输出带时间戳的结果后一方面走 WebSocket 推给前端一方面落一份 JSONL 日志到本地存储。将来需要回放时只要根据时间戳去匹配对应时段的视频切片即可。这套架构的好处是每个环节都只依赖带时间戳的数据而不是依赖某个时刻的全局状态。只要时间戳能对齐采集、推理、分发三个线程谁快谁慢都不影响最终结果的正确性。最后说点实在的体会。在 RK3588 上做端侧推理服务真正难的从来不是把模型跑起来而是让整套系统在无人值守的角落里连续跑上几十个小时不产生任何问题。我这次踩的 RTSP 时延漂移坑表面上是个取流工具选型问题本质上是对服务层健康指标的理解问题。如果你在设计服务层的第一天就把端到端延迟监控做进去后面至少能省出两到三个排查通宵。