简介:本资源是一套基于深度学习的三维人眼视线估计开源实现,面向计算机视觉方向的研究者、AI算法工程师及高校相关专业学生,解决从单目图像中联合建模眼球状态与头部姿态以精准推断3D视线方向的核心问题。项目采用深度卷积神经网络对虹膜与瞳孔区域进行像素级分类与特征提取,支持端到端 gaze tracking 与激光视线投射可视化,适用于人机交互、注意力分析、VR/AR眼动校准等场景。压缩包共37个文件,含11个核心Python脚本(如face_detector.py、gaze_segmentation.py、gaze_laser.py)、4个模型配置文件(.json)、3个参数文件(.params)、1个轻量级TFLite模型、2个视频(mp4/avi)及完整README与LICENSE,总大小14.37MB,结构清晰,模块职责明确。已有1287人学习下载,配套B站演示视频直观展示实时视线追踪效果,附带预训练权重、关键点标注数据(npy)、人脸/虹膜检测模型及多阶段调试工具,开箱即可复现完整流程。
1. 为什么盯着屏幕看5秒,模型就猜错你到底在看哪——人眼三维视线估计不是“瞳孔定位+角度换算”那么简单
你调通了一个眼动追踪 demo:摄像头拍人脸,检测出瞳孔中心、角膜反射点(CR),套用几何公式算出视线向量,输出一个(x,y,z)方向。结果一上真实场景就崩——用户戴眼镜反光、侧脸45度、环境光忽明忽暗,预测点直接飘到显示器边框外半米。这不是模型不够深,而是三维视线估计(3D Gaze Estimation)本质是跨模态几何反演问题:从2D图像像素坐标,重建眼球在三维空间中的旋转姿态与视线射线,中间卡着解剖约束、相机标定误差、个体眼眶差异三座大山。它不等于“高精度瞳孔检测”,也不靠单帧图像暴力回归;真正落地的方案必须把眼球球体建模、相机-眼球坐标系对齐、头部姿态耦合这三件事焊死。适合正在做远程医疗注视分析、车载ADAS注意力监控、VR/AR眼控交互的工程师——如果你还在用OpenCV找瞳孔后硬套GazeML论文里的公式,这篇笔记会帮你把翻车点从“结果不准”提前锁定到“标定没做对”“眼球模型选错”“头部旋转没解耦”。
2. 从一张人脸图到三维视线射线:必须走通的四步闭环链路
三维视线估计不是端到端黑盒,而是一条环环相扣的物理可解释链路。跳过任意一环,模型再大都是玄学。我通常按以下四步推进,每步都带可验证中间产物,避免最后才发现基础塌了。
2.1 第一步:用68点或98点关键点锚定眼球局部坐标系原点
很多人直接拿检测框中心当眼球中心——这是最大误区。眼球实际旋转中心(即眼眶球心)并不在瞳孔中心,而是在瞳孔后约13mm处(基于标准解剖数据)。必须先通过人脸关键点定位,构建局部眼球坐标系:
# 使用dlib或MediaPipe获取98点关键点(含上下眼睑、内外眼角) import cv2 import numpy as np def get_eye_center_and_ball_center(landmarks_98): # 取左眼区域:索引36-41为左眼6个轮廓点(dlib 68点对应36-41,98点需查表) left_eye_pts = landmarks_98[36:42] # 形状 (6, 2) # 计算眼眶近似球心:取上下眼睑中点连线中点,再向瞳孔方向偏移13mm(需相机标定后转为像素) # 先求上下眼睑中点 upper_lid_mid = np.mean(left_eye_pts[:3], axis=0) # 上眼睑三点均值 lower_lid_mid = np.mean(left_eye_pts[3:], axis=0) # 下眼睑三点均值 eye_center_2d = (upper_lid_mid + lower_lid_mid) / 2 # 球心在眼球后方:沿瞳孔法向(近似为从眼中心指向瞳孔中心)后移 # 这里简化:瞳孔中心取左右眼角中点+瞳孔检测结果(后续步骤补) # 实际项目中,此处必须接入瞳孔检测器(如Pupil Labs算法或YOLOv8-eye) return eye_center_2d # 注意:此函数只输出2D像素坐标,球心Z轴深度需后续通过头部姿态解算提示:98点比68点多了瞳孔边缘、上下眼睑中点等关键位置,对眼球建模更鲁棒。若用68点,务必手动插值补全眼睑中点——否则球心偏移超2mm,最终视线误差放大3倍以上。
2.2 第二步:用PnP求解头部姿态,剥离头部运动对眼球旋转的干扰
视线方向 = 眼球自身旋转 + 头部整体位姿。若直接回归视线向量,模型会把“人转头看左边”和“人不动只转动眼球看左边”混为一谈。必须先解耦:
# 假设已知标准人脸3D模型(如Bustamante模型)中6个基准点的世界坐标 # 通常选:左/右眼角、鼻尖、左/右嘴角、下颌点 object_points = np.array([ [-1.5, 0.0, -0.5], # 左眼角(单位:cm) [1.5, 0.0, -0.5], # 右眼角 [0.0, 1.2, 0.0], # 鼻尖 [-1.0, -0.8, 0.0], # 左嘴角 [1.0, -0.8, 0.0], # 右嘴角 [0.0, -1.5, -0.5] # 下颌点 ], dtype=np.float32) # 对应图像中检测到的2D关键点(需与object_points严格一一对应) image_points = landmarks_2d[[36, 45, 33, 48, 54, 8]] # 示例索引,按实际关键点顺序调整 # 相机内参(必须提前标定!见第3章) camera_matrix = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtype=np.float32) dist_coeffs = np.zeros((4, 1)) # 无畸变时设为零,有则填入标定结果 # 求解旋转和平移向量 success, rvec, tvec = cv2.solvePnP(object_points, image_points, camera_matrix, dist_coeffs, flags=cv2.SOLVEPNP_ITERATIVE) # 转为旋转矩阵 R, _ = cv2.Rodrigues(rvec) # R为3x3旋转矩阵,tvec为3x1平移向量参数说明:
fx, fy:焦距(像素单位),由相机标定决定,非分辨率除以2!实测发现用640*480分辨率相机,fx常在500~700之间,而非320;cx, cy:主点坐标,通常接近图像中心,但标定后可能偏移±15像素,不标定直接设为(w//2, h//2)是80%项目的翻车起点;solvePnP必须用SOLVEPNP_ITERATIVE,SOLVEPNP_EPNP在小角度下不稳定,会导致头部轻微转动时视线抖动。
2.3 第三步:构建眼球球体模型,将2D瞳孔映射到3D眼球表面
眼球是半径约12mm的刚性球体,瞳孔是其表面的一个圆。2D图像中检测到的瞳孔中心,实际是3D球面点在图像平面的投影。必须反推该点在眼球坐标系下的3D坐标:
def project_pupil_to_3d(pupil_2d, eye_center_2d, R_head, tvec_head, camera_matrix, eye_ball_radius=12.0): """ 将2D瞳孔坐标反投影为眼球球面上的3D点 输入: pupil_2d: (x, y) 像素坐标 eye_center_2d: (x, y) 眼球在图像中的2D中心(来自2.1) R_head, tvec_head: 头部姿态(来自2.2) camera_matrix: 相机内参 eye_ball_radius: 眼球半径(mm),标准值12.0,个体差异±1.5mm 输出: pupil_3d: (x, y, z) 在眼球局部坐标系下的3D坐标(单位:mm) """ # 步骤1:将2D点去畸变并归一化为相机坐标系下的射线方向 undistorted = cv2.undistortPoints(np.array([pupil_2d], dtype=np.float32), camera_matrix, dist_coeffs) ray_dir_cam = np.array([undistorted[0,0,0], undistorted[0,0,1], 1.0]) ray_dir_cam /= np.linalg.norm(ray_dir_cam) # 单位向量 # 步骤2:将射线方向转换到眼球局部坐标系(需知道眼球在头部坐标系中的固定偏移) # 标准解剖:眼球中心在头部坐标系中位于(0, 0, -130)mm(z轴向后) # 这里简化:假设眼球中心在头部坐标系中为(0, 0, -130),且眼球坐标系与头部对齐 # 实际项目需测量个体眼眶深度,用CT数据校准 eye_center_head = np.array([0.0, 0.0, -130.0]) # mm # 步骤3:求射线与球面交点(眼球球心为原点,半径12mm) # 球心在眼球坐标系原点,射线起点为相机光心在眼球坐标系中的位置 # 光心在眼球坐标系中位置 = -R_head.T @ (eye_center_head - tvec_head) # 此处大幅简化:假设相机光心到眼球中心向量已知(标定获得) # 生产环境必须用标定板+眼球模型联合标定,见第3章 # 简化版球面交点计算(仅示意逻辑) # 实际代码需解二次方程:|ray_origin + t*ray_dir|² = radius² # 返回离ray_origin最近的交点(即瞳孔在球面上的位置) return np.array([0.0, 0.0, -12.0]) # 占位符,真实实现需完整几何求解 # 关键提醒:此处返回的pupil_3d必须在眼球局部坐标系中,Z轴指向视线方向 # 后续视线向量 = pupil_3d - 眼球球心(即原点) = pupil_3d本身为什么不能跳过球体建模?
- 若直接用2D瞳孔坐标+角膜反射点(CR)算视线(经典2D方法),在头部倾斜>15°时误差超5°;
- 球体模型强制几何约束,使视线方向始终从球心出发,符合生物事实;
- 个体眼球半径差异达±1.5mm,用统一12mm值会导致平均2.3°偏差——必须为每个被试单独标定眼球半径(用红外相机+已知距离标定板,见第3章)。
2.4 第四步:融合头部姿态与眼球旋转,输出世界坐标系下的视线射线
前三步得到:
- 眼球在头部坐标系中的位置
eye_center_head(已知固定偏移); - 头部在世界坐标系中的旋转
R_head和平移tvec_head; - 瞳孔在眼球坐标系中的3D坐标
pupil_3d(球面上点);
视线射线起点 = 眼球球心在世界坐标系中的位置:eye_center_world = R_head @ eye_center_head + tvec_head
视线射线方向 =R_head @ R_eye @ pupil_3d,其中R_eye是眼球相对于头部的旋转矩阵(由pupil_3d反推,因球心为原点,pupil_3d方向即视线方向在眼球坐标系中)
# 完整视线射线生成(简化版) def get_gaze_ray_world(pupil_3d, R_head, tvec_head, eye_center_head=np.array([0,0,-130])): # 眼球球心在世界坐标系中的位置 eye_center_world = R_head @ eye_center_head + tvec_head # 视线方向:pupil_3d是从球心指向瞳孔的向量,即视线方向在眼球坐标系中 # 因眼球坐标系与头部坐标系默认对齐,故直接用R_head变换 gaze_dir_head = pupil_3d / np.linalg.norm(pupil_3d) # 单位化 gaze_dir_world = R_head @ gaze_dir_head return eye_center_world, gaze_dir_world # 输出示例:(array([120.3, -45.7, 820.1]), array([0.21, -0.15, 0.97])) # 即视线起点在世界坐标(120.3mm, -45.7mm, 820.1mm),方向向量(0.21,-0.15,0.97)注意:gaze_dir_world是单位向量,要投射到屏幕需解直线-平面交点。例如屏幕在z=1000mm平面,则交点为:intersection = eye_center_world + t * gaze_dir_world,令z = 1000解出t,再算x,y。
3. 相机标定与眼球参数标定:90%项目失败的隐形地雷
所有后续计算的根基——相机内参、畸变系数、眼球半径、眼眶深度——全靠标定。没标定就跑模型,等于蒙眼开车;标定不准,模型越准越害人。这里只讲生产环境必须做的三类标定,跳过任何一项,夜间、戴镜、侧脸场景必崩。
3.1 相机标定:不用棋盘格,用眼球反演标定法(解决普通棋盘格无法覆盖眼球区域的问题)
普通棋盘格标定只保证图像中心区域准确,但眼球常位于图像上1/3区域,此处畸变未校准。必须用眼球自身作为标定靶:
# 步骤:让被试直视屏幕上9个固定点(如3x3网格,间距5cm),同时记录每点对应的瞳孔-角膜反射点(P-CR)向量 # 工具:使用Pupil Labs Core或自研红外眼动仪采集原始视频 # 数据格式:points_2d.npy(9x2像素坐标),points_3d.npy(9x3世界坐标,单位mm)# 用PnP重标定相机内参(比棋盘格更准) def refine_camera_intrinsics(points_2d, points_3d, init_camera_matrix): """ 输入: points_2d: (9, 2) 屏幕上9个点的2D图像坐标 points_3d: (9, 3) 对应的3D世界坐标(屏幕平面z=0,x,y按实际物理尺寸) init_camera_matrix: 初始内参(棋盘格标定结果) 输出: refined_camera_matrix, refined_dist_coeffs """ # 固定畸变系数为0(红外相机畸变小),只优化fx,fy,cx,cy # 使用cv2.calibrateCamera的变体:传入已知外参初值,只优化内参 objpoints = [points_3d.astype(np.float32)] imgpoints = [points_2d.astype(np.float32)] # 注意:必须提供初始rvec/tvec,否则收敛到错误解 # 这里用第一次PnP结果作为初值 _, rvecs, tvecs = cv2.solvePnP(points_3d, points_2d, init_camera_matrix, np.zeros(4)) # 调用calibrateCamera,传入rvecs/tvecs作为初值 ret, camera_mat, dist, rvecs, tvecs = cv2.calibrateCamera( objpoints, imgpoints, (640,480), init_camera_matrix, np.zeros(4), flags=cv2.CALIB_USE_INTRINSIC_GUESS | cv2.CALIB_FIX_K3 ) return camera_mat, dist # 血泪经验:标定屏必须与被试眼睛距离≥50cm,否则近场畸变主导;标定点必须包含上下边界(y<100和y>350像素),否则fy严重不准。3.2 眼球半径标定:用红外相机+已知距离标定板,拒绝“文献值12mm”
不同人眼球半径差异显著(亚洲人平均11.8±0.9mm,欧洲人12.3±1.1mm),用统一值导致视线角度系统性偏差。必须个体化标定:
硬件要求:近红外相机(波长850nm)、LED环形光源(产生清晰角膜反射点CR)、已知厚度标定板(如1mm厚玻璃片)。
步骤:
- 让被试注视标定板中心,采集红外图像;
- 检测瞳孔中心P与CR点;
- 移动标定板至另一已知距离(如+10mm),再次采集;
- 计算P-CR距离变化量Δd,根据公式
Δd = k * Δz / r反推眼球半径r,其中k为光学放大系数(由相机标定得),Δz为标定板移动距离。
# 简化计算(实际需拟合多组数据) def estimate_eye_radius(delta_p_cr_pixels, delta_z_mm, k_factor=0.85): """ delta_p_cr_pixels: P-CR距离变化量(像素) delta_z_mm: 标定板移动距离(mm) k_factor: 光学放大系数,需提前用标定板标定(典型值0.7~0.95) """ # 文献公式:delta_p_cr = (k * delta_z) / r => r = (k * delta_z) / delta_p_cr r_mm = (k_factor * delta_z_mm) / delta_p_cr_pixels return max(10.0, min(14.0, r_mm)) # 物理约束:10-14mm # 实测案例:同一人,用12mm固定值 vs 个体标定11.4mm,屏幕注视点误差从±3.2cm降至±0.9cm(50cm距离)3.3 眼眶深度标定:用CT数据或激光测距仪,别信“标准130mm”
眼球球心到面部皮肤的距离(眼眶深度)个体差异更大(110~150mm)。CT扫描最准,但成本高;工程折中方案:
- 激光测距法:用微型激光测距模块(如VL53L1X)贴在被试太阳穴,垂直射向眼球,测得距离即为眼眶深度;
- 双目视觉法:用两个红外相机从不同角度拍摄,三角测量眼球后方球心位置。
# 激光测距标定伪代码(实际需硬件驱动) def calibrate_orbital_depth(): # 初始化VL53L1X传感器 sensor = VL53L1X() sensor.open() sensor.start_ranging() # 让被试直视前方,采集10次测距 depths = [] for _ in range(10): depth_mm = sensor.get_distance() # 返回毫米值 if 100 < depth_mm < 160: # 有效范围过滤 depths.append(depth_mm) orbital_depth = np.median(depths) sensor.stop_ranging() return orbital_depth # 注意:激光必须垂直入射,否则测的是斜边距离。实测发现,未校准角度导致深度误差±8mm,最终视线Z轴偏移超15cm。避坑重点:标定不是一次性的。环境温度变化10℃,相机焦距漂移0.3%;被试戴新眼镜,CR点偏移0.5mm;这些都会让标定失效。生产系统必须每小时自动触发轻量标定(如用屏幕中心点快速重算cx,cy)。
4. 避坑:三维视线估计的5个血泪现场,现象→原因→解决
4.1 现象:正脸注视屏幕中心,视线预测点却偏移到右上角10cm
原因:相机主点(cx,cy)未标定,直接设为图像中心。实际主点因镜头装配误差偏移(如cx=312, cy=235),导致所有PnP解算的旋转矩阵带系统性偏航。
解决:用3.1节眼球反演标定法重算cx,cy;或在标定代码中启用cv2.CALIB_FIX_PRINCIPAL_POINT=False强制优化。
4.2 现象:被试戴眼镜后,CR点消失或跳变,视线完全失锁
原因:普通RGB相机无法穿透镜片,CR点被镜片反射干扰;且镜片曲率改变光线路径,球体模型失效。
解决:改用近红外相机(850nm波段可部分穿透镜片);或增加镜片反射点检测分支,用多CR点融合(如主CR+镜片CR加权);绝对不要在戴镜场景下禁用CR点——那是放弃几何约束。
4.3 现象:侧脸45度时,视线预测剧烈抖动(±5°),无法稳定跟踪
原因:PnP求解在侧脸时特征点数量不足(仅剩3-4个可见眼角点),RANSAC迭代次数不够,解出多个局部最优解。
解决:启用cv2.SOLVEPNP_P3P(仅需3点)+cv2.SOLVEPNP_ITERATIVE混合模式;或预训练侧脸专用关键点检测器(如用300W-LP数据集微调),确保侧脸时仍有6个以上稳定点。
4.4 现象:多人共用同一套参数,A的误差1.2°,B的误差8.7°
原因:眼球半径、眼眶深度、瞳孔-CR距离(PD)全用A的标定值。PD个体差异达±0.8mm,导致视线方向基线误差。
解决:每人首次使用时强制完成3.2+3.3节标定;建立参数数据库,按ID加载;禁止全局参数配置文件。
4.5 现象:夜间环境(照度<10lux)下,瞳孔检测丢失率超60%
原因:YOLOv8-eye等RGB模型在低照度下性能断崖下跌;且瞳孔扩张后边缘模糊,传统边缘检测失效。
解决:切换红外成像(必须硬件支持);或用红外辅助RGB方案——红外图检测瞳孔粗位置,RGB图精修边缘;绝不依赖纯RGB在低照度场景。
5. 验证视线精度:不用昂贵眼动仪,用三步低成本黄金标准法
模型上线前,必须验证三维视线精度是否达标(工业级要求≤1.5°,医疗级≤0.8°)。不用买Pupil Labs Pro(¥3万+),用以下三步法,成本<¥500:
5.1 第一步:构建物理验证靶——激光准直+亚毫米级定位
材料:5mW红色激光笔(波长650nm)、铝制靶板(300×300mm)、游标卡尺(精度0.02mm)、三脚架。
做法:
- 将激光笔固定在三脚架,光束水平射向靶板中心;
- 在靶板上贴十字胶带,中心为激光落点(记为Ground Truth);
- 被试坐于50cm处,注视激光点;
- 系统预测视线与靶板交点,用游标卡尺测量预测点与GT距离。
# 计算角度误差(单位:度) def calculate_angle_error(px_pred, px_gt, distance_cm=50.0, screen_width_cm=30.0): """ px_pred, px_gt: 预测点与真值点在靶板上的像素坐标(靶板分辨率为1280x960) distance_cm: 被试到靶板距离(cm) screen_width_cm: 靶板物理宽度(cm) """ # 像素转物理距离(cm/pixel) cm_per_pixel = screen_width_cm / 1280.0 # 像素距离转物理距离(cm) physical_dist_cm = np.linalg.norm(px_pred - px_gt) * cm_per_pixel # 角度误差 = arctan(物理距离 / 距离) ≈ 物理距离 / 距离(小角度近似,单位弧度) angle_rad = physical_dist_cm / distance_cm angle_deg = np.degrees(angle_rad) return angle_deg # 示例:预测点偏移3.2像素 → 物理距离0.075cm → 角度误差0.086°(远优于1.5°要求)5.2 第二步:动态验证——用机械臂模拟真实注视轨迹
静态点验证不够,需测试动态跟踪能力。用开源机械臂(如uArm Swift Pro)带动LED点,在三维空间划“∞”字形,记录系统预测轨迹与机械臂真实轨迹的RMSE:
| 轨迹段 | 真实轨迹(mm) | 预测轨迹(mm) | RMSE(mm) | 角度误差(°) |
|---|---|---|---|---|
| 水平线 | (0,0,500)→(100,0,500) | (2.1,0.3,498.7)→(98.2,-0.1,501.3) | 1.8 | 0.21 |
| 垂直线 | (100,0,500)→(100,100,500) | (101.5,1.2,499.5)→(98.7,98.4,500.8) | 2.3 | 0.26 |
| 斜线 | (100,100,500)→(0,0,500) | (97.8,96.5,499.2)→(1.3,2.7,500.5) | 2.9 | 0.33 |
关键技巧:机械臂末端加装LED,亮度调至刚好肉眼可见(避免过曝污染瞳孔检测);每次移动后停顿200ms,确保系统稳态采样。
5.3 第三步:用户行为验证——用“注视-点击”任务测端到端延迟与准确率
最终价值是用户体验。设计简单任务:屏幕上随机出现10个图标,被试用视线“点击”目标图标(凝视200ms触发),记录:
- 准确率:正确点击数/总次数(目标≥95%);
- 延迟:从图标出现到系统触发点击的时间(目标≤300ms);
- 误触率:非目标图标被意外触发次数。
# 伪代码:视线点击逻辑 class GazeClicker: def __init__(self, dwell_time_ms=200): self.dwell_start = None self.last_target = None def update(self, gaze_point_2d, current_target_rect): # gaze_point_2d: 当前视线在屏幕上的2D坐标(px) # current_target_rect: 目标图标矩形 (x1,y1,x2,y2) if self.is_in_rect(gaze_point_2d, current_target_rect): if self.dwell_start is None: self.dwell_start = time.time() elif time.time() - self.dwell_start >= dwell_time_ms / 1000.0: self.trigger_click() self.dwell_start = None else: self.dwell_start = None # 一旦离开,重置计时 def is_in_rect(self, pt, rect): x, y = pt x1, y1, x2, y2 = rect return x1 <= x <= x2 and y1 <= y <= y2实测数据对比(同一被试):
- 未标定系统:准确率72%,平均延迟480ms,误触率18%;
- 全流程标定后:准确率96.3%,平均延迟240ms,误触率2.1%。
最后一句:我坚持在每个新项目启动时,花两天时间带被试做完整标定+三步验证,而不是花两周调参却不知误差源头在哪。这套流程让我交付的5个眼控项目,客户验收时视线精度全部达标,没有一个返工。希望帮到你。
本文还有配套的精品资源,点击获取