☰
RK3588部署YOLOv5s:从RKNN推理到FastAPI服务与摄像头接入的并发避坑指南
2026/10/6 1:14:04 网站建设 项目流程

1. 从裸模型到可用服务:为什么服务层不是"顺手写个接口"那么简单

模型在 NPU 上跑通推理,和"这套东西能被人用起来"之间,隔着一条比想象中宽得多的沟。我在 RK3588 上把 YOLOv5s 转成 RKNN、跑通单帧推理之后,一度以为剩下的就是"包个 FastAPI 接口"——结果真正折腾最久的恰恰是这一层。原因很朴素:板子上的推理是同步阻塞的、摄像头取流是持续不断的、而 HTTP 请求是并发且随时可能超时的,这三件事凑在一起,任何一处没处理好,表现出来都是"接口偶尔卡死"或者"跑一会儿就崩"。

这篇是系列第七篇,专门讲服务层怎么搭、摄像头怎么接、以及那个让我反复重启板子的坑。适合已经能在 RK3588 上跑通 RKNN 推理、准备把它做成一个真正能对外提供服务的读者。如果你还卡在模型转换阶段,建议先回看前几篇;如果你已经跑通服务但总觉得"不太稳",那这篇大概率能对上你的症状。

先把这一层的整体形态说清楚。我的目标很明确:板子开机后自动拉起一个 HTTP 服务,对外暴露一个检测接口,输入是一张图(或者一个摄像头帧),输出是检测框坐标加类别;同时后台有一个常驻线程持续从摄像头抓帧,按需触发推理。听起来简单,但拆开看至少涉及四块:推理引擎的封装、Web 框架的选型与并发模型、摄像头采集链路、资源调度与生命周期管理。下面逐块拆。

1.1 推理引擎封装:把 RKNN 的上下文管起来

RKNN 的推理不是无状态的。rknn_init会加载模型、申请 NPU 相关资源,rknn_run执行推理,rknn_outputs_get取结果,最后还要rknn_outputs_release和rknn_destroy。如果你在每个 HTTP 请求里都 init 一次、destroy 一次,单次延迟会高得离谱——我实测过,光 init 就要几百毫秒,而推理本身可能只要几十毫秒。所以正确做法是全局只初始化一次,常驻复用。

但常驻又带来新问题:RKNN context 不是线程安全的。多个线程同时调rknn_run会出各种诡异结果,轻则输出错乱,重则直接段错误。我的处理是给推理加一把互斥锁,把"一次完整推理"包成临界区。这样虽然牺牲了并行度,但 RK3588 的 NPU 本身也就那么点算力,串行反而更可控。

封装上我写了一个Detector类,核心方法就两个:infer(img)返回检测结果,release()释放资源。内部维护一个rknn句柄和一把threading.Lock。这里有个细节值得说:输入图像的预处理(resize、letterbox、归一化)最好放在锁外面做,因为这部分是纯 CPU 计算,不涉及 NPU 资源,放锁里会白白拉长临界区。我一开始图省事全塞锁里,QPS 直接掉了一半。

import threading import numpy as np from rknnlite.api import RKNNLite class Detector: def __init__(self, model_path): self.rknn = RKNNLite() self.rknn.load_rknn(model_path) self.rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0) self.lock = threading.Lock() def infer(self, img): # 预处理在锁外完成 blob = self._preprocess(img) with self.lock: outputs = self.rknn.inference(inputs=[blob]) return self._postprocess(outputs) def release(self): self.rknn.release()

注意:init_runtime的core_mask参数在 RK3588 上可以指定用哪个 NPU 核心。单模型场景用NPU_CORE_0就够,多模型并行时才考虑分核。别一上来就NPU_CORE_0_1_2,多核调度有额外开销,单模型反而更慢。

1.2 为什么选 FastAPI 而不是 Flask

热词里"flask 与 fastapi 比较"出现频率很高,这里说说我的取舍。Flask 是同步 WSGI 框架,默认一个请求占一个线程;FastAPI 基于 ASGI,原生支持 async。但要注意——我的推理是同步阻塞的,用 FastAPI 的 async 并不能让推理变快。那为什么还选它?

三个理由。第一,FastAPI 自带 Pydantic 校验和自动生成的接口文档,调试阶段省事太多;第二,它的依赖注入机制很适合管理 Detector 这种全局单例;第三,如果以后要加异步的日志上报、结果推送,ASGI 的扩展性更好。但关键点是:同步推理函数必须用def定义而不是async def,这样 FastAPI 会自动把它丢到线程池执行,不会阻塞事件循环。如果你写成async def却在里面调同步推理,整个服务会被一个请求卡死——这是我踩过的第一个坑。

