☰
基于深度学习的人脸识别签到系统实战:从模型选型到部署避坑
2026/10/4 1:06:11 网站建设 项目流程

简介:一套基于深度学习的人脸识别签到系统的完整毕业设计项目,面向计算机相关专业学生与需要快速搭建人脸识别应用的开发者,可用于解决课堂、会议等场景下的身份核验与签到记录问题。项目以Python为主实现,包含Flask应用主程序、人脸识别模型数据、前端页面模板及SQLite数据库文件。压缩包共27个文件,其中8个Python脚本覆盖应用入口、功能函数、测试与数据库迁移逻辑,7个HTML页面构成用户登录、注册、信息编辑、签到首页等交互界面,另有模型dat文件、CSS样式、中文字体及说明文档,整体大小约102.26MB,目录划分清晰。目前已有4467人浏览学习。读者拿到的是可运行的项目源码,除核心签到功能外,还具备管理员账户初始化、数据库升级等配套机制,配合README可顺利搭建环境。从人脸模型封装到Web页面展示,完整呈现了深度学习落地应用的典型链路,对毕业设计答辩和系统二次开发都很有参考价值。

1. 人脸签到系统:从“认出是谁”到“记录考勤”的最后一公里

会议室门口摆一台带摄像头的签到屏,员工走过去抬头看一眼,屏幕弹出“张伟,签到成功”,整个过程不到一秒。这个画面背后就是你标题里那套“基于深度学习的人脸识别签到系统”——前端是摄像头和屏幕,后端跑着人脸检测、特征提取、特征比对,外面再套一层签到业务的去重和时段逻辑。用过人脸门禁机的人会有个反直觉的体会:真正让系统翻车的往往不是“认不出人”,而是“侧脸被漏掉”“光线一变就不稳定”“拿手机照片晃一下也能过”。深度学习模型能不能把人认出来只是前提,把识别结果正确地变成一条签到记录,才是落地最容易踩坑的地方。

这篇文章按一条可复现的路线来讲:先搭起检测、对齐、特征提取的最小识别链路,再把识别结果接进签到业务,然后用 FastAPI 把模型包成服务,最后是五条血泪经验和一个守住整个流水线的验证脚本。适合正在做课堂签到、会议室签到、公司考勤这类内部系统的工程师,也适合被现成门禁机“阈值调不明白”坑过的同学。

2. 先让模型认出人:检测、对齐、特征提取的最小链路

人脸识别签到系统里,深度学习负责的其实是两件事:一是把画面里的人脸找出来,二是把这张脸压缩成一个“特征向量”。后面所有签到业务都不关心像素,只拿这个向量做比对。先把这个链路搭对,后面才不会返工。

2.1 模型选型:RetinaFace+ArcFace为什么是签到场景的稳妥起点,SCRFD做主推的边界在哪

人脸识别模型选型,我看重的不是排行榜上的准确率,而是“模型能不能稳定跑在签到机的 CPU 或低端 GPU 上”。常见组合是检测用 RetinaFace,特征提取用 ArcFace。RetinaFace 在密集人群、侧脸、部分遮挡场景下召回率好,ArcFace 输出的 512 维特征向量在余弦相似度下区分度足够,这是过去几年被验证得最多的一条技术路线。

近年 SCRFD 开始替代 RetinaFace 做检测,因为它在相同精度下推理速度更快,模型也更小。但 SCRFD 的训练和部署配置比 RetinaFace 折腾一些,新手直接上容易在锚点参数、输入分辨率这些地方翻车。我的建议是:训练数据是自己公司内部几百人的小规模场景,且设备性能一般,用 InsighFace 里预训练好的 RetinaFace + ArcFace 组合,省事且够用;要做百万级人脸的闸机、门禁一体机,才值得换 SCRFD 重新走训练流程。

特征提取层 MobileFaceNet 是一个更轻的选择,适合嵌入式设备。如果签到系统跑在树莓派或安卓盒子上,MobileFaceNet 的 128 维特征能让比对速度上一个台阶,代价是准确率比 ArcFace 稍弱。签到场景的人脸库通常就是几百人,MobileFaceNet 的区分度完全够。

