ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#+OpenCv组件连通分析:从原理到工业缺陷检测实战

C#+OpenCv组件连通分析:从原理到工业缺陷检测实战

1. 从一块“撕裂”的工件说起

我在调试一套零部件外观检测方案时遇到过一个问题:产品表面有一块印刷区域,良品应该是完整的,但实际产线上经常出现细小的墨点、拖尾、飞白。传统的像素级二值化对比,误杀率一直压不下来。因为正常印刷的边缘本身就有轻微锯齿,你把阈值放宽一点,真实缺陷会漏过去;收紧一点,正常的边缘毛刺又被当成缺陷报警。

后来我把思路从“像素长什么样”改成“这些像素是怎么连在一起的”。同一块墨迹的像素在空间上一定是邻接的,把它们看成一个个独立的“岛屿”,也就是组件,再根据岛屿的面积、位置、形状来判断——问题一下子简化了很多。这就是这篇文章要聊的:C# 基于 OpenCv 的视觉工作流里,组件连通分析这个环节到底怎么设计、怎么实现、怎么用好它。

组件连通分析(Connected Components Analysis,通常简称 BLOB 分析)是视觉工作流中很基础但极其关键的一步。它是承上启下的节点——上面接图像预处理和阈值分割,下面接特征计算和决策判定。很多刚接触视觉开发的同事容易忽略这个模块,觉得不就是数几个点吗?其实里面的坑不少:连通域的定义方式、标签算法的选择、数据结构的组织、区域特征的提取精度,每一个都直接影响后续判断的可靠性。

这篇文章适合三类人:一是刚上手 C# + OpenCv 开发视觉工具的工程师;二是已经会调阈值、但经常被“分割结果太碎”或“目标区域找不准”困扰的开发人员;三是想把视觉工作流做得更工程化、模块化的架构师。我会把整个组件连通环节从原理到代码、从单张测试到批量验证,完整地拆开来讲。

2. 组件连通在视觉工作流里到底扮演什么角色

2.1 一次像样的视觉检测,流程应该是这个样子的

一个标准的视觉工作流,从图像输入到最终输出,通常经历这几步:采集图像、预处理(去噪、增强对比度)、分割(灰度阈值、边缘提取或更高级的分割算法)、连通域分析、特征提取与过滤、决策输出。

很多人把精力都花在预处理和分割上,认为只要二值化得够好,后面就是水到渠成。这个观点有一半是对的——分割确实决定了你“看得清不清”,但如果分割之后不做组件划分,你面对的就是一张张几十万像素的纯黑白图,根本没法直接回答“图里到底有几个缺陷、每个有多大、分别在哪”这类问题。

连通域分析要做的,就是把黑白图像中的每一个独立白色区域(或黑色区域)标记出来,赋予每个区域一个唯一的编号。打个比方:阈值分割相当于把一整片森林里的每一棵树都浇上白漆,老远看去白花花一片;连通域分析则是给每一棵树挂上不同的号码牌,之后你才能说“3号树生病了、7号树长得特别高”。

2.2 预处理和分割环节,哪些动作会直接影响连通结果

在聊连通域之前,必须先提一个经验:分割结果的质量,直接决定了连通域分析能不能用。这不是废话,而是很多人踩坑的地方——他们拿到一张效果很糟糕的二值图,跑去调连通域的参数,结果无论如何都得不到理想的目标区域。

在实际工位调试中,我通常会在分割后加一个很小尺寸的开运算(Open)操作,用来消除那些由噪声引起的孤立白点。开运算对面积小于结构元素大小的亮区域有抑制作用,能显著减少过分割碎块的产生。但这里有个度的问题:如果噪声粒子和真实细小特征尺寸接近,开运算会连真实特征一起抹掉。具体的取舍,我会在后面章节用实例展示。

另外,光照不均匀导致的二值化漂移,会让同一目标在图像不同位置的灰度基底不一致。这时如果你用的是全局固定阈值,某些区域的目标可能被漏分出来,连通域自然就“断”成好几块。针对这种情况,一般会改用自适应阈值或者先做一个顶帽变换(Top-Hat)去掉背景渐变,再进行分割。

3. 组件连通的技术内核:标签算法与邻接关系

