catalinaの備忘録

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

OpenMVG, OpenMVSで3D再構築する

前回のエントリでOenMVGを使って三次元再構成ができることが確認できました。 今回はさらに進んで、ジオメトリを再構成してみます。

カメラ内部パラメータの設定

前回のエントリで生成したカメラ内部パラメータは間違えていました。正しくはこうなります。

間違えていた原因としては、OpenCVキャリブレーション関数でのRow/COlumnsの扱いが逆になっていたためでした。

手作業で格子状の画像からこんな感じの交点を抽出しましたが、これは横7, 縦5個の交点です。

    corners = np.array( [
        [1013, 321], [1052, 319], [1090, 318], [1129, 316], [1166, 315], [1204, 314], [1240, 313],
        [1012, 360], [1050, 358], [1088, 357], [1126, 353], [1164, 353], [1201, 352], [1237, 350],
        [1012, 395], [1050, 394], [1087, 394], [1124, 391], [1160, 389], [1197, 389], [1234, 386],
        [1011, 432], [1048, 431], [1085, 430], [1122, 427], [1158, 425], [1195, 424], [1230, 422],
        [1011, 469], [1047, 466], [1083, 465], [1120, 463], [1156, 460], [1192, 458], [1227, 456],
    ], np.float32  )

どういうことかというと、

    rows = 7
    cols = 5

として対応する画像上の点を生成するべきなのですが、rowとcolを逆に設定していたためにおかしなことになってました。 opencvあるあるですね。

というわけで、キャリブレーションやりなおすとこうなりました。

kp='2105.02823002;0;795.46581198;0;1386.68788826;422.25675948;0;0;1'
pIntrisics = subprocess.Popen( [os.path.join(OPENMVG_SFM_BIN, "openMVG_main_SfMInit_ImageListing"),  "-i", input_dir, "-o", matches_dir, "-k", kp, "-d", camera_file_params, "-c", "3"] )

キャリブレーションのコードはこちら

    cols = 5
    rows = 7

    # コーナー検出をせずに、自前で入力する
    corners = np.array( [
        [1013, 321], [1052, 319], [1090, 318], [1129, 316], [1166, 315], [1204, 314], [1240, 313],
        [1012, 360], [1050, 358], [1088, 357], [1126, 353], [1164, 353], [1201, 352], [1237, 350],
        [1012, 395], [1050, 394], [1087, 394], [1124, 391], [1160, 389], [1197, 389], [1234, 386],
        [1011, 432], [1048, 431], [1085, 430], [1122, 427], [1158, 425], [1195, 424], [1230, 422],
        [1011, 469], [1047, 466], [1083, 465], [1120, 463], [1156, 460], [1192, 458], [1227, 456],
    ], np.float32  )

    image_points = []
    image_points.append(corners)

    # 検出した画像座標上の点に対応する3次元上の点を作成する。
    world_points = np.zeros((rows * cols, 3), np.float32)
    world_points[:, :2] = np.mgrid[:cols, :rows].T.reshape(-1, 2)
    print('world_points shape:', world_points.shape)  # world_points shape: (54, 3)

    for img_pt, world_pt in zip(image_points[0], world_points):
        print('image coordinate: {} <-> world coordinate: {}'.format(img_pt, world_pt))

    # 画像の枚数個複製する。
    imagenum = 1
    object_points = [world_points] * imagenum

    np.set_printoptions(suppress=True)
    ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(object_points, image_points, (1920, 1017),None,None)
    print("camera instristics mtx = {}".format(mtx) )

PythonからOpenCVを使ってのキャリブレーションは、こちらのサイトを参考にさせていただきました。 http://pynote.hatenablog.com/entry/opencv-camera-calibration

キャリブレーションで得たパラメータを使ってOpenMVGを叩くコードはこんな感じです。 ほぼチュートリアルのままですが。 https://gist.github.com/javoren/dd7075211776ee51ac45c06b3827d905

これを実行すると

  • imagesディレクトリの画像をもとに三次元点群を生成する
  • tutorial_out/reconstruction_globalディレクトリに結果を出力する

という感じに動きます。

できあがったデータはこんな感じです。

f:id:Catalina1344:20191007140654p:plain

前回のエントリではキャリブレーションパラメータが間違えていて、何が何やら状態でしたが、これなら何か見えてきそうな気がしますね。

openMVS形式にファイルを変換する

できあがったデータはopenMVSにもっていきたいので、プロジェクトを変換します。

openmvgのコンテナ内で次のコマンドを実行します。

cd /opt/openMVG_Build/Linux-x86_64-RELEASE
openMVG_main_openMVG2openMVS -i /mnt/work/tutorial_out/reconstruction_global/sfm_data.bin /mnt/work/openMVS/scene.mvs -o /mnt/work/scene.mvs

これで/mnt/work/scene.mvsという、OpenMVSで利用可能なファイルが完成しました。

OpenMVSの用意

openmvsのリポジトリ公式の手順をもとにdockerコンテナを作成します。 まとめるのが下手なので、一旦個別にRUNしていくだけの簡単なものです。 rootディレクトリにすべて展開しちゃってるのはご愛敬ということで。。。

# Use Ubuntu 18.04 (will be supported until April 2023)
FROM ubuntu:16.04

# Add openMVG binaries to path
ENV PATH $PATH:/opt/openMVG_Build/install/bin

# Get dependencies
RUN apt-get update && apt-get install -y \
  cmake \
  build-essential \
  graphviz \
  git \
  coinor-libclp-dev \
  libceres-dev \
  libflann-dev \
  liblemon-dev \
  libjpeg-dev \
  libpng-dev \
  libtiff-dev \
  python-minimal; \
  apt-get autoclean && apt-get clean

##Prepare and empty machine for building:
RUN apt-get -y install build-essential git mercurial cmake libpng-dev libjpeg-dev libtiff-dev libglu1-mesa-dev libxmu-dev libxi-dev
RUN hg clone https://bitbucket.org/eigen/eigen#3.2
RUN mkdir eigen_build
RUN cd eigen_build && cmake . ../eigen
RUN cd eigen_build && make && make install

# boost, opencv
RUN apt-get -y install libboost-iostreams-dev libboost-program-options-dev libboost-system-dev libboost-serialization-dev
RUN apt-get -y install libopencv-dev
RUN apt-get -y install libcgal-dev libcgal-qt5-dev

##VCGLib (Required)
RUN git clone https://github.com/cdcseacave/VCG.git vcglib
RUN apt-get -y install libatlas-base-dev libsuitesparse-dev
RUN git clone https://ceres-solver.googlesource.com/ceres-solver ceres-solver
RUN mkdir ceres_build
RUN cd ceres_build && cmake . ../ceres-solver/ -DMINIGLOG=ON -DBUILD_TESTING=OFF -DBUILD_EXAMPLES=OFF
RUN cd ceres_build && make && make install

RUN apt-get -y install freeglut3-dev libglew-dev libglfw3-dev
RUN git clone https://github.com/cdcseacave/openMVS.git openMVS
RUN mkdir openMVS_build 
RUN cd openMVS_build && cmake . ../openMVS -DCMAKE_BUILD_TYPE=Release -DVCG_ROOT="$main_path/vcglib"

RUN cd openMVS_build && make && make install

このコンテナを起動するdocker-compose.ymlは次のようになりました。

version: "3"
services:
  openmvg:
    build:
      context: ./openMVG
      dockerfile: ./Dockerfile
    image: openmvg
    container_name: "openmvg"
    tty: true
    volumes:
      - "./work/:/mnt/work" 
      - "./work/images/:/opt/openMVG_Build/Linux-x86_64-RELEASE/images"

  openmvs:
    build:
      context: ./
      dockerfile: ./dockerfile
    image: openmvs
    container_name: "openmvs"
    tty: true
    volumes: 
      - "./work/:/mnt/work"
      - "./work/images/:/openMVS_build/bin/undistorted_images"

このコンテナに入って、次のコマンドを実行していけば三次元再構成ができます。

cd /openMVS_build/bin
# 密な点群にする
./DensifyPointCloud /mnt/work/openMVS/scene.mvs

密な点群にしてみるとこうなります。 f:id:Catalina1344:20191007140818p:plain

点の数がずいぶんと増えたので、キャラクタも武器も目視確認できますね。

cd /openMVS_build/bin
# メッシュにする
./ReconstructMesh /mnt/work/openMVS/scene_dense.mvs

点のままでは3DCGソフト上で扱いにくいのでメッシュにします。

f:id:Catalina1344:20191007141559p:plain

Blenderへのインポート時点でテクスチャがはがれてしまいましたが、ひとまず読み込みできました。

試しにキャラクタ部分のみ残してみるようにしたところ、およそ20万ポリゴンでした。

感想と今後の展望

ゲームのスクリーンショットをもとに三次元再構成ができることの確認ができました。 少し手直ししてあげれば3Dプリンタで出力することもできそうです。

ただし、今回の手法は弱点も存在していて、「光沢部分が凸なポリゴンとして認識されてしまう」という課題があります。 これはゲーム画面のレンダリング時は光沢として白色になるわけですが、分析時はその白色は凸のためにできたのか、もともと白かったからなのかという判断がうまくつかないからのようです。

もう少し入力データを工夫して色々と試してみたいと思います。

また、今回利用した手法(SfM)以外にも、DeepLearningを用いた手法もあるようなので、そちらも試していきたいと思います。

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

OpenMVGを試してみる(環境構築+α)

夏が終わって秋らしい気候になってきました。秋は色々と創作意欲を刺激されるイベントが多いので、今のうちから準備をやっていきたいと思います。

OpenMVGとは

公式はこれですね。 github.com

MultiViewGeometryの略で、複数視点から三次元ジオメトリを再構成しようってやつです。

技術的なカテゴリとしてはフォトグラメトリとかSLAMに近いもののようです。

