EyeTAG:基于轨迹感知的视线估计框架——BMVC 2026论文解读与代码复现

论文信息

项目 内容
标题 EyeTAG: Eye Trajectory-Aware Gaze Estimation
作者 Jungmin Lee, Niamat Ullah, Yoseob Han
机构 Soongsil University, Seoul, Republic of Korea
会议 BMVC 2026 (British Machine Vision Conference)
arXiv 2610.00922
代码 GitHub: peter8366/EyeTAG
框架 PyTorch, Python 3.11

核心创新

EyeTAG的核心创新是显式的一阶视线先验(first-order gaze prior):模型在每一步将自己的近期预测做差分(differencing),得到视线运动轨迹,并将其反馈为紧凑的运动学token。由于差分操作在视线空间中具有平移不变性,该token携带的是跨被试通用的运动模式而非个体化的视线偏移量。

一句话总结:首次将视线轨迹作为显式变量引入视频视线估计,通过差分反馈实现运动学先验建模。

问题背景

当前痛点

问题 现有方法 局限
单帧抖动 单帧方法独立预测每帧 连续输出波动为不真实的抖动
隐式时序 多帧LSTM/注意力方法 运动隐含在高维外观特征中,视线轨迹从未作为显式变量
扫视偏差 绝对视线历史先验 存在系统性扫视偏差(saccade bias),即使有时序上下文也无法消除
个体差异 个体化的绝对视线偏移 不同被试的静息视线不同,绝对先验不可泛化

EyeTAG的解决思路

方法详解

1. 显式一阶视线先验

EyeTAG将视线方向建模为一阶差分形式:

$$\mathbf{g}t = \mathbf{g}{t-1} + \Delta\mathbf{g}_t$$

其中 $\mathbf{g}_t \in \mathbb{R}^3$ 是3D视线向量,$\Delta\mathbf{g}_t$ 是连续帧间的差分变化。

关键性质: 差分操作是视线空间中的平移不变操作。对整个轨迹施加一个常数偏移 $c$:

$$\Delta\mathbf{g}’_t = (\mathbf{g}t + c) - (\mathbf{g}{t-1} + c) = \Delta\mathbf{g}_t$$

这意味着先验携带的是运动模式(方向变化率),而非个体化的视线偏移量。

2. 轨迹感知运动学编码器(TAKE)

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
import torch
import torch.nn as nn
from typing import Tuple

class TrajectoryAwareKinematicEncoder(nn.Module):
"""
EyeTAG论文 Section 3.2 的TAKE模块

将过去预测的差分序列编码为运动学token

Args:
prev_dim: 历史窗口大小 T
gaze_dim: 视线向量维度 (默认3, 3D单位向量)
hidden_dim: MLP隐藏层维度
out_dim: 输出token维度
"""

def __init__(self, prev_dim: int = 48, gaze_dim: int = 3,
hidden_dim: int = 256, out_dim: int = 256):
super().__init__()
self.prev_dim = prev_dim
self.gaze_dim = gaze_dim

# 差分计算:将绝对历史转为差分序列
# Δg_t = g_t - g_{t-1}

# MLP编码差分序列
self.delta_mlp = nn.Sequential(
nn.Linear(prev_dim * gaze_dim, hidden_dim),
nn.GELU(),
nn.Linear(hidden_dim, hidden_dim),
nn.GELU(),
nn.Linear(hidden_dim, out_dim)
)

# LayerNorm 稳定训练
self.norm = nn.LayerNorm(out_dim)

def compute_deltas(self, prev_gazes: torch.Tensor) -> torch.Tensor:
"""
计算差分序列

Args:
prev_gazes: 过去T帧的视线预测, shape=(B, T, 3)

Returns:
deltas: 差分序列, shape=(B, T-1, 3)
"""
# Δg_t = g_t - g_{t-1}
deltas = prev_gazes[:, 1:] - prev_gazes[:, :-1]
return deltas

def forward(self, prev_gazes: torch.Tensor) -> torch.Tensor:
"""
前向传播

Args:
prev_gazes: 过去T帧视线预测, shape=(B, T, 3)

Returns:
kinematic_token: 运动学token, shape=(B, out_dim)
"""
B = prev_gazes.shape[0]

# 步骤1: 计算差分
deltas = self.compute_deltas(prev_gazes) # (B, T-1, 3)

# 步骤2: 展平
deltas_flat = deltas.reshape(B, -1) # (B, (T-1)*3)

# 步骤3: MLP编码
token = self.delta_mlp(deltas_flat) # (B, out_dim)

