简介面向Unity开发者的实时人体图像分割源码工程利用谷歌BodyPix转换后的Onnx模型搭配Barracuda推理引擎可在客户端直接实现人物与背景的像素级分离适合增强现实、虚拟直播、体感交互等实时场景。工程完整覆盖摄像头纹理和视频纹理两条输入路径内置分割灵敏度设置与边缘模糊处理通过着色器将分割结果实时渲染到UI界面并附可运行场景、C#脚本及文件选择等辅助工具。借助示例代码可快速掌握WebCamTexture与VideoTexture的接入方式、Barracuda加载Onnx模型的流程以及自定义Shader对分割结果的呈现与优化。压缩包共82个文件以Unity的asset场景材质、C#逻辑脚本、Onnx模型、Shader着色器和json配置为主文件类型覆盖完整整体约184.24MB。资源已有227名开发者学习目录组织清晰摄像头分割、视频分割、模型切换等模块相对独立是研究BodyPix在Unity中落地的实用参考。1. 先把链路捋清摄像头、视频帧、分割模型和渲染输出很多第一次接触 Unity3d 的开发者会把“基于摄像头和视频画面做人体图像分割AI”想成一件简单的事模型拿到一帧画面吐出一张人像抠图然后显示出来就完事了。真正把功能做进游戏引擎里才会明白AI模型吐出来的只是一张概率图你得先把它变成渲染管线能直接采样的遮罩纹理再回贴到原始摄像头画面或视频画面上屏幕上才会出现“人还在背景已经被换掉”的效果。这件事在引擎侧需要打通三段链路取帧、推理、合成而这三段的节奏完全不一样。这篇笔记的目标读者是想做虚拟摄像头背景、直播抠像、会议虚拟背景这类功能的从业者默认你已经能跑通 Unity 的基础渲染流程但不需要你懂模型训练细节——模型训练和 Unity 接入本质上是两件事。2. 人体图像分割AI的工程选型从模型到渲染管线的取舍先说结论在 Unity3d 里落地人体分割最难的从来不是“模型精度不够”而是“模型输出怎么顺畅地回到渲染管线”。这一步决定了整个工程的结构也决定后面遇到性能问题时要往哪里查。我见过好几个项目模型换了一个又一个精度都说得过去最后翻车都翻在纹理拷贝、线程调度和坐标对齐这些渲染工程问题上。所以动手写代码之前先把“链路”和“选型”这两个地基打稳。2.1 一条完整分割链路不是“模型算完就结束”把功能拆开看一条完整的人像分割实时链路至少包含五个环节。一是输入侧。摄像头用 WebCamTexture视频文件用 VideoPlayer这两类输入源最终都要提供一个能被 Unity 纹理系统识别的 Texture 对象。二是预处理。把任意分辨率的画面裁剪、缩放成模型指定的输入尺寸常见的是 256x256 或 320x320 这种小分辨率完全没有必要把整帧 1080p 喂给模型。三是推理。模型输出一张与输入同尺寸的单通道概率图人物区域接近白背景区域接近黑部分模型还会顺带输出全身框、人脸框和全局置信度等元信息。四是回写。把概率图写入一张 RenderTexture成为渲染阶段可以采样的 Mask 遮罩。五是合成。用一段片元着色器根据 Mask 做背景替换、边缘羽化或者背景模糊。这五个环节里取帧跟着视频源走通常是 30fps推理不需要跟着全帧率走15fps 甚至 10fps 都能接受但合成必须跟上全帧率否则画面看起来就卡。这个“三段节奏”是后面所有参数调整的总纲。为什么要绕这么一大圈而不是 AI 模型直接输出一张渲染好的图因为背景替换不是简单地把人像贴回去而是要让分割结果和当前画面里的光照、边缘、运动模糊保持一致的观感。只有把遮罩交给 Shader由 GPU 逐像素做混合才能做到均匀自然的合成效果。若每一步都在 CPU 上拷贝大数组移动端内存带宽首先扛不住。2.2 分割模型怎么选固定分辨率、单通道输出、能导中间格式选模型这件事经常被误以为“越准越好”。在实时渲染场景里模型适配度和推理成本往往比精度更关键。我把常见的三类模型放在一起对比过差异很直接。模型类别典型特征实时性在这个场景的定位通用多类别语义分割能区分人、车、物等多个类别中低结构重除非你要做人体部位级特效否则杀鸡用牛刀轻量人像分割模型专门抠人输出单通道 Alpha高移动端常见首选适配摄像头/视频背景替换实例分割两阶段方案先检测再分割精度高低不适合端上全帧率一般用于服务端离线处理不碰选型时有三条硬性标准。第一输入分辨率要固定最好是 256 或 320 这种能被 2 的整数次幂整除的尺寸便于后续纹理对齐。第二模型输出必须是一个单通道概率图而不是动辄几十个通道的多分类结果多分类结果在后处理阶段会带来数倍的内存和带宽消耗。第三模型要能导出成 Unity 侧推理层支持的中间格式。常见做法是先在训练框架中把模型固化成标准中间格式再在 Unity 里创建一个配套的推理会话去加载它。这步如果选了一个环境受限的模型格式后面打包到安卓、iOS 时会非常痛苦。另外我一般会优先选择带元信息输出的模型。所谓元信息就是全身框、人脸框、整体置信度这一类附加输出。它们的用处很大当画面里没有人时用一个全局置信度做降级处理比硬着头皮把背景替换成一片错误的人像要稳妥得多。2.3 源码工程先搭好结构后面所有坑都有地方查工程结构决定了排查问题的效率。一套适配摄像头和视频画面双输入、可替换模型、可单独调合成效果的结构我会分成五个部分。目录 / 文件职责容易改错的地方Assets/Scripts/Input/CameraInput.cs摄像头权限、启动、取帧、旋转镜像旋转逻辑跟平台强相关Assets/Scripts/Input/VideoInput.csVideoPlayer 调度、循环、分辨率适配视频纹理的 Y 轴翻转Assets/Scripts/ML/SegmentationRunner.cs模型加载、推理节流、遮罩回写线程和生命周期Assets/Scripts/Render/CompositeRenderer.cs背景替换、背景模糊、降级策略Mask 与画面的对齐Assets/StreamingAssets/存放模型文件打包后读取路径特殊这个结构的核心思想是输入源、AI推理、画面合成三者彻底解耦。换模型时不碰渲染代码换输入源时不碰推理代码调背景效果时不碰 AI 代码。开发过程中每个人只碰自己负责的模块排查问题也更容易定位边界。训练代码则不应该放进 Unity 工程里。模型训练是一个独立的、节奏完全不同的工作流硬塞进来只会让仓库混乱还容易导致误触发重新训练之类的事故。Unity 工程里只需要放训练好的、已经导出为中间格式的模型产物。3. 在Unity3d工程里接推理从模型文件到遮罩纹理接入推理是整个功能中看起来最简单、实际最容易被绊住的一段。模型加载、会话生命周期、帧与线程的关系每一处都能踩出不一样的坑。下面按执行顺序把每个环节拆开讲。3.1 初始化推理会话的时机与生命周期常见的错误是在 Start() 里直接同步加载模型文件。一个小模型虽然只有几兆但解析、初始化、创建 GPU 缓冲加起来也足以让启动画面卡上一阵子。我在工程里通常用一个协程完成初始化把“读取文件→创建会话→创建中间纹理”这三步拆开让主线程可以继续渲染。public class SegmentationRunner : MonoBehaviour { private ISegmentationEngine engine; private Texture2D inputTex; private Texture2D outputTex; private RenderTexture maskRT; public IEnumerator InitializeModel(string modelName) { // 路径要拼接不能直接字符串连接 string path Path.Combine(Application.streamingAssetsPath, modelName); // Android 打包后 StreamingAssets 目录并不是普通文件系统路径 UnityWebRequest req UnityWebRequest.Get(path); yield return req.SendWebRequest(); byte[] modelData req.downloadHandler.data; engine new SegmentationEngine(modelData); yield return engine.LoadAsync(); int size engine.InputSize; inputTex new Texture2D(size, size, TextureFormat.RGBA32, false); outputTex new Texture2D(size, size, TextureFormat.RGBA32, false); maskRT RenderTexture.GetTemporary(size, size, 0, RenderTextureFormat.R8); } }这里我封装了一个ISegmentationEngine接口背后对应的就是你实际选用的推理后端。不同推理库在 Unity 里的加载方式差异很大但生命周期管理是共通的。代码里几个要点说一下用 UnityWebRequest 读取 StreamingAssets而不是 File.ReadAllBytes因为同一个路径在电脑上能读在安卓设备上大概率抛异常创建会话后马上准备好三块纹理。inputTex 负责把原始画面转成模型需要的输入张量outputTex 承接模型输出maskRT 是最终供 Shader 采样的遮罩。生命周期上有三条经验。第一一个模型会话在整个应用生命周期里只初始化一次不要在每次切换输入源时重新加载。第二切场景时不要让推理线程还在跑最好先停掉推理再切换否则移动端容易出现黑屏或崩溃。第三不要在析构函数里释放 GPU 纹理Unity 的渲染线程和主线程不是同步的释放操作要放到主线程里做。3.2 每帧的最小推理流程缩放、推理、回写遮罩初始化完成后每帧要做的事情可以压缩成三步把当前画面缩放到模型分辨率推理一次把输出回写成遮罩。这里最容易踩的坑是“把 GPU 纹理读回 CPU 再塞给模型”这一步如果写法不谨慎会直接把帧率打崩。private void RunInference(Texture source) { // 1. 统一缩放避免模型处理 1080p 大图 Graphics.Blit(source, scaleRT); // 2. 用异步读回避免阻塞渲染线程 AsyncGPUReadback.RequestIntoNativeArray(ref inputArr, scaleRT, 0, () { inferenceQueue.Enqueue(() { engine.Run(inputArr, outputArr); needUploadMask true; }); }); }代码里的scaleRT是一张和模型输入同尺寸的临时渲染纹理AsyncGPUReadback把这张纹理的内容异步取回 CPU 内存数组inputArr随后把推理任务丢到后台线程队列里。这里的关键是读回和推理都不在主线程同步执行否则手机上会直接卡成幻灯片。推理完成后再把outputArr写回纹理。回写遮罩时有一个性能细节不要一个个像素去 SetPixel那会慢到没法看。常见做法是先把输出数组转成Color32[]一次性交给 SetPixels32 再 Apply。private void UploadMaskToTexture(float[] maskData, int width, int height) { Color32[] colors new Color32[width * height]; for (int i 0; i colors.Length; i) { byte v (byte)(maskData[i] * 255f); colors[i] new Color32(v, v, v, 255); } outputTex.SetPixels32(colors); outputTex.Apply(false); Graphics.Blit(outputTex, maskRT); }这段代码把概率值转换成灰度值写入 RGBA 纹理再拷贝到 R8 格式的遮罩。R8 是单通道渲染纹理只存一个亮度值带宽比 RGBA32 少四倍。参数方面要留意模型输出的 float 数组长度必须等于 width * height如果模型内部做了下采样回写前要先做一次插值放大不然遮罩会是一块块马赛克。3.3 输出层不要做二值化连续Alpha才是常态很多示例代码会把模型输出大于 0.5 的像素设成纯白小于 0.5 的设成纯黑这样得到的遮罩边缘是一条硬边到合成阶段就会出现白边、锯齿和抖动。我一般直接让概率值保持连续把它当作 Alpha 用。模型输出的连续值本身就表示“这个像素有多大程度属于人”边缘像素的错误会被羽化逻辑吸收掉肉眼大概率看不出来。half mask tex2D(_MaskTex, uv).r; half soft 0.08; half alpha smoothstep(0.5 - soft, 0.5 soft, mask);soft就是羽化宽度0.08 是一个折中值。设大了人物边缘会虚设小了锯齿又压不住。这行代码出现在合成 Shader 的第一句后续的背景混合、背景模糊都依赖这个柔化后的 alpha。4. 摄像头与视频画面双输入取帧逻辑与像素对齐输入源是“摄像头”与“视频画面”并存的设计很多模块会在这里第一次出现诡异行为。因为摄像头在竖屏状态下有旋转和镜像视频文件则大多是横屏的 Y 轴翻转如果统一处理环节做少了最终合成画面上就会被模型“看出来”画面对不上。4.1 WebCamTexture 摄像头输入等帧、旋转和镜像WebCamTexture 的坑在于它启动后不是立刻有数据。如果你在调用 Play() 之后立刻去读 width 和 height大概率拿到 0。正确做法是挂一个协程等它内部真正产生第一帧。public IEnumerator StartCamera(bool frontFacing) { yield return Application.RequestUserAuthorization(UserAuthorization.WebCam); if (!Application.HasUserAuthorization(UserAuthorization.WebCam)) { yield break; } // 按摄像头名称找到对应设备这里是示意代码 _deviceName FindDesiredDeviceName(frontFacing); _webCam new WebCamTexture(_deviceName, 640, 480, 30); _webCam.Play(); // 等 width 从 0 变成真实值再继续初始化 while (_webCam.isPlaying false || _webCam.width 16) { yield return null; } }这里的 640x480 是刻意选的分辨率。模型输入只有 320x320取 1080p 的摄像头画面除了增加带宽开销没有任何收益。30 是请求帧率但这个值只是“请求”低端机上摄像头实际给多少帧要看驱动脸色。FindDesiredDeviceName 这部分要自己实现按名称找前后摄像头比按索引找更可靠因为不同设备的摄像头索引顺序并不一致。关于旋转和镜像竖屏时 WebCamTexture 的原始帧是横向的需要旋转 90 度或 270 度才能正立前置摄像头默认还有一个左右镜像问题。算法模型训练时见到的都是正立、不镜像的图所以这些变换要在喂给推理前端之前完成否则分割结果永远是歪的。4.2 VideoPlayer 视频输入统一进 RenderTexture 再喂模型视频输入的思路和摄像头不太一样。VideoPlayer 可以直接把内容输出到一张指定的 RenderTexture 上不需要像摄像头那样在 CPU 侧反复翻转。这样既省一次拷贝也保证后续统一处理时用的都是一个纹理类型。_videoRT new RenderTexture(960, 540, 0, RenderTextureFormat.Default); _player.renderMode VideoRenderMode.RenderTexture; _player.targetTexture _videoRT; _player.isLooping true; _player.Play();视频分辨率一般比摄像头高但也没必要直接上原片 4K。根据模型输入 320x320 来反推960x540 已经留足了缩放的余量。RenderTextureFormat.Default 对大多数平台都兼容如果是移动端可以考虑改成 ARGB32 以降低格式转换开销。循环播放记得用 isLooping否则视频播完最后一帧画面会变黑遮罩也会跟着失效。还需要处理 Y 轴翻转。VideoPlayer 输出到 RenderTexture 后uv 坐标的原点在左下角而很多视频素材的内容是自上而下排列的。我在合成 Shader 里统一约定源画面采样用 UV遮罩采样用同一个 UV如果发现人物在屏幕上上下颠倒就先单独修视频这一路而不是改推理代码。4.3 输入源切换的过渡处理切换即闪烁切换输入源比如从摄像头切到视频或者从前置摄像头切到后置摄像头画面会经历一个亮度跳变和分辨率跳变。这个瞬间 AI 模型的输出也会变得极不稳定遮罩会出现闪烁、白色闪屏甚至短暂丢人的现象。原因不在模型而在输入源的自动曝光和自动白平衡。摄像头在切换后的一两秒内画面亮度和色温还没有稳定下来模型看到的是异常输入自然给出异常结果。视频源虽然稳定但从视频到摄像头的切换仍然会触发一次类似的光照突变。我一般会在切换输入源时做一个“热备切换”先把新输入源启动起来等它产出了几帧有效数据后再真正把渲染指针切过去。同时暂停推理 2 到 3 帧这短暂的停顿在外观上几乎不可感知却能让模型避开最不稳定的过渡阶段。如果产品对实时性要求极高可以改成“遮罩冻结”切换期间继续显示上一帧遮罩等新输入稳定后再更新。5. 高频踩坑记录五个现象、原因与对应解决能走到这一步说明基础链路已经通了。接下来是真正花时间的部分把功能在不同设备、不同光照条件下跑稳。以下五条是我在类似工程里反复遇到的坑按现象、原因、解决三段写清楚。5.1 黑屏和宽高为 0摄像头启动的等待姿势不对现象开始预览后画面全黑或者 WebCamTexture.width 返回 0。在安卓部分机型上权限弹窗还没点完就继续执行后面的逻辑也会出现同样现象。原因WebCamTexture 是异步启动的Play() 之后底层驱动需要时间打开设备并产生第一帧。在帧数据真正到来之前width 和 height 都是无效值。另一个常见原因是权限申请的结果没有检查权限被拒绝时依然尝试启动摄像头。解决把启动流程放进协程先申请权限并确认结果再创建纹理并播放最后循环等待 width 大于 16。这个“16”是一个经验值有些驱动初始化时先报出低分辨率帧而后才切换到请求的分辨率等它稳定再继续最省事。5.2 分割遮罩横过来、左右手反了像素旋转和镜像问题现象模型分割结果和画面内容在空间上对不上人物在画面里是站着的分割结果却是横躺在边界上的或者左手变成右手。原因模型训练时使用的输入是“正立、无镜像”的 RGB 图像而摄像头原始帧带旋转和镜像信息这个信息不在纹理数据里它藏在 WebCamTexture 的 rotation 属性和垂直镜像状态里。直接把原始帧喂模型得到的结果自然是“模型眼中看到的画面”分割而不是“人眼中看到的画面”分割。解决把旋转和镜像逻辑收拢到 CameraInput 组件里对外只提供一个已经正立、已经按需镜像的 Texture。这样上层推理和合成都不需要关心来源。常见做法是借助 Shader 在 Blit 到 scaleRT 时完成旋转和翻转避免在 CPU 侧做像素级旋转。5.3 帧率掉到个位数同步推理和 GPU 读回是元凶现象编辑器里一切正常打包到真机后帧率从 60 掉到 5 到 8 之间机器发烫画面像在放幻灯片。原因推理没有被限流每一帧都在处理而且 ReadPixels 这类同步读回强制 GPU 停下等待 CPU 取数据这个同步等待每帧发生一次代价极高。解决先给推理加节流比如每两到三帧只推理一次。在移动端15fps 的推理频率配合 30fps 的合成观感上几乎没有差别。读取方式上把 ReadPixels 换成 AsyncGPUReadback让读回和推理都在后台流动不再阻塞主线程。如果还不行就把模型输入从 320 降到 256 或 192人像分割的精度损失在边缘处才看得出来。5.4 模型加载失败StreamingAssets 路径不能直接当物理路径现象在编辑器和 Windows 上运行正常打包成安卓包后模型加载失败报找不到文件或文件长度为 0。原因Application.streamingAssetsPath 在安卓上指向的是 APK 内部的压缩目录File.ReadAllBytes 这类物理文件接口根本访问不到它。iOS 也有差异只是没有安卓这么明显。解决统一用 UnityWebRequest 加载模型文件。处理完请求后把字节数组交给后面的会话加载器。如果你偏要在运行时把模型转存到可写目录也请用 UnityWebRequest 先读出来再写而不是尝试用文件路径直接打开。5.5 逆光人物“溶解”手机动态范围和模型分布的差异现象人物站在窗边逆光环境中分割边缘像被咬掉一样头发、肩膀区域出现黑色缺口甚至整套输出突然丢人。原因手机摄像头动态范围有限背景亮度高时人物面部和头发处于欠曝状态纹理内容几乎被“压扁”成一片暗色。模型训练数据里这类极端场景占比不足输出自然不稳定。解决在把画面喂给模型之前加一个轻量的曝光预处理。做法是先把画面缩小到 64x64 左右算出平均亮度再乘以一个缩放系数让输入画面的平均亮度回到模型训练集的常见区间。这个处理成本极低但对逆光场景有明显改善。如果某个瞬时帧实在无法稳定分割就保留上一帧遮罩宁可稍有迟滞也不要让画面出现黑白闪烁。6. 让分割结果变成可上线的虚拟背景边缘、置信度和验收遮罩拿到手只是第一步真正决定功能行不行的是边缘观感。一个锐利如刀切的分割结果放在视频会议里会显得很假但只要加一点羽化、一点时间滤波观感立刻会上去一个档次。6.1 遮罩后处理羽化、时间滤波和边缘收缩我在合成 Shader 里固定做两步后处理。第一步是时间滤波让遮罩变化不跟随帧逐像素跳动而是与上一帧做一次混合。第二步是羽化用 smoothstep 柔化边缘。如果边缘还有明显的“白边感”可以在羽化前先对遮罩做一次轻微腐蚀把半透边缘往里收一点人像边缘会更贴合被替换的背景。half currentMask tex2D(_MaskTex, uv).r; half prevMask tex2D(_PrevMaskTex, uv).r; half smoothed lerp(prevMask, currentMask, 0.35); half alpha smoothstep(0.42, 0.62, smoothed);0.35 是经验值。太小遮罩会拖尾太大时间滤波又起不到稳住边缘的作用。0.42 和 0.62 这对阈值要跟着实际模型微调模型输出整体偏保守就往下调输出偏激进就往上调。6.2 用置信度兜底没有人时别把真实背景漏出来如果选用的模型提供全局置信度这一定是值回票价的元信息。常见做法是置信度低于某个阈值时不直接替换成纯色背景而是退化成高斯模糊背景。因为纯色背景很容易让远处的大片人物区域变成一块剪影一下子暴露算法失败而模糊背景即使分割错了观众也看不出明显穿帮。这段逻辑放在 CompositeRenderer 里与推理层完全解耦。当置信度低时只切换背景处理分支遮罩照常生成。这也印证了 2.3 节把输入、推理、渲染拆开的必要性。6.3 验收这套分割AI功能的具体办法验收阶段我喜欢先录几段固定素材而不是每次都放真人在摄像头前测试。素材要覆盖四个典型场景室内顺光、窗边逆光、快速走动、从摄像头切换到视频素材。然后用同一组素材反复跑确认边缘稳定性。检查项测试素材通过标准边缘完整性头发丝、毛绒衣领10 秒内边缘可见丢帧不超过 2 帧背景遮挡人物快速挥手无超过 1 秒的大面积背景露出光照抗性窗边逆光人像头部轮廓连续 15 帧不丢失切换稳定性摄像头切视频再切回切换后 1 秒内画面恢复稳定性能底线低端机连续运行 5 分钟平均帧率达到目标值温度可控这套办法的价值在于让“分割效果变好”从感觉变成可执行的标准。我个人的习惯是每次换模型、升级 Unity 版本或换推理层之后不急着加新功能先把同一部测试视频在低端机上跑 5 分钟用帧率和温度说话。遮罩后处理调得再好如果机器发烫到自动降频直播或会议场景里一样会翻车。这个方向整体是值得投入的因为它绕开了传统绿幕的环境限制把背景替换从“固定机位、固定灯光”的约束里解放出来。但投入之前一定先确认你的目标机型性能基线够用——人像分割的实时表现有一半以上取决于推理层和渲染层的配合而不是模型本身。希望帮到你。本文还有配套的精品资源点击获取