手寫數字的分割和識別

前言

在機器學習領域,手寫數字數據集MNIST之於機器學習幾乎相當於HelloWorld之於編程語言,其重要地位不言而已。但是,然後呢?給你一張如下所示的圖片,你的模型能否也預測出結果?(其實下面這個應用就是OCR領域的內容了,另詳細的代碼內容和註釋可以參考我的github https://github.com/Wangzg123/HandwrittenDigitRecognition
在這裏插入圖片描述
這篇博客我想從一個工程的角度談談手寫數字識別的應用,期間將涉及到

  • ① CV (computer vision)方面的知識
  • ② 用Keras編寫及導出預測手寫數字的模型
  • ③ 手寫字符的分割(提供兩個解決思路)
  • ④ 特徵工程(將自己的手寫數字轉換爲MNIST數字集的模式)
  • ⑤ 用我們編寫的模型預測出結果並輸出(如上所示的效果)

一、MNIST手寫數字預測模型

爲了要識別我們自己手寫的數字,那麼我們就要用到手寫數字數據集MNIST。MNIST在網上有很多介紹和下載方式,詳細我就不過多說明了,在Keras(一個基於TensorFlow的高級api,在今年穀歌大會上TensorFlow 2.0已經將keras作爲官方高級api了)下已經內置了MNIST,我們可以通過下面的代碼很輕鬆的導入

from keras.datasets import mnist
(train_data, train_labels), (test_data, test_labels) = mnist.load_data()
print('train_shape {} {}'.format(train_data.shape,train_labels.shape))
print('test_shape {} {}'.format(test_data.shape,test_labels.shape))
''' output: train_shape (60000, 28, 28) (60000,) test_shape (10000, 28, 28) (10000,) '''

我們挑選一下其中的一個字符來看下,他是一個28*28的灰度圖,但是要注意和現實中我們拍出來的照片不一樣的是字體是白色的背景是黑色的,那麼就意味着在預測我們自己的模型時也必須轉換爲 黑色背景、白色數字和相對居中的圖 不然的話和數據集相差太大會有很大的誤差,詳細說明見 第三部分——特徵工程。
在這裏插入圖片描述
接下來做的事情就很簡單了,定義一個keras的model,然後將0-255的灰度值轉爲0-1之間的值(均一化處理),標籤數據轉爲onehot形式,然後通過fit就可以訓練我們的模型了

from keras import models
from keras import layers
import numpy as np
from keras.utils.np_utils import to_categorical
def model_conv():
    model = models.Sequential()
    model.add(layers.Conv2D(32, (3, 3), activation='relu',input_shape=(28, 28, 1)))
    model.add(layers.MaxPooling2D((2, 2)))
    model.add(layers.Conv2D(64, (3, 3), activation='relu'))
    model.add(layers.MaxPooling2D((2, 2)))
    model.add(layers.Conv2D(64, (3, 3), activation='relu'))
    model.add(layers.Flatten())
    model.add(layers.Dense(64, activation='relu'))
    model.add(layers.Dense(10, activation='softmax'))
    model.compile(optimizer='rmsprop', loss='categorical_crossentropy', metrics=['acc'])
    return model

# 數據預處理
x_train = train_data.reshape((60000, 28, 28, 1))
x_train = x_train.astype('float32')/255
x_test = test_data.reshape((10000, 28, 28, 1))
x_test = x_test.astype('float32')/255
y_train = to_categorical(train_labels)
y_test = to_categorical(test_labels)
print(x_train.shape, y_train.shape)

# 定義模型
model = model_conv()
model.summary()
his = model.fit(x_train, y_train, epochs=5, batch_size=64, validation_split=0.1)

# 看下測試集的損失值和準確率
loss, acc = model.evaluate(x_test, y_test)
print('loss {}, acc {}'.format(loss, acc))
model.save("my_mnist_model.h5")
''' output: (60000, 28, 28, 1) (60000, 10) loss 0.02437469101352144, acc 0.9927 測試集結果是99.27%,非常不錯的模型 '''

二、字符分割

我們知道我們預測的模型是一個一個的數字,那麼給下面的圖示我們怎麼轉爲MNIST那種圖像的表示方式。這就要用到我們的字符分割的方法了,這裏面我提供兩種方法,一種是行列掃描分割,一種是opencv裏面的findContours模式。
在這裏插入圖片描述

1、行列掃描分割

平時我們寫字,通常都是一行一行的,不會出現那種很不起的情況(不齊也可以,見第二種方法)。那麼這個時候就可以用到我們的行列掃描法(我自己命名的,我也不知道叫什麼),首先我們把上面的圖片通過以下代碼 反相處理

# 反相灰度圖,將黑白閾值顛倒
def accessPiexl(img):
    height = img.shape[0]
    width = img.shape[1]
    for i in range(height):
       for j in range(width):
           img[i][j] = 255 - img[i][j]
    return img

# 反相二值化圖像
def accessBinary(img, threshold=128):
    img = accessPiexl(img)
    # 邊緣膨脹,不加也可以
    kernel = np.ones((3, 3), np.uint8)
    img = cv2.dilate(img, kernel, iterations=1)
    _, img = cv2.threshold(img, threshold, 0, cv2.THRESH_TOZERO)
    return img

path = 'test1.png'
img = cv2.imread(path, 0)
img = accessBinary(img)
cv2.imshow('accessBinary', img)
cv2.waitKey(0)

在這裏插入圖片描述
接下來我們就要進行行列掃描了,首先了解一個概念,黑色背景的像素是0,白色(其實是灰度圖是1-255的)是非0,那麼從行開始,我們計算將每一行的像素值加起來,如果都是黑色的那麼和爲0(當然可能有噪點,我們可以設置個閾值將噪點過濾),有字體的行就非0,依次類推,我們再根據這個圖來篩選邊界就可以得出行邊界值,直觀的繪製成圖像就是如下所示
在這裏插入圖片描述
在這裏插入圖片描述
有了上面的概念,我們就可以通過行列掃描,根據0 , 非0,非0 … 非0,0這樣的規律來確定行列所在的點來找出數字的邊框了

# 根據長向量找出頂點
def extractPeek(array_vals, min_vals=10, min_rect=20):
    extrackPoints = []
    startPoint = None
    endPoint = None
    for i, point in enumerate(array_vals):
        if point > min_vals and startPoint == None:
            startPoint = i
        elif point < min_vals and startPoint != None:
            endPoint = i

        if startPoint != None and endPoint != None:
            extrackPoints.append((startPoint, endPoint))
            startPoint = None
            endPoint = None

    # 剔除一些噪點
    for point in extrackPoints:
        if point[1] - point[0] < min_rect:
            extrackPoints.remove(point)
    return extrackPoints

# 尋找邊緣,返回邊框的左上角和右下角(利用直方圖尋找邊緣算法(需行對齊))
def findBorderHistogram(path):
    borders = []
    img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
    img = accessBinary(img)
    # 行掃描
    hori_vals = np.sum(img, axis=1)
    hori_points = extractPeek(hori_vals)
    # 根據每一行來掃描列
    for hori_point in hori_points:
        extractImg = img[hori_point[0]:hori_point[1], :]
        vec_vals = np.sum(extractImg, axis=0)
        vec_points = extractPeek(vec_vals, min_rect=0)
        for vect_point in vec_points:
            border = [(vect_point[0], hori_point[0]), (vect_point[1], hori_point[1])]
            borders.append(border)
    return borders
    
# 顯示結果及邊框
def showResults(path, borders, results=None):
    img = cv2.imread(path)
    # 繪製
    print(img.shape)
    for i, border in enumerate(borders):
        cv2.rectangle(img, border[0], border[1], (0, 0, 255))
        if results:
            cv2.putText(img, str(results[i]), border[0], cv2.FONT_HERSHEY_COMPLEX, 0.8, (0, 255, 0), 1)
        #cv2.circle(img, border[0], 1, (0, 255, 0), 0)
    cv2.imshow('test', img)
    cv2.waitKey(0)
    
path = 'test1.png'
borders = findBorderHistogram(path)
showResults(path, borders)

在這裏插入圖片描述
當然以上的方法不單單是數字,漢字也是可以的(同樣的代碼我沒做修改就有以下的效果,可以嘗試修改下閾值,有興趣自己去調吧,哈哈哈哈,逃~)
在這裏插入圖片描述

2、findContours模式

當然你可能會有這樣的疑惑,如果寫得不起的話這種是不是不太行了,明確告訴你,是的。如下所知同樣的手寫數字效果就不是很好,針對於行列式這種掃描方式的侷限性我們來介紹第二種方法,基於opencv的一個尋找輪廓的方法
在這裏插入圖片描述
talk is cheap,show you code,其實沒什麼好說的,就是利用OpenCV裏面的findContours函數將邊緣找出來,然後通過boundingRect將邊緣擬合成一個矩形輸出邊框的左上角和右下角,然後執行這個代碼就可以得到以下的效果了

# 尋找邊緣,返回邊框的左上角和右下角(利用cv2.findContours)
def findBorderContours(path, maxArea=50):
    img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
    img = accessBinary(img)
    _, contours, _ = cv2.findContours(img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
    borders = []
    for contour in contours:
        # 將邊緣擬合成一個邊框
        x, y, w, h = cv2.boundingRect(contour)
        if w*h > maxArea:
            border = [(x, y), (x+w, y+h)]
            borders.append(border)
    return borders
    
path = 'test2.jpg'
borders = findBorderContours(path)
print(borders)
showResults(path, borders)

在這裏插入圖片描述
同樣也不侷限與數字,漢字也是ok的,當然代碼你要去調合適的閾值纔可以,以下是我一行代碼沒改得出的結果
在這裏插入圖片描述

三、特徵工程及模型預測

在做模型預測前,我想先給你看下一個例子

1936年,美國進行總統選舉,競選的是民主黨的羅斯福和共和黨的蘭登,羅斯福是在任的總統.美國權威的《文學摘要》雜誌社,爲了預測總統候選人誰能當選,採用了大規模的模擬選舉,他們以電話簿上的地址和俱樂部成員名單上的地址發出1000萬封信,收到回信200萬封,在調查史上,樣本容量這麼大是少見的,雜誌社花費了大量的人力和物力,他們相信自己的調查統計結果,即蘭登將以57%對43%的比例獲勝,並大力進行宣傳.最後選舉結果卻是羅斯福以62%對38%的巨大優勢獲勝,連任總統.這個調查使《文學摘要》雜誌社威信掃地,不久只得關門停刊.

上面的例子說明了樣本通俗化的重要性,在機器學習中也是這樣的,我們收集訓練的數據如果和實際使用的有些偏差,那麼即使你在測試集或驗證集上表現越好也是白搭,所以我們要將我們的手寫數字的格式無限貼近MNIST這份數據集,遺憾的是我在網上找了很多,都找不出當初收集這份數據集的人是怎麼格式化數據的,所以我只能通過自己的方法來轉化我們的圖片了。(我看了一些網上網友也討論過用自己模型預測自己手寫的,不像測試集那樣準確,這個大部分也是因爲你的轉化圖和MNIST數據集格式有偏差導致的
好了,通過上面兩種方法,我們已經可以找出圖中一個個的數字了,那麼我們怎麼轉換爲MNIST那種形式呢?如下所示,我們首先將邊框轉爲28*28的正方形,因爲背景是黑色的,我們可以通過邊界填充的形式,將邊界擴充成黑色即可,其中值得注意的是
MNIST那種數據集的格式是字符相對於居中的,我們得出的又是比較準的邊框,所以爲了和數據集相對一致,我們要上下填充一點像素

# 根據邊框轉換爲MNIST格式
def transMNIST(path, borders, size=(28, 28)):
    imgData = np.zeros((len(borders), size[0], size[0], 1), dtype='uint8')
    img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
    img = accessBinary(img)
    for i, border in enumerate(borders):
        borderImg = img[border[0][1]:border[1][1], border[0][0]:border[1][0]]
        # 根據最大邊緣拓展像素
        extendPiexl = (max(borderImg.shape) - min(borderImg.shape)) // 2
        targetImg = cv2.copyMakeBorder(borderImg, 7, 7, extendPiexl + 7, extendPiexl + 7, cv2.BORDER_CONSTANT)
        targetImg = cv2.resize(targetImg, size)
        targetImg = np.expand_dims(targetImg, axis=-1)
        imgData[i] = targetImg
    return imgData

path = 'test2.jpg'
borders = findBorderContours(path)
imgData = transMNIST(path, borders)
for i, img in enumerate(imgData):
    # cv2.imshow('test', img)
    # cv2.waitKey(0)
    name = 'extract/test_' + str(i) + '.jpg'
    cv2.imwrite(name, img)

在這裏插入圖片描述在這裏插入圖片描述
接下來就是導入我們第一節寫好的預測模型,用它來預測模型即可。如下示,我們將結果也寫在圖示上來直觀的表示這個預測模型(細心的你一定發現了其中一個數字預測錯了,其中一個9預測1,這個你可以再慢慢微調,到底是重定義我們模型、混合一些我們自己的數據作爲訓練集、加一些特徵轉化或者更改我們的預測模型的格式,我就不做啦,哈哈哈,逃~)

# 預測手寫數字
def predict(modelpath, imgData):
    from keras import models
    my_mnist_model = models.load_model(modelpath)
    print(my_mnist_model.summary())
    img = imgData.astype('float32') / 255
    results = my_mnist_model.predict(img)
    result_number = []
    for result in results:
        result_number.append(np.argmax(result))
    return result_number

path = 'test2.jpg'
model = 'my_mnist_model.h5'
borders = findBorderContours(path)
imgData = transMNIST(path, borders)
results = predict(model, imgData)
showResults(path, borders, results)

在這裏插入圖片描述

總結

以上就是我們這篇博客分享的內容,更加詳細的代碼可以查看我的github https://github.com/Wangzg123/HandwrittenDigitRecognition ,其中用到分割、介紹了兩種字符分割的方法,當然現在也許你有更先進的方法,比如yolo,但是那種計算代價肯定比我這種方法高,殺雞不用牛刀,還是要看下你的案例再用貼切的方法去解決。然後基於keras訓練一個模型來預測手寫數字,最後也簡要談了預測模型的特徵工程,預測數據的格式一定要貼合訓練的數據,不然會照成很大的誤差。這就是通過機器學習來系統性的解決問題的思路或方法了。