今年在一块香橙派5B(RK3588S)上做离线人脸识别门禁原型,要求不能上云、摄像头实时、成本可控。开始不少人第一反应是直接上YOLOv8,但把需求拆开才发现,人脸识别其实是两个活:先检测人脸在哪里,再识别这个人是谁。最后我选了RetinaFace做检测、MobileFaceNet做特征提取,也就是项目标题里的"rec",整条链路在RK3588的NPU上跑通了实时识别。这篇文章会把模型转换、RKNN部署、摄像头实时识别到性能调优的完整过程写清楚,代码直接抄,适合正在RK3588/RK3588S系列板卡上做人脸识别落地的朋友。
1. 为什么最终选了RetinaFace+rec,而不是一条YOLOv8走到底
1.1 人脸识别天生就是两段式结构
很多刚接触边缘AI的同事会问我,人脸识别能不能直接拿YOLOv8框出每个人,再裁剪一下当"识别"?这其实混淆了两个任务。检测任务回答的是"人脸的框和关键点在哪",识别任务回答的是"这张脸到底是谁"。YOLOv8做检测很擅长,但它不输出人脸5点关键点,也没有人脸特征嵌入能力。你虽然可以训练一个YOLOv8分类模型来区分张三李四,但那样每新增一个用户就要重新训练一次模型,完全不可维护。
所以正规做法是检测加识别两段式:检测模型负责在画面里找到人脸并给出5个关键点,再利用关键点把人脸对齐成标准角度,送入识别模型提取512维特征向量,最后拿特征向量和底库做相似度比对。这样新增用户只需要往底库里加一张注册照片即可,模型本身不用动。这也是RetinaFace加rec这套方案的根本逻辑。
1.2 RetinaFace在检测环节的实际优势
RetinaFace在检测圈子里不算新,但放在边缘部署场景依然很能打。它有几个点正好命中需求。
首先是它天然输出5点关键点,包括左眼、右眼、鼻尖、左嘴角、右嘴角。这5个点在做后续人脸对齐时是刚需。像YOLOv8如果要硬做人脸检测,你需要额外加关键点头,或者再训练一个关键点模型,工程复杂度直接翻倍。
其次是模型体积和算力友好。RetinaFace常见的MobileNet0.25 backbone,权重文件只有十几MB,INT8量化后更小。在RK3588的6TOPS NPU上,换算下来的负载并不高,可以做到一个NPU同时承载检测和识别两个模型。
RetinaFace的精度对于门禁、考勤、访客识别这类场景也够用。它基于WIDER FACE数据集训练,在小脸、遮挡、复杂光照下表现比很多YOLO轻量版本更稳。我用它处理室内监控画面,距离3到5米的人脸基本都能稳定检出。
1.3 rec在标题里的含义
标题里的"rec"我理解是recognizer,也就是识别器。检测只是把人脸框出来,要判断是谁,还得靠特征提取模型。这里我选的是MobileFaceNet,输出512维的人脸特征向量。MobileFaceNet本身在移动端人脸识别里跑得很成熟,配合ArcFace loss训练后,类内距离小、类间距离大,非常适合做底库比对。
有人会把rec理解成recognition,也有人把它理解成record,但在人脸识别项目里它指的就是"识别器"这个环节。这套组合本质上就是detector(RetinaFace)加recognizer(MobileFaceNet)的两段式架构。后续所有代码也都围绕这两块展开。
1.4 为什么这块板子跑得动
香橙派5B搭载RK3588S,CPU部分是4个Cortex-A76大核加4个Cortex-A55小核,NPU部分提供6TOPS算力。这个算力跑RetinaFace-MobileNet0.25加MobileFaceNet相当轻松。我实测两个模型连续推理,单帧总耗时可以控制在100毫秒以内,加上前后处理,实时摄像头识别完全没有压力。
它比我之前用树莓派4B加USB加速棒体验好太多。树莓派CPU推理RetinaFace基本要好几秒一帧,根本没法实时。有了NPU硬件加速,才真正做到"嵌入式设备上跑人脸识别"。
2. 环境准备:刷系统、装NPU运行库、接通调试链路
2.1 板卡与系统选择
我手上的板子是香橙派5B,8GB内存版本。RK3588S和RK3588在NPU能力上没有区别,都是6TOPS,区别主要是外围接口和PCIe通道。开发阶段建议用8GB以上内存版本,编译和跑多路摄像头时会从容一些。
系统方面,香橙派官方提供Ubuntu 22.04和Debian 12的镜像,我建议直接用官方Ubuntu 22.04桌面版或服务器版。社区里有人折腾移植Ubuntu 26之类的更新版本到RK3588上,这个东西精神可嘉,但生产项目别轻易追新。RKNN-Toolkit2、MPP、RGA这些Rockchip组件都针对特定的Ubuntu/Linux版本做适配,系统太新反而可能找不到匹配的库,浪费大量时间在环境修复上。先用官方稳定镜像,等整套流程跑通了再去折腾新版本也不迟。
烧录工具我用的是balenaEtcher,下载镜像后直接选择TF卡或SSD写入即可。注意RK3588系列支持从NVMe SSD启动,如果对磁盘性能有要求,推荐把系统装进NVMe,训练好的模型也放SSD上,加载速度比TF卡快不少。
2.2 NPU驱动与运行库确认
装完系统先确认NPU是否正常工作。板子上执行:
ls /dev/rknpu ldconfig -p | grep rknn如果能看到 /dev/rknpu 设备节点,且 ldd 输出里有 librknnrt.so,说明NPU驱动和运行库已经就位。香橙派官方镜像默认会带Rockchip的NPU驱动,不需要额外安装。如果没有,一般通过以下方式装:
sudo apt update sudo apt install librknnrt也可以直接从香橙派官方SDK或Rockchip的Release包中拷贝librknnrt.so到 /usr/lib/。这里特别提醒:PC端做模型转换的rknn-toolkit2版本和板端librknnrt版本必须对齐,比如在PC上用rknn-toolkit2 1.6.0转换出的rknn模型,板端librknnrt也需要是1.6.0对应的版本,否则会报参数无效或者推理结果异常。具体怎么排查我放在后面专门讲。
2.3 Python环境与常用依赖
RK3588板端推理有两条路:C/C++接口和Python接口。快速原型阶段我用Python,开发效率高,性能瓶颈主要在模型推理而非Python调用。板端Python推理库叫rknnlite,它是对librknnrt的轻量封装,去掉了模型转换功能,只保留推理能力。安装方式:
pip install rknn-toolkit-lite2同时安装常用的图像和数值库:
pip install opencv-python numpy如果是桌面版系统,直接用板子上的显示器操作;如果是服务器版,就用SSH远程连上去。注意OpenCV在ARM上默认不带GUI功能,如果需要在板子上直接显示画面,安装的是opencv-python的话可能要用cv2.imshow,它依赖GTK或者Qt。我实际用的方式是画面监控不走板端显示器,而是通过RTSP推流或直接输出到网络,具体在后面的扩展部分说。
2.4 摄像头与ADB调试链路搭建
人脸识别系统离不开摄像头。RK3588支持USB摄像头和MIPI CSI摄像头。MIPI摄像头画质好、延迟低,但前期调试麻烦,不同摄像头模组可能需要改设备树。我建议第一版直接用USB摄像头,插上就能用,省掉设备树适配的坑。
先确认摄像头节点:
ls /dev/video* v4l2-ctl --list-devices然后写个简单脚本测试OpenCV能否正常读取:
import cv2 cap = cv2.VideoCapture(0) if not cap.isOpened(): print("camera open failed") exit(1) ret, frame = cap.read() print(frame.shape) cap.release()这里有个常见的坑:有些USB摄像头默认输出YUYV格式,OpenCV能读但有点卡;有些摄像头支持MJPEG格式。可以在打开后设置:
cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc('M', 'J', 'P', 'G')) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) cap.set(cv2.CAP_PROP_FPS, 30)调完格式之后帧率会明显改善。
至于ADB连接,香橙派5B支持通过Type-C口接电脑调试。先在板子上开ADB服务:
sudo adb kill-server sudo adb start-server电脑端执行 adb devices 就能看到板子。开发阶段我习惯用ADB的push和pull命令把代码、模型文件直接推进板子,省得来回拔TF卡:
adb push retinaface.rknn /home/orangepi/face_system/ adb shell如果后续要做嵌入式量产,这套全流程还可以用网线SSH加ADB双通道。总之先把调试链路打开,后面迭代模型和代码效率会高很多。
3. RetinaFace检测模型:从PyTorch权重到RKNN转换的每一步
3.1 模型获取与ONNX导出的注意点
RetinaFace的开源实现很多,我实际用的是Pytorch_Retinaface仓库里的MobileNet0.25版本,权重文件是mobilenet0.25_Final.pth。这个模型在WIDER FACE上有不错的表现,也是社区里部署得最多的版本之一。
拿到PyTorch权重后,第一步是导出ONNX。导出时注意几个细节:
- 设置opset为11或12,RKNN-Toolkit2对这两个版本支持最稳。
- 导出时输入尺寸固定为[1, 3, 640, 640],不要动态维度。RK3588的NPU跑动态输入会降低效率,固定输入尺寸也方便后续量化和验证。
- 模型尾部自带的NMS和decode逻辑一定要去掉。这些后处理留在板端CPU上做,灵活性和可控性更强,也方便问题排查。
参考导出代码大致如下:
import torch from models.retinaface import RetinaFace model = RetinaFace(cfg='mnet') checkpoint = torch.load('mobilenet0.25_Final.pth', map_location='cpu') model.load_state_dict(checkpoint['state_dict']) model.eval() example = torch.randn(1, 3, 640, 640) torch.onnx.export( model, example, 'retinaface_mobile0.25.onnx', input_names=['input'], output_names=['loc', 'conf', 'landmarks'], opset_version=11, dynamic_axes=None )导出后可以用onnxruntime在PC上先验证一遍输出维度,确保模型结构正确。RetinaFace MobileNet0.25在640×640输入下,输出三个分支,loc的形状是[1, 16800, 4],conf是[1, 16800, 2],landmarks是[1, 16800, 10]。记住这些数字,后面后处理要严格对应。
3.2 RKNN转换配置与量化细节
PC端安装rknn-toolkit2后,转换脚本的核心逻辑如下:
from rknn.api import RKNN rknn = RKNN() rknn.config( mean_values=[[104, 117, 123]], std_values=[[1, 1, 1]], target_platform='rk3588' ) ret = rknn.load_onnx(model='retinaface_mobile0.25.onnx') if ret != 0: print('load onnx failed') exit(-1) ret = rknn.build(do_quantization=True, dataset='dataset.txt') if ret != 0: print('build failed') exit(-1) rknn.export_rknn('retinaface_mobile0.25.rknn') rknn.release()这里有三个地方特别容易踩坑。
第一个是mean/std的取值。这个值必须和模型训练时的预处理保持一致。Pytorch_Retinaface的预处理逻辑是图像转为RGB后,每个像素减掉[104, 117, 123]。所以mean_values设为[[104, 117, 123]],std_values保持[[1, 1, 1]]。有人直接用ImageNet的[0.485, 0.456, 0.406]归一化参数,结果检测框全部漂移,因为训练时根本不是那么处理的。
第二个是量化数据集。do_quantization=True时,RKNN需要用真实图片做校准,dataset.txt里每行写一张图片的路径,建议从真实摄像头场景里截几十张图,覆盖顺光、逆光、不同距离的情况。量化集太单一,模型在光线复杂环境下检测框会抖动。量化集不一定要多,50到100张就够,但一定要代表实际场景。
第三个是输入数据布局。RetinaFace的ONNX输入默认是NCHW格式,RKNN转换时会自动处理,板端推理时输入也要保持NCHW的float32数组,千万不要按NHWC直接塞进去。
转换完成后,在PC端可以先做一下仿真推理,确认输出维度和数值范围正常。但仿真结果只能参考,最终必须上板验证。NPU的实际推理结果和仿真在低比特量化下会有细微差异,直接以板端为准。
3.3 板端推理代码
板端推理用rknnlite非常简洁:
import cv2 import numpy as np from rknnlite.api import RKNNLite rknn_lite = RKNNLite() rknn_lite.load_rknn('retinaface_mobile0.25.rknn') rknn_lite.init_runtime() img = cv2.imread('test.jpg') # BGR img_resized = cv2.resize(img, (640, 640)) img_rgb = cv2.cvtColor(img_resized, cv2.COLOR_BGR2RGB).astype(np.float32) img_input = img_rgb - np.array([104, 117, 123], dtype=np.float32) img_input = img_input[None, :, :, :].transpose(0, 3, 1, 2) # NCHW outputs = rknn_lite.inference(inputs=[img_input]) loc, conf, landmarks = outputs注意这里先resize再减均值,顺序不要错。如果输入是BGR直接减了RGB均值,检测效果会大打折扣。我曾经在这个细节上排查了一个下午,最后对比模型源码才发现预处理顺序的问题。
3.4 后处理:anchor解码与NMS
RetinaFace的后处理包含anchor生成、框解码、置信度过滤、NMS四个步骤。源码里anchor生成是基于图像的3个stride:8、16、32,每个像素点生成两个不同宽高比的anchor。640×640输入下,总共anchor数量是16800,正好对应输出第一维。
decode框的简化逻辑如下:
def decode_bbox(loc, anchors): bbox = np.zeros_like(loc) bbox[:, 0] = anchors[:, 0] + loc[:, 0] * 0.1 * anchors[:, 2] bbox[:, 1] = anchors[:, 1] + loc[:, 1] * 0.1 * anchors[:, 3] bbox[:, 2] = anchors[:, 2] * np.exp(loc[:, 2] * 0.2) bbox[:, 3] = anchors[:, 3] * np.exp(loc[:, 3] * 0.2) return bboxlandmarks的decode也是类似,基于anchor中心点加上偏移乘anchor宽高。全部解码后,按conf中属于人脸的置信度过滤,通常阈值设在0.5到0.6之间,再做NMS。门禁场景为了减少漏检,我会把阈值放到0.4左右,多检几个框也不影响后续识别,反正识别环节会再做一次特征相似度过滤。
NMS直接用OpenCV的cv2.dnn.NMSBoxes即可,不用自己手写。如果觉得NMS在小目标上失效,可以放宽IoU阈值到0.4,避免两个人脸距离太近时叠加框。
4. rec识别链路:关键点对齐、特征提取、底库比对
4.1 对齐这件事有多重要
人脸识别系统能不能准确判断身份,很大程度上取决于对齐做得好不好。同一个人的脸,正面看和侧面30度看,像素差异巨大。如果直接裁剪检测框送入识别模型,识别率会明显下降。
RetinaFace输出的5个关键点,正好用来做仿射变换。具体做法是:把检测到的5个关键点,映射到一张112×112标准人脸图的固定位置。这些标准位置来自人脸识别数据集的统计平均值,比如左眼在(38.29, 51.69),右眼在(73.53, 51.50)附近,鼻尖、嘴角也有对应的标准坐标。
对齐代码核心如下:
def align_face(img, keypoints): template = np.array([ [38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041] ], dtype=np.float32) keypoints = keypoints.reshape(5, 2).astype(np.float32) mat = cv2.estimateAffinePartial2D(keypoints, template)[0] aligned = cv2.warpAffine(img, mat, (112, 112)) return aligned注意template坐标的横纵顺序是(x, y),而keypoints从模型输出中也要按相同顺序取。如果错位,对齐后人脸是歪的,识别率直接崩。
4.2 MobileFaceNet特征提取器
识别模型我选了MobileFaceNet,输出512维人脸特征。这个模型在移动端人脸识别任务中非常成熟,和ArcFace搭配训练后,类间距离拉得比较开。InsightFace项目提供训练好的权重,导出ONNX后在RK3588上转成RKNN,整个过程和RetinaFace的转换类似,只是输入尺寸是112×112,预处理用的是ImageNet的归一化参数。
MobileFaceNet的推理代码:
class FaceRecognizer: def __init__(self, rknn_path): self.rknn = RKNNLite() self.rknn.load_rknn(rknn_path) self.rknn.init_runtime() def get_embedding(self, aligned_img): img = aligned_img.astype(np.float32) / 255.0 img = (img - np.array([0.5, 0.5, 0.5])) / np.array([0.5, 0.5, 0.5]) # 根据具体模型预处理调整 input = img[None, :, :, :].transpose(0, 3, 1, 2) feat = self.rknn.inference(inputs=[input])[0].flatten() norm = np.linalg.norm(feat, 2) + 1e-6 return feat / normModel输出向量要L2归一化,这样后续做余弦相似度就是简单的向量点积。归一化后所有特征向量都在高维球面上,比对效率很高。
4.3 人脸底库设计与比对
底库本质是一堆"名字 + 512维特征向量"的集合。我建议把特征存成npy文件或者pickle,同时保留一份原始注册照片用于后续可视化。
底库管理类设计:
class FaceDatabase: def __init__(self, path='face_db.npy', names='face_names.npy'): self.embeddings = [] self.names = [] if os.path.exists(path): self.embeddings = np.load(path).tolist() self.names = np.load(names, allow_pickle=True).tolist() def add_face(self, name, embedding): self.names.append(name) self.embeddings.append(embedding) np.save('face_db.npy', np.array(self.embeddings)) np.save('face_names.npy', np.array(self.names)) def match(self, embedding, threshold=0.5): if not self.embeddings: return None, 0 matrix = np.array(self.embeddings) scores = np.dot(matrix, embedding) idx = int(np.argmax(scores)) if scores[idx] > threshold: return self.names[idx], float(scores[idx]) return None, float(scores[idx])注册新用户时,对一张人脸照片做RetinaFace检测、对齐、特征提取,然后调用add_face加入底库。整个过程不需要重训模型,非常灵活。
4.4 阈值怎么定才靠谱
阈值是影响误识率和拒识率的核心参数。阈值太高,相识的人会被拒绝;阈值太低,不同的人会被误识别。
我给出的经验值:MobileFaceNet在112×112输入、L2归一化后,同类人脸余弦相似度通常在0.6到0.8之间,不同人脸在0.1到0.4之间。门禁场景建议从0.5起步,在真实环境里用几十组正负样本调一下。追求安全就调高到0.6,追求通过率就放到0.4。考勤机场景我一般放0.45到0.5,兼顾通过率和安全性。
调试阈值时,建议在程序里打印出匹配分数,连续观察几天的数据分布,再决定最终阈值。不要拍脑袋定,也别只看实验室里的测试数据。
5. 主程序整合:摄像头实时识别完整代码
5.1 主流程设计
整个系统的主流程分成四步:读取帧、检测对齐、特征提取、底库匹配。实际代码里要考虑帧率控制和显示/推流解耦。
我采用的循环结构是:
- 打开摄像头,设置分辨率1280×720,帧率30。
- 每帧先送RetinaFace检测。
- 如果有检测框且置信度达标,就对框内人脸做对齐。
- 对齐后送入MobileFaceNet提取特征。
- 与底库匹配,如果分数超过阈值,在画面上绘制姓名和分数;否则标记为Unknown。
- 通过imshow或RTSP推流输出画面。
这里有个工程细节:检测模型和识别模型是同一个NPU,两个模型串行推理时会有切换开销。我建议一次model初始化时把两个rknn模型都load进来,运行时连续推理,避免重复加载模型。
5.2 完整代码示例
下面给一个可用度较高的主程序版本,方便直接在这个基础上改。
import cv2 import numpy as np from rknnlite.api import RKNNLite # 初始化两个模型 retina = RKNNLite() retina.load_rknn('retinaface_mobile0.25.rknn') retina.init_runtime() rec = RKNNLite() rec.load_rknn('mobilefacenet.rknn') rec.init_runtime() class FaceDB: def __init__(self): self.names = [] self.feats = [] def load(self, npy_path): data = np.load(npy_path, allow_pickle=True).item() self.names = data['names'] self.feats = data['feats'] def match(self, feat, threshold=0.5): if len(self.feats) == 0: return None, 0 feats = np.array(self.feats) scores = np.dot(feats, feat) idx = int(np.argmax(scores)) if scores[idx] >= threshold: return self.names[idx], float(scores[idx]) return None, float(scores[idx]) db = FaceDB() db.load('face_db.npy') def align_face(img, kps): template = np.array([ [38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041] ], dtype=np.float32) kps = kps.reshape(5, 2).astype(np.float32) mat = cv2.estimateAffinePartial2D(kps, template)[0] return cv2.warpAffine(img, mat, (112, 112)) cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc('M', 'J', 'P', 'G')) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) cap.set(cv2.CAP_PROP_FPS, 30) while True: ret, frame = cap.read() if not ret: continue img_resized = cv2.resize(frame, (640, 640)) img_rgb = cv2.cvtColor(img_resized, cv2.COLOR_BGR2RGB).astype(np.float32) img_input = img_rgb - np.array([104, 117, 123], dtype=np.float32) img_input = img_input[None].transpose(0, 3, 1, 2) loc, conf, landmarks = retina.inference(inputs=[img_input]) # 此处省略anchor decode和nms的具体实现,见上文后处理说明 boxes, kps_list = decode_and_nms(loc, conf, landmarks, score_thresh=0.5) for box, kps in zip(boxes, kps_list): x1, y1, x2, y2 = [int(v) for v in box] # 坐标映射回原图 scale = frame.shape[1] / 640.0 x1, y1, x2, y2 = int(x1 * scale), int(y1 * scale), int(x2 * scale), int(y2 * scale) kps[:, 0] *= scale kps[:, 1] *= scale aligned = align_face(frame, kps) feat = rec.inference(inputs=[aligned.astype(np.float32)[None].transpose(0, 3, 1, 2)])[0].flatten() norm = np.linalg.norm(feat, 2) + 1e-6 feat = feat / norm name, score = db.match(feat, threshold=0.5) label = f"{name} {score:.2f}" if name else "Unknown" cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow("face system", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()代码里decode_and_nms函数没有展开,因为完整实现比较长,建议直接参考Pytorch_Retinaface仓库里的后处理代码,稍作修改即可。文章末尾我会再强调这个部分最容易出问题。
5.3 扩展:USB摄像头转RTSP流
很多实际项目不满足于在板子上插显示器看画面,而是希望把识别结果推到局域网内,在任意电脑或手机上查看。这就是"USB摄像头转RTSP流"的典型需求。RK3588因为有硬件编码器MPP,做这件事几乎不占CPU。
最简单的方式是用ffmpeg直接推流:
ffmpeg -s 1280x720 -i /dev/video0 -c:v h264_rkmpp -b:v 2M -f rtsp -rtsp_transport tcp rtsp://0.0.0.0:8554/live也可以在Python里用GStreamer生成RTSP流。需要注意RK3588硬件编码的格式是H264/H265,客户端播放时用VLC或ffplay都能正常打开。如果要在画面里叠加识别框后再推流,就把cv2绘制后的frame通过管道送给ffmpeg,或者使用GStreamer的appsrc方式。
6. 性能实测、调优和踩过的坑
6.1 端到端性能数据
我在香橙派5B上实测过一组数据,环境是Ubuntu 22.04、RKNN-Toolkit2 1.6.0、摄像头输入1280×720:
| 环节 | 耗时/说明 |
|---|---|
| RetinaFace推理(INT8量化) | 约35到45毫秒 |
| MobileFaceNet推理(INT8量化) | 约10到15毫秒 |
| 前后处理(resize、decode、align) | 约15到25毫秒 |
| 端到端单帧 | 大约60到80毫秒,相当于12到16 FPS |
12到16FPS对门禁和考勤完全够用。如果觉得不够,有两个优化方向:一是把RetinaFace的输入从640×640降到512×512或416×416,推理时间能进一步缩短到25毫秒左右,但小脸检出率会下降;二是用OpenCV的setNumThreads开启多线程,或者把图像缩放和仿射变换放到单独的线程处理。
另外,RK3588的CPU大核跑OpenCV的缩放和resize比小核快很多,可以尝试用taskset把Python主进程绑定到A76大核上:
taskset -c 4-7 python3 main.py这个方法在某些负载下能提升5%到10%的帧率。
6.2 量化精度与检测框抖动
RetinaFace在INT8量化后,最典型的问题是检测框轻微抖动和关键点偏移。如果发现刚量化完在某种光线下框稳,换个灯光环境就忽大忽小,多半是量化校准集覆盖不够。
解决办法是重新收集量化集,加入不同时间段、不同灯光的图像。另外可以在RKNN的config里尝试关闭部分层的量化,只对敏感层做INT8。RKNN-Toolkit2支持per-layer量化设置,虽然操作复杂一些,但对RetinaFace这种输出head比较多的模型,有时能显著提升稳定性。
如果对精度要求高,也可以退一步做FP16推理。RK3588的NPU支持FP16,推理速度比INT8慢一些,但精度几乎无损。检测框抖动问题会缓解很多。
6.3 摄像头采集格式引发的花屏和绿屏
摄像头这块调试时容易遇到绿屏、偏色、延迟高问题。大部分是老旧的USB摄像头在YUYV格式下的带宽问题导致。在OpenCV中强制指定MJPEG格式后,帧率和色彩都能正常。还有一种是cv2.VideoCapture对某些摄像头的初始分辨率支持不好,设置1280×720会失败,反而640×480就正常。遇到这种可以先读取摄像头支持的分辨率列表:
v4l2-ctl --list-formats-ext按列表里的参数去设置,不要盲目追求高分辨率。
6.4 RKNN版本兼容教训
版本问题是我这次部署中最大的时间消耗点。我的PC端rknn-toolkit2升级到了2.1.0,但板子镜像里的librknnrt还是1.4.0,结果一加载模型就报RKNN_ERR_PARAM_INVALID,排查了很久。
这类问题的排查思路如下:
# 查看板端librknnrt版本 strings /usr/lib/librknnrt.so | grep -i version # 查看PC端rknn-toolkit2版本 pip show rknn-toolkit2确认两者大版本一致后,重新转换模型即可。如果板端运行库版本较旧,可以手动升级:
sudo cp librknnrt.so /usr/lib/ sudo ldconfig升级前记得备份原文件。版本对齐后,模型加载和推理基本不会再出幺蛾子。
6.5 CPU占用优化思路:RGA缩放和流水线
End-to-end跑起来后,你会发现NPU推理占的时间并不多,反而是OpenCV的resize和仿射变换消耗了不少CPU。RK3588里有RGA硬件加速器,专门做图像缩放、旋转、格式转换,理论上可以替代OpenCV的resize。
RGA的调用方式有两种:一种是使用Rockchip提供的librga库,通过C接口调用;另一种是用ffmpeg的rkmpp/rga滤镜。Python下使用RGA相对麻烦,需要写C扩展或者调用cffi。如果只是做单路摄像头人脸识别,我觉得暂时没必要上RGA,OpenCV在大核上跑缩放完全够用。只有当CPU占用率成为瓶颈,或者要做四路以上并发时,再考虑把图像缩放下沉到RGA。
另一个优化思路是流水线并行:用一个线程专门读摄像头和做图像预处理,另一个线程做NPU推理和显示。Python的GIL可能限制多线程收益,更激进的做法是用multiprocessing,把检测和识别放在子进程里。实测下来,子进程方案能再提升几帧,但代码复杂度增加不少。
6.6 其他值得注意的小问题
人脸识别系统部署中还会遇到一些零碎问题,简单记录一下:
- 识别模型加载慢,可以把rknn文件放在SSD上,加载时间从几秒降到几百毫秒。
- 底库特征文件要定期备份,我的习惯是每新增一个人脸就自动备份一份到NAS或Git仓库。
- 多人同时出现在画面时,NMS会把两个人脸合并成一个框。调整IoU阈值到0.4以下能缓解。
- 逆光环境下检测不到人脸的场景,建议调节摄像头的曝光参数,或开启宽动态功能。
这套RetinaFace加rec的方案我在板子上跑了两周,实际感受是真正常见的坑不是模型本身,而是联调环境里的版本匹配和预处理细节。你只要把PC端和板端的RKNN版本对齐,把预处理顺序和训练源码对齐,跑通实时识别其实真的不需要太久。后面我还在研究把活体检测加进链路里,目前计划用RGB摄像头加近红外摄像头做双模态方案,等跑出效果再来分享。