Unity Sentis部署YOLOv8:移动端实时目标检测与NMS后处理实战

发布时间:2026/7/31 7:41:08
Unity Sentis部署YOLOv8:移动端实时目标检测与NMS后处理实战 1. 项目概述为什么要在Unity里跑YOLOv8最近在做一个AR应用的原型需要在手机摄像头实时画面里识别出特定的物体。一开始想图省事直接用现成的云API但实测下来延迟和网络稳定性都是大问题离线场景更是直接歇菜。所以把模型塞到手机里本地运行成了唯一靠谱的选择。YOLOv8作为当前目标检测的“当红炸子鸡”精度和速度的平衡做得相当不错社区生态也活跃各种转换工具和部署方案层出不穷。而Unity早已不是那个只能做游戏的引擎了它的跨平台能力和AR Foundation框架让它成了移动端AR应用开发的事实标准。Sentis就是Unity官方推出的那个专门用于在Unity运行时中运行神经网络模型的包可以理解为Unity版的“推理引擎”。听起来很美好对吧但当你真的把从PyTorch导出的YOLOv8模型通常是.pt或.onnx格式拖进Unity挂上Sentis准备在Android手机上大展拳脚时大概率会懵怎么识别框到处都是一个目标能给你画出七八个框叠在一起这就是缺少了关键的后处理步骤——非极大值抑制Non-Maximum Suppression, NMS。原始的YOLOv8模型输出的是大量的候选框NMS的作用就是把那些重叠的、指向同一个物体的框合并成一个最准的。所以这个项目的核心就清晰了把一个“裸”的YOLOv8模型在Unity Sentis环境下手动给它“穿上”NMS这件衣服然后打包成一个能在Android真机上流畅运行的APK。这不仅仅是拖个模型进去那么简单它涉及到模型输出的解析、NMS算法的C#实现、与Unity渲染循环的集成以及针对移动端的性能优化是一套完整的工程化流程。2. 核心思路与方案选型2.1 为什么选择Sentis而不是Barracuda或其他Unity生态里能跑模型的方案不止一个。老牌的Barracuda现在叫Unity Sentis的前身之一很多人用过社区教程也多。那为什么选Sentis主要原因有三点官方正统与未来支持Sentis是Unity Technologies官方在2023年力推的解决方案代表着Unity在AI推理领域的未来方向。它的API设计更现代文档虽然还在完善和官方示例的更新也更及时。选择Sentis意味着你能更好地兼容Unity未来的版本更新遇到问题去官方论坛求助也更容易得到响应。性能与硬件加速Sentis在设计之初就深度集成了各平台的原生推理后端。在Android上它会优先尝试调用NNAPIAndroid Neural Networks API来利用手机的NPU神经网络处理单元或GPU进行加速。如果设备不支持NNAPI则会回退到CPU计算。这种硬件感知的调度对于移动端追求实时性的应用至关重要。Barracuda虽然也有GPU支持但在移动端的优化和硬件适配广度上Sentis更胜一筹。模型格式支持Sentis原生支持ONNX格式。而YOLOv8官方提供了非常便捷的export功能可以直接导出为ONNX省去了中间转换的麻烦也减少了精度损失的风险。当然Sentis目前还是相对较新的技术社区踩坑的案例不如Barracuda多有些高级功能可能需要自己摸索。但权衡下来为了项目的长期维护和性能上限我选择了Sentis。2.2 模型处理流程全貌整个流程可以拆解为以下几个关键阶段我画了一个简单的示意图来帮助理解[PyTorch YOLOv8模型] ↓ (export) [ONNX模型 (无NMS)] ↓ (导入Unity) [Unity Sentis Runtime] ↓ (推理) [原始输出张量 (e.g., 1x84x8400)] ↓ (C#脚本解析) [解码边界框、置信度、类别] ↓ (C#实现NMS算法) [过滤后的检测框] ↓ (Unity坐标系转换) [屏幕空间UI绘制/3D物体生成]核心决策点在哪里做NMS这是一个关键设计选择。理论上NMS也可以作为模型的一部分在导出ONNX前用PyTorch代码实现并“烧录”进模型里。但我不推荐这么做原因有二灵活性NMS的参数如IoU阈值、置信度阈值在应用开发阶段经常需要调整以平衡召回率和精度。如果固化在模型里每次调参都需要重新导出模型流程繁琐。兼容性某些简单的NMS算子可能无法顺利导出为ONNX或者在不同推理引擎上产生不一致的行为。在C#端实现完全可控也便于调试。因此我采用了“模型输出原始预测 C#端后处理”的方案这也是移动端部署的常见做法。3. 环境准备与模型导出3.1 Unity项目与Sentis配置首先你需要一个Unity项目建议使用2022.3 LTS或更新版本对Sentis支持更好。安装Sentis包打开Package Manager选择“Unity Registry”搜索“Sentis”并安装。或者通过manifest.json文件添加依赖com.unity.sentis: 1.2.0请使用最新版本。设置播放器转到File - Build Settings确保平台切换到Android。点击Player Settings进行关键配置Other SettingsScripting Backend: 选择IL2CPP。这是必须的因为Sentis需要AOT提前编译支持。Target Architectures: 勾选ARMv7和ARM64。确保覆盖绝大多数Android设备。Minimum API Level: 设置为24 (Android 7.0)或更高以确保NNAPI的可用性。Publishing Settings找到Build区域下的Minify选项对于调试阶段建议先设置为None或Proguard如果用了Proguard需配置规则保留Sentis相关类避免被混淆。3.2 从YOLOv8导出正确的ONNX模型这是最容易出错的一步。你需要Ultralytics的YOLOv8库。pip install ultralytics onnx假设你有一个训练好的模型yolov8n_custom.pt使用以下Python脚本导出from ultralytics import YOLO # 加载模型 model YOLO(yolov8n_custom.pt) # 关键导出为ONNX注意参数 success model.export( formatonnx, # 导出格式 imgsz640, # 输入图像尺寸必须与训练时一致或为倍数 opset12, # ONNX算子集版本12或13比较稳定 simplifyTrue, # 启用ONNX Simplifier简化模型非常重要 dynamicFalse, # 对于移动端部署建议固定批次和尺寸以获得更好优化 batch1, # 固定批次为1实时推理通常是单张图 nmsFalse # 核心不要导出内置NMS我们要自己实现 )注意dynamicFalse和batch1是为了让ONNX模型输入输出维度是固定的例如[1, 3, 640, 640]。这有助于Sentis和底层硬件进行更积极的图优化。simplifyTrue能移除很多冗余算子显著减小模型体积并提升推理速度。导出后你会得到一个.onnx文件。强烈建议用Netron一个开源模型可视化工具打开它检查输入输出输入应该是一个名为images、形状为[1, 3, 640, 640]、数据类型为float32的张量。注意通道顺序是RGB。输出对于不带NMS的YOLOv8通常是一个名为output0、形状为[1, 84, 8400]的张量。这里的84 4(bbox坐标) 80(COCO类别数)8400是锚点数量基于输入尺寸和模型结构。你的输出形状可能不同请务必记下因为后续C#代码解析依赖这个形状。4. Unity中集成模型与推理流水线4.1 加载模型并创建推理引擎将导出的.onnx文件拖入Unity项目的Resources文件夹或任意StreamingAssets文件夹下。我更喜欢StreamingAssets因为它可以在运行时动态加载便于热更新模型。创建一个C#脚本例如YOLOv8Detector.cs。using UnityEngine; using Unity.Sentis; // 引入Sentis命名空间 public class YOLOv8Detector : MonoBehaviour { [SerializeField] private ModelAsset modelAsset; // 拖拽赋值如果放在Resources下 // 或者 [SerializeField] private string onnxModelPath StreamingAssets/yolov8n_custom.onnx; private Model _runtimeModel; private IWorker _worker; private TensorFloat _inputTensor; private const int InputSize 640; // 与导出时一致 private const int NumClasses 80; // COCO是80自定义模型需修改 async void Start() { // 方式1通过ModelAssetResources // _runtimeModel ModelLoader.Load(modelAsset); // 方式2通过文件路径StreamingAssets string fullPath Path.Combine(Application.streamingAssetsPath, onnxModelPath); byte[] modelData await File.ReadAllBytesAsync(fullPath); // 注意异步读取 _runtimeModel ModelLoader.Load(modelData); // 创建Worker指定后端。Auto模式会优先选择最快的可用后端。 _worker WorkerFactory.CreateWorker(BackendType.GPUCompute, _runtimeModel); // 对于Android可以尝试 BackendType.NNAPI如果设备支持 // 预分配输入Tensor _inputTensor new TensorFloat(new TensorShape(1, 3, InputSize, InputSize)); } void OnDestroy() { _worker?.Dispose(); _inputTensor?.Dispose(); } }实操心得Worker的创建比较耗时尤其是第一次。务必在场景加载初期或空闲时完成避免在摄像头帧回调中创建否则会造成卡顿。对于BackendType的选择在编辑器里用GPUCompute如果支持或CPU调试在Android真机上使用WorkerFactory.CreateWorker(BackendType.Auto)让Sentis自动选择最佳后端通常是NNAPI GPU CPU。4.2 图像预处理从Texture到模型输入模型需要归一化后的[1, 3, H, W]的float32张量。我们需要从摄像头或图片中获取Texture2D并进行缩放、裁剪、归一化。using Unity.Sentis; using UnityEngine; private TensorFloat PreprocessTexture(Texture2D texture) { // 1. 缩放与裁剪到InputSize RenderTexture rt RenderTexture.GetTemporary(InputSize, InputSize, 0); Graphics.Blit(texture, rt); Texture2D resizedTex new Texture2D(InputSize, InputSize, TextureFormat.RGB24, false); RenderTexture.active rt; resizedTex.ReadPixels(new Rect(0, 0, InputSize, InputSize), 0, 0); resizedTex.Apply(); RenderTexture.ReleaseTemporary(rt); RenderTexture.active null; // 2. 获取像素数据并归一化 Color32[] pixels resizedTex.GetPixels32(); float[] tensorData new float[3 * InputSize * InputSize]; // YOLOv8通常使用0-1范围归一化且通道顺序为RGB for (int i 0; i pixels.Length; i) { int idx i * 3; tensorData[idx] pixels[i].r / 255.0f; // R tensorData[idx 1] pixels[i].g / 255.0f; // G tensorData[idx 2] pixels[i].b / 255.0f; // B } Destroy(resizedTex); // 及时销毁临时纹理 // 3. 将数据复制到预分配的Tensor中避免每次new Tensor // 这里简化演示实际可以使用TensorFloat.FromTexture等更高效API // 但需注意API的通道和布局要求 NativeTensorArrayfloat nativeArray _inputTensor.MakeReadableTensorArray() as NativeTensorArrayfloat; // ... 将tensorData复制到nativeArray ... // 更高效的做法是使用Compute Shader或AsyncGPUReadback进行预处理特别是对于摄像头连续帧。 return _inputTensor; // 返回预处理好的Tensor }注意事项预处理是性能瓶颈之一。对于实时摄像头应用上述CPU端的逐像素循环操作是致命的。最佳实践是使用Compute Shader在GPU上完成缩放、通道重排和归一化然后通过TensorFloat.FromTexture或类似接口直接生成Tensor可以避免CPU-GPU之间的昂贵数据回读。Sentis的示例项目中通常有更高效的预处理代码务必参考。4.3 执行推理与获取原始输出预处理完成后就可以执行推理了。private float[] RunInference(Texture2D inputTexture) { TensorFloat input PreprocessTexture(inputTexture); // 执行推理阻塞式对于实时应用考虑使用ScheduleExecution异步 _worker.Execute(input); // 获取输出。输出节点的名字需要和你用Netron看到的一致通常是output0 TensorFloat outputTensor _worker.PeekOutput() as TensorFloat; // 将Tensor数据读取到C#数组中进行后处理 // 注意PeekOutput()返回的Tensor可能仍在GPU上使用MakeReadableOnCPU确保可读 using TensorFloat cpuTensor outputTensor.DeepCopy() as TensorFloat; // 复制到CPU // 或者对于异步outputTensor.AsyncReadback(); float[] outputData cpuTensor.ToReadOnlyArray(); input.Dispose(); // 如果input不是预分配的需要销毁 // outputTensor 由worker管理通常不需要手动Dispose除非你DeepCopy了 return outputData; }现在你拿到了outputData一个一维的float数组。它的形状对应着之前Netron里看到的[1, 84, 8400]你需要根据这个形状来解析。5. 核心C#端解析YOLOv8输出与NMS实现这是整个项目的技术核心也是和单纯跑通Demo最大的区别。5.1 解析模型原始输出假设我们的输出形状是[1, 84, 8400]即batch1, channels84, height8400 Sentis有时会以NHWC或NCHW布局返回需要根据模型确认这里假设是NCHW。我们需要遍历这8400个预测每个预测有84个值前4个值(cx, cy, w, h)是相对于该网格/锚点的中心点坐标和宽高需要经过公式转换到相对于输入图像640x640的绝对坐标。第5个值目标置信度objectness score。后80个值每个类别的条件概率。public class Detection { public Rect BBox; // 矩形框 (x, y, width, height)相对于输入图像(640x640) public float Confidence; // 置信度 objectness * max(class_probability) public int ClassId; // 类别ID } private ListDetection ParseRawOutput(float[] outputData, float confidenceThreshold 0.25f) { ListDetection detections new ListDetection(); int numPredictions 8400; // 根据你的模型调整 int infoPerPrediction 84; // 4(bbox) 1(obj) 80(cls) for (int i 0; i numPredictions; i) { int baseIndex i * infoPerPrediction; // 1. 解析bbox (cx, cy, w, h) float cx outputData[baseIndex]; float cy outputData[baseIndex 1]; float w outputData[baseIndex 2]; float h outputData[baseIndex 3]; // 2. 转换到绝对坐标 (xywh格式) float x cx - w / 2; float y cy - h / 2; Rect rect new Rect(x, y, w, h); // 3. 获取目标置信度 float objectness outputData[baseIndex 4]; if (objectness confidenceThreshold) continue; // 初步过滤 // 4. 找到最大类别概率 int maxClassId 0; float maxClassProb 0f; for (int c 0; c NumClasses; c) { float prob outputData[baseIndex 5 c]; if (prob maxClassProb) { maxClassProb prob; maxClassId c; } } // 5. 计算最终置信度 float finalConfidence objectness * maxClassProb; if (finalConfidence confidenceThreshold) continue; detections.Add(new Detection { BBox rect, Confidence finalConfidence, ClassId maxClassId }); } return detections; }5.2 实现非极大值抑制NMSNMS的目的是去除冗余框。其核心思想是按置信度排序所有检测框从最高置信度的框开始将其与所有其他框计算IoU交并比如果IoU超过某个阈值如0.45则认为它们检测的是同一个物体抑制掉置信度较低的那个。private float CalculateIoU(Rect a, Rect b) { float x1 Mathf.Max(a.x, b.x); float y1 Mathf.Max(a.y, b.y); float x2 Mathf.Min(a.x a.width, b.x b.width); float y2 Mathf.Min(a.y a.height, b.y b.height); float interArea Mathf.Max(0, x2 - x1) * Mathf.Max(0, y2 - y1); float unionArea a.width * a.height b.width * b.height - interArea; return unionArea 0 ? interArea / unionArea : 0f; } private ListDetection ApplyNMS(ListDetection detections, float iouThreshold 0.45f) { // 1. 按置信度降序排序 detections.Sort((a, b) b.Confidence.CompareTo(a.Confidence)); ListDetection results new ListDetection(); while (detections.Count 0) { // 2. 取出当前置信度最高的框 Detection current detections[0]; results.Add(current); detections.RemoveAt(0); // 3. 遍历剩余框计算IoU并移除重叠度高的 for (int i detections.Count - 1; i 0; i--) { if (CalculateIoU(current.BBox, detections[i].BBox) iouThreshold) { detections.RemoveAt(i); } } } return results; }性能提示上述NMS实现是基础的CPU版本在检测框很多时1000可能成为瓶颈。对于生产环境可以考虑使用更高效的算法如Fast NMS、Matrix NMS或者使用System.Numerics.Vector进行SIMD优化。在Job System/Burst中实现利用Unity的C# Job System和Burst编译器将NMS计算并行化能极大提升性能。提前过滤在解析原始输出时使用一个较高的confidenceThreshold如0.5进行初筛能显著减少进入NMS的框数量。5.3 坐标转换与结果渲染经过NMS过滤后我们得到了在640x640输入空间中的检测框。现在需要把它们转换到屏幕空间进行绘制。private void ProcessAndRenderDetections(ListDetection filteredDetections, Texture2D sourceTexture) { // sourceTexture是原始输入纹理如摄像头纹理尺寸可能与模型输入640x640不同 float scaleX (float)sourceTexture.width / InputSize; float scaleY (float)sourceTexture.height / InputSize; // 假设我们有一个在UI Canvas上绘制的脚本 foreach (var det in filteredDetections) { // 转换到原始图像坐标 Rect screenRect new Rect( det.BBox.x * scaleX, det.BBox.y * scaleY, det.BBox.width * scaleX, det.BBox.height * scaleY ); // 注意Unity UI的坐标系原点在左上角而纹理/模型坐标原点可能在左下角或左上角。 // 需要根据你的渲染方式调整Y坐标。例如如果使用Screen Space - Overlay的UI // screenRect.y sourceTexture.height - screenRect.y - screenRect.height; // 调用你的绘制方法例如绘制一个UI矩形和标签 DrawBoundingBox(screenRect, det.ClassId, det.Confidence); } }DrawBoundingBox的实现取决于你的渲染方式可以是给RawImage覆盖一个带有RectTransform的预制体也可以使用GL或Graphics.DrawMesh在3D空间绘制或者使用CommandBuffer进行后处理绘制。6. Android真机部署与性能优化实战6.1 打包设置与关键配置在Build Settings中点击Build之前确保Graphics APIs在Player Settings - Other Settings - Rendering下只保留Vulkan和OpenGLES3。对于现代Android设备Vulkan性能通常优于OpenGL ES3。可以尝试先只用Vulkan如果遇到兼容性问题再添加OpenGLES3作为备选。Multithreaded Rendering勾选Multithreaded Rendering。这对于释放主线程压力让渲染和推理更好地并行至关重要。Strip Engine Code在Player Settings - Publishing Settings中可以勾选Strip Engine Code以减少包体但要做好Link.xml配置防止Sentis需要的原生库被错误剥离。一个基础的Assets/link.xml可能如下linker assembly fullnameUnity.Sentis preserveall/ assembly fullnameUnity.Sentis.Half preserveall/ assembly fullnameUnity.Barracuda preserveall/ !-- Sentis可能依赖 -- /linkerAndroid Manifest权限确保Assets/Plugins/Android/AndroidManifest.xml中包含了相机权限如果使用摄像头uses-permission android:nameandroid.permission.CAMERA / uses-feature android:nameandroid.hardware.camera android:requiredtrue /6.2 性能分析与优化策略在真机上运行后使用Unity Profiler通过ADB连接或Android Studio Profiler来分析性能瓶颈。通常瓶颈出现在GPU图像预处理缩放、格式转换优化如前所述使用Compute Shader在GPU上完成预处理。避免使用Texture2D.ReadPixels和GetPixels32它们会导致GPU-CPU同步等待。CPU推理执行worker.Execute与NMS计算优化异步推理使用_worker.ExecuteAsync或_worker.StartSchedule().Execute进行异步推理避免阻塞主线程。在Update中启动推理在LateUpdate或下一帧获取结果。NMS优化将NMS算法移植到Job System中利用多核。降低输入分辨率如果对精度要求不是极致将模型输入从640x640降到480x480甚至320x320推理速度会有数量级提升。使用更轻量模型从YOLOv8nnano开始尝试如果性能足够再考虑更大的模型。内存Tensor分配与GC垃圾回收优化对象池对Detection类、用于绘制的UI预制体等频繁创建销毁的对象使用对象池。复用Tensor在PreprocessTexture中尽量复用_inputTensor而不是每帧创建新的。避免Lambda装箱在排序等操作中避免使用会产生GC的委托可以定义明确的比较器类。6.3 常见问题与排查技巧在真机上推理结果全是乱码或为0检查预处理确保颜色通道顺序RGB vs BGR和归一化范围0-1 vs 0-255与模型训练时一致。YOLOv8官方训练通常使用RGB和0-1归一化。检查输入尺寸确保输入Tensor的尺寸与模型导出时设定的imgsz完全一致。检查后端在真机上尝试使用BackendType.CPU来排除NNAPI或GPU后端兼容性问题。有些设备的NNAPI实现可能有bug。查看日志使用adb logcat -s Unity查看Unity运行时和Sentis是否有报错。帧率极低手机发烫使用Profiler定位看是GPU还是CPU耗时高。降低推理频率不必每帧都推理可以每2帧或3帧推理一次帧采样。关闭垂直同步VSync在Quality Settings中降低或关闭VSync但可能增加屏幕撕裂。检查热区确保手机散热良好避免长时间高负载运行导致CPU/GPU降频。打包后闪退Il2Cpp错误检查Link.xml确保Sentis及其依赖的所有原生库都没有被代码剥离。检查Il2Cpp Code Generation在Player Settings - Other Settings中尝试将Il2Cpp Code Generation从Faster (smaller) builds改为Faster runtime牺牲一些包大小换取稳定性。查看崩溃日志在adb logcat中查找Fatal signal或backtrace定位崩溃的堆栈信息。检测框位置偏移或缩放不对检查坐标转换仔细核对从模型输出坐标到屏幕坐标的每一步转换公式。特别注意原点的差异模型输入空间、纹理空间、屏幕UI空间的原点可能不同。检查图像预处理时的缩放模式是拉伸Stretch还是保持比例并填充黑边Letterbox你的后处理转换需要与之匹配。如果预处理是Letterbox那么转换时还需要考虑黑边的偏移量。7. 进阶动态输入与多线程推理对于更稳定的应用可以考虑以下进阶方案动态输入处理Letterbox为了保持物体比例预处理时不应简单拉伸而应在保持长宽比的同时将图像缩放至模型输入尺寸不足的部分用灰色填充。这需要在预处理时记录缩放因子和填充偏移量并在后处理时将框的坐标转换回原始图像时进行逆变换。生产者-消费者模式的多线程推理创建一个专门的线程来管理推理Worker。主线程渲染线程将预处理好的图像数据放入一个队列推理线程从中取出并执行推理再将结果放入另一个队列主线程在下一帧从结果队列中取出并渲染。这可以避免推理阻塞主循环显著提升流畅度。但需要注意线程间数据传递的同步和Tensor的内存管理Sentis的Tensor可能不是线程安全的。实现这一套下来你会发现在Unity Sentis中部署YOLOv8并添加NMS远不止是调用一个API那么简单。它考验的是你对模型本身的理解、对移动端图形与计算管道的把握以及扎实的工程优化能力。从模型导出、预处理、推理、后处理到真机优化每一个环节都有坑但也都有明确的优化路径。当你的应用终于在手机上稳定跑出30FPS的实时检测时那种成就感绝对是调通一个云端API无法比拟的。