简介:资源为C# Onnx实现的轻量级密集卷积神经网络LDC边缘检测源码项目,面向需要在.NET环境下部署深度学习视觉算法的开发者,解决资源受限设备上的实时边缘检测问题。项目包含完整的Visual Studio解决方案与Demo程序,从依赖配置、模型加载、输入预处理到推理与后处理均有清晰代码可循,并附带LDC_640x360、LDC_1920x1080、LDC_3840x2160三种分辨率的ONNX模型,便于在不同算力场景下替换测试。压缩包共68个文件,以cs源码、dll运行库、onnx模型、jpg测试图片及配置类文件为主,整体大小29.09MB;其中cs工程文件与sln解决方案可直接编译,OpenCvSharp及Microsoft.ML.OnnxRuntime等dll为运行环境提供支持,部分cache、resources等为IDE辅助内容。资源已有168人学习浏览,适合熟悉C#但对ONNX模型集成流程不熟的开发者参考,能帮助快速理解边缘检测模型的调用逻辑与实际部署细节。
1. 边缘检测选型里的冷门答案:C# 调 Onnx 把 LDC 跑起来
做工业上位机遇到“边缘检测”需求,大部分人第一反应是 Canny 或 Sobel。可一旦背景里带纹理、光照不均匀,或者要抓的是“颜色变化不大的边缘”,传统算子立刻变成调参无底洞,同一套参数换条产线就得重来。深度学习边缘检测模型确实效果好,但动辄几十上百 MB 的 ONNX 文件,放在没有 GPU 的工控机上做实时推理,本身就是一道坎。LDC(Lightweight Dense Convolution)是个轻量级密集卷积网络,模型小、CPU 推理快,配合 C# 调 OnnxRuntime,正好卡在“精度够用 + 部署干净”这个位置。这篇文章不讲论文公式,只讲怎么把它从训练产物转成能用的 ONNX,再在上位机里跑起来,以及我踩过的那些坑。适合正在做视觉定位、尺寸测量、表面缺陷检测的上位机工程师。
2. LDC 这类轻量级密集卷积网络到底轻在哪
2.1 密集连接与“轻量级”这笔账怎么算
LDC 全称叫 Learning Dense Convolution,结构上是从 DenseNet 那套密集连接思想做了裁剪和改造。普通卷积网络每一层只看上一层的输出,而密集连接让每一层都能直接看到前面所有层的特征图,在通道维度上拼起来再卷积。对边缘检测来说这个特性极其重要:边缘本质是图像里的局部梯度变化,属于底层特征,网络越深越容易被抽象语义“洗掉”。密集连接给浅层信息开了一条短路径,让细线、弱边缘、低对比度边界能一路传到输出层,这正是它在轻量参数下还能保持精度的原因。
“轻量级”体现在两个地方。第一是参数量的控制,LDC 里每个 dense block 的通道增长率很小,没有像 DenseNet 那样把整个网络堆成几百层,整体算下来参数规模比同类的 PidiNet、HED 小一个数量级。我见过一些开源实现,权重文件也就在几 MB 上下,转成 ONNX 后放进上位机安装包毫无压力。第二是推理开销,一张 512x512 的输入图在普通 i5 工控机上用 ONNX Runtime 跑一次,大概在几十毫秒量级,不需要独显。有人会说 FPGA 做边缘检测更快,但 FPGA 方案迭代周期长、调试成本高,对大多数产线项目来说,CPU 上能实时跑完的 LDC 性价比明显更高。
需要提醒的是,“参数少”不等于“在什么场景下都够用”。LDC 拿手的是自然图像和工业图像里的通用边缘提取,如果你要检测的是非常微弱的亚表面缺陷,或者强噪声环境下的边缘,它一样会吃力。这类模型适合的是“把边缘检测做成一个稳定前置模块”,而不是“一个模型包打所有检测场景”。
2.2 PyTorch 模型转 ONNX:三步导出与两个边界参数
从源码仓库拿到 LDC 训练好的 .pth 权重后,第一步是转成 ONNX。常见做法是写一个 export 脚本,把模型加载出来,构造一个假输入,然后调用 torch.onnx.export。下面这段是标准的导出流程,你需要按自己训练时的输入尺寸把 img_size 改掉。
import torch from model import LDC # 按你源码包里的模型类名导入 model = LDC(pretrained=False) checkpoint = torch.load("ldc.pth", map_location="cpu") model.load_state_dict(checkpoint["state_dict"] if "state_dict" in checkpoint else checkpoint) model.eval() img_size = (512, 512) # 训练时用的输入尺寸,导出后一般固定 dummy_input = torch.randn(1, 3, *img_size) # 有的实现是 1 通道灰度图,看源码 torch.onnx.export( model, dummy_input, "ldc.onnx", input_names=["input"], output_names=["output"], opset_version=12, dynamic_axes=None # 工业上位机固定尺寸更稳 ) print("export done")这里最关键的是 eval 模式和 opset 版本。PyTorch 模型里有 BatchNorm 或 Dropout 时,不切 eval 直接导出,会把训练阶段的随机行为固化进 ONNX,推理结果时好时坏,属于经典翻车点。opset 版本建议至少 12,太低的话某些算子导出不了,太高则可能要求比较新的 OnnxRuntime 版本,C# 侧升级也麻烦。
第二个边界参数是 dynamic_axes。我一般不建议在工业项目里开动态输入。LDC 这类分割模型,输入尺寸一变,输出特征图的尺寸也会变,上位机里做缓存、做内存申请都很别扭。固定 512x512 或 640x640,C# 侧预处理只要无脑 Resize 到固定尺寸,少踩一堆坑。
2.3 导出后先在 Python 侧验证 ONNX 再进 C#
转出来的 ONNX 是个黑匣子,直接丢给 C# 调试,出了问题很难分清是导出、预处理还是推理环节的锅。我的习惯是先写十几行 Python,用 onnxruntime 跑一遍推理,确认输出形状和数值分布都正常,再往 C# 搬。
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("ldc.onnx", providers=["CPUExecutionProvider"]) for inp in sess.get_inputs(): print("input:", inp.name, inp.shape, inp.type) for out in sess.get_outputs(): print("output:", out.name, out.shape, out.type) x = np.random.randn(1, 3, 512, 512).astype(np.float32) res = sess.run(None, {"input": x}) print("output count:", len(res)) for i, r in enumerate(res): print(f"output[{i}] shape:", r.shape, "min:", r.min(), "max:", r.max())打印出来的输入输出名称,后面 C# 里要一一对应。输出数量也很重要,LDC 这类多尺度监督的模型经常不止一个输出,有的实现会把中间层也暴露出来。如果你只取了第 0 个输出,却发现边缘很粗或者缺失,很可能就是没找对输出分支。Python 侧验证通过后,C# 这边就只需要关心内存布局和类型转换。
3. 用 C# 跑通 LDC 的最小 ONNX 推理工程
3.1 新建工程与依赖:dotnet 命令把依赖装齐
C# 侧跑 ONNX 的标准方案是 Microsoft.ML.OnnxRuntime,图像处理用 OpenCvSharp。这两个库在 NuGet 上直接能拉,无需自己编译原生库。新建一个 .NET 6 或更高版本的控制台工程,然后装包:
dotnet new console -o LdcDemo cd LdcDemo dotnet add package Microsoft.ML.OnnxRuntime dotnet add package OpenCvSharp4 dotnet add package OpenCvSharp4.runtime.win装包完成后,确认 bin 目录下有 onnxruntime 的原生 dll。OpenCvSharp4.runtime.win 这个包会把 OpenCV 的原生库带进来,没有它代码能编过但运行时会报找不到 OpenCvSharpExtern。这一步卡住的人不少,敲完 add package 后先编译一下,跑个空 Main 看能否正常加载。
3.2 加载模型:先看清输入输出再动手写推理
模型加载本身只有一行new InferenceSession(...),但紧接着要做的不是直接 Run,而是把输入输出的元信息打出来。ONNX 模型文件的输入名、张量形状、数据类型,决定了你后面怎么构造输入缓冲。下面是加载并打印元信息的代码:
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; var session = new InferenceSession("ldc.onnx"); // 打印输入信息 foreach (var kv in session.InputMetadata) { Console.WriteLine($"Input: {kv.Key}, Shape: {string.Join(",", kv.Value.Dimensions)}, Type: {kv.Value.ElementType}"); } // 打印输出信息 foreach (var name in session.OutputNames) { Console.WriteLine($"Output: {name}"); }注意,不同版本的 OnnxRuntime 里,InputMetadata 这个属性的行为略有差异,旧版可能直接返回空的字典。如果你用的版本拿不到形状,就用一维数组展开后自己算总长度再加注释,别在这里死磕。打印结果如果显示输入是 int64 而不是 float,说明导出时的输入类型没对,后面构造 DenseTensor 时要改类型,否则会报类型不匹配。
这里顺带提一句,模型加载后的第一次 Run 通常很慢。 OnnxRuntime 在做线程池初始化、内存池分配、算子内核选择,耗时可能差出十倍以上。So 标准做法是加载完 session 后立刻拿一张全零图预热一次,再用真正的图像推理,后面第 4 章还会展开讲。
3.3 预处理:Resize、归一化、HWC 转 CHW 一步都不能错
C# 的 Mat 默认是 HWC 布局,也就是高度、宽度、通道这样的排列,而 ONNX 模型几乎都是 NCHW。这一步最容易出问题,但不是玄学,控制好每一步的数值就能复现。下面这段是标准的预处理流程:
using OpenCvSharp; var mat = Cv2.ImRead("test.png", ImreadModes.Color); Cv2.Resize(mat, mat, new Size(512, 512)); // 固定到模型输入尺寸 // BGR -> RGB,OpenCV 默认 BGR,PyTorch 训练一般用 RGB Cv2.CvtColor(mat, mat, ColorConversionCodes.BGR2RGB); // 归一化方式要和训练时一致,这里按 [0,1] 举例 var input = new DenseTensor<float>(new[] { 1, 3, 512, 512 }); mat.GetArray(out byte[] pixels); // pixels 是 HWC 布局的 RGB 数据 int h = 512, w = 512; for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { int idx = (y * w + x) * 3; input[0, 0, y, x] = pixels[idx] / 255f; // R input[0, 1, y, x] = pixels[idx + 1] / 255f; // G input[0, 2, y, x] = pixels[idx + 2] / 255f; // B } }这段代码里最需要跟训练脚本对齐的是归一化方式。有的 LDC 实现用 ImageNet 的 mean 和 std,有的就是简单除 255,还有的在训练代码里直接减 0.5 再乘 2。你从源码包里拿到的训练脚本里怎么写的,C# 侧就怎么写,差一点都不行。我见过有人在这里偷懒用pixels[idx] / 255f,但训练脚本里用的是(pixels[idx] - 128) / 128,推理结果出来边缘全偏黑,查了一下午才发现是这行的问题。
GetArray 这个 API 只适用于单通道或者三通道的连续内存 Mat。如果 Mat 的通道数不确定,先用mat.IsContinuous()判断一下,不连续就 Clone 一下保证连续。很多坑都是因为图像 Mat 带上了 ROI 区域,内存不连续导致 GetArray 读出来的数据错位。
3.4 推理与后处理:从输出张量到能看的边缘图
输入张量构造好后,调用 Run 拿到输出,然后要处理的是一堆浮点特征图。LDC 输出的一般是 sigmoid 之前的 logits,范围可能在 -3 到 3 之间,需要自己过一遍 sigmoid 再转成 0-255 灰度图。下面是完整的推理与后处理代码:
using Microsoft.ML.OnnxRuntime.Tensors; var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("input", input) }; using (var results = session.Run(inputs)) { var output = results.First().AsTensor<float>(); int channels = output.Dimensions[1]; int outH = output.Dimensions[2]; int outW = output.Dimensions[3]; // 常见做法:取最后一个通道,那个通常是最精细的边缘输出 var edgeMap = new Mat(outH, outW, MatType.CV_32FC1); for (int y = 0; y < outH; y++) { for (int x = 0; x < outW; x++) { float val = output[0, channels - 1, y, x]; edgeMap.At<float>(y, x) = 1f / (1f + (float)Math.Exp(-val)); // sigmoid } } // 缩放到 0-255 并转 8 位图 Cv2.Normalize(edgeMap, edgeMap, 0, 255, NormTypes.MinMax); var edge8u = new Mat(); edgeMap.ConvertTo(edge8u, MatType.CV_8UC1); Cv2.Resize(edge8u, edge8u, new Size(512, 512)); // 如果输出尺寸和原图不一致就 Resize Cv2.ImWrite("edge.png", edge8u); }注意,这里取最后一个通道是我的默认经验,具体的 LDC 实现输出通道含义可能要按源码包的 README 来确认。有的实现把最后一层作为主输出,有的把多个尺度融合后再输出。你拿到模型后先打印输出形状,再可视化每个通道看看哪个是主边缘图,心里有数了再写死通道索引。这步多花五分钟,后面省两小时。
4. 避坑:从“能跑通”到“能上线”的 5 个常见问题
4.1 第一次推理慢得离谱,后面速度恢复正常
现象:程序启动后第一次 Run 掉了几百毫秒甚至一秒,第二次开始才降到几十毫秒。在产线上如果每个检测周期都重启进程,这个延迟会直接拖垮节拍。
原因:OnnxRuntime 加载模型后第一次推理要做线程池初始化、内存池预分配,还会对算子做内核选择。如果用了 CPU 优化指令集,第一次调用还要做指令集探测。这些一次性开销被误算进了首次检测耗时。
解决:加载模型后立刻用一张全灰或者全零的图跑一遍推理,把初始化开销消化掉。预热后正式推理的耗时才是一个稳定值。代码很简单,就是拿Mat填充灰图后走一遍完整的预处理和推理流程。我在工程里一般把预热封装进模型加载阶段,启动画面多转一圈,产线首件检测就不会超时。
4.2 推理结果全黑或全白,先检查预处理而不是模型
现象:边缘图输出要么全是 0,要么全是 255,中间没有任何层次,看起来像模型没起作用。
原因:这个问题九成出在预处理没对齐训练时的数分布。最常见的是输入没归一化就送进网络,或者把 uint8 像素值直接当成 float 输入,导致数值范围差了几十倍。PyTorch 训练时图像数据是 0~1 浮点,你这里喂 0~255 的整型,模型输出自然全饱和。
解决:拿一张已知结果的标准图,用 Python 侧跑出参考输出,C# 侧再跑一遍,把两边输出做差。如果差的绝对值平均值大于 0.01,问题基本就在预处理。把 C# 预处理每一步的中间结果打印出来,跟 Python 侧 Numpy 的结果逐项对比,很快能找到是哪一步数值分叉。别猜,直接对比数据,这是最快路径。
4.3 固定阈值导致深色背景目标缺边
现象:浅色目标放在深色背景上,目标的上边缘能检出来,下边缘却很弱甚至消失。换成固定阈值 0.5 后,弱边直接被过滤掉。
原因:LDC 输出的边缘热图在梯度缓变一侧会拉得很宽,而梯度陡变一侧更集中。固定阈值对所有位置一视同仁,边缘响应弱的区域自然就被削掉了。这跟“颜色变化不大的边缘”检测是同一个问题,低对比度区域本来输出值就偏低。
解决:不要用固定阈值,改用动态阈值。我的做法是先对 sigmoid 后的热图做一次 min-max 归一化,再用 Otsu 求阈值。Otsu 按灰度分布自动算分割点,比手动拍一个 0.5 靠谱得多。如果边缘还是断续,先对热图做 3x3 高斯模糊再阈值化,让相邻像素的响应互相补偿,断线能连上一部分。
4.4 多输出模型只取了一个分支,边缘粗细对不上
现象:检测出来的边缘线条特别粗,或者出现“双影”——一条边缘旁边跟着一条平行的虚影。用 OpenCV 提取轮廓时,一个目标边出了两条轮廓。
原因:LDC 这类模型训练时带多尺度监督,导出后的 ONNX 可能包含多个输出节点。每个输出对应不同尺度的边缘响应。有的源码实现最后一个输出是融合结果,有的则只输出单一尺度。你只取第一个输出时,可能拿到的是深层粗尺度特征,边缘定位精度差;取到中间输出时,边缘宽窄和真实目标对不上。
解决:先打印所有输出的形状,把每个输出都存成图看一眼。确认哪个通道是主输出后,在 C# 代码里显式指定输出名,不要用results.First()这种随手写法。如果源码里的多尺度融合是在 PyTorch 层完成的导出后没有保留,那就在 C# 侧自己做一次加权融合,取两个尺度输出的平均,通常能同时保住细边和弱边。
4.5 尺寸测量场景边缘带毛刺,亚像素定位不稳
现象:用检测到的边缘轮廓做工件尺寸测量,同一张图反复跑,测出来的数值忽大忽小,偏差超过公差范围。
原因:LDC 输出的是像素级热图,阈值化后边缘带宽度可能有好几个像素。直接用轮廓外接矩或者质心定位,都会受边缘带宽度波动的影响。这个问题在测量场景尤其明显,因为测量要求的是边界位置的稳定复现,而不是“看起来边缘正确”。
解决:对二值边缘做形态学细化,用Cv2.Threshold得到二值图后,先Cv2.MorphologyEx做一次开运算去掉毛刺,再用Cv2.FindContours提取轮廓。然后沿轮廓法线方向做灰度重心计算,把像素级定位提升到亚像素级。代码量不大,但测量稳定性能提升一个台阶。
5. 把 LDC 从“能出图”磨到“能上场”的三个习惯
5.1 固定一张回验图,每次改代码都跑一遍对拍
不管改预处理、换 OnnxRuntime 版本还是重新导出 ONNX,我手里永远有一张标准测试图,以及这张图在 Python 侧跑出的参考输出。改完代码就重新跑一遍,对比两边输出的差异。灰度图的边缘检测结果很难用肉眼看出“对不对”,必须靠数值对比说话。对比方式不用复杂,把 C# 输出转成二进制文件,在 Python 里用 numpy 读取后算平均绝对误差,误差超阈值就停下来查。这个习惯帮我避免了好几次“改了代码编译通过就以为没事”的误判。
5.2 动态阈值要写成可配置参数
直接把 Otsu 硬编码进项目里的做法不太长久。换个光照环境,或者换条产线,最佳阈值可能就偏移了。我一般会把阈值模式和参数写进配置项,上线初期跑“固定阈值对比 + Otsu 对比”的并行模式,把两张边缘图都存下来,积累几天数据后再决定用哪种策略。不要一上来就追求全自动调参,先把控制权握在手里,出了问题能人工介入,这才是工程上稳的做法。
5.3 值得投入的优化只有 INT8 量化
CPU 推理想再提速,最有效的还是 INT8 量化。OnnxRuntime 的量化工具是离线静态量化,流程是准备一小批校准图,跑一遍统计数据范围,然后把模型量化成 INT8。
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "ldc.onnx", "ldc_int8.onnx", weight_type=QuantType.QInt8 )量化后模型体积缩小,CPU 推理速度通常能提升一半左右。但代价是边缘检测的召回率可能略降,特别是细边缘。我在项目里只在 CPU 负载吃紧时才启用量化,而且量化后必须拿实测场景的图重新验收一遍,确认没有样本被压掉。这块建议放在项目后期做,前面先保证精度正确。
最后说个真实教训。我第一次把 LDC 接进上位机时,后处理里写死了 0.5 的阈值,现场当天看着没问题,第二天换了批工件,背景颜色从深灰变成浅灰,一片边缘直接消失。从那以后我所有的后处理代码里,默认禁用固定阈值,宁愿多写几行动态阈值,也不给产线留这种随时会爆的定时炸弹。希望这些踩过的坑能帮你少走一段弯路。
本文还有配套的精品资源,点击获取