3.1 四邻域和八邻域,差一个像素可能结论完全不一样

组件连通的前提是定义“怎样算连通”。OpenCV 里通常有两种模式:四连通和八连通。

四连通:一个像素只考虑上、下、左、右四个相邻位置,斜对角方向就算“接触”了也不算连通。八连通:把四个斜对角位置也纳入邻域范围。

选择哪一种,需要结合你的实际目标形态。以我调试过的印刷缺陷检测为例,墨迹的边缘经常出现斜向的尖角,如果使用四连通,一条斜向窄长的墨点会被拆成多段;而八连通能比较好地保留这类斜向结构的完整性。反过来,如果你对噪声敏感,四连通天然对细小的斜向结构不友好,反而能借此过滤掉部分斜向噪声。

再举一个更直观的例子:一个由像素组成的 45 度斜线。在四连通定义下,线上的某个像素只有正上下或正左右的邻居,斜线在严格几何意义上其实是“断”的——因为相邻像素只有角点接触,没有边线接触。但在八连通里,它们是连续的一条线。很多工业场景,比如 PCB 走线、玻璃划痕检测,走线或划痕往往都是任意角度走向的,这种情况下八连通更能反映真实物理结构。

3.2 两遍扫描法和种子填充法,原理其实不难

连通域标记算法有两大类:基于游程编码的两遍扫描(Two-Pass)和基于像素/区域的种子填充(Flood Fill)。

两遍扫描法是最经典的做法。第一遍扫描,给每个前景像素临时分配一个标签,同时记录不同标签之间的等价关系(Equivalence);第二遍再扫描一次,把等价类统一成最终标签。这个算法实现起来很直接,时间复杂度也线性,适合工程中稳定使用。

种子填充法是从某个前景像素出发,不断向邻域扩散,把所有连通的前景像素纳入同一个区域,直到边界耗尽为止。它天然适合做“按需提取”——比如你只想找出图像里最大的那个区域,用种子填充法从候选点出发,一次就可以拿到完整区域,不必像两遍扫描那样整幅图先全部标记完再挑。

OpenCV 的 connectedComponents 函数底层主要采用两遍扫描的改进版本,同时支持通过参数指定四连通或八连通。对于一般场景直接用就行,理解底层原理的意义在于:当你面对超大图像、性能受限时,能判断瓶颈出在哪里,是标签分配的开销,还是等价表合并的开销。

3.3 OpenCV 的 connectedComponents 到底返回了什么

C# 调用 OpenCvSharp 中的函数时,最常见的一句是:

int nLabels = Cv2.ConnectedComponents(binaryImg, labelsImg, connectivity: 8, ltype: MatType.CV_32S);

这段代码做了几件事:遍历输入的二值图 binaryImg,对每一个前景像素分配标签值,输出到 labelsImg 中。nLabels 返回的是连通域的总个数,注意是包含背景的。也就是说,如果图里有一个独立目标区域,nLabels 通常是 2——标签 0 是背景,标签 1 是目标。我见过不止一个同事在统计区域数量时直接拿返回值当目标数,结果永远比实际多 1。这个坑要记住。

另外 ltype 参数一般用 CV_32S,因为标签值是一个 32 位有符号整数。用 8 位类型会限制最大标签数不超过 255,当图像很大、目标很碎时,标签不够用会直接报错或产生错误的合并结果。这里建议直接用 CV_32S,别为节省那点内存去折腾别的类型。

4. C# 环境里实现组件连通的完整工程化思路

4.1 从 Mat 到区域特征,中间还缺一个关键环节

connectedComponents 只给你一张带标签的图,但实际使用中我们更关心的是每个区域的面积、外接矩形、质心等特征。OpenCV 有两个函数可以进一步处理:connectedComponentsWithStats 直接返回统计信息,包括每个区域的面积、外接矩形(bounding box)、质心坐标等;connectedComponentsWithAlgorithm 则允许你在不同算法实现之间切换。

我的建议是直接使用 WithStats 版本,一步到位。它在内部除了生成标签图,还会通过一次额外扫描统计各标签对应的特征数据。在 C# 中调用方式和基础版差不多:

using OpenCvSharp; Mat labels = new Mat(); Mat stats = new Mat(); Mat centroids = new Mat(); int nLabels = Cv2.ConnectedComponentsWithStats(binaryImg, labels, stats, centroids, 8, MatType.CV_32S);

stats 是一个 nLabels 行、5 列的 Mat,每行依次为:left、top、width、height、area。centroids 是每个区域的质心坐标(x, y),类型为 double。读取方式如下:

for (int i = 1; i < nLabels; i++) { int left = stats.At<int>(i, (int)ConnectedComponentsTypes.Left); int top = stats.At<int>(i, (int)ConnectedComponentsTypes.Top); int width = stats.At<int>(i, (int)ConnectedComponentsTypes.Width); int height = stats.At<int>(i, (int)ConnectedComponentsTypes.Height); int area = stats.At<int>(i, (int)ConnectedComponentsTypes.Area); double cx = centroids.At<double>(i, 0); double cy = centroids.At<double>(i, 1); }

需要注意,stats 的行数和 nLabels 一一对应,第 0 行是背景特征,一般不参与分析。centroids 的坐标是浮点类型,在高精度定位场景下比整数坐标更实用。比如你后续要计算两个区域的圆心距,用浮点质心会准得多。

4.2 用掩膜提取任意一个连通域,而不是整张图

调试视觉方案时,经常有这种需求:我只想看看第 5 个区域的长相,或者把第 3 号缺陷单独截出来存档。这时就要用到标签图 + 掩膜提取的技巧。

实现思路是:构建一个与标签图尺寸相同的全黑图,然后把某个标签对应的像素置为白色,其余保持为 0。利用 OpenCV 的比大小和位运算可以很优雅地完成:

Mat mask = new Mat(binaryImg.Size(), MatType.CV_8UC1, Scalar.All(0)); Cv2.Compare(labels, targetLabel, mask, CmpType.EQ);

这样得到的 mask 就是一个只包含目标区域的掩膜图。之后哪怕你再对这个掩膜做轮廓提取、几何测量,都是精准限定在该区域内部,不会再被其他区域的像素干扰。

这个技巧在工业方案里特别有用。比如检测一个 logo 是否完整,你可以先对整张印刷品做连通域分析,筛选出面积最大的那个区域作为候选 logo 区域,然后生成掩膜,再与标准模板做比对。后续的形态学操作、轮廓拟合都基于掩膜执行,运算量小、精度高。

4.3 特征过滤器的设计:面积、外接矩形长宽比都是硬指标

拿到所有区域的 stats 之后,真正的工作才刚刚开始——过滤。一个完善的连通域分析模块,通常包含以下几个维度的过滤条件:

第一是面积范围。设定最小面积和最大面积,把面积过小的噪声点和面积异常大的背景块(如整幅图的白色底板意外连成一片)直接排除。

第二是外接矩形尺寸。单独的面积无法区分一片细长的划痕和一个又矮又宽的横条,这时候就要看外接矩形的宽度和高度范围,以及宽高比。

第三是填充率(Fill Ratio),也就是区域面积除以外接矩形面积。这个指标能很好地区分实心块和空心环带。一个实心圆斑的填充率接近 π/4,而一个圆环的填充率会低很多。

第四是位置约束。比如你只想分析图像中心区域的缺陷,就能用质心坐标做一次范围判断,把边缘区域的碎块滤除掉,减少很多不必要的计算量和误报。

把这些条件封装成一个过滤器类,是工程化最值得投入的一步。

4.4 引用类型还是值类型:C# 封装连通域结果的数据结构设计

在实际项目里,我习惯把连通域分析的结果封装为一个独立的数据结构,而不是到处散用地裸的 stats Mat。原因很简单:裸 Mat 的 At 索引访问性能一般,且类型转换容易出错;另一方面,区域的各种特征分散在 matrices 里,代码可读性很差。

一个轻量级的区域数据类可以这样设计:

public class BlobRegion { public int Label { get; set; } public int Left { get; set; } public int Top { get; set; } public int Width { get; set; } public int Height { get; set; } public int Area { get; set; } public double CenterX { get; set; } public double CenterY { get; set; } public double FillRatio => (double)Area / (Width * Height + 1e-6); public Rect BoundingRect => new Rect(Left, Top, Width, Height); }

