简介:基于Python的驾驶员面部特征疲劳检测系统是一款融合OpenCV、Dlib与单片机技术的毕业设计项目源码包,面向计算机视觉、嵌入式开发及智能交通方向的初学者和毕业设计学生。项目通过摄像头实时采集驾驶员面部图像,利用HOG特征检测定位眼睛、鼻子、嘴巴等关键点,并根据眼睑闭合程度和头部姿态判断疲劳状态,触发声光报警。资源包共45个文件,体积约135.75MB,包含10个Python主程序、20个XML配置文件(如Dlib模型参数)、4个IDEA工程配置、2个MP3报警提示音及2个DAT数据文件,代码结构清晰,便于二次开发。目前已有61人学习下载。读者可从中掌握Python图像处理、Dlib关键点检测、串行通信(UART/SPI)以及单片机协同控制的完整流程,同时参考工程源码理解实时系统设计中响应时间与可靠性权衡,是毕业设计实战与技能提升的实用素材。
1. 基于Python的驾驶员面部特征的疲劳检测系统源码.zip:这东西能干什么
「基于Python的驾驶员面部特征的疲劳检测系统源码.zip」这类压缩包,在免费python源码大全里非常常见。解压之后,里面通常是一套能直接接摄像头的Python工程:摄像头对准驾驶员的脸,程序先用68点关键点定位眼睛和嘴巴,再按闭眼时长、打哈欠幅度和闭眼帧占比判断是否需要报警。它解决的是长途货运、网约车场景里的疲劳预警:驾驶员不用穿戴任何设备,也不需要接管方向盘,系统只输出「该提醒一下了」这个信号。适合做毕业设计的学生、给车队做方案验证的工程师,也适合想评估视觉疲劳检测性价比的产品负责人。先说一个反直觉的结论:这类系统检测的从来不是疲劳本身,而是眼皮闭合和打哈欠这些外部表现;后续所有调参,其实都在跟这两个特征打交道。
2. 从人脸到疲劳指标:关键点、EAR/MAR与判定公式的拆解
2.1 dlib 68点关键点检测:为什么绕不开它
常见做法里,检测链路是「人脸检测 → 关键点定位 → 特征计算 → 时序判定」。OpenCV自带的Haar级联和深度学习人脸检测只给一个矩形框,框内部有没有眼睛、嘴在哪里,它不负责。要计算闭眼和打哈欠,必须拿到眼睛轮廓和嘴部轮廓的具体坐标,所以绝大多数基于面部特征的方案会用dlib的shape_predictor_68_face_landmarks.dat模型。
这个模型输出68个点,索引0到67,其中左眼是36到41,右眼是42到47,嘴部外圈集中在48到59。我把取点函数写成独立函数,方便后面直接复用:
import numpy as np def get_landmarks(gray, rect, predictor): shape = predictor(gray, rect) pts = np.zeros((68, 2), dtype="float") for i in range(68): pts[i] = (shape.part(i).x, shape.part(i).y) return ptspredictor接收灰度图和一个人脸矩形,调用一次返回全部68点。这里有个容易翻车的细节:shape.part(i).x取出来是int,如果直接参与EAR计算,除法结果的精度会差;先转成float数组,处理起来干净得多。另外dlib的rect是dlib.rectangle,不能直接当OpenCV的tuple来切片,否则后面画框时会报类型错误。
2.2 EAR和MAR:两个结构相同的公式,三种经典误判
EAR(Eye Aspect Ratio)衡量的是眼睛睁开程度,公式是垂直距离的均值除以水平距离。左眼和右眼各算一次再取平均,就是当前帧的EAR:
def eye_aspect_ratio(eye_points): # 传入6个点:左眼或右眼的dlib关键点坐标 p1, p2, p3, p4, p5, p6 = eye_points.astype("float") v1 = np.linalg.norm(p2 - p6) v2 = np.linalg.norm(p3 - p5) h = np.linalg.norm(p1 - p4) if h < 1e-6: return 0.0 return (v1 + v2) / (2.0 * h)睁眼时EAR一般在0.25到0.35,闭眼时会掉到0.05到0.15。这个比值对图像分辨率不敏感,所以摄像头距离变化不会直接摧毁阈值;但不同人眼型差异大,有人天生眼睛细长,睁眼EAR可能只有0.22,这也是后面要做标定的根本原因。
MAR(Mouth Aspect Ratio)的结构和EAR几乎一样,只是把点组换成了嘴部外圈轮廓:
def mouth_aspect_ratio(mouth_points): # mouth_points 是 pts[48:60] 的切片,按dlib官方68点顺序取 p1 = mouth_points[0] # 48 左侧嘴角区域 p4 = mouth_points[6] # 54 右侧嘴角区域 p2 = mouth_points[3] # 51 上唇 p3 = mouth_points[4] # 52 上唇内侧 p5 = mouth_points[8] # 56 下唇内侧 p6 = mouth_points[9] # 57 下唇 v1 = np.linalg.norm(p2 - p6) v2 = np.linalg.norm(p3 - p5) h = np.linalg.norm(p1 - p4) return (v1 + v2) / (2.0 * h)嘴部索引在不同源码里经常不一致,跑通第一步应该是打印几个嘴部点的坐标,确认它们确实落在嘴角和上下唇边缘,而不是直接信网上抄的编号。嘴巴闭合时MAR大约0.1到0.2,打哈欠时能到0.4以上。口罩场景会让这组点完全失真,后面第四章会讲怎么关掉这一路信号。
2.3 从单帧指标到疲劳判定:连续帧和PERCLOS
单帧的EAR低没有意义,因为正常眨眼也有闭眼帧。业界最常见的做法是两套规则叠加:第一套是连续帧判定,EAR低于阈值且持续超过一定帧数才报疲劳;第二套是PERCLOS,统计一个滑动窗口内闭眼帧的占比。
from collections import deque def perclos(closed_flags, window): # closed_flags 是每帧产生的布尔值,True表示该帧闭眼 if len(closed_flags) < window: return 0.0 recent = closed_flags[-window:] return sum(recent) / len(recent)这里有几个容易忽略的边界:closed_flags建议用deque(maxlen=window),否则跑几十分钟内存就涨下去;滑动窗口未填满时直接返回0,避免刚启动时把司机正常眨眼误判成疲劳。PERCLOS窗口长度一般取5秒,阈值常设在0.4,含义是5秒里有40%以上时间眼皮处于闭合状态,就认为驾驶员进入危险状态。
2.4 三种信号怎么组合才不误报
实际源码里最常见的是「EAR连续帧阈值 + PERCLOS + MAR打哈欠」三路并联:任一路超限就输出疲劳。但并联会放大误报,尤其MAR在说话、唱歌、咀嚼时都会波动。
我一般会把判定优先级调成:PERCLOS优先,因为它本身已经带时间窗口,抗单帧噪声最强;EAR连续帧作为辅助,专门抓那种「眼睛半闭但还没完全闭合」的疲劳状态;MAR只做加分项,不单独触发最终报警,而是当MAR偏高时把PERCLOS的阈值从0.4放宽到0.35,让系统更敏感。这个组合思路会贯穿后面所有参数调整。
3. 跑通源码的最小环境:安装顺序、模型文件与主循环
3.1 环境准备:python安装和dlib依赖的先后顺序
先处理环境。如果你还没装Python,按官方python安装教程装64位版本,推荐3.8或3.10,不建议一上来就装最新版,因为很多从源码集锦里打包下来的项目,依赖列表还没有适配新Python。装完Python后,用虚拟环境装依赖是底线操作:
python -m venv .venv source .venv/bin/activate # Windows下执行 .venv\Scripts\activate pip install opencv-python dlib numpy在Windows上装dlib容易卡在编译环节,它的前置条件是cmake和VS Build Tools。用pycharm配置python环境时,记得让项目指向这个虚拟环境,而不是全局解释器。很多下载源码的人都栽在同一个地方:dlib在全局环境里装了好几次,换台机器或者换项目后直接ModuleNotFoundError,因为全局环境里装了太多互相冲突的包版本。
如果目标设备是嵌入式Linux,编译dlib会更折腾;常见替代是OpenCV DNN配合一个独立的关键点ONNX模型,但本文标题这类源码默认就是dlib,所以先按dlib这条线走通,后续再谈替换。
3.2 模型文件放哪里:整个项目最容易卡住的黑匣子
dlib的人脸检测器和关键点模型是分开的:检测器内置于dlib库,关键点模型却是一个独立的dat文件。源码里通常只写了怎么调用,并没有自带这个文件。
注意:模型文件不会跟着pip安装进来,必须手动放到项目目录。先去源码的README里找下载说明,没有说明就把文件名固定为shape_predictor_68_face_landmarks.dat,放到models目录下。
项目目录我一般这样组织:
- 项目根目录
- main.py
- models/shape_predictor_68_face_landmarks.dat
- requirements.txt
代码里不要写死绝对路径,用相对路径定位:
from pathlib import Path model_file = Path(__file__).parent / "models" / "shape_predictor_68_face_landmarks.dat" if not model_file.exists(): raise FileNotFoundError(f"模型文件不存在: {model_file}")Path(__file__).parent取的是当前源码文件所在目录,不管项目被拷贝到哪台机器都能找到模型。提前做存在性检查,比等dlib内部报错然后傻眼要直观得多。
3.3 摄像头实时检测的最小主循环
把第2章的函数和模型加载拼起来,就是一个能跑的实时检测程序:
import cv2 import dlib import numpy as np from collections import deque from pathlib import Path model_file = Path(__file__).parent / "models" / "shape_predictor_68_face_landmarks.dat" detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor(str(model_file)) LEFT_EYE = [36, 37, 38, 39, 40, 41] RIGHT_EYE = [42, 43, 44, 45, 46, 47] def eye_aspect_ratio(eye_points): p1, p2, p3, p4, p5, p6 = eye_points.astype("float") v1 = np.linalg.norm(p2 - p6) v2 = np.linalg.norm(p3 - p5) h = np.linalg.norm(p1 - p4) return (v1 + v2) / (2.0 * h) cap = cv2.VideoCapture(0) if not cap.isOpened(): raise SystemExit("摄像头打不开,检查权限或被其他程序占用") closed_flags = deque(maxlen=150) while True: ok, frame = cap.read() if not ok: break frame = cv2.resize(frame, (640, 480)) gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) rects = detector(gray, 0) for rect in rects: pts = np.zeros((68, 2), dtype="float") shape = predictor(gray, rect) for i in range(68): pts[i] = (shape.part(i).x, shape.part(i).y) left_ear = eye_aspect_ratio(pts[LEFT_EYE]) right_ear = eye_aspect_ratio(pts[RIGHT_EYE]) ear = (left_ear + right_ear) / 2.0 closed_flags.append(ear < 0.22) if len(closed_flags) >= 30: pclos = sum(closed_flags) / len(closed_flags) if pclos > 0.4: cv2.putText(frame, "FATIGUE", (30, 60), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 0, 255), 3) cv2.rectangle(frame, (rect.left(), rect.top()), (rect.right(), rect.bottom()), (0, 255, 0), 2) cv2.imshow("driver fatigue monitor", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()detector(gray, 0)的第二个参数是上采样次数,0表示在原始分辨率上检测,1会让检测更齐全但CPU负载暴涨,驾驶座这种单人场景用0即可。cv2.resize(frame, (640, 480))是保证帧率的关键,1080p直接喂给dlib,普通CPU会卡到没法看。闭眼判断写在检测循环内部,每帧把ear < 0.22这个布尔值追加到deque,长度超过30帧后才有perclos输出,前30帧不参与判断。
3.4 报警方式:从界面提示到声音的接入点
界面红色提示只适合开发时验证逻辑,司机不会一直盯着屏幕。真正要接报警,最简单的方式是非阻塞声音提示:
import winsound def alarm(): winsound.Beep(880, 300) winsound.Beep(660, 300)winsound只在Windows上存在,Linux和macOS要用pygame或者直接调系统的音频播放命令。报警函数不要放在检测主循环的同一线程里连续调用,否则每次报警都会把摄像头帧率拖垮;常见做法是检测到疲劳后置一个标志位,由独立线程消费这个标志位去播放声音。如果源码里是直接在循环里sleep报警,那就要注意,这个sleep会连累下一帧的读取时间。
4. 驾驶场景参数调优:分辨率、灯光、眼镜和口罩怎么设
4.1 别一上来就跑1080p:输入尺度和检测频率
很多第一次跑疲劳检测源码的人,习惯把摄像头分辨率调到最高,结果打开程序发现画面像幻灯片。dlib的人脸检测在CPU上的耗时增长很陡,720p和1080p的体验完全不在一个量级。驾驶场景不需要把车窗外的细节拍清楚,只需保证人脸区域在150像素以上。所以第一件事就是把输入缩到640x480,检测频率从「每帧检测」降到「每2到3帧检测一次」。
降频的意思是:人脸检测和关键点计算每隔几帧做一次,中间帧直接沿用最后一次得到的关键点坐标。EAR在连续几帧之间变化极小,不会因为降频漏掉闭眼状态,但CPU占用能降一半。这个优化对工控机、树莓派这类设备尤其重要,因为疲劳检测系统往往还要同时跑GPS、4G通信和视频录制。
4.2 关键点抖动:对EAR做平滑而不是对报警做平滑
dlib的关键点稳定,但逐帧看还是会有1到2像素的抖动,体现在EAR上就是0.02到0.05的小幅波动。如果阈值正好卡在波动区间,程序会像抽风一样频繁进入报警又退出报警。
对EAR本身做指数移动平均,比在报警状态上做延时要干净:
alpha = 0.3 smoothed_ear = alpha * ear + (1 - alpha) * smoothed_earalpha越大越跟手,越小越平滑。0.2到0.4算是一个安全区间。不要为了追求平滑把alpha调到0.05以下,因为闭眼动作本身只有零点几秒,过度平滑会把快速闭眼抹成一条缓降曲线,反而让连续帧判定失效。
4.3 光照变化:夜间红外和背光怎么处理
dlib的正脸检测器对灰度图的对比度敏感,夜间红外摄像头输出的虽然是灰度图,但眼睛周围容易因为红外反光出现关键点漂移。最有效的做法不是加图像增强,而是开启摄像头自带的红外补光;没有红外补光时,就要求驾驶室保留一定的基础照明。
很多源码里会加直方图均衡化来增强对比度,这个操作在夜间背景容易把噪点放大,导致人脸框时有时无。处理丢帧的方法是保存上一次有效的人脸框位置,下一帧如果没有检测到人脸,就在上一帧位置附近扩大搜索范围重新检测,而不是直接报「人脸丢失」。把这一层逻辑加进去,夜间场景的稳定性会明显提升。
4.4 戴眼镜与戴口罩:两个容易被低估的变量
戴眼镜时,镜片反光会让眼裂区域的特征被高亮,关键点会向反光边缘偏移,结果是EAR整体偏大,闭眼时EAR可能跌不透阈值,造成漏报。最直接的排查方式是把EAR实时打印出来,让司机闭眼,看闭眼瞬间EAR到底掉到多少。如果闭眼EAR还停在0.22以上,说明固定阈值在这副眼镜下无效,要么换检测参考点,要么在闭眼分布和睁眼分布的中间取阈值。
口罩场景更直接:嘴部关键点会被布料推离真实位置,MAR完全失真。戴口罩时应该关闭MAR这条信号线,只用EAR和PERCLOS。最麻烦的组合是眼镜加口罩,这时只剩眼睛一组信号,系统的容错空间很小,阈值必须严格按这个司机的历史数据来标定,不能沿用另一个人的参数。
4.5 参数表:推荐初始值与标定方法
给出这套系统里最常用的初始参数,按30fps摄像头设定:
| 参数 | 推荐初始值 | 调参依据 |
|---|---|---|
| ear_thresh | 0.22 | 标定后的睁眼均值减2倍标准差 |
| close_frames_thresh | 15帧 | 30fps下约0.5秒闭眼 |
| perclos_window | 150帧 | 5秒滑动窗口 |
| perclos_thresh | 0.4 | 窗口内闭眼占比超过40%报警 |
| mar_thresh | 0.4 | 打哈欠时MAR的通用分界 |
| up_sample | 0 | 640x480输入时不用上采样 |
| min_face_width | 120像素 | 小于该宽度不送关键点,避免误检 |
我一般会在项目里做一个20秒标定流程:司机坐在驾驶位,正常睁眼看前方10秒,统计EAR均值和标准差;然后用ear_thresh = mean - 2 * std作为该司机的阈值。闭眼2秒,记录闭眼期的EAR最低值,确认它和睁眼均值有清晰间隔。
提示:标定时让司机保持实际驾驶坐姿,不要凑近摄像头。凑近会让眼裂占比变大,标定出的阈值偏紧,真正驾驶时误报会明显增加。
5. 避坑:源码能跑但不稳定的系统性排查
5.1 现象:摄像头画面很卡,fps掉到5以下
卡顿的原因一般是三个:输入没有缩放过,无穷上采样次数被设成1或2,以及OpenCV用同步读帧的方式读取高分辨率摄像头。解决方式是先把采集分辨率用cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)和cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)压下来,再在循环里用cv2.resize做二次确认。如果摄像头支持MJPG编码,可以额外申请一下:
cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG")) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)MJPG输出的是压缩帧,带宽占用低,摄像头内部解码压力小,帧率经常比默认YUV格式高一倍。改完之后如果还卡,就把检测频率降到每3帧一次,画面流畅度会立刻改观。
5.2 现象:车里有第二个人,系统频繁乱报警
原因是所有检测到的人脸都送进了疲劳判定逻辑,副驾聊天、后座乘客动一下都会触发。解决方法是只保留一个目标:驾驶座上的人。在多人脸场景里,最简单的筛选是取面积最大的人脸框,因为驾驶员离摄像头最近,面部占据的像素面积最大:
rects = detector(gray, 0) if rects: rect = max(rects, key=lambda r: r.width() * r.height())更稳的做法是做一个「最近帧匹配」:记录上一帧的人脸框位置,这一帧在上一帧周边一定范围内找最近的框,找不到才换成最大框。这样即使副驾偶尔比司机离摄像头更近,系统也不会跳目标。
5.3 现象:正常眨眼被误判成疲劳报警
正常眨眼持续0.1到0.4秒,30fps下大约是3到12帧;疲劳状态下的闭眼通常超过0.8秒。很多源码默认写的是ear < thresh且连续3帧就报警,这等于把每一次眨眼都当成疲劳。正确做法是把连续帧阈值提到15帧以上,这已经能过滤掉绝大多数正常眨眼。
如果阈值提高了还误报,就去查PERCLOS窗口。滑动窗口太短,比如只有30帧,一次闭眼就会把PERCLOS顶到0.3以上,再加上0.4的判定线,眨眼和疲劳的边界就被抹掉了。窗口保持150帧左右,再配合连续帧阈值,眨眼这种短时信号基本进不了报警状态。
5.4 现象:戴眼镜的司机闭眼检测不到,漏报严重
镜片反光造成EAR偏大,闭眼时EAR跌不破阈值,导致漏报。盲目把阈值调高,又会把睁眼状态误报成闭眼。解决思路是先标定,后调参:让司机闭眼几秒,打印出闭眼期EAR的实际分布;如果闭眼和睁眼两个分布仍有明显间隔,就把阈值取在两个分布交界处;如果两个分布已经重叠,说明这副眼镜反光太强,关键点本身不可信,只能改用另一只眼的数据。
具体到代码里,左眼右眼可以分别算EAR并打印,不要只看平均值。很多场景下反光只影响一侧眼睛,另一侧仍然可靠。这时可以让系统只用可信的那只眼做判定,代价是阈值要重新标定一次。
5.5 现象:打开程序就报错,模型文件找不到或依赖缺失
这个报错基本不是逻辑问题,而是路径和依赖。源码作者往往把模型文件放在自己的绝对路径下,直接分享压缩包时忘了包含models目录;接收者又习惯把源码解压后单独拿着main.py跑,于是dlib.shape_predictor("C:/Users/xxx/models/...")直接抛异常。我的习惯是在入口处做路径检查并打印出实际路径,一眼就能看出来是不是找错了地方。
from pathlib import Path model_file = Path(__file__).parent / "models" / "shape_predictor_68_face_landmarks.dat" if not model_file.exists(): raise FileNotFoundError(f"模型文件不存在: {model_file.resolve()}")另一个隐蔽问题是中文路径。项目根目录如果包含中文文件夹名称,部分旧版dlib在读取模型文件时会解析失败,表现是文件名明明对但一直报错。项目目录统一用英文字母命名,从根上避免这个玄学问题。
6. 进阶:把检测结果落盘并用离线视频重放验证阈值
6.1 先录制一段带时间戳的驾驶视频再调参
不建议直接开着系统上车实测,参数调不好时车内报警声会让人完全没法判断对错。正确流程是:先用摄像头录制一段10分钟左右的驾驶视频,覆盖正常驾驶、闭眼、打哈欠、看手机、侧脸说话这些片段,然后把检测程序改成离线模式,把每一帧的EAR、MAR、PERCLOS和判定结果写入CSV,再和视频时间轴对齐。
import csv with open("tuning_log.csv", "w", newline="") as f: writer = csv.writer(f) writer.writerow(["timestamp_ms", "frame", "ear", "mar", "perclos", "state"]) frame_idx = 0 while cap.isOpened(): ok, frame = cap.read() if not ok: break ts_ms = cap.get(cv2.CAP_PROP_POS_MSEC) ear, mar, pclos, state = process_frame(frame) writer.writerow([ts_ms, frame_idx, round(ear, 3), round(mar, 3), round(pclos, 3), state]) frame_idx += 1这里时间戳必须用cap.get(cv2.CAP_PROP_POS_MSEC),不能用系统当前时间。系统时间在视频文件里没有意义,回放时也没法和画面帧对齐;视频内时间戳是按帧位置计算的,逐帧对照时才不会错位。
6.2 回放时看误报点分布,而不是只看画面是否报警
CSV落盘后,用一个简单的回放脚本把视频按帧播放,同时读取CSV里对应帧的state,把判定为fatigue的帧画上红色边框。逐帧看过一遍,误报的规律就会浮出水面:正常睁眼但state是fatigue,多数是阈值太贴近睁眼均值;侧脸时误报,说明关键点在非正脸角度下漂移;刚眨完眼就报警,问题多半出在PERCLOS窗口太短或者连续帧阈值太低。
我第一次把这套系统装到实车上时,直接用了网上现成的固定阈值,结果夜路一路狂报,关掉报警后才发现每一条误报都能在CSV里找到对应帧。后来把每个司机都做一遍标定,用离线重放确认阈值边界,系统才算真正能交付。如果你也准备往这个方向投入,建议先别急着优化模型,把标定和回放这条链路搭起来,你会发现多数所谓检测不稳定,根源都在参数没有跟随真实场景。希望帮到你。
本文还有配套的精品资源,点击获取