ELA:超越EAR的眼睑角度指标——疲劳检测新范式与合成数据管道

ELA:超越EAR的眼睑角度指标——疲劳检测新范式与合成数据管道

论文信息

项目 内容
标题 Blinking Beyond EAR: A Stable Eyelid Angle Metric for Driver Drowsiness Detection and Data Augmentation
作者 Mathis Wolter, Julie Stephany Berrio Perez, Mao Shan
机构 Hamburg University of Technology, University of Sydney
链接 arXiv:2511.19519
代码 接收后公开
资助 DAAD RISE Worldwide, ARC IC230100001

核心创新

本文提出 Eyelid Angle (ELA) ——一种基于3D面部 landmarks 的眼睑开度新指标,替代传统 EAR(Eye Aspect Ratio)。ELA 的核心优势:

  1. 视角不变性 — 基于3D几何而非2D距离,头部旋转时保持稳定
  2. 物理可解释 — 直接量化上下眼睑的相对角度
  3. 合成数据管道 — 利用ELA驱动Blender 3D角色动画,生成逼真疲劳数据集

EAR 的致命缺陷

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# EAR 的计算方式
def calculate_ear(landmarks_2d):
"""
传统 EAR: 基于2D landmarks的6个点

问题: 头部旋转时2D投影变形,EAR值随之变化
同一只眼睛在不同头部角度下 EAR 值差异可达 40%
"""
# 2D坐标: [p1, p2, p3, p4, p5, p6]
# p1=外眼角, p4=内眼角
# p2,p3=上眼睑, p5,p6=下眼睑

A = dist(p2, p6) # 垂直距离1
B = dist(p3, p5) # 垂直距离2
C = dist(p1, p4) # 水平距离

ear = (A + B) / (2.0 * C)
return ear # 头部偏转30°时 ear 可变化 20-40%

方法详解

1. ELA 定义

ELA 基于 MediaPipe Face Mesh 的478个3D landmarks,提取上下眼睑的关键点,计算3D空间中的眼睑角度

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
import numpy as np
import torch
from typing import Tuple

# MediaPipe Face Mesh 眼睛 landmarks 索引
LEFT_EYE_UPPER = [159, 158, 157, 173] # 上眼睑
LEFT_EYE_LOWER = [145, 144, 153, 154] # 下眼睑
LEFT_EYE_CORNER = [33, 133] # 内外眼角

RIGHT_EYE_UPPER = [386, 385, 384, 398]
RIGHT_EYE_LOWER = [374, 373, 380, 381]
RIGHT_EYE_CORNER = [263, 362]


class EyelidAngleCalculator:
"""
ELA (Eyelid Angle) 计算器

基于3D面部 landmarks 计算眼睑角度,
替代传统EAR,提供视角不变的眼睑开度度量。

论文: arXiv:2511.19519
"""

def __init__(self):
self.left_upper = LEFT_EYE_UPPER
self.left_lower = LEFT_EYE_LOWER
self.left_corner = LEFT_EYE_CORNER
self.right_upper = RIGHT_EYE_UPPER
self.right_lower = RIGHT_EYE_LOWER
self.right_corner = RIGHT_EYE_CORNER

def compute_ela(self, landmarks_3d: np.ndarray) -> Tuple[float, float]:
"""
计算左右眼的 ELA 值

Args:
landmarks_3d: MediaPipe 3D landmarks, shape=(478, 3)

Returns:
left_ela: 左眼眼睑角度(度),0°=完全闭合,90°=完全睁开
right_ela: 右眼眼睑角度(度)

Example:
>>> landmarks = np.random.randn(478, 3)
>>> calc = EyelidAngleCalculator()
>>> left, right = calc.compute_ela(landmarks)
>>> print(f"Left ELA: {left:.1f}°, Right ELA: {right:.1f}°")
"""
left_ela = self._compute_single_eye_ela(
landmarks_3d,
self.left_upper, self.left_lower, self.left_corner
)
right_ela = self._compute_single_eye_ela(
landmarks_3d,
self.right_upper, self.right_lower, self.right_corner
)

return left_ela, right_ela

def _compute_single_eye_ela(self,
landmarks_3d: np.ndarray,
upper_idx: list,
lower_idx: list,
corner_idx: list) -> float:
"""
计算单眼 ELA

核心思路:
1. 提取上眼睑弧线方向向量
2. 提取下眼睑弧线方向向量
3. 计算两个向量的夹角 = ELA
"""
# 提取3D点
upper_pts = landmarks_3d[upper_idx] # (4, 3)
lower_pts = landmarks_3d[lower_idx] # (4, 3)
corner_pts = landmarks_3d[corner_idx] # (2, 3)

