1. 为什么办公楼考勤终于扛不住了,我决定自己写一套
先说背景。我接到的需求来自一个朋友所在的中型写字楼,他们原来用的是指纹打卡机,后来又换过一轮刷卡门禁。不管怎么换,行政那边每个月月底对考勤报表的时候都有一堆莫名其妙的申诉:指纹机冬天识别率低到离谱,手指脱皮的人直接打不上卡;刷卡的方式更不用提,同事之间互相"代刷"几乎拦不住,还有人把卡直接放在工位上,到公司楼下先远程"打个卡"再慢悠悠去吃早饭。
人脸识别打卡要解决的,其实就是这三类问题:识别介质不可靠(指纹受皮肤状态影响)、存在代打替打(卡和手机可以转交)、考勤数据需要二次人工处理(报表对不上)。
我确认了使用场景之后,明确了这套软件的目标范围:写字楼前台或闸机口放一台带摄像头的主机,员工走到摄像头前停留一两秒,系统自动识别并记录上下班时间;后台能看到当天谁来了、谁没来、谁迟到、谁早退;同时能阻挡那些没有录入人脸信息的陌生人。整套系统用 Python 实现,核心识别部分基于现成的人脸识别库完成,数据存储用轻量级数据库搞定,不需要专门的服务器,一台普通办公电脑就能跑。
项目整体包含源码、数据库脚本和执行文档,哪怕之前没怎么接触过图像识别的朋友,只要照着文档操作也能把系统搭起来。这篇文章我把整个项目从需求拆解、选型、编码到踩坑的过程完整写出来,重点讲那些文档里不会写的细节:特征阈值怎么设、重复打卡怎么防、识别速度怎么提、光照变差了怎么办。
2. 系统组件划分与选型逻辑:一套可以落地的人脸打卡方案
2.1 技术栈的最终选择与理由
技术栈选择上,我几乎没怎么纠结就定下了组合:Python 3.8 + OpenCV + face_recognition + SQLite。这套组合最大的优势是"能跑、好改、资料多"。
- face_recognition封装了 dlib 的人脸检测和特征提取能力,对外提供了非常简洁的 API,一句话就能检测人脸位置,再一句话就能拿到人脸特征向量。它底层用的是 HOG(方向梯度直方图)特征加线性 SVM 做检测,CNN 版本作为精度更高的备选。
- OpenCV负责摄像头取流、图像帧处理和显示,这个库在计算机视觉领域太常用了,读摄像头、转颜色空间、缩放帧都是现成的。
- SQLite是单文件数据库,不需要单独的数据库服务,适合这种接入量不大的场景。一天最多几百条打卡记录,SQLite 完全吃得消,部署时也不用让用户去装独立的数据库服务端。
为什么不选更"高级"的方案?我也考虑过用深度学习模型重新训练一个人脸识别网络,比如用开源预训练模型提取特征,再自己训分类头。但那是另一个量级的工作量:需要收集大量员工照片做数据增强,需要 GPU 训练,需要处理过拟合问题。而 face_recognition 的预训练模型在一般室内光照条件下识别率已经相当不错,对个人项目来说属于"拿现成零件组装一台机器",远比"从零造零件"现实。
有人可能会问,为什么不用现成的云人脸识别接口?我最初也评估过这个方向,结论是不合适。打卡是高频操作,每一帧识别都走网络接口,一是延迟不可控,员工站在闸机前等接口返回的体验很糟糕;二是按次计费,全天几百次调用累计成本不低;三是人脸数据属于敏感个人信息,员工照片全部传到外部服务,在隐私和合规层面很难向员工解释清楚。本地识别虽然要自己维护模型,但这套场景的数据完全不出办公室,从哪个角度看都更稳妥。
2.2 数据库设计与整体运行流程
数据库只设计了两张表,一张存员工档案,一张存打卡记录:
CREATE TABLE employees ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, department TEXT, face_encoding BLOB NOT NULL, photo_path TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE attendance_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, employee_id INTEGER NOT NULL, date TEXT NOT NULL, check_in_time DATETIME, check_out_time DATETIME, status TEXT DEFAULT 'normal', FOREIGN KEY (employee_id) REFERENCES employees(id) );员工表里的face_encoding是核心字段,存放的是人脸特征向量。要注意 SQLite 没有原生的数组类型,需要把向量序列化之后存进去,我这里用的是 pickle 序列化后写 BLOB 字段,读出来时再反序列化还原成 numpy 数组。
整条业务的运行流程是这样闭环的:
- 管理员打开建档界面,输入员工姓名、部门,摄像头拍一张正脸照片,系统提取特征向量存入员工表。
- 考勤机程序启动后,摄像头持续取帧,对每一帧做人脸检测。
- 检测到人脸后,提取当前人脸的特征向量,和员工表里所有向量做距离计算。
- 最近距离低于阈值,判定为该员工,接着查今天是否已打卡,没有就写入一条打卡记录。
- 最近距离高于阈值,判定为陌生人,界面提示未登记,同时可以保存一张截图备查。
这套流程里最关键的判断逻辑就两处:一是"距离阈值定多少",二是"重复打卡怎么处理"。这两块我会在后文专门展开。
3. 员工人脸建档与特征入库的关键代码
3.1 人脸特征提取:128维向量的生成原理
先花一点篇幅讲清楚人脸特征到底是个什么东西,否则后面代码写起来容易两眼一抹黑。
face_recognition 库的face_encodings方法内部做的事情是:先用 dlib 的模型在 68 个关键点(眼睛、鼻子、嘴巴、下巴轮廓等)上对齐人脸,然后经过一个在数百万张人脸图片上训练好的深度网络,把这张脸映射成一个128 维的浮点向量。这个向量的含义不是"眼睛大小""鼻子高度"这种直观属性,而是这张脸在特征空间里的坐标位置。
判断两张脸是不是同一个人的时候,不需要用什么分类器,直接计算两个向量之间的欧氏距离:距离越小,说明两张脸在特征空间里越靠近,越可能是同一个人。face_recognition 库的compare_faces方法内部就是干这件事的,它接收一个容差阈值tolerance,默认是 0.6,距离小于这个值的算匹配。
这个 0.6 的默认值在多数场景下够用,但不建议直接照搬。后面我会给出一个实测参考区间和调参方法。
3.2 建档模块的实现与批量处理
建档模块的代码核心其实很短,难点在摄像头取景、拍照时机和图像质量把控上。先贴一段最能说明问题的建档主逻辑:
import cv2 import face_recognition import pickle import sqlite3 def register_employee(name, department, db_path="attendance.db"): cap = cv2.VideoCapture(0) if not cap.isOpened(): raise RuntimeError("无法打开摄像头,请检查设备索引或驱动") print("请正对摄像头,保持光线充足,按空格键拍照建档") while True: ret, frame = cap.read() if not ret: continue # 缩小帧加快显示速度,但并不影响后续建档精度 display_frame = cv2.resize(frame, (0, 0), fx=0.5, fy=0.5) cv2.imshow("Register", display_frame) key = cv2.waitKey(1) & 0xFF if key == ord(" "): break elif key == ord("q"): cap.release() cv2.destroyAllWindows() return cap.release() cv2.destroyAllWindows() # 转成RGB,这是face_recognition最常见的坑 rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) face_locations = face_recognition.face_locations(rgb_frame, model="hog") if len(face_locations) == 0: print("未检测到人脸,请重试") return if len(face_locations) > 1: print("检测到多张人脸,请确保画面里只有本人") return face_encoding = face_recognition.face_encodings(rgb_frame, face_locations)[0] # 序列化入库 blob = pickle.dumps(face_encoding) conn = sqlite3.connect(db_path) conn.execute( "INSERT INTO employees(name, department, face_encoding, photo_path) VALUES(?, ?, ?, ?)", (name, department, blob, "") ) conn.commit() conn.close() print(f"员工 {name} 建档成功,特征维度: {len(face_encoding)}")这里有两个细节值得单独强调。
第一,RGB 和 BGR 的顺序问题。OpenCV 的读取方法读出来的图像通道顺序是 BGR,而 face_recognition 底层是按 RGB 处理的。如果不转换就直接传给人脸识别库,人脸检测通常也能跑,但特征提取结果可能不稳。所以我习惯在进入人脸识别流程之前一律用cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)转一次,这个习惯能省掉后面一大半"为什么识别率忽高忽低"的排查时间。
第二,建档时的多脸检测。办公环境下摄像头画面里很可能突然走过其他人,如果建档时拍进去两张脸,程序只取第一张,很可能存错人。所以建档环节一定要做人数判断,多于一张脸直接拒绝,宁可让操作人多拍两次。
批量建档的思路也一样,无非是循环读取多张照片文件。我建议照片分辨率不低于 320x240,人脸区域占画面比例不低于三分之一,正面、无遮挡、光线均匀为佳。实际测试下来,如果照片太糊或者侧脸角度超过 30 度,识别效果就会明显下降。
4. 实时识别、防重复打卡与迟到判断的实现细节
4.1 摄像头帧采集与人脸检测联动
考勤机的识别主循环看起来不复杂,但真正稳定运行起来有几个细节必须处理好。先贴上核心代码骨架:
import cv2 import face_recognition import pickle import sqlite3 from datetime import datetime class AttendanceKiosk: def __init__(self, db_path="attendance.db", tolerance=0.5): self.conn = sqlite3.connect(db_path, check_same_thread=False) self.tolerance = tolerance self.load_employee_encodings() self.cap = cv2.VideoCapture(0) self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) self.cap.set(cv2.CAP_PROP_FPS, 30) def load_employee_encodings(self): rows = self.conn.execute( "SELECT id, name, department, face_encoding FROM employees" ).fetchall() self.employee_ids = [r[0] for r in rows] self.employee_names = [r[1] for r in rows] self.known_encodings = [pickle.loads(r[3]) for r in rows] def run(self): while True: ret, frame = self.cap.read() if not ret: continue # 缩小帧到1/4,识别速度会快很多 small_frame = cv2.resize(frame, (0, 0), fx=0.25, fy=0.25) rgb_small_frame = cv2.cvtColor(small_frame, cv2.COLOR_BGR2RGB) face_locations = face_recognition.face_locations( rgb_small_frame, model="hog" ) if not face_locations: cv2.imshow("Attendance", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break continue face_encodings = face_recognition.face_encodings( rgb_small_frame, face_locations ) for face_encoding in face_encodings: distances = face_recognition.face_distance( self.known_encodings, face_encoding ) index = int(distances.argmin()) min_distance = float(distances[index]) if min_distance <= self.tolerance: emp_id = self.employee_ids[index] self.record_check_in(emp_id, min_distance) else: # 陌生人处理:保存截图 filename = f"unknown_{datetime.now():%Y%m%d_%H%M%S}.jpg" cv2.imwrite(filename, frame) cv2.imshow("Attendance", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break self.cap.release() cv2.destroyAllWindows()强调两个关于性能的细节。
一是缩小帧。识别速度是考勤场景很重要的指标,员工不会乐意站在闸机前等三秒。把传入识别的帧缩小到 1/4,人脸检测耗时会从原来的几百毫秒降到几十毫秒级别,而 640x480 的原始帧里人脸区域经过缩小后依然能保持 80x80 以上的尺寸,对识别影响不大。
二是HOG 模型和 CNN 模型的取舍。人脸检测支持model="cnn",CNN 在复杂背景下检测效果更好,但速度会慢几倍。我的实测结果:CPU 环境下 HOG 检测 640x480 帧大约 80~150ms,CNN 则要 500ms 以上。写字楼前台场景背景相对固定,光照可控,HOG 够用。如果部署环境实在复杂,我建议的做法是用 HOG 做首帧快速检测,检测不到再切换 CNN 兜底,而不是全程开 CNN。
4.2 身份判定:阈值到底设多少合适
阈值选择是整个系统里最需要调优的参数,直接决定误识别率和漏识别率。
我先给一个基于我这边几十个测试样本的经验结论:0.4~0.55 之间是比较合理的区间。0.6 是官方默认值,偏松,在人员较多的场景会出现不同的人互认错误;0.3 以下又太严,换个发型、戴个眼镜就识别不出来了。
调参的方法不是拍脑袋,而是收集数据。把每个员工的照片多拍几次,分别计算"本人比对距离"和"与其他人比对距离"这两组数据,然后选取一个让两类距离分布最大程度分开的数值。实际操作中,我会把两组距离数据打印出来观察,通常能看到明显的分层:
本人比对距离: 0.18, 0.22, 0.25, 0.24, 0.21 他人比对距离: 0.42, 0.51, 0.47, 0.58, 0.55像上面这种数据,阈值取 0.3 左右就能完美分开。但如果某个员工的本人比对距离到了 0.35(常见原因是建档照片太糊或者现场光照变化太大),阈值就得放宽到 0.45 附近,这时候就要接受一定的误识风险。所以回到源头:建档时把照片质量控好,比什么都重要。
4.3 打卡记录逻辑:防重复与上下班判定
打卡逻辑的坑在于"同一个人连续几帧都会被识别到"。摄像头一秒 15~30 帧,如果每一帧匹配成功都写一条记录,一个人站在摄像头前两秒钟就能刷出几十条打卡记录。
我的做法是加两个层次的过滤:
- 同一分钟内不重复记录。识别成功后,记下当前员工 id 和打卡时间,接下来一分钟内即使再次识别到该员工,也只刷新界面上"已打卡"的状态,不写数据库。
- 区分上班和下班。更精确的做法是看当天第一条记录和最后一条记录。一天内首次识别成功记为
check_in_time,超过间隔时间之后再次识别成功则记check_out_time。如果一天内只有一条记录,默认只有上班没下班。
具体到代码:
def record_check_in(self, emp_id, distance): today = datetime.now().strftime("%Y-%m-%d") now = datetime.now() row = self.conn.execute( "SELECT id, check_in_time, check_out_time FROM attendance_records " "WHERE employee_id=? AND date=?", (emp_id, today) ).fetchone() if row is None: self.conn.execute( "INSERT INTO attendance_records(employee_id, date, check_in_time) " "VALUES(?, ?, ?)", (emp_id, today, now.strftime("%H:%M:%S")) ) self.conn.commit() return record_id, check_in, check_out = row if check_out: return # 当天已有完整记录,忽略 last_time = datetime.strptime(check_in, "%H:%M:%S") # 与上次打卡时间太近,直接忽略,避免连帧误刷 if (now - last_time).seconds < 60: return # 距离上班时间超过8小时,视为下班打卡 if (now - last_time).seconds > 8 * 3600: self.conn.execute( "UPDATE attendance_records SET check_out_time=?, status='normal' WHERE id=?", (now.strftime("%H:%M:%S"), record_id) ) self.conn.commit()迟到的判断就更简单了。管理员在配置里定义上班时间,比如 09:00:00,后台跑一个定时任务,每天十点把所有check_in_time晚于 09:00:00 的记录状态改成late,没记录的直接标记为absent。这一步没必要放在实时识别循环里处理,避免拖慢主流程。
5. 实测数据、性能瓶颈分析与踩坑记录
5.1 一组真实环境下的识别测试数据
我在某写字楼的前台位置跑了将近一周的测试,这里给一组有代表性的数据供参考。测试环境:普通办公电脑(无独立显卡),USB 免驱摄像头,白天自然光和日光灯混合照明。
识别环节的耗时大致如下表:
| 环节 | 耗时范围 | 说明 |
|---|---|---|
| HOG 人脸检测(640x480 缩小4倍后) | 60~150ms | 画面中单张人脸 |
| 特征提取(128维) | 150~250ms | CPU 环境 |
| 数据库比对(每次全量比对) | 小于 10ms | 员工档案不超过200人 |
单次完整识别的总耗时约 0.3~0.4 秒,加上摄像头取帧和显示开销,员工从站定到完成打卡基本在 1 秒以内,体感上是可以接受的。
识别准确率方面,我统计了 60 人次的对照测试:
- 正常光照、正面、无遮挡:51 人次,全部正确识别;
- 戴眼镜、稍微侧脸、光线偏暗等一般变化:7 人次,其中 5 人次识别成功,2 人次需要靠近一点再试;
- 严重背光、人脸半张在阴影里:2 人次,均未能一次识别成功。
结论很明确:只要现场把摄像头位置摆在顺光位,识别成功率可以做到 95% 以上;识别失败的主要原因几乎都出在光照和角度上,而不是算法本身。
5.2 光照、角度、模糊问题的处理经验
人脸识别的可靠性上限,往往不取决于模型,而取决于图像质量。这个问题我在测试中感受特别深。
三个最常遇到的图像质量问题是:
- 过曝和背光:摄像头正对窗户时,人脸区域可能会过曝成一片白,特征提取直接失败。对策是调整摄像头安装角度,尽量让人脸不在逆光方向;有条件的话给摄像头加一个低成本的补光灯条,几十块钱就能把夜间识别率提一大截。
- 角度过大:人脸识别模型对正面脸效果最好,左右偏转超过 30 度之后,特征向量会漂移得比较厉害。所以闸机前的引导地贴和提示牌应该画一个人脸对准的位置,让员工习惯正对摄像头停留。
- 运动模糊:员工一边走路一边打卡是最容易出模糊帧的,脸部轮廓边缘都不清晰的情况下,检测模型会直接漏检。应对方式是在识别循环里做一个简单的"连续多帧检测到同一人脸才判定"的机制,而不是单帧就下结论,这样能过滤掉绝大多数一瞬而过的误检和模糊帧。
另外还有一个小技巧值得分享:在界面上显示实时识别距离值(也就是最小距离),员工看到当前值是 0.2 还是 0.45,就能直观理解"我是不是离摄像头太远、太偏了"。这个值对员工自我调整姿势很有帮助。
5.3 集成部署时踩过的坑
这一节全部是实操中真实遇到过的坑,按坑的杀伤力排序。
坑一:dlib 安装失败。face_recognition 依赖 dlib,而 dlib 在 PC 环境下经常需要 CMake 和 C++ 编译工具链支持。解决方案是在安装 face_recognition 之前先找到编译好的 dlib 安装包,或者直接用匹配的 Python 版本环境配合对应版本的安装包离线安装。这一步如果卡住,后面所有工作都开展不了。
坑二:摄像头索引不对。很多办公电脑自带摄像头,同时又外接了 USB 摄像头,cv2.VideoCapture(0)拿到的不一定是想用的那个设备。有的机器上索引 0 是内置摄像头,有的机器却不是。我在部署时遇到过一次程序画面一片黑,最后发现是取错了摄像头。建议在配置文件中把设备索引做成可配置项,启动时做一个自动检测,把所有摄像头都试一遍,找到能读到帧的那个。
坑三:GUI 线程卡死。如果程序用界面库做后台管理,直接在主线程里跑摄像头循环,界面会一直无响应。正确的做法是把识别循环放进独立线程,通过队列和主线程通信。我第一版就踩了这个坑,程序看起来像死机了一样。
坑四:程序异常退出后摄像头资源没释放。一旦摄像头没有执行release(),下次启动时设备可能被占用,导致无法打开。稳妥的做法是用try/finally保证资源释放,更稳妥的做法是设计一个看门狗机制,检测到程序异常退出后自动重启,启动前先强制释放摄像头。
坑五:SQLite 并发写入。如果不小心开了多个进程连接同一个数据库文件,可能会出现锁冲突。考勤机的识别进程负责写打卡记录,而管理后台的查询进程负责读,两者同时操作时偶尔会报 database is locked。解决办法是给连接设置超时参数sqlite3.connect(db_path, timeout=10),或者把写操作全部收敛到一个进程里。
6. 后续扩展与交付建议
6.1 可以进阶的方向
这套基础版本稳定跑通之后,我盘点过几个值得做的进阶方向,按性价比排序:
- 活体检测。目前这版存在用一张照片骗过摄像头的可能。进阶方案是检测眨眼:通过 68 个关键点里的眼部关键点计算眼睛纵横比(EAR),数值从大变小再变大,一个完整眨眼周期内才判定为活体。这个方案代码量不大,却能挡住最廉价的照片攻击。
- 陌生人抓拍与黑名单预警。在识别到陌生人时保留截图,再调用一个简单的目标跟踪逻辑,可以统计某个陌生人一天内在摄像头前出现的次数,超过阈值就告警。
- 迟到早退报表的自动推送。考勤数据都在 SQLite 里,写个定时脚本每天生成日报,把迟到、缺卡名单整理成表格,通过办公协作软件的机器人通知推送给行政部门。这一步能彻底替代人工对账。
- 多人同时识别。目前程序对画面里的多张人脸都会尝试识别,但打卡记录是逐条的。如果前台高峰期经常三五个人同时出现在画面里,可以考虑对每个人脸区域分别做速度优化,并加入跟踪 id,避免同一个人被重复判定。
6.2 交付出去之前要做好的几件事
最后聊几句交付层面的经验。很多人写完代码觉得能跑就算完了,但作为一套要给行政和前台日常使用的软件,"能跑"和"好用"之间差的功夫不少。
我在交付文档里额外强调了三件事:
- 摄像头安装位置说明。文档里配了安装说明,明确要求摄像头离地 1.4~1.6 米,正对来访通道,避免逆光和遮挡。这一步做对了,能减少 80% 以上的识别失败投诉。
- 员工照片采集规范。给行政发一份简单的采集指引:正对镜头、摘掉帽子、摘掉墨镜、不要在屏幕强光下采集。特别提醒不要在工位上随便用手机拍一张就上传,那种照片质量做识别源,后期维护成本极高。
- 异常情况的处理流程。文档里写清楚:识别失败时员工该找谁登记、如何重新采集照片、如何手动补录打卡记录。把线下兜底流程写清楚,软件才能真正落地进日常管理。
另外,SQLite 是单文件数据库,备份极其简单——每天把 attendance.db 复制到共享文件夹即可。我建议在文档里写上备份频率,并且把员工照片文件夹和特征库一起备份,否则电脑硬盘坏了,所有人的建档数据都会一起丢,重新采集照片的工作量非常大。
从我这几周的实测结果看,Python 这套人脸识别打卡方案完全足以支撑中小型写字楼的考勤场景。相比动辄上万块的商用考勤机,软件成本几乎为零,灵活性反而更高——换算法、改规则、接报表都是改改代码的事。如果你正好也有类似的考勤需求,照着这篇文章的思路搭一套,相信能少走不少弯路。