资讯动态

C# ONNX Runtime 集成 DBNet 实现条形码定位检测

发布时间:2026/10/27 9:48:21 来源:尧图企业网站定制
简介这是基于C#与ONNX运行时部署DBNet模型的条形码检测完整源码工程面向有C#基础、希望将深度学习模型落地到桌面应用的开发者。项目可在Visual Studio中直接打开编译包含解决方案文件、核心C#代码、ONNX模型文件以及OpenCvSharp、Microsoft.ML.OnnxRuntime等关键依赖库覆盖模型加载、推理、图像处理与结果展示的完整链路。压缩包共61个文件、大小约63.89MB其中11个cs文件提供主程序与窗体逻辑1个onnx文件为训练好的DBNet模型另有dll依赖、配置文件与测试图片结构清晰便于二次开发。已有419人学习下载适合作为入门C#调用ONNX模型、实践条形码检测任务的参考模板。通过阅读源码可了解DBNet在复杂背景下的定位方式、ONNX Runtime API调用技巧及OpenCvSharp配合使用的流程可直接迁移到其他目标检测场景。1. C# Onnx DBNet 检测条形码为什么文本检测网络能管条码定位拿到这份C# Onnx DBNet 检测条形码 源码的第一反应大多数人会愣一下DBNet 不是做场景文字检测的吗跟条形码有什么关系其实条形码检测的难点从来不在解码而在定位——把条码从复杂的商品包装、快递面单、金属蚀刻背景里框出来。DBNet 这类基于分割的检测网络对「细长、密集、有纹理」的目标天然敏感而一维条形码恰好就是这种结构所以用 DBNet 做条码定位反而比传统形态学方案稳得多。这份源码的价值在于它把 Python 生态里训练好的 DBNet 模型导出成 Onnx再在 C# 侧用 ONNX Runtime 完成推理和后处理整套链路不需要 Python 环境直接嵌入上位机或视觉检测程序。适合正在做工业视觉、物流分拣、仓储盘点需要离线批量检测条码位置但又不方便上 Python 服务的 C# 工程师。2. 接入 Onnx Runtime 与模型探针先看清输入输出再谈业务代码2.1 拿到模型先别急着跑用探针把输入输出打出来很多人在 C# 里集成 Onnx 模型翻车原因都一样没先确认模型实际的输入输出格式想当然地按 640×640、三通道去喂数据。DBNet 的 Onnx 模型在不同导出方式下输入输出差异很大。有的模型把后处理也打包进去了输入一张图直接出框有的只出概率图和阈值图后处理得自己写。所以第一步永远是写一个探针程序把InputMetadata和OutputMetadata打印出来。using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; static void ProbeModel(string modelPath) { using var session new InferenceSession(modelPath); Console.WriteLine( 输入节点 ); foreach (var kv in session.InputMetadata) { Console.WriteLine($名称: {kv.Key}, 类型: {kv.Value.ElementType}, 维度: {string.Join(,, kv.Value.Dimensions)}); } Console.WriteLine( 输出节点 ); foreach (var kv in session.OutputMetadata) { Console.WriteLine($名称: {kv.Key}, 类型: {kv.Value.ElementType}, 维度: {string.Join(,, kv.Value.Dimensions)}); } }这段代码的逻辑很直白InputMetadata是字典键是输入节点名值是节点元数据Dimensions是long[]直接string.Join打出来最直观。输出节点的个数和维度决定了你后面要不要写后处理——如果输出只有一个[1, 6, N]形状的张量说明模型内部已经做完了框解码你只需要解析坐标如果输出是两个[1, 2, H, W]形状的概率图、阈值图那恭喜你DBNet 的完整后处理链路你得自己从零写。这里有个容易看漏的点ElementType打出来的枚举值TensorElementType.Float对应的是单精度 floatInt64对应的是long。有些导出的模型坐标用 float 输出有些用 int64解析代码要按实际类型写不能统一转 float 一把梭。2.2 裸模型与成品模型后处理该自己写还是直接用探针打完你会遇到两种典型情况。第一种情况输出是[1, 2, H, W]的裸模型这是 DBNet 论文原生的输出形态第一个通道是概率图第二个通道是阈值图。这种情况下你必须自己实现「概率图二值化 → 轮廓提取 → 膨胀偏移 → NMS」这一整条后处理链路源码包里给的就是这条链路的 C# 实现。第二种情况输出直接是[1, N, 6]这样的形状N 是检测框数量6 代表[x1, y1, x2, y2, score, class]。这种模型在导出时集成了后处理你用起来最省事但代价是灵活性差——比如你想调 unclip 膨胀系数让框贴得更紧模型内部把这个参数写死了你改不了。我的建议是能用裸模型就尽量用裸模型因为 DBNet 后处理里的膨胀系数、阈值这些参数在不同场景下差异很大封装死在模型里弊大于利。需要特别注意的是有的模型输入是[1, 3, 640, 640]有的却是[1, 640, 640, 3]前者是 NCHW 布局后者是 NHWC。ONNX Runtime 对两种布局都支持但你喂数据的数组排布必须跟模型一致否则推理结果就是一团噪声。C# 侧绝大多数情况用 NCHW因为 OpenCvSharp 的Mat数据按 HWC 排布你在转float[]时要在内存里做一次通道重排。3. 预处理与后处理实现在 C# 里把 DBNet 从推理跑到出框3.1 图像预处理letterbox 和归一化别把条码压变形DBNet 训练时通常会把图像缩放到固定尺寸比如 640×640。如果直接Cv2.Resize强行拉伸条码的宽高比会变形检测框位置也会跟着偏。正确做法是 letterbox按比例缩放短的边用灰色填充到目标尺寸。static Mat ResizeAndPad(Mat src, int targetSize, out float scale, out int padX, out int padY) { int w src.Width, h src.Height; scale Math.Min((float)targetSize / w, (float)targetSize / h); int newW (int)Math.Round(w * scale); int newH (int)Math.Round(h * scale); using Mat resized new Mat(); Cv2.Resize(src, resized, new Size(newW, newH)); Mat padded new Mat(targetSize, targetSize, MatType.CV_8UC3, Scalar.All(114)); padX (targetSize - newW) / 2; padY (targetSize - newH) / 2; resized.CopyTo(padded[new Rect(padX, padY, newW, newH)]); return padded; }Scalar.All(114)对应的是 YOLO 系训练时常见的 padding 填充值DBNet 家族很多模型也是这个值。如果你下载的这个模型是用 PaddleOCR 微调的填充值可能不同最稳妥的办法是看模型训练时的配置文件或者直接试 114 和 0 两种值对比检测效果。padX和padY必须传出来后面坐标回推要用我见过太多人在这里漏掉导致检测框全部往左上角偏移。接下来把Mat转成模型需要的float[]。这里最容易出问题的是 BGR/RGB 通道顺序。OpenCvSharp 读图默认是 BGR而 PyTorch 训练的模型默认喂 RGB所以还得做一次通道转换。static float[] Normalize(Mat bgrImg) { using Mat rgb new Mat(); Cv2.CvtColor(bgrImg, rgb, ColorConversionCodes.BGR2RGB); rgb.ConvertTo(rgb, MatType.CV_32FC3, 1.0 / 255.0); float[] data new float[3 * rgb.Rows * rgb.Cols]; int idx 0; for (int c 0; c 3; c) { for (int i 0; i rgb.Rows; i) { for (int j 0; j rgb.Cols; j) { Vec3f pixel rgb.AtVec3f(i, j); data[idx] c 0 ? pixel.Item0 : (c 1 ? pixel.Item1 : pixel.Item2); } } } return data; }这段代码用三层循环按「先通道、再行、再列」的顺序把 HWC 数据重排成 NCHW。性能不算好但胜在清晰。要是追求速度可以用Cv2.Split拆出三个单通道Mat然后Marshal.Copy直接拷走三个平面的数据能省掉逐像素赋值的开销。如果你的模型导出时带了 mean/std 归一化参数记得在ConvertTo完之后做(pixel - mean) / std这一步很容易被当成可有可无实际影响很大。3.2 后处理主流程从概率图到候选框的 C# 实现推理拿到裸模型的输出后后处理的顺序是固定的先把概率图和阈值图拿出来用可微二值化公式算出二值图然后对二值图做轮廓提取再对每个轮廓做膨胀偏移——这一步 DBNet 原版用 Vatti clipping 算法C# 里可以用 Clipper 库也可以简化成对轮廓区域做形态学膨胀。static ListRotatedRect PostProcess(float[] probData, int h, int w, float binThresh 0.3f, float unclipRatio 1.5f) { // 概率图转 Mat Mat probMap new Mat(h, w, MatType.CV_32F); Marshal.Copy(probData, 0, probMap.Data, probData.Length); // 二值化 Mat bin new Mat(); Cv2.Threshold(probMap, bin, binThresh, 1.0, ThresholdTypes.Binary); // 阈值图参与二值化输出更精确的掩码 Mat bin8u new Mat(); bin.ConvertTo(bin8u, MatType.CV_8UC1, 255); // 轮廓提取 Point[][] contours; HierarchyIndex[] hierarchy; Cv2.FindContours(bin8u, out contours, out hierarchy, RetrievalModes.External, ContourApproximationModes.ApproxSimple); ListRotatedRect boxes new ListRotatedRect(); foreach (var contour in contours) { double area Cv2.ContourArea(contour); if (area 300) continue; // 过滤噪声小区域 Rect boundingRect Cv2.BoundingRect(contour); // 根据长宽比判断是不是条码形态常见条码宽高比大于 2 if (boundingRect.Width boundingRect.Height * 2) continue; RotatedRect rrect Cv2.MinAreaRect(contour); boxes.Add(rrect); } return boxes; }这里我用了Cv2.MinAreaRect而不是 DBNet 原版的四边形输出原因是条形码的目标形态是规整矩阵最小外接矩形比任意四边形更符合业务描述。binThresh是二值化阈值DBNet 论文里用 0.3但针对条码这种密集纹理目标我实测 0.4 以上能少很多误检。area 300这个过滤阈值要按你的输入分辨率调整输入 640×640 时 300 合适缩小到 320×320 时可能得降到 80。原版 DBNet 的 unclip 偏移用的是 Vatti clipping用多边形偏移让检测框贴紧目标边缘。C# 里最接近的库是Clipper2代码量不小。对条码这种目标我的习惯是直接省掉 unclip 步骤用MinAreaRect替代因为条码边界足够清晰语义分割的边界精度已经够用。如果你的模型检测出来的框偏大或者偏小再去考虑引入 Clipper。3.3 坐标回推与 NMS把方框贴回原图后处理得到的RotatedRect坐标是在 640×640 输入图上的必须回推到原始图像尺寸。这一步我见过太多人算错核心是要把 letterbox 的padX、padY和scale一起还原。static ListRotatedRect MapToOriginal(ListRotatedRect boxes, float scale, int padX, int padY) { ListRotatedRect result new ListRotatedRect(); foreach (var box in boxes) { Point2f center new Point2f( (box.Center.X - padX) / scale, (box.Center.Y - padY) / scale); Size2f size new Size2f( box.Size.Width / scale, box.Size.Height / scale); result.Add(new RotatedRect(center, size, box.Angle)); } return result; }回推公式很简单(检测坐标 - padding) / scale。但很多人写代码时只除了 scale忘了减 padX/padY结果就是检测框整体朝左上角偏移偏移量就是 padding 对应的像素数。只有当你输入图像本来就刚好是 1:1 比例时padX 和 padY 才为零其他情况必须减。多目标场景下NMS 是必须的。DBNet 在文字检测时一个单词可能会被分割成多个连通域产生多个重叠框。条码检测也存在这个问题尤其是一条条码中间有大片空白时模型可能把它拆成两段。static ListRotatedRect NMS(ListRotatedRect boxes, float iouThreshold 0.3f) { ListRotatedRect result new ListRotatedRect(); boxes.Sort((a, b) a.Size.Width * a.Size.Height.CompareTo(b.Size.Width * b.Size.Height)); // 按面积从大到小排序 ListRotatedRect candidates boxes.OrderByDescending(b b.Size.Width * b.Size.Height).ToList(); while (candidates.Count 0) { RotatedRect best candidates[0]; candidates.RemoveAt(0); result.Add(best); candidates.RemoveAll(b IOU(b, best) iouThreshold); } return result; }IOU 计算这里我略了具体实现OpenCvSharp 里可以先用Cv2.RotatedRectangleIntersection求交集面积再除以并集面积。注意 NMS 的 IOU 阈值条码检测设 0.3 比较合适设太大会把相邻独立的两条码合并成一个框设太小又压不住重叠框。4. 参数调优实践让检测框从「能看」到「贴边」4.1 核心参数表阈值、膨胀系数与最小面积后处理这套逻辑里真正需要你花时间调的核心参数就四个参数推荐范围作用调参场景binThresh0.3 ~ 0.5概率图二值化阈值决定哪些像素被认为是条码区域误检多就调高漏检多就调低unclipRatio1.2 ~ 2.0检测框向外扩展比例让框贴合目标边界框偏小就调大框覆盖到邻近条码就调小minArea100 ~ 500过滤过小的连通域图像上噪点多就调高条码本身小就调低NMS IOU0.2 ~ 0.4抑制重叠框同一目标多个框就调低相邻目标被合并就调高这四个参数里binThresh和minArea对结果影响最直观。unclipRatio在条码场景下的作用比文字检测小因为条码边缘就是连续的直线纹理分割出来的连通域边界已经很接近真实边界。我实际用下来条码场景unclipRatio设 1.2 就行设到 2.0 反而容易把相邻的两条码框在一起。minArea这个值的设定还要结合输入分辨率。同样是 640×640 输入如果实际条码在画面里占比很小比如商品包装上一角的小条码minArea设 500 会把真目标过滤掉。我的习惯是先用一个很低的阈值跑一遍把检测结果可视化出来看哪些是噪点哪些是真目标再逐步调高。4.2 调参手段用可视化中间图找问题调参最忌讳黑盒式乱试。我一般会在调试阶段把中间结果全部导出来二值图、轮廓图、最终框选图三个图并列放在一张画布上。static void DumpDebug(Mat original, Mat bin8u, ListRotatedRect boxes, string path) { // 二值图转 BGR 方便拼图 Mat binBgr new Mat(); Cv2.CvtColor(bin8u, binBgr, ColorConversionCodes.GRAY2BGR); // 在原图上画框 Mat boxed original.Clone(); foreach (var r in boxes) { Point2f[] pts r.Points(); for (int i 0; i 4; i) Cv2.Line(boxed, (Point)pts[i], (Point)pts[(i 1) % 4], Scalar.Red, 2); } // 水平拼接原图 | 二值图 | 框选图 Mat combined new Mat(); Cv2.HConcat(new Mat[] { original, binBgr, boxed }, combined); Cv2.ImWrite(path, combined); }调试时把这份拼图直接写到磁盘连续看十几张图参数该怎么调心里就有数了。比如二值图里条码区域呈现密集的白色条纹但轮廓提取时相邻条纹被连成一片这说明binThresh偏低比如框选图上框的边缘比条码实际边缘宽出一截说明unclipRatio偏大。这套可视化手段也可以用于验证代码改动有没有引入回归问题。我把测试集分成正常场景、弱光场景、条码密集场景三个目录每次改完参数全量跑一遍哪个目录的框选结果恶化一眼就能看出来。5. 避坑记录C# 集成 Onnx DBNet 的五个典型翻车现场5.1 平台目标没设对DLL 加载直接崩现象代码编译通过运行到new InferenceSession(modelPath)时报DllNotFoundException提示找不到onnxruntime.dll。原因Microsoft.ML.OnnxRuntime的 NuGet 包包含 win-x86、win-x64 等多个平台的原生 DLL如果你的项目平台目标是AnyCPU运行时可能加载到错误架构的 DLL。解决项目属性 → 生成 → 平台目标改成x64同时确保Solution Platform也是 x64。用过一次强一致这个配置后再没踩过。ONNX Runtime 对AnyCPU的处理没有 OpenCvSharp 那么宽容老老实实锁死 x64 是省时间的做法。5.2 letterbox 的 pad 没回推框全往左上偏现象检测框能框住条码但整体位置偏左上越靠近图像边缘偏移越明显。原因坐标回推只除了scale忘了减padX和padY。当输入图像是横向长图时letterbox 会在上下方向填充大量灰边这个灰边在回推时一定要先减掉。解决在MapToOriginal里严格执行(center - pad) / scale。我在代码里把padX、padY作为out参数强制传递从根上避免漏算。5.3 一个条码出两三个框连通域被切断现象一条完整的条码检测结果出现两个或三个互相重叠的方框每个框只覆盖条码的一部分。原因条码中间有白色间隔区域概率图上这部分像素值偏低二值化后被切成多个独立连通域每个连通域各自生成了候选框。解决NMS 的 IOU 阈值调低到 0.2把重叠框压掉。如果压掉后剩下的框虽然不重叠但边界紧贴属于同一条码被切成两段可以在后处理里加一步两个框横向间距小于一定像素且高度接近时直接合并成一个大框。5.4 相邻条码被框到一起膨胀系数过大现象画面里并排两个条码检测结果只有一个大框把两个条码全包进去了。原因unclipRatio设得偏大或者binThresh偏低导致两个条码边缘的像素连到了一起。解决unclipRatio从默认的 1.5 收到 1.2同时看二值图确认两个条码中间是否被噪声连接。如果是噪声连接把binThresh从 0.3 调到 0.4通常能断开。5.5 输入维度报错NCHW/NHWC 与 BGR/RGB 的双重坑现象推理时 ONNX Runtime 报错提示输入维度不匹配或者不报错但检测结果完全错误。原因模型导出时的输入布局与 C# 侧传的数组排布不一致。DBNet 的 PaddleOCR 版本导出的 Onnx 通常是 NCHW但也有少数版本是 NHWC。同时 OpenCvSharp 读图默认 BGR模型训练时用的是 RGB通道顺序搞反会导致检测置信度极低。解决用 2.1 节的探针把Dimensions打出来确认是[1,3,H,W]还是[1,H,W,3]然后按对应布局构造float[]。BGR/RGB 转换在Normalize里用Cv2.CvtColor做掉这一步永远不要省。6. 端到端验证与封装批量跑通测试集把检测类固化下来6.1 批量验证脚本一次跑完整目录输出可视化结果单张图调试通过不算完至少要跑完一个包含几十张图的测试目录才算数。我写了个极简的批量验证工具读入目录下所有图片检测后把框画在图上输出到指定文件夹。static void BatchValidate(string modelPath, string inputDir, string outputDir) { using var detector new DBNetDetector(modelPath); foreach (string file in Directory.GetFiles(inputDir, *.png)) { using Mat img Cv2.ImRead(file); if (img.Empty()) continue; var boxes detector.Detect(img); using Mat result img.Clone(); foreach (var r in boxes) { Point2f[] pts r.Points(); for (int i 0; i 4; i) Cv2.Line(result, (Point)pts[i], (Point)pts[(i 1) % 4], Scalar.Red, 2); } Cv2.ImWrite(Path.Combine(outputDir, Path.GetFileName(file)), result); Console.WriteLine(${Path.GetFileName(file)}: {boxes.Count} 个条码); } }跑完批量验证后我会按三个维度检查可视化结果漏检率条码没框出来、误检率非条码区域被框出来、贴合度框与条码边缘的距离。只要这三项没有明显恶化这套检测管线就可以固化了。6.2 封装成可复用的检测类推理会话复用是性能关键批量验证通过后把整个链路封装成DBNetDetector类。需要注意一个关键细节InferenceSession是有状态的内部缓存了模型推理的上下文每次检测都重新new InferenceSession会导致严重性能损耗。正确做法是把它做成类字段整个类生命周期只初始化一次Detect方法里只做预处理、推理、后处理三个步骤。public sealed class DBNetDetector : IDisposable { private readonly InferenceSession _session; private readonly int _inputSize; private readonly float _binThresh; private readonly float _minArea; public DBNetDetector(string modelPath, int inputSize 640, float binThresh 0.4f, float minArea 300f) { _session new InferenceSession(modelPath); _inputSize inputSize; _binThresh binThresh; _minArea minArea; } public ListRotatedRect Detect(Mat image) { // 预处理 - 推理 - 后处理完整流程 using Mat padded ResizeAndPad(image, _inputSize, out float scale, out int padX, out int padY); float[] inputData Normalize(padded); // 构造 DenseTensor执行推理解析输出后处理回推坐标 // 省略的推理代码和 3.2 节一致 return boxes; } public void Dispose() { _session.Dispose(); } }封装时我习惯把binThresh、minArea做成构造函数参数而不是硬编码这样同一个 DLL 在不同工位上部署时现场工程师改配置文件就能适配不用重新编译。从那以后我每接到一个条码检测的定制需求都强制走一遍「探针 → 单图调试 → 批量验证 → 封装固化」这套流程先把中间可视化跑通再谈业务接入。这套模板帮我把项目周期从一周压缩到两天希望也能帮到你少踩几个我踩过的坑。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