Dlib驾驶员疲劳检测实战:眨眼、哈欠、点头识别与PyQt5界面
2026/9/13 6:34:59 网站建设 项目流程

简介:基于Python与Dlib模型的驾驶员疲劳检测毕业设计资源,面向计算机、人工智能、通信工程、自动化等专业学生和开发者,适用于课程设计、项目初期演示或二次开发。项目从人脸朝向、眼睛开合度、眨眼频率、瞳孔收缩率等维度实时分析驾驶员状态,对打哈欠、眨眼异常和瞌睡点头行为作出安全提示。压缩包共17个文件,约85.92MB,含6个Python源码、2个ipynb交互式演示、界面工程fbp、人脸关键点模型dat、操作视频mp4、可执行exe及说明文档等;源码均已测试运行,答辩评审平均分达96分,并配有可视化界面。目前已有232人学习下载。除完整代码外,还附带安装包、运行效果图、README和模型文件,方便对照阅读与快速复现;其中dat模型与exe可直接使用,降低环境配置门槛。整体目录结构清晰,适合作为毕业设计参考,也可在此基础上扩展更丰富的驾驶安全提醒功能。

1. 为什么选 Dlib 做驾驶员疲劳检测,而不是直接上深度学习

毕业设计里做驾驶员疲劳检测,最常见的坑是方案选太重。一上来就上目标检测网络,环境、标注、训练走完一轮,一半时间没了。Dlib 的 68 点关键点模型在 CPU 上单帧 20~40ms,用几何比例就能量化眨眼、打哈欠、点头三个动作。

这套方案最大的优点是阈值可解释:调低闭眼阈值只影响眨眼判定,不会像端到端网络那样改一个参数就要重训。学生能由此快速跑通完整系统,有经验的工程师也能拿它作为实时行为检测的基线方案,用来和深度学习结果做对比。

下文先实现眨眼检测并标定参数,再补上哈欠和瞌睡点头,最后用 PyQt5 汇总成可视化界面,并用回放视频做回归验证,全程围绕 Dlib 模型展开,所有结论都能在拿到源代码后直接复现。

2. Dlib 人脸关键点检测:用 EAR 实现眨眼检测与参数标定

疲劳检测的第一步,是把人脸转成可计算的数值。Dlib 的 68 点模型会输出眉毛、眼睛、鼻子、嘴巴、下巴等位置的坐标,其中每只眼睛对应 6 个点,这 6 个点在一个经典公式里叫 EAR(Eye Aspect Ratio,眼部纵横比)。基于 Dlib 的疲劳检测系统,绝大多数实现的核心就是先算 EAR,再顺着它扩展出哈欠和点头。

2.1 EAR 公式推导:为什么闭眼时比值会掉下来

眼睛关键点的编号是固定的:36~41 是右眼的 6 个点(以受测者自身左右为准,在画面里位于左侧),42~47 是左眼,每个点按逆时针排列。EAR 取水平方向一对点的距离做分母,垂直方向两对点的距离求和做分子,再除以 2。睁眼时垂直距离接近水平距离的一半,EAR 落在 0.25~0.35;闭眼时上下眼睑重合,两个垂直距离趋近于 0,EAR 会掉到 0.15 以下。因为分子分母都来自同一张脸,EAR 天然对画面中脸的大小、离摄像头远近做了归一化,这个性质让它成为疲劳检测里最稳定的输入。

from scipy.spatial import distance as dist def eye_aspect_ratio(eye): # eye: [(x, y)] * 6, 按 dlib 输出的逆时针顺序传入 vertical_1 = dist.euclidean(eye[1], eye[5]) # 上眼睑两端的对角距离 vertical_2 = dist.euclidean(eye[2], eye[4]) # 下眼睑两端的对角距离 horizontal = dist.euclidean(eye[0], eye[3]) # 内眼角到外眼角 return (vertical_1 + vertical_2) / (2.0 * horizontal)

