catalinaの備忘録

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

UWPで非同期taskを書くときに気を付けること

まだまだtaskの操作に慣れていません。かたりぃなです。

今日はUWPのコードをC++/cxで書くにあたって、詰まったポイントと解決策を書いてみます。

UWPでの非同期taskとは

ムーアの法則の限界が叫ばれてからCPUはマルチコア時代に入っています。モバイル端末でさえも。

CPU資源の有効活用を考えたとき、マルチスレッド・マルチプロセスなコードにすればCPU資源を有効活用できるよねと概念レベルで言うだけならタダですが、実際にやってみると難しいことがあります。

難しいポイントとしては、例えば従来型の手続き型プログラミングの延長でマルチスレッド・マルチプロセスをやろうとすると、次のような問題に直面します。

  • スレッドやプロセス間のデータ受け渡し
  • スレッドの同期
  • スレッド立ち上げのコストと、並列実行のトレードオフ

特にC/C++のような手続き型・オブジェクト指向な言語でこれらを乗り越えようとすると相当キツイです。

設計上はうまく作ったつもりでも、マルチスレッド・マルチプロセスではテスト難しくなってきて「動くこともあるけど、動かないこともある」なんてことが簡単に起きます。

こういった類の問題はテストでは再現が難しい問題になりがちなので、相当タチが悪いバグになってプログラマを悩ませます。

Windowsアプリではこの問題に対する多くのアプローチがあります。

今回はその中でもtaskをどうやってうまく扱うかという問題に焦点をあてて分析してみます。

いわゆるMicrosoft-PPLです。

公式ドキュメントはこのあたりです。

https://msdn.microsoft.com/ja-jp/library/dd492418.aspx

https://msdn.microsoft.com/ja-jp/library/dd492418.aspx

taskを返す関数=高階関数としてとらえてコードを書く

これは私なりの結論です。

taskとは何かということを考えてコードを書くとき、それは関数であり、taskを生成する関数とは関数を返す関数、いわゆる高階関数だという解釈です。

※あくまで概念レベルで「ああ、私が欲しかったの、こういうやつだ」と感じただけの話なので、その世界で本気でやってる人からは異論あるとは思います。

高階関数の概要はwikipediaで一行で簡単に述べられています。 https://ja.wikipedia.org/wiki/%E9%AB%98%E9%9A%8E%E9%96%A2%E6%95%B0

要は

  • 関数を引数にとる関数
  • 関数を戻り値にできる関数

です。

というわけで 以下にコードと概念を整理します。

PPLでは、従来の関数と呼ばれてきたものを「タスク」として定義できる

タスクとはppl::concurrency::taskテンプレートクラスによってラップされた関数です。

ここではラムダをtaskで包むことに焦点をあてます。実際そういう使い方がほとんどですし。

PPLでタスクを作るには2つの方法がありました。

taskクラスのコンストラクタを使う方法

たとえば整数のリストを受け取って合計を出す関数を考えたとき

auto sum_lambda = [](std::vector<int> nums) -> int {
    int s = 0;
    for(auto val : nums){s += val;}
    return s;
};
auto sum_task = task(sum_lambda(arg_list));

こんな感じになります。 ここでは分けて書きましたが、以下のようにtaskクラスのコンストラクタに直接ラムダを渡してしまうほうが便利かつ安全です。

auto sum_task = task([](std::vector<int> nums) -> int {
    int s = 0;
    for(auto val : nums){s += val;}
    return s;
});

この例を手続き型orオブジェクト指向の考え方のまま読むと「sum_taskはtaskクラスのインスタンス」になります。

しかし「合計を求める"関数"をインスタンス化した」と考えたほうが後々スッキリします。

create_task関数を使う

関数をcreate_task()に渡すことでコンストラクタと同じようにtaskクラスを作ることができます。

UWPのAPI呼び出しなんかは、この関数を使って書かれているサンプルが多かった印象です。 APIの戻り値型はAPIごとに異なっていますが、taskクラステンプレートに戻り値型が適用されるため、あんまり気にせずautoで受ければいいかなと思っています。

ただし、普通は後続の処理(後述のthen)で型を明示的に指定するので、taskクラスインスタンスを直接どうこうするということは意識せずとも良さそうです。

たとえばファイルを開くタスクを作るコードはこうなります。

auto file_get_task = create_task(StorageFile::GetFileFromApplicationUriAsync(uri));

GetFileFromApplicationUriAsyncが返してきたタスクが生成されます。

タスクを実行する

上記の方法で、タスクを作ることはできました。 次は作ったタスクを実行する必要があります。

taskがラップしている関数であっても、C/C++のふつうの関数と同じです。 関数定義だけ書いてもどこかから実行してもらわなければ意味がありません。

wait, when_all, when_anyなどでタスクの実行完了を待つことができます。 結果を拾いたいときはget()で。

上記の方法で生成したタスクに対してそれぞれ呼び出すだけです。

waitとgetは単一のタスクの終了を待つものです。

たとえばこんな風に。

