Euro NCAP 2026 手机使用检测深度解析:Owl/Lizard 扫视分类与视线落点追踪完整实现

一、Euro NCAP 2026 手机使用检测要求

1.1 法规背景

Euro NCAP 2026 将手机使用从”一般分心”中独立出来,作为专门的检测类别。根据 Euro NCAP Safe Driving Driver Engagement Protocol v1.0(2025年3月),手机使用检测必须:

检测能力 法规要求 分值
基础手机使用 手持手机但不操作 2.5分
高级手机使用 操作手机屏幕(打字/滑动) 2.5分
检测时限 ≤3秒触发警告 -
警告分级 一级警告(非紧急)/ 二级警告(持续使用) -

1.2 Owl vs Lizard 扫视分类

Euro NCAP 定义了两种扫视模式:

扫视类型 运动特征 适用场景 检测方法
Owl(猫头鹰) 头部转动为主,眼球相对固定 基础手机使用(看膝盖/支架) 头部姿态角变化
Lizard(蜥蜴) 眼球转动为主,头部相对固定 高级手机使用(操作屏幕) 视线落点估计

分类逻辑:

1
2
3
4
if 头部偏航角变化 > 阈值:
扫视类型 = "Owl" # 头部转动为主
elif 视线落点在手机区域:
扫视类型 = "Lizard" # 眼球转动为主

1.3 视线落点区域

Euro NCAP 定义了多个视线落点区域:

基础使用视线落点:

区域编号 区域名称 描述
P-01 Driver knee outboard 驾驶员膝盖外侧
P-02 Driver knee inboard 驾驶员膝盖内侧
P-03 Driver lap 驾驶员大腿
P-04 Phone mounted dashboard 手机支架(仪表板上方)
P-05 Phone in OEM designed position 原厂设计的手机放置位

高级使用视线落点:

区域编号 区域名称 描述
A-01 Phone held center wheel 手机在方向盘中心(仪表盘下方)
A-02 Phone at 9-11 / 1-3 o’clock 手机在方向盘上部区域
A-03 Phone in windscreen view 手机在挡风玻璃视野内
A-04 Phone in cluster view 手机在仪表盘视野内

二、视线落点估计完整实现

2.1 依赖安装

1
2
3
4
5
# requirements.txt
numpy>=1.21.0
opencv-python>=4.5.0
mediapipe>=0.10.0
scipy>=1.7.0

2.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
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
import numpy as np
import mediapipe as mp
import cv2
from typing import Tuple, Optional
from dataclasses import dataclass

@dataclass
class HeadPose:
"""头部姿态数据结构"""
pitch: float # 俯仰角(上下点头),单位:度
yaw: float # 偏航角(左右转头),单位:度
roll: float # 翻滚角(头部倾斜),单位:度
timestamp: float # 时间戳


class HeadPoseEstimator:
"""
基于 MediaPipe Face Mesh 的头部姿态估计器

使用 PnP(Perspective-n-Point)算法求解 3D 姿态

关键点对应关系:
- 鼻尖: 1
- 下巴: 152
- 左眼外角: 33
- 右眼外角: 263
- 左嘴角: 61
- 右嘴角: 291
"""

# 3D 模型点(归一化坐标)
MODEL_POINTS = np.array([
[0.0, 0.0, 0.0], # 鼻尖
[0.0, -63.6, -12.5], # 下巴
[-43.3, 32.7, -26.0], # 左眼外角
[43.3, 32.7, -26.0], # 右眼外角
[-28.9, -28.9, -24.1], # 左嘴角
[28.9, -28.9, -24.1] # 右嘴角
])

# MediaPipe 关键点索引
LANDMARK_IDS = [1, 152, 33, 263, 61, 291]

def __init__(self,
frame_width: int = 640,
frame_height: int = 480,
focal_length: float = None):
"""
初始化头部姿态估计器

Args:
frame_width: 图像宽度
frame_height: 图像高度
focal_length: 焦距(像素),默认使用图像宽度
"""
self.frame_width = frame_width
self.frame_height = frame_height
self.focal_length = focal_length or frame_width

