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 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231
| """ Eyelid Angle (ELA) 指标实现
论文核心:从3D面部关键点计算上下眼睑之间的角度 相比EAR的2D距离比,ELA在头部旋转时保持稳定 """
import numpy as np from typing import Tuple, Optional
class EyelidAngle: """ 眼睑角度 (ELA) 计算 基于 MediaPipe Face Mesh 3D 关键点: - 上眼睑关键点(6个) - 下眼睑关键点(6个) ELA = 上下眼睑切线之间的角度 """ LEFT_EYE_UPPER = [33, 246, 161, 160, 159, 158] LEFT_EYE_LOWER = [145, 144, 153, 154, 155, 133] RIGHT_EYE_UPPER = [362, 398, 384, 385, 386, 387] RIGHT_EYE_LOWER = [263, 373, 374, 380, 381, 382] def __init__(self): pass def compute_ela(self, landmarks_3d: np.ndarray, eye: str = 'left') -> float: """ 计算眼睑角度 Args: landmarks_3d: (468, 3) MediaPipe 3D面部关键点 eye: 'left' 或 'right' Returns: ela_angle: 眼睑角度(度),0°=完全闭合,>30°=完全睁开 """ if eye == 'left': upper_idx = self.LEFT_EYE_UPPER lower_idx = self.LEFT_EYE_LOWER else: upper_idx = self.RIGHT_EYE_UPPER lower_idx = self.RIGHT_EYE_LOWER upper_pts = landmarks_3d[upper_idx] lower_pts = landmarks_3d[lower_idx] upper_dir = self._fit_line_direction(upper_pts) lower_dir = self._fit_line_direction(lower_pts) angle = self _angle_between_vectors(upper_dir, lower_dir) return np.degrees(angle) def _fit_line_direction(self, points: np.ndarray) -> np.ndarray: """最小二乘拟合方向向量""" center = points.mean(axis=0) centered = points - center _, _, vh = np.linalg.svd(centered) direction = vh[0] return direction def _angle_between_vectors(self, v1: np.ndarray, v2: np.ndarray) -> float: """两3D向量之间的角度(弧度)""" cos_angle = np.dot(v1, v2) / ( np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-8 ) cos_angle = np.clip(cos_angle, -1, 1) return np.arccos(cos_angle) def compute_ear_baseline(self, landmarks_2d: np.ndarray, eye: str = 'left') -> float: """ 传统 EAR 基线(对比用) EAR = (|p2-p6| + |p3-p5|) / (2 * |p1-p4|) """ if eye == 'left': p1, p2, p3, p4, p5, p6 = 33, 160, 158, 133, 153, 144 else: p1, p2, p3, p4, p5, p6 = 362, 385, 387, 263, 373, 374 pts = landmarks_2d[[p1, p2, p3, p4, p5, p6]] ear = ( np.linalg.norm(pts[1] - pts[5]) + np.linalg.norm(pts[2] - pts[4]) ) / (2 * np.linalg.norm(pts[0] - pts[3])) return ear def compute_blink_features( self, ela_sequence: np.ndarray, fps: int = 30, closing_threshold: float = 15.0, closed_threshold: float = 5.0 ) -> dict: """ 从 ELA 时序信号提取眨眼特征 Args: ela_sequence: ELA 角度序列(度),shape=(N,) fps: 帧率 closing_threshold: 开始闭眼的 ELA 阈值 closed_threshold: 完全闭眼的 ELA 阈值 Returns: blink_features: 眨眼时间特征 """ features = [] i = 0 while i < len(ela_sequence) - 1: if ela_sequence[i] < closing_threshold and ela_sequence[i-1] >= closing_threshold: blink_start = i j = i while j < len(ela_sequence) and ela_sequence[j] < closing_threshold: j += 1 if j < len(ela_sequence): closed_duration = (j - blink_start) / fps * 1000 closed_start = blink_start closed_end = j while closed_start < j and ela_sequence[closed_start] > closed_threshold: closed_start += 1 while closed_end > blink_start and ela_sequence[closed_end] > closed_threshold: closed_end -= 1 fully_closed = (closed_end - closed_start) / fps * 1000 if closed_end > closed_start else 0 reopen_start = j while reopen_start < len(ela_sequence) and ela_sequence[reopen_start] < closing_threshold: reopen_start += 1 reopen_duration = (reopen_start - j) / fps * 1000 total_duration = (reopen_start - blink_start) / fps * 1000 amplitude = closing_threshold - np.min(ela_sequence[blink_start:j+1]) features.append({ 'closing_duration': (j - blink_start) / fps * 1000, 'closed_duration': fully_closed, 'reopen_duration': reopen_duration, 'total_duration': total_duration, 'amplitude': amplitude, 'amp_vel_ratio': amplitude / ((j - blink_start) / fps + 1e-8), }) i = reopen_start else: i = j else: i += 1 if not features: return {'n_blinks': 0, 'avg_duration': 0, 'long_blink_ratio': 0} durations = [f['total_duration'] for f in features] long_blinks = sum(1 for d in durations if d > 500) return { 'n_blinks': len(features), 'avg_closing': np.mean([f['closing_duration'] for f in features]), 'avg_closed': np.mean([f['closed_duration'] for f in features]), 'avg_reopen': np.mean([f['reopen_duration'] for f in features]), 'avg_total': np.mean(durations), 'long_blink_ratio': long_blinks / len(features), 'avg_amplitude': np.mean([f['amplitude'] for f in features]), 'perclos_long': sum(1 for d in durations if d > 500) / len(ela_sequence) * fps * 100, 'features': features }
if __name__ == "__main__": np.random.seed(42) ela = EyelidAngle() landmarks = np.random.randn(468, 3) * 100 for idx in ela.LEFT_EYE_UPPER: landmarks[idx] = [0, 0.5, -5] for idx in ela.LEFT_EYE_LOWER: landmarks[idx] = [0, -0.5, -5] angle = ela.compute_ela(landmarks, eye='left') print(f"睁眼状态 ELA: {angle:.1f}°") for idx in ela.LEFT_EYE_UPPER: landmarks[idx] = [0, -0.3, -5] angle_closed = ela.compute_ela(landmarks, eye='left') print(f"闭眼状态 ELA: {angle_closed:.1f}°") n_frames = 150 ela_seq = np.full(n_frames, 30.0) ela_seq[50:60] = np.linspace(30, 2, 10) ela_seq[60:68] = 2.0 ela_seq[68:80] = np.linspace(2, 30, 12) blink = ela.compute_blink_features(ela_seq, fps=30) print(f"\n眨眼特征:") print(f" 眨眼次数: {blink['n_blinks']}") print(f" 闭眼时长: {blink['avg_closed']:.0f}ms") print(f" 重新睁眼: {blink['avg_reopen']:.0f}ms") print(f" 总时长: {blink['avg_total']:.0f}ms") print(f" 长眨眼比: {blink['long_blink_ratio']:.1%}")
|