简介:这是一份基于卷积神经网络的人脸识别考勤演示项目,面向Python初学者与大学生课程设计场景,完整展示从模型调用到界面操作的流程。资源打包了训练好的Caffe模型与网络配置,提供摄像头实时采集、人脸检测、特征比对以及签到签退的完整脚本,并配有多个Qt界面文件和示例图片,结构清晰,便于直接运行与二次开发。压缩包共30个文件,以Python源码为主体,另有界面设计文件、模型文件、图片素材与说明文档,总大小约32.54MB,目录按模型、界面、脚本和素材划分,方便按需查阅。目前已有173人学习下载,适合需要快速理解深度学习落地流程的读者,也可作为考勤、门禁等系统的课程设计基础,尤其适合大二、大三学生借鉴整体思路与模块拆分方式,是一款体量适中、代码组织规范的人工智能入门应用项目。
1. 第一次把CNN人脸识别考勤demo部署到现场,我翻车了整整一个上午
某个周一早晨,我负责把下载回来的人脸识别考勤demo部署到办公室:摄像头是现成的,电脑是普通i5,结果第一位同事站在镜头前,程序显示“未知人员”;第二位同事直接被识别成第一位,考勤表里打了一串错误工号。问题不在摄像头,也不在员工颜值,而在于这个基于CNN神经网络人脸识别实现的考勤demo项目,本质上只是把“人脸检测+特征提取+特征比对”的骨架跑通了,离“可靠考勤”还差数据规范、阈值调控和一整套路业务状态逻辑。这篇文章想帮你把这个demo真正变成能点名的工具:先讲清楚链路怎么拆,再给一份能直接抄的跑通流程,最后把我踩过的坑一条条列出来。
2. 拆开CNN人脸识别考勤的链路:检测、对齐、特征提取谁都不能少
2.1 考勤识别为什么不是“多分类问题”
很多人第一次接触CNN,第一反应是“把每个员工当一个类别”,用分类网络去训。分类网络当然能做:张三图片送进去,输出一个“张三概率0.93”。但考勤场景这么玩会翻车——新员工入职要重新训练整个模型,老员工换发型、戴眼镜,数据集又得重做一套;而且分类网络输出的是“见过的几个人里最像谁”,一旦来个陌生人,它也会强行归到某个员工头上,这就是误打卡的根源。
实际的人脸识别考勤走的是度量学习路线。CNN主网在这里不是输出类别,而是输出一个固定维度的特征向量,常见是128维或512维。训练阶段用ArcFace这类带margin的损失函数,让同一个人的特征向量在余弦空间里聚在一起,不同人的向量散开。推理阶段只做两件事:第一,算摄像头拍到的人脸特征;第二,和底库里每位员工的特征向量比对余弦相似度,超过阈值就认为是本人。入职新员工只需要往底库加一条记录,完全不用动网络。
这也解释了为什么demo里通常会带一个“注册”功能而不是“训练”功能。你点注册,程序采集几张照片、提取特征、存进底库,整套逻辑和通讯录加联系人差不多。把“识别”当成“比对”,把“考勤”当成“比对后的业务动作”,这个顺序理顺了,后面看代码不会一头雾水。
2.2 三级流水线:检测、对齐、特征提取的具体分工
一个完整的人脸识别考勤流程,代码上至少拆成三段。第一段是人脸检测,输入是整帧画面,输出是若干个脸部包围框和关键点坐标。第二段是人脸对齐,把检测到的脸通过仿射变换旋转、缩放到标准尺寸。第三段才是CNN特征提取,把对齐后的112×112标准人脸变成512维向量。
检测网络常用MTCNN、RetinaFace这类,它们除了输出人脸框,还能输出左眼、右眼、鼻尖、嘴角关键点。这些关键点是给对齐用的。一个歪着脖子站在门禁机前面的员工,如果直接把原图裁下来送进特征网络,特征质量会肉眼可见地下降;用关键点做一次仿射变换,把两只眼睛拉回同一条水平线,识别率立刻上一个台阶。
特征提取网络是性能和精度的权衡。常见的有MobileFaceNet、FaceNet、ArcFace配套的IResNet系列。考勤场景一般就几十个人,用MobileFaceNet在CPU上就能跑到单张20到50毫秒;如果要求更高精度且有大显卡,再考虑IResNet。模型选型我放在一张表里,方便对着选。
| 环节 | 常见模型 | 输出 | 考勤场景选型建议 |
|---|---|---|---|
| 检测 | MTCNN、RetinaFace、SCRFD | 人脸框+关键点 | 优先MTCNN,数据量小、上手快 |
| 对齐 | 关键点驱动的仿射变换 | 112×112标准化人脸 | 必须做,不做识别率差一截 |
| 特征提取 | MobileFaceNet、IResNet | 512维特征向量 | CPU部署选MobileFaceNet |
| 比对 | faiss线性扫描或余弦相似度 | 身份ID+相似度分数 | 几十人直接算余弦相似度即可 |
对齐这段代码是后面所有环节的基础,我一般会写成独立函数,不要放进识别主流程里临时拼,否则以后换模型你会到处改:
import cv2 import numpy as np # 参考点:112x112 人脸识别最常用的对齐目标坐标 # 顺序依次是左眼中心、右眼中心、鼻尖、左嘴角、右嘴角 REF_POINTS = np.array([ [38.2946, 51.6963], # 左眼中心 [73.5318, 51.5014], # 右眼中心 [56.0252, 71.7366], # 鼻尖 [41.5493, 92.3655], # 左嘴角 [70.7299, 92.2041] # 右嘴角 ], dtype=np.float32) def align_face(img, keypoints): # keypoints: 检测网络输出的5个关键点,顺序与REF_POINTS一致 dst = REF_POINTS.reshape(5, 2) src = np.array(keypoints, dtype=np.float32).reshape(5, 2) trans, _ = cv2.estimateAffinePartial2D(src, dst, method=cv2.LMEDS) aligned = cv2.warpAffine(img, trans, (112, 112), flags=cv2.INTER_LINEAR) return aligned逻辑说明:参考点坐标对应的是公开人脸识别模型最常用的112×112对齐标准,部分256×256版本参考点不一样,换模型时要去模型发布页把参考点一起换掉。cv2.estimateAffinePartial2D求的是部分仿射变换矩阵,只允许平移、旋转和缩放,不会出现剪式扭曲,对歪头、侧脸比较宽容。LMEDS是鲁棒估计方法,防止某个关键点抖动把整张脸拉歪。对齐后统一用INTER_LINEAR插值,速度和效果均衡,不需要为了“更清晰”换成INTER_CUBIC,那样只会变慢。
参数说明里有三个坑要留意:关键点顺序必须跟检测模型输出一致,MTCNN输出的是[左眼,右眼,鼻子,左嘴角,右嘴角],RetinaFace某些版本会多一个轮廓点,注意对齐前截取前5个;如果检测网络返回的置信度很低,对齐结果会飘,最好在调用对齐前先按置信度丢弃;图像通道顺序默认BGR,warpAffine不会改变通道,后面特征网络如果要求RGB输入,转换动作要放在对齐之后、预处理之前。
2.3 相似度比对的数学意义与“阈值”为什么不能拍脑袋
特征向量出来后,比对环节通常用余弦相似度。两个向量夹角的余弦值越高,说明方向越一致,理论上就是同一张脸。余弦相似度取值范围是[-1, 1],人脸识别里正常分布在0.3到0.9,同一个人的不同照片通常在0.55以上,不同人通常在0.45以下。但这个区间因模型而异,不要拿别人的经验值直接套用。
这一步的陷阱在于,很多人把阈值写死在代码里再不动。合理做法是:从系统里收集一批真实考勤数据,打上标签,算出同一个人的相似度分布和不同人的相似度分布,取两者的间隔中点做初始阈值;更工程一点,就跑一遍阈值在0.40、0.45、0.50、0.55下的误检率和漏检率。考勤场景宁可漏检不可误检,因为漏检最多是让员工再刷一次脸,误检就要人工改考勤表。默认从0.45到0.50起步,底库超过100人再考虑用faiss做向量检索,几十个人的底库真没必要引入向量数据库。
3. 环境搭建、底库采集与运行前检查:把demo跑起来之前先做三件事
3.1 用onnxruntime在CPU上跑通的最小依赖
很多人一拿到demo就想着GPU,但考勤摄像头实时识别,普通办公环境根本没有GPU。靠谱做法是优先保证CPU能跑,onnxruntime推理引擎非常适合这件事:它能把PyTorch训练好的模型在Windows和Linux上运行,还对CPU指令集做了优化。我习惯的依赖组合是这样:
conda create -n face_att python=3.9 -y conda activate face_att pip install opencv-python==4.8.1.78 onnxruntime==1.16.3 numpy==1.24.3 scipy pandas openpyxl这里的版本我全部锁死。opencv和onnxruntime的API变动快,新版opencv在人脸检测部分的细节行为时有变化,onnxruntime的算子支持也逐版更新,demo里自带的onnx模型可能是老版本导出的,新版本未必兼容。如果你机器有NVIDIA显卡,可以额外装onnxruntime-gpu,但不要在demo阶段接,先确认CPU路径跑通,再考虑加速。
提示:Python不要用3.12,部分预编译的依赖包找不到对应wheel,3.9或3.10最省事。装完以后建议顺手降级numpy到1.24.3,新版numpy的API有调整,老模型转换代码容易报错。
3.2 员工底库怎么建:一人多张、角度覆盖与质量筛选
考勤底库是整个系统的地基。我见过很多人直接从手机相册挑照片建底库,这是大概率翻车的行为。手机照片通常在顺光下拍摄,而现场摄像头往往装在头顶、逆光或者侧光位置,光照分布差异太大,识别率会直接崩掉。正规做法是用要部署的那台摄像头采集底库,每个员工正脸、左右转头、微微低头各拍一张,至少5张。
再配合一个质量筛选脚本,把模糊、过曝、人脸太小的帧自动过滤掉。脚本逻辑不复杂:检测网络返回置信度、人脸框面积、像素亮度三路指标。三个参数比较关键:min_face_area控制人脸最小像素面积,blur_threshold用拉普拉斯方差判断模糊程度,brightness_min控制最低亮度,防止逆光生成一团黑:
import cv2 def quality_check(frame, face_box, face_score, min_face_area=22500, blur_threshold=60, brightness_min=80): # face_box: [x, y, w, h],face_score: 检测框置信度 if face_score < 0.95: return False, "confidence too low" x, y, w, h = face_box if w * h < min_face_area: return False, "face too small" roi = frame[y:y + h, x:x + w] gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) blur = cv2.Laplacian(gray, cv2.CV_64F).var() if blur < blur_threshold: return False, "image too blurry" if gray.mean() < brightness_min: return False, "too dark" return True, "ok"参数说明:min_face_area等于22500像素,对应150×150的人脸,这是考勤场景比较合理的下限,再小的人脸(比如远处有同事走过)即使是真人也没有打卡价值。blur_threshold取60是经验值,室内摄像头拍的图拉普拉斯方差一般在100到150,这里偏低一点是因为很多摄像头自动对焦会轻微柔焦,阈值太严会误删可用照片。质量筛查的定位不是选“最美照片”,而是别让一张模糊脸注册进底库,否则当天就有人怎么都打不上卡。
3.3 目录结构与模型文件:运行前把文件摆清楚
一个够用的demo目录大致长这样。这不是固定规范,但建议保持这种风格,后面换模型、加功能时能少折腾:
face_attendance/ ├── models/ # 检测模型onnx、特征模型onnx ├── face_db/ # 注册照片原图 ├── feature_db/ # 序列化特征文件 .npy 或 .pkl ├── utils/ │ ├── align.py # 人脸对齐 │ ├── detector.py # 人脸检测封装 │ └── embedder.py # 特征提取封装 ├── record.db # sqlite考勤记录 └── main.py # 主入口跑之前第一件事是自检模型加载:onnx模型能不能正常加载,输入输出的张量形状是不是预期值。很多demo翻车不是代码写得不行,而是模型文件路径错误、模型加载后静默失败,程序继续跑,识别结果全是空。第二件事是确认摄像头编号。Windows下笔记本自带摄像头一般是0,外接USB摄像头可能是1,main.py里如果打开0号摄像头黑屏,先换成1试试。
4. 把识别demo改造成真正的考勤逻辑:线程分离、打卡状态机与落库统计
4.1 摄像头采集与识别线程分离:卡顿和漏识别的根源
demo最容易出的两个问题:界面卡成PPT、识别结果延迟好几秒。原因一般是把读取摄像头帧、人脸检测、特征比对全放在一个循环里,识别网络一推理,整个UI就阻塞了。常见做法是生产者消费者模式:采集线程只负责从摄像头不停捞帧,识别线程从队列里取帧处理。
import queue import threading frame_queue = queue.Queue(maxsize=2) def capture_worker(camera_id=0): import cv2 cap = cv2.VideoCapture(camera_id) while True: ok, frame = cap.read() if ok and frame_queue.qsize() < 2: # 队列满就丢帧,保证消费端拿到的永远是最新帧 frame_queue.put(frame) def infer_worker(model): while True: frame = frame_queue.get() faces = model.detect(frame) if faces: emb = model.embed(frame, faces[0]) result = model.search(emb) update_ui(result) # 只写全局状态,UI线程只读逻辑说明:队列maxsize=2很关键。队列设太大,消费端还在处理旧帧,画面会越来越卡;maxsize=2时,生产端发现队列满了就丢帧,识别线程拿到的永远是最新画面,延迟反而低。识别线程里可以加每隔3帧处理一次的计数逻辑,考勤不需要每帧都识别,30帧摄像头3帧一处理,员工从走到镜头前到完成打卡有1到2秒,足够了。
参数说明:skip_count=3适合一般办公场景。人走得快或者通道窄,改成skip_count=1,CPU占用会翻倍。如果单帧识别已经超过200ms,加大skip_count没有意义,得先优化模型或者换更轻量的检测网络,靠丢帧救不了性能问题。
4.2 打卡状态机:上班、下班、重复打卡与补卡逻辑
识别出一个人不等于完成打卡,这中间隔着一个状态机。考勤至少要处理三个基本状态:上班打卡未完成、下班打卡未完成、重复打卡(比如上午打完卡中午吃完饭又走回镜头前)。日常demo我用一张简单的字典表就能处理,不需要引入重量级框架:
class AttendanceState: def __init__(self): self.last_seen = {} # emp_id -> (last_time, status) def on_recognize(self, emp_id, timestamp, is_clockin=True): # 10分钟内同一人重复识别只算一次,避免来回走动重复打卡 last = self.last_seen.get(emp_id) if last and (timestamp - last[0]).total_seconds() < 600: return False, "duplicated" status = "in" if is_clockin else "out" self.last_seen[emp_id] = (timestamp, status) return True, status逻辑说明:600秒窗口是防抖参数,不是业务规则。员工在镜头前停留几秒,识别线程可能连续识别到多帧,没有这个窗口,一次打卡会被记录好几条。is_clockin由当前时间决定:上午时段识别到算上班,下午时段识别到算下班,中间时段识别到只更新last_seen不写入记录。
参数说明:重复窗口10分钟在普通办公室够用。三班倒车间场景半小时内可能有两批人交接,窗口缩到60秒更合理。按上午下午区分上下班其实是简化逻辑,更严谨的做法是拿当前时间和排班表比对,排班表存sqlite后状态机里直接查库。
4.3 考勤落库与统计:别把记录写进Excel就跑路
很多demo最后都是把打卡记录存成Excel文件,演示阶段可以,一排查问题就痛苦。我一般用sqlite,零配置、Python自带支持、统计起来也方便:
CREATE TABLE IF NOT EXISTS attendance ( emp_id TEXT NOT NULL, emp_name TEXT NOT NULL, check_time DATETIME NOT NULL, check_type TEXT NOT NULL CHECK (check_type IN ('in', 'out')), similar_score REAL, PRIMARY KEY (emp_id, check_time) ); CREATE INDEX idx_att_time ON attendance(check_time);逻辑说明:主键用(emp_id, check_time)天然防止同一秒写入重复记录;CHECK约束限制了check_type只能写入in或out;similar_score字段建议保留,排查“为什么张三被识别成李四”时非常有用——回放那条记录对应的相似度分数,很快能判断是阈值问题还是底库问题。
当日考勤统计用一条SQL就能算出来,julianday换算工作时长在sqlite里很顺手:
SELECT emp_id, emp_name, MAX(CASE WHEN check_type='in' THEN check_time END) AS first_in, MAX(CASE WHEN check_type='out' THEN check_time END) AS last_out, CAST(ROUND((julianday(MAX(CASE WHEN check_type='out' THEN check_time END)) - julianday(MAX(CASE WHEN check_type='in' THEN check_time END))) * 24 * 60) AS INTEGER) AS work_minutes FROM attendance WHERE date(check_time) = '2025-04-14' GROUP BY emp_id, emp_name;逻辑说明:MAX(CASE WHEN ...)的写法在只有一条in一条out的时候等价于取值,但员工一天打了多次卡,能自动取最早和最晚的记录。work_minutes直接换算成分钟,方便和排班比对迟到早退。字段结构初看简单,统计“谁没打下班卡”“谁迟到超过10分钟”都很顺手。
5. 人脸考勤demo避坑清单:5个翻车现场与解决路径
5.1 翻车一:底库用手机照片,现场怎么都认不出来
现象:用手机相册自拍注册底库,现场摄像头识别率惨不忍睹,同一个人站在镜头前经常返回“未知人员”。
原因:手机照片的光照条件、拍摄角度、镜头畸变与现场摄像头差异太大。识别模型对光照分布极其敏感,手机顺光自拍和现场头顶逆光,在模型眼里几乎不是同一张脸。
解决:切换成现场摄像头重新采集底库,识别率会明显回升。注册时让员工在镜头前缓慢转头,保留左右各30度以内的侧脸照片,能显著提升现场场景下的鲁棒性。这个步骤看起来慢,实际一个人半分钟就结束,比以后每天有人打不上卡强得多。
5.2 翻车二:整帧画面直接送识别网络,卡到怀疑人生
现象:1920×1080全高清画面,人脸检测处理整帧,CPU占用率拉满,识别延迟好几秒,画面肉眼可见地卡顿。
原因:检测网络在1080p上的计算量和640×480完全不是一个量级,大多数人脸检测模型按VGA分辨率训练,高清输入并不会提高检测精度。
解决:把输入帧先缩放到宽度640再检测。人脸检测不需要全高清,640宽照样能准确定位,推理速度能提升4到6倍。注意保留缩放比例,检测出的框和关键点坐标要映射回原图,否则后续裁剪人脸会偏:
def preprocess(frame, target_width=640): h, w = frame.shape[:2] scale = target_width / w resized = cv2.resize(frame, (target_width, int(h * scale))) return resized, scale参数说明:返回两个值,resized是送给检测网络的图,scale用于坐标还原。检测结果里的x、y、w、h全部除以scale才能映射回原图坐标。很多人在这里忘记映射,导致裁出的人脸区域是空白或者偏的。另外,裁剪人脸后先外扩20到30像素再对齐,比直接严格贴着检测框切效果要好,因为检测框偶尔会切掉额头或下巴。
5.3 翻车三:戴眼镜、口罩后识别率断崖下跌
现象:员工戴上口罩后识别失败,摘下口罩立刻成功;部分戴眼镜的员工识别不了,但摘下眼镜又能过。
原因:口罩盖住嘴部区域,特征提取网络拿到的有效信息变少,整体特征向量漂移;眼镜反光会干扰关键点定位,尤其是侧光环境下镜片反光。
解决:口罩场景建议单独建一个“口罩版本”底库,让员工戴口罩时重新拍摄注册照片,而不是拿不戴口罩的底库硬扛。眼镜场景则保留戴眼镜的照片入库。我见过最省事的做法是底库里每个员工存两组特征:正常组和口罩组,识别时先看口罩检测结果是哪种状态,再选对应组比对。这样两组数据互不干扰。
5.4 翻车四:训练时挺好,一部署识别率垮掉——预处理不一致
现象:模型在开源数据集上验证准确率90%,部署到demo里识别率掉到50%。
原因:绝大多数是预处理不一致——训练时的归一化方式、像素通道顺序、resize插值、是否减均值,部署pipeline完全对不上。人脸识别模型对这种事极为敏感,差一个归一化参数,特征空间全乱。
解决:把预处理函数单独抽成一个文件,训练端和部署端共用同一份代码。最常见的一组参数是:RGB顺序、归一化除以255、resize用双线性、输入尺寸112×112,部分模型还要求按通道减均值:
def preprocess_for_embedder(face_img): # face_img: 对齐后的BGR图 rgb = cv2.cvtColor(face_img, cv2.COLOR_BGR2RGB) rgb = cv2.resize(rgb, (112, 112), interpolation=cv2.INTER_LINEAR) rgb = rgb.astype(np.float32) / 255.0 # 如果训练时用了mean/std,在这里按模型要求补上 return np.transpose(rgb, (2, 0, 1))[None, ...]逻辑说明:transpose把HWC转成CHW,[None, ...]是在最前面增加batch维度,这步漏掉onnxruntime直接报维度错误。如果识别的相似度分数整体偏低,先回去核对预处理而不是急着调阈值,绝大多数“部署后变傻”的问题出在归一化而不是模型本身。
5.5 踩坑五:拿识别结果当底库不停更新,越更新越乱
现象:为了提高识别率,有人把每日识别成功的照片持续更新到底库,结果过几天发现张三谁都像,误识率暴涨。
原因:人的日常装扮变化会被累积进特征向量,每天迭代一次,特征点逐渐漂移,最后远离原始注册照,和其他人挤在一起。
解决:底库更新要加严格校验。只有相似度高于0.70且当天首次识别成功时,才允许增量更新;更新用加权方式,新特征占0.3,原特征保留0.7,而不是直接替换。这样既能适应换发型、胖瘦变化,又不会让短期装扮变化把特征带偏。特征更新做好记录,万一出错还能回滚到原始注册版本。
6. 验证一个demo到底行不行:正负样本对与混淆矩阵
6.1 动手前先测:用正负样本对给阈值把好关
demo跑通后第一件事不是加功能,而是验证识别靠不靠谱。我会把每个员工不同时段拍的照片分成两组,一组建底库,一组做测试。然后人工构造两类pair:正样本对是同一个人两张照片,负样本对是两个人各一张照片。跑完全部pair,记录相似度分数,画出两个分布曲线。这一步不超过半小时,能避免后面反复调参数的返工。
import numpy as np def compute_pairs(pairs, embedder, align_fn, detector): scores = [] labels = [] for img1, img2, is_same in pairs: e1 = embedder.embed(align_fn(detector.detect_face(img1))) e2 = embedder.embed(align_fn(detector.detect_face(img2))) e1 = e1 / np.linalg.norm(e1) e2 = e2 / np.linalg.norm(e2) scores.append(float(np.dot(e1, e2))) labels.append(is_same) return np.array(scores), np.array(labels)逻辑说明:特征向量先做L2归一化再点乘,结果就是余弦相似度,这是比直接用cosine_dist更稳定的做法。跑完后把正负样本的分数画直方图,两个峰的重叠越小,模型在你这批数据上越可靠;重叠越大,说明底库本身或者注册阶段就有问题,先修数据再谈阈值。
考勤场景选阈值的原则很明确:优先压低负样本的误检率。误检意味着把A识别成B,考勤里这是要人工改数据的严重事故;漏检最多是让员工再刷一次脸。负样本分布高分端的99分位点当作初始阈值,一般不会错太远。
6.2 再往前走一步:检索、模型、业务三个方向
验证通过后,如果还想继续往产品方向走,优先做三件事。第一,底库超过500人时别再线性扫描,引入faiss建索引,检索耗时从毫秒级降到微秒级;第二,MobileFaceNet速度好但精度一般,有GPU可以把特征网络换成IResNet带ArcFace head的组合,识别准确率还能上一个台阶;第三也是最重要的,考勤不等于识别,补卡审批、排班联动、迟到异常推送,这些业务功能才是考勤系统真正值钱的部分。
我做过最蠢的一件事,是把识别准确率调到99%以后才发现忘了写迟到计算,最后考勤表还是手改的。现在我的习惯是每天下班把当日识别失败的帧和similar_score低于0.4的记录翻一遍,不看热闹,专看错认。这套检查习惯比任何调参都管用——阈值调得再好,底库里有脏数据,一样白搭。希望今天的这些思路能帮到你,至少让你在部署第一个demo时少走点弯路。
本文还有配套的精品资源,点击获取