これらの細かい区別はよくわかりません。あしからず。

モチベーション

なんでこういうのに興味を持ったかというと、ARアプリと3Dプリンタを組み合わせるときに大事な技術になるのではないかという思いからです。

以前のエントリで、ゲーム中の登場アイテムを3Dプリンタで制作してARエフェクトを出してみるってのをやりました。

catalina1344.hatenablog.jp

これの最大の問題点は工数がかかりすぎという点で、趣味レベルで余暇を使ってやっているとはいえざっとこれくらいかかりました。

  • モデリング2か月
  • プリント, 仕上げ塗装 それぞれ1か月
  • ARアプリ1か月

スケールアップを考えたとき、一番ネックになりそうなのはモデリングかなと思っていて、ここをツール群を使って自動化したいなと考えました。

で、画像からの3Dモデル再構築ってどうなのだろうってことで試してみました。

もちろんゲーム中のSSを解析したりする行為は著作権やソフトウェア利用許諾に記載されているリバースエンジニアリングの禁止条項にかかる懸念はありますので、それはその問題として別途解決しておく必要はあります。

では早速try。

OpenMVG実行環境をdockerで作る

まずは環境作りからです。

ビルド環境

環境はいつものです。 WIndows10-pro上のdocker desktop for winwowsを使用します。

公式リポジトリをcloneしてきて、ビルドパラメータを修正します。 dockerfile末尾でmakeしている箇所の-jオプションを削除します。

# Build
RUN mkdir /opt/openMVG_Build; \
  cd /opt/openMVG_Build; \
  cmake -DCMAKE_BUILD_TYPE=RELEASE \
    -DCMAKE_INSTALL_PREFIX="/opt/openMVG_Build/install" \
    -DOpenMVG_BUILD_TESTS=ON \
    -DOpenMVG_BUILD_EXAMPLES=OFF \
    -DFLANN_INCLUDE_DIR_HINTS=/usr/include/flann \
    -DLEMON_INCLUDE_DIR_HINTS=/usr/include/lemon \
    -DCOINUTILS_INCLUDE_DIR_HINTS=/usr/include \
    -DCLP_INCLUDE_DIR_HINTS=/usr/include \
    -DOSI_INCLUDE_DIR_HINTS=/usr/include \
    ../openMVG/src; \
#    make -j 4;
    make;

jオプションを削除する意味

まずmakeのjオプションはジョブの個数を指定するオプションです。デフォルトの指定だと4プロセス平行に走らせるという意味です。

大雑把にいうと4CPUあればいい感じに走るわけなのですが、Docker for windowsのデフォルト設定ではコンテナに1CPUしか割り当てていません(うちの環境だけかも?)

この1CPU環境でjオプションを指定しているとコンテナごと応答なし状態になってしまったので、1プロセスでやってしまうということにしました。

起動パラメータ

また、このdockerfileのあるディレクトリの一つ上の階層にdocker-compose.ymlファイルを配置します。 openMVGのdockerfileが生成するコンテナの起動パラメータをまとめただけのものです。

version: "3"

services:
  openmvg:
    build: 
      context: ./openMVG
      dockerfile: ./Dockerfile
    image: openmvg
    container_name: openmvg
    tty: true
    volumes:
      - ./work:/mnt/work/

動作確認

チュートリアルらしいファイルが置かれているので叩いてみます。

対象ファイルは/opt/openMVG/src/software/SfM/tutorial_demo.py.inですが、そのままではパス指定がダメなので少し編集します

Linux上で編集したりするの大変なので、/mnt/workにでもコピーしてきて編集するのが楽です。(docker-composeでホスト側にマウントするよう指定した)

冒頭の部分を次のように書き換えます。

# Indicate the openMVG binary directory
#OPENMVG_SFM_BIN = "@OPENMVG_SOFTWARE_SFM_BUILD_DIR@"
OPENMVG_SFM_BIN = "/opt/openMVG_Build/Linux-x86_64-RELEASE/"

# Indicate the openMVG camera sensor width directory
#CAMERA_SENSOR_WIDTH_DIRECTORY = "@OPENMVG_SOFTWARE_SFM_SRC_DIR@" + "/../../openMVG/exif/sensor_width_database"
CAMERA_SENSOR_WIDTH_DIRECTORY = "/opt/openMVG/src/software/SfM/" + "/../../openMVG/exif/sensor_width_database"

これでOK。 実行は python tutorial_demo.py.inです。建物の画像をダウンロードしてきて、解析した結果をtutorial_outディレクトリに出してくれます。 ply形式なので、meshlabとかで見られます。

少しいじってみる(失敗)

チュートリアルのコードを見ると、GitHub上から画像データをダウンロードしてきて、それをもとにSfMをやってるということがわかります。 せっかくなので自前のデータを用意して差し替えてみましょう。 コードを次のようにしてみました。

def get_parent_dir(directory):
    return os.path.dirname(directory)


#os.chdir(os.path.dirname(os.path.abspath(__file__)))
#input_eval_dir = os.path.abspath("./ImageDataset_SceauxCastle")
## Checkout an OpenMVG image dataset with Git
#if not os.path.exists(input_eval_dir):
#  pImageDataCheckout = subprocess.Popen([ "git", "clone", "https://github.com/openMVG/ImageDataset_SceauxCastle.git" ])
#  pImageDataCheckout.wait()
#
#output_eval_dir = os.path.join(get_parent_dir(input_eval_dir), "tutorial_out")
#input_eval_dir = os.path.join(input_eval_dir, "images")
#if not os.path.exists(output_eval_dir):
#  os.mkdir(output_eval_dir)

input_eval_dir = "./images"
output_eval_dir = "./tutorial_out"

if not os.path.exists(output_eval_dir):
  os.mkdir(output_eval_dir)

input_dir = input_eval_dir
output_dir = output_eval_dir
print ("Using input dir  : ", input_dir)
print ("      output_dir : ", output_dir)

step5あたりで The input SfM_Data file "./tutorial_out/reconstruction_global/sfm_data.bin" cannot be read.と言われてしまいます。 このエラーの原因は、直前のプロセスまでに生成されているべきSFM_data.binが存在しないために発生するエラーです。

なぜsfm_dataが生成されていないかというと、今回はゲーム中の適当な画像を使ったのでexifヘッダが存在しないためでした。 どうもOpenMVGの実装上、jpegフォーマットのexifヘッダからカメラ内部パラメータを推測しているようです。 (カメラパラメータは、例えばテストデータとして使っているリポジトリにはK.txtなどといった形で記載されているものです。)

さらに少しいじってみる(少し進展した)

step1でsfmdata\jsonを生成していますが、ここでカメラ内部パラメータを与えることができます。 適当に引っ張ってきた値なので、使用する画像に合わせたパラメータに置き換える必要があります。

kp = "2905.88;0;1920;0;2905.88;1017;0;0;1"

print ("1. Intrinsics analysis")
#pIntrisics = subprocess.Popen( [os.path.join(OPENMVG_SFM_BIN, "openMVG_main_SfMInit_ImageListing"),  "-i", input_dir, "-o", matches_dir, "-d", camera_file_params, "-c", "3"] )
pIntrisics = subprocess.Popen( [os.path.join(OPENMVG_SFM_BIN, "openMVG_main_SfMInit_ImageListing"), "-i", input_dir, "-o", matches_dir, "-k", kp, "-d", camera_file_params, "-c", "3"] )
pIntrisics.wait()

これで、plyファイルが出力されるようになりました。meshlabで見てみるとこんな感じになりました。 f:id:Catalina1344:20190923131225j:plain うーん、よくわからないですね。

父の日ですね

たまには文章書かないと日本語忘れてしまいそうです。職場でも人と話することもないので。かたりぃなです。

 

ブログで技術以外のことを書くのは初めてかもしれません。どちらかというと政治&過去語りな内容。

興味のない人はそっと閉じてくださいね。

 

まず、私の父はもういません十数年前に他界しました。

そんな父は私がプログラマになるための最後の助けをしてくれました。

 

上京・進学のとき

時代は20世紀末期。就職氷河期が始まっているので大学進学したところで就職先があるかどうかもわからないご時世。

国公立ならあるいは?という選択肢もありましたが、浪人などしようものなら阪神淡路大震災の影響で悪化していた家庭環境。居場所がないのは自明でした。実家は四国なので、震災の直接的な影響はなかったのですが、親戚関連とかでいろいろあったらしいです。

 

公務員という選択肢も考えましたが、当時は就職氷河期の折。公務員の競争率は跳ね上がっていましたし、田舎なので縁故採用らしき枠がほとんどを持って行ってしまうんですね。悲しい現実です。

 

そんなこんなで、私がお金を使ってしまうと弟や妹が進学できなくなってしまう。国公立に賭けるみたいなギャンブル打つくらいなら専門学校で。と進路を決めました。

国公立に余裕で行ける学力がなかったのは自分の勉強不足だったんだろうなと思います。

専門学校となると今まで視野に入れてなかったので色々調べる必要がありました。

私がいた地方の専門学校は一族経営だったり、人脈だけで成り立ってたりと、そういう所だったので、こうするほかなかったという状況だったので場所は東京となりました。

母は大反対でしたが、父は快諾とまでは言わないまでも「やれるだけやってみ。無理なら帰ってこい」と言ってくれました。

 

上京の日は父がついてきていました。寮母さんに挨拶してから、西友で生活に必要なものを買ってくれたのを記憶しています。「バイトで頑張って貯めたみたいだけど、それは生活のためじゃなくて自分のやりたいことをやるために使いなさい。」そんなことを言ってた気がします。

 

当時の私の地方のバイト代は時給600が平均値でしたから、ほとんど溜まっていない状況でした。あの生活基盤を作ってくれたお金がなかったら、あっというまに困窮していたと思います。父に感謝です。

 

