8  形态学

阈值分割(章节 7)的输出从来不是一幅干净的二值图。哪怕阈值选得再好,前景里总会留着几个椒粒般的小孔,边缘上挂着毛刺,两个本该分开的目标被一条细桥粘在一起,一个本该完整的目标又被一条细缝割成几段,背景上还散落着孤立的噪声点。这些瑕疵对人眼无关紧要,对程序却是灾难:后续的 Blob 分析(章节 23)会把每个噪声点都数成一个目标,把粘连的两个目标数成一个,把断裂的一个目标数成三个。数学形态学(mathematical morphology)就是修理二值图的那把扳手——它用一个小小的模板在图像上滑动,按集合运算的规则删除或补全像素,把分割结果修整成后续算法能直接消化的形状。

本章用一幅 480×360 的合成二值场景贯穿所有实验(图 8.1)。场景中的每一处瑕疵都是刻意安排的:一个内部带 1~2 px 椒孔、边缘带 1~2 px 毛刺的实心矩形;两个被一条 2 px 细桥粘连的团块;一个被 1 px 与 2 px 细缝割裂成三段的矩形;一条 3 px 厚的细长条;外加 10 个孤立噪声点(6 个 1×1、4 个 2×2)。全部坐标硬编码、不含随机数,因此每次运行的统计结果完全一致:前景共 47426 个像素,按 8 连通计 16 个 blob。

图 8.1: 本章贯穿使用的合成二值场景:带椒孔与毛刺的矩形、被 2 px 细桥粘连的双团块、被 1 px/2 px 细缝割裂的矩形、3 px 细长条与 10 个孤立噪声点。前景 47426 px,8 连通 16 个 blob。

8.1 结构元与腐蚀膨胀

形态学的全部运算都围绕一个主角展开:结构元(structuring element)。它是一个带锚点(anchor)的小型形状模板——最常用的是 3×3、5×5 的矩形,锚点居中——运算时锚点逐像素扫过图像,结构元覆盖的邻域决定该像素的命运。结构元之于形态学,正如卷积核之于线性滤波(章节 6):模板的形状与尺寸决定了运算的全部性格。

最基本的两种运算是一对”反义词”。腐蚀(erosion)的集合定义为

\[ A \ominus B = \{\, z \mid B_z \subseteq A \,\}, \]

其中 \(A\) 是前景像素集合,\(B_z\) 表示把结构元 \(B\) 的锚点平移到位置 \(z\)。一句话翻译:只有当结构元能完整塞进前景时,锚点位置才保留为前景。任何容不下结构元的地方——细桥、毛刺、孤立小点、目标的最外一圈——统统被剥掉。膨胀(dilation)则相反:

\[ A \oplus B = \{\, z \mid B_z \cap A \neq \emptyset \,\}, \]

只要结构元与前景有一个像素相碰,锚点位置就被置为前景——前景向外长出一圈,细缝与小孔被对面长过来的前景填合。

腐蚀与膨胀互为对偶(duality)\((A \ominus B)^{c} = A^{c} \oplus \check{B}\),即腐蚀前景等价于膨胀背景。这不是数学装饰——它意味着”删除前景小结构”与”填补背景小缝隙”本质上是同一个运算从两侧看的结果,开、闭运算的对偶性(下节)正源于此。

对二值图而言还有一个更直观的等价视角:腐蚀就是邻域最小值滤波(min filter)——窗口里只要有一个背景(0),输出就是 0;膨胀就是邻域最大值滤波(max filter)——窗口里只要有一个前景(255),输出就是 255。这个视角到 小节 8.4 推广灰度形态学时会直接派上用场。

用 3×3 矩形结构元对测试场景各做一次,结果见 图 8.2。腐蚀后前景从 47426 px 降到 44233 px,blob 数从 16 个骤减到 7 个:10 个噪声点全部消失,毛刺被剥光,2 px 的细桥被切断——粘连的双团块分成了两个。代价同样肉眼可见:所有目标都瘦了一圈,椒孔被撑大,细缝变得更宽。膨胀则相反:前景增到 50603 px,椒孔被填平,1 px 与 2 px 的细缝都被合拢,但每个目标都胖了一圈,噪声点不仅没消失反而长大了。

(a) 3×3 腐蚀
(b) 3×3 膨胀
图 8.2: 腐蚀与膨胀(3×3 矩形结构元)。(a) 腐蚀:噪声点与毛刺尽除、细桥切断(16→7 个 blob),但前景缩水到 44233 px,孔与缝被撑大;(b) 膨胀:椒孔填平、细缝合拢,但前景胀到 50603 px,噪声点也跟着长大。

