読者です 読者をやめる 読者になる 読者になる

catalinaの備忘録

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

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レンダリングに挑戦していきたいと思います。

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