拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于计算机视觉的司机疲劳检测:dlib人脸关键点与EAR算法实战

基于计算机视觉的司机疲劳检测:dlib人脸关键点与EAR算法实战

简介:面向计算机视觉与图像处理方向的学习者及相关专业学生,一份PDF技术文献系统介绍基于计算机视觉的司机驾驶疲劳检测方案,可作为课程设计、毕业设计或相关研究的参考。内容涵盖人脸识别与人眼定位算法设计、基于dlib库的六十八个脸部特征点提取、眼部纵横比EAR值计算,以及基于闭眼阈值与连续帧计数的疲劳判定流程;实验结果显示系统成功率高达百分之九十。资源包内为单个PDF文档文件,压缩包大小约2.66MB,十分便于离线阅读;截至目前已有165人浏览学习。文中还给出了从视频帧预处理、人脸检测到EAR判断的完整实现步骤,并讨论了不同环境下检测可信度及局限性,阅读后可快速把握疲劳驾驶检测系统的设计与实现要点,为后续改进提供参考。

1. 基于计算机视觉的司机驾驶疲劳检测:为什么我要拆这份论文

做计算机视觉大作业的同学,十有八九会搜到吉林大学黄永平老师这篇《基于计算机视觉的司机驾驶疲劳检测系统》。论文思路很清晰:先用 dlib 检测人脸 68 个特征点,再用其中 36~47 号眼周特征点计算眼睛纵横比 EAR,通过 EAR 低于阈值的持续帧数判断司机是否闭眼疲劳。系统实验室内成功率能到 90% 上下,但论文里也承认,实验室环境光线稳定、驾驶员正对镜头,一到实际道路场景就会暴露不少问题。这份资源适合两类人:一是做 CV 课程设计、需要一套能跑通并写进报告的技术路线,二是想入门人脸关键点检测、想搞懂 EAR 阈值怎么标定的初学者。我将从选型对比、特征点坐标、EAR 算法推导、代码落地到参数坑位逐一拆解。

2. 从 haarcascades 到 dlib:人脸检测器的选型理由与特征点坐标解读

2.1 为什么 haarcascades 在人眼闭合时直接翻车

论文开头提到先用了 OpenCV 自带的 haarcascades 包做人脸检测和人眼定位,效果不理想:光线好、眼睛睁大时勉强能用,一旦眼睛闭合程度大、人眼区域在画面里占比小,就经常定位不到眼睛,甚至人脸都检测失败。这个现象我复现过,原因在于 haar 特征本质是基于像素灰度差异的弱分类器级联,它对"睁开的眼睛"这种有明确纹理对比的区域敏感,而闭眼状态下眼睑纹理被皮肤褶皱替代,灰度梯度特征消失,分类器自然失效。

另外 haar 检测器输出的是矩形包围盒,不给关键点坐标,你拿到眼睛框后还得自己从框内继续找瞳孔或眼睑位置,多做一步就多一个误差源。所以论文选 dlib 是合理的:dlib 的 68 点人脸关键点检测器基于 HOG 特征和线性分类器,加上形状回归的级联思想,对局部纹理变化更鲁棒,且直接输出每个关键点的 (x, y) 坐标,省掉了二次定位的麻烦。

2.2 68 点模型中 36~47 号点的位置含义与坐标提取

dlib 的 68 点模型把面部关键区域划分为:下颌轮廓 0~16,左眉 17~21,右眉 22~26,鼻梁 27~30,鼻翼 31~35,左眼 36~41,右眼 42~47,嘴巴 48~67。注意论文里写"36-47 为左右眼的特征点",严格讲是左眼 36~41、右眼 42~47。每个点是按固定语义顺序输出的,左眼 36 是外眼角、39 是内眼角,37、38 是上眼睑、40、41 是下眼睑。

import dlib import cv2 # 加载预训练模型,shape_predictor_68_face_landmarks.dat 需要另行下载 detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") img = cv2.imread("driver_face.jpg") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = detector(gray, 0) for face in faces: shape = predictor(gray, face) # 打印左眼6个点坐标,索引36~41 for i in range(36, 42): pt = shape.part(i) print(f"Point {i}: ({pt.x}, {pt.y})") # 打印右眼6个点坐标,索引42~47 for i in range(42, 48): pt = shape.part(i) print(f"Point {i}: ({pt.x}, {pt.y})")

