引言
本文基于OpenCV库和SVM算法,将MNIST用作训练集,在白板中绘制任意个数的手写数字,并对每个包含手写数字的图像进行处理作为测试集,预测图像中的数字并顺序输出。
本文记录了手写数字图像处理和机器学习图像识别的完整流程,可供初学者参考,其中还有许多可以改进的地方,欢迎各位提出意见和建议。
结果展示
识别结果:123
识别结果:67899876
识别结果:5201314
需要用到的库
import numpy as np
import matplotlib.pyplot as plt
import datetime as dt
import cv2
from sklearn.preprocessing import MinMaxScaler
from sklearn.svm import NuSVC
import torch
from torchvision import datasets
from torchvision.transforms import ToTensor
画图函数
def myplot(arr):
plt.imshow(arr, cmap='gray', vmin=0, vmax=255)
plt.xticks(ticks=[])
plt.yticks(ticks=[])
plt.show()
plt.close()
准备训练数据集
手写数字训练集选择MNIST,该数据集来自美国国家标准与技术研究所,手写数字图像的像素大小为28*28,特征数为784。Torchvision. datasets中包含了大量用于机器学习模型训练的图像数据集,通过datasets.MNIST()方法下载/导入该数据集。由于MNIST训练集和测试集的样本数均较大,这里加载测试集的10000幅图像即可。
test_data = datasets.MNIST(root='data', train=False, download=True, transform=ToTensor())
![](https://i-blog.csdnimg.cn/direct/b1c9aa213ba54fc69d7c35c3303af473.png)
下载后测试集数据和标签的文件名分别为“t10k-images-idx3-ubyte”、“t10k-labels-idx1-ubyte”,将test_data中的数据和标签均另存为npy文件,以我们熟悉的numpy ndarray类型进行读取。
Xdata = np.empty(shape=(10000, 28 * 28), dtype=np.float16)
Ydata = np.empty(shape=10000, dtype=np.uint8)
for idx, vari in enumerate(test_data):
arr = vari[0].numpy()
Xdata[idx, :] = arr.ravel()
Ydata[idx] = vari[1]
np.save("./Dataset/Xdata.npy", Xdata)
np.save("./Dataset/Ydata.npy", Ydata)
准备测试数据
打开windows自带的画图程序或其他绘图软件,在画板中绘制手写数字,画笔选择记号笔,笔的线条不宜太粗。此外,数字之间应保持一定的间隔,以便提取单个数字的轮廓。
图像预处理
在白板中绘制好手写数字后,需要对每个手写数字图像进行预处理,以得到结构化的模型输入数据。本文中图像处理采用OpenCV库实现,它是一个开源的计算机视觉库,被广泛应用于图像处理、图像分割、图像识别等场景。
加载图像
首先使用imread()函数读取含有手写数字的图像,cv2.IMREAD_GRAYSCALE表示以灰度形式加载图像(范围0~255),函数返回一个二维数组表示灰度值。当选择cv2.IMREAD_COLOR参数时,函数将返回3个二维数组,分别用于R、G、B3个通道。将图像的灰度值进行反转,使得图像中背景的灰度值设为0,与训练集图像保持一致。
image = cv2.imread(figpath, cv2.IMREAD_GRAYSCALE)
image = 255 - image
![](https://i-blog.csdnimg.cn/direct/9fe5ce6bc0754ffca5187e78d078c5d0.png)
![](https://i-blog.csdnimg.cn/direct/c5e22f827a2846f993d50cd7d059bffb.png)
图像二值化
由于一幅图像中含有多个手写数字,需要将其自动裁剪为只含有单个数字的图像,才能用于分类过程中的测试集数据。findContours()方法用于提取图像中物体的轮廓/边缘,它返回的contours参数中含有物体轮廓在图像中的坐标,是实现单个数字提取的关键函数。
首先需要将图像进行二值化,threshold()函数对图像进行阈值分割并创建二值化图像,阈值处理将图像前景像素与背景像素分开。该函数的主要参数有阈值(thresh)、最大像素值(maxval)、阈值类型,函数第一个返回值为retval,第二个返回值为阈值分割后的像元数组。阈值类型选择cv2.THRESH_BINARY,该参数的阈值分割结果dst(x,y) = maxval if src(x,y)>thresh 0 otherwise,即二值化的阈值分割,小于阈值的像素都设置为零,其余为最大像素值。
ret, binary = cv2.threshold(image, 50, 255, cv2.THRESH_BINARY)
![](https://i-blog.csdnimg.cn/direct/061c8ff20a4642d3ba482a227f22f74e.png)
获取图像轮廓
使用findContours()方法识别二值图像中手写数字的轮廓,该方法需设置两个关键参数:轮廓检索模式(mode)和轮廓近似法(method),本文选择的轮廓检索模式为cv2.RETR_EXTERNAL,该选项仅检索手写数字最外层的轮廓,轮廓近似法选择cv2.CHAIN_APPROX_SIMPLE,它将压缩轮廓的水平、垂直和对角线段,只保留轮廓的端点。函数返回contours和hierarchy两个参数,contours以点集的形式记录轮廓的xy坐标位置,hierarchy记录了contours数组的索引编号,可反映轮廓之间的层次信息。绘制图像轮廓使用drawContours()方法,参考OpenCV中的轮廓 (apachecn.github.io)。
contours, hierarchy = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
得到手写数字的轮廓后,使用boundingRect()方法计算轮廓的外接矩形,函数返回x、y、w、h,(x,y)为外接矩形的左上角坐标,w和h分别为矩形的宽和高,利用外接矩形的坐标信息裁剪原始图像,得到只含有单个数字的图像。
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
image_clip = image[y:y + h, x:x + w]
![](https://i-blog.csdnimg.cn/direct/116d99e9dcd845f58ba31000eec010f0.png)
扩充图像边界
此时我们已经自动裁剪出每个手写数字的图像,但仍与MNIST数据集中的原始数据存在差异,数字的外轮廓与图像边缘是存在一定距离的,因此需要用到copyMakeBorder()函数扩充图像的边界,该函数需定义上、下、左、右四个方向上扩充的宽度,以及边界类型(borderType),边界类型选择cv2.BORDER_CONSTANT,以常量填充整个边界(还需指定填充的颜色value),图像边缘四个方向上需要扩充的宽度由用户自行定义,在这里变量edge_h和edge_w分别定义了图像垂直和水平方向上需扩充的宽度。
edge_h = int(h * 0.25)
edge_w = (h + edge_h + edge_h - w) * 0.5
if edge_w < edge_h:
edge_w = edge_h * 2
else:
edge_w = int(edge_w * 0.9)
constant = cv2.copyMakeBorder(image_clip, edge_h, edge_h, edge_w, edge_w, cv2.BORDER_CONSTANT, value=(0, 0, 255))
![](https://i-blog.csdnimg.cn/direct/b04d75b439ad473ba238a741ddebdec0.png)
形态学操作(可选)
在对图像进行重采样之前,还需对数字线条宽度进行处理。由于我们在屏幕中绘制的手写数字线条粗细是任意的,数字线条过粗或过细可能会影响模型预测的准确率,因此可对图像进行形态学操作,当线条过粗时选择腐蚀操作,当线条过细时选择膨胀操作。腐蚀操作将侵蚀前景对象的边界(前景为白色),当内核中所有像素为1时,当前像素设为1,反之被侵蚀(设为0)。膨胀操作将增加前景对象的大小,当内核中至少有一个像素为1,则当前像素设为1。腐蚀函数为erode(),膨胀函数为dilate(),二者需设置内核大小(kernel)和迭代次数(iterations)。
kernel = np.ones((10, 10))
imerode = cv2.erode(constant, kernel, iterations=1)
![](https://i-blog.csdnimg.cn/direct/223c281a379e4b4da09e82f4d7fa32fe.png)
![](https://i-blog.csdnimg.cn/direct/deae3dcfc65c42d7985e0a1454a1ed95.png)
![](https://i-blog.csdnimg.cn/direct/05e7d8efa49444bb8c3642841670a2be.png)
kernel = np.ones((10, 10))
imdilate = cv2.dilate(constant, kernel, iterations=1)
图像重采样
最后将处理好的图像进行重采样,使其具有与MNIST数据集同样的分辨率(28*28),用到的函数为resize(),指定缩放后的图像分辨率,插值方法(interpolation)选择cv2.INTER_AREA。
imresize = cv2.resize(imdilate, (28, 28), interpolation=cv2.INTER_AREA)
![](https://i-blog.csdnimg.cn/direct/44956a5492c74d04817590cc2eae60ea.png)
支持向量机训练和测试
本文使用scikit-learn库中的NuSVC类对手写数字进行训练和预测,scikit-learn是一个开源的机器学习库,它支持数十种监督和无监督机器学习的算法和模型,它们被称为估算器(estimators),并提供了机器学习过程中所需要的模型拟合、数据预处理、模型选择、模型评估等多种工具。
scikit-learn中的支持向量机分类器有SVC、NuSVC、LinearSVC三种,SVC和NuSVC的原理相似,区别在于NuSVC引入了新参数nu用于控制支持向量的数量,以下是NuSVC分类器的参数,含义详见sklearn.svm.NuSVC-scikit-learn中文社区。
class sklearn.svm.NuSVC(*, nu=0.5, kernel='rbf', degree=3, gamma='scale', coef0=0.0, shrinking=True, probability=False, tol=0.001, cache_size=200, class_weight=None, verbose=False, max_iter=-1, decision_function_shape='ovr', break_ties=False, random_state=None)
首先导入准备好的模型训练数据。
Xdata = np.load("./Dataset/Xdata.npy")
Ydata = np.load("./Dataset/Ydata.npy")
初始化一个支持向量机分类器,优化好参数,nu=0.02,gamma=0.02(参考数字识别,从KNN,LR,SVM,RF到深度学习),由于NuSVC训练10000组数据时耗时明显增加,这里训练7000组数据即可。
clf = NuSVC(nu=0.02, kernel='rbf', gamma=0.02)
clf.fit(Xdata[0:7000, :], Ydata[0:7000])
在模型预测之前,对测试集数据进行归一化处理,使用MinMaxScaler()类将输入数据缩放到给定区间内,参数feature_range=(0,1)表示缩放后的数据大小在0和1之间。使用fit_transform()方法将其应用在测试集上。
scaler = MinMaxScaler(feature_range=(0, 1))
arr = scaler.fit_transform(imresize)
使用reshape()函数更改测试图像数组的形状,预测输入图像所对应的数字,展示模型预测的结果,验证支持向量机算法预测的数字是否与我们所写数字一致。
prediction = clf.predict(arr.reshape(1, -1))
print(prediction[0])
完整代码
import numpy as np
import matplotlib.pyplot as plt
import datetime as dt
import cv2
from sklearn.preprocessing import MinMaxScaler
from sklearn.svm import NuSVC
def feature_extra(figpath):
image = cv2.imread(figpath, cv2.IMREAD_GRAYSCALE)
image = 255 - image
ret, binary = cv2.threshold(image, 50, 255, cv2.THRESH_BINARY)
contours, hierarchy = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
digits = []
positions = []
if len(contours) != 0:
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
# 变量positions:对图像中数字所在位置的简单排序,可能失效
positions.append(x + y * 3)
image_clip = image[y:y + h, x:x + w]
edge_h = int(h * 0.25)
edge_w = (h + edge_h + edge_h - w) * 0.5
if edge_w < edge_h:
edge_w = edge_h * 2
else:
edge_w = int(edge_w * 0.9)
constant = cv2.copyMakeBorder(image_clip, edge_h, edge_h, edge_w, edge_w, cv2.BORDER_CONSTANT,
value=(0, 0, 255))
kernel = np.ones((10, 10))
imdilate = cv2.dilate(constant, kernel, iterations=1)
imresize = cv2.resize(imdilate, (28, 28), interpolation=cv2.INTER_AREA)
digits.append(imresize)
return digits, positions
def digitsCLF(digits, positions):
Xdata = np.load("./Dataset/Xdata.npy")
Ydata = np.load("./Dataset/Ydata.npy")
clf = NuSVC(nu=0.02, kernel='rbf', gamma=0.02)
clf.fit(Xdata[0:7000, :], Ydata[0:7000])
results = []
for digit in digits:
scaler = MinMaxScaler(feature_range=(0, 1))
arr = scaler.fit_transform(digit)
prediction = clf.predict(arr.reshape(1, -1))
results.append(int(prediction[0]))
orders = np.argsort(positions)
str_digit = ' '
for i in orders:
vari = results[i]
str_digit = str_digit + str(vari)
print(f'The handwritten digits are:{str_digit}')
if __name__ == '__main__':
figpath = "./Figures/Fig (1).png"
dt1 = dt.datetime.now()
digit, position = feature_extra(figpath)
if len(position) == 0:
print('Recognition failed!')
else:
digitsCLF(digit, position)
print(f'Duration: {dt.datetime.now() - dt1}')