# 相机内参矩阵
self.camera_matrix = np.array([
[self.focal_length, 0, frame_width / 2],
[0, self.focal_length, frame_height / 2],
[0, 0, 1]
], dtype=np.float64)

# 畸变系数(假设无畸变)
self.dist_coeffs = np.zeros((4, 1), dtype=np.float64)

# 初始化 MediaPipe Face Mesh
self.mp_face_mesh = mp.solutions.face_mesh
self.face_mesh = self.mp_face_mesh.FaceMesh(
static_image_mode=False,
max_num_faces=1,
refine_landmarks=True,
min_detection_confidence=0.5,
min_tracking_confidence=0.5
)

def estimate(self, frame: np.ndarray) -> Tuple[Optional[HeadPose], np.ndarray]:
"""
估计头部姿态

Args:
frame: BGR 图像,shape=(H, W, 3)

Returns:
head_pose: HeadPose 对象或 None
landmarks: (468, 3) 关键点坐标或空数组
"""
h, w = frame.shape[:2]

# BGR 转 RGB
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)

# 检测人脸关键点
results = self.face_mesh.process(rgb_frame)

if not results.multi_face_landmarks:
return None, np.array([])

# 提取关键点
face_landmarks = results.multi_face_landmarks[0]

# 转换为像素坐标
landmarks = np.array([
[lm.x * w, lm.y * h, lm.z]
for lm in face_landmarks.landmark
])

# 提取 2D 图像点
image_points = np.array([
landmarks[i, :2]
for i in self.LANDMARK_IDS
], dtype=np.float64)

# PnP 求解
success, rotation_vector, translation_vector = cv2.solvePnP(
self.MODEL_POINTS,
image_points,
self.camera_matrix,
self.dist_coeffs,
flags=cv2.SOLVEPNP_ITERATIVE
)

if not success:
return None, landmarks

# 转换为旋转矩阵
rotation_matrix, _ = cv2.Rodrigues(rotation_vector)

# 计算欧拉角
pitch = np.degrees(np.arcsin(-rotation_matrix[2, 0]))
yaw = np.degrees(np.arctan2(rotation_matrix[2, 1], rotation_matrix[2, 2]))
roll = np.degrees(np.arctan2(rotation_matrix[1, 0], rotation_matrix[0, 0]))

head_pose = HeadPose(
pitch=pitch,
yaw=yaw,
roll=roll,
timestamp=cv2.getTickCount() / cv2.getTickFrequency()
)

return head_pose, landmarks

def close(self):
"""释放资源"""
self.face_mesh.close()


# 测试代码
if __name__ == "__main__":
estimator = HeadPoseEstimator()
cap = cv2.VideoCapture(0)

print("按 'q' 退出")
while True:
ret, frame = cap.read()
if not ret:
break

head_pose, landmarks = estimator.estimate(frame)

