☰
RK3588部署YOLOv5s:服务层架构、RTSP接入与RKNN并发坑实战复盘
2026/10/31 1:04:40 网站建设 项目流程

RK3588 部署 YOLOv5s 这个系列,写到这篇已经是第七篇了。前面几篇从交叉编译环境、模型导出、NPU 算子对齐一路折腾到板卡上能跑出检测框,整套推理链路算是通了。这一篇把剩下两块难啃的骨头补完:服务通信协议层和摄像头接入,顺带把整个过程中折腾我最久的一个坑完整复盘一遍。

本篇主要解决三个问题:一是推理能力怎么暴露给外部程序,也就是服务层怎么做;二是摄像头画面怎么稳定变成模型输入,包括码流选择、拉流线程的写法;三是 RKNN 推理实例在并发场景下会踩到一个非常隐蔽的坑,这个坑我前后查了快三天。适合刚把 YOLOv5s 在 RK3588 上跑通、正准备接入真实业务流的读者,也适合那些已经上了服务层但总出现“用一会儿就假死”的兄弟对照排查。

1. 服务层设计:我的接口选型与技术取舍

1.1 先搞清楚服务层要解决什么问题

很多人把模型部署看成“模型能跑就行”,但在实际项目里这只是第一步。你的检测结果要给上位机、给 Web 前端、给 PLC,或者给另一个容器里的业务服务,必须有一个统一的服务通信协议层把这些能力封装出来。我在 RK3588 上跑 YOLOv5s 时,服务层要同时面对三类调用方:

  • PC 端调试工具,通过 HTTP 上传单张图片做检测;
  • 摄像头采集线程,持续把视频帧送进推理模块;
  • 局域网内的其他业务服务,需要实时拿到带检测结果的帧和结构化数据。

如果没有一个稳定的服务层,N 个调用方各自直接操作 RKNN 模型实例,不出十分钟就能把 NPU 折腾到崩溃。服务层在这里干的活很简单也很关键:统一接收数据、排队调度推理、返回结构化结果、把摄像头推流和控制接口也包进去。

1.2 FastAPI、Flask、gRPC 三选一,我最后定了 FastAPI

选型这事我做了个小对比,直接说结论:最终选了 FastAPI。原因不是因为它最“高级”,而是它正好卡在嵌入式开发的两个痛点上。

方案并发模型上手难度资源占用适用场景
Flask多线程,线程阻塞极低中等简单原型、内网小工具
FastAPIasyncio 异步 + 线程池低,自带 OpenAPI 文档较低高并发业务封装、流式输出
gRPC多语言高性能 RPC较高,需定义 proto中高大规模微服务、跨语言强契约

对 RK3588 这种板子来说,资源不是无限的,FastAPI 的异步特点可以让我在等待 GPU/NPU 返回时去处理请求体解析、队列调度等 IO 操作。尤其是摄像头这种长期运行的流任务,异步事件循环非常适合。Flask 不是不能用,但它的多线程模式容易把推理阻塞和请求阻塞混在一起,出了问题不太好定位。

还有个加分项是 FastAPI 自动生成 /docs 接口文档,测试摄像头接口时直接用网页点按钮,比对着 Postman 配请求头舒服太多。

1.3 服务层的目录结构

服务层我没有写成花哨的分层架构,就一个 inference_server 包,但代码边界必须清楚:

  • api目录:FastAPI 路由,只做参数校验和结果返回;
  • core目录:RKNN 模型封装,包括初始化、推理、输入预处理;
  • stream目录:摄像头拉流、帧队列、码流选择;
  • worker目录:推理 worker 和请求排队逻辑。

核心原则是:路由绝不直接碰 RKNN 句柄,摄像头线程也绝不直接调用推理函数。所有推理都走 worker 队列,这个设计在第三节的坑里会体现重要作用。

2. 摄像头接入:从裸视频流到可用的推理输入

2.1 RK3588 摄像头路线选择

