简介:基于Python3与OpenCV实现的实时眼球追踪项目源码包,面向计算机视觉入门者、人机交互及生物识别方向的开发者,可用于快速构建通过眼部运动控制界面的原型应用。资源共62个文件,以9个py源码文件为核心,完整覆盖摄像头捕获、图像预处理、Haar级联眼睛检测、瞳孔定位及交互映射等关键模块;另含6个xml分类器模型、36张png样本图像及少量配置与说明文件,压缩包仅393KB,目录结构清晰,便于按需查阅。已有1045人学习浏览,适合希望从零搭建眼球追踪系统或理解传统视觉管线的读者。借助该项目可深入实践灰度化、高斯滤波、HSV色彩空间分离、轮廓匹配等经典OpenCV技术,并获得一套可直接运行、便于二次开发的实验框架,便于后续向深度学习或头部追踪方向拓展。
1. 用 Python3 和 OpenCV 做眼球追踪:难的不是追踪,是找到瞳孔
把「眼球追踪」想成纯算法问题的人,多半会在拿到这套 Python3 + OpenCV 代码后愣一下——它没有深度学习模型,没有眼动仪 SDK,核心流程看着不到两百行。它做的是一整条实时链路:OpenCV 的 Haar 级联从摄像头画面里找脸和眼睛,阈值分割锁定瞳孔位置,最后把瞳孔相对眼睛框的偏移映射成屏幕坐标。这套方案的典型应用是眼控鼠标、注视交互原型和心理学实验的注视点采样,对刚接触 OpenCV 图像处理的开发者来说,是性价比很高的入门项目。它不追求医疗级精度,但能让你在一晚上之内,用笔记本摄像头跑出一个能实时跟随眼睛的光标。我的建议是先照着主流程跑一遍,再考虑要不要往深度学习、头部姿态补偿这些方向升级。
2. 环境与代码结构:把压缩包变成可运行程序的三个步骤
2.1 依赖安装:python3 和 OpenCV 的版本陷阱
先交代环境。这套代码基于 Python3,核心依赖是 OpenCV,辅助依赖有 numpy 和 pygame。安装 OpenCV 没有玄学,但有个细节容易翻车:pip 直接装opencv-python就够了,opencv-contrib-python是带扩展模块的版本,眼球追踪用到的CascadeClassifier、VideoCapture、findContours都在基础包里,不需要额外装 contrib。很多人一上来就装 contrib,反而和已有环境里的 opencv-python 冲突。
python3 -m pip install opencv-python numpy pygame这里故意用python3 -m pip而不是pip,是因为很多环境里pip指向的是另一个 Python 解释器,装完了 import cv2 照样报 ModuleNotFoundError。用python3 -m pip能保证装进当前解释器的 site-packages。装完立刻验证:
python3 -c "import cv2, numpy, pygame; print(cv2.__version__, numpy.__version__, pygame.ver)"看到版本号输出就说明环境通了。如果你用的是 Anaconda,在 Anaconda Prompt 里执行同样的命令,注意别在 base 环境和虚拟环境之间混着装包。OpenCV 版本建议 4.x 以上,4.2 之后的版本在CascadeClassifier路径处理上有变化,老教程里直接写'haarcascade_eye.xml'的相对路径在新版里会找不到文件,正确做法是用cv2.data.haarcascades + 'haarcascade_eye.xml'拼接绝对路径,这个细节在第三节代码里会用到。
2.2 压缩包里的文件怎么分工
解压后看到一整个webcam-eyetracker-based-on-Python3-main目录,文件名已经提示了它的定位:基于 webcam 的实时追踪。先别急着运行,花两分钟把文件分工搞清楚,后面排错会快很多。
| 文件 / 目录 | 角色 |
|---|---|
| camtracker.py | 主程序,摄像头实时追踪入口 |
| process_images.py | 离线处理图片或图片序列的脚本 |
| GUItest.py | GUI 交互测试,配合 pygame 验证事件响应 |
| example.py | 调用示例,演示模块怎么组合 |
| test.tsv | 制表符分隔的测试数据,离线验证用 |
| pygazetracker | 追踪逻辑模块包,被主程序 import |
| resources | 模型文件、级联文件、测试素材的存放目录 |
从命名看,camtracker.py承担的是摄像头实时主流程:打开摄像头、逐帧预处理、检测、显示结果。example.py是给你看调用方式的,test.tsv是离线数据,后面第六章我会专门讲怎么用它验证流程。至于pygazetracker这个目录,大概率是封装了瞳孔定位和视线估计的模块,example.py里应该能看到from pygazetracker import ...之类的导入。如果你拿到手的压缩包内容和这个清单略有出入,以实际解压结果为准。
2.3 跑通 camtracker.py 主流程
环境装好、文件分清楚之后,直接启动主程序:
cd webcam-eyetracker-based-on-Python3-main python3 camtracker.py --camera 0--camera是摄像头索引参数,0 通常对应笔记本内置摄像头,外接 USB 摄像头一般是 1。如果你的机器只有外接摄像头,索引 0 打不开,就换成--camera 1。如果主程序没有解析命令行参数,那就直接改camtracker.py顶部的cv2.VideoCapture(0),把 0 改成对应索引。
启动后能看到摄像头画面、人脸框和眼睛区域框,说明主流程已经跑通。Linux 下如果提示摄像头权限错误,检查当前用户是否在video组里,或者直接用sudo usermod -aG video $USER把用户加进去再注销重登。macOS 需要手动给终端或 IDE 授权摄像头权限,这个经常被忽略。
3. 眼部检测与瞳孔定位:Haar 级联、阈值分割与归一化参数
3.1 Haar 级联:为什么不用深度学习也能跑实时
眼球追踪的第一步是找到眼睛在哪里。如果直接在全画面里找瞳孔,计算量会非常大,而且容易把画面里任意一个暗色圆形物体误判成瞳孔。常见做法是分两步:先做人脸检测,再在人脸区域内做眼睛检测。
OpenCV 的 Haar 级联分类器在这里是最实用的选择。它不是深度学习,而是 AdaBoost 训练出来的一系列弱分类器的组合,每个弱分类器只判断某个小区域内的灰度特征,级联起来就是几十层快速筛选窗口。检测时在图像金字塔上做多尺度滑窗,效率非常高,单帧 CPU 运算量小,模型文件也只有几十 KB。这套代码既然用了 Haar,说明设计目标就是在普通笔记本上实时运行,不需要 GPU。
import cv2 # 用 cv2.data.haarcascades 拼接绝对路径,避免新版 OpenCV 找不到文件 face_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml" ) eye_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_eye.xml" ) frame = cv2.VideoCapture(0).read()[1] gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 先检测人脸,缩小眼睛搜索范围 faces = face_cascade.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=5, minSize=(80, 80) ) for (x, y, w, h) in faces: roi_gray = gray[y:y + h, x:x + w] # 在人脸 ROI 内检测眼睛,minSize 过滤掉过小的误检 eyes = eye_cascade.detectMultiScale( roi_gray, scaleFactor=1.1, minNeighbors=4, minSize=(20, 20) ) for (ex, ey, ew, eh) in eyes: cv2.rectangle(frame, (x + ex, y + ey), (x + ex + ew, y + ey + eh), (0, 255, 0), 2)detectMultiScale里有几个参数直接影响检测稳定性。scaleFactor是图像金字塔的缩放比例,1.1 表示每层缩小 10%,值越小检测越仔细但越慢,1.05 以下很容易出现同一只眼睛被框出好几个重叠框的情况。minNeighbors是候选矩形最少要满足的邻近命中次数,5 左右比较安全,低于 3 时误检率会明显上升,人脸的轮廓边缘、衣服上的纹路都可能被当成眼睛。minSize也很关键,设置成 (20, 20) 可以过滤掉远距离产生的极小候选框——那些大概率是噪点。
3.2 瞳孔中心:最暗区域、质心与阈值
眼睛框拿到之后,瞳孔定位的核心思路很朴素:瞳孔是眼睛区域内最暗的一块。用阈值分割把暗区域提取出来,再找连通域的质心。
# eye_gray 是从上一节眼睛框里裁剪出来的灰度图 eye_gray = roi_gray[ey:ey + eh, ex:ex + ew] # 中值滤波去高光噪点,核大小 5,太大会把瞳孔也抹平 eye_blur = cv2.medianBlur(eye_gray, 5) # 瞳孔比周围暗,所以用 BINARY_INV:低于阈值的变白,其余变黑 _, thresh = cv2.threshold(eye_blur, 60, 255, cv2.THRESH_BINARY_INV) # 提取轮廓,只取外轮廓,避免瞳孔内部的高光产生多个嵌套轮廓 contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: # 取面积最大的轮廓,通常是瞳孔,也有可能是眉毛 pupil = max(contours, key=cv2.contourArea) M = cv2.moments(pupil) if M["m00"] > 0: cx = int(M["m10"] / M["m00"]) cy = int(M["m01"] / M["m00"])这段代码里最容易出问题的是cv2.threshold的固定阈值 60。这个值是我在普通室内光照下测出来的经验值,环境一变就得重调。如果你的光线偏暗或者偏亮,可以改成cv2.threshold(eye_blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU),用 Otsu 自动计算阈值,代价是每帧多一点点计算量。cv2.moments计算的是轮廓的几何矩,m00是面积,m10 / m00和m01 / m00就是质心的 x、y 坐标,这里要加m00 > 0的判断,防止轮廓面积为零导致除零错误。
3.3 归一化:把像素坐标变成稳定的追踪特征
瞳孔坐标拿到之后,不要直接拿(cx, cy)去映射屏幕。摄像头和人的距离一变,同样的注视方向在图像上的瞳孔像素偏移会差好几倍。解决办法是把瞳孔坐标除以眼睛框的宽和高,转换成 0 到 1 的相对坐标。
nx = cx / ew ny = cy / eh # 限制在 [0, 1],防止边界处溢出 nx = max(0.0, min(1.0, nx)) ny = max(0.0, min(1.0, ny))归一化之后,这个(nx, ny)就代表瞳孔在眼睛框里的相对位置。看向左时nx偏小,看向右时nx偏大,上下同理。这也是后续映射到屏幕坐标的基础。有一点要提前说明:这种归一化只对头部基本不动的情况有效。头一歪,眼睛框整体位置变了,瞳孔相对坐标也会漂移,这是所有纯 2D 眼球追踪的天然边界。想要补偿头部运动,得额外做人头姿态估计,那属于进阶内容。
4. 避坑手册:跑 webcam 眼球追踪最常见的五个翻车现场
4.1 摄像头打不开:先查索引再查权限
现象:运行camtracker.py后弹出一个黑窗口或者直接报错,提示Unable to capture video,程序卡死或者退出。
原因:cv2.VideoCapture(0)返回的对象没有成功打开摄像头。常见原因有两个,一是摄像头索引不对,笔记本内置摄像头和外接摄像头同时存在时,索引顺序不一定是 0;二是系统权限没放开,Linux 下用户不在 video 组、macOS 下终端没获得摄像头权限。
解决:先写三行代码验证摄像头本身能否打开:
import cv2 cap = cv2.VideoCapture(1) # 换索引试试 print(cap.isOpened())isOpened()返回 False 就继续换索引。Linux 下用ls /dev/video*查看设备列表,macOS 去系统设置里给 Python 授权摄像头。搞定之后再回主程序。
4.2 眼睛框乱跳:尺度参数和光照的锅
现象:画面里眼睛框在眉毛、鼻梁、眼镜边缘之间来回跳,或者某一帧框住了整个下半张脸,下一帧又完全消失。
原因:minNeighbors设置太低,级联分类器的误检率高;scaleFactor太小导致金字塔层数多、候选框爆炸;光照太差时灰度对比度低,级联分类器找不到眼睛轮廓。这三者常常同时发作。
解决:先固定scaleFactor=1.1, minNeighbors=5,这两个参数不要轻易动。然后处理光照,在灰度图送入检测之前加一步直方图均衡化:
gray_eq = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(gray)clipLimit控制对比度增强强度,2.0 是经验值,太大会出现噪声被放大的问题。把gray换成gray_eq再送进detectMultiScale,绝大多数光照导致的乱跳都能缓解。如果还跳,检查一下是不是摄像头自动白平衡在快速变化,手动关掉白平衡会改善很多。
4.3 瞳孔质心偏到眉毛:ROI 范围没限制住
现象:眼睛框位置正常,但瞳孔定位画的十字点经常跑到眉毛上,或者被眼镜框的高光吸走。
原因:第 3.2 节里取的是眼睛框内面积最大的暗轮廓,而眉毛正好在眼睛框上边缘,灰度值和瞳孔接近,面积又往往比瞳孔大,于是max(contours, key=cv2.contourArea)直接选中了眉毛。
解决:在瞳孔定位之前,把眼睛框内的 ROI 裁剪掉上下边缘。眼睛框的中心区域才是瞳孔活动范围,眉毛基本集中在上 1/3 区域:
# 只保留眼睛框中间 50% 的区域做瞳孔定位 roi_h, roi_w = eye_blur.shape start_y = int(roi_h * 0.25) end_y = int(roi_h * 0.75) eye_crop = eye_blur[start_y:end_y, :] # 后续把找出来的质心坐标再加回 start_y 偏移这样眉毛被排除在 ROI 之外,瞳孔质心就稳了。戴眼镜的用户还会有反光问题,反光点的灰度值很高,THRESH_BINARY_INV里它不会变成白色,一般不影响瞳孔提取,但镜框边缘的暗色倒影容易被误判。加一步cv2.GaussianBlur(eye_blur, (3, 3), 0)能减少镜框倒影的影响。
4.4 import cv2 报错:环境混用是重灾区
现象:终端输入python3 camtracker.py直接报ModuleNotFoundError: No module named 'cv2',但刚才明明用 pip 安装成功过。
原因:几乎所有踩这个坑的人都不是真的没装 OpenCV,而是装错了环境。最常见的是终端里的python3是系统自带解释器,pip 却装进了 Anaconda 的环境里;或者反过来。另一个情况是系统里有多个 Python3 版本,python3和pip3指向不同解释器。
解决:安装和运行都用同一条命令链。安装用python3 -m pip install opencv-python,运行用python3 camtracker.py,两边统一。然后立即验证:
python3 -c "import cv2; print(cv2.__version__)"如果还是报错,用which python3和python3 -m pip --version看看两个命令指向的路径是否一致。不一致就以which python3输出的解释器为准重新装。Anaconda 用户直接在 Anaconda Prompt 里conda install -c conda-forge opencv,别用 pip 混装。
4.5 画面拖影:分辨率、跳帧与检测缓存
现象:实时画面有明显延迟,头已经转过去了,画面里的脸框还停留在上一秒的位置,光标响应也慢半拍。
原因:VideoCapture默认分辨率可能是 1280x720 甚至 1920x1080,Haar 级联在这么高的分辨率上做多尺度滑窗非常耗时。加上每帧都跑完整的人脸检测、眼睛检测、瞳孔定位、轮廓提取,一帧处理时间超过 100 毫秒,自然就拖影了。
解决:把输入分辨率降下来,同时做跳帧处理。追踪场景不需要全高清,320x240 够用。
cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) frame_id = 0 face_cache = None while True: ret, frame = cap.read() if not ret: break # 每两帧做一次完整检测,另一帧沿用上一次的人脸框 if frame_id % 2 == 0: face_cache = face_cascade.detectMultiScale(gray, 1.1, 5) # 后续直接用 face_cache 做眼睛检测 frame_id += 1cap.set把分辨率限制在 640x480,检测耗时能下降一半以上。跳帧之后,界面显示可以每帧刷新,检测是隔帧执行,视觉上流畅度几乎不变。如果还卡,把CAMERA_FPS相关的cap.set(cv2.CAP_PROP_FPS, 30)也加上。这里注意:跳帧时如果face_cache为空,要临时补一次完整检测,否则一只眼睛都找不到。
5. 视线到鼠标的映射:从瞳孔偏移到屏幕坐标的实战调参
5.1 线性映射与一次校准:四点定标法
瞳孔归一化坐标(nx, ny)现在是 0 到 1 的区间,要把它变成屏幕坐标,最直接的办法是线性映射。屏幕宽度screen_w、屏幕高度screen_h是已知的,理论上坐标就是:
sx = nx * screen_w sy = ny * screen_h但实际用起来会发现一个问题:人眼的最舒适注视范围不是整个屏幕,瞳孔在眼睛框里的移动范围通常只占归一化坐标的 0.3 到 0.7。直接线性映射会导致光标只在屏幕中间一小块区域里动。常见的做法是引入校准环节——四点定标法:
- 屏幕上依次显示四个角(左上、右上、左下、右下)。
- 用户盯着当前角点保持两秒,程序记录这段时间内瞳孔归一化坐标的平均值。
- 四个角分别得到一组
(nx, ny),对应已知的屏幕坐标,建立四对映射关系。 - 追踪阶段根据瞳孔坐标落在哪个区间,用对应的比例系数计算屏幕坐标。
# calib_points 是字典:{角点名称: (屏幕x, 屏幕y, 瞳孔nx, 瞳孔ny)} # 四个角各取一段时间的平均 nx, ny nx_range = (calib_points["tl"][2], calib_points["tr"][2]) # 瞳孔左右边界 screen_x = (nx - nx_range[0]) / (nx_range[1] - nx_range[0]) * screen_w关键是nx_range和ny_range的边界不只来自左右两个角,最好把上下角的nx也平均进来,避免用户盯左上角时头和盯右上角时头的微小偏移干扰比例计算。整套校准做完,光标活动范围能基本覆盖整个屏幕。
5.2 去抖滤波:EMA 参数怎么选
瞳孔定位的每一帧结果都有噪声,直接映射到屏幕会让光标像得了帕金森一样抖个不停。常见做法是用指数移动平均(EMA)做一阶低通滤波。
smoothed_x = alpha * raw_x + (1 - alpha) * previous_x smoothed_y = alpha * raw_y + (1 - alpha) * previous_yalpha决定平滑程度:alpha越大对新数据越敏感,光标响应快但抖;alpha越小越平滑,但延迟会增大。我通常把alpha设在 0.2 到 0.4 之间。室内稳定光线下用 0.3 比较均衡,逆光场景噪声大,我会降到 0.15 左右,先保证不抖,延迟大一点还能接受。
EMA 之外还要加一个滞回区:瞳孔坐标变化量小于一定阈值时直接忽略,不要更新光标位置。这个阈值用归一化坐标表示,一般取 0.01 到 0.02,相当于屏幕宽度百分之一左右的移动。
# 滞回判断:变化太小就保持上一帧光标位置 if abs(raw_x - previous_x) > 0.01 or abs(raw_y - previous_y) > 0.01: previous_x = raw_x previous_y = raw_y5.3 PyGame 交互与光标控制
坐标算出来之后,控制鼠标可以走 PyGame 路线。压缩包里的GUItest.py和 PyGame 目录暗示了原作者的交互方案:用 PyGame 窗口模拟全屏界面,通过pygame.mouse.set_pos设置光标位置。
import pygame pygame.init() screen = pygame.display.set_mode((screen_w, screen_h)) pygame.mouse.set_visible(True) # 主循环里更新光标位置 pygame.mouse.set_pos((int(smoothed_x), int(smoothed_y))) for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit()用 PyGame 而不是pyautogui的原因是它的set_pos不需要额外权限,而且GUItest.py本身就用 PyGame 做了事件测试,代码风格一脉相承。如果你需要把光标移到浏览器或编辑器里操作,PyGame 的set_pos也能做到,因为它在系统层面移动光标。触发交互事件时可以设阈值:瞳孔归一化坐标在某一个区域停留超过 0.5 秒,就触发点击,这比直接做成移动就点击要实用得多。
6. 离线验证技巧:用 test.tsv 先回放再上摄像头
每次拿到这类追踪代码,我最先做的不是把脸凑到摄像头前,而是先找它有没有离线数据。test.tsv就是这样一个东西——制表符分隔的测试记录,多半保存了时间戳和对应的瞳孔坐标,供process_images.py做离线回放和算法验证。
离线回放的价值在于可控。摄像头实时画面里的光照、头位、眨眼都是不可控变量,你没法判断算法表现变差是因为参数问题还是环境变化。而离线数据是固定的,你可以反复跑,改一次参数跑一次,对比输出坐标和记录坐标的偏差。
import csv with open("test.tsv", newline="") as f: reader = csv.DictReader(f, delimiter="\t") for row in reader: # 表头可能是 timestamp/x/y,也可能是 t/px/py,先看一行再定 ts = float(row["timestamp"]) x = float(row["x"]) y = float(row["y"]) # 在这里接入你的瞳孔定位函数,对比输出和记录值的差如果你拿到的test.tsv表头不是这个,用head -2 test.tsv先看两行再改字段名。回放时我会同时打印每一帧的(x, y)和算法输出的归一化坐标,统计两者偏差超过 0.05 的帧数占比。占比超过 20%,基本可以断定参数不适合当前数据,直接调参而不是怀疑代码有 bug。
还有一个验证习惯值得养成:准备三张瞳孔位置已知的测试图,一张看左上、一张看正中、一张看右下,跑process_images.py或直接调瞳认定函数,检查质心像素偏差是否在 5 像素以内。这一步比接摄像头实时调试快得多,因为瞳孔定位的精度问题在静态图上完全暴露,不需要对着屏幕反复转头去找规律。
从那以后,我每次拿到这类追踪代码,第一件事不是急着开摄像头对着脸跑实时,而是先看它有没有离线数据和测试图,有就先离线跑一遍,把阈值、ROI 范围、坐标映射这些参数全部摸清楚之后再碰摄像头。这么做,几分钟就能判断这份代码值不值得继续深挖,也能在接摄像头之前把所有可控变量都固定住。希望帮到你。
本文还有配套的精品资源,点击获取