简介:本项目是一个基于Python的多模态情感识别系统,综合CNN表情识别、BERT-base-Chinese文本情感分析与LSTM语音情感识别,并使用飞桨OCEMOTION中文情感分类数据集进行训练。资源面向对人机交互、智能客服、情绪分析等场景感兴趣的中高级Python开发者,可帮助理解多模态情感识别的完整技术链路。压缩包内含67个文件,以Python源码为主,搭配pyc缓存、模型配置文件(yaml)、GUI界面文件(ui/xml)等类型,整体体积仅95KB,便于快速部署与二次开发。系统核心入口为py_player_demo0.py,text_predict.py负责将中文文本输入至训练好的BERT模型进行情感预测,audio2text.py实现语音转文本,配合recording.py、face_recognition.py等脚本,可清晰掌握数据加载、模型推理与调试流程。目前已有206人学习下载,适合希望从代码层面掌握CNN、BERT、LSTM三种模型融合实践的开发者,拿到后即可直接运行、调试,并灵活扩展至其他情感分析应用场景。 说一个我观察到的现象。之前给一家做智能客服质检的团队做情绪分析方案,客户反馈“用户明明句句客气,退订率却一直降不下来”。我把通话录音和客服席位的视频拉出来细看,问题全出在“好的呢”这三个字上——用户说这句话时语速快了0.3倍,音调明显上扬,脸上表情是绷着的。如果只看语音转文字的记录,这场对话简直完美,但坐在对面的老客服当场就听懂了那种敷衍和不满。这件事让我认定,可靠的情感识别不能押在任何单一信号上。后来的项目,我直接奔着多模态去了,也就是这篇文章要讲的——基于面部表情、语音和语音转文字的情感识别Python源码实现:把视频画面、音频波形、转写文本三条信号全部接进来,最终输出七类基本情感(生气、厌恶、恐惧、开心、悲伤、惊讶、平静)。这篇文章适合正在做情绪识别、人机交互、质检/客服系统分析,或者想入门多模态融合的Python开发者。我会把整体架构、三个单模态分支的具体实现、融合策略、训练推理流程,以及实测中踩过的坑都摊开讲。
1. 为什么我坚持走“表情+语音+文本”三模态融合路线
1.1 单模态各自的上限在哪里
先看一张我平时做方案时常用的对照表,三个模态各自捕捉的信息类型完全不同:
| 模态 | 擅长捕捉的信息 | 典型失效场景 |
|---|---|---|
| 面部表情 | 即时情绪表露、微表情、肌肉张弛 | 面无表情的人、侧脸/遮挡、刻意伪装 |
| 语音 | 音调、语速、节奏、停顿、气息 | 刻意压低声音、设备收音差、方言口音 |
| 文本 | 语义内容的直接表达、观点倾向 | 反讽、双关、省略句、“好的呢”这类客气话 |
单用表情的问题很好理解——我见过太多人面试时全程微笑,心里已经在骂人了。单用语音的问题是,同样一句“你真厉害”,用正常语调说是夸奖,用拖长了的升调说就是阴阳怪气,MFCC特征对这类细微差别的区分能力其实很有限。单用文本的问题最隐蔽:文本情感分析只能看到字面意思,看不到“对方是不是在说气话”。
1.2 系统整体流程与技术选型
我做这套系统的目标很明确:以离线推理为主,可以处理一段视频文件,也能接入摄像头/麦克风做实时识别。情感类别统一成七类,方便做下游统计和分析。
整体流程是这样的:
- 视频流抽帧 → OpenCV人脸检测 → 表情分类模型 → 输出表情logits
- 音频流切窗 → Librosa提取MFCC特征 → BiLSTM模型 → 输出语音情感logits
- 音频流同源 → ASR语音转文字 → VADER/SnowNLP或fastText → 输出文本情感分数
- 三个分支的置信度进入融合模块 → 带置信度门槛的加权投票 → 最终情感标签
技术选型上没有追新。表情分类用EfficientNet-B0,语音用双向LSTM,文本优先用词典类工具加一个可选的轻量自训分类器,框架统一PyTorch。整套推理下来,在笔记本CPU上处理一条5秒的视频片段大概在1秒左右,GPU上可以做到实时。
1.3 为什么这样选型
可能有人会问,为什么表情不用更大一点的ResNet,语音不用Transformer,文本不用BERT?我在项目里都试过,结论是:规模上来之后精度提升很有限,但推理耗时、显存占用、模型部署复杂度都是成倍上涨。
具体来说,ResNet50在FER2013上比EfficientNet-B0的提升通常不到两个百分点,但参数量差了近十倍。语音分支我最初试过Wav2Vec2,效果确实好,但一个模型就几百MB,在没有GPU的机器上跑一段5秒音频要等4秒,完全没法用。文本分支用BERT做情感分类,在长文本上优势明显,但在客服对话这种短句场景里,VADER和SnowNLP的正确率差距并不大,而部署和内存开销差距非常大。做项目不是炫技,能把模型真正落地、在目标机器上稳定跑起来,优先级永远高于把准确率报表上那两三个点的数字做漂亮。
2. 三个“单模态基座”各自怎么落地
2.1 表情分支:人脸检测 + 轻量分类网络
表情分支是整个系统里最成熟的部分。人脸检测直接用OpenCV的Haar Cascade,虽然它偶尔会有漏检和误检,但胜在零依赖、速度快、部署简单。深度学习检测器(比如MTCNN、YOLO-Face)精度更高,但在这一步先不引入,原因是表情分类本身对边界框精度不敏感,能在视频帧里找到一张大致居中的人脸就够了。
分类网络用EfficientNet-B0,输入是48x48的灰度图,输出长度7的向量,对应七类情感。FER2013是表情分类最常用的数据集,3.5万张48x48人脸图,七类标签直接对齐我的需求。
核心代码如下:
import cv2 import torch import torch.nn as nn from torchvision import models, transforms face_cascade = cv2.CascadeClassifier("haarcascade_frontalface_default.xml") transform = transforms.Compose([ transforms.ToPILImage(), transforms.Grayscale(num_output_channels=3), transforms.Resize((48, 48)), transforms.ToTensor(), transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]), ]) # 替换EfficientNet-B0最后一层分类器,输出7类 model = models.efficientnet_b0(weights=None) model.classifier[1] = nn.Linear(1280, 7) model.load_state_dict(torch.load("facial_emotion.pt", map_location="cpu")) model.eval() def facial_predict(frame): faces = face_cascade.detectMultiScale( frame, scaleFactor=1.1, minNeighbors=5, minSize=(48, 48) ) if len(faces) == 0: return None x, y, w, h = faces[0] # 只取最大人脸 face = frame[y:y+h, x:x+w] tensor = transform(face).unsqueeze(0) with torch.no_grad(): logits = model(tensor) return logits.squeeze(0) # 7维logits两个细节需要强调。第一,输入统一用灰度图而不是三通道彩色图,因为FER2013本身就是灰度数据,强行转彩色对模型没有收益。第二,视频抽帧时不要每帧都送进模型,我按1秒3帧的节奏抽,既能抓住表情变化,又不会让推理压力过大。一个文件5秒的视频,约15帧,每帧独立预测之后把logits做平均,最终得到一个稳定的表情置信度。
2.2 语音分支:MFCC特征 + 双向LSTM
语音情感识别相对复杂一点,核心思路是把音频转成MFCC特征序列,再用循环神经网络建模时序关系。
MFCC的提取我用Librosa完成。参数上,80ms的窗口、40ms的帧移,提取40维MFCC,再叠加一阶差分和二阶差分,得到一个120维的特征向量序列。每个样本固定截取200帧,不够的部分补零。
为什么选MFCC而不是直接把原始波形丢给模型?MFCC模拟了人耳对不同频率声音的感知特性,能把语音中的情感相关信息(比如音高变化、共振峰偏移)压缩成相对紧凑的表征。原始音频采样率16kHz,5秒就有8万个采样点,直接建模对计算量和数据量的要求都高得多,在小数据集上反而不容易收敛。
双向LSTM的模型结构如下:
import torch.nn as nn class SpeechEmotionLSTM(nn.Module): def __init__(self, input_dim=120, hidden_dim=64, num_classes=7, num_layers=2): super().__init__() self.lstm = nn.LSTM( input_dim, hidden_dim, num_layers, bidirectional=True, batch_first=True, dropout=0.3 ) self.classifier = nn.Sequential( nn.Linear(hidden_dim * 2, 64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, num_classes) ) def forward(self, x): out, _ = self.lstm(x) out = out[:, -1, :] # 取最后一个时间步的隐藏状态 return self.classifier(out)注意我取的是序列最后一个时间步的输出,这个向量同时包含了前向和后向的信息,在双向LSTM里等价于对整段语音做了一个压缩。用它做分类,天然建模了“一句话从头到尾的情感走向”,比单纯取平均池化效果要好。
训练数据用RAVDESS数据集,24名演员,8种情绪(neutral, calm, happy, sad, angry, fearful, disgust, surprised)。我把calm归入平静,最后对齐成七类。训练时对男声样本做了一点过采样,原因后面讲踩坑时会说明。
2.3 文本分支:从零成本词典到可选的轻量分类器
文本情感分析是所有分支里最容易被低估的环节。很多人觉得它不就是一个情感词典的事儿,但实际上在短对话场景里,词典方法经常把“没事,你忙吧”里的“没事”识别成正面,而人类听到这句话时,往往能感受到那种带着失落的情绪。
我的方案分三层,按场景选择:
第一层,英文场景直接上VADER,它是专为社交媒体短文本调参的词典工具,对大小写、标点、程度副词都做了精细处理,零训练成本。
第二层,中文场景用SnowNLP,它是基于电商评论语料训练的情感分析库,对中文短句的正面/负面倾向判断在多数场景够用。
第三层,如果精度要求高,就在SnowNLP的分数基础上,用自训的fastText分类器做二次判断。fastText的优势是训练极快、模型文件极小(通常几MB),输入是结巴分词后的句子,输出正面/负面/中性三分类。训练语料我用了微博情感语料加上自己标注的一批客服对话,约两万条。
实际代码里最常用的是这个结构:
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer analyzer = SentimentIntensityAnalyzer() EMOTIONS = ["生气", "厌恶", "恐惧", "开心", "悲伤", "惊讶", "平静"] def text_sentiment(text, lang="en"): if lang == "en": vs = analyzer.polarity_scores(text) compound = vs["compound"] # -1 ~ 1 else: from snownlp import SnowNLP compound = (SnowNLP(text).sentiments - 0.5) * 2 # 转成 -1 ~ 1 if compound >= 0.5: emo, score = "开心", compound elif compound <= -0.5: emo, score = "生气", -compound else: emo, score = "平静", 0.5 - abs(compound) return emo, score这个函数把连续的情感分数映射到了三类。确实损失了一部分细粒度信息,但足够支撑融合投票了。文本分支输出的不是七类向量,而是一个情感倾向分数,这个分数进入融合模块时会被当成“文本对这一帧情感的投票置信度”来用。
3. 融合策略:我最后采用了带置信度门槛的加权投票
3.1 为什么不做特征级融合
多模态融合在学术界有各种高大上的做法,比如把视频特征、音频特征、文本特征拼成一个长向量送进Transformer做early fusion。我在项目初期也搭过这种结构,但很快放弃了。
原因很实际:特征级融合要求三个模态的输入在时间上严格对齐,视频抽帧、音频切片、文本分词这三者的粒度完全不同,实操中对齐代码极其繁琐。更重要的是,训练特征级融合模型需要一个三模态同时标注的数据集,现有公开数据集要么只有表情标注,要么只有语音标注,强行准备数据的时间和人力成本非常高。
对我来说,决策级融合(late fusion)是性价比最高的方案:三个分支独立训练、独立推理,最后在输出层做加权投票。哪个模型更可信,权重就高一些;某个分支在某种场景下不可靠,还可以动态把它“关掉”。
3.2 加权投票函数的具体实现
融合模块的核心逻辑不复杂,但阈值和权重的设定需要反复试。
我先给一组在实测中表现不错的初始参数:
- 基础权重:表情0.4,语音0.3,文本0.3
- 置信度门槛:0.45
表情权重最高,原因是表情分类在七分类任务上的绝对精度最高,人脸特征相对稳定。语音和文本在真实场景中都有各自的脆弱点:语音容易受环境噪声干扰,文本则可能被反讽误导。
置信度门槛0.45的含义是:一个分支给出的最高置信度如果低于这个值,说明它自己都没把握,这时它选择弃权而不是硬投一票。这个设计非常重要,它让系统具备了“知道自己不知道”的能力。
EMOTIONS = ["生气", "厌恶", "恐惧", "开心", "悲伤", "惊讶", "平静"] WEIGHTS = {"facial": 0.4, "speech": 0.3, "text": 0.3} CONF_THRESHOLD = 0.45 def fuse(facial_logits, speech_logits, text_emo, text_conf): votes = {} confs = {} # 表情分支:七分类logits f_conf, f_idx = facial_logits.max(0) f_conf, f_idx = float(f_conf), int(f_idx) if f_conf >= CONF_THRESHOLD: emo = EMOTIONS[f_idx] votes[emo] = votes.get(emo, 0) + WEIGHTS["facial"] confs[emo] = max(confs.get(emo, 0), f_conf) # 语音分支:七分类logits s_conf, s_idx = speech_logits.max(0) s_conf, s_idx = float(s_conf), int(s_idx) if s_conf >= CONF_THRESHOLD: emo = EMOTIONS[s_idx] votes[emo] = votes.get(emo, 0) + WEIGHTS["speech"] confs[emo] = max(confs.get(emo, 0), s_conf) # 文本分支:输出情感类别和置信度 if text_conf >= CONF_THRESHOLD: votes[text_emo] = votes.get(text_emo, 0) + WEIGHTS["text"] confs[text_emo] = max(confs.get(text_emo, 0), text_conf) if not votes: return "平静", 0.0 max_vote = max(votes.values()) candidates = [k for k, v in votes.items() if v == max_vote] if len(candidates) == 1: return candidates[0], votes[candidates[0]] # 多个类别并列最高时,退回置信度最高的一方 best = max(candidates, key=lambda x: confs[x]) return best, votes[best]并列时的回退逻辑是从真实场景中逼出来的。有一次测试,一个人一边大笑一边愤怒地说话,表情给了“开心”0.6,语音给了“生气”0.55,两边投票权重一样,如果随便取一个,结果就完全随机了。后来加了这条“并列比置信度”规则,系统会更倾向于相信画面里那个明确的表情信号。
3.3 用规则兜底处理“反常识”组合
加权投票之外,我还加了几条人工规则来应付那些模型层面“说不清”的场景。
规则一:表情是“生气”,但语音和文本都是正面倾向,比如“生气表情 + 开心语调 + 正面文本”,这大概率是表演或者夸张的玩笑,融合结果偏向“开心”,并给一个较低的置信度。
规则二:当文本是负面、语音是平静时,输出以文本为主,因为文本的负面信息在客服投诉场景里价值最高,语音的平静很可能是对方在压抑情绪。
规则三:当三个分支全部低于置信度门槛时,不强行给出情感判断,直接输出“平静/不确定”。宁可给一个低置信度的默认结果,也不要乱报一个高置信度的错误结果,这在质检系统里特别重要——一个错的标注比不标注更能误导人工复核。
这些规则不需要面面俱到,只处理最常出现的几种冲突组合即可。规则太多反而容易互相打架,维护成本也高。
4. 从数据处理到推理封装:工程落地的完整路径
4.1 三路输入的对齐与预处理
三模态系统在工程层面最麻烦的其实是数据管线。三个分支对输入的要求完全不同,但必须从同一段视频里抽取。
我实际采用的流程是:视频文件用OpenCV/FFmpeg抽帧,同时用FFmpeg把音轨提出来存成16kHz单声道wav;抽帧按1秒3帧,音频按5秒一个窗口切开,两个相邻窗口重叠2秒。这里的对齐逻辑是:表情分支处理某一帧时,语音分支处理包含该帧时刻前后约2.5秒的音频段,文本分支则转写这5秒窗口内ASR识别出的文本。窗口重叠2秒是为了保证情绪事件不会恰好落在窗口边界被截断。
文本分支要依赖ASR,我在本地用的是faster-whisper的small模型,中英文都能转,速度也能接受。如果你们是私部署环境,用讯飞或百度在线接口也完全可以,只是会引入网络延迟。要注意的是ASR的转写错误对文本情感分析影响非常大——一个负面的“不想”被识别成“想”,融合结果可能就从愤怒变成平静了。所以这里的工程建议是:ASR输出的置信度如果过低,文本分支直接弃权。
4.2 三个模型各自的训练配置
三个模型是独立训练的,我用了相对统一的训练策略:
| 模块 | 数据集 | 输入尺寸 | Epoch | Batch Size | 初始学习率 | 早停 |
|---|---|---|---|---|---|---|
| 表情分类 | FER2013 | 48x48 | 40 | 128 | 1e-3 | 验证集3轮不降则停 |
| 语音情感 | RAVDESS | 200帧x120维 | 60 | 32 | 1e-3 | 同上 |
| 文本分类 | 微博语料+自标注 | 分词序列 | 20(fastText) | - | 0.5 | 无 |
训练设备用一张RTX 3060就能跑完,表情模型大概半小时一轮,语音模型两小时以内。损失函数统一是交叉熵,优化器Adam。语音模型加了0.3的Dropout,因为RAVDESS数据集本身比较小,不加正则很容易过拟合到演员的个人音色上。
4.3 推理封装:一个函数跑完整个流程
训练完成后,推理封装对上层调用的人来说非常重要。我不希望下游业务系统关心“什么是MFCC”“什么是logits”,他们只需要传入一段视频,拿回一个情感标签。
所以我对外只开放了这么一层接口:
def predict_one_clip(video_path): # 1. 抽帧,得到表情logits facial_logits = infer_facial(video_path) # 2. 提取音轨,切窗,得到语音logits audio_path = extract_audio(video_path) speech_logits = infer_speech(audio_path) # 3. ASR转写,得到文本情感 text = asr_transcribe(audio_path) text_emo, text_conf = infer_text(text) # 4. 融合 emotion, confidence = fuse(facial_logits, speech_logits, text_emo, text_conf) return emotion, confidence, text语音和文本在这里还各自做了一件事:语音分支在整段视频上滑动多个窗口,把所有窗口的预测结果平均后再输出;文本分支则把所有窗口的ASR文本按句子切分,逐句打情感分后取平均。这样做可以把短期波动抹平,得到更稳定的片段级结果。
5. 实测效果与踩过的那些坑
5.1 融合到底带来了多少提升
我在自建的测试集上做过一组对比测试,每类情感约50条短视频,男女比例各半。下面是一组代表性场景的数据:
| 测试场景 | 表情单模态 | 语音单模态 | 文本单模态 | 三模态融合 |
|---|---|---|---|---|
| 正常对话中发火 | 61% | 54% | 45% | 74% |
| 压低声音说悲伤的事 | 42% | 63% | 38% | 69% |
| 反讽调侃(“你真厉害”) | 35% | 41% | 38% | 58% |
| 面无表情地表达平静诉求 | 33% | 52% | 57% | 64% |
第一行是典型的“嘴上说没事、脸上已经挂相”的场景。第三行是反讽场景,三个单模态全部翻车,融合提升明显。第四行是面无表情的路人甲,表情分支基本废掉,全靠语音和文本撑起来。
如果你看到单模态在某些场景下只有三成的正确率,不用惊讶,情感识别本身就是高度依赖语境的任务,同一段话换了上下文,人类判断都有分歧,何况模型。
5.2 坑一:表情分支被“惊讶/恐惧”高频带偏
调试中发现一个很烦的现象:人脸只要嘴巴张得稍大一点,表情分支的输出常常在“惊讶”和“恐惧”之间摇摆,而且相当多的情况下会直接给出“恐惧”这个高置信度结果。这跟FER2013的数据分布有关系——惊讶和恐惧这两类的样本里,嘴形和眉眼形态高度相似,模型学到的区分信号不足,而且这两类的样本量本身偏少。
我当时的处理不是换模型,而是做了两件事:一是把模型的输入分辨率从48x48提高到72x72,重新微调,单这一项就让“惊讶/恐惧”两类的混淆率下降了约四个百分点;二是在推理端对“惊讶”和“恐惧”这两个类别的softmax分数各乘一个0.92的衰减系数,强制让这两个类别不要轻易霸占最高分。这两个措施见效后,系统的整体置信度分布健康了很多,融合投票也更合理。
5.3 坑二:男声低音普遍被语音分支判成“悲伤”
第一次跑整套系统时,我拿自己录的几句音频做测试,每一句都被识别成“悲伤”或“平静”。一开始我还以为是情绪没表达到位,后来让几个男同事都录了一遍,发现只要音调偏低,系统就倾向于给“悲伤”。
查了训练数据之后发现问题出在RAVDESS上——这个数据集的女性演员比例较高,整体音高分布偏向中高音。男声的基频(F0)落在训练集的边缘,模型对低音区的区分力不足,于是把“低音”当成了“悲伤”的强信号。
解决思路有两个:一是在训练前把MFCC特征用全局均值方差做标准化,而不是对每个样本单独标准化,这样保留了绝对音高信息的同时也保留了样本间的相对分布;二是对男声样本做过采样,把训练集里男女性别比例拉平。这之后男声“愤怒”和“平静”的区分度明显好了很多。
5.4 坑三:静音/噪声段把语音分支的投票权重浪费掉
在实时场景中这个问题特别突出。一个人对着屏幕沉默了几秒,或者背景有空调声、键盘声,语音分支照样会输出一个高置信度的结果——不过它“自信地”输出的往往是随机噪声对应的情绪。
解决方法是加入一个VAD(语音活动检测)模块。我用的是webrtcvad,每30ms判断一次当前音频段是否包含有效语音。如果一个分析窗口内有效语音占比低于30%,直接让语音分支弃权,不参与投票。文本分支同理,如果ASR转写出来的文本只有“嗯”“啊”这类语气词,也直接弃权。这些“弃权”逻辑的价值在于,它让融合模块输出的置信度更真实,下游系统可以做阈值控制。
踩过这几个坑之后再回头看,这套系统的核心其实不是某个多聪明的模型,而是三路信号如何协同、如何在各自不靠谱的时候主动退出。我自己最深的体会是:多模态系统里,知道什么时候该不听某个模态的声音,比把每个模型都调到最准更重要。如果你也想在这条路上继续往下走,我的建议是先别急着上大模型,把三路分支的底子打好,把融合策略想清楚,再考虑用更强的模型去替换其中某一个基座。这个扩展路径,是我觉得最稳妥也最省钱的。
本文还有配套的精品资源,点击获取