☰
RK3588边缘计算实战:YOLOv5s模型服务化部署与摄像头推理优化
2026/11/2 3:14:50 网站建设 项目流程

1. 从模型到服务:为什么服务层才是真正落地的分水岭

模型跑通和系统能用之间,隔着一整个服务层的距离。我在 RK3588 上把 YOLOv5s 的推理跑通那一刻确实爽,但爽完之后立刻面对一个很现实的问题:模型推理只是一个函数调用,而一个能用的系统需要接收请求、调度资源、管理摄像头、输出结果、处理异常。这些东西加起来,才是真正意义上的“部署”。

这一篇是整个系列里折腾时间最长的一环,不是因为技术难度最高,而是因为坑最分散——摄像头兼容性、服务并发模型、NPU 资源竞争、图像格式转换,每一个单独拎出来都不算大事,但叠在一起就会让你怀疑人生。我前后大概花了三天时间才把整条链路跑稳,其中有一天半卡在一个摄像头相关的坑上,后面会详细说。

这篇文章适合已经在 RK3588 上跑通了 YOLOv5s 推理、准备把它包装成实际服务的同学。如果你还没跑通推理,建议先看前几篇把基础打牢。整条链路的技术栈是 FastAPI + OpenCV + RKNN Runtime + USB/MIPI 摄像头,最终目标是做一个能通过 HTTP 接口调用、同时支持实时摄像头推理的服务。

2. 服务层设计:FastAPI 在边缘设备上的取舍

2.1 为什么选 FastAPI 而不是 Flask 或 Django

在边缘设备上选 Web 框架,核心考量跟服务器端完全不一样。服务器端你可以随便堆资源,边缘设备上 CPU 核数有限、内存有限、还要留出算力给 NPU 推理。我选 FastAPI 主要基于三个原因。

第一是异步支持。FastAPI 原生基于 asyncio,摄像头取流和推理调度天然适合异步模型。如果用 Flask,每个请求占一个线程,摄像头这种持续占用的资源很容易把线程池打满。第二是性能,FastAPI 底层是 Starlette + Uvicorn,在 RK3588 这种 ARM 平台上实测比 Flask 快不少,尤其是并发请求场景。第三是开发效率,Pydantic 做请求校验、自动生成 OpenAPI 文档,调试阶段省了很多事。

Django 我压根没考虑,太重了,ORM、Admin、模板引擎这些在边缘设备上全是负担。Flask 其实也能用,但异步支持不如 FastAPI 自然,而且做文件上传和流式响应的时候代码会更啰嗦。

2.2 项目目录结构怎么组织才不乱

我一开始图省事把所有代码塞一个文件里,写到三百行的时候就已经改不动了。后来重新整理成下面这个结构,后面加功能舒服很多:

yolov5s_service/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 入口,路由注册 │ ├── config.py # 配置项集中管理 │ ├── models/ │ │ ├── __init__.py │ │ └── schemas.py # Pydantic 请求/响应模型 │ ├── services/ │ │ ├── __init__.py │ │ ├── detector.py # YOLOv5s 推理封装 │ │ └── camera.py # 摄像头管理 │ ├── routers/ │ │ ├── __init__.py │ │ ├── inference.py # 推理相关接口 │ │ └── camera.py # 摄像头相关接口 │ └── utils/ │ ├── __init__.py │ └── image.py # 图像格式转换工具 ├── models/ │ └── yolov5s.rknn # 转换好的模型文件 ├── requirements.txt └── run.sh

这个结构的好处是职责清晰。services/detector.py只管推理,services/camera.py只管取流,路由层只做参数校验和结果封装。后面我要换模型或者换摄像头,改一个文件就行,不用满项目找代码。

2.3 配置管理:别把参数写死在代码里

边缘设备部署有个特点,同一套代码可能要跑在不同板子上,摄像头设备号、模型路径、NPU 核心分配这些都可能不一样。我一开始把/dev/video0这种路径直接写在代码里,换块板子就得改代码重新部署,非常蠢。

后来改成用环境变量加配置文件的方式。config.py里定义默认值,启动时从环境变量覆盖:

import os from pydantic_settings import BaseSettings class Settings(BaseSettings): model_path: str = "models/yolov5s.rknn" camera_id: int = int(os.getenv("CAMERA_ID", "0")) camera_width: int = 640 camera_height: int = 480 npu_core: int = 0 # 0/1/2 对应 NPU 三个核心 conf_threshold: float = 0.25 iou_threshold: float = 0.45 max_detections: int = 100 class Config: env_file = ".env" settings = Settings()

