一张照片加一个摄像头,实时驱动照片里的人跟着你做表情、转头,输出能直接推流。这件事的难点不在效果,在帧率——扩散模型生成一帧要跑好几次 UNet,天生和实时是反的。
PersonaLive 是澳门大学、Dzine.ai 与大湾区大学 GVC Lab 的工作,CVPR2026 收录,代码和权重都放出来了。它给这个矛盾的答案很漂亮:不减少去噪步数,而是让不同新鲜度的帧共享同一次前向
这篇把它的流式去噪机制、三条条件通路、在线服务链路和 TensorRT 融合拆开记一遍。

PersonaLive 在线服务总览

它解决什么

肖像动画这条线上,质量和速度一直在打架。

GAN 系(LivePortrait 那一路)快,单帧几毫秒,但表情迁移的自然度和身份保真有上限
扩散系(AnimateAnyone、X-NeMo 那一路)质量高,但一帧要跑 N 次 UNet,N 通常 20 以上
把 N 压到 4 是常规操作(蒸馏),但 4 次前向摊到每帧还是太贵
更麻烦的是长度:视频扩散一般按固定长度的片段生成,直播要的是无限长且不能有拼接痕迹

PersonaLive 的定位是 real-time + streamable + infinite-length 三个词同时成立。核心手法下面单独说,先给它的规格:512×512、fp16、DDIM 4 步、ε 预测,timestep_spacingtrailing

流式金字塔去噪

这是整个项目最值得看的一处,也是「4 步却只跑 1 次前向」的来源。

流式金字塔去噪

常规 4 步 DDIM 是这样:拿一批帧,加噪到 t=999,跑一次 UNet 降到 666,再跑一次到 333,再跑一次到 0。同一批帧过了 4 次网络。
PersonaLive 把这个过程侧过来。它维护一个窗口队列 latents_pile(一个 deque),里面常驻 4 个窗口,每个窗口 4 帧,但每个窗口停在不同的噪声档上:

latents_pile   窗口A t=0    窗口B t=333   窗口C t=666   窗口D t=999
                 最老          ↑             ↑           最新(刚入队)

每轮做三件事:新来的 4 帧驱动条件加噪到 t=999 追加成新窗口;把 4 个窗口拼成一个 16 帧的批,跑一次 UNet3D,DDIM 让每个窗口按自己的 timestep 各前进一档;最老的那个窗口这一步正好走到 t=0,popleft 出来送 VAE 解码。

配置里就是这三个数:

temporal_adaptive_step: 4    # 4 个窗口
temporal_window_size: 4      # 每窗口 4 帧
num_inference_steps: 4       # 4 步

timestep 表和步长写死在 pipeline 里:

timesteps = torch.tensor([999, 666, 333, 0], device=device).long()
self.scheduler.set_step_length(333)

于是 4 步的计算量被摊平:一次前向 16 帧、吐出 4 帧成品,等效每帧 1/4 次 UNet 前向。而且队列长度恒定,输入 4 帧输出 4 帧,不随时间增长,这就是「无限长」的来路——没有片段边界,也就没有拼接痕迹。

出队时用的是 pred_original_sample 而不是最后一步的采样结果,所以送进 VAE 的 latent 里没有残余噪声。

首帧怎么办

队列刚开始是空的,直接跑会有 3 轮没有成品可吐,而且第一批画面会从参考姿态猛跳到驱动姿态。
它的处理是融合参考图时预填 12 帧阶梯噪声把队列一次填满,同时姿态和表情各做 12 步线性插值:

mot_bbox_param, kps_ref, kps_frame1, kps_dri = self.pose_encoder.interpolate_kps_online(
    self.ref_cond_tensor, tgt_cond_tensor, num_interp=12+1)
init_motion_hidden_states = self.interpolate_tensors(ref_motion, dri_motion[:,:1], num=12+1)[:,:-1]

从参考图的姿态平滑过渡到第一帧驱动姿态,所以开播瞬间不会抽一下。

抗身份漂移的补丁

流式生成跑久了,身份会往驱动者那边飘。它的对策是监控表情嵌入与历史的距离:

add_flag, motion_bank = self.calculate_dis(motion_bank, motion_hidden_state, threshold=17.)

最小 L2 距离超过 17 就把当前这帧干净图再过一次 Reference UNet,把写出的 16 层特征追加进 kv_bank,等于给注意力多一个「这个人长这样」的锚点。全程最多追加 3 次,否则 KV 会无限膨胀——这个上限还和后面 TensorRT 的动态维度档位对上了。

三条条件通路

身份、姿态、表情走三条完全分开的路,注入 UNet 的位置也各不相同。

UNet 条件注入路径

