做考勤系统时,我最常被问到一个问题:为什么不用刷卡机?其实不是不用,而是刷卡机的漏洞太多,代刷、忘刷、丢卡都是管理上的老大难。后来接触了基于Django和人脸识别的考勤系统,发现这条路才真正解决“人、时间、记录”三者对不上的问题——它既不依赖物理介质,又能把考勤数据直接落到数据库里,统计和回溯都很方便。这篇文章就把我个人动手实现这套系统的主要思路、踩过的坑和完整流程拆开来讲,给正在做课程设计、毕业设计或者公司内部小工具的同学一个可以照抄的参考。
1. 项目背景与方案选型
1.1 传统考勤方式的痛点
我最早给一家小公司做内部考勤时,用的是某厂商的指纹机。看起来挺可靠,但实际用起来问题很多:员工指纹磨损导致识别失败,排队打卡浪费时间,考勤数据还得一个个导出来再合并到Excel,月底对账能熬掉半条命。换刷卡或二维码,又会面临代刷、截屏转发的问题,很多人已经形成习惯“帮同事打一下卡”。
人脸识别考勤的出现,核心解决两件事:第一,考勤动作必须本人“到场”;第二,记录直接电子化,不用人工二次整理。人脸作为生物特征,不需要额外携带卡片,也不易被他人轻易替代,所以很适合作为考勤系统的身份凭证。
当然,人脸识别不是万能的,它也有光线、角度、遮挡等方面的限制,这是后话。但在技术选型时,我们首先要清楚:这个项目要覆盖多少员工?每天的数据量有多大?是否需要实时提醒?这些决定了我们是用纯Django服务端渲染,还是加上前端摄像头实时识别。
1.2 为什么用Django而不是其他框架
如果只是写一个人脸识别脚本,用Flask甚至直接写个Python脚本就够了,但考勤系统是一个完整的信息系统,它需要用户管理、权限控制、数据库建模、日志记录、报表输出这些能力。Django在这方面优势非常明显:
- 自带ORM,数据库模型改起来方便,不需要写一堆原生SQL;
- Admin后台开箱即用,管理人员可以快速维护员工信息、部门信息;
- Session和认证体系完整,普通用户和管理员能有效区分权限;
- 模板引擎配合Bootstrap,几分钟就能搭出一个可用的管理界面。
很多人觉得Django“重”,但考勤系统本身就需要这些重量级能力。如果选择一个轻量框架,后面要补认证、权限、分页、迁移这些模块,工作量反而更大。尤其是Django的migration机制,当你把员工表加一个字段时,一条命令就能同步到数据库,这在长期迭代中特别省心。
1.3 人脸识别方案选型
人脸识别是整个系统的核心,方案选型决定识别精度和开发成本。目前常见的有几种:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| OpenCV Haar Cascade | 文件小、速度快、部署简单 | 检测率低,容易误检,特征可靠性差 | 人脸检测实验、入门Demo |
| Dlib + HOG | 检测稳定,CPU可运行,社区成熟 | 特征提取较慢,模型表达能力有限 | 中低并发的人脸检测与识别 |
| Face Recognition库 | 封装了Dlib和深度学习特征,接口简洁 | 模型文件约100MB+,内存占用偏高 | 中小型考勤系统、快速开发 |
| MTCNN + FaceNet / ArcFace | 精度高,支持大姿态、复杂背景 | 依赖深度学习框架,推理耗时,需要GPU加速 | 大规模考勤、安防等高要求场景 |
我最终选择的是Face Recognition库。理由很直接:它把“人脸检测—人脸对齐—特征提取—相似度比对”整个过程封装成了几个函数,部署起来比MTCNN+FaceNet简单得多,识别精度在光线正常的室内环境下足够用。对于几百人规模的公司,纯Python后端的推理速度也能接受,如果以后想提高并发,可以把特征比对部分抽出来单独部署。
提示:Face Recognition库底层依赖Dlib,在Windows上安装时容易遇到编译问题。建议用Python 3.8配合预编译的轮子,或直接使用Linux环境,能少走很多弯路。
2. 系统整体架构与数据库设计
2.1 前后端交互方式选择
我在这个项目里用了“Django模板+少量Ajax”的方式。这意味着页面渲染由Django完成,摄像头截帧和人脸比对则通过浏览器里的JavaScript把图片传到后端接口,再由视图函数返回识别结果。
为什么不用前后端分离?因为考勤系统是一个内部工具,并发量不大,Django模板直接生成页面足以满足需求。前后端分离会让项目结构更复杂,但收益并不明显。如果你打算把它做成对外SaaS产品,那就应该用Django REST Framework提供API,前端用Vue或React单独部署。我后续重构时也是往这个方向走的。
不过这里必须注意一个关键点:摄像头在浏览器里面获取视频流,通常在本地localhost或者HTTPS环境下才能运行。如果部署到内网IP,浏览器会限制getUserMedia接口。解决办法是在Django项目里配置SECURE_SSL_REDIRECT,或者用反向代理加SSL证书。开发阶段可以直接在settings.py里设置CSRF_TRUSTED_ORIGINS允许内网IP访问。
2.2 模块划分
我把系统拆成了三个功能模块:
- 员工管理模块:负责部门、员工、人脸照片的录入与维护;
- 考勤记录模块:负责上下班打卡、异常状态判断、补卡及查询;
- 人脸识别模块:负责将摄像头照片转为128维人脸特征向量,并做一对一或一对多比对。
模块之间通过Django的APP机制隔离。每个APP只做自己负责的事情,例如人脸识别模块只提供注册和识别接口,不直接操作打卡记录。这样后期替换算法或调整考勤规则时,改动范围能控制在一个模块内。
2.3 数据库表设计
数据库设计直接关系到后续统计是否顺畅。我最初只设了一张员工表和一张打卡记录表,结果发现“一个员工对应多张人脸特征”时根本没有地方存,导致重新训练时反复改表。后来我把表分成了四张核心表:
Department:部门表,保存部门名称和负责人;Employee:员工表,关联部门,保存工号、姓名、职位、入职状态;FaceImage:人脸特征表,关联员工,保存照片路径和128维特征编码;AttendanceRecord:考勤记录表,关联员工,保存打卡时间、识别分数、打卡类型和异常状态。
人脸特征存储是我踩坑最多的地方。Dlib生成的128维向量是一串浮点数,不能直接存到普通的CharField里。我一开始用json.dumps存成字符串,后面发现比对时要反复解析,效率很低。后来改成BinaryField,直接存向量序列化后的字节,再用numpy.frombuffer还原,这样明显快了一些。
下面是简化版models.py:
import pickle from django.db import models class Department(models.Model): name = models.CharField(max_length=64, unique=True, verbose_name='部门名称') class Employee(models.Model): emp_no = models.CharField(max_length=20, unique=True, verbose_name='工号') name = models.CharField(max_length=64, verbose_name='姓名') department = models.ForeignKey( Department, on_delete=models.SET_NULL, null=True, verbose_name='部门' ) position = models.CharField(max_length=64, blank=True, verbose_name='职位') is_active = models.BooleanField(default=True, verbose_name='是否在职') class FaceImage(models.Model): employee = models.ForeignKey( Employee, on_delete=models.CASCADE, related_name='faces', verbose_name='员工' ) image = models.ImageField(upload_to='faces/', verbose_name='人脸照片') encoding = models.BinaryField(verbose_name='人脸特征编码') created_at = models.DateTimeField(auto_now_add=True, verbose_name='注册时间') class AttendanceRecord(models.Model): STATUS = [ ('normal', '正常'), ('late', '迟到'), ('early', '早退'), ('leave', '请假'), ('overtime', '加班'), ] employee = models.ForeignKey( Employee, on_delete=models.CASCADE, related_name='attendance', verbose_name='员工' ) punch_time = models.DateTimeField(auto_now_add=True, verbose_name='打卡时间') status = models.CharField(max_length=20, choices=STATUS, default='normal', verbose_name='考勤状态') recognition_score = models.FloatField(default=0, verbose_name='识别相似度') photo_path = models.CharField(max_length=255, blank=True, verbose_name='打卡照片路径') record_date = models.DateField(verbose_name='考勤日期')这里特别说明:AttendanceRecord新增了record_date字段,不要只依赖punch_time做日期查询。因为一天内可能有多次打卡(上午上班、下午上班、下班),单独用时间戳查询容易出错,加上日期字段后,统计日考勤和月考勤都会清晰很多。
2.4 关键流程设计
人脸注册流程:管理员上传员工照片,系统用人脸检测库从照片中提取人脸,如果检测不到或检测到多张人脸则提示“照片不合格”,检测到单个人脸则生成128维特征编码,存入FaceImage表。员工可以注册多张人脸照片,例如正面、侧面各一张,以解决角度和表情变化带来的识别率下降。
打卡流程:员工点击打卡按钮,浏览器打开摄像头预览画面,系统自动从视频流中截取一帧,将其上传到后端的识别接口。后端先做人脸检测,如果画面中没有检测到人脸,直接返回错误;如果检测到,则提取特征,再和库里的所有员工特征做距离比对。当最小距离小于阈值时,判定为对应员工,然后查询当天是否已经打过卡,如果打过则更新记录,否则生成一条新记录,最后返回员工信息、打卡时间和状态。
这样设计的好处是,后端不需要持久化保存每次打卡的原图,只保留特征编码和用于问题追溯的截图路径,兼顾了存储成本和审计需求。
3. 核心功能实现与踩坑记录
3.1 人脸注册与特征提取
注册功能的核心是把“照片文件”变成“特征向量”。我封装了一个公共方法:
import face_recognition import numpy as np import pickle def extract_face_encoding(image_path): image = face_recognition.load_image_file(image_path) face_locations = face_recognition.face_locations(image) if len(face_locations) != 1: return None, '照片中必须且只能包含一张人脸' face_encoding = face_recognition.face_encodings(image, face_locations)[0] return face_encoding, None注意,如果照片里有多个人,比如身份证照片和集体照乱传,系统必须拒绝注册,否则会污染特征库。我的做法是先判断len(face_locations) != 1,直接返回错误,而不是取最大的人脸。这一点在多人照片比较多的企业内部环境里非常重要。
保存特征向量时,用pickle.dumps(face_encoding)序列化到BinaryField:
face_image = FaceImage( employee=employee, image=uploaded_file, encoding=pickle.dumps(face_encoding), ) face_image.save()有些同学会把encoding存成JSON字符串,虽然开发时读起来方便,但数据库查询和比对时反而要多一步解析,性能不升反降。推荐直接用二进制字段,代码也更干净。
3.2 实时识别与考勤判定
摄像头截图上传的后端视图函数大概是这样的:
import base64 import pickle from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt import face_recognition import numpy as np @csrf_exempt def punch(request): if request.method != 'POST': return JsonResponse({'error': '仅支持POST请求'}, status=405) # 前端传的是 base64 图片数据 image_data = request.POST.get('image') if not image_data: return JsonResponse({'error': '缺少图像数据'}, status=400) try: image_bytes = base64.b64decode(image_data.split(',')[1]) with open('temp.jpg', 'wb') as f: f.write(image_bytes) except Exception: return JsonResponse({'error': '图像数据格式错误'}, status=400) image = face_recognition.load_image_file('temp.jpg') face_locations = face_recognition.face_locations(image) if len(face_locations) == 0: return JsonResponse({'error': '未检测到人脸,请调整角度和光线'}) face_encoding = face_recognition.face_encodings(image, face_locations)[0] # 从数据库中读取所有已注册员工的特征 known_faces = [] known_employees = [] for face_image in FaceImage.objects.select_related('employee').all(): encoding = pickle.loads(bytes(face_image.encoding)) known_faces.append(encoding) known_employees.append(face_image.employee) if len(known_faces) == 0: return JsonResponse({'error': '人脸库为空,请先注册'}) # 计算最相似的人脸 distances = face_recognition.face_distance(known_faces, face_encoding) min_distance_index = np.argmin(distances) min_distance = distances[min_distance_index] # 阈值设为 0.45,可依据实际环境调整 if min_distance > 0.45: return JsonResponse({'error': '未识别人脸,请靠近摄像头再试'}) employee = known_employees[min_distance_index] # 当天判重,避免短时间重复打卡 from datetime import date, datetime today = date.today() existing = AttendanceRecord.objects.filter( employee=employee, record_date=today, punch_time__gt=datetime.combine(today, time(12, 0)), ).first() status = 'normal' now = datetime.now() if now.time() > time(9, 0): status = 'late' AttendanceRecord.objects.create( employee=employee, punch_time=now, status=status, recognition_score=round(float(min_distance), 4), record_date=today, ) return JsonResponse({ 'employee': employee.name, 'status': status, 'time': now.strftime('%H:%M:%S'), 'score': round(float(min_distance), 4), })这个阈值0.45怎么定的?我实际测试中,同一个人在不同角度的face_distance一般在0.30到0.42之间,两个人的距离通常大于0.50。所以0.45是一个比较稳妥的临界点。如果现场光线暗、手机拍到的人脸模糊,可以适当放宽到0.50,但要注意识别混淆的风险。最稳妥的方式是每次注册时让员工正对摄像头拍摄,而不是只用一张早期照片。
3.3 考勤统计与报表设计
考勤系统除了打卡,更需要月底的统计报表。Django的ORM天然支持聚合查询,这里用annotate和Count就很方便。
比如统计一个人当月迟到次数:
from django.db.models import Count from datetime import date current_month_start = date.today().replace(day=1) late_count = AttendanceRecord.objects.filter( employee=employee, status='late', record_date__gte=current_month_start, ).count()如果想做月度汇总,可以用Values按员工分组:
from django.db.models import Sum summary = AttendanceRecord.objects.filter( record_date__year=year, record_date__month=month, ).values('employee__name', 'employee__emp_no').annotate( normal_count=Count('id', filter=models.Q(status='normal')), late_count=Count('id', filter=models.Q(status='late')), early_count=Count('id', filter=models.Q(status='early')), )这里要注意一点:考勤状态不应该只靠打卡时间硬算。我在实际项目里最开始就是“超过9:00就算迟到”,结果发现有些人是出差、外勤、漏打卡,全被判成迟到,月底解释成本很高。后来我在设计上增加了AttendanceRecord.status的可修改性,管理员可以在后台手动把状态改成“请假”或“正常”,同时记录修改人。真正合理的考勤规则应该是“系统初判 + 人工复核”,不要期望一个规则覆盖所有现实情况。
关于报表展示,我用的是Django模板加json把统计数据传到前端,再用ECharts画柱状图和饼图。如果你不想引入额外前端库,直接用HTML表格导出CSV也完全够用。
3.4 定时任务与异常处理
很多考勤系统还需要在每天下班后自动统计当日异常打卡人员,然后发通知给管理员。这里我用Django的APScheduler来跑定时任务。
安装scheduler配置:
from apscheduler.schedulers.background import BackgroundScheduler from django_apscheduler.jobstores import DjangoJobStore scheduler = BackgroundScheduler() scheduler.add_jobstore(DjangoJobStore(), 'default') def daily_report(): from datetime import date, timedelta today = date.today() records = AttendanceRecord.objects.filter(record_date=today) # 统计异常打卡人员并发送通知 # 这里省略具体实现 scheduler.add_job( daily_report, trigger='cron', hour=21, minute=0, id='daily_report', replace_existing=True, )定时任务的坑主要在内存泄漏和重复执行。开发环境用runserver跑没问题,但生产环境如果用多进程方式启动,可能会启动多个调度器。我的解决方案是单独用一条指令启动一个后台服务,或者直接在系统的cron里调用Django的管理命令,避免调度器互相抢任务。
4. 常见问题与排查技巧实录
4.1 人脸识别准确率低怎么办
准确率问题几乎是必然遇到的。最常见的几个原因和解决办法:
- 光线不足:建议在打卡机位置增加补光灯,或要求员工正对光源;
- 逆光导致人脸过暗:可在图像送入识别前做直方图均衡化;
- 角度倾斜:注册时多存两张不同角度的照片;
- 照片分辨率太低:摄像头截图时设置更高分辨率,或在上传前做放大处理;
- 人脸库过大导致比对时间变长:可以按部门分组,优先比对该员工所在部门的特征,减少距离计算次数。
如果现场人脸重复签到问题严重,除了提高阈值,更有效的办法是采用“活体检测”。Face Recognition库本身不带活体检测,需要额外集成眨眼、张嘴等动作指令识别,或者使用深度相机红外图像。对于一般考勤系统,加入随机动作指令就能挡住大部分屏幕翻拍攻击。
注意:不要为了追求识别率把阈值无限放宽。阈值过宽的后果是,两个长相相近的员工会被系统互相识别,这种错误在人事考勤里影响很大,宁可识别失败让员工重新打卡,也不要误判成另一个人。
4.2 重复打卡和并发问题
用户在摄像头前多逗留一秒,可能就会触发多次上传,导致同一个人连续产生好几条打卡记录。我在项目里做了两个层面的限制:
第一层是数据库约束。在AttendanceRecord中添加一个逻辑上的“唯一打卡”字段,比如employee + record_date + punch_period,其中punch_period分为上午、下午。也就是说,一个员工一天最多有两条记录,一条上午上班,一条下午下班(或其他班次)。用ORM创建记录时捕获IntegrityError,再返回“已经打过卡”的提示。
第二层是接口幂等。在视图函数里加一个时间窗口,比如5秒内同一个员工不重复生成记录。用Redis存last_punch:{employee_id},过期时间是5秒,这样能很好地避免用户重复操作。如果你不想引入Redis,也可以用Django内置的cache,但多进程环境下缓存一致性不如Redis。
下面这个片段是我在项目里实际用的Redis锁简化版:
import redis from django.conf import settings rds = redis.Redis(host='localhost', port=6379, db=0) def punch_once(employee_id): key = f'punch_lock:{employee_id}' if rds.set(key, '1', ex=5, nx=True): return True return False4.3 摄像头截帧上传的浏览器兼容问题
在Chrome上跑通的摄像头打卡页面,换到Edge或旧版Safari就出问题。这种情况通常是getUserMedia的适配问题。我推荐直接用navigator.mediaDevices.getUserMedia,但要注意浏览器安全上下文限制,非HTTPS或localhost环境下它是不工作的。
在我的实践里,内网开发环境最省事的方案是直接用Django自带runserver配合localhost访问,调试时先把功能跑通。上线到内网服务器时,再配置Nginx反向代理加自签名SSL证书。如果你所在网络环境不允许外网请求,那可以在前端把视频流分辨率固定为640x480,这样也能降低识别耗时。
4.4 模型推理速度慢与内存占用高
Dlib的人脸识别模型在CPU上跑一次特征提取大约需要0.4到0.8秒,对于打卡这种交互场景勉强够用。我刚开始把face_recognition.face_encodings写在每次请求里,导致项目并发上来之后CPU被打满,接口响应时间变成2秒以上。后来优化的方法是:模型和特征库加载后常驻内存,不每次请求重新初始化模型。
在Django里可以用AppConfig.ready()成员变量保存模型实例:
from django.apps import AppConfig import face_recognition class FaceConfig(AppConfig): name = 'face' model = None def ready(self): # 在Django启动时预先加载人脸模型 FaceConfig.model = face_recognition.face_encodings虽然这只是很小的改动,但效果非常明显。另外,如果员工数量很大,建议把特征向量加载到内存缓存里,避免每次请求都查一遍数据库。几百人的系统,哪怕从SQLite读一遍也要好几十毫秒,累积下来很影响体验。
5. 优化方向与项目感悟
5.1 从软件到硬件的部署思路
软件跑通后,如果想要部署成独立打卡机,可以考虑用树莓派或Jetson Nano这类小型硬件作为终端,摄像头直接连在设备上。Django服务部署在中心服务器上,树莓派只负责采集图片并发送HTTP请求。这样做的优点是企业不需要购买商业考勤机,成本低廉且功能可控。但如果员工数量很多,网络带宽和中心服务器压力会成为一个新问题,需要考虑边缘计算——在终端上直接完成识别,再只把“员工工号+打卡时间”上报给Django。
从个人实践来看,如果只是为了验证算法,直接在Django上跑没问题;如果真想放到公司门口,最好把识别任务独立成服务,不要让Django进程同时承担Web服务和模型推理。可以把识别接口单独部署到另一台机器上,通过内部API调用。
5.2 安全与合规的几个细节
考勤数据属于员工敏感个人信息,尤其是人脸特征,一旦泄露会产生法律风险。我在系统里至少做了以下几件事:
- 人脸特征编码不能直接还原成原始人脸照片,存储时再做一层加密;
- 打卡照片保存路径使用随机文件名,不包含员工姓名和工号;
- 管理员操作要有日志记录,后台登录使用强密码并启用两步验证;
- 数据库定期备份,备份文件单独加密保存。
这些点听起来很简单,但我见过不少课程设计和内部项目完全没做安全处理,这样的人脸考勤系统反而比传统考勤更危险。安全合规不是加分项,而是底线要求。尤其是“人脸特征”这类生物识别信息,相关法规对收集和存储都有严格约束,做项目之前一定要想清楚自己的数据从哪来、存多久、谁能访问。
5.3 我做完这个项目后的几点体会
这类系统在技术上不算难,难点在于把它做成一个真正可用、能落地的软件。我在实际开发过程中最深的体会是:不要一开始就追求复杂功能,先把“注册—打卡—统计”这条主链路跑通,再逐步加排班、请假、补卡、报表导出。看似简单的考勤,背后牵扯到考勤规则、加班计算、倒班逻辑,这些业务需求比人脸识别算法本身复杂得多。
另外,做课程设计或毕设的同学,建议把文档也写得完整一些,包括系统结构图、数据库设计说明、测试用例和识别准确率报告,这些材料在答辩时比代码更有说服力。如果是有打算开源这个项目,可以在Django框架基础上把前端界面做得更干净一点,并把配置抽成环境变量,这样其他同学拿到代码后,不需要改代码就能直接跑起来。
人脸识别考勤这件事,我认为未来会有两个趋势:一是从“识别你是谁”走向“确认你就是你”,活体检测和多模态验证会越来越普及;二是系统会从单一考勤延伸到“员工全生命周期管理”,跟请假、绩效串联起来。不过无论怎么发展,“数据准确、流程规范、可回溯”这三个基础,始终是考勤系统最重要的事。