☰
RK3588 上 YOLOv5s 服务化部署:摄像头接入与 NPU 推理避坑指南
2026/11/2 3:16:27 网站建设 项目流程

前前后后折腾了快一个月,RK3588 上的 YOLOv5s 总算从"能跑通 demo"进化到"能稳定提供检测服务"。前面几篇把交叉编译环境、RKNN 模型转换、NPU 推理这几关都过了,今天这篇是系列的第七篇,也是我个人觉得最有实战价值的一篇:服务层到底怎么设计、摄像头怎么接进来,以及我踩到的那个让我反复熬夜的坑。

先说结论:如果你准备在 RK3588 上做边缘检测服务,一定要提前规划好服务层的数据流和线程模型,摄像头采集不能和推理直接绑在同一个线程里,否则后面排查问题时你会非常痛苦。至于那个折腾最久的坑,其实是一个和 RKNN 模型输出解析以及图像色彩空间有关的小问题,但它的排查过程让我把整个链路的每一环节都重新查了一遍。

这篇内容适合已经有 YOLOv5s 模型和 RKNN 推理基础、正在做服务化封装或者摄像头接入的朋友,不需要你有很强的后端经验,我尽量把每个选择背后的理由都讲清楚。

1. 服务层整体设计与架构选择

先说为什么需要服务层。模型转换完了、推理代码跑通了,但如果只能通过在终端里执行 Python 脚本拿到检测结果,这个东西离"可用"还差得远。实际项目里,模型通常要作为一个独立的计算单元,接收来自不同业务模块(摄像头、局域网请求、告警联动)的输入,再把结果返回给调用方。这就是服务层要解决的问题。

1.1 服务框架选型:从 Flask 到接口设计

在 RK3588 这种边缘设备上做服务层,选型不能照搬服务器端那套。我用的是 Flask,理由很朴素:

  • RK3588 的内存和 CPU 资源虽然比树莓派强不少,但和 x86 服务器没法比,Flask 的纯 Python 运行时开销已经够用;
  • FastAPI 的异步特性在这里帮不上太大忙,因为瓶颈在 NPU 推理本身,而不在 IO;
  • 项目里的其他模块都是 Python 写的,Flask 接入成本最低,一个文件就能把服务拉起来。

但 Flask 自带的开发服务器不推荐直接用于生产。这一点可能有人会忽略——开发时用app.run()没问题,但如果服务层需要长期运行并且对接多路视频流,建议用waitress这类纯 Python 的 WSGI 服务器。我最终用的是waitress,稳定、没有额外的 C 扩展依赖,在 RK3588 上编译也省心。

服务接口我设计了三个:

# POST /api/detect # 接收图片数据流,返回检测结果 # GET /health # 健康检查,返回 NPU 状态、模型加载状态、最近推理耗时 # POST /api/reload # 重载模型配置,用于模型文件更新后不用重启进程

这个接口设计看起来简单,但实际使用中我有几点体会:

  1. 入参一定要收图片字节流,而不是要求调用方传一个本地路径。因为摄像头采集端在一台设备上,业务调用方可能在局域网另一台机器,传路径意味着要求双方有共享存储,这在边缘场景里不现实。
  2. 健康检查接口要能反映 NPU 状态。RK3588 的 NPU 在长时间运行后偶尔会出现驱动异常,健康检查里加一个非常轻量的推理测试(比如跑一张 32x32 的空图)能快速发现问题。
  3. 重载接口是必须的。模型文件在调试阶段会频繁更新,如果没有重载机制,每次都要重启进程,边缘设备的 SSH 连接不稳定,重启服务很麻烦。

1.2 为什么不能把摄像头采集和推理放在同一个线程

这是我在早期版本里犯的错。最初图省事,直接把摄像头的每一帧都丢给推理函数,结果问题极其明显:摄像头 30fps 进帧,推理大概 20ms 一帧(看起来很快对吧?),但整个系统偶尔会出现卡顿,帧率掉到 7~8fps。

原因不复杂:

  • 摄像头采集线程和推理线程共用同一个 GIL,Python 的多线程并不能让 NPU 调用和图像读取真正并行;
  • 图像解码(H.264 软解)本身就是 CPU 密集操作,会占掉 RK3588 的大核资源,而 NPU 算力再强,CPU 喂不进去数据也没用;
  • 摄像头驱动在帧堆积的情况下会自动丢帧,但你感知不到,"看起来像是在工作",实际上推理的输入已经滞后了好几秒。

