☰
人脸识别门禁系统实战:FaceNet+RetinaFace检测与特征提取全解析
2026/9/28 17:03:02 网站建设 项目流程

简介:这是一份面向毕业设计、课程设计场景的FaceNet+RetinaFace人脸识别管理系统完整项目包,采用Python+Java+Vue技术栈,适合计算机相关专业学生、Python开发者以及想要快速搭建人脸识别原型的研究者,系统覆盖人脸检测、特征提取、特征库存储、身份比对等闭环流程,可用于门禁管理、课堂签到、安防监控等实际场景。资源包为ZIP格式,共646个文件,大小57.39MB;其中242张PNG图片与22张JPG图片构成界面素材,30个GIF动图为演示截图;172个Java、64个Vue、34个JS及7个CSS文件组成前后端工程;18个Python脚本用于模型加载与识别流程,另有XML配置、JSON数据、SQL数据库脚本、PTH模型权重、MP4演示视频等,目录按功能模块划分,便于定位源码、样式、文档与数据集。已有448人学习下载,压缩包内提供可运行的完整源码,包含前端页面、后端接口、人脸检测与识别算法、模型参数以及数据库建表脚本;通过GIF和MP4演示可直观了解运行流程,结合代码注释,还能学习锚框策略、三元组损失、欧氏距离阈值设定等细节,便于二次开发,快速完成毕设或课设。

1. 门禁机背后那套 FaceNet+RetinaFace:检测要快、识别要稳

去楼下看一眼现在的人脸识别门禁机,镜头里人脸一靠近,屏幕闪一下,门就开了。这个动作背后不是一个人脸识别模型套到底,而是两个网络各管一段:RetinaFace 管人在哪、眼睛鼻子在哪;FaceNet 管把对齐后的脸压成 128 维向量,再跟数据库里的特征比远近。做人脸识别门禁系统设计或课程设计时,最容易翻车的地方,就是把检测和识别混成同一个模型去调参。这套笔记按门禁流程把两个网络拆开讲:怎么跑通、参数怎么设、管理系统的表怎么建,以及答辩前一定要补的验证实验。

2. RetinaFace 做检测:为什么门禁要用专门的检测网络

做人脸识别系统,第一步不是“认人”,而是“找人”。RetinaFace 在整套 pipeline 里负责把画面里的人脸先框出来,同时给出眼睛、鼻子、嘴角五个关键点的坐标。这两个输出一个给后面的 FaceNet 当输入,一个给门禁系统做“人是否正对镜头”的判断依据。

2.1 为什么检测和识别必须分开,而不是一个模型全包

很多初学者会问:能不能用一个网络同时完成“检测 + 识别”?技术上可行,但工程上没人这么干。原因很简单:检测目标和识别目标冲突。检测要的是“不管是谁,只要是脸就给我框出来”,识别要的是“把这张脸和库里某个人区分开”。硬绑在一个模型里,训练数据既要框的标注,又要身份的标注,数据量翻几倍,训练难度也翻几倍,最后效果还未必比两个模型串联好。

另一个更实际的原因是维护。系统上线后,发现某个角度检不出来,你要单独换检测模型;发现双胞胎分不清,你要单独换特征模型。两个模块独立升级,改一个不用动另一个,排查问题也容易定位——比对结果不对先查特征,检测结果不对先查 RetinaFace,互不甩锅。我用过一段时间端到端方案,后来换回检测+识别的两段式,维护成本低得多。

所以这个系统的架构很朴素:摄像头采集画面,RetinaFace 逐帧检测并输出关键点,关键点把脸“摆正”,摆正后的图送入 FaceNet 生成向量,向量去和数据库里注册过的特征算距离,距离小于阈值就开门。看清楚这条链子,后面每一步踩坑你都能知道该怪谁。

2.2 RetinaFace 的网络设计:多任务头怎么提升准召

