第七章 形态学图像处理
1. 形态学基本概念
前言
形态学是研究动物和植物形态和结构的学科。
数学形态学作为工具从图像中提取对于表达和描述区域形状有用的图像分量,如边界、骨架及凸壳。其原理是基于数学 集合论。
1. 基本集合运算
- 包含、不包含、空集
- 子集、并集、交集
- 互斥、补集、差集
- 平移、反射


2. 结构元素
形态学图像处理是在图像中移动一个结构元素并进行一种类似于卷积的操作。
空域滤波是滤波核与图像做相关运算,而形态学运算是结构元素与图像做集合运算
结构元素(structuring elements ,SE)是小的子图像集合,特点:
- 必须指定结构元素原点。
- 结构元素是四周用0填充的矩形。

结构组成与形状差异
| 特性 | 结构元素(SE) | 滤波核(Kernel) |
|---|---|---|
| 元素类型 | 通常是二值(0 或 1),有时是灰度(0~255) | 通常是浮点数(如 -1, 0, 1,或高斯权重等) |
| 常见形状 | 矩形、十字形、椭圆形(也有其他自定义形状) | 通常是正方形(如 3×3、5×5) |
| 原点(锚点) | 必须明确指定一个参考点(如中心),用于对齐图像 | 通常默认中心为锚点,或通过卷积函数自动对齐 |
| 是否必须为稀疏 | 通常大部分为 0(只关心部分区域是否“覆盖”) | 通常是密集的(每个值都参与加权计算) |
2. 膨胀、腐蚀、开运算、闭运算
前言

1. 腐蚀
腐蚀操作时取每一个位置的矩形邻域内值的最小值 作为该位置的输出灰度值。这里的邻域可以是矩形结构,也可以是椭圆形结构、十字交叉形结构等,这个结构被定义为结构元,实际上就是个01二值矩阵。
举个例子:
给定一个矩阵,也就是我们要做处理的图像,以及一个十字交叉结构元


在对(1,2)点出的灰度做处理时,也就是对21这个点做处理时,要在其十字形邻域内找最小值,赋值给
点。示意图如下:

从图中也可以很容易的看出,腐蚀操作将灰度值降低了,变成了11,也就是说腐蚀后的输出图像总体亮度比原图有所降低,图像中比较亮的区域面积会变小,比较暗的区域面积增大。
粗略的说,腐蚀可以使目标区域范围“变小”,其实质造成图像的边界收缩,
记忆策略:腐蚀,可以联想到浓硫酸腐蚀,浓硫酸会让木头、纸腐蚀,最后变黑。
应用场景:
- 腐蚀能将连接的对象分开

- 腐蚀能去除喷溅突出

- 腐蚀会缩小图像

2. 膨胀
膨胀操作时取每一个位置的矩形邻域内值的最大值作为该位置的输出灰度值。
膨胀相当于是腐蚀反向操作,图像中较亮的物体尺寸会变大,较暗的物体尺寸会减小。还是相同的例子,在21的十字邻域内找最大值,最大值为234,将234赋值到这个位置。

记忆策略:玉米经过加热后越来越大,也越来越白
应用场景:
- 膨胀可修复图像断裂

- 膨胀可修复侵入突出

- 膨胀会放大图像


import cv2
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif']=['SimHei'] #用来正常显示中文标签
plt.rcParams['axes.unicode_minus']=False #用来正常显示负
img = cv2.imread('./img/wirebond-mask.tif', 0) # 读取骰(tou)子灰度图
# 得到一个3x3的十字交叉结构元,实际就是numpy矩阵
kernel = cv2.getStructuringElement(cv2.MORPH_CROSS,(3,3))
erode_res = cv2.erode(img, kernel) # 腐蚀结果
dilate_res = cv2.dilate(img, kernel) # 膨胀结果
plt.subplot(1,3,1)
plt.imshow(erode_res, cmap=plt.cm.gray)
plt.title("腐蚀效果")
plt.subplot(1,3,2)
plt.imshow(img, cmap=plt.cm.gray)
plt.title("原图")
plt.subplot(1,3,3)
plt.imshow(dilate_res, cmap=plt.cm.gray)
plt.title("膨胀效果")
plt.show()
3. 开运算
膨胀使图像变大,腐蚀使图像变小,为保持图像大小,膨胀与腐蚀通常成对出现。
开运算是先腐蚀后膨胀,可以消除亮度较高的细小区域,让轮廓变得光滑,而且不会明显改变其他物体区域的面积。