RK3588 这板子摄像头接口很丰富,MIPI CSI、USB UVC、网络 RTSP 都能接。我这套实践里没有用 MIPI 屏和 MIPI 摄像头,因为现场摄像头是网络相机,而且走网线传输比 USB 线稳定得多。如果你的场景是固定机位且摄像头就在板子旁边,USB 摄像头省事;如果摄像头分布在现场多个点位,老老实实走 RTSP 网络流。

这里提醒一句:RK3588 接 MIPI 摄像头时,需要单独适配设备树和驱动,跟普通 Linux 发行版不太一样,不是插上就能用。购买任何摄像头模组前,先确认官方有没有给 RK3588 的驱动补丁。

2.2 读懂 RTSP 地址里的两个码流

很多刚从桌面视觉转到嵌入式的人,第一次看到海康、宇视或杂牌摄像头的 RTSP 地址都会懵。以最常见的海康格式为例:

rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101

关键的坑在最后一位数字:101代表通道 1 主码流,102代表通道 1 子码流。大华通常用cam/realmonitor?channel=1&subtype=0表示主码流,subtype=1表示子码流,各家厂商略有不同。

主码流分辨率高、码率大,适合录像和事后追溯;子码流分辨率低、码率小,适合实时预览和传输。但在推理场景我建议反过来用:推理用子码流,预览用主码流。

原因很直白,YOLOv5s 输入分辨率是 640x640,本来就要把帧缩放到这个尺寸,主码流的 2688x1520 高清信息在缩放过程中大部分被丢掉,而解码主码流占用的 CPU 资源却是子码流的好几倍。RK3588 虽然 CPU 强,但也别浪费在解码高码流上,NPU 推理那点时间才值得花。

实际操作时我在代码里先尝试打开子码流,失败了再回退主码流。有些杂牌摄像头子码流格式不规范,会导致 OpenCV 解码花屏,所以要做个降级逻辑。

2.3 拉流线程 + 帧队列的工程化写法

OpenCV 的cv2.VideoCapture直接放在主线程里是个很不好的习惯,read()是阻塞的,而且帧间隔不均匀。如果算法处理速度慢于摄像头帧率,你最后拿到的画面会越来越新,队列直接堆积到延迟爆表。

我的做法很简单:独立拉流线程 + 有界帧队列 + 推理侧消费。生产者只管读帧,消费侧按自己的节拍取帧,两边互不拖累。代码骨架长这样:

import threading import queue import cv2 class RtspStream: def __init__(self, rtsp_url, queue_size=8): self.url = rtsp_url self.frame_queue = queue.Queue(maxsize=queue_size) self._stop_event = threading.Event() self._thread = threading.Thread(target=self._run, daemon=True) def _run(self): cap = cv2.VideoCapture(self.url) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) while not self._stop_event.is_set(): ok, frame = cap.read() if not ok: print("拉流失败,尝试重连...") cap.release() cv2.VideoCapture(self.url) # 实际代码要做好延迟重试 continue if self.frame_queue.full(): # 丢最旧的一帧,而不是阻塞生产者 try: self.frame_queue.get_nowait() except queue.Empty: pass self.frame_queue.put(frame) cap.release() def get_frame(self): try: return self.frame_queue.get(timeout=0.5) except queue.Empty: return None def start(self): self._thread.start()

这段代码就俩关键点:队列是满的丢旧帧,不阻塞抓帧线程;每帧必须从队列里捞最新的,宁丢勿等。你如果对着队列做put_nowait然后一满了就不读了,不到半小时摄像头就和服务器断流。

3. 折腾最久的坑:RKNN 句柄被并发访问导致的“假死”

3.1 现象:一切正常,直到三分钟后

服务层跑起来的最初几天,一切都好得像假的。API 调用检测图片,正常;摄像头画面检测,正常;延迟从没超过 80ms。我甚至觉得这篇博文可以收尾了。

直到一次演示现场,观众围上来那一刻,服务挂了。准确说不是崩溃,是“假死”:CPU 占用率不高,内存没爆,NPU 温度正常,但任何请求打过来都是超时,摄像头画面固定在最后一帧,日志里没有任何报错。杀掉进程重启,又活蹦乱跳,再过三五分钟又卡住。

