OpenCV を使用したコインのテンプレート マッチング

パターン マッチングを行う 1 つの方法は、cv::matchTemplate を使用することです。

これは、入力画像と、テンプレートとして機能する小さな画像を受け取ります。テンプレートと重複した画像領域を比較して、テンプレートと重複した領域との類似性を計算します。比較を計算する方法はいくつかあります。
このメソッドは、スケールまたは方向の不変性を直接サポートしていません。しかし、候補を参照サイズにスケーリングし、いくつかのローテーションされたテンプレートに対してテストすることで、これを克服することができます.

この手法の詳細な例は、50c コインの存在と位置を検出するために示されています。他のコインにも同じ手順を適用できます。
2 つのプログラムがビルドされます。 1 つは、50c コインの大きな画像テンプレートからテンプレートを作成するためのものです。もう 1 つは、これらのテンプレートとコインの画像を入力として受け取り、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 を使用 画像を読むこと。データが読み取られ、画像に 3 つのチャネルがあることを確認し、読み取った画像を返します。

#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 チャネルを 2 値化し、これら 3 つの 2 値化画像の AND を実行し、CLOSE モルフォロジー演算を実行してマスクを生成します。
3 つのデバッグ行は、計算されたマスクをコピー操作のマスクとして使用して、元のイメージを黒いイメージにコピーします。これは、しきい値の適切な値を選択するのに役立ちました。

ここでは、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 マスクの重心とその半径を計算します。これらの 3 つの値を { x, y, radius } の形式で 1 ​​行のマットに返します。
cv::moments を使用しています これは、ポリゴンまたはラスタライズされた形状の 3 次までのすべてのモーメントを計算します。この場合のラスタライズされた形状。私たちはそれらすべての瞬間に興味があるわけではありません。しかし、そのうちの 3 つがここで役に立ちます。 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 によって計算された重心と半径を使用します 入力画像の関心領域とマスクの関心領域を取得して、そのような領域の中心がコインとマスクの中心でもあり、領域の辺の長さがコインの直径と等しくなるようにする/マスク。
これらの領域は、後で固定の 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 を低く定義すると、方向の不変性が向上しますが、計算コストが高くなることも意味します。

テンプレート作成プログラム全体をここからダウンロードできます。
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;
}

ここでの目標は、テンプレートと検査対象の画像を読み取り、テンプレートに一致するコインの場所を特定することです。

最初に、前のプログラムで作成したすべてのテンプレート画像を画像のベクトルに読み込みます。
次に、検査する画像を読み取ります。
次に、テンプレート メーカーとまったく同じ機能を使用して、検査対象の画像を 2 値化します。
getCandidates 一緒に多角形を形成している点のグループを見つけます。これらのポリゴンのそれぞれがコインの候補です。そして、それらはすべて再スケーリングされ、テンプレートと同じサイズの正方形に中央に配置されるため、スケーリングに不変な方法でマッチングを実行できます。
デバッグおよびチューニングの目的で、取得した候補イメージを保存します。
matchCandidates は、それぞれの最適一致の結果を格納するすべてのテンプレートを使用して、各候補を照合します。いくつかの向きのテンプレートがあるため、向きに対する不変性が提供されます。
各候補のスコアが出力されるので、50c コインと非 50c コインを分けるしきい値を決定できます。
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 に似ています .ただし、1 つではなく複数の画像を読み込み、std::vector に保存します。 .

loadImage テンプレートメーカーとまったく同じです。

createMask テンプレートメーカーとまったく同じです。今回はコインが複数ある画像に適用します。 2 値化のしきい値は 50c を 2 値化するために選択されたものであり、画像内のすべてのコインを 2 値化するには適切に機能しないことに注意してください。しかし、プログラムの目的は 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 等高線のベクトルを返します。各輪郭自体は、検出されたポリゴンの外側の線を形成する点のベクトルです。
各多角形は、各候補コインの領域を区切ります。
輪郭ごとに 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 デバッグ目的のために計算された候補を保存します。また、それらの画像をここに投稿できるようにします。

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_SQDIFF の最高スコアを保存します。 と CV_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 は、2 つの入力イメージを使用します。2 番目のイメージは、最初のイメージより小さいか同じサイズです。
一般的な使用例は、小さなテンプレート (2 番目のパラメーター) をより大きな画像 (1 番目のパラメーター) と照合することです。結果は、画像に沿ってテンプレートが一致する float の 2 次元マットです。この float の Mat の最大値 (メソッドによっては最小値) を特定すると、第 1 パラメーターの画像でテンプレートの最適な候補位置が得られます。
ただし、画像内でテンプレートを見つけることには関心がありません。候補の座標は既に取得されています。
私たちが望むのは、候補とテンプレートの間の類似度を測定することです。これが cv::matchTemplate を使用する理由です あまり一般的ではない方法で。これは、2 番目のパラメーター テンプレートと同じサイズの 1 番目のパラメーター イメージを使用して行います。この状況では、結果はサイズ 1x1 のマットになります。そして、その Mat の単一の値は、類似度 (または非類似度) のスコアです。

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

候補者ごとに得られたスコアを印刷します。
この表では、cv::matchTemplate で使用可能な各メソッドのスコアを確認できます。最高のスコアは緑色です。

CCORR と CCOEFF は間違った結果を返すため、これら 2 つは破棄されます。残りの 4 つの方法のうち、2 つの SQDIFF 方法は、最良の一致 (50c である) と 2 番目に最適な方法 (50c ではない) の間の相対的な差が大きいものです。それが私がそれらを選んだ理由です。
SQDIFF_NORMED を選択しましたが、明確な理由はありません。実際にメソッドを選択するには、1 つだけでなく、より多くのサンプルでテストする必要があります。
この方法の場合、作業しきい値は 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 );

結果は次のとおりです。

コイン マッチャーのコード全体は、ここからダウンロードできます。

これは良い方法ですか?

それはわかりにくい。
方法は一貫しています。提供されたサンプルと入力画像の 50c コインを正しく検出します。
ただし、適切なサンプルサイズでテストされていないため、この方法が堅牢であるかどうかはわかりません.さらに重要なことは、プログラムがコーディングされていたときに利用できなかったサンプルに対してテストすることです。これは、十分に大きなサンプルサイズで実行した場合の堅牢性の真の尺度です.
銀貨からの誤検知がない方法にはかなり自信があります。しかし、20c のような他の銅貨についてはよくわかりません。得られたスコアからわかるように、20c コインは 50c と非常によく似たスコアを取得します。
また、さまざまな照明条件の下で偽陰性が発生する可能性も十分にあります。これは、コインの写真を撮って数えるための機械を設計している場合など、照明条件を制御できる場合は回避できるものであり、回避すべきものです.

この方法が機能する場合、コインの種類ごとに同じ方法を繰り返して、すべてのコインを完全に検出できます。

この回答のコードは、Free Software Foundation によって公開されている GNU General Public License の条件、ライセンスのバージョン 3、または (オプションで) それ以降のバージョンの条件の下でも利用できます。