如何在 OpenCV 中定义分水岭的标记?

Posted

技术标签:

【中文标题】如何在 OpenCV 中定义分水岭的标记?【英文标题】:How to define the markers for Watershed in OpenCV? 【发布时间】:2012-07-02 22:29:49 【问题描述】:

我正在使用 OpenCV 为 android 写作。我正在使用标记控制的分水岭分割类似于下面的图像,而无需用户手动标记图像。我打算使用区域最大值作为标记。

minMaxLoc() 会给我这个价值,但我怎样才能将它限制在我感兴趣的 blob 中?我可以利用findContours() 或 cvBlob blob 的结果来限制 ROI 并将最大值应用于每个 blob?

【问题讨论】:

【参考方案1】:

首先:函数minMaxLoc 只能找到给定输入的全局最小值和全局最大值,因此它对于确定区域最小值和/或区域最大值几乎没有用处。但是您的想法是正确的,基于区域最小值/最大值提取标记以执行基于标记的分水岭变换是完全可以的。让我试着澄清一下什么是分水岭变换,以及你应该如何正确使用 OpenCV 中的实现。

一些涉及分水岭的论文以类似的方式描述了它(如果您不确定,我可能会错过一些细节:问)。考虑您知道的某个区域的表面,它包含山谷和山峰(以及其他与我们无关的细节)。假设在这个表面之下你只有水,有颜色的水。现在,在你表面的每个山谷打洞,然后水开始填满整个区域。在某些时候,不同颜色的水会相遇,当这种情况发生时,你会建造一座水坝,使它们不会相互接触。最后你会得到一个水坝的集合,这是分隔所有不同颜色的水的分水岭。

现在,如果您在该表面上打了太多孔,最终会产生太多区域:过度分割。如果你做的太少,你会得到一个细分不足。因此,几乎所有建议使用分水岭的论文实际上都提出了避免该论文所处理的应用程序出现这些问题的技术。

我写了所有这些(这对于任何知道分水岭转换的人来说可能都太天真了),因为它直接反映了您应该如何使用分水岭实现(当前接受的答案是以完全错误的方式做的)。现在让我们开始使用 Python 绑定的 OpenCV 示例。

问题中呈现的图像由许多对象组成,这些对象大多靠得太近,并且在某些情况下重叠。分水岭在这里的用处是正确地分离这些对象,而不是将它们组合成单个组件。因此,每个对象至少需要一个标记,背景需要好的标记。例如,首先通过 Otsu 对输入图像进行二值化,然后执行形态学打开以去除小物体。此步骤的结果如下左图所示。现在考虑对二值图像应用距离变换,结果如右图。

通过距离变换结果,我们可以考虑一些阈值,以便我们只考虑距离背景最远的区域(下图左)。这样做,我们可以通过在较早的阈值之后标记不同的区域来获得每个对象的标记。现在,我们还可以考虑上面左图的放大版本的边框来组成我们的标记。完整的标记如下右图所示(有些标记太暗而无法看到,但左图中的每个白色区域都显示在右图中)。

我们这里的这个标记很有意义。每个colored water == one marker 将开始填充该区域,流域转换将建造水坝以阻止不同“颜色”的融合。如果我们进行变换,我们会得到左边的图像。通过将它们与原始图像组合来仅考虑水坝,我们得到了正确的结果。

import sys
import cv2
import numpy
from scipy.ndimage import label

def segment_on_dt(a, img):
    border = cv2.dilate(img, None, iterations=5)
    border = border - cv2.erode(border, None)

    dt = cv2.distanceTransform(img, 2, 3)
    dt = ((dt - dt.min()) / (dt.max() - dt.min()) * 255).astype(numpy.uint8)
    _, dt = cv2.threshold(dt, 180, 255, cv2.THRESH_BINARY)
    lbl, ncc = label(dt)
    lbl = lbl * (255 / (ncc + 1))
    # Completing the markers now. 
    lbl[border == 255] = 255

    lbl = lbl.astype(numpy.int32)
    cv2.watershed(a, lbl)

    lbl[lbl == -1] = 0
    lbl = lbl.astype(numpy.uint8)
    return 255 - lbl


img = cv2.imread(sys.argv[1])

