雀魂の画面から画像認識で対戦情報を持ってくる（Vol. 4）

[≪前の記事](https://qiita.com/xenepic_takku/items/4aa94bece50364002497)　　　　　　　　　　　　　　　　　　　　　　　　　　　　　[次の記事≫](https://qiita.com/xenepic/items/4d083a37d1cb02d07aac)

# 前回までのあらすじ
openCVのパターンマッチングを用いて、ブラウザのスクリーンショットから雀魂本体の画像の切り出し、更には麻雀牌の識別に成功した。

# 今回やること
- 牌表画像の修正
- 赤ドラ牌の識別
- 鳴いた牌の識別

でお送りします。

# 本文
## 牌表画像の修正
前回のプログラムをテストしていると何回かうまく牌を認識できないことがあり、それが9pや9mなど、数牌の9に偏っていたんですよね。
萬子は漢字の認識なので分からなくもないけど、なんで9pが？と悩んでいたのですが、牌表画像を修正することでうまく認識できました。
![paiList.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/381769/76ae3f42-261f-a683-9ba2-6f65222390bf.png)
お分かり頂けただろうか。
9の隣に少し他の画像の切れ端を追加しました。
多分手牌を13個に切り抜く時に出来た僅かなズレが、牌表画像の端っこで悪さしてたんでしょうね。

## 赤ドラ牌の識別
openCVのテンプレートマッチングでは画像をグレースケール化するため、そのままでは赤ドラの判別が難しいです。
ただ、牌の種類が分かってしまえば、それが赤ドラかどうか判別する方法は色々考えられそうです。
![赤ドラ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/381769/a582dd20-28e6-d739-ca9e-101a35eda223.png)


最も直感的なのは、牌の中に含まれる赤色画素の数ですね。
> 【Python/OpenCV】赤・緑・青色の検出（HSV色空間）
https://algorithm.joho.info/programming/python/opencv-color-detection/#toc2

こちらの記事を参考に、牌画像に含まれる赤色のみを抽出して、その画素数をカウントしました。


```py
# 赤色画素の個数をカウントする
def count_red_pixel(img):
    # HSV色空間に変換
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)

    # 赤色のHSVの値域1
    hsv_min = np.array([0,64,0])
    hsv_max = np.array([30,255,255])
    mask1 = cv2.inRange(hsv, hsv_min, hsv_max)

    # 赤色のHSVの値域2
    hsv_min = np.array([150,64,0])
    hsv_max = np.array([179,255,255])
    mask2 = cv2.inRange(hsv, hsv_min, hsv_max)

    # 赤色領域のマスク（255：赤色、0：赤色以外）    
    mask = mask1 + mask2

    return np.count_nonzero(mask == 255)
```

||5萬  |5筒  |5索  |
|---|---|---|---|
|赤ドラ|2371  |3054  |2935  |
|ノーマル|1529  |717  |881  |

萬子は「萬」の字が赤く大きいため、他より赤い画素が多いですね。
それでも2000ぐらいを閾値にすれば判別できそうです。

```py
paiList = (
            ('m1','m2','m3','m4','m5','m6','m7','m8','m9','m5a'),
            ('p1','p2','p3','p4','p5','p6','p7','p8','p9','p5a'),
            ('s1','s2','s3','s4','s5','s6','s7','s8','s9','s5a'),
            ('j1','j2','j3','j4','j5','j6','j7'),
        )
if paiNum == 4 and count_red_pixel(paiImage)>2000:
    paiNum = 9
return paiList[paiKind][paiNum]
```
こんな感じでなんとかなりました。

## 鳴いた牌の識別
### コレが予想以上に曲者です。
鳴き牌識別の難点は次の通り
1. 牌が傾いている
1. 縦向きの牌と横向きの牌が混在している
1. 暗槓を含めると、横向きの牌の個数も一定ではない

ので、通常の手牌のように切り抜いて等分して識別、といった手法は取れません。
![naki01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/381769/b45ece3b-92d5-4ba5-77d1-30a1d48a217d.png)

なので手順としては
1. 牌の傾きを修正する
1. 右から順に縦横の向きを判定しながら識別していく

といったものが考えられます。

### 牌の傾きを修正する
この傾きを修正するためには、射影変換、いわゆる台形補正を施します。
すなわち、
![naki04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/381769/93c99b69-e203-a6a0-e2f7-c52eecd26f44.png)
上の画像を
![naki03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/381769/35976c1d-60f8-16cb-0de2-59ad533ca88f.png)
下のように変形することで、牌の傾きを補正します。

こういった変換については、こちらのサイトにめちゃくちゃわかりやすく載っているので、詳細を知りたい方はぜひ。
> Python, OpenCVで幾何変換（アフィン変換・射影変換など）
https://note.nkmk.me/python-opencv-warp-affine-perspective/

簡単に説明すると、射影変換では画像を表す配列に3×3の変換行列を掛けることで、四角形を任意の四角形へと変形できます。
なので、上のようないい感じに変形ができる変換行列をまず見つける必要がありますが、openCV先生はとても優秀なので、変換行列を一瞬で生成してくれる関数を用意してくれています。

```py
# 雀魂メイン画面の画像を読み込み
jantamaMainImage = cv2.imread('./jantamaMainImage.png')

# 泣き牌の部分だけ切り抜き
nakihai_img = jantamaMainImage[845:983, 550:1824]
h, w = nakihai_img.shape[:2]

# 移動点を指定して射影変換用の変換行列を作成
src_pts = np.array([[0, 0], [0, h], [w, h], [w, 0]], dtype=np.float32)
dst_pts = np.array([[-22, -30], [0, h], [w, h], [w+45, -30]], dtype=np.float32)

mat = cv2.getPerspectiveTransform(src_pts, dst_pts)
```
`src_pts`と`dst_pts`は、それぞれ変形前と変形後の四角形の頂点の座標です。
cv2.getPerspectiveTransform関数で合計8つの座標から変換行列を算出します。

```py
# 射影変換で鳴き牌画像を台形補正
nakihai_img = cv2.warpPerspective(nakihai_img, mat, (w, h))
```
cv2.warpPerspective関数と先ほど作った変換行列matを用いて、射影変換します。
実際にやってみましょう。

【変換前】
![nakihai_img_before.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/381769/e402c8df-8afe-ddd6-e281-e11c84906d8a.png)

【変換後】
![nakihai_img_after.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/381769/80a3c6be-37b6-4b5c-b6bc-d63aec40b79a.png)


先程まで斜めっていた牌がまっすぐになりました！
傾いて見えるような気もしますが、おそらく錯視です。
これで後は順番に切り抜いていくだけですね。

### 牌を切り出す

麻雀で鳴いた牌は、鳴いた相手が分かるように一つだけ牌を横向きにします。
上家からなら左、対面なら真ん中、下家なら右の牌を横向きします。
大明槓の場合も同じルールです。
暗槓の場合は、4つのうち外側の牌を裏側にして全て縦に置きます。
加槓の場合は横向きにした牌の上にツモった牌を横にして重ねます。

何が言いたいか？

鳴き牌は縦横バラバラで、しかもいくつ来るかも分からんってことです！

なので、

右から順番に縦向き横向き両方抜き出し、パターンマッチングの精度が高い方を採用することにします。

```py:recogNakihaiImage
# 鳴き牌の種類を識別する関数
def recogNakihaiImage(img, paiListImage, direction, accLimit = 0.7, showMatchingImage = False):
    # 横縦それぞれの場合の牌画像切り出しライン移動ピクセル数
    global dif_hor
    global dif_ver

    # 横向き牌の場合は縦向きになるよう回転
    if direction == 'horizon' or direction == 'upper_horizon':
        img = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
    # パターンマッチングと合うようにリサイズ
    img = cv2.resize(img, dsize = (66, 99))
    # パターンマッチングで牌の種類を識別
    paiInfo = recogPaiImage(img, paiListImage, accLimit, showMatchingImage)
    # 向き情報を追加
    paiInfo.append(direction)
    # カットラインの移動ピクセル数情報を追加
    if direction == 'horizon':
        paiInfo.append(dif_hor)
    elif direction == 'upper_horizon':
        paiInfo.append(0)
    else:
        paiInfo.append(dif_ver)

    return paiInfo
```

```py
# 雀魂メイン画面の画像を読み込み
jantamaMainImage = cv2.imread('./jantamaMainImage.png')

# 泣き牌の部分だけ切り抜き
nakihai_img = jantamaMainImage[845:983, 550:1824]
cv2.imwrite('nakihai_img_before.png', nakihai_img)
h, w = nakihai_img.shape[:2]

# 移動点を指定して射影変換用の変換行列を作成
src_pts = np.array([[0, 0], [0, h], [w, h], [w, 0]], dtype=np.float32)
dst_pts = np.array([[-22, -30], [0, h], [w, h], [w+45, -30]], dtype=np.float32)

mat = cv2.getPerspectiveTransform(src_pts, dst_pts)

# 射影変換で鳴き牌画像を台形補正
nakihai_img = cv2.warpPerspective(nakihai_img, mat, (w, h))

paiListImage = cv2.imread('./paiList.png')
paiList = []

nakihai_count = 16

# 縦横そろぞれの向きの牌を認識した時、ベースラインを何ピクセル移動させるか
dif_hor = 102
dif_ver = 71

# 画像切り出しのベースライン（右端の座標）
cut_line = w

for i in range(nakihai_count):
    # 横向きの牌として識別
    estimated_hor = recogNakihaiImage(nakihai_img[76:134,cut_line-87:cut_line], paiListImage, 'horizon', 0.7)

    # 縦向きの牌として識別
    estimated_ver = recogNakihaiImage(nakihai_img[51:134,cut_line-58:cut_line], paiListImage, 'vertical', 0.7)

    # 精度の高い方を採用
    def key_func(n):
        return n[1]
    pai, acc, direction, diff = max(estimated_hor, estimated_ver, key=key_func)
    paiList.append((pai, acc, direction))

    
    # 牌の向きが横向きだった場合は、加槓牌があるか調べる
    if direction == 'horizon':
        pai, acc, direction = recogNakihaiImage(nakihai_img[7:65,cut_line-87:cut_line], paiListImage, 'upper_horizon', 0.7)[:3]
        if pai != 'unknown':
            paiList.append((pai, acc, direction))
    
    # 切り出しのベースラインを移動
    cut_line -= diff

    if cut_line<dif_hor:
        break
    
for paiListIt in paiList:
    print(f'牌:{paiListIt[0]}, 精度:{paiListIt[1]}, 向き:{paiListIt[2]}')
```

recogPaiImageは、テンプレートマッチングで第一引数で与えられた画像がなんの牌か識別する自作関数で、識別結果と精度をリストで返します。
![jantamaMainImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/381769/25c56de4-fffd-4beb-8bd6-e3dcba46d9c6.png)


上記の画像で動かしてみた結果がこちら。

```
牌:p6, 精度:0.7281540036201477, 向き:horizon
牌:p6, 精度:0.7337332367897034, 向き:vertical
牌:p6, 精度:0.7332924604415894, 向き:vertical
牌:j6, 精度:0.8377761840820312, 向き:vertical
牌:j6, 精度:0.8391247391700745, 向き:vertical
牌:j6, 精度:0.8753969073295593, 向き:horizon
牌:j6, 精度:0.838071346282959, 向き:vertical
牌:j1, 精度:0.8953396081924438, 向き:vertical
牌:j1, 精度:0.8951357007026672, 向き:vertical
牌:j1, 精度:0.9010147452354431, 向き:horizon
牌:j1, 精度:0.8973944783210754, 向き:vertical
牌:p3, 精度:0.7820843458175659, 向き:vertical
牌:p3, 精度:0.7751430869102478, 向き:vertical
牌:p3, 精度:0.7893229722976685, 向き:horizon
牌:p3, 精度:0.7806193232536316, 向き:vertical
```

ちゃんと認識できてますね！
加槓がある場合も試してみます。
![jantamaMainImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/381769/7e53689d-8fc5-ffbc-553b-74c916f04caf.png)

```
牌:j3, 精度:0.8688743710517883, 向き:vertical
牌:j3, 精度:0.8498674631118774, 向き:horizon
牌:j3, 精度:0.8180862069129944, 向き:upper_horizon
牌:j3, 精度:0.8721718192100525, 向き:vertical
牌:s4, 精度:0.7711134552955627, 向き:vertical
牌:s4, 精度:0.7525062561035156, 向き:horizon
牌:s4, 精度:0.7763944268226624, 向き:vertical
牌:unknown, 精度:0.6982628703117371, 向き:vertical
牌:unknown, 精度:0.6943157315254211, 向き:vertical
牌:unknown, 精度:0.7326670289039612, 向き:vertical
牌:unknown, 精度:0.7255188822746277, 向き:vertical
牌:unknown, 精度:0.6672534346580505, 向き:vertical
牌:unknown, 精度:0.6124575138092041, 向き:vertical
牌:unknown, 精度:0.7977069020271301, 向き:vertical
牌:unknown, 精度:0.37311428785324097, 向き:vertical
牌:unknown, 精度:0.5133786201477051, 向き:vertical
牌:unknown, 精度:0.4513011872768402, 向き:horizon
```

良いですね！
加槓された西もきちんと認識できています。

暗槓はどうなるでしょうか。
![jantamaMainImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/381769/6d5889e0-956c-ae7a-7cb7-da747ba2c780.png)
```
牌:unknown, 精度:0.5329564809799194, 向き:vertical
牌:unknown, 精度:0.5143698453903198, 向き:vertical
牌:unknown, 精度:0.37484490871429443, 向き:vertical
牌:unknown, 精度:0.5397562384605408, 向き:horizon
牌:unknown, 精度:0.5357805490493774, 向き:vertical
牌:unknown, 精度:0.6342043280601501, 向き:vertical
牌:unknown, 精度:0.6158527135848999, 向き:vertical
牌:m2, 精度:0.7219629287719727, 向き:vertical
牌:m2, 精度:0.764488935470581, 向き:vertical
牌:unknown, 精度:0.4744590222835541, 向き:vertical
牌:unknown, 精度:0.5225497484207153, 向き:vertical
牌:unknown, 精度:0.7010692358016968, 向き:vertical
牌:unknown, 精度:0.6415959000587463, 向き:vertical
牌:unknown, 精度:0.7149266600608826, 向き:vertical
牌:unknown, 精度:0.959796130657196, 向き:horizon
牌:unknown, 精度:0.6409226059913635, 向き:horizon
```
これはひどい。

おそらく9sがドラでピッカピカに光っており、それで精度が著しく下がったのでしょうね。
更に鳴き牌認識の場合は最初の牌の認識に失敗すると、以降の切り出し位置がズレるため、全ての牌の認識がバラバラになります。

9sが光ってないタイミングで再度実行してみました。

![jantamaMainImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/381769/a3f2b2b4-6ed3-1610-dff8-a9ea9d838e74.png)
```
牌:s9, 精度:0.7681387066841125, 向き:vertical
牌:s9, 精度:0.7239039540290833, 向き:horizon
牌:s9, 精度:0.780181884765625, 向き:vertical
牌:s4, 精度:0.7484797239303589, 向き:horizon
牌:s4, 精度:0.7791476845741272, 向き:vertical
牌:s4, 精度:0.7862366437911987, 向き:vertical
牌:unknown, 精度:0.9810858368873596, 向き:vertical
牌:m1, 精度:0.8622937202453613, 向き:vertical
牌:m1, 精度:0.8615747690200806, 向き:vertical
牌:unknown, 精度:0.9806995391845703, 向き:vertical
牌:unknown, 精度:0.6367638111114502, 向き:vertical
牌:unknown, 精度:0.6124575138092041, 向き:vertical
牌:unknown, 精度:0.657243013381958, 向き:vertical
牌:unknown, 精度:0.6905979514122009, 向き:vertical
牌:unknown, 精度:0.9133580923080444, 向き:vertical
牌:unknown, 精度:0.6409226059913635, 向き:horizon
```

今度はうまく認識できました。
裏側牌を「裏側牌」として牌表画像に追加しても良いのですが、白との混同が起こりそうなので一旦見送っています。

ドラが光るのは仕方ないので、その当たりはjavascriptの方のプログラムでうまく処理したいですね。


# まとめ
難所である鳴き牌の識別でしたが、不安定ながらなんとかクリアできました！
いやーよかったよかった。
ピクセルを数えたり変形行列を微調整したりと、細かい作業ばかりでだいぶ疲れました。

次回は
- 鳴いた時のツモ牌の位置の修正
- 捨て牌（河）の識別
- 相手の牌の識別

あたりに手を付けたいと思っています。
あとソースの量も増えてきたので、ここらで一回整理もしておきたいんですよね。
それでは！


## 追記
ソースは完成したらGitで公開しようと思ってますが、未完成の段階でも公開した方が良い？