RetinaFace 是 2019 年提出来的检测网络,它的核心思路是在 RetinaNet 的基础上加了一组人脸专属的多任务头。主干网络可以用 ResNet,也可以用 MobileNet 这样的轻量骨干,然后接特征金字塔,把不同尺度的特征都拿来做预测。人脸有大有小,站在门禁机前 30 厘米和站 1 米,脸在画面里的尺寸差很多,特征金字塔能保证大小脸都有对应的特征层去检测。

它对每个候选框预测三样东西:人脸置信度、边框位置、五个关键点位置。有的变体还会额外预测密集人脸像素信息,用来提高难例的召回率。五个关键点这个输出很关键,它比单纯一个框有用得多。因为框只能告诉你“这里有一团像脸的东西”,但两眼之间的距离和角度,能给你“这张脸是正着还是歪着的”这个几何信息。FaceNet 对输入图的姿态很敏感,脸歪着送进去,特征会明显偏离注册时的样子。

在工程里我一般把多任务头理解为四个输出分支,实际推理时取前三类就够用:

任务头输出维度在门禁系统里的作用
人脸分类2(人脸/背景)筛掉误检,决定是否触发识别
边框回归4(x, y, w, h)画出人脸区域,供显示和后续裁切
五点回归10(两眼的坐标等)用于仿射对齐,把歪脸转正
密集回归(可选)逐像素分类+回归提升困难样本召回,部署可裁剪掉

这个设计让 RetinaFace 在 CPU 上也能跑得动,因为不是所有任务头都要参与最终推理。做课程设计时,你完全可以直接用公开权重,不必从零训练,后面会讲怎么加载。

2.3 跑通 RetinaFace 的最小推理代码:ONNX Runtime 推理与五点对齐

我从不在训练上折腾 RetinaFace,而是直接拿公开的 ONNX 权重做推理。下面这段代码只依赖 onnxruntime、OpenCV 和 NumPy,写清楚每一步在干什么。

# face_detector.py import cv2 import numpy as np import onnxruntime as ort class RetinaFaceDetector: def __init__(self, onnx_path="retinaface.onnx", input_size=(640, 640)): self.sess = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"]) self.input_size = input_size self.input_name = self.sess.get_inputs()[0].name def preprocess(self, img_bgr): # 保持宽高比缩放后填充到 input_size,避免把脸拉变形 h, w = img_bgr.shape[:2] scale = min(self.input_size[0] / h, self.input_size[1] / w) new_h, new_w = int(h * scale), int(w * scale) resized = cv2.resize(img_bgr, (new_w, new_h)) canvas = np.zeros((self.input_size[0], self.input_size[1], 3), dtype=np.uint8) canvas[:new_h, :new_w] = resized # RetinaFace 训练时图像归一化到 [-1, 1] tensor = canvas.astype(np.float32) tensor = (tensor - 127.5) / 127.5 return tensor.transpose(2, 0, 1)[None, ...], scale def detect(self, img_bgr, box_threshold=0.5): tensor, scale = self.preprocess(img_bgr) outs = self.sess.run(None, {self.input_name: tensor}) # outs 通常是 [bboxes, landmarks, scores],顺序随模型导出不同而变,先打印 shape 确认 bboxes, landmarks, scores = outs[0], outs[1], outs[2] valid = scores[0, :, 0] > box_threshold boxes_abs = bboxes[0, valid] / scale lms = landmarks[0, valid].reshape(-1, 10) / scale return boxes_abs, lms

代码逻辑说明:preprocess 里先算缩放比例,再把图像等比缩放后填充到方形画布,这一步是为了让 RetinaFace 输入尺寸固定,同时不破坏人脸比例。直接暴力 resize 到正方形会把圆脸拉成长脸,后面 FaceNet 提取的特征会漂。detect 里的阈值参数 box_threshold 控制的是“什么样算一张脸”,0.5 是保守值,门禁场景建议提到 0.8,宁可少检一次,也不要拿个背景板去刷脸。

