☰
Python人脸识别疲劳检测源码解析:EAR/MAR与参数调优
2026/10/29 23:42:37 网站建设 项目流程

简介:这份Python项目源码以驾驶员面部特征为切入点实现疲劳检测,面向计算机视觉初学者、课程设计学生以及需要完成毕业设计的人群,提供从人脸识别到疲劳状态判定的可运行实现。项目可服务于车内驾驶监控、交警摄像头巡查及高速收费站等场景,帮助读者理解视频帧读取、面部区域定位、眼部或嘴部状态判断与疲劳提醒之间的处理链路。压缩包共24个文件、约68.33MB,主体为5个Python源码文件,并配有XML配置、Markdown说明、Word文档、音频提示与数据文件,目录中包含源码模块与说明材料,便于按模块阅读、调试和二次开发。已有1360人学习下载,适合作为毕业设计参考,也能为视觉检测类项目提供可复用的工程骨架、配置组织方式与排错思路。

1. 从一帧摄像头画面说起:这套疲劳检测源码跑起来能看到什么

摄像头对准驾驶位,画面里的人正常睁着眼,控制台上的 EAR 数值在 0.28 上下浮动,一次眨眼把它压到 0.17,眨眼计数器加一;连续两秒低于阈值,报警音响起。这套 Python 人脸识别疲劳检测系统源码做的就是这件事:先把人脸关键点抓出来,再换算成睁眼度、张嘴度、头部俯仰角三个可以计算的量,最后用时间窗口判断驾驶员是不是真的困了。它属于典型的毕业设计项目源码,结构不复杂,但要跑稳、要调得准,里面有不少细节值得拆。适合正在找疲劳检测方向毕设课题的学生、想快速搭一个视觉监控原型的开发者,以及需要把人脸识别从「能认出脸」推进到「能读懂状态」的工程人员。

2. 拆开代码包:人脸关键点、EAR/MAR 与判定逻辑怎么串起来

拿到压缩包先别急着装环境,先看清楚代码是怎么组织的。这类项目的主干一般很清晰:一个视频采集入口,一个关键点检测模块,一个指标计算模块,一个状态判定模块,最后一个报警输出模块。真正决定检测效果好坏的,不是代码行数,而是关键点怎么选、指标怎么算、判定窗口怎么定。

2.1 关键点选型的差异:68 点与 468 点怎么选

市面上做人脸疲劳检测,关键点方案基本集中在两条路线上,差异不只是点的数量。

一条是传统的 68 点人脸关键点模型配合 dlib 的检测器。点的编号是固定的,左右眼各 6 个点,分别落在 36 到 41 和 42 到 47;嘴巴外轮廓是 48 到 59,内轮廓是 60 到 67。这意味着 EAR、MAR 这两个公式可以直接按固定索引写死,不用做任何点位映射,代码短、可读性高,答辩的时候也容易讲明白。

另一条是 468 点的人脸网格模型,配合轻量的推理框架。点更密,能拿到更细的眼睑轮廓和嘴角轮廓,对侧脸、低头这类姿态的容忍度更好。代价是索引要靠自己定义,官方给的只是拓扑连接关系,眼睛用哪几个点、嘴巴用哪几个点,需要你对照文档一个个挑出来。

下面这张表是我在实际项目里总结的对比,选型时可以直接参考:

维度68 点方案468 点方案
依赖体积需要额外下载约 100MB 的模型文件模型内置在包里,安装即用
单帧耗时(普通 CPU)约 40~80ms约 15~30ms
侧脸鲁棒性一般,超过 30 度偏转容易丢点较好,60 度以内仍能出点
点位索引固定,公式可直接写死需自行对照拓扑定义
二次开发难度低,改公式即可中,需要先做点位筛选

项目标题里给的是 Python 人脸识别 + 检测系统的组合,这类毕设源码绝大多数走的是 68 点路线,原因是依赖链清楚、代码短、调试直观。如果你拿到的是 68 点版本,下面的所有公式可以直接套;如果是 468 点版本,思路一致,只是把索引换成你自己挑出来的那几个编号。

2.2 EAR 与 MAR 的几何含义:两个能把表情变成数字的公式

EAR 全称是眼睛纵横比,思路很朴素:眼睛睁开时,上下眼睑之间的距离接近甚至超过眼睛的宽度;眼睛闭上时,上下眼睑贴在一起,这个比值会迅速掉下去。

公式写成这样:

EAR = (|p2 - p6| + |p3 - p5|) / (2 * |p1 - p4|)

其中 p1 到 p6 是单只眼睛的 6 个关键点,p1 和 p4 是左右眼角,p2、p3、p5、p6 是上下眼睑的四个点。分子是两组上下眼睑距离之和,分母是两倍的眼角距离。之所以除以两倍,是为了让睁眼状态下的数值大致落在 0.3 附近,方便设阈值。

对应到 68 点模型,左眼的 p1 到 p6 就是 36 到 41,右眼是 42 到 47。代码里通常写成一个通用函数,传两组索引进去就能算两只眼睛。

MAR 是嘴巴纵横比,思路一模一样,只是换成嘴巴的上下开合距离和左右嘴角距离:

MAR = (|p2 - p8| + |p3 - p7| + |p4 - p6|) / (3 * |p1 - p5|)

68 点模型里嘴巴内轮廓是 60 到 67,外轮廓是 48 到 59。用内轮廓算 MAR 更灵敏,打哈欠时数值能到 0.6 以上,正常说话时基本在 0.1 到 0.3 之间浮动。用外轮廓的话数值整体偏小,阈值要跟着往下调。

这个公式最容易被忽略的一点是:它描述的是比例,不是绝对距离。所以坐得远一点、坐得近一点,EAR 的值基本不受影响,这也是为什么它比直接量像素距离更适合做实时检测。

2.3 主循环的数据流:一帧画面怎么走完整个判定流程

把上面几块拼起来,主循环的逻辑大致是这样的:读一帧,转灰度,检测人脸框,在框内做关键点回归,算左右眼 EAR 取平均,算 MAR,算头部姿态角,把这几个值送进判定器,判定器根据连续帧的状态决定要不要报警。

下面是我平时用的骨架代码,省略了报警和日志部分,只保留数据流主干:

import cv2 import dlib import numpy as np from scipy.spatial import distance as dist # 68 点模型中左右眼与嘴巴内轮廓的索引 LEFT_EYE = list(range(36, 42)) RIGHT_EYE = list(range(42, 48)) MOUTH_INNER = list(range(60, 68)) def eye_aspect_ratio(pts): # 垂直方向两组距离 v1 = dist.euclidean(pts[1], pts[5]) v2 = dist.euclidean(pts[2], pts[4]) # 水平方向眼角距离 h = dist.euclidean(pts[0], pts[3]) return (v1 + v2) / (2.0 * h) def mouth_aspect_ratio(pts): # 内轮廓三个纵向距离 v1 = dist.euclidean(pts[1], pts[7]) v2 = dist.euclidean(pts[2], pts[6]) v3 = dist.euclidean(pts[3], pts[5]) h = dist.euclidean(pts[0], pts[4]) return (v1 + v2 + v3) / (3.0 * h) detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") cap = cv2.VideoCapture(0) 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) coords = np.array([[p.x, p.y] for p in shape.parts()]) ear = (eye_aspect_ratio(coords[LEFT_EYE]) + eye_aspect_ratio(coords[RIGHT_EYE])) / 2.0 mar = mouth_aspect_ratio(coords[MOUTH_INNER]) cv2.putText(frame, "EAR:{:.3f} MAR:{:.3f}".format(ear, mar), (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.imshow("fatigue", frame) if cv2.waitKey(1) & 0xFF == 27: break cap.release() cv2.destroyAllWindows()

逻辑说明部分:detector(gray, 1)里的第二个参数是上采样次数,值越大越容易检出小人脸,但耗时也成倍增加,驾驶位这种距离下用 1 就够。predictor内部是按人脸框做局部回归,所以必须先有人脸框,不能直接把整帧喂进去。EAR 取两只眼睛的平均值,是为了避免单侧遮挡或者单侧光照造成的跳变。

参数说明部分:上面这段代码里没有任何硬编码阈值,是因为这一版只负责把指标算出来并显示。真正的判定要放在下一层,用连续帧的计数器和时间窗口来做,这部分在第四章展开。

有一段需要特别提醒:shape.parts()返回的是整张脸的 68 个点,索引和你传入的检测框一一对应,但如果你在循环里对图像做了 resize,坐标必须先换算回原图尺度再画框,否则框和点会错位。

3. 环境与首次跑通:依赖版本、模型文件与摄像头参数的三个硬门槛

