☰
Python人脸识别疲劳检测源码:EAR/MAR与PERCLOS实战
2026/11/2 15:45:40 网站建设 项目流程

简介:这份源码面向计算机与软件工程专业的学生,以及正在学习Python的人,围绕驾驶员面部特征实现疲劳状态检测,可用于毕业设计选题、课程项目或人脸识别应用的二次开发。压缩包共24个文件,约68.33MB,包含5个py核心脚本、1个dat人脸特征数据文件、多个xml配置与界面布局文件,以及mp3提示音、md说明文档、docx文档与示例文本,覆盖从模型加载、摄像头读取到疲劳判定的完整流程。项目已按模块分目录组织,附带README与分支结构说明,下载解压后即可运行调试。目前已有1360人学习下载,适合理解人脸关键点检测、眨眼频率统计与报警逻辑;相关思路也可迁移到交警摄像头、高速收费站等疲劳驾驶筛查场景,帮助读者快速搭起可演示、可扩展的系统原型。

1. 摄像头前的 3 秒钟:Python 疲劳检测源码到底在算什么

第一次拿到这套 Python 人脸识别疲劳检测系统源码,我是在一段夜间行车片段上跑的:驾驶员眼皮下沉、眨眼间隔被拉长,画面里没有明显动作,单看截图很难判断困不困。这套毕业设计项目源码把问题拆成可计算的信号——人脸框、眼睑开合、嘴部张合、头部姿态和时间窗统计,最后落到报警和界面。它适合课程设计复现,也适合想摸清实时人脸检测链路的开发者。能不能用,先看它怎么把“困”变成阈值,而不是先看界面好不好看。

2. 面部特征链路拆解:从人脸框到 EAR/MAR 的四个关键参数

疲劳检测的工程链路并不神秘:先找到人脸,再拿到眼部与嘴部关键点,然后把几何比例算成 EAR、MAR,最后用时间窗统计把单帧抖动压下去。真正难的是每一环都有边界条件:侧脸、眼镜反光、光照突变、摄像头角度变化,任何一个都能让阈值瞬间失效。源码包里的检测器可以替换,但这条数据链路最好先跑通,再谈模型升级。

2.1 人脸检测与关键点选型:Haar、dlib 68 点、MediaPipe FaceMesh 怎么取舍

毕业设计场景里常见三种选型。Haar 级联检测速度快、依赖少,但侧脸和遮挡下容易丢框;dlib 的 68 点模型稳定、索引清晰,左眼 36–41、右眼 42–47、嘴部 48–67,写 EAR/MAR 很顺,缺点是安装时可能卡在编译;MediaPipe FaceMesh 关键点更密、跨平台体验好,适合实时视频,但点序和 dlib 不一样,源码里如果混用要重写索引。我的建议是:先按源码原有链路跑通,确认 EAR/MAR 曲线形态正常,再考虑替换检测后端。

方案优点常见问题适用场景
Haar + 自定义关键点依赖轻、启动快侧脸丢检、关键点粗课堂演示、低配机器
dlib 68 点索引固定、公式好写安装编译、帧率一般毕业设计、离线分析
MediaPipe FaceMesh实时性好、点多点序不同、版本差异摄像头实时、跨平台

选型不要只看跑分。离线视频回放时,dlib 的 68 点更容易对照论文公式;实时摄像头场景里,MediaPipe 的延迟往往更友好。源码包如果已经封装了检测器接口,优先保留接口,只换内部实现,别把 EAR/MAR 计算和检测库绑死。

2.2 EAR 与 MAR 的计算:公式落地时先解决尺度归一化

EAR 的本质是“眼睛纵向开合除以横向宽度”,用比值抵消人脸远近变化。dlib 左眼 6 个点按顺序取 p1 到 p6,纵向距离是 p2-p6 与 p3-p5,横向是 p1-p4。分母加一个极小值,防止正脸偏转时横向距离趋近于零导致除零。MAR 同理,但嘴部点更多,常见做法是只取左右嘴角和上下唇中点,减少嘴角抖动带来的噪声。