两种运算各自只解决一半问题,又各自引入一个新问题——尺寸变了。在测量场合这是不可接受的:腐蚀一次,目标的面积与轮廓位置就系统性地偏小;膨胀一次则偏大。怎样既享受”删噪/填缝”的好处,又把尺寸还回来?答案是把两者串起来用。

8.2 开闭运算

开运算(opening)定义为先腐蚀、后膨胀:

\[ A \circ B = (A \ominus B) \oplus B, \]

闭运算(closing)则是先膨胀、后腐蚀:

\[ A \bullet B = (A \oplus B) \ominus B. \]

它们的设计思想是同一个:第一步把目标结构”杀掉或救活”,第二步把幸存者的尺寸近似还原。开运算中,腐蚀先删掉所有容不下结构元的小结构——它们一旦消失,后续膨胀无从恢复;而大块目标只是瘦了一圈,膨胀又把这一圈补回来。闭运算对偶地作用于背景:膨胀先填掉所有容不下结构元的小缝小孔,腐蚀再把胀大的目标收回原形。

开与闭都是幂等(idempotent)的:\((A \circ B) \circ B = A \circ B\),闭运算同理。做一次和做一百次结果完全相同——该删的第一次就删干净了,幸存的结构已经”容得下结构元”,再做只是恒等变换。这与平滑滤波”越滤越糊”的累积行为截然不同。

实验结果见 图 8.3,数字把”尺寸近似保持”展示得非常干脆。3×3 开运算后前景为 47225 px,相对原图 47426 px 仅减少 0.4%;3×3 闭运算后为 47801 px,仅增加 0.8%——对比单独腐蚀/膨胀约 ±6.7% 的变化,开闭运算把尺寸偏差压低了一个数量级。而且这一减一增分毫不差:开运算删掉的 201 px 恰好等于 10 个噪声点(22 px)、三根毛刺(59 px)与细桥(120 px)的面积之和——矩形主体一个像素都没少;闭运算补上的 375 px 恰好等于两条细缝(360 px)与全部椒孔(15 px)的面积之和。

(a) 3×3 开运算
(b) 3×3 闭运算
图 8.3: 开运算与闭运算(3×3 矩形结构元)。(a) 开运算:噪声点、毛刺、细桥尽除(16→7 个 blob),目标尺寸几乎不变(−0.4%),但椒孔原样保留;(b) 闭运算:1 px 与 2 px 细缝全部缝合(三段重新连成一体,16→14 个 blob),椒孔填平(+0.8%),但噪声点与毛刺原封不动。

blob 计数进一步揭示了两者的分工。开运算后剩 7 个 blob:10 个噪声点没了,细桥断了(粘连一分为二),毛刺也除净了——但矩形内部的椒孔还在。开运算不填孔:它只删除前景里的小凸出结构,对背景里的小孔无能为力。闭运算后剩 14 个 blob:1 px 和 2 px 的缝都合上了,被割裂的三段重新连成一段,椒孔也填平了——但 10 个噪声点一个没少。闭运算不去毛刺、不除噪点。这正是对偶性的体现:开运算修理前景的”多余”,闭运算修理背景的”多余”,谁也替代不了谁。实际工程里两者经常串联使用——先开后闭,先扫净噪声再缝合断缝。本章全部运算的计数结果汇总于 表 8.1

表 8.1: 各形态学运算对测试场景的前景像素与 blob 计数影响
运算 前景像素 相对原图 blob 数(8 连通)
原始场景 47426 16
腐蚀 3×3 44233 −6.7% 7
膨胀 3×3 50603 +6.7%
开运算 3×3 47225 −0.4% 7
闭运算 3×3 47801 +0.8% 14
开运算 5×5 46025 −3.0% 6

8.3 结构元尺寸的选择

开闭运算把”删什么、留什么”的决定权完全交给了结构元的尺寸——容不下结构元的死,容得下的活。那么结构元是不是越大越保险?把开运算的结构元从 3×3 换成 5×5 再跑一次(图 8.4):前景降到 46025 px,blob 只剩 6 个。少掉的那一个不是噪声——是那条 3 px 厚的细长条。它容得下 3×3 的结构元,却容不下 5×5,于是被连根抹掉了。如果这条”细长条”是产品上的一条引线或一道真实划痕,检测系统就在预处理阶段亲手销毁了证据。