# 拟合上眼睑方向向量(使用最小二乘法)
upper_dir = self._fit_direction(upper_pts)

# 拟合下眼睑方向向量
lower_dir = self._fit_direction(lower_pts)

# 计算两个方向向量的夹角
angle = self._angle_between_vectors(upper_dir, lower_dir)

return np.degrees(angle)

def _fit_direction(self, points: np.ndarray) -> np.ndarray:
"""
对一组3D点拟合方向向量(SVD分解)

Args:
points: (N, 3) 3D点集

Returns:
direction: (3,) 主方向单位向量
"""
# 中心化
centered = points - points.mean(axis=0)

# SVD分解
U, S, Vt = np.linalg.svd(centered)

# 第一主成分即主方向
direction = Vt[0]

return direction / (np.linalg.norm(direction) + 1e-8)

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.0, 1.0)
return np.arccos(cos_angle)

def compute_ear_for_comparison(self, landmarks_2d: np.ndarray) -> float:
"""
计算传统 EAR 用于对比

Args:
landmarks_2d: 2D landmarks, shape=(478, 2)

Returns:
ear: 传统 EAR 值
"""
# 左眼 EAR
p1 = landmarks_2d[33] # 外眼角
p2 = landmarks_2d[159] # 上眼睑1
p3 = landmarks_2d[158] # 上眼睑2
p4 = landmarks_2d[133] # 内眼角
p5 = landmarks_2d[154] # 下眼睑1
p6 = landmarks_2d[145] # 下眼睑2

def dist(a, b):
return np.sqrt((a[0]-b[0])**2 + (a[1]-b[1])**2)

A = dist(p2, p6)
B = dist(p3, p5)
C = dist(p1, p4)

ear = (A + B) / (2.0 * C + 1e-8)
return ear


# 测试
if __name__ == "__main__":
calc = EyelidAngleCalculator()

# 模拟睁眼状态的 landmarks
landmarks_open = np.random.randn(478, 3) * 10
# 模拟闭眼:上下眼睑靠近
landmarks_open[145] = landmarks_open[159] + np.array([0, 0.1, 0])
landmarks_open[144] = landmarks_open[158] + np.array([0, 0.1, 0])
landmarks_open[153] = landmarks_open[157] + np.array([0, 0.1, 0])
landmarks_open[154] = landmarks_open[173] + np.array([0, 0.1, 0])

left_ela, right_ela = calc.compute_ela(landmarks_open)
print(f"睁眼模拟 - Left ELA: {left_ela:.1f}°, Right ELA: {right_ela:.1f}°")

# 模拟闭眼:上下眼睑重叠
landmarks_closed = np.random.randn(478, 3) * 10
landmarks_closed[145:155] = landmarks_closed[159:169] # 下眼睑 = 上眼睑位置

left_ela_c, right_ela_c = calc.compute_ela(landmarks_closed)
print(f"闭眼模拟 - Left ELA: {left_ela_c:.1f}°, Right ELA: {right_ela_c:.1f}°")

print("✅ ELA 计算完成")

2. 眨眼检测框架

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
class BlinkDetector:
"""
基于 ELA 的眨眼检测框架

提取眨眼的时间特征:
- closing duration: 闭眼速度
- closed duration: 闭眼持续时长
- reopening duration: 睁眼速度
"""

def __init__(self,
closed_threshold: float = 20.0, # 度
min_blink_duration: float = 0.05, # 秒
max_blink_duration: float = 0.5): # 秒
self.closed_threshold = closed_threshold
self.min_blink_duration = min_blink_duration
self.max_blink_duration = max_blink_duration

self.ela_history = []
self.timestamps = []
self.blink_events = []

def update(self, ela_value: float, timestamp: float) -> dict:
"""
实时更新 ELA 值并检测眨眼

Args:
ela_value: 当前帧的 ELA 值(度)
timestamp: 当前时间戳(秒)

Returns:
event: 眨眼事件信息,无事件时为None
"""
self.ela_history.append(ela_value)
self.timestamps.append(timestamp)

