ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python人眼识别实战:眨眼检测、闭眼检测与EAR状态机

Python人眼识别实战:眨眼检测、闭眼检测与EAR状态机

简介:面向计算机视觉初学者与Python实践者的实时人眼识别、眨眼检测与闭眼检测项目源码包,基于OpenCV的Haar级联分类器与dlib的68点面部关键点模型,在Ubuntu环境下完成摄像头视频流的眼睛定位、帧间差异判眨与持续闭眼状态判定,适合课程设计、毕业设计及视觉算法入门练手。压缩包共61个文件,约75.34MB,以37个py脚本为主体,辅以png/jpg演示图、dat关键点模型、cfg配置、pdf教程与md说明文档,代码、素材与讲解配套齐全。目前已有4369人学习下载。读者可获得可直接运行的检测脚本、逐行讲解的详细教程与依赖安装指引,并借助imutils等工具模块理解视频帧处理流程,掌握从人脸关键点提取到眨眼事件判定的完整思路,便于二次开发与排错参考。

1. 从摄像头画面开始:一套人眼识别 + 眨眼检测 + 闭眼检测能解决什么

摄像头对着脸,人明明没眨眼,计数却在涨;真闭眼了,程序还在判睁眼——做 Python 人眼识别的人多半都经历过这两个反向翻车。这份资源把实时人眼识别、眨眼检测、闭眼检测放进同一条流水线:取帧、人脸框定位、眼睛关键点回归、EAR 计算、状态机判态,最后输出眨眼次数和闭眼时长,源码与逐步教程同步给出。

它适合两类人:要快速做出可演示原型的人——在线课堂注意力统计、驾驶疲劳预警、护眼提醒这类小系统,改入口和阈值就能跑;以及刚学 OpenCV、想把「检测框」升级成「行为事件」的初学者,代码量不大,但每一步都有明确的参数可调。

2. 技术路线选型:Haar 级联、dlib 68 点与 Face Mesh 的边界在哪

先把路线定下来,后面所有阈值和代码都围绕它展开。很多人拿到代码就跑,跑到自己机器上发现眼睛框乱飘,回头才发现选型就不对。人眼识别在工程上其实是三件不同的事:找到人脸、找到眼睛位置、判断眼睛是睁还是闭。前两件有成熟方案,第三件才决定了整套代码的上限。

2.1 Haar 级联的眼睛检测为什么容易飘

OpenCV 自带的haarcascade_eye.xml用的是积分图 + 类 Haar 特征 + AdaBoost 级联分类器,本质是「拿一堆黑白矩形模板在图像上滑动打分」。它的训练样本里睁眼样本远多于闭眼样本,所以闭眼时经常直接检不到——注意,是「检不到」,不是「检到闭眼」。

这就埋下了第一个坑:如果你写的是「检测到眼睛 = 睁眼,检不到 = 闭眼」,那么侧脸、暗光、戴眼镜、眼睛只占十几个像素的时候,全都表现为「检不到」,误报会成片出现。我见过一个疲劳检测 Demo,正脸准确率挺高,一转头就开始疯狂报警。

真要用 Haar,可以调这几个参数:

参数常用值作用调大后的后果
scaleFactor1.05 ~ 1.2每次金字塔缩放比例越接近 1 越慢,漏检少;过大漏检明显
minNeighbors3 ~ 6邻域框合并阈值调大误检少,但小眼睛容易被吃掉
minSize(20, 20)最小检测尺寸太大时远景眼睛直接检不出
maxSize不设最大检测尺寸设小了近景大脸检不到

实操建议是:把人脸框作为 ROI,只在人脸内部做眼睛检测,能砍掉一大半背景误检;同时把minNeighbors提到 5,宁可漏几帧也不要乱框。但无论怎么调,Haar 拿不出亚像素级的眼角坐标,做不了精确的睁闭眼判定,这一层天花板要认。

2.2 dlib 68 点与 Face Mesh 的关键点差异

dlib 的shape_predictor_68_face_landmarks.dat走的是「HOG 人脸检测 + 回归树关键点」的路子,输出 68 个点的固定语义坐标。眼睛部分是硬编码的:图像左侧眼 36 到 41,右侧眼 42 到 47,每个眼睛 6 个点,顺序是「左角、上睑两点、右角、下睑两点」。这个固定索引意味着 EAR 公式可以直接照抄,不需要自己找点。