正确的做法是解耦:采集线程只管把最新的一帧放进环形队列,推理线程持续从队列取帧,队列只保留最新帧。这样即使推理速度暂时跟不上,也不会导致延迟无限增长。

我在服务层里用了一个极简的FrameBuffer:

import threading import time class FrameBuffer: """只保留最新帧的缓冲区,避免内存无限堆积""" def __init__(self, max_age=1.0): self._lock = threading.Lock() self._frame = None self._timestamp = 0.0 self._max_age = max_age def push(self, frame): with self._lock: self._frame = frame self._timestamp = time.time() def get(self): with self._lock: if self._frame is None: return None if time.time() - self._timestamp > self._max_age: return None # 帧太旧,说明采集已经异常 return self._frame

这个类做的事情很简单:一帧数据进来就覆盖上一帧,读取时如果发现帧已经太旧则返回空,让上层逻辑能感知到采集异常。这个"只保留最新帧"的设计是边缘视频服务的一个关键点——宁可丢帧,不能让延迟无限拉长。

2. 模型服务化的核心实现

服务层的框架搭好了,接下来是模型推理本身怎么封装。这里有几个细节值得展开说,因为我见过不少人在这一步出问题。

2.1 推理实例的初始化与 NPU 会话管理

RKNN 的RKNN对象在初始化时需要加载模型文件、申请 NPU 会话。很多人会在每次请求里都去做rknn = RKNN()再初始化,这在高频调用下会带来严重的开销,NPU 会话创建和销毁的时间可能比推理本身还长。

我的做法是:模块级单例,进程启动时一次性初始化,后面所有请求共用同一个推理实例。

# predictor.py import os import numpy as np from rknnlite.api import RKNN _model = None _init_lock = threading.Lock() def get_model(model_path: str, target_platform: str = "rk3588"): global _model if _model is not None: return _model with _init_lock: # double-check,防止并发初始化 if _model is not None: return _model rknn = RKNN() rknn.load_rknn(model_path) rknn.init_runtime(target_platform=target_platform) # 跑一次预热推理 dummy = np.zeros((640, 640, 3), dtype=np.uint8) rknn.inference(inputs=[dummy]) _model = rknn return _model

预热推理这块很多人不重视。NPU 首次调用的耗时经常是正常耗时的几十倍,因为需要完成模型加载、权重搬运、算子初始化。如果不预热,第一个请求的响应时间会非常难看,我实测过一个 640x640 的模型预热前首帧耗时接近 1.5 秒,预热后稳定在 20~25ms。

2.2 推理接口的数据格式与后处理

YOLOv5s 的 RKNN 输出是三个尺度的特征图,分别对应 80x80、40x40、20x20 的网格,每个网格有 3 个 anchor,每个 anchor 预测 85 个值(x, y, w, h, obj, 80 类)。这意味着推理输出还不是最终结果,必须经过解码、NMS(非极大值抑制)才能得到可用的检测框。

关键坑点:RKNN 版本不同,输出张量的排列方式可能不同。部分版本输出维度是[1, 255, 80, 80](CHW),部分版本是[1, 80, 80, 255](HWC),如果不做维度判断直接按常规方式解析,结果的置信度会完全不对。

我的解析函数开头先做了一次维度归一化:

def _prepare_outputs(raw_outputs): prepared = [] for out in raw_outputs: arr = np.array(out) # 统一为 HWC 格式 if arr.shape[1] == 255 or arr.shape[1] == 85: arr = arr.transpose(0, 2, 3, 1) prepared.append(arr) return prepared

另外,RKNN 推理输出的是经过 Sigmoid 激活的值还是原始 logits,取决于转换时是否在模型里带了 Sigmoid 层。YOLOv5s 原始导出 ONNX 时如果包含了 Sigmoid 节点,RKNN 推理输出就是 0~1 之间的概率值,不需要再做 Sigmoid;如果没有包含,则输出的是 logits,必须在后处理里手动做 Sigmoid。这两种情况我都遇到过,建议在后处理里加一个可配置项,比如通过判断输出数值范围来自动适配。

2.3 并发请求的锁与排队策略

RK3588 上一块 NPU 同时只能执行一个推理任务(至少用 RKNN 的同步接口是这样)。如果服务层收到了多个并发请求,直接让它们同时调用rknn.inference会出现问题,不能指望 NPU 帮你排队。

我的方案是:一个全局线程锁,推理请求进来后先获取锁,拿到锁才执行推理。因为推理本身只有 20ms 左右,这个锁的等待时间在可接受范围内。但要注意,锁会让 FastAPI 或 Flask 的 worker 线程全部阻塞,如果你的调用方有超时时间,需要合理设置并发上限。

