テンプレートマッチング・CNNで麻雀対局中継の手牌を検出しました。

# 1. はじめに

画像処理とテンプレートマッチングまたはCNNを用いて、麻雀対局中継の手牌画面から牌姿を検出しました。
門前手(手牌13枚or14枚)、上下逆さの牌なし、手・影の障害がない画像を対象としています。
![qiita1_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/e5f378f5-e069-9af5-3a46-87007c8717fc.png)
手牌画像は[Mリーグ2019](https://abema.tv/video/title/444-4)の対局中継のスクリーンショット画像を用いました。

書籍[ゼロから作るDeep Learning](https://www.oreilly.co.jp/books/9784873117584/)の復習と、機械学習の開発過程を一通り経験したいというのが大きな目的です。
開発過程を順々に書いていったので冗長な記事になってしまいました。


# 2. なぜ対局中継の手牌なのか？
・麻雀牌37種類(赤牌含む)は分類問題として手頃そう
・正面から撮影した手牌検出は前例がある
　　[拡張現実とスマートフォンを用いた麻雀初心者支援システムの開発, 情報処理学会(2012)](https://ipsj.ixsq.nii.ac.jp/ej/?action=pages_view_main&active_action=repository_view_main_item_detail&item_id=110509&item_no=1&page_id=13&block_id=8)
　　[「麻雀カメラ～スマホをかざすだけで点数計算～」 | ノースショア株式会社](https://www.north-s.co.jp/works/%E3%80%8C%E9%BA%BB%E9%9B%80%E3%82%AB%E3%83%A1%E3%83%A9%EF%BD%9E%E3%82%B9%E3%83%9E%E3%83%9B%E3%82%92%E3%81%8B%E3%81%96%E3%81%99%E3%81%A0%E3%81%91%E3%81%A7%E7%82%B9%E6%95%B0%E8%A8%88%E7%AE%97%EF%BD%9E)
・ネット配信の映像から開発データを入手しやすい
・撮影角度や照明などの条件が固定されているため、それほど教師データを大量に集めなくてよい(はず)

いずれは将棋のように、[麻雀AI](https://news.microsoft.com/ja-jp/2019/08/29/190829-mahjong-ai-microsoft-suphx/)などと連携して打牌候補や局収支期待値を中継映像に表示したり、牌譜を自動記録できるようになると面白いと思います。


# 3. テンプレートマッチングによる牌検出
麻雀牌の図柄そのものは変形しないため、[テンプレートマッチング](http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_template_matching/py_template_matching.html)が有力そうです。
最低限テンプレート画像は各牌1枚ずつ用意すれば良いので手軽に試せます。

画面全体を走査し、テンプレート画像との類似度が閾値を超えている領域を算出します。
類似度には明るさの変動に強いとされている零平均正規化相互相関([cv2.TM_CCOEF_NORMED](http://opencv.jp/opencv-2svn/cpp/imgproc_object_detection.html))を指定しています。
ここでは省略していますが、重複している領域はNon Maximum Suppressionで削除しています。

```.py
import numpy as np
import cv2

def template_matching(img, template, threshold=0.80):
    # 類似度
    res = cv2.matchTemplate(img, template, cv2.TM_CCOEFF_NORMED)
    # 類似度が閾値を超えた領域の左上角の座標
    loc = np.where(res >= threshold)
    return loc 
```
![TM_2m.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/1e8e2d89-8a69-e455-1fee-f999af454c7d.png)
牌ごとに1枚ずつテンプレート画像を用意し、遠近感を考慮して拡大・縮小を加えた上で手牌を検出してみましたが、上手く行きませんでした。主な問題点を下記に挙げます。

**問題点1. 萬子の識別精度が低い**
![TM - コピー (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/c7a70485-3511-a6a9-d214-abff6086768b.png)
例1)は極端な例ですが、全体的に萬子の識別ミスが多いです。
萬子かそうでないかの区別は容易ですが、どの萬子かを区別するには類似度の閾値を細かく調整しなくてはなりません。
また、「萬」の部分がテンプレート画像とよく一致していると、漢数字部分があまり一致していなくても類似度が高く計算され、識別ミスの原因となることも考えられます。
今回は試していませんが、「萬子であると検出」→「漢数字部分のみを見て分類」とすると精度が向上しそうです。

**問題点2. 右側の牌を検出できない**
例1)では手牌右端の六筒六索を検出できていません。
そこで、六索の閾値を下げていくと、六索を検出するよりも先に左の方にある七索を六索であると誤検出してしまいました。

改めて手牌画像をよく観察してみると、手前にある牌と奥にある牌では、見た目のサイズが異なるだけでなく、角度も異なっていることがわかります。
![persepect.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/215173ad-6d36-066d-9c23-9fdb53d0c056.png)
遠近感が三次元的(三点透視図法的)に効いているため、テンプレートを拡大・縮小するだけでは十分でなかったようです。
三次元的な変形をアフィン変換で近似するか、手牌位置(例：左側・中・右側)に応じてテンプレートを用意するといった対策が考えられます。
しかし、テンプレートの枚数が増えると、その分だけ調整する閾値が増える問題があります。

**問題点3. 特定の牌の並びによる誤検出**
![TM - コピー (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/c09337bb-ab73-6c4a-3eb5-c17d2c27eb22.png)
例2)では、五索×2の並びを四索と誤検出しています。同様のケースは六索×2などでも見られます。
また、白が複数並んでいると、白の枚数・位置を正しく検出できないことがあります。
牌と牌の切れ目は図柄の面積よりも小さく、類似度に与える影響が小さいため、検出の手がかりになりにくいです。
対策として、手牌の左側から順に牌を確定していく、あるいは、事前に画像処理などで各牌の位置を推定する(後述)ことが考えられます。

**問題点4. 赤牌と黒牌を区別できない**
cv2.matchTemplateは画像の色調を考慮していません(グレースケール変換した画像でも同じ結果となる)。
赤牌と黒牌は図柄の形状は同じなので、数牌の五と検出した後、RGB輝度値について下記を満たす画素数が一定以上ある場合を赤牌と識別するようにしました。

$$ R−(G+B)/2>Threshold $$

### まとめ
斜めから撮影した手牌に対して単純に画面全体をテンプレートマッチングで走査するだけでは上手くいかないことがわかりました。
工夫の余地は色々ありますが、閾値の数が多く調整の手間が大きなネックとなります(上下逆さの牌も含めるとさらに増える)。

次項では、画像処理によって各牌の位置を推定した後、テンプレートマッチングを行う方法について述べます。


# 4. 手牌の枚数・位置を推定 → テンプレートマッチング
「各牌の位置(矩形範囲)を推定」 → 「分類」という2段階のプロセスを踏むことで多くのメリットが得られます。
・牌の切れ目を無視した誤検出を防げる
・走査する範囲が絞られるため、計算量が抑えられる
・各牌に対して類似度が最大となるテンプレートを分類結果とすることで、類似度の閾値の調整が不要となる

![temp_match.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/5ee22057-cd9c-b30d-9d9c-0ef9263c1953.png)
また、切り出した各矩形範囲に対して、そのままCNNの画像分類が適用できます。
(R-CNNやYOLOなども試してみたいがアノテーションを作るのが大変）

### 牌領域の抽出
手順を簡単に示します。
手牌の映らない範囲を固定マスクで除外する。
![0_1_masked.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/0cacb015-b778-704f-e90e-5bd526307fc4.png)
RGB輝度値の閾値処理で範囲を絞る。
(自動卓の緑、肌色、牌の背中の色を除外)
![0_2_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/d055bd03-6bc9-9366-c160-53dd35b11c37.png)
[モルフォロジー変換](http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_morphological_ops/py_morphological_ops.html)で微小領域除去、穴埋め。
![0_2_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/417ee957-296e-cb04-d50b-a4fda29259d8.png)
[面積の小さい領域を除去](https://axa.biopapyrus.jp/ia/opencv/remove-objects.html)。
![0_2_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/8fbcfcc1-ad64-b6f8-fe86-b40288fa6aae.png)

### 抽出領域から手牌枚数・各牌の位置座標を推定
抽出した領域の下図の角部分の座標を用いて推定します。
![linear_deg.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/23be18ce-6a0f-ee2f-34fd-58d20b9667c2.png)
まず、手牌の枚数Nと左端の牌の幅W0(x方向成分)を線形回帰で求めます。
無作為に選んだ20枚の手牌画像データから最適化をしました。

```math
N=ax_L+by_L+cx_R+dy_R+e \\
w_0=ax_L+by_L+cx_R+dy_R+e
```
次に、各牌の幅を計算します。
右にいくほど牌の幅は等差数列的に小さくなると仮定すると、下記式から公差は一意に定まります。

$$ x_R−x_L=\sum_{k=0}^{N−1}(w_0+ak) $$
各牌の幅を左から足していけばx座標が求まり、牌領域からy座標も決まります。
![0_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/319245bc-7161-c0d5-75e0-2185a30ea378.png)

### テンプレートマッチング
各牌について切り出した矩形領域にテンプレートマッチングを行います。
類似度が最大となったテンプレートの牌を識別結果とします。
テンプレートは各牌ごとに3枚(手牌位置で左、中、右)用意しました。

### 検出結果
34枚の手牌画像(471牌)について、手牌検出を行いました。
手牌枚数の推定は全て正解し、牌位置も大きく外れることなく推定できました。
![2_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/3f7d239e-f9c3-74f4-5c05-763c14b8e3a6.png)
分類を間違えた牌は22/471牌、Accuracy(正解率)は95.3%となりました。

手牌の位置毎(左端が0)の誤答数は下記となります。
0: 0/34
1: 0/34
2: 1/34
3: 0/34
4: 0/34
5: 0/34
6: 2/34
7: 0/34
8: 2/34
9: 3/34
10: 1/34
11: 1/34
12: 2/34
13: 11/29
一番右の牌の誤答数が際立って多いです。

自身より右側に牌が無い点が他の位置の牌と大きく異るため、「右端専用のテンプレート画像を作る」「牌領域以外はマスクする」と精度が上がりそうです。
![307 - コピー - コピー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/2b19f491-a9c2-9692-66d8-f0852efe9122.png)
また、遠近感で小さく映ることで図柄が潰れていることが識別ミスの原因になっていそうです。
データ量削減のために画像を0.25倍に縮小したことが裏目に出たかもしれません。
![3_0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/35cfe284-056e-0c05-f2a2-489cbeda9893.png) 実際の検出対象画像サイズ


牌毎の予測数の表(混同行列)を以下に示します。
表の見方は、二段目を例に取ると、正解が二萬の牌が13枚あり、一萬と分類したものが1枚、二萬と分類したものが12枚であることを示しています。
![table.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/e21165f3-c2f8-0688-c47c-68ce9138bd41.png)
右端の牌を除いた誤答は下記の通りです。
　四萬を三萬(2回)、六筒を八筒(2回)、八筒を六筒(1回)、六索を九筒(3回)、六索を九索(1回)
人間が似ていると感じる牌同士はテンプレートマッチングでも間違いやすいようです。
六索については、図柄が直線の模様で構成されているため、テンプレートの角度/回転が少しでもずれると類似度が低くなることが影響していると思われます。

### まとめ
各牌の位置を推定した後、テンプレートマッチングを分類に用いることで、類似度の閾値調整を行うことなく手牌検出を行うことができました。

精度向上の改善案
・テンプレートの枚数を増やす、拡大・縮小・回転をより細かく行う
・萬子は漢数字部分のみで分類する
・牌以外の領域はマスクする

# 5. CNNによる牌分類
前述の手牌位置の推定結果から画像を切り出して、CNNで分類します。
分類モデルは[ゼロから作るDeep Learning](https://www.oreilly.co.jp/books/9784873117584/)の[ソースコード](https://github.com/oreilly-japan/deep-learning-from-scratch)を改良して実装しました。

### 開発データの作成・Data Augmentation
牌ごとに訓練データ30枚程度、テストデータ10枚程度の画像を手牌画像から切り出しました。
さらに[Data Augmentation](https://keras.io/ja/preprocessing/image/)で各牌200枚(37*200=7,400枚)に増やしました。

```.py
import numpy as np
from keras.preprocessing.image import ImageDataGenerator

def save_da_images(images, batch_size=10, save_to_dir=None):
    """
    :param images: Numpy array of rank 4 (image_num, height, width, channel)
    :param batch_size: int
    :param save_to_dir: str.
    :return: None
    """
    datagen = ImageDataGenerator(
        rotation_range=1,
        width_shift_range=0.10,
        height_shift_range=0.05,
        shear_range=1, zoom_range=0.1)

    gen = datagen.flow(images, batch_size=batch_size, save_to_dir=save_to_dir, save_prefix='da')

    if save_to_dir:
        for i in range(batch_size):
            gen_img = next(gen)
```

### 学習結果
**モデル1. conv - relu - pool - affine - relu - affine - softmax**
ゼロから作るDeep LearningのソースコードでMNIS手書き数字に対してtest accuracy:0.9888の結果が得られた構成を元しています。
入力画像のサイズを(28, 28)から(36, 24)、チャンネル数を1から3、クラス数を10から37に変更しました。
最適化アルゴリズムは確率的勾配降下法(Adam)を使いました。

<details><summary>ソースコード</summary><div>

```.py
import pickle
import numpy as np
from collections import OrderedDict
from common.layers import *
from common.functions import softmax
from common.trainer import Trainer

class SimpleConvNet:
    """
    conv - relu - pool - affine - relu - affine - softmax
      conv
       padding = 0
       filter.shape: (30, 3, 5, 5)# (filter_num, channel, h, w)
       stride = 1
      pool
       max pooling
       kernel: (2, 2)
       stride = 2
      affine
       hidden_size = 50

    Parameters
    ----------
    input_size : 入力サイズ（MNISTの場合は1channel（グレスケ）×28pixel×28pixel）
    hidden_size_list : 隠れ層のニューロンの数
    output_size : 出力サイズ（MNISTの場合は10）
    activation : 'relu' or 'sigmoid'
    weight_init_std : 重みの標準偏差を指定（e.g. 0.01）
        'relu'または'he'を指定した場合は「Heの初期値」を設定
        'sigmoid'または'xavier'を指定した場合は「Xavierの初期値」を設定
    """
    def __init__(self, input_dim=(3, 36, 24),
                 conv_param={'filter_num':30, 'filter_size':5, 'pad':0, 'stride':1},
                 hidden_size=50, output_size=37, weight_init_std=0.01):
        filter_num = conv_param['filter_num']
        filter_size = conv_param['filter_size']
        filter_pad = conv_param['pad']
        filter_stride = conv_param['stride']
        input_h = input_dim[1]
        input_w = input_dim[2]
        conv_output_h = (input_h - filter_size + 2*filter_pad) / filter_stride + 1
        conv_output_w = (input_w - filter_size + 2*filter_pad) / filter_stride + 1
        # (2, 2)のmax pooling後の画素数の合計値(affineレイヤーの重み行列のサイズ用)
        pool_output_size = int(filter_num * (conv_output_h/2) * (conv_output_w/2))

        # 重みの初期化
        self.params = {}
        self.params['W1'] = weight_init_std * \
                            np.random.randn(filter_num, input_dim[0], filter_size, filter_size)
        self.params['b1'] = np.zeros(filter_num)
        self.params['W2'] = weight_init_std * \
                            np.random.randn(pool_output_size, hidden_size)
        self.params['b2'] = np.zeros(hidden_size)
        self.params['W3'] = weight_init_std * \
                            np.random.randn(hidden_size, output_size)
        self.params['b3'] = np.zeros(output_size)

        # レイヤの生成
        self.layers = OrderedDict()
        self.layers['Conv1'] = Convolution(self.params['W1'], self.params['b1'],
                                           conv_param['stride'], conv_param['pad'])
        self.layers['Relu1'] = Relu()
        self.layers['Pool1'] = Pooling(pool_h=2, pool_w=2, stride=2)
        self.layers['Affine1'] = Affine(self.params['W2'], self.params['b2'])
        self.layers['Relu2'] = Relu()
        self.layers['Affine2'] = Affine(self.params['W3'], self.params['b3'])
        
        self.last_layer = SoftmaxWithLoss()

    def predict(self, x):
        for layer in self.layers.values():
            x = layer.forward(x)

        return x

    def loss(self, x, t):
        y = self.predict(x)
        return self.last_layer.forward(y, t)

    def accuracy(self, x, t, batch_size=1):
        if t.ndim != 1 : t = np.argmax(t, axis=1)

        acc = 0.0

        for i in range(int(x.shape[0] / batch_size)):
            tx = x[i*batch_size:(i+1)*batch_size]
            tt = t[i*batch_size:(i+1)*batch_size]
            y = self.predict(tx)
            y = np.argmax(y, axis=1)
            acc += np.sum(y == tt)

        return acc / x.shape[0]

    def gradient(self, x, t):
        # forward
        self.loss(x, t)

        # backward
        dout = 1
        dout = self.last_layer.backward(dout)

        layers = list(self.layers.values())
        layers.reverse()
        for layer in layers:
            dout = layer.backward(dout)

        # 設定
        grads = {}
        grads['W1'], grads['b1'] = self.layers['Conv1'].dW, self.layers['Conv1'].db
        grads['W2'], grads['b2'] = self.layers['Affine1'].dW, self.layers['Affine1'].db
        grads['W3'], grads['b3'] = self.layers['Affine2'].dW, self.layers['Affine2'].db

        return grads

    def save_params(self, file_name="params.pkl"):
        params = {}
        for key, val in self.params.items():
            params[key] = val
        with open(file_name, 'wb') as f:
            pickle.dump(params, f)

    def load_params(self, file_name="params.pkl"):
        with open(file_name, 'rb') as f:
            params = pickle.load(f)
        for key, val in params.items():
            self.params[key] = val

        for i, key in enumerate(['Conv1', 'Affine1', 'Affine2']):
            self.layers[key].W = self.params['W' + str(i+1)]
            self.layers[key].b = self.params['b' + str(i+1)]

network = SimpleConvNet(input_dim=(3, 36, 24), 
                        conv_param = {'filter_num': 30, 'filter_size': 5, 'pad': 0, 'stride': 1},
                        hidden_size=50, output_size=37, weight_init_std=0.01)

max_epochs = 30

trainer = Trainer(network, x_train, t_train, x_test, t_test,
                  epochs=max_epochs, mini_batch_size=100,
                  optimizer='Adam', optimizer_param={'lr': 0.001},
                  evaluate_sample_num_per_epoch=1000)
```
</div></details>

結果
epoch20を超えたあたりから学習が進まなくなりました。
Data Augmentationによって回転、変形等している分、訓練データの方が分類が難しく、Accuracy(分類が正解した数/全数)が低いです。
モデルの表現力が低いと思われます。
![train_log.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/b2d5d0fe-1de7-cab1-24c8-bec6182fe9ad.png)

**モデル2. conv - relu - pool - conv - relu - pool - affine - relu - affine - softmax**
変更点
・畳み込み層を1層から2層に変更(ゼロパディングも追加)
・隠れ層のニューロン数を50から100に変更

<details><summary>ソースコード</summary><div>

```.py
import pickle
import numpy as np
from collections import OrderedDict
from common.layers import *
from common.functions import softmax
from common.trainer import Trainer

class SimpleConvNet2:
    """
    conv - relu - pool - conv - relu - pool - affine - relu - affine - softmax
      conv
       padding = 2
       filter.shape: (30, 3, 5, 5)# (filter_num, channel, h, w)
       stride = 1
      pool
       max pooling
       kernel: (2, 2)
       stride = 2
      affine
       hidden_size = 100

    Parameters
    ----------
    input_size : 入力サイズ（MNISTの場合は1channel（グレスケ）×２８pixel×28pixel）
    hidden_size_list : 隠れ層のニューロンの数のリスト（e.g. [100, 100, 100]）
    output_size : 出力サイズ（MNISTの場合は10）
    activation : 'relu' or 'sigmoid'
    weight_init_std : 重みの標準偏差を指定（e.g. 0.01）
        'relu'または'he'を指定した場合は「Heの初期値」を設定
        'sigmoid'または'xavier'を指定した場合は「Xavierの初期値」を設定
    """
    def __init__(self, input_dim=(3, 36, 24),
                 conv_param={'filter_num':30, 'filter_size':5, 'pad':2, 'stride':1},
                 hidden_size=100, output_size=37, weight_init_std=0.01):
        filter_num = conv_param['filter_num']
        filter_size = conv_param['filter_size']
        filter_pad = conv_param['pad']
        filter_stride = conv_param['stride']
        input_h = input_dim[1]
        input_w = input_dim[2]
        conv1_output_h = (input_h - filter_size + 2*filter_pad) / filter_stride + 1
        conv1_output_w = (input_w - filter_size + 2*filter_pad) / filter_stride + 1
        pool1_output_h = int(conv1_output_h/2)
        pool1_output_w = int(conv1_output_w/2)
        conv2_output_h = (pool1_output_h - filter_size + 2*filter_pad) / filter_stride + 1
        conv2_output_w = (pool1_output_w - filter_size + 2*filter_pad) / filter_stride + 1
        # (2, 2)のmax pooling後の画素数の合計値(affineレイヤーの重み行列のサイズ用)
        pool2_output_size = int(filter_num * (conv2_output_h/2) * (conv2_output_w/2))
        input_size = input_dim[1]

        # 重みの初期化
        self.params = {}
        self.params['W1'] = weight_init_std * \
                            np.random.randn(filter_num, input_dim[0], filter_size, filter_size)
        self.params['b1'] = np.zeros(filter_num)
        self.params['W2'] = weight_init_std * \
                            np.random.randn(filter_num, filter_num, filter_size, filter_size)
        self.params['b2'] = np.zeros(filter_num)
        self.params['W3'] = weight_init_std * \
                            np.random.randn(pool2_output_size, hidden_size)
        self.params['b3'] = np.zeros(hidden_size)
        self.params['W4'] = weight_init_std * \
                            np.random.randn(hidden_size, output_size)
        self.params['b4'] = np.zeros(output_size)

        # レイヤの生成
        self.layers = OrderedDict()
        self.layers['Conv1'] = Convolution(self.params['W1'], self.params['b1'],
                                           conv_param['stride'], conv_param['pad'])
        self.layers['Relu1'] = Relu()
        self.layers['Pool1'] = Pooling(pool_h=2, pool_w=2, stride=2)
        self.layers['Conv2'] = Convolution(self.params['W2'], self.params['b2'],
                                           conv_param['stride'], conv_param['pad'])
        self.layers['Relu2'] = Relu()
        self.layers['Pool2'] = Pooling(pool_h=2, pool_w=2, stride=2)
        self.layers['Affine1'] = Affine(self.params['W3'], self.params['b3'])
        self.layers['Relu2'] = Relu()
        self.layers['Affine2'] = Affine(self.params['W4'], self.params['b4'])

        self.last_layer = SoftmaxWithLoss()

    def predict(self, x):
        for layer in self.layers.values():
            x = layer.forward(x)

        return x

    def loss(self, x, t):
        """損失関数を求める
        引数のxは入力データ、tは教師ラベル
        """
        y = self.predict(x)
        return self.last_layer.forward(y, t)

    def accuracy(self, x, t, batch_size=1):
        if t.ndim != 1 : t = np.argmax(t, axis=1)

        acc = 0.0

        for i in range(int(x.shape[0] / batch_size)):
            tx = x[i*batch_size:(i+1)*batch_size]
            tt = t[i*batch_size:(i+1)*batch_size]
            y = self.predict(tx)
            y = np.argmax(y, axis=1)
            acc += np.sum(y == tt)

        return acc / x.shape[0]

    def gradient(self, x, t):
        """勾配を求める（誤差逆伝搬法）
        Parameters
        ----------
        x : 入力データ
        t : 教師ラベル
        Returns
        -------
        各層の勾配を持ったディクショナリ変数
            grads['W1']、grads['W2']、...は各層の重み
            grads['b1']、grads['b2']、...は各層のバイアス
        """
        # forward
        self.loss(x, t)

        # backward
        dout = 1
        dout = self.last_layer.backward(dout)

        layers = list(self.layers.values())
        layers.reverse()
        for layer in layers:
            dout = layer.backward(dout)

        # 設定
        grads = {}
        grads['W1'], grads['b1'] = self.layers['Conv1'].dW, self.layers['Conv1'].db
        grads['W2'], grads['b2'] = self.layers['Conv2'].dW, self.layers['Conv2'].db
        grads['W3'], grads['b3'] = self.layers['Affine1'].dW, self.layers['Affine1'].db
        grads['W4'], grads['b4'] = self.layers['Affine2'].dW, self.layers['Affine2'].db

        return grads

    def save_params(self, file_name="params.pkl"):
        params = {}
        for key, val in self.params.items():
            params[key] = val
        with open(file_name, 'wb') as f:
            pickle.dump(params, f)

    def load_params(self, file_name="params.pkl"):
        with open(file_name, 'rb') as f:
            params = pickle.load(f)
        for key, val in params.items():
            self.params[key] = val

        for i, key in enumerate(['Conv1', 'Conv2', 'Affine1', 'Affine2']):
            self.layers[key].W = self.params['W' + str(i+1)]
            self.layers[key].b = self.params['b' + str(i+1)]

network = SimpleConvNet2(input_dim=(3, 36, 24), 
                        conv_param = {'filter_num': 30, 'filter_size': 5, 'pad': 2, 'stride': 1},
                        hidden_size=100, output_size=37, weight_init_std=0.01)

max_epochs = 30

trainer = Trainer(network, x_train, t_train, x_test, t_test,
                  epochs=max_epochs, mini_batch_size=100,
                  optimizer='Adam', optimizer_param={'lr': 0.001},
                  evaluate_sample_num_per_epoch=1000)
```
</div></details>

結果
epoch30でTest Accuracy: 0.968となりました。
![train_lo_g.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/8608fde5-ed2a-c2d1-333f-2322e96db229.png)
さらに学習を重ねると、訓練データのAccuracyが上回るようになり、過学習の傾向が見られます。
![train_log.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/7477dc14-e4ad-8e22-fb01-ae28906ea8ae.png)
epoch30時点での学習結果を用いて、テンプレートマッチングと同じ手牌画像で手牌検出を行いました。


### 手牌検出結果
分類を間違えた牌は14/471枚、Accuracyは97.0%となりました。
データ数が少ない中での結果ですが、正解率はテンプレートマッチング(95.3%)よりわずかに良いです。

手牌の位置毎(左端が0)の誤答数は下記となります。
0: 0/34
1: 1/34
2: 0/34
3: 0/34
4: 0/34
5: 0/34
6: 1/34
7: 1/34
8: 4/34
9: 1/34
10: 1/34
11: 1/34
12: 2/34
13: 2/29
全体的に手牌右側の牌の誤答数が多い結果となりましたが、テンプレートマッチングのように右端の牌の誤答数が突出して多くはないです。

誤答した牌の画像と予測確率トップ3を示します。
![mispredict.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/2722fef5-f28e-513d-f1fa-1452e5a3b845.png)
画像に映り込んだ左右の牌が誤答の原因となっている可能性があります。

上段左から四番目の正解は八萬ですが、99%九萬と予測しています。この画像では画面右に7索が映っています。
八萬と九萬の訓練データを見てみると、九萬には右端に七索が映っているものが含まれていますが、八萬にはありませんでした。
また、下段一番左の正解は一索ですが、予測上位は萬子が並んでいます。この画像には右端に三萬が映っています。
ある程度理牌した手牌画像から訓練データを作成したため、右端に萬子が映っている画像は萬子の可能性が高いと学習しているかもしれません。

単純なData Augmentationでは牌の並びをかさ増しすることはできないので、牌の並びによる過学習の対策にはなりません。
「切り出す領域を狭くとる」「右側にある牌ほど拡大する」などによって、左右の牌が写り込まないようにするのが良いでしょう。
牌を分類するには図柄全体が映っている必要はないので、ある程度見切れていても良さそうです。
牌の見切れ方はData Augmentationで容易に再現できるので、見切れ方で過学習することも防げます。

上段一番左の中を90%西と予測している原因はわかりませんでした。
CNNの判断根拠を可視化する手法を使えば何かわかるのでしょうか。今後の課題とします。

### まとめ
CNNによってテンプレートマッチングと同程度以上の精度で牌を分類できました。
精度悪化や過学習の原因となりそうな要素についてデータセットを作る段階で深く考えていなかったので、手戻りが多く・大きくなってしまったのが反省点です。

# 6. 追記：透視変換を用いた分類対象画像の取得
cv2.warpPerspectiveを用いて手牌を正面から見た画像に透視変換できます。
ただし、手牌の四隅の座標をかなり正確に検出しないと変換後の画像が大きく歪みます。

```.py
def warp(img, o1, o2, o3, o4, tiles_num):
    """
    :param o1, o2, o3, o4: tupple (x, y) 手牌表面の四隅の座標
    """
    # 変換後の画像サイズ
    tile_w = 20 * 4
    tile_h = 26 * 4
    w = tile_w * tiles_num
    h = tile_h
    
    # 変換マトリクス
    p_original = np.float32([[o1[0], o1[1]], [o2[0], o2[1]], [o3[0], o3[1]], [o4[0], o4[1]]])
    p_trans = np.float32([[0, 0], [w, 0], [0, h], [w, h]])
    M = cv2.getPerspectiveTransform(p_original, p_trans)

    # 透視変換
    img_warp = cv2.warpPerspective(img, M, (w, h))

    return img_warp
```
![debug_1 - コピー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/e9d30eb6-ad05-d106-6331-6e4130335e17.png)
![debug_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/868d048d-573d-7d5a-b35d-613e01374a5a.png)


# 7. おわりに
画像分類の勉強のために手牌検出に取り組んでみました。
学んだ技術を試してみることで理解が深まりましたし、さらに学ばないといけないことが見えてきました。
プログラミングや機械学習は技術を手軽に試せる環境が整っているので、今後も手を動かして学んでいきたいです。

今回は書籍から流用したソースコードの修正と開発データを作るのに多くの時間がかかりました。
学習目的なら機械学習フレームワークとオープンデータセットを使うほうが効率が良いでしょう。

ソースコード
https://github.com/smishimas/MahjongTileDetection

![mleague.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280419/b40541bb-74c3-8072-e3ca-09eaf8fc5555.png)
　（1画面に情報が収まらないし需要がなさそう……）

# 参考文献
[1] [ゼロから作るDeep Learning――Pythonで学ぶディープラーニングの理論と実装 (O'Reilly Japan)](https://www.oreilly.co.jp/books/9784873117584/)
[2] [機械学習のエッセンス 実装しながら学ぶPython、数学、アルゴリズム (SBクリエイティブ)](https://www.sbcr.jp/product/4797393965/)
[3] [物体検出 —opencv 2.2 documentation](http://opencv.jp/opencv-2svn/cpp/imgproc_object_detection.html)
[4] [テンプレートマッチング—OpenCV-Python Tutorials 1 documentation](http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_template_matching/py_template_matching.html)
[5] [モルフォロジー変換 —OpenCV-Python Tutorials 1 documentation](http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_morphological_ops/py_morphological_ops.html)
[6] [オブジェクト除去 | connectedComponentsWithStats を使ってノイズを消す方法](https://axa.biopapyrus.jp/ia/opencv/remove-objects.html)
[7] [画像の前処理 - Keras Documentation](https://keras.io/ja/preprocessing/image/)

# 画像出典
・手牌画像：[Mリーグ2019 - 本編 【Abemaビデオ】](https://abema.tv/video/title/444-4)
　【1/27】赤坂ドリブンズvsKONAMI 麻雀格闘倶楽部vsセガサミーフェニックスvsTEAM RAIDEN / 雷電
　【1/24】赤坂ドリブンズvsKADOKAWAサクラナイツvsKONAMI 麻雀格闘倶楽部vs渋谷ABEMAS
・麻雀牌素材：[【保存版】商用無料の高クオリティーの麻雀画像の無料素材まとめ | 麻雀豆腐](https://majandofu.com/mahjong-images)