数字图像处理-8-图像的阈值分割,连通区域标记,周长,面积计算(像素数量)

1.灰度图像二值化(后面算法很多基于二值图0-255)

由于后续很多算法都是用二值图来操作的,这里再介绍下如何二值化图像
它的原理很简单,循环查询灰度图的每个像素, 若灰度值 < 阈值T → 0(黑/前景),否则 → 255(白/背景)。注意这里,

  • 黑:前景
  • 白:背景

适用于前景背景灰度差异明显的图像

def binary_threshold(image, threshold):
    """
    参数:
      image     - shape=(H,W), dtype=uint8
      threshold - int, 阈值 [0,255]
    """
    # 这里使用了numpy库,下面表达的就是如果图像灰度值小于threshold,就把
    # 对应的灰度值置为0(前景),反之置为白(255)
    # 注意np.where内部会对image数组中每个元素进行遍历
    result = np.where(image < threshold, np.uint8(0), np.uint8(255))
    return result.astype(np.uint8) 

2.连通区域标记(两遍扫描法)

这里要注意,输入的是二值图像, 黑为前景,白为背景,

检测原理

  • 扫描顺序:从上到下、从左到右。
    对每个黑色像素(值=0),按优先级检查4个已访问邻居:
    1. 右上方
    2. 正上方
    3. 左上方
    4. 左方

在这里插入图片描述

  • 若找到已标记邻居 → 继承其标签
  • 若多个邻居有不同标签 → 合并(Union):将全图中旧标签替换为新标签
  • 若无已标记邻居 → 分配新标签

如上图所示,因为扫描顺序是自上而下,自左而右,所以当检测到上图中的黑像素时,它需要从map中获取到它邻居(上图高亮部分)的标签,然后把第一个标签的值赋给自己(MAP).

再举个例子,如下图是一个心形图像的原始图像,map数据,以及flag数据,由于是自上而下,自左而右的扫描顺序,再检测到1号箭头指向的像素时,算会依次查询右上,正上,左上,左边像素的map数据,并把非0的第一个赋给1号箭头指向像素对应的map区域,下图的flag区域是标记各个标签像素数量的,这里我们图像简单,前景黑色的像素flag的数量是16.
在这里插入图片描述

  • python代码
    两遍扫描法标记连通区域(8连通前景)
def connected_components(image):
    """
    参数:
      image - shape=(H,W), dtype=uint8 的二值图(0=前景,非0=背景)

    返回:
      (label_map, num_labels, flag)
        label_map  - shape=(H,W), dtype=np.int32, 0=背景,1~N=各区域
        num_labels - 实际连通区域数
        flag       - 各标签的像素计数数组(索引=标签号)
    """
    H, W = image.shape
    # 标签图:0=背景,正整数=前景区域标签
    label_map = np.zeros((H, W), dtype=np.int32)
    flag = np.zeros(256, dtype=np.int32)  # flag[k] = 标签k的像素数
    x_sign = 0  # 已分配的最大标签号

    for j in range(1, H - 1):
        for i in range(1, W - 1):
            if image[j, i] != 0:
                continue  # 跳过背景像素,这里前面已经说了,0是前景,其它非0背景

            # 按优先级检查4个已访问邻居
            # 邻居必须是前景(黑=0)且已分配标签(label>0)
            def get_label(rr, cc):
                if image[rr, cc] == 0 and label_map[rr, cc] > 0:
                    return label_map[rr, cc]
                return 0

            lbl_ru = get_label(j - 1, i + 1) if i + 1 < W else 0
            lbl_u  = get_label(j - 1, i)
            lbl_lu = get_label(j - 1, i - 1) if i - 1 >= 0 else 0
            lbl_l  = get_label(j, i - 1) if i - 1 >= 0 else 0

            # 收集非零邻居标签
            nbrs = [lbl for lbl in [lbl_ru, lbl_u, lbl_lu, lbl_l] if lbl > 0]

            if nbrs:
                # 取第一个(优先级最高)邻居的标签
                x_temp = nbrs[0]
                label_map[j, i] = x_temp
                flag[x_temp] += 1
                # 合并所有其他不同标签到x_temp
                for y_temp in nbrs[1:]:
                    if y_temp != x_temp:
                        _merge_labels(label_map, flag, y_temp, x_temp)
            else:
                # 无已访问邻居 → 分配新标签
                x_sign += 1
                if x_sign > 250:
                    raise RuntimeError("连通区域数目太多(>250),请提高二值化阈值以减少目标数")
                label_map[j, i] = x_sign
                flag[x_sign] = 1

    # 统计有效连通区域数(flag非零的标签数)
    num_labels = int(np.sum(flag[1:x_sign + 1] > 0))
    return label_map, x_sign, flag

