☰
Python人脸识别疲劳检测源码:EAR、MAR与PERCLOS工程解析
2026/11/2 6:55:16 网站建设 项目流程

简介:面向高校学生毕业设计与计算机视觉初学者的人脸疲劳检测项目源码,聚焦驾驶员面部特征识别与疲劳状态判定,可用于课程设计、毕设参考及交通场景中的疲劳驾驶预警思路验证。压缩包共24个文件、约68.33MB,其中5个py文件承载核心检测逻辑,xml与iml文件用于项目结构与IDE配置,md、docx和txt提供说明与补充材料,mp3、dat则配合提示音或数据读取,目录组织相对完整。已有1360人学习下载,具备一定参考热度。源码下载后可直接运行,便于读者梳理人脸检测、面部特征提取、疲劳判定与报警提示之间的实现关系,也能据此替换检测策略、调整阈值或接入摄像头做二次开发,适合作为毕业设计原型与视觉项目入门案例。

1. 一个能跑起来的疲劳检测 Demo,比十页算法推导更值钱

去年帮一个学弟看毕业设计,他纠结了整整两周:到底是抄论文里的深度学习模型,还是找一个能当场演示的方案。我让他先把摄像头打开,用 Python 人脸识别把「眼睛闭没闭」这件事跑通,再谈模型。二十分钟后,屏幕上出现了一个绿色矩形框和一行 EAR 数值,他立刻就懂了整个系统该怎么往下写。这套基于驾驶员面部特征的疲劳检测源码,本质上就是把这件事工程化:摄像头取流、人脸检测、关键点定位、眼睛与嘴巴状态计算、连续帧统计、超阈值报警,一条链路串起来,配一个可视化界面。它适合三类人:赶毕业设计需要完整可演示系统的学生、想入门计算机视觉工程落地的开发者、以及要给车载或工地场景做原型验证的团队。代码是 Python 写的,语法门槛低,改起来不用重新编译,这对要反复调阈值的项目来说太重要了。

2. 目录结构与技术栈:解压后先看哪几层

拿到一个陌生源码包,我的习惯是先别急着装依赖,先用文件树把「入口在哪、配置在哪、模型在哪」这三件事定位清楚。这个项目的结构比较典型,属于「单入口 + 分模块 + 资源外置」的组织方式,没有过度分层,也没有把所有逻辑堆在一个文件里,对二次开发比较友好。

2.1 典型的目录骨架

解压后一般能看到类似下面这样的层级(不同版本细节会有差异,但主干基本一致):

fatigue_detection/ ├── main.py # 程序入口,负责启动界面或命令行流程 ├── config.py # 阈值、窗口大小、摄像头索引等全局参数 ├── detector/ │ ├── face_landmark.py # 人脸检测 + 68/468 关键点 │ ├── eye.py # EAR 计算、眼睛状态判定 │ └── mouth.py # MAR 计算、哈欠判定 ├── core/ │ ├── fatigue_engine.py # 状态机、PERCLOS 统计、报警触发 │ └── alarm.py # 声音报警 / 日志写入 ├── ui/ │ ├── main_window.py # PyQt5 主界面 │ └── widgets.py # 视频显示控件、状态指示 ├── models/ │ ├── shape_predictor_68_face_landmarks.dat │ └── haarcascade_frontalface_default.xml ├── data/ # 报警截图、日志输出目录 ├── requirements.txt └── README.md

这个划分的关键点是:检测器只负责「告诉我眼睛在哪」,不负责「判断累不累」。很多人一开始会把判定逻辑写进face_landmark.py,结果后面想换一种疲劳指标时,得把整个文件翻一遍。把detector和core拆开,判定策略就能单独替换,这是这套源码最值得保留的设计。

models/目录外置也是加分项。dlib 的 68 点模型文件接近 100MB,Haar 级联文件也有几百 KB,如果被打包进 exe 而不外置,加载路径在 PyInstaller 打包后会集体失效,这是很常见的翻车点。

2.2 依赖清单与版本选择

一个能跑通的requirements.txt大致长这样:

