ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

dlib 68点疲劳驾驶检测:EAR/MAR/PERCLOS与阈值标定避坑

dlib 68点疲劳驾驶检测:EAR/MAR/PERCLOS与阈值标定避坑

简介:面向计算机视觉课程设计、毕业设计与疲劳检测入门实践的文档,围绕基于 dlib 的疲劳驾驶检测系统展开,重点解决面部特征点提取、眨眼与打哈欠统计、点头行为识别及疲劳状态判定等设计问题。压缩包内为 1 个 docx 文件,约 1.13MB,内容按摘要、导论、相关技术、系统分析、系统设计等章节组织,呈现从研究背景、PERCLOS 原理到需求分析、整体框架与可行性论证的写作脉络。已有 1958 人学习,适合需要参考论文结构、算法选型与系统设计表达的学生和开发者。文档对 dlib 的 68 个面部特征点、长宽比计算、人体姿态估计及面部采集流程均有展开,可用于梳理疲劳检测方案的技术路线、对照章节模板查漏补缺,并辅助完成课程设计或毕业设计中的系统分析与设计部分。

1. 一段夜路视频跑出来的报警,靠的到底是什么

深夜的驾驶模拟舱里,摄像头正对着一张脸。屏幕上同时叠着 68 个绿色小点,眼睛处的两个椭圆随着眨眼一次次压扁,嘴巴那条线在第三个哈欠时冲到 0.62,右上角计数器跳到 3,警示音响起。这就是一套典型的基于 dlib 模型的疲劳驾驶检测系统在做的事:用计算机视觉把"困了"这个模糊感受,翻译成 EAR、MAR、头部俯仰角三组能比较大小的数字,再交给 PERCLOS 判据下结论。它适合谁?做疲劳检测方向毕业设计的同学、想给车载 Demo 加一层非接触式监控的开发者、以及刚入门人脸关键点、想把程序设计从课设做成能完整演示的系统的程序员。硬件门槛极低——一个普通 USB 摄像头加一台能跑 Python 的笔记本,不需要红外相机,也不需要贴电极。

2. 68 个点怎么变成三组数字:EAR、MAR、HPE 的计算口径

整套系统的地基就一句话:dlib 的 68 点模型给出面部关键点的像素坐标,剩下的全是几何。听起来简单,但真正决定成败的不是算法本身,而是你对"哪几个点、怎么连、除以谁"这几个问题的取舍。同一条 68 点结果,索引取错一个,EAR 曲线立刻失真;分子分母定义不一致,换个人种、换副眼镜,阈值就得推倒重来。这一章把三个指标的计算逻辑钉死,后面所有调参才有参照物。

2.1 环境与模型文件:先让单帧跑通,再谈实时

dlib 在 Windows 上编译是新手第一道坎,常见做法是避开源码编译,直接用预编译轮子或者走 conda 渠道。Python 版本别追新,3.8 到 3.10 之间兼容性最稳,太新的解释器经常找不到对应轮子,报一堆找不到编译器的错。

# 先装基础图像与数值依赖 pip install opencv-python numpy scipy # dlib 单独装:能拿到轮子就别编译 pip install dlib # 如果上面这条开始疯狂编译并最终失败,退回 conda 渠道 # conda install -c conda-forge dlib

三段命令里,前两行是纯 Python 包,没有编译风险;dlib那行才是关键。它一旦进入源码编译,机器上必须同时具备 CMake 和 C++ 构建工具链,缺一个就会在中途中断。判断是否踩坑很简单:看安装日志里有没有出现Building wheel for dlib之后长时间无输出,出现了就说明在本地编译,此时切 conda 渠道通常几十秒解决。

模型文件只需要一个:dlib 公开的 68 点人脸关键点预测器,解压后放在工程根目录,路径写进代码。建议目录结构按下面这样切,后面调试阈值时不至于满项目找文件:

fatigue_demo/ ├── models/ │ └── shape_predictor_68_face_landmarks.dat ├── core/ │ ├── metrics.py # EAR / MAR / HPE 三个计算函数 │ └── detector.py # 人脸检测与关键点封装 ├── ui/ │ └── app.py # 可视化界面与报警 ├── tools/ │ └── calibrate.py # 阈值标定脚本 └── main.py

