图像分割:阈值分割、区域生长、分水岭算法、GrabCut

图像分割,说白了就是把一张图里你感兴趣的东西「抠」出来。我刚开始做视觉的时候,觉得这玩意儿不就是画个框吗?后来才发现,真正要把目标从复杂背景里干净地分离出来,门道多着呢。

今天咱们聊四种经典方法:阈值分割、区域生长、分水岭算法、GrabCut。它们各有各的脾气,也各有各的适用场景。

一、阈值分割:最简单,也最看运气

阈值分割的原理一句话就能讲清楚:设定一个灰度值T,大于T的算一类,小于T的算另一类。嗯,就这么简单。

但问题来了——T怎么选?

我见过不少新手直接拍脑袋定个127,结果换张图就崩了。你想想看,光照一变,灰度分布全变了,固定阈值肯定不行。

所以实际项目中,我几乎不用固定阈值。常用的方法是:

  • Otsu(大津法):自动找最佳阈值,基于类间方差最大化的原理。说白了就是让前景和背景的差异最大。
  • 自适应阈值:把图像分成小块,每块单独算阈值。适合光照不均匀的场景。

核心要点:阈值分割只适合「目标与背景灰度差异明显」的情况。如果目标内部灰度变化大,或者背景复杂,效果会很差。

import cv2
import numpy as np

# 读取灰度图
img = cv2.imread('example.jpg', 0)

# Otsu自动阈值
_, thresh_otsu = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

# 自适应阈值
thresh_adaptive = cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                                        cv2.THRESH_BINARY, 11, 2)

我曾经踩过的坑:有一次做工业零件检测,零件表面有反光,Otsu死活分不开。后来发现是光照角度的问题,调整光源后阈值分割就稳了。所以记住——预处理比算法本身更重要。

二、区域生长:从种子开始「长」出目标

区域生长的思路很直观:你先选一个像素点作为「种子」,然后看它周围的像素,如果和种子相似(灰度差小于阈值),就合并进来。不断重复,直到没有新像素加入。

这个方法的好处是:能处理灰度不均匀的目标。坏处是:种子点要手动选,而且对噪声敏感。

我个人习惯在医学图像分割里用区域生长。比如CT图像里的肿瘤区域,灰度相对均匀,手动点一下种子,效果还不错。

def region_growing(img, seed, threshold=10):
    h, w = img.shape
    segmented = np.zeros((h, w), dtype=np.uint8)
    seed_value = img[seed[0], seed[1]]
    
    # 用队列做BFS
    queue = [seed]
    segmented[seed[0], seed[1]] = 255
    
    while queue:
        x, y = queue.pop(0)
        # 检查4邻域
        for dx, dy in [(-1,0), (1,0), (0,-1), (0,1)]:
            nx, ny = x + dx, y + dy
            if 0 <= nx < h and 0 <= ny < w and segmented[nx, ny] == 0:
                if abs(int(img[nx, ny]) - int(seed_value)) < threshold:
                    segmented[nx, ny] = 255
                    queue.append((nx, ny))
    return segmented

小技巧:种子点不要选在边缘区域,否则容易「长歪」。我一般会先做一次高斯模糊降噪,再跑区域生长,效果会稳定很多。

三、分水岭算法:把图像看成地形图

分水岭算法的思路很有意思——把灰度图想象成一张地形图,灰度值高的地方是「山脊」,低的地方是「山谷」。然后从最低点开始「注水」,水慢慢上涨,当两个不同区域的水要汇合时,就筑一道「坝」。最终这些「坝」就是分割边界。

听起来很酷对吧?但实际用起来有个大问题——过分割。因为图像里噪声和细节太多,会产生无数个小「山谷」,结果分割出几百个区域。

怎么解决?我常用的方法是:先做距离变换,找到确定的前景区域,然后用这些区域作为标记(marker),再跑分水岭。这样就能避免过分割。

import cv2
import numpy as np

# 二值化
_, thresh = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

# 距离变换
dist = cv2.distanceTransform(thresh, cv2.DIST_L2, 5)
_, markers = cv2.threshold(dist, 0.5 * dist.max(), 255, cv2.THRESH_BINARY)
markers = cv2.connectedComponents(np.uint8(markers))[1]

# 分水岭
markers = cv2.watershed(cv2.cvtColor(img, cv2.COLOR_GRAY2BGR), markers)
img[markers == -1] = [0, 0, 255]  # 边界标红

注意:分水岭算法对噪声非常敏感。我建议在预处理阶段多做几步:中值滤波去噪、形态学操作(开闭运算)清理小噪点。否则分水岭跑出来的结果会让你怀疑人生。

四、GrabCut:交互式分割的扛把子

GrabCut 是微软研究院提出的算法,本质上是图割(Graph Cut)的改进版。你只需要画一个矩形框住目标,算法会自动迭代优化,把目标抠出来。

它的核心思想是:把图像建模成高斯混合模型(GMM),然后通过最小化能量函数来分割。说白了,就是让「前景像素」和「背景像素」的分布差异最大化。

我在做图像编辑工具的时候用过 GrabCut。用户只需要框一下,就能得到不错的抠图结果。但要注意——如果目标和背景颜色相近,效果会打折扣。

import cv2
import numpy as np

img = cv2.imread('photo.jpg')
mask = np.zeros(img.shape[:2], np.uint8)

# 初始化GMM
bgd_model = np.zeros((1, 65), np.float64)
fgd_model = np.zeros((1, 65), np.float64)

# 矩形框(x, y, w, h)
rect = (50, 50, 300, 400)

# 运行GrabCut
cv2.grabCut(img, mask, rect, bgd_model, fgd_model, 5, cv2.GC_INIT_WITH_RECT)

# 提取前景
mask2 = np.where((mask == 2) | (mask == 0), 0, 1).astype('uint8')
result = img * mask2[:, :, np.newaxis]

我的经验:如果矩形框得不准,GrabCut 可能会把背景也当成前景。我一般会让用户先框一个「紧贴目标」的矩形,然后跑一次迭代。如果效果不好,再让用户手动标记一些前景/背景的笔触(用 GC_INIT_WITH_MASK 模式),这样精度会高很多。

五、四种方法对比

方法 适用场景 优点 缺点
阈值分割 目标与背景灰度差异大 速度快、实现简单 对光照和噪声敏感
区域生长 目标内部灰度均匀 能处理不规则形状 需要手动选种子、对噪声敏感
分水岭算法 目标粘连、边缘模糊 能分割出精细边界 容易过分割、需要预处理
GrabCut 交互式抠图、复杂背景 精度高、用户交互少 计算量大、对颜色相近区域效果差

最后说一句:没有万能的分割算法。我面试的时候经常问候选人:「给你一张图,你怎么选分割方法?」其实我想听的答案不是「我会用XXX算法」,而是「我会先分析图像特点,再选合适的方法」。嗯,这才是工程思维。