☰
CASIA-SURF 多模态人脸活体检测:数据处理与三模态融合实践
2026/11/2 12:06:45 网站建设 项目流程

第一次把 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 先看目录,别急着写 Dataset

CASIA-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", encoding="utf-8", errors="ignore") 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, train=True): 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 = f"CASIA-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", index=False)

跑完这个脚本,你至少能回答三个问题:有没有哪个样本缺了一整路模态、帧数差异的分布长什么样、每个受试者的样本数是不是均匀。缺模态的样本一定要挑出来单独处理,直接跳过或者补零都行,但绝不能让它悄悄混进训练把某一路的分布带偏。

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_dim=64): super().__init__() self.stem_rgb = nn.Sequential( nn.Conv2d(3, out_dim, 3, stride=2, padding=1, bias=False), nn.BatchNorm2d(out_dim), nn.ReLU(inplace=True)) self.stem_ir = nn.Sequential( nn.Conv2d(1, out_dim // 2, 3, stride=2, padding=1, bias=False), nn.BatchNorm2d(out_dim // 2), nn.ReLU(inplace=True)) self.stem_d = nn.Sequential( nn.Conv2d(2, out_dim // 2, 3, stride=2, padding=1, bias=False), # 深度 + 无效掩码 nn.BatchNorm2d(out_dim // 2), nn.ReLU(inplace=True)) self.fuse = nn.Sequential( nn.Conv2d(out_dim * 2, out_dim, 1, bias=False), nn.BatchNorm2d(out_dim), nn.ReLU(inplace=True)) def forward(self, rgb, ir, depth): f = torch.cat([self.stem_rgb(rgb), self.stem_ir(ir), self.stem_d(depth)], dim=1) 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(dim=1) # 先在帧维度平均,再在 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_weight=torch.tensor(pos_weight, device=device)) 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 / n

pos_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, target=0.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 再压零点几个点。但这份数据真正的门槛从来不在模型结构上,而在数据处理的那几十行代码里——帧对齐、深度归一化、无效掩码、按人划分、失衡处理,这五件事只要有一件做错,你后面所有的实验结论都是建立在流沙上的。我见过太多人花了两个月调网络,最后发现问题出在验证集和训练集有人员重叠。先把数据这条线捋直,再谈模型,顺序反了就是白费力气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询