介绍

为了实现对手写数字串的识别,本次用到了opencv+mnist技术,使用opencv能更好的处理图片,分割图片,使用mnist数据集进行预测识别率大概达到96%左右,但真实测试的时候识别率要低一些,因为mnist训练的数据集都是外国人写的数字,而国人写的数字会跟外国人有些不同,所以识别率低一点也是在预期之内

准备

先导入cv2 ,numpy 和一些自定义的参数

import cv2
import numpy as np
import test_digit
img_size = 28  # 28*28是mnist的图片训练集尺寸
kernel_connect = np.array([[1, 1, 1], [1, 0, 1], [1, 1, 1]], np.uint8)  # 膨胀化用的参数
ans = []  # 保存图片数组

因为数字的训练和测试输入均为二值像素(黑色为0,白色为255或1),所以我们要对识别的图像进行阈值处理即可,即低于阈值的均设为0,高于阈值的均设为255。由于我们的输入图像为白底黑字,矫正后的A4纸的部分黑色边缘需裁减(crop),避免被误认为黑色文字。又由于MNIST中用白色表示数字,黑色表示背景,要将我们图像中的黑白色反转,即低于阈值的均设为255,高于阈值的均设为0。对大部分图像而言,阈值为127可区分出文字和背景。值得注意的是,由于文字不是全黑(边缘为灰色),有时候会和纸张的阴影混淆。阈值越高,保留的灰色越多,但可能也将背景的阴影保留了下来;阈值越低,保留的灰色越少,但可能使文字断裂现象比较严重。因此阈值的选取不能一概而论,需根据图像的颜色和文字的粗细等情况选取。

预处理代码

if __name__ == '__main__':
    img = cv2.imread('7.png')
    img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)# 设置为灰度图
    ret, thresh_img = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY_INV) # 图像二值化
    show(thresh_img) #显示图片
    split_digits(thresh_img, "split_img/split_img")

以下图为例,
在这里插入图片描述

二值化后的图片:
在这里插入图片描述

对数字串进行分割

以10.jpg的数字串20220511为例:
在这里插入图片描述

对每一个数字串,如何识别出单个的数字。可用OpenCV的findcounter函数来找出每个数字。但前提是数字内部不断裂,数字之间不粘连。数字断裂的问题再次可用膨胀解决,同样要注意膨胀的方向,避免将相邻的数字连接起来。粘连的问题业界有“滴水算法”,即利用同一个数字的笔画如流水般自然的趋势找出一个数字内的笔画。简单的方法可用等距切割,适用于大小均匀分布的手写体数字。

找出单个数字之后需要缩放成2828像素以适应MNIST数据集。此处缩放的策略非常关键。若不考虑原数字的宽高比直接resize成2828,数字会变形失真。

在这里插入图片描述
可以看到诸如数字1这种过胖或过瘦的变形严重,其他的虽然肉眼可辨别,但是对于SVM这种基于模板和神经网络这种基于数据的方法会产生较大的误差。
正确的策略是先按原数字宽高比缩放,空缺的地方用背景色(黑色)填充(padding)。我采用的是将原数字的bounding box居中,上下/左右均匀padding。参考自MNIST官网:

With some classification methods (particuarly template-based methods, such as SVM and K-nearest neighbors), the error rate improves when the digits are centered by bounding box rather than center of mass.

另外还需注意的是最后需要给数字整体进行padding,使得28*28像素图像中边缘约4个像素内是背景(黑色),这是通过观察MNIST数据集得知的,同样是为了使自制的数据集尽量和和使用的训练集相似。
最后需要注意的是按顺序读取数字。通过实践观察知,cv2.findContours在图像中从下到上查找,因此我们需要先旋转图片到纵向书写方向,首个数字在图片下方的方向,并在找到连通域后恢复为水平方向和MNIST数据集一致。

整体代码如下:
main.py:

import cv2
import numpy as np
import test_digit

img_size = 28  # 28*28是mnist的图片训练集尺寸
kernel_connect = np.array([[1, 1, 1], [1, 0, 1], [1, 1, 1]], np.uint8)  # 膨胀化用的参数
ans = []  # 保存图片数组


def show(name):
    cv2.imshow("123", name)
    cv2.waitKey(0)


