拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# 集成 OnnxRuntime 部署 LivePortrait 视频生成实战

C# 集成 OnnxRuntime 部署 LivePortrait 视频生成实战

简介:本资源面向具备一定C#与深度学习基础的开发者,聚焦在.NET环境下通过OnnxRuntime部署LivePortrait模型,实现快速、高质量的人像驱动视频生成。内容围绕模型推理、图像预处理与视频合成等关键环节展开,适合研究数字人、虚拟主播或人脸动画的工程人员参考。压缩包共384个文件,约882.12MB,包含61个dll动态库、40个xml配置、33张jpg示例图、19段mp4演示视频、12个cs源码文件、7个onnx模型及onnxruntime相关组件,另附h头文件、lib库、nupkg包与多平台so、dylib等,覆盖Windows与跨平台运行所需依赖。已有182人学习下载。资源提供完整工程结构与可运行示例,读者可据此理解LivePortrait在C#侧的集成方式,掌握模型加载、张量输入输出处理及视频驱动流程,并借助示例素材与配置快速复现效果,为二次开发与性能调优提供参考。

1. 从一张照片到一段会说话的视频:LivePortrait 在 C# 里到底能跑多快

手里只有一张正面人像,想让它在几秒内跟着一段驱动视频做出眨眼、转头、微笑的动作,这是很多做数字人、虚拟主播、在线教育课件的团队最现实的需求。LivePortrait 把这件事做到了开源方案里的第一梯队:它用隐式关键点做表情和姿态迁移,生成质量比早期 First Order Motion 稳定得多,边缘和牙齿的伪影明显更少。而 OnnxRuntime 让这套原本跑在 PyTorch 里的模型,可以脱离 Python 环境,直接嵌进 C# 上位机、WinForm 工具或者服务端进程里。这篇要讲的就是:怎么在 C# 里用 OnnxRuntime 把 LivePortrait 的推理链路搭起来,从模型导出、张量对齐、显存控制到视频帧合成,把「快速、高质量」这两个词落到可复现的参数上。适合已经有 C# 基础、想接 AI 视频生成能力但不想被 Python 部署绑架的工程师。

2. LivePortrait 的推理链路拆成 C# 能吃的几块

2.1 先搞清楚 LivePortrait 到底有哪几个模型在跑

LivePortrait 不是单个模型,而是一条由多个子网络串起来的流水线。常见做法是把它拆成五块:外观特征提取器(appearance extractor)、运动提取器(motion extractor)、关键点变形网络(warping network)、生成器(generator/spade decoder),以及可选的 stitching 和 retargeting 模块。外观提取器负责把源图编码成 3D 外观特征量;运动提取器从驱动帧里抽出隐式关键点和头部姿态;warping 网络根据源关键点到驱动关键点的位移,算出形变场;生成器再把形变后的特征解码成 RGB 帧。

在 C# 里部署时,你不需要理解每个网络的数学细节,但必须知道它们的输入输出张量形状和名字,因为 OnnxRuntime 只认张量名。导出 ONNX 时建议用固定 batch=1、固定分辨率(比如 256×256 的人脸区域),动态轴只保留时间维度或者干脆全固定,这样 C# 侧的内存分配可以提前做好,避免每帧都重新创建 tensor 导致 GC 抖动。

提示:导出前先在 Python 侧用 onnxruntime 跑一遍数值对齐,确认 ONNX 输出和 PyTorch 输出误差在 1e-3 以内,再进 C#,否则后面排查会非常痛苦。

2.2 用 Python 把 PyTorch 权重导成 ONNX 的最小脚本

导出是整个链路里最容易翻车的一步。LivePortrait 官方仓库里有些层用了自定义算子或者动态控制流,直接 torch.onnx.export 可能会报不支持的 op。稳妥的做法是先把模型切分成独立子模块,每个子模块单独导出,遇到不支持的算子就替换成等价实现。

