Rockchip RK3566 SoC 内置的 NPU算力达到 0.8 TOPS,对于人脸检测与识别这类任务而言,是替代 CPU 进行通用推理的绝佳选择。

基于 Qt 5 (GUI & 线程管理) + OpenCV (图像预处理) + RKNN (NPU推理) 的高效人脸识别系统。

1. 系统架构设计

为了保证 1080P 视频流的流畅显示与实时检测,传统的单线程循环模式(采集->检测->显示)会导致界面严重卡顿。本工程采用 多线程流水线 架构:

  • **主线程 (UI Thread)**:负责界面绘制、视频渲染、用户交互。
  • **采集线程 (Video Thread)**:使用 cv::VideoCapture 读取摄像头数据。
  • **检测线程 (Detection Thread)**:运行 YOLOv5-Face 模型,定位人脸坐标。
  • **识别线程 (Recognition Thread)**:运行 MobileFaceNet 模型,提取 128维 特征向量并比对。

1.1 Qt 多线程实现模式

不同于继承 QThread 并重写 run() 的方式,本工程使用了更灵活的 moveToThread 模式,利用信号槽机制实现流水线解耦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// MainWindow.cpp 初始化片段
MainWindow::MainWindow(QWidget *parent) : ... {
// 实例化业务对象与线程对象
readVideo = new ReadVideo();
faceDetection = new FaceDetection();
videoThread = new QThread(this);
faceDetectionThread = new QThread(this);

// 核心:将业务对象移动到独立线程
readVideo->moveToThread(videoThread);
faceDetection->moveToThread(faceDetectionThread);

// 信号槽连接:采集完成 -> 开始检测
connect(readVideo, &ReadVideo::frameCaptured,
faceDetection, &FaceDetection::startDetection, Qt::QueuedConnection);

// 启动线程
videoThread->start();
faceDetectionThread->start();
}

关键点Qt::QueuedConnection 确保了 cv::Mat 数据在跨线程传递时不仅是安全的,而且会让槽函数在接收者线程的事件循环中执行,从而真正实现并行。

2. NPU 推理流程详解 (RKNN)

RKNN Runtime 的调用流程是通用的:init -> input_set -> run -> output_get。以下以 YOLOv5-Face 检测模块为例。

2.1 模型加载与上下文初始化

在类构造函数中,我们将 .rknn 模型文件加载到内存,并建立 NPU 上下文。

1
2
3
4
5
6
7
8
9
10
11
12
// FaceDetection.cpp
FaceDetection::FaceDetection(QObject *parent) : QObject(parent) {
// 1. 读取模型文件
unsigned char* model_data = load_model(YOLO_MODEL_PATH, &model_data_size);

// 2. 初始化 RKNN 上下文
// rknn_yolo 是 rknn_context 类型句柄
int ret = rknn_init(&rknn_yolo, model_data, model_data_size, 0, NULL);
if (ret != RKNN_SUCC) {
qDebug() << "RKNN Init Failed!";
}
}

2.2 输入张量封装

RKNN API 需要特定的输入结构。OpenCV 的 cv::Mat 数据通常是 BGR 也可以是 RGB,需要根据模型训练时的预处理要求进行转换,并填入 rknn_input 结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void FaceDetection::startDetection(const cv::Mat &frame) {
// ... 图像预处理 (Resize / Padding) ...
// 将图像数据拷贝到连续内存 img_batch 中

// 准备输入数据
rknn_input inputs[1];
memset(inputs, 0, sizeof(inputs));
inputs[0].index = 0;
inputs[0].type = RKNN_TENSOR_UINT8; // 量化模型通常输入为 UINT8
inputs[0].size = IMG_SIZE * IMG_SIZE * 3; // 640*640*3
inputs[0].fmt = RKNN_TENSOR_NHWC; // TensorFlow/TFLite 常用布局
inputs[0].pass_through = 0; // 需要 NPU 进行量化参数转换
inputs[0].buf = img_batch.data; // 指向封装好的数据指针

// 绑定输入
rknn_inputs_set(rknn_yolo, 1, inputs);

// 执行推理 (阻塞调用,但因在独立线程,不会卡UI)
rknn_run(rknn_yolo, NULL);
}

2.3 获取推理结果与后处理

模型输出通常是未经过处理的多维数组。为了简化代码,我们可以让 NPU 驱动自动将量化后的输出反量化为浮点数 (want_float = 1)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 准备输出结构
rknn_output outputs[3]; // YOLOv5 通常有3个尺度的输出
memset(outputs, 0, sizeof(outputs));
for (int i = 0; i < 3; i++) {
outputs[i].want_float = 1; // 关键:请求驱动转换为 float
}

// 获取输出
rknn_outputs_get(rknn_yolo, 3, outputs, NULL);

// ... 后处理算法 (解析锚框 Anchor,执行 NMS 非极大值抑制) ...

// 释放输出内存,避免泄漏
rknn_outputs_release(rknn_yolo, 3, outputs);

技术细节outputs[i].want_float = 1 极大地简化了应用层代码,虽然会增加一点 CPU 转换开销,但避免了手动处理 zero_pointscale 的繁琐过程。

2.4 YOLOv5 后处理算法解析

YOLO 模型输出的是三个不同尺度(Stride 8, 16, 32)的 Feature Map。在 yolov5_post_process 中,需要根据预定义的 Anchors 将 Feature Map 上的偏移量映射回原图坐标。