把计算函数单独放一个文件,是整个工程最值钱的一个决定。阈值标定、回归测试、界面刷新都要反复调用这几个函数,混在主循环里写,后面想做批量回放几乎要重写。

2.2 EAR 与 MAR:索引错一位,曲线全废

dlib 的 68 点有固定分区:0 到 16 是下颌轮廓,17 到 21 是右眉,22 到 26 是左眉,27 到 35 是鼻梁与鼻尖,36 到 41 是图像左侧眼睛,42 到 47 是图像右侧眼睛,48 到 59 是嘴部外轮廓,60 到 67 是内轮廓。注意"图像左侧"是被检测者本人的右眼,写注释时最好标清楚,否则调参时自己都会绕晕。

EAR 的思路是把眼睛看成一个六边形,用上下眼睑的垂直距离除以内外眼角的水平距离,眼睛睁大比值升高,闭眼时垂直距离趋近于零,比值塌下来。

import numpy as np def eye_aspect_ratio(eye_pts): """ eye_pts: 长度为 6 的数组,顺序必须是 [p1, p2, p3, p4, p5, p6] 对应 dlib 左眼 36-41 或右眼 42-47 的自然顺序 """ # 垂直方向两组距离:上眼睑中点到下眼睑中点 v1 = np.linalg.norm(eye_pts[1] - eye_pts[5]) v2 = np.linalg.norm(eye_pts[2] - eye_pts[4]) # 水平方向:内外眼角距离 h = np.linalg.norm(eye_pts[0] - eye_pts[3]) if h < 1e-6: # 关键点重合时兜底,避免除零炸掉整帧 return 0.0 return (v1 + v2) / (2.0 * h) def mouth_aspect_ratio(mouth_pts): """ mouth_pts: dlib 48-59 共 12 个外唇轮廓点 """ # 上下唇中点距离 / 左右嘴角距离 v = np.linalg.norm(mouth_pts[3] - mouth_pts[9]) # 51 与 57 h = np.linalg.norm(mouth_pts[0] - mouth_pts[6]) # 48 与 54 if h < 1e-6: return 0.0 return v / h

eye_aspect_ratio里取了两组垂直距离再平均,是为了抗单侧眼睑被眼镜框遮挡导致的跳变;分母乘 2 只是归一化,不影响阈值之间的相对关系。mouth_aspect_ratio用了最简版本,只用外唇的上下中点与两个嘴角。想让打哈欠的判别更稳,可以把内唇 60 到 67 也算一条 MAR,取两者的较大值,张嘴时内唇比值通常涨得更早,但闭嘴时噪声也更大,是否叠加取决于你的摄像头分辨率。

一个必须提前想清楚的问题:眼睛比例对头部俯仰角极其敏感。低头看仪表盘时,即使眼睛完全睁开,EAR 也可能掉到 0.15 以下,直接触发误报。所以 EAR 判定不能只看数值,必须和头部姿态耦合,这一点在第 4 章的阈值设计里会展开。

2.3 HPE 头部姿态:solvePnP 的四个输入一个都不能想当然

头部姿态估计不走机器学习,是标准的 PnP 解算:已知若干面部点在真实世界里的三维坐标,又知道它们在图像里的二维像素坐标,求解相机的旋转和平移,旋转矩阵再转成欧拉角,就得到俯仰、偏航、滚转三个角度。点头对应俯仰角,左右张望对应偏航角,歪头对应滚转角。

三维模型点用一套通用的人脸刚体近似坐标,单位毫米,原点是鼻尖:

import cv2 import numpy as np # 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(landmarks, frame_w, frame_h): """landmarks: dlib 68 点列表,每个元素是 (x, y)""" idx = [30, 8, 36, 45, 48, 54] image_points = np.array( [(landmarks[i][0], landmarks[i][1]) for i in idx], dtype=np.float64 ) # 简易相机内参:焦距按图像宽度估,主点取画面中心 focal = frame_w camera_matrix = np.array([ [focal, 0, frame_w / 2.0], [0, focal, frame_h / 2.0], [0, 0, 1.0], ], dtype=np.float64) dist_coeffs = np.zeros((4, 1)) # 不标定畸变时置零,别传 None ok, rvec, tvec = cv2.solvePnP( MODEL_POINTS, image_points, camera_matrix, dist_coeffs, flags=cv2.SOLVEPNP_ITERATIVE ) if not ok: return None rmat, _ = cv2.Rodrigues(rvec) proj = np.hstack((rmat, tvec)) # 第 7 个返回值是欧拉角,顺序为俯仰、偏航、滚转 euler = cv2.decomposeProjectionMatrix(proj)[6].flatten() return euler[0], euler[1], euler[2]

