☰
礼帽与黑帽变换:OpenCV形态学背景估计与缺陷检测调试
2026/10/24 4:59:30 网站建设 项目流程

去年接了个表面检测的小任务,产品是深色注塑件,缺陷分两类:一类是比周围亮一点的凸点,零点几毫米;另一类是比周围暗的细划痕,宽度一两个像素。第一版用的是全局阈值加大津法,白天拍的样图还行,搬到产线荧光灯下,整张图背景亮度从左上到右下差了三四十个灰度级,阈值一调就两头堵——压住背景就漏掉缺陷,抓住缺陷就满屏噪点。后来把思路换成“先估计背景,再把背景减掉”,问题一下就顺了。减背景这一步用的就是形态学里的礼帽变换和黑帽变换,也叫顶帽与底帽,OpenCV 里对应cv2.MORPH_TOPHAT和cv2.MORPH_BLACKHAT两个标志位。这两个操作代码只有一行,但真正决定成败的,是核尺寸怎么定、噪声怎么压、阈值怎么选,以及什么时候它压根不该上场。下面把我这几轮调试里攒下来的东西完整摊开讲一遍,从公式的物理含义到能直接抄的脚本,再到结果全黑、满屏白点这类问题的排查链路。

1. 这两个操作到底在跟谁做减法

1.1 开运算留下的不是噪声,而是一块局部背景

很多人第一次看MORPH_TOPHAT的文档,只记住一句话“原图减去开运算结果”,然后就卡住了:开运算我懂,先腐蚀后膨胀,去小亮点;可为什么要减它?减出来的东西为什么正好是目标?

把灰度图想象成一张起伏的地形图,亮度就是海拔。腐蚀是在每个位置取邻域内的最低点,相当于用一把铲子把山头削平;膨胀再取邻域最高点,把削下去的地方重新抬起来,但抬的高度受周围地形限制。这两个动作连起来,效果是:所有比结构元素“窄”的山头被整体磨掉,比结构元素宽的缓坡和大地形原样保留。所以开运算的输出,本质上是一张抹掉了细小亮结构之后的背景地形图——它不包含你要找的凸点,只保留了照明、材质、反光这些大尺度的东西。

既然开运算结果约等于背景,那把原图减掉它,剩下的自然是“背景上没有、原本存在”的那些小亮峰。这就是礼帽,数学写作TH = I - Open(I, K)。它的物理意义非常直白:局部背景减法。核越大,估计出来的背景越平滑,减完之后保留的结构尺度也越大;核小到接近目标尺寸,减完基本什么都不剩。

1.2 闭运算填掉的正是黑帽要找的东西

闭运算是反过来的顺序:先膨胀再腐蚀。膨胀让亮的区域往外扩,把小的暗坑、暗缝填平;随后的腐蚀再把边界收回来,但已经被填掉的部分回不去了。所以闭运算的结果是一张把细小暗结构补平之后的背景图,它不小于原图,任何位置都不会比原图更暗。

BH = Close(I, K) - I,闭运算减去原图,剩下的就是“本来有一个暗坑,被填平了”的那部分高度差。凸起的亮块不会在闭运算里被改动,减完是零;细小的暗划痕、暗斑点、白纸上的黑字笔画,全都会被这一减暴露出来。

这两者的分工可以一句话记死:

操作表达式针对目标结果特征
礼帽(顶帽)原图 − 开运算比背景亮的细小结构背景趋近全黑,亮目标凸显
黑帽(底帽)闭运算 − 原图比背景暗的细小结构背景趋近全黑,暗目标变亮

我见过不少人把这两个搞反,尤其是处理“白底黑字”的文档图时用了礼帽,结果啥也提取不到。判断方法很简单:目标比周围亮就用礼帽,目标比周围暗就用黑帽。别记名字,记亮度关系。

2. 灰度形态学里的那条不等式,决定了结果天然非负

2.1 从集合运算到灰度曲面,变的是包络关系

