这个项目我前后拆了两周时间,踩了一堆文档里查不到的坑,也有几个差点让我崩溃的细节。先交代一下背景:产线上一台工控机要做产品外观缺陷检测,现场还需要 Android 平板远程看状态,办公室坐着的品控也想打开网页瞅一眼。过去这种需求多半是 WPF 写一套 Windows 上位机,再单独用 Java 或者 Kotlin 写一套 Android 端。两套代码,两拨维护,光同步判定逻辑就够喝一壶的。
后来我认真试了试 .NET MAUI + YOLO 这套组合,把原本要写两遍的检测、通信、UI 逻辑收到一个解决方案里,一次性编译出 Windows 桌面端和 Android 平板端。这篇帖子就是把这个过程完整复盘一遍:为什么选这个方案、模型怎么训怎么导、C# 里怎么做推理和后处理、检测结果怎么通过串口和 Modbus 下发给 PLC,以及最后三端部署时遇到的那些怪问题。
先说结论:如果你的场景是“检测算法跑在本地 + 界面要跨 Windows / Android / Linux + 工业现场要和下位机打交道”,这套方案完全值得投入。它不是花架子,是真的能顶到产线上用的。
1. 方案选型与整体架构:为什么偏要用 MAUI 来干这事
1.1 上位机开发的“老套路”有几处痛点
传统上位机开发,绝大多数老工程师手里都是 WinForms 或者 WPF。WinForms 开发快、资料多,但界面写出来确实跟不上时代;WPF 强大,可一旦客户说“我这边还要一个安卓平板能看结果”,大部分人只能另起炉灶。
用 Electron 的也有,开发工具链成熟,但 Electron 每个平台都要装运行时,工控机配置普遍不高的场景下吃内存很凶,而且和串口、Modbus 这类硬件交互通常还得再套一层 Node 原生模块,挺折腾。
MAUI 的特点是没错位。它是 Xamarin.Forms 的下一代,一套 XAML 界面逻辑可以跑 Android、iOS、Windows、macOS,底层直接编译到各平台原生应用。对做上位机的人来说,这意味着串口类、Socket、Modbus 库、甚至 ONNX Runtime 推理库都能在同一个 .NET 生态里复用,不用跨语言去搞什么 JNI 桥。
更重要的一点,C# 在工业自动化领域本来就有一大批现成库,比如 HslCommunication 这种盛行于国内工控圈的通信库,原生支持 MAUI。这意味着我原来写在 WPF 里的通信代码,几乎可以原封不动地迁移到新项目里。
1.2 YOLO模型怎么选:v5、v8,还是最新的v11
目标检测模型我直接锁定了 YOLO 系,原因就三个字,快、稳、省。产线上做检测,帧率就是效率,一个零件走过去,相机可能只有几百毫秒的抓拍窗口,YOLO 系列在 COCO 数据集上跑出的速度曲线已经说明问题。
但 YOLO 版本之间差别很大,这个坑必须单独说。早期 YOLOv5 的 ONNX 导出结构特别经典,一般有两个输出,一个是边框层,一个是分类层,后处理的时候要分别处理,还要自己实现跨层 NMS。Ultralytics 家的 YOLOv8 改成了单输出头,直接吐出一个统一尺寸的张量,后处理代码简化了很多;YOLOv9 和 v10 偏向于结构创新和训练效率,如果只看部署稳定性,v8 的生态目前最成熟;YOLOv11 是 Ultralytics 近期主推的,分类头和检测头做得更紧凑,精度有小幅提升,但 ONNX Runtime 下跑出来的速度提升其实没那么明显。
我最后选了 YOLOv8n,不是因为它精度最好,而是因为它体积小、推理快,工业上位机经常是 i3 级的老工控机,CPU 推理才是常态。同样一个模型跑在 Windows 和 Android 上,用小模型能保证两端都流畅,调参成本也低。
注意:模型选型没有绝对“最新就是最好”的说法。工业现场第一优先级是稳定和可维护,哪怕你用 YOLOv8n 只检测 10 个自定义类别,也比去折腾一个最新但社区资料稀少的版本靠谱。
1.3 整体数据流设计(图像采集 → 推理 → 通信 → 联动)
整个系统的数据流,我画成了一条清晰的主线,建议大家动手之前先把这条线理明白。
- 图像采集:工业相机或者 USB 摄像头采集图片,在工控机上由相机 SDK 拿到原始帧,转成 Bitmap 或 byte[]。
- 目标检测:图片送入 YOLO 模型推理,输出检测框坐标、类别、置信度,经过 NMS 去掉重复框。
- 结果上传:检测结果通过 MVVM 绑定刷新本地界面,同时在另一个线程里把结果写给 PLC 或者上传到服务器。
- 下位联动:PLC 根据检测结果执行剔除、报警、复位等动作,再把执行状态回传给上位机。
MAUI 在这里承担的是 UI 和宿主管道的角色,真正的重计算在 ONNX Runtime 里。只要把这条链路拆成独立的 Service 层,后面想换相机、换 PLC、换模型,都不用在界面代码里大动干戈。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心链路实操:YOLO模型训练、导出ONNX与MAUI工程搭建
2.1 模型训练与导出:别踩导出版本的坑
训练部分网上教程一大堆,我只强调几个和部署强相关的点。
数据标注我用 labelimg,标注完生成 YOLO 格式的 txt 文件,每个 txt 一行表示一个目标,内容是“类别 x_center y_center width height”,坐标都是归一化到 0 到 1 的小数。训练用的框架是 Ultralytics YOLO,命令很简单:
bash复制pip install ultralytics
yolo detect train data=dataset.yaml model=yolov8n.pt epochs=100 imgsz=640 batch=16
这里有个容易忽略的点,imgsz 参数。训练时用的输入尺寸会直接影响特征图尺寸,部署时推理的输入尺寸必须和训练尺寸一致,或者尽量一致。我训练用的 640,导出后推理也固定填 640。
模型训练完,导出 ONNX 的命令是:
bash复制yolo export model=best.pt format=onnx imgsz=640 opset=12
导出格式选 ONNX,是因为 ONNX Runtime 在 .NET 生态里有官方 NuGet 包,跨端支持也好。这里强调 opset 参数,一般 12 左右比较稳妥,太高的 opset 版本会导致部分平台的 ONNX Runtime 不支持,反而增加部署风险。
导出后建议用 Netron 打开 onnx 文件看一眼输出层名称和形状。v8 系模型输出层名字通常叫 /model.22/Concat_0 或者类似的名字,形状是 [1, 84, 8400]。如果你导入 C# 时发现读取张量尺寸不对,或者 session.Run 报维度错误,基本都是这里没对齐。
2.2 MAUI项目结构、依赖包与平台注意事项
在 Visual Studio 里新建 .NET MAUI 项目时,建议直接选 Blank App,模板自带一个跨平台项目结构,包含 Platforms 文件夹,里面分 Android、iOS、Windows 等平台特定代码。
我项目的解决方案结构是这样的:
code复制MauiYoloApp/
├─ MauiProgram.cs
├─ App.xaml / App.xaml.cs
├─ MainPage.xaml / MainPage.xaml.cs
├─ Core/
│ ├─ Models/
│ ├─ Services/
│ └─ Communication/
├─ Platforms/
│ ├─ Android/
│ ├─ Windows/
│ └─ Linux/(后续手动加)
└─ Resources/
依赖包我加了三样,都是 NuGet 官网顺手搜出来的稳定版:
- Microsoft.ML.OnnxRuntime
- CommunityToolkit.Mvvm
- System.IO.Ports
ONNX Runtime 包会附带对应平台的原生运行库,Windows 下是 x64 的 dll,Android 下会自动编入 arm64-v8a 等 so 文件。把模型文件放进 Resources/Raw 文件夹并设置生成操作为 MauiAsset,就能在代码里跨端访问,读取方式后面详说。
注意:System.IO.Ports 这个包在 MAUI Windows 端可用,但 Android 端受系统权限限制,串口不是直接可用的,一般得配 USB 转串口 OTG 线并借助 usb-serial-for-android 这类库,或者干脆在 Android 端只用网络通信。这点设计架构时要提前想好。
2.3 推理核心代码:预处理、NMS后处理与结果解析
这部分是整个项目最核心也最容易写错的地方。YOLO 模型期望的输入张量格式是 [1, 3, 640, 640] 的 float32 数据,数值范围归一化到 0 到 1。摄像头拍的原始图片一般不是 1:1,需要做 letterbox 缩放,也就是把长边缩放到 640,短边按比例缩放后补灰边。
我用 C# 写了一个预处理函数,把 Image 转成批次张量。核心思路是:
csharp复制public static Tensor<float> Preprocess(byte[] imageBytes, int inputSize = 640)
{
// 解码图片,获取原始宽高,计算缩放比例 scale
using var image = Image.Load<Rgba32>(imageBytes);
var oldWidth = image.Width;
var oldHeight = image.Height;
float scale = Math.Min((float)inputSize / oldWidth, (float)inputSize / oldHeight);
int newWidth = (int)Math.Round(oldWidth * scale);
int newHeight = (int)Math.Round(oldHeight * scale);
// letterbox:先缩放,再居中补灰边到 inputSize
var resized = image.Clone(ctx => ctx.Resize(newWidth, newHeight));
float[] data = new float[3 * inputSize * inputSize];
// 把 R、G、B 分通道写入 float 数组,并 /255f 归一化
for (int y = 0; y < newHeight; y++)
{
for (int x = 0; x < newWidth; x++)
{
var pixel = resized[x, y];
int offsetY = (inputSize - newHeight) / 2 + y;
int offsetX = (inputSize - newWidth) / 2 + x;
int pixelIndex = offsetY * inputSize + offsetX;
data[pixelIndex] = pixel.R / 255f;
data[pixelIndex + inputSize * inputSize] = pixel.G / 255f;
data[pixelIndex + inputSize * inputSize * 2] = pixel.B / 255f;
}
}
return new DenseTensor<float>(data, new[] { 1, 3, inputSize, inputSize });
}
推理部分,加载模型并跑一次得到输出:
csharp复制var session = new InferenceSession(modelPath);
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("images", inputTensor)
};
using var results = session.Run(inputs);
// 输出张量是 [1, 4 + classCount, 8400]
var output = results.First().AsTensor<float>();
v8 模型的输出结构是每个锚点一行,每行前 4 个是 cx、cy、w、h 中心点坐标,后面跟着各类别分数。我后处理时先按分数阈值过滤掉置信度低的目标,再还原回原始图像尺寸,最后做 NMS 合并重叠框。
NMS 我用的 IoU 阈值是 0.45,置信度阈值是 0.25。这两个值在产线场景下要根据误检率反复调。阈值越低,召回率越高,但误报也多;阈值太高,漏检风险大。工业应用里我通常先跑一批真实样本,统计 F1 曲线再定。
3. 上位机联动:检测结果如何驱动串口/Modbus/本地UI
3.1 通过MVVM绑定检测结果到UI
MAUI 的界面开发和 WPF 很像,无非是 XAML 加绑定。我用 CommunityToolkit.Mvvm 框架定义了一个 MainViewModel,把检测结果、相机状态、PLC 状态都做成可绑定属性。
csharp复制public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private ObservableCollection<DetectBox> boxes;
[ObservableProperty]
private string fpsText;
[ObservableProperty]
private string resultCount;
}
界面最核心的是一个 SKCanvasView,用来画检测框。绘制时把模型检测到的基础坐标 x、y、width、height 从模型坐标系还原回原始图像坐标系,再按控件大小做一次缩放。这里要特别注意,MAUI 的触屏事件和 Windows 鼠标事件的坐标系不一样,Android 端做双击放大时经常会偏移几个像素,调试的时候我直接在画布上画了一个十字辅助线才定位到问题。
检测结果实时更新到了界面,操作员看到的不是一堆冷冰冰的数字,而是画面上清晰的框和类别标签。这个反馈速度直接决定了现场人员愿不愿意用这套系统,很影响体验。
3.2 检测结果下发给PLC:串口、Modbus TCP、WebSocket三种方式
上位机真正的“上位”价值,是把算法结果变成产线的动作。我给这套系统写了三种通信通道,按现场需求切换。
第一种是串口通信。工控机和 PLC 之间走 RS232/RS485,用 System.IO.Ports.SerialPort。检测到 NG 品时,上位机通过串口向 PLC 发送一个 ASCII 字符串,比如“NG:0.98”,PLC 解析后触发剔除气缸。
csharp复制var port = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One);
port.Open();
port.Write("NG:0.98\r\n");
第二种是 Modbus TCP。很多新产线的 PLC 都支持 Modbus TCP,上位机作为 Client 去写线圈或者保持寄存器。调用库时我用的 HslCommunication,代码量很少,几行就能把检测结果推送到 PLC:
csharp复制var modbus = new ModbusTcpClient("192.168.1.10", 502);
modbus.SetTimeout(1000);
modbus.ConnectServer();
// 写线圈:地址0为合格,地址1为NG
await modbus.WriteCoilAsync(1, isNG);
第三种是 WebSocket,主要给局域网内的平板看板用。MAUI 的 Android 端和浏览器页面都能连,推送检测结果 JSON 给看板端做实时的产线状态展示。这个方案在现场很讨喜,因为客户往往希望在办公室大屏上也实时看到产线状态。
3.3 工业场景下的性能优化与异常兜底
工业软件没有“重启一下就好了”的宽容度。我把检测逻辑丢到后台线程循环执行,UI 线程只负责渲染,避免推理阻塞界面导致操作员以为程序死了。
还有一个特别容易被忽略的点:模型推理的输入张量是一块固定大小的内存,频繁分配和释放会引发 GC 压力。我用对象池复用了输入输出缓冲区,连续跑一整天也不会有明显的内存增长。实测下来,Windows i5 工控机上 YOLOv8n 640 输入,单帧推理稳定在 40ms 左右;Android 骁龙 778G 平板大概 60ms 左右,完全能满足产线节拍。
异常兜底方面,我写了一个独立的看门狗线程,每 2 秒检查一次相机流和 PLC 连接状态。发现通信断了就自动重连,同时把故障状态推到界面和远程看板。这个兜底机制看着简单,实际上救了整个项目一次,因为现场有一次 PLC 重启导致通信断开,我人都没到场,看板自己就恢复了。
4. 多端部署实测:Windows/Linux/Android三端打包记录
4.1 Windows工控机:CPU推理与GPU加速
Windows 端是整个系统的主部署目标。MAUI 的 Windows 项目默认生成 WinUI 3 应用,部署时直接发布成可执行的 exe 文件夹。在一台 i5-8500T、16G 内存、无独立显卡的工控机上,CPU 推理 YOLOv8n,640x640 输入,单帧耗时 50ms 左右,够用。
如果工控机有 NVIDIA 显卡,可以用 ONNX Runtime 的 CUDA 执行提供程序来加速。NuGet 里换上 Microsoft.ML.OnnxRuntime.Gpu,代码里注册 CUDA 执行提供程序:
csharp复制var sessionOptions = new SessionOptions();
sessionOptions.AppendExecutionProvider_CUDA(0);
var session = new InferenceSession(modelPath, sessionOptions);
实测同一台带 RTX 3060 的机器,GPU 推理能压到 8ms 一帧,提升了近 6 倍。但注意,装 CUDA 执行提供程序的时候要确保显卡驱动、CUDA 版本和 ONNX Runtime 三者的兼容矩阵,否则会直接报 Failed to find CUDA,这个坑我耗掉了一下午。
4.2 Android平板:端侧部署的坑与优化
Android 端是我最担心的部分,因为工业平板的 SoC 差异太大。低端平板可能用的是 RK3568 这类 ARM 芯片,高端一点的是骁龙 8 系。YOLOv8n 在 ARM CPU 上跑 640 推理,理论性能会比 x86 CPU 弱一截,但实测大部分中端平板都能在 100ms 内完成一帧,看监控足够了。
Android 端最终的优化方案是用 NNAPI。ONNX Runtime 支持 Android 上的 NNAPI 加速,能调用 NPU/DSP,把推理耗时从 90ms 压到 40ms。代码改动很小:
csharp复制sessionOptions.AppendExecutionProvider_Nnapi();
不过 NNAPI 的兼容性在不同厂商的 SoC 上表现很不一致,有些国产芯片厂商对 NNAPI 支持不完整,推理结果可能错位。我最终的处理是加了一个配置开关,Android 端默认开 CPU,如果平板支持 NNAPI 再手动打开。
4.3 Linux(Docker/工控小主机):无界面部署与自启动
这个本来不在原需求里,但后期有个项目希望把检测服务单独跑在 Linux 工控机上,界面只做展示。MAUI 本身不支持 Linux 界面,但我的检测和通信逻辑全在 Core 服务层,完全可以抽出来单独跑一个 console 服务。
我把 Core 服务层单独编译成一个 .NET 通用库,然后写了一个 ASP.NET Core Web API 挂在上面。这样 Linux 机器上部署的就是一个提供了 HTTP 接口的目标检测服务,Windows 和 Android 的 MAUI 应用通过 REST 调用它。这是工程上非常典型的分层设计,验证了 MAUI 项目的代码复用优势。
Linux 上的部署用 systemd 托管 service,配一个 watchdog,服务挂了自动拉起。整机通电启动后无需人工干预,做到了真正的无人值守。实测这台无 GPU 的 Linux 小主机,CPU 推理速度和 Windows 工控机相当。
5. 常见问题与排查技巧实录
5.1 常见问题速查表
这几个月里我在项目里遇到过的怪问题,整理成一张速查表,比翻文档快得多。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 推理结果全为 0 或者框错位 | 预处理时没有补 letterbox,或坐标还原没按 scale | 统一用 640 输入,框坐标还原时乘回 scale 并减去偏移 |
| 同一目标出现多个框 | NMS 没做,或 IoU 阈值过高 | IoU 阈值设为 0.4~0.5,代码里先按置信度降序排列 |
| MAUI 打不开串口 COM 口 | Windows 未安装对应驱动,或权限不足 | 以管理员身份运行应用,检查设备管理器里的 COM 号 |
| Android 端访问不到本地服务 | Android 网络安全配置禁止明文 HTTP | 在 AndroidManifest.xml 里开启 usesCleartextTraffic |
| ONNX Runtime 初始化报 DllNotFound | 缺少 VC++ 运行时或 NuGet 包版本不一致 | 装 Visual C++ Redistributable,检查各平台原生库是否都被打包 |
| NNAPI 上推理结果错乱 | 部分 SoC 的 NNAPI 实现有 bug | 改为 CPU 或加开关控制是否启用 NNAPI |
| 工控机长时间运行内存暴涨 | 频繁创建 Session 或张量未释放 | 只创建一次 InferenceSession,复用 Tensor 缓冲区 |
5.2 几个很少被写进文档的独家经验
第一个经验是关于模型文件读取的。MAUI 里模型放在 Resources\Raw 下时,Android 端读取的路径是 Raw/xxx.onnx,Windows 端却是项目目录里的相对路径。我为了统一,把模型文件在首次启动时拷贝到 AppData 目录,再统一用那个路径加载。这样既绕开了平台路径差异,之后想支持热更新模型也方便。
第二个经验是 C# 的浮点精度写法会直接影响检测结果。YOLO 内部的置信度计算涉及大量 float 运算,ONNX Runtime 输出的是 float,但如果你在 C# 里用 double 做中间计算,再转回 float,可能因为精度差异导致结果微小偏移。工业检测这种场景,置信度 0.49 和 0.51 可能就是合格与不合格的区别。我后来统一用 float 运算,保证和 Python 推理结果一致。
第三个经验是异常不能光 catch 不处理。MAUI 的 Android 端有个特点,TPL 异步里的异常如果不是在 UI 线程 context 下抛出的,很容易被吞掉。我全局挂了一个 TaskScheduler.UnobservedTaskException 事件,把所有异常统一打到日志文件,才把一些隐性问题暴露出来。
最后一个技巧:打包 Windows 端时,一定要在 csproj 里显式声明需要支持的系统架构。Windows MAUI 应用默认可能只打 x64,如果现场工控机是 ARM 版 Windows,或者客户拿了一台老 32 位机器,打包目标架构不对就根本启动不了。我直接配置了 RuntimeIdentifier 为 win-x64 和 win-arm64 两个版本打包,一个小细节,避免了不少售后。
我个人在实际操作中最大的体会是,这类跨端项目真正决定成败的不是框架本身,而是边界和复用的划分策略。YOLO 模型是老本事,MAUI 是新玩具,但把它们粘在一起的 Service 层设计、异常处理机制和部署自动化,才是整个项目最难也最值钱的部分。这个项目做完之后,我再接视觉检测需求时,已经不太会纠结“用什么框架写界面”了,因为框架只是载体,能一次编码把算法、通信和界面串起来去适应不同产线,这才是真正的生产力。
