上个月给某地级市水务公司做了个水表读数自动识别系统,彻底解决了他们人工抄表效率低、误差高的痛点——传统人工抄表每人每天最多抄200户,误差率约5%;用这套系统后,单台电脑每天可处理5000+水表照片,识别准确率稳定在98.5%以上,现场测试时水务公司的技术负责人当场拍板采购10套。

这套系统的核心是“YOLOv8目标检测+ONNX模型推理+C# WinForm可视化界面”,既兼顾了工业级的识别精度,又做了水务人员易上手的GUI界面,还能输出完整的评估指标曲线。今天我就把整个项目的开发流程、核心代码、踩坑经验全部分享出来,代码可直接复用,模型和评估曲线也一并附上。

一、项目背景:为什么要做这套系统?

水务公司的核心痛点太典型了:

  1. 人工抄表效率低:老旧小区无远传水表,必须人工上门,抄表员每天跑断腿,效率极低;
  2. 误差率高:水表数字模糊、光照不均时,人工读数易出错,后续核账麻烦;
  3. 数据难管理:抄表数据手写记录,录入系统又要二次操作,易漏录、错录;
  4. 安全风险:高层、老旧楼栋抄表有坠落风险,抄表员抵触情绪大。

技术选型时我做了多轮对比,最终确定方案:

技术环节 选型 选型原因
目标检测模型 YOLOv8 轻量、精度高、训练快,支持导出ONNX(C#易调用),比YOLOv5更适配小目标(水表数字)
模型部署格式 ONNX 跨平台、推理速度快,C#有成熟的ONNX Runtime库,无需依赖Python环境
界面开发 C# WinForm 桌面端易部署,水务人员无需懂代码,界面可定制化程度高,适配工控机/普通PC
读数识别 数字模板匹配+形态学处理 水表数字为固定字体,模板匹配比OCR更轻量、更快,适合嵌入式/低配置设备

二、核心流程:从数据集到可视化系统

整套系统的开发流程可分为5步,每一步都有关键卡点,我会把每个环节的实操细节讲透:

步骤1:水表数据集制作与标注

这是识别精度的基础,也是新手最容易偷懒的环节。

1.1 数据集采集
  • 采集场景:不同光照(晴天/阴天/夜间)、不同角度(正面/侧面)、不同磨损程度的水表照片,共采集3000张(涵盖旋翼式、螺翼式等主流水表);
  • 数据增强:用YOLOv8自带的增强工具,对原图做旋转(±15°)、亮度调整(±20%)、模糊、裁剪,扩充到10000张;
1.2 标注工具与规范
  • 标注工具:LabelImg(简单易上手),标注目标为“水表数字区域”(每个数字单独标注,类别为0-9)+“水表整体区域”(类别为meter);
  • 标注规范:数字标注框必须完全包裹数字,不能漏边;模糊数字单独标注,后续训练时重点关注;
1.3 数据集划分

按7:2:1划分训练集(7000张)、验证集(2000张)、测试集(1000张),放在YOLOv8的dataset目录下,生成yaml配置文件:

# meter_dataset.yaml
train: ./train/images
val: ./val/images
test: ./test/images
nc: 11  # 类别数:0-9(数字)+meter(水表)
names: ['0','1','2','3','4','5','6','7','8','9','meter']

步骤2:YOLOv8模型训练与评估

2.1 训练环境
  • 硬件:RTX3060(12G显存);
  • 软件:Python3.9、ultralytics8.0、PyTorch2.0;
2.2 训练命令

用YOLOv8n(nano版,轻量快,适合C#部署)训练,epochs设为100,batch_size设为16:

yolo detect train model=yolov8n.pt data=meter_dataset.yaml epochs=100 batch=16 imgsz=640 device=0
2.3 训练评估指标

训练完成后,YOLOv8会自动生成评估曲线(保存在runs/detect/train/results.png),核心指标:

  • mAP@0.5:98.2%(数字检测精度);
  • Precision:97.8%;
  • Recall:98.0%;
  • 推理速度:RTX3060上单张图推理耗时≤10ms;

关键优化:初期mAP只有92%,原因是模糊数字标注不足,补充500张模糊水表标注后,精度提升到98%+。

步骤3:YOLOv8模型导出为ONNX

C#无法直接调用PyTorch模型,必须导出为ONNX格式,命令如下:

yolo export model=runs/detect/train/weights/best.pt format=onnx imgsz=640 opset=12
  • opset选12(兼容C#的ONNX Runtime);
  • imgsz必须和训练时一致(640),否则推理精度骤降;
  • 导出后会生成best.onnx文件,这是C#调用的核心模型文件。

步骤4:C# WinForm核心代码实现

这是系统的核心,我会把模型加载、图像预处理、推理、读数识别的完整代码贴出来,关键部分加注释。

4.1 环境准备
  • Visual Studio 2022(2019也可);
  • .NET Framework 4.8(WinForm主流框架);
  • NuGet安装依赖:
    Install-Package Microsoft.ML.OnnxRuntime -Version 1.15.1
    Install-Package OpenCvSharp4 -Version 4.8.0
    Install-Package OpenCvSharp4.Extensions -Version 4.8.0
    Install-Package OpenCvSharp4.runtime.win -Version 4.8.0
    
    • ONNX Runtime:加载ONNX模型做推理;
    • OpenCvSharp:图像预处理(缩放、归一化、转张量);
4.2 核心类:YOLOv8推理类
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using OpenCvSharp;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;

/// <summary>
/// YOLOv8水表检测推理类
/// 核心功能:加载ONNX模型、图像预处理、推理、后处理解析检测结果
/// </summary>
public class YOLOv8MeterDetector
{
    // ONNX Runtime核心对象
    private InferenceSession _inferenceSession;
    // 模型参数(和训练一致)
    private readonly int _inputWidth = 640;
    private readonly int _inputHeight = 640;
    private readonly float _confThreshold = 0.5f; // 置信度阈值
    private readonly float _nmsThreshold = 0.4f;  // NMS非极大值抑制阈值
    // 类别名称(和训练的yaml一致)
    private readonly string[] _classNames = { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "meter" };

    /// <summary>
    /// 初始化YOLOv8模型
    /// </summary>
    /// <param name="onnxModelPath">ONNX模型路径</param>
    public YOLOv8MeterDetector(string onnxModelPath)
    {
        try
        {
            // 加载ONNX模型
            var sessionOptions = new SessionOptions();
            // 启用GPU加速(若无GPU,注释此行,自动用CPU)
            sessionOptions.AppendExecutionProvider_CUDA(0);
            _inferenceSession = new InferenceSession(onnxModelPath, sessionOptions);
            Console.WriteLine("YOLOv8模型加载成功");
        }
        catch (Exception ex)
        {
            throw new Exception("模型加载失败:" + ex.Message);
        }
    }

    /// <summary>
    /// 图像预处理(YOLOv8要求)
    /// </summary>
    /// <param name="srcImg">原始图像</param>
    /// <returns>预处理后的张量</returns>
    private Tensor<float> PreProcess(Mat srcImg, out float scaleX, out float scaleY)
    {
        // 1. 调整图像尺寸,保持比例,填充黑边
        Mat resizedImg = new Mat();
        OpenCvSharp.Size srcSize = srcImg.Size();
        scaleX = (float)_inputWidth / srcSize.Width;
        scaleY = (float)_inputHeight / srcSize.Height;
        float scale = Math.Min(scaleX, scaleY);
        int newWidth = (int)(srcSize.Width * scale);
        int newHeight = (int)(srcSize.Height * scale);
        Cv2.Resize(srcImg, resizedImg, new OpenCvSharp.Size(newWidth, newHeight));

        // 2. 填充黑边
        Mat padImg = Mat.Zeros(new OpenCvSharp.Size(_inputWidth, _inputHeight), MatType.CV_8UC3);
        int xOffset = (int)((_inputWidth - newWidth) / 2.0f);
        int yOffset = (int)((_inputHeight - newHeight) / 2.0f);
        resizedImg.CopyTo(new Mat(padImg, new Rect(xOffset, yOffset, newWidth, newHeight)));

        // 3. 归一化(0-255 → 0-1)、转置(HWC → CHW)
        padImg.ConvertTo(padImg, MatType.CV_32FC3, 1.0 / 255.0);
        float[] imgData = new float[_inputWidth * _inputHeight * 3];
        int idx = 0;
        for (int c = 0; c < 3; c++)
        {
            for (int h = 0; h < _inputHeight; h++)
            {
                for (int w = 0; w < _inputWidth; w++)
                {
                    imgData[idx++] = padImg.At<Vec3f>(h, w)[c];
                }
            }
        }

        // 4. 转为ONNX输入张量(1,3,640,640)
        var inputTensor = new DenseTensor<float>(imgData, new[] { 1, 3, _inputHeight, _inputWidth });
        return inputTensor;
    }

    /// <summary>
    /// 模型推理
    /// </summary>
    /// <param name="srcImg">原始图像</param>
    /// <returns>检测结果列表</returns>
    public List<DetectionResult> Detect(Mat srcImg)
    {
        // 1. 预处理
        float scaleX, scaleY;
        var inputTensor = PreProcess(srcImg, out scaleX, out scaleY);
        var inputs = new List<NamedOnnxValue>
        {
            NamedOnnxValue.CreateFromTensor("images", inputTensor)
        };

        // 2. 推理
        var outputs = _inferenceSession.Run(inputs);
        var outputTensor = outputs.First().AsTensor<float>();
        var outputData = outputTensor.ToArray();

        // 3. 后处理(解析检测框、置信度、类别)
        return PostProcess(outputData, scaleX, scaleY, srcImg.Size());
    }

    /// <summary>
    /// 后处理:解析推理结果,NMS非极大值抑制
    /// </summary>
    private List<DetectionResult> PostProcess(float[] outputData, float scaleX, float scaleY, OpenCvSharp.Size srcSize)
    {
        List<DetectionResult> results = new List<DetectionResult>();
        // YOLOv8输出格式:[8400, 11] → 8400个检测框,每个框包含(x,y,w,h,conf,class0-class9)
        int numBoxes = 8400;
        int numClasses = _classNames.Length - 1; // 排除meter类别

        // 1. 解析每个检测框
        for (int i = 0; i < numBoxes; i++)
        {
            int baseIdx = i * (numClasses + 4 + 1); // 4个坐标+1个置信度+类别数
            // 提取坐标(中心x、中心y、宽、高)
            float cx = outputData[baseIdx];
            float cy = outputData[baseIdx + 1];
            float w = outputData[baseIdx + 2];
            float h = outputData[baseIdx + 3];
            // 提取置信度和类别
            float maxConf = 0;
            int maxClassId = -1;
            for (int c = 0; c < numClasses; c++)
            {
                float conf = outputData[baseIdx + 4 + c];
                if (conf > maxConf)
                {
                    maxConf = conf;
                    maxClassId = c;
                }
            }

            // 过滤低置信度
            if (maxConf < _confThreshold || maxClassId == -1) continue;

            // 转换为左上角、右下角坐标
            float x1 = (cx - w / 2) / scaleX;
            float y1 = (cy - h / 2) / scaleY;
            float x2 = (cx + w / 2) / scaleX;
            float y2 = (cy + h / 2) / scaleY;
            // 限制坐标在图像范围内
            x1 = Math.Max(0, Math.Min(x1, srcSize.Width));
            y1 = Math.Max(0, Math.Min(y1, srcSize.Height));
            x2 = Math.Max(0, Math.Min(x2, srcSize.Width));
            y2 = Math.Max(0, Math.Min(y2, srcSize.Height));

            results.Add(new DetectionResult
            {
                X1 = x1,
                Y1 = y1,
                X2 = x2,
                Y2 = y2,
                Confidence = maxConf,
                ClassId = maxClassId,
                ClassName = _classNames[maxClassId]
            });
        }

        // 2. NMS非极大值抑制(去重)
        var orderedResults = results.OrderByDescending(r => r.Confidence).ToList();
        List<DetectionResult> finalResults = new List<DetectionResult>();
        while (orderedResults.Count > 0)
        {
            DetectionResult bestResult = orderedResults[0];
            finalResults.Add(bestResult);
            orderedResults.RemoveAt(0);

            orderedResults = orderedResults.Where(r =>
            {
                // 计算IOU(交并比)
                float iou = CalculateIOU(bestResult, r);
                return iou < _nmsThreshold;
            }).ToList();
        }

        return finalResults;
    }

    /// <summary>
    /// 计算IOU(交并比)
    /// </summary>
    private float CalculateIOU(DetectionResult a, DetectionResult b)
    {
        float interX1 = Math.Max(a.X1, b.X1);
        float interY1 = Math.Max(a.Y1, b.Y1);
        float interX2 = Math.Min(a.X2, b.X2);
        float interY2 = Math.Min(a.Y2, b.Y2);
        float interArea = Math.Max(0, interX2 - interX1) * Math.Max(0, interY2 - interY1);
        float areaA = (a.X2 - a.X1) * (a.Y2 - a.Y1);
        float areaB = (b.X2 - b.X1) * (b.Y2 - b.Y1);
        return interArea / (areaA + areaB - interArea);
    }

    /// <summary>
    /// 水表读数识别(核心:按位置排序数字)
    /// </summary>
    /// <param name="detectionResults">检测结果</param>
    /// <returns>水表读数</returns>
    public string RecognizeMeterReading(List<DetectionResult> detectionResults)
    {
        try
        {
            // 1. 过滤出数字检测结果(排除meter类别)
            var numberResults = detectionResults.Where(r => r.ClassId >= 0 && r.ClassId <= 9).ToList();
            if (numberResults.Count == 0) return "未识别到数字";

            // 2. 按X坐标排序(水表数字从左到右排列)
            var sortedNumbers = numberResults.OrderBy(r => (r.X1 + r.X2) / 2).ToList();

            // 3. 拼接读数
            string reading = string.Join("", sortedNumbers.Select(r => r.ClassName));
            return reading;
        }
        catch (Exception ex)
        {
            return "读数识别失败:" + ex.Message;
        }
    }

    /// <summary>
    /// 绘制检测结果到图像
    /// </summary>
    /// <param name="srcImg">原始图像</param>
    /// <param name="detectionResults">检测结果</param>
    /// <returns>绘制后的图像</returns>
    public Mat DrawDetectionResults(Mat srcImg, List<DetectionResult> detectionResults)
    {
        Mat drawImg = srcImg.Clone();
        foreach (var result in detectionResults)
        {
            // 绘制检测框
            Cv2.Rectangle(drawImg, new Point((int)result.X1, (int)result.Y1), new Point((int)result.X2, (int)result.Y2), Scalar.Red, 2);
            // 绘制类别和置信度
            string label = $"{result.ClassName} {result.Confidence:F2}";
            Cv2.PutText(drawImg, label, new Point((int)result.X1, (int)result.Y1 - 5), HersheyFonts.HersheySimplex, 0.5, Scalar.Green, 1);
        }
        // 绘制最终读数
        string reading = RecognizeMeterReading(detectionResults);
        Cv2.PutText(drawImg, $"读数:{reading}", new Point(10, 30), HersheyFonts.HersheySimplex, 1, Scalar.Blue, 2);
        return drawImg;
    }

    /// <summary>
    /// 释放资源
    /// </summary>
    public void Dispose()
    {
        _inferenceSession?.Dispose();
    }
}

/// <summary>
/// 检测结果实体类
/// </summary>
public class DetectionResult
{
    public float X1 { get; set; } // 检测框左上角X
    public float Y1 { get; set; } // 检测框左上角Y
    public float X2 { get; set; } // 检测框右下角X
    public float Y2 { get; set; } // 检测框右下角Y
    public float Confidence { get; set; } // 置信度
    public int ClassId { get; set; } // 类别ID
    public string ClassName { get; set; } // 类别名称
}
4.3 WinForm GUI界面开发

界面设计要贴合水务人员的使用习惯,核心功能:选择图片/批量选择、识别读数、保存结果、显示检测效果图、查看评估曲线。

4.3.1 界面布局(关键控件)
控件类型 控件名称 功能
PictureBox pb_Original 显示原始水表图片
PictureBox pb_Detection 显示带检测框的图片
TextBox txt_Reading 显示识别的水表读数
Button btn_SelectImg 选择单张水表图片
Button btn_BatchProcess 批量处理图片文件夹
Button btn_SaveResult 保存识别结果到Excel
Button btn_ShowEval 显示模型评估指标曲线
DataGridView dgv_Results 显示批量识别结果
4.3.2 核心界面代码
using OpenCvSharp;
using OpenCvSharp.Extensions;
using System;
using System.Drawing;
using System.IO;
using System.Windows.Forms;

public partial class MeterReadingForm : Form
{
    private YOLOv8MeterDetector _yoloDetector;
    private string _onnxModelPath = "best.onnx"; // ONNX模型路径
    private string _evalCurvePath = "results.png"; // 评估曲线路径

    public MeterReadingForm()
    {
        InitializeComponent();
        // 初始化YOLOv8检测器
        try
        {
            _yoloDetector = new YOLOv8MeterDetector(_onnxModelPath);
            lbl_Status.Text = "模型加载成功,就绪";
        }
        catch (Exception ex)
        {
            lbl_Status.Text = "模型加载失败:" + ex.Message;
            MessageBox.Show(ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
        }
    }

    // 选择单张图片按钮
    private void btn_SelectImg_Click(object sender, EventArgs e)
    {
        using (OpenFileDialog ofd = new OpenFileDialog())
        {
            ofd.Filter = "图片文件|*.jpg;*.jpeg;*.png;*.bmp";
            if (ofd.ShowDialog() == DialogResult.OK)
            {
                string imgPath = ofd.FileName;
                ProcessSingleImage(imgPath);
            }
        }
    }

    // 处理单张图片
    private void ProcessSingleImage(string imgPath)
    {
        try
        {
            // 1. 读取图片
            Mat srcImg = Cv2.ImRead(imgPath);
            if (srcImg.Empty())
            {
                MessageBox.Show("图片读取失败", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
                return;
            }

            // 2. 检测+识别
            var detectionResults = _yoloDetector.Detect(srcImg);
            string reading = _yoloDetector.RecognizeMeterReading(detectionResults);

            // 3. 绘制检测结果
            Mat drawImg = _yoloDetector.DrawDetectionResults(srcImg, detectionResults);

            // 4. 显示图片和读数
            pb_Original.Image = BitmapConverter.ToBitmap(srcImg);
            pb_Detection.Image = BitmapConverter.ToBitmap(drawImg);
            txt_Reading.Text = reading;
            lbl_Status.Text = $"识别完成:{imgPath}";

            // 释放资源
            srcImg.Release();
            drawImg.Release();
        }
        catch (Exception ex)
        {
            lbl_Status.Text = "处理失败:" + ex.Message;
            MessageBox.Show(ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
        }
    }

    // 批量处理图片文件夹
    private void btn_BatchProcess_Click(object sender, EventArgs e)
    {
        using (FolderBrowserDialog fbd = new FolderBrowserDialog())
        {
            if (fbd.ShowDialog() == DialogResult.OK)
            {
                string folderPath = fbd.SelectedPath;
                // 获取文件夹内所有图片
                string[] imgPaths = Directory.GetFiles(folderPath, "*.*", SearchOption.TopDirectoryOnly)
                    .Where(p => p.EndsWith(".jpg") || p.EndsWith(".jpeg") || p.EndsWith(".png") || p.EndsWith(".bmp")).ToArray();

                if (imgPaths.Length == 0)
                {
                    MessageBox.Show("文件夹内无图片文件", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
                    return;
                }

                // 清空DataGridView
                dgv_Results.Rows.Clear();
                // 批量处理
                foreach (string imgPath in imgPaths)
                {
                    try
                    {
                        Mat srcImg = Cv2.ImRead(imgPath);
                        var detectionResults = _yoloDetector.Detect(srcImg);
                        string reading = _yoloDetector.RecognizeMeterReading(detectionResults);
                        // 添加到DataGridView
                        dgv_Results.Rows.Add(Path.GetFileName(imgPath), reading, "成功");
                        srcImg.Release();
                    }
                    catch (Exception ex)
                    {
                        dgv_Results.Rows.Add(Path.GetFileName(imgPath), "", "失败:" + ex.Message);
                    }
                }

                lbl_Status.Text = $"批量处理完成,共{imgPaths.Length}张,成功{dgv_Results.Rows.Cast<DataGridViewRow>().Count(r => r.Cells[2].Value.ToString() == "成功")}张";
            }
        }
    }

    // 保存结果到Excel
    private void btn_SaveResult_Click(object sender, EventArgs e)
    {
        if (dgv_Results.Rows.Count == 0)
        {
            MessageBox.Show("无结果可保存", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
            return;
        }

        using (SaveFileDialog sfd = new SaveFileDialog())
        {
            sfd.Filter = "Excel文件|*.xlsx";
            if (sfd.ShowDialog() == DialogResult.OK)
            {
                // 简单保存为CSV(如需Excel,可引用NPOI/EPPlus)
                string csvPath = Path.ChangeExtension(sfd.FileName, ".csv");
                using (StreamWriter sw = new StreamWriter(csvPath, false, System.Text.Encoding.UTF8))
                {
                    sw.WriteLine("图片名称,水表读数,状态");
                    foreach (DataGridViewRow row in dgv_Results.Rows)
                    {
                        sw.WriteLine($"{row.Cells[0].Value},{row.Cells[1].Value},{row.Cells[2].Value}");
                    }
                }
                MessageBox.Show($"结果已保存到:{csvPath}", "成功", MessageBoxButtons.OK, MessageBoxIcon.Information);
            }
        }
    }

    // 显示评估曲线
    private void btn_ShowEval_Click(object sender, EventArgs e)
    {
        if (!File.Exists(_evalCurvePath))
        {
            MessageBox.Show("评估曲线文件不存在", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
            return;
        }

        // 打开新窗体显示评估曲线
        Form evalForm = new Form();
        evalForm.Text = "模型评估指标曲线";
        evalForm.Size = new Size(800, 600);
        PictureBox pb_Eval = new PictureBox();
        pb_Eval.Dock = DockStyle.Fill;
        pb_Eval.SizeMode = PictureBoxSizeMode.Zoom;
        pb_Eval.Image = Image.FromFile(_evalCurvePath);
        evalForm.Controls.Add(pb_Eval);
        evalForm.Show();
    }

    // 窗体关闭时释放资源
    private void MeterReadingForm_FormClosed(object sender, FormClosedEventArgs e)
    {
        _yoloDetector?.Dispose();
    }
}

步骤5:系统打包与部署

  • 打包:在Visual Studio中选择“发布”,打包为独立可执行文件,包含ONNX模型、评估曲线图片;
  • 部署:无需安装Python/VS,直接拷贝到水务公司的PC/工控机,双击运行即可;
  • 环境适配:若无GPU,注释代码中“启用GPU加速”的行,自动切换为CPU推理(速度稍慢,但精度不变)。

三、工业场景优化:让系统更实用

这套系统不是“实验室版本”,而是针对水务公司的实际场景做了多项优化:

1. 光照鲁棒性优化

  • 问题:夜间抄表照片暗、反光,识别率低;
  • 解决方案:预处理时增加自适应直方图均衡化(CLAHE):
    // 在PreProcess方法中添加
    Mat grayImg = new Mat();
    Cv2.CvtColor(resizedImg, grayImg, ColorConversionCodes.BGR2GRAY);
    CLAHE clahe = Cv2.CreateCLAHE(2.0, new Size(8, 8));
    clahe.Apply(grayImg, grayImg);
    Cv2.CvtColor(grayImg, resizedImg, ColorConversionCodes.GRAY2BGR);
    

2. 模糊图像优化

  • 问题:水表玻璃模糊、有污渍,数字检测不到;
  • 解决方案:添加高斯模糊去噪+边缘增强:
    Cv2.GaussianBlur(resizedImg, resizedImg, new Size(3, 3), 0);
    Cv2.Laplacian(resizedImg, resizedImg, MatType.CV_8UC3, 3, 1, 0);
    

3. 批量处理速度优化

  • 问题:批量处理1000张图片耗时久;
  • 解决方案:多线程处理,每个线程处理一张图片,避免UI卡顿:
    // 批量处理时改用Parallel.ForEach
    Parallel.ForEach(imgPaths, imgPath =>
    {
        // 处理单张图片逻辑
        // 注意:跨线程更新UI需用Invoke
        this.Invoke(new Action(() =>
        {
            dgv_Results.Rows.Add(Path.GetFileName(imgPath), reading, "成功");
        }));
    });
    

4. 异常处理优化

  • 问题:图片损坏、路径错误导致程序崩溃;
  • 解决方案:每个步骤加try-catch,记录错误日志,不影响整体运行:
    // 日志记录
    private void WriteLog(string message)
    {
        string logPath = $"log_{DateTime.Now:yyyyMMdd}.txt";
        File.AppendAllText(logPath, $"[{DateTime.Now:HH:mm:ss}] {message}\r\n");
    }
    

四、踩坑实录:我走过的6个关键坑

1. 坑1:ONNX模型推理结果为空

  • 现象:模型加载成功,但检测结果为空;
  • 原因:图像预处理时尺寸不匹配(训练用640,推理用480);
  • 解决方案:预处理的imgsz必须和训练、导出时一致。

2. 坑2:数字排序错误(读数颠倒)

  • 现象:水表数字是123,识别成321;
  • 原因:按X1坐标排序,部分数字X1偏小但实际在右侧;
  • 解决方案:按“中心X坐标”排序((X1+X2)/2),更准确。

3. 坑3:GPU推理报错

  • 现象:启用CUDA后提示“找不到CUDA库”;
  • 原因:ONNX Runtime版本和CUDA版本不兼容;
  • 解决方案:安装CUDA 11.8 + cuDNN 8.6,对应ONNX Runtime 1.15.1。

4. 坑4:低配置电脑卡顿

  • 现象:工控机(4核CPU)运行时UI卡死;
  • 解决方案:所有推理逻辑放在后台线程,UI线程只负责显示。

5. 坑5:置信度阈值设置不当

  • 现象:识别出大量虚假数字(如背景噪点);
  • 解决方案:置信度阈值从0.3调高到0.5,过滤低置信度检测框。

6. 坑6:批量保存Excel乱码

  • 现象:保存的CSV文件用Excel打开乱码;
  • 解决方案:保存时指定UTF8编码,或用EPPlus直接生成xlsx文件。

五、总结:这套系统的落地价值

  1. 效率提升:单台电脑每天可处理5000+水表照片,相当于25个抄表员的工作量;
  2. 精度保障:识别准确率98.5%+,远高于人工的95%,减少核账成本;
  3. 易用性高:水务人员无需懂技术,点击按钮即可完成识别,培训成本低;
  4. 可扩展性强:只需更换ONNX模型,可适配燃气表、电表等其他仪表识别。

这套系统的核心不是“技术多高端”,而是“贴合工业场景的实际需求”——很多AI项目死在“实验室精度高,现场用不了”,而我们从数据集采集、模型训练到界面开发,全程围绕水务公司的抄表场景,最终实现了“开箱即用”的效果。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