4. 图像分割:从像素到区域的实战拆解

图像分割,说白了就是把一张图里「谁是谁」给划清楚。做机器视觉这几年,我越来越觉得分割是承上启下的关键一步——前面预处理做得再好,分割一垮,后面识别、测量全白搭。

这一章咱们不绕弯子,直接看四种最常用的分割手段:阈值分割、边缘检测、区域生长、分水岭。每种我都会结合项目里的坑来讲。

本章核心脉络: 从「像素值」到「边缘」再到「区域」,逐步递进。
图像分割方法体系 图像分割 阈值分割 边缘检测 区域生长法 分水岭算法 OTSU 大津法 自适应阈值 Canny 边缘 Sobel 算子 Laplacian 算子 种子点选取 生长准则 距离变换 标记控制

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;
}
我的习惯: OTSU 适合直方图呈双峰分布的图像。如果直方图只有一个峰,OTSU 效果会打折扣,这时候我会考虑自适应阈值。

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 是二阶导数算子,对边缘的响应更锐利,但缺点也很明显——对噪声极度敏感。

避坑: 我曾经直接拿 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);
                }
            }
        }
    }
}
经验之谈: 区域生长慢在逐像素遍历。如果 ROI 很大,建议先用阈值或边缘检测缩小范围,再用区域生长做精细分割。

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 找边缘,再用边缘结果做分水岭的标记。组合起来往往比单一方法靠谱得多。