ARTICLE DETAIL

资讯详情

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

双流浅层网络实现微表情识别:PyTorch实战与源码解析

双流浅层网络实现微表情识别:PyTorch实战与源码解析 简介面向计算机视觉与深度学习开发者这份微表情识别项目提供了基于双流浅层网络的完整算法实现。算法通过空间流与时间流并行学习面部静态纹理和瞬时动态变化浅层设计降低了计算开销尤其适配实时监控、人机交互等场景。压缩包共10个文件、仅1.22MB含7个Python脚本utils、preprocess、dataloader、network、train等覆盖从数据读取、预处理到模型训练、图像保存的完整流程另有预训练权重partial.pt、requirements.txt依赖清单和README.md说明文档。项目代码结构规范便于复现实验同时可针对真实数据进行二次开发是理解双流网络结构与微表情识别原理的优质实战资料。目前已有139人学习下载适合作为算法研究或课程设计的起点。1. 微表情识别靠双流浅层网络做单一模型这套源码方案到底解决什么问题如果你做过表情识别会发现一个奇怪现象普通表情的识别准确率已经能到 90% 以上但一旦换成微表情同一个模型往往直接垮掉。原因不在分类头而在微表情本身——持续时间只有 1/25 秒到 1/5 秒动作幅度极小而且公开数据集全部加起来也就几百个样本。微表情识别领域的从业者普遍认为靠单帧静态图像做微表情基本走不通必须让网络同时看到“长什么样”和“怎么动的”。双流浅层网络正是为这个约束设计的一个分支吃静态外观另一个分支吃光流运动再用浅层网络结构控制参数量防止过拟合。这套带源码的实战项目把数据处理、双流搭建、训练评估串成了一条完整链路适合正在做深度学习实战项目案例、想快速复现微表情识别 baseline 的工程师。接下来我按自己能直接跑通的方式把这条链路拆开讲。2. 双流浅层网络结构拆解空间流和时间流各管什么PyTorch 怎么搭2.1 为什么是“浅层”微表情的数据规模和纹理强度决定网络深度很多人第一次看到“浅层网络”会下意识觉得是不是精度不够。但在微表情这个任务上浅层不是妥协是主动选择。先看数据规模CASME2 官方标注样本约 250 段左右SAMM 一百多段SMIC 三百多段合在一起做跨数据集训练也就几百个样本。这种规模下ResNet50 这种深层网络几乎必过拟合训练集能到 99%测试集 L 失一塌糊涂。再看微表情本身的特性微表情的强度低纹理细节非常细腻深层网络的下采样会把这些高频纹理直接抹掉。反观浅层网络卷积层数少、特征图分辨率保持得好反而能留下更多可判别的细节。我一般把“浅层”理解为一个具体范围单分支 4 到 6 层卷积。这个深度既能拟合微表情的小样本分布又能保留足够的时空特征。更深当然可以试但要在每个分支后面加很强的 dropout 和正则否则就是拿测试集分数做赌注。另一个容易忽略的点双流结构本身就是一种正则。空间流和时间流学到的是两种不同分布的特征融合后分类器看到的表征更多样单分支的过拟合风险会被稀释。所以这个项目叫“双流浅层网络”本质是用结构换泛化而不是用深度换精度。2.2 双流分支定义一个可复跑的 PyTorch 实现核心结构很清晰两个分支共享相同的卷积骨干但输入不同。空间流输入是灰度图或 RGB 人脸图时间流输入是光流图。这里给出一个能直接运行的分支定义import torch import torch.nn as nn class StreamBranch(nn.Module): 单分支浅层卷积网络空间流和时间流共用这个结构。 输入大小为 (B, C, H, W)输出 (B, 128) 特征向量。 def __init__(self, in_channels, base_channels32): super().__init__() self.features nn.Sequential( # 第一层保留分辨率stride1 nn.Conv2d(in_channels, base_channels, kernel_size3, stride1, padding1), nn.BatchNorm2d(base_channels), nn.ReLU(inplaceTrue), # 第二层开始下采样 nn.Conv2d(base_channels, base_channels * 2, kernel_size3, stride2, padding1), nn.BatchNorm2d(base_channels * 2), nn.ReLU(inplaceTrue), # 第三层 nn.Conv2d(base_channels * 2, base_channels * 4, kernel_size3, stride2, padding1), nn.BatchNorm2d(base_channels * 4), nn.ReLU(inplaceTrue), # 第四层 nn.Conv2d(base_channels * 4, base_channels * 4, kernel_size3, stride2, padding1), nn.BatchNorm2d(base_channels * 4), nn.ReLU(inplaceTrue), ) self.global_pool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(base_channels * 4, 128) def forward(self, x): x self.features(x) x self.global_pool(x) x torch.flatten(x, 1) x self.fc(x) return x这个分支设计有三个关键参数in_channels、base_channels和卷积层的 stride。in_channels对空间流是 1灰度图或 3RGB对时间流是 2水平光流 U 垂直光流 V。base_channels控制参数量从 32 起步四层卷积下来最后一层是 128 通道。这里刻意没有用第 5 层卷积因为输入分辨率通常是 112×112 或 128×128四层 stride2 后特征图缩小到 7×7 左右再往下卷就剩 3×3信息太稀疏。stride 的选择也需要注意第一层保持 stride1让网络先在原分辨率下提取纹理从第二层才开始压缩。头几层卷积的纹理提取能力直接决定了微表情的局部微小动作能不能被抓住。2.3 融合层与分类头两个特征向量如何变成一个预测结果有了两个分支剩下来自两个 128 维向量的融合。常见做法有三种直接拼接、逐元素相加、注意力加权。对小样本数据我一般直接用拼接不做过多的融合门控。原因很简单相加强行让两个特征对齐到一个语义空间但空间流特征和时间流特征的分布差异较大相加等于假设它们在每个维度上都是一一对应的——这个假设对微表情并不成立。注意力融合确实更强但需要额外的训练数据来稳定注意力权重数据量不够时很容易变成玄学。拼接是最稳的 baseline代码只有三行class DualStream(nn.Module): def __init__(self, num_classes, in_channels_spatial1, in_channels_temporal2): super().__init__() self.spatial_stream StreamBranch(in_channels_spatial) self.temporal_stream StreamBranch(in_channels_temporal) self.classifier nn.Sequential( nn.Dropout(0.4), nn.Linear(128 * 2, num_classes), ) def forward(self, x_spatial, x_temporal): feat_s self.spatial_stream(x_spatial) feat_t self.temporal_stream(x_temporal) feat torch.cat([feat_s, feat_t], dim1) return self.classifier(feat)torch.cat之后是 256 维接一个 Dropout 再接nn.Linear到类别数。注意 Dropout 我放在分类头前面而不是分支内部。分支内的每层卷积后面已经有 BN 在起正则作用再叠加 dropout 会削弱浅层网络的特征表达能力尤其是空间流分支容易变成什么都不敏感。微表情的类别数通常不多四分类惊讶、厌恶、压抑、其他或三分类积极、消极、惊讶是主流做法。num_classes 对应你的标签方案即可。整个双流模型参数量大概在 200 万到 300 万之间显存占用很低一张 1080Ti 或者 RTX 3060 就能把 batch size 跑到 32 以上这对微表情识别项目实战落地来说非常友好。3. 微表情数据准备对齐、裁剪与光流提取这一步做不好模型白练3.1 数据集选型与评估协议CASME2、SAMM、SMIC 怎么搭搞微表情识别的从业者应该都清楚这个方向的公开数据集不像 ImageNet 那样随便下。CASME2 和 SAMM 需要向作者提交申请SMIC 相对容易一些但也需要登记用途。数据集的标注协议也不统一CASME2 有 7 类细粒度标签SAMM 也是 7 类SMIC 只有 3 类。直接拿原始标签训练类别分布差异很大而且 7 类里很多类别只有十几个样本根本训不动。常见做法是做一个标签映射把 7 类映射到 4 类或者 3 类如正向、负向、惊讶、其他保证每个类别样本量在 40 个以上。评估协议方面微表情社区公认的做法是 LOSOLeave-One-Subject-Out也就是按受试者划分同一受试者的所有样本不能同时出现在训练集和测试集里。数据集里同一个人的表情习惯高度相似如果不按人分网络很容易记住人而不是记住表情。这一点在后面的避坑章节我会强调。另一个协议是交叉数据集验证比如用 CASME2 训练、SAMM 测试这个更能体现泛化能力但结果是 UAR 通常在 50% 上下非常残酷不是入门阶段该背的包袱。3.2 人脸对齐与 ROI 裁剪脚本拿到视频样本后第一件事是检测人脸并做对齐。dlib 的 68 点模型或 MediaPipe 都可以但微表情数据集里的人脸框通常已经由数据集作者裁剪好并给出了 landmarks 标注。如果是从原始视频重新处理我一般先用 MediaPipe 检测人脸再做仿射变换把两只眼睛对齐到水平位置。以下的函数可以复用于数据预处理import cv2 import numpy as np def align_face(image, left_eye, right_eye, output_size112): 根据左右眼坐标做仿射对齐输出方形人脸图。 dx right_eye[0] - left_eye[0] dy right_eye[1] - left_eye[1] angle np.degrees(np.arctan2(dy, dx)) center (image.shape[1] // 2, image.shape[0] // 2) matrix cv2.getRotationMatrix2D(center, angle, scale1.0) rotated cv2.warpAffine(image, matrix, (image.shape[1], image.shape[0])) # 以两眼中心为基准向外扩 40% 区域作为 ROI eye_center ((left_eye[0] right_eye[0]) / 2, (left_eye[1] right_eye[1]) / 2) crop_size int(np.linalg.norm([dx, dy]) * 1.8) x int(eye_center[0] - crop_size / 2) y int(eye_center[1] - crop_size / 2) x max(0, x); y max(0, y) crop rotated[y:y crop_size, x:x crop_size] if crop.size 0: raise ValueError(ROI 裁剪越界请检查 landmarks) return cv2.resize(crop, (output_size, output_size))这里两个参数是坑的重灾区scale1.0是旋转后保持原尺寸不要改成大于 1 的值否则裁剪区域会跟着放大crop_size eye_distance * 1.8的经验值主要来自 CASME2 的标注习惯眼睛间距的 1.8 倍能覆盖眉毛到下巴的大部分区域并且会把背景干扰降到最低。如果你试过直接把整张 1920×1080 原图喂给网络会发现光流里全是背景的噪声模型学到的几乎全是“肩膀晃动”而不是微表情自身。3.3 光流计算用 OpenCV 提取时间流的输入时间流的输入不是视频帧本身而是光流。严格来说微表情的光流通常计算 onset 帧到 apex 帧之间的运动场其中 apex 是表情强度最大的帧标注文件里一般会给出。但很多数据集的标注只有 onset 和 offset没有 apex这时常见做法是取中间帧近似。用 OpenCV 提取光流的代码如下import cv2 import numpy as np def compute_optical_flow(prev_frame, next_frame): 输入灰度图uint8输出 U/V 两个光流通道。 返回形状 (H, W, 2)最后一维是 (U, V)。 prev_gray cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY) next_gray cv2.cvtColor(next_frame, cv2.COLOR_BGR2GRAY) # 使用 DIS 光流速度快且对小位移效果好 dis cv2.DISOpticalFlow_create(cv2.DISOPTICAL_FLOW_PRESET_MEDIUM) flow dis.calc(prev_gray, next_gray, None) # 用 Farneback 是另一个选择但慢很多且对小位移不敏感 return flow.astype(np.float32) def flow_to_color(flow): 把光流归一化到 [0,255]否则网络训练时不收敛。 mag, ang cv2.cartToPolar(flow[..., 0], flow[..., 1]) mag_norm cv2.normalize(mag, None, 0, 255, cv2.NORM_MINMAX) u_norm cv2.normalize(flow[..., 0], None, 0, 255, cv2.NORM_MINMAX) v_norm cv2.normalize(flow[..., 1], None, 0, 255, cv2.NORM_MINMAX) return np.stack([u_norm, v_norm, mag_norm], axis-1).astype(np.uint8)参数说明DISOPTICAL_FLOW_PRESET_MEDIUM是精度和速度的折中我用它替代 Farneback因为微表情的帧位移通常只有 1 到 3 个像素Farneback 对小位移的响应不够稳定cartToPolar算出的 magnitude 通道建议拼进时间流当第三通道很多实现只给 U/V 两个通道微表情幅度信息被丢掉了等于浪费了一半标注。最后一个关键点光流计算前两帧都必须是对齐并且裁剪好的而不是原始视频帧。你拿两个不同 ROI 的帧去算光流网络会认为人脸的细微位移都是表情运动这个误差会被放大到时间流的主成分里。4. 训练配置与关键参数小样本约束下怎么让网络正常收敛4.1 数据划分手动实现 LOSO别偷懒用随机划分既然前面讲了 LOSO这里给出可直接落到代码的划分实现。关键是得到“按受试者分组”的分层划分索引而不是简单的train_test_splitfrom sklearn.model_selection import LeaveOneGroupOut from sklearn.preprocessing import LabelEncoder def make_loso_folds(subject_ids, labels): logo LeaveOneGroupOut() folds [] for train_idx, test_idx in logo.split(np.zeros(len(subject_ids)), labels, groupssubject_ids): folds.append((train_idx, test_idx)) return folds # 示例subject_ids 是每个样本对应的人名labels 是类别 subject_codes LabelEncoder().fit_transform(subject_ids) folds make_loso_folds(subject_codes, label_ids) print(LOSO 折数, len(folds))LeaveOneGroupOut会把每个受试者轮流作为测试集剩下所有人做训练。这样折数等于受试者人数CASME2 大概 30 多人就是 30 多折。注意这里训练集和测试集的样本类别分布可能完全不同——某个人可能只有惊讶类样本那在他的测试折里就看不到其他类别。最终的评估指标要把所有测试折的预测结果拼在一起算而不是逐折算准确率再求平均这个细节很多新手踩坑。小样本还要留意每一个折内训练集类别可能不平衡所以要在训练循环里按类别采样。常见做法是给 DataLoader 传一个WeightedRandomSampler权重设置为样本数的倒数。否则像“厌恶”只有 20 个样本的类别会被网络当成离群点直接忽略。在微表情这种数据量上类别权重带来的提升比换模型架构大得多。4.2 增强策略翻转、时序抖动和光流同步约束微表情增强有两个特殊约束空间流和时间流的增强必须同步且翻转光流时要同时翻转方向分量。水平翻转图像时光流的 U 分量水平方向要取负V 分量不变垂直翻转则相反。如果不做这个处理时间流的运动方向就颠倒了模型会学到错误的表情运动规律。def synchronize_augment(face_img, flow_img, label): 空间流和时间流共享同一个随机增强参数。 if np.random.rand() 0.5: face_img cv2.flip(face_img, 1) # 水平翻转 flow_img cv2.flip(flow_img, 1) # U 通道翻转取负V 和 magnitude 不变 flow_img[..., 0] -flow_img[..., 0] return face_img, flow_img, label增强强度也要克制我一般只做水平翻转、±5 度旋转、±10% 缩放还有 10% 的亮度扰动。不要做随机擦除微表情的局部纹理本来就很细微擦除一块区域直接把关键肌肉运动抹掉了。时序抖动适用于时间流如果样本标注的 onset/apex 帧给了区间可以在区间内随机抽两帧算光流相当于给运动强度做了扰动。这个操作对防止过拟合很有效因为微表情的幅度本身就有正常波动。4.3 超参设置与学习率曲线参考双流浅层网络的超参不像大模型那么敏感但仍有几个关键位置。我按下表给出建议值都是从实验中比较稳定的设置超参项建议值说明优化器Adam比 SGD 稳定微表情数据量不足以支撑长时间 SGD warmup初始学习率1e-3批量 32 以上时可用batch 太小时降到 5e-4权重衰减1e-4只作用在卷积层权重BN 的 γ/β 不参与Batch Size16 ~ 32取决于显存不建议小于 8Epochs50 ~ 80配合早停训练损失不再下降就停Dropout0.4放在融合后的分类头前方学习率计划CosineAnnealing配合前 5 轮 warmup稳定性和收敛速度都更好训练循环里加一个简单的“早停 最优模型保存”防止后面过度训练导致测试指标回退best_score 0 patience 10 bad_epoch 0 for epoch in range(max_epochs): train_loss train_one_epoch(model, train_loader, criterion, optimizer) val_uar evaluate_uar(model, val_loader) if val_uar best_score: best_score val_uar torch.save(model.state_dict(), best_dual_flow.pt) bad_epoch 0 else: bad_epoch 1 if bad_epoch patience: print(fEarly stop at epoch {epoch}, best UAR: {best_score:.4f}) break早停的 patience 在小样本上不能设太大10 个 epoch 够用。微表情数据集本身就小epoch 多了以后训练集损失可以降到 0.01 以下但验证集 UAR 会从 65% 一路掉到 40%过拟合的特征非常明显。另外要盯一下两个分支各自的 loss 贡献有些项目发现空间流学得比时间流快最后分类结果基本被空间流主导可以从 logits 里拆开看两者梯度的数量级如果空间流梯度远超时间流把时间流分支的第一层卷积学习率调大 2 倍属于最后的调参玄学。5. 微表情训练避坑样本泄漏、光流噪声和评估指标的几个真实翻车点5.1 样本泄漏受试者级别的帧混入指标直接虚高现象训练集 UAR 在 95% 以上测试集 UAR 也到了 80%让人觉得模型已经可用。但一换数据集直接掉到 35%。后来检查代码发现在做视频切帧的时候把同一个视频的连续帧同时放进了训练集和测试集。原因切帧是随机切没有按受试者隔离模型见过同一张脸的不同帧。解决把所有数据划分、增强、预处理都放在读取样本时完成并且划分必须以受试者为最小单位同一人的全部帧只能进一个集合。这个坑最容易发生在为了模型做完整体数据扩充的环节比如拿到几个视频后先做了随机裁剪增强再进入划分流程增强出的碎片会跨集合漂移。5.2 光流背景主导归一化方式让网络学会了“边框漂移”现象时间流分支的卷积核可视化后发现前两层关注的全是图像边缘和黑色边框。原因光流归一化使用整幅图的全局 min-max背景区域因为灰度接近 0归一化后会形成一个强烈的对比边缘网络直接把这个边缘当成了主要特征。解决在计算光流前先基于灰度图做一个 ROI mask把非人脸区域置为 0再对光流做归一化。另外不要只用全局归一化可以换成在 ROI 内部做 per-image normalization。这个坑只要出现时间流分支等于白训因为它学到的特征和表情运动没有相关性。5.3 只看准确率小样本微表情里 Accuracy 会骗人现象某个折的准确率到了 90%让人觉得模型没有毛病。细看混淆矩阵才发现测试集里 90% 的样本都是同一类模型把所有样本都预测成那一类就有了 90% 的准确率。原因LOSO 划分下每个受试者携带的类别分布通常不均衡主导类样本超过 80%。解决必须用 UAR未加权平均召回率和 F1 作为核心指标。UAR 的计算方法是每一类算召回再对所有类取平均每类权重相同。只看 Accuracy 被高分类别绑架是微表情识别项目实战中最常见的评估翻车比模型本身的问题严重得多。5.4 apex 帧选错光流时间窗拉错识别效果归零现象时间流分支在训练时 loss 下降正常但测试时几乎约等于随机预测。排查后发现是光流时间窗的问题标注里有的样本 apex 帧在 onset 之后第 3 帧有的在第 10 帧统一用第 5 帧去算光流导致很多样本的 apex 帧落在时间窗之后。原因微表情的峰值帧时序跨度大不能简单用固定帧偏移。解决先统计数据集标注中 onset 到 apex 的平均帧数按每个样本实际标注计算光流不要用固定偏移。如果你用的数据集没有 apex 标注就用 onset 到 offset 之间的中间帧并在训练时做时序采样增强把帧位置在一定区间内随机化让网络对时序偏移鲁棒。6. 复现后的验证方法把 UAR、F1 和混淆矩阵摆出来才算跑通6.1 指标计算的正确打开方式拼接所有折再算LOSO 的多折结果不能逐折平均要先把所有测试折的预测拼接成完整预测数组再统一计算三项指标UAR、加权 F1、每个类别的召回率。一段最小实现如下from sklearn.metrics import f1_score, recall_score, confusion_matrix all_preds [] all_labels [] for train_idx, test_idx in folds: preds, labels evaluate_fold(model, test_idx) all_preds.extend(preds) all_labels.extend(labels) uar recall_score(all_labels, all_preds, averagemacro) f1_weighted f1_score(all_labels, all_preds, averageweighted) cm confusion_matrix(all_labels, all_preds) print(UAR: {:.4f}, F1: {:.4f}.format(uar, f1_weighted)) print(类别召回率:, recall_score(all_labels, all_preds, averageNone))averagemacro得到的就是 UAR每一类召回权值相等。averageweighted是加权 F1作为补充参考。混淆矩阵能直观看到类别混淆模式。这套代码跑完后能客观看到模型在哪些类别上崩溃再决定增加数据还是调分支权重。6.2 提升分辨能力的两个进阶方向如果 baseline 的 UAR 稳定在 60% 到 65%下一步可以考虑引入 AU动作单元辅助监督将每个微表情标注对应的 AU 编号作为辅助分类目标让空间流分支额外输出一个 AU 概率分布背面再跟着表情分类头。这种方式能让网络学到与面部肌肉动作相关的中间表征对数据量要求比纯分类头更低。第二个方向是把两个分支的特征向量改成加权融合权重可学习但初始化为 0.5/0.5并用温度参数控制融合强度。不要一上来就上 Transformer——在小样本下注意力机制的效果极不稳定很容易让训练变成黑匣子。这些年我做微表情项目最大的教训是别急着改网络结构。先看数据处理有没有泄漏再看指标有没有被主导类欺骗最后才轮到模型有效果提升的空间。双流浅层网络是一个稳定可复现的 baseline把它跑透再往深度方向去扩展会省掉很多返工时间。这套思路和代码路径希望帮到你。本文还有配套的精品资源点击获取
返回列表