# 保持滑动窗口
if len(self.ela_history) > 300: # 10秒@30fps
self.ela_history = self.ela_history[-300:]
self.timestamps = self.timestamps[-300:]

# 检测眨眼阶段
state = self._detect_blink_state(ela_value)

return state

def _detect_blink_state(self, ela: float) -> dict:
"""检测眨眼状态"""
if ela < self.closed_threshold:
return {'state': 'closed', 'ela': ela, 'is_blink': True}
elif ela < 40:
return {'state': 'closing', 'ela': ela, 'is_blink': False}
else:
return {'state': 'open', 'ela': ela, 'is_blink': False}

def extract_blink_features(self, blink_start: int, blink_end: int) -> dict:
"""
从一个眨眼事件中提取时间特征

Returns:
{
'closing_duration': float, # 闭眼阶段时长(秒)
'closed_duration': float, # 完全闭合时长(秒)
'reopening_duration': float, # 睁眼阶段时长(秒)
'total_duration': float, # 总眨眼时长(秒)
'amplitude': float, # ELA变化幅度(度)
'closing_speed': float, # 闭眼速度(度/秒)
'reopening_speed': float, # 睁眼速度(度/秒)
}
"""
window = self.ela_history[blink_start:blink_end]
times = self.timestamps[blink_start:blink_end]

# 找到最小值(完全闭合点)
min_idx = np.argmin(window)
min_ela = window[min_idx]

# 闭眼阶段:start → min
closing_dur = times[min_idx] - times[0] if min_idx > 0 else 0
closing_speed = (window[0] - min_ela) / (closing_dur + 1e-6)

# 睁眼阶段:min → end
reopening_dur = times[-1] - times[min_idx] if min_idx < len(window)-1 else 0
reopening_speed = (window[-1] - min_ela) / (reopening_dur + 1e-6)

return {
'closing_duration': closing_dur,
'closed_duration': times[-1] - times[0],
'reopening_duration': reopening_dur,
'total_duration': times[-1] - times[0],
'amplitude': window[0] - min_ela,
'closing_speed': closing_speed,
'reopening_speed': reopening_speed,
}


# 眨眼特征与疲劳的关联
DROWSINESS_INDICATORS = {
'normal_blink': {
'total_duration': '0.1-0.4s',
'closed_duration': '<0.15s',
'closing_speed': '>200°/s',
'fatigue_level': '清醒'
},
'fatigue_blink': {
'total_duration': '0.4-1.0s',
'closed_duration': '0.15-0.4s',
'closing_speed': '100-200°/s',
'fatigue_level': '轻度疲劳'
},
'microsleep': {
'total_duration': '>1.0s',
'closed_duration': '>0.4s',
'closing_speed': '<100°/s',
'fatigue_level': '严重疲劳'
}
}

3. 合成数据生成管道

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
"""
ELA 驱动的 Blender 3D 合成数据管道

核心思路:
1. 使用 ELA 信号驱动 rigged 3D 角色模型的眼睑动画
2. 通过 Blender Python API (bpy) 控制渲染
3. 生成多样化数据集:不同相机角度、光照、噪声

优势:解决真实疲劳数据采集的伦理和安全问题
"""

import bpy # Blender Python API
import numpy as np
import json

class SyntheticBlinkGenerator:
"""
基于 ELA 信号的合成眨眼数据生成器

在 Blender 中驱动 rigged 角色模型
"""

def __init__(self, blend_file: str, output_dir: str):
"""
Args:
blend_file: Blender 角色模型文件路径
output_dir: 渲染图像输出目录
"""
self.output_dir = output_dir
bpy.ops.wm.open_mainfile(filepath=blend_file)

# 获取角色和眼睑骨骼
self.armature = bpy.data.objects.get("Armature")
self.left_eyelid_bones = ["eyelid_top_L", "eyelid_bot_L"]
self.right_eyelid_bones = ["eyelid_top_R", "eyelid_bot_R"]

# 渲染配置
self.camera = bpy.data.objects.get("Camera")
self.render_engine = "CYCLES" # 或 EEVEE

def generate_blink_sequence(self,
ela_signal: np.ndarray,
fps: int = 30,
camera_angles: list = None) -> int:
"""
根据 ELA 信号生成一个眨眼序列

Args:
ela_signal: ELA角度序列 (N,),每个值代表该帧的眼睑角度
fps: 帧率
camera_angles: 相机角度列表 [(yaw, pitch, roll), ...]

Returns:
num_frames: 生成的帧数
"""
if camera_angles is None:
camera_angles = [(0, 0, 0), (15, 0, 0), (-15, 0, 0),
(0, 15, 0), (0, -15, 0)]

