catalinaの備忘録

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

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側は分類結果を受け取るのがいいかなと思っています。

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

HololensでARをやってみる

HololensでARをやってみる

HoloLensでARをやってみました。かたりぃなです。 まだ完成には遠いので間違いなど含まれているかもしれません。

参考にさせていただいたサイト http://littlewing.hatenablog.com/entry/2016/09/25/172541 座標系のことが丁寧に邦訳されていました。原文を読む前にざっと読んでおくと作業が捗ります。

やったこと・実験環境リスト

やったことは単純です。

  • 適当な前処理と検出処理をして、MTGのカードを認識する
  • 3次元姿勢推定
  • 検出対象物上に文字列を書いた板ポリゴンをレンダリング

実験環境やライブラリは次のとおりです

とりあえずARっぽいことができるようになったので手順を整理します。

カメラキャリブレーション

まずキャリブレーション自体は不要です。 不要というと語弊がありますが、正確にいうとHoloLensのAPIでカメラパラメータをとれるので、そのパラメータがそのまま使えます。

カメラ内部パラメータを取り出す

UWPのMediaCaptureで映像フレームを受け取ると、Windows::Media::Capture::Frames::VideoMediaFrameクラスの形になるので、このCameraIntrinsicsプロパティを拾います。

公式ドキュメントはこちら

https://docs.microsoft.com/en-us/uwp/api/Windows.Media.Capture.Frames.VideoMediaFrame

ここで得られたパラメータをOpenCVの3次元姿勢推定関数に渡すために、cv::Matを作ります。 DirectXと行列の定義が少々異なるので、要注意です。

ちなみにOpenCVのカメラ内部パラメータの行列の定義はこうなっています。

http://docs.opencv.org/2.4/modules/calib3d/doc/camera_calibration_and_3d_reconstruction.html

カメラ内部パラメータをOpenCVで扱える行列にする

姿勢推定にはおなじみのOpenCVを使います。 というわけでVideoMediaFrameからカメラパラメータを抽出して行列を作るコードです。 flとかppとかをデバッガでみるとそれらしい値が入っているのが分かります。

     auto camin = videoMediaFrame->CameraIntrinsics;
        auto fl = camin->FocalLength;       // 焦点距離
        auto pp = camin->PrincipalPoint;    // 主点
        double inst_raw[] =
        { fl.x, 0, pp.x,
            0, fl.y, pp.y,
            0, 0, 1 };
        cv::Mat inst(3, 3, CV_64F, inst_raw); // カメラ内部パラメータ

三次元姿勢推定を行う

カメラ内部パラメータは取得できました。あとは3次元の姿勢推定のために次の2つがあればOKです。

  • 平面に投影された画像上で検出されたオブジェクトのピクセル単位の座標
  • ピクセル座標と結びついている実世界空間上での検出対象物の座標

歪み補正パラメータはいったん無視しておきます。まず動かしたいので。 とりあえずカードの四隅が検出できるものとして、それが実世界でどうなるかの座標を定義します。 MTGのカードのサイズは6.3x8.8cmなので、メートル単位に直して設定しておきます。 つまりwidth=0.063, height=0.088です。 どうしてメートル単位でいくかというと、HoloLensの座標系がメートル単位なので、合わせておいたほうが色々と都合が良いです。

 // 6.3cm x 8.8cm
    std::vector<cv::Point2f> base_points;
    const float left = -m_width * 0.5f;    // 0.5はカードの中央を原点としたいので。
    const float right = m_width * 0.5f;
    const float top = m_height * 0.5f;
    const float bottom = -m_height * 0.5f;
    base_points.push_back(cv::Point3f(right, top, 0) );        // 右上
    base_points.push_back(cv::Point3f(right, bottom, 0) ); // 右下
    base_points.push_back(cv::Point3f(left, bottom, 0));   // 左下
    base_points.push_back(cv::Point3f(left, top, 0));  // 左上

これで検出したカードの実世界での座標が定義できました。 次に画像中からカードの四隅の座標を検出します。 今回試した簡易的な方法ではまだまだ誤検出が多いので、ソースコード掲載しません。

ここまでに得られた情報をもとにsolvePnPに問題を解いてもらいます。 cam_distは空っぽです。

std::vector<cv::Point2f> detect_points = {};//画像処理によって得られたオブジェクトの位置(ピクセル座標)
cv::Mat tvec, rvec, cam_dist;
cv::solvePnP(base_points, detect_points, cam_inst, cam_dist, rvec, tvec);

これでrvec,tvecが得られました。 しかし、このrvec,tvecともにp(0,0,0)からp(0,0,1)を向いているカメラ座標系でのお話です。 HoloLensではワールド空間に対し、装着者の頭の位置を加味する必要があるので、少々加工します。その前に……。

姿勢推定結果を行列ではなくベクトルで扱いたい

cv::Mat形式で姿勢推定結果が出ました(tvec,rvec)。しかしOpenCV2.4のcv::Matをそのまま扱うのはちょっと面倒です。

行列のままでもいいのですが、これは必ず3x1の行列になるので、各要素へのアクセスは常にcol=0です。つまりrow=1がx, row=2がy, row=3がzを表すベクトルです。 ただのベクトルとして扱いやすいようにベクトル型にします。

cv::Vec3d t,r;
tvec.copyTo(t);     // 得られたtvec,rvecともに=3x1行列なので
rvec.copyTo(r);     // 単純なコピーでよい

これでベクトルになりました。 Hololens(UWP)のAPIに受け渡しやすいようにfloat3型のほうがよかったかもしれませんね。

HoloLensの空間上に姿勢推定結果を使ってレンダリングする

とりあえず適当にやってみます。 ここまでに得られた平行移動ベクトルと回転ベクトル(tとr)はいずれも装着者の頭の位置からの相対座標です。 レンダリングは基本的にワールド空間で行いたいので、ワールド空間座標を求めるためのHoloLens装着者の頭の位置、姿勢を表す行列を作ります

装着者の頭の位置から座標系を表す行列を求める

やってることはそのまんまです。

  1. 装着者の頭の姿勢(headRight, headUp, headBack)を3次元空間の基底とする
  2. レンダリング対象のオブジェクトの位置を頭の位置からの相対座標で設定する
  3. ワールド空間上でのカメラの位置と姿勢+オブジェクトの位置を表現する行列を作る
     // HoloLensのpointerposeをもとに座標系を求める。SolvePnPで得られている結果は、この座標系からの相対座標。
        float3 const headPosition = pointerPose->Head->Position;
        float3 const headForward = pointerPose->Head->ForwardDirection;
        float3 const headBack = -headForward;
        float3 const headUp = pointerPose->Head->UpDirection;
        float3 const headRight = cross(headForward, headUp);

        // 装着者の頭の位置を原点としてレンダリング対象の位置を求める
        m_targetPosition = headPosition + (headRight * trans[0]) + (headUp * -trans[1]) + (headBack * -trans[2]);
        m_normal = normalize(-m_position);

        // 時間軸方向の線形補間
        float3 const prevPosition = m_position;
        m_position = lerp(m_position, m_targetPosition, lerpDeltaTime);

        // 行列をつくる
        camera = make_float4x4_world(m_position, -m_normal, headUp);

