CASIA-SURF 多模态人脸活体检测:数据处理与三模态融合实践
第一次把 CASIA-SURF 的压缩包解开绝大多数人会走同一条路把color目录往ImageFolder里一塞套个 ResNet-18 训两轮验证集准确率冲到 98%心里一阵轻松然后换一批自己用手机拍的照片去测准确率直接掉到六成出头打印出来的照片被模型当成真人放行。这个落差不是模型不够深而是从一开始就把这套数据当成了普通图像分类任务。CASIA-SURF 是人脸活体检测方向最经典的多模态公开数据之一它真正值钱的地方不在于给了你 1000 个人、两万多段视频而在于它同时提供了可见光、近红外、深度三路互相补充的物理证据。这篇文章我想按一个真正要做项目的人的路径来讲这套数据到底在测什么、文件怎么组织、上手前必须清点哪些东西、三模态怎么接进网络、指标怎么算才不会被自己骗以及从这份数据里能挖出哪些可以迁移到自建采集上的经验。不管你是刚接触活体检测的新手还是已经在单模态上折腾很久想换思路的人下面这些内容应该都能直接用上。1. 这套数据解决的从来不是“分类问题”而是“证据不足问题”1.1 打印照片攻击到底在画面上留下了什么要理解多模态的价值得先弄清楚攻击者拿一张打印纸骗摄像头时物理世界里到底发生了什么变化。一张打印出来的人脸照片和被拍摄的真人在成像链路上有四处本质差异第一是材质纸张的微观纤维结构和打印机墨点的半色调网点会在高频区域留下规律性纹理肉眼看不出来但缩放到像素级就很明显第二是反射人的皮肤是次表面散射材质光线会渗进去再散射出来所以高光是柔和、有宽度、边缘发虚的而纸张和相纸的高光是镜面反射硬、亮、边缘锐利第三是几何真人的脸是一个连续起伏的三维曲面照片是一张几乎平的纸最多带点弯曲第四是边界纸的边缘、捏着纸的手指、背后的墙面这些都是照片里本不该出现的东西。麻烦在于这四条线索里只有前两条在可见光图像里比较明显而后两条恰恰是最难被伪造的。这就解释了为什么单纯堆 RGB 数据训出来的模型在实验室数据上表现很好一换环境就崩——它学到的大概率不是“真假”而是某个特定相机加特定光照加特定纸张的联合指纹。1.2 只有 RGB 的模型为什么一定会遇到上限我在几个不同项目里反复验证过一个现象用单模态 RGB 训出来的活体模型在你自己的测试集上能做到 99%但把采集设备从一台换成另一台哪怕还是同一个场景、同一批人分数分布就会整体平移。原因很直白——摩尔纹的空间频率和相机传感器的像素间距强相关高光的形状和光源位置强相关纸张纹理的可见度和曝光参数强相关。这些东西都是“设备环境”的函数不是“真假”的函数。模型越深越容易把这些 nuisance 特征学到极致。更致命的是攻击方的进化速度。早期的打印攻击用的是普通 A4 纸喷墨打印网点粗、反光怪RGB 一眼就能看出来。现在稍微讲究一点的攻击者会用相纸、用柔光、把照片贴在一个略带弧度的硬板上可见光下的纹理线索几乎被抹平。这时候你唯一还能依赖的就是物理量本身——近红外的反射率差异和深度图上的平面度。CASIA-SURF 的价值就在于它把这两路物理量和一个正常的 RGB 流一起采了下来让你能验证“加了这两路到底能提升多少”。1.3 三个模态各自补的是哪块短板我把三路数据的分工整理成下面这张表后面所有预处理和网络设计的决策基本都是从这张表推出来的模态记录的真实物理量打印照片在这里露馅的原因典型失效场景可见光color可见光波段的反射亮度组合半色调网点、纸张纤维纹理、硬边高光、纸张边缘色散相纸加柔光纹理被抹平强逆光导致过曝近红外ir近红外波段的反射强度皮肤、油墨、纸张在近红外下的反射率差异远大于可见光真人眼球有回射形成的亮瞳环境里有强的近红外源干扰戴反光眼镜深度depth传感器到每个像素对应点的距离真人是连续曲面深度有平滑梯度照片是平面深度值几乎恒定边缘会出现台阶或空洞深色吸光材质、超出量程、镜面反射造成大片无效像素这张表里最值得注意的是深度那一行的“失效场景”。很多人第一次用深度图会以为它是万能的实际上深度传感器在遇到高反光的相纸时会大面积返回无效值反而给出一个很强的信号——真人脸不会让深度图整片塌掉。所以无效像素本身也是特征这一点后面讲预处理时还会展开。2. 把数据集拆到文件级一千个人、两万多段视频是怎么组织的2.1 先看目录别急着写 DatasetCASIA-SURF 的官方描述是一千个受试者、约两万一千段视频、三路模态。按人头平均下来每个人大概有二十一段左右的采集记录这个数字不是随便来的——它对应的是“每人一段真人采集 每人六种攻击方式每种攻击方式在三路模态下各录一段”。也就是说在文件层面真人和攻击样本的比例大约是 1:6。我刚拿到数据时的第一件事不是写模型而是把目录树打出来看# 只看目录不看文件深度四层 find CASIA-SURF -maxdepth 4 -type d | head -50 # 统计每个一级目录下的二级目录数量 for d in CASIA-SURF/train/*/; do echo -n $d ; ls $d | wc -l; done | head结构大体是三层一级是受试者编号二级是该受试者的第几段采集三级是模态子目录color、depth、ir再往下才是逐帧的图片文件。不同发布批次在文件夹命名上会有出入有的版本二级目录直接用数字编号有的会带上前缀所以动手前先find一遍比看任何文档都靠谱。2.2 标签到底挂在哪一层这是第一个容易翻车的地方这里有个非常关键的认知同一受试者的真实样本和攻击样本是放在同一个一级目录下的。这意味着如果你按照“视频”为单位做随机划分同一个人会同时出现在训练集和验证集里模型只要记住这张脸就能在两边都拿高分指标会漂亮得不像话但完全没有意义。正确的划分单位必须是受试者。官方给出的划分是训练集三百人、验证集一百人、测试集六百人这三份之间人员不重叠。你自己如果要做交叉验证也必须按人来分组绝对不能按帧或者按视频随机打散。我见过不止一个项目在这里踩坑最后模型上线才发现泛化能力为零。标签文件的形式各个版本略有差异有的是一个train_label.txt每行是样本标识加 0/1 标签用逗号或者空格分隔有的版本干脆把类别编码进了目录名二级目录编号 1 代表真实、2 到 7 对应六种攻击。稳妥的做法是先head -5看一眼再决定解析方式写一个容错的解析函数def read_label(path): 容错读取支持逗号、空格、制表符分隔支持两列或三列 table {} with open(path, r, encodingutf-8, errorsignore) as f: for line in f: line line.strip() if not line or line.startswith(#): continue parts line.replace(,, ).replace(\t, ).split() if len(parts) 3: table[(parts[0], parts[1])] int(float(parts[2])) elif len(parts) 2: table[parts[0]] int(float(parts[1])) return table写完之后打印一下len(table)和几条样例跟受试者数量对一对。如果数量对不上说明解析规则猜错了别硬着头皮往下走。2.3 测试集标签不公开这对你的实验设计意味着什么官方开放的测试集只有图像、没有标签需要走评测流程才能拿到分数。这个设定对做研究的人是好事对做工程的人其实有点难受——你没法快速迭代。我的建议是把官方的一百人验证集当成唯一的“客观尺子”训练过程中所有决策都在这上面做但心里要清楚它只有一百个人样本量小指标的方差会比较大。具体做法上我会在验证集上额外做两件事一是用 bootstrap 重采样算一下指标的置信区间看看两个方案的差距是不是落在噪声范围内二是在训练集内部再切一个按人分组的留出集用来做超参搜索避免反复在验证集上过拟合。这两步花不了多少时间但能显著减少“以为涨了两个点其实是随机波动”的误判。3. 上手前的数据清点四件不做就一定返工的事3.1 三路模态的帧数不对齐是最常见的隐形炸弹三路传感器不是硬件级同步的起止时刻有偏移帧数经常差几帧到十几帧。如果你用同一个帧序号去取三路图像取到的根本不是同一时刻的画面融合的时候等于在拿A时刻的深度配B时刻的可见光模型学到的融合关系是乱的。正确做法是按归一化时间取帧也就是把每段视频的时间轴压缩到 0 到 1然后在同一批时间点上分别去三路里找最接近的帧def sample_indices(n_frames, clip_len, trainTrue): if n_frames 0: raise ValueError(空目录) if train and n_frames clip_len: start np.random.randint(0, n_frames - clip_len) idx np.arange(start, start clip_len) else: t np.linspace(0.0, 1.0, clip_len) idx np.round(t * (n_frames - 1)).astype(int) return np.clip(idx, 0, n_frames - 1) # 使用时 t np.linspace(0.0, 1.0, clip_len) idx_color np.round(t * (n_color - 1)).astype(int) idx_depth np.round(t * (n_depth - 1)).astype(int) idx_ir np.round(t * (n_ir - 1)).astype(int)训练阶段用连续片段是另一回事那时候三路各自随机取一个连续窗口就行只要保证窗口在时间上的相对位置大致对应不需要严格逐帧对齐。但推理和评估阶段一定要用上面的归一化时间对齐否则你测出来的分数没有可比性。3.2 深度图的数值范围和无效像素比想象中麻烦深度图最容易踩的坑是“看起来像图其实不是图”。它记录的是距离单位通常是毫米存储格式可能是 16 位单通道也可能是被压成 8 位的预览图。这两种情况处理方式完全不同16 位原始数据里0 通常代表无效像素超出量程、反射太强、材质吸光你可以拿它做掩码也可以做真实的距离归一化8 位预览图则已经丢掉了绝对距离信息只剩相对明暗精细的平面度分析基本做不了只能当粗糙的几何先验用。先写个脚本确认一下你手里是哪一种from PIL import Image import numpy as np p CASIA-SURF/train/1/2/depth/0001.jpg # 换成你的实际路径 img Image.open(p) arr np.array(img) print(mode:, img.mode, shape:, arr.shape, dtype:, arr.dtype) print(min/max:, arr.min(), arr.max()) print(零值占比:, float((arr 0).mean()))如果是uint16且最大值在几千量级那基本就是毫米单位的原始深度可以放心做距离分析。如果是uint8那就要接受精度损失的事实。归一化策略上我试过三种效果差异挺大策略做法优点缺点全局固定量程固定裁剪到比如 300 到 1500 毫米再线性映射保留绝对距离的物理含义跨设备相对稳定需要知道传感器量程超范围的部分会被压死逐图 min-max每张图用自己的有效像素最小最大值归一化对量程差异不敏感鲁棒丢掉了“这张脸离镜头多远”的信息平面攻击的绝对平坦度被掩盖逐图 min-max 加无效掩码在上面基础上额外给一路二值掩码通道无效区域显式建模模型能学到“这里本该有深度”输入通道变多第一层要改我最后选的是第三种。原因是无效像素的分布本身就是强特征——真人脸的深度图无效区通常出现在边缘和头发附近是零散的平面照片的无效区经常成片出现甚至有整块塌陷。把掩码单独喂进去等于明确告诉网络“这块地方没测到”比让它自己去从零值里推断要稳得多。3.3 一比六的类别失衡会怎么扭曲你的模型按前面的推算真人样本和攻击样本在文件层面大概是 1:6。如果你不做任何处理直接训模型很快会发现“全部判成攻击”就能拿到八成以上的准确率于是它就这么干了。表现出来就是验证集准确率看着还行但真人被拒绝的比例高得离谱实际根本没法用。这里要建立一个观念在活体检测里这两类错误的代价完全不对称。把攻击放过去是安全事件把真人拦下来是体验事件两者都需要单独看不能合成一个准确率。处理失衡我有几个常用手段按优先级排第一是评估时永远分开报两个错误率别只看总体第二是损失函数里加正样本权重权重值取负正样本比例的开方左右比较温和取全比例会太激进导致反过来偏真人第三是采样时做分层保证每个 batch 里真人和攻击样本都有并且攻击样本按六种攻击类型轮转避免某一类攻击主导梯度。第三点经常被忽略。六种攻击方式在数据量上可能差不多但难度差异很大有的攻击在 RGB 下几乎看不出来、在深度下却一目了然有的反过来。如果 batch 里总是那几类容易的模型就会偷懒。3.4 写一份自己的数据清点表在写训练代码之前我一定会先生成一份统计表把每个样本的三路帧数、分辨率、深度有效像素比例都记下来。这份表后面会反复用到定位坏样本、分析错例、决定裁剪策略都靠它。import os, glob import pandas as pd MODS (color, depth, ir) rows [] for split in (train, val): root fCASIA-SURF/{split} for subj in sorted(os.listdir(root)): sdir os.path.join(root, subj) if not os.path.isdir(sdir): continue for vid in sorted(os.listdir(sdir)): vdir os.path.join(sdir, vid) if not os.path.isdir(vdir): continue counts, missing {}, [] for m in MODS: mdir os.path.join(vdir, m) fs glob.glob(os.path.join(mdir, *)) counts[m] len(fs) if len(fs) 0: missing.append(m) rows.append({ split: split, subject: subj, video: vid, n_color: counts[color], n_depth: counts[depth], n_ir: counts[ir], missing: ,.join(missing), max_gap: max(counts.values()) - min(counts.values()), }) df pd.DataFrame(rows) print(df.describe()) print(缺模态样本数:, (df[missing] ! ).sum()) print(帧数差超过20的样本数:, (df[max_gap] 20).sum()) df.to_csv(surf_stats.csv, indexFalse)跑完这个脚本你至少能回答三个问题有没有哪个样本缺了一整路模态、帧数差异的分布长什么样、每个受试者的样本数是不是均匀。缺模态的样本一定要挑出来单独处理直接跳过或者补零都行但绝不能让它悄悄混进训练把某一路的分布带偏。4. 三模态怎么接进网络四种接法和我的选择4.1 早融合、中融合、晚融合的取舍把三路数据送进网络本质上是在决定“在哪一层让它们开始互相看”。常见的有四种做法我列个表对比融合方式具体做法优势代价早融合三路拼成五通道直接进同一个骨干实现最简单跨模态的局部对应关系能被第一层卷积直接利用三路统计分布差异巨大共享第一层容易互相干扰中融合各自一个浅层 stem 提特征在中间层拼接后再共享深层兼顾了模态特异性和跨模态交互实际效果最稳参数量略增实现稍复杂晚融合三个独立模型最后对分数做加权平均每个模态都能单独调一个模态坏了不影响其他完全放弃了跨模态的互补信息参数量三倍交叉注意力用注意力在模态之间做交互理论上限最高在数据量足够时优势明显对数据量敏感一千人的规模容易过拟合我在这份数据上做过对比中融合的性价比最高。早融合的问题是深度那一路的数值分布和 RGB 完全不在一个量级共享第一层的批归一化会把深度通道的统计量拉平训练前期深度分支几乎不贡献梯度晚融合则是白白浪费了这份数据最大的卖点——三路是同一时刻同一角度采的天然对齐不做交互太可惜了。至于交叉注意力类的结构我建议等你把中融合的基线跑稳、有一份可信的对照分数之后再去试。一千人的规模注意力模块很容易学成“记住某个人的脸”。4.2 为什么第一层一定要拆开这一条我想单独强调因为它是最容易被跳过、又最影响结果的一步。可见光三通道是反射亮度的组合近红外单通道是反射强度深度单通道是毫米距离这三者的数值范围、方差、空间频率特性完全不同。如果它们共享第一层卷积和批归一化归一化层会用一个混合的均值和方差去标准化所有通道结果就是深度那一路的动态范围被严重压缩网络看到的几乎是一张常数图。具体做法有两种一种是分组卷积让第一层卷积在通道维度分组、组内不交叉然后在后面再混另一种是干脆给每一路配一个独立的浅层 stem各自出 32 或 64 维特征拼接后再进共享骨干。我用的是第二种代码大致是这样import torch import torch.nn as nn class TriModalStem(nn.Module): def __init__(self, out_dim64): super().__init__() self.stem_rgb nn.Sequential( nn.Conv2d(3, out_dim, 3, stride2, padding1, biasFalse), nn.BatchNorm2d(out_dim), nn.ReLU(inplaceTrue)) self.stem_ir nn.Sequential( nn.Conv2d(1, out_dim // 2, 3, stride2, padding1, biasFalse), nn.BatchNorm2d(out_dim // 2), nn.ReLU(inplaceTrue)) self.stem_d nn.Sequential( nn.Conv2d(2, out_dim // 2, 3, stride2, padding1, biasFalse), # 深度 无效掩码 nn.BatchNorm2d(out_dim // 2), nn.ReLU(inplaceTrue)) self.fuse nn.Sequential( nn.Conv2d(out_dim * 2, out_dim, 1, biasFalse), nn.BatchNorm2d(out_dim), nn.ReLU(inplaceTrue)) def forward(self, rgb, ir, depth): f torch.cat([self.stem_rgb(rgb), self.stem_ir(ir), self.stem_d(depth)], dim1) return self.fuse(f)这个 stem 输出 64 通道、空间尺寸减半的特征图后面直接接一个去掉第一层的 ResNet-18 或者 MobileNetV3 主体就行。改动量很小但我在三组对照实验里都看到了稳定的提升尤其是在只有一百人的验证集上跨受试者的分数分布明显更集中。4.3 帧采样和视频级聚合别小看这两步每段视频有上百帧你不可能全送进去。我的默认设置是训练取 8 帧、测试取 8 帧但重复 3 次不同起点再平均。训练时随机起点是为了让模型见到一段采集里不同的姿态和表情变化测试时多取几次是为了降低单次采样的方差——实测下来这一个操作能让验证集指标的波动幅度收窄一半左右。聚合方式上我建议对 logit 取平均而不是对概率取平均。原因是概率经过 sigmoid 之后会被压到 0 到 1 之间个别极端帧会主导平均值logit 保留了对数几率平均之后再做判定更平滑。如果你的模型是逐帧出分的可以这样写def aggregate(logits_per_clip): # logits_per_clip: [n_clip, clip_len] 的 tensor return logits_per_clip.mean(dim1) # 先在帧维度平均再在 clip 维度平均还有一种做法是取最大值理由是“只要有一帧明显是攻击整段就判攻击”这在安全优先的场景下合理但会推高真人被误拒的比例。我一般会两种都算出来看哪个更符合业务上的代价权衡。4.4 一个能跑起来的最小训练循环把上面这些拼起来训练部分其实不复杂import torch.nn.functional as F def train_one_epoch(model, loader, opt, pos_weight, device): model.train() total, correct, n 0.0, 0, 0 for rgb, ir, dep, label in loader: rgb, ir, dep, label rgb.to(device), ir.to(device), dep.to(device), label.to(device) logits model(rgb, ir, dep).squeeze(1) loss F.binary_cross_entropy_with_logits( logits, label, pos_weighttorch.tensor(pos_weight, devicedevice)) opt.zero_grad() loss.backward() opt.step() total loss.item() * label.size(0) correct ((torch.sigmoid(logits) 0.5).float() label).sum().item() n label.size(0) return total / n, correct / npos_weight就是前面说的正样本权重如果你把真人当作正类标签 1那在 1:6 的失衡下大概取 2 到 3 之间比较合适不要直接取 6会让模型过度偏向真人。5. 指标算错比模型差更可怕5.1 APCER、BPCER、ACER 到底怎么算活体检测的评估有一套相对固定的口径不按这个口径报数跟别人的结果就没法比。三个核心指标是这样定义的把攻击样本判成真人的比例叫 APCER把真人样本判成攻击的比例叫 BPCER两者的平均叫 ACER。看起来简单但有两个细节特别容易搞错。第一个细节是 APCER 要在每种攻击类型上分别算然后取最大值不是把所有攻击样本混在一起算。这是安全评估的基本逻辑——攻击者只会挑最容易突破的那一种方式下手平均分会掩盖掉最弱的那一环。第二个细节是阈值的选择方式。直接取 0.5 是没有意义的正确做法是在验证集上先定一个目标 BPCER比如百分之一然后找到对应的阈值再在这个阈值上报 APCER。import numpy as np def apcer_bpcer(scores, labels, attack_types, thr): scores: 真人得分越大越像真人labels: 1 真人 / 0 攻击 scores, labels np.asarray(scores), np.asarray(labels) attack_types np.asarray(attack_types) bona scores[labels 1] bpcer float((bona thr).mean()) per_type [] for t in np.unique(attack_types[labels 0]): s scores[(labels 0) (attack_types t)] per_type.append(float((s thr).mean())) apcer max(per_type) if per_type else 0.0 return apcer, bpcer, (apcer bpcer) / 2, dict(zip(np.unique(attack_types[labels 0]), per_type)) def thr_at_bpcer(scores, labels, target0.01): bona np.sort(scores[np.asarray(labels) 1]) k max(0, int(len(bona) * target) - 1) return float(bona[k])这套代码跑出来的per_type字典很有用它能告诉你模型最怕哪一种攻击。如果某一类的 APCER 特别高基本就是模型在这类攻击的关键线索上失明了顺着这条线去查预处理的对应模态往往能定位到问题。5.2 只看准确率会掩盖什么前面提过 1:6 的失衡。假设验证集里有一百四十三个真人样本、八百五十七个攻击样本一个把所有输入都判成攻击的“模型”准确率是 85.7%看着还挺体面但它的 BPCER 是百分之百任何一个真人都过不去。反过来一个全判真人的模型准确率只有 14.3%但 APCER 是百分之百等于完全不设防。所以在汇报结果的时候我坚持只报三个数在目标 BPCER 下的 APCER、对应的 ACER、以及每种攻击类型的分项 APCER。准确率这个数字在这类任务里几乎没有信息量写进报告反而容易误导决策的人。还有一个常被忽略的点是分数分布的可分性。两个模型可能 ACER 一样但一个的分数分布是双峰分离得很干净的另一个是挤在一起靠阈值勉强切开的。后者的鲁棒性差得多换个设备稍微一漂就崩。我会把真人和攻击的分数直方图画出来看一眼这比任何单一数字都直观。5.3 跨设备掉点时的排查链路在这份数据上训出不错的分数之后拿自己采的数据一测掉点是常态。我一般按这个顺序排查从便宜到贵第一步查预处理。深度图的归一化方式是不是和训练时一致近红外的伽马有没有被无意改动三路的裁剪比例对不对这几项里只要有一项不一致掉十个点很正常。第二步查分辨率与视场。训练时用的缩放和裁剪策略在推理时是不是原样保留尤其是深度和近红外它们的视场往往比可见光窄如果按可见光的方式裁剪深度图边上会出现大片无效区。第三步查模型是不是学到了设备指纹。做法很简单把你新采的一批真人分成两组一组用训练时的设备拍一组用新设备拍分别看模型给的分数分布。如果两组分得很开说明模型对设备敏感那就要在数据增强上想办法比如加不同强度的抖动、随机模拟不同的伽马曲线。第四步才是考虑模型容量和结构。前面三步都没问题还掉点才说明是表示能力不足。很多时候问题根本不在模型上。6. 把它当素材库而不是当成一张考卷6.1 攻击类型的组合方式可以直接抄这份数据最值得借鉴的地方其实是它的构造逻辑。六种攻击方式不是随便凑的而是把“材质、尺寸、呈现方式、光照”这几个维度做了正交组合。你在设计自己的采集方案时完全可以沿用这个思路先列出你能想到的攻击维度每个维度取两到三个取值做笛卡尔积这样得到的攻击类型覆盖面远比“随便打印几张纸”要系统。举个例子材质维度可以是普通纸和相纸呈现维度可以是平放、手持、贴板微弯光照维度可以是正面柔光和侧向强光。三个维度各取两到三个值就是十几到二十几种组合每种录三段一个人的数据量就够了。这样的数据训出来的模型面对没见过的组合时退化会平缓得多。反过来如果你的采集只覆盖了“平放的普通纸打印”这一种情况那模型学到的其实是“平放”这个特征遇到手持的照片立刻失效。这是我在实际项目里吃过的最大的一个亏。6.2 增强策略要往物理合理的方向走多模态数据的增强有个铁律几何变换必须三路同步光度变换必须分模态独立。翻转、旋转、裁剪、缩放这些操作三路图像必须用完全相同的参数否则深度和可见光就对不上了融合直接失效。而亮度、对比度、伽马这类光度变换只能作用在可见光和近红外上绝对不能对深度图做——深度图里每个像素是毫米数你把它调亮一点在物理上没有意义只会教模型学错东西。针对深度图我用的增强是这几样随机把一小块区域置零模拟无效像素、给有效区域加一点点高斯噪声模拟传感器抖动、随机做一个整体的线性缩放模拟不同距离。针对近红外主要是伽马抖动和轻微的高斯模糊模拟不同强度的主动照明。这些都是有物理对应关系的操作比通用的随机擦除、混合样本要安全得多。还有一个小技巧训练时随机丢弃某一路模态。这样做有两个好处一是让模型不至于完全依赖某一路二是在部署时如果某一路传感器临时失效模型还能降级工作。我一般以百分之十的概率单独丢掉深度或者近红外效果比想象中好。6.3 自建采集时的对齐清单如果你打算照着这套数据的思路自己采一批我把自己踩过的坑整理成一份清单可以直接拿去用检查项具体要求不做的后果时间同步三路统一触发记录起始时间戳帧序号错位融合学到错误对应关系视场对齐三路尽量共轴或记录标定参数深度图和可见光边缘对不上裁剪时丢信息受试者覆盖至少覆盖不同年龄段、是否戴眼镜、不同肤色模型对某类人群系统性偏高误拒攻击类型正交按材质、呈现、光照做组合模型记住单一攻击的无关特征环境多样性不同室内光照、不同背景模型把背景当线索设备多样性至少两种采集设备换设备后分数分布整体漂移最后再分享一个我在实际操作中的体会。很多人拿到 CASIA-SURF 之后第一反应是去追 SOTA想办法把 ACER 再压零点几个点。但这份数据真正的门槛从来不在模型结构上而在数据处理的那几十行代码里——帧对齐、深度归一化、无效掩码、按人划分、失衡处理这五件事只要有一件做错你后面所有的实验结论都是建立在流沙上的。我见过太多人花了两个月调网络最后发现问题出在验证集和训练集有人员重叠。先把数据这条线捋直再谈模型顺序反了就是白费力气。