1. 顶帽变换到底是什么:从一次表面缺陷检测说起1.1 光照不均为什么让阈值分割直接失灵两年前我接过一个金属零件表面质量检测的小项目,任务并不复杂:从生产线上拍下来的零件图里,找出表面残留的加工亮斑。图片分辨率不高,目标就是几十个像素大小的高亮区域,按理说用固定阈值一割就能出来。结果第一张图就翻车了。零件表面本身有金属光泽,现场拍摄又是环形光源加环境光混着来,整幅图的灰度从左上到右下有一个明显的渐变。我在一个区域选好阈值,这边把亮斑分出来了,那边整片背景也跟着全白;把阈值抬高,背景压住了,另一侧的几个真实缺陷又被当成了背景。我试过直方图均衡、自适应阈值、拉普拉斯锐化,要么把噪声一起放大,要么计算量大得没法上线,始终没找到一个稳定方案。后来跟一位做机器视觉的老前辈聊天,他提到一句:你这个背景是低频的,目标是小尺度的,先做个开运算把背景估计出来,原图减掉它不就完事了?他说的就是形态学里的顶帽变换。当时我对形态学的理解还停留在“腐蚀膨胀去噪点”阶段,根本没想过开运算还能这样用。回去一试,效果出乎意料地干净。1.2 顶帽的救命思路:用开运算猜一张背景图顶帽变换的公式很简单:顶帽结果 = 原图 - 开运算结果也就是先用开运算处理原图,得到一张“背景估计图”,再用原图减去这张背景图,剩下的就是那些被开运算抹掉的亮细节。这套路听着抽象,其实特别直白:开运算会先把图中尺寸小于结构元素的亮区域抹平,再恢复大面积灰度轮廓,所以得到的图片像是一张“没有目标、只有背景”的底图。原图和这张底图一做差,被抹掉的亮斑、细线、亮纹就全部显形了。我在OpenCV里第一次跑的代码就三行:import cv2 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (75, 75)) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) tophat = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel)跑完之后我用固定阈值去分割顶帽结果,之前要分十几个区域分别调阈值的麻烦一下没了。整个图像的背景被压得很平,目标亮斑在深色底子上明亮又突出。也是从这次之后,我开始系统性地把顶帽当成处理光照不均和提取小目标的首选预处理手段。这篇文章就围绕顶帽展开:从形态学运算的底层逻辑,到核参数的实战选择,再到几个我亲自试过的应用场景,最后聊聊它处理不了的边缘情况。如果你也在跟光照不均、小目标提取、背景扣除这类问题死磕,希望我这几个月的经验能帮你少走点弯路。2. 形态学四件套拆解:为什么开运算能当背景估计器2.1 腐蚀和膨胀:顶帽最底层的两次“取极值”要彻底理解顶帽,得先回到它的地基——腐蚀和膨胀。这两个操作不看像素颜色,只看窗口内灰度的最大值和最小值。腐蚀是拿结构元素在图上滑动,每次取窗口内像素灰度的最小值作为中心点的新值。想象一个亮斑,它的四周是暗背景,腐蚀时窗口只要碰到暗背景,中心值就被拉低,亮斑边缘一圈会先变暗,亮斑整体缩小,很亮的小结构甚至会被整个吞掉。膨胀正好反过来,取窗口内最大值,亮区域会向外扩张,暗的小缺口会被填平。把两者组合起来就得到开运算:先腐蚀再膨胀。腐蚀把小于核的亮细节抹掉了,膨胀再把剩下的结构“恢复”到接近原来的尺寸——但已经被腐蚀吞掉的那部分信息,膨胀是找不回来的。所以开运算的净效果就是:去掉尺寸小于结构元素的亮细节,保留大面积的亮结构轮廓。这里有个容易忽略的点:开运算并不是“模糊”,它是基于形状和尺度的筛选。模糊会把边缘搞糊、把灰度层次抹匀,而开运算保留边缘的锐度,只是把小于核的条件删除。这也是它能当背景估计器的原因——背景通常是大面积缓变的灰度场,属于“大尺度结构”,开运算几乎不伤它;而目标斑点、细线属于“小尺度结构”,会被针对性移除。2.2 开运算、闭运算与黑帽:亮细节和暗细节的两条路线开运算处理的是亮细节,那暗细节怎么办?形态学里有个对称操作:闭运算,先膨胀再腐蚀。膨胀先把暗细节填掉,腐蚀再把整体轮廓恢复,所以闭运算移除的是尺寸小于核的暗结构。基于闭运算,你就能得到黑帽变换:黑帽结果 = 闭运算结果 - 原图闭运算把暗细节抹平,得到一张“没有暗目标的背景图”,减掉原图后,剩下的就是从背景里分离出来的暗细节。它和顶帽正好一正一反,一个取亮目标,一个取暗目标。操作公式提取目标典型场景顶帽原图 - 开运算比背景更亮的细节亮斑、亮纹、光照补偿黑帽闭运算 - 原图比背景更暗的细节裂纹、暗划痕、墨点缺陷我最初以为黑帽只是顶帽的“镜像”,实际用了才发现还有一层区别:光照不均对暗细节的干扰往往更严重。因为暗目标和背景的对比度常常很低,直接用阈值很难卡,而黑帽把背景压平之后,暗目标反而变成“背景上凸出来的暗区”,分割难度会大幅下降。所以项目里凡是找划痕、裂纹、针孔这类暗缺陷,我第一反应不是锐化,而是先跑一次黑帽。3. 核尺寸与形状:顶帽调参的核心战场3.1 核必须比目标大:尺寸选错的两种崩法顶帽效果好不好,80%的功夫在结构元素的选择上。我见过太多人直接套用默认的 3x3 核,结果顶帽出来的图一团噪声,随后就下结论说“顶帽没用”——其实是核选错了。核的尺寸必须大于目标的最大尺寸,这是顶帽能成立的前提。开运算是用来“猜背景”的,如果核比目标还小,目标本身就不会被完全抹掉,背景估计图里还残留着目标轮廓,原图一减,目标信号被大大削弱,甚至出现负值。我踩过一次坑:一批目标亮斑是 20 像素左右,我拿 11x11 的核去做顶帽,结果亮斑在结果图里变成了一个中间发黑的“甜甜圈”,阈值分割之后再怎么闭运算都补不回来,白白浪费了一天时间。反过来,核也不是越大越好。核大到一定程度,开运算估计的背景就过分平滑,光照的局部变化反而没法被准确描述,顶帽结果里会混进大面积的灰度残差。一个我从实践中总结出来的经验范围:核的直径取目标最大尺寸的1.5到3倍。比如目标亮斑直径约 30 像素,核用 45 到 90 像素区间,先从 75 起步,看效果再往两边调,通常一两次就能找到稳定区间。3.2 结构元素形状怎么选:矩形、椭圆、十字、线形除了尺寸,结构元素的形状也直接影响顶帽结果。OpenCV 提供三种直接可用的结构元素:矩形、椭圆、十字,另外还可以自定义任意形状的核。矩形核:对方向不敏感,适合提取块状目标,但对边缘突出的角点比较敏感,容易在方块目标的四角留下残影。椭圆核:我的默认选择。它没有矩形核的尖锐角,对目标的形状干扰最小,尤其适合圆斑、不规则亮斑这类真实缺陷。十字核:只在水平和垂直方向起作用,适合处理横平竖直的纹理目标,比如电路板上的亮线、栅格结构。线形核:虽然要自己用cv2.getStructuringElement配合形状参数生成,但对细长目标非常关键。线形核我单独说一下。检测划痕、拉丝这类细长缺陷时,圆形或矩形核都会把细线当成小目标整条抹掉,导致顶帽结果里只剩下一截一截的短线。正确做法是用一个 1xN 或 Nx1 的线形核,让核的“棒”方向与划痕方向垂直或者平行,具体要看你希望保留的纹理方向。实际操作中我会先生成 0 度角和 90 度角两个方向的线形核,分别跑顶帽,再取逐像素最大值合并,这样横竖两个方向的细线都能被完整地抠出来。import cv2 import numpy as np kernel_h = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 31)) kernel_v = cv2.getStructuringElement(cv2.MORPH_RECT, (31, 1)) tophat_h = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel_h) tophat_v = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel_v) tophat_combined = np.maximum(tophat_h, tophat_v)这种多方向合并的思路,在真实项目里比单一核稳定很多,代价只是多跑几次形态学运算,对现在的硬件来说几乎可以忽略。4. 顶帽在真实项目里的三种打开方式4.1 光照不均背景校正:从灰度图到干净的二值图顶帽最常见的落地场景是光照不均校正。工业现场很难保证环境光一致,金属表面、反光材料、曲面零件都会产生大面积灰度起伏,这种起伏对阈值分割来说就是一场灾难。我分享一下自己用得最多的完整流程。假设输入的是一张金属零件图,目标是找表面亮斑:先转灰度,中值滤波去一下传感噪声,然后按目标尺寸选椭圆核做顶帽。顶帽结果一般会偏暗,我会做一次对比度拉伸,让亮目标从背景里更突出,最后用 Otsu 自动阈值分割,再做一次小尺寸开运算把零星噪点去掉。import cv2 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray = cv2.medianBlur(gray, 5) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (75, 75)) tophat = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel) # 对比度拉伸 norm = cv2.normalize(tophat, None, 0, 255, cv2.NORM_MINMAX) # Otsu 阈值 _, thresh = cv2.threshold(norm, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 去小噪点 clean = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))这套流程在之前那个金属零件项目里非常稳。原本要按九宫格区域分别设阈值,现在一个全局 Otsu 就解决了,漏检率反而更低。要注意的是,顶帽校正后得到的是“高亮细节图”,不是一张可以直接当灰度图用的增强图,后续一定要搭配阈值分割,否则你看到的只是一堆灰蒙蒙的残差。4.2 暗缺陷增强:用黑帽把裂纹划痕从背景里“抠”出来暗缺陷比亮缺陷麻烦,因为裂纹和划痕在自然图像里往往是低对比度的,有时人眼都得凑近了仔细看。黑帽变换在这种场景下比任何锐化滤波器都管用。有一个模拟场景我记得很清楚:深色塑料件表面有一条宽度只有 2~3 像素的细划痕,旁边还有轻微的模具纹理干扰。用拉普拉斯锐化,纹理被当成边缘一起放大了;用 Canny 边缘检测,划痕断裂成好几段,无法形成完整链路。最后我改用线形核做黑帽:核用 1x21 的线形,执行cv2.MORPH_BLACKHAT,划痕在结果图里变成一条连续的高亮细线,模具纹理因为尺度比核小太多,被闭运算直接抹平了。黑帽之后,再配合一次骨架化和端点连接,就能把断续的划痕拼成一条完整链,后续测量长度和方向都很方便。如果你处理的暗目标不是细线而是暗斑,椭圆核黑帽同样有效。核心原则依然是:核大于目标,但不要大到把局部背景变化也吞掉。4.3 与阈值分割、连通域分析配合的完整流程顶帽单独使用时只是个“预处理滤镜”,真正出结果还得靠后续判断。我习惯把顶帽放进一条完整的小目标检测流水线里:先预处理,再阈值分割,再连通域筛选。比如检测某印刷品上的白色墨点,步骤是这样的:灰度化后做顶帽,得到亮墨点增强图;Otsu 阈值分割后,墨点变成白色连通域;然后调用 connectedComponentsWithStats 提取每个连通域的面积、宽高、外接矩形,把面积小于 15 像素、长宽比超过 5 的过滤掉,剩下的就是真实缺陷。num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(thresh, connectivity=8) for i in range(1, num_labels): area = stats[i, cv2.CC_STAT_AREA] w = stats[i, cv2.CC_STAT_WIDTH] h = stats[i, cv2.CC_STAT_HEIGHT] if area >= 15 and 0.2 <= w / h <= 5: results.append(stats[i])这套流水线的优点是可解释性强:每个环节都是独立的,哪个环节出了问题可以直接定位。顶帽负责把目标从背景里分离出来,阈值负责把灰度转换成二值,连通域负责做逻辑筛选,三者各管一段,哪一步效果不好就单独调哪一步。我在项目里维护这类流程时,从来不会费劲去调一个端到端的神经网络,因为传统视觉方案出问题时,调试成本低太多。5. 顶帽的边界条件和坑:哪些场景它救不了你5.1 椒盐噪声会在顶帽结果里“原地爆炸”顶帽对椒盐噪声非常敏感。椒盐噪声是随机出现的白点或黑点,尺寸常常只有 1 个像素,而顶帽恰恰就是用来增强小尺寸高频成分的。开运算处理完原图后,原图里的噪声白点并没有被开运算全部抹掉,原图减开运算结果时,噪点会被放大成一个个亮点。我自己最早跑顶帽时就遇到这个问题:结果图里噪声点甚至比真实目标还亮,阈值分割后一片白点。后来学乖了,凡是输入图像有可见噪声,一定先做一次中值滤波再跑顶帽,不要直接拿原始图硬上。中值滤波的窗口大小不用大,3x3 或者 5x5 就够,它能把脉冲噪声压掉,又不会明显影响目标的边缘。5.2 镜面高光和强反光区域:背景估计直接失真顶帽有一个明显软肋:面对镜面高光会失灵。当图中某块区域因为反光亮度饱和到 255,它的亮度已经超出形态学运算能描述的灰度范围,开运算的背景估计也会跟着失真。原图减开运算以后,饱和区域里会残留一个逆反的暗洞,看起来就像一个假缺陷。金属表面、透明瓶身、光滑塑料这类高反光材料经常出现这种问题。遇到这种情况,我一般先在原图里把饱和区域 mask 出来,顶帽只对非饱和区域计算,最后再把两半结果拼接。更省事的方案是调整打光,换成低角度光源或者偏振光,从源头压住高光。传统视觉很多时候是“七分打光、三分算法”,顶帽处理不了的,往往不是算法问题,而是图就没拍好。5.3 参数过拟合:换一条产品线就失灵在某个特定型号上把核尺寸调得刚刚好,换一个产品线就完全失效——这是顶帽项目里最常见的工程翻车。我有一次在 A 类零件上定好 75x75 的椭圆核,上线测了两周都很稳,结果 B 类零件一上来目标尺寸直接变成 100 像素,顶帽结果里所有目标都只留下残缺轮廓,当时差点以为是相机出了问题。现在我的做法是:不管项目多小,调参时都会把目标尺寸范围写进配置,不允许把核大小硬编码在代码里。如果一个产线的目标尺寸变化超过 30%,我会干脆跑多尺度顶帽——用几个不同尺寸的核分别提取,然后按权重融合,或者每个尺度单独做一次判断再综合结果。虽然逻辑上多了一点点工作量,但它的鲁棒性提升是实打实的。多尺度思路不复杂:核大小分别取目标尺寸的 1.5 倍、2 倍、3 倍,对每个尺度都做顶帽和阈值分割,最后把三个结果做按位或合并。小目标在每个尺度下都会被至少一个核完整保留,大目标也不会因为核太小而变成空心圆。5.4 背景太复杂的图像:顶帽不是万能的顶帽强在分离“与背景尺度不同的细节”,但如果背景本身也是复杂纹理,比如布料、石材、树皮这类天然纹理,顶帽会把纹理里的小尺度灰度起伏也当成目标提取出来。这时候看到的结果往往是“全是目标,等于没有目标”。这种情况我会谨慎使用顶帽,或者干脆放弃它,改用频域滤波或者深度学习语义分割。判断标准很简单:先跑一次顶帽,看一眼结果图是不是一片斑驳,如果是,说明背景纹理和目标尺度重叠,再继续调参也是浪费时间。从我个人经验来说,顶帽这个工具在工业检测里依然是性价比极高的预处理手段。它不需要样本、计算量小、可解释性强,配合阈值和连通域分析就能解决一大类光照不均和小目标提取问题。但它吃透了前提条件——目标比背景小一个尺度、图像里没有饱和高光、噪声可控——这三条只要有一条不满足,我就不会硬用。最后说一个小技巧:如果你不确定核大小怎么定,先打开图像软件量一下目标长边像素数,再按 2 倍左右开核,基本不会差太远。多数项目里顶帽表现不佳,问题都不在算法,而是核一开始就没给对。
1. 顶帽变换到底是什么:从一次表面缺陷检测说起1.1 光照不均为什么让阈值分割直接失灵两年前我接过一个金属零件表面质量检测的小项目,任务并不复杂:从生产线上拍下来的零件图里,找出表面残留的加工亮斑。图片分辨率不高,目标就是几十个像素大小的高亮区域,按理说用固定阈值一割就能出来。结果第一张图就翻车了。零件表面本身有金属光泽,现场拍摄又是环形光源加环境光混着来,整幅图的灰度从左上到右下有一个明显的渐变。我在一个区域选好阈值,这边把亮斑分出来了,那边整片背景也跟着全白;把阈值抬高,背景压住了,另一侧的几个真实缺陷又被当成了背景。我试过直方图均衡、自适应阈值、拉普拉斯锐化,要么把噪声一起放大,要么计算量大得没法上线,始终没找到一个稳定方案。后来跟一位做机器视觉的老前辈聊天,他提到一句:你这个背景是低频的,目标是小尺度的,先做个开运算把背景估计出来,原图减掉它不就完事了?他说的就是形态学里的顶帽变换。当时我对形态学的理解还停留在“腐蚀膨胀去噪点”阶段,根本没想过开运算还能这样用。回去一试,效果出乎意料地干净。1.2 顶帽的救命思路:用开运算猜一张背景图顶帽变换的公式很简单:顶帽结果 = 原图 - 开运算结果也就是先用开运算处理原图,得到一张“背景估计图”,再用原图减去这张背景图,剩下的就是那些被开运算抹掉的亮细节。这套路听着抽象,其实特别直白:开运算会先把图中尺寸小于结构元素的亮区域抹平,再恢复大面积灰度轮廓,所以得到的图片像是一张“没有目标、只有背景”的底图。原图和这张底图一做差,被抹掉的亮斑、细线、亮纹就全部显形了。我在OpenCV里第一次跑的代码就三行:import cv2 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (75, 75)) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) tophat = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel)跑完之后我用固定阈值去分割顶帽结果,之前要分十几个区域分别调阈值的麻烦一下没了。整个图像的背景被压得很平,目标亮斑在深色底子上明亮又突出。也是从这次之后,我开始系统性地把顶帽当成处理光照不均和提取小目标的首选预处理手段。这篇文章就围绕顶帽展开:从形态学运算的底层逻辑,到核参数的实战选择,再到几个我亲自试过的应用场景,最后聊聊它处理不了的边缘情况。如果你也在跟光照不均、小目标提取、背景扣除这类问题死磕,希望我这几个月的经验能帮你少走点弯路。2. 形态学四件套拆解:为什么开运算能当背景估计器2.1 腐蚀和膨胀:顶帽最底层的两次“取极值”要彻底理解顶帽,得先回到它的地基——腐蚀和膨胀。这两个操作不看像素颜色,只看窗口内灰度的最大值和最小值。腐蚀是拿结构元素在图上滑动,每次取窗口内像素灰度的最小值作为中心点的新值。想象一个亮斑,它的四周是暗背景,腐蚀时窗口只要碰到暗背景,中心值就被拉低,亮斑边缘一圈会先变暗,亮斑整体缩小,很亮的小结构甚至会被整个吞掉。膨胀正好反过来,取窗口内最大值,亮区域会向外扩张,暗的小缺口会被填平。把两者组合起来就得到开运算:先腐蚀再膨胀。腐蚀把小于核的亮细节抹掉了,膨胀再把剩下的结构“恢复”到接近原来的尺寸——但已经被腐蚀吞掉的那部分信息,膨胀是找不回来的。所以开运算的净效果就是:去掉尺寸小于结构元素的亮细节,保留大面积的亮结构轮廓。这里有个容易忽略的点:开运算并不是“模糊”,它是基于形状和尺度的筛选。模糊会把边缘搞糊、把灰度层次抹匀,而开运算保留边缘的锐度,只是把小于核的条件删除。这也是它能当背景估计器的原因——背景通常是大面积缓变的灰度场,属于“大尺度结构”,开运算几乎不伤它;而目标斑点、细线属于“小尺度结构”,会被针对性移除。2.2 开运算、闭运算与黑帽:亮细节和暗细节的两条路线开运算处理的是亮细节,那暗细节怎么办?形态学里有个对称操作:闭运算,先膨胀再腐蚀。膨胀先把暗细节填掉,腐蚀再把整体轮廓恢复,所以闭运算移除的是尺寸小于核的暗结构。基于闭运算,你就能得到黑帽变换:黑帽结果 = 闭运算结果 - 原图闭运算把暗细节抹平,得到一张“没有暗目标的背景图”,减掉原图后,剩下的就是从背景里分离出来的暗细节。它和顶帽正好一正一反,一个取亮目标,一个取暗目标。操作公式提取目标典型场景顶帽原图 - 开运算比背景更亮的细节亮斑、亮纹、光照补偿黑帽闭运算 - 原图比背景更暗的细节裂纹、暗划痕、墨点缺陷我最初以为黑帽只是顶帽的“镜像”,实际用了才发现还有一层区别:光照不均对暗细节的干扰往往更严重。因为暗目标和背景的对比度常常很低,直接用阈值很难卡,而黑帽把背景压平之后,暗目标反而变成“背景上凸出来的暗区”,分割难度会大幅下降。所以项目里凡是找划痕、裂纹、针孔这类暗缺陷,我第一反应不是锐化,而是先跑一次黑帽。3. 核尺寸与形状:顶帽调参的核心战场3.1 核必须比目标大:尺寸选错的两种崩法顶帽效果好不好,80%的功夫在结构元素的选择上。我见过太多人直接套用默认的 3x3 核,结果顶帽出来的图一团噪声,随后就下结论说“顶帽没用”——其实是核选错了。核的尺寸必须大于目标的最大尺寸,这是顶帽能成立的前提。开运算是用来“猜背景”的,如果核比目标还小,目标本身就不会被完全抹掉,背景估计图里还残留着目标轮廓,原图一减,目标信号被大大削弱,甚至出现负值。我踩过一次坑:一批目标亮斑是 20 像素左右,我拿 11x11 的核去做顶帽,结果亮斑在结果图里变成了一个中间发黑的“甜甜圈”,阈值分割之后再怎么闭运算都补不回来,白白浪费了一天时间。反过来,核也不是越大越好。核大到一定程度,开运算估计的背景就过分平滑,光照的局部变化反而没法被准确描述,顶帽结果里会混进大面积的灰度残差。一个我从实践中总结出来的经验范围:核的直径取目标最大尺寸的1.5到3倍。比如目标亮斑直径约 30 像素,核用 45 到 90 像素区间,先从 75 起步,看效果再往两边调,通常一两次就能找到稳定区间。3.2 结构元素形状怎么选:矩形、椭圆、十字、线形除了尺寸,结构元素的形状也直接影响顶帽结果。OpenCV 提供三种直接可用的结构元素:矩形、椭圆、十字,另外还可以自定义任意形状的核。矩形核:对方向不敏感,适合提取块状目标,但对边缘突出的角点比较敏感,容易在方块目标的四角留下残影。椭圆核:我的默认选择。它没有矩形核的尖锐角,对目标的形状干扰最小,尤其适合圆斑、不规则亮斑这类真实缺陷。十字核:只在水平和垂直方向起作用,适合处理横平竖直的纹理目标,比如电路板上的亮线、栅格结构。线形核:虽然要自己用cv2.getStructuringElement配合形状参数生成,但对细长目标非常关键。线形核我单独说一下。检测划痕、拉丝这类细长缺陷时,圆形或矩形核都会把细线当成小目标整条抹掉,导致顶帽结果里只剩下一截一截的短线。正确做法是用一个 1xN 或 Nx1 的线形核,让核的“棒”方向与划痕方向垂直或者平行,具体要看你希望保留的纹理方向。实际操作中我会先生成 0 度角和 90 度角两个方向的线形核,分别跑顶帽,再取逐像素最大值合并,这样横竖两个方向的细线都能被完整地抠出来。import cv2 import numpy as np kernel_h = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 31)) kernel_v = cv2.getStructuringElement(cv2.MORPH_RECT, (31, 1)) tophat_h = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel_h) tophat_v = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel_v) tophat_combined = np.maximum(tophat_h, tophat_v)这种多方向合并的思路,在真实项目里比单一核稳定很多,代价只是多跑几次形态学运算,对现在的硬件来说几乎可以忽略。4. 顶帽在真实项目里的三种打开方式4.1 光照不均背景校正:从灰度图到干净的二值图顶帽最常见的落地场景是光照不均校正。工业现场很难保证环境光一致,金属表面、反光材料、曲面零件都会产生大面积灰度起伏,这种起伏对阈值分割来说就是一场灾难。我分享一下自己用得最多的完整流程。假设输入的是一张金属零件图,目标是找表面亮斑:先转灰度,中值滤波去一下传感噪声,然后按目标尺寸选椭圆核做顶帽。顶帽结果一般会偏暗,我会做一次对比度拉伸,让亮目标从背景里更突出,最后用 Otsu 自动阈值分割,再做一次小尺寸开运算把零星噪点去掉。import cv2 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray = cv2.medianBlur(gray, 5) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (75, 75)) tophat = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel) # 对比度拉伸 norm = cv2.normalize(tophat, None, 0, 255, cv2.NORM_MINMAX) # Otsu 阈值 _, thresh = cv2.threshold(norm, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 去小噪点 clean = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))这套流程在之前那个金属零件项目里非常稳。原本要按九宫格区域分别设阈值,现在一个全局 Otsu 就解决了,漏检率反而更低。要注意的是,顶帽校正后得到的是“高亮细节图”,不是一张可以直接当灰度图用的增强图,后续一定要搭配阈值分割,否则你看到的只是一堆灰蒙蒙的残差。4.2 暗缺陷增强:用黑帽把裂纹划痕从背景里“抠”出来暗缺陷比亮缺陷麻烦,因为裂纹和划痕在自然图像里往往是低对比度的,有时人眼都得凑近了仔细看。黑帽变换在这种场景下比任何锐化滤波器都管用。有一个模拟场景我记得很清楚:深色塑料件表面有一条宽度只有 2~3 像素的细划痕,旁边还有轻微的模具纹理干扰。用拉普拉斯锐化,纹理被当成边缘一起放大了;用 Canny 边缘检测,划痕断裂成好几段,无法形成完整链路。最后我改用线形核做黑帽:核用 1x21 的线形,执行cv2.MORPH_BLACKHAT,划痕在结果图里变成一条连续的高亮细线,模具纹理因为尺度比核小太多,被闭运算直接抹平了。黑帽之后,再配合一次骨架化和端点连接,就能把断续的划痕拼成一条完整链,后续测量长度和方向都很方便。如果你处理的暗目标不是细线而是暗斑,椭圆核黑帽同样有效。核心原则依然是:核大于目标,但不要大到把局部背景变化也吞掉。4.3 与阈值分割、连通域分析配合的完整流程顶帽单独使用时只是个“预处理滤镜”,真正出结果还得靠后续判断。我习惯把顶帽放进一条完整的小目标检测流水线里:先预处理,再阈值分割,再连通域筛选。比如检测某印刷品上的白色墨点,步骤是这样的:灰度化后做顶帽,得到亮墨点增强图;Otsu 阈值分割后,墨点变成白色连通域;然后调用 connectedComponentsWithStats 提取每个连通域的面积、宽高、外接矩形,把面积小于 15 像素、长宽比超过 5 的过滤掉,剩下的就是真实缺陷。num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(thresh, connectivity=8) for i in range(1, num_labels): area = stats[i, cv2.CC_STAT_AREA] w = stats[i, cv2.CC_STAT_WIDTH] h = stats[i, cv2.CC_STAT_HEIGHT] if area >= 15 and 0.2 <= w / h <= 5: results.append(stats[i])这套流水线的优点是可解释性强:每个环节都是独立的,哪个环节出了问题可以直接定位。顶帽负责把目标从背景里分离出来,阈值负责把灰度转换成二值,连通域负责做逻辑筛选,三者各管一段,哪一步效果不好就单独调哪一步。我在项目里维护这类流程时,从来不会费劲去调一个端到端的神经网络,因为传统视觉方案出问题时,调试成本低太多。5. 顶帽的边界条件和坑:哪些场景它救不了你5.1 椒盐噪声会在顶帽结果里“原地爆炸”顶帽对椒盐噪声非常敏感。椒盐噪声是随机出现的白点或黑点,尺寸常常只有 1 个像素,而顶帽恰恰就是用来增强小尺寸高频成分的。开运算处理完原图后,原图里的噪声白点并没有被开运算全部抹掉,原图减开运算结果时,噪点会被放大成一个个亮点。我自己最早跑顶帽时就遇到这个问题:结果图里噪声点甚至比真实目标还亮,阈值分割后一片白点。后来学乖了,凡是输入图像有可见噪声,一定先做一次中值滤波再跑顶帽,不要直接拿原始图硬上。中值滤波的窗口大小不用大,3x3 或者 5x5 就够,它能把脉冲噪声压掉,又不会明显影响目标的边缘。5.2 镜面高光和强反光区域:背景估计直接失真顶帽有一个明显软肋:面对镜面高光会失灵。当图中某块区域因为反光亮度饱和到 255,它的亮度已经超出形态学运算能描述的灰度范围,开运算的背景估计也会跟着失真。原图减开运算以后,饱和区域里会残留一个逆反的暗洞,看起来就像一个假缺陷。金属表面、透明瓶身、光滑塑料这类高反光材料经常出现这种问题。遇到这种情况,我一般先在原图里把饱和区域 mask 出来,顶帽只对非饱和区域计算,最后再把两半结果拼接。更省事的方案是调整打光,换成低角度光源或者偏振光,从源头压住高光。传统视觉很多时候是“七分打光、三分算法”,顶帽处理不了的,往往不是算法问题,而是图就没拍好。5.3 参数过拟合:换一条产品线就失灵在某个特定型号上把核尺寸调得刚刚好,换一个产品线就完全失效——这是顶帽项目里最常见的工程翻车。我有一次在 A 类零件上定好 75x75 的椭圆核,上线测了两周都很稳,结果 B 类零件一上来目标尺寸直接变成 100 像素,顶帽结果里所有目标都只留下残缺轮廓,当时差点以为是相机出了问题。现在我的做法是:不管项目多小,调参时都会把目标尺寸范围写进配置,不允许把核大小硬编码在代码里。如果一个产线的目标尺寸变化超过 30%,我会干脆跑多尺度顶帽——用几个不同尺寸的核分别提取,然后按权重融合,或者每个尺度单独做一次判断再综合结果。虽然逻辑上多了一点点工作量,但它的鲁棒性提升是实打实的。多尺度思路不复杂:核大小分别取目标尺寸的 1.5 倍、2 倍、3 倍,对每个尺度都做顶帽和阈值分割,最后把三个结果做按位或合并。小目标在每个尺度下都会被至少一个核完整保留,大目标也不会因为核太小而变成空心圆。5.4 背景太复杂的图像:顶帽不是万能的顶帽强在分离“与背景尺度不同的细节”,但如果背景本身也是复杂纹理,比如布料、石材、树皮这类天然纹理,顶帽会把纹理里的小尺度灰度起伏也当成目标提取出来。这时候看到的结果往往是“全是目标,等于没有目标”。这种情况我会谨慎使用顶帽,或者干脆放弃它,改用频域滤波或者深度学习语义分割。判断标准很简单:先跑一次顶帽,看一眼结果图是不是一片斑驳,如果是,说明背景纹理和目标尺度重叠,再继续调参也是浪费时间。从我个人经验来说,顶帽这个工具在工业检测里依然是性价比极高的预处理手段。它不需要样本、计算量小、可解释性强,配合阈值和连通域分析就能解决一大类光照不均和小目标提取问题。但它吃透了前提条件——目标比背景小一个尺度、图像里没有饱和高光、噪声可控——这三条只要有一条不满足,我就不会硬用。最后说一个小技巧:如果你不确定核大小怎么定,先打开图像软件量一下目标长边像素数,再按 2 倍左右开核,基本不会差太远。多数项目里顶帽表现不佳,问题都不在算法,而是核一开始就没给对。