代码看懂了不等于能跑起来。这类项目在环境这一步卡住的人最多,卡点集中在三处:编译型依赖装不上、模型文件找不到、摄像头读不出帧。这一章按顺序把三个门槛过一遍。

3.1 依赖安装:编译失败的四条排查路径

核心依赖通常是四个:OpenCV 负责图像采集和绘制,dlib 负责检测和关键点回归,NumPy 和 SciPy 负责数值计算和距离运算。前三个是二进制包,装起来很快,dlib 是 C++ 扩展,在部分平台上需要本地编译。

下面是我常用的安装顺序,先装纯 Python 包,最后装 dlib:

# 基础数值库与图像库 pip install numpy scipy opencv-python # dlib 优先尝试预编译轮子 pip install dlib # 如果上面这条报编译错误,再考虑源码编译 pip install cmake pip install dlib --no-binary dlib

逻辑说明:先装cmake再源码编译 dlib,是因为 dlib 的构建脚本依赖 CMake 生成构建文件;不加--no-binary时 pip 会优先拉取对应平台的预编译包,省掉编译环节。

编译失败最常见的四种原因:一是本机没有 C++ 编译工具链,Windows 上表现为找不到 cl.exe,Linux 上表现为 gcc 缺失;二是 CMake 版本过旧,dlib 的新版本对 CMake 有最低版本要求;三是内存不足,编译 dlib 的过程中峰值内存能到 2GB 以上,虚拟机里很容易被杀掉;四是 Python 版本太新,预编译包还没跟上,只能走源码编译。

判断走哪条路有个简单办法:装完之后执行一次python -c "import dlib; print(dlib.__version__)",能打印出版本号就说明装好了,报 ImportError 就说明还没成,需要看完整报错。

提示:如果只是跑通检测流程,不打算改 dlib 内部实现,可以优先找带预编译包的 Python 版本组合,省下的编译时间足够你把参数调三轮。

3.2 模型文件:路径、校验与两种常见缺失

68 点方案需要单独的模型文件,文件名通常是shape_predictor_68_face_landmarks.dat,体积在 100MB 上下。代码里dlib.shape_predictor(...)的参数就是这个文件的路径。

两个最常见的翻车场景:一是把 .dat 文件放在项目根目录,但代码里写的是相对路径,运行目录一变就找不到;二是下载过程中断,文件大小对但内容不完整,加载时不报错,检测时直接崩。

校验的方法很简单,加载之后打印一次点数:

import dlib predictor = dlib.shape_predictor("models/shape_predictor_68_face_landmarks.dat") # 用一个已知的人脸图测试,正常应输出 68 print("landmark parts:", predictor.num_parts)

num_parts输出 68 说明模型加载正确。如果这里抛异常,八成是路径错了或者文件损坏,重新下载一次比继续排查更省时间。

路径管理上我一般会做一层封装,把模型目录做成可配置项,而不是写死在代码里:

import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) MODEL_PATH = os.path.join(BASE_DIR, "models", "shape_predictor_68_face_landmarks.dat") # 启动时先做一次存在性检查,避免跑到一半才崩 if not os.path.exists(MODEL_PATH): raise FileNotFoundError("模型文件缺失: " + MODEL_PATH)

这段代码解决的是打包和迁移时的问题:用__file__取当前脚本所在目录,再拼相对路径,不管你从哪个目录启动脚本,路径都是对的。如果后面要打包成可执行文件,这一层封装还能改成从临时解压目录读取。

3.3 摄像头参数:分辨率、帧率与首帧黑屏

摄像头这一环看起来最简单,实际上参数没设对会直接影响检测效果。默认分辨率下,人脸在画面里可能只有一百多像素宽,关键点回归的精度会明显下降,EAR 抖动得厉害。

我一般会显式指定分辨率和帧率,并在启动后读一次实际生效的参数做校验:

import cv2 cap = cv2.VideoCapture(0) # 期望的分辨率,不是所有摄像头都支持,设置后要读回确认 cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap.set(cv2.CAP_PROP_FPS, 30) real_w = cap.get(cv2.CAP_PROP_FRAME_WIDTH) real_h = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) real_fps = cap.get(cv2.CAP_PROP_FPS) print("实际生效: {}x{} @ {}fps".format(real_w, real_h, real_fps))

参数说明:set只是提出请求,摄像头驱动不一定照办,所以必须用get读回真实值。640×480 是这类检测的性价比区间,再往上清晰度提升有限但单帧耗时线性上涨;帧率设 30 是给后面的时间窗口留余量,如果实际只有 15 帧,那么「连续 20 帧闭眼」对应的真实时长会翻倍,判定就会偏松。