模块可选模型适合场景特征维度相对成本
人脸检测RetinaFace通用,侧脸/密集场景稳不涉及模型较大,CPU 可跑
人脸检测SCRFD追求推理速度,闸机/一体机不涉及部署配置略复杂
特征提取ArcFace大库、高精度要求512精度高,计算量大
特征提取MobileFaceNet轻量设备,几百人小库128速度快,精度略低

2.2 用InsightFace搭最小识别链路:检测、对齐、特征比对

最直接的上手办法是用现成的 Python 库,把上面说的 RetinaFace 和 ArcFace 封装好的模型加载起来。InsightFace 是一个经常被选的开源项目,提供的 FaceAnalysis 接口同时完成检测、对齐和特征提取,适合在项目初期快速验证流程。

import cv2 import numpy as np import insightface from insightface.app import FaceAnalysis # 初始化分析器,buffalo_l 是官方预训练包,包含检测+识别模型 app = FaceAnalysis(name='buffalo_l', providers=['CPUExecutionProvider']) app.prepare(ctx_id=0, det_size=(640, 640)) # 注册:把员工照片转成特征向量存入待比对列表 def register_face(image_path, name, face_db): img = cv2.imread(image_path) faces = app.get(img) if len(faces) == 0: print(f"警告:{name} 的照片里没有检测到人脸") return # 取面积最大的人脸,注册时通常单人照片 face = max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1])) face_db[name] = face.normed_embedding print(f"已注册:{name},特征维度 {face.normed_embedding.shape[0]}") # 识别:拿现场人脸和库里所有人比对,返回最高分 def recognize_face(image_path, face_db, threshold=0.5): img = cv2.imread(image_path) faces = app.get(img) if len(faces) == 0: return None, 0.0 best_name, best_score = None, 0.0 for face in faces: embedding = face.normed_embedding for name, db_emb in face_db.items(): score = np.dot(embedding, db_emb) # 特征已归一化,点积即余弦相似度 if score > best_score: best_score = score best_name = name return best_name, best_score # 用法示例 face_db = {} register_face("photos/zhangwei.jpg", "张伟", face_db) name, score = recognize_face("camera_frame.jpg", face_db, threshold=0.5) print(f"识别结果:{name},相似度:{score:.3f}")

这段代码把三个人脸识别的关键步骤都覆盖了。FaceAnalysis 的get方法内部完成人脸检测、关键点定位和对齐,返回的 face 对象里有bbox(人脸框)、landmarks(五个人脸关键点)和normed_embedding(归一化特征向量)。normed_embedding是已经做过 L2 归一化的,所以比对时直接用np.dot算余弦相似度,不需要再手动归一。

两个值得注意的参数:providers按机器实际环境选 CPU 还是 CUDA,det_size=(640, 640)控制的不是摄像头分辨率,而是送入检测模型的输入尺寸。输入尺寸越大,小脸检测越准,但推理越慢。签到场景摄像头一般离人两米内,640 是个不容易漏检也不至于卡顿的折中值。摄像头分辨率很高时,记得先cv2.resize到合理尺寸再送入app.get,否则检测耗时会被无效放大。

2.3 阈值要校准:cosine相似度0.5不是万能数字

很多人第一次跑通识别链路后,习惯直接把相似度阈值设成 0.5,然后发现两种情况之一:库里人少时,随便一个陌生人都能匹配成功;库里人多时,真人却经常被拒。这不是代码写错了,是 0.5 这个数字本身就没有普适性。

阈值的选择本质上是接受率和误识率之间的权衡。阈值越低,真人被放行的概率越高(FRR低),但陌生人被误认为库内人员的概率也越高(FAR高)。阈值越高则反过来。ArcFace 在公开测试集上,阈值 0.5 附近 FAR 已经很低,但在真实签到场景里,摄像头角度、光线、化妆、表情都会让同一人的相似度降到 0.5 以下。