def split_digits(s, prefix_name):
    s = np.rot90(s)  # 使图片逆时针旋转90°
    # show(s)
    s_copy = cv2.dilate(s, kernel_connect, iterations=1)
    s_copy2 = s_copy.copy()
    contours, hierarchy = cv2.findContours(s_copy2, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)  # 该函数可以检测出图片中物品的轮廓
    # contours:list结构,列表中每个元素代表一个边沿信息。每个元素是(x, 1, 2)的三维向量,x表示该条边沿里共有多少个像素点,第三维的那个“2”表示每个点的横、纵坐标;
    # hierarchy:返回类型是(x, 4)的二维ndarray。x和contours里的x是一样的意思。如果输入选择cv2.RETR_TREE,则以树形结构组织输出,hierarchy的四列分别对应下一个轮廓编号、上一个轮廓编号、父轮廓编号、子轮廓编号,该值为负数表示没有对应项。

    # for it in contours:
    #     print(it)
    # print("##########################")

    idx = 0
    for contour in contours:
        idx = idx + 1
        [x, y, w, h] = cv2.boundingRect(contour)  # 当得到对象轮廓后,可用boundingRect()得到包覆此轮廓的最小正矩形,
        # show(cv2.boundingRect(contour))
        digit = s_copy[y:y + h, x:x + w]
        # show(digit)
        pad_len = (h - w) // 2
        # print(pad_len)
        if pad_len > 0:
            digit = cv2.copyMakeBorder(digit, 0, 0, pad_len, pad_len, cv2.BORDER_CONSTANT,value=0)
        elif pad_len < 0:
            digit = cv2.copyMakeBorder(digit, -pad_len, -pad_len, 0, 0, cv2.BORDER_CONSTANT, value=0)

        pad = digit.shape[0] // 4  # 避免数字与边框直接相连,留出4个像素左右。
        digit = cv2.copyMakeBorder(digit, pad, pad, pad, pad, cv2.BORDER_CONSTANT, value=0)
        digit = cv2.resize(digit, (img_size, img_size), interpolation=cv2.INTER_AREA)  # 把图片缩放至28*28
        digit = np.rot90(digit, 3)  # 逆时针旋转270°将原本图片旋转为原来的水平方向
        # show(digit)
        cv2.imwrite(prefix_name + str(idx) + '.jpg', digit)
        ans.append(digit)
    test_digit.dj(ans)


if __name__ == '__main__':
    img = cv2.imread('7.png')
    img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    ret, thresh_img = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY_INV)
    show(thresh_img)
    split_digits(thresh_img, "split_img/split_img")


识别数字:

当分割完全部数字时,可以将分割后的图像存到ans数组里,接着调用test_digit.dj(ans),进行识别
在test_digit.py中我们通过q()函数先训练下keras中的mnist的数据集:
keras.mnist文档
test_digit .py:

def q():  # 直接使用Keras载入的训练数据(60000, 28, 28) (60000,)
    (train_images, train_labels), (test_images, test_labels) = mnist.load_data()
    # 变换数据的形状并归一化
    train_images = train_images.reshape(train_images.shape[0], -1)  # (60000, 784)
    train_images = train_images.astype('float32') / 255

    test_images = test_images.reshape(test_images.shape[0], -1)
    test_images = test_images.astype('float32') / 255

    # 将标签数据转为float32
    train_labels = train_labels.astype(np.float32)
    test_labels = test_labels.astype(np.float32)

    # 传入knn的训练数据形状为(60000, 784) 训练标签为(60000,)

    # 创建knn对象
    knn = cv2.ml.KNearest_create()
    # 设置k值 默认的k=10
    knn.setDefaultK(5)
    # 设置是分类还是回归
    knn.setIsClassifier(True)

    # 开始训练
    knn.train(train_images, cv2.ml.ROW_SAMPLE, train_labels)

    # 手写数字识别保存的knn模型非常大 有两百多兆
    knn.save('mnist_knn.xml')

训练完数据集后,我们就可以调用dj()函数进行预测。首先是加载我们我们制作好的mnist_knn.xml训练集,然后遍历img_array(这是之前传过来分割后的图片数组),将图片矩阵的整数转化为浮点数,接着将三维矩阵矩阵转化为一维矩阵,归一化,最后就可以进行预测了。

def dj(img_array):
    ans = []
    knn = cv2.ml.KNearest_load('mnist_knn.xml')   # 载入knn模型
    n = 0
    for img in img_array:
        img_sw = img.copy()
        img = img.astype(np.float32)    # 将数据类型由uint8转为float32
        # for i in img:
        #     print(i)
        img = img.reshape(1, 784)  # 将三维矩阵转换为一维矩阵
        img = img / 255  # 图片数据归一化
        img_pre = knn.predict(img)  # 进行预测
        print(img_pre)
        ans.append(int(img_pre[0]))
        cv2.imshow('test', img_sw)
        cv2.waitKey(0)
        n += 1
    print(ans)

预测后的结果:
在这里插入图片描述

参考资料

opencv-python官方文档
mnist数据集介绍
numpy教程
参考博客

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