基于EAR与双阈值状态机的眨眼闭眼检测及PERCLOS疲劳判定

发布时间:2026/10/28 20:19:14

基于EAR与双阈值状态机的眨眼闭眼检测及PERCLOS疲劳判定 简介面向计算机视觉初学者与需要快速实现实时眼部状态检测的开发者这套代码与教程围绕Python、OpenCV构建人眼定位、眨眼判断和闭眼识别流程可在Ubuntu环境下运行也可作为课程设计、课堂演示或视觉算法入门练手项目。压缩包共61个文件约75.34MB以37个py源码为核心配合png/jpg示例图、dat面部关键点模型、cfg配置、pdf图文教程及md说明等覆盖从环境依赖、模型加载到实时视频处理的完整结构目录组织清晰便于按模块查阅与实践。已有4361人学习下载说明其作为实践案例具有较高参考度。读者可据此理解Haar级联检测、连续帧眼部变化分析与闭眼状态判定思路并借助现成脚本和教程快速跑通Demo定位常见配置与运行问题为进一步扩展疲劳检测、人机交互等应用打基础。1. 从一次误报说起为什么“找到眼睛”不等于“检测到眨眼”Python 人眼识别的开源代码一搜一大把但把眨眼检测和闭眼检测叠上去之后还能稳定跑的没几个。前段时间有人拿着一份这样的工程来问眼睛框画得挺准眨眼计数一分钟能跳八十多次。翻到判定那段就明白了——它写的是“这一帧有眼睛、下一帧没眼睛就算眨了一次”。检测器回答的只是“眼睛在哪”而眨眼是持续 100~400 毫秒的时序事件拿帧级的存在性去描述一个时间过程误检必然失控。这份资源补的正是后半段用 68 个关键点算出 EAREye Aspect Ratio眼纵横比再用双阈值状态机把连续帧串成“睁开—闭合—睁开”并把闭合超过一定时长的片段单独记成闭眼事件供疲劳判定使用。它适合已经能让摄像头出画面、想要一套可交付的计数与告警逻辑的人如果你连人脸框都还没定位过第 2 章先把三条定位路线摆开对比照着选一条再往下走。2. 三条人眼定位路线的选型Haar、68 点与网格的取舍动手前先把这个选择做掉后面 80% 的返工都源于一开始选错了定位方式。人眼识别这一层有三种常见做法它们输出的东西根本不是一个量级一种只给你一个矩形框一种给你 6 个坐标点一种给你上百个网格顶点。你后面能不能算 EAR、能不能做闭眼时长统计完全取决于这一步拿到了什么。2.1 三种方案的输出形态与适用边界先把差异摆在表里这张表是我自己选型时会对照的版本速度是 640×480、普通笔记本 CPU 单线程的大致量级不同机器会有浮动看数量级就够。方案输出单帧耗时量级依赖典型用途Haar 级联分类器人眼矩形框3~8 msOpenCV 自带 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, scaleFactor1.15, minNeighbors5, 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, scaleFactor1.1, minNeighbors8, 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()], dtypenp.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~P6EAR 定义为EAR (|P2 - P6| |P3 - P5|) / (2 * |P1 - P4|)分子是上下眼睑两处垂直距离之和分母是眼角水平距离的两倍。取两倍是为了让“完全睁开”时的值落在 0.3 附近读数直观。眼睛闭合时分子趋近于 0EAR 掉到 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, window5): self.buf deque(maxlenwindow) 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_ratio0.85, close_ratio0.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, fps30, close_n2, open_n2, blink_max_ms500): 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, fps30, short_ms500, long_ms1500): 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 PERCLOS60 秒滑窗里的闭眼占比单次闭眼时长说明不了问题真正能反映状态的是单位时间内的闭眼占比也就是 PERCLOSPercentage of Eye Closure。做法很朴素维护一个固定长度的时间窗每一帧记录“闭”或“睁”算窗口内的闭合比例。窗口长度常用 60 秒短窗口反应快但抖动大长窗口平稳但迟钝。from collections import deque class PERCLOS: 滑动窗口闭眼占比按帧存入窗口长度由 fps 和秒数决定 def __init__(self, window_sec60, fps30, threshold0.15): self.buf deque(maxlenint(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, src0, width640, height480): 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(maxsize1) self.stopped False def start(self): threading.Thread(targetself._run, daemonTrue).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()maxsize1是这段代码的全部要点。队列给大了处理慢的时候旧帧堆积表现出来就是画面越来越滞后给 1 则永远丢掉积压只处理最新一帧代价是帧率不足时会丢帧——对实时检测来说丢帧远好过延迟。验收这一步比调参重要得多。我的固定流程是先录一段 60 秒的测试视频包含正常眨眼、刻意长闭眼、戴眼镜、侧头、暗光五段然后把程序跑在录像上而不是摄像头上这样每次改参数都是同一份输入结果可比。输出 CSV 后人工标注真值统计三个数眨眼计数的绝对误差、闭眼事件的漏检率、误检率。别只看准确率样本里阴性帧占绝大多数准确率 95% 也可能意味着漏掉了大半闭眼事件看召回和误报次数更实在。关键参数最后汇总一张表换设备时照着重新标一遍参数建议起点调整方向ear_close基线 × 0.60漏检多则上调误检多则下调ear_open基线 × 0.85与 close 保持 0.2×基线 以上间隔滤波窗口5 帧抖动大加到 7短眨眼漏检则降到 3close_n/open_n2 / 2计数翻倍就加反应迟钝就减blink_max_ms500 ms按场景收紧到 400 或放宽到 600PERCLOS 窗口60 s实时性要求高降到 30 sPERCLOS 阈值0.15用正常状态基线 × 1.5 反推最后说个习惯。早些年我调这类程序喜欢一边看摄像头一边改阈值改到“看着差不多”就收工结果换个光照、换个人全部推倒重来血泪经验。现在我每接一个视觉类的活都强制先把录像回放、逐帧标注、CSV 落盘这三件套跑一遍再谈调参——因为只有可复现的输入参数才是有意义的数字否则只是在拟合当时那盏灯。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/28 20:19:14

Linux Docker离线安装全链路:依赖打包、镜像搬运与故障排查

内网机房交付这种活,最容易出现的一种情况是:机器已经上架、电源和网络指示灯都亮着,但整个网段没有外网出口,交付清单上却写着“安装容器运行时”。很多人第一反应是“下个安装包传上去双击”,结果传上去发现跑不起来…

2026/10/28 20:19:14

Redis持久化机制与策略选择:RDB、AOF、混合持久化配置指南

线上 Redis 实例要配持久化,很多人的做法是照着默认redis.conf抄一遍,或者干脆把save全注释掉只留 AOF。真出事那天才发现,Redis 持久化机制这套东西从来不是“开不开”的问题,而是“你愿意为哪一种失败场景付代价”的问题。RDB、…

2026/10/28 21:46:25

数字孪生与AI行为分析在监所智能化改造中的应用

1. 项目背景与核心价值这个项目本质上是在解决监所管理领域的一个根本性痛点——如何从传统的被动监控升级为主动的空间治理。过去十年我参与过多个监所智能化改造项目,最深的体会就是:单纯增加摄像头数量只会产生更多无效数据,而真正需要的是…

2026/10/28 21:46:25

AI项目联合开发协议核心条款避坑指南

1. 这份协议不是“签完就扔”的纸,而是项目生死线上的第一道保险栓我见过太多团队——三五个人凑在一起做产品,技术方案聊得热火朝天,UI稿改了七版,原型跑通了三轮,结果在“谁出钱、谁署名、代码归谁、卖了钱怎么分”这…

2026/10/28 21:46:25

智能客服系统如何通过动态知识图谱实现自我进化

1. 项目概述:智能客服系统的自我进化革命在客服行业摸爬滚打八年,我见过太多企业陷入"知识库维护陷阱"——每次业务变动都需要人工重新整理FAQ,新员工培训周期长达数月,客户咨询的重复解答率居高不下。直到去年接触Hero…

2026/10/28 21:46:25

从零构建 t3code:T3 Stack 全栈开发实战全记录

最近一直在琢磨一个事:代码片段到底怎么管才最省心。浏览器收藏夹里的 Stack Overflow 链接越存越多,本地笔记软件里散落着临时复制的配置命令,GitHub 上的 Gist 又只能靠账号体系勉强支撑。于是我就用周末时间搭了一个小项目,代号…

2026/10/28 21:46:25

Three.js实战:从零搭建可交互Web3D场景的完整指南

最近在折腾一个叫 t3code 的项目,说是项目,其实最初只是一个念头:我想在浏览器里跑一个不依赖后端、打开页面就能看的 3D 场景,能转、能缩放、能切换视角,最好还能塞进几个自己建模的物件。市面上做 Web 3D 的方案不少…

2026/10/28 21:30:35

ECC 全解析:从椭圆曲线密码学到 SAP ECC 与纠错码

1. 从“ECC”这三个字母说起:它到底是什么,为什么总被反复提起第一次看到“ECC”这个缩写的人,十有八九会先愣一下——这三个字母在不同圈子里指向的东西完全不一样。做安全的人第一反应是椭圆曲线密码学(Elliptic Curve Cryptogr…

2026/10/28 14:42:12

全国计算机二级C语言真题PDF高效刷题指南

简介:这份PDF是全国计算机二级C语言考试的试题与答案解析,专为备考二级C语言的考生准备。内容涵盖C语言特点与设计理念、程序基本组成单位、main函数执行流程、函数嵌套调用规则、标识符命名规范、整型实型字符型数据类型、八进制十六进制数合法形式、运…

2026/10/28 15:28:56

requests+BeautifulSoup:爬虫抓取h1标题的入门实战指南

简介:一份以实战为导向的Python爬虫入门资源,面向具备基本编程基础、希望快速掌握网络数据抓取的初学者,可解决从零编写简单爬虫的常见困惑。实例以requests库发送HTTP请求、BeautifulSoup解析HTML文档,完整演示从网页中提取 标题…

2026/10/28 3:32:29

递推最小二乘参数辨识MATLAB编程:二阶系统与渐消记忆实现

简介:这是一篇面向控制系统、信号处理与机器学习等领域学习者的最小二乘参数辨识程序说明文档。资源共包含一个docx格式文档,压缩包大小七十八KB,内容围绕递推最小二乘算法和渐消记忆最小二乘算法展开:先构造一个极点位于单位圆内…

2026/10/28 0:02:13

Python股票自动交易系统源码解析:从行情获取到风控的完整链路

简介:这份资源是面向高校计算机、金融相关专业毕业设计场景的完整项目源码包,主题为基于Python的股票自动交易系统,适合需要完成毕设选题、课程设计或自学金融量化交易开发的学生与开发者参考。压缩包共收录687个文件,整体约14.93…

2026/10/28 0:02:13

视酷酷信IM源码部署指南:Java 17+MySQL 8.0.33实战配置

简介:这是一套2024年亲测可部署的视酷酷信IM即时通讯系统源码,面向具备Java、OC、C#及Vue开发能力的技术人员,解决私有化即时通讯平台快速搭建与二次定制需求,尤其适用于需集成商城、保障数据主权的中小型企业或独立开发者。资源共…

2026/10/28 0:02:13

Python爬取百度迁徙数据:接口解析、批量采集与避坑指南

简介:这份资源是一个用Python爬取百度迁徙数据的脚本压缩包,面向具备Python基础、希望入门网络爬虫与数据解析的开发者,解决按日期获取全国热门城市人口迁徙数据的问题。包内共1个文件,为单个py脚本,压缩包约1KB&#…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