这样部署的时候只要改.env文件或者传环境变量就行,代码完全不用动。NPU 核心分配这个参数后面会细说,RK3588 的 NPU 有三个核心,合理分配能明显提升吞吐。

3. 摄像头接入:从设备识别到稳定取流

3.1 USB 摄像头和 MIPI 摄像头的选择

RK3588 支持多种摄像头接口,常见的是 USB 和 MIPI CSI。我两种都试过,各有优劣。

USB 摄像头的好处是即插即用,/dev/video*设备节点直接出现,OpenCV 用cv2.VideoCapture(0)就能打开。缺点是带宽受 USB 限制,1080p 30fps 的 MJPG 流基本是上限,再高就会掉帧。而且 USB 摄像头在 ARM 平台上有个通病,就是枚举顺序不稳定,重启之后设备号可能变。

MIPI CSI 摄像头的好处是带宽大、延迟低,适合高分辨率高帧率场景。缺点是驱动适配麻烦,不同型号的 sensor 需要对应的驱动和设备树配置,RK3588 的 MIPI 输入还涉及 ISP 调优,不是插上就能用。我用的是一块 OV5647 模组,走 MIPI 接口,配置过程后面单独说。

如果你只是想快速验证功能,建议先用 USB 摄像头,省去驱动适配的时间。等业务逻辑跑通了再换 MIPI 提升性能。

3.2 用 v4l2 确认摄像头是否被正确识别

在写代码之前,先用系统工具确认摄像头状态,这一步能省掉很多瞎猜的时间。

# 列出所有视频设备 v4l2-ctl --list-devices # 查看某个设备支持的格式 v4l2-ctl -d /dev/video0 --list-formats-ext # 查看设备能力 v4l2-ctl -d /dev/video0 --all

--list-formats-ext这个命令特别有用,它会列出摄像头支持的所有像素格式和分辨率组合。我遇到过一个摄像头只支持 YUYV 不支持 MJPG,OpenCV 默认会尝试 MJPG,结果打开失败。看到格式列表之后就知道要手动指定 FOURCC。

3.3 OpenCV 取流的参数调优

OpenCV 打开摄像头的默认参数往往不是最优的,需要手动设置。下面是我实测下来比较稳的配置:

import cv2 def open_camera(device_id=0, width=640, height=480, fps=30): cap = cv2.VideoCapture(device_id, cv2.CAP_V4L2) if not cap.isOpened(): raise RuntimeError(f"无法打开摄像头 /dev/video{device_id}") # 设置 MJPG 格式,USB 带宽利用率更高 cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG')) cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) cap.set(cv2.CAP_PROP_FPS, fps) # 减少缓冲区,降低延迟 cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # 验证实际生效的参数 actual_w = cap.get(cv2.CAP_PROP_FRAME_WIDTH) actual_h = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) actual_fps = cap.get(cv2.CAP_PROP_FPS) print(f"实际参数: {actual_w}x{actual_h} @ {actual_fps}fps") return cap

这里有几个关键点。cv2.CAP_V4L2显式指定后端,避免 OpenCV 自动选择 GStreamer 导致行为不一致。CAP_PROP_BUFFERSIZE设为 1 很重要,默认缓冲区会缓存好几帧,导致你拿到的画面延迟很大。设置完之后一定要读回来验证,因为摄像头不一定支持你请求的参数,它会静默回退到最接近的支持值。

注意:CAP_PROP_BUFFERSIZE在部分 USB 摄像头上不生效,这是驱动层面的限制。如果发现延迟还是很大,可以在取流循环里连续 grab 几次再 retrieve,手动丢弃旧帧。

4. 推理服务封装:让 NPU 真正跑起来

4.1 RKNN Runtime 初始化与 NPU 核心分配

RK3588 的 NPU 有三个核心,可以单独使用也可以组合使用。RKNN Runtime 提供了NPU_CORE_0、NPU_CORE_1、NPU_CORE_2和NPU_CORE_0_1_2几种模式。单核模式适合单路推理,三核模式适合多路并发或者大模型。

我的场景是单路摄像头实时推理,理论上单核就够。但实测发现单核跑 YOLOv5s 640x640 大概在 25ms 左右,如果加上前后处理,帧率会掉到 25fps 以下。后来我改成三核模式,推理时间降到 12ms 左右,整体帧率能稳定在 30fps。

