ARTICLE DETAIL

资讯详情

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

基于C# WinForms的工业相机本地取图与TCP信号同步可视化实现

基于C# WinForms的工业相机本地取图与TCP信号同步可视化实现 简介这是一份面向C#开发者的图像处理与网络通信可视化参考工程解决相机无法通过SDK直接取图时从本地文件夹实时读取图像并显示同时接收TCP信号并转换为字符串展示在窗体中的检测可视化需求适用于工业监控、远程诊断等需图像与数据同步分析的场景。资源共80个文件以C#源码.cs、工程配置文件.sln/.csproj、依赖类库dll/nupkg/xml为主压缩包整体约18.64MB目录结构清晰便于直接定位核心窗体与逻辑模块。工程包含完整窗体设计、日志记录辅助类、基础网络通信配置并集成HslCommunication、Newtonsoft.Json等常用库可快速还原开发环境。已有70人学习下载适合正在搭建本地取图TCP数据可视化方案的中级C#开发者参考帮助理解文件夹轮询绘制、Socket异步接收、字节流转字符串及界面刷新的完整思路。1. 相机取不到图、信号却走 TCP本地目录加字符串上屏的可视化补位方案工业现场有类设备很拧巴相机没有公开 SDK或者 SDK 只给了保存接口采集软件把一帧帧样本写进本地文件夹你拿不到像素流但下位机那边偏偏通过 TCP 把检测结果和状态信号发过来两边数据对不上就没法做实时检测可视化。这个 C# 工程处理的正是这条链路后台按固定周期扫描本地图像目录把最新帧绘制到窗体同时用 TcpClient 收下 TCP 信号解成字符串直接显示在同一个界面上。它不追求花哨的图像算法胜在结构简单、复用性高适合工业监控、视觉检测看板、远程判图这类需要「图像 信号」同步展示的场景。如果你是做 WinForms 的老手这套代码可以作为模板快速改造成自己的取图加收数框架如果你是刚接触 C# 网络编程的初级开发者它也提供了一个不难上手的完整参考——从 Program.cs 入口到 Form1 窗体事件从轮询目录到 TCP 收发都是最小可用闭环。2. 文件清单里的工程骨架三个依赖库与三个核心 cs 文件的职责拆解把压缩包解开之后面对这些文件很多人第一反应是懵.vs、bin、obj是编译产物和 IDE 缓存不用管真正要看的是Vision_watch.sln对应的解决方案结构以及Form1.cs、Program.cs、log.cs三个逻辑主体。还有三个 NuGet 包写在packages.config里分别是HslCommunication.12.1.2、McProtocol.1.2.5、Newtonsoft.Json.13.0.1。这三个库基本框定了这个项目的三条能力边界。2.1 三个依赖库通信库、PLC 协议与 JSON 序列化管的边界先说HslCommunication。它是工业通信场景里非常常见的 C# 通信库封装了 TCP、串口、Modbus、三菱/西门子 PLC 协议等大量通道。在这个项目里它的价值不只是“能收发字节”而是把连接管理、重连、超时、报文校验这些脏活都收口了。你可以不直接new TcpClient而是用HslCommunication提供的高级客户端对象去连下位机代码量会少很多异常分支也会清晰一些。但它不是必须的。如果下位机就是一个裸 TCP 服务端只发送简单字符串直接用System.Net.Sockets.TcpClient也能做。所以HslCommunication在这个项目里的定位更像“能力储备”——后续如果要把通信对象从裸 TCP 换成三菱 PLC 的 MC 协议它的McProtocol命名空间可以直接顶上不用推翻重写。再说McProtocol。这是三菱 PLC 通信协议的具体实现服务于 Q、FX、L 系列 PLC 通过以太网以 MC 协议通信。很多视觉检测项目最终要把检测结果写回 PLC 做下位机联动或者从 PLC 读取当前工位。通过McProtocolTcpClient可以像读内存一样去读 PLC 的 D 寄存器、M 软元件读出来是OperateResultT结构看一眼IsSuccess就知道这次读写成没成比直接处理二进制帧要省心得多。最后是Newtonsoft.Json。这是 .NET 生态里最主流的 JSON 序列化反序列化库在这个工程里最典型的用法是把 TCP 收到的字符串按 JSON 解析成结构化对象。TCP 传输的字符串如果是{result:OK,count:100}这类报文用JObject.Parse或JsonConvert.DeserializeObjectT可以把检测结果、时间戳、OK/NG 标志拆出来再选择性地用控件展示——比如把 NG 的字符串标成红色。下面这个表格能直观看出三个库各自的边界依赖版本职责范围可替换方案HslCommunication12.1.2统一通信通道、连接管理、协议封装裸 SocketMcProtocol1.2.5三菱 PLC MC 协议读写无Newtonsoft.Json13.0.1TCP 字符串转结构化对象System.Text.Json2.2 Program.cs、Form1.cs、log.cs 与 App.config 的分工Program.cs是 WinForms 的入口核心就一行Application.Run(new Form1())它往往只有几行代码但所有生命周期都从这里开始。Form1.cs是主窗体的逻辑部分取图轮询、TCP 收数、字符串解码和 UI 刷新都会挂在它上面Form1.Designer.cs是设计器生成的布局代码控件位置、属性、事件绑定都写在这里一般不需要手改。Form1.resx是窗体的资源文件比如图标和默认图片。log.cs是项目里容易被忽略、但现场调试时最有用的类。工业环境里窗口跑在工控机上断网、文件锁、异常不会有人一直盯着所以凡是 TCP 重连、取图失败、解码异常这些事件都要追加进日志文件。最朴素的做法就是File.AppendAllText往固定目录写一行时间戳、消息级别和事件描述不追求结构化但排查问题上帮助极大。很多看似偶发的“运行几小时后不刷新”问题都是靠日志里最后一条 TCP 异常定位出来的。App.config保存可配置项TCP 服务端 IP 和端口、本地图像目录、轮询间隔等。典型配置长这样appSettings add keyImageDir valueD:\camera_capture / add keyTcpHost value192.168.1.50 / add keyTcpPort value6000 / add keyPollInterval value500 / /appSettings代码与配置分离这一点在现场很关键——图像目录和 PLC 地址往往与开发环境不一样现场调试时改配置比重编译程序安全得多。把这几个文件的关系理清后再读Form1.cs里的方法困惑会少很多。3. 本地实时取图实现轮询周期、文件共享与绘制策略相机无法通过 SDK 取图的场景里图像数据只能从磁盘目录来。第一个问题就是程序怎么及时发现新图像最简单的思路是FileSystemWatcher监听目录里文件的新增和变化事件。但实际项目里我不太推荐直接用它原因后面详细说更稳的做法是固定周期的轮询扫描。3.1 为什么用轮询而不是 FileSystemWatcher先说结论这个场景下轮询的可靠性比事件驱动更高关键原因有两条。第一工业现场图像目录经常落在网络映射盘或移动硬盘上FileSystemWatcher依赖文件系统变更通知网络盘上的通知机制并不总是可靠丢事件是常事第二相机采集软件写入图片是一个持续过程可能一边写一边触发Created事件你马上去打开文件大概率会撞上“文件正在被占用”的异常。而轮询是“我按固定间隔去看一眼”结合修改时间和文件名做去重能有效降低采集软件写入和程序读取之间的竞争。有一个反直觉的细节轮询间隔不是越短越好。每 200ms 扫一次目录没问题但每次扫描都做GetFiles在图像帧率低比如每分钟 1 帧的场景里就是纯额外开销。更合理的做法是把轮询间隔设成采集周期的一半左右采图周期是 1 秒扫描间隔放在 300~500ms 就足够感知到新帧。取图完成后记录文件名做比对去重避免同一帧重复加载。3.2 取图到上屏的关键代码去重、共享读取与复制下面是这个场景最核心的取图方法。我习惯把轮询放到System.Windows.Forms.Timer里因为它基于 UI 线程事件驱动刷新PictureBox不需要额外Invoke。代码里几个关键分支我写进注释private string _watchDir D:\camera_capture; private string _lastFile ; private void PollTimer_Tick(object sender, EventArgs e) { if (!Directory.Exists(_watchDir)) return; try { // 1. 取目录中最新的一张 BMP/JPG按修改时间倒序 var latest new DirectoryInfo(_watchDir) .GetFiles(*.bmp) .Concat(new DirectoryInfo(_watchDir).GetFiles(*.jpg)) .OrderByDescending(f f.LastWriteTime) .FirstOrDefault(); if (latest null) return; // 2. 文件名没变化就跳过避免重复刷新 if (latest.FullName _lastFile) return; // 3. 用 FileShare.ReadWrite 打开允许采集软件继续写文件 using (var fs new FileStream(latest.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) using (var img Image.FromStream(fs)) { // 4. 复制成新 Bitmap释放文件句柄避免占用原文件 var copy new Bitmap(img); pictureBox.Image?.Dispose(); pictureBox.Image copy; _lastFile latest.FullName; lblStatus.Text $当前帧: {latest.Name}; } } catch (IOException ex) { // 采集软件正在写文件时读取偶尔会撞锁记录后跳过 Logger.WriteLine($取图失败: {ex.Message}); } }这段代码重点在三个位置LastWriteTime排序确定了“最新帧”的定义采集软件写完文件后修改时间就是最后的时间点FileShare.ReadWrite让FileStream不独占文件否则采集软件正在写图时你的程序会直接打开失败new Bitmap(img)把图像复制一份这样PictureBox不再借用文件流里的数据文件流关闭后采集软件可以安心覆盖原文件。参数上需要注意轮询间隔在 Timer 的Interval属性里设置单位毫秒。网络盘路径做轮询时GetFiles可能因连接不稳定抛异常需要在 catch 里记录日志而不是让程序崩溃。采集软件如果写图速度特别慢可以加一个小重试失败后等 200ms 再扫一次连续三次失败才记日志。如果想要叠加十字线、ROI 框或检测结果PictureBox就不够灵活了改用Paint事件手动绘制更好。下面这段是自绘版本按窗口比例缩放并保持宽高比private Bitmap _currentImage; // 轮询线程里赋值 protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (_currentImage null) return; // 按窗体客户区比例缩放保持图像宽高比 var clientArea this.ClientSize; float scale Math.Min( (float)clientArea.Width / _currentImage.Width, (float)clientArea.Height / _currentImage.Height); int drawW (int)(_currentImage.Width * scale); int drawH (int)(_currentImage.Height * scale); int drawX (clientArea.Width - drawW) / 2; int drawY (clientArea.Height - drawH) / 2; e.Graphics.DrawImage(_currentImage, drawX, drawY, drawW, drawH); // 在这画十字线、ROI 框或检测结果文本 using (var pen new Pen(Color.Red, 2f)) { e.Graphics.DrawLine(pen, drawX drawW / 2, drawY, drawX drawW / 2, drawY drawH); e.Graphics.DrawLine(pen, drawX, drawY drawH / 2, drawX drawW, drawY drawH / 2); } }自绘版本里图像每次更新后要调用Invalidate()触发重绘而不是直接改PictureBox.Image。两种方式在这个方案里都可以选取决于你对叠加标注的需求。4. TCP 字符串信号接收异步 Socket、数据解码与跨线程上屏TCP 部分是这套方案的第二根柱子。取图解决“看到什么”TCP 解决“结果是什么”。字符串信号从上位机或检测主机发过来可能是 OK/NG 判定、PLC 工位状态也可能是 JSON 格式的检测数据包。要做的事只有三件建立连接、收字节流、转字符串然后上屏。4.1 异步接收代码拿字节流与处理粘包下面这段是典型的异步 TCP 接收骨架。它区别于ReadLine之类的同步阻塞写法不会卡 UI 线程。我用BeginRead加回调收到数据后再决定怎么解析private TcpClient _tcpClient; private NetworkStream _stream; private byte[] _buffer new byte[4096]; private StringBuilder _pendingData new StringBuilder(); private void ConnectAndStartReceive(string host, int port) { _tcpClient new TcpClient(); _tcpClient.Connect(host, port); _stream _tcpClient.GetStream(); _stream.BeginRead(_buffer, 0, _buffer.Length, ReceiveCallback, null); } private void ReceiveCallback(IAsyncResult ar) { try { int bytesRead _stream.EndRead(ar); if (bytesRead 0) { // 1. 按 UTF-8 解码本次收到的字节 string chunk Encoding.UTF8.GetString(_buffer, 0, bytesRead); _pendingData.Append(chunk); // 2. 按换行符拆包处理 TCP 粘包 string[] lines _pendingData.ToString() .Split(new[] { \n }, StringSplitOptions.None); _pendingData.Clear(); _pendingData.Append(lines[lines.Length - 1]); foreach (var line in lines.Take(lines.Length - 1)) { if (string.IsNullOrWhiteSpace(line)) continue; // 3. 跨线程上屏 this.BeginInvoke(new Action(() { txtSignal.Text line.Trim(); lblSignalTime.Text DateTime.Now.ToString(HH:mm:ss); })); } // 4. 继续等待下一段数据 _stream.BeginRead(_buffer, 0, _buffer.Length, ReceiveCallback, null); } else { // 对端关闭连接走重连逻辑 Logger.WriteLine(TCP 连接被对端关闭); } } catch (Exception ex) { Logger.WriteLine($TCP 接收异常: {ex.Message}); } }逻辑说明BeginRead不会阻塞读取动作交给线程池数据到达时回调触发期间 UI 线程该干嘛干嘛。StringBuilder _pendingData保存的是没有被完整消费的数据因为 TCP 是字节流一次BeginRead可能包含多条消息也可能只包含一条消息的前半段。按\n拆行是最朴素的拆包策略工业字符串信号很多是按行结尾的比如OK:12345\n。如果信号不是按行分隔而是固定长度或带长度头这一段要替换成对应协议。参数说明_buffer 4096字节单条字符串超过这个长度时多余数据会在下一次回调继续读不受影响BeginInvoke把更新动作交给 UI 线程队列避免在后台线程直接操作控件抛异常。建议把连接超时单独限出来用ConnectAsync(host, port).Wait(3000)或类似写法控制等待时间。4.2 跨线程上屏与显示区的刷新策略再细说跨线程。BeginRead回调跑在线程池线程上直接改txtSignal.Text会抛InvalidOperationException提示“线程间操作无效”。BeginInvoke是标准解法但要注意在窗体释放后再触发回调的情况。下面这段把显示逻辑抽到一个方法里顺便加上了状态颜色区分private void UpdateSignalDisplay(string message) { if (this.IsHandleCreated false) return; this.BeginInvoke(new Action(() { txtSignal.Text message; // 检测到 NG 关键字就标红现场一眼可见 if (message.Contains(NG)) txtSignal.ForeColor Color.Red; else txtSignal.ForeColor Color.Black; })); }代码里按内容判断状态字符串包含NG时红字否则黑字。这类可视化看板场景里颜色区分比人眼读文字可靠得多。如果收到的字符串本身是 JSON可以加一步JObject.Parse提取字段比如result、timestamp再把解析后的值传给显示方法而不是直接把原始报文显示出来。刷新频率也要控制。服务端每几十毫秒发一条信号时UI 不需要那么高的刷新率持续BeginInvoke会导致消息队列堆积窗体拖动都卡顿。常见做法是原始数据显示区只保留最近一条再用计数器滚动累加总接收数量文本控件不用被频繁 SetText。提示窗体释放前先把_closing标志位设为 true回调里判断后再上屏否则BeginInvoke触发时控件已销毁程序会抛ObjectDisposedException导致崩溃。5. 避坑指南本地取图加 TCP 显示最常见的五个故障现场这一章把同类项目里最容易翻车的地方汇总出来。按上面代码搭完大概率会撞上其中一两条每条我都按现象、原因、解决的顺序写。5.1 界面假死同步接收把 UI 线程堵死了现象程序启动后拖拽窗体完全没反应标题栏显示“未响应”几秒后可能恢复也可能一直卡死。原因TCP 接收用了同步stream.Read或未放回线程池的循环UI 线程阻塞在等数据上。尤其是把Connect和Read写在Form_Load里服务端没回应时Read一直挂着界面自然就死了。解决改异步BeginRead或把收发逻辑丢进后台Task。核心原则是 UI 线程不碰任何阻塞调用。连接超时也要单独设置限时等待后主动提示重试而不是无休止阻塞。5.2 中文乱码编码不匹配让字符串变成问号现象TCP 收到的字符串在调试窗口看着正常显示到窗体 TextBox 上却变成乱码或问号。原因发送端用 GB2312/GBK 编码接收端用Encoding.UTF8解码编码表对不上。工业设备很多默认走 GBK 编码尤其是国产 PLC 或串口透传模块。解决先从设备文档确认发送端编码拿不到文档就抓包看原始字节再用Encoding.GetEncoding(GBK)尝试。更稳妥的办法是在配置项里加一个EncodingName字段切换编码不用重新编译。5.3 图片闪烁重影PictureBox 刷新没启用双缓冲现象轮询取图时图像区域闪得厉害快速切换时尤其明显有时新旧帧交替闪现。原因PictureBox每次Image赋值都触发一次完全重绘未开启双缓冲时绘制过程先擦除背景再画新图肉眼看到的就是闪烁。解决在窗体构造里给PictureBox开启双缓冲最简单的是pictureBox.DoubleBuffered true;。自绘OnPaint时同样要开否则闪烁无解。还要注意pictureBox.Image?.Dispose()放在赋值之前旧位图不释放会造成内存持续增长。5.4 文件被占用读不出来采集软件还没写完就动手了现象程序偶尔报IOException提示“正由另一进程使用”或日志里反复出现取图失败。原因采集软件正在写文件时程序尝试打开FileStream读取采集软件可能以独占方式写文件读打开失败。BMP 动辄几十 MB写入窗口期越长冲突概率越高。解决FileShare.ReadWrite能缓解部分冲突但不是所有采集软件都配合。更稳的是加重试机制失败后等 200ms 再取一次连续三次失败才记录日志。另外可以优先看LastWriteTime而不是只按文件名排序必要时FileStream打开后先检查文件长度等 50ms 再读读不到就跳过这一帧。5.5 断线后收不到数据TCP 连接掉了没感知现象程序运行几小时后窗体上信号不再更新但程序本身不报错看起来一切正常。原因TCP 长连接中途被路由器或对端服务重启断开客户端没心跳和重连机制EndRead一直挂着界面自然没有新数据。解决用心跳机制。每 5 秒发一个Ping或空包连续 3 次没收到Pong就断开重连。同时监听ReceiveCallback里bytesRead 0的情况这个值表示对端正常关机需要立刻走重连逻辑。日志里也要记录重连次数和时间点方便现场判断网络质量。6. 进阶用 HslCommunication 的 McProtocol 直连 PLC把信号显示做成通用管线既然包里已经引入HslCommunication和McProtocol可以再往前走一步如果信号源不是裸 TCP 服务端而是 PLC怎么处理答案是改用McProtocolTcpClient直接读 PLC 寄存器把读到的数据当成字符串一样上屏using HslCommunication; using HslCommunication.Profinet.Melsec; McProtocolTcpClient mc new McProtocolTcpClient(192.168.1.10, 6000); mc.ConnectServer(); // 从 D100 开始连续读 20 个字转成字符串 OperateResultstring readResult mc.ReadString(D100, 20); if (readResult.IsSuccess) { UpdateSignalDisplay(readResult.Content); } else { Logger.WriteLine($PLC 读取失败: {readResult.Message}); }这段代码的意义在于不管裸 TCP 信号还是 PLC 寄存器数据最终都收敛到UpdateSignalDisplay这一个方法。做完这步显示管线就不再绑定某一种信号来源——裸 Socket、ModbusTcp、McProtocol 都是往同一个 UI 出口送数据。这也是我比较推荐的结构把取信号和显示信号解耦而不是让 Form1 里的代码塞满各种通信细节。验证方法也简单开发机上用 TCP 调试工具模拟服务端定时发送一行字符串比如OK:1001、NG:2003观察窗体显示区域能否在数百毫秒内刷新颜色是否正确变化。这样不等现场设备通电TCP 链路就能整个跑通到了现场把 IP 和端口改成真实值切到 PLC 或相机上位机地址直接复用同一套上屏逻辑。这个项目解压后就是完整的 VS 解决方案打开Vision_watch.sln可以直接编译运行。换成自己的目录、IP、端口时优先改App.config再动Form1.cs里的轮询和接收逻辑不要一上来就重构先用默认配置把链路跑通再逐步改成你的协议格式。这几年我拆这类工程最深的教训就是先让数据流全链路跑通再谈优化和重构。从那以后每接一个取图加收数的需求我都强制先搭一个最小显示管线把图像帧和信号字符串对齐到同一个 UI 线程上然后再往里面加协议细节。这个方法能省掉大量排错时间希望帮到你。本文还有配套的精品资源点击获取
返回列表