简介:这份资源面向情感计算与计算机视觉方向的研究者、学生及开发者,提供一套基于自适应关键帧的视频微表情识别算法完整实现,用于解决微表情持续时间短、识别难度大、计算开销高等问题。压缩包共14个文件,约404KB,以6个Python源码文件为核心,涵盖主流程、工具函数、数据集加载与图像变换等模块,另含5张jpg与2张png实验图表及1份md说明文档,便于快速理解算法结构与实验结论。目前已有271人学习下载。项目围绕视频预处理、关键帧检测、LBP与DoG特征提取、SVM或CNN分类及模型训练优化等环节展开,源码可直接运行调试,帮助读者掌握从数据到识别的完整链路,并可作为智能客服、情绪分析、安全监控等场景的二次开发参考。
1. 微表情识别遇上自适应关键帧:一段 200 帧的短视频,为什么大部分帧其实可以扔掉
微表情识别这件事,真正上手做过的人都知道,难点从来不在分类网络本身。你拿 FER 或者 CASME II 这类数据集,套一个 ResNet 或者 3D-CNN,训练脚本跑通并不难。难的是真实视频里,一段 10 秒、30fps 的素材有 300 帧,而真正承载微表情信息的,往往只有嘴角抽动、眉头轻蹙的那 5 到 8 帧。剩下 290 多帧是中性表情、是说话动作、是头部微动,它们不但不提供信息,还会把分类器的注意力稀释掉。
这就是「自适应关键帧」要解决的问题:不靠人工标注起始帧和顶点帧,而是让算法自己判断哪些帧值得留下。标题里说的「基于自适应关键帧的视频中微表情识别算法实现」,本质是一条两段式流水线——先用关键帧选择把长视频压成稀疏的、信息密度最高的帧序列,再把这个序列喂给识别网络。它适合两类人:一类是手里有视频数据、想跑通端到端微表情识别的工程同学;另一类是已经做过静态表情识别,想往时序方向迁移、但被「帧太多、信噪比太低」卡住的人。
我见过太多项目把关键帧选择写成「每隔 N 帧抽一帧」,那不叫自适应,那叫均匀采样,遇到微表情只持续 0.2 秒的素材直接漏掉。真正的自适应,得让帧的重要性由数据自己说话。下面这套方案,我会把关键帧打分、帧序列构造、识别网络训练和踩坑记录都摊开讲,源码结构也按能直接复现的方式组织。
2. 自适应关键帧到底「自适应」在哪:从光流能量到注意力打分的选型逻辑
2.1 微表情的时序特性决定了不能均匀采样
微表情的持续时间通常在 40ms 到 200ms 之间,按 30fps 算就是 1 到 6 帧。它的运动幅度极小,面部肌肉位移往往只有几个像素,普通光流法在这么小的位移上信噪比很差。更麻烦的是,微表情的起始帧(onset)和顶点帧(apex)之间没有明显边界,你很难用固定阈值切出来。
均匀采样的问题在于:如果采样间隔是 5 帧,一个持续 3 帧的微表情有大概率被整个跳过;如果间隔是 1 帧,那等于没压缩,300 帧全进网络,显存和计算量都吃不消。所以关键帧选择必须满足两个条件——第一,打分函数对微小运动敏感;第二,选出的帧在时间上要覆盖微表情的完整起落过程,而不是只挑运动最大的那一帧。
常见做法是三条路线:基于光流能量的、基于帧间差分统计的、基于注意力权重的。光流能量法对大幅度表情好用,对微表情容易淹没在噪声里;帧间差分统计计算便宜,但受光照和头部运动干扰大;注意力权重法需要先有一个预训练网络,属于「用模型选帧」,效果上限高但依赖预训练质量。我一般会先用帧间差分做粗筛,再用一个轻量注意力打分网络做精排,兼顾速度和精度。
2.2 关键帧打分网络的结构与输入构造
打分网络不直接输出「是不是关键帧」,而是给每一帧输出一个 0 到 1 的重要性分数。输入不是单帧图像,而是以当前帧为中心的短时窗口,比如前后各 2 帧共 5 帧,这样网络能看到局部时序上下文。结构上用一个共享的轻量 CNN 提每帧特征,再在时间维度上做一维卷积或者 GRU,最后接 sigmoid 输出分数。
下面是我常用的打分网络定义,输入张量形状是(batch, window=5, channel=1, H=64, W=64),灰度图足够,微表情识别里颜色信息贡献有限,还能省一半计算。
import torch import torch.nn as nn class FrameScorer(nn.Module): def __init__(self, window=5): super().__init__() self.window = window # 共享 CNN:对窗口内每一帧独立提特征 self.cnn = nn.Sequential( nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 64->32 nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 32->16 nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool2d(1) ) # 时间维建模:输入 (B, window, 64) self.temporal = nn.GRU(64, 32, batch_first=True) self.head = nn.Linear(32, 1) def forward(self, x): # x: (B, window, 1, 64, 64) B, W, C, H, Wd = x.shape x = x.view(B * W, C, H, Wd) feat = self.cnn(x).view(B, W, -1) # (B, window, 64) out, _ = self.temporal(feat) # (B, window, 32) score = torch.sigmoid(self.head(out)) # (B, window, 1) return score.squeeze(-1) # (B, window)逻辑说明:CNN 部分对窗口内每帧共享权重,保证打分只看局部外观;GRU 在时间维上聚合,让当前帧的分数能参考前后帧的运动趋势。参数上,window=5是经验值,微表情持续 1 到 6 帧,窗口太小看不到起落,太大引入无关帧;输入分辨率 64×64 是精度和速度的折中,再低会丢失嘴角和眼角的细微变化。输出是每帧一个分数,训练时用顶点帧标注作为监督信号,让顶点帧分数趋近 1、中性帧趋近 0。
2.3 用打分结果做非极大值抑制式选帧
拿到每帧分数后,不能简单取 top-k,因为微表情的顶点帧附近几帧分数都会偏高,全选进来等于没压缩。我一般用一维非极大值抑制:先按分数排序,每次选当前最高分帧,然后把它的时间邻域(比如前后 3 帧)内的帧分数置零,重复直到选够 N 帧或者分数低于阈值。
import numpy as np def select_keyframes(scores, min_gap=3, max_frames=16, thresh=0.3): scores = np.array(scores) selected = [] work = scores.copy() while len(selected) < max_frames: idx = int(np.argmax(work)) if work[idx] < thresh: break selected.append(idx) # 抑制时间邻域 lo = max(0, idx - min_gap) hi = min(len(work), idx + min_gap + 1) work[lo:hi] = 0 return sorted(selected)逻辑说明:min_gap=3控制选帧的最小时间间隔,对应约 100ms,避免选出几乎重复的帧;max_frames=16是上限,一段 300 帧视频压到 16 帧,压缩比接近 20 倍;thresh=0.3是分数下限,低于它的帧即使没选够也不选,防止把纯中性帧硬塞进来。返回的索引保持时间顺序,方便后续按序构造序列。这套选帧逻辑是整个方案里最影响最终指标的一环,参数要根据你的帧率和微表情持续时间调,不能照搬。
3. 从视频到训练样本:把 CASME II 这类数据集整理成可训练的帧序列
3.1 数据集目录结构与标注解析
公开微表情数据集常见的是按被试和样本组织的文件夹,每个样本一个视频或帧序列,标注文件里记录起始帧、顶点帧、结束帧和表情类别。以 CASME II 风格的组织为例,目录大致是这样:
| 路径 | 内容 | 说明 |
|---|---|---|
data/sub01/EP02_01f/ | 单个样本的帧图 | 命名含帧号 |
data/sub01/EP02_01f/*.jpg | 灰度或彩色帧 | 分辨率不一 |
label.xlsx或label.csv | 标注表 | 含 onset/apex/offset |
data/sub01/ | 被试目录 | 用于划分训练测试 |
标注解析的关键是把 onset、apex、offset 三个帧号读出来,apex 帧作为打分网络的正样本监督,onset 到 offset 之间的帧作为候选关键帧区间。如果标注只有类别没有帧号,那就只能退化成弱监督,用类别标签反推,效果会打折扣,这也是很多人复现指标上不去的原因。
import pandas as pd import os def load_labels(label_path): df = pd.read_excel(label_path) if label_path.endswith('xlsx') else pd.read_csv(label_path) # 统一列名,不同数据集列名不一致,这里做映射 df = df.rename(columns={ 'OnsetFrame': 'onset', 'ApexFrame': 'apex', 'OffsetFrame': 'offset', 'Estimated Emotion': 'emotion', 'Subject': 'subject' }) df = df[['subject', 'filename', 'onset', 'apex', 'offset', 'emotion']].dropna() return df逻辑说明:列名映射是必须的,不同数据集字段命名差异很大,硬编码列名换一个数据集就崩。dropna去掉标注不全的样本,微表情数据集里标注缺失很常见,留着会污染训练。解析出来的 onset/apex/offset 后面既用于打分监督,也用于评估选帧是否覆盖了真实微表情区间。
3.2 帧序列构造与对齐
选出的关键帧索引是变长的,而识别网络需要固定长度输入。常见做法是选固定数量 N 帧,不足的用相邻帧重复填充,超出的截断。填充时不要用零帧,零帧会引入虚假的强边缘,用最近的关键帧复制更安全。
import cv2 import numpy as np def build_sequence(frame_dir, key_idx, seq_len=16, size=64): files = sorted(os.listdir(frame_dir)) frames = [] for i in key_idx: img = cv2.imread(os.path.join(frame_dir, files[i]), cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (size, size)) frames.append(img) # 变长对齐到 seq_len if len(frames) < seq_len: pad = [frames[-1]] * (seq_len - len(frames)) frames = frames + pad else: frames = frames[:seq_len] seq = np.stack(frames, axis=0).astype(np.float32) / 255.0 return seq # (seq_len, size, size)逻辑说明:seq_len=16和选帧上限对应,保证一一对应不截断;灰度读取省内存,微表情识别里灰度够用;归一化到 0 到 1 是常规操作,但要注意如果你的数据集整体偏暗,可以改成按样本减均值除标准差,对微表情这种低对比度场景更稳。填充用最后一帧复制而不是零,是因为零帧在卷积里会产生强响应,干扰后续特征。
3.3 训练集与测试集的被试独立划分
微表情数据集样本量小,通常只有几百个样本,如果按样本随机划分,同一个人的相似表情会同时出现在训练和测试里,指标虚高。正确做法是按被试划分,留出若干被试完全不参与训练。这一点在论文复现里是硬要求,自己搭项目时也建议照做,否则你线上部署时会发现泛化能力断崖式下跌。
def split_by_subject(df, test_subjects): test_mask = df['subject'].isin(test_subjects) train_df = df[~test_mask].reset_index(drop=True) test_df = df[test_mask].reset_index(drop=True) return train_df, test_df逻辑说明:test_subjects一般取总被试数的 20% 到 30%,被试数太少时(比如少于 10 人)可以改用留一被试交叉验证。划分完要检查两个集合的类别分布,微表情数据集类别本身就不平衡,划分后可能某一类在测试集里一个都没有,那就得调整被试选择。
4. 识别网络怎么接:把稀疏关键帧序列喂进时序分类器
4.1 时序 backbone 的选择:3D-CNN 还是 CNN+GRU
关键帧序列是稀疏的,帧与帧之间时间间隔不均匀,这是和普通视频理解最大的区别。3D-CNN 默认输入是连续均匀帧,直接套用会把不均匀间隔当成均匀处理,丢失时间尺度信息。相比之下,CNN 逐帧提特征再加 GRU 或 Transformer 做时序聚合,对不均匀间隔更友好,因为时间建模和空间建模解耦了。
我的选择是 CNN+GRU 作为基线,原因是参数量小、在小数据集上不容易过拟合,而且可以在 GRU 前把每帧的时间戳作为额外特征拼进去,让网络知道帧之间的真实间隔。如果你的数据量足够大,可以换成 TimeSformer 这类结构,但在几百个样本的微表情数据集上,Transformer 通常训不动。
class MERNet(nn.Module): def __init__(self, seq_len=16, num_class=5): super().__init__() self.cnn = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.AdaptiveAvgPool2d(1) ) self.gru = nn.GRU(128, 64, batch_first=True, bidirectional=True) self.fc = nn.Linear(128, num_class) def forward(self, x): # x: (B, seq_len, 1, 64, 64) B, T, C, H, W = x.shape x = x.view(B * T, C, H, W) feat = self.cnn(x).view(B, T, -1) # (B, T, 128) out, _ = self.gru(feat) # (B, T, 128) out = out.mean(dim=1) # 时间平均池化 return self.fc(out)逻辑说明:CNN 部分三层卷积,通道 32 到 128,配合 BatchNorm 稳定小数据集训练;GRU 用双向,因为微表情的起落是对称的,双向能看到完整上下文;最后时间维平均池化而不是取最后一帧,是因为关键帧顺序里顶点帧不一定在末尾。num_class按你的数据集类别数改,CASME II 常见是 5 类。这个网络在 16 帧输入下显存占用很小,单卡 8G 足够。
4.2 训练参数与类别不平衡处理
微表情数据集类别极不平衡,某一类可能只有十几个样本。直接交叉熵会让网络偏向多数类。常见做法是加权交叉熵,权重取类别频率的倒数,再配合标签平滑防止过拟合。
from collections import Counter def train_one_epoch(model, loader, optimizer, device, class_weights): model.train() total_loss = 0 ce = nn.CrossEntropyLoss(weight=class_weights, label_smoothing=0.1) for seq, label in loader: seq, label = seq.to(device), label.to(device) optimizer.zero_grad() logits = model(seq) loss = ce(logits, label) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader) # 类别权重计算 labels = train_df['emotion'].tolist() cnt = Counter(labels) weights = torch.tensor([1.0 / cnt[c] for c in sorted(cnt)], dtype=torch.float32) weights = weights / weights.sum() * len(cnt) # 归一化,避免整体学习率被放大逻辑说明:label_smoothing=0.1在小数据集上是有效的正则,能缓解网络对训练样本的过度自信;类别权重归一化很重要,不归一化的话权重绝对值可能很大,等效于放大了学习率,训练容易发散。学习率建议 1e-3 起步,配合余弦退火,batch size 取 8 或 16,微表情数据集样本少,batch 太大一个 epoch 只有几步,梯度噪声太小反而不好。
4.3 评估指标不能只看准确率
微表情识别里准确率是最容易骗人的指标。如果某一类占了一半样本,全预测成那一类也有 50% 准确率。必须看宏平均 F1 和混淆矩阵,尤其是少数类的召回率。另外,关键帧选择本身也要评估——选出的帧有没有覆盖真实的 onset 到 offset 区间,可以用覆盖率这个指标。
| 指标 | 含义 | 关注点 |
|---|---|---|
| 宏平均 F1 | 各类 F1 的平均 | 类别不平衡下的真实性能 |
| 加权 F1 | 按样本数加权 | 和准确率接近,参考用 |
| 关键帧覆盖率 | 选中帧落在真实区间的比例 | 选帧是否漏掉微表情 |
| 压缩比 | 原帧数 / 选中帧数 | 效率与精度的权衡 |
评估时建议固定随机种子跑 3 次取平均,微表情数据集小,单次结果波动可能有好几个百分点,只看一次容易得出错误结论。
5. 避坑与排查:自适应关键帧方案里最容易翻车的 5 个地方
5.1 选帧全挤在顶点帧附近,起落过程丢失
现象:可视化选出的关键帧,发现 16 帧里有 10 帧都集中在顶点帧前后,onset 和 offset 几乎没选到,识别指标上不去。
原因:打分网络用顶点帧做正样本监督,训练后顶点帧分数天然最高,非极大值抑制的min_gap又设得太小,导致顶点邻域反复被选中。
解决:把min_gap从 3 调到 5 到 7,并在训练打分网络时,不只把顶点帧标为正样本,把 onset 到 offset 之间的帧也标为中等分数(比如 0.6),让网络学会给整个微表情区间打分,而不是只盯顶点。
5.2 帧间差分对光照变化过敏,选出一堆无关帧
现象:视频里有人开关灯或者镜头自动曝光调整,选出的关键帧全是亮度突变的帧,微表情帧反而没选上。
原因:帧间差分统计的是像素绝对差,光照变化引起的全局像素变化远大于微表情的局部肌肉位移。
解决:差分前先做光照归一化,常见做法是直方图均衡化或者按帧减均值除标准差;更稳的是改用光流,光流对全局亮度变化不敏感,只关注运动。如果计算资源允许,直接上光流能量打分。
5.3 序列填充用零帧导致分类器学到虚假边缘
现象:训练 loss 下降正常,但测试时对短序列样本预测全错,混淆矩阵里某一类几乎全被误判。
原因:变长序列对齐时用零帧填充,零帧和真实帧之间有极强边缘,CNN 把这个边缘当成了判别特征。
解决:填充改用最近关键帧复制,或者用序列均值帧填充。更好的做法是用掩码,让 GRU 忽略填充位置,PyTorch 的pack_padded_sequence就是干这个的,但要注意它要求按长度排序,用之前先排好序再还原。
5.4 按样本随机划分导致指标虚高
现象:本地测试宏平均 F1 有 0.8 以上,换一批新被试的视频直接掉到 0.4。
原因:同一个人的不同样本被分到了训练和测试,网络记住了这个人的面部特征,而不是微表情本身的模式。
解决:严格按被试划分,测试被试完全不参与训练。如果被试数太少,用留一被试交叉验证,虽然训练次数多,但指标可信。这一点没有捷径,微表情识别的泛化瓶颈就在跨被试。
5.5 打分网络和识别网络联合训练时梯度打架
现象:把打分网络和识别网络端到端联合训练,两个 loss 一起反传,结果打分网络退化成输出常数,选帧变成均匀采样。
原因:识别 loss 对打分网络的梯度信号很弱,因为选帧是离散操作(argmax、NMS),不可导,梯度传不回去,打分网络实际上没被识别 loss 有效监督。
解决:要么两阶段训练,先单独训打分网络,固定后再训识别网络;要么用 Gumbel-Softmax 或者软注意力把选帧变成可微的。工程上我推荐两阶段,简单可控,联合训练调参成本太高,收益不明显。
6. 让选帧真正自适应的一个技巧:用识别反馈回炉打分网络
两阶段训练跑通之后,很多人就停在这里了。但你会发现一个现象:打分网络是独立训练的,它认为重要的帧,未必是识别网络真正需要的帧。比如打分网络可能偏爱运动幅度大的帧,但识别网络可能更依赖嘴角形状而不是运动幅度。这个错位,就是指标卡在天花板的原因。
我的做法是加一轮「识别反馈回炉」。具体来说,第一轮用初始打分网络选帧,训练识别网络,然后在验证集上统计每个被选帧对最终分类的贡献——最简单的方式是做帧遮挡实验:把某一帧替换成均值帧,看分类置信度掉多少,掉得多的帧就是识别网络真正依赖的帧。把这些帧的分数调高,重新训练打分网络,再跑一轮选帧和识别。
def frame_importance(model, seq, label, device): """逐帧遮挡,衡量每帧对分类的贡献""" model.eval() seq = seq.to(device) with torch.no_grad(): base_logit = model(seq.unsqueeze(0)) base_prob = torch.softmax(base_logit, dim=1)[0, label].item() importance = [] mean_frame = seq.mean(dim=0, keepdim=True) for t in range(seq.shape[0]): perturbed = seq.clone() perturbed[t] = mean_frame with torch.no_grad(): logit = model(perturbed.unsqueeze(0)) prob = torch.softmax(logit, dim=1)[0, label].item() importance.append(base_prob - prob) # 掉得越多越重要 return importance逻辑说明:mean_frame用序列均值帧做遮挡,比零帧温和,不会引入强边缘;base_prob - prob是置信度下降量,正值表示该帧对正确分类有贡献,负值表示该帧可能是干扰。拿到 importance 后,把它和原打分网络的输出做加权融合,再重新训练打分网络。这个技巧相当于用识别网络当裁判,告诉打分网络「你选错了」。
参数上,融合权重建议识别反馈占 0.3 到 0.5,太高会让打分网络过拟合到当前识别网络,失去泛化。回炉轮数一到两轮就够,再多收益递减,而且容易过拟合验证集。这个技巧我在几个小数据集上试过,宏平均 F1 通常能涨 2 到 4 个百分点,代价是多花一轮训练时间,值不值取决于你对指标的容忍度。
最后说个血泪经验:微表情识别这个方向,数据质量比模型结构重要得多。我见过有人花两周调网络结构,指标纹丝不动,后来发现是数据集里有一批样本的 onset 标注整体偏移了十几帧,修正标注后指标直接涨了 8 个点。所以在你怀疑模型之前,先把标注和选帧结果可视化出来看一遍,很多时候问题不在算法,在数据。希望帮到你。
本文还有配套的精品资源,点击获取