ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CNN人脸识别考勤demo实战:从模型训练到打卡系统

CNN人脸识别考勤demo实战:从模型训练到打卡系统 简介这是一份面向高校学生与深度学习入门者的课程设计级人脸识别考勤演示项目基于卷积神经网络实现人脸检测与签到流程适合作为人工智能、机器学习相关课程的实践参考或毕业设计起步模板。压缩包共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 也不用 Transformer2.1 人脸识别任务里 CNN 到底比传统方法强在哪传统人脸识别走的是人工特征 分类器的路子。早期用 Haar 特征做检测用 LBPH 做识别本质上是在描述人脸的纹理和梯度分布。这套方法在光照均匀、姿态端正、背景干净的环境下能跑到 80% 左右的准确率但一旦光线偏了、头歪了、戴了眼镜准确率断崖式下跌。原因很简单人工设计的特征表达能力有限它只能捕捉你让它捕捉的东西。CNN 的不同在于它自己学特征。一个标准的卷积神经网络前面几层学的是边缘和角点中间几层学的是眼睛、鼻子、嘴巴这些局部部件后面几层学的是整张脸的全局结构。这种层次化的特征提取方式让 CNN 对光照、姿态、表情的变化有更强的鲁棒性。在 LFW 这类公开人脸数据集上传统方法做到 85% 就算不错了而一个结构合理的 CNN 可以轻松做到 95% 以上。但这里有个关键点人脸识别不是普通的分类任务。普通分类是这张图是猫还是狗人脸识别是这张脸是不是同一个人。后者需要模型学会度量——把两张人脸映射到一个特征空间里同一个人的特征距离近不同人的特征距离远。这就是为什么人脸识别 CNN 通常不直接用 Softmax 分类而是用 Triplet Loss 或 ArcFace 这类度量学习损失函数。2.2 为什么 demo 场景下不选 Transformer 和 SVMTransformer 在视觉领域确实火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, transformNone): 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(brightness0.3, contrast0.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_dim128, num_classes50): super().__init__() backbone models.mobilenet_v2(pretrainedTrue) # 去掉原来的分类头保留特征提取部分 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, p2, dim1) out self.classifier(emb) return emb, out # 训练循环 def train(model, dataloader, epochs30, lr1e-3): device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lrlr, weight_decay1e-4) scheduler optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.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(fEpoch {epoch1}/{epochs}, Loss: {total_loss/len(dataloader):.4f}, Acc: {acc:.2f}%) if __name__ __main__: dataset FaceDataset(root_dir./faces, transformtrain_transform) dataloader DataLoader(dataset, batch_size32, shuffleTrue, num_workers2) model FaceEmbeddingNet(embedding_dim128, num_classeslen(dataset.class_to_idx)) train(model, dataloader, epochs30) torch.save(model.state_dict(), face_embedding.pth)这段代码的逻辑分三层。第一层是数据组织每个人一个文件夹文件夹名就是标签这是人脸识别项目最通用的数据格式后面你要加人只需要新建文件夹丢照片进去。第二层是模型结构用预训练的 MobileNetV2 做骨干因为它在 ImageNet 上学到的特征已经包含了大量人脸相关的底层模式迁移过来只需要微调就能收敛。第三层是训练策略用 CrossEntropyLoss 做分类训练但输出的是归一化后的嵌入向量推理时只取嵌入向量做比对。参数方面embedding_dim128是嵌入向量的维度这个值越大表达能力越强但存储和计算开销也越大128 在 demo 场景下是甜点值。batch_size32是一般显卡能扛住的批量大小如果显存不够可以降到 16。lr1e-3配合 StepLR 每 10 个 epoch 衰减一半是迁移学习常用的学习率策略。epochs30对于几百张照片的数据集来说足够收敛再多容易过拟合。注意pretrainedTrue会下载预训练权重第一次运行需要联网。如果网络不通可以提前下载好权重文件放到~/.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_dim128, num_classes50) model.load_state_dict(torch.load(face_embedding.pth, map_locationdevice)) 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, scaleFactor1.1, minNeighbors5, minSize(60, 60)) return facesdetectMultiScale的三个参数需要根据实际场景调。scaleFactor1.1表示每次图像尺寸缩小 10%值越小检测越慢但越不容易漏minNeighbors5是每个候选框需要被邻居框确认的次数值越大误检越少但漏检越多minSize(60, 60)是最小人脸尺寸如果摄像头离得远可以调小但太小会引入大量误检。3.2 底库构建与余弦相似度匹配底库就是已知人员的人脸特征库。每个人录入 3 到 5 张不同角度的照片提取嵌入向量后取平均作为这个人的特征模板。识别时把当前人脸的特征向量和底库里每个人算余弦相似度取最高分且超过阈值的那一个作为识别结果。import json import os def build_gallery(face_root./faces, save_pathgallery.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, keylambda f: f[2] * f[3]) face_img img[y:yh, x:xw] emb extract_embedding(face_img) embeddings.append(emb) if embeddings: # 多张照片取平均降低单张照片的噪声影响 mean_emb np.mean(embeddings, axis0) # 重新归一化 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, threshold0.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阈值threshold0.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_pathattendance.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_minutes5): 写入考勤记录同一人 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(minutescooldown_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_minutes5是冷却时间意思是同一个人 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, size5): self.buffer deque(maxlensize) 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, alpha0.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 galleryalpha0.1表示新特征只占 10% 的权重这样单次更新不会造成剧烈变化但长期积累下来底库会逐渐适应当前状态。如果发现某个人识别率持续下降可以临时把 alpha 调到 0.3 加速更新。这三个技巧叠加使用在 demo 场景下可以把实际打卡的识别率从 85% 左右提升到 95% 以上。但要注意任何提升识别率的手段都有代价——多帧投票增加延迟动态阈值增加逻辑复杂度底库更新增加存储和计算开销。在 demo 阶段建议先跑通基础版本再根据实际翻车情况逐个加上去。我自己在这个项目上踩过最深的坑是一开始为了追求高精度把模型搞得很大结果在普通笔记本上跑一帧要 2 秒打卡的人站在摄像头前等了 5 秒还没反应体验极差。后来换成 MobileNet 加多帧投票单帧推理降到 50 毫秒整体体验反而好了很多。人脸识别考勤这个场景速度和稳定性比绝对精度更重要——员工不会因为你认错了一次就投诉但会因为每次打卡都要等 3 秒而放弃使用。希望帮到你。本文还有配套的精品资源点击获取
返回列表