恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C#部署Detic:用ONNX Runtime实现21k类开放式词汇检测
首页
资讯中心
/
C#部署Detic:用ONNX Runtime实现21k类开放式词汇检测
C#部署Detic:用ONNX Runtime实现21k类开放式词汇检测
发布时间:2026/8/26 6:41:16
简介物体检测是计算机视觉的核心任务之一传统YOLO等模型受限于固定类别数难以应对数万类别的工业质检场景。开放式词汇检测通过CLIP文本-视觉对齐将类别名称编码为文本向量使模型可识别训练中未见的类别。Detic借助这一原理支持21k类物体识别但将其落地到生产环境需要解决模型部署难题。ONNX Runtime提供了跨平台推理能力使C#应用能够高效加载并运行Detic模型实现大规模类别检测。从文本嵌入预处理到候选框后处理C#技术栈可完整承接推理链路适用于工业零部件识别、电商商品分类等长尾场景。围绕C#环境下基于ONNX Runtime的Detic部署实践为高类别数物体检测需求提供了一套可行方案。1. 先把原理讲透Detic为何能识别21k类以及这对部署意味着什么之前有段时间项目组接到一个需求要在工业质检场景里识别两万多种不同的零部件和产品外观异常。一开始团队按老路子走用YOLO训练一个多分类检测模型结果数据标注就卡了一个多月2万个类别的边界框标注根本做不完类间混淆也严重。后来调研到Meta的Detic——一个使用图像级标签训练的开放式词汇检测模型官方宣称能检测21k类物体。这个量级确实不是YOLO方案能比的。但问题是Detic是PyTorch生态的项目我们这边的业务系统是C#技术栈要做成Windows上位机里的一个功能模块。于是就有了这篇文章的主题C#用onnxruntime部署Detic实现2万1千种类别的物体检测。先把结论放在前面这条路能走通但有很多坑——模型导出、文本嵌入、类别后处理、性能调优每个环节都值得好好梳理。如果你也在做C#桌面端或服务端的物体检测正愁类别数不够用这篇文章应该能帮你省下一两周的摸索时间。1.1 开放式词汇检测CLIP的文本-视觉对齐如何工作传统检测器在模型最后有一个全连接分类头输出维度等于训练时的类别数。以YOLOv8为例如果你训练了80个COCO类别模型输出就是[batch, 84]——前4个是坐标后80个是各类别概率。一旦业务上新增一个类别就得重新收集数据、重新训练整个模型。这在类别数只有几十个的时候还能忍一旦到了上千甚至上万几乎走不通。Detic的思路完全不同。它的分类头不再是一个固定维度的全连接层而是把“类别”变成了文本向量。具体来说Detic用CLIP的文本编码器把每个类别的名称比如“coffee maker”“red wine glass”编码成一个768维或1024维的向量多个模板句子的向量取平均后作为这个类别的“代表向量”。在检测时模型的分类头输出的是图像区域的特征向量然后把这个视觉特征向量和预先算好的类别文本向量做点积点积得分最高的类别就是检测结果。用大白话说传统检测器像一个只认识本班学生的班主任名单是固定的Detic像是拿着一本全校花名册的教导主任不管谁来了都能把名字和人脸比对出来。它之所以能检测21k类是因为类别向量是文本编码来的不依赖于训练时的固定标签。这对C#部署来说是一个关键信号推理链路可以拆成两部分。第一部分是视觉编码图像进入网络输出候选框和对应的视觉特征向量第二部分是类别匹配把视觉特征和预计算的文本嵌入矩阵相乘得到分类分数。第二部分完全可以离线准备好C#程序启动时加载一个类别向量矩阵就行。理论清晰了接下来就是实操层面的细节。1.2 模型推理链路拆解ONNX输入输出到底长什么样如果你直接把Detic官方仓库的PyTorch模型扔给torch.onnx.export导出大概率会失败。因为Detectron2里有不少自定义算子比如RoIAlign和Deformable ConvONNX导出兼容性比较差。更麻烦的是Detic推理时还涉及一些后处理逻辑这些不应该也没必要塞进ONNX图里。我的建议是导出一个“视觉编码器”版本而不是把完整的检测pipeline都导出来。这个简化版本的ONNX模型输入是一张图像输出三样东西输出名称形状说明proposals[N, 4]候选框坐标归一化到0~1features[N, 768]每个候选框经过RoIAlign和分类头之前的视觉特征向量objectness[N]候选框的置信度分数可用于筛选也就是说模型只负责“找到可能有个东西的地方并提取特征”至于这个东西是什么留给C#侧的文本嵌入矩阵来回答。这个方案有几个好处一是ONNX文件体积小几百MB主要是backbone权重二是类别体系可以随时换不用重新导出模型三是21k类的分类矩阵乘法可以放在C#侧灵活优化。如果你的业务完全不需要动态增删类别也可以把21k类的文本嵌入矩阵直接变成一个ONNX常量权重做成一个额外的矩阵乘子模型把分类计算也扔到GPU上跑。这个思路后面在性能优化部分再展开。1.3 2万1千类的“真面目”LVIS类别体系与文本embedding需要澄清一点Detic论文里说的21k类并不是LVIS数据集本身有21k个类别。LVIS v1.0大约有1200多个常见类别Detic利用WordNet和ImageNet-21k的层级关系把LVIS的类别标签扩展到了21k个细粒度类别——比如“杯子”被扩展成了“咖啡杯”“红酒杯”“啤酒杯”等。这种细粒度扩展让检测器能区分近义但不同的物体但也带来了一个实际问题类别之间的文本向量可能非常接近比如“cappuccino cup”和“coffee cup”这会让分类分数非常接近误检率升高。文本嵌入矩阵的规模大约是[21000, 768]的float32数组算下来64MB左右。如果类别向量维度是1024那就变成86MB。这个矩阵在C#里加载没问题但要注意加载时机和内存管理建议在程序启动时一次性读入内存并转置成[768, 21000]的布局方便后续矩阵乘。还需要注意prompt模板的影响。Detic在生成类别文本向量时不是只把类别名丢给CLIP文本编码器而是会用多个prompt模板比如“a photo of a {label}”“a photo of the {label}”“itap of a {label}”等分别编码取平均。这样得到的类别向量对CLIP来说更“自然”分类效果也更好。如果你要自己生成新的类别向量建议保留这个多模板平均策略不要只用裸类别名。2. 模型转换与运行环境拿到能用的ONNX文件只是第一步2.1 从PyTorch到ONNX的导出思路如果你有PyTorch环境可以自己从Detic官方权重导出。大致的流程是加载Detic的config和权重把model的forward方法拦腰截断让它在输出proposals和RoI features的地方停下来然后通过torch.onnx.export导出。实际操作中有几个容易卡住的点Detic内部用的是Detectron2的GeneralizedRCNN结构导出的图里可能会带torchvision::nms这样的自定义节点onnxruntime不一定支持。解决办法是导出时关掉NMS把NMS留到C#侧做。RoIAlign在onnxruntime 1.14之后的CPU EP已经支持但GPU EP的支持情况要看版本。如果你导出时遇到不支持的操作符可以考虑把RoIAlign从导出的图里剥出来用C#自己实现——但这很麻烦建议优先尝试升级onnxruntime版本或者改用支持更好的模型变体。导出时要设置dynamic_axes让proposal数量N这个维度是动态的因为每张图生成的候选框数量不一样。我个人的建议是如果不是非要从官方PyTorch权重自己导优先找社区已经转好的Detic ONNX文件。GitHub上有些项目专门做Detectron2模型到ONNX的转换里面可能就有Detic R50或R101的成品。自己导一次的成本不算高但排查算子兼容性的时间成本完全不可控——我就因为一个DeformableConv2d算子折腾了差不多一个星期。2.2 C#侧onnxruntime配置CPU/CUDA/DirectML选型在C#项目里引入onnxruntime通常是通过NuGet安装对应的包先看你们项目的运行环境再选。后端NuGet包名适用场景推理速度CPUMicrosoft.ML.OnnxRuntime无GPU服务器或轻量验证较慢CUDAMicrosoft.ML.OnnxRuntime.GpuNVIDIA显卡追求最高性能快速DirectMLMicrosoft.ML.OnnxRuntime.DirectMLWindows下AMD/Intel/NVIDIA通用中等TensorRT自定义编译EPNVIDIA高端卡极致优化最快但配置复杂如果目标机器是Windows且显卡不是N卡推荐用DirectML EP它走的是DirectML APIWindows 10/11自带的驱动就能跑不需要额外装CUDA环境。如果目标机器有N卡且你愿意装CUDA Toolkit和cuDNN那就用CUDA EP。如果只是想先跑通流程、验证结果CPU版本就够只是速度会慢不少尤其后处理部分。项目文件里加上这些包PackageReference IncludeMicrosoft.ML.OnnxRuntime Version1.17.1 / PackageReference IncludeOpenCvSharp4 Version4.8.0.20230708 / PackageReference IncludeOpenCvSharp4.runtime.win Version4.8.0.20230708 /如果走CUDA路线换成Microsoft.ML.OnnxRuntime.Gpu版本一定要和CUDA版本匹配。比如onnxruntime 1.17.x对应CUDA 11.8和cuDNN 8.x装错了运行时会报Failed to find cudart之类的错误。2.3 最容易卡住的DLL加载问题这个问题基本上每个C#接入onnxruntime的人都会遇到。你编译完程序跑起来第一行代码就抛异常System.DllNotFoundException: Unable to load DLL onnxruntime: 找不到指定的模块。排查方向有几个按概率从高到低项目的Platform target不是x64。onnxruntime的Native DLL只有64位版本如果你的项目编译目标是AnyCPU且勾选了Prefer 32-bit就会加载失败。项目平台目标必须设为x64。NuGet包没把native DLL复制到输出目录。正常的Microsoft.ML.OnnxRuntime包会在runtimes/win-x64/native/下带onnxruntime.dllMSBuild会自动复制。但如果你手动更换过输出目录或做过特殊清理可能会漏掉。检查一下输出目录里有没有onnxruntime.dll。用了Gpu包但CUDA相关DLL不在系统路径。Microsoft.ML.OnnxRuntime.Gpu包本身只带onnxruntime.dll运行时会动态加载cudart64_110.dll、cublas64_11.dll、cudnn64_8.dll等CUDA依赖。你需要把CUDA Toolkit的bin目录加入系统Path或者直接把相关DLL复制到程序输出目录。多个onnxruntime版本冲突。如果解决方案里有多个项目一个引了CPU包一个引了Gpu包最终输出目录里的onnxruntime.dll可能被覆盖。解决方案是统一版本只保留一个包引用。注意如果程序运行环境是Windows Server可能需要安装VC 2019 Redistributable。有些服务器精简版系统缺这个运行库onnxruntime.dll加载时也会报DllNotFoundException。3. 核心代码实战C#调用onnxruntime完成推理3.1 图像预处理resize、归一化、通道顺序Detic训练时用的是Detectron2的标准预处理方式图像保持BGR通道顺序因为OpenCV读取就是BGR逐通道减去均值[103.530, 116.280, 123.675]除以标准差[1.0, 1.0, 1.0]。等一下这里有个容易踩的坑CLIP本身是基于RGB图像预训练的但Detic的检测backbone是Detectron2里的ResNet它训练时用的是BGR输入。所以你在预处理时到底用BGR还是RGB取决于导出的ONNX模型内部是否做了通道转换。最稳妥的做法是先用一张纯红色图片做测试比如一张全红的图用代码打印预处理后的张量值看第一个通道是不是接近红色通道值。这个方法虽然是笨办法但能一次性确认通道顺序后面处理彩色图像时不会出错。整体预处理流程如下面的代码using OpenCvSharp; public static Tensorfloat PreprocessImage(string imagePath, int targetWidth, int targetHeight) { Mat image Cv2.ImRead(imagePath, ImreadModes.Color); Mat resized new Mat(); Cv2.Resize(image, resized, new Size(targetWidth, targetHeight)); float[] mean { 103.530f, 116.280f, 123.675f }; float[] std { 1.0f, 1.0f, 1.0f }; var tensor new DenseTensorfloat(new[] { 1, 3, targetHeight, targetWidth }); unsafe { byte* ptr (byte*)resized.Data; for (int y 0; y targetHeight; y) { for (int x 0; x targetWidth; x) { int channelOffset (y * targetWidth x) * 3; tensor[0, 0, y, x] (ptr[channelOffset 0] - mean[0]) / std[0]; tensor[0, 1, y, x] (ptr[channelOffset 1] - mean[1]) / std[1]; tensor[0, 2, y, x] (ptr[channelOffset 2] - mean[2]) / std[2]; } } } return tensor; }这段代码用的是OpenCvSharp的Mat.Data指针直接读取像素速度比AtVec3b快不少。如果你项目里不方便用unsafe代码也可以用Mat.GetArray或者resized.Data的Marshal.Copy方式但预处理耗时在整体推理耗时里占比不大640x640的图像大约几十毫秒性能敏感度不高。3.2 推理会话与输入张量构造创建onnxruntime会话的代码比较模板化但有三个细节会影响最终性能。第一一定要开图的优化级别第二如果机器有多个CPU核心配置线程数第三显式做一次warm-up推理。using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class DeticDetector { private readonly InferenceSession _session; private readonly string _inputName; public DeticDetector(string modelPath, bool useGpu false) { var options new SessionOptions(); options.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL; if (useGpu) { options.AppendExecutionProvider_CUDA(0); } else { options.AppendExecutionProvider_CPU(); // 显式设置CPU线程数默认用满所有核心反而会因为调度开销变慢 options.SetSessionThreadOptions(new SessionOptionsThreadOptions { IntraOpNumThreads Environment.ProcessorCount / 2, InterOpNumThreads 1 }); } _session new InferenceSession(modelPath, options); _inputName _session.InputMetadata.Keys.First(); // warm-up推理让内存池和kernel初始化完成 var dummyInput new DenseTensorfloat(new[] { 1, 3, 640, 640 }); var dummyInputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(_inputName, dummyInput) }; using (var dummyResult _session.Run(dummyInputs)) { } } public (float[], float[], int) RunInference(Tensorfloat inputTensor) { var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(_inputName, inputTensor) }; using (var results _session.Run(inputs)) { var boxes results.First(r r.Name proposals).AsTensorfloat(); var features results.First(r r.Name features).AsTensorfloat(); var scores results.First(r r.Name objectness).AsTensorfloat(); int numBoxes boxes.Dimensions[0]; float[] boxData boxes.ToArray(); float[] featureData features.ToArray(); float[] scoreData scores.ToArray(); return (boxData, featureData, numBoxes); } } }关于CPU线程数有个经验值如果目标机器是4核8线程IntraOpNumThreads设置成4通常比8更快。onnxruntime内部对算子的并行度控制并不总是完美线程数设得过高反而会引入大量上下文切换在Core i5级别的机器上尤其明显。如果你在服务器上部署还需要考虑机器上其他服务的CPU占用不要默认吃满所有核心。3.3 输出解析从裸浮点数据到干净的目标框proposals输出的是归一化后的候选框坐标需要乘回图像原始宽高才能得到实际像素坐标。features是每个候选框对应的视觉特征向量维度一般是768。objectness是候选框的置信分数通常模型可能输出上千个候选框但大部分objectness分数很低先用阈值筛掉一批能大幅减少后续分类和NMS的计算量。public class DetectionBox { public float X1, Y1, X2, Y2; public float Score; public int ClassId; } public ListDetectionBox ParseProposals(float[] boxes, float[] features, float[] scores, int numBoxes, int featureDim, float objectnessThreshold) { var candidates new List(int Index, float Score)(); for (int i 0; i numBoxes; i) { if (scores[i] objectnessThreshold) { candidates.Add((i, scores[i])); } } // 只保留objectness最高的N个比如300个 var topCandidates candidates .OrderByDescending(c c.Score) .Take(300) .Select(c c.Index) .ToList(); var proposals new Listfloat[](topCandidates.Count); foreach (int idx in topCandidates) { float x1 boxes[idx * 4 0]; float y1 boxes[idx * 4 1]; float x2 boxes[idx * 4 2]; float y2 boxes[idx * 4 3]; // 从归一化坐标转换到原始图像尺寸 x1 * originalWidth; y1 * originalHeight; x2 * originalWidth; y2 * originalHeight; // 提取对应视觉特征 float[] feature new float[featureDim]; Array.Copy(features, idx * featureDim, feature, 0, featureDim); proposals.Add(new float[] { x1, y1, x2, y2 }); } return proposals; }注意objectnessThreshold的取值。Detic的objectness分数和最终分类分数不是一个尺度前者通常在0~1之间但分布偏集中。我建议先跑几张测试图打印一下分数分布然后取一个合适的阈值通常0.2到0.4之间比较合理。阈值太低会导致候选框过多矩阵乘和NMS都变慢阈值太高可能把真正的目标框过滤掉。4. 21k类的后处理与性能瓶颈真正压榨推理性能4.1 类别置信度计算与TopK选择拿到视觉特征后下一步就是和文本嵌入矩阵做矩阵乘法得到每个候选框在21k个类别上的分数。这一步是整个C#部署方案里计算量最大的环节处理不好性能直接崩。先看最直观的写法// textEmbeddingMatrix: float[21000, 768] // features: float[300, 768]top 300候选框 // scores: float[300, 21000] float[,] scores new float[numBoxes, numClasses]; for (int i 0; i numBoxes; i) { for (int c 0; c numClasses; c) { float sum 0; for (int d 0; d featureDim; d) { sum features[i, d] * textEmbeddingMatrix[c, d]; } scores[i, c] sum; } }这段代码在300个框、21000个类别、768维特征下一共是300 * 21000 * 768 48.4亿次浮点乘加运算。C#纯循环跑这个量级就算开了Release模式也要几十秒完全不可用。优化思路有三个层次。第一层用Parallel.For并行化。48亿次运算在8核机器上摊到每核6亿次降到能接受的范围但依然需要好几秒。第二层把类别内层循环改成矩阵乘布局利用缓存局部性。如果文本嵌入矩阵按[768, 21000]预转置存储特征和矩阵的乘法可以写成Parallel.For(0, numBoxes, i { for (int c 0; c numClasses; c) { float sum 0; for (int d 0; d featureDim; d) { sum features[i, d] * textEmbeddingTransposed[d, c]; } scores[i, c] sum; } });这个写法能让特征向量连续访问内存友好一些但本质没有改变计算量。第三层也是最推荐的做法把矩阵乘放到GPU上。前面提到可以把文本嵌入矩阵做成一个简单的ONNX矩阵乘子模型在C#里加载后作为一个额外的InferenceSession调用。这样视觉特征先通过Detic模型得到再把特征张量喂给这个“分类器模型”矩阵乘的耗时从几秒降到几十毫秒。如果不想多维护一个ONNX模型也可以用ONNX Runtime的TensorAPI结合DirectML EP在C#里直接调用GPU算子。但封装起来比较复杂维护成本高。如果你们的部署环境没有GPU还有一招业务上很多时候不需要真的区分全部21k类而是只需要关注业务相关的几百个类别。比如电商场景只需要识别热门商品类目那文本嵌入矩阵可以把类别筛选成500行矩阵乘计算量直接降到原来的四十分之一。这个思路在工程落地上非常实用。4.2 类别感知NMS的实现与优化分类完成后每个候选框都有一个最高分类分数和对应类别。接下来的NMS流程和YOLO里的NMS一样但需要按类别分组进行——也就是说同类别之间做去重不同类别之间的重叠框保留。21k类别如果逐类做NMS确实很夸张但实际操作中大部分类别没有候选框可以先按类别分组再过滤掉空组。public ListDetectionBox ClassAwareNms(ListDetectionBox detections, float iouThreshold) { var groups detections .OrderByDescending(d d.Score) .GroupBy(d d.ClassId); var result new ListDetectionBox(); // 并行执行每个类别内部的NMS Parallel.ForEach(groups, group { var kept new ListDetectionBox(); var sorted group.OrderByDescending(d d.Score).ToList(); while (sorted.Count 0) { var best sorted[0]; kept.Add(best); sorted.RemoveAt(0); sorted.RemoveAll(d ComputeIou(best, d) iouThreshold); } lock (result) { result.AddRange(kept); } }); return result.OrderByDescending(d d.Score).ToList(); } private static float ComputeIou(DetectionBox a, DetectionBox b) { float x1 Math.Max(a.X1, b.X1); float y1 Math.Max(a.Y1, b.Y1); float x2 Math.Min(a.X2, b.X2); float y2 Math.Min(a.Y2, b.Y2); float interW Math.Max(0, x2 - x1); float interH Math.Max(0, y2 - y1); float inter interW * interH; float areaA (a.X2 - a.X1) * (a.Y2 - a.Y1); float areaB (b.X2 - b.X1) * (b.Y2 - b.Y1); float union areaA areaB - inter; return union 0 ? inter / union : 0; }这里有个容易被忽略的问题Parallel.ForEach里用到了lock (result)如果某个类别框特别多比如“人”这个类别在人群场景下可能产生大量候选框这个类别的NMS计算时间会拖慢整体速度。一个更精细的做法是让每个类别产生NMS结果后写入独立列表最后再合并但处理起来稍复杂。个人建议先把Parallel方案跑通只在实际性能不达标时再优化。4.3 实测数据与调优记录我在一台i5-12400 CPU GTX 1660 Super 16GB内存的Windows 11机器上做了几组实测部署的是ResNet50 backbone的Detic模型输入640x640配置目标数量平均耗时CPU推理 CPU矩阵乘300个候选框约3.2秒GPU推理 CPU矩阵乘300个候选框约1.8秒GPU推理 GPU矩阵乘300个候选框约0.6秒GPU推理 GPU矩阵乘100个候选框约0.35秒这个数据的启示很明显Detic模型的backbone推理在GPU上并不慢真正拖后腿的是21k类矩阵乘和NMS。所以如果你的业务可以接受降低候选框数量比如把objectness阈值从0.2提高到0.4或者按业务场景把类别从21k缩小到几百个整体延迟能大幅下降。另一个值得注意的点是内存占用。Detic ONNX模型本身大约400MBResNet50加上文本嵌入矩阵64MB再加上运行时缓冲区进程内存占用很容易超过1GB。如果部署在内存受限的生产环境需要关注这一点。可以考虑用半精度float16存储文本嵌入矩阵64MB直接减半到32MB精度损失在可接受范围内。踩坑记录与最后的经验分享整个部署过程中最让我头疼的不是onnxruntime本身而是Detic这种研究型模型的pipeline拆解。研究代码里很多操作是为了学术上的通用性而存在的真正落地到C#工程时必须做减法。我个人的建议是先别急着把21k类全跑通先用100个类别的文本嵌入矩阵验证完整链路把推理、后处理、画框都跑顺了再逐步扩展类别数。这样在排查问题时每个环节的复杂度都小一个量级。还有一个小技巧在开发阶段把每个中间步骤的结果导出到文件里检查。比如把输入的张量、proposals的坐标、特征的均值和标准差都dump出来和Python环境里的结果对比。这一步虽然繁琐但能快速定位到底是预处理问题、模型导出问题还是后处理问题。我当时排查颜色通道搞反的问题就是这个方法救的命。最后说一句关于onnxruntime版本的选择不要一味追求最新版本。onnxruntime的版本更新经常伴随算子实现和EP行为的变化你可能今天升级了小版本某一天线上模型的输出就和之前不一样了。确定一个验证过所有测试用例的版本然后锁死在项目里比什么都重要。这不是说不要升级而是升级要有充分的回归测试兜底。本文还有配套的精品资源点击获取