from rknnlite.api import RKNNLite class Detector: def __init__(self, model_path, npu_core=0): self.rknn = RKNNLite() ret = self.rknn.load_rknn(model_path) if ret != 0: raise RuntimeError(f"加载模型失败: {ret}") # 根据配置选择 NPU 核心 core_map = { 0: RKNNLite.NPU_CORE_0, 1: RKNNLite.NPU_CORE_1, 2: RKNNLite.NPU_CORE_2, 3: RKNNLite.NPU_CORE_0_1_2, } ret = self.rknn.init_runtime(core_mask=core_map.get(npu_core, RKNNLite.NPU_CORE_0)) if ret != 0: raise RuntimeError(f"初始化运行时失败: {ret}")

这里有个细节,init_runtime的时候如果指定了三核模式,模型会被复制到三个核心上,内存占用会翻倍。RK3588 一般有 4GB 或 8GB 内存,YOLOv5s 的 rknn 模型大概 7MB 左右,翻倍也无所谓。但如果你跑的是更大的模型,就要注意内存了。

4.2 前处理:从 BGR 到模型输入的完整链路

YOLOv5s 的输入是 640x640 的 RGB 图像,归一化到 0-1。摄像头出来的是 BGR,分辨率也不一定是 640x640。前处理要做的事情包括:resize、letterbox、BGR 转 RGB、归一化、NHWC 转 NCHW。

letterbox 是 YOLO 系列的标准做法,保持宽高比缩放,短边补灰边。这样做的好处是避免图像变形导致检测精度下降。我一开始图省事直接 resize,结果发现细长物体的检测效果明显变差,换成 letterbox 之后好了很多。

import numpy as np import cv2 def letterbox(img, new_shape=(640, 640), color=(114, 114, 114)): shape = img.shape[:2] # 原始 h, w r = min(new_shape[0] / shape[0], new_shape[1] / shape[1]) new_unpad = (int(round(shape[1] * r)), int(round(shape[0] * r))) dw = new_shape[1] - new_unpad[0] dh = new_shape[0] - new_unpad[1] dw /= 2 dh /= 2 if shape[::-1] != new_unpad: img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR) top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1)) left, right = int(round(dw - 0.1)), int(round(dw + 0.1)) img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color) return img, r, (left, top) def preprocess(img): img, ratio, pad = letterbox(img) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = img.astype(np.float32) / 255.0 img = np.expand_dims(img, axis=0) # 加 batch 维度 return img, ratio, pad

注意 RKNN 模型的输入格式。如果你在转换模型的时候没有指定 mean 和 std,那前处理就要自己做归一化。如果转换时已经内置了归一化,那前处理只要做 resize 和格式转换就行。这个要看你转换模型时的配置,搞错了会导致检测结果完全不对。

4.3 后处理:解码输出并映射回原图坐标

YOLOv5s 的输出是三个尺度的特征图,每个格子预测三个 anchor。后处理要做的是解码这些预测,做 NMS,然后把坐标映射回原图。

def postprocess(outputs, ratio, pad, conf_thres=0.25, iou_thres=0.45): # outputs 是 RKNN 输出的列表,需要根据模型结构解码 # 这里假设输出已经过 sigmoid,格式为 [1, 25200, 85] predictions = outputs[0] # 过滤低置信度 conf_mask = predictions[..., 4] > conf_thres predictions = predictions[conf_mask] if len(predictions) == 0: return [] # 解码 xywh -> xyxy boxes = predictions[:, :4] scores = predictions[:, 4:5] * predictions[:, 5:] # ... NMS 逻辑 # 坐标映射回原图 boxes[:, [0, 2]] = (boxes[:, [0, 2]] - pad[0]) / ratio boxes[:, [1, 3]] = (boxes[:, [1, 3]] - pad[1]) / ratio return boxes, scores

后处理这块最容易出错的是坐标映射。letterbox 的时候加了 padding,解码出来的坐标是在 640x640 图上的,要减掉 padding 再除以缩放比例才能映射回原图。我一开始忘了减 padding,结果检测框整体偏移,排查了半天。

5. 那个折腾最久的坑:摄像头格式与 NPU 输入的不匹配

5.1 问题现象:推理结果全是乱框

坑是这样的。我把整条链路搭好之后,用一张测试图片走 HTTP 接口推理,结果完全正确。但换成摄像头实时取流,检测框全是乱的,位置随机,类别也乱七八糟。

第一反应是模型有问题,但我用同一张图片反复测试都正常,说明模型没问题。第二反应是摄像头取流有问题,我把摄像头画面保存成图片,肉眼看完全正常。第三反应是前处理有问题,但前处理代码跟测试图片走的是同一套。

