简介

连通域标记(connected component labelling)即找出二值图像中互相独立的各个连通域并加以标记,如下图所示(引自 MarcWang 的 Gist

diagram

可以看到图中有三个独立的区域,我们希望找到并用数字标记它们,以便计算各个区域的轮廓、外接形状、质心等参数。连通域标记最基本的两个算法是 Seed-Filling 算法和 Two-Pass 算法,下面便来分别介绍它们,并用 Python 加以实现。

(2022-01-04 更新:修复了 seed_filling 重复追加相邻像素的问题,修改了其它代码的表述。)

Seed-Filling 算法

直译即种子填充,以图像中的特征像素为种子,然后不断向其它连通区域蔓延,直至将一个连通域完全填满。示意动图如下(引自 icvpr 的博客

seed-filling

具体思路为:循环遍历图像中的每一个像素,如果某个像素是未被标记过的特征像素,那么用数字对其进行标记,并寻找与之相邻的未被标记过的特征像素,再对这些像素也进行标记,然后以同样的方法继续寻找与这些像素相邻的像素并加以标记……如此循环往复,直至将这些互相连通的特征像素都标记完毕,此即连通域 1。接着继续遍历图像像素,看能不能找到下一个连通域。下面的实现采用深度优先搜索(DFS)的策略:将与当前位置相邻的特征像素压入栈中,弹出栈顶的像素,再把与这个像素相邻的特征像素压入栈中,重复操作直至栈内像素清空。

import numpy as np

def seed_filling(image, diag=False):
    '''
    用Seed-Filling算法标记图片中的连通域.

    Parameters
    ----------
    image : ndarray, shape (nrow, ncol)
        图片数组,零值表示背景,非零值表示特征.

    diag : bool
        指定邻域是否包含四个对角.

    Returns
    -------
    labelled : ndarray, shape (nrow, ncol), dtype int
        表示连通域标签的数组,0表示背景,从1开始表示标签.

    nlabel : int
        连通域的个数.
    '''
    # 用-1表示未被标记过的特征像素.
    image = np.asarray(image, dtype=bool)
    nrow, ncol = image.shape
    labelled = np.where(image, -1, 0)

    # 指定邻域的范围.
    if diag:
        offsets = [
            (-1, -1), (-1, 0), (-1, 1),(0, -1),
            (0, 1), (1, -1), (1, 0), (1, 1)
        ]
    else:
        offsets = [(-1, 0), (0, -1), (0, 1), (1, 0)]

    def get_neighbor_indices(row, col):
        '''获取(row, col)位置邻域的下标.'''
        for (dx, dy) in offsets:
            x = row + dx
            y = col + dy
            if 0 <= x < nrow and 0 <= y < ncol:
                yield x, y

    label = 1
    for row in range(nrow):
        for col in range(ncol):
            # 跳过背景像素和已经标记过的特征像素.
            if labelled[row, col] != -1:
                continue
            # 标记当前位置和邻域内的特征像素.
            current_indices = []
            labelled[row, col] = label
            for neighbor_index in get_neighbor_indices(row, col):
                if labelled[neighbor_index] == -1:
                    labelled[neighbor_index] = label
                    current_indices.append(neighbor_index)
            # 不断寻找与特征像素相邻的特征像素并加以标记,直至再找不到特征像素.
            while current_indices:
                current_index = current_indices.pop()
                labelled[current_index] = label
                for neighbor_index in get_neighbor_indices(*current_index):
                    if labelled[neighbor_index] == -1:
                        labelled[neighbor_index] = label
                        current_indices.append(neighbor_index)
            label += 1

    return labelled, label - 1

Two-Pass 算法

顾名思义,是会对图像过两遍循环的算法。第一遍循环先粗略地对特征像素进行标记,第二遍循环中再根据不同标签之间的关系对第一遍的结果进行修正。示意动图如下(引自 icvpr 的博客

two-pass

具体思路为

  • 第一遍循环时,若一个特征像素周围全是背景像素,那它很可能是一个新的连通域,需要赋予其一个新标签。如果这个特征像素周围有其它特征像素,则说明它们之间互相连通,此时随便用它们中的一个旧标签值来标记当前像素即可,同时要用并查集记录这些像素的标签间的关系。
  • 因为我们总是只利用了当前像素邻域的信息(考虑到循环方向是从左上到右下,邻域只需要包含当前像素的上一行和本行的左边),所以第一遍循环中找出的那些连通域可能会在邻域之外相连,导致同一个连通域内的像素含有不同的标签值。不过利用第一遍循环时获得的标签之间的关系(记录在并查集中),可以在第二遍循环中将同属一个集合(连通域)的不同标签修正为同一个标签。
  • 经过第二遍循环的修正后,虽然假独立区域会被归并,但它所持有的标签值依旧存在,这就导致本应连续的标签值序列中有缺口(gap)。所以依据需求可以进行第三遍循环,去掉这些缺口,将标签值修正为连续的整数序列。

其中提到的并查集是一种处理不相交集合的数据结构,支持查询元素所属、合并两个集合的操作。利用它就能处理标签和连通域之间的从属关系。我是看 算法学习笔记(1) : 并查集 这篇知乎专栏学的。下面的实现中仅采用路径压缩的优化,合并两个元素时始终让大的根节点被合并到小的根节点上,以保证连通域标签值的排列顺序跟数组的循环方向一致。

from scipy.stats import rankdata

class UnionFind:
    '''用列表实现简单的并查集.'''
    def __init__(self, n):
        '''创建含有n个节点的并查集,每个元素指向自己.'''
        self.parents = list(range(n))

    def find(self, i):
        '''递归查找第i个节点的根节点,同时压缩路径.'''
        parent = self.parents[i]
        if parent == i:
            return i
        else:
            root = self.find(parent)
            self.parents[i] = root
            return root

    def union(self, i, j):
        '''合并节点i和j所属的两个集合.保证大的根节点被合并到小的根节点上.'''
        root_i = self.find(i)
        root_j = self.find(j)
        if root_i < root_j:
            self.parents[root_j] = root_i
        elif root_i > root_j:
            self.parents[root_i] = root_j
        else:
            return None

def two_pass(image, diag=False):
    '''
    用Two-Pass算法标记图片中的连通域.

    Parameters
    ----------
    image : ndarray, shape (nrow, ncol)
        图片数组,零值表示背景,非零值表示特征.

    diag : bool
        指定邻域是否包含四个对角.

    Returns
    -------
    labelled : ndarray, shape (nrow, ncol), dtype int
        表示连通域标签的数组,0表示背景,从1开始表示标签.

    nlabel : int
        连通域的个数.
    '''
    image = np.asarray(image, dtype=bool)
    nrow, ncol = image.shape
    labelled = np.zeros_like(image, dtype=int)
    uf = UnionFind(image.size // 2)

    # 指定邻域的范围,相比seed-filling只有半边.
    if diag:
        offsets = [(-1, -1), (-1, 0), (-1, 1), (0, -1)]
    else:
        offsets = [(-1, 0), (0, -1)]

    def get_neighbor_indices(row, col):
        '''获取(row, col)位置邻域的下标.'''
        for (dx, dy) in offsets:
            x = row + dx
            y = col + dy
            if 0 <= x < nrow and 0 <= y < ncol:
                yield x, y

    label = 1
    for row in range(nrow):
        for col in range(ncol):
            # 跳过背景像素.
            if not image[row, col]:
                continue
            # 寻找邻域内特征像素的标签.
            feature_labels = []
            for neighbor_index in get_neighbor_indices(row, col):
                neighbor_label = labelled[neighbor_index]
                if neighbor_label > 0:
                    feature_labels.append(neighbor_label)
            # 当前位置取邻域内的标签,同时记录邻域内标签间的关系.
            if feature_labels:
                first_label = feature_labels[0]
                labelled[row, col] = first_label
                for feature_label in feature_labels[1:]:
                    uf.union(first_label, feature_label)
            # 若邻域内没有特征像素,当前位置获得新标签.
            else:
                labelled[row, col] = label
                label += 1

    # 获取所有集合的根节点,由大小排名得到标签值.
    roots = [uf.find(i) for i in range(label)]
    labels = rankdata(roots, method='dense') - 1
    # 利用advanced indexing替代循环修正标签数组.
    labelled = labels[labelled]

    return labelled, labelled.max()

其中对标签值进行重新排名的部分用到了 scipy.stats.rankdata 函数,自己写循环来实现也可以,但当标签值较多时效率会远低于这个函数。从代码来看,Two-Pass 算法比 Seed-Filling 算法更复杂一些,但因为不需要进行递归式的填充,所以理论上要比后者更快。

其它方法

许多图像处理的包里有现成的函数,例如

  • scipy.ndimage.label
  • skimage.measure.label
  • cv2.connectedComponets

具体信息和用法请查阅文档。顺便测一下各方法的速度,如下图所示(通过 IPython 的 %timeit 测得)

times

显然调包要比手工实现快 100 倍,这是因为 scipy.ndimage.labelskimage.measure.label 使用了更高级的算法和 Cython 代码。因为我不懂 OpenCV,所以这里没有展示 cv2.connectedComponets 的结果。

值得注意的是,虽然上一节说理论上 Two-Pass 算法比 Seed-Filling 快,但测试结果相差不大,这可能是由于纯 Python 实现体现不出二者的差异(毕竟完全没用到 NumPy 数组的向量性质),也可能是我代码写的太烂,还请懂行的读者指点一下。

例子

以一个随机生成的 (50, 50) 的二值数组为例,展示 scipy.ndimage.labelseed_fillingtwo_pass 三者的效果,采用 8 邻域连通,如下图所示

random

可以看到三种方法都找出了 17 个连通域,并且连标签顺序都一模一样(填色相同)。不过若 Two-Pass 法中的并查集采用其它合并策略,标签顺序就很可能发生变化。下面再以一个更复杂的 (800, 800) 大小的空露露图片为例

image

将图片二值化后再进行连通域标记,可以看到おつるる的字样被区分成多个区域,猫猫和露露也都被识别了出来。代码如下

import numpy as np
from PIL import Image
from scipy import ndimage
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors

from connected_components import two_pass, seed_filling

if __name__ == '__main__':
    # 将测试图片二值化.
    picname = 'ruru.png'
    image = Image.open(picname)
    image = np.array(image.convert('L'))
    image = ndimage.gaussian_filter(image, sigma=2)
    image = np.where(image < 220, 1, 0)

    # 设置二值图像与分类图像所需的cmap.
    cmap1 = mcolors.ListedColormap(
        ['white', 'black'])
    white = np.array([1, 1, 1])
    cmap2 = mcolors.ListedColormap(
        np.vstack([white, plt.cm.tab20.colors])
    )

    fig, axes = plt.subplots(2, 2, figsize=(10, 10))

    # 关闭ticks的显示.
    for ax in axes.flat:
        ax.xaxis.set_visible(False)
        ax.yaxis.set_visible(False)

    # 显示二值化的图像.
    axes[0, 0].imshow(image, cmap=cmap1, interpolation='nearest')
    axes[0, 0].set_title('Image', fontsize='large')

    # 显示scipy.ndimage.label的结果.
    # 注意imshow中需要指定interpolation为'nearest'或'none',否则结果有紫边.
    s = np.ones((3, 3), dtype=int)
    labelled, nlabel = ndimage.label(image, structure=s)
    axes[0, 1].imshow(labelled, cmap=cmap2, interpolation='nearest')
    axes[0, 1].set_title(
        f'scipy.ndimage.label ({nlabel} labels)', fontsize='large'
    )

    # 显示Two-Pass算法的结果.
    labelled, nlabel = two_pass(image, diag=True)
    axes[1, 0].imshow(labelled, cmap=cmap2, interpolation='nearest')
    axes[1, 0].set_title(f'Two-Pass ({nlabel} labels)', fontsize='large')

    # 显示Seed-Filling算法的结果.
    labelled, nlabel = seed_filling(image, diag=True)
    axes[1, 1].imshow(labelled, cmap=cmap2, interpolation='nearest')
    axes[1, 1].set_title(f'Seed-Filling ({nlabel} labels)', fontsize='large')

    fig.savefig('image.png', dpi=200, bbox_inches='tight')
    plt.close(fig)

不只是邻接

虽然 scipy.ndimage.labelskimage.measure.label 要比手工实现更快,但它们都只支持 4 邻域和 8 邻域的连通规则,而手工实现还可以采用别的连通规则。例如,改动一下 seed_filling 中关于 offsets 的部分,使之能够表示以当前像素为原点,r 为半径的圆形邻域

offsets = []
for i in range(-r, r + 1):
    k = r - abs(i)
    for j in range(-k, k + 1):
        offsets.append((i, j))
offsets.remove((0, 0))  # 去掉原点.

在某些情况下也许能派上用场。

参考链接

网上很多教程抄了这篇,但里面 Two-Pass 算法的代码里不知道为什么没用并查集,可能会有问题。

OpenCV_连通区域分析(Connected Component Analysis-Labeling)

一篇英文的对 Two-Pass 算法的介绍,Github 上还带有 Python 实现。

Connected Component Labelling

代码参考了

你都用 Python 来做什么?laiyonghao 的回答

连通域的原理与Python实现