Python+OpenCV实时人眼识别与眨眼闭眼检测实战

发布时间:2026/11/6 12:33:53
Python+OpenCV实时人眼识别与眨眼闭眼检测实战

简介:这是一份面向Python初学者与计算机视觉入门开发者的实战源码包,围绕OpenCV实现实时人眼定位、眨眼判断与闭眼状态识别,可用于课堂演示、课程设计、视觉算法练手及摄像头监测原型验证。代码以Haar级联分类器完成眼部检测,结合连续帧差异、光流或霍夫变换分析眨眼,并配置六十八点面部关键点模型,附带Ubuntu环境下的依赖说明。压缩包共61个文件,约75.34MB,主体为37个Python脚本,另有12张png、4张jpg示例图,以及模型文件、pdf教程和md说明,按源码、教程与依赖库分区组织,便于对照查阅和复现实验。已有4369人学习,读者可从教程理解分类器调用、关键点对齐与闭眼判定思路,并借助示例图和脚本搭建自己的实时检测工程,完成从原理学习到项目改造的过渡,也可作为课程作业与毕业设计前期验证的参考资料。

1. 从摄像头到眨眼判定:这套 Python+OpenCV 人眼识别项目适合谁

Python 和 OpenCV 做实时人眼识别、眨眼检测、闭眼检测,听起来像三个模型叠在一起,其实主线可以很朴素:摄像头取帧,人脸检测框出脸,眼睛检测框出眼,眨眼检测看眼睛区域纵横比在连续帧里的起伏,闭眼检测则看这个比值持续低于阈值多久。这个项目适合手里有课程设计、疲劳监测原型、互动 Demo 需求,想用一台普通摄像头和一份能改的源码快速跑通的人。它不追求论文级精度,优势是链路短、依赖少、每一帧都能可视化。读下去你会看到目录怎么拆、EAR 怎么算、阈值怎么调,以及我踩过的那些摄像头和光照的坑。

2. 项目骨架与技术选型:Haar 级联、关键点与 EAR 指标怎么配合

这份源码的主线不是训练模型,而是把 OpenCV 的检测能力和一个可解释的几何指标拼成实时管线。人脸检测负责缩小搜索范围,眼睛检测负责拿到局部区域,眨眼判定负责把单帧数值变成连续动作。三者拆开写,后面换检测器、调阈值、加报警都不会牵一发动全身。我一般先把项目按“采集层、检测层、判定层、展示层”四块看,再决定改哪一块,这样不容易把摄像头问题误判成算法问题。

2.1 目录结构与模块职责

常见做法是按职责拆文件,而不是把所有逻辑塞进一个main.py。下面这种结构在课程设计和原型项目里很常见,文件名可以不同,但分层思路基本一致:

eye_blink_demo/ ├── main.py # 入口:参数解析、摄像头循环、按键退出 ├── config.py # 阈值、分辨率、模型路径等集中配置 ├── detectors/ │ ├── face_detector.py # 人脸检测封装 │ └── eye_detector.py # 眼睛区域检测或关键点封装 ├── logic/ │ ├── ear.py # 眼睛纵横比计算 │ └── blink_state.py # 眨眼/闭眼状态机 ├── utils/ │ ├── drawing.py # 框、文字、FPS 绘制 │ └── camera.py # 摄像头或视频文件读取 ├── models/ │ ├── haarcascade_frontalface_default.xml │ └── haarcascade_eye.xml └── requirements.txt

这个结构的价值在于“检测”和“判定”解耦。face_detector.py只关心输入一帧灰度图、输出人脸框列表;eye_detector.py只关心输入人脸 ROI、输出眼睛框或关键点;blink_state.py只接收每帧的 EAR 数值,输出眨眼次数、闭眼时长和当前状态。后面想把 Haar 换成人脸关键点检测器,只需要改检测层,状态机不用动。config.py集中放阈值也很关键,实时项目调参频繁,阈值散落在循环里,改一次就要全局搜索,血泪经验是先把参数收口。

提示:如果源码是单文件版本,也建议先按这四层在脑子里画边界,二次开发时再拆文件,不要一上来大改。

2.2 技术选型:为什么不是所有方案都用深度学习

这个项目标题里明确是 Python+OpenCV,所以最稳的基线是 OpenCV 自带 Haar 级联:人脸一个模型,眼睛一个模型,依赖少,CPU 能跑,适合实时预览。它的短板也明显,侧脸、遮挡、强逆光下容易漏检,眼睛框会跳。常见增强做法是引入 68 点人脸关键点模型,用眼睛周围的 6 个点算 EAR,数值比矩形框更稳;再重一点的做法是轻量关键点网络,精度更好,但部署和依赖会增加。