图 8.4: 5×5 开运算:噪声照样除净,但 3 px 细长条也被整条抹掉(blob 数 16→6)——结构元一旦大过要保留的结构,删除就不再区分噪声与目标。

由此得到本章最重要的工程法则:结构元必须大于要删除的噪声、小于要保留的最小结构。本例中噪声最大 2×2、最小真实结构 3 px 厚,3×3 恰好落在夹缝里,5×5 则越界了。如果两个边界冲突——噪声颗粒比最小目标还大——那么尺寸这一个维度已经分不开它们,不要硬调结构元,应当换用形状各异的结构元(细长目标配细长结构元)或把残余噪声留给后续的面积筛选。

这条法则与 章节 6 中值滤波的核尺寸法则同构:中值核必须小于要保留的最小结构宽度的两倍,否则细线连同噪声一起被抹掉。这不是巧合——开运算与中值滤波同属”按尺寸排序取舍”的非线性滤波,它们都不认识”缺陷”与”噪声”,只认识尺寸

8.4 灰度形态学与顶帽

小节 8.1 提到的 min/max 视角让形态学自然地走出二值世界:对灰度图像,灰度腐蚀(grayscale erosion)就是取结构元邻域内的灰度最小值灰度膨胀(grayscale dilation)最大值。灰度开运算(先 min 后 max)会削平所有”宽度容不下结构元”的亮峰,而对大面积的背景起伏几乎不动——换句话说,灰度开运算是一个只保留背景的运算

这立刻派生出一件工业利器。白顶帽变换(white top-hat)定义为原图减去其灰度开运算:

\[ T_b(f) = f - (f \circ b). \]

\(f \circ b\) 是”被抹掉小亮结构的背景估计”,原图减去它,剩下的恰好是那些小亮结构本身——背景无论是平的、斜的还是弯的,都被减干净了。顶帽对背景亮度水平天然免疫,这正是 章节 4 里阴影补正想达到的目标:可以把顶帽理解为”自带背景估计的小目标提取”,背景模型不用另外拍摄、不用拟合,就藏在开运算里。

(a) 灰度场景
(b) 11×11 白顶帽
(c) 顶帽后阈值 35
图 8.5: 不均匀照明下的小亮斑提取。(a) 背景从 30 到 160 的水平渐变上叠加 16 个”背景+70”的小亮斑,最暗的亮斑峰值约 116,比右侧 160 的背景还暗——任何全局阈值必败;(b) 11×11 白顶帽把渐变背景整体减除,只剩 16 个亮斑;(c) 阈值 35 二值化:恰好 16 个 blob、216 px,与亮斑面积真值分毫不差。

实验场景(图 8.5)刻意设计成让 章节 7 的全局阈值必败:背景是从 30 到 160 的水平渐变,其上叠加 16 个比局部背景亮 70 个灰度级的小亮斑(2×2 到 5×5 不等)。位于图像左侧的最暗亮斑峰值只有约 116,而右侧最亮处的背景已经是 160——亮斑比背景还暗,不存在能分开两者的全局阈值。用 11×11 的结构元(大于最大的 5×5 亮斑)做白顶帽后,渐变背景被整体减除,再用阈值 35 二值化,结果恰好提取出 16 个 blob、共 216 px——与 16 个亮斑的面积真值(\(4\times(4+9+16+25)=216\))完全一致,一个不多、一个不少。顶帽的结构元尺寸法则与上节相同:必须大于要提取的目标,目标才会被开运算从背景估计中抹掉、从而出现在差值里。

8.5 SciVision 实现

SciVision SDK 的形态学功能由 SciMorphStructure(结构元)与 SCIMV::SciSvMorphology(运算)两个类提供,与本章实验一致的调用方式如下:

// 结构元:CreateElement(rows, cols, anchorX, anchorY, shape, data)
SciMorphStructure se3, se11;
long rc = se3.CreateElement(3, 3, 1, 1, SCI_MS_RECT);    // 3×3 矩形,锚点(1,1)居中
if (rc) { /* 返回码必须检查 */ }
rc = se11.CreateElement(11, 11, 5, 5, SCI_MS_RECT);      // 顶帽用 11×11

SciROI roi;
SciPoint tl(0, 0), br(W, H);   // GenRect1 右下角为排他端点:全图须传 (W,H)
roi.GenRect1(tl, br);          // 接收非 const 引用,须用具名变量