这就很诡异了。同样的前处理,同样的模型,输入图片正常,输入摄像头帧就乱。我甚至怀疑是 NPU 在并发场景下的问题,试了加锁、试了单线程,都没用。

5.2 排查过程:从格式到内存布局

排查这种问题,最有效的方法是二分法。我把摄像头帧保存下来,然后用测试图片的路径重新走一遍推理,结果正常。这说明问题出在摄像头帧本身,而不是推理链路。

然后我对比了摄像头帧和测试图片的差异。用img.shape和img.dtype打印出来,发现摄像头帧是(480, 640, 3)的uint8,测试图片也是。看起来一样。

但当我用img.flags查看内存布局时,发现了问题。摄像头帧的C_CONTIGUOUS是False,而测试图片是True。这意味着摄像头帧在内存里不是连续存储的。

OpenCV 从摄像头读取的帧,有时候会是 ROI 视图或者有 stride 对齐,导致内存不连续。而 RKNN 的输入要求是连续内存,如果不连续,它读取的数据就会错位,导致推理结果完全乱掉。

5.3 解决方案:一行代码搞定

解决方法很简单,加一行np.ascontiguousarray:

def preprocess(img): img, ratio, pad = letterbox(img) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = np.ascontiguousarray(img) # 关键:确保内存连续 img = img.astype(np.float32) / 255.0 img = np.expand_dims(img, axis=0) return img, ratio, pad

就这一行,折腾了我一天半。np.ascontiguousarray会强制把数组复制成 C 连续布局,虽然多了一次内存拷贝,但保证了 RKNN 读取的数据是正确的。

实操心得:在 ARM 平台上做图像处理,内存布局问题比 x86 上常见得多。摄像头驱动、OpenCV 的优化路径、NEON 指令对齐要求,都可能导致内存不连续。养成习惯,在把图像数据喂给推理引擎之前,先np.ascontiguousarray一下,能省掉很多莫名其妙的 bug。

5.4 延伸:其他可能导致推理异常的原因

除了内存不连续,还有几个坑我也踩过或者见别人踩过,一并列出来。

第一个是颜色通道顺序。OpenCV 默认 BGR,但很多模型训练时用的是 RGB。如果忘了转换,检测结果会偏,但不至于全乱。第二个是归一化参数。有的模型训练时用了 ImageNet 的 mean 和 std,推理时如果只做 0-1 归一化,结果会差很多。第三个是输入尺寸。模型输入是 640x640,如果你传了 320x320,RKNN 不会报错,但结果肯定不对。

6. 服务接口设计与并发处理

6.1 接口设计:图片推理和摄像头推理分开

服务层我设计了两个核心接口。一个是POST /predict,接收上传的图片,返回检测结果。另一个是GET /camera/stream,返回 MJPEG 流,实时显示摄像头画面和检测框。

图片推理接口适合做批量处理和测试,摄像头流接口适合做实时监控。两个接口分开的好处是资源隔离,图片推理不会影响摄像头流的稳定性。

from fastapi import FastAPI, File, UploadFile from fastapi.responses import StreamingResponse app = FastAPI(title="YOLOv5s RK3588 Service") @app.post("/predict") async def predict(file: UploadFile = File(...)): contents = await file.read() img = cv2.imdecode(np.frombuffer(contents, np.uint8), cv2.IMREAD_COLOR) results = detector.detect(img) return {"detections": results} @app.get("/camera/stream") async def camera_stream(): return StreamingResponse( generate_frames(), media_type="multipart/x-mixed-replace; boundary=frame" )

MJPEG 流的原理是在一个 HTTP 响应里持续发送 JPEG 图片,每张图片用 boundary 分隔。浏览器收到之后会自动刷新显示,看起来就像视频。这种方式的优点是实现简单、兼容性好,缺点是带宽利用率低,每帧都是独立 JPEG。

6.2 并发模型:摄像头资源怎么加锁

摄像头是独占资源,同一时间只能有一个线程读取。如果多个请求同时访问摄像头流接口,就会出现资源竞争。我用了一个简单的锁来保护:

import threading camera_lock = threading.Lock() def generate_frames(): while True: with camera_lock: ret, frame = cap.read() if not ret: continue results = detector.detect(frame) frame = draw_boxes(frame, results) _, jpeg = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80]) yield (b'--frame\r\n' b'Content-Type: image/jpeg\r\n\r\n' + jpeg.tobytes() + b'\r\n')

这里有个取舍。加锁保证了安全,但如果有多个客户端同时请求流,每个客户端都会触发一次推理,NPU 会被打满。更好的做法是推理和分发分离,推理线程只跑一次,把结果分发给所有客户端。不过对于单客户端场景,简单加锁就够了。

