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 就不能停在 HaarHaar 级联输出的是矩形框。它对一张人脸给出「这块区域像眼睛」的判断你能拿到 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 到 4136 外眼角37、38 上眼皮39 内眼角40、41 下眼皮右眼图像右侧42 到 4742 内眼角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, fBlinks: {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只能进CLOSINGCLOSING才能进ALARMALARM只能被睁眼打回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 在部分平台没有现成 wheelpip 会退回源码编译机器上没有 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 次把脚本跑一遍看计数准不准、报警时间点对不对。我一般要求眨眼计数误差在两成以内、闭眼报警不漏才算能用。单看某一帧的判断对错没有意义序列层面的准确率才是这套东西的价值所在。血泪经验是每次换摄像头、换场地、换戴不戴眼镜这套参数都要重新走一遍标定图省事直接沿用旧阈值翻车只是时间问题。从那以后我每次部署前都强制跑一遍录制回放验证把这个步骤写进脚本的启动检查里不让它靠自觉。希望帮到你。本文还有配套的精品资源点击获取