
1. 为什么是 RK3588 RetinaFace这个组合的真实价值先说结论RK3588 的 6 TOPS NPU 算力刚好卡在“能跑人脸检测”的甜蜜区间。比它弱的芯片跑 RetinaFace 容易掉帧比它强的芯片价格又不够亲民。而 RetinaFace 本身在 WiderFace 上的精度表现至今仍是轻量级人脸检测模型里最能打的那批——它同时输出人脸框、5 个关键点和 3D 姿态一套推理结果能直接喂给后续的人脸对齐、人脸比对、活体检测模块不用再单独训练一个关键点模型。我做这个项目的时候最初的诉求其实特别朴素在 RK3588 上跑一个实时人脸检测服务输入 USB 摄像头或者 RTSP 视频流输出检测框和关键点坐标延迟控制在 30ms 以内。当时手头有 PyTorch 1.8 训练好的 RetinaFaceMobileNet0.25 backbone本想走 ONNX Runtime 直接硬跑结果在 RK3588 的 CPU 上测了一下单帧推理要 120ms 左右完全没法用。换 RKNN-Toolkit2 量化转成 NPU 模型之后单帧推理降到 15ms 左右整个流程才真正活过来。这篇文章不是从零讲解 RetinaFace 原理也不是 RKNN 官方文档的复读而是把我踩过的坑、反复验证过的参数、以及最终跑通的完整链路记录下来。适合三种人看手里有 RK3588 开发板想把 PyTorch 模型部署上去的新手已经在跑 RKNN 但遇到“转换成功、推理结果全错”这类玄学问题的开发者需要做人脸检测 关键点输出的项目但不确定 RK3588 方案是否可行的方案选型者。整个流程围绕一条主线展开PyTorch 模型 → ONNX → RKNN 量化 → NPU 推理。每个环节都有对应的坑位我会按实际踩坑的顺序来讲不跳步。先说清楚我的硬件环境后面所有操作都基于这套项目配置开发板瑞芯微 RK35888GB 内存版也可以理解成 RK3588SNPU 驱动rknpu2 1.6.0RKNN-Toolkit22.3.0PC 端转换用转换环境Ubuntu 20.04 x86_64 Python 3.8板端推理环境Ubuntu 22.04板载系统 Python 3.10原始模型RetinaFace-MobileNet0.25PyTorch 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 python3.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 格式可以做量化、仿真推理rknpu2rknnlite跑在板子上负责加载 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 自带的 democd /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 做 backboneFPN 做多尺度特征融合输出三个分支——分类分支是否为人脸、边框回归分支人脸框、关键点回归分支5 个关键点坐标。以我用的 MobileNet0.25 版本为例模型输出的 tensor 结构如下bbox_regressionsshape 为[batch, 4, 锚点总数]对应人脸框的四个偏移量classificationsshape 为[batch, 2, 锚点总数]人脸的分类得分landmarksshape 为[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_locationcpu)) 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_axesdynamic_axes, opset_version11, do_constant_foldingTrue )几个关键点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(fOutput {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(verboseTrue) # 配置阶段核心 rknn.config( mean_values[[104, 117, 123]], std_values[[1, 1, 1]], target_platformrk3588, quantized_dtypew8a8, quantized_algorithmnormal, optimization_level3 ) # 加载 ONNX 模型 ret rknn.load_onnx(modelretinaface_mnet.onnx) assert ret 0, load onnx failed # 构建 RKNN 模型这个阶段会自动做量化校准 ret rknn.build(do_quantizationTrue, datasetdataset.txt) assert ret 0, build failed # 导出 RKNN 文件 ret rknn.export_rknn(retinaface_mnet.rknn) assert ret 0, export failed4.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.txtimages/face_001.jpg images/face_002.jpg ... images/face_100.jpg然后 build 的时候指定这个文件ret rknn.build(do_quantizationTrue, datasetdataset.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_dtypew8a8, quantized_algorithmmmse, )检测效果有明显改善。但 mmse 的缺点是转换时间变长量化校准阶段大概多了 3~5 分钟。如果你的模型对精度要求高、推理速度要求没那么极限优先用 mmse。另一个调优技巧如果你发现总体精度可以但小尺寸人脸检测率下降可以尝试把输入分辨率从 640×640 提到 960×960 或者 1280×1280。但这样推理速度会明显变慢NPU 的利用率会上升。RetinaFace 本身对小脸的支持就一般提升输入分辨率比调量化参数更有效。4.5 量化前先做精度对比实验这里分享一个我总结出的高效工作流先用 float16 跑通全流程再转 int8 做精度对比。具体操作是rknn.config( quantized_dtypefp16, # 先不量化 )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_maskRKNNLite.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-255mean_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 万个锚点如果不做筛选直接 NMSCPU 扛不住。我采用的策略是三层过滤先按置信度阈值粗筛只保留 score 0.5 的候选框这一步能筛掉 95% 以上的锚点按类别得分排序取 top-K在粗筛后的框里按得分从高到低排序只取前 5000 个最后做标准的 NMSIoU 阈值设为 0.4。具体代码片段def filter_and_nms(bboxes, landmarks, scores, score_threshold0.5, top_k5000, nms_threshold0.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 Runtime112ms使用 rk3588 的 4 个大核 A76CPU RKNNNPU 不可用时的 fallback87msRKNN 在 CPU 上模拟运行NPU RKNNfp16 量化22ms精度无损失NPU RKNNint8 量化normal 算法12ms精度有下降NPU RKNNint8 量化mmse 算法14ms精度接近 fp16从数据可以直观看到NPU 推理比 CPU 快了 5~8 倍。int8 比 fp16 又快了接近一倍。如果你的应用对精度要求没那么苛刻比如做门禁闸机人脸近距离通过检测难度低int8 mmse 是性价比最高的组合。6.2 三核 NPU 的使用策略RK3588 的 NPU 有三个核心Core0/1/2core_mask参数决定推理时使用哪个核心rknn_lite.init_runtime(core_maskRKNNLite.NPU_CORE_0) rknn_lite.init_runtime(core_maskRKNNLite.NPU_CORE_1) rknn_lite.init_runtime(core_maskRKNNLite.NPU_CORE_2) rknn_lite.init_runtime(core_maskRKNNLite.NPU_CORE_0_1) rknn_lite.init_runtime(core_maskRKNNLite.NPU_CORE_0_1_2) rknn_lite.init_runtime(core_maskRKNNLite.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 摄像头 1080p8~12resize 到 640×6402~3颜色转换YUV→BGR如果有3~5NPU 推理12~15解码 NMS4~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 模型mmseint8 模型normal检出率IoU0.596.5%95.0%88.5%误检数成绩图3412关键点平均误差像素2.32.84.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_threshold0.5, nms_threshold0.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_maskRKNNLite.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, dtypenp.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 的过程中碰到了这篇文章没有覆盖的坑欢迎沿着这个思路去拆解——大概率还是在“转换配置、输入预处理、解码参数”这三个环节里出了问题。