detector(gray, 0)的第二个参数 0 表示不使用图像金字塔上采样,检测速度快但小脸可能漏检;如果摄像头离人较远、人脸像素少,改成 1 或 2 会增加一次上采样,能多检出小脸,但耗时翻倍。predictor接收灰度图和检测到的人脸框,输出 68 个点的坐标集合。这里有个经验:输入图像分辨率不要太低,dlib 官方建议人脸区域至少 80x80 像素,否则关键点回归精度会明显下降。

拿到坐标后别直接拿去做距离计算,最好先按人脸框大小做归一化。因为不同人离摄像头远近不同,同一双眼睛在画面里的绝对像素距离可以差一倍以上,直接比绝对值没有意义。EAR 算法的妙处就在于它是一个比值,分子分母同时缩放,距离影响被抵消了。

2.3 论文没细说的:特征点检测失败的常见输入原因

用 dlib 时最容易翻车的不是算法本身,而是输入图像质量。灰度转换是必须的,dlib 的 HOG 检测器在灰度图上提取梯度特征,彩色图会被内部转灰度,提前转换能省一次转换开销。光照过暗时梯度信息弱,检测器可能整个人脸都框不出来;强侧光会在面部形成大块阴影区,关键点回归容易偏移。我实测过,当人脸偏航角超过 45 度,即司机转头看右侧后视镜时,68 点模型会在可见的半张脸上输出全部 68 个点,其中被遮挡侧的点坐标基本是"猜"的,计算出的 EAR 值完全不可信。

提示:实际做车载场景时,优先保证正脸采样。摄像头装在方向盘前仪表盘位置,比装在 A 柱更利于关键点检测。

3. 核心算法 EAR:眼睛纵横比的计算逻辑与闭眼判定原理

3.1 从眼周 6 点到纵横比公式的推导

论文的疲劳判定核心是 EAR,全称 Eye Aspect Ratio,眼睛纵横比。睁眼时上下眼睑距离大,闭眼时上下眼睑几乎贴合,通过计算上下眼睑特征点之间的垂直距离与内外眼角水平距离的比值,就能得到一个对距离不敏感、对睁闭眼状态敏感的量。

左眼的 6 个特征点编号为 36~41,定义如下:p36 为左外眼角,p39 为左内眼角,p37、p38 为上眼睑左右两点,p40、p41 为下眼睑左右两点。EAR 公式为:

EAR = (||p37 - p41|| + ||p38 - p40||) / (2 * ||p36 - p39||)

分子是两条垂直方向上的眼睑距离之和,分母是眼角水平距离的两倍。睁眼时分子较大,EAR 通常在 0.25~0.35 之间;闭眼时分子趋近于零,EAR 会掉到 0.1 以下。论文提到"一般睁眼时上下特征点距离较大,闭眼时上下距离较小",说的就是这个几何关系。两只眼睛分别计算后取平均,能抵消单眼误检带来的抖动。

3.2 完整可运行的 EAR 检测代码与逐行参数说明

import dlib import cv2 import numpy as np detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") def eye_aspect_ratio(eye_points): # eye_points 是由6个 (x, y) 坐标组成的数组,顺序为外眼角到内眼角环绕 p2_p6 = np.linalg.norm(eye_points[1] - eye_points[5]) p3_p5 = np.linalg.norm(eye_points[2] - eye_points[4]) p1_p4 = np.linalg.norm(eye_points[0] - eye_points[3]) ear = (p2_p6 + p3_p5) / (2.0 * p1_p4) return ear cap = cv2.VideoCapture(0) frame_count = 0 ear_sum = 0.0 while True: ret, frame = cap.read() if not ret: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = detector(gray, 0) for face in faces: shape = predictor(gray, face) left_eye = [] right_eye = [] for i in range(36, 42): pt = shape.part(i) left_eye.append((pt.x, pt.y)) for i in range(42, 48): pt = shape.part(i) right_eye.append((pt.x, pt.y)) left_ear = eye_aspect_ratio(np.array(left_eye, dtype=np.float64)) right_ear = eye_aspect_ratio(np.array(right_eye, dtype=np.float64)) ear = (left_ear + right_ear) / 2.0 cv2.putText(frame, f"EAR: {ear:.2f}", (30, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.imshow("Fatigue Detection", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()

