做机器视觉检测这些年,相机图片展示、工艺图片打包压缩、缺陷图片的类别查询,这三件事几乎是每套检测软件都要面对的硬需求。我最近整理了一套C#程序源码,把这三个功能串成一个小工具,正好给做机器视觉、质量追溯或者设备上位机开发的朋友提供一个现成的参考。这套代码不依赖某一家的硬件SDK,核心思路能直接抄到自己的检测项目里,该贴代码的地方我尽量贴全,遇到问题怎么排查也一起写了。
这个工具解决什么问题呢?产线上视觉系统拍到的图片,至少有三类人要用:现场操作工要盯着实时画面判断设备状态;品质工程师要在一堆工艺图片里找出当天的不良证据;主管则想按缺陷类别快速统计哪个工位问题最多。很多裸代码项目只做了检测算法,没人管图片怎么显示、怎么存、怎么找。这套源码就是补上这块短板的。
适合谁来读?一类是做WinForm上位机的C#开发,想看工业相机取流和图像存储的标准写法;另一类是机器视觉应用工程师,想在企业MES系统里加一个独立的缺陷图片管理模块。下面我把设计思路和关键代码一段一段拆开讲,全程不绕弯子,直接说结论。
1. 项目概述与整体设计思路
1.1 三个核心需求背后的真实场景
先还原一下我在某视觉检测项目上遇到的情况。一条组装产线上有两台相机,一台负责拍整个工艺过程,另一台专门盯着成品区域找缺陷。软件要在触摸一体机上把相机画面实时显示出来,同时每检测一个产品就要保存当前帧;到了换班或者批量生产结束,还得把这一时段的图片打包压缩上传到共享目录;最后,品质人员能打开历史记录,按“划伤”、“异物”、“缺料”这种缺陷类别筛选图片,再一键全屏看原始大图。
这三个需求单独拆开都不难,凑在一起就有讲究了:实时显示要求低延迟,打包压缩要求不阻塞检测流程,类别查询要求图片和业务数据能对得上。如果一边取流一边压缩,UI帧率马上掉;如果把图片和数据库分开存,类别字段一变查询结果就对不上。所以整体设计必须是“显示、归档、查询”三条线各管各的,中间通过约定好的目录结构和标识字段衔接。
1.2 技术选型为什么不绕弯子
用C#做这类工具,我直接选WinForms而不是WPF,理由很现实:工业现场的老旧工控机多,WinForms兼容性好,部署简单,机器视觉团队普遍也最熟悉它。相机SDK一般用厂商自带或者基于Halcon的接口,工具本身不采集原始数据,只负责接收图像回调。图片打包压缩用.NET自带的System.IO.Compression,不引第三方库;缺陷类别和图片索引用SQLite单文件库,方便整个文件夹一起拷贝迁移。
选型上有一条铁律:凡是相机厂商SDK能干的,不自己造轮子;凡是.NET框架自带能力够用的,不引入重依赖。这套工具里唯一需要自己多写几行的就是全屏显示窗口,因为WinForms自带的PictureBox在全屏模式下交互太弱,后面我直接用了一个自定义的透明窗口加缩放绘制。
整体结构大致分成四个模块: ICameraService —— 相机取流、图像回调、掉线重连 IImageArchiver —— 临时保存、打包压缩、清理磁盘 IQueryRepository —— SQLite索引、类别筛选、分页加载 FullScreenViewer —— 全屏显示、键盘翻页、缩放查看四个模块用接口串起来,之后就算你要把相机换成别的品牌,或者把SQLite换成SQL Server,只动对应实现类就行。
2. 相机图片展示:实时取流千万不能卡界面
2.1 取流线程模型与UI线程的配合
相机的图像回调是高频事件,工业相机60帧的都有,但WinForms的UI线程刷新一秒钟挂到60次必然吃不消,而且回调线程里直接操作PictureBox会抛跨线程异常。标准做法是:后台线程只负责把相机原始数据转成Bitmap,然后通过BeginInvoke丢给UI线程显示。
// 相机SDK回调中的一个典型实现 private void OnImageGrabbed(IntPtr frameAddr, int width, int height, int stride) { if (_disposed) return; var bmp = new Bitmap(width, height, stride, PixelFormat.Format24bppRgb, frameAddr); // 把显示动作交给UI线程,并做好旧图释放 this.BeginInvoke(new Action(() => { var old = pictureBox.Image; pictureBox.Image = bmp; if (old != null) old.Dispose(); })); }这里有个细节,frameAddr指向的是相机SDK内部的内存缓冲。如果直接拿来创建Bitmap,SDK下一帧就会覆盖这块内存,所以必须拷贝一份。稳妥做法是先创建一个可写的Bitmap,然后用Marshal.Copy把数据整块拷进去。上面那个写法在某些SDK里能用,是因为Bitmap构造函数会引用外部内存,不保险,我实际项目里都做了显式拷贝。
BeginInvoke有个隐患:如果UI线程正在做别的耗时操作,回调会被堆积。所以我加了丢帧策略:记录上一次显示的时间戳,只有距离上次超过50毫秒才更新画面,这样哪怕相机输出30帧,界面也只刷新约20次,触摸一体机上实测下来很稳。
2.2 图像对象释放的纪律
GDI+的Bitmap是托管对象底层的原生句柄,你不主动Dispose,垃圾回收不一定及时,跑一晚上就出“内存不足”或者“参数无效”这种诡异异常。我在这套源码里定了两条纪律:第一,pictureBox.Image每次替换前必须把旧对象释放;第二,凡是临时转换用的Bitmap一律用using包起来。
private void ShowFrame(byte[] rawData, int width, int height) { using (var bmp = PixelConverter.ToBitmap(rawData, width, height)) { var old = picMonitor.Image; picMonitor.Image = new Bitmap(bmp); // 复制一份,避免旧图被释放后PictureBox引用失效 if (old != null) old.Dispose(); } }第二条纪律看着多余,实际很关键。很多坑都出在复制这一步:PictureBox绑定的是同一个Bitmap实例,你顺手把临时图Dispose了,界面就变成一块白板。所以要么深拷贝一份再绑定,要么统一走“旧图释放、新图挂载”的模式。
2.3 花屏和断线重连的一次排查
有次产线反映画面每隔十几秒就闪一道彩色噪点。排查下来不是相机坏了,是我创建Bitmap时用的Stride不对。相机输出行字节数可能不是4的倍数,Bitmap构造函数里要求Stride等于原始缓冲区每行字节数,一旦少了,后面每行像素就开始错位。像1920×1080这种分辨率,行字节数7680刚好是4的倍数,但有些小分辨率就不是,必须用((width * 3 + 3) / 4) * 4去算实际行宽,拿到图像数据后逐行填补对齐。
断线重连又是另一类坑。相机网线被运维人员碰松一秒,SDK的取流线程直接异常退出,UI上画面定格,程序看着像死机。我给取流循环包了外层重连逻辑,异常后先释放相机句柄,等1秒重新初始化,最多重试5次。同时用一个状态指示灯告诉自己,绿的是在取流,黄的是重建中,红的是连接失败,这样即使重连不上,产线也知道设备状态而不是干着急。
3. 工艺图片打包压缩:先落临时目录再后台压缩
3.1 为什么不能边拍边压缩
一开始我想简单:每拍一帧图片,在内存里转成JPEG直接写进压缩包。结果是检测节拍被拖慢,相机缓存积压,到后面直接丢帧。原因很直白,JPEG编码本身就是耗时操作,压缩成Zip还有一个回写过程,全部顺序执行检测周期铁定超时。
后来改成“快速存临时目录,后台线程异步压缩”。业务流程变成:图像回调里把当前帧编码成JPEG写入一个临时文件夹,记录文件路径;检测完成一个产品后把业务数据写入SQLite;后台线程每隔5分钟扫一次临时目录,把已经归档完的图片打包成Zip,上传到共享盘后删除临时文件。这样检测线程只做一次JPEG编码,耗时可控,压缩在后台排队不抢UI资源。
3.2 目录结构和打包代码
图片保存最怕目录乱,我定了一套固定规则。以日期为根目录,往下按产线、产品批次、产品编号逐级建目录,缺陷图片单独放一个子目录。
2025-01-15/ Line01/ ProductA_001/ process/ // 工艺过程图 defect/ // 缺陷图 record.json // 该产品的结果信息 Line02/ ...打包直接用.NET自带的ZipFile,简单可靠。但要注意不要在回调线程里执行打包,统一交给Task后台任务。
public void ArchiveDirectory(string sourceDir, string zipPath) { // 压缩前先确保目标目录存在 var targetDir = Path.GetDirectoryName(zipPath); if (!Directory.Exists(targetDir)) Directory.CreateDirectory(targetDir); // 如果已有同名压缩包,加时间戳避免覆盖 zipPath = Path.Combine(targetDir, $"{DateTime.Now:HHmmss}.zip"); Task.Run(() => { ZipFile.CreateFromDirectory(sourceDir, zipPath, CompressionLevel.Fastest, includeBaseDirectory: false); Directory.Delete(sourceDir, true); }); }CompressionLevel.Fastest在这里是故意的。工业图片场景里图片本身已经是JPEG,再用最强压缩省不了多少体积,反而白白浪费CPU,实测Fastest和Optimal在照片类数据上体积差距不到3%。
3.3 压缩包内再打包一张索引清单
打包完成后,图片原始路径全丢了,类别查询时按什么找?我的做法是在每次打包前生成一个record.json放进去,里面记录每个图片文件的相对路径、缺陷类别、产品编号和时间。这样就算压缩包被拷贝到别的机器,只要目录结构完整,解压后也能恢复完整索引。
{ "batchId": "B20250115001", "productCode": "ProductA_001", "images": [ { "path": "process/01.jpg", "type": "process", "captureTime": "2025-01-15 10:22:11" }, { "path": "defect/02.jpg", "type": "defect", "category": "scrape" } ] }类别字段我存的是英文代码而不是中文。中文字符在编码转换时容易乱码,而且万一以后要改叫法,老数据就废了。存代码加一张映射表,界面显示中文,底层用代码做关联,麻烦一次,好处长期。
4. 类别查询与全屏显示缺陷图片
4.1 数据表怎么设计才不乱
单张表就够用,字段别贪多。我的SQLite表结构大概是这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键自增 |
| image_time | TEXT | 拍摄时间 |
| product_code | TEXT | 产品编号 |
| category_code | TEXT | 缺陷类别代码 |
| image_path | TEXT | 图片相对路径 |
| archive_path | TEXT | 所在压缩包路径 |
| synced | INTEGER | 0未上传,1已打包 |
category_code一定用固定代码表维护,查询时下拉框显示中文,筛选时翻译成代码执行SQL。如果直接把中文当条件,类别改名后所有历史记录都会查不到。
4.2 查询逻辑与分页细节
查询条件一般两个:时间范围加缺陷类别。写SQL时最忌讳字符串拼接,一定要参数化,防止哪天类别名里出现单引号直接炸掉。
private List<ImageRecord> QueryImages(string categoryCode, DateTime start, DateTime end, int pageIndex, int pageSize) { var sql = @"SELECT id, image_time, product_code, category_code, image_path, archive_path FROM image_records WHERE image_time >= @start AND image_time <= @end AND (@category = '' OR category_code = @category) ORDER BY image_time DESC LIMIT @pageSize OFFSET @offset"; // 参数赋值后执行,分页一次只拉50条 }缩略图列表用ListView的VirtualMode,只维护50个对象的实例,图片按需加载。很多人在这一步写成把所有记录全部加载再画缩略图,几千条记录程序就直接卡死。虚拟模式往下翻,才实时加载下一页,内存占用基本不变。
4.3 全屏显示窗口的几个实用交互
缺陷图片全屏显示不只是一个Form加一张大图,现场用的时候两个需求特别强烈:一是保持原始比例,不放变形;二是键盘能翻页,人站在设备前不用碰鼠标。
我做一个无边框、全屏的Form,把PictureBox的SizeMode设成Zoom,这样图片自动保持纵横比居中,上下留黑边也正常。然后重写OnKeyDown事件,支持空格切换下一张、Esc退出、回车看下一张。顺便加一个简单缩放,Ctrl+滚轮把PictureBox的缩放比例放大缩小,方便品质人员看缺陷细节。
public partial class FullScreenViewer : Form { private Bitmap _currentImage; private int _index; private List<ImageRecord> _records; public FullScreenViewer(List<ImageRecord> records, int startIndex) { InitializeComponent(); FormBorderStyle = FormBorderStyle.None; WindowState = FormWindowState.Maximized; TopMost = true; _records = records; _index = startIndex; LoadImage(); } private void LoadImage() { // 从压缩包里解压当前图,或用缩略图缓存 using (var fs = ZipFile.OpenRead(_records[_index].ArchivePath) .GetEntry(Path.GetFileName(_records[_index].ImagePath)) .Open()) { var old = _currentImage; _currentImage = new Bitmap(Image.FromStream(fs)); if (old != null) old.Dispose(); picFull.Image = _currentImage; lblInfo.Text = $"{_index + 1} / {_records.Count} {_records[_index].ProductCode}"; } } protected override void OnKeyDown(KeyEventArgs e) { if (e.KeyCode == Keys.Space || e.KeyCode == Keys.Right) { _index = (_index + 1) % _records.Count; LoadImage(); } else if (e.KeyCode == Keys.Escape) { Close(); } base.OnKeyDown(e); } }每次LoadImage都去压缩包里取图会导致卡顿,尤其Zip很大时。改进方案是首次查询时顺便生成一张100×100的缩略图存到本地缓存目录,全屏查看时才打开Zip解压原图。体验上打开第一张会有零点几秒延迟,翻到下一张就顺畅很多。
5. 踩坑记录:这套Demo里我处理过的典型问题
踩坑1:GDI+内存爆炸
症状是程序跑半天后内存占到2GB,然后弹出一个“内存不足”的错误。定位思路很简单:在Bitmap的Dispose方法里打一条日志计数,看图片对象创建量是不是远大于释放量。排查下来发现全屏显示的LoadImage里,每次翻页都new了Bitmap,但旧对象没在异常分支里释放。后来把所有图片切换统一放到finally里做清理,问题消失。记住一条经验,凡是用Image.FromStream或者new Bitmap,一定要在同一个方法的finally里写Dispose,别依赖垃圾回收。
踩坑2:压缩包文件被占用
ZipFile.CreateFromDirectory正在写文件时,另一个后台任务去读取同一个压缩包,会抛“文件正由另一进程使用”。我没有用锁去防并发,而是用文件锁加队列:归档层维护一个ConcurrentQueue,后台单线程消费,同一路径的任务串行执行。这样既不会并发写,也避免使用Lock避免死锁。缺点是不支持并发打包多个目录,但在这种单产线场景够用。
踩坑3:路径过长导致无法解压
WinForms项目如果放在桌面目录很深,图片路径超过200个字符就频繁报“找不到路径”。把压缩包内的entry命名改为相对路径,比如只保留“Process/01.jpg”这一段,不要从盘符开始拼。一旦引用绝对路径,图片在其他机器上解压后必然失效,这也是我没把完整路径存进数据库的另一个原因。
踩坑4:类别查询结果张冠李戴
有一次查询“划伤”类别,结果列表里出现很多“异物”的图。查下来是SQL里的LIKE查询误匹配,category_code里既有“scratch”又有“scratch2”,LIKE '%scratch%'把后者也带出来了。解决办法是查询时用“=”,不用LIKE,类别代码全精确匹配;如果需要模糊搜索,那就另加一个关键词字段做辅助过滤。
| 问题表象 | 根因 | 处理方式 |
|---|---|---|
| 画面出现彩色噪点 | Stride对齐错误 | 按公式计算每行字节数 |
| 相机掉线后界面假死 | 取流线程异常未捕获 | 外层重连循环 |
| 内存持续上涨 | Bitmap未释放 | 统一Dispose纪律 |
| 压缩包被占用 | 并发写同一文件 | 后台单线程队列 |
| 查询结果含无关类别 | LIKE模糊匹配 | 改用精确等值匹配 |
6. 最后分享一点产线上的实用心得
这套源码我陆续改了三版,最值钱的不是哪一段代码,而是把握住了三条时间线:取流线程只做显示,压缩任务在后台慢慢磨,查询操作永远分页。只要把这三件事按节奏分开,系统稳定性就有基础保障。
另外强烈建议在实际部署时把记录.json和压缩包按天滚动生成,别一个包越滚越大。超过一天的包归入历史目录,在线索引只保留最近30天,再久的记录让品质人员直接去共享盘翻压缩包,否则SQLite膨胀到几十万行,查询性能会肉眼可见地下降。
最后再给个经验,产线上的软件永远要允许“人比算法更可靠”。缺陷图片全屏显示、类别筛选、打包下载这一套流程,不只是给数据存档用的,更是给工艺改善提供证据链。我在查询界面还留了一个“导出报表”按钮,能把筛选结果连同图片清单导成Excel,品质例会直接投影用。你如果也正在做类似的功能,可以优先把这几个交互做顺,再去折腾花里胡哨的可视化界面。毕竟现场设备上,少一次报错、多看一眼缺陷图,比什么都实在。