不少做工业视觉的开发者都遇到过类似的场景:算法工程师在 Python 环境里把异常检测模型训练好,检测效果也不错,但到了客户端部署阶段,现场机器却没有 Python 环境,甚至不允许安装大型运行库。这时候把模型导出为 ONNX,再配合 C# WinForm 做一个轻量级的桌面演示工具,是性价比非常高的方案。
这篇文章会围绕“基于 C# WinForm 部署 anomalib 的 ONNX 模型”这条主线,完整梳理从模型导出、C# 工程搭建、图像预处理、推理调用到结果可视化的全过程。适合有基础 C# 开发经验、同时想了解 anomalib 模型落地流程的读者;如果你正在做工业外观检测、缺陷检测相关的客户端工具,也可以直接参考这套实现思路。
1. 技术背景与整体思路
1.1 什么是 anomalib
anomalib 是一个开源的异常检测算法库,里面实现了 PatchCore、PaDiM、STFPM、DFKDE 等主流无监督异常检测算法。它最大的特点是可以用大量“正常样本”训练模型,让模型学习正常产品的外观分布,然后在推理阶段判断输入图像是否偏离这个分布,并定位偏离的位置。
这个思路非常适合工业视觉场景,比如:
- 产品表面划痕、污渍检测;
- 装配缺件、位置偏移检测;
- 布料、薄膜、金属表面的纹理异常检测;
- PCB 板、连接器、电池表面的轻微缺陷检测。
相比需要大量标注缺陷样本的目标检测方案,异常检测只需要正常样本就能完成训练,缺陷样本只是用来验证阈值和最终效果,因此落地门槛更低。
1.2 为什么选择 ONNX 部署
anomalib 训练出来的模型本质上是 PyTorch 模型,无法直接在 C# 环境中运行。要部署到 WinForm 客户端,通常有几种方案:
- 在 C# 里调用 Python 进程,通过命令行传递图片;
- 使用 ML.NET 转换模型;
- 将模型导出为 ONNX,再用 ONNX Runtime 加载推理。
第一种方案依赖客户端安装 Python 环境和相关依赖,部署成本高,不推荐用于正式项目。第二种方案对 PyTorch 模型支持有限,迁移成本不小。相比之下,ONNX 是一种开放的模型交换格式,ONNX Runtime 提供了官方 C# API,只需要通过 NuGet 引入一个程序集,就能在 Windows 桌面程序中离线加载并运行模型。
部署时的主要优势有:
- 客户端不需要安装 Python;
- 推理速度可以接受,CPU 跑 PatchCore 小图基本能达到实时或准实时;
- 模型文件只有一个 .onnx,方便交付和版本管理;
- ONNX Runtime 支持 CPU、GPU、OpenVINO 等多种执行后端,后续可以按现场硬件升级。
1.3 整体流程拆解
整个部署流程可以分成下面几步:
- 使用 anomalib 完成模型训练,得到 PyTorch 权重文件;
- 将模型导出为 ONNX 文件;
- 在 VS 中创建 WinForm 项目;
- 通过 NuGet 引入 ONNX Runtime 和 OpenCvSharp;
- 在 C# 中加载 ONNX 模型,完成图片预处理和推理;
- 解析模型输出,计算异常分数,生成热力图、掩膜和叠加图。
本文会重点围绕第 2 到第 6 步展开,并给出可直接参考的演示源码结构。
2. 环境准备与版本说明
2.1 Python 侧环境
因为模型训练和导出通常发生在 Python 环境,因此需要先准备:
- Python 3.8 或 3.10;
- PyTorch,建议 1.13 或更高版本;
- anomalib 库,建议使用与你训练项目一致的版本;
- onnx 和 onnxruntime,用于验证导出后的模型。
具体版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。
2.2 C# 侧开发环境
C# 部分的开发环境建议如下:
- Windows 10/11;
- Visual Studio 2022;
- 目标框架可以是 .NET 6/8 的 Windows 窗体应用,也可以是 .NET Framework 4.7.2;
- NuGet 包:
- Microsoft.ML.OnnxRuntime
- OpenCvSharp4
- OpenCvSharp4.runtime.win
需要说明的是,OpenCvSharp4.runtime.win 这个包是运行时依赖,负责把 OpenCvSharp 需要的原生 DLL 复制到输出目录,不要漏掉。
如果你使用的是 .NET Framework 老项目,NuGet 包的版本可能需要适当降低,以保证和项目目标框架兼容。
2.3 示例项目结构
为了方便阅读,本文示例项目结构如下:
AnomalibWinFormDemo/ ├── AnomalibWinFormDemo.csproj ├── Program.cs ├── MainForm.cs ├── OnnxInferenceHelper.cs ├── InferenceResult.cs ├── models/ │ └── anomalib_patchcore.onnx └── images/ ├── normal_sample.png └── defect_sample.png其中 MainForm.cs 负责界面交互,OnnxInferenceHelper.cs 负责 ONNX 模型的加载、预处理和推理,InferenceResult.cs 存放推理结果。
3. anomalib 模型训练与 ONNX 导出
3.1 准备好训练好的模型
anomalib 训练完成后,通常会保存 PyTorch Lightning 的 checkpoint 文件,文件名一般是 last.ckpt 或 best.ckpt。拿到这个权重文件后,我们需要把它加载到模型结构里,再执行导出。
由于不同版本 anomalib 的模型接口有差异,这里给出一种常见写法:
import torch from anomalib.models import Patchcore # 加载训练好的 checkpoint,优先级是尝试用 Lightning 方式加载 model = Patchcore.load_from_checkpoint( "best.ckpt", map_location="cpu" ) model.eval()如果你的环境加载方式和这段代码不一致,建议先打印 checkpoint 的键名,再决定是手动 load_state_dict 还是使用 load_from_checkpoint。不同版本的 anomalib 在这块变化较快,以你训练环境的实际 API 为准。
3.2 导出 ONNX 的两种方式
anomalib 本身提供模型导出能力,有的版本支持直接导出 OpenVINO IR 或 ONNX。如果你的版本支持 ONNX 导出,可以直接用官方工具脚本。如果老版本只支持 OpenVINO,也可以先导出中间格式,再通过 OpenVINO 的模型转换工具转成 ONNX。
不过从工程可控性角度,我更推荐直接用 PyTorch 的 torch.onnx.export 导出。这样导出的模型不依赖 OpenVINO 组件,后续在 C# 侧也更容易控制预处理逻辑。
3.3 使用 torch.onnx.export 导出
下面是一段参考导出代码。假设输入图片尺寸是 224x224,模型输出包含 anomaly_map 和 score:
import torch # 固定输入尺寸,必须与训练时的预处理尺寸一致 input_size = (224, 224) dummy_input = torch.randn(1, 3, *input_size) torch.onnx.export( model, dummy_input, "models/anomalib_patchcore.onnx", input_names=["input"], output_names=["anomaly_map", "score"], opset_version=11, dynamic_axes={ "input": {0: "batch", 2: "height", 3: "width"}, "anomaly_map": {0: "batch", 1: "height", 2: "width"}, } ) print("ONNX 导出完成")导出时需要注意几个细节:
- 模型一定要 eval(),否则 BN 和 Dropout 会影响导出结果;
- dummy_input 的通道数是 3,对应 RGB 输入;
- 不同异常检测模型输出名称不一定相同,有的模型还会输出 feature map,建议导出后在 Python 侧先验证一遍;
- 如果实际推理时需要固定 batch=1,那么可以不写 dynamic_axes,这样模型尺寸更小,推理速度也更稳定。
3.4 导出后验证模型结构
导出后不要急着写 C#,先在 Python 里用 onnxruntime 跑一遍随机输入,确认输入输出节点符合预期:
import onnxruntime as ort import numpy as np sess = ort.InferenceSession( "models/anomalib_patchcore.onnx", providers=["CPUExecutionProvider"] ) for inp in sess.get_inputs(): print("输入:", inp.name, inp.shape, inp.type) for out in sess.get_outputs(): print("输出:", out.name, out.shape, out.type) x = np.random.randn(1, 3, 224, 224).astype(np.float32) outputs = sess.run(None, {"input": x}) for out in outputs: print("推理输出 shape:", out.shape)这一步能确认 ONNX 模型文件是否完整,也能提前看出 C# 侧实际要处理哪些输出张量。建议把输出的名称、shape 记录下来,写 C# 代码时会用到。
4. C# 侧关键概念与项目配置
4.1 ONNX Runtime 的核心对象
在 C# 中使用 ONNX 模型,主要会用到几个核心对象:
- InferenceSession:负责加载模型、管理推理会话;
- NamedOnnxValue:把输入张量按名称传给模型,也用来读取输出张量;
- Tensor :多维张量,图像数据在 C# 侧需要转为这个结构;
- DenseTensor :Tensor 的具体实现,适合小尺寸图像数据。
调用流程也很固定:
- 构造 InferenceSession,传入 .onnx 文件路径;
- 构造输入 Tensor ;
- 创建 NamedOnnxValue 列表;
- 调用 session.Run 执行推理;
- 从输出中读取 score 和 anomaly_map。
4.2 OpenCvSharp 在项目中的角色
OpenCvSharp 负责图像读取、缩放、通道转换、热力图生成以及最终显示。它和 Emgu CV 类似,都是 OpenCV 的 C# 封装,但 OpenCvSharp 在 NuGet 集成和 API 写法上更直接,比较适合快速开发桌面工具。
本文会用到的主要 API 包括:
- Cv2.ImRead:读取图片为 Mat;
- Cv2.CvtColor:转换 BGR 到 RGB;
- Cv2.Resize:缩放图像到模型输入尺寸;
- Cv2.ApplyColorMap:将灰度热力图映射为彩色图;
- Cv2.AddWeighted:原图和热力图叠加;
- Cv2.Threshold:根据阈值生成二值掩膜。
4.3 创建 WinForm 项目并添加依赖
在 Visual Studio 中新建一个 Windows 窗体应用项目,然后在 csproj 中添加以下 NuGet 包:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net6.0-windows</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <Nullable>disable</Nullable> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.16.3" /> <PackageReference Include="OpenCvSharp4" Version="4.8.0.20230708" /> <PackageReference Include="OpenCvSharp4.runtime.win" Version="4.8.0.20230708" /> </ItemGroup> </Project>如果你使用的是 VS 图形界面,也可以在“管理 NuGet 程序包”中搜索安装,效果一样。版本号不是固定的,使用 NuGet 上最新的稳定版即可。
5. 编写 WinForm 推理程序
5.1 定义推理结果类
先定义一个 InferenceResult 类,用来集中存放模型输出和后续生成的可视化结果。
// 文件路径:InferenceResult.cs using OpenCvSharp; namespace AnomalibWinFormDemo { public class InferenceResult { /// <summary> /// 异常分数,值越大表示越异常 /// </summary> public float Score { get; set; } /// <summary> /// 模型输出的原始异常热力图,CV_32FC1 /// </summary> public Mat AnomalyMap { get; set; } /// <summary> /// 彩色热力图,用于界面展示 /// </summary> public Mat HeatMap { get; set; } /// <summary> /// 二值掩膜,用于标记异常区域 /// </summary> public Mat Mask { get; set; } /// <summary> /// 原图与热力图叠加结果 /// </summary> public Mat Overlay { get; set; } } }5.2 编写推理帮助类
接下来是核心的 OnnxInferenceHelper 类。它负责模型加载、输入预处理、推理执行和输出解析四个部分。
// 文件路径:OnnxInferenceHelper.cs using System; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; namespace AnomalibWinFormDemo { public class OnnxInferenceHelper : IDisposable { private InferenceSession _session; private int _inputHeight = 224; private int _inputWidth = 224; // ImageNet 常用归一化参数,具体以你的 anomalib 训练配置为准 private readonly float[] _mean = { 0.485f, 0.456f, 0.406f }; private readonly float[] _std = { 0.229f, 0.224f, 0.225f }; public OnnxInferenceHelper(string modelPath) { _session = new InferenceSession(modelPath); LoadInputShape(); PrintModelInfo(); } /// <summary> /// 从模型输入元数据中读取 H/W,如果读取失败则使用默认 224x224 /// </summary> private void LoadInputShape() { var inputMeta = _session.InputMetadata.Values.FirstOrDefault(); if (inputMeta == null) return; var dims = inputMeta.Dimensions; if (dims.Count == 4 && int.TryParse(dims[2], out var h) && int.TryParse(dims[3], out var w)) { _inputHeight = h; _inputWidth = w; } } private void PrintModelInfo() { Console.WriteLine("输入节点:"); foreach (var kv in _session.InputMetadata) { Console.WriteLine($"{kv.Key}: {string.Join(",", kv.Value.Dimensions)}"); } Console.WriteLine("输出节点:"); foreach (var kv in _session.OutputMetadata) { Console.WriteLine($"{kv.Key}: {string.Join(",", kv.Value.Dimensions)}"); } } /// <summary> /// 执行完整推理流程 /// </summary> public InferenceResult Infer(Mat originalImage) { using var inputTensor = Preprocess(originalImage); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("input", inputTensor) }; using var outputs = _session.Run(inputs); return PostProcess(outputs, originalImage); } /// <summary> /// 图像预处理:BGR->RGB,缩放,归一化,生成 NCHW 张量 /// </summary> private Tensor<float> Preprocess(Mat src) { using var rgb = new Mat(); using var resized = new Mat(); Cv2.CvtColor(src, rgb, ColorConversionCodes.BGR2RGB); Cv2.Resize(rgb, resized, new Size(_inputWidth, _inputHeight)); var tensor = new DenseTensor<float>(new[] { 1, 3, _inputHeight, _inputWidth }); for (int y = 0; y < _inputHeight; y++) { for (int x = 0; x < _inputWidth; x++) { var pixel = resized.At<Vec3b>(y, x); tensor[0, 0, y, x] = (pixel[0] / 255f - _mean[0]) / _std[0]; tensor[0, 1, y, x] = (pixel[1] / 255f - _mean[1]) / _std[1]; tensor[0, 2, y, x] = (pixel[2] / 255f - _mean[2]) / _std[2]; } } return tensor; } /// <summary> /// 输出解析:根据输出节点名称识别 score 和 anomaly_map /// </summary> private InferenceResult PostProcess( IReadOnlyCollection<NamedOnnxValue> outputs, Mat originalImage) { var result = new InferenceResult(); foreach (var output in outputs) { var name = output.Name.ToUpperInvariant(); if (name.Contains("SCORE")) { result.Score = output.AsTensor<float>().ToArray()[0]; } else if (name.Contains("MAP") || name.Contains("ANOMALY")) { result.AnomalyMap = ConvertTensorToMat(output.AsTensor<float>()); } } // 如果模型只有一个输出,则按第一个输出张量处理为热力图 if (result.AnomalyMap == null && outputs.Count > 0) { var first = outputs.First(); result.AnomalyMap = ConvertTensorToMat(first.AsTensor<float>()); } if (result.AnomalyMap != null) { BuildVisualization(result, originalImage); } return result; } /// <summary> /// 把 Tensor<float> 转为 OpenCV 的 Mat,只取第一个 batch/通道 /// </summary> private Mat ConvertTensorToMat(Tensor<float> tensor) { int h, w; var dims = tensor.Dimensions; if (dims.Length == 2) { h = dims[0]; w = dims[1]; } else if (dims.Length == 3) { h = dims[1]; w = dims[2]; } else if (dims.Length == 4) { h = dims[2]; w = dims[3]; } else { throw new NotSupportedException("不支持的输出张量维度:" + dims.Length); } var data = tensor.ToArray(); int planeLength = h * w; var plane = new float[planeLength]; Array.Copy(data, plane, planeLength); var mat = new Mat(h, w, MatType.CV_32FC1); Marshal.Copy(plane, 0, mat.Data, planeLength); return mat; } /// <summary> /// 根据异常热力图生成彩色热力图、掩膜和叠加图 /// </summary> private void BuildVisualization(InferenceResult result, Mat originalImage) { using var normalized = new Mat(); Cv2.Normalize(result.AnomalyMap, normalized, 0, 255, NormTypes.MinMax); Mat gray8 = new Mat(); normalized.ConvertTo(gray8, MatType.CV_8UC1); // 彩色热力图 result.HeatMap = new Mat(); Cv2.ApplyColorMap(gray8, result.HeatMap, ColormapTypes.Jet); // 在阈值处生成掩膜 float threshold = Math.Min(0.5f, result.Score); result.Mask = new Mat(); Cv2.Threshold(gray8, result.Mask, threshold * 255, 255, ThresholdTypes.Binary); // 将热力图缩放到原图尺寸,再叠加 using var resizedHeatMap = new Mat(); Cv2.Resize(result.HeatMap, resizedHeatMap, new Size(originalImage.Width, originalImage.Height)); result.Overlay = new Mat(); Cv2.AddWeighted(originalImage, 0.6, resizedHeatMap, 0.4, 0, result.Overlay); gray8.Dispose(); } public void Dispose() { _session?.Dispose(); } } }这段代码有几点需要说明:
- ConvertTensorToMat 只取第一个样本或第一个通道的数据,适合 batch=1 的推理场景;
- 归一化参数使用的是 ImageNet 常见值,如果你的 anomalib 训练配置不同,需要同步修改;
- 阈值策略这里写的是临时方案:取 score 和 0.5 的较小值。更严谨的做法是从验证集上计算最佳阈值,后面会在工程建议中展开。
5.3 设计主界面
在 WinForm 设计器中,可以放置以下控件:
- Button:加载模型;
- Button:选择图片;
- Button:执行推理;
- PictureBox:显示原图;
- PictureBox:显示热力图;
- PictureBox:显示掩膜;
- PictureBox:显示叠加图;
- Label:显示异常分数;
- TextBox:可手动输入阈值,实现简单交互。
界面布局可以根据实际喜好调整,下面不再贴完整设计器代码,只展示 MainForm.cs 中的关键事件处理逻辑。
// 文件路径:MainForm.cs using System; using System.Drawing; using System.Windows.Forms; using OpenCvSharp; namespace AnomalibWinFormDemo { public partial class MainForm : Form { private OnnxInferenceHelper _helper; private string _currentImagePath; public MainForm() { InitializeComponent(); } private void BtnLoadModel_Click(object sender, EventArgs e) { using (OpenFileDialog ofd = new OpenFileDialog()) { ofd.Filter = "ONNX 模型|*.onnx"; if (ofd.ShowDialog() == DialogResult.OK) { _helper?.Dispose(); _helper = new OnnxInferenceHelper(ofd.FileName); labelStatus.Text = "模型加载完成:" + ofd.FileName; } } } private void BtnSelectImage_Click(object sender, EventArgs e) { using (OpenFileDialog ofd = new OpenFileDialog()) { ofd.Filter = "图片文件|*.png;*.jpg;*.jpeg;*.bmp"; if (ofd.ShowDialog() == DialogResult.OK) { _currentImagePath = ofd.FileName; pictureBoxOriginal.Image = new Bitmap(_currentImagePath); labelStatus.Text = "图片加载完成"; } } } private void BtnInfer_Click(object sender, EventArgs e) { if (_helper == null) { MessageBox.Show("请先加载 ONNX 模型"); return; } if (string.IsNullOrEmpty(_currentImagePath)) { MessageBox.Show("请先选择图片"); return; } try { using Mat src = Cv2.ImRead(_currentImagePath, ImreadModes.Color); if (src.Empty()) { MessageBox.Show("图片读取失败"); return; } var result = _helper.Infer(src); labelScore.Text = "异常分数: " + result.Score.ToString("F6"); pictureBoxHeatmap.Image = OpenCvSharp.Extensions.BitmapConverter.ToBitmap(result.HeatMap); pictureBoxMask.Image = OpenCvSharp.Extensions.BitmapConverter.ToBitmap(result.Mask); pictureBoxOverlay.Image = OpenCvSharp.Extensions.BitmapConverter.ToBitmap(result.Overlay); labelStatus.Text = "推理完成"; } catch (Exception ex) { MessageBox.Show("推理失败:" + ex.Message); } } } }这里为了演示方便,推理过程放在了 UI 线程中。如果图片较大或模型推理较慢,界面会卡顿,生产环境建议用 Task.Run 放到后台线程,然后在需要更新控件时通过 Invoke 回到 UI 线程。
6. 运行演示与结果判断
6.1 预期效果
运行程序后,首先加载 models/anomalib_patchcore.onnx 文件,再选择一张图片。点击“执行推理”后,可以看到四个区域的输出:
- 原图:显示当前测试图片;
- 热力图:异常区域被映射为红色到蓝色渐变色,越红说明异常越明显;
- 掩膜:异常区域为白色,正常区域为黑色;
- 叠加图:原图和热力图叠加后的效果,便于定位缺陷位置。
对于正常样本,异常分数通常较低,热力图基本是均匀的冷色,掩膜中几乎没有大面积白色区域。对于包含划痕、脏污或异物的缺陷样本,异常分数会明显升高,热力图在缺陷位置出现高亮红点或红块,掩膜也会圈出对应区域。
6.2 如何判断是否异常
anomalib 的 score 通常是一个 0 到 1 之间的浮点数,越接近 1 说明输入图像偏离正常分布越严重。但具体阈值不能拍脑袋定,这里提供两种简单做法:
- 如果导出模型时已经内部完成了阈值处理,score 可以直接和 0.5 比较;
- 如果没有内置阈值,建议在训练好的验证集上统计正常样本和缺陷样本的 score 分布,选取一个准确率和召回率平衡点,然后把它写成配置文件。
在演示程序中,可以先观察正常样本的最高分和缺陷样本的最低分,取一个中间值作为临时阈值。后面的正式项目中,再换成从验证集计算得到的最佳阈值。
7. 常见问题与排查思路
7.1 常见问题汇总
下面这张表汇总了 C# WinForm 部署 ONNX 模型时最容易遇到的一批问题:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 运行时报找不到 OpenCvSharp 原生 DLL | 未安装 OpenCvSharp4.runtime.win 包 | 通过 NuGet 安装运行时包,并确认输出目录存在 OpenCvSharpNative.dll |
| ONNX Runtime 加载模型失败 | 缺少 VC++ 运行库、平台位数不一致 | 安装对应 x64 运行库,项目平台设置为 x64 |
| 推理一直报输入张量名称不匹配 | C# 侧写死的输入名称与模型不一致 | 在 PrintModelInfo 中查看实际输入名称,改用同名 |
| 异常分数始终在 0.5 附近,没有区分度 | 输入归一化的 mean/std 与训练时不一致 | 对照 anomalib 训练配置中的 transform 修改预处理参数 |
| 输出热力图全是噪声或雪花点 | 图像通道顺序或者缩放逻辑错误 | 检查是否完成 BGR 转 RGB,确认输入尺寸 |
| 程序内存持续增长 | Mat、Bitmap 没有及时 Dispose | 每次推理后释放旧 PictureBox 图像和临时 Mat |
| 界面点击推理后卡死 | 推理放在 UI 线程执行 | 改为 Task.Run + Invoke 更新控件 |
| 模型输出结构不止 score 和 anomaly_map | 某些模型导出结果较多 | 用 Python 或 C# 打印所有输出节点,按名称解析 |
7.2 典型的排查流程
如果你在推理时遇到问题,建议按下面的顺序排查:
- 确认 ONNX 文件在 Python onnxruntime 中可以正常推理;
- 打印 C# 侧的输入输出节点信息,和 Python 侧对比;
- 检查输入张量的形状是否为 NCHW;
- 检查图像预处理是否正确,包括通道顺序、缩放、归一化;
- 单独测试 score 输出是否稳定;
- 再检查热力图转换和可视化逻辑。
只要 Python 侧能跑通,C# 侧的问题通常都出在输入输出节点名称、数据形状或者预处理不一致上。
8. 工程化建议与后续方向
演示程序能跑通之后,如果要把它推向实际项目,还有几个地方值得完善。
第一,阈值不要写死在代码里。把最佳阈值放在配置文件或模型附带文件中,每次发布模型时一起更新。这样现场调参时不需要重新编译程序。
第二,推理调用尽量放到后台线程。现场图片可能来自相机采集,也可能是操作员选择本地图片,无论哪种方式,都不应该让界面在推理期间无响应。建议使用 Task.Run 配合 Control.Invoke 更新界面。
第三,注意资源释放。ONNX 模型每次加载会占用内存,Session 对象、Mat 对象、Bitmap 对象都应该及时释放。PictureBox 在切换图片时,旧的 Image 也要调用 Dispose,否则长时间运行后内存会不断增长。
第四,模型文件交付时最好带上模型版本号和输入预处理参数。一个模型文件本身说明不了太多信息,如果现场换了一台设备、换了一个分辨率,没有预处理参数很难快速排错。
第五,性能优化可以按阶段推进。先用 CPU 推理跑通功能;如果现场有 Intel 平台,可以尝试 OpenVINO Execution Provider;如果机器有 NVIDIA 显卡,则换用 onnxruntime-gpu 包。一般来说,异常检测模型输入尺寸不大,CPU 在很多场景下已经够用。
最后,如果需要在 WinForm 之外进一步扩展,可以考虑把推理封装成独立的推理服务,由 C# 客户端通过本地 HTTP 或 gRPC 调用。这样算法更新时只需要替换服务端模型,不用重新发布整个桌面程序。
希望这篇基于 C# WinForm 部署 anomalib ONNX 模型的教程,能帮你少走一些弯路。从模型导出、C# 环境配置到推理和后处理,每一步看起来都不难,但真正串起来时容易卡在版本和参数细节上。如果你也正在做工业异常检测的客户端部署,可以直接参考这套代码把演示跑通,再根据自己项目的模型输出格式做适当调整。