简介:这是一套面向深度学习入门者与课程设计需求者的CNN人脸识别考勤系统完整项目,包含可直接运行的预训练模型,帮助读者跳过繁琐训练环节,快速搭建并验证人脸考勤流程。压缩包共4848个文件,以4835张jpg人脸图像构成训练与测试数据集,另有8个py脚本负责模型加载、识别与考勤逻辑,配合1个h5权重文件、1个ui界面文件及xml、md等配置说明,整体约108.06MB,目录结构清晰,便于按数据、代码、模型分层查阅。目前已有310人学习下载。资源覆盖图像采集、预处理、特征提取、人脸匹配到考勤记录生成的完整链路,读者可基于预训练模型进行微调与二次开发,理解卷积层、池化层与全连接层在人脸识别中的作用,并参考数据增强、批量归一化等优化思路提升泛化能力与实时性,适合作为毕业设计、课程作业或工程原型的实践起点。
1. 基于CNN的人脸识别考勤系统:预训练模型拿到手,怎么让它当天跑起来
公司行政抱来一台旧工控机,说想给车间装个刷脸打卡,预算零,工期一周。这种需求听起来像开玩笑,但真做过就知道,基于CNN的人脸识别考勤系统配上预训练模型,是少数能在这种条件下落地的方案。核心逻辑不复杂:CNN卷积神经网络负责把一张人脸图压成一个高维向量,考勤系统拿这个向量和库里已注册的向量做比对,距离够近就算同一个人,记一条打卡时间。预训练模型的价值在于,它已经在海量人脸数据上学会了"怎么描述一张脸",你不需要从零训练,只要拿它当特征提取器,再补一个轻量的比对和存储层就能跑。适合谁?适合想快速验证刷脸考勤可行性、手里只有普通摄像头和一台能装Python的机器的团队。这一章先把整条链路讲清楚,后面几章拆实现、参数和坑。
2. 预训练模型选型与CNN特征提取链路拆解
2.1 为什么用预训练模型而不是自己训一个CNN
自己训一个人脸识别CNN,门槛不在网络结构,而在数据。你要有几十万张带身份标注的人脸,还要做对齐、增广、难例挖掘,训练一次动辄几天GPU。对考勤这种几十到几百人的封闭场景,完全没必要。预训练模型已经把"人脸长什么样"这件事学透了,它输出的高维向量具备判别力:同一个人不同角度的向量距离近,不同人的向量距离远。
常见做法是拿一个在大型人脸集上训过的骨干网络,砍掉最后的分类层,只保留到全连接层之前的输出,作为特征提取器。这样每张人脸图进来,出去就是一个固定长度的向量,比如512维或128维。考勤系统要做的,就是维护一个"人名 → 向量"的映射表,识别时算距离找最近邻。
选型时看三个点:输入尺寸是否固定、输出向量维度、推理速度。输入尺寸决定你要不要把摄像头帧缩放到指定大小;向量维度影响存储和比对耗时;推理速度决定单帧处理能不能跟上摄像头帧率。对考勤场景,单张图推理在CPU上控制在100毫秒以内就够用,因为打卡不是连续视频流,一秒处理几帧完全能接受。
提示:预训练模型分两类,一类直接输出人脸向量(人脸识别专用),一类是通用图像分类骨干(如ResNet)。考勤场景优先选人脸识别专用预训练模型,通用骨干虽然也能提特征,但判别力差一截,容易把两个人认成同一个。
2.2 从一张图到一条打卡记录:完整数据流
把链路拆开,一张摄像头抓拍的人脸图要变成打卡记录,经过这几步:
第一步,人脸检测。从整帧画面里框出人脸位置,裁出人脸区域。这一步不做,背景、工牌、灯光都会干扰后面的特征提取。
第二步,人脸对齐。根据眼睛、鼻尖、嘴角等关键点,把人脸旋转缩放成标准姿态。对齐做得好,同一个人侧脸和正脸的向量才接近。
第三步,缩放归一化。把对齐后的人脸缩放到预训练模型要求的输入尺寸,像素值归一化到模型训练时的分布。
第四步,CNN前向推理。图片送进网络,取指定层的输出作为特征向量。
第五步,向量比对。拿当前向量和注册库里每个人的向量算距离,取最小距离,小于阈值判定为同一个人。
第六步,写考勤记录。判定成功后,把姓名、时间、设备号写进数据库,同一个人短时间内重复刷脸做去重。
这六步里,第一到第三步是预处理,第四步是模型推理,第五第六步是业务逻辑。预训练模型只负责第四步,但前两步的质量直接决定第四步的输出好不好用。很多人拿到模型跑通了就以为完事,结果识别率上不去,问题往往出在检测和对齐上。
2.3 最小可运行代码:加载预训练模型提取人脸向量
下面这段代码演示核心链路:用OpenCV做检测和对齐,用预训练模型提特征。具体模型加载方式按你拿到的预训练模型文件调整,这里用通用接口示意。
import cv2 import numpy as np import torch import torchvision.transforms as transforms # 1. 加载人脸检测器(OpenCV自带,无需额外模型文件) detector = cv2.CascadeClassifier( cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' ) # 2. 加载预训练特征提取模型 # 假设拿到的是TorchScript或state_dict格式,按实际文件调整 model = torch.load('face_feature_model.pt', map_location='cpu') model.eval() # 3. 预处理:缩放到模型输入尺寸,归一化 preprocess = transforms.Compose([ transforms.ToPILImage(), transforms.Resize((112, 112)), # 按模型要求改 transforms.ToTensor(), transforms.Normalize( mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5] ) ]) def extract_feature(img_bgr): """输入BGR图像,输出归一化后的特征向量""" gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) faces = detector.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=5, minSize=(60, 60) ) if len(faces) == 0: return None # 取最大人脸 x, y, w, h = max(faces, key=lambda f: f[2] * f[3]) face = img_bgr[y:y+h, x:x+w] face = cv2.cvtColor(face, cv2.COLOR_BGR2RGB) tensor = preprocess(face).unsqueeze(0) with torch.no_grad(): feat = model(tensor).cpu().numpy().flatten() # L2归一化,方便后面用余弦距离 feat = feat / (np.linalg.norm(feat) + 1e-10) return feat逻辑说明:检测器负责框脸,取面积最大的框避免误检小区域;预处理把脸缩到模型输入尺寸并归一化,均值和标准差必须和预训练模型训练时一致,否则特征分布偏移,比对会失准;推理用torch.no_grad()关掉梯度,省内存也提速;最后做L2归一化,这样后面算余弦距离等价于算点积,省一步。
参数说明:scaleFactor=1.1控制检测的缩放步长,值越小检测越细但越慢,考勤场景1.1到1.2够用;minNeighbors=5控制误检,调大减少误检但可能漏检;minSize=(60,60)过滤太小的脸,按摄像头分辨率和拍摄距离调。模型输入尺寸112×112是常见人脸识别模型的规格,如果你的模型是160×160或224×224,改Resize参数即可,但归一化的均值和标准差要跟着模型走。
2.4 注册库怎么建:向量存储与比对策略
注册就是给每个人录几张脸,提取向量存起来。存储结构简单到可以用一个字典加一个npy文件:
import os import numpy as np DB_PATH = 'face_db.npy' META_PATH = 'face_meta.npy' def register(name, img_list): """img_list是多张该人的人脸图,取平均向量提高鲁棒性""" feats = [] for img in img_list: f = extract_feature(img) if f is not None: feats.append(f) if not feats: raise ValueError(f'{name} 没有检测到有效人脸') mean_feat = np.mean(feats, axis=0) mean_feat = mean_feat / (np.linalg.norm(mean_feat) + 1e-10) if os.path.exists(DB_PATH): db = np.load(DB_PATH) meta = list(np.load(META_PATH, allow_pickle=True)) else: db = np.zeros((0, mean_feat.shape[0])) meta = [] db = np.vstack([db, mean_feat]) meta.append(name) np.save(DB_PATH, db) np.save(META_PATH, np.array(meta, dtype=object)) print(f'注册 {name} 完成,当前库内 {len(meta)} 人')逻辑说明:同一个人录多张,取平均向量,能抵消单张图的姿态和光照噪声。存成npy方便加载,几百人的库内存占用可以忽略。比对时算余弦相似度:
def recognize(img, threshold=0.6): feat = extract_feature(img) if feat is None: return None, 0.0 db = np.load(DB_PATH) meta = np.load(META_PATH, allow_pickle=True) sims = db @ feat # 已L2归一化,点积即余弦相似度 idx = int(np.argmax(sims)) best = float(sims[idx]) if best < threshold: return None, best # 低于阈值判为陌生人 return str(meta[idx]), best参数说明:threshold是判定阈值,余弦相似度范围-1到1,人脸场景通常0.5到0.7之间。阈值调高,误识率降低但拒识率升高,员工可能刷几次才成功;调低则相反,可能把别人认成你。这个值必须用你自己的数据实测,拿同一个人的不同照片和不同人的照片各跑一批,看相似度分布,取两类分布的分界点。没有万能阈值。
3. 考勤业务层落地:从识别结果到打卡记录
3.1 打卡去重与时间窗口设计
识别对了不等于考勤对了。同一个人站在摄像头前,一秒可能被识别十几次,如果每次都写库,数据库里全是重复记录。必须做去重。
常见做法是设一个时间窗口,比如同一个人60秒内只记一次。实现上维护一个内存字典,记录每个人上次打卡时间:
import time from datetime import datetime last_punch = {} # name -> timestamp DEDUP_WINDOW = 60 # 秒 def punch(name): now = time.time() if name in last_punch and now - last_punch[name] < DEDUP_WINDOW: return None # 窗口内重复,忽略 last_punch[name] = now record = { 'name': name, 'time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'device': 'gate-01' } save_to_db(record) return record逻辑说明:last_punch放内存,进程重启会丢,但考勤场景重启不频繁,丢了最多多记一条,可接受。如果要严格,把上次打卡时间也写库,启动时加载。DEDUP_WINDOW按打卡习惯设,上班打卡窗口60秒够,如果员工可能反复刷脸确认,可以调到120秒。
注意:去重窗口不能设太长。如果设成10分钟,员工中途出去再回来就刷不上,会被当成漏卡。60到120秒是平衡点。
3.2 考勤规则:上班、下班与迟到判定
识别只给出"谁在什么时候刷了脸",迟到早退判定是另一层规则。把规则和识别解耦,识别层只管写原始打卡记录,规则层定时跑批或实时判断。
一个简单的班次规则表:
| 字段 | 含义 | 示例 |
|---|---|---|
| shift_name | 班次名 | 白班 |
| work_start | 上班时间 | 09:00 |
| work_end | 下班时间 | 18:00 |
| late_buffer | 迟到宽限 | 5分钟 |
| early_buffer | 早退宽限 | 5分钟 |
判定逻辑:当天第一条打卡记录,时间在work_start + late_buffer之前算正常,之后算迟到;当天最后一条记录,时间在work_end - early_buffer之后算正常,之前算早退。中间记录忽略。
def judge(records, shift): if not records: return '缺卡' records.sort(key=lambda r: r['time']) first = records[0]['time'] last = records[-1]['time'] status = [] if first > shift['work_start_plus_buffer']: status.append('迟到') if last < shift['work_end_minus_buffer']: status.append('早退') return '正常' if not status else '、'.join(status)逻辑说明:把时间字符串比较换成分钟数比较更稳,避免跨天和格式问题。实际落地时,班次规则建议做成配置表,不同部门不同班次,不要硬编码。
3.3 用Flask搭一个最小打卡接口
考勤系统最终要有个地方看记录、导报表。用Flask搭最小接口,几十行能跑:
from flask import Flask, request, jsonify import base64 import numpy as np import cv2 app = Flask(__name__) @app.route('/punch', methods=['POST']) def api_punch(): data = request.json img_b64 = data['image'] img_bytes = base64.b64decode(img_b64) img_array = np.frombuffer(img_bytes, np.uint8) img = cv2.imdecode(img_array, cv2.IMREAD_COLOR) name, score = recognize(img) if name is None: return jsonify({'ok': False, 'msg': '未识别', 'score': score}) record = punch(name) if record is None: return jsonify({'ok': True, 'msg': '重复打卡已忽略', 'name': name}) return jsonify({'ok': True, 'name': name, 'time': record['time']}) @app.route('/records', methods=['GET']) def api_records(): date = request.args.get('date') rows = query_records(date) return jsonify(rows) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)逻辑说明:接口收base64图片,解码后走识别和打卡。前端可以是摄像头页面,定时抓帧发过来。/records按日期查记录,给管理端用。生产环境要加并发控制,识别是CPU密集操作,多请求同时来会排队,建议加个信号量限制并发数,或者用队列串行处理。
参数说明:host='0.0.0.0'让局域网内其他机器能访问,工控机部署时前端页面和接口可以分开机器。端口5000按需改。图片base64传输体积比二进制大三分之一,局域网内可接受,如果要省带宽可以改成multipart上传。
4. 避坑与排查:识别率上不去时先查这五处
4.1 现象:同一个人刷脸,相似度忽高忽低
原因:人脸检测框不稳定,有时框到半张脸,有时框进背景。检测器参数minNeighbors和minSize没调好,或者摄像头角度太偏。
解决:固定摄像头位置和角度,让员工站在指定区域刷脸。调大minNeighbors减少误检,调minSize过滤远处的小脸。如果检测器本身太弱,换更稳的人脸检测模型,检测质量是识别率的地基。
4.2 现象:两个人长得像,经常互相认错
原因:阈值设太低,或者预训练模型判别力不够。通用图像骨干提的特征,对细粒度人脸差异不敏感。
解决:先调高阈值,看误识是否消失,代价是本人也可能刷不过。如果调高后本人通过率掉太多,说明模型不行,换人脸识别专用预训练模型。另外注册时每人多录几张不同角度,平均向量能拉开类间距离。
4.3 现象:换了个摄像头,识别率断崖下跌
原因:新摄像头的色彩、曝光、分辨率不同,预处理里的归一化参数是按旧数据定的,特征分布偏移。
解决:归一化的均值和标准差必须和预训练模型训练时一致,不要按摄像头调。如果新摄像头画面明显偏色,在预处理前加一步白平衡或直方图均衡。更稳的做法是注册库用新摄像头重新录一遍,让注册和识别的图像分布一致。
4.4 现象:CPU跑推理,打卡排队卡顿
原因:模型太大或输入尺寸太大,单帧推理超过200毫秒,多人同时刷脸就排队。
解决:换轻量骨干,或者把输入尺寸从224降到112。考勤场景不需要极高精度,速度优先。还可以把模型转成ONNX或量化成int8,CPU推理能快两到三倍。如果机器有GPU,用GPU推理,单帧能压到10毫秒以内。
4.5 现象:数据库里同一个人出现两条不同名字的记录
原因:注册时名字拼写不一致,或者同一个人被录了两次。识别本身没错,是注册库脏了。
解决:注册接口加唯一性校验,按工号或身份证号做主键,名字只作显示。注册前先跑一次识别,如果和库里某人相似度超过高阈值,提示"该人脸已注册为XXX",阻止重复录入。
5. 把识别率从能用推到好用:一个实测调参习惯
预训练模型拿到手,默认参数跑通不难,难的是把识别率从"大部分能认"推到"稳定能认"。我自己的习惯是建一个小测试集:找10个人,每人5张不同场景的照片,再找5个不在库里的人各3张。每次调参,跑一遍测试集,记录三个数:本人通过率、陌生人误识率、平均单帧耗时。这三个数一起看,才知道调参是往好里走还是拆东墙补西墙。
具体调参顺序,我一般这么排:
| 顺序 | 调什么 | 观察指标 | 经验范围 |
|---|---|---|---|
| 1 | 检测器minNeighbors | 人脸检出率 | 5到8 |
| 2 | 检测器minSize | 误检率 | 按拍摄距离定 |
| 3 | 模型输入尺寸 | 耗时与通过率 | 112或160 |
| 4 | 相似度阈值 | 误识与拒识平衡 | 0.5到0.7 |
| 5 | 注册照片数量 | 本人通过率 | 每人3到5张 |
先调检测,检测不稳后面全白搭。检测稳了再调阈值,阈值是误识和拒识的跷跷板,没有两全,只有平衡。最后加注册照片,这是提升本人通过率最直接的手段,但要注意照片要覆盖不同角度和光照,全是一个角度反而让平均向量偏斜。
还有一个容易被忽略的点:比对时用余弦相似度还是欧氏距离,取决于特征有没有做L2归一化。归一化后用余弦,没归一化用欧氏。两者不要混用,混用会导致阈值完全对不上。我见过有人换了模型忘了改距离度量,阈值还是旧值,结果全军覆没,排查半天才发现是这里。
验证方法上,除了测试集,上线前做一次"盲测":找几个没参与注册的同事,让他们直接刷脸,看通过率和误识情况。测试集是自己挑的,可能有偏,盲测更接近真实。上线后头一周每天看一遍误识记录,发现规律就回去调,通常一周内能把参数收敛到稳定。
这套方案值不值得做?如果场景是几十到几百人的封闭考勤,有现成摄像头和一台能跑Python的机器,投入一两天就能出可用版本,值得。如果场景是几千人、多门禁、要求毫秒级响应,预训练模型加简单比对就不够了,得上专门的向量检索库和更强的模型。边界清楚,才不会翻车。希望帮到你。
本文还有配套的精品资源,点击获取