二值形态学里,开运算是结构元素在目标内部“滚一圈”能覆盖的所有位置的并集,所以开运算结果必然是原图的子集。推广到灰度图,这个“子集”关系变成了逐像素的大小关系:Open(I) ≤ I ≤ Close(I)。这不是巧合,是算子性质决定的——灰度腐蚀取邻域最小值,输出必然不大于中心像素;灰度膨胀取邻域最大值,输出必然不小于中心像素。开运算先腐蚀(变小)后膨胀(变大),最终结果被压在原图下面;闭运算先膨胀后腐蚀,结果顶在原图上面。

有了这条不等式,礼帽和黑帽的结果在数学上就一定是非负的。实际用起来这一点很重要:你拿到的礼帽图里不会出现负值,背景区域就是一片接近 0 的暗区,目标的亮度值等于它高出局部背景的那部分高度。这个“高度”是有物理量纲的灰度差,不是随便一个特征值,所以后面做阈值时心里有数——如果缺陷只比背景亮 8 个灰度级,那礼帽图里它的峰值也就 8 左右,你拿固定阈值 127 去切,必然切出全黑。

2.2 尺度分离:核大小就是一道高通滤波的截止尺度

我更喜欢用信号处理的视角来理解这两个变换。把图像拆成不同尺度的成分:照明渐变、反光带、阴影属于低频大尺度;缺陷、纹理、颗粒属于高频小尺度。开运算用一个尺寸为 k 的结构元素,把尺度小于 k 的亮结构全部抹掉,保留尺度大于 k 的部分,所以Open(I)是原图的低频分量(针对亮结构这一侧),而I - Open(I)就是被滤掉的那部分高频。

这意味着核尺寸不是随便填的,它直接对应你要保留的目标尺度。核比目标大,目标被完整滤出,礼帽里清晰可见;核比目标小,结构元素放不进目标内部,开运算伤不到它,减完接近零。这就是为什么很多人第一次试礼帽,随手写个(3,3)的核,结果图全黑——不是 API 用错了,是尺度参数没对上。

顺带说一句,礼帽和黑帽都不是严格的线性高通滤波,它保边不模糊,这一点比高斯差分、拉普拉斯这些算子更适合工业检测,因为缺陷的边界不会被抹成一片渐变,后续找轮廓、量尺寸都更准。

2.3 别和形态学梯度混淆

MORPH_GRADIENT是膨胀减腐蚀,输出的是所有边缘,不管亮暗,只要灰度有跳变就会响应。礼帽和黑帽是带方向选择的:礼帽只对“亮的小凸起”响应,黑帽只对“暗的小凹陷”响应。做表面缺陷检测时这个区别很关键,因为划痕有亮有暗(取决于打光角度),用梯度会得到一堆干扰边缘,用礼帽加黑帽各跑一遍再合并,才能把两类缺陷分开统计,也方便后面对不同缺陷走不同判定规则。

3. 结构元素的形状与尺寸,比操作本身更影响结果

3.1 方核、圆核、十字核的实测差异

OpenCV 提供三种常用形状,MORPH_RECT(矩形)、MORPH_ELLIPSE(椭圆,实际是内接椭圆)、MORPH_CROSS(十字)。它们的差别在礼帽上体现得特别明显,因为礼帽保留的是“结构元素放不进去”的部分,形状不匹配会直接改变保留下来的目标轮廓。

矩形核在水平和垂直方向的作用范围最大,对斜向的细结构抑制较弱。所以你要提取的是圆的凸点,矩形核会把凸点的对角线方向保留得更多,出来的斑块偏方形。椭圆核对各方向的作用比较均匀,提取圆形颗粒、细胞、焊点时边缘更干净,我一般默认用它。十字核只在正交方向生效,对斜线几乎不起作用,处理斜向划痕时反而会给背景留出大量残留,一般只在特定的骨架化或细化流程里才用。

