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

发布时间:2026/10/22 19:35:40
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_locationcpu) model.eval() dummy_input torch.randn(1, 3, 640, 640) torch.onnx.export( model, dummy_input, yolov7_custom.onnx, opset_version12, 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类那就是41510后面C#解析逻辑必须同步调整。2.2 C#侧依赖包与版本匹配的坑Visual Studio里创建一个.NET Windows窗体项目目标平台建议直接选x64不要选AnyCPU也别选x86。YOLOv7推理涉及大量张量计算x86下性能明显吃亏而且某些原生库的native依赖只提供了x64版本你选错平台会莫名其妙报无法加载DLL。NuGet里需要装的包就这几个Microsoft.ML.OnnxRuntimeCPU推理需要当前稳定版一般在1.16.x或1.17.x。OpenCvSharp4和OpenCvSharp4.runtime.win负责相机采集、Mat与Bitmap互转比System.Drawing单独处理图像要顺手。System.IO.Ports串口通信.NET 6以上这个包已经分出来了。Microsoft.Data.SqliteSQLite数据库访问。这里有个我踩过的版本坑ONNX Runtime对ONNX算子集版本(opset)有兼容范围如果你的模型导出时用了过新的opset而C#侧装了很旧的ONNX Runtime运行时会直接报不支持某算子。反过来也一样旧模型配新库一般没事但新模型配旧库大概率出事。建议导出时用opset_version12ONNX 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; } // 中心点cx640坐标系 public float Y { get; set; } public float W { get; set; } public float H { get; set; } } private static ListDetection PostProcess(float[] raw, int numAnchors, int numClasses, float confThresh, float iouThresh) { int stride 4 1 numClasses; var candidates new ListDetection(); 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 ListDetection Nms(ListDetection detections, float iouThresh) { var result new ListDetection(); 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, ListDetection 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这会让精度小幅下降但帧率翻倍二是走GPUonnxruntime-gpu配合一张普通独立显卡640输入跑标准YOLOv7能到30到60帧。GPU版本要额外注意CUDA、cuDNN和onnxruntime-gpu三者的版本严格对应这一块我后面还会讲到。5. 串口协议设计让上位机和下位机用同一套行话沟通5.1 为什么必须自定义帧协议串口就是一根线传过去的是一堆字节。下位机怎么知道这堆字节里哪几个是帧头、哪几个是数据、数据到哪结束靠的就是协议。很多人图省事直接发字符串比如detect 320 240短距离测试没问题一旦信号干扰丢一个字节下位机解析就错位了整个通信直接崩溃。所以我设计了一个带帧头、长度、校验的二进制协议。帧结构如下字段帧头1帧头2长度命令字数据校验字节数1111N1值/含义0xA50x5A命令字数据长度命令编号业务数据前4N字节的异或帧头固定为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 ConcurrentQueueDetectionResult _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 ListDetectionResult(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文件输入输出结构不变替换文件后上位机程序不用重新编译这种解耦带来的维护便利在实际项目里远比多写几个类要值。