方案依赖实时性稳定性适合场景
OpenCV Haar 人脸+眼睛低好正脸尚可,侧脸弱课程演示、快速原型
人脸关键点 + EAR中较好戴眼镜、小角度更稳疲劳监测原型
轻量深度学习检测较高看硬件复杂光照更好产品化验证

选型时不要只看精度,先看你的摄像头位置和用户会不会正脸。固定正脸、光照稳定的场景,Haar 足够跑通;用户会转头、戴眼镜、离摄像头忽远忽近,就要尽早考虑关键点方案。这个项目的教学价值在于 EAR 判定逻辑,检测器只是前端,换检测器时保持get_eye_region(frame)这类接口不变,后面就能平滑替换。

2.3 从采集到判定的数据流

实时人眼识别的主循环可以压缩成下面这段骨架。它不包含具体检测实现,但把每一帧的流转顺序定死了:取帧、转灰度、检测人脸、取眼睛 ROI、算 EAR、更新状态、绘制结果。

import cv2 cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) 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) # 轻度均衡,缓解背光 faces = detect_faces(gray) # 检测层:返回人脸框列表 for face in faces: eye_roi = crop_eye_region(gray, face) ear = compute_ear(eye_roi) # 判定层输入:单帧数值 state = blink_state.update(ear) # 状态机输出:眨眼/闭眼 draw_result(frame, face, state) cv2.imshow("eye blink demo", frame) if cv2.waitKey(1) & 0xFF == 27: # ESC 退出 break cap.release() cv2.destroyAllWindows()

参数上,CAP_PROP_FRAME_WIDTH和HEIGHT设成 640×480 是实时检测的常用起点,分辨率再高,Haar 检测耗时上升很快。equalizeHist不是必须,但在室内背光下能减少“脸亮眼睛黑”的翻车。cv2.flip(frame, 1)只影响预览,不影响检测数值,注意如果你后续要保存视频,保存镜像后的帧更符合用户看到的画面。waitKey(1)控制刷新,按 ESC 退出;如果你在循环里做重检测,这个值不要设太大,否则窗口会卡。

3. 核心模块拆解:人脸检测、眼睛 ROI 与眨眼状态机

这一章把核心代码拆开。人脸检测负责“找到脸”,眼睛 ROI 负责“缩小战场”,EAR 负责“把眼睛开合变成数字”,状态机负责“把数字变成动作”。四段里最容易出问题的是状态机,因为单帧 EAR 会抖,直接拿阈值比较会出现一次眨眼计数好几次的情况。我的做法是阈值、连续帧数、冷却时间三个参数一起看,不要只调一个。

3.1 人脸与眼睛检测:Haar 级联的初始化与参数

Haar 级联的初始化放在循环外,不要每帧加载 XML。下面这段是常见写法,先检测人脸,再在人脸框上半部分检测眼睛,减少误检。

