简介这份资源面向具备Python基础、希望入门计算机视觉与人机交互的开发者提供一套基于OpenCV的实时眼动追踪示例代码。项目通过普通摄像头即可运行无需高端硬件可应用于人机交互、心理学研究及部分医疗场景的演示与二次开发。压缩包内共1个PDF文件约126KB完整收录了主程序代码与实现说明涵盖摄像头帧捕获、灰度转换、Haar级联面部与眼睛检测、眼球关键点估计及眨眼判断等核心逻辑并借助PyQt5构建GUI界面实时刷新显示结果。代码结构清晰process模块将检测算法与界面逻辑分离便于读者按需替换检测模型或调整阈值参数。目前已有3108人学习下载配套B站演示视频可直观对照运行效果适合作为OpenCV与GUI编程的实践范例帮助读者快速理解眼动追踪的完整实现链路并在此基础上扩展功能。1. 用普通摄像头跑实时眼动追踪这套 PythonOpenCV 示例到底能落地到什么程度很多人第一次听到「眼动追踪」脑子里浮现的是 Tobii 那种动辄上万的专用红外设备。但这套 PythonOpenCV 示例代码走的是另一条路一个普通 USB 摄像头加上 Haar 级联检测和 SimpleBlobDetector就能在 PyQt5 界面上实时标出瞳孔位置。我第一次跑通的时候盯着屏幕上那两个红点确实有点意外——原来不用高端硬件也能把这件事做出来。这套代码适合谁如果你正在做人机交互原型、注意力检测的课程设计或者单纯想找一个把 OpenCV 和 PyQt5 串起来的完整项目练手它是个不错的起点。它不涉及深度学习模型不依赖 GPU整个流程从人脸检测到眼睛区域裁剪再到瞳孔 blob 提取每一步都能在代码里看到明确的输入输出。但也要说清楚边界它的精度受光照和头部姿态影响很大不能拿去做医疗级或科研级测量。下面我从代码结构开始拆把每个模块的参数含义、调参逻辑和实际踩过的坑都过一遍。2. 拆解 process 模块Haar 检测 Blob 提取的完整链路2.1 init_cv 里三个检测器的选型理由process.init_cv()返回三个对象人脸检测器、眼睛检测器、blob 检测器。前两个用的是 OpenCV 自带的 Haar 级联 XML 文件第三个是SimpleBlobDetector。为什么不用 Dlib 的 HOG 或者 MTCNN因为这套代码的目标是「轻量 可解释」Haar 级联在 CPU 上单帧检测耗时大约 30-50ms对于 640×480 的输入基本能跑到 15-20 FPS够用。def init_cv(): 加载所有 OpenCV 工具 face_detector cv2.CascadeClassifier( os.path.join(Classifiers, haar, haarcascade_frontalface_default.xml)) eye_detector cv2.CascadeClassifier( os.path.join(Classifiers, haar, haarcascade_eye.xml)) detector_params cv2.SimpleBlobDetector_Params() detector_params.filterByArea True detector_params.maxArea 1500 detector cv2.SimpleBlobDetector_create(detector_params) return face_detector, eye_detector, detector这里有个容易忽略的点filterByArea True配合maxArea 1500意思是只保留面积小于 1500 像素的连通区域。瞳孔在眼睛裁剪图里通常只占几十到几百像素这个上限是为了过滤掉大面积阴影或反光。但如果你把摄像头分辨率调高比如 1280×720眼睛区域变大1500 可能就不够用了需要按比例放大。2.2 detect_face 的多脸处理与眼睛估计区域detect_face里有个实用逻辑当画面中出现多张脸时只取最大的那个。这在实际场景中很合理——通常离摄像头最近的人才是操作者。coords cascade.detectMultiScale(img, 1.3, 5) if len(coords) 1: biggest (0, 0, 0, 0) for i in coords: if i[3] biggest[3]: biggest i biggest np.array([i], np.int32)detectMultiScale的两个关键参数scaleFactor1.3表示每次图像尺寸缩小 1.3 倍minNeighbors5表示一个候选框至少被检测到 5 次才确认为人脸。这两个值调大能减少误检但会漏检调小则相反。我一般会在光线稳定的室内把minNeighbors降到 4让侧脸也能被捕捉到。检测到人脸后代码用比例估算左右眼的搜索区域lest (int(w * 0.1), int(w * 0.45)) # 左眼水平范围 rest (int(w * 0.55), int(w * 0.9)) # 右眼水平范围这是基于人脸几何先验的硬编码。左眼在人脸框宽度 10%-45% 的位置右眼在 55%-90%。这个假设在正脸时成立但头部旋转超过 15 度就会偏。如果你要做头部姿态鲁棒的眼动追踪这一步需要换成基于面部关键点的动态估计。2.3 detect_eyes 的鼻孔过滤与眉毛裁剪detect_eyes里用眼睛中心点的水平坐标来判断检测到的是左眼、右眼还是鼻孔eyecenter int(float(x) (float(w) / float(2))) if lest[0] eyecenter and eyecenter lest[1]: leftEye img[y:y h, x:x w] leftEyeG img_gray[y:y h, x:x w] leftEye, leftEyeG cut_eyebrows(leftEye, leftEyeG) elif rest[0] eyecenter and eyecenter rest[1]: rightEye img[y:y h, x:x w] rightEyeG img_gray[y:y h, x:x w] rightEye, rightEye cut_eyebrows(rightEye, rightEyeG) else: pass # 鼻孔丢弃haarcascade_eye.xml有个老毛病它会把鼻孔也当成眼睛检测出来。用水平位置过滤是最简单的解法。另外cut_eyebrows直接砍掉眼睛区域顶部 15 像素因为眉毛在灰度图里和瞳孔一样暗不裁掉的话 blob 检测会同时抓到眉毛和瞳孔。def cut_eyebrows(img, imgG): height, width img.shape[:2] img img[15:height, 0:width] # 裁掉顶部 15 像素 imgG imgG[15:height, 0:width] return img, imgG15 这个数字不是万能的。如果摄像头架得高、人脸在画面中偏小眉毛可能不在顶部 15 像素内反过来如果脸很大15 像素又裁得不够。常见做法是根据眼睛区域高度动态计算裁剪比例比如裁掉height * 0.2。2.4 process_eye 的形态学操作链与 blob 面积过滤这是整个流程里最核心的一步从灰度眼睛图中提取瞳孔位置。def process_eye(img, threshold, detector, prevAreaNone): _, img cv2.threshold(img, threshold, 255, cv2.THRESH_BINARY) img cv2.erode(img, None, iterations2) img cv2.dilate(img, None, iterations4) img cv2.medianBlur(img, 5) keypoints detector.detect(img) if keypoints and prevArea and len(keypoints) 1: tmp 1000 for keypoint in keypoints: if abs(keypoint.size - prevArea) tmp: ans keypoint tmp abs(keypoint.size - prevArea) keypoints np.array(ans) return keypoints操作顺序是二值化 → 腐蚀 2 次 → 膨胀 4 次 → 中值滤波 5×5 → blob 检测。腐蚀去掉噪点膨胀把瞳孔区域连成一片中值滤波平滑边缘。threshold参数由 GUI 上的滑块控制默认值通常在 50-70 之间。阈值太高会把瞳孔也滤掉太低则眼白和阴影全变成白色。prevArea是上一帧的瞳孔面积用来在多个 blob 中选出最可能是瞳孔的那个。这个逻辑假设瞳孔在连续帧之间面积变化不大对于眨眼或快速转头的情况会短暂失效但下一帧通常能恢复。3. PyQt5 主循环从摄像头帧到界面显示的完整实现3.1 start_webcam 的 CAP_DSHOW 与定时器间隔def start_webcam(self): if not self.camera_is_running: self.capture cv2.VideoCapture(cv2.CAP_DSHOW) if self.capture is None: self.capture cv2.VideoCapture(0) self.camera_is_running True self.timer QTimer(self) self.timer.timeout.connect(self.update_frame) self.timer.start(2)cv2.CAP_DSHOW是 Windows 上的 DirectShow 后端。代码注释里提到VideoCapture(0)有时会报#-1072875772错误这是 Windows 上 OpenCV 的经典坑——默认的 MSMF 后端在某些摄像头驱动下会初始化失败。用 DSHOW 能绕过去。如果你在 Linux 上跑这个参数要换成cv2.CAP_V4L2或者直接去掉。timer.start(2)表示每 2ms 触发一次update_frame。但实际帧率受限于摄像头采集速度和图像处理耗时不是真的 500 FPS。这个值设小是为了让界面尽可能流畅真正的节流在capture.read()那里——它没准备好新帧时会阻塞。3.2 update_frame 的处理流水线update_frame是主循环每帧做这些事def update_frame(self): _, base_image self.capture.read() self.display_image(base_image) processed_image cv2.cvtColor(base_image, cv2.COLOR_RGB2GRAY) face_frame, face_frame_gray, left_eye_estimated_position, \ right_eye_estimated_position, _, _ process.detect_face( base_image, processed_image, self.face_decector) if face_frame is not None: left_eye_frame, right_eye_frame, left_eye_frame_gray, \ right_eye_frame_gray process.detect_eyes( face_frame, face_frame_gray, left_eye_estimated_position, right_eye_estimated_position, self.eye_detector) # 右眼处理 if right_eye_frame is not None: if self.rightEyeCheckbox.isChecked(): right_eye_threshold self.rightEyeThreshold.value() right_keypoints, self.previous_right_keypoints, \ self.previous_right_blob_area self.get_keypoints( right_eye_frame, right_eye_frame_gray, right_eye_threshold, previous_areaself.previous_right_blob_area, previous_keypointself.previous_right_keypoints) process.draw_blobs(right_eye_frame, right_keypoints) right_eye_frame np.require(right_eye_frame, np.uint8, C) self.display_image(right_eye_frame, windowright) # 左眼处理逻辑对称略注意cv2.cvtColor(base_image, cv2.COLOR_RGB2GRAY)这里写的是 RGB2GRAY但 OpenCV 的VideoCapture.read()返回的是 BGR 格式。严格来说应该用COLOR_BGR2GRAY。不过灰度转换的权重差异很小实际影响可以忽略。如果你发现灰度图颜色不对检查一下这里。get_keypoints封装了「检测失败时沿用上一帧结果」的逻辑def get_keypoints(self, frame, frame_gray, threshold, previous_keypoint, previous_area): keypoints process.process_eye(frame_gray, threshold, self.detector, prevAreaprevious_area) if keypoints: previous_keypoint keypoints previous_area keypoints[0].size else: keypoints previous_keypoint return keypoints, previous_keypoint, previous_area这个「后悔药」机制在瞳孔被遮挡或阈值突变时很有用能避免红点闪烁。但副作用是如果眼睛真的移开了红点还会停留在上一帧的位置。所以previous_keypoint最好加一个超时重置比如连续 10 帧检测失败就清空。3.3 display_image 的 QImage 格式转换OpenCV 的 numpy 数组要显示在 Qt 控件上必须转成QImagedef display_image(self, img, windowmain): qformat QImage.Format_Indexed8 if len(img.shape) 3: if img.shape[2] 4: qformat QImage.Format_RGBA8888 else: qformat QImage.Format_RGB888 out_image QImage(img, img.shape[1], img.shape[0], img.strides[0], qformat) out_image out_image.rgbSwapped() # BGR - RGB if window main: self.baseImage.setPixmap(QPixmap.fromImage(out_image)) self.baseImage.setScaledContents(True) # left / right 窗口同理img.strides[0]是每行字节数OpenCV 的图像通常连续存储直接用没问题。rgbSwapped()把 BGR 转成 RGB否则显示出来颜色会偏蓝。setScaledContents(True)让图像自适应控件大小但会拉伸变形——如果你在意宽高比需要自己算缩放比例。4. 避坑与排查Haar 检测翻车现场和参数调优记录4.1 摄像头打开报错 -1072875772现象cv2.VideoCapture(0)返回的 capture 对象isOpened()为 False控制台报错码-1072875772。原因Windows 上 OpenCV 默认使用 MSMF 后端部分 USB 摄像头驱动不兼容。解决改用cv2.VideoCapture(cv2.CAP_DSHOW)。如果 DSHOW 也不行试试cv2.CAP_MSMF或者把摄像头拔了重插换一个 USB 口。代码里已经做了 fallback但 fallback 到VideoCapture(0)其实还是同一个后端真正要改的是后端参数。4.2 瞳孔红点乱跳或同时标出多个点现象眼睛窗口里出现两个以上的红点或者红点在瞳孔和眉毛之间来回跳。原因maxArea1500太大把眉毛或眼影也当成了 blob或者cut_eyebrows的 15 像素裁剪不够。解决先把maxArea降到 500 试试观察瞳孔面积实际是多少。可以在process_eye里加一行print([kp.size for kp in keypoints])看每帧检测到的 blob 面积。如果眉毛的面积和瞳孔接近说明裁剪不够把cut_eyebrows的 15 改成int(height * 0.25)。4.3 阈值滑块调了没反应现象拖动 GUI 上的阈值滑块眼睛窗口的二值化图像没有变化。原因rightEyeThreshold.value()返回的是整数但process_eye里的cv2.threshold要求阈值在 0-255 之间。如果滑块范围设成了 0-100实际生效的阈值就偏小。解决检查.ui文件里滑块控件的minimum和maximum属性确保范围覆盖 0-255。另外阈值是每帧从控件读取的如果update_frame里没有重新读取改了也不会生效——代码里是在if self.rightEyeCheckbox.isChecked():分支内读取的确认这个分支被执行到。4.4 帧率低、界面卡顿现象视频画面明显延迟拖动窗口时程序无响应。原因update_frame在主线程里做图像处理Haar 检测耗时较长时会阻塞 Qt 事件循环。解决把图像处理放到QThread里通过信号槽把处理结果传回主线程显示。另一个简单办法是降低处理分辨率——在detect_face之前把base_imageresize 到 320×240检测完再把坐标映射回原尺寸。这样 Haar 检测耗时能降到原来的四分之一。4.5 戴眼镜时瞳孔检测失效现象不戴眼镜时红点正常戴上眼镜后瞳孔区域出现大片白色反光blob 检测不到瞳孔。原因镜片反光在灰度图里亮度很高二值化后和瞳孔一样是白色但形状不规则blob 检测器无法识别为圆形区域。解决这种情况 Haar Blob 的方案基本无解需要考虑红外摄像头或者基于深度学习的方法。如果只是轻微反光可以尝试调整光源角度让反光点偏离瞳孔位置。另一个思路是在process_eye里加一个圆形度过滤SimpleBlobDetector_Params有filterByCircularity参数设成 0.7 左右能过滤掉不规则的反光区域。5. 进阶调参让瞳孔定位在复杂光照下也稳得住5.1 自适应阈值替代固定阈值固定阈值在光照变化时很脆弱。一个实用的改进是用cv2.adaptiveThreshold替代cv2.thresholdimg cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2)blockSize11是邻域大小C2是从均值中减去的常数。这样每个像素的阈值由它周围的像素决定对光照渐变更鲁棒。代价是计算量增加但在 320×240 的眼睛区域上完全可以接受。5.2 用卡尔曼滤波平滑瞳孔轨迹如果要做注视点估计瞳孔坐标的抖动会直接传递到最终结果。一个简单的卡尔曼滤波能显著改善kalman cv2.KalmanFilter(4, 2) kalman.measurementMatrix np.array([[1, 0, 0, 0], [0, 1, 0, 0]], np.float32) kalman.transitionMatrix np.array([[1, 0, 1, 0], [0, 1, 0, 1], [0, 0, 1, 0], [0, 0, 0, 1]], np.float32) kalman.processNoiseCov np.eye(4, dtypenp.float32) * 0.03状态向量是[x, y, dx, dy]测量值是[x, y]。processNoiseCov控制平滑程度值越小越平滑但延迟越大。我一般从 0.03 开始调如果红点跟手但抖动明显就降到 0.01如果红点拖尾就加到 0.05。5.3 参数速查表参数位置默认值调整方向影响scaleFactordetect_face1.3减小→更慢更准人脸检测灵敏度minNeighborsdetect_face5减小→更多误检人脸检测召回率maxAreainit_cv1500减小→过滤大 blob瞳孔 blob 面积上限thresholdGUI 滑块50-70增大→更少白色区域二值化分割点erode iterationsprocess_eye2增大→噪点更少腐蚀强度dilate iterationsprocess_eye4增大→瞳孔更完整膨胀强度medianBlur ksizeprocess_eye5增大→更平滑中值滤波核大小cut_eyebrows pxcut_eyebrows15增大→裁更多眉毛裁剪高度这张表建议打印出来贴在显示器旁边。调参的顺序是先固定光照调threshold让瞳孔在二值化图里清晰可见然后调maxArea确保只检测到一个 blob最后微调形态学操作的迭代次数。每次只改一个参数改完观察至少 30 秒因为有些问题只在特定眨眼或转头时出现。从那以后我每次拿到新的摄像头或者换一个房间跑这套代码都会先花五分钟把这张表过一遍确认threshold和maxArea在当前环境下是合理的。这套 PythonOpenCV 的眼动追踪示例代码不算完美但它把从摄像头采集到瞳孔定位的完整链路摊开在你面前每一行都能改、每一个参数都能调。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?