实测下来,同一张图、同样尺寸的核,椭圆核出来的目标边界最贴合原目标形状,矩形核出来的斑块面积会略大一圈,十字核介于两者之间但方向性太强。做面积统计、颗粒计数这类需要精确量化的任务,优先椭圆核。

3.2 核尺寸该怎么算,给一组可以直接套的经验值

没有公式能精确算出核尺寸,但有个稳妥的起步方法:量出目标在任意方向上的最大宽度 w,核尺寸取 1.5w 到 2w,向上取奇数。取 1.5 倍是为了让结构元素确实放不进目标内部,取 2 倍是留一点余量对抗二值化边缘的抖动;超过 2.5 倍,背景的大尺度起伏开始漏进礼帽图,反而引入干扰。

下面这组数据是我在一个 0.05 毫米凸点检测任务上记录的,图像分辨率约 0.01 毫米/像素,凸点直径约 4 像素:

核尺寸(椭圆)礼帽后目标峰值现象
3 × 3约 2目标几乎全被开运算保留,减法后接近全黑
5 × 5约 15目标边缘开始消失,中心残留一圈
7 × 7约 32目标完整提取,形状清晰
9 × 9约 34与 7 接近,背景开始有轻微残留
21 × 21约 34目标完整,但大片照明不均被当成目标保留
41 × 41约 35背景残留严重,已无法用阈值分离

这组数据说明一个很反直觉的结论:核越大不等于越好。核小到一定程度后礼帽基本失效,核大到一定程度后光照梯度会被当成“大尺度亮结构”保留下来。合适的窗口其实挺窄,所以我一般会用 3 到 4 个候选尺寸各跑一遍,看峰值和背景残留的比例,再定下来。

3.3 iterations 能不能顶替更大的核

morphologyEx有个iterations参数,默认 1,设成 2 意味着整个形态学操作重复两遍。写起来很省事,但要注意它和“换一个更大的核”并不完全等价。

矩形核下,3×3 迭代两次在效果上接近一次 5×5,因为矩形腐蚀是可分离的、可叠加的。椭圆核下这个等价关系就不成立了,重复两次 3×3 椭圆核得到的是一个近似菱形偏方的作用区域,边界形状和一次 5×5 椭圆有明显差别。所以如果你对目标形状的保真度有要求,比如要做圆度判定,老老实实换核尺寸,别用迭代次数凑。

迭代次数真正的用处是在对抗噪声时做“渐进腐蚀”,比如黑帽提取细划痕,先用(3,3)核跑两遍去掉单像素干扰,再用大核跑一遍提取主干,这种分步策略比一次性上大核更可控。至于性能,矩形核在 OpenCV 内部可以做行列分离,同尺寸下比椭圆核快不少,对实时性有要求的产线上,我经常退而求其次用矩形核,然后在后处理里用开运算修一下形状。

4. 两段能直接跑通的链路,从原图到干净掩膜

4.1 亮目标提取:礼帽的完整调用流程

这段代码是我在自己项目里用了几轮的版本,删掉了业务逻辑,只保留通用部分。流程是:轻度去噪、礼帽、动态拉伸、自适应阈值、面积过滤。

import cv2 import numpy as np gray = cv2.imread("part.png", cv2.IMREAD_GRAYSCALE) assert gray is not None, "读图失败,检查路径和权限" # 1) 去噪:核必须明显小于目标尺寸,否则把目标一起抹了 denoised = cv2.GaussianBlur(gray, (3, 3), 0) # 2) 礼帽:核取目标最大宽度的 1.5~2 倍 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) tophat = cv2.morphologyEx(denoised, cv2.MORPH_TOPHAT, kernel) # 3) 拉伸:礼帽结果整体偏暗,动态范围被压缩,先归一化 th_f = tophat.astype(np.float32) lo, hi = np.percentile(th_f, 1), np.percentile(th_f, 99.5) th_f = np.clip((th_f - lo) / max(hi - lo, 1e-6), 0, 1) * 255 th_u8 = th_f.astype(np.uint8) # 4) 阈值:均值 + k 倍标准差,比固定阈值稳得多 mu, sigma = th_u8[th_u8 > 0].mean(), th_u8[th_u8 > 0].std() th_val = int(np.clip(mu + 3.0 * sigma, 25, 240)) _, mask = cv2.threshold(th_u8, th_val, 255, cv2.THRESH_BINARY) # 5) 开运算去掉孤立噪点,核用 3x3 即可 mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) num, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8) for i in range(1, num): area = stats[i, cv2.CC_STAT_AREA] if area < 12 or area > 4000: mask[labels == i] = 0