def _merge_labels(label_map, flag, old_label, new_label):
    """将label_map中所有old_label替换为new_label并更新flag。"""
    if old_label == new_label or old_label == 0:
        return
    old_count = flag[old_label]
    flag[old_label] = 0
    flag[new_label] += old_count
    label_map[label_map == old_label] = new_label	
  • 效果图
    通过上面的算法,可以得到连通区域标记的数据, 连通数量,以及flag(各个连通区域对应像素的数量. 我这里本地做了后处理,根据map的信息,把各个连通区域和背景用了不同颜色标记了一下,便于区分.
    在这里插入图片描述

3.测量连通区域面积和重心

其实这一步关键还在于连通区域的识别,下面代码没有太多逻辑,这里就不赘述.

  • python代码
def measure_objects(image):
    """
    标记连通区域并测量各区域面积(像素数)和重心坐标。

    返回:
      (objects, label_map)
        objects   - list of dict: {'label', 'area', 'cx'(列), 'cy'(行)}
        label_map - 标签图
    """
    label_map, x_sign, flag = connected_components(image)
    objects = []

    for t in range(1, x_sign + 1):
        if flag[t] == 0:
            continue
        positions = np.argwhere(label_map == t)
        if len(positions) == 0:
            continue
        # 下面计算算术平均值,就当作图像的中心坐标了.
        cy = int(np.mean(positions[:, 0]))  # 行均值
        cx = int(np.mean(positions[:, 1]))  # 列均值
        objects.append({
            'label': t,
            'area':  int(flag[t]),
            'cx':    cx,
            'cy':    cy,
        })

    return objects, label_map
  • 效果图
    下面是脚本运行时打印出来的日志,他根据flag查找到各个连通区域的像素数量当作面积, 重心则使用区域内像素的x,y的平均值.针对规则的图像这样做问题不大,对复杂的图像以及密度不同的对象就要从多方考虑了.

[连通区域标记]
发现 5 个连通区域
区域 1: 面积= 10704 px, 重心=(88,89)
区域 2: 面积= 3068 px, 重心=(208,98)
区域 3: 面积= 2759 px, 重心=(119,183)
区域 9: 面积= 99 px, 重心=(30,173)
区域 11: 面积= 268 px, 重心=(194,191)
在这里插入图片描述

4.删除面积小于阈值的目标

这里重点还是先做连通区域标记,找到每个区域的像素计数(即面积)。若面积 < min_area,将该区域的所有像素置为白(背景)。

# 测试时:min_area:100
def remove_small_objects(image, min_area):
    """
    删除面积(像素数)小于 min_area 的连通区域。
    参数:
      min_area - int, 面积下限(小于此值的目标被删除)
    """
    label_map, x_sign, flag = connected_components(image)
    result = image.copy()
    for t in range(1, x_sign + 1):
    	# 这里测试,我设置的最小阈值为100, 所以从flag区域找出各个区域像素数量
    	# 小于100的,背景置为白色,如下图所示
        if 0 < flag[t] < min_area:
            result[label_map == t] = 255  # 置为白(背景)
    return result
  • 效果图
    如下图中,重心坐标(30,173)地方对应的连通图,像素为99,**小于阈值100,**则在图中被抹除了.

区域 1: 面积= 10704 px, 重心=(88,89)
区域 2: 面积= 3068 px, 重心=(208,98)
区域 3: 面积= 2759 px, 重心=(119,183)
区域 9: 面积= 99 px, 重心=(30,173)
区域 11: 面积= 268 px, 重心=(194,191)
在这里插入图片描述

5.轮廓跟踪并测量周长(Moore邻域法)

对每个连通区域进行Moore邻域轮廓跟踪,测量边界周长(像素数)。

检测原理

  • 对每个连通区域t:
    1. 找到第一个属于t的像素作为起始点P0
    2. 初始扫描方向 BeginDirect=0(西北方向)
    3. 按8方向循环检查邻居:若找到属于t的邻居Pn → 记录Pn为轮廓点
    再将BeginDirect逆时针旋转2步(确保不遗漏轮廓像素)否则 → BeginDirect顺时针旋转1步
    4. 重复直到回到起始点P0
    最终统计各区域的轮廓像素数即为周长。

  • 8方向顺序(从NW开始,逆时针):
    NW(-1,-1), N(0,-1), NE(1,-1), E(1,0), SE(1,1), S(0,1), SW(-1,1), W(-1,0)
    其中 (dc, dr) = (列变化, 行变化)

  • python代码

def measure_perimeter(image):
    """
    返回:
      (perimeters, contour_img)
        perimeters  - list of {'label', 'perimeter'}
        contour_img - shape=(H,W), 仅含轮廓像素(各区域编号,背景=255)
    """
    label_map, x_sign, flag = connected_components(image)
    H, W = image.shape
    contour_img = np.full((H, W), 255, dtype=np.uint8)

    # 8方向 (dc, dr):dc=列变化,dr=行变化
    # 从NW开始逆时针排列(与C++的BMP坐标系等价)
    DIR = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)]

    perimeters = []

    for t in range(1, x_sign + 1):
        if flag[t] == 0:
            continue

        # 找起始点:从上到下、从左到右第一个属于t的像素
        positions = np.argwhere(label_map == t)
        if len(positions) == 0:
            continue
        # 取行最小(最上),再取列最小(最左)
        start_r = positions[np.argmin(positions[:, 0] * W + positions[:, 1]), 0]
        start_c = positions[np.argmin(positions[:, 0] * W + positions[:, 1]), 1]

        contour_img[start_r, start_c] = t

        cur_r, cur_c = int(start_r), int(start_c)
        begin_dir = 0
        back_to_start = False

        while not back_to_start:
            found_next = False
            iter_count = 0
            while not found_next:
                dc, dr = DIR[begin_dir]
                nr, nc = cur_r + dr, cur_c + dc
                if 0 <= nr < H and 0 <= nc < W and label_map[nr, nc] == t:
                    found_next = True
                    cur_r, cur_c = nr, nc
                    if cur_r == int(start_r) and cur_c == int(start_c):
                        back_to_start = True
                    contour_img[cur_r, cur_c] = t
                    # 逆时针退两步(Moore跟踪的核心:确保不绕圈跳过边界点)
                    begin_dir = (begin_dir - 2) % 8
                else:
                    # 顺时针转一步继续搜索
                    begin_dir = (begin_dir + 1) % 8
                iter_count += 1
                if iter_count > 8:
                    break  # 安全退出(孤立点保护)

        peri = int(np.sum(contour_img == t))
        perimeters.append({'label': t, 'perimeter': peri})

    return perimeters, contour_img
  • 效果图

