前回、TesseractおよびPyTorchのニューラルネットワークで手書き数字の認識をやってみました。
今回は前回のPyTorchのニューラルネットワークに角度情報を加えて、手書き数字認識をやってみました。
具体的には、MNISTの28×28画像から、数字を線化処理し、8×8の2値画像に8段階の角度情報加えた8×8×8のデータで、512x1000x10の3層ニューラルネットワークを作り、自分の手書き数字をさせました。
結果、前回6~8割くらいだった正解率が9割くらいになりました。
追記:畳み込みニューラルネットワークもやってみたら更に高い正解率でした。
(ソースコードは記事の最後にあります)
環境:Windows10、Python3.8
プログラムの概要
ニューロンネットワークの構造は、入力層512個、中間層1000個、出力層10個です。
入力データは8×8の座標に対し、線の角度を8分割した8チャンネルとした8×8×8=512の一次行列で、線化された数字の線が8×8分割のある格子内にある時、その格子内の線の角度に応じたデータを1(プログラム上は255)とします。
イメージとして分かりやすくするために、角度を3分割までにして、カラー表示させるとこんな感じ。
具体的な処理は、以下のような流れです。
- 画像をスケルトン化および単純化(近似化)して、ポリラインデータ(点のリスト)に変換
- 線化した文字が95%くらいの大きさになるようにサイズおよび位置を調整
- 8×8の格子状の線データを作成し、ポリラインデータを格子と交差するところで線を分割
- 線毎に位置(どの格子内か)と角度を算出して、該当のデータに255を代入
細線化(スケルトン化)
細線化する処理をスケルトン化と呼ぶらしいです。Scikit-Imageという画像処理ライブラリにスケルトン化の機能があります。
Scikit-Imageでスケルトン化したラスタ画像をベクタ化するのに、Skeleton Network(sknw.py)というモジュールを使います。sknwにはnumba(高速化モジュール)を使用しているので、numbaもインストールします。
Scikit-Imageのインストール
pip install scikit-image
Skeleton Networkのインストール
他で使わないのであれば、sknw.pyを同フォルダにコピーするだけです。
Skeleton Network
https://github.com/Image-Py/sknw
Numbaのインストール
pip install numba
Python – ラスター画像からベクター画像への変換について
https://teratail.com/questions/244128
ちなみにOpenCVでも細線化の機能はあるのですが、ベクター化(ポリライン化)する方法がわからず、この方法を使いました。
データセットの作成
詰まると思っていたPyTorchの学習データセットの作成ですが、良質な記事のおかげか、そこまで詰まることなくできました。
pyTorchのtransforms,Datasets,Dataloaderの説明と自作Datasetの作成と使用
https://qiita.com/mathlive/items/2a512831878b8018db02
手書き数字の認識結果
まずはMNISTのtestingデータで正解率を測ってみます。
0:99.18 % 1:97.62 % 2:96.41 % 3:92.18 % 4:94.60 % 5:94.96 % 6:95.30 % 7:92.02 % 8:85.01 % 9:84.74 % total: 93.24 %
全体の正解率は前回より落ちてしまいました。
Accuracyはまだ上がり調子なので、学習回数が足りていないのかもしれません。
さて、自分の手書き数字は?
0( 90%): 0 0 0 6 0 0 0 0 0 0 1( 80%): 1 1 9 2 1 1 1 1 1 1 2(100%): 2 2 2 2 2 2 2 2 2 2 3( 90%): 3 3 3 3 3 3 3 3 3 2 4( 80%): 4 9 4 4 9 4 4 4 4 4 5(100%): 5 5 5 5 5 5 5 5 5 5 6(100%): 6 6 6 6 6 6 6 6 6 6 7(100%): 7 7 7 7 7 7 7 7 7 7 8(100%): 8 8 8 8 8 8 8 8 8 8 9( 80%): 7 9 9 9 9 9 9 9 7 9 total: 92.00 %
おお、NMISTのデータとほぼ変わらない正解率となりました。嬉しい!
文字サイズを80%に調整した画像データを使うと正解率97%。
0(100%): 0 0 0 0 0 0 0 0 0 0 1( 90%): 1 1 1 6 1 1 1 1 1 1 2(100%): 2 2 2 2 2 2 2 2 2 2 3( 90%): 3 3 3 3 3 3 3 3 3 7 4(100%): 4 4 4 4 4 4 4 4 4 4 5(100%): 5 5 5 5 5 5 5 5 5 5 6(100%): 6 6 6 6 6 6 6 6 6 6 7(100%): 7 7 7 7 7 7 7 7 7 7 8( 90%): 8 8 1 8 8 8 8 8 8 8 9(100%): 9 9 9 9 9 9 9 9 9 9 total: 97.00 %
まったく処理していない画像で正解率89%でした。
線化処理の過程で2値化や文字サイズ・位置の調整を行っているので、画像の前処理による差はでないはずっと思ってたんですけど、たまたま、ですかね。それでも、前回の時ほどデリケートではなく、9割くらいの正解率になっています。
正直、この正解率の向上が、角度情報が良かったのか、線化したことで線の太さのばらつきが軽減されたからなのか、数字の位置・サイズの調整を行ったからなのか、よく分かりません。
分かろうとするなら、色々なパターンを試す必要があるので、泥沼にはまりそうな気がします・・・。多分、ここから正解率95%くらいまでもっていくのも大変なんだろうなぁ。ということで、この辺でやめときます。
ソースは以下です。計算速度は全く考慮していません(せっかくnumpyの配列なのに普通にforループしてます)。
# -*- coding: utf-8 -*- import os,random import torch import torchvision import torch.nn.functional as f from torch.utils.data import DataLoader from torchvision import datasets, transforms import matplotlib.pyplot as plt import skeletonize #NN info im_width = 8 im_height = 8 im_channel = 8 im_datanum = im_width*im_height*im_channel class MyNet(torch.nn.Module): def __init__(self): super(MyNet, self).__init__() self.fc1 = torch.nn.Linear(im_datanum, 1000) self.fc2 = torch.nn.Linear(1000, 10) def forward(self, x): x = self.fc1(x) x = torch.sigmoid(x) x = self.fc2(x) return f.log_softmax(x, dim=1) class Mydatasets(torch.utils.data.Dataset): def __init__(self, data, labels, transform = None): self.transform = transform self.data = data self.label = labels self.datanum = len(self.label) print('datanum',self.datanum) def __len__(self): return self.datanum def __getitem__(self, idx): out_data = self.data[idx] out_label = self.label[idx] if self.transform: out_data = self.transform(out_data) return out_data, out_label def training_mnist(): # 学習回数 epoch = 20 batch = 100 # 学習結果の保存用 history = { 'train_loss': [], 'test_loss': [], 'test_acc': [], } # ネットワークを構築 net: torch.nn.Module = MyNet() # MNISTのデータローダーを取得 train_loader = mnist_loader('S:/Temp/mnist_png/training',0,batch) test_loader = mnist_loader('S:/Temp/mnist_png/testing',0,batch) optimizer = torch.optim.Adam(params=net.parameters(), lr=0.001) for e in range(epoch): """ Training Part""" loss = None # 学習開始 (再開) net.train(True) for i, (data, target) in enumerate(train_loader): # 1次元化 data = data.view(batch,im_datanum) optimizer.zero_grad() output = net(data) loss = f.nll_loss(output, target) loss.backward() optimizer.step() if i % 10 == 0: print('Training log: {} epoch ({} / 60000 train. data). Loss: {}'.format(e+1, (i+1)*batch, loss.item()) ) history['train_loss'].append(loss) """ Test Part """ # 学習のストップ net.eval() test_loss = 0 correct = 0 with torch.no_grad(): for data, target in test_loader: data = data.view(-1,im_datanum) output = net(data) test_loss += f.nll_loss(output, target, reduction='sum').item() pred = output.argmax(dim=1, keepdim=True) correct += pred.eq(target.view_as(pred)).sum().item() test_loss /= 10000 print('Test loss (avg): {}, Accuracy: {}'.format(test_loss, correct / 10000)) history['test_loss'].append(test_loss) history['test_acc'].append(correct / 10000) #モデルの保存 torch.save(net.state_dict(), 'my_nn_model.pth') # 結果の出力と描画 print(history) plt.figure() plt.plot(range(1, epoch+1), history['train_loss'], label='train_loss') plt.plot(range(1, epoch+1), history['test_loss'], label='test_loss') plt.xlabel('epoch') plt.legend() plt.savefig('loss.png') plt.figure() plt.plot(range(1, epoch+1), history['test_acc']) plt.title('test accuracy') plt.xlabel('epoch') plt.savefig('test_acc.png') def mnist_loader(datapaths, sampling=0, batch=100): labels = [] datalist = [] for i in range(10): path = os.path.join(dirpath,str(i)) flist = [f for f in os.listdir(path) if os.path.isfile(os.path.join(path,f))] if sampling: flist = random.sample(flist,sampling) datapaths = [] for f in enumerate(flist): fp = os.path.join(path,f) data = skeletonize.create_data(fp,size=(im_width,im_height), channel=im_channel,invert=True) datalist.append(data) labels.append(i) if i % 100 == 0: print('creating data n='+str(len(labels))) trans = torchvision.transforms.ToTensor() dataset = Mydatasets(datalist,labels,trans) loader = torch.utils.data.DataLoader(dataset,batch,shuffle=True) return loader def create_tensor(im_path,im_invert=False): data = skeletonize.create_data(im_path,invert=im_invert) transform=transforms.Compose([transforms.ToTensor()]) data = transform(data) data = data.view(-1, im_datanum) return data def prediction_single(im_path, im_invert=True): net: torch.nn.Module = MyNet() net.load_state_dict(torch.load('my_nn_model.pth')) net = net.eval() data = create_tensor(im_path,invert=invert) output = net(data) _, predict = torch.max(output, 1) print('result=' + str(predict[0].item())) def test_prediction(): net = MyNet() net.load_state_dict(torch.load('my_nn_model.pth')) net = net.eval() path = './img_src/' total_n = total_c = 0.0 for i in range(10): files = os.listdir(path) flist = [f for f in files if os.path.isfile(os.path.join(path, f))] n = c = 0 result = '' for j in range(10): f = str(i)+'-'+str(j)+'.png' filepath = os.path.join(path,f) data = create_tensor(filepath) output = net(data) _, prediction = torch.max(output, 1) # 結果を出力 re = str(prediction[0].item()) if str(i) == re: c += 1 n += 1 result += re+' ' per = float(c)/float(n)*100 total_c += c total_n += n print('%d(%d%%): %s' % (i,per,result)) per = total_c/total_n*100 print('total: %0.2f %%' % per) def main(): #training_mnist() test_prediction() if __name__ == '__main__': main()
[skeletonize.py]
#!/usr/bin/env python # -*- coding: utf-8 -*- import os,math import cv2 import numpy as np from skimage.morphology import skeletonize import sknw def img_to_polylines(im_path,invert=True): img = cv2.imread(im_path) #2値化 img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if invert: img = cv2.bitwise_not(img) img = cv2.GaussianBlur(img,(5,5),0) ret,img = cv2.threshold(img,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU) #細線化(スケルトン化) ske = skeletonize(~(img != 0)) ske_view = (ske * 255).astype(np.uint8) ske_view = cv2.cvtColor(ske_view, cv2.COLOR_GRAY2RGB) ske_view = cv2.bitwise_not(ske_view) graph = sknw.build_sknw(ske.astype(np.uint16), multi=True) #ポリラインの正規化 xmin = ymin = float('inf') xmax = ymax = 0.0 for (s,e) in graph.edges(): for g in graph[s][e].values(): for y,x in g['pts'].tolist(): if x < xmin: xmin = x if x > xmax: xmax = x if y < ymin: ymin = y if y > ymax: ymax = y width = max(abs(xmax-xmin),abs(ymax-ymin))*1.05 xshift = (width-(xmax-xmin))/2 yshift = (width-(ymax-ymin))/2 polylines = [] for (s,e) in graph.edges(): for g in graph[s][e].values(): pts = [] for y,x in g['pts'].tolist(): x = float(x-xmin+xshift)/width y = float(y-ymin+yshift)/width pts.append((x,y)) polylines.append(pts) #パスの簡略化 new_polylines = [] for pts in polylines: pts = np.array(pts, np.float32) epsilon = 0.02 #*cv2.arcLength(pts,False) approx = cv2.approxPolyDP(pts,epsilon,False) pts = [] for pt in approx.tolist(): pts.append(pt[0]) new_polylines.append(pts) polylines = new_polylines return polylines def split_lines(line,lattice_lines): split_lines = [] split_lines.append(line) while True: has_crosspoint = False is_reset = False for line0 in split_lines: p0,p1 = line0 for line1 in lattice_lines: p2,p3 = line1 cp = cross_point(p0,p1,p2,p3) if not cp: continue cp = cp[:2] d0 = distance(p0,cp) d1 = distance(p1,cp) if d0<0.000001 or d1<0.000001: continue split_lines.remove(line0) split_lines.append((p0,cp)) split_lines.append((cp,p1)) has_crosspoint = True is_reset = True break if is_reset: break if not has_crosspoint: break return split_lines def distance(p0,p1): d = (p1[0]-p0[0])**2+(p1[1]-p0[1])**2 d = d**0.5 return d def cross_point(p0,p1,p2,p3): d = float((p1[0]-p0[0])*(p3[1]-p2[1])-(p1[1]-p0[1])*(p3[0]-p2[0])) if d == 0: return False ac = (p2[0]-p0[0],p2[1]-p0[1]) t0 = ((p3[1]-p2[1])*ac[0] - (p3[0]-p2[0])*ac[1]) / d t1 = ((p1[1]-p0[1])*ac[0] - (p1[0]-p0[0])*ac[1]) / d if t0 < 0 or 1 < t0: return False if t1 < 0 or 1 < t1: return False x = p0[0] + t0*(p1[0] - p0[0]) y = p0[1] + t0*(p1[1] - p0[1]) p = [x,y] return p def create_data(img_path,size=(8,8),channel=8,invert=False): #格子線作成 lattice_lines = [] for i in range(size[0]): x = i * 1.0 / size[0] line = ((x ,0.0),(x, 1.0)) lattice_lines.append(line) for j in range(size[1]): y = j * 1.0 / size[1] line = ((0.0, y),((1.0),y)) lattice_lines.append(line) #格子線で分割 lines = [] polylines = img_to_polylines(img_path,invert) for polyline in polylines: p0 = polyline.pop(0) for p1 in polyline: line = (p0,p1) lines += split_lines(line,lattice_lines) p0 = p1 #位置、角度計算:データ作成 data = np.zeros((size[1],size[0],channel),np.uint8) for line in lines: p0,p1 = line x = (p0[0]+p1[0])/2 y = (p0[1]+p1[1])/2 r = math.degrees(math.atan2(p1[1]-p0[1],p1[0]-p0[0])) if r < 0: r += 180 r = r *0.99 i = math.floor(x/(1.0/size[0])) j = math.floor(y/(1.0/size[1])) k = math.floor(r/(180.0/channel)) data[j][i][k] = 255 return data #for debug imsize = 300 img = np.ones((imsize, imsize, 3))*255 img = draw_lines(img,lines,imsize) img = draw_lines(img,lattice_lines,imsize) show(img) return data def show_polylines(polylines): #線画の作成 imsize = 100 img = np.ones((imsize, imsize, 3))*255 n = 1 for pts in polylines: _p = pts.pop(0) x = int(_p[0]*imsize) y = int(_p[1]*imsize) img = cv2.circle(img,(x,y), 3, (0,0,255), -1) for p in pts: x0 = int(_p[0]*imsize) y0 = int(_p[1]*imsize) x1 = int(p[0]*imsize) y1 = int(p[1]*imsize) cv2.line(img,(x0,y0),(x1,y1),(0,0,0),1) _p = p n += 1 show(img) def draw_lines(img,lines,imsize = 100): n = 1 for line in lines: p0,p1 = line x0 = int(p0[0]*imsize) y0 = int(p0[1]*imsize) x1 = int(p1[0]*imsize) y1 = int(p1[1]*imsize) img = cv2.line(img,(x0,y0),(x1,y1),(0,0,0),1) img = cv2.circle(img,(x0,y0), 3, (0,0,255), -1) img = cv2.circle(img,(x1,y1), 2, (255,0,0), -1) return img def show(img): cv2.imshow('test',img) cv2.waitKey(0) cv2.destroyAllWindows() def test_single_img(img_path,invert=True): data = create_data(img_path,(8,8),3,invert) img = cv2.resize(data,(300,300),interpolation=cv2.INTER_NEAREST) show(img) def main(): test_single_img('img2/7-0.png',invert=False) #test_single_img(r'S:\Temp\mnist_png\testing\1\1673.png') if __name__ == '__main__': main()