C# + ONNX Runtime + YOLOv8 竹签与一次性筷子计数实战
2026/8/31 20:11:37 网站建设 项目流程

简介:本资源是一套基于C#实现的YOLOv8目标检测落地项目,面向具备基础.NET开发能力与计算机视觉兴趣的中高级开发者,解决竹签、一次性筷子等细长物体在产线或质检场景中的自动化计数难题。项目采用ONNX Runtime在C#中高效部署YOLOv8模型,涵盖模型推理、NMS后处理、坐标映射及数量统计全流程,无需Python环境即可完成端到端计数应用开发。压缩包共316个文件,含56个运行依赖DLL、15个核心C#源码文件(含图像预处理与结果可视化逻辑)、27个配置与说明文本、3个测试图像及2个ONNX模型文件,整体体积301.02MB,结构清晰,适配Visual Studio开箱即用。目前已有466人学习下载,提供完整可编译解决方案,包括.sln工程配置、NuGet包管理清单、ONNX模型加载示例及计数结果输出逻辑,显著降低工业轻量级视觉计数项目的集成门槛。

C# Onnx yolov8 竹签计数、一次性筷子计数 源码

一直有做餐饮供应链和餐具消毒厂的朋友问我,竹签和一次性筷子的数量到底怎么数。人工数,一根一根点,费时费力不说,工人盯着几百根签子数十分钟,眼睛一花就出错;用称重估算,又受竹签含水量、批次粗细影响,误差大到没法用。去年我接到一个需求,客户要求对流水线上的竹签捆和筷子包做实时计数,准确率要超过98%,最终我落地的方案就是 C# + OnnxRuntime + YOLOv8 这套组合。这篇文章把我从数据标注到模型导出,再到C#里写推理代码、处理边界遮挡的完整过程都记录下来,尤其是那些你在官方Demo里根本看不到的坑,逐个讲清楚。

如果你正准备做类似的工业计数项目——不管是竹签、筷子,还是钢筋、管材、棒料计数,这篇博文可以直接当作参考手册用。当然,完全不懂 C# 或者没接触过 YOLOv8 的读者,只要按着步骤走,也能跑通一套能用的计数程序。说到底,这事的核心就三件事:模型能不能“看清”目标,推理代码能不能跑得够快,计数逻辑能不能扛住目标堆叠和交错。

1. 选型和架构:为什么是 C# + ONNX Runtime + YOLOv8,而不是其他方案

拿到“竹签计数”这个需求时,我脑子里冒出来的可选项其实不少:老牌的 OpenCV 形态学处理、传统的图像分割算法、Python 跑的 YOLOv5/v8、甚至 halcon 这类商业视觉库。但结合客户现场环境,决策过程其实很清晰。

1.1 传统图像算法的局限:为什么不能只靠轮廓检测

最开始我确实想过用 OpenCV 的轮廓检测来做——竹签在深色背景下其实挺明显的,二值化之后找轮廓、数轮廓数量,理论上似乎可行。但你真拿一批货试试就发现,现实里的竹签根本不会老实的一根一根平铺好。会有签子叠在一起、交叉、尾端碰头、光线在签子上打出高光,导致二值化后断裂或粘连。一旦出现粘连,传统算法要么把两根算成一根,要么需要写一堆图像形态学参数去修,换个批次的光照条件参数又得重新调。这种“好了伤疤忘了疼”式的方案,在工厂产线上是撑不过一个月的。

1.2 深度学习模型的确定性优势

YOLOv8 这类目标检测模型的好处是,它不靠颜色阈值或固定形状模板去匹配,而是基于大量标注数据学习“竹签”和“筷子”的语义特征。哪怕背景复杂度变化、目标有部分遮挡、光线不统一,模型依然能稳定输出每个目标的边界框。这一点对我来说是最关键的优势——换个环境不用改算法逻辑,最多补充数据微调。

1.3 用 C# 而不是 Python:部署、线程和硬件配套

至于为什么选择 C#,而不是继续用 Python,原因也非常实际:

C# 和 .NET 在 Windows 生态下做上位机软件实在太方便了。客户现场的工控机几乎全是 Windows,配套的 PLC、扫码枪、相机 SDK 也基本都提供 C# 的接口。我要在项目里写串口通信、TCP/IP 协议对接产线系统、写数据库记录、配置 UI 界面,这些用 C# 全都是“原生技能”,而用 Python 则需要额外打包一堆依赖、处理各种环境问题。

ONNX Runtime 提供了非常完善的 C# API,推理性能和 Python 版本完全一致。YOLOv8 训练好的模型可以轻松导出为 ONNX 格式,C# 程序直接加载运行。实际测试下来,在普通的 i5 工控机上(无 GPU),推理一张 640x640 的图片也就 30-50ms,完全能满足产线实时性要求。

1.4 整体架构一览

整个项目我分成了几个模块:

模块技术选型职责
模型训练Python + YOLOv8数据标注、训练、验证
模型导出Pytorch -> ONNX将训练好的权重转换为部署格式
推理服务C# + ONNX Runtime加载模型、执行推理、解析输出
计数逻辑C# 业务代码坐标聚类、计数、输出结果
交互层WinForms / WPF实时画面显示、结果展示、参数配置

模型训练是一次性的,但实际部署和维护会一直跑在 C# 里。所以我坚持把推理代码做成一个独立的服务类,方便后续嵌入到不同的 UI 框架中。

2. 从数据准备到模型导出:一份可以照着做的最小可用流程

YOLOv8 的部署确实简单,但“能跑通”和“能计数准确”之间差着十万八千里。这一节我不讲 YOLOv8 原理,直接讲一套我验证过、数据量要求不高的流程。

2.1 数据采集和标注:少走弯路的三个原则

数据是深度学习里决定上限的东西。我的竹签数据来源有几个渠道:一是现场架相机拍不同角度、不同光线、不同摆放状态的竹签照片,大概收集了1500张;二是找了几个不同批次的竹签产品(粗细、颜色略有差别)补充多样性;三是通过简单的旋转、平移、亮度变化做了数据增强,最终参与训练的图片大约2000张。

标注工具我用的是 LabelImg,YOLO 格式输出。每一步要点:

  1. 标注框贴着签子的实际边缘,不要太松也不要太紧,宽松的框会让模型学到的特征包含太多背景。
  2. 每一类目标单独打标签,我这边建了两个类别:bamboo_skewerchopstick
  3. 特别注意标注“密集堆叠区域”——这类样本太少了模型就学不会区分叠在一起的签子。我专门用堆放状态的数据做了补充标注,确保图像里有两根交叉、三根平行挨着的情况出现。

如果你的目标数据里交叉情况很少,模型对这种场景就会很吃力。尽量多拍一些目标处于“不理想状态”的照片,比增加总图数有效得多。

2.2 训练细节:参数量和轮次的选择

用 YOLOv8 训练时,我选择的是YOLOv8nYOLOv8s两种结构对比过,因为竹签这种目标结构简单、尺寸固定,不需要大模型。最终线上用的是YOLOv8n,参数量小、推理快,在我的 GTX 1660 Ti 上训练一轮大约 5 分钟,200 轮下来完全能收敛。关键训练参数我记录如下:

参数说明
modelyolov8n.pt预训练权重
imgsz640输入分辨率,够用且推理快
epochs200提前停止机制生效
batch161660Ti 显存能承受的上限
workers8数据加载线程数
optimizerauto自动选择优化器
patience5050轮无提升自动停止

训练中还要留意results.png和验证曲线,如果val/box_loss在前20轮就压得很低,说明模型很容易收敛——目标形状太单一,这其实是个好事。但如果训练完用实际照片测试发现漏检多,优先补充那些“特定摆放角度”的样本,而不是盲目增加轮次。

2.3 ONNX 导出与 Int8 量化部署的思考

训练完成后,导出 ONNX 的代码非常简单:

from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") model.export(format="onnx", imgsz=640, opset=12)

这里有一件事我一再提醒自己:ONNX 导出后,一定要用onnxruntime先跑一遍,确认输出张量维度符合预期。YOLOv8 的原始输出是(1, 84, 8400)的格式——84 等于 4 个坐标值 + 80 个类别概率,8400 是三个尺度下所有预设框的总和。如果你训练时类别数不是 80,这个 84 要改成4 + 类别数。用 C# 读取时必须按这个维度去解析数据,后面会细说。

