備忘録

弱小院生のメモ

Splatoon2のギア通知botを作り、お蔵入りさせたはなし

こんにちは。

アドベントカレンダーのために放置していたブログを引っ張り出してきました。

というわけで、この記事はこちらのアドベントカレンダーの16日目の記事になります。 adventar.org

今回アドベントカレンダー用に用意していたネタがあったのですが、 色々な手違い(担当日付を勘違いなど)により間に合いませんでした...

ということで今回は、記事化せず放置していたbotの話をします。

Splatoonbotを作りました

Splatoonと周辺ツールのはなし

f:id:reverent_f:20171216222238j:plain:w500

皆さんSplatoon2、遊んでますか?

もうすぐ発売5ヶ月になりますが、 先日の大型アップデートで新ルールのガチアサリも配信され、まだまだ盛り上がっているゲームです!買ってください!

さて、このゲームではガチマッチという所謂レート戦のモードがあるのですが、 その手のゲームでは、得てして対戦結果の統計を取って色々と(得意なステージとか)分析をしたいという欲求がつきものなんですね。

所謂e-sports系のコア層向けのゲームを展開しているBLIZZARD社なんかは、 こうした要求に合わせて戦績閲覧ができるAPIを公開していたり するのですが、 ライトゲーマーが多い任天堂さんは公開APIは準備してくれてはいません。

イカリングというフレンド交流機能をメインとしたはありましたが、戦績を確認することはできません。

f:id:reverent_f:20171216222300p:plain:w250

(引用 : イカリング / 久しぶりに見にいったらサービス終了してました...。)

WiiU用ソフトとして発売された前作では、キャプチャしたゲームの映像から画像認識で戦績を抽出するなんて荒技(IkaLog)で戦績の保存ツールを作成する方もいらっしゃいました。

www.slideshare.net

(割と単純な画像処理技術でも高い精度で認識できるようです。面白い話が多いので興味がある方は是非読んでみてください。)

さて、新作のSplatoon2になりますと、 新機種Switchのセールスポイントの1つであるスマホアプリ[Nintendo Switch Online]上のサービス、イカリング2が大幅パワーアップしました!

f:id:reverent_f:20171216223952p:plain:w400

(引用 : イカリング2 | スプラトゥーン2 | Nintendo Switch | 任天堂<)

戦績の確認に加えてゲームのチーム分けと連動した通話機能なんかも搭載されまして、 発表時かなりワクワクしたことを覚えています。

このサービスはPCなどからは閲覧できず、専用アプリ上からのみの閲覧となっています。

ところが、中間者攻撃の仕組みを使ってiksm_sessionという名前のセッションをハイジャックすれば、 PCでイカリング2の閲覧や、戦績情報などを引っ張れる!ということで、 色々なツールが誕生します。(SquidTrackssplatnet2statinkなど。)

さらには,毎回mitmproxyを使ってiksm_sessionを持ってくるのは面倒なので、 ニンテンドーアカウントのログイン情報からiksm_sessionを生成する人も現れました...

...が、さすがにラインを超えているということで、

10月頃(だったかな?)のアップデートでiksm_sessionの生成処理が変更され、 一部のアプリケーションを除き手動でのiksm_session取得が必要になりました。

