简介:本资源面向希望在人像动画生成方向落地的开发者与算法工程师,提供一套基于onnxruntime推理的LivePortrait部署程序,同时给出C++与Python两套实现,便于在桌面端或工程环境中集成。压缩包共14个文件,约459KB,包含4个cpp源文件、3个头文件、2个Python脚本,以及CMakeLists.txt、README.md说明文档,另附示例图片、模板与演示视频,覆盖模型推理、人脸分析与裁剪等核心模块。已有204人学习下载,说明该方案在轻量化部署场景中具备一定参考价值。读者可借此了解onnxruntime加载LivePortrait模型的完整流程,对照C++与Python两种调用方式,掌握人脸检测、关键点裁剪与动画生成的衔接逻辑,并参考CMake构建配置快速搭建可运行工程,减少从零摸索的排错成本。
1. 从一张照片到一段表情:LivePortrait 在本地跑起来到底难在哪
手里有一张人像照片,想让它的眼睛眨起来、嘴角动起来,甚至跟着一段驱动视频做出同步表情——这是 LivePortrait 这类人像动画方案最直接的诉求。它和早期那种靠关键点硬拽的换脸不同,走的是隐式关键点加形变迁移的路线,生成结果更自然,尤其是嘴唇和眼周的细节。但真正落到工程里,问题往往不在模型本身,而在部署:PyTorch 训练出来的权重怎么转成 ONNX、onnxruntime 的 C++ 和 Python 两套接口怎么调、动态输入尺寸怎么处理、显存和内存怎么控。标题里点名的 onnxruntime 部署,本质就是把这些环节从「能跑通 demo」推进到「能嵌进自己的程序里」。这篇笔记面向的是已经拿到模型、想用 C++ 或 Python 把它接进实际项目的工程师,也适合刚入门、想搞清楚推理框架和模型文件之间关系的新手。下面按「先立住原理、再动手复现、最后避坑」的顺序讲。
2. 为什么选 onnxruntime 而不是直接上 PyTorch:部署链路的取舍
2.1 推理框架选型的三个硬指标
把 LivePortrait 从研究代码变成产品功能,第一道坎就是推理框架。PyTorch 在训练和实验阶段无可替代,但部署时它带来的依赖体积、启动开销和跨平台编译复杂度,往往让 C++ 项目组头疼。onnxruntime 的核心价值在于:模型被固化成 ONNX 格式后,推理只依赖一个相对轻量的运行时,C++ 侧不需要链接完整的 libtorch,Python 侧也不需要每次 import 庞大的 torch 包。
选型时我一般看三个指标。第一是跨语言一致性,同一份 ONNX 文件,Python 调出来的数值和 C++ 调出来的必须对齐,否则调试会变成玄学。第二是算子覆盖,LivePortrait 里涉及大量的卷积、插值、grid_sample 类操作,要确认目标 opset 版本下 onnxruntime 都支持。第三是动态维度支持,人像动画的输入分辨率经常要按业务调整,如果模型导出时把宽高写死,后面改尺寸就得重新导出。
提示:ONNX 的 opset 版本不是越高越好,要和 onnxruntime 版本匹配。导出时先用
onnxruntime自带的工具验证一遍算子,再进 C++ 集成。
2.2 把 PyTorch 权重导出成 ONNX 的关键参数
导出这一步决定了后面 C++ 和 Python 能不能共用同一份模型。LivePortrait 通常拆成多个子模块,比如外观提取、运动提取、形变网络、生成器,导出时要逐个处理,不能指望一个脚本全包。下面是一个通用的导出骨架,实际使用时把模型类替换成对应的子模块。
import torch import torch.onnx # 假设 model 是已经加载好权重的 LivePortrait 子模块 model.eval() # 构造与真实输入一致的 dummy 输入,注意 batch 和通道顺序 dummy_input = torch.randn(1, 3, 256, 256) torch.onnx.export( model, dummy_input, "liveportrait_submodule.onnx", export_params=True, # 把权重一起写进文件 opset_version=17, # 与 onnxruntime 版本匹配 do_constant_folding=True, # 常量折叠,减小图体积 input_names=["input"], output_names=["output"], dynamic_axes={ # 声明动态维度,方便改分辨率 "input": {0: "batch", 2: "height", 3: "width"}, "output": {0: "batch", 2: "height", 3: "width"}, }, )这段代码里,opset_version=17是当前比较稳妥的选择,覆盖了大部分现代算子。dynamic_axes把 batch、height、width 标成动态,导出后模型不会因为输入尺寸变化而报错。do_constant_folding会把能提前算的常量合并,减少推理时的计算量。导出完成后,别急着写 C++,先用 Python 的 onnxruntime 跑一遍,确认输出和 PyTorch 原模型对得上,误差在 1e-4 量级以内才算过关。
2.3 Python 侧最小验证:三行代码确认模型可用
Python 在这里的角色不只是训练,它是最快的验证工具。装好 onnxruntime 后,用几行代码就能确认模型文件是否健康。
import onnxruntime as ort import numpy as np # 指定 CPU 或 GPU,GPU 需要 onnxruntime-gpu 包 sess = ort.InferenceSession("liveportrait_submodule.onnx", providers=["CPUExecutionProvider"]) # 查看输入输出名称和形状,C++ 侧要对齐这些名字 for i in sess.get_inputs(): print(i.name, i.shape, i.type) for o in sess.get_outputs(): print(o.name, o.shape, o.type) # 构造输入并推理 input_name = sess.get_inputs()[0].name dummy = np.random.randn(1, 3, 256, 256).astype(np.float32) result = sess.run(None, {input_name: dummy}) print(result[0].shape)providers参数决定用 CPU 还是 GPU,C++ 侧也有对应的配置。get_inputs和get_outputs打印出来的名字,就是后面 C++ 里GetInputNameAllocated要用的字符串,名字对不上会直接抛异常。sess.run的第二个参数是字典,键必须和模型输入名完全一致。这一步跑通,说明 ONNX 文件本身没问题,接下来才是 C++ 集成的硬仗。
3. C++ 侧集成 onnxruntime:从环境配置到推理封装
3.1 Windows 下编译环境与依赖的坑
C++ 调 onnxruntime,第一步不是写代码,是把环境搭对。Windows 上最常见的问题是运行时库缺失,程序启动就报找不到onnxruntime.dll或者vcruntime140.dll。onnxruntime 的官方发布包分 CPU 和 GPU 版本,下载后解压,目录里有include、lib、bin三部分。编译时头文件路径指向include,链接库指向lib下的onnxruntime.lib,运行时把bin里的 dll 放到可执行文件旁边。
Visual Studio 项目里,除了链接 onnxruntime,还要确保 C++ 运行时库版本一致。如果报Microsoft Visual C++ 2015-2022 Redistributable相关的错误,装一遍对应的 x64 运行库通常能解决。另一个高频翻车点是 Debug 和 Release 混用:onnxruntime 的 lib 分 Debug 和 Release,项目配置必须匹配,否则会出现链接错误或者运行时崩溃。
注意:onnxruntime 的 GPU 版本对 CUDA 和 cuDNN 版本有严格要求,版本不匹配时不会报编译错误,而是推理时直接失败或结果异常。装之前先查对应版本的依赖矩阵。
3.2 用 C++ 加载模型并跑通一次推理
环境就绪后,C++ 的推理流程可以拆成四步:创建环境、创建会话、准备输入、执行推理。下面是一个最小可运行示例,重点看每一步的参数含义。
#include <onnxruntime_cxx_api.h> #include <vector> #include <iostream> int main() { // 1. 创建环境,日志级别设为 WARNING 减少输出 Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "liveportrait"); // 2. 会话选项,可设置线程数 Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); // 3. 加载模型,宽字符路径在 Windows 上更稳 const wchar_t* model_path = L"liveportrait_submodule.onnx"; Ort::Session session(env, model_path, session_options); // 4. 准备输入,形状要和导出时一致 std::vector<int64_t> input_shape = {1, 3, 256, 256}; std::vector<float> input_data(1 * 3 * 256 * 256, 0.5f); auto memory_info = Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor = Ort::Value::CreateTensor<float>( memory_info, input_data.data(), input_data.size(), input_shape.data(), input_shape.size()); // 5. 输入输出名要和 Python 侧打印的一致 const char* input_names[] = {"input"}; const char* output_names[] = {"output"}; auto outputs = session.Run(Ort::RunOptions{nullptr}, input_names, &input_tensor, 1, output_names, 1); // 6. 读取输出 float* out = outputs[0].GetTensorMutableData<float>(); auto out_shape = outputs[0].GetTensorTypeAndShapeInfo().GetShape(); std::cout << "output dim: " << out_shape.size() << std::endl; return 0; }Ort::Env全局只需要一个,多个会话共享。SetIntraOpNumThreads控制单算子内部并行线程数,CPU 推理时调到物理核心数附近比较合适。ORT_ENABLE_ALL会启用图优化,包括算子融合,能明显提速。输入张量的形状必须和导出时的dynamic_axes声明兼容,如果导出时宽高是动态的,这里可以传不同尺寸。session.Run的输入输出名是 C 字符串数组,数量要匹配。输出用GetTensorMutableData拿到指针,再配合GetTensorTypeAndShapeInfo读形状,后续做后处理。
3.3 把推理封装成可复用的类
实际项目里不会把推理代码散在 main 里,通常封装成一个类,管理会话生命周期和输入输出缓冲。下面是一个简化版封装,重点在资源管理和异常处理。
class LivePortraitSession { public: LivePortraitSession(const std::wstring& model_path) { Ort::SessionOptions opts; opts.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); session_ = std::make_unique<Ort::Session>(env_, model_path.c_str(), opts); } std::vector<float> Infer(const std::vector<float>& input, const std::vector<int64_t>& shape) { auto mem = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value tensor = Ort::Value::CreateTensor<float>( mem, const_cast<float*>(input.data()), input.size(), shape.data(), shape.size()); const char* in_names[] = {"input"}; const char* out_names[] = {"output"}; auto outputs = session_->Run(Ort::RunOptions{nullptr}, in_names, &tensor, 1, out_names, 1); float* data = outputs[0].GetTensorMutableData<float>(); auto info = outputs[0].GetTensorTypeAndShapeInfo(); size_t count = info.GetElementCount(); return std::vector<float>(data, data + count); } private: Ort::Env env_{ORT_LOGGING_LEVEL_WARNING, "liveportrait"}; std::unique_ptr<Ort::Session> session_; };这个类把环境、会话、推理都收在一起,构造时加载模型,Infer接收输入数据和形状,返回输出向量。const_cast是因为 onnxruntime 的接口要求非 const 指针,但实际不会修改输入。GetElementCount拿到输出元素总数,方便构造返回向量。封装之后,上层业务只需要关心输入输出的语义,不用碰 onnxruntime 的 API 细节。如果要做 GPU 推理,把SessionOptions换成OrtCUDAProviderOptions并 append 到 options 里即可,其余代码基本不变。
4. 动态输入与多模块串联:LivePortrait 推理链路的工程化
4.1 动态分辨率下的输入预处理
LivePortrait 的输入往往不是固定 256x256,而是根据人脸检测框裁剪出来的区域,尺寸随视频帧变化。ONNX 导出时声明了动态维度,C++ 侧就要按实际尺寸构造张量。预处理包括 resize、归一化、通道顺序调整,这些操作在 C++ 里没有 Python 的 PIL 和 numpy 那么顺手,通常用 OpenCV 完成。
#include <opencv2/opencv.hpp> std::vector<float> Preprocess(const cv::Mat& face_bgr, int target_h, int target_w) { cv::Mat resized; cv::resize(face_bgr, resized, cv::Size(target_w, target_h)); cv::Mat rgb; cv::cvtColor(resized, rgb, cv::COLOR_BGR2RGB); rgb.convertTo(rgb, CV_32FC3, 1.0 / 255.0); // 归一化到 0-1 // HWC 转 CHW,并展平 std::vector<float> tensor(3 * target_h * target_w); std::vector<cv::Mat> channels(3); cv::split(rgb, channels); for (int c = 0; c < 3; ++c) { std::memcpy(tensor.data() + c * target_h * target_w, channels[c].ptr<float>(), target_h * target_w * sizeof(float)); } return tensor; }cv::resize把裁剪区域缩放到目标尺寸,cvtColor转成 RGB,因为多数模型训练时用的是 RGB。convertTo做归一化,缩放因子 1/255。split把 HWC 拆成三个通道,再按 CHW 顺序拷贝到连续内存。这里的目标尺寸要和模型动态维度允许的范围一致,太小会丢细节,太大显存吃紧。如果模型对输入做了均值方差归一化,这里还要减去均值、除以标准差,具体数值看训练时的配置。
4.2 多模块串联时的数据流管理
LivePortrait 不是单个模型,而是一条链路:外观提取、运动提取、形变、生成。每个模块都是一个 ONNX 文件,C++ 侧要按顺序调用,中间结果在内存里传递。这里最容易出问题的是形状对不上和数值范围漂移。
| 模块 | 输入 | 输出 | 常见问题 |
|---|---|---|---|
| 外观提取 | 源图 3xHxW | 特征向量 | 归一化参数不一致 |
| 运动提取 | 驱动图 3xHxW | 隐式关键点 | 关键点顺序错乱 |
| 形变网络 | 特征+关键点 | 形变场 | 尺寸不匹配 |
| 生成器 | 形变场+特征 | 输出图 3xHxW | 输出范围未截断 |
串联时,每个模块的输出先转成std::vector<float>,再作为下一个模块的输入。形状信息要显式传递,不能靠猜。如果某个模块输出是 4D 张量,下一个模块期望 3D,中间要做 squeeze 或 reshape。数值范围也要检查,比如生成器输出可能在 0-1 之外,后处理时要 clamp 到合法区间,否则保存出来的图会出现异常像素。
提示:多模块串联时,建议每个模块单独用 Python 验证一遍输入输出,记录形状和数值范围,再在 C++ 里对齐。跳过这一步,后面调试会非常痛苦。
4.3 性能与内存:CPU 和 GPU 的取舍
CPU 推理胜在部署简单,不需要 CUDA 环境,适合边缘设备或者对延迟不敏感的场景。GPU 推理速度快,但依赖多、显存占用高。LivePortrait 的生成器部分计算量最大,如果整条链路都放 CPU,单帧可能要几百毫秒甚至更久;放 GPU 后能降到几十毫秒级别。
实际选择时,我一般先测 CPU 版本的端到端延迟,如果满足业务要求就用 CPU,省去 GPU 环境的维护成本。如果必须上 GPU,注意显存分配:多个会话可以共享同一个Ort::Env,但每个会话有自己的显存池。输入分辨率越大,显存占用越高,动态尺寸下要留足余量。另外,GPU 推理的第一次调用会有初始化开销,测延迟时要跑几轮取稳定值,别被第一帧的数据误导。
5. 部署 LivePortrait 时最容易翻车的五个地方
5.1 现象:Python 推理正常,C++ 结果全黑或全白
原因通常出在输入数据的布局或归一化上。Python 侧用 numpy 构造输入时,形状是 NCHW,数值范围 0-1;C++ 侧如果忘了转通道顺序,或者归一化因子写错,模型收到的就是错误分布的数据,输出自然异常。
解决方法是把 Python 和 C++ 的输入张量都 dump 出来,逐元素对比。先确认形状一致,再确认前几个数值一致。如果 C++ 用的是 OpenCV 读图,注意 OpenCV 默认 BGR,模型要 RGB,转换不能漏。归一化参数也要和训练配置对齐,别凭感觉写。
5.2 现象:模型加载报错,提示找不到输入名
原因是 C++ 里硬编码的输入名和 ONNX 文件里的实际名字不一致。导出时如果没指定input_names,ONNX 会自动生成类似input.1的名字,和代码里写的input对不上。
解决办法是在 Python 侧用sess.get_inputs()打印真实名字,然后同步到 C++。更稳妥的做法是把输入输出名做成配置项,而不是写死在代码里。如果模型有多个输入,顺序也要和session.Run里的数组顺序一致。
5.3 现象:动态尺寸输入时报维度错误
原因是导出 ONNX 时没有正确声明dynamic_axes,或者 C++ 侧构造张量时形状数组和实际数据量不匹配。比如声明了动态宽高,但传入的形状还是固定值,或者数据长度和形状乘积对不上。
解决方法是先确认 ONNX 模型的输入形状里哪些维度是字符串(动态)哪些是数字(固定)。C++ 侧构造Ort::Value时,形状数组的乘积必须等于数据元素个数,否则会抛异常。动态维度可以传不同值,但不能超出模型支持的范围。
5.4 现象:GPU 推理结果和 CPU 不一致
原因是浮点运算在 GPU 和 CPU 上的精度差异,或者 GPU 版本 onnxruntime 的算子实现有细微不同。多数情况下差异很小,但如果后处理对数值敏感,就可能放大。
解决办法是统一推理设备,别混用。如果必须对比,用相同的输入跑两边,统计最大绝对误差,通常在 1e-3 以内可以接受。超过这个量级就要查是不是某个算子在不同 provider 下行为不同,必要时把该算子固定到 CPU 执行。
5.5 现象:程序运行一段时间后内存持续增长
原因是每次推理都创建新的Ort::Value和输出向量,没有及时释放,或者会话对象被反复创建。onnxruntime 的内部有内存池,但外部缓冲需要自己管理。
解决办法是把会话做成单例,复用同一个Ort::Session。输入输出缓冲尽量复用,避免每帧都分配大块内存。如果用了 GPU,注意Ort::MemoryInfo的配置,GPU 和 CPU 之间的拷贝也要控制频率。用任务管理器或性能分析工具观察内存曲线,确认没有泄漏。
6. 用 Python 做回归验证:让 C++ 部署不再靠猜
C++ 调试成本高,一个崩溃可能要查半天。我的习惯是先用 Python 把整条链路跑通,保存每一阶段的输入输出作为基准,C++ 实现后再用同样的输入去比对。这样能把问题定位到具体模块,而不是在 C++ 里盲猜。
具体做法是写一个 Python 脚本,加载 ONNX 模型,用固定随机种子生成输入,跑一遍推理,把输入和输出都存成.npy文件。C++ 侧读取同样的输入文件,跑推理,把输出存下来,再用 Python 对比两组输出。
import numpy as np import onnxruntime as ort sess = ort.InferenceSession("liveportrait_submodule.onnx", providers=["CPUExecutionProvider"]) np.random.seed(42) dummy = np.random.randn(1, 3, 256, 256).astype(np.float32) np.save("input.npy", dummy) result = sess.run(None, {"input": dummy})[0] np.save("output_python.npy", result) # C++ 跑完后读取对比 cpp_result = np.load("output_cpp.npy") diff = np.abs(result - cpp_result) print("max diff:", diff.max(), "mean diff:", diff.mean())这个脚本的关键是固定随机种子,保证每次生成的输入一致。np.save把数组存成二进制,C++ 侧可以用简单的文件读取解析,或者用现成的 npy 解析库。对比时看最大绝对误差和平均误差,如果最大误差在 1e-4 以内,说明 C++ 实现正确;如果某个区域误差特别大,就去查对应的预处理或后处理步骤。
这套方法看起来多写了几行代码,但省下的调试时间远超投入。我踩过的坑里,有一半是因为 C++ 和 Python 的输入不一致导致的,有了基准对比,这类问题几分钟就能定位。另一个习惯是把每个模块的输入输出形状和数值范围都打印出来,形成一份「链路档案」,换模型或者改分辨率时对照检查,能提前发现不兼容。
部署 LivePortrait 这类多模块方案,最怕的不是某个 API 不会用,而是链路太长、问题藏得深。把 Python 当验证工具,把 C++ 当最终交付,中间用数据对齐,是我目前觉得最稳的路子。希望帮到你。
本文还有配套的精品资源,点击获取