我一般会做一个小规模校准:找 10 个同事,每人拍 3 张照片,一张正常、一张侧脸、一张光线偏暗。用这些照片跑一遍比对,统计“本人比对相似度”和“不同人比对相似度”,两类分数的分界点就是阈值应该待的位置。理想情况下,本人分数最低值应该高于他人分数最高值,在这个区间中间取值即可。签到场景不建议把阈值设得太严,漏一次签到比偶尔误识一次更让管理员头疼。

3. 签到业务建模:当日去重、迟到判断与特征库同步

识别链路只回答“画面里是谁”,签到系统还要回答“这个人今天算不算签过”“现在签算不算迟到”“库里没有这个人怎么办”。这一层不用深度学习了,但设计不好,前面模型再准也白搭。

3.1 签到状态机:一条记录还是多条记录,为什么以最后一次识别为准

常见的签到业务有两种建模方式。第一种是人脸识别一次就写一条签到记录,这种最简单,但会造成一个人一天出现多条记录,统计考勤时还要额外去重。第二种是一个员工一天只有一条签到记录,首次识别成功时创建记录,之后同一人再次出现时更新记录。

我建议采用第二种方式,并且以“最后一次识别的时间”为准来更新签到时间。原因是签到场景里,员工走到摄像头前有个过程,第一次识别成功可能发生在 8:59:58,但人还在往前走,摄像头又抓到一次更清晰的正面,时间是 9:00:05。如果以第一次为准,这个人就被判迟到。以最后一次为准,给了他一个“走到最佳位置”的缓冲,打卡体验更接近门禁机的真实行为。注意只更新时间,不把“已签到”状态改成“未签到”,当天只要出现过一次有效识别,状态就固定下来。

迟到的判断规则建议做成可配置项,不要写死在代码里。常见配置是:最早签到时间(比如 7:30 之前识别不算签到,防止前一天晚上挂机误刷)、迟到时间(比如 9:00 之后算迟到)、最晚签到时间(比如 11:00 之后不再接受签到)。这些边界值在部署时可能随时调整,写成配置文件或数据库字段能省掉不少沟通成本。

3.2 带缓存去重的签到服务怎么拆:Python代码

签到服务最简单的实现是用一个内存字典做当日缓存,key 是员工工号,value 是最后一次识别时间。这个方案只适合单机部署,但如果签到系统就是一个办公室一台摄像头,它已经够用了。下面是一段可运行的签到逻辑示例。

import datetime from collections import defaultdict class SignService: def __init__(self): # 当日签到缓存:工号 -> 最后一次识别时间 self.today_records = defaultdict(dict) # 签到规则配置 self.earliest_time = datetime.time(7, 30) # 7:30 之前不算签到 self.late_time = datetime.time(9, 0) # 9:00 之后算迟到 self.latest_time = datetime.time(11, 0) # 11:00 之后不再签到 def sign(self, employee_id, recognize_time=None): recognize_time = recognize_time or datetime.datetime.now() current = recognize_time.time() # 1. 早于最早签到时间,忽略(防止摄像头误触发) if current < self.earliest_time: return {"status": "ignored", "reason": "too_early"} # 2. 晚于最晚签到时间,拒绝 if current > self.latest_time: return {"status": "rejected", "reason": "too_late"} # 3. 判断迟到 is_late = current > self.late_time # 4. 更新签到记录,覆盖为最后一次识别时间 record = self.today_records[employee_id] record["last_time"] = recognize_time record["late"] = is_late self.today_records[employee_id] = record return {"status": "signed", "employee_id": employee_id, "late": is_late} def get_today_report(self): return dict(self.today_records)

这个类把签到规则收敛在一个地方。首次签到和重复签到的动作完全一样:更新last_time,所以不需要单独判断“是不是第一次”。today_records在每天零点后自然过期,但实际部署时建议加一个定时任务,每天 0 点清空,或者从数据库重新加载昨天的记录用于生成日报。

注意这个实现没有落库,进程重启后当天签到记录就没了。生产环境需要在sign方法里同步写数据库或 Redis,消息队列异步写库也可以,但签到操作本身很快,同步写 SQLite 也能承受。关键是“内存缓存 + 数据库”两层都更新,缓存用来快速判断当天状态,数据库用来保证重启不丢数据。