VisualStudioが吐き出した元々のスケルトンからの改造なのですが、m_targetPositionを求めるためにベクトルにベクトルを掛けていて謎でした。 「内積でも外積でもない、なんだこれ?」と悩んでいましたが、よく考えると、平行移動行列とベクトルの積を展開したものです。

なので、m_targetPositionがワールド座標系における検出対象オブジェクトの位置になります。 (ワールド空間上の座標=ワールド空間上のカメラ座標 * カメラからの相対座標で表現されたオブジェクトの座標)

cameraにはこの平行移動成分とカメラの姿勢が含まれた行列が入ります。

オブジェクトのローカル座標系での回転量を設定

回転ベクトルをロドリゲスの回転公式から求めてもいいのですが、OpenCVDirectXでは座標系の変換が面倒なので、DirectXの世界で行列を作りました。 手順としては

  1. 回転ベクトルの大きさを求め、回転量とする
  2. 回転ベクトルを正規化。これで回転の軸として使える
  3. make_float4x4_from_axis_angleで、回転軸と回転量をもとにした行列を作る

です。ちょっと回りくどいのでそのうち直したいところです。

        // OpenCVとDirectXの行列の変換とか面倒なので、DirectX(UWP)のAPIで行列を作る
        auto r = float3{ rot[0], rot[1], -rot[2] };
        float strong = length(r);
        float3 axis = normalize(r);
        float4x4 local_rot = make_float4x4_from_axis_angle(axis, strong);

最後に回転行列を求めて順序を間違えないようにかけてあげればOKです。 この行列はそのままVirtexShaderが使うモデル行列になります。

     m_modelConstantBufferData.model = local_rot * camera;

できた!でも、、、

とりあえず板ポリゴンは出ました。しかしまだ面白味のある3Dモデルがレンダリングできていません。。。 座標書いた板ポリゴンだけじゃ味気ないです。そもそも座標ズレている気がしますし。

あと細かいところですが、座標変換が直観的にわかりにくいコードになってしまいました。 モデルの位置と姿勢は本来はモデル座標系のお話なので、カメラには含めるべきではない気がします。 (とはいえ、SolvePnPではカメラが原点からZ軸方向を向いているという前提での姿勢推定なので、この記述でもいいのかもしれない?)

感想と今後の展望

やっと「実機がないとできないこと」をやり始められたので一安心です。 カードの検出と姿勢推定もとりあえずはできたので、次は安定化のために検出したカードの上にアンカー置いてみたいところです。 またカードの種別を識別したいのでDeepLearningのモデルを使ってのカードの画像分類もやっていきたいですね。 やりたいこと山盛りです。

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

MTGのカード画像を分類する学習器をつくる

どうしてイラストから分類?

まず、わざわざDeepLearningフレームワークを使ってイラスト分類する理由です。

最初は安直に「カードに名称書いてあるやん。これ文字認識(OCR)にかければいいでしょ」と思って文字認識API叩いてみました。 結果は惨憺たるもので、とても実用に耐えられるものではありませんでした。 工夫すればなんとかなる気はしますが、仮に英語だけに対応して精度をあげても、同じような苦労を日本語でもやることを考えるとコストかかりすぎです。

というわけで、DeepLearningでモデルを作って機械に分類する方法を作ってもらうことにしました。

MTGの学習用データをつくる

前回のエントリ http://catalina1344.hatenablog.jp/entry/2017/03/30/221959 で、MTGのカードイラストを手に入れることはできました。 これを水増しします。

水増しの方針は色々考えられますが、基本は元画像に対して何かの変化(ノイズ)を加えることです。 実例をもとにノイズの要因を考えてみると、次のようなものがあります。

  • 撮影環境の輝度変化
  • カード表面からの反射光
  • カメラ映像からカード部分を抽出する際のパースペクティブ変換での補間で発生するノイズ

これらを一度に全部やろうとすると収集がつかなくなるので、今回はパースペクティブ変換における「拡大縮小」に関するノイズを加えることにします。

1つの教師データをもとに特定のノイズのみを想定して学習データを水増しさせた場合、そのノイズ以外の外的要因に対するロバスト性がないモデルになることが予想されますが、今はそれは置いておきます。

とりあえずノイズを載せる

統計学的にノイズを載せるなら正規分布をもとにうんたらとかありますが、とりあえず今回は簡単な方法でいきます。 まず乱数が必要なので作ります。 C++の乱数って便利になってたんですね。

 // 乱数を生成する
    std::random_device  rnd;
    std::vector<std::uint_least32_t> v(10);
    std::generate(v.begin(), v.end(), std::ref(rnd) );
    std::mt19937    engine(std::seed_seq(v.begin(), v.end() ) );
    std::uniform_real_distribution<double>   dist(-noise_strong, noise_strong);

これでdistを使って乱数を得られます。

一般的にデジタル画像は縮小してから拡大するとぼやける(というかブロックノイズ)が乗るので、これを利用します。 以下のコードはopenCVを使って画像を縮小してから元のサイズに戻す処理です。縮小のサイズを変えることでノイズの載り方が変わるので、この縮小後のサイズに乱数で求めたオフセットをはかせます。(-noise_strong ~noise_strong) 一定量以上縮小しないとノイズとはならないので、baseには0.4とか0.3みたいな適当な値を入れておきます。

 double noise_factor = base + dist(engine);

    // 縮小して元のサイズに戻すだけ
    auto size = cv::Size((int)(src_img.cols*noise_factor), (int)(src_img.rows*noise_factor) );
    cv::resize(src_img, tmp, size);
    auto base_size = cv::Size(src_img.cols, src_img.rows);
    cv::resize(tmp, dst_img, base_size);

この方法で画像を水増しするとこうなりました。

f:id:Catalina1344:20170409205035p:plain

元画像のURLはこちら。

http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=417574&type=card

以前にAndroidタブレットでカード部分を抽出してパースペクティブ変換して取り出したものは2とか5みたいなぼやけた感じだった気がします。 Androidタブレットはもう稼働させてないので、すぐに比較できないのが残念です。

あとWebAPIからとってきた画像はどうもサーバー側で生成しているもののようで、実際のカードの画像ではない気がしています。 たとえば上記カードであればフレーバーテキストが枠とかみ合っていなくて違和感があります。

これは今後の課題ということで。 (うまくいけばイラストだけで分類できる可能性もありますし。)

Chainerで画像データを読み込む(datasetsを使う)

これで学習用データが準備できたので、DeepLearningに突っ込んでいろいろ試していきます。

