简介:围绕基于 dlib 模型的疲劳驾驶检测系统展开的毕业设计文档,面向计算机视觉、程序设计与 Web 设计方向的学生及开发者,聚焦驾驶员眨眼、打哈欠、点头等疲劳行为的识别与预警。文档以单份 docx 交付,压缩包约 1.13MB,按导论、相关技术、系统分析、系统设计等章节展开,涉及 OpenCV 图像处理、dlib 68 个面部特征点、PERCLOS 指标、人体姿态估计,以及需求分析、整体框架、可行性与模块划分等内容。读者可从中梳理面部采集、图像预处理、人脸检测、疲劳行为识别和报警提示的实现路径,理解从算法选型到系统集成的设计逻辑,适合作为课程设计、毕业设计选题参考或相关项目的前期资料。目前已有 1958 人学习,便于对照同类方案查漏补缺。
1. 阈值比模型更决定成败:这套 dlib 疲劳检测系统能干什么
先说个反直觉的结论。这套基于 dlib 的疲劳驾驶检测系统,真正决定它好不好用的不是 68 个特征点准不准,而是 EAR、MAR、头部俯仰角这几个阈值在你自己的脸、你的摄像头、你的光照条件下能不能站得住。项目本体是一份完整的计算机视觉方向的毕业设计工程:OpenCV 抓帧做预处理,dlib 出人脸框和 68 个关键点,再用 EAR、MAR、HPE 三个几何指标把眨眼、打哈欠、点头量化成数字,最后用 PERCLOS 时间窗做疲劳判决,触发界面提示和声音报警。它适合两类人:一类是要交毕业设计、需要一套有界面能跑起来的完整工程的人;另一类是想在车载设备或工位监控上做个低成本视觉原型的开发者。接下来按能干什么、怎么跑、怎么改的顺序往下拆。
2. 选型与骨架:OpenCV 抓帧、dlib 出点、三个几何指标拼成一条流水线
2.1 三条技术路线为什么最后只剩面部特征
疲劳检测在这个领域里有三条成熟路线,选型阶段必须先把它们摆平,否则后面所有代码都是白写。
第一条是基于生理信息。心电、脑电、肌电、心率变异性这些信号确实能真实反映疲劳程度,精度在所有方案里最高。问题在于接触式采集:电极要贴在身上,驾驶员动作一大就掉,视线和操作也会被干扰,实际装车时几乎没人愿意配合。第二条是基于车辆信息,靠方向盘转角频率、车道偏离、速度波动来反推。这条路非接触、成本低,但采集到的数据受车型、路况、驾驶习惯影响极大,权重怎么分配至今没有统一说法,阈值没法固定,出了误判也很难解释清楚是哪一步错了。
第三条是基于面部信息,这几年成了主流。一个普通摄像头对着驾驶员,靠眨眼频率、哈欠、点头这些外显行为判断状态,非接触、硬件便宜、结果直观可解释——你甚至能截一帧图给人看:"这一刻他闭眼超过两秒"。这套系统选的就是它。
选它不代表它没短板。面部方案对光照极其敏感,强逆光下 dlib 的 HOG 检测器会直接丢脸;戴粗框眼镜、口罩、墨镜会破坏特征点定位;夜间只有仪表盘微光时,不补红外基本没法用。所以在需求分析里我把适用范围划得很清楚:白天强光、夜间弱光、带透明镜片眼镜这三种场景要能跑,人脸丢失时不做判定但保留历史累计值。这条边界写进代码里,比写在文档里有用得多。
2.2 六个模块的职责边界
整个系统拆成六个模块,每个模块的输入输出必须明确,否则调试时会陷入"到底是检测错了还是判定错了"的黑匣子。下面这张表是我从设计文档里整理出来的实际划分:
| 模块 | 输入 | 输出 | 关键依赖 |
|---|---|---|---|
| 视频采集 | 摄像头索引 | BGR 帧 | cv2.VideoCapture |
| 图像预处理 | BGR 帧 | 灰度图、缩放图 | cvtColor、equalizeHist、medianBlur |
| 人脸检测与定位 | 灰度图 | 人脸矩形、68 点坐标 | dlib.get_frontal_face_detector、shape_predictor |
| 特征计算 | 68 点坐标 | EAR、MAR、pitch/yaw/roll | numpy、cv2.solvePnP |
| 疲劳判决 | 三个指标序列 | 疲劳等级 0/1/2 | collections.deque 滑窗 |
| 报警与界面 | 疲劳等级、原始帧 | 提示文字、蜂鸣 | Tkinter、winsound |
这里有个容易被忽略的设计点:特征计算和疲劳判决必须分开。前者是纯函数,输入坐标输出浮点数,可以单独拿一段视频离线跑;后者是有状态的,维护着时间窗口和计数器。混在一起写的话,你永远没法回答"到底是 EAR 算错了还是窗口太小"这个问题。我当时就是把这两块硬拆开,后面调阈值的时候省了至少两天。
2.3 预处理链路的三个动作和它们的实际收益
原始帧直接丢给 dlib 检测器是能跑的,但检出率会明显偏低,尤其是驾驶员坐得稍远、车内光线不均的时候。预处理链路加三个动作,每个都有明确目的,不是为了凑代码行数。
import cv2 import numpy as np def preprocess(frame, det_scale=0.5, blur_ksize=3): """ frame: 原始 BGR 帧 det_scale: 检测用缩放比例, 0.5 表示缩小一半, 提升速度 blur_ksize: 中值滤波核大小, 必须是奇数 返回: (gray_full, gray_small) 全尺寸灰度图用于取点, 缩放图用于检测 """ # 1. 转灰度: dlib 的 HOG 检测器只吃单通道图 gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 2. 直方图均衡: 夜间仪表盘微光、隧道进出口明暗突变时拉升对比度 gray = cv2.equalizeHist(gray) # 3. 中值滤波: 车载摄像头噪点以椒盐噪声为主, 中值比高斯更对症 gray = cv2.medianBlur(gray, blur_ksize) # 4. 缩放一份给检测器用, 人脸较大时能显著降耗时 h, w = gray.shape[:2] gray_small = cv2.resize(gray, (int(w * det_scale), int(h * det_scale))) return gray, gray_small这段代码里三个参数的含义值得展开。det_scale控制检测分辨率,人脸在画面里占比大时取 0.5 甚至 0.4,速度能翻倍;但如果你把摄像头装在方向盘后方一米开外,人脸只占画面十分之一,就要改成 1.0 甚至用上采样,否则 dlib 直接检不到。blur_ksize一般固定 3,调到 5 会把眼睑边缘也糊掉,EAR 计算会跟着漂。
直方图均衡这一步有个血泪经验:cv2.equalizeHist只接受单通道八位图,直接对彩色帧调用会抛异常。有人想保留彩色画面做均衡,正确做法是转到 YCrCb 空间只处理 Y 通道再转回来,但这套系统显示层用的是原始彩色帧,均衡只服务于检测,所以先转灰度就够了,没必要多绕一圈。
注意缩放后的坐标不能直接拿去取 68 点。dlib 的shape_predictor接收的是"原图 + 矩形",如果你在缩小图上检测到人脸框,必须把矩形按1/det_scale的倍数还原回原图坐标系再送进去,否则取出来的关键点位置会整体偏移。这个坑我第一次写的时候踩得结结实实,画面上眼睛标在了下巴上。
3. 核心算法拆解:EAR、MAR、HPE 如何从 68 个坐标点算出疲劳证据
3.1 68 个特征点的分组与索引约定
dlib 的shape_predictor_68_face_landmarks.dat输出的是一个 68 行 2 列的数组,每行是一个点的 (x, y)。这 68 个点的编号是固定的,必须记牢,因为后面所有计算都靠索引取值:
| 区域 | 索引范围 | 点数 | 用途 |
|---|---|---|---|
| 下颌轮廓 | 0–16 | 17 | 一般不用,可用于人脸对齐 |
| 右眉 | 17–21 | 5 | 辅助判断 |
| 左眉 | 22–26 | 5 | 辅助判断 |
| 鼻梁与鼻尖 | 27–35 | 9 | 鼻尖 30 用于头部姿态 |
| 右眼 | 36–41 | 6 | EAR 右眼 |
| 左眼 | 42–47 | 6 | EAR 左眼 |
| 外唇 | 48–59 | 12 | MAR 外唇 |
| 内唇 | 60–67 | 8 | MAR 内唇(更灵敏) |
左右眼的 6 个点排列顺序是固定的:第 1 个和第 4 个是内外眼角,第 2、3 个在上眼睑,第 5、6 个在下眼睑。这个顺序决定了 EAR 公式怎么写,写反了算出来的值会变成倒数。
# 索引常量, 全局只定义一次, 避免各处魔法数字 RIGHT_EYE = list(range(36, 42)) LEFT_EYE = list(range(42, 48)) OUTER_MOUTH = list(range(48, 60)) INNER_MOUTH = list(range(60, 68)) def get_points(shape, idx_list): """把 dlib 的 shape 对象转成 numpy 数组, 方便后面做向量运算""" return np.array([[shape.part(i).x, shape.part(i).y] for i in idx_list], dtype=np.float64)统一转成 numpy 是必要的。dlib 的shape.part(i)每次调用都有开销,逐帧算三个指标要取几十次点,累积起来在低端 CPU 上能吃掉两三毫秒,转成数组后一次取完就没事了。
3.2 EAR 眼睛长宽比:眨眼判定的核心公式
EAR 的思路很朴素:眼睛睁开时是一个接近椭圆的形状,高度和宽度有稳定比例;闭眼时高度趋近于零,比例跟着塌下去。公式用两组垂直距离之和除以两倍的水平距离,做归一化,这样人坐得远近都不影响判断。
def eye_aspect_ratio(pts): """ pts: 6x2 数组, 顺序为 [左眼角, 上眼睑1, 上眼睑2, 右眼角, 下眼睑1, 下眼睑2] 返回: 浮点数, 睁眼正常值约 0.28~0.35, 闭眼低于 0.15 """ # 两组垂直距离: 上眼睑到对应下眼睑 v1 = np.linalg.norm(pts[1] - pts[5]) v2 = np.linalg.norm(pts[2] - pts[4]) # 水平距离: 内眼角到外眼角 h = np.linalg.norm(pts[0] - pts[3]) # 分母兜底, 防止侧脸时水平距离退化为 0 导致除零 if h < 1e-6: return 0.0 return (v1 + v2) / (2.0 * h)代码里pts[0]和pts[3]对应索引 36 和 39(右眼),42 和 45(左眼)。垂直方向用 37-41 和 38-40 两组,平均一下能抵消单组点在侧脸时的抖动。
实际取值上,正常人睁眼 EAR 在 0.28–0.35 之间浮动,闭眼会掉到 0.10 以下,中间有一段过渡区。阈值定在 0.22 左右比较稳,但这不是绝对值——不同人的眼型差异很大,单眼皮和双眼皮能差出 0.05。所以这套系统在正式判定前会做一段基线标定,取前若干帧睁眼状态的均值作为个人基线,阈值按基线的一个比例下浮。这一步不做,换个人用就误报。
还有一个细节:左右眼要分别算再取平均,不能只算一只。驾驶员侧头看后视镜时,靠近摄像头的那只眼可能被鼻梁挡住一半,单眼判定会直接崩,取平均能把这个影响压下去。
3.3 MAR 嘴部长宽比:把打哈欠和说话区分开
MAR 的公式结构和 EAR 完全一样,换个部位而已。区别在于嘴巴的点位更复杂,外唇 12 个点、内唇 8 个点,用哪一组会直接影响灵敏度。
def mouth_aspect_ratio(pts): """ pts: 外唇 12 个点 (索引 48~59) 返回: 浮点数, 闭嘴约 0.05~0.15, 打哈欠张嘴可达 0.6 以上 """ # 上下唇垂直距离, 取两组取平均 v1 = np.linalg.norm(pts[3] - pts[9]) # 51 - 57 v2 = np.linalg.norm(pts[2] - pts[10]) # 50 - 58 # 嘴角水平距离 h = np.linalg.norm(pts[0] - pts[6]) # 48 - 54 if h < 1e-6: return 0.0 return (v1 + v2) / (2.0 * h)选外唇而不是内唇,原因是内唇在说话时开合幅度太大,正常交流就能冲到 0.4,误报率极高。外唇因为要考虑嘴唇本身的厚度,同样的张嘴幅度算出来的值更保守,打哈欠这种大幅度张嘴才能突破 0.6。
但光靠 MAR 一个阈值区分不了打哈欠和说话,这两件事在单帧上看起来太像了。所以判定必须加时间维度:打哈欠是一个持续一到三秒、幅度由小到大再回落的过程,说话是快速抖动。工程上的做法是记录 MAR 连续超过阈值的帧数,帧数落在 20 到 90 帧之间(30fps 下约 0.7 到 3 秒)才计一次哈欠,超出的当成长时间张嘴,忽略掉。
满足这个条件才算一次有效哈欠,单次不报警,累计到一定次数或者和闭眼指标叠加才升级疲劳等级。这一层时间过滤是把误报率从"没法看"压到"能接受"的关键,比调阈值有用得多。
3.4 HPE 头部姿态估计:pitch 角判点头,yaw 角防作弊
头部姿态这一块是整套系统里最容易翻车的部分,因为它引入了三维运算,一步错处处错。
思路是:dlib 给的是图像上的二维像素坐标,我们手里还有一组人脸的通用三维模型坐标,知道至少 4 组"三维点 ↔ 二维点"的对应关系,就能用cv2.solvePnP解出相机的旋转向量和平移向量,旋转向量转成旋转矩阵再转欧拉角,就得到头部的俯仰、偏航、翻滚。
# 通用人脸三维模型坐标(单位 mm), 顺序与下面取的二维点一一对应 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_pose(shape, frame_size): """ 返回 (pitch, yaw, roll), 单位度 pitch 为负表示低头, 这套系统里 |pitch| > 15 视为低头 """ h, w = frame_size img_pts = np.array([[shape.part(i).x, shape.part(i).y] for i in LANDMARK_IDX], dtype=np.float64) # 简化相机内参: 焦距取画面宽度, 主点取画面中心 # 真实标定过的内参更准, 但原型阶段这样够用 focal = w cam_matrix = np.array([[focal, 0, w / 2.0], [0, focal, h / 2.0], [0, 0, 1.0]], dtype=np.float64) dist = np.zeros((4, 1)) ok, rvec, tvec = cv2.solvePnP(MODEL_POINTS, img_pts, cam_matrix, dist, flags=cv2.SOLVEPNP_ITERATIVE) if not ok: return 0.0, 0.0, 0.0 rmat, _ = cv2.Rodrigues(rvec) proj = np.hstack((rmat, tvec)) _, _, _, _, _, _, euler = cv2.decomposeProjectionMatrix(proj) pitch, yaw, roll = euler.flatten() # decomposeProjectionMatrix 返回的角度可能落在 0~180, 转成 -90~90 更方便判断 pitch = pitch - 180 if pitch > 90 else pitch yaw = yaw - 180 if yaw > 90 else yaw roll = roll - 180 if roll > 90 else roll return pitch, yaw, roll参数上有几处必须说清楚。MODEL_POINTS是一套通用的人脸尺寸假设,单位毫米,来自 dlib 官方示例,别自己瞎编,改错一个坐标整个角度就歪了。focal = w是拿画面宽度当焦距的近似,对普通广角摄像头误差在 10% 以内,判点头足够了;如果你要用作精细测量,得拿棋盘格老老实实标一次内参。SOLVEPNP_ITERATIVE适合点数少的场景,用 EPNP 也行,但 ITERATIVE 在正脸情况下更稳。
俯仰角判定的坑在于阈值符号。低头时 pitch 可能是负的也可能是正的,取决于模型点顺序和相机坐标系的定义方向,不同人抄同一份代码结果可能相反。我一般做法是:让测试者故意低头,打印出实际角度值,看到底往哪个方向走,再定符号。这一步不实测,光看公式,十个有八个搞反。
另一个必须加的保护是 yaw 角。驾驶员转头看后视镜、低头看手机,这两种动作都会让 pitch 出现大幅变化,但只有真正的疲劳点头是"慢速下沉、猛地抬起"的节奏。所以判定逻辑里加了 yaw 约束:|yaw| 超过 25 度时暂停点头计数,避免把正常观察路况算成瞌睡。
4. 判决与报警:PERCLOS 滑窗、阈值标定实验和四页面可视化界面
4.1 PERCLOS 滑窗:把单帧指标变成时间序列判决
PERCLOS 的定义是单位时间内眼睛闭合时间所占的比例,原始公式就是闭眼时长除以总时长再乘百分之百。它比"眨眼次数"更可靠的地方在于,疲劳的典型表现不是眨得快,而是闭眼时间变长——微睡眠状态下一次闭眼可能持续两秒以上,而正常眨眼只有一百多毫秒。
工程实现上不能真的去记时间戳,太麻烦。用固定长度的帧队列更简单:维持一个长度为 N 的 deque,每帧往里塞一个布尔值(这一帧是否闭眼),队列满了就自动弹出最老的,然后算队列里 True 的比例。
from collections import deque class FatigueDetector: def __init__(self, fps=30, window_sec=30, ear_th=0.22, mar_th=0.62, pitch_th=15.0, perclos_mild=0.15, perclos_severe=0.30): # 滑窗长度按秒换算成帧数, 30fps 下 30 秒 = 900 帧 self.window = int(fps * window_sec) self.eye_buf = deque(maxlen=self.window) self.mar_th = mar_th self.pitch_th = pitch_th self.perclos_mild = perclos_mild self.perclos_severe = perclos_severe self.ear_th = ear_th self.closed_frames = 0 # 连续闭眼帧 self.blink_count = 0 # 累计眨眼次数 self.yawn_frames = 0 # 连续张嘴帧 self.yawn_count = 0 # 累计哈欠次数 self.nod_frames = 0 # 连续低头帧 self.nod_count = 0 # 累计点头次数 def update(self, ear, mar, pitch, yaw): """每帧调用一次, 返回 (疲劳等级, 各项统计)""" # --- 眼部: 同时维护实时计数和 PERCLOS 滑窗 --- closed = ear < self.ear_th self.eye_buf.append(closed) if closed: self.closed_frames += 1 else: # 闭眼后重新睁眼, 且持续了 2~15 帧, 计一次有效眨眼 if 2 <= self.closed_frames <= 15: self.blink_count += 1 self.closed_frames = 0 # --- 嘴部: 连续 20~90 帧才算一次哈欠, 过滤说话抖动 --- if mar > self.mar_th: self.yawn_frames += 1 else: if 20 <= self.yawn_frames <= 90: self.yawn_count += 1 self.yawn_frames = 0 # --- 头部: yaw 过大时暂停点头计数, 避免转头误判 --- if abs(yaw) < 25.0 and pitch < -self.pitch_th: self.nod_frames += 1 else: if self.nod_frames >= 10: # 低头持续 0.3 秒以上才算 self.nod_count += 1 self.nod_frames = 0 # --- PERCLOS 计算 --- perclos = sum(self.eye_buf) / len(self.eye_buf) if self.eye_buf else 0.0 # --- 综合判决: 单指标不报警, 多指标叠加才升级 --- level = 0 if perclos >= self.perclos_severe or self.closed_frames >= 20: level = 2 # 重度: 立即声音报警 elif perclos >= self.perclos_mild or self.yawn_count >= 1 and self.nod_count >= 1: level = 1 # 轻度: 屏幕提示 return level, { "ear": round(ear, 3), "mar": round(mar, 3), "pitch": round(pitch, 1), "perclos": round(perclos, 3), "blink": self.blink_count, "yawn": self.yawn_count, "nod": self.nod_count, }这段代码里每个数字都有来历。2 <= closed_frames <= 15是为了过滤掉瞬时的检测抖动:单帧闭眼很可能是特征点跳了,不是真眨。超过 15 帧(半秒)就不算眨眼了,那是闭眼或微睡眠,单独由closed_frames >= 20这条规则升级到重度报警。20 <= yawn_frames <= 90同理,低于 20 帧是说话,高于 90 帧是长时间张嘴打哈欠或者干脆在嚼东西。
perclos_mild=0.15和perclos_severe=0.30是经验值。正常清醒状态下 PERCLOS 在 0.05 以下,开始犯困会爬到 0.15 左右,到 0.30 基本就是打瞌睡了。窗口长度用 30 秒,太短噪声大,太长反应迟钝——三分钟窗口的 PERCLOS 很稳,但你得等三分钟才能等到报警,实际早就睡着了。
4.2 阈值标定实验:EAR、MAR、HPE 各调了几轮
这套系统的设计文档里专门有一节叫阈值优化,写的就是这三个指标怎么定。做法很土但有效:录一段自己正常驾驶状态的视频,一段故意打哈欠的视频,一段故意点头的视频,然后离线跑指标、打印曲线、看分布。
EAR 的结论是:睁眼时稳定在 0.30 上下,自然眨眼时最低到 0.14–0.18,用力闭眼能到 0.08。阈值定在 0.22,落在这两段之间的空档里,误判空间最大。后来发现戴眼镜的人睁眼基线会低 0.02 左右,就把标定环节加上了,前 100 帧取中位数作为个人基线,阈值取基线的 0.7 倍。
MAR 的结论是:闭嘴 0.06,正常说话 0.25–0.40,打哈欠峰值 0.60–0.75。阈值定 0.62 能避开说话,但偶有漏检——张嘴幅度小的人可能只到 0.55。补偿办法是靠时间过滤而不是降阈值,因为降阈值会把说话全放进来。
HPE 的结论最麻烦。低头 20 度时算出来的 pitch 是 -18 到 -22,点头过程中峰值能到 -35 左右。阈值定 -15 度,同时要求持续 10 帧以上。这里有个翻车点:戴眼镜的人,鼻梁点位的检测会因为镜框反光偏移,导致 pitch 整体偏个三五度。所以判定用的不是绝对值,而是相对于标定阶段记录的"正脸基准角"的偏移量。
4.3 四页面可视化界面与报警模块
界面用的是 Tkinter,轻、不需要额外装运行时、打包成 exe 也小。设计文档里分了四个页面:行车小贴士页(启动时的安全提示)、开始页(选择摄像头索引和参数)、介绍页(系统说明)、检测页(实时画面加指标面板)。四个页面用tkinter.ttk.Notebook或者一个简单的Frame.raise()切换都行,后者更可控。
真正麻烦的是线程。视频采集必须放在独立线程里跑,Tkinter 的界面循环在主线程,两者直接交叉调用会卡死甚至崩溃。标准做法是用queue.Queue做单向传递:
import threading, queue, tkinter as tk class DetectApp: def __init__(self): self.root = tk.Tk() self.frame_q = queue.Queue(maxsize=2) # 只留最新帧, 满了丢弃旧帧 self.stat_q = queue.Queue(maxsize=8) self.running = False self._build_ui() def _build_ui(self): self.canvas = tk.Canvas(self.root, width=640, height=480) self.canvas.pack() self.label = tk.Label(self.root, text="等待开始", font=("微软雅黑", 14)) self.label.pack() self.root.after(30, self._refresh) # 每 30ms 刷新一次界面 def _refresh(self): """界面刷新只从队列取数据, 绝不在这里跑检测""" try: img = self.frame_q.get_nowait() # PIL 转 ImageTk 后画到 canvas, 略 except queue.Empty: pass try: stats = self.stat_q.get_nowait() self.label.config(text=f"EAR {stats['ear']} PERCLOS {stats['perclos']}") except queue.Empty: pass self.root.after(30, self._refresh) def start(self): self.running = True threading.Thread(target=self._worker, daemon=True).start() self.root.mainloop()maxsize=2这个细节很关键。如果队列不设上限,视频线程产出帧的速度快过界面消费速度,队列会越积越长,画面延迟越来越高,最后变成"人已经抬头了,屏幕上还在显示五秒前的低头"。设成 2 配合put_nowait加异常吞噬,就变成了自动丢帧,永远是新的覆盖旧的。
报警模块分两级。轻度疲劳只在界面上把文字变黄并弹提示条;重度疲劳要出声音。Windows 上用winsound.Beep(1000, 500)最简单,跨平台就用pygame.mixer播一段提示音,或者用simpleaudio。注意声音播放不能阻塞视频线程,否则响一下卡一下,帧率直接掉下来,得丢进单独的线程或者用异步播放。
5. 跑通与排查:依赖安装、最小可运行脚本和五条踩坑记录
5.1 环境搭建与最小可运行脚本
这套工程的依赖不多,但有两个是硬骨头。Python 版本建议 3.8–3.10,太新的版本 dlib 预编译包常常还没跟上。
# 建议先建虚拟环境, 避免全局污染 python -m venv venv venv\Scripts\activate # Windows source venv/bin/activate # Linux / macOS # 顺序别乱: 先装 numpy, 再装其余 pip install numpy==1.24.3 pip install opencv-python==4.8.0.76 pip install dlib==19.24.1 pip install pillow # dlib 装不上的话走源码编译, 前提是先装好 CMake 和 C++ 编译工具链 # Windows 需要 Visual Studio Build Tools 的"使用 C++ 的桌面开发"组件 pip install cmake pip install dlib --no-binary dlib模型文件shape_predictor_68_face_landmarks.dat有六十多兆,得单独下载后放到工程目录下。路径别用中文,别用相对路径靠运行目录猜,用os.path.dirname(__file__)拼绝对路径,省得换台机器就报文件找不到。
跑通的最小验证脚本不用界面,先确认摄像头、检测器、关键点三件事都对:
import cv2, dlib, os detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) while True: ok, frame = cap.read() if not ok: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 第二个参数是上采样次数, 1 表示放大一倍后检测, 小脸更易检出 faces = detector(gray, 1) for rect in faces: shape = predictor(gray, rect) # 画左右眼轮廓 for i in list(range(36, 42)) + list(range(42, 48)): cv2.circle(frame, (shape.part(i).x, shape.part(i).y), 2, (0, 255, 0), -1) cv2.imshow("landmarks", frame) if cv2.waitKey(1) & 0xFF == 27: # ESC 退出 break cap.release() cv2.destroyAllWindows()detector(gray, 1)里的 1 是上采样次数,每加一次图像放大一倍,检测耗时大约翻四倍。人脸大就传 0,人脸小传 1,别传 2——除非你想看幻灯片。cv2.waitKey(1)必须调用,否则 imshow 窗口画不出来,这是 OpenCV 的经典坑。
5.2 五条踩坑记录
现象:pip install dlib报错,提示找不到 CMake 或者编译一堆 C++ 报错。原因:没有预编译 wheel 匹配当前 Python 版本和系统时,pip 会退回源码编译,这时依赖本地的 C++ 工具链。解决:优先降 Python 版本到 3.9 或 3.10 找现成 wheel;不行就装 CMake 加 Visual Studio Build Tools,确保cl.exe在 PATH 里。用 conda 装conda install -c conda-forge dlib成功率更高,能省掉一整个下午。
现象:cap.read()一直返回 False,或者画面全黑。原因:摄像头索引不对、被其他程序占用、或者笔记本的外接摄像头和内置摄像头索引不同。解决:把VideoCapture(0)依次试到 4,看哪个能出画面。另外先关掉微信、腾讯会议、浏览器里所有可能占摄像头的页面。Windows 上还要注意隐私设置里的摄像头权限开关,这个开关关着的话 OpenCV 打不开摄像头但不报错,属于玄学级问题。
现象:画面里人明明在,但一帧都检测不到人脸。原因:光照过暗或逆光、人脸在画面里占比太小、传进检测器的不是灰度图。解决:先确认传的是单通道gray,彩色图传进去 dlib 会静默返回空列表。再确认尺寸,人脸宽度小于 80 像素时把上采样次数改成 1。夜间场景基本无解,除非换红外摄像头,这个在需求阶段就该写清楚。
现象:界面越跑越卡,五六分钟后完全跟不上。原因:帧队列没有长度上限,视频线程产出的帧越积越多;或者把耗时的人脸检测放在了界面刷新回调里。解决:队列设maxsize并用put_nowait配合 try 吞掉queue.Full异常;检测逻辑一律放工作线程,界面线程只负责取最新结果渲染。
现象:误报严重,人正常说话被判成打哈欠,低头看手机被判成瞌睡。原因:只在单帧上做阈值比较,没有时间维度的持续帧过滤;头部姿态只用了 pitch 没看 yaw。解决:给每个动作都加最小持续帧和最大持续帧的下上界,说话 3 到 10 帧就结束,打哈欠会持续 20 帧以上;点头判定加abs(yaw) < 25约束。这两个过滤加完,误报率下降非常明显。
6. 二次开发:从帧级判定到状态机与自适应基线的改造思路
如果只是交作业,上面这套跑通就够了。但真想拿去做原型验证,有两处必须动。
第一处是把帧级判定升级成状态机。现在的update是每帧独立算等级,问题在于等级会来回抖——闭一下眼跳到重度,睁开又回轻度,报警器变成节拍器。改成显式的三状态机(清醒 / 轻度疲劳 / 重度疲劳),加迟滞:进入重度需要连续 N 帧满足条件,退出重度需要连续 M 帧不满足,M 大于 N。这一改,报警的"稳"会立刻上一个档次。
第二处是 EAR 自适应基线。前面提过不同人眼型差异能到 0.05,硬编码阈值换个人就得重调。做法是启动后前 100 帧进入标定态,这段时间只采集不判定,取 EAR 的中位数作为睁眼基线ear_base,实际阈值用ear_base * 0.7。同时每隔一段时间(比如每五分钟)在检测到连续睁眼时缓慢更新基线,防止疲劳加深后基线漂移导致的漏检。
改造点清单大致是这样:
| 改造点 | 涉及文件 | 改动量 | 收益 |
|---|---|---|---|
| 三级状态机加迟滞 | detector.py | 约 60 行 | 报警稳定性显著提升 |
| EAR 个人基线标定 | detector.py + 界面 | 约 40 行 | 换人不用改代码 |
| 多线程队列限长 | app.py | 约 20 行 | 延迟不再累积 |
| 检测结果落盘 | 新增 logger.py | 约 30 行 | 便于事后复盘误报 |
| 指标推送到 web 前端 | 新增 Flask 服务 | 约 80 行 | 可做远程监控面板 |
最后一条值得单独说。这套系统的界面是 Tkinter 桌面端,如果需求变成"车队管理端要远程看司机状态",桌面界面就没用了。比较省事的做法是保留采集和计算逻辑,把每帧的指标打成 JSON,用 Flask 起一个服务,一帧图像走 MJPEG 推流接口,指标走 WebSocket 长连接推给浏览器。前后端分离后,界面那部分用网页重写反而更灵活,也不用担心跨平台打包的问题。这就是这类视觉原型项目往 web 方向演进的常规路子,图像和数值分开走两条通道,别把帧塞进 WebSocket 里。
还有个小技巧:把每帧的 EAR、MAR、pitch、perclos 连同时间戳写进 CSV。调阈值的时候直接拉出来画散点图,比盯着实时画面猜快十倍。我当时是靠自己反复装作打哈欠才把 MAR 阈值定下来的,后来有了日志才发现,我"自然说话"时的 MAR 峰值有 0.48,离原来的阈值只差 0.14,纯属运气好没误报。
从那以后我每次调视觉类阈值,都强制先落一份离线日志再下手改数字,不再靠盯着屏幕感觉。希望帮到你。
本文还有配套的精品资源,点击获取