身份走注意力。参考图过 CLIP ViT 得到 1×768 嵌入走标准 cross-attention 提供全局语义;同时过 VAE 编码(乘 0.18215)喂给一个和去噪 UNet 同构的 Reference UNet2D,前向时把每层的 norm_hidden_states 存进 bank。去噪侧把这 16 层特征拼在自己的 KV 前面做空间自注意力——效果上等价于「一边看着参考图一边作画」。参考图只编码一次,整段直播都复用。

姿态走残差。MotionExtractor(convnextv2 主干)预测 21 个隐式 3D 关键点,以及 pitch / yaw / roll / 平移 / 尺度。重定向时保留参考图的规范关键点和尺度,只取驱动帧的旋转,平移按 0.5 缩放——所以你大幅晃头时照片里的人跟着转,但不会跟着你把脸怼到镜头上。然后从 21 点里挑 7 点画成彩色圆点图,PoseGuider(输出层零初始化)卷成 320 通道,加到 conv_in 的输出上。

表情走交叉注意力。按关键点算出的框裁一张 224² 人脸,和全局姿态解耦,过 MotEncoder(FAN 沙漏网络)出 512 维,reshape 成 32 个 16 维 token 再加正弦位置编码。注意 motion_module 的配置:

cross_attention_dim: 16
attention_block_types: [Spatial_Cross, Spatial_Cross]
num_attention_heads: 8

交叉注意力的维度只有 16,注意力开销几乎可以忽略。表情单独走一路而不是和姿态揉在一起,是这套方案表情能做到「expressive」的关键——挑眉、眨眼、嘴型这些细节不会被头部大幅运动冲掉。

在线链路

在线实时推理时序

工程上分得很干净:FastAPI 主进程只做 WebSocket 协议和队列搬运,绝不碰 GPU;推理跑在 mp.set_start_method("spawn") 起的守护子进程里,两边用 mp.Queue 通信。

浏览器端是 SvelteKit,摄像头帧编码成 JPEG 走 WS 二进制推上来,驱动帧率 1–30 FPS 可调。服务端转张量归一化后入队。子进程侧凑够 4 帧才触发一次前向:

read_size = min(queue.qsize(), num_frames_needed * 3)

队列里超过需要的量时按等距索引抽样,宁可丢帧也不排队——这条决定了延迟不会随时间累积。README 里提到调延迟可以把这个乘数从 3 调大。

回传侧也限速。解码帧转 JPEG 进 asyncio 队列,推送协程用 EMA 估计帧间隔:

MIN_FPS = 10
MAX_FPS = 30
SMOOTHING = 0.8
...
ema_frame_interval = SMOOTHING * ema_frame_interval + (1 - SMOOTHING) * raw_interval
sleep_time = min(max(ema_frame_interval, 1 / MAX_FPS), 1 / MIN_FPS)

钳在 1/30 到 1/10 秒之间,队列满了丢最旧帧。所以延迟的下界很清楚:4 帧采集时间 + 1 次前向时间。

控制面上 pause 只清输入队列,reset 才会清 bank 并要求重新上传参考图——因为参考图的编码结果是整段直播的身份基础,换人必须重来。

TensorRT 融合

TensorRT 融合与运行期绑定

官方说转 TensorRT 大约快 2 倍,做法比「把 UNet 单独转一下」激进得多:PoseGuider、MotEncoder、UNet3D、DDIM 一步、VAE 解码,五个模块被包进同一个 nn.Module 前向,导出成一张图。

融合后的签名是 23 输入 6 输出:

inputs:  sample, encoder_hidden_states, motion_hidden_states, motion,
         pose_cond_fea, pose, new_noise,
         d00 d01 d10 d11 d20 d21 m u10 u11 u12 u20 u21 u22 u30 u31 u32
outputs: pred_video, latents, pose_cond_fea_out,
         motion_hidden_states_out, motion_out, latent_first

7 个常规输入加 16 个参考特征输入。注意这 16 个:PyTorch 版靠 Python 侧的 hook 把 Reference UNet 的特征塞进注意力,图里没法这么干,所以换成 16 个显式输入 d00…u32,对应 UNet 各层。这也是为什么仓库里有一个 unet_3d_explicit_reference.py

导出走 ONNX opset 17,再用 polygraphy 建 fp16 引擎。动态维度只留给那 16 个参考特征的序列长度,按 1x / 2x / 4x 设 min / opt / max——正好覆盖「历史关键帧最多追加 3 次」,前面那个上限在这里闭环了。其余输入固定形状。

运行期最漂亮的一手是 bind():把输出的显存地址直接设成下一轮对应输入的地址。滚动状态(latents、pose、motion)在显存里自环,零拷贝。整轮只有一次 host 调用,省掉逐算子的 Python 调度开销。

代价也写清楚了:建引擎约 20 分钟;仓库自带的引擎是 H100 上产的,所有人都建议本机重建;导出前要把 FAN_temporalpos_embed.requires_grad 置 False;TensorRT 优化会带来轻微画质波动。

三阶段训练

训练代码 2026-05-15 才放出来,8×H100 上大约 13h / 15h / 20h。