パソコン

進学と同時にパソコンを買いたかったのですが、そんなお金はどこにもありませんでした。進学後はなんとかしようとバイトしつつも試行錯誤の毎日でした。

通学時間中に本を読んで、紙にコードを書いて、学校のPCで打ち込むという生活を半年近く続けました。バイトも掛け持ちしていたので当時の睡眠時間は3時間前後だったと思います。今にしてみればよく生きてたなと。

専門学校の一年目が終わるころにはC++, DirectXくらいはいじれるようになっていましたが、やっぱりパソコンなしでは限界がありました。

ダメもとで父に電話したところ「ボーナスが少し入った。少しだけなら」と。

当時としてはなかなかのスペックの中古PCとモニタ(Gateway製, Pentium100MHz, RAM=96M)みたいなマシンでした。

おかげで制作発表会では実写を取り込んだ2D格闘ゲームを展示できて反響もありました。

反響があったというのが嬉しくて、寝る間も惜しんで色々プログラムを作ってた気がします。

父のおかげです。当時の私はまだ19歳でした。

 

成人式

もともと出るつもりはなかったのですが、アルバイト先の仲間から出たほうがいいといわれ、休みももらえたので出ることにしました。

成人式前日に実家に帰ってみると、母が封筒をくれました「待ってたよ。父さんから預かったお金、スーツ代。リクルートスーツじゃせっかくの成人式が台無しだからね」と。

成人式に多少なりともまともなスーツで参加できたのも父のおかげです。

その後も何度か実家には帰ったりはしました。

父から聞いた最後の言葉は「気をつけてな。悪いやつに騙されるなよ。お前は人が良すぎる」でした。

 

父の他界

世間がサブプライム問題とか賑わっていた時期でした。

当時の会社の同僚の結婚披露宴の最中に電話が鳴りました。父が倒れたと。

すぐ羽田から四国へ飛び、病院に着いてみると父は手術中でした。

待っている間に状況を聞いてみると、あまりよくないようでした。

一度は医大病院に運ばれたが、医者が居ないからと県立病院へ移送、一刻も早く手術をしないといけない状況なのに、ここにも医者が居ないから手術まで数時間待ち。そんな状況でした。

幸いにも手術は成功しました。

 

私は安心して東京へ戻り、次の日には再び職場で仕事をしていました。「元気になったら好物でも買って行ってやろう。一緒に酒飲んだこともなかったな」なんて呑気に考えてました。

3日後、再び電話がなりました。合併症とのこと。もうダメだと。

私は喪服を用意して再び四国へ飛びました。「すぐ息を引き取るわけではないけれども、回復の見込みは極めて低い。」そんなお医者さんからのお話でした。

私はせめてその時くらい一緒にいてやろうと一週間有給を入れました。

 

当時の私の職場は下請け企業なのでリーマンショックの余波を受けての政治争い(受注争い)が大変ですが、そんなことよりも大切なことがあると思っています。

 

親のことが好きとか嫌いとか、自分の立場がとか、会社の業績がとか。

そんなものどうでもよかった。ただ今まで助けてくれた父の最期を看取ることくらいしたかったのです。

 

有給の期間が過ぎ、母が告げました「あんた、リーマンショックの影響で大変なんだから帰りな。あとはなんとかする。」と。

強情な母なのでそれを受けて、次の日に東京に戻ることで話がついた夜。病院から電話でした。

「血圧が低下しています。ご家族や親類の方を。」

没年50歳後半でした。

 

職場に電話して忌引き休暇の連絡、葬儀屋の手配、病院の請求書の処理。

葬儀は私が喪主を務め無事に終えることができました。

火葬のボタンを押すとき「今までありがとうございました」と。

 

後悔

せめて一緒に酒くらい呑んでやればよかった。嫁に合わせてやりたかった。

そんな思いで四十九日を終え、やっと本格的に職場復帰したころでした。

やはり長期間職場を不在にすると政治的に不利になりますね。

現場としてはそんな負荷を押し付けられたと思うでしょうし、総務や人事は知ったこっちゃないという状況です。

心理的にも耐えられないと思い、退職を申し出ました。しばらく田舎で父の残してくれたものの処理をしようと考えていました。

 

ここで父の最期の言葉を思い出しておけばよかったと悔やまれます。

離職票の退職日や保険の加入などを偽装され、金額的にも精神的にもかなりの損失を被りました。

ストレスからの暴飲暴食からの胃潰瘍で出血多量となって死にかけましたが、今も私はなんとか生きてます。

 

もう10年以上前のことです。

 

個人的に思うこと

私は親があまり好きではありませんでした。。

ただ、今にして思えば、生活が苦しい中で、父は本当に必要なものを選んで私に与えてくれていたんだと思います。

父が他界したとき、葬儀屋さんからいただいた言葉に救われた気がします。

「皆さん、親にお礼してないと悔やまれます。ただね、子供が親にするお礼というのはですね、10歳まで一緒に生きるというのが最初にして最高のお礼なんですよ。10歳過ぎたら、感謝しつつ自分の道を進んでいく。それで充分なんですよ」と。

確かにそれくらいまでの父の姿が一番記憶に残っている気がします。それを過ぎると反抗期も始まりますし。

 

政治に対して思うこと

法の下の平等というのは何なのだろう?と父の他界を機に本気で考え続けています。

 

ただ、一つだけ不平等だと言えるのは父の他界です。

小泉政権のときに「地方切り捨てだ!」などと叫ばれていましたが、私は他人事のように思っていました。

地方切り捨てが行われたらどうなるのかも考えず、自分の地方がその切り捨てられる側になるなんて考えず。

そして「医者がいないから手術ができなかった」という言葉をまさか父が受けることなるなんて考えてもいませんでした。

 

父はこのことを教えてくれたのかもしれません。

 

総括

日本の政治によって私の父は命を奪われた。私は今でもそう思っています。

ただ、憎しみは何も生まない。だから私は悲しむ人が少しでも減るような社会になってほしい。そう願います。

今の日本社会を見ていると、皆が自分のことで精一杯で余裕がないなと感じています。

 

だとしたら、それは今の政治であったり、社会、働き方、会社、色々なしくみが限界を迎えているのではないか?と。

 

少し暗い話になりましたが、できることなら、全世界から苦しみが無くなって幸せになれる世界が訪れるといいなと思っています。

これを仏教の言葉で「大欲得清浄」というらしいです。意味は大きな欲は清浄を生む。

自分だけ良ければそれでよいというのではなく、もっと大きな目で物事を見てみると何か大切なものが見えてくるかもしれません。

 

私にそれを教えてくれたのは父の命でした。

 

南無大師遍照金剛

MTGのカード情報の傾向分析を行う(準備編)

GWが終わって、しばらく祝日なしの日が続きます。 私は毎年この時期を「6月砂漠」と呼ぶことにしています。かたりぃなです。

今回はMTGのカードのテキストデータを分析してみようと思います。 画像処理ばかりやってると数式相手になることが多いので、時には気分転換も必要です。

目的とか

MTGのカードは時代によって流行り廃れがあります。 たとえば、去年はすごく流行っててカードショップでも高額で取引されていたカードが、今は全然見かけないとか。

これを分析することができれば、何かデータが見えてこないかなというのが今回の目的です。

テキストマイニングは初めてなので、できるだけ手軽に既存のツールやライブラリを組み合わせて実現していこうと思います。

まず今回は環境を作り上げて軽く動かすところまでやってみます。

環境

いつものです

  • ホストOS : Windows10 Pro
  • 仮想化 : Docker desktop for windows
  • ゲストOS : Linux
  • 分析基盤 : Elasticsearch
  • GUIフロントエンド : Kibana

コンテナ一式を立てる

Elasticsearchを使うためのコンテナ一式を立てます。

今回のElasticsearchの用途は次のとおりです

  • 他ホストからRESTでデータを投げ込んでもらう
  • 日本語解析にはkuromojiを使う
  • Kibanaで確認する

早速コンテナを立てるところからやってみます。

docker-compose

docker-compose.ymlはこうなりました。

version: "3"
services:
  elasticsearch:
    build: 
      context: .
      dockerfile: ./kuromoji.dockerfile
    image: elastic
    ports: 
      - "9200:9200"
      - "9300:9300"
    environment:
      - discovery.type=single-node
    networks: 
      - elastic-stack

  kibana:
    image: docker.elastic.co/kibana/kibana:6.5.4
    ports:
      - "5601:5601"
    networks: 
      - elastic-stack

networks:
  elastic-stack:

kuromoji.dockerfile

elasticsearchの日本語解析プラグインとしてkuromojiを使いたいので、それを組み込むためのdockerfileを書きます。

上記docker-compose.ymlが参照するkuromoji.dockerfileはこうなりました。

インストールするだけでした。

FROM docker.elastic.co/elasticsearch/elasticsearch:6.5.4

# 公式マニュアルにあるkuromojiをインストールする。
# https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html
RUN elasticsearch-plugin install analysis-kuromoji

これでelasticsearchの用意ができました。

起動確認

docker-compose up -dして起動確認します。うまくいかないときはdocker psで生存確認したり、docker-compose logsでログを見たりします。

うまく起動したら、kuromojiが有効になっているか確認します。

curl -X GET 'http://localhost:9200/_nodes/plugins?pretty' 

このあたりの基本的な操作はこちらのサイトを参考にさせていただきました。 http://pppurple.hatenablog.com/entry/2017/05/28/141143

Elasticsearchに投入するデータを用意する(MTGのカードデータ)

以下の手順でデータを注入します。

  1. MTGのカードデータをMTG公式のRESTから取得し、ファイルに保存
  2. 保存したファイルを整形して、ElasticsearchのRESTに渡す

データのダウンロードとElasticSearchへの注入を分けた理由は、MTG-APIが外部サーバだからというのと、個人的に空いた時間にちまちまとコードを書きたかったからです。

