MediaPipe 系列 41:IMS DMS 架构——疲劳检测流水线完整实现

一、疲劳检测业务背景

1.1 为什么需要疲劳检测?

据 WHO 统计,疲劳驾驶导致的事故占高速公路致命事故的 15-20%。Euro NCAP 从 2020 年开始将 DMS(驾驶员监控系统)纳入评分体系,2025 年后疲劳检测将成为强制要求

典型疲劳表现:

  • 眼睛闭合时间延长(PERCLOS 指标)
  • 眨眼频率异常(过低或过高)
  • 打哈欠频率增加
  • 头部姿态下垂

1.2 技术路线选择

方案 优点 缺点 适用场景
穿戴设备 精度高 用户接受度低 商用车监控
方向盘传感器 成本低 间接指标 辅助验证
摄像头视觉 直观、多功能 光照敏感 乘用车主流

IMS 选择视觉方案的原因:

  1. 非接触式,用户体验好
  2. 可同时检测分心、眼动、身份识别
  3. 成本可控(单目 IR 摄像头 ~$15)

1.3 检测指标体系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌─────────────────────────────────────────────────────────────┐
│ 疲劳检测指标体系 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 一级指标(直接观测) │
│ ├── PERCLOS:眼睛闭合时间占比(核心指标) │
│ ├── 眨眼频率:每分钟眨眼次数 │
│ ├── 眨眼持续时间:单次眨眼的时长 │
│ └── 眼睛开合度:EAR (Eye Aspect Ratio) │
│ │
│ 二级指标(行为分析) │
│ ├── 打哈欠频率:每分钟打哈欠次数 │
│ ├── 头部姿态:pitch/yaw/roll 角度 │
│ ├── 注视稳定性:视线抖动程度 │
│ └── 面部表情:疲劳面部特征 │
│ │
│ 三级指标(融合决策) │
│ ├── 疲劳得分:多指标加权融合 │
│ ├── 疲劳等级:0-3 四级分类 │
│ └── 告警决策:是否触发告警 │
│ │
└─────────────────────────────────────────────────────────────┘

二、PERCLOS 原理详解

2.1 什么是 PERCLOS?

PERCLOS (Percentage of Eyelid Closure) 是 NHTSA 认证的疲劳检测金标准

定义:在指定时间窗口内,眼睛闭合超过 80% 的帧数占总帧数的百分比。

为什么 PERCLOS 是核心指标?

研究表明,PERCLOS 与疲劳程度的相关性达到 0.89(Wierwille et al., 1994),远高于其他指标。

2.2 PERCLOS 计算方法

步骤 1:计算 EAR (Eye Aspect Ratio)

1
2
3
4
5
6
7
8
9
10
11
EAR = (|P2-P6| + |P3-P5|) / (2 × |P1-P4|)

其中 P1-P6 是眼睛的 6 个关键点:
P1 ─────────── P4 (外眼角)
│ │
P2P5
│ / \ │
P3 ● ● P6 (内眼角)

- EAR ≈ 0.3-0.4:眼睛睁开
- EAR < 0.2:眼睛闭合

步骤 2:统计闭合帧数

1
2
3
4
5
6
// 滑动窗口内统计
for (int i = 0; i < window_size; i++) {
if (ear[i] < threshold) { // threshold = 0.2
closed_frames++;
}
}

步骤 3:计算 PERCLOS

1
PERCLOS = (closed_frames / total_frames) × 100%

2.3 PERCLOS 阈值设定

PERCLOS 值 疲劳等级 说明
< 15% 0 (正常) 清醒状态
15% - 30% 1 (轻度) 轻微疲劳
30% - 50% 2 (中度) 需要休息
> 50% 3 (重度) 危险,立即告警

阈值来源:

  • NHTSA 建议 PERCLOS > 40% 触发告警
  • 实际项目中根据用户反馈调优
  • Euro NCAP 测试协议中的验收标准

三、完整流水线架构