代码把 6 个点拆成 3 对欧氏距离。vertical_1 和 vertical_2 是上下眼睑的两组对角距离,horizontal 是内眼角到外眼角。用平均垂直距离除以水平距离,就得到与脸大小无关的比值。这里有一个很多人踩过的坑:eye 的坐标顺序不能打乱,一旦把 6 个点重排成顺时针,vertical 和 horizontal 对不上,EAR 会变成无效值。scipy 里的 distance.euclidean 是纯数值计算,直接传入 (x, y) 元组即可,不需要手动写平方和开方。

2.2 python 安装与 dlib 模型文件放置:先跑通最小检测

在动手写计数逻辑前,先把环境处理利落。python 建议用 3.8~3.10,按 python 安装教程装完勾选 Add to PATH;dlib 在 Windows 上最容易卡在编译环节,如果 pip install dlib 报 C++ 编译错误,常见做法是下载与 python 版本、系统位数匹配的预编译 wheel,再离线安装,能省掉 CMake 和 Visual Studio Build Tools 的折腾。模型文件名为 shape_predictor_68_face_landmarks.dat,从 dlib 模型库下载后放在和主程序相同的目录,代码里用相对路径就能加载。

提示:Windows 下如果 dlib 安装卡在编译,先确认是否缺少 Microsoft C++ Build Tools;下载预编译 wheel 是最省事的路径。

如果你刚接触 python,建议先把下面这个最小例子跑通再往下加逻辑。最小例子的目标只有一个:在画面里看到实时的 EAR 数值。开发时用 pycharm 配置 python 环境,记得在 Run/Debug Configurations 里确认解释器选的是安装了 dlib 和 opencv-python 的那个虚拟环境,否则运行时报 ModuleNotFoundError,那不是代码问题,是环境串了。

import dlib import cv2 detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") cap = cv2.VideoCapture(0) # 0 表示默认摄像头 while True: ret, frame = cap.read() if not ret: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = detector(gray, 1) # 第二个参数是图像上采样次数 for face in faces: landmarks = predictor(gray, face) left_eye = [(landmarks.part(i).x, landmarks.part(i).y) for i in range(36, 42)] right_eye = [(landmarks.part(i).x, landmarks.part(i).y) for i in range(42, 48)] ear = (eye_aspect_ratio(left_eye) + eye_aspect_ratio(right_eye)) / 2.0 cv2.putText(frame, f"EAR: {ear:.2f}", (30, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow("dlib EAR", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()

detector 负责在灰度图里找人脸,predictor 在返回的人脸框上做关键点回归。取点左右眼各 6 个点,算出 EAR 后直接画到帧上。dlib 的检测器对灰度图更稳定,所以先 cvtColor 再做检测;左右眼取的编号 36~41 和 42~47 与画面里的左右相反,这是 dlib 标注的语义决定的,不需要改。detector 的第二个参数是上采样次数,调成 2 更容易检出较远、较小的脸,代价是单帧耗时上升。这一步跑通后画面里应该有实时 EAR 数值,如果完全检测不到人脸,优先检查摄像头权限和光线。

2.3 眨眼计数逻辑与三个必调参数

有了 EAR 序列,眨眼就是一次“低于阈值、持续若干帧、再恢复”的完整波形。简单做法是维护一个 frame_counter:EAR 低于阈值时累加,高于阈值时判断累加值是否超过连续帧下限,超过才把眨眼次数加一。用连续帧过滤的原因在于,正常快速眨眼只有 100~200ms,而眼睛眯一下、眨一半、刻意眨眼都会产生短暂的 EAR 波动,不设下限会出现一次眨眼被拆成两次计数。

EAR_THRESH = 0.22 # EAR 低于该值, 认为眼睛处于闭合状态 CLOSED_FRAMES = 3 # 连续闭合多少帧才算一次眨眼 frame_counter = 0 blink_total = 0 while True: # ... 沿用 2.2 的检测循环, 拿到 ear 之后做计数 if ear < EAR_THRESH: frame_counter += 1 else: # 只有连续闭合超过 CLOSED_FRAMES 帧, 才累积一次眨眼 if frame_counter >= CLOSED_FRAMES: blink_total += 1 frame_counter = 0

