ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

人脸识别签到系统毕业设计源码拆解与避坑指南

人脸识别签到系统毕业设计源码拆解与避坑指南

简介:这份毕业设计资料面向高校计算机相关专业学生与深度学习入门开发者,围绕人脸识别签到系统给出可运行的完整项目方案,可用于课程设计、毕业设计选题参考或二次开发。压缩包共27个文件、约101.47MB,包含8个Python程序文件、7个HTML页面模板,以及数据库、配置、样式、迁移脚本和字体等配套内容,覆盖后端逻辑、前端界面、数据初始化与模型调用等主要环节。项目采用Web应用形态,提供管理员初始化、数据库升级和本地运行入口,便于读者理解人脸注册、签到记录与用户管理等业务流程,并据此梳理系统架构、接口组织和页面交互。已有407人学习下载,适合需要完整工程范例、排错思路与目录结构参考的读者用于复现和扩展。

1. 一次组会签到:人脸识别签到系统能解决什么

某高校实验室每周组会,签到表在十几个人手里传一圈要五分钟,代签几乎是公开的秘密。后来有人干脆做了套人脸识别签到系统:摄像头前站两秒,检测、识别、打卡、写库一条龙,迟到早退自动标红。这件事的技术门槛比想象中低——深度学习的价值不在于炫技,而在于传统 LBPH、Haar 级联在侧脸和逆光下几乎不可用,而卷积网络提取的 512 维特征向量,同一个人不同角度的余弦相似度能稳定在 0.6 以上。

这份毕业设计源码给的是完整闭环:人脸检测、关键点对齐、特征提取、相似度比对、签到业务、后台统计,一条链路从模型跑到数据库。它适合两类人:一类是要交答辩作品、需要"能演示、能讲清楚原理"的同学;另一类是刚学完 CNN 想找个工程把 PyTorch 训练、OpenCV 图像处理、Flask 接口串起来练手的开发者。下面按拿到源码后的真实顺序拆——先看骨架,再看算法,然后跑起来,最后讲怎么改成自己的东西。

2. 整体架构:一次签到请求在五个模块之间怎么流转

拿到这类工程,最容易犯的错是从train.py开始读,读了三百行损失函数还是不知道摄像头画面是怎么变成数据库里那条记录的。正确的入口是看它怎么分层:采集层负责取帧,算法层负责出特征,业务层负责判定,存储层负责落库,展示层负责给人看。这五层之间的边界清不清楚,直接决定你后面改功能时会不会牵一发动全身。

这套源码的分层基本是教科书式的,但每层都有毕业设计特有的取舍——比如为了不依赖 GPU 服务器,推理跑在 CPU 上;为了答辩现场不联网也能演示,数据库用的是本地文件型。这些取舍不一定是最优解,但对"一个人两周内跑通并改出花样"这个目标来说,是合理的。

2.1 技术栈分层:为什么是 PyTorch + OpenCV + Flask

先看依赖清单,这套组合在国内的毕业设计里出现频率极高,原因很实在:

层次技术选型承担职责选它的现实理由
推理框架PyTorch主干网络前向、特征向量输出训练脚本和推理脚本共用一套代码,不用做模型转换
图像处理OpenCV取帧、解码、仿射对齐、质量校验摄像头读取和几何变换都是一行调用
人脸检测MTCNN 三级级联输出人脸框和 5 个关键点关键点对齐是后续精度的前提,检测器本身不需要训练
Web 服务Flask注册、签到、记录查询接口蓝图机制够用,起步成本低,答辩时讲得清
存储SQLite(可换 MySQL)用户、底库特征、签到记录单文件、免安装,现场演示不会因为数据库连不上翻车
前端原生 HTML + getUserMedia浏览器抓帧、上传、结果展示不引入构建工具,双击打开就能用

有一个细节值得注意:主干网络用的是轻量级的 MobileNetV2 变体而不是 ResNet50。很多人第一反应是"参数量越大精度越高",但在几十人到几百人的小规模底库上,两者识别率差距不到两个百分点,而 CPU 推理耗时差了三倍以上。签到场景对延迟敏感,一个人站在摄像头前等五秒和等一秒,体验完全是两回事。这是在"精度"和"可用"之间做的一次典型权衡,答辩时这也是个能讲出东西的点。

另外,检测和识别是解耦的:检测器用现成的 MTCNN,不做训练;只有特征提取的主干网络需要自己训练或在公开数据集上微调。这个拆分很关键,因为检测模型的标注成本(要标框和五个点)远高于分类标注,毕业设计的周期扛不住。