from fastapi import FastAPI, UploadFile from fastapi.responses import JSONResponse app = FastAPI() detector = Detector("yolov5s.rknn") @app.post("/detect") def detect(file: UploadFile): # 注意是 def 不是 async def img = decode_image(file.file.read()) results = detector.infer(img) return JSONResponse({"boxes": results})

1.3 服务层要解决的三个隐性问题

除了接口本身,还有三件事必须在设计阶段就想清楚,否则后期返工成本极高。

第一是超时与背压。摄像头帧是持续产生的,如果推理速度跟不上采集速度,帧会越堆越多,内存涨到爆。我的做法是采集端只保留"最新一帧",旧帧直接丢弃。检测请求永远拿最新帧,不排队。

第二是生命周期。Detector 的初始化和释放必须绑定到服务的启动和关闭事件上,用 FastAPI 的lifespan机制管理,别用全局变量裸初始化——否则 uvicorn 热重载时会重复 init,NPU 资源泄漏。

第三是日志。热词里"uvicorn fastapi 日志丢失问题"是个真实痛点。uvicorn 默认的日志配置会覆盖你自定义的 logger,导致自己打的日志不输出。解决办法是在启动时显式配置 logging,或者用--log-config指定配置文件。我一开始排查"为什么推理日志不打印"花了大半天,最后发现是 uvicorn 把 root logger 的 handler 清掉了。

2. 摄像头接入:从 V4L2 到 OpenCV 的取舍与踩坑

摄像头这块,热词里出现了 ov5647、ov2640、MIPI 屏幕适配、RTSP 取流等一堆关键词,说明大家踩的坑高度重合。我在 RK3588 上试过三种接入方式:USB 摄像头走 V4L2、MIPI 摄像头走板载 ISP、网络摄像头走 RTSP。三种方式的坑各不相同,下面分开说。

2.1 USB 摄像头:OpenCV 能开但帧率上不去

最省事的是 USB 摄像头,cv2.VideoCapture(0)直接就能开。但实测下来有两个问题。一是默认的 MJPG 格式在 RK3588 上解码会吃 CPU,1080p 下能占到一整个核心;二是 OpenCV 的read()是阻塞的,如果摄像头掉线,这个调用会一直卡住,把整个采集线程拖死。

我的处理是显式指定后端和格式:

cap = cv2.VideoCapture(0, cv2.CAP_V4L2) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'YUYV')) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap.set(cv2.CAP_PROP_FPS, 30)

用 YUYV 而不是 MJPG,是因为 YUYV 是原始格式,不需要解码,CPU 占用低得多,代价是带宽高。640x480 对 YOLOv5s 的输入尺寸(通常是 640)刚好匹配,不用额外缩放。另外一定要设一个采集超时,别让read()无限阻塞。

提示:cv2.CAP_PROP_FOURCC的设置必须在设置分辨率之前,否则可能不生效。这个顺序问题我在两个不同的摄像头上都遇到过,属于 V4L2 驱动的通病。

2.2 MIPI 摄像头:ISP 配置才是真正的门槛

MIPI 摄像头(比如 ov5647、ov2640 这类)在 RK3588 上不是插上就能用的。它走的是板载 MIPI CSI 接口,需要设备树里正确配置 ISP 和 sensor 节点,内核里要有对应的驱动。热词里"rk3588 linux 适配 mipi 屏幕"和摄像头其实是同一类问题——都是设备树和驱动的事。

我的经验是:先确认内核里有没有对应 sensor 的驱动,用v4l2-ctl --list-devices看能不能枚举出设备节点。如果枚举不出来,别急着写应用代码,先回去查设备树。设备树里 sensor 的 I2C 地址、时钟频率、lane 数、data-lanes 这些参数错一个,设备就出不来。这部分调试建议用dmesg看内核日志,驱动加载失败一般会有明确报错。

设备节点出来之后,MIPI 摄像头通常输出的是 RAW 格式(比如 RAW10),需要经过 ISP 处理才能变成 YUV 或 RGB。RK3588 的 ISP 可以通过 rkisp 驱动配合 3A(自动曝光、自动白平衡、自动对焦)库来用,但配置相当繁琐。如果只是做检测,我建议直接用板厂提供的 ISP 配置工具生成一份可用的配置,别自己从零调。

2.3 RTSP 网络摄像头:延迟和断流是两大敌人