四类输入逐条说清楚。相机内参用图像宽度当焦距是工程上最常见的近似,误差在 10% 量级,用来判断"头低没低"完全够,但如果要精确到度数,还是老老实实做一次棋盘格标定。畸变系数必须给一个形状正确的零数组,传None在某些 OpenCV 版本里会直接抛异常。三维模型点的尺度不必是真实毫米,只要六个点之间的相对比例对,解出来的角度方向就可用。姿态解算标志位选迭代法,对 6 组点这种小规模输入收敛稳定,速度快。

欧拉角的符号是这套代码里最容易翻车的地方。不同版本的返回值可能会有整体正负翻转,判断标准只有一个:真人低一次头,看俯仰角是变大还是变小,然后决定阈值写<还是>。我一般会在第一次跑通时打一行日志把这六个点画在画面上,肉眼确认点的位置和编号对得上,再去做符号校准。

2.4 PERCLOS 的统计窗口:逐帧判定只会得到满屏报警

PERCLOS 是"单位时间内眼睛闭合时长占比"的意思,它天生是一个滑动窗口统计量,不是逐帧布尔值。常见做法是维护一个长度为 N 帧的队列,每帧记录该帧是否闭眼,窗口内的闭眼帧数除以 N 就是当前 PERCLOS。N 取多少取决于帧率:30 帧每秒的摄像头,一分钟窗口对应 1800 帧,内存能吃但响应会滞后好几秒;实用区间通常在 1 到 3 秒,也就是 30 到 90 帧之间。

统计量物理含义常用窗口观察到的现象
EAR 均值眼睛开合瞬时程度单帧眨眼时快速下探后回升
PERCLOS窗口内闭眼帧占比30 到 90 帧疲劳时持续抬升
眨眼次数窗口内 EAR 穿越阈值的次数约 60 帧疲劳时变密或变稀
MAR 峰值单次张嘴最大开合度单帧打哈欠时明显高于说话
俯仰角越界帧数低头且持续约 30 帧点头时成段出现

用窗口统计而不是瞬时值,带来一个直接好处:偶发的一次眨眼、一次说话,不会污染最终判断,误报率能压下去一大截。代价是响应延迟,这就要在窗口长度和报警及时性之间做权衡,而权衡的依据是帧率,不是拍脑袋。

3. 从摄像头到报警:把主循环拆成五个可独立验证的阶段

很多人写这类系统,喜欢把所有逻辑塞进一个while True,跑起来能演示,一旦误报或者卡顿就完全无从下手。更稳的做法是把链路切成采集、预处理、检测、指标、判定五段,每段有明确的输入输出,任何一段出问题都能单独拎出来验证。

3.1 采集与预处理:三个操作,随手加一个就是一倍开销

采集用 OpenCV 的VideoCapture,它在不同平台上的后端差异很大。Windows 下指定 DirectShow 后端能显著降低首帧延迟,Linux 下用默认 V4L2 就行。分辨率不要一上来就 1080P,人脸检测的开销和像素数基本线性相关,640×480 对看清眼睑开合已经足够。

import cv2 import dlib detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("models/shape_predictor_68_face_landmarks.dat") # 0 表示默认摄像头;Windows 下加 CAP_DSHOW 能避免首帧卡顿 cap = cv2.VideoCapture(0, cv2.CAP_DSHOW) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap.set(cv2.CAP_PROP_FPS, 30) while True: ok, frame = cap.read() if not ok: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 直方图均衡对抗弱光,务必在灰度图上做 gray = cv2.equalizeHist(gray) # 中值滤波去椒盐噪声,核必须是奇数 gray = cv2.medianBlur(gray, 3) # 上采样一次:远处小脸能检出,代价是耗时翻倍 faces = detector(gray, 1) for face in faces: shape = predictor(gray, face) pts = [(p.x, p.y) for p in shape.parts()] # 后续交给指标计算模块

