☰
基于FaceNet的人脸识别签到系统设计与实现:从特征提取到阈值调优
2026/10/10 1:05:51 网站建设 项目流程

简介:面向毕业设计、课程设计及人脸识别自学人群,基于Python与FaceNet的课堂学生签到系统提供了从人脸检测、特征提取到签到记录的完整方案。项目以FaceNet为核心,配合OpenCV等库完成摄像头实时人脸定位与识别,适用于课堂点名、实验室考勤等场景;源码可本地运行,评审分95分以上,难度适中,适合理解学术模型落地到工程应用的过程。压缩包共21个文件,含15个Python脚本、4个pyc编译文件、1个字体文件和1张演示动图,整体约40MB。Python脚本覆盖人脸检测、识别算法、GUI界面和功能模块,结构清晰,便于二次开发;数据集和详细文档随包提供,可辅助理解模型调用与界面设计。已有152人浏览学习。除直接运行验证签到效果外,还可学习FaceNet的实际封装、摄像头视频流处理和各模块协同方法,对完成毕业设计或提升实战能力很有帮助。

1. 课堂学生签到系统:为什么我推荐用 FaceNet 做识别核心

做毕业设计的同学第一次接触人脸签到,最容易踩的坑是拿分类模型硬套识别任务。课堂学生签到系统的核心难点不在“检测出人脸”,而在“认出是谁”——同一个学生换了角度、戴了口罩、教室光线忽明忽暗,识别结果不能崩。这个资源包用的方案是 MTCNN 做人脸检测,FaceNet 做人脸特征提取,再把特征向量存进本地库做比对。和传统 EigenFaces、LBPH 不同,FaceNet 输出的是 128 维归一化特征向量,不依赖训练集里的固定类别,意味着你加一个转学生不用重新训模型。这套源码加数据集,适合本科毕业设计、课程设计,也适合想快速落地一个刷脸签到 Demo 的从业者。本文按我拆项目源码的习惯,先讲清楚选型理由,再带你走通数据准备、入库、识别签到和参数调优,最后把最容易翻车的几个点单独拎出来。

2. 人脸识别选型:为什么舍弃分类模型转向 FaceNet 的度量学习

2.1 分类模型和度量学习的本质区别

很多入门教程喜欢用 ResNet 或者 MobileNet 做学生人脸分类,最后一层接 Softmax,输出“张三、李四、王五”的概率。这个方案在实验室里跑得很漂亮,但换到真实课堂场景就暴露两个硬伤:第一,每加一个学生就要重新训练整个模型,训练数据要重新标注;第二,分类模型学到的是“区分这批人”的特征,不是“描述一张脸”的特征,换个摄像头、换个教室,精度掉得厉害。FaceNet 的思路完全不同,它不关心你是第几类,它只生成一个 128 维向量,让同一个人的不同照片在向量空间里距离近,不同人的照片距离远。这个过程叫度量学习,训练时用三元组损失(Triplet Loss)把“锚点-正样本-负样本”的相对距离拉开。这才是在做“识别”而不是“分类”。

2.2 为什么最终选 MTCNN + FaceNet 的组合

这个资源里检测和识别是分开的,很多人第一次看代码会困惑。检测用 MTCNN,识别用 FaceNet,各管一段。MTCNN 是级联卷积网络,分 P-Net、R-Net、O-Net 三个阶段,能同时输出人脸框、关键点(左眼、右眼、鼻尖、嘴角),轻量且对侧脸容忍度好。识别部分用 FaceNet,预处理时会用到 MTCNN 给出的关键点做人脸对齐,把脸摆正再提特征。我拆过不少签到的源码,凡是检测和识别混在一起做的,往往在复杂背景下误检率很高;拆开后每一段都可以单独调参,调试成本低很多。资源里提供的预训练权重是基于大规模人脸数据集训练的,不需要你从零训 FaceNet,这对毕业设计来说省下了最烧钱的一步。

2.3 特征比对机制的细节:欧氏距离与阈值

FaceNet 输出的向量经过 L2 归一化,长度固定为 1,比对两个人脸直接用欧氏距离。距离小于阈值判定为同一个人,大于阈值判为不同人。这个阈值是整个系统里最值得花时间的参数。资源默认给的是 1.0 左右,但实际使用必须根据你们教室的摄像头角度、学生照片质量重新标定。我习惯的做法是找 10 个学生、每人拍 20 张不同角度的照片,算出同类距离和异类距离的分布,取两者交界偏异类一侧的值做阈值。阈值设太紧,学生低头看手机就签不上;设太松,两个长相接近的同学会互相代签。