SCIMV::SciSvMorphology m;
SciImage dErode, dOpen, dTop;
rc = m.Erode(src, roi, se3, 1, &dErode);     // 腐蚀,iteration = 1
rc = m.Open (src, roi, se3, 1, &dOpen);      // 开运算
rc = m.TopHat(gsrc, roi, se11, 1, &dTop);    // 白顶帽

CreateElement 的前两个参数是结构元的行数与列数,anchorX/anchorY 指定锚点位置(常规用法取中心,如 3×3 取 (1,1)、11×11 取 (5,5));shapeSCI_MS_RECT 表示实心矩形,最后的 int* data 参数仅在自定义形状时使用,矩形核可缺省。Erode/Dilate/Open/Close/TopHat 五个接口签名一致:输入图像、ROI、结构元、迭代次数与输出指针。iteration 参数表示运算重复执行的次数——对矩形结构元,\(n\) 次 3×3 腐蚀等价于一次 \((2n+1)\times(2n+1)\) 腐蚀,可以用小核迭代代替大核;但对开闭运算,由于幂等性,iteration 大于 1 与等于 1 的结果可能并无差别,通常固定传 1。

一个必须如实交代的陷阱:SciROI::GenRect1右下角是排他端点章节 7 的 SciVision 一节已记录过同一语义)。若按”最后一个像素坐标”的直觉传 \((W-1,H-1)\),最后一行与最后一列就被排除在 ROI 之外、不被处理——形态学输出在该边带上恒为 0,顶帽差值 \(f - 0\) 退化为原图灰度,渐变背景较亮的一侧会留下一条足以击穿阈值的高响应伪影。这条伪影很容易被误读成”SDK 不处理最外一行/列”的缺陷;其实传 \((W,H)\)(或干脆使用未定义 ROI)后 SDK 处理的就是完整图像,伪影随之消失。本章实验全部以 \((W,H)\) 全图 ROI 运行。

工业案例:焊点检测的开运算救场

某 PCB 焊点检测工位上,锡膏飞溅在板面留下大量 1~3 px 的小亮颗粒,阈值分割后全部成为前景,blob 计数比真实焊点数虚高数倍。在分割与计数之间插入一步 3×3 开运算后,飞溅颗粒被除净,计数恢复正常——教科书式的开运算应用。三个月后有工程师把结构元改成 7×7,理由是”更保险”:飞溅确实除得更干净了,但产线随即漏检——该产品最小规格的焊点直径仅 6 px,容不下 7×7 的结构元,被连同飞溅一起抹掉了。复盘结论写进了部门规范:结构元的尺寸上限必须从工艺文件的最小焊点尺寸反推,而不是从”噪声除得干不干净”正推;残余飞溅交给后续面积筛选,比放大结构元安全得多。

8.6 小结

  • 形态学是阈值分割与 Blob 分析之间的修理工序:腐蚀按”结构元须完整放入前景”删像素,膨胀按”结构元与前景相碰即置位”补像素;二者互为对偶,等价于邻域 min/max 滤波,但单独使用都会系统性地改变目标尺寸(本章实验约 ±6.7%)。
  • 开=先腐后膨,删噪点、毛刺、细桥而不填孔;闭=先膨后腐,缝细缝、填小孔而不除噪点。两者都把尺寸偏差压到 ±1% 以内(47426 → 47225 / 47801),且均幂等——做一次就到位。
  • 结构元尺寸法则:大于要删的噪声、小于要保的结构(3×3 保住 3 px 细条,5×5 抹掉它)。这与中值滤波的核尺寸法则同构——非线性滤波只认尺寸,不认语义。
  • 白顶帽 \(f-(f\circ b)\) 是自带背景估计的小亮目标提取,对照明梯度免疫:全局阈值必败的渐变场景,顶帽后用一个固定阈值恰好提出全部 16 个亮斑、216 px 与真值吻合。结构元须大于待提取目标。
  • 工程提醒GenRect1 的右下角是排他端点——传 \((W-1,H-1)\) 会把最后一行/列排除在处理之外,顶帽在该边带退化为原图灰度;全图 ROI 必须传 \((W,H)\)

数学形态学的理论源头是 Serra 的奠基性专著 (Serra 1982),Soille 的著作则系统覆盖了灰度形态学及其应用 (Soille 2004);二值与灰度形态学算子的统一综述可参阅 Haralick、Sternberg 与 Zhuang 的经典论文 (Haralick, Sternberg, 和 Zhuang 1987)。形态学的完整理论体系(击中击不中变换、测地重建、分水岭等进阶专题)可进一步阅读 Steger 等人的著作 (Steger, Ulrich, 和 Wiedemann 2018)