equalizeHist在强光或弱光下的收益很明显,但在均匀光照下会把噪声一起放大,所以它不是必选项,而是按摄像头环境决定。medianBlur的核大小必须为正奇数,写 4 会直接抛错,这是新手高频错误。detector的第二个参数是上采样次数,取 1 时会把整张图放大一倍再检测,小脸检出率提升明显,但单帧耗时会接近翻倍;如果驾驶员离摄像头只有半米,直接给 0 就行。灰度图必须同时喂给检测器和预测器,两者都只接受单通道输入。

预处理里最容易忽略的一条:人脸检测和关键点预测应该复用同一张灰度图。有人顺手在预测器那里再转一次灰度,看似无害,实际上多了一次全图遍历,30 帧每秒下就是白扔掉的 5% 到 8% CPU。

3.2 检测与关键点提取:把封装做对,后面少写一大半代码

把检测和预测封成一个类,好处是后续想换检测器(比如换成 CNN 版本或者 MediaPipe)时,只改一处。

class FaceLandmarkDetector: def __init__(self, model_path, upsample=1): self.detector = dlib.get_frontal_face_detector() self.predictor = dlib.shape_predictor(model_path) self.upsample = upsample def detect(self, gray): """返回 [(68点列表, 人脸矩形), ...],无脸时返回空列表""" results = [] faces = self.detector(gray, self.upsample) for face in faces: shape = self.predictor(gray, face) pts = [(p.x, p.y) for p in shape.parts()] results.append((pts, face)) return results

upsample做成构造参数而不是写死,是因为在标定阶段你可能想让远处的人脸也检出来,实测阶段又要压帧率,两个阶段取值不同。返回空列表而不是None或抛异常,是为了让主循环能平静地处理"驾驶员扭头看后视镜"这种正常情况,此时应该保留上一次的统计状态继续累加,而不是清零重来。这一点在第 5 章的丢失人脸场景里会重点讲。

关键点坐标转成 Python 列表而不是 numpy 数组,是权衡后的结果:dlib 返回的parts()是生成器,逐点访问比一次性转数组快,而 68 个点的规模也完全不需要向量化。真正需要数组的是后面的距离计算,那一步再转。

3.3 状态机与计数:连续帧阈值才是抑制误报的核心

单帧 EAR 低于阈值不能算眨眼,闭眼可能只是正常眨眼的一部分,也可能是一帧噪声。稳妥的做法是引入状态机:连续 K 帧低于阈值,才认定"进入闭眼";连续 M 帧高于阈值,才认定"睁眼恢复";一次完整的下降再回升记一次眨眼。

class BlinkCounter: def __init__(self, ear_thresh=0.22, close_frames=3, open_frames=2): self.ear_thresh = ear_thresh self.close_frames = close_frames self.open_frames = open_frames self.closed_count = 0 # 连续闭眼帧数 self.open_count = 0 # 连续睁眼帧数 self.is_closed = False self.blink_total = 0 self.closed_frame_total = 0 # 用于 PERCLOS 累加 def update(self, ear): if ear < self.ear_thresh: self.closed_count += 1 self.open_count = 0 self.closed_frame_total += 1 if self.closed_count >= self.close_frames and not self.is_closed: self.is_closed = True # 状态切换只发生一次 else: self.open_count += 1 self.closed_count = 0 if self.open_count >= self.open_frames and self.is_closed: self.is_closed = False self.blink_total += 1 # 一次完整闭合记一次眨眼 return self.is_closed, self.blink_total

三个参数各有分工。ear_thresh是判定线,被眼镜、光照、人种影响最大,必须标定。close_frames控制灵敏度,取 2 到 3 帧,太小噪声就会误触发,太大会漏掉快速眨眼。open_frames是防抖回差,它的存在让"闭眼—睁眼"的边界不会在阈值附近来回震荡,等于给状态机加了迟滞。closed_frame_total单独累加,是为了算 PERCLOS 时不用再扫一遍历史。

打哈欠的判定同理,但阈值和帧数不一样:嘴巴张大是一个持续过程,取MAR > 0.5且持续超过 15 帧(约半秒)才算一次哈欠,短促的说话张嘴不应该计数。点头判定用俯仰角,取"绝对值超过某个角度且持续 20 帧以上"作为一次点头。