网络摄像头走 RTSP 是最灵活的方案,热词里"海康摄像头 rtsp 协议的主码流和子码流"说明很多人卡在取流地址上。主码流分辨率高但码率高、延迟大,子码流分辨率低但流畅。做实时检测,优先用子码流,因为 YOLOv5s 输入就 640,主码流的 1080p 或 4K 纯属浪费带宽和解码算力。

OpenCV 读 RTSP 的经典问题是延迟累积和断流不重连。延迟累积是因为 OpenCV 内部有缓冲队列,帧越积越多。解决办法是开一个独立线程持续read()并只保留最新帧,主线程从共享变量取。断流重连则要自己写重试逻辑,检测到连续多帧读取失败就释放重连。

import threading, time, cv2 class RTSPReader: def __init__(self, url): self.url = url self.frame = None self.running = True self.thread = threading.Thread(target=self._loop, daemon=True) self.thread.start() def _loop(self): while self.running: cap = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) fail = 0 while self.running and fail < 30: ok, frame = cap.read() if ok: self.frame = frame fail = 0 else: fail += 1 time.sleep(0.01) cap.release() time.sleep(1) # 重连前稍等 def get(self): return self.frame

这个模式我用了很久,稳定性比直接在请求里read()好太多。核心思想就是采集和消费解耦,采集线程只管把最新帧放到共享变量,消费方永远拿最新的,不关心中间丢了多少帧。

3. 那个折腾最久的坑:多线程下的 NPU 资源竞争

前面铺垫了这么多,现在说正题——那个让我反复重启板子、排查了整整两天的坑。现象是这样的:服务跑起来之后,单请求测试完全正常,但只要并发上来(哪怕只有两三个请求同时打),板子就会在几秒到几十秒内整机卡死,SSH 都连不上,只能硬重启。

3.1 现象拆解:为什么是"整机卡死"而不是"接口报错"

正常的程序 bug 顶多让进程崩溃或者返回 500,但整机卡死说明问题出在内核层或者硬件资源层。我一开始怀疑是内存泄漏,用free -h盯着看,发现内存确实在涨,但涨得没那么快,不至于几十秒就 OOM。又怀疑是 CPU 过热降频,查了温度也就六十多度,正常。

真正的线索来自一次偶然:我在卡死前刚好开着dmesg -w,看到刷屏的 NPU 相关报错,大意是"资源忙"或者"上下文无效"。这时候我才意识到,问题不在我的 Python 代码逻辑,而在NPU 驱动层对并发访问的处理。

3.2 根因定位:锁加错了地方

回头看我的代码,Detector 里确实加了锁,但锁的粒度有问题。我最初写的是:

def infer(self, img): with self.lock: blob = self._preprocess(img) # 预处理也在锁里 outputs = self.rknn.inference(inputs=[blob]) return self._postprocess(outputs)

看起来没问题对吧?但问题在于,我同时开了两个 Detector 实例——一个给 HTTP 接口用,一个给摄像头后台线程用。两个实例各自有各自的锁,但它们共享同一个 NPU 硬件。两个锁互不感知,两个线程同时调rknn_run,NPU 驱动就炸了。

这就是坑的核心:锁保护的是 Python 对象,不是硬件资源。只要有两个独立的 RKNN context 同时访问 NPU,锁再多也没用。修复方案很简单——全局只保留一个 Detector 实例,所有推理请求都走它,锁自然就生效了。

# 全局单例,HTTP 和摄像头线程共用 detector = Detector("yolov5s.rknn")

改完之后,并发测试跑了半小时,稳如老狗。回头看这个坑其实不复杂,但它隐蔽在"我明明加了锁"的错觉里,加上整机卡死这种极端表现,很容易往错误方向排查。

3.3 排查这类问题的通用思路

这次经历让我总结出一套排查嵌入式并发问题的套路,分享出来:

排查方向具体手段判断依据
内存free -h持续观察是否快速逼近上限
温度cat /sys/class/thermal/thermal_zone*/temp是否触发降频阈值
内核日志dmesg -w实时盯有无驱动层报错
进程状态top看 CPU 占用分布是否某进程吃满
硬件资源查驱动文档的并发限制是否允许多 context

关键心得是:整机级故障优先看内核日志,别在应用层瞎猜。应用层的日志在整机卡死时根本来不及打出来,而dmesg是内核环形缓冲,卡死前的报错往往还在里面。

注意:RKNN 的 NPU 资源是独占的,官方文档里其实有提到多线程访问需要自己做同步,但这句话很容易被忽略。如果你要用多模型,正确做法是分核(core_mask 指定不同核心),而不是让多个 context 抢同一个核。

