2. 传统分割方法:阈值分割(Otsu算法)、边缘检测(Canny)、区域生长法、分水岭算法
好,咱们进入第二章。这一章我打算聊聊那些“老派”但依然能打的分割方法。别觉得传统就过时了,我跟你讲,在很多工业场景下,这些方法比深度学习还靠谱——毕竟它们不需要海量标注数据,跑起来也快得多。
2.1 阈值分割与Otsu算法
阈值分割,说白了就是“一刀切”。你设定一个阈值,大于它的算一类,小于它的算另一类。最简单的情况就是灰度图转二值图。
但问题来了:阈值怎么选?手动调?我刚开始做项目时就这么干,结果换一张光照不同的图片就崩了。后来我学乖了——用Otsu算法自动算。
Otsu的核心思想其实挺朴素的:它遍历所有可能的阈值,找到那个能让前景和背景“类内方差最小、类间方差最大”的值。嗯,说白了就是让两坨像素各自抱团,别混在一起。
Otsu算法适用场景: 图像直方图呈现明显的双峰分布时效果最好。如果光照不均匀,建议先做预处理。
import cv2
import numpy as np
# 读取灰度图
img = cv2.imread('coin.jpg', 0)
# 直接用Otsu算阈值
ret, thresh = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print(f"Otsu算出来的阈值是: {ret}")
# 显示结果
cv2.imshow('Original', img)
cv2.imshow('Otsu', thresh)
cv2.waitKey(0)
我个人习惯在调用Otsu之前先做高斯模糊,去掉一些噪声点。你想想看,如果原图上有很多椒盐噪声,Otsu算出来的阈值可能会被带偏。
小技巧: 如果图像光照不均,试试自适应阈值(cv2.adaptiveThreshold),它会在局部窗口内分别算阈值,效果比全局Otsu好不少。
2.2 边缘检测:Canny算法
边缘检测,顾名思义就是找图像中像素值变化剧烈的地方。Canny算法是这一块的“扛把子”,直到今天很多项目还在用。
Canny的流程分四步:
- 去噪: 先用高斯滤波平滑图像,减少噪声干扰
- 算梯度: 用Sobel算子算每个像素的梯度幅值和方向
- 非极大值抑制: 只保留梯度方向上的局部最大值,把“胖”边缘变“瘦”
- 双阈值检测: 设定高低两个阈值,高阈值确定强边缘,低阈值连接弱边缘
我在项目中遇到过一个问题:Canny的两个阈值怎么设?设高了边缘断断续续,设低了噪声全进来了。后来我总结了一个经验——高阈值一般是低阈值的2到3倍,具体数值可以先用直方图看看梯度分布再定。
import cv2
img = cv2.imread('lena.jpg', 0)
# 高斯去噪
blurred = cv2.GaussianBlur(img, (5, 5), 1.0)
# Canny边缘检测
edges = cv2.Canny(blurred, threshold1=50, threshold2=150)
cv2.imshow('Edges', edges)
cv2.waitKey(0)
注意: Canny对噪声敏感,一定要先做平滑。我曾经偷懒没做这一步,结果边缘图里全是密密麻麻的假边缘,根本没法用。
2.3 区域生长法
区域生长法和阈值分割的思路不太一样。它不是“一刀切”,而是从一个种子点开始,像病毒扩散一样,把相邻的、像素值相近的区域“感染”成同一类。
具体步骤:
- 选一个或多个种子点(可以手动点,也可以自动选)
- 设定一个相似性判据(比如灰度差小于10)
- 从种子点出发,检查相邻像素,符合条件的就归入当前区域
- 重复直到没有新像素加入
区域生长的好处是能分割出连通区域,不会像阈值分割那样出现“孤岛”。但缺点也很明显——种子点选不好,结果天差地别。我记得有一次做医学图像分割,种子点点偏了一个像素,结果整个肺部分割成了气管,气得我...嗯,后来我改用自动种子点选取了。
import cv2
import numpy as np
def region_growing(img, seed, threshold=10):
h, w = img.shape
segmented = np.zeros((h, w), dtype=np.uint8)
seed_value = img[seed[1], seed[0]]
# 用队列做BFS
queue = [seed]
while queue:
x, y = queue.pop(0)
if segmented[y, x] == 1:
continue
# 检查4邻域
for dx, dy in [(1,0), (-1,0), (0,1), (0,-1)]:
nx, ny = x+dx, y+dy
if 0 <= nx < w and 0 <= ny < h and segmented[ny, nx] == 0:
if abs(int(img[ny, nx]) - int(seed_value)) <= threshold:
segmented[ny, nx] = 1
queue.append((nx, ny))
return segmented
# 使用示例
img = cv2.imread('brain.jpg', 0)
seed = (100, 100) # 手动选种子点
result = region_growing(img, seed, threshold=15)
cv2.imshow('Region Growing', result * 255)
cv2.waitKey(0)
避坑指南: 我曾经用区域生长法分割一张纹理丰富的图片,结果因为阈值设得太宽松,整个图像都被“感染”了。建议先用小阈值试跑,再逐步放宽。
2.4 分水岭算法
分水岭算法这个名字挺形象的。你把图像想象成一张地形图,像素值越低的地方是“山谷”,越高的地方是“山脊”。然后从山谷开始“注水”,水慢慢上涨,当两个不同区域的水快要汇合时,就在山脊上筑一道“堤坝”——这就是分割边界。
但直接应用分水岭算法容易过分割——因为噪声和微小纹理会产生太多“山谷”。我建议先做距离变换,找到真正的“种子区域”,再用分水岭。
import cv2
import numpy as np
# 读取并二值化
img = cv2.imread('coins.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
# 距离变换,找到种子点
dist = cv2.distanceTransform(thresh, cv2.DIST_L2, 5)
ret, markers = cv2.threshold(dist, 0.7 * dist.max(), 255, cv2.THRESH_BINARY)
markers = np.uint8(markers)
# 用连通组件标记种子
ret, markers = cv2.connectedComponents(markers)
# 应用分水岭
markers = cv2.watershed(img, markers)
img[markers == -1] = [0, 0, 255] # 边界标红
cv2.imshow('Watershed', img)
cv2.waitKey(0)
分水岭算法在分割粘连物体时特别好用——比如一堆硬币、细胞、或者颗粒。你想想看,如果用阈值分割,两个挨在一起的硬币会被当成一个整体。但分水岭能沿着它们之间的“山脊”把它们分开。
核心要点: 分水岭算法的关键在于种子点的选取。种子点选得好,分割结果就漂亮。我一般会结合距离变换和形态学操作来生成种子点,效果比手动点稳定得多。
2.5 四种方法对比
| 方法 | 核心思想 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
| Otsu阈值分割 | 自动找最佳阈值 | 简单、快速、无参数 | 对光照敏感,只适合双峰直方图 | 文档二值化、简单目标提取 |
| Canny边缘检测 | 梯度+双阈值 | 边缘定位准、连续性好 | 对噪声敏感,参数调优麻烦 | 物体轮廓提取、车道线检测 |
| 区域生长法 | 种子点扩散 | 能分割连通区域,结果直观 | 种子点依赖性强,速度慢 | 医学图像分割、特定目标提取 |
| 分水岭算法 | 地形注水+筑坝 | 能分割粘连物体 | 容易过分割,需要预处理 | 细胞分割、颗粒分析、硬币分离 |
好了,这一章的内容就到这里。四种传统分割方法各有各的脾气,没有哪个是万能的。我个人的建议是:先试试Otsu,不行就上Canny找边缘,再不行就用区域生长或分水岭。实际项目中,我经常把这几种方法组合着用——比如先用Canny找轮廓,再用分水岭细化分割。嗯,下一章咱们聊聊基于图论的分割方法,那个更有意思。