
简介基于PythonFlask与OpenCV深度学习技术的人脸识别签到系统适配软件工程、人工智能、通信工程等计算机相关专业的在校学生、老师或企业员工用于毕业设计、课程设计及项目演示针对传统人工签到效率低、易代签等问题给出从算法到Web端的完整落地实现。压缩包共28个文件涵盖8个Python核心源码、7个HTML页面模板以及SQLite数据库、数据文件与说明文档整体约101.47MB按功能模块划分目录清晰便于按需检索与二次开发。目前已有341人学习下载。代码经过运行测试功能正常可直接部署使用附带的详细文档覆盖环境配置、功能设计与实现思路既能支撑毕业答辩与项目演示也可作为深度学习、OpenCV人脸识别及Flask开发入门的参考素材适合在此基础之上扩展考勤统计、权限管理等其他功能。1. 人脸识别签到系统把“谁来了”从人工核对变成一条可审计记录教室或实验室的签到最怕的就是代签和补签。基于 Python Flask OpenCV 的人脸识别签到系统本质是把“摄像头抓脸 → 深度学习特征提取 → 比对注册库 → 写入签到表”这条链路串起来让“谁来了、几点来的”自动沉淀成可查询的记录。毕业设计选这个题目很常见因为一套系统里同时包含了 Web 后端、图像处理、模型调用和数据存储工程量适中展示效果好答辩时有得聊。这套方案也适合想快速搭一个局域网签到原型的工程师。需要先泼一盆冷水它不是装好就能 100% 可靠的东西光线一变、角度一偏就可能翻车提前知道坑在哪比闷头多写几千行代码更重要。2. 技术方案拆解Flask、OpenCV、深度学习各管哪一段先把三样技术栈在系统里的分工说清楚。Flask 负责对外提供 Web 服务管理签到页面和接口OpenCV 负责图像采集、人脸检测、图像预处理深度学习部分负责把一张人脸变成可以比对的向量特征。三者通过 Python 粘在一起是一个典型的数据处理流水线摄像头读帧OpenCV 找到人脸深度学习模型提取特征Flask 把比对结果写到数据库里。2.1 Flask vs FastAPI vs 桌面程序签到后端为什么优先选 Flask写这类系统很多人会在 Flask、FastAPI、PyQt 桌面程序之间犹豫。我的选择标准很简单看你的运行环境和交付方式。方案优势代价适合场景Flask Web 服务上手快、模板和路由简单、教程多同步 IO极端并发能力弱毕业设计、局域网签到原型FastAPI异步、自动生成 API 文档、类型校验好需要额外配模板/静态方案入门曲线高一点后续要接移动端、高并发 API纯桌面PyQt OpenCV实时性最稳不依赖网络每台机器要装 Python 环境界面开发量大固定工位闸机、离线签到Flask 在这个场景里的核心价值不是性能而是“可访问性”。浏览器打开http://服务器IP:5000就能签到答辩时一台电脑装好服务其他同学用手机或笔记本访问同一页面效果比每台机器都装一遍 Python 环境好得多。FastAPI 虽然异步性能和自动文档更好但毕业设计和快速原型场景下Flask 的资料密度和排错难度更有优势。桌面端更适合识别延迟要求极高的闸机场景对大多数签到打卡需求反而是杀鸡用牛刀。2.2 检测与特征提取的模型分工OpenCV 是眼深度学习是脑人脸识别链路里最容易误解的地方是把“深度学习”和“OpenCV”对立起来。实际上它们是不同层级的组件OpenCV 负责找脸、裁剪、缩放、画框这些图像操作深度学习负责把对齐后的人脸转换成特征向量。识别方案我一般推荐“预训练 Embedding 模型 距离比对”而不是自己训练 CNN 分类器。原因很现实毕设场景没有几万张同人的照片也没有多卡训练环境自己训分类器容易过拟合到数据集上现场换个人就废。预训练模型把一张脸映射成 128 维浮点向量系统只需要注册时保存每个人的向量签到时算当前人脸向量和注册库的距离小于阈值就算匹配成功。这样数据量要求低而且换人注册不需要重新训练模型。ArcFace 在这类任务上精度更高但模型获取和推理依赖更重dlib 的 128 维描述子模型在 Windows 上最容易装通也足够应付签到场景。2.3 从打包文件到最小可跑工程先跑通摄像头页面再说手头这种“源码数据集详细文档”的打包文件第一件事不是急着跑完整系统而是先做环境体检。打开压缩包先找三个东西requirements.txt、模型权重文件通常以.dat或.h5结尾、数据分析或预处理脚本。常见翻车现场是文档里写 Python 3.7代码用 3.10 跑不通数据集路径被写死成绝对路径模型文件缺失导致 import 报错。所以第一步永远是建干净环境按依赖清单装库。python -m venv venv venv\Scripts\activate # Windows 下激活虚拟环境 pip install -r requirements.txt然后在同目录写一个最小骨架先证明“摄像头 Flask 能一起工作”再往里面加识别逻辑。这样可以隔离问题如果连视频流都出不来后面所有识别代码都是空转。# app.py —— 最小骨架先证明摄像头和 Flask 能同时跑 from flask import Flask, Response import cv2 app Flask(__name__) def gen_frames(): cap cv2.VideoCapture(0) # 0 表示默认摄像头 while True: ok, frame cap.read() if not ok: break ret, jpeg cv2.imencode(.jpg, frame) if ret: yield (b--frame\r\n bContent-Type: image/jpeg\r\n\r\n jpeg.tobytes() b\r\n) app.route(/video) def video(): return Response(gen_frames(), mimetypemultipart/x-mixed-replace; boundaryframe) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)这段代码做了三件事持续读摄像头帧、压缩成 JPEG、以 multipart 流的方式推给浏览器。host0.0.0.0意味着监听所有网卡局域网内其他设备可以通过http://本机IP:5000/video看到画面这是 Flask 部署最常见的配置点写127.0.0.1就只有本机能访问。debugFalse一定要保持关闭Flask 调试模式会双进程运行摄像头设备在 Windows 上常常因此打不开。3. 识别链路核心检测、对齐、128 维特征与比对从这一章开始进入系统的核心。整个识别流程可以拆成四步检测人脸位置、对齐姿态、提取特征、计算距离。每一步都有可调的参数和容易踩的坑尤其是“一个在现场识别不了的人特征库里到底该存什么照片”这比调参更重要。3.1 dlib HOG 检测 vs OpenCV 级联检测检测器怎么选、参数怎么设人脸检测器的选择直接决定后续识别的输入质量。常见有两套方案dlib 的 HOG 检测器和 OpenCV 的 Haar 级联检测器。Haar 级联的优势是轻量老机器上也能跑dlib HOG 对侧脸和小脸的容忍度略好但速度稍慢。签到场景里人脸一般离摄像头不远两种都能用。dlib 检测器用法很直接返回的是可迭代的dlib.rectangle对象import dlib detector dlib.get_frontal_face_detector() # HOG 线性分类器 def detect_faces(gray): # 第二个参数 1 表示金字塔上采样一次小脸更容易被召回代价是速度变慢 faces detector(gray, 1) return [(int(f.left()), int(f.top()), int(f.right()), int(f.bottom())) for f in faces]OpenCV 级联的写法则是detectMultiScale它的参数对结果的影响非常明显import cv2 face_cascade cv2.CascadeClassifier(haarcascade_frontalface_default.xml) def detect_faces_haar(gray): faces face_cascade.detectMultiScale( gray, scaleFactor1.1, # 每轮缩放比例越小越容易检出小脸但更慢 minNeighbors5, # 邻域内最少命中次数越大误检越少但容易漏检 minSize(64, 64) # 小于该尺寸的框直接忽略 ) return [(x, y, x w, y h) for x, y, w, h in faces]参数的意义一句话讲透scaleFactor是扫描金字塔的步长1.1比1.3召回率高但耗时翻倍minNeighbors是误检过滤器调到8以上会开始漏掉侧脸minSize决定系统离多远能认出人。签到场景里人脸占画面比例不会太小minSize(64, 64)足够还能显著降低 CPU 压力。采样时优先用灰度图因为这两个检测器都不依赖颜色信息转换一次颜色空间能省不少耗时。3.2 68 点对齐与 128 维特征姿态漂移如何拉回来检测出人脸框之后不能直接做特征提取。同一个人的脸如果歪着头拍照特征向量会明显偏离注册时的值。所以要先做人脸对齐让输入模型的人脸尽量是“正脸”。最常用的对齐参考是两只眼睛的位置。通过 68 点关键点模型找到左右眼中心然后以两眼的连线为水平参考做旋转仿射变换。import cv2 import numpy as np import dlib predictor dlib.shape_predictor(shape_predictor_68_face_landmarks.dat) def align_face(gray, face_rect): shape predictor(gray, face_rect) left_eye np.array([shape.part(36).x, shape.part(36).y]) # 左眼外眼角 right_eye np.array([shape.part(45).x, shape.part(45).y]) # 右眼外眼角 eye_center ((left_eye right_eye) / 2.0).astype(np.float32) dx right_eye[0] - left_eye[0] dy right_eye[1] - left_eye[1] angle np.degrees(np.arctan2(dy, dx)) # 计算两眼中线的倾斜角 M cv2.getRotationMatrix2D(tuple(eye_center), angle, 1.0) aligned cv2.warpAffine(gray, M, (gray.shape[1], gray.shape[0])) # 按人脸框裁剪并缩放到模型要求的输入尺寸 x, y, w, h face_rect.left(), face_rect.top(), face_rect.width(), face_rect.height() aligned_face aligned[y:y h, x:x w] aligned_face cv2.resize(aligned_face, (160, 160)) return aligned_face这里 160 不是固定值取决于你用的特征提取模型。dlib 官方例子里常用 150×150FaceNet 常用 160×160具体以权重文件训练时的输入尺寸为准。cv2.warpAffine的第三个参数是输出图像尺寸如果写成(0, 0)就会报 OpenCV 相关错误这是新手高频翻车点。对齐之后送入特征模型得到 128 维特征向量face_net dlib.face_recognition_model_v1(dlib_face_recognition_resnet_model_v1.dat) def compute_feature(aligned_face): # 输入的 aligned_face 必须是 RGB 色彩空间 rgb cv2.cvtColor(aligned_face, cv2.COLOR_GRAY2RGB) return np.array(face_net.compute_feature(rgb))注意 dlib 的特征模型输入要求 RGB 图像喂灰度图会导致特征数值漂移。比对时用 L2 欧氏距离即可dlib 官方阈值经验值在0.5左右但实际部署必须用自己的注册照片重新标定。def match(feature, db_features, threshold0.5): distances [np.linalg.norm(feature - db) for db in db_features] idx int(np.argmin(distances)) if distances[idx] threshold: return idx, distances[idx] return -1, distances[idx]argmin找到库里距离最小的那个人如果最小距离都超过阈值就认为“不认识”。阈值调大放松匹配但误认增加阈值调小误认减少但本人也容易被拒。这个值的标定方法会在最后一章细说。3.3 特征库构建与数据集质量一个人 20 张照片才够特征库是整个系统的数据库前置资产。打包里的数据集一般结构是“按人建文件夹每张照片是一个人脸”类似dataset/ wang/ 1.jpg 2.jpg zhang/ 1.jpg构建特征库时不应该只取一张照片的特征而是把同一个人的多张特征向量做平均得到一个更稳定的“中心向量”。import os import pickle db {} # 结构: { wang: np.ndarray(128,) } for name in os.listdir(dataset): feats [] face_dir os.path.join(dataset, name) for img_name in os.listdir(face_dir): img_path os.path.join(face_dir, img_name) gray cv2.imread(img_path, 0) if gray is None: continue faces detector(gray, 1) if len(faces) 0: print(f跳过无脸图片: {img_path}) continue aligned align_face(gray, faces[0]) feats.append(compute_feature(aligned)) if feats: db[name] np.array(feats).mean(axis0) else: print(f警告: 用户 {name} 没有任何可用照片) with open(features.pkl, wb) as f: pickle.dump(db, f)这里的逻辑值得说明检测不到人脸的图片直接跳过说明它要么模糊、要么角度太大硬塞进均值里反而污染特征每个成员至少保证 20 张以上照片覆盖正脸、左右各 15 度转头、抬头低头、不同光照。注册照片里一个人只有一张自拍现场必然识别不稳这是绝大多数识别失败的根本原因不是模型不够好。很多打包数据集的真实情况是每人只有 10 张左右而且光线单一。拿到后建议先用视频抽帧扩充对着摄像头慢慢左右转头按每秒 2 帧抽取去掉模糊帧保留 20~30 张比单纯复制粘贴图片有用得多。4. 把识别结果变成签到记录Flask 路由与 SQLite识别链路跑通之后系统还剩最后一块拼图把“这个人是谁”变成一条可以查询、统计、导出的签到记录。这里有两个设计重点一个是前后端数据交互方式一个是数据库的防重逻辑。4.1 路由与识别请求设计前端截帧后 POST 给后端常见做法是浏览器端用canvas.toDataURL()截取当前摄像头画面转成 base64 字符串POST 给 Flask 接口。这样做的好处是识别逻辑全部集中在服务端浏览器只负责采集画面。后端接收到图片后解码、检测、对齐、提取特征、比对返回结果。app.route(/api/checkin, methods[POST]) def checkin(): data request.get_json() b64 data[image].split(,)[1] # 去掉 data:image/jpeg;base64 前缀 img_bytes base64.b64decode(b64) np_arr np.frombuffer(img_bytes, np.uint8) frame cv2.imdecode(np_arr, cv2.IMREAD_COLOR) gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces detector(gray, 1) if len(faces) 0: return jsonify(successFalse, message未检测到人脸) aligned align_face(gray, faces[0]) feature compute_feature(aligned) idx, dist match(feature, list(db.values())) if idx -1: return jsonify(successFalse, message未注册人员) person_name list(db.keys())[idx] ok insert_sign(person_name, person_name) if ok: return jsonify(successTrue, messagef签到成功: {person_name}) return jsonify(successFalse, message今日已签到)接口设计上建议返回结构化 JSON而不是渲染一个 HTML 页面。前端拿到success和message后自己决定显示什么文字这样接口可以被后续的移动端复用。data[image]是完整 base64 字符串形如data:image/jpeg;base64,/9j/...所以必须先分割取后半段否则b64decode会报错。4.2 SQLite 签到表与防重逻辑同一张脸在视频流里出现 30 帧怎么处理签到系统最容易在设计上翻车的地方是重复签到。摄像头是流式的同一个人站在镜头前 10 秒画面会产生几十帧人脸如果每一帧都触发一次写入数据库里就会刷出几十条记录。正确做法是让“同一人同一天只能有一条记录”。SQLite 建表时通过联合唯一约束从数据库层面兜底CREATE TABLE IF NOT EXISTS sign_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, person_id TEXT NOT NULL, person_name TEXT NOT NULL, sign_date TEXT NOT NULL, checkin_time TEXT NOT NULL, UNIQUE(person_id, sign_date) );写入逻辑用两层防重先查一次今天的记录如果存在直接返回“已签到”不存在则插入利用唯一约束兜底。这样即使前端重复提交、或者两个客户端同时发起请求数据库也不会写入重复记录。import sqlite3 from datetime import datetime def insert_sign(person_id, person_name): now datetime.now() sign_date now.strftime(%Y-%m-%d) checkin_time now.strftime(%Y-%m-%d %H:%M:%S) conn sqlite3.connect(sign.db) cur conn.cursor() try: cur.execute( INSERT INTO sign_record (person_id, person_name, sign_date, checkin_time) VALUES (?, ?, ?, ?) , (person_id, person_name, sign_date, checkin_time)) conn.commit() return True except sqlite3.IntegrityError: return False # UNIQUE 约束生效当天已签过 finally: conn.close()这里更推荐用应用层先查再插的方式返回“今日已签到”的提示而不是每次都等到IntegrityError才反映。因为用户看到“已签到”和看到“失败”是两种完全不同的体验前者明确告诉他系统已记录了他的打卡。另外checkin_time不要直接依赖数据库当前时间统一用 Python 的datetime.now()这样后续如果要切换不同时区或者做测试时间逻辑都在代码里可查。4.3 前端页面与浏览器安全限制getUserMedia 为什么在局域网 IP 上被拦前端页面不需要太复杂一个视频流、一个签到按钮、一个结果提示就够。关键是浏览器对摄像头权限的限制非常严格这块的坑比后端还多。video idcam autoplay playsinline/video canvas idframe styledisplay:none/canvas button onclickcheckin()签到/button b idresult/basync function checkin() { const video document.getElementById(cam); const canvas document.getElementById(frame); canvas.width video.videoWidth; canvas.height video.videoHeight; canvas.getContext(2d).drawImage(video, 0, 0); const dataUrl canvas.toDataURL(image/jpeg, 0.9); const resp await fetch(/api/checkin, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({image: dataUrl}) }); const data await resp.json(); document.getElementById(result).textContent data.message; } navigator.mediaDevices.getUserMedia({video: true, audio: false}) .then(stream document.getElementById(cam).srcObject stream) .catch(err console.error(摄像头被拒绝, err));drawImage截取当前帧toDataURL(image/jpeg, 0.9)的第二个参数是 JPEG 压缩质量0.9 够清晰再低会损失人脸细节影响识别。最麻烦的是浏览器安全策略getUserMedia只允许在localhost或 HTTPS 环境下调用。你用http://192.168.1.100:5000访问页面控制台会报getUserMedia() must be called in secure context。开发时最省事的办法是在本机用http://localhost:5000访问演示时再给电脑配一个自签名 HTTPS 证书或者用 Chrome 的 insecure origin 白名单参数启动浏览器。5. 从装库到摄像头占用人脸签到落地常见的 5 个坑人脸签到系统的代码量不一定大真正卡人的都是环境问题和数据问题。下面这五条是按踩坑频率排的序基本覆盖从零开始搭建的完整路径。5.1 ModuleNotFoundError: No module named cv2现象运行python app.py第一行 import 就报错。原因大多数情况是 Python 版本太新老版本opencv-python轮子还没适配或者依赖装到了全局环境虚拟环境里没有。还有一部分人错装了opencv-python-headless它在服务器上能用但缺少 GUI 相关模块如果你后续调用cv2.imshow()会直接报错。解决进入项目虚拟环境执行pip install opencv-python如果系统是 Python 3.12 以上优先安装最新版 opencv。部署到服务器跑识别时再考虑替换成opencv-python-headless两者不能混装否则有版本冲突。5.2 cv2.error: OpenCV(4.4.0) 之类的参数报错现象程序能启动但跑到某一帧突然中断报出类似cv2.error: OpenCV(4.4.0) ...的一长串信息。原因按经验看绝大多数是图像尺寸或通道数不匹配。比如cv2.warpAffine的 dsize 传了(0, 0)或者把灰度图送进了期望 RGB 输入的特征模型。解决先看报错信息末尾的函数名warpAffine就查输出尺寸cvtColor就查通道数。建议在关键函数入口打印img.shape确认是(h, w)还是(h, w, 3)。灰度图是二维数组BGR 图是三维数组混用是最常见的问题源。5.3 摄像头打不开cap.read() 一直返回 False现象页面能打开但视频是黑的后端日志显示cap.read()一直返回False。原因Windows 下 Flask debug 模式会启动两个进程两个进程同时抢占摄像头设备或者摄像头被其他软件占用虚拟机里没有把宿主机的摄像头设备挂载进来。解决把app.run(debugFalse)写死并检查任务管理器里是否有残留 Python 进程。如果多人共用一个 USB 摄像头前一个人没释放设备后一个人就打不开。重启电脑是最快的排错手段能解决八成摄像头占用问题。5.4 识别率低注册库照片只有一张自拍现象注册时用手机拍了一张正脸照现场测试时怎么测都不识别。调大阈值能认出来但陌生人也被误认成已注册人员。原因这是数据集质量问题的典型表现。单张照片只覆盖了一个角度和一种光线现场姿态、明暗稍有变化特征向量就会漂移出阈值范围。解决按第 3 章的方法采集 20 张以上照片覆盖左右转头、抬头低头、室内灯光和自然光。如果原始打包数据集不够用可以写脚本按视频帧抽取注册照片。这段是纯数据工程不值得省时间。5.5 同一人一分钟内签到了三次数据库没有防重现象测试时发现签到表同一人出现多条记录而且时间间隔只有几秒。原因视频流连续产生人脸帧前端自动触发多次 POST而后端没有当天防重逻辑。解决用第 4 章的表结构UNIQUE(person_id, sign_date)约束加上应用层先查再插。另外建议前端设一个状态位点击签到后按钮置灰直到收到后端响应再恢复从源头减少重复请求。6. 验证与进阶先做小样本测试再提活体和防刷系统能跑通只是第一步能不能交付看的是阈值标定和边界场景处理。这里介绍一套我习惯用的验证流程和三个值得做的进阶点。6.1 用小样本验证集把阈值找出来dlib 官方给的0.5只是经验值每个注册库的成员构成、摄像头型号、现场光线都会改变最优阈值。正确做法是留出验证集做阈值扫描注册时每人存 20 张另留 10 张完全不参与特征库构建专门用来测试误识率FAR和拒识率FRR。for thr in [0.40, 0.45, 0.50, 0.55, 0.60]: far 0 frr 0 total_far 0 total_frr 0 for name in test_names: for img_path in test_imgs[name]: gray cv2.imread(img_path, 0) faces detector(gray, 1) if not faces: continue aligned align_face(gray, faces[0]) feat compute_feature(aligned) idx, dist match(feat, db) if dist thr: if list(db.keys())[idx] ! name: far 1 else: frr 1 total_far 1 if list(db.keys())[idx] ! name else 0 total_frr 1 print(fthr{thr:.2f} FAR{far/total_far:.3f} FRR{frr/total_frr:.3f})扫描结果的判读规则简单直接FAR 是“不是他的人被认成他”FRR 是“他是本人却没认出来”。签到系统里 FAR 比 FRR 危险因为误认会导致别人能帮你签到。我一般选“FAR 为 0 且 FRR 尽可能低”的那个阈值而不是硬套 0.5。6.2 三个有价值的进阶活体检测、注册模式、签到时间窗静态照片攻击是签到系统最容易被挑战的点一张 A4 纸打印的照片就能骗过基础识别。最简单的活体判断是用关键点算眼睛睁开程度连续 5 帧中检测到眨眼动作才认为“人是活的”。用 68 点关键点里上眼睑和下眼睑的坐标比值就能实现不需要额外模型代码量也不大但答辩时说到“防照片攻击”会非常加分。注册模式也值得做。与其手工整理数据集不如在系统里加一个“注册页”人站在摄像头前自动采样 30 帧剔除模糊帧后取特征均值入库。这样换人登记就不需要碰代码。签到时间窗可以配合考勤规则超过早上 9 点签到标记“迟到”写入独立字段。SQLite 表加一列status即可逻辑不复杂但让系统从“签到工具”变成“考勤系统”实用性提升不少。这些功能加完后记得重新过一遍验证集。阈值不是调好一次就不变的每加一个人进特征库整个人脸特征分布都会变化阈值可能有几毫的偏移。我自己的习惯是每新增一批注册数据就重跑一次阈值扫描哪怕结果没变也让心里有底。用固定套路反复验证比临时瞎调省心得多。希望帮到你。本文还有配套的精品资源点击获取