简介:这是基于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对应的是单精度 float,Int64对应的是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.At<Vec3f>(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 List<RotatedRect> 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); List<RotatedRect> boxes = new List<RotatedRect>(); 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 List<RotatedRect> MapToOriginal(List<RotatedRect> boxes, float scale, int padX, int padY) { List<RotatedRect> result = new List<RotatedRect>(); 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 List<RotatedRect> NMS(List<RotatedRect> boxes, float iouThreshold = 0.3f) { List<RotatedRect> result = new List<RotatedRect>(); boxes.Sort((a, b) => a.Size.Width * a.Size.Height.CompareTo(b.Size.Width * b.Size.Height)); // 按面积从大到小排序 List<RotatedRect> 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 核心参数表:阈值、膨胀系数与最小面积
后处理这套逻辑里,真正需要你花时间调的核心参数就四个:
| 参数 | 推荐范围 | 作用 | 调参场景 |
|---|---|---|---|
| binThresh | 0.3 ~ 0.5 | 概率图二值化阈值,决定哪些像素被认为是条码区域 | 误检多就调高,漏检多就调低 |
| unclipRatio | 1.2 ~ 2.0 | 检测框向外扩展比例,让框贴合目标边界 | 框偏小就调大,框覆盖到邻近条码就调小 |
| minArea | 100 ~ 500 | 过滤过小的连通域 | 图像上噪点多就调高,条码本身小就调低 |
| NMS IOU | 0.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, List<RotatedRect> 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 List<RotatedRect> 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 在不同工位上部署时,现场工程师改配置文件就能适配,不用重新编译。从那以后我每接到一个条码检测的定制需求,都强制走一遍「探针 → 单图调试 → 批量验证 → 封装固化」这套流程,先把中间可视化跑通再谈业务接入。这套模板帮我把项目周期从一周压缩到两天,希望也能帮到你少踩几个我踩过的坑。
本文还有配套的精品资源,点击获取