简介:这是一套基于Python的人脸识别签到系统完整工程,面向具备Python基础、希望快速实现考勤或会务签到的开发者与学生。系统结合OpenCV、深度学习人脸模型与数据库管理,覆盖图像采集、预处理、特征提取、身份比对、签到记录及考勤统计等完整流程,支持记录上下班时间并统计迟到早退,可直接用于课程设计、毕业设计或企业内部门禁签到场景。包内共23个文件,包含Python源码、UI界面、XML模型参数、PNG示例图片、CSV考勤数据以及README说明文档等,整体仅1.93MB,目录清晰、便于快速定位与二次开发。目前已有1482人学习/下载。通过阅读源码和配套说明,可掌握人脸检测、特征匹配、界面交互与数据导出的具体实现思路,按自己的业务需求替换数据库或优化识别模型即可落地使用。
1. 会议室门口的签到终端:Python 人脸识别签到的真实现场
会议室门口摆一台跑着 Python 人脸识别签到的设备,人过来不用掏手机、不用报工号,摄像头前停两秒,屏幕弹出姓名,签到表里自动多一行。这套工程把从「摄像头抓人脸」到「考勤记录导出 CSV」的完整链路都串好了,图像采集走 OpenCV,检测对齐用 MTCNN,特征提取用 FaceNet,数据落在 SQLite,最后用 pandas 导出表格。它适合三类人:课设需要快速交付的同学;想在会议签到、小型门禁场里落地原型的工程师;以及想搞清楚「人脸识别签到到底等不等于人脸检索」的算法学习者。先说一个反直觉的结论:系统不是在核对两张脸像不像,而是在比对两个 128 维特征向量离得近不近——理解这一点,后面所有阈值的调整逻辑就通了一半。
2. 识别链路选型:MTCNN + FaceNet 为什么比现成方案更适合这套工程
2.1 三段式识别链路:检测、对齐、特征
人脸识别这条链路,拆开看是三个独立问题:人脸在哪里、人脸摆正了没有、这张脸是谁。第一问由检测器回答,第二问由关键点对齐回答,第三问才轮到特征提取和比对。
MTCNN 在这个工程里承担前两步。它输出的不只是人脸框,还有左眼、右眼、鼻尖、左嘴角、右嘴角五个关键点。有了关键点,就能把人脸做仿射变换,把眼睛拉到同一水平线,脸也就被「摆正」了。这一步极其关键:一个人仰头、低头、侧脸 15 度时,原始像素分布差别非常大,但摆正之后,输入到特征网络里的内容就稳定得多。FaceNet 接收的是对齐后的人脸图像,输出一个 128 维的浮点向量。这个向量就是这张脸的「指纹」。
比对阶段用的是欧氏距离。由于特征向量先做了 L2 归一化,欧氏距离和余弦相似度在排序结果上是等价的。实际工程里我更习惯用欧氏距离,因为它直观:距离 0 意味着完全一样,距离越远越不可能是同一个人。签到场景需要的是一个距离阈值,小于阈值判定为本人,大于阈值就拒绝。
2.2 与现成库和传统方案的取舍
很多人一开始会问:直接用 face_recognition 库不好吗,十几行就搞定。这话对一半。face_recognition 底层是 dlib 的 CNN 检测器加 ResNet 特征提取,本质上和 MTCNN + FaceNet 是同一套思路。但它把中间步骤全部封装死了,你想看检测置信度、调关键点对齐、改距离计算方式,都进不去。我把这套方案和另外两条常见路线放在一起对比过:
| 方案 | 优点 | 局限 |
|---|---|---|
| OpenCV Haar 级联检测器 | 轻量、CPU 上快 | 只出人脸框,没有关键点,不做对齐,角度稍偏就废 |
| face_recognition 全家桶 | 代码量极小,跑通最快 | 黑匣子,参数被封死;底层 dlib 在部分 IOT 板子上编译费劲 |
| MTCNN + FaceNet 手动链路 | 检测、对齐、特征全环节可改 | 要下载预训练权重,入门时多几步 |
项目名带 IOT 字样,说明它要考虑树莓派这类小设备。dlib 在树莓派上从源码编译耗时很长,而 MTCNN 的推理模型是纯 TensorFlow 图,直接加载权重就能跑,换环境成本低。FaceNet 的权重文件虽然不小,但只做前向推理,CPU 也能维持在可用的帧率范围。我在门禁类场景也验证过这套链路:门禁对误识别率要求更高,通常会把阈值收得更紧一点,签到场景则可以适当放宽,这就是为什么把链路参数暴露出来比封装死更有价值。
2.3 工程模块划分与目录结构
装完依赖后,我建议先对着目录结构把每个文件的职责理一遍。这套工程解压出来大致按下面这种模块拆分,虽然不同压缩包里命名略有差异,但业务边界是近似这样的:
IOT_FaceRecogition_Attendance/ ├── README.md ├── requirements.txt ├── services/ │ ├── detect_face.py # MTCNN 人脸检测与关键点封装 │ └── embedding.py # FaceNet 特征提取封装,输出 128 维向量 ├── modules/ │ ├── face_register.py # 建库录入:把照片变成特征向量存起来 │ ├── recognizer.py # 实时识别主循环,负责连续帧确认 │ └── attendance.py # 签到与考勤数据管理,写 SQLite ├── gui_main.py # Tkinter 界面入口 └── data/ # 运行后生成向量库与数据库services 层只做视觉模型相关的事,modules 层做业务逻辑,gui_main.py 负责把两者接到界面上。依赖文件 requirements.txt 里的核心是这么几项:opencv-python 负责读摄像头和图像处理,mtcnn 做检测,tensorflow 加载 FaceNet 权重,numpy 做向量计算,pandas 负责考勤表导出,Pillow 给 Tkinter 显示图像用。装环境的命令就一条:
pip install opencv-python mtcnn numpy pandas Pillow tensorflow我一般建议在 Python 3.8 以上的环境装,太新的 Python 配旧版 TensorFlow 容易踩编译坑。装完后用 vscode 或 PyCharm 的虚拟环境配置,把解释器指到当前 venv 即可。Windows 上建库时照片目录别放中文路径,这个坑后面专门说。
2.4 首次运行的整体顺序
正确的启动顺序是:先建库,再识别,最后才开界面。建库阶段只需要把每个参与签到的人的照片准备好,运行录入脚本生成向量库;识别阶段读摄像头,加载向量库做实时比对;界面阶段把识别结果显示出来并联通写库逻辑。三步拆开跑的好处是,每一步错了都能独立定位,不至于一启动就报一串错误分不清源头。
cd IOT_FaceRecogition_Attendance python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -r requirements.txt python modules/face_register.py --photo ./data/photos这个流程里最容易被忽略的是第 4 行:录进去的照片质量直接决定后面识别能不能用。下面一章专门说建库和识别主循环怎么把向量用起来。
提示:先让录入脚本跑通一张照片,确认 MTCNN 能检测出人脸并生成向量,再批量处理整个目录。
3. 把脸变成 128 维向量:建库录入与实时识别主循环的实现
3.1 建库录入:为什么不建议只存一张照片的向量
录入阶段的目标是给每个人生成一个代表向量。工程里常见的做法是:把同一个人的多张照片分别过检测和特征提取,得到多组向量,然后取平均作为这个人的人脸指纹。原因很好理解:同一张脸在上午和傍晚的光线下,特征向量会有波动,单张照片的向量可能落在某个极端位置上,而多张平均能把波动抹掉一部分。
# face_register.py 核心片段 import os import cv2 import numpy as np from mtcnn import MTCNN from services.face_model import FaceEmbedding detector = MTCNN() embedder = FaceEmbedding() # 加载预训练 FaceNet 权重,embed() 输出 128 维向量 face_db = {} def register_person(name, image_dir): vectors = [] for img_name in sorted(os.listdir(image_dir)): path = os.path.join(image_dir, img_name) img_bgr = cv2.imread(path) if img_bgr is None: continue img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) detections = detector.detect_faces(img_rgb) if not detections: continue box = detections[0]['box'] x1, y1, w, h = box face = img_rgb[y1:y1 + h, x1:x1 + w] face = cv2.resize(face, (160, 160)) vec = embedder.embed(face) # 形状 (1, 128) vec = vec / np.linalg.norm(vec) # L2 归一化 vectors.append(vec) if vectors: face_db[name] = np.mean(vectors, axis=0) return len(vectors)代码里detector.detect_faces()返回的是人脸框的坐标和置信度,box给出左上角 x、y 以及宽高,切出来后必须 resize 到 FaceNet 要求的输入尺寸(这个工程是 160x160)。np.linalg.norm做 L2 归一化是为了让后续距离计算不受人脸图像整体亮度差异影响。最后np.mean(vectors, axis=0)把同一人多张照片的特征向量逐维平均,得到最终入库向量。
参数上要注意两个点:人脸检测置信度默认值通常在 0.9 以上,如果 MTCNN 对某些照片死活检测不到,先把置信度暂时调低再试;每张照片里如果有多个脸,detections[0]只取第一个,录入脚本建议只放单人照片,否则容易张冠李戴。
3.2 实时识别主循环:连续帧命中加超时失效
实时识别最容易翻车的不是识别算法本身,而是「误触发」。摄像头前有人路过,侧脸被 MTCNN 框住,特征向量又恰好和库里的某个人接近,单帧比对成功就写库的话,一天能签出七八条假记录。我在识别主循环里加了两道闸:连续命中 N 帧才确认,两次命中之间超过 1.5 秒就作废重来。
# recognizer.py 识别主循环的核心状态机 import time import cv2 import numpy as np HIT_THRESHOLD = 3 # 连续命中 3 帧才确认签到 HIT_EXPIRE = 1.5 # 相邻两次命中间隔超过 1.5 秒则清空累计 hit_count = {} last_hit_time = {} while True: ok, frame = cap.read() if not ok: break frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) detections = detector.detect_faces(frame_rgb) name = None if detections: box = detections[0]['box'] face = crop_resize(frame_rgb, box) vec = embedder.embed(face) name, dist = match_face(vec, face_db, threshold=0.6) now = time.time() if name: hit_count[name] = hit_count.get(name, 0) + 1 last_hit_time[name] = now else: hit_count.clear() # 检测到了人脸但没人匹配上,重置 # 累计过程中间断太久,说明人已经离开,作废 for k in list(hit_count): if now - last_hit_time[k] > HIT_EXPIRE: hit_count.pop(k) if name and hit_count[name] >= HIT_THRESHOLD: attendance_queue.put(name) # 交给写库线程处理 hit_count.clear() time.sleep(5) # 确认完成后 5 秒冷静期match_face的比对逻辑是这样:遍历库中所有向量,取欧氏距离最小的那个名字,如果距离小于阈值就返回名字,否则返回 None。
def match_face(emb, face_db, threshold=0.6): best_name, best_dist = None, float('inf') for name, vec in face_db.items(): dist = np.linalg.norm(emb - vec) if dist < best_dist: best_name, best_dist = name, dist if best_dist <= threshold: return best_name, best_dist return None, best_dist这里的阈值 0.6 是经验默认值。门禁类更严格的场景我会收到 0.45 到 0.55,签到场景放到 0.6 能让通过率更高。但要注意:阈值必须用你自己环境的数据来定,后面第 6 章专门讲怎么用抽帧实验确定它,不要直接拿网上抄来的数字上生产。
3.3 识别帧率与主循环的取舍
实时识别不需要 30fps 跑满,人脸走进摄像头视野、停下来、被确认,整个过程有 2 到 3 秒窗口,10 到 15fps 足够。所以我在主循环里刻意不追求高帧率,把省下来的 CPU 留给检测器。摄像头分辨率我一般设置 640x480,再高对 MTCNN 检测提升有限,反而拉高延迟。
需要注意,识别线程不要自己做 UI 刷新,也不要直接写数据库。代码里用的是attendance_queue.put(name),把写库动作丢给独立线程。这样做的原因是 SQLite 连接默认不能跨线程复用,识别线程和写库线程共用同一个连接,会在运行几分钟后突然报sqlite3.ProgrammingError,这也是一个高频翻车点。
4. 签到记录落到数据库:SQLite 表设计、状态机与考勤 CSV 导出
4.1 两张表和当天状态机
人脸识别只解决「这个人是谁」,签到的业务逻辑是另一层:什么时候记签到、什么时候记签退、重复识别怎么办。这个工程用 SQLite 存数据,一张表存人员基础信息,另一张表存签到流水。我把关键字段列在下面:
| 表名 | 字段 | 说明 |
|---|---|---|
| employees | id / name / reg_time | 人员注册信息,识别确认后对应到 name |
| attendance | id / name / date / check_in / check_out | 每人每天一条记录,签退时更新 check_out |
签到逻辑是一个小型状态机:当天第一条记录写 check_in,第二条把 check_out 补上,第三条及以后直接忽略。这样一个人在镜头前反复进出不会产生垃圾数据。
# attendance.py 签到状态机 from datetime import datetime def sign(name, conn): now = datetime.now() today = now.strftime('%Y-%m-%d') cur = conn.cursor() cur.execute( 'SELECT id, check_in, check_out FROM attendance WHERE name=? AND date=?', (name, today)) row = cur.fetchone() if row is None: cur.execute( 'INSERT INTO attendance(name, date, check_in, check_out) VALUES(?,?,?,?)', (name, today, now.strftime('%H:%M:%S'), None)) conn.commit() return 'check_in' if row[1] and row[2] is None: cur.execute( 'UPDATE attendance SET check_out=? WHERE id=?', (now.strftime('%H:%M:%S'), row[0])) conn.commit() return 'check_out' return 'already'这个函数返回三个状态值:check_in表示首次签到成功,check_out表示签退成功,already表示当天已经签满。识别模块调用它之后可以根据返回值决定界面提示语。这里的一个细节是:查询条件用的是name + date,没有依赖自增 id 判断当天唯一性,因为同一个人同一天只允许一条记录,这个组合天然就是业务主键。
4.2 考勤统计与 CSV 导出
考勤计算放在 pandas 里做比写在 SQL 里更灵活。每个月的原始数据拿出来后,先算工作时长,再算迟到分钟数,最后导出 CSV。导出时我强制用utf-8-sig编码,这是开 Excel 不乱码的后悔药,踩过一次就记住了。
import pandas as pd df = pd.read_sql_query('SELECT * FROM attendance', conn) df['check_in'] = pd.to_datetime(df['check_in'], format='%H:%M:%S') df['check_out'] = pd.to_datetime(df['check_out'], format='%H:%M:%S') work_minutes = (df['check_out'] - df['check_in']).dt.total_seconds() / 60 df['work_minutes'] = work_minutes.fillna(0).astype(int) late = (df['check_in'] - pd.Timestamp('09:00:00')).dt.total_seconds() / 60 df['late_minutes'] = late.clip(lower=0).fillna(0).astype(int) df.to_csv('attendance_export.csv', index=False, encoding='utf-8-sig')fillna(0)处理的是那些只有签到没有签退的数据,clip(lower=0)把早到的人迟到分钟数压成 0,避免出现负数。encoding='utf-8-sig'是 CSV 给 Excel 用的关键,普通utf-8导出的文件用 Excel 打开中文列名会乱码,这是这门课里最容易让人抓狂的细节。
4.3 识别与写库解耦:一个连接带来的教训
识别主循环和数据库模块之间,我坚持只通过队列传名字。前面提到 SQLite 连接不能跨线程随便用,这个坑我实际遇到过:识别线程创建了连接,UI 线程也拿同一个连接去读表,程序跑几分钟后突然报错。原因就是 Python 的 sqlite3 模块默认校验线程归属,同一个连接在多个线程里轮换使用就会触发异常。
正确做法是让数据库连接只属于写库线程。识别线程把 name 放进queue.Queue,写库线程阻塞在queue.get()上,拿到名字后调用sign()写入并 commit。界面线程如果要刷新签到记录,重新开一个只读连接查最新数据就行。这样每个线程各管各的连接,互不干扰,也比在主循环里搞锁更省心。
5. 拼一个能上台面的界面:Tkinter 整合与四个高频翻车点排查
5.1 Tkinter 还是 PyQt:按现场需要选
这个工程的界面入口用的 Tkinter,原因很直接:它是 Python 标准库自带的,不用额外装东西,在一台刚装好环境的机器上最不容易出幺蛾子。PyQt 界面更现代,但打包体积大,IOT 设备上跑起来也更重。如果只是会议室签到这种展示型场景,Tkinter 足够。
界面上需要显示三样东西:摄像头实时画面、最近一次识别结果、当前签到人数。核心难点是摄像头读帧不能放在 Tkinter 主循环里,否则窗口一拖动整个画面就卡死。我的做法是单独开一个线程读帧刷新图像,UI 更新通过after传回主线程。
# gui_main.py 界面更新核心片段 import threading import time import tkinter as tk from PIL import ImageTk, Image def start_camera(): threading.Thread(target=camera_loop, daemon=True).start() def camera_loop(): while True: frame = grab_frame() # 从摄像头取一帧,已转成 RGB img = Image.fromarray(frame) imgtk = ImageTk.PhotoImage(image=img) panel.imgtk = imgtk # 防止 PhotoImage 被垃圾回收 panel.after(0, update_panel, imgtk) time.sleep(0.03) def update_panel(imgtk): panel.configure(image=imgtk)panel.imgtk = imgtk这一行看着多余,但凡是做过 Tkinter 图像显示的人都知道漏掉它会出现画面闪一下就变灰的怪问题——PhotoImage 引用被回收,图像就没了。after(0, ...)是把更新动作排进 Tkinter 主线程的事件队列,避免跨线程直接操作控件导致的随机崩溃。这个小经验每次搭界面都会用到。
5.2 高频翻车点排查
以下五条都是现场跑出来的血泪记录,每条按现象、原因、解决的顺序写,对着查就好。
现象:摄像头前有人路过,系统隔三差五刷出一条签到记录。 原因:单帧识别成功就直接写库,没有确认窗口。人走动时的侧脸、模糊帧有概率碰巧离某个人的库向量很近。 解决:识别主循环里加连续命中计数和超时失效逻辑,确认 3 帧再放行进队列,写库前再加 5 秒冷静期。
现象:中文路径下摄像头打不开、模型加载失败。 原因:OpenCV 的 VideoCapture 和 TensorFlow 在 Windows 下对非 ASCII 路径处理不友好,跟代码逻辑无关。 解决:工程根目录、照片目录、模型目录全部改成英文小写;读取文件前先用
os.path.exists判断,能直接定位到是路径问题还是文件缺失问题。现象:逆光或暗光会场,识别率突然崩掉。 原因:MTCNN 在低对比度下漏检,FaceNet 的训练数据偏向正常光照,暗光输入会让特征向量整体偏移。 解决:取帧后先做 CLAHE 自适应直方图均衡化再送检测和识别。注意检测前也要做,只做识别前的处理不够,检测器同样怕暗光。
现象:阈值从网上抄了个 0.6,结果 A 被识别成 B。 原因:0.6 是别人环境下的经验值。不同摄像头、不同录入照片、不同现场光线,距离分布完全不一样。 解决:用第 6 章的抽帧实验先收集自己的距离分布数据,再定阈值。换摄像头必须重做,参数永远跟环境走。
现象:
python gui_main.py跑得好好的,打包成 exe 后一启动就报模型文件找不到。 原因:PyInstaller 默认不带项目根目录里散落的权重文件,运行时路径也变了。 解决:打包时用--add-data把模型目录带进去,代码里用sys._MEIPASS兼容解压路径与源码路径。
import os, sys if getattr(sys, 'frozen', False): base_dir = sys._MEIPASS # PyInstaller 临时解压目录 else: base_dir = os.path.dirname(__file__) model_path = os.path.join(base_dir, 'models/facenet_model.h5')6. 抽帧实验定阈值:上线前最后一道验证与数据增强退路
6.1 用真实视频跑出一份距离报告
定阈值这件事,我习惯用一段现场拍的真实视频来做:让签到人员在镜头前正常走动、停留、侧身,再让几个非注册人员也经过摄像头,程序抽帧比对并记录每一帧的距离。这个脚本输出的是一张表,几十行数据里能看到本人的距离范围、陌生人的距离范围,阈值往两者中间切就是最合理的取值。
# offline_threshold_test.py import cv2, csv from recognizer import verify_frame # 返回 (name, distance) cap = cv2.VideoCapture('field_video.mp4') writer = csv.writer(open('distance_report.csv', 'w', newline='')) writer.writerow(['frame', 'predicted', 'distance']) frame_idx = 0 step = 30 # 每 30 帧抽一帧,约每 1 秒采一个点 while True: ok, frame = cap.read() if not ok: break if frame_idx % step == 0: name, dist = verify_frame(frame) writer.writerow([frame_idx, name, round(dist, 4)]) frame_idx += 1抽帧间隔按视频长度调:30 秒的短片每 15 帧抽一帧,样本量太少了统计没有意义。我通常每个场景收集 40 到 60 个样本点,然后看两类距离有没有明显分层。下面是我一个会议室环境跑出来的示意数据分布:
| 人员类型 | 距离范围 | 判定建议 |
|---|---|---|
| 注册人员本人 | 0.28 - 0.42 | 阈值可以放在 0.50 |
| 未注册陌生人 | 0.61 - 0.92 | 阈值放在 0.60 会漏放一部分 |
| 侧脸、暗光等困难样本 | 0.43 - 0.57 | 阈值需要结合现场容忍度 |
这个表说明一件事:如果本人距离和陌生人距离有清晰间隔,阈值很好选;如果两类分布混在一起,阈值怎么调都会误判,这时候该回头优化录入照片的光照和角度,而不是继续折腾阈值。
6.2 上线前验证清单
每次部署前我都会把下面这张表跑一遍再放人用,宁可花掉一个下午也不想上线后被人在群里 @。
| 验证项 | 预期结果 |
|---|---|
| 注册人员正常走入镜头 | 2 秒内弹姓名,签到表出现 check_in |
| 同一个人第二次经过 | 更新 check_out,不产生第二条记录 |
| 陌生人在镜头前晃 | 界面提示未注册,不写库 |
| 人脸离开后 5 秒内再回来 | 冷静期内不重复签到 |
| 导出 CSV 用 Excel 打开 | 中文列名和内容是正常的,不是乱码 |
6.3 数据增强与后悔药
如果每个人的录入照片只有一两张,直接平均向量容易偏。我一般会在录入前对照片做三个简单增强:水平翻转、亮度微调、随机小幅度裁剪。三张变六张,向量平均后可靠程度明显提升。增强代码很短:
img_flip = cv2.flip(img_rgb, 1) # 水平翻转 img_bright = cv2.convertScaleAbs(img_rgb, alpha=1.1, beta=20) # 提亮 img_crop = cv2.resize(face, (160, 160)) # 人脸对齐后的常规裁剪另外一个习惯是从第一天上线开始,就保留每一帧识别结果到日志文件,包括时间、预测人名、距离值。别小看这个日志,它是后来所有问题的证据:有人说「我上午明明签到了」,一查日志,距离 0.71,当时系统没认出来是正确行为。保留距离日志等于给整个系统留后悔药,排查纠纷全靠它。
从那以后,我每换一个摄像头、每换一个会场,都会先跑一遍抽帧实验再定阈值,不再拿网上抄来的参数当玄学用。希望帮到你。
本文还有配套的精品资源,点击获取