再用一个静态方法从 stats 和 centroids 批量转换成 List 。这样后续无论做过滤、排序、还是可视化叠加,都是在 List 上做 LINQ 查询,代码既简洁又高效。

public static List<BlobRegion> ExtractRegions(Mat stats, Mat centroids, int nLabels) { var regions = new List<BlobRegion>(nLabels - 1); for (int i = 1; i < nLabels; i++) { regions.Add(new BlobRegion { Label = i, Left = stats.At<int>(i, 0), Top = stats.At<int>(i, 1), Width = stats.At<int>(i, 2), Height = stats.At<int>(i, 3), Area = stats.At<int>(i, 4), CenterX = centroids.At<double>(i, 0), CenterY = centroids.At<double>(i, 1) }); } return regions; }

这样做还有一个额外的好处:单元测试变得容易了。你可以直接构造一个 List 喂给过滤逻辑,不必每次测试都依赖真实图片环境。

5. 实操过程:从一张真实缺陷图上跑通完整组件连通分析

5.1 案例背景:印刷字符区域内的脏点检测

我拿一个之前调过的实际场景来走一遍完整流程。检测对象是一张印有字符的标签,需要找出字符区域内的异常脏点。这里的难点在于,字符本身在二值化后也是一个大的白色区域,脏点通常紧挨着字符边缘,单纯按面积过滤很容易把真实脏点当成字符边缘的一部分。

处理策略分三步走。

第一步,预处理。原图转灰度后做高斯滤波,消除高频噪声。然后取一个相对较高的阈值做二值化,让字符和背景彻底分开。这一步要注意,阈值不要取得过高,否则字符笔画内部的浅色部分会被掏空,产生空洞,之后连通域统计会把一个完整的字符区域错误地拆成多个碎片。

第二步,组件连通分析。用 WithStats 获取所有白色区域的特征。在这一步,我把 connectivity 设置为 8,保证字符边缘的斜向结构能连成一个整体。

第三步,过滤。首先通过面积过滤排除掉那些面积小于 50 像素小噪点。然后重点来了——为了排除字符本身,我计算了每个区域的外接矩形,并将与字符区域外接矩形有交集的区域保留下来,滤除完全孤立的背景噪声。最后用填充率过滤掉一些空心环状的干扰。

5.2 关键代码演示:过滤器的联调

定义好区域类后,过滤逻辑可以这样实现:

var regions = ExtractRegions(stats, centroids, nLabels); double minArea = 50; double maxArea = 5000; var filtered = regions .Where(r => r.Area >= minArea) .Where(r => r.Area <= maxArea) .Where(r => r.FillRatio <= 0.8) .ToList();

这里把填充率的阈值定为 0.8,但因为字符实心区域的填充率普遍偏高(通常接近 1),而脏点往往是不规则的实心块,填充率可能只有 0.5 到 0.6,所以这个条件能有效筛掉大部分字符实体。当然具体阈值要针对你的实际图像做标定,不同打光、不同墨色,这个值波动很大。

筛选结束后,需要把结果叠加到原图上进行可视化确认。这一步最适合发现参数调得合不合理。我一般会用Cv2.Rectangle把每个通过过滤的区域的边框画出来,用Cv2.Circle画质心点。这样一眼就能看出哪些区域被选中、哪些被漏掉,比对着数字猜要高效得多。

5.3 单次执行性能实测:把耗时控制在毫秒级

在实际工位中,一幅 1200x800 的灰度图,从读到完成连通域分析,耗时大致在 25 到 35 毫秒之间。其中主要时间花在二值化、连通域标记、特征统计三步。这个速度对于每分钟几十个工件的产线来说是足够了。

如果遇到性能瓶颈(比如 4K 相机、每秒 30 帧),可以考虑先对图像做降采样,再进行连通域分析。很多场景下,目标区域的面积特征是尺度不变的近似——只要不是精确测量,降采样对结果影响有限。另外,如果你只需要找最大的那个区域,可以考虑在 downscale 后的图上先做连通域粗筛,锁定目标大概位置,再回到原图对应的小范围内做精确分析。这种“由粗到精”的策略在大型图像处理上非常管用。