ネットが繋がらない状態でもローカルにファイルを持っていれば作業できるので。

MTG公式からのカードデータ取得と保存

python3でいきます。こうなりました。 以前DeepLearningのときに試したコードの引用ですね。

# -*- coding: utf-8 -*-

from mtgsdk import Card
from mtgsdk import Set
from mtgsdk import Type
from mtgsdk import Supertype
from mtgsdk import Subtype
from mtgsdk import Changelog

import json
import argparse
import pickle
import urllib
import os
import codecs

def print_allsets():
    print("================ cardset list ================")
    allsets = Set.all()
    for cardset in allsets:
        print('{}  ({})'.format(cardset.code, cardset.name) )
    print("================ cardset list ================")

def main():
    parser = argparse.ArgumentParser(description='mtg image download')
    parser.add_argument('--setcode',
        action='store', type=str, nargs=None, const=None, 
        default=None, choices=None,
        help='download card images from set code')
    args = parser.parse_args()    

    if args.setcode == None:
        print_allsets()
        parser.print_help()
        exit()

    cardlist = Card.where(set=args.setcode).where(language='japanese').all()
    workdir = os.path.join( '.', 'card-texts', args.setcode)

    print("{} card num = {}".format(args.setcode, len(cardlist) ) )
    print("image download to {}".format(workdir) )

    if not os.path.exists(workdir):
        os.makedirs(workdir)

    for index, card in enumerate(cardlist):
        # 有用な情報は多々とれそうだが、一旦日本語の部分だけ抽出してみる
        for foreign_names in card.foreign_names:
            if foreign_names['language'] == 'Japanese':
                # ファイル名にはユニークなイテレーションの数値を使う
                out_file_name = '%s/%d%s' % (workdir, index, '.txt')
                print("{} ( {} )".format(out_file_name, card.name) )
                out_file = codecs.open(out_file_name, "w", 'utf-8')
                out_file.writelines( json.dumps(foreign_names, ensure_ascii=False) )
                out_file.close()

    print("download complete")

if __name__ == '__main__':
    main()

使い方はこんな感じです

python download_cardtext.py --setcode=DOM

こうするとカレントディレクトリに./card-texts/DOMディレクトリが作られ、そこにドミナリアのカード情報一覧が保存されます。

たとえば./card-texts/DOM/0.txtはこんな内容になります。json形式ですね。

{"name": "アカデミーのドレイク", "text": "キッカー{4}(あなたはこの呪文を唱えるに際し、追加で{4}を支払ってもよい。)\n飛行\nアカデミーのドレイクがキッカーされていたなら、これは+1/+1カウンターが2個置かれた状態で戦場に出る。", "flavor": null, "imageUrl": "http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=444273&type=card", "language": "Japanese", "multiverseid": 444273}

elasticsearchにデータを入れていく

ElasticSearchの前準備として次の2つを行います。

  • kuromoji日本語解析器を設定します
  • MTGのカードを解析するためのマッピング設定を行います。

簡単に説明します、前者の日本語解析器はいわゆる形態素解析してくれるモジュールです。

形態素解析 is 何?

私はその分野の専門家ではないので、今回の用途だけに絞って簡単に説明します。

興味ない人は読み飛ばしてください。

上記jsonのままでは、たとえばtextフィールドの長い文字列からの検索になってしまい、単語単位での分析がうまくできません。

おおざっぱに解釈すると、単語単位に分割する方法の一つが形態素解析らしいです。

そんな面倒な処理必要なの?って思ったので軽く調べたところ、

  • 英語やドイツ語などであれば、空白で区切れば単語単位になる
  • 日本語では空白で区切るという概念はない

あたりが要因のようです。

「日本語は助詞で区切ればいいのでは?」と思いますが、境界判別という問題があって、助詞がどこかわからない文字列だと解釈に困ります。

そのあたりを頑張ってくれるモジュールということなので、便利に使わせてもらいましょう。

設定はこうなりました。これをmapping.jsonとして保存しておいて

{
  "settings": {
    "index": {
      "analysis": {
        "tokenizer": {
          "kuromoji_user_dict": {
            "type": "kuromoji_tokenizer",
            "mode": "extended"
          }
        },
        "analyzer" : {
          "my_analyzer" : {
            "type" : "custom",
            "tokenizer" : "kuromoji_tokenizer"
          }
        }
      }
    }
  },
  "mappings": {
    "cards" : {
      "properties": {
        "name": {
          "type": "text",
          "fielddata": true,
          "analyzer": "kuromoji",
          "fields": {
              "keyword": {
                "type": "keyword",
                "ignore_above": 256
              }
          }
        },
        "text": {
          "type": "text",
          "fielddata": true,
          "analyzer": "kuromoji",
          "fields": {
              "keyword": {
                "type": "keyword",
                "ignore_above": 256
              }
          }
        },
        "flavor": {
          "type": "text",
          "fielddata": true,
          "analyzer": "kuromoji",
          "fields": {
              "keyword": {
                "type": "keyword",
                "ignore_above": 256
              }
          }
        }
      }
    }
  }
}

こうやってelastic-searchに投げます。

curl -X PUT 'localhost:9200/mtg?&pretty=true' -d @mapping.json -H 'Content-Type: application/json'

うまくいけばこんなjsonレスポンスが返ってきます。

{
  "acknowledged" : true,
  "shards_acknowledged" : true,
  "index" : "mtg"
}

下準備ができたのでデータ注入します。

Elasticsearchに入力するデータは少々加工する必要があるので、スクリプト書きました。

スクリプトの概要はこんな感じです。

  • ローカル環境に保存してある特定のカードセットすべてのファイルを読み込んで、結合
  • ElasticSearchのために各カード情報にヘッダ(のようなもの)を付与
  • 動作確認用にall_<カードセット名>.txtとして保存
  • elasticSearchに入れる

思ったより簡単にできたのでブログに直接乗せてしまいます。

# -*- coding: utf-8 -*-

import json
import argparse
import urllib
import os
import codecs

from mtgsdk import Set

def print_allsets():
    print("================ cardset list ================")
    allsets = Set.all()
    for cardset in allsets:
        print('{}  ({})'.format(cardset.code, cardset.name) )
    print("================ cardset list ================")

def listup_carddata(workdir):
    for filename in os.listdir(workdir):
        fullpath = os.path.join(workdir, filename)
        if os.path.isfile(fullpath):
            yield fullpath

# 1件のエントリをelasticsearchに転送できるよう加工する
def deform_carddata(data):
    return '{ "index": {"_index":"mtg", "_type":"cards" } }\n' + data + '\n'


def collect_data(workdir):
    allcard = ''
    for filepath in listup_carddata(workdir):
        f = codecs.open(filepath, 'r', 'utf-8')
        data = f.read()
        allcard += deform_carddata(data)
    return allcard

def register_es(elasticsearch_host, indexname, json_data):
    headers = {'Content-Type': 'application/json',}
    api = '/_bulk/?pretty'
    url = urllib.parse.urljoin(elasticsearch_host, indexname) + api
    print("request url = {}".format(url) )
    req = urllib.request.Request(url=url, data=json_data.encode(), headers=headers )
    result = urllib.request.urlopen(req)
    print(result)

def main():
    parser = argparse.ArgumentParser(description='mtg image download')
    parser.add_argument('--setcode',
        action='store', type=str, nargs=None, const=None, 
        default=None, choices=None,
        help='download card images from set code')
    args = parser.parse_args()

    if args.setcode == None:
        print_allsets()
        parser.print_help()
        exit()

    workdir = os.path.join( '.', './card-texts', args.setcode)
    if not os.path.exists(workdir):
        exit()

    # データを集めて
    allcard = collect_data(workdir)
    allcard += '\n\n'  # esとしては末尾に改行が必要なので

    # データ形式があってるか目視確認用のファイルを保存
    f = codecs.open('all_'+args.setcode+'.txt', 'w', 'utf-8')
    f.write(allcard)
    f.close()

    # ElasticSearchに入れる
    elasticsearch_host = 'http://localhost:9200'
    es_index = 'mtg'
    register_es(elasticsearch_host, es_index, allcard)


if __name__ == '__main__':
    main()

動かします。

python register_cardlist.py --setcode=DOM
request url = http://localhost:9200/mtg/_bulk/?pretty
<http.client.HTTPResponse object at 0x000001FCA430A048>

エラーチェックがすっぽ抜けてますね。。。 HTTPResponseオブジェクトが返ってきてとりあえずいけました。

API叩いていても寂しいので、テキストマイニングっぽくワードクラウドを作ってみます。

これでkibanaから日本語で検索したり色々できるようになります。

ElasticSearchでワードクラウドを作る用意

kibanaで操作していきます。

インデックスパターンを作る

  1. kibanaの画面左のManagementを選択
  2. ElasticSearch の Index Pattern を選択
  3. インデックス名に"MTG"を入力
  4. あとは適当にOKおしていく

こんだけです。

このときの注意点で、フィールド名一覧の管理画面でtext,name,flavorの各フィールドの「Aggregatable」が有効になっていることを確認しておきます。

詳しいことはわからないのですが、どうやらRAM上に載せておいて色々な処理に使える(たとえば今回のワードクラウド)ようです。

データが入っているか確認する

  1. kibanaの画面左のDiscoverを選択
  2. 目視確認

それっぽいデータが入っていればOKです。

ワードクラウドを作る用意

ElasticSearchではタグクラウドというらしいです。

タグクラウドを作るための基本設定をしていきます。

  1. kibanaの画面左のVisualizeを選択
  2. "+ Create a visualization"を選択
  3. ビジュアライズ手法の最下部にある'Tag Cloud'を選択
  4. From a New Search, Select IndexのNameから'MTG'を選択(これはkibanaで最初に作ったインデックスの名前です)

