形态学图像处理
形态学图像处理关心的不是“像素值相差多少”,而是一个形状能否容纳、碰到或避开另一个小形状,这个小形状称为结构元素。把结构元素在图像上平移,就能把局部几何关系转化为腐蚀、膨胀、开闭、边界、连通量和骨架等可计算对象。[1]
需要一开始就固定一点:结构元素的形状、尺寸与原点就是本方法的模型。任何形态学结论都是相对这个模型而言的——同一个目标在小结构元素下可能连成一片,在大结构元素下却被分开;原点偏置还会造成方向性偏差。因此本章的要旨不是记住运算符,而是先看清“用哪个结构元素、在什么邻接约定下”再下结论。
设前景像素取 1、背景取 0。二值图像可以等价表示为格点集合
结构元素 是带有指定原点的小型格点集合。它在位置 处的平移为

结构元素不是越大越好。尺寸应当与目标尺度相关:过小的结构元素容纳不下目标尺度的噪声,无法真正去除;过大的结构元素则会抹掉窄连接、尖角和小目标。形状携带连通性语义:十字形偏向四邻接,方形同时连接对角方向,圆盘则近似各向同性。文中先建立二值集合定义,再给出灰度级扩展。
一、腐蚀与膨胀
1. 腐蚀
集合 被结构元素 腐蚀定义为
腐蚀会让前景边界向内退缩。退缩量不是固定“一个像素”,而由结构元素从原点到边界的距离和方向决定。它可以消除比结构元素小的亮点、断开狭窄连接、缩小物体,并为边界提取和距离分析提供基础。若结构元素原点不在几何中心,腐蚀结果还会产生方向偏移。
2. 膨胀
集合 被结构元素 膨胀定义为
膨胀让前景向外扩张,可填补比结构元素小的缝隙、连接邻近物体、加粗细线。它也会放大前景噪声,所以“先膨胀再腐蚀”与“先腐蚀再膨胀”具有不同作用,不能交换顺序。

import cv2
import numpy as np
def binary_erode_dilate(
binary: np.ndarray,
kernel_size: int = 5,
) -> tuple[np.ndarray, np.ndarray]:
if binary.ndim != 2:
raise ValueError("binary image must be two-dimensional")
if kernel_size <= 0 or kernel_size % 2 == 0:
raise ValueError("kernel_size must be a positive odd number")
foreground = np.where(binary > 0, 255, 0).astype(np.uint8)
kernel = cv2.getStructuringElement(
cv2.MORPH_ELLIPSE,
(kernel_size, kernel_size),
)
eroded = cv2.erode(
foreground,
kernel,
iterations=1,
borderType=cv2.BORDER_CONSTANT,
borderValue=0,
)
dilated = cv2.dilate(
foreground,
kernel,
iterations=1,
borderType=cv2.BORDER_CONSTANT,
borderValue=0,
)
return eroded, dilated3. 对偶性
相对于全集的补集记为上标 ,则

对偶性既是理论关系,也是实现检查工具。例如用“补集—膨胀—补集”计算的腐蚀应与直接腐蚀一致。若不一致,通常是边界补充值、前景约定或非对称结构元素反射处理不同。
4. 灰度级腐蚀与膨胀
灰度形态学把二值集合的“完全包含”和“发生相交”推广为局部最小值与最大值。对平坦结构元素 ,
腐蚀选择邻域最暗值,因此缩小亮区、扩大暗区;膨胀选择邻域最亮值,因此扩大亮区、缩小暗区。非平坦结构元素还带高度函数 ,相应公式变为 和 。

kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11))
gray_eroded = cv2.erode(gray, kernel)
gray_dilated = cv2.dilate(gray, kernel)二、开运算与闭运算
1. 开运算
集合 被 开运算定义为
开运算能删除小亮点、切断细连接、平滑向外尖出的边界。它不会恢复在腐蚀阶段已经完全消失的分量,因此不是简单的“缩小后放大到原样”。
2. 闭运算
集合 被 闭运算定义为
闭运算适合填小孔、连接窄裂缝、平滑向内凹陷。开与闭互为对偶,但实际去噪时仍要依据噪声极性选择:白色孤立点使用开运算,黑色小孔使用闭运算。
若结构元素包含原点,则开运算具有反扩张性 ,闭运算具有扩张性 ;二者均幂等:

3. 灰度级开闭运算
灰度开运算仍是先灰度腐蚀后灰度膨胀,用于压制比结构元素小的亮细节;灰度闭运算先膨胀后腐蚀,用于填平小暗细节。白顶帽 提取小亮结构,黑底帽 提取小暗结构。
opened = cv2.morphologyEx(image, cv2.MORPH_OPEN, kernel)
closed = cv2.morphologyEx(image, cv2.MORPH_CLOSE, kernel)
white_tophat = cv2.morphologyEx(image, cv2.MORPH_TOPHAT, kernel)
black_tophat = cv2.morphologyEx(image, cv2.MORPH_BLACKHAT, kernel)三、击中—击不中变换
设 ,其中 描述必须命中的前景形状, 描述必须命中的背景形状,且二者不相交。变换定义为

击中—击不中不是只寻找“像素相同”的模板匹配。它把局部结构拆成必须存在与必须不存在的两部分,适合检测端点、拐角、分叉和特定排列。旋转目标需要同时旋转模板并合并各方向结果。

# 1 表示必须为前景,-1 表示必须为背景,0 表示不关心。
template = np.array(
[
[-1, 1, -1],
[0, 1, 0],
[0, 1, 0],
],
dtype=np.int8,
)
binary01 = (binary > 0).astype(np.uint8)
hits = cv2.morphologyEx(binary01, cv2.MORPH_HITMISS, template)灰度图没有天然的前景与背景集合。常见扩展是先按一个或多个灰度阈值构造水平集,再对每个水平集应用二值击中—击不中;另一种做法是用灰度腐蚀分别约束局部下界与上界。必须明确所采用的灰度模板定义,不能直接把二值 MORPH_HITMISS 用于任意灰度数组。
四、边界提取
使用包含原点的小结构元素 ,集合 的形态学内边界定义为
外边界可写为 ;形态学梯度为 ,同时覆盖内外两侧。结构元素越大,所得边界越厚。灰度形态学梯度用膨胀图减腐蚀图,能突出局部亮暗跨度。
kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3))
inner_boundary = cv2.subtract(binary, cv2.erode(binary, kernel))
outer_boundary = cv2.subtract(cv2.dilate(binary, kernel), binary)
gradient = cv2.morphologyEx(gray, cv2.MORPH_GRADIENT, kernel)五、孔洞填充
若已知孔洞中的一个种子点集合 ,可以在补集内做受限膨胀:
直到 。最终填充结果是 。交集约束阻止生长穿过前景边界。

def fill_holes(binary: np.ndarray) -> np.ndarray:
foreground = np.where(binary > 0, 255, 0).astype(np.uint8)
reachable_background = foreground.copy()
flood_mask = np.zeros(
(foreground.shape[0] + 2, foreground.shape[1] + 2),
dtype=np.uint8,
)
cv2.floodFill(reachable_background, flood_mask, (0, 0), 255)
holes = cv2.bitwise_not(reachable_background)
return cv2.bitwise_or(foreground, holes)上述洪泛实现从图像边界背景出发,先找“不是孔洞”的背景,再取反得到全部孔洞。若左上角不是背景,应从全部边界背景像素建立种子,或先添加一圈背景边框。
六、连通分量提取
给定分量内的种子 ,可令 ,迭代
直到稳定。这里与孔洞填充的形式相同,但约束集合从 换成 。实际程序通常用 BFS、DFS 或两遍标记算法一次提取所有分量。