帧率变化会直接影响 CLOSED_FRAMES 的意义:30fps 下 3 帧约 100ms,15fps 下 3 帧就是 200ms。换摄像头或改分辨率前先确认帧率,别把参数从旧环境直接搬过来。下面这张参数表是这类 Dlib 疲劳检测项目里最常用的起步组合。

参数推荐初值作用调节方向
EAR_THRESH0.22判定眼睛是否闭合戴眼镜、离摄像头偏远时下调到 0.18~0.20
CLOSED_FRAMES3一次眨眼最少持续帧数帧率 30fps 用 2~4;低于 15fps 用 2
统计窗口60 s计算眨眼频率与 PERCLOS 的时间窗标定时先跑 2 分钟取平均值

EAR_THRESH 是三个参数里最敏感的一个。调高会让“眯眼”被记成眨眼,调低会漏掉闭眼幅度小的人。比较稳的标定顺序是:保持其他参数不动,只调 EAR_THRESH,让标注视频里的眨眼全部被检出且不产生多余计数。第 5 章会给出用回放视频自动找 F1 最大值的方法,这里先记住一个原则:一次只动一个参数,改完立刻看波形,不要同时调三个值。

3. 打哈欠与瞌睡点头:嘴巴开合比和鼻尖位移的联合检测

眨眼解决的是“眼睛闭合”这一维,哈欠和点头分别对应“张嘴持续”和“头部下压”。三者共用同一批 dlib 关键点,判定逻辑都是“几何比例 + 持续帧数”,所以放进同一个检测循环里最合理。单独实现哈欠或点头意义不大,疲劳检测系统的完整度体现在这三个指标能互相印证。

3.1 嘴巴开合比 MAR:为什么不用嘴部面积

打哈欠时嘴张得大且持续 0.5 秒以上,和说话、打喷嚏的关键区别在持续时长。嘴巴开合程度用 MAR(Mouth Aspect Ratio)度量:取外唇上沿中心点 51 和下沿中心点 57 的垂直距离,除以嘴角点 48 到 54 的水平距离。平时嘴巴闭合时这个比值在 0.1~0.2,打哈欠时会超过 0.6。有人会直接用嘴唇轮廓围出的像素面积判断,但这依赖人脸框大小的稳定性,摄像头远近一变面积就失真。用比值做归一化后,远近和脸型差异基本被抵消,阈值可以跨设备沿用,这正是几何方法比面积法更适合疲劳检测的原因。

def mouth_aspect_ratio(shape): # 索引对应 dlib 68 点标注: 51 上唇中心, 57 下唇中心 top = (shape.part(51).x, shape.part(51).y) bottom = (shape.part(57).x, shape.part(57).y) # 48 和 54 分别是左右嘴角 left = (shape.part(48).x, shape.part(48).y) right = (shape.part(54).x, shape.part(54).y) return dist.euclidean(top, bottom) / dist.euclidean(left, right)

哈欠判定加一层持续帧过滤:MAR 连续超过阈值达到 20 帧(30fps 下约 0.7 秒)才累加一次哈欠。普通说话偶尔也会让 MAR 短暂越过 0.6,但一般撑不过 0.5 秒,所以持续帧数比阈值本身更能区分“说话”与“哈欠”。打完哈欠后人的嘴会先张大再闭合,如果只想统计完整哈欠,可以用和眨眼类似的恢复判定,避免一次哈欠被记成两次。

3.2 点头检测:用鼻尖到眼线的距离,而不是解头部姿态角

理论上点头可以用 solvePnP 解出头部俯仰角,但需要知道相机内参,换摄像头就得重新标定,毕业设计里不划算。常见做法是度量鼻尖相对眼线的位置:把左右眼各自的中点连成一条线,取鼻尖点 30 到这条线的垂直距离,再除以双眼距离做归一化。正常抬头时鼻尖明显低于眼线,比值较大;低头时人脸在画面里纵向压缩,鼻尖向眼线靠近,比值缩小。瞌睡点头的动作特征是“比值先低于阈值、保持一小段时间、再恢复”,和一直低头看仪表盘的区别在于它有回弹,所以计数逻辑必须等回弹出现才算一次完整的点头。

