C#工业视觉实战:从图像预处理到ONNX推理的完整方案
简介一份基于C#语言的图形图像识别软件源代码面向希望掌握图像处理与文字识别技术的开发者可配合Windows窗体应用学习桌面端识别功能搭建。压缩包共26个文件约792KB包含6个C#源码文件、2个动态链接库、2个资源文件以及可执行程序、调试信息和解决方案工程文件等项目结构完整便于直接加载与调试。已有616人学习下载资源体量精简却覆盖完整开发环节适合入门到进阶的C#开发者借鉴。源码覆盖图像加载与缩放、界面显示、鼠标选区交互、异常处理等关键环节并可能集成开源OCR方案帮助理解从图像采集到文字提取的完整链路同时采用事件驱动模型识别按钮触发的处理流程清晰易读。代码注释与文件组织对学习友好附带的可执行程序可边运行边对照源码快速验证效果也可作为课程设计或工程入门模板。1. 为什么我会在C#里做图形图像识别而不是转头用Python1.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#图像识别库选型对比给还没入门的同学一张表选型的时候直接照抄库底层实现适合做什么维护状态缺点OpenCvSharp4OpenCV原生库P/Invoke封装图像预处理、模板匹配、轮廓分析、相机标定活跃API有学习成本Emgu CVOpenCV封装官方支持老项目、需要商业支持活跃版本落后OpenCVAForge.NET纯C#实现简单图像处理、机器视觉教学基本停更性能弱不建议新项目OnnxRuntime微软深度学习推理引擎加载ONNX模型做分类、检测、分割活跃需要了解张量/NDArrayTesseractC 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包清单就四个OpenCvSharp4OpenCvSharp4.runtime.winOpenCvSharp4.ExtensionsBitmap和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 DenseTensorfloat(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.AtVec3b(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. 推理 ListNamedOnnxValue inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(inputMeta.Key, tensor) }; using IDisposableReadOnlyCollectionDisposableNamedOnnxValue results _session.Run(inputs); // 4. 取输出按类别下标解析 var output results.First().AsEnumerablefloat().ToArray(); int cls Array.IndexOf(output, output.Max()); return new RecognitionResult(0, 0, output[cls], cls); } }这里要注意几个细节。第一模型训练时的预处理参数包括归一化均值方差、输入尺寸、通道顺序C#端必须完全复现否则推理精度会严重下降。第二像素访问这里用了AtVec3b只适合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当作队列相机线程只管往队列里丢最新帧识别线程从队列里取一帧处理处理完后丢弃队列里所有待处理帧只保留最新一帧。这样保证识别线程永远处理最新图像不堆积延迟。ChannelMat frameChannel Channel.CreateUnboundedMat(); // 相机回调里 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#的图形图像识别能力完全够用只要你把选型、预处理、工程细节这三层做扎实这套软件就能稳稳跑在客户产线上。本文还有配套的精品资源点击获取