opencv-python==4.8.1.78 dlib==19.24.2 numpy==1.24.3 PyQt5==5.15.9 scipy==1.11.4 imutils==0.5.4

这里有几个血泪经验值得说清楚。

dlib 是最大的安装障碍。在 Windows 上直接pip install dlib大概率会卡在编译阶段,报CMake must be installed或者Visual C++ 14.0 required。常见做法是两条路:一是装好 CMake 和 VS Build Tools 后源码编译,耗时长但干净;二是用已经编译好的 whl 文件本地安装,比如pip install dlib-19.24.2-cp39-cp39-win_amd64.whl,前提是 Python 版本、位数都要对上。我在一台只有 4G 内存的机器上编译过一次 dlib,中途直接卡死,从那以后都优先找 whl。

numpy 版本不能太新。dlib 的部分二进制包是对着特定 numpy ABI 编译的,如果你把 numpy 升到 2.x,运行时会看到numpy.dtype size changed, may indicate binary incompatibility这类报错,而且不是在你 import dlib 的时候报,是在真正调用shape_predictor的时候才炸,排查起来很费时间。把 numpy 钉在 1.24 附近是比较稳的。

opencv-python 和 opencv-contrib-python 不要共存。两个包装的是同一个cv2模块,混装之后会出现某些函数能找到、某些函数找不到的玄学现象。如果源码里用到了cv2.face这类 contrib 才有的模块,就统一用 contrib 版本,把opencv-python卸干净。

提示:装依赖之前先python --version确认位数和版本,再决定用源码编译还是现成 whl。这一步省下来的时间比后面调参多得多。

2.3 一帧图像的完整流转路径

理解数据流比读单个函数更重要。从摄像头打开到屏幕报警,中间大概是这样几步:

# main.py 中的主循环骨架(简化) import cv2 from detector.face_landmark import FaceLandmarkDetector from detector.eye import EyeAnalyzer from core.fatigue_engine import FatigueEngine cap = cv2.VideoCapture(0) # 0 表示默认摄像头 landmark = FaceLandmarkDetector("models/shape_predictor_68_face_landmarks.dat") eye = EyeAnalyzer(ear_thresh=0.25) # EAR 阈值 engine = FatigueEngine(consec_frames=20, perclos_window=60) while True: ok, frame = cap.read() if not ok: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 检测阶段用灰度 faces = landmark.detect(gray) # 第一步:找人脸 for face in faces: points = landmark.landmarks(gray, face) # 第二步:68 个关键点 ear = eye.ear(points) # 第三步:算 EAR closed = eye.is_closed(ear) # 第四步:判闭眼 engine.update(closed) # 第五步:状态机累计 if engine.alarming(): cv2.putText(frame, "FATIGUE!", (30, 60), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 0, 255), 3) cv2.imshow("monitor", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()

这段代码把六个环节串成了一条线:cap.read()拿原始帧,cvtColor转灰度是因为 Haar 和 dlib 的检测器都只需要单通道,转一次能省掉后面重复计算;landmark.detect只返回人脸矩形,landmark.landmarks才做关键点回归,两步分开是为了在没检测到人脸时能提前跳过,避免每帧都跑一次开销大的预测器;ear和is_closed是纯计算,把几何量翻译成布尔状态;engine.update负责时间维度上的累计,这是整个系统从「单帧判断」升级成「疲劳判断」的分水岭。

参数上,ear_thresh和consec_frames是最需要现场调的两个。前者决定「眼睛多小算闭眼」,后者决定「连续多少帧闭眼才报」。默认值给的是正常人睁眼距离的 60% 左右,但戴眼镜、单眼皮、逆光环境都会让实际值偏离,所以这两个参数一定要暴露在config.py里,而不是写死在函数中间。

3. 人脸检测与关键点定位:68 个点怎么变成眼睛和嘴

这一章是整个系统的地基。地基歪了,后面所有阈值调参都是白费力气。很多人拿到源码后直接把检测部分跳过,结果发现 EAR 忽高忽低、误报不断,最后才发现是关键点在脸上乱跳。