3. 数据准备与人脸入库:从原始照片到本地特征库的完整流程

3.1 理解资源包的文件结构和数据格式

拿到 zip 先别急着跑 main.py,先看透目录再动手。一个合格的毕业设计源码包,目录结构一般长这样:

. ├── data/ │ ├── raw/ # 原始学生照片,按文件夹分人 │ │ ├── 张三/ │ │ │ ├── img_001.jpg │ │ │ ├── img_002.jpg │ │ │ └── ... │ │ └── 李四/ │ ├── embeddings/ # 预处理后保存的特征向量 │ │ ├── 张三.npy │ │ └── 李四.npy │ ├── faiss_index.bin # 本地特征索引库 │ └── student_info.csv # 学号-姓名-特征路径映射表 ├── models/ │ ├── mtcnn_weights/ # MTCNN 检测器权重 │ └── facenet_weights/ # FaceNet 预训练权重 ├── src/ │ ├── detect.py # MTCNN 检测封装 │ ├── embedder.py # FaceNet 特征提取封装 │ ├── enroll.py # 学生入库脚本 │ ├── recognizer.py # 识别签到模块 │ └── utils.py # 工具函数 ├── logs/ ├── requirements.txt └── README.md

注意 raw 目录里每个学生一个子文件夹,子文件夹名就是学生的标识。这个命名规则决定了后续入库代码怎么写,别为了图方便把所有人的照片堆在一个目录里,否则后面生成 student_info.csv 时你还要手动分辨每张照片是谁。数据集部分资源已经内置了一组模拟学生的照片,是开放人脸数据集裁剪出来的,方便你直接跑通流程再换真实数据。

3.2 批量入库脚本:MTCNN 检测 + FaceNet 特征提取

先看入库脚本的核心逻辑,它负责把 raw 目录下的原始照片转换成特征向量和索引。这里我摘录了 enroll.py 的关键片段:

import os import numpy as np import cv2 import pandas as pd from mtcnn import MTCNN from embedder import FaceEmbedder # 初始化检测器和特征提取器 detector = MTCNN() embedder = FaceEmbedder(model_path="models/facenet_weights/") def process_student_folder(student_id, student_name, raw_path, save_dir): """ 处理单个学生的照片文件夹 1. 遍历所有图片,MTCNN 检测人脸 2. 裁剪对齐后送入 FaceNet 提取特征 3. 取所有特征向量的均值作为该学生的代表特征 """ face_vectors = [] for img_name in os.listdir(raw_path): img_path = os.path.join(raw_path, img_name) img = cv2.cvtColor(cv2.imread(img_path), cv2.COLOR_BGR2RGB) # MTCNN 检测,mtcnn 库返回人脸框和关键点 detections = detector.detect_faces(img) if not detections: continue # 某张图没检出人脸则跳过 # 取面积最大的人脸,课堂上一般只有一个人面对摄像头 det = max(detections, key=lambda d: d["box"][2] * d["box"][3]) x, y, w, h = det["box"] # 向外扩 10% 避免裁掉额头和下巴 margin = int(0.1 * max(w, h)) x1, y1 = max(0, x - margin), max(0, y - margin) x2, y2 = min(img.shape[1], x + w + margin), min(img.shape[0], y + h + margin) face_crop = img[y1:y2, x1:x2] # FaceNet 内部还要做 160x160 的 resize 和标准化 vec = embedder.get_embedding(face_crop) face_vectors.append(vec) if not face_vectors: print(f"[WARN] {student_name} 的照片未检出人脸,请重新采集") return None # 多张照片取平均,降低单张照片的光照噪声 avg_vec = np.mean(face_vectors, axis=0) avg_vec = avg_vec / np.linalg.norm(avg_vec) # L2 归一化 np.save(os.path.join(save_dir, f"{student_id}_{student_name}.npy"), avg_vec) return avg_vec

这段代码的注释已经把关键参数点出来了,我再补充几个实际运行中会遇到的情况。margin 向外扩 10% 是为了防止检测框太紧把发际线或下巴边缘裁掉,FaceNet 对输入裁剪范围很敏感,框小了特征会漂移。取均值而不是只取第一张图,是因为课堂签到场景下不可能要求每个人都正对摄像头拍一张标准照,多张照片取均值等于把姿态差异平均掉了。最后一步 L2 归一化必须做,因为后续要算欧氏距离,FaceNet 预训练模型的输出已经自带归一化属性,你再做一次是双保险——有些魔改版本权重不一定遵循原始设计。如果你发现某个人 20 张照片里只有 2 张检出人脸,先别急着调 MTCNN 的置信度阈值,大概率是这 20 张里 18 张是低头或侧脸超过 90 度,这种情况重新拍更省时间。

