ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Unity3D人体图像分割实战:摄像头采集与AI推理服务端架构全解析

Unity3D人体图像分割实战:摄像头采集与AI推理服务端架构全解析 简介一份面向Unity开发者的实时人体图像分割AI功能源码包基于BodyPix ONNX模型与Barracuda推理引擎实现对摄像头或视频画面的人物-背景分离并支持分割灵敏度调节与边缘模糊处理。压缩包共82个文件含26个asset工程资源、8个onnx模型、6个cs逻辑脚本、2个shader着色器及json/meta等辅助配置整体184.24MB可直接在Unity工程中部署。资源内置完整项目结构涵盖URP管线设置、ShaderGraph配置、测试预制体与文件选择对话框等模块便于理解WebCamTexture与VideoTexture的视频捕获机制以及C#脚本与Barracuda推理引擎的完整接入流程。已有226人学习下载适合AR、虚拟直播、体感交互等虚实融合场景的开发者上手门槛中等。1. 为什么“Unity3d人体图像分割”不能纯靠C#搞定做 Unity3D 开发的人接到“摄像头拍人实时把人从背景里抠出来”这种需求第一反应往往是去 Asset Store 找现成插件。但把“人体图像分割”和“AI”这两个词放进同一个标题里事情就没那么简单了分割不是绿幕抠像不能用 Color Key 或者边缘检测糊弄它要的是逐像素判断“哪个像素属于人”这背后是一套卷积神经网络在跑。C# 做业务逻辑很快但让它直接跑深度学习推理性能会很难看。我最早踩这个坑是在一个体感互动项目里客户要求用普通 USB 摄像头实时把人像抠出来替换背景。当时天真地以为 Unity 里接个 OpenCV 插件就能解决结果发现传统视觉在复杂背景、衣服颜色和背景接近、头发丝这些场景下全部翻车。后来把方案换成“摄像头采集 AI 推理分帧 掩码回传”的架构才真正把效果做到能交付。这篇笔记就是沿着这条链路把整个工程拆开讲摄像头和视频画面的采集怎么写AI 分割服务怎么搭掩码怎么在 Unity 里合成回画面以及在多摄像头、视频回放、性能瓶颈上那些没人愿意提前告诉你的坑。这个工程方向适合两类人一类是做互动投影、虚拟直播、体感游戏需要实时把人从画面里抠出来的 Unity 开发者另一类是手里有模型但不知道怎么接进 Unity 的算法工程师。前者缺 AI 落地的工程经验后者缺 Unity 侧的采集和渲染功底。这篇笔记的目标就是让这两类人都能照着把一端跑通。2. 先定架构再写码服务端推理回传为什么是Unity3D摄像头分割的首选2.1 三条技术路线的对比与选型理由把 AI 分割接进 Unity3D市面上能走的路无非三条一是用 Barracuda 这类神经网络推理库把模型转成 ONNX 后直接跑在 Unity 内二是写 Native Plugin 调 C 的推理引擎三是把画面帧通过网络发给一个 Python 服务推理完把掩码图回传给 Unity。我见过不少团队一上来就奔着第一条去觉得“全离线、不依赖网络”才是王道结果在模型转换和算子兼容性上耗掉两周。这里先把三条路放在一起对比。方案实时性集成难度模型灵活性适合场景Barracuda 端侧推理较好但有算子限制高需要转 ONNX 并处理不兼容层低只支持转成功的模型离线互动装置、演示机Native Plugin 调 C最好极高需要跨语言调试中对延迟极度敏感的商业项目WebSocket/HTTP 回传中等网络开销可优化低两端独立迭代高Python 侧随便换模型原型验证、中小型项目、内容更新频繁的场景我一般的建议是如果你的项目不是必须在无网环境跑优先选第三条。理由很现实——Unity 端只负责两件事采集画面、把回传掩码合成回去。模型训练、模型换新、输入预处理全在 Python 侧完成改模型不用重新出 Unity 包。对于“摄像头人体分割”这种需求业务方很可能今天要人像、明天要手势、后天要换背景服务端架构下这些改动只是换一个模型文件的事情。而且第三条路还有一个隐性好处调试成本低。分割效果不好时你可以直接用 Python 对着一张测试图调参不需要每次都在 Unity 编辑器里跑一遍。真机跑的时候再去处理延迟和帧同步。这个“先两端独立验证、最后联调”的习惯能让你少掉很多头发。2.2 坐标映射为什么掩码能直接叠回原图人体分割的 AI 模型输入是一张图输出是一张同尺寸的单通道掩码图每个像素的值表示该像素属于人体的概率。要把掩码叠回 Unity 的画面里最核心的前提是“输入帧的像素坐标系”和“Unity 渲染画面的坐标系”严格对齐。这个听起来简单实际翻车率极高因为涉及三个环节摄像头原始帧的尺寸和格式、传给 AI 前的缩放方式、Unity 侧纹理的坐标系。先说格式。摄像头的原始帧通常是 YUV 或 NV12而 AI 模型要的是 RGB。Unity 侧的 Texture2D 加载图片时默认是 RGBA32但摄像头帧你得自己转换。常见做法是用Texture2D.ReadPixels从 RenderTexture 里读出来这时Apply之后纹理的坐标系是“左上角原点”而 OpenCV 的imread读出来是“左上角原点”坐标刚好能对上。但如果传帧的时候用了 GPU 侧的Graphics.Blit做缩放再ReadPixels回来就要小心 RenderTexture 的尺寸是否和模型输入尺寸一致。再说尺寸。模型输入一般是 256x256、512x512 这种方形尺寸而摄像头是 16:9 或 4:3。不能直接拉伸否则人的脸部比例会变形分割边缘会错位。正确做法是等比缩放后居中填充灰色或黑色生成一个带 padding 的输入图掩码回来之后再把 padding 裁掉。这个映射关系必须写死成常量两端共享否则换一台摄像头就错位。2.3 推流管道设计帧率、压缩与带宽的取舍摄像头画面是连续帧流AI 推理服务不可能每帧都跑一次GPU 扛不住网络也扛不住。常见的做法是“Unity 侧抽帧发送推理服务只处理抽到的帧”。抽帧策略要按场景调静态展示场景 10-15 FPS 就够体感互动需要 20-30 FPS。注意摄像头本身的采集帧率是 30 FPS抽帧之后画面会有一卡一卡的感觉但分割结果不会丢因为掩码可以沿用上一帧。带宽是另一个容易被低估的坑。一张 1280x720 的 RGB 帧转成 JPEG 后大约 80-150KB按 15 FPS 算每秒约 1.2-2.2MB。局域网没问题但如果是远程调试就够呛。我一般会做两道压缩第一道把图像缩小到模型输入尺寸比如 512 宽再转 JPEG第二道调整 JPEG 质量因子到 75-85 之间。这两步做完单帧能压到 30-50KB即使 30 FPS 推流也才 1.5MB/s 左右普通千兆网完全无压力。传输协议上别用 HTTP 轮询。HTTP 的请求-响应模型对连续帧流来说是浪费而且延迟不可控。用 WebSocket 或者 UDP 都行WebSocket 自带重传和有序性实现简单UDP 延迟更低但需要自己处理丢包。我的经验是开发阶段先用 WebSocket跑通了再评估是否换 UDP。因为分割掩码这种数据丢一帧影响很小下一帧就补上了但如果是 HTTP 阻塞导致掩码迟迟不来Unity 主线程会被卡住。3. 最小可跑工程摄像头画面接入AI拿到人体掩码的完整链路3.1 Unity侧摄像头采集、Texture转换与WebSocket发送先跑通最简链路不追求效果只追求“画面能出去、掩码能回来”。Unity 侧用WebCamTexture就能满足大多数 USB 摄像头和笔记本内置摄像头。关键代码在帧数据的提取上WebCamTexture.GetPixels32()返回的是 Color32 数组转成 byte[] 时注意内存分配建议用Buffer.BlockCopy而不是逐像素赋值。// CameraSender.cs // 作用采集摄像头帧转成 JPEG 并通过 WebSocket 发送给推理服务 // 依赖需在 Unity 中导入 NativeWebSocket 插件 using System; using System.Collections; using System.Threading; using UnityEngine; using NativeWebSocket; public class CameraSender : MonoBehaviour { public WebCamTexture webCamTexture; public string serverUrl ws://127.0.0.1:8765; public int sendInterval 66; // 毫秒约 15 FPS private WebSocket ws; private byte[] jpgBuffer; private WebCamDevice[] devices; async void Start() { // 优先选择后置摄像头适合互动大屏场景 devices WebCamTexture.devices; for (int i 0; i devices.Length; i) { if (!devices[i].isFrontFacing) { webCamTexture new WebCamTexture(devices[i].name, 1280, 720, 30); break; } } if (webCamTexture null) { webCamTexture new WebCamTexture(devices[0].name, 1280, 720, 30); } webCamTexture.Play(); ws new WebSocket(serverUrl); ws.OnMessage (bytes) { /* 掩码回传处理见 SegmentComposer.cs */ }; await ws.Connect(); StartCoroutine(SendLoop()); } IEnumerator SendLoop() { while (true) { if (webCamTexture ! null webCamTexture.isPlaying ws.IsOpen) { // 等新帧到位再读取避免拿到重复的旧帧 yield return new WaitForEndOfFrame(); Texture2D snap new Texture2D(webCamTexture.width, webCamTexture.height, TextureFormat.RGBA32, false); snap.SetPixels32(webCamTexture.GetPixels32()); snap.Apply(); // 缩放到模型输入尺寸等比居中填充减少传输量 Texture2D resized ScaleTextureWithPadding(snap, 512, 512); jpgBuffer resized.EncodeToJPG(80); // 发送前带上帧序号用于回传掩码的帧同步 byte[] frameData new byte[jpgBuffer.Length 8]; BitConverter.GetBytes(Time.frameCount).CopyTo(frameData, 0); Array.Copy(jpgBuffer, 0, frameData, 8, jpgBuffer.Length); ws.Send(frameData); Destroy(snap); Destroy(resized); } yield return new WaitForSecondsRealtime(sendInterval / 1000f); } } }这段代码有几个参数要重点说明。sendInterval 66毫秒对应约 15 FPS这是均衡了 CPU 占用和流畅度的经验值如果你做的是体感游戏可以拉到 33 毫秒30 FPS但要注意移动端的发热会明显上升。EncodeToJPG(80)的质量因子也要留个心眼——超过 90 单帧体积会暴涨低于 70 分割边缘会出现锯齿状抖动80 是一个安全线。ScaleTextureWithPadding这个方法我没有贴实现但它的逻辑必须说清楚先计算原图宽高比和目标尺寸宽高比的差异在短边两侧补灰条再整体缩放。这样模型看到的画面不会变形后续掩码映射回原图时只需要按比例裁剪坐标误差能控制在 1 个像素以内。WebSocket 库的选择上Asset Store 里的 NativeWebSocket 和 UnityWebSocket 都可以用。前者基于底层 socket 实现不走 HTTP 升级延迟更低后者性能和前者接近但 API 更友好。如果项目要用老版本 Unity2019 以下注意 NativeWebSocket 可能需要自己编译 iOS 和 Android 的原生库。3.2 Python推理服务接收帧、跑模型、回传掩码Python 侧用 WebSocket 服务器接收帧推理模型用一个现成的分割模型跑。为了演示这里的推理函数可以先用一个“模拟模型”占位——直接返回一张全零掩码但把通信链路跑通。实际项目里把这个函数替换成你的 ONNX 模型或 PyTorch 模型即可。# segment_server.py # 作用接收 Unity 传来的 JPEG 帧执行分割把掩码编码后回传 # 依赖pip install websockets opencv-python numpy import asyncio import cv2 import numpy as np import websockets # 模拟推理函数实际项目中替换为 ONNX/TRT 推理 # 入参是 BGR 图像出参是 0~255 的单通道掩码 def infer_human_mask(image_bgr): # 占位实现返回全零掩码表示“没有检测到人” h, w image_bgr.shape[:2] mask np.zeros((h, w), dtypenp.uint8) # TODO: 加载模型 # session ort.InferenceSession(human_seg.onnx) # input_name session.get_inputs()[0].name # batch cv2.dnn.blobFromImage(image_bgr, 1/255.0, (512, 512), # swapRBTrue, cropFalse) # outputs session.run(None, {input_name: batch})[0][0] # mask (outputs * 255).astype(np.uint8) return mask async def handle_client(websocket, path): print(Unity client connected:, websocket.remote_address) try: async for message in websocket: # 消息布局前 8 字节帧序号之后是 JPEG 数据 frame_id int.from_bytes(message[:8], byteorderlittle) jpg_data np.frombuffer(message[8:], dtypenp.uint8) frame_bgr cv2.imdecode(jpg_data, cv2.IMREAD_COLOR) if frame_bgr is None: print(Decode failed, skip, frame_id) continue mask infer_human_mask(frame_bgr) # 掩码缩回 720p减少直接传输大图的开销 mask_resized cv2.resize(mask, (frame_bgr.shape[1], frame_bgr.shape[0]), interpolationcv2.INTER_LINEAR) # 压缩掩码PNG 对大面积平坦区域压缩比很高 ok, mask_png cv2.imencode(.png, mask_resized) if not ok: print(Mask encode failed) continue response bytearray() response.extend(frame_id.to_bytes(8, byteorderlittle)) response.extend(mask_png.tobytes()) await websocket.send(response) except websockets.exceptions.ConnectionClosed: print(Client disconnected) async def main(): async with websockets.serve(handle_client, 0.0.0.0, 8765): print(Segment server running on ws://0.0.0.0:8765) await asyncio.Future() if __name__ __main__: asyncio.run(main())这里有两个设计细节值得记下来。第一掩码用 PNG 而不是 JPEG 压缩是因为掩码只有两个值0 和 255PNG 的游程编码对这种图像压缩效率远高于 JPEG而且 JPEG 有损压缩会在边缘产生灰阶伪影导致 Unity 侧的锐化蒙版边缘发虚。第二frame_id的回传至关重要——Unity 主线程收到掩码后必须知道这个掩码对应的是哪一帧画面否则人已经移到左边掩码还在显示上一帧的人物位置画面边缘会出现明显的“鬼影”。3.3 Unity侧合成把掩码变成透明通道掩码回到 Unity 后要做的事情就是“把原图中掩码为黑色的像素变成全透明把白色的像素保留”然后叠加到任意背景上。这里有几个实现层次先用最简单的方式跑通。// SegmentComposer.cs // 作用接收 WebSocket 回传的掩码把掩码贴到原图上作为 Alpha 通道 // 挂载到 RawImage 上RawImage.texture 指向合成后的 RenderTexture using System; using UnityEngine; using UnityEngine.UI; using NativeWebSocket; public class SegmentComposer : MonoBehaviour { public RawImage outputImage; public Texture2D backgroundTexture; // 替换背景图 public int maskWidth 1280; public int maskHeight 720; private Texture2D maskTex; private Texture2D frameTex; // 缓存最新原始帧 private RenderTexture composeRT; // 合成输出 void Start() { maskTex new Texture2D(maskWidth, maskHeight, TextureFormat.R8, false); frameTex new Texture2D(maskWidth, maskHeight, TextureFormat.RGBA32, false); composeRT new RenderTexture(maskWidth, maskHeight, 0); outputImage.texture composeRT; // 注CameraSender 的 OnMessage 回调里要调用 HandleMask(byte[] data) } public void HandleMask(byte[] data) { int frameId BitConverter.ToInt32(data, 0); // 此处应比对 frameId 是否等于当前最新帧的 ID这里简化处理 // 实际项目中用一个 ConcurrentDictionary 存储未匹配帧 // 掩码数据是 PNG 编码用 ImageConversion.LoadImage 解码 byte[] pngData new byte[data.Length - 8]; Array.Copy(data, 8, pngData, 0, data.Length - 8); maskTex.LoadImage(pngData); // 合成遍历像素mask 128 保留原色否则透明 Color32[] srcPixels frameTex.GetPixels32(); Color32[] maskPixels maskTex.GetPixels32(); Color32[] outPixels new Color32[srcPixels.Length]; for (int i 0; i srcPixels.Length; i) { // 掩码是单通道 R8从 R 通道取灰度值 if (maskPixels[i].r 128) { outPixels[i] srcPixels[i]; } else { outPixels[i] new Color32(0, 0, 0, 0); // 全透明 } } Texture2D cutoutTex new Texture2D(maskWidth, maskHeight, TextureFormat.RGBA32, false); cutoutTex.SetPixels32(outPixels); cutoutTex.Apply(); // 把抠好的纹理和背景合成到 RenderTexture Graphics.Blit(cutoutTex, composeRT, new Vector2(1, 1), new Vector2(0, 0)); // 真正生产环境这里用 Shader 做 Alpha Blend逐像素赋值只是最小复现 } }逐像素遍历的做法在 1280x720 分辨率下每帧要遍历 92 万个像素纯 C# 跑大约 8-15ms勉强能接受但会占用主线程。正式项目里应该把这步替换成一个 Shader用 GPU 并行处理。方式是在 Shader 里采样两个纹理主纹理取 RGB掩码纹理取 R 通道作为 alpha然后输出。这样每帧耗时可以控制在 0.1ms 级别。上面代码保留逐像素版本是为了让逻辑透明新手能看懂“掩码到底怎么变成 alpha 的”。合成阶段的 RenderTexture 最好用Graphics.Blit而不是直接赋值给 RawImage.texture因为 RenderTexture 可以重复使用不会每帧产生 GC 压力。注意GetPixels32每次调用都会分配新数组所以frameTex要从摄像头采集处就维护好——也就是说CameraSender 发送帧的同时把这张 Texture 存一份给 SegmentComposer 用不要在这里再去WebCamTexture.GetPixels32()拉一次。4. 从摄像头切到视频文件Unity3D视频流分割的几个关键调整4.1 VideoPlayer 的像素读取方式与摄像头完全不同很多项目不是直接怼摄像头而是播一段录好的视频比如体感互动里的大屏待机循环画面。这个场景下WebCamTexture就不能用了要换成VideoPlayer。但 VideoPlayer 有一点坑它的texture属性在播放过程中不能直接GetPixels32()会报ReadPixels失败或者返回空白。因为 VideoPlayer 默认走的是硬件解码纹理在 GPU 侧CPU 读不到。正确做法是先把视频画面渲染到 RenderTexture再把 RenderTexture 作为输入源传给分割服务。这里涉及一个时序问题VideoPlayer 的帧更新和 Unity 的渲染循环不是严格同步的你需要监听frameReady事件在事件回调里读取 RenderTexture。注意frameReady在原生分辨率变化时也会触发要做一次分辨率判断避免后续缩放逻辑出错。// VideoFrameReader.cs // 作用从 VideoPlayer 中读取当前帧转换为自定义 Texture供 CameraSender 发送 using System.Collections; using UnityEngine; using UnityEngine.Video; public class VideoFrameReader : MonoBehaviour { public VideoPlayer videoPlayer; public RenderTexture videoRT; public int outputWidth 512; public int outputHeight 512; private Texture2D snapTex; void Start() { // 设置 VideoPlayer 渲染到 RenderTexture而不是直接显示 videoPlayer.renderMode VideoRenderMode.RenderTexture; videoPlayer.targetTexture videoRT; videoPlayer.isLooping true; videoPlayer.Play(); snapTex new Texture2D(outputWidth, outputHeight, TextureFormat.RGBA32, false); videoPlayer.frameReady OnFrameReady; } void OnFrameReady(VideoPlayer vp, long frameIndex) { // frameReady 在渲染线程触发这里不能立刻 ReadPixels // 用协程延迟到帧末读取 StartCoroutine(ReadFrameAtEndOfFrame()); } IEnumerator ReadFrameAtEndOfFrame() { yield return new WaitForEndOfFrame(); // 先把 RenderTexture 缩放到模型输入尺寸 RenderTexture currentRT RenderTexture.active; RenderTexture.active videoRT; // 创建临时纹理并缩放 RenderTexture scaledRT RenderTexture.GetTemporary(outputWidth, outputHeight, 0); Graphics.Blit(videoRT, scaledRT); // 从缩放后的 RenderTexture 读取像素 Texture2D temp new Texture2D(outputWidth, outputHeight, TextureFormat.RGBA32, false); temp.ReadPixels(new Rect(0, 0, outputWidth, outputHeight), 0, 0); temp.Apply(); // 交给分割发送模块 FrameSender.SendTexture(temp); RenderTexture.active currentRT; RenderTexture.ReleaseTemporary(scaledRT); Destroy(temp); } }关键点在于frameReady事件后的读取时机。这个事件是在视频解码线程触发的如果你在回调里直接ReadPixels极大概率拿到的是上一帧或半帧数据。所以我用了StartCoroutine(ReadFrameAtEndOfFrame())把真正的读取推到渲染管线末端保证 RenderTexture 里已经是完整的一帧。这种“事件驱动 延迟到帧末采样”的组合几乎能消除视频源撕裂的问题。outputWidth和outputHeight的设置要和分割模型的输入尺寸严格一致不要传原始 1080p 的画面过去。因为视频文件的码率通常比摄像头高1080p 的 JPEG 单帧能到 300KB传输和处理都会明显变慢。先缩到 512x512 再传既能保证人形轮廓足够清晰又能把网络负载压下来。4.2 视频帧率不同导致的推理节奏错位摄像头场景下Unity 端发送帧的频率由我们自己控制但视频文件播放有自己的帧率节奏——24 FPS、30 FPS、60 FPS 都有。如果 VideoPlayer 以 30 FPS 播放而我们的分割推理服务只能稳定跑 10 FPS那么 3 帧中就有 2 帧没有对应的新掩码。直接的表现是人物在动但掩码边缘像果冻一样拖着尾巴。解决思路是建立一个“最新掩码缓存池”。视频帧持续采集发送推理服务持续回传但合成模块永远只使用“最近一次成功回传的掩码”。如果掩码太旧超过 200ms就暂停合成输出等待新掩码到来。这种策略比强行降低视频帧率要好因为视频画面本身是流畅的只是分割层偶尔卡顿肉眼对边缘的短暂停顿远比对整体画面的卡顿更宽容。另一个思路是主动降低发送帧率。比如视频是 30 FPS我们只发第 0、6、12、18、24 帧相当于 5 FPS 的推理节奏。这个做法适合对实时性要求不高的场景能大幅减少 Python 服务的负载。但注意 VideoPlayer 要继续以 30 FPS 播放不能停否则画面会连带着卡。4.3 视频源的分辨率突变与播放器兼容视频文件有个摄像头没有的问题视频本身可能包含多个分辨率段落比如广告片前 5 秒是 1920x1080后面压成了 1280x720。VideoPlayer 在切换分辨率时可能触发texture重建旧 RenderTexture 会被释放如果你在frameReady里没有判断videoRT.width和videoRT.height的变化Graphics.Blit就会报错或者输出黑屏。我的习惯是监听videoPlayer.texture的尺寸变化在变化后重建 RenderTexture 和输出 Texture。同时发送给 Python 服务的协议里要带上一帧的原始宽高因为掩码回传后需要知道该缩放到什么尺寸才能和原始帧对齐。这个信息我以前偷懒没传结果换了几个视频素材后掩码全部错位排查了很久才发现是素材分辨率不一致导致的。播放器兼容性上Windows 下 VideoPlayer 对 MP4 的 H.264 编码支持良好但遇到 H.265/HEVC 编码的视频会黑屏。解决方案是让美术和素材同学统一输出 H.264 AAC 的 MP4这是 Unity 反正最稳的搭配。如果项目必须支持 H.265就得引入系统播放器插件比如 AVPro Video但这会引入授权成本非必要不碰。5. 避坑人体分割工程里的5个真实翻车现场5.1 掩码回传后人物边缘出现黑色描边现象合成后的画面里人物轮廓周围有一圈 1-2 像素的黑色或者暗色边尤其在头发和衣服边缘最明显。原因这个黑的来源是 JPEG 压缩的 ringing 效应。摄像头帧经过EncodeToJPG(80)压缩在人物边缘附近会产生振铃伪影像素值不是纯 0 或纯 255而是过渡灰阶。掩码本来也应该是二值的但模型输出的掩码在边缘天然是软过渡如果你直接用 128做硬切割过渡区里原本属于背景的暗色像素被保留成半透明视觉上就是黑边。解决把掩码二值化的阈值调高到 200并在二值化之前对掩码做一个轻微的高斯模糊kernel size 3 或 5。高斯模糊会让掩码边缘向外扩一点把 JPEG 振铃区域盖住然后用 200 的阈值切割。另一个辅助技巧是在合成 Shader 里对 alpha 做 1 像素的侵蚀erode把最外圈半透明像素丢掉。注意这两步不能在 Python 侧做完就完事因为 Unity 侧的 RawImage 采样是有双线性插值的边缘过渡会被插值再次放大。5.2 摄像头画面在 Unity 里是横着的或镜像的现象笔记本内置摄像头采集出来的画面在 RawImage 里显示时左右是反的或者转了 90 度。头部动左边画面里的人头往右动。原因WebCamTexture对内置摄像头的默认行为是带镜像的这是为了让用户在视频聊天里看到自己时符合“照镜子”的直觉。但做分割抠像时后半段合成用的是摄像头原始像素前半段传输给 AI 服务的也是原始像素两端都镜像了叠加回去没问题。但如果你只在显示层做了镜像AI 服务拿到的却是未镜像的帧掩码就和原图错位。解决在发送给 AI 服务之前做一次水平翻转让发送帧和显示帧保持一致。具体做法是在Texture2D像素数组上做for (int y 0; y height; y)内部做列交换或者用一个简单的 Shader 在Blit时翻转。注意翻转要在缩放之前做否则等比缩放的 padding 会跟着出错。外接 USB 摄像头一般不需要翻转但要在Start里检查WebCamTexture.devices的isFrontFacing字段前摄像头才做翻转。5.3 Python侧推理服务内存不断上涨最后崩溃现象分割服务运行半小时后内存占用从 1GB 涨到 4GB然后被系统 OOM killer 杀掉。重启服务后一切正常反复复现。原因cv2.imdecode每帧返回一个新的 numpy 数组而infer_human_mask函数内部如果用了 PyTorch/ONNX 的 Python API输入张量每次都要从 numpy 复制到 GPU 显存或 CPU 内存。如果推理函数里有torch.no_grad()但没有del显式释放中间变量Python 的 GC 可能来不及回收。另一个隐藏泄漏点是 WebSocket 的send调用如果客户端消费速度跟不上asyncio的发送队列会无限堆积。解决在推理函数里杜绝隐式复制。image_bgr转输入张量时用np.ascontiguousarray确保内存连续推理结束后立即del output, batch再调用gc.collect()。发送响应前检查websocket.send的返回值如果失败或者队列积压超过 20 帧直接丢弃掩码而不是继续塞队列。生产环境建议用uvloop替代 asyncio 默认的事件循环吞吐量能提升 20-30%内存碎片也会减少。5.4 多摄像头接入时 WebCamTexture 串台现象机器上插了 3 个摄像头Unity 启动时用设备名去匹配但经常匹配到错误的摄像头或者播放时画面只显示了其中一个摄像头的数据。原因WebCamTexture.devices的排列顺序在不同机器上不一致Windows 下 UVC 设备的枚举顺序由驱动的注册表决定同一个摄像头换个 USB 口插入顺序就会变。直接用索引去拿设备是典型的业余做法。解决启动时打印所有设备名并根据设备描述特征做匹配——比如海康威视的摄像头名称通常包含型号字符串USB 采集卡则包含“USB Video”把业务摄像头和聊天摄像头区分开。更稳妥的做法是在配置界面里做一个下拉列表让用户手动选择首次选择后把设备名写入 PlayerPrefs下次启动优先读取。绝对不要硬编码索引否则你会在客户现场被反反复复的摄像头顺序问题耗到崩溃。5.5 RTSP 网络摄像头的延迟与断线重连现象项目用了网络摄像头RTSP 协议通过VideoPlayer.url rtsp://...接入画面有 3-5 秒的巨大延迟而且摄像头一重启 Unity 就黑屏不恢复。原因VideoPlayer 的 RTSP 支持是通过 Windows Media Foundation 实现的它会缓冲大量数据来保证流畅天然引入高延迟。断线后 VideoPlayer 不会自动重试isPlaying会变成 false 但不会触发任何重连逻辑。解决RTSP 摄像头不要用 VideoPlayer用第三方的 RTSP 插件比如 uRTSPro直接拉流到 RenderTexture这类插件一般内置了低延迟模式和断线重连策略。如果不想花钱就把 RTSP 流转成 RTMP 或直接抓帧推到 Unity 的 Texture2D 上——用 FFmpeg 在 Python 侧拉流截图再通过 WebSocket 传给 Unity。反正我们的架构里已经有一条 WebSocket 通道了顺便传画面帧不增加额外复杂度。最终的延迟可以控制在 200ms 以内配合分割掩码的回传体验上完全可接受。6. 进阶把模型搬到端侧Barracuda以及最后一步掩码后处理如果项目要求完全离线或者网络环境不稳定就必须走 Barracuda 端侧推理了。这条路我完整走过一遍先说结论可行但不要指望把任何 PyTorch 模型直接拖进 Unity 就能跑。Barracuda 的算子支持有上限RNN、上采样层、部分注意力机制的算子转换时最容易报错。具体流程是PyTorch 模型先转 ONNX再用 Unity 的 ONNX 转 Barracuda 工具链转成.nn文件。转之前要手动检查模型里有没有nn.Upsample的align_cornersFalse参数Barracuda 对这类的支持经常出问题解决方案是在 PyTorch 侧把上采样换成nn.UpsamplingBilinear2d或者nn.ConvTranspose2d。模型结构越简单越好我最终跑通的是一个轻量 U2Net 的剪枝版本输入尺寸 320x320在 GTX 1060 上推理耗时约 15ms在手机端约 40-60ms基本满足互动场景需求。端侧推理的另一个麻烦是 CPU 和 GPU 的加载路径不同。Barracuda 的WorkerFactory.CreateWorker可以指定WorkerFactory.Device.GPU或CPU但 GPU 路径下还需要把输入纹理传到 GPU 显存这个传的过程如果用的是Texture2D.GetPixels再SetPixels效率会非常低。正确方式是输入用RenderTexture直接喂给worker.Execute的RenderTexture重载全程不落到 CPU 内存。但代价是 RenderTexture 的布局要严格是 RGBA32掩码输出也得从 GPU 侧读回来才能合成——这就又回到ReadPixels的开销问题上。实测下来小尺寸输入320x320读回来反而比纯 CPU 推理更快因为 GPU 推理省掉的时间远超回读损耗。最后一个值得打磨的点是掩码的时间平滑。神经网络输出的掩码在连续帧之间会有像素级的抖动直接叠上去会看到人物边缘像水波纹一样闪。我常用的做法是维护一个掩码数组新旧掩码按 0.7/0.3 的比例做逐像素加权平均。这个操作在 Python 侧做只需要两行 numpy在 Unity 侧用 Shader 做也就 5 行代码。但效果立竿见影——边缘闪烁肉眼基本消失人物轮廓变得稳定。注意在人物快速移动时这个平滑会留下 2-3 帧的拖影所以平滑系数要按场景动态调整静止展示用 0.7/0.3体感游戏降到 0.5/0.5。我从这个工程里学到的最重要一课是AI 分割的难点从来不在“让模型动起来”而在“让模型持续稳定地产出可用结果”。模型指标再好看接不进实时管线就没有意义。这条管线里每一个环节——采集、压缩、传输、推理、后处理、合成——都值得单独打磨任何一个环节出问题最终效果都归零。我的习惯是先把一条最简链路跑通哪怕先用模拟掩码再逐步替换真实组件这样排查问题时定位边界会非常清晰。希望这篇笔记能帮你在做 Unity3D 人体分割时少走一段弯路。本文还有配套的精品资源点击获取
返回列表