实测参数参考:

请求并发数平均响应时间丢帧/超时情况
122ms无
5110ms无
10230ms无
20510ms偶发超时,调用方需重试

这个数据基本上符合线性排队规律。如果你的业务需要更高的吞吐,有两个建议:

  • 在服务层做批量推理,把多个请求的图片拼成 batch 输入,虽然 RKNN 对 batch 的支持在不同模型转换方式下有差异,但值得试试;
  • 如果只是单路摄像头检测,完全不需要考虑并发,锁方案足够。

3. 摄像头接入的两种路线与部署细节

摄像头接入这块,我可以说是一路踩过来的。RK3588 作为边缘计算板子,接摄像头无外乎三种方式:USB 摄像头、MIPI CSI 摄像头、RTSP 网络摄像头。我最终同时接了两路:一路本地 USB,一路同一局域网内的 RTSP。

3.1 USB 摄像头的 V4L2 参数调优

先用v4l2-ctl --list-devices确认设备节点,再确认支持的格式:

v4l2-ctl --device=/dev/video0 --list-formats-ext

USB 摄像头默认上电后分辨率往往只有 640x480,或者帧率只有 5fps。需要手动设置想要的格式,这个操作可以用 OpenCV 的cv2.VideoCapture参数来做,但实测下来 OpenCV 在某些驱动下设置不生效。我更推荐直接用v4l2-ctl设置到设备层:

v4l2-ctl --device=/dev/video0 \ --set-fmt-video=width=1280,height=720,pixelformat=NV12 \ --set-parm=30

注意 RK3588 上的 USB 摄像头驱动走的是 UVC 协议,格式上 NV12 比 MJPEG 的 CPU 解码开销更低。如果你选 MJPEG 格式,OpenCV 会自动做 JPEG 解码,会额外占 CPU,在同时跑多路视频时影响很大。

还有一个小细节:摄像头设备节点的稳定性。USB 摄像头在板子重启后可能会从/dev/video0变成/dev/video1,如果你的服务层写死了设备路径,重启后摄像头就采集不到画面了。我的处理方式是在服务启动时通过设备产品号(lsusb里的ID:Product)动态匹配设备节点,避免依赖固定路径。

3.2 MIPI CSI 摄像头的适配要点

如果你用的是 RK3588 配套的 MIPI CSI 摄像头(比如 imx219、ov5647 这类),流程会复杂一些:

  • 需要确认板子的 dts 设备树文件是否已经启用了对应的摄像头节点;
  • 确认/dev/video节点的格式是否为NV12,MIPI 摄像头的数据流和 USB 不同,OpenCV 直接打开设备有时会报VIDIOC_REQBUFS错误;
  • 最常见的问题是图像方向不对,或者颜色偏绿/偏紫,这是 ISP 参数的问题,需要检查 rkisp 的配置文件。

如果你的需求是快速验证模型效果,我更推荐直接用 USB 摄像头,省去驱动层面的折腾。等模型在业务侧跑稳定后,再去适配 MIPI。

3.3 RTSP 拉流的延迟问题与 ffmpeg 后端

接网络摄像头时遇到的最典型问题是延迟越来越大。代码看起来没问题:

cap = cv2.VideoCapture("rtsp://192.168.1.64:554/stream1")

跑几分钟后,延迟可能从最初的几百毫秒涨到几秒。这是因为 OpenCV 在高版本中默认用的是 ffmpeg 后端拉流,ffmpeg 内部会有一个缓冲队列,当网络抖动或者解码速度跟不上时,缓冲会堆积。

我的处理方式有三个:

  1. 使用cv2.CAP_FFMPEG显式指定后端,至少保证行为一致;
  2. 设置 RTSP 传输协议为 TCP,而不是默认的 UDP,UDP 丢包会导致花屏和解码卡顿,TCP 的延迟稍高但稳定得多;
  3. 在采集线程中主动丢帧,不要求 OpenCV 的read()必须返回最新帧,而是手动清空缓冲区。实测有效。

如果cv2.VideoCapture的延迟控制始终不理想,可以用av库(PyAV)做底层 RTSP 拉流,PyAV 的内部缓冲区控制更细,可以设置buffer_size和timeout。不过这只是备选方案,我在 RK3588 上实测 OpenCV 配合 TCP 和主动丢帧已经完全够用。

4. 折腾最久的坑:RKNN 输出的解析错位与 BGR/RGB 通道问题