# 步骤4: LayerNorm
token = self.norm(token)

return token


# 测试
if __name__ == "__main__":
# 模拟参数
B, T, D = 4, 48, 3 # batch=4, 窗口=48帧, 3D视线

# 创建TAKE模块
take = TrajectoryAwareKinematicEncoder(prev_dim=T-1, gaze_dim=D)

# 模拟过去48帧的视线预测 (3D单位向量)
prev_gazes = torch.randn(B, T, D)
prev_gazes = prev_gazes / prev_gazes.norm(dim=-1, keepdim=True) # 归一化

# 前向传播
token = take(prev_gazes)
print(f"输入: prev_gazes shape={prev_gazes.shape}")
print(f"差分序列: deltas shape={take.compute_deltas(prev_gazes).shape}")
print(f"输出: kinematic_token shape={token.shape}")
print(f"参数量: {sum(p.numel() for p in take.parameters()):,}")

3. 多模态融合架构

EyeTAG的完整架构包含三个流:

模块 骨干网络 输入 输出维度
Face Encoder ResNet-50 (VGGFace2预训练) 面部图像裁剪 2048
Eye Encoder ResNet-18 左右眼裁剪 512
TAKE MLP 过去T帧差分视线 256

融合模块使用交叉注意力(Cross-Attention)和因果Transformer解码器:

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
class EyeTAGFusionModule(nn.Module):
"""
EyeTAG论文 Section 3.3 的融合模块

使用交叉注意力对齐面部和眼部特征,
再通过因果Transformer解码器融合运动学token
"""

def __init__(self, face_dim=2048, eye_dim=512, kinematic_dim=256,
hidden_dim=256, num_heads=4, num_layers=2, max_len=48):
super().__init__()

# 维度对齐
self.face_proj = nn.Linear(face_dim, hidden_dim)
self.eye_proj = nn.Linear(eye_dim, hidden_dim)

# 交叉注意力: Eye查询Face
self.cross_attn = nn.MultiheadAttention(
embed_dim=hidden_dim,
num_heads=num_heads,
batch_first=True
)

# 因果Transformer解码器
decoder_layer = nn.TransformerDecoderLayer(
d_model=hidden_dim,
nhead=num_heads,
dim_feedforward=hidden_dim * 4,
batch_first=True,
activation='gelu'
)
self.transformer_decoder = nn.TransformerDecoder(
decoder_layer,
num_layers=num_layers
)

# 输出头: 3D视线方向
self.gaze_head = nn.Linear(hidden_dim, 3)

# 位置编码
self.pos_embed = nn.Parameter(torch.randn(1, max_len, hidden_dim) * 0.02)

# 因果掩码
self.register_buffer(
'causal_mask',
torch.triu(torch.ones(max_len, max_len) * float('-inf'), diagonal=1)
)

def forward(self, face_feat, eye_feat, kinematic_token,
seq_len=None):
"""
Args:
face_feat: (B, T, face_dim) 面部特征序列
eye_feat: (B, T, eye_dim) 眼部特征序列
kinematic_token: (B, hidden_dim) 运动学token
seq_len: 实际序列长度
Returns:
gaze_3d: (B, 3) 3D视线方向
"""
B = face_feat.shape[0]
T = seq_len if seq_len is not None else face_feat.shape[1]

# 维度对齐
face = self.face_proj(face_feat) # (B, T, hidden)
eye = self.eye_proj(eye_feat) # (B, T, hidden)

# 交叉注意力: Eye查询Face
attn_out, _ = self.cross_attn(
query=eye, # (B, T, hidden)
key=face, # (B, T, hidden)
value=face # (B, T, hidden)
)

# 融合后的视觉特征
visual_feat = eye + attn_out # 残差连接

# 位置编码
visual_feat = visual_feat + self.pos_embed[:, :T, :]

# 运动学token扩展为序列前缀
kinematic_seq = kinematic_token.unsqueeze(1) # (B, 1, hidden)
memory = torch.cat([kinematic_seq, visual_feat], dim=1) # (B, T+1, hidden)

# 因果Transformer解码
# tgt是memory本身(自回归)
causal_out = self.transformer_decoder(
tgt=memory,
memory=memory,
tgt_mask=self.causal_mask[:memory.shape[1], :memory.shape[1]]
)

# 取最后一帧的输出预测3D视线
last_hidden = causal_out[:, -1, :] # (B, hidden)
gaze_3d = self.gaze_head(last_hidden) # (B, 3)