参数说明:input_size=(640, 640) 是效果和速度的平衡点。输出坐标之所以要除以 scale,是因为网络看到的是填充后的 640×640 图像,检测出的坐标要还原回原始图像分辨率才准确。如果发现输出 shape 不一致,常见原因是导出的模型把 landmark 和 score 的顺序调换了,跑一次打印各输出的 shape 就能确认。

2.4 门禁场景的输入尺寸和 CPU 取舍

做课程设计,你大概率没有 GPU,推理要跑在 CPU 上。RetinaFace 的输入尺寸直接决定 CPU 能不能扛得住。

640×640 在四核 CPU 上单帧大约 40~80 毫秒,只做检测还能接受,加上后面 FaceNet 的耗时,整体帧率会掉到 10 帧以下。320×320 分辨率能把检测压到 15~30 毫秒,小脸召回率会掉一点,但门禁场景人脸本来就靠近镜头,半米到一米距离内脸不会太小,所以损失不明显。

输入尺寸单帧推理耗时参考(CPU)适用场景
640×64040~80ms追求召回率,后台服务器处理
320×32015~30ms树莓派、PC 上的门禁原型
160×1605~10ms移动端或扩展板,误检明显增多

如果你看到的是基于 stm32 的人脸识别门禁系统设计,那要提前说明白:RetinaFace 在单片机上跑不起来,这类方案通常外接串口摄像头模组,由模组内部完成检测和识别。用 k10 行空板这类能跑 Python 的开发板,320 分辨率还能试一试,再低就基本检不准了。选型时先确认自己手头的硬件属于哪一类,再决定用不用 RetinaFace 原始权重。

3. FaceNet 做特征:把一张脸变成一组可比的数字

检测做完,接下来才是人脸识别系统的重头戏:特征提取。FaceNet 拿一张对齐后的人脸图,输出一个固定长度的向量。这里的关键不是“生成了什么”,而是“怎么让同一个人的向量始终相近、不同人的向量始终远离”。这背后是三元组损失。

3.1 三元组损失到底训练的是什么

FaceNet 的训练数据由三元组构成:一张锚图(Anchor)、一张同人的图(Positive)、一张不同人的图(Negative)。训练目标是让锚图和正样本的距离小于锚图和负样本的距离,并且留出一个间隔 margin。公式上就是让 d(A, P) + margin < d(A, N)。这个 margin 控制的是“不同人之间要靠得多远才算合格”,太大了训练难收敛,太小了特征会挤在一起。

真正影响训练效果的是三元组的采样策略。随机抽样拿到的三元组,大部分里面负样本离锚图很远,模型根本不需要学就能满足条件,loss 很快就变成 0,这就是常说的模型“躺平”。半难样本挖掘的做法是:在每个 batch 里找那些“虽然比正样本远,但远得不多”的负样本去训练,让模型始终在面对难区分的人脸。这个细节是做课程设计时经常被忽略的,答辩老师如果问到训练策略,你能答出半难样本挖掘,就说明真看懂了 FaceNet 的原理。

FaceNet 原始论文用的是 128 维输出,最后接了一个 L2 归一化层,让向量都落在单位球面上。这样算欧氏距离时,数值范围是固定的,不会因为某张图太亮或太暗把向量拉得特别长。实际部署时,我一般保留 L2 归一化这一步,后面所有阈值调试都是在这个前提下进行的。

3.2 用对齐后的脸生成 128 维向量:从关键点到特征

RetinaFace 给出的五个关键点,在这里派上用场。直接把人脸框裁下来送进 FaceNet 是常见错误,因为框里总带点头发背景,头稍微歪一点,裁出来的东西差别很大。正确做法是用关键点做仿射变换,把两只眼转到水平,再裁出固定大小的区域。这个操作决定了特征质量,值不值得做,看一组数据就知道:不正的脸送进去,同一个人两次识别的向量距离可能超过 0.8,而正脸对齐后往往在 0.5 以内。

