简介:面向毕业设计场景的 few-shot gaze 视线估计项目源码包,复现并优化了 Seonwook Park 的 few_shot_gaze 工作,基于 MPIIFaceGaze 与 GazeCapture 数据集,适合计算机视觉方向学生深入理解少样本学习与视线估计任务。包体共93个文件,约13.49MB,以41个Python脚本为核心,覆盖数据预处理、HDF5文件生成、特征提取、元学习训练与测试等环节;同时包含Caffe模型定义、预训练模型、配置文件、Bash训练脚本、Jupyter示例和项目说明文档,便于直接运行和二次开发。目前已有287人学习,资源目录清晰,配有CHECKPOINT及演示相关代码,可帮助读者快速搭建实验环境、复现论文结果,并在此基础上进行优化。整体是一份适合毕设参考的完整工程实现。
1. 为什么拿 few-shot-gaze 做毕设:MPIIFaceGaze 与 GazeCapture 的搭配逻辑
先把结论放在前面:few-shot-gaze 这个课题的核心,是把“给新用户做视线标定”的成本从几百张图压到个位数,方法和效果都有清晰的量化指标。它用 MPIIFaceGaze 和 GazeCapture 两个公开数据集做交叉验证,既考验模型在限制条件下的拟合能力,也检验跨数据集的泛化能力,恰好是本科毕设最需要的“工作量明确、对比显著、能讲故事”的组合。我拿到这个项目第一件事不是急着跑代码,而是把数据链路理清楚——因为几乎所有复现翻车,根源都出在标注格式和坐标系的误解上。下面按完整复现路线展开,你会看到从数据预处理、模型训练到调参避坑的全部实操细节。
2. 数据准备:把 MPIIFaceGaze 和 GazeCapture 预处理成同一套 npz
复现这个项目时,源码里最容易被忽略的是数据预处理层。表面上看,训练代码只要拿到图像和视线标签就能跑,实际上两个数据集各自有自己的目录结构、标注格式和坐标系,不统一成同一份 npz,后续训练脚本根本没法共用。
2.1 MPIIFaceGaze 的 26 维 txt 标注如何解析出视线向量
MPIIFaceGaze 目录结构很规整:每个受试者一个独立文件夹,比如p00到p14,每个文件夹里有上千张原始图片,另外有一个同名.txt标注文件。关键在于这个 txt 的解析——每行 26 个浮点数,按固定顺序排列,解析错了,视线方向就整个错掉。
import numpy as np import glob import cv2 import os # 读取受试者 p00 的标注,行数和该文件夹下图片数量一致 ann = np.loadtxt("data/MPIIFaceGaze/p00.txt") print("标注形状:", ann.shape) # (N, 26) # 约定俗成的 26 维字段排布: # 0-1 : 归一化眼睛图像里的 2D 注视目标点 # 2-4 : 相机坐标系下的 3D 视线方向(单位向量)——训练标签 # 5-13 : 3x3 头部旋转矩阵,按行展平 # 14-22: 3x3 相机内参/归一化矩阵 # 23-24: 原始图像宽高 # 25 : 颜色通道数 gaze3d = ann[:, 2:5] # (N, 3) 视线方向 head_r = ann[:, 5:14].reshape(-1, 3, 3) target = ann[:, :2] # 2D 注视目标,评估画图时用得到这段代码里没有玄学,完全是字段对齐问题。gaze3d才是训练回归目标,head_r用来做坐标变换或可视化头部坐标系。有些改写版本习惯把视线方向放在第 7~9 位,所以拿到 txt 后先打印前几行,再用一张图人工比对一下方向符号,这个步骤最多花五分钟,能省下后面一整天排查时间。
图片加载逻辑和标注行号必须严格对应,因为文件名和行号天然一一对应,排序时按字典序即可。数据量不大,单个人 3000 张左右,直接用cv2.imread逐张读不会很慢。实际操作中我习惯先把原始图缩到短边 256,再在数据增强阶段随机裁剪到 224,这样既能控制显存又保留空间扰动。
2.2 GazeCapture 的 JSON 标注如何转成训练样本
GazeCapture 和 MPIIFaceGaze 不同,它来自手机端采集,受试者接近 2500 人,数据组织形式也更散。每个受试者有独立的会话目录,元信息集中在 JSON 文件里,里面包含人脸裁剪图、左右眼裁剪图以及屏幕坐标系的注视落点。读取资源最稳妥的做法是先把 JSON 结构探查一遍,再写解析代码。
import json ann = json.load(open("data/GazeCapture/0/annotation.json")) frames = ann["frames"] f0 = frames[0] # 先摸清字段名再写具体解析,Git 上不同版本字段命名不完全一致 print(f0.keys()) print(f0.get("paths", {}).keys())大部分版本里,paths下会给出人脸图、左眼图、右眼图的相对路径,另外有gaze_target或screen_pos之类的字段给出注视点在屏幕上的坐标。注意一个关键差异:MPIIFaceGaze 直接给了相机坐标系下的 3D 视线方向,而 GazeCapture 给的是屏幕 2D 落点,需要结合手机屏幕到摄像头的距离做换算。常见的处理方式是直接用数据集中更高级别的预处理结果,或者使用官方工具链里的标定参数还原 3D 视线方向。
读到图片后需要统一裁成模型输入尺寸。这里有个血泪经验:GazeCapture 的原始人脸框有时候只框到半边脸,直接用原始裁剪图训练,跨数据集评估会让你怀疑人生。我的习惯是先做人脸对齐,用 OpenCV 的cv2.resize统一到目标尺寸,必要时再做一次水平翻转增强。对毕设来说,数据量不必全量使用,GazeCapture 抽 200 个受试者就足够训练出一个有说服力的 few-shot 模型了。
2.3 统一坐标系和尺寸:生成 npz 的最小脚本
把两个数据集统一成同一套 npz 格式,是复现训练流程的前置条件。每个样本包含imgs和gazes两个字段,gazes全部归一化成单位向量,这一步能在训练时省掉很多麻烦。
import os import numpy as np import cv2 def build_npz(img_dir, ann_path, save_path): ann = np.loadtxt(ann_path) img_paths = sorted(glob.glob(os.path.join(img_dir, "*.jpg"))) imgs, gazes = [], [] for p, row in zip(img_paths, ann): img = cv2.imread(p) img = cv2.resize(img, (224, 224)) # 统一到 224x224 imgs.append(img) gaze = row[2:5] gaze = gaze / (np.linalg.norm(gaze) + 1e-6) gazes.append(gaze) np.savez_compressed(save_path, imgs=np.stack(imgs), gazes=np.asarray(gazes)) # build_npz("data/MPIIFaceGaze/p00", "data/MPIIFaceGaze/p00.txt", "npz/p00.npz")参数上最重要的两个点:图像尺寸统一到 224 是为了配合 CLIP 视觉编码器的输入,提前 resize 而不是在训练时每次做,能省 GPU 显存。视线方向做了 L2 归一化,因为损失函数计算的是角度误差,标签长度不归一会影响收敛初期的稳定性。保存格式用npz_compressed,单个受试者大约几十 MB,全部数据集几十个 GB 也能接受。
3. 环境搭建与最小推理:先把视觉、文本双流特征提取链路跑通
拿到项目源码后,能不能在第一天跑通推理,直接决定了你后续调试的信心。这一章不急着训练,先把 Python 环境固定好,再加载 CLIP 预训练权重,跑一次完整的视觉-文本特征提取,确认硬件、驱动、依赖都正常。
3.1 Python 版本、PyTorch 与 CLIP 依赖安装
few-shot-gaze 的 python 源码依赖不算复杂,最要紧的是 PyTorch 和 OpenAI CLIP。这里建议用 conda 单独建环境,避免 pycharm 或 vscode 配置 python 环境时把系统解释器搞乱。Python 版本锁 3.9,PyTorch 用 1.13 以上即可,太新的 2.x 也没问题,但要确保配套的 torchvision 版本一致。
conda create -n fewgaze python=3.9 -y conda activate fewgaze pip install torch==2.0.1 torchvision==0.15.2 pip install opencv-python tqdm scikit-learn matplotlib ftfy regex pip install git+https://github.com/openai/CLIP.git参数说明:torch==2.0.1和torchvision==0.15.2是搭配好的版本对,版本错位会在clip.load时直接报torchvision依赖错误。ftfy和regex是 openai CLIP 仓库的硬依赖,缺少它们会在 tokenize 阶段报错。opencv-python提供cv2接口,数据处理和可视化都靠它。安装完最好进 Python 交互环境输入import torch, clip,确认没有DLL load failed之类的底层异常再继续。
3.2 用一张测试图跑通视觉-文本特征提取
CLIP 是这个项目的骨干网络,few-shot-gaze 用它提取视觉特征,同时用文本模板生成任务先验。最小推理只需要调用clip.load加载 ResNet-50 版本的预训练权重,然后对任意图片做编码。
import clip import torch from PIL import Image device = "cuda" if torch.cuda.is_available() else "cpu" model, preprocess = clip.load("RN50", device=device) img = preprocess(Image.open("demo.jpg")).unsqueeze(0).to(device) text = clip.tokenize(["a photo of a person looking at something"]).to(device) with torch.no_grad(): visual_feat = model.encode_image(img) # (1, 1024) text_feat = model.encode_text(text) # (1, 1024) print(visual_feat.shape, text_feat.shape)注意preprocess会把输入图像 resize 到 224 然后做 ImageNet 均值方差归一化,所以之前的 npz 预处理统一到 224 是合理的。visual_feat是全局视觉特征,text_feat由文本模板生成的语义特征,后续的 few-shot 任务 token 就从这两者里汇聚出来。这里能看到clip.load第一次运行会下载权重,网络不好时容易卡住,预训练权重下载失败的问题放在避坑章节展开。
3.3 视觉文本特征如何接到视线回归头
few-shot-gaze 的原版实现并不会直接把 CLIP 的原始特征输出作为预测结果,而是在视觉特征和文本特征之间加一个跨模态适配层,把“任务信息”编码成 task token,再喂给回归头。复现时若只想先验证数据链路,可以先用一个最简 MLP 回归头代替,损失选角度误差即可。
import torch.nn as nn class GazeRegressionHead(nn.Module): def __init__(self, in_dim=2048, hidden=512): super().__init__() self.fc = nn.Sequential( nn.Linear(in_dim, hidden), nn.ReLU(), nn.Linear(hidden, 3) ) def forward(self, visual_feat, text_feat): x = torch.cat([visual_feat, text_feat], dim=-1) return self.fc(x)这里的in_dim=2048是因为视觉特征和文本特征各 1024 维拼接得到。回归头输出三个值,训练时需要将该输出归一化为单位向量才能计算角度误差。先用这个简化版跑通 one-step 训练再替换成跨模态适配器,排查问题会清晰很多。
4. 训练流程复现:few-shot 采样、角度损失与跨数据集评估
数据与环境都就绪后,复现的核心难点集中在训练协议。许多人第一次跑这个项目时,误以为是一次性用全部 GazeCapture 数据训练、再用 MPIIFaceGaze 测试,实际 few-shot-gaze 的机制是 episode 式的任务采样,每一轮都从训练集里构造一个模拟少样本场景。
4.1 模型主干:跨模态适配器如何把任务信息灌进模型
few-shot-gaze 的关键思想是,让模型在推理时显式拿到少量支持样本,而不是在测试时临时微调。它的做法是:用支持集图像特征生成一个“任务描述”,再通过跨模态注意力把该信息注入到查询图像特征中,最终回归视线方向。简化但等价的最小实现可以写成下面这样,核心是带任务编码的注意力层。
import torch import torch.nn as nn class FewShotGaze(nn.Module): def __init__(self, clip_encoder, embed_dim=1024): super().__init__() self.visual = clip_encoder.visual self.task_proj = nn.Linear(embed_dim, embed_dim) self.cross_attn = nn.MultiheadAttention(embed_dim, 8, batch_first=True) self.reg = nn.Sequential( nn.Linear(embed_dim, 256), nn.ReLU(), nn.Linear(256, 3) ) def forward(self, support_img, support_gaze, query_img): # 支持集图像特征取平均,得到 task token sup_feat = self.visual(support_img) # (K, embed_dim) task_token = self.task_proj(sup_feat.mean(dim=0, keepdim=True)) # (1, embed_dim) # 查询图像特征与 task token 做 cross-attention q_feat = self.visual(query_img) # (B, embed_dim) out, _ = self.cross_attn(q_feat.unsqueeze(0), task_token.unsqueeze(0), task_token.unsqueeze(0)) return self.reg(out.squeeze(0))这里的support_img是从某个新受试者身上采的 K 张支持图像,support_gaze是这 K 张图对应的视线方向。取均值得到任务 token,再通过cross_attn让每张查询图都看到任务信息。这个简化版模型能跑通整个 few-shot 训练流程;原版里的可学习文本 prompt 和分层适配只是把任务 token 设计得更精细,训练协议是同一套。用这个模型理解 few-shot 采样已经足够了。
4.2 few-shot 的 episode 采样与训练循环
训练循环每次都要构造一个“任务”:随机选一个训练集中未参与测试的受试者,从中随机抽 K 张作为支持集,再抽若干张作为查询集,模型在查询集上计算损失。这样的好处是,训练和测试时面对的都是“新受试者”的设定,而不是让模型只记住训练集的统计分布。
import random import math import torch import torch.nn.functional as F def angular_loss(pred, gt): pred = F.normalize(pred, dim=-1) gt = F.normalize(gt, dim=-1) cos = (pred * gt).sum(dim=-1).clamp(-1.0, 1.0) return torch.acos(cos).mean() * 180.0 / math.pi opt = torch.optim.AdamW(model.parameters(), lr=1e-5, weight_decay=0.05) for step in range(2000): subject = random.choice(train_subjects) # 选一个受试者 k = 5 # 5-shot support_ids = random.sample(range(len(subject)), k) query_ids = random.sample( [i for i in range(len(subject)) if i not in support_ids], 16 ) sup_img = torch.stack([subject[i]["img"] for i in support_ids]).to(device) sup_gaze = torch.stack([subject[i]["gaze"] for i in support_ids]).to(device) qry_img = torch.stack([subject[i]["img"] for i in query_ids]).to(device) qry_gaze = torch.stack([subject[i]["gaze"] for i in query_ids]).to(device) pred = model(sup_img, sup_gaze, qry_img) loss = angular_loss(pred, qry_gaze) opt.zero_grad() loss.backward() opt.step() if step % 200 == 0: print(f"step {step}, loss {loss.item():.3f}")注意train_subjects要么来自 GazeCapture 的部分受试者,要么来自 MPIIFaceGaze 的前 12 人,具体看你要做哪个方向的跨数据集评估。学习率 1e-5 是 CLIP 骨干最稳妥的选择,weight_decay 用 0.05 能抑制过拟合。损失是角度误差,单位是度,初始 loss 大概率在 80 到 90 度之间,因为随机初始化的余弦相似度为 0 对应 90 度。看到 loss 从 90 缓慢下降属于正常,若直接降到个位数,可能是标签泄露或者数据加载里有 bug。
4.3 评估协议:MPIIFaceGaze 与 GazeCapture 交叉验证的角度误差
论文里最有说服力的是跨数据集评估,也就是用 GazeCapture 训练、在完整的 MPIIFaceGaze 15 人上测试,或者反过来。测试时不采样支持集的最差情况叫 zero-shot,给 1~5 张支持图像叫 few-shot。计算平均角度误差的脚本如下。
def evaluate(model, test_loader, support_data=None): model.eval() errors = [] with torch.no_grad(): for qry_img, qry_gaze in test_loader: if support_data is not None: sup_img, sup_gaze = support_data pred = model(sup_img, sup_gaze, qry_img) else: # 无支持集时,使用训练集得到的固定 task token pred = model.infer_zero_shot(qry_img) err = angular_loss_to_list(pred, qry_gaze) errors.extend(err) return sum(errors) / len(errors)评估时要特别注意:支持集只能来自测试集中的目标受试者,且支持集和查询集不能有重叠帧。如果无意把支持集采样到了训练受试者身上,指标会虚高,这就失去了少样本评估的意义。正确做法是先把受试者切分为训练/测试两组,测试时对每一位测试受试者单独抽取支持集,然后取所有受试者误差的平均值。
5. 避坑指南:few-shot-gaze 复现路上的常见问题与排查
这部分是复现过程中的踩坑记录,每个问题都是先看现象、再给原因、最后说解决办法,按这个顺序排查能省不少时间。
5.1 训练 loss 前 50 步直接变 NaN
现象:loss 在前几十步从 80 左右跳变成 NaN,后续训练彻底报废。
原因:最常见的是角度损失里acos输入超出定义域。虽然代码里加了 clamp,但在某些 PyTorch 版本下,pred * gt求和后微量超出 1 会导致梯度异常;另一个原因是支持集图像里有全黑或者全白样本,CLIP 编码器输出特征方差过大。
解决:先把clamp(-1.0, 1.0)换成clamp(-0.9999, 0.9999),再把输入图像做一次方差检查,删除标准差接近 0 的异常帧。若仍然复现,降低学习率到 3e-6,并检查标签中是否存在全零向量。
5.2 跨数据集评估误差 30 度,同数据集却只有 5 度
现象:在 GazeCapture 训练后直接测 GazeCapture 测试集表现不错,但拿到 MPIIFaceGaze 上误差飙升到 30 度以上。
原因:两个数据集的视线方向定义有细微差别。MPIIFaceGaze 的 3D 视线方向是在相机坐标系下定义的,而 GazeCapture 给的屏幕坐标注视点换算成 3D 向量时,很多预处理脚本把 Z 轴方向搞反了。坐标符号不统一在单数据集上影响不大,跨数据集就完全暴露。
解决:把两个数据集的标签分布可视化,分别统计x, y, z三个分量的均值。通常需要将其中一方的x取反或交换y与z才能对齐。逐项对比而不是靠猜,确定方向后写一个固定的convert_gaze_coordinate函数放在预处理开头。
5.3 few-shot 效果反而不如 zero-shot
现象:加了 5 张支持图像后,误差比零样本还高。
原因:支持集的信息被过度拟合了。当支持集的 K 张图在光线或姿态上高度相似时,取平均得到的 task token 会把偏差放大,而查询集本身与支持集并不一定分布一致,导致注意力过分关注支持集噪声。
解决:让任务 token 的生成更鲁棒。常见做法是在task_proj层前加 Dropout(0.5),并把支持集采样改成随机采样多轮取“平均 task token”。另外检查支持集内部是否过于同质,强制要求支持集内图像的 yaw 角度方差大于某个阈值,能显著缓解。
5.4 GPU 显存不足,batch size 一调小训练就不收敛
现象:6GB 显存跑batch_size=32直接 OOM,改成batch_size=4后 loss 波动大且不下降。
原因:CLIP ResNet-50 加上 cross-attention 在反向传播时会占用额外激活内存,显存不足就直接砍 batch 会引入过大的梯度噪声。
解决:固定 batch size 为 16,开启梯度累积和混合精度。下面这段代码直接替换原训练循环即可。
scaler = torch.amp.GradScaler("cuda") accum_steps = 4 # 等效总 batch = 16 * 4 for step, batch in enumerate(loader): with torch.autocast("cuda"): pred = model(batch["sup_img"], batch["sup_gaze"], batch["qry_img"]) loss = angular_loss(pred, batch["qry_gaze"]) / accum_steps scaler.scale(loss).backward() if (step + 1) % accum_steps == 0: scaler.step(opt) scaler.update() opt.zero_grad()混合精度能减少约 40% 显存占用,梯度累积保持有效 batch size 不变。如果显存仍然不够,把图像分辨率从 224 降到 160,任务头的hidden从 512 降到 256,对最终指标影响很小。
5.5 CLIP 预训练权重下载失败,环境一直卡在 clip.load
现象:clip.load("RN50")一直显示连接到下载地址超时,或者下载到一半中断。
原因:openai CLIP 的权重默认从境外存储下载,部分网络环境下链接不稳定,这不是代码问题。
解决:手动下载RN50.pt权重,放到本机缓存目录~/.cache/clip/RN50.pt,再执行同样的clip.load就会直接从缓存读取。如果连手动下载也访问不畅,可以用镜像站或者让同学代传文件,权重文件拿到后核对一下大小是否完整。下完放在正确的目录里,clip.load就不会再访问网络了。
6. 进阶验证:让毕设结论站得住的三个实验习惯
复现跑通只是开始,答辩时分数的差距在于你敢不敢做消融实验,以及实验结论是否足够严谨。最后一个部分我分享三个自己一直在用的手法,能直接提升整份毕设的可信度。
第一个是消融表的设计。把“完整版模型”“去掉文本任务先验”“去掉 few-shot 支持集只保留 CLIP 特征”三个版本放在同一张表格里对比,不一定要非常新颖,但能清晰说明每个模块的贡献。具体实现上,去掉文本先验就是把 4.1 节中的task_proj替换成固定随机向量,去掉支持集就直接用encode_image特征接回归头。真实实验做下来,1-shot 提升通常在 3~6 度,这组数据放在论文里非常有说服力。
第二个是视线方向的可视化。画角度数字不够直观,建议把预测的 3D 视线向量投影到原始图像上,和真值方向并列展示。做法很简单,读出头部旋转矩阵,将预测向量左乘旋转矩阵的逆变换回图像系,再用cv2.line画出射线。误差大的样本一眼就能看出来是上下翻转还是左右偏移,这也是你和导师沟通时最有价值的诊断素材。
第三个是随机种子和误差条。few-shot 训练随机性很大,单次运行的结果没有参考价值。我通常固定三个随机种子各跑一次,报告“均值 ± 标准差”,并用柱状图画误差条。训练参数表里必须记录当时的 seed、支持集数量、学习率和是否应用了水平翻转增强。这个小表看起来不起眼,却能你让你在答辩时从容应对“这个结果是不是偶然”的追问。
这几条做完,你的 few-shot-gaze 毕设就从“跑通复现”变成了“有对比、有可视化、有统计意义”的完整研究链条。最后提醒一句,所有试验结果记得同步存一份到表格文件里,代码一更新就重新生成,别等到答辩前再临时补数据,越补越慌。希望帮到你。
本文还有配套的精品资源,点击获取