做机器视觉这些年,网格线提取真的属于“看起来简单、做起来全是坑”的典型需求。客户经常丢过来一张图:把里面的横线单独抠出来,竖线也单独抠出来。如果图像干净、光照均匀,threshold加select_shape半小时搞定;但只要网格线稍微密集、图像带噪声或者光照不均,常规做法就开始翻车。Halcon里开运算恰恰是处理这类问题的利器。这篇就把我用Halcon开运算获取网格线横线和竖线的完整思路、参数依据、踩坑记录整理出来,希望你能少走点弯路。
这个方法在光伏栅线检测、PCB网格定位、标定板图像处理里非常常见。适合刚接触Halcon、只会用threshold和select_shape的新手,也适合已经在做线径测量、间距检测但被交叉点残留和断线困扰的老手。全文会从形态学原理讲到实战代码,再到问题排查和亚像素拟合,基本覆盖一个网格线提取项目的完整链路。
1. 网格线提取这件事,为什么开运算是首选?
1.1 先搞懂开运算到底对图像做了什么
开运算的数学定义是“先腐蚀、后膨胀”。在二值图像上,腐蚀会收缩亮区域,把那些细小的突出、孤立的噪声点消掉;膨胀则会把收缩后的区域重新扩大回去。两者叠加的效果是:面积较大的、形状符合结构元素的区域基本能恢复到原来的尺寸,而那些不属于结构元素形状的细节会被彻底删除。
用大白话说,开运算就是一个“形状过滤器”。你给它一个水平方向的长条矩形结构元素,它扫描整张图时会问:“这里有没有一段至少30像素长、1像素高的横向亮区域?”有,就保留;没有,就删除。于是竖线在这种定向模板下就很容易被腐蚀掉,横线却能安然无恙。
开运算为什么比单纯腐蚀好用,关键就在这里。腐蚀会把所有区域无差别缩小一圈,横线变短、变细,后续测量全部失真。膨胀更是灾难,会把横线和竖线在交叉点处焊成一片。只有开运算能在过滤形状的同时,尽量保持剩余区域的原始尺寸,这对工程测量来说太重要了。
1.2 开运算、闭运算、腐蚀和膨胀,到底怎么选
很多初学者会把四个算子混在一起,觉得反正都是形态学。实际上它们的用途差异非常大。用一张表来说明:
| 操作 | 对亮区域的直观作用 | 典型用途 | 在网格线提取里的角色 |
|---|---|---|---|
| 腐蚀 | 缩小边界,消除小凸起 | 去孤立噪声、拆分接触区域 | 不太直接用,开运算的内部组成 |
| 膨胀 | 扩大边界,连接邻近区域 | 填补小断裂、扩大特征 | 不太直接用,开运算的内部组成 |
| 开运算 | 先腐后膨,滤除小结构并保持尺寸 | 去亮噪声、分离方向性结构 | 分离横线和竖线的核心手段 |
| 闭运算 | 先膨后腐,填充小孔和小缺口 | 修复断裂、连接邻近区域 | 用于开运算后的断线修复 |
网格线提取场景里,如果目标是得到干净的横线区域,开运算天然适配:它能去掉竖线、毛刺、细小的引线,同时保留横线的宽度和长度。闭运算反而会把交叉点附近的区域补得更完整,让横竖更难分家。
还有一点值得注意:Halcon里的开运算分灰度开运算和区域开运算。灰度开运算作用于图像,能削弱亮斑和细纹理,效果更柔和;区域开运算作用于二值化后的Region,速度更快,参数也更直观。我们做网格线分离时,通常先用区域开运算,因为后续connection、select_shape都是区域操作,流程最顺。
1.3 为什么边缘检测和直线拟合不是第一选择
有人会问:“我直接用Canny边缘检测,再用Hough找直线不也能分离横线和竖线吗?”能,但在很多工业图像里非常脆弱。
边缘检测得到的是边缘点,不是形态完整的线条区域。网格线一旦有断点、遮挡或者光照暗区,边缘就断成一截一截,后续需要复杂的拼接策略。Hough变换对参数空间离散化敏感,网格线稍有倾斜或线宽不均,容易检出重复直线或者把断线检成一条斜线。
形态学开运算的优势在于它是一个“区域性”操作。它不依赖边缘的连续性,而是依赖形状的几何尺寸。哪怕一条横线中间有光照造成的灰阶波动,只要整体上它是一个横向连续区域,矩形核就能把它保留下来。所以我在项目中通常把形态学作为第一道工序,边缘拟合放到后面提取亚像素坐标时再用。
2. 横竖分离的两条路线:区域形态学与形状特征筛选
2.1 路线一:用矩形结构元素做方向性开运算
方向性开运算是分离横竖线最直观的方法。Halcon里对应的是opening_rectangle1算子,直接对二值Region做矩形核开运算,参数就三个:输入区域、输出区域、核的宽和核的高。
提取横线时,用宽大扁平的核,比如Width = 31, Height = 1。这个核只认可横向足够连续的结构,竖线因为高度方向不够长,在腐蚀阶段就会消失。提取竖线时反过来,用Width = 1, Height = 31的核。这里的宽高尺寸不能乱填,我后面会专门讲参数逻辑。
实际写出来就是:
* 提取横向线 opening_rectangle1 (RegionGrid, RegionHorizontal, 31, 1) * 提取纵向线 opening_rectangle1 (RegionGrid, RegionVertical, 1, 31)这个方法非常快,1000万像素级别的图像也就几十毫秒。但前提是网格线基本水平或垂直,一旦倾斜超过两三度,矩形核就开始失效。
2.2 路线二:先分割区域,再按形状特征分家
有些网格线线宽变化很大,或者图像里有比网格线更粗的特异结构,这时候方向性开运算容易误删。更稳妥的做法是先把所有网格线区域提取出来,然后利用每个连通域的形状特征来分横竖。
流程是:threshold得到Region,connection把连通的区域拆开,select_shape根据面积、宽度、高度、方向角筛选。
threshold (Image, RegionGrid, 120, 255) connection (RegionGrid, ConnectedRegions) select_shape (ConnectedRegions, HorizontalLines, ['width','height','orientation'], 'and', [50, 1, -0.3], [5000, 5, 0.3]) select_shape (ConnectedRegions, VerticalLines, ['width','height','orientation'], 'and', [1, 50, 1.2], [5, 5000, 1.9])这里orientation是以弧度为单位的区域主轴方向。横线的角度接近0或π,竖线的角度接近π/2,所以用弧度区间筛选是可行的。这个方式的好处是不用关心核尺寸,线粗一点细一点都能应对。缺点也明显:如果交叉点让横竖线连成一个大区域,connection拆不开,select_shape就筛不掉。
2.3 两条路线的对比和组合
| 对比项 | 方向性开运算 | 区域特征筛选 |
|---|---|---|
| 对线宽变化的鲁棒性 | 一般,核尺寸固定 | 较好,基于实际区域特征 |
| 交叉点粘连 | 有一定抗性,因为方向核会消掉另一方向的部分 | 差,连通域合并时筛不干净 |
| 对倾斜线的处理 | 差,矩形核角度固定 | 较好,orientation可以适应小角度倾斜 |
| 计算速度 | 快 | 中等,connection和select_shape稍慢 |
| 适用阶段 | 适合作为第一道粗分离 | 适合在粗分离后精筛 |
实际项目里我经常两路线混用:先用方向性开运算把横竖线粗分离,再用select_shape清掉残余碎块;或者先用select_shape去掉明显不是网格线的干扰,再用方向性开运算把交叉区域拆开。没有哪个是银弹,组合起来才稳。
3. Halcon实战流程:一步步把横线竖线抠出来
3.1 输入图像与预处理
先解决图像格式问题。工业相机输出的一般是彩色图或者灰度图,Halcon里统一读入后转灰度。如果图像本身是黑白,跳过rgb1_to_gray即可。
预处理阶段我最常用的是中值滤波,它能在不模糊线条边缘的前提下把椒盐噪声和细碎纹理压掉。median_image的滤波器类型我用'circle',半径选3,太大容易把细线抹平,太小噪声又滤不干净。
* 读取图像 read_image (Image, 'grid.png') * 转灰度 rgb1_to_gray (Image, GrayImage) * 中值滤波去噪声 median_image (GrayImage, MedianImage, 'circle', 3, 'mirrored')这一步的目标只有一个:让后续二值化得到更干净的区域。别小看中值滤波,它能让后面select_shape的筛选索引省很多事。
3.2 二值化:把网格线变成Region
预处理之后就是阈值分割。这里需要先确认图像中网格线是亮还是暗。如果网格线比背景亮,threshold直接选一个下阈值;如果网格线是暗的,可以先invert_image再处理,或者threshold时选上阈值。
我习惯直接把Threshold之后的区域显示在图像上检查一遍,看有没有漏线、有没有大片噪声区域混进来。只要这一步区域基本干净,后面的方向性开运算就很好做。
* 亮网格线场景 threshold (MedianImage, RegionGrid, 120, 255)如果光照不均导致明暗差异大,全局threshold不靠谱。这时可以用local_threshold或者dyn_threshold。dyn_threshold需要生成一个平滑的背景图像,然后用原图减去背景再阈值,原理类似顶帽变换,对渐变光有奇效。代码大概是:
mean_image (MedianImage, MeanImage, 51, 51) dyn_threshold (MedianImage, MeanImage, RegionGrid, 10, 'dark')注意'not_equal'、'dark'、'light'三种模式的选择,取决于你想提取的是网格线还是背景。用'dark'表示原图比背景暗的部分被提取,适合暗线条。
3.3 区域开运算分离横竖线
这是全文的核心环节。拿到RegionGrid之后,分别用水平矩形核和垂直矩形核做开运算:
* 分离横向线 opening_rectangle1 (RegionGrid, RegionHorizontal, 31, 1) * 分离纵向线 opening_rectangle1 (RegionGrid, RegionVertical, 1, 31)开运算后的结果通常还带一些小碎块,比如横线上残留的交叉点块、竖线被误删后的残角。用connection拆开,再用select_shape按面积和长宽比过滤:
connection (RegionHorizontal, ConnectedHorizontal) select_shape (ConnectedHorizontal, CleanHorizontal, ['area','width'], 'and', [500, 30], [99999, 999]) connection (RegionVertical, ConnectedVertical) select_shape (ConnectedVertical, ['area','height'], 'and', [500, 30], [99999, 999])这一步之后,CleanHorizontal和CleanVertical基本就是你想要的“纯横线”和“纯竖线”区域了。
3.4 结构元素尺寸怎么定:一个可复用的经验公式
很多新手卡在核尺寸上。调小了竖线去不干净,调大了真实横线也被腐蚀断。我总结的经验公式是:先量一下网格线的实际线宽w,再量一下相邻线间距d。矩形核的长边取线宽的3到5倍,但必须小于间距的一半。
举个例子:线宽3个像素,间距20个像素。长边取3×3=9到5×3=15之间,间距的一半是10,所以取9到10比较合适。我通常会先用9试,发现竖线残留就加到11,发现横线断裂就降到8。这个区间反复微调,每次只改两个像素,效率比盲试高很多。
核的短边一般固定为1。如果你发现横线边界有毛刺,短边可以设为2,但不能再大,否则会把线宽本身腐蚀掉。简短的检查方法是把开运算结果叠加到原图上,看横线宽度有没有明显变窄,变窄就说明核太宽了。
4. 实战中的四个翻车现场与排查思路
4.1 交叉点残留导致横竖线粘在一起
现象很典型:提取出的横线区域在网格交叉点处带着一块“小方肉”,竖线区域也有同样的现象。之后做骨架化或者直线拟合时,这些残留会让直线方向偏转,测量结果忽大忽小。
我当时的排查链路是这样的:先把RegionHorizontal和RegionVertical做交集,即intersection (RegionHorizontal, RegionVertical, CrossRegion),然后计算CrossRegion的面积。如果交叉区域面积明显大于“线宽×线宽”的理论值,说明方向性开运算没把交叉点的另一方向彻底消掉。原因通常是矩形核的长边不够大,或者线宽不均匀导致部分交叉块厚度超过核的短边。
解决思路有两个。一是加大矩形核长边,让腐蚀更彻底;二是保留残留,在后续骨架化时用分支裁剪去掉节点区域。直接做difference把交叉点减掉虽然也能清干净,但会让横线在交叉处断开,反而更麻烦。
4.2 开运算核过宽,真实线被腐蚀断头
另一个常见问题是“用力过猛”。为了把交叉点彻底消掉,把核长边从15加到30,结果横线在断点处直接蒸发。排查方法很简单:显示RegionHorizontal,放大到每条线末端,看是不是所有线都断在同一侧。如果是,基本就是核尺寸超过了线段的连续长度。
有些横线在图像里本来就只有20像素长,你用31像素的核,开运算后当然什么都不剩。这种情况下要回到3.4的经验公式,用线长而不是线宽来约束核尺寸。我的习惯是核长边不要超过最短目标线的60%,这样既能分离方向,又不至于误删短线。
如果实际需求就是线短、线密,方向性开运算可能就是不合适的手段,这时候建议改走select_shape路线,或者先做一次闭运算把短线接长,再做方向性开运算。
4.3 光照不均让二值化区域忽粗忽细
这个坑最容易出现在有弧面或环境光反射的产线上。全局threshold时,亮区线宽5像素,暗区线宽只有2像素,甚至直接断裂。方向性开运算倒是没做错,问题是输入的二值区域本身就烂了,输出自然烂。
我排查时先把RegionGrid和原图叠加,如果看到断线都集中在图像某个连续区域,就判断是光照问题。解决方案是放弃全局threshold,改用局部阈值。前面提到的dyn_threshold就很好用。背景平滑核的尺寸一定要大于最粗的线条直径,否则背景图里还残留线结构,减出来反而更糟。
另外,还可以在阈值前做一次灰度开运算,把背景的明暗不均去掉。Halcon里用gray_opening配合较大的结构元素,可以把大范围阴影抹掉,再threshold时就干净很多。
4.4 网格倾斜时定向开运算直接失效
网格线不是严格水平垂直,而是带了一个5度左右的倾斜角。这种情况下,31×1的水平核去腐蚀竖线,竖线因为自身也有横向投影,会被保留一部分;横线也因为倾斜,在某些段无法满足“连续横向覆盖”,出现周期性的断点。整体效果就是横也分不干净,竖也分不干净。
我当时的处理分两步。第一步,先算整体倾斜角,用orientation_region (RegionGrid, Phi)得到区域主轴角度,再走rotate_image或rotate_region把网格校平。第二步,在校平后的图像上重新做方向性开运算。gamma角小于2度时可以不校正,稍微加大核的长边并接受少量残留;超过2度就必须校正。
还有一种思路是先把整个网格区域提取出来,不用方向性开运算,而是对区域做骨架化,再根据骨架线段的局部方向分类。这个办法对任意角度都有效,但运算量稍大,适合离线分析和调试。
5. 从“横线区域”到“网格交点”:让结果继续可用
5.1 骨架化:把区域变成单像素曲线
很多场景下我们不只需要区域,还需要精确的线条位置。区域是有宽度的,直接用它做尺寸测量误差很大。常规选择是先做骨架化,把区域压缩成单像素宽的骨架线。
Halcon里的skeleton算子一步到位:
skeleton (CleanHorizontal, SkeletonH) connection (SkeletonH, ConnectedSkeletonH) select_shape (ConnectedSkeletonH, SelectedSkeletonH, 'area', 'and', 100, 99999)骨架化后的交叉点区域会产生分叉,如果你需要的是纯横线骨架,最好在交叉点处打断。一个简单办法是利用之前算出的交叉区域,用difference把CrossRegion从骨架里减掉,再重新connection。这样得到的每条骨架线段都不会包含交叉节点。
5.2 亚像素直线拟合
骨架线还是像素级坐标,想进一步精确,需要把骨架转成XLD轮廓,再做直线拟合。Halcon的基本路线:
gen_contour_region_xld (SelectedSkeletonH, ContoursH, 'border') segment_contours_xld (ContoursH, SegmentsH, 'lines', 5, 4, 5) fit_line_contour_xld (SegmentsH, 'tukey', -1, 0, 5, 2, RowBegin, ColBegin, RowEnd, ColEnd, Nr, Nc, Dist)fit_line_contour_xld里'sigma'和'Tukey'的金字塔参数要在实际图上试,一般把最大距离设为2到3像素就能滤掉边缘毛刺。拟合后拿到的RowBegin、ColBegin、RowEnd、ColEnd就是每条线的起终点,配合Nr、Nc的方向分量,可以做各种几何运算。
5.3 求交点、间距与畸变
有了直线方程,求横竖交点就很容易。Halcon里intersection_lines可以直接算两条无限直线的交点:
intersection_lines (Row1, Col1, Angle1, Row2, Col2, Angle2, RowCross, ColCross, IsOverlapping)其中Angle可以用angle_line从起终点求得。把所有横线和所有竖线两两求交,就能得到网格的全部节点。这些节点的坐标可以直接用来判断网格间距是否均匀,也能用来做标定映射或者畸变分析。
我在项目里通常会把节点坐标连起来画成一张网格图,和原图叠加,一眼就能看出哪里的线间距超差。这个可视化步骤虽然简单,但比看一堆数据管用得多。
最后说一个我自己的操作习惯:所有核尺寸参数定下来之前,先用region_to_image做几张不同尺寸的示意图,放在网格图上观察覆盖关系。比如想看核的宽度够不够,就画一个1×31的白色矩形,放到横线上看它是否完全被线包住。这样做能避免反复调参的盲目感。形态学参数本身不复杂,复杂的是不同图像表现差异很大,把检查动作做在前面,成功率会高一截。