4. 服务与摄像头的联动:把两条链路接起来

单有服务、单有摄像头采集都不难,难的是让它们协同工作还不互相拖累。这一节讲联动设计和资源调度。

4.1 触发式推理 vs 轮询式推理

摄像头帧是 30fps 持续来的,但推理可能只有 10fps,如果每帧都推理,帧会堆积。两种策略:一是轮询式,后台线程固定间隔取最新帧推理;二是触发式,有 HTTP 请求时才推理当前最新帧。

我最终选的是混合模式:后台线程以固定频率(比如 5fps)对最新帧做推理,结果存到一个共享的"最新结果"变量里;HTTP 接口直接返回这个最新结果,不触发新推理。这样接口响应极快(微秒级),推理负载也可控。如果业务需要"请求即推理",再加一个同步接口走同一个 Detector 即可。

class InferenceWorker(threading.Thread): def __init__(self, reader, detector, interval=0.2): super().__init__(daemon=True) self.reader = reader self.detector = detector self.interval = interval self.latest = None self.running = True def run(self): while self.running: frame = self.reader.get() if frame is not None: self.latest = self.detector.infer(frame) time.sleep(self.interval)

4.2 资源竞争下的优先级设计

当 HTTP 同步推理和后台推理同时存在时,谁优先?我的设计是HTTP 请求优先。因为后台推理是"尽力而为",晚一点没关系;而 HTTP 请求有超时,卡住用户体验差。实现上可以用一个带优先级的锁,或者简单地让后台线程在拿不到锁时直接跳过这一轮。

这里有个反直觉的点:后台推理频率不是越高越好。我一开始设成 10fps,结果 NPU 长期满载,温度上来之后降频,反而整体吞吐下降。后来降到 5fps,NPU 有了喘息空间,HTTP 请求的延迟反而更稳定。嵌入式场景下,"留余量"比"榨干性能"更重要。

4.3 优雅关闭:别让板子带着脏状态重启

服务关闭时,必须按顺序做几件事:停止采集线程、停止推理线程、释放 Detector、释放摄像头。顺序错了会出问题——比如先释放 Detector 再停推理线程,推理线程会访问已释放的 context,直接段错误。

用 FastAPI 的 lifespan 管理:

from contextlib import asynccontextmanager @asynccontextmanager async def lifespan(app): reader = RTSPReader(RTSP_URL) worker = InferenceWorker(reader, detector) worker.start() yield worker.running = False worker.join(timeout=3) reader.running = False detector.release() app = FastAPI(lifespan=lifespan)

这套流程跑通之后,服务可以反复启停而不需要重启板子,开发效率高了很多。

5. 实测数据与几个值得记住的经验

最后把实测数据和踩坑经验集中说一下,这些是文档里不会写、但实际部署一定会遇到的东西。

5.1 性能实测

在 RK3588 上,YOLOv5s(640x640 输入)单帧推理耗时约 25-35ms,取决于 NPU 频率和是否降频。加上预处理和后处理,端到端约 40-50ms,也就是单实例约 20-25fps 的理论上限。但实际部署我建议按 10fps 设计,留出余量应对温度波动和并发。

环节耗时(ms)说明
图像预处理8-12resize + letterbox + 归一化
NPU 推理25-35受频率影响大
后处理5-8NMS 是主要开销
端到端40-55单实例串行

5.2 几条用血换来的经验

第一,别在锁里做 CPU 计算。预处理、后处理都放锁外,临界区只包rknn_run。这一条让我的 QPS 提升了近一倍。

第二,全局单例是嵌入式推理的默认选择。除非你明确知道要分核跑多模型,否则一个 Detector 走天下。多实例带来的不是性能,是灾难。

第三,摄像头采集永远独立线程 + 只留最新帧。这个模式适用于任何"采集快、消费慢"的场景,能避免 90% 的延迟累积问题。

第四,整机卡死先看 dmesg。应用层日志在整机故障时不可靠,内核环形缓冲才是真相所在。

第五,留余量。NPU 别跑满,温度别贴阈值,内存别用尽。嵌入式设备的稳定性来自余量,不是来自极限压榨。

这套服务层 + 摄像头的组合,我从最初的天天重启板子,到现在能连续跑几天不出问题,中间踩的坑基本都在这篇里了。如果你正在做类似的事,希望这些经验能帮你少走点弯路。下一篇我打算聊聊怎么把这套东西做成开机自启的 systemd 服务,以及远程更新模型文件的方案,有兴趣的可以关注。

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

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

立即咨询