import numpy as np def eye_aspect_ratio(eye_points): # eye_points 为 6 个关键点,顺序按 dlib 68 点:p1..p6 p1, p2, p3, p4, p5, p6 = eye_points vertical = np.linalg.norm(p2 - p6) + np.linalg.norm(p3 - p5) horizontal = 2.0 * np.linalg.norm(p1 - p4) + 1e-6 return vertical / horizontal def mouth_aspect_ratio(mouth_points): # 取左右嘴角与上下唇中点,避免用全部 20 个点导致抖动 left, right, top, bottom = mouth_points vertical = np.linalg.norm(top - bottom) horizontal = np.linalg.norm(left - right) + 1e-6 return vertical / horizontal

这段代码只做几何比值,不包含人脸检测。eye_points和mouth_points需要由检测器先转成 NumPy 数组;如果源码里直接用 dlib 的shape.part(i),要先做坐标提取。EAR 的经验阈值常见在 0.20–0.25,MAR 在 0.5–0.7,但这两个数不是 universal 的:摄像头高度、镜头畸变、受试者眼型都会让基线偏移。我一般会先录一段清醒片段,统计 EAR 的 5% 分位数,再录一段疲劳片段统计 95% 分位数,两个分位数之间的空隙才是阈值候选区。

提示:左右眼要分别算再取平均,不要只算一只眼。单眼被头发或方向盘遮挡时,双眼平均能撑一下,单眼会直接把噪声送进时间窗。

2.3 PERCLOS 与眨眼频率:把单帧判断塞进时间窗

单帧 EAR 低于阈值只能说明“这一帧眼睛闭着”,不能说明疲劳。更稳的做法是维护一个时间窗,统计闭眼帧占比,也就是 PERCLOS 的简化版。窗口长度常见 30–60 秒,阈值 0.3–0.4;同时记录连续闭眼时长,连续 2–3 秒以上直接升到高等级报警。离线回放时时间戳要用视频时间frame_idx / fps,不要用墙钟时间,否则视频解码快慢会把统计带偏。

from collections import deque import time class FatigueWindow: def __init__(self, window_sec=30, ear_thresh=0.22, mar_thresh=0.60): self.window_sec = window_sec self.ear_thresh = ear_thresh self.mar_thresh = mar_thresh self.samples = deque() # (timestamp, is_closed, is_yawn) self.eye_closed_since = None self.last_alarm = 0 def update(self, ear, mar, now=None): now = now or time.time() closed = ear < self.ear_thresh yawn = mar > self.mar_thresh self.samples.append((now, closed, yawn)) while self.samples and now - self.samples[0][0] > self.window_sec: self.samples.popleft() if closed and self.eye_closed_since is None: self.eye_closed_since = now if not closed: self.eye_closed_since = None total = len(self.samples) closed_ratio = sum(1 for _, c, _ in self.samples if c) / max(total, 1) yawn_ratio = sum(1 for _, _, y in self.samples if y) / max(total, 1) continuous_closed = 0 if self.eye_closed_since is None else now - self.eye_closed_since return { "perclos": closed_ratio, "yawn_ratio": yawn_ratio, "continuous_closed_sec": continuous_closed, "total_samples": total, }

window_sec控制统计平滑程度,窗口太短会敏感、太长会迟钝;ear_thresh和mar_thresh是帧级判定;continuous_closed_sec是连续闭眼时长,适合做二级报警。total_samples很重要,窗口刚启动时样本太少,PERCLOS 会虚高,源码里最好加一个最小样本数,比如 30 帧或 1 秒后再参与报警判断。这个类不负责画框和响铃,只产出统计值,界面层拿值做展示和决策,职责清楚后排查会轻松很多。

3. 把源码跑起来:环境、目录、摄像头与视频流三种入口

源码包能不能用,第一步不是改代码,而是把入口跑通。常见目录会按入口、检测器、工具、配置分开,先找主程序,再看它读的是摄像头还是视频文件。摄像头入口适合现场演示,离线视频入口适合复现和调阈值。我的习惯是先把离线视频跑通,日志能落盘,再切摄像头;反过来做,现场光线和角度会把你带进玄学调参。

3.1 依赖安装与版本锁定:先让 dlib 编译过,再谈检测

Python 侧常见依赖是 OpenCV、NumPy、imutils、dlib,界面可能是 PyQt 或 Tkinter。dlib 在 Windows 上最容易翻车,报错通常不是 Python 代码问题,而是缺少 CMake 或 C++ 编译工具链。先建虚拟环境,把依赖装在一个干净环境里,避免和系统里的 OpenCV 冲突。

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate python -m pip install --upgrade pip pip install opencv-python numpy imutils pip install dlib # 如果 dlib 编译失败,先确认 cmake 和编译器可用,再考虑 conda 安装

