十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

TensorFlow多特征融合实战:微表情识别从特征导出到模型训练

TensorFlow多特征融合实战:微表情识别从特征导出到模型训练 简介面向需要完成期末大作业或课程设计的计算机视觉方向学生这份基于TensorFlow的多特征融合微表情识别项目提供了一套从视频预处理、特征提取到分类评估的完整可运行方案。代码已获导师指导并在期末答辩中获得97分下载后无需修改即可复现结果适合作为高年级本科生或研究生的实践参考。压缩包共8个文件以Python源码为主体另有说明文档与Git属性配置其中主程序、时域插值模型、局部加权均值配准、欧拉视频放大预处理、特征提取及分类评估等模块分工明确便于按流程理解多特征融合思路。资源整体仅19KB体量轻巧配合README可快速掌握运行环境与使用方式。目前已有115人学习使用适合希望短时间搭建微表情识别基线系统、降低重复编码成本的学习者。1. 微表情识别为什么必须走 TensorFlow 多特征融合这条路微表情持续时间通常在 1/25 秒到 1/5 秒在单帧上只是几像素的嘴角或眉毛位移常规动作识别网络很难把这种弱信号从头部抖动和背景噪声里扣出来。干过视觉项目的人都清楚弱信号场景下单一特征容易撞天花板帧差法扛不住轻微点头灰度纹理区分不开近似表情光流在中性帧到顶点帧之间会混入大量无效位移。把纹理、光流、几何这三类互补信息做多特征融合是微表情识别从“能跑”走向“能看”的关键路径。在 TensorFlow 里实现这条路径工程重点和论文不一样样本通常只有几百个网络不能堆太厚特征要在进入模型前对齐帧长融合层放在哪、用什么加权直接决定验证集上是 61% 还是 78%。这篇文章面向在做表情识别、多模态或行为分析的算法工程师梳理从特征导出、三流模型搭建到损失与训练技巧的一整套可落地做法。2. 数据端先行微表情数据集与光流、LBP、关键点特征导出2.1 微表情数据集的规模决定了融合模型的克制程度先看为什么必须做特征融合而不是直接堆深度网络公开可申请的主流微表情数据集样本量基本都在几十到两三百个片段级别类别也高度不平衡。相比 ImageNet 上动辄百万张图片这个规模经不起随便加深网络参数量稍微膨胀就会在验证集上立刻过拟合。多特征融合在这种情况下是一种“用信息密度换模型复杂度”的策略每个分支只负责一种模态的弱信号最后合并出来的特征比单一大网络更容易在小样本上压住偏差。不同数据集的常用划分和标注口径并不一致做跨数据集评测前必须先对齐。多数实验会把标注归并成 5 到 7 类比如惊讶、厌恶、愤怒、幸福、压抑以及其他。即便都是 200 帧率采集片段的有效长度也差别很大直接整段输入会引入大量中性帧反而稀释了顶点帧附近的判别信息。数据集片段规模帧率常见做法CASME II200 段左右200 FPS截取 onset 到 offset按 AU 归并为 5 到 7 类SAMM150 段左右200 FPS按受试者划分训练与测试避免同一个人出现在两边SMIC160 段左右100 FPS常用正向、负向、惊讶三分类一个通用的预处理约束是把输入序列统一到固定帧长 T通常在 16 到 24 帧。做法是先从 onset 到 apex 附近均匀采样 T 帧序列不足的重复最后一帧补齐。剪到 apex 而不是 offset 很关键因为微表情最明显的幅度变化集中在顶点帧之前后面恢复期对分类反而可能是噪声。2.2 特征导出脚本光流、LBP、面部关键点位移很多微表情源码包不会把原始帧直接塞给三维卷积而是先在数据端导出三路特征。原因很实际微表情数据集的采集环境噪音大离线做一次人脸对齐和特征归一化比在模型里反复学这些变换要稳定得多也方便后续做留一特征试验。第一路是光流。微表情幅度小帧间像素灰度变化很微弱TV-L1 光流比 Farneback 在小位移上更稠密、更抗噪声。第二路是局部纹理通常会先算每帧的 LBP 图再让后面的卷积层自己去学时间维上的共现模式可以理解为 LBP-TOP 的一个简化变体。第三路是面部关键点位移用 68 点人脸关键点检测器提取每个点在时间轴上的相对位移这一路对眉毛、嘴角的局部运动非常敏感。# extract_features.py import cv2 import numpy as np from skimage.feature import local_binary_pattern def extract_faces(frames, rois, target_size(224, 224)): 按检测器给出的人脸框裁剪并缩放: frames shape (T,H,W,C) - (T,224,224,3) faces [] for frame, roi in zip(frames, rois): x, y, w, h roi face frame[y:yh, x:xw] faces.append(cv2.resize(face, target_size)) return np.stack(faces, axis0) def compute_tvl1_flow(frames): TV-L1 光流: 返回 (T-1,H,W,2)末尾复制最后一帧保持时序长度一致 flows [] prev cv2.cvtColor(frames[0], cv2.COLOR_RGB2GRAY) for i in range(1, len(frames)): curr cv2.cvtColor(frames[i], cv2.COLOR_RGB2GRAY) flow cv2.optflow.calcOpticalFlowTVL1(prev, curr) flows.append(flow) prev curr flows.append(flows[-1]) return np.stack(flows, axis0) # (T,H,W,2), 通道0为dx, 通道1为dy def compute_lbp_sequence(frames, radius1, n_points8): 逐帧均匀模式 LBP: 返回 (T,H,W)值域为 0 到 n_points1 lbps [] for frame in frames: gray cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY) lbp local_binary_pattern(gray, n_points, radius, methoduniform) lbps.append(lbp.astype(np.float32)) return np.stack(lbps, axis0)radius决定 LBP 采样的邻域半径半径取 1 时对细小的纹理更敏感但也更容易把压缩噪声带进来实践里微表情任务常用 1 到 3验证集上对比后再定。n_points一般取 8 或 16均匀模式会把 256 级模式压到 10 或 18 个 bin否则后续卷积特征太稀疏收敛很慢。光流补帧的做法也需要留意多种补法看起来不影响 loss但对 F1 的影响在 1 到 2 个百分点我一般会把补齐策略当成超参一起搜。关键点位移需要在同一套代码里完成先对每一帧做人脸对齐再把 68 个关键点坐标减去第一帧坐标最后展平成(T, 136)。注意这里不能用绝对坐标否则头部位置平移会被模型当成表情信号。2.3 用 tf.data 把三路特征拼成一个 TensorFlow 训练流特征导出完成后训练阶段不再需要解码视频直接读预提取的中间结果就行。常见做法是存成单个 HDF5 文件每个 key 对应一个样本内部的flow、lbp、landmark三个数组成员作为三个输入分支。TensorFlow 这边用tf.data的from_generator把 HDF5 包成多输入数据集比在model.fit里手工切片更容易管理 shuffle 和 prefetch。# data_pipeline.py import h5py import numpy as np import tensorflow as tf def build_multi_input_dataset(h5_path, label_path, batch_size8, t_length16): with h5py.File(h5_path, r) as f: clip_names list(f.keys()) labels np.load(label_path, allow_pickleTrue).item() def gen(): for name in clip_names: with h5py.File(h5_path, r) as f: g f[name] yield (g[flow][:t_length].astype(np.float32), g[lbp][:t_length].astype(np.float32) / 18.0, g[landmark][:t_length].astype(np.float32)), labels[name] ds tf.data.Dataset.from_generator( gen, output_signature( (tf.TensorSpec(shape(None, 224, 224, 2), dtypetf.float32), tf.TensorSpec(shape(None, 224, 224, 1), dtypetf.float32), tf.TensorSpec(shape(None, 136), dtypetf.float32)), tf.TensorSpec(shape(), dtypetf.int32) ) ) ds ds.shuffle(64).batch(batch_size).prefetch(tf.data.AUTOTUNE) return ds这里几个参数值得单独说明。batch_size取 8 而不是 32是因为光流分支的输入是(8, 16, 224, 224, 2)显存占用接近一个小的视频分类模型不是卡不行而是微表情总共两三百个样本大 batch 反而让每一轮梯度都偏向多数类。shuffle(64)的 buffer 超过样本总数一半即可太大没有额外收益。LBP 分支除以模式总数是为了把整数模式值压到 0 到 1 区间避免第一个卷积层就出现数值极差过大的问题。提示划分训练集和测试集时按受试者分组不要把同一个人的多个片段同时分到两边。否则模型记住人脸特征就能得分融合层根本不会被逼着学习真实的微表情模式。3. 三路输入与融合层TensorFlow 多特征融合模型的具体搭法3.1 特征级、中间级、决策级融合的适用边界多特征融合并不是把三个特征向量拼在一起那么简单。按融合发生的位置可以分成三类每一类在微表情这种小数据集上的表现差异很明显。特征级融合是最直接的做法三个分支各自编码最后Concatenate送进分类头优点是梯度能同时回传到三个分支缺点是如果某个分支特征维度远大于其他分支融合后的向量会被它主导。中间级融合在分支网络的中间层加入注意力或交叉交互表达能力更强但实现和调试成本更高。决策级融合是三个分支各自独立训一个分类器最后把概率加权平均或投票三个模型可以分开调参但无法在训练中共享低层特征。融合方式融合位置参数量小样本下的表现特征级 Concat分类头之前最低简单可靠但要处理分支间尺度差异注意力中间级各分支特征之后、分类头之前中等能自动降权噪声分支收敛更稳决策级投票每个分支独立分类器最高适合跨数据集融合但需要分别调三个模型在只有一个 GPU、样本不超过 300 段的日常项目里我通常会先做特征级 Concat 作为基线再切到注意力融合。这个顺序能快速确认每个分支是否真的带信息而不是一上来就加复杂模块最后分不清提升来自注意力还是来自随机种子。3.2 用 TensorFlow Functional API 写一个三流微表情识别模型下面这段代码对应常见源码包里主干网络的写法光流分支用 3D-CNN 编码时空信息LBP 纹理分支用逐帧 2D-CNN 加双向 GRU关键点几何分支直接用 GRU 建模时间序列。三个分支输出的一维特征进入融合层后面再接全连接分类头。# model.py import tensorflow as tf from tensorflow.keras import layers def _attention_fusion(features, hidden_dim32): 先投影到相同维度再做通道注意力加权而不是简单拼接 projected [layers.Dense(hidden_dim, activationrelu)(f) for f in features] x tf.stack(projected, axis-1) # (B, hidden_dim, 3) att layers.GlobalAveragePooling1D()(x) # 沿特征维度取平均 - (B, 3) att layers.Dense(3, activationsoftmax, namebranch_att)(att) merged tf.reduce_sum( [projected[i] * att[:, i:i1] for i in range(3)], axis0 ) return merged def build_mer_model(t_length16, num_class5, fuseattention, hid32): flow_in layers.Input(shape(t_length, 224, 224, 2), nameflow_input) lbp_in layers.Input(shape(t_length, 224, 224, 1), namelbp_input) lm_in layers.Input(shape(t_length, 136), namelandmark_input) # 光流流: 3D-CNN, 卷积核 (时间, 高度, 宽度) x layers.Conv3D(16, (3, 5, 5), paddingsame, activationrelu)(flow_in) x layers.MaxPool3D((2, 2, 2))(x) x layers.Conv3D(32, (3, 3, 3), paddingsame, activationrelu)(x) flow_feat layers.GlobalAveragePooling3D()(x) # LBP 流: 每帧过一个共享 CNN, 再用双向 GRU 建模时间依赖 lbp_flat layers.Reshape((t_length, 224, 224, 1))(lbp_in) lbp_feat layers.TimeDistributed( tf.keras.Sequential([ layers.Conv2D(16, 3, paddingsame, activationrelu), layers.MaxPool2D((2, 2)), layers.Conv2D(32, 3, paddingsame, activationrelu), layers.GlobalAveragePooling2D(), ]) )(lbp_flat) lbp_feat layers.Bidirectional(layers.GRU(32, return_sequencesFalse))(lbp_feat) # 几何流: 68 个关键点在时间轴上已对齐成 T×136 lm_feat layers.GRU(64, return_sequencesFalse)(lm_in) if fuse concat: merged layers.Concatenate()([flow_feat, lbp_feat, lm_feat]) else: merged _attention_fusion([flow_feat, lbp_feat, lm_feat], hidden_dimhid) out layers.Dense(64, activationrelu)(merged) out layers.Dropout(0.5)(out) out layers.Dense(num_class, activationsoftmax)(out) return tf.keras.Model(inputs[flow_in, lbp_in, lm_in], outputsout)Conv3D的第一个维度是时间核大小取 3 表示一次只看相邻三帧这比 5 或 7 更稳妥因为微表情片段本身只有 16 帧左右时间核太大容易把 onset 到 apex 的关键变化磨平。GlobalAveragePooling3D把光流特征压成一维向量避免了在全连接层直接拍平导致的大量参数。几何流的GRU(64)没有加双向原因是关键点位移序列已经非常短双向结构在训练后期容易记住训练集的时序模式泛化反而变差。3.3 融合层不一定是 Concat注意力加权在小样本下更稳_attention_fusion做的事情可以拆成四步先让三个分支的输出经过各自的投影层变成相同维度再把三个向量堆叠成矩阵用全局池化得到每个分支的标量描述最后通过 softmax 输出三个权重。这个结构不需要额外标注训练时梯度会自动把权重推向更有判别力的分支。这里有一个容易忽略的细节Concat 融合要求分支之间的数值范围大体一致否则 LBP 分支输出范围如果是 0 到 1而几何分支输出是 0 到 50LBP 分支的信息在融合后会被稀释。_attention_fusion里的 Dense 投影层天然做了尺度重标定所以从 Concat 切到注意力融合后往往验证集 UAR 会涨 2 到 4 个百分点不是注意力本身玄学而是它顺带做了特征归一化。如果你不想加注意力也可以在每个分支输出后补一个LayerNormalization效果接近但少了分支可解释性。4. 训练策略与高频排错小样本微表情识别模型的收敛问题4.1 预训练与分阶段训练比直接端到端更稳微表情数据量太小端到端从随机初始化开始训几乎必然过拟合。常见做法是把训练拆成两段第一阶段冻结卷积层只训练融合层和分类头第二阶段再整个模型一起低学习率微调。对于 LBP 分支的 2D-CNN可以加载 ImageNet 预训练权重虽然输入是单通道 LBP 图但把第一个卷积层的权重按通道复制三份初始化通常比随机初始化快得多。光流分支的输入是双通道没有现成预训练权重随机初始化后要加一个L2正则权重衰减一般设在 1e-4 到 5e-4。训练阶段冻结范围学习率Epoch 数说明阶段一三个分支的卷积权重3e-430 到 50训练融合层与分类头观察分支收敛阶段二全部解冻1e-420 到 40低学习率微调早停看 UAR优化器用 AdamW 而不是普通 Adam关键在多一个解耦的权重衰减比在kernel_regularizer里手写 L2 更容易控制也不会影响偏置项。学习率调度可以省掉但 warmup 不要省先线性上升到目标学习率再余弦退火能让注意力融合层的权重在前几个 epoch 不来回震荡。4.2 损失函数与评价指标UAR 比 Accuracy 更能反映融合效果微表情数据集的类别分布天然不均衡“其他”类可能占 40%而“惊讶”类只有 10%。如果直接优化交叉熵模型会把几乎所有样本都判成多数类Acc 看起来有 50% 以上但每一类微表情基本没识别出来。评价指标要用 UAR也就是各类别召回的均值损失函数上Focal Loss 比简单加权交叉熵更常用。# losses.py import tensorflow as tf def focal_loss(alpha0.25, gamma2.0): alpha 控制正负样本权重, gamma 控制难易样本权重 def loss_fn(y_true, y_pred): ce tf.keras.losses.categorical_crossentropy(y_true, y_pred) p_t tf.reduce_sum(y_true * y_pred, axis-1) weight alpha * tf.pow(1.0 - p_t, gamma) return tf.reduce_mean(weight * ce) return loss_fn参数上gamma取 2.0 是默认值它会让置信度高的简单样本贡献更小如果发现训练 loss 下降很慢先检查是不是多数类被过度压权把gamma降到 1.0 试试。alpha一般取 0.25但微表情场景里如果最少的类别只占 5%这个值不够可以把各类样本占比的倒数归一化后作为alpha传入。注意y_pred是 softmax 输出而不是 logits直接算会引入数值问题实际实现里通常传入from_logitsTrue再配合tf.nn.softmax_cross_entropy_with_logits改写。4.3 四个高频训练问题与定位方向现象常见根源排查动作训练 loss 不降UAR 一直在 0.3 附近数据归一化不一致或标签错位检查三个输入分支的数值范围打印每个 batch 的均值方差验证集 UAR 远低于训练集模型过拟合加大 Dropout减少 GRU hidden units把分支卷积正则从 1e-4 调到 5e-4光流分支出现 NaN输入存在 Inf 或学习率过高检查导出光流时是否出现除零把学习率降到 1e-4 以下注意力权重长期接近 0对应分支没有有效梯度单独训该分支的分类头确认特征导出是否正确再考虑换特征这里面最容易被忽略的是第一个现象。微表情视频帧率不同CSV 标签顺序与 HDF5 文件名顺序不一致时训练 loss 会先降后震荡看起来像模型没调好实际上是标签错位。建议在from_generator里先输出样本名训练前跑一个批次的ds.take(1)人工核对文件名和标签。提示微表情识别里 UAR 能到 0.7 以上已经是不错的融合模型。看到论文里的 0.9 先确认数据划分方式按受试者独立划分比随机划分的结果通常低 10 到 15 个百分点。5. 别急着调参先做一次留一分支验证5.1 留一分支试验的开关设计多特征融合的代码很容易越改越复杂最后分不清哪一路特征真正起了作用。我习惯在训练脚本里加一个--branch参数让每次训练可以指定参与融合的分支这样能快速做留一分支leave-one-branch-out试验先全分支训练出一个基准 UAR再每次去掉一个分支重新训练。如果去掉光流分支后 UAR 从 0.70 掉到 0.58说明光流贡献很大如果掉到 0.69说明这个分支基本没用可以直接砍掉甚至还能省显存。# train.py import argparse parser argparse.ArgumentParser() parser.add_argument(--branch, actionappend, choices[flow, lbp, landmark], default[flow, lbp, landmark]) args parser.parse_args()在构建模型时根据args.branch决定创建哪些Input并在融合前过滤对应的特征向量。注意每次试验要保持训练轮数一致不能用早停后的最优 epoch 直接对比否则容易把随机波动当成特征差异。180 个训练样本下一个分支跑了 50 epoch 也就几分钟全组合四次试验完全能在半天内跑完这是性价比最高的模型诊断手段。5.2 验证时看两个更具体的信号第一把测试序列裁剪到 onset 到 apex 区间后重新评估。很多融合模型在完整片段上表现正常一旦只保留关键区间就大幅掉点说明模型实际依赖的是恢复期的多余运动而不是微表情本身的幅度变化。第二把第 3 章branch_att层的 softmax 权重在测试集上取平均三个分支的权重应该分布在 0.2 到 0.5 之间。如果某个分支权重长期低于 0.1优先检查特征导出阶段是否出了问题而不是急着换更大的注意力模块。留一分支试验做完后融合模型的收益边界也就清楚了。之后再决定要不要加跨模态注意力、把 GRU 换成 TCN、或者引入对比学习做帧级对齐就有明确参照物。本文还有配套的精品资源点击获取
返回列表