基于Python+Dlib+OpenCV的课堂人脸签到与考勤系统

发布时间:2026/9/17 14:21:40
基于Python+Dlib+OpenCV的课堂人脸签到与考勤系统 简介面向高校计算机相关专业的毕业设计与课程实践选题这是一套基于Python的课堂人脸签到系统论文方案针对传统点名效率低、替课现象难以杜绝的问题给出从技术选型到系统测试的完整实现思路。压缩包内仅1个docx文件约1.58MB按章节组织涵盖Python语言、Flask框架、OpenCV与Dlib库的技术介绍系统调研与可行性分析需求分析、架构设计、数据库设计与模块设计并逐项展开选择课程、人脸识别签到、考勤查询、开放课程、编辑课程、拍照权限设置与考勤修改等功能的实现细节另附测试用例、结论、参考文献与致谢。目前已有458人学习下载。对准备开题与答辩的学生而言可参照其目录框架与模块划分梳理写作结构也可借助人脸识别与前后端数据交互部分理解Flask、RESTful API在实际项目中的落点适合作为毕设模板与课程设计参考。1. 从替课说起Dlib 与 OpenCV 双库撑起的人脸签到闭环上课铃响过三分钟签到表从前排传到后排最后交上来一张写满名字的纸而教室里实际坐着的人少了七八个——这是很多高校课堂的真实切片。纸质签到、二维码签到、蓝牙签到都试过二维码可以截图转发蓝牙可以让同学把手机带进教室唯独人脸这件东西不好代劳。基于 Python 的课堂人脸签到系统就是冲着这个缝隙做的学生端选课、录入人脸教师端开放签到、查看考勤管理员维护用户与课程整套业务跑在 Flask 上人脸部分交给 OpenCV 做图像预处理、Dlib 做人脸检测与 128 维特征编码特征向量落进 MySQL签到结果实时回写考勤表。它适合两类人一类是要交毕业设计、需要一套能跑通、能答辩、能扩展的完整工程的学生另一类是想把生物特征认证接进自己 Web 系统的开发者。整套代码不依赖 GPU一台带摄像头的普通笔记本就能完成开发与演示难点不在模型而在环境编译、特征阈值标定和并发签到的一致性。2. 环境与依赖落地Python 虚拟环境、Dlib 编译、MySQL 建库拿到源码包之后先别急着python app.py。这套系统的失败案例里八成卡在依赖安装这一步尤其是 Dlib 需要本地 C 编译工具链直接pip install dlib大概率报错退出。2.1 技术选型为什么是 Dlib OpenCV 而不是单一库从功能分工看OpenCV 负责把图变干净Dlib 负责把人认出来。OpenCV 的cv2提供摄像头抓帧、BGR/RGB 色彩空间转换、直方图均衡化、人脸区域裁剪和缩放这些是喂给识别模型前的必要预处理Dlib 则提供 HOG 方向梯度直方图人脸检测器、68 点面部关键点预测器以及基于 ResNet 的face_recognition_model_v1把一张对齐后的人脸压成 128 维浮点向量。两者组合的实际收益是OpenCV 的分辨率归一化和灰度均衡能把暗光下的检出率拉回来一部分而 Dlib 的 128 维描述子在 LFW 类数据集上的判别力足够区分同班几十名学生。相比 FaceNet 这类需要 TensorFlow 的方案Dlib 的模型文件只有几十 MBCPU 单帧推理在百毫秒量级课堂场景几十人的签到压力完全可以承受。相比纯 OpenCV 的 LBPH 识别器Dlib 在侧脸、戴眼镜、刘海遮挡下的稳定性明显更好代价是多一次模型文件加载。2.2 依赖安装与 Dlib 编译的常见坑先把虚拟环境建起来避免污染系统 Python# 建虚拟环境隔离依赖Python 建议 3.8 ~ 3.10 python -m venv venv # Linux / macOS 激活 source venv/bin/activate # Windows 激活cmd venv\Scripts\activate.bat # 先装纯 Python 依赖用国内镜像提速 pip install -i https://pypi.tuna.tsinghua.edu.cn/simple flask pymysql opencv-python numpy jinja2 werkzeugDlib 单独装装之前确认编译工具链。Ubuntu 上需要build-essential、cmake和libopenblas-devWindows 上装 Visual Studio Build Tools 勾选使用 C 的桌面开发再装 CMake 并加入 PATHmacOS 装 Xcode Command Line Tools 即可。工具链就位后再执行# 编译安装 dlib第一次会花几分钟到十几分钟 pip install -i https://pypi.tuna.tsinghua.edu.cn/simple dlib # 装完自检能打印版本号说明编译成功 python -c import dlib, cv2; print(dlib.__version__, cv2.__version__)如果编译在中途报CMake Error或者内存被杀绝大多数情况是并行编译占用过高可以改用pip install dlib --no-cache-dir -v观察日志或者在资源受限的机器上直接下载对应 Python 版本的预编译 wheel 文件离线安装。另外两个模型文件必须放在项目models/目录下shape_predictor_68_face_landmarks.dat负责关键点dlib_face_recognition_resnet_model_v1.dat负责特征编码路径写死在配置里缺一个都会在第一次调用时抛异常。依赖用途是否需编译典型坑flask路由、模板渲染、请求分发否版本过新时werkzeug的 URL 规则报错opencv-python抓帧、色彩转换、缩放裁剪否服务器无 GUI 时报imshow相关错误dlib人脸检测、关键点、128 维编码是缺 CMake / C 工具链模型文件路径错numpy向量运算、距离计算、特征均值否与 dlib 的 ABI 不匹配导致段错误pymysql连接 MySQL、读写特征 BLOB否字符集未设 utf8mb4中文姓名乱码2.3 数据库建表与 Flask 项目骨架表结构直接决定了后面接口怎么写。课程表、学生表、教师表按论文里的设计走关键是人脸特征表和考勤表-- 学生人脸特征表一条学生记录对应一个 128 维向量 CREATE TABLE student_feature ( S_id INT NOT NULL COMMENT 学生编号主键, feature BLOB NOT NULL COMMENT 128 维 float64 向量序列化后存入, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (S_id), CONSTRAINT fk_feature_student FOREIGN KEY (S_id) REFERENCES student(S_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 考勤表(学生,课程) 唯一天然防重复签到 CREATE TABLE attendance ( id INT NOT NULL AUTO_INCREMENT, S_id INT NOT NULL, C_id INT NOT NULL, time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, result VARCHAR(20) NOT NULL DEFAULT normal, PRIMARY KEY (id), UNIQUE KEY uk_stu_course (S_id, C_id), KEY idx_course_time (C_id, time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;feature用BLOB而不是VARCHAR因为numpy.ndarray.tobytes()出来的是二进制存字符串会因编码转换丢精度128 维 float64 正好 1024 字节读取时用np.frombuffer(blob, dtypenp.float64)还原即可。attendance上的uk_stu_course唯一索引是防重复签到的第一道闸门接口层再加一次存在性判断两道防线之后并发提交也只会落一条记录。idx_course_time是给教师端按课程查考勤用的签到高峰时这条复合索引能把全表扫描挡掉。Flask 侧用应用工厂模式把路由拆开避免所有接口堆在一个文件里# app/__init__.py from flask import Flask from .config import Config from .db import init_db def create_app(): app Flask(__name__, template_folder../templates, static_folder../static) app.config.from_object(Config) # 数据库地址、模型路径、识别阈值都放这里 init_db(app) # 注册 pymysql 连接池 from .views import student, teacher, admin app.register_blueprint(student.bp, url_prefix/student) app.register_blueprint(teacher.bp, url_prefix/teacher) app.register_blueprint(admin.bp, url_prefix/admin) return appConfig类里至少要暴露四个可调项MODEL_DIR模型目录、MATCH_THRESHOLD匹配阈值、MYSQL_*连接参数、MAX_FACES_PER_FRAME单帧最多处理几张脸。把它们抽出来而不是散落在代码里后面调阈值、换数据库、换模型都不用改业务逻辑这也是答辩时被问系统怎么扩展最实在的回答。3. 人脸特征提取68 关键点、128 维描述子与签到判定签到准不准取决于特征提得稳不稳、比得合不合理。整条链路是抓帧 → 检测人脸框 → 关键点定位 → 编码成向量 → 与库中向量算距离 → 距离小于阈值即认定为本人。3.1 检测、对齐、编码三步流水线Dlib 的 HOG 检测器对正面脸非常灵敏但它对图像尺寸敏感超过 800 像素宽的大图会明显变慢。常见的做法是在检测前把帧缩到 480 或 640 宽检测和编码都在缩略图上做裁剪和展示再映射回原图坐标import cv2 import dlib import numpy as np detector dlib.get_frontal_face_detector() sp dlib.shape_predictor(models/shape_predictor_68_face_landmarks.dat) facerec dlib.face_recognition_model_v1(models/dlib_face_recognition_resnet_model_v1.dat) def face_vector(bgr_img, up_sample1, num_jitters3): 输入 BGR 图返回 128 维特征向量检测不到人脸返回 None rgb cv2.cvtColor(bgr_img, cv2.COLOR_BGR2RGB) # dlib 吃 RGB不吃 BGR # 缩放降低检测耗时同时保住检出率 h, w rgb.shape[:2] scale 640.0 / w if w 640 else 1.0 small cv2.resize(rgb, (int(w * scale), int(h * scale))) if scale ! 1.0 else rgb dets detector(small, up_sample) # up_sample 越大越慢检出越多 if len(dets) 0: return None # 一帧多脸时取最大的一张签到场景通常只有一个主体 d max(dets, keylambda r: r.width() * r.height()) shape sp(small, d) # 68 个关键点用于对齐 # num_jitters 次抖动采样取平均能压掉单帧噪声 vec facerec.compute_face_descriptor(small, shape, num_jitters) return np.array(vec, dtypenp.float64)up_sample控制检测前的上采样次数默认 1 已经够用把它调到 2 会让小脸更容易被检出但耗时接近翻倍跟拍距离近的签到场景没必要。num_jitters是编码阶段的采样次数3 次是本项目实测比较合适的取值次数太低向量方差大次数太高单帧编码时间从十几毫秒涨到几十毫秒。compute_face_descriptor得到的是 128 维结果必须显式转成float64的ndarray否则与库里向量做减法时会因对象类型不一致而报错。3.2 连续五张取均值把单帧抖动压下去论文里人脸录入的操作序列写得很明确——连续拍摄五张照片提交。这不是为了好看而是工程上最省事的降噪手段。同一张脸在不同帧里因为轻微头部转动、摄像头自动曝光变化编码出的向量会有小幅波动把五帧的向量取算术平均再重新做一次 L2 归一化比单帧向量稳定得多def enroll_vector(frames): frames: 连续 5 帧 BGR 图像列表返回平均后的特征向量 vecs [] for f in frames: v face_vector(f) if v is not None: vecs.append(v) if len(vecs) 3: return None # 有效帧不足 3 张直接判定录入失败 avg np.mean(np.stack(vecs, axis0), axis0) # 逐维取均值 norm np.linalg.norm(avg) return avg / norm if norm 0 else None # L2 归一化统一量纲返回None的两种情形要分开处理有效帧少于 3 张是拍摄质量问题前端应该提示光线不足或面部未正对镜头请重拍五张全部检测不到人脸多半是摄像头没正确打开或者被人挡了属于交互问题。把这两种失败分开反馈能省掉大量为什么我录入不了的反复沟通。归一化这一步不能省未归一化的向量在做欧氏距离比较时会被向量的模长带偏判断结果。3.3 特征入库与欧氏距离匹配库里存的是归一化后的 128 维向量签到时的判定就是把当前向量与库里每条向量算欧氏距离取最小值再和阈值比较def match(vec, gallery, threshold0.45): gallery: {S_id: np.ndarray(128,)}返回 (学生编号, 距离) best_id, best_dist None, float(inf) for sid, gv in gallery.items(): dist np.linalg.norm(vec - gv) # 128 维欧氏距离 if dist best_dist: best_id, best_dist sid, dist if best_dist threshold: return None, best_dist # 超出阈值视为陌生人 return best_id, best_dist阈值是整套系统最需要现场标定的参数没有一个放之四海皆准的数值。用 face_recognition 封装时默认 0.6直接用 Dlib 原始描述子时通常要收紧到 0.4~0.5 区间。同一个人在不同光照下的距离大概落在 0.2~0.4不同人之间普遍大于 0.55中间这段就是需要按班级样本实测确定的边界。阈值识别表现适用场景0.35 及以下误识率极低但本人常被拒高安全场景允许二次人工确认0.40 ~ 0.50同班数十人规模下误识与漏识较平衡常规课堂签到推荐起点0.55 ~ 0.60几乎不会拒真但近似脸型可能误识人员区分度高的场景0.60 以上风险明显上升不建议用于考勤判定实际标定时把本班每个人的多张照片互相两两比对画一条距离分布曲线取认对的最大距离和认错的最小距离的中点附近作为阈值比凭感觉拍一个数字靠谱得多。演示环境人数少、脸型差异大阈值可以放宽真正上线到几百人的院系最好再补一个第二判据比如取距离最小的两个候选人若两者差距小于 0.05 就判为不确定转人工确认。4. Flask Jinja2 RESTful API签到接口与考勤数据闭环特征提得准只是上半场下半场是把识别结果变成一条不可抵赖的考勤记录并且让教师、学生、管理员三个角色各看到该看的东西。4.1 接口设计三个角色、九类操作按论文里的功能模块划分接口层可以归成三组。设计时统一JSON返回结构{code, msg, data}前端只判断code不解析 HTTP 状态码的语义能少写一堆分支。接口路径方法角色作用/student/course/selectPOST学生选课 / 退课/student/face/enrollPOST学生提交 5 帧图像录入人脸特征/student/attendance/checkinPOST学生人脸识别并签到/student/attendance/queryGET学生查询已选课程考勤/teacher/course/openPOST教师开放 / 关闭课程选课/teacher/course/editPOST教师增删改课程/teacher/photo/permissionPOST教师设置学生拍照权限/teacher/attendance/startPOST教师开放当次签到/teacher/attendance/modifyPOST教师人工修改考勤结果/admin/user/managePOST管理员用户与课程的后台管理设置学生拍照权限这条看着不起眼其实是防作弊链条上的关键一环。教师在课堂签到开始前才打开权限学生端的人脸录入和签到接口都会先查这张权限表权限关闭时直接返回拒绝避免学生提前把照片传上去。4.2 人脸录入与签到接口实现录入接口接收前端分片上传的五帧 base64 图像解码后走编码流程最后把向量写库from flask import Blueprint, request, jsonify import base64, cv2, numpy as np from app.db import get_conn from app.face import enroll_vector bp Blueprint(student, __name__) bp.post(/face/enroll) def face_enroll(): sid request.json.get(s_id) imgs request.json.get(images, []) # 前端传 5 张 base64 if not sid or len(imgs) 5: return jsonify(code400, msg图像数量不足, dataNone) frames [] for b64 in imgs: raw base64.b64decode(b64.split(,)[-1]) # 去掉 data:image/jpeg;base64, 前缀 arr np.frombuffer(raw, np.uint8) img cv2.imdecode(arr, cv2.IMREAD_COLOR) # 解码成 BGR if img is not None: frames.append(img) vec enroll_vector(frames) if vec is None: return jsonify(code422, msg人脸质量不合格请重拍, dataNone) conn get_conn() with conn.cursor() as cur: cur.execute( REPLACE INTO student_feature (S_id, feature) VALUES (%s, %s), (sid, vec.tobytes()) # float64 二进制直接落库 ) conn.commit() return jsonify(code0, msgok, data{dim: int(vec.shape[0])})REPLACE INTO让重复录入变成覆盖更新学生换发型、换眼镜后重新录入不需要先删旧记录。imdecode前先用frombuffer把字节流转成uint8数组这是 OpenCV 在内存中解码图片的标准写法绕开了写临时文件的 IO。返回里带上向量维度前端可以在控制台直接确认录入结果是不是 128 维排查时非常省事。签到接口多了两步先查拍照权限和签到是否已开放再做识别和落库。bp.post(/attendance/checkin) def checkin(): c_id request.json.get(c_id) img_b64 request.json.get(image) raw base64.b64decode(img_b64.split(,)[-1]) frame cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) conn get_conn() with conn.cursor() as cur: # 1. 教师是否已开放该课程的拍照权限与签到 cur.execute(SELECT flag FROM courses WHERE C_id%s, (c_id,)) row cur.fetchone() if not row or row[0] ! open: return jsonify(code403, msg当前未开放签到, dataNone) # 2. 取出本课程已选学生的人脸特征 cur.execute(SELECT f.S_id, f.feature FROM student_feature f JOIN courses c ON c.C_id%s JOIN attendance a ON a.S_idf.S_id, (c_id,)) gallery {sid: np.frombuffer(blob, dtypenp.float64) for sid, blob in cur.fetchall()} from app.face import face_vector, match vec face_vector(frame) if vec is None: return jsonify(code422, msg未检测到人脸, dataNone) sid, dist match(vec, gallery, threshold0.45) if sid is None: return jsonify(code404, msg未匹配到学生, data{distance: round(dist, 4)}) with conn.cursor() as cur: # 3. 唯一索引兜底重复签到不会产生第二条记录 cur.execute(INSERT INTO attendance (S_id, C_id, result) VALUES (%s, %s, normal) ON DUPLICATE KEY UPDATE resultVALUES(result), (sid, c_id)) conn.commit() return jsonify(code0, msg签到成功, data{s_id: sid, distance: round(dist, 4)})ON DUPLICATE KEY UPDATE配合前面的uk_stu_course唯一索引让签到接口天然幂等同一个学生连点三次签到按钮数据库里仍然只有一条记录只是result被重复写了一遍。返回体里的distance值务必带上出问题时能立刻判断是没检出脸还是检出了但不像这是排查现场问题最快的一条线索。4.3 Jinja2 模板与前端摄像头抓帧页面用 Jinja2 渲染骨架摄像头抓帧交给浏览器原生 API把 5 帧压缩后一次性提交避免多次请求带来的状态同步麻烦// 连续抓 5 帧间隔 200ms让头部有轻微姿态变化 async function captureFrames(video, n 5, gap 200) { const canvas document.createElement(canvas); canvas.width 640; canvas.height 480; const ctx canvas.getContext(2d); const frames []; for (let i 0; i n; i) { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); frames.push(canvas.toDataURL(image/jpeg, 0.85)); // 0.85 质量兼顾体积与细节 await new Promise(r setTimeout(r, gap)); } return frames; }0.85的 JPEG 质量是实践中的折中值压到 0.7 以下时面部细节开始明显损失128 维向量的判别力跟着下降保持在 0.9 以上单张图翻到几百 KB五张一起传在弱网教室的响应时间会很难看。gap设为 200 毫秒五帧的整体耗时约一秒足够采集到自然的姿态变化又不至于让学生举着手机站太久。4.4 考勤查询与教师改签的数据一致性学生端查考勤、教师端改考勤看着简单但两者读写的字段必须约定清楚否则会出现学生看到已签、教师看到缺勤的矛盾。约定是attendance.result只取normal、leave、absent三个值学生端只读教师端通过/teacher/attendance/modify修改任何绕过这个接口直接改库的操作都可能破坏唯一索引约束。-- 学生查询已选课程的考勤情况 SELECT c.C_name, a.time, a.result FROM attendance a JOIN courses c ON c.C_id a.C_id WHERE a.S_id %s ORDER BY a.time DESC; -- 教师人工改签必须带课程编号和学生编号双重条件 UPDATE attendance SET result %s, time NOW() WHERE S_id %s AND C_id %s;教师改签的UPDATE语句里同时限定S_id和C_id是为了防住参数拼接出错时的越权修改。如果这条语句影响行数为 0说明该学生这次课根本没有签到记录——这时不应该用INSERT补一条而应该走补签流程并记录操作人否则后面出了争议查不出是谁改的。给attendance加一张operation_log表记录改签人、原值、新值和改签时间是成本很低但对答辩加分明显的一件事。5. 阈值标定与线上排错误识、光照、并发签到的处理技巧代码跑起来只是起点真到课堂上用最先暴露的往往是阈值和光照。5.1 用样本距离分布标定阈值而不是拍脑袋把本班学生分成注册集和测试集注册集每人一张均值向量入 gallery测试集每人多张照片做查询统计所有样本对的欧氏距离画两张直方图同人距离分布和异人距离分布。取两条曲线交叉点偏保守一侧的值作为初始阈值再按实际拒绝率微调。实操中更省事的办法是每次签到把匹配距离写进日志表跑完两三次课用真实数据反过来看距离分布比实验室标定更接近实际摄像头和光照条件。常见做法是把匹配阈值设成可配置项教师端后台开一个输入框现场发现误识就往上调 0.02发现拒真就往下调 0.02改完即时生效不用重启服务。5.2 光照、遮挡与活体判定的兜底手段教室的顶灯会在额头造成高光逆光时人脸整体偏暗这两种情况都会让检出率掉下来。预处理阶段做一次自适应直方图均衡化cv2.createCLAHE能缓解一部分但更有效的是在拍照页面上加实时提示给视频加一个居中的人脸参考框检测不到人脸就不允许点击提交按钮从源头减少废片。至于拿照片对着摄像头这一招单帧的 128 维向量算不出活的还是死的工程上比较务实的下限是要求连续五帧都检出人脸且五帧向量的标准差小于 0.05纯静态照片在轻微手持抖动下往往过不了这一关。真要更强的活体能力再叠加眨眼检测或随机动作指令但那已经超出这套毕业设计的范围了。5.3 并发签到的幂等与唯一索引一个班三十多人同时点签到Flask 默认开发服务器会排队处理接口层面的竞态主要出现在先查是否存在、再插入这种两步写法上两个请求同时查到不存在然后都执行插入第二条被唯一索引拦下抛异常前端就会看到 500。解决办法是把插入写成INSERT ... ON DUPLICATE KEY UPDATE让数据库在一次原子操作里完成判重和写入Python 侧只需要捕获pymysql.err.IntegrityError作为最后兜底返回已签到提示即可。连接池大小按并发量给课堂场景pool_size10完全够用真正需要关注的是签到接口里的特征比对循环——把 gallery 一次性取出放在内存里比对比每帧都去查一次数据库快一个数量级。最后把(S_id, C_id)上的唯一索引建好签到接口天然幂等重复提交最多只会得到一条影响行数为 0 的记录。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询