☰
C# Onnx YOLOv8仪表指针检测:从训练到读数全流程指南
2026/10/2 18:22:35 网站建设 项目流程

简介:这是一份基于C#与ONNX Runtime的YOLOv8仪表指针检测完整工程,面向需要部署轻量级目标检测的桌面应用开发者,可用于仪表盘自动读数、工业巡检等场景。工程包含Visual Studio解决方案(.sln)、C#源码、ONNX模型文件、NuGet依赖包及大量运行时DLL,共351个文件,压缩包约355MB。其中.cs文件涵盖检测主逻辑,.onnx为训练好的YOLOv8模型,.dll和.nupkg为ONNX Runtime及相关库,另含XML配置、TXT说明等辅助材料,目录结构清晰,可直接编译运行。已有404人学习浏览,适合希望快速上手C#调用ONNX模型、理解YOLOv8输出解析与图像预处理流程的开发者。通过这套源码,可以掌握从模型加载、推理到检测结果绘制的完整链路,并为二次开发仪表识别类应用提供可靠参考。

1. 搜到「C# Onnx yolov8 仪表指针检测 源码.rar」后,我建议你先别急着解压

搜到一份「C# Onnx yolov8 仪表指针检测 源码.rar」时,很多人第一反应是解压跑通。但指针检测真正难的从来不是运行,而是让C#上位机拿到的检测框能稳定换算成表盘读数。YOLOv8模型在Python里训练并导出ONNX,C#侧用OnnxRuntime加载推理,恰好绕开了在工业电脑上装PyTorch的麻烦,也把训练环境和部署环境彻底切开。这篇文章不聊框架的架构演进,只讲一条能复现的落地链路:数据怎么标、导出ONNX时怎么选参数、C#代码里输入输出张量怎么解析、指针角度怎么从检测框算出来,以及部署时最容易翻车的几个点。适合手里有现成C#测控系统、想把仪表读数自动化的工程师,也适合刚拿到这类源码包但不知道从哪下手的同学。

2. 从 YOLOv8 训练到导出 ONNX:先让指针能被模型“看见”

2.1 处理数据集用于 yolov8 训练:labelme 标注与 YOLO 格式转换

拿到网上的源码包,不要指望里面附带全套训练数据。常见结构是C#工程、一个best.onnx、几张样例图。真要落地到自己的表盘,还是得重新做一轮标注和训练。所以第一件事是把数据集整理成Ultralytics能吃的YOLO格式,这一步卡住的人比想象中多。

标注工具用labelme。它导出的是JSON,每个目标是一个多边形,而YOLOv8训练需要txt,每条记录是class_id x_center y_center width height,全部归一化到图像宽高。转换脚本的核心就是把多边形的外接矩形算出来。要注意两点:一是归一化基准是原始图像尺寸,不是模型输入尺寸;二是如果标注的是多边形,默认的矩形会把指针周围的背景也包进去,导致模型学到的东西偏大,后面算指针尖端时误差变大。所以标注时点要紧贴指针边缘,不要为了省事画一个大框。

类别定义也有讲究。如果现场表盘形态固定,只标一个pointer类就够了;如果表盘大小、拍摄角度有变化,我建议再加一个dial类,用表盘外框给后续角度计算提供圆心参考。源码包标题虽然只提了“指针检测”,但落地时多数项目都会把表盘类别一起带上。单类模型也能跑,只是角度参考系要靠外部标定参数补齐。

数据量方面,单块表盘拍200张不同角度、不同光照的图,标完就能训练。多表盘则每种至少100张。仪表指针检测相对简单,不需要几万张那种规模,重点是把现场的遮挡、反光、暗光场景覆盖到,否则模型很容易在真正现场漏检。

2.2 训练自己的指针检测模型:data.yaml、GPU 还是 CPU、最小命令

训练前先把数据整理成固定目录结构:

dataset/ ├── images/ │ ├── train/ │ └── val/ └── labels/ ├── train/ └── val/

对应写一个data.yaml,内容很固定:

path: dataset train: images/train val: images/val nc: 2 names: ['dial', 'pointer']