これでタグクラウドの設定画面まできました。

タグクラウドを出してみる

タグクラウドの設定画面で色々設定してみます。

テキストマイニングは初めてなので、まずは雰囲気を掴んで面白いと思うところが大事だと思います。

設定値はこんな感じにしました。

  • Buckets
    • Aggregation = Terms
    • Field = text
    • Order By = metric:count
    • order = desenc
    • size = 40

こんな感じのできました。 f:id:Catalina1344:20190528133913p:plain

なかなか楽しいですね。

ぱっと見たところ、MTG固有の用語がうまく分析できてなさそうです。

  • プレインズウォーカー = (プレイン / ズウォーカー)
  • トークン = (トー / クン)
  • トレイリア(地名) = (トレイ / リア)

ひとまずテキストマイニングの土台ができたと思うので一旦良しとします。

感想と今後の展望

テキストの分析がうまくいっていないのは形態素解析の都合で、未知の単語をうまく解釈できてないことによるものです。

今回の用途(MTG専用の分析をやりたい)であれば、kuromojiの辞書に追加してあげるだけでよさそうです。

また単語の出現傾向を分析するのも色々楽しそうです。

今回のタグクラウドをいじっていると歴史的アーティファクトがセットで現れることが多いです。

ゲームが分かる人からみれば「ドミナリアでテストしたから当然でしょ」って内容ではありますが、こういう「当たり前だね」っていうのをデータで示しておくことは分析では重要なことだと思います。

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

WinRTを試してみる

桜の花も散っていき、春から初夏への移り変わりを感じます。かたりぃなです。

今回は新年度ということで、新しいことを試してみようと思います。C++/WinRTです。

C++/winRTを使う理由

Hololens用のUnityのネイティブプラグインを書こうと色々調べていたら、C++/CX終わるよみたいなアナウンスに気づきました。

https://docs.microsoft.com/ja-jp/cpp/cppcx/visual-c-language-reference-c-cx?view=vs-2019

要約すると、「C++17とWinRTを使うと、幸せになれるよ」ってことですね。

というわけで、今後のことも考えてwinrtを試してみます。ダメだったら戻ればいいかと思って試したところ、かなりイケてると思います。

なのでWinRT/C++でいきます。

WinRTとは

winRTはc++17の機能を使うことでC++/CXよりエレガントに記述できます。 実際に書いてみるとわかるのですが、C++使いなら直感的に記述できる感じでした。

プラットフォームの観点からは

  • UWPアプリを書ける
  • ネイティブアプリも書ける(従来のデスクトップアプリ)
  • Unityプラグインとしてもいける(はず?)

コードを書く観点から

  • 非同期操作が扱いやすくなっている(concurrencyTaskなんて無かった)
  • プロパティへのアクセスが直感的
  • イベントハンドラ登録が直感的に書ける
  • COMへの参照はwinrtの中で良しなにやってくれる

とかでしょうか。 特に非同期操作がラクになったのが大きくて、意味不明なテンプレートのエラーを見る回数が減ったと思います。

今回はまだ非同期操作まわりはよくわかってないので、同期的な処理で完結しています。

実際にwinrtでコード書いてみる

最終目標はunity ネイティブプラグインです。

なので、visual studio プロジェクトとしてはDLLになります。

また、DLLのテスト用にコンソールアプリケーションを作成します。

UWPアプリケーションからDLLを呼び出すテストはまた今度。。。(Hololens実機でのネイティブプラグイン相当)

環境

いつものです。

環境設定

Visual Studio 2017に色々いれたのですが、試行錯誤していたせいで失念してしまいました。

VisualStudioでオンラインのWinRTテンプレートをインストールか、VisualStudio のインストーラからコンポーネントを指定したかもしれません。

設定が終わると新規作成プロジェクトからC++/WinRTが選択できるようになります。

プロジェクト構成

VisualStudioのプロジェクトとしては2つ作ります。 1つはc++/winRTを使用したDLLそのものので、もう一つはテスト用プロジェクトです。

ソリューションにこの2つのプロジェクトを配置することにします。

このDLLは最終的にUnity側で利用するわけですが、テストのたびにUnityとVSを行ったり来たりしてのデバッグは大変なので、テストプロジェクトで普通のDLLとして扱えることを確認しておきます。

WinRTのDLLプロジェクトを作成する

WinRTのDLLプロジェクトのテンプレートが手元にないので、手作業で作ります。

具体的には次の手順です。

  1. 新規プロジェクト作成から、VisualC++ -> Windowsデスクトップ -> DLL を選択
  2. プロジェクトの設定値を変更する

これでWinRTでDLLが作成できるようになりました。

WinRTを利用するためのプロジェクト設定の手順はこちらのサイトを参考にさせていただきました。 https://blog.okazuki.jp/entry/2018/10/16/144556

実際のDLLのコードは後で示します。

テストプロジェクトを作成する

DLLをテストするプロジェクトとしてコンソールアプリケーションを作成します。 テストプロジェクトは、上記DLLプロジェクトと同じソリューションに含まれるようにしておきます。

具体的には ソリューションエクスプローラのroot要素(ソリューション)を右クリックして、

追加->新しいプロジェクト->コンソールアプリケーション

です。

テストプロジェクトは同じくコンソールアプリケーションを使います。

2つのプロジェクト間の依存関係の設定

テストプロジェクト側をスタートアッププロジェクトに設定し、プロジェクトの依存関係からDLLが先にビルドされるよう設定しておきます。

ついでに関数の呼び出し規約をstdcallに設定します。

ソリューションエクスプローラのプロジェクトを右クリックして「プロパティ」「C++タブ」「詳細設定」「呼び出し規約」のところを「stdcall/Gz」に設定します。

あとはビルドターゲットを

  • Releaseビルド
  • 32bit,64biをUnityのバージョンと合わせる

とします。

テストプログラムから呼び出す

DLLを呼び出すにはWindowsAPIでゴリゴリ(LoadLibrary, GetProcAddr)とかやってもいいのですが、手抜きでいきます。

DLLプロジェクトがDLLとインポートライブラリを生成してくれているので、インポートライブラリ利用します。

プロジェクト設定画面から、 インポートライブラリはリンカの設定で、DLL本体はデバッグ時のパスの設定で解決します。

リンク設定とデバッグ環境のパス設定はこんな感じです。

リンク設定

リンカ-> 入力-> 追加の依存ファイルに $(SolutionDir)x64\release\consoleDll.lib;%(AdditionalDependencies) を設定します。 ここでconsoleDllというのはDLL側プロジェクトの名前です。

デバッグ環境のパス設定

デバッグ-> 環境 の項目にPATH=$(SolutionDir)x64\release\を設定します。

ここまででDLLの生成とそれを読み込むテストプログラムの設定ができました。

設定が正しいかどうかDLLとアプリケーションをビルドして確認しておきます。パス指定を間違えたりしているとリンク時やデバッグ時にエラーが出るので状況確認してつぶしていきましょう。

実際にコードを書いてみる(DLL側)

単純なDLLではデスクトップアプリを作る場合と何も変わらないので、UWPで利用できるAPIを呼び出してみることにします。

昔の記事でC++/CXでやってたMediaCaptureを使ってみます。

#include <thread>

#pragma comment(lib, "windowsapp")

#include <winrt/Windows.UI.Notifications.h>
#include <winrt/Windows.Data.Xml.Dom.h>

using namespace winrt;
using namespace Windows::Foundation;
using namespace Windows::UI::Notifications;

#include "winrt/Windows.Foundation.h"
#include "winrt/Windows.Web.Syndication.h"
using namespace winrt;
using namespace Windows::Foundation;
using namespace Windows::Web::Syndication;

#include <winrt/Windows.Media.h>
#include <winrt/Windows.Media.Capture.h>
#include <winrt/Windows.Media.Capture.Frames.h>

using namespace Windows::Media::Capture;
using namespace Windows::Media::Capture::Frames;

// 新しいメディアフレームが到着した際にMediaCaptureから呼び出されるコールバックイベントハンドラ
void OnFrameArrived(
    MediaFrameReader sender,
    Windows::Media::Capture::Frames::MediaFrameArrivedEventArgs args)
{
    OutputDebugString(L"OnFrameArrived called \n");
    auto frame = sender.TryAcquireLatestFrame();
}


// MediaCaptureのための状態をまとめて管理する
struct MediacaptureWrapper {
    MediacaptureWrapper(MediaCapture cap, MediaFrameReader rd) :
        mc(cap),
        reader(rd) {};

    MediaCapture        mc;
    MediaFrameReader    reader;
};

// todo : できることならDLL利用側で管理してもらう
std::shared_ptr<MediacaptureWrapper>  g_capture;


