few-shot-gaze复现指南:从数据预处理到跨数据集评估的完整实践
简介面向毕业设计场景的 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, imgsnp.stack(imgs), gazesnp.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.9PyTorch 用 1.13 以上即可太新的 2.x 也没问题但要确保配套的 torchvision 版本一致。conda create -n fewgaze python3.9 -y conda activate fewgaze pip install torch2.0.1 torchvision0.15.2 pip install opencv-python tqdm scikit-learn matplotlib ftfy regex pip install githttps://github.com/openai/CLIP.git参数说明torch2.0.1和torchvision0.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, devicedevice) 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_dim2048, hidden512): 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_dim2048是因为视觉特征和文本特征各 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_dim1024): 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_firstTrue) 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(dim0, keepdimTrue)) # (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(), lr1e-5, weight_decay0.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(fstep {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_dataNone): 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_size32直接 OOM改成batch_size4后 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 毕设就从“跑通复现”变成了“有对比、有可视化、有统计意义”的完整研究链条。最后提醒一句所有试验结果记得同步存一份到表格文件里代码一更新就重新生成别等到答辩前再临时补数据越补越慌。希望帮到你。本文还有配套的精品资源点击获取