3.1 检测器选型:Haar、dlib 和轻量模型的取舍

源码里最常见的组合是Haar 级联做人脸粗定位 + dlib 做 68 点回归。这个组合不是最先进的,但是最稳的、最好装的。

Haar 级联的优点是速度极快,CPU 上跑 640×480 的帧基本不占什么资源,而且cv2.CascadeClassifier是 OpenCV 自带的,不需要额外模型。缺点是对侧脸、遮挡、暗光比较敏感,正面清晰人脸效果还不错。调用方式很直接:

import cv2 # 加载外置的级联模型文件 cascade = cv2.CascadeClassifier("models/haarcascade_frontalface_default.xml") def detect_faces(gray): # scaleFactor 控制每次缩放的步长,越小越慢但越全 # minNeighbors 越大误检越少,但漏检会变多 # minSize 限制最小人脸,避免把远处的小方块当人脸 faces = cascade.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=5, minSize=(80, 80) ) return faces

三个参数的实际意义:scaleFactor=1.1表示每次把检测窗口放大 10%,值调到 1.05 会更细但帧率明显掉;minNeighbors=5是投票机制,只有被判定为脸超过 5 次的区域才保留,调到 3 会开始出现误检,调到 8 在光线差的环境下会漏掉人脸;minSize我一般设为画面高度的 1/8 左右,太小的脸关键点精度极差,算出来的 EAR 没有参考价值。

dlib 的 68 点预测器接手粗定位结果,把脸部区域细化成具体的轮廓点。它的输入必须是灰度图和一个矩形框,输出是一个dlib.full_object_detection对象,可以通过part(i)取第 i 个点。

如果换成face_recognition库,写法会更简单,一行face_landmarks()就能拿到字典形式的点集,但它的底层其实还是 dlib,只是封装了一层。代价是依赖更重、启动更慢,而且拿到的点是列表不是对象,索引写法要改。对毕业设计来说,直接用 dlib 更透明,答辩时能讲清楚每一步在做什么。

3.2 68 点索引与眼睛、嘴巴的 ROI 提取

dlib 的 68 点是有固定编号的,记住这几组就够了:

区域索引范围说明
左眼36–416 个点围成一圈
右眼42–476 个点围成一圈
嘴巴外轮廓48–5912 个点
嘴巴内轮廓60–678 个点,判断张嘴更有用
鼻梁27–30常用来做头部姿态参考

把索引翻译成坐标,代码大致是:

def eye_points(points, start, end): """从 68 点对象里抽出眼睛区域的 6 个点,转成 (x, y) 列表""" pts = [] for i in range(start, end + 1): p = points.part(i) # dlib 的 part 返回 point 对象 pts.append((p.x, p.y)) return pts left_eye = eye_points(points, 36, 41) right_eye = eye_points(points, 42, 47)

注意points.part(i)取到的坐标是相对于原图的,不是相对于人脸框的。这一点在画图时容易搞混:如果你在裁剪后的小图上做检测、却用原图坐标去画,框会跑到画面外面去。稳妥的做法是全程在整帧上做关键点,只在需要局部放大显示时才做坐标平移。

嘴巴同理,48–59是外轮廓,60–67是内轮廓。判断打哈欠用内轮廓更准,因为外轮廓在说话时也会大幅变化,而内轮廓只有在真正张嘴时才明显张开。

3.3 关键点抖动与时间平滑

这是源码里最容易缺失、也最影响体验的一环。dlib 的预测器在相邻帧之间会有 1–3 像素的抖动,如果你的 EAR 阈值卡得很紧,就会出现「眼睛明明睁着,但某一帧 EAR 突然掉到阈值以下」的情况,连续帧计数被意外打断,报警永远触发不了。

常见的处理有两种。第一种是中值滤波,维护一个长度为 5 的队列,每次取中位数:

from collections import deque class SmoothValue: """滑动窗口中值滤波,压掉单帧抖动""" def __init__(self, size=5): self.buf = deque(maxlen=size) def push(self, v): self.buf.append(v) # 排序后取中间值,比均值更能抵抗离群点 return sorted(self.buf)[len(self.buf) // 2] ear_smoother = SmoothValue(5) ear = ear_smoother.push(compute_ear(points))

