如何在 OpenCV2 中将阈值分割成方块?

Ale*_*ggs 2 python opencv rubiks-cube

我有一张可爱的魔方的照片:

魔方

我想把它分成几个正方形并确定每个正方形的颜色。我可以对其运行高斯模糊,然后运行“Canny”,最后运行“Dilate”以获得以下结果:

后扩张

这看起来看起来不错,但我无法将其变成正方形。我尝试的任何类型的“findContours”都只能显示一两个方块。距离我的目标九还差得很远。除此之外,人们对我还能做什么有什么想法吗?

目前最佳解决方案:

侧面

代码如下,需要 numpy + opencv2。它需要一个名为“./sides/rubiks-side-F.png”的文件,并将多个文件输出到“steps”文件夹。

import numpy as np
import cv2 as cv

def save_image(name, file):
    return cv.imwrite('./steps/' + name + '.png', file)


def angle_cos(p0, p1, p2):
    d1, d2 = (p0-p1).astype('float'), (p2-p1).astype('float')
    return abs(np.dot(d1, d2) / np.sqrt(np.dot(d1, d1)*np.dot(d2, d2)))

def find_squares(img):
    img = cv.GaussianBlur(img, (5, 5), 0)
    squares = []
    for gray in cv.split(img):
        bin = cv.Canny(gray, 500, 700, apertureSize=5)
        save_image('post_canny', bin)
        bin = cv.dilate(bin, None)
        save_image('post_dilation', bin)
        for thrs in range(0, 255, 26):
            if thrs != 0:
                _retval, bin = cv.threshold(gray, thrs, 255, cv.THRESH_BINARY)
                save_image('threshold', bin)
            contours, _hierarchy = cv.findContours(
                bin, cv.RETR_LIST, cv.CHAIN_APPROX_SIMPLE)
            for cnt in contours:
                cnt_len = cv.arcLength(cnt, True)
                cnt = cv.approxPolyDP(cnt, 0.02*cnt_len, True)
                if len(cnt) == 4 and cv.contourArea(cnt) > 1000 and cv.isContourConvex(cnt):
                    cnt = cnt.reshape(-1, 2)
                    max_cos = np.max(
                        [angle_cos(cnt[i], cnt[(i+1) % 4], cnt[(i+2) % 4]) for i in range(4)])
                    if max_cos < 0.2:
                        squares.append(cnt)
    return squares

img = cv.imread("./sides/rubiks-side-F.png")
squares = find_squares(img)
cv.drawContours(img, squares, -1, (0, 255, 0), 3)
save_image('squares', img)
Run Code Online (Sandbox Code Playgroud)

你可以在这里找到其他方面

sta*_*ine 7

我知道您可能不会接受这个答案,因为它是用C++. 没关系; 我只是想向您展示一种检测正方形的可能方法。如果您希望将此代码移植到Python.

\n

目标是尽可能准确地检测所有9方块。这些是步骤:

\n
    \n
  1. 获取边缘遮罩,其中整个立方体的轮廓\n清晰可见。
  2. \n
  3. 过滤这些边缘以获得二元立方体(分割)掩模。
  4. \n
  5. 使用立方体蒙版获取立方体\xe2\x80\x99s 边界框/矩形。
  6. \n
  7. 使用边界矩形获取每个正方形的尺寸和位置(所有正方形都具有恒定的尺寸)。
  8. \n
\n

首先,我将尝试应用您描述的步骤来获取边缘蒙版。我只是想确保我能达到与您目前所处的相似的起点。

\n

管道是这样的read the image > grayscale conversion > Gaussian Blur > Canny Edge detector::

\n
    //read the input image:\n    std::string imageName = "C://opencvImages//cube.png";\n    cv::Mat testImage =  cv::imread( imageName );\n\n    //Convert BGR to Gray:\n    cv::Mat grayImage;\n    cv::cvtColor( testImage, grayImage, cv::COLOR_RGB2GRAY );\n\n   //Apply Gaussian blur with a X-Y Sigma of 50:\n    cv::GaussianBlur( grayImage, grayImage, cv::Size(3,3), 50, 50 );\n\n    //Prepare edges matrix:\n    cv::Mat testEdges;\n\n    //Setup lower and upper thresholds for edge detection:\n    float lowerThreshold = 20;\n    float upperThreshold = 3 * lowerThreshold;\n\n    //Get Edges via Canny:\n    cv::Canny( grayImage, testEdges, lowerThreshold, upperThreshold );\n
Run Code Online (Sandbox Code Playgroud)\n

好吧,这就是起点。这是我得到的边缘掩模:

\n\n

接近你的结果。现在,我将应用扩张。在这里,操作的迭代次数很重要,因为我想要漂亮、厚的边缘。还需要闭合打开的轮廓,因此,我想要温和的扩张。iterations = 5我设置了使用矩形结构元素的数量。