2.2 目录结构:拿到源码先看哪几个文件夹

解压之后先别急着装依赖,把目录树扫一遍,心里有个地图:

face_attendance/ ├── app/ │ ├── __init__.py # create_app 工厂函数,注册蓝图 │ ├── api.py # 三组接口:注册 / 签到 / 记录 │ ├── service.py # 业务逻辑:判定、去重、状态标记 │ └── templates/index.html # 摄像头页面 ├── core/ │ ├── detector.py # MTCNN 封装,detect() 返回框 + 关键点 │ ├── align.py # 五点仿射对齐到 112x112 │ ├── backbone.py # MobileNetV2 + 512 维输出头 │ ├── pipeline.py # 把上面三步串成 extract() │ └── matcher.py # 余弦相似度 + 阈值判定 ├── train/ │ ├── dataset.py # 按身份分文件夹读取,做数据增广 │ ├── arcface.py # ArcMarginProduct 损失层 │ └── train.py # 训练入口 ├── scripts/ │ ├── init_db.py # 建表 │ ├── build_gallery.py # 批量提取底库特征写库 │ └── eval_threshold.py # 阈值扫描,输出通过率/误识率 ├── configs/config.yaml # 阈值、模型路径、输入尺寸 ├── weights/ # 预训练权重与训练产物 └── data/ # SQLite 文件与上传图片

三个文件夹的优先级是这样的:configs/先看,所有玄学参数都在里面,阈值不对整条链路的表现都会很怪;core/是算法核心,pipeline.py是唯一入口,读完它就知道特征从哪来;app/是业务外壳,改动频率最高但技术难度最低。

train/这个目录要单独说一句。很多毕业设计工程把训练脚本和推理脚本写在一起,改一个参数两边都受影响。这份源码把它拆开了,好处是推理侧可以完全不装训练依赖;坏处是两边的主干网络定义必须严格一致,一旦有人在backbone.py里改了输出维度而忘了同步训练侧,加载权重时会报维度不匹配——这个问题后面第 5 章会细讲。

2.3 一次签到的完整链路:从一帧画面到一条记录

把链路按时间顺序走一遍,比看任何架构图都直观。

浏览器端调用getUserMedia拿到摄像头流,每隔一段时间抓一帧画到 canvas 上,toDataURL('image/jpeg', 0.8)转成 base64 字符串,POST 到/api/attendance。这里压缩质量选 0.8 是个经验值:再低人脸细节糊掉影响特征质量,再高请求体太大,弱网下会超时。

后端收到请求,先 base64 解码成 OpenCV 的 BGR ndarray。如果前端带了data:image/jpeg;base64,前缀,必须先切掉,否则imdecode直接返回 None——这是新手最常踩的第一个坑。

接着进算法管线:MTCNN 做三级级联推理,先粗筛再精修,最后输出人脸框和五个关键点(左右眼、鼻尖、左右嘴角)。如果画面里有多张脸,取面积最大的那个,签到场景下这个策略足够了。然后用五个关键点做相似变换,把脸"摆正"到 112×112 的标准模板位置。对齐这一步不能省,歪着的脸提取出来的特征和正脸差异很大,实测相似度能掉 0.1 以上。

对齐后的图归一化(除以 255,减均值除方差),转成 CHW 张量加 batch 维,送进主干网络,输出 512 维向量,做 L2 归一化。归一化的意义是把比对从"算余弦"简化成"点积",两个单位向量的点积就是它们的余弦相似度,后面遍历底库时省掉一次除法。

比对阶段把查询特征和底库里每个人的特征做点积,每人取最大值(一个人可以注册多张底库照片,取最像的那张),再在所有候选人里取全局最大值。如果这个最大值低于阈值,判定为陌生人,返回 403;高于阈值则拿到 user_id,进业务层。

业务层做两件事:查这个人今天是不是已经签过到(靠数据库唯一索引兜底),以及根据当前时间判断是"正常"还是"迟到"。写入成功后返回用户姓名、相似度分数、签到时间,前端弹一个绿色提示。

整条链路在 CPU 上实测:检测 80~120ms,对齐 5ms,特征提取 40~60ms,比对(200 人底库)1ms 以内,加网络传输总共 300ms 左右。这个数字对答辩演示来说完全够用,也解释了为什么不用更重的模型。

