1. はじめに
この記事では、さまざまな制約化にもめげず AI-OCR を自作した過程を記述しています。生成AIで何でも可能なこのご時世にそんなモノ好きはいないでしょうが、もし、仮にAI-OCRを自作される機会がありましたら、参考となりましたら幸いです。
2. 背景
2.1. 動機
教育訓練講座(SAMURAI ENGINEER AIデータサイエンスコース)を半年間受講し、機械学習の基礎を学びました。
得た知識を使って、是非実現したいことがありました。それがAI-OCRの作成です。
私は現在、とある企業の人事・給与事務の統括をしており、部下の人員削減の一方、業務量の増大に日々頭を抱えています。その私が今、一番恐れているのが、4月に新入社員が入ってくることによる(一過性ですが)業務量の増大です。
人を雇うと、
- 人事システムに人事基礎データ(氏名、住所、生年月日など)登録
- マイナンバーを収集し、人事システムへ登録
- 社員証の発行(外注ではなく、私の部署でカード発行機で発行している)
- 雇用保険加入手続き
- 厚生年金加入手続き
- 健康保険加入手続き
といった事務を2週間くらいでやり遂げなければならず、結構負担です。
それに加え、
- 社内システムの統制が厳しく、社外へのネットワークは遮断されている
- 業務用パソコンへの新たなソフトウエア導入はご法度
- 社内業務アプリ作成・メンテを委託していたグループ会社との業務委託契約が解消され、社内に業務アプリを作成する部署がなくなった
そういった事情で、未だにマイナンバーなどの収集は「紙」です。
収集した紙を部下を総動員して(もちろん管理職の私も含め)紙からデータ起こしをしてシステムに登録しています。
今年は新入300名。多い。疲弊している部下に仕事の上乗せをしたら暴動が起きるかもしれない。怖い。。。
こりゃ、自前で AI-OCR作るっきゃないでしょ!
2.2. 開発環境
ハード(業務用PC)
項目 | 内容 |
---|---|
OS | window10 |
CPU | core i5-8365u |
GPU | インテルHD Graphics |
ソフト
名称 | バージョン |
---|---|
Python | 3.11 |
※Pythonはプレインストールされているもの
ライブラリ
名称 | バージョン |
---|---|
numpy | 1.24.3 |
Tensorflow | 2.16.1 |
pandas | 1.5.3 |
Matplotlib | 3.7.1 |
opencv-python | 4.11.0.86 |
Pilow | 9.4.0 |
尚、ライブラリは pypi からダウンロードしてきたものをローカルのpylibというフォルダに置いておき、pipする際に、
pip install --no-index --find-links=pylib [導入するライブラリ名]
とします。
2.3. 学習データ
MNIST手書き数字データセットを使用します。
6万件の学習データと、1万件のテストデータがあり、AIモデルを鍛えるのにはうってつけです。
さらに、認識率をあげるために、kerasのImageDatageneratorも使い、6万件の学習データにバリエーションを与えています。
# data
(X_train, y_train), (X_test, y_test) = tf.keras.datasets.mnist.load_data()
# input_shape : (data.shape[0],image_w, image_h, 1) float32
X_train = X_train.reshape(X_train.shape[0], image_w, image_h, 1).astype('float32')
X_test = X_test.reshape(X_test.shape[0], image_w, image_h, 1).astype('float32')
X_train /= 255
X_test /= 255
y_train = tf.keras.utils.to_categorical(y_train, 10)
y_test = tf.keras.utils.to_categorical(y_test, 10)
# add start
datagen = tf.keras.preprocessing.image.ImageDataGenerator(
#回転
rotation_range = 5,
#左右反転
horizontal_flip = False,
#上下平行移動
height_shift_range = 0.4,
#左右平行移動
width_shift_range = 0.4,
#ランダムにズーム
zoom_range = 0.4,
#チャンネルシフト
channel_shift_range = 0
)
testgen = tf.keras.preprocessing.image.ImageDataGenerator()
train_generator = datagen.flow(
X_train,
y_train,
batch_size=256,
seed=0
)
test_generator = testgen.flow(
X_test,
y_test,
batch_size=256
)
2.4. 学習モデル
自分で試行錯誤してモデルを定義してみましたが、学習してもaccuracy(正答率)が0.89どまりのものしか作れませんでした。(本当はここで0.95などを叩き出すものを提示できたらカッコ良かったのですが、実力が足りませんでした。ゴメンなさい。)
そこで、先人の知恵をお借りすることとしました。
25万件のデータで鍛え上げ、accuracy=099757を叩き出したとのNotebookをkaggleで見つけました。(https://www.kaggle.com/code/cdeotte/25-million-images-0-99757-mnist)
恐れながら、そのモデルを真似させて頂きます。
def build_model():
model = tf.keras.Sequential([
tf.keras.layers.Conv2D(32, kernel_size=(3, 3),activation='relu',input_shape=(28, 28 , 1)),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Conv2D(32, (3, 3), activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Conv2D(32, (5, 5), strides=2, padding='same', activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Dropout(0.4),
#
tf.keras.layers.Conv2D(64, (3, 3), activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Conv2D(64, (3, 3), activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Conv2D(64, (5, 5), strides=2, padding='same', activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Dropout(0.4),
#
tf.keras.layers.Conv2D(128, (4, 4), activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Flatten(),
tf.keras.layers.Dropout(0.4),
tf.keras.layers.Dense(nb_classes, activation='softmax')
])
3. 準備
このままOCRに読ませて、成果物を得ることができたらカッコいいのですが、初心者らしく手堅く進めます。
具体的には、この帳票から「社員コード」と「個人番号」を読み取りたいのですが、文字の大きさが「社員コード」と「個人番号」では異なる上に、「個人番号」には格子までついています。いかにも調整が難しそうです。
そこで2ファイルに分け、それぞれをモデルに掛けることにします。
なお、ここの技法(切り出して、グレースケール保存する)については、
(1)社員番号を切り出すコード
import numpy as np
import os
from PIL import Image
path = os.getcwd()
#オリジナル スキャンデータの保管場所
in_path = os.path.join(path,"AIocr","myno","ori")
#加工後のデータの保管場所
out_path = os.path.join(path,"AIocr","myno","kcd")
#社員コードが写っている部分だけ抽出
# (1850, 870 ) (1850+500, 870 )
# (1850, 870+150) (1850+500, 870+150)
y = 870
h = 150
x = 1850
w = 500
for curDir,dirs,files in os.walk(in_path):
for file in files:
img = Image.open(os.path.join(in_path,file))
# グレイスケール変換
if img.mode != "RGB":
img = img.convert("RGB")
rgb = np.array(img,dtype="float32")
# 切り出し枠
rgb = rgb[y:y+h,x:x+w]
rgbL = pow(rgb/255.0,2.2)
r,g,b = rgbL[:,:,0],rgbL[:,:,1],rgbL[:,:,2]
grayL = 0.299 * r + 0.587 * g + 0.114 * b
gray = pow(grayL,1.0/2.2)*255
img_gray = Image.fromarray(gray.astype("uint8"))
file_name = file.split(".")[0]
img_gray.save(os.path.join(out_path,file_name + ".bmp"),"BMP",quality=100)
【切り出した画像】

※なお、保存形式としては、Bitmapを採用しました。
Bitmapにすると、MS-paintで開いて、該当する部分が何pixelか数えることができるからです。
(2)個人番号を切り出すコード
import numpy as np
import os
from PIL import Image
# 個人番号届出書のマイナ部分のみ切り出し
path = os.getcwd()
# オリジナルスキャンデータの保存場所
in_path = os.path.join(path,"AIocr","myno","ori")
# 加工後のデータの保管場所
out_path = os.path.join(path,"AIocr","myno","mno")
#社員CDが写っている部分だけ抽出
# (300, 1375 ) (300+900, 1375 )
# (300, 1375+180) (300+900, 1375+180)
y = 1375
h = 180
x = 300
w = 900
for curDir,dirs,files in os.walk(in_path):
for file in files:
img = Image.open(os.path.join(in_path,file))
# グレイスケール変換
if img.mode != "RGB":
img = img.convert("RGB")
rgb = np.array(img,dtype="float32")
# 枠
rgb = rgb[y:y+h,x:x+w]
rgbL = pow(rgb/255.0,2.2)
r,g,b = rgbL[:,:,0],rgbL[:,:,1],rgbL[:,:,2]
grayL = 0.299 * r + 0.587 * g + 0.114 * b
gray = pow(grayL,1.0/2.2)*255
img_gray = Image.fromarray(gray.astype("uint8"))
#img_gray = img_gray.resize((img_gray.width // 6, img_gray.height // 6))
#img_gray.save(os.path.join(out_path,file))
file_name = file.split(".")[0]
img_gray.save(os.path.join(out_path,file_name + ".bmp"),"BMP",quality=100)
【切り出した画像」

4. 制作
4.1. モデルの学習
モデル構築と、学習のコード全体です。
# python 3.11 および tensorflow 2.16 対応
# 注意 tensorflow2.15以前では、kerasの扱いが違うので、以下のコードではエラーになる。
import tensorflow as tf
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import japanize_matplotlib
import seaborn as sns
%matplotlib inline
image_w = 28
image_h = 28
nb_classes = 10
# Define CNN model
def build_model():
model = tf.keras.Sequential([
tf.keras.layers.Conv2D(32, kernel_size=(3, 3),activation='relu',input_shape=(28, 28 , 1)),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Conv2D(32, (3, 3), activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Conv2D(32, (5, 5), strides=2, padding='same', activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Dropout(0.4),
#
tf.keras.layers.Conv2D(64, (3, 3), activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Conv2D(64, (3, 3), activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Conv2D(64, (5, 5), strides=2, padding='same', activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Dropout(0.4),
#
tf.keras.layers.Conv2D(128, (4, 4), activation='relu'),
tf.keras.layers.BatchNormalization(),
tf.keras.layers.Flatten(),
tf.keras.layers.Dropout(0.4),
tf.keras.layers.Dense(nb_classes, activation='softmax')
])
model.compile(loss=tf.keras.losses.CategoricalCrossentropy(from_logits=True),
optimizer='adam',
metrics=['accuracy'])
return model
# data
(X_train, y_train), (X_test, y_test) = tf.keras.datasets.mnist.load_data()
# input_shape : (data.shape[0],image_w, image_h, 1) float32
X_train = X_train.reshape(X_train.shape[0], image_w, image_h, 1).astype('float32')
X_test = X_test.reshape(X_test.shape[0], image_w, image_h, 1).astype('float32')
X_train /= 255
X_test /= 255
y_train = tf.keras.utils.to_categorical(y_train, 10)
y_test = tf.keras.utils.to_categorical(y_test, 10)
# add start
datagen = tf.keras.preprocessing.image.ImageDataGenerator(
#回転
rotation_range = 5,
#左右反転
horizontal_flip = False,
#上下平行移動
height_shift_range = 0.4,
#左右平行移動
width_shift_range = 0.4,
#ランダムにズーム
zoom_range = 0.4,
#チャンネルシフト
channel_shift_range = 0
)
testgen = tf.keras.preprocessing.image.ImageDataGenerator()
train_generator = datagen.flow(
X_train,
y_train,
batch_size=256,
seed=0
)
test_generator = testgen.flow(
X_test,
y_test,
batch_size=256
)
# add end
# 学習
model = build_model()
history = model.fit(
train_generator,
validation_data=test_generator,
epochs=100
)
# 結果
score = model.evaluate(X_test, y_test, verbose=0)
print('score=', score)
# graph
result = pd.DataFrame(history.history)
result[['loss','val_loss']].plot(ylim=[0, 2])
result[['accuracy', 'val_accuracy']].plot(ylim=[0, 1])
# model save
model.save('mnist1.keras')
100エポックの訓練後、accuracy=0.9431, val_accuray=0.9916 という、すばらしいモデルになりました。
4.2. 本番運用
4.2.1. 社員番号の抽出
社員番号を抽出するコードは以下のとおりです。
OPEN-CVというライブラリを使用して、画像処理を行います。
OPEN-CVの使い方は次のサイトを参考にしました。
[OpenCVのfindContours関数を使った画像の輪郭検出]
https://www.argocorp.com/OpenCV/imageprocessing/opencv_find_contours.html
# kcdフォルダの画像から、検知結果 kcd_output.txt を作成
import os
import sys
import math
import tensorflow as tf
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
#import japanize_matplotlib
import seaborn as sns
import cv2
path = os.getcwd()
mnist = tf.keras.models.load_model(os.path.join(path,"mnist1.keras"))
in_path = os.path.join(path,"myno","kcd")
nlist = []
for curDir,dirs,files in os.walk(in_path):
for file in files:
# Data Input
r_file = os.path.join(in_path,file)
im = cv2.imread(r_file, cv2.IMREAD_GRAYSCALE) # im w=841 h=429
# モノクロ変換
# if im.mode != "RGB":
# gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
# else:
# gray = im
gray = im
# ガウス分布に基づくブラー(ぼかし)
blur = cv2.GaussianBlur(gray, (5, 5), 0)
# 2値化(明るいところを白く、暗いところを黒く
thresh = cv2.adaptiveThreshold(blur, 255, 1, 1, 11, 2)
# 輪郭抽出 len(contours)=23
contours = cv2.findContours(
# RETR_EXTERNAL は 一番外側だけを検出するパラメータ
# RETR_RETR_TREE 輪郭の階層情報をツリー形式で取得
# RETR_LIST 白、黒の区別なく、すべての輪郭を同じ階層として取得
# RETR_CCOMP 白の輪郭と黒の輪郭の情報だけを取得
thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
# CHAIN_APPROX_NONE 輪郭上のすべての座標を取得。
# CHAIN_APPROX_SIMPLE 縦、横、斜め45°方向に完全に直線の部分の輪郭の点を省略。
# CHAIN_APPROX_TC89_L1,CHAIN_APPROX_TC89_KCOS 輪郭の座標を直線で近似できる部分の輪郭の点を省略します
# 抽出した座標を左上から右下へ並べ替える
rects = []
im_w = im.shape[1] #im.shape (429, 841, 3) -> im_w = 841
for c in contours:
x, y, w, h = cv2.boundingRect(c)
if w < 7 or h < 10: continue
if w > im_w / 2: continue
y2 = round(y/10)*10
index = y2 * im_w + x
rects.append((x, y, w, h))
rects = sorted(rects, key=lambda x:(x[0],x[1]))
#このソートが問題点だった。 xの昇準、yの昇順にならべないと、ラベルと齟齬する。
# 抽出した領域の画像データ
X = []
# for i, r in enumerate(rects): enumerateだと、index順に出力されてしまうので、使わない
# index, x, y, w, h = r
i = 1
for r in rects:
x, y, w, h = r[0],r[1],r[2],r[3]
num = gray[y:y+h, x:x+w]
num = 255 - num # ネガポジ反転
# 正方形の中に数字を描画
ww = round((w if w > h else h) * 1.80)
spc = np.zeros((ww,ww))
wy = (ww-h)//2
wx = (ww-w)//2
spc[wy:wy+h, wx:wx+w] = num
num = cv2.resize(spc, (28, 28)) # MNISTのサイズ 28x28にリサイズ
#cv2.imwrite(str(i)+"-num.png", num) # 切り出した様子を保存
#i += 1
num = num.reshape(28,28)
num = num.astype("float32") / 255
X.append(num)
nlist.append([file.split(".")[0], mnist.predict(np.array(X))])
nlist_sorted = sorted(nlist,key=lambda x:x[0])
o_file = os.path.join(path,"kcd_output.txt")
with open(o_file, 'w') as o:
for ele in nlist_sorted:
st1 = ele[0] + ","
for i in range(len(ele[1])):
st2 = np.argmax(ele[1][i])
st1 = st1 + str(st2)
print(st1, end="\n", file=o)
4.2.2. 個人番号の抽出
個人番号を抽出するコードは以下のとおりです。
# mnoフォルダの画像から、検知結果 mno_output.txt を作成
import os
import sys
import math
import tensorflow as tf
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
#import japanize_matplotlib
import seaborn as sns
import cv2
path = os.getcwd()
mnist = tf.keras.models.load_model(os.path.join(path,"mnist1.keras"))
in_path = os.path.join(path,"myno","mno")
#print(in_path)
nlist = []
for curDir,dirs,files in os.walk(in_path):
for file in files:
# Data Input
r_file = os.path.join(in_path,file)
im = cv2.imread(r_file) # im w=841 h=429
#im = cv2.imread(r_file, cv2.IMREAD_GRAYSCALE) # im w=841 h=429
#print(im)
# モノクロ変換
# if im.mode != "RGB":
gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
# else:
# gray = im
# gray = im
# ガウス分布に基づくブラー(ぼかし)
blur = cv2.GaussianBlur(gray, (5, 5), 0)
# 2値化(明るいところを白く、暗いところを黒く
thresh = cv2.adaptiveThreshold(blur, 255, 1, 1, 11, 2)
# 輪郭抽出 len(contours)=23
contours = cv2.findContours(
#thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)[0]
# RETR_EXTERNAL は 一番外側だけを検出するパラメータ
# RETR_TREE 輪郭の階層情報をツリー形式で取得
# RETR_LIST 白、黒の区別なく、すべての輪郭を同じ階層として取得
# RETR_CCOMP 白の輪郭と黒の輪郭の情報だけを取得
thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
# CHAIN_APPROX_NONE 輪郭上のすべての座標を取得。
# CHAIN_APPROX_SIMPLE 縦、横、斜め45°方向に完全に直線の部分の輪郭の点を省略。
# CHAIN_APPROX_TC89_L1,CHAIN_APPROX_TC89_KCOS 輪郭の座標を直線で近似できる部分の輪郭の点を省略します
# 抽出した座標を左上から右下へ並べ替える
rects = []
im_w = im.shape[1] #im.shape (429, 841, 3) -> im_w = 841
# for i, cnt in enumerate(contours):
# x, y, w, h =cv2.boundingRect(cnt)
for c in contours:
x, y, w, h = cv2.boundingRect(c)
if w < 5 or h < 10: continue # 仮に5から変更
if w > im_w / 2: continue
if h > 150: continue
y2 = round(y/10)*10
index = y2 * im_w + x
rects.append((x, y, w, h))
rects = sorted(rects, key=lambda x:(x[0],x[1]))
#このソートが問題点だった。 xの昇準、yの昇順にならべないと、ラベルと齟齬する。
# 抽出した領域の画像データ
X = []
# for i, r in enumerate(rects): enumerateだと、index順に出力されてしまうので、使わない
# index, x, y, w, h = r
i = 1
for r in rects:
x, y, w, h = r[0],r[1],r[2],r[3]
num = gray[y:y+h, x:x+w]
num = 255 - num # ネガポジ反転
# 正方形の中に数字を描画
ww = round((w if w > h else h) * 1.80)
spc = np.zeros((ww,ww))
wy = (ww-h)//2
wx = (ww-w)//2
spc[wy:wy+h, wx:wx+w] = num
num = cv2.resize(spc, (28, 28)) # MNISTのサイズ 28x28にリサイズ
#cv2.imwrite(str(i)+"-num.png", num) # 切り出した様子を保存
#i += 1
num = num.reshape(28,28)
num = num.astype("float32") / 255
X.append(num)
nlist.append([file.split(".")[0], mnist.predict(np.array(X))])
nlist_sorted = sorted(nlist,key=lambda x:x[0])
o_file = os.path.join(path,"mno_output.txt")
with open(o_file, 'w') as o:
for ele in nlist_sorted:
st1 = ele[0] + ","
for i in range(len(ele[1])):
st2 = np.argmax(ele[1][i])
st1 = st1 + str(st2)
print(st1, end="\n", file=o)
5. 結果
サンプル画像 20250227192457-0001
につきまして、
(1)kcd_output.txt
20250227192457-0001,0408317
という結果になっていました。
どういうふうに認識しているかを次のコードで書き出ししてみます。
# OPEN-CVを使った、数値画像の認識の調査
import sys
import os
import numpy as np
import cv2
import matplotlib.pyplot as plt
#import japanize_matplotlib
path = os.getcwd()
im = cv2.imread(os.path.join(path,"kcd",'20250227192457-0001.bmp'))
# モノクロ変換
gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
# ガウス分布に基づくブラー(ぼかし)
blur = cv2.GaussianBlur(gray, (5, 5), 0)
# 2値化(明るいところを白く、暗いところを黒く
thresh = cv2.adaptiveThreshold(blur, 255, 1, 1, 11, 2)
# 輪郭抽出
contours = cv2.findContours(
#thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)[0]
# RETR_EXTERNAL は 一番外側だけを検出するパラメータ
thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
im_w = im.shape[1]
for cnt in contours:
x, y, w, h = cv2.boundingRect(cnt)
if w < 7 or h < 10: continue
if w > im_w / 2: continue
red = (0, 0, 255)
cv2.rectangle(im, (x,y), (x+w, y+h), red, 2)
cv2.imwrite('numbers-cnt.png', im)

3文字目の「6」を「0」と誤認識するのは仕方がないけど、最後の「7」を「1」「7」とご認識しているのは痛い。
ここはロジックの次の部分が甘いことによります。
ここは認識しても無視するサイズを指定しています。
if w < 7 or h < 10: continue
if w > im_w / 2: continue
7の一画目の幅は8ピクセルあります。
if w < 10 or h < 10: continue
if w > im_w / 2: continue
ロジックをこのように変更すると、

というふうに、7の一角目を無視してくれます。
この修正を加えてもう一度 kcd_output.txt を再作成すると、
20250227192457-0001,040837
と修正されました。
(2)mno_output.txt
(1)と同様に、認識しないサイズを調整することで、次の水準にはできました。
2文字目の「8」を認識できていません!!大きさは十分あるのに何故???
答えは、「8」の書き終わりが、枠線に接触してしまいとても大きなオブジェクトと認識されたからです。
(3)わかったこと
とりあえず試作機を作成することはできました。
- 数字を高確率で判別することができるCNNを自作することができる
- 認識するサイズが重要で、職人芸でサイズ調整を行う必要がある
- 枠内に文字を書くケースでの認識は至難の技
尚、今回は数字がターゲットでしたが、漢字認識は桁違いに難解なようです。
(なぜなら学習データの確保が難しいからです。)
6.参考にした情報
本当に世の中にはすごい方がおられまして、その方々が公開していただいている情報は宝の山です。
今回もそれを参考にさせていただいております。
最大の謝辞を述べさせていただきます。
[CNNで画像OCRを作成する]
「増補改訂Pythonによるスクレイピング&機械学習 開発テクニック」
クジラ飛行机 著
ソシム株式会社 発行
[CNNモデル]
https://www.kaggle.com/code/cdeotte/25-million-images-0-99757-mnist
[OpenCVのfindContours関数を使った画像の輪郭検出]
https://www.argocorp.com/OpenCV/imageprocessing/opencv_find_contours.html