第二种是指数移动平均,用s = α * v + (1 - α) * s,α 取 0.3–0.5。它比中值滤波更平滑,但对突变的响应会滞后一两帧。我的习惯是 EAR 用中值滤波(对突发噪声敏感),PERCLOS 的统计用滑动窗口(本来就是时间维度的量),两者不要混用。

注意:平滑窗口不要设太大。窗口设到 15 帧以上时,快速眨眼会被抹平,闭眼事件直接丢失,PERCLOS 会系统性偏低。

4. 疲劳判定算法:EAR、MAR 与 PERCLOS 三件套

检测层拿到的是坐标,判定层要把它变成「困不困」这个结论。这套源码的价值主要集中在这一层,因为它是「能用」和「不好用」的分界线。核心是三个指标:EAR 管闭眼,MAR 管打哈欠,PERCLOS 管一段时间内的累计疲劳程度。

4.1 EAR 眼睛纵横比:公式、边界和阈值

EAR(Eye Aspect Ratio)的思路很朴素:睁眼时眼睛是接近圆形的,闭眼时上下眼睑贴合成一条线,用「上下距离之和」除以「左右距离的两倍」就能区分两种状态。

$$EAR = \frac{|p_2-p_6| + |p_3-p_5|}{2 \times |p_1-p_4|}$$

对应到代码里,六点顺序是[左角, 上左, 上右, 右角, 下右, 下左]:

import math def eye_aspect_ratio(pts): """pts 为 6 个 (x, y) 点,顺序遵循 dlib 的 36-41 编号""" # 垂直方向两组距离:上眼睑到下眼睑 v1 = math.hypot(pts[1][0] - pts[5][0], pts[1][1] - pts[5][1]) v2 = math.hypot(pts[2][0] - pts[4][0], pts[2][1] - pts[4][1]) # 水平方向:左右眼角距离 h = math.hypot(pts[0][0] - pts[3][0], pts[0][1] - pts[3][1]) if h == 0: return 0.0 # 防御性判断,避免除零 return (v1 + v2) / (2.0 * h)

分母乘 2 是为了让正常睁眼的 EAR 落在 0.25–0.35 这个区间,数值看起来比较直观。戴眼镜的人在正常睁眼时可能只有 0.22,眯眼习惯的人可能到 0.38,所以固定阈值必然有局限。源码里给的默认值一般是 0.25,实际部署时最好先跑一段「正常睁眼」的采集,取均值乘以 0.7 作为阈值,这样适应性强很多。

还有一个细节:左右眼的 EAR 要分开算再取平均,还是直接把 12 个点混在一起算?实践里分开算再平均更稳,因为侧脸时一只眼的点可能被压扁,混算会污染另一只眼的判断。

4.2 MAR 嘴巴纵横比:打哈欠和说话的区分

MAR(Mouth Aspect Ratio)的结构和 EAR 类似,用上下唇的距离除以嘴角距离。判断打哈欠的关键不是「嘴张开了」,而是「张大并且持续」。

def mouth_aspect_ratio(pts): """pts 为内轮廓 60-67 共 8 个点""" # 上下唇中心两点距离 v = math.hypot(pts[3][0] - pts[7][0], pts[3][1] - pts[7][1]) # 左右嘴角距离 h = math.hypot(pts[0][0] - pts[4][0], pts[0][1] - pts[4][1]) return v / h if h else 0.0

阈值一般在 0.6 左右,超过就认为是张口。但说话、笑、打哈欠这三种动作的 MAR 都能过 0.6,区别在持续时间:正常说话的张口时间在几百毫秒内波动,打哈欠通常持续 3 秒以上。所以判定逻辑必须是「MAR 超阈值且持续超过 N 帧」,源码里常见的 N 是 15–25 帧(按 25fps 算约 0.6–1 秒),我会调到 40 帧左右,宁可漏检也不要在地铁上被人说话触发报警。