eye_aspect_ratio函数里eye_points[0]是外眼角、eye_points[3]是内眼角,这是因为 dlib 输出顺序固定为顺时针环绕眼睛,索引 0 对应 36 外眼角,索引 3 对应 39 内眼角。np.linalg.norm计算两点间欧氏距离,p2_p6对应上眼睑点 37 到下眼睑点 41 的距离,p3_p5对应 38 到 40 的距离,这两个都是垂直方向。p1_p4是水平方向的内外眼角距,乘以 2 是为了让睁眼时的 EAR 值落在 0.25~0.35 这个便于比较的区间。

实时视频流里我一般直接取相邻帧的 EAR 值做均值滤波,比如滑动窗口取最近 5 帧的平均,可以有效抑制单帧抖动。但注意窗口别太大,否则闭眼瞬间的 EAR 变化会被平滑掉,导致漏检。

3.3 EAR 阈值与连续帧判定:论文流程的两处关键参数

论文的判定流程是:EAR 小于闭眼阈值则计数器加一,否则计数器清零;当连续帧数大于疲劳阈值时发出警告。这里有两个参数需要标定,一个是闭眼阈值,另一个是疲劳阈值即连续多少帧判定为疲劳。

闭眼阈值我一般取 0.2~0.25 之间。太大会把正常眨眼误判为闭眼,太小则闭眼不彻底时检不出来。论文提到"眨眼时眼睛的宽度会迅速下降到零",但实际视频流里受帧率和运动模糊影响,EAR 很难降到零,通常闭眼瞬间 EAR 在 0.1 左右,睁眼在 0.3 左右,所以阈值取 0.2 是比较稳妥的分界线。疲劳阈值和帧率直接相关:30fps 下闭眼 0.5 秒就是 15 帧,所以连续帧阈值取 15~20 比较合理。如果摄像头帧率只有 15fps,同等闭眼时长对应帧数减半,疲劳阈值要相应下调。

4. 从视频流到疲劳报警:系统实现的完整流程与边界条件

4.1 六步处理流程的代码化落地

论文 3.1 节给出的流程可以概括为:灰度转换、加载检测器、检测人脸、提取眼部特征点、计算 EAR、阈值比较与计数器累加。用代码把这几步串起来,加上报警逻辑,就是一个最小可用的疲劳检测系统。

import dlib import cv2 import numpy as np detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") EYE_AR_THRESH = 0.20 EYE_AR_CONS_FRAMES = 15 frame_counter = 0 alarm_on = False def eye_aspect_ratio(eye): A = np.linalg.norm(eye[1] - eye[5]) B = np.linalg.norm(eye[2] - eye[4]) C = np.linalg.norm(eye[0] - eye[3]) return (A + B) / (2.0 * C) cap = cv2.VideoCapture("driver_video.mp4") while True: ret, frame = cap.read() if not ret: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces = detector(gray, 0) if len(faces) == 0: cv2.putText(frame, "No face", (30, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) for face in faces: shape = predictor(gray, face) left_eye = np.array([(shape.part(i).x, shape.part(i).y) for i in range(36, 42)], dtype=np.float64) right_eye = np.array([(shape.part(i).x, shape.part(i).y) for i in range(42, 48)], dtype=np.float64) ear = (eye_aspect_ratio(left_eye) + eye_aspect_ratio(right_eye)) / 2.0 if ear < EYE_AR_THRESH: frame_counter += 1 if frame_counter >= EYE_AR_CONS_FRAMES: alarm_on = True cv2.putText(frame, "FATIGUE ALERT!", (100, 100), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 0, 255), 3) else: frame_counter = 0 alarm_on = False cv2.putText(frame, f"EAR: {ear:.2f} Frames: {frame_counter}", (30, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.imshow("Fatigue Detection", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()

