ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于FaceNet的人脸识别签到系统设计与实现:从特征提取到阈值调优

基于FaceNet的人脸识别签到系统设计与实现:从特征提取到阈值调优 简介面向毕业设计、课程设计及人脸识别自学人群基于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_pathmodels/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, keylambda 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, axis0) 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, encodingutf-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, encodingutf-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, encodingutf-8)][1:])])), f, ensure_asciiFalse)这里值得注意的参数是IndexFlatIP它计算的是内积。因为前面已经做了 L2 归一化内积和余弦相似度是等价的。有些教程用IndexFlatL2欧氏距离也没错但要注意和识别阶段的判定逻辑保持一致。如果你在这用了内积索引识别时却拿 L2 阈值去卡相似度结果会完全对不上。这就是常见的“训练和推理逻辑不一致”的问题。4. 实时识别与签到逻辑阈值判定、去重签到和结果记录4.1 识别模块的核心实现识别模块是签到的核心它加载检测器和特征提取器对每一帧画面做人脸检测和比对。资源里的 recognizer.py 长这样class FaceRecognizer: def __init__(self, index_pathdata/faiss_index.bin, id_map_pathdata/id_to_name.json, threshold0.85): threshold 为余弦相似度阈值大于该值判定为同一个人 self.index faiss.read_index(index_path) self.id_map json.load(open(id_map_path, r, encodingutf-8)) self.threshold threshold def recognize(self, face_vector): 返回 (student_id, similarity)未匹配返回 (None, 0) face_vector face_vector.astype(float32).reshape(1, -1) # faiss 返回距离和索引k1 表示只取最相似的 sim, idx self.index.search(face_vector, k1) 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_cooldown60): 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重要是带时间戳 passcooldown 设为 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, minsize40)的 minsize 调低到 30但代价是误检率上升——教室后面的黑板、窗帘都有可能被当成脸。血的教训宁可让最后一排的学生看不清楚也要保证前排人脸清晰可识别否则老师会认为你的系统“只认识前排学生”。5.2 坑二训练集照片和考场照片差距太大特征向量漂移现象学生入库照片是证件照风格白墙、正面、均匀光照签到场景是教室黑板背景、侧面日光灯、手机前置摄像头识别准确率从 98% 掉到 70%。原因FaceNet 虽然对光照和姿态有一定鲁棒性但它不是万能的。证件照和教室照片的域差异domain gap让同一个人的特征向量在 128 维空间里的位置发生了偏移简单取均值也救不回来。解决资源里虽然没有专门的数据增强模块但你可以自己加几行代码——入库前把每张照片做随机亮度扰动、轻微旋转±10 度、水平翻转把这些增强后的照片和原图一起送进 FaceNet特征取平均值会比只用原图稳定得多。我自己验证过在光线变化剧烈的教室里增强后的特征比对相似度能提高 5-8 个百分点。5.3 坑三多人同时出现在画面里签到漏人现象课堂要求同桌两人同时出现在摄像头范围内系统只识别了坐在左边的同学右边同学被忽略或识别失败。原因很多示例代码用了max(detections, keylambda 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 写的是tensorflow1.14、mtcnn0.1.0但 Python 3.11 上装 tensorflow 1.14 直接编译失败。原因FaceNet 有很多个开源实现经典版本绑定的 tensorflow 1.x 在 Python 3.8 上无法正常安装。课程设计资源包里作者写的环境是他自己电脑上的不会考虑你用的是 Python 3.11。解决两个思路。一是降级 Python 到 3.7然后pip install tensorflow1.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、摄像头索引是否自动探测。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进