前段时间忙了好一阵,终于有时间继续学习了,今天开始通过paddlepaddle的手写数字识别看一下简单的cnn图像识别模型是怎么训练出来的。
概述
手写识别属于典型的图像分类问题,比较简单,示例使用MNIST数据集,它包含7w个如下图所示的手写数字图片和对应的人工标注数值。图片是28×28的像素矩阵,标签则对应着0~9的10个数字。每张图片都经过了大小归一化和居中处理。
MNIST图片示例
MNIST数据集格式说明
标签集前8个字节是magic和数目,后面每个字节代表数字0-9的标签;
图像集前16字节是一些数据集信息,包括magic、图像数目、行数和列数,后面每个字节代表每个像素点,连续取出28*28个字节按顺序就可以组成28*28的图片。
模型介绍
定义:
X是输入:MNIST图片是28×28 的二维图像,为了进行计算,我们将其转化为784维向量,即X=(x0,x1,…,x783)。
Y是输出:分类器的输出是10类数字(0-9),即Y=(y0,y1,…,y9),每一维yi代表图片分类为第i类数字的概率。
L是图片的真实标签:L=(l0,l1,…,l9)也是10维,但只有一维为1,其他都为0。
Softmax回归(Softmax Regression)
最简单的Softmax回归模型是先将输入层经过一个全连接层得到的特征,然后直接通过softmax 函数进行多分类[9]。
输入层的数据X传到输出层,在激活操作之前,会乘以相应的权重 W ,并加上偏置变量 b 。
对于有 N个类别的多分类问题,指定 N 个输出节点,N 维结果向量经过softmax将归一化为 N 个[0,1]范围内的实数值,分别表示该样本属于这 N 个类别的概率。此处的 yi 即对应该图片为数字 i 的预测概率。
详细介绍请参考维基百科激活函数。
下图为softmax回归的网络图,图中权重用蓝线表示、偏置用红线表示、+1代表偏置参数的系数为1。
softmax回归网络结构图
多层感知器(Multilayer Perceptron, MLP)
Softmax回归模型采用了最简单的两层神经网络,即只有输入层和输出层,因此其拟合能力有限。为了达到更好的识别效果,我们考虑在输入层和输出层中间加上若干个隐藏层[10]。
经过第一个隐藏层,可以得到 H1=ϕ(W1X+b1),其中ϕ代表激活函数,常见的有sigmoid、tanh或ReLU等函数。
经过第二个隐藏层,可以得到 H2=ϕ(W2H1+b2)。
最后,再经过输出层,得到的Y=softmax(W3H2+b3),即为最后的分类结果向量。
下图为多层感知器的网络结构图,图中权重用蓝线表示、偏置用红线表示、+1代表偏置参数的系数为1。
多层感知器网络结构图
卷积神经网络(Convolutional Neural Network, CNN)
在多层感知器模型中,将图像展开成一维向量输入到网络中,忽略了图像的位置和结构信息,而卷积神经网络能够更好的利用图像的结构信息。LeNet-5是一个较简单的卷积神经网络。图4显示了其结构:输入的二维图像,先经过两次卷积层到池化层,再经过全连接层,最后使用softmax分类作为输出层。下面我们主要介绍卷积层和池化层。
LeNet-5卷积神经网络结构
卷积层
卷积层是卷积神经网络的核心基石。在图像识别里我们提到的卷积是二维卷积,即离散二维滤波器(也称作卷积核)与二维图像做卷积操作,简单的讲是二维滤波器滑动到二维图像上所有位置,并在每个位置上与该像素点及其领域像素点做内积。卷积操作被广泛应用与图像处理领域,不同卷积核可以提取不同的特征,例如边沿、线性、角等特征。在深层卷积神经网络中,通过卷积操作可以提取出图像低级到复杂的特征。
卷积层
卷积过程动画
上图是一个卷积计算过程的示例图,输入图像大小为H=5,W=5,D=3,即5×5大小的3通道(RGB,也称作深度)彩色图像。这个示例图中包含两(用K表示)组卷积核,即图中滤波器W0和W1。在卷积计算中,通常对不同的输入通道采用不同的卷积核,如图示例中每组卷积核包含(D=3)个3×3(用F×F表示)大小的卷积核。另外,这个示例中卷积核在图像的水平方向(W方向)和垂直方向(H方向)的滑动步长为2(用S表示);对输入图像周围各填充1(用P表示)个0,即图中输入层原始数据为蓝色部分,灰色部分是进行了大小为1的扩展,用0来进行扩展。经过卷积操作得到输出为3×3×2(用Ho×Wo×K表示)大小的特征图,即3×3大小的2通道特征图,其中Ho计算公式为:Ho=(H−F+2×P)/S+1,Wo同理。 而输出特征图中的每个像素,是每组滤波器与输入图像每个特征图的内积再求和,再加上偏置bo,偏置通常对于每个输出特征图是共享的。输出特征图o[:,:,0]中的最后一个−2计算如上图右下角公式所示。
在卷积操作中卷积核是可学习的参数,经过上面示例介绍,每层卷积的参数大小为D×F×F×K。在多层感知器模型中,神经元通常是全部连接,参数较多。而卷积层的参数较少,这也是由卷积层的主要特性即局部连接和共享权重所决定。
局部连接:每个神经元仅与输入神经元的一块区域连接,这块局部区域称作感受野(receptive field)。在图像卷积操作中,即神经元在空间维度(spatial dimension,即上图示例H和W所在的平面)是局部连接,但在深度上是全部连接。对于二维图像本身而言,也是局部像素关联较强。这种局部连接保证了学习后的过滤器能够对于局部的输入特征有最强的响应。局部连接的思想,也是受启发于生物学里面的视觉系统结构,视觉皮层的神经元就是局部接受信息的。
权重共享:计算同一个深度切片的神经元时采用的滤波器是共享的。例如图4中计算o[:,:,0]的每个每个神经元的滤波器均相同,都为W0,这样可以很大程度上减少参数。共享权重在一定程度上讲是有意义的,例如图片的底层边缘特征与特征在图中的具体位置无关。但是在一些场景中是无意的,比如输入的图片是人脸,眼睛和头发位于不同的位置,希望在不同的位置学到不同的特征 (参考斯坦福大学公开课)。请注意权重只是对于同一深度切片的神经元是共享的,在卷积层,通常采用多组卷积核提取不同特征,即对应不同深度切片的特征,不同深度切片的神经元权重是不共享。另外,偏重对同一深度切片的所有神经元都是共享的。
通过介绍卷积计算过程及其特性,可以看出卷积是线性操作,并具有平移不变性(shift-invariant),平移不变性即在图像每个位置执行相同的操作。卷积层的局部连接和权重共享使得需要学习的参数大大减小,这样也有利于训练较大卷积神经网络。
池化层
池化层
池化是非线性下采样的一种形式,主要作用是通过减少网络的参数来减小计算量,并且能够在一定程度上控制过拟合。通常在卷积层的后面会加上一个池化层。池化包括最大池化、平均池化等。其中最大池化是用不重叠的矩形框将输入层分成不同的区域,对于每个矩形框的数取最大值作为输出层,如上图所示。
更详细的关于卷积神经网络的具体知识可以参考斯坦福大学公开课和图像分类教程。
训练
注:本次学习代码使用Fluid API,一种接近Pytorch和Tensorflow的开发习惯。使用前需要先升级自己的paddlepaddle。
下边是训练过程的代码和自己的理解,有错误欢迎指正:
vim train.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# 使用fluid学习cnn图像识别手写数字
from __future__ import print_function
import os
import platform
import subprocess
from PIL import Image
import numpy
import paddle
import paddle.fluid as fluid
try:
from paddle.fluid.contrib.trainer import *
from paddle.fluid.contrib.inferencer import *
except ImportError:
print(
"In the fluid 1.0, the trainer and inferencer are moving to paddle.fluid.contrib",
file=sys.stderr)
from paddle.fluid.trainer import *
from paddle.fluid.inferencer import *
import utils
def main():
# 定义训练和测试数据batch reader
mnist_path = '/home/work/.cache/paddle/dataset/mnist/'
train_image = mnist_path + 'train-images-idx3-ubyte.gz'
train_label = mnist_path + 'train-labels-idx1-ubyte.gz'
test_image = mnist_path + 't10k-images-idx3-ubyte.gz'
test_label = mnist_path + 't10k-labels-idx1-ubyte.gz'
img_path = '/home/work/paddle/sample/recognize_digits/train/data/'
train_reader = paddle.batch(paddle.reader.shuffle(
#paddle.dataset.mnist.train(),
utils.mnist_reader_creator(train_image,train_label,buffer_size=100), #自己读取mnist训练集
#utils.image_reader_creator(img_path+'train/',28,28), #自己读取images
buf_size=500),
batch_size=64)
test_reader = paddle.batch(
#paddle.dataset.mnist.test(),
utils.mnist_reader_creator(test_image,test_label,buffer_size=100), #自己读取mnist测试集
#utils.image_reader_creator(img_path+'test/',28,28), #自己读取images
batch_size=64)
# 是否使用GPU
use_cuda = False # set to True if training with GPU
place = fluid.CUDAPlace(0) if use_cuda else fluid.CPUPlace()
# 创建训练器(train_func损失函数; place是否使用gpu; optimizer_func优化器)
trainer = Trainer(
train_func=utils.train_program, place=place, optimizer_func=utils.optimizer_program)
# 模型参数保存目录
params_dirname = "model/"
# 定义event_handler,输出训练过程中的结果
lists = []
def event_handler(event):
if isinstance(event, EndStepEvent): # 每步触发事件
if event.step % 100 == 0:
# event.metrics maps with train program return arguments.
# event.metrics[0] will yeild avg_cost and event.metrics[1] will yeild acc in this example.
print("Pass %d, Batch %d, Cost %f" % (event.step, event.epoch,
event.metrics[0]))
if isinstance(event, EndEpochEvent): # 每次迭代触发事件
# test的返回值就是train_func的返回值
avg_cost, acc = trainer.test(
reader=test_reader, feed_order=['img', 'label'])
print("Test with Epoch %d, avg_cost: %s, acc: %s" %
(event.epoch, avg_cost, acc))
# 保存模型参数
trainer.save_params(params_dirname)
# 保存训练结果损失情况
lists.append((event.epoch, avg_cost, acc))
# 开始训练模型
trainer.train(
num_epochs=5,
event_handler=event_handler,
reader=train_reader,
feed_order=['img', 'label'])
# 找到训练误差最小的一次结果(找完没用?trainer.save_params()自动做了最优选择?)
best = sorted(lists, key=lambda list: float(list[1]))[0]
print('Best pass is %s, testing Avgcost is %s' % (best[0], best[1]))
print('The classification accuracy is %.2f%%' % (float(best[2]) * 100))
# 加载测试数据
cur_dir = os.path.dirname(os.path.realpath(__file__))
img = utils.load_image(cur_dir + '/data/image/infer_3.png',28,28)
# 使用保存的模型参数+测试图片进行预测
inferencer = Inferencer(
# infer_func=utils.softmax_regression, # uncomment for softmax regression
# infer_func=utils.multilayer_perceptron, # uncomment for MLP
infer_func=utils.convolutional_neural_network, # uncomment for LeNet5
param_path=params_dirname,
place=place)
results = inferencer.infer({'img': img})
lab = numpy.argsort(results) # probs and lab are the results of one batch data
print("Inference result of image/infer_3.png is: %d" % lab[0][0][-1])
if __name__ == '__main__':
main()
vim utils.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import print_function
import os
import platform
import subprocess
from PIL import Image
from PIL import ImageOps
import cv2
import numpy
import paddle
import paddle.fluid as fluid
try:
from paddle.fluid.contrib.trainer import *
from paddle.fluid.contrib.inferencer import *
except ImportError:
print(
"In the fluid 1.0, the trainer and inferencer are moving to paddle.fluid.contrib",
file=sys.stderr)
from paddle.fluid.trainer import *
from paddle.fluid.inferencer import *
# 定义输入层及网络结构: 单层全连接层+softmax
def softmax_regression():
img = fluid.layers.data(name='img', shape=[1, 28, 28], dtype='float32')
predict = fluid.layers.fc(input=img, size=10, act='softmax')
return predict
# 定义输入层及网络结构: 多层感知器+relu*2+softmax(Multilayer Perceptron, MLP)
def multilayer_perceptron():
img = fluid.layers.data(name='img', shape=[1, 28, 28], dtype='float32')
# first fully-connected layer, using ReLu as its activation function
hidden = fluid.layers.fc(input=img, size=128, act='relu')
# second fully-connected layer, using ReLu as its activation function
hidden = fluid.layers.fc(input=hidden, size=64, act='relu')
# The thrid fully-connected layer, note that the hidden size should be 10,
# which is the number of unique digits
prediction = fluid.layers.fc(input=hidden, size=10, act='softmax')
return prediction
# 定义输入层及网络结构: 卷积神经网络(Convolutional Neural Network, CNN)
def convolutional_neural_network():
img = fluid.layers.data(name='img', shape=[1, 28, 28], dtype='float32')
# first conv pool
conv_pool_1 = fluid.nets.simple_img_conv_pool(
input=img,
filter_size=5,
num_filters=20,
pool_size=2,
pool_stride=2,
act="relu")
conv_pool_1 = fluid.layers.batch_norm(conv_pool_1)
# second conv pool
conv_pool_2 = fluid.nets.simple_img_conv_pool(
input=conv_pool_1,
filter_size=5,
num_filters=50,
pool_size=2,
pool_stride=2,
act="relu")
# output layer with softmax activation function. size = 10 since there are only 10 possible digits.
prediction = fluid.layers.fc(input=conv_pool_2, size=10, act='softmax')
return prediction
# 定义训练损失函数
def train_program():
# 定义训练用label数据层
label = fluid.layers.data(name='label', shape=[1], dtype='int64')
# 定义网络结构
# predict = softmax_regression() # uncomment for Softmax
# predict = multilayer_perceptron() # uncomment for MLP
predict = convolutional_neural_network() # uncomment for LeNet5
# 定义cost损失函数
cost = fluid.layers.cross_entropy(input=predict, label=label)
avg_cost = fluid.layers.mean(cost)
# acc用于在迭代过程中print
acc = fluid.layers.accuracy(input=predict, label=label)
return [avg_cost, acc]
# 定义优化器
def optimizer_program():
return fluid.optimizer.Adam(learning_rate=0.001)
# 自定义mnist数据集reader
def mnist_reader_creator(image_filename,label_filename,buffer_size):
def reader():
#调用命令读取文件,Linux下使用zcat
if platform.system()=='Linux':
zcat_cmd = 'zcat'
else:
raise NotImplementedError("This program is suported on Linux,\
but your platform is" + platform.system())
# 读取mnist图片集
sub_img = subprocess.Popen([zcat_cmd, image_filename], stdout = subprocess.PIPE)
sub_img.stdout.read(16) # 跳过前16个magic字节
# 读取mnist标签集
sub_lab = subprocess.Popen([zcat_cmd, label_filename], stdout = subprocess.PIPE)
sub_lab.stdout.read(8) # 跳过前8个magic字节
try:
while True: #前面使用try,故若再读取过程中遇到结束则会退出
# 批量读取label,每个label占1个字节
labels = numpy.fromfile(sub_lab.stdout,'ubyte',count=buffer_size).astype("int")
if labels.size != buffer_size:
break
# 批量读取image,每个image占28*28个字节,并转换为28*28的二维float数组
images = numpy.fromfile(sub_img.stdout,'ubyte',count=buffer_size * 28 * 28).reshape(buffer_size, 28, 28).astype("float32")
# 像素值映射到(-1,1)范围内用于训练
images = images / 255.0 * 2.0 - 1.0;
for i in xrange(buffer_size):
yield images[i,:],int(labels[i]) #将图像与标签抛出,循序与feed_order对应!
finally:
try:
#结束img读取进程
sub_img.terminate()
except:
pass
try:
#结束label读取进程
sub_lab.terminate()
except:
pass
return reader
# 自定义image目录文件列表reader
def image_reader_creator(img_path,height,width):
def reader():
imgs = os.listdir(img_path)
for i in xrange(len(imgs)):
#imgs[i] = '0-5.png'
#print(imgs[i])
label = imgs[i].split('.')[0].split('-')[1]
image = load_image(img_path + imgs[i],width,height)
#print(img_path + imgs[i])
yield image[0][0],int(label)
return reader
# 加载测试图片数据
def load_image(img_path,height,width,rotate=0,invert=False,sobel=False,ksize=5,dilate=0,erode=0,save_resize=False):
if sobel: #边缘检测
img_path = image_sobel(img_path, ksize=ksize, dilate=dilate, erode=erode)
#加载图片
im = Image.open(img_path).convert('L')
#缩略图
im = im.resize((height, width), Image.ANTIALIAS)
#旋转
if rotate != 0: #旋转度数
im = im.rotate(rotate)
#反转颜色(不要跟sobel一起用,因为sobel已经自动转为黑底+白边缘了)
if invert:
im = ImageOps.invert(im)
#临时保存
if save_resize:
name = img_path.split('/')[-1]
resize_path = img_path.replace(name,'') + '../tmp/' + name.split('.')[0]+"_"+str(height)+"x"+str(width)+"."+name.split('.')[1]
print(resize_path)
im.save(resize_path)
#返回数据
im = numpy.array(im).reshape(1, 1, height, width).astype(numpy.float32) #[N C H W] N几张图;C=1灰图;H高;W宽
im = im / 255.0 * 2.0 - 1.0
return im
def image_sobel(img_path, ksize=5, dilate=0, erode=0, dilate2=0):
"""图片边缘检测"""
img = cv2.imread(img_path)
#灰度图
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
#write_image(gray, img_path, 'gray')
# 高斯平滑
gaussian = cv2.GaussianBlur(gray, (3, 3), 0, 0, cv2.BORDER_DEFAULT)
#write_image(gaussian, img_path, 'gaussion')
# 中值滤波
median = cv2.medianBlur(gaussian, 5)
#write_image(median, img_path, 'median')
# Sobel算子,X方向求梯度,对图像进行边缘检测
sobel = cv2.Sobel(median, cv2.CV_8U, 1, 0, ksize=ksize) #ksize:1/3/5/7 cv2.CV_8U/cv2.CV_16S
#sobel = cv2.Sobel(median, cv2.CV_16S, 1, 0, ksize=ksize) #ksize:1/3/5/7 cv2.CV_8U/cv2.CV_16S
sobel = cv2.convertScaleAbs(sobel)
# 二值化
ret, binary = cv2.threshold(sobel, 170, 255, cv2.THRESH_BINARY)
threshold_path = write_image(binary, img_path, 'threshold')
if dilate == 0 and erode == 0:
return threshold_path
else:
# 膨胀和腐蚀操作的核函数
element1 = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 1))
element2 = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 7))
# 膨胀一次,让轮廓突出
dilation = cv2.dilate(binary, element2, iterations=dilate) #iterations=1
dilation_path = write_image(dilation, img_path, 'dilation')
if erode > 0:# 腐蚀,去掉细节
dilation = cv2.erode(dilation, element1, iterations=erode) #iterations=1
dilation_path = write_image(dilation, img_path, 'erosion')
if dilate2 > 0: # 再次膨胀,让轮廓明显一些
dilation2 = cv2.dilate(erosion, element2, iterations=dilate2) #iterations=3设置太大但车牌区域很小时非车牌区域容易边缘连片过度,设置太小但车牌占比过大时容易省简称和后边连不上
dilation_path = write_image(dilation2, img_path, 'dilation2')
return dilation_path
def write_image(img, img_path, step='', path='tmp'):
"""保存图片并打印"""
name = img_path.split('/')[-1]
img_path = img_path.replace(name,'')
#print(name)
#print(img_path)
if step != '':
img_file = img_path+'../'+path+'/'+name.split('.')[0]+'_'+step+'.'+name.split('.')[1]
else:
img_file = img_path+'../'+path+'/'+name
cv2.imwrite(img_file, img)
print(img_file)
return img_file
def mkdir(path):
"""检查并创建目录"""
if not os.path.exists(path):
os.makedirs(path)
if __name__ == '__main__':
#函数测试
mnist_path = '/home/work/.cache/paddle/dataset/mnist/'
train_image = mnist_path + 'train-images-idx3-ubyte.gz'
train_label = mnist_path + 'train-labels-idx1-ubyte.gz'
for img,label in mnist_reader_creator(train_image,train_label,1)(): #reader
print(img)
print(len(img))
print(len(img[0]))
print(label)
break
img_path = '/home/work/paddle/sample/recognize_digits/train/data/train/'
for img,label in image_reader_creator(img_path,28,28)(): #reader
print(img)
print(len(img))
print(len(img[0]))
print(label)
break
运行打印信息:
最优模型准确率98.56%,模型保存到了./model目录内。
使用训练好的模型进行识别:
vim infer.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# 手写数字识别模型测试
#cmd: python infer.py infer_0.jpeg
from __future__ import print_function
import sys
import os
import platform
import subprocess
from PIL import Image
from PIL import ImageOps
import numpy
import paddle
import paddle.fluid as fluid
try:
from paddle.fluid.contrib.trainer import *
from paddle.fluid.contrib.inferencer import *
except ImportError:
print(
"In the fluid 1.0, the trainer and inferencer are moving to paddle.fluid.contrib",
file=sys.stderr)
from paddle.fluid.trainer import *
from paddle.fluid.inferencer import *
import utils
import getopt
def main():
opts, args = getopt.getopt(sys.argv[1:], "p:", ["infer_file="])
if len(args) == 0:
print("usage: python infer.py [infer_file_name] \n\tpython infer.py infer_5.jpeg")
return
infer_file = args[0]
# 是否使用GPU
use_cuda = False # set to True if training with GPU
place = fluid.CUDAPlace(0) if use_cuda else fluid.CPUPlace()
params_dirname = "model/" # 模型参数保存目录
cur_dir = os.path.dirname(os.path.realpath(__file__))
height = width = 28
# 加载测试数据
#infer_img = cur_dir + '/data/image/infer_3.png'
infer_img = cur_dir + '/data/image/' + infer_file
imgs = [] #使用多种不同的预处理方法
imgs_weight = [1,0.99,0.99] #不同预处理方法的结果权重
try: #测试集图片
imgs.append(utils.load_image(infer_img,height,width))
except:
imgs.append([])
try: #白纸手写照片
print(len(infer_file.split('_')[1].split('.')[0]))
if len(infer_file.split('_')[1].split('.')[0])>=2 and int(infer_file.split('_')[1][1:2]) > 0:
imgs_weight[1] = 5
imgs.append(utils.load_image(infer_img, height, width, rotate=0, sobel=True, save_resize=True,ksize=5,dilate=1))
except:
imgs.append([])
try: #黑纸粗笔写照片
imgs.append(utils.load_image(infer_img, height, width, rotate=0, sobel=True, save_resize=True,ksize=3,dilate=6,erode=1))
except:
imgs.append([])
# 使用保存的模型参数+测试图片进行预测
inferencer = Inferencer(
# infer_func=softmax_regression, # uncomment for softmax regression
# infer_func=multilayer_perceptron, # uncomment for MLP
infer_func=utils.convolutional_neural_network, # uncomment for LeNet5
param_path=params_dirname,
place=place)
label = -1
result_cnt = 0
results_sum = numpy.ndarray([])
results_max = numpy.ndarray([])
numpy.set_printoptions(precision=2)
for i in xrange(len(imgs)):
if len(imgs[i])==0:continue
result = inferencer.infer({'img': imgs[i]}) #此输入img的各label概率
result = numpy.where(result[0][0]>0.001 ,result[0][0],0) #概率<0.1%的直接设置为0
print(result)
print(numpy.argsort(result))
results_sum = results_sum + result*imgs_weight[i] #累加label下标概率
result_cnt+=1
#print(imgs_weight)
#按概率排序
lab = numpy.argsort(results_sum) # probs and lab are the results of one batch data
label = lab[-1] #概率倒排最后一个
print("概率加和&排序: ")
print(results_sum)
print(lab)
print("测试图片: %s" % infer_img)
print("模型识别结果: %d 概率: %f" % (label,results_sum[label]/result_cnt))
if __name__ == '__main__':
main()
让儿子手写了些数字(下图),使用模型识别,能正常识别出是数字6
*注:因模型是使用比较干净的黑底白字图片训练的,直接拿拍的手写照片是识别不出来的,另外不同粗细的笔对模型识别也是有影响,所以在模型识别前需要做些预处理,与训练集保持一致,例如转换成黑背景+白字,具体可参考“#加载测试数据 #白纸手写照片”部分。
yan 2018.12.2 23:35
做了个微信小程序,直接拍照方便点,不用手动上传了
yan 2018.12.19 22:13
参考:
http://www.paddlepaddle.org/documentation/book/zh/develop/02.recognize_digits/index.cn.html
http://www.cnblogs.com/dzqiu/p/9514447.html