第 3 步容易被跳过,但它是效果好坏的分水岭。礼帽输出的原始图里,背景区域可能只有 0~3 的灰度值,目标峰值 30 左右,此时用 Otsu 有时还行,但一旦图里噪声稍多,Otsu 会把大量低值噪声一并切进来。按百分位拉伸后再算mean + k*sigma,相当于自动适应了这张图的对比度水平,换批次换光照不用重调参数。k我一般取 2.5 到 4,缺陷越少、越大,k就可以适当降低;缺陷密集时提高,防止粘连。

第 4 步里我用的是th_u8[th_u8 > 0]来统计,把纯黑背景排除掉,否则背景占压倒性比例,sigma会被拉得很小,阈值定得偏低。这个细节不写进代码很容易翻车。

4.2 暗目标提取:黑帽的流程与几处差异

黑帽的调用结构和礼帽完全一样,只是换个标志位。但有两个地方必须调整,否则结果会让你怀疑人生。

# 前处理同上 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)) blackhat = cv2.morphologyEx(denoised, cv2.MORPH_BLACKHAT, kernel) bh_f = blackhat.astype(np.float32) lo, hi = np.percentile(bh_f, 1), np.percentile(bh_f, 99.5) bh_f = np.clip((bh_f - lo) / max(hi - lo, 1e-6), 0, 1) * 255 bh_u8 = bh_f.astype(np.uint8) # 黑帽场景常是“线状目标”,闭运算去掉断点比开运算更合适 bh_u8 = cv2.morphologyEx(bh_u8, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))) mu, sigma = bh_u8[bh_u8 > 0].mean(), bh_u8[bh_u8 > 0].std() th_val = int(np.clip(mu + 2.5 * sigma, 20, 240)) _, mask = cv2.threshold(bh_u8, th_val, 255, cv2.THRESH_BINARY)

第一处差异:黑帽的目标往往是细长的划痕、笔画、裂纹,形态偏线状,所以后处理用闭运算把断裂处接起来比用开运算更合理,因为开运算会把细线直接腐蚀断。第二处差异:线状目标面积小、峰值低,k值要相应降低到 2 到 2.5,否则阈值过高,细线中段会被切掉,只剩下最深的几段。

还有一点值得单独说:黑帽对背景的均匀性比礼帽更敏感。因为闭运算用的是邻域最大值,如果背景本身有大面积暗区,闭运算会把暗区填亮,减完让整片暗区都变成“目标”。所以黑帽之前如果背景明暗跨度大,最好先做一次大核平滑匀光,或者直接用“闭运算得到背景后做除法”的照明校正,把低频压掉再上黑帽。

4.3 两个变换叠加使用的局部对比度增强

礼帽和黑帽还能凑成一个很实用的增强公式:增强图 = 原图 + 礼帽 − 黑帽。思路是:礼帽代表亮细节高出背景的部分,加上去让亮细节更亮;黑帽代表暗细节低于背景的部分,减去它让暗细节更暗。加减都带饱和截断,不会溢出 8 位范围,OpenCV 的add和subtract天然处理了这点。

k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11)) top = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, k) blk = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, k) enhanced = cv2.add(gray, top) enhanced = cv2.subtract(enhanced, blk)