现在说那个折腾我最久的坑。这个问题说起来非常简单——但排查过程极其折磨。只用一个词概括:通道顺序。

4.1 现象描述:置信度全部异常

当时的情况是这样的:模型转换没问题,推理调用没报错,输出张量的形状也符合预期,但检测结果完全不对——所有目标框的置信度都极低,甚至出现大量的错误框,几乎等同于模型失效。最诡异的是:同样的模型、同样的输入图片,在我用官方 NPU demo 脚本测试时是正常的,但只要丢进我自己的服务层代码里,结果就崩了。

这让我一开始怀疑是服务层代码的问题,排查了两天,把 Flask 部分、线程部分、图像压缩部分全查了一遍,毫无收获。

4.2 排查过程:从输入到输出的逐步验证

我开始逐步打日志,对比 demo 脚本和服务层代码的每一步差异。先检查输入图像:demo 脚本用的是cv2.imread()读取图片,我的服务层收到的是调用方上传的图片字节流,再通过cv2.imdecode()解码。两者读出来的结果应该都是 BGR 顺序的numpy数组,看起来没有区别。

再对比推理前对输入的处理,问题终于露头了——我在服务层代码里多写了一行:

img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

这行代码放在预处理里。看起来"没问题"?不对。之所以写这行,是因为我在参考其他项目的 RKNN 推理代码时,看到他们做了一次 BGR 到 RGB 的转换,因为很多模型训练时用的是 RGB 输入。但问题在于,YOLOv5 的原始推理流程里,输入到网络的图像直接从 OpenCV 读出来就是 BGR 格式,不需要额外转换。我的模型在转换时设置的mean_values和std_values是按照 BGR 还是 RGB 来算的?这直接决定了预处理是否必须做通道转换。

翻看了转换脚本:

rknn.config(mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform="rk3588")

mean_values和std_values各只有一个三元组,如果这个三元组被 RKNN 解释为R,G,B顺序,那输入必须是 RGB;如果被解释为B,G,R顺序,那输入必须是 BGR。经过逐项验证,RKNN 在工具链的这个版本中,输入数组的通道顺序需要和模型训练时的预处理一致,而 YOLOv5 的训练管线用的是 RGB 且做了归一化,实际转换时 RKNN 工具链已经内置了对应的预处理方式。问题在于我在服务层为了"保险"加了cvtColor,把本来是 BGR 的图像转成了 RGB,然后输入的数字和 RKNN 期望的数字就反了,模型输出的置信度自然会崩。

4.3 根因确认与解决方案

用排除法确认:去掉这行cvtColor,直接用cv2.imdecode()得到的 BGR 数组作为输入。推理结果立刻恢复正常,置信度回到 0.85 以上。

根因说透了就是:模型转换时用的归一化参数是基于输入数组原样计算的,如果你在预处理环节改变了通道顺序却没改归一化参数,等于喂给模型的数据分布完全不同。BGR 和 RGB 的通道互换会让 R、G、B 三个通道的值被错误对应,对于 YOLOv5 这种对颜色敏感的检测模型,效果就是灾难性的。

这里补充一个非常实用的检查方法:如果你拿到一个黑盒的 RKNN 模型,不确定它的预处理到底期望什么通道顺序,可以制作一张纯红色图片和一张纯蓝色图片分别推理,看模型输出类别/置信度变化。纯红的 BGR 值大约是(0,0,255),纯蓝是(255,0,0),如果数据被通道互换,模型对这两张图的响应会非常不一样。这个方法帮我快速确认了不少模型的输入通道要求。

4.4 另一个隐藏坑:NPU 推理输出与 Sigmoid 的精度偏差

通道问题解决后,又出现了一个若隐若现的坑:检测框偶尔会漏检,尤其是置信度在 0.45~0.5 附近的目标。排查后发现问题出在 RKNN 的算子融合上。

原版 YOLOv5 的 PyTorch 模型中,置信度分支经过 Sigmoid 后,数值是精确的浮点;但 RKNN 工具链在转换时,会尝试将输出层的 Sigmoid 算子融合到前面的卷积运算中,可能使用近似计算或降低精度的方式优化。实测在置信度接近阈值(比如 0.45~0.5)的目标上,RKNN 的 Sigmoid 输出和 PyTorch 的标准 Sigmoid 输出有大约 1e-3 级别的偏差。这个偏差看起来很小,但恰好落在阈值边界时,就会造成漏检。

