简介:本资源面向具备一定 C# 与深度学习基础的开发者,聚焦于在 .NET 环境中部署 PaDiM 异常检测模型这一具体工程问题,适用于工业视觉检测、医学影像分析等需要图像异常识别的场景。压缩包共 435 个文件,约 793.99MB,包含 10 个 cs 源码文件、1 个 sln 解决方案与 csproj 工程文件、2 个 onnx 模型文件,以及大量 png 示例图片、dll 依赖库、xml 配置与 nupkg 包等,覆盖从模型加载、输入输出定义到推理优化的完整链路。资源内含可直接运行的 Onnx Demo 演示程序与完整解决方案,读者可据此快速启动项目,省去环境配置与依赖查找的繁琐过程,并对照代码理解 ONNX Runtime 的 API 调用方式与 PaDiM 模型的集成思路。目前已有 78 人学习下载,适合希望将异常检测模型落地到 C# 应用的中高级开发者参考实践。
1. 从 Python 训练到 C# 产线部署:PaDiM 异常检测模型落地要跨过哪几道坎
工业质检场景里,PaDiM 算是异常检测里性价比很高的一条路线:预训练 CNN 提特征、每个 patch 位置拟合多元高斯分布、推理时用马氏距离打分,不需要负样本,几十张 OK 图就能跑起来。但真正卡住大多数人的不是算法本身,而是训练在 Python、产线设备跑的是 C# 上位机,中间隔着一整套张量对齐、算子兼容和内存管理的活儿。这份资源给的就是把 PaDiM 从 PyTorch 导出成 ONNX、再用 C# 和 ONNX Runtime 在本地推理的完整链路,包含模型导出脚本、C# 推理工程和预处理对齐代码。适合两类人:一类是做视觉质检上位机、需要把算法塞进现有 C# 系统的工程师;另一类是已经跑通 Python 版 PaDiM、想把它搬到无 Python 环境的产线机器上的开发者。下面按「模型怎么导 → C# 怎么接 → 坑在哪」的顺序拆开讲。
2. 把 PaDiM 导出成 ONNX:输入输出张量怎么定、算子怎么过
2.1 为什么 PaDiM 导出 ONNX 比想象中麻烦
PaDiM 的推理逻辑分两段:特征提取(ResNet18/50 的中间层输出)和距离计算(对每个 patch 位置算马氏距离)。特征提取部分导出 ONNX 很顺,因为全是标准卷积;麻烦的是距离计算——它依赖训练阶段统计出来的均值向量和协方差逆矩阵,这两个东西是随训练集变化的,不能写死在网络里。
常见做法有两种。第一种是把均值和高斯参数也做成 ONNX 的输入,网络只负责特征提取和距离计算,参数从外部喂进去。第二种是把参数固化进模型,导出成一个自包含的 ONNX。第一种灵活,换批次不用重新导出;第二种部署简单,但每次换产品型号都要重新导一次。这份资源走的是第一种,因为产线经常要切型号。
导出时还要注意:PaDiM 原版对每个 patch 位置单独建高斯分布,ResNet18 的 layer2+layer3 拼接后通道数是 448,特征图假设 28×28,那就有 784 个位置、每个位置一个 448×448 的协方差矩阵。这个量级直接塞进 ONNX 会很大,所以导出前一般会做降维(随机选 100 维左右),这一步在 Python 侧完成,ONNX 里只保留降维后的维度。
2.2 导出脚本与关键参数
import torch import torch.nn as nn import numpy as np class PaDiMExportWrapper(nn.Module): """只导出特征提取 + 马氏距离计算,均值/协方差作为输入""" def __init__(self, backbone): super().__init__() self.backbone = backbone # 已去掉 fc 的 ResNet def forward(self, x, mean, cov_inv): # x: [1, 3, H, W] feat = self.backbone(x) # [1, C, h, w] b, c, h, w = feat.shape feat = feat.permute(0, 2, 3, 1).reshape(b, h * w, c) # [1, N, C] # 马氏距离: sqrt((f-mean)^T * cov_inv * (f-mean)) diff = feat - mean # mean: [1, N, C] # cov_inv: [1, N, C, C] dist = torch.matmul(diff.unsqueeze(2), cov_inv) dist = torch.matmul(dist, diff.unsqueeze(3)).squeeze() dist = torch.sqrt(torch.clamp(dist, min=1e-8)) return dist.reshape(h, w) # 输出异常热力图 # 导出 model = PaDiMExportWrapper(backbone).eval() dummy_x = torch.randn(1, 3, 224, 224) dummy_mean = torch.randn(1, 784, 100) dummy_cov_inv = torch.eye(100).repeat(1, 784, 1, 1) torch.onnx.export( model, (dummy_x, dummy_mean, dummy_cov_inv), "padim.onnx", input_names=["input", "mean", "cov_inv"], output_names=["anomaly_map"], dynamic_axes={"input": {0: "batch"}}, opset_version=12, do_constant_folding=True )这段脚本的逻辑:把 backbone 和距离计算包成一个 nn.Module,forward 里先提特征、再 reshape 成 patch 序列、最后算马氏距离。参数说明几个关键点。opset_version=12是因为 matmul 对高维张量的支持和 squeeze 行为在 11 之后才稳定,低于 11 容易在 C# 侧报维度不匹配。dynamic_axes只对 batch 维开放,H/W 固定,因为 PaDiM 的均值向量是按固定特征图尺寸统计的,输入尺寸一变,patch 数量就对不上。do_constant_folding=True能把 BN 层折进卷积,减小模型体积,但要注意如果后面要改 BN 的 momentum,得重新导。
导出完建议用 onnxruntime 的 Python 版先验一遍,确认输出和 PyTorch 原版误差在 1e-4 以内再往 C# 搬,否则后面在 C# 里排查数值问题会很痛苦。
2.3 导出后的模型自检
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("padim.onnx", providers=["CPUExecutionProvider"]) x = np.random.randn(1, 3, 224, 224).astype(np.float32) mean = np.random.randn(1, 784, 100).astype(np.float32) cov_inv = np.tile(np.eye(100, dtype=np.float32), (1, 784, 1, 1)) out = sess.run(["anomaly_map"], {"input": x, "mean": mean, "cov_inv": cov_inv}) print("输出形状:", out[0].shape) # 期望 (28, 28) print("数值范围:", out[0].min(), out[0].max())这一步是黑匣子验证:只要形状对、数值不是 NaN、和 PyTorch 输出对得上,就说明 ONNX 图没问题。如果这里就报维度错误,别急着改 C#,先回 Python 查导出时的 reshape 顺序——permute 和 reshape 的顺序写反是最高频的翻车点。
3. C# 侧接 ONNX Runtime:张量构造、会话配置与内存复用
3.1 环境准备与 NuGet 包选择
C# 侧要跑 ONNX,核心就一个包:Microsoft.ML.OnnxRuntime。CPU 版直接装这个;如果要上 GPU,装Microsoft.ML.OnnxRuntime.Gpu,但要注意 CUDA 和 cuDNN 版本必须和包内置的版本对齐,否则会话创建时直接抛异常。产线机器如果没独显,CPU 版配CPUExecutionProvider就够,PaDiM 的 backbone 是 ResNet18 级别,单张 224×224 推理在普通 i5 上大概几十毫秒,节拍要求不高的场景完全能接受。
dotnet add package Microsoft.ML.OnnxRuntime --version 1.16.3版本号建议锁死,别用浮动版本。ONNX Runtime 的 API 在小版本间偶有变动,产线环境最忌讳的就是「昨天还能跑今天编译不过」。
3.2 构造输入张量的正确姿势
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class PaDiMInference : IDisposable { private readonly InferenceSession _session; private readonly string[] _inputNames = { "input", "mean", "cov_inv" }; public PaDiMInference(string modelPath) { var options = new SessionOptions(); options.IntraOpNumThreads = 4; // 按 CPU 核数调 options.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL; _session = new InferenceSession(modelPath, options); } public float[,] Infer(float[] chwData, float[] mean, float[] covInv, int h, int w, int patchNum, int dim) { // 图像张量: [1, 3, H, W] var inputTensor = new DenseTensor<float>(chwData, new[] { 1, 3, h, w }); // 均值: [1, N, C] var meanTensor = new DenseTensor<float>(mean, new[] { 1, patchNum, dim }); // 协方差逆: [1, N, C, C] var covTensor = new DenseTensor<float>(covInv, new[] { 1, patchNum, dim, dim }); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("input", inputTensor), NamedOnnxValue.CreateFromTensor("mean", meanTensor), NamedOnnxValue.CreateFromTensor("cov_inv", covTensor) }; using var results = _session.Run(inputs); var map = results.First(r => r.Name == "anomaly_map").AsTensor<float>(); var output = new float[h, w]; for (int i = 0; i < h; i++) for (int j = 0; j < w; j++) output[i, j] = map[i, j]; return output; } public void Dispose() => _session?.Dispose(); }逻辑说明:DenseTensor 的构造函数接收一维数组和形状,数组必须按行优先(C 顺序)排好。图像数据从 Bitmap 转过来时,常见做法是先锁定位图取像素,再按 R 通道全部、G 通道全部、B 通道全部的顺序填进数组,也就是 CHW 而不是 HWC。这一步写反了不会报错,但输出热力图会完全乱掉,属于典型的「不崩但结果错」的坑。
参数说明:IntraOpNumThreads控制单算子内部并行线程数,产线机器如果同时跑多个相机,建议调小避免线程争抢;GraphOptimizationLevel设成 ALL 让 ORT 做算子融合,能提速 10% 到 20%。NamedOnnxValue.CreateFromTensor每次调用都会包一层,如果追求极致性能,可以用OrtValue和IOBinding做零拷贝,但代码复杂度上一个台阶,一般质检场景没必要。
3.3 预处理对齐:归一化和 resize 必须和训练一致
PaDiM 训练时用的归一化参数(ImageNet 的 mean/std)和 resize 方式(双线性还是双三次)必须原样搬到 C#。C# 里没有现成的 torchvision,resize 得自己写或者用 OpenCvSharp。用 OpenCvSharp 的话:
using OpenCvSharp; public static float[] Preprocess(string imagePath, int targetH, int targetW) { using var src = Cv2.ImRead(imagePath, ImreadModes.Color); using var resized = new Mat(); // 训练用双线性,这里必须一致 Cv2.Resize(src, resized, new Size(targetW, targetH), 0, 0, InterpolationFlags.Linear); var floatData = new float[3 * targetH * targetW]; int idx = 0; // OpenCV 默认 BGR,PyTorch 训练用 RGB,通道顺序要换 for (int c = 2; c >= 0; c--) { for (int y = 0; y < targetH; y++) { for (int x = 0; x < targetW; x++) { var px = resized.At<Vec3b>(y, x)[c] / 255.0f; // ImageNet 归一化 float[] mean = { 0.485f, 0.456f, 0.406f }; float[] std = { 0.229f, 0.224f, 0.225f }; floatData[idx++] = (px - mean[c]) / std[c]; } } } return floatData; }这里有两个高频错误。一是通道顺序:OpenCV 读进来是 BGR,PyTorch 训练是 RGB,不换通道结果会偏。二是归一化参数:有人图省事只除以 255 不减均值,训练时减了推理不减,异常分数整体偏移,阈值全废。这两个错误都不会让程序崩,但会让检测结果不可用,属于血泪经验级别的坑。
4. 避坑与排查:C# 调 ONNX 最容易翻车的五个地方
4.1 现象:会话创建成功但 Run 报维度不匹配
原因通常是 C# 侧构造的 DenseTensor 形状和 ONNX 里声明的输入形状对不上。ONNX 导出时如果 mean 声明成[1, 784, 100],C# 侧传了[784, 100]或者[1, 100, 784],ORT 在绑定输入时就会抛。解决方法是先用 Netron 打开 onnx 文件,把每个输入的名字和形状抄下来,C# 侧严格照抄,别凭记忆写。
4.2 现象:推理结果全是 NaN 或极大值
多半是协方差逆矩阵传进去的时候维度或数值有问题。cov_inv 是[1, N, C, C],如果 C# 侧按[N, C, C]填,ORT 可能不报错但计算全乱。另外协方差逆矩阵在 Python 侧算的时候如果用了 float64,导出到 C# 用 float32,精度损失可能导致某些接近奇异的矩阵求逆后数值爆炸。常见做法是在 Python 侧对协方差加一个小的正则项(比如 0.01 倍单位阵)再求逆,保证数值稳定。
4.3 现象:单张推理正常,连续跑几百张后内存持续上涨
这是没释放 InferenceSession 或者没 dispose 结果。_session.Run返回的IDisposableReadOnlyCollection必须用 using 包起来,否则每次推理的中间张量不释放。另外 DenseTensor 如果反复 new,GC 压力也大。产线连续跑的场景,建议把输入张量的缓冲区复用,用DenseTensor的Buffer属性直接写数据,避免每次分配。
4.4 现象:CPU 版跑得动,换 GPU 版就报 provider 加载失败
GPU 版 ONNX Runtime 对 CUDA/cuDNN 版本极其敏感。包内置的 CUDA 版本和机器上装的不一致时,会话创建会直接失败。解决方法是查所用 ONNX Runtime 版本的官方文档,确认它要求的 CUDA 和 cuDNN 具体版本,然后在产线机器上装对应版本,别用系统里已有的其他版本。如果产线机器不方便装 CUDA,老老实实用 CPU 版,PaDiM 这个量级 CPU 完全扛得住。
4.5 现象:热力图看起来对,但阈值怎么调都不对
这通常不是推理的问题,而是训练侧和推理侧的预处理没对齐。检查三件事:resize 的插值方式是否一致、归一化参数是否一致、输入图像是否做了和训练时相同的裁剪。还有一个容易忽略的点:PaDiM 训练时如果对图像做了 padding 或者中心裁剪,推理时也要做同样的操作,否则特征图对应的空间位置就错位了,异常分数整体偏移。
5. 进阶:把热力图变成产线能用的判定结果
拿到float[,]的热力图只是第一步,产线要的是「OK/NG」和一个可追溯的分数。常见做法是对热力图做后处理:先高斯模糊去掉噪点,再取最大值或者 top-k 均值作为图像级异常分数,最后和一个标定好的阈值比较。
public static (bool isNg, float score) Judge(float[,] heatmap, float threshold, int topK = 10) { int h = heatmap.GetLength(0), w = heatmap.GetLength(1); var flat = new List<float>(h * w); for (int i = 0; i < h; i++) for (int j = 0; j < w; j++) flat.Add(heatmap[i, j]); flat.Sort((a, b) => b.CompareTo(a)); // 降序 float score = 0; for (int i = 0; i < Math.Min(topK, flat.Count); i++) score += flat[i]; score /= Math.Min(topK, flat.Count); return (score > threshold, score); }用 top-k 均值而不是最大值,是因为单点噪声容易让最大值虚高,top-k 更稳。阈值怎么定?拿一批已知 OK 和 NG 的图跑一遍,画 score 的分布,取两类分布之间能分开的那个点。如果两类重叠严重,说明模型或者预处理有问题,别硬调阈值。
还有一个实用技巧:把热力图叠加到原图上保存,NG 的时候留档,方便后面追溯是模型误判还是真有缺陷。C# 里用 OpenCvSharp 做伪彩色叠加几行代码的事,但对产线调试帮助很大。
从那以后我每次把 Python 模型往 C# 搬,都强制先跑一遍「同一张图、Python 和 C# 各推理一次、逐像素比对」的流程,误差超过 1e-3 就先查预处理,绝不带着偏差往下调阈值。希望帮到你。
本文还有配套的精品资源,点击获取