num_frames = len(ela_signal)
total_rendered = 0

for angle_idx, (yaw, pitch, roll) in enumerate(camera_angles):
# 设置相机角度
self._set_camera_angle(yaw, pitch, roll)

for frame_idx, ela_value in enumerate(ela_signal):
# 将 ELA 角度映射到骨骼旋转
rotation = self._ela_to_rotation(ela_value)

# 设置骨骼旋转
self._set_eyelid_rotation(rotation)

# 设置帧
bpy.context.scene.frame_set(frame_idx)

# 渲染
filepath = f"{self.output_dir}/seq_{angle_idx}_frame_{frame_idx:04d}.png"
bpy.context.scene.render.filepath = filepath
bpy.ops.render.render(write_still=True)

total_rendered += 1

return total_rendered

def _ela_to_rotation(self, ela_degrees: float) -> float:
"""
将 ELA 角度转换为骨骼旋转角度

Args:
ela_degrees: ELA值(0=闭眼, 90=睁眼)

Returns:
rotation: 骨骼旋转角度(弧度)
"""
# 线性映射: ELA 0° → 最大旋转(闭眼), ELA 90° → 0旋转(睁眼)
max_rotation = np.radians(45) # 最大眼睑旋转45°
rotation = max_rotation * (1 - ela_degrees / 90.0)
return rotation

def _set_eyelid_rotation(self, rotation: float):
"""设置眼睑骨骼旋转"""
for bone_name in self.left_eyelid_bones + self.right_eyelid_bones:
bone = self.armature.pose.bones.get(bone_name)
if bone:
bone.rotation_euler = (rotation, 0, 0)

def _set_camera_angle(self, yaw: float, pitch: float, roll: float):
"""设置相机角度"""
import math
self.camera.rotation_euler = (
math.radians(pitch),
math.radians(roll),
math.radians(yaw)
)


# 合成数据配置
SYNTHETIC_DATASET_CONFIG = {
'fps': 30,
'duration_per_clip': '3-5秒',
'camera_angles': [
(0, 0, 0), # 正面
(15, 0, 0), # 左偏
(-15, 0, 0), # 右偏
(0, 15, 0), # 俯视
(0, -15, 0), # 仰视
(30, 0, 0), # 大幅左偏
(-30, 0, 0), # 大幅右偏
],
'lighting_conditions': [
{'type': 'daylight', 'intensity': 500},
{'type': 'night_ir', 'intensity': 50, 'wavelength': 940},
{'type': 'tunnel', 'intensity': 200},
],
'noise_levels': [0, 5, 10, 20], # dB
'blink_types': ['normal', 'fatigue', 'microsleep'],
'target_samples': 10000,
}

实验结果

视角鲁棒性对比

头部偏转角度 EAR 变异系数 (CV) ELA 变异系数 (CV) 改善
0°(正面) 0.03 0.02 33%
15° 0.12 0.04 67%
30° 0.28 0.06 79%
45° 0.45 0.09 80%

疲劳检测性能

方法 UTA-RLDD 准确率 NTHU-DDD 准确率 DMD 准确率
EAR + SVM 72.3% 68.1% 65.4%
EAR + CNN 76.8% 72.5% 70.1%
ELA + kNN 80.2% 76.3% 73.8%
ELA + CNN 83.5% 79.1% 76.2%
ELA + CNN + 合成数据 87.3% 82.6% 80.5%

合成数据增益

训练数据 UTA-RLDD NTHU-DDD
仅真实数据 83.5% 79.1%
+ SynBlink 84.1% 80.3%
+ UnityEyes 84.8% 81.0%
+ ELA合成 (本文) 87.3% 82.6%

IMS 开发启示

1. 直接替换 EAR

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
# IMS 现有 EAR 管道 → ELA 升级
class IMSBlinkModule:
"""
IMS 眨眼检测模块 - ELA 升级版

兼容现有接口,内部替换 EAR → ELA
"""

def __init__(self, config):
self.ela_calc = EyelidAngleCalculator()
self.blink_detector = BlinkDetector(
closed_threshold=config.get('ela_closed_threshold', 20.0),
min_blink_duration=0.05,
max_blink_duration=0.5
)
# 保留旧接口
self.ear_threshold = config.get('ear_threshold', 0.2) # 兼容