之后用Ultralytics官方命令行启动训练。如果只有CPU机器,在Ubuntu20.04上搭建yolov8环境CPU版本也不复杂,装好ultralytics包就行,只是训练时间会长一些。我的常用命令:

pip install ultralytics yolo detect train data=data.yaml model=yolov8n.pt epochs=150 imgsz=640 batch=8 device=0 patience=20

参数说明:model=yolov8n.pt是nano预训练权重,指针检测属于简单目标,n或s足够,不要一上来就上large,训练时间翻倍收益却很小。imgsz=640必须和后面导出ONNX的输入尺寸保持一致,训练和部署输入不一致会明显掉点。batch=8在6GB显存以上基本能跑,显存不够就降到4,但batch变小会影响BN统计稳定性。patience=20是早停轮数,验证集20轮不涨就自动停。没有GPU就把device=0删掉,命令会自动用CPU,这时候epochs可以减到100,不然等得心慌。

训练完看runs/detect/train/weights/best.pt,这是验证集上表现最好的权重。部署只用best.pt,不要用last.pt。如果你去看YOLOv8的网络结构图,会发现它的检测头输出三种尺度的特征图,640输入下候选框总数是(640/8)^2 + (640/16)^2 + (640/32)^2 = 8400。这个数字后面在C#里解析输出张量时会用到。

2.3 导出 onnx:pytorch 转 onnx 的 opset、动态尺寸与输出结构

训练完成后,导出ONNX是这一步的重点。Ultralytics已经封装好接口,命令行:

yolo export model=runs/detect/train/weights/best.pt format=onnx opset=12 imgsz=640 dynamic=False simplify=True

等价Python脚本:

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

三个参数要理解再动手:

  • opset=12:OnnxRuntime对opset 12支持已经非常成熟。opset越高不一定越好,如果C#侧NuGet包版本偏旧,高opset会报“Unsupported operator”。保守选12。
  • dynamic=False:固定输入尺寸640×640。C#侧张量形状恒定,代码简单。开了动态尺寸,输入输出都要动态查询,排查麻烦,性能也未必好。
  • simplify=True:用onnxsim做计算图简化,去掉冗余节点。

导出后输出形状通常是1 × (4 + nc) × 8400。我们标了两个类,就是1 × 6 × 8400。如果你把类别数写成80,想当然按COCO的84通道解析,肯定会乱。C#侧解析时这里的偏移量必须按实际nc算。顺带提一句onnx量化int8:YOLOv8这种小目标检测模型,int8量化掉点比较明显,尤其是细指针。真要做量化,必须用现场表盘图片做校准集,不能拿通用数据集凑合。

3. 用 C# 加载 ONNX 跑推理:最小可运行代码与输入输出张量解析

3.1 NuGet 引包与 InferenceSession:C# 调用 onnxruntime 的标准姿势

C#侧要分清两个概念:onnx是模型文件格式,onnxruntime是推理引擎。在C#项目里实际引用的是Microsoft.ML.OnnxRuntime这个NuGet包。命令行添加:

dotnet add package Microsoft.ML.OnnxRuntime

如果打算用GPU推理,替换成Microsoft.ML.OnnxRuntime.Gpu,但GPU包体积大,部署机上还要配CUDA和cuDNN,版本对不上就直接崩。工业上位机我默认用CPU包,省心。

最小调用代码:

using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; var sessionOptions = new SessionOptions { GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL, IntraOpNumThreads = 2 }; using var session = new InferenceSession(@"weights\best.onnx", sessionOptions); Console.WriteLine($"输入数量: {session.InputMetadata.Count}, 输出数量: {session.OutputMetadata.Count}");

参数说明:ORT_ENABLE_ALL让onnxruntime做全套计算图优化,CPU推理提升明显;IntraOpNumThreads = 2限制推理线程数,上位机还要跑界面和通信,把CPU占满会导致整个系统卡顿。如果现场机器核心多,可以先不设,跑起来观察CPU占用再调。using var是为了让session释放原生内存,一个进程保持一个session就行,不要每次都new。

这里有个经常让人误会的点:如果C#侧报Access Violation或进程崩溃,别只怀疑自己的代码。先检查NuGet包版本和模型opset是否匹配,再检查是否同时引用了CPU包和GPU包,原生DLL冲突会直接炸掉进程,错误信息却不友好。

