自作のデータセットで手書き認識して、ちょこっとDXする

# はじめに
前回は、MNIST数字データを活用し、手書き帳票の記入結果をデータベースへ取り組みました。
今回は、手書き帳票の記入結果をもとに自作のデータセットを作り、学習をさせます。

- 前回の記事はこちら

https://qiita.com/claviers2k/items/b655b28a0360f4251906

## この記事について

<dl>
  <dt>想定読者</dt>
    <dd>自作のデータセットについて作成したことがない方</dd>
    <dd>機械学習は知っているけど、プログラムを組んだことがない方</dd>
    <dd>業務で手書き認識できたら便利だなぁと思う方</dd>
  <dt>記事のゴール</dt>
    <dd>自作データで手書き文字(数字)をPCに取り込む流れがわかる、やれる、を目指します</dd>
</dl>

# 本文

## 全体像

今回、取り組んだ作業の流れ

![mnist_schematic_3_slide.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3762098/d5430f22-3eb8-786e-7500-8f48feeb0197.png)

## 作業した環境
<dl>
  <dt>環境</dt>
    <dd>OS: macOS Sonoma 14.5</dd>
    <dd>CPU: Apple M3</dd>
    <dd>RAM: 16GB</dd>
    <dd>Python: 3.10.x</dd>
</dl>

## イメージしたシーン

<dl>
  <dt>課題</dt>
    <dd>MNISTの数字データセットと現場ユーザの手書き文字の癖に差がある</dd>
  <dt>対応</dt>
    <dd>ユーザが書いた文字をそのまま活用し、現場にあった学習モデルをつくる</dd>
</dl>

## 実施内容

### 実装(サンプルプログラム)

GitHub
```
git clone https://github.com/m5071106/MnistTrial.git
```

``` text: 資産構成
MnistTrial
├── ImgSelector (前回記事範囲のため割愛)
├── db_sample (前回記事範囲のため割愛)
│   └─ backup
├── img_clipper (前回記事範囲のため割愛)
├── number_recog (前回記事範囲のため割愛)
│   └── backup
├── pdf_to_img (前回記事範囲のため割愛)
├── recog_by_selfdata
│   ├── Net.py
│   ├── backup
│   ├── check_selfdata.py
│   ├── data
│   │   ├── test_data
│   │   │   ├── 0
│   │   │   ├── 1
│   │   │   ├── 2
│   │   │   ├── 3
│   │   │   ├── 4
│   │   │   ├── 5
│   │   │   ├── 6
│   │   │   ├── 7
│   │   │   ├── 8
│   │   │   └── 9
│   │   └── train_data
│   │       ├── 0
│   │       ├── 1
│   │       ├── 2
│   │       ├── 3
│   │       ├── 4
│   │       ├── 5
│   │       ├── 6
│   │       ├── 7
│   │       ├── 8
│   │       └── 9
│   ├── extensions.txt
│   ├── img_dir.txt
│   ├── lbl_dir.txt
│   ├── make_selfdata.py
│   ├── models
│   ├── number_recog.py
│   ├── result
│   ├── result.txt
│   ├── sample
│   ├── source.txt
│   ├── target.txt
│   ├── temporary
│   └── train_by_selfdata.py
└── README.md
```

#### 判定した結果から学習データセットをつくる
:::note 
`以降、recog_by_selfdata`フォルダで実施している内容です
:::
`number_recog`フォルダの`number_recog.py`では、ユーザの手書き画像を`backup`フォルダに、
`db_sample`フォルダの`db_sample.py`では、数字認識結果を`backup`フォルダに
それぞれ格納しました。
これらから下記プログラム実行で、訓練データを作成します。

``` recog_by_selfdata フォルダ
python3 make_selfdata.py
```

誤認識した結果は、訓練データが正しくなるよう手動でファイルを移動します。
調整後、訓練データを検証データと振り分け、
`data`フォルダ配下で`train_data`, `test_data`がそれぞれ同じ構成となるようにします

``` make_selfdata.py
from pathlib import Path
import shutil
import cv2

def make_train_data():
    '''
    過去に認識したデータを元に、学習データを作成する
    '''
    # 過去に認識したデータのディレクトリを取得
    srcimg_dir = Path('./img_dir.txt').read_text().strip()
    srclbl_dir = Path('./lbl_dir.txt').read_text().strip()
    srclbl_list = sorted(list(Path(srclbl_dir).glob('*')))
    srclbl_list = [file for file in srclbl_list if 'readme.txt' not in file.name]
    srcimg_list = sorted(list(Path(srcimg_dir).glob('*')))
    srcimg_list = [file for file in srcimg_list if 'readme.txt' not in file.name]

    # 学習データのディレクトリを取得
    target_dir = Path('./target.txt').read_text().strip()

    # 学習データのディレクトリを作成
    if not Path(target_dir).exists():
        Path(target_dir).mkdir(parents=True, exist_ok=True)

    # 学習データのディレクトリを初期化
    for i in range(10):
        if Path(target_dir + '/' + str(i)).exists():
            shutil.rmtree(target_dir + '/' + str(i))
        Path(target_dir + '/' + str(i)).mkdir(parents=True, exist_ok=True)

    # 学習データを過去に認識したデータから作成
    index = 0
    for lbl_file in srclbl_list:
        lbl_content = lbl_file.read_text()
        print(lbl_file, lbl_content, srcimg_list[index])
        shutil.copy(srcimg_list[index], target_dir + "/" + str(lbl_content) + "/" + srcimg_list[index].name)
        index += 1
        
    # ファイル名を連番に変更
    index1 = 0
    for i in range(10):
        temp_list = sorted(list(Path(target_dir + '/' + str(i)).glob('*')))
        temp_list = [file for file in temp_list if 'readme.txt' not in file.name]
        for file in temp_list:
            new_name = file.parent / (str(index1) + '.png')
            index1 += 1
            file.rename(new_name)
            img = cv2.imread(str(new_name), cv2.IMREAD_GRAYSCALE)
            # 28x28にリサイズ
            resized_img = cv2.resize(img, (28, 28))
            # 保存
            cv2.imwrite(str(new_name), resized_img)

    print('誤認識していたデータは該当する数字フォルダへ手で移動してください。')

if __name__ == '__main__':
    make_train_data()
```