extern "C" {
    // unityプラグインのP/Invoke呼び出し規約はstdcall
    __declspec(dllexport) int __stdcall mediatest(int a, int b) {
#if 1
        // todo : これはUnityエディタのときは呼び出してはいけない
        //        実行時環境を参照する方法を調べて、自動的に呼び出しを制御する方法を考える。
        winrt::init_apartment();

        // media capture の関数を一戸だけ呼び出すテスト
        // これ自体は問題なく成功するので、UnityからWinRTのAPIを呼びだすこと自体は問題ない
        // Win32アプリケーションでテストコードをかいて実験する必要あり
        auto groups = MediaFrameSourceGroup::FindAllAsync().get();

        MediaFrameSourceGroup selectedGroup = nullptr;
        MediaFrameSourceInfo selectedSourceInfo = nullptr;

        // 列挙したカメラの最初に見つけたものを使用する。
        // Hololens RS-4で確認したところ、NV12カラーフォーマットで列挙された
        for (MediaFrameSourceGroup sourceGroup : groups) {
            for (MediaFrameSourceInfo sourceInfo : sourceGroup.SourceInfos() ) {
                if (sourceInfo.SourceKind() == MediaFrameSourceKind::Color) {
                    selectedSourceInfo = sourceInfo;
                    break;
                }
            }

            if (selectedSourceInfo != nullptr) {
                selectedGroup = sourceGroup;
                break;
            }
        }

        // 利用可能なカメラが見つからない場合
        if (selectedGroup == nullptr || selectedSourceInfo == nullptr) {
            OutputDebugString(L"selected source group not found\n");
            // todo
        }

        // たぶんプロパティと同様の扱いでいいんだろうけど、どうなの?
        auto settings = MediaCaptureInitializationSettings();
        settings.MemoryPreference(MediaCaptureMemoryPreference::Cpu); // Need SoftwareBitmaps for FaceAnalysis
        settings.StreamingCaptureMode(StreamingCaptureMode::Video);   // Only need to stream video
        settings.SourceGroup(selectedGroup);

        // よくわかってないところ。
        // winrtのオブジェクトに対してnew演算子が使えなくて、こういう方法でインスタンス化できるっぽい?
        // ただ、MSDNにあるサンプルではインターフェースをもつクラスインスタンスを作る例なので、MediaCaptureではこれが正解かどうかは謎い
        auto mcaf = winrt::get_activation_factory<MediaCapture>();
        auto mc =  mcaf.ActivateInstance<MediaCapture>();

        // 使用するカメラのパラメータが確定したので、初期化を行う
        // 1, メディアキャプチャの初期化
        // 2, 読みだしストリームの作成
        // 3, キャプチャの開始
        mc.InitializeAsync(settings).get();
        auto mapview = mc.FrameSources();
        auto selectedSource = mapview.Lookup(selectedSourceInfo.Id() );

        auto media_reader = mc.CreateFrameReaderAsync(selectedSource).get();
        auto status = media_reader.StartAsync().get();

        // https://docs.microsoft.com/ja-jp/windows/uwp/cpp-and-winrt-apis/handle-events
        // デリゲートを使ってイベントを処理する方法
        media_reader.FrameArrived({&OnFrameArrived});

        g_capture = std::make_shared<MediacaptureWrapper>(mc, media_reader);

        if (status == MediaFrameReaderStartStatus::Success) {
            // mediacapture, mediaframereaderのペアができた
            // 選択されているカメラはselectedSourceである
            OutputDebugString(L"media capture start success\n");
        }
        else {
            // メディアキャプチャの読み出しが開始できない
            OutputDebugString(L"media capture start fail \n");
        }
        std::this_thread::sleep_for(std::chrono::seconds(5) );
#endif
        // 単純なDLLのテスト用として、加算した結果を返すだけ。
        return a + b;
    }
}

次にテストプログラム側です。

ライブラリのリンクはできているので、単純に呼び出すだけです。

int main()
{
    std::cout << "Hello World!\n"; 

    // dll のテスト
    mediatest(1,2);

    std::cout << "finish.\n";
}

コードの概要(全体像)

DLLはmediatestという関数をエクスポートし、テストプログラム側はこれを利用します。 引数、戻り値にint型をとっていますが、DLLが呼べるかなどの基本的なテスト用に残しています。

このコードは実行すると5秒間カメラから映像フレームを取得し続けます。

映像フレームは保存せずに捨てるだけなので害はありません。

コードの詳細(DLL側)

DLL側ではwinRTを使った実装をしています。慣れない部分は多々ありますが、理解した範囲でメモしておきます。

C++マングリング

ネームマングリングについて雑に説明します。

C++には関数のオーバーライドとか、デフォルト引数みたいな機能がたくさんあります。 これはC言語には存在していないものです。

関数を拡張したような機能を実現するためにC++コンパイラはネームマングリングといって、内部的に異なる関数名を生成します。

(リンクエラーで関数が見つからないときに記号や数字が付いた関数名が表示されると思いますが、アレがコンパイラ内部で扱われている関数名相当です。)

これがネームマングリングと呼ばれるもので、これだとDLLとして呼び出すときに非常に不便です。(マングル規則を知らないと呼び出せない)

そこで、「ネームマングリングしないで。Cの関数として扱って」というのがextern "C"です。

WinRTライブラリの指定

#pragma comment(lib, "windowsapp") は、ライブラリのリンクの指定で、ここではwindowsappのライブラリ一式をリンクする指定です。

includeとかusing

使う機能をincludeしてusingで以降の名前空間の指定を省略するという使い方です。

ここで注意点で、たとえばinclude <winrt/Windows.Media.Capture.Frames.h>を消すと、コンパイルは通るがリンクエラーになるという事態が起きます。

(これは公式マニュアルのFAQにもあるので読んでみるのが良いでしょう)

なぜコンパイルエラーではなくリンクエラーかというと、おそらくC++の「テンプレートの明示的インスタンス化」の影響だと考えています。(ヘッダオンリーライブラリにありがち)

たとえば#include <winrt/Windows.Media.Capture.h>をすると、MediaCaptureの入出力でFramesクラスを使うので「Framesクラスがあるよ」宣言が必要がありますが、ここでは実態は定義されません。で、テンプレートのインスタンスはFrames.h側にあるので、リンク時に問題になるわけですね。

順番ごちゃごちゃして汚いですが、こんな感じになりました。

#include <winrt/Windows.UI.Notifications.h>
#include <winrt/Windows.Data.Xml.Dom.h>

using namespace winrt;
using namespace Windows::Foundation;
using namespace Windows::UI::Notifications;

#include "winrt/Windows.Foundation.h"
#include "winrt/Windows.Web.Syndication.h"
using namespace winrt;
using namespace Windows::Foundation;
using namespace Windows::Web::Syndication;

#include <winrt/Windows.Media.h>
#include <winrt/Windows.Media.Capture.h>
#include <winrt/Windows.Media.Capture.Frames.h>

using namespace Windows::Media::Capture;
using namespace Windows::Media::Capture::Frames;