3.2 图像预处理:Resize、letterbox、归一化的顺序

YOLOv8训练时做了letterbox,推理时也必须做一模一样的预处理,否则框偏移、置信度低。C#常见做法是用System.Drawing读Bitmap,再做缩放填充。完整步骤如下:

  1. 读取图像,转成RGB顺序。Bitmap.GetPixel返回的是RGB,但如果用LockBits直接读底层字节,很多摄像头帧是BGR布局,必须先做通道转换。
  2. 计算缩放比例scale = min(targetW / srcW, targetH / srcH)。
  3. 等比缩放到(newW, newH),再放到640×640画布中央,四周填充灰色(114, 114, 114)。
  4. 像素值除以255,按CHW顺序放进float数组。

代码:

float[] Preprocess(string imagePath, int targetSize = 640) { using var src = new Bitmap(imagePath); int srcW = src.Width, srcH = src.Height; float scale = Math.Min((float)targetSize / srcW, (float)targetSize / srcH); int newW = (int)(srcW * scale); int newH = (int)(srcH * scale); var canvas = new Bitmap(targetSize, targetSize); using var g = Graphics.FromImage(canvas); g.Clear(Color.FromArgb(114, 114, 114)); int padX = (targetSize - newW) / 2; int padY = (targetSize - newH) / 2; g.DrawImage(src, padX, padY, newW, newH); var floats = new float[3 * targetSize * targetSize]; for (int y = 0; y < targetSize; y++) { for (int x = 0; x < targetSize; x++) { var c = canvas.GetPixel(x, y); floats[0 * targetSize * targetSize + y * targetSize + x] = c.R / 255f; floats[1 * targetSize * targetSize + y * targetSize + x] = c.G / 255f; floats[2 * targetSize * targetSize + y * targetSize + x] = c.B / 255f; } } return floats; }

这里有两个性能坑。第一,GetPixel逐像素访问很慢,一帧640×640要40万次调用,原型验证可以,正式项目建议用LockBits把像素拷贝到byte[]再循环。第二,Graphics.DrawImage默认插值方式与OpenCV的双线性不完全一样,可能造成轻微偏移。想和训练预处理完全对齐,可以用OpenCvSharp的Cv2.Resize,或者自己写双线性插值。现场对精度要求高的话,把这项列为排查重点。

3.3 解析 YOLOv8 输出张量:1x6x8400 变成检测框

把预处理后的数组变成输入tensor,然后推理:

var inputTensor = new DenseTensor<float>(floats, new[] { 1, 3, 640, 640 }); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor(session.InputMetadata.Keys.First(), inputTensor) }; using var results = session.Run(inputs); var outputTensor = results.First().AsTensor<float>(); var shape = outputTensor.Dimensions; // 1, 6, 8400

以nc=2为例,输出是1 × 6 × 8400。数据排布:每一个候选框先存4个坐标,再存2个类别得分,所以第一个框在索引0~5,第二个框在索引6~11,依次类推。解析代码:

int numAnchors = shape[2]; // 8400 int numClasses = shape[1] - 4; var detections = new List<Detection>(); float confThreshold = 0.35f; for (int i = 0; i < numAnchors; i++) { int offset = i * shape[1]; float cx = outputTensor[offset + 0]; float cy = outputTensor[offset + 1]; float w = outputTensor[offset + 2]; float h = outputTensor[offset + 3]; float maxClassScore = 0; int classId = -1; for (int c = 0; c < numClasses; c++) { float score = outputTensor[offset + 4 + c]; if (score > maxClassScore) { maxClassScore = score; classId = c; } } if (maxClassScore >= confThreshold) { detections.Add(new Detection(classId, maxClassScore, cx, cy, w, h)); } }

YOLOv8没有objectness分支,直接在类别得分上取最大值,不要再乘一个物体置信度。如果按YOLOv5的习惯乘,最终得分会偏低,很多低置信度指针直接被吞掉。这段代码只做了阈值过滤,后面还要做NMS,因为导出ONNX时不会自动带NMS层。

3.4 C# 侧结果关联:把检测框坐标映射回原图