- `img_dir.txt`: ユーザ手書き画像の格納フォルダを記載
- `lbl_dir.txt`: 数字認識結果の格納フォルダを記載
- `target.txt`: 訓練データ保存先

#### 自作データセットで学習

整備した訓練データ、検証データをもとに学習モデルを作成します。
`number_recog`フォルダの`number_recog_train.py`とは、3点ほど差異があります。

::: note 
相違点は、`# 相違点○: `のようにプログラム内にコメントを入れました
:::

``` python: train_by_selfdata.py
import torch
import torch.nn as nn
import torch.optim as optim
import torchvision.datasets as datasets
import torchvision.transforms as transforms
import PIL.ImageOps    
import PIL.Image as pilimg

from Net import Net

# 相違点1: データの前処理 (訓練データと検証データの定義)
transform = {
    'train': transforms.Compose([
        transforms.Resize((28, 28)),
        transforms.ToTensor(),
        transforms.Normalize((0.5,), (0.5,))
    ]),
    'test': transforms.Compose([
        transforms.Resize((28, 28)),
        transforms.ToTensor(),
        transforms.Normalize((0.5,), (0.5,))
    ])
}

# 相違点2: 自作データセットの読み込み
# 判定対象ごとにフォルダを作成し、その中に画像を配置.フォルダ内が0件の場合エラーとなる.
train_dataset = datasets.ImageFolder(root='./data/train_data', transform=transform['train'])
test_dataset = datasets.ImageFolder(root='./data/test_data', transform=transform['test'])

# DataLoaderの作成 batch_size の値変更で学習を調整する 128, 64, 32 など
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=64, shuffle=False)

# モデルのインスタンス化
model = Net()

# ハイパーパラメータの設定 学習率(0.01 や 0.1 などで調整)とエポック数(10, 20, 30などで調整)
learning_rate = 0.01
epochs = 30

# 損失関数と最適化アルゴリズムの定義
criterion = nn.CrossEntropyLoss()
# 最適化アルゴリズムの種類: Adam, SGD, Adagrad, RMSprop, Adadelta など
optimizer = optim.Adagrad(model.parameters(), lr=learning_rate)

# 学習ループ
for epoch in range(epochs):
    for batch_idx, (data, target) in enumerate(train_loader):
        # 入力データとラベルの取得
        optimizer.zero_grad()
        # 相違点3: チャネル数を1に変更
        data = data[:, 0, :, :].unsqueeze(1)
        output = model(data)
        loss = criterion(output, target)
        loss.backward()
        optimizer.step()

    # 1エポックごとにテストデータでモデルを評価
    test_loss = 0
    correct = 0
    with torch.no_grad():
        for data, target in test_loader:
            # 相違点3: チャネル数を1に変更
            data = data[:, 0, :, :].unsqueeze(1)
            output = model(data)
            test_loss += criterion(output, target).item()
            pred = output.argmax(dim=1, keepdim=True)
            correct += pred.eq(target.view_as(pred)).sum().item()

    test_loss /= len(test_loader.dataset)
    accuracy = 100. * correct / len(test_loader.dataset)
    print('Epoch: {} Test set: Average loss: {:.4f}, Accuracy: {}/{} ({:.2f}%)'.format(epoch, test_loss, correct, len(test_loader.dataset), accuracy))

# モデルのパラメータを保存
torch.save(model.state_dict(), 'models/model_weight.pth')
# モデル全体を保存
torch.save(model, 'models/model.pth')

# ここから文字認識のテスト
model = Net()
model.load_state_dict(torch.load('models/model_weight.pth'))

# 例：手書き数字の画像を読み込む
image = pilimg.open("sample/sample.png").convert('L')
image = PIL.ImageOps.invert(image)
transform = transforms.Compose([
    transforms.Resize((28, 28)),
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,))
])
image = transform(image).unsqueeze(0)

with torch.no_grad():
    output = model(image)
    # 出力の中で最大の値を持つインデックスを取得
    prediction = output.argmax(dim=1, keepdim=True)
    print("\nPrediction:", prediction.item())
```

学習済モデル作成後は、前回記事と同じ流れです。

## 評価

- 手書き結果が少ないためまだ誤認識も多いが、ユーザに近い内容で調整ができるようになった
- 訓練データ、学習データの組み合わせからアルファベットなども認識できることがわかった

# 参考文献

[PDFファイルの画像ファイルへの変換](https://qiita.com/Gyutan/items/5e62420cc8f6bb106bed)

[画像のデータセットを最も楽に作る方法【Pytorch】](https://qiita.com/coticoticotty/items/5fe4a7f3c740523eb2fb)