这段命令只负责环境,不涉及源码改动。python -m venv建隔离环境;激活脚本按系统切换;opencv-python提供摄像头和视频读写;dlib提供 68 点模型。dlib 安装失败时先看错误里有没有CMake、Visual Studio或gcc字样,不要上来就换 Python 版本。源码里如果带requirements.txt,以它为准,别自己叠版本。

依赖作用常见问题处理思路
OpenCV读视频、画框、显示摄像头打不开、编码缺失换后端、确认索引
dlib人脸检测与 68 点编译失败、模型路径错装编译工具、检查路径
NumPy坐标与比值计算数组维度不匹配打印 shape 定位
imutils图像缩放与工具版本差异导致导入失败按源码要求装

3.2 主程序参数与配置文件:--source、阈值、窗口一次讲清

很多毕业设计源码把参数写死在文件顶部,调一次改一次,容易把实验记录弄丢。更稳的做法是用argparse收参数,配置文件只放默认值。下面这个入口把视频源、分辨率、EAR/MAR 阈值、PERCLOS 窗口和报警冷却都暴露出来,离线回放和摄像头共用一套逻辑。

import argparse def build_parser(): p = argparse.ArgumentParser(description="驾驶员疲劳检测入口") p.add_argument("--source", default="0", help="摄像头索引或视频文件路径") p.add_argument("--width", type=int, default=640) p.add_argument("--height", type=int, default=480) p.add_argument("--ear-thresh", type=float, default=0.22) p.add_argument("--mar-thresh", type=float, default=0.62) p.add_argument("--perclos-window", type=float, default=30.0) p.add_argument("--perclos-thresh", type=float, default=0.35) p.add_argument("--closed-sec", type=float, default=2.5) p.add_argument("--alarm-cooldown", type=float, default=8.0) p.add_argument("--display", action="store_true", help="是否弹出预览窗口") return p

--source传0表示默认摄像头,传文件路径表示离线视频;--width和--height影响检测速度和画面细节,低配机器先降到 640×480;--ear-thresh与--mar-thresh是帧级阈值;--perclos-window是统计窗口秒数;--perclos-thresh是窗口报警线;--closed-sec是连续闭眼时长;--alarm-cooldown防止报警连环响。参数暴露出来以后,调参不用改源码,实验记录也能写清楚。

注意:摄像头索引不一定是 0。笔记本外接摄像头、虚拟摄像头都会占索引,打不开时先试 1、2,再看系统设备列表。

3.3 离线视频回放与结果落盘:验证检测逻辑的最短路径

离线回放的价值是可重复。同一段视频,今天跑和明天跑应该得到同一组 EAR/MAR 曲线。把逐帧结果写进 CSV,后面画图、算误报、对比阈值都方便。下面这段只保留主循环,检测器用detector.step(frame)表示,源码里换成实际类即可。

import cv2 import csv def run_offline(video_path, detector, out_csv="fatigue_log.csv"): cap = cv2.VideoCapture(video_path) if not cap.isOpened(): raise RuntimeError(f"打不开视频: {video_path}") fps = cap.get(cv2.CAP_PROP_FPS) or 25.0 frame_idx = 0 with open(out_csv, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["frame", "timestamp", "ear", "mar", "perclos", "closed_sec"]) while True: ok, frame = cap.read() if not ok: break frame_ts = frame_idx / fps # 用视频时间,不用墙钟时间 ear, mar = detector.step(frame) # 检测器内部完成人脸与关键点 stats = detector.window.update(ear, mar, now=frame_ts) writer.writerow([ frame_idx, round(frame_ts, 3), round(ear, 4), round(mar, 4), round(stats["perclos"], 4), round(stats["continuous_closed_sec"], 2), ]) frame_idx += 1 cap.release()

video_path是离线片段路径,out_csv是日志文件;fps拿不到时给 25 兜底;frame_ts保证时间窗按视频进度走;detector.step返回当前帧的 EAR 和 MAR;stats里的 PERCLOS 和连续闭眼时长直接落盘。跑完先看 CSV 前几十行,EAR 是否在 0.15–0.35 之间波动,MAR 是否在说话时明显抬升。如果 EAR 一直是一条直线,先怀疑关键点没取对,而不是阈值。

4. 界面与报警联动:PyQt/Tkinter 怎么接、报警怎么不扰民