# face_embedder.py import cv2 import numpy as np import onnxruntime as ort class FaceNetEmbedder: def __init__(self, onnx_path="facenet.onnx", input_size=(160, 160)): self.sess = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"]) self.input_size = input_size def align_crop(self, img_bgr, landmarks): # landmarks: [left_eye_x, left_eye_y, right_eye_x, right_eye_y, ...] left_eye = landmarks[0:2] right_eye = landmarks[2:4] dx, dy = right_eye - left_eye angle = np.degrees(np.arctan2(dy, dx)) M = cv2.getRotationMatrix2D(tuple(left_eye.astype(float)), angle, 1.0) rotated = cv2.warpAffine(img_bgr, M, (img_bgr.shape[1], img_bgr.shape[0])) # 以两眼中心为基准,裁 2 倍眼距的正方形区域 eye_center = (left_eye + right_eye) / 2 distance = np.linalg.norm(right_eye - left_eye) size = int(distance * 2.0) x0 = int(eye_center[0] - size / 2) y0 = int(eye_center[1] - size / 2) crop = rotated[max(y0, 0): y0 + size, max(x0, 0): x0 + size] crop = cv2.resize(crop, self.input_size) tensor = crop.astype(np.float32) tensor = (tensor - 127.5) / 127.5 return tensor.transpose(2, 0, 1)[None, ...] def embed(self, img_bgr, landmarks): aligned = self.align_crop(img_bgr, landmarks) outputs = self.sess.run(None, {"input": aligned}) emb = outputs[0][0] norm = np.linalg.norm(emb) return emb / norm # 保留 L2 归一化

逻辑说明:align_crop 先用两眼坐标算出旋转角度,把整帧图转正,再以眼距的固定倍数裁出人脸区域。这样 FaceNet 看到的图里,眼睛始终在同一水平线,脸的大小也基本固定,输入分布的波动就小很多。参数 size 取 2 倍眼距,能罩住眉毛到下巴的大致范围,太大容易带进耳朵和头发,太小会切掉额头。

参数说明:FaceNet 对输入尺寸有严格约定,如果权重是 160×160 训练的,喂 112×112 特征会乱。embed 方法里最后除以范数,是为了让向量落在单位球面上。所有预训练权重都有对应的预处理要求,加载之前先确认模型文档里的尺寸、通道顺序和归一化方式,别省这一步。

3.3 欧氏距离和余弦相似度:阈值到底该怎么选

FaceNet 官方语义里用的是欧氏距离,距离越小越像。余弦相似度则相反,越大越像。两者在向量已经 L2 归一化的情况下是等价的,但工程习惯不同。

度量方式官方推荐门禁初值容易踩的坑
欧氏距离越小越像1.0~1.2忘记归一化时阈值完全失效
余弦相似度越大越像0.80~0.95阈值偏高导致拒识率飙升

我做门禁系统时偏好欧氏距离,原因只有一个:距离的物理意义直观,1.0 就是“差了多少”的直接度量。但无论用哪种,都不能直接抄论文里的阈值。不同光照、不同摄像头拍出来的特征分布不同,必须在自己的数据集上校准。后面有一章专门讲怎么用 ROC 曲线去定阈值,这里先记住一个原则:宁可阈值紧一点,多拦几次熟人,也别让陌生人随便刷开。误拒可以手动处理,误识就是安全事故。

4. 把两个网络接成系统:注册、比对、数据库与记录

模型能跑通只是第一步,这套标题叫“人脸识别管理系统”,重点在“管理”两个字。你要能注册新人、查识别记录、导出考勤,哪怕界面丑一点,逻辑必须完整。这一章直接给数据流、注册代码、识别代码和数据库表设计。

4.1 从摄像头到开门的完整链路:每一帧都干了什么

