ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# WinForms+OpenCvSharp实现实时图像与TCP检测结果同窗显示

C# WinForms+OpenCvSharp实现实时图像与TCP检测结果同窗显示 简介针对相机无法通过SDK直接取图、只能从本地文件读取场景这份C#工程源码提供了一套图像与通信联动的检测可视化方案。程序基于System.Drawing与System.Net.Sockets实现两路并行定时扫描本地文件夹并实时绘制最新图像同时监听TCP信号将二进制数据解码为字符串后同步显示在窗体上可用于工业监控、远程检测等需要图像与数据对应分析的场合。压缩包共80个文件大小18.64MB除核心cs源码外还包含dll运行库、xml配置说明、exe可执行程序、config/json配置文件以及第三方通信与序列化依赖如HslCommunication、Newtonsoft.Json工程结构较完整。目前已有70人学习适合熟悉C#、对TCP通信和窗体应用有一定基础又希望快速完成图像实时显示与网络数据可视化的开发者。通过这套代码可梳理线程管理、数据解码、界面刷新和异常处理等实现要点按自身场景替换取图方式与信号格式即可复用。1. 从本地实时拿图显示到窗口中接收 tcp 信号转字符串显示在窗体——这个可视化闭环到底在解决什么从本地实时拿图显示到窗口中接收 tcp 发送的信号转为字符串显示在窗体中——这个标题其实是检测类项目里最典型的上位机需求。算法端无论是 YOLO、传统视觉还是工业检测 SDK通常都在后台或远端跑只把结果通过 TCP 发出来界面这边要同时干三件事把本地摄像头画面的每一帧显示出来把 TCP 收到的字节转成能看懂的字符串再合成到同一个窗体里。下面用 C# WinForms OpenCvSharp 把闭环落地给出最小可运行代码、参数和踩坑记录重点讲透跨线程更新 UI 与 TCP 粘包这两个最容易翻车的地方。适合正在做产线联调、算法验证或实验台架可视化的朋友直接参考。2. 用 OpenCvSharp 把本地实时图像送进 WinForms 窗体取图链路与刷新策略2.1 先决定取图源摄像头、视频文件还是图像序列“本地实时拿图”这句话在不同项目里含义不一样。我见过三种做法打开默认摄像头、打开本地视频文件、按序号循环读图像序列。调试可视化链路时我一般建议先用视频文件把主流程跑通再切到摄像头。视频文件不会因为驱动、分辨率不支持或者被别的软件占用而中途罢工而且能反复回放同一段画面方便对比检测结果。在 WinForms 项目里安装 OpenCvSharp4 和 OpenCvSharp4.runtime.win 这两个包就能通过VideoCapture统一打开摄像头和视频文件。摄像头用数字索引0 是默认摄像头1 是第二个摄像头视频文件直接传路径。初始化时先判断IsOpened()否则后面Read会一直返回失败窗体上只会留一块空白 PictureBox。using OpenCvSharp; private VideoCapture _capture; private bool InitCapture(string source) { // 数字开头当成摄像头索引否则当成文件路径 if (int.TryParse(source, out int camIndex)) { // 用 DirectShow 后端打开 USB 摄像头兼容性比默认后端好 _capture new VideoCapture(camIndex, VideoCaptureAPIs.DSHOW); } else { _capture new VideoCapture(source); } if (!_capture.IsOpened()) { return false; } // 这两个参数不是所有摄像头都支持失败时 OpenCV 会忽略 _capture.Set(VideoCaptureProperties.FrameWidth, 1280); _capture.Set(VideoCaptureProperties.FrameHeight, 720); return true; }这里有个容易被忽略的细节VideoCapture.Set只是“请求”某个分辨率和帧率USB 摄像头如果不支持它会静默失败。所以初始化之后我习惯用_capture.Get(VideoCaptureProperties.FrameWidth)把实际分辨率读出来显示到窗体标题栏上。否则你以为是 1280x720实际可能只有 640x480后面画检测框时坐标比例就对不上了。2.2 两种刷新画面的方式UI 定时器与后台抓帧线程第一种方式是用窗体TimerTick 事件里抓一帧并显示。这种方式代码最简单心跳间隔设成 33ms理论帧率约 30 FPS。问题在于Read摄像头数据、BGR 转 RGB、转 Bitmap 这三步都跑在 UI 线程里一旦某帧耗时超过 33ms界面就会卡顿窗口拖动、按钮点击都会延迟响应。private Timer _refreshTimer; private void StartTimerMode() { _refreshTimer new Timer { Interval 33 }; _refreshTimer.Tick (s, e) { using var frame new Mat(); if (!_capture.Read(frame) || frame.Empty()) return; using var rgb new Mat(); // OpenCV 默认 BGRWinForms 的 Bitmap 需要 RGB必须转换 Cv2.CvtColor(frame, rgb, ColorConversionCodes.BGR2RGB); var bmp OpenCvSharp.Extensions.BitmapConverter.ToBitmap(rgb); pictureBox.Image?.Dispose(); pictureBox.Image bmp; }; _refreshTimer.Start(); }第二种方式是把抓帧放到Task.Run的后台循环里只把最终的 Bitmap 通过BeginInvoke丢回 UI 线程。这种方式帧率更稳摄像头读取和图像转换的耗时不会直接卡住窗体重绘适合后面要叠加 TCP 检测结果的场景。private CancellationTokenSource _captureCts new CancellationTokenSource(); private async Task CaptureLoopAsync(string source, CancellationToken token) { if (!InitCapture(source)) { MessageBox.Show(打开摄像头或视频文件失败); return; } using (_capture) { while (!token.IsCancellationRequested) { using var frame new Mat(); if (!_capture.Read(frame) || frame.Empty()) { // 视频文件播放到结尾时等待一会儿再继续 await Task.Delay(50, token); continue; } using var rgb new Mat(); Cv2.CvtColor(frame, rgb, ColorConversionCodes.BGR2RGB); var bmp OpenCvSharp.Extensions.BitmapConverter.ToBitmap(rgb); // 把 Bitmap 交给 UI 线程显示旧的 Image 控件引用要释放 pictureBox.BeginInvoke(new ActionBitmap(bitmap { var old pictureBox.Image; pictureBox.Image bitmap; old?.Dispose(); }), bmp); // 33ms 一帧约 30 FPS调试时改成 66ms 也能接受 await Task.Delay(33, token); } } }这段代码里最需要注意的是BitmapConverter.ToBitmap(rgb)的执行时机。OpenCvSharp 的BitmapConverter会把 Mat 里的像素数据复制出来生成一张托管位图所以在using块结束、Mat 被释放之后Bitmap 仍然有效。反过来如果你用某些包装库直接把 Mat 的内存地址共享给 BitmapMat 一旦释放PictureBox 绘制时就可能访问到已经被回收的内存图像会花掉或者程序直接崩溃。实操中尽量保持“转换出的 Bitmap 是独立数据”这条原则能省掉大量莫名其妙的 GDI 报错。2.3 显示性能参数分辨率、帧率与内存占用怎么权衡很多人在第一步就把分辨率设成 1920x1080、帧率设成 60结果 CPU 直接被打满。我们要先想清楚这个窗体是给谁看的检测服务的输出分辨率是多少。如果检测算法输入是 640x480你用 1080p 摄像头采集最后还是要缩放没必要白白增加 CPU 计算量。使用场景推荐分辨率目标帧率说明链路调试640x48015先把画面和 TCP 数据显示通检测框叠加1280x72025~30与主流检测模型输入分辨率接近大屏可视化1920x108015~20必须监控内存和 GDI 对象数帧率和Task.Delay的间隔不是严格反比关系因为Read本身也耗时。实测中想要 30 FPSDelay 设 33ms实际可能只有 25 FPS想精确限帧可以记录上一帧时间戳算出当前帧需要等待的剩余时间。另外抓帧循环里每帧都会创建一个 Bitmap如果BeginInvoke的速度跟不上UI 线程来不及处理积压的委托内存会一直涨。这种情况的典型表现是任务管理器里内存持续上升而 PictureBox 的画面并不流畅。解决思路是让 UI 线程只保存最新的一帧旧帧直接释放可以像上面代码那样先Dispose上一帧再赋值确保内存不涨。pictureBox.BeginInvoke(new ActionBitmap(bitmap { var old pictureBox.Image; pictureBox.Image bitmap; old?.Dispose(); }), bmp);这里的old?.Dispose()就是防止内存上涨的关键。如果漏掉这一句每帧生成的 Bitmap 都会残留运行半小时后 GDI 对象数会涨到上万系统开始卡顿最终抛异常。建议在窗体上放一个定时刷新的Label每秒读一次Process.GetCurrentProcess().GdiObjectCount连续观察几分钟只要数字没有单调上涨说明 Bitmap 释放是干净的。3. TCP 信号怎么收、怎么转字符串从 TcpListener 到 UI 显示3.1 先建一个独立的 TCP 服务端三次握手完成后才开始读数据检测服务的输出一般通过 TCP 客户端主动连到上位机。上位机开一个TcpListener监听固定端口等检测端连接。这里先明确关系TCP 三次握手是操作系统内核完成的AcceptTcpClientAsync返回时握手已经完成客户端和服务器之间已经建立一条可靠的字节流通道。我们应用层要做的事情是从这条通道里读出字节流再按协议切分数据。using System.Net; using System.Net.Sockets; private TcpListener _tcpListener; private CancellationTokenSource _tcpCts new CancellationTokenSource(); private void StartTcpServer(int port) { _tcpListener new TcpListener(IPAddress.Any, port); _tcpListener.Start(); // 接受连接不能卡 UI 线程放到后台 Task 里 Task.Run(() AcceptLoopAsync(_tcpCts.Token)); } private async Task AcceptLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { TcpClient client await _tcpListener.AcceptTcpClientAsync(); // 每个客户端独立处理避免一个客户端断开影响整个循环 _ Task.Run(() HandleClientAsync(client, token)); } catch (SocketException ex) { // 端口被占用、监听被停止都会走到这里 System.Diagnostics.Debug.WriteLine($Accept 异常: {ex.SocketErrorCode}); await Task.Delay(500, token); } } }监听地址选IPAddress.Any表示同时监听本机所有网卡地址这样检测服务无论是从本机 127.0.0.1 连接还是从局域网另一台机器连接都能连进来。如果只希望本机联调可以改成IPAddress.Loopback更安全一些。AcceptTcpClientAsync是异步方法不会占用线程资源但HandleClientAsync里的循环可能会长期运行所以每个新连接都放到独立Task里处理。3.2 接收字节流转字符串用 Decoder 而不是每次 GetStringTCP 是字节流没有自带的消息边界。检测服务一次Send发过来的数据在接收端可能分成两段到达也可能两次Send合并成一段一次收到。最常见也更省事的协议是约定“每行一条结果”字符串末尾带\n。接收端维护一个累积缓冲按换行符拆出完整消息。如果只是简单地把每次ReadAsync的结果当场GetString后直接显示遇到中文半个字符的情况就会出现乱码。原因在于Encoding.GetString每次都是独立解码。当一个 UTF-8 中文占三个字节前一次ReadAsync可能只收到两个字节后一次才收到第三个字节。用GetString分两次解第一次会生成一个替代字符第二次再生成半个。正确做法是用System.Text.Decoder保存跨读取边界的状态或者用StringBuilder把字节拼完再统一解码。using System.Text; private async Task HandleClientAsync(TcpClient client, CancellationToken token) { using (client) using (NetworkStream stream client.GetStream()) { var buffer new byte[4096]; // Decoder 会暂存未完成的多字节字符 var decoder Encoding.UTF8.GetDecoder(); while (!token.IsCancellationRequested) { int read await stream.ReadAsync(buffer, 0, buffer.Length, token); if (read 0) break; // 0 表示客户端正常关闭 int charCount decoder.GetCharCount(buffer, 0, read); var chars new char[charCount]; int actual decoder.GetChars(buffer, 0, read, chars, 0); string text new string(chars, 0, actual); AppendTextToForm(text); } } } private void AppendTextToForm(string text) { if (textBoxResult.InvokeRequired) { textBoxResult.BeginInvoke(new Actionstring(AppendTextToForm), text); } else { // 先用文本框把原始字符串显示出来方便排查 textBoxResult.AppendText(text Environment.NewLine); } }这段代码里的Decoder对象要长期复用不能每次循环新建。它的内部状态会保存上一次解码时未处理完的尾部字节下一次GetChars会把新旧字节合并解码这就是中文不会乱码的关键。注意GetCharCount是必须的因为 UTF-8 编码下字节数和字符数不成正比必须先算出目标字符数组长度。3.3 把字符串解析成结构化检测结果JSON 反序列化与字符串转数字字符串只是中间产物真正要让窗体做“检测可视化”还需要把检测结果变成结构化数据。常见做法是让检测端发送 JSON 字符串比如{cmd:detect,class:person,conf:0.93,x:100,y:200,w:50,h:80}其中x、y、w、h是检测框在图像坐标系中的位置单位是像素。这种格式用System.Text.Json直接反序列化最省事注意class是 C# 关键字需要加前缀。using System.Text.Json; public class DetResult { public string cmd { get; set; } public string class { get; set; } public double conf { get; set; } public int x { get; set; } public int y { get; set; } public int w { get; set; } public int h { get; set; } } private DetResult _latestDet; private void HandleDetectText(string line) { // 统一在这里做字符串比较识别不同命令 if (!line.StartsWith({)) { textBoxResult.AppendText(line Environment.NewLine); return; } try { var det JsonSerializer.DeserializeDetResult(line); if (det ! null string.Equals(det.cmd, detect, StringComparison.Ordinal)) { _latestDet det; labelStatus.Text $最近结果: {det.class} 置信度 {det.conf:P1}; } } catch (JsonException ex) { textBoxResult.AppendText($JSON 解析失败: {ex.Message}{Environment.NewLine}); } }如果检测端用的不是 JSON而是一行自定义文本比如class:person,conf:0.93,x:100,y:200,w:50,h:80也可以先用 C# 正则表达式把字段抽出来再做字符串转数字。int.TryParse和double.TryParse必须配对使用因为 TCP 信号里可能混入NaN或空字符串直接int.Parse会在 UI 线程抛异常导致窗体无响应。using System.Text.RegularExpressions; var match Regex.Match(line, class:(\w),conf:([\d.]),x:(\d),y:(\d),w:(\d),h:(\d)); if (match.Success) { string className match.Groups[1].Value; double conf double.Parse(match.Groups[2].Value); int x int.Parse(match.Groups[3].Value); // 后续按同样方式解析 y、w、h }这里要特别提醒坐标字段如果不是数字Regex匹配会失败match.Success为 false程序要跳过这行而不是直接崩溃。检测端偶尔会发一些非结果文本比如“模型加载完成”“连接正常”这些内容原样显示在文本框里更利于调试不要一股脑塞给解析器。4. 画面与 TCP 结果合一跨线程更新、绘制检测框与连接管理4.1 跨线程更新 UI后台线程不能直接碰控件WinForms 窗口属于 UI 线程所有控件句柄只能在 UI 线程里修改。TCP 接收循环跑在后台 Task 里直接写textBoxResult.Text会抛InvalidOperationException提示“从不是创建控件的线程访问它”。我见过的翻车现场里十个有九个是新手在后台线程里改控件然后来来回回换代码最后改成了Invoke和BeginInvoke混用。private void UpdateStatusLabel(string text) { if (labelStatus.InvokeRequired) { // 异步投递到 UI 线程不阻塞接收线程 labelStatus.BeginInvoke(new Actionstring(UpdateStatusLabel), text); } else { labelStatus.Text text; } }InvokeRequired是判断控件是否由当前线程访问的标准做法。当前是 UI 线程时直接赋值避免Invoke造成的额外开销当前是后台线程时BeginInvoke把委托投递到 UI 线程消息队列里方法立即返回接收线程不会被 UI 绘制速度拖慢。如果使用Invoke同步方式接收线程会等 UI 处理完再继续读 TCP极端情况下界面卡顿会反过来阻塞网络接收所以消息量大的场景优先用BeginInvoke。4.2 把检测框画到画面上在 Mat 上绘制还是 GDI 绘制拿到_latestDet之后最直接的做法是在每一帧的 RGB Mat 上画矩形和文字再转成 Bitmap 显示。这样原始图像和检测信息在同一个坐标系里比例不会错。using OpenCvSharp; private Bitmap RenderFrameWithDet(Mat bgrFrame, DetResult det) { using var rgb new Mat(); Cv2.CvtColor(bgrFrame, rgb, ColorConversionCodes.BGR2RGB); if (det ! null) { var rect new OpenCvSharp.Rect(det.x, det.y, det.w, det.h); Cv2.Rectangle(rgb, rect, Scalar.Red, 2); // OpenCV 的 PutText 只支持 ASCII中文会出现问号 string label ${det.class} {det.conf:P1}; Cv2.PutText(rgb, label, new OpenCvSharp.Point(det.x, Math.Max(0, det.y - 8)), HersheyFonts.HersheySimplex, 0.7, Scalar.Red, 2); } return OpenCvSharp.Extensions.BitmapConverter.ToBitmap(rgb); }这里有两个坐标陷阱。第一如果 PictureBox 的SizeMode是Zoom控件显示的画面被缩放但检测坐标是基于原始分辨率的框会画偏。解决办法是把原始坐标按缩放比例换算到控件坐标然后直接调用pictureBox.CreateGraphics().DrawRectangle在控件上绘制。第二如果检测端发的是归一化坐标0 到 1 之间一定要先乘以当前帧的实际宽度和高度再传给Rect否则框会画到图像外面。中文标签问题我一般这样做先把画面转成 Bitmap再用 System.Drawing 的Graphics.DrawString叠加文字中英文都能正常显示。这个方法比 OpenCV 的PutText实用得多。using System.Drawing; using System.Drawing.Drawing2D; private void DrawChineseLabel(Bitmap bmp, DetResult det) { using var g Graphics.FromImage(bmp); g.SmoothingMode SmoothingMode.AntiAlias; using var font new Font(微软雅黑, 12, FontStyle.Bold); using var brush new SolidBrush(Color.Red); g.DrawString(${det.class} {det.conf:P1}, font, brush, new PointF(det.x, Math.Max(0, det.y - 22))); }注意Graphics.FromImage创建的Graphics对象和Font、Brush都需要Dispose否则 GDI 对象会累积。如果只是短链路调试用using把它们包住比手动写finally更可靠。4.3 TCP 断线重连与关闭清理别让端口被占死检测服务经常重启上位机如果只Accept一次客户端断开后整个接收线程就退出了窗体不再显示新结果。更稳妥的做法是把监听和连接处理写成循环每个客户端断开后回到Accept继续等待下一个连接。private async Task ListenLoopAsync(CancellationToken token) { _tcpListener new TcpListener(IPAddress.Any, 9000); _tcpListener.Start(); while (!token.IsCancellationRequested) { try { using var client await _tcpListener.AcceptTcpClientAsync(); await HandleClientAsync(client, token); } catch (SocketException ex) { // 监听被中止或端口冲突 System.Diagnostics.Debug.WriteLine(ex.SocketErrorCode); await Task.Delay(1000, token); } } }这里特意用了“同步等待单个客户端处理完成再回到 Accept”的写法目的是避免多客户端并发时 UI 上分不清哪条消息来自哪个检测端。如果确实需要多个检测端同时接入就在Accept后创建独立任务并且给每个任务标记客户端编号。窗体关闭时一定要停掉监听否则进程虽然退出了端口要等系统释放再次启动就报only one usage of each socket address。private void MainForm_FormClosing(object sender, FormClosingEventArgs e) { _captureCts.Cancel(); _tcpCts.Cancel(); _tcpListener?.Stop(); _capture?.Release(); }CancellationTokenSource.Cancel()不会强制中断正在执行的任务只会让下一次循环检查到IsCancellationRequested后退出。所以ReadAsync那里要传入同一个 token这样窗体关闭时正在等待数据的连接也能被取消。_capture.Release()是必须的否则摄像头驱动会被一直占用下次调试时打开摄像头会报“设备被占用”。5. 检测可视化联调的常见排查摄像头、端口、乱码、粘包的 5 个现场5.1 摄像头打开成功但画面全黑或只有一帧现象窗体弹出来了画面要么全黑要么刚打开时显示一帧之后再也不刷新。原因最常见的是摄像头实际支持的分辨率和Set请求不一致OpenCV 虽然不报错但驱动只输出一帧后出错。另一种可能是 USB 摄像头被别的程序占用比如微信、相机应用导致读帧失败。解决初始化后立刻用Get读取实际分辨率先用摄像头的默认参数打开不主动设置任何值确认画面正常后再逐项调整。代码里把Read失败的情况打印出来不静默return至少弹到调试输出窗口。int actualWidth _capture.Get(VideoCaptureProperties.FrameWidth); int actualHeight _capture.Get(VideoCaptureProperties.FrameHeight); textBoxResult.AppendText($实际分辨率: {actualWidth}x{actualHeight}{Environment.NewLine});5.2 TCP 端口绑定失败only one usage of each socket address现象程序第二次启动或者调试时多开了一个实例监听端口直接抛SocketException提示only one usage of each socket address。原因上一个程序实例没有释放监听端口或者检测服务本身也监听同一个端口。Windows 默认情况下端口处于LISTENING状态时不能被第二个进程重复绑定。解决先用命令查端口占用确认是哪个进程。netstat -ano | findstr 9000输出最后一列是 PID接着用任务管理器找到该进程并结束后再启动上位机。代码层面在TcpListener.Start()外面套try/catch如果绑定失败给用户一个明确的提示不要直接崩溃。try { _tcpListener new TcpListener(IPAddress.Any, port); _tcpListener.Start(); } catch (SocketException ex) { MessageBox.Show($端口 {port} 被占用请检查是否有重复实例: {ex.Message}); }5.3 中文全部变成乱码现象检测端发的字符串里中文显示成???或者而英文字符正常。原因两种可能。一是发送端用 GBK/GB2312 编码接收端按 UTF-8 解码二是发送端按 UTF-8 编码但接收端每次ReadAsync后单独GetString遇到半个 UTF-8 字符。解决先和检测端确认编码统一改成 UTF-8。若检测端编码固定为 GBK接收端显式指定// .NET 6 及以上需要先注册代码页编码提供程序 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding gbk Encoding.GetEncoding(GBK);如果双方都是 UTF-8乱码仍然出现把GetString改成上文用过的Decoder方式。排查时可以把接收到的字节以十六进制打印到文本框用BitConverter.ToString(buffer, 0, read)格式看是E4 B8 AD这种合法 UTF-8 序列就说明发送端没错问题出在解码方式上。5.4 画面闪烁严重内存持续上涨现象画面可见闪烁运行几分钟后任务管理器内存涨几十兆GDI 对象数持续上升。原因PictureBox 默认没有双缓冲直接改Image属性会反复触发重绘出现闪烁。内存上涨多半是 Bitmap 释放不干净或者BeginInvoke投递的帧太多UI 线程来不及消化。解决窗体构造函数里把 PictureBox 的双缓冲打开然后打印每秒钟帧数和 GDI 对象数看趋势。// 构造方法里写一行 pictureBox.GetType().GetProperty(DoubleBuffered, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(pictureBox, true);进一步定位时在抓帧循环里加一个帧率计数器每秒把FPS和GDI 对象数显示出来连续观察三分钟。如果 GDI 对象数稳定在 500 到 1000 之间说明释放逻辑正常如果单调上涨优先检查old?.Dispose()是否真的执行了以及BitmapConverter.ToBitmap生成的 Bitmap 有没有被using或显式释放。int frameCount 0; DateTime lastCalc DateTime.Now; // 每抓一帧后 frameCount; if ((DateTime.Now - lastCalc).TotalSeconds 1) { labelFps.Text $FPS: {frameCount}; frameCount 0; lastCalc DateTime.Now; }5.5 TCP 粘包半包导致 JSON 解析失败现象文本框里看到的字符串是断的有时两行消息拼在一起JSON 反序列化频繁异常。原因TCP 字节流没有消息边界。发送端一次Send的 100 字节接收端可能分两次收到发送端两次Send的数据也可能被一次ReadAsync全部读出。所以不能假定“一次 ReadAsync 就是一条消息”。解决这是 TCP 应用层协议设计问题常见的解法是“按换行符分帧”。接收端维护一个StringBuilder把每次解码出的字符串追加进去然后按\n切分最后一段如果没有换行符说明是不完整消息留到下一次一起处理。private readonly StringBuilder _frameBuffer new StringBuilder(); private void ProcessStreamText(string chunk) { _frameBuffer.Append(chunk); // 按换行符切分出完整消息 string[] lines _frameBuffer.ToString().Split(\n); // 最后一段没有换行符保留在缓冲区里 _frameBuffer.Clear(); _frameBuffer.Append(lines[lines.Length - 1]); for (int i 0; i lines.Length - 1; i) { string message lines[i].TrimEnd(\r); if (message.Length 0) { HandleDetectText(message); } } }这里把切分逻辑从HandleClientAsync里抽出来了因为粘包问题属于协议层不该和网络读取混在一起。检测端如果发的是不带换行符的定长包则改用“前 4 个字节表示包体长度”的协议接收端先读满 4 字节长度再读对应长度的包体。这两种方式选一种检测端和上位机定好即可上了生产环境后不能再改协议格式。6. 把可视化窗体升级成检测看板右边状态栏、压测方法和一个调试习惯6.1 右边栏加一个状态统计区让窗体不只是一块画布到现在为止窗体已经能实时显示画面和检测结果但离“可用”还差一点操作人员希望一眼看到累积目标数量、当前连接状态和最近几条检测记录。做法很简单在窗体右侧放一个ListBox和一个Label组。每收到一条有效检测结果就往ListBox顶部插入一条记录保留最近 50 条Label显示当前 TCP 连接状态和累计帧数。这样画面、字符串、状态信息都集中在同一个窗体内调试时不用在多个窗口之间来回切换。甚至可以调整窗体Opacity做一个半透明悬浮窗让检测信息浮在别的软件上方作为大屏可视化的一种轻量形态。6.2 用模拟客户端压测整套接收链路联调前先做一次压力自测能发现大多数性能问题。写一个简单的 TCP 客户端每秒发 30 条 JSON 检测结果持续 3 分钟观察上位机窗体的帧率、GDI 对象数和内存占用。如果窗体操作出现明显卡顿优先检查AppendTextToForm是否直接把每一条消息都追加到文本框里。文本框行数过多时重绘开销很大应该限制最大行数并定时把超出的部分移除。压测不通过时不要急着加锁先看是 UI 绘制瓶颈还是网络接收瓶颈再把缓冲区控制住。6.3 保留原始图源叠加层单独管理我现在的习惯是抓帧线程永远保留一份原始 Mat 的引用检测框和文字不直接画在原始图上而是画在它的副本上。这样出现误报时按下快捷键就能保存一份没有叠加信息的原图用来定位检测算法的问题。这个习惯在产线调试里帮了我很多次因为一旦把检测框画死在原始图上你就永远拿不到干净的回放数据了。最后希望这个方案能帮你把检测可视化链路跑通少踩几次跨线程和粘包的坑。本文还有配套的精品资源点击获取
返回列表