Face Mesh 类的方案输出 468 个点(带虹膜版本 478 个),点数多、速度更快,CPU 上跑 640×480 基本能到 30fps。代价是眼睛没有「编号 36 到 41」这种现成语义,你得自己挑上下睑的点对,比如用 159/145 做上睑、33/133 做眼角。挑错了点,EAR 曲线会抖得没法用。

维度Haar 级联dlib 68 点Face Mesh
关键点精度无关键点,只有框稳定,亚像素级稳定,点数多
闭眼鲁棒性差(闭眼常检不到)好好
CPU 单帧耗时5 ~ 15ms20 ~ 40ms8 ~ 20ms
依赖体积内置,几百 KB模型文件约 100MB安装包较大,首次加载慢
侧脸表现差中等(超过约 30 度开始飘)较好
适合场景教学演示、低配设备桌面端离线原型实时性要求高的产品原型

体积那一列我只给量级,具体以你下载到的模型文件为准,不同版本差距不小。

2.3 按场景选型:别为了炫技堆模型

选型逻辑其实很朴素。跑在低配设备或树莓派上的,用 Haar 拿人脸框 + 眼睛 ROI 内做灰度/二值化统计,判断黑像素占比,阈值一调就能出效果,虽然糙但资源占用极低。桌面端笔记本摄像头做演示原型的,dlib 68 点是性价比最高的选择,代码短、调试直观、索引固定,教程也最好写。要做成有实时性要求的交互程序,或者需要戴口罩场景下的鲁棒性,再考虑 Face Mesh。

我个人习惯是:先用 dlib 跑通整条链路和状态机,把眨眼逻辑验证扎实,再换更快的检测器做性能替换。反过来先上快模型、状态机没调好,你根本分不清是检测抖还是逻辑错。

3. 环境跑通:依赖安装、摄像头采集与第一个眼睛框

选型定了 dlib 这条路,接下来把最小可运行闭环搭出来。这一步的目标很窄:能把摄像头画面读进来,能在人脸上画出眼睛的 6 个点,不追求判态。很多人跳过这一步直接跑完整代码,结果出错时分不清是环境问题、摄像头问题还是算法问题,排查成本翻倍。

3.1 依赖清单与安装

需要的东西不多:opencv-python负责采集和绘制,numpy做距离计算,dlib做关键点回归。建议开独立虚拟环境,避免和系统里的其他 OpenCV 版本打架。

# 建环境,Python 3.8 ~ 3.11 都比较稳 python -m venv eyenv # Windows 激活:eyenv\Scripts\activate source eyenv/bin/activate # 先装 numpy,再装 opencv,最后装 dlib pip install "numpy<2" --upgrade pip install opencv-python pip install dlib

opencv-python和opencv-contrib-python不要同时装,两者会互相覆盖,装重了会出现「某个函数一会儿有一会儿没有」的玄学问题。dlib 在 Windows 上需要本地编译,机器上得有 CMake 和 C++ 生成工具,第一次装可能要等几分钟到十几分钟。实在编译不过,可以退回到 Face Mesh 方案,或者用 Haar + 像素统计那条路走通逻辑,别卡在安装上耗一整天。

模型文件shape_predictor_68_face_landmarks.dat从资源包里拿,放到项目根目录,路径别带中文和空格——这是血泪经验,某些环境读带空格路径会静默失败。

3.2 摄像头采集骨架

采集这一层要做的三个决定:分辨率、帧率、缓冲策略。分辨率太高 CPU 扛不住,太低眼睛只有十几个像素,关键点直接失效。640×480 是实时人眼识别的常用起点。

import cv2 # Windows 上加 CAP_DSHOW,摄像头冷启动能快 1~2 秒 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) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # 只保留最新一帧,降低延迟 if not cap.isOpened(): raise RuntimeError("摄像头打开失败:检查索引是否被占用、驱动是否正常") while True: ok, frame = cap.read() if not ok: break frame = cv2.flip(frame, 1) # 镜像,画面方向和人的直觉一致 cv2.imshow("eye", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()

CAP_PROP_BUFFERSIZE设成 1 是关键一条:默认驱动会缓存好几帧,你处理得慢,画面就会越跑越滞后,最后出现「人已经睁眼了,程序还在处理 300 毫秒前那帧」。cv2.flip那行的作用是让画面像镜子,否则人往左偏、框往右走,演示时很别扭。

3.3 第一个眼睛框与坐标检查

采集通了就接关键点。这一步不要急着算 EAR,先把坐标打印出来看看范围。

