ARTICLE DETAIL

资讯详情

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

C# WinForm视觉检测上位机:本地取图+TCP信号实时显示

C# WinForm视觉检测上位机:本地取图+TCP信号实时显示 简介这是一份基于 C# 的本地图像实时显示与 TCP 网络数据可视化示例程序适用于相机 SDK 受限、只能将采集图像保存到本地文件夹后再结合网络信号进行同步监测的工业上位机开发场景。资源包共 80 个文件压缩后约 18.64 MB以 .cs 源代码、.dll 依赖库、.config/.json 配置文件及可执行程序为主并包含 HslCommunication、Newtonsoft.Json 等网络与 JSON 处理组件便于直接打开和编译运行。已有 70 人学习适合有一定 C# 基础、正在做视觉检测上位机或 TCP 通信相关项目的开发者参考。该工程演示了如何通过轮询本地文件夹刷新窗体图像并在同一界面中接收 TCP 信号、将其解码为字符串实时显示可帮助读者快速掌握 C# 中 System.Drawing 绘图、Socket 通信、多线程及 UI 更新等技术完整项目结构和注释为二次开发提供了清晰起点。1. 为什么会有这种工程相机取不了图就从本地文件夹拿做工业上位机的朋友大概率遇到过这种事相机的 SDK 被供应商锁死、驱动装不上或者产线那台工控机根本不让你装 SDK但图像数据明明有——相机把采集到的样本一张张存到了本地文件夹里。这时候要出一个检测可视化界面最直接的办法反而是绕开 SDK从那个文件夹里实时抓最新的图像显示到窗体上再把上位机通过 TCP 发来的信号结果比如 OK/NG、坐标、缺陷类型解析成字符串同步刷在同一个窗口里。这份 vision_ClientNEW41 源码就是这个思路的完整落地C# WinForm 窗体本地取图 TCP 接收 字符串可视化适合做视觉检测上位机、远程监控客户端或者设备调试工具的开发者直接改。2. 认识 vision_ClientNEW41源码包结构、三件套依赖与运行入口2.1 源码包里的文件分别管什么解压开这个 rar 包整体是一个标准的 .NET Framework WinForms 工程不是 .NET Core也不是 WPF。解决方案文件 Vision_watch.sln 在根目录主工程叫 Vision_watch是典型的旧式 csproj 格式配合 packages.config 做 NuGet 还原这说明它是在 Visual Studio 2019 或更早版本环境下建的你直接用 VS2022 打开也能编译只是第一次会触发 NuGet 还原需要联网拉包。工程内部的核心文件没几个我逐个说文件作用Form1.cs / Form1.Designer.cs主窗体的逻辑代码和设计器代码取图、接收 TCP、显示字符串全在这Program.cs程序入口Main 函数启动 Form1log.cs自封装的日志类往本地写调试日志App.config应用配置TCP 的 IP、端口这类参数一般放在这里packages.configNuGet 包清单三个依赖的版本号就在这里Vision_watch.csproj工程文件编译入口favicon.ico窗体图标无关紧要这种结构的好处是几乎没有多余封装你打开 Form1.cs 从头读到尾就能搞懂整套逻辑。我一般拿到这种源码包第一件事不是看界面而是先查 Form1.cs 里的构造函数和 Timer Tick 事件确认取图和通信分别在哪个线程里跑。2.2 三个 NuGet 依赖HslCommunication、McProtocol、Newtonsoft.Jsonpackages.config 里列了三个包版本分别是 HslCommunication 12.1.2、McProtocol 1.2.5、Newtonsoft.Json 13.0.1。新手可能觉得这三个依赖都是干嘛的我用大白话拆一下。HslCommunication 是国内工业自动化圈很常用的通信库作者是苏州的胡工工业设备通信这块用它能省大量时间。这个库提供的 TCP 客户端封装比裸写 C# 原生 TcpClient 更省事而且自带断线重连机制、PLC 通信封装。McProtocol 是三菱 PLC 通信协议库如果你的检测信号来自三菱 PLC而不是一个自定义 TCP 服务端那走的就是这个协议。Newtonsoft.Json 则是 JSON 解析库如果 TCP 服务端发过来的不是纯字符串而是 JSON 结构比如{result:NG,code:D-102}就需要用它来反序列化。也就是说这套程序的信号来源有两种可能路径一是走 HslCommunication 或原生 TcpClient 接收自定义 TCP 字符串二是走 McProtocol 读取三菱 PLC 的寄存器值。绝大多数情况下用的是前者后者是给产线上有 PLC 的场景准备的。你编译前先确认引用有没有还原成功三个包都能在 packages 目录看到对应 DLL 就算 OK。3. 本地实时取图与绘制轮询目录、防闪烁与图片文件占用3.1 选轮询还是文件监视两种实时取图方案对比从本地文件夹实时取图业内无非两条路一是 FileSystemWatcher 文件监视靠系统事件通知你有新文件了二是自己开个定时器轮询目录每隔几百毫秒扫一次文件夹对比文件列表有没有变化。FileSystemWatcher 看起来更高明事件驱动不用空转但实际上坑不少。工业相机往本地存图经常是先写到一个临时文件名写完再改名或者一边写一边改文件长度Watcher 的 Created、Changed 事件会连续触发好几次你得自己加防抖逻辑。而且 Watcher 在某些网络映射盘、U 盘上工作不稳定文件多的时候事件风暴能把 CPU 吃满。所以我更习惯用 System.Windows.Forms.Timer 做轮询每 200ms 扫一次目录。这个方案虽然粗暴但胜在可控。文件监视适合出图频率低、目录结构稳定的场景轮询适合出图频率高、相机不断覆盖写文件的场景。相机的检测节拍一般在每秒几张到几十张200ms 的轮询完全够用还把代码逻辑降维成找最新文件 → 对比路径 → 加载显示三步调试起来舒服很多。3.2 轮询取最新图的代码与参数说明Form1.cs 里取图核心逻辑大致是这样我用最简代码还原一遍private readonly string imageDir D:\CameraSamples; // 相机保存目录 private System.Windows.Forms.Timer imageTimer; // 轮询定时器 private string lastImagePath string.Empty; // 上次加载的图片路径 private void InitImageTimer() { imageTimer new System.Windows.Forms.Timer(); imageTimer.Interval 200; // 轮询间隔 200ms太快会增加磁盘 IO imageTimer.Tick Timer_Tick; imageTimer.Start(); } private void Timer_Tick(object sender, EventArgs e) { // GetFiles 不支持多通配符直接过滤所以要自己循环拼 string[] files Directory.GetFiles(imageDir, *.*) .Where(f f.EndsWith(.jpg) || f.EndsWith(.png) || f.EndsWith(.bmp)) .ToArray(); if (files.Length 0) return; // 按最后写入时间倒序取最新一张 string newest files .Select(f new FileInfo(f)) .OrderByDescending(f f.LastWriteTime) .FirstOrDefault()?.FullName; if (string.IsNullOrEmpty(newest) || newest lastImagePath) return; lastImagePath newest; ShowImage(newest); }这段代码里两个关键点一是Directory.GetFiles(imageDir, *.*)只接收单个通配符你写*.jpg;*.png是直接抛异常的这一点我踩过坑老老实实先拿全量文件再内存里过滤二是OrderByDescending(f f.LastWriteTime)排序用的是最后写入时间不是创建时间。工业相机存图如果文件名是流水号自然排序没问题但如果文件名是时间戳或者纯随机名按创建时间排序会乱套LastWriteTime 最稳。真正把图片加载进窗体的方法是 ShowImageprivate void ShowImage(string filePath) { try { // 不能直接用 Image.FromFile它会持续锁住文件句柄 using (FileStream fs new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { Image img Image.FromStream(fs); if (pictureBox.Image ! null) { pictureBox.Image.Dispose(); // 释放上一次的位图资源 } pictureBox.Image (Bitmap)img.Clone(); } } catch (IOException) { // 文件正在被相机写入、暂时被占用跳过这一帧 } }这里有个非常容易翻车的细节Image.FromFile会一直持有文件句柄不释放。表面看程序能跑但相机端下次覆盖同一张图时会报文件被另一进程占用直接把相机的存图逻辑干崩。正确做法是FileStreamImage.FromStream并且用FileShare.ReadWrite允许相机边写边读。我最后用img.Clone()再复制一份位图是为了让using块结束、fs释放之后窗体上显示的 Image 对象不受影响。3.3 甩掉重影防闪烁与背景图拖影处理取图显示这块最恼人的问题是闪烁和拖影。默认的 WinForm 窗体没开双缓冲PictureBox 频繁刷新 Image 时画面会明显闪。尤其当窗体还设置了背景图比如产线操作界面喜欢用公司 Logo 做背景重绘时背景和图片交替刷新那个闪得叫一个酸爽肉眼可见的跳变。处理方式有两个层次。浅层做法是给窗体和 PictureBox 开双缓冲// 在 Form1 构造函数里加 this.DoubleBuffered true; // 减少窗体背景闪烁 pictureBox.DoubleBuffered true; // PictureBox 自身重绘优化深层做法是干脆不走 PictureBox直接用内存画布在 OnPaint 里用Graphics.DrawImage手动绘制。这种方案的原理是先把最新图像画到一张内存 Bitmap 上再一次性地把整张画布推给屏幕中间不会有擦了重画的过程重影和闪烁会大幅减少。如果你的画面刷新频率超过 20fps建议改成这种方案如果只是每秒几张图双缓冲就够用。另外注意pictureBox.SizeMode要设成Zoom否则图像的宽高比会被拉伸变形检测界面上比例一旦不对操作工看缺陷位置会看歪。相机分辨率如果是 1920×1080 量级PictureBox 直接显示是没问题的但如果到了 500 万像素以上大图每次更新都要注意及时Dispose()旧图像否则内存会像漏水一样涨。4. TCP 信号接收与字符串可视化异步接收、解码与跨线程刷新4.1 接收线程TcpClient 异步读取与字节解码TCP 接收这块程序里用的是 HslCommunication 还是原生 TcpClient 其实都行原理是通的。我按原生.NET方式给你讲清楚后面换成任何库都只是 API 的差异。接收 TCP 数据不能放在 UI 线程里跑否则一个Read阻塞就把界面卡死。正确做法是开一个后台线程循环读取网络流收到字节后转成字符串再通过委托切回 UI 线程更新窗体。这里我用async/await实现非阻塞读取private TcpClient tcpClient; private NetworkStream stream; private CancellationTokenSource cts; private async Task ReceiveLoopAsync(string host, int port) { tcpClient new TcpClient(); tcpClient.Connect(host, port); // 建立 TCP 连接三次握手交给系统 stream tcpClient.GetStream(); byte[] buffer new byte[4096]; while (!cts.IsCancellationRequested) { int len await stream.ReadAsync(buffer, 0, buffer.Length, cts.Token); if (len 0) { // 服务端主动断开len 为 0需要退出重连 break; } string msg Encoding.UTF8.GetString(buffer, 0, len); ProcessReceived(msg); } }关键参数是缓冲区大小buffer.Length。我习惯设 4096这个值不是随便拍的TCP 是流协议不存在一次发来就是一个完整消息的边界服务端发过来的数据可能被拆成多个 TCP 段也可能多个消息黏在一个段里。缓冲区 4096 足够容纳绝大多数检测信号——一条检测结果撑死几百字节设大了浪费内存设小了反而容易把一条消息截断成两半。如果服务端发的是大图或者大 JSON再调到 8192 或 16384。解码这里有个血泪经验Encoding.UTF8不是万能的。很多老的产线上位机用的是 GB2312 或 GBK 编码如果你固定用 UTF8 解码中文必然变成一堆乱码。我不知道你对接的那台上位机用的是什么编码但常见做法是优先尝试 UTF8 严格解码失败就回退到Encoding.GetEncoding(GBK)或者直接在 App.config 里加一个Encoding配置项让现场实施的人自己改。4.2 字符串拆包与信号可视化显示ProcessReceived不能拿到啥就显示啥TCP 流式的特性决定了你必须做拆包处理。我这里按换行符拆因为检测信号用\n做结束符是最常见的协议设计private StringBuilder buffer new StringBuilder(); private void ProcessReceived(string chunk) { buffer.Append(chunk); while (true) { int newlineIndex buffer.ToString().IndexOf(\n); if (newlineIndex 0) break; // 还没收到完整一行等下一段数据 string line buffer.ToString().Substring(0, newlineIndex).TrimEnd(\r); buffer.Remove(0, newlineIndex 1); if (!string.IsNullOrEmpty(line)) { OnSignalReceived(line); } } }这个拆包逻辑是这个程序里最重要的一个环节它处理了两个 TCP 高频问题一是粘包服务端连续发了OK\nNG\n底层 TCP 可能一次就全给你了二是半包服务端发abc你可能先收到ab再收到c。用StringBuilder做累积缓冲遇到换行符才切一条完整消息出来这样不管包怎么黏、怎么拆最终都能完整恢复出来。切出来的line是纯字符串再往下就是可视化的活了。信号显示部分我的建议是别只往 TextBox 里塞字符串而是做一层简单的信号解析。比如服务端发OK|样本001|缺陷划痕你可以在OnSignalReceived里按|拆分把 OK/NG 映射成窗体的状态灯颜色和一行醒目的文字private void OnSignalReceived(string line) { // 万一是 JSON 格式用 Newtonsoft.Json 解析 if (line.TrimStart().StartsWith({)) { try { dynamic json JsonConvert.DeserializeObject(line); string result json.result ! null ? json.result.ToString() : line; ShowResult(result); } catch (Exception ex) { log.Write(JSON 解析失败: ex.Message); } return; } ShowResult(line); } private void ShowResult(string str) { // 跨线程更新 UI 的切口 this.BeginInvoke(new Action(() { txtSignal.AppendText($[{DateTime.Now:HH:mm:ss}] {str}\r\n); // 追加到文本框 lblStatus.Text str.Contains(NG) ? NG : OK; // 状态栏同步 lblStatus.ForeColor str.Contains(NG) ? Color.Red : Color.Green; })); }这段代码跑到最后一行BeginInvoke时已经进入了 UI 线程所以操作控件是安全的。TextBox 用AppendText追加而不是一次性Text 替换是为了保留历史信号记录方便操作工回溯前几条检测结果。如果你想控制显示条数就在追加后检查txtSignal.Lines.Length 100时清掉最早那行。4.3 跨线程刷新 UI 的正确姿势跨线程操作控件为什么不能在后台线程直接写label.Text ...因为 WinForm 控件依赖 Windows 消息循环子线程直接改控件属性会导致消息泵不同步轻则闪烁重则抛InvalidOperationException提示线程间操作无效。网上有人让你在 Main 函数里写一行Control.CheckForIllegalCrossThreadCalls false把这个异常屏蔽掉。这个做法我强烈不推荐它就是掩耳盗铃屏蔽了异常但没解决问题线程竞争照样存在界面照样可能随机崩溃。正确的做法就是我上面写的BeginInvoke或Invoke。区别在于Invoke是同步的调用线程会卡着等 UI 线程处理完接收频率高的时候容易堵住后面的数据BeginInvoke是异步的丢给 UI 线程就继续读网络了高频信号下也不至于丢数据。所以这个项目里如果每秒好几条信号进来BeginInvoke是最合适的。5. 避坑记录五个现象→原因→解决的真实翻车现场5.1 图片文件被锁死相机端覆盖失败现象程序运行一段时间后相机保存目录里新旧图像交替时相机的存图程序报文件被另一进程占用甚至直接罢工。原因Image.FromFile()用完后GDI 仍然持有文件句柄你以为Dispose()了实际上句柄要等 GC 回收才释放相机端想去覆盖同名文件就撞上了锁。解决放弃Image.FromFile用FileStream(FileMode.Open, FileAccess.Read, FileShare.ReadWrite)打开文件流再走Image.FromStream。FileShare.ReadWrite这个权限位尤其重要它明确告诉操作系统我不介意别人同时读写这个文件。从那以后我写所有取图代码开头第一句必写FileShare.ReadWrite。5.2 轮询取到半张图画面花屏现象图像显示出来是半截的下半部分是灰色的或者画面明显撕裂一条线一分为二。原因相机还在往文件里写字节流时轮询线程已经读到文件并加载显示了。JPEG、BMP 这类格式读了一半解码器只能解出残缺图像。解决取文件列表时不能只看路径变化还得多加一道校验。最稳的做法是记录候选文件的LastWriteTime和Length连续两次轮询间隔 200ms 左右发现这两个值都不变才认为文件写入完成。如果相机是每秒好几张的高频节拍等一个周期也就 400ms完全不影响实时性。如果还出现花屏就在ShowImage里对Image.FromStream包try-catch捕获到ArgumentException就跳过这一帧。5.3 中文信号全变乱码现象TCP 发过来的中文在窗体上显示成锟斤拷或者淇℃伅。原因编码不一致。上位机用 GBK/GB2312 发你用 UTF8 解或者反过来。解决这个没有银弹必须知道对方的编码。有一个省事的兜底做法是先尝试 UTF8 严格解码用new UTF8Encoding(false, true)构造一个抛异常的 UTF8如果抛了编码异常就回退 GBKtry { var strictUtf8 new UTF8Encoding(false, true); string msg strictUtf8.GetString(buffer, 0, len); } catch (DecoderFallbackException) { string msg Encoding.GetEncoding(GBK).GetString(buffer, 0, len); }这套逻辑写在接收循环里能兼容绝大多数产线环境。如果整个项目范围可控我更建议在 App.config 里放一个Encoding: UTF-8配置项让现场部署的人按上位机实际编码改。5.4 跨线程更新控件界面随机崩现象程序跑着跑着突然弹出线程间操作无效请使用 Control.Invoke有时甚至不弹窗直接进程消失。原因后台接收线程直接操作了txtSignal.Text、lblStatus这些 UI 控件违反了 WinForm 的线程模型。CheckForIllegalCrossThreadCalls默认在调试模式下会拦但即使你关掉异常检查线程竞争也会造成偶发崩溃。解决所有 UI 刷新都走BeginInvoke委托把数据传给 UI 线程由 UI 线程自己改控件。收数据线程保证线程安全UI 线程保证显示稳定两者各司其职。这个事没有玄学就是规矩。5.5 TCP 断线后一蹶不振必须重开程序现象上位机重启、网线抖动、交换机断电之后客户端界面再也不更新了也不报错就是静悄悄地死了。原因TCP 连接建立时是三次握手但断线的时候如果对端非正常关闭断电、崩溃客户端这边不能立刻感知到。你那个ReadAsync会一直挂着直到 TCP 超时或系统发来 RST而默认超时时间可能有好几分钟看起来就是界面卡死了。解决两点。一是给读操作加超时控制用CancellationTokenSource.CancelAfter(3000)实现3 秒收不到数据就认为是连接死了二是在 UI 线程放一个保活 Timer每 2 秒尝试发一次心跳包比如发一个\n或自定义心跳指令连续 3 次没响应就主动断开重连private void Heartbeat_Tick(object sender, EventArgs e) { if (tcpClient null || !tcpClient.Connected) { _ Task.Run(() TryReconnectAsync()); // 异步重连 return; } // 正常情况发个心跳探测 }重连逻辑里我建议加一个指数退避第一次等 1 秒第二次 2 秒第三次 4 秒最大 30 秒封顶避免上位机还没起来时客户端疯狂重连占用资源。6. 进阶验证与调优用好 log.cs、模拟 TCP 发信号、盯死资源释放6.1 先把 log.cs 用起来别让程序当黑匣子这个源码包自带了 log.cs 这个日志类这是很多人会忽略的好东西。工业现场跑程序最怕的就是出问题的时候一点头绪都没有窗体上没有任何提示日志目录也是空的。你把 log.cs 里那个写日志的方法在关键节点都调用上窗体启动、TCP 连接成功、TCP 断开重连、每一次收到信号、每一次取图异常。日志内容不需要花哨时间戳加事件描述就够了log.Write($Signal received: {line}); log.Write($Image loaded: {filePath}, size: {img.Width}x{img.Height});日志是排障的后悔药尤其现场设备不在你手边的时候远程指挥操作工把日志文件发过来比啥都有用。6.2 本地模拟验证三板斧验证这套程序不需要真的接相机和上位机。我的习惯是在本机模拟完整链路先用一个 Python 脚本定时生成几张测试图片丢到取图目录再用另一个脚本模拟上位机通过 TCP 发信号import socket, time, shutil, os # 模拟 TCP 信号发送端 s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((127.0.0.1, 9000)) # 端口和 Form1 里配置保持一致 for i in range(10): msg fOK|样本{i1}|缺陷无\n.encode(utf-8) s.sendall(msg) time.sleep(0.5) s.close()把这段脚本跑起来窗体上应该每 500ms 跳出一条信号记录同时取图目录里的图片也在同步刷新。这一步能把取图线程、接收线程、跨线程 UI 刷新三个模块全部串起来验证发现问题先看是哪个环节断了。模拟图像生成就更简单了OpenCV 或者 Pillow 随便画几个色块存成 jpg 就行重点测的是程序对文件新增、覆盖、删除的响应。6.3 上线前最后一遍资源体检上线别的都好说资源释放是最后一道坎。我每写完一个这类程序都会用任务管理器盯着内存曲线跑半个小时。如果内存稳定在一个区间不涨说明pictureBox.Image.Dispose()和FileStream的using都做对了如果内存一路涨恭喜你肯定有某个Image对象没释放。取图刷新这种高频操作每次泄漏几十 MB跑一天就是几个 G现场必崩。秒表打一下帧率也是一个方法。在 OnPaint 或 Timer_Tick 里用Stopwatch统计从扫描目录到图像显示完成的总耗时正常应该控制在 50ms 以内。超过 100ms 就要检查是不是在 UI 线程里做了耗时操作比如GetFiles扫描的目录里文件太多。从那以后我每做一次这种本地取图 TCP 信号可视化的项目都强制自己走一遍模拟发信号、内存观察、日志回查这三步才敢把程序交付到产线上跑。这程序本身不复杂但每一步都有它自己的脾气把上面这些坑提前踩平现场会少很多麻烦。希望帮到你。本文还有配套的精品资源点击获取
返回列表