catalinaの備忘録

ソフトウェアやハードウェアの備忘録。後で逆引きできるように。

古典的な手法を使ってMTGのカードを検出する

古典的な方法で画像中からMTGのカードを取り出す実験をしてみます。かたりぃなです。

BRISK特徴点のマッチング実験

わかっててダメもとでやってみました。

ダメでした。はい。

f:id:Catalina1344:20170610212059j:plain

説明省略。

BRISKの使い方間違えていた

前回のエントリでBRISK使ったつもりでしたが、間違えていました。 正しくはこうです

 auto brisk = cv::BRISK::create();
    std::vector<cv::KeyPoint> brisk_kp;
    cv::Mat descriptors;
    brisk->detect(img, brisk_kp);
    brisk->compute(img, brisk_kp, descriptors);
    cv::Mat brisk_img;
    cv::drawKeypoints(img, brisk_kp, brisk_img, 255, cv::DrawMatchesFlags::DRAW_RICH_KEYPOINTS);
    cv::imwrite("brisk.jpg", brisk_img);

BRISK特徴量は方向を持っているので、その方向も表示されます。(前回のエントリでは出てなかった)

f:id:Catalina1344:20170610220231j:plain

古典的な手法の概要

単純に画像全体に対して特徴点マッチングをしても、「本来マッチしたいのが何か」というのがあいまいになってしまうため、そのままではうまくいきません。

というわけで、基本に立ち戻ってまずは古典的な方法で検出を試みることにします。

古典的アルゴリズムの概要だけ簡単に説明します。

  1. 前処理としてガウシアンフィルタと二値化を行う
  2. ラベリングをして、それっぽい面積の部分だけ抽出する
  3. canny法による輪郭抽出
  4. hough変換でカードの輪郭を表す直線を求める
  5. 直線同士の交点を求める
  6. できた!

この方法でMTGのカードを複数枚撮影した画像を処理すると、こんな感じになりました。

f:id:Catalina1344:20170610213754p:plain

やってみた詳細

今回もopencvです。 カメラ画像だとテストが大変なので、再現実験をやりやすい静止画でまずは実験します。

前処理

ガウシアンフィルタで細かいノイズを除去して、thresholdで二値化します。 後から知りましたが、ガウシアンフィルタを加えてthresholdする関数もあるらしい。

 cv::Mat img = cv::imread("testimg.jpg", 0);
    cv::Mat gaus_img = img.clone();
    cv::GaussianBlur(img, gaus_img, cv::Size(5,5), 8 );

    cv::Mat threashold_img;
    cv::threshold(gaus_img, threashold_img, 85, 255, cv::THRESH_BINARY_INV);

上記マッチング実験の画像の右側画像に対して処理をかけると、こうなります。

f:id:Catalina1344:20170610212225p:plain

ラベルングと領域抽出

srcが二値化画像。baseimgが元画像です。 結果を可視化するにはbaseimgを書き換えたほうがわかりやすいのでこうしています。

今回はとりあえず静止画でのテストなので、面積500以下の領域は除外しています。

void labeling_test(cv::Mat src, cv::Mat baseimg)
{
    //ラべリング処理
    cv::Mat LabelImg;
    cv::Mat stats;
    cv::Mat centroids;
    int nLab = cv::connectedComponentsWithStats(src, LabelImg, stats, centroids);

    // 小さすぎるエリアを除外する
    auto is_exactly_area = [](cv::Mat stats, int index) {
        int *param = stats.ptr<int>(index);
        int area = param[cv::ConnectedComponentsTypes::CC_STAT_AREA];
        return area > 500 ? true : false; };

    // ラベリング結果の描画色を決定
    std::vector<cv::Vec3b> colors(nLab);
    colors[0] = cv::Vec3b(0, 0, 0);
    for (int i = 1; i < nLab; ++i) {
        colors[i] = cv::Vec3b((rand() & 255), (rand() & 255), (rand() & 255));
    }

    // ラベリング結果の描画
    cv::Mat Dst(src.size(), CV_8UC3);
    for (int i = 0; i < Dst.rows; ++i) {
        int *lb = LabelImg.ptr<int>(i);
        cv::Vec3b *pix = Dst.ptr<cv::Vec3b>(i);
        for (int j = 0; j < Dst.cols; ++j) {
            pix[j] = colors[lb[j]];
        }
    }

    // カードと思われる矩形領域から、傾いていないカードの4点を探す
    for (int i = 1; i < nLab; ++i) {
        if (is_exactly_area(stats, i) != true) { // 適度な大きさでないものは処理しない
            continue;
        }
        int *param = stats.ptr<int>(i);
        int x = param[cv::ConnectedComponentsTypes::CC_STAT_LEFT];
        int y = param[cv::ConnectedComponentsTypes::CC_STAT_TOP];
        int height = param[cv::ConnectedComponentsTypes::CC_STAT_HEIGHT];
        int width = param[cv::ConnectedComponentsTypes::CC_STAT_WIDTH];
        cv::Mat tmp_img(src, cv::Rect(x,y,width,height) );
        hough_test(tmp_img, cv::Point( x, y), baseimg);
    }
}