3.3 数据表设计:人脸特征也要入库,迟到时间归属要精确

签到记录表是这个系统最核心的一张表,字段不要偷懒。employee_id、sign_date、first_sign_time、last_sign_time、is_late、face_score这六个字段缺一不可。face_score很多人会忽略,但它是事后排查“这个人怎么今天早上 8:59 才签到”的唯一证据——如果当时相似度只有 0.51,说明摄像头角度可能不好,需要调整设备位置。

CREATE TABLE sign_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, employee_id VARCHAR(20) NOT NULL, sign_date DATE NOT NULL, first_sign_time DATETIME, last_sign_time DATETIME, is_late BOOLEAN DEFAULT 0, face_score REAL DEFAULT 0, UNIQUE(employee_id, sign_date) ); CREATE TABLE employee_face ( employee_id VARCHAR(20) PRIMARY KEY, face_embedding BLOB NOT NULL, register_time DATETIME DEFAULT CURRENT_TIMESTAMP, photo_path VARCHAR(255) ); CREATE INDEX idx_sign_date ON sign_record(sign_date);

特征向量(embedding)也要入库。几百人的考勤系统,每个员工一个 512 维的浮点向量,存成 BLOB 占用的空间很小,但换来的好处是:换服务端机器后不需要重新跑注册程序,直接查库就能比对。employee_face表里建议同时存photo_path,方便前端展示注册照片。

first_sign_time和last_sign_time分开存这个设计容易被新手简化掉。按 3.1 的设计,一天内要保留首次和最后一次两个时间:首次用于判断“这个人今天有没有出现”,末次用于判断“最终签到时间”。只存一个字段,要么丢掉迟到判断精度,要么无法回答“他是不是 8:30 就来过但 9:01 又出现一次”这类问题。

4. 部署与前端交互:把模型变成“刷一下脸出结果”的服务

识别链路写在脚本里能跑还不算完,签到系统要接摄像头和 UI,模型得作为一个常驻服务跑起来,前端拿到识别结果后展示给用户。这里最常见的问题不是模型跑不起来,而是服务启动慢、接口响应被阻塞、前端不知道识别结果什么时候回来。

4.1 用FastAPI把识别包成接口,模型在服务里只加载一次

FastAPI 是目前把 Python 模型包成 HTTP 服务最顺手的工具,关键点是模型只加载一次,所有请求共用同一个 FaceAnalysis 实例。F脸分析模型加载一次要几百毫秒,如果每个请求都重新初始化,摄像头一帧一帧传过来,服务直接卡死。

from fastapi import FastAPI, UploadFile, File import numpy as np import cv2 import insightface from insightface.app import FaceAnalysis from sign_service import SignService app = FastAPI() sign_service = SignService() face_db = {} # 启动时从数据库加载工号 -> embedding # 全局只初始化一次 analyzer = FaceAnalysis(name='buffalo_l', providers=['CPUExecutionProvider']) analyzer.prepare(ctx_id=0, det_size=(640, 640)) @app.post("/recognize") async def recognize(file: UploadFile = File(...)): data = await file.read() img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR) faces = analyzer.get(img) if len(faces) == 0: return {"status": "no_face"} face = max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1])) best_name, best_score = None, 0.0 for emp_id, embedding in face_db.items(): score = np.dot(face.normed_embedding, embedding) if score > best_score: best_score = score best_name = emp_id if best_score < 0.5: return {"status": "not_matched", "score": round(float(best_score), 3)} result = sign_service.sign(best_name) return {"status": "signed", "employee_id": best_name, "score": round(float(best_score), 3), "late": result["late"]}

UploadFile接收前端传来的图片,imdecode把字节流变成 OpenCV 图像,然后进入识别流程。注意analyzer在模块加载时创建,不在请求函数里创建,这是服务稳定性的第一个关键。另一个关键点是不要在async函数里跑 CPU 密集的analyzer.get,FastAPI 的事件循环会被阻塞。如果并发量高,改用def声明让 FastAPI 走线程池,或者用run_in_executor包一层。签到场景一秒钟最多来一两帧,现状足够。