推理得到的坐标基于640画布,要映射回原图才能画框和算角度:

float scale = Math.Min((float)640 / srcW, (float)640 / srcH); float padX = (640 - srcW * scale) / 2f; float padY = (640 - srcH * scale) / 2f; float x1 = (cx - w / 2f - padX) / scale; float y1 = (cy - h / 2f - padY) / scale; float x2 = (cx + w / 2f - padX) / scale; float y2 = (cy + h / 2f - padY) / scale;

如果这个映射写错,最典型的表现是图像中心区域的框看起来正常,越靠近边缘框越歪。原因就是padX和padY没参与缩放计算。坐标映射完要把结果夹到图像边界,否则画框时越界。

NMS实现不复杂:按置信度降序排序,保留最高分框,抑制与其IoU大于0.45的框。我习惯把指针和表盘两类放在一起做NMS,防止表盘框把指针框吞掉。到这一步,C#推理链路已经通了。下一章就是把这堆矩形变成实际读数。

4. 仪表指针角度计算:从检测框到读数,差的只是三角函数

4.1 指针检测框中心与表盘圆心:先确定参考系

YOLOv8检测框本身不含方向信息。要把“读数”算出来,必须引入参考系:表盘圆心、指针尖端、零位角度、量程角度。

常见做法是用dial检测框的中心当表盘圆心。工业相机固定后,表盘基本在画面某个稳定区域,检测框中心足够。但如果有透视、表盘倾斜,圆心偏一点,角度误差会被放大。更好的做法是拍一张标定图,手动标出表盘圆心和零位,写入配置文件。C#启动时读配置,而不是每次推理都靠检测框。

当没有dial类时,回退做法是用整幅图中心当圆心,这只能用于demo。这也是我坚持加上dial类的原因:指针检测归检测,圆心归圆心,两者解耦,后续调整标定参数不用重训模型。

拿到圆心后,还要从指针框里找出“指向”的特征。很多开源代码直接用检测框中心(cx, cy)与圆心连线,这有系统性误差:指针细长,检测框中心基本落在指针中部,不是尖端。对于长短针都有的表盘,中心方向可能和实际尖端方向差到10°以上,在270°量程的表盘上能差出好几个刻度。更稳的近似做法是:取指针检测框四条边的中点,分别计算到圆心的距离,选最远的那个点作为指针指向末端。代码:

public static PointF FindTip(PointF center, RectangleF box) { var candidates = new[] { new PointF(box.Left, (box.Top + box.Bottom) / 2f), new PointF(box.Right, (box.Top + box.Bottom) / 2f), new PointF((box.Left + box.Right) / 2f, box.Top), new PointF((box.Left + box.Right) / 2f, box.Bottom) }; PointF tip = candidates[0]; float maxDist = -1; foreach (var p in candidates) { float dx = p.X - center.X; float dy = p.Y - center.Y; float dist = dx * dx + dy * dy; if (dist > maxDist) { maxDist = dist; tip = p; } } return tip; }

这段逻辑假设指针尖端在外接矩形远离圆心的一侧。对普通工业仪表基本够用;如果是扇形量大、指针被截断,那就要走关键点检测,属于另一个方案,不在这个源码包的范畴。

4.2 用 Atan2 计算指针角度:象限、零位与顺时针方向

C#的MathF.Atan2返回弧度,范围在(-π, π]。转成角度并归一化到[0, 360):

public static float AngleFromCenter(PointF center, PointF tip) { float angle = MathF.Atan2(tip.Y - center.Y, tip.X - center.X) * 180f / MathF.PI; if (angle < 0) angle += 360f; return angle; }

这里有个必须处理的坐标系问题:图像坐标的Y轴向下,Atan2算出的角度在视觉上是逆时针方向,而大多数仪表是顺时针量程。我的做法是转换到以12点为0、顺时针增加的角度:

float visualAngle = 90f - angle; if (visualAngle < 0) visualAngle += 360f;

这样0°表示指针垂直向上,90°表示指向右边,180°表示向下。如果表盘零位不在12点,再减零位偏移。很多读数代码的错误都来自角度参考方向不一致,所以标定函数要明确记录:固定表盘,分别记录指针指向最小刻度和最大刻度时的visualAngle,保存为minAngle和maxAngle。例如0~100kPa的表盘,零位在135°,满量程在315°,就记录这两个数。