首帧黑屏是另一个常见问题。有些摄像头需要预热几帧才能出正常画面,解决办法是在进入主循环之前先读若干帧丢弃:

# 预热,丢弃前 10 帧,避免首帧黑屏或过曝 for _ in range(10): cap.read()

这个细节不影响算法,但会直接影响你第一次运行时的观感,也影响日志里第一段数据的可信度。

4. 参数调优:EAR 阈值、连续帧数与误报率之间的三角关系

能跑起来之后,下一个问题一定是「为什么老是误报」或者「为什么真困了却不报」。这类系统的调参本质是在三个量之间找平衡:EAR 阈值决定单帧判断的松紧,连续帧数决定时间维度的确认强度,误报率是前两者共同作用的结果。三者改一个,另外两个都得跟着看。

4.1 EAR 阈值的实测标定:别抄默认值

网上流传的 EAR 阈值大多是 0.25 或 0.3,直接抄过来往往不好用,因为 EAR 的绝对值和人脸在画面里的尺度、摄像头的焦距、关键点模型的精度都有关系。

我的做法是分三步标定。第一步,让测试对象正常睁眼盯着镜头 30 秒,记录 EAR 的均值 μ_open 和标准差 σ_open。第二步,让他正常眨眼 20 次,记录每次眨眼过程中的最低值。第三步,把阈值定在 μ_open 减去 2 到 3 倍标准差的位置,同时确保这个值低于正常眨眼的最低值、高于真正闭眼时的读数。

import numpy as np # 从日志里读一段睁眼状态的 EAR 序列 open_ear = np.array(open_ear_samples) mu = open_ear.mean() sigma = open_ear.std() # 阈值取均值减 2.5 倍标准差,再和区间下限取大 threshold = max(mu - 2.5 * sigma, 0.16) print("mu={:.3f} sigma={:.3f} threshold={:.3f}".format(mu, sigma, threshold))

逻辑说明:均值减若干倍标准差是统计学里常用的异常下限思路,这里借用它来区分「正常睁眼的自然波动」和「闭眼造成的骤降」。max(..., 0.16)是一道保险,防止样本里混入大量闭眼帧导致算出来的阈值过低。

参数说明:2.5 这个倍数可以调,样本干净就用 2.0,样本里噪声多用 3.0。0.16 这个下限对不同人脸不一定合适,需要在测试集上再看一遍。

注意:标定用的样本必须来自实际部署的摄像头和实际光照条件,换一个摄像头、换一个房间,EAR 的分布都会变,之前调的阈值基本作废。

4.2 时间窗口与连续帧计数:单帧判断为什么不可靠

单帧 EAR 低于阈值就报警,这套逻辑在真实场景里几乎不能用,因为眨眼、低头看仪表、揉眼睛都会造成短暂的低值。可靠的判定必须在时间维度上做确认。

常见的做法是双计数器:一个持续计数,专门对付长时间闭眼;一个滑动窗口,专门对付频繁眨眼。两者的区别在于前者衡量的是「闭了多久」,后者衡量的是「眨得多频繁」。

from collections import deque EAR_THRESHOLD = 0.21 CONSEC_FRAMES = 20 # 连续闭眼帧数,约 0.7 秒 BLINK_WINDOW_SEC = 60 # 眨眼统计窗口 BLINK_RATE_LIMIT = 25 # 窗口内眨眼次数上限 closed_counter = 0 blink_records = deque() # 存每次眨眼的时间戳 fps = 30.0 def update(ear, now): global closed_counter if ear < EAR_THRESHOLD: closed_counter += 1 else: # 从闭到开,记一次眨眼 if 0 < closed_counter < CONSEC_FRAMES: blink_records.append(now) closed_counter = 0 # 剔除窗口外的旧记录 while blink_records and now - blink_records[0] > BLINK_WINDOW_SEC: blink_records.popleft() long_close = closed_counter >= CONSEC_FRAMES high_rate = len(blink_records) >= BLINK_RATE_LIMIT return long_close, high_rate

逻辑说明:closed_counter从 0 开始累加,一旦 EAR 回升就置零;在置零之前判断它是否落在「一次正常眨眼」的范围内,是的话才计入眨眼记录。这样可以把长时间闭眼和正常眨眼区分开,不会互相污染统计。