发现 5 个区域
区域 1: 周长= 360 px
区域 2: 周长= 218 px
区域 3: 周长= 168 px
区域 9: 周长= 36 px
区域 11: 周长= 52 px
在这里插入图片描述

6.轮廓提取(去除内部像素法)

本方法适用域二值图像,所以在处理前需要将图像转换为二值图像)

  • 检测原理:
    对每个黑色像素(0),统计其8个邻居中黑色像素的数量。
    若8个邻居全为黑(该像素完全被黑色包围)→ 是内部像素 → 置为白(255)。
    保留的黑色像素即为边界像素(至少有一个白邻居)。

  • 与上面轮廓跟踪的区别:
    - 本方法保留所有边界像素,轮廓可能有多个像素宽
    - Moore跟踪法只跟踪一条1像素宽的轮廓线

  • python代码

def contour_extraction(image):
    """
    通过去除内部像素来提取目标轮廓。
    实现:用8邻域和判断(numpy滑动窗口)
    """
    # 拷贝一份原图像, 对图像阈值分割,灰度大于127的置白,小于等于127的置黑
    binary = image.copy()
    binary[binary > 127] = 255
    binary[binary <= 127] = 0

    H, W = binary.shape
    # 统计8个邻居的像素值之和(不含自身)
    padded = np.pad(binary.astype(np.int32), 1, constant_values=255)
    nbr_sum = np.zeros((H, W), dtype=np.int32)
    for dr in range(-1, 2):
        for dc in range(-1, 2):
            if dr == 0 and dc == 0:
                continue  # 跳过自身
            nbr_sum += padded[1 + dr:H + 1 + dr, 1 + dc:W + 1 + dc]

    result = np.full((H, W), 255, dtype=np.uint8)
    # 内部点:自身为黑(0) 且 8邻居全为黑(sum=0)→ 置白
    interior = (binary == 0) & (nbr_sum == 0)
    # 边界点:自身为黑(0) 且 至少有一个白邻居(sum>0)→ 保留为黑
    boundary = (binary == 0) & (nbr_sum > 0)
    result[boundary] = 0
    # interior保持白(255)
    return result
  • 效果图
    在这里插入图片描述