4.3 仪表量程映射:线性刻度下的读数公式与校验

当指针角度落在[minAngle, maxAngle]区间内,读数按线性比例:

public static float AngleToReading( float angle, float minAngle, float maxAngle, float scaleMin, float scaleMax) { float normalized = (angle - minAngle) / (maxAngle - minAngle); normalized = Math.Clamp(normalized, 0f, 1f); return scaleMin + normalized * (scaleMax - scaleMin); }

这个公式有一个隐藏坑:如果minAngle和maxAngle跨过0°/360°边界,不能直接相减。比如零位在350°,满量程在10°,实际量程只有20°,但maxAngle - minAngle = -340°,算出来完全错。解决方法是先做角度展开:

public static float UnrollAngle(float angle, float reference) { while (angle < reference - 180f) angle += 360f; while (angle > reference + 180f) angle -= 360f; return angle; }

使用时以minAngle做参考:

float unrolled = UnrollAngle(angle, minAngle); float normalized = (unrolled - minAngle) / (maxAngle - minAngle);

校验方法很直接:把指针分别拨到0、25、50、75、100,各拍一张图,记录模型读数和人工读数,算最大绝对误差。仪表指针检测项目一般允许误差在量程的1%~2%,超过这个范围优先检查圆心标定和零位角度,而不是调模型。指针检测做到这一步,读数已经能从ONNX输出流里稳定算出来了,但离现场能用还差一段距离。

5. ONNX 部署避坑与常见问题排查:C# 侧和模型侧的几个血泪点

5.1 DllNotFoundException / Access Violation:OnnxRuntime 原生库没加载对

现象:C#项目在开发机跑得好,拷贝到现场工控机后要么抛DllNotFoundException,要么在session.Run时直接报AccessViolationException。甚至同一台机器上另一个C#程序也崩。

原因:Microsoft.ML.OnnxRuntime的NuGet包里包含原生DLL,但默认可能没有正确复制到输出目录;更常见的是现场机器缺少VC++运行库,或者解决方案里同时引用了CPU包和GPU包,多个版本的onnxruntime.dll互相覆盖。C#调用C++互操作时出现Access Violation,大部分是原生库版本不一致,不是托管代码逻辑的锅。

解决:先检查输出目录里有没有onnxruntime.dll,和开发机的一致。打开NuGet管理器看有没有传递性引用把不同版本带进来,有就删掉冗余的包。现场机器先装VC++ 2015-2022 Redistributable。最后把bin目录里整个runtimes/win-x64目录复制到部署目录,保证原生库就在exe旁边。原生库加载失败不像托管异常那样有友好提示,只能按这条路径排查。

5.2 检测结果全是 0 置信度或空输出:预处理与训练配置不一致

现象:同样一张图,Python脚本能检测到指针,C#却输出空列表;偶尔有几条结果,置信度都低于0.2。

原因:最常见的三个不一致。一是颜色通道顺序反了,YOLOv8训练用RGB,C#用LockBits读BGR直接送进去,红色指针直接就废了。二是letterbox填充颜色不是训练时的114,而是0或255,模型没见过这种分布。三是缩放插值算法差距太大,目标特征被磨没了。

解决:预处理严格按训练配置重写。先用最慢的GetPixel逐像素验证颜色顺序,确认正确后再优化性能。letterbox填充值固定(114, 114, 114)。缩放插值优先改成双线性。调试时把预处理后的图像保存成文件,和Python侧同一条预处理流水线保存的图像逐像素对比,差异超过1就说明是预处理问题,立刻能定位。

5.3 CPU 推理慢,换 GPU 反而更慢:线程数与执行提供程序没配对

现象:i7机器CPU推理500ms一次,换成GPU并改用OnnxRuntime.Gpu包,结果变成1.2秒一次,还不如CPU。

原因:YOLOv8 nano或small模型太小,GPU推理的启动开销和CPU-GPU数据拷贝占大头。640×640输入下,GPU收益本来就有限。另一个常见原因是CPU包和GPU包同时存在,或者GPU包没有匹配的CUDA版本,运行时回退到CPU但线程数被限制,反而更慢。