这个增强在低对比度的文档扫描、老照片细节还原、医学影像的纹理增强上都挺好用,它比直方图均衡化的好处是不放大背景噪声,只放大“相对局部背景的偏离量”。缺点是处理速度比单次礼帽慢一倍左右,实时场景要算一下预算。核尺寸的选择逻辑和前面一样,想增强什么尺度的细节,就把核定在那个尺度稍大一点。

5. 三类真实场景里我用的参数配方

5.1 光照不均的金属件表面检测

金属件的问题是反光,高光区域整片饱和,阴影区域又黑得没细节。单纯全局阈值完全没法用,自适应阈值又容易被高光边缘带偏。我的做法是先用大核闭运算估计背景,再做比值校正,然后才上黑帽找缺陷。

# 背景估计:核要远大于缺陷,但小于照明变化的尺度 bg_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (61, 61)) bg = cv2.morphologyEx(gray, cv2.MORPH_CLOSE, bg_k) # 比值校正,比减法更能对抗乘性光照变化 corrected = cv2.divide(gray.astype(np.float32), bg.astype(np.float32) + 1e-3) corrected = np.clip(corrected * 128, 0, 255).astype(np.uint8)

这里为什么用闭运算估背景而不是开运算?因为金属件表面缺陷是暗的(凹坑、划痕),闭运算把暗结构填平,剩下的是干净的亮背景;开运算反而会把高光小点抹掉,估出来的背景偏暗,除法校正后高光会过曝。这个选择逻辑跟前面“目标比背景亮用礼帽”是一脉相承的,只是换了个用法。

校正之后再用黑帽提取暗缺陷,核取 9 到 15 之间。这套流程在三个不同批次上跑过,参数只微调了阈值倍数,核尺寸没动过。

5.2 白底黑字的文本行与印章提取

文档类图像用黑帽是个经典用法,但很多人不知道它比自适应阈值强在哪。自适应阈值是逐像素按局部均值或高斯加权均值算阈值,遇到粗笔画时,笔画中心会被判成背景,出现“空心字”;黑帽是形态学尺度算子,只要核比笔画宽,笔画整体就被填平,减完是实心的,不会空心。

配方是这样的:先转灰度,核尺寸取笔画宽度的 2 倍左右。A4 扫描件 300dpi 下,正文笔画一般 3 到 5 像素宽,核取(11,11)椭圆核差不多。黑帽之后做一次百分位拉伸,然后用 Otsu 二值化。

