基于OpenCV与EAR的实时人眼、眨眼与闭眼检测

发布时间:2026/10/27 3:26:43
基于OpenCV与EAR的实时人眼、眨眼与闭眼检测 简介这是一份面向计算机视觉初学者与 Python 实践者的实战型学习资料围绕用 Python 与 OpenCV 在 Ubuntu 下实现实时人眼识别、眨眼检测与闭眼检测展开包含可直接运行的源代码与配套教程帮助读者理解 Haar 级联分类器定位眼部、基于连续帧差异判断眨眼以及眼睛持续闭合时的判定逻辑。压缩包共 61 个文件以 37 个 py 脚本为主体配合 12 个 png 与 4 个 jpg 演示图像、68 点人脸关键点 dat 模型、pdf 教程文档及 imutils 工具库源码等整体约 75.34MB目录按脚本、示例图与文档分置便于按模块查阅。目前已有 4361 人学习下载。读者可获得从环境依赖安装、代码运行到原理讲解的完整链路教程对帧差法、光流法、霍夫变换等实现思路均有说明。它适合作为课程设计、毕业项目或视觉算法入门的参考案例也可据此扩展为疲劳监测、注意力检测等应用原型。1. 从摄像头到 EAR实时人眼、眨眼、闭眼检测到底在算什么把 Python 和 OpenCV 接上摄像头做实时人眼识别、眨眼检测、闭眼检测真正难的不是把画面显示出来而是让阈值在真实光照、眼镜和侧脸下不飘。这份教程围绕一套可复现的源代码链路Haar 找脸、68 点关键点定位眼角、EAR 把眼睛开合变成数值、状态机区分眨眼和持续闭眼。适合已经会一点 Python、想把 Demo 改成能跑一整天的小工具的人也适合要给学生项目做验收的人。2. 把 OpenCV 人脸关键点链路跑通环境、模型文件与摄像头最小闭环2.1 为什么用 Haar 找脸加 68 点关键点而不是直接上眼睛分类器很多人第一次做人眼识别会直接找一个眼睛级联分类器在脸上框出眼睛然后根据有没有框到眼睛判断睁眼还是闭眼。这个思路在演示视频里能跑但一放到真实场景就容易翻车。原因是眼睛级联分类器大多是用睁眼样本训练出来的闭眼时它本来就容易检测不到你看到“没有眼睛框”无法区分是闭眼、侧脸、遮挡还是参数太严。闭眼检测最怕这种黑匣子式判断。更稳的做法是把任务拆成两层先用 Haar 人脸检测器找到人脸区域再用 68 点人脸关键点模型把人脸轮廓和眼睛周围的点定位出来。眼睛的开合不再依赖“有没有检测到眼睛”而是看上下眼睑之间的几何距离变化。这个距离用 EAR 表示Eye Aspect Ratio眼睛纵横比。眼睛睁大时上下眼睑距离大EAR 高眼睛闭合时上下眼睑贴近EAR 低。眨眼是 EAR 短暂下探后恢复持续闭眼是 EAR 长时间低于阈值。Haar 加 68 点的组合不是精度最高的方案但它的优势很适合落地依赖少、CPU 能跑、参数透明、调试链路短。常见做法是用cv2.face.createFacemarkLBF()加载 LBF 模型得到 68 个点。LBF 对正面和近正面人脸比较友好侧脸超过一定角度会掉点所以后面要加质量门限和使用约束。如果项目场景是强逆光、大角度、远距离换更重的人脸检测器也可以但 EAR 计算和眨眼状态机可以原样复用。这里先把“人眼识别”说清楚这份笔记里的识别不是虹膜识别也不是身份识别而是定位眼睛区域并判断开合状态。它适合疲劳提醒、闭眼告警、眨眼频率统计、互动触发这类场景不适合做身份认证。2.2 依赖安装与模型文件自检先让 cv2.face 能加载建议单独建虚拟环境只安装 contrib 包。opencv-python和opencv-contrib-python同时装容易互相覆盖表现为cv2.face时有时无。下面的命令不写死版本号按你本地能装到的稳定版即可。python -m venv .venv # 激活虚拟环境按你的终端执行对应的 activate 脚本 pip uninstall -y opencv-python opencv-contrib-python pip install opencv-contrib-python numpy安装完成后不要急着写主循环先跑一个资产检查脚本。Haar 人脸模型通常随 OpenCV 包一起分发可以用cv2.data.haarcascades拼路径LBF 关键点模型一般不在 pip 包里需要单独放到项目的models/lbfmodel.yaml。模型文件不要从不明来源随手下载放好后用脚本验证能否加载。from pathlib import Path import cv2 # Haar 人脸模型随 OpenCV 包分发路径用 cv2.data 拼 face_xml Path(cv2.data.haarcascades) / haarcascade_frontalface_default.xml # LBF 关键点模型需要单独放置 lbf_yaml Path(models/lbfmodel.yaml) assert face_xml.exists(), f缺少 Haar 人脸模型{face_xml} assert lbf_yaml.exists(), f缺少 LBF 关键点模型{lbf_yaml} assert hasattr(cv2, face), 当前 OpenCV 没有 face 模块检查是否装了 contrib 包 face_cascade cv2.CascadeClassifier(str(face_xml)) assert not face_cascade.empty(), Haar 模型加载失败 facemark cv2.face.createFacemarkLBF() facemark.loadModel(str(lbf_yaml)) print(asset ok)这段脚本做三件事确认 Haar 模型文件存在且能实例化确认cv2.face模块存在确认 LBF 模型能加载。CascadeClassifier.empty()返回真通常不是文件路径写错就是文件内容不完整。loadModel没有抛异常也不代表一定可用后面还要用真实人脸帧做一次fit验证。资产检查放在项目入口比在摄像头循环里报一堆看不懂的错更省时间。项目目录可以拆得简单一点assets_check.py做自检ear.py放 EAR 和状态机main.py做主循环models/放模型文件logs/放事件日志。不要把所有逻辑塞进一个 300 行的文件后面调阈值时你会感谢这个拆分。2.3 摄像头采集的四个参数分辨率、翻转、灰度与直方图均衡摄像头环节最容易被忽视。分辨率太高Haar 和关键点都慢分辨率太低眼睛区域只有十几个像素EAR 抖动会很大。实时闭眼检测我一般先用 640×480 跑通再根据人脸在画面里的像素宽度调整。人脸框宽度低于 100 像素时眼角点会明显漂EAR 的方差变大阈值再准也救不回来。下面是最小采集闭环。代码只做取帧、镜像、灰度、直方图均衡和退出不接检测先确认摄像头本身稳定。import cv2 cap cv2.VideoCapture(0) if not cap.isOpened(): raise RuntimeError(摄像头没有打开检查索引或占用情况) 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 # 镜像画面更符合照镜子的习惯不需要可以去掉 frame cv2.flip(frame, 1) gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 光照不均时有用但强反光会被放大后面会讲什么时候关掉 gray cv2.equalizeHist(gray) cv2.imshow(camera, gray) if cv2.waitKey(1) 0xFF 27: break cap.release() cv2.destroyAllWindows()CAP_PROP_FRAME_WIDTH和CAP_PROP_FRAME_HEIGHT是请求值不是保证值有些摄像头会返回最接近的分辨率。主循环里打印一次实际宽高不要假设一定是 640×480。flip只影响显示和坐标方向不影响 EAR 计算但如果你后面要做左右眼统计镜像后左右眼标签会反日志字段要写清楚是画面左眼还是人脸左眼。equalizeHist在背光、室内灯光不均时能提升 Haar 检出率但眼镜反光、白墙高光会被一起拉亮眼角点反而容易漂。我的习惯是准备两个版本室内均匀光用equalizeHist眼镜反光明显时改用 CLAHE或者直接不做均衡只做轻度高斯模糊。参数没有绝对正确先用画面和 EAR 曲线说话。3. 用 EAR 把眼睛开合变成可调参数68 点索引、阈值与平滑3.1 68 点索引里眼睛开合只关心六个点68 点模型把眼睛分成左右两组每组 6 个点。常见索引顺序是左眼 36 到 41右眼 42 到 47。每组点的排列逻辑是两个眼角在两端上下眼睑各有两组点。计算 EAR 时不需要所有脸轮廓点只取这 12 个点。索引写错是新手最容易犯的错表现是 EAR 一直很小或者一直很大画出来的多边形像一条乱线。区域索引用途画面左眼36、37、38、39、40、41计算单眼 EAR画面右眼42、43、44、45、46、47计算单眼 EAR眼角距离每组第 1 点和第 4 点EAR 分母上下眼睑距离第 2/6 点、第 3/5 点EAR 分子EAR 的公式不复杂两组上下眼睑距离求和除以两倍眼角距离。除以眼角距离是为了归一化人脸远近变化时眼角距离会变直接看像素距离会被摄像头距离带偏。分母加一个很小的 epsilon防止极端情况下除零。import numpy as np LEFT_EYE list(range(36, 42)) RIGHT_EYE list(range(42, 48)) def eye_aspect_ratio(points): points: 6x2 的浮点数组按眼角、上眼睑、下眼睑顺序排列 v1 np.linalg.norm(points[1] - points[5]) v2 np.linalg.norm(points[2] - points[4]) h np.linalg.norm(points[0] - points[3]) return float((v1 v2) / (2.0 * h 1e-6))points必须是浮点数。关键点模型输出后如果直接转成int再算距离量化误差会让 EAR 在小数点后第二位抖动眨眼阈值刚好卡在边界时就会反复触发。计算前用astype(np.float32)画图时再转int。v1和v2的顺序不影响结果但六个点的顺序不能乱乱序后公式没有意义。3.2 EAR 计算与左右眼一致性单眼 EAR 容易受遮挡影响。侧脸时远侧眼睛被鼻梁挡住关键点会缩成一团单眼 EAR 可能突然变低误判闭眼。更稳的做法是左右眼各算一次再取平均。平均能抵消一部分头部轻微旋转但如果用户单眼眨眼平均值可能不足以跨过阈值。闭眼检测通常关心双眼闭合取平均够用眨眼计数如果要求严格可以加“左右眼同时低于阈值”的条件。def both_eyes_ear(landmarks): left landmarks[LEFT_EYE].astype(np.float32) right landmarks[RIGHT_EYE].astype(np.float32) left_ear eye_aspect_ratio(left) right_ear eye_aspect_ratio(right) # 双眼平均减少单侧关键点抖动 return 0.5 * (left_ear right_ear), left_ear, right_ear这段函数返回三个值平均 EAR、左眼 EAR、右眼 EAR。日志里三个都记排查时能看出是双眼一起低还是单眼关键点坏了。如果左眼 EAR 长期只有右眼的一半先查关键点索引和遮挡不要急着调阈值。双眼差异超过 0.08 时我会把这一帧标记为低质量帧不参与闭眼计时避免侧脸造成连续误报。3.3 眨眼状态机用时间窗口替代帧计数EAR 低于阈值只说明“当前帧眼睛可能是闭的”不能直接加一次眨眼。摄像头帧率会波动30fps 下 3 帧是 100ms15fps 下 3 帧是 200ms用固定帧数做状态机会把阈值调成玄学。更稳的方式是用时间戳进入闭眼状态时记开始时间回到睁眼时算闭眼持续时长。持续时间在 80ms 到 450ms 之间算一次眨眼超过 1.2 秒还在闭眼触发持续闭眼告警。from collections import deque import time class EyeState: def __init__(self, ear_th0.22, blink_min0.08, blink_max0.45, closed_warn1.2, smooth_n5): self.ear_th ear_th self.blink_min blink_min self.blink_max blink_max self.closed_warn closed_warn self.buf deque(maxlensmooth_n) self.closed False self.closed_start 0.0 self.blink_count 0 self.closed_flag False self.last_ear 0.0 def update(self, ear, now): self.buf.append(ear) smooth sum(self.buf) / len(self.buf) self.last_ear smooth if smooth self.ear_th: if not self.closed: self.closed True self.closed_start now closed_dur now - self.closed_start if closed_dur self.closed_warn: self.closed_flag True return closed, smooth, closed_dur if self.closed: closed_dur now - self.closed_start if self.blink_min closed_dur self.blink_max: self.blink_count 1 self.closed False self.closed_start 0.0 self.closed_flag False return open, smooth, 0.0ear_th0.22只是起步值不同人脸、不同摄像头、不同关键点模型都会有差异。smooth_n5是 5 帧滑动平均30fps 下大约 160ms能压住抖动但也会让快速眨眼变钝。如果眨眼漏检多先把smooth_n降到 3如果计数乱跳先升到 5 或 7再加滞回阈值。blink_min0.08和blink_max0.45是常见眨眼窗口实际可以用录制回放校准。closed_warn1.2是持续闭眼告警时间疲劳检测场景可以调到 1.5 秒互动场景可以调到 0.8 秒。状态机里没有用帧计数所有判断都基于now。now用time.time()或time.monotonic()都可以长跑程序更推荐monotonic不受系统时间调整影响。如果主循环里同时有耗时操作now要在取帧后马上取不要等画完图再取否则闭眼时长会被渲染时间拉长。4. 实时闭眼检测的工程化帧率、ROI、报警与日志4.1 人脸检测降频关键点每帧跟CPU 花在刀刃上Haar 人脸检测是全图滑窗分辨率越高越慢68 点关键点是在人脸框里做耗时相对固定。实时系统里没必要每帧都重新找人脸人脸在连续帧之间移动有限可以每 2 到 3 帧检测一次中间帧复用上一个人脸框。关键点仍然每帧拟合因为眼睛开合是快速动作关键点降频会直接漏掉眨眼。下面这段是检测策略的核心。FACE_SKIP3表示每 3 帧做一次人脸检测如果上一轮没有脸下一帧立即重检避免人走进画面后长时间不响应。minSize(120,120)是经验值人脸小于这个尺寸时眼角点不稳定。scaleFactor1.1和minNeighbors5是速度和误检的折中光照差时可以降到 1.05 和 4但误检会增加。import cv2 import numpy as np FACE_XML cv2.data.haarcascades haarcascade_frontalface_default.xml LBF_YAML models/lbfmodel.yaml FACE_SKIP 3 face_cascade cv2.CascadeClassifier(FACE_XML) facemark cv2.face.createFacemarkLBF() facemark.loadModel(LBF_YAML) faces [] frame_idx 0 def detect_faces(gray): det face_cascade.detectMultiScale( gray, scaleFactor1.1, minNeighbors5, minSize(120, 120) ) return list(det) if len(det) else []detectMultiScale返回的是(x, y, w, h)数组可能有多张脸。闭眼检测一般只处理画面里最大的一张脸避免背景人脸干扰。max(faces, keylambda r: r[2] * r[3])按面积取最大。多脸场景要加人脸跟踪或按位置排序否则每帧切换目标日志会跳。4.2 主循环代码FPS、闭眼计时与可视化主循环要把采集、检测、关键点、EAR、状态机、可视化和日志串起来。下面这份代码可以直接作为main.py的骨架EyeState和eye_aspect_ratio用上一章的实现。日志部分为了看清过程在闭眼期间会连续写工程里应该改成上升沿只记一次后面会说明。import csv import time from collections import deque import cv2 import numpy as np # 假设 EyeState、eye_aspect_ratio 已从 ear.py 导入 # from ear import EyeState, eye_aspect_ratio FACE_XML cv2.data.haarcascades haarcascade_frontalface_default.xml LBF_YAML models/lbfmodel.yaml FACE_SKIP 3 EAR_TH 0.22 CLOSED_WARN_SEC 1.2 BLINK_MIN_SEC 0.08 BLINK_MAX_SEC 0.45 face_cascade cv2.CascadeClassifier(FACE_XML) facemark cv2.face.createFacemarkLBF() facemark.loadModel(LBF_YAML) state EyeState( ear_thEAR_TH, blink_minBLINK_MIN_SEC, blink_maxBLINK_MAX_SEC, closed_warnCLOSED_WARN_SEC, smooth_n5 ) 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) faces [] frame_idx 0 fps_buf deque(maxlen30) log_f open(logs/eye_events.csv, w, newline, encodingutf-8) log_w csv.writer(log_f) log_w.writerow([ts, event, ear, closed_sec]) while True: t0 time.time() ok, frame cap.read() if not ok: break frame cv2.flip(frame, 1) gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray cv2.equalizeHist(gray) if frame_idx % FACE_SKIP 0 or len(faces) 0: faces detect_faces(gray) if len(faces) 0: x, y, w, h max(faces, keylambda r: r[2] * r[3]) ok_lm, lms facemark.fit( gray, np.array([(x, y, w, h)], dtypenp.int32) ) if ok_lm and len(lms) 0: lm lms[0].astype(np.float32) left lm[36:42] right lm[42:48] ear 0.5 * (eye_aspect_ratio(left) eye_aspect_ratio(right)) now time.time() status, smooth_ear, closed_dur state.update(ear, now) for pts in (left, right): cv2.polylines( frame, [pts.astype(np.int32)], True, (0, 255, 0), 1 ) cv2.rectangle(frame, (x, y), (x w, y h), (255, 0, 0), 2) cv2.putText(frame, fEAR {smooth_ear:.2f}, (20, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2) cv2.putText(frame, fblink {state.blink_count}, (20, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2) if state.closed_flag: cv2.putText(frame, CLOSED, (20, 95), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 2) log_w.writerow([ f{now:.3f}, closed, f{smooth_ear:.4f}, f{closed_dur:.3f} ]) fps_buf.append(1.0 / max(time.time() - t0, 1e-6)) fps_text fFPS {sum(fps_buf) / len(fps_buf):.1f} cv2.putText(frame, fps_text, (20, 130), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) cv2.imshow(eye monitor, frame) if cv2.waitKey(1) 0xFF 27: break frame_idx 1 cap.release() cv2.destroyAllWindows() log_f.close()facemark.fit的第二个参数必须是np.int32的人脸框数组传浮点或形状不对会直接报错。ok_lm为假时不更新状态机保持上一帧状态避免关键点短暂失败把闭眼计时打断。state.update里用的是平滑后的 EAR画图也显示平滑值这样调阈值时眼睛看到的就是状态机实际用的数。FPS统计的是单帧处理耗时不是摄像头真实帧率。如果摄像头 30fps 但处理只有 12fpsFPS会显示 12 左右说明主循环跟不上。闭眼告警不能只看FPS还要看闭眼时长是否连续。处理慢的时候眨眼可能被漏掉因为中间帧根本没取到。4.3 事件日志与证据帧出问题能回看日志不要只记“闭眼”两个字。至少记时间戳、平滑 EAR、闭眼持续时长、左右眼 EAR、人脸框大小。人脸框大小能判断是不是离摄像头太远左右眼 EAR 能判断是不是单侧关键点坏了。下面是一行更完整的日志结构字段比上面的示例多实际项目可以按需要裁剪。字段含义排查用途ts时间戳对齐视频和事件eventopen/closed/blink状态变化ear_avg双眼平均 EAR看阈值是否合理ear_left左眼 EAR查单侧遮挡ear_right右眼 EAR查单侧遮挡face_w人脸框宽度判断距离和分辨率closed_sec闭眼持续时长区分眨眼和持续闭眼证据帧不要每帧都存闭眼事件触发时存前后各 1 秒的帧或者只存触发瞬间的 JPEG。存图前把人脸框和眼角点多边形画上去回看时一眼能看出是误报还是真闭眼。日志里的时间戳和视频帧号要能对上否则回放时找不到对应画面。提示日志文件不要放在摄像头循环里频繁打开关闭主循环开始时打开一次结束时关闭。闭眼期间连续写 CSV 会拖慢帧率工程里改成状态上升沿写一次或者用内存队列异步落盘。5. 实时检测翻车排查光、眼镜、侧脸与帧率抖动5.1 画面里有人脸但关键点不出现现象detectMultiScale能框到人脸facemark.fit返回假或者返回的点全挤在人脸框左上角。原因通常有三类模型文件没真正加载、传进去的是彩色图而不是灰度图、人脸框太小或角度太大。LBF 模型对输入图像尺寸和人脸框质量敏感人脸宽度低于 100 像素时失败率明显上升。解决顺序是先跑资产检查脚本确认cv2.face和模型文件都没问题再在fit前打印人脸框宽高小于 120 像素就提示用户靠近然后检查facemark.fit的第一个参数是不是gray不是frame。如果都正常把minNeighbors降到 4、scaleFactor降到 1.05 试一次但要注意误检会增加。侧脸超过大约 30 度时68 点模型本身就会掉点这不是调参能完全解决的使用约束要写清楚。5.2 眨眼计数一秒钟跳好几次现象用户只眨了一次blink_count增加三四次日志里 EAR 在阈值附近上下抖动。原因是没有平滑、没有滞回或者把闭眼期间的每一帧都当成一次新眨眼。摄像头噪声和关键点抖动会让 EAR 在 0.22 附近来回穿状态机如果只用单阈值就会反复进入和退出闭眼状态。解决是加三层保护第一层用 3 到 5 帧滑动平均第二层加滞回阈值进入闭眼用ear_th退出闭眼用ear_th 0.03第三层加冷却时间一次眨眼后 150ms 内不再计数。下面这段是滞回逻辑的核心可以替换状态机里的单阈值判断。# 进入闭眼低于低阈值退出闭眼高于高阈值 enter_th 0.22 exit_th 0.25 if not closed and smooth_ear enter_th: closed True closed_start now elif closed and smooth_ear exit_th: closed False # 在这里结算眨眼或持续闭眼enter_th和exit_th的差距不要太大0.02 到 0.04 比较常见。差距太大时半睁眼会长时间停留在中间区状态机不知道算睁还是闭。冷却时间用时间戳判断不要用帧数否则帧率一变就失效。5.3 戴眼镜时闭眼被当成睁眼现象用户戴粗框眼镜或镜片反光明显时睁眼 EAR 正常闭眼 EAR 却降本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询