HTTP接続までは簡単にできたので、一気にHoloLensとの連携までやってみました。かたりぃなです。
色々ためした結果
HTTPサーバまでできた時点で「あとはもう簡単だろう」と思っていたら、想像以上に手こずりました。
一言で結論だけ書くと「英語カードだけで学習したDeepLearningの分類機では日本語カードを分類できなかった」です。
ちょっと工夫が必要そうですね。
少しだけ詳細
HoloLensのカメラ映像からカード部分を抽出し、Chainerの分類器に入力する直前でファイルに保存するとこんなやつでした。
テストに使ったのは適当に箱から引っ張り出してきたカードです。
誤って分類したラベルのカードイラストはこんな感じです。
うん、なんか似てる気がする。(似てない)
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
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 )
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);
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);
cv::warpPerspective(card_mat, src_mat, perspective_mat, croped_size);
これでカメラから撮影された画像中からカード領域を抽出し平面投影したものを生成できました。
この記事の先頭に貼ってある映像、こんな感じの画像が抽出できます。(ただし、この段階では画像のように下半分が見切れたものではなくカード全体の画像になります)
カラーチャネルを合成する
自前で書くのもう面倒(さっきのカラー変換で力尽きた)なので、少々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でチャネルのマージ方法を細かく指定します。
cv::Mat yuv_mat = cv::Mat(cv::Size(224,312), CV_8UC3);
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)から切り取ります。
cv::Mat put_img(223, 223, rgb_mat.type(), rgb_mat.data, rgb_mat.step);
多少カクつきますが、とりあえず動きました。
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レンダリングに挑戦していきたいと思います。
それでは今回はこれにて。