こうなりました。 f:id:Catalina1344:20170610212250p:plain

上記コードの最後のほうでtmp_imgを作っていますが、こんな感じで取れます。 問題領域外を切り出してしまっているケースもあるので、そのうち対応を考えます。

f:id:Catalina1344:20170610212416p:plain f:id:Catalina1344:20170610212425p:plain f:id:Catalina1344:20170610212430p:plain f:id:Catalina1344:20170610212437p:plain

Canny法でエッジ抽出を行う

ここまでで、二値画像からカードらしき部分は抽出できました。 この二値画像からカードをあらわす長方形を求めます。

二値画像のままではハフ変換をできないのでcannyでエッジ抽出します。

二値化しただけの画像のままでハフ変換できない理由は、有効ピクセルが多すぎるためです。(カード内側や、枠外に値0でないピクセルが多数存在している)

void hough_test(cv::Mat src, cv::Point ofs, cv::Mat base_img)
{
    std::vector<cv::Vec4i>    lines;
    double in_rho = 4.0f;     // 距離分解能
    double in_theta = 3.14f / 180.0f;        // 角度分解能
    int threash = std::min(src.rows, src.cols) / 2;       // 有効投票数
    double min_len = std::min(src.rows, src.cols)/2;
    double max_len = src.rows + src.cols;

    // そのままの二値画像ではハフ変換ができない。
    // cannyを使う理由:
    //   ただの二値画像からのハフ変換では、直線は無限に求められる。(実運用上は分解能が上限となる)
    //   無限になってしまうのは、ハフ変換では値1のピクセルを通る直線を探そうとするため。
    //   すなわち、カードを検出するには領域の境界線(エッジ)のみの二値画像が良い。

    // Cannyエッジ抽出器でエッジを抽出する
    cv::Mat canny_img;
    cv::Canny(src, canny_img, 50.0, 200.0, 3);

これでエッジ画像ができました。 f:id:Catalina1344:20170610212552p:plain

どうでもいいことですが、上記画像とコードの位置関係の問題で、本文のほうが傾いているように見えてしまいます。

目の錯覚ですね。

ハフ変換による直線抽出

MTGのカードを撮影した画像からエッジ抽出した画像(上記)に対して直線検出を施せば、おそらくカードの枠を表す直線が取れるだろうことは予想できます。

というわけで、直線検出です。

opencvのハフ変換には、確率的ハフ変換と標準ハフ変換によるものが実装されています。 ここではアルゴリズムの詳細は置いといて、関数のインターフェースとして2つの違いを考えると、

  • 確率的ハフ変換は"線分"を求める
  • 標準ハフ変換は"直線"を求める

ということです。 今回は直線のほうが都合が良いので、標準ハフ変換で直線検出を行います。

#if 0
   // 確率的ハフ変換による"線分"の検出
   cv::HoughLinesP(canny_img, lines, in_rho, in_theta, threash, min_len, max_len);
   for (auto l : lines) {
       int x1 = l[0] + ofs.x;  // 始点 x 
       int y1 = l[1] + ofs.y;  // 始点 y
       int x2 = l[2] + ofs.x;  // 終点 x
       int y2 = l[3] + ofs.y;  // 終点 y
       cv::line(base_img, cv::Point(x1, y1), cv::Point(x2, y2), cv::Scalar(0, 0, 244));
   }
#endif

    // ハフ変換による"直線"の検出
    std::vector<cv::Vec2f>    lines2;
    cv::HoughLines(canny_img, lines2, 1, CV_PI/180, threash/2);
    for (auto l : lines2) {
        auto rho = l[0];      // 画像左上原点からの距離
        auto theta = l[1];        // 角度
        auto a = cos(theta);
        auto b = sin(theta);
        auto x0 = a * rho;
        auto y0 = b * rho;
        auto pt1_x = cvRound(x0 + 1000 * (-b));
        auto pt1_y = cvRound(y0 + 1000 * (a));
        auto pt2_x = cvRound(x0 - 1000 * (-b));
        auto pt2_y = cvRound(y0 - 1000 * (a));
        cv::line(base_img, cv::Point(pt1_x, pt1_y)+ofs, cv::Point(pt2_x, pt2_y)+ofs, cv::Scalar(0, 0, 255 ) );
    }

これで直線検出ができました。 たとえば一枚目のカードのエッジ画像に対する直線を表示するとこんな感じになります。

f:id:Catalina1344:20170610212944p:plain

直線の交点を求める

直線検出までできました。ただし、1つのエッジ直線に対して複数の直線が検出されていたりして問題がややこしいので、1つの仮定を置きます。 ここまでの処理で求めた直線について、「カードの外枠を表現する長方形をなすための4つの完璧な直線である」と仮定してみます。

そうしたとき、それら直線の交点はおそらく4つは存在すると考えられます。(歪みパラメータや透視投影変換の関係もあって5つ以上になる可能性もありますが、いまは考慮しません。)

というわけで、直線と直線の交点を求めます。

数学的に直線の交点を求める

ここはちょっとだけ数学の力を借ります。

直線の方程式は誰でも知っているものでは

{y = ax + b}

なんかがあります。

次に、2本の直線があるということは、たとえば

{y = 4x + 2}

{y = -2x + 10}

という2つの式が与えられたとき、その交点を求めよ。といったお話です。

連立方程式ですね。つまりこれを解けばいいのです。 ここまで中学数学です。

次に、ハフ変換で得られた直線は上記の表現ではありません。 ハフ変換の直線は、

{r = x*\cos(\theta) + y*\sin(\theta) }

で表現される式で、パラメータは距離rと傾きthetaです。

小難しく書きましたが、直線の表現方法(方程式)が変わっただけです。 つまり、「連立方程式を解くことで交点が得られる」という本質は変わっていません。

この連立方程式をプログラムで解くことを考えます。

OpenCVにも基本的な行列計算は入っているのでこれで済ませることにします。

さて、上記方程式の左辺と右辺をそれぞれ次のように表現することを考えます。

  • 左辺をlhandsという2x1行列で表現する
  • 右辺をrhandsという2x2行列で表現する

これはハフ変換で表現される直線の式をそのまま行列に代入しただけです。 この行列を計算することで、2つの値が得られます。すなわち交点を表すピクセル座標xとyです。

というわけでopencvに行列分解を任せましょう。 検出された直線すべての交点を求めます。

 for(int x = 0;x < lines2.size(); x++){
        for (int y = 0; y < lines2.size(); y++) {
            auto v0 = lines2[x];
            auto v3 = lines2[y];
            float lhandvalue[] = { sin(v0[1]), cos(v0[1]), sin(v3[1]), cos(v3[1]) };
            cv::Mat lhand(2, 2, CV_32FC1, lhandvalue);
            float rhandvalue[] = { v0[0], v3[0] };
            cv::Mat rhand(2, 1, CV_32FC1, rhandvalue);

            cv::Mat ans;
            cv::solve(lhand, rhand, ans);
            std::cout << ans;
            cv::Point   p(ans);
            cv::Point   a(p.y, p.x);
            a += ofs;

            // 交点を書く
            cv::circle(base_img, a, 8, cv::Scalar(0, 255, 255), 3);
        }
    }

opencvにはsolveという便利な関数があるので、これで済ませました。 行列分解の方法は第5引数(ここでは省略したのでLU分解になります)で指定できます。

ofsは原画像からカードのバウンディングボックスまでを表現するオフセットです。 つまり原画像に対して交点を書くので、このofsを加味するだけで良いです。

というわけで、各カード領域に対して直線の交点を求めていった結果こんなのが取れました。

f:id:Catalina1344:20170610213754p:plain

なんとなくよさげですね。

感想と今後の展望

これでカードのコーナー検出っぽいことはできました。

あとはコーナーと候補の点群から、それらを含む最大のバウンディングボックスをとればよさそうです。

あとは

  • それが本当にMTGのカードかどうか

を判別できれば目的に一歩近づけそうです。

この記事を書き終わってから気づきましたが、直線の交点ならベクトルの外積使ってごにょごにょしてあげればよかったのかもしれません。

それでは今回はこれくらいで。