4. 图像分割与形态学

图像分割和形态学处理,说白了就是把图像里「谁是谁」给分清楚,再把分出来的东西「修整」到能用。我做了这么多年嵌入式视觉,这两块几乎是每个项目都绕不开的基础操作。今天咱们就聊聊阈值分割、分水岭、形态学操作和轮廓查找——这些工具用好了,很多看似复杂的问题其实几行代码就能搞定。

4.1 阈值分割:Otsu 算法的妙用

阈值分割是最直观的分割方法。你设定一个值,像素高于它算一类,低于它算另一类。但问题来了——这个阈值怎么定?

我早期做项目时,经常手动调阈值。今天光线好,阈值设 128;明天阴天,就得改到 100。后来用了 Otsu 算法,才算是解放了双手。

Otsu 的核心思想:它自动计算一个阈值,让分割后的两类像素「类内方差最小、类间方差最大」。说白了,就是让前景和背景各自内部尽可能均匀,两者之间差异尽可能大。

关键公式(理解即可):

类间方差 σ² = ω₁ω₂(μ₁ - μ₂)²

其中 ω₁、ω₂ 是两类像素占比,μ₁、μ₂ 是两类平均灰度。Otsu 遍历所有可能阈值,找到使 σ² 最大的那个。

// C++ 示例:Otsu 阈值分割
cv::Mat src = cv::imread("defect.jpg", cv::IMREAD_GRAYSCALE);
cv::Mat binary;
double thresh = cv::threshold(src, binary, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU);
std::cout << "Otsu 自动阈值: " << thresh << std::endl;

我的经验:Otsu 对直方图呈双峰的图像效果极好。但如果光照不均,直方图只有一个峰,Otsu 也会翻车。这时候我会先做直方图均衡化,或者改用自适应阈值。

4.2 分水岭算法:处理粘连物体的利器

阈值分割只能做全局二值化,遇到物体互相粘连就抓瞎了。比如显微镜下的细胞、生产线上的零件——它们挨在一起,阈值分割根本分不开。

分水岭算法就是来解决这个问题的。它的思路很形象:把图像看成地形图,灰度值高的地方是山脊,低的地方是山谷。然后从每个局部最低点「注水」,水漫过山脊时,不同区域就自然分开了。

但直接用的效果往往不好——噪声会导致过度分割,出现密密麻麻的小区域。我建议的做法是:

  1. 先做距离变换,找到每个物体的「中心」
  2. 用这些中心作为种子点
  3. 再运行分水岭算法
// C++ 示例:基于距离变换的分水岭分割
cv::Mat dist;
cv::distanceTransform(binary, dist, cv::DIST_L2, 5);
cv::normalize(dist, dist, 0, 1.0, cv::NORM_MINMAX);

// 找到局部最大值作为种子
cv::Mat markers;
cv::findContours(binary, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);
markers = cv::Mat::zeros(src.size(), CV_32S);
for (size_t i = 0; i < contours.size(); i++) {
    cv::drawContours(markers, contours, static_cast<int>(i), 
                     cv::Scalar(static_cast<int>(i)+1), -1);
}
cv::watershed(src, markers);

避坑指南:我曾经在一个芯片引脚检测项目里直接套用分水岭,结果过度分割得一塌糊涂。后来发现是二值化阈值没调好,导致噪声被当成了独立区域。记住:分水岭的效果高度依赖预处理质量。

4.3 形态学操作:腐蚀、膨胀、开闭运算

二值化之后,图像里往往有噪点、断线、毛刺。形态学操作就是用来「修图」的。我把它比作雕刻——腐蚀是削掉一层,膨胀是补上一层。

操作 效果 典型应用场景
腐蚀 消除细小凸起,断开窄连接 去除噪点、分离粘连物体
膨胀 填补细小凹陷,连接断口 修复断裂、填充孔洞
开运算(先腐蚀后膨胀) 去除小噪点,保持大物体形状 去噪、平滑轮廓
闭运算(先膨胀后腐蚀) 填补小孔洞,连接邻近物体 填充裂缝、连接断线
// C++ 示例:形态学操作
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));

cv::Mat eroded, dilated, opened, closed;
cv::erode(binary, eroded, kernel);
cv::dilate(binary, dilated, kernel);
cv::morphologyEx(binary, opened, cv::MORPH_OPEN, kernel);
cv::morphologyEx(binary, closed, cv::MORPH_CLOSE, kernel);

核大小的选择:我一般从 3×3 开始试。核太小效果不明显,核太大会把目标本身也抹掉。对于嵌入式平台,3×3 核的运算量也最可控。

4.4 轮廓查找与绘制

形态学处理完,图像干净了,接下来就是提取目标物体的轮廓。轮廓是后续测量、识别、跟踪的基础。

轮廓查找的核心函数是 cv::findContours。它有两个关键参数:

  • 检索模式:RETR_EXTERNAL 只找最外层轮廓,RETR_TREE 找所有层级
  • 近似方法:CHAIN_APPROX_SIMPLE 压缩水平/垂直/对角线方向,只保留端点
// C++ 示例:轮廓查找与绘制
std::vector<std::vector<cv::Point>> contours;
std::vector<cv::Vec4i> hierarchy;
cv::findContours(binary, contours, hierarchy, 
                 cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

// 绘制所有轮廓
cv::Mat result = cv::Mat::zeros(src.size(), CV_8UC3);
for (size_t i = 0; i < contours.size(); i++) {
    cv::Scalar color = cv::Scalar(0, 255, 0);
    cv::drawContours(result, contours, (int)i, color, 2);
    
    // 计算轮廓面积和周长
    double area = cv::contourArea(contours[i]);
    double perimeter = cv::arcLength(contours[i], true);
    std::cout << "轮廓 " << i << ": 面积=" << area 
              << ", 周长=" << perimeter << std::endl;
}

我曾经踩过的坑:findContours 会修改输入图像!如果你后续还需要原二值图,记得先 clone() 一份。另外,轮廓的坐标是整数,做亚像素精度测量时要注意。

4.5 本章知识体系

下面这张图把本章的核心流程串起来了。你可以把它当作一个处理流水线:

图像分割与形态学处理流程 输入灰度图像 Otsu 阈值分割 形态学操作 腐蚀/膨胀/开闭运算 分水岭分割 距离变换+种子点 轮廓查找与绘制 输出轮廓/测量结果 根据场景选择形态学或分水岭 两者可组合使用

4.6 实战建议与避坑总结

最后,我把这些年做图像分割项目的一些心得整理成清单,希望能帮你少走弯路:

处理流程建议:

  1. 先做灰度化和去噪(高斯滤波或中值滤波)
  2. 用 Otsu 做全局阈值分割,观察效果
  3. 如果光照不均,改用自适应阈值
  4. 用开运算去除噪点,闭运算填充孔洞
  5. 物体粘连时,用分水岭算法分离
  6. 最后用 findContours 提取轮廓并计算特征

性能优化小技巧:在嵌入式平台上,形态学操作可以用查表法加速。3×3 核的腐蚀/膨胀,每个像素只依赖周围 8 个邻居,完全可以预先计算好所有 512 种组合的结果。

我曾经犯过的错:在一个工业检测项目里,我直接用 Otsu 分割金属表面的划痕。结果因为反光,划痕区域和背景的灰度值几乎一样,Otsu 完全失效。后来改用局部对比度增强 + 自适应阈值才搞定。记住:没有万能算法,理解你的图像特性比调参更重要。