简介:一套可直接运行的C# WinForms工业视觉检测示例程序,面向需要使用YOLOv8模型完成PCB板缺陷识别的开发者与工程师,重点解决线路板生产中的漏孔(missing_hole)缺陷检测问题。程序以Baumer工业相机SDK为例获取图像,同时支持从本地图片读取,调用ONNX格式的YOLOv8模型进行推理,并在界面上实时绘制检测框和置信度。代码组织简洁,方便替换为Basler、大恒、OpenCV采集等常见相机接口,适合工业相机接入和YOLO模型落地的入门参考。资源包共141个文件,压缩包大小65.06MB,其中48个dll提供运行时依赖与相机SDK,15个cs为核心源码,另有已编译的exe、pdb调试信息、onnx模型、png/xml/resx等界面及配置资源,工程结构清晰,打开即可编译运行。已有182人学习下载,可用于快速搭建PCB漏孔检测原型,熟悉ONNX Runtime调用流程、WinForms画框展示、多相机采集源替换等关键环节;对希望在手写视觉项目中引入YOLOv8的开发者来说,是一份高完成度的参考实现。
1. 工业相机+YoloV8做PCB缺陷检测:这套C# WinForms源码能解决什么
在PCB产线上目检工位待过的人都知道,一块板子几十个焊点、过孔、走线,看久了眼睛花,漏检率全靠个人状态。C# WinForms工业相机+本地图像 通过YoloV8模型实现PCB板缺陷检测,这种组合最近在中小工厂里越来越常见。它不要求你会C++板端部署,也不用养算法团队,只要把工业相机或本地图片喂给YOLOv8的ONNX模型,再用WinForms做界面、调用和缺陷框标记,就能把漏检率压到车间能接受的范围。适合做上位机的C#工程师,也适合想给现有AOI设备加一套AI复判的人。
2. 方案选型与整体架构:为什么YOLOv8能落到WinForms里
2.1 传统视觉算法和YOLOv8的分工边界
早期做PCB缺陷检测,最常用的还是阈值分割、blob分析、模板匹配。缺件、漏焊这类对比度明显的缺陷,传统算法两三行代码就能查出来,而且不需要样本。但到了“鼠咬”“开路”“短路”“针孔”这类缺陷,边界非常模糊,光照一变,同一套参数结果就完全不一样,现场调参调到后面就是玄学。
YOLOv8之所以能上位,是因为它把缺陷当成目标检测来做。网络结构里的C2f模块和Decoupled Head让它在精度和速度上比较均衡,官方实现又自带数据增强和训练管线,C#工程师不需要重写网络。但它不是万能药,我见过不少项目把传统算法全扔了,结果慢速缺陷检测器在流水线上一帧要跑几百毫秒,整个节拍都跟不上。常见做法是“传统视觉先粗筛,YOLOv8做复核”:先用模板匹配把疑似区域框出来,再让YOLOv8在这些小图上判断缺陷类型,既省算力又能换模型升级。
2.2 工业相机选型与本地图像:先想清楚拍多大的板子
工业相机选型是第一个坑。缺陷最小尺寸和视场决定了分辨率,比如要看清0.1mm的针孔,至少要3到5个像素,那么300mm宽的PCB在单像素方向至少要12000个像素,普通500万像素的相机根本不够用。实际项目里,我一般先算“单像素物理尺寸”,公式是传感器宽度除以视场宽度;小于缺陷目标尺寸的三分之一才敢用。如果板子大,宁愿用线扫相机分段拍,也别用低分辨率面阵相机硬撑。
接口方面,USB3.0相机的帧率和带宽足够多数桌面检测场景,GigE则适合多相机接同一个工控机。品牌上Basler、海康、大恒都有C# SDK,Basler pylon的.NET例子最全,适合WinForms新人入门。如果你只是本地图像验证算法,那更简单,OpenCvSharp读图片、做resize、画框,再丢给PictureBox显示,不需要驱动。
镜头选型也容易忽略。工作距离、靶面尺寸、焦距三者关系用“焦距≈工作距离×(传感器宽度/视场宽度)”估算。PCB检测常用8~16mm定焦镜头,光圈不要开到最大,边缘畸变会影响小缺陷定位。整个过程先建模再买设备,比拿着相机对着板子乱拍靠谱得多。
2.3 WinForms下的线程模型:UI、取流、推理要分开
WinForms做“上位机壳子”是合格的,但很多人第一个月就把界面卡死。相机SDK的回调线程、推理线程和UI线程绝不能混。我通常用一个有界队列做缓冲,生产线程往里丢帧,消费线程取帧推理,推理完毕再通过BeginInvoke回到UI线程画框。这样相机回调只负责拷贝图像,几十毫秒就能返回,不会把相机SDK内部卡死。
// 相机回调线程往队列里放帧,推理线程从队列取帧 private BlockingCollection<Mat> _frameQueue = new BlockingCollection<Mat>(boundedCapacity: 3); private void OnCameraFrame(Bitmap raw) { try { _frameQueue.Add(raw.ToMat()); // 拷贝一帧 } catch (InvalidOperationException) { // CompleteAdding 后 Add 会抛异常,说明正在关闭 } } private void InferenceLoop() { foreach (var mat in _frameQueue.GetConsumingEnumerable()) { var boxes = _detector.Run(mat); BeginInvoke(new Action(() => ShowResult(mat, boxes))); } }boundedCapacity: 3很关键,容量太小容易丢帧,太大会让延迟越来越高,实时检测会越跑越旧。队列里的Mat是OpenCvSharp对象,处理完记得Dispose,否则长时间运行内存会涨到让系统卡顿。线程模型定好之后,后面所有功能都是往这个模型里塞参数,所以不要图省事直接在回调里画框,那是给自己埋地雷。
3. 训练自己的PCB缺陷检测模型:从labelme标注到YOLOv8训练参数
3.1 缺陷类别的定义和数据集最小规模
PCB缺陷种类很多,常见的缺件、短路、开路、多铜、针孔,每一样视觉特征差异都很大。开发前先和质检部门一起把缺陷类别收敛到5类以内,否则标注成本成倍上涨。类别名称要固定,后面转YOLO格式和C#标签映射都依赖它。
数据集方面,每类缺陷建议至少200张,总样本量500到1000张起步。现场拍不到的那些缺陷,可以先用切片、旋转、镜像、亮度抖动凑原始数据,但不要用纯合成图代替真实缺陷。标注工具我用labelme画多边形,每个缺陷一个框。麻烦的是labelme默认输出JSON,YOLOv8要的是txt,所以要先做一次转换。
# labelme_2_yolo.py import json import os # 类别名到YOLO类别id的映射,必须和训练用的data.yaml一致 cls_id_map = { "missing": 0, "short": 1, "open": 2, "spur": 3, "pinhole": 4, } def convert_one_label(json_path: str, output_dir: str): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) img_w = data["imageWidth"] img_h = data["imageHeight"] out_file = os.path.join(output_dir, os.path.basename(json_path)[:-5] + ".txt") with open(out_file, "w") as out: for shape in data["shapes"]: label = shape["label"] pts = shape["points"] # 多边形顶点列表 # 取多边形外接矩形,转YOLO格式:cx, cy, w, h 归一化 xs = [p[0] for p in pts] ys = [p[1] for p in pts] x_min, x_max = min(xs), max(xs) y_min, y_max = min(ys), max(ys) cx = ((x_min + x_max) / 2) / img_w cy = ((y_min + y_max) / 2) / img_h w = (x_max - x_min) / img_w h = (y_max - y_min) / img_h out.write(f"{cls_id_map[label]} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}\n") # 批量转换 input_dir = "labels_json" output_dir = "labels_yolo" os.makedirs(output_dir, exist_ok=True) for name in os.listdir(input_dir): if name.endswith(".json"): convert_one_label(os.path.join(input_dir, name), output_dir)脚本里用多边形外接矩形转检测框,最简单也够用。如果你标的缺陷是细长开路,外接矩形成本会很大,但YOLOv8默认的检测框就是水平矩形,除非换成OBB定向框,否则只能接受这种误差。转换后打开一个txt对照原图看一眼,确保坐标没有除零,是很多项目的第一个翻车点。
3.2 data.yaml和训练参数怎么定
训练前先写pcb_defect.yaml,路径和类别顺序必须与转换脚本一致,否则模型学到的是错位标签。
path: ./pcb_dataset train: images/train val: images/val names: 0: missing 1: short 2: open 3: spur 4: pinhole训练命令用Ultralytics的CLI就够了。如果你是GTX1660Ti这种8G显存的卡,batch不要给太高。
yolo train data=pcb_defect.yaml model=yolov8n.pt \ epochs=150 imgsz=640 batch=8 device=0 patience=30model=yolov8n.pt代表从nano预训练权重开始,nano最快,适合先验证流程。如果精度不够,换成yolov8s.pt或yolov8m.pt,代价是推理速度下降。imgsz=640是默认值,但PCB上的针孔、开路这类小目标在640尺度下可能只有几个像素,我一般会跑到960甚至1280,前提是显存顶得住。patience=30表示30轮验证指标没变好就提前停,防止过拟合。
训练参数里最值得调的是lr0,默认0.01在数据集小的时候很容易震荡,降到0.005会更稳。数据增强参数hsv_h、hsv_s、hsv_v对PCB这种颜色单调的目标很有用,可以让模型适应不同曝光。但flipud要慎用,如果缺陷特征跟元件方向有关,上下翻转会制造错误样本。训练结束后看runs/detect/train/results.png里的损失曲线,以及验证集图片上的预测框位置,不要只看mAP。
4. 把YOLOv8导出成ONNX并集成进C# WinForms:推理代码与相机取流
4.1 用Ultralytics导出ONNX并检查输出Shape
训练好的best.pt要导出为ONNX,C#才能用ONNX Runtime加载。导出命令很简单:
yolo export model=runs/detect/train/weights/best.pt format=onnx imgsz=640 opset=12导出后要确认输出的维度。YOLOv8的ONNX输出一般是[1, 4+类别数, 8400],其中8400是三个尺度特征图上所有候选框的总和。如果你设了5个缺陷类别,输出就是[1, 9, 8400]。不同版本Ultralytics导出结果可能变成[1, 8400, 9],这个差异会直接让C#解析代码写错。我在项目里习惯先用Python打印一下再继续:
import onnxruntime as ort sess = ort.InferenceSession("best.onnx") for out in sess.get_outputs(): print(out.name, out.shape)看到真实shape之后,再去写C#那边的数据处理,不要猜。ONNX Runtime的NuGet包名叫Microsoft.ML.OnnxRuntime,WinForms项目里直接用InferenceSession加载onnx文件。如果你机器上装了CUDA和cuDNN,可以换Microsoft.ML.OnnxRuntime.Gpu,否则用CPU版本也能跑,只是帧率会低一些。
4.2 C# WinForms里的ONNX Runtime推理核心代码
下面这段是我常用的YoloV8Onnx类核心逻辑,配合OpenCvSharp读取图像。
using System; using System.Collections.Generic; using System.Linq; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; public struct DetectBox { public float X, Y, W, H, Score; public int ClassId; } public class YoloV8Onnx { private readonly InferenceSession _session; private readonly int _inputSize; private readonly int _numClasses; private readonly string[] _labels; public YoloV8Onnx(string onnxPath, string[] labels, int inputSize) { _session = new InferenceSession(onnxPath); _labels = labels; _numClasses = labels.Length; _inputSize = inputSize; } public List<DetectBox> Run(Mat bgr) { // 1. BGR转RGB,resize到模型输入尺寸,像素值归一化到0~1 Mat rgb = new Mat(); Cv2.CvtColor(bgr, rgb, ColorConversionCodes.BGR2RGB); Cv2.Resize(rgb, rgb, new Size(_inputSize, _inputSize)); rgb.ConvertTo(rgb, MatType.CV_32FC3, 1.0 / 255.0); // 2. HWC转CHW,填到DenseTensor var inputTensor = new DenseTensor<float>(new[] { 1, 3, _inputSize, _inputSize }); var channels = Cv2.Split(rgb); for (int c = 0; c < 3; c++) { for (int y = 0; y < _inputSize; y++) { for (int x = 0; x < _inputSize; x++) { inputTensor[0, c, y, x] = channels[c].At<float>(y, x); } } } // 3. ONNX Runtime推理 var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", inputTensor) }; using var results = _session.Run(inputs); var outputTensor = results.First().AsTensor<float>(); var shape = outputTensor.Dimensions.ToArray(); var data = outputTensor.ToArray(); // 4. 解析输出,shape形如[1, 4+numClasses, 8400] return PostProcess(data, shape[1], shape[2]); } private List<DetectBox> PostProcess(float[] data, int cols, int boxes) { var candidates = new List<DetectBox>(); for (int b = 0; b < boxes; b++) { float cx = data[b]; float cy = data[b + boxes]; float w = data[b + boxes * 2]; float h = data[b + boxes * 3]; int maxIdx = 4; float maxScore = 0; for (int c = 0; c < _numClasses; c++) { float score = data[b + boxes * (4 + c)]; if (score > maxScore) { maxScore = score; maxIdx = c; } } if (maxScore < 0.25) continue; candidates.Add(new DetectBox { X = (cx - w / 2) / _inputSize, Y = (cy - h / 2) / _inputSize, W = w / _inputSize, H = h / _inputSize, Score = maxScore, ClassId = maxIdx }); } return Nms(candidates, 0.45f); } private static List<DetectBox> Nms(List<DetectBox> list, float iouTh) { var result = new List<DetectBox>(); foreach (var box in list.OrderByDescending(x => x.Score)) { bool duplicate = false; foreach (var r in result) { if (IoU(box, r) > iouTh) { duplicate = true; break; } } if (!duplicate) result.Add(box); } return result; } private static float IoU(DetectBox a, DetectBox b) { float x1 = Math.Max(a.X, b.X), y1 = Math.Max(a.Y, b.Y); float x2 = Math.Min(a.X + a.W, b.X + b.W); float y2 = Math.Min(a.Y + a.H, b.Y + b.H); float inter = Math.Max(0, x2 - x1) * Math.Max(0, y2 - y1); float areaA = a.W * a.H, areaB = b.W * b.H; return inter / (areaA + areaB - inter + 1e-6f); } }代码里最关键的是最后坐标归一化。ONNX输出里的中心点坐标和宽高都是相对于640输入尺寸的,所以除以_inputSize转成0~1,画框时再乘回原图宽高。如果你直接拿输出画到原图上,框的位置会偏到左上角,这是无数人踩过的坑。PostProcess里的置信度阈值默认0.25,NMS阈值0.45,小目标可以用0.15阈值,但误检会多一些。
4.3 本地图像和相机实时视频怎么接进WinForms
本地图像走OpenFileDialog,选图后调用YoloV8Onnx.Run,再把检测框画到原图上。
using (var ofd = new OpenFileDialog() { Filter = "图片|*.png;*.jpg;*.bmp" }) { if (ofd.ShowDialog() == DialogResult.OK) { using (var bmp = new Bitmap(ofd.FileName)) using (var mat = OpenCvSharp.Extensions.BitmapConverter.ToMat(bmp)) { var boxes = _detector.Run(mat); foreach (var b in boxes) { int x = (int)(b.X * mat.Width); int y = (int)(b.Y * mat.Height); int w = (int)(b.W * mat.Width); int h = (int)(b.H * mat.Height); Cv2.Rectangle(mat, new Rect(x, y, w, h), Scalar.Red, 2); Cv2.PutText(mat, _labels[b.ClassId], new Point(x, y - 5), HersheyFonts.HersheySimplex, 0.8, Scalar.Red, 2); } pictureBox.Image = OpenCvSharp.Extensions.BitmapConverter.ToBitmap(mat); } } }相机实时检测就更讲究。Basler pylon的C#接口通常在OnFrameGrabbed回调里拿图,回调里只做拷贝和入队,别画框。免驱USB摄像头走DirectShow也可以,OpenCvSharp的VideoCapture能直接打开,但多路摄像头区分设备要靠CAP_PROP_OPENNI_MASK或SDK里的DevicePath,这个后续再做多相机时再讲。实时检测时推理线程循环消费队列,每一帧的耗时直接决定产线节拍。
5. 避坑/常见问题:PCB缺陷检测上位机最容易翻车的5个点
5.1 相机回调里直接更新PictureBox:界面卡死只是开始
现象:相机画面和WinForms窗口同时变卡,CPU占用忽高忽低,点关闭按钮要等好几秒。
原因:相机SDK的回调线程本质是SDK内部的高优先级线程,你在这个线程里访问PictureBox.Image,UI线程也在刷新,两个线程同时操作资源,轻则闪烁卡顿,重则报出奇怪的绘图异常。
解决:回调里只把图像拷贝到队列,UI更新全部走BeginInvoke。我在第2章给的_frameQueue就是为这个准备的。另外每帧替换PictureBox.Image前,弹出之前的Bitmap并Dispose,否则内存会持续攀升。
5.2 C#调用相机SDK出现AccessViolationException c0000005
现象:程序运行几分钟到几小时不等,突然弹出AccessViolationException: c0000005,线程上下文全部指向原生DLL。
原因:这是C#调用C++相机SDK时最常见的坑。相机SDK的委托在回调注册后,原本应该是托管对象持有,但你没保留委托引用,GC把它回收了,SDK下次回调就会踩到已释放的内存。另一个原因是回调里用了Bitmap的LockBits后没有及时Unlock,SDK内部像素转换和托管代码同时访问同一块内存。
解决:把相机SDK的回调委托保存为类字段,比如_grabbedHandler;回调里只做像素格式转换和拷贝,不跨线程共享Bitmap。关闭相机时先停止抓流,再注销回调,最后释放SDK资源,顺序反了也会触发同样的c0000005。这个错误一旦出现,很难稳定复现,排查时直接在Main里加UnhandledExceptionHandler输出完整堆栈。
5.3 ONNX输出shape解析错:框全画在左上角
现象:推理不报错,但画出来的框全部缩在图片左上角一个小区域,或者坐标明显超出图像范围。
原因:ONNX输出shape有两种排法。早期Ultralytics导出是[1, 9, 8400],最近一些版本导出成[1, 8400, 9]。你按维度顺序取数时,如果拿了data[b]当作第一个候选框的x,而实际数据是按列优先存的,自然全错。另一个常见原因是把归一化坐标直接当像素坐标用。
解决:导出后先用Python打印out.shape,再决定C#解析逻辑。我上面给的PostProcess是按[1, cols, boxes]写的,如果看到[1, boxes, cols],就把data[b]改成data[b * cols]。同时注意坐标除以_inputSize,不是除以原图宽高。
5.4 训练时用1280推理用640:小缺陷全部漏检
现象:训练mAP很高,验证集图片也画得不错,但一到WinForms里跑实时画面,针孔、开路几乎全漏。
原因:训练时设置imgsz=1280,模型已经适应用1280分辨率看小目标,导出和推理却改用640输入,等于把图像缩小一半,小缺陷直接摊到几个像素之下,网络根本认不出来。训练和推理的输入尺寸不匹配,是所有YOLOv8项目的隐形杀手。
解决:导出命令、ONNX Runtime预处理、C#代码里的_inputSize三者必须一致。如果显存实在撑不住1280,就用“切片推理”:把原图按滑窗切成几个640小图,分别推理,再把框坐标映射回原图。切片要有重叠,一般重叠50像素,避免缺陷正好被切在边缘。这样WinForms跑起来帧率会比整图推理低,但只要产线节拍允许,精度提升很值。
6. 从跑通到上线:用帧率统计和模型热更新把这套WinForms做成顺手工具
Demo跑通后,真正上线前我一般会补三件事。第一件是加推理耗时统计,用Stopwatch分别量取流、预处理、推理、画框四段耗时,哪一段超过预期就单独优化。第二件是模型热更新,生产现场换机种就要换板型,不可能每次重启上位机。常见做法是把ONNX和labels配置放到运行目录的models文件夹,用FileSystemWatcher监听模型文件变化,在推理线程空闲时锁住InferenceSession然后替换。替换Session前必须加锁,否则推理线程跑到一半模型被换,会出现不可预知的空引用。
private void btnEvaluate_Click(object sender, EventArgs e) { var sw = Stopwatch.StartNew(); var boxes = _detector.Run(_currentMat); sw.Stop(); lblLatency.Text = $"推理耗时 {sw.ElapsedMilliseconds}ms"; }第三件事是把每帧有缺陷的截图按批次号命名存档。上线后算法迭代、返修追溯都靠这批图,不存图等于白跑。这套东西做下来,最大教训是:YOLOv8的参数好调,真正难的是让相机、界面和推理三个线程别互相踩脚。我现在每写完一个上位机模块,都会先画线程关系图再看性能,少了特别多返工。希望帮到你。
本文还有配套的精品资源,点击获取