# Pre-processing.
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)    
_, img_bin = cv2.threshold(img_gray, 0, 255,
        cv2.THRESH_OTSU)
img_bin = cv2.morphologyEx(img_bin, cv2.MORPH_OPEN,
        numpy.ones((3, 3), dtype=int))

result = segment_on_dt(img, img_bin)
cv2.imwrite(sys.argv[2], result)

result[result != 255] = 0
result = cv2.dilate(result, None)
img[result == 255] = (0, 0, 255)
cv2.imwrite(sys.argv[3], img)

【讨论】:

感谢您的教程。很好地解释了我们不熟悉分水岭算法。正如您所提到的,分割对象的数量主要取决于在主要步骤中找到了多少标记,在这里,似乎距离变换和阈值化后将一些药丸分割为一个。我们是否可以通过更改阈值参数来改进结果? 应该是lbl * (255/ (ncc + 1)) 否则会丢失一个轮廓【参考方案2】:

我想在这里解释一个关于如何使用分水岭的简单代码。我正在使用 OpenCV-Python,但我希望你不会有任何理解困难。

在此代码中,我将使用分水岭作为前景-背景提取的工具。(此示例是 OpenCV 食谱中 C++ 代码的 Python 对应物)。这是一个理解分水岭的简单案例。除此之外,您可以使用分水岭来计算此图像中的对象数量。这将是此代码的稍微高级的版本。

1 - 首先我们加载我们的图像,将其转换为灰度,并使用合适的值对其进行阈值处理。我采用了Otsu的二值化,所以它会找到最佳阈值。

import cv2
import numpy as np

img = cv2.imread('sofwatershed.jpg')
gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
ret,thresh = cv2.threshold(gray,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)

下面是我得到的结果:

(即使这样的结果也很好,因为前景和背景图像之间的对比度很大)

2 - 现在我们必须创建标记。 标记是与原始图像大小相同的图像,即 32SC1(32 位有符号单通道)。

现在在原始图像中会有一些区域,您可以确定该部分属于前景。在标记图像中用 255 标记这样的区域。现在你确定是背景的区域用128标记。你不确定的区域用0标记。这是我们接下来要做的。

A - 前景区域:- 我们已经获得了药丸为白色的阈值图像。我们对它们进行了一点侵蚀,以确保剩余区域属于前景。

fg = cv2.erode(thresh,None,iterations = 2)

fg

B - 背景区域 :- 在这里我们扩大阈值图像以减少背景区域。但我们确信剩余的黑色区域是 100% 背景。我们将其设置为 128。

bgt = cv2.dilate(thresh,None,iterations = 3)
ret,bg = cv2.threshold(bgt,1,128,1)

现在我们得到 bg 如下:

C - 现在我们同时添加 fg 和 bg

marker = cv2.add(fg,bg)

以下是我们得到的:

现在我们可以从上图清楚地看出,白色区域是 100% 前景,灰色区域是 100% 背景,黑色区域我们不确定。

然后我们将其转换为 32SC1 :

marker32 = np.int32(marker)

3 - 最后我们应用分水岭并将结果转换回uint8图像:

cv2.watershed(img,marker32)
m = cv2.convertScaleAbs(marker32)

米:

4 - 我们对它进行适当的阈值化以获得蒙版并使用输入图像执行bitwise_and

ret,thresh = cv2.threshold(m,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)
res = cv2.bitwise_and(img,img,mask = thresh)

分辨率:

希望对你有帮助!!!

方舟

【讨论】:

考虑重新检查这个,因为它显示了对分水岭的完全错误使用。 @mmgp :也许你是对的。这只是cookbook 中给出的基本C++ 代码的python 版本,它显示设置标记等。我认为这对于新人来说是一个很好的示例,including me。无论如何,你的回答很棒。它填补了我的答案中缺少的东西。 那么,我需要 3 色图像作为标记还是 2 色就足够了?【参考方案3】:

前言

我之所以加入,主要是因为我发现watershed tutorial in the OpenCV documentation(和C++ example)以及mmgp's answer above 都非常令人困惑。我多次重新审视分水岭方法,最终因沮丧而放弃。我终于意识到我至少需要尝试一下这种方法并看到它的实际效果。这是我整理了所有遇到的教程后得出的结论。

