简介:这份资源面向希望将LivePortrait人像动画生成能力落地到实际工程中的开发者,提供基于onnxruntime推理的完整部署程序,同时覆盖C++与Python两种实现路径,适合具备一定深度学习推理基础、需要跨语言集成或做性能对比的中高级开发者参考。压缩包共14个文件,约459KB,包含4个cpp与3个h源文件构成C++推理主流程,2个py脚本负责Python侧调用与裁剪工具,另有txt、md说明文档及mp4、jpg、png示例素材,便于快速理解输入输出格式。内容围绕人脸检测、特征裁剪与动画驱动等模块展开,C++部分以CMake组织工程,Python部分提供可直接运行的入口脚本,读者可据此搭建本地推理环境、对照两种语言实现差异,并借鉴其模型加载与前后处理思路。目前已有204人学习下载,适合作为人像动画部署的入门与迁移参考。
1. 从一张照片到一段表情:LivePortrait 在 onnxruntime 上的落地路径
手里只有一张人像照片,想让它的眼睛眨起来、嘴角动起来,甚至跟着一段驱动视频做出同步表情——这是很多做数字人、虚拟主播、在线教育的团队都会碰到的需求。LivePortrait 这类人像动画生成方案,核心思路是用一段驱动视频的表情和姿态去驱动一张静态人像,输出一段自然的口型与头部动作视频。但真正把它推到生产环境,绕不开两个现实问题:一是原始实现往往依赖 PyTorch 生态,部署到没有 GPU 或只有国产 CPU 的机器上很吃力;二是 C++ 和 Python 两套调用方式各有各的适用场景,选错了后面全是返工。
这篇笔记锁定的就是这条路径:用 onnxruntime 作为推理后端,把 LivePortrait 的人像动画生成能力部署起来,同时给出 C++ 和 Python 两种调用方式。适合已经跑通过 PyTorch 版本、想往工程化落地的同学,也适合需要在鲲鹏 920 这类 ARM 服务器上做推理的团队。下面从模型拆解、环境搭建、两种语言实现,一路讲到参数调优和踩坑记录,尽量让每一步都能照着复现。
2. 拆开 LivePortrait:哪些模块要转 ONNX,哪些可以留在外面
2.1 模型结构里真正需要 ONNX 化的三个部分
LivePortrait 的推理流程大致可以拆成外观提取、运动提取、形变与生成三段。外观提取网络负责从源图像里抽出身份和纹理特征,这部分输入输出形状固定,最适合转成 ONNX。运动提取网络处理驱动视频的每一帧,输出表情和姿态系数,它的输入是连续帧,转 ONNX 时要注意动态轴设置。生成网络(通常是 warping 加解码器)把外观特征和运动系数合成最终画面,这部分计算量最大,也是 onnxruntime 加速收益最明显的地方。
常见的做法是只把外观提取和生成网络转 ONNX,运动提取如果帧数不多,留在 Python 侧用原始实现跑,或者单独转一个小模型。这样做的原因是运动提取对时序敏感,ONNX 的动态 shape 支持虽然够用,但调试成本高。我一般会先把生成网络转出来,跑通端到端再回头补其他模块。
转 ONNX 时用 torch.onnx.export,重点设置 opset_version 和 dynamic_axes。opset 建议不低于 14,否则一些插值算子会退化成不支持的版本。dynamic_axes 要把 batch 维和序列长度维标出来,否则驱动视频换长度就要重新导出。
import torch import torch.onnx # 假设 generator 是已经加载好权重的生成网络 dummy_appearance = torch.randn(1, 256, 64, 64) dummy_motion = torch.randn(1, 21, 3) torch.onnx.export( generator, (dummy_appearance, dummy_motion), "liveportrait_generator.onnx", opset_version=14, input_names=["appearance", "motion"], output_names=["output_image"], dynamic_axes={ "appearance": {0: "batch"}, "motion": {0: "batch", 1: "seq_len"}, "output_image": {0: "batch"} }, do_constant_folding=True )这段代码里 do_constant_folding 打开后会把能提前算的常量折叠掉,减小模型体积。dynamic_axes 里 motion 的第二维标成 seq_len,是因为驱动视频长度不固定。导出后建议用 onnxruntime 的 Python 接口先加载一次,确认没有算子报错再往下走。
2.2 onnxruntime 和 onnx 的区别,以及为什么选 onnxruntime 做推理
很多人第一次接触会混淆 onnx 和 onnxruntime。onnx 是一种模型格式标准,定义的是计算图的表示方式;onnxruntime 是微软开源的推理引擎,负责把 ONNX 模型加载进来并在 CPU、GPU 或特定加速器上执行。你可以把 onnx 理解成 PDF 文件格式,onnxruntime 理解成 PDF 阅读器。导出模型用 onnx 相关工具,跑推理用 onnxruntime。
选 onnxruntime 的理由很直接:跨平台、依赖少、对 ARM 架构支持成熟。在鲲鹏 920 上部署时,onnxruntime 有预编译的 aarch64 版本,装完就能用,不需要额外编译 PyTorch。相比之下,直接部署 PyTorch 模型在 ARM 服务器上要么编译一整天,要么找不到匹配的 wheel 包。onnxruntime 还支持通过 execution provider 切换后端,CPU 上用默认的 MLAS,有 GPU 时切 CUDA 或 TensorRT,代码几乎不用改。
提示:onnxruntime 的版本要和导出模型时的 opset 匹配。opset 14 的模型用 onnxruntime 1.12 以上版本加载比较稳,版本太低会报不支持的算子。
2.3 环境搭建:Python 侧和 C++ 侧各装什么
Python 侧相对简单,pip 装 onnxruntime 和 onnx 就行。如果要在鲲鹏 920 上跑,注意选 aarch64 的 wheel,不要装成 x86 版本。C++ 侧需要下载 onnxruntime 的预编译库或者从源码编译,然后配置头文件路径和链接库。
# Python 侧 pip install onnxruntime onnx numpy opencv-python # 验证安装 python -c "import onnxruntime as ort; print(ort.get_available_providers())"C++ 侧在 Linux 上一般下载 onnxruntime-linux-x64 或 aarch64 的压缩包,解压后得到 include 和 lib 两个目录。编译时用 -I 指定 include 路径,-L 指定 lib 路径,链接 onnxruntime 库。Windows 上则用 Visual C++ 的工程配置,把附加包含目录和附加库目录指过去。注意 Microsoft Visual C++ Redistributable 要装好,否则运行时会缺 DLL。
# C++ 编译示例(Linux) g++ -std=c++17 main.cpp -o liveportrait_demo \ -I./onnxruntime/include \ -L./onnxruntime/lib \ -lonnxruntime \ -lopencv_core -lopencv_imgproc -lopencv_imgcodecs编译参数里 -std=c++17 是因为 onnxruntime 的 C++ API 用了一些 C++17 特性。OpenCV 用来做图像预处理和后处理,如果不想引入 OpenCV,也可以自己写简单的图像读写,但会麻烦不少。
3. Python 侧跑通 LivePortrait:从加载 ONNX 到输出第一帧动画
3.1 用 onnxruntime 加载模型并做一次前向推理
Python 侧的优势是调试快,适合先把整个流程跑通再移植到 C++。加载模型用 InferenceSession,指定 providers 为 CPUExecutionProvider 或 CUDAExecutionProvider。输入数据要转成 numpy 数组,注意数据类型和形状要和导出时一致。
import onnxruntime as ort import numpy as np import cv2 # 创建推理会话 session = ort.InferenceSession( "liveportrait_generator.onnx", providers=["CPUExecutionProvider"] ) # 查看输入输出信息 for inp in session.get_inputs(): print(f"输入名: {inp.name}, 形状: {inp.shape}, 类型: {inp.type}") # 准备输入数据 appearance = np.random.randn(1, 256, 64, 64).astype(np.float32) motion = np.random.randn(1, 21, 3).astype(np.float32) # 执行推理 outputs = session.run( ["output_image"], {"appearance": appearance, "motion": motion} ) result = outputs[0] print(f"输出形状: {result.shape}")session.run 的第一个参数是输出名列表,第二个是输入字典。输入名要和导出时设置的 input_names 一致,否则会报找不到输入。输出结果是一个列表,顺序和输出名列表对应。拿到结果后一般要做反归一化再转成图像。
3.2 图像预处理:把人脸对齐到模型需要的输入尺寸
LivePortrait 的输入不是随便一张照片就能用,需要先做人脸检测和对齐,裁出人脸区域再缩放到模型要求的尺寸。常见做法是用人脸关键点检测器找到眼睛、鼻子、嘴角的位置,然后做仿射变换把脸摆正。
def preprocess_face(image_path, target_size=(256, 256)): img = cv2.imread(image_path) # 这里假设已经用关键点检测拿到了人脸框和关键点 # 实际项目中可以用 mediapipe 或 insightface face_region = detect_and_align(img) face_resized = cv2.resize(face_region, target_size) face_normalized = face_resized.astype(np.float32) / 255.0 # 转成 NCHW 格式 face_input = np.transpose(face_normalized, (2, 0, 1)) face_input = np.expand_dims(face_input, axis=0) return face_input预处理里最容易翻车的是归一化方式。有的模型要求减均值除方差,有的只要求除以 255。这个必须和训练时保持一致,否则输出画面会偏色或者糊掉。如果不确定,可以先用一张已知正确的图片跑一遍,对比输出和预期。
3.3 驱动视频逐帧推理与结果拼接
驱动视频要逐帧提取运动系数,然后和源图像的外观特征一起送进生成网络。每一帧输出一张图,最后用视频编码器拼成 mp4。
def animate_portrait(source_img, driving_video_path, session): appearance = preprocess_face(source_img) cap = cv2.VideoCapture(driving_video_path) frames = [] while True: ret, frame = cap.read() if not ret: break # 提取当前帧的运动系数 motion = extract_motion(frame) motion = np.expand_dims(motion, axis=0).astype(np.float32) # 推理 output = session.run( ["output_image"], {"appearance": appearance, "motion": motion} )[0] # 后处理 out_frame = postprocess(output) frames.append(out_frame) cap.release() save_video(frames, "output.mp4", fps=25)extract_motion 这一步如果也转成了 ONNX,就再开一个 session 跑。如果没转,就用原始 PyTorch 实现。逐帧推理时注意 batch 维保持为 1,不要一次塞多帧,除非模型导出时支持了动态 batch。
4. C++ 侧部署:用 onnxruntime C++ API 做高性能推理
4.1 创建会话和配置线程数
C++ 侧的 API 和 Python 侧思路一致,但写法更啰嗦。创建环境、会话选项、会话三步走。线程数通过 SessionOptions 设置,默认会用满所有核心,在服务器上跑多实例时建议限制一下。
#include <onnxruntime_cxx_api.h> #include <opencv2/opencv.hpp> Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "liveportrait"); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); Ort::Session session(env, "liveportrait_generator.onnx", session_options);SetIntraOpNumThreads 控制单个算子内部的并行度,SetInterOpNumThreads 控制算子之间的并行度。在鲲鹏 920 这种多核 ARM 上,IntraOp 设成 4 到 8 比较合适,设太大反而会因为线程切换开销导致性能下降。GraphOptimizationLevel 开到 ENABLE_ALL 会做算子融合和常量折叠,推理速度能提升一截。
4.2 构造输入张量并执行推理
C++ 里输入数据要用 Ort::Value 包装,内存布局和 Python 侧一致。注意数据生命周期,Ort::Value 创建时如果用的是外部内存,要保证推理期间内存不被释放。
std::vector<int64_t> appearance_shape = {1, 256, 64, 64}; std::vector<int64_t> motion_shape = {1, 21, 3}; std::vector<float> appearance_data(1 * 256 * 64 * 64, 0.5f); std::vector<float> motion_data(1 * 21 * 3, 0.0f); auto memory_info = Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value appearance_tensor = Ort::Value::CreateTensor<float>( memory_info, appearance_data.data(), appearance_data.size(), appearance_shape.data(), appearance_shape.size()); Ort::Value motion_tensor = Ort::Value::CreateTensor<float>( memory_info, motion_data.data(), motion_data.size(), motion_shape.data(), motion_shape.size()); const char* input_names[] = {"appearance", "motion"}; const char* output_names[] = {"output_image"}; Ort::Value input_tensors[] = { std::move(appearance_tensor), std::move(motion_tensor)}; auto output_tensors = session.Run( Ort::RunOptions{nullptr}, input_names, input_tensors, 2, output_names, 1);CreateTensor 的模板参数是数据类型,要和模型输入类型匹配。如果模型输入是 float16,这里就要写 CreateTensor Ort::Float16_t 。Run 的返回值是 vector Ort::Value ,取第一个就是输出。输出数据的指针用 GetTensorMutableData () 拿。
4.3 把输出张量转回图像并保存
输出张量一般是 NCHW 格式的 float 数组,要转成 HWC 的 uint8 才能存成图片。转换时注意通道顺序,OpenCV 默认是 BGR。
float* output_data = output_tensors[0].GetTensorMutableData<float>(); auto output_shape = output_tensors[0].GetTensorTypeAndShapeInfo().GetShape(); int out_h = static_cast<int>(output_shape[2]); int out_w = static_cast<int>(output_shape[3]); cv::Mat result(out_h, out_w, CV_32FC3); for (int h = 0; h < out_h; ++h) { for (int w = 0; w < out_w; ++w) { for (int c = 0; c < 3; ++c) { float val = output_data[c * out_h * out_w + h * out_w + w]; result.at<cv::Vec3f>(h, w)[c] = val; } } } result.convertTo(result, CV_8UC3, 255.0); cv::imwrite("output_frame.png", result);这段循环是逐像素拷贝,数据量大时可以用 memcpy 按通道拷贝再转置,但要注意内存布局。如果输出是 RGB 而 OpenCV 要 BGR,最后还要做一次通道交换。
5. 避坑与排查:部署 LivePortrait 时最容易翻车的五个地方
5.1 模型加载报错「Unsupported model IR version」
现象是 session 创建时直接抛异常,提示 IR 版本不支持。原因是导出模型用的 onnx 版本比 onnxruntime 支持的 IR 版本高。解决方法是降低导出时的 opset,或者升级 onnxruntime。如果升级 onnxruntime 不方便,就在导出时把 opset 设成 13 或 14,别用太新的。
5.2 推理结果全黑或全白
现象是输出图像没有任何内容,要么全黑要么全白。原因通常是输入归一化方式不对,或者输入数据布局搞错了。检查两点:一是输入值范围是不是和训练时一致,二是 NCHW 的维度顺序有没有写反。我遇到过把 HWC 直接当 NCHW 塞进去的情况,输出就是一片噪声。
5.3 C++ 侧编译通过但运行时报缺少 DLL 或 so
现象是编译没问题,一运行就提示找不到 onnxruntime.dll 或 libonnxruntime.so。原因是运行时链接器找不到库文件。Linux 下用 LD_LIBRARY_PATH 把库目录加进去,Windows 下把 DLL 放到 exe 同目录或者加到 PATH。另外 Microsoft Visual C++ Redistributable 没装也会导致类似报错,装一下就好。
5.4 驱动视频帧率变化导致输出抖动
现象是输出视频里人脸动作一顿一顿的,不流畅。原因是驱动视频帧率不稳定,或者运动系数在帧间跳变太大。解决方法是先对驱动视频做固定帧率重采样,再在运动系数上做一点平滑滤波。简单做法是用滑动平均,窗口设 3 到 5 帧。
5.5 鲲鹏 920 上推理速度远低于预期
现象是在 x86 上跑得好好的模型,搬到鲲鹏 920 上慢了好几倍。原因可能是 onnxruntime 装成了 x86 版本,或者没有启用 ARM 的 NEON 加速。确认方法是打印 ort.get_available_providers(),看是不是只有 CPUExecutionProvider。另外检查 onnxruntime 的版本是不是 aarch64 专用包,通用包在 ARM 上性能会打折。
6. 进阶技巧:用动态 batch 和缓存把吞吐量拉上去
单帧推理跑通之后,下一步要考虑的是吞吐量。如果驱动视频很长,逐帧推理会非常慢。一个实用的优化是把运动系数提取和图像生成解耦,运动系数提取可以批量做,图像生成再逐帧或小批量跑。另一个技巧是缓存源图像的外观特征,因为同一张源图像在整个驱动过程中外观特征不变,没必要每帧都重新提取。
# 缓存外观特征 appearance_cache = None def get_appearance(source_img, session): global appearance_cache if appearance_cache is None: appearance_cache = preprocess_face(source_img) return appearance_cache这个缓存看起来简单,但在实际项目里能省掉将近一半的计算量。外观提取网络虽然比生成网络小,但每帧都跑一遍累积起来也很可观。注意缓存的生命周期要和源图像绑定,换源图像时要清掉。
验证优化效果的方法是用同一段驱动视频跑两次,一次开缓存一次不开,对比总耗时。我一般会在代码里加一个简单的计时器,输出每帧平均耗时和总耗时。如果开了缓存之后耗时没有明显下降,说明瓶颈在生成网络,那就考虑把生成网络也做批量推理。
批量推理的做法是把多帧的运动系数拼成一个 batch 送进去,输出再拆开。这要求导出模型时 batch 维是动态的,前面 dynamic_axes 里已经设了。批量大小根据显存或内存来定,CPU 上一般 4 到 8 比较合适,太大反而会因为内存带宽瓶颈变慢。
# 批量推理示例 batch_motions = np.stack(motion_list, axis=0).astype(np.float32) batch_appearance = np.repeat(appearance, len(motion_list), axis=0) outputs = session.run( ["output_image"], {"appearance": batch_appearance, "motion": batch_motions} )这里 appearance 用 np.repeat 复制了多份,其实如果模型支持广播可以只传一份,但 ONNX 导出时不一定保留了广播语义,保险起见还是复制。批量推理的收益在 CPU 上通常有 1.5 到 2 倍,在 GPU 上更明显。
最后说一个我自己的习惯:每次改完模型导出参数或者推理代码,都会先用一张固定图片和一段固定驱动视频跑一遍,把输出保存下来作为基准。后面任何改动都跟这个基准对比,肉眼能看出差异就说明有问题。这个习惯帮我省了很多次重新排查的时间。希望帮到你。
本文还有配套的精品资源,点击获取