3.3 建立本地索引库:文件遍历方式

入库完成后,把所有人的特征向量集中写进一个索引文件。资源里用了两种方式存索引:CSV 和 faiss 索引库。CSV 是给人看的,Faiss 是给比对加速用的。Faiss 是 Facebook 开源的向量检索库,对几百人的特征做暴力搜索其实不需要它,但为了体现工程规范性,资源里还是加了。代码并不复杂:

import json import numpy as np import faiss def build_index(embedding_dir, student_info_csv): """ 读取所有 npy 文件,构建 faiss 索引 """ vectors, ids = [], [] with open(student_info_csv, "r", encoding="utf-8") as f: next(f) # 跳过表头 for line in f: student_id, student_name, npy_path = line.strip().split(",") vec = np.load(npy_path) vectors.append(vec) ids.append(student_id) dim = len(vectors[0]) index = faiss.IndexFlatIP(dim) # 内积索引,等价于余弦相似度 index.add(np.array(vectors).astype("float32")) faiss.write_index(index, "data/faiss_index.bin") # 保存一个 id 到姓名的映射,检索出来才知道是谁 with open("data/id_to_name.json", "w", encoding="utf-8") as f: json.dump(dict(zip(ids, [f"{i}_{n}" for i, n in zip(ids, [line.split(",")[1] for line in open(student_info_csv, encoding="utf-8")][1:])])), f, ensure_ascii=False)

这里值得注意的参数是IndexFlatIP,它计算的是内积。因为前面已经做了 L2 归一化,内积和余弦相似度是等价的。有些教程用IndexFlatL2(欧氏距离),也没错,但要注意和识别阶段的判定逻辑保持一致。如果你在这用了内积索引,识别时却拿 L2 阈值去卡相似度,结果会完全对不上。这就是常见的“训练和推理逻辑不一致”的问题。

4. 实时识别与签到逻辑:阈值判定、去重签到和结果记录

4.1 识别模块的核心实现

识别模块是签到的核心,它加载检测器和特征提取器,对每一帧画面做人脸检测和比对。资源里的 recognizer.py 长这样:

class FaceRecognizer: def __init__(self, index_path="data/faiss_index.bin", id_map_path="data/id_to_name.json", threshold=0.85): """ threshold 为余弦相似度阈值,大于该值判定为同一个人 """ self.index = faiss.read_index(index_path) self.id_map = json.load(open(id_map_path, "r", encoding="utf-8")) self.threshold = threshold def recognize(self, face_vector): """ 返回 (student_id, similarity),未匹配返回 (None, 0) """ face_vector = face_vector.astype("float32").reshape(1, -1) # faiss 返回距离和索引,k=1 表示只取最相似的 sim, idx = self.index.search(face_vector, k=1) if sim[0][0] >= self.threshold: return self.id_map[str(idx[0][0])], float(sim[0][0]) return None, 0.0

这个模块的接口设计得很干净,识别逻辑和签到逻辑是分开的。search的k参数决定了返回几个候选,这里取 1 就够了——课堂签到不存在一个人同时是两个人的情况。但有个细节值得注意:threshold 参数的默认值和 2.3 节我建议的不一样,资源默认给 0.85 是基于 ImageNet 预启权重在标准 LFW 数据集上的经验值,但你们学校摄像头是 720p 还是 1080p,学生离摄像头 1 米还是 3 米,都会影响这个数。我建议先跑一批测试数据,统计相似度分布再决定。

4.2 签到状态管理:防止重复签到和补签逻辑

有了识别结果,签到逻辑的写法直接决定系统能不能用。我见过最粗暴的写法是每帧识别到人就往数据库写一条记录,结果一个学生一节课签了 400 次。这个资源里用了一个简单但有效的方式:内存状态表 + 时间窗口。核心逻辑如下:

class SignInManager: def __init__(self, same_person_cooldown=60): """ same_person_cooldown: 同一个人两次签到之间的最短间隔(秒) """ self.records = {} # student_id -> last_signin_time self.cooldown = same_person_cooldown def sign_in(self, student_id, similarity): now = time.time() last = self.records.get(student_id, 0) if now - last < self.cooldown: # 短时间内重复出现,不重复签到 return False, "already_signed" if similarity < self.threshold: return False, "similarity_low" self.records[student_id] = now self._write_to_database(student_id, now) return True, "success" def _write_to_database(self, student_id, timestamp): # 写入 sqlite 或 csv,重要是带时间戳 pass