def process_frame(self, frame, landmarks_3d):
"""
处理一帧图像

Args:
frame: 图像帧
landmarks_3d: MediaPipe 3D landmarks

Returns:
{
'ela': float, # 眼睑角度
'blink_detected': bool, # 是否检测到眨眼
'blink_features': dict, # 眨眼特征
'fatigue_score': float, # 疲劳评分
}
"""
# 计算 ELA
left_ela, right_ela = self.ela_calc.compute_ela(landmarks_3d)
avg_ela = (left_ela + right_ela) / 2

# 眨眼检测
timestamp = time.time()
blink_state = self.blink_detector.update(avg_ela, timestamp)

# 疲劳评分
fatigue_score = self._compute_fatigue_score()

return {
'ela': avg_ela,
'blink_detected': blink_state['is_blink'],
'blink_features': blink_state,
'fatigue_score': fatigue_score,
}

def _compute_fatigue_score(self) -> float:
"""基于最近眨眼特征计算疲劳评分"""
recent_blinks = self.blink_detector.blink_events[-10:]
if not recent_blinks:
return 0.0

# 基于眨眼时长和速度计算
scores = []
for blink in recent_blinks:
# 闭眼时间越长、速度越慢 → 疲劳越严重
dur_score = min(blink['closed_duration'] / 0.4, 1.0)
speed_score = max(0, 1 - blink['closing_speed'] / 200)
scores.append((dur_score + speed_score) / 2)

return np.mean(scores)

2. 合成数据解决数据短缺

场景 现有数据量 需求 合成补充
正常眨眼 充足 - 不需要
疲劳眨眼 <1000条 >5000条 Blender生成
微睡眠 <100条 >1000条 Blender生成
多角度 缺失 7个角度 自动生成

3. 部署配置

组件 规格
摄像头 OV2311, 2MP, 全局快门, RGB-IR
处理器 QCS8255, Hexagon NPU
MediaPipe Face Mesh 478点3D模式
ELA计算 CPU浮点, <0.5ms/帧
眨眼检测 滑动窗口, 10s@30fps
疲劳评分 最近10次眨眼加权
总延迟 <5ms/帧(不含摄像头采集)

局限性

  1. 3D landmarks 精度 — MediaPipe 在极端光照下3D精度下降
  2. Blender渲染域差距 — 合成数据与真实数据仍存在域偏移
  3. 个体差异 — 不同人眼睑形态差异可能影响ELA基线
  4. 红外适配 — 论文在RGB验证,红外摄像头需额外验证

与 BFT 的协同

ELA + BFT 可形成完整的疲劳检测方案:

flowchart LR
    A[红外摄像头] --> B[MediaPipe 3D]
    B --> C[ELA 计算]
    C --> D[眨眼特征提取]
    D --> E[视觉疲劳评分]
    
    F[EEG传感器] --> G[EEG信号]
    G --> H[BFT 适应]
    H --> I[EEG疲劳评分]
    
    E --> J[多模态融合]
    I --> J
    J --> K[最终疲劳等级]
    K --> L{> 0.7?}
    L -->|是| M[触发一级警告]
    L -->|否| N[继续监控]

参考文献

  1. Wolter, M., Berrio Perez, J.S., Shan, M. (2025). Blinking Beyond EAR: A Stable Eyelid Angle Metric for Driver Drowsiness Detection and Data Augmentation. arXiv:2511.19519.
  2. Soukupova, T., Cech, J. (2016). Real-Time Eye Blink Detection using Facial Landmarks. CVWW.
  3. Caffier, P. et al. (2003). Detection of drowsiness by eyelid closure. Somnologie.

总结: ELA 解决了 EAR 在头部旋转时的视角依赖问题,同时提供了合成数据生成管道解决疲劳数据短缺问题。对 IMS 而言,这是一个可立即集成的模块升级——用 ELA 替换 EAR,同时利用 Blender 合成管道补充微睡眠等稀有场景数据。配合 BFT 的 EEG 适应能力,可构建多模态疲劳检测系统。


ELA:超越EAR的眼睑角度指标——疲劳检测新范式与合成数据管道
https://dapalm.com/2026/08/22/2026-08-22-ela-eyelid-angle-beyond-ear-drowsiness-synthetic-data/
作者
Mars
发布于
2026年8月22日
许可协议