7.半阈值分割和对称带通阈值分割

主要区别

  • 半阈值分割: (pixel - threshold) < band_width -> 保留灰度值,否则->置白(255)
  • 对称带通分割 : |pixel - threshold| < band_width → 将该像素置为threshold(标记)否则 → 置白(255)

7.1半阈值分割

保留灰度值小于 threshold + band_width 的像素(低灰度区域保留)。其余置白(255)。用于提取图像中较暗的区域。

  • python代码
# threshold: 128
def band_threshold(image, threshold, band_width=30):
    """
    单侧带通滤波分割:保留灰度值满足 (pixel - threshold) < band_width 的像素。
    参数:
      threshold  - int, 基准阈值
      band_width - int, 带宽(默认30)
    """
    diff = image.astype(np.int32) - threshold
    result = np.where(diff < band_width, image, np.uint8(255))
    return result.astype(np.uint8)

7.2对称带通分割

若 |pixel - threshold| < band_width → 将该像素置为threshold(标记)否则 → 置白(255)。
适用于提取特定灰度范围内的目标(如某种颜色区域或纹理)。

def band_threshold_symmetric(image, threshold, band_width=80):
    """
    对称带通分割:保留与阈值灰度差 < band_width 的像素。
    参数:
      threshold  - int, 目标灰度中心值
      band_width - int, 带宽(默认30)
    """
    diff = np.abs(image.astype(np.int32) - threshold)
    result = np.where(diff < band_width, np.uint8(threshold), np.uint8(255))
    return result.astype(np.uint8)
  • 效果图
    1.半阈值分割: threshold:128, band_width:30
    这里我用颜色选取器得到原始图像背景灰度值为192, 那么1(92-128) = 64,不满足小于30,所以背景被置白了.
    2.对称带通分割: threshold:128, band_width:80 ,|192-128|=64 < 80, 则将背景置为128,所以看起来是灰色的.

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值