16. 图像分割

一、分水岭算法

  任何一幅灰度图像,都可以被看作是地理学上的地形表面,灰度值高的区域可以被看成山峰,灰度值低的区域可以被看成山谷。如果我们向每一个山谷中灌注不同的水,那么随着水位不断升高,不同山谷的水就会汇集到一起。在这个过程中,为了防止不同山谷的水交汇,我们需要在水流可能汇集的地方构建堤坝。该过程将图像分成两个不同的集合:集水盆地和分水岭线。我们构建的堤坝就是分水岭线,即对原始图像的分割。这就是分水岭算法。

  我们可以在终端中使用 pip 安装 OpenCV 模块。默认是从国外的主站上下载,因此,我们可能会遇到网络不好的情况导致下载失败。我们可以在 pip 指令后通过 -i 指定国内镜像源下载

pip install opencv-python -i https://mirrors.aliyun.com/pypi/simple

  国内常用的 pip 下载源列表:

  在 OpenCV 中,我们可以使用 cv2.watershed() 函数实现分水岭算法。

cv2.watershed(image: cv2.typing.MatLike, markers: cv2.typing.MatLike) -> cv2.typing.MatLike: ...

  其中,参数 image8 位三通道的输入图像。参数 markers32 位单通道的标注,表示哪些是背景哪些是前景,它应该和 image 大小相等,它既是输入,也是输出。也就是说,函数会在该参数上进行标注处理,并将对该此参数的标注结果作为输出值。分水岭算法将标记为 0 的区域视为 不确定区域,标记为 1 的区域是为 背景区域,将标记 大于 1 的正整数 表示我们想得到的 前景

  在使用分水岭算法对图像进行分割前,我们需要对图像进行简单的进行简单的形态学处理,以达到去噪等目的。然后,我们还需要通过形态学操作和减法运算得到图像的边界。

  如果图像内的各个子图没有连接时,我们可以直接使用形态学的腐蚀操作确定前景对象,但是如果图像内的子图连接在一起时,就很难确定前景对象了。此时,我们可以使用 OpenCV 的 函数 计算二值图像内任意点到最近背景点的距离 来将前景对象提取出来。一般情况下,该函数计算的是图像内非零值像素点到最近的零值像素点的距离。如果像素点本身的值就为 0,则这个距离也为 0。

cv2.distanceTransform(src: cv2.typing.MatLike, distanceType: int, maskSize: int, dst: cv2.typing.MatLike | None = ..., dstType: int = ...) -> cv2.typing.MatLike: ...

  其中,参数 src8 位单通道的二值图像。参数 distanceType距离类型参数。参数 maskSize掩膜尺寸。参数 dst 是 目标图像,可以是 8 位或者 32 位,尺寸和 src 相同,单通道。参数 dstType 是 目标图像的类型,默认为 cv2.CV_32F

  参数 distanceType距离类型参数,它的可选值如下:

cv2.DIST_USER                                                                   # 用户自定义距离
cv2.DIST_L1                                                                     # distance = |x1 - x2| + |y1 - y2|
cv2.DIST_L2                                                                     # 欧氏距离
cv2.DIST_C                                                                      # distance = max(|x1 - x2|, |y1 - y2|)
cv2.DIST_L12                                                                    # distance = 2 * (sqrt(1 + x * x2) - 1)
cv2.DIST_FAIR                                                                   # distance = c ^ 2 * (|x| / c - log(1 + |x| / c)), c = 1.3998
cv2.DIST_WELSCH                                                                 # distance = c ^ 2 * (1 - exp(-(x / c) ^ 2)), c = 2.9846
cv2.DIST_HUBER                                                                  # distance = |x| < c ? x ^ 2 / 2 : c * (|x| - c / 2), c=1.345

  参数 maskSize掩膜尺寸,它的可选值如下:

cv2.DIST_MASK_3
cv2.DIST_MASK_5
cv2.DIST_MASK_PRECISE

distanceType = cv2.DIST_L1cv2.DIST_C 时,maskSize 强制为 3

  明确了图像内各个部分的划分之后,我们就可以对确定前景图像进行标注了。在 OpenCV 中,我们可以使用 函数 进行标注。该含税会将 背景 标注为 0,将其它的对象使用从 1 开始的正整数标注。