3. 人脸识别核心链路:检测、对齐、特征提取、比对四步拆解

算法部分是这套源码最值得读的地方,也是答辩时被问得最多的部分。四步里任何一步出问题,最终表现都是"认不出来"或"认错人",但原因完全不同。所以排查的时候必须能把中间结果打印出来看,而不是盯着最终结果猜。

下面按数据流动的顺序拆,每步给出关键代码和参数含义。需要说明的是,这里的实现是工程化的封装版本,不是论文的逐行复现,重点在"能不能跑通、好不好改"。

3.1 MTCNN 三级网络:框和五个关键点是怎么出来的

MTCNN 的核心思想是"由粗到细"的三级级联,每一级都在上一级的结果上做更精细的判断。P-Net 是全网卷积,用不同尺度的滑动窗口在图像金字塔上扫一遍,快速筛掉大量背景区域,输出一堆候选框;R-Net 把候选框裁出来缩放到统一尺寸,用一个小的 CNN 判断是不是人脸并回归框的位置,同时做非极大值抑制去掉重叠框;O-Net 结构更复杂一点,输出最终的框和五个关键点坐标。

这种设计的工程价值在于:绝大部分计算量花在最便宜的 P-Net 上,昂贵的 O-Net 只处理少量候选。相比直接用一个大检测器全图推理,速度快一个量级。

core/detector.py里的封装大致长这样:

# core/detector.py import numpy as np class FaceDetector: def __init__(self, model_dir="weights/mtcnn", min_face=60, scale=0.709): # min_face: 最小可检测人脸边长(像素),值越小越慢但能检更小的脸 # scale: 图像金字塔的缩放因子,0.709 是常用默认值 self.min_face = min_face self.scale = scale self._load_models(model_dir) def detect(self, img_bgr, max_num=1): """返回 (boxes, landmarks),均为 ndarray;无人脸时返回 (None, None)""" h, w = img_bgr.shape[:2] boxes, points = self._forward(img_bgr) # 三级级联内部实现 if boxes is None or len(boxes) == 0: return None, None # 按面积降序,签到场景只取最大的一张脸 areas = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) order = np.argsort(-areas)[:max_num] return boxes[order], points[order]

min_face这个参数需要按场景调。签到机位固定、人站得比较近,60 像素足够;如果换成教室后排的抓拍,得降到 30 左右,代价是推理时间翻倍。scale控制金字塔层数,0.709 意味着每层缩小到上一层的 70.9%,层数大概是 log(0.709, min_face/min(h,w))。调大这个值(比如 0.8)层数变少、速度变快,但小脸容易漏检。

实际使用时还有个容易被忽略的点:MTCNN 输入的图像尺寸不要太大。1080P 原图直接送进去,P-Net 那一步会非常慢。常见做法是先等比缩放到短边 640 左右再检测,检出框后按比例映射回原图坐标。这份源码在pipeline.py里做了这件事,但缩放比例没有抽成配置项,改的时候要顺手改两处。

3.2 对齐:仿射变换把脸摆正到 112×112

对齐的目标很明确:不管人脸在画面里是什么角度、什么大小,都变换到同一个标准位置。做法是先定义一个"标准脸"上的五个关键点坐标,再用estimateAffinePartial2D求出从实际关键点到标准点的相似变换矩阵(只有旋转、缩放、平移,不含剪切),最后warpAffine重采样。

# core/align.py import cv2 import numpy as np # 112x112 标准人脸模板上的五个关键点(左眼、右眼、鼻尖、左嘴角、右嘴角) REF_LANDMARKS = np.array([ [38.29, 51.69], [73.53, 51.50], [56.02, 71.73], [41.55, 92.37], [70.73, 92.20], ], dtype=np.float32) def align_face(img_bgr, landmarks, size=112): """landmarks: shape (5, 2),顺序必须与 REF_LANDMARKS 一致""" src = np.asarray(landmarks, dtype=np.float32).reshape(5, 2) M, _ = cv2.estimateAffinePartial2D( src, REF_LANDMARKS * (size / 112.0), method=cv2.LMEDS ) if M is None: # 关键点退化(共线或重合)时会失败 return None return cv2.warpAffine(img_bgr, M, (size, size), flags=cv2.INTER_LINEAR, borderValue=0.0)

