ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#上位机部署Detic ONNX模型:2.1万类检测与掩码分割

C#上位机部署Detic ONNX模型:2.1万类检测与掩码分割 简介这是一份面向C#开发者的ONNX目标检测与实例分割解决方案基于Detic模型实现两万一千类常见物体的识别并额外支持掩码输出适用于图像标注、视觉问答、智能安防等需要精细区分类别的场景。压缩包共包含29个文件整体约638MB核心部分为C#源码工程包含十个.cs文件以及窗体设计资源另有九个DLL依赖库含ONNX Runtime与OpenCvSharp与一个大型ONNX模型文件同时附带类别名称清单和测试图片目录结构清晰便于直接编译与调试。已有130人学习参考适合具备一定C#基础、希望在.NET环境中集成大规模开放词汇检测能力的开发者。通过这份工程可完整掌握从模型加载、推理调用到掩码结果可视化的实现思路节省自行搭建与调参时间同时也可作为后续二次开发的基础框架。1. 一个 C# 上位机就能跑 2.1 万类检测Detic 到底是什么做 C# 上位机的总会遇到一个尴尬时刻客户丢过来一个需求要识别仓库里的货品、流水线上的工件、园区里的车辆少说几十类多则上千类。以前我的第一反应是训练一个分类模型类别一多就露怯——数据要标注、模型要调、现场还总认错。后来换成了 Detic 的 ONNX 模型直接在 C# 工程里加载运行类别覆盖到 2.1 万个还顺带把掩码一起出了。这篇把整个落地过程拆透模型导出时输入输出怎么约束、C# 里怎么调用 ONNX Runtime、掩码怎么叠加到原图以及我踩过的几个大坑。适合正在做 C# 上位机视觉、又被类别数量卡住的朋友。2. 原理与导出从 PyTorch 到 ONNX2.1 万类是怎么塞进模型的2.1 开放词汇检测原理要落地一个模型先得弄清楚它的边界在哪。Detic 这个名字来自“Detecting Twenty-thousand classes”论文里做了一件关键的事把检测头固定的分类权重换成了类别名称的文本嵌入。传统检测模型像 Faster R-CNN最后一个分类层是一个 [num_classes, feature_dim] 的权重矩阵类别在训练时就写死了。你训了 80 类它就只会报这 80 类换个没见过的物体就瞎猜。Detic 的思路是把最后的分类矩阵替换成由 CLIP 文本编码器生成的文本嵌入类别名字符串进文本编码器得到一个特征向量图像区域的特征和这些文本向量做点积哪个分数高就归为哪一类。这样做的好处是类别表可以动态换推理时甚至可以临时加类别不用重训模型。但这个开放性也带了代价运行时必须有一条文本编码的路径。对 C# 部署来说最省事的做法是在导出 ONNX 时把全部类别名称预先编码成文本嵌入固化到模型里这样推理阶段只需要图像一个输入和普通检测模型的调用方式完全没有区别。那 2.1 万类是怎么凑出来的Detic 的词表来自 LVIS 的 1200 多类加上 ImageNet-21K 的约 2 万类合并去重后大约 21000 个类别。这个规模直接决定了模型体积backbone 用 ResNet50 时导出的 ONNX 在 300MB 上下如果把文本嵌入也全部固化进常量池文件会更大。加载的时候有点耐心后面 C# 里的 SessionOptions 我会设置成 ORT_ENABLE_ALL 来预优化首次加载多花几秒换来的是推理时的提速。backbone 的选型也影响后续部署。Swin-T 精度更高但导出 ONNX 的算子更复杂C# 端遇到 unsupported operator 的概率明显变大。如果你的应用不是特别在意那两三个点的 mAP第一版先跑 ResNet50 会省非常多事。我这次用的就是 ResNet50后面所有步骤都是基于这个配置讲的。2.2 pytorch 转 onnx导出时的四个关键约束接下来是导出很多人卡在这一步。常见做法是在 Detectron2 的推理脚本基础上把 torch.onnx.export 接在模型加载之后。不同分支的 Detic 实现 API 略有差异但导出前必须做的一件事是冻结文本编码器把 2.1 万个类别的名称全部编码成文本嵌入并固化成常量。我这次用的导出脚本大概长这样import torch from detic_predictor import DeticPredictor # 加载预训练权重配置文件里指定了 backbone、输入分辨率和词表路径 predictor DeticPredictor(configs/LVISv0.5_R50_640.yaml) predictor.model.eval() # 冻结文本编码器2.1 万类文本嵌入固化为常量 # 不同实现的接口不同有的叫 freeze_text_encoder有的要手动遍历参数 if hasattr(predictor, freeze_text_encoder): predictor.freeze_text_encoder() else: for param in predictor.model.clip_text_encoder.parameters(): param.requires_grad False # 固定输入尺寸为 640x640不要在这个阶段开动态轴 dummy_input torch.randn(1, 3, 640, 640) torch.onnx.export( predictor.model, dummy_input, detic_r50_640.onnx, input_names[input], output_names[boxes, scores, labels], dynamic_axesNone, # 不启用动态轴保持全程静态兼容性最好 opset_version17, do_constant_foldingTrue, # 把文本嵌入彻底变成常量 ) print(export done)这里有三个点值得展开说。第一个是 freeze_text_encoder 这一步必须做否则导出的模型会带着一条完整的文本编码计算图C# 里不仅要额外传入文本张量而且 2.1 万类跑一次文本编码推理时间直接翻倍。冻结之后文本嵌入变成常量池的一部分输入就只剩图像这也是 C# 侧能用几行代码跑起来的根本原因。第二个是 dynamic_axes我故意设为 None。很多教程让你开动态尺寸说能多分辨率复用但在 Detic 这种带注意力头和大常量池的模型上动态轴会让导出图里多出一堆动态形状算子ONNX Runtime 的图优化经常在这里翻车。固定 640x640换来的是 C# 端一次跑通。想要多分辨率工程上用多个 session 各跑各的比一个动态模型可控得多。第三个是 opset_version17 是 ONNX Runtime 兼容性最好的档位。太新的算子集在老版 NuGet 包上会报 unsupported operator太老又缺一些新算子。如果你用的 ONNX Runtime 版本比较旧导出前先看一眼官方兼容矩阵把 opset 对上号。导出成功不代表能用在做 C# 之前先用 Python 的 onnxruntime 验证一遍这一步能提前暴露九成的问题import onnxruntime as ort import numpy as np sess ort.InferenceSession(detic_r50_640.onnx, providers[CPUExecutionProvider]) dummy np.random.randn(1, 3, 640, 640).astype(np.float32) outs sess.run(None, {input: dummy}) for o, name in zip(outs, [boxes, scores, labels]): print(name, o.shape, o.dtype)如果报 Unsupported operator优先升级 ONNX Runtime 版本其次调低 opset如果报 Constant folding failed把 do_constant_folding 关掉重试文件会略大但至少能跑。还有一个很容易被忽略的文件类别词表。导出时模型只输出 labels 索引索引对应哪个类别靠的是词表顺序。我吃过一次亏把词表按字母重新排序结果类别全部错位。导出当天就要把词表文件原封不动备份格式是一行一个类别名顺序和模型训练时完全一致C# 侧只读取不排序。3. C# 工程落地OnnxRuntime 加载模型的完整流程3.1 NuGet 依赖与工程配置在 C# 里跑 ONNX 模型核心依赖是 Microsoft.ML.OnnxRuntimeCPU 版和 GPU 版二选一。CPU 版开箱即用任何 Windows 机器都能跑GPU 版需要 NVIDIA 显卡和对应 CUDA/cuDNN 环境接口完全一样只是底层换成 CUDA 执行提供程序。工程文件里加一行PackageReference IncludeMicrosoft.ML.OnnxRuntime Version1.16.3 /如果换 GPU 版把包名改成 Microsoft.ML.OnnxRuntime.Gpu版本号保持一致业务代码不需要动。这对我这种经常在不同机器上切换的人很友好现场机器没有显卡就发 CPU 版开发机有显卡就发 GPU 版。模型文件我一般放在程序目录下的 models 文件夹里结构大概是这样bin/ models/ detic_r50_640.onnx classes_lvis_21k.txt test.jpg CSharpDeticDemo.exe300MB 的模型文件不进版本库写一个启动检查逻辑发现模型缺失直接弹窗提示。加载代码就这么几行using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; var sessionOptions new SessionOptions { GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL }; using var session new InferenceSession( Path.Combine(AppContext.BaseDirectory, models, detic_r50_640.onnx), sessionOptions);GraphOptimizationLevel 设成 ORT_ENABLE_ALL 是起步配置让 ONNX Runtime 在加载阶段做层融合、常量折叠和内存复用这一步在 CPU 推理上有明显收益。代价是首次加载会慢几秒但换推理速度是值的。3.2 图像预处理尺寸、颜色通道与归一化Detic 的预处理和大多数 PyTorch 检测模型一致图像缩放或 padding 到 640像素归一化到 ImageNet 的均值方差通道顺序按 RGB。这里最容易翻车的是颜色顺序System.Drawing 返回的是 BGRA而模型训练用的是 RGB不交换的话检测结果会整体偏差不是完全不能用而是置信度普遍偏低边界错乱。我第一版代码就是把 R 和 B 写反了结果 0.8 分以上的目标一个都检不出来后来才想到是通道问题。写了一个通用预处理函数public static Tensorfloat Preprocess(string imagePath, int size 640) { using var bitmap new Bitmap(imagePath); var resized new Bitmap(bitmap, size, size); var data new float[3 * size * size]; for (int y 0; y size; y) { for (int x 0; x size; x) { var c resized.GetPixel(x, y); // System.Drawing 返回的是 BGRADetic 训练时用的是 RGB data[0 * size * size y * size x] (c.R / 255f - 0.485f) / 0.229f; data[1 * size * size y * size x] (c.G / 255f - 0.456f) / 0.224f; data[2 * size * size y * size x] (c.B / 255f - 0.406f) / 0.225f; } } return new DenseTensorfloat(data, new[] { 1, 3, size, size }); }归一化用的均值 [0.485, 0.456, 0.406] 和方差 [0.229, 0.224, 0.225] 是 ImageNet 预训练模型的标配Detic 的权重也是 ImageNet 起步的沿用这一组数就好。如果你拿到的 ONNX 是从自己训练的权重导出的那归一化参数要以训练时为准乱套会导致检测头全部失效。DenseTensor 的维度必须保持 [1, 3, size, size]对应 ONNX 输入的 NCHW。很多人把这里写成 [1, size, size, 3]模型不会报错但输出会全是垃圾因为形状对不上。检查方法是在 C# 里打印 session.InputMetadata看它要求的维度顺序。另外GetPixel 在循环里调用性能很差正式项目里我会用 LockBits 直接操作内存速度能快五六倍逻辑完全一样。这里的代码是为了让你先跑通后期再优化。3.3 推理与后处理从输出张量到目标框推理本身很直接Run 一次拿到所有输出。常见的是三个张量boxes [1, N, 4]、scores [1, N]、labels [1, N]。N 是模型检测出的目标总数不是固定值解析时按第一维遍历。后处理的核心是过滤低分目标和坐标还原。我把完整逻辑写成一个方法public static ListDetection Postprocess( IReadOnlyListDisposableNamedOnnxValue results, int originalWidth, int originalHeight, float threshold 0.5f) { var detections new ListDetection(); var boxes results.First(r r.Name boxes).AsTensorfloat(); var scores results.First(r r.Name scores).AsTensorfloat(); var labels results.First(r r.Name labels).AsTensorlong(); int num (int)boxes.Dimensions[1]; float scaleX originalWidth / 640f; float scaleY originalHeight / 640f; for (int i 0; i num; i) { float score scores[0, i]; if (score threshold) continue; float x1 boxes[0, i, 0] * scaleX; float y1 boxes[0, i, 1] * scaleY; float x2 boxes[0, i, 2] * scaleX; float y2 boxes[0, i, 3] * scaleY; detections.Add(new Detection { Label (int)labels[0, i], Score score, Rect new RectangleF(x1, y1, x2 - x1, y2 - y1) }); } return detections; }坐标参考系是这里最容易出错的地方。模型输出的坐标是在 640x640 输入图上算出来的要显示到原图上必须按缩放比例映射回去。如果原图是 1920x1080scaleX 等于 3.0scaleY 等于 1.6875两个比例不相等是正常的因为图像是等比缩放后 padding 的如果是直接拉伸到 640x640两个比例会相等。还有一个容易被忽略的问题Detic 的输出要不要再做 NMS。如果只按分数过滤两个高度重叠的框可能同时保留在上位机画面里会显得很乱。常见做法是在后处理里加一个简单的 NMSIoU 阈值取 0.5。思路是通用的按分数从高到低排序逐个和已选框算 IoU超过阈值的丢弃。很多人问 onnx 怎么运行其实核心就三步加载模型、喂入张量、解析输出。到这一步Detic 的检测框已经能出现在你的 C# 界面上了下一步才是重头戏——掩码。4. 掩码处理把检测结果从矩形框升级到像素级分割4.1 掩码输出张量的含义Detic 的“带掩码处理”是这份资源最实用的部分。检测只给矩形框掩码告诉你物体具体占了哪些像素这在货品叠加、工件抓取、目标分离这些场景里很有用。先搞清楚掩码从哪来。Detic 有两种形态第一种是模型本身带了 mask 头训练时用 MaskFormer 或者类似的头直接输出分割结果第二种是只训练了检测头掩码在后处理阶段用类别无关的掩码提议生成。拿到资源前先确认是哪种如果是第二种ONNX 里可能根本没有 mask 输出需要单独加载一个分割模型。这份资源带的是 mask 头版本所以按这种输出来讲。模型导出后输出里多一个张量形状可能有三种[N, maskH, maskW]、[N, 1, maskH, maskW] 或者 [1, N, maskH, maskW]。maskH 和 maskW 一般比输入图小常见的是 160 或 224。张量里的值是 logits不是概率不能直接拿来和 0.5 比大小必须先过 sigmoid。掩码的读取代码public static bool[,] ReadMask(Tensorfloat maskTensor, int index, float threshold 0.5f) { int dims maskTensor.Dimensions.Length; int offset maskTensor.Dimensions[0] 1 ? 1 : 0; int maskH dims 4 ? (int)maskTensor.Dimensions[offset 1] : (int)maskTensor.Dimensions[offset]; int maskW dims 4 ? (int)maskTensor.Dimensions[offset 2] : (int)maskTensor.Dimensions[offset 1]; var result new bool[maskH, maskW]; for (int y 0; y maskH; y) { for (int x 0; x maskW; x) { float raw dims 4 ? maskTensor[0, index, y, x] : maskTensor[index, y, x]; float prob 1f / (1f (float)Math.Exp(-raw)); result[y, x] prob threshold; } } return result; }这段代码最需要注意的是维度适配。Detic 的不同导出配置会让掩码张量维度不一样有的在前面多一个 batch 维。我先看 Dimensions.Length 再决定取数路径而不是写死四种情况这样换模型时不用改代码。sigmoid 手动用 1/(1e^-x) 算因为 ONNX 输出的掩码是 logits数值范围可能从 -10 到 10直接用会得到错误的分割图。阈值 0.5 是经验起步值掩码偏大就调高偏小就调低0.4 到 0.6 之间微调。4.2 掩码叠加与轮廓提取拿到 bool 掩码之后下一步是可视化或者提取轮廓。最简单的方式是生成半透明蒙版叠在原图上public static void DrawMask(Bitmap image, bool[,] mask, Color color) { int h mask.GetLength(0); int w mask.GetLength(1); for (int y 0; y h; y) { for (int x 0; x w; x) { if (!mask[y, x]) continue; int px x * image.Width / w; int py y * image.Height / h; var old image.GetPixel(px, py); image.SetPixel(px, py, Color.FromArgb( (old.R color.R) / 2, (old.G color.G) / 2, (old.B color.B) / 2)); } } }这套实现在小图上能跑但我必须说清楚SetPixel 逐像素操作在 1920x1080 上会很慢跑一遍要几百毫秒上位机实时显示根本扛不住。生产环境里我用 LockBits 把像素拷贝到 byte[] 里做颜色混合速度能快一个量级。如果项目里已经引了 OpenCvSharp直接用 Cv2.AddWeighted 叠加代码量更小性能也更好。轮廓提取在 C# 里没有现成的 findContours两个选择自己写八邻域追踪或者引入 OpenCvSharp。我一般用 OpenCvSharp因为上位机项目里基本都会用到它做图像处理using OpenCvSharp; var maskMat new Mat(maskH, maskW, MatType.CV_8UC1); for (int y 0; y maskH; y) for (int x 0; x maskW; x) maskMat.Set(y, x, mask[y, x] ? 255 : 0); Cv2.FindContours(maskMat, out var contours, out _, RetrievalModes.External, ContourApproximationModes.ApproxSimple); foreach (var contour in contours) { var scaled contour .Select(p new Point(p.X * image.Width / maskW, p.Y * image.Height / maskH)) .ToArray(); Cv2.Polylines(imageMat, new[] { scaled }, true, Scalar.LimeGreen, 2); }掩码还有一个实际用途是计算目标的像素面积这对上位机视觉很重要。判断传送带上的工件是否完全进入视野或者判断零件是否被遮挡用掩码的像素面积比用检测框面积更可靠。做法很简单遍历 bool 数组数一下 true 的个数再换算到原图分辨率。换算系数是长宽各乘一次比例面积比例要平方。掩码和检测框一起使用时要分清主次。掩码的价值在于分离粘连目标、计算像素面积、判断遮挡关系但类别可靠性还是要看检测框的分数。我在项目里一直是框和掩码一起展示掩码做颜色标记框做上报依据。5. 避坑记录Detic ONNX 在 C# 端落地的常见问题与排查5.1 模型加载后内存占用直接破 1GB现象用 InferenceSession 加载 Detic ONNX 文件后任务管理器里进程内存瞬间超过 1000MB每推理一帧还继续涨运行一天后感觉整个系统都卡。原因一方面是模型自身大FP32 权重加上 2.1 万类的文本嵌入常量文件 300MB 上下另一方面是如果没有做好内存复用ONNX Runtime 每次推理都会分配临时缓冲区。如果每次都在循环里 new InferenceSession内存只涨不降这是最常见的原因。解决session 全局复用整个程序生命周期只创建一次不要在循环里重复构建。SessionOptions 设 ORT_ENABLE_ALL 让图优化在加载阶段完成运行阶段不再做图变换。如果内存还是吃紧做 int8 量化权重能缩到原来的四分之一后面第 6 章会说。5.2 输出维度对不上底层报 index out of range现象解析输出时按 boxes[0, i, 0] 取数运行时报数组越界打印 Dimensions 才发现实际形状是 [N, 4] 而不是 [1, N, 4]也有反过来的情况。原因导出脚本不同输出张量的布局不一样。有的 PyTorch 实现会保留 batch 维有的顺手去掉了。即使同一份资源在不同机器上重新导出维度也可能不同。模型的输出名字也可能变化比如 labels 变成 class_ids。解决写一个维度适配层先打印 session 的输出元信息和实际张量 Dimensions再决定索引路径。我后来养成的习惯是Dimensions.Length 等于 3 就走 [1, N, ...]等于 2 就走 [N, ...]这样换模型只改配置不改代码。输出的 Name 也要先打印出来确认不要硬编码。5.3 类别 ID 与词表错位检测结果张冠李戴现象检测到一辆 car代码里查词表显示的是 dog集中出现在固定几个类别上其他类别正常。这类问题最难排查因为模型本身没报错框也很准。原因labels 是从 0 开始的索引词表文件的行顺序和模型训练时用的顺序不一致。LVIS 词表按字母排序而有些工具导出时会重新洗牌C# 侧再加载一个不同来源的 classes.txt两边对不上。最典型的坑是我自己干过的把词表文件按字母重新排序结果全部错位。解决导出当天就把词表文件当作构建产物一起备份C# 侧只读取不排序不裁剪。加了新类别后重新导出模型词表必须同步更新两边要来自同一次导出。最好在词表文件第一行写上来源说明和导出日期半年后再看还能想起来当时用的哪个版本。5.4 CPU 推理太慢单张图要 2~3 秒现象640x640 输入、CPU 环境下单次推理耗时 2~3 秒上位机画面完全没法跟帧。抓拍单张图片可以接受但视频流或连续检测场景直接不可用。原因模型大加上文本嵌入参与分类计算CPU 跑全精度 FP32 天然慢。ONNX Runtime 默认的线程策略偏保守多核优势没有发挥出来。解决第一步把输入尺寸降到 320x320速度大约能提 4 倍精度损失对大多数场景可接受第二步在 SessionOptions 里设置 intra_op 线程数为物理核心数第三步如果还是不够换 GPU 版 NuGet 包或者做 int8 量化。我的经验是先降分辨率再调线程这两个改动成本最低效果最明显。5.5 掩码尺寸与原图不一致边缘偏移十几个像素现象掩码叠加到原图后物体边界和实际轮廓对不上偏得不多但肉眼可见越靠近图像边缘偏移越大。原因掩码输出 160x160原图 1920x1080缩放映射时如果坐标用整数除法再取整精度损失会被放大。我第一版写的是 int mx x / targetWidth * maskW这里先做整数除法再乘误差叠加边缘偏移就出来了。解决把换算改成 float 比例最后再取整int mx (int)((float)x / targetWidth * maskW)同时确认掩码内部是否已经上采样ONNX 输出没有上采样的话需要自己在 C# 侧做双线性插值不要直接放大像素格子。检查方法是在掩码边缘画一个矩形框框对照原图里物体的实际边缘偏移超过两个像素就要处理。6. 进阶int8 量化与推理加速把单帧时间压下来如果项目对实时性有硬要求先把输入尺寸降到 320再看要不要量化。onnxruntime 的量化工具可以直接把 FP32 模型转成 int8不需要重新训练但要准备一批校准图片。from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( detic_r50_640.onnx, detic_r50_640_int8.onnx, weight_typeQuantType.QInt8, )动态量化只量化权重不量化激活精度损失最小推理速度提升约 1.5 到 2 倍。导出后记得对照原始模型跑同一批图片确认类别和分数没有明显漂移。我实测下来 Detic 这类大模型在 int8 下类别置信度会降 2%~5%但目标检出基本稳定对上位机场景是可接受的。C# 侧的加速配置不要忽略。SessionOptions 里可以显式设线程数IO 线程和内部线程分开调sessionOptions.AddSessionConfigEntry(session.intra_op_num_threads, 4); sessionOptions.AddSessionConfigEntry(session.inter_op_num_threads, 1);这样配置的效果是把单帧计算拆到 4 个物理核上inter_op 保持 1 防止多线程同步开销倒挂。GPU 环境下这两项建议关掉交给 CUDA 自己调度。如果后续要部署到嵌入式设备ONNX 还需要转成 rknn 之类的平台格式那是另一套流程但思路一致先导出 ONNX再走平台转换工具输入输出约束保持不变。从那以后我每次做 C# 视觉项目都会强制走一遍这个流程先打印所有输出维度再对照词表文件确认类别索引最后做一次量化前后的对比测试。这三个习惯帮我省掉了大量加班时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表