简介:一套面向毕业设计的视线估计(gaze estimation)few-shot 项目源码,核心工作为复现并优化 Seonwook Park 的 few_shot_gaze 方法,并基于 MPIIFaceGaze 与 GazeCapture 两大公开数据集完成训练与评估。压缩包共93个文件、整体约13.49MB,主体为41个 Python 脚本,覆盖 HDF5 数据预处理、自编码器训练、元学习阶段与结果合并等完整流程;同时附带 DenseNet、DT-ED 模型定义、多种损失函数实现、Caffe 模型与 prototxt 配置,以及可用于实时摄像头演示的 demo 脚本、MTCNN 人脸检测组件与相机标定工具。源码目录结构清晰,训练、测试、演示模块分离,并配有 README 说明、依赖清单与 shell 脚本,便于在 Linux 环境下复现实验;元学习与嵌入一致性等关键代码也便于逐段阅读对比。目前已有287人浏览学习,适合需要快速搭建少样本视线估计基线、理解元学习与嵌入一致性约束的计算机视觉方向毕业生或研究者参考。
1. few-shot-gaze是什么:两个数据集和一个毕设级复现问题
拿到这个标题时我第一反应是——又有同学在视线估计(gaze estimation)这个方向上选了个硬骨头。few-shot-gaze并不是某个单独的算法包名,而是“用少量带标注样本来训练视线估计模型”这一类问题的统称。标题里把MPIIFaceGaze和GazeCapture两个数据集放在一起,指向的是一个近年很常见的复现组合:前者是实验室环境下的头部姿态大样本数据集,后者是手机和平板环境下的大规模众包数据集。两个数据集的域差异(domain gap)非常大,跨数据集做少样本学习,正好是检验一个模型泛化能力最狠的试金石。
你要做的,就是把这样一个zip源码包里的训练流程跑通,搞清楚它为什么能在10张、20张带标注样本下拟合出新用户的视线方向,然后想办法优化它。这篇笔记我按自己的复现路径来写:环境怎么搭、数据怎么解析、few-shot训练循环怎么写、到底哪些坑会让你熬夜,最后收在如何验证优化效果上。适合正在做毕设、或者刚接触视线估计想要一个完整落地方案的人。
2. 复现前的环境与依赖:python版本、PyTorch与CUDA的选型
2.1 为什么python版本选择和CUDA绑定会让项目翻车
视线估计模型几乎都跑在PyTorch上,而PyTorch的版本链对python版本极其敏感。我见过的复现失败案例里,有一半是环境问题而不是代码问题。标题里的源码包大概率来自GitHub上某个学术实现,这类项目最常见的配置是python 3.7-3.9 + PyTorch 1.8-1.13。如果你直接上python 3.10+,老版本的torchvision可能无法import,分布式训练相关的APIs会报AttributeError。
这里我给一个保守但兼容性最好的组合:python 3.8 + CUDA 11.3 + PyTorch 1.12.1。这个组合能跑通绝大多数2022-2023年发布的few-shot相关开源项目。如果你显卡驱动只支持CUDA 12.x,那就换成PyTorch 2.0+,但要注意老代码里init_distributed_mode这类函数在新版本下参数变了,需要小幅修改。
2.2 用conda建立隔离环境:一份能直接用的安装脚本
我建议直接用conda建环境,python版本锁定在3.8,然后在conda环境里用pip装torch系列。这样做的好处是protobuf、numpy这些底层库的版本冲突概率会低很多。
conda create -n fsgaze python=3.8 -y conda activate fsgaze pip install torch==1.12.1 torchvision==0.13.1 --index-url https://download.pytorch.org/whl/cu113 pip install opencv-python==4.6.0.66 opencv-contrib-python==4.6.0.66 pip install numpy==1.23.5 scipy==1.9.3 pandas matplotlib tqdm tensorboard这段脚本里有个细节值得说明:opencv-python我把版本卡在4.6。原因是很多视线估计代码里会用cv2.face模块做人脸对齐,而新版OpenCV把face模块移到了opencv-contrib-python里,你不锁定版本就会遇到AttributeError: module 'cv2' has no attribute 'face'。scipy==1.9.3则是为了兼容读取.mat文件时的一些老API——如果你用了更新的scipy,部分低版本.mat文件读取会直接报错。
装完之后先import一遍,确认torch.cuda.is_available()返回True。如果返回False,大概率不是python问题,而是PyTorch的CUDA版本和驱动不匹配——Windows下可以用GPU-Z看驱动支持的CUDA版本号。这一步做对了,后续的工作基本就是舒舒服服调参数,做不对的话后面每一步都像是在踩雷。
2.3 源码包的目录结构为什么值得先花5分钟看一遍
解压zip后,不要急着找train.py去跑。先看一眼目录结构。常见的视线估计项目一般长这样:
few-shot-gaze/ ├── configs/ # yaml或py格式的配置文件 ├── datasets/ # 数据集解析代码 ├── models/ # 主干网络和head定义 ├── utils/ # 坐标系转换、指标计算、平均脸等 ├── train.py # 训练入口 ├── eval.py # 评估入口 └── requirements.txt我会先在configs目录里看数据集路径字段简写成了什么,比如mpiifacegaze_root、gaze_capture_dir。然后顺着它去datasets里找加载代码。这个动作能帮你提前预判数据集解析逻辑里有没有硬编码路径,避免后面运行到一半才爆出一个FileNotFoundError。多数源码包不会给你完整数据集,只会给你一个示例目录结构。所以提前看清楚它期望的目录形态很重要。
3. 吃透两个数据集的加载与预处理:MPIIFaceGaze和GazeCapture
3.1 MPIIFaceGaze的标注格式与读取方法
MPIIFaceGaze已经是视线估计领域的事实基准之一。15个参与者的数据,每个参与者采集约3000张人脸图像,标注字段是3D视线方向向量和对应的2D注视点屏幕坐标。它的原始标注文件是.mat格式,里面的人脸图像已经做了归一化裁剪,尺寸是60x60灰度图。这个尺寸非常小,直接决定了模型输入分辨率不需要设得太高。
读取.mat文件是第一步坑:MPIIFaceGaze官网下载的.mat文件是用MATLAB保存的结构体数组,需要scipy.io.loadmat来读,但要注意h5py和scipy读出来的结构不同——新版的MPIIFaceGaze数据集打包成.h5格式,而标题里的源码包如果给你们的是老脚本,那多半还是按.mat写的。下面这段代码兼容两种格式:
import h5py import numpy as np import scipy.io as sio def load_mpiigaze_annotations(path): """读取MPIIFaceGaze标注文件,兼容.mat和.h5两种格式""" if path.endswith('.mat'): data = sio.loadmat(path) # 老版本.mat格式:data['data']是一个结构体,里面有face和gaze字段 face = data['data']['face'][0, 0] # N x 60 x 60 的归一化人脸 gaze = data['data']['gaze'][0, 0] # N x 3 的视线方向向量 head = data['data']['head'][0, 0] # N x 3 的头部姿态向量 else: # .h5 with h5py.File(path, 'r') as f: person_ids = list(f.keys()) face = [] gaze = [] for pid in person_ids: face.append(np.array(f[pid]['face'])) gaze.append(np.array(f[pid]['gaze'])) face = np.concatenate(face, axis=0) gaze = np.concatenate(gaze, axis=0) return face, gaze这段代码有两个关键处理:第一,.mat格式拿到的face数组要检查维度是(N, 60, 60)还是(N, 60, 60, 1),后者需要squeeze掉最后一维,否则大多数模型的Conv2d第一层会报维度不匹配;第二,.h5格式的gaze向量单位是弧度,而.mat格式里同样是弧度——千万别当成角度去算损失,数值会差出57.3倍,这是视线估计领域最经典的翻车点之一。读取完之后,你要统一先做一个/ 180 * np.pi之类的检查,确认数据的取值范围:视线向量各分量应该落在[-1, 1]区间,头部姿态向量各分量应该在[-π/2, π/2]左右,但通常只有一个分量接近边界。
3.2 ????GazeCapture的JSON结构与帧采样策略
GazeCapture是移动端视线数据集,采样频率约为30fps,包含用户在自然使用手机时的人脸视频帧、注视点坐标以及设备传感器数据。它的原始数据下载后是一堆用户目录,每个用户目录里有一个frames子目录,放的是裁剪好的人脸图片(160x120彩色),以及一个包含人脸关键点、2D视线目标点、头部姿态角度的JSON文件。这个数据集最大的问题不是格式难读,而是数据量太大——完整下载要几十个GB,对毕设来说根本没必要。
通常源码包里给出的GazeCapture配置会支持按比例采样。比如只取其中5个用户目录,每个用户取200帧做few-shot微调,那就是非常典型的few-shot场景。解析engine的伪代码大概是这样:
import json import os import cv2 import numpy as np def load_gazecapture_sample(user_dir, frame_index): """读取GazeCapture单帧数据,返回人脸图、视线方向、头姿""" json_path = os.path.join(user_dir, 'frames', f'{frame_index}.json') img_path = os.path.join(user_dir, 'frames', f'{frame_index}.jpg') with open(json_path, 'r') as f: meta = json.load(f) # JSON里存的是2D注视点坐标,需要按输入图像尺寸归一化 h, w = 160, 120 gaze_x = meta['gaze_point_x'] / w gaze_y = meta['gaze_point_y'] / h # 有些版本会直接给出3D视线向量 gaze_3d = np.array(meta.get('gaze_3d', [gaze_x, gaze_y, 0.0])) img = cv2.imread(img_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 两个数据集统一成灰度 return img, gaze_3d这段代码需要注意gaze_point_x / w的归一化方式。GazeCapture的标注坐标原点是图像左上角,单位是像素,方向向右和下。而很多模型训练时使用的视线标签是“从眼睛到头戴设备屏幕中心连线与屏幕法线的夹角”,这是两组不一样的东西。如果源码包里的模型是直接回归3D视线向量,那么要对GazeCapture的2D注视点做一次反投影,把人眼坐标和屏幕注视点连成一条空间射线,再换算到相机坐标系下的方向向量。这个转换通常在utils/geometry.py里已经实现,你要做的是确认它调用时传入的相机内参到底是设备默认值还是外部标定值。
3.3 划分train/val/test时最容易犯的交叉污染错误
复现时一个很容易翻车的小动作,是直接在所有用户的数据上混合后随机划分训练集和测试集。视线估计的隐私属性很强——同一个人的脸部外观会在训练和测试里同时出现时,模型记住的是这个人长得什么样,而不是视线往哪看,测试分数会虚高得很厉害。正确的做法是按用户划分,即MPIIFaceGaze的15个参与者里取11个做训练、2个做验证、2个做测试,GazeCapture也是按用户目录划分,且训练用户与测试用户完全没有交集。
def split_by_person(person_ids, train_ratio=0.7, val_ratio=0.1, seed=42): """按用户划分数据集,确保测试用户没参与过训练""" rng = np.random.RandomState(seed) shuffled = rng.permutation(person_ids) n_train = int(len(shuffled) * train_ratio) n_val = int(len(shuffled) * val_ratio) train_persons = shuffled[:n_train] val_persons = shuffled[n_train:n_train + n_val] test_persons = shuffled[n_train + n_val:] return train_persons, val_persons, test_persons如果你在源码包里看到的是按“帧索引”随机划分而不是按用户划分,那这个源码包大概率是个玩具实现,不建议直接拿来做毕设基线。交叉污染会让评估结果失去意义,你在论文里写出来的benchmark也经不起评委追问。
另外还有一个细节:MPIIFaceGaze的60x60图像是经过人脸归一化的,头部中心已经被对齐到图像中心;而GazeCapture的160x120图像只是简单裁剪,并没有做姿态归一化。如果源码包里的模型期望输入是“对齐后的脸”,那你在训练GazeCapture数据前,要先用人脸关键点做一次similarity transform。不做这一步,跨域few-shot的效果会非常难看。
4. few-shot训练的主体实现:从主干网络到episode采样
4.1 主干网络选型:ResNet18是底线而非上限
视线估计模型的主干网络通常没有做得特别大。MPIIFaceGaze的输入才60x60,ResNet18甚至ResNet10已经能提取足够好的特征。使用更大的ResNet50在参数和显存上付出代价,精度提升却非常有限——因为输入分辨率本身限制了信息上限。few-shot条件下,大模型反而更容易过拟合,支持集(support set)只有10张或20张图片,微调阶段模型很快就记住了这些样本的个体特征而非视线语义。
所以复现时我建议先保留源码包的默认主干结构,只在最后一层全连接层上做调整。常见做法是去掉原始的1000类ImageNet分类头,换成输出维度为2(2D视线角)或3(3D视线方向)的小head。这会让预训练权重发挥作用,也不会破坏原有特征提取能力。
import torch import torch.nn as nn import torchvision.models as models class GazeResNet(nn.Module): """在ResNet18基础上替换分类头为视线回归头""" def __init__(self, pretrained=True, out_dim=2): super().__init__() backbone = models.resnet18(pretrained=pretrained) self.features = nn.Sequential( backbone.conv1, backbone.bn1, backbone.relu, backbone.maxpool, backbone.layer1, backbone.layer2, backbone.layer3, backbone.layer4, ) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512, out_dim) def forward(self, x): x = self.features(x) x = self.avgpool(x) x = torch.flatten(x, 1) return self.fc(x)这里有几个参数值得说明。out_dim=2对应回归两个视线角度(偏航角yaw和俯仰角pitch),out_dim=3对应回归3D单位方向向量。两种输出格式对应的损失函数不太一样:角度回归常用nn.L1Loss(平均绝对误差),3D向量回归常用nn.MSELoss——但要把预测向量做归一化,使其模长为1。源码包里如果直接对未归一化的向量做MSE,模型会学歪,因为它可以用缩小向量模长来投机取巧地把loss压小。
另外要注意,如果源码包里的模型在torchvision的ResNet基础上改了第一层卷积的stride和kernel_size,比如为了适应60x60低分辨率,通常会把第一层从kernel=7, stride=2改成kernel=3, stride=1,并去掉后续的maxpool。如果你不做这种改动,60x60输入经过两层下采样后感受野会被压缩得很厉害,视线特征根本没有足够大的空间范围来捕捉。我在实验里对比过,单是这一项,就能让MPIIFaceGaze上的角度误差差出1度左右。
4.2 episode采样:support set与query set的组织方式
few-shot视线估计的实现,典型做法是在训练阶段构造episode来模拟测试时的少样本场景。每个episode相当于一个学习任务:随机抽一个用户作为目标,从他的样本中拿K张作为support set,再抽一批其他样本作为query set。模型要从support set里尽快适应这个人的视线模式,再在query set上给出预测。
class FewShotEpisodeSampler: """构造few-shot episode的核心类:稳定随机种子,保证可复现""" def __init__(self, person_to_indices, k_shot=8, n_query=16, seed=42): self.rng = np.random.RandomState(seed) self.person_to_indices = person_to_indices self.k_shot = k_shot self.n_query = n_query def sample_episode(self): """返回一个task的support/query索引列表""" target_person = self.rng.choice(list(self.person_to_indices.keys())) indices = np.array(self.person_to_indices[target_person]) self.rng.shuffle(indices) support_idx = indices[:self.k_shot] query_idx = indices[self.k_shot:self.k_shot + self.n_query] return support_idx, query_idx, target_person参数上,k_shot就是每类(每个人)的标注样本数,few-shot视线估计里常见取值是4、8、16。n_query不宜太小,否则query上的评估噪声大,每个episode的loss波动会很明显。另一个参数是episode数量——一个epoch里构造多少个episode,我一般设200-400,太少则模型见过的任务数不足,太多则训练时间过长。
为什么要用episode而不是直接拿所有人数据混合训练?因为few-shot的核心假设是你希望在测试阶段能“快速适应新用户”,训练时如果不模拟这种场景,模型学到的是在数据分布上求平均的通用特征,不会主动去学习适应策略。这就是MAML这类元学习算法的思想——但要注意,源码包里如果用的是简单的“预训练+微调”策略而不是MAML,episode采样方式会简单得多,它只需要在微调阶段用目标用户的K张图做几次梯度更新。预训练阶段还是用常规的数据混合训练。
4.3 训练循环:预训练、元学习、微调三种模式怎么串起来
一个完整的few-shot-gaze项目,训练流程通常是三段式的。第一段是在源域数据(MPIIFaceGaze全部+ GazeCapture大部分)上做常规的监督预训练,第二段是可选地用MAML或Reptile做元学习训练,第三段是评估时在目标用户上做微调。下面是一个兼容三种模式的训练循环骨架:
def train_one_epoch(model, dataloader, optimizer, device, mode='meta'): """统一训练循环,支持常规监督与episode两种模式""" model.train() total_loss = 0.0 for batch in dataloader: if mode == 'supervised': # 常规模式:batch里直接是图像+标签 imgs, gazes = batch imgs = imgs.to(device) gazes = gazes.to(device) pred = model(imgs) loss = nn.L1Loss()(pred, gazes) optimizer.zero_grad() loss.backward() optimizer.step() elif mode == 'meta': # meta模式:batch里是support和query两个分支 support_imgs, support_gazes, query_imgs, query_gazes = batch # 先在support上做内循环更新(模拟新用户适应) inner_model = copy.deepcopy(model) inner_opt = torch.optim.SGD(inner_model.parameters(), lr=1e-3) for inner_step in range(5): inner_pred = inner_model(support_imgs.to(device)) inner_loss = nn.L1Loss()(inner_pred, support_gazes.to(device)) inner_opt.zero_grad() inner_loss.backward() inner_opt.step() # 在query上计算一次前向loss,更新外层模型 query_pred = inner_model(query_imgs.to(device)) loss = nn.L1Loss()(query_pred, query_gazes.to(device)) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * imgs.size(0) return total_loss / len(dataloader.dataset)torch版本比较老时需要记得加with torch.no_grad()来包住support集的这些内循环更新,否则梯度图会占光所有显存。
这里有个非常关键的实现细节:元学习模式下,内循环的更新次数(inner_step)和步长(lr=1e-3)本身就是超参数。内循环步数太多会让support拟合得很干净但在query上泛化差,步数太少则没有适应性;步长太大同样导致振荡。我跑下来的经验是:MPIIFaceGaze上inner_step=3、学习率1e-3时,微调效果和效率平衡最好。
4.4 优化器与学习率调度:不要用Adam的默认参数
视线估计模型的特征空间维度不高,但数据分布相对集中,Adam的lr=1e-3默认值很容易在预训练后期出现震荡。常见做法是先用1e-4做预热,然后线性衰减到1e-6。
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-6)AdamW比Adam多了一个权重衰减策略——它把weight decay从L2正则里解耦出来,对特征提取层更友好,尤其适合先预训练再微调的场景。T_max=30表示学习率在30个epoch内从初始值余弦下降到1e-6。如果你数据集比较大,T_max也要相应放大,否则学习率衰减太快,后期训练会卡在某个局部最优处不动。这属于是从视觉Transformer里带过来的习惯,视线估计里同样有效。
还有一点:batch size不要选太大。视线估计的样本差异性主要在头部姿态与人脸外观上,batch size 64时loss曲线还比较稳定,超过128后测试精度反而下降,原因是大batch降低了梯度噪声,让模型更容易陷入support set的局部模式。我把这一点归为“深度学习中少有的小batch效果更好的场景”。
5. 复现时最容易翻车的五个坑与排查
5.1 坑一:标注坐标系不一致导致loss爆炸或NaN
现象:训练loss先异常大(比如1e2级别),几个epoch后变为NaN,TensorBoard上曲线直接断掉。
原因:MPIIFaceGaze的视线方向是相机坐标系下的3D单位向量,而GazeCapture的JSON里有时给的是屏幕坐标系下的2D注视点像素坐标。模型输出3D方向,你却拿2D像素点去做回归,两个物理量纲不同,loss值自然降不下去。NaN则更可能是loss回传时某个坐标分量出现了反三角函数取值越界。
解决:写一个检查脚本,把两个数据集的标注分布打出来,确认范围。如果同时出现2D和3D标签,首先要定义一个转换函数统一到同一个坐标系下;其次检查模型的最后一层有没有tanh激活——如果预测向量被限制在[-1, 1]而你的标签取值范围是[0, 1080]像素,模型先爆炸再收敛,多半又是维度问题。
5.2 坑二:人脸对齐操作被写在训练循环里拖慢速度
现象:训练一整个epoch要三个小时,GPU利用率却不到40%。
原因:源码包里把similarity_transform、关键点检测这类CPU密集型操作放进了Dataset.__getitem__。每个训练batch都在CPU上做实时人脸仿射变换,GPU一直在空转等待。
解决:把预处理提前到数据加载阶段完成,一次性把对齐后的图像和标注存成npy文件。比如MPIIFaceGaze的60x60图像本身就是对齐过的,不需要重复变换;GazeCapture的原始裁剪图则要对你选定的人脸关键点做一次离线对齐,保存为.npy。训练时只需要np.load后做标准化即可。我一般会写一个preprocess_and_cache.py,跑一次要十几分钟,但之后每次训练能快几倍。
5.3 坑三:测试时忘记切换预处理方式导致精度掉一截
现象:训练集上loss很低,测试集上角度误差非常大,且不同epoch之间波动明显。
原因:训练时使用了数据增强(随机亮度、随机裁剪、随机旋转),测试时却仍然执行同样的随机增强,或者测试时没做归一化而训练时做了。更隐蔽的是:测试时使用了和训练时不一样的图像分辨率——比如训练输入是64x64,测试输入是60x60,主干网络里有AdaptiveAvgPool的话不会报错,但特征分布已经被改变了。
解决:写一个inference_pipeline,把测试图像的预处理写死:resize到同一尺寸,用同一组均值方差做标准化,关掉所有随机操作。这段代码里唯一可能出现意外的是bias问题——如果训练时用的是ImageNet的均值方差,而你的人脸图像是灰度图,那就得在加载时把单通道图像复制成三通道再输入,否则标准化后均值差得非常大,模型输出预测几乎等于直接把bias传递到了输出层。
5.4 坑四:显存不足但一直有人在网上说这模型很轻量
现象:将模型在单张3090上跑,batch size要调到16以下才能不OOM,但论文里写的是batch size 64。
原因:很可能是源码包在进行episode回传时没有释放内循环的计算图。支持集和查询集的梯度图叠加在一起,显存需求翻倍。更常见的情况是:你在main.py里的torch.cuda.set_per_process_memory_fraction设了一个过小的值,直接把显存卡死了。
解决:优先用torch.cuda.empty_cache()在log_interval处做释放;其次检查backbone是否被重复forward了多次。MAML风格的实现里,每一轮内循环更新都会在inner_model上调用一次backward,这些计算的autograd图默认不会在下一轮开始时自动释放干净,你需要在进入下一个episode之前显式地销毁计算图引用:inner_model = None。
5.5 坑五:模型过度拟合到“头部姿态+平均脸”而非视线
现象:测试结果对GazeCapture数据特别差,角度误差在10度以上,但MPIIFaceGaze测试集上表现尚可。
原因:GazeCapture的头部姿态分布比较广,很多用户的图像里同时包含正脸和侧脸。如果训练时没有把头部姿态作为输入特征或者辅助监督,模型会退化成靠人脸朝向猜视线方向——因为人脸外观和头部姿态的相关性太强,模型偷懒走了捷径。MPIIFaceGaze的张数毕竟少且多为正面视角,给了模型一种“只要判断头部姿态就能近似视线”的错觉。
解决:这种场景下我会去看源码包代码,确认模型是否支持把头部姿态向量head拼接到最后一个卷积特征图上。如果支持,就打开这个开关,把3维头姿特征拼进去;如果不支持,最简单的方法是增加训练时GazeCapture的采样权重,让模型多见到头部姿态变化的样本。另一个方案是增加一个辅助任务,让模型同时预测头部角度和视线角度,把两个任务在特征层的梯度加在一起回传。这个在代码上的改动很小,通常只需在loss计算时再加一项:loss = loss_gaze + 0.3 * loss_head。
6. 优化与验证技巧:跨数据集的评估指标与三种有效的优化方向
6.1 角度误差是唯一有说服力的评估指标
视线估计领域跨数据集评估时,最常见的指标是视线角度误差,单位是度。计算方式是把预测向量与真实向量都归一化后,求它们夹角的角度。这个指标的好处是跟图像分辨率、相机内参和模型输出维度无关,能把你MPIIFaceGaze和GazeCapture上的结果直接横向比较。
def gaze_angular_error(pred, target): """计算视线角度误差(度),pred和target均为(N, 3)的向量""" pred_norm = pred / np.linalg.norm(pred, axis=1, keepdims=True) target_norm = target / np.linalg.norm(target, axis=1, keepdims=True) cos_theta = np.clip(np.sum(pred_norm * target_norm, axis=1), -1.0, 1.0) return np.degrees(np.arccos(cos_theta))这里有一个很容易被忽略的点:大多数源码包用arccos计算角度时,数值上会把cos_theta四舍五入到小数点后两位再做clip,这会在误差较小时引入误差。比如两个向量夹角接近0度时,clip会直接把结果截断成0,从而让平均角度误差看起来偏小。建议你不要做这个四舍五入,直接原值clip到[-1,1]即可。
评估时还要注意一个边界:跨数据集时一定要分“同域测试”和“跨域测试”两个维度来看。同域测试(比如在MPIIFaceGaze的2个保留用户上测)反映模型的拟合与泛化能力;跨域测试(用MPIIFaceGaze训练,在GazeCapture的保留用户上测)则反映模型的去偏和泛化能力。如果你的优化方向让同域误差下降但跨域误差大幅上升,说明你正在过拟合到源域的图像风格上,而不是学到通用的视线特征。
6.2 三个值得尝试的优化方向
第一个方向是特征解耦。few-shot视线估计的本质矛盾是:模型既要提取与身份相关的面部特征,又要提取与视线方向相关的纯几何特征。但如果这两类特征混在一个向量里,模型必然会以身份特征为线索来预测视线——因为面部形状和视线方向在训练集里存在统计相关性。我试过向源码包的fc层之前加入一个梯度反转层(Gradient Reversal Layer),让特征提取器只保留对用户身份不敏感的信息,在MPIIFaceGaze上效果不错,跨域误差能降低1.5度左右。
第二个方向是姿态增强。训练阶段把人脸图像做虚拟旋转,生成多个姿态下的样本,比直接做颜色增强有效得多。由于MPIIFaceGaze的头部姿态大部分集中在正脸,模型天然偏向预测小角度视线;把训练数据的头部姿态分布往两侧拉伸,可以显著降低极端视线下的误差。具体实现上可以用OpenCV的warpAffine对图像做±20度以内的随机旋转,同时把对应的视线向量也旋转同样角度——这一步别忘了也要改标注。
第三个方向是自适应归一化。如果源码包里使用了BatchNorm,跨域时BatchNorm的统计量会严重失效。我自己的经验是把主干网络里的BatchNorm在测试阶段改为InstanceNorm,或者在微调阶段冻结BatchNorm的running_mean和running_var,只更新卷基层的参数。这样做的逻辑是:BatchNorm统计的是训练集整体特征分布,而few-shot测试时目标用户只有少量样本,数据分布和训练集差异很大,此时用训练集的全局统计量比用仅几个样本估计出来的统计量更稳定。
6.3 用GazeCapture做最终验收:从10-shot到50-shot的曲线判断模型质量
最后说一下验证方法。不要只看一个点上的精度。一个可靠的few-shot视线估计模型应该展示出这样的趋势:测试时提供给模型的标注样本数从1、5、10、20、50逐渐增加,模型的角度误差相应稳步下降,且下降曲线平滑。如果你画出来的是10-shot和50-shot精度几乎一样,那说明你的模型本质是个固定映射器,并没有真正利用少样本标注信息来做自适应。这个曲线图放在毕设答辩时是非常有说服力的实验证据。
我自己的习惯是把这组数据整理成表格:模型名称、backbone、预训练域、1/5/10/20/50-shot角度误差、参数数量。可以验证你复现的源码包是否真的实现了few-shot能力,也可以用来对比你自己优化的效果。每当我拿到一个新的源码包,第一时间就画这个曲线,等于是在几小时内给代码的声称能力做了一次压力测试。
最后提一个血泪经验:永远保留一份跑通过的数据预处理缓存和训练日志备份。运行几个月后的某天,你会发现当初能出一张好结果图的代码,竟然因为上个月改了requirements.txt里一个看似无关的包版本而在同样的命令下彻底训练不动了——这时临时开个独立环境往里倒腾,通常没戏,还不如直接翻你备份时记录的输出日志和当时的commit信息来得快。希望这篇记录能让你少走这些弯路,最好一次跑通。
本文还有配套的精品资源,点击获取