6.3 性能实测:端到端延迟和帧率

我在 RK3588 上实测了一组数据,供参考:

环节耗时
摄像头取流5-8ms
前处理(letterbox + 格式转换)8-12ms
NPU 推理(三核模式)12-15ms
后处理(解码 + NMS)5-8ms
绘制检测框2-3ms
JPEG 编码5-8ms
合计37-54ms

端到端延迟大概在 40-55ms,帧率能稳定在 20-25fps。如果关掉 JPEG 编码直接输出原始帧,帧率能到 30fps 以上。这个性能对于大多数实时监控场景已经够用了。

7. 常见问题与排查速查表

7.1 摄像头相关

问题可能原因解决方法
打不开摄像头设备号不对或被占用v4l2-ctl --list-devices确认,lsof /dev/video0查占用
画面全黑格式不支持或曝光问题指定 FOURCC 为 MJPG,检查--list-formats-ext
延迟很大缓冲区堆积设置CAP_PROP_BUFFERSIZE=1,或循环里 grab 丢弃旧帧
帧率上不去USB 带宽不足降低分辨率,或改用 MJPG 格式
设备号重启后变化USB 枚举顺序不稳定用/dev/v4l/by-id/下的稳定路径

7.2 推理相关

问题可能原因解决方法
检测结果全乱内存不连续np.ascontiguousarray
检测框偏移letterbox padding 没减检查坐标映射逻辑
置信度普遍偏低归一化参数不对确认模型转换时的 mean/std 配置
NPU 初始化失败核心被占用或驱动问题检查dmesg,确认 NPU 驱动加载正常
推理速度慢单核模式改用三核模式NPU_CORE_0_1_2

7.3 服务相关

问题可能原因解决方法
接口超时推理阻塞事件循环用run_in_executor把推理放到线程池
内存持续增长摄像头帧没释放检查循环引用,显式del frame
多客户端卡顿每个客户端都触发推理推理和分发分离,共享推理结果
服务启动慢模型加载耗时用 lifespan 在启动时预加载模型

8. 一些零散但重要的经验

8.1 模型预加载与生命周期管理

FastAPI 的 lifespan 机制可以在服务启动和关闭时执行代码。我把模型加载放在 lifespan 里,避免每次请求都重新加载:

from contextlib import asynccontextmanager @asynccontextmanager async def lifespan(app: FastAPI): global detector detector = Detector(settings.model_path, settings.npu_core) yield detector.rknn.release() app = FastAPI(lifespan=lifespan)

这样服务启动时加载一次模型,之后所有请求共用。服务关闭时释放 NPU 资源,避免下次启动冲突。

8.2 日志与监控

边缘设备上排查问题,日志是生命线。我用 Python 的 logging 模块,把关键信息打到文件里:

import logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler('service.log'), logging.StreamHandler() ] )

推理耗时、摄像头状态、请求量这些信息都值得记录。后面如果要接 Prometheus + Grafana 做监控,这些日志就是数据源。

8.3 关于模型轻量化的一点想法

YOLOv5s 在 RK3588 上跑 640x640 已经比较流畅了,但如果你要跑更高分辨率或者多路视频,就需要考虑模型轻量化。常见的手段包括:换更小的模型(YOLOv5n)、降低输入分辨率、剪枝、量化。RKNN 工具链支持 INT8 量化,量化之后模型体积和推理时间都能明显下降,但精度会有一定损失,需要根据业务场景权衡。

我实测下来,YOLOv5s 做 INT8 量化之后,推理时间从 12ms 降到 8ms 左右,mAP 下降大概 2-3 个百分点。对于大多数监控场景,这个精度损失是可以接受的。

8.4 后续可以扩展的方向

这套服务层搭好之后,后面可以做的事情很多。比如接入 RTSP 网络摄像头,把服务做成多路推理;比如加一个任务队列,支持异步批量推理;比如把检测结果推送到消息队列,对接上层业务系统。服务层的价值就在于它是一个可扩展的基础,后面加功能不用推倒重来。

我在实际使用中发现,边缘设备上的服务开发和服务器端最大的区别是资源约束。每一个内存分配、每一次线程切换、每一个锁竞争,在服务器上可能无所谓,在边缘设备上都会被放大。所以写代码的时候要时刻想着资源,能复用就复用,能异步就异步,能预分配就预分配。这些习惯养成之后,服务的稳定性会好很多。

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

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

立即咨询