简介:C#联合OpenCVSharp开发的视觉源码程序,面向需要快速搭建视觉识别与测量功能的C#开发者,直接提供模板匹配、找线找圆、图像预处理等常用模块的完整实现,可用于工业定位、尺寸检测、目标识别等场景。压缩包共737个文件,包含138个C#源码文件、182个dll依赖库以及xml配置、png/bmp界面资源、txt说明文档等,整体约409MB,目录结构清晰,既可直接运行,也便于按需修改扩展。程序内置图像显示控件和ROI绘制交互界面,能实时预览处理效果并手动划定感兴趣区域,适合对OpenCVSharp不熟悉的开发者作为入门参考,也可作为自动化视觉项目的底层框架。已有175人学习下载,是学习C#视觉编程与搭建实际工程的良好素材。
1. 从C#上位机到视觉检测:为什么OpenCvSharp成了最稳的选择
在一套C#上位机视觉程序里,最磨人的不是算法本身,而是图像显示、ROI交互、预处理链路的框架搭建。C#联合OpenCvSharp开发的视觉源码程序,通常把模板匹配、找线找圆、预处理这些核心功能,连同图像显示控件和绘制ROI的交互底座打包在一起,形成一套能直接跑起来的检测框架。这篇文章就按这个骨架来讲:显示控件怎么做、ROI怎么画、预处理参数怎么调、模板匹配和几何测量怎么落地,以及调试里翻过的那些车。适合正在做上位机视觉软件、想在一周内把检测框架跑起来的工程师——不是泛泛科普,是能照着改的实战路径。
2. 图像显示控件与ROI绘制:先把交互底座搭起来
2.1 用PictureBox还是自绘控件:显示层的选型逻辑
OpenCvSharp本身没有自带图像显示控件,这和Halcon的HWindowControl不一样。最常见的做法是两种:直接封装PictureBox,或者写一个继承UserControl的自绘控件。我一般选后者,原因很现实:后续要在图像上叠加绘制ROI、十字光标、匹配框和测量结果,如果全靠PictureBox的Paint事件重绘,缩放和平移时要不断换算坐标,代码会越写越乱。
自绘控件的好处是把“图像显示”和“图形叠加”拆成两层:底层是一张Bitmap,上层是GDI+绘制的矢量图层。鼠标事件直接落在控件上,操作体验接近市面上的视觉软件。
public class ImageViewer : UserControl { private PictureBox _picBox; private Mat _currentMat; public ImageViewer() { _picBox = new PictureBox { Dock = DockStyle.Fill, SizeMode = PictureBoxSizeMode.Zoom }; _picBox.MouseDown += OnMouseDown; _picBox.MouseMove += OnMouseMove; _picBox.MouseUp += OnMouseUp; _picBox.Paint += OnPaint; Controls.Add(_picBox); } public void ShowImage(Mat image) { _currentMat?.Dispose(); _currentMat = image.Clone(); _picBox.Image = BitmapConverter.ToBitmap(_currentMat); _picBox.Invalidate(); } }这段代码的要点在ShowImage里:传入的Mat不能直接拿给PictureBox用,因为外部可能继续修改这张图,浅拷贝会互相影响。Clone()之后控件持有自己的数据,外部改图不影响显示。BitmapConverter.ToBitmap是OpenCvSharp.Extensions命名空间里的转换方法,会把Mat转成System.Drawing.Bitmap。
这里有个容易忽略的坑:转换出来的Bitmap默认不会释放,如果每帧都调ShowImage转一次,内存会被Bitmap排队占满。我习惯在赋值前先_picBox.Image?.Dispose(),把上一帧的Bitmap清掉。另外,_currentMat作为控件内部持有的Mat副本,也要在下次ShowImage时释放,这是展示层最基本的内存纪律。
2.2 鼠标绘制ROI:矩形ROI与旋转矩形ROI的实现
绘制ROI是视觉框架里用户接触最多的交互。典型需求是:在图上按住鼠标左键拖出一个矩形框,松开后这个矩形进入ROI列表,后续模板匹配和预处理只在这个区域内做计算。实现上要处理两个问题:一是鼠标拖动时实时预览,二是控件坐标和图像坐标的换算。
private Point _dragStart; private Rectangle _currentRect; private bool _isDrawing; private void OnMouseDown(object sender, MouseEventArgs e) { if (e.Button == MouseButtons.Left) { _dragStart = e.Location; _isDrawing = true; } } private void OnMouseMove(object sender, MouseEventArgs e) { if (!_isDrawing) return; Point start = ToImagePoint(_dragStart); Point end = ToImagePoint(e.Location); _currentRect = new Rectangle( Math.Min(start.X, end.X), Math.Min(start.Y, end.Y), Math.Abs(start.X - end.X), Math.Abs(start.Y - end.Y)); _picBox.Invalidate(); } private void OnMouseUp(object sender, MouseEventArgs e) { if (!_isDrawing) return; _isDrawing = false; if (_currentRect.Width > 5 && _currentRect.Height > 5) _roiList.Add(new RoiItem { Rect = _currentRect, Name = $"ROI_{_roiList.Count + 1}" }); }_currentRect在MouseMove里实时更新,然后调用Invalidate()触发重绘,Paint事件里把框画出来。判断宽高大于5是为了过滤误点击。这里有个细节:_dragStart和e.Location都是控件坐标,必须先转成图像坐标再构造Rectangle,否则放大显示的时候,画出来的框位置和鼠标实际位置对不上。
旋转矩形ROI在视觉里也常见,尤其模板匹配目标本身带角度时。OpenCvSharp里对应的是RotatedRect,参数是中心点、Size和角度。鼠标交互上,我习惯用“先拖出中心,再拖出边,最后拖出角度”的三步法,实现代价比一次性拖出旋转框低很多。画旋转矩形时不能直接用Cv2.Rectangle,要取rotatedRect.Points()拿四个角点,再用Cv2.Polylines画出来。
2.3 图像坐标与控件坐标的换算:缩放、平移与状态栏联动
坐标换算是所有视觉交互程序的命门。PictureBox处于Zoom模式时,图像等比缩放,四周可能留黑边,鼠标位置到图像位置的映射必须知道缩放比例和偏移量。
public Point ToImagePoint(Point controlPoint) { Size imgSize = _currentMat.Size(); Size boxSize = _picBox.ClientSize; double scale = Math.Min(boxSize.Width / (double)imgSize.Width, boxSize.Height / (double)imgSize.Height); double offsetX = (boxSize.Width - imgSize.Width * scale) / 2.0; double offsetY = (boxSize.Height - imgSize.Height * scale) / 2.0; int imgX = (int)Math.Round((controlPoint.X - offsetX) / scale); int imgY = (int)Math.Round((controlPoint.Y - offsetY) / scale); imgX = Math.Clamp(imgX, 0, imgSize.Width - 1); imgY = Math.Clamp(imgY, 0, imgSize.Height - 1); return new Point(imgX, imgY); }反向换算——从图像坐标到控件坐标——就是上面公式的逆运算,通常在绘制十字光标和测量标注时用。这套换算在放大显示和滚轮缩放时同样要一致,否则就会出现“鼠标画框框偏移”“标注画到图外面”之类的问题。
坐标换算还有个实际用途:状态栏联动。把鼠标移动时的图像坐标实时显示在WinForm状态栏里,就是“c# winform如何更新状态栏与进度条”的典型场景。我一般用ToolStripStatusLabel,在MouseMove里刷新文本,注意不要在这个事件里做图像处理,它触发频率太高,只更新坐标文本,否则UI会卡顿。
3. 预处理模块:模板匹配和找线找圆都靠它喂数据
3.1 常用预处理算子与搭配:灰度、滤波、阈值、形态学
预处理是视觉里最容易被低估的环节。这里的预处理是图像域的预处理,不是文本预处理那种词表清理。OpenCvSharp框架里,模版匹配和找线找圆的输入质量,直接取决于预处理链路的设置。一条典型的链路是:灰度化 → 中值滤波 → 自适应阈值 → 形态学开运算。
灰度化把三通道图像降为单通道,计算量直接变成三分之一。中值滤波对椒盐噪声和产品表面的细小划痕有奇效,比高斯滤波更适合边缘类测量任务。自适应阈值解决光照不均的问题,它按局部邻域计算阈值,同一个参数在画面明暗差异大的区域也能稳定出二值图。最后开运算把细小的连接断开,避免后续找轮廓时把不该连的边缘连在一起。
public Mat Preprocess(Mat src) { Mat gray = new Mat(); Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY); Mat median = new Mat(); Cv2.MedianBlur(gray, median, _ksize); Mat binary = new Mat(); if (_useAdaptive) { Cv2.AdaptiveThreshold(median, binary, 255, AdaptiveThresholdTypes.MeanC, ThresholdTypes.Binary, _blockSize, _cValue); } else { Cv2.Threshold(median, binary, _thresh, 255, ThresholdTypes.Binary); } Mat morph = new Mat(); Mat kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(_morphSize, _morphSize)); Cv2.MorphologyEx(binary, morph, MorphTypes.Open, kernel); kernel.Dispose(); gray.Dispose(); median.Dispose(); binary.Dispose(); return morph; }参数上要注意:_ksize中值滤波核必须是奇数,3到7之间够用,再大图像会糊;_blockSize自适应阈值的邻域尺寸也必须是奇数,常见设置31或51,值越大阴影适应性越强,但太大会把小目标吞掉;_cValue是常数偏移,正值会让更多像素变成背景,负值则相反。形态学核的大小和形状要看目标形态,矩形核适合普通表面,十字核更适合定位类任务。
预处理最大的坑不是选哪个算子,而是参数不可调。如果这些参数写死在代码里,现场换一个光照环境就得改代码重编译。我坚持把所有预处理参数放进配置类,界面上用NumericUpDown直接绑定,这也是这套源码程序里预处理模块存在的实际价值。
3.2 可调预处理链路:让参数面板和图像刷新联动
参数面板的交互逻辑很简单:改变任何一个预处理参数,立即用当前原图重新走一遍链路,并把结果显示在图像控件上。这个“实时预览”的交互,比反复改代码重跑高效得多。
public class PreprocessOptions { public int KSize { get; set; } = 5; public bool UseAdaptive { get; set; } = true; public int BlockSize { get; set; } = 31; public int CValue { get; set; } = 10; public int MorphSize { get; set; } = 3; } // 界面事件绑定 private void numericUpDown_ValueChanged(object sender, EventArgs e) { _options.KSize = (int)numKSize.Value; _options.BlockSize = (int)numBlockSize.Value; _options.CValue = (int)numCValue.Value; Mat result = _preprocessor.Preprocess(_sourceMat); _viewer.ShowImage(result); }参数类用属性而非公共字段,是为了后续做JSON序列化持久化。每个参数改完后重新预处理,注意这里要控制触发频率:ValueChanged在拖动NumericUpDown时会连续触发,如果图像分辨率高,预处理一次要几十毫秒,连续触发会造成界面卡死。常见做法是加一个防抖计时器,事件触发后延迟200毫秒再执行,期间不断重置计时器,直到用户停下。
实时预览时的Mat释放也要注意。ShowImage内部会处理显示用Mat的释放,但_sourceMat这类长期持有的原图不要在预览时被意外替换。我习惯在整个预处理链路里用using块或手动Dispose中间Mat,避免一张大图反复处理产生几百MB的垃圾对象。
3.3 中间结果可视化:不弹窗的快速调试法
预处理调试最血泪的经验是:不要用Cv2.ImShow弹窗。OpenCvSharp的Cv2.ImShow在WinForm上位机里会阻塞消息循环,弹窗一多,程序直接假死。我习惯做一个调试侧面板,把每一步中间结果放进一个TabControl或ComboBox切换显示。
private List<Mat> _debugImages = new List<Mat>(); private List<string> _debugNames = new List<string>(); public Mat PreprocessWithDebug(Mat src, PreprocessOptions options) { _debugImages.Clear(); Mat gray = new Mat(); Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY); _debugImages.Add(gray.Clone()); _debugNames.Add("灰度"); Mat median = new Mat(); Cv2.MedianBlur(gray, median, options.KSize); _debugImages.Add(median.Clone()); _debugNames.Add("中值滤波"); Mat binary = new Mat(); Cv2.AdaptiveThreshold(median, binary, 255, AdaptiveThresholdTypes.MeanC, ThresholdTypes.Binary, options.BlockSize, options.CValue); _debugImages.Add(binary.Clone()); _debugNames.Add("自适应阈值"); gray.Dispose(); median.Dispose(); binary.Dispose(); return binary; }中间结果用Clone()存副本,是因为后续会继续Dispose上游Mat,如果不克隆,调试列表里存的引用全部失效。调试信息收集本身只在开发阶段开启,上线时用条件编译去掉,避免每帧多几次图像拷贝。
4. 模板匹配与找线找圆:从定位到测量的完整实现
4.1 模板匹配原理与OpenCvSharp的匹配模式
模板匹配的原理是在搜索图上滑动模板,计算每个位置的相似度,OpenCvSharp提供了Cv2.MatchTemplate,支持六种匹配模式。实际视觉项目里我用得最多的是TemplateMatchModes.CCoeffNormed,归一化相关系数对亮度的线性变化不敏感,现场光照波动时比SqDiffNormed稳得多。
需要强调的是,OpenCvSharp自带的模板匹配对旋转和缩放没有内置支持。模板旋转30度,相似度会断崖式下降。处理旋转的常见做法是预生成一个旋转模板集:把模板从-10度到+10度按步长旋转,每个角度存一张,匹配时逐一打分取最高。这个方案简单可靠,代价是角度范围越大、步长越细,耗时线性增长。
private List<Mat> _templateRotations = new List<Mat>(); private List<double> _templateAngles = new List<double>(); public void BuildTemplate(Mat source, Rect roi, double angleStart, double angleEnd, double step) { Mat template = new Mat(source, roi); for (double angle = angleStart; angle <= angleEnd; angle += step) { Mat rotated = new Mat(); Mat rotationMatrix = Cv2.GetRotationMatrix2D( new Point2f(template.Width / 2f, template.Height / 2f), angle, 1.0); Cv2.WarpAffine(template, rotated, rotationMatrix, template.Size(), InterpolationFlags.Cubic, BorderTypes.Replicate); _templateRotations.Add(rotated); _templateAngles.Add(angle); } }旋转模板的坑在于:旋转后模板四角会出现无像素区域,用BorderTypes.Replicate把边缘像素向外复制,比默认的黑边效果好很多,黑边在归一化匹配时会拉低分数。模板匹配要的就是稳定,模板图像的预处理方式和搜索图保持一致,这条铁律能省掉大量调试时间。
4.2 创建模板与运行匹配:从ROI到匹配框的完整代码
运行匹配的核心代码不长,但细节多。先看最简版本:
public MatchResult Match(Mat searchImage) { double bestScore = -1; Point bestPoint = Point.Empty; double bestAngle = 0; Rect bestRect = Rect.Empty; foreach (var rotatedTemplate in _templateRotations) { using (Mat result = new Mat()) { Cv2.MatchTemplate(searchImage, rotatedTemplate, result, TemplateMatchModes.CCoeffNormed); Cv2.MinMaxLoc(result, out _, out double maxVal, out _, out Point maxLoc); if (maxVal > bestScore) { bestScore = maxVal; bestPoint = maxLoc; bestAngle = _templateAngles[_templateRotations.IndexOf(rotatedTemplate)]; bestRect = new Rect(bestPoint.X, bestPoint.Y, rotatedTemplate.Width, rotatedTemplate.Height); } } } if (bestScore < _scoreThreshold) return null; return new MatchResult { Score = bestScore, Rect = bestRect, Angle = bestAngle }; }MatchTemplate的输入要求是灰度图或彩色图都可以,但模板和搜索图必须同通道、同深度,否则会抛异常。MinMaxLoc从结果矩阵里拿到最大值和位置,这个位置是模板左上角的坐标,不是中心点。如果要的是目标中心,记得bestRect = new Rect(bestPoint.X + template.Width / 2, bestPoint.Y + template.Height / 2, ...)。
_scoreThreshold是匹配通过的最低分数,通常设在0.6到0.85之间。太低会误检,太高会漏检。判断阈值是否合适的办法很土但有效:把现场几十张不同光照的图跑一遍,看分数直方图,找一个明显的分数间隙作为阈值。
4.3 找线、找圆:边缘点集与最小二乘拟合
找线和找圆是尺寸测量的基本功,思路跟模板匹配不一样——匹配是找“在哪”,找线找圆是测“准不准”。最可靠的方式不是直接用霍夫变换,而是先提取边缘点集,再做最小二乘拟合。
public LineResult FindLine(Mat preprocessed, Rect roi) { Mat roiMat = new Mat(preprocessed, roi); Mat edges = new Mat(); Cv2.Canny(roiMat, edges, _cannyLow, _cannyHigh); Point[][] contours; HierarchyIndex[] hierarchy; Cv2.FindContours(edges, out contours, out hierarchy, RetrievalModes.External, ContourApproximationModes.ApproxNone); List<Point> allPoints = new List<Point>(); foreach (var contour in contours) { if (Cv2.ContourArea(contour) > _minArea) allPoints.AddRange(contour); } if (allPoints.Count < 10) return null; Cv2.FitLine(allPoints.ToArray(), out Vec4f line, DistanceTypes.L2, 0, 0.01, 0.01); return new LineResult { Vx = line[0], Vy = line[1], X0 = line[2], Y0 = line[3] }; }Canny的双阈值一般设成经验值:低阈值是高阈值的一半到三分之一。ContourArea筛选是为了去掉噪声点和小碎边缘,最小面积按像素算。FitLine用的DistanceTypes.L2是最小二乘,适合边缘干净的场景;如果边缘有离群点,换成DistanceTypes.Huber或Tukey更稳,代价是收敛迭代次数变多。
这里有个找线的关键细节:FitLine的结果是直线上的一个点(X0, Y0)和方向向量(Vx, Vy),不是线段端点。绘制时要自己算出图像边界的两个交点,很多新手拿到Vec4f直接画,发现线跑出图像范围,就以为算法有问题。
找圆和找线一脉相通:先Canny拿边缘,再筛选轮廓,最后拟合。如果目标是一个完整圆弧,用Cv2.MinEnclosingCircle暴力求最小包围圆最简单;如果目标只是部分圆弧(比如圆孔被遮挡了一半),得用Cv2.FitEllipse拟合椭圆然后取长轴当直径,或者对点集做代数圆拟合。
4.4 测量结果输出:距离、角度与直径判定
测量输出的常见需求是:两条边的距离、两条线的夹角、两个圆心的距离、圆直径是否在公差内。这些几何计算用点线公式就能完成。
public double AngleBetweenLines(Vec4f line1, Vec4f line2) { double angle1 = Math.Atan2(line1[1], line1[0]); double angle2 = Math.Atan2(line2[1], line2[0]); double diff = Math.Abs(angle1 - angle2); if (diff > Math.PI / 2) diff = Math.PI - diff; return diff * 180 / Math.PI; }角度计算要处理方向向量反向的问题。Atan2返回的是-180度到180度,两条线夹角的有效范围是0到90度,不做归一会算出钝角。距离计算则是标准的点到直线公式,把(X0, Y0)和(Vx, Vy)代入即可。测直径时,我习惯搭配一个在线标定系数——像素直径乘标定值得到毫米值,标定值用一个已知尺寸的标准件在部署现场测一次,存进配置文件。每次测量结果跟公差上下限比较,输出OK/NG,这才是产线上真正需要的输出格式。
5. 常见的 5 个视觉程序排查现场:从 ROI 偏移到内存泄漏的踩坑记录
5.1 ROI 画出来了,匹配却在 ROI 外乱跳
现象:在图像控件上拖了一个矩形ROI,理论上模板匹配只在该区域内搜索,但匹配框经常跑到ROI外面,或者定位结果跟鼠标画的框完全对不上。
原因:ROI坐标没有做从控件坐标到图像坐标的换算。PictureBox处于Zoom模式时,图像居中缩放,控件坐标直接当作图像坐标使用,ROI边界就会有系统性偏差。更隐蔽的是DPI缩放——WinForm在高分屏下自动缩放,如果窗体启用了AutoScaleMode.Dpi,鼠标事件的坐标已经是物理像素,而图像尺寸是逻辑像素,差值会被放大。
解决:所有鼠标事件先用ToImagePoint换算成图像坐标再进入业务逻辑。ROI列表里存的一律是图像坐标,只有绘制时反向换算成控件坐标。DPI问题在程序入口显式调用SetProcessDpiAwareness,并关闭窗体的自动缩放,或者统一用DpiHelper换算。
5.2 模板匹配一帧要200ms,产线根本跑不动
现象:算法在开发电脑上运行流畅,部署到现场工控机上,单帧匹配耗时200毫秒以上,节拍完全跟不上。
原因:搜索图太大。如果整幅200万像素图像直接做MatchTemplate,模板每移动一个像素就计算一次,在分辨率高、模板多的情况下耗时直接爆炸。现场工控机CPU性能通常比开发机弱,差距会被放大。
解决:做金字塔粗匹配。先把搜索图和模板都做两层PyramidDown,在低分辨率下匹配一次得到大致位置,再映射回原分辨率后做局部精匹配。ROI范围也要尽量收敛,匹配只在包含目标的局部区域执行,不整图跑。还有一个习惯:提前看Cv2.UseOptimized()是否返回true,OpenCV在构建时启用了IPP加速,如果没有,得分低一半。
5.3 找线找圆结果随光照抖动
现象:同一个产品,在白天和晚上测出来的直径差了0.3个像素;产线灯光稍有变化,找线的角度就抖动。
原因:整条链路的阈值是固定值。固定阈值在光照变化时,边缘的像素可能落在阈值两侧,导致Canny提取的轮廓位置偏移几个像素。最小二乘拟合对边缘点位置敏感,几个像素的偏移足以让测量结果抖动。
解决:一是把固定阈值改成自适应阈值或Canny的自动阈值计算;二是拟合时换用DistanceTypes.Huber,对离群边缘点更鲁棒;三是在硬件上把光源调成稳定的漫射光,减少环境光干扰。软件能兜底一部分光照波动,但视觉测量要稳定,光照一致性是关键。
5.4 灰度图像显示成绿屏或偏色
现象:预处理后的灰度图传给图像显示控件,显示结果整体偏绿,或者出现红绿杂色,完全不是灰度效果。
原因:OpenCvSharp的Mat默认是BGR三通道,灰度图是单通道。BitmapConverter.ToBitmap按图像的通道数生成Bitmap,如果单通道图像没有正确转成8位灰度格式,GDI+会用错误的像素格式解释数据,出现偏色。还有一种情况:Mat的深度是16位,BitmapConverter没做类型转换,直接按8位解析,显示必然花屏。
解决:显示前统一做一次格式规范化,深度不是MatType.CV_8UC1就先用Cv2.ConvertScaleAbs转成8位,再交给BitmapConverter。另外,灰度图显示用PixelFormat.Format8bppIndexed加灰度调色板,不要直接拿三通道的Bitmap格式硬套。
5.5 连续采图内存涨到系统告警
现象:程序刚启动时内存200MB,连续运行两小时后涨到2GB,最后触发OutOfMemory异常。重启程序后恢复正常。
原因:最常见的三个位置:一是BitmapConverter.ToBitmap产生的Bitmap没有Dispose,每帧泄漏一个GDI对象;二是Mat中间变量用using但没有在循环里释放,比如在采集线程里每帧新建一个Mat,处理完不Dispose;三是事件订阅导致对象无法被GC回收,比如相机回调里订阅了UI事件而没有退订。
解决:给Mat和Bitmap的创建建立纪律:谁创建谁释放,处理完立刻Dispose。采集线程和UI线程通过独立的帧队列解耦,Mat在UI消费完由UI线程释放。可以用GC.GetTotalMemory打点监测,看到只增不减的曲线,再按上面三个位置逐个检查。内存问题往往不是一处泄漏,三处叠加才把你逼到崩溃边缘。
6. 把模块串成检测框架:多 ROI 并行、参数持久化与级联定位
当显示控件、预处理、匹配、测量这些模块都跑通后,真正让方案变成产品的,是最后这三件事。
多ROI并行。一个视觉程序常常要同时检测多个位置:多个模板匹配、多条边测量。用Parallel.For并行执行不同的ROI任务,是压榨工控机多核CPU最直接的手段。注意OpenCvSharp的Mat不是线程安全对象,每个并行任务内部必须有自己的Mat实例,不要跨任务共享一个Mat。我踩过这个坑:两个并行任务同时读同一张图做预处理,其中一个任务Dispose了Mat,另一个任务读取时直接抛异常。并行任务里只读共享原图,处理结果各存各的,不共享中间变量。
参数持久化用System.Text.Json。ROI列表、预处理参数、匹配阈值、公差上下限,这些都应该是现场可调的配置,而不是代码里的常量。把List<RoiItem>和PreprocessOptions序列化成JSON文件,部署时只拷配置文件,不动代码。RoiItem里的Rect直接序列化X、Y、Width、Height,反序列化时重建,这个习惯让换产线、换产品型号的成本从改代码降到改配置。
级联定位是视觉程序里最值得抄的架构。先用模板匹配粗定位目标,拿到目标在图像里的粗略位置和角度,再把用于精密测量的ROI按这个位置做映射,最后在映射后的ROI里做找边找圆。这样做的好处是:精密测量ROI始终锁定在目标上,不会因为产品在来料时的位置偏移而测偏。级联的每一级都独立保存参数,单独验证,出问题的时候能快速定位是哪一级失效。
这几套代码模块我用过几个项目后固定成了习惯:显示控件管交互,预处理管数据质量,模板匹配管定位,找线找圆管测量,JSON管配置,并行框架管性能。一开始我也在每个项目里重写轮子,后来发现模板匹配和找线找圆的调试逻辑高度相似,就把这套框架沉淀下来。找边找圆的坑我交过不少学费,特别是光照波动那次,最后靠自适应阈值加鲁棒拟合才稳住。调试视觉程序,耐心比聪明值钱,参数要一个变量一个变量地动,不要同时改两个,否则翻车了你都不知道该怪谁。希望帮到你。
本文还有配套的精品资源,点击获取