前言
我之所以加入,主要是因为我发现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),这是应用于距离变换结果的直方图拉伸步骤。出于多种原因,我省略了这一步(主要是因为我不认为我看到的直方图太窄而无法开始),但你的里程可能会有所不同。