import cv2 import dlib import numpy as np detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") cap = cv2.VideoCapture(0, cv2.CAP_DSHOW) while True: ok, frame = cap.read() if not ok: break frame = cv2.flip(frame, 1) gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 第二个参数是上采样次数,0 最快,1 能提升小脸检出率但更慢 faces = detector(gray, 0) for rect in faces: shape = predictor(gray, rect) pts = np.array([(p.x, p.y) for p in shape.parts()]) # dlib 索引:左眼 36-41,右眼 42-47 for idx in list(range(36, 48)): cv2.circle(frame, tuple(pts[idx]), 2, (0, 255, 0), -1) cv2.imshow("landmarks", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break

跑通后你会看到每只眼睛 6 个绿点,顺序是眼角、上睑、上睑、眼角、下睑、下睑。如果点数不够或者位置飘,先查两件事:人脸框是不是被detector检小了(把上采样参数从 0 改成 1 试试),以及画面分辨率是否太低。这一步没问题,后面的 EAR 才有意义。

4. EAR 落地:眨眼阈值、连续帧数与状态机参数

整套代码的核心就一个公式加一个状态机。公式决定「这一帧眼睛张开多少」,状态机决定「这串数字代表眨眼、闭眼还是正常睁眼」。绝大多数误报不是公式错,而是状态机的边界没定好。

4.1 EAR 的计算与关键点索引

EAR(Eye Aspect Ratio,眼睛纵横比)的直觉是:眼睛睁开时上下睑距离大,闭合时趋近于零,而眼角之间的水平距离基本不变。所以用「上下距离之和 / 两倍水平距离」,可以抵消人脸远近带来的尺度变化。

import numpy as np def eye_aspect_ratio(pts6): """pts6 顺序:[左角, 上睑1, 上睑2, 右角, 下睑2, 下睑1]""" A = np.linalg.norm(pts6[1] - pts6[5]) # 上睑1 到 下睑1 B = np.linalg.norm(pts6[2] - pts6[4]) # 上睑2 到 下睑2 C = np.linalg.norm(pts6[0] - pts6[3]) # 左右眼角距离 if C < 1e-6: return 0.0 return (A + B) / (2.0 * C) # 用法 left_ear = eye_aspect_ratio(pts[36:42]) right_ear = eye_aspect_ratio(pts[42:48]) ear = (left_ear + right_ear) / 2.0

分母C必须做零保护,人脸框异常时会返回退化坐标,除以零在 Python 里不一定报错,但会产出inf或nan,后面所有比较都失效,表现为「程序不报错,但永远不眨眼」。这是典型的黑匣子故障。

两只眼睛取平均而不是取最小,是为了抗单侧遮挡:手挡住一只眼时,取最小会直接判闭眼,取平均能把误判概率压下去。代价是单眼眨的人判不出来,一般场景不用管。

4.2 从单帧 EAR 到眨眼事件:滑动窗口加状态机

单帧 EAR 没有任何意义,因为它抖得厉害。要做的是滑动窗口平滑加双阈值状态机。平滑用中位数比均值好,中位数对偶发跳变不敏感。

from collections import deque import numpy as np class BlinkCounter: def __init__(self, ear_th=0.21, close_need=3, open_need=3, max_blink_frames=12, cooldown=8, win=5): self.ear_th = ear_th # 低于它视为“合上” self.close_need = close_need # 连续 N 帧才算真合上 self.open_need = open_need # 连续 N 帧才算真睁开 self.max_blink_frames = max_blink_frames # 超过它算持续闭眼,不算眨眼 self.cooldown = cooldown # 事件后冷却帧数,防重复计数 self.buf = deque(maxlen=win) # EAR 中位数平滑窗口 self.state = "open" self.closed_len = 0 self.open_cnt = 0 self.cool = 0 self.blink_count = 0 self.closed_events = 0 def update(self, ear_raw): self.buf.append(ear_raw) ear = float(np.median(self.buf)) if self.cool > 0: self.cool -= 1 if self.state == "open": if ear < self.ear_th: self.closed_len += 1 if self.closed_len >= self.close_need: self.state = "closed" self.open_cnt = 0 else: self.closed_len = 0 else: self.closed_len += 1 if ear >= self.ear_th: self.open_cnt += 1 if self.open_cnt >= self.open_need: # 睁眼确认,结算这次闭合 if self.cool == 0: if self.closed_len <= self.max_blink_frames: self.blink_count += 1 else: self.closed_events += 1 # 长时间闭眼 self.cool = self.cooldown self.state = "open" self.closed_len = 0 self.open_cnt = 0 return self.state, self.blink_count, self.closed_events

