简介:面向C#开发者与深度学习部署者的边缘检测工程示例,以轻量级密集卷积神经网络LDC为核心,结合ONNX Runtime实现了在.NET环境中的推理流程。该网络在计算效率与检测精度之间取得平衡,具有轻量级结构和高精度特点,能快速完成边缘检测,特别适合CPU推理或资源受限的嵌入式、边缘设备,可用于实时场景。压缩包共68个文件,大小约29MB;除完整C#源码工程(解决方案、项目文件、窗体及公共类)外,还包含360p、1080p、2160p三种输入分辨率的预训练ONNX模型,以及OpenCvSharp、ONNX Runtime依赖库、测试图片和配置文件,解压后可用Visual Studio直接加载调试。资源目前已有168人学习,适合作为快速上手的参考基线。项目完整演示了从模型加载、图像预处理、推理到边缘概率图后处理与可视化的闭环流程,公共模块封装了图像缩放、归一化、结果绘制等逻辑,便于移植到自有图像处理管线;通过切换不同分辨率模型,用户还能直观评估输入尺寸对边缘细节与检测耗时的影响,是理解C#调用ONNX模型、落地边缘检测任务的不错案例。
1. C# Onnx 跑轻量级密集卷积网络 LDC:边缘检测的落地第一步
如果你在做一个 C# 上位机项目,需要在工业相机、普通 USB 摄像头甚至一张静态图上实时提取边缘,又不想把 OpenCV 的 Canny 阈值反复调到怀疑人生,那直接上一套训练好的神经网络边缘检测模型是更省事的路。C# Onnx 推理组合的好处是:模型是 ONNX 格式,推理走 Onnx Runtime,不绑死任何深度学习框架;LDC 这种轻量级密集卷积网络,模型文件通常只有几 MB,在 CPU 上跑一次前向也就是十几毫秒到几十毫秒的量级,比 PiDiNet 这类稍重的模型更适合工控机部署。这篇笔记我会按自己的落地习惯,从模型导出讲到 C# 推理代码、参数调优和踩坑记录,给想把这个方案接进自己项目的人一条能直接照着走的路。
2. 理解 LDC:轻量级密集卷积网络凭什么做边缘检测
2.1 Dense Connection 的价值:低参数量下的梯度流动
边缘检测不是简单算梯度。像 Canny 这类经典算子,对光照、噪声、纹理太敏感,换个场景阈值就要重调。神经网络的思路是把边缘检测当成一个密集预测任务:输入一张图,输出一张同尺寸的概率图,每个像素代表它属于边缘的可能性。要做到这个效果,网络必须同时保留浅层的细粒度纹理和深层的语义轮廓,而 LDC 这类轻量级密集卷积网络的核心就是靠 Dense Connection 把这两者串起来。
Dense Connection 的意思是,在一个 Dense Block 内部,每一层卷积的输入都拼接上前面所有层的输出。这样做的好处首先是特征复用,网络不需要在每一层重新学一遍低级特征,参数总量明显下降。其次是梯度流动路径短,反向传播时梯度能直接从最后一层流向第一层,训练收敛更稳。对于边缘检测这种逐像素预测任务,浅层特征和深层语义同样重要,密集连接天然适合做多尺度特征融合。
我最初接触 LDC 时把它和 HED、PiDiNet 放在一起对比过。HED 是典型的早融合方案,通过多个侧输出层做监督;PiDiNet 则是用像素差分卷积来显式建模边缘方向。LDC 选择了一条更简单的路:把 Dense Block 堆起来,最后接一个 1x1 卷积把特征压成单通道边缘概率图。它的优势不在刷新精度榜单,而在模型体积和推理速度的平衡。实测下来,同样输入尺寸下 LDC 的参数量往往只有 VGG16 主干网络的十分之一左右,非常适合 CPU 部署。
2.2 LDC 与 Prewitt、Canny 的本质区别:学习型边缘 vs 手工算子
很多人会用 Prewitt 算子来验证边缘检测原理,两个 3x3 的卷积核分别响应横向和纵向梯度。Canny 在此基础上加了高斯平滑、非极大值抑制和双阈值滞后连接。这类手工算子的共性是:梯度大的地方就认为是边缘,但没办法区分“有意义的轮廓”和“纹理噪声”。比如一张满是树叶的照片,Canny 会输出密密麻麻的纹理边缘,而神经网络边缘检测模型经过数据集训练后,能学会把树叶纹理压下去、把树干轮廓保留下来。
LDC 的输入端是原始 RGB 图像,输出端是 0 到 1 的连续概率图。它不需要手动设定卷积核,所有边缘特征都是从训练数据里学出来的。这里有个容易被忽略的细节:模型的训练数据集决定了它的“审美”。如果训练数据以室内物体为主,那么它在室外场景上的表现就可能打折。所以你在 C# 里部署 LDC 时,最好先确认模型的训练数据和你业务场景是否接近,否则后处理调参救不回来。
从部署角度看,LDC 和 Prewitt 还有一个本质区别:Prewitt 是固定权重,任何语言几行代码就能实现;而 LDC 的权重是训练出来的,必须以神经网络格式分发。ONNX 在这里起到的作用就是模型打包标准。你可以把 PyTorch、TensorFlow 训练好的 LDC 结构导出成 ONNX 文件,C# 项目里通过 Onnx Runtime 加载。这也就是标题里“C# Onnx”这个组合的由来。
2.3 拿到模型第一步:从 PyTorch 导出 ONNX 的关键参数
大多数开源 LDC 实现是用 PyTorch 训练的,你要把它用到 C# 里,第一步是导出 ONNX。这一步看着简单,实际上很容易导出后跑不通。下面是一段我常用的导出脚本,关键点都写在注释里。
import torch model = LDC(num_classes=1).eval() # 假设模型只输出单通道边缘概率图 state_dict = torch.load("ldc_edge.pth", map_location="cpu") model.load_state_dict(state_dict) dummy_input = torch.randn(1, 3, 512, 512) # 固定输入尺寸:1张图、3通道、512x512 torch.onnx.export( model, dummy_input, "ldc_edge.onnx", input_names=["input"], output_names=["output"], dynamic_axes={ "input": {0: "batch"}, "output": {0: "batch"} }, opset_version=12, do_constant_folding=True ) print("export done")这段代码里,dummy_input的形状必须和模型前向期望的输入一致。很多模型在 forward 里写死了宽高,你换成 256x256 反而报错,所以导出前先用这个尺寸跑一次model(dummy_input)确认能通过。dynamic_axes只把 batch 维设为动态,是因为 Onnx Runtime 对固定尺寸的优化更激进,如果你希望 C# 侧可以传任意尺寸,可以把宽高也加进去,但会增加首次推理的耗时。
导出完成后,先用onnxruntime在 Python 侧验证一遍,确认输出形状是[1, 1, 512, 512]而不是[1, 512, 512, 1]。这个通道顺序问题直接关系到 C# 后处理代码怎么写。Python 侧验证通过后,再进入 C# 工程。
3. 在 C# 侧搭建 Onnx Runtime 推理工程
3.1 工程结构与 NuGet 包
C# 调用 ONNX 最直接的方式是用Microsoft.ML.OnnxRuntime这个 NuGet 包,它封装了原生推理引擎,不需要你手动 P/Invoke。我一般的解决方案结构是这样:
EdgeDetector/ ├── EdgeDetector.csproj └── EdgeDetector/ ├── LdcInference.cs ├── ImagePreprocessor.cs ├── ImagePostprocessor.cs ├── Models/ │ └── ldc_edge.onnx └── Program.cs创建项目时,目标框架我建议选 .NET 6 或 .NET 8,不要选 .NET Framework 除非你的项目被迫留在老环境。Onnx Runtime 官方 NuGet 包对 .NET Core/.NET 5+ 的支持更积极,版本更新也快。
EdgeDetector.csproj里需要加这几项:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0</TargetFramework> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <Platforms>x64</Platforms> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.16.3" /> <PackageReference Include="System.Drawing.Common" Version="8.0.0" /> </ItemGroup> <ItemGroup> <None Update="Models\ldc_edge.onnx" CopyToOutputDirectory="PreserveNewest" /> </ItemGroup> </Project>这里有个选择:System.Drawing.Common是 Windows 上处理 Bitmap 的老牌库,简单但性能一般。如果要做实时视频流,我会改用 OpenCvSharp4,但为了让新手能少引入一个依赖,下面代码我仍用System.Drawing.Common演示,你后续按需替换就行。
3.2 推理器核心代码:Session 创建、输入输出检查
推理器对外只暴露两个方法:LoadModel和Predict。加载模型时,Onnx Runtime 会解析整个计算图并把算子调度到对应执行逻辑上,所以 Session 对象建议复用,不要每张图都重新 new 一个。
using System; using System.Collections.Generic; using System.Drawing; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class LdcInference : IDisposable { private InferenceSession _session; private int _inputHeight; private int _inputWidth; public LdcInference(string modelPath, int inputHeight = 512, int inputWidth = 512) { _inputHeight = inputHeight; _inputWidth = inputWidth; _session = new InferenceSession(modelPath); Console.WriteLine("模型输入节点:"); foreach (var input in _session.InputMetadata) { Console.WriteLine($" {input.Key}: {string.Join(",", input.Value.Dimensions)}"); } Console.WriteLine("模型输出节点:"); foreach (var output in _session.OutputMetadata) { Console.WriteLine($" {output.Key}: {string.Join(",", output.Value.Dimensions)}"); } } public float[] Predict(Bitmap bitmap) { // 预处理、张量转换、推理、后处理由下面逐步完善 return null; } public void Dispose() { _session?.Dispose(); } }代码里加载完模型立刻打印输入输出节点信息,这是排查问题的第一道关卡。很多模型导出的输入名不是input,而是images、x之类;输出名也可能是output、logits、edge。你要在构造DenseTensor时用_session.InputMetadata.Keys里的实际名字,而不是凭感觉猜。Dimensions 数组里也会明确写出模型期望的维度,如果它显示[1,3,512,512],而你传入的是[1,512,512,3],后处理出来的边缘图大概率是坏的。
3.3 图像预处理:Bitmap 转张量,HWC 到 CHW,归一化
神经网络输入要求连续内存的张量,C# 里最常见的做法是把 Bitmap 的像素数据复制到 float 数组里,再装进DenseTensor<float>。顺序上有一点特别容易错:Bitmap 的 Scan0 内存布局一般是 BGR 或 RGB 按行排列,也就是 HWC,而 PyTorch 模型要求的是 CHW。所以你要手动转换维度和通道顺序。
public static float[] Preprocess(Bitmap src, int targetHeight, int targetWidth) { using var resized = new Bitmap(src, new Size(targetWidth, targetHeight)); var rect = new Rectangle(0, 0, targetWidth, targetHeight); var bmpData = resized.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); int stride = bmpData.Stride; byte[] raw = new byte[stride * targetHeight]; System.Runtime.InteropServices.Marshal.Copy(bmpData.Scan0, raw, 0, raw.Length); resized.UnlockBits(bmpData); float[] result = new float[3 * targetHeight * targetWidth]; int index = 0; for (int c = 0; c < 3; c++) { for (int h = 0; h < targetHeight; h++) { for (int w = 0; w < targetWidth; w++) { int offset = h * stride + w * 3; byte b = raw[offset]; byte g = raw[offset + 1]; byte r = raw[offset + 2]; float value = (c == 0) ? r : (c == 1) ? g : b; result[index++] = value / 255.0f; } } } return result; }这段代码先把图像缩放到模型输入尺寸,然后锁定内存直接读像素字节。stride不等于 width 乘以 3,系统为了内存对齐可能多出几个字节,所以每行偏移一定要用h * stride。通道顺序上,我读出来的变量名是b、g、r,但赋值给哪个通道取决于你用的模型训练时的通道顺序。如果模型是 OpenCV 训练出来的,通常是 BGR;如果是 PyTorch 标准 ImageNet 训练,通常是 RGB。这里的判断逻辑我放到第 5 章避坑部分详细展开。
3.4 后处理:张量转 Bitmap 边缘图
模型输出是一个一维数组,长度是batch * channels * height * width。你要把它重新组织成一张灰度图,并做一次可选的反向归一化。
public static Bitmap Postprocess(float[] output, int height, int width) { var bmp = new Bitmap(width, height, PixelFormat.Format24bppRgb); var rect = new Rectangle(0, 0, width, height); var bmpData = bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); int stride = bmpData.Stride; byte[] raw = new byte[stride * height]; for (int h = 0; h < height; h++) { for (int w = 0; w < width; w++) { int srcIndex = h * width + w; // 单通道输出 float value = output[srcIndex]; byte pixel = (byte)Math.Clamp(value * 255.0f, 0, 255); int dstIndex = h * stride + w * 3; raw[dstIndex] = pixel; raw[dstIndex + 1] = pixel; raw[dstIndex + 2] = pixel; } } System.Runtime.InteropServices.Marshal.Copy(raw, 0, bmpData.Scan0, raw.Length); bmp.UnlockBits(bmpData); return bmp; }这一段把单通道的概率值映射到 0-255,生成灰度边缘图。srcIndex的计算前提是输出张量形状为[1,1,height,width],是连续的。如果你的输出是[1,height,width],那要把srcIndex改成h * width + w之前先确认模型输出维度,否则图像会错位成条纹或雪花。
后处理的阈值问题我还没加,先保留概率值,调试时更直观。后面调参数阶段再决定是做二值化还是保留灰度。
4. 边缘检测推理的三个必调参数与调优顺序
4.1 推理尺寸:LDC 的输入分辨率与感受野
很多 LDC 结构图里写着 Dense Block,但没告诉你它实际感受野多大。推理尺寸直接决定边缘的粗细和断裂程度。输入尺寸越大,小结构保留得越多,但耗时近似平方上涨。我常用的一组对照是 256、512、1024:
- 256x256:速度最快,单帧 CPU 可以在 10ms 内完成,但细边缘容易变成断线,适合实时预览。
- 512x512:速度与效果平衡,多数开源模型训练时也常用这个尺寸。
- 1024x1024:能画得很细,但 CPU 推理时间可能到 200ms 以上,只适合离线处理。
需要强调:推理尺寸不是越大越好。边缘检测模型里的 Dense Block 下采样倍数固定,如果输入太大,输出层感受野覆盖不到某些细结构,反而会在边缘附近产生双影。我一般先按模型训练时的尺寸走,再通过torch.onnx.export的dummy_input固定下来,C# 侧不做随机缩放。
4.2 归一化常数:0-255、0-1 还是 ImageNet 均值方差
这是最容易翻车的地方。PyTorch 模型训练时通常把输入张量标准化到[0,1],或者减去 ImageNet 均值再除以方差。ONNX 模型本身不记录归一化参数,所以你在 C# 里看到的输入要求只能靠推测。第 3 章代码里写的是value / 255.0f,这只适合训练时分母是 255 的模型。
如果训练时用的是transform.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),那么预处理就要改成:
float value = (c == 0) ? r : (c == 1) ? g : b; result[index++] = (value / 255.0f - mean[c]) / std[c];判断方法很简单:导出 ONNX 前,在 Python 里用一张已知图片分别用 0-1 和 ImageNet 归一化喂进模型,比较输出边缘的强弱。然后在 C# 侧复现相同的公式。如果输出边缘明显变淡,十有八九是归一化没配对。
4.3 输出阈值与形态学去噪
模型输出是连续概率值,通常在 0 到 1 之间,但不会严格等于 1。要得到清晰的单像素边缘,必须做阈值处理。我一般会先看灰度边缘图的直方图,确定边缘像素和背景像素的分界点。常见做法是设定一个 0.2 到 0.5 之间的阈值,把低于阈值的像素置 0,高于阈值的保留原值或置 255。
阈值之后还可能存在离散噪点。我习惯再用一次形态学开运算,先腐蚀后膨胀,把孤立点消掉。这一步在 C# 里可以直接用 OpenCvSharp 的Cv2.MorphologyEx,不需要自己写。要注意的是,开运算的核大小不要超过 3x3,否则会把真正的细边缘一起抹掉。
public static Bitmap ApplyThresholdAndDenoise(Bitmap edge, float threshold) { // 这里用 OpenCvSharp 做示例,因为 System.Drawing.Common 不含形态学算子 using var mat = OpenCvSharp.Extensions.BitmapConverter.ToMat(edge); using var gray = mat.CvtColor(OpenCvSharp.ColorConversionCodes.BGR2GRAY); using var binary = new OpenCvSharp.Mat(); Cv2.Threshold(gray, binary, threshold * 255.0, 255, OpenCvSharp.ThresholdTypes.Binary); using var kernel = Cv2.GetStructuringElement(OpenCvSharp.MorphShapes.Ellipse, new OpenCvSharp.Size(3, 3)); Cv2.MorphologyEx(binary, binary, OpenCvSharp.MorphTypes.Open, kernel); return OpenCvSharp.Extensions.BitmapConverter.ToBitmap(binary); }threshold的建议取值范围我放在下面的表里。注意:这里我把阈值乘了 255,是因为前面Cv2.Threshold是作用在 0-255 灰度图上的,不是 0-1 概率图。
4.4 参数速查表
我把上面几节涉及的参数整理成一张表,方便你直接抄:
| 参数项 | 建议值 | 说明 |
|---|---|---|
| 输入尺寸 | 512x512 | 速度与效果平衡,CPU 单帧约 30-50ms |
| 缩放策略 | 直接 Resize | 拉伸会导致边缘变形,但实现最简单 |
| 通道顺序 | 按训练框架 | PyTorch 通常 RGB,OpenCV 训练通常 BGR |
| 归一化 | 0-1 或 ImageNet | 必须与训练时一致 |
| 输出阈值 | 0.2-0.5 | 先用直方图观察再定 |
| 形态学核 | 3x3 Ellipse | 过大丢失细边缘 |
| 运行库 | OnnxRuntime CPU | 可换 GPU 版 NuGet 包 |
这张表不是金科玉律,但能覆盖绝大多数模型。我每次接到一个新的 ONNX 边缘检测模型,都先按这个表跑通,然后再一项项调。调的顺序建议是:先固定输入尺寸,再查归一化,最后调阈值。其中归一化错了,后面调阈值意义不大。
5. C# Onnx 推理最常见的 5 个坑
5.1 坑 1:模型加载就崩,报 DllNotFoundException
现象:new InferenceSession(modelPath)抛出DllNotFoundException,提示找不到onnxruntime.dll或某个原生依赖。
原因:Microsoft.ML.OnnxRuntimeNuGet 包只在包目录下包含对应平台的原生库,如果你的项目PlatformTarget是AnyCPU,运行时可能加载失败;另外,释放模式没有把runtimes/win-x64/native下的文件复制到输出目录。
解决:把项目平台设为 x64,并在 csproj 里加<Platforms>x64</Platforms>。如果还报错,手动检查输出目录里onnxruntime.dll是否存在。不要直接把 x86 和 x64 的原生库混在同一目录,Windows 的 DLL 搜索顺序会让你非常痛苦。
5.2 坑 2:输入张量名称猜错,推理时维度冲突
现象:代码里写了input,但模型里叫images,运行时抛出InvalidArgument,提示输入维度不匹配或找不到输入名。
原因:ONNX 文件里的输入输出名并不是模型类名,而是导出脚本里input_names参数指定。即使指定了,有些框架导出时会额外加上伪节点。
解决:严格用_session.InputMetadata.Keys读取实际名称。代码里我已经在构造函数中打印过这些信息,部署时不要跳过这步。输入名称正确后,再检查维度。如果维度显示[1,3,512,512],但你DenseTensor<float>的维度是[1,512,512,3],需要回到预处理部分改 HWC 到 CHW 的顺序。
5.3 坑 3:边缘图变成“噪点云”,而不是清晰的线
现象:输出边缘图上的轮廓很粗,边缘内外全是斑点,完全看不出物体边界。
原因:绝大多数情况是 BGR 和 RGB 通道顺序混用。OpenCV 读图后连续内存是 BGR,而 PyTorch 训练库很多默认用 RGB。如果你在 C# 侧读 Bitmap 时按 RGB 赋值,但模型是在 OpenCV 数据管线里训练的,那卷积核学到的是反色通道特征,边缘响应自然乱七八糟。
解决:把预处理代码里的通道映射改成(c == 0) ? r : (c == 1) ? g : b的反向,即第一个通道取 B。我习惯先跑一张已知边界清晰的图像,比如黑白棋盘格,如果输出边缘正常,说明通道顺序对;如果输出边界像多了两层虚影,就反转通道顺序再试。
5.4 坑 4:输出全黑或全白,概率值范围异常
现象:后处理生成的边缘图全黑,或者整个画面近乎全白,只有很淡的轮廓。
原因:输出张量不是你想象中的概率值。可能模型输出的是 logits(未经过 Sigmoid),范围在 -5 到 5 之间;也可能输出张量里保存的是两个通道,比如一个是边缘概率,一个是内部区域概率。全黑时,如果 logits 是负数,你直接乘以 255 得到的像素是 0;全白时,正数 logits 会溢出成 255。
解决:在 Python 侧用 onnxruntime 打印输出的数值范围。如果输出范围明显超出 0-1,就要在 C# 后处理前加一步Sigmoid激活函数。float activation = 1.0f / (1.0f + (float)Math.Exp(-value));然后才做阈值或灰度映射。另外,如果你发现输出维度是[1,2,height,width],先检查是否第二个通道才是边缘图,或者两个通道分别对应边缘和背景,需要做 softmax 后取边缘通道。
提示:确认输出结构最直接的方式是打印
output.Value.Shape,不要靠猜。
5.5 坑 5:GPU 包报 CUDA 错误,CPU 反而正常
现象:换成Microsoft.ML.OnnxRuntime.Gpu后,调用InferenceSession直接抛CUDA error: the provided PTX was compiled with an incompatible version之类的异常。
原因:GPU 版本的 Onnx Runtime 对 CUDA 和 cuDNN 版本有严格要求,NuGet 包只是运行时外壳,还要你本机装有匹配的显卡驱动和 CUDA 运行时。很多人只看显卡能用就装了最新驱动,没装对应 CUDA Toolkit。
解决:如果你不是专门做 GPU 推理优化,CPU 版其实够用。LDC 这类轻量级模型在 CPU 上的性能已经不错,省去 CUDA 环境维护成本。如果必须用 GPU,先查 Onnx Runtime 官方文档里对应版本要求的 CUDA 版本,装好后用InferenceSession打印SessionOptions.AppendExecutionProvider_CUDA(0)的执行提供器列表,确认 CUDA 真正被启用,而不是 fallback 到 CPU。
6. 再进一步:LDC 边缘检测的实时化与量化
6.1 用 ONNX Runtime 自带 API 做 INT8 量化
LDC 模型虽然轻,但在低端工控机上仍可能想象力不足。常见做法是把 FP32 模型量化成 INT8。C# 侧可以直接用Microsoft.ML.OnnxRuntime.Quantization命名空间里的工具,但更可控的方式是在 Python 侧用onnxruntime.quantization先量化好,再让 C# 加载量化后的 ONNX 文件。
量化需要一份校准数据集,不需要标注,只要有几十到几百张和业务场景相近的图像。校准时会统计每层激活值的范围,然后用整数 8 位近似浮点计算。量化后模型体积能降到四分之一,速度可能提升 1.5 到 3 倍,但边缘检测这类对细节敏感的任务,量化后细边缘容易出现断裂。我做量化后一定会做一个 A/B 测试:同一张图分别用 FP32 和 INT8 推理,对比边缘像素占比和检测出的碎线段数量。
from onnxruntime.quantization import quantize_static, QuantType from onnxruntime.quantization import CalibrationMethod quantize_static( model_input="ldc_edge.onnx", model_output="ldc_edge_int8.onnx", calibration_dataset=MyCalibrationData(), # 你的数据加载器 quant_format=QuantType.QInt8, per_channel=True, calibration_method=CalibrationMethod.MinMax, )这段代码里per_channel=True对边缘检测结果影响很大。按通道量化比按张量量化更精细,保留了每个卷积核的数值分布差异,边缘细节保留得更好。CalibrationMethod.MinMax是最简单的,如果模型输出明显漂移,可以换成Entropy方法。
6.2 用 DirectShow/UVC 摄像头回调接实时边缘检测
C# 里接 USB 摄像头实时边缘检测,我一般分两条路:老式 DirectShow 方案用AForge.NET或OpenCvSharp的 VideoCapture,新式 UVC 方案直接用 MediaFoundation。很多工业相机上实际走的是 UVC 协议,回调里拿到的是 YUY2 或 MJPG 帧,要先转成 RGB 才能喂给 LDC。
OpenCvSharp 的VideoCapture会在内部线程里持续回调,每来一帧就丢给 LDC 推理,然后把边缘图画到 PictureBox 上。需要注意:推理耗时小于帧间隔时还好,如果推理慢于帧率,输出会出现明显延迟。我常用一个双缓冲队列:摄像头线程往队列里塞原始帧,推理线程从队列里取最新的帧处理,取不到就丢弃,保证界面始终显示最新边缘结果。
using var capture = new VideoCapture(0); using var frame = new Mat(); while (true) { capture.Read(frame); if (frame.Empty()) continue; using var bitmap = BitmapConverter.ToBitmap(frame); float[] edge = _inference.Predict(bitmap); using var edgeBmp = LdcPostprocess.Postprocess(edge, _inputHeight, _inputWidth); pictureBox.Image = edgeBmp; Thread.Sleep(1); }这段代码没有处理摄像头回传像素格式,实际中量产后,你会遇到 UVC 回调里区分多个摄像头的问题,解决办法是枚举VideoCapture的索引,或用设备路径匹配。这些与 LDC 本身无关,但会让你的实时边缘检测管线真正可用。
6.3 验证边缘检测效果:不能只看“看起来像”
最后一个进阶技巧是建立量化验证指标。边缘检测不像分类任务有准确率,常见做法是算边缘像素占比、连通区域数量、单像素宽度比例。我自己的习惯是准备一套十张左右的真实业务图,分别用 Canny 和 LDC 跑一遍,对比边缘像素占比的稳定性。如果 LDC 在同一场景不同亮度下边缘像素占比波动很大,说明模型过拟合亮度特征,需要加数据增强重新训练,或者回到第 4 章把归一化参数检查一遍。
我还会在跑验证时把边缘图和原图做一下叠加,直接观察边缘是否贴合物体轮廓。这一步用 C# 的Graphics.DrawImage就能叠,不需要 OpenCV。如果边缘比真实轮廓粗了两个像素以上,我习惯做一次腐蚀操作收缩边缘。腐蚀的迭代次数一般 1 到 2 次,迭代太多会让边缘消失。
在这些验证做完之前,我不会轻易把模型接到产线上。这也是我屡次翻车后养成的习惯:先跑通,再量化,再验证,最后才上实时管线。希望这份笔记能帮你在自己的 C# Onnx 边缘检测项目里少走几步弯路。
本文还有配套的精品资源,点击获取