打哈欠还有一个附加价值:它经常出现在闭眼之前。把 MAR 和 EAR 的触发做加权,比单独用任何一个都准。

4.3 PERCLOS 时间窗与状态机

EAR 只管「此刻」,PERCLOS 管「最近一段时间」。它的定义很简单:统计窗口内闭眼帧数占总帧数的比例。工程实现上通常用两个计数器加一个滑动窗口。

from collections import deque class FatigueEngine: def __init__(self, ear_thresh=0.25, consec_frames=20, perclos_window=150, perclos_thresh=0.4): self.ear_thresh = ear_thresh self.consec_frames = consec_frames # 连续闭眼多少帧算一次微睡眠 self.window = deque(maxlen=perclos_window) # 时间窗,按帧数近似 self.perclos_thresh = perclos_thresh self.closed_count = 0 # 当前连续闭眼计数 self.state = "NORMAL" def update(self, closed): # 1) 连续闭眼计数:用于捕捉微睡眠(microsleep) self.closed_count = self.closed_count + 1 if closed else 0 # 2) 滑动窗口记录最近 N 帧的闭眼情况 self.window.append(1 if closed else 0) # 3) 计算 PERCLOS perclos = sum(self.window) / len(self.window) if self.window else 0.0 # 4) 双条件触发:长时间闭眼 或 窗口内闭眼比例过高 if self.closed_count >= self.consec_frames or perclos >= self.perclos_thresh: self.state = "FATIGUE" elif perclos >= self.perclos_thresh * 0.6: self.state = "WARNING" # 中间态,先给提醒 else: self.state = "NORMAL" return self.state def alarming(self): return self.state == "FATIGUE"

几个参数的实际含义要说清。consec_frames=20在 25fps 下约 0.8 秒,捕捉的是「短暂走神闭眼」;perclos_window=150约 6 秒,捕捉的是「慢慢困下去」的累积趋势;perclos_thresh=0.4表示最近 6 秒里有 40% 的时间眼睛是闭着的。这三者组合起来,比单一阈值可靠得多。

引入WARNING中间态是我强烈建议补上的一点。原始源码常常是二值输出,要么正常要么报警,体验很差。加一个提醒级别,先弹提示音或改变边框颜色,让人有机会自己调整,比直接拉警报人性化。

状态机的另一个好处是防抖。真实场景里人会有正常眨眼,眨眼时 EAR 会短暂掉到阈值以下,如果窗口比较小,PERCLOS 会被眨眼污染。把窗口设大一点,或者对窗口做一次低通,都能缓解。

5. 避坑与排查:摄像头、路径和阈值的那些坑

这套源码我在不同机器上跑过好几遍,遇到的问题大体能归成两类:环境和设备的问题,以及算法和逻辑的问题。分开说,方便按现象定位。

5.1 环境与设备:跑不起来的那些原因

现象一:cap.read()一直返回 False,画面全黑。原因通常是摄像头索引不对,或者被其他程序占用。笔记本上如果有内置摄像头和外接 USB 摄像头,索引 0 和 1 的顺序并不固定;某些系统上还会先分配一个虚拟设备。解决方式是把VideoCapture(0)换成 1、2 逐个试,或者在系统设置里先确认哪个是物理摄像头。另外,Windows 上如果摄像头被会议软件占用,read()会静默失败而不抛异常,所以要检查ok标志而不是只看画面。

现象二:cv2.imshow报The function is not implemented。这基本可以确定装的是opencv-python-headless。headless 版本去掉了 GUI 依赖,适合服务器,但本地必须用带完整 GUI 的包。执行pip uninstall opencv-python-headless,再装opencv-python。如果是 Linux 环境,还可能是缺libgtk2.0-dev,这时装系统包而不是 Python 包。

现象三:模型文件路径报Unable to open shape_predictor_68_face_landmarks.dat。中文路径是老生常谈的问题,但真正导致失败的原因往往不是中文本身,而是程序的工作目录和脚本所在目录不一致。用相对路径models/xxx.dat时,路径是相对于启动命令所在的目录解析的,不是相对于脚本文件。稳妥写法是用os.path.dirname(os.path.abspath(__file__))拼出绝对路径:

import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) MODEL_PATH = os.path.join(BASE_DIR, "models", "shape_predictor_68_face_landmarks.dat")

这样不管是双击运行、命令行运行还是打包成 exe,路径都能对上。

现象四:帧率低到 5fps,画面卡成幻灯片。先看是不是把关键点检测和界面渲染放在了同一个线程。PyQt5 里如果在主线程里跑while True的取流循环,界面事件循环会被堵死,表现出来就是画面不刷新、按钮点不动。常见做法是把取流和计算放到QThread里,通过信号把QImage发回主线程渲染。另一个常见原因是每帧都在原分辨率上做检测,先把帧 resize 到 640 宽再检测,速度能提升两三倍,坐标按比例还原即可。

5.2 算法与逻辑:结果不准的那些原因

现象五:EAR 在正常睁眼时也一直低于阈值,报警停不下来。先打印出实际 EAR 值看分布。如果普遍在 0.15 以下,多半是眼睛的 6 个点取错了顺序,垂直距离算成了水平距离。另一种可能是人脸检测框偏大,dlib 把眉毛和脸颊也纳入了回归范围,导致眼睑点位偏移。可以在画面上把 68 个点全部画出来做一次肉眼核对,这是最快定位问题的方式,比盯代码强。

现象六:侧脸或低头时完全检测不到人脸。Haar 级联对正脸的要求比较高,侧脸超过 30 度基本失效。如果场景里有人会转头,要么换 dlib 的 HOG 检测器,要么加一个「最近一次有效关键点」的缓存,在检测丢帧时用上一帧的位置做延续,配合一个超时机制。这样短暂丢帧不会导致状态重置,报警逻辑不会被频繁打断。

现象七:PERCLOS 一直在 0 附近,即使明显闭眼。按帧数当时间窗是个近似,如果实际帧率波动很大(比如从 25fps 掉到 8fps),同一个窗口代表的真实时间就变了,阈值也就失准。更稳的做法是用time.time()记录每帧的时间戳,窗口按秒裁剪:

import time from collections import deque class TimeWindow: """按真实时间而非帧数维护窗口,抵抗帧率波动""" def __init__(self, seconds=6.0): self.seconds = seconds self.items = deque() # 存 (timestamp, closed) def push(self, closed): now = time.time() self.items.append((now, 1 if closed else 0)) # 丢掉窗口外的旧数据 while self.items and now - self.items[0][0] > self.seconds: self.items.popleft() def ratio(self): if not self.items: return 0.0 return sum(v for _, v in self.items) / len(self.items)

提示:调参时别靠感觉。在画面上把 EAR、MAR、PERCLOS 三个数值实时打印出来,先让测试者正常睁眼 30 秒、闭眼 5 秒、打哈欠 3 秒各走一遍,把数据截图存下来,再定阈值。这个过程花二十分钟,能省掉后面几天的反复怀疑。

5.3 一个自查顺序:出问题按这个流程走

我把排查顺序固化成了一条链路,遇到任何异常都从头走一遍:先确认摄像头能出画面(问题在环境),再确认画面里能画出 68 点(问题在模型或路径),再确认 EAR 数值在正常睁眼时落在合理区间(问题在点位或阈值),最后才看报警逻辑是否按预期触发(问题在状态机或窗口)。这条链路之所以有效,是因为它从最底层往上排查,任何一步失败都能把范围砍掉一半,不会在多层叠加的问题里打转。

6. 二次开发:从能演示到能交差的几步改造

原始源码能跑通演示,但离「一个像样的系统」还差几步。这几步改动成本不高,收益却很明显,也是答辩或验收时最容易被问到的点。

6.1 把界面、算法和报警解耦

原始代码常见的问题是:报警逻辑直接写在界面类里,if fatigue: play_sound()和按钮响应混在一起。想加一个「关闭报警音」的开关,得在事件循环里到处翻。改造的思路是把报警抽象成一个接口:

class AlarmBase: def trigger(self, level, frame=None): raise NotImplementedError class SoundAlarm(AlarmBase): def trigger(self, level, frame=None): if level == "FATIGUE": play_beep() # 具体播放实现 class RecordAlarm(AlarmBase): """触发时保存截图和日志,方便事后回溯""" def __init__(self, out_dir): self.out_dir = out_dir def trigger(self, level, frame=None): if level != "FATIGUE" or frame is None: return ts = time.strftime("%Y%m%d_%H%M%S") cv2.imwrite(f"{self.out_dir}/{ts}.jpg", frame) with open(f"{self.out_dir}/events.log", "a", encoding="utf-8") as f: f.write(f"{ts},FATIGUE\n")

这样疲劳引擎只管输出等级,具体是响铃、存图还是发串口指令,都由外部组合决定。FatigueEngine接收一个alarms: list[AlarmBase],遍历调用即可,加功能不用改核心类。这个改动大概半小时,但让整个系统的可扩展性上了一个台阶。

6.2 加一层数据落库,让「检测」变成「可分析」

只有实时报警,价值是有限的。加一张事件表,把每次疲劳触发的起止时间、持续时间、当时的 EAR 和 PERCLOS 记下来,就能做班次统计、时段分析。用 sqlite3 就够了,不需要额外服务:

import sqlite3 def init_db(path="data/events.db"): conn = sqlite3.connect(path) conn.execute(""" CREATE TABLE IF NOT EXISTS fatigue_event ( id INTEGER PRIMARY KEY AUTOINCREMENT, start_ts REAL, end_ts REAL, duration REAL, avg_ear REAL, max_perclos REAL ) """) conn.commit() return conn

表结构里的avg_ear和max_perclos是关键,它们让你在事后能判断这次报警到底是真疲劳还是阈值过紧造成的误报。没有这两个字段,日志就只能证明「报过」,证明不了「报得对」。

6.3 阈值自适应的一个土办法

固定阈值最大的问题是换人就要重调。真正做产品会用校准流程,但毕设阶段搞一套简单的前 N 秒自校准就够用:程序启动后先采集 10 秒「正常状态」数据,取 EAR 的 20 分位数乘以 0.85 作为闭眼阈值,取 MAR 的 90 分位数乘以 1.2 作为张嘴阈值。

def calibrate(ear_samples, mar_samples): """用启动阶段的采样自适应出阈值""" ear_samples.sort() mar_samples.sort() ear_th = ear_samples[int(len(ear_samples) * 0.2)] * 0.85 mar_th = mar_samples[int(len(mar_samples) * 0.9)] * 1.2 return ear_th, mar_th

分位数比均值稳,因为它不受偶尔的眨眼、说话干扰。校准期间要在界面上给出明确提示,告诉用户「请保持正常状态 10 秒」,否则用户一上来就打哈欠,阈值会被带偏。这个功能加上之后,同一套代码在不同人身上的误报率能降一大截。

6.4 验证方法:别只看「跑起来了」

改完之后怎么确认没改坏?我的做法是准备三段固定素材:一段正常睁眼的视频、一段有明显闭眼和打哈欠的视频、一段侧脸和低头的视频。每改一次核心逻辑,就把三段素材各跑一遍,记录报警次数和时间点,和改动前的基线对比。这个习惯是从一次教训里来的——我曾经把 PERCLOS 的窗口从帧数改成秒数,忘了同步改maxlen,结果窗口永远填不满,比例被系统性拉低,报警几乎不触发,但表面上程序一切正常,画面也在动。从那以后我每次动时间窗口相关的代码,都强制走一遍三段素材的回归,把每次的报警次数打印成一行数字贴在 commit 信息里。

这套源码的价值不在于算法多新,而在于它把一条完整链路摊开给你看,每一层都能单独替换。想换检测器就换detector层,想换判定策略就换core层,界面和存储都是可插拔的。真要往上做,往深度学习换检测、往头部姿态估计加维度、往时序模型做预测,接口都在那儿。希望这套拆解能帮你少走点弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询