# 归一化为单位向量
gaze_3d = gaze_3d / (gaze_3d.norm(dim=-1, keepdim=True) + 1e-8)

return gaze_3d


# 测试
if __name__ == "__main__":
B, T = 4, 48

fusion = EyeTAGFusionModule()

# 模拟特征
face_feat = torch.randn(B, T, 2048)
eye_feat = torch.randn(B, T, 512)
kin_token = torch.randn(B, 256)

gaze = fusion(face_feat, eye_feat, kin_token)
print(f"Face特征: {face_feat.shape}")
print(f"Eye特征: {eye_feat.shape}")
print(f"运动学token: {kin_token.shape}")
print(f"输出3D视线: {gaze.shape}")
print(f"视线向量范数: {gaze.norm(dim=-1)}")
print(f"融合模块参数量: {sum(p.numel() for p in fusion.parameters()):,}")

4. 计划采样训练策略

EyeTAG使用**计划采样(Scheduled Sampling)**解决训练-推理不一致问题:

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
class ScheduledSampling:
"""
EyeTAG论文 Section 3.4 的计划采样策略

训练时,输入TAKE的视线历史以概率α来自真实标签,
否则来自模型自身预测缓存。α从1.0线性退火到0.0。

关键:缓存每个epoch重建一次,无需在线rollout
"""

def __init__(self, total_epochs=5, start_alpha=1.0, end_alpha=0.0):
self.start_alpha = start_alpha
self.end_alpha = end_alpha
self.total_epochs = total_epochs

def get_alpha(self, epoch: int) -> float:
"""获取当前epoch的α值"""
if epoch >= self.total_epochs:
return self.end_alpha
progress = epoch / self.total_epochs
alpha = self.start_alpha - (self.start_alpha - self.end_alpha) * progress
return max(alpha, self.end_alpha)

def sample_history(self, gt_gazes, pred_cache, alpha):
"""
混合真实和预测的历史

Args:
gt_gazes: (B, T, 3) 真实视线标签
pred_cache: (B, T, 3) 模型预测缓存
alpha: 使用真实标签的概率

Returns:
mixed_history: (B, T, 3) 混合历史
"""
B, T, D = gt_gazes.shape
# 每帧独立采样
use_gt = torch.rand(B, T, 1, device=gt_gazes.device) < alpha
mixed = torch.where(use_gt, gt_gazes, pred_cache)
return mixed


# 退火曲线示例
if __name__ == "__main__":
import matplotlib
matplotlib.use('Agg')

ss = ScheduledSampling(total_epochs=5)
print("Epoch | α值 | 真实标签比例")
print("------|-------|------------")
for ep in range(8):
alpha = ss.get_alpha(ep)
print(f" {ep:2d} | {alpha:.2f} | {alpha*100:.0f}%")

5. 差分表示的选择

论文对比了多种几何表示:

表示方式 公式 说明
delta (pitch-yaw) $\Delta(\text{pitch}, \text{yaw})$ 论文默认,差分俯仰/偏航角
vector $\Delta\mathbf{g} \in \mathbb{R}^3$ 3D向量差分
tangent 切空间投影 投影到切平面
angvel 角速度 旋转角速度表示

论文实验表明 delta 表示在Gaze360上效果最佳。

实验结果

Gaze360基准

方法 类型 All (°) Semi-Front (°) Front (°) Mean (°)
L2CS-Net SF 11.10 10.77 9.92 10.60
GazeTR-Hybrid SF 10.56 10.30 9.63 10.16
CrossGaze SF 10.47 10.29 9.23 10.00
Gaze360 MF 10.46 10.18 9.32 9.99
STAGE MF 12.08 11.63 9.43 11.05
EyeTAG (ours) MF 9.29 9.12 8.08 8.83

EVE基准

方法 MAE (°)
CrossGaze 3.30
Gaze360 2.73
STAGE 2.58
EyeTAG 2.56

时序稳定性分析

| 方法 | J (抖动↓) | B (扫视偏差→0) | M = (J+|B|)/2 |
|——|———–|—————-|—————|
| CrossGaze | 5.06 | +0.23 | 2.65 |
| Gaze360 (non-causal) | 1.71 | −4.24 | 2.98 |
| STAGE | 3.81 | −0.89 | 2.35 |
| EyeTAG | 3.68 | +0.05 | 1.87 |

关键发现:差分先验消除了扫视偏差(B从−0.46降到+0.05),而不仅仅是时序平滑的效果。

模型复杂度