6. 工业应用中的高级玩法:筛选、排序、合并与统计

6.1 按面积排序之后,做什么都顺手了

连通域分析的结果天然是按标签顺序排列的,这个顺序和区域大小没有任何关系。如果你需要“最大的区域优先”,就需要排序。在 C# 里,一行 LINQ 就能完成:

var sorted = filtered.OrderByDescending(r => r.Area).ToList();

排序在目标筛选场景中很有用。比如工件上要检测多个定位孔,面积最大的一般是主定位孔,其次是辅助定位孔。先排序,再按业务规则逐个拿取,代码逻辑会比按标签顺序操作清晰得多。

6.2 连通域的合并与拆分:特殊情况下的处理技巧

有时候分割结果会把一个完整目标分裂成好几块,或者把几个不同目标粘连在一起。

分裂通常是由于目标内部灰度不均、局部阈值造成空洞。处理后有两个选择:一是用形态学闭运算先把裂缝补上,再重新做连通域;二是在连通域后做“区域合并”,把满足特定条件(比如质心距离小于某个阈值、或外接矩形有重叠)的多个区域合并为一个逻辑区域。

粘连则是相反的问题。两个目标靠得很近,八连通让它们变成一个连通域。此时单靠连通域本身无法解决,需要回到分割阶段,使用分水岭算法或基于距离变换的分离操作。连通域分析在这里变成了一个“预检工具”——它告诉你粘连了,但搞定粘连的是分割环节的调整。

6.3 计数、坐标定位和缺陷标记:连通域输出怎么和业务对接

组件连通分析的最终价值要落到业务逻辑上。最典型的三个用途:

第一是目标计数。统计一张图里有多少个独立目标,比如药片数量、螺丝数量,直接拿过滤后的区域数量即可。注意只统计前景目标,别把背景标签 0 混进去。

第二是目标定位。利用质心坐标或者外接矩形中心,引导机械手抓取、对准。此时用浮点质心会比用外接矩形中心更稳妥,因为质心对目标内部的灰度分布不敏感,定位误差小。

第三是缺陷判定。计算每个区域的面积,和标准值做比较。超过上限判大缺陷,低于下限往往当成噪声忽略。这里需要说明的是,连通域面积统计的是像素个数,不等于物理面积。想得到物理面积,要考虑相机标定得到的像素当量,比如每个像素对应多少平方毫米。

如果项目对精度要求高,建议走一遍相机标定流程,不然你用像素值测出的面积是没法直接跟产品规格书的单位对应上的。

7. 经验心得:我踩过的坑和长期稳定运行的配置建议

7.1 调试阶段先在可视化工具里确认,再写死参数

我踩过最大的坑,就是一上来就写死了所有阈值参数,然后直接批量跑图。结果某些光照变化大的批次里,误报率突然飙升,排查起来非常痛苦。后来我养成一个习惯:所有连通域分析的参数都先做成可配置项,并且在调试模式下把每次筛选结果实时绘制到界面上,确认三五十张不同类型图像都没问题,再把参数固化进配置文件中。

7.2 同一套参数在不同分辨率下不要盲目复用

有一次我把一套在 200 万像素相机上调好的面积阈值,直接搬到一台 500 万像素的相机项目里,结果原来干净的背景多了几千个小噪声区域。原因很简单:分辨率提高后,同样的物理缺陷在像素层面的面积变大了,而那些原本小于阈值的噪声点也被放大到了阈值以上。

正确的做法是,换相机或调分辨率后,重新标定面积范围、填充率等关键参数。如果你的算法模块做得够好,这些参数都应该集中在配置中心里,而不是散落在代码的不同角落。

7.3 关于轮廓函数 findContours 和 connectedComponents 怎么选

很多新手会困惑:既然都能从二值图里提取区域,用 findContours 和 connectedComponents 有什么区别?

findContours 提取的是目标边缘轮廓,是基于拓扑结构的;connectedComponents 提取的是像素标注区域,直接对应完整的内部像素点集合。