auto sum_task = task([](std::vector<int> nums) -> int {/* 略*/}
sum_task.wait();

when_allは複数のタスクが完了するのを待つ関数です。when_anyは複数のタスクの完了を待つという点では同様ですが、「いずれかが完了するのを待つ」関数です。 以下のコードではテクスチャの読み込み/デコードを並列実行可能なタスクにしたものです。

すべてが並列に実行される保証はありませんが、一例として。

 std::vector< task<void> >  texture_read_tasks;
    for (int i = 0; i < texture_num; i++) {
        auto readtask = task([](int num){/*ごにょごにょ*/});
        texture_read_tasks.push_back(readtask);
    }
    when_all(texture_read_tasks.begin(), texture_read_tasks.end()).then([]() {
        OutputDebugString(L"texture load/decode success\n");
        return;
    });

タスクとタスクをくっつけるthen

UWPのC++/cxでも従来の手続き型のように記述していきたいです。

従来の手続き型のように記述したいというのは、入出力の依存関係があって並列化できないケースなどがわかりやすいです。 たとえばファイル操作のopen/read-write/closeなんかが該当します。

こういうときに役立つのがtaskクラスのメソッドthenです。 タスクの後続タスクを定義するものです。

taskのthenの説明の前にコードを書くときの論理レベルで考えると

  • ファイルを開くタスク
  • ファイルハンドルを使って読みだすタスク
  • ファイルハンドルを閉じるタスク

とタスクを定義できます。これらのタスクの実行は、順序が大切です。

thenはあるタスクの後続タスクを定義するものなので、こういった場面で必須になります。

thenによって数珠つなぎにされたタスクをタスクチェーンと呼ぶらしいです。

タスクチェーンを実行する

thenが返してくるのもタスクです。 どのようなタスクでも実行してあげる必要があります。作ったまま放置ではいけません。

まだ完全に把握できていませんが、私が書いたコードについていえば、task関連の実行時エラーの原因の大半は作ったまま放置でした。

というわけでタスクチェーンの実行です。 これは末尾タスクの終了を待つだけで良いです。

タスクチェーンで「末尾のタスク完了を待つ」ということは、タスクチェーン全体が実行されるのを待つことに相当します。

ここで勘違いしていてすごく詰まったのですが、thenは「後続タスクを定義する」だけであって、「実行する」わけではないです。

なので、作ったタスクチェーンは誰かが実行してあげなければいけません。 (もしくはフレームワークのどこかで一括して実行する機構があるなど)

ラムダを使う理由

タスクを作るのにどうしてラムダを多用するのだろうと自分なりに考えてみました。

私なりの結論としては「task間のデータ受け渡しが安全である」ためと考えました。

C++のラムダは、定義した位置にクロージャオブジェクトが生成されます。 コンストラクタも生成されるので、それを使ってデータ受け渡しが行われます。

すなわち、ラムダの引数がtaskへ受け渡すデータになります。イメージとしてはプロセスへメッセージを送るというほうがしっくりきます。

ここで「安全」といっているのは「taskに渡すデータそのものが競合していない」という前提があったうえでの話です。

その前提を守ったうえでの安全です。

なお「ラムダ使ううえで、これは避けましょう」というのはMS公式からも提示されていました。

https://msdn.microsoft.com/ja-jp/library/dd492427.aspx

要は「taskに渡したラムダの実行完了前に寿命が尽きるオブジェクト(スタック上の変数とか)をキャプチャしないでね」ということですね。

これらの情報をもとに色々コード書いて試行錯誤した結果、スマートポインタ系をラムダの引数に渡すのが一番よさそうだと考えています。

ただし、スマートポインタとはいえ、もしstd::shared_ptrをtaskに受け渡す必要に迫られた場合、「それの寿命が尽きないこと」は言語側で保証できますが「競合をしていないこと」はプログラマが保証しなければいけないので注意が必要です。

理想的な設計

ここまででtaskの基本的な扱いができるようになりました。 次に一歩進んでキレイな設計とは何だろうということについて考えます。

宗教観とか時代の流れとかあるので、ここでの答えはあくまで現時点での私なりの答えです。

関数(をラップしたタスク)を返す関数

急に関数型言語っぽくなりましたね。関数型言語の世界でいう高階関数っぽいものです。

従来の手続き型プログラミングのように値やオブジェクトを返すのではなく関数を返そうみたいなアプローチです。

どうしてこれが良さそうと考えたかというと、

  • 色々試行錯誤した結果から
  • MSのサンプルコードでもこの方式が多い
  • タスクチェーンは呼び出し元が組み立てたいから

最後の以外あまり説得力ないので、最後のだけちょっと書きます。

タスクチェーンを呼び出し元で組み立てたい理由は、従来の手続き型プログラミングでの「ある関数の実行結果を使って、後続の関数を実行する」ように記述していたプログラミング方法の"関数"を"タスク"に置き換えたいからです。

混ぜるな危険

もし「順次処理の関数」と「PPLタスク」を混ぜると次のような面倒な事になってしまいます。

  • 手続き実行結果をもとに後続タスクを実行する
  • タスクの実行結果をもとに手続きを実行する

こういうコードを作ってしまうと「どこがタスクとして実行されて、どこが手続きとしてメインスレッドがら実行されるのか」が見えにくい・わかりにくいものができました。

順次処理とtaskを混ぜたコードは、一応完成しましたが、ちょっと機能拡張しようとかやりはじめたときに手も足もでなくなりました。

taskを返さずに、手続き関数の中でwaitしてはどうかと試みましたが、そうしてしまうと今度は「どの関数がブロッキングで、どの関数がノンブロッキングなのか」がわからなくなりました。

つまりデバッグできないのです。こういうコードは廃棄処分です。

というわけで、UWPの枠組みでやるならできるだけtaskにしたほうがスッキリします。UWPのC++API自体もtask返してくるのが多いですし。

感想

今まで何となくサンプルコードを真似して書いていたtaskですが、高階関数の概念のおかげでスッキリしました。

taskを使って競合を避けるコードを書こうとすると、どことなく関数型言語っぽくなってきた気がするので、また別途記事を書く予定です。

長くなりましたが今回はこれくらいで。

Hololensでカメラの解像度を変更する

前回つくったカード検出器をHololensで使ってみました。かたりぃなです。 処理がやっぱり重いみたいで表示がカクつくくらいに気持ち悪いです。

本当に負荷が原因なのか知るために、画像処理全体の負荷を下げて試すことにします。

簡単に負荷を下げる方法として、カメラの設定(解像度、フレームレートなど)を下げてしまうことにしました。

もちろんアルゴリズムによっては低解像度では使えないなど弊害はありうるので注意が必要です。

簡単に試したい理由は、アルゴリズムの最適化をかける前に「そもそも負荷が低ければアプリとして成立するのか?」を確認したいからです。

個人開発での限りある時間を無駄にはしたくないので。

カメラ(MediaCapture)の初期化

いつもどおりマニフェストファイルにカメラを使うよう設定してから、mediaCaptureクラスを使います。 Platform::AgileはいわゆるスレッドセーフなCLRらしいです。

初期化コードはこんな感じです。

        Platform::Agile<MediaCapture> mediaCapture( ref new MediaCapture() );
        return create_task(mediaCapture->InitializeAsync(settings))
            .then([=]
        {
                // ここでmediacaptureのカメラを起動する
        });        

カメラの解像度を変更

カメラ選択のUIとか作ろうかと思いましたが、面倒なのでやめました。

実験だけのつもりなのでデバッガで出力して、パラメータを書き換えるだけのほうが手っ取り早いです。

コードだけ簡単に。

         // ここまででmediaCaptureはインスタンス化されていること。開始はしてなくてもよい。

            // 負荷が低くなるよう、小さめのサイズのカメラ入力画像を設定する
            auto FindPreviewResolutions = [](Platform::Agile<MediaCapture> cap) -> Windows::Media::MediaProperties::VideoEncodingProperties ^
            {
                auto prop_list = cap->VideoDeviceController->GetAvailableMediaStreamProperties(MediaStreamType::VideoPreview);
                if (prop_list->Size == 0) {
                    // todo
                }

                Windows::Media::MediaProperties::VideoEncodingProperties ^ vp;
                for (auto prop : prop_list) {
                    auto name = prop->GetType()->FullName;
                    if (name == ref new String(L"Windows.Media.MediaProperties.VideoEncodingProperties")) {
                        auto video_prop = static_cast<Windows::Media::MediaProperties::VideoEncodingProperties ^>(prop);
                        char buf[1024];
                        sprintf_s(buf, 1024, "w=%d, h=%d\n", video_prop->Width, video_prop->Height);
                        OutputDebugStringA(buf);
                        if (video_prop->Width == 896) {
                            vp = video_prop;
                        }
                    }
                }
                return vp;
            };
            auto vp = FindPreviewResolutions(mediaCapture);
            // 低解像度のプロファイルがとれたので、設定する

簡単に説明。 cap->VideoDeviceController->GetAvailableMediaStreamProperties(type)で、メディアのプロパティリストが取れます。

このリストの要素はIMediaEncodingPropertiesというインターフェースなので、prop->GetType()->FullNameに目的のプロパティが入っていることを確認します。これはStringなので文字列比較です。

目的の型が入っていることが確認できたら、その型にキャストしてからプロパティを読み出します。

こういうキャストって個人的にはちょっと嫌です。ダウンキャストっぽく見えて。 たぶんただの宗教観なので気にしないことにします。MSのサンプルコードもこういう形になっていたので。

最後に取得したプロファイルをmediaCaptureに設定してあげれば完了です。 こんな感じに。 asyncなのでtaskでラップしてあげるのを忘れずに。

            mediaCapture->VideoDeviceController->SetMediaStreamPropertiesAsync(MediaStreamType::VideoPreview, vp);

というわけでこのコードをHololensの実機に放り込むと、サクサクとカメラ映像を処理できるようになりました。

つまり、カード検出まわりのアルゴリズムをもうちょっと工夫すればなんとかなるレベルというわけですね。 もしくは低解像度で検出できる仕組みを考えるか。

感想

C++らしくテンプレートで分岐できないかなと考えましたが、すぐには無理でした。

というのも、Stringの中身は実行時に確定するので、テンプレートを展開するコンパイル時にそれを判断する処理を入れるのにはちょっと工夫が必要になってしまいます。

ここまで書いて気づきましたが、いわゆるvisitorパターンを実装してあげればキャストなしでいけそうな気がします。 つまり任意のメディア型をacceptするクラスを作る。

しかしvisitorまで書き始めてしまうと今回の目的と比べると大がかりすぎるからやめておきます。

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

古典的な手法を使って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のカードかどうか

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

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

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

物体検出器をいろいろと試す

Hololensで使える実用的な物体検出器がないか試行錯誤しています。かたりぃなです。

どうして物体検出器?

まず、現状までに作った機能では「特定の条件下」でのみMTGのカードを検出できています。 手順は次の通りです。

  1. ガウシアンフィルタで細かいノイズを除去
  2. キャニー法による輪郭抽出
  3. Hu不変量モーメントで、実際のカードの輪郭と比較

です。

しかし「特定の条件下」というのが曲者で、検出できたりできなかったりと不安定です。 この不安定さのせいで実験の手間がかかってしまいます。

というわけで、真面目に物体検出に取り組んでみました。

結果

今回試した方法だけでは私がやりたいことに届きませんでした。 原因は物体検出の検出率と実行速度のトレードオフがとれないためです。

  • 精度を上げると実行速度が下がる(リアルタイムに検出できない)
  • 高速な検出手法では検出精度が下がる

というわけです。 リアルタイム性を犠牲にしたくはないので、何か妥当な策がないか調べてみました。

物体検出器の種類

とりあえず色々調べてみたことを整理します。

物体検出の方法はたくさんありますが、今回調べた範囲ではこんな感じでした。

方法は大きく分けて2つあって

  1. 特定物体認識によるもの
  2. セグメンテーションによるもの

です。 今回調べたものはほとんどが2に該当します。 今回調べた範囲で、私の目的達成のために使えそうなものは次の3つでした。

  • R-CNN
  • saliency(顕著度)
  • 適当な特徴量と特徴記述

以下の2つは調べてみたものの、負荷が高すぎるのでリアルタイム処理には向いていないです。

  • k-means法
  • グラフカット

最後に、キーワードだけメモします。使えるかもしれない方法論。

  • selective search
  • object proposal
  • objectness-BING

以下は実験の記録です。

実験環境

  • 入力画像は1280x720,グレースケール
  • Windows-PC上で実行

では順にやってみたことを紹介します。

R-CNN

deep-learningです。deep-learningすごい勢いですね。

まず今回やってみたR-CNNについて。

CNNはconbolutional nural networkの略で、畳み込みニューラルネットワークです。

CNNの前についてる"R"は、文脈によって2つあります。

  • 領域抽出で使うRegion-CNN
  • 系列データで使う Recurent-CNN

今回試したのはRegionのほうです。

みんな大好きchainerを作っているPFNETさんがchainercvというものを出してくれているので、これを使うことにします。

公式のマニュアルどおりにインストールして、デモを起動します。

R-CNN(faster-r-cnn)のデモはリポジトリのexample/faster-rcnnディレクトリに入ってます。

こんな感じで画像ファイルを突っ込んであげるだけで、モデルのダウンロードと識別が行われます。

PS C:\myproject\deep_learning\chainercv\examples\faster_rcnn> python .\demo.py .\pic0.jpg --gpu 0

入力 f:id:Catalina1344:20170530225056j:plain

出力 f:id:Catalina1344:20170602231248p:plain

検出できた。すごい。 でも、chainercvさん、、、それテレビモニタちゃう!

なんか簡単にできすぎたので、複数枚のカードも見つけられるか試してみました。

出力 f:id:Catalina1344:20170602231419p:plain

すごい。

でもそれテレビじゃないよ?

テレビだと認識してしまう原因は単純です。 学習済みモデルは"MTGのカード"なんてニッチなオブジェクトを知らないので。 学習した中ではテレビモニタってこんなのだったのでしょう。

ちなみに識別の実行時間を測定してみると2秒くらいでした。 少々厳しいですね。。。

正確性よりもリアルタイム性が欲しいので、別の方法がないか考えてみました。

適当な特徴量と特徴記述

今回特徴量として試してみたのはFASTとBRISKです。

実行時間とコードはこんな感じです。

  • FAST : 30mSec
  • BRISK : 4mSec
 cv::Mat img = cv::imread("testimg.jpg", 0);

    std::vector<cv::KeyPoint> kp;
    cv::FAST(img, kp, 10); // 30msec
    cv::Mat fast_img;
    cv::drawKeypoints(img, kp, fast_img, 255, cv::DrawMatchesFlags::DRAW_RICH_KEYPOINTS);
    cv::imwrite("fast.jpg", fast_img);

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

FASTで検出した特徴点 f:id:Catalina1344:20170602231217j:plain

BRISKで検出した特徴点 f:id:Catalina1344:20170602231213j:plain

特徴点はそれっぽく出ているのですが、マッチングがうまくいきませんでした。

なぜかというと、画像の特徴量算出をかけると、イラスト部分の特徴点まで抽出されてしまうためです。

イラスト部分まで特徴抽出されてしまうと、カードのフチ画像だけの特徴点とマッチングを試みたとき、どうにもうまくいきません。 Opencvのmatcherが混乱してしまうから?それとも使い方が間違っているのか。

詳細を別の機会に追ってみたいところです。

まあ、この手法は単一マーカーでのARなら良いかもしれません。

しかし私のやりたいことを実現するには一工夫必要そうです。

欲しい機能は「イラスト以外の部分で特徴抽出とマッチング」です。 というのも、カードゲームのイラストは種類が多すぎるためです。

イデアとしては

  • 物体検出、トラッキング、姿勢推定はリアルタイムに行う
  • 実際のイラストの識別はdeep learning側に分類問題として解かせる

といったところです。 トラッキングと姿勢推定さえリアルタイムに動いていれば、「そこに何を表示するか」は多少の遅延があってもエンターテインメント用のアプリとしては成立するだろうと思っています。

遅延が許されるだろうというのは、HoloLensは、装着者(HoloLens本体)とオブジェクトの空間上の位置関係を覚えられるようになっています。

なので、オブジェクトを一度検出してしまえば、あとはオブジェクトの追跡さえできれば良いんじゃないかなと思っています。

というわけで、少し工夫してイラスト部分をマスクすればいいかもと考えました。

しかしマスク画像を作るためにはカードを正しく検出できていなければなりません。 また鶏が先か卵が先かみたいな不毛な話になってしまうので、この手法は一旦保留にします。

ちなみに、以下の条件下であれば、画像中のカードの場所に特徴点が集中していることは確認できました。

  • 模様のない台紙の上に置いたカード
  • 適当なパラメータでcanny法を適用
  • BRISK特徴量を求める

この集中している特徴点を利用できないかと考え、調べたの結果が以下のようなクラスタリングです。

k-means法

いわゆるデータクラスタリングの手法です。 映像中のカードの周辺に特徴点が集中するため、これをクラスタリングにかけてあげればいけるかもしれないという考えからです。

しかし、これは私の目的を達成する手段にはなりませんでした。 まずk-meansという名前のとおり、このアルゴリズムクラスタ数を与えたうえでクラスタリングを行うものです。

カメラからの入力映像に何枚のカードが含まれているかは事前に知ることはできません。

もちろんゲームのプレイ状況をすべて解析できるならクラスタ数を求められるかもしれませんが、敷居が高いです。 クラスタ数を動的に求める方法もあるみたいですが、この手段だけに拘るのもどうかと思い、いったん保留としました。

グラフカット

PRMLの下巻で、グラフカットを使ったノイズ除去の話があったのを思い出しました。 あれを応用できないかと考えました。

  • OpenCV本体のgrabCut
  • OpenCVのcontribにあるximgprocモジュールのグラフカット

この2つを試してみました。 重すぎて今回の用途では使えなさそうでしたが、後者はカードの領域は分離できています。惜しい。

 // 50 sec
    cv::Rect roi(10, 10, 1260, 700);
    cv::Mat dst, mask, bg, fg;
    cv::grabCut(img, dst, roi, bg, fg, 1, cv::GC_INIT_WITH_RECT);
    compare(dst, cv::GC_PR_FGD, mask, cv::CMP_EQ);
    img.copyTo(dst, mask);
    cv::imwrite("graphcat.jpg", dst);

    // 3 Sec
    cv::Mat segmented;
    auto graph_segmenter = cv::ximgproc::segmentation::createGraphSegmentation();
    graph_segmenter->processImage(img, segmented);
    cv::imwrite("graph_segmentation.jpg", segmented);

grabCut f:id:Catalina1344:20170602231230j:plain

graph_segmenter f:id:Catalina1344:20170602231226j:plain

saliencity

日本語では顕著度、顕著性と呼ばれます。

この手法を例えるならば、「ある画像を見たとき、人はまずどこに着目するの」というお話らしいです。 世の中に出回っているけしからん画像も、この理論を応用しているのかもしれませんね。(空想)

さて、この顕著度のアルゴリズムは高速に動作したので、今のところこれが第一候補になりそうです。

顕著度にもいろいろな尺度があるらしいので、2件ほど試してみました。 まだ理屈がよくわかっていないので、動作結果だけ示します。

spectral_residualのほうは特に軽いですね。検出結果を30FPSでのレンダリングに使うなら、リアルタイムに処理できるかもしれません。

 cv::Mat saliency_map;
    auto grained = cv::saliency::Objectness::Saliency::create("FINE_GRAINED");
    grained->computeSaliency(img, saliency_map);// 2 Sec
    cv::imwrite("grained.jpg", saliency_map);
    auto spectral_residual = cv::saliency::Saliency::create("SPECTRAL_RESIDUAL");
    cv::Mat spectral_residual_map;
    spectral_residual->computeSaliency(img, spectral_residual_map);// 20m Sec
    cv::imwrite("spectral_residual.jpg", spectral_residual_map);

SPECTRAL_RESIDUAL f:id:Catalina1344:20170602231237j:plain

FINE_GRAINED f:id:Catalina1344:20170602231223j:plain

感想と今後

思ったより物体検出手法がたくさんあって驚いています。 R-CNNの世界ではselective-searchが流行っているらしいので、次回はこのあたりを調べていきたいと思います。

あとは、もしかしたらHololensから取得できる深度マップをうまく使えば、何か進展があるかもしれません。 それでは今回はこれにて。

DirectXのHLSLシェーダーをデバッグする

DirectXデバッグが捗る便利な機能があるのを知りました。かたりぃなです。

その名も「VisualStudio Graphics Analyzer」。

VisualとGraphicって似たような意味だと思ってしまう私は英語が苦手です。

環境

公式ドキュメント https://msdn.microsoft.com/ja-jp/library/hh873197.aspx

DirectXグラフィクスデバッガでできること

DirectXグラフィクスデバッガを使って次のことができました

これはデバッグが捗りますね。

DirectXグラフィックスデバッガを起動する

VisualStudio2015communityでは次の手順で起動できました

画面上部のメニューから、デバッグ->グラフィックス->グラフィックスデバッグの開始の順に選択。

メニューから操作はこんな感じです。 f:id:Catalina1344:20170526214208p:plain

キーボードショートカットは手元の環境ではAlt+F5でした。(古いVisualStudioのキーバインドにしているので、ほかの環境だと違うかもしれません。)

無事起動すると、上記スクリーンショットでメニューの後ろに見えているタブが開きます。

Graphics Analyzerを起動する

新しく開いたタブの画面下部のカメラアイコン「フレームのキャプチャ」をクリックすると、フレームがキャプチャできます。 キャプチャしたフレーム画像をダブルクリックすると、以下のような画面が起動します。

f:id:Catalina1344:20170526214523p:plain

DirectXに慣れてる人であれば、あとはもう直観で使えそうな見た目ですね。

レンダリングの命令が期待通りに呼び出されていることを確認する

まず画面左の「イベント一覧」ペインは名前のまんまですが、わかりやすくいうと 「C++から呼び出した関数のうち、GPUに設定が行われたもの」と考えておけばよさそうです。 DrawIndexedInstancedとかUpdateSubresourceとか並んでいますね。 まずはここで期待した命令が順序通りに呼び出されているか確認できます。 たとえば、

  • テクスチャの更新(UpdateSubresource)の呼び出しを確認
  • 行列の設定(UpdateSubresource)の呼び出しを確認
  • レンダリング処理(DrawIndexedInstanced)の呼び出しを確認

などがあります。 スクリーンショットの時点でジオメトリ情報がおかしそうという推測はできるので、さらに詳細を見るために次のステップに進みます

レンダリングパイプラインを確認する

先の画面でDrawIndexedInstancedの関数をクリック選択すると、レンダリングパイプラインが表示されます。 どうやら、このレンダリング命令で実行されたパイプラインのようです。

f:id:Catalina1344:20170526214245p:plain

パイプラインの各シェーダーオブジェクトをクリックすると、入出力パラメータが表示されます。 実行されなかったパイプラインステージは「ステージが実行されませんでした。(理由)」と表示されるのでわかりやすいです。 パイプラインステージの実行をデバッガで追いたいときは、右矢印ボタンを押すとデバッガが起動します。 デバッガはVisualStudioでC++デバッグするのと同じように操作できました。

レンダリングに関連するオブジェクトを確認する

GPUに転送したデータが期待したものかどうか確認したいことって多々あります。 頂点バッファ、インデックスバッファ、テクスチャ…。 Graphic Analyzerではこれも確認できました。 先ほど開いたパイプラインステージの表示で、左に「オブジェクトテーブル」があります。これに切り替えると、こんな画面になりました。

f:id:Catalina1344:20170526214551p:plain

GPU側へ設定したものが一通り載ってますね。便利。

ここまでの手順で、入力アセンブラの段階で頂点データがおかしそうというのが見えているので、頂点バッファとインデックスバッファを確認してみたいところです。

とりあえずBlendStateとか今は関係ないので、バッファだけ表示させます。

f:id:Catalina1344:20170526214800p:plain

これでバッファだけ表示されます。C++からみた変数名では引けないのでC++側から識別する情報を見ます。 C++のデバッガで確認してサイズを照らし合わせて、どっちのバッファが何を表しているかを確認しました。

この図でサイズの大きいほうが頂点バッファ、小さいほうはインデックスバッファのようです。

頂点バッファの中身を確認する

頂点バッファの中身も見れます。

先の手順で表示されたオブジェクトテーブルの中から、見たいバッファをダブルクリックするとバイナリで表示してくれます。

この頂点バッファビューはデフォルトでは16bit幅区切りの16進数表示です。 インデックスバッファは整数値だからこれでいいでしょうけれども、今見たいのは頂点バッファなので浮動小数点数です。 浮動小数点数を16進数でさらっと読めるほど私は訓練されていないので、もうちょっと見やすくします。 「形式」のところに型を指定するっぽいのでfloat3と入力してみました。

f:id:Catalina1344:20170526215159p:plain

すごく読みやすくなりました。 型がわからないときはドロップダウンにプリセットがあるので、適当に選択してそれっぽい型を選べばよさそうです。

感想

デバッガを使って確認すると作業早く進むのでいいですね。

今回悩んでいたバグの原因は頂点バッファの入力が正しくGPUに渡っていないようです。

今やっているのは複数個のモデルを表示するところなので、そのあたりの関連が怪しいですね。

ありがとうMicrosoftデバッグが捗ります。

HoloLensからHTTPでChainerの画像分類器を叩く

HTTP接続までは簡単にできたので、一気にHoloLensとの連携までやってみました。かたりぃなです。

色々ためした結果

HTTPサーバまでできた時点で「あとはもう簡単だろう」と思っていたら、想像以上に手こずりました。

一言で結論だけ書くと「英語カードだけで学習したDeepLearningの分類機では日本語カードを分類できなかった」です。 ちょっと工夫が必要そうですね。

少しだけ詳細

HoloLensのカメラ映像からカード部分を抽出し、Chainerの分類器に入力する直前でファイルに保存するとこんなやつでした。 テストに使ったのは適当に箱から引っ張り出してきたカードです。

f:id:Catalina1344:20170507004424p:plain

誤って分類したラベルのカードイラストはこんな感じです。

f:id:Catalina1344:20170507004728p:plain

うん、なんか似てる気がする。(似てない)

DeepLearningの識別器はまだまだ改善の余地ありですね。

ただそれでも連休中に「私がやりたいこと」のうち「今まで一度もやったことないもの」の技術要素は詰め込み終わったので、あとは時間が空いたときにチマチマと進められると思います。

以下、やってみたことの詳細です。(長文です)

PythonでHTTPサーバを立ち上げる

今回はシンプルなHTTPサーバとしてbottleを使うことにしました。

現時点では小難しいことをするつもりはないので、こういう単純な仕組みで充分です。 bottleは非常にシンプルにできていてREST-APIを軽く作って試すだけなら数時間でできました。

bottleをインストールする

pipで一発です。ファイルは一個だけなのでわざわざインストールしなくてもいい気はします。

pip install bottle

bottleを使ってリクエストを処理するサーバを書く

from bottle import route, run
from bottle import get, post, put, request

@route('/hello')
def hello():
    return "Hello World!"

def main():
    run(host='localhost', port=8080, debug=True, reloader=True)

if __name__ == '__main__':
    main()

これでブラウザでlocalhostの8080番ポートの/helloにアクセスするとHelloWorldが表示されます。

HoloLensからアクセスするとき(localhost以外からアクセスする)はrunのhost=のパラメータを"0.0.0.0"とかにしておくとよさげです。

立ち上がらない

うちのマシンだと、なぜかlocalhost:8080をlistenできませんでした。 どこかで見覚えのある番号だなーと思って調べてみると、Jenkins氏が使っていました。 上記pythonのコード中のポート番号を変えて対応しました。

あと、アンチウイルス系のソフトに付随するファイアウォール系のソフトが動いているとリモートからアクセスできないなんてことがあるので、必要に応じて設定を変えておきます。

PUTメソッドを実装する

すごく簡単でした。以下の行を書き加えるだけです

@put('/resource')
def put_resource():
    # リクエストのボディをとる
    data = request.body.read()
    return ("ok, %d" % len(data) )

これが"localhost:8080/resource"に対するPUTのハンドラになります。 識別結果を"ok,ファイルサイズ"の形でとりあえず返して目視確認します。

今回は画像データをPUTしたいので、power shellから適当な画像ファイルを投げ込んでみます。まずは学習に使ったデータファイルを投げます。

Windows上からPowershellでファイルをPUTするには、-Method PUTと-InFileオプションで指定します。

> Invoke-RestMethod -Uri "http://localhost:8081/resource" -Method PUT -InFile 1
85\185_1.png
ok, 89197

すんなりいきました。 このままchainerの分類器をくっつけてみることにします。

PUTで受けたpngファイルをdecodeして画像分類器にかける

前回のエントリ(http://catalina1344.hatenablog.jp/entry/2017/05/04/171318)でやったことをくっつけます。

import training         # 自前の学習器
import chainer.serializers
import chainer.functions as F
import argparse
import numpy as np
from PIL import Image

# HTTPアクセス用
from bottle import route, run
from bottle import get, post, put, request
from io import BytesIO

@put('/resource')
def put_resource():
    # リクエストのボディをとる
    data = request.body.read()

    # リクエストのボディに記述されている画像データをデコードする
    output = BytesIO(data)
    image = Image.open( output )

    # NNへ入力可能な形式に変換する
    pixels = np.asarray(image).astype(np.float32)
    pixels = pixels.transpose(2, 0, 1)
    pixels = pixels.reshape((1,) + pixels.shape)

    # 学習済みモデルを利用する準備
    model = training.MLP()
    chainer.serializers.load_npz('result/best-model.npz', model)

    # 識別
    y = model(pixels)
    prediction = F.softmax(y)
    m = np.argmax(prediction.data)
    return ("ok,%d" % m)

ポイントはhttp-PUTで取得したボディのデコードです。 PUTのbodyに載っているデータは先のPowerShellのアクセス方法ではPNGエンコードされたフォーマットです。 なので、オンメモリ上のPNGファイルイメージをデコードする必要があります。

オンメモリ上のPNGファイルイメージはそのままではPIL.Image.openに渡すことができません。 オンメモリ上のバイト列をio表現にしてあげてからImage.openに渡す必要があるみたいです。

io表現はPython3からはio.BytesIOを使うようで、これはio.StringIO(文字列)と違ってエンコード・デコードが行われない生のバイト列を表現するものです。

fromarrayとか使えないか試しましたが、これはデコードされたピクセルデータのバイト列を渡すためのものでした。

で、つまるところio.BytesIOで表現されたバイト列をPIL.Image.openに渡せばデコードしてくれるというわけですね。

あとは前回やったようにchainerのNNの入力形式に合わせてあげて、NNに突っ込んで結果を取り出すだけです。

結果は"ok,分類ラベル"の形で返されます。

HoloLensからPUTする

まずHoloLensからPUTするための画像を作る必要があります。 カメラ映像をそのまま送るのは負荷的に優しくないので、必要部分だけ切り取ります。

そのままコードに落としこむ(重すぎて使えない)

ダメでした。色変換とパースペクティブ変換が重すぎます。 映像中から一枚のMTGのカードを抽出するのに1~2秒かかります。

まず色変換で盛大に詰まったので、やったことだけをメモします。

  • NV12=YUV420Pだと思っていたけど、チョット違うらしい
  • OpenCVのMatで異なる大きさを持つ次元の取り扱いがわからない

まずNV12はMS曰く、4:2:0-Planer形式だそうです。 https://msdn.microsoft.com/ja-jp/library/windows/desktop/dd391027(v=vs.85).aspx

以前やったARの方法ではモノクロ画さえあればよかったので、画像の先頭アドレスからwidth*heightバイトをunsigned charとして取り扱っていましたが、chainerに渡すにはu,vチャネルも適切に処理する必要があります。

で、マニュアルにはPlaner形式とあるので次のように推測していました。

  • Yプレーンの後にUプレーンが来る
  • UとVはYプレーンのサイズと比較してwidth,heigntがそれぞれ1/2
  • Uプレーンの後にVプレーンが来る

こう思って適当にコード書いて変換かけてみましたが、色味がおかしい。 で、辿り着いたのがこのマニュアル。 https://msdn.microsoft.com/ja-jp/library/windows/desktop/dd206750(v=vs.85).aspx#nv12

NV12ってYだけPlaneになっていてUVはPackedらしいです。こんなの初めて見ました。 上記ページにあるYV12かIMC2,4あたりを想定していただけにショックです。しかもlittle-endianとか書いてますね。

適当にバイト列を取り扱ってOpenCVのcvtColorに与えればいいかなと思っていましたが、これでは取り扱いに困ります。(PlanerとPackedの組み合わせで表現された画像をMatでどうやって表現すればいいのか。。。)

というわけで、書きなぐったコードは重すぎるので捨てました(動作確認はとれたので理論が正しいことは確認できた)。

YプレーンとUVプレーンに分けて処理する

cv::Matでplanerとpackedを混ぜて扱う方法がわからないので、分けて処理します。

pSourceBufferが画像データの先頭アドレスだとして、次のように2つのcv::Matで表すことができます。uvはpackedなのでchが2です。

  auto y_plane = cv::Mat(cv::Size(w, h), CV_8UC1, pSourceBuffer);
  auto uv_planes = cv::Mat(cv::Size(w/2, h/2), CV_8UC2, pSourceBuffer + w*h);

カード画像が含まれている矩形だけ処理する

HoloLens自身のCPUはそれほど贅沢なもの載ってないので、計算量を減らすことにします。 まずモノクロ画でカード検出はできているので、カードが含まれている矩形範囲を抽出します。

auto gen_boundingbox_with_card = [](cv::Mat& base, std::vector<cv::Point>& regon, std::vector<cv::Point>& offset_regon) -> cv::Mat{
    auto rect = cv::boundingRect(regon);   // カードを含む最小の矩形(傾いていない)を求める

    // カードの検出点をバウンディングボックスの左上隅を原点(0,0)とする相対座標に変換する
    offset_regon.clear();
    offset_regon.reserve(regon.size());
    for (auto &p : regon) {
        cv::Point pos(p.x - rect.x, p.y - rect.y);
        offset_regon.push_back(pos);
    }
    return cv::Mat(base, rect);
};

これで以降の処理はカメラ映像の一部に対してのみ行うことになるので、負荷は一気に下がります。

全てのカラーチャネル個別にパースペクティブ変換する

前回の記事でやったChainerで学習したモデルは、アフィン変換・パースペクティブ変換などを含まない画像から学習されたモデルです。

そういった不変性に対するロバスト性は持っていないことは想定されるので、HoloLens上であらかじめパースペクティブ変換してしまいます。

上記のバウンディングボックス計算で得られたROI中のカードの四隅の位置と、変換後のカードの画像(NNに入力するもの)をもとにパースペクティブ変換行列を作ります。 そのパースペクティブ変換行列を使って、カメラで撮影された画像を平面に投影した画像をつくります。

 std::vector<cv::Point> relative_card_regon;
    cv::Mat card_regon_y = gen_boundingbox_with_card(cv_preview_image, card_regon, relative_card_regon);
    std::vector<cv::Point2f>    src_regon;
    for (auto p : relative_card_regon) {
        src_regon.push_back(
            cv::Point2f(static_cast<float>(p.x), static_cast<float>(p.y))
        );
    }

    // 右上から時計回りに定義
    std::vector<cv::Point2f> target = {
        cv::Point2f(224, 0),
        cv::Point2f(224, 312),
        cv::Point2f(0, 312),
        cv::Point2f(0, 0)
    };

    cv::Mat perspective_mat = cv::getPerspectiveTransform(src_regon, target);

    auto croped_size = cv::Size(224, 312); // uvチャネルがYUV420(1byteで4pixel表現する)に起因する制約(=w,hは偶数であること)
    cv::warpPerspective(card_mat, src_mat, perspective_mat, croped_size);

これでカメラから撮影された画像中からカード領域を抽出し平面投影したものを生成できました。

この記事の先頭に貼ってある映像、こんな感じの画像が抽出できます。(ただし、この段階では画像のように下半分が見切れたものではなくカード全体の画像になります)

f:id:Catalina1344:20170507004424p:plain

カラーチャネルを合成する

自前で書くのもう面倒(さっきのカラー変換で力尽きた)なので、少々CPU負荷が高くても力技でいきます。

カラーチャネルの合成をOpenCVの関数でやるためには、すべてのプレーンが同じ幅、高さを持っている必要があります。 UVチャネルは幅、高さそれぞれ1/2なのでresizeで引き延ばしてしまいます。補間でノイズが載りそうですが今は無視します。

  cv::Mat fullscale_uvmat;
  cv::resize(uv_mat, fullscale_uvmat, cv::Size(224, 312), 0, 0, cv::INTER_LINEAR );

Y,U,Vのチャネルをマージしたいのですが、NV12カラーフォーマットではcv::mergeは使えません。 cv::mergeはcv::mixChannelsという関数の特別な条件が成立したときに使えるものなので、mixChannelsでチャネルのマージ方法を細かく指定します。

 // Y, U, V 全てのチャンネルをmixする
    cv::Mat yuv_mat = cv::Mat(cv::Size(224,312), CV_8UC3);
    // cv::mergeはシングルチャネル同士のmergeなので使えない。ここではmixChannelsを使う
    cv::Mat merge_src[] = { y_mat, fullscale_uvmat };
    int from_to[] = { 0,0, 1,1, 2,2};
    cv::mixChannels(merge_src, 2, &yuv_mat, 1, from_to, 3);

これでy_mat(Y-Plane)とfullscale_uvmat(UV-PackedPlane)の各チャネルを合成できました。

cropしてNNに入力できる形に合わせる

NNは223x223の画像の入力を期待しているので、ここまでにできたイメージ(224x312)から切り取ります。

 // NNへ入力可能なサイズにcropする
    cv::Mat put_img(223, 223, rgb_mat.type(), rgb_mat.data, rgb_mat.step);

多少カクつきますが、とりあえず動きました。

オンメモリ上でpngエンコードする

HTTP経由で送る前に、送るデータに意味付けをする必要があります。先のHTTPサーバはpngのファイルイメージを期待しているので、それに合わせます。

HoloLens上のオンメモリの画像イメージからpngファイルイメージをオンメモリ上に生成します。 OpenCVの関数でいうimencodeです。

 std::vector<unsigned char>  file_image;
    cv::imencode(".png", put_img, file_image);
    g_file_image = std::make_shared< std::vector<unsigned char> >(file_image);

これでとりあえずfile_imageにpng形式のファイルイメージが格納できます。先頭3Byteが'png'になっていることが確認できます。

HTTP-PUTする

MS公式がUWPでhttpを取り扱うためのサンプルコードを出してくれているので、これを参考にします。 https://github.com/Microsoft/Windows-universal-samples/tree/master/Samples/HttpClient

まだcreate_task.thenに慣れませんが、とりあえずこれで先に立ち上げたWebサーバにpng画像を投げて、識別できるようになりました。

 const unsigned int contentLength = file_image.size();
    create_task(GenerateSampleStreamAsync(contentLength, g_file_image)).then(
        [=](IRandomAccessStream^ stream)
    {
        Windows::Web::Http::HttpStreamContent^ streamContent = ref new Windows::Web::Http::HttpStreamContent(stream);

        auto uri = ref new Windows::Foundation::Uri(L"http://192.168.0.14:8080/resource");
        Windows::Web::Http::HttpRequestMessage^ request = ref new Windows::Web::Http::HttpRequestMessage(Windows::Web::Http::HttpMethod::Put, uri);
        request->Content = streamContent;

        auto filter = ref new Windows::Web::Http::Filters::HttpBaseProtocolFilter();
        filter->CacheControl->ReadBehavior = Windows::Web::Http::Filters::HttpCacheReadBehavior::MostRecent;
        auto client = ref new Windows::Web::Http::HttpClient(filter);
        auto headers = client->DefaultRequestHeaders;
        return create_task(client->SendRequestAsync(request));

    }, task_continuation_context::use_current()).then([=](task<Windows::Web::Http::HttpResponseMessage^> previousTask)
    {
    }, task_continuation_context::use_current());

感想と今後の展望

これで私がやりたいことの土台は整いました。

あとは識別率をあげるとか、演出をどうするとか仕組みを整えていくことになりそうです。

次は3Dレンダリングに挑戦していきたいと思います。

それでは今回はこれにて。

ChainerでMTGのカードを分類する(識別フェーズ)

黄金週間なので趣味プログラミングに没頭です。かたりぃなです。

MTGのカードの種類を識別するPythonモジュールを作っていきたいと思います。

今回でPython側でchainerを使う部分はひとまず完成です。

環境

ソフトウェア

  • Windows10 Pro
  • VisualStudio 2015 community(C++用)
  • VisualStudio Code(Python用)
  • Python 3.5
  • Chainer 1.20.0.1

ハードウェア

Chainerで学習させる

前回Chainerをいじったとき、識別率が60%くらいで頭打ちになってしまいました。

http://catalina1344.hatenablog.jp/entry/2017/04/09/222330

せめてもう少しまともに識別できるようにしたいので、まずはここから。

AlexNetを真似してみる

alexnetを真似して、前回の学習モデルを少し改造してみました。 畳み込み+MaxPoolingの層が3層、線形結合が3層です。 最後の層の出力274が1-of-k符号化されたラベルそれぞれの確率です。 dropoutや正規化は今は入れていません。

class TEST_NN(chainer.Chain):
    def __init__(self):
        super(MLP, self).__init__(
            conv1 = F.Convolution2D(3, 96, 11, stride=4 ),
            conv2 = F.Convolution2D(None, 256, 5, stride=2 ),
            conv3 = F.Convolution2D(None, 384, 3, stride=1 ),
            l0=L.Linear(None, 1000),
            l1=L.Linear(None, 500),
            l2=L.Linear(None, 274),
        )

    def __call__(self, x):
        h = F.relu(self.conv1(x))
        h = F.max_pooling_2d(h, ksize=5, stride=2)

        h = F.relu(self.conv2(h))
        h = F.max_pooling_2d(h, ksize=3, stride=3)

        h = F.relu(self.conv3(h))
        h = F.max_pooling_2d(h, ksize=3, stride=3)

        h = self.l0(h)
        h = self.l1(h)
        h = self.l2(h)
        return h

学習用のデータを学習モデルに合わせる

MTGのカード画像は長方形なのですが、CNNの実装では正方形の画像を前提としていることが多いです。長方形画像を分類するNNを実装する方法がわからず仕舞いなので、入力画像自体を正方形にcropすることにしました。

前回作ったデータ水増し用のプログラムにcrop機能を追加します。OpenCV便利ですね。

void crop_transform(cv::Mat& src_img, cv::Mat& dst_img, int w, int h)
{
    auto type = src_img.type();
    auto step = src_img.step;
    cv::Mat img(w, h, type, src_img.data);
    dst_img = img.clone();
}

これでwとhに同じ値を与えれば正方形にcropできます。w=h=223としました。 こうして切り取ってノイズを付与して水増しすると次のようになります。

f:id:Catalina1344:20170504164419p:plain

よさげですね。 ちなみに、カードのテキスト部分にも分類するための情報は含まれていますが、イラストのほうが情報量が多いのでイラスト側を残すことにしました。

「わかったぞ!わかったぞ!わかっ・・・」みたいにフレーバーテキストだけで判別できるものもありますが、今は置いときます。

ちなみにMTGのカードって、ある時期からプレインズウォーカーだけカード名に頭がめり込んでいるような気がします。(最初気づいたのは「世界を目覚めさせるものニッサ」のとき)昔はカード名の部分は見切れてた気がするのですが。。。

学習結果を保存する

chainerのexamplesが出力するスナップショットは識別フェーズでは使わない情報がついています。(optimiser, updaterなど) 識別フェーズで必要なのは学習済みのモデルのデータ(重み係数とかバイアス項とか)なので、モデルだけ保存するコードを追加します。

    nn = TEST_NN()
    model = L.Classifier(nn)
    # 略

    # Run the training
    trainer.run()

    # モデルを保存する
    chainer.serializers.save_npz( os.path.join(args.out, "mymodel.npz"), nn)

Classifierの返すモデルでは損失関数を付与したモデルになってしまうので、その前でインスタンス化したネットワークをとっておき、学習終了時にこれをserializerで保存します。 これで生成されたモデルのnpzファイルは10MByteくらいになりました。

このエントリを書き終わってから気づきましたが、識別フェーズでもsoftmaxを通すので、別にClassifireしたモデル保存してもいいのかもしれません。後で考える。

学習の経過はこんなグラフになりました。

前回は60%前後の正答率で打ち止めだったので、この数字だけ見ると改善はしているようです。

過学習気味(学習用の水増しデータが似たようなものが多いから?)かもしれませんが、今の私には判断つかないので次に進みます。 f:id:Catalina1344:20170504164807p:plain f:id:Catalina1344:20170504164814p:plain

実行にかかる時間は、ミニバッチサイズ100として20epoch回すのに30分程度です。 SSDの容量が厳しくなってきたのでHDD上に画像データを置いて実験しましたがお話になりませんでした。。

学習済みモデルをPythonで読み込んで使う

コマンドラインから実行する実験コードを書きました。

-iオプションで与えた画像ファイルを読み込んで、それをNNに入力してから結果を観測するだけのものです。

import training         # 自前の学習器
import chainer.serializers
import chainer.functions as F
import argparse
import numpy as np
from PIL import Image

def main():
    parser = argparse.ArgumentParser(description='TEST_NN')
    parser.add_argument('--inputimage', '-i', action='store', default=100,
                        help='image file name')
    args = parser.parse_args()
    filename = args.inputimage
    print("{}".format(filename) )

    # 学習済みモデルを利用する準備
    model = training.TEST_NN()
    chainer.serializers.load_npz("result\mymodel.npz", model)

    # テスト用画像を読み込む
    image = Image.open(filename)

    # Chainerのモデルに入力できるように、データの順序を合わせてNumpy形式の4次元テンソルにする
    pixels = np.asarray(image).astype(np.float32)
    pixels = pixels.transpose(2, 0, 1)
    pixels = pixels.reshape((1,) + pixels.shape)

    # 識別
    y = model(pixels)
    prediction = F.softmax(y)
    m = np.argmax(prediction.data)
    print("result={}".format(m) )

if __name__ == '__main__':
    main()

学習に使ったデータとかを適当に突っ込んでみます。

> python .\detect.py -i "dataset/185/185_48.png"
dataset/185/185_48.png
result=185
> python .\detect.py -i "dataset/121/121_3.png"
output/121/121_3.png
result=121

とりあえず分類はできているようです。

ピクセル列を4次元テンソルに変換している部分はchainerのdatasetあたりのコードから引っ張ってきたもので、理屈はまだよくわかっていません。(特に最初の次元は何を表しているものなのか。。。)

感想

これでMTGのカードのうち「カラデシュ」は分類できるようになりました。

コマンドラインから叩くだけでは感動は少なめですが、ARアプリのための土台ができつつあります。

「274種類のイラストを分類して任意のモデルを表示できる」という部分だけを誇大解釈すればVuforiaを超えたかもしれません。(超えてない)

今後の課題

  • 分類ラベルを増やすとどうなるか
  • 実際の映像からの分類でどれくらいの精度が出るか

などがあると思います。 前者は学習時間がかかるので、夜間とかに実行しておくなど時間を有効活用して研究したいと思います。

MTGはカード種類が多いですが、所詮は人工物なので階層的な分類が効果的なのかもしれないと思っています。(エキスパンションを識別してから、特定エキスパンション内で分類するなど)

後者はHoloLensと連携できるようにしてから改善していく予定です。 実際にモノを動かしながらやったほうが楽しいですし。

次の予定

これでカードイラストからカードを分類する仕組みをPython上で実現できました。 次はいよいよHoloLensとの連携部分です。 データの収集と分析といえばfluentdですが、HoloLens上に載せるには少々敷居が高そうです。

HoloLensからChainerを走らせているPCへHTTP/PUTして、HoloLens側は分類結果を受け取るのがいいかなと思っています。

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