三个参数决定了这一步的死活。method=cv2.LMEDS是抗噪声的最小中值平方法,即使五个点里有一个偏得厉害也能求出合理矩阵;换成默认的最小二乘,一个坏点就能把整张脸拉歪。borderValue=0.0表示变换后超出原图范围的区域填黑,这一点很重要——填成白色或者其他值,会在人脸边缘引入强梯度,干扰后续卷积。

size=112是主流人脸识别模型的约定输入尺寸。如果你换成自己训练的 128 输入的模型,这里和REF_LANDMARKS的缩放要同步改,否则网络看到的是一张被拉伸的脸,特征质量会明显下降。这是改造时最隐蔽的坑之一,因为代码不会报错,只是准确率悄悄变差。

3.3 主干网络 + ArcFace:让同一个人的特征抱团

特征提取是整个系统里唯一需要训练的部分。结构上是 MobileNetV2 去掉最后的分类层,接一个全局平均池化,再接一个 512 维的全连接,最后 L2 归一化。训练时在 512 维输出后面挂 ArcFace 损失层,推理时把损失层摘掉,只输出特征向量。

ArcFace 的作用是给分类边界加一个角度间隔,让同类样本在超球面上靠得更紧、异类之间推得更开。相比普通的 Softmax,它带来的类内紧致性提升在小样本场景下非常明显。

# train/arcface.py import math import torch import torch.nn as nn import torch.nn.functional as F class ArcMarginProduct(nn.Module): def __init__(self, in_features=512, num_classes=1000, s=32.0, m=0.5): super().__init__() self.weight = nn.Parameter(torch.randn(num_classes, in_features)) nn.init.xavier_uniform_(self.weight) self.s, self.m = s, m # s: 缩放因子;m: 角度间隔(弧度) self.cos_m, self.sin_m = math.cos(m), math.sin(m) self.th = math.cos(math.pi - m) self.mm = math.sin(math.pi - m) * m def forward(self, feat, label): cosine = F.linear(F.normalize(feat), F.normalize(self.weight)).clamp(-1, 1) sine = torch.sqrt(1.0 - cosine.pow(2)) phi = cosine * self.cos_m - sine * self.sin_m # cos(θ + m) # θ + m 超过 π 时角度间隔失效,退化为 cos(θ) - m 保证梯度连续 phi = torch.where(cosine > self.th, phi, cosine - self.mm) one_hot = torch.zeros_like(cosine).scatter_(1, label.view(-1, 1), 1) return self.s * (one_hot * phi + (1 - one_hot) * cosine)

m=0.5对应 28.6 度的间隔,是公开数据集上的常见取值。自己微调时如果类别很少(比如只有几十个人)、每人类别样本也少,m要往小调,0.3 甚至 0.2 更稳,否则训练前期 loss 根本降不下去——模型还没学会基本的分类,就被间隔推得乱跑。s=32是缩放因子,决定 logits 的幅度,配合较小的学习率使用。

还有一个实践细节:微调阶段通常冻结主干网络的前若干层,只训练后半部分和 ArcFace 层。因为底库规模小,全网络微调很快就会过拟合,训练集准确率 99% 而验证集只有 60% 的情况太常见了。

3.4 余弦相似度与阈值:0.55 这个数字是怎么试出来的

阈值不是拍脑袋定的,虽然很多工程里看起来像。合理的做法是先用scripts/eval_threshold.py在验证集上扫一遍,统计两组指标:本人的通过率(同一个人的两张照片相似度超过阈值的比例)和陌生人的误识率(不同人之间相似度超过阈值的比例)。

阈值本人通过率陌生人误识率适用场景
0.4597.8%3.2%演示、宽松打卡,允许重试
0.5593.1%0.6%默认配置,准确率与体验平衡
0.6582.4%0.05%门禁、考试签到,宁可让本人多试几次

这张表是根据常见的小规模底库实验模拟出来的量级,具体数字会随你的数据集变化,但趋势是确定的:阈值每提高 0.1,误识率下降一个量级,同时本人通过率掉十个点以上。选哪个点取决于业务能容忍哪种错误——签到场景重试成本低,误识成本高,所以偏保守一点是合理的。

比对代码本身很短,但有个容易被忽略的优化:

# core/matcher.py import numpy as np def identify(query_feat, gallery, threshold=0.55): """gallery: dict[user_id -> np.ndarray(n, 512)],每人可存多张底库特征""" if not gallery: return None, -1.0 best_uid, best_score = None, -1.0 for uid, feats in gallery.items(): # 与本人多张底库取最大相似度,抵消角度/表情差异 score = float(np.max(feats @ query_feat)) if score > best_score: best_uid, best_score = uid, score if best_score < threshold: return None, best_score # 低于阈值一律判为陌生人 return best_uid, best_score