参考 : SplatNet 2 Login Changes (and How to Avoid Making Nintendo Angry

ギア通知Botの作成

長くなりましたが以上が前置きになります。

私も面白半分で9月頃にこの非公式APIを使ったbotを作っていたので、その話をしようと思います。

当時は既に戦績保存ツール・統計可視化サービスなど一通り揃っていたので、 ごくごく個人的な要求を解決するためのbotを作りました。

(以下、スプラトゥーンを知らない方向けの説明は省いているので読み飛ばしてください)

f:id:reverent_f:20171216222324j:plain:w500

前作のダウニーさんに引き続いて、Splatoon2ではこのダウニー君が 広場にいるイカ達の装備しているギアの注文を請け負ってくれます。

前作ではマッチングしたプレイヤーが数戦分広場に止まってくれていたのですが、 今作ではマッチングしたプレイヤーは直近1戦分、その他はいいねをたくさんもらったプレイヤーで埋め尽くされるようになりました。

この変更によって、自分が欲しいギアを広場にいるプレイヤーから探すことが困難になりました。

f:id:reverent_f:20171216224838p:plain:w500

1戦分のプレイヤーしかいないので、よく注文を逃す

一方、イカリング2ではマッチングした相手の保持していたギアも

f:id:reverent_f:20171216222344p:plain:h300

このように表示されます。

そこで、イカリング2の非公式APIを使って、
バトル終了直後にマッチングした相手のギアを通知してくれるBotを作りました!

個人用通知bot

今回作成するbotの機能はこんな感じになります。

要件

  • 使用するのは自分だけ
  • バトル終了後、次のバトル開始までに通知を行う
  • 対戦相手のギアのうち、サブのギアパワーが全て揃っているものがあればそれを通知
  • 通知はslackに投げる
  • 非公式APIを利用するため、APIを叩く頻度を抑える(重要)

使用するのは自分だけなのですが、自慢したかったのでゲーム仲間とのSlackに通知させることにしました。

最後のAPI呼び出し回数については特に注意しなければなりません。

ポケモンGOの某サービスのように、サーバに負荷をかけて正規ユーザに迷惑をかけてしまうようなことは絶対に避けましょう!

Don’t make Nintendo angry

It’s always important to be careful when accessing someone else’s API. This is especially true when using an unofficial API where they don’t intend for anything other than their own apps to be accessing it.

引用 : SplatNet 2 Login Changes (and How to Avoid Making Nintendo Angry

構成

詳細は省きますが、今回利用するAPIには2種類のものがあります。

  1. 直近50戦の一覧API
  2. バトルの詳細API (query : バトルID)

定期的に1のAPIをチェックし、新しいバトルの情報があれば詳細をフェッチすれば良さそうです。

というわけで、botの構成はこんな感じになります。

f:id:reverent_f:20171216222403p:plain

中身は定期的にcurlするだけです。確かrubyで実装した気がします。

バトルにかかる時間が1回あたり3~5分程度なので、1分に1度呼び出すことにします。

このときのAPI呼び出し回数ですが、1時間あたり最大でも60回(新着チェック)+20(詳細チェック) = 80回なので充分通常利用の範囲内といえるでしょう!

結果

f:id:reverent_f:20171216222416p:plain:w300

こんなかんじで通知が飛んできます。 表示方法は試行錯誤の結果、各ギアをstampとして登録する形になりました。

複数人が使用できるように拡張する

当然Slack内の友人も利用したいという要望がでてきたため、botを拡張しました。

要件

  • 利用人数は身内の数人のみ
  • セッションの取り扱いは後で考える
  • 金がないので無料の構成にしたい

構成

なるべく金をかけずに構成するために、heroku + Railsでバックエンドを実装しました。

herokuを利用する際に困るのが、定期実行です。

herokuではcronが使えないうえに、heroku schedularでは毎分の定期実行は不可能です。

そこで今回はGoogle Apps Scriptのタイマー機能を使います。 (参考 : Google Apps Scriptを使って定期実行するcronを作る - SakanaTech)

f:id:reverent_f:20171216222432p:plain

通知オン/オフ

24時間毎分全員分の更新確認を行うわけにはいかないので、 通知のオンオフ機能をSlackのInteractive Message機能を使って実装します。

f:id:reverent_f:20171216222442p:plain:w300

結末

以上長くなりましたが目的のbotが作成できました!

あとは友人のiksm_sessionの自動更新処理を作るだけ...というところまで作った段階で、

iksm_sessionの生成方法が変更されてしまい、 実際に友人に使ってもらう前にこのbotはお蔵入りすることになりました。

まとめ

  • Slackのbotを作るのは初めてだったので良い経験になった
  • GAS便利
  • 非公式APIの使い方には気をつけよう

Kodak PIXPRO SP360-4K のWifiストリーミング環境構築

Kodak社のPIXPRO SP360-4Kは1台で半天球、2台合わせて全天球映像を撮影可能なカメラである。

PIXPRO - マスプロ電工|MASPRO

UVC規格対応でUSBケーブルによる接続でWebカメラとしても利用できるが、 Wifiルータを内蔵しており映像をストリーミングできる機能が付いている。

下記のサイトを参考にMacでの利用を試みたが微妙にハマったので忘れないようにメモしておく。

tks-yoshinaga.hatenablog.com

yaaam.blog.jp

環境

Mac OS Sierra + Python3(3.5.2) + openCV3(3.2.0)

とりあえず繋いでみる

cap = cv2.VideoCapture("http://172.16.0.254:9176/")
cap is None:
        print("Failed to open the camera.")
while True:
        ret, frame = self.cap.read()
        if not ret:
            print("Failed to capture the image.")
            return None
        cv2.imshow("image", frame)
$ python capture.py 
VIDEOIO(cvCreateFileCapture_AVFoundation (filename)): raised unknown C++ exception!

Failed to capture the image.

原因

どうやらffmpegが無かったのが原因らしい。

$brew reinstall --force opencv3 --with-ffmpeg --with-python3

virtualenvを利用しているので再インストールしたOpenCV3へのシンボリックリンクを作り直す。

blog.ymyzk.com

その他

参考にしたサイトには受信側IPを172.16.0.1に固定するように書いてあったが、 自動設定でも問題なく接続できた。(むしろ固定すると接続に失敗した)

Pythonによるスクレイピング&機械学習 開発テクニックを買った

夕飯の買い物ついでにふらりと寄った書店で技術書を買った。

スクレイピングもTensorflowもやってみたかったのでちょうどいいと思い購入。 Seleniumってスクレイピングにも使えるのね(考えてみればそりゃそうだけど)

コンマイの楽曲データをスクレイピングするプログラムでもサクッと書いてみようと思う。 画像認証(reCAPTCHAとかそういう意味の)の突破はやらない。

adaptiveThresholdさん見直し

2値化の処理を探っているとき、cv2.adaptivethreshold()があまり使えないように思っていたが、ただ単に自分のチューニング不足だった。

cv2.adaptivethreshold(image, method, blocksize, c)

画像のしきい値処理 — OpenCV-Python Tutorials 1 documentation

Block Size - しきい値計算に使用する近傍領域のサイズ.1より大きい奇数を指定する必要があります.

C - 計算されたしきい値から引く定数です

Cの存在意義を理解していなかったが、文字領域の抽出においてはこいつを適切にチューニングする必要があった。

OpenCvSharpをつかう その15(適応的閾値処理) - schima.hatenablog.com

減算定数の意味

最後の減算定数は何のためにあるのでしょうか。

(中略)

文字が有る領域: 周囲の画素値はバラエティ豊か(白地に黒い細い線、で構成されるので)

文字が無い領域: 周囲の画素値はほぼ同じ(周りじゅうが白)

周り中が似たような色のとき、減算定数が有ることで、減算後は対象ピクセル閾値を上回ることになり、白くなります。

これにより、背景領域では多少のノイズ・色の揺らぎに負けずに白で塗りつぶしやすくし、文字領域では黒いエッジを残しやすくなります。賢いですね。

なるほど賢いので早速実験してみた。

コード

def binalizeByAdaptive(img):
    r = img.copy()

    # R, G値のみ取り出しグレースケール化
    green = r[:,:,1]
    red = r[:,:,2]
    redGreen = cv2.addWeighted(red, 0.5, green, 0.5, 0)

    # binalize
    th_red = cv2.adaptiveThreshold(redGreen,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,\
            cv2.THRESH_BINARY_INV,21,20)
    
    # cleaning noise by opening
    kernel = np.ones((1,1),np.uint8)
    th_red = cv2.morphologyEx(th_red, cv2.MORPH_OPEN, kernel)

    cv2.imshow("binalize", th_red)
    # cv2.waitKey(0)

    return th_red

blockSize, Cの値は試行錯誤した結果の値である。

結果

成功例

f:id:reverent_f:20170124185128p:plain:w250 f:id:reverent_f:20170124185130p:plain:w250

文字の縁取り部分まで綺麗に検出できている。 また、0,8,9などの文字中の空洞部分も比較的綺麗に見える!

失敗例

  • 減算定数

f:id:reverent_f:20170124185156p:plain:w250

減算定数が大きすぎることで文字の中心部分が潰れた。 これは検出に失敗したら減算定数を変化させてもう一度認識、とすれば対応できそう。

  • モアレ

f:id:reverent_f:20170124185235p:plain:w250 f:id:reverent_f:20170124185238p:plain:w250

モアレが発生している画像だとノイズが多量にのってしまう。 フーリエ変換して高周波成分を取り除く、までやるのは難しいか … ?

まとめ

やはり適応的2値化処理は照明条件の変化に強く、綺麗に文字の検出ができる!

ただし、文字の縁も綺麗に残るので、文字の切り出し処理を修正しなければならない(嬉しい悲鳴)

ただし、モアレに非常に弱いことが判明した。 有効なモアレの除去方法が思いつかないため、モアレが発生している場合は従来の2値化処理を使うしかなさそうだ。

その場合は教師データを2値化の処理ごとに2つ用意しなければならない … 。

雑記

どこまでの画像を認識対象とするか、そろそろ限定しなければならない。 そろそろサイトの作成にも取り掛かりたい。

kNNで文字認識

kNNとは

k近傍法(k-nearest neighbor)。かなり単純なクラスタリングアルゴリズム

  1. 教師データ(ラベリング済)を特徴ベクトル化して学習
  2. クラスタリング対象のデータも特徴ベクトル化
  3. 特徴ベクトルの距離を比較し、対象データと近い順にk個の教師データを選ぶ
  4. 選ばれた教師データに付与されているラベルから対象データのクラスを決定

直感的に理解できる簡素なアルゴリズムである。 OpenCVで実装されているため簡単に試せる。

qiita.com

IkalogではkNNで文字認識を行なっているらしい。

「スプラトゥーン」リアルタイム画像解析ツール 「IkaLog」の裏側

文字位置・角度・照明条件が可変な今回のケースではどの程度の精度が出るのか、試しにやってみた。

学習データの準備

前項までで文字位置の特定ができているので、その領域を切り出して等サイズにリサイズ→保存する。

resized_img = cv2.resize(character_img, (RESIZED_WIDTH, RESIZED_HEIGHT), 
                                            interpolation=cv2.INTER_NEAREST)

PythonでkNN

学習は毎回実行時に行う。

  1. 学習データを1行N列のベクトルに変形
# sample = img.reshape((1, img.shape[0] * img.shape[1]))
sample = img.reshape((1, -1))

reshapeの引数のサイズのうち片方に-1を指定すると,もう一方の値から適切な数値を判断して変形してくれる。 Numpyつよい。

ラベルは別配列に要素番号が同じになるように保存する。

label = ord('0') + int(character_i)
labels.append(label)

samplesは[1行N列のベクトル]の配列、かつtype() == CV_32Sになるように注意して変形する。 labelsも[1行1列のベクトル(?)]の配列、かつtype() == CV_32Sになるように変形。

printするとこんな感じ。

samples
[[   0.    0.    0. ...,    0.    0.    0.]
 [   0.    0.    0. ...,    0.    0.    0.]
 [   0.    0.    0. ...,    0.    0.    0.]
 ..., 
 [   0.    0.    0. ...,  255.  255.    0.]
 [   0.    0.    0. ...,  255.  255.    0.]
 [   0.    0.    0. ...,  255.  255.  255.]]

labels
[[ 48.]
 [ 48.]
 [ 48.]
...,
 [ 57.]
 [ 57.]
 [ 57.]
 [ 57.]]

学習部分全体のコード。

    samples = None
    labels = []
    # for 0 - 9
    for character_i in range(0, 10):
        img_dir = TRAIN_DATA_DIR + str(character_i) + "/"
        files = os.listdir(img_dir)
        for file in files:
            ftitle, fext = os.path.splitext(file)
            if fext != '.jpg' and fext != '.png':
                continue

            # Load image
            abspath = os.path.abspath(img_dir + file)
            img = cv2.imread(abspath, cv2.IMREAD_GRAYSCALE)
            if img is None:
                print('Failed to read image')
                break

            sample = img.reshape((1, -1))
            label = ord('0') + int(character_i)

            if samples is None:
                samples = np.empty((0, img.shape[0] * img.shape[1]))
            samples = np.append(samples, sample, 0).astype(np.float32)
            labels.append(label)

    labels = np.array(labels, np.float32)
    labels = labels.reshape((labels.size, 1)).astype(np.float32)

    knn = cv2.ml.KNearest_create()
    knn.train(samples, cv2.ml.ROW_SAMPLE, labels)

文字認識

学習結果を使って画像をクラスタリングすることで文字認識を行う。

# kNN
ch_string=""
for ch_img in character_imgs:
    sample = ch_img.reshape((1, ch_img.shape[0] * ch_img.shape[1]))
    sample = np.array(sample, np.float32)

    k = 3
    retval, results, neigh_resp, dists = knn.findNearest(sample, k)
    d = chr(int(results.ravel()))
    ch_string += str(d)
print("result:" + ch_string)
cv2.waitKey(0)

認識対象も勿論1行N列の32bitfloat配列に直してknn.findNearest()につっこむ。

結果

成功例

f:id:reverent_f:20170124182341p:plain:w200

result:09828932

f:id:reverent_f:20170124182510p:plain:w200

result:08463768

失敗例

f:id:reverent_f:20170124182543p:plain:w200

result:09955509

f:id:reverent_f:20170124182548p:plain:w200

result:09680560

13画像(104字)に対して6文字の認識ミスだったので認識精度は94.23%。 0-8-9あたりの誤認識が多かった。

考察

kNNのアルゴリズムを考慮すると、学習が足りないとかいう問題ではなくノイズの影響が大きいように思う。

  • ひどい例

9 : f:id:reverent_f:20170124182747p:plain 8 : f:id:reverent_f:20170124182750p:plain

また、各画素値を単純に並べて特徴ベクトルにする関係上、上下端の余白の大きさや回転による影響もありそう。

  • 不必要な部分まで切り取られたパターン

8 : f:id:reverent_f:20170124183215p:plain

2値化処理を見直すかDeep Learning様の力でゴリ押すかしようと思う。

macOS10.12 x Objective-C x 32bit(i386)でビルドエラー

ちょっとした理由があり、SwiftではなくObjective-COSXのアプリケーションを書いている。

初遭遇するタイプのハマり方をしたのでメモ書き程度に残しておく。

問題

f:id:reverent_f:20170119190333p:plain

  • Architectures を64bit(x86_64)なら問題なくビルドできる
  • Architectures が32bit(i386)の時のみエラーが発生する

エラー内容

@property (nonatomic, strong) HOGE *hoge;

しているのに

_hoge = fuga;

Use of undeclared identifier '_hoge'.

自動生成されるはずのsetter/getterが作られていない?

stackoverflow.com

5年前の記事だけど、そういうことらしい。 32bit版と64bit版で挙動が異なるなんてことがあるのだなあと勉強になった。 (バグ?)

まとめ

  • Architecturesの違いでSDKの動作が変わるなんてことがあるらしい
  • 思った以上にMacOSSDKの仕様変更の影響は大きい
  • 1から書くのならあまり問題にはならないけれど、古いコードを新SDKに対応させるのはとても大変だ
  • 早くAWSOSXSDKをリリースしてほしい

文字位置切り抜き

学習用の画像を集めていくうちに文字位置の切り抜きに失敗することが多くなり、 このままではロバストな認識は行えないということで改善を試みた。

スコア部分の切り抜き

画像によってヘッダの認識にずれがあり、切り出したスコア部分に欠けが生じることがあった。

この処理で欠けると後の処理では回復できないので、かなり大きめに領域を取得する。

f:id:reverent_f:20170114200349p:plain

2値化

P-タイル法を試してみたが、安定しないので今まで通り R値とG値の合算結果に大津のアルゴリズムを適用して2値化する。

f:id:reverent_f:20170114200617p:plain

スコア文字部分の絞り込み

大きく切り抜いた分だけ余計な領域が発生する。 幸い、切り抜きたい文字の周りには黒い余白が現れるためこれを手掛かりに絞り込みを行う。

  1. 画像の上下左右に固定幅の白枠をつける

このとき、元から画像に存在している枠によって、目的文字部分と(あれば)右の余計なコンボ数部分が隔てられる。

2.白枠で縁取られた領域のうち最大部分を切り抜く

    # 白枠で縁取られた面積最大の領域を探す
    image, contours, hierarchy = cv2.findContours(l_img,cv2.RETR_CCOMP,cv2.CHAIN_APPROX_SIMPLE)
    contourImg = cv2.cvtColor(l_img, cv2.COLOR_GRAY2BGR)
    bgr_total = contourImg.copy()

    inner_contours = []
    for index, contour in enumerate(contours):
        if hierarchy[0][index][2] == -1:
            inner_contours.append(contour)

    approxes = []
    max_box = None
    for contour in inner_contours:
        # 矩形補完
        epsilon = 0.01*cv2.arcLength(contour,True)
        approx = cv2.approxPolyDP(contour,epsilon,True)
        area = cv2.contourArea(approx)

        if max_box is None or cv2.contourArea(max_box) < cv2.contourArea(approx):
            max_box = approx
        if(area > 20):
            approxes.append(approx)
            x,y,w,h = cv2.boundingRect(contour)

f:id:reverent_f:20170114201337p:plain

各文字特定

以前の記事で書いたヒストグラムを使った処理によって文字候補をリストアップする。 以前までの処理では文字以外の部分もリストアップされていたが、文字候補部分のアスペクト比からさらに候補を絞り込む。

f:id:reverent_f:20170114201559p:plain

以前より閾値を厳しく設定し、文字候補に漏れがあっても文字同士が連結しないようにする。

候補から外れた文字部分の補完

文字候補として外れてしまった文字(上図なら 7 )を文字の位置関係から推測する。

ほとんどの場合スコア先頭に来る 0 , 9 は上記の処理で文字候補から外れることはほぼ無い。 そこで、スコア先頭文字は確実に候補中にあるとして、右方向に8文字あると仮定し補完する。 先頭5文字と後半3文字でフォントサイズが異なることに気をつける。 細かい推測方法は省略する。

f:id:reverent_f:20170114201818p:plain f:id:reverent_f:20170114202302p:plain f:id:reverent_f:20170114202305p:plain

整形

あとは各文字の上下の余白を削除・等サイズにリサイズして完成

まとめ

やっとこれで学習用データの切り出しができるようになった! 文字位置の特定がロバストにできればTesseractを使う必要はないので(各文字の画像を検出器にかければいいので)、文字の認識はkNNかKerasあたりをつかったDeepLearningでやる予定。