def head_nod_ratio(shape): # 左眼中点: 外眼角 36 与内眼角 39 取平均 left_mid = ((shape.part(36).x + shape.part(39).x) // 2, (shape.part(36).y + shape.part(39).y) // 2) # 右眼中点: 内眼角 42 与外眼角 45 取平均 right_mid = ((shape.part(42).x + shape.part(45).x) // 2, (shape.part(42).y + shape.part(45).y) // 2) eye_line_y = (left_mid[1] + right_mid[1]) / 2.0 nose_y = shape.part(30).y # 鼻尖 eye_width = dist.euclidean(left_mid, right_mid) return (nose_y - eye_line_y) / eye_width

运行这段代码前先记录一个正常坐姿下的基线值,比如前 30 帧的均值。点头判定用相对基线的比例:当 nod_ratio 低于基线值的 60% 且持续 8 帧以上,记为一次下压;恢复之后再检测回弹,才算完整的一次点头。只记下压不记回弹,会把“低头捡东西”误判成瞌睡,这是实车测试最容易暴露的问题。

3.3 合并进同一个检测循环:一次 dlib 推理同时喂给三个检测器

这一个循环里同时维护眨眼、哈欠、点头三组计数器,它们共享 detector 和 predictor 的输出,所以整帧只做一次人脸检测和一次关键点回归,CPU 占用不会因为指标变多而翻倍。整个 Dlib 疲劳检测的源代码主干实际上就是下面这个循环,后续加可视化界面时也只改数据出口。

YAWN_THRESH = 0.6 # 嘴部开合比阈值 YAWN_FRAMES = 20 # 张嘴持续帧数下限, 约 0.7s NOD_BASE_RATIO = 0.6 # 下压阈值 = 基线值 * 该系数 NOD_FRAMES = 8 # 下压持续帧数下限 while True: # ... 前面的人脸检测与 landmarks 获取 left_eye = [(landmarks.part(i).x, landmarks.part(i).y) for i in range(36, 42)] right_eye = [(landmarks.part(i).x, landmarks.part(i).y) for i in range(42, 48)] ear = (eye_aspect_ratio(left_eye) + eye_aspect_ratio(right_eye)) / 2.0 mar = mouth_aspect_ratio(landmarks) nhr = head_nod_ratio(landmarks) if mar > YAWN_THRESH: yawn_counter += 1 else: if yawn_counter >= YAWN_FRAMES: yawn_total += 1 yawn_counter = 0 nod_down = nhr < nod_baseline * NOD_BASE_RATIO if nod_down: nod_counter += 1 else: # 只统计完成"下压 + 回弹"的点头, 上下限过滤抖动 if NOD_FRAMES <= nod_counter <= 30: nod_total += 1 nod_counter = 0

nod_baseline 用前 30 帧的 nhr 均值初始化,之后每帧用指数移动平均缓慢更新,防止坐姿调整后基线失效。三个计数器都在 60 秒窗口内累计,窗口结束时重置,同时把统计结果交给界面层。这里有个容易被忽略的坑:nod_counter 上限写死 30 帧,如果摄像头帧率低于 20fps,正常点头可能超过上限被漏记,需要根据实际帧率放宽。下面这张表概括了本章四个参数的失败模式,标定时按这个方向排查。

参数推荐初值作用失败时的表现
YAWN_THRESH0.6嘴部开合比阈值阈值太低会把说话当哈欠
YAWN_FRAMES20张嘴持续帧数下限太大漏检短哈欠,太小误报
NOD_BASE_RATIO0.6下压深度的相对阈值太小,点头被当成正常低头
NOD_FRAMES8下压持续帧数下限太大漏检快速点头

4. 可视化界面:用 PyQt5 把眨眼、哈欠、点头聚合成疲劳等级

