
简介面向人脸识别与视觉Transformer研究者提供T2T-ViT网络的Python实现代码可用于轻量级视觉Transformer在人脸识别任务中的实验对比与模型部署。资源共5个Python文件整体仅6KB包含token_transformer、token_performer、transformer_block等核心模块以及t2t_vit主体网络定义代码结构紧凑便于直接阅读、修改与集成到现有项目。当前已有1386人浏览学习适合想快速复现T2T-ViT结构、或将其作为人脸识别backbone进行算法验证的开发者。配合作者博客中的讲解可帮读者理清T2T-ViT的Token化与Transformer模块串联思路减少从零搭建网络的时间成本尤其适用于轻量级人脸识别模型的入门与进阶研究。1. 拿 T2T-ViT 做人脸识别解决的不只是换个骨干网络人脸识别这几年在工程侧基本被 CNN 统治从 MobileFaceNet 到 GhostFaceNet大家比的是谁能在边缘设备上跑得更快、特征更稳。但 ViT 出来以后很多人试过直接把图像切成 16×16 patch 丢进 Transformer效果并不理想数据需求大、收敛慢、小图上容易欠拟合。T2T-ViTTokens-to-Token Vision Transformer恰恰是针对 ViT 这个短板设计的——它用渐进式 token 化替代一次性 patch 切分把局部结构信息保留下来在 ImageNet 上以更少的参数追平了 DeiT而且迁移到人脸这类细粒度任务时表现更稳。这个压缩包里的项目做的就是把 T2T-ViT 从图像分类搬到人脸识别场景涉及 backbone 改造、损失函数切换、数据管线调整和推理部署一整套流程。对 5 年以上工程师来说值得关注的点有两个T2T 的结构归纳偏置到底给人脸任务带来了什么以及训练配置和部署环节有哪些跟 CNN 时代不一样的坑。2. T2T-ViT 的结构拆解token 化方式决定了它适合人脸识别2.1 Tokens-to-Token 模块把“切 patch”改成“逐步融合”原始 ViT 的做法是把 224×224 的图直接切成 196 个 16×16 的 patch每个 patch 展平后过一个线性映射变成 token。问题在于这种切法完全丢掉了 patch 内部的像素关系——16×16 区域内部的纹理、边缘、五官结构信息只剩一个向量模型只能靠 attention 去隐式恢复。对 ImageNet 这种类别差异大的任务够用但人脸识别要求的是同一身份不同照片的特征尽可能接近不同身份尽可能远离这种细粒度判别对局部纹理非常敏感。T2T-ViT 的思路是用两次“soft split 重组”把图像逐步变成 token 序列。第一步把 224×224 的图像按 7×7 窗口、步长 3 做重叠切分得到大约 56×56 个 token每个 token 是 7×7×3 的向量然后把这些 token 过一层 Transformer 编码器做变换再按 3×3 窗口、步长 2 做第二次重组得到 28×28 的 token 序列最后再过一层 Transformer按同样方式变换到 14×14。后续接一个标准的 ViT backbone。这个设计的关键在于重叠窗口。7×7 的重叠切分相当于先用卷积的方式提取了局部特征再交给 attention 做全局建模。人脸识别里特别吃局部信息眼睛、鼻翼、嘴角这些区域的微纹理差异是区分相似面孔的主要依据T2T 的 soft split 天然保留了这些信息。实际使用中我一般会把第一次 soft split 的窗口从默认的 7×7 调到 5×5因为人脸输入通常是 112×112 而不是 224×2247×7 窗口在最底层感受野偏大容易把相邻器官的特征混在一起。2.1.1 人脸输入尺寸下的 token 数量变化工程上常用的人脸对齐图是 112×112 或 96×112。以 112×112 输入为例如果用 T2T-ViT 原始配置soft split 参数不变token 序列变化是这样阶段输入尺寸切分参数token 数量token 维度Soft Split 1112×112×37×7, stride 3129636×36147重组后36×36×64Transformer 变换129664Soft Split 236×36×643×3, stride 232418×18576Transformer 后18×18×576变换324576最终序列18×18324线性映射324384324 个 token 比原始 ViT 在 224×224 下的 196 个还要多计算量会上去。我的建议是第二次 soft split 的 stride 从 2 改成 3把 token 压到 14412×12左右这样 backbone 部分的计算量和 DeiT-S 基本持平。代价是精度会有轻微下降LFW 上大概 0.1 到 0.2 个百分点换来的是训练和推理速度提升约三成。具体怎么取舍看你手头 GPU 的余量。2.2 人脸识别的训练目标和分类任务不一样ArcFace 才主角T2T-ViT 原始实现用的是交叉熵做图像分类。但人脸识别不能直接拿交叉熵训练原因在于分类层会把特征映射到类别空间推理时你要的是 embedding 本身。人脸识别的标准做法是 margin-based softmax当前工程界默认用 ArcFace。它的核心改动是把分类层的权重和特征都做 L2 归一化然后在夹角上加上一个 margin 项公式是L -log( e^(s·cos(θ_y m)) / (e^(s·cos(θ_y m)) Σ e^(s·cos(θ_j)) ) )s特征缩放因子默认 64。它的作用是让归一化后的特征在超球面上分布不至于太密s 越小特征区分度越差s 太大训练容易不收敛。m角度 margin默认 0.5。m 越大类内约束越强特征会更紧凑但训练难度也越大。把 T2T-ViT 的 backbone 输出层改为 embedding 维度后接一个 ArcFace head这是整个项目改造的核心。T2T-ViT 因为 token 间有 attention 做全局交互输出的 class token 或平均 token 已经隐含了全局关系比 CNN 的 global average pooling 特征更适合直接进 ArcFace。我一般取最后一层 Transformer 输出后做 mean pooling而不是用 class token原因在于人脸特征希望覆盖全图所有区域mean pooling 能避免 attention 过于集中在某一小块。2.2.1 T2T-ViT-14 做人脸识别的参考配置模块参数说明输入尺寸112×112×3对齐后的人脸图T2T 第一次切分soft split 5×5, stride 2缩小窗口保留细节T2T 第二次切分3×3, stride 2保持默认Transformer blocks14 层对应 T2T-ViT-14每层 head 数4减少 head 数量以适配小输入embedding 维度512ArcFace 常用特征维度缩放因子 s64默认即可margin m0.4数据量不足时从 0.4 起步这里的 head 数值得单独说。原始 T2T-ViT-14 用的是 6 个 head但在 112×112 输入下 token 只有一两百个head 太多会导致每个 head 分到的注意力维度太低反过来限制特征表达能力。改成 4 个 head 以后我在自建数据集上 TARFAR1e-4 提升了约 0.8 个点。这个改动不增加参数量值得直接照抄。3. 训练数据管道与参数配置复用 ImageNet 预训练模型要注意输入适配3.1 数据预处理对齐比增强更重要人脸识别领域有个共识对齐质量直接决定最终精度的上限。模型看到的是对齐后的人脸图对齐做歪了再好的 backbone 也拉不回来。常见的数据集WebFace、Glint360K 这类通常附带了五个关键点坐标两眼、鼻尖、两嘴角你需要做的第一步是用仿射变换把关键点映射到标准位置。标准位置不是固定的不同项目有不同约定我常用的是 112×112 下的这组坐标import cv2 import numpy as np REFERENCE_POINTS np.array([ [38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041] ], dtypenp.float32) def align_face(img, landmarks): # landmarks: 5 个关键点的坐标顺序为左眼、右眼、鼻尖、左嘴角、右嘴角 # 这里用相似变换而不是仿射变换避免图像变形 tform cv2.estimateAffinePartial2D(landmarks, REFERENCE_POINTS, methodcv2.LMEDS)[0] aligned cv2.warpAffine(img, tform, (112, 112), flagscv2.INTER_CUBIC) return aligned这个函数做了两件事先把检测到的关键点和标准位置之间的相似变换矩阵算出来然后按这个矩阵把原图 warp 到 112×112。注意我用的是estimateAffinePartial2D而不是estimateAffine2D——前者只允许平移、旋转、缩放不包含切变能保证人脸不会变形失真。很多人在这里用了完整仿射变换导致生成的人脸图被拉伸训练出来模型的泛化能力有明显下降。对于没有关键点标注的数据可以先用 RetinaFace 或 MTCNN 检测并提取关键点再对齐。工程上建议在对齐后随机做一次水平翻转作为训练时唯一的空间增强原因在于人脸是近似对称的且 ArcFace 这类损失函数已经对特征角度做了约束过多的空间增强反而会破坏身份相关的纹理信息。3.2 训练超参设置小 batch 下的稳定性优先人脸识别训练集一般很大MS1MV2 有约 580 万张图、8.5 万类Glint360K 更大。如果你是从头训练学习率策略和 CNN 时代差异不大但如果你是拿 ImageNet 预训练模型做迁移有几个参数必须调超参数推荐值说明batch size256单卡显存不够就 128但同步 BN 必须关掉初始学习率0.02batch 256 基准线性缩放lr 0.000078 × batch_sizewarmup steps2000ViT 类模型不吃高初始 lr必须 warmupweight decay5e-4人脸任务可以比分类调低一点epoch20微调/ 70从头训看数据集规模学习率衰减cosineTransformer 用 step decay 容易震荡这里特别强调sync BN必须关掉。T2T-ViT 里每个 token 的归一化层统计的是当前 batch 内的像素分布如果开了同步 BN多卡之间频繁通信会严重拖慢训练速度而且 ViT 类模型对 BN 统计量的扰动比 CNN 更敏感。我遇到过开着 sync BN 训练到第 10 个 epoch 时 loss 突然跳变的情况关了以后问题消失。代码层面ArcFace head 的实现直接决定了训练能否收敛。一个容易踩的坑是权重初始化分类层权重 W 必须在归一化后随机初始化不能带幅度信息。标准做法如下import torch import torch.nn as nn import math class ArcFaceHead(nn.Module): def __init__(self, embedding_dim, num_classes, s64.0, m0.5): super().__init__() self.s s self.m m # 权重初始化为单位向量空间的随机方向 self.weight nn.Parameter(torch.FloatTensor(num_classes, embedding_dim)) nn.init.xavier_uniform_(self.weight) # 把权重 l2 归一化到范数为 1 with torch.no_grad(): self.weight.data nn.functional.normalize(self.weight.data, dim1) self.cos_m math.cos(m) self.sin_m math.sin(m) self.th math.cos(math.pi - m) self.mm math.sin(math.pi - m) * m def forward(self, embedding, label): # embedding 先做归一化权重本身已经归一化 embedding nn.functional.normalize(embedding, dim1) cos_theta torch.matmul(embedding, self.weight.t()) cos_theta torch.clamp(cos_theta, -1.0, 1.0) # 计算加入 margin 后的 cos 值 sin_theta torch.sqrt(1.0 - cos_theta ** 2) cos_theta_m cos_theta * self.cos_m - sin_theta * self.sin_m # 处理 theta m pi 的情况 cos_theta_m torch.where(cos_theta self.th, cos_theta_m, cos_theta - self.mm) one_hot torch.zeros_like(cos_theta) one_hot.scatter_(1, label.view(-1, 1), 1.0) output one_hot * cos_theta_m (1 - one_hot) * cos_theta output output * self.s return nn.functional.cross_entropy(output, label)ArcFace head 是整个训练的核心部件。xavier_uniform_初始化后立即做一次归一化确保权重落在单位超球面上。torch.where那行代码是用来处理角度加上 margin 后越过 π 的问题——如果不处理反向传播时会出现梯度突变为 NaN 的情况。s 64在这里不是经验值它和特征维度、类别数有关类别数越多s 越小越难收敛保持 64 是一个相对安全的选择。如果数据集类别数超过 10 万可以考虑把 s 提高到 128。还有一个细节embedding 输出的内部维度不要直接设为 512。原因是 ArcFace 的归一化会丢失幅度信息如果 backbone 输出维度太低信息瓶颈过于严重。我一般把 T2T-ViT 的 Transformer 输出维度设为 1024再接一个线性层压到 512。这样既保留了 ArcFace 需要的特征维度又给 backbone 足够的表达空间。参数量增加约 2M但对精度有稳定提升。4. 推理、特征比对与边缘设备部署4.1 特征提取注意归一化和输入通道顺序训练完成以后模型输出的是 512 维 embedding。部署阶段你要做的事情是把 embedding 做 L2 归一化然后存到特征库里。推理时同样归一化之后算余弦相似度。这里有一个常见的错误有人把归一化做在最后比对时但提取特征存库时忘了归一化导致库里的特征和实时提取的特征尺度不一致相似度被系统性压低。下面是完整的特征提取和比对推演代码可以直接落地import torch import torch.nn.functional as F import numpy as np class FaceRecognitionModel: def __init__(self, model, devicecpu): self.model model.to(device).eval() self.device device def preprocess(self, img_bgr): # 输入是 BGR 的 112x112 对齐图 img_rgb img_bgr[:, :, ::-1] # 转 RGB img_rgb img_rgb.astype(np.float32) # 归一化到 [0,1]注意和训练时的归一化方式保持一致 img_rgb img_rgb / 255.0 # 标准化均值方差按训练时的 ImageNet 参数但如果从头训人脸集 # 建议用 (0.5, 0.5, 0.5) 或干脆只做 [0,1] 归一化 mean np.array([0.5, 0.5, 0.5], dtypenp.float32) std np.array([0.5, 0.5, 0.5], dtypenp.float32) img_rgb (img_rgb - mean) / std # 转为 CHW 并增加 batch 维度 tensor torch.from_numpy(img_rgb.transpose(2, 0, 1)).unsqueeze(0) return tensor.to(self.device) def get_embedding(self, img_bgr): tensor self.preprocess(img_bgr) with torch.no_grad(): feature self.model(tensor) # L2 归一化后转成 numpy feature F.normalize(feature, dim1) return feature.cpu().numpy().reshape(-1) def compare(self, emb1, emb2): return float(np.dot(emb1, emb2)) # 因为都归一化了点积就是余弦相似度训练时如果用了(0.5, 0.5, 0.5)做标准化推理时必须用同样的参数。很多人直接套 ImageNet 的 mean/std导致精度掉一到两个点。另外注意输入的图片必须是已经对齐的 112×112 人脸图如果你在部署流程里直接用检测框裁剪的图送进模型效果会大打折扣。门禁机这类设备上一般会先跑一个人脸检测模型拿到关键点再做对齐最后才进特征提取网络。4.2 大规模底库比对用 faiss 而不是暴力遍历当底库特征量达到百万级逐条计算余弦相似度会非常慢。常见做法是先把所有 embedding 做 L2 归一化然后用 faiss 的IndexFlatIP内积索引加速检索因为归一化后的内积就是余弦相似度。import faiss def build_index(embeddings): # embeddings: shape (N, 512)已经 L2 归一化 index faiss.IndexFlatIP(512) index.add(embeddings) return index def search(index, query_emb, top_k10): # query_emb 需要 reshape 成 (1, 512) scores, indices index.search(query_emb.reshape(1, -1), top_k) return scores[0], indices[0]IndexFlatIP是暴力精确检索适合百万级以下。超过千万级时要换IndexIVFFlat做倒排加速但那是另一个话题。在实际门禁或闸机项目里我见过很多人纠结要不要用 GPU 加速 faiss其实单机 CPU 跑百万底库的暴力检索也就几十毫秒瓶颈往往在图像采集和对齐上。先做好 pipeline 再优化索引不迟。4.3 边缘设备上的人脸识别门禁机部署要点“人脸识别门禁机”是边缘人脸识别最常见的落地形态。这类设备算力有限很多用的是 CPU 或者集成 GPU 而不是独立显卡运行 T2T-ViT 需要做几个关键优化。第一把模型转换成 ONNX 格式然后根据设备的推理后端再做量化和优化。PyTorch 直接跑效率很低尤其 Transformer 的 attention 计算在 CPU 上表现糟糕。转换命令很简单python -m onnxruntime.tools.convert_onnx_models_to_ort \ --optimization_level 99 \ --model_path face_t2t.onnx \ --output_dir converted/转换后模型会被计算图优化去掉不必要的算子融合。如果设备支持 INT8 量化还可以用dynamic quantization把权重压到 int8模型体积缩到四分之一在 CPU 上的速度提升通常在 2 到 3 倍。但注意量化后余弦相似度的数值分布会变阈值需要重新标定。第二Transformer 推理的固定开销很大特别是有 14 层 attention每层都要做多头注意力的矩阵运算。常见的优化方法是把 batch size 固定为 1然后用 TensorRT 的静态 shape 优化。这样能把推理时间从 30ms 压到 15ms 左右取决于设备代价是输入尺寸必须固定为 112×112不能动态变化。第三底库比对在边缘设备上要控制规模。门禁机的底库通常只有几百到几千人全部在设备端内存里做暴力比对完全可行不需要连服务器。这时候把特征库加载到内存用一个 numpy 矩阵存着就够了没必要上 faiss。5. 验证与调优用 LFW 协议和 TARFAR 指标判断模型到底行不行5.1 LFW 精度怎么算按协议跑不要自娱自乐人脸识别领域有一套标准评测协议LFW 是其中最基础的一个。它提供了 6000 对人脸其中 3000 对是同一个人的不同照片3000 对是不同人的照片你需要在这些对上计算相似度然后按阈值判定是否同一个人最后给出准确率。很多人说自己模型在 LFW 上 99.2%但一查代码发现是用训练集同分布数据测的甚至没有按标准协议划分这个数字没有任何意义。标准的 LFW 评测脚本逻辑如下import numpy as np from sklearn.metrics import roc_curve, auc def evaluate_lfw(model, pairs_data, pairs_label): pairs_data: shape (6000, 2, 512)每一对的两张人脸 embedding pairs_label: 6000 维数组0 表示不同人1 表示同一人 # 计算所有对的余弦相似度 emb1 pairs_data[:, 0, :] # (6000, 512) emb2 pairs_data[:, 1, :] # (6000, 512) # 因为 embedding 已经归一化点积就是余弦相似度 similarities np.sum(emb1 * emb2, axis1) # 用 ROC 曲线找最优阈值 fpr, tpr, thresholds roc_curve(pairs_label, similarities) fnr 1 - tpr # 最优阈值取 FAR 和 FRR 相等处 optimal_idx np.argmin(np.abs(fpr - fnr)) optimal_threshold thresholds[optimal_idx] accuracy (tpr[optimal_idx] 1 - fpr[optimal_idx]) / 2 return accuracy, optimal_threshold这里计算的准确率是在整个 6000 对上的平均精度其中阈值是全局同一个。要注意LFW 协议分成 10 折交叉验证标准做法是每折单独选阈值再平均我这里展示的是简化的整体计算。严格复现时建议按官方协议的 10 折方式来跑否则数值会比标准结果略高。5.2 TARFAR 指标衡量模型在低误报场景下的真实表现LFW 只能反映基础能力真正决定模型能否落地的指标是 TARFAR。TARTrue Acceptance Rate表示同一个人被正确识别的比例FARFalse Acceptance Rate表示不同人被错误接受的比例。门禁机这类安全敏感场景通常要求 FAR 小于 1e-4甚至 1e-5。假设有 10000 个底库用户FAR1e-4 意味着平均每次识别有 1 个陌生人会被错误放行。计算 TARFAR 的过程是这样的def compute_tar_at_far(similarities_pos, similarities_neg, far_target1e-4): similarities_pos: 同一对人的相似度数组 similarities_neg: 不同对人的相似度数组 # 把所有负样本对相似度排序找到对应 FAR 的阈值 neg_sorted np.sort(similarities_neg) threshold_idx int((1 - far_target) * len(neg_sorted)) threshold neg_sorted[threshold_idx] # 大于等于阈值的正样本对视为正确接受 tar np.mean(similarities_pos threshold) return tar, threshold代码逻辑很直白先从负样本对里找到分位数对应的相似度阈值然后统计在这个阈值下正样本被接受的比例。之所以用负样本排序而不是直接指定一个固定相似度阈值是因为不同模型的相似度分布差异很大只有相对位置是有意义的。T2T-ViT 在跨年龄、跨姿态的人脸对上表现通常比同参数量 CNN 更好但这一点必须在 CFP-FP跨姿态和 AgeDB-30跨年龄数据集上验证不能只看 LFW。5.3 自建测试集比刷公开基准更有工程价值公开基准刷得高不等于产品能用。实际部署中你会遇到公开数据集覆盖不到的情况戴口罩、戴眼镜、光线昏暗、摄像头分辨率低、角度刁钻。我建议在任何项目交付前自建一个和实际场景一致的测试集至少包含三类样本同一人当天不同时间段光照变化、同一人不同天服装和发型变化、不同设备拍摄的同一个人摄像头型号差异。自建测试集的评测不走 10 折协议直接按业务逻辑来底库固定 N 个人测试集包含 M 次真实抓拍统计通过率、误识别率和平均响应时间。这里有一个经验值当底库人数超过 1000 时TARFAR1e-4 至少要达到 95% 以上才适合门禁场景低于这个值建议回炉调数据而不是调阈值。阈值调低能提升通过率但误识别人数会指数级上升得不偿失。5.3.1 验证模型是否真的收敛看 embedding 的类内距离分布最后给一个实用的验证技巧训练结束后从测试集里抽 100 个身份每个身份 10 张图计算类内余弦相似度的均值和标准差。T2T-ViT 收敛正常的标志是类内相似度均值在 0.65 以上、标准差小于 0.1。如果均值低于 0.5说明模型没训透或者数据有问题如果标准差太大说明模型对某些特定姿态或光照仍然不稳定需要检查是不是输入归一化和训练时不一致。这个办法简单直接比反复刷公开基准更能帮助你判断模型状态。本文还有配套的精品资源点击获取