除了作为计算机视觉新手之外,我的大部分麻烦可能与我使用 OpenCVSharp 库而不是 Python 的要求有关。 C# 没有像 NumPy 中那样内置的高功率数组运算符(尽管我意识到这是通过 IronPython 移植的),所以我在理解和在 C# 中实现这些操作方面都遇到了很多困难。另外,郑重声明,我真的很鄙视这些函数调用中的细微差别和不一致之处。 OpenCVSharp 是我用过的最脆弱的库之一。但是,嘿,这是一个港口,所以我在期待什么?最棒的是——它是免费的。

事不宜迟,让我们谈谈我对分水岭的 OpenCVSharp 实现,并希望从总体上阐明分水岭实现的一些棘手点。

应用程序

首先,确保分水岭是您想要的,并了解它的用途。我正在使用染色的细胞板,比如这个:

我花了很长时间才弄清楚我不能只打一个分水岭来区分该领域的每个细胞。相反,我首先必须隔离一部分字段,然后在这小部分上调用分水岭。我通过一些过滤器隔离了我的感兴趣区域 (ROI),我将在这里简要解释一下:

    从源图像开始(左,为演示目的而裁剪) 隔离红色通道(左中) 应用自适应阈值(右中) 找到轮廓,然后消除小区域的轮廓(右)

一旦我们清理了由上述阈值操作产生的轮廓,就该寻找分水岭的候选者了。就我而言,我只是遍历了所有大于某个区域的轮廓。

代码

假设我们已经从上述字段中分离出这个轮廓作为我们的 ROI:

让我们看看如何编写分水岭。

我们将从一个空白垫子开始,只绘制定义我们 ROI 的轮廓:

var isolatedContour = new Mat(source.Size(), MatType.CV_8UC1, new Scalar(0, 0, 0));
Cv2.DrawContours(isolatedContour, new List<List<Point>>  contour , -1, new Scalar(255, 255, 255), -1);

为了使分水岭调用发挥作用,它需要一些关于投资回报率的“提示”。如果您像我一样是一个完整的初学者,我建议您查看CMM watershed page 以获得快速入门。可以说我们将通过在右侧创建形状来在左侧创建有关 ROI 的提示:

要创建此“提示”形状的白色部分(或“背景”),我们只需 Dilate 隔离形状,如下所示:

var kernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(2, 2));
var background = new Mat();
Cv2.Dilate(isolatedContour, background, kernel, iterations: 8);

要在中间(或“前景”)创建黑色部分,我们将使用距离变换和阈值,将我们从左边的形状带到右边的形状:

这需要几个步骤,您可能需要调整阈值的下限以获得适合您的结果:

var foreground = new Mat(source.Size(), MatType.CV_8UC1);
Cv2.DistanceTransform(isolatedContour, foreground, DistanceTypes.L2, DistanceMaskSize.Mask5);
Cv2.Normalize(foreground, foreground, 0, 1, NormTypes.MinMax); //Remember to normalize!

foreground.ConvertTo(foreground, MatType.CV_8UC1, 255, 0);
Cv2.Threshold(foreground, foreground, 150, 255, ThresholdTypes.Binary);

然后我们将这两个垫子相减得到我们“提示”形状的最终结果:

var unknown = new Mat(); //this variable is also named "border" in some examples
Cv2.Subtract(background, foreground, unknown);

同样,如果我们Cv2.ImShowunknown,它看起来像这样:

不错!这对我来说很容易缠住我的头。然而,下一部分让我很困惑。让我们看看将我们的“提示”变成Watershed 函数可以使用的东西。为此,我们需要使用ConnectedComponents,它基本上是一个大的像素矩阵,根据它们的索引进行分组。例如,如果我们有一个带有字母“HI”的垫子,ConnectedComponents 可能会返回这个矩阵:

0 0 0 0 0 0 0 0 0
0 1 0 1 0 2 2 2 0
0 1 0 1 0 0 2 0 0 
0 1 1 1 0 0 2 0 0
0 1 0 1 0 0 2 0 0
0 1 0 1 0 2 2 2 0
0 0 0 0 0 0 0 0 0