3.4 报警与可视化:界面刷新和报警音不能堵在主循环里

界面有两个常见落法。一是纯 OpenCV 的imshow叠加文字和矩形框,代码量最少,适合调参阶段;二是 Tkinter 或 PyQt 做独立窗口,把视频帧转成图像塞进控件,适合答辩演示。前者写起来快,后者看起来正式,取舍看场合。

import winsound import threading def fire_alarm_async(): """报警音放子线程,避免阻塞视频循环造成卡顿""" def _beep(): for _ in range(3): winsound.Beep(1000, 200) threading.Thread(target=_beep, daemon=True).start()

报警音一定要异步。winsound.Beep是阻塞调用,直接在循环里连响三次,画面会明显停顿,帧率掉下来之后 EAR 的时序统计全乱。daemon=True保证主线程退出时不会被这个线程拖住。跨平台的话用pygame.mixer播一段短音频,思路一样:预加载、单次触发、不等待。

界面文字部分,除了显示实时 EAR、MAR、俯仰角和各自的阈值,强烈建议把最近 5 秒的 EAR 曲线画在一个小画布上。调试阈值时,这条曲线比任何日志都直观——你能一眼看到是阈值太高把正常眨眼算成了闭眼,还是太低导致闭眼没触发。

4. 阈值不是拍脑袋:三组参数怎么在自己的摄像头上标定

网上流传的 EAR 阈值 0.2、MAR 阈值 0.6 这类数字,来自特定数据集和特定设备,直接抄过来大概率水土不服。摄像头焦距、安装角度、镜片反光、环境光,每一项都能把绝对值推移 20% 以上。真正可靠的做法是在自己的硬件上跑一遍标定,用数据说话。

4.1 采集基线:正常态和疲劳态各要多少帧

标定脚本要做的事很朴素:连续录 60 秒正常睁眼状态,再录 60 秒刻意闭眼、张嘴、低头的状态,把每帧的三个指标写进 CSV。30 帧每秒下各 1800 帧,统计上足够稳定,再多就是浪费时间。