先明确流程,再写代码。整套系统按模块拆成四段:

  1. 摄像头取流:OpenCV 打开摄像头,不断读取画面帧。
  2. 检测与筛选:RetinaFace 跑检测,保留置信度高于阈值的人脸,过滤掉太小的人脸区域。
  3. 特征提取与匹配:FaceNet 生成当前脸的向量,和内存中的注册特征库算距离,取最小值,判断是否低于阈值。
  4. 动作记录:匹配成功后写入一条开门记录,包含用户 ID 和事件时间。

这里面有个工程细节:不是每一帧都要跑全流程。CPU 上检测加特征提取一帧可能要吃 50 毫秒以上,如果摄像头是 30 帧,队列里很快就会积压。常见做法是跳帧,比如每隔 3 帧才做一次完整推理;同时结果还要做去抖,连续两三次匹配到同一个人,才真正触发开门。单帧误检很常见,不做去抖的话,路过的人会让门不停地开。

4.2 注册模块:把“张三”变成一条数据库记录

注册的本质,是把某个人的人脸特征向量存进数据库。操作界面可以是 PyQt,也可以是命令行,核心逻辑都一样。下面的代码演示怎么把特征写入 SQLite,注意我选择用 BLOB 而不是文本格式存向量。

# register.py import sqlite3 import numpy as np def register_user(db_path, user_name, img_bgr, landmarks, embedder): embedding = embedder.embed(img_bgr, landmarks) # 已 L2 归一化,shape (128,) feature_blob = embedding.astype(np.float32).tobytes() # 512 字节 conn = sqlite3.connect(db_path) try: conn.execute( "INSERT INTO users (name, feature_blob) VALUES (?, ?) " "ON CONFLICT(name) DO UPDATE SET feature_blob=excluded.feature_blob", (user_name, feature_blob), ) conn.commit() finally: conn.close()

逻辑说明:embedder.embed 返回的是已经归一化的 128 维 NumPy 数组。tobytes 把它转成二进制字节串,这一步是关键。有人喜欢把向量转成 JSON 列表或者用逗号拼接字符串再存,读取的时候 float 精度已经损失了,而且每次比对都要做字符串解析,又慢又容易出错。

参数说明:ON CONFLICT(name) 子句解决重复注册的问题——同一个人录第二次,直接更新特征,不用先删再插。这里假定 name 是唯一键;如果系统里允许同名用户,可以改用身份证号或工号做唯一键,只需把字段换掉即可。

4.3 实时识别:用队列避免摄像头帧堆积

实时识别最容易犯的错是:摄像头线程里直接跑模型,一帧还没算完,下一帧就来了,画面越来越卡,最终看起来像死机。我一般用两个线程加两个队列来解耦。

# recognize.py import queue import threading import numpy as np frame_queue = queue.Queue(maxsize=2) # 帧队列,存不下就丢 result_queue = queue.Queue(maxsize=1) # 识别结果队列 def camera_loop(cap): while True: ok, frame = cap.read() if not ok: break if frame_queue.full(): try: frame_queue.get_nowait() # 丢掉旧帧,保证及时处理新帧 except queue.Empty: pass frame_queue.put(frame) def recognize_loop(detector, embedder, known_embs, names, threshold, frame_counter): while True: frame = frame_queue.get() frame_counter += 1 if frame_counter % 3 != 0: # 跳 2 帧做一次完整推理 continue boxes, lms = detector.detect(frame, box_threshold=0.8) for box, lm in zip(boxes, lms): if (box[2] - box[0]) < 40: # 人脸过小,检出来也认不准 continue query_emb = embedder.embed(frame, lm) dists = np.linalg.norm(known_embs - query_emb, axis=1) idx = int(np.argmin(dists)) if dists[idx] < threshold: result_queue.put(("open", names[idx], float(dists[idx])))