逻辑上有三个点值得说清楚。close_need和open_need是滞回:如果只用单帧比较,EAR 在阈值附近来回跳,一次眨眼能被拆成五六次事件,计数直接翻倍。max_blink_frames是分类边界:正常人一次眨眼大约 100 到 400 毫秒,30fps 下对应 3 到 12 帧,短于这个区间可能是噪声,长于这个区间就是闭眼或疲劳,应该走到另一个分支上。cooldown是后悔药,防止同一段闭合被重复结算。

参数没有普适值,但有一组能当起点的默认:ear_th=0.21、close_need=3、open_need=3、max_blink_frames=12、cooldown=8。我的建议是先把ear_th标定对,再动帧数参数,顺序反了会调得很痛苦。

4.3 参数标定:让阈值从你自己的数据里长出来

网上的 0.2、0.25 这类数字都是别人脸上的统计结果。眼型、摄像头俯仰角、分辨率都会让 EAR 的绝对值整体平移,照抄大概率翻车。正确做法是采一段自己的数据看分布。

import numpy as np, cv2, dlib, time # 前 3 秒睁眼,后 3 秒闭眼,简单粗暴但有效 def calibrate(cap, predictor, detector, seconds=3): samples = {"open": [], "closed": []} for phase in ("open", "closed"): t0 = time.time() while time.time() - t0 < seconds: ok, frame = cap.read() if not ok: continue gray = cv2.cvtColor(cv2.flip(frame, 1), cv2.COLOR_BGR2GRAY) for rect in detector(gray, 0): pts = np.array([(p.x, p.y) for p in predictor(gray, rect).parts()]) l = eye_aspect_ratio(pts[36:42]) r = eye_aspect_ratio(pts[42:48]) samples[phase].append((l + r) / 2.0) o = np.array(samples["open"]); c = np.array(samples["closed"]) print("睁眼 p10/p50:", np.percentile(o, 10), np.percentile(o, 50)) print("闭眼 p50/p90:", np.percentile(c, 50), np.percentile(c, 90)) # 阈值取睁眼下沿与闭眼上沿的中点 return (np.percentile(o, 10) + np.percentile(c, 90)) / 2.0

看的是两个数:睁眼的第 10 百分位和闭眼的第 90 百分位。阈值取两者中点,能保证九成以上的睁眼帧判对、九成以上的闭眼帧判对。如果这两个数几乎重合,说明你的眼睛在画面里太小或者太糊,先把摄像头拉近、把分辨率提上去,别硬调阈值。

提示:标定结果和你坐的位置强相关。正式跑之前,让人坐到实际使用的位置再标一次,省掉后面一堆「怎么一会儿灵一会儿不灵」的疑惑。

5. 常见问题排查:眼镜反光、侧脸低头、暗光误判的 5 条修正

代码跑通不等于能用。真实环境里,误报来源非常集中,处理掉下面这几类,准确率能上一个台阶。每条都按现象、原因、解法写,方便对着现象找。

5.1 先把误报量化:三个指标比「感觉不准」有用

在动手调参之前,先定义清楚什么叫「不准」。我一般看三个数:眨眼计数误差(一分钟内实际眨眼次数与程序计数之差)、闭眼误报率(睁眼状态被判闭眼的帧占比)、闭眼漏报率(真闭眼却没被判出的帧占比)。前两个指标决定演示效果,第三个指标决定疲劳预警这类功能能不能用。

做法很简单,录一段 60 秒的视频,人工标出每次眨眼的时间点,跑一遍程序输出事件时间戳,做对齐比对。没有这一步,所有调参都是凭手感。

5.2 现象:戴眼镜时 EAR 曲线锯齿明显,计数虚高

原因:镜片反光和镜框边缘落在眼睛区域,关键点回归被干扰,上睑点忽上忽下。EAR 序列出现高频抖动,中位数平滑窗口小于 5 帧时压不住。

解决三步走:先把平滑窗口从 5 帧加到 7 帧,观察抖动是否收敛;再把人脸框分辨率提上去,480p 改 720p,眼睛像素多了,关键点明显更稳;最后才考虑换检测方案。另外用 Haar 做辅助检测时,可以换成带眼镜的模型文件,闭眼检出率会好一些,但对睁闭判断帮助有限,别指望它解决根本问题。

5.3 现象:一侧脸或低头,眼睛点直接跳到眉毛上

原因:68 点模型是在正脸数据上训练的,偏航角超过大约 30 度、俯仰角过大时,回归结果会退化。退化后的点画出来还在脸上,但位置已经完全错了,EAR 可能瞬间变成一个很大的值,于是「闭眼被判睁眼」。

