Empathy‑Avator 是一个离线运行的多模态情绪识别驱动虚拟陪伴系统。它通过摄像头和麦克风采集用户的表情、语音和输入文本,分别进行面部表情识别(FER)、流式语音情绪识别(SER)和文本情绪分析,并利用置信度门控的加权融合生成稳定的情绪标签。系统内置检索增强的对话引擎,根据情绪和关键词在轻量知识库中检索贴合的回复,辅以规则式基础回复与风险应急提示。回答通过 Piper TTS 合成语音,同时根据音频能量生成嘴形(viseme),驱动虚拟形象实时播放。整个流程通过 FastAPI 提供 REST 和 WebSocket 接口,可在 Jetson Nano 等设备上离线运行,适用于心理辅导和陪伴等场景
项目链接(Github):
当前初始版本包括如下模块:
模型推理:面部情绪识别、流式语音情绪识别、文本情绪分析。
融合与平滑:基于置信度的权重和多数投票形成稳定情绪。
对话引擎:关键字加权检索的知识库,结合基础回复模板与风格描述生成回复。
TTS 与嘴型:调用外部 Piper 命令行合成语音并分析能量生成嘴型参数。
服务器接口:FastAPI 以及 WebSocket,实现摄像头/麦克风采集、推送情绪与回复。
1. 面部情绪识别 (FER)
FER 模块用于从摄像头视频帧中识别用户面部情绪。FER 类在初始化时加载一个 MobileNetV3‑small 模型的 ONNX 权重,并创建 ONNX Runtime 推理器,优先使用 CUDA 提供者。推理流程如下:
通过这一流程,FER 模块输出 {probs, conf, bbox, emo} 作为视觉模态的情绪信息
人脸检测与裁剪:优先调用 MediaPipe 人脸检测,如果检测不到人脸则默认使用整幅图像。检测到的人脸框会被扩展一定比例并截取。
预处理:将裁剪后的 BGR 图像缩放到 224×224,像素值归一化为 (x/255−0.5)/0.5,再添加一个批次维度形成张量。
ONNX 推理:使用
session.run得到 logits 向量,通过 softmax 函数 \sigma(z_{i}) = \frac{e^{z_{i}}}{\sum_{j} e^{z_{j}}} 转为概率分布。结果解析:返回各类情绪概率、最大概率作为置信度、当前框,以及概率最大的类别作为情绪标签
该 FER 实现默认支持七类基本情绪:angry、disgust、fear、happy、sad、surprise、neutral,即 DEFAULT_CLASSES 中定义的顺序。推理过程中使用 FaceDetection 模型 model_selection=0 和 min_detection_confidence=0.5 检测人脸,当检测到多张人脸时选择置信度最高的一张并裁剪,裁剪框扩展至原图范围内并使用 max/min 函数限制在图像边界。若没有检测器或未检出人脸,则将整幅图像作为输入。模型输入尺寸为 (224,224),输出经过 softmax 归一化后对应上述类别顺序,因此调用者可以通过索引获取各情绪概率。
通过这一流程,FER 模块输出 {probs, conf, bbox, emo} 作为视觉模态的情绪信息。
2. 流式语音情绪识别 (SER)
StreamingSER 类实现了一个无需深度模型的流式语音情绪识别器,适合边缘设备。构造函数指定采样率、窗口长度和情绪类别。process 方法对最近一段音频执行以下步骤:
特征提取:将音频归一化后,计算每个窗口的统计特征:
RMS(均方根)能量: \mathrm{RMS}(x) = \sqrt{\frac{1}{N} \sum_{i=1}^{N} x_{i}^{2}} 。
零交叉率 (ZCR):窗口内符号变化的次数与样本数的比值;反映音频粗糙度。
谱质心 (Spectral Centroid):频谱能量的重心,公式为 \frac{\sum_{k} f_{k} |\hat{X}(k)|}{\sum_{k} |\hat{X}(k)|},其中 \hat{X}(k) 是窗口的离散傅里叶变换系数。
特征经过滑动窗口归一化和指数平均平滑,以抑制抖动。
启发式打分:为每个情绪类别设置若干启发式权重。具体地,先将 RMS、零交叉率和谱质心分别归一化为 rms_{n} = \mathrm{min}(1, \frac{\mathrm{RMS}}{0.3}) zcr_{n} = \mathrm{min}(1, \frac{\mathrm{ZCR}}{0.5}) 和 centroid_{n} = \mathrm{min}(1, \frac{\mathrm{Centroid}}{2500})。然后按以下规则计算得分向量 s:
angry: s_{\mathrm{angry}} = 0.6 \mathrm{rms}_{n} + 0.4 \mathrm{centroid}_{n};
surprise: s_{\mathrm{surprise}} = 0.5 \mathrm{centroid}_{n} + 0.5 \mathrm{zcr}_{n};
fear: s_{\mathrm{fear}} = 0.5 \ centroid_{n} + 0.5(1 - zcr_{n});
sad: s_{\mathrm{sad}} = 0.6(1 - rms_{n}) + 0.4(1 - centroid_{n});
happy: s_{\mathrm{happy}} = 0.6 \, zcr_{n} + 0.4 \, rms_{n};
disgust: s_{\mathrm{disgust}} = 0.5 \mathrm{rms}_{n} + 0.5(1 - \mathrm{centroid}_{n});
neutral: s_{\mathrm{neutral}} = 0.3(1 - rms_{n}) + 0.3(1 - zcr_{n}) + 0.4(1 - centroid_{n})。
计算完成后将得分设为非负,并对其做归一化得到概率向量。这些启发式权重来源于对音频情绪表达的经验规律:例如 angry 与声音强度和高频含量正相关,而 sad 与低能量和低频含量相关。这样可以在无需深度模型的情况下估计情绪。
归一化与平滑:将每个情绪的得分设为非负,统一归一化为概率分布,并通过滑动窗口对概率进行时间平滑。最终返回
{probs, conf, features, emo},其中conf为最大概率。
通过这种轻量级规则模型,SER 能够在低功耗设备上实时运行。
3. 文本情绪识别
TextEmotion 模块采用词典驱动的情感分析,通过统计文本中出现的情绪关键词来估计概率。在初始化时加载情绪类别及对应中文词典,并包含敏感风险词集合。其 analyse 方法流程如下:
关键词匹配:对输入文本进行分词或直接遍历,统计每个情绪类别词典中关键词出现次数及权重。
风险检测:若文本中含有诸如“自杀”“自残”等风险词,则标记
risk=True。概率计算:将每类匹配次数加一防止除零,然后归一化为概率分布;若没有任何匹配则提升中性概率。
输出:返回
{probs, conf, matched, risk},matched包含每类匹配词数量。
虽然是简易词典方法,但在对话场景中可以快速识别极端情绪和风险表达。词典中包含大量中文关键词,例如快乐类包含“开心”“高兴”“喜悦”,愤怒类包含“生气”“愤怒”,害怕类包含“担心”“恐惧”等;悲伤类和厌恶类也分别包含与情绪相关的词汇。当文本中未匹配任何关键词时,算法会提升中性类别的概率,防止因无匹配导致误判。此外,风险检测使用单独的敏感词列表,如“自杀”“自残”等,如果匹配则会激活风险标志 risk=True
4. 置信度门控融合
RuleFusion 将三种模态的输出融合为统一情绪。构造函数指定类别顺序和基础权重。fuse 方法接收各模态的概率和置信度,并计算权重:
门控权重:对于每个模态,首先根据 base_weights 给定基础权重和全局最小置信度阈值 min_confidence=0.15。设某模态置信度为 c,其实际权重按下述分段函数:
当置信度低于阈值时权重与置信度成比例缩小,反之则在 0.6+c0.6+c0.6+c 与上限 1.5 之间取较小值,使权重随置信度上升而增强,但不超过设定的上限。这样高置信度结果占比更高,而低置信度模态的影响被削弱。
加权求和:将各模态概率向量乘以对应权重,再除以权重和得到融合概率 \overline{p}_{f} = \frac{\sum_{i} w_{i}p_{i}}{\sum_{i} w_{i}}。
融合: 取最大概率的类别为融合情绪,输出 {emo, conf, probs} 。
这一融合策略保证了不同模态对最终情绪的贡献随其置信度动态调整。
5. 管道与运行逻辑:
Pipeline 类是系统的核心,将 FER、SER、文本分析、融合、对话引擎和 TTS 串联起来。
初始化
构造函数实例化各模块并设置参数,维护若干状态:情绪缓冲队列 emo_hist 用于多数投票平滑,上一轮 TTS 的内容与时间戳用于控制 TTS 触发间隔,历史对话列表用于检索
分支和处理
在 step 方法中,管道处理每个时间步的输入:
视觉分支:调用
_vision_branch得到 FER 的概率、置信度和框,计算当前视觉情绪。音频分支:调用
_audio_branch对最新音频片段运行 SER,返回概率、置信度和推断情绪。文本分支:调用
_text_branch对用户文本执行关键词分析,得到概率和情绪,并检测risk标志。
融合和平滑
将视觉、音频和文本模态的概率、置信度打包传给 RuleFusion 进行融合。融合结果 fused_label 和 fused_conf 加入历史队列 emo_hist,多数投票函数选出过去帧中出现频率最高的标签作为稳定情绪 maj。
多数投票选择在最近的平滑窗口内出现次数最多的情绪标签:
若有触发器则认为需要生成新回复,调用 DialogEngine.generate,传入用户文本、稳定情绪、触发器、文本分析结果、历史对话和风险标志,得到 DialogTurn。生成的回复会记录在历史中,并将相关信息存入 dialog_payload。
这一触发逻辑防止在情绪短暂波动时频繁生成回复。系统会比较当前稳定情绪 maj 与上一轮稳定情绪,仅当差异超过多数投票窗口并满足上述触发条件时才会生成新的对话内容。此外,TTS 调度器记录上一次合成语音的文本和时间戳,只有当文本确实变化且距离上次合成至少 2 秒时才重新调用 Piper 合成新的语音,防止用户因重复语音干扰。平滑窗口的长度默认 8 帧,可通过配置灵活调节。
TTS 生成与缓存
若需新的语音或距离上次 TTS 超过设定间隔,则调用 _tts_once 合成语音并生成 viseme。该方法使用 PiperTTS.synth 产生 PCM 数据,并写入 WAV 文件。同时调用 viseme_from_audio 计算嘴型能量。若无需更新,则重用上一次生成的音频和 viseme 缓存
最终输出
step 方法返回一个字典,其中包含平滑情绪、置信度、回复文本、wav 文件名与采样率、检测框、viseme 能量、模态具体信息和融合结果。服务器端会将这些信息推送给前端客户端。
6. 检索增强对话引擎
DialogEngine 定义一个轻量的知识库,每个 KnowledgeEntry 包含 id、标题、回复正文、跟进建议、关键词及适用情绪。其生成流程如下:
情绪风格描述:根据情绪标签调用
describe_style返回相应的语气,如“轻快、鼓励”或“安抚、安全感”。风险应对:若
risk为真则立即返回emergency_reply()中的通用警示语。前言构建:组合风格与基础模板得到前言,例如
[温柔、共情] 我能理解你的难过。我在这里,愿意听你慢慢说。。检索打分:遍历知识库,对每个条目计算得分\mathrm{score} = \sum_{kw} w(kw) \times \mathrm{hit}(kw)。其中
hit(kw)为关键词在用户文本和历史上下文中出现的次数,历史命中按 0.4 权重计入;若条目适用于当前情绪,则得分乘 1.2;若该条目在历史中出现过,则乘 0.6 降低权重。生成回复:根据得分排序选择
top_k条目返回前top_k来源,并用得分最高的条目的response及followup与前言拼接作为回复。如果没有合适条目则仅返回前言。知识标注:返回的
DialogTurn包含回复策略(retrieval / fallback / emergency)、触发器、风格、线索、来源及风险标识。
这种检索+模板的方法既能利用有限知识库提供具体建议,又能保持风格一致和回复多样性。
检索打分采用加权匹配:对每个关键词统计其在用户文本和最近对话上下文中的命中次数,当前文本命中计权 1.0,历史命中计权 0.4;如果条目适用于当前稳定情绪,则整体得分乘 1.2;若条目在最近回复中过于频繁出现,为避免重复,其得分乘以 0.6。最终选择得分最高的条目用于生成回复,并返回前 top_k 条目的来源,便于在前端显示知识来源
7. 回复策略与风格
dialog/policy.py 定义了应急回复语、情绪对应语气和基础回复模板。其中 EMO_STYLES 为每个情绪提供风格描述:快乐→“轻快、鼓励”,中性→“平静、中性”,悲伤→“温柔、共情”,愤怒→“冷静、缓和”,恐惧→“安抚、安全感”,惊讶→“克制、解释”,厌恶→“尊重、转焦点”,困惑→“澄清、引导”。BASE_REPLY 定义了基础回复模板,例如“我能理解你的难过。我在这里,愿意听你慢慢说。”对应悲伤情绪。函数 make_reply 根据用户文本和情绪标签生成简单回复:若文本包含风险词(例如“自杀”“伤害自己”等),则直接返回应急回复;否则生成带有情绪风格标签的基础回复。describe_style 则仅返回风格描述,以便用于对话引擎或前端显示
8. 文本到语音
PiperTTS 是对外部 Piper 命令行工具的封装,用于离线合成自然语音。
初始化:指定 piper 可执行文件、语音模型路径、输出目录和采样率,启动时确保输出目录存在。
清理音频目录:私有方法
_gc_audio_dir按更新时间排序清理旧 wav 文件,最多保留一定数量。即时合成:
synth方法在临时文件中保存 TTS 输出,然后读取 wav 文件转为 numpy.int16 PCM,并返回 PCM 和采样率;最后删除临时文件。保存到文件:
synth_to_wavfile方法生成 wav 文件保存于指定目录,并使用_gc_audio_dir清理旧文件,返回文件名和采样率。
在默认配置 configs/default.yaml 中,piper_exe 指向平台对应的 piper 二进制,piper_voice 指向中文女声模型 tts/voices/zh_cn_voice.onnx,采样率设为 22050 Hz。因此 PiperTTS 在合成时会构建命令 piper -m <voice_path> -s 22050 -f <output> 并通过标准输入向 Piper 传入要朗读的文本。生成完毕后读取 WAV 文件中的 PCM 并返回,也可将文件保存在 tmp_audio 目录供前端下载。这种离线 TTS 方式保证了系统在没有网络连接时也能流畅运行
9. 嘴型生成
avatar/driver.py 中的 viseme_from_audio 函数依据音频能量计算嘴型参数。
标准化:将 int16 PCM 转为 float32 并归一化到 [−1,1][-1,1][−1,1],减去均值。
窗口划分:根据采样率和目标 FPS(默认 25)确定每帧的样本数
window=sr/fps,将音频划分为数个段。能量计算:对每段计算 RMS 能量 \sqrt{\mathrm{max}(\bar{x}^{2}, \epsilon)},其中 ε 是防止除零的小值。
归一化:所有能量减去最小值再除以最大值得到 0–1 之间的能量序列。
输出字典包含 fps 和 energy 序列。前端根据能量大小调整头像嘴部开合。
在实际应用中,前端会根据 energy 数组将每帧的嘴部开合角度映射为动画曲线。能量序列体现了语音中的强弱节奏,可用于驱动 2D 表情贴图或 3D 骨骼动画,使虚拟头像的口型与朗读内容同步。通过调整能量归一化和阈值,开发者可以控制嘴巴打开程度的最小值和最大值,以获得更自然的口型。
10. 音频采集
SystemAudioStream 的采样率由 StreamingSER 的配置决定(默认 16 kHz),window_seconds 默认为 1.2 秒,因此每次可读取约 19 200 个采样点。采集回调在后台线程持续运行,将最新音频数据存入 _latest。调用 pop_latest 时返回当前缓冲区并清空,以便在下一次处理时不会重复使用旧数据。通过这种方式,SER 始终基于最近的一段语音分析情绪,而且不会阻塞主线程。
11. FastAPI 服务器接口
server/api.py 提供了一套 REST 和 WebSocket API,用于与前端交互。
服务器的摄像头采集线程 _capture_loop 运行在后台线程中,根据配置中的 img_size 将摄像头分辨率设置为 224×224,或者在失败时自动回退到 640×480,尝试不同的摄像头索引直至成功打开设备。采集线程不断读取最新帧并存入共享变量,每当前端通过 REST 路由 /frame 或 WebSocket 请求帧时,服务器会获取最新帧并通过 _draw_overlay 在其上叠加检测框、情绪标签及置信度,超出 TTL 时间的标注将被清除。为减轻网络负担,WebSocket 以约 16 FPS 的频率推送更新,同时客户端可单独下载音频文件通过 /audio/{fname} 路由。所有接口均通过 async def 实现,不阻塞事件循环。
12. 配置文件
configs/default.yaml 定义了模型和资源路径、音频目录和图像大小等配置。例如默认使用 fer_mbv3.onnx、tts/piper.exe、tts/voices/zh_cn_voice.onnx,并指定情绪类别顺序。这些参数可在运行时通过 Pipeline 构造函数覆盖。
配置文件还允许自定义情绪类别的顺序,默认为 [angry, disgust, fear, happy, sad, surprise, neutral]。如果替换 FER 模型或使用不同的情绪集合,需要保证各模块共享同一顺序。其他字段包括 audio_tmp_dir(音频缓存目录)以及 img_size(用于摄像头的目标分辨率),开发者可以根据硬件能力调整这些参数以平衡实时性和效果。