1. 从模型跑通到服务可用,中间隔着多少坑
RK3588 上跑 YOLOv5s,模型转换、NPU 推理、后处理解码这几步,网上能搜到的资料已经不少了。但真正要把这套东西变成一个“能用的服务”——摄像头接进来、HTTP 接口暴露出去、推理结果实时返回——你会发现真正的麻烦才刚刚开始。我这次做的项目,目标很明确:在 RK3588 开发板上,用 YOLOv5s 做实时目标检测,通过 FastAPI 暴露推理接口,同时接入 USB 摄像头做实时视频流处理。整套链路从底层 NPU 推理到上层服务封装,全部自己搭一遍。
为什么不用现成的方案?因为实际场景里,你很难找到一个刚好满足所有约束的轮子。比如你要控制延迟、要自定义后处理逻辑、要把检测结果和业务系统对接,这些需求叠加在一起,现成方案要么太重,要么太死。所以我的选择是:NPU 推理用 RKNN Runtime 直接调,服务层用 FastAPI 自己写,摄像头用 OpenCV 的 V4L2 后端直接读。每一层都保持可控,出了问题能定位到具体环节。
这篇文章适合谁看?如果你已经在 RK3588 上跑通了 YOLOv5s 的单张图片推理,现在想把整套东西变成服务,那这篇就是写给你的。如果你还在模型转换阶段,建议先把 RKNN 工具链那部分搞定再来看。另外,文中会涉及 FastAPI 的项目结构设计、摄像头取流的线程模型、以及一个我折腾了整整两天才解决的坑——这个坑跟 NPU 内存管理有关,网上几乎搜不到相关资料。
2. 整体架构设计与技术选型思路
2.1 为什么是 FastAPI 而不是 Flask
服务层框架的选择,我对比了 Flask 和 FastAPI。Flask 更成熟、资料更多,但 FastAPI 有三个点直接打动了我。第一是原生异步支持,摄像头取流和 NPU 推理都是 IO 密集和计算密集混合的场景,异步框架能更好地利用等待时间。第二是自动生成 API 文档,FastAPI 内置 Swagger UI,调试接口的时候直接在浏览器里就能测,省掉了写测试脚本的时间。第三是 Pydantic 模型校验,请求参数和返回结构定义清楚之后,前端对接几乎不会出现字段类型不匹配的问题。
实际用下来,FastAPI 在 RK3588 上的性能表现也够用。单次推理请求的响应时间,从 HTTP 请求进来到 JSON 结果返回,稳定在 80 到 120 毫秒之间,这个延迟对于大多数实时检测场景是可以接受的。如果你需要更高的并发,可以考虑用 uvicorn 的多 worker 模式,但要注意 NPU 推理本身是串行的,多 worker 反而可能因为资源竞争导致性能下降。
2.2 摄像头接入方案:V4L2 直读 vs RTSP 拉流
摄像头这块,我一开始想的是用 RTSP 拉流,毕竟网络摄像头部署更灵活。但实测下来,RTSP 在 RK3588 上的延迟明显高于 USB 直连。用 USB 摄像头通过 V4L2 直接读取,端到端延迟可以控制在 150 毫秒以内;换成 RTSP 之后,即使把缓冲调到最低,延迟也在 300 毫秒以上。对于实时检测场景,这个差距是致命的。
所以最终方案是:USB 摄像头通过 OpenCV 的 V4L2 后端直接读取,用独立的线程做取流,主线程做推理和结果返回。如果你确实需要接网络摄像头,建议用 RTSP 的子码流做检测,主码流做展示,这样能在延迟和画质之间找到一个平衡点。
2.3 线程模型:取流、推理、服务三层分离
整个系统的线程模型是这样的:一个采集线程专门负责从摄像头读帧,读到的帧放进一个固定大小的队列;一个推理线程从队列里取帧,调用 NPU 做推理,结果放进结果队列;FastAPI 的请求处理函数从结果队列里取最新的检测结果返回。这样做的好处是,摄像头取流不会被推理阻塞,推理也不会被 HTTP 请求阻塞,三层各司其职。
队列的大小我设的是 2,也就是最多缓存两帧。设大了会导致延迟累积,设小了会丢帧。实测下来,队列大小为 2 的时候,既能保证推理线程不会空转,又不会让延迟超过 200 毫秒。这个参数可以根据你的实际场景调整,如果对实时性要求极高,可以设为 1,但要做好丢帧的心理准备。
3. 核心细节解析与实操要点
3.1 FastAPI 项目目录结构设计
项目结构这块,我踩过一个坑:一开始把所有代码都塞在一个 main.py 里,结果写到三百多行的时候,自己都找不到某个函数在哪了。后来重新组织了一下,结构如下:
rk3588_yolo_service/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 入口,路由注册 │ ├── config.py # 配置参数,模型路径、摄像头设备号等 │ ├── camera.py # 摄像头采集线程 │ ├── inference.py # NPU 推理封装 │ ├── postprocess.py # 后处理,NMS、坐标映射 │ └── schemas.py # Pydantic 模型定义 ├── models/ │ └── yolov5s.rknn # 转换好的 RKNN 模型 ├── requirements.txt └── run.sh # 启动脚本这个结构的好处是职责清晰。camera.py 只管取流,inference.py 只管推理,postprocess.py 只管后处理,main.py 只管路由和请求处理。调试的时候,哪个环节出问题就去看对应的文件,不用在几百行代码里翻来翻去。
3.2 RKNN 推理封装的几个关键参数
RKNN 推理的核心是rknn.inference()这个调用,但有几个参数直接决定了推理的稳定性和性能。第一个是data_format,我用的NHWC,因为 OpenCV 读出来的图像本身就是 HWC 格式,转成 NHWC 比转成 NCHW 少一步操作。第二个是data_type,设成uint8,这样输入数据不需要做归一化,RKNN 内部会自动处理。第三个是want_float,设成False,输出直接是量化后的整数,省掉了反量化的时间。
这里有个细节:YOLOv5s 的输入尺寸是 640x640,但摄像头读出来的帧是 1920x1080。你需要先做 letterbox 缩放,保持宽高比,然后把缩放后的图像放到一个 640x640 的灰色画布上。这个操作在 postprocess.py 里做,缩放比例和填充偏移量要记录下来,后处理的时候要把检测框映射回原图坐标。
3.3 摄像头取流的线程安全与缓冲策略
OpenCV 的VideoCapture在多线程环境下不是线程安全的,所以取流必须放在单独的线程里。我的做法是:采集线程持续调用cap.read(),读到的帧加锁放进队列。这里有个坑:如果队列满了,put操作会阻塞,导致采集线程卡住,摄像头缓冲区溢出,画面出现撕裂。解决办法是用put_nowait,队列满的时候直接丢弃当前帧,保证采集线程永远不阻塞。
另一个细节是摄像头的缓冲区设置。V4L2 默认会缓存多帧,导致你读到的画面其实是几百毫秒之前的。可以通过cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)把缓冲区设为 1,这样读到的永远是最新帧。这个设置对降低延迟非常关键,我实测下来,设置前后延迟差了将近 200 毫秒。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
RK3588 上的环境准备,第一步是确认 NPU 驱动和 RKNN Runtime 已经正确安装。你可以通过cat /sys/kernel/debug/rknpu/version查看 NPU 驱动版本,通过python3 -c "from rknnlite.api import RKNNLite; print('ok')"确认 RKNN Runtime 可用。如果这一步报错,后面的都不用做了,先把驱动和 Runtime 搞定。
Python 依赖这块,核心是这几个:fastapi、uvicorn、opencv-python、numpy、rknn-toolkit-lite2。注意rknn-toolkit-lite2是专门给板端推理用的,不要装成rknn-toolkit2,那个是给 PC 端做模型转换用的。安装命令如下:
pip3 install fastapi uvicorn opencv-python numpy pip3 install rknn-toolkit-lite2如果你用的是 Ubuntu 20.04 或 22.04,OpenCV 建议用系统包管理器安装,sudo apt install python3-opencv,这样能避免一些 V4L2 相关的兼容性问题。
4.2 模型加载与 NPU 初始化
模型加载的代码在 inference.py 里,核心逻辑是初始化 RKNNLite 对象,加载模型,然后初始化运行时。这里有个关键点:rknn.init_runtime()的时候要指定core_mask,RK3588 有三个 NPU 核心,你可以指定用哪个核心,或者让系统自动分配。我实测下来,指定单核和自动分配的性能差距不大,但指定单核的稳定性更好,不会出现多核竞争导致的推理时间波动。
from rknnlite.api import RKNNLite class YOLOv5Inference: def __init__(self, model_path): self.rknn = RKNNLite() ret = self.rknn.load_rknn(model_path) if ret != 0: raise RuntimeError("模型加载失败") ret = self.rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0) if ret != 0: raise RuntimeError("NPU 初始化失败") def infer(self, img): outputs = self.rknn.inference( inputs=[img], data_format='nhwc', data_type='uint8' ) return outputs这段代码看起来简单,但有一个隐藏的坑:inference方法返回的 outputs 是一个列表,里面有三个数组,分别对应三个不同尺度的检测头。你需要按照 YOLOv5 的后处理逻辑,把这三个数组解码成检测框。这个解码过程在 postprocess.py 里实现,核心是锚框解码和 NMS。
4.3 后处理:从三个检测头到最终检测框
YOLOv5s 的输出是三个特征图,尺寸分别是 80x80、40x40、20x20,每个特征图上的每个点预测三个锚框。解码的过程是:先把预测的偏移量转换成实际的框坐标,然后根据置信度阈值过滤掉低置信度的框,最后做 NMS 去掉重叠的框。
这里有个细节:RKNN 量化后的输出是整数,你需要根据量化参数把整数还原成浮点数。RKNNLite 的inference方法在want_float=False的时候返回的是量化后的整数,你需要自己根据scale和zero_point做反量化。这个反量化的公式是:float_value = (int_value - zero_point) * scale。scale 和 zero_point 可以从模型的量化参数里获取,或者在转换模型的时候打印出来。
NMS 的阈值我设的是 0.45,置信度阈值设的是 0.25。这两个参数可以根据你的场景调整。如果误检比较多,把置信度阈值调高;如果漏检比较多,把置信度阈值调低。NMS 阈值调高会导致重叠框保留更多,调低会导致重叠框被过度抑制。
4.4 FastAPI 路由设计与请求处理
FastAPI 的路由设计很简单,两个接口:一个/detect接口,接收图片,返回检测结果;一个/stream接口,返回 MJPEG 流,用于实时预览。/detect接口的实现逻辑是:接收上传的图片,解码成 numpy 数组,调用推理,返回 JSON 格式的检测结果。
from fastapi import FastAPI, UploadFile, File from fastapi.responses import StreamingResponse import cv2 import numpy as np app = FastAPI() @app.post("/detect") async def detect(file: UploadFile = File(...)): contents = await file.read() nparr = np.frombuffer(contents, np.uint8) img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) results = inference_pipeline(img) return {"detections": results} @app.get("/stream") async def stream(): return StreamingResponse( generate_frames(), media_type="multipart/x-mixed-replace; boundary=frame" )/stream接口用的是 MJPEG 流,每一帧都是一张 JPEG 图片,通过 multipart 协议连续发送。这个方案的好处是浏览器直接就能看,不需要额外的播放器。缺点是带宽占用比较大,如果只是本地调试用,问题不大。
4.5 摄像头采集线程的实现
摄像头采集线程的核心逻辑是一个 while 循环,不断读取帧,放进队列。这里要注意的是,线程退出的时候要正确释放摄像头资源,否则下次启动的时候会报设备被占用。
import threading import cv2 from queue import Queue, Full class CameraThread(threading.Thread): def __init__(self, device_id=0, queue_size=2): super().__init__() self.device_id = device_id self.queue = Queue(maxsize=queue_size) self.running = True self.cap = None def run(self): self.cap = cv2.VideoCapture(self.device_id, cv2.CAP_V4L2) self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080) while self.running: ret, frame = self.cap.read() if not ret: continue try: self.queue.put_nowait(frame) except Full: pass self.cap.release() def stop(self): self.running = False这段代码里,put_nowait是关键,它保证了队列满的时候不会阻塞采集线程。CAP_PROP_BUFFERSIZE设为 1 也是关键,它保证了读到的永远是最新帧。
5. 那个折腾最久的坑:NPU 内存泄漏与推理稳定性
5.1 问题现象:推理几百次之后程序崩溃
这个问题是在压力测试的时候发现的。我写了一个脚本,连续调用推理接口 1000 次,结果跑到大概 400 多次的时候,程序直接崩溃,报错信息是rknn_init_runtime failed或者malloc failed。一开始我以为是内存不够,用free -m看了一下,发现系统内存还有富余,但 NPU 的专用内存已经被耗尽了。
RK3588 的 NPU 有独立的内存区域,跟系统内存是分开的。每次调用rknn.inference()的时候,RKNN Runtime 会在 NPU 内存里分配缓冲区,如果这些缓冲区没有被正确释放,就会导致 NPU 内存泄漏。跑个几百次之后,NPU 内存耗尽,推理就失败了。
5.2 排查过程:从系统内存到 NPU 内存
排查这个问题的过程比较曲折。第一步是确认不是系统内存的问题,用free -m和top看了半天,系统内存确实没问题。第二步是怀疑 OpenCV 的 Mat 对象没有释放,检查了代码里的del和gc.collect(),也没发现问题。第三步才想到可能是 NPU 内存的问题,用cat /sys/kernel/debug/rknpu/mem查看 NPU 内存使用情况,发现每次推理之后,NPU 内存占用都会增加一点,跑几百次之后就满了。
5.3 根因分析:RKNNLite 的 inference 调用方式
问题的根因在于 RKNNLite 的inference方法。如果你每次调用的时候都传入新的输入数组,RKNN Runtime 会为每次调用分配新的输入缓冲区,但这些缓冲区不会自动释放。正确的做法是:复用同一个输入缓冲区,每次推理之前把新数据拷贝进去,而不是每次都创建新的数组。
class YOLOv5Inference: 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.input_buffer = np.zeros((1, 640, 640, 3), dtype=np.uint8) def infer(self, img): self.input_buffer[0] = img outputs = self.rknn.inference( inputs=[self.input_buffer], data_format='nhwc', data_type='uint8' ) return outputs改成这样之后,NPU 内存就不再持续增长了。连续跑 5000 次推理,NPU 内存占用稳定在一个固定值,程序也不会崩溃了。
5.4 经验总结与避坑清单
这个坑给我最大的教训是:嵌入式 AI 部署,内存管理比模型精度更重要。模型精度不够,顶多是检测不准;内存管理出问题,程序直接崩溃。下面是我整理的一个避坑清单,供你参考:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 推理几百次后崩溃 | NPU 内存泄漏 | 查看/sys/kernel/debug/rknpu/mem | 复用输入缓冲区 |
| 推理时间波动大 | 多核竞争 | 多次推理取平均 | 指定单核推理 |
| 摄像头画面延迟高 | V4L2 缓冲区过大 | 对比设置前后延迟 | 设CAP_PROP_BUFFERSIZE=1 |
| 检测框位置偏移 | letterbox 参数未记录 | 检查缩放比例和偏移量 | 后处理时映射回原图坐标 |
| 服务无响应 | 推理线程阻塞 | 检查队列是否满 | 用put_nowait丢弃旧帧 |
注意:RKNNLite 的
inference方法在传入新数组时会分配新缓冲区,这个行为在官方文档里没有明确说明,是我通过反复实验才确认的。如果你用的是 RKNN Toolkit2 的 Python 接口,行为可能不一样,需要单独验证。
6. 常见问题与排查技巧实录
6.1 摄像头打不开或者画面黑屏
这个问题最常见的原因是设备号不对或者权限不够。先用ls /dev/video*确认摄像头设备号,然后用v4l2-ctl --list-devices查看设备详情。如果设备号是对的但打不开,可能是权限问题,把当前用户加到video组里:sudo usermod -aG video $USER,然后重新登录。
另一个可能的原因是摄像头被其他进程占用了。用fuser /dev/video0查看哪个进程在用,如果有其他进程占用,先杀掉再试。还有一种情况是摄像头支持的分辨率跟代码里设置的不匹配,导致cap.read()一直返回 False。解决办法是用v4l2-ctl --list-formats-ext查看摄像头支持的分辨率,然后在代码里设置一个支持的分辨率。
6.2 FastAPI 服务启动后无法访问
这个问题通常是网络配置的问题。首先确认 uvicorn 启动的时候 host 设的是0.0.0.0而不是127.0.0.1,后者只能本机访问。然后确认防火墙没有挡住端口,用sudo ufw status查看防火墙状态,如果有规则挡住了 8000 端口,加一条放行规则。
如果是在 Docker 里跑的,还要确认端口映射是否正确。docker run -p 8000:8000这种映射方式,前面的 8000 是宿主机端口,后面的 8000 是容器内端口,两个都要对。另外,RK3588 上如果开了其他服务占用了 8000 端口,也会导致 FastAPI 启动失败,用netstat -tlnp | grep 8000查看端口占用情况。
6.3 推理结果不稳定,同一张图多次推理结果不同
这个问题在量化模型上比较常见。RKNN 的量化推理本身是有一定随机性的,因为量化过程中会有精度损失。如果你发现同一张图多次推理的结果差异很大,可能是量化参数设置得太激进。解决办法是在模型转换的时候,用更多的校准图片,或者把量化方式从asymmetric_quantized-u8改成dynamic_fixed_point-16,后者精度更高但速度稍慢。
另一个可能的原因是输入图像的预处理不一致。比如 letterbox 的填充颜色,有的代码用灰色填充,有的用黑色填充,这会导致检测结果有细微差异。建议统一用灰色填充,因为 YOLOv5 训练的时候用的就是灰色填充。
6.4 NPU 推理速度慢于预期
RK3588 的 NPU 理论算力是 6 TOPS,但实际推理速度受很多因素影响。如果你发现推理速度明显慢于预期,可以从这几个方面排查:第一,确认模型是否真的跑在 NPU 上,而不是回退到了 CPU。可以在推理的时候用top查看 CPU 占用,如果 CPU 占用很高,说明可能在跑 CPU 推理。第二,确认core_mask设置是否正确,如果设成了NPU_CORE_0_1_2,三个核心同时跑,反而可能因为竞争导致速度下降。第三,确认输入图像的尺寸是否跟模型匹配,如果输入尺寸不对,RKNN 会做额外的缩放操作,增加耗时。
6.5 服务运行一段时间后自动退出
这个问题通常是内存泄漏或者未捕获的异常导致的。首先检查系统日志dmesg | tail -50,看看有没有 OOM(内存不足)的记录。如果有,说明系统内存被耗尽了,需要检查代码里有没有未释放的大对象。其次检查 Python 的异常日志,FastAPI 默认会把未捕获的异常打到控制台,如果你是用nohup或者systemd启动的,日志会写到对应的文件里。
还有一个可能的原因是 NPU 驱动崩溃。RK3588 的 NPU 驱动在某些情况下会崩溃,导致推理失败。如果dmesg里有rknpu相关的错误信息,说明是驱动的问题。解决办法是更新 NPU 驱动到最新版本,或者降低推理频率,给驱动留出足够的恢复时间。
7. 一些实操心得与后续扩展方向
整套系统跑通之后,我最大的感受是:嵌入式 AI 部署,模型转换和推理只是冰山一角,真正的工作量在服务封装和稳定性保障上。模型跑通可能只需要一天,但让服务稳定运行一周不出问题,可能需要一周甚至更久。这里面涉及的内存管理、线程安全、异常处理,每一项都需要仔细打磨。
如果你想把这套系统用到实际项目里,有几个方向可以继续扩展。第一是加一个简单的 Web 前端,用 Gradio 或者 Streamlit 快速搭一个界面,方便演示和调试。第二是加一个结果存储模块,把检测结果写到 SQLite 或者 CSV 里,方便后续分析。第三是加一个模型热更新机制,不用重启服务就能切换模型。第四是加一个性能监控接口,实时查看推理延迟、NPU 内存占用、帧率等指标。
最后分享一个小技巧:如果你在调试的时候发现推理结果不对,但又找不到原因,可以先把模型换成非量化的版本跑一遍。如果非量化版本结果正确,说明是量化的问题;如果非量化版本也不对,说明是预处理或者后处理的问题。这个二分法能帮你快速定位问题所在的环节。