news 2026/9/7 16:37:36

OpenCV 分水岭图像分割详解:基于标记的 Watershed 算法与距离变换实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OpenCV 分水岭图像分割详解:基于标记的 Watershed 算法与距离变换实战

OpenCV 分水岭图像分割详解:基于标记的 Watershed 算法与距离变换实战

【免费下载链接】opencvOpen Source Computer Vision Library项目地址: https://gitcode.com/GitHub_Trending/opencv31/opencv

本文围绕 OpenCV 官方 JS 教程中的分水岭图像分割(Watershed)主题展开:先理解"地形图"直觉与基于标记的改进思路,再逐步拆解 Otsu 阈值、形态学开闭运算、距离变换、连通域标记与cv.watershed()五步流水线,并结合仓库中的 C++ 参考实现(imageSegmentation.cpp)、WebAssembly 交互示例(js_watershed_watershed.html)与回归测试(test_watershed.cpp)源码,讲解每个参数取值依据和底层实现原理。读完后,你将掌握分离相互接触物体(如堆叠硬币)的完整可运行方案,并能读懂watershed()的优先级队列式扫描实现。

1. 目标与理论:从地形直觉到基于标记的改进

官方教程(js_watershed.markdown)设定的学习目标是:学会使用基于标记(marker-based)的分水岭算法做图像分割,核心 API 为cv.watershed()

经典分水岭的地形直觉:任意灰度图像都可以看作一幅地形图——高亮度是山峰与山脊,低亮度是山谷。从每个孤立山谷(局部极小值)开始注入不同颜色的水(即不同的标签),随着水位上升,相邻山谷的水会因附近山峰(灰度梯度)的形状而汇合;为了避免不同颜色的水混合,在汇合处修建屏障。持续注水与筑坝直到所有山峰被淹没,这些屏障就构成最终的分割结果。

为什么必须引入标记:纯粹的地形方法会因图像噪声或不规则纹理产生过度分割(over-segmentation)。因此 OpenCV 实现了 marker-based watershed:由调用者显式指定哪些"谷点"应当合并、哪些不该合并,使其成为一种交互式分割。具体操作是:

  • 确信是前景/物体的区域,赋予一个颜色(或灰度)标签;
  • 确信是背景/非物体的区域,赋予另一个标签;
  • 不确定的区域,标为0——这就是标记(marker)。

随后应用分水岭算法:marker 图会被原地更新为我们赋予的标签,而物体边界处的像素值变为 -1。这一"用 0 表示未知、用 -1 输出边界"的约定,在下面的 JS 示例源码中可以直接验证:js_watershed_watershed.html 中unknown区域对应的 marker 被置 0,算法运行后markers.intPtr(i, j)[0] == -1的像素被涂红以显示"屏障"。

2. 五步分割流水线:以接触硬币为例

教程选取的经典场景是相互接触的硬币图像(见文首 coins.jpg):硬币彼此相贴,即使直接做阈值化,轮廓仍然粘连在一起。完整流程为:Otsu 二值化 → 形态学开/闭运算 → 提取"确定前景"与"确定背景" → 距离变换 + 阈值 → 构造 marker 并运行 watershed

2.1 第一步:Otsu 二值化获得粗略前景

首先找到硬币的近似轮廓,教程给出的手段是 Otsu 自动阈值化。JS 交互示例(js_watershed_threshold.html)中的实际代码为:

let src = cv.imread('canvasInput'); // gray and threshold image cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0); cv.threshold(gray, gray, 0, 255, cv.THRESH_BINARY_INV + cv.THRESH_OTSU);

注意这里使用了THRESH_BINARY_INV:硬币是深色、背景是浅色,反色后前景(硬币)变为白色,便于后续形态学与距离变换以"白色前景"为前提工作。C++ 侧的参考实现 imageSegmentation.cpp 在另一场景(白底卡牌)中则使用正向 Otsu:

Mat bw; cvtColor(imgResult, bw, COLOR_BGR2GRAY); threshold(bw, bw, 40, 255, THRESH_BINARY | THRESH_OTSU);

两种方向的选择只取决于"前景该是白还是黑",核心都是让distanceTransform输入一个 8 位单通道二值图。

2.2 第二步:形态学开闭运算,得到确定前景与确定背景

教程原文指出:阈值化图像中会有小的白色噪声,用**形态学开运算(opening)去除;物体内部的小孔用闭运算(closing)**填充。之后,"靠近物体中心的区域一定是前景,远离物体的区域一定是背景,唯一不确定的只有硬币的边界区域"。

JS 示例中的对应实现:

// get background let M = cv.Mat.ones(3, 3, cv.CV_8U); cv.erode(gray, gray, M); // opening = 先腐蚀 cv.dilate(gray, opening, M); // 再膨胀,即开运算去噪 cv.dilate(opening, coinsBg, M, new cv.Point(-1, -1), 3); // 膨胀 3 次 -> 确定背景

逻辑解读:

  • 确定前景:腐蚀(erode)会剥离边界像素,剩下的像素必然是硬币内部;但腐蚀只能解决"物体不接触"的情形。由于硬币相互接触,教程给出的更优方案是距离变换 + 合适阈值(见 2.4 节)。
  • 确定背景:将开运算结果膨胀若干次(示例中 3 次),使物体边界向外扩展,从而确保膨胀结果的背景区域"真的是背景"——因为边界的不确定区域已被吞入前景侧。

2.3 第三步:未知区域(border)= 确定背景 − 确定前景

剩余区域是无法判断属于硬币还是背景的部分,通常位于硬币边界、前景与背景(或两枚硬币)的交界处,教程称之为border。它可以通过确定背景区域减去确定前景区域得到,示例代码为:

cv.subtract(coinsBg, coinsFg, unknown);

2.4 第四步:距离变换提取确定前景

教程在此处给出 API 原型及参数说明,完整继承如下:

cv.distanceTransform(src, dst, distanceType, maskSize, labelType = cv.CV_32F)

