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 时建议用固定 batch1、固定分辨率比如 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_locationcpu)) motion.load_state_dict(torch.load(motion.pth, map_locationcpu)) warping.load_state_dict(torch.load(warping.pth, map_locationcpu)) generator.load_state_dict(torch.load(generator.pth, map_locationcpu)) # 构造固定形状的 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_version17, # 17 对 LayerNorm/GeLU 支持较好 do_constant_foldingTrue, dynamic_axesNone # 全固定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_version17, do_constant_foldingTrue )这段脚本的关键点有三个。第一eval() 必须调用否则导出图里会带训练态分支C# 推理结果和预期完全对不上。第二opset_version 选 17 而不是默认值是因为 LivePortrait 里大量用了 LayerNorm 和 GeLUopset 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 DenseTensorfloat(srcData, new[] { 1, 3, 256, 256 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(src_img, srcTensor) }; using var results appearanceSession.Run(inputs); var appearanceFeat results.First(r r.Name appearance_feat).AsTensorfloat();这里有几个参数必须对齐。第一图像归一化方式要和 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 Tensorfloat _cachedAppearanceFeat; private Tensorfloat _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).AsTensorfloat().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).AsTensorfloat().Clone(); } public float[] ProcessFrame(float[] drvData) { var drvTensor new DenseTensorfloat(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).AsTensorfloat(); var poseDrv motOut.First(r r.Name pose_drv).AsTensorfloat(); // 变形 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).AsTensorfloat(); // 生成 using var genOut _generator.Run(new[] { NamedOnnxValue.CreateFromTensor(warped_feat, warpedFeat) }); return genOut.First(r r.Name output_img).AsTensorfloat().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 BlockingCollectionMat(boundedCapacity: 8); var outputQueue new BlockingCollectionMat(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 读出来是 BGRPyTorch 训练用的是 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先别急着优化代码回头检查模型导出时有没有把不必要的分支也导进去很多时候砍掉一个没用的输出头速度就能翻倍。希望帮到你。本文还有配套的精品资源点击获取
返回列表