3.1 架构图

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
┌─────────────────────────────────────────────────────────────────────────┐
│ IMS DMS 疲劳检测完整流水线 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 输入层 │
│ ┌─────────────┐ │
│ │ IR Camera │ → 640×480 @ 30fps │
│ │ (Near-IR) │ 850nm 波长,消除可见光干扰 │
│ └─────────────┘ │
│ │ │
│ ▼ │
│ 检测层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │Face Detection│────▶│Face Mesh │────▶│Eye/Iris │ │
│ │(BlazeFace) │ │(468点) │ │Detection │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ROI: Face │ │Landmarks │ │Iris Centers │ │
│ │Bbox │ │[x,y,z]×468 │ │+ Eye Contour│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ 分析层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │EAR Calculator│────▶│Sliding │────▶│PERCLOS │ │
│ │ │ │Window │ │Calculator │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │EAR: 0.25 │ │Buffer: 30帧 │ │PERCLOS: 35% │ │
│ │ │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ 融合层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │Yawn │ │Head Pose │ │Blink Freq │ │
│ │Detection │ │Estimation │ │Analysis │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ └───────────────────┼───────────────────┘ │
│ ▼ │
│ ┌─────────────┐ │
│ │Fatigue │ │
│ │Aggregator │ │
│ └─────────────┘ │
│ │ │
│ ▼ │
│ 输出层 │ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ FatigueResult { │ │
│ │ fatigue_score: 0.72, │ │
│ │ fatigue_level: 2, │ │
│ │ perclos: 35.2, │ │
│ │ yawn_count: 2, │ │
│ │ alert: true │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘

3.2 数据流时序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Time: 0ms    33ms   66ms   100ms  133ms  166ms  200ms
│ │ │ │ │ │ │
Frame │──F1──│──F2──│──F3──│──F4──│──F5──│──F6──│
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
Face │──────│──────│──────│──────│──────│──────│
Det │ 2ms │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ ▼
Face │ │──────│──────│──────│──────│──────│
Mesh │ │ 5ms │ │ │ │ │
│ │ ▼ ▼ ▼ ▼ ▼
EAR │ │ │──────│──────│──────│──────│
Calc │ │ │ 1ms │ │ │ │
│ │ │ ▼ ▼ ▼ ▼
Perclos│ │ │ │─────│─────│─────│
(CPU) │ │ │ │<1ms │ │ │
│ │ │ │ ▼ ▼ ▼
Output│ │ │ │ [Result]

关键时序要求:

  • 总延迟 < 50ms(满足实时性要求)
  • Face Detection: 2-3ms (GPU/NPU)
  • Face Mesh: 5-8ms (GPU/NPU)
  • PERCLOS: < 1ms (CPU)

四、完整 Graph 配置

4.1 主 Graph 配置

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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# mediapipe/graphs/ims/dms_fatigue_graph.pbtxt

# ============== 输入输出定义 ==============
input_stream: "IR_IMAGE:ir_image"
input_stream: "TIMESTAMP:timestamp"
input_stream: "VEHICLE_STATE:vehicle_state" # 车辆状态(车速、刹车等)

output_stream: "FATIGUE_RESULT:fatigue_result"
output_stream: "DEBUG_OUTPUT:debug_output" # 调试输出(可选)

# ============== Side Packet(静态配置)==============
input_side_packet: "MODEL_PATH:model_path"
input_side_packet: "CONFIG:config"

# ============== 1. 预处理 ==============
# 1.1 图像格式转换(如果需要)
node {
calculator: "ImageTransformationCalculator"
input_stream: "IMAGE:ir_image"
output_stream: "IMAGE:preprocessed_image"
options {
[mediapipe.ImageTransformationCalculatorOptions.ext] {
output_width: 640
output_height: 480
}
}
}

# 1.2 流量控制(避免积压)
node {
calculator: "FlowLimiterCalculator"
input_stream: "ir_image"
input_stream: "fatigue_result" # 反馈信号
input_stream_info: {
tag_index: "fatigue_result"
back_edge: true
}
output_stream: "throttled_image"
options {
[mediapipe.FlowLimiterCalculatorOptions.ext] {
max_in_flight: 1
max_in_queue: 1
}
}
}

# ============== 2. 人脸检测 ==============
node {
calculator: "FaceDetectionCalculator"
input_stream: "IMAGE:throttled_image"
output_stream: "DETECTIONS:face_detections"
input_side_packet: "MODEL:model_path"
options {
[mediapipe.FaceDetectionOptions.ext] {
# 使用短距模型(驾驶员距离摄像头 ~0.5m)
model_path: "/models/face_detection_short_range.tflite"
# 置信度阈值(根据实际场景调优)
min_score_thresh: 0.6
# 最大检测人数
max_faces: 1
}
}
}

# ============== 3. 人脸跟踪 ==============
# 3.1 检测结果验证
node {
calculator: "DetectionPresenceCalculator"
input_stream: "DETECTIONS:face_detections"
output_stream: "PRESENCE:face_presence"
}

# 3.2 条件执行(无人脸时跳过后续处理)
node {
calculator: "GateCalculator"
input_stream: "throttled_image"
input_stream: "face_detections"
input_stream: "face_presence"
output_stream: "gated_image"
output_stream: "gated_detections"
options {
[mediapipe.GateCalculatorOptions.ext] {
# 人脸存在时才通过
allow: true
}
}
}

# ============== 4. Face Mesh ==============
node {
calculator: "FaceMeshCalculator"
input_stream: "IMAGE:gated_image"
input_stream: "DETECTIONS:gated_detections"
output_stream: "LANDMARKS:face_landmarks"
output_stream: "FACE_GEOMETRY:face_geometry"
options {
[mediapipe.FaceMeshOptions.ext] {
# Face Mesh 模型
model_path: "/models/face_landmark.tflite"
# 启用注意力模型(提高精度)
enable_attention: true
# 输出虹膜关键点
enable_iris: true
}
}
}

# ============== 5. 眼睛状态分析 ==============
# 5.1 EAR 计算
node {
calculator: "EyeAspectRatioCalculator"
input_stream: "LANDMARKS:face_landmarks"
output_stream: "LEFT_EAR:left_ear"
output_stream: "RIGHT_EAR:right_ear"
output_stream: "AVG_EAR:avg_ear"
options {
[mediapipe.EyeAspectRatioOptions.ext] {
# 左眼关键点索引(Face Mesh 标准索引)
left_eye_upper: [159, 158, 157, 173]
left_eye_lower: [145, 144, 163, 133]
left_eye_left: 33
left_eye_right: 133

# 右眼关键点索引
right_eye_upper: [386, 385, 384, 398]
right_eye_lower: [374, 373, 382, 263]
right_eye_left: 362
right_eye_right: 263
}
}
}

# 5.2 眼睛状态判断
node {
calculator: "EyeStateClassifierCalculator"
input_stream: "AVG_EAR:avg_ear"
output_stream: "EYE_STATE:eye_state"
options {
[mediapipe.EyeStateOptions.ext] {
# 闭合阈值(根据实际数据调优)
closed_threshold: 0.2
# 眨眼检测的最小持续时间
blink_min_duration_ms: 50
blink_max_duration_ms: 500
}
}
}

# ============== 6. 时序分析 ==============
# 6.1 EAR 滑动窗口
node {
calculator: "SlidingWindowCalculator"
input_stream: "avg_ear"
output_stream: "ear_window"
options {
[mediapipe.SlidingWindowOptions.ext] {
# 窗口大小:30帧 = 1秒 @ 30fps
window_size: 30
# 滑动步长
step_size: 1
}
}
}

# 6.2 PERCLOS 计算
node {
calculator: "PERCLOSCalculator"
input_stream: "EAR_WINDOW:ear_window"
output_stream: "PERCLOS:perclos"
output_stream: "PERCLOS_LEVEL:perclos_level"
options {
[mediapipe.PERCLOSOptions.ext] {
# 闭合阈值
closed_threshold: 0.2
# PERCLOS 阈值
level_1_threshold: 15.0 # 轻度疲劳
level_2_threshold: 30.0 # 中度疲劳
level_3_threshold: 50.0 # 重度疲劳
}
}
}

# 6.3 眨眼频率分析
node {
calculator: "BlinkFrequencyCalculator"
input_stream: "EYE_STATE:eye_state"
output_stream: "BLINK_FREQUENCY:blink_frequency"
output_stream: "BLINK_STATS:blink_stats"
options {
[mediapipe.BlinkFrequencyOptions.ext] {
# 统计窗口:60
analysis_window_seconds: 60
# 异常阈值
low_blink_threshold: 5 # 每分钟 < 5
high_blink_threshold: 30 # 每分钟 > 30
}
}
}

# ============== 7. 打哈欠检测 ==============
node {
calculator: "YawnDetectionCalculator"
input_stream: "LANDMARKS:face_landmarks"
output_stream: "YAWN_STATE:yawn_state"
output_stream: "YAWN_FREQUENCY:yawn_frequency"
options {
[mediapipe.YawnDetectionOptions.ext] {
# 嘴巴张开阈值
mouth_open_threshold: 0.5
# 打哈欠的最小持续时间
min_yawn_duration_ms: 1000
# 统计窗口
analysis_window_seconds: 60
# 告警阈值
yawn_alert_threshold: 3 # 每分钟 > 3
}
}
}

# ============== 8. 头部姿态估计 ==============
node {
calculator: "HeadPoseCalculator"
input_stream: "LANDMARKS:face_landmarks"
output_stream: "HEAD_POSE:head_pose"
options {
[mediapipe.HeadPoseOptions.ext] {
# 使用的 6 个关键点
landmark_indices: [1, 152, 33, 263, 61, 291]
# 下垂检测阈值
pitch_down_threshold: 20.0 # 低头 > 20°
yaw_threshold: 30.0 # 偏转 > 30°
}
}
}

# ============== 9. 疲劳融合决策 ==============
node {
calculator: "FatigueAggregatorCalculator"
input_stream: "PERCLOS:perclos"
input_stream: "PERCLOS_LEVEL:perclos_level"
input_stream: "BLINK_STATS:blink_stats"
input_stream: "YAWN_FREQUENCY:yawn_frequency"
input_stream: "HEAD_POSE:head_pose"
input_stream: "VEHICLE_STATE:vehicle_state"
output_stream: "FATIGUE_RESULT:fatigue_result"
options {
[mediapipe.FatigueAggregatorOptions.ext] {
# 指标权重
perclos_weight: 0.5
blink_weight: 0.2
yawn_weight: 0.2
pose_weight: 0.1

# 疲劳阈值
low_fatigue_threshold: 0.3
medium_fatigue_threshold: 0.6
high_fatigue_threshold: 0.8

# 车速因子(高速时更严格)
speed_factor_enabled: true
speed_factor_threshold: 80.0 # km/h

# 告警配置
alert_cooldown_seconds: 30
alert_repeat_enabled: true
}
}
}

# ============== 10. 调试输出(可选)==============
node {
calculator: "DebugOutputCalculator"
input_stream: "face_landmarks"
input_stream: "avg_ear"
input_stream: "perclos"
input_stream: "fatigue_result"
output_stream: "DEBUG_OUTPUT:debug_output"
options {
[mediapipe.DebugOutputOptions.ext] {
enabled: true
output_format: "json"
}
}
}

4.2 Graph 配置要点解析

1. FlowLimiterCalculator 的作用

1
2
3
4
5
6
7
8
9
10
11
# 为什么需要 FlowLimiter?
#
# 如果处理速度 < 输入帧率,会导致:
# - 输入队列堆积
# - 延迟增加
# - 内存占用上升
#
# FlowLimiter 通过反馈机制控制输入流量
# max_in_flight = 1 表示同时只处理 1
# max_in_queue = 1 表示队列最多 1
# 超过的帧会被丢弃(而不是堆积)

2. GateCalculator 条件执行

1
2
3
4
// 条件执行的意义:
// 1. 无人脸时跳过后续处理,节省计算资源
// 2. 避免空数据导致的崩溃
// 3. 提供更清晰的数据流逻辑

3. 滑动窗口大小选择

1
2
3
4
5
6
7
8
窗口大小权衡:
- 太小(10帧):PERCLOS 波动大,误报率高
- 太大(60帧):响应慢,错过早期告警
- 推荐(30帧):平衡响应速度和稳定性

实际项目中:
- 30fps 场景:窗口 = 30帧 = 1秒
- 15fps 场景:窗口 = 15帧 = 1秒

五、核心 Calculator 完整实现

5.1 EyeAspectRatioCalculator

头文件:

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
// mediapipe/calculators/ims/eye_aspect_ratio_calculator.h
#ifndef MEDIAPIPE_CALCULATORS_IMS_EYE_ASPECT_RATIO_CALCULATOR_H_
#define MEDIAPIPE_CALCULATORS_IMS_EYE_ASPECT_RATIO_CALCULATOR_H_

#include "mediapipe/framework/calculator_framework.h"
#include "mediapipe/framework/formats/landmark.pb.h"
#include "mediapipe/calculators/ims/eye_aspect_ratio_calculator_options.pb.h"

namespace mediapipe {

// 眼睛状态数据结构
struct EyeState {
float left_ear; // 左眼 EAR
float right_ear; // 右眼 EAR
float avg_ear; // 平均 EAR
bool left_closed; // 左眼是否闭合
bool right_closed; // 右眼是否闭合
bool is_blinking; // 是否在眨眼
int64_t timestamp; // 时间戳
};

class EyeAspectRatioCalculator : public CalculatorBase {
public:
static absl::Status GetContract(CalculatorContract* cc);

absl::Status Open(CalculatorContext* cc) override;
absl::Status Process(CalculatorContext* cc) override;

private:
// 计算 EAR
float CalculateEAR(const NormalizedLandmarkList& landmarks,
const std::vector<int>& upper_indices,
const std::vector<int>& lower_indices,
int left_index,
int right_index);

// 计算两点距离
float Distance(const NormalizedLandmark& p1, const NormalizedLandmark& p2);

// 平滑滤波
float SmoothEAR(float current_ear);

// 配置
std::vector<int> left_eye_upper_;
std::vector<int> left_eye_lower_;
int left_eye_left_;
int left_eye_right_;

std::vector<int> right_eye_upper_;
std::vector<int> right_eye_lower_;
int right_eye_left_;
int right_eye_right_;

float closed_threshold_;

// 平滑滤波器
std::deque<float> ear_history_;
int smooth_window_size_ = 5;

// 眨眼检测
bool prev_eye_closed_ = false;
int64_t blink_start_time_ = 0;
int blink_count_ = 0;
};

} // namespace mediapipe

#endif // MEDIAPIPE_CALCULATORS_IMS_EYE_ASPECT_RATIO_CALCULATOR_H_

实现文件:

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
// mediapipe/calculators/ims/eye_aspect_ratio_calculator.cc
#include "mediapipe/calculators/ims/eye_aspect_ratio_calculator.h"
#include "mediapipe/framework/port/ret_check.h"
#include "mediapipe/framework/port/status.h"
#include <cmath>

namespace mediapipe {

using mediapipe::NormalizedLandmarkList;
using mediapipe::NormalizedLandmark;

absl::Status EyeAspectRatioCalculator::GetContract(CalculatorContract* cc) {
// 输入:Face Mesh 468 关键点
cc->Inputs().Tag("LANDMARKS").Set<NormalizedLandmarkList>();

// 输出
cc->Outputs().Tag("LEFT_EAR").Set<float>();
cc->Outputs().Tag("RIGHT_EAR").Set<float>();
cc->Outputs().Tag("AVG_EAR").Set<float>();

// Options
cc->Options<EyeAspectRatioOptions>();

return absl::OkStatus();
}

absl::Status EyeAspectRatioCalculator::Open(CalculatorContext* cc) {
const auto& options = cc->Options<EyeAspectRatioOptions>();

// 读取配置
left_eye_upper_.assign(options.left_eye_upper().begin(),
options.left_eye_upper().end());
left_eye_lower_.assign(options.left_eye_lower().begin(),
options.left_eye_lower().end());
left_eye_left_ = options.left_eye_left();
left_eye_right_ = options.left_eye_right();

right_eye_upper_.assign(options.right_eye_upper().begin(),
options.right_eye_upper().end());
right_eye_lower_.assign(options.right_eye_lower().begin(),
options.right_eye_lower().end());
right_eye_left_ = options.right_eye_left();
right_eye_right_ = options.right_eye_right();

closed_threshold_ = options.closed_threshold();

LOG(INFO) << "EyeAspectRatioCalculator initialized";
LOG(INFO) << " Left eye indices: upper=" << left_eye_upper_.size()
<< ", lower=" << left_eye_lower_.size();
LOG(INFO) << " Right eye indices: upper=" << right_eye_upper_.size()
<< ", lower=" << right_eye_lower_.size();
LOG(INFO) << " Closed threshold: " << closed_threshold_;

return absl::OkStatus();
}

absl::Status EyeAspectRatioCalculator::Process(CalculatorContext* cc) {
// 检查输入
if (cc->Inputs().Tag("LANDMARKS").IsEmpty()) {
LOG(WARNING) << "Empty landmarks input at timestamp: " << cc->InputTimestamp();
return absl::OkStatus();
}

const auto& landmarks = cc->Inputs().Tag("LANDMARKS").Get<NormalizedLandmarkList>();

// 验证关键点数量
if (landmarks.landmark_size() < 468) {
LOG(WARNING) << "Insufficient landmarks: " << landmarks.landmark_size();
return absl::OkStatus();
}

// 计算左眼 EAR
float left_ear = CalculateEAR(landmarks, left_eye_upper_, left_eye_lower_,
left_eye_left_, left_eye_right_);

// 计算右眼 EAR
float right_ear = CalculateEAR(landmarks, right_eye_upper_, right_eye_lower_,
right_eye_left_, right_eye_right_);

// 平均 EAR
float avg_ear = (left_ear + right_ear) / 2.0f;

// 平滑滤波
avg_ear = SmoothEAR(avg_ear);

// 输出
cc->Outputs().Tag("LEFT_EAR").AddPacket(
MakePacket<float>(left_ear).At(cc->InputTimestamp()));

cc->Outputs().Tag("RIGHT_EAR").AddPacket(
MakePacket<float>(right_ear).At(cc->InputTimestamp()));

cc->Outputs().Tag("AVG_EAR").AddPacket(
MakePacket<float>(avg_ear).At(cc->InputTimestamp()));

// 调试日志
VLOG(1) << "EAR: left=" << left_ear << ", right=" << right_ear
<< ", avg=" << avg_ear;

return absl::OkStatus();
}

float EyeAspectRatioCalculator::CalculateEAR(
const NormalizedLandmarkList& landmarks,
const std::vector<int>& upper_indices,
const std::vector<int>& lower_indices,
int left_index,
int right_index) {

// 计算垂直距离的平均值
float vertical_sum = 0.0f;
for (size_t i = 0; i < upper_indices.size(); ++i) {
vertical_sum += Distance(landmarks.landmark(upper_indices[i]),
landmarks.landmark(lower_indices[i]));
}
float vertical_avg = vertical_sum / upper_indices.size();

// 计算水平距离
float horizontal = Distance(landmarks.landmark(left_index),
landmarks.landmark(right_index));

// 避免除零
if (horizontal < 1e-6f) {
LOG(WARNING) << "Horizontal distance too small: " << horizontal;
return 0.0f;
}

// EAR = 垂直距离 / 水平距离
return vertical_avg / horizontal;
}

float EyeAspectRatioCalculator::Distance(const NormalizedLandmark& p1,
const NormalizedLandmark& p2) {
float dx = p1.x() - p2.x();
float dy = p1.y() - p2.y();
float dz = p1.z() - p2.z();
return std::sqrt(dx * dx + dy * dy + dz * dz);
}

float EyeAspectRatioCalculator::SmoothEAR(float current_ear) {
// 添加到历史
ear_history_.push_back(current_ear);

// 保持窗口大小
while (ear_history_.size() > smooth_window_size_) {
ear_history_.pop_front();
}

// 计算均值
float sum = 0.0f;
for (float ear : ear_history_) {
sum += ear;
}

return sum / ear_history_.size();
}

REGISTER_CALCULATOR(EyeAspectRatioCalculator);

} // namespace mediapipe

5.2 PERCLOSCalculator

头文件:

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
// mediapipe/calculators/ims/perclos_calculator.h
#ifndef MEDIAPIPE_CALCULATORS_IMS_PERCLOS_CALCULATOR_H_
#define MEDIAPIPE_CALCULATORS_IMS_PERCLOS_CALCULATOR_H_

#include "mediapipe/framework/calculator_framework.h"
#include "mediapipe/calculators/ims/perclos_calculator_options.pb.h"
#include <deque>

namespace mediapipe {

// PERCLOS 结果
struct PERCLOSResult {
float perclos; // PERCLOS 值 (%)
int level; // 疲劳等级 (0-3)
int closed_frames; // 闭合帧数
int total_frames; // 总帧数
float average_ear; // 平均 EAR
int64_t window_start; // 窗口开始时间
int64_t window_end; // 窗口结束时间
};

class PERCLOSCalculator : public CalculatorBase {
public:
static absl::Status GetContract(CalculatorContract* cc);

absl::Status Open(CalculatorContext* cc) override;
absl::Status Process(CalculatorContext* cc) override;

private:
// 计算 PERCLOS
PERCLOSResult CalculatePERCLOS(const std::deque<float>& ear_window);

// 确定疲劳等级
int DetermineLevel(float perclos);

// 配置
float closed_threshold_;
float level_1_threshold_;
float level_2_threshold_;
float level_3_threshold_;

// 统计
int total_frames_processed_ = 0;
};

} // namespace mediapipe

#endif // MEDIAPIPE_CALCULATORS_IMS_PERCLOS_CALCULATOR_H_

实现文件:

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
// mediapipe/calculators/ims/perclos_calculator.cc
#include "mediapipe/calculators/ims/perclos_calculator.h"
#include "mediapipe/framework/port/logging.h"

namespace mediapipe {

absl::Status PERCLOSCalculator::GetContract(CalculatorContract* cc) {
// 输入:EAR 滑动窗口
cc->Inputs().Tag("EAR_WINDOW").Set<std::deque<float>>();

// 输出
cc->Outputs().Tag("PERCLOS").Set<float>();
cc->Outputs().Tag("PERCLOS_LEVEL").Set<int>();

cc->Options<PERCLOSOptions>();

return absl::OkStatus();
}

absl::Status PERCLOSCalculator::Open(CalculatorContext* cc) {
const auto& options = cc->Options<PERCLOSOptions>();

closed_threshold_ = options.closed_threshold();
level_1_threshold_ = options.level_1_threshold();
level_2_threshold_ = options.level_2_threshold();
level_3_threshold_ = options.level_3_threshold();

LOG(INFO) << "PERCLOSCalculator initialized";
LOG(INFO) << " Closed threshold: " << closed_threshold_;
LOG(INFO) << " Level thresholds: " << level_1_threshold_ << "/"
<< level_2_threshold_ << "/" << level_3_threshold_;

return absl::OkStatus();
}

absl::Status PERCLOSCalculator::Process(CalculatorContext* cc) {
// 检查输入
if (cc->Inputs().Tag("EAR_WINDOW").IsEmpty()) {
return absl::OkStatus();
}

const auto& ear_window = cc->Inputs().Tag("EAR_WINDOW").Get<std::deque<float>>();

// 窗口太小,跳过
if (ear_window.size() < 10) {
VLOG(1) << "Window too small: " << ear_window.size();
return absl::OkStatus();
}

// 计算 PERCLOS
PERCLOSResult result = CalculatePERCLOS(ear_window);

// 输出
cc->Outputs().Tag("PERCLOS").AddPacket(
MakePacket<float>(result.perclos).At(cc->InputTimestamp()));

cc->Outputs().Tag("PERCLOS_LEVEL").AddPacket(
MakePacket<int>(result.level).At(cc->InputTimestamp()));

// 调试日志
VLOG(1) << "PERCLOS: " << result.perclos << "%, level: " << result.level
<< ", closed: " << result.closed_frames << "/" << result.total_frames;

// 告警日志
if (result.level >= 2) {
LOG(WARNING) << "Fatigue detected! PERCLOS: " << result.perclos
<< "%, level: " << result.level;
}

total_frames_processed_++;

return absl::OkStatus();
}

PERCLOSResult PERCLOSCalculator::CalculatePERCLOS(const std::deque<float>& ear_window) {
PERCLOSResult result;
result.total_frames = static_cast<int>(ear_window.size());
result.closed_frames = 0;
result.average_ear = 0.0f;

// 统计闭合帧
for (float ear : ear_window) {
result.average_ear += ear;
if (ear < closed_threshold_) {
result.closed_frames++;
}
}

result.average_ear /= result.total_frames;

// 计算 PERCLOS
result.perclos = static_cast<float>(result.closed_frames) /
result.total_frames * 100.0f;

// 确定疲劳等级
result.level = DetermineLevel(result.perclos);

return result;
}

int PERCLOSCalculator::DetermineLevel(float perclos) {
if (perclos >= level_3_threshold_) {
return 3; // 重度疲劳
} else if (perclos >= level_2_threshold_) {
return 2; // 中度疲劳
} else if (perclos >= level_1_threshold_) {
return 1; // 轻度疲劳
}
return 0; // 正常
}

REGISTER_CALCULATOR(PERCLOSCalculator);

} // namespace mediapipe

六、调试与测试

6.1 单元测试

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
// mediapipe/calculators/ims/eye_aspect_ratio_calculator_test.cc
#include "mediapipe/framework/calculator_framework.h"
#include "mediapipe/framework/calculator_runner.h"
#include "mediapipe/framework/formats/landmark.pb.h"
#include "mediapipe/framework/port/gmock.h"
#include "mediapipe/framework/port/gtest.h"

namespace mediapipe {
namespace {

TEST(EyeAspectRatioCalculatorTest, CalculatesCorrectEAR) {
// 设置 Calculator
CalculatorRunner runner(R"(
calculator: "EyeAspectRatioCalculator"
input_stream: "LANDMARKS:landmarks"
output_stream: "AVG_EAR:avg_ear"
options {
[mediapipe.EyeAspectRatioOptions.ext] {
left_eye_upper: [159, 158]
left_eye_lower: [145, 144]
left_eye_left: 33
left_eye_right: 133
right_eye_upper: [386, 385]
right_eye_lower: [374, 373]
right_eye_left: 362
right_eye_right: 263
closed_threshold: 0.2
}
}
)");

// 构造测试数据(眼睛睁开状态)
NormalizedLandmarkList landmarks;
for (int i = 0; i < 468; i++) {
auto* landmark = landmarks.add_landmark();
landmark->set_x(0.5f);
landmark->set_y(0.5f);
landmark->set_z(0.0f);
}

// 设置眼睛关键点(模拟睁眼)
// 左眼
landmarks.mutable_landmark(33)->set_x(0.4f); // 左眼角
landmarks.mutable_landmark(133)->set_x(0.5f); // 右眼角
landmarks.mutable_landmark(159)->set_y(0.48f); // 上眼睑
landmarks.mutable_landmark(145)->set_y(0.52f); // 下眼睑
// 右眼类似...

// 输入
runner.MutableInputs()->Tag("LANDMARKS").packets.push_back(
MakePacket<NormalizedLandmarkList>(landmarks).At(Timestamp(0)));

// 运行
MP_ASSERT_OK(runner.Run());

// 验证输出
const auto& outputs = runner.Outputs().Tag("AVG_EAR").packets;
ASSERT_EQ(outputs.size(), 1);

float ear = outputs[0].Get<float>();
EXPECT_GT(ear, 0.2f); // 睁眼 EAR 应该 > 0.2
EXPECT_LT(ear, 0.5f); // EAR 应该 < 0.5
}

TEST(EyeAspectRatioCalculatorTest, DetectsClosedEyes) {
// 测试眼睛闭合检测
// EAR < 0.2 应该被判定为闭合
// ... 类似上面的测试代码
}

} // namespace
} // namespace mediapipe

6.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
// 完整流水线测试
TEST(FatiguePipelineTest, DetectsFatigueFromVideo) {
// 1. 准备测试视频
std::string video_path = "test_data/fatigue_driver.mp4";

// 2. 设置 Graph
CalculatorRunner runner(R"(
# 完整 Graph 配置
...
)");

// 3. 逐帧输入
cv::VideoCapture cap(video_path);
cv::Mat frame;
int frame_count = 0;

while (cap.read(frame)) {
// 转换为 ImageFrame
ImageFrame image_frame(ImageFormat::SRGB, frame.cols, frame.rows);
frame.copyTo(formats::MatView(&image_frame));

// 输入
runner.MutableInputs()->Tag("IR_IMAGE").packets.push_back(
MakePacket<ImageFrame>(std::move(image_frame)).At(Timestamp(frame_count * 33333)));

frame_count++;
}

// 4. 运行
MP_ASSERT_OK(runner.Run());

// 5. 验证输出
const auto& results = runner.Outputs().Tag("FATIGUE_RESULT").packets;

// 统计检测结果
int fatigue_detected = 0;
for (const auto& packet : results) {
const auto& result = packet.Get<FatigueResult>();
if (result.fatigue_level() >= 2) {
fatigue_detected++;
}
}

// 验证:疲劳视频应该有超过 50% 的帧检测到疲劳
float fatigue_ratio = static_cast<float>(fatigue_detected) / results.size();
EXPECT_GT(fatigue_ratio, 0.5f);
}

6.3 调试技巧

1. 可视化 EAR 曲线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 在 Process 中添加
static std::ofstream ear_log("ear_debug.csv");
ear_log << cc->InputTimestamp().Value() << "," << avg_ear << std::endl;

// 绘制曲线
// python plot_ear.py
import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv('ear_debug.csv', names=['timestamp', 'ear'])
plt.plot(df['timestamp'], df['ear'])
plt.axhline(y=0.2, color='r', linestyle='--', label='Closed threshold')
plt.xlabel('Timestamp (us)')
plt.ylabel('EAR')
plt.legend()
plt.savefig('ear_curve.png')

2. 日志级别控制

1
2
3
4
5
6
7
# 运行时设置日志级别
GLOG_logtostderr=1 GLOG_v=2 ./dms_demo

# VLOG(1): 一般调试信息
# VLOG(2): 详细调试信息
# LOG(INFO): 重要信息
# LOG(WARNING): 告警信息

3. 性能分析

1
2
3
4
5
6
7
8
// 在关键 Calculator 中添加计时
auto start = std::chrono::high_resolution_clock::now();

// ... 处理代码 ...

auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
VLOG(1) << "Process took " << duration.count() << " us";

七、性能优化

7.1 延迟分析

1
2
3
4
5
6
7
8
9
10
11
典型延迟分解(高通 8295):

Face Detection: 2-3 ms (NPU)
Face Mesh: 5-8 ms (NPU)
EAR Calculation: <1 ms (CPU)
PERCLOS: <1 ms (CPU)
Other Processing: <1 ms (CPU)
─────────────────────────────────
Total: 10-15 ms

满足实时性要求 (< 33ms @ 30fps)

7.2 优化策略

1. 推理加速

1
2
3
4
5
6
7
8
9
10
// 使用 NPU/DSP 加速
node {
calculator: "FaceMeshCalculator"
options {
# 使用 Hexagon DSP
delegate: "QNN"
# 或使用 GPU
# delegate: "GPU"
}
}

2. 内存优化

1
2
3
4
5
6
7
8
9
10
11
12
// 避免频繁分配
// 错误:
std::vector<float> buffer;
buffer.resize(1000); // 每帧分配

// 正确:
class MyCalculator {
std::vector<float> buffer_; // 复用
};

// 在 Open 中预分配
buffer_.resize(1000);

3. 并行处理

1
2
3
4
5
6
7
8
# 设置线程池
node {
calculator: "FaceDetectionCalculator"
options {
# 使用独立线程
executor: "gpu"
}
}

八、实际部署经验

8.1 高通平台部署

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 编译
bazel build -c opt \
--config=android_arm64 \
--define MEDIAPIPE_DISABLE_GPU=1 \
--define qnn=true \
//mediapipe/graphs/ims:dms_fatigue

# 部署
adb push bazel-bin/... /data/local/tmp/
adb push /models/ /data/local/tmp/models/
adb push $QNN_SDK/lib/*.so /data/local/tmp/

# 运行
adb shell
export LD_LIBRARY_PATH=/data/local/tmp:$LD_LIBRARY_PATH
./dms_fatigue --config=/data/local/tmp/config.pbtxt

8.2 常见问题

问题 1:EAR 波动大

1
2
3
4
5
原因:关键点检测不稳定
解决:
1. 增加 EAR 平滑滤波(滑动窗口)
2. 使用 attention 模型的 Face Mesh
3. 调整摄像头曝光时间

问题 2:误报率高

1
2
3
4
5
原因:阈值设置不当
解决:
1. 收集真实场景数据
2. 统计分析确定最佳阈值
3. 使用动态阈值(根据驾驶员特征调整)

问题 3:光照影响

1
2
3
4
5
原因:IR 补光不均匀
解决:
1. 优化 IR 补光角度
2. 使用自适应直方图均衡
3. 增加训练数据的多样性

九、总结

9.1 关键要点

要点 说明
PERCLOS 疲劳检测核心指标
EAR 眼睛开合度的量化表示
滑动窗口 时序分析的必要手段
多指标融合 提高检测准确性

9.2 最佳实践

  1. 阈值调优:基于真实数据统计
  2. 平滑滤波:减少噪声影响
  3. 条件执行:节省计算资源
  4. 日志分级:方便调试

系列进度: 41/55
更新时间: 2026-03-12


MediaPipe 系列 41:IMS DMS 架构——疲劳检测流水线完整实现
https://dapalm.com/2026/03/12/MediaPipe系列41-IMS-DMS架构:疲劳检测流水线/
作者
Mars
发布于
2026年3月12日
许可协议