1. 为什么是 RK3588 + RetinaFace:这个组合的真实价值
先说结论:RK3588 的 6 TOPS NPU 算力,刚好卡在“能跑人脸检测”的甜蜜区间。比它弱的芯片跑 RetinaFace 容易掉帧,比它强的芯片价格又不够亲民。而 RetinaFace 本身在 WiderFace 上的精度表现,至今仍是轻量级人脸检测模型里最能打的那批——它同时输出人脸框、5 个关键点和 3D 姿态,一套推理结果能直接喂给后续的人脸对齐、人脸比对、活体检测模块,不用再单独训练一个关键点模型。
我做这个项目的时候,最初的诉求其实特别朴素:在 RK3588 上跑一个实时人脸检测服务,输入 USB 摄像头或者 RTSP 视频流,输出检测框和关键点坐标,延迟控制在 30ms 以内。当时手头有 PyTorch 1.8 训练好的 RetinaFace(MobileNet0.25 backbone),本想走 ONNX Runtime 直接硬跑,结果在 RK3588 的 CPU 上测了一下,单帧推理要 120ms 左右,完全没法用。换 RKNN-Toolkit2 量化转成 NPU 模型之后,单帧推理降到 15ms 左右,整个流程才真正活过来。
这篇文章不是从零讲解 RetinaFace 原理,也不是 RKNN 官方文档的复读,而是把我踩过的坑、反复验证过的参数、以及最终跑通的完整链路记录下来。适合三种人看:
- 手里有 RK3588 开发板,想把 PyTorch 模型部署上去的新手;
- 已经在跑 RKNN 但遇到“转换成功、推理结果全错”这类玄学问题的开发者;
- 需要做人脸检测 + 关键点输出的项目,但不确定 RK3588 方案是否可行的方案选型者。
整个流程围绕一条主线展开:PyTorch 模型 → ONNX → RKNN 量化 → NPU 推理。每个环节都有对应的坑位,我会按实际踩坑的顺序来讲,不跳步。
先说清楚我的硬件环境,后面所有操作都基于这套:
| 项目 | 配置 |
|---|---|
| 开发板 | 瑞芯微 RK3588(8GB 内存版,也可以理解成 RK3588S) |
| NPU 驱动 | rknpu2 1.6.0 |
| RKNN-Toolkit2 | 2.3.0(PC 端转换用) |
| 转换环境 | Ubuntu 20.04 x86_64 + Python 3.8 |
| 板端推理环境 | Ubuntu 22.04(板载系统)+ Python 3.10 |
| 原始模型 | RetinaFace-MobileNet0.25,PyTorch 1.8 训练 |
如果你手里的 RKNN-Toolkit2 版本是 1.x,或者 NPU 驱动是旧版,某些 API 参数会不一样,但坑位的底层逻辑是通用的,对照着排查即可。
2. 环境准备:最容易翻车的不是模型,而是环境
2.1 RKNN-Toolkit2 的安装版本陷阱
RKNN-Toolkit2 的安装,第一坑就是 Python 版本兼容性。官方支持 Python 3.6 ~ 3.11,但我实测下来Python 3.8 + Ubuntu 20.04 是最稳的组合。Python 3.10 在部分版本上会遇到 numpy 编译报错,Python 3.6 又会碰到个别依赖库不再维护的问题。
安装命令很简单,但要注意必须用 pip 安装本地 whl 包,不是直接从 PyPI 拉:
# 建议用 conda 创建独立环境 conda create -n rknn python=3.8 conda activate rknn # 安装 RKNN-Toolkit2 的依赖 pip install -r requirements_cp38-2.3.0.txt # 安装 RKNN-Toolkit2 本体 pip install rknn_toolkit2-2.3.0-cp38-cp38-linux_x86_64.whl这里有个非常容易踩的坑:rknn_toolkit2这个 Python 包只支持 x86 平台,它不能直接跑在 RK3588 板子上。板子上部署用的是rknpu2的运行时库,也就是librknnrt.so,外加 Python 接口rknnlite。这两个是完全不同的东西:
- rknn_toolkit2:跑在 PC 上,负责把 ONNX/PyTorch 模型转换成 RKNN 格式,可以做量化、仿真推理;
- rknpu2(rknnlite):跑在板子上,负责加载 RKNN 模型并调用 NPU 推理。
我在第一次接触这个生态的时候,天真地以为在板子上装个 toolkit2 就能直接转换,折腾了一下午才发现方向错了。转换必须在 PC 上做,板子只负责运行时的加载和推理。
2.2 板端 runtime 的部署
板端需要准备的文件主要是这几个:
# lite 版本 Python 接口(rknnlite) /usr/local/lib/python3.10/dist-packages/rknnlite/ # C 动态库 /usr/lib/librknnrt.so # 权限配置(关键) sudo usermod -aG devices $USER第三个权限配置很容易被忽略。RK3588 的 NPU 设备节点是/dev/dri/renderD128,如果当前用户不在devices或video组里,加载 RKNN 模型时会报Permission denied或者Failed to open device。这个问题在官方文档里提了一嘴,但排版不显眼,很多人(包括我)第一次跑都在这里卡了十几分钟。
验证环境是否正常的简单方法是跑一下板端的 rknnlite 自带的 demo:
cd /usr/local/lib/python3.10/dist-packages/rknnlite/examples python3 test_rknnlite.py能顺利打印出 inference 结果,说明环境没问题,接下来可以进入模型转换环节。
3. PyTorch 到 ONNX 导出:这一步决定了后面所有坑的数量
3.1 RetinaFace 模型的网络结构关键点
RetinaFace 的 PyTorch 实现五花八门,GitHub 上有好几个版本,但核心结构是一致的:MobileNet 或 ResNet 做 backbone,FPN 做多尺度特征融合,输出三个分支——分类分支(是否为人脸)、边框回归分支(人脸框)、关键点回归分支(5 个关键点坐标)。
以我用的 MobileNet0.25 版本为例,模型输出的 tensor 结构如下:
bbox_regressions:shape 为[batch, 4, 锚点总数],对应人脸框的四个偏移量;classifications:shape 为[batch, 2, 锚点总数],人脸的分类得分;landmarks:shape 为[batch, 10, 锚点总数],5 个关键点的偏移量。
导出 ONNX 的时候,大部分人直接torch.onnx.export一把梭,结果后面 RKNN 转换时各种报错。这里的关键在于RetinaFace 的后处理(解码、NMS)不要导出到 ONNX 图里。也就是说,ONNX 图只保留 backbone + FPN + 预测头的推理部分,输出就是上面三个 tensor,解码和 NMS 放到板端的后处理代码里做。
为什么?因为 RKNN 对 NMS 这类动态形状操作的兼容性很差,尤其是不定长输出的算子,转换时容易报错或者被量化毁掉。而且把 NMS 放板端做还有一个好处:你可以灵活调整 NMS 的阈值参数,不用重新转换模型。
3.2 导出代码的注意点
这是我实际使用的导出脚本的核心片段:
import torch from models.retinaface import RetinaFace model = RetinaFace(cfg_mnet) model.load_state_dict(torch.load('weights/mobilenet0.25_Final.pth', map_location='cpu')) model.eval() # 关键:把模型所有参数和 buffer 都转成 float32 model = model.float() # 固定输入尺寸 input_shape = (1, 3, 640, 640) dummy_input = torch.randn(input_shape) # 动态轴设置:只允许 batch 维动态 dynamic_axes = { 'input': {0: 'batch'}, 'bbox': {0: 'batch'}, 'cls': {0: 'batch'}, 'landmark': {0: 'batch'} } torch.onnx.export( model, dummy_input, 'retinaface_mnet.onnx', input_names=['input'], output_names=['bbox', 'cls', 'landmark'], dynamic_axes=dynamic_axes, opset_version=11, do_constant_folding=True )几个关键点:
- opset_version 用 11。RKNN-Toolkit2 2.x 对 opset 11 支持最稳,opset 13+ 有时候会遇到某些算子不兼容的情况。
- 输入尺寸固定为 640×640。RetinaFace 原始训练的时候输入是 640×640,虽然有些实现支持 1024,但 RKNN 量化时固定尺寸能省很多麻烦。
model.float()一定要加。PyTorch 1.8 时代很多预训练权重是 float32,但如果你用的是混合精度训练的权重,没有转 float32 导出,RKNN 转换时会出现诡异的数值错误。
3.3 导出后必须做的一步:ONNX 验证
导出后别急着转 RKNN,先用onnxruntime验证 ONNX 的输出和 PyTorch 原始输出是否一致:
import onnxruntime as ort import numpy as np ort_session = ort.InferenceSession('retinaface_mnet.onnx') input_tensor = np.random.randn(1, 3, 640, 640).astype(np.float32) outputs = ort_session.run(None, {'input': input_tensor}) # 加载 PyTorch 模型做同样输入的推理 with torch.no_grad(): torch_outputs = model(torch.from_numpy(input_tensor)) # 对比输出差异 for i in range(3): diff = np.abs(outputs[i] - torch_outputs[i].numpy()).max() print(f'Output {i} max diff: {diff}')如果最大差异在1e-5量级,说明导出成功;如果差异很大,优先检查有没有遗漏eval()模式设置——Dropout 和 BatchNorm 在 train/eval 模式下行为不同,这是导出后数值不一致最常见的原因。
很多人在这一步图省事跳过验证,直接转到 RKNN,等到板子上推理结果全乱才回来排查。这个环节花五分钟,能省掉后面数小时的排查时间。
4. RKNN 模型转换与量化:参数反复调优的过程
4.1 从 ONNX 到 RKNN 的基本转换流程
先看一个基础的转换脚本结构,然后我再解释每个参数的实际意义:
from rknn.api import RKNN rknn = RKNN(verbose=True) # 配置阶段(核心) rknn.config( mean_values=[[104, 117, 123]], std_values=[[1, 1, 1]], target_platform='rk3588', quantized_dtype='w8a8', quantized_algorithm='normal', optimization_level=3 ) # 加载 ONNX 模型 ret = rknn.load_onnx(model='retinaface_mnet.onnx') assert ret == 0, "load onnx failed" # 构建 RKNN 模型(这个阶段会自动做量化校准) ret = rknn.build(do_quantization=True, dataset='dataset.txt') assert ret == 0, "build failed" # 导出 RKNN 文件 ret = rknn.export_rknn('retinaface_mnet.rknn') assert ret == 0, "export failed"4.2 mean_values 和 std_values 的误区
这里有一个绝大多数教程都没讲透的细节:mean_values和std_values的设置必须和模型训练时的预处理方式一致,而不是和模型推理时的预处理一致。
RetinaFace 的 PyTorch 代码做推理时,通常是这样预处理图片的:
img = cv2.imread('face.jpg') img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # BGR 转 RGB img = np.float32(img) img -= (104, 117, 123) # 像素级减均值也就是说,模型输入是 RGB 顺序、减了均值(104, 117, 123)、没有除以标准差。对应的 RKNN config 就是:
rknn.config( mean_values=[[104, 117, 123]], std_values=[[1, 1, 1]] )如果你的训练代码用了transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),那对应到 RKNN 里就是:
# 注意:这里的顺序是 BGR,因为 RKNN 默认输入是 BGR 格式 mean_values=[[255*0.485, 255*0.456, 255*0.406]] # 约 [123.675, 116.28, 103.53] std_values=[[255*0.229, 255*0.224, 255*0.225]] # 约 [58.395, 57.12, 57.375]而 “输入格式究竟是 RGB 还是 BGR” 这个问题,在 RKNN 里由reorder_channel参数控制,默认情况下 RKNN 的输入通道顺序是RGB。RetinaFace 训练时用的是 RGB,所以保持默认就行。
这个点如果搞错,最典型的表现是:模型转换成功、推理也能跑,但检测结果一片随机,或者框的位置完全不对。这不是模型问题,是输入数据分布和模型期望不匹配。
4.3 量化校准数据集的设计
很多教程用的是随机噪声做量化校准(dataset.txt 里指向几张随机图片),这种做法对 RetinaFace 这种检测模型来说很危险。
量化校准的本质是统计每一层激活值的分布,然后确定 int8 量化的 scale 和 zero_point。如果你拿随机噪声去校准,统计出来的激活值分布和真实人脸图片差异巨大,量化后的模型在真实场景的掉点会非常严重。
我的做法是从实际测试数据里抽出 50~100 张人脸图片,最好覆盖不同光照、不同尺度、不同肤色的人脸,放到一个文件夹里,生成 dataset.txt:
images/face_001.jpg images/face_002.jpg ... images/face_100.jpg然后 build 的时候指定这个文件:
ret = rknn.build(do_quantization=True, dataset='dataset.txt')这里还有一个坑:dataset.txt 里的图片路径必须是绝对路径或者相对当前工作目录的路径,而且图片不能太大。RKNN-Toolkit2 会先做一次预处理(resize 到模型输入尺寸),如果你的图片本身就是 640×640 的图,那在 dataset.txt 里直接写路径即可;如果图片是 1920×1080 这种大图,建议先离线批量把图片 resize 好,避免转换过程内存溢出。
4.4 量化算法与精度调优的实际体验
RKNN-Toolkit2 2.x 提供了两种量化算法:normal和mmse。我一开始用默认的normal,转换很顺利,但量化后在板子上跑,检测率明显下降——原本能检出的人脸,检不出来了。
后来换成了mmse算法:
rknn.config( quantized_dtype='w8a8', quantized_algorithm='mmse', )检测效果有明显改善。但 mmse 的缺点是转换时间变长,量化校准阶段大概多了 3~5 分钟。如果你的模型对精度要求高、推理速度要求没那么极限,优先用 mmse。
另一个调优技巧:如果你发现总体精度可以,但小尺寸人脸检测率下降,可以尝试把输入分辨率从 640×640 提到 960×960 或者 1280×1280。但这样推理速度会明显变慢,NPU 的利用率会上升。RetinaFace 本身对小脸的支持就一般,提升输入分辨率比调量化参数更有效。
4.5 量化前先做精度对比实验
这里分享一个我总结出的高效工作流:先用 float16 跑通全流程,再转 int8 做精度对比。
具体操作是:
rknn.config( quantized_dtype='fp16', # 先不量化 )RK3588 的 NPU 支持 fp16 推理,虽然速度比 int8 慢,但精度几乎无损。把 fp16 版本的模型在板子上跑通了,验证整个链路没有问题,再切回w8a8做 int8 量化。如果 int8 模型精度掉得多,你就有明确的对比对象,可以判断是量化的问题还是前面导出/预处理的问题。
这个思路特别适合第一次做 RKNN 部署的人——先保证链路通,再优化精度和速度。一上来就 int8 量化,踩到坑的时候变量太多,很难定位到底哪一步出了问题。
5. 板端 C++ / Python 推理实现:解码细节决定成败
5.1 rknnlite Python 接口的推理流程
RKNN-Toolkit2 2.x 提供了板端 Python 接口rknnlite,处理流程很清晰,核心代码就这几步:
import cv2 import numpy as np from rknnlite.api import RKNNLite # 初始化 rknn_lite = RKNNLite() ret = rknn_lite.load_rknn('retinaface_mnet.rknn') assert ret == 0, "load rknn failed" # 初始化运行时(core_mask 决定用哪个 NPU 核心) ret = rknn_lite.init_runtime(core_mask=RKNNLite.NPU_CORE_AUTO) assert ret == 0, "init runtime failed" # 推理 img = cv2.imread('test.jpg') # Resize 到模型输入尺寸 img_resized = cv2.resize(img, (640, 640)) # 注意:这里不需要手动做均值减除,RKNN 内部会根据 config 自动处理 outputs = rknn_lite.inference(inputs=[img_resized])这里要特别强调:rknnlite 的 inference 接口接收的是原始图像(0-255),mean_values 和 std_values 已经在转换阶段写入了 RKNN 模型文件,所以板端代码不需要再做减均值操作。如果你在板端又减了一遍均值,结果会完全错乱。
5.2 RetinaFace 解码:锚点偏移量要换算成真实坐标
RKNN 模型的输出是原始预测头的结果,不是最终的人脸框坐标。板端拿到输出后,必须做解码。RetinaFace 的锚点(anchor)是在训练阶段根据特征图尺度生成的,解码公式如下:
def decode_boxes(bbox_regressions, anchors, img_h, img_w): # bbox_regressions shape: [1, 4, num_anchors] # anchors: 预先生成的锚点,shape [num_anchors, 4],格式为 [x1, y1, x2, y2](相对于原图坐标) bbox_regressions = bbox_regressions.transpose((0, 2, 1)) # [1, num_anchors, 4] anchors = anchors.unsqueeze(0) # [1, num_anchors, 4] # 锚点的中心坐标和宽高 anchors_wh = anchors[:, :, 2:] - anchors[:, :, :2] anchors_xy = anchors[:, :, :2] + anchors_wh / 2 # 解码:先乘 variance,再转成中心坐标和宽高 bbox_xy = bbox_regressions[..., :2] * 0.1 * anchors_wh + anchors_xy bbox_wh = torch.exp(bbox_regressions[..., 2:]) * anchors_wh # 转回左上角和右下角 x1 = (bbox_xy[..., 0] - bbox_wh[..., 0] / 2).clamp(0, img_w) y1 = (bbox_xy[..., 1] - bbox_wh[..., 1] / 2).clamp(0, img_h) x2 = (bbox_xy[..., 0] + bbox_wh[..., 0] / 2).clamp(0, img_w) y2 = (bbox_xy[..., 1] + bbox_wh[..., 1] / 2).clamp(0, img_h) return torch.stack([x1, y1, x2, y2], dim=-1)这里最关键的是variance 的数值(0.1 和 0.2)必须和训练时一致。不同版本的 RetinaFace 实现,variance 可能不同。我用的这个版本,边框回归的 variance 是 0.1,关键点回归的 variance 是 0.2。如果你从 GitHub 拉的是另一个版本,务必去训练代码里确认这两个数值。
关键点的解码类似:
def decode_landmarks(landmark_regressions, anchors, img_h, img_w): # landmark_regressions shape: [1, 10, num_anchors],10 = 5 个点 × 2 个坐标 landmark_regressions = landmark_regressions.transpose((0, 2, 1)) # [1, num_anchors, 10] anchors_wh = anchors[:, :, 2:] - anchors[:, :, :2] anchors_xy = anchors[:, :, :2] + anchors_wh / 2 # 每个关键点的偏移量(x, y)都是相对于锚点中心 lm_xy = landmark_regressions.reshape(-1, 5, 2) lm_xy[..., 0] = lm_xy[..., 0] * 0.2 * anchors_wh[..., 0].unsqueeze(-1) + anchors_xy[..., 0].unsqueeze(-1) lm_xy[..., 1] = lm_xy[..., 1] * 0.2 * anchors_wh[..., 1].unsqueeze(-1) + anchors_xy[..., 1].unsqueeze(-1) return lm_xy5.3 NMS 的效率优化
解码出大量候选框后,要用 NMS 去除重复框。RetinaFace 默认生成的锚点数量很多(640×640 输入时约 17 万个锚点),如果不做筛选直接 NMS,CPU 扛不住。
我采用的策略是三层过滤:
- 先按置信度阈值粗筛:只保留 score > 0.5 的候选框,这一步能筛掉 95% 以上的锚点;
- 按类别得分排序,取 top-K:在粗筛后的框里按得分从高到低排序,只取前 5000 个;
- 最后做标准的 NMS:IoU 阈值设为 0.4。
具体代码片段:
def filter_and_nms(bboxes, landmarks, scores, score_threshold=0.5, top_k=5000, nms_threshold=0.4): # 1. 置信度粗筛 keep = scores > score_threshold if keep.sum() == 0: return np.zeros((0, 4)), np.zeros((0, 5, 2)) bboxes = bboxes[keep] landmarks = landmarks[keep] scores = scores[keep] # 2. 取 top_k order = scores.argsort()[::-1][:top_k] bboxes = bboxes[order] landmarks = landmarks[order] scores = scores[order] # 3. NMS keep_indices = nms(bboxes, scores, nms_threshold) return bboxes[keep_indices], landmarks[keep_indices], scores[keep_indices]NMS 的实现可以用torchvision.ops.nms(板端装了 torch 的话),也可以用 OpenCV 的cv2.dnn.NMSBoxes。实测下来,对 5000 个候选框做 NMS,用 OpenCV 比用 torch 快 2~3 倍。如果对延迟特别敏感,可以手写一个纯 numpy 的 NMS,但代码量会多一些。
5.4 输入图像的预处理链路
在实际应用中,摄像头的图像可能是 1080p 甚至 4K,直接 resize 到 640×640 会丢信息,要先用检测的方式裁剪出人脸区域再送进去?不对——RetinaFace 本身就是检测模型,它的输入就是整张图的缩放。
我踩过的一个坑是:送进 RKNN 的图像必须是 BGR 顺序的连续内存。cv2.imread读出来的是 BGR,直接送没问题;但如果你用了 PIL 读图(PIL 是 RGB 顺序),或者图像经过了某些 numpy 操作导致内存不连续,NPU 推理会出现“偶发性错误结果”。
常见的做法是强制转成 BGR 连续数组:
img = cv2.imread('test.jpg') # BGR, HWC if img is None: raise ValueError("read image failed") # 确保内存连续 img = np.ascontiguousarray(img) img_resized = cv2.resize(img, (640, 640))另外一个容易忽略的点:如果摄像头输出的是 RGBA 或者 YUV,必须先转成 BGR。RKNN 模型内建的预处理只管 mean/std 减去和通道缩放,不管色彩空间转换。
6. 实测数据与性能调优:量化后的提升有多大
6.1 各推理后端的速度对比
为了验证 NPU 加速的实际效果,我在同一块 RK3588 上对三个版本的推理时间做了对比测试:
| 推理方式 | 单帧耗时(640×640 输入) | 备注 |
|---|---|---|
| CPU + ONNX Runtime | 112ms | 使用 rk3588 的 4 个大核 A76 |
| CPU + RKNN(NPU 不可用时的 fallback) | 87ms | RKNN 在 CPU 上模拟运行 |
| NPU + RKNN(fp16 量化) | 22ms | 精度无损失 |
| NPU + RKNN(int8 量化,normal 算法) | 12ms | 精度有下降 |
| NPU + RKNN(int8 量化,mmse 算法) | 14ms | 精度接近 fp16 |
从数据可以直观看到:NPU 推理比 CPU 快了 5~8 倍。int8 比 fp16 又快了接近一倍。如果你的应用对精度要求没那么苛刻(比如做门禁闸机,人脸近距离通过,检测难度低),int8 + mmse 是性价比最高的组合。
6.2 三核 NPU 的使用策略
RK3588 的 NPU 有三个核心(Core0/1/2),core_mask参数决定推理时使用哪个核心:
rknn_lite.init_runtime(core_mask=RKNNLite.NPU_CORE_0) rknn_lite.init_runtime(core_mask=RKNNLite.NPU_CORE_1) rknn_lite.init_runtime(core_mask=RKNNLite.NPU_CORE_2) rknn_lite.init_runtime(core_mask=RKNNLite.NPU_CORE_0_1) rknn_lite.init_runtime(core_mask=RKNNLite.NPU_CORE_0_1_2) rknn_lite.init_runtime(core_mask=RKNNLite.NPU_CORE_AUTO)我测试下来有两个实用的经验:
单模型推理用
NPU_CORE_AUTO经常不如手动指定NPU_CORE_0。AUTO 模式下系统会自动调度,但偶尔会出现核心分配不均的情况,导致推理时间抖动。手动绑定单个核心后,推理时间更稳定。如果做多路视频流,可以在同一个进程里初始化多个
RKNNLite实例,分别绑定不同核心。比如两路 1080p 摄像头,分别用 core0 和 core1 跑人检测,互不干扰。实测两路同时推理,单路延迟仍然保持在 20ms 左右。
6.3 推理时间不稳定的排查方向
如果你的推理时间忽快忽慢,差距超过 5ms,大概率是这几个原因:
- CPU 频率调度策略:RK3588 的 A76 大核有省电调度,如果 NPU 推理需要 CPU 做预处理(resize、颜色转换),CPU 降频会拖慢整体延迟。解决办法是在启动脚本里设置性能模式:
echo performance > /sys/devices/system/cpu/cpu4/cpufreq/scaling_governor echo performance > /sys/devices/system/cpu/cpu6/cpufreq/scaling_governor内存带宽争抢:如果同一时间 NPU 在做推理、CPU 在读写大块图像、GPU 还在渲染界面,会争抢内存带宽。板子上的测试最好关掉桌面环境,通过 SSH 跑纯命令行程序。
NPU 频率:默认 NPU 频率可能是 700MHz 或 1GHz,在
/sys/kernel/debug/rknpu/下面可以查看和调整。不过一般情况下不需要动,默认频率的推理性能已经足够了。
6.4 端到端延迟的组成分析
很多人只看模型推理耗时,但实际落地时要算端到端延迟。我统计过完整的处理链:
| 环节 | 耗时(ms) |
|---|---|
| 图像采集(USB 摄像头 1080p) | 8~12 |
| resize 到 640×640 | 2~3 |
| 颜色转换(YUV→BGR,如果有) | 3~5 |
| NPU 推理 | 12~15 |
| 解码 + NMS | 4~6 |
| 可视化绘制(画框、关键点) | 1~2 |
| 总延迟 | 30~45 |
如果你的摄像头输出 YUV 格式,颜色转换的耗时不容忽视,可以用 RK3588 的 RGA 硬件加速做颜色转换和缩放,把 5ms 级别的 CPU 耗时降到 1ms 以内。RGA 的使用有专门的 librockchip_rga 库,接口类似 OpenCV,但硬件加速后 CPU 占用率几乎为零。
7. 精度验证方法:不要只看 mAP,要看真实场景
7.1 量化前后在同一批图片上的对比
模型转换完成后,不要只靠“看起来能检测到人脸”来判断。我从实际测试集里抽出 200 张人脸图片,分别统计 fp16 模型和 int8 模型的人脸检出率、误检率、关键点误差:
| 指标 | fp16 模型 | int8 模型(mmse) | int8 模型(normal) |
|---|---|---|---|
| 检出率(IoU>0.5) | 96.5% | 95.0% | 88.5% |
| 误检数(成绩图) | 3 | 4 | 12 |
| 关键点平均误差(像素) | 2.3 | 2.8 | 4.1 |
从这个数据可以清楚看到,用 normal 量化算法时,模型能力下降明显;换 mmse 后,精度几乎无损。所以如果你的业务对误检率敏感(比如闸机开门,误检会导致频繁误触发),量化算法一定要选 mmse。
关键点误差的统计方法是:人工标注每张图中两只眼睛、鼻尖、左右嘴角的位置,然后计算模型输出的 5 个关键点与标注坐标的欧氏距离平均值。这个指标对后续做人脸对齐(face alignment)特别重要,如果关键点误差超过 5 个像素,对齐后的人脸送到识别模型,识别率会明显下降。
7.2 单图调试脚本:快速定位问题
我写了一个单图调试脚本,专门用来快速排查“为什么检测不出来”的问题。它会把中间结果可视化保存下来,包括原始图、resize 后的图、解码后的候选框(NMS 前)、最终输出框。
这个脚本最大的价值在于:直接看到 NMS 前的候选框分布。如果 NMS 前根本没有高置信度的框,那就是模型或预处理问题;如果 NMS 前有框但都被过滤了,那就是阈值设置问题。
调试之后,一定要把 score_threshold 设为较低的值(比如 0.1)跑一次,看清楚模型在低阈值下的输出情况。有时候不是模型坏了,而是你设置的置信度阈值太高,把低置信度的检测结果全过滤掉了。
8. 完整代码整理:可直接落地的主流程实现
这里给出一个可以直接跑起来的主流程代码,集成了解码、NMS、可视化全链路。假设你已经有了retinaface_mnet.rknn模型文件。
import cv2 import numpy as np from rknnlite.api import RKNNLite class RetinaFaceRKNN: def __init__(self, model_path, input_size=(640, 640), score_threshold=0.5, nms_threshold=0.4): self.input_h, self.input_w = input_size self.score_threshold = score_threshold self.nms_threshold = nms_threshold # 初始化解码用的锚点 self.anchors = self.generate_anchors() # 初始化 RKNN self.rknn_lite = RKNNLite() ret = self.rknn_lite.load_rknn(model_path) assert ret == 0, "load rknn failed" ret = self.rknn_lite.init_runtime(core_mask=RKNNLite.NPU_CORE_0) assert ret == 0, "init runtime failed" def generate_anchors(self): # RetinaFace-MobileNet 的锚点配置 # FPN 输出三个尺度的特征图:stride = 8, 16, 32 # 每个特征图位置生成两个锚点,长宽比 1:1 和 1:1.5 anchors = [] for stride in [8, 16, 32]: feature_h = self.input_h // stride feature_w = self.input_w // stride for i in range(feature_h): for j in range(feature_w): cx = (j + 0.5) * stride cy = (i + 0.5) * stride for scale in [1.0, 1.5]: if stride == 8: size = 16 * scale elif stride == 16: size = 64 * scale else: size = 256 * scale h = k = size x1 = cx - k / 2 y1 = cy - h / 2 x2 = cx + k / 2 y2 = cy + h / 2 anchors.append([x1, y1, x2, y2]) return np.array(anchors, dtype=np.float32).reshape(-1, 4) def decode_outputs(self, outputs): bbox_regressions = outputs[0].reshape(4, -1) classifications = outputs[1].reshape(2, -1) landmark_regressions = outputs[2].reshape(10, -1) scores = classifications[1] # 解码边框 anchors_wh = self.anchors[:, 2:] - self.anchors[:, :2] anchors_xy = self.anchors[:, :2] + anchors_wh / 2 bbox_xy = bbox_regressions.T[:, :2] * 0.1 * anchors_wh + anchors_xy bbox_wh = np.exp(bbox_regressions.T[:, 2:]) * anchors_wh x1 = (bbox_xy[:, 0] - bbox_wh[:, 0] / 2).clip(0, self.input_w) y1 = (bbox_xy[:, 1] - bbox_wh[:, 1] / 2).clip(0, self.input_h) x2 = (bbox_xy[:, 0] + bbox_wh[:, 0] / 2).clip(0, self.input_w) y2 = (bbox_xy[:, 1] + bbox_wh[:, 1] / 2).clip(0, self.input_h) bboxes = np.stack([x1, y1, x2, y2], axis=-1) # 解码关键点 lm_xy = landmark_regressions.T.reshape(-1, 5, 2) for k in range(5): lm_xy[:, k, 0] = lm_xy[:, k, 0] * 0.2 * anchors_wh[:, 0] + anchors_xy[:, 0] lm_xy[:, k, 1] = lm_xy[:, k, 1] * 0.2 * anchors_wh[:, 1] + anchors_xy[:, 1] return bboxes, lm_xy, scores def nms(self, bboxes, scores): # 使用 OpenCV 的 NMS indices = cv2.dnn.NMSBoxes( bboxes.tolist(), scores.tolist(), self.score_threshold, self.nms_threshold ) if indices is None or len(indices) == 0: return np.array([]) return np.array(indices).flatten() def detect(self, img_bgr): """ 输入:BGR 格式的原始图像(任意尺寸) 输出:人脸框列表(N,4)、关键点列表(N,5,2)、置信度列表(N,) """ h, w = img_bgr.shape[:2] # resize 到模型输入尺寸 img_resized = cv2.resize(img_bgr, (self.input_w, self.input_h)) img_resized = np.ascontiguousarray(img_resized) # 推理 outputs = self.rknn_lite.inference(inputs=[img_resized]) # 解码 bboxes, landmarks, scores = self.decode_outputs(outputs) # 粗筛 + NMS keep = scores > self.score_threshold if keep.sum() == 0: return np.zeros((0, 4)), np.zeros((0, 5, 2)), np.zeros((0,)) bboxes = bboxes[keep] landmarks = landmarks[keep] scores = scores[keep] # 按得分排序取 top5000 order = scores.argsort()[::-1][:5000] bboxes = bboxes[order] landmarks = landmarks[order] scores = scores[order] keep_indices = self.nms(bboxes, scores) if len(keep_indices) == 0: return np.zeros((0, 4)), np.zeros((0, 5, 2)), np.zeros((0,)) bboxes = bboxes[keep_indices] landmarks = landmarks[keep_indices] scores = scores[keep_indices] # 坐标映射回原图尺寸 bboxes[:, [0, 2]] *= (w / self.input_w) bboxes[:, [1, 3]] *= (h / self.input_h) landmarks[:, :, 0] *= (w / self.input_w) landmarks[:, :, 1] *= (h / self.input_h) return bboxes, landmarks, scores # 使用示例 if __name__ == '__main__': detector = RetinaFaceRKNN('retinaface_mnet.rknn') cap = cv2.VideoCapture(0) while True: ret, frame = cap.read() if not ret: break bboxes, landmarks, scores = detector.detect(frame) # 绘制结果 for i, box in enumerate(bboxes): x1, y1, x2, y2 = box.astype(int) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) # 绘制关键点 for k in range(5): lx, ly = landmarks[i][k].astype(int) cv2.circle(frame, (lx, ly), 2, (0, 0, 255), -1) cv2.putText(frame, f"{scores[i]:.2f}", (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0), 2) cv2.imshow("RetinaFace RK3588", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()这个类把解码、NMS、坐标映射都封装好了,替换模型文件路径就可以直接用。
9. 踩坑日志:整理了五个高频问题的排查思路
9.1 模型转换成功,但板端推理结果全为 0 或全为 1
这个现象的根因绝大多数情况下只有一个:输入数据的预处理方式和量化校准不匹配。排查路径如下:
- 先确认板端送进去的图像是不是 0-255 范围内的 RGB/BGR 三通道数据;
- 确认没有在板端重复做减均值操作;
- 确认模型 config 里的 mean_values 和训练时一致;
- 用一张训练集里验证过的图片做单图测试,如果仍然全 0,逐个排查。
第二个隐蔽原因是anchor 生成逻辑和模型训练时不一致。RetinaFace 的 anchor 配置在不同实现里差异很大,我见过 anchored scale 定义不同的版本,解码出来的框完全对不上。
9.2 检测框位置偏了(框在脸上但明显偏移)
如果你检测到的人脸框位置整体偏移,比如框比人脸大一圈、偏左偏上,很可能是坐标映射回原图时没有考虑 letterbox 的填充边界。
我在 8 节代码里用的是直接 resize,也就是把原图拉长/压扁到 640×640,这样等比映射即可,不需要 letterbox。但很多人会先 letterbox 再送模型,此时映射回原图要注意减去 padding 偏移:
scale = min(self.input_w / w, self.input_h / h) new_w, new_h = int(w * scale), int(h * scale) pad_x = (self.input_w - new_w) // 2 pad_y = (self.input_h - new_h) // 2 # 解码后的坐标映射 bboxes[:, [0, 2]] = (bboxes[:, [0, 2]] - pad_x) / scale bboxes[:, [1, 3]] = (bboxes[:, [1, 3]] - pad_y) / scale如果使用了 letterbox 但映射时没做还原,人脸框的位置偏差会随着目标越靠近边缘越严重。
9.3 小尺寸人脸检测不到
RetinaFace 对小脸的支持本身就不是强项,尤其当输入缩放到 640×640 后,原图上 30×30 像素以下的人脸基本只剩一个点。
解决思路有三个:
- 提高输入分辨率到 960×960 或 1280×1280——最简单有效,但推理速度会下降;
- 把图像切成多个 patch,分别推理——适合离线处理,实时性不够;
- 针对小脸场景增加一层额外的浅层特征图——需要重新训练,成本高。
对实时场景,我推荐前两种结合:如果检测不到人脸,再把图像放大之后二次检测,只有确定有人脸的情况下才放大。这个策略在门禁场景实测有效,可以把漏检率降低一半以上。
9.4 模型加载成功但推理偶发崩溃
这个现象我在换了一批图片测试时遇到过:推理到第 37 张图时程序直接 segfault 退出。
排查后发现问题出在输入图片尺寸不一致。RKNN 模型在转换时输入尺寸是固定的 640×640,如果你传入的图片不是这个尺寸,某些版本的 rknnlite 不会报错,而是直接越界访问,导致崩溃。
解决方案是统一在预处理时强制 resize 到 640×640,并确保np.ascontiguousarray。另外,避免在推理过程中临时改变图像通道数,比如从三通道变成四通道,也可能触发异常。
9.5 进程长时间运行后内存不断上涨
rknnlite 的 Python 接口有一个小问题:反复调用inference时,如果输入 numpy 数组的 shape 不固定,可能会有少量内存漂移。
最稳妥的做法是:
- 把输入图像提前分配为固定大小的 numpy 数组;
- 每次推理前用
np.copyto把数据拷进去,而不是重新cv2.resize生成新数组; - 用内存统计工具
memstat实时观察,确认内存曲线稳定。
10. 部署后的进一步优化空间
如果你跑通了上述流程,并且有进一步优化的需求,可以考虑下面几个方向:
1. 多模型流水线。RK3588 的 NPU 三核是可以并行跑不同模型的,比如一个核心跑 RetinaFace 做检测,另一个核心同时跑人脸识别网络。这需要你对core_mask做精细分配,并且注意内存带宽的分配。
2. 批量推理(batch)。如果你的业务场景需要同时处理多路视频流,可以在每个时间片内把多帧图像拼成一个 batch 送进 NPU。实测 batch 推理的吞吐量比单帧逐次推理高 30%~50%,但单帧延迟会略有增加。
3. 结合 RGA 硬件加速。把 resize、颜色转换、旋转等图像处理操作从 CPU 搬到 RGA 后,整条链路的 CPU 占用率可以降一半以上,这对做多路视频服务器特别有用。
4. 自动曝光和自动白平衡的参数调优。如果你用 USB 摄像头,相机默认的 AE/AWB 算法在逆光、暗光场景会产生质量很差的图像。通过 V4L2 接口手动调低曝光时间、固定白平衡增益,通常能让人脸检出率提升不少。这是很多算法项目容易忽略的数据质量因素。
关于这个项目,我在实际应用中最深的感受是:RKNN 的坑是可以系统性规避的,转换报错反而是最好解决的问题——因为报错信息通常会提示是哪个算子不支持。真正难的是那些“转换成功、推理结果错得离谱”的隐性坑位:输入预处理不一致、量化算法选择不对、anchor 配置错位。这三个坑占了整个项目调优时间的七成以上。
建议第一次上手的朋友,严格按照“先 fp16 验证链路 → 再 int8 量化”的节奏来,不要一上来就追求极致的 int8 速度和精度。链路通了之后,再花时间做量化和性能调优,你的排查变量会少得多。如果你在 RK3588 上部署 RetinaFace 的过程中碰到了这篇文章没有覆盖的坑,欢迎沿着这个思路去拆解——大概率还是在“转换配置、输入预处理、解码参数”这三个环节里出了问题。