界面不是装饰。疲劳检测的界面要同时处理视频刷新、统计值更新、报警提示和日志写入,任何一项阻塞都会让画面卡成幻灯片。常见翻车是直接在 UI 线程里while True读摄像头,主窗口转圈,用户以为程序死了。更稳的写法是把视频循环放到工作线程,UI 线程只收信号、刷控件。

4.1 界面线程与视频线程分离:别让 cv2.waitKey 卡住窗口

PyQt 里用QThread包视频循环,pyqtSignal把帧和统计值发回主线程。工作线程不碰控件,主线程不做耗时检测,两边通过信号解耦。停止时置位标志并wait,确保摄像头释放。

from PyQt5.QtCore import QThread, pyqtSignal import cv2 class VideoWorker(QThread): frame_ready = pyqtSignal(object) stats_ready = pyqtSignal(dict) def __init__(self, source, detector, parent=None): super().__init__(parent) self.source = source self.detector = detector self.running = True def run(self): cap = cv2.VideoCapture(self.source) while self.running and cap.isOpened(): ok, frame = cap.read() if not ok: break ear, mar = self.detector.step(frame) stats = self.detector.window.update(ear, mar) self.frame_ready.emit(frame) self.stats_ready.emit(stats) cap.release() def stop(self): self.running = False self.wait(1000)

source可以是摄像头索引或视频路径;detector是封装好的检测器;frame_ready发原始帧给界面显示;stats_ready发统计字典;running控制循环退出;wait(1000)给线程一秒收尾。界面层收到帧后做cvtColor和QPixmap转换,别在工作线程里操作 QLabel。Tkinter 场景同理,用队列或after轮询,不要跨线程直接改控件。

4.2 报警策略:分级阈值、冷却时间与日志留存

报警要分级。一级可以是屏幕黄色提示,二级加声音或闪烁;连续闭眼和 PERCLOS 同时满足时再升级,避免单帧噪声触发。冷却时间必须有,否则司机眨眼一次响三声,反而干扰驾驶。日志至少记录时间、EAR、MAR、PERCLOS、连续闭眼时长和报警等级,后面复盘误报全靠它。

def decide_alarm(stats, now, last_alarm, cooldown=8.0): level = 0 if stats["continuous_closed_sec"] >= 2.5: level = 2 elif stats["perclos"] >= 0.35 and stats["total_samples"] >= 30: level = 1 if level and now - last_alarm >= cooldown: return level, now return 0, last_alarm

stats来自时间窗;now是当前时间戳;last_alarm是上次报警时间;cooldown是冷却秒数。连续闭眼优先于 PERCLOS,因为它是更强信号;total_samples >= 30防止窗口刚启动时误报。日志写入建议单独开文件句柄或缓冲队列,别每帧open/close,否则磁盘 IO 会把帧率拖下去。

4.3 配置分离与可复现:把阈值从界面代码里拿出来

界面代码里最怕散落魔法数字:这里 0.22,那里 0.35,改一处忘一处。把阈值、窗口、冷却、报警声音路径放进 JSON 或 YAML,启动时读一次,界面只展示不硬编码。毕业设计答辩时也能说清楚每个参数的来源。

import json def load_config(path="config.json"): with open(path, "r", encoding="utf-8") as f: cfg = json.load(f) required = ["ear_thresh", "mar_thresh", "perclos_window", "perclos_thresh"] for key in required: if key not in cfg: raise KeyError(f"配置缺少字段: {key}") return cfg

path是配置文件路径;required是最小字段集,缺字段直接报错,别让程序带着默认值悄悄跑。配置文件里还可以放摄像头索引、分辨率、日志目录。这样换一台机器只需要改配置,不用动源码。

5. 避坑与排查:光照、眼镜、角度、误报的五个血泪记录

疲劳检测的误报很少是单一原因,通常是光照、姿态、阈值和时间窗叠在一起。下面五条是现场最容易翻车的记录,每条按现象、原因、解决写。别急着改模型,先把输入质量和时间戳对齐,很多问题会自己消失。

5.1 五条现场排查记录:现象、原因、解决

现象一:逆光下人脸框还在,EAR 却像过山车。原因是自动曝光把脸部压暗,关键点在人脸边缘漂移,EAR 分母忽大忽小。解决不是硬调阈值,而是先加补光或换侧光,再在检测前做轻度 CLAHE,限制 ROI 在人脸框内,不要全图增强。时间窗加一个中值滤波,连续三帧取中间值,能压掉一部分跳变。