count, labels, statistics, centroids = cv2.connectedComponentsWithStats(
(binary > 0).astype(np.uint8),
connectivity=8,
)
for label in range(1, count):
area = statistics[label, cv2.CC_STAT_AREA]
left = statistics[label, cv2.CC_STAT_LEFT]
top = statistics[label, cv2.CC_STAT_TOP]
width = statistics[label, cv2.CC_STAT_WIDTH]
height = statistics[label, cv2.CC_STAT_HEIGHT]
component_mask = np.where(labels == label, 255, 0).astype(np.uint8)七、凸壳
凸壳填平所有向内凹陷,但不会简单地把包围盒全部填满。数字图像中既可用一组方向性击中—击不中模板迭代填补凹口,也可先提取轮廓,再使用几何算法计算凸壳顶点。后者更直接,并能返回有序多边形。
contours, _ = cv2.findContours(
binary,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE,
)
hull_mask = np.zeros_like(binary)
for contour in contours:
hull = cv2.convexHull(contour)
cv2.fillConvexPoly(hull_mask, hull, 255)若多个物体必须分别处理,应先按连通分量或外轮廓计算各自凸壳;把所有前景点一次送入 convexHull 会得到包围全部物体的单个大凸壳。
八、细化与粗化
1. 细化
使用击中—击不中模板 的细化定义为
细化的目标是把宽线条压缩为单像素宽表示,同时保持端点、连通性和主要拓扑。不能用普通腐蚀替代,因为腐蚀会从所有方向同时收缩并最终删除整个物体。Zhang–Suen 算法在两个子迭代中检查八邻域:邻居数必须在 2 到 6 之间,按圆周从 0 到 1 的跃迁次数必须为 1,再利用两组方向条件避免同时删除关键连接点。
2. 粗化
使用模板 的粗化定义为
粗化可以连接断裂骨架、恢复线宽或逐步构造凸壳,但同样需要终止条件或约束集合,否则会持续扩张。实际中常把新增像素限制在掩膜 内:。

def one_thinning_pass(binary01: np.ndarray, phase: int) -> np.ndarray:
source = binary01.copy()
remove = []
for y in range(1, source.shape[0] - 1):
for x in range(1, source.shape[1] - 1):
if source[y, x] == 0:
continue
neighbors = [
source[y - 1, x], source[y - 1, x + 1],
source[y, x + 1], source[y + 1, x + 1],
source[y + 1, x], source[y + 1, x - 1],
source[y, x - 1], source[y - 1, x - 1],
]
count = sum(neighbors)
transitions = sum(
neighbors[index] == 0
and neighbors[(index + 1) % 8] == 1
for index in range(8)
)
if not 2 <= count <= 6 or transitions != 1:
continue
north, east, south, west = (
neighbors[0], neighbors[2], neighbors[4], neighbors[6]
)
if phase == 0:
safe = north * east * south == 0 and east * south * west == 0
else:
safe = north * east * west == 0 and north * south * west == 0
if safe:
remove.append((y, x))
for y, x in remove:
source[y, x] = 0
return source九、骨架
令 表示用 连续腐蚀 次, 是使 非空的最大整数。骨架层定义为
完整骨架为
骨架可以理解为所有最大内接结构元素中心的集合。若一个以骨架点为中心的结构元素仍能增大而保持在 内,它就不是最大内接元素。骨架适合描述形状的中心线、分支和局部厚度,但对边界噪声敏感:一个很小的凸起也可能产生很长的伪分支,因此常先平滑边界或在骨架后按分支长度剪枝。

若保留全部骨架层及其腐蚀次数,则
def morphological_skeleton(binary: np.ndarray) -> np.ndarray:
work = np.where(binary > 0, 255, 0).astype(np.uint8)
skeleton = np.zeros_like(work)
kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3))
while cv2.countNonZero(work) > 0:
eroded = cv2.erode(work, kernel)
opened = cv2.dilate(eroded, kernel)
layer = cv2.subtract(work, opened)
skeleton = cv2.bitwise_or(skeleton, layer)
work = eroded
return skeleton这个循环实现集合公式:每轮产生一个 层,再把输入腐蚀一次。若还要精确重建,必须分别保存各层及其 ,而不能只保存所有层的并集图像。
References
- [1] R. C. Gonzalez and R. E. Woods, Digital Image Processing, 4th ed. Pearson, 2018.
- [2] OpenCV, “More Morphology Transformations.” Accessed: Aug. 28, 2026. [Online]. Available: https://docs.opencv.org/4.x/d3/dbe/tutorial_opening_closing_hats.html
- [3] T. Y. Zhang and C. Y. Suen, “A Fast Parallel Algorithm for Thinning Digital Patterns,” Communications of the ACM, vol. 27, no. 3, pp. 236–239, 1984.
Illustrated by