记忆策略: 要打开一个东西, 就先腐蚀, 腐蚀了才能打开
开运算的几何解释:类似于B在一个轮廓内部滚动,开运算会去除外尖角
4.闭运算
闭运算与开运算相反,先膨胀后腐蚀。可以消除细小黑色空洞,也不会明显改变其他物体区域面积。


记忆策略:欲使其自闭,必先让其膨胀
案例代码 👇
1.案例1 ✏️
import cv2
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif']=['SimHei'] #用来正常显示中文标签
plt.rcParams['axes.unicode_minus']=False #用来正常显示负
# 因为图像太大,做运算效果不明显,所以先resize到小尺寸
img = cv2.resize(cv2.imread('./img/banana.jpg', 0), (150, 100))
# img = cv2.resize(cv2.imread('./img/kbys.png', 0), (150, 100))
# img = cv2.resize(cv2.imread('./img/Fig0913(a)(small-squares).tif', 0), (100, 100))
# 得到一个5x5的矩形结构元
kernel = cv2.getStructuringElement(cv2.MORPH_RECT,(5,5))
iterations = 10 # 执行开闭运算的次数 可以调整哦!!!
# 次数越多,开闭运算效果越明显,但是运算时间越长
open_res = cv2.morphologyEx(img, cv2.MORPH_OPEN, kernel, iterations)
close_res = cv2.morphologyEx(img, cv2.MORPH_CLOSE, kernel, iterations)
plt.subplot(1,3,1)
plt.imshow(open_res, cmap=plt.cm.gray)
plt.title("开运算 10 iter") # iterations 迭代次数
plt.subplot(1,3,2)
plt.imshow(img, cmap=plt.cm.gray)
plt.title("原图")
plt.subplot(1,3,3)
plt.imshow(close_res, cmap=plt.cm.gray)
plt.title("闭运算 10 iter")
plt.show()


2. 案例2 ✏️
import numpy as np
import cv2
from matplotlib import pyplot as plt
%matplotlib inline
%config InlinBackend.figure_format="retina"
plt.rcParams['font.sans-serif']=['SimHei'] #用来正常显示中文标签
plt.rcParams['axes.unicode_minus']=False #用来正常显示负
img = cv2.imread(r'./img/morphology.png',0)
# img = cv2.imread(r'./img/fingerprint.png',0) # 指纹图
_, img_binary = cv2.threshold(img, 128, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
kernelSize=33 #换成指纹头,要修改核大小哦,建议从1开始试
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(kernelSize,kernelSize))
img_erode = cv2.erode(img_binary,kernel,iterations = 1)
img_dilate = cv2.dilate(img_binary, kernel, iterations=1)
img_open = cv2.morphologyEx(img_binary, cv2.MORPH_OPEN, kernel)
img_close = cv2.morphologyEx(img_binary, cv2.MORPH_CLOSE, kernel)
plt.figure(figsize=(12,4))
plt.subplot(151)
plt.axis("off")
plt.imshow(img_binary,cmap="gray")
plt.title("原图")
plt.subplot(1,5,2)
plt.axis("off")
plt.imshow(img_erode,cmap="gray")
plt.title("腐蚀")
plt.subplot(1,5,3)
plt.axis("off")
plt.imshow(img_dilate,cmap="gray")
plt.title("膨胀")
plt.subplot(1,5,4)
plt.axis("off")
plt.imshow(img_open,cmap="gray")
plt.title("开运算")
plt.subplot(1,5,5)
plt.axis("off")
plt.imshow(img_close,cmap="gray")
plt.title("闭运算")
plt.show()

3. 形态学算法
前言
1.边界提取
β(A) 代表集合A的边界,可以采取如下进行获取:


import numpy as np
import cv2
from matplotlib import pyplot as plt
%matplotlib inline
%config InlinBackend.figure_format="retina"
plt.rcParams['font.sans-serif']=['SimHei'] #用来正常显示中文标签
plt.rcParams['axes.unicode_minus']=False #用来正常显示负
img = cv2.imread(r'./img/mapleleaf.tif',0) # 读取图像
_, img_binary = cv2.threshold(img, 128, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 二值化
plt.figure(figsize=(12,6))
plt.subplot(141)
plt.axis("off")
plt.imshow(img_binary,cmap="gray")
i=1 # 计数器
for kernelSize in [3,9,15]:# 3x3,9x9,15x15
kernel = cv2.getStructuringElement(cv2.MORPH_RECT,(kernelSize,kernelSize))
img_result = cv2.morphologyEx(img_binary, cv2.MORPH_GRADIENT, kernel)
i+=1
plt.subplot(1,4,i)
plt.title("边界提取:"+str(kernelSize)+"x"+str(kernelSize))
plt.axis("off")
plt.imshow(img_result,cmap="gray")
plt.show() # 显示图像
2.区域填充

区域填充步骤:
- 在区域内确定一个种子点X0
- 从种子点开始膨胀 ,通过与A的补集的交集,限制膨胀在闭环边界内

- 不断的迭代,直到Xk=Xk-1,此时Xk即为填充后的区域. 步骤图如下👇

import numpy as np
import cv2
from matplotlib import pyplot as plt
%matplotlib inline
%config InlinBackend.figure_format="retina"
plt.rcParams['font.sans-serif']=['SimHei'] #用来正常显示中文标签
plt.rcParams['axes.unicode_minus']=False #用来正常显示负
# 填充所有孔洞
def fill_holes(imgBinary,kernel):
'''
孔洞填充
:param imgBinary: 待处理二值图像
:param kernel: 结构算子
:return: 填充孔洞后的图像
'''
# 原图取补得到MASK图像
mask = 255 - imgBinary
# 构造Marker图像
marker = np.zeros_like(imgBinary)
marker[0, :] = 255
marker[-1, :] = 255
marker[:, 0] = 255
marker[:, -1] = 255
marker_0 = marker.copy()
while True:
marker_pre = marker
dilation = cv2.dilate(marker, kernel)
marker = np.min((dilation, mask), axis=0)
if (marker_pre == marker).all():
break
dst = 255 - marker
filling = dst - imgBinary
return dst
# 填充一个孔洞
def fill_onehole(imgBinary,kernel,m1,n1,m2,n2):
'''
孔洞填充
:param imgBinary: 待处理二值图像
:param kernel: 结构算子
:m1,n1:左上角坐标
:m2,n3:右下角坐标
:return: 填充孔洞后的图像
'''
patch=imgBinary[m1:m2,n1:n2]
# 原图取补得到MASK图像
mask = 255 - patch
# 构造Marker图像
marker = np.zeros_like(patch)
marker[0, :] = 255
marker[-1, :] = 255
marker[:, 0] = 255
marker[:, -1] = 255
marker_0 = marker.copy()
while True:
marker_pre = marker
dilation = cv2.dilate(marker, kernel)
marker = np.min((dilation, mask), axis=0)
if (marker_pre == marker).all():
break
dst = 255 - marker
result=imgBinary.copy()
result[m1:m2,n1:n2]=dst
return result
img = cv2.imread(r'./img/hole.png',0)
_, img_binary = cv2.threshold(img, 128, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
kernelSize=3
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(kernelSize,kernelSize))
img_fillOne=fill_onehole(img_binary,kernel,50,130,130,220)
img_fillAll=fill_holes(img_binary,kernel)
plt.figure(figsize=(12,4))
plt.subplot(131)
plt.axis("off")
plt.title("原图")
plt.imshow(img_binary,cmap="gray")
plt.subplot(1,3,2)
plt.axis("off")
plt.title("填充一个孔洞")
plt.imshow(img_fillOne,cmap="gray")
plt.subplot(1,3,3)
plt.axis("off")
plt.imshow(img_fillAll,cmap="gray")
plt.title("填充所有孔洞")
plt.show()
3. 连通分量提取和骨架提取
| 技术 | 核心关注点 | 主要作用 | 典型应用场景 |
|---|---|---|---|
| 边界提取 | 物体的外部轮廓/边缘形状 | 获取物体的外形线,用于形状分析、检测、显示 | OCR字符边界、物体检测轮廓、图像分割边界 |
| 区域填充 | 物体的内部像素集合 | 填充封闭区域内部,获取完整区域,常用于修复或分析 | 填充选中区域、修复破损区域、区域分析 |
| 连通分量提取 | 多个物体的分组与独立标记 | 发现图像中所有独立物体,为每个物体分配唯一ID | 细胞计数、字符分割、多目标分析、实例分割前处理 |
| 骨架提取 | 物体的内部结构主干/拓扑中心线 | 提取代表物体形状中心的纤细骨架,用于结构分析、分类 | 形状拓扑分析、路径规划、字符骨架、分支检测 |
接区域填充的代码 👇
1. 连通分量提取案例 ✏️
# ----------------------------
# 4. 在填充所有孔洞后的图像上进行连通分量提取(统计硬币/物体数量)
# ----------------------------
# 注意:img_fillAll 是二值图像,前景(比如白色区域)为 255,背景为 0
# 我们要统计的是前景中的连通区域个数,通常是物体/硬币等
# 使用 connectedComponentsWithStats 进行连通分量分析
# 参数说明:
# - img_fillAll:二值图像(前景为255,背景为0)
# - connectivity:8 表示 8 邻域连通,更常用(也可选4)
num_labels_all, labels_all, stats_all, centroids_all = cv2.connectedComponentsWithStats(img_fillAll, connectivity=8)
# 连通标签中,0 代表背景,所以实际物体个数 = 总标签数 - 1
object_count_all = num_labels_all - 1
print(f"🔢 填充所有孔洞后,检测到的连通分量(物体/硬币)个数:{object_count_all}")
# ----------------------------
# 5. (可选)可视化:给每个连通区域着不同颜色,直观显示分割结果
# ----------------------------
# 构造一个彩色图像用于显示不同连通区域
colored_result = np.zeros((img_fillAll.shape[0], img_fillAll.shape[1], 3), dtype=np.uint8)
# 为每个连通区域(除背景0外)分配一个随机颜色
import random
for label in range(1, num_labels_all): # 从1开始,跳过背景
color = [random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)]
colored_result[labels_all == label] = color
# 显示原图、填充所有孔洞图、以及连通分量着色结果
plt.figure(figsize=(15, 5))
plt.subplot(1, 4, 1)
plt.axis("off")
plt.title("原图(二值)")
plt.imshow(img_binary, cmap="gray")
plt.subplot(1, 4, 2)
plt.axis("off")
plt.title("填充所有孔洞")
plt.imshow(img_fillAll, cmap="gray")
plt.subplot(1, 4, 3)
plt.axis("off")
plt.title(f"连通分量提取\n(共 {object_count_all} 个)")
plt.imshow(colored_result)
plt.tight_layout()
plt.show()
2. 骨架提取案例 ✏️
import cv2
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif']=['SimHei'] #用来正常显示中文标签
plt.rcParams['axes.unicode_minus']=False #用来正常显示负
# --------------------------
# 1. 读取并预处理手写字图像
# --------------------------
# 请将 'your_handwriting.png' 替换为你的手写字图片路径
img_path = './img/a.png' # 🔧 替换为你的图片路径!
img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # 以灰度模式读取
if img is None:
raise FileNotFoundError("图片路径无效!请确保 'your_handwriting.png' 存在或替换为有效路径。")
# --------------------------
# 2. 图像二值化
# --------------------------
# 目标:让手写字为白色(255),背景为黑色(0)【或者反过来,根据实际情况调整】
# 方法1:如果你手写字是深色(如黑色),背景是浅色(如白色),使用 THRESH_BINARY_INV
# 方法2:如果你手写字是浅色,背景是深色,使用 THRESH_BINARY
# 自动阈值(Otsu),并使用 THRESH_BINARY_INV(假设手写字为黑色,背景为白色)
_, img_binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
# 如果你发现手写字是白色,背景是黑色,请使用下面这行代替上一行:
# _, img_binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# 方法:使用 OpenCV 的形态学骨架化(推荐,简单且效果好)
def get_skeleton(img_binary):
skeleton = np.zeros(img_binary.shape, dtype=np.uint8)
element = cv2.getStructuringElement(cv2.MORPH_CROSS, (3,3))
while True:
eroded = cv2.erode(img_binary, element)
temp = cv2.dilate(eroded, element)
temp = cv2.subtract(img_binary, temp)
skeleton = cv2.bitwise_or(skeleton, temp)
img_binary = eroded.copy()
if cv2.countNonZero(img_binary) == 0:
break
return skeleton
skeleton = get_skeleton(img_binary)
# --------------------------
# 4. 显示原图和骨架
# --------------------------
plt.figure(figsize=(8, 4))
plt.subplot(1, 2, 1)
plt.imshow(img_binary, cmap='gray')
plt.title('原图(二值化手写字)')
plt.axis('off')
plt.subplot(1, 2, 2)
plt.imshow(skeleton, cmap='gray')
plt.title('字形骨架')
plt.axis('off')
plt.tight_layout()
plt.show()
