
简介基于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.pyGUI 交互测试配合 pygame 验证事件响应example.py调用示例演示模块怎么组合test.tsv制表符分隔的测试数据离线验证用pygazetracker追踪逻辑模块包被主程序 importresources模型文件、级联文件、测试素材的存放目录从命名看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, scaleFactor1.1, minNeighbors5, 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, scaleFactor1.1, minNeighbors4, 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, keycv2.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太小导致金字塔层数多、候选框爆炸光照太差时灰度对比度低级联分类器找不到眼睛轮廓。这三者常常同时发作。解决先固定scaleFactor1.1, minNeighbors5这两个参数不要轻易动。然后处理光照在灰度图送入检测之前加一步直方图均衡化gray_eq cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)).apply(gray)clipLimit控制对比度增强强度2.0 是经验值太大会出现噪声被放大的问题。把gray换成gray_eq再送进detectMultiScale绝大多数光照导致的乱跳都能缓解。如果还跳检查一下是不是摄像头自动白平衡在快速变化手动关掉白平衡会改善很多。4.3 瞳孔质心偏到眉毛ROI 范围没限制住现象眼睛框位置正常但瞳孔定位画的十字点经常跑到眉毛上或者被眼镜框的高光吸走。原因第 3.2 节里取的是眼睛框内面积最大的暗轮廓而眉毛正好在眼睛框上边缘灰度值和瞳孔接近面积又往往比瞳孔大于是max(contours, keycv2.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 甚至 1920x1080Haar 级联在这么高的分辨率上做多尺度滑窗非常耗时。加上每帧都跑完整的人脸检测、眼睛检测、瞳孔定位、轮廓提取一帧处理时间超过 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 范围、坐标映射这些参数全部摸清楚之后再碰摄像头。这么做几分钟就能判断这份代码值不值得继续深挖也能在接摄像头之前把所有可控变量都固定住。希望帮到你。本文还有配套的精品资源点击获取