指标 数值
参数量 37.91M
计算量 121.57 GFLOPs
推理时间 19.97ms / 48帧窗口
硬件 RTX A6000, FP32, batch=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
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
"""
EyeTAG 完整推理示例

论文:EyeTAG: Eye Trajectory-Aware Gaze Estimation
会议:BMVC 2026
arXiv: 2610.00922
GitHub: https://github.com/peter8366/EyeTAG

依赖:
pip install torch torchvision decord h5py

# 需要VGGFace2预训练权重
# 下载: https://github.com/cydonia999/VGGFace2-pytorch
# 放置于: checkpoints/resnet50_ft_weight.pkl
"""

import torch
import torch.nn as nn
import torchvision.models as models
from typing import Optional
import numpy as np


class EyeTAGInference(nn.Module):
"""
EyeTAG完整推理模型

使用方法:
model = EyeTAGInference(num_frames=48)
model.load_state_dict(torch.load('eyetag_gaze360.pth'))

# 流式推理
face, left_eye, right_eye = preprocess(frame)
gaze = model(face, left_eye, right_eye)
"""

def __init__(self, num_frames: int = 48):
super().__init__()
self.num_frames = num_frames

# Face Encoder: ResNet-50 (VGGFace2预训练)
self.face_encoder = models.resnet50(weights=None)
self.face_encoder.fc = nn.Identity() # 移除分类头
self.face_proj = nn.Linear(2048, 256)

# Eye Encoder: ResNet-18
self.eye_encoder = models.resnet18(weights=None)
self.eye_encoder.fc = nn.Identity()
self.eye_proj = nn.Linear(512, 256)

# 运动学编码器
self.take = TrajectoryAwareKinematicEncoder(
prev_dim=num_frames - 1,
gaze_dim=3,
hidden_dim=256,
out_dim=256
)

# 融合模块
self.fusion = EyeTAGFusionModule(
face_dim=256,
eye_dim=256,
kinematic_dim=256,
hidden_dim=256,
num_heads=4,
num_layers=2,
max_len=num_frames
)

# 历史预测缓存(流式推理用)
self.register_buffer(
'gaze_history',
torch.zeros(1, num_frames, 3)
)

def forward(self, face: torch.Tensor,
left_eye: torch.Tensor,
right_eye: torch.Tensor) -> torch.Tensor:
"""
单帧流式推理

Args:
face: (1, 3, 224, 224) 面部裁剪
left_eye: (1, 3, 64, 64) 左眼裁剪
right_eye: (1, 3, 64, 64) 右眼裁剪

Returns:
gaze_3d: (1, 3) 3D视线单位向量
"""
# 编码
face_feat = self.face_proj(self.face_encoder(face)) # (1, 256)
eye_feat = self.eye_proj(
self.eye_encoder(torch.cat([left_eye, right_eye], dim=0).mean(dim=0, keepdim=True))
) # (1, 256)

# 扩展为序列形式(流式:只取当前帧)
face_seq = face_feat.unsqueeze(1) # (1, 1, 256)
eye_seq = eye_feat.unsqueeze(1) # (1, 1, 256)

# 运动学token
kin_token = self.take(self.gaze_history) # (1, 256)

# 融合预测
gaze = self.fusion(face_seq, eye_seq, kin_token, seq_len=1) # (1, 3)

# 更新历史缓存(滚动窗口)
with torch.no_grad():
self.gaze_history = torch.cat([
self.gaze_history[:, 1:, :],
gaze.detach()
], dim=1)

return gaze

def reset_history(self):
"""重置历史缓存(新被试时调用)"""
self.gaze_history.zero_()


def preprocess_frame(frame: np.ndarray,
face_bbox: tuple,
eye_bboxes: dict) -> tuple:
"""
预处理单帧图像

Args:
frame: (H, W, 3) BGR图像
face_bbox: (x, y, w, h) 面部边界框
eye_bboxes: {'left': (x,y,w,h), 'right': (x,y,w,h)}

Returns:
face_tensor: (1, 3, 224, 224)
left_eye_tensor: (1, 3, 64, 64)
right_eye_tensor: (1, 3, 64, 64)
"""
import cv2

def crop_resize(img, bbox, size):
x, y, w, h = bbox
crop = img[y:y+h, x:x+w]
crop = cv2.resize(crop, (size, size))
crop = crop[:, :, ::-1] # BGR -> RGB
crop = crop.astype(np.float32) / 255.0
# ImageNet标准化
mean = np.array([0.485, 0.456, 0.406])
std = np.array([0.229, 0.224, 0.225])
crop = (crop - mean) / std
crop = np.transpose(crop, (2, 0, 1))
return torch.from_numpy(crop).unsqueeze(0).float()

