简介一套C# WinForms工业相机与本地图像缺陷检测源码面向工业视觉开发者基于YOLOv8模型实现对PCB板missing_hole类型缺陷的自动识别适合需要快速搭建视觉检测Demo或验证算法效果的中高级C#程序员。资源共141个文件以cs源码、dll运行库、onnx模型、png测试图等为主整体压缩包约65MB结构精简便于按需修改。已有182人学习。代码以Baumer SDK为示例接入工业相机也支持读取本地图像可直接运行并在界面上实时绘制检测框与置信度工程模块划分清楚包含相机采集、图像输入、YOLO推理与结果绘制等部分可方便替换为Basler、Daheng或OpenCV采集接口同时演示了YOLOv8 ONNX模型的标准调用流程对学习C#环境下YOLO模型集成、工业相机对接和PCB缺陷检测有较高参考价值工程文件已包含多数依赖配置好相机SDK或本地图片路径即可运行完整检测流程。1. PCB 板缺陷检测为什么是 C# WinForms YoloV8一条能直接上线的落地路径贴片产线上的 AOI 复判工位一块 PCB 过完光学检测之后还要人工再看一遍屏一天几千片下来眼睛先扛不住。用 C# WinForms 做上位机、通过工业相机抓图、再扔给 YoloV8 模型做漏孔、短路、缺铜这类缺陷的二次分类是中小产线里性价比很高的一条改造路径。它解决的诉求非常具体不重写产线 MES 系统、不引入一套 Python 服务端在现有工控机上把检测能力直接嵌进桌面程序。适合两类人一类是懂 C# 上位机但没碰过模型部署的工程师一类是会用 YoloV8 训练但是不想维护 Python 运行环境的算法同学。后面所有内容都围绕“能跑起来、能上线、坏了能查”这三个目标展开。2. 把 YoloV8 模型部署进 C# 桌面端从导出 ONNX 到推理管线选型2.1 为什么绕开 Python 环境锁死 ONNX Runtime先把话说透YoloV8 的训练和验证离不开 Python但产线工控机上的推理环境完全可以不装 Python。常见做法是用 Ultralytics 训练完把 best.pt 导出成 ONNX 格式然后在 C# 里通过 ONNX Runtime 加载执行。这样工控机上只需要部署几个原生 DLL版本可控坏了能重新拷贝不用处理 conda 环境、pip 依赖、CUDA 驱动这些和产线稳定性无关的问题。我做过的几个项目里早期方案是启动一个 Python 的子进程做推理或者架一个 Flask 服务通过 HTTP 来回传结果。这两种方式在实验室都正常一进车间就暴露出问题Windows 更新后环境变量变了服务起不来、杀毒软件把 python.exe 当可疑进程、内存涨上去之后推理速度从 40ms 慢慢滑到 200ms。换到 ONNX Runtime 之后整个推理变成一次普通的原生库调用和调相机 SDK 在本质上是同一类事工控机上不再需要“守护一个 Python 进程”。对比一下三种常见做法的边界方便你决策方案部署复杂度推理性能和 C# 集成的顺手程度适用场景Python 子进程高环境脆弱一般进程间通信有开销低要管理进程生命周期原型验证不适合产线本地 HTTP 服务中多一个服务要守护一般中本地回环也怕端口被占已有算法服务化体系ONNX Runtime 原生调用低DLL 拷走即用CPU 上接近 PyTorch 的 1.5 倍高直接 P/Invoke桌面集成、产线工控机实际用下来除非必须用 TensorRT 在 NVIDIA 显卡上榨取极限帧率否则 ONNX Runtime 是 WinForms 项目里最稳的选择。CPU 推理在 i5 工控机上跑 YoloV8n 的 640 输入单帧大约 30 到 80ms对 PCB 缺陷复判这种每秒一两片、允许 200ms 内出结果的场景完全够用。2.2 训练好的模型导出 ONNX一条命令和三个容易踩的版本问题导出这一步在 Python 环境做一次就行命令很简单yolo export modelruns/detect/train/weights/best.pt formatonnx opset12 imgsz640opset 参数值得单独说。ONNX 的算子集版本决定了导出的模型能被哪个版本的 ONNX Runtime 解析。opset12 是兼容性最好的档位从 ONNX Runtime 1.10 到 1.17 都能跑opset17 能利用更新的算子实现但如果你用的 NuGet 包不是最新版加载时会直接报“unsupported operator”之类错误。我一般固定在 12除非有确切的性能优化需求。另一个常见坑是 export 时没指定 imgsz导致导出的模型输入尺寸和训练时不一致C# 端又按 640 去预处理最后输出候选框的坐标全部偏掉。导完模型之后用下面这段 Python 检查输入输出维度十秒钟能省一小时的联调import onnx m onnx.load(pcb_best.onnx) print(输入:, [i.name str(i.type.tensor_type.shape) for i in m.graph.input]) print(输出:, [o.name str(o.type.tensor_type.shape) for o in m.graph.output])YoloV8 的 ONNX 输出是一个维度为 [1, 4num_classes, 8400] 的张量前四个通道是预测框的 cx、cy、w、h后面是每个类别的置信度。这个 8400 是三个尺度的候选框总数C# 端解析时不要写死直接读张量的 Dimensions 更稳妥。2.3 桌面端推理管线的完整结构ONNX 模型解决的只是“前向计算”这一环完整的桌面端检测管线包含六个步骤采集帧、letterbox 预处理、归一化、session.Run 推理、候选框过滤与 NMS、坐标还原到原图。很多第一次上手的人在 WinForms 里只处理了“加载模型 → 跑推理 → 画框”跳过 letterbox 和坐标还原导致框的位置总是偏左上角一小截。原因在于 YoloV8 训练时默认把输入缩放到 640x640 正方形而工业相机的分辨率通常是 1280x960 或 2048x1536直接 Resize 成长宽比 1:1 会让缺陷的形状比例失真小缺陷直接漏检。正确做法是先按比例缩放到短边接近 640然后把多余部分用灰色114填充到 640x640推理得到结果后再把坐标减去 padding 值、除以缩放比例映射回原图。这一步在第三章的代码里会完整实现。3. 在 WinForms 里集成工业相机与本地图像检测最小可运行项目3.1 工程搭建与 NuGet 依赖用命令行创建一个 WinForms 项目然后加两个包一个是 ONNX Runtime一个是 OpenCvSharp4。OpenCvSharp 负责图像解码、缩放、画框和 Mat 与 Bitmap 的互转比直接操作 System.Drawing 顺手得多而且底层是原生 OpenCV性能有保证。dotnet new winforms -n PcbInspect cd PcbInspect dotnet add package Microsoft.ML.OnnxRuntime dotnet add package OpenCvSharp4 dotnet add package OpenCvSharp4.runtime.win最后一个包是 Windows 下的原生运行库必须装不然编译能过运行时报“找不到 opencv_core 系列 DLL”。装完检查了一下 obj 目录下的生成文件确认 OpenCvSharp4.runtime.win 的 native 文件被正确拷贝到了输出目录。UI 上我留三个控件一个 Button 用来选本地图片一个 Button 用来启动相机一个 PictureBox 显示结果一个 Label 显示缺陷数量。界面不是重点检测逻辑才是。3.2 写一个 YoloV8Detector 推理类letterbox 预处理与坐标还原核心封装成一个独立的 Detector 类把预处理、推理、后处理全收进去。这个类的代码可以直接抄注意两个关键点输入张量要转成 RGB 顺序并归一化到 0~1输出坐标必须经过 padX、padY、scale 三个变量还原回原图坐标系。using OpenCvSharp; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; namespace PcbInspect { public class YoloV8Detector { private readonly InferenceSession _session; private readonly int _inputSize 640; private readonly float _confThresh; private readonly float _iouThresh; // 与训练时 data.yaml 中的 names 顺序严格一致 private static readonly string[] ClassNames { missing_hole, mouse_bite, open_circuit, short, spur, spurious_copper }; public YoloV8Detector(string modelPath, float conf 0.35f, float iou 0.45f) { _confThresh conf; _iouThresh iou; _session new InferenceSession(modelPath); } public ListDetResult Detect(Mat image) { float scale Math.Min((float)_inputSize / image.Width, (float)_inputSize / image.Height); int newW (int)(image.Width * scale); int newH (int)(image.Height * scale); int padX (_inputSize - newW) / 2; int padY (_inputSize - newH) / 2; using Mat resized new Mat(); Cv2.Resize(image, resized, new Size(newW, newH)); using Mat padded new Mat(new Size(_inputSize, _inputSize), MatType.CV_8UC3, Scalar.All(114)); using Mat roi new Mat(padded, new Rect(padX, padY, newW, newH)); resized.CopyTo(roi); // 注意 OpenCV 的通道顺序是 BGRONNX 输入要求 RGB var input new DenseTensorfloat(new[] { 1, 3, _inputSize, _inputSize }); for (int y 0; y _inputSize; y) { for (int x 0; x _inputSize; x) { Vec3b px padded.AtVec3b(y, x); input[0, 0, y, x] px.Item2 / 255f; // R input[0, 1, y, x] px.Item1 / 255f; // G input[0, 2, y, x] px.Item0 / 255f; // B } } var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(images, input) }; using var results _session.Run(inputs); var output results.First().AsTensorfloat(); int classCount output.Dimensions[1] - 4; int boxCount output.Dimensions[2]; var candidates new ListDetResult(); for (int i 0; i boxCount; i) { float cx output[0, 0, i]; float cy output[0, 1, i]; float w output[0, 2, i]; float h output[0, 3, i]; float bestScore 0f; int bestCls -1; for (int c 0; c classCount; c) { float s output[0, 4 c, i]; if (s bestScore) { bestScore s; bestCls c; } } if (bestScore _confThresh) continue; // 坐标还原减去 padding除以缩放比例 float left (cx - w / 2f - padX) / scale; float top (cy - h / 2f - padY) / scale; float right (cx w / 2f - padX) / scale; float bottom (cy h / 2f - padY) / scale; candidates.Add(new DetResult { ClassId bestCls, Label bestCls 0 bestCls ClassNames.Length ? ClassNames[bestCls] : unknown, Score bestScore, Rect new Rect( (int)Math.Clamp(left, 0, image.Width), (int)Math.Clamp(top, 0, image.Height), (int)Math.Clamp(right - left, 0, image.Width), (int)Math.Clamp(bottom - top, 0, image.Height)) }); } return NMS(candidates); } private ListDetResult NMS(ListDetResult dets) { var result new ListDetResult(); var ordered dets.OrderByDescending(d d.Score).ToList(); while (ordered.Count 0) { var best ordered[0]; result.Add(best); ordered.RemoveAt(0); var keep new ListDetResult(); foreach (var d in ordered) { float inter best.Rect.Intersect(d.Rect).Area(); float union best.Rect.Area() d.Rect.Area() - inter; if (inter / union _iouThresh) keep.Add(d); } ordered keep; } return result; } } public class DetResult { public int ClassId; public string Label; public float Score; public Rect Rect; } }这段代码里最容易写错的地方在像素遍历那两层循环。padded.AtVec3b(y, x) 返回的 Vec3b 中 Item0 是蓝色通道Item2 是红色通道而 ONNX 模型输入是 RGB所以赋值时要反一下。如果顺序不对模型在大多数情况下仍然能出框但置信度会整体偏低特别是一些颜色对比度不高的缺陷比如 mouse_bite 缺口会漏掉。归一化到 0~1 也很重要直接拿 0~255 的像素值喂进去输出置信度会变成一团接近 0 的数。3.3 本地图像检测入口与绘制结果本地图片检测是最容易验证的一条链路读文件、调用 Detect、画框、转 Bitmap 显示。这里用 OpenCvSharp 的 Mat 保存中间结果最后再转成 Image 给 PictureBox避免 GDI 在大量绘图时句柄泄漏。using OpenCvSharp; using OpenCvSharp.Extensions; private readonly YoloV8Detector _detector new(pcb_best.onnx, 0.35f, 0.45f); private readonly OpenFileDialog _openFileDialog new() { Filter PCB图片|*.png;*.jpg;*.jpeg;*.bmp }; private void btnLoadImage_Click(object sender, EventArgs e) { if (_openFileDialog.ShowDialog() ! DialogResult.OK) return; using Mat image new(_openFileDialog.FileName, ImreadModes.Color); var dets _detector.Detect(image); DrawResults(image, dets); pictureBox.Image?.Dispose(); pictureBox.Image BitmapConverter.ToBitmap(image); labelStatus.Text $缺陷 {dets.Count} 处; } private void DrawResults(Mat image, ListDetResult dets) { foreach (var d in dets) { Scalar color Scalar.Red; Cv2.Rectangle(image, d.Rect, color, 2); Cv2.PutText(image, ${d.Label} {d.Score:F2}, new Point(d.Rect.X, d.Rect.Y - 6), HersheyFonts.HersheySimplex, 0.55, color, 1); } }关于置信度阈值 0.35 这个默认值说下我的习惯。训练集和实际产线图像的分布总有差异0.35 属于一个“既不太浪也不太怂”的档位。如果你发现缺陷框太多、杂音明显优先往 0.45 调如果发现漏检往 0.25 调。真正的平衡点要靠一批留出的验证图来标定别一上来就追求极致。3.4 工业相机采集从相机回调到检测线程工业相机的接入有两条路。如果你的相机是 USB 接口包括部分 UVC 协议的工业相机用 OpenCvSharp 的 VideoCapture 就能跑通代码最简适合先做原型验证如果是 GigE 接口的 Basler、海康这类主流相机就要用厂商 SDK它们的基础步骤是一致的初始化相机、设置触发模式、注册取流回调、把回调里的帧数据转成 Mat。以 Basler 的 pylon SDK 为例官方 C# 例程里拿到的是 Bitmap 或者原始字节缓冲区转成 Mat 时注意通道顺序即可。先写出“两步走”的第一版帮助你把采集和检测的逻辑分开。用后台线程循环抓帧抓一帧检测一帧检测完成后 Invoke 回 UI 线程更新显示避免在回调或工作线程里直接操作控件。private VideoCapture _cap; private CancellationTokenSource _cts; private async void btnStartCamera_Click(object sender, EventArgs e) { if (_cap ! null) { StopCamera(); return; } _cts new CancellationTokenSource(); _cap new VideoCapture(0, VideoCaptureAPIs.DSHOW); if (!_cap.IsOpened()) { MessageBox.Show(相机打开失败检查USB连接或被其他程序占用); return; } _cap.Set(VideoCaptureProperties.FrameWidth, 1280); _cap.Set(VideoCaptureProperties.FrameHeight, 960); await Task.Run(() CameraLoop(_cts.Token)); } private void CameraLoop(CancellationToken token) { using Mat frame new(); while (!token.IsCancellationRequested _cap.Read(frame)) { if (frame.Empty()) continue; var dets _detector.Detect(frame); Invoke(new Action(() { using Mat display frame.Clone(); DrawResults(display, dets); pictureBox.Image?.Dispose(); pictureBox.Image BitmapConverter.ToBitmap(display); labelStatus.Text $当前缺陷: {dets.Count} 时间: {DateTime.Now:HH:mm:ss}; })); } }CameraLoop 里有一个很多人会忽略的问题_cap.Read(frame)读出来的 Mat 指向的是相机内部缓冲区下一次 Read 会覆盖它所以 Invoke 到 UI 线程之后要frame.Clone()再绘图。直接在 Invoke 里用 frame 画框显示出来的画面会一帧一帧地闪偶尔还会出现撕裂的横线。这里要说明的是上面这段代码里 Detector 在后台线程同步执行CPU 推理期间取流仍在继续对 FPS 的要求不高时没问题。4. PCB 缺陷数据集处理labelme 标注转换、训练参数与 ONNX 导出4.1 PCB 缺陷类别与标注格式PCB 缺陷检测领域有个公开数据集 PCB_DATASET里面定义了六类典型缺陷missing_hole漏孔、mouse_bite缺口、open_circuit开路、short短路、spur毛刺、spurious_copper多余铜。自己建数据集的场景下这六个类基本覆盖了常见表观缺陷labelme 标注时的标签名最好和这里保持一致后面转格式省事。用 labelme 标注时画矩形框就行不用画多边形YoloV8 训练需要的是带旋转角的矩形框不需要普通 axis-aligned 矩形就够。标注质量直接决定模型上限。每类缺陷至少准备 300 到 500 个实例同一块板子的同一处缺陷换个角度、换个光照再采一次图都能显著提升泛化能力。我踩过的教训是只拿几十张高分辨率原图去标注模型在验证集上 mAP 能到 0.9一上产线换了相机位置立刻掉到 0.6 以下原因就是训练图里全是同一种打光角度。4.2 labelme 标注转 YOLO txt一个转换脚本labelme 默认输出 JSONYoloV8 训练要的是每张图对应一个 txt 文件每行格式为类别ID 中心点x 中心点y 框宽 框高坐标都是归一化到 0~1 的浮点数。用下面的脚本批量转换提前建好 images 和 yolo_labels 两个目录import json import glob import os from PIL import Image CLASS_MAP { missing_hole: 0, mouse_bite: 1, open_circuit: 2, short: 3, spur: 4, spurious_copper: 5, } def convert(json_path, json_root, image_root, out_dir): with open(json_path, encodingutf-8) as f: data json.load(f) img_path os.path.join(json_root, data[imagePath]) with Image.open(img_path) as img: w, h img.size lines [] for shape in data[shapes]: label shape[label] if label not in CLASS_MAP: continue (x1, y1), (x2, y2) shape[points] cx ((x1 x2) / 2.0) / w cy ((y1 y2) / 2.0) / h bw abs(x2 - x1) / w bh abs(y2 - y1) / h lines.append(f{CLASS_MAP[label]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) out_txt os.path.join(out_dir, os.path.basename(json_path).replace(.json, .txt)) with open(out_txt, w, encodingutf-8) as f: f.write(\n.join(lines)) for jf in glob.glob(pcb_dataset/json/*.json): convert(jf, pcb_dataset/json, pcb_dataset/images, pcb_dataset/yolo_labels)这段脚本有两个容易出错的点。第一labelme 的 imagePath 字段是相对路径必须保证脚本执行目录和原始标注目录一致否则 Image.open 直接报错第二labelme 里画矩形框时鼠标点下去是起点、松开是终点两点顺序不固定所以转换时要用 abs 求宽度和高度。做完转换后随手抽查几个 txt看坐标值是否都在 0~1 范围内避免尺寸单位搞错导致训练时大量边框直接越界。4.3 训练参数怎么定设备、epoch、imgsz 和增强训练侧和桌面端需要约定的只有一个核心参数imgsz。训练时用的分辨率要和 C# 端 _inputSize 保持一致否则转 ONNX 后虽然可以改输入尺寸但精度会有损失。PCB 缺陷属于小目标偏多的场景我用 640 属于平衡项如果你检测的是 0201 封装级别的极小微缺件可以考虑用 960 训练 960 推理代价是推理时间几乎翻倍。# pcb.yaml path: ./pcb_dataset train: images/train val: images/val nc: 6 names: [missing_hole, mouse_bite, open_circuit, short, spur, spurious_copper]yolo detect train datapcb.yaml modelyolov8n.pt imgsz640 batch16 epochs200 device0 yolo export modelruns/detect/train/weights/best.pt formatonnx opset12epochs 这个参数在 PCB 这种 6 类别、单类几百实例的数据量下200 轮足够收敛多跑只会让验证集分数虚高。batch 的大小由显存决定6G 显存选 1612G 可以选 32如果你用 CPU 训练batch 直接降到 4 然后去睡一觉。开启自动增强即可Ultralytics 的默认增强对光照变化已经做了处理产线换灯管、相机增益变化这类情况在训练时用自带的 HSV 增强能覆盖一部分。4.4 导出 best.pt 前必须对上的三个名字训练完别急着导出先做三件事。第一打开 runs/detect/train/ 下的 args.yaml确认 imgsz 和你预期的桌面端输入尺寸一致第二打开 data.yaml确认 names 的顺序和 C# 端 ClassNames 数组完全一致YoloV8 输出的类别 ID 是按这个顺序来的错一位短路的框就画成了漏孔第三用一张训练集图片在 Python 里跑一次模型拿到输出类别 ID再和 C# 端跑同一张图比对两边结果一致了才算真正打通。这类“两边类别对不上”的问题非常隐蔽。C# 端如果只显示 Label 字符串而不显示 ClassId你看到满屏的 missing_hole 会以为是模型没训好实际上只是 names 顺序差了一位。我在项目里会额外把 ClassId 一起画到框上面调试期能一眼看出问题。5. C# YoloV8 落地的 5 个翻车现场与排查套路5.1 相机回调里崩溃AccessViolationException (c0000005)现象程序刚启动一切正常抓了几百帧之后突然弹出 AccessViolationException地址后面跟着 c0000005整个程序退出。原因工业相机 SDK 底层是 C 原生代码注册给 SDK 的回调委托如果只写了“局部变量”或被 GC 回收原生代码在回调触发时访问到的是一块已释放的托管内存直接访问冲突。这是 C# 调用 C 最常见的翻车原因和热词里说的 c#调用c出现access violation c0000005 是同一类问题。解决把回调委托保存成类级别的字段引住不让 GC 回收。另外在相机初始化时把托管对象显式加进 GC 根比如用 GCHandle.Alloc 保持引用等程序退出再释放。如果你用的是 OpenCvSharp 的 VideoCapture虽然没有委托陷阱但要注意 Mat 不能跨线程被提前 Disposeusing 块的作用域要严格包住最后一次使用。5.2 点击“开始相机”后界面白屏现象按钮按下去窗口立刻变成“未响应”转圈过一会儿恢复但画面是黑屏。原因相机采集、图像转换、模型推理全在 UI 线程上执行。VideoCapture.Read 在 USB 相机上通常要等数据帧这个等待过程遇上传输不顺畅时可能卡几百毫秒再加上检测推理几百毫秒UI 消息泵被堵死系统就判定窗口无响应。解决把整条相机采集循环丢进 Task.RunUI 控件操作全部走 Invoke。注意避免用 async void 绑定相机启动事件后在循环里写 await Task.Delay这个组合容易让按钮重复触发多个采集线程导致多个线程同时读相机、画面变成慢动作。入口处加一个状态锁或者重复点击时先 Stop 再 Start。5.3 小缺陷漏检且框的位置整体往左上偏移现象短路、缺口这类细小缺陷几乎检测不到能检到的大缺陷框和真实位置差半个身位尤其图像边缘附近偏移更严重。原因按二维缩放把 1280x960 的图硬拉伸成 640x640缺陷的宽高比被扭曲小目标特征被破坏。或者 Resize 之后直接推理没有做 letterbox 的 padding 处理模型输出的坐标是方图坐标系直接套用到了原图上。解决两件事都要做。用 3.2 节里的等比例缩放加分边填充预处理推理输出坐标减去 padX、padY 再除以 scale。检查方法很简单找一张已知缺陷位置的图把 C# 端的框画出来看是否和缺陷边缘吻合。如果整体往右上偏大概率是 padding 计算时只减了一半或者用了四舍五入而不是整数除法。5.4 推理速度从 40ms 掉到 300msCPU 被打满现象单张图检测第一次推理几毫秒跑一段时间后越来越慢CPU 八个核全部 100%风扇狂转。原因ONNX Runtime 默认会根据 CPU 核数创建线程池Windows 工控机上经常是 8 核 16 线程。多线程并行计算在单张图、小模型场景下不仅不加速反而因为线程调度、内存带宽竞争拖慢速度。另外OpenCV 的图像处理也默认开多线程两套线程池叠加会产生严重的资源争抢。解决创建 InferenceSession 时设置 SessionOptions.IntraOpNumThreads我一般取物理核数的一半4 核机器设 28 核机器设 4。同时对 OpenCV 设置 Cv2.SetNumThreads(0) 或者设置成和 ONNX 相同的数值。这招在其他场景也同样适用比如工业上常见的 C# 上位机里同时开模型推理和图像处理时线程数收敛能稳定地提升整体吞吐。var options new SessionOptions(); options.IntraOpNumThreads 4; _session new InferenceSession(modelPath, options); Cv2.SetNumThreads(4);5.5 置信度很高的误检整块 PCB 被标成 defect现象所有正常板子都被画了一个大框置信度 0.9 以上而且框的位置固定不是缺陷。原因数据集里负样本缺失训练时没有“正常板”这一类。像 open_circuit 和 spur 这类缺陷如果不加背景类模型会强行把整块板、定位孔、金手指等结构分配到最接近的缺陷类别上。置信度还特别高因为模型“确信”它不是背景。解决最稳的方法是加一个 “normal” 背景类把正常板子的随机裁剪图也标进去类别数从 6 变成 7。如果暂时不想重训用保守阈值并配合规则过滤比如框面积占图像面积超过 30% 就忽略这属于止血方法治标不治本。注意加了 normal 类别之后C# 端的 ClassNames 数组和 data.yaml names 顺序也要同步加。6. 进阶技巧异步推理不卡 UI顺手把缺陷统计报表做出来前面的 CameraLoop 是一个“抓一帧、检一帧、显示一帧”的串行循环检测耗时直接决定显示帧率。想进一步提高吞吐常见做法是在后台线程里专门跑检测用并发队列做缓冲队列积压超过阈值就丢最老的帧保证显示画面始终跟手。这里一个关键细节是防止重入按钮连点两下可能开两个检测线程同一张图被检两次缺陷统计就有水分。我习惯用一个 SemaphoreSlim 来锁检测入口允许排队但不允许并发执行private readonly SemaphoreSlim _detectLock new(1, 1); public async TaskListDetResult DetectAsync(Mat image) { await _detectLock.WaitAsync(); try { return await Task.Run(() _detector.Detect(image)); } finally { _detectLock.Release(); } }除了实时显示产线还关心一个事缺陷能不能自动归档。每检出一处缺陷把类别、置信度、坐标和时间戳写进 CSV再裁一块包含检测框的 ROI 存成 PNG这是老板看得见、下个班次交接也需要的成果。写入操作放在检测线程而不是 UI 线程避免文件 IO 拖着界面foreach (var d in dets) { using Mat roi new Mat(frame, d.Rect); Cv2.ImWrite($./defects/{DateTime.Now:yyyyMMdd_HHmmss_fff}_{d.Label}.png, roi); File.AppendAllText(./defects/defects.csv, ${DateTime.Now:yyyy-MM-dd HH:mm:ss},{d.Label},{d.Score:F3},{d.Rect.X},{d.Rect.Y},{d.Rect.Width},{d.Rect.Height}\n); }这里的验证方法也顺手说一句找 50 张标注好的留出图在 C# 端批量跑一遍把检测结果和人工标注做比对统计每类缺陷的检出率和误检率。不要只看整体准确率PCB 缺场景里短路和漏孔的代价完全不同分类别看指标才能决定最终阈值。我养成的习惯是每改一次阈值或模型就重新跑这一遍离线验证产线出问题汇报时手上永远有一份当天的数据少费很多口舌。如果这套流程能帮你把检测逻辑跑通希望帮到你。本文还有配套的精品资源点击获取