エクスポート関数の指定と呼び出し規約の指定

 __declspec(dllexport) int __stdcall mediatest(int a, int b) {

DLLが外部に公開する関数だということを明示します。今回はテスト用に1個の関数だけexportするので、defファイル書くまでもないなと思い、こうやってます。

__stdcallはプロジェクト設定でやってもいいですし、こうやって各関数個別に指定してもいいです。

COMの初期化

winrt::init_apartment()

winrtの内部実装はCOMらしいので、その初期化をします。 こいつも注意で、今回のテストプログラムから使うときは必要ですが、Unityから使う場合は消しておく必要があります。(Unityの中でCOMの初期化やってる?) まあ、DirectXもCOMですし、何かやっててもおかしくはないでしょう。

非同期操作(async)

非同期操作関連はコルーチンを使えばきれいに書けるみたいですが、コルーチンはまだよくわからないので同期的に処理することにしました。

たとえば auto groups = MediaFrameSourceGroup::FindAllAsync().get()では、FindAllAsync非同期操作の完了まで待機して結果を受け取ります。 もちろんFindAllAsync()を一旦変数で受けてからgetするというのもできます。

コレクションの処理

IVectorViewなどのWindows固有のコレクションも、範囲forで片づけます。

for (MediaFrameSourceGroup sourceGroup : groups)とかですね。

今回は勉強なので、各要素の型を明示しましたが、autoで受けても大丈夫でした。

プロパティ

C#のプロパティのように、クラスのフィールドにアクセスするには

  • 代入時は関数呼び出しの構文で
  • 取り出し時は変数 = プロパティ

と覚えておけばだいたい事足りました。

たとえばメディアキャプチャの設定をするのはこんな感じですね。

 auto settings = MediaCaptureInitializationSettings();
    settings.MemoryPreference(MediaCaptureMemoryPreference::Cpu);
    settings.StreamingCaptureMode(StreamingCaptureMode::Video);
    settings.SourceGroup(selectedGroup);

インスタンス

ここはまだ理解しきれていないので自信ないところです。

C++/CXのときはref new ClassName()とかしてた部分になります。

一旦activation_factoryテンプレートクラスを、インスタンス化したいクラス名のテンプレートとして構築し、そのactivationfactoryのActivateInstanceメソッドを呼ぶことでインスタンス化できるようです。

 auto mcaf = winrt::get_activation_factory<MediaCapture>();
    auto mc =  mcaf.ActivateInstance<MediaCapture>();

イベントハンドラ

イベントハンドラはコルーチン(?)に対するデリゲートでいけました。

media_reader.FrameArrived({&OnFrameArrived});

すごくシンプルですね。c++/WinRT素晴らしいですね。

一番楽になったのはコールバック関数をコルーチン(?)として与えることができるので、すごく楽になったと感じます。

ちなみにC++/CXではstd::bindにプレースホルダー指定しつつ、テンプレートのインスタンス化みたいな難読なコードでした。

できた

ここまでで、テストプログラムを実行するとコールバック関数が呼び出されることが確認できます。

DLL側にブレークポイントを打ってデバッグすることも可能です。

軽くUnityエディタで動かしてみたところ、問題なく動いたので、これを利用してネイティブプラグインの開発ができそうです。

感想と今後の展望

ここまでで、UWPのMediaCaptureを利用するDLLができました。

本当はUnityエディタから利用する部分まで書こうかと思っていたのですが、記事が長くなりそうなので今回はこれくらいにしておきます。

次回はUnityのネイティブプラグインのファイル配置、開発環境、デバッグ方法などを書いてみようと考えています。

少しずつですがUnityプラグイン作って色々やっていきたいと思います。

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

OpenCV4.0をソースコードからインストールする

想像以上に手間取りました。かたりぃなです。

OpenCVのバージョンだけなら大した問題ではなかったのですが、バージョンアップに追従してC++も新しいものにしていこうという動きがあるみたいです。 (実際OpenCV3の世代でCインターフェースはレガシーなものとして切り捨てられましたし。)

さらに4.0になってからはOpenCV自体をビルドするCMakeも要求バージョンが上がっていて、これもビルドしなおす必要があります。

CMakeもビルド自体は簡単なのですが、CMakeをビルドするためには新しいlibcが必要になっています。

新しいlibcが必要ということはgcc自体のアップデートが必要になるということです。

私がよく使うCentOSgccがかなり古いバージョンしか入っていないので、gccからビルドしなおす必要があるということです。

ついにこの時が来てしまったかといった感じですね。。。

というわけで、次の順にやっていきます。

  1. gcc8.1のビルドとインストール
  2. CMake3.13のビルドとインストール
  3. OpenCV4.0のビルドとインストール

なんか大変なことになってしまいましたが、gcc8までいくとC++20も多少は使えるようになるので、未来志向で前向きにいきたいと思います。

環境はいつものです

  • Windows10Pro
  • Docker for windows
  • centos7ベース

躓いたポイント

cmake/opencvとも、いつものようにビルドすればいいのですが一点だけ注意。

cmakeが参照するcurlhttps対応してない問題というのがあるらしいです。

https://stackoverflow.com/questions/51573524/cmake-is-unable-to-download-by-protocol-https-while-own-cmake-option-procedure

要約すると、opencvのビルド時に追加で必要なファイルをダウンロードしてきたりしているところで面倒な問題があるようです。

追加ライブラリののダウンロードにcurlが使われるのですが、cmakeはシステム組み込みのcurlではなくcmake自信が持っているcurlを使うようになっています。(デフォルトの動作)

これだけなら大したことないのですが、cmakeが持っているcurlと不随するライブラリが古いらしく、httpsサイトからのダウンロードに失敗します。(SSLの一部をサポートしていないため)

これがopencvのbuild時に発覚するので、ビルド時間の長さも相まって手戻りが半端なかったです。

環境構築のためのdockerfileはベタ書きしたので、他の環境でやるときも手順書の代わりになるかと思うので、次回以降はビルドやり直しの手間は減るかなと思います。

docker-file

まずは答えから。

インストールするものをまとめてコンテナ化するdockerfileになります。

ひたすらコマンドを実行するだけのものです。

RUN yum groupinstall -y 'Development tools'
RUN yum install -y git wget cmake
RUN yum install -y sudo

# cmakeのためにlibcとgccを入れる(C++最新を使いたい)
RUN wget http://ftp.tsukuba.wide.ad.jp/software/gcc/releases/gcc-8.1.0/gcc-8.1.0.tar.gz
RUN tar zxvf gcc-8.1.0.tar.gz
RUN cd gcc-8.1.0; ./contrib/download_prerequisites; mkdir build
RUN cd gcc-8.1.0; ./configure --enable-languages=c,c++ --prefix=/usr/local --disable-bootstrap --disable-multilib; make;
RUN cd gcc-8.1.0; make install
RUN cp gcc-8.1.0/x86_64-pc-linux-gnu/libstdc++-v3/src/.libs/libstdc++.so.6.0.25 /usr/lib64/
# libcのバックアップとインストール
RUN mv /usr/lib64/libstdc++.so.6 /usr/lib64/libstdc++.so.6.backup
RUN ln -s /usr/lib64/libstdc++.so.6.0.25 /usr/lib64/libstdc++.so.6
RUN rm gcc-8.1.0.tar.gz

# opencv4のためにcmake 3.5以上が欲しい
RUN yum install -y git wget cmake
RUN yum install -y sudo curl-devel zlib-devel
RUN wget https://gitlab.kitware.com/cmake/cmake/-/archive/v3.13.4/cmake-v3.13.4.tar.gz
RUN tar zxvf cmake-v3.13.4.tar.gz
RUN cd cmake-v3.13.4; ./bootstrap --system-curl && make && make install
RUN mv /bin/cmake /bin/cmake.backup
RUN ln -s /usr/local/bin/cmake /bin/cmake
RUN rm cmake-v3.13.4.tar.gz

# c++ opencv
RUN git clone https://github.com/opencv/opencv.git /root/opencv; cd /root/opencv/; git checkout -b 4.0.0 4.0.0 ;
# contrib入れると大きすぎてdockerのデフォルト容量をオーバーして失敗することがあるので、一旦除外
#RUN git clone https://github.com/opencv/opencv_contrib.git /root/opencv_contrib; cd /root/opencv_contrib; git checkout -b 4.0.0 4.0.0 ;
#RUN mkdir /root/opencv/build; cd /root/opencv/build; cmake -D OPENCV_EXTRA_MODULES_PATH=/root/opencv_contrib/modules ..
RUN mkdir /root/opencv/build; cd /root/opencv/build; cmake ..
RUN cd /root/opencv/build; make; make install;

このdockerfileの注意点として、OpenCVのcontribはつけてません。

contribはやたら大きいので、手元の環境ではdocker desktop for windowsのコンテナサイズを超えてしまいました。 なので、一旦contribなしでいきました。

各パッケージのinstall後でファイルを削除しているのも同様の理由です。

動作確認

適当にコード書きます。

OpenCVのビルド情報を表示するだけのものです。

#include <iostream>

#include <opencv2/opencv.hpp>

int main(int argc, char*argv[])
{
    auto buildinfo = cv::getBuildInformation();
    std::cout << buildinfo;
    return 0;
}

ビルドします。ここでも注意点が1つ。

OpenCV4.0ではpkg-configのサポートを終わらせるかどうか議論しているようです。

(自分でビルドとインストールするならパスは分かっているので気にしなくていいのですが、cmake使わない人=パッケージマネージャな人?だとどうするんだとか)

https://github.com/opencv/opencv/issues/13154

どうやらcmakeのオプションにpkgconfig使うように指定すれば、pcファイル吐いてくれるので、それを使えばよさそうです。

今回は自分でビルドしてインストールパスもわかっているので、直接指定します。たとえばこんな感じで。

g++ test.cpp -I/usr/local/include/opencv4/ -L/usr/local/lib64/ -lopencv_core

動かしてみる

ここでもpkg-configがsoを探してくれないので、ちょっとばかり対応が必要です。

出来上がった実行ファイルをそのまま実行すると、soが見つからないっていわれます。

[root@ce33ad34d4ba src]# ./a.out
error while loading shared libraries: libopencv_core.so.4.0: cannot open shared object file: No such file or directory

新しいライブラリ入れるとよくある現象ですね。soを参照できているか見てみます。

[root@ce33ad34d4ba src]# ldd a.out
        linux-vdso.so.1 =>  (0x00007fffd5d80000)
        libopencv_core.so.4.0 => not found
        libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007f51622b6000)
        libm.so.6 => /lib64/libm.so.6 (0x00007f5161fb4000)
        libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f5161d9e000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f51619d1000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f5162639000)
[root@ce33ad34d4ba src]# ./a.out

確かにopencvがnot found.ですね。ここでもパスは分かっているので、環境変数を指定して実行します。

LD_LIBRARY_PATH環境変数opencv*.soの置かれているパスを入れて実行。

[root@ce33ad34d4ba src]# LD_LIBRARY_PATH=/usr/local/lib64/ ./a.out

General configuration for OpenCV 4.0.0 =====================================
  Version control:               4.0.0

(略)

やったね。

トラブルシューティング

libcのバージョンが古いときは、共有ライブラリの中を確認する必要があります。

strings /usr/lib64/libstdc++.so.6 | grep GLIBCXX

これで該当するバージョンが入っているかどうか調べることができます。

gccとlibcのバージョンアップではこちらのサイトを参考にさせていただきました。

https://www.saintsouth.net/blog/update-libstdcpp-on-centos6/

感想と今後の展望

サーバ側のOpenCVは最新にすることができました。これでOpenCVのONNIXが利用できるようになります。

ONNNIXはDeepLearningの各種フレームワーク間でデータを受け渡せるようにするフォーマットで、今後はサーバ側はC++/OpenCVだけでいけるかもしれません。

とはいえサーバ側をすべてC++で書くのは骨が折れるので、PythonバインディングOpenCV叩いたほうが早い気がしなくもないです。

あとはC++が最新版になったので、いろいろと使いたい機能が使えるようになりました。

C++も20になって関数型言語っぽい香りが本格的になってきたので、時間をとっていろいろ試したいと思います。

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

websocketとhttpの共存Webアプリを作る(nginx + uwsgi + flask)

そろそろWebSocketやってみようと思いました。かたりぃなです。

まずWebSocketを概念レベルで整理してから、技術的な実装をやっていきます。サンプルコードコピペしたい人は後半まで飛んでください。(未来の自分へのメッセージ)

WebSocketの概要

WebSocketはウェブアプリケーションでの双方向通信を実現するためのプロトコルです。

と一言で表現しても抽象的すぎます。このままでは何が美味しいのかわからないので幾つかの方向から分析してみます。

HTTPとの比較

HTTPは基本的にRequest-Responseという1組のメッセージのやり取りで構成されるシンプルなものです。

クライアントからのRequestがシーケンスの起点となっているため、クライアント→サーバのメッセージは任意のタイミングで実行できますが、サーバ→クライアントのメッセージは色々と難しいという問題があります。

クライアントがポーリングするとか、ロングポーリングという解決策もありますが、HTTPのヘッダを毎回送信するのとか、ロングポーリングの時間の問題とかいろいろあったりします。

んで、この問題を解決する方法の1つがWebSocketです。

WebSocket使わずにTCPレベルでやりとりすればいいのでは?

WebSocket以外の解決策を考えた時、実装レイヤを下げてトランスポート層(TCP,UDP)でやるのもテかと思います。

しかしそうしてしまうと面倒な問題が増えてしまいます。すぐ思いつくのは疎通できるかどうかの問題です。

