简介:这份基于Python与面部特征分析的驾驶员疲劳检测系统源码,面向计算机、人工智能相关专业做毕业设计的学生,也适合想练手人脸识别与状态判别项目的开发者。项目围绕驾驶员面部关键点与疲劳状态判定展开,可用于车载监测、交警摄像头或高速收费站等场景的辅助识别思路。压缩包共24个文件,约68.33MB,包含5个py源码文件、10个xml配置、说明文档与少量音视频及数据文件,目录与运行所需素材相对完整,并附有README说明。已有1360人学习下载。读者可从中获取人脸检测、疲劳特征判断、工程配置与项目说明等完整实现,便于在此基础上改写论文方案、替换数据集或做二次开发,也能借鉴其模块划分与调试思路,减少从零搭建的时间。
1. 一个必须“当场跑起来”的选题:python 人脸识别 检测系统为什么值得做
某高校的毕业设计开题会上,A 同学被问了一个很实际的问题:这套 python 人脸识别 检测系统,能不能在答辩现场用一台普通笔记本加一个 USB 摄像头,两分钟内把“闭眼、哈欠、低头”三个状态演示清楚。疲劳检测刚好是个能同时满足“演示直观”和“算法有东西可讲”的题目,麻烦的地方在于:能找到的毕业设计项目源码大多只做到“摄像头能开出窗口、画几个点”,至于阈值从哪里来、PERCLOS 怎么统计、误报怎么压下去,往往没有交代。这份基于驾驶员面部特征的疲劳检测系统源码要解决的正是“能跑,但说不清”的问题——它把眨眼、哈欠、低头三类面部信号串成一条可调、可看、可复现的判定链路,适合要交完整毕业设计的同学,也适合需要一个能改、能扩展的视觉项目底座的从业者。
2. 需求拆解与技术选型:从一帧画面到一次疲劳判定
疲劳本身是一个主观状态,代码没法直接读。系统的第一件事,是把“困了”翻译成几个能被摄像头测出来的量,再决定用什么工具去测。
2.1 把“疲劳”翻译成三个可测量的信号
驾驶员打瞌睡时,面部会出现三个可观测的变化:眼睑闭合时间变长、打哈欠频率上升、头部逐渐下沉。对应到工程上就是三组指标。
眼睛部分用 EAR(Eye Aspect Ratio,眼睛纵横比)。人的眼睛在睁着的时候是一个近似椭圆,闭上时上下眼睑距离趋近于零,用“上下眼睑的垂直距离”除以“左右眼角的水平距离”,就得到一个与脸部远近、摄像头分辨率关系不大的归一化数值。睁眼时一般在 0.25 到 0.35 之间,闭眼时会掉到 0.15 以下,这个比例特性是它比“直接数黑色像素”更稳定的原因。
嘴部用 MAR(Mouth Aspect Ratio,嘴巴纵横比),思路和 EAR 一样,只是把上下唇的垂直距离除以嘴角宽度。打哈欠时 MAR 会显著抬高;说话、微笑也会抬高,但持续时间短,靠时长过滤即可。
头部姿态用俯仰角 pitch。低头看手机、点头打盹时 pitch 会明显偏离正视基准。这一路信号的价值在于补盲:戴墨镜、逆光导致眼睛特征点丢失时,头姿仍然可用。
再往上还有一层统计量——PERCLOS(Percentage of Closure),指一个时间窗口内眼睛处于闭合状态的时间占比。它比“连续闭眼 N 帧”更抗抖动,因为帧率波动、偶尔一两帧检测失败不会把结果带偏。这也是这类系统里最该讲清楚、最容易讲砸的一个指标。
2.2 技术选型:dlib 68 点、MediaPipe 与端到端模型怎么选
人脸关键点有三条常见路线,选型直接决定了后面代码的写法和答辩时能讲什么。
| 方案 | 关键点数量 | CPU 帧率(640×480) | 依赖体积 | 可解释性 | 适用判断 |
|---|---|---|---|---|---|
| dlib + shape_predictor_68 | 68 | 20~30 FPS | 模型文件约 99MB | 高,每个点有明确语义 | 毕业设计首选 |
| MediaPipe Face Mesh | 468 | 30 FPS 以上 | 随包安装 | 中,需自行映射点位 | 想要更流畅时的替代 |
| 端到端 CNN 分类 | 无 | 依赖模型大小 | 需训练与数据集 | 低,只能说“模型判断” | 不推荐作为主线 |
dlib 的 68 点模型最大的优势是点位编号稳定、语义清晰:36 到 41 是左眼,42 到 47 是右眼,48 到 67 是嘴部,30 是鼻尖,8 是下巴。EAR、MAR 的计算可以直接按下标取值,答辩时能指着屏幕讲“第 37 号点是上眼睑,第 41 号点是下眼睑”,这比“我用了一个深度学习模型”有说服力得多。
MediaPipe 更快,但 468 个点的编号没有 dlib 那么“教科书”,做 EAR 需要自己查索引表,反而容易在讲解环节卡住。合理的做法是以 dlib 为主干,在代码里留一个 landmark 提供者的抽象接口,把 MediaPipe 作为后备实现——这样既保证演示稳定,又能在答辩时说明“我知道有更快的方案,并且留了替换口”。
提示:dlib 在 Windows 上需要 CMake 和 C++ 编译环境,装不上是这个项目第一个卡点,环境搭建要提前一天做完,不要留到答辩前一晚。
2.3 运行环境与依赖清单
这个项目对环境的容忍度不算高,Python 版本和几个库的版本要卡住,否则会出现“dlib 装上了但 cv2 报错”这类连锁问题。下面这套组合是实测比较稳的。
# 建议 Python 3.8 ~ 3.10,3.11 以上部分轮子还没跟上 pip install opencv-python==4.8.1.78 pip install dlib==19.24.2 pip install imutils==0.5.4 pip install scipy==1.10.1 pip install numpy==1.24.3 pip install PyQt5==5.15.9 pip install pygame==2.5.0 # 报警音播放,比 playsound 稳定 pip install pandas==2.0.3 # 检测日志导出 CSVopencv-python负责采集与绘制,dlib出关键点,imutils提供坐标转换的便捷函数,scipy算欧氏距离,PyQt5做界面,pygame播报警音,pandas负责把检测记录写成 CSV。版本不用完全一致,但 dlib 和 numpy 的 ABI 有耦合,随意升级 numpy 很容易把 dlib 弄崩,这一点在第 5 章还会展开。
硬件上,普通 720P USB 摄像头足够,不需要红外或深度相机。这一点在开题时值得主动说明:方案不依赖特殊硬件,是为了让系统在真实车载或实验室环境下都能复现,而不是为了演示特意搭一个理想环境。
3. 总体架构设计:四层结构与模块职责划分
把算法堆在一个main.py里能跑,但改一个阈值就要翻三百行代码,答辩被问到“你这个模块怎么划分的”会很难看。这套源码按数据处理顺序切成四层,每层只做一件事。
3.1 四层结构与数据流
从摄像头到界面,数据依次经过采集层、特征层、判定层、交互层。每一层的输入输出都是明确的,层与层之间不互相依赖内部实现。
| 层级 | 主要职责 | 输入 | 输出 | 关键模块 |
|---|---|---|---|---|
| 采集层 | 读帧、缩放、灰度转换、丢帧控制 | 摄像头设备 | 标准尺寸 BGR 帧 | camera.py |
| 特征层 | 人脸检测、68 点定位、EAR/MAR/头姿计算 | 单帧图像 | 特征字典 | detector.py |
| 判定层 | PERCLOS 统计、评分融合、状态机 | 特征序列 | 疲劳等级 | judge.py |
| 交互层 | 视频渲染、仪表盘、报警、日志 | 等级与特征 | 界面与文件 | ui_main.py |
数据流是这样的:采集层以固定分辨率读入一帧,特征层先用 dlib 的 HOG 人脸检测器框出人脸,再把框送进 68 点预测器拿到关键点坐标,随即算出左右眼 EAR、MAR 和头部 pitch,打包成一个特征字典;判定层不关心这一帧长什么样,只吃这个字典,把当前帧推入 PERCLOS 滑窗,更新融合评分,输出“正常 / 轻度疲劳 / 重度疲劳”三档;交互层根据等级换颜色、触发声音、写日志。
这样切分带来的直接好处是可测试。判定层可以完全脱离摄像头,用一段预先录好的特征序列跑回归测试——这对答辩很关键,因为现场光线不可控,如果演示翻车,你可以立刻切到离线回放模式证明判定逻辑是对的。
3.2 目录结构与模块职责
工程目录按“配置、核心、界面、资源、数据”五块划分,每一块只放同一类东西。
fatigue_detect/ ├── config.py # 所有阈值、权重、路径集中在这里 ├── main.py # 程序入口,只负责组装和启动 ├── core/ │ ├── camera.py # 采集层:读帧、分辨率统一 │ ├── detector.py # 特征层:人脸检测 + 68 点 + EAR/MAR/头姿 │ ├── judge.py # 判定层:PERCLOS + 融合评分 + 状态机 │ └── logger.py # 检测记录落盘为 CSV ├── ui/ │ ├── ui_main.py # 主窗口、视频区、仪表盘 │ └── widgets.py # 自定义控件(进度条、状态灯) ├── assets/ │ ├── shape_predictor_68_face_landmarks.dat │ └── alarm.wav # 报警提示音 └── data/ └── records/ # 每次运行的检测日志把config.py单独抽出来,是这套源码里最值得抄的一个细节。所有阈值、权重、窗口长度都放在一个文件里,调参时不碰业务代码,答辩时也能直接打开给老师看“我的参数是这么定的”。
3.3 配置项与阈值参数表
配置集中管理之后,参数的含义要写清楚,否则过两周自己都忘了EAR_CLOSED到底是闭眼阈值还是睁眼基准。
# config.py 阈值与权重集中配置 class Config: # 采集 CAMERA_ID = 0 FRAME_WIDTH = 640 FRAME_HEIGHT = 480 DETECT_EVERY_N = 2 # 每 2 帧做一次关键点检测,其余帧复用上次结果 # 眼睛 EAR_CLOSED = 0.21 # 低于此值判定为闭眼 EAR_RANGE = 0.10 # 评分归一化区间,EAR 越低分数越高 EYE_CLOSED_FRAMES = 3 # 连续闭眼帧数达到该值才开始计时 # 嘴部 MAR_YAWN = 0.55 # 高于此值判定为哈欠候选 MAR_RANGE = 0.25 # 头部姿态 PITCH_BASE = 0.0 # 正视时的 pitch 基准,需现场标定 PITCH_DOWN = 18.0 # 低于基准 18 度判定为低头 PITCH_RANGE = 20.0 # PERCLOS PERCLOS_WINDOW = 30.0 # 滑窗秒数 PERCLOS_SAFE = 0.15 # 低于此占比视为正常 PERCLOS_DANGER = 0.40 # 高于此占比视为重度疲劳 # 融合权重,四项之和为 1.0 W_EYE, W_PERCLOS, W_YAWN, W_HEAD = 0.35, 0.30, 0.20, 0.15 ALARM_SCORE = 0.60 # 评分超过此值触发报警| 参数 | 典型取值 | 调大后的效果 | 调小后的效果 |
|---|---|---|---|
| EAR_CLOSED | 0.21 | 更难判闭眼,漏报增加 | 误报增加,正常眨眼被算成闭眼 |
| PERCLOS_WINDOW | 30 秒 | 反应变慢,但更稳 | 反应快,抖动明显 |
| PERCLOS_DANGER | 0.40 | 报警更迟钝 | 报警更敏感,容易吵 |
| ALARM_SCORE | 0.60 | 只对明显疲劳报警 | 稍一走神就叫 |
| DETECT_EVERY_N | 2 | 更省 CPU,特征更新变慢 | 更流畅,CPU 占用上升 |
这份表的用法是:先按默认值跑通,再拿自己录的几段视频做标定,把 EAR_CLOSED 调到“正常睁眼不触发、闭眼能触发”的位置。每个人的眼型差异不小,用别人的阈值直接上,翻车概率很高。
4. 关键算法实现:EAR、MAR、头姿与评分融合
这一章是整套源码的核心,四个计算环节加一个状态机。代码都不长,但每个参数背后都有取舍。
4.1 EAR 计算与闭眼判定
dlib 返回的 68 个点是按顺序排列的,左右眼各 6 个点,位置固定:左眼 36–41,右眼 42–47,且 0 号和 3 号是左右眼角,1、2 是上眼睑,4、5 是下眼睑。
import numpy as np from scipy.spatial import distance as dist def eye_aspect_ratio(eye_points: np.ndarray) -> float: """计算单只眼睛的纵横比。 eye_points: (6, 2) 数组,顺序为 [左眼角, 上眼睑1, 上眼睑2, 右眼角, 下眼睑2, 下眼睑1] """ # 两组垂直距离:上眼睑到对应下眼睑 v1 = dist.euclidean(eye_points[1], eye_points[5]) v2 = dist.euclidean(eye_points[2], eye_points[4]) # 水平距离作为归一化基准,消除人脸远近影响 h = dist.euclidean(eye_points[0], eye_points[3]) if h < 1e-6: # 防止侧脸导致水平距离为 0 时除零 return 0.0 return (v1 + v2) / (2.0 * h) def both_eyes_ear(landmarks: np.ndarray) -> float: """取左右眼 EAR 的平均值,单眼被遮挡时另一只眼仍可支撑判定""" left = eye_aspect_ratio(landmarks[36:42]) right = eye_aspect_ratio(landmarks[42:48]) return (left + right) / 2.0这里有两个工程细节。一是加了h < 1e-6的保护,侧脸或关键点漂移时水平距离可能退化为零,没有这行会直接抛除零异常,程序在演示中途崩掉。二是取双眼平均而不是取最小,因为戴单边眼镜、单侧逆光时单眼数据会失真,平均能压住一部分噪声;代价是双眼同时闭合时数值依然会掉下去,不影响主要判断。
闭眼判定的第一层是阈值比较,但只看单帧阈值不够——人正常眨眼一次大约 100 到 400 毫秒,在 25 FPS 下就是 3 到 10 帧,闭眼帧数会短暂低于阈值。所以需要一个最小连续帧数作为门限,低于 3 帧视为正常眨眼,不计入疲劳。
class EyeStateTracker: """把逐帧 EAR 转成稳定的闭眼状态,过滤正常眨眼""" def __init__(self, threshold: float, min_frames: int = 3): self.threshold = threshold self.min_frames = min_frames self.closed_count = 0 self.is_closed = False def update(self, ear: float) -> bool: if ear < self.threshold: self.closed_count += 1 # 连续闭眼达到门限才确认状态,避免眨眼误判 if self.closed_count >= self.min_frames: self.is_closed = True else: self.closed_count = 0 self.is_closed = False return self.is_closedmin_frames这个参数直接决定灵敏度。调到 1,每次眨眼都会被记录,PERCLOS 会虚高;调到 6 以上,真正的微睡眠可能被漏掉。3 是大多数 25~30 FPS 场景下的平衡点,换帧率要同步换算:帧率翻倍,这个值也应该相应调整,否则“连续 3 帧”代表的实际时长会缩短一半。
4.2 PERCLOS 滑窗统计与冷启动处理
PERCLOS 的定义是窗口内闭眼时间占比,实现上不需要记时间戳差分,只要在固定长度的滑动窗口里统计闭眼帧的比例就够了——前提是帧率相对稳定。
from collections import deque import time class PerclosCounter: """滑动时间窗内的闭眼时间占比统计""" def __init__(self, window_seconds: float = 30.0, min_samples: int = 30): self.window = deque() # 元素为 (时间戳, 是否闭眼) self.window_seconds = window_seconds self.min_samples = min_samples def update(self, is_closed: bool, ts: float | None = None) -> None: ts = ts if ts is not None else time.time() self.window.append((ts, is_closed)) # 剔除窗口外的历史帧,保证内存占用恒定 while self.window and ts - self.window[0][0] > self.window_seconds: self.window.popleft() def value(self) -> float: # 样本不足时直接返回 0,避免程序刚启动就报警 if len(self.window) < self.min_samples: return 0.0 closed = sum(1 for _, c in self.window if c) return closed / len(self.window)min_samples对应的是冷启动保护。系统刚启动时窗口是空的,如果直接按比例计算,前几帧只要有一帧闭眼,占比就是 100%,会立刻触发报警,体验非常差。加上样本下限之后,前一两秒不输出判定,等窗口填满再开始工作。
窗口长度选 30 秒而不是 60 秒,是考虑到毕业设计演示的节奏:60 秒窗口意味着改变状态后要等半分钟才看得出变化,现场演示会显得系统“没反应”。30 秒既能平滑抖动,又能在一次演示里看到曲线变化。真实车载场景通常会取更长窗口,这一点在答辩时可以作为“工程折中”主动说明。
4.3 MAR 哈欠检测与头部姿态估计
嘴部的点位是 48 到 67,其中 48 和 54 是左右嘴角,51、52、53 偏上唇,57、58、59 偏下唇,62、63、64、65、66、67 是下唇内圈。计算时取三组上下距离求平均,再除以嘴角宽度。
def mouth_aspect_ratio(mouth_points: np.ndarray) -> float: """嘴部纵横比,用于哈欠检测。 mouth_points: (20, 2) 数组,对应 dlib 的 48-67 号点 """ # 三组上下唇垂直距离,取平均降低单点抖动 a = dist.euclidean(mouth_points[2], mouth_points[10]) b = dist.euclidean(mouth_points[3], mouth_points[9]) c = dist.euclidean(mouth_points[4], mouth_points[8]) # 嘴角宽度作为归一化基准 d = dist.euclidean(mouth_points[0], mouth_points[6]) if d < 1e-6: return 0.0 return (a + b + c) / (3.0 * d)MAR 单独用误报率不低,因为说话、笑都会抬高数值。工程上通常再加一层时长约束:MAR 超过阈值并且持续 1.5 秒以上才算一次哈欠,短于这个时长的一律忽略。这与眨眼过滤是同一个思路——用时间维度把瞬时动作和持续性状态分开。
头部姿态需要 2D 关键点配合一个 3D 人脸模型做 solvePnP。模型点用近似成人面部比例的固定值即可,不需要精确标定。
import cv2 import numpy as np # 3D 人脸模型近似坐标(毫米),顺序须与 2D 点严格对应 MODEL_POINTS = np.array([ (0.0, 0.0, 0.0), # 鼻尖 30 (0.0, -63.6, -12.5), # 下巴 8 (-43.3, 32.7, -26.0), # 左眼角 36 (43.3, 32.7, -26.0), # 右眼角 45 (-28.9, -28.9, -24.1), # 左嘴角 48 (28.9, -28.9, -24.1), # 右嘴角 54 ], dtype=np.float64) LANDMARK_IDX = [30, 8, 36, 45, 48, 54] def head_pitch(landmarks: np.ndarray, frame_shape: tuple) -> float: """估算头部俯仰角,正值表示低头""" h, w = frame_shape[:2] focal = float(w) # 用画面宽度近似焦距,够用且免标定 cam_matrix = np.array([[focal, 0, w / 2], [0, focal, h / 2], [0, 0, 1]], dtype=np.float64) dist_coeffs = np.zeros((4, 1)) image_points = np.array([landmarks[i] for i in LANDMARK_IDX], dtype=np.float64) ok, rvec, _ = cv2.solvePnP(MODEL_POINTS, image_points, cam_matrix, dist_coeffs, flags=cv2.SOLVEPNP_ITERATIVE) if not ok: return 0.0 rmat, _ = cv2.Rodrigues(rvec) # 旋转矩阵转欧拉角,取俯仰分量 pitch = np.degrees(np.arctan2(-rmat[2][1], rmat[2][2])) return float(pitch)这套估算的绝对值不一定准,因为焦距是近似的、人脸比例也有个体差异。但疲劳检测关心的是相对变化:先让测试者正视摄像头几秒,记录此时 pitch 作为PITCH_BASE,后续与该基准比较,低头超过 18 度就计为低头帧。用相对量代替绝对量,是这类无标定场景下的常规做法。
注意:solvePnP 对关键点噪声很敏感,脸部快速转动时会跳变,建议对 pitch 做 5 帧滑动平均再送进判定层,否则低头计数会虚高。
4.4 融合评分与报警状态机
四个特征各自能说明一部分问题,但也都有失效场景。融合的方式不是简单投票,而是把每个特征归一化到 0 到 1 的“疲劳贡献度”,再按权重加权求和。
def clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float: return max(lo, min(hi, x)) def fused_score(ear: float, mar: float, pitch: float, perclos: float, cfg) -> float: """四项特征归一化后加权,输出 0~1 的疲劳评分""" # 闭眼程度:EAR 距闭眼阈值越远,说明闭得越实 s_eye = clamp((cfg.EAR_CLOSED - ear) / cfg.EAR_RANGE) # PERCLOS 落在安全线与危险线之间的部分线性映射 s_perclos = clamp((perclos - cfg.PERCLOS_SAFE) / (cfg.PERCLOS_DANGER - cfg.PERCLOS_SAFE)) # 哈欠程度 s_yawn = clamp((mar - cfg.MAR_YAWN) / cfg.MAR_RANGE) # 低头程度,相对基准计算 s_head = clamp((pitch - cfg.PITCH_BASE - cfg.PITCH_DOWN) / cfg.PITCH_RANGE) score = (cfg.W_EYE * s_eye + cfg.W_PERCLOS * s_perclos + cfg.W_YAWN * s_yawn + cfg.W_HEAD * s_head) return round(score, 3) def level_of(score: float, cfg) -> str: """三档状态判定,带 0.05 的回滞区间防止在边界反复跳变""" if score >= cfg.ALARM_SCORE: return "severe" if score >= cfg.ALARM_SCORE - 0.25: return "mild" return "normal"权重分配的逻辑值得在答辩时讲:眼睛占 0.35 最高,因为闭眼是最直接、误报最低的疲劳信号;PERCLOS 占 0.30,它是时序统计量,权重高但依赖眼睛数据,所以排第二;哈欠 0.20、低头 0.15 作为补充。四个权重之和必须是 1.0,这个约束可以在配置加载时断言检查。
回滞区间是另一个容易被忽略的细节。评分在阈值附近上下浮动时,如果没有回滞,状态会在“轻度”和“重度”之间疯狂切换,报警音响个不停。加 0.05 的缓冲,状态变化就会平滑很多。
主循环把上面这些串起来,关键是控制检测频率:
def run_loop(cap, detector, judge, ui, cfg): frame_idx = 0 last_landmarks = None while True: ok, frame = cap.read() if not ok: break frame_idx += 1 # 隔帧检测:关键点计算最耗时,没必要每帧都做 if frame_idx % cfg.DETECT_EVERY_N == 0 or last_landmarks is None: last_landmarks = detector.detect(frame) if last_landmarks is not None: ear = both_eyes_ear(last_landmarks) mar = mouth_aspect_ratio(last_landmarks[48:68]) pitch = head_pitch(last_landmarks, frame.shape) judge.update(ear, mar, pitch) # 内部维护 PERCLOS 与评分 ui.render(frame, judge.state) # 界面渲染放在判定之后DETECT_EVERY_N设为 2,意味着每两帧做一次关键点检测,中间帧复用上一次的结果。摄像头上限通常是 30 FPS,而 dlib 在普通 CPU 上跑一次 68 点大约 30~40 毫秒,不隔帧的话实际帧率会被拖到 15 FPS 以下,画面明显卡顿。隔帧之后,界面流畅度和特征更新频率取得平衡,代价是 EAR 的时间分辨率下降一半,对 PERCLOS 的影响可以忽略。
5. 避坑与常见问题排查:五个最容易翻车的地方
这套系统在实验室跑通和在现场跑通是两回事。下面五条是这类项目里出现频率最高的问题,按“现象—原因—解决”整理。
5.1 逆光或夜间画面发黑,人脸检测直接丢失
现象:白天靠窗或晚上开台灯时,画面里人明明在,但程序一直显示“未检测到人脸”,关键点一个都不出。
原因:dlib 默认的 HOG 人脸检测器对光照对比度敏感,画面整体过暗或局部过曝时,梯度特征被破坏,检测窗口直接落空。另外很多人为了提速会把图像转灰度后缩放到 320 宽,缩放过度也会让小脸丢特征。
解决:先做一次直方图均衡化再送检测,成本很低但效果明显;把检测分辨率下限卡在 480 宽,不要为了帧率一味缩小;如果环境光确实差,加一个补光或者切换到 MediaPipe 的检测后端作为兜底。代码上把检测失败次数计入日志,连续丢失超过 2 秒就提示“光照不足”,比默默不出结果要好得多。
5.2 戴眼镜反光、侧脸导致 EAR 忽高忽低
现象:测试者戴眼镜时,EAR 曲线像心电图一样上下乱跳,闭眼判定时有时无。
原因:镜片反光会让下眼睑的关键点偏移,镜框遮挡会让 37、38 号点定位到镜框边缘;侧脸超过 30 度时,一只眼睛的点位会挤在一起,水平距离趋近于零。
解决:三点措施一起上——EAR 取双眼平均而不是单眼;对 EAR 序列做 5 帧中值滤波,中值比均值更能压住孤立跳变;在检测前用头姿判断侧脸程度,偏航角超过阈值时本帧不计入 PERCLOS 统计,只显示不判定。第三点是关键,宁可少统计几帧,也不要把脏数据喂进滑窗。
5.3 帧率不稳导致 PERCLOS 失真
现象:同一段视频,在不同机器上跑出来的 PERCLOS 差了一倍,评分自然也对不上。
原因:PERCLOS 的实现按帧计数统计比例,隐含假设是帧率稳定。如果隔帧检测加 CPU 抖动,实际处理帧率从 25 掉到 12,同样的闭眼时长对应的帧数就少了一半,占比被系统性低估。
解决:滑窗里存的是时间戳而不是帧序号,统计时按时间加权而不是按帧计数,这是最稳的做法。简化版可以在程序启动时测一次实际处理帧率,把它写进日志,答辩演示前先看一眼这个数字是否正常。另外把日志导出成 CSV 时带上时间戳,事后复核才有依据。
5.4 阈值照搬别人,现场误报不断
现象:代码跑起来五分钟报了十几次警,测试者明明很清醒。
原因:EAR 的绝对值跟眼型、摄像头俯仰角度强相关。别人在特定角度下标定的 0.21,换一个人、换一个机位可能正常睁眼就只有 0.22,稍微一眨眼就跌破阈值。侧脸、戴眼镜还会把这个基线再往下拉。
解决:加一个启动标定流程——程序开始的前 5 秒进入“采集基准”状态,统计这段时间内 EAR 的中位数作为该测试者的睁眼基准,闭眼阈值取基准的 65% 左右;pitch 同理,取前几秒的均值作为正视基准。这套自标定逻辑写起来不到三十行,但能显著改善现场表现,答辩时也是一个加分点。
5.5 打包和依赖问题让程序在别人电脑上起不来
现象:自己电脑上跑得好好的,拷到答辩用的机器上,import dlib直接报错。
原因:dlib 是带 C++ 扩展的包,和 numpy 的版本、Python 的位数强绑定;PyQt5 的界面资源如果用相对路径,换目录就找不到;模型文件.dat有近百兆,很容易在拷贝时漏掉。
解决:所有资源路径用os.path.join(os.path.dirname(__file__), ...)拼绝对路径;启动时先检查模型文件是否存在,不存在就弹一个明确的提示框而不是抛异常;交付前在一台干净机器上完整走一遍“解压—装依赖—运行”。如果时间允许,用 PyInstaller 打成单文件会更省事,但注意模型文件要作为外部资源一起带上,不能塞进包里。
6. 怎么验收与怎么答辩:把指标讲清楚比把功能讲全更重要
验收环节最常见的失分点,不是功能少,而是说不清“你的系统到底准不准”。答辩前建议准备一张指标表,把每个指标的口径写清楚,比笼统说“准确率 90%”有说服力得多。
| 指标 | 测量方法 | 参考目标 | 说明 |
|---|---|---|---|
| 关键点检测成功率 | 有效帧数 / 总帧数 | 95% 以上 | 光照正常、正脸条件下统计 |
| 闭眼识别准确率 | 人工标注 200 帧对比 | 90% 以上 | 需区分正常眨眼与持续闭眼 |
| 平均处理帧率 | 连续运行 3 分钟取均值 | 20 FPS 以上 | 640×480、隔帧检测 |
| 报警响应延迟 | 从持续闭眼到报警的时间 | 3 秒以内 | 与 PERCLOS 窗口长度相关 |
| 误报次数 | 清醒状态运行 5 分钟 | 不超过 1 次 | 最能体现实战水平 |
演示流程我一般会这么排:先打开程序,让摄像头对着自己正常说话三十秒,展示“正常”状态的绿色指示和实时 EAR 曲线;然后故意闭眼三秒,等报警灯变红、声音响起;最后打开一段事先录好的模拟疲劳视频做离线回放,证明系统不依赖现场光线。这三步加起来不到两分钟,但覆盖了实时性、判定准确性和可复现性三个维度。
提问环节高频问题集中在三处。第一是“为什么用 EAR 不用深度学习”,回答要落在可解释性和样本成本上:EAR 的物理含义明确,不需要标注数据集,阈值可以现场标定,这在毕业设计的时间预算内是更负责的选择。第二是“误报怎么控制”,把回滞区间、最小连续帧数、双眼平均这三条讲出来就够了。第三是“实际车载能不能用”,别硬吹,如实说这套是视觉单模态方案,夜间和强逆光下会退化,工程上通常要加红外补光或多传感器融合,这样的回答反而显得你清楚边界。
还有一个容易被忽略的点:提前准备一份离线特征序列。现场网络、光线、摄像头任何一环出问题,你都可以切换到回放模式,用预存数据把判定逻辑完整跑一遍。我吃过一次亏,答辩教室的投影仪占用了一个 USB 口,摄像头识别不到,当场只能干讲代码。从那以后我每次做这类演示系统,都会强制走一遍“断摄像头也能演示”的降级路径,哪怕用不上,心里也踏实。希望这套结构和验收思路能帮到你,把这份基于驾驶员面部特征的疲劳检测系统源码改成自己能讲透的版本。
本文还有配套的精品资源,点击获取