scikit-learnでルーン文字の手書き文字認識をやってみた

この記事は、富士通システムズウェブテクノロジー Advent Calendarの15日目の記事です。
（お約束）本記事の掲載内容は私自身の見解であり、所属する組織を代表するものではありません。

### はじめに
本記事では、**Pythonの機械学習用ライブラリであるscikit-learnを使用して
ルーン文字の手書き文字認識をやってみた**際の手順と結果をまとめています。
本Advent Calendarの先達がかなり実業務に役立ちそうな凄い機能やノウハウを載せてくださっているなか、
ただ自分が楽しいだけのものを作ってしまい、ものすごくいたたまれないです。。
さらさらっと見ていただけますと幸いです。

また、本記事の作成者が機械学習初学者なため 今更的な内容が多いかも知れないですが
同じくpython, scikit-learn をこれから触ってみる人にとって
手ごろな情報を提供できるように頑張ります。よろしくお願いいたします。

#### 背景

普段業務では全く触らないですが機械学習に興味があり、学習や動作の基礎を学びたいと思い
scikit-learn に触れてみました。
ライブラリが持っているデータセットなどを使って動かしてみてもよかったですが、
「どういうデータを用意して何をどうすればしたいことができるのか？」を知りたく
データを用意するところからやってみることにしました。

**(余談)**
 ルーン文字ってなんだかカッコイイですよね！

### 使用したもの
- Anaconda ： Python本体とよく使われるライブラリを含んだパッケージです。
- scikit-learn ：Pythonでニューラルネットワークを簡単に使用できるライブラリです。オープンソースで公開されています。今回は、その中でもMLPClassifierという「分類」を行うモデルを使用します。

- E-cutter ： 画像分割のためのフリーソフトです。手書き文字画像の分割に使用しました。


### データの準備

今回はルーン文字のうち「ゲルマン共通ルーン文字(24文字)」を対象にします。
![images.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310568/dfae3b1f-d1d3-d40c-3c16-2a6d4544d703.png)

「機械学習用ルーン文字データ」のような都合の良いデータはなさそうなので、自前で画像を用意します。
今回は以下の方法で作成しました。

①等間隔に手書き文字が並んでいる画像を手書きで作成します。
　（画像ファイル名を描画した文字一字（例:「ᚠ.png」）にしておくと後々便利です）
②E-cutter(フリーソフト)で画像を等分します。
<img width="400" alt="01_画像取得Ecutter.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310568/ce2a746b-466a-684c-f369-4f4be42b30b8.png">
分割後の画像は「[元ファイル名]_[枝番].png」で指定のフォルダに保存してくれるためとても便利でした。
一旦、ルーン文字一種類当たり 18個の画像データを作成しました。


### 画像の読み込み
ここからPythonで処理をおこないます。まずは画像を読み込みます。

``` py
import cv2 # 画像変換のためのライブラリ
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image
import os, glob

# 画像データを格納する配列
X = []
# 画像データに対応する文字(答え)を格納する配列
Y = []

# 学習データのディレクトリ・ファイル
dir = "[画像データ格納ディレクトリ]"
files = glob.glob(dir + "\\*.png")

# 画像の縦横のサイズ(ピクセル)
image_size = 50

# ディレクトリ内のファイルを読み込んで学習データのリストに追加する
for i, file in enumerate(files):
    image = Image.open(file)
    # 8bit グレイスケールに変換
    image = image.convert("L")
    image = image.resize((image_size, image_size))
    data = np.asarray(image).flatten()
    X.append(data)
    moji = file.split("\\")[-1].split("_")[0]
    Y.append(moji)
 
X = np.array(X)
Y = np.array(Y)
```

読み込んだ画像を表示してみます。

```py
# 1番目のデータを可視化する
showimage = np.reshape(X[0], (50,50)) # reshapeで50x50の2重配列にする
plt.subplot(121),plt.imshow(showimage),plt.title('Input')
plt.show()
```

<img width="148" alt="02_画像読み込み.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310568/5e560f4c-4cb1-49ab-14f6-9a3b2349e6e6.png">

50*50のサイズのデータとして読み込めています。

#### ◆学習と分類をさせてみる

データ数がかなり少ないですが、一旦このデータ(24(文字)*18(枚))で学習と分類をさせてみます！

##### ・データを学習用とテスト用に分ける

```py
# データを訓練用とテスト用に分割する
x_train, x_test, y_train, y_test = model_selection.train_test_split(X, Y, test_size=0.1, random_state=0)
```

##### ・学習と判定をさせる

```py
# 学習
clf = MLPClassifier(hidden_layer_sizes=(200,))
clf.fit(x_train, y_train)

# テスト用のデータを分類
y_pred = clf.predict(x_test)

#結果の表示
print("--- 想定している答え ---")
print(y_test)

print("--- モデルが出した答え ---")
print(y_pred)

print("--- 正答率 ---")
accuracy_score(y_test, y_pred)
```

#### ・結果

<img width="800" alt="12120023_最初の結果_全然ダメ.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310568/914112a1-6dd7-f62f-c614-410e4d7427e5.png">


**正答率がとても低い…！！(7.1%) 　orz** 

ほとんどが「ᚱ」に分類されていて、それ以外に判定された文字も誤りです…。
ただ、「ᚱ」以外に分類されているものは、ルーン文字のなかでも形が類似した文字に分類されているように見えます。
知能の萌芽的なものを垣間見られてちょっと嬉しいです（結局間違えてますが）。

やはり学習データが不足しているようですが、手書き文字データをさらに追加するのは手間なので
データの水増し(Data Augmentation)を試します。

