ネット麻雀(雀魂)をOpenCVと機械学習で自動化した話

概要

pythonからOpenCVのテンプレートマッチ及びGUI操作モジュールを使うことで、
webブラウザ上の麻雀牌をBOTに認識・クリック操作させることができ、プレイの自動化ができました。
また、どの麻雀牌をクリックするかのロジック部分には機械学習を用いました。

テンプレートマッチの探索用画像を差し替えれば雀魂に限らず他の麻雀ゲーム全般で利用可能であり、機械学習の部分を変えれば、特定条件下で合理的選択を繰り返し求められるようなゲーム全般で応用が可能です。

※内容理解の一助とするために記事内随所に雀魂のゲーム内画像を利用していますが、著作権保護等の観点から強いボカシを入れています。

対象読者

(麻雀が好きで)機械学習を触ってみたい人
WindowsやGUI操作の自動化に興味があるけどOpenCVって何だろうって人

雀魂は好きだけど試練イベント走るのがマジ試練すぎて心が折れた人
過去に大学で画像処理系の研究室にいた私の書く暇つぶしスクリプトに興味がある人

私の不純なモチベーション

先日、雀魂にハマりました。最近、飽きました。

デイリーで何局か対戦したり、イベントに参加するとガチャ報酬までたどり着けるようになっているので、ゲームのプレイは続けて可愛い娘をピックアップしたい

でもルールを知ってるかすら怪しいネットの知らん人と、これ以上作業のような麻雀に付き合えない。
(特にイベントは実力に関係なく他プレイヤーとマッチングするし、プレイ後の順位と報酬が一致しないので真面目にプレイするのが辛すぎる)

かといって簡単なツモ切りBOTを動かしただけでは、離席判定をされてしまって最悪アカウントがBANされます。(これには驚いた。やるな運営。)

ということで、

  • マウスのクリック操作をシミュレートすることによってブラウザ操作する
  • 牌を選んで捨てる&アガリができる

BOTを作ろうとなった次第です。

構成要素

手牌+ツモ牌情報の取得

概要

OpenCVのテンプレートマッチングと呼ばれる仕組みを使いました。

OpenCV(Open Source Computer Vision Library)...インテルが開発・公開したオープンソースのコンピュータビジョン向けライブラリ。コンピューターで画像や動画を処理するのに必要となるさまざま機能がこれ1つで実現できる夢のようなライブラリ。

テンプレートマッチング(Template matching)...入力画像中からテンプレート画像(部分画像)と最も類似する箇所を探索する夢のような処理。

C#でWindowsアプリケーションとして作ってもよかったのですが、そんなに労力をかけるくらいなら雀魂に直接課金した方が早いので今回はOpenCVにさくっとアクセスできるpythonを採用しました。

pythonをインストールした後

$ pip install opencv-python

だけでOpenCVが使えるようになります。昔はチュートリアルするのも一苦労だったのに…
環境構築はここを参考にするとよいかと思います

pipでOpenCVのインストール
https://qiita.com/fiftystorm36/items/1a285b5fbf99f8ac82eb

全パターン牌画像をあらかじめ用意しておいて、実際のプレイ画面のキャプチャから13牌を切り出し、それぞれ一致する牌画像を探すことで牌情報を取得させます。

画面キャプチャの注意

ブラウザサイズ=アプリ画面サイズとならないことに注意してください。

雀魂はブラウザをみょんみょん変形させるとわかりますが、どの解像度でも縦横比が一致するように作られたwebアプリケーションです。1
つまり、ブラウザ変形してゲーム本来の縦横比と一致しない描画エリアは漆黒地帯として表示されます。

ここでテンプレートマッチはサイズ伸縮した検出対象に弱いので、処理の最初に画面(キャプチャ)サイズを取得したうえで、用意しておいた牌テンプレート画像のサイズに合わせるように調整処理を入れるのが大切です。
(毎回決まったディスプレイ環境+ブラウザサイズでゲームをプレイするということであれば、毎回キャプチャ画像とテンプレート画像のサイズが一致するのでこの処理は不要です)

※ブラウザのキャプチャをとるpython用モジュールも多種配布されてますが、私の手法では単純に全画面キャプチャを撮ったのち、アプリ画面の端あたりをテンプレートマッチングによって位置検出することで純粋なアプリ画面の切り出しをすることにしました。

image.png
縮尺の計算時に誤差が少なくなるよう、なるべく遠い2点に位置する画像要素をテンプレート用画像として事前に抽出します。
局面によって変化せず、画面上に類似する特徴量をもたない(≒識別しやすい特徴的な)画像を選ぶとよいでしょう。