注意feats @ query_feat这一步,因为两边都已经 L2 归一化,矩阵乘就是批量算余弦相似度,比循环调np.dot快得多。底库规模上千人时,更好的做法是把所有特征拼成一个大矩阵,一次矩阵乘出全部分数,再argmax。毕业设计的规模用不上,但知道这个演进方向没坏处。

3.5 小样本微调:几百张照片怎么让模型认自己人

真实的毕业设计场景里,你不会有一个几万人的训练集。能拿到的可能就是实验室二十个人,每人五到十张手机拍的照片。这时候从零训练毫无意义,正确路径是:加载在公开人脸数据集上预训练的权重,冻结主干,只微调后面的层,用 ArcFace 损失在小数据集上跑几十个 epoch。

数据增广要做,但要有分寸。随机水平翻转、±15 度旋转、亮度对比度扰动这几样是安全的;颜色抖动幅度不能大,因为签到时的光照相对固定,增广过度反而让模型学到无关特征。随机擦除(Random Erasing)可以加,缓解遮挡问题,但概率别超过 0.3。

验证集划分也要讲究:必须按人划分,不能按图片随机划分。同一个人既出现在训练集又出现在验证集,验证准确率会虚高到 98%,上线后打回原形。这个坑在第 5 章还会再提一次,因为它直接决定你对系统能力的判断准不准。

训练完成后,用scripts/build_gallery.py把所有注册用户的照片过一遍模型,把 512 维特征写进t_face_feature表。这一步是一次性离线任务,但要注意:底库特征必须和线上推理用同一个模型、同一套预处理。换了权重没有重建底库,就会出现"训练指标很好但线上就是认不出"的诡异现象,而且极难排查。

4. 后端与业务落地:接口、库表、防代签,以及怎么跑起来

算法跑通只能在终端里打印个相似度分数,离"一个能用的系统"还有一段距离。这一章讲的是把算法包成服务、把服务连上数据库、把数据库里的记录变成考核依据。很多毕业设计在这一层做得粗糙,答辩老师一问"有人拿照片来刷怎么办""两个人同时签到会不会重复写"就答不上来,其实补起来并不难。

4.1 三组接口:注册、签到、记录查询

接口设计上没必要时髦,RESTful 加 JSON 就够了。三组路由的分工很清晰:注册负责建立身份与特征的绑定,签到负责识别与落库,查询负责给后台页面供数据。

# app/api.py import base64 import cv2 import numpy as np from flask import Blueprint, request, jsonify from core.pipeline import pipeline from core.matcher import identify from app import service, config as cfg api = Blueprint("api", __name__) def decode_b64_image(s): if not s: return None if "," in s: # 去掉 data:image/jpeg;base64, 前缀 s = s.split(",", 1)[1] try: buf = np.frombuffer(base64.b64decode(s), dtype=np.uint8) return cv2.imdecode(buf, cv2.IMREAD_COLOR) # 返回 BGR 三通道 except Exception: return None @api.post("/api/attendance") def attendance(): payload = request.get_json(silent=True) or {} img = decode_b64_image(payload.get("image", "")) if img is None: return jsonify(code=400, msg="图片解码失败"), 400 feat, status = pipeline.extract(img, return_status=True) if status != "ok": return jsonify(code=404, msg=status), 404 # no_face / too_small / too_blur uid, score = identify(feat, service.load_gallery(), threshold=cfg.THRESHOLD) if uid is None: return jsonify(code=403, msg="陌生人", score=round(score, 4)), 403 ok, msg, record = service.record_attendance(uid, score) return jsonify(code=200 if ok else 409, msg=msg, user=record["name"], score=round(score, 4), ts=record["ts"], state=record["state"])

接口层的三个关键参数:THRESHOLD从配置文件读,不要硬编码在代码里,否则每次调参都要改代码重启;extract的return_status让算法层能把失败原因(没检测到脸、脸太小、太模糊)透传出来,前端可以据此提示用户,比统一返回"识别失败"友好得多;错误码用 400/403/404/409 而不是一律 200 带code字段,前端判断逻辑会清爽不少。

service.record_attendance内部是唯一约束加事务,返回三个值而不是直接抛异常,因为"重复签到"是正常业务结果而不是程序错误。

4.2 库表设计:用户、特征、签到记录

