Python+OpenCV实时人眼识别:眨眼检测与闭眼监测实战
简介:面向计算机视觉初学者与需要做人眼状态分析的开发者,这套代码与教程围绕 Python、OpenCV 在 Ubuntu 环境下的实时人眼识别、眨眼检测和闭眼检测展开,可用于课堂实验、课程设计或疲劳监测类原型验证。压缩包共 61 个文件,约 75.34MB,包含 37 个 py 源码、12 个 png 与 4 个 jpg 示例图、1 个 dat 人脸关键点模型,以及说明文档、配置文件和第三方辅助库目录,便于按模块阅读、调试与运行。已有 4369 人学习下载。内容从 Haar 级联分类器的人眼定位入手,结合连续帧差异、光流或霍夫变换思路判断眨眼与持续闭眼,并配有详细教程讲解依赖安装、代码运行和原理理解;68 点人脸关键点模型与示例图片可用于调试和效果对照,适合作为计算机视觉实践案例与功能二次开发基础,也可帮助读者快速理解检测流程并扩展应用。
1. 一段能跑的链路:先搞清这套东西解决的是哪类问题
摄像头对着工位,屏幕上每帧都在回答一个问题:这个人是在正常眨眼,还是已经闭眼不睁。用 Python 加 OpenCV 做实时人眼识别、眨眼检测、闭眼检测,拆开看只有三件事——把眼睛位置找出来,把睁眼程度量化成一个数,再按时间窗口判断这个数掉下去多久。这份资源把这三件事串成了一条能直接跑的链路,源码配详细教程,核心逻辑集中在两三个脚本里,改阈值和参数就能挪到自己的场景。
它适合谁:做疲劳监测演示、网课注意力统计、课程设计答辩的开发者,手里有个普通 USB 摄像头就能验。不适合谁:要求多人同时检测、强遮挡、夜间红外成像的工业现场,那种场景得上带跟踪的人脸关键点方案,单帧检测撑不住。后面几章按「先跑通、再算准、最后压误报」的顺序拆,代码都能直接抄。
2. 技术路线选型:Haar 级联和 68 点关键点先跑哪个
拿到源码包,第一件事不是 pip install,而是翻一眼它用的是哪条路线。人眼识别在这个场景下有两条主流实现:OpenCV 自带的 Haar 级联分类器,和基于回归的人脸 68 点关键点模型。两条路线的输出形态完全不同,直接决定了你后面能不能算 EAR。选错了,代码跑起来能看到框,但眨眼检测永远做不准。
2.1 为什么算 EAR 就不能停在 Haar
Haar 级联输出的是矩形框。它对一张人脸给出「这块区域像眼睛」的判断,你能拿到 box 的左上角坐标和宽高,但拿不到眼皮的具体位置。有人会想用 box 的高宽比来近似睁眼程度,实际跑起来会发现这个比值随人脸距离、拍摄角度剧烈变化,同一个人往前凑十厘米,比值就跳一档,根本没法做阈值判断。
关键点模型给的是眼角、上眼皮、下眼皮的坐标点,EAR 公式要的正是这六个点的两两距离。这就是为什么做眨眼检测绕不开关键点。资源里两条路线都给了,我一般的顺序是:先用 Haar 把「摄像头能开、人脸能检、画面能画框」这条链路跑通,确认环境没问题,再换成关键点算 EAR。这样出问题时能快速定位是环境问题还是算法问题。
两条路线的差异可以对照着看:
| 维度 | Haar 级联 | 68 点关键点 |
|---|---|---|
| 输出形态 | 矩形框 | 68 个坐标点 |
| 能否算 EAR | 不能,只能做粗分类 | 能 |
| 640x480 单帧耗时 | 约 5 到 10 毫秒 | 约 15 到 30 毫秒 |
| 依赖 | opencv 自带 xml | 需额外下载模型文件 |
| 侧脸、低头 | 容易漏检 | 也容易漏,但可加姿态判断补救 |
| 对光照敏感度 | 高 | 中等 |
一句话选型:只做「有没有睁眼」的粗判断,Haar 够用;要做眨眼的次数统计和闭眼时长,必须上关键点。
2.2 依赖装不上,后面全是白搭
这条链路只需要三个包:opencv-python、numpy、dlib。前两个好装,dlib 是常见的翻车点,它在 Windows 上默认走源码编译,机器上没有 CMake 和 C++ 编译链就会在 build wheel 阶段报一堆红字。我一般直接用 conda 装预编译版绕开:
# 先升级 pip,老版本 pip 解析 wheel 容易失败 python -m pip install --upgrade pip # opencv 和 numpy 走 pip pip install opencv-python numpy # dlib 在 Windows 上走 conda 预编译版,省掉本地编译 conda install -c conda-forge dlib逻辑说明:opencv-python 装的是主包,cv2.data.haarcascades里自带级联分类器的 xml,不用另外下。dlib 只负责关键点回归和人脸检测,模型文件shape_predictor_68_face_landmarks.dat要单独下载,百兆级,放在脚本同级目录最省事。
参数说明:不要同时装opencv-python和opencv-contrib-python,两者会互相覆盖,运行时出现cv2.dnn找不到这类怪问题。只留一个即可。Python 版本建议 3.8 到 3.10,太新的版本某些 dlib wheel 还没跟上。
装完立刻验证一句,别等到写完整脚本才发现导入失败:
import cv2, dlib, numpy as np print(cv2.__version__) print(dlib.__version__) # 模型文件存在性检查,省得运行到一半才报错 import os assert os.path.exists("shape_predictor_68_face_landmarks.dat"), "模型文件没放到当前目录"2.3 摄像头自检:先确认能稳定读到 30 帧
很多「检测不准」的问题,根子不在算法,在摄像头本身。分辨率给到 1080p、帧率被自动降到 15,检测器每帧都在追一帧旧画面,抖动自然大。先单独跑一段摄像头自检,把分辨率和实际帧率固定下来:
import cv2 # 设备号 0 是默认摄像头;Windows 下加 CAP_DSHOW 可以避免首帧卡两三秒 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) if not cap.isOpened(): raise RuntimeError("摄像头打不开,检查设备号或被其他程序占用") t0 = cv2.getTickCount() frames = 0 while True: ok, frame = cap.read() if not ok: break frames += 1 if frames % 30 == 0: # 用 tick 计时比 time.time 更稳,不受系统时钟调整影响 elapsed = (cv2.getTickCount() - t0) / cv2.getTickFrequency() print(f"实际帧率 {30 / elapsed:.1f}") t0, frames = cv2.getTickCount(), 0 cv2.imshow("cam", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()逻辑说明:cap.set只是向驱动「请求」参数,能不能生效取决于摄像头固件,所以必须实测帧率。打印出来的实际值低于 25,后面所有基于帧数的判定都要跟着改。
参数说明:640x480 是这条链路性价比最高的分辨率,关键点模型在这个尺寸下单帧 15 到 30 毫秒,30 帧能跑满。直接上 1080p,单帧可能到 80 毫秒以上,画面会明显拖影。人脸检测调用时还有个上采样参数:
detector = dlib.get_frontal_face_detector() gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 第二个参数 1 表示上采样一次,小脸更容易检到,耗时约翻倍 faces = detector(gray, 1)坐在正常距离用 0 就够,坐得远或者画面里人脸占比小,再调到 1。这个参数是新手最容易忽略的性能开关。
3. EAR 落地:六个点怎么算出一个能判眨眼的数
环境通了,接下来是核心。EAR 全称 Eye Aspect Ratio,翻译过来就是眼睛纵横比,思路非常朴素:睁眼时上下眼皮的距离大,闭眼时这组距离趋近于零,而眼角的水平距离基本不变。用垂直距离除以水平距离,就得到一个与脸部远近无关的归一化数值。这个数值就是眨眼检测和闭眼检测共用的那根标尺。
3.1 EAR 的几何含义和合理区间
68 点模型里,每只眼睛由六个点构成。取上眼皮两个点、下眼皮两个点,分别算它们到对侧点的垂直距离,再取眼角之间的水平距离,公式是:
EAR = (|p2 - p6| + |p3 - p5|) / (2 * |p1 - p4|)分母乘 2 是为了和分子两项对齐量纲。经验值上,正常睁眼时 EAR 大致落在 0.25 到 0.35 之间,闭眼时掉到 0.10 到 0.18,眯眼在中间地带。注意这里说的是「大致」,个体差异非常大,眼眶深的、单眼皮的、戴眼镜的,基线能差出 0.05 以上。所以任何教程里写死的阈值都只能当起点,不能当结论。
还有一个容易忽略的点:EAR 对头部姿态敏感。抬头时上下眼皮在图像上的投影距离被拉长,EAR 会虚高;低头时被压缩,EAR 虚低。如果场景里人经常低头看手机或者看键盘,必须配合人脸框的位置做过滤,否则误报会集中出现在低头那几秒。
3.2 68 点模型里的眼睛索引
关键点顺序是固定的,两只眼睛各六个点,索引直接决定你切哪几个坐标:
| 部位 | 索引范围 | 点位含义 |
|---|---|---|
| 左眼(图像左侧) | 36 到 41 | 36 外眼角,37、38 上眼皮,39 内眼角,40、41 下眼皮 |
| 右眼(图像右侧) | 42 到 47 | 42 内眼角,43、44 上眼皮,45 外眼角,46、47 下眼皮 |
把这两个切片喂给同一个函数就行:
import numpy as np from scipy.spatial import distance as dist def eye_aspect_ratio(eye): # eye 是 6x2 的数组,顺序与 68 点模型一致 A = dist.euclidean(eye[1], eye[5]) # 上眼皮到对侧下眼皮 B = dist.euclidean(eye[2], eye[4]) # 另一组垂直距离 C = dist.euclidean(eye[0], eye[3]) # 眼角水平距离 # 加 1e-6 防止极端侧脸时 C 为 0 导致除零 return (A + B) / (2.0 * C + 1e-6) # 主循环里这样取 shape = predictor(gray, face) pts = np.array([(shape.part(i).x, shape.part(i).y) for i in range(68)]) left_eye = pts[36:42] right_eye = pts[42:48] left_ear = eye_aspect_ratio(left_eye) right_ear = eye_aspect_ratio(right_eye) ear = (left_ear + right_ear) / 2.0逻辑说明:predictor返回的是 dlib 的点对象,转成 numpy 数组后切片才方便。左右眼取平均而不是各判各的,是因为单眼被头发、镜框挡住时,单侧 EAR 会瞬间跳水,直接判闭眼会误报;平均之后另一只眼的正常值能把结果拉回来。
参数说明:shape.part(i)是逐点访问,68 次调用在大循环里有开销,用列表推导一次转成数组后复用,实测能省一到两毫秒。如果只要眼睛,可以只转 36 到 48 这几个点,但人脸框画图还要别的点,整体转更省事。
3.3 单帧阈值不够,要加连续帧计数
有了 EAR,最直接的写法是「小于阈值就判闭眼」。这么写的代码在现场跑十分钟能被误报烦死:关键点每帧都有微小抖动,某帧恰好掉到阈值下方一丁点,就触发一次。解法是把单帧判断升级成事件判断——连续 N 帧都在阈值下方,才算一次眨眼。
EYE_AR_THRESH = 0.22 # 判定阈值,先用经验值,后面按人标定 EYE_AR_CONSEC_FRAMES = 2 # 连续多少帧低于阈值才算数 counter = 0 # 当前连续低于阈值的帧计数 total = 0 # 累计眨眼次数 # 主循环内 if ear < EYE_AR_THRESH: counter += 1 else: # 从闭到睁的那一刻结算,避免长闭眼被重复计次 if counter >= EYE_AR_CONSEC_FRAMES: total += 1 counter = 0 cv2.putText(frame, f"Blinks: {total}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)逻辑说明:计数的结算放在「睁眼」分支里,是因为眨眼是一个「闭下去再睁开」的完整动作。如果放在闭眼分支里累加,一个持续三秒的闭眼会被算成几十次眨眼,统计直接失真。这个细节决定了后面闭眼检测能不能和眨眼检测共存。
参数说明:阈值 0.22 适合大多数人坐姿正对摄像头的情况,戴黑框眼镜的建议上调到 0.24,因为镜框会让上眼皮的点位整体下移。连续帧数取 2,在 30 帧下约等于 66 毫秒,能覆盖绝大多数抖动;取 3 更保守,但快速眨眼可能漏计。帧率降到 15 的话,这个数字要跟着翻倍,否则时间窗口会缩短一半。
4. 闭眼检测:用时间戳把「眨一下」和「闭着不睁」分开
眨眼检测统计的是次数,闭眼检测关心的是时长。这两件事共用同一个 EAR 数值,但判定逻辑必须分开写,混在一起是新手最常见的结构性问题。一个人在疲劳状态下,眨眼频率可能没变,但每次闭眼的时间会从 200 毫秒拉长到一秒以上,这个「时长」才是疲劳信号的真正载体。
4.1 眨眼约 300 毫秒,闭眼可能是 3 秒
正常自主眨眼从闭上到睁开大约 100 到 400 毫秒,绝大多数落在 200 毫秒上下。而打瞌睡、走神、微睡眠状态下,闭眼会持续一秒到数秒。这两者的时间尺度差了一个数量级,所以判定依据不能是「闭没闭」,而是「闭了多久」。
这里就出现一个关键选择:用帧数计数还是用时间戳。用帧数的问题在于它依赖帧率,同一份代码在 30 帧机器上判 1 秒,在 15 帧机器上就变成 2 秒,换个摄像头行为就变了。所以我一般直接用时间戳:
import time closed_start = None # 本次闭眼的起始时间戳 ALARM_SEC = 1.5 # 闭眼超过这个秒数触发提示 if ear < EYE_AR_THRESH: if closed_start is None: closed_start = time.time() # 刚闭上,记下起点 duration = time.time() - closed_start if duration >= ALARM_SEC: cv2.putText(frame, "CLOSED", (10, 70), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 255), 2) else: closed_start = None # 睁眼即清零,下次重新计时逻辑说明:closed_start为 None 是「当前睁眼」的状态标记,第一次跌破阈值时赋值,之后每帧只算差值,不再重置。睁眼立刻清空,这样连续两次闭眼之间不会互相污染计时。
参数说明:ALARM_SEC是整套闭眼检测里最需要按场景调的参数。驾驶疲劳场景一般取 1.0 到 1.5 秒,在线学习的注意力统计可以放宽到 2 到 3 秒,避免正常思考时的短暂垂眼被算进去。阈值调得太低,误报会把人烦到关掉程序。
4.2 状态机写法:OPEN、CLOSED 和报警三态
只用 if-else 加一个时间戳,逻辑短的时候够用,一旦要加「短暂闭眼不计、长闭眼报警、报警后需要睁眼多久才复位」,分支就会缠成一团。我习惯把它写成一个显式的三态状态机,状态迁移一眼能看懂,加需求也不容易改崩:
from enum import Enum class EyeState(Enum): OPEN = 0 CLOSING = 1 # 已闭眼但未达报警时长 ALARM = 2 # 已超过报警时长 state = EyeState.OPEN closed_start = None def update_state(ear, thresh, alarm_sec): global state, closed_start now = time.time() if ear >= thresh: # 睁眼,任何状态都复位 state = EyeState.OPEN closed_start = None return state if closed_start is None: # 刚闭上 closed_start = now state = EyeState.CLOSING return state if now - closed_start >= alarm_sec: state = EyeState.ALARM else: state = EyeState.CLOSING return state逻辑说明:状态机的价值在于把「当前处于什么阶段」变成一个可查询的变量,而不是散落在各处的布尔标记。画界面上色、写日志、触发声音提示,都只需要读state。
参数说明:三个状态之间没有「跳过」的路径,OPEN只能进CLOSING,CLOSING才能进ALARM,ALARM只能被睁眼打回OPEN。这条约束保证了报警不会因为某帧抖动被反复触发。
4.3 用录制片段回放标定阈值
现场对着摄像头调阈值是效率最低的做法,因为你没法复现刚才那一帧。正确姿势是先录一段带标注的视频,离线跑一遍收集 EAR 序列,看分布再定阈值。录制内容要覆盖:正常睁眼、自然眨眼、眯眼、持续闭眼、戴眼镜、侧脸、低头。三十秒到一分钟就够。
import numpy as np ears = [] # 离线跑视频时把每帧的平均 EAR 存进来 # ... 逐帧处理,ear 计算完成后 ears.append(ear) arr = np.array(ears) # 看分位数比看均值有用得多,极值才是误报来源 print("P1 ", np.percentile(arr, 1)) print("P5 ", np.percentile(arr, 5)) print("P50 ", np.percentile(arr, 50)) print("P95 ", np.percentile(arr, 95))逻辑说明:P5 附近基本对应「闭眼」的取值区间,P50 是正常睁眼的基线。阈值取在两者之间偏下的位置,比如 P5 加一点点余量,能同时兼顾灵敏度和稳定性。
参数说明:样本里如果混进大量侧脸帧,P1 会被拉得很低,这时候要先把侧脸帧剔掉再统计,否则阈值定得过低,正脸时反而检不出闭眼。这个预处理步骤很多现成脚本都省略了,是复现效果对不上的常见原因。
5. 避坑排查:五个让误报率翻倍的具体问题
这套代码在实验室灯光下跑得挺漂亮,搬到真实环境里,误报率能翻好几倍。下面这五条是我在实际调试里反复撞上的,每条都按现象、原因、解决三段说清楚,照着排查能省掉大量试错时间。
5.1 下午靠窗就误报:逆光让关键点整体漂移
现象:上午检测正常,下午坐在窗边,闭眼报警频繁触发,画面里人明明睁着眼。
原因:逆光时人脸变成剪影,关键点回归模型找不到眼皮边缘,会把上眼皮的点往下「猜」,EAR 被系统性压低。这不是阈值问题,是输入质量塌了。灰度直方图集中在一端时,任何基于外观的回归都会失准。
解决:先做直方图均衡化再送检测,gray = cv2.equalizeHist(gray),对逆光有肉眼可见的改善。更彻底的做法是加一个亮度检查,画面平均亮度低于某个值时直接跳过判定并在界面提示「光照不足」,宁可不出结果也别出错误结果。物理层面调转座椅方向或者补一盏正面光,比任何算法都有效。
5.2 没眨眼计数却一直涨:关键点抖动被当成了眨眼
现象:盯着屏幕不动,眨眼计数每分钟多出七八次。
原因:连续帧阈值设成 1,任何一帧的微小抖动都会形成一次「闭-睁」循环。EAR 序列在阈值附近来回穿越时,计数逻辑会被反复触发。
解决:连续帧数至少设 2,同时对 EAR 做滑动平均,取最近 3 帧的均值再判。滑动窗口别开太大,5 帧以上会把真实的快速眨眼抹平。另外把阈值从 0.22 往 0.20 挪一点,让它离正常睁眼的基线更远,穿越次数自然下降。
5.3 dlib 装不上:缺的是编译链不是网络
现象:pip install dlib卡在 building wheel,滚出一屏 CMake 报错。
原因:dlib 在部分平台没有现成 wheel,pip 会退回源码编译,机器上没有 CMake 或 C++ 编译器就必然失败。反复重试、换源都没用,因为问题不在下载。
解决:Windows 上装 Visual Studio Build Tools 的 C++ 组件,或者直接用 conda 装预编译版。装完先import dlib验证再往下走,别把环境问题和算法问题混在一起排查。
5.4 左右眼数据对不上:镜像画面和索引写反
现象:接上外接摄像头后,统计里左眼 EAR 一直比右眼低一截,或者画出来的点位整体偏移。
原因:两个可能。一是画面做了水平翻转(自拍习惯),但关键点是在翻转后的图上检测的,视觉上的「左眼」其实对应索引里的右眼,写统计时容易搞混。二是有人手动把 36 到 42 和 42 到 48 两个切片写反了。
解决:统一约定——先 flip 再检测,之后所有坐标都基于同一张图,不要在中间环节混用。调试时把 68 个点全部画出来看一眼,点位和眼睛是否对齐一眼就能判断,比读代码快。
5.5 1080p 反而更卡:分辨率和检测耗时不成正比
现象:换成高清摄像头后画面明显迟滞,EAR 数值跳变幅度变大。
原因:关键点检测耗时随像素数增长,1080p 下单帧可能到 80 毫秒以上,帧率掉到十几帧。帧率一低,两帧之间人的动作幅度变大,关键点位置跳动加剧,EAR 序列噪声上升,误报跟着变多。
解决:采集端直接请求 640x480,不要采高分辨率再缩放。确实需要高清画面做展示的,用两路:一路低分辨率专供检测,一路高分辨率只做显示,检测结果按比例映射回大图。这个双路结构在真实项目里很常见。
6. 把误报压下去:双阈值迟滞与个体自适应
单阈值方案在稳定光照、固定坐姿下能用,一旦人稍微动一动就开始飘。我自己项目里最后落地的做法是双阈值迟滞:设一个进入闭眼的下阈值和一个退出闭眼的恢复阈值,两者之间留一段缓冲带,EAR 在这个带里来回穿不会改变状态。
ENTER_THRESH = 0.20 # 低于此值才算进入闭眼 EXIT_THRESH = 0.25 # 高于此值才算恢复睁眼 is_closed = False def hysteresis(ear, is_closed): if is_closed: # 只有明确回到高位才解除,避免在阈值附近反复横跳 if ear > EXIT_THRESH: is_closed = False else: if ear < ENTER_THRESH: is_closed = True return is_closed逻辑说明:缓冲带的宽度就是抗抖动能力。带宽太窄等于没用,太宽会让真实闭眼延迟触发,一般取 0.04 到 0.06,差不多是正常睁眼基线的 15% 到 20%。
个体自适应是第二层。每个人睁眼时的 EAR 基线不同,与其用固定值,不如在启动后前几秒收集一段「睁眼」样本,取中位数乘以一个系数作为该用户的阈值:
| 参数 | 取值方式 | 说明 |
|---|---|---|
| 基线 EAR | 启动后 3 秒内睁眼帧的中位数 | 滤掉眨眼帧,只保留高位 |
| 进入阈值 | 基线 × 0.65 | 约等于基线下移 35% |
| 恢复阈值 | 基线 × 0.80 | 与进入阈值形成缓冲带 |
| 报警时长 | 按场景配置 | 疲劳 1.5 秒,注意力统计 2.5 秒 |
验证方法也简单:录一段已知答案的视频,正常眨眼 20 次、持续闭眼 3 次,把脚本跑一遍,看计数准不准、报警时间点对不对。我一般要求眨眼计数误差在两成以内、闭眼报警不漏,才算能用。单看某一帧的判断对错没有意义,序列层面的准确率才是这套东西的价值所在。
血泪经验是:每次换摄像头、换场地、换戴不戴眼镜,这套参数都要重新走一遍标定,图省事直接沿用旧阈值,翻车只是时间问题。从那以后我每次部署前都强制跑一遍录制回放验证,把这个步骤写进脚本的启动检查里,不让它靠自觉。希望帮到你。
本文还有配套的精品资源,点击获取