import torch import torch.onnx from liveportrait_modules import AppearanceExtractor, MotionExtractor, WarpingNet, Generator # 加载官方权重,eval 模式必须开,否则 BN/Dropout 会引入随机性 appearance = AppearanceExtractor().eval() motion = MotionExtractor().eval() warping = WarpingNet().eval() generator = Generator().eval() appearance.load_state_dict(torch.load("appearance.pth", map_location="cpu")) motion.load_state_dict(torch.load("motion.pth", map_location="cpu")) warping.load_state_dict(torch.load("warping.pth", map_location="cpu")) generator.load_state_dict(torch.load("generator.pth", map_location="cpu")) # 构造固定形状的 dummy 输入,形状必须和 C# 侧完全一致 dummy_src = torch.randn(1, 3, 256, 256) dummy_drv = torch.randn(1, 3, 256, 256) torch.onnx.export( appearance, dummy_src, "appearance.onnx", input_names=["src_img"], output_names=["appearance_feat"], opset_version=17, # 17 对 LayerNorm/GeLU 支持较好 do_constant_folding=True, dynamic_axes=None # 全固定,C# 侧好分配 ) torch.onnx.export( motion, (dummy_src, dummy_drv), "motion.onnx", input_names=["src_img", "drv_img"], output_names=["kp_src", "kp_drv", "pose_src", "pose_drv"], opset_version=17, do_constant_folding=True )

这段脚本的关键点有三个。第一,eval() 必须调用,否则导出图里会带训练态分支,C# 推理结果和预期完全对不上。第二,opset_version 选 17 而不是默认值,是因为 LivePortrait 里大量用了 LayerNorm 和 GeLU,opset 11 以下会退化成多个基础算子拼接,精度和速度都受影响。第三,dynamic_axes 设成 None 意味着输入形状写死,C# 侧就可以用固定长度的 float 数组直接喂,省掉动态 shape 的检查开销。如果确实需要支持不同分辨率,把 dynamic_axes 只开在 H/W 轴上,但 C# 侧要相应做 resize 和 padding。

导出完成后,用 onnxruntime 的 Python API 做一次数值校验:

import onnxruntime as ort import numpy as np sess = ort.InferenceSession("appearance.onnx", providers=["CPUExecutionProvider"]) inp = np.random.randn(1, 3, 256, 256).astype(np.float32) onnx_out = sess.run(None, {"src_img": inp})[0] torch_out = appearance(torch.from_numpy(inp)).detach().numpy() print("max diff:", np.abs(onnx_out - torch_out).max())

max diff 控制在 1e-3 以内就可以进 C#。如果超过 1e-2,说明某个算子导出有问题,优先检查 LayerNorm 和 interpolate 的 align_corners 参数。

2.3 C# 侧加载 ONNX 模型与张量对齐

C# 用 OnnxRuntime 的核心类是 InferenceSession,加载时把线程数、执行提供器、图优化级别都显式设好。LivePortrait 的生成器计算量最大,建议把 intra-op 线程数设成物理核数,execution mode 用 ORT_SEQUENTIAL 避免多模型并行时显存争抢。

using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; // 加载模型,显式指定线程和优化级别 var sessionOptions = new SessionOptions(); sessionOptions.IntraOpNumThreads = Environment.ProcessorCount; sessionOptions.ExecutionMode = ExecutionMode.ORT_SEQUENTIAL; sessionOptions.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL; // 如果有 GPU,优先用 CUDA 或 DirectML // sessionOptions.AppendExecutionProvider_CUDA(0); // sessionOptions.AppendExecutionProvider_DML(0); var appearanceSession = new InferenceSession("appearance.onnx", sessionOptions); var motionSession = new InferenceSession("motion.onnx", sessionOptions); var warpingSession = new InferenceSession("warping.onnx", sessionOptions); var generatorSession = new InferenceSession("generator.onnx", sessionOptions); // 构造输入张量,形状必须和导出时一致:1x3x256x256 float[] srcData = LoadImageAsFloatArray("source.jpg", 256, 256); // 归一化到 [0,1] 或 [-1,1] var srcTensor = new DenseTensor<float>(srcData, new[] { 1, 3, 256, 256 }); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("src_img", srcTensor) }; using var results = appearanceSession.Run(inputs); var appearanceFeat = results.First(r => r.Name == "appearance_feat").AsTensor<float>();

这里有几个参数必须对齐。第一,图像归一化方式要和 Python 训练时一致,LivePortrait 通常用 [-1,1],如果你在 C# 里用 [0,1],生成结果会整体偏灰。第二,DenseTensor 的内存布局是行优先,和 PyTorch 的 NCHW 一致,不要自己转成 NHWC。第三,Run 返回的 IDisposableReadOnlyCollection 必须用 using 包住,否则每帧都会泄漏非托管内存,跑几百帧后进程直接 OOM。第四,如果开了 CUDA provider,输入张量会在第一次 Run 时拷贝到显存,后续帧复用同一个 session 就不会重复拷贝权重,但输入 tensor 每次都要新建。

2.4 把多模型串成一条帧处理流水线