cv2.connectedComponents(image: cv2.typing.MatLike, labels: cv2.typing.MatLike | None = ..., connectivity: int = ..., ltype: int = ...) -> tuple[int, cv2.typing.MatLike]: ...

  其中,参数 image8 位单通道的待标注图像。参数 labels标注的结果图像

import sys
import cv2
import numpy as np

if __name__ == '__main__':
    image = cv2.imread("assets/images/12.png")

    if image is None:
        print("加载图片失败")
        sys.exit(0)

    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

    # 形态学操作
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
    opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2)    # 开运算,先腐蚀再膨胀,去除噪点
    background = cv2.dilate(opening, kernel, iterations=2)                      # 膨胀运算

    distance_transform = cv2.distanceTransform(opening, cv2.DIST_L2, 5)         # 计算二值图像内任意点到最近背景点的距离
    cv2.normalize(distance_transform, distance_transform, 0, 1.0, cv2.NORM_MINMAX)  # 归一化距离变换结果到[0, 1]范围
    _, foreground = cv2.threshold(distance_transform, 0.5 * distance_transform.max(), 255, cv2.THRESH_BINARY)   # 二值化距离变换结果

    foreground = foreground.astype(np.uint8)
    
    # 剩下的区域,不能确定是背景还是前景
    # 通过膨胀之后的结果减去腐蚀之后的结果,得到未知的区域
    unknown = cv2.subtract(background, foreground)

    # 用 0 标注背景,用大于 0 的整数标注前景
    _, markers = cv2.connectedComponents(foreground)

    # watershed()方法中认为 0 是不确定区域,1 是背景,大于 1 是前景
    markers = markers + 1                                                       # 加1确保背景标注为1
    markers[unknown == 255] = 0                                                 # 未知区域标注为0
    # 分水岭算法,返回的 markers 已经做了修改,边界区域标记为 -1
    markers = cv2.watershed(image, markers)

    # 抠图,要扣的区域赋值为255,背景赋值为0
    mask = np.zeros(shape=image.shape[:2], dtype=np.uint8)
    mask[markers > 1] = 255
    image_matting = cv2.bitwise_and(image, image, mask=mask)

    cv2.imshow("Image Matting", image_matting)

    # cv2.imshow("Result", np.hstack((background, foreground, unknown)))
    cv2.waitKey(0)
    cv2.destroyAllWindows()
    sys.exit(0)

二、GrabCut分割算法

  GrabCut 算法通过交互的方式获得前景物体。在开始提取前景时,先用一个矩形框指定前景区域所在的大致位置范围,然后不断地迭代分割,直到达到最好地效果。经过上述处理后,提取前景的效果可能并不理想,存在前景没有提取出来,或者将背景提取为前景的情况,此时需要用户干预提过程。用户在原始图像的副本中(也可是是与原始图像大小相等的任意一幅图像),用白色标注要提取为前景的区域,用黑色标注要作为背景的区域。然后,将标注后的图像作为掩膜,让算法继续迭代提取前景从而得到最终结果。

  GrabCu t算法的具体实施过程如下:

  1. 将前景所在的全部区域使用矩形框标注出来。,此时矩形框框出的是前景的大致位置,其中要包含全部的前景,部分背景。因此,该区域实际上是未确定区域,目前尚无法区分矩形框内的背景和前景。但是,该区域以外的区域可以被认为是 “确定背景”。在此过程中,可以根据需要对前景和背景进行手工标注。手工标注的前景和背景是 “硬标记”,是无法改变的。
  2. 根据矩形框外部的 “确定背景” 数据,以及 “硬标记”(如果存在),来区分矩形框区域内的前景和背景
  3. 用高斯混合模型对前景和背景建模。GMM会根据用户的输入学习并创建新的像素分布。对未分类的像素(可能是背景也可能是前景),根据其与已知分类像素(前景和背景)的关系进行分类。
  4. 根据像素分布情况生成一张图,图像中的各个像素点都是该图中的节点。除此以外,还有两个节点:前景节点和背景节点。每一个像素点都同时连接到前景节点、背景节点。每个像素连接到前景节点或背景节点的边的权重由像素是前景或背景的概率来决定。此处的权重,可以理解为,表示的是一个像素点属于前景、背景的概率值,可以将其称之为 “区域权重”。
  5. 图中的每个像素除了与前景节点及背景节点相连外,彼此之间还存在着连接。两个像素连接的边的权重值由它们的相似性决定,两个像素的颜色越接近,边的权重值越大,表示其越不可能是边缘两个像素的颜色差别越明显,边的权重越小,表示其越可能是边缘。因为该权重与边缘相关,因此将其称为 “边缘权重”。此处的权重,可以理解为,表示的是一个像素点不属于边缘的权重值。该权重值作为后续分割的依据,权重值越大,其越不可能是边缘(更小的概率被切割);权重值越小,越可能是边缘(更大的概率被切割)。
  6. 完成节点连接后,需要解决的问题变成了一幅连通的图。将图切成具有最小成本函数的两个分离的前景节点集合(前景)、背景节点集合(背景)。成本函数是被切割边缘的所有权重的总和。当然,成本函数同时要考虑区域权重、边缘权重两个值。
  7. 不断重复上述过程,直至分类收敛为止。

  在 OpenCV 中,我们可以使用 函数实现交互式前景提取。

