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 | 多线程,线程阻塞 | 极低 | 中等 | 简单原型、内网小工具 |
| FastAPI | asyncio 异步 + 线程池 | 低,自带 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
服务层我对外暴露了这几个接口,不多不少:
| 接口 | 方法 | 作用 | 请求体 / 参数 |
|---|---|---|---|
/healthz | GET | 服务健康检查 | 无 |
/detect/image | POST | 上传图片并检测 | multipart图片文件 |
/detect/url | POST | 传入图片 URL 并检测 | {"url": "..."} |
/camera/start | POST | 启动一路摄像头 | {"rtsp_url": "...", "cam_id": "cam1"} |
/camera/stop | POST | 停止指定摄像头 | {"cam_id": "cam1"} |
/camera/preview | GET | 获取当前检测后的预览帧 | ?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 推理 | 18ms | 25ms | 单核 60% 左右 |
| 摄像头连续推理(~15fps) | 22ms | 80ms | 四核总共 40% |
| 双摄像头各 10fps | 28ms | 120ms | 四核总共 55% |
| 静态图压测(20 并发) | 20ms | 单请求 120ms | NPU 稳定接近满载 |
注意双摄像头时,解码子码流加上帧缩放确实增加了一些 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 的句柄就是那么脆弱,该加队列就加队列,别迷信框架的隐式锁。如果你也被“跑一阵子就假死”折磨得头疼,不妨按这个方向查一遍:把所有模型访问收敛到单个线程,大概率药到病除。