三个检测器产出的是原始计数,可视化界面要回答的问题是“这个人现在疲劳吗”。界面在毕业设计里的作用不只是展示,它承担实时状态显示、警报触发和参数调节三个职责。我用 PyQt5 搭界面,因为它和 OpenCV 同属 python 生态,信号槽机制天然适合把检测线程和界面线程分开。

4.1 界面线程拆分:为什么检测不能放在 UI 线程

dlib 检测在 CPU 上每帧 20~40ms,如果直接写在 PyQt 的事件循环里,鼠标拖动窗口、点击按钮的响应都会被检测过程卡住,界面上视频会一卡一卡。常见做法是开一个 QThread 专门跑摄像头读取和检测,通过 signal 把处理后的帧和统计数据发回主线程,主线程只负责显示和更新控件。线程分离之后,即使检测线程偶发掉帧,界面依然能即时响应。

from PyQt5.QtCore import QThread, pyqtSignal class FatigueWorker(QThread): frame_ready = pyqtSignal(object, dict) # 一帧图像 + 当前统计 level_changed = pyqtSignal(str) # 疲劳等级变化时发出 def __init__(self): super().__init__() self.running = True def run(self): # 把第 3 章的 while True 循环搬进来 while self.running and cap.isOpened(): ret, frame = cap.read() # ... 三个检测器更新 ear/mar/nhr, 维护三组计数 stats = { "ear": ear, "mar": mar, "blink": blink_total, "yawn": yawn_total, "nod": nod_total, } self.frame_ready.emit(frame, stats) # 每 1 秒调用一次疲劳等级判定, 触发 level_changed

QThread 里不能直接操作界面控件,数据只能通过信号传出去。frame_ready 携带一帧图像和当前统计,level_changed 在疲劳等级变化时发出,主线程对应的槽函数只做赋值和重绘。写的时候注意把 cap 和 detector 都作为 worker 的属性,方便 stop 时统一释放;cap 不要在线程外用全局变量引用,否则退出时容易报“QThread: Destroyed while thread is still running”的警告。

4.2 疲劳等级判定:PERCLOS、哈欠、点头怎么汇总

单一指标误报率太高:有人天生眨眼频率低,有人习惯性张嘴。更稳的做法是三个指标在 60 秒窗口内各算一个状态,再按规则合成疲劳等级。PERCLOS 是闭眼帧数占总帧数的比例,是驾驶疲劳研究里最经典的标准,通常以 P80 判据为准,即眼睑遮挡瞳孔面积超过 80% 的时间占比。

指标(60s 窗口)正常轻度疲劳重度疲劳
PERCLOS< 0.30.3 ~ 0.4≥ 0.4
哈欠次数≤ 12 ~ 3≥ 4
点头次数01≥ 2

综合规则:任一指标达到重度,判定重度疲劳;两个及以上指标达到轻度,判定轻度疲劳;其余情况正常。这个规则的好处是可解释,论文里能直接写清楚每个等级对应什么行为,答辩时不会被追问“你这个分数是怎么算出来的”。想改成打分制也可以,比如给 PERCLOS、哈欠、点头分别配 0.45、0.3、0.25 的权重,但规则制在实车标定时更容易定位问题,建议先把规则制跑通再考虑加权。

4.3 界面骨架:视频区、疲劳等级、统计面板的代码组织

界面大致分三块:左侧是实时视频(QLabel 显示帧),右侧上方是疲劳等级指示灯(用 QLabel 换背景色),右侧下方是统计面板,每秒刷新眨眼、哈欠、点头计数。OpenCV 的帧是 BGR,送到 QLabel 前要转成 RGB 的 QImage,否则颜色会偏蓝。这里有一个必须记住的细节:从 numpy 数组构造 QImage 时要加 copy(),否则帧缓冲被下一帧覆盖后,界面会出现花屏或条纹。