また、このときテンプレート画像はサイズ種類のものを3-5種類ほど用意して(あるいは処理中に縮尺変形させて)最も一致度が高くなるテンプレート画像とその尺度を採用するなどの処理が必要です。2(テンプレートとして用意する画像と、実際のキャプチャ画像のサイズが異なった結果、画面領域の検出に失敗すると処理全体が不安定になるため)

テンプレート画像として抽出した画像のサイズとその時点の2点間距離をあらかじめ計ってデータとして持たせておくことで、ディスプレイ環境が変わっても手牌が並ぶ場所の相対位置を求めることができるようになります。
テンプレートのどちらかを基準に手牌を1つずつ切り出し(cv2.crop)すると下記のように画像が抽出できました。

(テンプレート元となる手牌画像データを手作業で収集したせいか、すこしガタつきがあります。認識精度に大きな影響がでなかったのでよしとしました)

image.png

ドラ牌は常にピカピカ光っている画面上の演出があるのですが、画像処理的にはすごーーーく嫌な予感がしますね(笑)

手牌の認識

さて、牌そのものを認識するためには、さらに抽出した牌画像それぞれを下記のような「牌一覧画像」からテンプレートマッチさせます。

image.png
(※入力した九萬画像が牌一覧画像の一部にテンプレートマッチしている様子)

横1列にずらーっと牌を並べたデータを作っておいて、検出した画像のx座標によって牌の正体が分かるという具合です。
(この全麻雀牌横並びデータを作るのが一苦労でした…)
image.png
処理は問題なさそうですが、ここまでで2.7秒かかってしまっている。。。
雀魂は持ち時間が5秒なので、1処理あたりにかけられる時間は5秒まで。
もうちょっと言えば、ループ処理の始まり=ツモした瞬間というわけにはいかない訳ですから、少なくとも2.5秒間隔の監視ループ(手番スタート→処理開始(Max2.5s経過後)→処理完了(2.5s)→手番終了)とする必要があるので、あかん感じですね(;'∀')機械学習部分も処理時間かかるのに…ま、後からリファクタリングしましょ!3

「何切る」問題

概要と先行事例

13手牌とツモ牌を入力することで、捨てるべき牌を出力するプログラムを探しました。ないですね。
というか麻雀ゲーム+AI界隈はまさにこのあたりについてしのぎを削っている最中でした。ごめん諸先輩。

さらに私の目的は「ぼくのかんがえた最強のまーじゃんAI」ではなく、雀魂運営がロボットと断定できない程度にぬらぬらプレイし続けるBOTということなので、この辺は適当につくります。

かの有名なgithubという名のアマゾンに「mahjong」キーワードで分け入ってみましたところ

HTML5 + JavaScript で動作する麻雀アプリ「電脳麻将」より「何切る」判定部分
https://github.com/kobalab/Majiang
[動作デモ]http://kobalab.net/majiang/dapai.html

これはかなりよさそうに見えました。
著者はご自身のブログで「何切る」判定ロジックに係る四苦八苦をログとして残されているので、最強AIを目指す方にはかなり参考になると思います。

とはいえこれjavascriptなのでpythonからデータ入出力の接続をするのが若干面倒。。。
pythonでそのまま使えるのはないかな~と探していたら、ありました。

機械学習の採用

ディープラーニングによる麻雀の何切るAI
https://github.com/hogeki/dlmahjong

これは機械学習に触れたことがない方にはreadmeだけだと、少しとっつきにくいので下記にgit cloneした後、動作するまでの手順を記します。

まずTensorFlow4をpipで導入しますが、ここではバージョンはきっちり合わせた方が幸せになれるでしょう。マジで。人柱の話は聞いといた方がいいです(真顔)

pip tensorflow==1.70 (2020.8.5現在の当該gitレポジトリの内容に従う)

ありがたいことに天鳳の牌譜データ(.txt)もついているので、そのまま学習してパラメータとして保存できます。

python mahjong_ai.py --train --save

これで学習結果のパラメータが外部データ(学習モデル/学習済みモデル)として保存されるはずです。
今後はこのモデルを引数として使うことで何切る判定処理ができるようになります。

該当gitのスクリプトでは「13+1の牌データを情報として渡して、何を切るべきか教えてくれる」という機能は実装されていないので(あくまで後述する連続したテストとその性能評価しかない)その部分は自作する必要があります。コードは下記の通りです。

引数として手牌+ツモ牌の情報を天鳳仕様の文字列で入力すれば、何を切るべきか教えてくれる仕様となりました。(前述の学習モデルはデフォルト引数となっています)

実装スクリプト

mahjong_ai.py(追加部分)
#calc_dahai関数は外部ファイルから呼び出すことを想定しているので
#付随するファイルをmahjong_ai.pyに付随するファイルをimportで参照
import mahjong_common as mjc
import mahjong_loader as mjl

