我正在使用OpenCV为Android编写。我正在使用标记控制的分水岭分割类似于下面的图像,而无需用户手动标记图像。我计划使用区域最大值作为标记。
minMaxLoc()
会给我这个值,但是我如何将它限制在我感兴趣的blob上?我可以利用findContour()
或cvBlob blob的结果来限制ROI并将最大值应用于每个blob吗?
首先:函数minMaxLoc
仅查找给定输入的全局最小值和全局最大值,因此对于确定区域最小值和/或区域最大值大多无用。但是你的想法是对的,根据区域最小值/最大值提取标记以执行基于标记的分水岭变换是完全可以的。让我尝试澄清什么是分水岭变换以及你应该如何正确使用OpenCV中存在的实现。
一些关于分水岭的论文对分水岭的描述与下面类似(如果你不确定,我可能会错过一些细节:问一问)。考虑你知道的某个地区的表面,它包含山谷和山峰(以及其他与我们无关的细节)。假设在这个表面下面,你只有水,彩色的水。现在,在你表面的每个山谷上打洞,然后水开始填满所有的区域。在某个时候,不同颜色的水会相遇,当这种情况发生时,你建造一个水坝,这样它们就不会相互接触。最后,你有一个水坝的集合,这是分隔所有不同颜色水的分水岭。
现在,如果你在表面上打了太多的洞,你最终会得到太多的区域:过度分割。如果你做得太少,你会得到一个分割不足的结果。因此,实际上任何建议使用分水岭的论文实际上都提出了避免这些问题的技术。
我写了所有这些(对于任何知道分水岭转换是什么的人来说,这可能太天真了),因为它直接反映了你应该如何使用分水岭实现(目前公认的答案是完全错误的)。现在让我们开始使用Python绑定的OpenCV示例。
问题中呈现的图像由许多对象组成,这些对象大多过于接近,在某些情况下是重叠的。这里分水岭的有用性是正确地分离这些对象,而不是将它们分组到一个单独的组件中。因此,您需要为每个对象至少一个标记,并为背景提供良好的标记。例如,首先通过Otsu对输入图像进行二值化,并执行用于去除小对象的形态学开口。此步骤的结果显示在左图中。现在使用二值图像考虑对其应用距离变换,结果在右侧。
有了距离变换结果,我们可以考虑一些阈值,这样我们就只考虑离背景最远的区域(下面的左图)。这样做,我们可以通过在前面的阈值之后标记不同的区域来获得每个对象的标记。现在,我们还可以考虑上面左图的放大版本的边界来组成我们的标记。完整的标记显示在下面的右边(有些标记太暗而看不见,但是左图中的每个白色区域都在右图中表示)。
我们这里的这个标记很有意义。每个彩色水==一个标记
将开始填充该区域,流域转换将构建水坝来阻止不同的“颜色”合并。如果我们进行转换,我们会得到左边的图像。通过将它们与原始图像组合,只考虑水坝,我们会得到右边的结果。
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)
我想在这里解释一个关于如何使用分水岭的简单代码。我使用的是OpenCVPython,但我希望你不会有任何困难去理解。
在这段代码中,我将使用分水岭作为前景-背景提取的工具。(这个例子是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)
m:
4-我们正确阈值以获取掩码并对输入图像执行bitwise_and
:
ret,thresh = cv2.threshold(m,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)
res = cv2.bitwise_and(img,img,mask = thresh)
分辨率:
希望有帮助!!!
ARK
前言
我插话主要是因为我发现OpenCV留档(和C示例)中的分水岭教程以及上面mmgp的答案都很混乱。我多次重温分水岭方法,最终出于沮丧放弃了。我终于意识到我至少需要尝试一下这种方法,看看它在行动中。这是我整理了我遇到的所有教程后得出的结论。
除了作为一个计算机视觉新手之外,我的大部分麻烦可能与我要求使用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);
为了让分水岭调用起作用,它需要一些关于ROI的“提示”。如果你像我一样是一个完全的初学者,我建议你查看CMM分水岭页面,快速入门。可以说,我们将通过创建右侧的形状来创建左侧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。ImShow
未知,它看起来像这样:
不错!这对我来说很容易理解。然而,接下来的部分让我很困惑。让我们看看将我们的“提示”转换为水沟
函数可以使用的东西。为此,我们需要使用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”。(如果你到了这一步,想可视化你的矩阵,我建议你看看这个有指导意义的答案。)现在,我们将如何利用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。
在这一点上,我们应该都设置为调用滑铁卢
。但是,在我的特定应用程序中,在此调用期间仅可视化整个源图像的一小部分是有用的。这对您来说可能是可选的,但我首先通过扩展它来屏蔽一小部分源:
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);
结果
上面的Water缔约国
调用将修改标签
。您必须回到记住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可能会出现一些难以理解的错误消息。我真的应该使用上面的使用,但是
mat. Release()
也是一个选项。
此外,mmgp上面的答案包括这一行:dt=((dt-dt. min()) / (dt.max()-dt.min())*255).astype(numpy.uint8)
,这是一个应用于距离变换结果的直方图拉伸步骤。我省略了这一步有很多原因(主要是因为我不认为我看到的直方图一开始就太窄了),但因人而异。