EYE_AR_THRESH和EYE_AR_CONS_FRAMES分别对应论文中的闭眼阈值和疲劳阈值。计数器逻辑是关键:每帧 EAR 小于阈值就累加,直到达到疲劳阈值才报警,这样一次快速眨眼只有三四帧低于阈值,不会触发报警;而真正打瞌睡时闭眼会持续半秒以上,必然突破连续帧阈值。这里报警只做了画面提示,实际车载系统可以接蜂鸣器或方向盘震动模块。

4.2 接口与硬件设计:论文图 4 的实际含义

论文 3.3 节提到接口设计,虽然没有给出详细电路图,但从系统架构能推断出基本的硬件链路:USB 摄像头采集模拟视频信号,通过 USB 接口传入车载工控机或嵌入式板卡,板卡上的 OpenCV/dlib 处理完帧数据后,通过 GPIO 或串口输出报警信号给蜂鸣器或震动座椅。我重新搭过一套类似的,用的树莓派 4B 加 USB 摄像头,处理 640x480 分辨率视频能达到 20fps 左右,EAR 计算耗时约 4ms,瓶颈在 dlib 的人脸检测器上。

嵌入式部署有一个容易被忽视的问题:dlib 模型文件约 60MB,加载进内存后树莓派这类设备的内存占用会偏高。解决办法是换用 OpenCV 的 DNN 模块加载小型人脸检测模型,或者干脆用 MediaPipe 的 Face Mesh,它在嵌入式设备上更轻量。但论文场景下的核心算法逻辑是通用的,换检测器不影响 EAR 计算与疲劳判定部分。

4.3 论文没提但必须考虑的:多人脸、侧脸与遮挡

论文实验环境是单人正对摄像头,实际驾驶场景会出现:副驾驶有人、司机转头、手部遮挡面部。dlib 检测器返回的faces是一个列表,代码里for face in faces会遍历所有人脸,如果副驾人脸更靠近镜头,可能先被处理并占用 EAR 计算资源。需要加一个人脸排序逻辑,取画面中面积最大的脸作为驾驶员。

侧脸场景更麻烦:当偏航角超过 30 度时,左右眼特征点会重叠,计算出的 EAR 值变得极小,可能持续低于阈值,系统误报疲劳。处理方法是用人脸姿态估计筛选有效帧,我一般用 OpenCV 的solvePnP配合 68 点中的鼻尖、下巴、左右眼角做姿态解算,偏航角超过 30 度的帧直接跳过不参与 EAR 统计。

5. 避坑与常见问题:我用这份论文方案时踩过的五个坑

5.1 帧率不匹配导致连续帧阈值失效

现象:按论文参数设置EYE_AR_CONS_FRAMES = 15,在低帧率摄像头下正常眨眼都被报警。

原因:15 帧这个数字只有在 30fps 下才对应闭眼 0.5 秒。15fps 摄像头下 15 帧已经是闭眼 1 秒了,反而容易漏报;如果帧率只有 10fps,15 帧对应 1.5 秒,漏报更严重。反之帧率 60fps 时 15 帧仅对应 0.25 秒,正常眨眼就可能触发报警。

解决:处理前先读取cap.get(cv2.CAP_PROP_FPS),把连续帧阈值换算成时间,按int(fps * 0.5)方式动态设置。从那以后我每次接入新摄像头,第一件事就是打印帧率,再决定阈值取多少。

5.2 光照突变导致 EAR 曲线出现断崖式下跌

现象:车辆驶出隧道瞬间,画面亮度骤变,EAR 值突然从 0.3 掉到 0.1,触发报警。

原因:dlib 关键点检测对光照变化敏感,光线突变时眼睑特征点回归不稳定,上下眼睑点可能收敛到同一位置,导致分子趋近于零。

解决:对 EAR 序列做低通滤波,我用的是滑动窗口均值,窗口大小取 5~7 帧。光照突变引起的异常 EAR 通常只持续一两帧,均值滤波后会被平抑掉。同时可以检测画面整体亮度变化率,变化超过 30% 时短暂冻结疲劳判定,等光线稳定后再恢复。

5.3 眼镜反光让上眼睑点漂移

现象:佩戴反光较强的镜片时,左眼 EAR 正常但右眼 EAR 长期偏低,系统频繁报警。