解决:加姿态门控。用眼睛中点与鼻尖、下巴的相对位置粗估姿态,偏离过大时冻结状态机——保持上一帧的状态,不计数、不判定,等回到正脸再恢复。千万别在检测失败时把状态重置成「睁眼」,那会在一次长时间闭眼里塞进好几个假眨眼。

def face_ok(pts, yaw_tol=0.18): # 用眼角到鼻尖的水平偏移做极简偏航估计 eye_mid_x = (pts[36][0] + pts[45][0]) / 2.0 nose_x = pts[30][0] face_w = abs(pts[45][0] - pts[36][0]) + 1e-6 return abs(nose_x - eye_mid_x) / face_w < yaw_tol

yaw_tol从 0.18 起调,正脸时这个比值一般在 0.1 以内,转头会明显变大。

5.4 现象:晚上开台灯,闭眼一直判成睁眼

原因:光照不足时灰度图整体压缩,眼睑边缘对比度低,关键点回归出来的上下睑距离偏大,EAR 反而升高。这类问题白天根本复现不了,非常折磨人。

解决:在转灰度之后、送进检测器之前加一步 CLAHE 局部对比度增强,clipLimit从 2.0 开始试,tileGridSize用 8×8。同时把摄像头的自动曝光关掉、手动设一个偏低的值,避免画面在明暗之间来回跳。如果设备支持,补一个正面光源比调任何参数都有效。

clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = clahe.apply(gray)

这一行对白天几乎无副作用,对夜间的稳定性提升很明显,属于加进去不亏的操作。

5.5 现象:一次眨眼计成两三次,或者干脆不计

原因:阈值定在 EAR 分布的重叠区,导致状态反复横跳;或者max_blink_frames设得太小,正常眨眼被判成了持续闭眼而不是眨眼。

解决:看日志。把每帧的平滑后 EAR 和当前状态打出来,一次眨眼应该是一条漂亮的 V 形曲线。如果你的曲线在阈值附近有两次穿越,就是阈值问题;如果曲线只有一次干净的下穿但没计数,就是帧数边界问题。先把max_blink_frames从 12 放宽到 15 试,再把close_need降到 2,逐个变量改,一次只改一个。

5.6 现象:帧率掉到个位数,画面越来越滞后

原因:多数是把关键点检测放在了采集循环里同步做,加上分辨率开到了 1080p。dlib 在 1080p 上单帧可能吃掉 100 毫秒以上,帧率直接塌掉。

解决:分辨率回 640×480;检测前把图像缩小一半送进检测器,坐标再乘以 2 映射回来;真正需要高帧率就上多线程,采集和推理分开,只保留最新一帧。1080p 对人眼识别没有额外价值,眼睛本来就没多大。

6. 帧率与稳定性收尾:多线程采集加阈值自标定的组合拳

单线程跑通了,最后一步是把实时性做实。采集和推理串在一个循环里,任何一次检测卡顿都会累积成延迟;采集线程只负责取帧并覆盖写入共享变量,主线程拿最新帧做推理,画面观感会明显不同。

import threading, cv2, time class Camera: def __init__(self, idx=0): self.cap = cv2.VideoCapture(idx, cv2.CAP_DSHOW) self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) self.frame = None self.running = True threading.Thread(target=self._loop, daemon=True).start() def _loop(self): while self.running: ok, f = self.cap.read() if ok: self.frame = cv2.flip(f, 1) # 只覆盖,不排队 else: time.sleep(0.01) def read(self): return None if self.frame is None else self.frame.copy() def stop(self): self.running = False self.cap.release()

共享变量只存一帧、不做队列,是刻意的:队列会让程序处理历史帧,延迟越积越大;只留最新帧意味着处理不过来时自动丢帧,延迟恒定。这个取舍在实时视觉里几乎总是对的。

配套的习惯是启动即标定。程序起来后先进入 3 秒标定态,让使用者睁眼、闭眼各保持一段时间,用 4.3 那套百分位逻辑算出本轮阈值,再进入正式检测。同一台机器、同一个人、同一个位置,标定一次能用很久;换了座位或换了人,重标一次比调半天参数有效。

还有个小技巧:把平滑后的 EAR 实时画成一条滚动曲线叠在画面角落,调试期开着,交付前关掉。有没有这条曲线,排查效率差一个量级——很多「玄学抖动」一看曲线就知道是检测跳点还是阈值贴太近。

从那以后我每次做人眼识别相关的项目,都强制先跑标定脚本再谈准确率,不标定直接调参的后果我吃过太多次。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进