
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.04OpenCV 建议用系统包管理器安装sudo apt install python3-opencv这样能避免一些 V4L2 相关的兼容性问题。4.2 模型加载与 NPU 初始化模型加载的代码在 inference.py 里核心逻辑是初始化 RKNNLite 对象加载模型然后初始化运行时。这里有个关键点rknn.init_runtime()的时候要指定core_maskRK3588 有三个 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_maskRKNNLite.NPU_CORE_0) if ret ! 0: raise RuntimeError(NPU 初始化失败) def infer(self, img): outputs self.rknn.inference( inputs[img], data_formatnhwc, data_typeuint8 ) return outputs这段代码看起来简单但有一个隐藏的坑inference方法返回的 outputs 是一个列表里面有三个数组分别对应三个不同尺度的检测头。你需要按照 YOLOv5 的后处理逻辑把这三个数组解码成检测框。这个解码过程在 postprocess.py 里实现核心是锚框解码和 NMS。4.3 后处理从三个检测头到最终检测框YOLOv5s 的输出是三个特征图尺寸分别是 80x80、40x40、20x20每个特征图上的每个点预测三个锚框。解码的过程是先把预测的偏移量转换成实际的框坐标然后根据置信度阈值过滤掉低置信度的框最后做 NMS 去掉重叠的框。这里有个细节RKNN 量化后的输出是整数你需要根据量化参数把整数还原成浮点数。RKNNLite 的inference方法在want_floatFalse的时候返回的是量化后的整数你需要自己根据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_typemultipart/x-mixed-replace; boundaryframe )/stream接口用的是 MJPEG 流每一帧都是一张 JPEG 图片通过 multipart 协议连续发送。这个方案的好处是浏览器直接就能看不需要额外的播放器。缺点是带宽占用比较大如果只是本地调试用问题不大。4.5 摄像头采集线程的实现摄像头采集线程的核心逻辑是一个 while 循环不断读取帧放进队列。这里要注意的是线程退出的时候要正确释放摄像头资源否则下次启动的时候会报设备被占用。import threading import cv2 from queue import Queue, Full class CameraThread(threading.Thread): def __init__(self, device_id0, queue_size2): super().__init__() self.device_id device_id self.queue Queue(maxsizequeue_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_maskRKNNLite.NPU_CORE_0) self.input_buffer np.zeros((1, 640, 640, 3), dtypenp.uint8) def infer(self, img): self.input_buffer[0] img outputs self.rknn.inference( inputs[self.input_buffer], data_formatnhwc, data_typeuint8 ) return outputs改成这样之后NPU 内存就不再持续增长了。连续跑 5000 次推理NPU 内存占用稳定在一个固定值程序也不会崩溃了。5.4 经验总结与避坑清单这个坑给我最大的教训是嵌入式 AI 部署内存管理比模型精度更重要。模型精度不够顶多是检测不准内存管理出问题程序直接崩溃。下面是我整理的一个避坑清单供你参考问题现象可能原因排查方法解决方案推理几百次后崩溃NPU 内存泄漏查看/sys/kernel/debug/rknpu/mem复用输入缓冲区推理时间波动大多核竞争多次推理取平均指定单核推理摄像头画面延迟高V4L2 缓冲区过大对比设置前后延迟设CAP_PROP_BUFFERSIZE1检测框位置偏移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 内存占用、帧率等指标。最后分享一个小技巧如果你在调试的时候发现推理结果不对但又找不到原因可以先把模型换成非量化的版本跑一遍。如果非量化版本结果正确说明是量化的问题如果非量化版本也不对说明是预处理或者后处理的问题。这个二分法能帮你快速定位问题所在的环节。