简介为Unity开发者和AR/VR、虚拟直播、体感交互等领域的技术人员提供一份基于BodyPix ONNX模型与Barracuda推理引擎的实时人体图像分割完整源码工程。工程覆盖摄像头画面与视频文件两种输入方式支持分割灵敏度调节与边缘模糊处理通过C#脚本解析WebCamTexture/VideoTexture数据并让Shader在UI图上呈现干净的抠像结果。整个工程包含82个文件以onnx模型、C#脚本、Shader、材质与Prefab等类型为主压缩包约184.24MB目录结构清晰便于直接对照工程理解管线。目前已有226人学习下载适合需要快速接入人体分割能力、想参考完整Unity工程实现的中高级开发者可有效缩短基于BodyPix模型的原型验证周期。1. Unity3d人体图像分割AI功能先把画面流转跑通再谈模型精度做Unity3d摄像头人体图像分割时最容易踩的坑不是模型推理有多慢而是画面从摄像头到模型输入的流转链路没理清。常见幻觉是“把TensorFlow模型丢进Unity就能跑”实际上一碰到视频帧丢失、纹理方向颠倒、内存持续增长帧率就会直接从30掉到个位数。人体图像分割AI功能的最小可用闭环应该是摄像头或视频画面 - 纹理 - Tensor - ONNX Runtime推理 - 掩膜 - 叠加渲染。这个闭环里每一步都涉及单独的工程决策模型选型只是开头。这篇文章会按可落地的源码工程结构讲清模型选型、画面采集、推理和渲染的完整路径适合已经有一年以上Unity3d开发经验、想在项目里加入AI抠图或者人物轮廓识别的工程师。2. 人体图像分割在Unity3d的模型选型与推理引擎取舍人体图像分割先要分清语义分割和实例分割。摄像头前的真实场景里人和人经常重叠画面里也可能出现半身、侧身。如果只想要“人像抠图”效果语义分割模型就够了如果需要区分“第一个人”和“第二个人”或者只想显示某个特定人物轮廓就要用实例分割模型。Unity3d这类实时画面场景里模型体积、算力开销和输出后处理复杂度往往比mIoU更重要。这一章把主流选项放在Unity3d实际工程上下文里对比然后给出可用的推理引擎组合。2.1 人体图像分割模型对比YOLOv8-seg、PP-HumanSeg和Mask R-CNN传统做法里Mask R-CNN是最常被提到的。它输出的是逐像素实例掩膜精度高但输入尺寸通常要800x1333在Unity3d里光是把一张WebCamTexture的帧Resize到这个尺寸再归一化耗时就能吃掉一个完整的帧预算。更麻烦的是Mask R-CNN的ONNX输出里包含ROI Align后的MaskLogits形状和输出层解析方式很复杂在Unity这类C#环境里手动做NMS和阈值过滤代码量很大。我一般会把备选收缩到YOLOv8-seg和PP-HumanSeg两个。YOLOv8-seg输出的是“1xNxMxK”的掩膜系数和一组检测框需要先做NMS再从系数里恢复具体掩膜适合多人且需要目标框的场景。PP-HumanSeg是百度开源的专门人像分割模型输出就是单通道HxW的概率图直接做ArgMax或者Sigmoid就能拿到二值掩膜甚至有修剪过的移动端版本输入尺寸能压到192x192。模型输出类型常用输入尺寸Unity3d实时性适用画面YOLOv8-seg检测框类别掩膜系数640x640中高取决于后处理多人、需要区分个体PP-HumanSeg单通道概率图192x192至512x512高几乎无后处理单人像抠图、直播间背景替换Mask R-CNN实例掩膜800x1333低不推荐实时离线精细分割如果项目是“摄像头实时人体抠图”PP-HumanSeg是最稳的开端如果后续要叠加姿态估计或者按人头计数再换成YOLOv8-seg。注意PP-HumanSeg的ONNX里很多算子接近全固定较早版本的Barracuda不一定能完整加载这也是我直接走ONNX Runtime的原因。2.2 Unity3d端推理引擎ONNX Runtime和Barracuda的取舍Unity官方有Barracuda优点是纯C#层能跑一部分ONNX层不需要额外装Native库打包到手机也方便。但它的算子集一直比较保守模型里一旦出现自定义Resize模式或者特定版本的SliceBarracuda就会直接抛“unsupported operator”。我的项目里用ONNX Runtime是因为它对ONNX的标准算子覆盖度高而且C#层有官方生成的 Native DLL绑定在Editor和Build都能跑。推理引擎算子支持内存控制集成复杂度适合场景BarracudaONNX子集靠ITensor生命周期偏自动低简单MLP、轻量模型ONNX Runtime标准ONNX全量可手动绑定输入输出中YOLOv8-seg、PP-HumanSegTensorFlow Lite需转TFLite较好但Unity绑定弱中高移动端专用ONNX Runtime在Unity里的基本用法是先挂一个NativeARuntime单例然后加载模型文件创建OrtSession。一个工程里不要频繁创建和销毁Session否则会反复加载模型卡顿明显。我通常把Session的生命周期放在一个SegmentationModel类里通过Initialize(string modelPath)一次性创建之后每一帧只做Run()。using System; using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; using UnityEngine; using OrtSharp; // 假设封装了Native ONNX Runtime public class SegmentationModel : IDisposable { private IntPtr _session; private IntPtr _env; public void Initialize(string modelPath) { _env OrtSharp.OrtEnv.Create(); _session OrtSharp.OrtSession.Create(_env, modelPath); } public void Run(float[] inputTensor, int width, int height, float[] outputMask) { var inputType new OrtSharp.OrtTensorType { Float inputTensor }; var inputShape new long[] { 1, 3, height, width }; var outputType new OrtSharp.OrtTensorType { Float outputMask }; var outputShape new long[] { 1, 1, height, width }; OrtSharp.OrtSession.Run(_session, inputType, inputShape, outputType, outputShape); } public void Dispose() { if (_session ! IntPtr.Zero) OrtSharp.OrtSession.Release(_session); if (_env ! IntPtr.Zero) OrtSharp.OrtEnv.Release(_env); } }OrtShartp这类封装可以自己写本质就是DllImport。关键是每次Run直接把float[]传进去避免Unity托管数组和Native内存反复拷贝。输入inputShape用的是NCHW后面处理摄像头画面时也要按这个顺序排数据。2.3 源码工程目录把模型、脚本、Shader和资源分开源码工程里我习惯按职责分四块而不是把所有脚本都堆在Assets根目录。这样后面替换模型、调参数、排查性能时不用到处找文件。Assets/ ├── Model/ # ONNX模型文件、模型版本说明 ├── Scripts/ │ ├── Core/ # Session管理、Tensor转换、Mask解析 │ ├── Capture/ # WebCamTexture、VideoPlayer采集封装 │ └── Render/ # CommandBuffer绘制、Shader绑定 ├── Shaders/ # 掩膜合成Shader └── Resources/ # 模型路径配置、分辨率配置Scripts/Core里的SegmentationModel类不依赖任何采集类输入只有float[]输出float[]。这样换摄像头还是换视频文件都不影响推理代码。Scripts/Capture做画面采集和Texture转TensorScripts/Render只负责把Mask画到屏幕或者RenderTexture上。模型文件放到Model/目录后在Model/ModelConfig.json里写清楚输入尺寸和归一化参数避免每次换模型都要改代码。3. Unity3d摄像头和视频画面采集把像素转成Tensor的预处理管线模型选好之后最难的是让Unity3d的画面成为模型能理解的输入。摄像头和视频文件在Unity里拿到的都是一个Texture但Texture在GPU上通常不是标准RGB排列而且输入尺寸和模型要求常常不一致。这一章从设备枚举、视频源加载、像素转换三个层面把预处理管线拆开。3.1 摄像头采集WebCamTexture的设备枚举与分辨率参数Unity3d打开摄像头用的是WebCamTexture。很多项目直接new WebCamTexture()然后不检查设备列表结果在Windows上拿到的是虚拟摄像头拍出来的画面方向也不对。更稳的做法是先枚举设备再按设备支持的分辨率创建。using UnityEngine; using System.Linq; public class CameraCapture : MonoBehaviour { private WebCamTexture _cam; private WebCamDevice[] _devices; void Start() { _devices WebCamTexture.devices; // 优先选择带“Front”或“back”标记的设备 var target _devices.FirstOrDefault(d d.isFrontFacing) ?? _devices[0]; _cam new WebCamTexture(target.name, 640, 480, 30); _cam.Play(); // 帧数据准备就绪后使用 _cam.GetPixels32() 继续处理 } }new WebCamTexture(deviceName, width, height, fps)里的fps并不是真正保证30帧它只会请求设备支持的模式。这里设置640x480主要是为了给模型Resize时减少计算摄像头原始分辨率很高时Resize开销反而成为瓶颈。_devices[i].isFrontFacing在Android上经常不可靠最好在设置界面让用户自己选择前后摄像头。WebCamTexture的rotationAngle属性在手机上需要手动处理。横屏模式下传感器的方向导致画面旋转90度不能只旋转UIRGB数据也要同步做旋转否则送进模型前就会把人像旋转90度Mask画出来也是歪的。3.2 视频文件和RTSP视频流VideoPlayer与Unity3d视频流接入对于“视频画面”这个来源Unity3d常见的方案是用VideoPlayer播放本地mp4文件。VideoPlayer可以直接把视频帧输出到一个RenderTexture。这个路径比WebCamTexture要干净因为VideoPlayer的纹理已经是Texture2D不需要走设备权限请求。using UnityEngine; using UnityEngine.Video; public class VideoCapture : MonoBehaviour { public string videoPath Assets/Videos/human.mp4; private VideoPlayer _player; void Start() { var rt new RenderTexture(1280, 720, 0); _player GetComponentVideoPlayer(); _player.renderMode VideoRenderMode.RenderTexture; _player.targetTexture rt; _player.url Application.dataPath / videoPath; _player.isLooping true; _player.Play(); } }关键参数是VideoRenderMode.RenderTexture和targetTexture。如果不设targetTextureVideoPlayer默认用相机背面的Alpha纹理读取Texture2D.GetPixels32()时拿不到画面。rt的宽度和高度要和视频原始分辨率一致不一致时VideoPlayer会自动裁剪而不是缩放画面里人物会被截断。RTSP或局域网摄像头视频流在Unity3d原生VideoPlayer里无法直接使用工程里常见的做法是引入FFmpeg插件推成低延迟的UDP帧或者用SDK把视频流解析成Texture2D。这一层和模型推理无关建议先跑通本地视频文件再把采集接口换成流媒体回调。3.3 像素到Tensor把Texture2D转成RGBA并保存到NativeArray摄像头或者视频画面最终要变成float[]。最差的做法是每帧调用Texture2D.GetPixels32()那会产生大量托管数组垃圾。较稳的方式是先用AsyncGPUReadback把GPU上的纹理所回读到NativeArraybyte再手动转成NCHW顺序。using UnityEngine; using UnityEngine.Rendering; using Unity.Collections; public static class TextureToTensor { public static void Convert(Texture src, float[] tensor, int targetW, int targetH) { var rt RenderTexture.GetTemporary(targetW, targetH, 0, RenderTextureFormat.ARGB32); Graphics.Blit(src, rt); var request AsyncGPUReadback.Request(rt, 0, TextureFormat.RGBA32); request.WaitForCompletion(); var data request.GetDatabyte(); int pixelCount targetW * targetH; float scale 1f / 255f; for (int i 0; i pixelCount; i) { int y i / targetW; int x i % targetW; tensor[0 * pixelCount i] (data[i * 4 0] - 0.5f) * scale; // R tensor[1 * pixelCount i] (data[i * 4 1] - 0.5f) * scale; // G tensor[2 * pixelCount i] (data[i * 4 2] - 0.5f) * scale; // B } RenderTexture.ReleaseTemporary(rt); } }这段代码里的AsyncGPUReadback.Request(rt, 0, TextureFormat.RGBA32)会强制把GPU上的RT纹理读回CPU。WaitForCompletion()会阻塞当前线程一帧里不能做多次否则帧率直接下降。我一般会把读取和推理拆到不同线程或者接受一个必然的1-2帧延迟。tensor的排布是Caffe和ONNX常用的NCHW第一个通道是R第二个是G第三个是B。使用均值方差归一化时把这里改成(value - mean) / std即可别在模型输入里再重复归一化。4. 基于ONNX Runtime完成人体图像分割推理并渲染掩膜预处理把每一帧画面变成Tensor后下一步就是推理和把结果画回给用户。这个环节里最容易出问题的不是Session调用而是输出张量的形状和语义。YOLOv8-seg和PP-HumanSeg的输出不一样拿到掩膜的方式也完全不同。4.1 推理Session的构建与输入张量绑定ONNX Runtime的Session初始化在第2章已经给了基本结构。这里要额外注意的是模型输入名称。不同模型转换出来的输入名称可能叫input、data或者image不能在代码里写死。我一般会在启动时读取模型输入和输出元数据把名称缓存下来。public class Segmentor { public void Run(Texture cameraTexture, int targetW, int targetH, NativeArrayfloat maskBuffer) { float[] inputTensor new float[3 * targetW * targetH]; TextureToTensor.Convert(cameraTexture, inputTensor, targetW, targetH); float[] mask new float[targetW * targetH]; // session.Run内部会绑定上面缓存的输入输出节点名 session.Run(inputTensor, new long[]{1, 3, targetH, targetW}, mask, new long[]{1, 1, targetH, targetW}); NativeArrayfloat.Copy(mask, maskBuffer, mask.Length); } }Run方法里输入输出Shape必须和模型一致。如果模型是NHWC排布inputShape就要是1, targetH, targetW, 3同时TextureToTensor里的通道顺序也要从NCHW改成NHWC。这里常见的坑是形状和像素顺序对不上导致分割结果像马赛克但识别又没完全错乱就是因为宽和高在Permute时弄反了。4.2 解析分割输出从Logits到二值Mask假设现在用的是PP-HumanSegONNX输出通常是1x1xHxW的Logits。我们需要对每个像素做ArgMax阈值0.5以上视为人物。如果直接用float[]当作输出张量那么输出内容就是对所有类别未做Softmax的logits。public static void ExtractMask(float[] logits, int width, int height, float threshold, Color32[] maskPixels) { int pixelCount width * height; for (int i 0; i pixelCount; i) { float personScore 1f / (1f Mathf.Exp(-logits[i])); // Sigmoid转概率 bool isPerson personScore threshold; maskPixels[i] isPerson ? new Color32(0, 255, 0, 255) : new Color32(0, 0, 0, 0); } }注意PP-HumanSeg有的输出节点不带Softmax直接用logits[i] 0也能近似但阈值不直观。我一般用Sigmoid把分数映射到0到1后把threshold设为0.5。如果出现背景漏一块或者人像内部有空洞先把阈值抬高到0.6或0.7试一下别急着改模型。如果是YOLOv8-seg输出结构则是检测框、类别分数和掩膜系数三组。掩膜系数要和模型原始输出尺寸的特征图做矩阵乘法再经过Sigmoid才能得到掩膜。这个后处理在纯C#里很绕建议用ComputeShader或者把输出降采样到更小的分辨率再做矩阵乘法。源码工程里我通常会把YOLOv8-seg后处理拆成一个独立的YoloSegProcessor类和PP-HumanSeg的解析类共用同一个IMaskParser接口。4.3 用CommandBuffer把Mask贴回到相机画面拿到二值Mask后不能直接把Color32[]赋给一个Texture2D再塞到材质上那样每帧都会产生额外纹理上传。较稳的方式是先把Mask写到一张RenderTexture然后使用CommandBuffer.Blit执行材质渲染。using UnityEngine; using UnityEngine.Rendering; public class MaskRenderer { private Material _maskMaterial; private RenderTexture _maskRT; public void ApplyMask(Texture sourceTex, Texture maskTex) { var cmd new CommandBuffer(); cmd.Blit(maskTex, _maskRT, _maskMaterial); cmd.Blit(_maskRT, BuiltinRenderTextureType.CameraTarget); Graphics.ExecuteCommandBuffer(cmd); cmd.Release(); } }对应Shader里把Mask的R通道作为混合因子与原视频画面做插值。float4 frag(v2f i) : SV_Target { float4 color tex2D(_MainTex, i.uv); float mask tex2D(_MaskTex, i.uv).r; float4 bg float4(0, 0, 0, 1); return lerp(bg, color, mask); }lerp(bg, color, mask)中mask接近1时输出原画接近0时输出黑色背景。这种写法比把mask做在C#里再拷贝到圆形贴图更省性能。要注意CommandBuffer的Blit需要指定正确的RT池如果直接cmd.Blit(maskTex, _maskRT)要先用GetTemporary创建_maskRT并且保证它和相机目标分辨率一致。5. 人体分割画面输出的验证和性能调优手段当整个链路跑通后下一步不是加功能而是找到瓶颈。像素从摄像头到模型输入再从模型输出到屏幕中间至少经过四次拷贝WebCamTexture - Texture2D - Tensor - ONNX输出 - Mask纹理。每次拷贝在90%情况里都集中在CPU和GPU之间的Pixel Readback上。5.1 用Profiler抓住纹理上传瓶颈Unity Profiler的CPU模块里如果看到Gfx.WaitForPresent很高说明CPU在等GPU此时要尽快把AsyncGPUReadback换成分批次读取只对奇数帧做分割偶数帧继续用上一次的Mask这样画面会从30帧降到15帧但延迟不再累积。Memory模块里如果Mono堆每帧都在涨优先检查Texture2D.SetPixels32和float[]数组是否在每帧里分配。常见做法是把所有重复使用的Tensor和NativeArray放到Buffer池里。5.2 三个高频坑摄像头旋转、内存持续增长、mask闪烁WebCamTexture的方向问题要用rotationAngle做一次旋转但不能只旋转RawImage否则模型输入的画面还是歪的Mask和原图对不上。内存持续增长通常来自AsyncGPUReadback.Request没有释放data要确保GetDatabyte()返回的NativeArraybyte在使用后调用Dispose()。Mask闪烁是因为分割结果偶发性跳动给Mask的阈值加一个时间上的一阶滞后或者使用数学形态学的腐蚀和膨胀能明显减弱边缘抖动。5.3 参数快查位置建议参数模型输入尺寸192x192或256x256归一化值域[-0.5,0.5]或[0,1]与模型训练保持一致输出阈值0.5起步抠图开0.65推理线程数OrtSession建议设2多出回导致卡顿摄像头采集640x48030fps优先RGB格式如果最终目的是视频画面的人体图像分割可以把摄像头采集和视频播放抽象成同一个ITextureSource接口分别实现CameraSource和VideoSource。这样不管是切源还是调分辨率都只改一行的依赖注入不用大幅度动推理代码。本文还有配套的精品资源点击获取