face = crop_resize(frame, face_bbox, 224)
left = crop_resize(frame, eye_bboxes['left'], 64)
right = crop_resize(frame, eye_bboxes['right'], 64)

return face, left, right


# 完整演示
if __name__ == "__main__":
print("=" * 60)
print("EyeTAG 推理演示")
print("=" * 60)

# 初始化模型
model = EyeTAGInference(num_frames=48)
model.eval()

total_params = sum(p.numel() for p in model.parameters())
print(f"\n模型参数量: {total_params:,} ({total_params/1e6:.2f}M)")
print(f"预期: ~37.91M (论文报告)")

# 模拟单帧推理
face = torch.randn(1, 3, 224, 224)
left_eye = torch.randn(1, 3, 64, 64)
right_eye = torch.randn(1, 3, 64, 64)

# 重置历史
model.reset_history()

# 流式推理10帧
print("\n流式推理10帧:")
print(f"{'帧':>4} | {'pitch(°)':>10} | {'yaw(°)':>10} | {'|g|':>8}")
print("-" * 42)

for i in range(10):
with torch.no_grad():
gaze = model(face, left_eye, right_eye)

# 转为角度
pitch = torch.asin(gaze[0, 1]).item() * 180 / np.pi
yaw = torch.atan2(gaze[0, 0], gaze[0, 2]).item() * 180 / np.pi
norm = gaze[0].norm().item()

print(f"{i:4d} | {pitch:10.2f} | {yaw:10.2f} | {norm:8.6f}")

print(f"\n历史缓存状态: shape={model.gaze_history.shape}")
print(f"历史缓存非零帧数: {(model.gaze_history.abs().sum(-1) > 0).sum().item()}")

IMS开发启示

1. 直接可用的改进点

IMS模块 当前方案 EyeTAG改进 预期提升
视线估计 单帧GazeTR 差分轨迹先验 抖动降低30%+,扫视偏差消除
分心检测 帧级视线区域分类 时序连续视线轨迹 减少误报,提升检测稳定性
PERCLOS 帧级闭眼判定 时序平滑后的闭眼统计 误报率降低
视线落点 瞬时视线→区域 轨迹→意图预测 提前200ms预测视线落点

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
# 边缘部署优化建议
# 1. 视觉编码器缓存(流式部署)
class CachedEyeTAG:
"""
流式部署优化:视觉编码器跨帧缓存

论文说明:GFLOPs figure是无缓存上限
流式部署中,视觉编码器可跨滑动窗口缓存
"""

def __init__(self, model, cache_size=48):
self.model = model
self.cache_size = cache_size
self.face_cache = [] # 缓存面部特征
self.eye_cache = [] # 缓存眼部特征

def step(self, face_img, left_eye, right_eye):
# 编码当前帧
with torch.no_grad():
face_feat = self.model.face_proj(
self.model.face_encoder(face_img)
)
eye_feat = self.model.eye_proj(
self.model.eye_encoder(
torch.cat([left_eye, right_eye]).mean(0, keepdim=True)
)
)

# 更新缓存
self.face_cache.append(face_feat)
self.eye_cache.append(eye_feat)
if len(self.face_cache) > self.cache_size:
self.face_cache.pop(0)
self.eye_cache.pop(0)

# 只计算运动学token和融合
# 省去重复的视觉编码计算
# 实际FLOPs可降低到原来的1/48
pass

3. 优先级排序

任务 优先级 工作量 预期收益
替换视线估计为EyeTAG差分先验 🔴 高 2人周 抖动↓30%,偏差↓90%
集成到分心检测时序模块 🟡 中 1人周 误报率↓20%
流式部署缓存优化 🟡 中 1人周 推理速度↑3x
多表示对比实验 🟢 低 0.5人周 选型依据

总结

EyeTAG的核心贡献是将视线轨迹从隐式变量变为显式变量,通过差分反馈实现运动学先验建模。这一设计:

  1. 跨被试泛化:差分操作消除个体化偏移,运动模式通用
  2. 消除扫视偏差:B从−0.46→+0.05,不是平滑而是机制性消除
  3. 因果推理:只用过去信息,支持流式部署
  4. 即插即用:TAKE模块可附加到任何单帧视线估计器上

代码开源:https://github.com/peter8366/EyeTAG

IMS落地建议:优先在视线估计模块集成TAKE差分先验,预期减少30%抖动和90%扫视偏差,直接提升分心检测和PERCLOS的稳定性。