Python3+OpenCV实现眼球追踪:瞳孔检测与注视估计实战
简介面向计算机视觉入门者及人机交互开发者这份压缩包提供了一套基于Python3与OpenCV的实时眼球追踪方案涵盖摄像头视频流捕获、图像灰度化与滤波预处理、Haar级联眼部检测、瞳孔中心定位及视线交互映射等核心环节适合学习传统CV特征检测与简单视线控制应用。资源共62个文件包括9个Python源码、6个XML级联分类器配置、36张示例图片、编译生成的pyc文件及许可证等压缩包约393KB结构紧凑便于快速复用。目前已有1045人学习项目从环境配置到屏幕坐标映射均给出可运行代码配合示例图和分类器文件可直观理解“眼白分离—瞳孔定位—视线映射”的处理链路若要进一步提升精度可在现有框架上引入深度学习或头部姿态补偿。该资源是入门眼球追踪、积累OpenCV实战经验的经济型参考。1. 用 python3 和 OpenCV 做眼球追踪到底能做到什么程度一个普通 USB 摄像头、一台能跑 Python 3 的笔记本、一个 OpenCV能不能做出眼球追踪答案是能而且不需要深度学习白天室内灯光下就能稳定追踪瞳孔中心。这套方案做不了医疗级眼动仪但做疲劳监测、注意力统计、无障碍交互原型完全够用。标题里的基于 python3 和 OpenCV 的眼球追踪项目核心是一条四层管线找人脸、找眼睛、找瞳孔、把瞳孔坐标映射成屏幕坐标。下面按这条管线展开把每一步的算法选型、参数设置和真实环境里会踩的坑讲清楚。适合有 Python 3 基础、想低成本验证眼球追踪方案的工程师不需要深度学习背景一台带摄像头的电脑就能开始。2. 先把眼球追踪拆成四层管线检测、定位、特征、映射2.1 人脸与人眼检测Haar 级联是省事的起点眼球追踪的第一步不是找瞳孔而是先确定眼球大概在哪。全图里直接找瞳孔的问题在于背景里的深色区域、阴影、衣领全是干扰源。常见做法是先做一次人脸检测拿到人脸框之后再把眼睛的搜索范围压缩到人脸框的上半部分。这一层在 OpenCV 里最省事的方案就是 Haar 级联分类器模型文件由 OpenCV 自带不需要你训练任何东西。import cv2 face_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml ) eye_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_eye.xml ) def detect_face_and_eyes(gray): faces face_cascade.detectMultiScale( gray, scaleFactor1.1, minNeighbors5, minSize(80, 80) ) result [] for (x, y, w, h) in faces: roi gray[y:y h, x:x w] eyes eye_cascade.detectMultiScale( roi, scaleFactor1.1, minNeighbors5, minSize(20, 20) ) result.append(((x, y, w, h), [(x ex, y ey, ew, eh) for (ex, ey, ew, eh) in eyes])) return result这段代码是整套方案的地基重点说三个参数。detectMultiScale 的 scaleFactor 控制每轮缩放比例1.1 表示每次缩小 10%值越小检测越细但越慢minNeighbors 控制一个区域至少要命中几次才算有效设 5 能压掉相当一部分误检minSize 是下限人脸只关心 80×80 以上、眼睛 20×20 以上的目标再小基本都是噪声。cv2.data.haarcascades 这个路径在 OpenCV 4.x 里内置3.x 里不存在这是版本差异的第一个坑后面避坑章节单独展开。Haar 级联的优点是零训练成本、CPU 上也能跑缺点同样明显对侧脸、低头、戴深色眼镜框基本失效。如果你的应用场景是人正坐在屏幕前Haar 完全够用如果允许头部大幅转动就得换 OpenCV DNN 人脸检测模型或者干脆把上一帧的人脸框扩大后继续跟踪用跟踪代替检测。我一般先让 Haar 跑通全流程再决定要不要加这个复杂度。另外注意这里眼睛检测取的是人脸 ROI 内的坐标返回时加回了 (x, y) 偏移否则后面切眼睛图块时会切错位置。2.2 瞳孔定位阈值分割加轮廓筛选比 Hough 圆检测稳找到眼睛区域后核心问题变成瞳孔中心在哪里。很多人第一反应是 HoughCircles 找圆理由是瞳孔的样子就是圆。实际跑过就知道这参数非常玄学param2 调高了瞳孔检测不到调低了满屏假圆光照一变同一套参数这次好用下次翻车。在几十像素的小眼睛 ROI 里Hough 圆的稳定性远不如阈值分割加轮廓筛选这套传统流程。瞳孔在灰度图里是整只眼睛中最暗的连续区域。基于这个事实先做高斯模糊去噪再用 OTSU 自动阈值把图像分成前景和背景反色后瞳孔就变成了白色块。接着用形态学开运算把睫毛这类细长暗线去掉最后在二值图上找轮廓按面积和圆度筛选取质心。质心比拟合圆更抗遮挡。眨眼时瞳孔轮廓不完整拟合圆八成直接失败但只要残余区域还在质心依然有参考价值。这一步的原理不复杂参数选择却直接决定后面映射精度我把完整实现放到第 3 章顺便给你一张参数表。这里先记住结论普通室内光照、可见光摄像头瞳孔主要靠它是最暗的连续块这个特征区分出来而不是它是圆。有这一条认知后面能少走很多弯路。2.3 特征与映射瞳孔中心、角膜反射和 EAR 各管一摊眼动的原始特征不只有瞳孔中心。消费级和研究级方案里还有两个常被提及的方向需要先分清。第一个是角膜反射法也叫普尔钦斑或 CRT。原理是红外光源在角膜上产生一个高亮反射点瞳孔中心和反射点之间的相对向量会随注视方向改变。这个方案对硬件有要求需要红外 LED 和带红外滤镜的摄像头普通笔记本摄像头拍不到可靠的角膜反射点所以这套方案不做这一步但你要知道它是高端眼动仪的常见原理精度上限比可见光方案高一个量级。第二个是眼睛纵横比 EAR用眼睛周围 6 个关键点算一个无量纲比值。睁眼时 EAR 大约在 0.3 左右闭眼时掉到 0.2 以下阈值设 0.2 左右就能判断眨眼。这套方法常用于疲劳监测它不参与屏幕坐标映射而是作为用户当前是否在有效注视的状态判据。OpenCV 本身不直接提供人脸关键点常见做法是配合 dlib 或者 OpenCV Facemark 拿 68 点模型后面进阶章节再展开。还有一个容易忽略的变量是头部姿态。瞳孔中心映射到屏幕坐标的前提是头部大致不动如果人往前凑、往后靠或者左右偏头映射关系就变了。要做头部补偿常见做法是用 solvePnP 配合人脸关键点和相机内参求出头部旋转量再对瞳孔坐标做校正。这个复杂度比较高普通原型阶段通常先用固定头部位置来规避我更建议先把基础映射做通再考虑补偿。这一章先把四层管线立住后面每一步都围绕这四个层展开。2.4 一次只调一个变量先离线验证再上实时最后说一个执行层面的习惯不要盯着实时画面调参。实时画面里瞳孔跳、帧率抖、光照变你根本分不清是阈值问题还是摄像头问题。我一般会先录 10 秒左右的视频或者从摄像头抓 20 帧存成图片离线跑瞳孔定位把所有阈值调好再上实时主循环。这样每次只改一个参数效果能直接对比效率高得多。这也是判断一个眼球追踪项目能不能用的第一步代码跑通不算完先离线看它对不同光照、不同角度下的稳定程度再谈在线体验。后面几章全部按这个思路展开每一条参数改动都能在离线数据上验证而不是拍脑袋。3. 用 python3 和 OpenCV 跑通最小工程主循环、完整实现与参数表3.1 主循环骨架摄像头读帧、人脸框、眼睛框、瞳孔坐标先把最小工程跑起来。无论你手里的项目压缩包代码怎么组织核心都逃不开这个主循环读一帧画面做一次人脸检测在眼睛 ROI 里找瞳孔把坐标画出来。为了性能我会先把画面宽度缩到 640再往上分辨率对 Haar 检测速度和瞳孔分割稳定性都没有额外收益。import cv2 # find_pupil 的完整实现见 3.2 节同一文件定义或单独模块导入都可以 def find_pupil(eye_gray): # 占位见 3.2 节 pass cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) face_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml ) eye_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_eye.xml ) while True: ok, frame cap.read() if not ok: break frame cv2.flip(frame, 1) # 镜像方便用户对屏幕操作 gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale(gray, 1.1, 5, minSize(80, 80)) for (fx, fy, fw, fh) in faces: # 眼睛只可能出现在人脸的上半部分缩小搜索范围 top_half gray[fy:fy int(fh * 0.6), fx:fx fw] eyes eye_cascade.detectMultiScale(top_half, 1.1, 5, minSize(20, 20)) for (ex, ey, ew, eh) in eyes: # 外扩 10 像素防止眼眶边缘被裁掉 x0, y0 fx max(0, ex - 10), fy max(0, ey - 10) x1, y1 fx min(fw, ex ew 10), fy min(int(fh * 0.6), ey eh 10) eye_gray gray[y0:y1, x0:x1] pupil find_pupil(eye_gray) if pupil: px, py pupil cv2.circle(frame, (x0 px, y0 py), 3, (0, 0, 255), -1) cv2.rectangle(frame, (x0, y0), (x1, y1), (0, 255, 0), 1) cv2.imshow(eye-tracker, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()逻辑说明每帧先镜像让调试者看着自己的镜像操作鼠标时感觉自然人脸检测全图做眼睛检测只做人脸上半部分能显著减少把眉毛当成眼睛的误检。外扩 10 像素是因为 Haar 输出的眼睛框往往贴着眼睑瞳孔边缘经常被框线切掉留出余量后再裁 ROI 会稳很多。眼镜检测可能同时返回左右眼两个框原型阶段我取第一个或者取更靠近 ROI 中心的那一个标定时固定用同一只眼否则左右眼瞳孔交替参与映射坐标一致性会被破坏。参数说明cap.set 把分辨率固定到 640×480低分辨率下 Haar 和瞳孔分割的耗时都在可接受范围detectMultiScale 沿用第 2 章的 1.1/5/minSize 组合。cv2.flip 只在调试时用做标定时如果不需要镜像就关掉否则瞳孔坐标和屏幕坐标的左右对应关系会多一层转换标定结果会很怪。3.2 瞳孔定位函数完整实现与参数表主循环里调用的 find_pupil 是最核心的一段它决定瞳孔坐标稳不稳。完整实现如下import cv2 def find_pupil(eye_gray): h, w eye_gray.shape # 缩小到固定宽度统一后续参数的量纲 if w 120: scale 120.0 / w eye_gray cv2.resize(eye_gray, (120, int(h * scale))) blur cv2.GaussianBlur(eye_gray, (5, 5), 0) # OTSU 自动阈值反二值化后瞳孔变成白色块 _, th cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 先闭运算补反光空洞再开运算去睫毛 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) morph cv2.morphologyEx(th, cv2.MORPH_CLOSE, kernel) morph cv2.morphologyEx(morph, cv2.MORPH_OPEN, kernel) contours, _ cv2.findContours(morph, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) best None for c in contours: area cv2.contourArea(c) if area 30 or area 0.5 * eye_gray.size: continue peri cv2.arcLength(c, True) if peri 0: continue circularity 4 * 3.1415926 * area / (peri * peri) if circularity 0.5: continue if best is None or area best[0]: best (area, c) if best is None: return None m cv2.moments(best[1]) if m[m00] 0: return None cx int(m[m10] / m[m00]) cy int(m[m01] / m[m00]) return cx, cy先闭运算再开运算的顺序是刻意的闭运算能把瞳孔中央的高亮反光点补成实心开运算再把睫毛这种细线去掉。只做开运算的话反光点会让瞳孔变成空心环质心被拉偏。面积上限用的是 eye_gray.size 的比例而不是固定像素ROI 尺寸变化时不用重新调参数。下面这张参数表是调参时的依据建议贴在屏幕边上。参数值作用什么时候要调目标宽度120 px统一 ROI 尺寸稳定阈值和面积筛选ROI 来自高分辨率源时可放大到 160高斯核5×5去传感器噪声图像发虚时降到 3×3OTSU自动按灰度分布自动定分割阈值光照不均时改用 CLAHE 后再 OTSU形态学核5×5 椭圆闭运算补洞、开运算去睫毛睫毛干扰重时加大到 7×7面积下限30 px过滤掉小噪声块分辨率提高时按比例放大面积上限50% ROI过滤掉大面积黑色皮肤误检人脸 ROI 带进头发时收紧到 30%圆度下限0.5过滤长条形眼角暗区瞳孔被眼皮切半时放宽到 0.33.3 落盘与可视化把瞳孔轨迹记成 CSV光看实时画面很难判断追踪质量我习惯把每一帧的瞳孔坐标和时间戳写进 CSV。这个文件是后面调参、评估精度、复现问题的原材料比截图可靠得多。import csv import time csv_fp open(pupil_track.csv, w, newline) writer csv.writer(csv_fp) writer.writerow([frame_id, timestamp, pupil_x, pupil_y]) frame_id 0 start time.time() # 在 3.1 主循环里调用pupil 命中时写入 # pupil find_pupil(eye_gray) if pupil: ts time.time() - start writer.writerow([frame_id, round(ts, 4), x0 px, y0 py]) frame_id 1记录的是瞳孔在整帧画面里的绝对坐标也就是 x0 px 而不是 ROI 内坐标后面画轨迹、做标定都不用再换算。timestamp 用相对时间而不是系统时间方便对齐帧率波动。csv 每一行写一次就落盘程序崩溃也丢不了多少数据这是调试阶段的后悔药。3.4 用视频文件调试没有摄像头或想复现问题时的标准做法实时摄像头调试的变量太多遇到难缠的问题时我会把输入源换成视频文件。做法是把 VideoCapture 的入参从 0 换成视频路径其他代码完全不动cap cv2.VideoCapture(./recorded_session.avi)录制测试视频可以用 OpenCV 的 VideoWriter也可以用 OBS 之类的工具格式建议 avi mp4v 编码OpenCV 兼容性最好。拿到视频后在 Jupyter notebook 里逐帧调用 find_pupil把每次的输出和中间二值图画出来看确定是阈值问题还是轮廓筛选问题。这样做的另一个好处是不用一直占用摄像头对比不同参数时输入完全一致结果才有可比性。我一般会录三段正常光照一段、侧光一段、戴眼镜一段。三段都能稳定找到瞳孔才敢上实时。这个习惯省掉了我大量对着摄像头发呆的时间。4. 标定与注视估计9 点标定加最小二乘把瞳孔坐标换成屏幕坐标前面输出的瞳孔坐标只能描述眼球往哪看要得到用户在看屏幕上哪个位置必须建立瞳孔坐标到屏幕坐标的映射关系。这一步就是标定。没有标定眼球追踪只能算瞳孔跟踪做完标定才能叫注视估计。4.1 9 点标定交互流程和数据采集最常用的标定方式是 9 点标定屏幕上依次显示 3×3 网格的目标点用户注视目标点程序记录每个目标点对应的瞳孔坐标。9 个点能覆盖屏幕的主要区域而且数量足够支撑下面的二阶多项式拟合。import cv2 import numpy as np # 归一化屏幕坐标与具体分辨率解耦 CALIB_POINTS [ (0.1, 0.1), (0.5, 0.1), (0.9, 0.1), (0.1, 0.5), (0.5, 0.5), (0.9, 0.5), (0.1, 0.9), (0.5, 0.9), (0.9, 0.9), ] def collect_calib(cap, face_cascade, eye_cascade, find_pupil, screen_w, screen_h): samples [] # 每项: ((u, v), (sx, sy)) for sx_norm, sy_norm in CALIB_POINTS: sx, sy int(sx_norm * screen_w), int(sy_norm * screen_h) # 画标定目标白底、黑色十字、红色圆点 overlay np.full((screen_h, screen_w, 3), 255, np.uint8) cv2.line(overlay, (sx - 20, sy), (sx 20, sy), (0, 0, 0), 2) cv2.line(overlay, (sx, sy - 20), (sx, sy 20), (0, 0, 0), 2) cv2.circle(overlay, (sx, sy), 8, (0, 0, 255), -1) cv2.imshow(calib, overlay) pupil_list [] while True: ok, frame cap.read() gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # current_pupil: 把 3.1 主循环里人脸 - 眼睛 - 瞳孔 的提取逻辑 # 抽成函数返回瞳孔的绝对坐标 (px_abs, py_abs) pupil current_pupil(gray, face_cascade, eye_cascade, find_pupil) if pupil: pupil_list.append(pupil) cv2.circle(frame, pupil, 3, (0, 0, 255), -1) cv2.imshow(calib-camera, frame) key cv2.waitKey(1) 0xFF if key ord( ): # 用户确认当前点采够 30 个样本 break arr np.array(pupil_list[-30:], dtypenp.float32) median np.median(arr, axis0) # 用中位数抗眨眼飞点 samples.append(((median[0], median[1]), (sx, sy))) return samples流程上有几个细节直接影响标定质量。第一目标点用归一化坐标换屏幕分辨率不用重写数据。第二每个点不是只采一帧而是等用户按下空格后取最近 30 帧的中位数中位数比均值更能抗眨眼瞬间的飞点。第三每采完一个点程序把瞳孔当前位置实时显示在摄像头画面里用户能直观确认自己是否看准了目标点。标定的交互过程最影响精度。用户头没坐正、眼镜反光、目标点停留时间太短都会反映在最后映射的误差里。所以标定过程宁可慢一点每个点至少停留 3 秒让瞳孔坐标稳定下来再按空格。4.2 多项式拟合屏幕两个轴各一组系数有了 9 个点的瞳孔坐标 (u,v) 和屏幕坐标 (sx,sy)接下来用最小二乘拟合一个二阶多项式。为什么用二阶因为摄像头和屏幕之间存在透视关系瞳孔移动和屏幕坐标的关系在中心区域近似线性但边缘有弯曲二阶项能吸收这部分弯曲。再高阶就需要更多标定点否则会过拟合。import numpy as np def fit_mapping(samples): n len(samples) U np.array([s[0][0] for s in samples], dtypenp.float64) # 瞳孔 u V np.array([s[0][1] for s in samples], dtypenp.float64) # 瞳孔 v Sx np.array([s[1][0] for s in samples], dtypenp.float64) Sy np.array([s[1][1] for s in samples], dtypenp.float64) # 特征矩阵: [1, u, v, u*v, u^2, v^2] A np.vstack([np.ones(n), U, V, U * V, U * U, V * V]).T coef_x, *_ np.linalg.lstsq(A, Sx, rcondNone) coef_y, *_ np.linalg.lstsq(A, Sy, rcondNone) return coef_x, coef_y def apply_mapping(coef_x, coef_y, u, v): x (coef_x[0] coef_x[1]*u coef_x[2]*v coef_x[3]*u*v coef_x[4]*u*u coef_x[5]*v*v) y (coef_y[0] coef_y[1]*u coef_y[2]*v coef_y[3]*u*v coef_y[4]*u*u coef_y[5]*v*v) return x, y特征矩阵里 U*V、U^2、V^2 是三个二阶项加上常数项和一阶项共 6 个未知数。9 个标定点对 6 个未知数是超定方程组numpy 的 lstsq 会给出最小二乘意义下的最优解。rcondNone 是让 numpy 自动处理矩阵的数值稳定性比老代码里手动设 rcond-1 更规范。映射用的屏幕坐标我建议先归一化到 0~1 区间再拟合。这样换窗口、换分辨率时不用重新拟合直接把映射结果乘回当前分辨率即可。瞳孔坐标本身也随采集窗口大小变化归一化相当于把两套坐标都固定在一个标准量纲里后续换设备只重新标定不用改代码。4.3 精度验证像素误差和角度误差怎么算做完标定必须验证否则根本不知道这套映射能不能用。验证最简单有效的办法是标定后不要立刻结束再显示几个额外的验证点不要用刚才那 9 个点让用户注视用 apply_mapping 算出预测坐标和真实目标坐标求欧氏距离这就是平均像素误差。def angular_error(px_error, distance_cm, screen_size_cm, screen_res): # 把像素误差换算成屏幕上的厘米误差再算用户视角下的角度误差 cm_per_px screen_size_cm[0] / screen_res[0] cm_error px_error * cm_per_px angle_deg np.degrees(np.arctan(cm_error / distance_cm)) return angle_deg说明一下预期范围。USB 摄像头加 9 点标定在没有红外光源的情况下做到 1~2 度视角误差是可以接受的对应 60 厘米距离、27 寸屏幕上大概 2~4 厘米的偏差。这个精度做注意力分区、疲劳监测完全够要做瞳孔指向鼠标落在精确按钮上误差还是偏大。想再提升精度方向是换更高分辨率摄像头、用角膜反射法消除头部微动或者把标定点加密到 16 点、25 点。验证时注意一点验证点要覆盖屏幕边缘和中点不能只验证标定点附近区域。多项式拟合在标定区域内部效果好外推区域误差会明显放大这一点在多轮标定测试里体现得特别明显。提示换摄像头、换屏幕、换座位距离后标定必须重做。瞳孔到屏幕的映射依赖相机和屏幕的相对几何关系任何一项变了旧系数都失效。5. 眼球追踪避坑指南光照、反射、眨眼和 OpenCV 环境这一章是实战里最容易原地崩溃的部分每一条都是真实环境里反复出现过的坑按现象、原因、解决的顺序写。5.1 环境坑import cv2 失败、Anaconda 里装错环境、OpenCV 版本差异现象报 No module named cv2或者更误导人的 No module named opencv。在 Anaconda Prompt 里明明装过 opencv但启动 Jupyter notebook 后 import cv2 依然报错。原因最常见的是装错了环境。Anaconda Prompt 里默认激活的是 base 环境而你的 Jupyter kernel 可能指向 conda 里另一个 Python 环境或者 pip 装到了系统 Python但解释器用的是 conda 的 Python。报错信息是 No module named opencv 而不是 cv2 时基本可以确定是照搬教程把包名抄错了opencv 的导入名永远是 cv2安装名才是 opencv-python。解决先确认你面对的 Python 到底是哪个。命令行里跑 which pythonWindows 上是 where pythonJupyter 里跑 import sys; sys.executable 对比路径。确认后对着这个解释器执行 pip install opencv-python别只对着 base 环境装。现象同一份代码在 A 机器跑得好好的到 B 机器报 cv2.data 不存在或者 findContours 返回值解包报错。原因OpenCV 版本差异。cv2.data.haarcascades 是 OpenCV 3.4 之后才有的属性更早版本要用绝对路径指定级联文件findContours 的返回值在 3.x 和 4.x 也不一样。很多老旧教程基于 3.x代码直接拷到 4.x 会翻车。解决统一用 OpenCV 4.2 以上的版本pip 安装 opencv-python 默认就是 4.x。代码里统一用 _, contours cv2.findContours(...) 这种写法兼容两个大版本。如果你在 Linux 上装过 CUDA 版 OpenCV眼球追踪这套纯 CPU 方案根本用不上它别折腾源码编译直接 pip 装官方预编译包省心。5.2 光照一变瞳孔坐标就跳OTSU 不是万能的现象白天跑得好好的拉上窗帘或者打开台灯瞳孔中心突然往旁边跳甚至跳到虹膜边缘画面上红点开始乱飘。原因OTSU 是全局阈值它的分割点由整张 ROI 的灰度分布决定。光照变化会改变灰度分布形态分割结果也跟着变。更麻烦的是光线从侧面照过来时瞳孔一侧的虹膜和瞳孔灰度差变小分割边界会整体偏移。解决不要只用裸 OTSU先对眼睛 ROI 做 CLAHE 自适应直方图均衡再进 OTSU同时把瞳孔检测结果限制在 ROI 中心附近一个范围内超出范围判定为无效。最后一个土办法很有效把 ROI 缩小到只包住瞳孔周边减少背景灰度变化对阈值的扰动。ROI 越小阈值越稳定这个规律在多光源环境下非常救命。5.3 眼镜反光和睫毛遮挡形态学参数是最后的救场手段现象戴眼镜的用户瞳孔中央出现一块高亮反光轮廓筛选后瞳孔变成空心环质心被拉到反光点旁边下睫毛浓密的用户瞳孔区域被分割成几块最大轮廓只剩一半。原因反光区域在二值化后形成空洞质心计算会被空洞拉偏睫毛是暗色细线在反二值化图里和瞳孔同色容易把瞳孔区域切断。解决形态学顺序改成先闭运算再开运算闭运算先把反光空洞补上开运算再处理睫毛细线。开运算的核从 5×5 加到 7×7 能更彻底清除睫毛但代价是瞳孔边缘也变糊质心精度下降所以不要无脑加大。如果反光特别严重管道里加一步找出 ROI 内灰度最高的区域把它反色成瞳孔色再参与分割比改核参数更直接。5.4 眨眼瞬间的飞点速度滤波吃掉异常跳变现象正常眨眼那几帧瞳孔坐标会突然跳到奇怪的位置比如眼角方向下一帧又恢复正常。单帧看是正常的追踪丢失但记录到 CSV 里就成了尖刺标定和注视估计都会被带偏。原因瞳孔被眼皮完全覆盖后阈值分割在 ROI 里找不到真正的瞳孔轮廓筛选退而求其次选择了面积最大的暗色区域通常是睫毛根部或眼角阴影这些位置的质心和真实瞳孔没有关系。解决加一层速度滤波。人眼的正常扫视速度有上限眨眼造成的跳变通常远大于正常角速度。实现时维护上一帧的瞳孔坐标本帧与上一帧的位移超过阈值时用上一帧的值替代连续多帧超阈值才判定追踪丢失。def speed_filter(x, y, last_pt, max_d25): if last_pt is None: return (x, y), True dx, dy x - last_pt[0], y - last_pt[1] if dx * dx dy * dy max_d * max_d: return last_pt, False # 用上一帧认为本帧无效 return (x, y), True阈值取值要结合图像尺寸。ROI 宽 120 像素时max_d 取 25 左右比较合适如果 ROI 放大到全脸范围这个值要按比例放大。滤波不适合单独扛飞点它是最后一道保险前面光照和形态学的稳定性才是根本两件事别搞反优先级。5.5 USB 摄像头拉流中断和掉帧read 卡死的问题用线程加队列解决现象程序跑几分钟后画面卡住cap.read() 一直不返回或者频繁返回 False主循环直接退出换一个 USB 口有时能好一阵有时彻底黑屏。原因USB 摄像头在带宽不足、过热或驱动异常时会丢流OpenCV 的 read() 是同步阻塞调用一旦底层取流异常主线程就卡死。同一总线挂载过多设备比如外接键盘接收器加摄像头共用控制器也可能引发。解决把读帧放到独立线程主循环只从队列取最新帧取不到就跳过当前帧继续跑。关键点是队列只保留最新帧丢旧帧不丢实时性否则画面延迟会越来越大。import queue import threading import time import cv2 class CameraReader: def __init__(self, src0): self.src src self.cap cv2.VideoCapture(self.src) self.q queue.Queue(maxsize2) self.alive True t threading.Thread(targetself._loop, daemonTrue) t.start() def _loop(self): while self.alive: if self.cap is None or not self.cap.isOpened(): self.cap cv2.VideoCapture(self.src) time.sleep(0.5) continue ok, frame self.cap.read() if ok: if self.q.full(): try: self.q.get_nowait() except queue.Empty: pass self.q.put(frame) else: self.cap.release() self.cap None time.sleep(0.5) def read(self): try: return self.q.get_nowait() except queue.Empty: return None线程用 daemon 模式主程序退出时不会卡在等待上。这套方案不能根治硬件问题但能让主循环在摄像头短期异常时继续运行画面出帧即显示不会整体冻住。跑长时间采集时我会额外记录连续失败帧数超过 30 帧就主动重置摄像头连接。6. 进阶用 EAR 做眨眼检测用跳帧与局部 ROI 稳住帧率到这里瞳孔坐标和注视映射已经能跑通。最后一个进阶点是把追踪变成状态识别判断用户是不是在眨眼以及用跳帧策略保住实时帧率。EAR 需要眼睛区域的 6 个关键点常见做法是 dlib 的 68 点模型也可以在 OpenCV 里用 Facemark。计算逻辑不复杂def eye_aspect_ratio(eye_pts): # eye_pts 是单眼 6 个关键点顺序按 dlib 索引约定 A np.linalg.norm(eye_pts[1] - eye_pts[5]) B np.linalg.norm(eye_pts[2] - eye_pts[4]) C np.linalg.norm(eye_pts[0] - eye_pts[3]) return (A B) / (2.0 * C)睁眼时 EAR 在 0.3 上下闭眼时掉到 0.2 以下。取阈值 0.2连续 2~3 帧低于阈值就记一次眨眼。这个指标和瞳孔追踪结合后可以滤掉眨眼期间的映射结果正好补上速度滤波的盲区。眨眼数据落地也方便记个时间戳和眨眼标志半小时一统计就是一份现成的疲劳趋势曲线。帧率方面我的习惯是分层降负载人脸检测每 3 帧做一次瞳孔定位每帧都做人脸框从上次位置外扩 20% 继续找眼睛不再全图扫。这样在树莓派一类弱设备上也能维持 25 帧以上。验证做法很固定录一段固定场景视频分别统计人脸检测、瞳孔定位耗时超过 20 毫秒的模块优先优化而不是盲目降低分辨率。整个方案跑下来我的判断是普通摄像头加传统 OpenCV 算法眼球追踪能稳定落在注意力监测、疲劳预警、行为分析这个区间再往上追究精确注视点就得引入红外硬件和角膜反射了。做原型没问题做交付要看清自己的精度需求。头一次做这个方向的人最容易栽在使用 Hough 找圆和盯着实时画面调参这两件事上这篇希望能帮你绕开也祝你把瞳孔坐标玩出自己的应用来希望帮到你。本文还有配套的精品资源点击获取