解决:先跑官方ONNX Runtime benchmark,分别测CPU EP和CUDA EP。如果模型是nano或small,CPU线程数调到4~6,开启ORT_ENABLE_ALL,大概率比GPU划算。如果模型是large且推理频繁,再上GPU。嵌入式场景另说,比如要在RK3588上部署,那是用rknn-toolkit2转RKNN,不是OnnxRuntime能直接解决的,选型阶段就要定清楚。上位机场景通常30帧以内就够,CPU推理指针检测完全能扛住。

5.4 指针角度在 0° 附近跳变,读数抖得没法看:角度环绕没处理

现象:指针从355°慢慢转到5°,读数不是连续变化,而是从95跳到-95再到5。加上滤波之后更乱。

原因:角度是取模360°的,355°和5°数值上相差350°,物理上只差10°。如果直接对角度做平均,平均值约180°,完全错误。这是指针仪表检测特有的坑,和模型无关。

解决:先做角度展开,再滤波。每帧拿到新角度后,与上一帧滤波角度比较:

float delta = angle - lastAngle; if (delta > 180f) angle -= 360f; else if (delta < -180f) angle += 360f;

然后把展开后的角度用于量程映射和滤波。这个逻辑必须在读数映射之前,否则跨零表的量程也会错乱。

5.5 C# 与 Python 结果不一致:输出张量排布和 NMS 逻辑差异

现象:同一张测试图,Python侧yolo predict结果很准,C#侧同一个onnx跑出来框偏、漏检。

原因:Python侧的命令行工具默认做了NMS和坐标映射,C#侧跑的是裸ONNX输出,两个流程本来就不等价。很多人以为两边应该完全一致,其实差在NMS参数、坐标缩放和输出维度理解上。执行顺序也有影响:Python是先NMS再缩放坐标,C#如果先映射再NMS,目标附近多个重叠框时结果不同。

解决:统一流程:先对ONNX裸输出做置信度过滤,再做坐标映射,最后NMS。不要先映射到原图再做NMS。同时把Python侧推理用的conf和iou值同步到C#侧。校验方法是用Python读同一个ONNX,写一段和C#功能一致的脚本,对比同一张图的框数量和坐标,差异大于1像素就逐字段debug。

6. 让检测读数更稳的进阶技巧:角度滤波、阈值策略与回归验证

6.1 用滑动平均做角度滤波

角度跳变解决的下一步是平滑。我用一个固定长度的队列:

Queue<float> history = new(); const int windowSize = 5; float FilterAngle(float newAngle, float reference) { float unrolled = UnrollAngle(newAngle, reference); history.Enqueue(unrolled); if (history.Count > windowSize) history.Dequeue(); return history.Average(); }

每次滤波前用上一节写过的UnrollAngle把新角度展开到参考值附近,参考值取上一次滤波结果。窗口5帧时,30fps下响应约170ms,既平滑又不至于太迟滞。

6.2 按运行状态动态调整置信度阈值

生产环境不像测试集那么友好。我习惯设两个阈值:0.25用来判断“疑似有指针”,0.7用来确认“稳定读数”。单帧低于0.25视为无指针;连续5帧都大于0.7,才把读数提交给PLC;中间状态只做显示不参与控制。这样偶发反光、短暂遮挡造成的跳变都挡在控制链路之外。YOLOv8的置信度天然没有经过校准,与其纠结阈值小数点,不如用帧计数确认。

6.3 回归验证:把检测框和读数画回原图

每次改动算法后,拿同一个现场视频跑一遍,把检测框、圆心、角度和最终读数画在图像上,和人工真值存成对比图。我养成的习惯是保留至少200张带真值的回归集,每次改置信度、滤波参数或预处理,都重跑整个回归集,统计误差分布。只需要用C#的Graphics画线再保存,成本很低,但能拦住大多数“改了个参数,结果别处坏了”的翻车。指针检测做到后面你会发现,模型好坏只占一半,另一半在预处理和角度标定的一致性上。我自己在这上面交过不少学费,希望帮到你。

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

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

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

立即咨询