1.0 简介:推断用户视线与注意力的挑战
理解用户的注意力焦点是开发智能人机交互(HCI)系统、驾驶员监控应用和用户行为分析的关键组成部分。头部姿态——即头部在三维空间中的朝向——是衡量注意力的重要指标。通过判断用户是直视前方、侧视还是低头,我们可以推断其是否专注于主屏幕、分心或正在查看手机等次要设备。
本文全面分析了如何利用OpenCV的cv2.solvePnP
函数从二维面部特征点计算头部姿态。最终目标是建立一种可靠的方法论,用于区分两种常见状态:用户注视电脑屏幕与用户低头查看手机。
2.0 解构头部姿态估计的solvePnP流程
核心在于cv2.solvePnP
函数。该函数用于解决经典的计算机视觉任务——透视n点(PnP)问题。PnP问题的目标是根据已知物体上一组3D点及其在图像中对应的2D投影,计算出已标定相机相对于该3D物体的位置和方向(统称为"位姿")。在这个场景中,"物体"指代的是人的头部,而"相机"则是网络摄像头。
solvePnP
的输出是一个旋转向量(rvec)和一个平移向量(tvec)。这两个向量精确描述了3D模型点如何通过变换才能匹配它们在相机视角中观察到的2D位置。
2.1. 输入参数分析
为了实现这一点,solvePnP需要几个关键输入。
2.1.1. model_points: 3D人脸模型
model_points
numpy数组定义了标准直立姿势下面部标志点的3D坐标。这是面部的"理想"3D模型。
model_points = np.array([
[0.0, 0.0, 0.0], # 鼻尖
[-30.0, -30.0, -30.0], # 左眼
[30.0, -30.0, -30.0], # 右眼
[-25.0, 30.0, -30.0], # 左嘴角
[25.0, 30.0, -30.0], # 右嘴角
])
分析与批判:
定义了一个简单的对称3D模型,鼻尖位于原点(0,0,0)。虽然这提供了基本的几何关系,但其准确性值得商榷。这些坐标似乎是为了方便而选择的,而非基于解剖学现实。例如,眼睛和嘴角都被放置在同一Z平面(-30.0毫米)上,这并不能反映真实人脸的三维曲率。推荐:
为提高精确度,三维模型点应源自真实世界数据。研究强烈建议采用平均三维人脸模型,该模型通过整合数千次三维面部扫描数据构建而成。文献中提及的一个典型案例是基于350次面部激光扫描建立的模型,可提供21个关键标志点的坐标。例如,一套更符合解剖学原理的坐标点集可能如下所示(单位:毫米):鼻尖坐标:(0.0, 0.0, 0.0)
下巴坐标:(0.0, -330.0, -65.0)(注:该数值可能偏大,可能是源数据笔误或采用不同坐标系。常见值更接近(0.0, 60.0, -50.0),具体取决于坐标系设定)
左眼左角坐标:(6.825897, 6.760612, 4.402142)
右眼右角坐标:(-6.825897, 6.760612, 4.402142)
左嘴角坐标:(5.311432, -2.485128, 4.373994)
右嘴角坐标:(-5.311432, -2.485128, 4.373994)
使用经过验证的3D模型是确保最终姿态角度与实际头部旋转具有对应意义的最关键步骤之一。
2.1.2. image_points: 检测到的二维特征点
该数组包含二维视频帧中检测到的相同面部特征点的(x, y)像素坐标。特征点检测器(如Dlib、MediaPipe)的精度至关重要,因为这些坐标的误差将直接传递到姿态计算中。
2.1.3. 相机矩阵:相机的内参属性
相机矩阵将三维点从相机坐标系映射到二维图像平面。它包含焦距(fx, fy)和光心(cx, cy)参数。
h, w = img_shape[:2]
focal_length = w
center = (w / 2, h / 2)
camera_matrix = np.array([
[focal_length, 0, center[[0]],
[0, focal_length, center[[1]],
[0, 0, 1]
], dtype='double')
分析与批判:
该实现采用了一种常见的启发式方法:焦距近似为图像宽度,光心假定为图像中心。在没有标定数据时,这是一种合理的猜测,但不够精确。像流行的罗技C920这样的消费级网络摄像头具有特定的光学特性,其真实焦距并非简单的图像宽度。光学中心也可能与几何中心存在轻微偏移。建议:
在需要可靠姿态估计的任何应用中,显式相机标定至关重要。这是一次性过程,通过使用已知图案(如棋盘格)来计算特定分辨率下特定网络摄像头的精确相机矩阵和畸变系数。搜索结果提供了罗技网络摄像头的实验性参数,但数值存在差异。对于1920×1080分辨率的罗技C920,某数据源测得焦距约为fx=1384.6和fy=1382.4像素;而另一组未标明分辨率的C920标定数据则显示相机矩阵为fx=598.36和fy=600.76。这种差异表明,即便是同型号相机,每台设备都应单独标定以获得最佳效果。使用这些标定值而非经验值,将显著提升solvePnP函数的计算精度。
2.1.4. dist_coeffs: 镜头畸变
该向量可校正导致图像中直线出现弯曲的镜头缺陷(径向畸变和切向畸变)。
dist_coeffs = np.zeros((4, 1))
分析与批判:
代码假设镜头畸变为零。这对消费级摄像头几乎不成立,这类设备通常存在明显的桶形或枕形畸变。忽略此因素会导致图像点的感知位置不准确,进而影响姿态计算的精度。建议:
畸变系数应与相机矩阵在同一标定过程中获取。某次对罗技C920的实验标定报告显示其畸变系数为[0.2460, -1.8737, -0.0023, -0.0043, 5.4119]。应用这些校正参数可使solvePnP算法基于更精确的无畸变图像点坐标进行计算。
2.1.5. flags
: PnP算法的选择
OpenCV 提供了多种算法来解决 PnP 问题。
flags=cv2.SOLVEPNP_EPNP
- 分析与批评:
这是一个极佳的选择。EPnP(高效PnP)是一种非迭代方法,以其高精度和计算效率著称,尤其在采用4个或更多点时表现优异。该方法对噪声具有较强鲁棒性,通常被推荐用于此类应用场景。其他选项如SOLVEPNP_ITERATIVE同样可行,但可能对初始猜测值更为敏感。
3.0 从旋转向量到可解释的头部姿态
solvePnP 的原始输出(rvec 和 tvec)并不直观。通过多个步骤将其转换为欧拉角(俯仰角、偏航角、翻滚角),这是一种更易理解的姿态表示方式。
- Pitch: Rotation around the X-axis (looking up/down). Positive values typically mean looking down.
- Yaw: Rotation around the Y-axis (looking left/right).
- Roll: Rotation around the Z-axis (tilting the head side-to-side).
头部姿态估计——在solvePnP之后输出欧拉角_cv2.solvepnp 获取角度-CSDN博客
3.1. 角度转换过程分析
rotation_mat, _ = cv2.Rodrigues(rotation_vector)
pose_mat = cv2.hconcat((rotation_mat, translation_vector))
_, _, _, _, _, _, euler_angles = cv2.decomposeProjectionMatrix(pose_mat)
yaw = euler_angles[1, 0]
pitch = euler_angles[0, 0]
roll = euler_angles[2, 0]
分析与批评:
这种方法有效但非常规,可能存在潜在问题。cv2.Rodrigues
函数能正确将紧凑的3x1旋转向量转换为完整的3x3旋转矩阵。然而后续使用cv2.decomposeProjectionMatrix
的操作较为复杂——该函数本意是用于分解完整的3x4投影矩阵为相机矩阵、旋转矩阵、平移向量等组成部分。虽然它能输出欧拉角,但可能返回多个解集,且其主要功能并非单纯用于角度提取。建议:
获取欧拉角更标准直接的方法是从cv2.Rodrigues
返回的3x3旋转矩阵直接计算。虽然OpenCV没有提供单行函数实现,但其数学公式已非常成熟。cv2.RQDecomp3x3
函数与此相关,但最常用方法是基于旋转矩阵元素进行三角函数运算。这种方法可避免decomposeProjectionMatrix
可能产生的歧义。
这提供了从旋转矩阵到所需欧拉角的更直接可靠的转换方式。
4.0 区分手机观看与屏幕观看
拥有了可靠的头部姿态角度计算流程后,我们现在可以着手解决核心目标:判断用户是在看手机还是看屏幕。研究的关键发现在于,这两种行为会产生具有统计学差异的头部姿态分布,尤其体现在俯仰角上。
4.1. 差异化凝视模式
- 手机浏览:该行为会持续迫使用户低头。研究表明,手机使用与俯仰角正向偏移及头颈部弯曲度显著增加存在关联。
- 电脑屏幕观看:在观看台式电脑或智能电视时,屏幕通常位于或略低于眼睛水平线。这导致头部俯仰角度接近零度,甚至出现轻微负角度(轻微仰视)。
4.2. 定义普遍性门槛的挑战
尽管存在明显差异,但搜索结果在一个观点上达成一致:目前尚无任何同行评审研究能提供单一的定量俯仰角阈值来可靠区分手机与屏幕观看行为(同行评审研究报告中提及数值化俯仰角阈值...,同行评审研究报告中提及具体俯仰角阈值...)。
为什么没有“魔法数字”?
人体工学差异:具体俯仰角度取决于用户身高、椅子高度、桌面配置、屏幕位置及持握手机的方式。
设备与姿势关联:用户可能懒散地看着显示器或将手机高举,模糊了设备间的界限。
任务依赖性:用户可能前倾(增加正向俯角)近距离查看显示器,模拟手机浏览姿态。
有研究在视线估计中提到40°的经验阈值,但这仅用于决定何时用头部姿态替代视线追踪,而非区分设备。其他研究报道了单情境下的观察值(如使用智能手机时头部前倾33°-45°),但并非对比阈值。
4.3. 阈值确定的实证策略
由于不存在通用的阈值,一个稳健的系统必须基于针对其特定用例通过经验得出的数值。推荐策略如下:
实施最佳实践:
使用经过验证的3D平均人脸模型作为model_points
。执行一次性相机标定,为您的摄像头和分辨率获取精确的camera_matrix
和dist_coeffs
。采用标准方法从旋转矩阵中提取欧拉角。受控数据收集:在两种明确界定的不同条件下记录多位用户的短视频片段
条件A(屏幕观看):用户以正常坐姿,观看位于中央的电脑显示器。
条件B(手机观看):同一批用户坐在同一位置,低头观看放在膝盖上或桌面上的手机。数据处理与分析:
对所有收集到的视频帧运行改进后的头部姿态估计脚本。
提取并存储每帧计算得到的俯仰角。
绘制两张俯仰角的直方图或密度图,一张对应条件A,一张对应条件B。阈值选择:
# Example logic after calculating pitch LOOKING_DOWN_THRESHOLD = 20.0 # degrees, determined empirically LOOKING_STRAIGHT_YAW_THRESHOLD = 25.0 # degrees if pitch > LOOKING_DOWN_THRESHOLD: status = "User is looking down (likely at phone)" elif abs(yaw) < LOOKING_STRAIGHT_YAW_THRESHOLD and abs(pitch) < LOOKING_DOWN_THRESHOLD: status = "User is looking at screen" else: status = "User is looking away"
- 你应该观察到两种不同的分布。“屏幕观看”的分布可能会集中在接近零的位置,而“手机观看”的分布则会集中在明显更高的正值(例如+25°至+50°)。
- 选择一个介于这两个分布之间的俯仰角值作为阈值。例如,如果屏幕观看角度的峰值在+5°,手机观看角度的峰值在+40°,那么可以设定俯仰角 >20° 作为“低头”动作的稳健分类标准。然后即可定义逻辑规则:
5.0 判断用户是否正对屏幕
在判断用户是否正对屏幕时,使用欧拉角(yaw, pitch, roll)之和作为角度分数(angle_score = abs(yaw) + abs(pitch) + abs(roll))是一种常见的方法。这种做法背后的逻辑是:当用户头部姿态接近理想状态(即正对屏幕)时,其欧拉角的绝对值会较小,因此它们的和也会较小。相反,如果用户头部姿态偏离了理想状态,某些欧拉角的绝对值会增大,导致它们的和也增大。
为什么欧拉角之和越小越好?
欧拉角的定义:
- Yaw(偏航角) :绕垂直于物体或相机的轴旋转的角度,通常以 z 轴为轴进行旋转,正值表示逆时针旋转,负值表示顺时针旋转。
- Pitch(俯仰角) :绕物体或相机的横轴旋转的角度,通常以 x 轴为轴进行旋转,正值表示向上旋转,负值表示向下旋转。
- Roll(滚转角) :绕物体或相机的纵轴旋转的角度,通常以 y 轴为轴进行旋转,正值表示向右旋转,负值表示向左旋转。
理想状态下的欧拉角:
- 当用户头部正对屏幕时,通常希望 yaw 接近 0(没有左右偏转),pitch 接近 0(没有上下倾斜),roll 接近 0(没有左右翻转)。因此,此时的欧拉角之和为 0。
偏离理想状态的影响:
- 如果用户头部有轻微的左右偏转(yaw 增大),或者上下倾斜(pitch 增大),或者左右翻转(roll 增大),这些变化都会导致欧拉角的绝对值增加,从而使得它们的和也增加。
- 因此,通过计算欧拉角的绝对值之和,可以有效地衡量用户头部姿态与理想状态之间的偏差。和越小,说明用户头部姿态越接近理想状态。
实际应用中的考虑:
- 在实际应用中,由于环境因素和用户行为的多样性,完全理想的欧拉角(0, 0, 0)可能难以达到。因此,通常会设定一个阈值,当 angle_score 小于某个值时,认为用户头部姿态良好,可以判断为正对屏幕。
6.0 结论与最终建议
cv2.solvePnP
函数是从二维图像计算三维头部姿态的强大有效工具。
模型与标定至关重要:整个系统的精度从根本上受限于三维人脸模型(model_points)和相机参数(camera_matrix, dist_coeffs)的质量。通过采用平均人脸模型数据和规范的相机标定流程来替代当前任意的三维模型和启发式相机矩阵,可显著提升代码的准确性。
角度计算方法注意事项:欧拉角提取方法虽然有效,但为了清晰性和鲁棒性,建议采用直接从3x3旋转矩阵计算欧拉角的标准方法。
不存在通用的“看手机”阈值:主要挑战不在于姿势计算本身,而在于其解读。研究证实,虽然手机和屏幕观看会产生不同的俯仰角分布,但没有单一数值阈值能普遍适用。
因此,最有效的推进路径包含双管齐下的方法:首先,通过实施解决PnP输入的最佳实践来强化技术流程;其次,通过收集特定用例数据进行实证分析,以确定能有效区分目标用户行为的可靠俯仰角阈值。将技术稳健的实现与数据驱动的决策相结合,就能构建出高效推断用户注意力的系统。
源代码:
# 欧拉角(俯仰角、偏航角、翻滚角)的获取
def estimate_pose_from_kps(kps, img_shape):
# kps: (5,2) 眼左、眼右、鼻尖、嘴左、嘴右
# 3D模型点(单位mm,假设人脸标准模型)
model_points = np.array([
[0.0, 0.0, 0.0], # 鼻尖
[-30.0, -30.0, -30.0], # 左眼
[30.0, -30.0, -30.0], # 右眼
[-25.0, 30.0, -30.0], # 左嘴角
[25.0, 30.0, -30.0], # 右嘴角
])
# 2D图像点
image_points = np.array([
kps[2], # 鼻尖
kps[0], # 左眼
kps[1], # 右眼
kps[3], # 左嘴角
kps[4], # 右嘴角
], dtype='double')
# 相机参数
h, w = img_shape[:2]
focal_length = w
center = (w / 2, h / 2)
camera_matrix = np.array([
[focal_length, 0, center[0]],
[0, focal_length, center[1]],
[0, 0, 1]
], dtype='double')
dist_coeffs = np.zeros((4, 1)) # 假设无畸变
# solvePnP,指定EPNP算法
success, rotation_vector, translation_vector = cv2.solvePnP(
model_points, image_points, camera_matrix, dist_coeffs, flags=cv2.SOLVEPNP_EPNP)
if not success:
return None, None, None
# 转欧拉角
# 将旋转向量转换为3×3旋转矩阵
rotation_mat, _ = cv2.Rodrigues(rotation_vector)
print(f"rotation_mat: {rotation_mat}")
# 将3×3旋转矩阵和3×1平移向量组合成3×4姿态矩阵
pose_mat = cv2.hconcat((rotation_mat, translation_vector))
print(f"pose_mat: {pose_mat}")
# 分解投影矩阵,只取最后一个
_, _, _, _, _, _, euler_angles = cv2.decomposeProjectionMatrix(pose_mat)
print(f"euler_angles: {euler_angles}")
yaw = euler_angles[1, 0]
pitch = euler_angles[0, 0]
roll = euler_angles[2, 0]
return yaw, pitch, roll
# 人脸质量分数计算
def calculate_face_quality(face_img, yaw, pitch, roll, frame_idx, track_id):
"""
计算人脸质量分数,综合考虑角度和清晰度
Args:
face_img: 人脸图像
yaw, pitch, roll: 角度信息
frame_idx: 帧索引(用于调试)
track_id: track ID(用于调试)
Returns:
quality_score: 质量分数(越小越好)
"""
# 1. 角度分数(越小越好)
angle_score = abs(yaw) + abs(pitch) + abs(roll)
# 2. 清晰度分数(使用拉普拉斯方差)
if face_img.size > 0:
# 转换为灰度图
if len(face_img.shape) == 3:
gray = cv2.cvtColor(face_img, cv2.COLOR_BGR2GRAY)
else:
gray = face_img
# 计算拉普拉斯方差(清晰度指标)
laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()
# 清晰度分数(方差越大越清晰,所以用负值)
clarity_score = -laplacian_var
else:
clarity_score = 0
laplacian_var = 0
# 3. 人脸大小分数(面积越大越好,所以用负值)
area_score = -face_img.shape[0] * face_img.shape[1]
# 4. 标准化分数到0-1范围
# 角度标准化:假设最大角度为90度(3个角度各30度)
max_angle = 90.0
normalized_angle = min(angle_score / max_angle, 1.0)
# 清晰度标准化:假设拉普拉斯方差范围0-2000
max_clarity = 2000.0
normalized_clarity = min(laplacian_var / max_clarity, 1.0)
normalized_clarity_score = 1.0 - normalized_clarity # 转换为越小越好
# 大小标准化:假设最大人脸面积为100x100
max_area = 100 * 100
current_area = face_img.shape[0] * face_img.shape[1]
normalized_area = min(current_area / max_area, 1.0)
normalized_area_score = 1.0 - normalized_area # 转换为越小越好
# 5. 综合分数(标准化后的加权平均)
angle_weight = 0.8 # 角度权重
clarity_weight = 0.2 # 清晰度权重
area_weight = 0.0 # 大小权重
quality_score = (angle_weight * normalized_angle +
clarity_weight * normalized_clarity_score +
area_weight * normalized_area_score)
return quality_score, {
'angle_score': angle_score,
'normalized_angle': normalized_angle,
'clarity_score': clarity_score,
'normalized_clarity': normalized_clarity_score,
'laplacian_var': laplacian_var,
'area_score': area_score,
'normalized_area': normalized_area_score,
'current_area': current_area
}