
简介这是一套基于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, axis0) return len(vectors)代码里detector.detect_faces()返回的是人脸框的坐标和置信度box给出左上角 x、y 以及宽高切出来后必须 resize 到 FaceNet 要求的输入尺寸这个工程是 160x160。np.linalg.norm做 L2 归一化是为了让后续距离计算不受人脸图像整体亮度差异影响。最后np.mean(vectors, axis0)把同一人多张照片的特征向量逐维平均得到最终入库向量。参数上要注意两个点人脸检测置信度默认值通常在 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, threshold0.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, threshold0.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 存数据一张表存人员基础信息另一张表存签到流水。我把关键字段列在下面表名字段说明employeesid / name / reg_time人员注册信息识别确认后对应到 nameattendanceid / 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(lower0).fillna(0).astype(int) df.to_csv(attendance_export.csv, indexFalse, encodingutf-8-sig)fillna(0)处理的是那些只有签到没有签退的数据clip(lower0)把早到的人迟到分钟数压成 0避免出现负数。encodingutf-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(targetcamera_loop, daemonTrue).start() def camera_loop(): while True: frame grab_frame() # 从摄像头取一帧已转成 RGB img Image.fromarray(frame) imgtk ImageTk.PhotoImage(imageimg) panel.imgtk imgtk # 防止 PhotoImage 被垃圾回收 panel.after(0, update_panel, imgtk) time.sleep(0.03) def update_panel(imgtk): panel.configure(imageimgtk)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, alpha1.1, beta20) # 提亮 img_crop cv2.resize(face, (160, 160)) # 人脸对齐后的常规裁剪另外一个习惯是从第一天上线开始就保留每一帧识别结果到日志文件包括时间、预测人名、距离值。别小看这个日志它是后来所有问题的证据有人说「我上午明明签到了」一查日志距离 0.71当时系统没认出来是正确行为。保留距离日志等于给整个系统留后悔药排查纠纷全靠它。从那以后我每换一个摄像头、每换一个会场都会先跑一遍抽帧实验再定阈值不再拿网上抄来的参数当玄学用。希望帮到你。本文还有配套的精品资源点击获取