基于Python+Django+OpenCV的疲劳检测系统:从EAR到Web落地
简介:面向计算机视觉与毕业设计场景的疲劳检测系统文档,围绕 Python、Django 与 OpenCV 展开眼睛闭合程度、眨眼频次及面部表情的检测思路,给出图像识别、图片分析、照片管理等平台模块的设计与实现。资源包仅含 1 个 docx 文件,大小约 1.01MB,内容以学位论文式文档为主,涵盖摘要、绪论、国内外研究现状、系统设计、数据库应用与结论等章节,并涉及疲劳驾驶预警背景、连续驾驶四小时强制休息等应用依据,适合需要参考完整论文结构、梳理 OpenCV 人脸检测流程或准备答辩材料的本科与高职学生。已有 366 人学习,读者可从中获取疲劳检测的算法逻辑、Python+Django 平台开发脉络、MySQL 数据存储方案以及论文写作框架,便于对照课题完成需求分析、模块划分与文档撰写。
1. 一条眼缝里的浮点数:疲劳检测系统为什么先算眼睛
一张 640×480 的摄像头画面里,真正决定「困不困」的信息可能只有几十个像素——上下眼睑之间那条缝。基于 Python + Django + OpenCV 的疲劳检测系统,做的全部事情就是把这个缝的宽度变成一个浮点数,再用规则判断它够不够宽、窄了多久。反直觉的地方在于:系统并不去「理解」疲劳,而是把疲劳拆成眨眼频次、闭眼时长、哈欠比例这些可量化的代理指标,再交给阈值去裁决。搞懂这条从像素到判定的链路,你就同时搞懂了人脸关键点回归的坐标语义、图像预处理的因果顺序,以及 Web 框架如何把一个耗时的视觉计算塞进请求响应周期里——这也是很多人做毕业设计时最想抄却抄不明白的部分。
2. EAR 与 MAR:把「困」翻译成可计算的标量
疲劳本身不可测,可测的是它的外在痕迹。人困的时候,眼睑闭合时间会从正常的 0.1~0.4 秒拉长到 1 秒以上,眨眼频次先升后降,嘴巴张开的角度变大且持续时间变长,头部还会不自觉地往下点。这四类痕迹里,眼睛的性价比最高:采样帧率 15fps 就够用,不需要额外硬件,普通摄像头直接可采。所以整套判定的第一步,是建立「眼睛睁开程度」的数值表示。
早期做法是直接对眼部 ROI 做二值化和轮廓分析,数白色像素占比。这条路能跑,但极其依赖光照和阈值,稍微逆个光就翻车。后来主流方案换成了人脸关键点回归,把眼睛抽象成 6 个有序坐标点,用几何比例代替像素统计,抗光照能力立刻上了一个台阶。
2.1 68 个关键点如何落到眼睛的六个坐标上
关键点模型输出的是一张人脸 68 个点的坐标表,索引是有约定俗成含义的:0~16 是下颌轮廓,17~26 是眉毛,27~35 是鼻梁与鼻头,36~41 是左眼,42~47 是右眼,48~67 是嘴部。这个编号顺序是整个算法的基础,写错一个索引,后面所有数字都是错的。
每只眼睛的 6 个点按顺序是:外眼角、上眼睑外侧、上眼睑内侧、内眼角、下眼睑内侧、下眼睑外侧。把它们连起来,本质上是在近似一个多边形,而「睁眼程度」被定义成这个多边形在垂直方向上的高度与水平方向宽度的比值,也就是眼睛纵横比 EAR(Eye Aspect Ratio)。
import numpy as np # 左眼 36-41,右眼 42-47,顺序固定,改顺序会导致 EAR 完全失真 LEFT_EYE_IDX = list(range(36, 42)) RIGHT_EYE_IDX = list(range(42, 48)) def eye_aspect_ratio(eye_points): """ eye_points: shape=(6, 2) 的 numpy 数组,顺序为 [外眼角, 上外, 上内, 内眼角, 下内, 下外] """ # 垂直方向两组距离:p2-p6 与 p3-p5 vertical_a = np.linalg.norm(eye_points[1] - eye_points[5]) vertical_b = np.linalg.norm(eye_points[2] - eye_points[4]) # 水平方向距离:p1-p4,作为归一化分母 horizontal = np.linalg.norm(eye_points[0] - eye_points[3]) if horizontal < 1e-6: # 极端侧脸时两眼角可能重合,必须防除零 return 0.0 return (vertical_a + vertical_b) / (2.0 * horizontal) def frame_ear(landmarks): left = np.array([landmarks[i] for i in LEFT_EYE_IDX], dtype=np.float64) right = np.array([landmarks[i] for i in RIGHT_EYE_IDX], dtype=np.float64) # 双眼取平均,单眼被遮挡时不至于直接判死 return (eye_aspect_ratio(left) + eye_aspect_ratio(right)) / 2.0三个参数值得说清楚。eye_points必须是浮点坐标而不是整数像素,因为关键点回归的输出本身就是亚像素精度的浮点数,过早取整会引入 1~2 像素的抖动,在 EAR 这种小数值上会被放大成 10% 的误差。分母horizontal的作用是归一化:人脸离镜头远近会让眼睛整体变大变小,但比值不变,这是 EAR 能跨距离使用的根本原因。最后那个除零保护不是多余的,侧脸或关键点漂移时两眼角坐标确实可能近乎重合,没有这一行,后台会直接抛异常。
双眼取平均是个工程妥协。单眼方案在戴单边眼罩、侧头、局部遮挡时容易误判,双眼平均相当于做了一次空间上的冗余校验,代价是当只有一只眼闭上(比如眨眼玩笑或者面瘫)时,EAR 会被拉高,闭眼判定变迟钝。如果你追求更严格的判定,可以改成「任一眼 EAR 低于阈值即计数」,但误报率会上升,这就是典型的灵敏度与特异度取舍。
2.2 阈值不是玄学:EAR 基线、闭眼帧数与时间窗
拿到 EAR 之后,最容易被误用的做法是直接写死EAR < 0.2就判定闭眼。这个数字来自公开实验的统计均值,落到个体身上差异很大:眼睛本身偏小的人睁眼时 EAR 可能只有 0.22,眼睛大的人能到 0.35,一个固定阈值会把前者全天判成疲劳。
常见的实现思路是动态基线。程序启动后的前若干帧只做一件事——采集当前被测者睁眼状态下的 EAR 中位数,记为baseline,之后的判定阈值取baseline * 0.75左右。用中位数而不是均值,是因为前几帧里总会混进一两次眨眼,均值会被拉低。
class FatigueJudge: def __init__(self, fps=15, ear_ratio=0.75, close_seconds=2.0): self.fps = fps self.ratio = ear_ratio # 阈值相对基线的比例 self.close_frames = int(fps * close_seconds) self.baseline = None self.calib_buf = [] self.closed_counter = 0 # 连续闭眼帧计数 self.blink_total = 0 # 累计眨眼次数 self.microsleep = 0 # 累计长闭眼(微睡眠)次数 def update(self, ear): # 前 60 帧做基线标定,期间不做任何判定 if self.baseline is None: self.calib_buf.append(ear) if len(self.calib_buf) >= 60: self.baseline = float(np.median(self.calib_buf)) return None threshold = self.baseline * self.ratio if ear < threshold: self.closed_counter += 1 else: # 闭眼结束:短闭眼算眨眼,长闭眼算微睡眠 if 0 < self.closed_counter < self.close_frames: self.blink_total += 1 elif self.closed_counter >= self.close_frames: self.microsleep += 1 self.closed_counter = 0 # 超过阈值时长仍未睁眼,直接判定为当前帧疲劳 return self.closed_counter >= self.close_framesear_ratio=0.75不是一个神圣的数字,它的含义是「睁眼高度下降四分之一以上就算闭眼」。调小到 0.6,系统会更迟钝,漏检变多;调到 0.85,普通眨眼也会被当成闭眼,误报飙升。close_seconds=2.0则是把帧数换算成真实时间的桥梁——注意这里必须用实际帧率,而不是你以为的帧率。摄像头在低端机器上标称 30fps 实际跑 12fps 是常事,用错帧率会让「2 秒」变成「5 秒」,判定逻辑彻底失真。这是血泪经验里最常见的一条。
比瞬时 EAR 更稳的指标是 PERCLOS,即单位时间窗口内闭眼帧所占的比例,工程上常取 30 秒或 60 秒窗口,比例超过 0.3~0.4 判定为疲劳。它的好处是把「偶尔一次长闭眼」和「持续性地频繁闭眼」区分开了,代价是需要维护一个滑动窗口队列,内存和计算都要多一点。
2.3 打哈欠与点头:单指标不够,就做加权融合
只靠眼睛会漏掉一类情况:人明明睁着眼,但已经开始犯困,表现为频繁打哈欠和头部下垂。所以完整的判定通常再加两路信号。
打哈欠用 MAR(Mouth Aspect Ratio),思路和 EAR 同构,只是换成嘴部的 48~67 号点:取上下唇内侧的垂直距离除以左右嘴角的水平距离。张嘴说话时这个值也会变大,区别在于持续时间——说话时口型快速变化,打哈欠能维持 1.5 秒以上。所以判据必须是「MAR 超阈值且持续超过 N 帧」,只看单帧必然误报。
点头用头部姿态角。拿 68 个点里的鼻尖、下巴、左右眼角这类稳定点,配合一个通用三维人脸模型做cv2.solvePnP,解出旋转向量再转欧拉角,取 pitch(俯仰角)向下超过 15 度且持续 1 秒以上,作为点头判据。这一路的计算量比 EAR 大一个数量级,很多轻量实现干脆跳过,只用眼睛加嘴巴两路。
融合方式是加权打分:给闭眼时长、眨眼频次、哈欠次数、点头次数各配一个权重和归一化系数,加总成一个 0~1 的疲劳分,再映射成「正常 / 轻度疲劳 / 重度疲劳」三档。权重没有标准答案,通常按各路信号的可信度分配,闭眼时长给最高。下面这张表是常见的起步配置,实际部署时需要用真实样本回调。
提示:权重和阈值都属于「超参数」,不要指望一次调对。正确做法是固定一段测试视频,反复回放,观察哪一路先误报,再单独调那一路。
3. OpenCV 预处理链:顺序错了,后面全错
关键点回归不是凭空工作的,它吃的是预处理之后的图像。这一步在论文里往往被一句「通过 OpenCV 进行图像预处理」带过,但它恰恰是准确率波动的最大来源。同一套 EAR 算法,在不同预处理下表现能差出一倍。
3.1 从 BGR 到灰度:丢掉颜色换来的是什么
摄像头读进来的是三通道 BGR 图像,OpenCV 的默认通道顺序就是 BGR 而不是 RGB,这一点坑过无数人。检测器只关心纹理和梯度,颜色对它没有信息增益,反而让每帧的计算量变成三倍。转灰度是这个流程里性价比最高的一步:
import cv2 def preprocess(frame, use_clahe=True): # 1) BGR -> GRAY,OpenCV 默认通道序是 BGR,不是 RGB gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 2) 直方图均衡化:拉开灰度分布,逆光时效果尤其明显 if use_clahe: # CLAHE 分块均衡,比全局 equalizeHist 更不容易放大噪点 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) gray = clahe.apply(gray) else: gray = cv2.equalizeHist(gray) # 3) 高斯平滑:抑制传感器噪点,核必须是奇数 gray = cv2.GaussianBlur(gray, (5, 5), 0) return grayclipLimit=2.0控制对比度增强的上限,调高会让暗部细节更明显,但同时把噪点一起放大,关键点会开始抖。tileGridSize=(8,8)是把图分成 8×8 的小块分别均衡,适合光照不均匀的场景——比如人脸一半亮一半暗,全局均衡会把亮部直接削平。(5,5)的卷积核是经验和计算量的折中,3×3 抑制噪声不够,7×7 会把眼睑边缘也糊掉,EAR 反而下降。
论文里常写的cvSmooth和cvEqualizeHist是 OpenCV 1.x 时代的 C 接口函数名,在 Python 的cv2里它们对应的就是cv2.GaussianBlur与cv2.equalizeHist。拿着老代码直接跑会报module 'cv2' has no attribute 'cvSmooth',这不是环境装错了,是接口换代了。看到这类报错,先查函数是不是被拆成了更细的现代接口,别急着重装库。
3.2 平滑和均衡的先后顺序,决定了你要付出什么代价
先平滑再均衡,还是先均衡再平滑?两种都能跑,但副作用不同。先平滑后均衡:噪声被压掉了,均衡时不会把噪点放大,但平滑也会削弱真实的边缘对比度,均衡器拿到的是一张「本来就钝」的图,拉不开差距。先均衡后平滑:边缘对比度先被拉满,再被平滑抑制一部分噪点,整体观感更锐利,但在极暗环境下会把噪点也拉到满格,平滑压不住。
我一般会这么理解:如果目标是给人脸检测器用(Haar 或 HOG),先均衡后平滑更合适,因为这类检测器依赖强边缘;如果目标是给关键点回归用,先平滑后均衡更稳,因为回归器对噪声敏感而对绝对对比度不敏感。两件事在同一个 pipeline 里都做的时候,就得分两路——检测用一路,关键点用另一路,多花一点 CPU,换来的是两条链路都不被对方的偏好绑架。
3.3 Haar 级联与 HOG、深度学习检测器:为什么老方案还在用
人脸检测器的选型直接决定了系统的硬件门槛。Haar 级联是 OpenCV 自带的经典方案,cv2.CascadeClassifier加载一个 XML 就能用,CPU 单核也能跑到实时,代价是正脸依赖强,侧脸和遮挡下容易丢。HOG + 线性 SVM 的cv2.HOGDescriptor精度更好一些,速度略慢。基于 CNN 的检测器精度最高,但在没有 GPU 的普通笔记本上处理 640×480 的图,单帧可能就要几十毫秒,叠上关键点回归,整个链路就掉到 10fps 以下。
这里的取舍是明确的:一个疲劳检测系统如果连实时性都保证不了,精度再高也没意义,因为闭眼判定本质上是时间维度上的统计。掉帧会让「连续闭眼 2 秒」这个条件永远凑不满帧数,系统变成永远不报警的摆设。所以很多实现选择在低分辨率上做检测——把原图缩到宽 320 再送进检测器,拿到人脸框坐标后按比例放大回原图,再在放大后的区域上做关键点。这样做速度能翻两三倍,精度损失很小,因为人脸框本来就是粗定位,真正需要高精度的是框内的关键点。
def detect_faces(gray, scale=0.5): # 缩小再检测,坐标按比例还原,是低配机器上最有效的一招 small = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml") faces = cascade.detectMultiScale( small, scaleFactor=1.1, # 每次搜索窗口放大的比例,越小越慢但越全 minNeighbors=5, # 相邻框合并阈值,调大能压掉误检 minSize=(60, 60) # 太小的人脸检测不出来,直接不搜,省时间 ) inv = 1.0 / scale return [(int(x * inv), int(y * inv), int(w * inv), int(h * inv)) for (x, y, w, h) in faces]scaleFactor=1.1和minNeighbors=5是两个最需要调的参数。前者控制搜索粒度,1.05 更全但慢很多;后者控制误检,疲劳检测场景里误检的代价是「框到一个不是脸的东西然后算出离谱的 EAR」,宁可漏检也别误检,所以这个值通常往大调。minSize是性能阀门,摄像头离人远的时候人脸本来就小,与其让它搜个半天搜不到,不如直接设个下限。
4. Django 侧:一次上传如何穿过视图、模型与 MySQL
算法只是内核,真正让这套东西变成「系统」的是外面那层 Web 骨架。基于 Django 的实现通常走 MVT:模板出页面,视图收请求调算法,模型把结果落库。这条链路看着简单,但每一步都有能让新手卡一整天的细节。
4.1 请求生命周期:文件落盘与计算的分离
用户点「上传并检测」,浏览器发一个 multipart 请求,Django 的request.FILES里躺着文件对象。这里第一个决策点是:先落盘还是先在内存里算。直接在内存里cv2.imdecode处理,省一次磁盘 IO,但一旦算法抛异常,你连问题图片都找不到;先落盘再读,多一次 IO,但留下了排查现场。我倾向于后者——疲劳检测的失败往往和具体某张图的光照有关,没有原图,排查就是瞎猜。
import os import cv2 import numpy as np from django.conf import settings from django.shortcuts import render from .models import PhotoInfo from .detector import analyze_image # 封装了预处理 + EAR/MAR 判定 def upload_and_detect(request): if request.method != "POST": return render(request, "detect.html") file = request.FILES.get("photo") if not file: return render(request, "detect.html", {"msg": "未选择文件"}) # 1) 落盘,文件名加时间戳防止同名覆盖 name = f"{int(time.time())}_{file.name}" path = os.path.join(settings.MEDIA_ROOT, name) with open(path, "wb") as f: for chunk in file.chunks(): # 分块写,避免大图吃满内存 f.write(chunk) # 2) 读图并交给算法层,视图本身不含任何视觉逻辑 img = cv2.imdecode(np.fromfile(path, dtype=np.uint8), cv2.IMREAD_COLOR) if img is None: return render(request, "detect.html", {"msg": "图片解码失败"}) result = analyze_image(img) # 返回 eyes / haqian / pilao 三项 # 3) 结果入库,字段与数据库表一一对应 PhotoInfo.objects.create( picname=name, eyes=result["eyes"], # "open" / "closed" / "ratio:0.21" haqian="yes" if result["yawn"] else "no", pilao="yes" if result["fatigue"] else "no", ) return render(request, "result.html", {"r": result, "img": name})这段代码里最值得说的是np.fromfile配合cv2.imdecode。直接用cv2.imread读中文路径会返回None,这不是编码错误,是底层 IO 用了系统本地编码,遇到非 ASCII 字符就挂了。绕开的方式就是先用 numpy 把字节读进来,再让 OpenCV 从内存解码。另外file.chunks()分块写也必要,手机拍的照片动辄 5MB 以上,一次性read()进内存,多用户并发时内存会明显吃紧。
还有一个更大的坑在部署侧:Django 默认的开发服务器是单线程的(虽然有runserver的自动重载),一个耗时 300ms 的视觉计算会阻塞整个请求队列。开发阶段感觉不出来,一旦两三个人同时点上传,页面就开始转圈。常见做法是把计算丢给 Celery 之类的任务队列异步跑,前端轮询结果;轻量一点的方案是把识别接口从主页面里拆出来,用 AJAX 单独请求,至少不阻塞页面渲染。
4.2 照片表的字段设计:为什么用 varchar 存布尔值会疼
论文里的表设计很朴素:照片 ID、图片名称、眼睛状态、是否打哈欠、是否疲劳、创建时间,六列。字段类型清一色 varchar,长度从 2 到 50 不等。这个设计能跑通,但从工程角度看有几处值得拆开讲。
CREATE TABLE photo_info ( picid VARCHAR(32) NOT NULL, -- 主键,一般存 UUID 或时间戳 picname VARCHAR(50) NOT NULL, -- 落盘文件名,与 MEDIA_ROOT 对应 eyes VARCHAR(50) NOT NULL, -- 存 "open"/"closed" 或 "ratio:0.21" haqian VARCHAR(2) NOT NULL, -- 存 "是"/"否" 或 "1"/"0" pilaov VARCHAR(2) NOT NULL, -- 是否疲劳,同上是字符串 addtime VARCHAR(20) NOT NULL, -- 创建时间,字符串形式 PRIMARY KEY (picid) ) DEFAULT CHARSET=utf8mb4;第一个问题:pilaov VARCHAR(2)存「是/否」。在 MySQL 里,字符串比较要走字符集校验,比整数比较慢,更关键的是它没法直接参与数值聚合——你想算「本月疲劳图片占比」,得先CASE WHEN pilaov='是' THEN 1 ELSE 0 END转一遍。换成TINYINT(1)或者ENUM,索引和统计都会顺很多。第二个问题:addtime存字符串,排序就变成了字典序。"2024-9-1"和"2024-10-1"按字符串比,前者反而更大,分页查询直接乱序。换成DATETIME是必须的。第三个问题:eyes VARCHAR(50)同时塞了状态和数值两种语义,后期的统计分析会被这层模糊拖住,理想做法是拆成eye_state TINYINT和ear_value DECIMAL(4,3)两列。
Django 的 ORM 侧对应的是models.CharField(max_length=50)这种定义。如果用BooleanField,Django 会自动映射成tinyint(1),并且表单层自动渲染成勾选框,省掉一堆手工转换。这种「论文表 vs 工程表」的差距,本质上是从「能存下」到「能查、能统计、能改」的跨越。
注意:
utf8在 MySQL 里最多存 3 字节,遇到 emoji 或生僻字会直接插入失败。涉及用户昵称、备注的表一律用utf8mb4,别等线上出问题再改表。
4.3 统计图的数据从哪来:聚合查询而非前端硬算
首页会展示总用户数、总图片数,照片分析页会画柱状图和饼图,展示睁眼占比、哈欠占比、疲劳占比。这些数字不该在前端遍历一遍列表来算——数据量一大就卡死,而且每次刷新都要重新拉全量数据。
正确做法是在数据库层聚合,用 Django 的aggregate/annotate或者原生 SQL:
from django.db.models import Count, Q def dashboard_stats(user=None): qs = PhotoInfo.objects.all() if user: qs = qs.filter(user_id=user.id) return qs.aggregate( total=Count("picid"), fatigue=Count("picid", filter=Q(pilaov="yes")), yawn=Count("picid", filter=Q(haqian="yes")), closed=Count("picid", filter=Q(eyes="closed")), ) # 返回形如 {"total": 128, "fatigue": 21, "yawn": 33, "closed": 40}filter=Q(...)生成的是 SQL 里的条件聚合,也就是COUNT(CASE WHEN ... THEN 1 END),一次查询扫一遍表就出四个数,比发四次count()快得多。拿到这四个数字,百分比在 Python 里算完直接丢给模板,前端图表库只负责把[21, 107]这种数组渲染成饼图,不参与任何业务计算。
时间维度的「24 小时个人表现」图表同理,按小时分组用annotate配合TruncHour,一次查询拿到 24 个桶的计数。真正需要警惕的是把这种聚合写成 Python 循环里的for h in range(24): count(),24 次数据库往返,页面打开就要一秒多。这种性能问题在开发阶段完全看不出来,因为本地数据只有几十条。
5. 避坑与排查:这套链路最容易翻车的五个地方
算法逻辑写对了,系统跑起来仍然会出现各种「看起来像是随机」的误判。下面这五条是从实际调试里攒出来的,每一条都对应一个具体现象。
5.1 逆光下永远判定为疲劳
现象:人坐在窗户前,系统持续报警「重度疲劳」,但人明明睁着眼。
原因:背光时人脸整体变暗,直方图集中在一小段灰度区间里,关键点回归器的输入对比度过低,眼睑位置的预测开始往中间收,EAR 被系统性压低。此时无论怎么调阈值都没用,因为误差是结构性的。
解决:两条路一起走。一是把预处理里的全局均衡换成 CLAHE,分块处理能把暗部人脸拉回来;二是在算法层加一路「EAR 异常检测」——如果连续若干帧的 EAR 都低于正常人的物理下限(比如 0.12),大概率不是真的闭眼,而是关键点崩了,此时应该丢弃该帧并给出「环境光照不足,请调整姿势」的提示,而不是硬判疲劳。让人知道系统不可靠,比给一个错误结论要好。
5.2 戴眼镜的人闭眼检测不出来
现象:不戴眼镜的人测试正常,换成戴眼镜的同事,闭眼时 EAR 几乎不下降。
原因:镜片反光和镜框遮挡。反光会让眼睑区域出现高亮块,关键点被吸到高亮区;镜框在上眼睑附近形成强边缘,回归器容易把它当成眼睑上沿,导致上眼睑坐标被「抬高」,EAR 始终偏高。
解决:首先在检测前加一轮眼镜检测(Haar 有现成的haarcascade_eye_tree_eyeglasses),检测到眼镜时把阈值动态下调 10%~15%,这是最省事的做法。更稳的是放弃单点,改用眼睑区域的平均曲率——取上下眼睑中间若干采样点的拟合曲线,用曲线围成的面积代替两点距离,对局部异常点有天然的鲁棒性。另外提醒被测者摘掉有色镜片,这一条虽然朴素但有效。
5.3 换个人就误判:阈值没有基线标定
现象:开发者自己测一整天都不报警,换室友一测就疯狂报警。
原因:EAR 的绝对值因人而异,眼裂小的人睁眼 EAR 可能只有 0.18,硬编码的 0.2 阈值对他来说就是「永远闭眼」。
解决:强制走一遍基线标定,也就是前面FatigueJudge里的做法。落地上还可以更细:标定期内主动提示「请正视镜头并保持睁眼 4 秒」,采集完在页面上回显标定值,让用户知道系统已经认识他的眼睛了。标定值应该跟用户记录一起入库,下次登录直接复用,避免每次都标定。人疲劳时眼睑本身会轻微下垂,所以基线建议按周更新一次,别用半年前的数据。
5.4 单张图片上传超时或内存暴涨
现象:用手机拍的照片(4MB 以上)上传,偶尔报 500;并发两三个人上传,服务直接卡住。
原因:一是没有限制上传大小,FILE_UPLOAD_MAX_MEMORY_SIZE默认 2.5MB,超过这个阈值的文件会写到临时目录,但如果服务器磁盘 IO 慢,写临时文件本身就成了瓶颈;二是原图直接送进算法,一张 4000×3000 的图做关键点检测,单帧就要一两秒;三是一次性file.read()把整个文件读进内存,多并发时内存曲线直接飙上去。
解决:上传端先做尺寸收敛——用cv2.resize把长边限制在 800 像素以内再送检测,精度几乎无损,耗时能降一个数量级。同时在表单层限制文件大小和类型(accept="image/*"),在视图里对file.size做二次校验。文件写盘用chunks()分块,别用read()。如果并发是常态,把识别过程改成异步任务,接口立刻返回一个任务 ID,前端轮询结果。
5.5 数据库字符集导致结果写不进去
现象:本地开发一切正常,部署到另一台机器后,插入带中文备注的记录时报错,或者存进去变成问号。
原因:建库时用了默认的latin1或者utf8(三字节版本),而 Django 连接层用的是utf8mb4,两边不一致。还有一种更隐蔽的情况:表建好了但列级字符集没跟上,SHOW CREATE TABLE一看就能发现。
解决:建库、建表、连接三层统一到utf8mb4,Django 的DATABASES配置里显式写"OPTIONS": {"charset": "utf8mb4"}。迁移前用SHOW VARIABLES LIKE 'character_set%'把服务端、客户端、连接三组变量都看一遍,别只改一处。这类问题排查成本高但修复成本低,属于典型的一次性踩坑、终身受益。
6. 精度之外的取舍:这套系统为什么敢用「图片」而不是视频流
实时视频流看起来更高级,但它把三件事同时变复杂了:计算量、状态管理和带宽。逐帧处理意味着算法必须跑在摄像头帧率之上,一旦单帧耗时超标,帧队列就会堆积,判定的时间基准跟着漂移,「连续闭眼 2 秒」这种依赖时间的规则直接失效。图片模式反过来了——每张图独立处理,耗时多少都不影响判定正确性,因为判定所需的时间维度信息被折叠进了「多次上传之间的间隔」和「一张图内部的统计结果」里。
这个取舍的代价是真实的:单张静态图看不到眨眼过程,只能看到某一瞬间的眼睛状态,理论上无法区分「偶尔眨一下」和「持续闭眼」。所以这套系统在工程上必须靠两件事补回来,一是让用户连续拍多张(或者上传一组图),用帧间序列还原时间维度;二是把「是否疲劳」的判定降级为「当前这张图呈现的状态」,把最终结论交给历史记录的统计趋势,也就是照片分析页那些柱状图和饼图存在的真正理由。理解这一点,你就明白那些图表不是装饰,而是静态图方案的必要补偿。
同理,选择 Django 而不是更轻的 Flask,也是取舍。Django 自带 ORM、认证、后台管理、表单校验,一个毕业设计体量的系统能在一周内把增删改查和权限跑通,代价是启动慢、目录结构重、对异步支持相对晚熟。如果这个系统要长期跑在高并发环境里,我大概会把识别服务单独拆出去做 API,Django 只负责页面和业务,让它别背着视觉计算的包袱。数据库选择 MySQL 而不是 SQLite,也是同一个逻辑——SQLite 单写入锁在多用户并发上传时会成为瓶颈,而这个系统的写入频率恰好不低。
至于验证这套系统到底靠不靠谱,光看「测试通过」四个字是不够的。更实用的做法是自己造一个小测试集:找几个人,分别在精神好和熬夜后各录一段固定时长的视频,统计系统报出的疲劳帧占比,对比真实状态。如果熬夜组的报警率不比正常组明显高,说明阈值或者融合权重有问题,回去调参而不是改界面。这个步骤很多人跳过,然后在答辩现场被问「准确率多少」时答不上来。
从那以后我每次碰这类「把生理状态翻译成数字」的系统,都会强制先把基线标定和异常帧丢弃这两步写进第一版,而不是等功能都铺完了再回头补——因为这两步一旦缺席,后面调的所有阈值都是在错误的分布上调,越调越偏。希望帮到你。
本文还有配套的精品资源,点击获取