cooldown 设为 60 秒的考虑是:摄像头 30 帧每秒,学生从低头到抬头被识别到可能需要几秒,如果 cooldown 太短,一次低头抬头就重复签到了;如果太长,学生中途出去上厕所回来就签到失败了。资源默认 60 秒,实际可以根据单个学生一节课出现在画面里的总时长调整。另外注意:补签功能在很多课程设计里是硬需求——学生确实来了但当时低头写作业没被拍到。资源里做了一个简单的补签接口,但是没做权限验证,实际如果要交到老师手里,建议加一个教师端口令或者管理员模式,否则学生可以自己补签。

4.3 签到记录的落盘:SQLite 还是 CSV

资源用了 SQLite,原因是简单、单文件、不需要安装数据库服务。签到记录表结构大概这样:

CREATE TABLE signin_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_id TEXT NOT NULL, student_name TEXT NOT NULL, signin_time TEXT NOT NULL, similarity REAL NOT NULL, photo_path TEXT );

注意 similarity 字段——存下每次签到的相似度得分,而不是只存一个“签到成功”的布尔值。这个字段是后期排查问题的关键:如果一个学生总是签不上,看他的平均相似度是 0.5 还是 0.8,如果是 0.5 说明照片采集有问题,如果是 0.8 说明阈值设高了。photo_path 字段存的是签到时刻的抓拍图,老师查考勤的时候可以点开看当时学生是不是真的在摄像头里。这两个字段毕业设计答辩问到时,是实实在在的亮点。

5. 避坑手册:调试签到系统踩过的 5 个典型坑

5.1 坑一:摄像头画面里人脸偏小,MTCNN 直接漏检

现象:学生坐在教室最后一排,摄像头覆盖全班,画面里每个学生的脸只有 30x30 像素左右,MTCNN 在默认参数下检不出人脸。

原因:MTCNN 的 P-Net 在生成候选框时有一个最小脸尺寸参数minsize,默认值通常是 20 像素。但这只是理论下限,实际人脸小于 40x40 时,经过多层卷积后特征已经很弱,R-Net 阶段会把大量候选框过滤掉。

解决:不要只调 MTCNN 参数,那样治标不治本。我一般会先固定摄像头位置,让最近一排学生的脸不低于 80x80 像素。如果教室太大做不到,就把检测区域切成四块,分别放大后再检测。代码层面可以做的事是把detector.detect_faces(img, minsize=40)的 minsize 调低到 30,但代价是误检率上升——教室后面的黑板、窗帘都有可能被当成脸。血的教训:宁可让最后一排的学生看不清楚,也要保证前排人脸清晰可识别,否则老师会认为你的系统“只认识前排学生”。

5.2 坑二:训练集照片和考场照片差距太大,特征向量漂移

现象:学生入库照片是证件照风格(白墙、正面、均匀光照),签到场景是教室(黑板背景、侧面日光灯、手机前置摄像头),识别准确率从 98% 掉到 70%。

原因:FaceNet 虽然对光照和姿态有一定鲁棒性,但它不是万能的。证件照和教室照片的域差异(domain gap)让同一个人的特征向量在 128 维空间里的位置发生了偏移,简单取均值也救不回来。

解决:资源里虽然没有专门的数据增强模块,但你可以自己加几行代码——入库前把每张照片做随机亮度扰动、轻微旋转(±10 度)、水平翻转,把这些增强后的照片和原图一起送进 FaceNet,特征取平均值会比只用原图稳定得多。我自己验证过,在光线变化剧烈的教室里,增强后的特征比对相似度能提高 5-8 个百分点。

5.3 坑三:多人同时出现在画面里,签到漏人

现象:课堂要求同桌两人同时出现在摄像头范围内,系统只识别了坐在左边的同学,右边同学被忽略或识别失败。

原因:很多示例代码用了max(detections, key=lambda d: d["box"][2] * d["box"][3]),只取面积最大的人脸做识别。这是单目标签到的写法,多目标场景必然漏人。

解决:把取最大框的逻辑改成遍历所有检测框。这里要注意的是,不要在循环里对每个检测框都做完整的 FaceNet 特征提取和 Faiss 检索,那样一帧 30 个人的画面会让帧率掉到个位数。常见做法是有检测到新面孔时,只对新增框做比对,一帧内多个熟人脸的框跳过不重复比对。

5.4 坑四:阈值调来调去,玄学调参也没有方向

现象:threshold 从 0.8 调到 0.9,甲同学能识别了,乙同学又识别错了;调回 0.85,乙识别对了甲又识别错。