参数说明:CONSEC_FRAMES和帧率强相关,20 帧在 30fps 下是 0.67 秒,在 15fps 下就是 1.33 秒。如果代码里没有动态读帧率,建议在启动时算一次实际帧率再换算,别把帧数写死。BLINK_RATE_LIMIT的取值因人而异,正常人每分钟眨眼 15 到 20 次,疲劳状态下会降到 10 次以下或者出现长时间闭眼,25 这个上限是配合持续闭眼检测一起用的辅助判据。

4.3 光照、眼镜与头部角度:三类会让阈值失真的场景

调好的阈值换到另一个场景就失效,通常不是算法的问题,而是输入分布变了。三类场景最常见。

逆光或者侧光。强光从背后打过来时,人脸整体变暗,关键点回归的精度下降,EAR 会出现无规律抖动。解决思路是预处理阶段做一次直方图均衡,把灰度分布拉开:

gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 限制对比度的自适应均衡,比全局均衡更不容易过曝 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) gray_eq = clahe.apply(gray)

clipLimit控制对比度增强的上限,值越大增强越猛但也越容易放大噪声;tileGridSize是分块大小,8×8 在 640×480 的画面下比较合适。

戴眼镜。镜片反光会在眼睛区域形成高亮斑,遮挡关键点。这个没有一劳永逸的解法,实际操作里通常是把 EAR 阈值往下调一档,同时把连续帧数调大,用时间维度换单帧的可靠性。如果项目允许,也可以在检测前加一层简单的反光检测,命中就跳过该帧的判定。

头部大角度偏转。低头超过 20 度时,眼睛在画面里被压缩,EAR 会自然变小,容易误判成闭眼。这时候需要引入头部姿态角做补偿:用关键点做一次姿态估计,把俯仰角超过阈值的帧单独标记,判定时降低权重或者直接不计入闭眼计数。

# 用 68 点里的鼻尖、下巴、左右眼角做粗略姿态估计 # 这里只示意取点,具体 solvePnP 的参数需要相机内参 image_points = np.array([ coords[30], # 鼻尖 coords[8], # 下巴 coords[36], # 左眼角 coords[45], # 右眼角 ], dtype="double") # rvec / tvec 由 cv2.solvePnP 求出后,再转欧拉角判断俯仰

这段代码只是把点位取出来,真正的姿态解算需要相机内参矩阵和畸变系数。没有标定数据时,可以用一个近似内参先跑起来,精度差一些但足以区分「大幅低头」和「正常注视」。

5. 避坑排查:五类跑起来之后才会遇到的翻车

前面几章讲的都是「怎么让它跑」,这一章讲「跑起来之后为什么会坏」。下面五条是我在实际调试里反复遇到的,每条按现象、原因、解决三段来写。

5.1 摄像头能打开但一直读不到帧

现象:cap.isOpened()返回 True,但cap.read()一直返回 False,画面窗口是灰的。

原因:设备被其他进程占用,或者索引选错了。笔记本上外接摄像头时,内置摄像头通常是 0,外接的是 1 或 2;有些系统上虚拟摄像头会占据前面的索引。

解决:先把索引从 0 到 3 依次试一遍,看哪个能读出非空帧;再检查任务管理器里有没有其他程序占着摄像头。代码里加一层读帧失败的重试,连续失败超过阈值再退出,避免偶发失败直接终止程序:

fail_count = 0 while True: ok, frame = cap.read() if not ok: fail_count += 1 if fail_count > 30: print("连续读帧失败,退出") break continue fail_count = 0 # 正常处理

5.2 人脸框跳动导致 EAR 剧烈抖动

现象:人坐着不动,EAR 的显示值却在 0.18 到 0.32 之间来回跳,报警时不时触发一下。

原因:逐帧独立做人脸检测,相邻帧的检测框位置有细微差异,关键点回归的输入跟着变,输出就有抖动。这是检测器的正常特性,不是 bug。

解决:两个方向。一是给检测框加平滑,用上一帧的框和当前帧的框做加权平均;二是给 EAR 序列加滑动平均,用最近 5 帧的均值代替当前值参与判定。前者治本,后者治标,一般两个都上:

from collections import deque ear_window = deque(maxlen=5) def smooth_ear(raw): ear_window.append(raw) return sum(ear_window) / len(ear_window)

