简介:一份基于C#语言的图形图像识别软件源代码,面向希望掌握图像处理与文字识别技术的开发者,可配合Windows窗体应用学习桌面端识别功能搭建。压缩包共26个文件,约792KB,包含6个C#源码文件、2个动态链接库、2个资源文件,以及可执行程序、调试信息和解决方案工程文件等,项目结构完整,便于直接加载与调试。已有616人学习下载,资源体量精简却覆盖完整开发环节,适合入门到进阶的C#开发者借鉴。源码覆盖图像加载与缩放、界面显示、鼠标选区交互、异常处理等关键环节,并可能集成开源OCR方案,帮助理解从图像采集到文字提取的完整链路;同时采用事件驱动模型,识别按钮触发的处理流程清晰易读。代码注释与文件组织对学习友好,附带的可执行程序可边运行边对照源码,快速验证效果,也可作为课程设计或工程入门模板。
1. 为什么我会在C#里做图形图像识别,而不是转头用Python
1.1 一个真实项目把我逼到了C#图像识别的路上
去年年中接了个视觉定位需求,客户产线上一台机械臂要抓取传送带上的金属零件,相机固定在支架上,抓拍一张图,系统要算出零件的中心坐标和旋转角度,然后把数据发给PLC控制机械臂动作。需求聊完,同事脱口而出:"这种活不是应该用Python吗?OpenCV都写好了,模型一跑就出结果。"我摇着头拒绝了。不是Python做不了,是这套产线旁边的电脑上全是C#写的上位机程序,和相机、PLC、扫码枪、MES系统打交道的SDK几乎全都是C#版本优先。与其用Python单独做一个识别服务,再用Socket跟C#上位机通信,不如直接在C#进程里把图形图像识别这块逻辑做掉,少一层网络通信,少一堆部署问题。
这篇文章就是把那套C#图形图像识别软件的关键源代码思路、项目结构、识别方案和踩坑记录完整拆出来。里面的代码段都是从可运行项目里抽出来的,不是那种网上复制过来自己都跑不通的片段。适合正在做上位机、视觉定位、缺陷检测、条码识别的C#开发者,也适合刚开始接触图形图像识别但不想从Python转过来的.NET程序员。
1.2 C#在工业视觉里的生态位置,比很多人想象中稳
很多人有一个刻板印象,觉得C#图像识别是个冷门方向,其实放眼工业视觉领域,C#反而是最主流的应用层语言之一。海康、大华、Basler这些工业相机厂商,提供的SDK里最完整的往往就是C#版本,官方Demo都是拿WinForms做的。OpenCV虽然最初是C++写的,但OpenCVSharp这个封装层把大部分核心API都搬到了C#下,性能损失在一个可接受范围内。如果你要跑深度学习模型,微软官方也有ONNX Runtime的C#库,YOLO系列、分类模型、分割模型都能直接加载推理。
再说部署。客户现场的工控机是什么环境你控制不了,但跑一个C#桌面程序只需要.NET运行时,打包成本比Python装Anaconda、配虚拟环境低太多。Python程序在客户机器上缺DLL、缺OpenCV、版本冲突这些事我见过不止一次。C#程序用发布单文件之后,拷过去就能跑,这个优势在项目交付阶段非常值钱。
1.3 常用C#图像识别库选型对比
给还没入门的同学一张表,选型的时候直接照抄:
| 库 | 底层实现 | 适合做什么 | 维护状态 | 缺点 |
|---|---|---|---|---|
| OpenCvSharp4 | OpenCV原生库P/Invoke封装 | 图像预处理、模板匹配、轮廓分析、相机标定 | 活跃 | API有学习成本 |
| Emgu CV | OpenCV封装,官方支持 | 老项目、需要商业支持 | 活跃 | 版本落后OpenCV |
| AForge.NET | 纯C#实现 | 简单图像处理、机器视觉教学 | 基本停更 | 性能弱,不建议新项目 |
| OnnxRuntime | 微软深度学习推理引擎 | 加载ONNX模型做分类、检测、分割 | 活跃 | 需要了解张量/NDArray |
| Tesseract | C++ OCR引擎封装 | 印刷体文字识别 | 活跃 | 手写体效果一般 |
我的结论很直接:图形图像识别主链路用OpenCvSharp4,深度学习推理用OnnxRuntime,两者配合能覆盖95%的识别人场景。AForge就别碰了,它现在连Overload OpenCV都不更新,新项目用纯C#实现的图像算法库在性能上撑不住工业相机几百万像素的图。至于Emgu CV,如果你之前用过它,继续用问题不大,但如果从零开始,我建议直接上OpenCvSharp4,社区活跃、踩坑答案好找。
2. 从零搭项目骨架:包引用与目录设计
2.1 创建项目与NuGet包清单
项目基于.NET 8.0 + WinForms,为什么不用WPF?项目里需要频繁显示相机画面和识别结果,WinForms里PictureBox直接指向Bitmap就行,简单直接。创建完项目之后,先干一件事:把项目属性的平台目标改成x64。这一步非常关键,OpenCvSharp4的runtime包原生库只有64位版本,默认AnyCPU在64位系统上跑也会出DllNotFoundException。
NuGet包清单就四个:
- OpenCvSharp4
- OpenCvSharp4.runtime.win
- OpenCvSharp4.Extensions(Bitmap和Mat互转需要)
- Microsoft.ML.OnnxRuntime
安装完这些,项目属性确认一遍:目标框架 .NET 8.0,平台目标x64。然后新建一个叫"图像处理"的文件夹,把代码分模块放好。不要一上来就把所有逻辑塞进Form1.cs里,图形图像识别项目一旦复杂起来,Form1.cs几千行代码会让你拆到怀疑人生。
2.2 项目目录结构怎么分
我实际使用的目录结构供你参考:
VisionDemo/ ├─ CameraLayer/ // 相机采集,获取Bitmap帧 │ └─ ICameraCapture.cs ├─ ImageProcessing/ // 灰度化、滤波、二值化、形态学 │ └─ ImagePreprocessor.cs ├─ Recognition/ // 模板匹配、轮廓分析、ONNX推理 │ ├─ IRecognitionEngine.cs │ ├─ TemplateMatcher.cs │ ├─ ContourAnalyzer.cs │ └─ OnnxClassifier.cs ├─ Models/ // 识别结果、矩形框、检测项 │ └─ RecognitionResult.cs └─ Ui/ └─ MainForm.cs每个模块只管自己的事。CameraLayer负责从相机SDK拿到Bitmap,采集线程把最原始的图像丢给ImageProcessing做预处理,预处理完再把干净的图像喂给Recognition模块。识别模块返回一个RecognitionResult对象,里面包含目标位置坐标、置信度、类别名称这些信息。UI层只做显示和参数配置,不碰算法。
这样分层之后,我在项目中期从海康相机换成Basler相机,只改了CameraLayer里的实现类,其他代码一行没动。这种解耦带来的收益,等你项目做到一半客户说要换硬件的时候就会深有体会。
2.3 定义一个识别引擎抽象
为了支持多种识别方案,我定义了一个IRecognitionEngine接口:
public interface IRecognitionEngine { RecognitionResult Recognize(Mat processedImage); }模板匹配、轮廓分析、ONNX分类器都是这个接口的实现类。MainForm里只需要维护一个IRecognitionEngine实例,切换算法时在UI上选一下,底层换实现类就行。这是整个软件源代码设计的核心思路,也是保证后续能扩展新算法而不破坏现有功能的关键。
3. 图像预处理管道:识别之前最关键的一步
3.1 从Bitmap到OpenCV的Mat
识别流程的第一步是把相机返回的Bitmap转成OpenCV能处理的Mat。直接调OpenCvSharp.Extensions的转换函数:
using OpenCvSharp; using OpenCvSharp.Extensions; var bitmap = await _cameraCapture.GetFrameAsync(); // 相机或者文件返回Bitmap Mat src = BitmapConverter.ToMat(bitmap);这里有个隐藏的坑:Bitmap的像素格式可能是24bppRGB,也有可能是32bppARGB,转换之后Mat的通道数和顺序可能是BGR而不是RGB。OpenCV里图像通道顺序默认是BGR,在某些情况下你需要Cv2.CvtColor再做一次转换。这个后面专门讲。
3.2 预处理流程与代码
拿到Mat之后不是直接丢给识别算法,还要做一道预处理管道。我常用的处理顺序是:灰度化 -> 高斯滤波 -> 二值化或边缘检测 -> 形态学操作。代码如下:
public Mat Preprocess(Mat src) { // 1. 灰度化:减少计算量,去掉颜色干扰 using Mat gray = new Mat(); Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY); // 2. 高斯滤波:降噪,5x5核 using Mat blur = new Mat(); Cv2.GaussianBlur(gray, blur, new Size(5, 5), 0); // 3. 二值化:Otsu自动阈值,把目标从背景中分离 using Mat binary = new Mat(); Cv2.Threshold(blur, binary, 0, 255, ThresholdTypes.Otsu); // 4. 形态学闭运算:填充目标内部小孔 using Mat kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(5, 5)); Cv2.MorphologyEx(binary, binary, MorphTypes.Close, kernel); return binary.Clone(); }灰度化的目的是把三通道图像压缩成单通道,计算量直接降三分之一,而且很多识别任务本身就不依赖颜色。高斯滤波是为了抑制传感器噪声,用一个5x5的高斯核对每个像素邻域做加权平均,让图像更平滑,边缘检测的结果更干净。二值化是把灰度图变成纯黑白图,目标区域255,背景0,这样后续FindContours才能有清晰的轮廓边界。形态学闭运算解决的是目标内部有纹理、有孔洞的问题,先用膨胀把孔洞填上,再腐蚀缩回原尺寸,这样轮廓提取出来才是一个完整封闭的外形。
3.3 阈值选择:固定阈值还是自适应
预处理里最让人头疼的是二值化的阈值。固定阈值,比如Cv2.Threshold(blur, binary, 128, 255, ThresholdTypes.Binary),在光线稳定的实验室环境没问题,一旦车间里有自然光变化、灯光抖动,固定128就废了。所以我优先用Otsu方法自动算阈值,它会在灰度直方图上找一个类间方差最大的值,把前景和背景分得最开,不需要人为指定,绝大多数工业件在均匀光照下效果都不错。
如果光照不均匀,比如零件表面有反光,Otsu也会失效。这时候换自适应阈值更稳妥:
Cv2.AdaptiveThreshold(blur, binary, 255, AdaptiveThresholdTypes.GaussianC, ThresholdTypes.Binary, 11, 2);自适应阈值的思想是让每个像素的阈值由它邻域窗口的加权均值决定,同一张图不同区域用不同的阈值,应对局部光照变化比Otsu强得多。我一般先试Otsu,有反光就换自适应,程序里加一个可配置项让调试时切换。
4. 识别核心三种方案:模板匹配、轮廓分析、ONNX深度学习
4.1 模板匹配:最快的起点但最脆弱
识别环节根据任务灵活选方案。如果你的目标物形态固定、无旋转、无缩放、光照稳定,比如印刷包装上的定位Mark点,模板匹配就够用。核心代码:
public class TemplateMatcher : IRecognitionEngine { private readonly Mat _template; public RecognitionResult Recognize(Mat processedImage) { using Mat result = new Mat(); Cv2.MatchTemplate(processedImage, _template, result, TemplateMatchModes.CCoeffNormed); Cv2.MinMaxLoc(result, out _, out double maxVal, out _, out Point maxLoc); return new RecognitionResult(maxLoc.X, maxLoc.Y, maxVal); } }MatchTemplate在模板周围滑窗,计算每个位置的归一化相关系数,相关系数最高的地方就是匹配位置。它实现简单、速度快,但本质上是像素级相关性比对,目标稍微旋转几度、缩放一些、光照变化一下,相关系数就会断崖式下跌。所以模板匹配只能当入门方案,适合固定工位的Mark定位,不适合做通用识别。
4.2 轮廓分析:给几何零件做特征描述
碰到机械零件、螺丝垫片这类轮廓明确的东西,用轮廓分析比模板匹配靠谱得多。思路是先从二值图提取所有轮廓,再对每个轮廓计算面积、周长、外接矩形、多边形逼近顶点数、Hu矩等特征,用这些特征判断它是不是我们想要的目标。
Cv2.FindContours(binary, out Point[][] contours, out HierarchyIndex[] hierarchy, RetrievalModes.External, ContourApproximationModes.ApproxSimple); foreach (var contour in contours) { double area = Cv2.ContourArea(contour); double perimeter = Cv2.ArcLength(contour, true); Rect rect = Cv2.BoundingRect(contour); // 根据实际目标尺寸过滤 if (area < 1000 || area > 50000) continue; if (perimeter < 100 || perimeter > 800) continue; // 多边形逼近,判断轮廓形状 Point[] approx = Cv2.ApproxPolyDP(contour, 0.02 * perimeter, true); if (approx.Length == 4) { // 四边形候选,再结合宽高比确认是不是目标件 double ratio = (double)rect.Width / rect.Height; if (ratio > 0.8 && ratio < 1.2) { // 是目标,记录中心点 } } }面积和周长过滤能删掉一大半噪声轮廓,多边形逼近的顶点数能区分圆、三角形、四边形,宽高比能进一步确认形状特征。这个方法的核心价值在于不依赖像素的精确位置,而是描述目标的几何属性,所以目标有一定程度的旋转也不怕,Hu矩本身就具备旋转、缩放和平移不变性。工业视觉里做定位、分拣、缺陷尺寸检测,轮廓分析是应用最广的经典方案。
4.3 ONNX Runtime部署深度学习模型
轮廓分析在复杂场景下会崩溃,比如背景杂乱、目标被遮挡、多类别分类。这种场景就需要深度学习模型了。模型可以用PyTorch或TensorFlow训练好,导出成ONNX格式,然后C#端用ONNX Runtime做推理。
加载和推理的核心代码:
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class OnnxClassifier : IRecognitionEngine { private readonly InferenceSession _session; public OnnxClassifier(string modelPath) { _session = new InferenceSession(modelPath); } public RecognitionResult Recognize(Mat processedImage) { // 1. 预处理:resize到模型输入尺寸,归一化 using Mat resized = new Mat(); Cv2.Resize(processedImage, resized, new Size(224, 224)); Cv2.CvtColor(resized, resized, ColorConversionCodes.BGR2RGB); // 2. 转成ONNX Runtime需要的DenseTensor var tensor = new DenseTensor<float>(new[] { 1, 3, 224, 224 }); var inputMeta = _session.InputMetadata.First(); for (int y = 0; y < 224; y++) { for (int x = 0; x < 224; x++) { Vec3b pixel = resized.At<Vec3b>(y, x); tensor[0, 0, y, x] = pixel.Item0 / 255f; tensor[0, 1, y, x] = pixel.Item1 / 255f; tensor[0, 2, y, x] = pixel.Item2 / 255f; } } // 3. 推理 List<NamedOnnxValue> inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor(inputMeta.Key, tensor) }; using IDisposableReadOnlyCollection<DisposableNamedOnnxValue> results = _session.Run(inputs); // 4. 取输出,按类别下标解析 var output = results.First().AsEnumerable<float>().ToArray(); int cls = Array.IndexOf(output, output.Max()); return new RecognitionResult(0, 0, output[cls], cls); } }这里要注意几个细节。第一,模型训练时的预处理参数,包括归一化均值方差、输入尺寸、通道顺序,C#端必须完全复现,否则推理精度会严重下降。第二,像素访问这里用了At<Vec3b>,只适合224x224这种小尺寸,大图要换更高效的指针内存拷贝方式。第三,ONNX Runtime的InferenceSession是线程安全的,同一个session可以并发做推理,不用每个请求都重新加载模型。
如果做的是YOLO目标检测,输出解析会复杂一些,要处理 bounding box、置信度阈值过滤和NMS,但整体框架一样,都是预处理、张量转格式、推理、后处理。
5. 落地工程细节:UI稳定、并发识别、性能优化
5.1 识别不要放在UI线程,否则画面卡成幻灯片
做WinForms图像识别最忌讳的事就是把Cv2那套耗时操作直接扔在按钮点击事件或者相机回调里跑。一张500万像素的图片做预处理加识别,少说也要几十毫秒,如果uI线程被占住,窗口拖动、按钮点击都会卡住,客户体验极差。
我的做法是开一个后台识别线程,UI线程只负责接收结果显示:
private async void OnFrameCaptured(Bitmap frame) { Mat mat = BitmapConverter.ToMat(frame); RecognitionResult result = await Task.Run(() => _engine.Recognize(mat)); pictureBox1.BeginInvoke(new Action(() => UpdateUI(result, frame))); }Task.Run把识别丢到线程池,await返回之后用BeginInvoke回到UI线程更新PictureBox。这样相机回调再频繁,UI也保持流畅。注意UpdateUI里对Bitmap的释放要小心,PictureBox还在显示它的时候不能dispose,我一般显示完上一帧再释放上一帧的Bitmap资源。
5.2 并发识别与帧率控制:不是每帧都要处理
工业相机跑30帧每秒,但识别算法可能只能每秒处理10帧。如果每帧都进识别管道,线程池会被撑爆,内存疯涨,程序迟早挂掉。我的方案是用一个Channel当作队列,相机线程只管往队列里丢最新帧,识别线程从队列里取一帧处理,处理完后丢弃队列里所有待处理帧,只保留最新一帧。这样保证识别线程永远处理最新图像,不堆积延迟。
Channel<Mat> frameChannel = Channel.CreateUnbounded<Mat>(); // 相机回调里 await frameChannel.Writer.WriteAsync(mat); // 识别线程里 while (await frameChannel.Reader.WaitToReadAsync()) { while (frameChannel.Reader.TryRead(out Mat? latest)) { latest?.Dispose(); // 丢旧帧释放内存 } RecognitionResult result = _engine.Recognize(latestFrame); }这个"只保留最新帧"的思路在高速产线上非常管用,视觉系统的实时性比完整性重要得多。你要是坚持每一帧都识别还都处理完,系统延迟会越来越高,机械臂执行的永远是几百毫秒之前的结果,那才真的危险。
5.3 性能优化:GetPixel是万恶之源
网上一堆C#图像处理的教程还在教GetPixel和SetPixel,这两个方法在小尺寸图玩具代码里没问题,放到百万像素图上就是灾难。一个1000x1000的图,全图GetPixel大概要几百毫秒到几秒,而用OpenCV的Mat直接操作内存块,同样操作只要几十毫秒。
在C#里处理像素的效率顺序大致是:OpenCV Mat原生操作高于LockBits指针操作远高于GetPixel/SetPixel。所以一切像素级算法都交给OpenCV完成,别自己写循环去遍历Bitmap。实在需要读像素,优先用Mat.At方法,数据量再大就上指针加unsafe代码块。内存释放也要养成习惯,Cv2操作产生的大量临时Mat用using或者手动Dispose,不然一个长时间运行的上位机程序内存会慢慢涨到失控。
6. 我这个项目里踩过的坑
6.1 DllNotFoundException:平台目标引发的"灵异事件"
项目文件挪到另一台电脑上运行,一启动就报OpenCvSharpException,提示找不到OpenCV原生DLL。第一反应是重装NuGet包,没用;再查环境变量,也没问题。最后发现那台电脑的项目配置被自动改成了AnyCPU,程序以64位进程启动但OpenCvSharp4.runtime.win的DLL路径没有被正确加载。解决办法是把所有项目的平台目标显式设置成x64,并且确认Configuration Manager里的Active solution platform也是x64。这个坑每个用OpenCVSharp的人都会踩一遍,装完包第一件事就是查平台目标。
6.2 图像花屏:Bitmap格式与通道顺序问题
从相机SDK拿到的Bitmap有些是24bppRGB,有些是32bppARGB,直接转成Mat之后图像颜色错乱,看起来像是通道顺序被调换了。原因是OpenCV默认按BGR存储,如果你的源图是RGB,不做转换就直接用,红色和蓝色就会互换,识别结果自然不准。解决办法是在预处理管道开头加一步通道转换,调用Cv2.CvtColor(src, src, ColorConversionCodes.RGB2BGR)。别问我怎么知道要加的,问就是盯着花屏图像排了一个小时。
6.3 光照一变就全错:阈值必须自适应
项目调试阶段在实验室跑得非常好,准确率98%。装到客户产线之后,上午还好,下午靠窗的位置阳光一照,错误率直接飙升到40%。后来过去看了现场,发现是自然光影响了二值化效果,固定阈值把零件阴影区域和孔洞全混在一起了。我把阈值策略改成光照亮时用Otsu,表面有反光时自适应阈值,又把二值化之前的滤波核从3x3调到5x5,情况才稳定下来。所以识别代码写完之后,一定要在目标环境的不同时段各测几遍,尤其是采光条件会变的地方。
6.4 模板匹配在旋转场景下的误匹配
最开始给机械臂定位试过模板匹配,固定角度测试完美,但传送带上的零件角度偶尔会偏几度,一旦偏到10度以上匹配系数就掉到0.6以下,抓取直接失败。我一开始试图改进模板匹配,做了多角度模板库,效果还是不稳定,模板数量一多反而误匹配增加。后来干脆换轮廓分析方案,用中心点和最小外接矩形的角度作为定位输出,旋转问题直接解决。这个教训我记到现在:识别方案的选择要匹配真实场景的变量范围,如果目标存在旋转、缩放变化,趁早放弃模板匹配,省得后面反复调。
最后再分享一点个人体会
这套C#图形图像识别软件从MVP到稳定运行,前后改了三版,最大的收获不是某几个API的用法,而是识别方案一定要在项目早期就去现场验证。你在办公室里用几张固定图片测出来的准确率,到了产线上什么都不是。光照、镜头畸变、传送带震动、零件摆放方向,这些因素没一个是能在实验室提前完全模拟的。
如果让我重做一遍,我会一开始就把相机放在现场拍200张真实图片,用这些图片做预处理参数调整和算法选型,而不是先写代码后验证。C#的图形图像识别能力完全够用,只要你把选型、预处理、工程细节这三层做扎实,这套软件就能稳稳跑在客户产线上。
本文还有配套的精品资源,点击获取