疎通できるかどうかで一番大きなものはルータ越えの問題(NATトラバーサル)とかでしょうか。 昔のPCでやるMMORPGなんかで見かけた「ポートxxを通過できるようルータやファイアウォールを設定してください」とかいうやつですね。

最近だとルータのUPnPを叩いてポートマッピングを自動でやったりとかが多いようですが、Webアプリで双方向通信をするためにそういうのを使うのは大げさすぎます。

なので、そんな大げさなことはせず、せっかくWeb標準としての通信規格として存在しているWebSocketを利用するのが現時点では最良と考えます。

実際のプロトコル

誤解を恐れずにいうなら「HTTP上に構成されるソケット通信プロトコル」といったところでしょうか。

実際のシーケンスとしては

  1. クライアントはHTTPリクエストに「WebSocket使いたいよ!」というUpgrade要求を含めて、サーバへ要求
  2. サーバはアップグレード要求に対する応答を返す
  3. WebSocket通信開始
  4. クライアント/サーバとも任意のトリガでソケットをread/writeすればいい

これでNATトラバーサルの話はこの時点で既に半分はクリアしていることになります。 HTTP上に構成されるプロトコルなのでWebページを見る場合と何ら変わりません。

少々気を付けるポイントとしてKeepAlive的なところで、WebSocket接続を維持するために一定時間ごとにメッセージを送受信する必要があります。

ただし、いくつかのWebSocket対応ライブラリでは自動で面倒見てくれるようになっています。

今回利用するuwsgiも自動でやりますよと明記されています。

WebSocketを試してみる

実験環境はいつものです。

システム構成

nginxをリバースプロキシとして、HTTPページとWebSocketページに振り分けます。

HTTPとWebSocketのどちらもWebアプリケーションのフレームワークとしてflaskを使用しますが、実際にはルーティング程度しかやっていません。

flaskをむき出しで運用するのはよくないので、アプリケーションサーバとしてuwsgiを使用することにします。

uwsgiとアプリケーション間のプロトコルは色々選択できるようになっています。今回はHTTPでやっちゃいます。

nginxの設定

ホスト名を見て振り分けたいところですが、ホストOSのhosts設定とか面倒なのでポート番号で振り分けることにしました。

HTTPの場合は何もせずHTTPサーバへ転送します。

user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                  '$status $body_bytes_sent "$http_referer" '
                  '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    upstream uwsgi {
        server uwsgi:3031;
    }

    # FlaskによるHTTPサーバ
    server {
        listen 8080;
        charset utf-8;

        location / {
            include uwsgi_params;
            uwsgi_pass uwsgi;
        }
    }

    upstream websocket {
        server uwsgi-ws:3032;
    }

    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    # WebSocket
    server {
        listen 8081;
        charset utf-8;

        location / {
            proxy_pass http://websocket;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
        }
    }
}

一個だけポイントがあって、WebSocketホスト側の転送設定でproxy_set_headerしています。

これはnginxをWebSocketのリバースプロキシとして使うとき、upgradeメッセージを適切にバックエンドに渡してあげる必要があるためです。

詳細はnginxのマニュアルを参照。

https://www.nginx.com/blog/websocket-nginx/

UWSGI

uwsgiはアプリケーションサーバです。cgi関連の実装にWSGIというのがありますが、それとは別物です。

初めてuwsgiの名前を知った時、u=μ(マイクロ)だと思って「軽量なwsgiプロトコルかな?」とか思ってしまいましたが、そうではなかったようです。

今回のuwsgiの役割はpythonアプリケーションのホスティングです。

今までflaskむき出しでテストしていましたが、これからはflaskむき出しではなく、サービス化も目指してこいつを使っていこうと思います。(実際、flaskのビルトインサーバは実運用では非推奨なので。)

uwsgi環境を用意するdockerfileはこうなりました。(後述の参考URLの内容ほぼそのままです)

# ベースイメージ
FROM python:3.6

RUN mkdir /var/www
# workdirの指定
WORKDIR /var/www

# 依存Pythonライブラリ一覧コピー
COPY requirements.txt ./

# 依存Pythonライブラリインストール
RUN pip install --no-cache-dir -r requirements.txt

# ログ出力用ディレクトリ
RUN mkdir /var/log/uwsgi/

CMD ["uwsgi","--ini","/var/www/uwsgi.ini"]

依存ライブラリを指定するrequirements.txtには

Flask
uwsgi

を書いておけばOKです。

UWSGIを構成する

今回は1つのuwsgiで1つのアプリケーションをホスティングします。実質、2つのuwsgiを立てます(http用,websoket用)。

1つのuwsgiで複数のWebアプリを管理する方法がまだわからない&dockerコンテナなら1コンテナ1役割のほうがいいだろうという考えです。

コンテナならこういうのを軽く実現できるから良いですね。

コンテナ起動時にuwsgi.iniを与えて、それに従って動作します。

たとえばwebsocket側のuwsgiの設定はこうなりました

[uwsgi]
wsgi-file = /var/www/src/uwsgi_test.py
callable = app
master = true
processes = 1
http=0.0.0.0:3032
vacuum = true
die-on-term = true
py-autoreload = 1
http-websockets = true
log-5xx = true
async = 100
ugreen = true

HTTP側はこんな感じです。

[uwsgi]
wsgi-file = /var/www/src/run.py
callable = app
master = true
processes = 1
http=0.0.0.0:3031
chmod-socket = 666
vacuum = true
die-on-term = true
py-autoreload = 1

注意点として、このdockerfileでインストールされるuwsgiはasyncとupgreenパラメータの設定が必要でした。 バージョンごとにasync対応かどうかみたいなのがあるみたいですね。

flask(HTTP)

単純な仕組みなのでコードだけ記載して説明は省略します。 hello worldを返すだけのアプリケーションです。

from flask import Flask
app = Flask(__name__)

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

flask(WebSocket)

こちらも単純な仕組みなのでコードだけ記載して説明は省略します。 これで一旦動くとこまで来ましたが、なんか違う気がしていてちょっと自信ないです。

import uwsgi
import time
import logging
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello():
    app.logger.setLevel(logging.INFO)
    app.logger.info('flask document root accessed')
    uwsgi.websocket_handshake()
    while True:
        msg = uwsgi.websocket_recv()
        app.logger.info('uwsgi.websocket_recv result = {}'.format(msg) )
        uwsgi.websocket_send(msg)
        app.logger.info('uwsgi.websocket_send finish.')

if __name__ == '__main__':
    app.run(debug=True)

docker-compose

ここまでに作成した3つのコンテナを立てたり落としたりするdocker-composeです。

今回は実験用なので、nginx, uwsgi(websocket), uwsgi(http)の3つのホストすべてを同じネットワークに接続しています。

各サービスでnetworksを記述することで、そこに接続されたホストと通信できるようなネットワーク構成ができます。

version: "2"
services:

  # HTTPのWebアプリケーション
  uwsgi:
    build: ./app
    volumes:
      - ./app:/var/www/
    networks:
      - python-website
    ports:
      - "3031:3031"
    environment:
      TZ: "Asia/Tokyo"

  # WebSocketのWebアプリケーション
  uwsgi-ws:
    build: ./appws
    volumes:
      - ./appws:/var/www/
    networks:
      - python-website
    ports:
      - "3032:3032"
    environment:
      TZ: "Asia/Tokyo"

  # WebFront
  nginx:
    build: ./nginx
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
    networks:
      - python-website
    ports:
      - "8080:8080"
      - "8081:8081"
    environment:
      TZ: "Asia/Tokyo"

networks:
  python-website:

client

WebSocketのサーバを叩くのはjsからやることにしました。ほとんど書いたことありませんが。

本当は使い慣れた言語(C++とか)から叩きたかったのですが、仕様とか調べるのが面倒だったので。

jsのコードはこちらのページを参考にさせていただきました。

https://qiita.com/hiroykam/items/c3e3d20c223b01d9f0e8

<!DOCTYPE HTML>
<html>
  <head>
    <script type="text/javascript">
      function WebSocketTest()
      {
        if ("WebSocket" in window) {
          alert("WebSocket is supported by your Browser!");
          var ws = new WebSocket("ws://localhost:8081/");
          ws.onopen = function() {
            ws.send("Hello from client");
            alert("Message is sent...");
          };
          ws.onmessage = function (evt) {
            var received_msg = evt.data;
            alert("Message is received...");
            alert(received_msg);
          };
          ws.onclose = function() {
            alert("Connection is closed...");
          };
        } else {
          alert("WebSocket NOT supported by your Browser!");
        }
      }
    </script>
  </head>
  <body>
    <input type="button" onClick="WebSocketTest();" value="WebSocket Test">
  </body>
</html>

動作確認

上記htmlを開いてボタンを押せば順にアラートが表示されます。

サーバ側のログを見たい場合はdocker-compose logsとかdocker log コンテナ名で確認できます。

websocket側のログにwebsocketで接続受けたとかメッセージ受けたとか残っています。

感想と今後の展望

ひとまずWebSocketの最低限の基盤が整いました。

今後試していきたい内容は

  • 具体的なデータ送受信(たとえばバイナリデータや、圧縮された映像、音声データのようなフレーム単位での時系列データなど)
  • ポートでHTTP/WebSocket分岐ではなく、flaskのルーティングで分岐したい
  • もう一歩進んでWebRTC

などでしょうか。

特にARでの利用を考えたときに物体検出などは時系列データである動画のほうが有利です。(空間方向だけでなく時間方向も推定する)

ただし、動画にしてしまうとエンコード時に多少なりとも情報が落ちる&処理の負荷があがるので、どうするのが最適かとか考えていきたいですね。

(よくある動画フォーマットならデコードせずに周波数空間で物体検出できるのでは?とか思っていますが、コード書くの面倒すぎるのでたぶんやらないと思います。)

サーバースペックとの兼ね合いもあると思うので、

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