def update_view(self, frame, stats): rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape # .copy() 必须加, 否则 QImage 引用的是可复用的帧缓冲 img = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy() self.video_label.setPixmap(QPixmap.fromImage(img)) self.blink_count_label.setText(f"眨眼: {stats['blink']}") self.yawn_count_label.setText(f"哈欠: {stats['yawn']}") self.nod_count_label.setText(f"点头: {stats['nod']}") def set_level(self, level): color = {"正常": "#2ecc71", "轻度疲劳": "#f1c40f", "重度疲劳": "#e74c3c"} self.level_label.setText(level) self.level_label.setStyleSheet(f"background-color: {color[level]};")

启动和停止按钮分别控制 worker 的 start() 和 running 标志。关窗口时先置 running=False,再调用 worker.wait() 等线程退出,否则会在退出时弹出线程销毁相关的警告。界面上建议再放两个阈值输入框,分别控制 EAR_THRESH 和 YAWN_THRESH,调试时不用改代码就能调参。这个能力在实车测试时很实用,因为光照和摄像头角度变化后,阈值基本都要微调。

提示:关闭窗口时先置 running=False 再 worker.wait(),避免线程未结束时销毁 QThread。

5. 阈值调优:用录制视频回放评估眨眼检测的准确率

5.1 用回放视频替代摄像头:让检测结果可复现

实车测试最大的问题是场景不可控,同一段动作每次跑都不一样。常见做法是提前录制几段包含对照行为的视频:正常驾驶 2 分钟、低头看仪表盘、刻意打哈欠、快速眨眼。检测代码只改一行,把 VideoCapture(0) 换成 VideoCapture('test.mp4'),检测循环完全不变。回放视频带有固定帧率,参数标定和回归测试都基于同一份数据,改完参数能立刻对比效果,这对毕业设计的验收和答辩论据都很有价值。

5.2 用人工标注文件计算 F1 值

标定眨眼阈值时,我会录一段包含 20 次左右眨眼的视频,人工标注每一帧眼睛是否闭合,存成文本标注文件。然后让检测循环输出每一帧的预测值,与标注逐帧比对,统计 TP、FP、FN 算出 F1。这个过程完全自动化,比肉眼盯着 EAR 曲线调参收敛快得多。调 EAR_THRESH 时,先以 0.02 为步长从 0.14 扫到 0.30,记录每个阈值下的 F1,取最大值对应的阈值作为最终配置。

def evaluate(gt_list, pred_list): # gt_list: 人工标注, 1 表示该帧闭眼; pred_list: 检测结果 tp = sum(1 for g, p in zip(gt_list, pred_list) if g == 1 and p == 1) fp = sum(1 for g, p in zip(gt_list, pred_list) if g == 0 and p == 1) fn = sum(1 for g, p in zip(gt_list, pred_list) if g == 1 and p == 0) precision = tp / (tp + fp) if tp + fp else 0.0 recall = tp / (tp + fn) if tp + fn else 0.0 # 只有 precision 和 recall 都不为 0 时, F1 才有意义 return 2 * precision * recall / (precision + recall) if precision + recall else 0.0

F1 对阈值扫描的意义在于:precision 高说明误报少,recall 高说明漏检少,两者在阈值变大或变小时是反向变化的,F1 取最大值的位置就是当前环境下最平衡的阈值。哈欠和点头的参数也可以用同样的流程标定,区别只是标注文件里记录的是“该帧是否张嘴”和“该帧是否处于低头状态”。

5.3 光线处理与参数收敛顺序

夜间或者逆光时,关键点检测会不稳定,EAR 曲线噪声明显变大。我会在灰度图进 detector 前加一步 CLAHE 对比度受限自适应直方图均衡,它对暗部细节的提升比普通 equalizeHist 温和,不会把噪声一起放大。

clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) gray = clahe.apply(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY))

参数标定的顺序固定为:先确认帧率,再标 EAR_THRESH,然后标 YAWN_THRESH,最后标 NOD_BASE_RATIO。一次只动一个参数,每次标定复用同一段回放视频,记录 F1 随阈值的变化曲线。全部参数收敛后,把阈值写进一个 config.py 或 JSON 文件,检测代码里只读配置不写死数值,这样白天、夜间、戴眼镜三组标定结果可以并存,按场景切换配置即可。

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

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

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

立即咨询