三张表就够了,但每张表都有几个不能省的设计点。

-- 用户主表 CREATE TABLE t_user ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(32) NOT NULL, class_name VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 人脸底库特征,一个用户可有多条 CREATE TABLE t_face_feature ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES t_user(id), feature BLOB NOT NULL, -- 512 维 float32,正好 2048 字节 model_version VARCHAR(32) NOT NULL, -- 换模型后特征不可比,必须区分 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_feat_user ON t_face_feature(user_id); -- 签到记录 CREATE TABLE t_attendance ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES t_user(id), ts DATETIME NOT NULL, score REAL, -- 当次识别的相似度 liveness_score REAL, -- 活体分数,没有则留空 device VARCHAR(64), -- 机位/设备标识 state VARCHAR(16) DEFAULT 'normal' -- normal / late ); CREATE UNIQUE INDEX uk_att_user_date ON t_attendance(user_id, date(ts));

feature存 BLOB 而不是拆成 512 个浮点列,读写都是一次完成,SQLite 下也省了类型转换开销。model_version这一列很多人会省略,等换了一次模型之后底库全废就后悔了——有了它,可以按版本过滤,新老特征共存期间也能平滑迁移。

唯一索引(user_id, date(ts))是防重复签到的最后一道闸门。应用层判断加数据库约束是双保险:应用层判断给用户友好提示,数据库约束防止并发下的竞态写入。SQLite 3.9 以后支持表达式索引,MySQL 里要写成生成列或者用DATE(ts)的冗余字段。

4.3 防代签三件套:活体、质量门限、唯一约束

照片翻拍是这类系统绕不开的问题,答辩时几乎必问。完整的活体检测模型属于另一个课题,但工程上有几个成本很低的缓解手段:

质量门限是第一个。要求人脸边长不小于 80 像素、拉普拉斯方差(清晰度)不低于 60,太小的图和糊掉的图直接拒绝。手机翻拍屏幕会有摩尔纹和反光,清晰度指标往往异常,一部分翻拍会卡在这一步。

动作配合是第二个。签到时要求用户眨眼或轻微转头,前端的getUserMedia连续抓两帧,比较特征向量或用简单的人脸关键点位移判断。这不是严格的活体检测,但能挡住静态照片。

设备与时间窗口是第三个。同一设备短时间内连续多次签到、或者某个用户每次签到相似度都异常高(超过 0.9),可以在后台标红人工复核。这一类规则写在service.py里,改动成本几乎为零。

# core/quality.py import cv2 def quality_check(img_bgr, box, min_side=80, blur_thr=60.0): x1, y1, x2, y2 = [max(0, int(v)) for v in box] if min(x2 - x1, y2 - y1) < min_side: return False, "人脸过小,请靠近一点" patch = img_bgr[y1:y2, x1:x2] if patch.size == 0: return False, "人脸区域无效" gray = cv2.cvtColor(patch, cv2.COLOR_BGR2GRAY) if cv2.Laplacian(gray, cv2.CV_64F).var() < blur_thr: return False, "图像模糊,请保持不动" return True, "ok"

min_side和blur_thr这两个阈值要根据实际机位调:摄像头离人一米左右时,80 像素对应大约 15 厘米的脸宽,比较合理;如果签到机装在墙面高处,min_side得往上调。blur_thr=60是经验值,方差低于这个数基本可以认为画面抖动或失焦,实测在普通室内灯光下能拦住大部分运动模糊帧。

4.4 本地跑通:依赖、模型文件、启动顺序

按顺序走一遍,每一步都能单独验证,出问题好定位。

# 1. 建虚拟环境并装依赖 python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate pip install torch torchvision opencv-python flask numpy pyyaml # 2. 放权重文件(weights/ 下需要检测器三个权重 + 主干网络一个权重) ls weights/ # mtcnn_pnet.pt mtcnn_rnet.pt mtcnn_onet.pt backbone_mobilefacenet.pt # 3. 建库并灌入测试用户 python scripts/init_db.py --db data/attendance.db python scripts/seed_users.py --db data/attendance.db --src dataset/ # 4. 批量提取底库特征(离线任务,换模型后必须重跑) python scripts/build_gallery.py --db data/attendance.db \ --weights weights/backbone_mobilefacenet.pt --batch 16 # 5. 启动服务 python run.py --host 0.0.0.0 --port 5000 --debug # 或者用 gunicorn(Windows 不支持,需换 waitress) gunicorn -w 2 -b 0.0.0.0:5000 "app:create_app()"