Chainerにはユーザーが準備した学習データ(データセット)を読み込むための方法が提供されています。

今回は画像の分類なのでchainer.datasets.LabeledImageDatasetを使います。名前のまんまですね。 これは第一引数にファイルパスとラベルのタプルのリスト、第二引数に画像を参照するルートディレクトリを与えて使います。 たとえば

labeldImage[
  ("image_path_A-0", 0),
  ("image_path_A-1", 0),
  ("image_path_B-0", 1),
  ("image_path_B-1", 1)
]

こんな感じのデータです。 画像ファイルを置くディレクトリ構成を工夫してあげればラベルと画像の関連付けが簡単になるので幸せになれます。 ちなみに外部の適当なファイルに上記のようなデータの羅列を書いておいて読み込ませる使い方もできるらしい。

Chainerで画像データセットを学習用とテスト用に分割する

よくあるDeepLearningの学習フェーズでは、データセットを学習用データとテスト用データに分割して使います。 先人がせっかくいろいろと理論を考えてくれてるので、それを利用します。 先ほどの手順で得られたデータセットをchainerに分割してもらいます。

train, test = chainer.datasets.split_dataset_random(dataset, div_num)

これでtrainに学習用データ、testにテスト用データがそれぞれ格納されます。 他にも交差検証用の分割とかいろいろありましたが、まずは何か動くものが欲しいのでランダム分割を使いました。

とりあえず学習を試す

CNNの前に、まずは単純なmnistのサンプルについてるNNの構成を使えばいいじゃないと思って試しました。

残念ながらMemoryErrorになります。

どうしてMemoryError?

学習の前に重み係数を初期化しているあたりでMemoryErrorでお亡くなりになります。 どれくらいのメモリ容量を確保しようとしているのか見てみると、43G要素くらいでした。無理ですよね。

どうしてLinearなNNでそんな膨大なメモリ容量が必要なのか?

よく考えると、LinearなNNに大きな画像を入れようとしているからこそ起きる問題でした。 まず画像サイズについて。幅223,高さ311で3chなので約208KByteです。(水増し時点でαチャンネル削除したRGB形式なので3ch)。 これを32bit浮動小数点型として扱ったとしてもたかだか4倍です。 (以降、Byte型とかfloat型とか考えるの面倒なので"要素数"で統一します)

次にNNの形についてです。 mnistサンプルにあるNNはこんな形をしています。

class MLP(chainer.Chain):

    def __init__(self, n_units, n_out):
        super(MLP, self).__init__(
            # the size of the inputs to each layer will be inferred
            l1=L.Linear(None, n_units),
            l2=L.Linear(None, n_units),
            l3=L.Linear(None, n_out),
        )

    def __call__(self, x):
        h1 = F.relu(self.l1(x))
        h2 = F.relu(self.l2(h1))
        return self.l3(h2)

こうやって使いました。 274は分類ラベルの数です。(今回使っているカラデシュというセットには274種類のカードがある)

unitnum = 223*311*3
model = L.Classifier(MLP(unitnum, 274))

問題の箇所はl1,l2で、全結合層で、入力層と同じ数のユニットをもちます。 この層の重み係数を保持するために必要なデータの要素数は、「1つのユニットにつき208K個」です。 つまり、この重み係数の領域を確保するために必要なメモリサイズは208Kの二乗(=43.288G)の容量が必要になってしまいます。 今時のマシンはメモリが潤沢とはいえ、これでは無理ですね。

畳み込みNNにする

メモリ容量という側面から畳み込みNNが必要とされた一つの要因が見えた気がします。 単純に全結合していてはお話にならないということですね。

というわけで、おとなしく畳み込みニューラルネットワークを使ってみることにします。 まずは動くかどうかの実験です。畳み込み+MaxPoolingが2層、1層の識別層です。

class MLP(chainer.Chain):

    def __init__(self, n_units, n_out):
        super(MLP, self).__init__(
            l3=L.Linear(None, n_out),  # n_units -> n_out
            conv1 = F.Convolution2D(3, 3, 7, stride=3 ),
            conv2 = F.Convolution2D(3, 1, 7, stride=3 ),
        )

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

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

        y  = self.l3(h)
        return y

initの引数n_unitsを使わなくなりましたが、これで呼び出し元は変更なしで大丈夫です。

できた!

しばらく動作させて学習の進捗を見るとこんなグラフになりました。

f:id:Catalina1344:20170409205737p:plain f:id:Catalina1344:20170409205824p:plain

1epochあたり10秒くらいで、だいたい3時間くらい動かしました。

学習は遅いですが順調に進んでいるような気がします。 このあたりの高速化はDropOutを入れるとかいろいろあるので、追々試してみます。

感想と今後の展望

画像からMTGのカードを分類するための土台(を作るための準備)ができました。 学習には時間がかかるので色々とバッググラウンドで走らせて実験しつつHoloLensアプリ側の実装を進めたいと思います。

HoloLensからどうやってこの学習モデルを使うかは検討中です。 現状想定しているのは

  • OpenCV3以降のdnnモジュールを使ってHoloLens上で動作させる
  • PC上で動作させ、webAPIを作ってHTTP経由で叩く

この2つあたりです。

HoloLensの性能を見る限り、なんとなく後者が妥当な気がしています。

そもそもUWP用のOpenCVビルドは古いバージョンなのでdnn入っていませんし。

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

MTGの公式APIを叩いてみる

カードゲームのイラストを自動判別しようと試行錯誤しています。かたりぃなです。 今回はMagicTheGatheringのカードの自動判別の前に、データ集めに挑戦します。

どうやってデータを集めるか?

DeepLearningとか機械学習だの前に、まず学習させるデータが必要です。 学習用のデータは実際にカメラで撮影したもののほうが程よくノイズが乗って学習にはよさそうな気がしますが、そのためにはそういうデータを収集するアプリが必要です。

学習済みモデルをアプリで利用したいのに、学習するためのアプリを作る必要があって、鶏が先か卵が先かみたいな不毛な話になってしまいます。

仕方ないので試しに手作業で実際のカードをスキャナで取りんでみましたが、一枚あたり一分くらいです。一つのシリーズに200から300種類のカードがあるので、とりあえずは300分ですね。

こんなの手作業でやる気は起きません。そもそもすべてのカード揃ってるわけないですし、人力でカードの束からサーチしたりと手間かかりすぎます。

クローラーを書く?

少し試しましたが、面倒すぎます。スクレイピングした結果のデータを目視確認してラベル付けする必要もありますし、そんなの手作業でやっていてはお話になりません。

目視確認の代わりにDeepLearningでモデルを作ってそれを使えば……と思いましたが、またしても鶏と卵になってしまいます。

公式APIがあった

発売元から公式のAPIが提供されていました。こいつを使えばなんとかなりそうです。 https://docs.magicthegathering.io/

試しにREST-APIを叩いてみる