#機能追加に伴うimportの追加
import unicodedata
import re

def init_sess():

    #calc_dahai関数は外部ファイルから呼び出すことを想定しているので
    #sessを設定する部分を関数化し、globalで保存させる。
    # (sessが何かということについてはmahjong_common.pyから読めます)
    global sess

    make_model()
    saver = tf.train.Saver()
    sess = tf.Session()
    sess.run(tf.global_variables_initializer())
    sess.run(tf.local_variables_initializer())
    saver.restore(sess, "ckpt/my_model")

def calc_dahai(hands_str,tsumo_str):

    #第1引数 入力例
    #hands_str="6p6p7p8p3s4s5s5s7s8s東東東"

    #第2引数 入力例
    #tsumo_str="6s"

    #実行前にinit_sess()しないと動かない
    global sess

    #手牌文字列を手牌1つごとの文字列の配列に変換
    tehai_tmp=[]

    #1バイト文字と2バイト文字が混在していて処理しづらいので、2バイト文字→1バイト文字に変換
    #(クソのような仕様だが、天鳳からの入力データがそうなっているのだからしょうがない)
    hands_str = hands_str.replace('東', 'HI')
    hands_str = hands_str.replace('南', 'MI')
    hands_str = hands_str.replace('西', 'NI')
    hands_str = hands_str.replace('北', 'KI')
    hands_str = hands_str.replace('白', 'SI')
    hands_str = hands_str.replace('発', 'HA')
    hands_str = hands_str.replace('中', 'NA')

    #2文字ごとに配列に格納
    tehai_str_list_by_2char = re.split('(..)',hands_str)[1::2]

    #文字を元に戻す
    tehai_str_list_by_2char = [s.replace('HI', '東') for s in tehai_str_list_by_2char]
    tehai_str_list_by_2char = [s.replace('MI', '南') for s in tehai_str_list_by_2char]
    tehai_str_list_by_2char = [s.replace('NI', '西') for s in tehai_str_list_by_2char]
    tehai_str_list_by_2char = [s.replace('KI', '北') for s in tehai_str_list_by_2char]
    tehai_str_list_by_2char = [s.replace('SI', '白') for s in tehai_str_list_by_2char]
    tehai_str_list_by_2char = [s.replace('HA', '発') for s in tehai_str_list_by_2char]
    tehai_str_list_by_2char = [s.replace('NA', '中') for s in tehai_str_list_by_2char]

    #手牌文字列の配列をmahjong_common.pyで決められた数値の配列に変換
    for tehai_one in tehai_str_list_by_2char:#tehai_tmp:
        h = mjc.get_hai_number(tehai_one)
        tehai_num_array[h] += 1

    print("--------麻雀AIの判定結果--------")

    tstr = mjc.get_string_from_tehai(tehai_num_array)
    print("手牌:" + tstr)

    tsumo = mjc.get_hai_number(tsumo_str)
    print("自摸:" + mjc.get_hai_string(tsumo))

    tehai_num_array[tsumo] += 1

    #ツモしたかどうかを判定する関数も用意されているので
    #成績を収集したり個別になんらかの処理をさせることも可能
    #if mjc.is_agari(tehai_num_array):
    #    print("和了")

    #機械学習による判定処理
    ai_outs = sess.run(out, feed_dict={x:[tehai_num_array]})

    dahai = get_ai_dahai(tehai_num_array, ai_outs[0])
    #print("打:" + mjc.get_hai_string(dahai))

    return(mjc.get_hai_string(dahai))

また本記事の主目的からすれば余談ですが、そもそもこのgitレポジトリの本来の目的である機能として、学習して出来上がったモデルをつかって、ランダムな麻雀牌ツモシミュレータをループさせて十分な試行回数でのテンパイ率などがテストできるようにしてくれてあります。

python mahjong_ai.py --run

image.png
[1000回シミュレートした結果]

AIの高機能化を目指すのであれば、学習用の牌譜データをもっとたくさん集めたり、プロやうまい人の牌譜データを学習させたりするなどします。
学習データを変えるとテスト結果も変わりますから面白いですよ。

ここでは機械学習についての記述は割愛しますが、機械学習の基本の部分に簡単に触れるようにしてもらってあるので、是非色々遊んでみてください。そしてよさげなAIができたら私にも譲ってください
ちなみに、現在時点で天鳳の牌譜データを大量に収集するのはちょっと難しいようですね。
データ収集できるいいスクリプトがあればそれもコメントで共有していただけると全人類と主に私がが助かります。

マウスシミュレート

色々試しましたが、下記のチュートリアルが分かりやすかったです。
必要なモジュールをpipしたらコピペで動きます。

