从大四上学期开始,我就在琢磨怎么把自己课上那个"替人答到"的顽疾给治了。学校里有些课靠签到表传阅,有些课靠老师点名,结果就是前排同学一人签一排,后排睡觉的同学稳如泰山。后来我干脆把这个想法做成了一整套系统:Python后端用Flask搭建,人脸识别做身份校验,微信小程序做学生端入口,再加上GPS定位来做签到范围限制,顺便把选课功能也叠了进去。这篇文章就把我整个开发过程、架构设计、踩过的坑一次讲清楚,希望能给准备做类似项目(尤其是毕业设计方向是Python、Flask、微信小程序这几样组合的同学)一个完整的参考。
先说清楚这套系统大概是什么样:学生在微信小程序里登录选课,到上课时间进入签到页,小程序端调用摄像头拍一张人脸照片,同时获取当前的GPS定位坐标,一起提交到Flask后端。后端先校验坐标是否在老师设定的签到范围(比如教室半径100米内),再调用人脸识别模块比对证件照库里的照片,识别通过后才把签到记录写入数据库。老师端(同样是微信小程序,或者后台网页)可以查看出勤统计、导出考勤报表、管理课程和学生名单。
1. 从一个课堂痛点开始:这套考勤系统到底值不值得做
1.1 传统课堂考勤的三个老大难问题
做这个项目之前,我其实先翻了学校里的考勤需求。传统考勤方式的痛点非常明显,每一个都值得被技术解决。
第一是代签问题。纸质签到表一人写一排的玩法几乎没法防,就算老师当面点名,后排同学帮忙应一声也没辙。第二是考勤数据统计麻烦。纸质数据要助教手动录入Excel,出错率高,期末算平时分的时候翻车是常有的事。第三是考勤结果无法跟教学互动结合。老师只知道谁来了谁没来,却不知道学生是否真的在教室范围内,比如有些学生卡点到、签完就走,实质上课堂参与度仍然为零。
人脸识别和定位这两个能力刚好能解决前三者,而选课系统则给考勤数据加了一层"课程维度"的组织关系。你想想,如果一个学生选了三门课,不同课程在不同教室,那签到记录就必须绑定到具体课程和具体节次上,不然出勤数据就是乱账。所以选课、签到、定位、人脸识别这四块功能是咬合在一起的整体,拆开任何一个都会让系统不完整。
1.2 技术选型:为什么是Flask、微信小程序和开放的人脸识别库
这块是当初纠结最久的部分。先给结论:对课程设计、毕业设计级别的项目来说,这套组合是目前综合成本和效果最平衡的方案。
Flask相比Django更轻,路由简单透明,非常适合一个人快速搭建API服务。你不需要理解Django那种大而全的Admin后台、ORM迁移体系,Flask从app = Flask(name)到跑起来只需要几分钟。而考勤系统本质上就是一组REST接口加一个数据库,没有特别复杂的业务状态机,用Flask刚好合适。
微信小程序的选型理由更直接:学生不需要安装额外App,扫码或搜索即可进入,而且微信提供了稳定的定位接口(wx.getLocation)和相机调用能力(wx.chooseMedia)。比起还得上架应用商店、处理签名证书的原生App,小程序的迭代和分发成本要低一个量级。如果你以后想扩展到其他学校,把小程序代码上传审核就行,不需要每个用户都经历安装过程。
人脸识别这里我没有选择从头训练深度学习模型,而是用了face_recognition这个Python库,它封装的dlib模型在中小规模人脸库场景下识别精度足够,而且API极其简单。当然它有两个坑,后面我会专门讲:一个是对生产环境部署不太友好(模型文件大、依赖重),另一个是在ARM架构或国内云服务器上有时候编译装不上。但考虑到这个项目是课堂考勤场景,一个班几十人,一张照片几秒内能出结果,完全够用。
1.3 系统的核心流程:一次完整签到的链路
我用大白话把这套系统的完整流程串一遍。学生在小程序里先登录(微信授权手机号或者用户名密码都行),登录后进入选课页面,勾选本学期课程。到上课时间段,系统在小程序首页推送签到入口,学生点击后先授权定位,小程序获取当前GPS坐标;随后弹起拍照界面,学生对着镜头拍一张正脸照片;小程序把坐标+照片+课程ID+学生ID打包,POST到Flask后端的签到接口。
后端接口收到请求后干四件事:第一,解析出学生ID和课程ID,检查该学生是否选了这门课;第二,校验坐标是否在签到范围(这里用的是GPS经纬度与课程预设中心点的球面距离计算,不严谨但够用);第三,调用人脸识别服务,把学生上传的照片与数据库预存的证件照人脸特征做比对;第四,全部通过后写入出勤记录,响应里返回"签到成功"。整个过程从拍照到拿到结果,实测在班级人数50人以内时基本能控制在3到5秒,主要耗时在照片上传和CPU上的人脸特征提取。
2. 后端Flask接口设计:从数据库表结构到人脸校验接口的实现
2.1 数据库表结构设计:五张表撑起整个考勤业务
做后端第一件事不是写接口,而是把表结构想清楚。我用的SQLite,零配置、单文件、足够撑起几百个用户的量级。如果你嫌SQLite不专业,换MySQL或者PostgreSQL也容易,ORM层用SQLAlchemy的话切换成本很低。
我建了五张表:用户表(users)、学生选课表(enrollments)、课程表(courses)、签到记录表(attendance_records)和人脸特征表(face_features)。
用户表存学生和老师的基础信息,字段大致是id、username、password_hash(我用的werkzeug自带的generate_password_hash)、role(区分student/teacher)、real_name、student_id。这里特别说明,密码一定不要明文存,哪怕只是课设项目,被人扒出来明文密码挂在GitHub上都是社死现场。
课程表存course_id、course_name、teacher_id、latitude、longitude、radius(签到半径)、start_time、end_time、weekday。经纬度和半径就是定位签到的核心配置,老师在创建课程的时候直接把教室的GPS坐标填进去,半径默认100米。
选课表主要是把学生和课程做多对多关联,字段是id、student_id、course_id。签到记录表是整个系统最核心的表:id、student_id、course_id、timestamp、latitude、longitude、photo_path、recognize_score、status(1为成功,0为失败,2为定位不在范围内)。人脸特征表我用来缓存每个人的人脸特征向量,字段是user_id、feature_blob。为什么要单独放一张表而不是存在用户表里?因为特征向量是1024维的float数组,序列化后体积不小,和其他频繁读取的用户信息混在一起会影响查询性能。
2.2 签到接口的关键代码:定位校验和人脸比对的完整逻辑
签到接口是整个项目的重头。我用Flask蓝图来组织路由,核心签到逻辑写在一个attendance_bp里。先看定位校验这段,这里用的是两个经纬度点间的Haversine公式计算球面距离,对比课程设置的半径来判断是否允许签到。
from math import radians, sin, cos, sqrt, asin def calculate_distance(lat1, lng1, lat2, lng2): # Haversine公式,计算两个GPS坐标点的球面距离,单位:米 R = 6371000 phi1, phi2 = radians(lat1), radians(lat2) d_phi = radians(lat2 - lat1) d_lambda = radians(lng2 - lng1) a = sin(d_phi / 2) ** 2 + cos(phi1) * cos(phi2) * sin(d_lambda / 2) ** 2 return 2 * R * asin(sqrt(a)) def check_location(lat, lng, course): distance = calculate_distance(lat, lng, course.latitude, course.longitude) if distance > course.radius: return False, distance return True, distance这里注意一个问题:GPS坐标在室内的漂移往往比室外大,一栋教学楼内一般会偏移十几米甚至几十米,所以签到半径不要卡得太死。我实测过,设置100米半径基本能覆盖教室楼内各个位置,但如果你设置50米,有些学生站在窗边、走廊就会出现误判。这个参数在课程表里是可调的,建议默认100到150米之间。
人脸比对这段代码更重要。学生上传的照片经过图像处理后,在后端与预存特征做欧氏距离计算,低于阈值才判定为同一人。这里我用face_recognition这个库,整体逻辑做了简化但流程是完整的:
import face_recognition import numpy as np def verify_face(upload_photo_path, stored_feature_blob): # 从上传照片中提取人脸特征 uploaded_image = face_recognition.load_image_file(upload_photo_path) uploaded_encodings = face_recognition.face_encodings(uploaded_image) if len(uploaded_encodings) == 0: return False, "未检测到人脸" if len(uploaded_encodings) > 1: return False, "检测到多张人脸,请确保只有自己入镜" uploaded_feature = uploaded_encodings[0] stored_feature = np.frombuffer(stored_feature_blob, dtype=np.float64) distance = np.linalg.norm(uploaded_feature - stored_feature) # 欧氏距离小于0.6判定为同一人,这是我调过的合理阈值 if distance < 0.6: return True, round(distance, 4) else: return False, round(distance, 4)阈值0.6是dlib模型的一个经验值,你如果觉得识别过松或者过严,可以根据实际测试调整。阈值越低越严格,建议在0.5到0.6之间做AB测试。这里我还要提醒一个细节:如果学生照片里检测不到人脸或者检测到多张人脸,接口一定要返回明确的错误提示,写清楚是哪一种原因,不然前端会一直懵着不知道到底哪里出问题。
2.3 人脸数据入库:证件照上传与特征预处理
人脸比对的输入是特征向量,不是照片本身。所以我需要提前把每个人的证件照做一次特征提取,存成二进制BLOB。老师端有一个人脸库管理页面,上传学生照片后,后端调face_recognition提取特征,然后把特征序列化存入人脸特征表。
from flask import request, jsonify @app.route('/api/upload_face', methods=['POST']) def upload_face(): file = request.files['photo'] user_id = request.form.get('user_id') temp_path = 'temp_face.jpg' file.save(temp_path) image = face_recognition.load_image_file(temp_path) encodings = face_recognition.face_encodings(image) if len(encodings) != 1: return jsonify({'code': 1, 'msg': '请上传只有一张正脸的高清照片'}) feature_bytes = encodings[0].tobytes() # upsert到face_features表 ...我碰到过的坑是有人上传生活照,侧脸太厉害,导致特征提取失败,或者提取出的特征与正脸照片比对距离过大。所以上传页面上要加一个"请上传正面免冠照,保证光线充足"的提示,后台也可以在库里跑一遍同一人的两张照片做自检,提前把不合格的拦下来。
2.4 API路由设计一览:不只是签到接口
除了签到接口,后端还需要一组支撑业务的路由。我按蓝图划分了四组:
- auth_bp:登录(POST /api/login)、注册(POST /api/register)、获取当前用户信息(GET /api/me)
- course_bp:创建课程(POST /api/courses)、查询课程列表(GET /api/courses)、选课(POST /api/enroll)、退选(DELETE /api/enroll/<course_id>)
- attendance_bp:签到(POST /api/attendance)、查看我的考勤记录(GET /api/attendance/my)、老师查看某门课考勤统计(GET /api/attendance/course/<course_id>)
- face_bp:上传人脸照片(POST /api/upload_face)、更新人脸照片(PUT /api/face)
接口返回格式我统一用JSON,固定带code(0为成功,非0为各类错误码)、msg和data三个字段。这样小程序端解析的时候就特别省事,不用每个接口写一堆出错判断分支。错误码要提前约定清楚,比如1001代表"未选课不能签到",1002代表"定位不在范围内",1003代表"人脸比对失败",前端拿到这些码可以精确提示用户问题在哪。
3. 微信小程序端开发:从登录到拍照签到的完整链路
3.1 小程序页面结构设计与页面跳转逻辑
小程序端的页面我控制在五个以内,避免让用户在小程序里迷路。首页是课程列表页,展示当前用户已选的课程,点击进入课程详情;课程详情页有签到按钮、考勤记录和选课入口;签到页是最核心的页面,负责定位、拍照和提交;个人中心页展示用户信息和人脸照片状态。
页面跳转关系不复杂:登录后默认进首页,首页两个tab("我的课"和"个人中心")。选课入口我放在首页顶部,点进去是可选课程列表,选了以后回到首页刷新课程列表。课程详情页点签到就跳转签到页,签到成功后返回详情页,同时刷新下方的考勤记录。
3.2 登录授权的两种实现方式
微信小程序的登录跟普通Web登录有区别。我实现了两种方案,一种是纯账号密码登录(适合演示环境,不依赖后端AppID配置),另一种是微信授权登录(适合正式部署到微信平台)。
账号密码登录就是在小程序里放一个输入框,收集用户名密码后调后端登录接口。JWT用的是PyJWT,生成token后存到小程序全局变量和storage里,每次请求带上Authorization头。
微信授权登录则是先调wx.login拿code,再把code传给后端,后端用code去微信的接口换openid,用openid作为唯一标识在库里找用户,找不到就自动创建。这个方案更符合微信生态,但前提是你得有一个小程序AppID,并且配置好合法域名。
3.3 定位授权:wx.getLocation的使用与常见坑
小程序的定位签名是wx.getLocation,它默认返回type为wgs84的坐标,这里有个大坑:国内地图普遍用火星坐标系(GCJ-02),而你如果用纯wgs84坐标和教室设的坐标去算距离,会有一个几百米的系统性偏移。这个偏移在大学城那种开阔区域不算大,但在城市楼群里可能导致定位签到的范围偏出去一栋楼。
所以我建议统一用type:'gcj02',后端课程创建的时候老师定位也用相同坐标系。如果你实在要先拿wgs84,那就后端做一次坐标转换,把wgs84转gcj02再算距离。我的做法是后端保存和计算全部用gcj02,并在接口里加注释提醒自己别混坐标系。
调wx.getLocation的时候要在app.json里声明requiredPrivateInfos字段,同时在页面里做好授权失败的处理:如果用户拒绝了定位授权,小程序端不要直接放弃,而是弹窗提示需要定位才能签到,并引导去设置页重新授权。
// 小程序端定位签到代码片段 wx.getLocation({ type: 'gcj02', isHighAccuracy: true, success(res) { const latitude = res.latitude const longitude = res.longitude // 这里继续拍照流程,把坐标暂存到data里 that.setData({ latitude, longitude }) }, fail(err) { wx.showModal({ title: '提示', content: '需要获取位置信息才能完成签到,请前往设置开启定位权限', confirmText: '去设置', success(res) { if (res.confirm) { wx.openSetting() } } }) } })3.4 拍照与人脸照片处理
拍照功能小程序里最合适的组件是wx.chooseMedia,它能调起摄像头,也可以从相册选择。注意,签到场景一定要控制只能实时拍照,不能从相册选,否则学生完全可以上传一张预先准备好的别人照片。canvas拍照流我没用,因为兼容性和处理成本都更高。
拍照拿到图片后可以先看看大小,微信返回的图片经常有1到3MB,直接传后端会让接口变慢。我建议前端用canvas把图片压缩到640x480左右再上传,质量设为0.8,这样体积能压到一两百KB,后端识别速度会快很多。
wx.chooseMedia({ count: 1, mediaType: ['image'], sourceType: ['camera'], sizeType: ['compressed'], camera: 'front', success(res) { const tempFilePath = res.tempFiles[0].tempFilePath that.setData({ photoPath: tempFilePath }) // 展示预览让用户确认是本人正脸 } })拍照这个环节有两个细节让我吃过亏。一是必须强制前置摄像头,如果用户在拍照界面切换成后置摄像头,很容易拍到别人或者场景,我检查过souceType和camera的参数,但个别安卓机对camera参数的响应不完整,所以前端最好在拿到照片后做人脸检测,如果检测不到人脸或者检测到多人脸,直接提示重新拍。二是有学生反应拍出来的人脸是暗的,教室光线不好,我后来在页面上加了一个"请在光线充足处拍摄"的提示,并做了拍照后的清晰度检查。
4. 定位考勤的防作弊设计和实测数据
4.1 为什么定位+人脸识别比单纯一种方案可靠得多
一个常见的误区是"只要有人脸识别就不会代签"。实际不是这样,人脸识别只能证明"来的人是本人",但证明不了"本人在教室里"。所以定位信息不是可选功能,而是必要维度。
反过来,定位能证明"设备在教室里",却永远证明不了"设备主人就是拿着手机的人"。所以只有人脸+定位同时过,这套考勤结果才能同时回答"是不是本人"和"人是否在教室"这两个问题。这也是我在设计时坚持两个校验都要做的原因,任何一项做为可选项都会留下作弊漏洞。
4.2 细节上的防作弊策略:不只是阈值那么简单
我针对常见的作弊方法做了几层防御,这部分我在答辩时被问到过,提前讲一下思路。
第一种是照片翻拍,拿证件照或者别人照片去拍照识别。我的应对策略是人脸活体检测的初级版:前端强制实时拍照并定时调用内置的人脸关键点检测,如果检测不到关键点或者关键点数异常就终止流程;更彻底的做法是接入第三方活体检测API,但课设阶段成本太高,初级版足够了。
第二种是改定位数据,用模拟定位软件把打卡坐标改到教室。这个层面防不胜防,因为小程序端和后端都很难区分真实GPS和模拟GPS。我的应对思路是记录设备指纹,比如提取手机型号和系统版本,如果一台设备多次以不同模拟位置签到并且位置跳跃异常,后台会提示异常考勤让老师人工核验。这个方案不能完全杜绝作弊,但能降低作弊的隐蔽度。
第三种是照片中检测到的多张人脸,比如一个学生拿了三部手机帮三个人签到。我的接口里限定了uploaded_encodings的数量,必须恰好检测到一张人脸,就算照片里同时出现多张脸,也只有正中间那一张才会被取出来比对,其余人会被拦截。实际测试中这个限制效果不错。
4.3 定位精度实测记录:不同楼宇环境下的表现
这部分数据我花了两个星期在不同教学楼里测出来的。在开阔的操场边,GPS精度在5到10米;普通教学楼里,走廊和教室靠窗的位置,定位精度约15到30米;老旧教学楼深处、没有靠窗的内侧教室,GPS漂移最厉害,可以达到50米以上,甚至有一次测出过110米的偏移。
所以签到半径的设置根本不能一刀切。我给老师端的建议是:创建课程的时候先实地打开地图,在教学楼中心取一个点,根据教学楼面积和教室分布把半径设置在100到200米之间。如果想更精准,还可以让学生提前在教室里打几个"参考点",后端自动算出教室中心,减少老师手动定位的工作量。
5. 部署上线中的踩坑记录:依赖、模型和前后端联调
5.1 face_recognition安装的灾难现场
这个库的安装是我在整个项目里踩过最深的一个坑。在macOS上装它相对轻松,但在Windows上如果你没装好Visual C++ Build Tools和dlib的编译环境,pip install face_recognition能给你报出一堆红字错误。dlib需要编译Boost和CMake,很多人在这步就直接放弃了。
后来我找到的最省事方案是:用Anaconda装dlib,conda install -c conda-forge dlib会好很多。然后是face_recognition的模型文件,它默认从网上下载dlib的预训练模型(大约60到100MB),国内网络有时候下不动,我建议先把模型文件手动下载好放到项目目录,并设置环境变量指向它。不然上线那天现场下载,几节课都等不起。
5.2 前后端联调的典型错误:坐标丢失和文件上传超时
我在联调阶段遇到了两个非常典型的错误,都是那种"单个模块正常、拼起来就废"的问题。
第一个是定位坐标丢失。有段时间签到接口时报"缺少latitude参数"的频率很高,排查发现小程序端用户点击签到太快,定位还没返回就跳到拍照页,等照片拍完要提交时经度纬度为0。我最后的解决办法是用户在签到页先点"准备签到"按钮,小程序先请求定位,定位成功并显示"定位已锁定,距离教室XX米"后再启用拍照按钮。这样从流程上杜绝了坐标丢失。
第二个是照片上传超时。班级人数多一些之后,同一时间十几个学生签到,Flask默认的开发服务器(Werkzeug)是单线程的,文件上传请求和Face Recognition推理会互相卡住,一个照片推理耗时两三秒,十几个请求排队就是半分钟起步。我临时改用gunicorn启动4个worker,排队问题基本消失。另外前端压缩图片也很关键,如果一张原图3MB直接上传,接口响应就要6到8秒,小程序的超时配置还不一定扛得住。
5.3 Flask生产运行的补充:不要用调试模式对外服务
调试模式(app.run(debug=True))下Flask会带一个交互式调试器,这在本地很舒服,但一旦对外开放简直就是风险敞口。我上线时用的是gunicorn跑Flask,命令大概是:
gunicorn -w 4 -b 0.0.0.0:5000 app:app如果服务器本身配置不高,worker数不要开太多,因为每个worker都会加载一次face_recognition库和模型,内存占用很大。我在2核4G的云服务器上开4个worker,内存直接涨到3.2G,后来改回2个worker才稳定。
5.4 小程序审核相关的心得
如果你的小程序要正式发布,需要过微信的审核。审核团队会重点检查"人脸识别"相关功能的隐私合规,要求你说明收集人脸照片的用途、存储方式、是否加密、是否有删除机制。我在后台加了一个"用户可申请删除人脸数据"的功能,申诉材料里把这个截图放进去,审核通过率会高不少。这里涉及的内容较多,如果只是本地演示用,可以先忽略。
6. 这套系统的后续进阶方向
项目做完一个完整版本之后,我反而觉得它更像是一个"考勤系统骨架",后续扩展空间非常大。比如考勤数据可以接入已有的教务系统做最终成绩联动;人脸识别可以从静态照片升级成摄像头实时流识别,配合活体检测,识别安全性会提升一个档次;定位可以用WiFi指纹或者蓝牙信标(iBeacon)方案替代GPS,解决教学楼深处GPS漂移的问题。
还有一个很实际的方向:把签到页做成"随堂测验"模式,老师签到的时候出一道选择题,学生做完题签到就算完成,这样不光防代签,还能把考勤环节变成教学互动的一部分,这可能才是老师真正想要的。
从最初"想治一治纸质签到表"的小念头,到这个系统完整跑通,中间虽然踩了不少坑,但回过头来看整个架构的实用价值很高。如果你也在做类似的选课签到考勤项目,我的建议是先把"人脸+定位双校验"这条主链路跑通,再做外围功能,不要一上来就想着功能大而全,否则排错和打磨的时间会成倍增加。