
1. 从模型跑通到服务可用中间隔着一整条工程链路模型在 RK3588 上推理出第一帧结果的那一刻很多人会以为活干完了。实际上那只是整条链路里最容易被验证的一环——你手里有一个能出框的二进制但它离一个能被摄像头喂数据、能被外部调用、能长时间稳定运行的服务还差着十万八千里。我这次做的就是把 YOLOv5s 在 RK3588 上从裸推理推进到服务层 摄像头的完整闭环中间踩的坑里有一个折腾了整整两天后面会重点讲。这篇是系列第七篇前面几篇分别处理了交叉编译环境、RKNN 模型转换、NPU 推理封装、后处理对齐这些事。到这一篇核心目标很明确用 FastAPI 把 NPU 推理包装成一个 HTTP 服务接上摄像头取流形成取帧—推理—回传的完整链路。关键词里出现的 RK3588、YOLOv5s、FastAPI、NPU、摄像头正好对应这条链路的五个关键节点。适合谁看如果你已经能在板子上跑通 rknn 的 demo但不知道怎么把它变成一个真正能用的服务或者你在做智能车、边缘盒子、视觉检测终端这类项目需要把模型和摄像头串起来这篇的实操细节和踩坑记录应该能帮你省下不少时间。如果你还停在模型转换阶段建议先回看前几篇否则直接上手服务层会有点懵。整篇我会按真实推进顺序讲先讲为什么服务层选 FastAPI 而不是 Flask再讲摄像头取流在 RK3588 上的实际表现然后是服务层和推理引擎怎么解耦最后重点复盘那个折腾最久的坑。每一步都给出可复现的操作和参数不玩虚的。2. 服务层选型为什么是 FastAPI 而不是 Flask2.1 边缘设备上 Web 框架的真实约束在 RK3588 这种边缘设备上选 Web 框架和在你笔记本上选完全是两回事。笔记本上你关心的是开发效率、生态丰富度板子上你首先要关心的是内存占用、启动速度、并发模型。RK3588 虽然有 8 核 CPU 和 6TOPS 的 NPU但如果你跑的是 Ubuntu 桌面版系统本身已经吃掉不少内存留给服务的余量并不宽裕。我一开始其实想用 Flask因为熟。但实测下来有两个问题第一Flask 默认是同步阻塞的摄像头取流和 NPU 推理都是耗时操作一个请求进来会把整个 worker 卡住第二个请求只能排队第二Flask 要跑并发得靠 gunicorn 多 worker每个 worker 一份内存在板子上开 4 个 worker 内存直接紧张。FastAPI 基于 ASGI原生支持 async配合 uvicorn 单进程就能处理并发 IO这对取流 推理这种 IO 密集和计算密集混合的场景更友好。这里要澄清一个常见误解FastAPI 的 async 并不能让 NPU 推理变快。NPU 推理是同步的 C 调用你把它塞进 async 函数里它照样阻塞事件循环。正确的做法是把推理放到线程池里跑run_in_executor或者asyncio.to_thread让事件循环去处理网络 IO。这一点后面 4.2 节会详细讲很多人在这里翻车。2.2 FastAPI 项目目录结构别一上来就堆在一个文件里热词里fastapi项目目录结构被搜了很多次说明这是普遍痛点。我见过太多人把整个服务写在一个main.py里几百行下去自己都找不到北。边缘服务虽然不复杂但该分的层还是要分否则后面加功能会非常痛苦。我实际用的结构是这样的yolo_service/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 实例、路由注册、生命周期 │ ├── config.py # 配置项模型路径、摄像头地址、端口 │ ├── schemas.py # Pydantic 请求/响应模型 │ ├── routers/ │ │ ├── __init__.py │ │ ├── infer.py # 推理相关接口 │ │ └── camera.py # 摄像头相关接口 │ ├── services/ │ │ ├── __init__.py │ │ ├── rknn_engine.py # NPU 推理引擎封装 │ │ └── camera_reader.py # 摄像头取流封装 │ └── utils/ │ ├── __init__.py │ └── image.py # 图像编解码、letterbox 等 ├── models/ │ └── yolov5s.rknn ├── requirements.txt └── run.sh这个结构的好处是推理引擎和摄像头读取都被隔离在 services 层路由层只负责参数校验和结果返回。后面你要换模型、换摄像头只动 services 层路由不用改。config.py单独抽出来是因为板子上部署时路径和开发机不一样集中管理避免到处硬编码。提示models/目录不要放进 Python 包里模型文件动辄十几 MB打包进 wheel 或者被 import 扫描到都会拖慢启动。用绝对路径从 config 里读。2.3 依赖安装与版本锁定FastAPI 的依赖在 ARM64 上装起来基本顺利但有几个包要注意版本。我实测的可用组合是pip install fastapi0.110.0 pip install uvicorn[standard]0.29.0 pip install opencv-python4.9.0.80 pip install numpy1.26.4 pip install pydantic2.6.4uvicorn[standard]会带上uvloop和httptools在 ARM64 上能正常编译性能比纯 Python 实现好一截。opencv-python在板子上建议用系统包或者自己编译pip 装的版本有时候缺 GStreamer 支持读 RTSP 流会报错。如果你要用 GStreamer 后端读流装opencv-python之前先apt install libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev。版本锁定这件事在边缘设备上尤其重要。板子一旦部署出去你不会想因为某次pip install -U把环境搞崩。我习惯把requirements.txt里的版本全部写死部署时用pip install -r requirements.txt而不是逐个装。3. 摄像头取流RTSP、USB 和 MIPI 三条路怎么选3.1 三种摄像头接入方式的实测对比RK3588 上接摄像头有三条主流路径USB 摄像头UVC、MIPI CSI 摄像头、网络摄像头RTSP。这三条路我都在板子上试过表现差异很大直接上对比表接入方式延迟稳定性配置难度适用场景USB UVC中80-150ms高低快速验证、固定机位MIPI CSI低30-60ms高高低延迟要求、嵌入式集成RTSP 网络高200-500ms中中远程监控、多路汇聚USB 摄像头是最省事的插上/dev/video*就能用OpenCV 直接cv2.VideoCapture(0)。但要注意 RK3588 的 USB 带宽如果你同时插了 USB 3.0 硬盘和 USB 摄像头可能会因为带宽争抢导致掉帧。MIPI CSI 延迟最低但配置最麻烦需要改设备树、配 media-ctl 管线热词里rk3588 linux 适配mipi屏幕和这个是一个道理都是显示/采集管线的适配问题。RTSP 网络摄像头胜在灵活但延迟高而且取流稳定性受网络影响大。我这次项目最终用的是 RTSP因为设备要装在离板子有一定距离的位置走网线比拉 USB 线现实。下面重点讲 RTSP 取流的坑。3.2 RTSP 取流地址的构成与常见错误RTSP 地址的通用格式是rtsp://[用户名]:[密码][IP]:[端口]/[路径]。不同厂商的路径不一样海康一般是/Streaming/Channels/101主码流和/Streaming/Channels/102子码流大华是/cam/realmonitor?channel1subtype0。热词里海康摄像头rtsp协议的主码流和子码流问的就是这个——主码流分辨率高但码率大子码流分辨率低但流畅。在 RK3588 上做推理我强烈建议用子码流。原因很简单YOLOv5s 的输入是 640x640你取 1080P 主码流再缩放到 640纯属浪费带宽和 CPU。子码流一般是 704x576 或 640x480直接拿来用刚好。我实测主码流取流时 CPU 占用比子码流高 15% 左右全花在解码和缩放上了。一个常见错误是地址里带了特殊字符没转义。比如密码里有或#直接拼进 URL 会解析失败。正确做法是用urllib.parse.quote对用户名密码编码from urllib.parse import quote user quote(admin, safe) pwd quote(pass123, safe) rtsp_url frtsp://{user}:{pwd}192.168.1.64:554/Streaming/Channels/1023.3 OpenCV 读 RTSP 的缓冲与超时设置OpenCV 读 RTSP 默认会缓冲导致你拿到的帧是几秒前的。在实时推理场景里这是致命的。必须设置缓冲大小为 1import cv2 cap cv2.VideoCapture(rtsp_url, cv2.CAP_FFMPEG) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 5000) cap.set(cv2.CAP_PROP_READ_TIMEOUT_MSEC, 5000)CAP_PROP_BUFFERSIZE设成 1 能显著降低延迟但代价是如果推理速度跟不上取流速度会丢帧。这在实时场景里是可以接受的——宁可丢帧也不要延迟累积。超时设置是必须的否则摄像头断网时cap.read()会一直阻塞把整个服务卡死。还有一个坑OpenCV 的 FFMPEG 后端在 RTSP 断流后不会自动重连cap.read()会返回 False 但不会抛异常。你必须在读取循环里判断返回值连续失败 N 次后主动cap.release()再重新VideoCapture。这个重连逻辑我放在camera_reader.py里后面 5.3 节给完整代码。4. 服务层与推理引擎的解耦设计4.1 推理引擎的单例与线程安全RKNN 的推理上下文RKNN对象不是线程安全的多个线程同时调用rknn.inference()会出问题。但 FastAPI 的请求是并发的怎么保证安全我的做法是推理引擎做成单例用一个锁串行化推理调用。听起来会限制并发但实际上 NPU 本身一次也只能跑一个推理串行化反而避免了上下文切换开销。import threading from rknnlite.api import RKNNLite class RknnEngine: _instance None _lock threading.Lock() def __new__(cls, *args, **kwargs): if cls._instance is None: with cls._lock: if cls._instance is None: cls._instance super().__new__(cls) return cls._instance def __init__(self, model_path): if hasattr(self, _initialized): return self.rknn RKNNLite() ret self.rknn.load_rknn(model_path) if ret ! 0: raise RuntimeError(fload_rknn failed: {ret}) ret self.rknn.init_runtime(core_maskRKNNLite.NPU_CORE_0) if ret ! 0: raise RuntimeError(finit_runtime failed: {ret}) self._infer_lock threading.Lock() self._initialized True def infer(self, img): with self._infer_lock: return self.rknn.inference(inputs[img])这里core_mask我指定了NPU_CORE_0。RK3588 有 3 个 NPU core理论上可以并行跑三个模型实例。但实测下来单模型多 core 并行的收益不明显反而增加调度开销。如果你要同时跑多个模型比如检测 分类可以每个模型绑一个 core。4.2 把同步推理塞进 async 路由的正确姿势前面提过NPU 推理是同步阻塞的直接写在async def路由里会阻塞事件循环。正确做法是用run_in_executor丢到线程池from fastapi import FastAPI, UploadFile from concurrent.futures import ThreadPoolExecutor import asyncio app FastAPI() executor ThreadPoolExecutor(max_workers2) app.post(/infer) async def infer(file: UploadFile): data await file.read() loop asyncio.get_event_loop() result await loop.run_in_executor(executor, engine.infer, data) return {detections: result}max_workers设成 2 就够了因为推理本身有锁串行化开太多线程只是排队。这里有个细节run_in_executor传的参数必须是可序列化的或者线程安全的UploadFile对象不能直接传要先await file.read()拿到 bytes。注意不要用asyncio.to_thread替代run_in_executor就以为万事大吉。to_thread内部也是用默认线程池默认池大小受 CPU 核数限制在板子上可能不够用。显式创建ThreadPoolExecutor更可控。4.3 请求体设计base64 还是 multipart推理接口的输入有两种常见设计base64 编码的 JSON或者 multipart 文件上传。我两种都试过推荐 multipart。base64 会把图片体积膨胀 33%而且 JSON 解析大字符串在板子上很慢。multipart 直接传二进制省带宽省 CPU。响应体我返回 JSON包含检测框坐标、类别、置信度。坐标用归一化值0-1还是像素值我建议返回归一化值让调用方自己乘原图尺寸。因为服务端可能做了 letterbox 缩放返回像素值容易和原图对不上归一化值更通用。from pydantic import BaseModel from typing import List class Detection(BaseModel): x1: float y1: float x2: float y2: float conf: float cls_id: int cls_name: str class InferResponse(BaseModel): detections: List[Detection] infer_ms: floatinfer_ms这个字段很有用方便你在客户端监控服务性能。我实测 YOLOv5s 在 RK3588 单核 NPU 上640x640 输入推理耗时约 25-35ms加上前后处理总共 40-50ms也就是 20-25 FPS。这个数据可以作为你的性能基线。5. 那个折腾最久的坑摄像头断流后的服务假死5.1 现象描述服务还在但再也不出结果坑是这样的服务跑起来一切正常推理接口响应也快。但运行几个小时后接口开始超时日志里没有任何报错进程还在CPU 占用掉到接近 0。重启服务就好了但过几小时又复现。这个问题折腾了我整整两天因为它不报错你根本不知道从哪查。一开始我怀疑是内存泄漏用ps看内存确实在缓慢增长但增长很慢不至于几小时就卡死。又怀疑是 NPU 上下文泄漏但重启 NPU 驱动也没用。最后是加了一堆日志才发现问题出在摄像头取流线程上——RTSP 断流后cap.read()阻塞住了取流线程卡死推理线程拿不到新帧整个链路停摆。5.2 排查链路从日志到线程栈排查过程值得完整记录因为这类假死问题在边缘设备上很常见。我的排查步骤是加时间戳日志在取流循环、推理调用、响应返回三个点打时间戳发现取流循环的时间戳不再更新确认卡在取流。看线程状态用py-spy dump --pid pid抓线程栈发现取流线程停在cv2.VideoCapture.read()的 C 调用里。复现断流手动拔掉摄像头网线观察服务行为确认断流后read()会阻塞而不是返回 False。验证超时设置检查CAP_PROP_READ_TIMEOUT_MSEC是否生效发现 OpenCV 的 FFMPEG 后端对这个属性支持不完整某些版本会忽略。关键发现是第 4 步OpenCV 的 RTSP 超时设置不是所有后端都生效。CAP_FFMPEG后端在部分版本里会忽略READ_TIMEOUT_MSEC导致read()无限阻塞。这就是为什么服务假死但没有任何报错。5.3 修复方案独立取流线程 看门狗 强制重连修复思路是把取流和推理彻底解耦取流线程独立运行主线程通过队列拿帧加看门狗监控取流线程心跳。完整代码import cv2 import threading import time import queue class CameraReader: def __init__(self, rtsp_url, queue_size2): self.rtsp_url rtsp_url self.frame_queue queue.Queue(maxsizequeue_size) self.running False self.last_frame_time time.time() self.thread None def _open(self): cap cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) return cap def _loop(self): cap self._open() fail_count 0 while self.running: ret, frame cap.read() if not ret: fail_count 1 if fail_count 5: cap.release() time.sleep(1) cap self._open() fail_count 0 continue fail_count 0 self.last_frame_time time.time() if self.frame_queue.full(): try: self.frame_queue.get_nowait() except queue.Empty: pass self.frame_queue.put(frame) cap.release() def start(self): self.running True self.thread threading.Thread(targetself._loop, daemonTrue) self.thread.start() def stop(self): self.running False if self.thread: self.thread.join(timeout3) def get_frame(self, timeout1.0): try: return self.frame_queue.get(timeouttimeout) except queue.Empty: return None def is_healthy(self, threshold5.0): return (time.time() - self.last_frame_time) threshold看门狗逻辑放在主循环里如果is_healthy()返回 False 超过一定时间就强制stop()再start()重建取流线程。这样即使read()阻塞看门狗也能通过重建线程把服务拉回来。def watchdog(camera, interval10): while True: time.sleep(interval) if not camera.is_healthy(): camera.stop() camera.start()这个方案上线后服务连续跑了 72 小时没再假死。代价是断流恢复期间会丢几秒的帧但相比整个服务卡死这个代价完全可以接受。5.4 举一反三边缘服务里还有哪些静默阻塞这个坑的本质是底层 C 调用阻塞导致 Python 层无感知。同类问题在边缘服务里还有几个NPU 推理卡死某些异常输入比如尺寸不对的图会让rknn.inference()卡住。防护方法是推理前严格校验输入 shape并且给推理也加超时监控。磁盘写入阻塞如果服务要存检测结果到 SD 卡SD 卡老化后写入会变慢甚至阻塞。建议用独立线程写盘主链路不碰 IO。日志同步写Python 的 logging 默认是同步的高频日志会拖慢主线程。用QueueHandler异步写日志。这些问题的共同特征是不报错但卡住排查时都要靠心跳监控 线程栈抓取。养成给关键线程加心跳时间戳的习惯能省下大量排查时间。6. 完整启动流程与实测性能6.1 从零启动服务的操作步骤把上面所有东西串起来完整的启动流程是这样的# 1. 进入项目目录 cd yolo_service # 2. 激活虚拟环境板子上建议用 venv 隔离 source venv/bin/activate # 3. 确认 NPU 驱动正常 cat /sys/kernel/debug/rknpu/version # 4. 启动服务 uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1--workers一定要设成 1。因为推理引擎是单例多 worker 会导致每个 worker 各加载一份模型内存翻倍而且 NPU 上下文会冲突。单 worker 线程池足够处理这个场景的并发。app/main.py里用 lifespan 管理摄像头和引擎的生命周期from contextlib import asynccontextmanager from fastapi import FastAPI asynccontextmanager async def lifespan(app: FastAPI): engine RknnEngine(models/yolov5s.rknn) camera CameraReader(rtsp://...) camera.start() app.state.engine engine app.state.camera camera yield camera.stop() app FastAPI(lifespanlifespan)用 lifespan 而不是app.on_event(startup)因为后者在新版 FastAPI 里已经废弃而且 lifespan 能保证清理逻辑一定执行。6.2 实测性能数据与瓶颈分析在 RK3588Ubuntu 20.04CPU 4xA764xA55NPU 6TOPS上实测环节耗时RTSP 取帧子码流 704x5768-12msletterbox 缩放到 640x6405-8msNPU 推理25-35ms后处理NMS8-15ms结果序列化 返回2-3ms端到端50-70ms瓶颈在 NPU 推理和后处理。后处理优化空间比较大用 numpy 向量化替代 Python 循环能省一半时间。NPU 推理这块YOLOv5s 已经算轻量了想再快只能换更小的模型或者量化到 int8。热词里yolov5s模型轻量化和yolo26n rk3588都是这个方向后续可以单独写一篇。6.3 长时间运行的稳定性观察服务连续跑 72 小时内存从 180MB 缓慢涨到 220MB 后稳定没有持续泄漏。CPU 占用稳定在 35-45%单核推理 取流解码。NPU 占用率约 60%。期间模拟了 3 次摄像头断流看门狗都在 10 秒内恢复了取流。有一个细节值得注意uvicorn 的访问日志在高频请求下会拖慢服务。如果你不需要每个请求都记日志启动时加--no-access-log能省下 5% 左右的 CPU。热词里uvicorn fastapi 日志丢失问题其实很多时候是日志级别配置问题不是真的丢了而是被过滤了。7. 几个容易被忽略的部署细节7.1 开机自启与进程守护服务跑通之后下一步是让它开机自启并且挂了能自动拉起。用 systemd 是最稳的[Unit] DescriptionYOLOv5s RKNN Service Afternetwork.target [Service] Typesimple Userroot WorkingDirectory/opt/yolo_service ExecStart/opt/yolo_service/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log Restartalways RestartSec5 [Install] WantedBymulti-user.targetRestartalways配合RestartSec5能保证进程崩溃后 5 秒内拉起。但要注意如果是摄像头断流导致的假死进程没崩systemd 不会重启。所以看门狗逻辑还是必须的systemd 只能兜底进程级崩溃。7.2 温度与降频RK3588 满载跑推理时发热不小如果散热没做好会触发降频推理耗时从 30ms 涨到 60ms 以上。建议加个散热片或者小风扇并且监控温度cat /sys/class/thermal/thermal_zone0/temp超过 80 度就要注意了。我实测加散热片后连续跑 2 小时温度稳定在 65 度左右没有降频。7.3 网络配置的坑如果板子用 WiFi 连接RTSP 取流会受 WiFi 抖动影响丢帧率明显高于有线。能用网线就用网线。另外如果摄像头和板子不在同一网段RTSP 可能连不上要确认路由配置。这些网络层的细节虽然简单但在现场部署时经常成为服务跑不起来的真凶。8. 写在最后的一点个人体会这套服务层 摄像头的链路从代码量上看并不大核心逻辑加起来也就几百行。但真正花时间的从来不是写代码而是排查那些不报错的静默故障。摄像头断流假死那个坑如果一开始就知道OpenCV 的 RTSP 超时不可靠这个事实可能半小时就解决了。但恰恰是这种文档里不会写、搜索引擎也不好找的经验才是边缘部署里最值钱的东西。我现在做这类项目会强制自己给每个关键线程加心跳时间戳服务启动时就把监控埋点铺好。看起来是多写了点代码但出问题时能省下的是成倍的时间。另外模型跑通只是起点服务化、稳定性、可维护性才是真正决定项目能不能落地的部分。下一篇我打算讲讲多路摄像头并发和模型量化到 int8 的实操那又是另一个坑窝了。