三阶段训练流程

Stage 1 图像级预热。随机采一个 timestep 加噪,网络预测 ε,换算回 x0 和真值算 latent MSE。额外对人脸、眼、嘴的掩码区域再算一遍 MSE 强化五官——数据预处理那步 tools/get_boxes.py 抽的就是这三种框。10% 概率走无条件前向,给分类器自由引导留空间。

Stage 2 四步蒸馏加对抗精修。时间表固定成 [999, 666, 333, 0],随机取 1~4 步自举,只对最后一步回传梯度。损失搬到像素域:MSE + 掩码 MSE + LPIPS×2 + StyleGAN2 对抗×0.05,判别器从 FFHQ512 预训练权重起步,和生成器交替更新。这一阶段才是让 4 步能出好图的地方。

Stage 3 时序模块微调。冻结 VAE、CLIP、两个 UNet、MotEncoder、PoseGuider,只训 temporal_modules。关键是训练里按 4×4 滑窗复现推理时的调度:窗口错位、逐步加噪、pred_x0 覆盖,让时序模块在训练时就见过推理时那种「一批里各帧噪声档不同」的分布。同时参考特征 bank 以 0.3 的比例随机丢 token,逼时序模块不要只依赖参考图。

时序模块不是从零开始,是从 X-NeMo 的权重初始化的。三个阶段之间的衔接要手改 yaml 指向上一阶段 exp_output 里的 checkpoint,没有自动串联。
另外注意推理时 stage1&2 和 stage3 用不同的 inference yaml,前者 use_temporal_module=false

跑起来

环境是 torch 2.1.0 + diffusers 0.27.0 + transformers 4.36.2 + xformers 0.0.22 这一套,python 3.10。

conda create -n personalive python=3.10 && conda activate personalive
pip install -r requirements_base.txt
python tools/download_weights.py

权重六个 .pthdenoising_unet / reference_unet / motion_encoder / motion_extractor / pose_guider / temporal_module,加上 sd-vae-ft-msesd-image-variations-diffusers 两个基座。

离线推理一条命令,流式策略默认开着,官方说 12GB 显存也能生成长视频:

python inference_offline.py

在线要先起 WebUI,然后:

python inference_online.py --acceleration none | xformers | tensorrt

浏览器开 http://localhost:7860,流程是上传图片 → 融合参考 → 开始动画。

RTX 50 系(Blackwell)注意:xformers 还没完全适配,要 --use_xformers False 或者 --acceleration none,否则会崩。

值得记的几点

抛开肖像动画这个具体领域,这套东西里有几个可迁移的想法:

  • 把「多步」侧过来变成「多流水级」。4 步去噪不是必须对同一批数据串行 4 次;让 4 批处于不同阶段的数据共享一次前向,吞吐立刻乘 4。这本质上是软件流水线,只是用在了扩散采样上。
  • 恒定队列长度就是无限长。每轮进 4 出 4,状态不增长,于是不需要「片段」这个概念,拼接痕迹这个问题直接消失。
  • 条件解耦要解到注入点。身份、姿态、表情不只是分开编码,而是分别注入自注意力 KV、conv_in 残差、交叉注意力三个不同位置。三者互不挤占容量。
  • 图优化要连调度器和解码器一起吞。只转 UNet 收益有限,把 DDIM 一步和 VAE 解码也融进同一张图,才省得下 host 往返;bind() 让滚动状态在显存自环,这一手很省。
  • 训练要复现推理时的分布。Stage 3 在训练里照抄推理的滑窗调度,是这类流式方案能不掉质量的前提。训练和推理的调度不一致,时序模块就学不到该学的东西。
  • 上限要和下游对齐。「历史关键帧最多 3 次」这个数字,同时是抗漂移的预算和 TensorRT 动态维度的 4x 档位。两处对上不是巧合,是设计。

局限

  • 学术研究用途授权,README 里写明不得用于生成有害、诽谤或违法内容,生成内容责任在使用者。这类技术的滥用风险是实打实的。
  • 延迟强依赖算力。下界是 4 帧采集加一次前向,但一次前向多快取决于卡;调低驱动 FPS 或放大抽帧乘数只是在降负载,不是在降下界。
  • TensorRT 那条路摩擦不小:pycuda 装不上要走 conda,Windows 上转引擎有专门的 issue 讨论,引擎还得本机重建,而且画质有轻微波动。
  • 三阶段训练之间靠手改 yaml 衔接,没有编排;8×H100 跑将近两天,复现门槛不低。

仓库在 GVCLab/PersonaLive,论文 arXiv:2512.11253,权重在 HuggingFace 和 ModelScope 都有。ComfyUI 也有社区实现。

读完最大的感受是,把扩散模型做到实时这件事,真正的杠杆不在模型也不在算子,在调度。同样 4 步、同样的网络,换个排法就从离线变成了直播。