首页 / 资讯中心 / 文章详情

RK3588部署YOLOv5s:FastAPI服务层与RTSP摄像头避坑指南

RK3588部署YOLOv5s:FastAPI服务层与RTSP摄像头避坑指南 ★ FEATURED ARTICLE
1. 为什么服务层是整个链路里最容易被低估的一环模型转换完了NPU 推理也跑通了单张图片测下来延迟稳定在 20ms 出头这时候很多人会觉得项目已经成了。我一开始也是这么想的直到真正要把这套东西接到摄像头上、做成一个能持续跑的服务才发现前面那些工作只是把发动机装好了而服务层才是底盘、传动和刹车。这个项目标题里的“服务层 摄像头 一个折腾最久的坑”其实点出了 RK3588 部署 YOLOv5s 从 demo 走向可用系统的分水岭。前面几篇讲的是模型转换、RKNN 推理、后处理这些偏底层的东西而这一篇要解决的是怎么让推理能力变成一个别人能调用的接口怎么让摄像头的数据稳定地喂进来以及在这个过程中我踩过的那个让我查了整整两个晚上的坑。先说清楚这套东西适合谁看。如果你手上有一块 RK3588 的开发板已经能把 YOLOv5s 转成 RKNN 模型并跑通单张推理现在想把它做成一个能对外提供服务的完整系统那这篇就是写给你的。如果你还在模型转换阶段建议先把前面几篇的基础打牢因为服务层会放大底层所有的不稳定因素。整套方案我用的是 FastAPI 做服务框架摄像头走 RTSP 取流推理走 RKNN 的 NPU 加速最终对外暴露一个 HTTP 接口返回检测结果。这里先给一个整体数据让你对规模有个概念。整套服务在 RK3588 上跑 YOLOv5s640x640 输入单帧 NPU 推理约 22ms加上前后处理和取流解码端到端单帧处理约 45ms也就是理论上能到 20 FPS 左右。实际我压测下来单路摄像头稳定在 15 FPS 是比较舒服的状态CPU 占用控制在 40% 以内NPU 占用约 60%。这些数字后面会反复用到你先记着。2. 服务层框架选型为什么是 FastAPI 而不是 Flask2.1 选型背后的真实考量在 RK3588 这种边缘设备上做服务层框架选型不能只看“哪个流行”。我一开始其实想用 Flask因为它的生态最成熟随便搜个例子都能跑。但真正评估下来FastAPI 在几个关键点上更适合这个场景。第一个是异步支持。摄像头取流、NPU 推理、HTTP 响应这三件事的耗时差异很大取流是 IO 密集推理是计算密集如果用 Flask 的同步模型一个请求进来就会阻塞整个线程多路摄像头根本没法并发。FastAPI 基于 Starlette原生支持 async/await取流和响应可以异步处理推理部分用线程池隔离整体吞吐能上去。第二个是自动生成的接口文档。FastAPI 自带 Swagger UI服务起来之后直接访问/docs就能看到所有接口调试的时候特别省事。边缘设备上你不可能装一堆调试工具有个网页版的接口测试页面非常实用。第三个是 Pydantic 的数据校验。检测结果的结构是固定的用 Pydantic 定义好模型之后输入输出的格式校验、类型转换都是自动的省掉大量手写校验代码也避免了脏数据把服务搞崩。当然 FastAPI 也有代价。它的异步模型对新手不友好如果你在 async 函数里写了阻塞代码整个事件循环会被卡住表现就是服务“假死”。这个坑我在后面会专门讲。另外 FastAPI 依赖 uvicorn 作为 ASGI 服务器部署时多了一层但换来的是更好的并发能力我觉得值。2.2 项目目录结构设计服务层的目录结构我改了三版才定下来。第一版所有代码堆在一个文件里写到 300 行就完全没法维护了。第二版按功能拆了模块但循环依赖搞得一团糟。最终这版是按“职责分层”来组织的你可以直接抄yolo_service/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 应用入口 │ ├── config.py # 配置管理 │ ├── schemas.py # Pydantic 数据模型 │ ├── api/ │ │ ├── __init__.py │ │ └── routes.py # 路由定义 │ ├── core/ │ │ ├── __init__.py │ │ ├── detector.py # RKNN 推理封装 │ │ └── camera.py # 摄像头取流封装 │ └── utils/ │ ├── __init__.py │ └── postprocess.py # 后处理工具 ├── models/ │ └── yolov5s.rknn # 转换好的模型 ├── requirements.txt └── run.sh这个结构的关键在于core层和api层彻底解耦。detector.py和camera.py只管自己的事不关心 HTTP 是什么routes.py只负责接收请求、调用 core 层、返回结果。这样后面要换服务框架或者要把推理逻辑复用到别的项目里直接搬 core 层就行。2.3 依赖安装与版本锁定RK3588 上的 Python 环境比较敏感尤其是 numpy 的版本装错了会和 RKNN 的运行时冲突。我实测下来这套版本组合最稳# requirements.txt fastapi0.104.1 uvicorn[standard]0.24.0 pydantic2.5.0 numpy1.24.4 opencv-python4.8.1.78 rknn-toolkit-lite21.5.2这里有个细节opencv-python不要装 headless 版本因为后面要处理 RTSP 流headless 版本缺少一些视频相关的编解码支持。另外rknn-toolkit-lite2是专门给板端推理用的和 PC 上转换模型用的rknn-toolkit2不是一回事别装混了。注意numpy 一定要锁在 1.24.x我试过 1.26 版本RKNN 运行时加载模型时会报符号找不到的错误查了半天才发现是 numpy ABI 不兼容。3. 摄像头取流RTSP 接入的完整实现3.1 为什么选 RTSP 而不是 USB 摄像头标题里提到摄像头这里的选择其实挺多的。USB 摄像头直接插板子上用 OpenCV 的VideoCapture(0)就能读最简单。但实际项目里摄像头往往装在离板子很远的地方或者需要多个视角这时候网络摄像头IP Camera就是更合理的选择而 RTSP 是网络摄像头最通用的取流协议。我用的是一台支持 RTSP 的网络摄像头取流地址格式是rtsp://用户名:密码IP地址:554/流路径。不同品牌的路径不一样海康一般是/Streaming/Channels/101大华是/cam/realmonitor?channel1subtype0。这里有个经验优先用子码流。主码流通常是 1080P 甚至 4K解码压力大而 YOLOv5s 输入只有 640x640用子码流一般 720P 或 D1完全够用还能省下大量解码开销。3.2 取流封装的核心逻辑摄像头取流最容易出的问题是“卡死”和“延迟累积”。卡死是因为 RTSP 连接断了之后 OpenCV 不会自动重连延迟累积是因为缓冲区堆积了旧帧。我的封装思路是单独开一个线程持续读帧只保留最新的一帧主线程要的时候直接拿最新的这样天然避免了延迟累积。import cv2 import threading import time class RTSPCamera: def __init__(self, rtsp_url, reconnect_interval5): self.rtsp_url rtsp_url self.reconnect_interval reconnect_interval self.cap None self.frame None self.lock threading.Lock() self.running False self.thread None def start(self): self.running True self.thread threading.Thread(targetself._capture_loop, daemonTrue) self.thread.start() def _capture_loop(self): while self.running: if self.cap is None or not self.cap.isOpened(): self.cap cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG) # 关键设置缓冲区为1避免延迟累积 self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) if not self.cap.isOpened(): time.sleep(self.reconnect_interval) continue ret, frame self.cap.read() if not ret: self.cap.release() self.cap None time.sleep(self.reconnect_interval) continue with self.lock: self.frame frame def read(self): with self.lock: if self.frame is None: return False, None return True, self.frame.copy() def stop(self): self.running False if self.thread: self.thread.join(timeout3) if self.cap: self.cap.release()这段代码有几个关键点值得展开说。cv2.CAP_FFMPEG是显式指定用 FFmpeg 后端解码RK3588 上 OpenCV 默认可能走 GStreamer实测 FFmpeg 更稳。CAP_PROP_BUFFERSIZE设为 1 是防延迟累积的核心不设的话缓冲区会堆好几秒的旧帧你看到的画面永远是几秒前的。重连逻辑放在循环里断了就释放重连不需要外部干预。3.3 取流参数调优RTSP 取流有几个参数会直接影响稳定性我整理成表格方便你对照参数推荐值说明传输协议TCPUDP 丢包会导致花屏TCP 更稳缓冲区大小1防止延迟累积解码后端FFMPEGRK3588 上比 GStreamer 稳子码流分辨率720P够用且解码压力小重连间隔5 秒太短会频繁重连太长恢复慢传输协议这块补充一句OpenCV 默认可能用 UDP你可以在 RTSP 地址后面加?transportmodetcp强制走 TCP或者在环境变量里设OPENCV_FFMPEG_CAPTURE_OPTIONSrtsp_transport;tcp。我实测 TCP 模式下连续跑 24 小时没断过UDP 模式下平均两小时就会花屏一次。4. NPU 推理封装让 RKNN 在服务里稳定工作4.1 RKNN 初始化的正确姿势RKNN 的初始化有个坑rknn.init_runtime()这一步会占用 NPU 资源如果服务里反复初始化NPU 会被占满导致后续初始化失败。正确做法是全局初始化一次所有推理请求复用同一个 RKNN 实例。from rknnlite.api import RKNNLite import numpy as np class YOLOv5Detector: def __init__(self, model_path, conf_threshold0.25, iou_threshold0.45): self.conf_threshold conf_threshold self.iou_threshold iou_threshold self.rknn RKNNLite() ret self.rknn.load_rknn(model_path) if ret ! 0: raise RuntimeError(f加载模型失败: {ret}) ret self.rknn.init_runtime(core_maskRKNNLite.NPU_CORE_0) if ret ! 0: raise RuntimeError(f初始化运行时失败: {ret}) def infer(self, img): # 预处理resize BGR2RGB 归一化 input_img self._preprocess(img) outputs self.rknn.inference(inputs[input_img]) return self._postprocess(outputs, img.shape)core_mask这个参数值得说一下。RK3588 有 3 个 NPU 核心NPU_CORE_0是单核NPU_CORE_0_1_2是三核并联。单路摄像头用单核就够了多路并发的时候可以用三核但要注意三核模式下每个核心的算力是共享的不是简单叠加。我实测单核跑 YOLOv5s 是 22ms三核是 15ms 左右提升没有想象中大因为瓶颈不完全在 NPU。4.2 预处理与后处理的性能陷阱预处理和后处理看起来简单但它们是 CPU 密集操作在边缘设备上很容易成为瓶颈。我一开始用 numpy 手写预处理单帧要 30ms比推理还慢。后来改用 OpenCV 的cv2.dnn.blobFromImage降到 8ms。后处理主要是 NMS非极大值抑制YOLOv5s 输出 25200 个候选框NMS 要遍历这些框。用 numpy 向量化实现比 Python 循环快 10 倍以上。这里给一个关键的后处理片段def _postprocess(self, outputs, orig_shape): # outputs[0] 形状为 [1, 25200, 85] predictions np.squeeze(outputs[0]) # 过滤低置信度 scores predictions[:, 4] mask scores self.conf_threshold predictions predictions[mask] if len(predictions) 0: return [] # 类别置信度 class_scores predictions[:, 5:] class_ids np.argmax(class_scores, axis1) confidences scores[mask] * class_scores[np.arange(len(class_ids)), class_ids] # 转成 xywh - xyxy boxes self._xywh2xyxy(predictions[:, :4]) # NMS indices self._nms(boxes, confidences, self.iou_threshold) return self._format_results(boxes, confidences, class_ids, indices, orig_shape)注意后处理里的坐标缩放要用原始图像的尺寸不是 640x640。我一开始忘了这步画出来的框位置全错查了半天才发现是坐标没还原。4.3 推理线程池隔离FastAPI 是异步框架但 RKNN 推理是阻塞的。如果你直接在 async 函数里调detector.infer()整个事件循环会被卡住其他请求全部排队。正确做法是用run_in_executor把推理丢到线程池import asyncio from concurrent.futures import ThreadPoolExecutor executor ThreadPoolExecutor(max_workers2) app.post(/detect) async def detect(request: DetectRequest): loop asyncio.get_event_loop() result await loop.run_in_executor( executor, detector.infer, request.image ) return result线程池大小设成 2 是因为 RKNN 实例本身不是线程安全的多个线程同时调inference会出问题。要么加锁串行化要么每个线程一个 RKNN 实例。我选的是后者两个实例分别绑到 NPU_CORE_0 和 NPU_CORE_1这样两路并发互不干扰。5. 那个折腾最久的坑uvicorn 日志丢失与请求假死5.1 问题的表象服务搭好之后本地测试一切正常接口能调通检测结果也对。但部署到板子上跑了一段时间后出现了两个诡异现象一是 uvicorn 的访问日志时不时丢失明明有请求进来日志里却看不到二是服务偶尔会“假死”接口不响应但进程还在CPU 占用也不高。这两个现象看起来不相关但我后来发现它们是同一个根因。日志丢失是因为 uvicorn 的日志输出被阻塞了假死是因为事件循环被卡住了。而卡住事件循环的正是我在取流线程里写的一行“看起来没问题”的代码。5.2 排查过程排查花了整整两个晚上。第一晚我怀疑是 RTSP 连接的问题加了大量日志发现取流线程本身是正常的帧一直在更新。第二晚我用py-spy抓了运行时的调用栈发现主线程卡在cv2.VideoCapture.read()上。这就奇怪了取流明明在独立线程里为什么会卡住主线程继续深挖才发现OpenCV 的VideoCapture在某些后端下不是线程安全的而且它内部会持有 GIL。当取流线程在read()里阻塞时GIL 被它占着主线程的 Python 代码根本没法执行表现就是事件循环卡死。更隐蔽的是这个问题不是必现的。网络好的时候read()很快返回GIL 释放及时一切正常网络抖动的时候read()阻塞几百毫秒GIL 被占住主线程就卡住了。这就是为什么本地测试没问题部署到实际网络环境才暴露。5.3 解决方案找到根因之后解决思路就清晰了不能让取流线程长时间持有 GIL。具体做法是把read()的超时控制住并且用cv2.CAP_PROP_OPEN_TIMEOUT_MSEC和cv2.CAP_PROP_READ_TIMEOUT_MSEC设置超时self.cap cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG) self.cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 5000) self.cap.set(cv2.CAP_PROP_READ_TIMEOUT_MSEC, 1000) self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)设置读超时为 1 秒之后即使网络抖动read()最多阻塞 1 秒就返回GIL 能及时释放。实测下来假死现象彻底消失日志也恢复正常了。但这个方案有个副作用超时返回的帧是空的需要处理。我在取流循环里加了判断ret为 False 时跳过这一帧不更新self.frame这样主线程拿到的永远是最新的有效帧。提示如果你也遇到 uvicorn 日志丢失或者服务假死先别怀疑 FastAPI八成是某个阻塞调用占住了 GIL。用py-spy dump --pid pid抓一下调用栈一眼就能看出来。5.4 这个坑给我的教训这个坑折腾了我两个晚上但收获很大。第一个教训是在异步框架里任何阻塞调用都是定时炸弹。OpenCV 的read()、imread()、甚至某些 numpy 操作都可能在特定条件下阻塞。第二个教训是本地测试通过不代表部署没问题网络环境、硬件状态都会影响阻塞行为。第三个教训是排查问题要用工具不要靠猜。我第一晚靠猜浪费了一整晚第二晚用 py-spy 十分钟就定位了。6. 完整服务层实现与接口设计6.1 接口设计服务对外暴露三个接口设计上尽量简单接口方法功能返回/healthGET健康检查服务状态、NPU 状态/detectPOST单张图片检测检测框列表/detect/streamGET实时流检测MJPEG 流/health接口很重要边缘设备上服务可能因为各种原因挂掉有个健康检查接口方便外部监控。/detect是核心接口接收 base64 编码的图片返回检测结果。/detect/stream是实时流直接返回 MJPEG浏览器里打开就能看到实时检测画面调试的时候特别方便。6.2 数据模型定义用 Pydantic 定义输入输出这里贴核心部分from pydantic import BaseModel from typing import List class DetectRequest(BaseModel): image: str # base64 编码 conf_threshold: float 0.25 class Detection(BaseModel): class_id: int class_name: str confidence: float bbox: List[float] # [x1, y1, x2, y2] class DetectResponse(BaseModel): success: bool detections: List[Detection] inference_time_ms: floatinference_time_ms这个字段是我特意加的方便监控推理性能。实际跑下来单帧推理稳定在 22-25ms如果某段时间突然涨到 50ms 以上说明 NPU 可能被其他进程占用了。6.3 应用入口与生命周期管理FastAPI 的 lifespan 机制用来管理摄像头和推理器的生命周期服务启动时初始化关闭时释放from contextlib import asynccontextmanager asynccontextmanager async def lifespan(app: FastAPI): # 启动时 app.state.detector YOLOv5Detector(models/yolov5s.rknn) app.state.camera RTSPCamera(RTSP_URL) app.state.camera.start() yield # 关闭时 app.state.camera.stop() app.state.detector.release() app FastAPI(lifespanlifespan)这样写的好处是资源管理集中在一处不会出现服务重启后 NPU 没释放、摄像头没断开的问题。我一开始把初始化写在模块顶层结果热重载的时候 NPU 被反复占用最后直接报错。6.4 启动脚本最后给一个启动脚本包含环境变量设置#!/bin/bash export OPENCV_FFMPEG_CAPTURE_OPTIONSrtsp_transport;tcp export RKNN_LOG_LEVEL2 uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1--workers 1是必须的因为 RKNN 实例不能跨进程共享多 worker 会导致每个 worker 都初始化一个 NPU 实例资源直接爆掉。要提升并发能力应该在线程池层面做而不是进程层面。7. 常见问题速查与避坑清单7.1 问题速查表现象可能原因解决方法服务假死接口不响应阻塞调用占住 GIL设置 read 超时用 py-spy 定位日志丢失事件循环被阻塞同上检查阻塞调用NPU 初始化失败实例未释放或重复初始化全局单例lifespan 管理画面延迟几秒缓冲区堆积设置 BUFFERSIZE1检测框位置错坐标未还原到原图后处理用原始尺寸缩放RTSP 频繁断连网络抖动或协议不对强制 TCP加重连逻辑推理速度慢预处理用 numpy 手写改用 cv2.dnn.blobFromImage多路并发卡顿单 NPU 核心过载多实例绑不同核心7.2 独家避坑经验第一个经验是关于 NPU 核心分配的。RK3588 的三个 NPU 核心不是对等的核心 0 的调度优先级最高。如果你跑多路把最重要的那路绑到核心 0其他的绑到核心 1 和 2实测下来整体更稳。第二个经验是关于内存的。RK3588 的内存是共享的NPU 推理、摄像头解码、Python 进程都在抢内存。我建议给 NPU 预留至少 512MB可以在设备树里配置。内存不足的时候NPU 推理会莫名变慢但不会报错很难查。第三个经验是关于温度。RK3588 满载跑久了会降频推理速度从 22ms 掉到 35ms 以上。加个散热片是必须的有条件上个小风扇温度控制在 60 度以下性能就稳了。7.3 性能优化建议如果你觉得 15 FPS 还不够可以试试这几个方向。一是降低输入分辨率YOLOv5s 支持 320x320 输入速度能翻倍精度掉一些看你的场景能不能接受。二是跳帧处理摄像头 25 FPS你只处理每两帧实际检测 12.5 FPS但视频流还是流畅的。三是用 RKNN 的零拷贝接口直接把摄像头解码后的 DMA buffer 喂给 NPU省掉一次内存拷贝能省 5ms 左右。我个人在实际操作中的体会是边缘设备上的性能优化瓶颈往往不在你以为的地方。我一开始死磕 NPU 推理速度后来发现预处理和后处理才是大头。用 py-spy 或者 cProfile 先测一遍找到真正的瓶颈再优化比盲目调参有效得多。这套服务层方案我跑了三个月除了偶尔的网络抖动触发重连整体非常稳定你可以放心参考。
阅读完成 · 觉得有帮助?
咨询建站