1. 从零开始:理解HumanML3D与DeepPhase的结合
如果你对3D人体动作生成感兴趣,最近肯定绕不开两个词:HumanML3D 和 DeepPhase。我第一次看到这个组合时,也觉得有点懵,这俩东西放一块儿能干嘛?简单来说,你可以把HumanML3D想象成一个巨大的、标注好的“动作字典”,里面包含了各种人体运动的3D数据。而DeepPhase,则像是一个经验丰富的“动作分析师”,它擅长从复杂的运动数据中,提炼出最核心、最本质的“相位”信息,比如走路时哪只脚着地、挥手时手臂的节奏。
那么,motion-latent-diffusion(运动潜在扩散模型)在这里扮演什么角色呢?它就像一个顶级的“动作生成导演”。导演(扩散模型)拿到了剧本(文本描述,比如“一个人高兴地跳跃”),然后他需要从动作字典(HumanML3D)里找素材,同时听取动作分析师(DeepPhase)的专业意见,最后合成一段全新的、符合剧本要求的流畅动作。我们今天要做的,就是钻进这位“导演”的工作室,看看他的剧本(代码)到底是怎么写的,每一个分镜(函数)是如何实现的。
为什么这个组合值得深究?我自己的体会是,单纯用扩散模型生成动作,很容易得到“飘忽”或者“脚滑”的不自然结果。DeepPhase提供的相位信息,比如脚部接触地面的精确时刻,就像给生成过程加了一个物理锚点,让生成的角色能稳稳地“踩”在地上。而HumanML3D提供了高质量、多样化的数据基础,确保“导演”有足够丰富的素材库进行学习。接下来,我们就一步步拆解这个结合了数据、先验知识和生成模型的复杂系统,我会尽量用我踩过的坑和调试的经验,让你不仅能看懂,还能自己动手复现关键部分。
2. 数据基石:HumanML3D的预处理与运动表示
在让模型学习之前,我们必须把原始的3D动作数据“烹饪”成模型能消化、且喜欢吃的格式。HumanML3D数据集本身已经做了很多清洗工作,但拿到我们手里,还需要经过一系列关键的预处理步骤。这些步骤隐藏在代码里,却是整个流程稳定的基石。
2.1 骨骼对齐与标准化:让人人都从“T-Pose”开始
想象一下,如果数据集里每个人的骨架长短不一、初始站姿(有的朝东,有的朝北),模型直接学肯定会混乱。所以第一步就是骨骼对齐。在 motion_representation.py 的 uniform_skeleton 函数里,这个操作非常直观。
# 代码逻辑示意(非原封不动拷贝)
def uniform_skeleton(positions, tgt_offsets):
# positions: 原始骨骼关节点位置 [序列长度, 关节点数, 3]
# tgt_offsets: 一个标准骨架的骨骼朝向和长度信息
# 1. 计算缩放比例:通常用腿骨长度作为基准
src_leg_len = np.linalg.norm(positions[0, 左髋] - positions[0, 左膝])
tgt_leg_len = np.linalg.norm(tgt_offsets[左髋] - tgt_offsets[左膝])
scale_rt = tgt_leg_len / src_leg_len
# 2. 缩放所有关节点位置
positions = positions * scale_rt
# 3. 将根节点(通常是臀部)平移到原点
root_pos_init = positions[0, 0] # 第一帧的根节点位置
positions = positions - root_pos_init
# 4. 身体朝向转正:让第一帧的正面朝向Z轴正方向
# 计算身体正面方向(例如,通过左右髋和左右肩的向量计算)
forward_init = compute_forward_direction(positions[0])
target_forward = np.array([0, 0, 1])
root_quat_init = qbetween_np(forward_init, target_forward) # 计算旋转四元数
# 5. 将整个序列应用这个旋转,让人物“转正”
positions = qrot_np(root_quat_init, positions)
return positions
我在这里踩过一个坑:这个“转正”操作是基于第一帧的朝向。这意味着如果一段动作是以侧身开始的(比如侧向行走的起始帧),那么整个序列都会被旋转,导致全局方向变化。在评估生成动作时,有时需要忽略这个全局旋转,关注动作本身的相对运动。另一个关键点是 amass_to_pose 函数中那个“绕着X轴顺时针旋转90度”的操作。这是因为不同3D软件和数据集(如AMASS)的坐标系(Y-up还是Z-up)不同,这个旋转是为了统一到项目约定的坐标系(通常是Y轴向上,Z轴向前)下。忘记这一步,生成的人可能会“躺”在地上。
2.2 运动特征的精心计算:速度、角速度与脚部接触
对齐骨骼位置只是第一步,模型真正学习的是这些位置变化所蕴含的运动特征。motion_representation.py 后半部分的核心就是计算一系列紧凑而富有表现力的特征。这些特征将被拼接成一个特征向量,作为扩散模型直接处理的对象。
-
根节点速度与角速度:这是描述人体整体运动的关键。
- 根节点线性速度:不是简单地在世界坐标系下计算臀部位置差分。代码里先计算了差分,然后用根节点的旋转
r_rot对它进行了一次旋转qrot_np。我最初很不解,后来意识到,这是为了将速度表达在局部坐标系下。也就是说,这个速度特征描述的是“人体自身感觉到的前进/侧移速度”,而不是“世界坐标系下的绝对移动”。这更符合运动生成的直觉,也使得模型更容易学习。 - 根节点角速度:通过相邻帧根节点旋转四元数的差异
qmul_np(r_rot[1:], qinv_np(r_rot[:-1]))计算得到。它描述了身体朝向变化的快慢。
- 根节点线性速度:不是简单地在世界坐标系下计算臀部位置差分。代码里先计算了差分,然后用根节点的旋转
-
局部关节位置与速度:在根节点特征之外,我们还需要细节。
ric_data:这是除根节点外,所有关节点的局部坐标(相对于根节点,并且已经过“转正”)。它去除了全局平移和旋转,只保留姿态信息。local_vel:关节点的局部速度。同样是在局部坐标系下计算,更能反映肢体自身的运动模式。
-
脚部接触(Foot Contact):这是DeepPhase思想的一个体现,也是让生成动作不“脚滑”的灵魂。代码里通过计算脚部关节(如脚踝、脚趾)的速度大小,并与一个经验阈值比较,来判断脚是否接触地面。这是一个二值特征(0或1),但它提供了至关重要的物理约束信号。在后续的IK(逆向运动学)处理或损失函数中,这个信号可以强制脚在接触期保持静止。
这些特征最终被拼接成如下的数据结构,成为模型的训练目标:
# 特征维度示意
# B: batch大小, seq_len: 序列长度
‘root_linear_velocity‘: (B, seq_len, 2) # 根节点局部线性速度 (x,z)
‘root_angular_velocity‘: (B, seq_len, 1) # 根节点角速度 (y轴旋转)
‘root_y‘: (B, seq_len, 1) # 根节点高度 (y坐标)
‘ric_data‘: (B, seq_len, (joint_num-1)*3) # 局部关节位置
‘rot_data‘: (B, seq_len, (joint_num-1)*6) # 关节旋转(6D表示)
‘local_velocity‘: (B, seq_len, joint_num*3) # 局部关节速度
‘foot_contact‘: (B, seq_len, 4) # 脚部接触状态(例如左脚跟、左脚尖、右脚跟、右脚尖)
3. 网络核心:Motion Latent Diffusion Model 架构拆解
数据准备好后,就该看看模型如何消化并生成它们了。运动潜在扩散模型的核心思想是:不直接在复杂的动作数据上添加噪声去噪,而是先用一个VAE(变分自编码器)把动作压缩到一个低维、平滑的潜在空间,然后在潜在空间里进行扩散和去噪。这样做大大降低了学习难度,提高了生成效率和稳定性。
3.1 动作编码器:MldVae 如何压缩动作
关键文件在 mld/models/architectures/mld_vae.py。这个VAE由编码器(Encoder)和解码器(Decoder)组成。
- 编码器(Encoder):输入是我们上一节计算出的那个长长的运动特征向量。它通常由多个
1D卷积层、残差连接和注意力层组成。为什么用1D卷积?因为动作是时间序列数据,1D卷积能很好地捕捉短时依赖关系(比如抬脚后必然落脚)。编码器的最终输出是两个向量:均值(mu)和对数方差(log_var),这代表了潜在空间中一个高斯分布的参数。通过“重参数化技巧”,从这个分布中采样,就得到了动作的潜在编码(latent code),维度通常是256,远小于原始特征。 - 解码器(Decoder):它的任务是把采样得到的潜在编码,还原成原始维度的运动特征。结构上与编码器对称。训练VAE时,损失函数包含两部分:重建损失(让解码出的特征尽可能接近输入)和KL散度损失(让潜在空间的分布接近标准正态分布,保证其规整性和插值性)。
我实测下来,VAE的重建质量至关重要。如果VAE重建的动作都有明显失真或脚滑,那后续扩散模型学得再好也白搭。一个训练技巧是,可以先用较大的重建损失权重,确保VAE学会“记住”动作;后期再逐步增加KL散度的权重,让潜在空间更平滑。
3.2 文本编码器:让模型听懂你的话
要让模型根据文本生成动作,就必须把文本也变成向量。这部分在 mld/models/architectures/mld_clip.py 的 MldTextEncoder 中实现。它通常基于预训练的语言模型(如CLIP的文本编码器或BERT)。
# 简化流程示意
from transformers import AutoTokenizer, AutoModel
class MldTextEncoder(nn.Module):
def __init__(self, model_name=‘bert-base-uncased‘):
super().__init__()
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModel.from_pretrained(model_name)
def forward(self, text_list):
# 1. 分词
inputs = self.tokenizer(text_list, return_tensors=‘pt‘, padding=True, truncation=True)
# 2. 通过预训练模型获取词嵌入
outputs = self.model(**inputs)
# 3. 通常取[CLS]标记的隐藏状态作为整个句子的表示
text_embeddings = outputs.last_hidden_state[:, 0, :] # [batch_size, hidden_dim]
return text_embeddings
得到的 text_embeddings(例如768维)会被投影到与动作潜在空间相匹配的维度,然后与扩散模型中的时间步嵌入拼接,作为条件信息输入。这里有个细节:在 mld_denoiser 中,时间嵌入和文本嵌入拼接后,有时会只取前一部分参与计算,这是一种常见的特征融合与降维策略,目的是防止条件信息过强而淹没掉动作本身的生成。
3.3 扩散与去噪:在潜在空间中的“绘画”过程
这是最核心的生成部分,位于 mld/models/architectures/mld.py。扩散模型的工作分为两个过程:
- 前向扩散过程:这是一个固定的、逐步添加噪声的过程。对于VAE编码后的潜在动作
z_0,我们逐步添加高斯噪声,经过T步后,它会变成一个纯噪声z_T。这个过程在训练时可以用一个公式直接计算任意步的噪声状态,不需要循环。 - 反向去噪过程:这是模型要学习的。它训练一个去噪网络(通常是U-Net结构),输入是第
t步的带噪声潜在z_t、时间步t和文本条件c,目标是预测出添加到z_t中的噪声ε。一旦这个网络训练好,我们就可以从纯噪声z_T开始,一步步预测并减去噪声,最终得到干净的潜在动作z_0。
这个去噪网络的设计很有讲究。它需要处理时间序列数据,所以主干是1D U-Net,包含下采样和上采样块,中间还有注意力层来融合文本条件。文本条件 c 通常通过交叉注意力(Cross-Attention)机制注入到U-Net的中间层,让模型在去噪的每一步都能“看到”文本描述。
4. 逆向运动学(IK)与后处理:从数据到可驱动骨骼
模型输出了运动特征,但这还不是最终可用的3D骨骼动画。我们需要通过逆向运动学(IK) 将这些特征(特别是局部关节位置 ric_data)转换回骨骼的旋转角度,并处理脚部接触等物理约束。原始文章和代码中提到了两套IK实现,都值得研究。
4.1 基于标准姿态的IK计算
在 skeleton.py 的 inverse_kinematics_np 函数中,实现了一种基于标准T-Pose偏移的IK方法。它的思路很清晰:
- 计算全局朝向:首先,根据臀部(根节点)和肩膀的位置,估算出身体的“正面”方向。这个计算方式对于站立行走等动作有效,但对于躺下、倒立等姿势可能不准,这是该实现的一个局限性。
- 计算根节点旋转:计算从第一步得到的“正面”方向,到世界坐标系标准前方
[0,0,1]的旋转四元数。这个旋转就是根节点(臀部)的全局旋转。 - 计算局部关节旋转:这是核心。已知:
t2m_raw_offsets:标准T-Pose下,每个子关节相对于父关节的偏移向量(方向+长度)。- 当前帧的关节全局位置(从
ric_data和根节点位置恢复而来)。 - 当前帧的关节局部位置(通过减去父关节的全局位置并除以父关节的全局旋转得到)。
那么,局部旋转就是:让标准偏移向量
t2m_raw_offsets,旋转到与实际局部位置向量对齐所需要的旋转。这个旋转就是该关节相对于父关节的局部旋转系数,也就是最终要存储的quat_params。
这种方法本质上是基于稠密关节点位置的IK。它的优点是直接、快速,因为每个关节的位置都是已知的。但缺点是对关节位置误差敏感,且没有考虑骨骼长度约束(因为位置是给定的)。
4.2 DeepPhase中的迭代优化式IK
在DeepPhase的Unity实现中(Assets/Scripts/Tools/UltimateIK/UltimateIK.cs),我们看到了另一种更经典、更灵活的IK解法。它常用于游戏动画中,将手、脚等末端效应器定位到目标位置。
它的核心函数 Optimise 采用了一种从根节点向末端递归优化的策略:
void Optimise(Joint joint) {
if(joint.Active) {
Vector3 pos = joint.Transform.position;
Quaternion rot = joint.Transform.rotation;
Vector3 forward = Vector3.zero;
Vector3 up = Vector3.zero;
int count = 0;
// 遍历该关节影响的所有目标(通常是末端,如手、脚)
foreach(int index in joint.Objectives) {
Objective o = Objectives[index];
if(o.Active && o.SolvePosition) {
// 关键计算:计算一个旋转,使得从当前关节到末端关节的向量,转向到目标方向
Quaternion q = Quaternion.Slerp(
rot,
Quaternion.FromToRotation(Joints[o.Joint].Transform.position - pos, o.TargetPosition - pos) * rot,
GetWeight(joint, o) // 权重,控制调整幅度
);
forward += q * Vector3.forward;
up += q * Vector3.up;
count += 1;
}
}
if(count > 0) {
// 综合所有目标的影响,更新当前关节的旋转
joint.Transform.rotation = Quaternion.LookRotation((forward/count).normalized, (up/count).normalized);
}
}
// 递归优化子关节
foreach(int index in joint.Childs) {
Optimise(Joints[index]);
}
}
这种方法的优点是:
- 目标驱动:只关心末端位置是否到达目标,不关心中间关节具体路径,更符合“脚踩地”、“手抓物”这类任务。
- 可加权融合:通过
GetWeight函数,可以控制不同关节的调整幅度(例如,靠近躯干的关节调整小一些,更自然)。 - 易于结合脚部接触:正如代码中
ProcessFootIK所示,可以将计算出的foot_contact二值信号作为权重,在目标位置(地面固定点)和上一帧位置之间插值。当脚接触地面时(contact=1),目标位置完全锁定在地面点;当脚抬起时(contact=0),目标位置由网络生成的轨迹自由决定。这是解决“脚滑”问题的关键后处理步骤。
在实际项目中,我通常采用混合策略:先用第一种方法从网络输出快速解算出一个初始的旋转序列,然后对脚部等关键末端,再用第二种迭代IK方法,以 foot_contact 为约束进行微调。这样既能保证整体效率,又能获得物理上合理的足部效果。
5. 训练、推理与Blender渲染全流程
理解了各个模块,我们最后把它们串起来,看看从训练到最终看到动画的完整流程。
5.1 训练流程与损失函数
训练分为两个主要阶段:
- VAE训练:使用HumanML3D数据,输入运动特征,训练编码器和解码器。损失是重建损失和KL散度的加权和。
- 扩散模型训练:固定训练好的VAE编码器。对于每个训练样本:
- 用VAE编码器得到潜在动作
z_0。 - 随机采样一个时间步
t(1到T)。 - 根据公式计算
z_t(即z_0加上了t步噪声后的状态)。 - 将
z_t、时间步t的嵌入、文本条件嵌入一起输入去噪U-Net。 - 计算U-Net预测的噪声与真实添加噪声之间的均方误差(MSE)。这就是扩散模型的核心损失。
- 用VAE编码器得到潜在动作
在 models/losses/utils.py 中,你可能会看到更复杂的损失组合,比如对速度、脚部接触的额外约束损失,这些都是为了进一步提升生成动作的物理真实性和与文本的对齐度。
5.2 推理(生成)流程
当模型训练完成后,我们就可以用文本来生成动作了:
- 文本编码:输入描述文本(如“一个人慢慢走路”),通过文本编码器得到条件向量
c。 - 潜在空间采样:从标准正态分布中采样一个随机噪声
z_T。 - 迭代去噪:对于
t从 T 到 1:- 将当前的
z_t、时间步t和条件c输入训练好的去噪U-Net。 - U-Net预测出噪声
ε_θ。 - 根据扩散模型的采样公式(如DDPM或DDIM),计算
z_{t-1}。
- 将当前的
- VAE解码:经过T步去噪后,得到干净的潜在动作
z_0。将其输入VAE的解码器,得到重建的运动特征向量。 - 后处理与IK:
- 调用
recover_from_ric等函数,将运动特征(局部坐标、速度等)还原为全局3D关节位置序列。 - 应用IK算法(如4.2节所述),将关节位置转换为骨骼旋转序列,并利用
foot_contact信息进行脚部锁定修正。
- 调用
- 输出:最终得到每一帧每个关节的旋转四元数,可以保存为FBX、BVH等标准动画格式,或直接用于游戏引擎。
5.3 使用Blender进行可视化渲染
代码库中通常包含用Blender进行渲染的脚本(如 mld/render/blender/render.py)。它的原理并不复杂:
- 导入骨骼数据:将生成的关节旋转数据(或位置数据)导入Blender。
- 创建骨架:在Blender中创建一个对应关节层级的骨架(Armature)。
- 关键帧绑定:将每一帧的关节数据(旋转或位置)设置为对应骨骼的变换属性,并插入关键帧。
- 网格蒙皮与渲染:可以创建一个简单的人体网格(如圆柱和球体拼接的简模),将其绑定到骨架上(蒙皮)。然后,Blender会在每一帧根据骨骼驱动网格变形,并渲染出图像或视频序列。
这个流程虽然自动化,但在调试时非常有用。你可以直观地看到生成动作的质量,检查脚滑、关节扭曲等问题。我在实际使用中,经常需要根据Blender渲染的结果,回头调整IK权重或损失函数,这是一个迭代优化的过程。
整个项目通过 OmegaConf 库来管理配置,这非常方便。你可以在一个YAML文件里定义模型结构、训练参数、数据路径等所有设置,代码通过 $ 引用这些值,使得实验管理和复现变得清晰。从数据预处理到模型训练,再到IK后处理和可视化,这条流水线上的每一个环节都充满了工程细节和调优技巧,希望这次的代码解析能帮你打通任督二脉,在实际动手时少走些弯路。
49



被折叠的 条评论
为什么被折叠?



