简介:围绕疲劳驾驶检测这一计算机视觉落地场景,这份毕业设计文档给出了基于 dlib 模型的系统设计与实现方案,适合计算机视觉、Web 设计方向的学生,以及需要完成课程设计、毕业设计的学习者参考。内容从研究背景与意义切入,梳理生理信息、车辆信息与面部信息三类检测思路;进而展开 OpenCV 与 dlib 的 68 个面部特征点定位、长宽比计算、PERCLOS 原理、眨眼与哈欠次数统计、人体姿态估计点头计数,以及系统需求、整体框架、可行性与面部采集等章节,形成从技术选型到模块设计的完整论证链条。压缩包仅含 1 个 docx 文件,大小约 1.13MB,轻量便于查阅与二次整理。目前已有 1958 人学习下载。读者可从中获取论文目录结构、技术选型依据、系统模块划分与疲劳判定思路,作为开题、撰写和答辩准备的参考样本。
1. 摄像头夹在 A 柱旁的那半张脸
摄像头夹在 A 柱旁边,画面里只有半张脸,光线还忽明忽暗——大多数「基于 dlib 模型的疲劳驾驶检测系统」第一次在真机上跑起来都是这个样子。方案本身不算复杂:dlib 从人脸框里回归出 68 个特征点,算 EAR 判眨眼、算 MAR 判打哈欠、算头部欧拉角判点头,再用 PERCLOS 准则把三个指标合成一个疲劳分。真正麻烦的是把它做成一份能交、能复现、能答辩的完整设计与实现。这篇写给两类人:正在做疲劳检测毕业设计、需要一套完整结构的学生,以及要交计算机视觉课程设计或带界面演示的从业者。后面按需求、选型、架构、实现、避坑、验收的顺序,把整套东西摊开讲。
2. 需求分析与技术选型:三条路线怎么选,为什么落在 OpenCV + dlib
2.1 生理、车辆、面部三条路线横向对比
做疲劳检测,先要回答一个问题:疲劳从哪里读出来。学术界和工业界摸过的路线基本就三条,每条都有明确的代价,选型不是选先进,是选代价能接受的那条。
基于生理信息的路线读的是脑电、肌电、心电、眼电这些信号。脑电随疲劳程度会出现频带能量迁移,肌电在疲劳时频率和幅值曲线会有明显变化,心电则靠心率变异性做相关性分析。这条路线准确度最高,因为它测的就是生理本体,不依赖行为外显。但它的代价是接触式:电极要贴在头皮、胸口、手腕上,长时间驾驶会痒、会移位,驾驶员主观上非常抵触,实际车里根本铺不开。它适合实验室和模拟驾驶舱,不适合量产。
基于车辆信息的路线读的是方向盘转角与转动频率、车速与加速度波动、车道偏离程度。这条路线的优势是非接触,传感器本来就在车上,不用额外加装。问题在于干扰源太多:车型不同转向手感不同,路面状况不同方向盘抖动不同,驾驶习惯不同操作风格不同,同一套参数在 A 车上标定完换到 B 车上就飘了。更麻烦的是几个指标的权重至今没有统一定论,判错了也没法给出可解释的归因。它能做辅助信号,不适合做单一判据。
基于面部信息的路线读的是眨眼、打哈欠、点头这些外显行为。一个普通摄像头就能采,非接触,硬件成本压到最低,而且判定逻辑对人是透明的——「你刚才连续闭眼超过两秒」这种解释任何人都听得懂。它的短板是受光照和遮挡影响,夜间、逆光、戴口罩都会掉精度。但对一套要做完整设计与实现、要能演示、要能讲清原理的系统来说,这条路线是唯一在成本和可解释性上都过关的。
| 维度 | 生理信息 | 车辆信息 | 面部信息 |
|---|---|---|---|
| 接触方式 | 接触式,需佩戴电极 | 非接触,读总线信号 | 非接触,单摄像头 |
| 硬件成本 | 高 | 中(依赖车型) | 低 |
| 抗环境干扰 | 强 | 中,受路况车况影响 | 弱,受光照遮挡影响 |
| 判定可解释性 | 强 | 弱,权重无定论 | 强,指标直观 |
| 落地与演示难度 | 高 | 中 | 低 |
提示:选型章节在文档里不要只写「面部信息更好」,要把三条路线的代价写出来。答辩时导师最常追问的就是「你为什么不选另外两条」,这段对比表就是答案。
2.2 OpenCV 与 dlib 各自负责什么
工具链分工必须清楚,否则代码会写成谁调谁都说不清。OpenCV 负责「取和画」:VideoCapture 拉摄像头流、cvtColor 转灰度、equalizeHist 拉对比度、medianBlur 去噪、rectangle 和 putText 把结果画回画面。dlib 负责「找和定位」:get_frontal_face_detector 用 HOG 特征加 SVM 分类器做人脸框检测,shape_predictor 在框内回归 68 个特征点的坐标。numpy 负责算距离,imutils 负责缩放。
为什么不用端到端 CNN 一步到位?三个现实原因。第一是标注成本,端到端要几千张带疲劳标签的人脸图,一个人做毕设标不完。第二是可解释性,卷积网络输出一个概率值,答辩时说不清它凭什么判定疲劳,而 EAR 是一条曲线,每一帧都能指着数字讲。第三是算力,HOG + SVM 在纯 CPU 上跑 640×480 的画面能到 20 帧以上,笔记本直接演示,不用赌实验室有没有显卡。
# 基础依赖。dlib 在 Windows 上需要 CMake 和 VS Build Tools,缺一个就会编译失败 pip install opencv-python dlib imutils numpyimport cv2 import dlib import imutils import numpy as np # 两个模型文件:人脸检测器无需下载,68 点预测器需要单独放到位 detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") def preprocess(frame, width=640): # 统一宽度:分辨率越高 dlib 越慢,640 是速度与精度的折中点 frame = imutils.resize(frame, width=width) gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = cv2.equalizeHist(gray) # 直方图均衡:弱光下把脸部对比度拉回来 gray = cv2.medianBlur(gray, 3) # 中值滤波:抑制摄像头噪点,核必须取奇数 return frame, graypreprocess里三步的顺序不能随意调换。灰度化必须最先做,因为后面两个算子都只吃单通道;直方图均衡放在去噪之前,否则噪声也会被一起放大,均衡完再滤就滤不干净了;medianBlur的核大小取 3 是保守值,取 5 在弱光下会连睫毛一起糊掉,EAR 会失真。imutils.resize只改宽度、按比例算高度,比直接cv2.resize少一次手动算比例的出错机会。
2.3 功能需求与性能需求怎么写才不空
需求章节最容易写成废话,比如「系统应具有良好的实时性」。合格的需求必须可测。下面这张表是按系统实际能覆盖的场景列的,每一条都对应一个能现场演示的动作。
| 功能项 | 说明 |
|---|---|
| 白天强光检测 | 车窗逆光、侧光条件下仍能稳定框住人脸 |
| 夜晚弱光检测 | 车内顶灯或仪表盘微光下完成检测 |
| 佩戴透明镜片眼镜检测 | 镜片反光不导致关键点崩坏 |
| 疲劳判定 | 行驶过程中综合眼部、嘴部、头部三路信号给出等级 |
| 丢脸处理 | 检测不到人脸时不误报,保留算法状态等待恢复 |
| 疲劳报警 | 达到阈值后屏幕显示预警信号并发声 |
性能指标同样要落到公式。准确率定义为真实疲劳帧数除以总帧数,目标 90% 及以上;误报率定义为误判疲劳帧数除以总帧数,目标 10% 及以下。这两个定义写进文档的意义在于,它把「准确率」从形容词变成了可复现的计算过程——你需要人工标一段视频作为基准,逐帧比对系统输出。标 500 帧大概要花一个下午,但这一下午是整份文档里最有说服力的部分。
3. 总体架构设计:两路特征子系统、五个模块与一条帧循环
3.1 模块划分与职责边界
整个系统按「采集 → 预处理 → 检测定位 → 特征判定 → 报警呈现」切成五个模块,同时按特征来源分成两路子系统的观察视角:面部特征子系统管眼睛和嘴巴,头部特征子系统管姿态角。两路并行算,最后在判定层汇合。
| 模块 | 核心职责 | 关键依赖 |
|---|---|---|
| 视频采集 | 打开本地摄像头,按固定分辨率持续读帧 | cv2.VideoCapture |
| 图像预处理 | 缩放、灰度化、直方图均衡、中值滤波 | imutils、cv2 |
| 人脸检测与定位 | 输出人脸框、68 点坐标、人脸区域 | dlib HOG 检测器、shape_predictor |
| 疲劳检测 | 由 68 点分组算出 EAR、MAR、pitch,做融合判定 | numpy |
| 报警与界面 | 显示实时指标、疲劳等级、触发声音与提示 | Tkinter 或 PyQt5、winsound |
这个划分的好处是每个模块都能单独替换。比如换摄像头就从采集模块改,换判定规则只动疲劳检测模块,界面的同学和算法的同学可以并行开工。毕设常见的翻车是所有人挤在一个main.py里改,最后谁也说不清哪行是谁写的。
3.2 主帧循环:从 VideoCapture 到一次判定
主循环是整个系统的主干,写法决定了后面所有优化有没有空间。
cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) frame_idx = 0 while True: ok, frame = cap.read() if not ok: break frame_idx += 1 frame, gray = preprocess(frame) # 第二个参数 0 表示不放大;改 1 会做一次上采样,小脸更准但帧率减半 faces = detector(gray, 0) if len(faces) == 0: # 丢脸不等于疲劳:不清空窗口,只标记本帧无效 state.on_face_lost() cv2.imshow("Fatigue Detection", frame) if cv2.waitKey(1) & 0xFF == 27: break continue shape = predictor(gray, faces[0]) pts = np.array([(shape.part(i).x, shape.part(i).y) for i in range(68)]) left_eye = pts[36:42] right_eye = pts[42:48] mouth = pts[48:60] ear = (eye_aspect_ratio(left_eye) + eye_aspect_ratio(right_eye)) / 2.0 mar = mouth_aspect_ratio(mouth) angles = head_pose_angles(pts, frame.shape[1], frame.shape[0]) state.push(ear, mar, angles, frame_idx) cap.release() cv2.destroyAllWindows()几个参数值得单独说。faces[0]只取第一张脸,因为驾驶位上只可能有一个驾驶员,取多人脸反而会引入副驾干扰。detector(gray, 0)的上采样参数是性能开关,0 表示原图检测,1 表示先放大一倍再检测——放大对小脸和远距离有效,但耗时直接翻倍,笔记本上开 1 通常就掉到 10 帧以下。cv2.waitKey(1)的 1 是毫秒等待,不写这句画面根本刷不出来,这是新手最常踩的一个坑。ESC 键退出(ASCII 27)比关窗口按钮可靠,因为 OpenCV 的窗口关闭事件在部分系统上不触发。
3.3 68 个特征点的分组与索引约定
dlib 的 68 点是有固定编号的,不记住编号后面全是玄学。
| 索引区间 | 部位 | 本系统用途 |
|---|---|---|
| 0–16 | 下颌轮廓 | 不参与判定,可作人脸区域参考 |
| 17–21 | 一侧眉毛 | 不参与判定 |
| 22–26 | 另一侧眉毛 | 不参与判定 |
| 27–35 | 鼻梁与鼻翼 | 取 30 号作姿态估计的鼻尖参考点 |
| 36–41 | 图像左侧眼睛(6 点) | EAR 计算 |
| 42–47 | 图像右侧眼睛(6 点) | EAR 计算 |
| 48–59 | 嘴部外轮廓(12 点) | MAR 计算 |
| 60–67 | 嘴部内轮廓(8 点) | 可选,用于加强哈欠判定 |
注意:dlib 的「左右」是按图像坐标说的,跟人脸解剖学上的左右眼正好相反。写文档时要么统一按图像方向描述,要么在图上标清楚,否则读者对着代码看会以为你写反了。
3.4 时间窗口与状态缓存:为什么不能逐帧判疲劳
单帧数据毫无意义。一帧里眼睛闭着,可能只是正常眨眼;一帧里嘴巴张着,可能只是在说话。疲劳是时间维度上的统计现象,所以必须有一个滑动窗口把最近若干帧的指标攒起来。
窗口长度的选择有个换算原则:按时间定,不按帧数定。30 帧每秒时 3 秒是 90 帧,但笔记本掉帧到 15 帧每秒时,90 帧就变成 6 秒了,判定会明显迟钝。稳妥的做法是记录每帧的时间戳,窗口按秒裁剪。
from collections import deque import time class FatigueState: def __init__(self, window_sec=3.0): self.window = window_sec self.ear_buf = deque() # 存 (时间戳, EAR) self.mar_buf = deque() self.pitch_buf = deque() def _trim(self, buf, now): # 按时间戳裁剪,掉帧时窗口的真实时长仍然稳定 while buf and now - buf[0][0] > self.window: buf.popleft() def push(self, ear, mar, angles, _frame_idx): now = time.time() self.ear_buf.append((now, ear)) self.mar_buf.append((now, mar)) if angles is not None: self.pitch_buf.append((now, angles[0])) self._trim(self.ear_buf, now) self._trim(self.mar_buf, now) self._trim(self.pitch_buf, now)deque比 list 合适,因为窗口滑动时要频繁从左侧弹出,list 的pop(0)是 O(n),跑久了会拖慢主循环。_trim用时间戳而不是长度做裁剪条件,这是整套代码里唯一一处主动对抗帧率波动的设计,改掉它判定就会在低配机器上失准。
4. 三路特征实现与判定融合:EAR、MAR、HPE 和 PERCLOS
4.1 眼部 EAR:用长宽比把「睁眼」变成一个数
EAR 的思路很朴素:眼睛睁开时是个偏圆的形状,闭合时趋于一条线。用垂直方向的两组距离之和,除以水平方向的距离,再取平均,就得到一个跟人脸尺度无关的比值。
def eye_aspect_ratio(eye): # eye 是 6 个点的数组,顺序:左角、上左、上右、右角、下右、下左 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)除以2.0 * c这一步是整套算法能跨人使用的原因。不同人在画面里的大小不同,脸大的像素距离大、脸小的像素距离小,但比值基本一致。除以 2 是取两组垂直距离的平均,避免单侧关键点抖动主导结果。左右眼各算一次再平均,是为了压制单眼被头发或镜框遮住时的异常值。
EAR 的典型取值范围:完全睁开大约 0.28 到 0.35,正常眨眼瞬间会掉到 0.15 以下。判定闭眼用的阈值通常落在 0.22 到 0.25 之间,具体取多少要看你的摄像头高度和俯仰角,后面 4.5 节讲怎么标。
4.2 嘴部 MAR:哈欠和张嘴的区别在持续时间
MAR 的构造跟 EAR 同源,只是垂直方向取三组距离,因为嘴的张开是上下颌分离,中间段最容易测到位移。
def mouth_aspect_ratio(mouth): # mouth 取 48~59 共 12 点,局部下标 0~11 a = np.linalg.norm(mouth[2] - mouth[10]) # 上内唇 到 下内唇 b = np.linalg.norm(mouth[4] - mouth[8]) # 另一组上下唇 c = np.linalg.norm(mouth[0] - mouth[6]) # 左右嘴角,归一化基准 return (a + b) / (2.0 * c)只靠 MAR 超阈值判哈欠,误报会高得离谱——说话、唱歌、打嗝都会触发。真正区分哈欠的是持续时长:正常说话嘴部开合是高频小幅,哈欠是一次持续 1.5 秒以上的大开合。所以 MAR 判定必须配一个持续时间条件,通常是连续超过阈值 45 帧(约 1.5 秒)才计一次哈欠,计完还要锁一段时间防重复计数。
4.3 头部姿态 HPE:solvePnP 把 2D 点还原成三维角
点头判定用人体姿态估计的思路:拿 6 个稳定的面部点在二维图像上的位置,跟它们在标准三维人脸模型上的位置做匹配,解出旋转向量,再转成欧拉角。
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) def head_pose_angles(pts, w, h): image_points = np.array( [pts[30], pts[8], pts[36], pts[45], pts[48], pts[54]], dtype=np.float64 ) # 没有标定板就用近似内参:焦距取画面宽度,主点取画布中心 focal = w camera_matrix = np.array([[focal, 0, w / 2.0], [0, focal, h / 2.0], [0, 0, 1.0]], dtype=np.float64) dist_coeffs = np.zeros((4, 1)) ok, rvec, _ = cv2.solvePnP(MODEL_POINTS, image_points, camera_matrix, dist_coeffs, flags=cv2.SOLVEPNP_ITERATIVE) if not ok: return None rmat, _ = cv2.Rodrigues(rvec) sy = np.sqrt(rmat[0, 0] ** 2 + rmat[1, 0] ** 2) pitch = np.degrees(np.arctan2(-rmat[2, 0], sy)) # 俯仰,点头的主指标 yaw = np.degrees(np.arctan2(rmat[1, 0], rmat[0, 0])) # 偏航,左右转头 roll = np.degrees(np.arctan2(rmat[2, 1], rmat[2, 2])) # 翻滚,歪头 return pitch, yaw, rollMODEL_POINTS里的坐标是一套通用成人人脸的平均尺寸,单位毫米,原点在鼻尖。它不需要精确到某个真人,因为 solvePnP 只关心相对结构。camera_matrix用近似内参是这套方案能落地的关键妥协——没有棋盘格标定就用画面宽度当焦距,误差在可接受范围内,但这也意味着 pitch 的绝对值不准,只有相对变化可用。所以点头阈值不能写死成「pitch 小于 -15 度」,得先让系统采集一段正常驾驶的基线,再按基线的偏移量判定。
欧拉角的符号方向跟坐标系定义有关,有人算出来点头是负的,有人是正的。别照抄符号,跑一遍抬头低头的动作,看数值往哪个方向走,再定阈值。
4.4 PERCLOS 与三指标融合判定
PERCLOS 的核心是一个比例:在一段时间内,眼睛闭合的时间占比。工程上落成帧占比,因为逐帧比较便宜。传统的 P80 定义是眼睑覆盖瞳孔面积超过 80% 的时间比例,用关键点做的话,等价于 EAR 低于阈值的帧占窗口总帧的比例。
EAR_TH = 0.23 # 闭眼阈值,需按 4.5 节标定 MAR_TH = 0.60 # 哈欠阈值 PITCH_TH = -14.0 # 低头阈值,需按基线偏移修正 W_PERCLOS, W_YAWN, W_NOD = 0.55, 0.25, 0.20 # 权重来自标定实验 def judge_fatigue(ear_buf, mar_buf, pitch_buf): n = max(len(ear_buf), 1) # 1) PERCLOS:窗口内闭眼帧占比 perclos = sum(1 for _, v in ear_buf if v < EAR_TH) / n # 2) 哈欠帧占比 yawn = sum(1 for _, v in mar_buf if v > MAR_TH) / max(len(mar_buf), 1) # 3) 低头帧占比 nod = sum(1 for _, v in pitch_buf if v < PITCH_TH) / max(len(pitch_buf), 1) score = W_PERCLOS * perclos + W_YAWN * yawn + W_NOD * nod if score >= 0.62: return 2, score # 重度:立即报警 if score >= 0.40: return 1, score # 轻度:界面提示 + 小贴士 return 0, score三个权重不是拍脑袋来的,是标定阶段用正常和模拟疲劳两组数据跑出来的:哪个指标在疲劳组和正常组之间的区分度大,权重就给得高。PERCLOS 权重最高,因为它是三者中跟疲劳相关性最稳的;MAR 次之,因为哈欠的个体差异大;pitch 权重最低,因为低头看仪表盘会污染这个信号。判定阈值 0.40 和 0.62 同样是标定结果,不是普适常数。
4.5 阈值标定实验:把三个参数从玄学变成数据
阈值是这个项目里最容易被质疑的地方。导师一定会问:你这个 0.23 是哪来的?答案必须是「我标出来的」,不能是「参考别人论文的」。
标定流程通常这样做。先采两段数据,各 60 秒:一段是正常状态下的静坐和自然眨眼,一段是模拟疲劳状态下的缓慢闭眼、打哈欠、点头。摄像头位置、光照、佩戴眼镜情况必须跟实际演示时一致。然后把逐帧 EAR、MAR、pitch 全部存成 CSV,画出分布直方图。闭眼阈值取正常状态 EAR 的均值减去两倍标准差,这样正常眨眼有 95% 以上的概率不会误触。
| 参数 | 参考区间 | 取值依据 |
|---|---|---|
| EAR_TH | 0.22 – 0.25 | 正常睁眼均值减 2 倍标准差 |
| MAR_TH | 0.55 – 0.65 | 哈欠峰值与正常说话峰值的中间偏上位置 |
| PITCH_TH | 基线偏移 -10° 至 -15° | 相对正常驾驶姿态的偏移量,不是绝对角 |
| 窗口长度 | 2.5 – 3.5 秒 | 覆盖至少两个完整眨眼周期 |
| 哈欠持续 | 40 – 50 帧 | 明显长于正常说话的嘴部动作 |
标定的意义不只是数值,它还是性能指标的分母。90% 准确率怎么算出来的,就靠标定时留下的人工标注结果。这份 CSV 建议在文档附录里贴一段,答辩时直接打开给人看,比任何形容词都有力。
4.6 界面与报警联动的实现
界面部分通常用 Tkinter 或 PyQt5,四个页面分工明确。
| 界面 | 主要元素 | 作用 |
|---|---|---|
| 开始界面 | 标题、开始检测、使用说明、退出 | 进入前的功能说明与适用范围提示 |
| 介绍界面 | 算法流程、三项指标含义 | 答辩时讲原理的落地页 |
| 行车小贴士 | 休息建议、疲劳征兆清单 | 轻度疲劳时弹出,不打断检测 |
| 检测界面 | 视频画面、EAR/MAR/pitch 实时数值、疲劳等级条、报警区 | 主工作区 |
检测界面必须把三个原始数值实时显示出来。这不只是为了好看,它是调试期的黑匣子——判定异常时你能一眼看出是 EAR 飘了还是 pitch 崩了。报警用独立的线程发声音,不要在主循环里直接Beep,否则声音播放期间画面会卡住。
import threading import winsound def fire_alarm(level): # 二级用长鸣,一级用短提示,两者都不能阻塞主循环 if level == 2: threading.Thread(target=lambda: winsound.Beep(1000, 800), daemon=True).start() elif level == 1: threading.Thread(target=lambda: winsound.Beep(600, 200), daemon=True).start()5. 避坑与排查:五个最容易翻车的点
5.1 白天准得离谱,一到夜里就丢脸
现象:实验室白天演示一切正常,晚上把车开到地库或者关掉顶灯,人脸框直接消失,系统一直走「丢脸」分支。
原因:dlib 的 HOG + SVM 检测器是在正常光照的人脸上训的,对低对比度极度敏感。灰度化之后如果整体像素集中在一个很窄的区间,梯度特征就提不出来,检测器直接判无人脸。
解决:把直方图均衡提到灰度化之后的第一位,并且加一个亮度判断——整帧平均灰度低于阈值时,先做一次cv2.convertScaleAbs拉亮再送入检测。同时在采集端加一盏朝向面部的补光,这是最有效的一招,比任何算法补偿都稳定。夜里还要把detector的上采样参数从 0 改成 1,牺牲帧率换检出率。
5.2 报警器响成交响乐
现象:进入疲劳状态后,报警声每帧触发一次,一秒响三十下,演示现场直接失控。
原因:判定函数是无状态的,每帧独立算一次 score,只要 score 达标就调一次fire_alarm。缺一个状态机。
解决:加状态保持和冷却。连续 N 帧判定为疲劳才升级到报警,报警后锁住 M 帧不再触发,直到状态回落到正常才解锁。
class AlarmGate: def __init__(self, hold=45, cooldown=300): self.hold = hold # 连续多少帧才确认疲劳 self.cooldown = cooldown # 报警后的静默帧数 self.count = 0 self.rest = 0 self.fired = False def update(self, level): if self.rest > 0: self.rest -= 1 return False if level >= 2: self.count += 1 else: self.count = 0 self.fired = False if self.count >= self.hold and not self.fired: self.fired = True self.rest = self.cooldown return True return Falsehold取 45 帧约等于 1.5 秒,刚好跨过正常眨眼的时长;cooldown取 300 帧约等于 10 秒,给驾驶员一个反应窗口。
5.3 帧率掉到个位数,画面像幻灯片
现象:跑起来 CPU 单核占满,帧率只有 8 到 10,EAR 曲线抖得不成样子。
原因:detector(gray, 1)的上采样让检测耗时翻倍;另外每帧都在全图跑检测,画面里绝大部分像素是车内饰,纯属浪费。
解决:三个动作按性价比排序。第一,把上采样参数改回 0,只在连续多帧检测失败时才临时切到 1。第二,缩到 640 宽再检测,1080p 直接送进去等于自找麻烦。第三,加跟踪:检测成功后的下一帧用dlib.correlation_tracker跟踪人脸框,每 5 帧才做一次真正的检测,关键点预测仍然每帧做。这一条通常能把帧率拉回 20 以上。
5.4 低头看仪表盘就被判疲劳
现象:驾驶员正常看导航、看仪表、调空调,系统一律判重度疲劳,报警不停。
原因:pitch 只看俯仰角,而低头看仪表跟疲劳点头在数值上是同一个方向,单靠角度分不开。加上 yaw 大幅偏转(转头看后视镜)时 solvePnP 的解会失真,pitch 也跟着乱跳。
解决:给点头判定加两个前置条件。一是 yaw 必须在正负 20 度以内,转头时直接跳过点头判定;二是点头必须「快速下坠再缓慢抬起」,用 pitch 的一阶差分判断下降速度,疲劳点头是失控的快速下坠,主动低头是匀速的。把这两条加上,误报能降一大截。
5.5 论文里的阈值抄过来,误报率飙到三成
现象:按参考文献里的 EAR 阈值 0.25 设好,别人的数据集上跑得挺好,自己摄像头一装,误报率超过 30%。
原因:EAR 的绝对值跟摄像头高度、俯仰角、镜头焦距强相关。摄像头装在仪表台上往上拍,眼睑的投影形状跟平视完全不一样,同一个人的 EAR 能差 0.05。直接搬阈值必然翻车。
解决:重新标定,而且是必须在最终安装位置标定。标定流程按 4.5 节走一遍,采两段各 60 秒的数据,算出自己的阈值。这一步没有任何捷径,但它花的时间(大概两小时)远少于后期调参耗掉的时间。
6. 验收与答辩:用一段脚本化录像把方案讲圆
到了验收环节,多数人栽在同一个地方:功能都能跑,但说不清「怎么证明它对」。我的建议是提前准备一段脚本化测试录像,把要演示的行为按固定顺序演一遍:正常驾驶 20 秒、连续眨眼 10 秒、打两次哈欠、点三次头、转头看后视镜 5 秒、摘下眼镜 5 秒。全片录下来存成文件,演示时用cv2.VideoCapture("test.mp4")读取,而不是开摄像头现场演。这样做的最大好处是可复现——导师让你再放一遍,你放的就是同一段,不会因为现场光线或坐姿变化出现「刚才明明报警了」的尴尬。
配合这段录像,准备三张表:
| 表名 | 内容 | 用途 |
|---|---|---|
| 功能测试用例表 | 用例编号、操作步骤、预期结果、实际结果、是否通过 | 逐条对应需求表,证明每条需求都有验证 |
| 性能指标表 | 准确率、误报率、平均帧率、丢脸恢复时间 | 用标定阶段的人工标注做分母 |
| 阈值标定表 | 三个阈值的取值、标定方法、参考区间 | 回答「这个数哪来的」 |
答辩时最容易被追问的三个问题,提前把答案写进讲稿。第一个是「为什么不用深度学习」,答案在 2.2 节的成本、可解释性、算力三条。第二个是「夜间怎么办」,答案在 5.1 节的补光加均衡加降采样策略。第三个是「误报怎么控制」,答案在 5.4 节的 yaw 过滤加下降速度判据,以及 5.2 节的状态机。
还有一个细节值得单独提醒:录演示视频时,一定要用跟最终答辩同一个摄像头、同一个分辨率。我曾见过一套系统在实验室的 720p 摄像头上跑得稳稳的,换到答辩教室的 1080p 设备上,因为预处理没做统一缩放,帧率掉到 5,整个演示卡成 PPT。从那以后我每次交付前都强制走一遍「目标机器冷启动 + 脚本录像回放」的流程,这一步花不了十分钟,但能挡掉绝大部分现场事故。
希望这些能帮到你,少踩两个坑,多睡两小时。
本文还有配套的精品资源,点击获取