ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C# WinForm集成YOLOv7实时目标检测上位机开发实战

C# WinForm集成YOLOv7实时目标检测上位机开发实战

接到要开发实时目标检测上位机的需求,是几个月前的事。现场是一台小型自动化测试设备,工业相机固定在工位上方,要求电脑端实时显示检测画面,一旦识别出画面里的目标物体,就要把目标中心坐标通过串口发送给下位机去触发动作,同时每一次检测结果都要写入本地数据库,方便事后统计和追溯。对方手里已经有一份训练好的YOLOv7权重文件——视觉模型不需要我重新训练,我的任务是把模型嵌入到C# WinForm上位机里,再把串口通信和结果持久化这两个环节串起来。

这种需求在一线项目里相当常见,横跨三块内容:WinForm界面与YOLOv7推理集成、串口通信、检测结果持久化。很多人卡在中间某一步——模型在Python里跑得飞快,一到C#就不知道怎么接;或者检测功能做好了,却因为直接在UI线程做推理导致界面卡死。这篇文章按我从零搭到整机联调的完整路径来写,模型导出、推理封装、多线程刷新、串口协议、SQLite落库都有能直接用的代码和细节,坑也一并交代。适合正在做设备上位机、想把目标检测能力集成进去,或准备把Python训练好的模型部署到Windows程序的开发者参考。

1. 需求拆解与技术选型:为什么是WinForm加ONNX Runtime这套组合

1.1 功能边界:上位机在这套系统里到底要干什么

先别急着写代码,把功能边界理清楚。这套系统里的电脑端不是单纯做一个"能出画面的播放器",它是整条业务链路的调度中枢。整条数据链路是:相机采集画面 → Letterbox预处理 → 640×640图像送入推理引擎 → 解析25200个候选框 → NMS得到最终目标列表 → 界面叠加绘制 → 检测到目标时组帧经串口下发 → 每次检测结果写入SQLite。三个能力缺一不可:

  • 视觉识别:要求能在普通办公电脑上连续跑YOLOv7推理,实时性不能差到连操作员都看不过去。
  • 串口通信:检测结果要能转成下位机能识别的指令,不能只是停留在屏幕上的绿色框。
  • 结果持久化:每条检测记录都要能查回来,否则设备运行一整天后,你根本无法回答"今天下午三点检出过什么"这种问题。

我在项目里见过不少半成品:检测画面做得花里胡哨,但结果一关机就丢;或者模型识别精度很高,却不会往外发消息,最后还是人工看着屏幕手动操作。所以这三个环节不是可选项,而是这套上位机的完整闭环。

1.2 为什么不用Python直接部署,偏要绕一圈到C#

很多做算法的人第一反应是:Python里PyTorch加载模型多方便,再加个PyQt写界面不就完了?说实话,如果是实验室Demo,这么干一天就能跑通。但放到现场设备上,问题就来了。工业客户的电脑环境参差不齐,很多机器上没有Python运行时,你不可能要求客户自己去装解释器、配虚拟环境。PyQt打包出来的程序体积大,启动慢,现场人员一看命令行窗口就发怵。

另一个更现实的原因是工业硬件生态。做上位机的人都知道,PLC、运动控制卡、采集卡的SDK接口,十有八九优先提供C#或C++的库,个别老设备甚至只有C#的例程。你要是用Python做,后面接硬件时还得自己用ctypes去调DLL,接口文档看半天,效率太低。WinForm在工业上位机领域的基础太扎实了,从Visual Studio里拖控件到和硬件SDK对接,整套链路都是顺的。

1.3 ONNX是"训练与部署解耦"的关键棋子

这就引出了整套方案里我认为最值得强调的思路:训练和部署是两件事,不要捆在一起。用Python生态训练模型、做数据增强、调超参数,体验确实好;但部署端完全可以用另一套技术栈。ONNX就是中间这个翻译官——它是模型的一种通用中间表示格式,推理端不用安装PyTorch,只用一个轻量的原生推理库就能跑。

在C#这边,这个库就是ONNX Runtime。它在NuGet上有现成包,CPU和GPU版本都有,集成成本很低。更妙的是,这个方案把模型和程序解耦了:你今天用YOLOv7,哪天换了YOLOv8s或者自己训了个更轻量的模型,只要导出的ONNX输入输出结构保持一致,C#端程序几乎不用改,换一个文件就行。这个灵活性在实际项目中太重要了。