参数说明
src8 位单通道(二值)源图像
dst输出距离图像,8 位或 32 位浮点单通道,与src同尺寸
distanceType距离类型(见cv.DistanceTypes,如DIST_L1/DIST_L2
maskSize距离变换核尺寸(见cv.DistanceTransformMasks
labelType输出图像类型,可为cv.CV_8Ucv.CV_32FCV_8U仅可用于函数第一种变体且distanceType == DIST_L1

交互示例(js_watershed_distanceTransform.html)中的实际调用与阈值:

// distance transform cv.distanceTransform(opening, distTrans, cv.DIST_L2, 5); cv.normalize(distTrans, distTrans, 1, 0, cv.NORM_INF); // get foreground cv.threshold(distTrans, coinsFg, 0.7 * 1, 255, cv.THRESH_BINARY); coinsFg.convertTo(coinsFg, cv.CV_8U, 1, 0);

参数取值依据:DIST_L2使用欧氏距离,maskSize = 5是精度与速度的常用折中;距离图经normalize[0, 1]后以0.7为阈值,即可把每个硬币中心的"山丘"单独切出来——相互接触的多枚硬币在距离图中被峰谷分开,阈值化后变成彼此分离的前景斑点,这正是距离变换对 watershed 最关键的价值。

教程还特别提醒:如果只做前景分割(不分离接触物体),则不需要距离变换,腐蚀就够了——腐蚀只是提取确定前景区域的另一种手段。C++ 示例 imageSegmentation.cpp 也印证了同样的套路:

// Perform the distance transform algorithm Mat dist; distanceTransform(bw, dist, DIST_L2, 3); // Normalize the distance image for range = {0.0, 1.0} normalize(dist, dist, 0, 1.0, NORM_MINMAX); // Threshold to obtain the peaks // This will be the markers for the foreground objects threshold(dist, dist, 0.4, 1.0, THRESH_BINARY);

注意两个实现阈值不同(JS 用 0.7、C++ 用 0.4):阈值本质上决定前景斑点的大小与数量,0.7 只保留最深处核心(分离效果更强、但对薄物体易丢失),0.4 保留更大范围(更稳健但可能不分离),需要按场景调整。

2.5 第五步:构造 marker(connectedComponents)并执行 watershed

cv.connectedComponents原型及参数(教程原文):

cv.connectedComponents(image, labels, connectivity = 8, ltype = cv.CV_32S)

参数说明
image待标记的 8 位单通道图像
labels输出标记图像(cv.CV_32SC1类型)
connectivity8 或 4,表示 8 连通或 4 连通
ltype输出标记类型,当前支持cv.CV_32Scv.CV_16U

cv.watershed原型及参数(教程原文):

cv.watershed(image, markers)

参数说明
image输入 8 位 3 通道图像
markers输入/输出 32 位单通道标记图,尺寸必须与image相同

marker 图构造的关键细节(教程原文 + JS 实现对应):marker 是与原图同尺寸、int32 类型的数组;确信区域(无论前景还是背景)标为不同的正整数,不确定区域保持0cv.connectedComponents()默认把背景标 0、其余连通块从 1 递增编号——但 0 会被 watershed 当作"未知区域"处理,所以背景必须换个编号。JS 示例的循环正是这样做的:所有标签 +1(使背景 0 变成 1,前景块变成 2、3、…),再把 unknown 区域置回 0

// get connected components markers cv.connectedComponents(coinsFg, markers); for (let i = 0; i < markers.rows; i++) { for (let j = 0; j < markers.cols; j++) { markers.intPtr(i, j)[0] = markers.ucharPtr(i, j)[0] + 1; // 背景 0 -> 1,前景 -> 2,3,... if (unknown.ucharPtr(i, j)[0] == 255) { markers.intPtr(i, j)[0] = 0; // 未知区域 -> 0 } } } cv.cvtColor(src, src, cv.COLOR_RGBA2RGB, 0); cv.watershed(src, markers); // draw barriers for (let i = 0; i < markers.rows; i++) { for (let j = 0; j < markers.cols; j++) { if (markers.intPtr(i, j)[0] == -1) { src.ucharPtr(i, j)[0] = 255; // R src.ucharPtr(i, j)[1] = 0; // G src.ucharPtr(i, j)[2] = 0; // B } } }

注意两个前提条件,均由源码断言保证(见第 3 节):src必须是CV_8UC3markers必须是CV_32SC1且与图像同尺寸——这也是为什么示例先执行cvtColor(src, src, cv.COLOR_RGBA2RGB, 0)。运行后markers被原地修改:边界像素变为-1,示例把这些像素涂成红色,即教程所说的"barriers"。

2.6 C++ 参考实现中的 marker 构造差异

imageSegmentation.cpp 展示了另一种更常见的 C++ 写法:不用connectedComponents,而是对距离变换阈值图做findContours,把每条轮廓用drawContours(markers, contours, i, Scalar(i+1), -1)以不同整数填充,再在角落画一个圆作为背景标记:

// Create the marker image for the watershed algorithm Mat markers = Mat::zeros(dist.size(), CV_32S); // Draw the foreground markers for (size_t i = 0; i < contours.size(); i++) { drawContours(markers, contours, static_cast<int>(i), Scalar(static_cast<int>(i)+1), -1); } // Draw the background marker circle(markers, Point(5,5), 3, Scalar(255), -1); // Perform the watershed algorithm watershed(imgResult, markers);

两种写法本质相同:前景用互不相同的正整数、背景用一个正整数、未知为 0。区别仅在于前景标记来源(连通域编号 vs 轮廓填充)。该示例还演示了结果可视化:把标记图转 8 位后逐像素查markers.at<int>(i,j),用随机颜色填充每个编号区域,得到彩色分割结果。

3. 源码深挖:cv::watershed 是如何"筑坝"的

教程的"哲学"段落描述了注水与筑坝的过程,而仓库中 segmentation.cpp 的cv::watershed实现揭示了它的工程本质——一次按"梯度代价"排序的多源扫描,而非真正模拟水位:

  1. 输入校验:segmentation.cpp#L161-L162 用CV_Assert强制src.type() == CV_8UC3 && dst.type() == CV_32SC1且两图同尺寸——这就是第 2.5 节参数约束的出处。
  2. 256 级优先队列:实现维护一个WSQueue q[NQ]NQ = 256)的桶式优先队列,队列索引等于像素与其已标记邻居之间 BGR 三通道的最大绝对差(见c_diff宏,segmentation.cpp#L150-L159)。差值越小优先级越高,即算法总是先扩展"代价最小"的边界。
  3. 初始相位:遍历图像,把每个 marker 邻居中值为 0 的像素按最小差值入队,并置为IN_QUEUE(-2)标记,同时确定盆地初始边界(segmentation.cpp#L183-L223)。
  4. 递归填充盆地:从最高优先级非空队列取节点;若该像素的已标记邻居全部属于同一标签,则继承该标签,并将其 0 值邻居按各自差值入队;若邻居出现两个不同标签,该像素被标为WSHED(-1)(segmentation.cpp#L267-L290)——这就是"屏障"的生成点,与教程中"不同颜色的水汇合处筑坝"的直觉一一对应。
  5. 图像边框:开头先把最外圈像素直接写成 -1(segmentation.cpp#L179-L181),所以结果图像的外边界天然带一圈"屏障"。

另一个值得注意的实现事实:若 marker 中没有任何正数标记(无源),算法在找到第一个非空队列前即直接return,输出图保持原样(segmentation.cpp#L230-L232)。从源码结构看,marker 构造的成败(尤其背景不能留 0 以外漏标的正整数混乱)直接决定输出质量。

4. 回归测试如何验证分水岭的正确性

Imgproc_Watershed 回归测试 为"marker 应如何构造"提供了官方级验证范式,其做法与教程流程高度一致:

  • 种子构造:对参考标签图逐区域erode(expected == label, seed, kernel)(15×15 椭圆核)后写入markers,即"腐蚀参考区域作为分水岭种子,算法必须把边界重新长回来"(源码注释见 test_watershed.cpp#L47-L49);
  • 断言要点:运行后countNonZero(result == 0)必须为 0(不允许有像素未被标记,呼应"0 表示未知"的语义);标签值最小不小于 -1、最大不超过种子标签数(不能凭空产生新标签);内部像素与参考标签的吻合率要求 > 95%(注释实测:正确实现约 99.6%,若 watershed 什么都没做则仅约 78.7%);两次运行结果必须逐像素一致(确定性)。

这些断言恰好可以作为自己实现流水线时的自检清单:结果中不应残留 0、不应出现种子之外的标签、且同一输入必须可复现。

5. 实战要点与常见陷阱汇总

结合教程正文、JS 示例与 C++ 源码,可提炼出以下可直接执行的清单:

  1. 前提方向:二值图中前景必须为白;若原始图像是"浅色前景、深色背景",使用THRESH_BINARY_INV(JS 示例即此用法);
  2. 距离变换阈值是调参核心normalize[0,1]后的阈值(示例中 0.7,C++ 示例中 0.4)决定前景斑点分离度——阈值过高会丢失细物体,过低则接触物分离不净;
  3. marker 三个区域缺一不可:确定前景(不同正整数)、确定背景(一个正整数)、未知(0);connectedComponents输出需整体 +1 才能把背景从 0 移开(JS 循环中+1的原因);
  4. 类型约束不可绕过image必须CV_8UC3markers必须CV_32SC1且同尺寸,watershed内部CV_Assert会直接触发异常;
  5. 结果解读:输出 markers 中-1 即分割边界,JS 示例将其涂红、C++ 示例通过bitwise_not与随机着色展示;
  6. 退化提醒:只做前景分割不分离接触物体时,省略距离变换、直接用腐蚀即可(教程原文明确说明)。

教程在 HTML 文档中还内嵌了五个可交互实验页面,均加载同一张 coins.jpg 并允许上传自定义图像,可按步骤在浏览器中逐步运行:Otsu 阈值 → 确定背景 → 距离变换 → 确定前景 → 完整 watershed。

适用前提与限制说明:本文以当前仓库doc/js_tutorials教程、modules/imgproc的 C++ 实现与samples/cpp官方示例为准。watershed要求 8 位三通道输入,单通道灰度图需先cvtColorCOLOR_GRAY2BGR(从CV_Assert(src.type() == CV_8UC3)可确认该约束);基于标记的性质决定了算法质量强依赖 marker 的构造质量,对纹理复杂或前景背景对比极低的图像,需要更精细的前景/背景证据(如边缘信息、学习得到的先验),而不仅仅是形态学手段。

【免费下载链接】opencvOpen Source Computer Vision Library项目地址: https://gitcode.com/GitHub_Trending/opencv31/opencv

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 16:37:18

从娱乐视频到技术实践:短视频平台背后的算法与工程架构

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 16:35:43

Linux命令行文件管理:从基础命令到高效同步与权限控制

1. 文件管理的核心思路与命令行优势命令行管理文件这件事&#xff0c;很多刚接触的人第一反应是“没必要”。鼠标拖拽不香吗&#xff1f;双击打开不直观吗&#xff1f;我在实际接触生产环境后才发现&#xff0c;图形界面能覆盖的场景实在太有限了——几十台服务器不可能一台台远…

作者头像 李华
网站建设 2026/9/7 16:31:46

AIGC质量上不去?先搞懂原创性逻辑,别急着换工具

AIGC质量上不去&#xff1f;别急着换工具&#xff0c;先把“原创性逻辑”想明白先扔个结论&#xff1a;过去半年我一直在折腾AIGC内容质量这件事&#xff0c;从生图、生文、生代码到多模态混排&#xff0c;踩了无数坑&#xff0c;最后发现一个扎心的事实——大部分人的AIGC作品…

作者头像 李华
网站建设 2026/9/7 16:29:19

HarmonyOS Canvas绘制分段函数:坐标变换、采样与断点处理实战

1. 实例拆解&#xff1a;分段函数绘制的核心思路1.1 这个实例到底在解决什么问题很多做鸿蒙应用开发的朋友&#xff0c;入门的时候接触的往往都是界面跳转、列表展示、数据持久化这类常规操作。但真正到了做工具类应用、教育类应用或者图形处理相关功能时&#xff0c;会遇到一个…

作者头像 李华