PyAutoGuiで繰り返し作業をPythonにやらせよう
https://qiita.com/hirohiro77/items/78e26a59c2e45a0fe4e3

目の前で電卓アプリが立ち上がって勝手にマウスドラッグされます。
初見では「おおっ」と拍手までしてしまった。

これのマウスクリックに必要な部分だけをコピペでもってきて、前述の動作で割り出した座標位置をクリックさせればOK

ここまでの3要素をこねて混ぜれば完成です。
コード記述はもはや省略。需要あればgitに上げます。

動作の様子(動画)

じゃんたまプレイ動画
サムネイルクリックでyoutubeへリンクジャンプします

中々きな臭いプレイングですが、アガリに向かって牌の選択ができているように見えます。

めちゃんこ簡単な牌選択も、人間だとちょっと迷うような牌選択も、どちらも同じ時間で処理できるのは機械の強みですね。

観察すると現在の課題点として以下のようです。

①なんとか持ち時間の20秒を使い切らない程度に18打牌できていますが、状況によっては持ち時間をオーバーします。

この部分についてはリファクタリングの余地がありありですが、別に時間切れで負けても問題ないので、気にしていません。

結局ポンやチーする想定はしていないのだから、対局時は常に「鳴き無し」オプションを選択しておいた方が処理が安定しそうですね。

②学習時のデータに他家の捨て牌やドラの情報を含めていませんから、動画のように他家がリーチしていようと打牌はブレませんし、ドラ牌も気にせず捨てます(笑)

もし機械学習に興味があって、この機能を強化させたいという場合は、次のステップはドラ情報や自風/場風邪の情報を学習させるのがいいと思います。
次元(つまり、データが持つ意味合い)の異なるデータを1つの入力パラメータとして学習させるように機能拡張するのは重要なステップです。(mahjong_common.py側の改修が必要です)

(ただし河情報や他家のリーチの情報を学習に含めると入力パラメータが膨大になってしまって、きちんと工夫しないと安定動作そのものが難しくなると思います)

③ダマテン、聴牌崩し、暗カンには対応していません。

構想そのものから、一直線でツモアガリをひたすら目指すマシーンです。

その後

BANされました(笑)

というのは冗談で、折角なので時間切れを起こさないようにテンプレートマッチングの回数や頻度、処理手順をリファクタリングしてリーグ戦でも使ってみました。

勝ったり負けたり負けたりですが、なんといっても機械は疲れないのでずーーーっとプレイを続けてくれます。(現在進行形、私のパソコンが起動している間に限りますが...)

冒頭で紹介したgitに同梱されている天鳳データを単純に学習させたモデルが、最終的にどのあたりのリーグで安定するのか、その実力を測ってみたいと思います。

後日また追記したいと思いますので、お楽しみに。

◆記事内における雀魂側への権利侵害等の恐れについて

今回は雀魂運営側が意図していないであろうBOTのサービス内利用および記事内で雀魂の動画を掲載しましたが、公式のガイドラインに抵触しない範囲での利用であり、また著作権等を侵害していないとの判断をしております。
ただし本記事を参考にして同様の行為をされる場合は各位の責任において実施下さい。
参考までに下記に抵触する恐れのある雀魂の利用規約等を提示します。

ゲーム実況配信及び動画投稿に関するガイドライン (禁止事項抜粋)

・政治、宗教、特定の信条の宣伝(該当してるw)など、ゲーム実況を見せる以外の目的で利用すること

雀魂利用規約 第11条 禁止事項

(12)当社が本サービスを通じて提供する各種コンテンツを不正な方法で取得する行為、又はこれを助長する行為。(ガチャ報酬をBOT運用で獲得するなど)
(16)本サービスの運用・利用を妨げる行為、又はそのおそれのある行為。
(21)前各号に準じる行為その他当社が不適切と判断する行為。


  1. 後の検証で縦横比が若干変動している可能性があることがわかりましたが、誤差の範囲内かと思われます。私のスクリプトでは縦横のスケールを別で計測して補正することにしました。 

  2. この内容については諸説あるかと思うのですが、あくまで私のベストプラクティスです。テンプレートマッチングという手法を採用してしまうと必ず発生する問題なのですが、何か他に(時間がかからなくて)いい方法知りませんか? 

  3. 後からリファクタリングなんてこれまで参入したプロジェクトでちゃんとできた試しがない。主にリソース不足とかモチベの問題で。。。 

  4. TensorFlow(テンソルフロー)...Googleが開発しオープンソースで公開している、機械学習に用いるためのソフトウェアライブラリ。 

ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
コメント
この記事にコメントはありません。
あなたもコメントしてみませんか :)
すでにアカウントを持っている方は
ユーザーは見つかりませんでした