2. 模型导出与C#推理环境:先把YOLOv7的ONNX文件搞对

2.1 从PyTorch权重导出ONNX,优先用官方脚本

拿到手的是PyTorch格式的权重文件best.pt,第一步是先转成ONNX。YOLOv7官方仓库里自带导出脚本,优先用它,参数兼容性最好。我当时用的命令大致是这样:

python export.py --weights best.pt --img-size 640 640 --simplify

这个脚本会完成模型加载、算子转换、静态图导出的流程,--simplify参数会调用onnx-simplifier优化计算图,去掉一些冗余节点,导出后的文件在ONNX Runtime上跑起来更稳。

如果你的仓库版本不一样,或者你想自定义导出,手动导出也可以,核心代码思路如下:

import torch from models.experimental import attempt_load model = attempt_load('best.pt', map_location='cpu') model.eval() dummy_input = torch.randn(1, 3, 640, 640) torch.onnx.export( model, dummy_input, 'yolov7_custom.onnx', opset_version=12, input_names=['images'], output_names=['output'], dynamic_axes={'images': {0: 'batch'}, 'output': {0: 'batch'}} )

导出完成后别急着扔进C#,先用Python侧加载ONNX文件跑一遍,确认输出形状。YOLOv7标准输出是[1, 25200, 85]——1是batch大小,25200是三个尺度的锚点总数,85里前4个是候选框坐标,第5个是目标置信度,后面80个是COCO类别分数。如果你自己训练时改了类别数量,最后一维会跟着变,比如只分5类,那就是4+1+5=10,后面C#解析逻辑必须同步调整。

2.2 C#侧依赖包与版本匹配的坑

Visual Studio里创建一个.NET Windows窗体项目,目标平台建议直接选x64,不要选AnyCPU也别选x86。YOLOv7推理涉及大量张量计算,x86下性能明显吃亏,而且某些原生库的native依赖只提供了x64版本,你选错平台会莫名其妙报"无法加载DLL"。

NuGet里需要装的包就这几个:

  • Microsoft.ML.OnnxRuntime:CPU推理需要,当前稳定版一般在1.16.x或1.17.x。
  • OpenCvSharp4和OpenCvSharp4.runtime.win:负责相机采集、Mat与Bitmap互转,比System.Drawing单独处理图像要顺手。
  • System.IO.Ports:串口通信,.NET 6以上这个包已经分出来了。
  • Microsoft.Data.Sqlite:SQLite数据库访问。

这里有个我踩过的版本坑:ONNX Runtime对ONNX算子集版本(opset)有兼容范围,如果你的模型导出时用了过新的opset,而C#侧装了很旧的ONNX Runtime,运行时会直接报不支持某算子。反过来也一样,旧模型配新库一般没事,但新模型配旧库大概率出事。建议导出时用opset_version=12,ONNX Runtime装稳定版,两者兼容性最稳妥。如果你要上GPU加速,需要装Microsoft.ML.OnnxRuntime.Gpu包,并且CUDA和cuDNN的版本必须严格匹配库的发布说明,差一个小版本都可能在加载模型时崩溃。

2.3 InferenceSession初始化和输入输出约定

推理会话是整个检测器的核心对象,我一般这样封装:

using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class YoloDetector : IDisposable { private readonly InferenceSession _session; private readonly string _inputName; private readonly string _outputName; public YoloDetector(string modelPath) { var opts = new SessionOptions(); opts.LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_WARNING; _session = new InferenceSession(modelPath, opts); // 从模型元数据动态拿输入输出名,不要靠猜 _inputName = _session.InputMetadata.Keys.First(); _outputName = _session.OutputMetadata.Keys.First(); var shape = _session.OutputMetadata[_outputName].Dimensions; Console.WriteLine($"Output shape: [{string.Join(",", shape)}]"); } public void Dispose() => _session.Dispose(); }

有个细节值得注意:不要硬编码输入输出名。官方导出的模型通常叫images和output,但社区里有人会二次加工ONNX修改节点名,你硬编码在别人环境里就可能崩。用InputMetadata和OutputMetadata动态读取,一行代码的事,省掉后面排查的大半天时间。

2.4 Letterbox预处理:为什么不能直接把画面拉伸到640×640

模型训练时输入是640×640正方形,但相机画面一般是1920×1080。很多人图省事直接Resize到640×640,画面比例就变了,物体被压扁,小目标检测率直线下降。正确的做法是Letterbox:保持原图宽高比缩放,长边缩放到640,短边方向用灰色填充补齐,让整体还是640×640。

我用的是YOLO系列默认的填充值114、114、114。实现代码贴在下面,这个方法是熟手和新手的典型分水岭:

public static (Bitmap letterboxed, float scale, int padX, int padY) LetterBox(Bitmap src, int targetSize = 640) { 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); int padX = (targetSize - newW) / 2; int padY = (targetSize - newH) / 2; var dst = new Bitmap(targetSize, targetSize); using (var g = Graphics.FromImage(dst)) { g.Clear(Color.FromArgb(114, 114, 114)); g.DrawImage(src, padX, padY, newW, newH); } return (dst, scale, padX, padY); }

缩放到640×640之后,还要把Bitmap转成模型需要的张量:通道顺序从BGR转RGB、数据布局从HWC转CHW、数值从0-255归一化到0-1。这里我用了LockBits的方式,性能比GetPixel一个个取要快一个数量级:

private static float[] Preprocess(Bitmap letterboxed) { var rect = new Rectangle(0, 0, 640, 640); var bmpData = letterboxed.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); int stride = bmpData.Stride; byte[] pixels = new byte[stride * 640]; System.Runtime.InteropServices.Marshal.Copy(bmpData.Scan0, pixels, 0, pixels.Length); letterboxed.UnlockBits(bmpData); float[] tensor = new float[3 * 640 * 640]; for (int y = 0; y < 640; y++) { for (int x = 0; x < 640; x++) { int idx = y * stride + x * 3; byte b = pixels[idx]; byte g = pixels[idx + 1]; byte r = pixels[idx + 2]; int tIdx = y * 640 + x; tensor[tIdx] = r / 255f; tensor[640 * 640 + tIdx] = g / 255f; tensor[2 * 640 * 640 + tIdx] = b / 255f; } } return tensor; }

预处理做完,把tensor塞进DenseTensor去跑Session,这个环节就不多说了,三行代码的事。真正烧脑的是输出解析,下面单独讲。

3. 输出张量解析:25200个候选框是怎么变成画面上的绿框的

3.1 输出形状背后的检测头逻辑

ONNX Runtime给回来的结果是一个[1, 25200, 85]的张量。25200这个数字不是随便来的:YOLOv7在640×640输入下有三个检测头,分别在原图的1/8、1/16、1/32尺度上做预测,每个尺度用3个锚点。算一下就是(80×80×3) + (40×40×3) + (20×20×3),加起来正好25200。每一行对应一个锚点位置的候选框,85列分三段理解:第0到3列是框坐标,第4列是目标置信度,第5到84列是80个类别的得分。

理解这个结构之后,写解析代码就有底气了。注意一个隐患:不同版本的导出脚本,坐标格式不完全一样。有的输出中心点格式cx,cy,w,h,有的输出左上角和右下角x1,y1,x2,y2。判断方法很朴素——打印第一行数据,看取值范围。如果在0到640之间且格式是四个数加一堆分数,基本就是中心点格式。如果带负值或宽度高度跟位置数值量级差不多,就得再确认一下。

3.2 置信度过滤:目标置信度和类别分数相乘才是最终分数

从25200行里筛出有效目标,不是简单地看类别分数最大值就行。YOLOv7每个候选框自带一个objectness分数,表示"这里是不是有物体",要把这个分数和各类别分数相乘,得到该框属于某个类别的综合置信度。只取类别分数而忽略objectness,会漏掉很多低置信度的真目标;反过来只取objectness,你无法知道到底是哪一类。

我的做法是:先问objectness是否达到阈值,一般取0.25;再由80个类别里挑分数最高的那个,用objectness乘上它得到最终分数;最终分数没达到阈值的直接丢弃。这样一轮筛下来,25200个框通常只剩几十个候选。

3.3 NMS非极大值抑制:让重叠的框只留一个

筛完置信度之后,剩下的框还是大量重叠的——同一个物体可能被三四个锚点同时命中。NMS的思路很直白:按分数从高到低排序,保留分数最高的框,然后把它和剩下所有框算IoU交并比,IoU超过0.45的说明和它重合度太高,直接删掉,循环处理直到列表清空。C#实现如下,可以直接抄:

public class Detection { public string ClassName { get; set; } public float Confidence { get; set; } public float X { get; set; } // 中心点cx,640坐标系 public float Y { get; set; } public float W { get; set; } public float H { get; set; } } private static List<Detection> PostProcess(float[] raw, int numAnchors, int numClasses, float confThresh, float iouThresh) { int stride = 4 + 1 + numClasses; var candidates = new List<Detection>(); for (int i = 0; i < numAnchors; i++) { int baseIdx = i * stride; float objConf = raw[baseIdx + 4]; if (objConf < confThresh) continue; float bestScore = 0; int bestClass = -1; for (int c = 0; c < numClasses; c++) { float s = raw[baseIdx + 5 + c]; if (s > bestScore) { bestScore = s; bestClass = c; } } float finalScore = objConf * bestScore; if (finalScore < confThresh) continue; candidates.Add(new Detection { X = raw[baseIdx], Y = raw[baseIdx + 1], W = raw[baseIdx + 2], H = raw[baseIdx + 3], Confidence = finalScore, ClassName = _classNames[bestClass] }); } return Nms(candidates, iouThresh); } private static List<Detection> Nms(List<Detection> detections, float iouThresh) { var result = new List<Detection>(); var sorted = detections.OrderByDescending(d => d.Confidence).ToList(); while (sorted.Count > 0) { var best = sorted[0]; result.Add(best); sorted.RemoveAt(0); sorted.RemoveAll(d => IoU(best, d) > iouThresh); } return result; } private static float IoU(Detection a, Detection b) { float x1 = Math.Max(a.X - a.W / 2, b.X - b.W / 2); float y1 = Math.Max(a.Y - a.H / 2, b.Y - b.H / 2); float x2 = Math.Min(a.X + a.W / 2, b.X + b.W / 2); float y2 = Math.Min(a.Y + a.H / 2, b.Y + b.H / 2); float interW = Math.Max(0, x2 - x1); float interH = Math.Max(0, y2 - y1); float interArea = interW * interH; float unionArea = a.W * a.H + b.W * b.H - interArea; return unionArea <= 0 ? 0 : interArea / unionArea; }

上面这段代码默认坐标是中心点格式cxcywh,所以计算IoU时我手动转换成了左上角右下角形式。如果你的模型输出是x1y1x2y2格式,IoU计算部分要相应简化,这里需要灵活处理。

3.4 坐标还原回原图:Letterbox反算

后处理得到的目标坐标都在640×640坐标系里,但界面显示的是原始1920×1080画面,所以必须反算回去。之前Letterbox时记录了三个参数:缩放比例scale、水平填充padX、垂直填充padY。还原公式是:原图坐标 = (640坐标系坐标 - 填充偏移) / 缩放比例。绘制矩形框和标签的代码如下:

private void DrawBoxes(Image frame, List<Detection> dets, float scale, int padX, int padY) { using var g = Graphics.FromImage(frame); using var font = new Font("Microsoft YaHei", 12); using var pen = new Pen(Color.LimeGreen, 2); using var brush = new SolidBrush(Color.LimeGreen); foreach (var d in dets) { float x1 = (d.X - d.W / 2 - padX) / scale; float y1 = (d.Y - d.H / 2 - padY) / scale; float w = d.W / scale; float h = d.H / scale; g.DrawRectangle(pen, x1, y1, w, h); g.DrawString($"{d.ClassName} {d.Confidence:F2}", font, brush, x1, Math.Max(0, y1 - 24)); } }

这里有个GDI资源泄漏的坑:Pen、Font、Brush这些对象每次循环都new,用完后没有释放,程序跑几小时GDI对象数会涨到系统上限,最终整个程序画不出任何东西。我第一次跑的时候没注意,跑了半天界面突然黑屏,任务管理器一看GDI对象几万个。正确做法就是像上面这样,把频繁使用的画刷、画笔放进using块统一释放,或者直接定义为窗口字段复用。

4. 多线程与实时显示:为什么界面会白屏、卡顿甚至内存暴涨

4.1 线程模型:推理和绘制必须搬出UI线程

WinForm有个铁律:UI线程不能做耗时操作,否则界面会卡死、白屏、甚至被系统判定为"无响应"。YOLOv7推理一帧要上百毫秒,直接把Session.Run放在按钮点击事件里,界面必卡。我见过有人用Timer在Tick里跑推理,结果就是窗口拖不动、按钮点没反应。

我的方案是一个工作线程循环,负责"采集→预处理→推理→后处理→绘制→推送UI"整条流水线。UI线程只负责接收渲染好的Bitmap,然后显示出来。用CancellationToken控制启停:

private CancellationTokenSource _cts; private void BtnStart_Click(object sender, EventArgs e) { _cts = new CancellationTokenSource(); Task.Run(() => DetectLoop(_cts.Token)); } private void BtnStop_Click(object sender, EventArgs e) { _cts?.Cancel(); } private void DetectLoop(CancellationToken token) { using var cap = new VideoCapture(0); if (!cap.IsOpened()) { MessageBox.Show("无法打开相机"); return; } using var frame = new Mat(); while (!token.IsCancellationRequested) { if (!cap.Read(frame) || frame.Empty()) continue; using var bmp = OpenCvSharp.Extensions.BitmapConverter.ToBitmap(frame); var (letterboxed, scale, padX, padY) = LetterBox(bmp, 640); var tensorData = Preprocess(letterboxed); var raw = Infer(tensorData); var dets = PostProcess(raw, 25200, 80, 0.25f, 0.45f); DrawBoxes(bmp, dets, scale, padX, padY); BeginInvokeUI(() => { var old = picBox.Image; picBox.Image = bmp; old?.Dispose(); lblInfo.Text = $"目标数: {dets.Count}"; }); } } private void BeginInvokeUI(Action action) { if (IsHandleCreated) BeginInvoke(action); else action(); }

这里有个细节要注意:工作线程里创建的Bitmap,通过BeginInvoke扔给UI线程后,工作线程绝不能自己Dispose,否则UI拿到手就是一张坏图。正确的释放时机是UI线程下一次换图之前,把上一次的旧图释放掉。这也是内存暴涨的根源——如果不释放旧图,PictureBox的Image属性每次被替换后,旧Bitmap成了无根对象又没被及时回收,内存一路涨上去。

4.2 PictureBox刷新与跨线程调用的选择

跨线程更新UI,很多人用Invoke而不是BeginInvoke。两者差别在于:Invoke会阻塞工作线程直到UI执行完,BeginInvoke是异步丢过去立刻返回。在视频循环里用Invoke,工作线程的节奏会被UI拖死,帧率直接下降一半。所以我上面统一用BeginInvoke。

另一个小优化是把PictureBox的SizeMode设为Zoom,这样相机画面是1920×1080时,显示控件会自动缩放,绘制代码不需要关心控件尺寸。代价是缩放显示会有轻微模糊,但对目标检测场景完全够用。

4.3 实测帧率:你的机器能跑到多少

我在一台普通办公电脑上(i5级别的CPU,无独立显卡)实测,YOLOv7标准模型640×640输入,CPU推理单帧大约120到180毫秒,加上预处理和绘制,整体大约6到8帧每秒。这个帧率对"看画面做判断"的工业场景勉强可用,但谈不上流畅。如果换成yolov7-tiny,单帧能压到40到60毫秒,帧率能到15到25帧,体验好很多。

如果你的项目对实时性要求高,两个方向:一是在导出模型时就考虑用tiny版本或者把输入尺寸降到416或512,这会让精度小幅下降但帧率翻倍;二是走GPU,onnxruntime-gpu配合一张普通独立显卡,640输入跑标准YOLOv7能到30到60帧。GPU版本要额外注意CUDA、cuDNN和onnxruntime-gpu三者的版本严格对应,这一块我后面还会讲到。

5. 串口协议设计:让上位机和下位机用同一套行话沟通

5.1 为什么必须自定义帧协议

串口就是一根线,传过去的是一堆字节。下位机怎么知道这堆字节里哪几个是帧头、哪几个是数据、数据到哪结束?靠的就是协议。很多人图省事直接发字符串,比如"detect 320 240",短距离测试没问题,一旦信号干扰丢一个字节,下位机解析就错位了,整个通信直接崩溃。

所以我设计了一个带帧头、长度、校验的二进制协议。帧结构如下:

字段帧头1帧头2长度命令字数据校验
字节数1111N1
值/含义0xA50x5A命令字+数据长度命令编号业务数据前4+N字节的异或

帧头固定为0xA5 0x5A,下位机一看到这两个字节就知道新帧开始。长度字段控制切帧边界,防止粘包。校验用异或,把帧头、长度、命令字和全部数据逐字节异或,保证任何一个字节传错都能被发现。

5.2 串口收发与粘包、半包处理

C#里用SerialPort操作串口很简单,配置好参数订阅DataReceived事件即可。真正的坑在事件回调:DataReceived可能一次给你半帧,也可能一次给好几帧粘一起。所以必须在内存里攒一个接收缓冲区,不断往里追加,然后循环解析出完整帧再处理:

private SerialPort _serial; private readonly byte[] _rxBuffer = new byte[4096]; private int _rxLen = 0; private void OpenSerial(string portName, int baudRate = 115200) { _serial = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _serial.DataReceived += OnDataReceived; _serial.Open(); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var sp = (SerialPort)sender; int available = sp.BytesToRead; if (_rxLen + available > _rxBuffer.Length) { _rxLen = 0; // 缓冲区溢出,丢弃重来 } available = sp.Read(_rxBuffer, _rxLen, available); _rxLen += available; int consumed = ParseFrames(_rxBuffer, _rxLen); if (consumed > 0) { Array.Copy(_rxBuffer, consumed, _rxBuffer, 0, _rxLen - consumed); _rxLen -= consumed; } } private int ParseFrames(byte[] buf, int len) { int i = 0; int consumed = 0; while (i < len) { if (buf[i] != 0xA5 || i + 1 >= len || buf[i + 1] != 0x5A) { i++; continue; } if (i + 3 >= len) break; // 长度字段还没收齐 int dataLen = buf[i + 2]; int frameLen = 3 + dataLen + 1; if (i + frameLen > len) break; // 半包,等下一批数据 byte checksum = 0; for (int j = i; j < i + 2 + dataLen; j++) checksum ^= buf[j]; if (checksum == buf[i + 2 + dataLen]) { byte cmd = buf[i + 3]; byte[] payload = new byte[dataLen]; Array.Copy(buf, i + 4, payload, 0, dataLen); OnFrameReceived(cmd, payload); i += frameLen; consumed = i; } else { i++; // 校验失败,跳过一字节继续找帧头 } } return consumed; }

发送端封装同样要组帧。以下是发送一个命令的通用方法,检测线程里直接调用:

public void SendCommand(byte cmd, byte[] payload) { int len = payload?.Length ?? 0; byte[] frame = new byte[3 + len + 1]; frame[0] = 0xA5; frame[1] = 0x5A; frame[2] = (byte)len; frame[3] = cmd; if (payload != null) Array.Copy(payload, 0, frame, 4, len); byte sum = 0; for (int i = 0; i < 3 + len; i++) sum ^= frame[i]; frame[3 + len] = sum; _serial.Write(frame, 0, frame.Length); }

5.3 与检测线程的联动:发送节流和确认机制

检测线程每帧都可能产出目标坐标,但下位机执行机械动作是需要时间的,你不能每帧都把新坐标砸过去,否则下位机永远在执行上一条指令。我的做法是引入"变化才发"策略:记录上一次下发的坐标,只有当新坐标与上一次偏差超过阈值(比如10个像素)时才重新下发。另一个办法是固定节流,比如每5帧最多发一次。两种手段可以叠加。

协议指令表我设计成这样:

命令字方向含义数据内容
0x01上位机→下位机开始检测无
0x02上位机→下位机停止检测无
0x03上位机→下位机目标坐标下发类别号1字节 + 中心X两字节 + 中心Y两字节
0x04下位机→上位机动作执行完成状态字1字节

0x03是核心指令。我特别定义了类别号加坐标的复合数据格式,因为下位机不同动作可能需要区分不同类别的物体。同时,下位机执行完动作要回一帧0x04,上位机收到后在界面上更新"已下发/已执行"状态,这样操作员能看到整个闭环流程,出了问题也容易定位是上位机没发还是下位机没执行。

另外注意DataReceived回调运行在后台线程,如果你在这个回调里直接更新WinForm控件,会抛跨线程异常。要么像前面一样用BeginInvoke,要么把收到的消息扔进队列,由UI定时器去拉取。

6. 检测结果持久化:SQLite落库和异步写入的完整设计

6.1 为什么选SQLite,表结构怎么定

现场工控机基本不会有数据库服务,更不可能指望客户给你装一个SQL Server。SQLite单文件、零配置、随程序部署,是最稳妥的选择。微软官方有Microsoft.Data.Sqlite这个NuGet包,直接操作,不需要安装任何额外软件。

检测记录的核心数据是:什么时间、什么类别、置信度多少、目标在画面哪个位置。表结构我设计如下:

CREATE TABLE IF NOT EXISTS detection_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, class_name TEXT NOT NULL, confidence REAL NOT NULL, cx INTEGER NOT NULL, cy INTEGER NOT NULL, w INTEGER NOT NULL, h INTEGER NOT NULL, source TEXT ); CREATE INDEX IF NOT EXISTS idx_detection_log_ts ON detection_log(ts);

ts用本地时间字符串保存,格式"yyyy-MM-dd HH:mm:ss.fff",排序和查询都方便。cx、cy、w、h存的是还原后的原始画面坐标,不是640坐标系里的坐标,这样后面做统计分析、回放标注时候不用再做二次换算。source字段用来区分多路相机,如果你后来扩展到两路摄像头,这个字段就能派上用场。

6.2 高频写入的坑:不要在检测线程里直接落库

每帧都写一条记录,一天下来就是几十万条。如果直接在检测线程里同步执行INSERT,数据库写入开销会直接拖累检测帧率。而且SQLite本身对高频单条INSERT并不友好,频繁提交事务还会遇到锁冲突,多个线程同时写会导致"database is locked"错误。

我的方案是引入一个写入队列:检测线程只把结果扔进ConcurrentQueue,后台单独跑一个持久化任务,攒够一批再批量插入,所有写入都走同一个连接同一个锁。这样数据库操作跟检测流程彻底解耦,无论检测多快,落库都不会拖后腿。核心代码:

private readonly ConcurrentQueue<DetectionResult> _logQueue = new(); private readonly object _dbLock = new object(); private SqliteConnection _conn; private void EnqueueLog(DetectionResult det) => _logQueue.Enqueue(det); private async Task RunPersistenceLoop(CancellationToken token) { var batch = new List<DetectionResult>(50); while (!token.IsCancellationRequested) { batch.Clear(); while (batch.Count < 50 && _logQueue.TryDequeue(out var det)) batch.Add(det); if (batch.Count > 0) { lock (_dbLock) { using var tx = _conn.BeginTransaction(); using var cmd = _conn.CreateCommand(); cmd.Transaction = tx; cmd.CommandText = @"INSERT INTO detection_log (ts, class_name, confidence, cx, cy, w, h, source) VALUES (@ts, @class, @conf, @cx, @cy, @w, @h, @src)"; foreach (var d in batch) { cmd.Parameters.Clear(); cmd.Parameters.AddWithValue("@ts", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")); cmd.Parameters.AddWithValue("@class", d.ClassName); cmd.Parameters.AddWithValue("@conf", d.Confidence); cmd.Parameters.AddWithValue("@cx", d.Cx); cmd.Parameters.AddWithValue("@cy", d.Cy); cmd.Parameters.AddWithValue("@w", d.W); cmd.Parameters.AddWithValue("@h", d.H); cmd.Parameters.AddWithValue("@src", _sourceName); cmd.ExecuteNonQuery(); } tx.Commit(); } } await Task.Delay(200, token); } }

程序启动时打开SQLite连接、建表、启动持久化任务;关闭时先清空队列里剩余记录,再释放连接。这个队列加事务的方式,实测落几千条记录也就几百毫秒,对检测线程零干扰。

6.3 查询统计与导出:让数据真正能用起来

数据落库只是第一步,后面重要的其实是查询。现场人员最常问的问题是"今天检测了多少次,分别是什么类别"。这条SQL直接回答:

SELECT class_name, COUNT(*) AS cnt FROM detection_log WHERE ts >= '2025-01-15 00:00:00' AND ts < '2025-01-16 00:00:00' GROUP BY class_name ORDER BY cnt DESC;

历史记录查询界面用DataGridView绑定结果即可。导出CSV的需求也常有,用StreamWriter一行行写,注意加BOM,否则Excel打开中文会乱码:

private void ExportToCsv(string path, DateTime from, DateTime to) { using var writer = new StreamWriter(path, false, System.Text.Encoding.UTF8); writer.WriteLine("时间,类别,置信度,中心X,中心Y,宽,高,来源"); lock (_dbLock) { using var cmd = _conn.CreateCommand(); cmd.CommandText = @"SELECT ts, class_name, confidence, cx, cy, w, h, source FROM detection_log WHERE ts >= @from AND ts < @to ORDER BY id"; cmd.Parameters.AddWithValue("@from", from.ToString("yyyy-MM-dd HH:mm:ss.fff")); cmd.Parameters.AddWithValue("@to", to.ToString("yyyy-MM-dd HH:mm:ss.fff")); using var reader = cmd.ExecuteReader(); while (reader.Read()) { writer.WriteLine( $"{reader.GetString(0)},{reader.GetString(1)},{reader.GetDouble(2):F3}," + $"{reader.GetInt32(3)},{reader.GetInt32(4)},{reader.GetInt32(5)},{reader.GetInt32(6)},{reader.GetString(7)}"); } } }

时间戳这里我统一只用本地时间,因为工控机固定在现场,不会涉及跨时区部署。如果你做的产品要远程部署到多个地区,这个设计就需要重新考虑,最好存UTC再在展示层转换。

7. 整机联调复盘:卡死、漏检、丢包这些问题的根因

7.1 我遇到过的典型问题与排查路径

整个系统联调阶段,问题不是技术不会,而是不起眼的细节接二连三冒出来。我把几个有代表性的问题和解决路径整理成了一张表:

现象根因解决办法
程序运行内存持续上涨换图时旧Bitmap没有DisposePictureBox换图前释放旧图
界面卡成白板推理放在UI线程跑推理整体搬进工作线程,用BeginInvoke推送
模型加载时报不支持算子ONNX Runtime版本过旧升级ONNX Runtime或降低导出opset
串口指令偶发无效没有帧头同步和校验加0xA5 0x5A帧头和XOR校验
SQLite报database is locked多线程同时写库所有写入统一走队列,加锁
检测框位置偏左上或偏移Letterbox反算时忘了减pad按(坐标-pad)/scale公式严格还原

这里特别说一下检测框偏移的问题。现象是框画在物体左上方,距离真实位置有一段固定偏差。排查到最后,是因为我在反算时只除以了scale,却没有减掉padX和padY。由于Letterbox是居中填充,pad偏移是真实存在的,不减掉的话每个框都会朝左上角偏移,而且画面越接近正方形偏差越小,很容易被忽略。这种问题用尺子量一量显示器上的偏差距离,再对照padding值,基本就能锁定了。

7.2 性能优化复盘:同样一台机器,怎么把帧率提上去

整个系统跑通之后,我花了一天做性能调优。经验总结下来就几条。第一,推理尺寸是最大杠杆,从640降到512,帧率能提升大约30%到40%,精度损失在普通场景下几乎看不出来,但如果你的目标都是小物体,就要慎重。第二,不要每帧都画完整UI,把界面信息显示频率控制在刷新时更新一次就够了,别在循环里频繁设置Text属性。第三,GDI对象复用,Pen、Font、Brush实例化成本不低,反复创建既耗CPU又占句柄。

如果是GPU方案,onnxruntime-gpu的坑比CPU版多。CUDA版本、cuDNN版本、显卡驱动版本三者必须匹配,装错了大概率在创建Session时直接报DLL加载失败。我的建议是先跑通CPU版,再升GPU版;GPU版跑不通至少还有CPU兜底,不至于项目卡死。

7.3 项目做完后的一点体会

这套东西做下来,我最大的体会是:工业上位机项目里,算法能力只是第一公里,后面还有九公里在工程化。YOLOv7推理本身花了两天就通了,串口协议权衡设计花了将近一天,反而是SQLite批量写入、Bitmap释放、线程调度这些看似基础的东西,在整个联调过程中消耗了最多时间。

如果你也要做类似的东西,我建议在一开始就按"检测循环、通信模块、持久化模块"三条线分清楚边界,不要都塞在窗口类里。界面只做展示和参数配置,业务逻辑拆成独立的类,后面不管换模型还是换下位机,都不会牵一发而动全身。模型升级这个问题我已经验证过:同一个ONNX文件,输入输出结构不变,替换文件后上位机程序不用重新编译,这种解耦带来的维护便利,在实际项目里远比多写几个类要值。

返回列表