import cv2 face_cascade = cv2.CascadeClassifier( "models/haarcascade_frontalface_default.xml" ) eye_cascade = cv2.CascadeClassifier( "models/haarcascade_eye.xml" ) def detect_faces(gray): faces = face_cascade.detectMultiScale( gray, scaleFactor=1.1, # 每次缩放比例,越小越慢但更容易检出 minNeighbors=5, # 邻域数量,越大越严格 minSize=(80, 80) # 太小的脸不检测,降低误检 ) return faces def detect_eyes(gray, face): x, y, w, h = face # 眼睛通常在上半脸,裁掉下半部分减少鼻孔嘴巴误检 upper = gray[y:y + h // 2, x:x + w] eyes = eye_cascade.detectMultiScale( upper, scaleFactor=1.1, minNeighbors=8, minSize=(20, 20) ) return eyes

scaleFactor=1.1是精度和速度的折中,调到 1.05 更细但更慢,调到 1.3 快但小脸容易丢。minNeighbors对人脸取 5 左右,对眼睛取 8 更严格,因为眼睛区域小、纹理复杂,宽松了会把眉毛、镜框当眼睛。minSize必须结合分辨率设,640×480 下 80×80 的脸已经比较远,再小就不可靠。detect_eyes里只取上半脸是实用技巧,能明显减少误检,但如果人脸框本身偏下,眼睛可能被裁掉,所以人脸框的稳定性比眼睛参数更重要。

3.2 眼睛关键点与 EAR 计算

如果资源里带关键点模型,EAR 用 6 个点算更稳。左眼取水平两点和上下四点,右眼同理,公式是上下距离之和除以水平距离的两倍。没有关键点模型时,也可以用眼睛矩形框的宽高比做近似,但抗遮挡差一些。

import numpy as np def eye_aspect_ratio(eye_points): # eye_points 顺序:左角、上左、上右、右角、下右、下左 p1, p2, p3, p4, p5, p6 = eye_points vertical = np.linalg.norm(p2 - p6) + np.linalg.norm(p3 - p5) horizontal = np.linalg.norm(p1 - p4) if horizontal == 0: return 0.0 return vertical / (2.0 * horizontal) def compute_ear_from_landmarks(landmarks, left_idx, right_idx): left = np.array([landmarks[i] for i in left_idx], dtype=np.float32) right = np.array([landmarks[i] for i in right_idx], dtype=np.float32) return (eye_aspect_ratio(left) + eye_aspect_ratio(right)) / 2.0

eye_aspect_ratio里horizontal == 0的判空不能省,关键点模型在极端侧脸时可能给出重合点,直接除会出inf,状态机就乱了。左右眼取平均能降低单眼遮挡的影响,但如果你要做单眼闭眼检测,就分别保留两个值。常见阈值在 0.2 到 0.3 之间,具体要看摄像头距离和关键点模型,不能照抄。矩形框近似版本可以写成h / w,但阈值完全不同,不要混用两套参数。

3.3 眨眼状态机:从单帧 EAR 到连续动作判定

状态机要解决三件事:低于阈值多久算闭眼,连续多少帧算稳定,一次闭眼后多久内不重复计数。下面这个类把“眨眼”定义成“闭眼后重新睁开”,闭眼时长用时间戳累计,不依赖帧率。

import time class BlinkState: def __init__(self, ear_threshold=0.22, close_frames=2, cooldown=0.25): self.ear_threshold = ear_threshold self.close_frames = close_frames self.cooldown = cooldown self.closed_count = 0 self.blink_count = 0 self.closed_start = None self.last_blink_time = 0.0 def update(self, ear): now = time.time() if ear < self.ear_threshold: self.closed_count += 1 if self.closed_start is None: self.closed_start = now else: if self.closed_count >= self.close_frames: if now - self.last_blink_time > self.cooldown: self.blink_count += 1 self.last_blink_time = now self.closed_count = 0 self.closed_start = None closed_duration = 0.0 if self.closed_start is not None: closed_duration = now - self.closed_start return { "ear": ear, "blink_count": self.blink_count, "closed_duration": closed_duration, "is_closed": self.closed_start is not None }

ear_threshold是核心阈值,close_frames=2表示连续两帧低于阈值才认可闭眼,能过滤单帧抖动。cooldown=0.25秒是冷却时间,防止一次眨眼在阈值边缘反复触发。closed_duration用time.time()算,不用帧数乘 FPS,因为摄像头实际帧率会掉,用帧数会把闭眼时间算短。返回字典里同时给blink_count和is_closed,展示层和报警层各取所需。注意last_blink_time初始为 0,第一帧不会误触发。

3.4 闭眼检测与实时叠加显示

展示层不只是画框,它决定了你调试时能不能一眼看出问题。EAR 数值、当前状态、闭眼时长、FPS 都叠在画面上,调阈值时不用猜。

import cv2 import time def draw_result(frame, face, state, fps): x, y, w, h = face color = (0, 0, 255) if state["is_closed"] else (0, 255, 0) cv2.rectangle(frame, (x, y), (x + w, y + h), color, 2) text1 = f"EAR: {state['ear']:.3f}" text2 = f"Blink: {state['blink_count']}" text3 = f"Closed: {state['closed_duration']:.2f}s" cv2.putText(frame, text1, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2) cv2.putText(frame, text2, (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2) cv2.putText(frame, text3, (10, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2) cv2.putText(frame, f"FPS: {fps:.1f}", (10, 120), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 0), 2)

color根据闭眼状态切换,调试时红绿变化比看数字快。FPS建议用滑动平均,不要每帧用1 / (now - last)直接打印,波动会很大。closed_duration保留两位小数足够,实时预览不需要更高精度。绘制文字位置固定在左上角,避免跟随人脸框跳动导致看不清。真正部署时可以把这些调试信息收进开关,但开发阶段不要省,黑匣子式调试会浪费大量时间。

4. 跑起来:环境安装、摄像头调试与命令行参数

代码看懂了,下一步是让它在你的机器上出画面。实时项目最容易卡在环境而不是算法,摄像头权限、OpenCV 版本、模型路径三件事先确认。下面按“装依赖、看入口、换输入、做自检”的顺序走,每一步都有可复现的命令和失败时该看的地方。

4.1 依赖安装与版本约束

建议用虚拟环境,不要和系统 Python 混装。核心依赖只有 OpenCV 和 NumPy,关键点方案再加 dlib 或对应模型运行时。

python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate pip install opencv-python numpy # 如果教程使用关键点模型,再装对应依赖 # pip install dlib

opencv-python装 4.x 即可,常见接口CascadeClassifier、VideoCapture、detectMultiScale都稳定。装完先跑python -c "import cv2; print(cv2.__version__)",能打印版本说明基础环境通了。dlib 在部分平台需要 CMake 和编译器,装不上时先别死磕,确认项目是否真的需要关键点;如果源码只用 Haar,跳过 dlib 也能跑。模型 XML 文件要放在代码能找到的相对路径下,不要假设它在 OpenCV 安装目录里,路径错的表现就是启动时报空级联。

4.2 主程序入口与参数说明

入口用argparse接参数,比在代码里改常量方便。下面这段覆盖摄像头索引、视频路径、阈值和显示开关,实际项目可以按需增删。

import argparse def parse_args(): parser = argparse.ArgumentParser() parser.add_argument("--source", default="0", help="摄像头索引或视频文件路径") parser.add_argument("--ear-threshold", type=float, default=0.22, help="EAR 低于该值视为闭眼") parser.add_argument("--close-frames", type=int, default=2, help="连续闭眼帧数,过滤单帧抖动") parser.add_argument("--width", type=int, default=640) parser.add_argument("--height", type=int, default=480) parser.add_argument("--no-show", action="store_true", help="不显示窗口,用于后台压测") return parser.parse_args()

--source默认"0"是本机默认摄像头,传视频路径就能离线跑。--ear-threshold和--close-frames暴露出来,调参不用改源码。--no-show在批量跑视频时有用,避免没有显示器环境报错。--width和--height只是请求值,摄像头不一定完全支持,读帧后要用frame.shape确认真实分辨率。参数名尽量和状态机字段对应,后面写日志时不容易混。

4.3 摄像头与视频文件两种输入

把输入统一成VideoCapture,摄像头和视频文件走同一条循环,减少分支。

import cv2 def open_source(source, width, height): if source.isdigit(): cap = cv2.VideoCapture(int(source)) cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) else: cap = cv2.VideoCapture(source) if not cap.isOpened(): raise RuntimeError(f"无法打开输入源: {source}") return cap cap = open_source("0", 640, 480) while True: ok, frame = cap.read() if not ok: break # 后续检测与绘制

source.isdigit()区分摄像头索引和文件路径,传"1"就试第二个摄像头。cap.isOpened()只说明打开成功,不代表每帧都能读到,所以循环里还要判ok。摄像头读帧失败常见原因是权限、被其他程序占用、索引不对;视频文件读失败常见原因是路径含中文或编码不支持,先换英文短路径试。cap.set的分辨率请求可能被驱动忽略,不要用请求值去算坐标,始终用实际frame.shape。

4.4 运行结果自检

跑起来后按下面几项自检,能快速定位是环境问题还是算法问题:

检查项正常表现异常时先看
窗口是否出画面实时彩色画面摄像头索引、权限、cap.read()
人脸框是否稳定正脸时框住整脸光照、minNeighbors、分辨率
眼睛框是否落在眼睛上左右眼附近小框是否只裁上半脸、minSize
EAR 数值是否随眨眼变化睁眼高、闭眼低关键点顺序、阈值范围
眨眼计数是否合理一次眨眼加一冷却时间、连续帧数

提示:先保证cap.read()连续返回 True,再调检测参数;读帧都不稳,后面所有阈值都是玄学。

如果画面正常但 EAR 一直不变,先打印眼睛 ROI 的尺寸,尺寸为 0 说明眼睛没检到;如果眨眼计数疯涨,先把close_frames调到 3 到 4,再把cooldown加到 0.3 秒看是否收敛。自检顺序不要反,先输入后算法,能少走很多弯路。

5. 避坑与排查:摄像头、光照、误检和性能的 5 个血泪经验

实时人眼识别项目翻车,很多时候不是 EAR 公式错,而是输入不稳、阈值太理想、状态机没防抖。下面 5 条是我在课程演示和原型里反复遇到的,按“现象、原因、解决”拆开写,你遇到类似表现可以直接对照。

5.1 现象:摄像头能打开但窗口全黑

现象:程序不报错,cap.isOpened()返回 True,但imshow出来的窗口全黑,或者等很久才出一帧。原因通常不是 OpenCV 坏了,而是读帧失败被忽略、摄像头被其他程序占用、索引指向了虚拟设备,或者驱动在低分辨率下才工作。解决时先把ok, frame = cap.read()的ok打出来,连续打印 10 帧,如果大量 False,就换索引 0、1、2 逐个试;再检查系统隐私设置里摄像头权限;最后把分辨率降到 320×240 试,有些采集设备在高分辨率下协商失败。循环里不要写frame为空还继续cvtColor,那会把问题变成异常,反而看不出是读帧失败。

5.2 现象:眨眼计数疯狂上涨

现象:人没怎么眨眼,blink_count每秒涨好几次。原因一般是 EAR 阈值设得太高,睁眼数值在阈值附近抖动;或者没有连续帧过滤,单帧低于阈值就计数;再或者一次闭眼后没有冷却时间,睁开瞬间反复触发。解决时先看画面上的 EAR 数值,记录睁眼和闭眼的大致范围,把阈值放到两者中间偏下;再把close_frames加到 2 或 3;最后加 0.25 秒冷却。如果眼睛框本身在跳,EAR 会跟着跳,这时要先修检测框,不要继续压阈值。状态机里“闭眼后重新睁开才算一次”的定义也要确认,别把每帧闭眼都当眨眼。

5.3 现象:侧脸或戴眼镜时眼睛框乱跳

现象:正脸时正常,一转头眼睛框跑到眉毛、镜框或脸颊上。原因是 Haar 眼睛分类器对正脸和纹理敏感,侧脸时人脸框偏移,眼睛 ROI 跟着偏;镜片反光也会破坏纹理。解决时先限制眼睛检测区域在人脸上半部分,并适当扩大人脸框再裁 ROI;侧脸场景不要硬调minNeighbors,越严格越容易丢,考虑换关键点方案;戴眼镜时避免强光直射镜片,必要时用关键点而不是矩形框。还有一个小坑:人脸框坐标是相对整帧的,眼睛框坐标是相对 ROI 的,绘制时别忘了加回偏移,否则框会画到左上角。

5.4 现象:帧率越跑越低

现象:刚开始 25 帧,跑几分钟掉到 10 帧以下,或者一开绘制就卡。原因常见是每帧都做全图多尺度检测、绘制文字过多、视频写入没控制、分辨率太高。解决时先把检测频率降下来,人脸检测每 2 到 3 帧做一次,中间帧复用上一帧框;再把分辨率降到 640×480 或 480×360;绘制只保留必要信息,FPS 用滑动平均;如果开了视频保存,确认写线程没有拖慢主循环。Haar 的scaleFactor调到 1.2 也能省时间,但小脸会丢,按场景取舍。性能问题不要靠猜,先打印每帧各阶段耗时,通常检测占大头。

5.5 现象:闭眼超过几秒才报警/不报警

现象:闭眼很久但closed_duration不涨,或者报警条件一直不触发。原因可能是状态机只在“眨眼完成”时更新计数,闭眼期间没累计时间;也可能用帧数乘 FPS 算时长,实际帧率掉了导致时间偏短;还可能是阈值太高,闭眼数值没低于阈值。解决时用time.time()记录闭眼开始时间,每帧算差值;把is_closed和closed_duration分开输出,先确认状态是否进入闭眼;报警阈值用秒不用帧。如果你要区分“正常眨眼”和“长时间闭眼”,在状态机里加一个long_close_threshold,超过就置报警标志,不要和眨眼计数混在一个变量里。

6. 二次开发:换检测器、加报警和阈值自适应的改造清单

这份源码最好的用法是当基线,不是当终点。换检测器时保留detect_faces和compute_ear两个接口,状态机不动;加报警时把closed_duration接到声音或日志,不要改判定核心;调阈值时先录一段视频离线回放,比对着摄像头盲调快得多。下面这张表是我加日志时常用的字段,直接对应状态机输出,后面做统计不用再回头改。

字段含义来源
timestamp当前帧时间time.time()
ear双眼平均 EARcompute_ear
is_closed是否闭眼状态机
closed_duration连续闭眼秒数状态机
blink_count累计眨眼次数状态机
fps当前帧率滑动平均

改造时先做小步验证:换检测器后只跑 100 帧,对比人脸框和 EAR 曲线;加报警后先用视频文件跑,确认不会每帧都响;阈值自适应不要一上来就上复杂算法,先按场景分两档,室内一档、室外一档,够用再迭代。从那以后我每次接摄像头项目,都强制先把采集、检测、判定三层拆开跑一遍,确认read不返回空、框不飘、EAR 有变化,再谈阈值和报警。希望帮到你。

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