摄像头采集端不要直接传整段视频流给接口,这会让带宽和 CPU 同时爆炸。正确做法是摄像头端用 OpenCV 或 ffmpeg 抽帧,送 JPEG 编码后的单帧图片到/recognize,拿到结果后在本地覆盖显示。这样摄像头和识别服务解耦,摄像头坏了换一个不用改模型服务。

4.2 前端拿结果:轮询、状态码与“识别中”的界面处理

前端是浏览器页面,配合后端服务,常见的设计是:摄像头预览画面用getUserMedia实时播放,每隔 1 秒抽一帧发送到/recognize接口,拿到识别结果后弹窗显示员工姓名和签到状态。发送图片直接用fetch上传 Blob,比 WebSocket 推流简单得多。

async function captureAndRecognize() { const video = document.getElementById('camera'); const canvas = document.createElement('canvas'); canvas.width = 480; canvas.height = 360; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.8)); const formData = new FormData(); formData.append('file', blob, 'frame.jpg'); const response = await fetch('/recognize', { method: 'POST', body: formData }); const result = await response.json(); if (result.status === 'signed') { showMessage(`${result.employee_id} 签到成功`); } else if (result.status === 'no_face') { // 画面里没人,不提示 } else { showMessage('未识别'); } } setInterval(captureAndRecognize, 1000);

前端抽帧的间隔决定了实时性。1 秒一次足够,时间太长员工站在摄像头前要等两秒才有反馈。画布尺寸 480x360 是为了减少上行带宽,同时保证人脸在画面里足够大。toBlob的第三个参数 0.8 是 JPEG 质量,质量太高没有意义,反而增加传输耗时。

这里要处理一个业务细节:status: signed和status: not_matched用不同的前端提示。签到成功要醒目,可以弹绿色提示条;识别失败不要每次都弹,那个“未识别”的提示会一直在屏幕上闪,干扰下一个人签到。同样,no_face状态直接静默处理。前端状态处理的分寸感,比模型本身更影响使用体验。

4.3 模型导出与量化:让推理追上摄像头帧率的两个手段

如果签到机是个老旧 CPU 设备,模型推理时间超过 300ms,就会明显感觉到画面卡顿且识别滞后。这时候有两个调整手段:一是把输入图像从 640 降到 320,虽然小脸检测率会下降,但两米内的人脸在 320 分辨率下仍然足够;二是把模型转换成 ONNX 再跑量化,减少计算量。

InsightFace 自带的模型本来就能通过face_app.model_dir找到 ONNX 文件,直接用onnxruntime加载可以减少一次转换步骤。如果用的是自己训练的 PyTorch 模型,导出 ONNX 的代码是标准流程:

import torch import onnxruntime as ort # model 是你的 PyTorch 特征提取模型,inputs 是一张示例输入 torch.onnx.export(model, inputs, "face_feature.onnx", input_names=["input"], output_names=["embedding"], opset_version=11) # 用 onnxruntime 加载,选择 CPU 优化 sess = ort.InferenceSession("face_feature.onnx", providers=["CPUExecutionProvider"])

导出后可以用onnxruntime的GraphOptimizationLevel.ORT_ENABLE_ALL做图优化,也可以转成 INT8 量化模型,但 INT8 量化在 512 维特征上精度损失不可控,不建议盲上。先试 FP16 精度或直接用 ONNX Runtime 的 CPU 优化,通常就能把单帧推理降到 100ms 以内。真到 INT8,必须用真实签到照片做回归测试,确认 FRR 没有明显恶化。

部署时还要注意模型初始化的一次性开销。FastAPI 服务进程启动时加载模型,首次请求如果要等模型加载完成会超时。常见做法是在启动事件里显式加载模型并做一次热身推理,让模型文件预加载进内存,接口接受请求时已经就绪。这个热身推理随便用一张黑色图片即可,目的是触发 ONNX Runtime 的会话初始化。

5. 落地避坑:5条常见签到事故的现象、原因与解决

识别链路和业务模块都能跑通之后,真正让人头疼的问题会一个接一个浮上来。有些是算法选择的问题,有些是工程细节的问题,还有的是数据问题。这里整理五条签到系统落地时最容易遇到的坑,每条按“现象 → 原因 → 解决”来讲清楚,避免你重复走弯路。

5.1 侧脸、低头、口罩把签到漏掉

现象:员工正常走到摄像头前,系统毫无反应,屏幕一直停留在“未识别”。认真看采集画面才发现,他正低头看手机,只留给摄像头一个额头。

原因:深度学习人脸识别模型对姿态和遮挡敏感。RetinaFace 能检测到侧脸,但 ArcFace 提取侧脸特征后和正脸注册特征的相似度会明显下降,低于阈值就被拒。低头超过一定角度,检测阶段就会丢脸。口罩也是同样的道理,下半张脸的特征被完全遮住。

解决:签到设备的安装角度比模型选择更重要。摄像头安装在人的正前方 1.5 米、高度与人眼平齐的位置,能避免大部分俯仰角问题。同时在识别策略上加“连续三帧内出现一次成功识别即算通过”,而不是单帧判断,给低头瞬间一帧抬头的机会。口罩场景要么接受低通过率并让员工短暂摘口罩,要么训练一个口罩人脸识别专用模型,后者工作量明显更大,一般公司不建议碰。

5.2 阈值设太高,一天下来签到成功的没几个

现象:现场测试时都能通过,正式用了一上午,签到成功人数不到实际到场人数的一半。看日志发现大量识别分数在 0.5 到 0.6 之间,但阈值设在了 0.65。

原因:测试照片往往是光线充足、姿态端正的摆拍,现场签到是摄像头实时采集,受光线、角度、运动模糊影响,同一人的相似度普遍比注册照片低 0.1 到 0.2。阈值按测试集的表现设定,没有留出现场余量。

解决:阈值别用固定值,首先要按 2.3 的方法做真实摄像头数据的校准。其次在技术上可以做动态阈值:工作日的早高峰时段(7:30-9:00)把阈值降低 0.05,因为这段时间摄像头前的人确实是员工;非高峰时段恢复正常阈值,防止陌生人蹭脸。这个逻辑虽然简单,但实测能明显降低漏签率,又不会显著增加误识率。

5.3 新员工入职当天刷不上脸

现象:新员工第一天上班,走到签到屏前,系统没有任何反应,提示“未识别”。管理员查了数据库,确实已经录入了他的照片和工号。

原因:入职当天录入的照片是 HR 拍的证件照,背景是白墙,光线均匀。现场摄像头的光线条件、人脸角度、表情都不同,证件照提取的特征和现场照的相似度可能低于阈值。更隐蔽的原因是,注册时没有检查照片是否包含清晰正脸,有些证件照是侧脸或者像素太低。

解决:注册流程加入“注册质检”,用同一个识别模型反查注册照片,如果检测不到人脸或人脸面积太小,直接拒绝录入并提示重新拍照。现场识别时,如果相似度在阈值的 80% 到 100% 区间内,返回“低置信度”状态,让管理员人工确认,而不是直接拒签。这个“灰色区间”的引入,把漏签的硬伤变成了可人工干预的软处理。

5.4 手机照片在摄像头前晃一下也能签到

现象:有人拿同事的手机照片,屏幕对着摄像头,系统直接识别成功。管理员刚开始不信,测试之后发现连一段录好的视频都能通过。

原因:深度学习人脸识别模型没有“活体感知”能力,它只比对平面特征。照片和视频里的人脸与真人脸在特征上高度一致,模型区分不出来。签到系统如果只做识别不做活体,在安全要求高的场合是明显的漏洞。

解决:加活体检测模块。最轻量的是动作活体,让用户按屏幕提示眨眼、点头、张嘴,模型检测到对应动作才算通过。这个方案对用户体验有影响,但实现简单。更高级的是静默活体,利用红外摄像头或 RGB 图像的纹理、反光、景深信息判断是不是真人,不需要用户配合,但需要额外硬件或较复杂的模型。几百人内部签到场景,用动作活体加“限定同一 IP 每天最多签到一次”的组合,防范代价已经够用。

5.5 同一人早中晚相似度波动大,签到结果时好时坏

现象:同一个员工,早上 8:00 签到相似度 0.72 通过,下午 2:00 出现在摄像头前,相似度降到 0.48,被拒。管理员怀疑是模型抽风,但看采集画面发现,下午的背光让整张脸处在阴影里。

原因:光照变化是人脸识别最大的干扰因素。签到设备如果正对窗户,上午顺光、中午顶光、下午逆光,同一张脸在帧里的成像差异巨大。ArcFace 训练时虽然做了光照增强,但对现实中的极端光线变化仍然不够鲁棒。

解决:先从设备层面解决,摄像头尽量避开直射阳光和窗户方向,必要时加补光灯。算法层面,在图像送入模型前做一次简单的自适应直方图均衡化(CLAHE),能减少一部分光照影响。更可靠的做法是每个员工注册时采集 3 张不同光线条件下的照片,取特征的平均向量作为该员工的注册特征,相当于把光照变化“融入”注册特征里,比任何图像预处理都直接。

6. 用自动化回归脚本锁住流水线:验证、调参、持续维护

签到系统一旦上线,最怕的是某次改动(换模型、调阈值、改代码)导致一部分人突然刷不上脸。人工一张张测试照片试过去既慢又不全面。我习惯在项目初期就写一个自动化回归脚本,把各种“难样本”固定下来,每次改动模型或阈值后跑一遍,用客观数据决定是否上线。

import json import numpy as np from insightface.app import FaceAnalysis app = FaceAnalysis(name='buffalo_l', providers=['CPUExecutionProvider']) app.prepare(ctx_id=0, det_size=(640, 640)) # 离线加载注册库和测试样本 with open("reg_db.json", "r") as f: reg_db = json.load(f) # {"zhangwei": [0.012, -0.023, ...], ...} test_cases = [ {"name": "zhangwei", "photo": "test_cases/zhangwei_side.jpg", "expect_match": True}, {"name": "zhangwei", "photo": "test_cases/zhangwei_dark.jpg", "expect_match": True}, {"name": "lisi", "photo": "test_cases/zhangwei_front.jpg", "expect_match": False}, {"name": "unknown", "photo": "test_cases/stranger.jpg", "expect_match": False}, ] threshold = 0.5 failed = [] for case in test_cases: img = cv2.imread(case["photo"]) faces = app.get(img) if len(faces) == 0: if case["expect_match"]: failed.append(f"{case['name']}: 未检测到人脸") continue face = max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1])) best_name, best_score = None, 0.0 for name, emb in reg_db.items(): score = np.dot(face.normed_embedding, np.array(emb)) if score > best_score: best_score = score best_name = name matched = best_score >= threshold and best_name == case["name"] if matched != case["expect_match"]: failed.append(f"{case['name']}: 期望{'通过' if case['expect_match'] else '拒绝'},实际分数 {best_score:.3f},匹配 {best_name}") print(f"共 {len(test_cases)} 个用例,失败 {len(failed)} 个") for f in failed: print(f)

这个脚本的价值在于把“凭感觉调阈值”变成了“用数据说话”。每次调整阈值或更换模型版本时,把脚本结果记录下来。如果新模型的失败数比旧模型多,说明这次改动是退步,哪怕它在标准测试集上的准确率更高。reg_db.json需要和employee_face表保持同步,建议从数据库导出而不是手写。

我吃过一次亏:上线后改了检测模型版本,单张照片测试都是好的,但没跑回归就部署了,结果百来号人里七八个戴眼镜的相似度普遍掉了 0.08,当天早上考勤一片混乱。后来所有模型变更都必须过这个脚本,侧脸、暗光、口罩、陌生人四组样本一票否决。维护回归数据本身也是工作的一部分,每次发现新的失败样本,就把它加进test_cases。这套脚本并不复杂,但它能让你在模型迭代时睡得着觉,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询