1. 为什么专门做人脸考勤需求拆解与边界定义先交代一下这个项目的起因不然你很难理解后面的技术选型为什么是这么做的。去年我们团队帮某单位改造考勤方式原有的指纹考勤机每个月都要处理一批异常记录有人手上蜕皮按不出来有人在冬天手指太干识别失败还有最让人头疼的——代打卡。指纹按理说是个人的但总有办法找别人代按管理员也没法逐条核实。当时恰好参考了一些同类系统发现基于Django和人脸识别的考勤系统在校园、中小型企业、实验室场景下讨论度很高核心思路就是用摄像头做人脸检测与身份识别替代或者补位传统刷卡、指纹设备。我决定把这个方向做成一个完整的项目并且尽量在纯软件层面落地不依赖专用考勤机硬件。这个系统真正要解决的问题可以拆成几条员工或学生入楼、进实验室的瞬间摄像头自动抓拍人脸系统识别出是谁然后自动记录考勤时间管理员能维护人员底库录新人、删旧人、改部门不用培训也会操作考勤规则可配置比如上午上班时间、下午下班时间、迟到多久算迟到、提前多久算早退有异常考勤、缺卡记录时支持补卡申请和审批而不是让HR对着Excel手工改。还有几条是这个项目明确“不做”的不做复杂排班。固定班制场景不碰倒班、夜班、弹性工时这类高级规则不做门禁联动。只做考勤记录不做开关门控制避免牵扯硬件的安全和电气改造不做活体检测。这里用的是消费级USB摄像头区分不了照片和真人所以只适合室内、有人值守的场景逻辑上不算严格的身份认证只能算“辅助考勤”。边界清楚了后面设计和实现的复杂度就完全可控了。本文面向的读者是两类人一类是刚学完Django基础、想找一个完整实战项目练手的开发者另一类是真的要给实验室、工作室或者小型团队搭一套低成本考勤方案的技术负责人。我会把整个系统的核心设计思路、关键代码和踩坑过程都过一遍你可以直接从里面挑自己需要的部分来改。2. 技术选型不是拍脑袋Django生态与人脸识别库的取舍2.1 为什么选Django而不是Flask或者FastAPI做这个系统后端框架的选择其实比想象中重要。人脸识别本身只是一个处理环节考勤系统的大部分工作量在业务逻辑上人员管理、考勤规则、记录查询、报表统计、审批流程这些东西需要一整套成熟的Web框架来承载。Django在这一块的优势非常明显ORM自带迁移机制建表、改字段、同步数据库一批命令就能完成不用手动维护SQL脚本Admin后台开箱即用人员列表、识别日志、考勤记录在开发阶段直接暴露在后台里方便核对数据模板系统够用考勤记录查询页、部门筛选、日历展示这些页面用Django模板配合Bootstrap就能做得很干净没有必须上前后端分离的必要生态丰富认证、权限、缓存、信号、定时任务都有现成方案不用重复造轮子。如果你只用Flask人员管理和权限这块需要自己写不少东西用FastAPI虽然性能好但ORM和Admin生态相对没那么“全家桶”。对这种重业务场景Django的“一站式”就是最大的效率来源。2.2 人脸识别方案本地算法库还是云API这是立项时最纠结的一项选型我把它单独拎出来说。人脸识别有三种常见路线方案优点缺点适用场景云端API准确率高、无需自己研究算法、接口简单按调用量付费、依赖网络、人脸图片要上传到第三方服务器、隐私风险对成本不敏感、网络稳定的企业本地开源库OpenCVdlib/face_recognition免费、离线可用、数据不出内网、部署灵活准确率相对云API稍低、模型较大、需要自行处理光线/角度问题中小规模、隐私敏感、网络不稳定的场景自训深度学习模型完全可控、可针对场景微调需要数据标注、训练成本高、需要GPU、迭代周期长高校实验室、算法团队做研究我选了“本地开源库”这一档。具体的库是face_recognition它是基于dlib封装的人脸识别工具库调用方式非常友好底层是ResNet模型提取128维人脸特征向量。对于几十人到一两百人规模的人员底库识别速度和精度都完全够用而且整条链路上不需要把任何一张人脸图片发到外部服务器隐私上更稳妥。这里也给一个朴实的建议云API适合快速交付给甲方、对方能接受按量付费的场景如果是自己团队长期用或者部署在校园内网本地开源库是更省心的一条路。2.3 数据库与辅助组件不给自己挖坑的选择数据库我建议开发阶段直接用Django默认的SQLite等部署上线再切到MySQL或者PostgreSQL。为什么这么做因为开发阶段你最频繁的操作是改模型、跑迁移、看数据SQLite零配置、单文件、备份方便能把一切干扰降到最低。但上线时必须换成MySQL或PostgreSQL原因有三点SQLite对并发写支持较弱识别服务在写入考勤记录的同时可能有多个人查询SQLite不适合长时间运行在服务器上承受持续读写生产环境需要更成熟的备份和权限体系。我自己是切到了MySQL 5.7以上版本用InnoDB引擎字符集指定utf8mb4避免中文乱码问题。影响到识别效率的还有一个隐藏点人脸特征比对。如果你每次都把底库里所有人的128维特征全部加载到内存里然后用循环一个个算欧氏距离当底库人数涨到几百人后并发请求一来基本就卡住了。我最终的方案是加了一个annoy索引来加速近邻查找这个后面在识别引擎章节会展开讲。3. 人脸底库采集流程、特征提取与数据库设计3.1 先想明白存照片是下策存特征才是上策很多第一次接触人脸系统的人第一反应是在数据库里建一张表存员工的照片URL然后把照片传到某个目录里识别时再读出来跑算法。这个做法不是不能用但它有三个隐患照片原图属于敏感个人信息一旦服务器被拖库人脸照片泄露的风险很大每次识别都要读图、解码、提取特征计算开销高对比速度慢无法做“一人多照”的灵活管理比如某员工戴眼镜和摘眼镜的差异就需要多张样本综合处理。正确的做法是**注册阶段提取人脸特征数据库只存128维浮点向量序列化后的二进制或文本原图可留作日志但和业务解耦。**这样识别过程永远在特征层面进行速度快且即使备份文件泄露也不直接暴露人脸图像。3.2 Django模型人员表和特征表怎么设计我建了两张核心表。一张是基础人员表一张是人脸特征表。人员表简洁一点不要塞太多字段class Employee(models.Model): emp_no models.CharField(max_length20, uniqueTrue, verbose_name工号) name models.CharField(max_length50, verbose_name姓名) department models.CharField(max_length100, verbose_name部门, blankTrue) position models.CharField(max_length100, verbose_name岗位, blankTrue) is_active models.BooleanField(defaultTrue, verbose_name在职状态) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) def __str__(self): return f{self.emp_no}-{self.name} class Meta: db_table attendance_employee verbose_name 人员信息人脸特征表单独一张class FaceFeature(models.Model): employee models.ForeignKey(Employee, on_deletemodels.CASCADE, related_nameface_features) feature models.BinaryField(verbose_name128维人脸特征) image_path models.CharField(max_length255, blankTrue, verbose_name采集原图路径) is_primary models.BooleanField(defaultFalse, verbose_name是否为主特征) created_at models.DateTimeField(auto_now_addTrue) class Meta: db_table attendance_face_feature verbose_name 人脸特征feature用BinaryField存储你可能会问为什么不直接用JSONField存数组——因为特征向量是numpy.ndarray直接存JSON会多一道序列化开销而且查起来不方便。实际我在代码里是这么处理的import numpy as np from django.db import models # 保存时 feature_bytes feature_vector.tobytes() FaceFeature.objects.create(employeeemp, featurefeature_bytes, image_pathsave_path) # 读取时 feature_vector np.frombuffer(feature_obj.feature, dtypenp.float64)这里有个细节容易忽略face_recognition提取出来的特征向量dtype默认是float64还是float32取决于底层dlib编译版本。我在项目里统一用了float64来tobytes()和frombuffer读写两侧保持一致避免上线后出现维度对得上但数据全乱的情况。3.3 底库采集一个人多张照片才是正经事人脸识别最影响准确率的往往不是算法而是底库样本质量。只给每个人录入一张照片换发型、戴眼镜、光线变化后就可能识别不出来。我的做法是注册时让员工站在摄像头前稍微转动头部系统连续抓取三到五帧不同角度的人脸提取特征后取平均向量作为主特征。原理很简单多个样本的平均特征可以在一定程度上抵消姿态和光线带来的扰动这比任何复杂的调参都有效。实操时有个前提——抓取到的都是同一个人的脸不能混入别人的帧。所以我在采集端做了个简单的逻辑检测到人脸后要求连续10帧都能检测到且特征间的欧氏距离不超过0.45才认为这一组样本可信。核心代码如下import face_recognition def collect_face_samples(frame_list): vectors [] for frame in frame_list: # 原图太大时先缩小可以大幅提升检测速度 small cv2.resize(frame, (0, 0), fx0.5, fy0.5) face_locations face_recognition.face_locations(small, modelhog) if not face_locations: continue encodings face_recognition.face_encodings(small, face_locations) if not encodings: continue vectors.append(encodings[0]) if len(vectors) 3: return None mean_vec np.mean(np.array(vectors), axis0) return mean_vec注意这里face_locations用modelhog而不是modelcnn。HOG模式在CPU上每帧耗时低CNN模式精度高但CPU上慢得离谱。对这个项目来说室内固定摄像头场景下HOG完全够用。3.4 Admin后台顺手配置一下Django Admin在开发阶段是神器但默认界面太简陋。我用简单配置把人员列表、特征状态放出来from django.contrib import admin from .models import Employee, FaceFeature admin.register(Employee) class EmployeeAdmin(admin.ModelAdmin): list_display (emp_no, name, department, position, is_active) search_fields (emp_no, name, department) list_filter (department, is_active) admin.register(FaceFeature) class FaceFeatureAdmin(admin.ModelAdmin): list_display (employee, is_primary, created_at) list_filter (is_primary,)这样管理人员录入新员工后可以直接在后台看到“这个人有没有主特征”方便及时补充底库数据。4. 考勤业务规则与核心数据模型设计识别成功并不等于打卡成功4.1 考勤规则要想清楚再动手识别出一个人只是完成了考勤系统的一半。另一半是“这次识别算什么考勤结果”。这里的需求远比想象中细碎一个人一天之内可能被识别很多次哪些算上班打卡、哪些算下班打卡早上9点上班有人8点50到有人9点10分到状态怎么区分下午下班时间之前走了系统要不要记早退一天完全没识别到怎么判断是旷工、请假还是漏拍我的方案是不试图用一个模型覆盖所有情况而是把规则拆成基础时间和状态机两层。基础时间在系统配置表里维护。默认配置上班考勤窗口上班前30分钟到上班后60分钟内第一次识别视为“上班打卡”下班考勤窗口下班前60分钟到下班后30分钟内最后一次识别视为“下班打卡”超过上班时间到达状态为迟到早于下班时间离开状态为早退。这个设计参考了多数考勤机的处理方式参数都做成可配置项方便不同团队按自己的上下班时间调整。4.2 考勤记录表的模型设计考勤记录表是系统里最核心的表字段不能拍脑袋乱写。我最终的模型大致如下class AttendanceRecord(models.Model): STATUS_CHOICES [ (NORMAL, 正常), (LATE, 迟到), (EARLY, 早退), (ABSENT, 旷工), ] employee models.ForeignKey(Employee, on_deletemodels.CASCADE, related_nameattendance_records) work_date models.DateField(verbose_name考勤日期) first_check_time models.DateTimeField(nullTrue, blankTrue, verbose_name上班打卡时间) last_check_time models.DateTimeField(nullTrue, blankTrue, verbose_name下班打卡时间) status models.CharField(max_length10, choicesSTATUS_CHOICES, defaultNORMAL, verbose_name考勤状态) is_manual models.BooleanField(defaultFalse, verbose_name是否手动补卡) remark models.CharField(max_length255, blankTrue, verbose_name备注) class Meta: db_table attendance_record unique_together (employee, work_date) verbose_name 考勤记录几个字段的设计理由值得单独讲first_check_time和last_check_time存的是同一个员工当天最早一次和最晚一次的有效识别时间而不是每次识别都插一条记录。这样查询界面简洁统计报表不用做复杂的聚合。unique_together (employee, work_date)保证了同一个人同一天只出现一条记录不会因为重复识别生成多条脏数据。is_manual字段用来区分正常识别产生的记录和后来管理员/申请人补卡的记录。报表统计时可以单独过滤方便审计。4.3 识别到人脸之后业务判断流程从识别出人脸到写入考勤记录中间要走一段判断逻辑。我用伪代码把这个过程写清楚def handle_attendance(employee, check_time): work_date check_time.date() record AttendanceRecord.objects.filter( employeeemployee, work_datework_date ).first() if record is None: # 当天第一次识别判断是上班打卡还是异常记录 if 上班窗口内(check_time): status 迟到判断(check_time) AttendanceRecord.objects.create( employeeemployee, work_datework_date, first_check_timecheck_time, last_check_timecheck_time, statusstatus, ) elif 下班窗口内(check_time): # 上班时段完全没识别到只有下班识别标记为异常 AttendanceRecord.objects.create( employeeemployee, work_datework_date, first_check_timeNone, last_check_timecheck_time, statusABSENT, remark缺少上班打卡记录, ) else: # 不在任何考勤窗口内不记录 return else: # 当天已有记录根据时间更新为下班打卡或忽略 if check_time record.last_check_time and 下班窗口内(check_time): record.last_check_time check_time if 早退判断(check_time): record.status EARLY record.save()这一段的业务逻辑是实测中反馈最多的地方。因为没有经验的人常常只想到“识别到了就写库”结果生产环境跑一天库里全是零碎记录真正想要的“每日一条汇总”反而查不出来。建议你先把判断窗口的边界条件写清楚再用单元测试把分界时间点都跑一遍比上线后再改省事得多。4.4 迟到早退判断的两个边界细节判断迟到早退的代码很简单但有两个细节容易被挤到角落里。第一个是“上班时间比较”不能直接用字符串比“09:00:00”这样简单粗暴。比如有人8点59分59秒打卡字符串比较会出问题正确做法是直接用datetime.time对象比较def is_late(check_time, work_start_time, late_threshold): limit_time datetime.combine(check_time.date(), work_start_time) if check_time limit_time timedelta(minuteslate_threshold): return True return False第二个细节是跨天问题。如果某单位下班时间晚于零点比如晚上11点下班那“当天”这个边界就不能简单按自然日切分。我做的妥协是仍然以自然日分组但把下班时间晚于凌晨的场景配置成“次日凌晨X点之前都算当天”。这块我只处理到了“支持配置项”的层面没有做完整的跨天班次系统因为再往下走就超出项目边界了。5. 识别引擎与Web应用的联动摄像头数据如何驱动Django5.1 架构为什么识别服务不能直接写在Django视图里这是很多人第一次做WebAI时最想当然的地方在Django的某个view函数里调用face_recognition用户访问某个网址就触发识别。实际跑起来会发现两个问题face_recognition初始化模型需要加载底库并构建索引这个过程可能要几百毫秒甚至更久如果每次都重新加载请求必然超时摄像头视频流是持续不断的不可能每帧都触发一次HTTP请求那样再小的并发也会把Django拖垮。所以我把系统拆成了两个进程识别服务进程独立运行在服务器上打开USB摄像头循环读取视频帧做人脸检测、特征提取、比对、写入考勤记录所有耗时操作都在这个进程里完成Django Web应用负责人员管理、考勤记录展示、规则配置、报表统计等业务功能。两个进程之间的通信我用的是数据库中间表的方式简单可靠不需要额外部署消息队列识别服务检测到某人直接把考勤记录写到AttendanceRecord表同时往RecognitionLog表插一条原始识别日志Web前端通过轮询接口拉取最新识别日志在页面上实时滚动显示“xx刚刚签到成功”。这里我跳过了WebSocket和Celery方案原因是纯软件场景下载入不高数据库轮询完全够用。如果你以后要接入多路摄像头再考虑用Redis做消息队列也不迟。5.2 识别服务主循环的完整代码识别服务的主循环用OpenCV读取摄像头帧加上face_recognition做人脸检测和特征比对。核心流程如下import face_recognition import cv2 import numpy as np from datetime import datetime class RecognitionService: def __init__(self, known_faces, known_employee_ids): self.known_faces known_faces self.known_employee_ids known_employee_ids self.match_threshold 0.55 self.similarity_cache {} self.last_check_time {} def recognize_frame(self, frame): # 缩小画面提升性能长边控制在640以内 h, w frame.shape[:2] scale 640 / max(h, w) if scale 1: frame cv2.resize(frame, (int(w * scale), int(h * scale))) rgb_frame cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) face_locations face_recognition.face_locations(rgb_frame, modelhog) face_encodings face_recognition.face_encodings(rgb_frame, face_locations) results [] for loc, enc in zip(face_locations, face_encodings): distances face_recognition.face_distance(self.known_faces, enc) min_idx int(np.argmin(distances)) min_dist float(distances[min_idx]) if min_dist self.match_threshold: emp_id self.known_employee_ids[min_idx] results.append((emp_id, min_dist, loc)) else: results.append((None, min_dist, loc)) return results我强烈建议你保留min_dist这个值不要只返回“识别成功/失败”因为它可以作为置信度的衡量。比如在界面上显示“相似度距离0.42识别为张三”管理人员能直观地判断阈值设置是否合理。5.3 阈值怎么调0.55不是万能答案face_recognition官方推荐的人脸比对阈值通常说0.6实际这个值要根据场景微调。我自己项目的实测经验如下阈值识别结果问题0.50识别很严格几乎不误判同一人稍微换个角度就识别失败漏检率高0.55漏检和误判比较平衡推荐起步值适合室内固定机位0.60几乎都能认出人可能出现不同人之间误判尤其发型像的同事0.65以上误判明显增多不推荐用于考勤场景我最终设置为0.55并且在识别日志里附带距离值方便后续按周做一次抽样复检看是否有人频繁出现在阈值边缘。如果某个员工的识别距离长期在0.5上下徘徊就该考虑给他补录底库样本了。5.4 防重复打卡识别服务里的并发保护识别服务常驻运行同一员工可能连续几秒都被摄像头捕捉到。如果每次识别成功都写一条考勤记录数据库会被刷爆。解决办法是“时间窗口去重”def should_record(emp_id, now): last self.last_check_time.get(emp_id) if last is None: self.last_check_time[emp_id] now return True if (now - last).seconds 300: # 5分钟内同一人不重复打卡 self.last_check_time[emp_id] now return True return False这个5分钟窗口是按“人脸出现在摄像头前”的粒度设计的。比如有人工位就正对着摄像头一天会被识别几十次上百次但真正需要写入考勤记录的只有上班和下班两个时间点所以在业务判断时还会再过滤一次。两重过滤下来库里不会出现噪音数据。5.5 页面实时显示识别流水考勤记录写入Django数据库后Web页面需要有一个“今日签到流水”实时刷新的区域。我用了最简单的轮询方案前端每3秒请求一次API接口后端返回最近10条识别日志模板渲染成一个滚动列表。代码不复杂但对体验提升非常大管理人员一抬头就能看到谁刚完成签到。接口大概长这样require_GET def recent_recognition_logs(request): logs RecognitionLog.objects.select_related(employee).order_by(-created_at)[:10] data [ { employee_name: log.employee.name if log.employee else 陌生人, matched: log.matched, distance: str(log.distance), time: log.created_at.strftime(%H:%M:%S), } for log in logs ] return JsonResponse({logs: data})注意select_related外键字段在列表页循环访问时避免一条一条查询数据库这个小优化在记录量大时效果很明显。6. 性能优化与部署实践识别速度、并发冲突和上线配置6.1 底库人数变多后用annoy索引加速特征比对前文提到当底库人数增长到几百人以上逐条计算欧氏距离的性能就开始吃紧了。我的优化方案是引入近似最近邻库annoy把128维特征向量建索引查找时间复杂度从O(n)降到接近O(log n)。实现方式不复杂from annoy import AnnoyIndex # 建立索引 f 128 t AnnoyIndex(f, euclidean) for i, vec in enumerate(all_features): t.add_item(i, vec) t.build(10) # 10棵树 t.save(face_index.ann) # 查询时 index AnnoyIndex(f, euclidean) index.load(face_index.ann) nearest_idx, nearest_dist index.get_nns_by_vector(input_vec, 1, include_distancesTrue)[0]这里注意一点annoy用的是欧几里得距离face_recognition官方常用的是face_distance——两者虽然都是越小越相似但数值不直接等价。所以引入索引后阈值需要重新标定不能直接把之前的0.55拿来用。我在项目里的做法是用annoy先粗筛出前5个候选再用face_distance对候选列表精确计算距离并和0.55阈值比较两全其美。6.2 识别服务CPU占用过高跳帧和缩图是便宜又有效的方案刚开始跑识别服务时我的CPU占用率一度接近100%因为每帧都做完整的人脸检测和编码。后来给主循环加了三层降载跳帧每3帧只处理1帧30fps的摄像头实际每秒处理10帧左右缩图长边限制在640像素以内检测耗时大幅下降只在检测到人脸时做特征编码这一步最关键因为face_encodings比face_locations耗时高一个量级。我先用face_locations判断画面里有没有人脸没有人脸直接跳过编码环节。实测优化后CPU占用降到30%左右完全不影响Linux服务器上Django的正常运行。如果你的摄像头分辨率特别高缩图的比例甚至可以更激进。6.3 部署结构Nginx Gunicorn Django 识别进程部署方案采用的是一套比较成熟的组合Nginx监听80端口处理静态文件和反向代理同时把动态请求转发给GunicornGunicorn作为Django的WSGI服务用geventworker模式适合这类以IO等待为主的应用识别服务进程用systemd当服务守护开机自启崩溃自动拉起MySQL生产环境数据库Redis用于缓存配置项和识别进程的并发锁比如防止多个识别进程同时改同一个人考勤记录。systemd服务文件里最关键的是Restartalways加上RestartSec5这样识别进程挂在异常上能自动拉起。我在项目实施中遇到过一次USB摄像头被系统休眠切断导致服务空转的情况加了Aftermulti-user.target和自动重启后问题就少很多了。6.4 上线前一定要跑通的验收清单项目上线前我们按以下清单逐项验收省去了很多后顾之忧同一个员工连续出现在画面中5分钟内只产生一条有效考勤记录删除人脸底库后该员工立即无法被识别新增员工录入底库后30秒内可被系统识别无需重启服务摄像头在正常室内光线与晚间开灯两种条件下识别成功率均达到预期老板和HR在Admin后台能看到完整报表且当天迟到早退统计准确服务器断电重启后Django和识别服务都能自动拉起无需人工干预。这次做下来最深的体会是技术上最难的不是“人脸识别”这几个字而是把识别结果和考勤业务规则做无缝衔接。识别算法是可靠的可业务窗口、重复打卡、缺失记录、补卡审批才是真正花时间打磨的地方。希望你做类似项目时能提前把规则想透而不是等上线后再一阵反工。