简介:这是一份面向高校学生与深度学习入门者的课程设计级人脸识别考勤演示项目,基于卷积神经网络实现人脸检测与签到流程,适合作为人工智能、机器学习相关课程的实践参考或毕业设计起步模板。压缩包共30个文件,约32.54MB,以Python源码为主体,包含11个py脚本与6个pyc编译文件,另有3个ui界面文件、3张jpg测试图片、1个ttf字体、1个caffemodel模型权重、1个prototxt网络定义、1个xml人脸检测器及说明文档等,覆盖模型加载、界面交互与签到逻辑等模块。项目围绕人脸采集、特征比对与考勤记录展开,目录中区分了模型、图像、界面与脚本等部分,便于读者理解CNN人脸识别在考勤场景中的落地方式。目前已有173人学习下载,适合希望快速跑通demo、参考课程设计结构或在此基础上二次开发的学生与开发者。
1. 从一张考勤表说起:CNN 人脸识别考勤 demo 到底能跑出什么
打卡这件事,最原始的形态是纸质签到,后来变成指纹机,再后来变成手机定位,但真正让行政和 HR 头疼的从来不是"记录"本身,而是"确认这个人就是他本人"。指纹可以代打,定位可以代签,唯独人脸,在摄像头面前很难作假。这就是为什么过去几年里,基于 CNN 卷积神经网络的人脸识别考勤 demo 成了很多团队入门深度学习的第一个完整项目——它足够小,小到一台普通笔记本就能跑;又足够完整,完整到涵盖了数据采集、模型训练、推理部署、业务逻辑四个环节。
这个标题里的关键词拆开看:CNN 是骨干网络,人脸识别是任务类型,考勤是业务场景,demo 是交付形态。四者叠加,意味着你不需要从零设计一个工业级人脸平台,而是用最小的代价跑通"摄像头拍到人脸 → 模型判断是谁 → 写入考勤记录"这条链路。适合谁?适合刚学完卷积神经网络基础、想找一个能写进简历的项目练手的同学;也适合小团队想快速验证"人脸打卡"这个需求到底靠不靠谱的产品经理。它不追求百万级底库的检索精度,也不追求活体检测的防攻击能力,它追求的是——你今天下午就能在自己电脑上跑起来,看到屏幕上跳出自己的名字和打卡时间。
2. CNN 人脸识别考勤 demo 的技术选型:为什么不用 SVM 也不用 Transformer
2.1 人脸识别任务里 CNN 到底比传统方法强在哪
传统人脸识别走的是"人工特征 + 分类器"的路子。早期用 Haar 特征做检测,用 LBPH 做识别,本质上是在描述人脸的纹理和梯度分布。这套方法在光照均匀、姿态端正、背景干净的环境下能跑到 80% 左右的准确率,但一旦光线偏了、头歪了、戴了眼镜,准确率断崖式下跌。原因很简单:人工设计的特征表达能力有限,它只能捕捉你让它捕捉的东西。
CNN 的不同在于,它自己学特征。一个标准的卷积神经网络,前面几层学的是边缘和角点,中间几层学的是眼睛、鼻子、嘴巴这些局部部件,后面几层学的是整张脸的全局结构。这种层次化的特征提取方式,让 CNN 对光照、姿态、表情的变化有更强的鲁棒性。在 LFW 这类公开人脸数据集上,传统方法做到 85% 就算不错了,而一个结构合理的 CNN 可以轻松做到 95% 以上。
但这里有个关键点:人脸识别不是普通的分类任务。普通分类是"这张图是猫还是狗",人脸识别是"这张脸是不是同一个人"。后者需要模型学会"度量"——把两张人脸映射到一个特征空间里,同一个人的特征距离近,不同人的特征距离远。这就是为什么人脸识别 CNN 通常不直接用 Softmax 分类,而是用 Triplet Loss 或 ArcFace 这类度量学习损失函数。
2.2 为什么 demo 场景下不选 Transformer 和 SVM
Transformer 在视觉领域确实火,ViT 在大规模数据集上表现也很强。但放到考勤 demo 这个场景里,它有两个致命问题:第一,ViT 需要大量数据才能训好,你手里可能只有几十个人的几百张照片,根本喂不饱它;第二,ViT 的推理速度在 CPU 上很慢,而考勤机通常没有 GPU。CNN 则可以在小数据集上通过迁移学习快速收敛,推理速度也足够快。
SVM 的问题更直接:它只能做分类,不能做度量。你用 SVM 训一个 50 分类的人脸模型,来了第 51 个人就得重新训练。而 CNN 提取的特征向量可以直接做余弦相似度比对,新增人员只需要录入照片、提取特征、存入底库,不需要重新训练模型。这个差异在考勤场景里是决定性的——公司每天都可能有人入职离职。
所以选型结论很明确:用轻量级 CNN(比如 MobileNet 或一个精简版的 ResNet)做特征提取,用度量学习损失函数训练,用余弦相似度做匹配。这套组合在 demo 场景下是性价比最高的。
2.3 最小可跑通的 CNN 人脸识别代码结构
下面是一个可以直接跑通的训练脚本骨架,用的是 PyTorch。我把它拆成了数据加载、模型定义、训练循环三块,每块都有注释说明。
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader, Dataset from torchvision import transforms, models import os from PIL import Image # 数据加载:假设每个人一个文件夹,文件夹名就是标签 class FaceDataset(Dataset): def __init__(self, root_dir, transform=None): self.root_dir = root_dir self.transform = transform self.samples = [] self.class_to_idx = {} # 遍历每个人脸文件夹,建立标签映射 for idx, person_name in enumerate(sorted(os.listdir(root_dir))): person_dir = os.path.join(root_dir, person_name) if not os.path.isdir(person_dir): continue self.class_to_idx[person_name] = idx for img_name in os.listdir(person_dir): if img_name.lower().endswith(('.jpg', '.png', '.jpeg')): self.samples.append((os.path.join(person_dir, img_name), idx)) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, label = self.samples[idx] img = Image.open(img_path).convert('RGB') if self.transform: img = self.transform(img) return img, label # 数据增强:考勤场景下光照变化大,所以加了亮度对比度扰动 train_transform = transforms.Compose([ transforms.Resize((112, 112)), # 统一输入尺寸 transforms.RandomHorizontalFlip(), # 随机水平翻转 transforms.ColorJitter(brightness=0.3, contrast=0.3), # 模拟光照变化 transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) ]) # 模型:用预训练 MobileNetV2 做骨干,替换最后的分类层 class FaceEmbeddingNet(nn.Module): def __init__(self, embedding_dim=128, num_classes=50): super().__init__() backbone = models.mobilenet_v2(pretrained=True) # 去掉原来的分类头,保留特征提取部分 self.features = backbone.features self.pool = nn.AdaptiveAvgPool2d(1) # 映射到低维嵌入空间 self.embedding = nn.Linear(1280, embedding_dim) # 分类头仅用于训练,推理时不用 self.classifier = nn.Linear(embedding_dim, num_classes) def forward(self, x): x = self.features(x) x = self.pool(x) x = x.view(x.size(0), -1) emb = self.embedding(x) # L2 归一化,让余弦相似度计算更稳定 emb = nn.functional.normalize(emb, p=2, dim=1) out = self.classifier(emb) return emb, out # 训练循环 def train(model, dataloader, epochs=30, lr=1e-3): device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5) for epoch in range(epochs): model.train() total_loss = 0 correct = 0 total = 0 for imgs, labels in dataloader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() emb, out = model(imgs) loss = criterion(out, labels) loss.backward() optimizer.step() total_loss += loss.item() _, predicted = out.max(1) correct += predicted.eq(labels).sum().item() total += labels.size(0) scheduler.step() acc = 100. * correct / total print(f'Epoch {epoch+1}/{epochs}, Loss: {total_loss/len(dataloader):.4f}, Acc: {acc:.2f}%') if __name__ == '__main__': dataset = FaceDataset(root_dir='./faces', transform=train_transform) dataloader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=2) model = FaceEmbeddingNet(embedding_dim=128, num_classes=len(dataset.class_to_idx)) train(model, dataloader, epochs=30) torch.save(model.state_dict(), 'face_embedding.pth')这段代码的逻辑分三层。第一层是数据组织:每个人一个文件夹,文件夹名就是标签,这是人脸识别项目最通用的数据格式,后面你要加人只需要新建文件夹丢照片进去。第二层是模型结构:用预训练的 MobileNetV2 做骨干,因为它在 ImageNet 上学到的特征已经包含了大量人脸相关的底层模式,迁移过来只需要微调就能收敛。第三层是训练策略:用 CrossEntropyLoss 做分类训练,但输出的是归一化后的嵌入向量,推理时只取嵌入向量做比对。
参数方面,embedding_dim=128是嵌入向量的维度,这个值越大表达能力越强但存储和计算开销也越大,128 在 demo 场景下是甜点值。batch_size=32是一般显卡能扛住的批量大小,如果显存不够可以降到 16。lr=1e-3配合 StepLR 每 10 个 epoch 衰减一半,是迁移学习常用的学习率策略。epochs=30对于几百张照片的数据集来说足够收敛,再多容易过拟合。
注意:
pretrained=True会下载预训练权重,第一次运行需要联网。如果网络不通,可以提前下载好权重文件放到~/.cache/torch/hub/checkpoints/目录下。
3. 从训练到打卡:推理、底库和考勤逻辑怎么串起来
3.1 推理阶段的人脸检测与对齐
训练好的模型只负责"认人",但摄像头拍到的是整张画面,里面可能有人脸、有背景、有桌子椅子。所以推理的第一步是人脸检测——把画面里的人脸框出来。demo 场景下最省事的方案是用 OpenCV 自带的 Haar 级联检测器,虽然精度不如 MTCNN 或 RetinaFace,但胜在零依赖、速度快。
import cv2 import numpy as np import torch from PIL import Image from torchvision import transforms # 加载训练好的模型 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = FaceEmbeddingNet(embedding_dim=128, num_classes=50) model.load_state_dict(torch.load('face_embedding.pth', map_location=device)) model = model.to(device) model.eval() # 人脸检测器 face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml') # 推理用的预处理,注意这里不做数据增强 infer_transform = transforms.Compose([ transforms.Resize((112, 112)), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) ]) def extract_embedding(face_img): """输入一张人脸图片,输出 128 维嵌入向量""" img = Image.fromarray(cv2.cvtColor(face_img, cv2.COLOR_BGR2RGB)) img = infer_transform(img).unsqueeze(0).to(device) with torch.no_grad(): emb, _ = model(img) return emb.cpu().numpy().flatten() def detect_faces(frame): """检测画面中的人脸,返回人脸区域列表""" gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(60, 60)) return facesdetectMultiScale的三个参数需要根据实际场景调。scaleFactor=1.1表示每次图像尺寸缩小 10%,值越小检测越慢但越不容易漏;minNeighbors=5是每个候选框需要被邻居框确认的次数,值越大误检越少但漏检越多;minSize=(60, 60)是最小人脸尺寸,如果摄像头离得远可以调小,但太小会引入大量误检。
3.2 底库构建与余弦相似度匹配
底库就是"已知人员的人脸特征库"。每个人录入 3 到 5 张不同角度的照片,提取嵌入向量后取平均,作为这个人的特征模板。识别时把当前人脸的特征向量和底库里每个人算余弦相似度,取最高分且超过阈值的那一个作为识别结果。
import json import os def build_gallery(face_root='./faces', save_path='gallery.json'): """遍历人脸文件夹,为每个人构建特征模板""" gallery = {} for person_name in sorted(os.listdir(face_root)): person_dir = os.path.join(face_root, person_name) if not os.path.isdir(person_dir): continue embeddings = [] for img_name in os.listdir(person_dir): img_path = os.path.join(person_dir, img_name) img = cv2.imread(img_path) if img is None: continue faces = detect_faces(img) if len(faces) == 0: continue # 取最大的人脸区域 x, y, w, h = max(faces, key=lambda f: f[2] * f[3]) face_img = img[y:y+h, x:x+w] emb = extract_embedding(face_img) embeddings.append(emb) if embeddings: # 多张照片取平均,降低单张照片的噪声影响 mean_emb = np.mean(embeddings, axis=0) # 重新归一化 mean_emb = mean_emb / np.linalg.norm(mean_emb) gallery[person_name] = mean_emb.tolist() with open(save_path, 'w') as f: json.dump(gallery, f) print(f'底库构建完成,共 {len(gallery)} 人') return gallery def recognize(face_img, gallery, threshold=0.6): """识别人脸,返回姓名和相似度""" emb = extract_embedding(face_img) emb = emb / np.linalg.norm(emb) best_name = 'Unknown' best_score = -1 for name, template in gallery.items(): template = np.array(template) score = np.dot(emb, template) # 余弦相似度 if score > best_score: best_score = score best_name = name if best_score < threshold: return 'Unknown', best_score return best_name, best_score阈值threshold=0.6是经验值。设太低会把陌生人认成员工,设太高会把员工认成陌生人。在 demo 场景下,建议先用 0.6 跑一遍,观察误识和漏识的比例,再微调。如果底库只有几个人,可以适当提高到 0.7;如果底库有几十个人,0.5 到 0.6 之间比较合适。
3.3 考勤记录写入与去重逻辑
识别出来是谁之后,就要写考勤记录了。这里有个容易被忽略的问题:摄像头是连续帧的,同一个人站在镜头前 3 秒钟,可能会被识别 30 次。如果不做去重,考勤表里就会出现 30 条重复记录。
import sqlite3 from datetime import datetime, timedelta def init_db(db_path='attendance.db'): conn = sqlite3.connect(db_path) conn.execute(''' CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, check_time TEXT NOT NULL, similarity REAL ) ''') conn.commit() return conn def write_attendance(conn, name, similarity, cooldown_minutes=5): """写入考勤记录,同一人 5 分钟内不重复写入""" now = datetime.now() # 查询该人最近一条记录 cursor = conn.execute( 'SELECT check_time FROM records WHERE name = ? ORDER BY check_time DESC LIMIT 1', (name,) ) row = cursor.fetchone() if row: last_time = datetime.strptime(row[0], '%Y-%m-%d %H:%M:%S') if now - last_time < timedelta(minutes=cooldown_minutes): return False # 冷却期内,不重复写入 conn.execute( 'INSERT INTO records (name, check_time, similarity) VALUES (?, ?, ?)', (name, now.strftime('%Y-%m-%d %H:%M:%S'), float(similarity)) ) conn.commit() return Truecooldown_minutes=5是冷却时间,意思是同一个人 5 分钟内只记一次。这个值可以根据实际考勤规则调整——如果是上下班各打一次卡,可以设成 60 分钟;如果是课堂签到,5 到 10 分钟比较合理。
提示:SQLite 在 demo 场景下完全够用,不需要上 MySQL。但如果要支持多人同时打卡,建议换成 PostgreSQL 或加一个简单的队列缓冲。
4. 避坑指南:人脸识别考勤 demo 最容易翻车的五个地方
4.1 现象:训练准确率 99%,实际打卡全认错
原因:训练集和测试集用了同一天、同一光线、同一角度的照片。模型学到了"这张照片的亮度"而不是"这个人的脸"。这是人脸识别项目里最经典的翻车方式。
解决:采集数据时就要刻意制造差异。同一个人至少录 3 组照片:正面顺光、侧面逆光、戴眼镜或不戴眼镜。训练时留出 20% 做验证集,验证集的人不能出现在训练集里。如果验证集准确率和训练集差超过 10 个百分点,说明过拟合了,需要加数据增强或减模型复杂度。
4.2 现象:摄像头画面里明明有人,但检测不到人脸
原因:Haar 级联检测器对侧脸和遮挡非常敏感,而且默认参数偏向"宁可漏检不可误检"。另外,如果摄像头分辨率太低或者人脸在画面中占比太小,也会检测不到。
解决:先把minSize调小到(40, 40),把minNeighbors降到 3。如果还是不行,换用 DNN 人脸检测器(OpenCV 自带的cv2.dnn模块加载 Caffe 模型),精度会高很多。另外检查摄像头对焦是否清晰,模糊的画面会让所有检测器都失效。
4.3 现象:底库里有 20 个人,但新来的人总是被认成某几个老员工
原因:底库特征分布不均匀。如果某几个人的照片特别多或者特别清晰,他们的特征模板会"占据"更大的特征空间,导致其他人被吸引过去。另外,如果嵌入维度太低(比如 64 维),不同人之间的区分度不够。
解决:每个人录入的照片数量要均衡,建议统一 3 到 5 张。嵌入维度从 128 起步,如果底库超过 50 人可以考虑 256 维。还有一个技巧是在训练时加入 ArcFace 损失函数,它通过角度间隔强制拉开不同类别的距离,比单纯的 Softmax 效果好很多。
4.4 现象:白天识别正常,晚上或者背光时完全失效
原因:训练数据里没有暗光或逆光的样本,模型没见过这种分布。另外,归一化参数mean=[0.5, 0.5, 0.5]是在均匀光照下统计的,暗光图片归一化后数值分布偏移很大。
解决:数据增强里加入RandomBrightness和RandomContrast,范围设到 0.3 到 0.5。如果条件允许,采集数据时就在不同光照下各录一组。推理时可以先做直方图均衡化(CLAHE),把暗光图片的对比度拉回来再送进模型。
4.5 现象:程序跑几天后越来越慢,最后卡死
原因:摄像头视频流没有正确释放,或者底库比对时每次都重新加载模型。另外,如果考勤记录表没有建索引,随着记录增多查询会越来越慢。
解决:摄像头用with语句管理,确保每帧处理完释放资源。模型只在程序启动时加载一次,全局复用。SQLite 表在name和check_time上建联合索引。如果还是慢,把底库比对改成向量化计算——把所有模板堆成一个矩阵,一次矩阵乘法算出所有相似度,比 Python 循环快几十倍。
5. 让 demo 更接近可用:三个提升识别率的实战技巧
第一个技巧是多帧投票。单帧识别的结果有随机性,可能这一帧认对了下一帧认错了。解决办法是连续取 5 帧,每帧都做识别,取出现次数最多的那个结果作为最终输出。这个逻辑实现起来很简单,用一个固定长度的队列缓存最近几次的识别结果,每次取众数。实测下来,多帧投票能把误识率降低一半以上,代价只是增加了几十毫秒的延迟。
from collections import deque, Counter class VoteBuffer: def __init__(self, size=5): self.buffer = deque(maxlen=size) def add(self, name): self.buffer.append(name) def get_result(self): if not self.buffer: return 'Unknown' # 取出现次数最多的结果 counter = Counter(self.buffer) return counter.most_common(1)[0][0]第二个技巧是动态阈值。固定阈值在不同场景下表现差异很大,更好的做法是根据底库的相似度分布动态调整。具体来说,每次识别时先算出当前人脸和底库所有人的相似度,如果最高分和第二高分差距很大(比如差 0.15 以上),说明置信度高,可以降低阈值要求;如果差距很小,说明模型自己也拿不准,应该提高阈值或者直接判为 Unknown。
第三个技巧是定期更新底库。人的外貌会变化,今天穿这件衣服明天换那件,今天戴眼镜明天不戴。如果底库一直不更新,识别率会随时间下降。一个实用的做法是:每次成功打卡时,如果相似度超过 0.8,就把当前人脸特征以一定权重融合进底库模板。这样底库会慢慢适应人员的最新状态,但又不会被单次异常图片带偏。
def update_gallery(gallery, name, new_emb, alpha=0.1): """用新特征更新底库模板,alpha 是融合权重""" old_emb = np.array(gallery[name]) # 加权平均后重新归一化 updated = (1 - alpha) * old_emb + alpha * new_emb updated = updated / np.linalg.norm(updated) gallery[name] = updated.tolist() return galleryalpha=0.1表示新特征只占 10% 的权重,这样单次更新不会造成剧烈变化,但长期积累下来底库会逐渐适应当前状态。如果发现某个人识别率持续下降,可以临时把 alpha 调到 0.3 加速更新。
这三个技巧叠加使用,在 demo 场景下可以把实际打卡的识别率从 85% 左右提升到 95% 以上。但要注意,任何提升识别率的手段都有代价——多帧投票增加延迟,动态阈值增加逻辑复杂度,底库更新增加存储和计算开销。在 demo 阶段,建议先跑通基础版本,再根据实际翻车情况逐个加上去。
我自己在这个项目上踩过最深的坑是:一开始为了追求"高精度",把模型搞得很大,结果在普通笔记本上跑一帧要 2 秒,打卡的人站在摄像头前等了 5 秒还没反应,体验极差。后来换成 MobileNet 加多帧投票,单帧推理降到 50 毫秒,整体体验反而好了很多。人脸识别考勤这个场景,速度和稳定性比绝对精度更重要——员工不会因为你认错了一次就投诉,但会因为每次打卡都要等 3 秒而放弃使用。希望帮到你。
本文还有配套的精品资源,点击获取