简介:面向计算机视觉初学者与需要快速实现实时眼部状态检测的开发者,这套代码与教程围绕Python、OpenCV构建人眼定位、眨眼判断和闭眼识别流程,可在Ubuntu环境下运行,也可作为课程设计、课堂演示或视觉算法入门练手项目。压缩包共61个文件,约75.34MB,以37个py源码为核心,配合png/jpg示例图、dat面部关键点模型、cfg配置、pdf图文教程及md说明等,覆盖从环境依赖、模型加载到实时视频处理的完整结构,目录组织清晰,便于按模块查阅与实践。已有4361人学习下载,说明其作为实践案例具有较高参考度。读者可据此理解Haar级联检测、连续帧眼部变化分析与闭眼状态判定思路,并借助现成脚本和教程快速跑通Demo,定位常见配置与运行问题,为进一步扩展疲劳检测、人机交互等应用打基础。
1. 从一次误报说起:为什么“找到眼睛”不等于“检测到眨眼”
Python 人眼识别的开源代码一搜一大把,但把眨眼检测和闭眼检测叠上去之后还能稳定跑的没几个。前段时间有人拿着一份这样的工程来问:眼睛框画得挺准,眨眼计数一分钟能跳八十多次。翻到判定那段就明白了——它写的是“这一帧有眼睛、下一帧没眼睛,就算眨了一次”。检测器回答的只是“眼睛在哪”,而眨眼是持续 100~400 毫秒的时序事件,拿帧级的存在性去描述一个时间过程,误检必然失控。
这份资源补的正是后半段:用 68 个关键点算出 EAR(Eye Aspect Ratio,眼纵横比),再用双阈值状态机把连续帧串成“睁开—闭合—睁开”,并把闭合超过一定时长的片段单独记成闭眼事件,供疲劳判定使用。它适合已经能让摄像头出画面、想要一套可交付的计数与告警逻辑的人;如果你连人脸框都还没定位过,第 2 章先把三条定位路线摆开对比,照着选一条再往下走。
2. 三条人眼定位路线的选型:Haar、68 点与网格的取舍
动手前先把这个选择做掉,后面 80% 的返工都源于一开始选错了定位方式。人眼识别这一层有三种常见做法,它们输出的东西根本不是一个量级:一种只给你一个矩形框,一种给你 6 个坐标点,一种给你上百个网格顶点。你后面能不能算 EAR、能不能做闭眼时长统计,完全取决于这一步拿到了什么。
2.1 三种方案的输出形态与适用边界
先把差异摆在表里,这张表是我自己选型时会对照的版本,速度是 640×480、普通笔记本 CPU 单线程的大致量级,不同机器会有浮动,看数量级就够。
| 方案 | 输出 | 单帧耗时量级 | 依赖 | 典型用途 |
|---|---|---|---|---|
| Haar 级联分类器 | 人眼矩形框 | 3~8 ms | OpenCV 自带 xml | 快速验证、嵌入式降级方案 |
| 68 点关键点回归 | 每只眼 6 个坐标 | 15~35 ms | 额外约 100 MB 模型文件 | 桌面端眨眼/闭眼检测主力 |
| 人脸网格(468 点) | 稠密顶点 + 眼部环 | 5~15 ms | 独立推理框架 | 需要头部姿态、移动端 |
关键差别在于:矩形框不能用来算 EAR。有人试过拿人眼框的高度变化当眼睑开合,结果框的高度受分类器窗口尺度、人脸检测框抖动、眼镜反光三重影响,噪声比信号还大。框方案只能回答“这一帧有没有检出眼睛”,这恰恰是第 1 章那个翻车案例的根源。要做眨眼和闭眼,必须有眼睑上下缘的坐标,也就是后两种方案。
那为什么还留着 Haar?因为它零模型文件、装上 OpenCV 就能跑,适合在正式接关键点之前先把“摄像头读取—人脸定位—ROI 裁剪—显示”这条链路跑通,确认硬件和分辨率没问题。我一般会拿它当冒烟测试,跑通了再换 68 点。
2.2 Haar 级联:十分钟跑通链路,但别拿它做判定
先跑通这条最简链路。注意两个细节:人脸框裁到只剩上半部分再去找眼睛,以及直方图均衡化。
import cv2 # OpenCV 自带的级联文件,cv2.data.haarcascades 指向安装目录下的 xml 文件夹 face_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml") # 戴眼镜场景优先用 eye_tree_eyeglasses,它对镜框的容忍度略高 eye_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_eye_tree_eyeglasses.xml") 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 = face_cascade.detectMultiScale( gray, scaleFactor=1.15, minNeighbors=5, minSize=(120, 120)) for (x, y, w, h) in faces: cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2) # 人眼在脸上半部分,裁掉下半张脸能砍掉一大半误检 roi_gray = gray[y:y + int(h * 0.55), x:x + w] roi_color = frame[y:y + int(h * 0.55), x:x + w] eyes = eye_cascade.detectMultiScale( roi_gray, scaleFactor=1.1, minNeighbors=8, minSize=(20, 20)) for (ex, ey, ew, eh) in eyes: cv2.rectangle(roi_color, (ex, ey), (ex + ew, ey + eh), (255, 0, 0), 2) cv2.imshow("eye", frame) if cv2.waitKey(1) & 0xFF == 27: # ESC 退出 break cap.release() cv2.destroyAllWindows()三个参数直接决定误检率,值得单独说清楚。scaleFactor是每次图像金字塔缩小的比例,1.05 找得细但慢,1.15~1.2 是实时场景的常用区间;调大到 1.3 会开始漏小目标。minNeighbors是候选框需要被多少个邻居确认才保留,人脸检测给 5 左右,眼睛检测因为目标小、纹理杂,通常要提到 8~10,否则眉毛、鼻孔、镜框都会被抓成眼睛。minSize是最小检测尺寸,按画面里人脸的像素宽度反推——640 宽的画面里人脸一般 150~250 px,眼睛 30~60 px,给(20, 20)是留了余量。
跑起来你会发现两个现象:一是眉毛被框住,二是人一转头眼睛就丢了。这两个都是 Haar 的固有特性,靠调参解决不了,只能说“够用就行”。所以我从不在这条链路上做眨眼判定,只把它当链路验证。
2.3 68 点关键点:把眼睛变成 6 个可计算坐标
换成关键点回归之后,每只眼睛拿到 6 个点,顺序固定:外眼角、上眼睑外侧、上眼睑内侧、内眼角、下眼睑内侧、下眼睑外侧。这个顺序是后面算 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") LEFT_EYE_IDX = list(range(36, 42)) # 图像左侧那只眼(受镜像影响,别纠结左右) RIGHT_EYE_IDX = list(range(42, 48)) def shape_to_np(shape): """把 dlib 的 shape 对象转成 (68, 2) 的 int 数组,后面切片更方便""" return np.array([[p.x, p.y] for p in shape.parts()], dtype=np.int32) def get_eye_points(pts68, idx): """按索引取 6 个眼部坐标,返回 float64 便于做范数运算""" return pts68[idx].astype(np.float64) 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) # 第二个参数 0 表示不做上采样;1 会慢一倍左右,小脸场景才需要 faces = detector(gray, 0) for rect in faces: shape = predictor(gray, rect) pts68 = shape_to_np(shape) for idx in (LEFT_EYE_IDX, RIGHT_EYE_IDX): for (px, py) in pts68[idx]: cv2.circle(frame, (px, py), 2, (0, 255, 0), -1) cv2.imshow("landmarks", frame) if cv2.waitKey(1) & 0xFF == 27: break cap.release() cv2.destroyAllWindows()detector(gray, 0)的第二个参数是上采样次数,0 表示原图检测,1 表示先放大一倍再检,小脸和远距离场景检出率会明显提升,代价是耗时翻倍。工程里常用的折中是:正常帧用 0,连续 N 帧没检到人脸时切一次 1 做补救。
到这一步,你已经拿到了可以算 EAR 的全部输入,接下来才是这份资源真正的核心。
3. EAR 与状态机:把眨眼从帧级判断变成时间序列判断
拿到 6 个点之后,很多人第一反应是“算眼睛框的高度不就行了”,然后又掉回第 2 章那个坑里。正确做法是用上下眼睑两点间距与左右眼角间距的比值,这个比值对尺度不敏感——人离摄像头远近变化时,分子分母同时缩放,EAR 基本不变,这是它能跨距离使用的原因。
3.1 EAR 的几何含义与公式
对单只眼睛的 6 个点 P1~P6,EAR 定义为:
EAR = (|P2 - P6| + |P3 - P5|) / (2 * |P1 - P4|)分子是上下眼睑两处垂直距离之和,分母是眼角水平距离的两倍。取两倍是为了让“完全睁开”时的值落在 0.3 附近,读数直观。眼睛闭合时分子趋近于 0,EAR 掉到 0.1 上下;正常睁开在 0.25~0.35 之间,个体差异不小,这也解释了为什么阈值不能写死。
def eye_aspect_ratio(eye_pts): """ eye_pts: (6, 2) 的 float 数组,顺序为 外眼角,上外,上内,内眼角,下内,下外 返回单眼 EAR;分母做保护,避免侧脸时两点重合导致除零 """ v1 = np.linalg.norm(eye_pts[1] - eye_pts[5]) v2 = np.linalg.norm(eye_pts[2] - eye_pts[4]) h = np.linalg.norm(eye_pts[0] - eye_pts[3]) if h < 1e-6: return 0.0 return (v1 + v2) / (2.0 * h) def both_eyes_ear(pts68): """双眼取平均,比单眼稳;一只眼被头发遮住时可以直接退化到另一只""" left = eye_aspect_ratio(get_eye_points(pts68, LEFT_EYE_IDX)) right = eye_aspect_ratio(get_eye_points(pts68, RIGHT_EYE_IDX)) return (left + right) / 2.0, left, right双眼取平均是有意的:闭眼往往不是完全同步,单眼算容易出现一只眼已经闭合、另一只眼还半睁的中间态,平均之后曲线更平滑。但两只眼的原始值一定要保留,后面排查“为什么这个人一直判成闭眼”时,把左右眼分开画出来,经常能发现是一侧被遮挡或关键点漂移。
还有一个容易被忽略的点:EAR 曲线本身抖动很大,尤其是戴眼镜、光照不均、脸部有小幅移动的时候。原始值直接送进判定,会出现大量跨越阈值的毛刺。常见做法是先做 3~5 帧的中值滤波或指数滑动平均再判定,代价是引入几帧延迟。我用得多的是中值滤波,它对“突然跳一下”的毛刺抑制最好,又不像均值那样把真实的快速闭眼抹平。
from collections import deque class EarSmoother: """滑动中值滤波,窗口给奇数;窗口越大越稳,但延迟越高""" def __init__(self, window=5): self.buf = deque(maxlen=window) def update(self, ear): self.buf.append(ear) return float(np.median(self.buf))窗口取 5、帧率 30 时,引入的延迟约 2~3 帧,也就是 70~100 毫秒,对眨眼这种 200 毫秒量级的事件是可接受的。窗口拉到 9 以上,短的眨眼会被直接抹掉,表现出来就是“明明眨了但没计数”。
3.2 逐人标定:阈值不该写死在代码里
公开示例里 EAR 阈值常写 0.2 或 0.25,直接抄过来在自己机器上大概率偏。原因很实在:摄像头高度、俯仰角、眼镜度数、眼型都影响这个值。同一个阈值,A 同学睁眼基线 0.31 用着挺好,换 B 同学基线只有 0.24,一低头就判成闭眼。
我一般会让程序在启动后先跑 2~3 秒采集基线:这段时间提示用户自然睁眼平视,收集 EAR 样本,取中位数作为ear_open_base,然后按比例推出两个阈值。
def calibrate(samples, open_ratio=0.85, close_ratio=0.60): """ samples: 标定阶段收集到的 EAR 列表(建议 >= 60 帧) open_ratio / close_ratio: 相对于睁眼中位数的比例系数 """ samples = [s for s in samples if s > 0.05] # 剔除明显异常帧 if len(samples) < 30: return None # 样本不足,让调用方用默认值 base = float(np.median(samples)) return { "base": base, "ear_open": base * open_ratio, # 回升到这以上才算真正睁开 "ear_close": base * close_ratio, # 跌破这以下才算闭合 }open_ratio和close_ratio之间的这段间隔就是滞回带,宽度大约 0.25×base。带子太窄,EAR 在阈值附近抖一下就来回切状态,眨眼计数会翻倍;带子太宽,半睁眼的中间态被吞掉,闭眼时长统计会偏短。0.85 和 0.60 这组数在我经手的几台设备上都还算稳,但换设备一定要重新看曲线。
标定阶段还有个细节:如果用户戴眼镜且镜片反光严重,基线会被拉低,导致后面闭眼阈值过低、永远判不出闭眼。这种情况我会在标定时同时输出左右眼基线,一侧明显低于另一侧就提示调整角度或光源,而不是硬着头皮往下跑。
3.3 双阈值滞回 + 连续帧去抖的状态机
有了平滑后的 EAR 和两个阈值,剩下的是状态迁移。判定不能写成if ear < th: closed = True,而要维护一个显式状态机,并且两个方向都要求“连续 N 帧”才认账。
class BlinkFSM: """ 状态机:OPEN <-> CLOSED - EAR 连续 close_n 帧低于 ear_close -> 进入 CLOSED - EAR 连续 open_n 帧高于 ear_open -> 回到 OPEN * 若本次闭合帧数在 [close_n, blink_max_frames] 内,记一次眨眼 """ def __init__(self, ear_close, ear_open, fps=30, close_n=2, open_n=2, blink_max_ms=500): self.ear_close = ear_close self.ear_open = ear_open self.close_n = close_n self.open_n = open_n self.blink_max_frames = int(blink_max_ms / 1000.0 * fps) self.state = "OPEN" self.low_cnt = 0 self.high_cnt = 0 self.closed_frames = 0 self.blink_count = 0 def update(self, ear, t_ms): """返回 (state, event),event 取值 None / 'blink' / 'close_start' / 'close_end'""" event = None if self.state == "OPEN": if ear < self.ear_close: self.low_cnt += 1 if self.low_cnt >= self.close_n: self.state = "CLOSED" self.closed_frames = 0 event = "close_start" else: self.low_cnt = 0 else: self.closed_frames += 1 if ear > self.ear_open: self.high_cnt += 1 if self.high_cnt >= self.open_n: # 闭合时长落在合理区间才算眨眼,过长的是闭眼事件 if self.closed_frames <= self.blink_max_frames: self.blink_count += 1 event = "blink" else: event = "close_end" self.state = "OPEN" self.low_cnt = 0 self.high_cnt = 0 else: self.high_cnt = 0 return self.state, event三个参数的作用各不相同,改之前要想清楚代价。close_n和open_n是去抖帧数,各给 2,意味着至少 2 帧持续同向才认;给 1 会明显更灵敏,但误计数上升;给 3 以上短促的眨眼会被吃掉。blink_max_ms是眨眼与闭眼的分界线,正常眨眼 100~400 毫秒,我给 500 毫秒留余量,超过这个时长的闭合不进眨眼计数,而是走闭眼事件通道——这条分界就是第 4 章疲劳判定的入口。
时间戳t_ms一定要从外部传进来,用time.perf_counter()或cv2.getTickCount() / cv2.getTickFrequency() * 1000,别用帧序号乘 1/30。摄像头实际帧率经常在 24~30 之间浮动,甚至会因为 USB 带宽不够掉到 15,用固定帧率换算时长,误差能到 30% 以上,最后表现为“闭眼告警时快时慢”。
4. 闭眼与疲劳:从单帧 EAR 到 PERCLOS 滑窗统计
眨眼计数只是副产品,这份资源真正有工程价值的是闭眼检测这一路。眨眼和闭眼在单帧上长得一模一样——都是 EAR 掉下去,唯一的区别是持续多久。所以判定不能停在帧级,必须落到时长和占比上。
4.1 用持续时间给闭眼分档
按持续时长分成三档,这套分档是我在几个实际场景里反复调过的,可以直接当起点:
| 档位 | 持续时长 | 语义 | 处理建议 |
|---|---|---|---|
| 眨眼 | 100~500 ms | 正常生理眨眼 | 只计数,不告警 |
| 短闭眼 | 0.5~1.5 s | 走神、低头、看别处 | 记事件,累计频次 |
| 长闭眼 | > 1.5 s | 疑似打瞌睡 | 立即触发告警 |
档位之间的边界不要照抄,尤其 1.5 秒这一条,不同场景差别很大:盯屏幕的办公场景可以收到 1.2 秒,驾驶类场景通常要更激进。改的时候只改配置,别把数字散落在代码里,后面调参会很痛苦。
class CloseEyeTracker: """按持续时间分档统计闭眼事件,与 BlinkFSM 串联使用""" def __init__(self, fps=30, short_ms=500, long_ms=1500): self.fps = fps self.short_frames = int(short_ms / 1000.0 * fps) self.long_frames = int(long_ms / 1000.0 * fps) self.closed_frames = 0 self.short_events = 0 self.long_events = 0 self.fired_long = False def on_closed(self): """每帧处于 CLOSED 状态时调用""" self.closed_frames += 1 if self.closed_frames >= self.long_frames and not self.fired_long: self.long_events += 1 self.fired_long = True # 同一次闭合只告警一次 return "long_close" return None def on_open(self): """每帧处于 OPEN 状态时调用,收尾并复位""" if self.short_frames <= self.closed_frames < self.long_frames: self.short_events += 1 self.closed_frames = 0 self.fired_long = False return Nonefired_long这个标志位是必须的。少了它,一次 3 秒的闭眼会在第 1.5 秒之后的每一帧都触发告警,告警风暴比不告警更糟。同理,closed_frames必须在回到 OPEN 时清零,否则跨事件的帧数会累加,第二次闭眼会瞬间判成长闭眼。
4.2 PERCLOS:60 秒滑窗里的闭眼占比
单次闭眼时长说明不了问题,真正能反映状态的是单位时间内的闭眼占比,也就是 PERCLOS(Percentage of Eye Closure)。做法很朴素:维护一个固定长度的时间窗,每一帧记录“闭”或“睁”,算窗口内的闭合比例。窗口长度常用 60 秒,短窗口反应快但抖动大,长窗口平稳但迟钝。
from collections import deque class PERCLOS: """滑动窗口闭眼占比;按帧存入,窗口长度由 fps 和秒数决定""" def __init__(self, window_sec=60, fps=30, threshold=0.15): self.buf = deque(maxlen=int(window_sec * fps)) self.threshold = threshold def push(self, is_closed: bool): self.buf.append(1 if is_closed else 0) @property def value(self): if not self.buf: return 0.0 return sum(self.buf) / len(self.buf) @property def is_fatigued(self): # 窗口没填满时不下结论,避免刚启动就误报 if len(self.buf) < self.buf.maxlen * 0.8: return False return self.value > self.thresholdthreshold给 0.15 是个常用起点,含义是 60 秒里累计闭眼超过 9 秒就认为偏疲劳。这个值和高低眼型、是否戴眼镜、任务类型都有关,别当真理用。我给的建议是:先跑一段正常状态采集基线,看正常人的 PERCLOS 落在什么区间(通常 0.03~0.10),再把阈值定在基线上方 50% 左右的位置。窗口没填满时不给结论这条也很重要,len(self.buf) < maxlen * 0.8的判断能挡掉启动瞬间的误报。
4.3 把结果组织成可消费的结构
工程里最容易被忽略的是输出结构。计数、状态、时长散在几个变量里,上层想接个告警、存个日志、画个曲线都要重新拼,最后代码会烂成一团。我习惯每帧产出一个扁平字典,一次算完,谁需要谁取。
import time def build_result(frame_idx, ear, state, fsm, tracker, perclos): return { "frame": frame_idx, "t_ms": round(time.perf_counter() * 1000, 1), "ear": round(ear, 4), "state": state, # OPEN / CLOSED "blink_total": fsm.blink_count, "short_close_total": tracker.short_events, "long_close_total": tracker.long_events, "closed_frames": tracker.closed_frames, "perclos": round(perclos.value, 4), "fatigued": perclos.is_fatigued, }有了这个字典,写 CSV 日志、推给前端、做阈值回测都是几行的事。我吃过亏的一次是早期把结果直接画在视频帧上,等要分析准确率时才发现历史数据一条没留,只能重录重跑。从那以后不管多简单的视觉脚本,我都会先开一路 CSV 落盘。
5. 避坑实录:五个把眨眼准确率打回 60% 的细节
下面这五条都是我自己踩过或者帮人排查过的,按“现象 → 原因 → 解决”记,遇到同款现象可以直接对号。
5.1 眨眼计数翻倍,一次眨眼算成两三次
现象:被测试者 30 秒眨 15 次,程序计出 40 多次,视频回放看帧上的状态标记,确实在闭合和睁开之间反复横跳。
原因:EAR 曲线在阈值附近有毛刺,判定用的是单阈值即时比较,值一上一下就算两次事件。再加上没有连续帧确认,单帧噪声直接被当成一次完整眨眼。
解决:双阈值滞回 + 双向连续帧确认,就是 3.3 那套ear_open/ear_close分开的状态机,配上 3.1 的中值滤波。改完同一个人的计数从 40 多次回到 16 次,误差在正常生理波动范围内。
5.2 戴眼镜的人一直显示闭眼
现象:不戴眼镜正常,戴上眼镜 EAR 整体掉到 0.18 以下,状态长期停在 CLOSED。
原因:镜片反光和镜框遮挡让下眼睑关键点向上漂移,分子被压缩,EAR 系统性偏低。这不是噪声,是偏移,滤波没用。
解决:两步。第一,标定阶段按 3.2 的方法逐人采集基线,戴眼镜的人基线本身就低,用固定阈值必然翻车;第二,标定后校验左右眼基线差异,差异超过 20% 就提示调整光源角度,避免单侧反光。换用haarcascade_eye_tree_eyeglasses只能缓解 Haar 的漏检,对关键点漂移无效,别指望它。
5.3 闭眼时长统计忽长忽短,同一次闭眼误差上百毫秒
现象:同一段录像跑两遍,闭眼时长能差出 300 毫秒;换成另一台电脑差异更大。
原因:用帧数除以固定 30 帧换算时长,实际帧率受 USB 带宽、曝光时间、后台进程影响,可能只有 22~26 帧,还在波动。
解决:所有时长计算改用真实时间戳,time.perf_counter()或cv2.getTickCount()都行。与此配套,blink_max_frames这类按帧配置的参数要改成按毫秒配置,运行时再换算。改完之后同段录像两次结果差在 2 帧以内。
5.4 侧脸和低头就丢眼睛,检测框消失
现象:正脸很稳,头一转 30 度以上直接检不到人脸,计数中断,PERCLOS 窗口被“睁眼”污染。
原因:HOG 人脸检测器对正脸友好,大角度侧脸和低头召回率下降明显。这是模型能力边界,不是参数问题。
解决:三条路按成本排。低成本的是加人脸跟踪(如 CSRT 或简单的 IOU 匹配),检测器掉帧时靠跟踪框续上关键点;中等成本是检测器上采样参数从 0 切到 1 做补救;较高成本是换更鲁棒的人脸网格方案。另外,丢检期间的状态要标记为UNKNOWN而不是默认“睁眼”,否则疲劳统计会被系统性拉低。
5.5 帧越积越多,画面延迟越来越大
现象:程序跑几分钟后画面明显滞后,30 秒前的动作现在才反应,CPU 占用还一路走高。
原因:读帧在主线程里和推理串行,cap.read()不断往内部缓冲塞帧,处理不过来就排队。cv2.waitKey(1)给的间隔也不足。
解决:开一个独立线程专读cap.read(),只保留最新一帧(用长度为 1 的队列覆盖写),主线程永远处理最新帧。这个改动通常能把端到端延迟从数百毫秒压到 100 毫秒以内。细节在 6 章展开。
6. 提速与验收:把帧率跑满并验证准确率的做法
调参调到后面一定会遇到“准了但慢”或者“快了但不准”的两难,我的做法是先保住准确率,再用工程手段把速度抢回来,顺序反过来会陷入无休止的阈值返工。
提速有四刀,按收益排序。第一刀是降采样:人脸检测在 0.5 倍缩放的灰度图上做,关键点回归在原图对应的 ROI 上做,检测耗时直接降到四分之一,精度基本无损。第二刀是跳帧:人脸检测每 N 帧做一次(N 取 5~10),中间帧用上一次的人脸框加光流或 IOU 跟踪续上,关键点每帧都算——贵的操作降频,便宜的操作保持频率。第三刀是 ROI 复用:只在人脸框周围留 1.3 倍边距的区域做关键点回归,别整帧送进去。第四刀是读帧线程分离。
import threading, queue class FrameGrabber: """独立线程读帧,队列长度 1,永远只保留最新帧,避免延迟累积""" def __init__(self, src=0, width=640, height=480): self.cap = cv2.VideoCapture(src) self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) self.q = queue.Queue(maxsize=1) self.stopped = False def start(self): threading.Thread(target=self._run, daemon=True).start() return self def _run(self): while not self.stopped: ok, frame = self.cap.read() if not ok: self.stopped = True break if self.q.full(): self.q.get() # 丢掉旧帧,只留最新的 self.q.put(frame) def read(self): return self.q.get() def stop(self): self.stopped = True self.cap.release()maxsize=1是这段代码的全部要点。队列给大了,处理慢的时候旧帧堆积,表现出来就是画面越来越滞后;给 1 则永远丢掉积压,只处理最新一帧,代价是帧率不足时会丢帧——对实时检测来说,丢帧远好过延迟。
验收这一步,比调参重要得多。我的固定流程是:先录一段 60 秒的测试视频(包含正常眨眼、刻意长闭眼、戴眼镜、侧头、暗光五段),然后把程序跑在录像上而不是摄像头上,这样每次改参数都是同一份输入,结果可比。输出 CSV 后人工标注真值,统计三个数:眨眼计数的绝对误差、闭眼事件的漏检率、误检率。别只看准确率,样本里阴性帧占绝大多数,准确率 95% 也可能意味着漏掉了大半闭眼事件,看召回和误报次数更实在。
关键参数最后汇总一张表,换设备时照着重新标一遍:
| 参数 | 建议起点 | 调整方向 |
|---|---|---|
ear_close | 基线 × 0.60 | 漏检多则上调,误检多则下调 |
ear_open | 基线 × 0.85 | 与 close 保持 0.2×基线 以上间隔 |
| 滤波窗口 | 5 帧 | 抖动大加到 7,短眨眼漏检则降到 3 |
close_n/open_n | 2 / 2 | 计数翻倍就加,反应迟钝就减 |
blink_max_ms | 500 ms | 按场景收紧到 400 或放宽到 600 |
| PERCLOS 窗口 | 60 s | 实时性要求高降到 30 s |
| PERCLOS 阈值 | 0.15 | 用正常状态基线 × 1.5 反推 |
最后说个习惯。早些年我调这类程序,喜欢一边看摄像头一边改阈值,改到“看着差不多”就收工,结果换个光照、换个人全部推倒重来,血泪经验。现在我每接一个视觉类的活,都强制先把录像回放、逐帧标注、CSV 落盘这三件套跑一遍,再谈调参——因为只有可复现的输入,参数才是有意义的数字,否则只是在拟合当时那盏灯。希望帮到你。
本文还有配套的精品资源,点击获取