我的解决思路是:把后处理的置信度阈值从默认的 0.25 提高到 0.35 以上,或者反过来,如果你对漏检极其敏感,可以降低到 0.2,但会引入大量误检。更合理的做法是:为 NPU 部署单独校准阈值,不要直接沿用训练时的阈值。由于 NPU 推理结果的数值分布和 GPU/CPU 上不完全一致,后处理参数应该基于实际部署设备上的验证集重新调一次。

5. 服务层部署后的完整验证与性能记录

所有问题解决后,我把整条链路做了一次完整的端到端验证,包括服务层的压测、摄像头采集的稳定性,以及长时间运行下的内存表现。这里给出我的实测记录和参考建议。

5.1 端到端性能实测数据

测试条件:

  • 板子:RK3588,8G 内存版
  • 系统:Ubuntu 20.04(kernel 5.10)
  • 模型:YOLOv5s 640x640,INT8 量化
  • 摄像头:USB 摄像头 1280x720@30fps,RTSP 摄像头 1920x1080@25fps
  • 服务:Flask + waitress,单 worker
项目数值
模型加载耗时880ms(首次,含预热)
单帧推理耗时22~28ms
预处理(resize + 归一化)3~5ms
后处理(解码 + NMS)6~9ms
端到端 P95 延迟40ms
稳定运行 72 小时内存占用680MB 左右

CPU 占用方面,推理部分的 CPU 主要消耗在resize和 NMS 上,NV12 格式的 UVC 摄像头解码开销比 MJPEG 低很多。如果同时跑两路摄像头,建议给采集线程设置不同的 CPU 亲和性,把采集线程绑到大核,推理线程不要绑核,让 NPU 驱动自己调度。

5.2 长时间运行时的内存与句柄监控

Linux 下长期运行的服务最怕"隐性泄漏"——内存看着不涨,但文件描述符、线程数在缓慢增长。我在板子上加了一个简单监控脚本:

while true; do echo "$(date) $(ps -o pid,rss,vsz,etime,cmd -p $(pgrep -f app.py))" >> /var/log/ai_service_stat.log ls /proc/$(pgrep -f app.py)/fd | wc -l >> /var/log/fd_count.log sleep 60 done

实测 72 小时后,文件描述符数量稳定在 45 个左右,没有持续增长;内存从启动初期的 620MB 涨到 680MB 后稳定。这基本可以排除明显的资源泄漏问题。如果你的服务 fd 数量持续上涨,优先检查摄像头VideoCapture是否没有释放,以及 Flask 的日志句柄是否在反复创建。

另外要注意:RK3588 的 NPU 驱动和图形驱动在长时间运行后偶尔会触发 GPU 频率切换,建议在/sys/class/devfreq/fdab0000.npu下把 governor 设为 userspace,固定一个合理频率(比如 1.0GHz),防止频率波动带来的推理耗时抖动。

5.3 供下一位实践者的几个建议

走到这一步,整个项目已经算完整收官了。回顾整个过程,有几个建议想分享给同样在折腾 RK3588 + YOLOv5 的朋友:

不要盲目参考 x86 上的部署方案。边缘设备的 NPU 调度、内存带宽、驱动行为都和服务器 GPU 完全不同,很多参数要自己实测。

把"数据流向"画清楚再写代码。摄像头采集、图像缓冲、推理、后处理、API 返回,每个环节之间的数据格式转换是出问题最多的地方,花半小时把数据流画清楚,能省三天的排查时间。

日志要带时间戳和模块标识。排查那个通道问题的时候,如果我有完整的日志记录每个阶段的图像尺寸和数值范围,定位会快很多。建议在预处理后打印一次输入数组的均值、方差,这类信息对异常排查非常有用。

NPU 部署的阈值和后处理参数必须独立调优,不要拿着训练时的指标直接套。RKNN 工具链的算子优化会带来数值层面的微小变化,这些变化在边缘场景下会被放大。

这次实践整体上验证了 RK3588 作为边缘 AI 设备的性价比:一块几百块钱的开发板,能稳定跑到 25ms 级别的单帧检测延迟,同时承担摄像头采集、服务对外、告警联动这些边角任务。但整个过程中最大的感受是,模型转换和 NPU 调用本身并不是最难的,真正花时间的反而是数据格式、通道顺序、线程模型这些"看起来简单"的工程细节。

如果你也正在做 RK3588 上的 YOLO 部署,建议先把这篇里的"服务层设计思路"和"通道顺序排查方法"复制到自己的笔记里,大概率能帮你少走几个弯路。后面我还会继续分享 RK3588 上多模型调度和低功耗运行的经验,欢迎持续关注。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询