在我每周的标准作业清单中,有一项是编写计算机视觉算法来计算该图像中米粒的数量:
- 因此,当我的一个好朋友M给我发了一张纸上的扁豆照片(显然是受到上述转发的启发),请我帮他数一下谷物的数量时,它勾起了我怀旧的回忆。因此,我在我的旧硬盘上寻找很久以前编写的代码作为上述问题的参考解决方案。花了一些时间才找到他们。
- 旧代码是用C 编写的,并使用现已过时的 OpenCV 1.x API。我当前的 PC 中不再安装旧的库版本,而且由于 Python 现在很流行,我决定使用最新的 OpenCV API 将逻辑移植到 Python 3 代码。
- 在这篇文章中,我将演示实现上述解决方案的非常简单的步骤,解释所做出的一些算法选择、此处介绍的解决方案的一些替代方案和局限性。请注意,这是一个纯粹的计算机视觉算法解决方案。联系qq1309399183
简化问题
- 图像中的像素可以取很大范围的值,甚至是代表相同或相似对象的像素。
- 这给使用经典计算机视觉算法步骤解决图像理解任务带来了特殊的障碍。在我们的输入灰度图像中,值表示 8 位图像表示的各种灰度范围从 0 到255。
- 将一种类型的对象与另一种类型的对象分开的值的边界并不总是清晰的,并且由于图像采集期间的照明条件,可能具有显着的范围重叠。标准方法是将值减少为几个不同的值,每个值代表有意义的对象。这称为分段。在这种情况下,图像中只有两种类型的区域。一个是属于米粒的像素组,另一个属于背景虚空的像素组。
- 这是一种非常常见的特殊情况,称为二进制分割,即将输入灰度图像转换为纯黑白形式。纯白色描绘米粒的像素,纯黑色描绘普遍背景。一旦完成,问题就归结为仅仅计算图像中不同的纯白色物体的数量。有多种不同的方法可以实现上述目的。
正如您可能已经推断出的,这假设可能存在一个特定值,使得所有米粒像素都比该值更亮,而所有背景像素都更暗。为简单起见,我们认为本例中的值为 127(0-255 范围的中间)。但查看输出,我们确实意识到相当多的背景像素已被标记为白色,而许多米粒像素已被标记为黑色。现在的问题是,我们是否必须通过反复试验得出一个合适的阈值,或者是否有更结构化的方法来做到这一点?
thresh, output_binthresh = cv.threshold(input_rice, 127, 255, cv.THRESH_BINARY) print("固定阈值", thresh) cv.imshow("二进制阈值(固定)", output_binthresh)
使用 Otsu 方法进行二元阈值处理
Nobuyuki Otsu 的阈值选择方法(更广泛地称为Otsu 方法)通过最大化类间方差来统计计算合适的阈值。我们可以使用相同的方法来获得更好质量的二值分割结果。
thresh, output_otsuthresh = cv.threshold(input_rice, 0, 255, cv.THRESH_BINARY | cv.THRESH_OTSU) print("大津阈值", thresh) cv.imshow("二进制阈值 (otsu)", output_otsuthresh)
- 上面的结果肯定比以前干净,但仍然不够好。我们可以做得更好吗?答案是肯定的。
- 如果我们返回并观察原始输入图像,我们可以看到整个图像的照明并不均匀。
- 中心区域当然是最亮的。它向底部变暗,在某种程度上向顶部和角落也变暗。
- 由于点光源,这是一种常见的情况。因此,整个图像上的单一阈值可能不是解决二值分割问题的最佳方法。
局部自适应阈值
改变照明的解决方案是需要确定仅适合图像的有限部分的不同阈值。局部自适应阈值的作用是根据像素周围有限矩形区域内像素阴影的分布,为每个像素找到统计上合适的阈值。这抵消了照明梯度。
output_adapthresh = cv.adaptiveThreshold(input_rice,255.0,cv.ADAPTIVE_THRESH_MEAN_C,cv.THRESH_BINARY,51,-20.0) cv.imshow("自适应阈值", output_adapthresh)
矩形区域的大小是一个可调参数。有一些直观的过程可以让它正确。我们选择的区域大小为每个像素周围的 51X51 像素,大约是 512X512 图像尺寸的 10%。这似乎适用于我们的输入样本中的缓慢照明变化。对于快速变化的照明,需要较小的区域。
后期处理
即使使用局部自适应阈值,似乎可以很好地执行二进制分割,但我们在输出中仍然存在一些小问题。在应该有背景的地方有随机的明亮像素斑点。另外,有些谷物对象是连体的,这可能会导致计数结果出现偏差。我们需要清理分割的图像,使其更适合准确计数。
形态侵蚀
侵蚀是一种几何变换,旨在减少又名侵蚀前景形状。我们使用 5 像素矩形算子腐蚀图像。这有助于去除外围斑点。也可以分离出连体的颗粒物体。
kernel = np.ones((5,5),np.uint8) output_erosion = cv.erode(output_adapthresh, kernel) cv.imshow(“Morphological Erosion”, output_erosion)
由此产生的输出现在已完全准备好用于我们的计数算法。
数着谷物
正如我之前提到的,通过适当的简化和后处理,看似复杂的问题已简化为仅计算不同纯白色物体的数量。我将提出两种替代方案来完成同样的任务。
连接组件
label_image = output_erosion.copy() label_count = 0 rows, cols = label_image.shape for j in range(rows): for i in range(cols): pixel = label_image[j, i] if 255 == pixel: label_count += 1 cv.floodFill(label_image, None, (i, j), label_count) print("Number of foreground objects", label_count) cv.imshow("Connected Components", label_image)
- 上述逻辑首先系统地寻找白色像素,为它们分配一个唯一的标签 ID(1 到
254),并递归地为所有相邻的白色像素分配相同的标签,直到覆盖并标记整个不同的对象。 - 然后继续寻找下一个可用的白色像素 (255),并重复该过程,直到整个图像已处理完毕。分配的唯一标签的数量与不同的前景对象(即米粒)相同。
_, contours, _ = cv.findContours(output_erosion, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE) output_contour = cv.cvtColor(input_rice, cv.COLOR_GRAY2BGR) cv.drawContours(output_contour, contours, -1, (0, 0, 255), 2) print(“Number of detected contours”, len(contours)) cv.imshow(“Contours”, output_contour)
- 轮廓检测逻辑的工作原理是找到前景/背景边界像素,然后执行边界跟随逻辑,以链码的形式对外部轮廓形状进行编码.
- 执行此操作直到覆盖所有前景对象的边界。检测到的独特外部轮廓的数量与米粒的数量相同。
局限性
上述算法在给定的输入图像上完成其工作,但仍然需要手动调整一些参数。相同的解决方案可能不足以概括到不同的输入或不同的照明条件下。此外,如果图像中颗粒的密度较高,即大多数颗粒彼此相邻或相互遮挡,则这也会严重失败。