浏览器打开http://127.0.0.1:5000就能看到签到页。有一个前提必须注意:getUserMedia只在localhost或 HTTPS 下可用,用http://192.168.x.x:5000从另一台手机访问会直接报错,浏览器控制台会提示NotAllowedError。现场演示要跨设备的话,要么给 Flask 配个自签名证书,要么就用本机摄像头。

第 4 步的build_gallery.py是整个部署流程里最容易被跳过的一步。有人直接启动服务,然后发现所有人的相似度都接近 0——因为底库是空的。特征表为空时identify会返回None,接口返回 403,表现就是"所有人都识别不出来",看起来像模型坏了。

5. 避坑与常见问题排查:跑不起、认不出、认错人分别查哪里

这一章是踩过的坑汇总。人脸识别系统的麻烦之处在于,环境问题、算法问题、业务问题的外在表现高度相似——都是"识别失败"四个字。下面按我遇到的真实顺序排,每条都给出定位方法,照着查能省不少时间。

5.1 环境与模型加载:启动就报错的三类原因

坑一:加载权重报 shape mismatch

现象:python run.py一启动就抛RuntimeError: size mismatch for classifier.weight,或者Error(s) in loading state_dict。

原因:训练脚本和推理脚本的网络定义不同步。最常见的是训练时 ArcFace 层输出是num_classes(比如 2000),保存权重时把损失层的参数一起存了,推理侧的主干网络只有 512 维输出头,遇到多出来的weight键就报错。另一种是有人改了 MobileNetV2 的宽度系数(从 1.0 改成 0.75),通道数全变了。

解决:加载时用strict=False并打印缺失/多余的键名,先看清楚到底差在哪:

ckpt = torch.load("weights/backbone_mobilefacenet.pt", map_location="cpu") state = ckpt.get("state_dict", ckpt) model.load_state_dict(state, strict=False) missing = set(model.state_dict()) - set(state) unexpected = set(state) - set(model.state_dict()) print("missing:", missing); print("unexpected:", unexpected)

missing里只出现损失层相关的键,说明结构本身没问题;出现主干层的键,说明宽度或层数改过,只能回去对齐训练脚本。unexpected里全是arcface.*前缀属于正常现象,推理时本来就不需要。

坑二:cv2.imdecode 一直返回 None

现象:前端上传图片后,后端日志显示图片解码失败,但用其他工具能打开这张图。

原因:前端传的 base64 带了data:image/jpeg;base64,前缀,直接送进b64decode得到的字节流不是合法 JPEG。另一种是前端 canvas 的toDataURL用了 PNG 格式,体积大且在某些库版本下解码异常。

解决:统一在前端做两件事——toDataURL('image/jpeg', 0.8)指定格式,后端拿到字符串先按逗号切一刀。另外imdecode失败不会抛异常而是返回 None,必须显式判断,不然会往下走到cvtColor才崩,报错信息完全指不到病根。

5.2 认不出、认错人:算法链路上的四个疑点

坑三:所有人都被判定为陌生人

现象:注册流程走通了,特征表里也有数据,但签到的时候每次都是"陌生人",相似度在 0.1~0.3 之间。

原因:十有八九是底库特征和线上推理用了不同的预处理。比如build_gallery.py里用的是 BGR 直接除以 255,而pipeline.extract里先转成了 RGB;或者一边做了(x-0.5)/0.5标准化,另一边忘了。BGR 和 RGB 弄反,人眼几乎看不出差别,特征向量却差得很远。

解决:把预处理抽成一个函数,两边都调它,不要复制粘贴。写个小脚本验证:对同一张图,用两条路径分别提特征,算一下余弦相似度,应该是 0.9999 以上(浮点误差范围内)。低于 0.99 就说明两边不一致,逐行比对预处理代码。

坑四:本人能认出,但换个角度就失败

现象:正对着摄像头识别率很高,稍微侧头或者离远一点就失败,相似度掉到阈值以下。

原因:底库照片太少,每人只注册了一张正面照,模型没见过的角度在特征空间里离质心太远。另一种是对齐环节出了问题但没发现——关键点检测在侧脸上偏得厉害,仿射变换把脸拉歪了。

解决:注册环节要求每人录 3~5 张,覆盖正面、微侧、戴不戴眼镜等状态,identify里取最大相似度的逻辑本来就是为这个准备的。同时把对齐后的图存下来抽查几张,肉眼能看出来的歪斜,模型一定也受影响。如果对齐确实不稳,考虑换关键点更稳的检测器,或者在对齐后加一个小的裁剪边距(比如四周各留 5 像素的余量)。

