使用 OpenCV 进行硬币模板匹配

Template Matching for Coins with OpenCV

我正在进行一个项目,该项目将自动计算输入图像中硬币的价值。到目前为止,我已经使用带有边缘检测的一些预处理和霍夫变换对硬币进行了分割。

我的问题是如何从这里开始?我需要根据一些先前存储的特征对分割后的图像进行一些模板匹配。我该怎么做。

我还读到了一个叫做 K-Nearest Neighbours 的东西,我觉得我应该使用它。但是我不太确定如何去使用它。

我关注的研究文章:

(1) 使用Hough Transform Algorithm求硬币边。 (2) 确定硬币的原点。我不知道你会怎么做。 (3) 您可以使用 kKNN Algorithm 来比较硬币的直径或。不要忘记设置偏置值。

您可以尝试设置硬币图像训练集并生成 SIFT/SURF 等描述符。 (编辑:OpenCV feature detectors 使用这些数据,您可以设置 kNN 分类器,使用硬币值作为训练标签。

对分割的硬币图像执行 kNN 分类后,您的分类结果将产生硬币值。

如果您正确检测所有硬币最好使用大小(径向)RGB特征来识别它的价值。连接这些特征不是一个好主意,因为它们的数量不相等(大小是一个数字,而 RGB 特征的数量远大于一个)。为此,我建议您使用两个 classifier。一个用于尺寸,另一个用于 RGB 特征。

  • 你必须class将所有硬币变成例如 3(这取决于类型 你的硬币)大小 class。你可以用一个简单的 1NN 来做到这一点 classifier(只需计算测试币的径向并class化为 最近的预定义径向线)

  • 然后你应该有一些每个大小的模板,并使用模板匹配来识别它的值。(所有模板和检测到的硬币都应该调整到特定的大小。例如(100,100))对于模板 匹配你可以使用matchtemplate函数。我认为 CV_TM_CCOEFF 方法可能是最好的方法,但您可以测试所有方法 得到一个好的结果。 (请注意,您不需要在图像上搜索硬币,因为您之前已检测到硬币,正如您在 题。你只需要使用这个函数得到一个数字作为两个图像之间的 similarity/difference 并且 class 将测试硬币验证为 class 相似性最大化或差异最小化)

EDIT1:您应该在每个 class 的模板中进行所有旋转,以补偿测试币的旋转。

EDIT2:如果所有硬币的大小都不同,第一步就足够了。否则你应该将相似的大小修补为一个 class 并 class 使用第二步(RGB 特征)验证测试硬币。

进行模式匹配的一种方法是使用 cv::matchTemplate.

这需要一个输入图像和一个较小的图像作为模板。它将模板与重叠图像区域进行比较,计算模板与重叠区域的相似度。有几种计算比较的方法可用。
此方法不直接支持比例或方向不变性。但是可以通过将候选对象缩放到参考大小并针对多个旋转模板进行测试来克服这个问题。

显示了此技术的详细示例,用于检测 50 美分硬币的存在和位置。相同的程序可以应用于其他硬币。
将构建两个程序。一种是从 50c 硬币的大图像模板创建模板。另一个将这些模板以及带有硬币的图像作为输入,并将输出一个标记有 50c 硬币的图像。

模板制作工具

#define TEMPLATE_IMG "50c.jpg"
#define ANGLE_STEP 30
int main()
{
    cv::Mat image = loadImage(TEMPLATE_IMG);
    cv::Mat mask = createMask( image );
    cv::Mat loc = locate( mask );
    cv::Mat imageCS;
    cv::Mat maskCS;
    centerAndScale( image, mask, loc, imageCS, maskCS);
    saveRotatedTemplates( imageCS, maskCS, ANGLE_STEP );
    return 0;
}

我们在这里加载将用于构建模板的图像。
将其分段以创建遮罩。
找到所述面具的质心。
然后我们重新缩放并复制那个面具和硬币,以便它们占据一个固定大小的正方形,其中正方形的边缘接触面具和硬币的圆周。也就是说,正方形的边长与缩放后的蒙版或硬币图像的直径相同(以像素为单位)。
最后,我们保存硬币的缩放和居中图像。我们保存它的更多副本,以固定角度增量旋转。

cv::Mat loadImage(const char* name)
{
    cv::Mat image;
    image = cv::imread(name);
    if ( image.data==NULL || image.channels()!=3 )
    {
        std::cout << name << " could not be read or is not correct." << std::endl;
        exit(1);
    }
    return image;
}

loadImage使用cv::imread读取图像。验证数据已被读取并且图像具有三个通道和 returns 读取的图像。

#define THRESHOLD_BLUE  130
#define THRESHOLD_TYPE_BLUE  cv::THRESH_BINARY_INV
#define THRESHOLD_GREEN 230
#define THRESHOLD_TYPE_GREEN cv::THRESH_BINARY_INV
#define THRESHOLD_RED   140
#define THRESHOLD_TYPE_RED   cv::THRESH_BINARY
#define CLOSE_ITERATIONS 5
cv::Mat createMask(const cv::Mat& image)
{
    cv::Mat channels[3];
    cv::split( image, channels);
    cv::Mat mask[3];
    cv::threshold( channels[0], mask[0], THRESHOLD_BLUE , 255, THRESHOLD_TYPE_BLUE );
    cv::threshold( channels[1], mask[1], THRESHOLD_GREEN, 255, THRESHOLD_TYPE_GREEN );
    cv::threshold( channels[2], mask[2], THRESHOLD_RED  , 255, THRESHOLD_TYPE_RED );
    cv::Mat compositeMask;
    cv::bitwise_and( mask[0], mask[1], compositeMask);
    cv::bitwise_and( compositeMask, mask[2], compositeMask);
    cv::morphologyEx(compositeMask, compositeMask, cv::MORPH_CLOSE,
            cv::Mat(), cv::Point(-1, -1), CLOSE_ITERATIONS );

    /// Next three lines only for debugging, may be removed
    cv::Mat filtered;
    image.copyTo( filtered, compositeMask );
    cv::imwrite( "filtered.jpg", filtered);

    return compositeMask;
}

createMask做模板的分割。它对每个 BGR 通道进行二值化,对这三个二值化图像进行 AND 运算,并执行 CLOSE 形态学运算以生成掩码。
三个调试行使用计算出的掩码作为复制操作的掩码,将原始图像复制为黑色图像。这有助于为阈值选择合适的值。

这里我们可以看到在createMask

中创建的蒙版过滤后的50c图像

cv::Mat locate( const cv::Mat& mask )
{
  // Compute center and radius.
  cv::Moments moments = cv::moments( mask, true);
  float area = moments.m00;
  float radius = sqrt( area/M_PI );
  float xCentroid = moments.m10/moments.m00;
  float yCentroid = moments.m01/moments.m00;
  float m[1][3] = {{ xCentroid, yCentroid, radius}};
  return cv::Mat(1, 3, CV_32F, m);
}

locate 计算遮罩的质心及其半径。以 { x, y, radius } 的形式在单行垫中返回这 3 个值。
它使用 cv::moments 来计算多边形或栅格化形状的所有三阶矩。在我们的例子中是一个栅格化的形状。我们对所有这些时刻都不感兴趣。但是其中三个在这里很有用。 M00 是蒙版的面积。并且可以从 m00、m10 和 m01 计算质心。

void centerAndScale(const cv::Mat& image, const cv::Mat& mask,
        const cv::Mat& characteristics,
        cv::Mat& imageCS, cv::Mat& maskCS)
{
    float radius = characteristics.at<float>(0,2);
    float xCenter = characteristics.at<float>(0,0);
    float yCenter = characteristics.at<float>(0,1);
    int diameter = round(radius*2);
    int xOrg = round(xCenter-radius);
    int yOrg = round(yCenter-radius);
    cv::Rect roiOrg = cv::Rect( xOrg, yOrg, diameter, diameter );
    cv::Mat roiImg = image(roiOrg);
    cv::Mat roiMask = mask(roiOrg);
    cv::Mat centered = cv::Mat::zeros( diameter, diameter, CV_8UC3);
    roiImg.copyTo( centered, roiMask);
    cv::imwrite( "centered.bmp", centered); // debug
    imageCS.create( TEMPLATE_SIZE, TEMPLATE_SIZE, CV_8UC3);
    cv::resize( centered, imageCS, cv::Size(TEMPLATE_SIZE,TEMPLATE_SIZE), 0, 0 );
    cv::imwrite( "scaled.bmp", imageCS); // debug

    roiMask.copyTo(centered);
    cv::resize( centered, maskCS, cv::Size(TEMPLATE_SIZE,TEMPLATE_SIZE), 0, 0 );
}

centerAndScale使用locate计算的质心和半径得到输入图像的感兴趣区域和mask的感兴趣区域,这样这些区域的中心也是硬币和面具的中心以及区域的边长等于 coin/mask.
的直径 这些区域后来被缩放到固定的 TEMPLATE_SIZE。这个缩放区域将是我们的参考模板。当稍后在匹配程序中我们想要检查检测到的候选硬币是否是该硬币时,我们也会在执行模板匹配之前采用候选硬币的区域,以相同的方式居中和缩放该候选硬币。这样我们就实现了尺度不变性。

void saveRotatedTemplates( const cv::Mat& image, const cv::Mat& mask, int stepAngle )
{
    char name[1000];
    cv::Mat rotated( TEMPLATE_SIZE, TEMPLATE_SIZE, CV_8UC3 );
    for ( int angle=0; angle<360; angle+=stepAngle )
    {
        cv::Point2f center( TEMPLATE_SIZE/2, TEMPLATE_SIZE/2);
        cv::Mat r = cv::getRotationMatrix2D(center, angle, 1.0);

        cv::warpAffine(image, rotated, r, cv::Size(TEMPLATE_SIZE, TEMPLATE_SIZE));
        sprintf( name, "template-%03d.bmp", angle);
        cv::imwrite( name, rotated );

        cv::warpAffine(mask, rotated, r, cv::Size(TEMPLATE_SIZE, TEMPLATE_SIZE));
        sprintf( name, "templateMask-%03d.bmp", angle);
        cv::imwrite( name, rotated );
    }
}

saveRotatedTemplates 保存之前计算的模板。
但是它保存了它的几个副本,每个副本旋转了一个角度,定义在 ANGLE_STEP 中。这样做的目的是提供方向不变性。我们定义的 stepAngle 越低,我们获得的方向不变性越好,但它也意味着更高的计算成本。

您可以下载整个模板制作程序here
当 运行 和 ANGLE_STEP 为 30 时,我得到以下 12 个模板:

模板匹配。

#define INPUT_IMAGE "coins.jpg"
#define LABELED_IMAGE "coins_with50cLabeled.bmp"
#define LABEL "50c"
#define MATCH_THRESHOLD 0.065
#define ANGLE_STEP 30
int main()
{
    vector<cv::Mat> templates;
    loadTemplates( templates, ANGLE_STEP );
    cv::Mat image = loadImage( INPUT_IMAGE );
    cv::Mat mask = createMask( image );
    vector<Candidate> candidates;
    getCandidates( image, mask, candidates );
    saveCandidates( candidates ); // debug
    matchCandidates( templates, candidates );
    for (int n = 0; n < candidates.size( ); ++n)
        std::cout << candidates[n].score << std::endl;
    cv::Mat labeledImg = labelCoins( image, candidates, MATCH_THRESHOLD, false, LABEL );
    cv::imwrite( LABELED_IMAGE, labeledImg );
    return 0;
}

这里的目标是读取模板和要检查的图像,并确定与我们的模板匹配的硬币的位置。

首先,我们将在上一个程序中生成的所有模板图像读入一个图像向量。
然后我们读取要检查的图像。
然后我们使用与模板制作器中完全相同的功能对要检查的图像进行二值化。
getCandidates 定位一起形成多边形的点组。这些多边形中的每一个都是硬币的候选者。并且所有这些都被重新缩放并以与我们的模板大小相等的正方形为中心,以便我们可以以缩放不变的方式进行匹配。
我们保存获得的候选图像以用于调试和调整目的。
matchCandidates 将每个候选人与存储每个最佳匹配结果的所有模板进行匹配。由于我们有多个方向的模板,这提供了方向不变性。
每个候选人的分数都会打印出来,这样我们就可以决定将 50 分硬币与非 50 分硬币分开的阈值。
labelCoins 复制原始图像并在得分大于(或小于某些方法)MATCH_THRESHOLD 中定义的阈值的图像上绘制标签。
最后我们将结果保存在 .BMP

void loadTemplates(vector<cv::Mat>& templates, int angleStep)
{
    templates.clear( );
    for (int angle = 0; angle < 360; angle += angleStep)
    {
        char name[1000];
        sprintf( name, "template-%03d.bmp", angle );
        cv::Mat templateImg = cv::imread( name );
        if (templateImg.data == NULL)
        {
            std::cout << "Could not read " << name << std::endl;
            exit( 1 );
        }
        templates.push_back( templateImg );
    }
}

loadTemplates 类似于 loadImage。但它会加载多张图片而不是一张图片,并将它们存储在 std::vector.

loadImage 和模板制作器里的完全一样

createMask 也和模板制作器中的完全一样。这次我们将它应用到有几个硬币的图像上。应该注意的是,选择了二值化阈值来对 50c 进行二值化,这些阈值无法正常工作以对图像中的所有硬币进行二值化。但这无关紧要,因为程序 objective 仅用于识别 50c 硬币。只要这些被正确分割,我们就可以了。如果在此细分中丢失了一些硬币,它实际上对我们有利,因为我们将节省评估它们的时间(只要我们只丢失不是 50c 的硬币)。

typedef struct Candidate
{
    cv::Mat image;
    float x;
    float y;
    float radius;
    float score;
} Candidate;

void getCandidates(const cv::Mat& image, const cv::Mat& mask,
        vector<Candidate>& candidates)
{
    vector<vector<cv::Point> > contours;
    vector<cv::Vec4i> hierarchy;
    /// Find contours
    cv::Mat maskCopy;
    mask.copyTo( maskCopy );
    cv::findContours( maskCopy, contours, hierarchy, CV_RETR_TREE, CV_CHAIN_APPROX_SIMPLE, cv::Point( 0, 0 ) );
    cv::Mat maskCS;
    cv::Mat imageCS;
    cv::Scalar white = cv::Scalar( 255 );
    for (int nContour = 0; nContour < contours.size( ); ++nContour)
    {
        /// Draw contour
        cv::Mat drawing = cv::Mat::zeros( mask.size( ), CV_8UC1 );
        cv::drawContours( drawing, contours, nContour, white, -1, 8, hierarchy, 0, cv::Point( ) );

        // Compute center and radius and area.
        // Discard small areas.
        cv::Moments moments = cv::moments( drawing, true );
        float area = moments.m00;
        if (area < CANDIDATES_MIN_AREA)
            continue;
        Candidate candidate;
        candidate.radius = sqrt( area / M_PI );
        candidate.x = moments.m10 / moments.m00;
        candidate.y = moments.m01 / moments.m00;
        float m[1][3] = {
            { candidate.x, candidate.y, candidate.radius}
        };
        cv::Mat characteristics( 1, 3, CV_32F, m );
        centerAndScale( image, drawing, characteristics, imageCS, maskCS );
        imageCS.copyTo( candidate.image );
        candidates.push_back( candidate );
    }
}

getCandidates 的核心是 cv::findContours,它可以找到输入图像中存在的区域的轮廓。这是之前计算的掩码。
findContours returns 轮廓向量。每个轮廓本身都是构成检测到的多边形外线的点向量。
每个多边形划定每个候选硬币的区域。
对于每个轮廓,我们使用 cv::drawContours 在黑色图像上绘制填充的多边形。
对于这个绘制的图像,我们使用前面解释的相同过程来计算多边形的质心和半径。
我们使用 centerAndScale,与模板制作器中使用的相同功能,将包含在该多边形中的图像居中并缩放到与我们的模板具有相同大小的图像中。这样我们以后就可以对不同比例的照片中的硬币进行正确的匹配。
这些候选硬币中的每一个都被复制到一个候选结构中,其中包含:

  • 候选图片
  • 质心的 x 和 y
  • 半径
  • 得分

getCandidates 计算除分数之外的所有这些值。
在组成候选之后,它被放入一个候选向量中,这是我们从 getCandidates.

得到的结果

这些是获得的 4 个候选人:

void saveCandidates(const vector<Candidate>& candidates)
{
    for (int n = 0; n < candidates.size( ); ++n)
    {
        char name[1000];
        sprintf( name, "Candidate-%03d.bmp", n );
        cv::imwrite( name, candidates[n].image );
    }
}

saveCandidates 保存计算出的候选项,以备调试之用。而且这样我就可以 post 那些图片了。

void matchCandidates(const vector<cv::Mat>& templates,
        vector<Candidate>& candidates)
{
    for (auto it = candidates.begin( ); it != candidates.end( ); ++it)
        matchCandidate( templates, *it );
}

matchCandidates 只需为每个候选人调用 matchCandidate。完成后,我们将计算所有候选人的分数。

void matchCandidate(const vector<cv::Mat>& templates, Candidate& candidate)
{
    /// For SQDIFF and SQDIFF_NORMED, the best matches are lower values. For all the other methods, the higher the better
    candidate.score;
    if (MATCH_METHOD == CV_TM_SQDIFF || MATCH_METHOD == CV_TM_SQDIFF_NORMED)
        candidate.score = FLT_MAX;
    else
        candidate.score = 0;
    for (auto it = templates.begin( ); it != templates.end( ); ++it)
    {
        float score = singleTemplateMatch( *it, candidate.image );
        if (MATCH_METHOD == CV_TM_SQDIFF || MATCH_METHOD == CV_TM_SQDIFF_NORMED)
        {
            if (score < candidate.score)
                candidate.score = score;
        }
        else
        {
            if (score > candidate.score)
                candidate.score = score;
        }
    }
}

matchCandidate 有一个候选和所有模板作为输入。它的目标是将每个模板与候选人进行匹配。这项工作委托给 singleTemplateMatch.
我们存储获得的最佳分数,CV_TM_SQDIFFCV_TM_SQDIFF_NORMED是最小的,其他匹配方法是最大的。

float singleTemplateMatch(const cv::Mat& templateImg, const cv::Mat& candidateImg)
{
    cv::Mat result( 1, 1, CV_8UC1 );
    cv::matchTemplate( candidateImg, templateImg, result, MATCH_METHOD );
    return result.at<float>( 0, 0 );
}

singleTemplateMatch 执行匹配。
cv::matchTemplate 使用两个输入图像,第二个图像小于或等于第一个图像。
常见的用例是将一个小模板(第二个参数)与一个较大的图像(第一个参数)进行匹配,结果是一个二维浮点数垫,模板沿图像进行匹配。定位这个浮动垫的最大值(或最小值,取决于方法),我们在第一个参数的图像中获得模板的最佳候选位置。
但是我们对在图像中定位模板不感兴趣,我们已经有了候选人的坐标。
我们想要的是在我们的候选者和模板之间获得相似性的度量。这就是为什么我们以一种不太常见的方式使用 cv::matchTemplate 的原因;我们使用大小等于第二个参数模板的第一个参数图像来执行此操作。在这种情况下,结果是大小为 1x1 的垫子。该 Mat 中的单个值是我们的相似性(或不相似性)分数。

for (int n = 0; n < candidates.size( ); ++n)
    std::cout << candidates[n].score << std::endl;

我们打印每位考生的分数。
在此 table 中,我们可以看到 cv::matchTemplate 可用的每种方法的分数。最好的分数是绿色的。

CCORR 和 CCOEFF 给出了错误的结果,所以这两个被丢弃。在其余 4 种方法中,两种 SQDIFF 方法是最佳匹配(50c)和第二最佳(不是 50c)之间相对差异较大的方法。这就是我选择它们的原因。
我选择了 SQDIFF_NORMED 但没有充分的理由。为了真正选择一种方法,我们应该用更多的样本进行测试,而不仅仅是一个。
对于这种方法,工作阈值可以是 0.065。选择合适的阈值也需要很多样本。

bool selected(const Candidate& candidate, float threshold)
{
    /// For SQDIFF and SQDIFF_NORMED, the best matches are lower values. For all the other methods, the higher the better
    if (MATCH_METHOD == CV_TM_SQDIFF || MATCH_METHOD == CV_TM_SQDIFF_NORMED)
        return candidate.score <= threshold;
    else
        return candidate.score>threshold;
}

void drawLabel(const Candidate& candidate, const char* label, cv::Mat image)
{
    int x = candidate.x - candidate.radius;
    int y = candidate.y;
    cv::Point point( x, y );
    cv::Scalar blue( 255, 128, 128 );
    cv::putText( image, label, point, CV_FONT_HERSHEY_SIMPLEX, 1.5f, blue, 2 );
}

cv::Mat labelCoins(const cv::Mat& image, const vector<Candidate>& candidates,
        float threshold, bool inverseThreshold, const char* label)
{
    cv::Mat imageLabeled;
    image.copyTo( imageLabeled );

    for (auto it = candidates.begin( ); it != candidates.end( ); ++it)
    {
        if (selected( *it, threshold ))
            drawLabel( *it, label, imageLabeled );
    }

    return imageLabeled;
}

labelCoins 在分数大于(或小于取决于方法)阈值的候选位置绘制标签字符串。 最后将 labelCoins 的结果保存为

cv::imwrite( LABELED_IMAGE, labeledImg );

结果为:

可以下载硬币匹配器的完整代码here

这个方法好吗?

这很难说。
方法是一致的。它正确地检测了示例和提供的输入图像的 50c 硬币。
但我们不知道该方法是否稳健,因为它没有用适当的样本量进行测试。更重要的是针对程序编码时不可用的样本对其进行测试,这是在使用足够大的样本量时真正衡量稳健性的方法。
我对这种没有银币误报的方法很有信心。但我不太确定其他铜币,如 20c。正如我们从获得的分数中看到的那样,20c 硬币的分数与 50c 非常相似。
在不同的光照条件下也很可能会出现假阴性。如果我们可以控制照明条件,例如当我们设计一台机器来拍摄硬币照片并计算它们时,这是可以而且应该避免的事情。

如果该方法有效,则可以对每种类型的硬币重复相同的方法,从而全面检测所有硬币。


此答案中的代码也可根据自由软件基金会发布的 GNU General Public License 条款获得,即许可版本 3 或(由您选择)任何更高版本。