逻辑说明:camera_loop 只负责把帧塞进队列,塞不进去就丢旧帧,保证队列里永远是刚拍的画面。recognize_loop 从队列取帧推理,frame_counter 做跳帧控制。maxsize=2 是刻意的——队列太长没意义,模型处理速度跟不上,堆再多帧也只有被丢掉的命。检测阈值在这里提到 0.8,比基础演示的 0.5 更严格,因为门禁场景下误开门的代价远大于漏检一次。

参数说明:box[2] - box[0] 是检测框宽度,小于 40 像素的人脸在 160×160 的 FaceNet 输入里会被放大 4 倍以上,插值噪声会严重影响特征质量,所以直接跳过。known_embs 是启动时从数据库加载的注册特征矩阵,形状是 (N, 128),这样对比时 numpy 广播一次算出所有距离,比 for 循环快得多。

4.4 数据库表设计:两张表搞定人员与记录

门禁系统的数据量不大,SQLite 足够可靠,而且零配置,适合课程设计。真正要设计好的是表结构,两张表分工明确:users 存注册用户,records 存每次开门的记录。

CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, feature_blob BLOB NOT NULL, -- 128 * 4 = 512 字节的浮点向量 avatar_path TEXT, -- 登记照片路径,界面展示用 group_id INTEGER DEFAULT 0, -- 分组,比如“学生”“教师” created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE records ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, event_type TEXT DEFAULT 'open', event_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) );

字段说明:feature_blob 是 512 字节的二进制数据,从 NumPy 数组 tobytes 而来。读取时用 np.frombuffer(blob, dtype=np.float32) 还原成 128 维向量。avatar_path 建议存相对路径,不要把图片二进制塞进数据库,后期备份和读取都会变慢。records 表的 user_id 关联 users 表,查某个人的开门记录时一条 JOIN 就解决。

这里有个常见认知偏差:觉得特征比对应该用数据库的 SQL 去查。实际上 128 维向量的 k 近邻查询用 SQL 写非常别扭,而且门禁场景注册的人可能就几百个,内存里放一个 (N, 128) 的矩阵做暴力比对,耗时在毫秒级。所以识别时,把 users 表全部加载进内存,比对完再写记录,不需要数据库参与计算。

5. 避坑:人脸识别门禁系统最常见的六个翻车点

模型能跑、系统能开,这只是起点。真正让课程设计从“能演示”变成“能验收”的,是你踩没踩过下面这些坑。我把它们分成三组,每一组都是真实场景里反复出现的。

5.1 检测阶段的两个坑:报名照检不出,小脸截出来糊

坑一:录入照片用证件照,结果系统检测不到人脸。现象是纯色背景的登记照送入 RetinaFace,返回空结果。原因是这类照片人脸占比太大,而且边缘锐度高,网络训练数据里这种极近距离的“大头照”占比不高,加上预处理时如果直接缩放,会把原本清晰的五官磨糊。解决方法是先不要做任何缩放,把整张图放进检测网络,等检测框出来后再按框裁切;如果原图分辨率太大,比如 2000×3000,先用最近邻插值缩到 1200 宽再检测,避免压缩得太狠。

坑二:检测框裁出来的图送进 FaceNet,特征和注册时对不上。现象是同一个人现场刷脸连续三次被拒。原因是直接用框的坐标去裁原图,框边缘通常带着头发和耳朵,背景一变,特征也跟着变。解决方法是别用框裁剪,用 2.3 节里的五点关键点做仿射对齐后再裁。同一个道理,注册时也要走同样的对齐流程,保证训练和推理的数据分布一致。

5.2 特征与阈值的两个坑:阈值拿生活常识设,向量存成文本丢了精度

坑三:相似度阈值随手填了 0.5,结果系统对所有人都放行。这个坑在校验时表现得很隐蔽,因为演示时只有几个人测试,每次距离都在 0.3 以下,看不出问题。原因是没有区分度量的方向,余弦相似度是越大越像,你填 0.5 相当于“长得有三分像就放行”。解决方法是先跑一批正负样本对,打印出距离分布的直方图,再选一个能把两类明显分开的值。欧氏距离初值从 1.0 往上试,余弦相似度从 0.9 往下试,别拍脑袋。

