
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就行。我实测下来同样一台 RK3588FastAPI 在并发 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 是 0std 是 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(foutput {i}: shape{attr.shape}, fmt{attr.fmt})这一步花我半小时但省了后面几小时的调试。3. 摄像头接入UVC、RTSP 和 MIPI 三条路怎么选摄像头这块是整篇文章里折腾最久的部分没有之一。RK3588 支持三种摄像头接入方式各有各的坑。UVCUSB 摄像头是最简单的插上/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(targetself._update, daemonTrue) 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_contexts3): self.pool Queue() for i in range(num_contexts): ctx RKNNModel(model_path, core_idi) 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(fcamera 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_configNone启动自己在代码里配。5.3 性能实测数据最后贴一组我在 RK3588 上的实测数据供参考场景配置平均延迟吞吐单路 HTTP 检测1 context45ms22 fps3 路并发 HTTP3 context120ms25 fps摄像头实时检测1 context 跳帧80ms12 fps摄像头 HTTP 混合3 context150ms18 fps数据不算惊艳但胜在稳定。RK3588 的 NPU 算力摆在那YOLOv5s 这个量级的模型跑实时检测是够用的关键是把服务层的并发和资源管理做扎实。6. 几个值得单独拎出来的经验写到这里把几个我认为最有价值的经验单独说一下都是踩过坑之后总结的。第一NPU context 一定要池化不要加锁。加锁方案在低并发下没问题但一旦并发上来串行化会让延迟线性增长。池化方案虽然占内存但能真正利用 RK3588 的多 core。第二摄像头帧一定要拷贝。多线程环境下共享 numpy array 是定时炸弹拷贝的成本远低于调试串帧问题的时间成本。第三量化校准集用自己场景的图。这个前面说过但值得再强调一次。官方校准集训出来的模型在你自己的场景里精度可能掉得没法用。第四服务层要能降级。摄像头挂了不影响 HTTP 接口NPU 忙的时候请求排队而不是拒绝。这些细节决定了这套东西是能演示还是能上线。第五日志和监控要提前做。我一开始没做推理耗时统计出问题的时候完全不知道瓶颈在哪。后来加了每帧的推理耗时、队列长度、context 使用率这些指标排查效率高了一个数量级。这套东西从零到跑通前后花了大概两周其中服务层和摄像头联调占了一半时间。NPU 推理本身反而不是最难的难的是让整个系统稳定地跑起来。如果你也在做类似的事情希望这些经验能帮你少走点弯路。