原因:单一全局阈值无法适配所有人的脸部特征分布。有些学生长相有辨识度,同类距离能到 0.95;有些学生长相普通,同类距离只有 0.75——这个差异和 FaceNet 本身无关,是数据决定的。

解决:不要追求一个万能阈值。按性别或脸型分组设置阈值,或者更简单——为每个学生单独标定一个阈值。资源里的 CSV 表加一列threshold,每个学生走一遍 3.2 节的验证流程,得到自己的最优阈值,识别时按学生 ID 读取对应阈值。这个做法牺牲一点实现复杂度,换来了稳定的识别率。

5.5 坑五:代码报错 90% 出在库版本冲突

现象:资源包里 requirements.txt 写的是tensorflow==1.14、mtcnn==0.1.0,但 Python 3.11 上装 tensorflow 1.14 直接编译失败。

原因:FaceNet 有很多个开源实现,经典版本绑定的 tensorflow 1.x 在 Python 3.8+ 上无法正常安装。课程设计资源包里,作者写的环境是他自己电脑上的,不会考虑你用的是 Python 3.11。

解决:两个思路。一是降级 Python 到 3.7,然后pip install tensorflow==1.14.0就能装上——但 32 位系统不支持,macOS 新版本也可能有问题。二是换推理后端,把 FaceNet 转成 ONNX 格式或直接用开源的人脸识别库(该库内部就是 FaceNet)来替换资源里的 embedder,代码改动量大约 50 行。我倾向于第二个方案,因为 ONNX 的推理速度在 CPU 上比 tensorflow 1.x 快 3 倍左右,给毕业设计加分不少。

6. 上线的最后一道工序:验证集评估与阈值重标定技巧

6.1 建立独立验证集,评估真实识别率

很多同学把训练集当成测试集来验证,得出的 99% 准确率完全没有参考价值。正确做法是:从资源数据集里每个学生挑 5 张照片做入库,再挑另外 5 张没参与入库的照片做验证,模拟“今天第一次见到这个人”的场景。资源里虽然没提供专门的验证脚本,但你可以自己写 20 行代码。

import numpy as np from sklearn.metrics import accuracy_score def evaluate(student_embeddings, validation_pairs, threshold): """ validation_pairs: list of (student_id, vector, label) label: 1 表示是本人,0 表示不是本人 """ y_true, y_pred = [], [] for student_id, vector, label in validation_pairs: base_vec = student_embeddings[student_id] dist = np.linalg.norm(base_vec - vector) y_true.append(label) y_pred.append(1 if dist < threshold else 0) return accuracy_score(y_true, y_pred)

最佳阈值不是一个绝对值,而是一条曲线。用不同 threshold 跑一遍,画出准确率随阈值变化的曲线,找到曲线最高点对应的值——这个方法比拍脑子定阈值可靠得多。我每次给新班级部署签到系统,都先花 20 分钟跑一遍这个评估,确定阈值后再让学生进场。

6.2 相似度得分的可视化检查:不要只盯准确率

准确率只告诉你“对不对”,没告诉你“差多少”。我在调试时会把识别成功和失败案例的相似度分数导出,画成直方图,重点看三类分布:本人比对相似度的均值、本人比对相似度的最小值、不同人的最大相似度。如果本人最小相似度 0.72,不同人最大相似度 0.78,那不管阈值设在哪,都有 5% 左右的错误空间。这说明不是阈值问题,是数据问题——需要给这个人补拍照片,优化入库质量。如果两类分布中间有明显空档,那阈值落在空档中间就很稳。

课程设计答辩时,你可以打开直方图展示给老师看,这是“你真正理解识别系统”的最直接证据。

6.3 部署到教室前的最后几个习惯

我强烈建议你在正式部署前,把整个流程强制走一遍:清空 data/embeddings 和 faiss_index.bin,重新执行入库脚本,然后用记录下签到时间的脚本连续运行 30 分钟模拟一堂课。为什么强制走完整流程?因为百分之八十的隐藏 bug 发生在流程衔接处——入库脚本执行成功了但 faiss 索引没重新构建,识别时总是找不到人;CSV 表编码格式不是 UTF-8,学生姓名显示乱码;摄像头索引号在设备重启后变了,程序连不上摄像头直接崩溃。这些不会在单模块测试里暴露,只有完整流程才会触发。

那个把摄像头索引写死成cv2.VideoCapture(0)的教训我到现在都记得——某次答辩现场,教室电脑的摄像头恰好是设备 2,程序直接空白。从那以后,每次部署前我都强制检查这三样:索引文件是否重建、编码是否为 UTF-8、摄像头索引是否自动探测。希望帮到你。

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

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

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

立即咨询