maxlen=5决定平滑窗口长度,值越大越平滑但响应越慢。30fps 下 5 帧大约是 0.17 秒的延迟,对疲劳检测这种秒级判断完全可以接受。

5.3 戴眼镜或逆光时误报率飙升

现象:同一个人,不戴眼镜时判定正常,戴上眼镜后十分钟报八次。

原因:镜片反光遮挡眼睛关键点,模型回归出来的点位偏离真实眼睑位置,EAR 被人为压低。逆光同理,整体灰度偏低导致关键点定位漂移。

解决:先做灰度均衡,再把 EAR 阈值整体下调 0.02 到 0.03,同时把连续帧数从 20 提到 30。这套组合的本质是用更保守的判定换更低的误报,代价是对真正疲劳的响应变慢,需要在测试里确认延迟可以接受。

5.4 长时间运行内存持续上涨

现象:跑半小时之后内存占用从 300MB 涨到 2GB,最后卡死。

原因:多半是 matplotlib 或日志模块在循环里反复创建对象没有释放,也可能是把每一帧都存进了列表里做统计。OpenCV 的imshow本身不会泄漏,但如果循环里每次都createCLAHE或者新建VideoWriter,累积起来就很可观。

解决:把可以复用的对象提到循环外面创建;统计用的容器设上限,比如用deque(maxlen=N)代替list。检查的时候可以在循环里每隔 100 帧打印一次psutil.Process().memory_info().rss,看曲线是不是单调上升。

5.5 打包成可执行文件后模型加载失败

现象:源码里跑得好好的,打包之后一启动就报找不到 .dat 文件。

原因:打包工具把资源文件放进了临时解压目录,运行时的工作目录和开发时不一样,相对路径失效。

解决:运行时用sys._MEIPASS判断是否处于打包环境,是的话从临时目录取模型,否则从脚本目录取:

import sys import os def resource_path(relative): # 打包后资源被解压到 _MEIPASS 指向的临时目录 base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) return os.path.join(base, relative) MODEL_PATH = resource_path("models/shape_predictor_68_face_landmarks.dat")

同时要在打包配置里显式声明 .dat 文件为附加资源,否则它根本不会被复制进去。

6. 从能跑到能交差:把检测结果落成日志、曲线和分级报警

一个只会在屏幕上显示 EAR 数值的 Demo,和一个能拿去答辩、能给别人演示的系统,差距往往不在算法,而在结果怎么呈现。我一般会给这类项目补三样东西:结构化日志、趋势曲线、分级报警。

日志这块,别用print,用 CSV 按帧落盘,字段至少包含时间戳、EAR、MAR、闭眼计数、当前判定状态。这样后面画曲线、算统计指标、复盘误报都能直接读文件,不用重新跑一遍视频。写的时候用追加模式,程序中断也不会丢已写入的数据:

import csv, time with open("fatigue_log.csv", "a", newline="") as f: writer = csv.writer(f) # 首行写表头,实际使用时要判断文件是否已存在 writer.writerow(["ts", "ear", "mar", "closed", "state"]) # 循环内 writer.writerow([round(time.time(), 3), round(ear, 4), round(mar, 4), closed_counter, state])

趋势曲线用 matplotlib 画,横轴时间、纵轴 EAR,把阈值画成一条水平参考线,闭眼区间用底色标出来。这张图在答辩时比任何口头描述都有说服力,评审一眼就能看出阈值定得合不合理。

分级报警是我觉得最值得加的一点。不要只有一个「困了」状态,分成三级:一级是眨眼频率异常但还没长时间闭眼,只记录不响铃;二级是连续闭眼超过 1 秒,播放提示音;三级是连续闭眼超过 2 秒或者一分钟内触发多次二级,报警音加强并写一条高优先级日志。分级的好处是误报的代价变小了,一级误报只是多一条日志,不会打扰驾驶。

验证方法上,我习惯用录制视频代替实时摄像头做回归测试:把几段标注好的视频(正常、轻度疲劳、重度疲劳各一段)喂进程序,看输出的状态序列和人工标注的重合度。这样每次改参数都能快速跑一遍,不用每次都真人坐在摄像头前面演。

血泪经验是早期图省事,直接拿实时摄像头调参,调一次要重新演一遍,演得还不一样,根本没法对比。从那以后我每次改判定逻辑都强制走一遍录制视频的回归,参数改动前后各跑一次,把两份日志对齐比较。希望这套流程能帮到你,少走几轮弯路。

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

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

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

立即咨询