
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_maskRKNNLite.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 热重载时会重复 initNPU 资源泄漏。第三是日志。热词里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 上解码会吃 CPU1080p 下能占到一整个核心二是 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(targetself._loop, daemonTrue) 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_runNPU 驱动就炸了。这就是坑的核心锁保护的是 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, interval0.2): super().__init__(daemonTrue) 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 长期满载温度上来之后降频反而整体吞吐下降。后来降到 5fpsNPU 有了喘息空间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(timeout3) reader.running False detector.release() app FastAPI(lifespanlifespan)这套流程跑通之后服务可以反复启停而不需要重启板子开发效率高了很多。5. 实测数据与几个值得记住的经验最后把实测数据和踩坑经验集中说一下这些是文档里不会写、但实际部署一定会遇到的东西。5.1 性能实测在 RK3588 上YOLOv5s640x640 输入单帧推理耗时约 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 服务以及远程更新模型文件的方案有兴趣的可以关注。