はじめに
MNIST数字認識の活用検討として、
手組みで、手書きされた帳票をデータベースへ取り組む例を起こしてみました。
この記事について
- 想定読者
- 手書き認識を外部サービスや有償のソフトウェアを買わずに試したい方
- 機械学習は知っているけど、プログラムを組んだことがない方
- 業務で手書き認識できたら便利だなぁと思う方
- 記事のゴール
- 手書き文字(数字)をPCに取り込む流れがわかる、やれる、を目指します
本文
全体像
この記事で紹介する手書き文字認識の活用イメージ
作業した環境
- 環境
- OS: macOS Sonoma 14.5
- CPU: Apple M3
- RAM: 16GB
- Python: 3.10.x
- Java: open jdk 22.0.x
イメージした利用者
- 業種
- 小規模工場の現場部門
- 人数
- 20名ぐらい(チェックしやすい規模感想定)
- 特性
- PCは共有、個人所有なし。作業実績は紙に記録。
イメージした課題と対応
- 課題
- 現場の作業記録はすべて紙。書いた内容はそのままで作業分析に至っていない。
- 対応
- 現場の作業記録はすべて紙。書いた内容はDBに取り込まれ、作業実態がわかる。
- (現場はそのままの点がちょこっとDX)
実施内容
前提
- 0 から 9 の数値手書き部分を認識対象とした帳票を選定する (MNISTの学習範囲)
例えば作業日報。
開始と終了時刻、作業がわかれば、直接工と間接作業の割合ぐらいはわかるか?
実装(サンプルプログラム)
GitHub
git clone https://github.com/m5071106/MnistTrial.git
構成情報ファイル1を分けて管理するため、別々のフォルダでプログラムを作成します。
MnistTrial
├── ImgSelector
│ ├── ImgSelector.java
│ └── parameter.txt
├── db_sample
│ ├── backup
│ ├── db_sample.py
│ └── source.txt
├── img_clipper
│ ├── backup
│ ├── extensions.txt
│ ├── img_clipper.py
│ ├── parameter.txt
│ ├── result
│ ├── result.txt
│ └── source.txt
├── number_recog
│ ├── Net.py
│ ├── backup
│ ├── data
│ ├── extensions.txt
│ ├── models
│ ├── number_recog.py
│ ├── number_recog_train.py
│ ├── result
│ ├── result.txt
│ ├── sample
│ │ └── sample.png
│ ├── source.txt
│ └── temporary
└── pdf_to_img
├── backup
├── extensions.txt
├── pdf_to_img.py
├── result
├── result.txt
├── sample
│ └── daily_report_sample.pdf
├── source
└── source.txt
モジュールインストール(pip install
や brew install
など)は割愛しています
pdfからpngへの変換
複合機等でpdfスキャンした資料をpngへ変換することをイメージしたプログラム
同階層に以下のようなファイルを置いて構成情報を管理します。
-
source.txt
: 入力元フォルダを記載 -
result.txt
: 出力先フォルダを記載 -
extensions.txt
: 取込対象の拡張子を列挙
source.txt
へ記載したフォルダに、pdfファイルを格納しプログラムを実行すると、
result.txt
へ記載したフォルダに、サイズ調整されたpngファイルが作成されます。
from pathlib import Path
from pdf2image import convert_from_path
import os
import glob
def convert_pdf():
# 対象拡張子の読み込み
with open('extensions.txt', 'r') as file:
extensions = file.read().splitlines()
# 入出力ディレクトリのパス
source_dir = Path('./source.txt').read_text().strip()
result_dir = Path('./result.txt').read_text().strip()
# sourceディレクトリ内のファイル一覧を取得
file_list = os.listdir(source_dir)
# 認識対象の拡張子のファイルのみを抽出し、変換を行う
for filename in file_list:
if any(extension in filename for extension in extensions):
file_name_without_extension = os.path.splitext(filename)[0]
images = convert_from_path(f'{source_dir}/{filename}', poppler_path='/opt/homebrew/opt/poppler/bin/', dpi=300)
for i, image in enumerate(images):
# 1024より幅が大きい時、1024にリサイズ
if image.width > 1024:
new_height = int((1024 / image.width) * image.height)
image = image.resize((1024, new_height))
# 750より高さが大きい時、750にリサイズ
if image.height > 750:
new_width = int((750 / image.height) * image.width)
image = image.resize((new_width, 750))
image.save(f'{result_dir}/{file_name_without_extension}_{i}.png', "PNG")
if __name__ == "__main__":
convert_pdf()
手書き認識部分の切り抜き
1文字1画像で数値認識するため、対象を個別の画像として切り抜きます。
同階層に以下のようなファイルを置いて構成情報を管理します。
-
source.txt
: 入力元フォルダを記載 -
result.txt
: 出力先フォルダを記載 -
extensions.txt
: 取込対象の拡張子を列挙 -
parameter.txt
: 切り抜き位置を1行1枠で、カンマ区切りで記載したファイル
source.txt
へ記載したフォルダに、pngファイルを格納しプログラムを実行すると、
result.txt
へ記載したフォルダに、
parameter.txt
をもとに切り取りを行ったpngファイルが作成されます。
from pathlib import Path
from PIL import Image
import csv
import datetime
import os
# 指定された領域を切り取って保存する関数
def clip_image(top: int, left: int, height: int, width: int, input_file: str, output_file: str):
image = Image.open(input_file)
right = left + width
bottom = top + height
# 画像抽出
clipped_image = image.crop((left, top, right, bottom))
# 画像保存
clipped_image.save(output_file)
def read_files():
# 対象拡張子の読み込み
with open('extensions.txt', 'r') as file:
extensions = file.read().splitlines()
# 入出力ディレクトリのパス
source_dir = Path('./source.txt').read_text().strip()
result_dir = Path('./result.txt').read_text().strip()
backup_dir = './backup'
# sourceディレクトリ内のファイル一覧を取得
file_list = os.listdir(source_dir)
# 認識対象の拡張子のファイルのみを抽出し、変換を行う
for filename in file_list:
if any(extension in filename for extension in extensions):
# ファイル名から拡張子を除外
converted_filename = filename
for extension in extensions:
converted_filename = converted_filename.replace('.' + extension, '')
print(f'{converted_filename}の画像変換を開始')
# 変換時刻を取得
datetimenow = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
# 読み込み用パラメタファイル
with open('parameter.txt', 'r') as csvfile:
reader = csv.reader(csvfile)
index = 1
for row in reader:
top, left, height, width = map(int, row[:4])
# 画像抽出関数呼び出し
clip_image(top, left, height, width, source_dir + "/" + filename, result_dir + "/" + converted_filename + '_' + f'{index:02}' + '.png')
index += 1
# 処理したファイルをバックアップディレクトリに移動
os.rename(f'{source_dir}/{filename}', f'{backup_dir}/{filename}')
# バックアップディレクトリに移動したファイルに年月日時分秒をつけてリネーム
os.rename(f'{backup_dir}/{filename}', f'{backup_dir}/{filename}.{datetimenow}')
if __name__ == '__main__':
read_files()
parameter.txt
の作成を行う補助プログラム。
Java
で起こしましたが、方法はいろいろあると思います。
プログラムを起動するとpngファイルの選択ダイアログが表示されます。
マウスで開始と終了位置を選択し「座標設定」ボタンクリックで切り抜き位置が登録され、
「CSV保存」でimp_clipper.py
の参照元となるparameter.txt
が作成されます。
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.GridLayout;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.Collections;
import javax.imageio.ImageIO;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextArea;
// 画像を選択してクリップするための準備クラス
public class ImgSelector {
JFrame frame;
String top;
String left;
String height;
String width;
String prevTop;
String prevLeft;
String temporaryCoordinate;
public ImgSelector() {}
public ImgSelector(String filePath) {
try {
// 変数初期化
top = "0";
left = "0";
height = "0";
width = "0";
prevTop = "0";
prevLeft = "0";
// 引数のパスから画像を読み込む
File file = new File(filePath);
BufferedImage image = ImageIO.read(file);
// filePathからファイル名を取得
String fileName = file.getName();
// fileNameから拡張子を除外
String fileNamePrefix = fileName.substring(0, fileName.lastIndexOf("."));
// 画像サイズをもとにフレームを作成する
frame = new JFrame();
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(image.getWidth()+200, image.getHeight()+100);
// 画像を表示するためのパネルを作成する
JPanel panel1 = new JPanel() {
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
g.drawImage(image, 0, 0, null);
}
};
// 現在座標を表示するためのラベル
JLabel label1 = new JLabel("");
label1.setHorizontalAlignment(JLabel.CENTER);
// 第二座標選択位置を表示するためのラベル
JLabel label2 = new JLabel("Shape = Top:" + prevTop + ", Left: " + prevLeft + ", Height: " + height + ", Width:" + width);
label2.setHorizontalAlignment(JLabel.CENTER);
// ファイル出力する対象を記載するテキストボックス
JTextArea textArea = new JTextArea();
textArea.setText("");
// リセットボタン
JButton resetButton = new JButton("リセット");
resetButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
top = "0";
left = "0";
width = "0";
height = "0";
prevTop = "0";
prevLeft = "0";
label1.setText("");
label2.setText("Shape = Top:" + prevTop + ", Left: " + prevLeft + ", Height: " + height + ", Width:" + width);
textArea.setText("");
}
});
// 終了ボタン
JButton exitButton = new JButton("終了");
exitButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
System.exit(0);
}
});
// Label2の内容をtextAreaに設定するボタン
JButton setButton = new JButton("座標設定");
setButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
textArea.setText(textArea.getText() + temporaryCoordinate + "\n");
}
});
// textAreaの内容をファイルに出力するボタン
JButton saveButton = new JButton("CSV保存");
saveButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
try {
File file = new File("parameter.txt");
if (file.exists()) {
file.delete();
}
file.createNewFile();
java.io.PrintWriter pw = new java.io.PrintWriter(new java.io.BufferedWriter(new java.io.FileWriter(file)));
pw.print(textArea.getText());
pw.close();
} catch (Exception ex) {
ex.printStackTrace();
}
}
});
// ラベルを設定するためのパネルを作成する
JPanel panel2 = new JPanel();
panel2.setPreferredSize(new Dimension(image.getWidth(), 100));
panel2.setLayout(new GridLayout(6,1));
panel2.add(label1);
panel2.add(label2);
panel2.add(setButton);
panel2.add(saveButton);
panel2.add(resetButton);
panel2.add(exitButton);
// UIをフレームに追加して表示する
frame.add(panel1, BorderLayout.CENTER);
frame.add(panel2, BorderLayout.SOUTH);
frame.add(textArea, BorderLayout.EAST);
frame.setUndecorated(true);
frame.setVisible(true);
// マウスでクリックした座標を表示するためのリスナーを追加する
frame.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
int x = e.getX();
int y = e.getY();
if( x < 0 || y < 0 || x > image.getWidth() || y > image.getHeight() ) {
return;
}
int distanceFromTop = y;
int distanceFromLeft = x;
// ラベルに座標を表示する
top = String.valueOf(distanceFromTop);
left = String.valueOf(distanceFromLeft);
if(prevTop.equals("0") && prevLeft.equals("0")) {
prevTop = top;
prevLeft = left;
}
width = String.valueOf(Integer.parseInt(left) - Integer.parseInt(prevLeft));
height = String.valueOf(Integer.parseInt(top) - Integer.parseInt(prevTop));
temporaryCoordinate = prevTop + "," + prevLeft + "," + height + "," + width;
label2.setText("Shape = Top:" + prevTop + ", Left: " + prevLeft + ", Height: " + height + ", Width:" + width);
prevTop = top;
prevLeft = left;
}
});
// マウスオーバーイベントでlabel1に現在のx, yの位置を表示する
frame.addMouseMotionListener(new MouseAdapter() {
@Override
public void mouseMoved(MouseEvent e) {
int x = e.getX();
int y = e.getY();
label1.setText("Current X: " + x + ", Y: " + y);
}
});
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
try {
// ファイル選択ダイアログを表示
FileDialog fileDialog = new FileDialog(new JFrame(), "画像ファイルを選択してください");
fileDialog.setMode(FileDialog.LOAD);
fileDialog.setVisible(true);
String filePath = fileDialog.getDirectory() + fileDialog.getFile();
System.out.println("filePath: " + filePath);
if (filePath == null || filePath.isEmpty() || filePath.equals("nullnull") || filePath.contains("nullnull")){
System.out.println("ファイルが選択されていません");
System.exit(0);
} else {
ImgSelector imgSelector = new ImgSelector(filePath);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
pngとparameter.txt
のサンプル
108,146,35,36
109,190,33,34
110,234,32,33
109,276,32,35
218,149,30,30
216,191,31,33
216,249,31,35
216,292,31,34
216,366,32,32
217,408,28,29
216,465,32,34
215,508,34,37
214,565,34,34
256,146,35,37
257,190,34,35
258,249,32,33
258,291,33,36
257,364,33,35
258,408,32,35
258,467,33,35
257,508,33,37
257,568,32,33
300,147,32,36
299,190,31,34
299,251,32,31
301,293,31,34
300,364,33,35
299,407,31,33
299,466,33,36
300,510,31,35
299,567,33,32
344,148,30,35
343,190,32,34
342,249,33,35
342,294,32,32
341,364,32,34
342,406,34,36
342,466,35,35
341,509,34,36
342,564,31,36
384,148,30,30
384,190,32,33
384,249,34,35
384,292,32,33
383,362,34,35
385,407,32,35
384,467,33,35
383,508,33,35
384,565,32,35
手書き認識
学習を実行後、学習済モデルで手書き認識を行います。
学習
はじめに、認識精度調整のため、下記パラメタを選定します。
batch_size
, learning_rate
, epochs
, optimizer
プログラム実行後models
フォルダに学習済モデルが保存されます。
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
# データの前処理
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5,), (0.5,))
])
# MNISTデータセットの読み込み
train_dataset = datasets.MNIST(root='./data', train=True, transform=transform, download=True)
test_dataset = datasets.MNIST(root='./data', train=False, transform=transform)
# 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()
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:
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')
認識
手書き文字を認識します。
同階層に以下のようなファイルを置いて構成情報を管理します。
-
source.txt
: 入力元フォルダを記載 -
result.txt
: 出力先フォルダを記載 -
extensions.txt
: 取込対象の拡張子を列挙
source.txt
へ記載したフォルダに、数字のpngファイルを格納しプログラムを実行すると、
result.txt
へ記載したフォルダに、認識結果のテキストファイルが作成されます。
import cv2
import datetime
import os
import PIL.ImageOps
import PIL.Image as pilimg
import torch
import torch.nn as nn
import torchvision.datasets as datasets
import torchvision.transforms as transforms
from pathlib import Path
from Net import Net
# データの前処理
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5,), (0.5,))
])
# モデル読込
model = Net()
model.load_state_dict(torch.load('models/model_weight.pth'))
def predict_number():
# 変数の初期化
resultfilename = None
result = None
# 対象拡張子の読み込み
with open('extensions.txt', 'r') as file:
extensions = file.read().splitlines()
# 入出力ディレクトリのパス
source_dir = Path('./source.txt').read_text().strip()
result_dir = Path('./result.txt').read_text().strip()
backup_dir = './backup'
temporary_dir = './temporary'
# sourceディレクトリ内のファイル一覧を取得
file_list = os.listdir(source_dir)
# tempoary_dir 内のファイルを削除
for filename in os.listdir(temporary_dir):
os.remove(f'{temporary_dir}/{filename}')
# 認識対象の拡張子のファイルのみを抽出し、変換を行う
for filename in file_list:
if any(extension in filename for extension in extensions):
print(f'{filename}の画像変換を開始')
# 変換時刻を取得
datetimenow = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
# 変換後のファイル名を作成
converted_filename = filename
for extension in extensions:
converted_filename = converted_filename.replace('.' + extension, '')
# 変換後のファイル名
resultfilename = f'{converted_filename}.txt'
# 入力
img = cv2.imread(source_dir + "/" + filename)
# グレースケール
img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 2値化
threshold = 140
img = cv2.threshold(img, threshold, 255, cv2.THRESH_BINARY)[1]
# 画像の短い方の辺の長さを取得
shortest_side = min(img.shape[0], img.shape[1])
# 正方形に変換
img = cv2.resize(img, (shortest_side, shortest_side))
# 一時フォルダへ書き出し
cv2.imwrite(temporary_dir + "/" + filename, img)
# 画像読込
image = pilimg.open(temporary_dir + "/" + filename).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)
result = prediction.item()
# 処理したファイルをバックアップディレクトリに移動
os.rename(f'{source_dir}/{filename}', f'{backup_dir}/{filename}')
# バックアップディレクトリに移動したファイルに年月日時分秒をつけてリネーム
os.rename(f'{backup_dir}/{filename}', f'{backup_dir}/{filename}.{datetimenow}')
# resultフォルダ内にファイルを作成し、結果を書き込む
with open(f'{result_dir}/{resultfilename}', mode='w+') as f:
f.write(str(result))
# tempoary_dir 内のファイルを削除
for filename in os.listdir(temporary_dir):
os.remove(f'{temporary_dir}/{filename}')
return resultfilename, result
if __name__ == '__main__':
resultfilename, result = predict_number()
print(resultfilename, result)
モデル
class を別途定義し、参照するかたちにしています。
import torch
import torch.nn as nn
# モデルの定義
class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
self.conv1 = nn.Conv2d(1, 32, 3, 1)
self.conv2 = nn.Conv2d(32, 64, 3, 1)
self.dropout1 = nn.Dropout(0.25)
self.dropout2 = nn.Dropout(0.5)
self.fc1 = nn.Linear(9216, 128)
self.fc2 = nn.Linear(128, 10)
def forward(self, x):
x = self.conv1(x)
x = nn.functional.relu(x)
x = self.conv2(x)
x = nn.functional.relu(x)
x = nn.functional.max_pool2d(x, 2)
x = self.dropout1(x)
x = torch.flatten(x, 1)
x = self.fc1(x)
x = nn.functional.relu(x)
x = self.dropout2(x)
x = self.fc2(x)
output = nn.functional.log_softmax(x, dim=1)
return output
認識結果をDBへ登録
認識結果をDBへ取り込みます。
同階層に以下のようなファイルを置いて構成情報を管理しています。
-
source.txt
: 入力元フォルダを記載
source.txt
へ記載したフォルダに、認識結果のテキストをおきプログラムを実行すると、
sample.db
内のテーブルにデータが書き込まれます。
from pathlib import Path
from PIL import Image
import csv
import datetime
import os
import sqlite3
def insert_records():
# 入出力ディレクトリのパス
source_dir = Path('./source.txt').read_text().strip()
backup_dir = './backup'
# sourceディレクトリ内のファイル一覧を取得
file_list = sorted(os.listdir(source_dir))
# 登録形式に変換
dataarray = []
for filename in file_list:
# ファイルを開く
with open(f'{source_dir}/{filename}', 'r', encoding='utf-8') as f:
# ファイルを読み込む
reader = csv.reader(f)
for row in reader:
dataarray.append(row)
# ここでdataarrayからDBへ取り込む形式へ変換(割愛)
# データベースに接続
conn = sqlite3.connect('sample.db')
c = conn.cursor()
# insert 文 割愛
conn.commit()
conn.close()
# sourceディレクトリ内のファイルをbackupディレクトリに移動
for filename in file_list:
# 登録時刻を取得
datetimenow = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
# 処理したファイルをバックアップディレクトリに移動
os.rename(f'{source_dir}/{filename}', f'{backup_dir}/{filename}')
# バックアップディレクトリに移動したファイルに年月日時分秒をつけてリネーム
os.rename(f'{backup_dir}/{filename}', f'{backup_dir}/{filename}.{datetimenow}')
if __name__ == '__main__':
insert_records()
実装してみた評価
- 多くの処理を省きつつ、手書き帳票の内容をDBへ取り込む流れが実現できました
- 一方、3や6などが誤認識されやすく、改良が必要な点はあります
以降の取組
- 手書き帳票の認識で作成された数値データを元に再学習する方法を記載していき、
再学習後、精度向上が図れたかを紹介したいと思います
参考文献
PDFファイルの画像ファイルへの変換
わかりやすいPyTorch入門③(手書き数字認識と精度の向上)
【E資格の前に】PyTorchで学ぶディープラーニング実装
-
source.txt
,result.txt
,extensions.txt
など ↩