简介:一份面向计算机与软件工程专业毕业设计场景的疲劳检测系统文档,适合正在做 Python、Django 与 OpenCV 相关选题,或需要参考论文结构、系统功能划分与疲劳判定思路的学生。资源包仅含 1 个 docx 文件,约 1.01MB,内容为学士学位论文完整稿,包含中英文摘要、绪论、开发背景、国内外研究现状及目录等部分,可据此了解选题背景与写作框架。文档围绕眼动信号与人脸判断展开,借助 OpenCV 检测眼睛闭合程度,并结合面部表情、眨眼频次表征疲劳状态;系统层面采用 Python 与 MySQL,涉及图像识别、图片分析、照片管理等功能模块,对数据库设计与平台管理表述也有参考价值。已有 366 人学习,可作为毕业设计开题、论文撰写和技术路线梳理的对照材料。
1. 需求先立住
做疲劳检测毕设的人,十个里有八个一上来就想训练 CNN 判闭眼,数据集没凑齐、显卡跑不动、答辩现场一演示就卡成幻灯片。真正能交上去、现场还能跑通的版本,往往是最朴素的那套:Python 起一个 Django 服务,浏览器调摄像头拍一张脸,OpenCV 在后台切出人脸和人眼区域,算出闭眼占比和哈欠次数,落一条记录进 MySQL,前台用柱状图把结果摆出来。这套基于 Python + Django + OpenCV 的疲劳检测系统源码与数据库设计,要解决的就是检测能跑、页面能演示、数据能复盘这三件事,适合正在做毕业设计、课程设计,或者要给车队、产线做一套低成本岗前状态筛查的读者参考。
2. 需求拆解与模块边界:六个页面背后的数据流
2.1 每个页面到底承担什么职责
这套系统的功能清单看着长,摊开其实就六个页面,而且它们不是并列关系,是围绕一张照片信息表长出来的。登录页负责身份,用户名、密码加验证码三重校验,为的是让每次检测结果能挂到具体人头上;首页是统计看板,展示系统总用户数、总图片数、当前年月,再配一张用户 24 小时行为图表,让人一眼看到"这个账号最近什么时候在测";图片识别页是入口,支持摄像头实时拍照和本地图片上传两条路;照片分析页把单次或多次检测的眼睛状态、睁眼情况、打哈欠占比用柱状图和饼图摊开,供用户复核;照片管理页是全集列表,能按条件搜索,也能删掉拍糊的、识别失败的脏数据;用户管理页处理改密码、改资料和新增用户,新增只要姓名、密码、手机号三个字段。
理解这层关系很重要,因为它决定了后面所有代码的走向。一次检测动作的产物只有一条 PhotoInfo 记录,首页的统计是对这张表做 count,分析页是对这张表做 group by,管理页是对这张表做分页查询。很多同学把功能模块写成六个互不相干的 Controller,每个里面各写一套查库逻辑,改一个字段要动六处,这就是没想清楚数据流的代价。
从交付角度看,这六个页面的优先级也不是平的。图片识别和照片管理是核心链路,必须一次做对;首页统计和分析图表属于加分项,做得好答辩时很出彩,做不完也不影响主流程;登录和用户管理是底座,但可以直接借用 Django 自带的能力,省下来的时间全部砸到识别准确率上。我一般建议学生按这个顺序推进:先跑通"上传一张图 → 返回一个结果"的最小闭环,再补列表和删除,最后才是统计图表。
2.2 技术选型:为什么是 Python + Django + OpenCV + MySQL
技术栈是题目给定的,但答辩时老师一定会问"为什么不用别的",所以选型理由要提前想明白。Python 在这套系统里干两件事:一是驱动 OpenCV 做图像运算,二是作为 Django 的开发语言。它的优势在于图像处理生态完整,numpy 数组和 OpenCV 的 Mat 之间转换几乎是零成本,写一个检测函数几十行就能收工。
Web 框架选 Django 而不是 Flask,核心理由是"毕设周期内能少写代码"。Django 自带 ORM、自带用户认证、自带 admin 后台,用户管理页里"新增用户"这个功能,用 admin 稍微配置一下就能用,省下的时间足够你把检测算法调三轮。ORM 还有个隐性好处:数据库表结构由模型类定义,改字段的时候跑一条 migrate 命令就同步了,不用手写 ALTER TABLE,也不会出现论文里写的字段名和代码里对不上的翻车现场。
OpenCV 承担检测,重点是它不需要 GPU、不需要预训练大模型,Haar 级联分类器在普通笔记本上单帧处理能压到几十毫秒。这一点对答辩极其关键——你永远不知道答辩教室的电脑是什么配置,能不能联网,有没有独立显卡。用 Haar 至少保证最坏情况下也能跑起来,而深度学习方案一旦环境出问题,现场就是黑匣子,解释不清。
MySQL 存业务数据,图片文件本身落磁盘,表里只存文件名和判定结果。这个拆分要写进论文,因为很多人会把图片二进制塞进数据库,几万张图之后查询直接拖垮,这是典型的选型错误。
2.3 数据库概念结构与照片信息表
概念结构用 E-R 图描述:用户实体和照片实体是一对多关系,一个用户可以上传多张照片,一张照片只属于一个用户;照片实体的属性包括照片 ID、图片名称、眼睛状态、是否打哈欠、是否疲劳、创建时间。这套结构简单到几乎没有设计空间,但恰恰因为简单,字段类型的选择就更容易出错。
原始设计里的照片信息表是这样定的:picid 为 varchar(32) 主键,picname 为 varchar(50),eyes 为 varchar(50),haqian 为 varchar(2),pilaov 为 varchar(2),addtime 为 varchar(20),除 picid 外均不可为空。
| 字段含义 | 字段名 | 类型 | 长度 | 可空 | 备注 |
|---|---|---|---|---|---|
| 照片 ID(主键) | picid | varchar | 32 | 是 | UUID 去横线,32 位 |
| 图片名称 | picname | varchar | 50 | 否 | 存原始文件名 |
| 眼睛状态 | eyes | varchar | 50 | 否 | 睁眼 / 闭眼 / 未检出 |
| 是否打哈欠 | haqian | varchar | 2 | 否 | 是 / 否 |
| 是否疲劳 | pilaov | varchar | 2 | 否 | 是 / 否 |
| 创建时间 | addtime | varchar | 20 | 否 | yyyy-MM-dd HH:mm:ss |
这张表能跑,但有两处我一般会动手改。第一处是 haqian 和 pilaov 用 varchar(2) 存"是/否",中文在不同字符集下占的字节数不一样,某些环境里 varchar(2) 只够存一个汉字,插入直接报错。稳妥做法是改成 char(1) 存 '1'/'0',或者干脆用 tinyint,前后端约定好 1 表示是、0 表示否,判断逻辑还更快。第二处是 addtime 用 varchar 存时间,排序和范围查询都会退化成字符串比较,"2024-9-1" 和 "2024-10-1" 按字典序排会出错。改成 datetime 类型,Django 里用 DateTimeField(auto_now_add=True),首页那个"当前年月"统计和 24 小时行为图表的查询会顺手很多。
提示:论文里写的是 varchar,代码里改了类型,一定要在论文的数据库设计章节同步更新,答辩老师翻论文对代码是常规操作。
3. 检测算法设计:从人脸框到疲劳判定的三级指标
3.1 人脸与人眼定位:Haar 级联的参数怎么调
整条检测链路的第一层是人脸。OpenCV 自带 Haar 级联分类器,加载 xml 模型文件之后调 detectMultiScale 就能拿到人脸矩形框,代码量极小:
import cv2 # 加载 OpenCV 自带的级联模型,路径用 cv2.data.haarcascades 拿,避免硬编码 face_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + 'haarcascade_frontalface_default.xml') eye_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + 'haarcascade_eye.xml') def detect_face_and_eyes(frame): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 直方图均衡化,逆光或室内灯光偏暗时能明显提升检出率 gray = cv2.equalizeHist(gray) faces = face_cascade.detectMultiScale( gray, scaleFactor=1.1, # 每次缩放 10%,越小越慢但越不容易漏 minNeighbors=5, # 一个候选框至少被复核 5 次才留下,抑制误检 minSize=(80, 80) # 小于 80x80 的人脸直接丢弃,过滤远处背景 ) if len(faces) == 0: return None, [] # 只取面积最大的人脸,多人同框时以主体为准 x, y, w, h = max(faces, key=lambda f: f[2] * f[3]) # 人眼几乎不会出现在人脸框的下半部分,只在上半区搜索 roi_gray = gray[y:y + h // 2, x:x + w] eyes = eye_cascade.detectMultiScale(roi_gray, 1.1, 4, minSize=(20, 20)) return (x, y, w, h), eyes这三个参数是踩坑重灾区。scaleFactor 设成 1.1 意味着图像金字塔每次缩小 10%,一张 640×480 的图大概扫十几层,耗时可控;调成 1.05 检出率会上去一点,但耗时接近翻倍,如果是视频流实时检测,掉帧会很明显。minNeighbors 是误检的闸门,设成 2 或 3 的时候,墙上的插座、衣服上的花纹都可能被认成人脸;设到 8 以上又容易漏检侧脸。minSize 这一项最容易被忽略,不设的话远处的路人脸也会被框进来,导致程序在多个检测结果之间来回跳。
人眼定位这里有个必须提前讲清楚的事实:Haar 的眼睛分类器是用睁眼样本训练的,闭眼时大概率检测不到眼睛。所以"检测不到眼睛"这件事本身就携带信息,含混地把它当作失败来处理,是后面判定逻辑出错的主要源头。我的做法是把人脸框上半区裁出来,先跑一次眼睛检测,检到了说明睁眼,再对这个区域做灰度阈值二值化,用暗像素占比做二次确认。
3.2 闭眼判定与 PERCLOS:单帧不可信,要看窗口
单帧判闭眼的可靠性有限,眨眼那 100 到 300 毫秒里任何一次抓拍都可能抓到闭眼,按帧判断会满屏疲劳。行业里通用的做法是 PERCLOS,即统计一个时间窗口内闭眼帧所占的比例,而不是看某一帧。这套系统的检测对象是一张上传的静态照片,没法直接算时间窗口,所以我一般做两步处理:单张图给出瞬时状态,多张图按 addtime 排序后回溯 30 秒或 60 秒算占比。
先看单帧的眼睛状态判定,思路是裁出人眼区域后统计暗像素比例:
import numpy as np def eye_state(eye_roi): """返回 'open' / 'close' / 'unknown',输入是人眼小块彩色图""" if eye_roi is None or eye_roi.size == 0: return 'unknown' gray = cv2.cvtColor(eye_roi, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (5, 5), 0) # 反向二值化:瞳孔和睫毛变白,眼白变黑 _, thresh = cv2.threshold(gray, 60, 255, cv2.THRESH_BINARY_INV) dark_ratio = np.count_nonzero(thresh) / thresh.size # 经验阈值:闭眼时睫毛和眼睑占满,暗像素比例明显升高 if dark_ratio > 0.42: return 'close' elif dark_ratio > 0.08: return 'open' return 'unknown'0.42 和 0.08 这两个数不是拍脑袋来的,是拿几十张自己拍的样本调出来的:睁眼时暗像素集中在瞳孔和睫毛,比例通常在 0.1 到 0.3 之间;闭眼时整条眼缝都是暗的,比例会冲到 0.5 以上。不同人种、是否戴眼镜、光照强弱都会让这两个阈值偏移,戴黑框眼镜的同学尤其明显,眼镜框本身就会被算进暗像素,导致睁眼也被判成闭眼。所以这两个数在论文里要写成"经验阈值,可通过配置文件调整",别写成定论。
有了单帧状态,多帧融合就有了基础。窗口逻辑是:取当前时间往前 30 秒内的全部检测记录,统计 eyes 字段为"闭眼"的条数占总条数的比例,这个比例就是简化版 PERCLOS。正常清醒状态下这个值一般在 0.15 以下,超过 0.3 就该提示,超过 0.4 属于明显疲劳。这个指标比"是否疲劳"这个布尔值有说服力得多,答辩时讲清楚它的来源,比干巴巴说一句"准确率 90%"有力。
3.3 打哈欠检测与三路指标的融合打分
打哈欠靠嘴部区域判断。OpenCV 没有现成的嘴部分类器(haarcascade_smile 只能识别微笑,且对张嘴无效),所以只能自己做:从人脸框下半区裁出嘴巴 ROI,二值化后找最大轮廓,算轮廓的外接矩形高宽比,比例明显大于常态就是张嘴。
def mouth_state(face_roi): """输入人脸下半区,返回 'yawn' / 'normal' / 'unknown'""" gray = cv2.cvtColor(face_roi, cv2.COLOR_BGR2GRAY) _, thresh = cv2.threshold(gray, 50, 255, cv2.THRESH_BINARY_INV) contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return 'unknown' c = max(contours, key=cv2.contourArea) _, _, w, h = cv2.boundingRect(c) if w == 0: return 'unknown' ratio = h / float(w) # 闭嘴时高宽比接近 0.2~0.4,打哈欠张嘴会拉到 0.6 以上 return 'yawn' if ratio > 0.6 else 'normal'三路指标(闭眼占比、眨眼频次异常、哈欠次数)各自独立,最后要合成一个"是否疲劳"的结论写进 pilaov 字段。最省事的做法是加权求和:
| 指标 | 计算方式 | 权重 | 触发阈值 |
|---|---|---|---|
| 闭眼占比 PERCLOS | 30 秒窗口内闭眼条数 / 总条数 | 0.5 | > 0.30 |
| 眨眼频次异常 | 60 秒内闭眼次数 < 10 或 > 30 | 0.2 | 越界记 1 分 |
| 打哈欠次数 | 60 秒内哈欠条数 | 0.3 | ≥ 2 次记 1 分 |
总分超过 0.6 判为疲劳。权重的分配逻辑是:PERCLOS 是学术界公认最可靠的单一指标,所以给一半权重;哈欠是强信号但发生频率低,给 0.3;眨眼频次波动大、个体差异明显,只给 0.2 做辅助修正。这套打分表建议直接写进论文第三章,比只写"结合多项指标判断"要实在得多。
需要提醒的是,这套算法在单张静态图上只能给出"当前帧是不是闭眼/张嘴",真正的疲劳判定依赖历史记录的窗口统计。所以第一次使用系统的用户,头几条记录一定是"不疲劳",这是正常的冷启动,别在验收时把这条当成 bug 报上去。我的处理方式是在分析页加一句说明,告诉用户至少上传 5 张不同时刻的照片才有参考价值。
4. Django 后端落地:模型、视图与识别链路
4.1 数据模型与表结构映射
Django 的 ORM 把数据库表定义搬到 Python 类里,模型类写完之后 makemigrations 和 migrate 两条命令下去,表就建好了。按照第 2 章的字段调整建议,模型定义如下:
# models.py from django.db import models class PhotoInfo(models.Model): # 主键用 UUID,避免自增 ID 在前端暴露业务量,也方便分布式扩展 picid = models.CharField('照片ID', max_length=32, primary_key=True) picname = models.CharField('图片名称', max_length=50) eyes = models.CharField('眼睛状态', max_length=10, default='unknown') # 是否打哈欠 / 是否疲劳,1=是 0=否,比存中文稳 haqian = models.SmallIntegerField('是否打哈欠', default=0) pilaov = models.SmallIntegerField('是否疲劳', default=0) # 用 DateTimeField,范围查询和排序都正常走索引 addtime = models.DateTimeField('创建时间', auto_now_add=True) # 关联用户,方便首页做"当前用户行为图表" owner = models.ForeignKey('auth.User', on_delete=models.CASCADE) class Meta: db_table = 'photo_info' ordering = ['-addtime'] # 默认按时间倒序,列表页直接受益几个设计点值得说明。picid 用 CharField 而不是 AutoField,是为了在文件落盘时就确定唯一文件名,先有 ID 再存文件,避免"文件存了但数据库插入失败"产生的孤儿文件。owner 外键指向 Django 内置的 auth.User,用户管理页的增删改可以直接复用 admin,登录态也不用自己维护 session。addtime 用 auto_now_add,插入时自动填当前时间,前端不用传,也不存在时区格式不统一的问题。
照片管理页的搜索功能,本质上就是在这个模型上叠加查询条件。眼睛状态、是否疲劳、时间范围三个条件组合,用 Django 的 Q 对象拼一下就行,不需要写原生 SQL。分页用 Paginator,每页 10 条,和原始设计里的列表页对得上。
4.2 上传识别视图:一条请求走完五步
图片识别的视图是整个系统最核心的一段代码,它要把"收文件、存磁盘、读图像、跑检测、写数据库"五件事串起来,中间任何一步出错都要给前端明确的反馈。
# views.py import os, uuid import cv2 import numpy as np from django.conf import settings from django.http import JsonResponse from django.shortcuts import render from .models import PhotoInfo from .detector import analyze_frame # 封装第 3 章的检测逻辑 def photo_detect(request): if request.method == 'GET': return render(request, 'detect.html') file = request.FILES.get('image') if not file: return JsonResponse({'code': 400, 'msg': '没有拿到图片'}) # 第一步:生成 UUID 做文件名,避开中文名和重名问题 picid = uuid.uuid4().hex ext = os.path.splitext(file.name)[1].lower() or '.jpg' save_path = os.path.join(settings.MEDIA_ROOT, 'photos', picid + ext) # 第二步:分块写盘,避免大图一次性读进内存 with open(save_path, 'wb+') as f: for chunk in file.chunks(): f.write(chunk) # 第三步:用 imdecode 读盘,cv2.imread 在中文路径下会返回 None img = cv2.imdecode(np.fromfile(save_path, dtype=np.uint8), cv2.IMREAD_COLOR) if img is None: return JsonResponse({'code': 415, 'msg': '图片格式不支持'}) # 第四步:跑检测,analyze_frame 内部含人脸过滤和三级指标计算 result = analyze_frame(img) # 第五步:入库,字段和前端返回保持一致 PhotoInfo.objects.create( picid=picid, picname=file.name, eyes=result['eyes'], haqian=result['haqian'], pilaov=result['pilaov'], owner=request.user ) return JsonResponse({'code': 200, 'data': result})第三步那个中文路径的坑必须单独说,cv2.imread 底层是 C++ 实现,遇到中文路径直接返回 None,报错信息还只有一句"image is None",很多人对着这段代码查半天。np.fromfile 先把文件读成字节流,再让 imdecode 解码,就绕开了文件系统编码的问题,这一行改动能省掉一整晚的排查时间。
返回值统一用 {code, msg, data} 三段式,前端只看 code 决定走成功还是失败分支,data 里放 eyes、haqian、pilaov 三个字段,页面拿到之后直接渲染结果卡片。这种约定在前后端分离的项目里是标配,但在毕设里常被忽略,很多同学的做法是后端直接 return render 一个拼好的 HTML,结果前端想加个 loading 动画都加不进去。
摄像头拍照走的是另一条路:前端用 getUserMedia 拿到视频流,canvas 截帧转 base64,POST 到同一个接口,后端把 base64 解成 ndarray 之后复用 analyze_frame。前端这段逻辑浏览器兼容性差异挺大,Safari 对 getUserMedia 要求 HTTPS 或 localhost,答辩现场如果是用局域网 IP 访问,摄像头会直接调不起来。稳妥办法是提前在本地起服务演示,或者准备几张图片做备用。
4.3 照片分析与统计接口:图表数据从哪来
照片分析页要出柱状图和饼图,数据不可能是前端硬编码的,得由后端按条件聚合。Django 的 annotate 加 Count 能直接生成 group by 查询,不需要手写 SQL:
# views.py from django.db.models import Count from django.utils import timezone from datetime import timedelta def analysis_data(request): # 默认看最近 7 天,前端传 days 参数可以改 days = int(request.GET.get('days', 7)) start = timezone.now() - timedelta(days=days) qs = PhotoInfo.objects.filter(owner=request.user, addtime__gte=start) # 疲劳占比:饼图数据 fatigue_dist = list(qs.values('pilaov').annotate(cnt=Count('picid'))) # 按天统计检测次数和疲劳次数:柱状图数据 daily = (qs.extra(select={'day': 'date(addtime)'}) .values('day') .annotate(total=Count('picid'), fatigue=Count('picid', filter=models.Q(pilaov=1))) .order_by('day')) return JsonResponse({ 'code': 200, 'data': {'fatigue_dist': fatigue_dist, 'daily': list(daily)} })这里有一个设计上的取舍值得写进论文:按天聚合用的是数据库的 date() 函数,意味着这条查询绑定了 MySQL,换成别的数据库要改写法。如果追求跨库兼容,可以在 Python 层把查询结果取出来再用 itertools.groupby 分组,代价是多查一批数据到内存。毕设场景下数据量不会大,两种都行,但要在文档里写清楚选了哪种、为什么。
前端的图表建议用 ECharts,配置项少、中文文档全,饼图和柱状图各二三十行配置就能出效果。数据接口返回的 fatigue_dist 是 [{pilaov: 1, cnt: 8}, {pilaov: 0, cnt: 22}] 这种结构,前端做个映射把 1 显示成"疲劳"、0 显示成"正常"就行。首页那个"用户 24 小时行为图表"同理,把 addtime 按小时分组统计即可,SQL 层用 HOUR(addtime) 提取小时数。
另外提醒一点,统计接口一定要带 owner 过滤。原始设计里的首页统计是"系统总用户数、总图片数",那是管理员视角;普通用户登录后看到的应该是自己的数据。这两套口径在代码里是同一个视图加个 if 判断,但如果忘了加,A 用户能看到 B 用户的检测记录,答辩时被问到数据隔离就不好回答了。
5. 常见问题排查:四类最容易翻车的现场
5.1 摄像头能打开,但人脸框一直不出现
现象是页面视频流正常,点了拍照也返回成功,但结果永远是"未检出人脸"。原因通常有三个层次:最表层是光照,逆光或者室内只有一盏顶灯的时候,人脸整体偏暗,equalizeHist 拉不回来;中间层是分辨率,很多笔记本前置摄像头默认输出 1280×720,缩放到检测尺寸时人脸只剩几十像素,低于 minSize 直接被过滤;最深层是分类器选错,用了 haarcascade_frontalface_alt 这种对正脸要求极高的模型,稍微侧一点就检不到。
解决顺序是从便宜到贵:先在拍照前加一步亮度检测,画面平均灰度低于 60 就提示"光线不足,请面向光源";然后把 minSize 从 (80, 80) 降到 (60, 60),同时把输入图缩放到宽 640 再做检测;最后换回默认的 haarcascade_frontalface_default,它对侧脸的容忍度比 alt 版本好。如果三步做完还是检不到,那就是摄像头本身的问题,换一台机器验证,别在代码里继续折腾。
5.2 白天测得挺准,晚上全是"疲劳"
现象很典型:下午演示一切正常,晚上或者拉上窗帘再测,十条记录里八条判疲劳。原因是第 3 章那两个暗像素阈值在低照度下彻底失效,环境一暗,整张脸都是暗的,二值化之后人眼区域几乎全白,dark_ratio 轻松超过 0.42,稳定被判成闭眼。
解决要分两层。算法层加一步自适应阈值,用 cv2.adaptiveThreshold 替代固定阈值 60,让阈值跟着局部亮度走;业务层加一个前置判断,计算整张人脸区域的平均灰度,低于 50 就直接返回"图像质量不足,请改善光照后重试",而不是硬着头皮给一个错误结论。后者更重要,宁可让系统说"测不了",也别让它自信地给出错误答案,这在答辩演示时是保命设计。
5.3 上传大图后页面转圈,最后 500 错误
现象是手机拍的 4000×3000 照片一上传,请求卡住十几秒,然后报服务器内部错误。原因是原图直接送进 detectMultiScale,图像金字塔要扫十几层,每层都是千万像素级的卷积运算,内存和 CPU 同时顶到天花板,再加上 Django 默认的上传内存限制,请求体超过阈值就被拒。
解决分三步:上传前在前端用 canvas 压缩到长边 1280,这一步能砍掉 90% 的数据量;后端在 analyze_frame 入口统一 resize 到宽 640,检测完如果需要展示原图,再按比例把坐标映射回去;Django 配置里把 FILE_UPLOAD_MAX_MEMORY_SIZE 调到 10MB,DATA_UPLOAD_MAX_MEMORY_SIZE 同步调整。三步里前端压缩收益最大,而且用户几乎感知不到画质损失,因为检测只用得到人脸那几百个像素。
5.4 统计图和照片列表对不上数
现象是照片管理页显示 30 条记录,分析页的饼图加起来只有 25 条。原因一般是统计接口用了额外的过滤条件,比如只统计最近 7 天,而列表页是全量;或者统计时用了 distinct 去重,而 picid 在某些异常情况下重复了。还有一种更隐蔽的情况:删除照片时只删了数据库记录,磁盘文件还在,或者反过来,导致两边口径不一致。
解决办法是把"统计口径"写成一个常量,放在配置里,列表页和统计页都引用同一个时间范围定义;删除操作封装成一个事务,先删数据库记录再删文件,文件删除失败只记日志不回滚,避免因为磁盘权限问题导致数据库记录删不掉。验收的时候拿同一时间段的数据两边对一遍,数字对得上才算过。
6. 验收与答辩:用例怎么造,指标怎么讲
系统测试这一章最容易被写成流水账,"点击登录按钮,登录成功,测试通过"这种写法老师看三行就翻页了。真正有说服力的做法是把测试拆成功能测试和可用性测试两条线,每条线都有明确的输入、预期和判定标准。
功能测试围绕核心链路造用例,重点是异常路径而不是正常路径。正常路径谁都能跑通,异常路径才体现你想没想过边界。建议至少准备下面这张表里的六条,每条都实际跑一遍并截图存档:
| 用例编号 | 测试场景 | 输入 | 预期结果 | 判定 |
|---|---|---|---|---|
| F-01 | 正常登录 | 正确用户名 + 密码 + 验证码 | 跳转首页,显示当前用户 | 通过 |
| F-02 | 验证码错误 | 正确账号密码 + 错误验证码 | 停留登录页并提示 | 通过 |
| F-03 | 上传清晰正脸照 | 640×480 室内光照片 | 返回眼睛状态,记录入库 | 通过 |
| F-04 | 上传无人脸图 | 风景照 | 返回"未检出人脸",不写库 | 通过 |
| F-05 | 上传超大图 | 4000×3000 照片 | 自动压缩后正常识别 | 通过 |
| F-06 | 跨用户访问 | A 用户请求 B 的记录 ID | 返回无权限,不泄露数据 | 通过 |
F-04 和 F-06 这两条是加分项。前者证明你考虑了误输入,后者证明你想过数据隔离,很多毕设系统在这两点上是空的。
可用性测试量化页面响应,别写"运行流畅"这种形容词。用浏览器开发者工具的网络面板,记录登录、上传识别、列表加载三个动作的耗时,各测五次取平均。一般标准是:页面跳转 1 秒内,上传识别 3 秒内(含网络传输),列表查询 500 毫秒内。达不到就去看慢在哪,是图像处理耗时还是数据库查询没走索引。addtime 字段如果按第 2 章的建议改成了 DateTimeField,记得在模型里加 db_index=True,否则按时间倒序的列表页在数据量上千之后会明显变慢。
答辩环节真正拉开差距的是演示策略,不是算法多先进。我的建议是准备三组对照素材:一组清醒状态的正脸照、一组明显闭眼的照片、一组打哈欠的照片,每组三到五张,提前跑一遍确认结果符合预期。现场演示时先走图片上传这条稳定路径,把三组结果依次展示,让老师看到判定确实有区分度;摄像头实时检测放在最后,作为"还支持实时采集"的补充,即使现场光线不理想也不影响主体印象。
被问到准确率时,不要张口就说 95%。更稳妥的讲法是拆开说:人脸检出率在正常光照下接近百分之百,闭眼判定的单帧准确率受光照和眼镜影响,在自建的小样本集上大约八成,疲劳结论依赖 30 秒窗口的 PERCLOS 统计,样本量越大越稳。然后主动说出局限——没做多人脸跟踪、没处理戴墨镜的情况、冷启动阶段结论不可靠。主动暴露边界的回答,比一个漂亮但站不住的数字可信得多。
还有一个细节值得提前准备:老师很可能会问"为什么不用深度学习"。标准答案是成本与可解释性——Haar 加规则的方式,每个判定都能指到具体的阈值和像素比例,出问题能调;神经网络是个黑匣子,答辩现场一旦判错,你解释不了它为什么错。这个回答既真实又显得你想过取舍。
从那以后我每次带人做这类检测系统,都强制在验收前跑一遍那张异常用例表,尤其是"上传无人脸图"和"跨用户访问"这两条,十次里有六次能揪出问题。系统的可信度不来自它多聪明,来自它在明显该失败的时候确实失败了。希望这套结构和这些踩过的坑,能帮到你。
本文还有配套的精品资源,点击获取