if head_pose is not None:
# 显示姿态角
cv2.putText(frame, f"Pitch: {head_pose.pitch:.1f}°", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
cv2.putText(frame, f"Yaw: {head_pose.yaw:.1f}°", (10, 60),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
cv2.putText(frame, f"Roll: {head_pose.roll:.1f}°", (10, 90),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)

# 判断头部转动
if abs(head_pose.yaw) > 20:
cv2.putText(frame, "HEAD TURN", (10, 120),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)

cv2.imshow("Head Pose", frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break

cap.release()
estimator.close()
cv2.destroyAllWindows()

2.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
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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
import numpy as np
import cv2
from typing import Tuple, Optional, List
from dataclasses import dataclass
from enum import Enum

class GazeRegion(Enum):
"""视线落点区域"""
# 驾驶相关区域
ROAD_FORWARD = "road_forward" # 前方道路
ROAD_LEFT = "road_left" # 左侧道路
ROAD_RIGHT = "road_right" # 右侧道路
MIRROR_LEFT = "mirror_left" # 左后视镜
MIRROR_RIGHT = "mirror_right" # 右后视镜
MIRROR_CENTER = "mirror_center" # 中央后视镜

# 手机相关区域(基础使用)
PHONE_KNEE_OUTBOARD = "phone_knee_outboard" # P-01
PHONE_KNEE_INBOARD = "phone_knee_inboard" # P-02
PHONE_LAP = "phone_lap" # P-03
PHONE_MOUNTED_DASHBOARD = "phone_mounted_dashboard" # P-04
PHONE_OEM_POSITION = "phone_oem_position" # P-05

# 手机相关区域(高级使用)
PHONE_HELD_CENTER = "phone_held_center" # A-01
PHONE_HELD_WHEEL_TOP = "phone_held_wheel_top" # A-02
PHONE_WINDSCREEN = "phone_windscreen" # A-03
PHONE_CLUSTER = "phone_cluster" # A-04

# 其他
INFOTAINMENT = "infotainment" # 中控屏
GLOVEBOX = "glovebox" # 手套箱
FOOTWELL = "footwell" # 脚部区域
PASSENGER = "passenger" # 乘客
UNKNOWN = "unknown"


@dataclass
class GazePoint:
"""视线落点数据结构"""
x: float # 图像 x 坐标(像素)
y: float # 图像 y 坐标(像素)
region: GazeRegion # 所属区域
confidence: float # 置信度


class GazeEstimator:
"""
视线落点估计器

基于瞳孔中心-角膜反射向量法(PCCR)
简化实现:使用眼球中心到瞳孔的向量估计视线方向
"""

# MediaPipe 眼部关键点索引
LEFT_EYE = {
'center': 468, # 左眼中心(精细关键点)
'pupil': 473, # 左眼瞳孔(精细关键点)
'inner': 133, # 内眼角
'outer': 33, # 外眼角
'upper': 159, # 上眼睑
'lower': 145 # 下眼睑
}

RIGHT_EYE = {
'center': 473, # 右眼中心(精细关键点)
'pupil': 468, # 右眼瞳孔(精细关键点)
'inner': 362, # 内眼角
'outer': 263, # 外眼角
'upper': 386, # 上眼睑
'lower': 374 # 下眼睑
}

def __init__(self,
frame_width: int = 640,
frame_height: int = 480,
gaze_calibration: dict = None):
"""
初始化视线估计器

Args:
frame_width: 图像宽度
frame_height: 图像高度
gaze_calibration: 校准参数(可选)
"""
self.frame_width = frame_width
self.frame_height = frame_height

# 校准参数(需要标定)
self.calibration = gaze_calibration or {
'left_eye_offset': [0, 0],
'right_eye_offset': [0, 0],
'scale': 1.0
}

# 区域边界定义(需要根据实际摄像头位置标定)
# 这些值是示例,需要根据实际部署调整
self.region_bounds = self._define_region_bounds()

def _define_region_bounds(self) -> dict:
"""
定义视线落点区域边界

Returns:
区域边界字典,每个区域包含 x, y 的 [min, max] 范围
"""
w, h = self.frame_width, self.frame_height

return {
# 前方道路(中心区域)
GazeRegion.ROAD_FORWARD: {
'x': [0.35 * w, 0.65 * w],
'y': [0.2 * h, 0.5 * h]
},
# 手机膝盖外侧(左下)
GazeRegion.PHONE_KNEE_OUTBOARD: {
'x': [0, 0.2 * w],
'y': [0.7 * h, h]
},
# 手机膝盖内侧(中下)
GazeRegion.PHONE_KNEE_INBOARD: {
'x': [0.2 * w, 0.4 * w],
'y': [0.7 * h, h]
},
# 手机大腿(下方中心)
GazeRegion.PHONE_LAP: {
'x': [0.3 * w, 0.7 * w],
'y': [0.8 * h, h]
},
# 手机支架(右上)
GazeRegion.PHONE_MOUNTED_DASHBOARD: {
'x': [0.7 * w, 0.9 * w],
'y': [0.1 * h, 0.4 * h]
},
# 中控屏(右侧)
GazeRegion.INFOTAINMENT: {
'x': [0.6 * w, 0.9 * w],
'y': [0.4 * h, 0.7 * h]
},
# 左后视镜(左上)
GazeRegion.MIRROR_LEFT: {
'x': [0, 0.2 * w],
'y': [0, 0.3 * h]
},
# 右后视镜(右上)
GazeRegion.MIRROR_RIGHT: {
'x': [0.8 * w, w],
'y': [0, 0.3 * h]
}
}

def estimate(self, landmarks: np.ndarray, head_pose: 'HeadPose') -> Tuple[GazePoint, GazePoint]:
"""
估计双眼视线落点

Args:
landmarks: (468, 3) 关键点坐标
head_pose: 头部姿态

Returns:
left_gaze: 左眼视线落点
right_gaze: 右眼视线落点
"""
# 获取眼球中心和瞳孔位置
left_eye_center = landmarks[self.LEFT_EYE['center'], :2]
left_pupil = landmarks[self.LEFT_EYE['pupil'], :2]

right_eye_center = landmarks[self.RIGHT_EYE['center'], :2]
right_pupil = landmarks[self.RIGHT_EYE['pupil'], :2]

# 计算眼球到瞳孔的向量(视线方向)
left_gaze_vector = left_pupil - left_eye_center
right_gaze_vector = right_pupil - right_eye_center

# 结合头部姿态,估计视线落点
# 简化实现:使用视线向量 + 头部姿态偏移
left_gaze_point = self._calculate_gaze_point(
left_eye_center, left_gaze_vector, head_pose
)
right_gaze_point = self._calculate_gaze_point(
right_eye_center, right_gaze_vector, head_pose
)

# 分类区域
left_gaze_point.region = self._classify_region(left_gaze_point.x, left_gaze_point.y)
right_gaze_point.region = self._classify_region(right_gaze_point.x, right_gaze_point.y)

return left_gaze_point, right_gaze_point

def _calculate_gaze_point(self, eye_center: np.ndarray,
gaze_vector: np.ndarray,
head_pose: 'HeadPose') -> GazePoint:
"""
计算视线落点

Args:
eye_center: 眼球中心坐标
gaze_vector: 视线向量
head_pose: 头部姿态

Returns:
GazePoint 对象
"""
# 基础落点(眼球中心)
base_point = eye_center.copy()

# 添加视线向量偏移(放大系数)
scale = 500.0 # 需要标定
base_point[0] += gaze_vector[0] * scale
base_point[1] += gaze_vector[1] * scale

# 添加头部姿态偏移
# 当头部转动时,视线落点也会移动
yaw_offset = head_pose.yaw * 10 # 度转像素
pitch_offset = head_pose.pitch * 8

gaze_x = base_point[0] + yaw_offset
gaze_y = base_point[1] + pitch_offset

# 限制在图像范围内
gaze_x = np.clip(gaze_x, 0, self.frame_width)
gaze_y = np.clip(gaze_y, 0, self.frame_height)

return GazePoint(
x=gaze_x,
y=gaze_y,
region=GazeRegion.UNKNOWN,
confidence=0.8 # 简化置信度
)

def _classify_region(self, x: float, y: float) -> GazeRegion:
"""
根据坐标分类视线落点区域

Args:
x: x 坐标
y: y 坐标

Returns:
GazeRegion 枚举值
"""
for region, bounds in self.region_bounds.items():
if (bounds['x'][0] <= x <= bounds['x'][1] and
bounds['y'][0] <= y <= bounds['y'][1]):
return region

return GazeRegion.UNKNOWN

def get_combined_gaze(self, left_gaze: GazePoint,
right_gaze: GazePoint) -> GazePoint:
"""
融合双眼视线落点

Args:
left_gaze: 左眼视线落点
right_gaze: 右眼视线落点

Returns:
融合后的视线落点
"""
# 简单平均
combined_x = (left_gaze.x + right_gaze.x) / 2
combined_y = (left_gaze.y + right_gaze.y) / 2

# 取置信度高的区域
if left_gaze.confidence > right_gaze.confidence:
region = left_gaze.region
confidence = left_gaze.confidence
else:
region = right_gaze.region
confidence = right_gaze.confidence

return GazePoint(
x=combined_x,
y=combined_y,
region=region,
confidence=confidence
)


# 测试代码
if __name__ == "__main__":
head_estimator = HeadPoseEstimator()
gaze_estimator = GazeEstimator()
cap = cv2.VideoCapture(0)

print("按 'q' 退出")
while True:
ret, frame = cap.read()
if not ret:
break

# 估计头部姿态
head_pose, landmarks = head_estimator.estimate(frame)

if head_pose is not None and len(landmarks) > 0:
# 估计视线落点
left_gaze, right_gaze = gaze_estimator.estimate(landmarks, head_pose)
combined_gaze = gaze_estimator.get_combined_gaze(left_gaze, right_gaze)

# 显示视线落点
cv2.circle(frame, (int(combined_gaze.x), int(combined_gaze.y)),
10, (0, 0, 255), -1)

# 显示区域
cv2.putText(frame, f"Region: {combined_gaze.region.value}", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)

# 判断手机使用
phone_regions = [
GazeRegion.PHONE_KNEE_OUTBOARD,
GazeRegion.PHONE_KNEE_INBOARD,
GazeRegion.PHONE_LAP,
GazeRegion.PHONE_MOUNTED_DASHBOARD
]
if combined_gaze.region in phone_regions:
cv2.putText(frame, "PHONE USE DETECTED", (10, 60),
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)

cv2.imshow("Gaze Estimation", frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break

cap.release()
head_estimator.close()
cv2.destroyAllWindows()

2.4 Owl/Lizard 分类器

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
import numpy as np
from collections import deque
from typing import List, Tuple, Optional
from dataclasses import dataclass
from enum import Enum

class ScanType(Enum):
"""扫视类型"""
OWL = "owl" # 头部转动为主
LIZARD = "lizard" # 眼球转动为主
UNKNOWN = "unknown"


@dataclass
class ScanEvent:
"""扫视事件"""
timestamp: float
scan_type: ScanType
start_region: GazeRegion
end_region: GazeRegion
head_rotation: float # 头部转动角度
duration: float # 持续时间(秒)


class OwlLizardClassifier:
"""
Owl/Lizard 扫视分类器

分类规则:
- Owl: 头部偏航角变化 > 15°,视线落点变化主要由头部运动引起
- Lizard: 头部偏航角变化 < 10°,视线落点变化主要由眼球运动引起
"""

def __init__(self,
fps: int = 30,
owl_yaw_threshold: float = 15.0,
lizard_yaw_threshold: float = 10.0,
history_window: int = 30):
"""
初始化分类器

Args:
fps: 帧率
owl_yaw_threshold: Owl 判定的头部转动阈值(度)
lizard_yaw_threshold: Lizard 判定的头部转动阈值(度)
history_window: 历史窗口大小(帧数)
"""
self.fps = fps
self.owl_yaw_threshold = owl_yaw_threshold
self.lizard_yaw_threshold = lizard_yaw_threshold

# 历史数据缓存
self.yaw_history = deque(maxlen=history_window)
self.gaze_region_history = deque(maxlen=history_window)
self.timestamp_history = deque(maxlen=history_window)

# 事件记录
self.scan_events: List[ScanEvent] = []

# 当前状态
self.current_scan_type = ScanType.UNKNOWN
self.scan_start_time = None
self.scan_start_region = None
self.scan_start_yaw = None

def update(self, head_pose: 'HeadPose', gaze_region: GazeRegion) -> Tuple[ScanType, Optional[ScanEvent]]:
"""
更新分类器,返回当前扫视类型和新事件

Args:
head_pose: 头部姿态
gaze_region: 视线落点区域

Returns:
scan_type: 当前扫视类型
event: 新的扫视事件(如果有)
"""
timestamp = head_pose.timestamp

# 添加到历史
self.yaw_history.append(head_pose.yaw)
self.gaze_region_history.append(gaze_region)
self.timestamp_history.append(timestamp)

# 计算头部转动幅度
if len(self.yaw_history) >= 10:
yaw_delta = abs(max(self.yaw_history) - min(self.yaw_history))
else:
yaw_delta = 0.0

# 判断扫视类型
if yaw_delta > self.owl_yaw_threshold:
new_scan_type = ScanType.OWL
elif yaw_delta < self.lizard_yaw_threshold and gaze_region != GazeRegion.ROAD_FORWARD:
new_scan_type = ScanType.LIZARD
else:
new_scan_type = ScanType.UNKNOWN

# 检测扫视事件
event = None

if new_scan_type != ScanType.UNKNOWN:
if self.current_scan_type == ScanType.UNKNOWN:
# 开始新的扫视
self.scan_start_time = timestamp
self.scan_start_region = gaze_region
self.scan_start_yaw = head_pose.yaw

elif new_scan_type != self.current_scan_type:
# 扫视类型变化,记录事件
if self.scan_start_time is not None:
event = ScanEvent(
timestamp=self.scan_start_time,
scan_type=self.current_scan_type,
start_region=self.scan_start_region,
end_region=gaze_region,
head_rotation=abs(head_pose.yaw - self.scan_start_yaw),
duration=timestamp - self.scan_start_time
)
self.scan_events.append(event)

# 开始新的扫视
self.scan_start_time = timestamp
self.scan_start_region = gaze_region
self.scan_start_yaw = head_pose.yaw

else:
# 扫视结束
if self.current_scan_type != ScanType.UNKNOWN and self.scan_start_time is not None:
event = ScanEvent(
timestamp=self.scan_start_time,
scan_type=self.current_scan_type,
start_region=self.scan_start_region,
end_region=gaze_region,
head_rotation=abs(head_pose.yaw - self.scan_start_yaw) if self.scan_start_yaw else 0,
duration=timestamp - self.scan_start_time
)
self.scan_events.append(event)

self.scan_start_time = None
self.scan_start_region = None
self.scan_start_yaw = None

self.current_scan_type = new_scan_type

return self.current_scan_type, event

def get_phone_use_type(self, gaze_region: GazeRegion) -> str:
"""
根据视线区域和扫视类型判断手机使用类型

Args:
gaze_region: 视线落点区域

Returns:
'basic' / 'advanced' / 'none'
"""
phone_basic_regions = [
GazeRegion.PHONE_KNEE_OUTBOARD,
GazeRegion.PHONE_KNEE_INBOARD,
GazeRegion.PHONE_LAP,
GazeRegion.PHONE_MOUNTED_DASHBOARD,
GazeRegion.PHONE_OEM_POSITION
]

phone_advanced_regions = [
GazeRegion.PHONE_HELD_CENTER,
GazeRegion.PHONE_HELD_WHEEL_TOP,
GazeRegion.PHONE_WINDSCREEN,
GazeRegion.PHONE_CLUSTER
]

if gaze_region in phone_basic_regions:
if self.current_scan_type == ScanType.OWL:
return 'basic'
elif self.current_scan_type == ScanType.LIZARD:
return 'advanced'
else:
return 'basic' # 默认基础使用

elif gaze_region in phone_advanced_regions:
return 'advanced'

return 'none'

三、Euro NCAP 测试场景详解

3.1 手机使用检测测试场景

D-02: 基础手机使用(Owl)

前置条件:

  • 驾驶员正常坐姿
  • 手机放置在膝盖或大腿上
  • 车辆速度 ≥50 km/h

测试步骤:

  1. 驾驶员正常驾驶 30 秒(基线)
  2. 驾驶员转头看向手机(Owl 扫视)
  3. 保持视线在手机区域 3-4 秒
  4. 驾驶员转头回前方道路
  5. 记录检测结果和时延

判定条件:

检测项 通过条件 失败条件
检测触发 检测到基础手机使用 未检测到
扫视类型 Owl Lizard/Unknown
检测时延 ≤3 秒 >3 秒
警告等级 一级警告 二级警告/无警告

预期输出:

1
2
3
4
5
6
7
[00:00] INFO: 开始测试 D-02
[00:30] INFO: 基线建立完成
[00:35] WARN: 检测到 Owl 扫视
[00:36] WARN: 基础手机使用检测,区域: PHONE_LAP
[00:37] WARN: 触发一级警告(手机使用)
[00:40] INFO: 驾驶员视线回前方
[00:42] PASS: D-02 测试通过,时延: 2s

D-03: 高级手机使用(Lizard)

前置条件:

  • 驾驶员正常坐姿
  • 手机持握在方向盘区域
  • 车辆速度 ≥50 km/h

测试步骤:

  1. 驾驶员正常驾驶 30 秒(基线)
  2. 驾驶员眼球转向手机(Lizard 扫视,头部不动)
  3. 模拟打字/滑动操作 3-4 秒
  4. 驾驶员视线回前方道路
  5. 记录检测结果和时延

判定条件:

检测项 通过条件 失败条件
检测触发 检测到高级手机使用 未检测到
扫视类型 Lizard Owl/Unknown
检测时延 ≤3 秒 >3 秒
警告等级 一级/二级警告 无警告

D-04: 持续手机使用

前置条件:

  • 同 D-02

测试步骤:

  1. 驾驶员正常驾驶 30 秒
  2. 驾驶员使用手机 10 秒
  3. 收到一级警告后继续使用 5 秒
  4. 记录是否升级为二级警告

判定条件:

检测项 通过条件 失败条件
一级警告 ≤3 秒触发 >3 秒
二级警告 持续使用后升级 未升级

四、硬件选型指南

4.1 摄像头配置

参数 基础要求 推荐配置 高端配置
分辨率 640×480 1280×720 1920×1080
帧率 25fps 30fps 60fps
视场角 50° 60° 70°
红外波长 850nm 940nm 940nm
全局快门 可选 推荐 必须

4.2 安装位置

位置 优点 缺点 适用场景
仪表板上方 视野好,遮挡少 成本高 高端车型
方向盘柱 成本低,易集成 视野受限 中端车型
A柱 侧面视野好 需要多摄像头 全功能 DMS

4.3 处理器平台

平台 视线估计性能 功耗 适用场景
QCS8255 <20ms 5W 高端车型
TDA4VM <30ms 5W 中端车型
RK3588 <25ms 6W 性价比方案

五、IMS 开发优先级

优先级 模块 功能 精度要求
P0 头部姿态估计 Pitch/Yaw/Roll 角度误差 <3°
P0 视线落点估计 区域分类 准确率 >85%
P0 Owl/Lizard 分类 扫视类型识别 准确率 >80%
P1 手机使用检测 基础/高级分类 召回率 >90%
P1 持续使用检测 时长统计 时延 <1s
P2 手部检测 手机持握 准确率 >80%

六、参考资料

6.1 官方文档

6.2 技术参考


发布日期: 2026-04-16
标签: Euro NCAP, DMS, 手机检测, 分心检测, Owl, Lizard, 视线追踪, VATS
更新记录: v1.0 - 初始版本,完整实现 Owl/Lizard 分类与视线落点估计


Euro NCAP 2026 手机使用检测深度解析:Owl/Lizard 扫视分类与视线落点追踪完整实现
https://dapalm.com/2026/04/16/2026-04-16-euro-ncap-2026-phone-use-detection-owl-lizard/
作者
Mars
发布于
2026年4月16日
许可协议