k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11)) bh = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, k) bh = cv2.normalize(bh, None, 0, 255, cv2.NORM_MINMAX) _, text_mask = cv2.threshold(bh, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

这套在印章提取上尤其好用。红色印章转灰度后是中等灰度,跟纸面差异不大,全局阈值切不出来,但印章笔画宽度固定、比纸面暗,用黑帽一提就干净了。要注意印章往往是双线结构,核尺寸别取太大,否则内外两圈线条会被合并成一坨。

5.3 小颗粒计数里的顶帽预处理

显微图像里计数细胞、颗粒、气泡这类任务,背景通常有缓慢的亮度渐变(视场边缘暗、中心亮),直接阈值会把整片亮区算成一个巨大的“颗粒”。礼帽正好能解决这个问题,因为它减掉的就是这种大尺度渐变。

流程是:高斯去噪、礼帽(核取颗粒直径的 1.8 倍)、分水岭或距离变换分离粘连、连通域统计。这里有个特别容易踩的坑——去噪核不能大于颗粒直径。我一开始用了(7,7)高斯,颗粒直径才 6 像素,结果颗粒被抹成一团糊,礼帽提取出来的全是背景噪声。改成(3,3)之后立刻正常。

另一个点是粘连处理。礼帽只负责把颗粒从背景里抠出来,粘在一起的颗粒它不管。要分离就用距离变换加阈值,或者对掩膜做腐蚀找种子点再分水岭。这一步的阈值选择要结合颗粒的实际尺寸,别指望礼帽能一步到位。

6. 结果全黑、满屏白点、边缘发亮:一条完整的排查链路

6.1 全黑或接近全黑,九成是核比目标小

礼帽输出全黑,说明开运算和原图几乎一模一样,也就是结构元素没能“伤害”到目标。原因只有两类:核尺寸小于目标宽度,或者核形状跟目标走向完全不匹配(比如用十字核提取斜线)。

排查方法不用瞎猜,直接把中间结果存下来看:cv2.imwrite("open.png", cv2.morphologyEx(gray, cv2.MORPH_OPEN, k)),把开运算结果和原图并排看。如果两张图肉眼几乎没差别,就是核太小;如果开运算明显把目标抹掉了但礼帽还是黑的,那问题在减法之后的类型转换上——比如你先做了浮点归一化再减,正负搞反了。

还有一个隐蔽原因:图片本身是 16 位深度,OpenCV 读进来是uint16,礼帽之后数值分布还在 0 到 65535 之间,你按 8 位的视觉习惯去看,整张图自然是黑的。这种情况要先缩放到 8 位,或者调整显示映射。

6.2 满屏白点,多半是把噪声当成了目标

礼帽的本质是“比核小的亮结构”,而单个噪声像素恰好就是最小的亮结构,所以礼帽对椒盐噪声、传感器热噪极其敏感。我在一个低照度相机的项目上就吃过这个亏:礼帽之后整张图密密麻麻全是白点,阈值根本没法定。

处理顺序应该是先滤波再礼帽,而不是反过来。滤波核要遵循两个约束:小于目标尺寸、大于噪声团尺寸。如果噪声颗粒和目标尺寸接近(比如都是 2 到 3 像素),滤波这条路就走不通,得改用多帧平均、或者提高信噪比从源头解决,形态学救不了。

另一个容易忽略的点是二值化阈值定得太低。前面提过,礼帽后背景区域不是绝对的 0,可能有 1 到 5 的残留,固定阈值设成 10 就会把这些残留全切进来。用mean + k*sigma的自适应阈值,或者干脆用np.percentile(th_u8, 99.5)作为阈值起点,都比拍脑袋定一个数靠谱。

6.3 边界那一圈白边,和手动改 borderType 有关

morphologyEx默认的边界处理是一种特殊策略,会把图像外的虚拟像素当作“不参与运算”的存在,所以在边界处做腐蚀或膨胀时,不会因为图像外是 0 而把边界值拉低或抬高。这个设计对礼帽和黑帽很友好,边界是干净的。

但如果你手动传了borderType=cv2.BORDER_CONSTANT且borderValue=0,情况就变了:膨胀时图像外全被当成最暗,边界像素会被压暗,开运算结果在边界处偏低,礼帽一减,边界就出现一条异常亮的白边。这个现象在窄幅图像(比如长条形的编码图)上特别明显,容易误判成缺陷。

排查这类问题很简单,把结果图的边界区域裁掉 2 个像素再看,如果白边消失了,就是边界策略的问题。解决办法是别手动改borderType,或者改了之后记得把边界区域主动屏蔽掉。

还有一个相关陷阱是数据类型。如果原图是float32,礼帽计算过程中可能出现 -1e-7 这种浮点误差级的负值,虽然理论上不可能,但数值实现上确实会有极小负值。做阈值之前用np.clip(x, 0, None)兜一下,避免后续平方、开方操作出问题。这个坑我是在算梯度方向时踩到的,礼帽图里有负值,sqrt直接出nan,查了半天才发现源头在这儿。

最后分享一个我现在的习惯动作:每次写形态学相关的代码,第一行先把目标尺寸和核尺寸打印出来,对一遍比例;第二行把中间结果图存盘,跑完扫一眼。这两个动作花不了十秒钟,但能省掉后面一两个小时的瞎调。核尺寸这个东西,凭感觉填十个里有八个是错的,宁可多存几张图看,也别盯着最终结果猜。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询