4. 图像分割:从像素到区域的实战拆解
图像分割,说白了就是把一张图里「谁是谁」给划清楚。做机器视觉这几年,我越来越觉得分割是承上启下的关键一步——前面预处理做得再好,分割一垮,后面识别、测量全白搭。
这一章咱们不绕弯子,直接看四种最常用的分割手段:阈值分割、边缘检测、区域生长、分水岭。每种我都会结合项目里的坑来讲。
4.1 阈值分割:最朴素但最实用
阈值分割的原理简单到离谱:设定一个值 T,像素大于 T 的归一类,小于 T 的归另一类。但你别小看它,在打光稳定的产线上,阈值分割往往是最快最稳的方案。
4.1.1 OTSU(大津法)
OTSU 自动算阈值,核心思想是让前景和背景的「类间方差」最大。说白了,就是找一个值把两边分得最开。
我记得有一次做 PCB 焊盘检测,光照每批次都有波动,固定阈值根本扛不住。换成 OTSU 后,阈值自动跟着亮度走,问题一下就解决了。
// C++ 示例:OTSU 阈值分割
#include <opencv2/opencv.hpp>
cv::Mat otsuSegmentation(const cv::Mat& src) {
cv::Mat gray, binary;
if (src.channels() == 3)
cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY);
else
gray = src.clone();
// OTSU 自动阈值
double thresh = cv::threshold(gray, binary, 0, 255,
cv::THRESH_BINARY | cv::THRESH_OTSU);
std::cout << "OTSU 计算阈值: " << thresh << std::endl;
return binary;
}
4.1.2 自适应阈值
光照不均匀怎么办?全局阈值肯定不行。自适应阈值把图像分成小块,每块单独算阈值。
我曾经做过一个手机屏幕划痕检测的项目,屏幕边缘有反光,中间正常。用全局阈值边缘全白,中间又看不清。换成自适应阈值后,每个局部自己算阈值,划痕一下就出来了。
cv::Mat adaptiveSegmentation(const cv::Mat& gray) {
cv::Mat binary;
// blockSize 必须是奇数,C 是常数偏移
cv::adaptiveThreshold(gray, binary, 255,
cv::ADAPTIVE_THRESH_GAUSSIAN_C,
cv::THRESH_BINARY, 11, 2);
return binary;
}
| 方法 | 适用场景 | 参数敏感度 | 速度 |
|---|---|---|---|
| OTSU | 光照均匀、双峰直方图 | 低 | 快 |
| 自适应阈值 | 光照不均匀、局部对比度变化 | 中(blockSize 和 C 值) | 中 |
| 固定阈值 | 打光极其稳定的产线 | 高(需要手动调) | 最快 |
4.2 边缘检测:找到物体的「轮廓」
边缘是图像中灰度变化剧烈的地方。做边缘检测这么多年,我最大的体会是:没有最好的算子,只有最合适的算子。
4.2.1 Sobel 算子
Sobel 是一阶导数算子,分别计算水平和垂直方向的梯度。它带一点平滑效果,对噪声不那么敏感。
我一般用 Sobel 做粗定位,比如找电路板上的焊盘大致位置。速度快,够用就行。
cv::Mat sobelEdge(const cv::Mat& gray) {
cv::Mat grad_x, grad_y, grad;
cv::Sobel(gray, grad_x, CV_16S, 1, 0, 3);
cv::Sobel(gray, grad_y, CV_16S, 0, 1, 3);
cv::convertScaleAbs(grad_x, grad_x);
cv::convertScaleAbs(grad_y, grad_y);
cv::addWeighted(grad_x, 0.5, grad_y, 0.5, 0, grad);
return grad;
}
4.2.2 Laplacian 算子
Laplacian 是二阶导数算子,对边缘的响应更锐利,但缺点也很明显——对噪声极度敏感。
4.2.3 Canny 边缘检测
Canny 是边缘检测里的「扛把子」。它做了四步:去噪、算梯度、非极大值抑制、双阈值连接。效果确实好,但参数调起来也最麻烦。
我个人习惯的调参顺序:先固定 low_threshold,再调 high_threshold,一般 high/low 比例在 2:1 到 3:1 之间。
cv::Mat cannyEdge(const cv::Mat& gray) {
cv::Mat edges;
// 先高斯滤波去噪
cv::Mat blurred;
cv::GaussianBlur(gray, blurred, cv::Size(5, 5), 1.5);
// Canny 双阈值
cv::Canny(blurred, edges, 50, 150);
return edges;
}
| 算子 | 阶数 | 噪声敏感度 | 边缘定位精度 | 典型应用 |
|---|---|---|---|---|
| Sobel | 一阶 | 低 | 中等 | 粗定位、方向检测 |
| Laplacian | 二阶 | 高 | 高(但易受干扰) | 细边缘、零交叉检测 |
| Canny | 一阶+优化 | 低(有去噪步骤) | 高 | 通用、高精度边缘提取 |
4.3 区域生长法:从种子到区域
区域生长的思路很直观:选一个种子点,把周围像素值和种子相近的像素「拉进来」,不断扩张直到满足停止条件。
做这个算法,最关键的是两件事:种子点选在哪,生长准则怎么定。
我记得有个项目要分割 CT 图像中的肿瘤区域。种子点由医生手动点选,生长准则用灰度差 + 距离约束。效果比纯阈值好很多,因为肿瘤边缘是渐变的,阈值根本切不干净。
// 伪代码:区域生长核心逻辑
void regionGrowing(const cv::Mat& src, cv::Mat& result,
cv::Point seed, int threshold) {
std::queue<cv::Point> points;
points.push(seed);
int seedValue = src.at<uchar>(seed);
while (!points.empty()) {
cv::Point p = points.front();
points.pop();
// 检查 4 邻域或 8 邻域
for (auto& neighbor : getNeighbors(p)) {
if (isInBounds(neighbor) && result.at<uchar>(neighbor) == 0) {
int diff = abs(src.at<uchar>(neighbor) - seedValue);
if (diff <= threshold) {
result.at<uchar>(neighbor) = 255;
points.push(neighbor);
}
}
}
}
}
4.4 分水岭算法:解决「粘连」问题
分水岭算法把图像看成地形图,灰度值高的地方是山脊,低的地方是山谷。往山谷里注水,水漫过山脊时不同区域就分开了。
但经典分水岭有个大问题——容易过分割。图像里一点点噪声就会产生一堆假山谷。
我做过一个细胞计数的项目,细胞经常粘在一起。用阈值分割根本分不开,用经典分水岭又过分割得一塌糊涂。后来用了「标记控制的分水岭算法」,先手动标出哪些是真正的细胞,哪些是背景,效果才稳定下来。
cv::Mat watershedSegmentation(const cv::Mat& gray) {
// 1. 距离变换
cv::Mat dist;
cv::distanceTransform(gray, dist, cv::DIST_L2, 3);
cv::normalize(dist, dist, 0, 1.0, cv::NORM_MINMAX);
// 2. 找局部极大值作为标记
cv::Mat markers;
cv::threshold(dist, markers, 0.5, 255, cv::THRESH_BINARY);
markers.convertTo(markers, CV_32S);
// 3. 应用分水岭
cv::Mat colorImg;
cv::cvtColor(gray, colorImg, cv::COLOR_GRAY2BGR);
cv::watershed(colorImg, markers);
return markers;
}
4.5 四种方法怎么选?
最后给个我自己的选择思路,你参考一下:
- 场景简单、光照稳定 → 阈值分割(OTSU 优先)
- 光照不均匀 → 自适应阈值
- 需要精确轮廓 → Canny 边缘检测
- 目标区域灰度渐变、边界模糊 → 区域生长法
- 目标粘连、需要分离 → 标记控制的分水岭
实际项目中,我经常把几种方法串起来用。比如先 Canny 找边缘,再用边缘结果做分水岭的标记。组合起来往往比单一方法靠谱得多。