现象二:戴眼镜反光,闭眼被当成眯眼。镜片反射会让眼角关键点偏移,EAR 基线整体抬高,真正闭眼时反而降不到阈值。解决是调整摄像头角度,避开正对光源;把 EAR 阈值按戴镜场景单独标定;如果源码支持,换 MediaPipe 或近红外方案。不要用一套阈值同时覆盖戴镜和不戴镜,那是给自己埋雷。

现象三:摄像头仰角太低,EAR 基线整体偏低。镜头从下往上拍,眼睑在图像里被压缩,清醒时 EAR 就接近闭眼阈值。原因是透视形变,不是司机困了。解决是固定支架,让镜头接近眼睛高度;启动时做 5 秒清醒基线采集,用相对下降比例而不是绝对阈值。源码里如果只有绝对阈值,至少加一个启动校准。

现象四:说话被当成打哈欠,MAR 频繁越线。嘴部张合和打哈欠在单帧上很像,尤其 MAR 阈值设到 0.5 时。原因是只看幅度不看持续时间。解决是 MAR 超阈值持续 1.5 秒以上才记一次哈欠,并和 EAR 联动:只有 MAR 高且 EAR 低或 PERCLOS 高时才升级报警。冷却时间也要加,别让一句话触发三次。

现象五:帧率抖动让 PERCLOS 虚高。离线视频解码快、摄像头读帧慢,如果用墙钟时间做窗口,统计会偏。更隐蔽的是跳帧:检测耗时导致实际处理帧率低于视频帧率,窗口里闭眼样本被放大。解决是统一用视频时间戳或帧索引换算时间,记录实际处理 FPS,窗口按时间而不是按帧数裁剪。压测时连续跑十分钟,看 FPS 是否稳定。

5.2 误报与漏报的快速定位表

排查顺序建议从输入到统计:先看人脸有没有框住,再看关键点是否落在眼角,再看 EAR/MAR 曲线,最后看时间窗和报警逻辑。下面这张表可以当现场清单用。

现象先看什么常见处理验证方式
频繁误报闭眼EAR 曲线是否整体偏低重标基线、检查镜头角度回放清醒片段
真闭眼不报警关键点是否偏移换检测器、加补光逐帧看 CSV
打哈欠误报多MAR 超阈值持续多久加持续时间、冷却统计误报次数
画面卡顿检测耗时、线程阻塞降分辨率、跳帧、分离线程连续跑 10 分钟
日志时间对不上用墙钟还是视频时间统一时间戳对比 frame/fps

这张表不是让你一次改所有参数。每次只动一个变量,跑同一段视频,对比 CSV 里的报警次数和人工标注。血泪经验是:先把输入质量弄稳,再谈阈值;阈值调不动时,多半是关键点已经不可信了。

6. 从能跑到能用:阈值标定、模型替换与性能压测的进阶手法

阈值标定别靠单帧截图。我一般录三段:清醒、轻微疲劳、明显闭眼,每段至少 30 秒,分别统计 EAR 的 5% 和 95% 分位数,取清醒下边界和疲劳上边界之间的空隙做候选阈值。MAR 同理,但要把说话片段单独拿出来,避免把正常嘴部动作算进哈欠。

import numpy as np awake = np.array([0.28, 0.30, 0.27, 0.29]) # 清醒片段逐帧 EAR 采样 fatigue = np.array([0.18, 0.20, 0.19, 0.21]) # 疲劳片段逐帧 EAR 采样 low_awake = np.percentile(awake, 5) # 清醒下边界 high_fatigue = np.percentile(fatigue, 95) # 疲劳上边界 print(low_awake, high_fatigue) # 两者之间的空隙就是阈值候选区

这段只是标定思路,真实数据要替换成 CSV 里读出来的列。np.percentile比均值稳,能避开极端帧;清醒和疲劳两组分布如果重叠,说明特征区分度不够,先检查关键点,不要硬调。模型替换时优先换检测后端,不动时间窗和报警逻辑;性能压测看三个数:平均 FPS、最慢帧耗时、连续十分钟内存增长。降分辨率、跳帧检测、把画框和检测分离,通常比换模型更快见效。从那以后我每次交付前都强制跑一遍离线回放和连续两小时压测,不再靠单帧截图判断。希望帮到你。

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

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

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

立即咨询