1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
| import numpy as np from typing import Tuple
def calculate_ela( upper_eyelid_3d: np.ndarray, lower_eyelid_3d: np.ndarray, eye_corner_left_3d: np.ndarray, eye_corner_right_3d: np.ndarray ) -> float: """ ELA (Eyelid Angle) 计算 - 基于 3D 面部关键点(论文 arXiv 2511.19519 的几何定义)
核心思想:上、下眼睑各拟合一个平面,计算两平面法线的 arccos 夹角 睁眼时角度大,闭眼时角度趋近于0
Args: upper_eyelid_3d: 上眼睑关键点 3D 坐标, shape=(N, 3) lower_eyelid_3d: 下眼睑关键点 3D 坐标, shape=(N, 3) eye_corner_left_3d: 左眼角 3D 坐标, shape=(3,) eye_corner_right_3d: 右眼角 3D 坐标, shape=(3,)
Returns: ela: 眼睑角度(弧度),范围 [0, π/2]
Notes: - 使用 MediaPipe Face Landmark(Face Mesh V2 + BlazeFace)的 3D 关键点 - 论文每片眼睑取 7 个关键点拟合平面;本示例用 3 点做最小可运行演示, N≥3 时 SVD 平面拟合同样成立 - 关键点索引(左眼): 上眼睑: 159, 145, 153 下眼睑: 23, 27, 25 眼角: 33 (左), 133 (右) - 右眼对应索引镜像 """ def fit_plane_normal(points_3d): """拟合3D点集所在平面的法线向量""" centroid = np.mean(points_3d, axis=0) centered = points_3d - centroid _, _, vh = np.linalg.svd(centered) normal = vh[-1] return normal
upper_dir = fit_plane_normal(upper_eyelid_3d) lower_dir = fit_plane_normal(lower_eyelid_3d)
cos_angle = np.dot(upper_dir, lower_dir) / ( np.linalg.norm(upper_dir) * np.linalg.norm(lower_dir) ) cos_angle = np.clip(cos_angle, -1.0, 1.0) angle_rad = np.arccos(cos_angle) return angle_rad
def calculate_ela_mediapipe(face_mesh_result): """ 使用 MediaPipe Face Mesh 结果计算 ELA Args: face_mesh_result: MediaPipe FaceMesh 输出 Returns: ela_left: 左眼 ELA ela_right: 右眼 ELA """ landmarks = face_mesh_result.multi_face_landmarks[0].landmark def get_3d_points(indices): return np.array([ [landmarks[i].x, landmarks[i].y, landmarks[i].z] for i in indices ]) LEFT_UPPER = [159, 145, 153] LEFT_LOWER = [23, 27, 25] LEFT_CORNER_L = 33 LEFT_CORNER_R = 133 RIGHT_UPPER = [386, 374, 380] RIGHT_LOWER = [253, 257, 255] RIGHT_CORNER_L = 362 RIGHT_CORNER_R = 263 upper_left = get_3d_points(LEFT_UPPER) lower_left = get_3d_points(LEFT_LOWER) corner_l = np.array([landmarks[LEFT_CORNER_L].x, landmarks[LEFT_CORNER_L].y, landmarks[LEFT_CORNER_L].z]) corner_r = np.array([landmarks[LEFT_CORNER_R].x, landmarks[LEFT_CORNER_R].y, landmarks[LEFT_CORNER_R].z]) ela_left = calculate_ela(upper_left, lower_left, corner_l, corner_r) upper_right = get_3d_points(RIGHT_UPPER) lower_right = get_3d_points(RIGHT_LOWER) corner_l_r = np.array([landmarks[RIGHT_CORNER_L].x, landmarks[RIGHT_CORNER_L].y, landmarks[RIGHT_CORNER_L].z]) corner_r_r = np.array([landmarks[RIGHT_CORNER_R].x, landmarks[RIGHT_CORNER_R].y, landmarks[RIGHT_CORNER_R].z]) ela_right = calculate_ela(upper_right, lower_right, corner_l_r, corner_r_r) return ela_left, ela_right
if __name__ == "__main__": upper = np.array([[0, 0.3, 0], [0.5, 0.35, 0], [1.0, 0.3, 0]], dtype=float) lower = np.array([[0, -0.3, 0], [0.5, -0.35, 0], [1.0, -0.3, 0]], dtype=float) cl = np.array([0, 0, 0], dtype=float) cr = np.array([1.0, 0, 0], dtype=float) ela_open = calculate_ela(upper, lower, cl, cr) print(f"睁眼 ELA: {np.degrees(ela_open):.1f}°") upper_closed = np.array([[0, 0.05, 0], [0.5, 0.05, 0], [1.0, 0.05, 0]], dtype=float) lower_closed = np.array([[0, -0.05, 0], [0.5, -0.05, 0], [1.0, -0.05, 0]], dtype=float) ela_closed = calculate_ela(upper_closed, lower_closed, cl, cr) print(f"闭眼 ELA: {np.degrees(ela_closed):.1f}°") theta = np.radians(30) R_yaw = np.array([ [np.cos(theta), 0, np.sin(theta)], [0, 1, 0], [-np.sin(theta), 0, np.cos(theta)] ]) upper_rot = upper @ R_yaw.T lower_rot = lower @ R_yaw.T ela_yaw = calculate_ela(upper_rot, lower_rot, cl @ R_yaw.T, cr @ R_yaw.T) print(f"偏航30° ELA: {np.degrees(ela_yaw):.1f}° (变化: {abs(np.degrees(ela_yaw - ela_open)):.2f}°)")
|