1. 服务层选型:为什么是 FastAPI 而不是 Flask
把 YOLOv5s 在 RK3588 的 NPU 上跑通推理,只是整条链路的一半。真正让这套东西"能用"的,是外面那层服务接口——摄像头把画面喂进来,服务层调度推理,再把结果吐给前端或者下游业务。我在这一层上纠结的时间,说实话比调 NPU 算子还长。
先说选型。RK3588 上做推理服务,常见的选择无非三种:Flask、FastAPI、还有直接上 C++ 写个 socket 服务。Flask 生态最熟,但它是同步阻塞模型,YOLOv5s 单帧推理在 NPU 上大概 30 到 50 毫秒,如果摄像头是 30fps 推流,Flask 默认的单线程处理会直接把请求堆成山。C++ 服务性能最好,但开发迭代慢,改个返回字段都要重新编译,调试成本太高。
FastAPI 恰好卡在中间:原生 async/await 支持,底层是 Starlette + Uvicorn,异步 IO 处理摄像头帧的读取和推理调度非常顺手;Pydantic 做请求响应校验,返回结构一目了然;自动生成 Swagger 文档,联调的时候前端直接看/docs就行。我实测下来,同样一台 RK3588,FastAPI 在并发 8 路请求下的平均响应延迟比 Flask 低了将近 40%。
不过这里有个坑要先说清楚:FastAPI 的异步不等于推理异步。YOLOv5s 的 NPU 推理是同步阻塞的 C 接口调用,你把它塞进async def里,事件循环照样被卡住。正确的做法是用run_in_executor把推理丢到线程池,或者干脆用多进程 + 队列的方式做推理调度。这个点后面会展开讲。
1.1 服务层的职责边界到底划在哪
很多人一开始会把服务层写成"万能层"——读摄像头、预处理、推理、后处理、画框、编码、推流全塞在一个接口里。我第一版就是这么干的,结果一个/detect接口 200 多行,改一处崩三处。
后来我重新划了边界,服务层只做三件事:
- 接收请求:可以是 HTTP 上传的图片,也可以是摄像头帧的触发信号
- 调度推理:把图像数据交给推理引擎,拿回检测结果
- 组织响应:把结果序列化成 JSON 或者带标注的图片返回
预处理(resize、归一化、letterbox)和后处理(NMS、坐标还原)放在推理引擎模块里,摄像头采集单独一个模块,三者通过明确的数据结构通信。这样拆完之后,每个模块都能单独测试,NPU 推理模块甚至可以在 PC 上用 ONNX Runtime 跑通再移植到板子上。
1.2 目录结构:别小看这一层,它决定了你后期改动的痛苦程度
FastAPI 项目最容易犯的错就是所有代码堆在main.py。我踩过一次之后,现在固定用这套结构:
yolo_service/ ├── app/ │ ├── main.py # 应用入口,注册路由和生命周期 │ ├── api/ │ │ ├── routes_detect.py # 检测相关接口 │ │ └── routes_camera.py # 摄像头控制接口 │ ├── core/ │ │ ├── config.py # 配置管理 │ │ └── logger.py # 日志 │ ├── schemas/ │ │ └── detect.py # Pydantic 模型 │ ├── services/ │ │ ├── inference.py # NPU 推理封装 │ │ └── camera.py # 摄像头采集 │ └── utils/ │ └── image.py # 图像处理工具 ├── models/ │ └── yolov5s.rknn # 转换好的模型 └── requirements.txt这个结构的好处是,services/inference.py里只关心"给我一张图,我还你检测框",完全不依赖 FastAPI。哪天你要换成 gRPC 或者 MQTT 接口,这一层原封不动。
2. NPU 推理封装:从 rknn 接口到可复用的服务模块
RK3588 的 NPU 通过 RKNN Runtime 调用,核心 API 就那几个:rknn_init、rknn_query、rknn_inputs_set、rknn_run、rknn_outputs_get。但直接裸调这些接口写业务代码,会有几个问题:模型加载只能一次、输入输出 shape 要手动管理、多线程调用会崩。
我的做法是封装一个RKNNModel类,在应用启动时初始化一次,全局复用。这里有个关键点:RKNN 的 context 不是线程安全的。如果你用 FastAPI 的多线程 executor 并发调用同一个 context,大概率会段错误。解决方案有两种,我两种都试过:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 多 context | 每个线程初始化一个 RKNN context | 并发安全,实现简单 | 内存占用翻倍,模型加载慢 |
| 单 context + 锁 | 全局一个 context,加 threading.Lock | 内存省,模型只加载一次 | 并发退化成串行 |
RK3588 有 3 个 NPU core,理论上可以并行跑 3 路推理。我最后采用的是多 context + core 绑定的方案:初始化 3 个 context,分别绑定到 NPU core 0/1/2,用一个简单的轮询调度器分配请求。实测 3 路并发时,总吞吐比单 context 加锁方案高了 2.3 倍。
2.1 模型加载阶段的参数陷阱
rknn_init的时候有几个参数特别容易踩坑。第一个是mean_values和std_values,这两个必须和你在 PC 上转换模型时用的归一化参数一致。YOLOv5s 官方用的是 0-1 归一化,mean 是 0,std 是 255。如果你转换时没设,推理时又按 0-1 传图,结果会全错但不会报错——这是最坑的,模型能跑,就是框全乱。
第二个是target_platform,必须显式指定RK3588。不指定的话 RKNN 会按默认平台优化,性能差一大截。
第三个是量化方式。YOLOv5s 转 RKNN 一般用 int8 量化,速度快但精度会掉。我实测下来,用 500 张自己的场景图做量化校准,mAP 掉大概 2 到 3 个点;如果用官方 COCO 校准集,掉 5 个点以上。所以量化校准集一定要用你自己场景的图,这个经验值不少钱。
2.2 输入输出的内存布局
RKNN 的输入输出都是 numpy array,但布局有讲究。输入一般是 NHWC,输出 YOLOv5s 是三个尺度的 feature map。后处理的时候要按 RKNN 输出的顺序解析,不同版本的 rknn-toolkit2 输出顺序可能不一样,建议在初始化后打印一次rknn_query的 output attrs,确认 shape 和顺序。
# 初始化后确认输出结构 for i in range(output_num): attr = rknn.query(RKNNQuery.OUTPUT_ATTR, i) print(f"output {i}: shape={attr.shape}, fmt={attr.fmt}")这一步花我半小时,但省了后面几小时的调试。
3. 摄像头接入:UVC、RTSP 和 MIPI 三条路怎么选
摄像头这块是整篇文章里折腾最久的部分,没有之一。RK3588 支持三种摄像头接入方式,各有各的坑。
UVC(USB 摄像头)是最简单的,插上/dev/video*就能用,OpenCV 的VideoCapture直接读。但 RK3588 的 USB 带宽有限,1080p 30fps 的 MJPEG 流勉强够,如果同时接两个 USB 摄像头,第二个大概率掉帧或者直接枚举失败。我试过用 USB Hub 接两个,结果两个都只能跑到 15fps。
RTSP(网络摄像头)适合已经布好网线的场景,海康、大华这些国标摄像头都支持。OpenCV 读 RTSP 有个经典问题:延迟会越积越大,因为解码速度跟不上推流速度时,缓冲区会堆积。解决办法是设置CAP_PROP_BUFFERSIZE为 1,或者用单独的线程持续grab()丢弃旧帧,只retrieve()最新帧。
MIPI CSI(排线摄像头)是 RK3588 原生支持的,延迟最低,带宽最大,但配置最麻烦。需要改设备树、配 ISP、调 media-ctl 管线。我用的是 OV5647 模块,从接线到出图花了整整两天。
3.1 UVC 摄像头的枚举与多路区分
如果你只接一个 USB 摄像头,cv2.VideoCapture(0)就够了。但接多个的时候,/dev/video0、/dev/video1的顺序是不固定的,重启一次可能就变了。正确做法是通过v4l2-ctl --list-devices拿到设备名和对应的/dev/videoX映射,然后在代码里按设备名匹配。
v4l2-ctl --list-devices # 输出示例: # USB Camera: USB Camera (usb-xhci-hcd.6.auto-1): # /dev/video0 # /dev/video1注意一个 USB 摄像头通常会占用两个 video 节点,一个是采集,一个是 metadata。真正能读帧的是第一个。这个坑我踩过,代码里打开/dev/video1一直读不到帧,查了半天才发现是 metadata 节点。
3.2 RTSP 取流的延迟控制
RTSP 取流我用的是 OpenCV + FFmpeg 后端。默认配置下,延迟会从几百毫秒慢慢涨到几秒。核心原因是 OpenCV 内部有个帧缓冲队列,解码慢的时候队列越堆越长。
我的解决方案是开一个独立线程专门做grab(),主线程只取最新帧:
import cv2 import threading class RTSPReader: def __init__(self, url): self.cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG) self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) self.frame = None self.running = True self.lock = threading.Lock() self.thread = threading.Thread(target=self._update, daemon=True) self.thread.start() def _update(self): while self.running: ret, frame = self.cap.read() if ret: with self.lock: self.frame = frame def read(self): with self.lock: return self.frame.copy() if self.frame is not None else None这个方案实测能把延迟稳定在 200 到 300 毫秒,对于大多数检测场景够用了。如果你要更低延迟,得上硬解码,RK3588 的 VPU 支持 H.264/H.265 硬解,但需要走 MPI 或者 GStreamer 管线,复杂度上一个台阶。
3.3 MIPI 摄像头:设备树和 ISP 管线
MIPI 摄像头是折腾最久的部分。OV5647 接上 RK3588 的 MIPI CSI 接口后,/dev/video*里看不到设备,因为设备树里没使能对应的节点。
需要改的地方有三处:I2C 节点下添加 sensor 子节点、MIPI CSI 控制器节点使能、ISP 节点配置。改完设备树重新编译 dtb,重启后才能看到设备。
出图之后还有 ISP 调优的问题。默认参数下画面偏暗偏绿,需要调 AWB(自动白平衡)和 AE(自动曝光)。RK3588 的 ISP 通过media-ctl和v4l2-ctl调参,具体参数值要看 sensor 手册和实际光照环境。我调了两天才把画面调正常,这块没有捷径,就是试。
4. 那个折腾最久的坑:异步推理与摄像头帧的竞态
前面铺垫了这么多,现在说标题里那个"折腾最久的坑"。表面上看是个并发问题,实际上牵扯到 FastAPI 的事件循环、NPU 的线程安全、还有摄像头帧的生命周期管理。
现象是这样的:单路请求测试一切正常,检测结果准确,延迟也低。但一旦同时开摄像头推流和 HTTP 检测接口,服务跑几分钟就会崩,日志里报的是Segmentation fault,没有任何 Python 层面的异常堆栈。
4.1 排查过程:从日志到 core dump
第一步是确认崩溃点。Python 层没有异常,说明是 C 扩展里崩的。我打开了 core dump,用 gdb 分析,栈顶是rknn_run内部的一个内存访问。这就指向了 RKNN context 的并发问题。
第二步是复现。我写了个最小复现脚本:两个线程同时调用同一个 RKNN context 的rknn_run,跑 100 次,第 30 多次就崩了。确认是 context 非线程安全。
第三步是定位为什么会有并发。FastAPI 的async def接口里,我用了await loop.run_in_executor(None, infer, frame),默认的 executor 是 ThreadPoolExecutor,线程数等于 CPU 核数。RK3588 是 8 核,所以最多 8 个线程同时调推理。而摄像头推流那边也有一个线程在调推理。两边共用一个 context,必崩。
4.2 修复方案:推理调度器的设计
修复思路很直接:不能让多个线程同时碰同一个 context。但简单地加锁会把并发性能打回串行,所以要做细一点。
我的方案是做一个推理调度器,内部维护一个 context 池,每个 context 绑定一个 NPU core,请求进来时轮询分配。每个 context 同时只被一个线程使用,用完归还。
import threading from queue import Queue class InferenceScheduler: def __init__(self, model_path, num_contexts=3): self.pool = Queue() for i in range(num_contexts): ctx = RKNNModel(model_path, core_id=i) self.pool.put(ctx) def infer(self, frame): ctx = self.pool.get() # 阻塞直到有可用 context try: return ctx.run(frame) finally: self.pool.put(ctx)这个方案的关键在于Queue.get()的阻塞特性——没有空闲 context 时请求会等待,而不是并发调用导致崩溃。实测 3 个 context 的情况下,并发 8 路请求时平均延迟从崩溃前的不可用变成了稳定的 120 毫秒左右。
4.3 摄像头帧的生命周期:另一个隐藏的坑
修完 context 并发之后,服务稳定了,但出现了新问题:检测结果偶尔会串帧。A 摄像头的检测框画到了 B 摄像头的画面上。
原因是摄像头读取线程和推理线程共享了 frame 对象。读取线程更新 frame 的时候,推理线程可能正在读同一个 numpy array,导致数据竞争。numpy 的赋值不是原子的,大数组拷贝过程中被覆盖就会串。
解决办法是每次读取都返回一份拷贝,或者用双缓冲。我选了拷贝方案,虽然多一次内存拷贝,但 1080p 的图拷贝一次也就几毫秒,换来的是数据安全。
def read(self): with self.lock: if self.frame is None: return None return self.frame.copy() # 关键:返回拷贝这个坑之所以折腾最久,是因为它不报错,只是结果偶尔不对,很难复现,也很难定位。我一开始怀疑是模型问题,换了模型、换了量化参数,都没用。后来加了帧序号追踪,才发现是数据竞争。
5. 服务层与摄像头的联调:从能跑到好用
单模块都跑通之后,联调阶段还有一堆细节要处理。这部分没有惊天动地的大坑,但每一个小问题都会影响最终体验。
5.1 启动顺序与资源初始化
FastAPI 的 lifespan 机制适合做资源初始化。RKNN context 池、摄像头连接、线程池都在启动时创建,关闭时释放。这里要注意的是,如果摄像头初始化失败,服务不应该直接崩,而是要降级——HTTP 检测接口还能用,只是摄像头功能不可用。
from contextlib import asynccontextmanager @asynccontextmanager async def lifespan(app): app.state.scheduler = InferenceScheduler("models/yolov5s.rknn") try: app.state.camera = CameraManager() except Exception as e: logger.error(f"camera init failed: {e}") app.state.camera = None yield if app.state.camera: app.state.camera.release()5.2 日志:uvicorn 日志丢失的解决
用 uvicorn 跑 FastAPI 的时候,默认的日志配置会覆盖你自定义的 logger,导致自己打的日志不输出。解决办法是在启动时传--log-config指定配置文件,或者在代码里重新配置 root logger。
我踩过一次,调试的时候发现logger.info完全不输出,查了半天以为是日志级别问题,最后发现是 uvicorn 的dictConfig把 handler 全清了。现在固定用log_config=None启动,自己在代码里配。
5.3 性能实测数据
最后贴一组我在 RK3588 上的实测数据,供参考:
| 场景 | 配置 | 平均延迟 | 吞吐 |
|---|---|---|---|
| 单路 HTTP 检测 | 1 context | 45ms | 22 fps |
| 3 路并发 HTTP | 3 context | 120ms | 25 fps |
| 摄像头实时检测 | 1 context + 跳帧 | 80ms | 12 fps |
| 摄像头 + HTTP 混合 | 3 context | 150ms | 18 fps |
数据不算惊艳,但胜在稳定。RK3588 的 NPU 算力摆在那,YOLOv5s 这个量级的模型跑实时检测是够用的,关键是把服务层的并发和资源管理做扎实。
6. 几个值得单独拎出来的经验
写到这里,把几个我认为最有价值的经验单独说一下,都是踩过坑之后总结的。
第一,NPU context 一定要池化,不要加锁。加锁方案在低并发下没问题,但一旦并发上来,串行化会让延迟线性增长。池化方案虽然占内存,但能真正利用 RK3588 的多 core。
第二,摄像头帧一定要拷贝。多线程环境下共享 numpy array 是定时炸弹,拷贝的成本远低于调试串帧问题的时间成本。
第三,量化校准集用自己场景的图。这个前面说过,但值得再强调一次。官方校准集训出来的模型,在你自己的场景里精度可能掉得没法用。
第四,服务层要能降级。摄像头挂了不影响 HTTP 接口,NPU 忙的时候请求排队而不是拒绝。这些细节决定了这套东西是"能演示"还是"能上线"。
第五,日志和监控要提前做。我一开始没做推理耗时统计,出问题的时候完全不知道瓶颈在哪。后来加了每帧的推理耗时、队列长度、context 使用率这些指标,排查效率高了一个数量级。
这套东西从零到跑通,前后花了大概两周,其中服务层和摄像头联调占了一半时间。NPU 推理本身反而不是最难的,难的是让整个系统稳定地跑起来。如果你也在做类似的事情,希望这些经验能帮你少走点弯路。