原因:眼镜片反射的灯光或阳光会在眼周区域形成高光,HOG 特征提取时高光区域梯度极大,关键点回归被高光吸引,上眼睑点被拉到镜片反光位置。

解决:先用 Haar 检测眼镜区域,或者对眼部 ROI 做直方图均衡化,压低高光影响。更简单的做法是直接提高闭眼阈值到 0.22,并加大连续帧阈值到 20,牺牲一点灵敏度换取稳定性。论文没提眼镜问题,但在中国驾驶员里戴眼镜的比例很高,这一条不做肯定翻车。

5.4 人脸检测器在低分辨率下漏检

现象:摄像头分辨率设为 320x240 时,人脸稍远就检测不到,程序直接输出"No face"。

原因:dlib 的 HOG 人脸检测器要求人脸区域至少 80x80 像素,320x240 画面中人的头部可能只有 60x60 像素。

解决:把detector(gray, 0)改成detector(gray, 1),开启一次图像金字塔上采样,相当于把图像放大一倍再检测,小脸也能框出来。代价是每帧耗时从约 15ms 涨到约 30ms。另一个方案是缩小输出画面但不缩小处理画面,即摄像头采集 640x480,检测时用完整分辨率,显示时才缩放。

5.5 连续帧计数器没有设置上限

现象:司机确实闭眼睡着了,系统报警一次后,EAR 一直低于阈值,计数器持续累加,报警逻辑反复触发。

原因:计数器无上限时,闭眼 10 秒和闭眼 1 秒的最终结果一样,都是超过阈值后报警,但无法区分轻微疲劳和深度睡眠。

解决:为计数器设置一个最大值,比如frame_counter = min(frame_counter, EYE_AR_CONS_FRAMES + 30),达到最大值后保持报警状态但不再累加。更进阶的做法是分两级判定:连续 15 帧触发一级警告,连续 40 帧触发二级强警告,对应论文里"闭眼或眯眼时间过长"的表述。

6. 进阶:把 PERCLOS 指标引入系统,替换简单的连续帧计数

论文用连续帧计数判断疲劳,这在工程上过于粗暴。真实驾驶场景中,司机可能不会一次性闭眼超过 0.5 秒,而是频繁出现微闭眼,每次只持续 0.2~0.3 秒。这类情况用连续帧阈值完全检测不到。我在论文方案基础上引入 PERCLOS 指标,即单位时间内眼睛闭合时间占比,这是交通心理学研究里公认的疲劳度量。

具体做法是计算最近 60 秒内眼睛闭合帧数占总帧数的比例。统计窗口用 60 秒,闭眼帧定义为 EAR 小于 0.2 的帧。当 PERCLOS 超过 0.15,即每分钟闭眼累计 9 秒以上,判定为疲劳状态。这个指标对频繁短闭眼更敏感,因为它是统计量而不是连续量。实现上只需要在原有 EAR 循环里增加一个环形缓冲区:

from collections import deque ear_history = deque(maxlen=1800) # 30fps下60秒的帧数 closed_frames = 0 window_frames = 0 while True: # 原有EAR计算逻辑... ear_history.append(ear) if len(ear_history) == 1800: closed_frames = sum(1 for e in ear_history if e < 0.20) perclos = closed_frames / 1800 if perclos > 0.15: cv2.putText(frame, "FATIGUE (PERCLOS)", (100, 100), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 3) ear_history.clear()

deque(maxlen=1800)自动丢弃最老的数据帧,保持窗口长度为 60 秒,省去手动管理列表头尾的麻烦。perclos > 0.15这个阈值参考了驾驶疲劳研究的常模,但实际使用时需要根据目标人群微调:经常熬夜的司机的正常闭眼频率本身就高,阈值要放宽到 0.18 左右。

验证这个进阶方案是否有效,你可以录制一段 5 分钟视频,前 3 分钟正常开车状态,后 2 分钟模拟频繁短闭眼。用原有连续帧逻辑大概率报不出警,换 PERCLOS 后能稳定检出。从那以后我做人脸疲劳检测,默认就是连续帧阈值加 PERCLOS 双通道并行判定,单一指标太容易出边界 case。希望帮到你。

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

返回列表