坑四:向量存成 JSON 字符串,入库再读出来标准差了好大一截。现象是同一个人注册两次,两次特征的距离比对出来有 0.8,几乎认成两个人。原因是 JSON 序列化时 float32 被转成 float64,小数点后面多位被四舍五入,128 个维度累积起来误差就放大了。解决方法是原则上一律用 BLOB 存二进制,读出来用 np.frombuffer 还原,全程不经过字符串转换。这条血泪经验,只要你用 MySQL 或 SQLite 做毕设,早晚会遇到一次。

5.3 部署环境的两个坑:CPU 推理顶不住,打印照片刷开门禁

坑五:RetinaFace 加 FaceNet 的完整链路在 CPU 上跑不到 10 帧,演示时画面明显卡顿。原因很简单,两个 FP32 模型串行推理,单帧耗时超 100 毫秒,还没有任何跳帧控制。解决方法是先加跳帧,每 3 帧做一次推理;然后把 RetinaFace 输入从 640 降到 320;最后把 OpenCV 的帧率限制在 15 帧,画面反而看起来更流畅。如果你的硬件是 k10 行空板这类带 NPU 的板卡,优先把 FaceNet 放到 NPU 上跑,检测保留在 CPU,因为检测的输出是稀疏的,不受实时帧率约束。

坑六:拿一张打印的照片对着摄像头,门开了。这是人脸识别门禁系统设计里最致命的问题。原因是纯 2D 人脸特征只依赖纹理和几何,打印照片完全具备这些信息。解决方法的完整方案是加活体检测模块,最基础的做法是检测连续帧里眼睛是否眨眼或者头部是否转动,再高级一点用红外摄像头排除屏幕和纸张。做课程设计时,如果做不了红外,至少做一个简单的眨眼检测:在 RetinaFace 关键点基础上,连续几帧记录上下眼睑距离的变化,有明显波动才判定为活体。哪怕简单,答辩时也能讲清楚设计考量。

6. 让系统通过答辩的收尾实验:用 ROC 曲线定阈值

答辩老师最爱问的问题就是:“这个 1.0 的阈值怎么来的?”如果你回答“试出来的”,这题就悬了。正确做法是提前做一个阈值标定实验,把结果画成 ROC 曲线。具体操作分三步:

第一步,构造测试样本对。从注册库里挑 20 个人,每人拍 3 张不同角度的照片,两两组合得到同一人的正样本对;再从外部找 20 个未注册的人拍照片,和库里的每个人组合得到负样本对。正负样本对各至少 100 组,不用多,够画趋势就行。

第二步,把所有样本对的距离算出来。提前写一个脚本,把同一对图片两次送入 RetinaFace + FaceNet 提特征,记录欧氏距离,输出到 CSV。

第三步,遍历候选阈值。比如从 0.6 到 1.4,步长 0.05,对每个阈值统计误识率 FPR 和拒识率 TPR,画一条曲线。门禁系统关心的指标是误识率,也就是陌生人被放行的概率,一般要求低于 1%。在 ROC 曲线上找到 FPR 低于 1% 时对应的最大阈值,这个值就是你的最终参数。

我一般会把这个过程固定在验收脚本里,每次换摄像头或换光照环境就重跑一遍。阈值不是玄学,它是一张可以写进论文附录的对照表。用数据说话,而不是用“感觉”说话,是整套系统能不能让人信服的分界线。

做完阈值标定,再补一个活体检测最小实现,这套 FaceNet+RetinaFace 的人脸识别管理系统基本就完整了。我自己做这类项目时一直保留一个习惯:把每个阶段的阈值、帧率和识别距离记录在文档里,而不是只记住“能跑”。这份记录在后续换模型、调硬件时就是你最后的后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询