\n
    //Prepare a rectangular, 3x3 structuring element:\n    cv::Mat SE = cv::getStructuringElement( cv::MORPH_RECT, cv::Size(3, 3) );\n\n    //OP iterations:\n    int dilateIterations = 5;\n\n   //Prepare the dilation matrix:\n    cv::Mat binDilation;\n\n   //Perform the morph operation:\n    cv::morphologyEx( testEdges, binDilation, cv::MORPH_DILATE, SE, cv::Point(-1,-1), dilateIterations );\n
Run Code Online (Sandbox Code Playgroud)\n

我明白了:

\n\n

这是迄今为止的输出,具有漂亮且非常明确的边缘。最重要的是明确定义 cube,因为我将依靠它的轮廓来计算bounding rectangle后面的内容。

\n

接下来是我尝试尽可能准确地清除立方体边缘的所有其他东西。正如您所看到的,有很多不属于立方体的垃圾和像素。我特别感兴趣的是用与立方体(黑色)不同的颜色(白色)填充背景,以获得良好的分割。

\n

Flood-filling但有一个缺点。如果轮廓未闭合,它也可以填充轮廓的内部。我尝试用“边框蒙版”一次性清理垃圾和闭合轮廓,“边框蒙版”只是扩张蒙版侧面的白线。

\n

我将此蒙版实现为与膨胀蒙版接壤的四条超粗线。要应用线条,我需要起点终点,它们对应于图像的角点。这些定义在vector

\n
    std::vector< std::vector<cv::Point> > imageCorners;\n    imageCorners.push_back( { cv::Point(0,0), cv::Point(binDilation.cols,0) } );\n    imageCorners.push_back( { cv::Point(binDilation.cols,0), cv::Point(binDilation.cols, binDilation.rows) } );\n    imageCorners.push_back( { cv::Point(binDilation.cols, binDilation.rows), cv::Point(0,binDilation.rows) } );\n    imageCorners.push_back( { cv::Point(0,binDilation.rows), cv::Point(0, 0) } );\n
Run Code Online (Sandbox Code Playgroud)\n

四个条目向量中的四个起始/结束坐标。我应用循环这些坐标的“边框蒙版”并绘制粗线:

\n
    //Define the SUPER THICKNESS:\n    int lineThicness  = 200;\n\n    //Loop through my line coordinates and draw four lines at the borders:\n    for ( int c = 0 ; c < 4 ; c++ ){\n        //Get current vector of points:\n        std::vector<cv::Point> currentVect = imageCorners[c];\n       //Get the starting/ending points:\n        cv::Point startPoint = currentVect[0];\n        cv::Point endPoint = currentVect[1];\n        //Draw the line:\n        cv::line( binDilation, startPoint, endPoint, cv::Scalar(255,255,255), lineThicness );\n    }\n
Run Code Online (Sandbox Code Playgroud)\n

凉爽的。这让我得到这个输出:

\n\n

现在,让我们应用该floodFill算法。此操作将用“替代”颜色填充相同颜色像素的封闭区域。它需要一个种子点和替代颜色(在本例中为白色)。让我们对刚刚创建的白色蒙版内部的四个角进行洪水填充。

\n
    //Set the offset of the image corners. Ensure the area to be filled is black:\n    int fillOffsetX = 200;\n    int fillOffsetY = 200;\n    cv::Scalar fillTolerance = 0; //No tolerance\n    int fillColor = 255; //Fill color is white\n   \n    //Get the dimensions of the image:\n    int targetCols = binDilation.cols;\n    int targetRows = binDilation.rows;\n\n    //Flood-fill at the four corners of the image:\n    cv::floodFill( binDilation, cv::Point( fillOffsetX, fillOffsetY ), fillColor, (cv::Rect*)0, fillTolerance, fillTolerance);\n    cv::floodFill( binDilation, cv::Point( fillOffsetX, targetRows - fillOffsetY ), fillColor, (cv::Rect*)0, fillTolerance, fillTolerance);\n    cv::floodFill( binDilation, cv::Point( targetCols - fillOffsetX, fillOffsetY ), fillColor, (cv::Rect*)0, fillTolerance, fillTolerance);\n    cv::floodFill( binDilation, cv::Point( targetCols - fillOffsetX, targetRows - fillOffsetY ), fillColor, (cv::Rect*)0, fillTolerance, fillTolerance);\n
Run Code Online (Sandbox Code Playgroud)\n

这也可以作为循环实现,就像“边界掩码”一样。执行此操作后,我得到这个掩码:

\n\n

接近了,对吧?现在,根据您的想象,一些垃圾可以在所有这些“清洁”操作中幸存下来。我建议应用区域过滤器。区域过滤器将删除阈值区域下的每个像素块。这很有用,因为立方体的斑点是掩模上最大的斑点,并且这些斑点肯定会在区域过滤器中幸存下来。

\n

无论如何,我只是对立方体的轮廓感兴趣;我不需要立方体内的那些线。我将把(倒置的)斑点膨胀出去,然后侵蚀回原始尺寸以消除立方体内部的线条:

\n
    //Get the inverted image:\n    cv::Mat cubeMask = 255 - binDilation;\n\n    //Set some really high iterations here:\n    int closeIterations = 50;\n\n    //Dilate\n    cv::morphologyEx( cubeMask, cubeMask, cv::MORPH_DILATE, SE, cv::Point(-1,-1), closeIterations );\n    //Erode:\n    cv::morphologyEx( cubeMask, cubeMask, cv::MORPH_ERODE, SE, cv::Point(-1,-1), closeIterations );\n
Run Code Online (Sandbox Code Playgroud)\n

这是一个关闭操作。这是一个相当残酷的结果,这就是应用它的结果。记得我之前颠倒了图像:

\n\n

这不是很好?查看立方体蒙版,此处叠加到原始 RBG 图像中:

\n\n

太好了,现在让我们得到这个斑点的边界框。做法如下:

\n
Get blob contour > Convert contour to bounding box\n
Run Code Online (Sandbox Code Playgroud)\n

这实现起来相当简单,Python等效的应该与此非常相似。首先,通过 获取轮廓 findContours。正如您所看到的,应该只有一个轮廓:立方体轮廓。接下来,使用 将轮廓转换为边界矩形boundingRect。这C++是代码:

\n
    //Lets get the blob contour:\n    std::vector< std::vector<cv::Point> > contours;\n    std::vector<cv::Vec4i> hierarchy;\n\n    cv::findContours( cubeMask, contours, hierarchy, CV_RETR_TREE, CV_CHAIN_APPROX_SIMPLE, cv::Point(0, 0) );\n\n    //There should be only one contour, the item number 0:\n    cv::Rect boundigRect = cv::boundingRect( contours[0] );\n
Run Code Online (Sandbox Code Playgroud)\n

这些是找到的轮廓(只有一个):

\n\n

一旦你把这个轮廓转换为边界矩形,你就可以得到这个漂亮的图像:

\n\n

啊,我们已经接近尾声了。由于所有正方形都具有相同的尺寸,并且您的图像似乎没有非常透视扭曲,因此我们可以使用边界矩形来估计正方形尺寸。所有正方形具有相同的宽度和高度,每个立方体宽度有 3 个正方形,每个立方体高度有 3 个正方形。

\n

将边界矩形分成 9 个相等的子正方形(或者,我称之为“网格”),并从边界框的坐标开始获取它们的尺寸和位置,如下所示:

\n
    //Number of squares or "grids"\n    int verticalGrids = 3;\n    int horizontalGrids = 3;\n\n    //Grid dimensions:\n    float gridWidth = (float)boundigRect.width / 3.0;\n    float gridHeight = (float)boundigRect.height / 3.0;\n\n    //Grid counter:\n    int gridCounter = 1;\n    \n    //Loop thru vertical dimension:\n    for ( int j = 0; j < verticalGrids; ++j ) {\n\n        //Grid starting Y:\n        int yo = j * gridHeight;\n\n        //Loop thru horizontal dimension:\n        for ( int i = 0; i < horizontalGrids; ++i ) {\n\n            //Grid starting X:\n            int xo = i * gridWidth;\n            \n            //Grid dimensions:\n            cv::Rect gridBox;\n            gridBox.x = boundigRect.x + xo;\n            gridBox.y = boundigRect.y + yo;\n            gridBox.width = gridWidth;\n            gridBox.height = gridHeight;\n\n            //Draw a rectangle using the grid dimensions:\n            cv::rectangle( testImage, gridBox, cv::Scalar(0,0,255), 5 );\n\n            //Int to string:\n            std::string gridCounterString = std::to_string( gridCounter );\n\n            //String position:\n            cv::Point textPosition;\n            textPosition.x = gridBox.x + 0.5 * gridBox.width;\n            textPosition.y = gridBox.y + 0.5 * gridBox.height;\n\n            //Draw string:\n            cv::putText( testImage, gridCounterString, textPosition, cv::FONT_HERSHEY_SIMPLEX,\n                         1, cv::Scalar(255,0,0), 3, cv::LINE_8, false );\n\n            gridCounter++;\n\n        }\n\n    }\n
Run Code Online (Sandbox Code Playgroud)\n

在这里,对于每个网格,我绘制了它的矩形并在其中心绘制了一个漂亮的数字。绘制矩形函数需要一个定义的矩形:左上起始坐标和矩形的宽度和高度,它们是使用类型gridBox变量定义的cv::Rect

\n

这是一个很酷的动画,展示了立方体如何分为 9 个网格:

\n\n

这里\xe2\x80\x99是最终图像!

\n\n

一些建议:

\n
    \n
  1. 您的源图像太大,请尝试将其调整为较小的尺寸,
    对其进行操作并缩小结果。
  2. \n
  3. 实施区域过滤器。它对于消除小\n像素斑点非常方便。
  4. \n
  5. 根据您的图像(我刚刚测试了您在问题中发布的图像)和相机引入的透视失真,简单的contour可能boundingRect还不够。在这种情况下,\n另一种方法是通过霍夫线检测\n获取立方体轮廓的四个点。
  6. \n
\n