よくあるWebAPIの使い方と同じです。

カードに関するクエリであればHTTPRequestとして示されている

https://api.magicthegathering.io/v1/cards

のURLをベースとし、?の後ろにクエリを付けてあげればOKです。

名前検索するならname=“検索したいカード名"とか。

たとえば"nissa"を含むカード一覧を取得する例はこうなります。 https://api.magicthegathering.io/v1/cards?name=nissa ブラウザで開くとJSON文字列が並びます。

画像をとってくる

↑の結果得られたJSONには「名前に"nissa"を含むカード」がリストアップされています。このcards配列から適当な"imageUrl"要素を抽出すると、こんなURLがとれます。

http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=190411&type=card

ここにアクセスすると、確かにそれらしいイラストが取得できました。

Windows上から叩きたい

Linuxならcurlwgetですが、windows上で自動化するにはPowerShellから叩きたいところです。

というわけでInvoke-RestMethodというのを使えば一発でした。

先ほどのimageUrlの指す画像を保存するにはPowershellから

> Invoke-RestMethod -Uri "http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=190411&type=card" -OutFile test.jpg

できました。 これで理論上はMTG公式APIが提供している全てのカードの絵柄は、自動で収集できるということになります。

Pythonで自動化する

よくよく考えたらPowerShell書かなくても公式が出してる各プログラミング言語の実装を使えば済みますね。

公式のsdkPython版を落としてきて、適当に叩いていきます。 叩き方がわかればあとはスクリプト書いてぐるぐるまわせばいいので。

その1, 準備

まずはローカルでテストしやすいように全カードの情報をとります。 10分くらいかかるので待ちましょう。

import mtgsdk
cards = Card.all()

毎回待つの嫌なので、ローカルファイルに保存します

import pickle
base_file = open('cardlist.bin', 'wb')
pickle.dump(cards, base_file)
close(base_file)

80MBくらいのファイルになりました。

このデータを読み込むときは

import pickle
base_file = open('cardlist.bin', 'rb')
cards = pickle.load(base_file)

で読めます。webからのダウンロードは時間かかりますが、これなら数秒で終わります。

その2, 中身を確認

上記で取得してきたデータの中身を見てみます。

len(cards)
>>> 32768
dir(cards[0])
>>> 長いので省略。アトリビュートが表示される
cards[0].name
>>> 'Air Elemental'
cards[0].image_url
>>> 'http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=94&type=card'

要素が32768ていうのが何かの上限に引っかかってそうな値(216 signed?)で嫌な感じですが、URLも取れてるので先に進みます。

これでカード名をもとにローカルに保存するファイル名が生成できそうです。(この例だと"Air Elemental.jpg"とか。)

その3, Pythonからイメージをダウンロード

urllibというライブラリを叩くだけでした。

import urllib
card = cards[0] # 実験しやすいように先頭のカードの情報をとっておく
image_request = urllib.request.urlopen(card.image_url)
image_data = image_request.read()

これでimage_dataにダウンロードしたイメージのバイナリが格納されます。 あとはfileにwriteしてあげればOKです。

その4, スクリプトでカードイメージを全部ダウンロードする

先の全カードのダウンロードでは嫌な数字が見えているので、特定のエキスパンションだけダウンロードします。

いきなり数万枚もの画像分類モデルを作るよりは、少しずつ積み重ねていったほうが色々と見えそうというのもあります。

というわけで「カラデシュ」という弾の(略称=kld)カードをすべてダウンロードします。

from mtgsdk import Card
from mtgsdk import Set
import urllib

ktk_cards = Card.where(set='kld').all()   # ここで与えるパラメータset=にはエキスパンションの略称。

print("card num = {}", len(ktk_cards) )
for card in ktk_cards:
    image_url_request = urllib.request.urlopen(card.image_url)
    image_data = image_url_request.read()
    out_file_name = card.name +".jpg"
    print("{}".format(out_file_name) )
    out_file = open(out_file_name, "wb")
    out_file.write(image_data)
    out_file.close()

できました! ただし、同じカード名のものが重複してしまうとうまく保存できません。 土地カード(forestとかswampとか)は同一カード名で複数の絵柄があります。これは今後の課題ということで。

同様にしてset=のところを'ktk'とかにすれば「タルキール覇王譚」が取れます。

感想と今後の展望

実際にカードの属性見て気づいたのですが、カードを分類するための情報ってカード名だけじゃないんですよね。 学習モデルに突っ込むことを考えると、カード名は人間がつけた分類ラベルであって、そのラベルに「人間が見て意味を解釈できる文字列」が関連付けられているだけのことです。

次のステップでは今回の画像セットを水増しして、学習モデルに入力していきたいと思います。 HoloLens側のアプリも作っていきたいですね。

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

Hololensでビルボードを表示する

アプリをごりごり作るよりも前にデバッグ情報などを実機で見やすくする仕組みをつくりました。かたりぃなです。

結論

従来手法のビルボードはHoloLensではそのまま使うのは少し難ありです。HoloLensが提供している機能を使ってビルボードレンダリングするのが良い。

ビルボードって?

この用語が本当に正しいのかどうか疑問ではありますが、概要だけ。 一言で言うなら「常にカメラのほうを向くポリゴン」です。

どういうところで使うの?

3Dのゲームを遊んだことがある人なら一度は見かけたことがあると思います。 「常にカメラのほうを向いていなければいけない」ポリゴンというのは多々あって、例えば次のようなものが考えられます。

  • MMORPGなどでのキャラクタ名、ダメージ数字の表示、メニュー、ツールバーなど
  • FPSの照準アイコン
  • ポイントスプライト(パーティクル)

これらは一枚の板ポリゴンにテクスチャを張って、そのポリゴンが必ずカメラのほうを向くように設定することで実現できます。

どうやって実現するの?

カメラのほうを向くポリゴンを作る方法は、まず最も単純な方法としてはカメラ座標系でX,Y平面にポリゴンを書くだけで実現できると思います。 先の例でのメニューやツールバーなどはこれで解決ですが、応用してパーティクルを使った演出をする場合にちょっと不便で汎用性に欠けます。

というのもパーティクルはワールド空間上の特定の位置に配置されるオブジェクト(火の粉だったり、煙だったりのエフェクト)であるので、ワールド座標系で指定したいものです。

こうして考えると、カメラ座標系で直接ポリゴンを描くのではなく、ワールド座標系で座標を指定したうえでポリゴンが常にカメラのほうを向くようにしたいです。

3Dゲームなんか向けのサイトを見てみると、カメラの回転行列からの逆行列をオブジェクトの行列の回転行列成分にセットするという方法が一般的のようです。 というわけで、HoloLensのカメラ行列からビルボードを作れないか試してみます。

HoloLensのレンダリングパイプライン