单帧推理的流程是:源图过一次 appearance 得到外观特征(这个只算一次,可以缓存);每张驱动帧过一次 motion 得到驱动关键点和姿态;warping 网络拿源关键点、驱动关键点、外观特征算出形变后的特征;generator 解码成 RGB 帧。C# 里可以用一个 Pipeline 类把这些 session 和缓存的特征串起来。

public class LivePortraitPipeline : IDisposable { private readonly InferenceSession _appearance, _motion, _warping, _generator; private Tensor<float> _cachedAppearanceFeat; private Tensor<float> _cachedSrcKp; public void PrepareSource(string srcPath) { var srcTensor = ImageUtil.LoadAsTensor(srcPath, 256, 256); // 外观特征只算一次,后续所有驱动帧复用 using var appOut = _appearance.Run(new[] { NamedOnnxValue.CreateFromTensor("src_img", srcTensor) }); _cachedAppearanceFeat = appOut.First(r => r.Name == "appearance_feat").AsTensor<float>().Clone(); // 源关键点也缓存 using var motOut = _motion.Run(new[] { NamedOnnxValue.CreateFromTensor("src_img", srcTensor), NamedOnnxValue.CreateFromTensor("drv_img", srcTensor) }); _cachedSrcKp = motOut.First(r => r.Name == "kp_src").AsTensor<float>().Clone(); } public float[] ProcessFrame(float[] drvData) { var drvTensor = new DenseTensor<float>(drvData, new[] { 1, 3, 256, 256 }); // 运动提取 using var motOut = _motion.Run(new[] { NamedOnnxValue.CreateFromTensor("drv_img", drvTensor) }); var kpDrv = motOut.First(r => r.Name == "kp_drv").AsTensor<float>(); var poseDrv = motOut.First(r => r.Name == "pose_drv").AsTensor<float>(); // 变形 using var warpOut = _warping.Run(new[] { NamedOnnxValue.CreateFromTensor("appearance_feat", _cachedAppearanceFeat), NamedOnnxValue.CreateFromTensor("kp_src", _cachedSrcKp), NamedOnnxValue.CreateFromTensor("kp_drv", kpDrv), NamedOnnxValue.CreateFromTensor("pose_drv", poseDrv) }); var warpedFeat = warpOut.First(r => r.Name == "warped_feat").AsTensor<float>(); // 生成 using var genOut = _generator.Run(new[] { NamedOnnxValue.CreateFromTensor("warped_feat", warpedFeat) }); return genOut.First(r => r.Name == "output_img").AsTensor<float>().ToArray(); } }

缓存外观特征是性能关键。外观提取器参数量不小,如果每帧都重算,1080p 视频的生成速度会掉一半以上。源关键点同理,它只和源图有关。ProcessFrame 里每次 Run 都会分配新的输出张量,如果追求极致性能,可以用 OnnxRuntime 的 IOBinding 把输出直接写到预分配的缓冲区,省掉一次拷贝。但 IOBinding 在 C# API 里用起来比较绕,建议先把基础版本跑通,确认质量没问题再优化。

3. 参数调优:让生成速度和画质同时站得住

3.1 分辨率、batch 和显存之间的三角关系

LivePortrait 的输入人脸区域通常是 256×256,但最终输出可以放大到 512 甚至 1024。放大这一步是在 generator 里通过上采样层完成的,不是后处理插值。如果你把导出分辨率直接改成 512,显存占用大约翻四倍,速度降到原来的三分之一左右。实际项目里更常见的做法是:推理保持在 256,输出后用 Real-ESRGAN 之类的超分模型单独放大,这样两个模型可以异步跑,整体吞吐更高。

配置输入分辨率显存占用(FP32)单帧耗时(RTX 3060)适用场景
轻量256×256~1.2 GB18-25 ms实时预览、低配机器
标准256×256 + 512 输出~2.0 GB35-45 ms常规视频生成
高质量512×512~4.5 GB90-120 ms离线渲染、短片

batch 在 LivePortrait 里意义不大,因为每帧的驱动姿态不同,warping 和 generator 没法简单批处理。真要提吞吐,用多进程或者多 session 并行,而不是加大 batch。

3.2 关键点缩放和姿态归一化的三个必调参数

LivePortrait 生成质量对三个参数特别敏感:关键点缩放系数(scale)、姿态旋转的归一化强度、以及 stitching 模块的融合权重。这三个参数在 Python 侧通常是写死的,但 C# 部署时你可能需要根据输入视频的特点微调。

关键点缩放系数控制驱动动作的幅度。设太小,人物表情僵硬;设太大,会出现面部撕裂。常见做法是在 0.8 到 1.2 之间调,默认 1.0。姿态归一化强度影响头部转动的跟随程度,如果驱动视频里头部转动很大但源图只有正面,归一化强度要调低,否则侧脸会出现明显伪影。stitching 融合权重只在源图和驱动图人脸区域差异大时才需要调,一般保持默认。

// 在 warping 输入前对关键点做缩放 private float[] ScaleKeypoints(float[] kp, float scale) { // kp 布局是 [x1,y1,x2,y2,...],以人脸中心为原点缩放 var result = new float[kp.Length]; for (int i = 0; i < kp.Length; i += 2) { result[i] = kp[i] * scale; result[i + 1] = kp[i + 1] * scale; } return result; } // 姿态归一化:把驱动姿态的 yaw/pitch/roll 按比例衰减 private float[] NormalizePose(float[] pose, float strength) { // pose 布局通常是 [yaw, pitch, roll, tx, ty, tz] var result = (float[])pose.Clone(); result[0] *= strength; // yaw result[1] *= strength; // pitch result[2] *= strength; // roll return result; }

这两个函数看着简单,但位置很关键。ScaleKeypoints 必须在 warping 之前调用,NormalizePose 必须在 motion 输出之后、warping 之前调用。放错位置不会报错,但生成结果会莫名其妙地抖或者僵。

3.3 视频帧的读取、推理、写回怎么不丢帧

C# 侧读视频常见做法是用 OpenCVSharp 或者 FFmpeg 的 pipe。OpenCVSharp 的 VideoCapture 读帧方便,但解码和推理如果在同一个线程里串行跑,帧率会被推理耗时拖死。正确做法是生产者-消费者模式:一个线程专门解码,把帧放进 BlockingCollection;另一个线程取帧推理,结果放进输出队列;第三个线程负责编码写回。

using System.Collections.Concurrent; using OpenCvSharp; var frameQueue = new BlockingCollection<Mat>(boundedCapacity: 8); var outputQueue = new BlockingCollection<Mat>(boundedCapacity: 8); // 解码线程 var decodeTask = Task.Run(() => { using var cap = new VideoCapture("driving.mp4"); var frame = new Mat(); while (cap.Read(frame)) { if (frame.Empty()) break; frameQueue.Add(frame.Clone()); // Clone 避免 Mat 被复用 } frameQueue.CompleteAdding(); }); // 推理线程 var inferTask = Task.Run(() => { var pipeline = new LivePortraitPipeline(); pipeline.PrepareSource("source.jpg"); foreach (var frame in frameQueue.GetConsumingEnumerable()) { var resized = frame.Resize(new Size(256, 256)); var input = ImageUtil.MatToFloatArray(resized); var output = pipeline.ProcessFrame(input); var outMat = ImageUtil.FloatArrayToMat(output, 256, 256); outputQueue.Add(outMat); } outputQueue.CompleteAdding(); }); // 编码线程 var encodeTask = Task.Run(() => { using var writer = new VideoWriter("output.mp4", FourCC.MP4V, 25, new Size(256, 256)); foreach (var mat in outputQueue.GetConsumingEnumerable()) { writer.Write(mat); mat.Dispose(); } }); Task.WaitAll(decodeTask, inferTask, encodeTask);

boundedCapacity 设成 8 是经验值。太小会导致解码线程频繁阻塞,太大则内存里堆积太多 Mat,一张 1080p 的 Mat 大约 6MB,堆 100 张就是 600MB。frame.Clone() 不能省,OpenCV 的 Read 会复用同一个 Mat 对象,不 Clone 的话队列里所有元素都指向同一块内存,推理结果全错。

4. 避坑与排查:C# 调 OnnxRuntime 最常见的五类翻车

4.1 现象:推理结果全黑或者全灰,但 Python 侧正常

原因几乎总是归一化不一致。LivePortrait 训练时输入是 [-1,1],很多 C# 图像库默认输出 [0,1] 或者 [0,255]。另一个可能是通道顺序,OpenCV 读出来是 BGR,PyTorch 训练用的是 RGB,不转换的话肤色会偏蓝。

解决:在 MatToFloatArray 里显式做 BGR→RGB 和 (x/127.5 - 1.0) 的归一化,写完后拿一张图在 Python 和 C# 各跑一次,对比第一个输出张量的均值,误差应该在 1e-4 以内。

4.2 现象:跑几十帧后进程内存暴涨然后崩溃

原因是没有释放 InferenceSession.Run 返回的结果。IDisposableReadOnlyCollection 里的每个 DisposableNamedOnnxValue 都持有非托管内存,不用 using 包住就不会及时释放。另外 DenseTensor 如果每帧都新建,GC 压力也很大。

解决:所有 Run 调用都用 using 包住,输出张量如果要在 using 块外使用,先 ToArray() 或者 Clone()。如果还是涨,用 dotnet-counters 看 Gen2 GC 频率,正常应该几十帧才触发一次。

4.3 现象:GPU 推理比 CPU 还慢

原因通常是每帧都在做 host→device 的拷贝,或者 execution provider 没正确加载。OnnxRuntime 的 CUDA provider 需要 CUDA 和 cuDNN 版本严格匹配,版本不对会静默回退到 CPU,但日志里不一定有明显报错。

解决:在 SessionOptions 里打开 verbose 日志,确认 provider 是 CUDAExecutionProvider 而不是 CPUExecutionProvider。然后用 IOBinding 把输入输出都固定在 GPU 上,避免每帧拷贝。如果还是慢,检查是不是每个模型都单独创建了 session,多个 session 会各自占一份显存,权重没法共享。

4.4 现象:生成视频里人脸区域正常但边缘有撕裂

原因是 warping 网络的形变场超出了源图的有效区域。当驱动帧的头部姿态和源图差异太大时,warping 会把边界外的像素也拉进来,生成器解码时就会出现撕裂。

解决:在 warping 输出后加一个 mask,把形变场限制在源图人脸区域内。LivePortrait 本身有 stitching 模块处理这个问题,但如果你导出时把 stitching 去掉了,就要自己在 C# 侧做。简单做法是对 warping 输出的形变场做 clamp,超出 [-1,1] 的值截断。

4.5 现象:同一段驱动视频,每次生成的细微表情都不一样

原因是模型里还有随机性没关掉。PyTorch 的 eval() 只关掉了 Dropout 和 BatchNorm 的训练态,但如果模型里有其他随机操作(比如某些数据增强残留),导出成 ONNX 后可能变成 RandomNormal 之类的算子。

解决:导出前用 torch.onnx.export 的 do_constant_folding 和固定随机种子,导出后用 onnx 工具检查图里有没有 Random 类算子。如果有,找到对应的 PyTorch 模块,把随机分支去掉再重新导出。ONNX 推理本身是确定性的,只要图里没有随机算子,同样输入必然同样输出。

5. 把单帧推理变成可用的视频生成工具:我的收尾习惯

走到这一步,你已经有了一个能跑的 C# 推理链路。但离「可用的视频生成工具」还差一层:错误恢复和批量处理。我自己的习惯是在 Pipeline 外面再包一层重试逻辑,因为视频解码偶尔会遇到坏帧,ONNX 推理遇到异常输入也会抛,如果不捕获,整个批量任务就断了。

public Mat SafeProcessFrame(LivePortraitPipeline pipeline, Mat frame, int maxRetry = 2) { for (int i = 0; i <= maxRetry; i++) { try { var resized = frame.Resize(new Size(256, 256)); var input = ImageUtil.MatToFloatArray(resized); var output = pipeline.ProcessFrame(input); return ImageUtil.FloatArrayToMat(output, 256, 256); } catch (OnnxRuntimeException ex) when (i < maxRetry) { // 记录日志,稍微调整输入后重试 Console.WriteLine($"Frame failed, retry {i}: {ex.Message}"); frame = frame.GaussianBlur(new Size(3, 3), 0); // 轻微模糊降低异常值 } } return frame; // 全部失败就返回原帧,保证视频不断 }

这个 SafeProcessFrame 看起来不起眼,但在批量处理几百个视频时能救命。ONNX 推理失败的原因很多:输入里有 NaN、显存瞬时不足、驱动帧尺寸异常。重试时做一次轻微高斯模糊,能把大部分由噪声引起的异常值压下去。如果两次重试还失败,直接返回原帧,至少保证输出视频的帧数和时长是对的,后期可以人工替换那几帧。

另一个习惯是给每个模型单独记耗时。用 Stopwatch 在 ProcessFrame 里分段打点,跑完一批视频后看哪一段是瓶颈。我遇到过 generator 占了 70% 时间的情况,后来把 generator 的 opset 从 17 降到 15,速度反而快了 15%,因为 17 里某些算子在这个模型上触发了低效实现。这种细节没有通用答案,只能靠实测。

最后说一个判断标准:如果你生成的视频在 256 分辨率下单帧能压到 30ms 以内,那这套 C# + OnnxRuntime 的方案就值得往产品里放。如果超过 100ms,先别急着优化代码,回头检查模型导出时有没有把不必要的分支也导进去,很多时候砍掉一个没用的输出头,速度就能翻倍。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表