### データの水増し(Data Augmentation)

準備した [文字種類]*18枚の画像だけでは学習用のデータが足りなさそうなので、
データを変換してデータ量を増やします。

データの水増し(Data Augmentation)については以下の記事が大変参考になりました。
https://products.sint.co.jp/aisia/blog/vol1-7#toc-3

手法としては、「ノイズを増やす」「反転」「シフト」「変形」などなどがあるようです。
今回はその中の「変形」と「回転」を行います。

#### 変形


```py
# 画像を変形させる
for i, file in enumerate(files):
    image = Image.open(file)   
    image = image.resize((image_size, image_size))
    image = image.convert("L")
    moji = file.split("\\")[-1].split("_")[0]
    
    # データのビットを反転させて配列にする
    image_array = cv2.bitwise_not(np.array(image))
    
    ## 変形①
    # 画像の変形マップを作成
    pts1 = np.float32([[0,0],[0,100],[100,100],[100,0]])
    pts2 = np.float32([[0,0],[0, 98],[102,102],[100,0]])
    # 画像の変形
    M = cv2.getPerspectiveTransform(pts1,pts2)
    dst1 = cv2.warpPerspective(image_array,M,(50, 50))
    
    X.append(dst1.flatten())
    Y.append(moji)
    
    ## 変形②
    # 画像の変形マップを作成
    pts2 = np.float32([[0,0],[0, 102],[98, 98],[100,0]]) 
    # 画像の変形
    M = cv2.getPerspectiveTransform(pts1,pts2)
    dst2 = cv2.warpPerspective(image_array,M,(50, 50))
    X.append(dst2.flatten())
    Y.append(moji)

# 末尾のデータを表示する
showimage = np.reshape(image_array, (50,50)) # reshapeで50x50の2重配列にする
plt.subplot(121),plt.imshow(showimage),plt.title('Input')
plt.subplot(122),plt.imshow(dst),plt.title('Output')
plt.show()
```

##### 変形した画像の表示

<img width="293" alt="03_画像変形.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310568/a9d665ee-cdf7-6945-8553-c56f3e6520b7.png">

元の画像と比べて うっすら変形された画像が生成できました。
元画像一つにつき二つの変形画像を生成し、学習データに追加します。

#### 回転 

さらにデータを増やすために、元画像を15度回転させた画像を作って追加してみます。

```py
#画像を回転させる
for i, file in enumerate(files):
    image = Image.open(file)   
    image = image.resize((image_size, image_size))
    image = image.convert("L")
    moji = file.split("\\")[-1].split("_")[0]
    
    # データのビットを反転させて配列にする
    image = cv2.bitwise_not(np.array(image))
    
    # １．時計回りに15度回転
    #回転角を指定
    angle = -15.0
    #スケールを指定
    scale = 1.0
    #getRotationMatrix2D関数を使用
    trans = cv2.getRotationMatrix2D((24, 24), angle , scale)
    #アフィン変換
    image1 = cv2.warpAffine(image, trans, (50, 50))
    X.append(image1.flatten())
    Y.append(moji)
    
    # ２．反時計回りに15度回転
    #回転角を指定
    angle = 15.0
    #スケールを指定
    scale = 1.0
    #getRotationMatrix2D関数を使用(引数：中心の位置、回転角、スケール）
    trans = cv2.getRotationMatrix2D((24, 24), angle , scale)
    #アフィン変換
    image2 = cv2.warpAffine(image, trans, (50, 50))
    X.append(image2.flatten())
    Y.append(moji)

# 末尾のデータを表示する
showimage = np.reshape(image, (50,50)) # reshapeで50x50の2重配列にする
plt.subplot(121),plt.imshow(showimage),plt.title('Input')
plt.subplot(122),plt.imshow(image1),plt.title('Output')
plt.show()

showimage = np.reshape(image, (50,50)) # reshapeで50x50の2重配列にする
plt.subplot(121),plt.imshow(showimage),plt.title('Input')
plt.subplot(122),plt.imshow(image2),plt.title('Output')
plt.show()
```

##### 回転した画像の表示
<img width="293" alt="03_画像回転.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310568/f0aae952-cea1-c2e2-a28c-dd9d9da899cd.png">

元画像を左右に15度回転させた画像がそれぞれ生成できました。
この画像も学習データに追加します。

これで、学習用のデータ数は元の５倍となりました（オリジナル、変形①、変形②、右回転、左回転）。

### 学習・認識 [再]
再び学習とテスト用の画像の認識（分類）をさせてみます！

#### ・結果
<img width="800" alt="12152147_次の結果_うまくいった！.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310568/7e2e703c-3e4a-e05b-5ac0-149d6573da27.png">


**精度が上がった・・・・！(86.9%)**



### 振り返り

##### 結局どのデータが有効だったのか
「データを増やしたら分析精度が上がった！やったぜ！」という感じの結果にはなりましたが、
結局変形画像がそれぞれどれくらい効いたのかが気になったので、
ざっくりですが、学習させるデータの内訳を変えて正答率を検証してみました。
<img width="700" alt="雑まとめ.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/310568/bc9984e0-e503-f4ad-e6d9-3c9e59228b25.png">
画像にバリエーションをもたせるほど正答率が高くなっていることを確認することができました。

##### 今後やりたいこと
ほかの手法（「トリミング」「ノイズ」…）によってさらに精度が上がるのか…を引き続き検証していきたいです。
また、今回は 200 固定で進めていたニューラルネットワークの隠れ層のノード数を変更したパターンも検証しようと思います。

以上読んでいただきありがとうございました！