简介本资源面向微表情识别方向的研究人员与算法学习者提供一套基于自适应关键帧筛选的视频微表情检测完整实现方案可用于理解时序特征建模、关键帧选取与深度分类的工程落地路径。压缩包共18个文件约608KB以6个Python源码文件为核心辅以jpg与png实验图表、zbak备份文件及md说明文档覆盖模型、数据集与工具模块。算法通过光流变化度量帧间差异并设定自适应阈值定位关键表情帧结合局部二值模式与方向梯度直方图描述细微肌肉运动再由多层卷积网络完成特征提取与分类流程包含面部区域检测、特征点定位与运动单元分析。代码按数据预处理、模型训练与性能评估模块化组织参数可配置、便于扩展注释完整适合在此基础上复现实验或二次开发。目前已有44人学习下载。1. 自适应关键帧做微表情识别为什么固定抽帧总在关键瞬间掉链子微表情识别这件事真正难的不是分类网络选 ResNet 还是 ViT而是你根本不知道那一帧峰值表情出现在第几帧。我最早做微表情识别时用的是固定间隔抽帧200 帧的片段每 10 帧取一张结果模型在 CASME II 上 AUC 死活上不去。后来把原始帧序列逐帧看了一遍才发现微表情的 onset 到 apex 往往只有 610 帧固定步长抽帧大概率直接跳过 apex模型看到的全是中性脸或者刚起势的过渡帧。自适应关键帧要解决的就是这个问题让抽帧策略跟着表情强度走而不是跟着时间走。核心思路是先算一个逐帧的强度信号常见做法是光流幅值、帧间差分能量或面部动作单元激活度再在这个信号上做峰值检测和自适应分段把真正有信息量的帧挑出来送进识别网络。这套方案适合两类人一类是手里已经有微表情数据集、但识别精度卡在瓶颈的算法工程师另一类是想把微表情识别落到实际场景比如面试情绪分析、心理辅助评估的开发者。下面我会把算法实现和源码里几个关键模块拆开讲清楚包括我踩过的坑和参数怎么调。2. 自适应关键帧的强度信号怎么算从光流到 AU 激活2.1 为什么不能直接对原始帧做差分最朴素的想法是相邻帧做像素差取均值当强度。这个做法在光照稳定的实验室数据集上勉强能用但一换到自然场景就翻车——摄像头自动曝光、人脸轻微移动、背景抖动都会让差分信号出现大量伪峰。我试过在 SMIC 上直接用帧差检测到的关键帧有三分之一落在眨眼和头部微动上。更稳的做法是先做人脸对齐再在 ROI 区域算光流。微表情的能量集中在眉毛、眼角、嘴角这几个区域把全脸光流平均会稀释信号。我一般会按 AU 分区取 ROIAU1眉内侧、AU4眉下压、AU12嘴角上扬、AU15嘴角下压每个区域单独算光流幅值再按权重融合。2.2 光流强度信号的实现import cv2 import numpy as np # 定义微表情相关 AU 的 ROI基于 68 点人脸关键点归一化坐标 AU_ROI { AU1: (0.28, 0.10, 0.44, 0.22), # 左眉内侧 AU4: (0.30, 0.18, 0.42, 0.30), # 眉下压区 AU12: (0.35, 0.62, 0.48, 0.75), # 左嘴角 AU15: (0.52, 0.62, 0.65, 0.75), # 右嘴角 } AU_WEIGHT {AU1: 0.3, AU4: 0.3, AU12: 0.25, AU15: 0.15} def compute_intensity_signal(frames, roi_size(32, 32)): 输入对齐后的灰度帧序列输出逐帧强度信号 signals [] for i in range(1, len(frames)): prev frames[i - 1] curr frames[i] h, w prev.shape frame_energy 0.0 for au, (x1, y1, x2, y2) in AU_ROI.items(): # 裁剪 ROI 并缩放到统一尺寸 p cv2.resize(prev[int(y1*h):int(y2*h), int(x1*w):int(x2*w)], roi_size) c cv2.resize(curr[int(y1*h):int(y2*h), int(x1*w):int(x2*w)], roi_size) # Farneback 稠密光流 flow cv2.calcOpticalFlowFarneback( p, c, None, pyr_scale0.5, levels3, winsize15, iterations3, poly_n5, poly_sigma1.2, flags0 ) mag np.sqrt(flow[..., 0]**2 flow[..., 1]**2) frame_energy AU_WEIGHT[au] * np.mean(mag) signals.append(frame_energy) return np.array(signals)这段代码的逻辑是对每一对相邻帧在四个 AU 区域分别算 Farneback 稠密光流取幅值均值再按 AU 权重加权求和。pyr_scale0.5控制金字塔缩放winsize15是光流窗口大小——这两个参数直接影响信号平滑度。窗口太小比如 5信号噪声大太大比如 25会漏掉快速微表情。levels3表示三层金字塔对微表情这种小位移场景够用。提示如果你的数据没有做严格人脸对齐建议先用 dlib 或 MediaPipe 做 68 点对齐再送进来否则 ROI 会漂移信号质量断崖式下降。2.3 信号后处理平滑与归一化原始强度信号会有毛刺直接做峰值检测会误检。我一般用 Savitzky-Golay 滤波做平滑窗口长度取 57 帧微表情持续时间的经验值多项式阶数取 2。平滑后再做 min-max 归一化把信号压到 [0,1]方便后续统一设阈值。from scipy.signal import savgol_filter def smooth_and_normalize(signal, win5, order2): smoothed savgol_filter(signal, window_lengthwin, polyorderorder) smoothed np.clip(smoothed, 0, None) # 去掉负值 if smoothed.max() 0: smoothed smoothed / smoothed.max() return smoothedwin5是我在 CASME II 上试出来的平衡点再小去不掉噪声再大 apex 会被削平。如果你的帧率高于 200fps窗口可以适当放大到 79。3. 自适应关键帧的选取策略峰值检测与动态分段3.1 峰值检测的三个必调参数拿到平滑后的强度信号下一步是找 apex 帧。最直接的方法是用scipy.signal.find_peaks但默认参数在微表情上基本不可用。我一般会设三个约束参数含义推荐值调参方向height峰值最低高度0.3数据噪声大就调高distance相邻峰最小间隔4 帧帧率高就调大prominence峰值突出度0.15区分主峰和次峰from scipy.signal import find_peaks def detect_apex(signal, height0.3, distance4, prominence0.15): peaks, props find_peaks( signal, heightheight, distancedistance, prominenceprominence ) return peaks, propsheight0.3意味着归一化后强度低于 0.3 的波动不算微表情——这个值不能设太低否则会把说话时的嘴部动作误检成微表情。distance4是防止同一个微表情被检出多个峰因为一个微表情的 apex 通常只持续 23 帧。prominence用来过滤那些虽然高但周围也很高的平台峰微表情应该是尖峰而不是宽峰。3.2 动态分段以 apex 为中心取关键帧找到 apex 之后关键帧的选取不是只取 apex 那一帧而是以 apex 为中心向前后各扩展若干帧覆盖 onset 和 offset。我一般取 apex 前 4 帧、后 2 帧共 7 帧作为一个微表情片段的关键帧集合。这个不对称是有原因的onset 阶段包含更多动态信息offset 阶段往往已经接近中性脸。def extract_keyframes(signal, peaks, pre4, post2): keyframe_indices [] for p in peaks: start max(0, p - pre) end min(len(signal) - 1, p post) keyframe_indices.append(list(range(start, end 1))) return keyframe_indices如果两个 apex 距离太近导致关键帧区间重叠我会做合并处理取并集然后按信号强度排序保留前 N 帧。N 一般取 16因为后续识别网络输入通常是 16 帧的 clip。3.3 自适应阈值的引入固定阈值在不同数据集上表现差异很大。CASME II 的微表情强度普遍偏低height0.3 可能漏检而 SAMM 的强度高0.3 又会引入大量误检。我的做法是用信号自身的统计量做自适应def adaptive_threshold(signal, k1.5): mu np.mean(signal) sigma np.std(signal) return mu k * sigmak1.5是经验值意思是峰值要超过均值 1.5 倍标准差才算显著。这个自适应阈值比固定阈值鲁棒得多换数据集时基本不用重调。如果某个片段完全没有超过阈值的峰说明这段可能不含微表情直接跳过这也顺便做了微表情检测。4. 识别网络怎么接从关键帧序列到分类输出4.1 输入构造光流堆叠 vs 帧堆叠关键帧提取完之后送进识别网络有两种常见方式一种是直接把 RGB 帧堆叠成 (T, H, W, 3) 的张量另一种是算关键帧之间的光流堆叠成 (T-1, H, W, 2)。微表情识别里光流堆叠通常比 RGB 堆叠高 35 个点因为微表情的判别信息主要在运动模式而不是纹理上。def build_flow_input(keyframes, size(64, 64)): 把关键帧序列转成光流堆叠张量 flows [] for i in range(1, len(keyframes)): prev cv2.resize(keyframes[i-1], size) curr cv2.resize(keyframes[i], size) flow cv2.calcOpticalFlowFarneback( prev, curr, None, 0.5, 3, 15, 3, 5, 1.2, 0 ) flows.append(flow) return np.stack(flows, axis0) # shape: (T-1, H, W, 2)这里 ROI 裁剪和 resize 到 64x64 是权衡太小丢失细节太大显存吃不消。64x64 在 CASME II 上是我试过的性价比最高的尺寸。4.2 网络结构选型微表情数据量小CASME II 只有 247 个样本直接上 3D ResNet 必然过拟合。我一般用轻量 3D CNN 时序注意力的结构前端 4 个 3D 卷积块提取时空特征中间接一个时序注意力模块给不同关键帧加权最后全局池化 全连接分类。import torch import torch.nn as nn class MicroExprNet(nn.Module): def __init__(self, num_class5): super().__init__() self.conv1 nn.Conv3d(2, 32, kernel_size3, padding1) self.conv2 nn.Conv3d(32, 64, kernel_size3, padding1) self.conv3 nn.Conv3d(64, 128, kernel_size3, padding1) self.pool nn.MaxPool3d(2) self.attn nn.Sequential( nn.AdaptiveAvgPool3d(1), nn.Conv3d(128, 16, 1), nn.ReLU(), nn.Conv3d(16, 128, 1), nn.Sigmoid() ) self.fc nn.Linear(128, num_class) def forward(self, x): # x: (B, T, H, W, 2) - (B, 2, T, H, W) x x.permute(0, 4, 1, 2, 3) x self.pool(torch.relu(self.conv1(x))) x self.pool(torch.relu(self.conv2(x))) x torch.relu(self.conv3(x)) w self.attn(x) x (x * w).mean(dim[2, 3, 4]) return self.fc(x)时序注意力模块的作用是让网络自己学哪些关键帧更重要。实际训练下来注意力权重最高的往往就是 apex 附近那几帧说明自适应关键帧的选取和网络学习是相互印证的。4.3 训练参数与数据增强微表情数据集小数据增强是必须的。我一般用水平翻转、随机裁剪幅度不超过 5 像素、时间抖动关键帧索引 ±1。学习率用 1e-3 配余弦退火batch size 设 8再大显存不够训练 100 个 epoch 左右。损失函数用带 label smoothing 的交叉熵smoothing 系数 0.1能缓解小样本过拟合。注意时间抖动不要超过 ±1 帧否则会破坏 onset-apex-offset 的时序结构反而掉点。5. 源码解析时最容易踩的坑从数据对齐到评估指标5.1 坑一关键帧索引和原始帧对不上现象提取的关键帧可视化出来apex 明显偏移了几帧。原因数据预处理时做了帧率统一比如从 200fps 降到 100fps但关键帧索引是在降帧后的序列上算的可视化时又映射回原始帧索引没做换算。解决在整条流水线里统一用一个帧率基准所有索引都基于这个基准。如果必须降帧在降帧后的序列上做所有操作最后可视化时用index * (orig_fps / target_fps)换算。5.2 坑二光流信号被头部运动污染现象某些样本的强度信号在整段上都很高峰值检测失效。原因被试在录制过程中有头部移动光流把头部运动也算进去了。解决先做人脸对齐用关键点做仿射变换把眼睛和鼻尖对齐到标准位置再算光流。对齐后头部运动的影响能降低 70% 以上。如果还有残留可以在 ROI 外围加一个背景区域用背景光流做减除。5.3 坑三自适应阈值在短序列上不稳定现象有些片段只有 30 帧自适应阈值算出来偏高或偏低导致漏检或误检。原因均值和标准差在短序列上估计不准。解决对短于 50 帧的序列改用固定阈值兜底或者用整个数据集的全局统计量做阈值。我一般会设一个最小长度判断低于阈值就走固定分支。5.4 坑四评估时用了帧级准确率现象模型报告 95% 准确率但实际用起来一塌糊涂。原因微表情数据集中性帧占绝大多数帧级准确率被中性帧刷高了。解决用样本级准确率 宏平均 F1。每个微表情片段算一个样本预测类别取该片段所有关键帧预测的多数投票。这样评估才反映真实性能。另外 LOSOleave-one-subject-out交叉验证是必须的随机划分会高估 10 个点以上。5.5 坑五关键帧数量不固定导致 batch 组装失败现象DataLoader 报错说 tensor 尺寸不一致。原因不同样本提取的关键帧数量不同有的 5 帧有的 9 帧直接 stack 会失败。解决在 Dataset 的__getitem__里做统一采样——多于目标帧数的做等间隔下采样少于的做重复填充。目标帧数我一般设 8 或 16根据网络输入要求定。6. 把自适应关键帧用到新数据集上的检查清单换一个新数据集时我不会直接跑端到端而是按下面这个顺序逐项验证。这套流程帮我在三个不同数据集上把调试时间从一周压到一天。检查项验证方法通过标准人脸对齐质量可视化 68 点关键点贴合五官无明显漂移强度信号合理性叠加信号曲线和原始帧峰值位置肉眼可见对应表情 apex峰值检测召回人工标注 20 个 apex 对比召回率 85%关键帧覆盖检查 onset/apex/offset 是否都在三段都有帧被选中类别平衡统计各类样本数最多类/最少类 3如果峰值检测召回率低于 85%先别动网络回头调height和prominence。我遇到过一次召回率只有 60%查了半天发现是光流窗口设成了 25把快速微表情平滑掉了改回 15 就正常了。最后一个习惯每次改完关键帧提取参数先把提取结果可视化一遍再训练。我早期图省事跳过这步结果训练 loss 正常下降但验证集不涨回头一看关键帧全提取到眨眼上了。这个可视化脚本我建议你写成固定的 debug 工具输入视频输出带标注框的关键帧序列每次调参后跑一遍比看 loss 曲线直观得多。希望帮到你。本文还有配套的精品资源点击获取