cv2.grabCut(img: cv2.typing.MatLike, mask: cv2.typing.MatLike, rect: cv2.typing.Rect, bgdModel: cv2.typing.MatLike, fgdModel: cv2.typing.MatLike, iterCount: int, mode: int = ...) -> tuple[cv2.typing.MatLike, cv2.typing.MatLike, cv2.typing.MatLike]: ...

  其中,参数 img8 位 3 通道的输入图像。参数 mask8 位单通道的掩膜图像。参数 rect包含前景对象的区域,该区域外的部分被认为是确定背景,其格式为 (x, y, width, height)。参数 bgdModel算法内部模型所使用的数组。参数 fgdModel 是 算法内部前景模型所使用的数组。参数 iterCount迭代的次数。参数 mode分割模式

  参数 mask 用于 确定前景区域、背景区域和不确定区域,其值为 4 种形式之一:

cv2.GC_BGD                                                                      # 确定背景
cv2.GC_FGD                                                                      # 确定前景
cv2.GC_PR_BGD                                                                   # 可能的背景
cv2.GC_PR_FGD                                                                   # 可能的前景

  参数 mode分割模式,它的可用取值如下:

# 该函数使用提供的矩形初始状态和掩码,之后,它将运行由参数iterCount指定的迭代次数
cv2.GC_INIT_WITH_RECT
cv2.GC_INIT_WITH_MASK                                                           # 使用自定义模板
cv2.GC_EVAL                                                                     # 修复模式
cv2.GC_EVAL_FREEZE_MODEL                                                        # 使用固定模式

  函数中 mask 参数,既是参数又是返回值,根据使用模式的不同,mask 参数的使用方法不尽相同。

  • 当参数 mode 模式设置为 cv2.GC_INIT_WITH_RECT 时,表示在函数初始化时,使用矩形框(参数 rect)作为前景、背景区分方式。此时,只需将 mask 初始化为一个值均为 0 的数组。函数 cv2.grabCut() 运行完成后,mask 参数内自动包含前景背景划分信息,可以作为掩膜图像完成图像分割。
  • 当参数 mode 模式设置为 cv2.GC_INIT_WITH_MASK 时,表示 自定义模板,需要 手动初始化 mask。需要注意,其内部值必须是cv2.GC_BGDcv2.GC_FGDcv2.GC_PR_BGDcv2.GC_PR_FGD 之一。或者,更一般情况,其内部值使用数字 0、1、2、3 之一表示。函数 cv2.grabCut() 运行完成后,mask 内包含了比初始化时更丰富的前景、背景信息。此时,它作为返回值,表示的是最终的前景、背景划分结果,可以作为掩膜图像,完成图像分割。
import sys
import cv2
import numpy as np

if __name__ == '__main__':
    image = cv2.imread("assets/images/4.png")

    if image is None:
        print("加载图片失败")
        sys.exit(0)

    rect = (15, 15, 250, 300)
    mask = np.zeros(image.shape[:2], np.uint8)
    background_model = np.zeros((1, 65), np.float64)
    foreground_model = np.zeros((1, 65), np.float64)
    cv2.grabCut(image, mask, rect, background_model, foreground_model, 5, cv2.GC_INIT_WITH_RECT)

    # 把前景抠出来
    mask1 = np.where((mask == cv2.GC_FGD) | (mask == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
    output = cv2.bitwise_and(image, image, mask=mask1)
    cv2.imshow("Output", output)

    cv2.waitKey(0)
    cv2.destroyAllWindows()
    sys.exit(0)
posted @ 2026-01-06 23:13  星光映梦  阅读(15)  评论(0)    收藏  举报