所以,0 是背景,1 是字母“H”,2 是字母“I”。 (如果您已经到了这一点并想要可视化您的矩阵,我建议您查看this instructive answer。)现在,我们将使用ConnectedComponents 为分水岭创建标记(或标签):

var labels = new Mat(); //also called "markers" in some examples
Cv2.ConnectedComponents(foreground, labels);
labels = labels + 1;

//this is a much more verbose port of numpy's: labels[unknown==255] = 0
for (int x = 0; x < labels.Width; x++)

    for (int y = 0; y < labels.Height; y++)
    
        //You may be able to just send "int" in rather than "char" here:
        var labelPixel = (int)labels.At<char>(y, x);    //note: x and y are inexplicably 
        var borderPixel = (int)unknown.At<char>(y, x);  //and infuriatingly reversed

        if (borderPixel == 255)
            labels.Set(y, x, 0);
    

请注意,分水岭函数要求边界区域用 0 标记。因此,我们在标签/标记数组中将任何边界像素设置为 0。

此时,我们应该都准备好调用Watershed。但是,在我的特定应用程序中,仅在此调用期间可视化整个源图像的一小部分很有用。这对你来说可能是可选的,但我首先只是通过扩大它来掩盖一小部分来源:

var mask = new Mat();
Cv2.Dilate(isolatedContour, mask, new Mat(), iterations: 20);
var sourceCrop = new Mat(source.Size(), source.Type(), new Scalar(0, 0, 0));
source.CopyTo(sourceCrop, mask);

然后进行魔法召唤:

Cv2.Watershed(sourceCrop, labels);

结果

上述Watershed 调用将修改labels就地。您必须回过头来记住ConnectedComponents 产生的矩阵。这里的区别是,如果分水岭在分水岭之间发现任何水坝,它们将在该矩阵中标记为“-1”。与ConnectedComponents 结果一样,不同的分水岭将以类似的递增数字方式进行标记。出于我的目的,我想将它们存储到单独的轮廓中,所以我创建了这个循环来将它们分开:

var watershedContours = new List<Tuple<int, List<Point>>>();

for (int x = 0; x < labels.Width; x++)

    for (int y = 0; y < labels.Height; y++)
    
        var labelPixel = labels.At<Int32>(y, x); //note: x, y switched 

        var connected = watershedContours.Where(t => t.Item1 == labelPixel).FirstOrDefault();
        if (connected == null)
        
            connected = new Tuple<int, List<Point>>(labelPixel, new List<Point>());
            watershedContours.Add(connected);
        
        connected.Item2.Add(new Point(x, y));

        if (labelPixel == -1)
            sourceCrop.Set(y, x, new Vec3b(0, 255, 255));

    

然后,我想用随机颜色打印这些轮廓,所以我创建了以下垫子:

var watershed = new Mat(source.Size(), MatType.CV_8UC3, new Scalar(0, 0, 0));
foreach (var component in watershedContours)

    if (component.Item2.Count < (labels.Width * labels.Height) / 4 && component.Item1 >= 0)
    
        var color = GetRandomColor();
        foreach (var point in component.Item2)
            watershed.Set(point.Y, point.X, color);
    

显示时会产生以下结果:

如果我们在源图像上绘制之前用 -1 标记的水坝,我们会得到:

编辑:

我忘了注意:请确保在使用完垫子后清理它们。它们将留在内存中,OpenCVSharp 可能会出现一些难以理解的错误消息。我真的应该使用上面的using,但mat.Release() 也是一个选项。

此外,mmgp 上面的答案包括这一行:dt = ((dt - dt.min()) / (dt.max() - dt.min()) * 255).astype(numpy.uint8),这是应用于距离变换结果的直方图拉伸步骤。出于多种原因,我省略了这一步(主要是因为我不认为我看到的直方图太窄而无法开始),但你的里程可能会有所不同。

【讨论】:

以上是关于如何在 OpenCV 中定义分水岭的标记?的主要内容,如果未能解决你的问题,请参考以下文章

基于标记的分水岭分割算法

youcans 的 OpenCV 例程200篇184.鼠标交互标记的分水岭算法

youcans 的 OpenCV 例程200篇183.基于轮廓标记的分水岭算法

OpenCV图像处理之——分水岭算法的图像分割

OpenCV—Python 分水岭算法图像分割

OpenCV Canny + 分水岭