手写数字的分割和识别

前言

在机器学习领域,手写数字数据集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训练一个模型来预测手写数字,最后也简要谈了预测模型的特征工程,预测数据的格式一定要贴合训练的数据,不然会照成很大的误差。这就是通过机器学习来系统性的解决问题的思路或方法了。