计算面积时,findContours 配合 contourArea 算出来的是轮廓围成区域的面积,和真实的像素计数面积有细微差别(特别是边界上会差半个像素级别)。connectedComponents 的 area 是逐像素数出来的,更“真实”。

实际操作中,如果我只关心区域特征统计,优先用 connectedComponentsWithStats;如果要做形状分析(比如拟合椭圆、计算凸包),则通常从 findContours 入手,得到轮廓点序列更灵活。

一个很常见的组合是:先连通域分析,拿到目标区域掩膜或外接矩形,然后在矩形小图内跑 findContours 做精细的形状分析。这个策略兼顾了性能和灵活性,我在多个项目中都验证过效果很好。

7.4 标签图的复用价值,别用完就丢

很多快速原型里,拿到 stats 之后就把 labels 这张图释放掉了,其实它很有价值。标签图保存了每个区域的空间分布信息。比如有一次需要统计每个缺陷相对于基准位的偏移方向,我把 labels 图和基准位坐标结合起来,直接按标签找到对应区域内的所有像素点,再求像素点到基准位的最小距离,得到了每个缺陷的精确偏移分布。这套逻辑如果只用外接矩形,精度会差很多。

标签图另一个常见用途是做区域删选后的二次分析。如果你把面积小于阈值的区域标签直接置为背景,然后把新标签图作为掩膜,再输出到原有流程的下一个环节,可以有效避免原始二值图噪声干扰——这比重新做一次分割要稳定得多。

8. 完整可跑通的示例工程思路与后续扩展方向

8.1 一个最小可验证的控制台程序骨架

在实际工程里,我建议把视觉流程拆分为:图像读取 -> 预处理 -> 二值化 -> 连通域分析 -> 特征过滤 -> 结果输出。以下是一个控制台程序的最小骨架,可以直接在 C# 项目里运行:

using OpenCvSharp; class Program { static void Main(string[] args) { using var src = Cv2.ImRead("sample.png", ImreadModes.Grayscale); using var blurred = new Mat(); Cv2.GaussianBlur(src, blurred, new Size(5, 5), 0); using var binary = new Mat(); Cv2.Threshold(blurred, binary, 180, 255, ThresholdTypes.Binary); using var labels = new Mat(); using var stats = new Mat(); using var centroids = new Mat(); int nLabels = Cv2.ConnectedComponentsWithStats(binary, labels, stats, centroids, 8, MatType.CV_32S); var regions = ExtractRegions(stats, centroids, nLabels); var validRegions = regions.Where(r => r.Area > 100 && r.FillRatio < 0.85).ToList(); using var output = src.Clone(); foreach (var region in validRegions) { Cv2.Rectangle(output, region.BoundingRect, Scalar.Red, 2); Cv2.Circle(output, (int)region.CenterX, (int)region.CenterY, 4, Scalar.Green, -1); } Cv2.ImWrite("output.png", output); } }

这段代码的意图很直白,但我想强调的一点是:在正式的产线项目里,不建议把 sobel 或 Laplacian 这类增强算子放在 GaussianBlur 之前。预处理顺序对连通域分析的最终效果影响巨大,建议每一步都叠加可视化输出,确认中间结果没有异常再继续往下走。

8.2 向更复杂的工作流扩展:级联分析与结果联动

组件连通分析不是孤岛,它可以和其他视觉模块级联。比如表面缺陷检测中,你常常先做连通域分析得到缺陷区域列表,然后对每个缺陷区域单独跑一次形态学处理或纹理分析,再综合判定缺陷类型。这种“先粗筛再细查”的模式,可以大幅提升复杂检测的准确率。

我个人的体会是,连通域分析在一个视觉工作流里,有点像车间里的分拣员——它不负责判断这个零件合不合格,但它把“哪一坨像素跟哪一坨连着”这个最朴素的事实划分清楚,后面任何分析模块都能在这个清晰的划分上大展拳脚。做视觉开发这么多年,这个模块从来都不是最花哨的,但它永远是最稳的那个地基。

如果想把组件连通这一章做得更深入,可以在《章36》里聊聊轮廓分析与几何拟合,或者聊聊如何用深度学习方法取代传统阈值分割后,组件连通如何作为后处理模块继续发光发热。这些都是实际项目里很有价值的下一步方向。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进