☰
基于OpenCV与EAR的实时人眼、眨眼与闭眼检测
2026/10/27 3:26:20 网站建设 项目流程

简介:这是一份面向计算机视觉初学者与 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 帧是 100ms,15fps 下 3 帧是 200ms,用固定帧数做状态机会把阈值调成玄学。更稳的方式是用时间戳:进入闭眼状态时记开始时间,回到睁眼时算闭眼持续时长。持续时间在 80ms 到 450ms 之间算一次眨眼;超过 1.2 秒还在闭眼,触发持续闭眼告警。

from collections import deque import time class EyeState: def __init__(self, ear_th=0.22, blink_min=0.08, blink_max=0.45, closed_warn=1.2, smooth_n=5): self.ear_th = ear_th self.blink_min = blink_min self.blink_max = blink_max self.closed_warn = closed_warn self.buf = deque(maxlen=smooth_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.0

ear_th=0.22只是起步值,不同人脸、不同摄像头、不同关键点模型都会有差异。smooth_n=5是 5 帧滑动平均,30fps 下大约 160ms,能压住抖动,但也会让快速眨眼变钝。如果眨眼漏检多,先把smooth_n降到 3;如果计数乱跳,先升到 5 或 7,再加滞回阈值。blink_min=0.08和blink_max=0.45是常见眨眼窗口,实际可以用录制回放校准。closed_warn=1.2是持续闭眼告警时间,疲劳检测场景可以调到 1.5 秒,互动场景可以调到 0.8 秒。

状态机里没有用帧计数,所有判断都基于now。now用time.time()或time.monotonic()都可以,长跑程序更推荐monotonic,不受系统时间调整影响。如果主循环里同时有耗时操作,now要在取帧后马上取,不要等画完图再取,否则闭眼时长会被渲染时间拉长。

4. 实时闭眼检测的工程化:帧率、ROI、报警与日志

4.1 人脸检测降频,关键点每帧跟:CPU 花在刀刃上

Haar 人脸检测是全图滑窗,分辨率越高越慢;68 点关键点是在人脸框里做,耗时相对固定。实时系统里没必要每帧都重新找人脸,人脸在连续帧之间移动有限,可以每 2 到 3 帧检测一次,中间帧复用上一个人脸框。关键点仍然每帧拟合,因为眼睛开合是快速动作,关键点降频会直接漏掉眨眼。

下面这段是检测策略的核心。FACE_SKIP=3表示每 3 帧做一次人脸检测;如果上一轮没有脸,下一帧立即重检,避免人走进画面后长时间不响应。minSize=(120,120)是经验值,人脸小于这个尺寸时眼角点不稳定。scaleFactor=1.1和minNeighbors=5是速度和误检的折中,光照差时可以降到 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, scaleFactor=1.1, minNeighbors=5, minSize=(120, 120) ) return list(det) if len(det) else []

detectMultiScale返回的是(x, y, w, h)数组,可能有多张脸。闭眼检测一般只处理画面里最大的一张脸,避免背景人脸干扰。max(faces, key=lambda 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_th=EAR_TH, blink_min=BLINK_MIN_SEC, blink_max=BLINK_MAX_SEC, closed_warn=CLOSED_WARN_SEC, smooth_n=5 ) 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(maxlen=30) log_f = open("logs/eye_events.csv", "w", newline="", encoding="utf-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, key=lambda r: r[2] * r[3]) ok_lm, lms = facemark.fit( gray, np.array([(x, y, w, h)], dtype=np.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, f"EAR {smooth_ear:.2f}", (20, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2) cv2.putText(frame, f"blink {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 = f"FPS {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 但处理只有 12fps,FPS会显示 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 却降

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

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

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

立即咨询