import csv import time def collect_baseline(cap, landmark_detector, seconds=60, label="normal", out_path="baseline.csv"): """按标签采集基线数据,label 取 normal 或 fatigue""" writer_rows = [] start = time.time() while time.time() - start < seconds: ok, frame = cap.read() if not ok: continue gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) for pts, _ in landmark_detector.detect(gray): ear = (eye_aspect_ratio(pts[36:42]) + eye_aspect_ratio(pts[42:48])) / 2.0 mar = mouth_aspect_ratio(pts[48:60]) angles = head_pose_angles(pts, frame.shape[1], frame.shape[0]) if angles is None: continue writer_rows.append([label, round(ear, 4), round(mar, 4), round(angles[0], 2), round(angles[1], 2), round(angles[2], 2)]) with open(out_path, "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["label", "ear", "mar", "pitch", "yaw", "roll"]) w.writerows(writer_rows)

左右眼的 EAR 取平均,比只算一只眼稳定得多。头部姿态返回None时必须跳过这一帧而不是写零,写零会污染后面的分位数统计。标签列是必须的,因为下一步要算的是两种情况的分位数,没有标签就只能靠肉眼看分布,效率低很多。

4.2 EAR 阈值:分位数比均值靠谱

拿到基线 CSv 之后,不要看均值,看分位数。正常睁眼状态的 EAR 应该取第 5 百分位附近作为阈值的上界参考——意思是 95% 的正常睁眼帧都高于这个值。刻意闭眼状态取第 95 百分位附近作为下界参考。两个分位数之间如果有 0.05 以上的间隔,说明区分度良好,阈值取中间偏下的位置;如果两个区间大面积重叠,说明当前摄像头或者安装角度根本分不开睁眼闭眼,此时调阈值是徒劳的,得先去改硬件。

常见的翻车情形是戴反光镜片:镜片把屏幕光反射进镜头,眼睑边缘在灰度图上糊成一团,关键点抖动剧烈,EAR 在 0.15 到 0.35 之间乱跳。这种情况下降采样加中值滤波能救一点,但根本上还是建议把摄像头挪到不逆光的位置。

4.3 MAR 与 HPE:阈值必须和持续时间绑定

张嘴这个动作和闭眼有个本质区别:说话、咀嚼都会让 MAR 升高,单看幅值区分不开。所以 MAR 的判定必须写成"幅值 + 持续帧数"的组合,缺一不可。

参数含义建议初值实测调整方向
EAR_THRESH闭眼判定线0.22戴眼镜下调 0.02 到 0.03
EAR_CLOSE_FRAMES连续闭眼帧数3帧率低于 20 时减到 2
MAR_THRESH张嘴判定线0.55嘴型偏小的人下调
MAR_HOLD_FRAMES打哈欠持续帧数15说话频繁的场景上调
PITCH_THRESH低头角度阈值15 度摄像头仰角不同需重标
PITCH_HOLD_FRAMES点头持续帧数20结合 PERCLOS 窗口调整
PERCLOS_WINDOW闭眼统计窗口90 帧与帧率挂钩,约 3 秒
PERCLOS_THRESH疲劳判定线0.35灵敏度与误报的平衡点

俯仰角的初值不能照抄度数,因为摄像头安装俯仰直接影响零点。正确做法是让人坐正、平视前方,记录 30 秒的俯仰角均值记为零点,之后所有判定都改成相对于零点的偏移量。这一步不做,换个摄像头位置整套参数就得重来。

4.4 三路信号怎么融合:加权不如"或"来得实在

初学者容易想到给三个指标各赋一个权重再求和,实际上权重怎么定完全没有依据,调起来是个无底洞。更实用的方案是分级判定:任意单一指标严重越界(比如 PERCLOS 超过 0.5,或连续低头超过 2 秒)直接报疲劳;两个以上指标轻度越界(PERCLOS 超过 0.3 且哈欠计数在一分钟内超过 3 次)也报疲劳。这种"或"结构的好处是每一条规则的物理意义都清楚,排查误报时能直接定位是哪一路触发的。

5. 避坑与排查:跑不通、误报、卡顿的五个真实成因

这一节的东西基本都是踩过之后才愿意写下来的,按现象、原因、解决三段来过一遍,对照着查能省掉大半天。

5.1 画面卡成幻灯片,帧率从 30 掉到 5

现象是程序刚启动还流畅,几十秒后画面明显迟滞,EAR 曲线开始出现大段平台。

原因通常有两个叠加:报警音阻塞主循环,以及上采样次数设成了 1 甚至 2。winsound.Beep每次调用阻塞 200 毫秒,连响三次就是 0.6 秒,这期间一帧都没采集,窗口统计的帧数基准就失真了。上采样则让检测耗时直接翻倍。

解决方式是报警放子线程,并且在启动时把上采样设为 0,跑一遍看帧率,如果人脸检出率不够再逐级往上加。另外在循环里加一个帧率打印,每 30 帧输出一次实际 FPS,这个数字比任何主观感受都准。

5.2 明明睁着眼,系统一直报疲劳

现象是报警频繁,但回放录像看人精神得很。

第一个原因是 EAR 阈值照抄了别人的数字,而你的摄像头俯角更大,眼睑在图像上本来就显窄,正常睁眼的 EAR 只有 0.2 左右,一抄 0.22 就全线低于阈值。第二个原因是关键点抖动,两侧眼角点在某些帧里跳到眉毛上,导致分母突然变大一倍,EAR 掉到 0.1 以下。

解决方式是先跑标定脚本看自己数据的分布,阈值按分位数定;再在计算前对关键点做一次三点滑动平均,用前后帧的坐标把抖动压掉。滑动平均会引入一帧延迟,在人脸追踪这种低频信号上完全可以接受。

5.3 一低头就报警,看仪表盘也报警

现象是每次视线短暂离开正前方,报警就响。

根本原因是 EAR 判定和头部姿态没有耦合。低头时眼睛在图像上被压缩,EAR 自然下降,这属于几何效应而不是真的闭眼。解决方式是加一个门控:当俯仰角偏离零点超过一定角度时,暂停 EAR 的闭眼累加,但仍保留姿态本身的判定。换句话说,低头这件事交给姿态模块管,眼睛模块只在头基本正对摄像头时才发言。

这个门控逻辑不做,误报率基本不可能降到 10% 以下。

5.4 换了台电脑,模型加载直接报错

现象是代码在开发机上正常,拷到另一台机器上启动就抛异常,提示找不到预测器文件。

原因是路径写成了绝对路径,或者模型文件名在传输过程中被改成了带括号的副本名。另一种可能是文件没有完整拷贝,解压中断导致体积不对。

解决办法是统一用相对于工程根目录的路径,并在启动时做一次存在性检查,文件缺失时打印明确提示而不是让底层库抛出难懂的异常。路径拼接用pathlib,避免 Windows 和 Linux 的斜杠差异。

5.5 扭头看后视镜,人脸丢失后计数被清零

现象是驾驶员扭头两秒再转回来,之前累积的哈欠次数和 PERCLOS 全部归零。

原因是代码里检测不到人脸就continue,顺手把计数器重置了。这个行为在演示时看不出来,实测时会严重低估疲劳程度。

正确的处理是:人脸丢失只冻结统计,不清零。用一个lost_frames计数,超过 30 帧(约 1 秒)仍然没人脸,才把状态标记为"未知"并暂停判定;重新检测到人脸后接着之前的累加继续走,但把丢失期间的帧从 PERCLOS 分母里剔除,否则会人为拉低闭眼占比。

6. 让它从"能跑"到"可信":用固定视频做回放回归

答辩前一晚最怕的事,是白天还好的系统突然开始乱报。阈值调过、代码改过、摄像头挪过位置,任何一个变动都可能让之前的表现失效。我一般的做法是把一段包含正常驾驶、眨眼、打哈欠、低头、扭头五种片段的录像固化成测试素材,每次改完参数就跑一遍回放,用同一份数据比对结果。

回放不需要摄像头,把VideoCapture的参数从 0 换成文件路径即可,帧率和时序完全一致,这也是把指标计算单独抽成模块的最大回报。

import csv def evaluate(csv_path, pred_fn): """csv_path 含 label 列(0 正常 / 1 疲劳),pred_fn 输入一行返回 0/1""" tp = fp = tn = fn = 0 with open(csv_path, encoding="utf-8") as f: for row in csv.DictReader(f): truth = int(row["label"]) pred = pred_fn(row) if pred == 1 and truth == 1: tp += 1 elif pred == 1 and truth == 0: fp += 1 elif pred == 0 and truth == 0: tn += 1 else: fn += 1 acc = (tp + tn) / max(tp + tn + fp + fn, 1) fpr = fp / max(fp + tn, 1) # 误报率:正常被误判为疲劳 fnr = fn / max(fn + tp, 1) # 漏报率:疲劳被漏掉 return {"accuracy": round(acc, 3), "false_positive": round(fpr, 3), "false_negative": round(fnr, 3)}

这段代码的价值在于把"感觉还行"变成三个能对比的数字。false_positive对应需求里说的误报率,false_negative对应漏报,两个指标是此消彼长的关系:把阈值调低更灵敏,漏报降下去但误报升上来。工程上通常优先压误报,因为报得太勤驾驶员会直接关掉系统,那就等于零。

一套可以参考的回归用例设计是这样的:

用例内容期望输出关注指标
C1正常睁眼 60 秒不报警误报率
C2每秒眨眼一次,持续 30 秒不报警误报率
C3刻意闭眼 3 秒报警检出延迟
C4连续打哈欠 3 次报警漏报率
C5低头 5 秒报警检出延迟
C6扭头离开画面 2 秒再回状态延续不清零状态连续性

跑完这六条,基本能覆盖答辩时老师会问的绝大多数场景。检出延迟也值得单独记一笔,从动作发生到报警响起的帧数,超过 45 帧(约 1.5 秒)就说明窗口设得太长,响应会显得迟钝。

顺手提一个容易被忽略的细节:回归用的 CSV 里,label是按片段整体标注的,而 PERCLOS 是窗口统计量,两者时间粒度不一致。我吃过这个亏——用逐帧标签去对窗口判定,算出来的准确率完全没意义。后来改成按片段中后段取样,跳过窗口填充期,数字才稳定下来。从那以后我每次动阈值,都强制走一遍这六条回放,一条不过就不往下调。希望帮到你。

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

返回列表