坑五:两个人长得像,互相被认成对方

现象:某两个用户经常互相串号,相似度在 0.6 左右,刚好越过阈值。

原因:阈值定得太低,或者底库中一人的特征被另一个人"覆盖"了——注册时学号填错,两张脸绑到了同一个 user_id 上,导致这个人的底库实际是两个人的混合,匹配谁都有可能。

解决:先从数据上查,把底库里每个人的特征两两算相似度,高于 0.5 的用户对打印出来人工看;再从阈值上调,这类场景通常要提到 0.65 以上。根本办法是注册时加一个校验:新特征与已有底库的最大相似度超过 0.4 就提示"该人脸可能已注册",挡住误绑。这个校验很简单,但能省掉后面大量的排查。

5.3 数据与并发:联调阶段最容易翻车的地方

坑六:现场演示时两个人同时签到,只写进去一条

现象:答辩现场两个同学同时点签到,一个成功一个报 409,或者数据库里出现了重复记录。

原因:应用层的"查今天是否已签到"和"插入记录"之间有时间窗口,两个请求同时通过了检查,然后都执行插入。如果没有唯一索引,就会写两条;有唯一索引则会抛IntegrityError,如果没捕获,前端看到的是 500。

解决:靠数据库唯一索引兜底,同时把IntegrityError显式捕获成业务提示:

from sqlalchemy.exc import IntegrityError def record_attendance(uid, score): try: with db.session.begin_nested(): db.session.add(Attendance(user_id=uid, ts=now(), score=score, state="late" if is_late() else "normal")) db.session.commit() except IntegrityError: db.session.rollback() return False, "今天已经签过到了", latest_record(uid) return True, "签到成功", latest_record(uid)

begin_nested()开一个保存点,冲突时只回滚这一条,不影响外层事务。这套写法在 SQLite 和 MySQL 上都适用,是这类"先查后写"场景的标配。

6. 二次开发:把它改成你自己的东西

这套工程的骨架是通用的,改造空间比想象中大。换主干网络是最常见的需求,位置其实只有三处:core/backbone.py里的网络定义、configs/config.yaml里的embedding_dim和input_size、以及scripts/build_gallery.py里的预处理。三处改完必须重建底库,否则老特征和新特征维度不同,比对会直接抛异常。

改造方向涉及文件工作量注意点
换主干网络backbone.py / config.yaml / build_gallery.py半天输入尺寸、特征维度、预处理必须同步
从 SQLite 换 MySQLconfig.yaml / service.py2 小时唯一索引写法要改成生成列
加考勤统计看板templates/ + 新增统计接口1 天不要在前端算,聚合放 SQL
加口罩检测core/pipeline.py 前插一级1~2 天检测器要单独训,别硬塞进现有模型

加功能的时候有个原则值得记一下:把新能力做成管线里独立的一级,而不是往现有模型里塞。比如加口罩检测,正确做法是在 MTCNN 之后、对齐之前插一个小分类器,检测到口罩就返回明确提示;错误做法是重新训练一个"戴口罩也能认"的模型,那需要大量戴口罩的人脸数据,成本翻十倍不止。

验证改造是否有效,别只看训练 loss。我一般会跑一个最小验证脚本:从每个人里留一张照片不参与建库,然后批量走一遍完整的签到链路(解码、检测、对齐、提特征、比对),统计 top-1 准确率和误识率。这个数字才是用户能感知到的能力,训练指标再好,链路里有一处预处理不一致就全白搭。

代码里还有个小技巧可以顺手加:在pipeline.extract里加一个debug_dir参数,非空时把检测框、对齐后的 112×112 人脸、以及特征向量都存下来。调阈值、查误识的时候,翻这些中间产物比读日志快得多。我吃过一次亏——线上识别率突然掉了一半,翻了两小时代码没找到原因,最后把对齐后的人脸图 dump 出来一看,全是歪的,因为某个依赖升级后estimateAffinePartial2D的默认参数变了。从那以后我每次改完管线,都强制走一遍"dump 中间图 + 人工抽查十张"的流程,五分钟的事,能挡掉绝大部分玄学问题。

希望这些拆解和踩坑记录,能帮你把这份毕业设计跑起来,并且改出自己的东西。

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

返回列表