カメラ行列からの逆行列を求めてそれを使うというアプローチですが、HoloLensで同様の手法を使うことはできなさそうです。 3Dモデルをレンダリングするとき、のカメラ行列の設定ですが、VisualStudioが生成するスケルトンではこうなっていました。

// DX::CameraResources::UpdateViewProjectionBuffer関数
    DX::ViewProjectionConstantBuffer viewProjectionConstantBufferData;
    bool viewTransformAcquired = viewTransformContainer != nullptr;
    if (viewTransformAcquired)
    {
        // Otherwise, the set of view transforms can be retrieved.
        HolographicStereoTransform viewCoordinateSystemTransform = viewTransformContainer->Value;

        viewProjectionConstantBufferData.viewProjection[0] = viewCoordinateSystemTransform.Left * cameraProjectionTransform.Left;
        viewProjectionConstantBufferData.viewProjection[1] = viewCoordinateSystemTransform.Right * cameraProjectionTransform.Right;
    }
// 以降、viewProjectionConstantBufferDataをUpdateSubresourceでVertexShaderのconstant bufferに転送する

constant bufferを参照するHLSLシェーダはこうなっています。

cbuffer ViewProjectionConstantBuffer : register(b1)
{
    float4x4 viewProjection[2];
};
// 略
VertexShaderOutput main(VertexShaderInput input){
    // 略
    // Correct for perspective and project the vertex position onto the screen.
    pos = mul(pos, viewProjection[idx]);
    output.pos = (min16float4)pos;

何やってるのかの前にAR/VRの基本的な話です。

HMDを使って3Dレンダリングするとき、左右のレンズに少しだけ異なる映像を出すことで立体感を出すという手法があります。(=ステレオグラム) 人間の目が少し離れた位置についていて~と話が長くなりそうなので、ここでは「そういう手法がある」程度にとどめておきます。

HoloLensも同じ理屈で、左右のレンズに同じものをレンダリングしているわけではなく、左レンズ用と右レンズ用の映像をレンダリングします。 HolographicStereoTransformというクラスが左レンズと右レンズそれぞれの透視投影変換とビュー行列をもっているので、これを引っ張ってくるわけですね。

それぞれのレンズ用にレンダリングするためにDrawIndexedInstancedを使って左レンズ用インスタンスと右レンズ用インスタンスレンダリングします。 テンプレートプロジェクトではシェーダーの入力パラメータにinstIdというのがいますが、これがインスタンスのIDというわけですね。

HolographicStereoTransform struct - UWP app developer | Microsoft Docs

なので、従来手法の「カメラの回転要素の逆行列を3Dモデルの回転行列に使う」というアプローチをそのまま流用しようとしても、モデルの回転行列を左右レンズそれぞれに生成する必要があって、それはあんまりです。

HoloLensの座標系で考える

そもそもHoloLensではスクリーン中央に常にカーソルがレンダリングされています。 このカーソルは視線の先の空間マッピングされたオブジェクト上に出るのですが、これと同じことができればいいわけです。

公式マニュアルに座標系のことが書いてありました。

Locatable camera

これを真似していけば良さそうです。というかできました。

バイスの相対座標と絶対座標

絶対座標と相対座標というと誤解しそうですが、概念として

  • 絶対座標:空間マッピングしたワールド空間上の座標を表す
  • 相対座標:デバイスそのものからの座標を表す

と解釈できます。 HoloLensのプロモーションなどで空間上に何かのオブジェクトを配置しているケースでは上記の絶対座標を用います。 相対座標はドラゴンボールスカウター名探偵コナンの探偵メガネのように装着者からみた座標系として考えられます。

バイス相対座標を取得する

まず、VisualStudioのHoloLensテンプレートプロジェクトでは、座標系として絶対座標(ワールド座標系)を使っています。 Windows::Perception::Spatial::SpatialStationaryFrameOfReferenceがそれです。

上記リンク先の記事を読みつつ、相対座標系に置き換えます。 Windows::Perception::Spatial::SpatialLocatorAttachedFrameOfReferenceがデバイスからの相対座標を扱うための参照です。

型を置き換えてもこのままではインタフェースが違うのでうまくいきません。 SpatialLocatorAttachedFrameOfReferenceでSpatialCoordinateSystem^を拾うには

GetStationaryCoordinateSystemAtTimestamp(prediction->Timestamp);

とします。

HoloLensの姿勢を取得する

これでHoloLensの相対座標系で色々できるようになりました。 姿勢(視線の向き)などは

SpatialPointerPose^ pointerPose = SpatialPointerPose::TryGetAtTimestamp(currentCoordinateSystem, prediction->Timestamp);

で拾えます。

公式マニュアルはこちら。

SpatialPointerPose class - UWP app developer | Microsoft Docs

pointerPoseからは次のようにして頭の位置、向きなどが拾えるようです。

pointerPose->Head->Position
pointerPose->Head->ForwardDirection
// etc.

あとはマニュアルどおりやればビルボードが出ました。

手っ取り早く解決したい(サンプルコードを持ってくる)

手っ取り早くサンプルコードをコピペして済ませる方法もありました。

https://github.com/Microsoft/Windows-universal-samples/tree/master/Samples/HolographicFaceTracking

HoloLens実機ない人のために動作概要の解説

  • HoloLensのカメラで実世界のカメラ映像を取得(media capture)
  • Microsoftの顔認識APIに映像を入力
  • 顔として認識できる領域があれば、そこを切り取ってDirectXテクスチャを作る
  • 切り取った顔領域をビルボードとして画面中央より少し左よりの位置にレンダリング
  • 顔領域と思われる領域の空間の少し上部に3Dモデルをレンダリング
  • 文字列をテクスチャとして生成する(direct2d)
  • 顔が認識できないもしくはカメラがない(エミュレータ)の場合は前述の文字列を書き込んだテクスチャをビルボードレンダリング

です。 顔検出は置いといて、エミュレータで動かしても「no avalilavle camera」のビルボードは出るので、必要箇所を抜粋すれば良いかと。 試しに該当する機能を持ってきてみると、期待通りビルボードが表示できました。

ちょっと詰まったポイントがあったので2点ほど。

以下、サンプルコード引用時のトラブルシューティングです。

サンプルコード引用したシェーダーのコンパイルに失敗する

GeometryShaderを新たに作ってVisualStudioでコンパイルすると、こんなエラーが出ます。

warning X3554: unknown attribute maxvertexcount, or attribute invalid for this statement
error X3514: 'main': input parameter 'input' cannot have a geometry specifier

これはVisualStudioプロジェクトに後から追加したhlslファイルはコンパイラの設定が不足していることによるものです。 maxvertexcountはgeometryShaderでのみ使えるアトリビュートなので、コンパイラに「これはgeometryShaderですよ」と教えてあげる必要があります。

新しいhlslファイルをソシューションに組み込んだ直後の設定はこうなっています。 この図ではシェーダーの種類が指定されていません。何シェーダを作るつもりなんでしょうね。 f:id:Catalina1344:20170319123008p:plain

例えばGeometryShaderならこういう形で指定します。pixelShaderやVertexShaderも同様。 シェーダーレベルは環境に合わせて適切なものを選択します。 f:id:Catalina1344:20170319123011p:plain

サンプルコード引用したが何も表示されない

VisualStudio2015の生成するHoloLens用Direct3Dのテンプレートと、上記サンプルコードでは、行列の型が違っています。

テンプレートではDirectX::XMFLOAT4X4を行列の型として使っていますが、サンプルコードではWindows::Foundation::Numerics::float4x4です。 全体で統一が取れていればいいので、どちらかに合わせてしまいましょう。

たぶん内部構造が少し違うのかなと推測していますが、追うの面倒なので「やっぱ型は合わせたほうがいいよね」程度で。

文字列をテクスチャとして生成できているか謎

そもそも基本的な機能が動いていないとお話になりませんよね。 例えばテクスチャがすべて黒だった(=何も表示されていないように見えていただけ)なんて悲しい思いはしたくないものです。

テクスチャが生成されていない/テクスチャの参照が失敗しているかもと不安な場合は、pixelshaderでテクスチャサンプラを使わずに単色出力を試すと幸せになれます。

たとえば

//   return min16float4(rgbChannel.Sample(defaultSampler, input.texCoord));
    return float4(1.f, 1.f, 1.f, 1.f);  // RGBA . Aは0に近いほど透明. 1は不透過

で、白色単色のビルボードが出るので、最初はこうしておけば問題切り分けに役立ちます。

感想と今後の展望

とりあえずビルボードレンダリングできるようになりました。 HoloLensの座標系も少し詳しくなりました。

あとはARやるための仕組みをごりごりと書いていきたいところですが、 もう少しコード整理して、いつでもデバッグ用に組み込める形にしてから次のステップに移りたいと思います。

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

HololensでMediaCaptureを使ってカメラプレビューを取得する

HoloLensで入力画像を処理する前準備をしました。かたりぃなです。

今回やること

今回の記事でやることをざっと列挙します。

  • UWPでMediaCaptureを叩いてプレビューフレームを取得する
  • OpenCVのMat形式で扱えるようにする
  • とりあえず画像処理する

です。 画像処理の結果を使って3Dレンダリングまでやりたかったのですが、ちょっと詰まってしまっているのでいったんここまで記事にしておきます。 シミュレータではカメラが使えないのでほとんどが実機デバッグになります。買ってよかったHoloLens。

カメラから映像を取得する

HoloLensでMediaCaptureを使ってプレビューフレームを取得するコードはMicrosoft公式がサンプルコードを出してくれているのでそのまま使います。 中身ざっと眺めたところ、CreateAsyncしてGetLatestFrameするだけで使えるみたいです。

https://github.com/Microsoft/Windows-universal-samples/blob/master/Samples/HolographicFaceTracking/cpp/Content/VideoFrameProcessor.cpp

早速コード。 アプリのメインクラスの定義にメンバ変数を作っておきます。 mediacaptureを制御するクラスと、プレビューフレームを処理するワーカースレッドです。

    std::shared_ptr<HolographicFaceTracker::VideoFrameProcessor>    m_videoProcessor;
    std::thread                                                     m_worker_thread;

CreateAsyncでインスタンス化したものを保持しておき、std::threadでぐるぐる回します。 取得した映像フレームを処理するVideoProcessingという関数を作りました。ここで画像処理をします。

    task<void> videoInitTask = HolographicFaceTracker::VideoFrameProcessor::CreateAsync()
    .then([this](std::shared_ptr<HolographicFaceTracker::VideoFrameProcessor> videoProcessor)
    {
        m_videoProcessor = std::move(videoProcessor);
        m_worker_thread = std::thread([this]{
            while (1) {
                if (m_videoProcessor) {
                    auto frame = this->m_videoProcessor->GetLatestFrame();
                    if (frame) {
                        if (Windows::Media::Capture::Frames::VideoMediaFrame^ videoMediaFrame = frame->VideoMediaFrame) {
                            VideoProcessing(videoMediaFrame);
                        }
                    }
                }
            }
        });
    });

OpenCVに渡す(前準備)

映像フレームをOpenCVで処理します。 その前にOpenCVをUWP向けにビルドしておいたライブラリをプロジェクトに組み込みます。 MSがUWP向けに準備してくれているOpenCVはこちら。 GitHub - Microsoft/opencv: Open Source Computer Vision Library

試しに組み込んだときの記事はこちら。 WindowsストアアプリでOpenCVを使う - catalinaの備忘録

というわけで同様にして組み込みます。

先ほどのサンプルコード見るとそのまま画素にアクセスできるらしいので真似します。

 auto buffer = frame->SoftwareBitmap->LockBuffer(Windows::Graphics::Imaging::BitmapBufferAccessMode::Read);
    IMemoryBufferReference^ bufferRef = buffer->CreateReference();

    Microsoft::WRL::ComPtr<Windows::Foundation::IMemoryBufferByteAccess> memoryBufferByteAccess;
    if (SUCCEEDED(reinterpret_cast<IInspectable*>(bufferRef)->QueryInterface(IID_PPV_ARGS(&memoryBufferByteAccess))))
    {
        BYTE* pSourceBuffer = nullptr;
        UINT32 sourceCapacity = 0;
        if (SUCCEEDED(memoryBufferByteAccess->GetBuffer(&pSourceBuffer, &sourceCapacity)) && pSourceBuffer)
        {
            // pSourceBuffer使ってごにょごにょする
            // YUV420Pとして扱えばいい
        }
    }

ここで注意として、今回のコードではbitmapはNv12フォーマットです。 NV12とは。。。 YUV Video Subtypes (Windows) とのことで、いわゆるYUV420P形式です。

プレビューフレームの画素のバイト列をOpenCVのcv::Matにする。

簡単に。 OpenCVのMatとして取り扱います。YUV420Pなので最初のプレーンを参照すれば輝度のみ取れるので、グレースケール画像として取り扱えます。

auto cv_preview_image = cv::Mat(cv::Size(frame->SoftwareBitmap->PixelWidth, frame->SoftwareBitmap->PixelHeight), CV_8UC1, pSourceBuffer);

これでOKです。 cv::Matのコンストラクタは色々ありますが、この形式のものを使います。 第一引数はcv::Sizeであらわされる画像の幅と高さ。 第二引数はピクセルフォーマット(厳密にはmatの各要素のtype)で、pSourceBufferが画素列の先頭アドレスです。

デバッガで目視確認する

ピクセルの数列見ても楽しくないので、画を見たいです。 MS公式がVisualStudioのプラグインとしてcv::Matのイメージビューワを提供してくれてるので使います。 Image Watch - Visual Studio Marketplace

適当にフィルタ処理してみるとそれっぽくできてるのが確認できました。 というわけで画像処理の入り口まで辿り着きました。

課題・今後の展望

HoloLens実機でのデバッグはそれなりに手間がかかります。デバッガで少し見て解る問題ならいいんですが、ちょっと複雑なことをやろうとすると、厳しいです。

特に問題になるのは、リアルタイム処理とVisualStudioのデバッガが相性悪すぎといったところです。 映像をリアルタイムに処理した結果をデバッガのログに出していても、それを見るために頭を動かしてしまうと状況が変わってしまいます。 こればっかりはどうしようもないので、HoloLens側でデバッグ情報をレンダリングする仕組みを作っておいたほうが後々幸せになれそうな気がします。

とりあえずビルボードでテキストをレンダリングする仕組みくらい作っておいて損はなさそうです。

では今回はこれにて。

HoloLensでChainerの学習経過を覗いてみた

せっかくHoloLensがあるので機械学習の途中経過を覗いて遊んでみました。かたりぃなです。

学習中のデータの可視化する意義

機械学習についてですが、Chainerなどのフレームワークがどうやって学習を進めているかよくわかっていません。 具体的にはフレームワークによってネットワーク内パラメータがどう変動していくのかが分からないのです。 optimizerがいて順伝播と逆伝播の誤差を~というのは理屈ではそうなのでしょうけれども、実際に中を見ていないのでしっくりきていません。

Chainer自体はよくできたフレームワークなのでexampleの中身動かして「あ、こんな簡単に動くんだね」と感動するのですが、そこから先どうしようかという展望を持ちにくい印象です。 学習が進むことによって「ニューラルネットワークのパラメータにどういう"変化"が起きているのか」を見れたらもっと欲望というか展望が見える気がします。

可視化といえばchainerが出しているログをグラフにしたり、各層を可視化するなどといった手法はありますが、それって静的なものなので、「どういう過程で生み出されたものなのか」が見えにくいです。 少しずつ変化していっているとしても、やっぱりそこを見てみたいですよね。 可視化した画像をブラウザで開いてCtrl+F5連打という力業を試しましたが毎回連打するのは疲れます。

というわけで、機械学習の進行状況をリアルタイムで見れるようにアプリを作ってみます。 せっかくHoloLensあるのでこいつを使います。

実運用ではオーバーヘッドが大きすぎ(GPUからCPUにNNを取り出してからnpz形式でファイル出力)で役に立たないとは思いますが、まずは動いているものを見て興味を持つという点が重要かなと思っています。

結果

こんなの出ました。一定周期で更新されるワイヤーフレームはまるで昭和時代のCGです。 だんだんと山と谷の差が大きくなっていく様子が観察できました。

f:id:Catalina1344:20170218215123p:plain

実世界のテーブルの上とかに表示しておいて山の裏側を見たいってときは回り込めば見えます。 (実機でのキャプチャしようと思ったのですがDirectXレンダリング結果をオーバーレイしたキャプチャがうまくできないので保留です)

やったこと、やらなかったこと

今回やってみたことと、保留にしたものを列挙します。 大まかに考えると、「可視化すること」が目標で、そこから洗い出された課題などはいったん保留です。

やったこと

  • chainerのネットワークの状態を一定周期でファイルに出力する
  • ネットワークの種類はautoencoder(可視化して結果がわかりやすそう)
  • HoloLens側からファイルをポーリングして一定周期で読み出す
  • HoloLens上でDirectXを使ってレンダリング

やらなかったこと

  • 機械学習の分野の詳細に立ち入ること(学習が収束するとか、効率の良いネットワークだとか)
  • 可視化した結果の正確性
  • 過度な高速化(HoloLensでとりあえず表示できればいい)

以降、実装の詳細です。

chainerのサンプルコードを編集

chainerはPC上で実行します。 まずchainerのmnistサンプルコードでスナップショットを出力する部分がありますが、ここを変更します。

trainer.extend(extensions.snapshot(filename='work.npz'), trigger=(1, 'epoch'))

デフォルトでは連番ファイルを出力するようになっているところを変更します。同一ファイルをwrite/readし続けたほうが実験としてはやりやすいので。 また、トリガは毎epoch終了ごととします。 ネットワークが大きいくなると毎epochやっていると重そうですが、今は気にしないことにします。

次にネットワークをautoencoderにします。 autoencoderを選んだ理由は実装が簡単かつ「なんかそれっぽい気がする」ものが見えそうだからです。 データセットにmnistを使うので入力/出力ともに784次元のデータです。

class MLP(chainer.Chain):
    def __init__(self, n_units):
        super(MLP, self).__init__(
            # the size of the inputs to each layer will be inferred
            encoder = L.Linear(None, n_units),  # n_in -> n_units
            decoder = L.Linear(None, 784)
        )

    def __call__(self, x):
        h = F.relu(self.encoder(x))
        return self.decoder(h)

ここでencoder/decoderと名前を付けましたが、この名前がそのままスナップショット内のネットワークのレイヤ名に使われるので、わかりやすい名前をつけておいたほうが幸せになれます。

最後にデータセットを入出力ともに同じものを使うようにします。 withlabelをfalseにすれば教師ラベルなしの入力データがそのまま取得できます。 ただし、学習のイテレーションでは教師データをTupleDatasetとして与える必要があるので、 入力=出力となるDatasetを生成します。testも同様です。

    train, test = chainer.datasets.get_mnist(withlabel=False)
    train = tuple_dataset.TupleDataset(train, train)
    test = tuple_dataset.TupleDataset(test, test)

ちょっと寄り道して動作確認。 それっぽく動いているかどうかpythonから画像を出力して確認してみます。 plot関係のライブラリを使って画像に出します。

なんか見れた。 f:id:Catalina1344:20170218151616p:plain

寄り道の詳細

ClassifierしたmodelからだとNNを参照するのが大変そう(簡単にできるのだろうか?)なので、networkをとっておいてそこから重み係数を参照することにしました。 また画像として出力するにはユニット数が少ないほうが出力画像に収めやすいので10x10枚の可視化=100ユニットとしています。

import matplotlib.pyplot as plt
import numpy as np

#~~~
network = MLP(unit)
model = L.Classifier(network)

#~~~
save_images(network.encoder.data, "plot_test.png")

#~~~
# 画像で保存(隠れ層=100という前提)
def save_images(x, filename):
    fig, ax = plt.subplots(10, 10, figsize=(10, 10), dpi=100)
    for ai, xi in zip(ax.flatten(), x):
        ai.imshow(xi.reshape(28, 28))
    fig.savefig(filename)

本当にこれでいいのか不安ですが、これでchainer側で可視化の準備は整いました。 スナップショットはresultフォルダに出力されます。 ついでにdotファイル吐かせてgraphvizで見れることも確認しました。

chainerのスナップショットをhttpで取得する

まずHTTPサーバをpythonで簡単に立ち上げます。3.5系ではこうなります。

python -m http.server

これで8000番ポートが開くので、httpアクセスすると上記スクリプトを実行したディレクトリに置かれたファイルを参照できます。

次はHoloLensアプリ側の実装です。 と、その前にHoloLensの設定とリモートデバッガの設定をします。

HoloLensの設定

HoloLensのデバイスポータルに接続してデバイスの情報を拾えるようにしておきます。

これをやっておけばPCのブラウザからHoloLensにアクセスできるようになります。 証明書のインストールとかあるので、手順は公式を参照。

HoloLens 用 Device Portal | Microsoft Docs

リモートデバッガを構成する

VisualStudioのデバッグ構成を変更してリモートデバッグの設定をします。 とはいえ毎回IP調べるのは面倒なので固定IPにしたほうが便利です。 f:id:Catalina1344:20170214214457p:plain

HTTPサーバからファイルを取得する

ここからHoloLens側の実装です。 VisualStudio2015のテンプレートからHoloLens DirectX11を使って作業していきます。

httpアクセスしてchainerの学習中スナップショットを取得します。 UWPのhttpclientクラスはキャッシュが効いてしまって実験中は不便なのでHttpCacheReadBehavior::MostRecentを設定します。 あとは普通にresponseを見てdatareadすれば済みます。 エラーチェックしていませんが、ストアに提出するようなアプリのときはもうちょっと真面目にやりましょう。

     auto uri = ref new Windows::Foundation::Uri(L"http://192.168.0.14:8000/result/work.npz";);
        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;
        create_task(client->GetAsync(uri)).then([cb](Windows::Web::Http::HttpResponseMessage ^ response) {
            response->EnsureSuccessStatusCode();
            return response->Content->ReadAsBufferAsync();
        }).then([cb](Windows::Storage::Streams::IBuffer ^ input) {
            auto reader = Windows::Storage::Streams::DataReader::FromBuffer(input);
            auto loaded_buffer = reader->ReadBuffer(input->Length);
            parse_npz(loaded_buffer, cb);
        });

この処理を一定周期(1秒間隔とか)で呼び出してあげたうえでzip形式を解釈してzlibで伸長してあげれば完成です。

Direct3Dレンダリングする

取得したNNのencoderレイヤの各ユニットの重み係数は28x28のシングルチャンネル画像として解釈できます。 今回は単純にW係数を高さ(Y)にとることにします。(つまり、X,Zは0~27の間のいずれかの値で、X,Zが与えられるとYが一意に決まる,Yは重み係数) ポリゴンレンダリングすると色々と大変なのでワイヤーフレームでいきます。ワイヤーフレームのほうが味がありますし。

ラスタライズステージでワイヤーフレームにします。

 D3D11_RASTERIZER_DESC rasterizerDesc = {
        D3D11_FILL_WIREFRAME,   // ワイヤフレーム
        D3D11_CULL_NONE,        // カリングなし
        FALSE,
        0,
        0.0f,
        FALSE,
        FALSE,
        FALSE,
        FALSE,
        FALSE
    };
    ID3D11RasterizerState* rasterizerState = NULL;
    HRESULT hr = m_deviceResources->GetD3DDevice()->CreateRasterizerState(&rasterizerDesc, &rasterizerState);
    if (FAILED(hr)) {
        // TODO : エラー処理
    }
    context->RSSetState(rasterizerState);

しかしこれではやりたいことに対して少し不足しています。 VisualStudioのDirectXテンプレートではTriangleListをシェーダーに入力していますが単純にLineListを入力したいところです。

GeometryShaderを書く

LineListをシェーダに入力するには、まずCPU側のコードは単純に

context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_LINELIST);

