C#中部署Detic开放词汇检测模型到Onnx Runtime的实战指南
简介这是一份面向.NET开发者的C# Onnx Detic目标检测工程可识别2.1万种类别的物体并生成掩码适合需要大规模开放词汇检测、智能图像标注或视觉落地项目的场景。压缩包共29个文件约638MB核心为10个C#源码文件覆盖WinForms界面、检测主流程与结果解析另外配有DLL运行库、ONNX模型、resx界面资源、配置文件以及类别名称文本构成可直接还原的完整Visual Studio解决方案。模型采用IN-21K与COCO联合训练的Detic权重能覆盖2.1万类常见与细粒度物体并同步输出检测框和掩码结果随包附带的测试图片和说明文档可帮助快速验证效果。已有130人学习适合具备一定C#与ONNX Runtime基础、希望集成高精度大规模目标检测能力的开发者参考。1. Detic 是什么C# 工程师为什么要在 Onnx Runtime 里跑 2 万 1 千类检测模型DeticDetector with image classes是 Meta 开源的一套开放词汇目标检测方案核心思路是把类别名用 CLIP 文本编码器转成特征向量再让检测头在这些向量空间里做分类。相比传统模型只认识 COCO 的 80 类或 LVIS 的 1200 类Detic 可以一次覆盖 2 万 1 千个类别并且每个目标除了边界框还带掩码输出能直接拿到像素级分割结果。对 C# 工程来说模型转成 Onnx 后通过 Onnx Runtime 调用不需要 Python 环境也没有推理服务进程要常驻适合直接嵌进上位机、质检软件、图像检索工具里。本文从模型导出讲起沿 C# 侧预处理、推理、掩码后处理一路落到部署排错新手能照步骤跑通熟手可以跳着看参数和边界。2. 把 Detic 导出成 Onnx从 PyTorch 到固定输入的推理图2.1 Detic 的组成检测主干 CLIP 文本编码器Detic 跑在 Detectron2 体系里常见主干有 ResNet50、SwinTransformer检测头采用 Faster R-CNN 结构并带一个可选的 Mask 分支。它和普通检测器的最大区别在分类头传统模型维护一组固定维度的线性权重Detic 则把“类别名列表”交给 CLIP 的文本编码器得到形状为 [类别数, 512] 的文本嵌入再和图像区域的 ROI 特征做相似度匹配。类别名换一换、加一加不需要重新训练这就是它能覆盖 2 万 1 千类的原因。这个设计给部署带来一个关键点导出的 Onnx 图不能只喂图像还要喂文本嵌入。常见做法有两种一是把文本嵌入作为 Onnx 的输入张量由 C# 侧在启动时加载数组二是干脆把文本嵌入常量折叠进图里但这样类别列表就写死了运行时没法局部裁剪。我建议用第一种保留运行时灵活性也就是本文采用的方式。类别名表用 ImageNet-21K 那套每个类名先过 CLIP 的 tokenize再编码成 512 维向量一次性保存成 .npy 文件供导出和部署共用。2.2 导出脚本冻结权重并固定输入尺寸导出前先准备好三样东西Detic 的权重文件.pth、推理用的 yaml 配置、Imagenet-21K 的 CLIP 文本嵌入数组。下面这个脚本把模型转成 Onnx输入尺寸固定为 640x640类别嵌入从外部传入。# export_detic_onnx.py import torch import numpy as np from detic.config import get_cfg from detic.modeling.meta_arch import Detic cfg get_cfg() cfg.merge_from_file(configs/Detic_LI_CLIP_SwinB_21K_640.yaml) cfg.MODEL.WEIGHTS Detic_LI_CLIP_SwinB_21K_640.pth cfg.MODEL.MASK_ON True model Detic(cfg) model.eval() # 预生成的文本嵌入[21000, 512]float32 text_embeds np.load(imagenet_21k_clip_embeds.npy) text_embeds torch.from_numpy(text_embeds).float().unsqueeze(0) dummy_image torch.randn(1, 3, 640, 640) torch.onnx.export( model, (dummy_image, text_embeds), detic_21k.onnx, input_names[image, text_embeds], output_names[boxes, scores, class_ids, masks], opset_version17, do_constant_foldingTrue, ) print(export done)这段脚本里值得注意的有四个点。其一cfg.MODEL.MASK_ON True不能省否则导出的图里没有 mask 输出后面掩码处理无从谈起。其二opset_version17是我在 Onnx Runtime 1.16.x 下验证过的组合如果你的运行时是 1.13 或更老导出时会遇到不兼容算子要么升级运行时要么把 opset 降到 14 再测试一次。其三固定 640x640 输入是有意为之C# 侧每个输入张量不需要随图像尺寸重新分配推理图的内存规划和算子调度都能提前做静态优化。代价是长宽比怪异的图像会被拉伸实际部署时 640 对多数工业场景够用。其四text_embeds加了unsqueeze(0)变成 [1, 21000, 512]Detic 的图输入要求带 batch 维C# 侧也要保持相同布局。导出完成后你会得到一个包含 boxes、scores、class_ids、masks 四个输出的 Onnx 文件。其中 boxes 是每个目标框的 x1、y1、x2、y2 坐标scores 是该框的置信度class_ids 是在 21K 类别表中的索引masks 是每个实例的掩码 logits形状取决于模型配置常见是 [N, 28, 28]。2.3 导出后校验用 onnxruntime 在 Python 侧先跑一遍导出成功不代表图是对的。很多问题要到真正跑推理时才会暴露比如输入名被 Detectron2 的 exporter 改成了data或images或者 mask 输出形状和预想不一致。所以我习惯在 Python 环境里先做一次冒烟测试再交给 C# 侧。# verify_onnx.py import onnxruntime as ort import numpy as np sess ort.InferenceSession(detic_21k.onnx, providers[CPUExecutionProvider]) image np.random.rand(1, 3, 640, 640).astype(np.float32) embeds np.load(imagenet_21k_clip_embeds.npy).astype(np.float32)[None, ...] outs sess.run(None, {image: image, text_embeds: embeds}) for name, arr in zip([boxes, scores, class_ids, masks], outs): print(name, arr.shape, arr.dtype)这个脚本只要输出形状符合预期就说明 Onnx 图和 Detic 的配置对上了。我见过不少导出后 shape 正常、但一换真实图片就全部框为空的案例原因多是文本嵌入的 dtype 不一致PyTorch 里默认 float32但 .npy 保存时被转成了 float16。所以校验脚本里显式astype(np.float32)C# 侧读数组时也必须统一成 float32。如果这里发现 boxes 输出是 [1, N, 4] 而不是 [N, 4]也别慌这是某些导出设置下自动多出的 batch 维。C# 侧解析时统一按当前 shape 做一次识别即可通常squeeze掉第一维就干净了。3. C# 侧 Onnx Runtime 部署模型加载、预处理、推理3.1 NuGet 环境Microsoft.ML.OnnxRuntime 与 OpenCvSharp4C# 侧做推理我一般只依赖两个包Microsoft.ML.OnnxRuntime负责加载和执行 Onnx 模型OpenCvSharp4.Windows负责图像解码、resize、颜色转换和可视化。使用 .NET 6 或更高版本项目在工程目录下执行dotnet add package Microsoft.ML.OnnxRuntime --version 1.16.3 dotnet add package OpenCvSharp4.Windows --version 4.8.0.20230708版本选择上有讲究。Onnx Runtime 1.16.3 对应支持 opset 17 的模型如果模型导出时用了更高版本算子运行时版本也要跟着往上提。OpenCvSharp4.Windows 自带原生 dllWindows x64 下直接跑省去手动配置 PATH 的麻烦。如果项目要部署到服务器或工控机记得把这两个包的依赖 dll 一并拷贝到输出目录常见翻车是把原生库漏了换一台机器就报 DllNotFoundException。3.2 预处理BGR→RGB、归一化、维度布局Detic 在 Detectron2 里的预处理方式是图像先按短边缩放640再居中填充到正方形最后除以 255 并做均值和方差标准化。为了配合固定输入图我把缩放和填充简化成直接拉伸到 640x640这样代码简单、可复现工业现场非极端长宽比下精度损失很小。using OpenCvSharp; static float[] Preprocess(Mat bgr, int target 640) { using Mat rgb new Mat(); Cv2.CvtColor(bgr, rgb, ColorConversionCodes.BGR2RGB); using Mat resized new Mat(); Cv2.Resize(rgb, resized, new Size(target, target), 0, 0, InterpolationFlags.Linear); float[] data new float[3 * target * target]; float[] mean { 0.485f, 0.456f, 0.406f }; float[] std { 0.229f, 0.224f, 0.225f }; int stride target * target; for (int y 0; y target; y) { for (int x 0; x target; x) { Vec3b p resized.AtVec3b(y, x); data[y * target x] (p.Item0 / 255f - mean[0]) / std[0]; data[stride y * target x] (p.Item1 / 255f - mean[1]) / std[1]; data[2 * stride y * target x] (p.Item2 / 255f - mean[2]) / std[2]; } } return data; }逻辑说明OpenCv 默认 BGR而 Detic 训练时用的是 RGB第一步必须转换。数据布局上 Onnx Runtime 要求 NCHW也就是 channel 在前所以代码按 R 通道整块、G 通道整块、B 通道整块的顺序填充每个通道内按行优先排。很多新手在 HWC 和 CHW 上踩坑症状是输出框全乱、目标位置对不上检查点就是这里。均值和标准差取 ImageNet 标准值这是 Detectron2 默认配置不需要自己改。3.3 核心推理Session.Run 循环推理模型加载使用InferenceSession建议把 session 设为单例不要每次推理都 new 一个否则模型文件 I/O 和算子初始化会拖垮整体性能。下面代码演示最基础的加载和推理using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; private readonly InferenceSession _session; private readonly float[] _textEmbeds; // [21000, 512] public DeticInference(string modelPath, string embedPath) { var options new SessionOptions { GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL, EnableMemoryPattern true }; _session new InferenceSession(modelPath, options); _textEmbeds LoadFloatArray(embedPath); // 自实现按 float32 读取 npy } public ListDetection Run(Mat image) { float[] input Preprocess(image); int classCount _textEmbeds.Length / 512; using var imageTensor new DenseTensorfloat(input, new[] { 1, 3, 640, 640 }); using var embedTensor new DenseTensorfloat(_textEmbeds, new[] { 1, classCount, 512 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(image, imageTensor), NamedOnnxValue.CreateFromTensor(text_embeds, embedTensor) }; using var results _session.Run(inputs); return ParseOutputs(results); }参数说明ORT_ENABLE_ALL打开图优化会把算子做融合速度提升明显EnableMemoryPattern允许 Onnx Runtime 在多次推理间复用内存块对连续视频帧很友好。DenseTensor的构造参数第一维是 batch size固定为 1后续维度和导出时一致。Run返回的IDisposableReadOnlyCollectionDisposableNamedOnnxValue记得用 using 包住或手动 Dispose否则非托管内存只靠 GC 回收长时间稳定运行会出现内存缓慢上涨。3.4 输出解析找出 top-K 框和类别索引Run返回的结果顺序和导出时output_names一致boxes、scores、class_ids、masks。解析时先做一次置信度过滤再把框坐标和类别索引对应起来。public ListDetection ParseOutputs(IDisposableReadOnlyCollectionDisposableNamedOnnxValue results) { var boxes results[0].AsTensorfloat(); var scores results[1].AsTensorfloat(); var clsIds results[2].AsTensorlong(); var masks results[3].AsTensorfloat(); var detections new ListDetection(); for (int i 0; i scores.Length; i) { float score scores[i]; if (score 0.3f) continue; var box new Rectangle( (int)boxes[i, 0], (int)boxes[i, 1], (int)(boxes[i, 2] - boxes[i, 0]), (int)(boxes[i, 3] - boxes[i, 1]) ); detections.Add(new Detection { ClassId (int)clsIds[i], Score score, Box box, MaskLogits ExtractMask(masks, i) }); } return detections; }这里的ExtractMask需要根据实际输出张量形状处理。如果 masks 是 [N, 28, 28]就按第 i 行拷贝如果模型输出 [N, 112, 112]拷贝逻辑一样只是后面缩放的尺寸不同。另外注意 class_ids 的 dtype 是 long强转 int 时不要溢出21K 类远小于 int 范围安全。置信度 0.3 是经验值端到端落地时先跑一批自己的测试图看漏检和误检的平衡点再决定提高或降低。4. 掩码处理把 mask logits 变成可视化遮挡物4.1 掩码输出形状与 sigmoid 解码Detic 的 mask 分支输出的是 logits不是概率。要得到二值掩码先过 sigmoid 映射到 01再做阈值化。从 Onnx 输出里取到某个实例的 mask logits 后形状是 [28, 28]或 [112, 112]两步解码如下static float[,] SigmoidMask(float[] maskFlat, int size) { var map new float[size, size]; for (int i 0; i size * size; i) { float x maskFlat[i]; map[i / size, i % size] 1f / (1f (float)Math.Exp(-x)); } return map; }逻辑说明sigmoid 公式里Math.Exp对负数也安全极端情况下 logits 大于 20Math.Exp(-20)接近 0结果趋近 1不会溢出。decode 完得到的是 float 概率图后续缩放和阈值操作都用它。4.2 掩码缩放到原图从 28x28 到 bbox 再到全图掩码只有 28x28 分辨率要显示到原图上需要两级缩放。第一级把掩码放大到检测框尺寸第二级放进全图坐标系。这里有个关键点mask logits 解码出的 28x28 网格对应的是边界框内部区域不是整张图。static Mat BuildFullMask(float[,] maskProb, Rect bbox, int origW, int origH) { int size maskProb.GetLength(0); using Mat small new Mat(size, size, MatType.CV_32F); for (int y 0; y size; y) for (int x 0; x size; x) small.Set(y, x, (float)maskProb[y, x]); using Mat resized new Mat(); Cv2.Resize(small, resized, new Size(bbox.Width, bbox.Height), 0, 0, InterpolationFlags.Linear); Mat full Mat.Zeros(origH, origW, MatType.CV_32F); using Mat roi new Mat(full, bbox); resized.CopyTo(roi); return full; }参数说明Cv2.Resize用线性插值比最近邻平滑边界不突兀new Mat(full, bbox)创建感区域视图不改原图数据拷贝到 roi 后其实写的是 full。注意 bbox 坐标来自 Onnx 输出已经是原图像素尺度不需要再乘缩放系数。如果发现掩码错位、和框对不齐问题基本都出在框坐标不是原图尺度而是 640x640 输入尺度排查时先确认这一点。4.3 用掩码计算物体面积与做分割显示掩码的后处理不只有可视化还可以做面积统计和区域遮挡。我常用的是生成半透明覆盖层在图像上直观标注目标轮廓static Mat DrawMaskOverlay(Mat image, Mat fullMask, Scalar color) { Mat maskU8 new Mat(); fullMask.ConvertTo(maskU8, MatType.CV_8UC1, 255); Cv2.Threshold(maskU8, maskU8, 127, 255, ThresholdTypes.Binary); Mat overlay image.Clone(); image.SetTo(color, maskU8); Cv2.AddWeighted(image, 0.6, overlay, 0.4, 0, overlay); return overlay; }面积计算则简单得多对二值掩码做像素统计得到的是像素数。如果相机标定过像素和物理尺寸的映射关系可以直接换算目标面积。这个能力非常适合做缺陷检测和尺寸测量普通检测框只能提供一个矩形区域掩码能把背景像素剔除掉面积统计更接近真实值。5. 避坑C# Onnx Detic 的实际部署教训5.1 现象第一帧推理巨慢疑似模型卡死第一次session.Run花了 45 秒后续每帧只有 300ms。原因Onnx Runtime 首次推理会执行内存规划、算子预热和线程池初始化这部分开销不算在单帧延迟里。解决程序启动后用一个黑色占位图先跑一次推理把预热工作做完后续即可正常速度。如果你发现每次调用都慢而不是只有第一次检查是否每次都在 newInferenceSession这是最常见误用。5.2 现象掩码坐标漂移框对得上但掩码错位框坐标看起来是对的掩码却整体偏移或者缩放过度。原因掩码缩放用了错误的原图尺寸或者 bbox 坐标被二次转换。解决确认 boxes 输出的坐标系。Detic 导出的图通常已经映射回原图分辨率如果你在 C# 侧Cv2.Resize回原图时又把框坐标乘了一遍缩放系数掩码必然错位。我在工程里加了一行校验打印某张图上框坐标和原图宽高如果框超出图像边界说明坐标系不对。5.3 现象文本嵌入维度不匹配Session.Run 抛异常Session.Run报错“Input tensor text_embeds has wrong shape”或者直接黑匣子式异常。原因CLIP 文本嵌入在 Python 侧保存时用了 float16C# 侧读取后未转 float32或者 .npy 文件不是 [21000, 512] 布局。解决保存嵌入时统一np.float32C# 侧加载后校验Length 21000 * 512。我吃过一次亏是类别表某一行混入了空字符串CLIP 照样编码出一个向量数组长度没变但类别索引全乱了最后排查是读取类名文本时编码问题。5.4 现象CPU 上 21K 类推理慢到不可用一台普通 i5 主机跑 21K 类全量推理接近 3 秒一帧。原因分类头的矩阵乘法规模和类别数线性相关21K 类意味着每个框都要和 2 万多个类别向量比对。解决按业务场景裁剪类别子集把文本嵌入只传给模型相关行速度提升显著第 6 章会给具体做法。另外关闭ORT_ENABLE_ALL之外的额外优化项实测有些优化在 CPU 上反而收益小先跑基准再决定。5.5 现象动态输入尺寸导致偶发内存上涨如果导出时给 image 开了动态宽高C# 侧每帧传入不同尺寸Onnx Runtime 会为每个输入尺寸重新分配中间缓冲长时间运行内存膨胀。解决C# 侧统一 resize 到固定 640x640模型保持静态输入。固定输入还有一个好处EnableMemoryPattern能真正生效复用一个内存池内存曲线平稳很多。如果必须支持不同分辨率建议准备两到三个固定档位的 session比如 640、960而不是完全动态。6. 进阶按业务场景裁剪类别列表让吞吐翻几倍做真实项目时21K 类几乎从不需要全量。安防场景可能只要 person、car、dog 那几十类质检场景也许就十来个缺陷类别。把类别列表从 21K 砍到几百甚至几十分类头矩阵乘法的规模立刻降下来CPU 上也能从秒级进到百毫秒级。static float[] SelectEmbeds(float[] allEmbeds, int dim, int[] classIdx) { var subset new float[classIdx.Length * dim]; for (int i 0; i classIdx.Length; i) Array.Copy(allEmbeds, classIdx[i] * dim, subset, i * dim, dim); return subset; }用法启动时读入完整的 21K 嵌入数组再按业务类别的索引取出对应行构建一个新的嵌入数组传给 session。做这个裁剪时类别索引必须和原始类别表严格对应否则检测出的 class_id 会落到错误的类别名上。裁剪后的 class_id 依然是在完整类别表中的编号C# 侧显示类别名时用完整表去查不要用裁剪后的局部索引进表。验证方法很简单分别用全量嵌入和裁剪嵌入对同一张测试图推理对比剩余类别的检测框坐标和分数。理论上应该完全一致因为 Detic 的分类头只是过滤了文本嵌入的行没参与计算的类别自然不会出现在结果里。如果分数出现细微差异检查是否在裁剪时把 float32 转成了其它精度。我在实际项目里的习惯是类别裁剪逻辑做成配置文件让现场人员改 JSON 而不动代码嵌入数组按业务拆成多个小文件适配不同产线。另外别忘了掩码分支不受类别数影响帧率瓶颈往往就卡在分类头裁剪完如果还不够快才考虑 Onnx 的 INT8 量化但掩码分支量化容易引入锯齿状伪掩码建议只对图像输入做量化文本嵌入保留 FP32。把 21K 类模型落进 C# 上位机最怕的不是模型不准而是工程里的小细节自己跟自己打架。先把导出和 C# 侧的输入输出对齐再谈性能和裁剪这条路径我反复走过几遍按上述步骤来能少折腾一两个星期。希望帮到你。本文还有配套的精品资源点击获取