SARAH:空间感知的实时对话虚拟人
一句话总结
SARAH 是首个能在 VR 头显上实时运行的空间感知对话系统,让虚拟人能像真人一样根据用户位置转身、调整视线、做出自然的肢体语言——300+ FPS 的推理速度,完全因果架构。
为什么这个问题重要?
应用场景
- VR 社交:多人会议中,虚拟化身要能看向说话者
- 远程呈现:让远程参会者的数字分身有真实的存在感
- 数字人客服:客服 Avatar 应该面向用户,而不是盯着虚空
现有方法的问题
传统语音驱动动作生成(如 LatentSync)只关注手势与语音同步,忽略了:
- 空间关系:用户在左边,虚拟人却看向右边
- 实时性:离线渲染可以,VR 需要低延迟
- 因果性:不能”预知未来”的语音信号
SARAH 同时解决了这三个问题。
背景知识
3D 人体表示
本文使用 SMPL-X 参数化表示:
- 姿态:$\theta \in \mathbb{R}^{165}$(55 个关节 × 3 轴旋转)
- 形状:$\beta \in \mathbb{R}^{10}$(体型参数)
- 全局位置:$T \in \mathbb{R}^3$,旋转 $R \in SO(3)$
其中 $W$ 是蒙皮函数,$T_P$ 是姿态混合形状,$J$ 是关节位置。
流匹配(Flow Matching)
传统扩散模型需要多步去噪,SARAH 使用流匹配实现单步生成:
\[\frac{d\mathbf{x}_t}{dt} = v_\theta(\mathbf{x}_t, t, c), \quad \mathbf{x}_0 \sim \mathcal{N}(0, I), \mathbf{x}_1 = \text{data}\]训练目标是匹配条件速度场:
\[\mathcal{L} = \mathbb{E}_{t, \mathbf{x}_0, \mathbf{x}_1} \| v_\theta(\mathbf{x}_t, t, c) - (\mathbf{x}_1 - \mathbf{x}_0) \|^2\]相比扩散模型,推理只需 1 步(或少量 ODE 求解步),适合实时场景。
核心方法
直觉解释
想象你在开会,同事在你左前方说话:
- 你会转头看向他(空间对齐)
- 同时点头或摊手(语音同步手势)
- 偶尔眼神交流(可控的注视强度)
SARAH 的架构正是模拟这三个过程:
用户轨迹 ──┐
├──> 流匹配模型 ──> 全身动作
语音特征 ──┘ ↑
└── 因果 VAE(流式推理)
Pipeline 概览
输入:
├── 用户位置轨迹: (x, y, θ)_t # 相对虚拟人的位置和朝向
├── 双向音频: 虚拟人语音 + 用户语音
└── 注视强度: γ ∈ [0, 1] # 可调参数
处理流程:
1. 因果 Transformer-VAE 编码历史动作 → 潜在表示 z_t
2. 音频特征提取(HuBERT)
3. 流匹配模型:
- 输入:z_t, 用户轨迹, 音频, 注视强度
- 输出:SMPL-X 参数 θ_{t+1}
4. 流式输出(无需等待完整序列)
数学细节
1. 因果 VAE 的潜在表示
传统 VAE 需要完整序列,SARAH 使用交替令牌(Interleaved Tokens)实现流式编码:
\[\mathbf{z}_t = \text{Encoder}(\mathbf{x}_{<t}), \quad \hat{\mathbf{x}}_t = \text{Decoder}(\mathbf{z}_t)\]关键:编码器是因果 Transformer,解码器可以并行(训练时)或自回归(推理时)。
2. 空间条件的注入
用户轨迹 $\mathbf{u}_t = (x, y, \theta)$ 通过相对坐标系编码:
\[\mathbf{u}_t^{\text{rel}} = \mathbf{T}_{\text{agent}}^{-1} \cdot \mathbf{u}_t\]防止模型学到”用户总在前方”的偏置。
3. 注视分数与引导
定义注视分数 $s(\mathbf{x}, \mathbf{u})$ 衡量头部朝向与用户位置的对齐度:
\[s(\mathbf{x}, \mathbf{u}) = \text{head\_dir}(\mathbf{x}) \cdot \frac{\mathbf{u} - \text{head\_pos}(\mathbf{x})}{\| \mathbf{u} - \text{head\_pos}(\mathbf{x}) \|}\]使用无分类器引导(Classifier-Free Guidance)调节注视强度:
\[v_\theta^{\text{guided}} = (1-\gamma) v_\theta(\mathbf{x}_t, c_{\text{no\_gaze}}) + \gamma v_\theta(\mathbf{x}_t, c_{\text{full}})\]- $\gamma=0$:自然对话(偶尔看向用户)
- $\gamma=1$:强制眼神交流
实现
环境配置
# 依赖安装
pip install torch einops trimesh smplx
# 数据集(需申请)
# Embody 3D: https://ait.ethz.ch/projects/2024/embody/
核心代码
1. 因果 Transformer VAE
import torch
import torch.nn as nn
from einops import rearrange
class CausalTransformerVAE(nn.Module):
"""因果 VAE,支持流式推理"""
def __init__(self, input_dim=165, latent_dim=256, n_heads=8, n_layers=6):
super().__init__()
self.input_dim = input_dim
self.latent_dim = latent_dim
# 编码器:因果 Transformer
self.encoder = nn.TransformerEncoder(
nn.TransformerEncoderLayer(
d_model=input_dim, nhead=n_heads,
dim_feedforward=input_dim*4, batch_first=True
),
num_layers=n_layers
)
self.fc_mu = nn.Linear(input_dim, latent_dim)
self.fc_logvar = nn.Linear(input_dim, latent_dim)
# 解码器:自回归 Transformer
self.latent_proj = nn.Linear(latent_dim, input_dim)
self.decoder = nn.TransformerDecoder(
nn.TransformerDecoderLayer(
d_model=input_dim, nhead=n_heads,
dim_feedforward=input_dim*4, batch_first=True
),
num_layers=n_layers
)
self.output = nn.Linear(input_dim, input_dim)
def encode(self, x, mask=None):
"""
x: (B, T, input_dim) - 历史动作序列
mask: 因果掩码(可选)
返回: mu, logvar (B, T, latent_dim)
"""
if mask is None:
# 生成因果掩码
T = x.size(1)
mask = torch.triu(torch.ones(T, T), diagonal=1).bool().to(x.device)
h = self.encoder(x, mask=mask)
mu = self.fc_mu(h)
logvar = self.fc_logvar(h)
return mu, logvar
def reparameterize(self, mu, logvar):
std = torch.exp(0.5 * logvar)
eps = torch.randn_like(std)
return mu + eps * std
def decode(self, z, tgt_len):
"""
z: (B, T, latent_dim) - 潜在表示
tgt_len: 目标解码长度
"""
memory = self.latent_proj(z)
# 自回归解码(训练时可并行)
tgt = torch.zeros(z.size(0), tgt_len, self.input_dim).to(z.device)
out = self.decoder(tgt, memory)
return self.output(out)
def forward(self, x):
mu, logvar = self.encode(x)
z = self.reparameterize(mu, logvar)
recon = self.decode(z, x.size(1))
return recon, mu, logvar
2. 流匹配生成模型
class FlowMatchingMotion(nn.Module):
"""流匹配模型,生成空间感知的对话动作"""
def __init__(self, latent_dim=256, audio_dim=1024, traj_dim=3):
super().__init__()
# 条件编码器
self.audio_encoder = nn.Linear(audio_dim, 256)
self.traj_encoder = nn.Linear(traj_dim, 128)
self.time_embed = nn.Sequential(
nn.Linear(1, 128),
nn.SiLU(),
nn.Linear(128, 128)
)
# 速度场网络
cond_dim = latent_dim + 256 + 128 + 128 # z + audio + traj + time
self.velocity_net = nn.Sequential(
nn.Linear(cond_dim, 512),
nn.LayerNorm(512),
nn.SiLU(),
nn.Linear(512, 512),
nn.SiLU(),
nn.Linear(512, latent_dim) # 输出速度向量
)
def forward(self, z_t, t, audio_feat, user_traj, gaze_strength=0.5):
"""
z_t: (B, latent_dim) - 当前状态
t: (B, 1) - 时间步 [0, 1]
audio_feat: (B, audio_dim) - 音频特征
user_traj: (B, traj_dim) - 用户轨迹
gaze_strength: 注视强度 γ
"""
# 编码条件
audio_emb = self.audio_encoder(audio_feat)
traj_emb = self.traj_encoder(user_traj)
time_emb = self.time_embed(t)
# 无分类器引导
if gaze_strength < 1.0:
# 部分去除轨迹条件
traj_emb = traj_emb * gaze_strength
# 拼接所有条件
cond = torch.cat([z_t, audio_emb, traj_emb, time_emb], dim=-1)
# 预测速度场
v = self.velocity_net(cond)
return v
@torch.no_grad()
def sample(self, audio_feat, user_traj, vae_model, n_steps=10, gaze_strength=0.5):
"""单步生成(或少量 ODE 求解步)"""
B = audio_feat.size(0)
device = audio_feat.device
# 初始噪声
z_0 = torch.randn(B, self.velocity_net[0].in_features - 256 - 128 - 128).to(device)
# 简化的 Euler 方法
z_t = z_0
dt = 1.0 / n_steps
for step in range(n_steps):
t = torch.full((B, 1), step * dt, device=device)
v = self.forward(z_t, t, audio_feat, user_traj, gaze_strength)
z_t = z_t + v * dt
# 解码为 SMPL-X 参数
motion = vae_model.decode(z_t.unsqueeze(1), tgt_len=1)
return motion.squeeze(1)
3. 完整推理 Pipeline
class SpatialConversationAgent:
"""空间感知对话虚拟人"""
def __init__(self, vae_ckpt, flow_ckpt, device='cuda'):
self.device = device
# 加载模型
self.vae = CausalTransformerVAE().to(device)
self.flow = FlowMatchingMotion().to(device)
self.vae.load_state_dict(torch.load(vae_ckpt))
self.flow.load_state_dict(torch.load(flow_ckpt))
self.vae.eval()
self.flow.eval()
# 音频特征提取器(HuBERT)
from transformers import Wav2Vec2Model
self.audio_model = Wav2Vec2Model.from_pretrained(
"facebook/hubert-large-ls960-ft"
).to(device)
def extract_audio_features(self, audio_waveform, sr=16000):
"""提取音频特征"""
with torch.no_grad():
inputs = self.audio_model(audio_waveform.to(self.device))
# 取最后一层的平均池化
feat = inputs.last_hidden_state.mean(dim=1)
return feat
def stream_motion(self, audio_stream, user_position_stream, gaze_strength=0.5):
"""
流式生成动作
audio_stream: 实时音频片段
user_position_stream: 实时用户位置 (x, y, θ)
"""
for audio_chunk, user_pos in zip(audio_stream, user_position_stream):
# 提取特征
audio_feat = self.extract_audio_features(audio_chunk)
user_traj = torch.tensor(user_pos, dtype=torch.float32).unsqueeze(0).to(self.device)
# 生成动作(单步,约 3ms)
motion = self.flow.sample(
audio_feat, user_traj, self.vae,
n_steps=1, gaze_strength=gaze_strength
)
# motion: SMPL-X 参数,可直接渲染
yield motion.cpu().numpy()
3D 可视化
import trimesh
import numpy as np
from smplx import SMPL
def visualize_motion_sequence(smplx_params, smplx_model_path):
"""可视化生成的动作序列"""
body_model = SMPL(smplx_model_path, gender='neutral')
meshes = []
for param in smplx_params:
output = body_model(
body_pose=torch.tensor(param['body_pose']).unsqueeze(0),
global_orient=torch.tensor(param['global_orient']).unsqueeze(0),
transl=torch.tensor(param['transl']).unsqueeze(0)
)
vertices = output.vertices.detach().cpu().numpy()[0]
mesh = trimesh.Trimesh(vertices=vertices, faces=body_model.faces)
meshes.append(mesh)
# 使用 trimesh.Scene 展示序列
scene = trimesh.Scene(meshes)
scene.show()
实验
数据集说明
Embody 3D 数据集:
- 7 名演员,双人对话场景
- 捕捉设备:OptiTrack 光学动捕(120 Hz)+ 双向音频
- 标注:SMPL-X 参数 + 相对位置轨迹
- 数据量:约 45 分钟高质量对话
数据预处理:
# 相对坐标转换
def to_relative_coords(agent_pose, user_pose):
"""将用户位置转为虚拟人局部坐标系"""
agent_mat = pose_to_matrix(agent_pose) # 4×4 变换矩阵
user_rel = np.linalg.inv(agent_mat) @ pose_to_matrix(user_pose)
x, y = user_rel[0, 3], user_rel[1, 3]
θ = np.arctan2(user_rel[1, 0], user_rel[0, 0])
return np.array([x, y, θ])
定量评估
| 方法 | FGD ↓ | Diversity ↑ | FPS | 内存 (GB) |
|---|---|---|---|---|
| SARAH (Ours) | 0.032 | 9.21 | 315 | 1.2 |
| LatentSync | 0.045 | 8.73 | 105 | 2.1 |
| EMAGE | 0.051 | 8.45 | 82 | 3.5 |
| DiffGesture | 0.038 | 9.01 | 30 | 4.8 |
FGD (Fréchet Gesture Distance):衡量生成动作与真实分布的距离
Diversity:动作多样性(越高越好,避免重复动作)
SARAH 的速度优势来自:
- 流匹配只需 1 步(vs 扩散模型 50 步)
- 因果架构无需等待完整序列
- 轻量级潜在空间(256 维)
定性结果
成功案例:
- 用户从左侧走向右侧 → 虚拟人平滑转身跟随
- 用户靠近提问 → 虚拟人稍微后退,保持舒适距离
- 双方同时说话 → 虚拟人停止手势,倾听姿态
失败案例:
- 用户在背后说话 → 虚拟人转身过慢(约 500ms 延迟)
- 快速移动 → 偶尔出现抖动(高频噪声)
- 极端姿态(趴地、倒立)→ 生成异常(训练数据缺失)
工程实践
实际部署考虑
实时性分析
在 RTX 3090 上的性能:
- VAE 编码:1.2 ms/帧
- 流匹配采样:1.8 ms/帧(1 步)
- SMPL-X 网格生成:0.5 ms/帧
- 总延迟:~3.5 ms(285 FPS)
VR 头显要求:
- 最低 90 FPS(11 ms 预算)
- 推理只占 3.5 ms,剩余时间用于渲染
优化技巧:
# 使用 TorchScript 加速
scripted_model = torch.jit.script(flow_model)
scripted_model.save("flow_model.pt")
# 混合精度推理
with torch.autocast(device_type='cuda', dtype=torch.float16):
motion = flow.sample(...)
硬件需求
- 最低配置:GTX 1080 Ti(8GB VRAM)
- 推荐配置:RTX 3060(12GB VRAM)
- 部署平台:Quest 3(需量化到 INT8,60 FPS)
内存占用
- 模型权重:~500 MB
- 运行时缓存:~700 MB(历史动作 buffer)
- 峰值:1.2 GB(批量推理时)
大场景优化:
# 历史窗口截断
MAX_HISTORY = 300 # 10 秒(30 FPS)
def maintain_history_buffer(history, new_frame):
if len(history) >= MAX_HISTORY:
history.pop(0)
history.append(new_frame)
return history
数据采集建议
高质量对话数据的采集要点
- 光照稳定:避免阴影遮挡光学标记点
- 对话自然性:
- 不要朗读剧本,要真实互动
- 鼓励自由走动(不要站桩)
- 音频同步:
- 使用时间码对齐动捕和音频
- 采样率 ≥ 16 kHz
- 标注质量检查:
def check_smplx_quality(params): # 检查穿模 body_model = SMPL(...) output = body_model(**params) vertices = output.vertices # 简单的自碰撞检测 mesh = trimesh.Trimesh(vertices[0], body_model.faces) if not mesh.is_watertight: print("Warning: 网格有漏洞") # 检查关节角度合理性 for joint_angle in params['body_pose']: if abs(joint_angle) > np.pi: print("Warning: 不合理的关节角度")
常见坑
1. 坐标系混乱
问题:模型输出的动作在错误的位置
原因:SMPL-X 的局部坐标 vs 世界坐标混淆
解决:
# 统一使用虚拟人的局部坐标系
def world_to_local(agent_global_orient, user_world_pos):
R_agent = rotation_matrix_from_axis_angle(agent_global_orient)
user_local = R_agent.T @ (user_world_pos - agent_world_pos)
return user_local
2. 注视抖动
问题:虚拟人的视线快速左右晃动
原因:流匹配模型对小扰动敏感
解决:
# 平滑用户轨迹
from scipy.ndimage import gaussian_filter1d
def smooth_trajectory(traj, sigma=2.0):
"""对用户位置轨迹做高斯平滑"""
return gaussian_filter1d(traj, sigma=sigma, axis=0)
3. 音频-动作不同步
问题:手势延迟于语音
原因:音频特征提取的窗口与动作生成帧不对齐
解决:
# 严格对齐时间戳
FRAME_RATE = 30 # 30 FPS
AUDIO_HOP_LENGTH = 320 # 16kHz / 50 = 320 samples per frame
def align_audio_to_frames(audio, sr=16000):
"""确保音频特征与帧率同步"""
hop = sr // FRAME_RATE
features = []
for i in range(0, len(audio), hop):
chunk = audio[i:i+hop]
feat = extract_feature(chunk)
features.append(feat)
return features
什么时候用 / 不用?
| 适用场景 | 不适用场景 |
|---|---|
| VR 单人/双人对话 | 多人会议(>5 人) |
| 光照稳定的室内 | 户外/剧烈光照变化 |
| 慢速-中速移动 | 快速运动(跑步、跳跃) |
| 有明确说话者 | 嘈杂环境(多人同时说话) |
| 实时交互场景 | 离线电影制作(可用更高质量的非实时方法) |
与其他方法对比
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| LatentSync | 手势与语音同步好 | 无空间感知,非因果 | 单人演讲 |
| EMAGE | 支持情绪表达 | 速度慢(82 FPS) | 虚拟主播 |
| DiffGesture | 动作多样性高 | 扩散模型太慢(30 FPS) | 离线生成 |
| SARAH (本文) | 实时 + 空间感知 + 因果 | 对数据质量要求高 | VR 实时对话 |
关键差异:
- 空间感知:只有 SARAH 考虑用户位置
- 因果性:LatentSync/EMAGE 需要完整音频序列
- 速度:流匹配比扩散快 10 倍
我的观点
这个方向的发展趋势
- 多模态融合:当前只用音频 + 位置,未来可加入:
- 用户的肢体语言(手势识别)
- 面部表情(情绪感知)
- 场景语义(”指向桌上的杯子”)
- 物理合理性:生成的动作要符合:
- 脚部接触约束(不能悬空)
- 平衡性(重心稳定)
- 碰撞避免(与环境交互)
- 个性化:当前模型生成”平均”的动作,未来应支持:
- 不同性格(外向 vs 内向)
- 文化差异(眼神交流习惯)
- 个人风格(手势幅度)
离实际应用还有多远?
技术成熟度:7/10
- ✅ 实时性已达标(VR 可部署)
- ✅ 因果架构支持流式推理
- ⚠️ 对数据质量敏感(需高质量动捕)
- ❌ 缺少长期记忆(无法记住之前的对话)
工程化挑战:
- 数据成本:动捕设备昂贵(OptiTrack 系统 >$50k)
- 泛化性:训练数据的演员数量有限,新用户可能效果差
- 多语言:当前只测试英语对话
值得关注的开放问题
- 零样本泛化:能否不用动捕,直接从视频学习?
- 启发:结合 HMR 2.0 从单目视频估计 SMPL-X
- 长期一致性:如何保持对话中的人格一致性?
- 思路:引入记忆模块,记录用户偏好
- 物理交互:虚拟人能否拿起物体、推开障碍物?
- 技术:需要与物理引擎(PhysX)集成
SARAH 证明了实时空间感知对话虚拟人的可行性,但距离”完全拟人化”还有很长的路。下一步的关键是降低数据门槛,让更多开发者能训练自己的对话虚拟人。
Comments