すれば良いです。

これに合わせてGeometryShadeerのコードを変更します。 3頂点を受け取ってに三角形を吐き出すのではなく、2頂点を受け取って1つのLine(始点,終点)を出力するよう書き換えます。

-[maxvertexcount(3)]
-void main(triangle GeometryShaderInput input[3], inout TriangleStream<GeometryShaderOutput> outStream)
+[maxvertexcount(2)]
+void main(line GeometryShaderInput input[2], inout LineStream<GeometryShaderOutput> outStream)

シェーダー内でやっているジオメトリ生成も同様にして2頂点ずつ生成するようにします。

    [unroll(2)]
    for (int i = 0; i < 2; ++i)
    {
        // 略
    }

あとはLineListの規則に従って頂点バッファとインデックスバッファを定義して格子状のメッシュを生成すれば完成です。

頂点バッファを動的に更新する

表示するワイヤーフレームができたので、Y軸を動的に更新します。 乱暴ですが新しい頂点情報を受け取るたびにVertexBufferを作り直すことにしました。 UpdateSubResourceのほうが適任な気がしますが、HoloLensのサンプルコードのspatialmappingもこういう実装になっていたりするのでいったん良しとします。 コードは自分自身納得いっていないので省略です。

参考にさせていただいたサイト

chainerのモデルをC++から読み込むのはこちらのサイトが参考になりました。そのままnpzを読み込めました。 ChainerのモデルをC++で読み込む - TadaoYamaokaの日記