如果你用的是量化后的int8模型,部署时要注意精度损失问题。我试过把竹签模型量化成 int8,结果在快速移动的流水线上出现了约 1%~2% 的漏检。工业应用我建议先用 FP32 跑通流程,遇到性能瓶颈再考虑量化到 FP16,极少数情况才上 int8。

3. C# 端推理引擎实现:从加载模型到解析输出的完整代码

C# 端的核心就是 ONNX Runtime 的封装。我花了不少时间调试输出张量的解析,这也是最容易踩坑的地方。

3.1 环境准备:NuGet 包和 GPU 加速的坑

创建一个 .NET 6 或 .NET 8 的控制台/WinForms 项目都可以,重点是把依赖引对。在csproj中引用:

<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.19.2" /> <PackageReference Include="Microsoft.ML.OnnxRuntime.Gpu" Version="1.19.2" /> <PackageReference Include="OpenCvSharp4" Version="4.10.0.20240616" /> <PackageReference Include="OpenCvSharp4.runtime.win" Version="4.10.0.20240616" />

如果你只装了 CPU 版本的 OnnxRuntime,就跑不了 CUDA 推理。要同时装 Gpu 包和 Cpu 包,Gpu 包会自动包含 CPU 后端的回退。另外,ONNX Runtime 的 GPU 版对 CUDA 版本有严格对应关系,我用的是 CUDA 11.8 + cuDNN 8.9,配 1660Ti 和 3060 都验证过稳定。

遇到无法加载 DLL 'onnxruntime.dll'之类的报错,先检查x64输出目录下的原生 DLL 是否完整,然后把Microsoft.ML.OnnxRuntime.Gpu换到与你 CUDA 对应的版本试试。

3.2 推理引擎封装类

我封装了一个YoloDetector类,核心逻辑如下:

using System; using System.Collections.Generic; using System.Drawing; using System.Linq; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; public class YoloDetector : IDisposable { private InferenceSession _session; private string[] _labels; private float _confidenceThreshold; private float _iouThreshold; private int _inputWidth = 640; private int _inputHeight = 640; // 输出张量名,YOLOv8 ONNX 中通常为 "output0" private readonly string _outputName = "output0"; public YoloDetector(string modelPath, string[] labels, float conf = 0.25f, float iou = 0.45f) { _labels = labels; _confidenceThreshold = conf; _iouThreshold = iou; var options = new SessionOptions(); // 启用 CUDA;若没有 GPU 会自动回退到 CPU options.AppendExecutionProvider_CUDA(0); options.AppendExecutionProvider_CPU(); _session = new InferenceSession(modelPath, options); } public List<DetectionResult> Detect(Mat image) { var inputs = Preprocess(image); var outputs = RunInference(inputs); return Postprocess(outputs, image.Width, image.Height); } // ... 后续小节展开 Preprocess / RunInference / Postprocess }

这里要特别提醒:AppendExecutionProvider_CUDA(0)的参数是 GPU 设备 ID,多卡机器要按需调整。同时注册了 CPU 后处理器,这样在没有 GPU 的电脑上,程序不会直接崩溃,只是慢一些。

3.3 图像预处理:为什么要转 BGR 并做 Letterbox

YOLOv8 训练时会对输入图片做 Letterbox 处理——保持宽高比缩放,周围填充灰色(114, 114, 114),让所有图片都变成 640x640 正方形输入。推理时也必须做同样的处理,否则检测精度会明显下降。

private Tensor<float> Preprocess(Mat image) { var originalHeight = image.Height; var originalWidth = image.Width; // 计算缩放比例,取最小比例确保完全包含图片 float ratio = Math.Min((float)_inputWidth / originalWidth, (float)_inputHeight / originalHeight); int newWidth = (int)Math.Round(originalWidth * ratio); int newHeight = (int)Math.Round(originalHeight * ratio); // 缩放图片 Mat resized = new Mat(); Cv2.Resize(image, resized, new OpenCvSharp.Size(newWidth, newHeight)); // 生成画布并填充灰色 (114, 114, 114),将缩放图粘贴到中央 Mat canvas = new Mat(_inputWidth, _inputHeight, MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(new Mat(canvas, new OpenCvSharp.Rect((_inputWidth - newWidth) / 2, (_inputHeight - newHeight) / 2, newWidth, newHeight))); // BGR -> RGB、HWC -> CHW、归一化 var tensor = new DenseTensor<float>(new[] { 1, 3, _inputHeight, _inputWidth }); for (int y = 0; y < _inputHeight; y++) { for (int x = 0; x < _inputWidth; x++) { Vec3b pixel = canvas.At<Vec3b>(y, x); tensor[0, 0, y, x] = pixel.Item2 / 255f; // R tensor[0, 1, y, x] = pixel.Item1 / 255f; // G tensor[0, 2, y, x] = pixel.Item0 / 255f; // B } } return tensor; }

这里踩过一个坑:OpenCV 的Mat.At<Vec3b>取到的像素顺序是 BGR 而不是 RGB。YOLOv8 训练时用的是 RGB 通道顺序,如果直接拿 BGR 数据灌进去,模型的识别精度会大幅下降,但不会完全失灵。我当时排查了半天,最后一张一张对比推理结果才发现是这个通道顺序的问题。所以我在代码里把Item0赋给 B 通道,Item2赋给 R 通道,正好完成 BGR 到 RGB 的转换。

3.4 推理和输出解析:读懂 YOLOv8 的 8400 个预测框

推理本身一行代码就完成了:

private List<Tensor<float>> RunInference(Tensor<float> input) { var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", input) }; using (var results = _session.Run(inputs)) { // 提取输出 var output = results.First().AsTensor<float>(); return ParseYoloOutput(output); } }

YOLOv8 输出张量的形状是(1, 84, 8400),84 维的前4个是坐标,后面 80 维是类别概率。对我们这种自定义两类目标的模型,如果导出时类别数是2,输出维度就是(1, 6, 8400)

解析代码:

private List<DetectionResult> ParseYoloOutput(Tensor<float> output) { var detections = new List<DetectionResult>(); // 输出张量的形状 var dimensions = output.Dimensions; // [1, 6, 8400] int numChannels = dimensions[1]; int numAnchors = dimensions[2]; // 类别数 = 通道数 - 4 int numClasses = numChannels - 4; for (int i = 0; i < numAnchors; 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 maxProb = 0; int bestClassId = -1; for (int j = 0; j < numClasses; j++) { float prob = output[0, 4 + j, i]; if (prob > maxProb) { maxProb = prob; bestClassId = j; } } if (maxProb < _confidenceThreshold) continue; // 中心点坐标转边界框(注意这里还是 letterbox 后的坐标) float x1 = (cx - w / 2f); float y1 = (cy - h / 2f); float x2 = (cx + w / 2f); float y2 = (cy + h / 2f); detections.Add(new DetectionResult { Label = _labels[bestClassId], Confidence = maxProb, BoundingBox = new RectangleF(x1, y1, w, h) }); } // NMS(非极大值抑制)去掉重复检测框 return ApplyNms(detections, _iouThreshold); }

检测结果默认是 letterbox 之后的坐标系,最后要映射回原图尺寸。映射公式也简单:

public RectangleF MapToOriginal(RectangleF box, int origW, int origH, int inputW, int inputH) { float ratio = Math.Min((float)inputW / origW, (float)inputH / origH); float newW = origW * ratio; float newH = origH * ratio; float padX = (inputW - newW) / 2f; float padY = (inputH - newH) / 2f; float x1 = box.Left - padX; float y1 = box.Top - padY; float x2 = box.Right - padX; float y2 = box.Bottom - padY; // 边界裁剪 x1 = Math.Max(0, x1 / ratio); y1 = Math.Max(0, y1 / ratio); x2 = Math.Min(origW, x2 / ratio); y2 = Math.Min(origH, y2 / ratio); return new RectangleF(x1, y1, x2 - x1, y2 - y1); }

3.5 NMS 的实现细节

NMS(非极大值抑制)的目标是去掉同一目标上的多个重复框。我用基本的算法实现:

private List<DetectionResult> ApplyNms(List<DetectionResult> detections, float iouThreshold) { var result = new List<DetectionResult>(); if (detections.Count == 0) return result; // 按置信度降序排序 var sorted = detections.OrderByDescending(d => d.Confidence).ToList(); while (sorted.Count > 0) { var best = sorted[0]; result.Add(best); sorted.RemoveAt(0); // 移除与 best 重叠度过高的框 var remains = new List<DetectionResult>(); foreach (var det in sorted) { float iou = ComputeIou(best.BoundingBox, det.BoundingBox); if (iou <= iouThreshold) { remains.Add(det); } } sorted = remains; } return result; } private float ComputeIou(RectangleF a, RectangleF b) { float x1 = Math.Max(a.Left, b.Left); float y1 = Math.Max(a.Top, b.Top); float x2 = Math.Min(a.Right, b.Right); float y2 = Math.Min(a.Bottom, b.Bottom); float interW = Math.Max(0, x2 - x1); float interH = Math.Max(0, y2 - y1); float interArea = interW * interH; float unionArea = a.Width * a.Height + b.Width * b.Height - interArea; if (unionArea <= 0) return 0; return interArea / unionArea; }

工业场景里如果目标特别密集,可以改成 class-aware NMS,也就是不同类别之间不做抑制——竹签和筷子靠得很近时,类别间不互斥会更合适。

4. 计数逻辑:从“检测到目标”到“数得准”的关键设计

模型输出了一堆检测框,但计数不等于简单的叠加。实际现场里竹签会堆叠、遮挡,甚至有些签子探出图片边缘被截断。如何把这些边缘情况处理好,才是这个项目真正的价值所在。

4.1 靠检测框中心点还是框数量计数?

最开始我直接数检测框数量,跑了 50 张测试图,发现准确率只有 92%。排查后发现两个问题:

  1. 两根竹签几乎完全重叠时,模型只输出一个框,少算。
  2. 一根竹签被某些原因切成两个框(比如中间光线干扰导致检测中断),多算。

我的解决思路是:不直接数框,而是以检测框的中心点坐标为准,再做一次“聚类合并”。如果两个中心点距离小于某个阈值(比如竹签直径的 1/2),则认为是同一个目标。这样可以消除大部分重复框问题。

实际上 YOLOv8 的 NMS 已经处理了大部分重复框,但工业计数里“重叠”的模式太复杂,仅靠 NMS 不够。我实现了一个基于距离的合并:

public int CountByClustering(List<DetectionResult> detections, double minDistance) { if (detections.Count == 0) return 0; var centers = detections.Select(d => new PointF( d.BoundingBox.X + d.BoundingBox.Width / 2, d.BoundingBox.Y + d.BoundingBox.Height / 2)).ToList(); var visited = new bool[centers.Count]; int count = 0; for (int i = 0; i < centers.Count; i++) { if (visited[i]) continue; visited[i] = true; count++; // 将距离近的中心点归入同一组 for (int j = i + 1; j < centers.Count; j++) { if (visited[j]) continue; float dist = (float)Math.Sqrt( Math.Pow(centers[i].X - centers[j].X, 2) + Math.Pow(centers[i].Y - centers[j].Y, 2)); if (dist < minDistance) { visited[j] = true; } } } return count; }

4.2 边缘截断目标的处理策略

流水线上竹签可能有一部分在画面外。如果直接忽略,计数会少;如果强行计入,又无法保证是完整的一根。我的处理方式是:定义一个置信度阈值和完整度阈值,只有当检测框被图片边界切断的面积占比小于 20% 时才计入总数。

这话说起来简单,写起来要注意检测框是否“越界”。我在MapToOriginal阶段已经把框裁剪到图像范围内了,所以可以用“裁剪前后面积比”来判断是否被切断:

public bool IsTruncated(RectangleF originalBox, RectangleF clippedBox, float threshold = 0.2f) { float originalArea = originalBox.Width * originalBox.Height; float clippedArea = clippedBox.Width * clippedBox.Height; float truncRatio = 1f - clippedArea / originalArea; return truncRatio > threshold; }

4.3 处理密集堆叠的 ROI 分割策略

当竹签数量很大(比如 50 根以上)且堆叠严重时,单张 640x640 的图已经很难分辨每一根。我的方案是用超高分辨率相机 + 分割检测(tiling),把大图切成若干小块分别推理,最后汇总计数。

切分时注意给相邻块之间留出约 10% 的重叠区域,防止目标正好卡在切分线上。我当时的做法是:

  1. 把 2000x1500 的原图切成 640x640 的块,步长 500,保证重叠。
  2. 每个块的检测框坐标需要映射回原图全局坐标系。
  3. 对所有块的检测框做全局 NMS。

这一步完成后,计数准确率从 94% 提升到了 97.5% 左右。

4.4 阈值调优:置信度和 NMS 阈值的平衡

阈值是计数准确率另一个重要变量。置信度阈值设太低会混入很多假阳性的“背景虚检”;太高会导致漏检。我根据验证集做了小实验:

置信度阈值精确率召回率计数误差
0.1091.3%96.2%偏多
0.2595.8%95.1%少量偏多/偏少
0.4097.9%92.0%偏少
0.5598.6%86.5%明显偏少

最终我选择0.25作为默认值——虽然精确率不是最高,但误差最小。读者如果遇到特定场景,可以用同样方法做一张小表,选适合自己需求的阈值。

5. 实测效果与现场踩坑:把代码跑在真实产线上的血泪教训

光写代码是不行的,设备拉到现场才是考验的开始。这一节我集中记录一些现场调试遇到的真实问题。

5.1 模型精度和实际检测的差距:一个“伪阳性”案例

竹签在不同背景下检测效果差异很大。比如在深绿色的传送带上,模型会把传送带表面的污渍、纹路误判为竹签。这是因为训练数据里绿背景样本太少。解决办法不是调阈值,而是补充背景多样化的训练数据——我后来专门拍了不少不同传送带颜色的数据加进去,误检率显著下降。

5.2 相机选型和镜头距离的影响

计数结果高度依赖图像分辨率和拍摄角度。竹签直径大约 3-5mm,在图像上至少要占 20 个像素以上,检测才稳定。实际选用的是海康威视的 500 万像素工业相机,拍摄距离 50cm,分辨率 2448x2048,画面里约容纳 100 根竹签。如果是 130 万像素相机,画面同样大小时每根竹签占的像素就少了,检测难度会指数级上升。

拍摄角度方面,垂直俯拍的效果最好。倾斜角度超过 30 度后,竹签之间投影重叠严重,模型精度下降明显,计数误差会拉大到 5% 以上。

5.3 实时推理性能优化

如果 WinForms 界面里直接调用推理,界面会卡顿。我用了异步线程 + 队列的方式实现实时检测,UI 线程只负责显示结果。核心伪代码如下:

// 采集线程 while (true) { Mat frame = camera.Read(); detectionQueue.Enqueue(frame); } // 推理线程 while (true) { if (detectionQueue.TryDequeue(out Mat frame)) { var detections = detector.Detect(frame); int count = countLogic.CountByClustering(detections, 12f); UpdateUI(count); } Thread.Sleep(1); }

这样即使一帧推理要 40ms,UI 也始终流畅。另外,如果帧率超过推理速度,可以丢弃部分帧,保证检测结果是最新的。

5.4 ONNX Runtime GPU 版本的一个隐藏坑

ONNX Runtime 的 CUDA 执行提供方是很挑剔的。它要求 CUDA 和 cuDNN 的版本必须匹配。如果版本不对,AppendExecutionProvider_CUDA不会直接报错,而是在Run时才回退到 CPU——症状是程序跑得慢,但看不出任何异常。排查方法是在SessionOptions里启用日志:

options.LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_VERBOSE;

日志里会明确显示是否成功加载了 CUDA 执行提供方。我在现场就遇到过工控机没有 NVIDIA 驱动,程序还是跑通了,但速度完全达不到要求——通过日志才发现是回退到了 CPU。

5.5 模型的长期维护:数据回流和周期性微调

计数模型跑久了,会因为产品的批次变化(竹签变细、表面颜色改变)而逐渐失效。我给客户做了一个简单的数据回流流程:现场每拍一张图,如果模型输出的置信度低于 0.5,就把图单独存到一个文件夹;攒到几百张后重新标注微调一次。微调训练只用 20-30 轮就够了,不需要从头训练。这个方法让模型在客户现场跑了半年,计数准确率依然稳定在 97% 以上。

6. 常见问题与排查思路:代码跑不通的时候,按这个顺序查

如果照着上面的代码写完还是跑不出来,别慌。90% 的问题集中在以下几个地方,我按排查顺序列出。

6.1 推理输出全为空 / 检测不到任何目标

依次检查:

  • 输入图像是否做了 Letterbox?直接拉伸到 640x640 会让目标变形,检测能力大降。
  • 通道顺序是否正确?BGR 与 RGB 颠倒,置信度会断崖式下跌。
  • 模型输入名是否是 "images"?导出 ONNX 时输入名默认是 "images",但如果你自己改过,NamedOnnxValue.CreateFromTensor里的名字就要同步。
  • 置信度阈值是否太高?先用 0.05 试试,如果能看到大量低置信度框,说明是阈值问题。

还有一个小概率原因:模型文件路径问题。ONNX Runtime 加载失败会直接抛异常,如果没抛异常但结果全空,建议检查模型输出节点名。可以在 Python 里打印一下:

import onnx model = onnx.load("best.onnx") print([node.name for node in model.graph.output])

把输出的名字填入 C# 的_outputName字段即可。

6.2 计数始终偏多

  • NMS 阈值设得太低,导致多个紧密挨着的框没有被合并。把 IOU 阈值提高到 0.5-0.6 试试。
  • 竹签和竹签之间毫无间隙地并排摆放,模型可能输出一个框覆盖两根。这种只能靠更高分辨率的训练数据解决。

6.3 计数始终偏少

  • 目标堆叠太严重,模型只检测到上层一排。此时考虑分拍两到三次、分层拍摄后合并。
  • 图片边缘目标被截断,我的处理逻辑是直接忽略,导致边缘目标不计入。如果希望边缘目标也计入,可以把截断面积保留阈值从 20% 放宽至 40%。

6.4 推理速度太慢

  • 确认是否真的走了 GPU 而不是回退到 CPU(查看日志)。
  • 尝试使用 FP16 模型,推理速度可提升 30%-50%。
  • 如果 CPU 推理也能接受(每帧 50ms以内),可以不用 GPU,还能省掉部署时 CUDA 环境匹配的麻烦。

6.5 安装包发布时缺少原生 DLL

ONNX Runtime 的原生 DLL 在 NuGet 包里会自动输出到runtimes/win-x64/native,但如果发布时裁剪了未使用的文件,可能导致运行时报DllNotFoundException。发布时使用dotnet publish -r win-x64 --self-contained能避免大部分此类问题。

7. 后续扩展方向:从一个计数需求到一个视觉检测平台

项目跑通之后,客户又提了几个新需求,这也证明了这套架构的可扩展性。我觉得值得分享给大家参考。

7.1 质量检测一体化

竹签计数之外,还可以在同一个模型里加一个defect类别,标注发霉、开裂、弯曲的竹签,一并进行质量筛选。训练数据只需要补充部分缺陷样本,模型结构不用改,计数和质检一次搞定。

7.2 多相机并行计数

对于太宽的生产线,一台相机覆盖不了整个幅面。可以用多台相机 + 多线程推理同步计数,但要注意重叠区域的去重。我当时用一个关键思路:每台相机负责一个固定的物理区间,区间之间留出标定线,目标跨线时按中心点归属判断,基本不会重复计数。

7.3 接入 PLC 和数据库

将计数结果通过 Modbus TCP 写入 PLC,或写入 SQL Server 数据库,方便追溯。建议把每次检测的统计数据(总数、置信度均值、检测时长)都存下来,后续做质量分析和设备状态监测。

7.4 端侧部署的进一步优化

未来可以尝试用 TensorRT 的 C# 绑定替换 ONNX Runtime,在 NVIDIA 显卡上推理速度可以再提升 1.5-2 倍。不过这块维护成本会高一些,没有硬性性能需求的话,ONNX Runtime 已经很够用了。

整套项目从数据采集到部署上线大概花了三周。最花时间的其实是数据标注——但这一步偷懒,后面所有环节都会还回来。如果你也在做类似的计数项目,我的建议是:先把现场照片拍到足够多、覆盖足够全,再动模型和代码。数据到位了,C# 部署反而是最顺手的一环。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询