
这几年人脸识别考勤系统在企业里越来越常见很多朋友问我能不能做个简单版本自己玩或者给小团队内部用。说实话市面上成品设备动辄上千起步管理后台还经常绑定厂商云服务数据安全上总觉得不踏实。用Flask加上SQLite自己搭一套成本几乎为零逻辑也完全透明可控甚至我一个人用了一下午就完成了核心流程跑了半年一直很稳。这套系统解决的核心问题是三件事人脸怎么被识别、考勤记录怎么存储、管理后台怎么呈现。Flask负责Web服务把摄像头画面、人脸识别算法和SQLite数据库串起来SQLite负责轻量存储签到记录、人员信息都放在一个文件里备份就是复制文件那么简单。它适合小团队、工作室、实验室甚至个人开发者学习完整的全栈开发流程也适合作为毕业设计或公司内部工具的内测版本。先说清楚整体架构再说数据库设计、人脸识别核心逻辑、接口实现和排坑心得全程按工程化思路走代码给你列关键部分照着做就不会跑偏。1. 整体设计与技术选型思路1.1 为什么是Flask而不是FastAPI或Django聊技术选型之前先说定位。这是一个典型的小型内部工具系统不需要高并发支撑不需要复杂的权限体系核心诉求是快速落地、易于二次开发。在Flask、FastAPI、Django三者里我最终选了Flask理由很实际。FastAPI确实性能更亮眼自带异步支持和自动生成OpenAPI文档但它的异步特性在这里用不上。人脸识别本来就是CPU密集型的张量计算Flask同步进程处理得好好的换成FastAPI反而要额外处理事件循环的阻塞问题识别一卡整个请求队列全堵住。Django则是重武器自带Admin后台和ORM但它对模型迁移和配置的管理在全栈框架里算重的学习成本明显高而且Django默认的Admin做考勤报表界面并不灵活还不如自己写几个页面来得直接。Flask的微框架特性在这种场景下成了优势。路由简洁、请求生命周期透明、本地开发不依赖复杂配置生产部署时用Gunicorn或Waitress一挂就能跑。做管理后台页面时Flask配合Jinja2模板引擎完全够用不需要前端工程化那一整套构建链路。实际测试下来单台普通服务器跑人脸识别考勤QPS达到个位数就绰绰有余瓶颈根本不在Web框架而在摄像头图像上传和特征比对这两个环节。关键判断选框架前先看场景瓶颈在哪。考勤系统的瓶颈在图像处理链路和数据库简单查询不在Web框架本身的吞吐能力。1.2 人脸识别流程拆解人脸识别考勤不是简单的“拍张照对比一下”从摄像头画面到最终签到成功中间经过五个环节图像采集、人脸检测、特征提取、特征比对、记录判定。第一步采集的画面通常是640x480或更高的彩色图要传给检测器找“这张图里哪里有脸”。人脸检测用现成的OpenCV级联分类器也能跑但精度偏低我直接用了一个叫Face Recognition的库底层封装的深度学习模型检测和编码一条龙。特征提取是整个流程的核心。人脸图像经过卷积神经网络处理后会变成一个高维向量——很多模型固定输出128维或512维浮点数。同一张人脸在不同光线、不同角度下得到的向量在欧几里得空间里距离很近不同人脸则距离较远。系统做的事就是把这个向量存进数据库下次来打卡时提取新向量计算和库里所有人的距离小于阈值就判定为同一个人。比对环节用欧氏距离或余弦相似度均可。人脸识别库默认推荐欧氏距离阈值设置为0.6时识别效果比较均衡。拿一套512维向量来说0.4以内几乎就是同一人0.4到0.6波动大0.6以上基本可以判定为不同人。但阈值不是拍脑袋定的——需要收集本团队实际环境下的样本去调整光照差的办公室和采光好的前台同一人脸的距离会差很多。最后是记录判定。识别通过后把用户ID、时间、考勤类型写入数据库。这里还要处理一个细节同一人在一分钟内重复打卡算不算第二次签到这个逻辑我在后文考勤规则里展开讲。1.3 SQLite在考勤场景下的定位有人一听SQLite就觉得“那玩意儿能存十万条数据吗”。我在Linux下专门做过压测一张结构简单的考勤记录表插入十万条数据后做一次“查询某人某天所有打卡记录”的SQL操作耗时在几十到一百毫秒级别。这个表现对考勤记录这种追加型数据完全够用而且SQLite的特性恰好匹配考勤系统的几条硬性需求。第一数据落地为单文件。整个数据库就是一个.db文件备份太方便了我每天定时用cron打包一份丢到NAS。第二事务支持完备多进程安全可控。Flask多进程部署时SQLite的写操作会锁库但锁等待时间极短考勤写入这种低频操作根本感知不到。第三不要服务器进程零运维成本。当然SQLite对并发写有硬上限。如果你要同时允许两百个人在同一秒内完成打卡那PHP连MySQL那套就真的要上了——但一个小团队、实验室或者几十人规模的应用SQLite反而比MySQL靠谱至少不用去纠结数据库账号权限问题。人脸特征向量本身也是二进制数据塞进SQLite的BLOB字段非常灵活不用额外装向量数据库——单表最多几百上千人暴力遍历比对又快又简单。2. 数据库设计与核心实现2.1 数据表结构与字段设计基于考勤系统的业务模型表设计上我拆了三张表员工表、考勤记录表、参数配置表。员工表存姓名、工号、人脸特征向量考勤记录表存每次打卡的时间戳和类型参数配置表用来存识别阈值、允许重复打卡的最小间隔这类可调值。CREATE TABLE employee ( id INTEGER PRIMARY KEY AUTOINCREMENT, employee_no TEXT UNIQUE NOT NULL, name TEXT NOT NULL, face_vector BLOB NOT NULL, photo_path TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE attendance ( id INTEGER PRIMARY KEY AUTOINCREMENT, employee_id INTEGER NOT NULL, check_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, check_type TEXT NOT NULL CHECK(check_type IN (checkin, checkout)), device_ip TEXT, raw_image_path TEXT, FOREIGN KEY (employee_id) REFERENCES employee(id) ); CREATE TABLE config ( key TEXT PRIMARY KEY, value TEXT NOT NULL );这里最有讲究的是face_vector字段。初始模型输出的向量是NumPy数组直接存成二进制比存成JSON文本省一半空间以上。代码里通过face_vector.tobytes()转成字节流存入BLOB读取时再用numpy.frombuffer(vector_bytes, dtypenp.float64)还原。512维float64单个向量约4KB大小一千个人才4MB对任何存算设备都没压力。check_type字段设计成checkin和checkout两种类型方便区分上下午考勤。device_ip字段存了内网IP排查问题时非常有用这算是我踩过一次坑后的后见之明——最初版没有这个字段后来发现自己测接口和别人刷脸的数据混在一张表格里加上来源才能讲清楚数据是哪里来的。2.2 SQLite字段调整与数据库工具SQLite修改字段类型是个经典痛点。数据库已经建了表需要把某个字段从TEXT改成INTEGER或加个新字段很多新手在这里卡住。SQLite对ALTER TABLE的支持有限早期版本不能直接改字段类型标准做法是重建表先创建新表结构拷贝数据删除旧表重命名新表。数据库管理工具我用的是DB Browser for SQLite这款工具在Linux和Windows下都有图形界面浏览表数据、执行SQL、导出CSV报表都很顺手。它的下载链接在官网就有支持AppImage和Windows安装包。平时调试数据库不用每次敲命令行直接在图形界面查看考勤记录分布、检查BLOB特征向量是否正常效率高很多。Linux下如果只装了命令行环境可以安装sqlite3命令行工具。Debian系用apt-get install sqlite3CentOS系用yum install sqlite。命令行有个小技巧查询结果默认没有表头执行.headers on和.mode column之后列名和列宽都清晰了这个设置对排查数据和写脚本调试很重要。补充经验SQLite设计表结构时把字段类型想清楚再建表后期改字段的代价永远比重建表的代价大。特别是BLOB和TEXT之间的切换虽然能改但要注意中途数据格式变化引发的兼容问题。2.3 数据库连接封装与性能配置项目里我单独封装了一个db.py核心逻辑是保证每次请求都拿到独立连接用完自动关闭避免SQLite连接在多个线程间共享。import sqlite3 from flask import g DATABASE_PATH attendance.db def get_db(): if db not in g: g.db sqlite3.connect(DATABASE_PATH) g.db.row_factory sqlite3.Row g.db.execute(PRAGMA journal_modeWAL;) return g.db def close_db(eNone): db g.pop(db, None) if db is not None: db.close()WAL模式的开启值得展开说。SQLite默认的回滚日志模式是DELETE模式每次写操作都要写原始数据到日志写完后清理并发读会被锁影响。WAL模式改为写前日志写入不阻塞读整个系统做Web服务时不同页面同时读考勤表就不会产生卡顿。这对一个小系统来说属于“不加白不加”的优化一行PRAGMA就换来了并发性能的提升。关于Gunicorn多进程部署还有个小坑要提前说明。SQLite的WAL模式允许多进程同时读写数据库但多个Gunicorn worker进程同时打开同一个SQLite文件时偶尔会遇到database is locked。解决办法是设置较长的busy timeout让SQLite等待锁释放时多等一会儿——sqlite3.connect参数里加timeout5即可。3. 人脸识别与考勤核心逻辑3.1 人脸特征入库与注册流程注册新员工时我一般要求上传一张正面照片也可以直接调用摄像头拍摄。这里有几个必须注意的经验细节。照片质量方面尽量让人脸在画面中居中且占图像的30%以上光线柔和均匀。照片小于100KB或者模糊过度的直接拒绝。拍照时直接使用face_recognition.face_encodings(image)提取特征如果返回结果为空说明没有检测到人脸这时候不要硬存储提示重拍。人脸对齐问题也值得强调。face_recognition这个库内部已经做了人脸对齐但照片里人脸过大或者倾斜超过45度时特征质量会下降。建议注册时做一次人脸质量检查计算人脸欧氏距离时库里存的特征会直接影响匹配稳定度——注册时的照片质量不好后面打卡全受影响。宁可在注册环节多折腾用户一下也别让后续每一次打卡都出问题。import face_recognition import numpy as np def register_employee(image_path, employee_no, name): image face_recognition.load_image_file(image_path) face_locations face_recognition.face_locations(image) if len(face_locations) ! 1: return {error: 需要且仅需要一张清晰人脸} face_encoding face_recognition.face_encodings(image, known_face_locationsface_locations)[0] vector_bytes np.asarray(face_encoding).tobytes() db.execute( INSERT INTO employee (employee_no, name, face_vector) VALUES (?, ?, ?), (employee_no, name, vector_bytes) ) db.commit() return {success: True}提取到的人脸特征向量一般是128维float64类型写库前最好做一次归一化。face_recognition.face_distance()内部用的L2距离归一化后比对的数学性质更稳定。虽然Face Recognition库输出本身已经做过L2归一化我这里显式再来一遍双保险成本只是几个毫秒而已。3.2 考勤识别与判重机制签到接口接收一张Base64编码的图片或直接从摄像头读到的帧核心识别逻辑是加载该员工的特征向量与新提取的特征算距离。如果距离小于配置的阈值就判定为同一人。判重逻辑是考勤系统里容易被忽略但实际影响体验的部分。如果不做处理一个人在系统前反复经过每次摄像头捕捉到都会产生记录后台全是垃圾数据。现在我的设计是同一个员工、同一种考勤类型checkin或checkout、时间差小于系统设定的180秒时自动忽略这次请求。具体实现是在插入考勤记录前先查询这个人最后一次打卡记录last db.execute( SELECT check_time, check_type FROM attendance WHERE employee_id ? AND check_type ? ORDER BY check_time DESC LIMIT 1, (employee_id, check_type) ).fetchone() if last and (now - parse_time(last[check_time])).seconds int(max_interval): return {msg: 重复打卡已忽略, ignored: True}这里有一个实际业务细节签到后紧接着签退的间隔很小但系统的出发点往往是“一个员工早上去上班打卡晚上下班打签退卡”所以180秒判重针对的是同一考勤类型签退不会造成误判。如果按“所有人脸出现后都忽略”的逻辑处理就会出现刚签到的人系统不理他的签退请求反而乱了套。3.3 阈值设定经验谈人脸识别阈值不是越大越好也不是越小越好。阈值设太大不同员工之间也会被误判成同一个人考勤结果失真设太小光线变化导致的特征偏移会使同一人脸识别不通过员工天天卡在门口管理员被投诉到爆炸。我的调参路径是这样的先让几名同事在不同光线、不同角度下各拍5张照片提取特征后与自己已有的注册特征算欧氏距离得到“本人距离范围”。再让不同同事互相对比得到“他人距离范围”。两个范围重叠越少说明系统在当前环境越可靠阈值取两个范围的中间值。以我实测为例同一办公室同一台摄像头条件下本人距离通常在0.35到0.55之间他人距离通常在0.75以上重叠区域很小阈值取0.6就非常安全。但如果你在强逆光或昏暗走廊部署考勤本人距离可能飘到0.65这时候必须把阈值放宽到0.7同时加强注册照片质量否则误拒率会高到不可用。最终阈值不要硬编码写在config表里运营一段时间后根据签到日志的失败率动态调整。4. Flask接口实现与前端页面4.1 路由设计与请求流程整个系统设计成三个核心页面注册页、打卡页、报表页。对应的路由分为/register、/check、/report。其中打卡页是系统的主入口页面打开后摄像头自动启动每秒抓取一帧图像通过AJAX POST到/api/check接口接口返回识别结果后在页面上提示打卡成功或失败。为了保证接口返回速度我在前端做了缩略图预处理。摄像头原始画面640x480直接传给后台识别耗时约50到100毫秒但如果我们只截取画面中心区域并缩放到320x240识别速度明显提升且人脸特征不会显著恶化。这个优化在实际体验中很重要——一个员工走到摄像头前要摆姿势等一秒才能出结果体验就很差了缩略后基本是“走到跟前立刻出结果”的感觉。4.2 摄像头调用与前端代码浏览器端调摄像头使用WebRTC的getUserMedia接口。这个过程唯一有坑的是HTTPS或localhost限制——浏览器不允许在非安全上下文里调摄像头如果通过IP地址直接访问电脑浏览器会拒绝启动摄像头。开发时用localhost没问题生产部署时建议配置HTTPS或者退一步用HTTP但接受部分浏览器限制。前端核心代码做成了一个简单的单页async function startCamera() { const stream await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } }); video.srcObject stream; await video.play(); tick(); } function tick() { if (!running) return; canvas.width 320; canvas.height 240; ctx.drawImage(video, 160, 120, 320, 240, 0, 0, 320, 240); canvas.toBlob(async (blob) { const result await sendImage(blob); if (result.success) { showResult(result.msg); running false; } }, image/jpeg, 0.7); setTimeout(tick, 1200); }这里有个小细节每次抓帧之间保留了1.2秒间隔避免请求堆积。实际测试中识别耗时约80毫秒加上网络开销和数据库写入一轮请求在200毫秒内完成。为了不让摄像头画面闪烁我只有在识别成功后才暂停连续检测否则就无限循环识别——这样既能防止刷屏又不会漏掉员工正常打卡。4.3 服务端接口实现服务端/api/check接口的完整逻辑是接收前端传来的图片字节流用OpenCV解码人脸定位并提取特征与库里所有员工特征比对取最小距离距离小于阈值则写入考勤记录并返回姓名。计算逻辑里有个性能优化需要留心。如果是单表几百人直接遍历比对完全没有压力但如果未来员工规模过千建议内存中缓存所有特征向量和员工ID的映射避免每次请求都从SQLite读一堆BLOB再解码。我现在的实现是启动时加载一次特征缓存员工注册或更新时更新缓存识别请求只在内存中做距离计算这比每次都查库快一个量级。app.route(/api/check, methods[POST]) def check_attendance(): image_data request.get_data() np_image cv2.imdecode(np.frombuffer(image_data, np.uint8), cv2.IMREAD_COLOR) rgb_image cv2.cvtColor(np_image, cv2.COLOR_BGR2RGB) face_locations face_recognition.face_locations(rgb_image) if not face_locations: return jsonify({success: False, msg: 未检测到人脸}) face_encodings face_recognition.face_encodings(rgb_image, known_face_locationsface_locations) distances face_recognition.face_distance(cached_vectors, face_encodings[0]) min_index np.argmin(distances) min_distance distances[min_index] if min_distance threshold: return jsonify({success: False, msg: 人脸未注册}) employee_id cached_ids[min_index] # 插入考勤记录判重逻辑略 insert_attendance(employee_id, checkin) return jsonify({success: True, name: get_employee_name(employee_id), distance: round(min_distance, 4)})实际部署时这个接口还有一个要注意的安全细节接口没有任何鉴权。虽然只是内部系统但任何人都可以POST一张照片伪造打卡记录。我后来额外加了一个简单的Token校验前端在初始化时从服务端请求一个临时Token每次请求带在Header里。对于生产环境加一层登录鉴权操作日志这个安全基础工作值得做。4.4 考勤报表页实现思路报表页用了Jinja2模板渲染结合一个简单的日期筛选表单。默认展示当日记录按时间和员工排序。为了直观展示上下午考勤情况表格里额外加了两列上班时间、下班时间。模板逻辑里做一次数据整合把同一个人同一天的checkin和checkout合并到一行展示这种格式比原始流水账清晰太多。SELECT employee.name, date(attendance.check_time) AS work_date, MAX(CASE WHEN check_type checkin THEN time(check_time) END) AS checkin_time, MAX(CASE WHEN check_type checkout THEN time(check_time) END) AS checkout_time FROM attendance JOIN employee ON attendance.employee_id employee.id WHERE date(attendance.check_time) ? GROUP BY employee.id, date(attendance.check_time);这里用MAX聚合是SQLite支持的写法如果某天只有签到没有签退checkout_time返回NULL页面显示为“—”一眼就能看出谁没签退。导出CSV报表的功能也简单加一个路由生成CSV文件返回给前端下载Excel打开没有任何乱码问题。5. 实测数据与常见问题排查5.1 十万条考勤记录查询速度实测我在Linux环境安装了SQLite命令行工具写了个脚本批量向attendance表插入十万条模拟考勤数据然后做了几组常用查询查询场景数据量耗时查询某员工最近10条记录10万行约5ms查询某天所有签到人员10万行约40ms按日期分组统计出勤天数10万行约120ms单字段模糊查询员工名10万行约30msSQLite在小数据量下面的表现确实很快。这里如果再为主键和employee_id建上索引查询还能再缩一半时间。不过要注意索引会增大数据库体积并拖慢写入速度但考勤系统是读多写少的模式给外键建索引是值得的。CREATE INDEX idx_attendance_employee ON attendance(employee_id); CREATE INDEX idx_attendance_checktime ON attendance(check_time);5.2 常见问题排查实录人脸识别考勤系统跑起来之后大家会遇到的问题高度集中我整理成了一个速查表症状可能原因解决办法摄像头黑屏不出画面浏览器权限或HTTPS限制用localhost访问或给站点配HTTPS识别成功率低经常误拒阈值太紧或注册照片质量差收集多人真实样本重新校准阈值重新录入高质量照片识别成功但后台没有记录判重逻辑过于激进检查间隔设置是否过短区分checkin和checkout再判重SQLite报database is locked多进程写库锁冲突开启WAL模式、加busy timeout、避免高频并行写数据库文件越来越大考勤流水照片全存本地定期清理旧照片附件路径可迁移至NAS或对象存储部署后接口地址带IP打不开未监听0.0.0.0flask run加上 --host0.0.0.0有一个我在实测中踩过的比较深的坑是摄像头识别成功后返回了姓名但SQLite写入事务没有提交页面一直显示打卡中。后来排查发现是SQLAlchemy或原生sqlite连接在长连接下缓存了旧事务状态。解决方式是每次请求都显式commit并且不保留跨请求的长事务这正是我在2.3小节用g.db按请求获取连接的原因。另一个有代表性的坑是生产环境用Gunicorn多worker部署时内存中的人脸特征缓存不一致。worker A缓存了新注册员工worker B没有导致同一个员工在不同worker下识别结果不一样。解决办法是为Gunicorn配置preload在启动时统一加载缓存或者把特征缓存改成定期从数据库自动刷新我最终选择了后者——简单可靠注册新员工后最多延迟10秒生效。5.3 安全性、隐私与合规注意事项人脸属于个人生物识别信息系统虽然是自己内测或内部使用也要注意分级保护。数据库文件如果泄露人脸特征向量是可以被还原成可对比模板的所以至少要做到以下几点数据库文件不要用默认权限建议 chmod 600只有项目运行用户可读写注册照片和打卡抓拍图定期清理或加密保存不要长期堆在服务器上系统上线前去掉调试接口和默认Token做简单的访问控制如果公司内部合规要求严格建议在门口放个提示牌告知员工正在使用人脸识别考勤系统这些点虽然看起来像“课外知识”但实际运维中经常能先预防大麻烦。千万不要图省事把数据库文件放在Web静态目录里否则任何人直接访问路径就能下载整个考勤数据库。5.4 后续可扩展方向这套系统到我交付的阶段已经达到内部可用标准但如果后续要扩展到更大规模有几个方向值得考虑。一是从SQLite迁移到PostgreSQL。人脸识别和考勤逻辑可以完全复用只改数据库驱动和少量SQL语法。二是对接企业微信或钉钉的消息接口打成功后自动通知员工的家人或主管。三是加上每月考勤统计报表的自动生成定时发送邮件给管理者。四是引入活体检测——目前这套系统只能识别“是这张脸”无法判断是不是视频或照片攻击如果对安全性要求高需要加深度活体模块。实际开发这套系统让我更确认了一点工具的价值不在于技术多新、框架多全而在于能否用最低成本解决最核心的业务问题。Flask加SQLite加上一个成熟的人脸识别库已经能覆盖绝大多数中小团队的考勤需求。这个方案还给你保留了全部数据的控制权不存在云端厂商锁定这是很多商业考勤系统做不到的。如果你准备动手搭一套建议先把员工注册流程跑通在真实光照环境下校准好阈值再去加报表、通知等功能一步一步来翻车率会低很多。