感想

chainerのモデルをC++から読みだすことができるようになりました。

「なんとなくdeepLearningやってる」感じが見えたので良しとします。 できれば1つのレイヤ内のすべてのユニットを可視化したかったのですが、視界からはみ出して残念な見栄えになるので諦めました。

とはいえChainerに慣れていない段階だと便利な気がします。HoloLens装着したまま別の作業進めつつ、進捗状況をチラ見できます。 PCの画面を占有しない+進捗確認の作業が単純(頭の向きを変えるだけ)っていうのは正義ですね。

しかしながらワイヤーフレーム表示では学習が進んでデータのレンジが大きくなってきたときに辛いです。実世界に表示したモデルをぐるっと回って見るのであれば光源処理を入れたポリゴンのほうがいい気がします。

あと今回の簡易的な実装ではHoloLens側の処理がやたら重いです。 どれくらい重いかというとHoloLensのジェスチャ操作が効きにくくなるくらい重いです。

原因は詳しく調べてはいませんが、Chainerのsnapshotのnpzを展開するとき何も考えずに全部展開してしまっているのが原因かもしれないと思っています。というのも、snapshotで保存されるnpzはすべての情報が含まれていて、今回使わないdecoderやoptimizerなんかも展開してしまっているので。。。 気が向いたときに調べてみます。

今後の展望

読みだした学習済みモデルを食べさせるC++実装があれば色々できそうです。 あとはdotファイルをうまく解析すれば「c++からchainerのモデルなんでも読めるよ」という夢も叶うかもしれませんね。 書いてて気づきましたがboost::proptreeとcv::dnn使えばできそうな気がします。

アプリづくりの練習としてChainerの出力したdotファイルを読んで、どの層を見るかを選択するUIつけてあげれば面白そうです。

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