这种“周期性假死”是嵌入式部署里很难受的问题,因为它不像段错误那样当场暴露问题,而是给你一个“疲惫期”。

3.2 排查:把系统拆成三块逐个排除

我排查这一类问题很少直接看代码,而是先划嫌疑范围。当时拆成了三块:

第一块,摄像头线程。为了排除它,我把RtspStream整个停掉,只发静止图片检测请求,结果请求量一大照样卡死。怀疑对象一排除。

第二块,FastAPI 的异步阻塞。因为 RKNN 推理是同步阻塞函数,我在路由里用了run_in_executor把它塞进线程池。看起来合理,但一个疑问冒上来:会不会是线程池里的多个线程同时抢一个 NPU 句柄,导致 RKNN 内部状态错乱?这是我当时的猜测,但证据还不够。

第三块,RKNN 模型实例本身。我写了个脚本,不开 HTTP 服务,单纯开 8 个 Python 线程同时调用同一个 RKNN 对象的inference方法。果然,脚本跑到几分钟后出现E [RKNN] Internal error: Out of memory,然后整个推理卡死。到这里,根因基本锁定了。

3.3 根因:同一个 RKNN 实例做不了多线程访问

结论很明确:RKNN Runtime 的同一个模型句柄重复进入rknn.run()是非线程安全的。在 NPU Runtime 的角度看,每一次推理要从内部缓冲区申请输入、触发 NPU 任务、回收输出,这个过程不仅依赖参数,还依赖内部上下文。多线程同时调用时,两个线程可能共同修改了同一个内部上下文对象,轻则推理结果混乱,重则上下文损坏,后续所有推理请求全部卡在 NPU 等待队列里。

打个比方,这就像两个厨师同时往同一口锅里加菜而没有任何协调,锅本身没坏,但菜品完全失控,下一次进厨房的人都不知道该切什么。

我一开始天真地以为 RKNN 内部有锁,毕竟市场上的深度学习推理框架大多做了线程安全。但实测下来,要么加外部锁,要么用单 worker 串行化,千万别裸上并发。

3.4 修复:推理进程串行化 + 请求排队

我的修复方案没有用全局锁,因为锁会让所有请求排队等待同一个 NPU 算力,表面看不卡死,实际上吞吐量也上不去。更清晰的思路是引入一个推理 worker 线程,所有推理请求都通过队列交给它,只有这个线程能接触 RKNN 实例。代码如下:

import queue import threading import asyncio from concurrent.futures import Future class InferenceWorker: def __init__(self, rknn_model): self.rknn = rknn_model self._queue = queue.Queue(maxsize=128) self._thread = threading.Thread(target=self._run, daemon=True) self._thread.start() def _run(self): while True: frame, future = self._queue.get() try: results = self.rknn.inference(frame) future.set_result(results) except Exception as e: future.set_exception(e) def infer_sync(self, frame): future = Future() self._queue.put((frame, future)) return future.result(timeout=2.0) async def infer_async(self, frame): future = Future() self._queue.put((frame, future)) return await asyncio.wrap_future(future)

这样 FastAPI 路由可以安全地调用await worker.infer_async(frame),摄像头线程也通过同一个队列访问,RKNN 实例永远只出现在一个线程里,不可能被并发触碰。同时我把队列容量限制了 128 条,满了就丢最旧的推理请求而不是新来的识别请求,防止内存无限涨。

改了之后跑了三天,用摄像头连续推流 + 每秒 10 次 API 请求的混合压力,稳定复现次数为零。这个坑算是彻底踩平了。

4. 服务接口设计、参数调优与长期稳定运行

4.1 接口设计:对外只暴露几个必要的 API

服务层我对外暴露了这几个接口,不多不少:

接口方法作用请求体 / 参数
/healthzGET服务健康检查无
/detect/imagePOST上传图片并检测multipart图片文件
/detect/urlPOST传入图片 URL 并检测{"url": "..."}
/camera/startPOST启动一路摄像头{"rtsp_url": "...", "cam_id": "cam1"}
/camera/stopPOST停止指定摄像头{"cam_id": "cam1"}
/camera/previewGET获取当前检测后的预览帧?cam_id=cam1

检测结果统一返回 JSON,字段固定为boxes,scores,classes,fps,cam_id,其中boxes是[x1, y1, x2, y2]归一化坐标,方便上位机直接换算到任意分辨率。

这里多说一句:很多人在设备对接时把接口设计得巨复杂,什么批量检测、什么自定义模型版本切换,全都堆上去。嵌入式平台上 HTTP 接口每层包装都是成本,前期保持接口精简、参数固定,出问题时更容易定位是服务层问题还是上游问题。

4.2 实测性能与资源占用

我部署环境是 RK3588、8GB 内存,YOLOv5s 转 RKNN(fp16 混合量化),输入分辨率 640x640。用今晚的实际数据做个参考:

场景平均推理耗时摄像头到JSON整体延迟板卡 CPU 占用
单图 HTTP 推理18ms25ms单核 60% 左右
摄像头连续推理(~15fps)22ms80ms四核总共 40%
双摄像头各 10fps28ms120ms四核总共 55%
静态图压测(20 并发)20ms单请求 120msNPU 稳定接近满载

注意双摄像头时,解码子码流加上帧缩放确实增加了一些 CPU 开销,但整体依然远未跑满。这也验证了前面的指导:推理用子码流 + 独立队列,资源占用非常健康。

另外一个容易被忽略的资源点是内存。RKNN 模型加载后内部缓冲区大概占 200MB 多,摄像头帧队列如果每帧是缩放后的 640x640 RGB,单帧大概 1.2MB,队列控制在 8 帧以内,整个服务峰值内存加起来不到 600MB,对 8GB 的板子非常宽松。

4.3 稳定的关键:队列上限、超时与看门狗

在跑了一段时间后我发现,长期稳定运行拼的不是算力,而是细节。两三个小时后服务是否还健康,取决于这几个不起眼的配置:

  • 队列上限必须有。摄像头队列、推理队列、FastAPI 请求队列全部设置maxsize,满了丢旧数据,防止内存被慢消费方拖垮;
  • 推理必须设超时。我用的future.result(timeout=2.0),如果单帧推理超过 2 秒,直接抛异常返回错误给上层,不让请求挂死;
  • 摄像头断流重连要做退避。连续失败 5 次后休眠 10 秒再重连,否则现场摄像头短时断电后,拉流线程会陷入无限重连循环,CPU 被白白吃掉;
  • 定期健康检查。我在/healthz里检查推理 worker 队列积压长度和摄像头线程存活状态,返回200/503,配合系统定时任务做进程级看门狗。

4.4 常见问题速查表

问题原因解决
推理卡死时无任何报错RKNN 句柄被多线程并发访问用单 worker 串行化推理
摄像头画面延迟越来越大帧队列无界,消费跟不上生产有界队列丢旧帧
RTSP 断流后无法恢复拉流线程退出后没有重连逻辑增加退避重试循环
请求偶尔超时FastAPI 事件循环被同步阻塞用infer_async或run_in_executor
双路摄像头内存涨不停每路摄像头都申请独立 RKNN 模型共享同一个 worker 实例

5. 写在最后的一点体会

我在实际部署中最深的体会是:RK3588 上的 YOLOv5s 其实已经不难跑了,真正拉开差距的是“服务层怎么设计”和“边界条件处理得干不干净”。那个折腾最久的坑也提醒我,跑在 CPU 上的思路放到 NPU 上不一定成立——RKNN 的句柄就是那么脆弱,该加队列就加队列,别迷信框架的隐式锁。如果你也被“跑一阵子就假死”折磨得头疼,不妨按这个方向查一遍:把所有模型访问收敛到单个线程,大概率药到病除。

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

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

立即咨询