核心步骤包括:

  1. Sigmoid 激活:将 NPU 输出的原始数据映射到 (0, 1)。
  2. 坐标还原
    $$
    b_x = 2 \sigma(t_x) - 0.5 + c_x
    $$
    $$
    b_y = 2 \sigma(t_y) - 0.5 + c_y
    $$
    $$
    b_w = p_w (2 \sigma(t_w))^2
    $$
    $$
    b_h = p_h (2 \sigma(t_h))^2
    $$
    其中 $c_x, c_y$ 是网格坐标,$p_w, p_h$ 是 Anchor 宽高。
  3. **NMS (非极大值抑制)**:使用 OpenCV 的 dnn::NMSBoxes 剔除重叠度高的冗余框。
1
2
3
4
5
// FaceDetection.cpp 中对输出的处理
float bx = (sigmoid(input[index + 0]) * 2.0 - 0.5 + x) * (640.0 / grid_w);
float by = (sigmoid(input[index + 1]) * 2.0 - 0.5 + y) * (640.0 / grid_h);
float bw = pow(sigmoid(input[index + 2]) * 2.0, 2) * anchors[mask[i]][0];
float bh = pow(sigmoid(input[index + 3]) * 2.0, 2) * anchors[mask[i]][1];

3. 人脸特征比对与加速 (FLANN)

Mobilefacenet 模块提取出 128维 人脸特征向量后,需要与数据库中已存的 N 个人脸特征进行比对。传统的暴力遍历(Brute-force)在人脸库较大时效率低下。

本工程采用了 OpenCV 的 FLANN (Fast Library for Approximate Nearest Neighbors) module 进行高维向量检索。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Mobilefacenet.cpp 初始化
// 加载所有特征到 features 矩阵 (N x 128)
loadFeaturesFromDatabase(features, faceIDs);

// 构建 KD-Tree 索引
if (!features.empty()) {
flannIndex = new flann::Index(features, flann::KDTreeIndexParams(4), cvflann::FLANN_DIST_EUCLIDEAN);
}

// 实时比对
void Mobilefacenet::start_mbf(const cv::Mat &frame) {
// ... NPU推理得到 current_feature (1 x 128) ...

vector<int> indices(1);
vector<float> dists(1);

// 使用 KNN 搜索最近邻
flannIndex->knnSearch(current_feature, indices, dists, 1, flann::SearchParams(32));

if (dists[0] < THRESHOLD) {
// 识别成功,门禁通行
emit capture_disp(faceIDs[indices[0]], 1);
}
}

使用 KD-Tree 结构可以将搜索复杂度从 $O(N)$ 降低到 $O(\log N)$ 级别,显著提升了在数百人脸库规模下的响应速度。

4. 业务逻辑与数据库管理

除了算法核心,工程还集成了 SQLite 数据库用于从管理人员信息。DatabaseUtils 命名空间封装了基础的 CRUD 操作:

  • face 表:存储当前系统的最大人脸ID。
  • IDtoPeople 表:存储 ID 与姓名、部门的映射关系。
  • 特征存储:由于 SQLite 存取 vector 较慢,特征文件被序列化为 .xml 或直接读取到内存中建立索引,数据库仅存储元数据。

这套轻量级的数据库方案非常适合嵌入式单机门禁系统,既保证了数据持久化,又没有引入过重的 MySQL 等依赖。

5. 遇到的坑与优化策略

  1. Mat 数据类型注册
    由于信号槽参数必须是 Qt 元对象系统已知的类型,在 main.cpp 或构造函数中必须调用:
    qRegisterMetaType<cv::Mat>("cv::Mat");
    否则信号虽然发出,但槽函数可以通过编译却永远不会被调用。

  2. 跳帧策略
    检测线程的处理速度可能低于采集速度(例如检测耗时 30ms,采集耗时 16ms)。为了防止信号队列堆积导致的图像延迟,我们在 FaceDetection 中加入了简单的跳帧逻辑:

    1
    2
    3
    if (frameCount % 5 != 0) { // 每5帧处理1帧
    return;
    }

    这保证了 UI 显示的是最新鲜的画面,而不是几百毫秒前的“旧账”。

  3. 内存对齐与格式
    NPU 对输入数据的步长(stride)和对齐敏感。使用 cv::Mat 连续内存(Continuous Memory)通常没问题,但如果是裁剪后的 ROI 区域(非连续内存),必须执行 clone()memcpy 到一块新的连续缓冲区再传给 API。

6. Zero-Copy 优化

目前的实现中,图像数据在 CPU (OpenCV) 和 NPU 之间存在 memcpy 拷贝。RK3566 的 NPU 支持 DMA 分配器(DRM/DMA-BUF)。

未来的优化方向是使用 rknn_mem_create 分配物理连续内存,并将该内存直接映射给 OpenCV 的 cv::Mat 使用(通过自定义 Allocator)。这样,摄像头的采集数据可以直接写入 NPU 可访问的物理内存区域,实现真正的 Zero-Copy 零拷贝推理,进一步降低 CPU 占用率。


7. 总结

通过上述实现,我们在 RK3566 平台上构建了一套完整的人脸门禁系统逻辑。Qt 负责调度与展现,OpenCV 负责“搬运”与“预处理”,RKNN 负责核心的“思考”。