注册

写了个自动批改小孩作业的代码(上)

一、亮出效果

最近一些软件的搜题、智能批改类的功能要下线。

退1024步讲,要不要自己做一个自动批改的功能啊?万一哪天孩子要用呢!

昨晚我做了一个梦,梦见我实现了这个功能,如下图所示:

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

功能简介:作对了,能打对号;做错了,能打叉号;没做的,能补上答案。

醒来后,我环顾四周,赶紧再躺下,希望梦还能接上。

二、实现步骤

基本思路

其实,搞定两点就成,第一是能识别数字,第二是能切分数字。

首先得能认识5是5,这是前提条件,其次是能找到5、6、7、8这些数字区域的位置。

前者是图像识别,后者是图像切割

  • 对于图像识别,一般的套路是下面这样的(CNN卷积神经网络):

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

  • 对于图像切割,一般的套路是下面的这样(横向纵向投影法):

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

既然思路能走得通,那么咱们先搞图像识别。准备数据->训练数据并保存模型->使用训练模型预测结果

2.1 准备数据

对于男友,找一个油嘴滑舌的花花公子,不如找一个闷葫芦IT男,亲手把他培养成你期望的样子。

咱们不用什么官方的mnist数据集,因为那是官方的,不是你的,你想要添加±×÷它也没有。

有些通用的数据集,虽然很强大,很方便,但是一旦放到你的场景中,效果一点也不如你的愿。

只有训练自己手里的数据,然后自己用起来才顺手。更重要的是,我们享受创造的过程。

假设,我们只给口算做识别,那么我们需要的图片数据有如下几类:

索引:0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
字符:0 1 2 3 4 5 6 7 8 9 = + - × ÷

如果能识别这些,基本上能满足整数的加减乘除运算了。

好了,图片哪里来?!

是啊,图片哪里来?

吓得我差点从梦里醒来,500万都规划好该怎么花了,居然双色球还没有选号!

梦里,一个老者跟我说,图片要自己生成。我问他如何生成,他呵呵一笑,消失在迷雾中……

仔细一想,其实也不难,打字我们总会吧,生成数字无非就是用代码把字写在图片上。

字之所以能展示,主要是因为有字体的支撑。

如果你用的是windows系统,那么打开KaTeX parse error: Undefined control sequence: \Windows at position 3: C:\̲W̲i̲n̲d̲o̲w̲s̲\Fonts这个文件夹,你会发现好多字体。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

我们写代码调用这些字体,然后把它打印到一张图片上,是不是就有数据了。

而且这些数据完全是由我们控制的,想多就多,想少就少,想数字、字母、汉字、符号都可以,今天你搞出来数字识别,也就相当于你同时拥有了所有识别!想想还有点小激动呢!

看看,这就是打工和创业的区别。你用别人的数据相当于打工,你是不用操心,但是他给你什么你才有什么。自己造数据就相当于创业,虽然前期辛苦,你可以完全自己把握节奏,需要就加上,没用就去掉。

2.1.1 准备字体

建一个fonts文件夹,从字体库里拷一部分字体放进来,我这里是拷贝了13种字体文件。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

好的,准备工作做好了,肯定很累吧,休息休息休息,一会儿再搞!

2.1.2 生成图片

代码如下,可以直接运行。

from __future__ import print_function
from PIL import Image
from PIL import ImageFont
from PIL import ImageDraw
import os
import shutil
import time

# %% 要生成的文本
label_dict = {0: '0', 1: '1', 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '=', 11: '+', 12: '-', 13: '×', 14: '÷'}

# 文本对应的文件夹,给每一个分类建一个文件
for value,char in label_dict.items():
  train_images_dir = "dataset"+"/"+str(value)
  if os.path.isdir(train_images_dir):
      shutil.rmtree(train_images_dir)
  os.makedirs(train_images_dir)

# %% 生成图片
def makeImage(label_dict, font_path, width=24, height=24, rotate = 0):

  # 从字典中取出键值对
  for value,char in label_dict.items():
      # 创建一个黑色背景的图片,大小是24*24
      img = Image.new("RGB", (width, height), "black"
      draw = ImageDraw.Draw(img)
      # 加载一种字体,字体大小是图片宽度的90%
      font = ImageFont.truetype(font_path, int(width*0.9))
      # 获取字体的宽高
      font_width, font_height = draw.textsize(char, font)
      # 计算字体绘制的x,y坐标,主要是让文字画在图标中心
      x = (width - font_width-font.getoffset(char)[0]) / 2
      y = (height - font_height-font.getoffset(char)[1]) / 2
      # 绘制图片,在那里画,画啥,什么颜色,什么字体
      draw.text((x,y), char, (255, 255, 255), font)
      # 设置图片倾斜角度
      img = img.rotate(rotate)
      # 命名文件保存,命名规则:dataset/编号/img-编号_r-选择角度_时间戳.png
      time_value = int(round(time.time() * 1000))
      img_path = "dataset/{}/img-{}_r-{}_{}.png".format(value,value,rotate,time_value)
      img.save(img_path)
       
# %% 存放字体的路径
font_dir = "./fonts"
for font_name in os.listdir(font_dir):
  # 把每种字体都取出来,每种字体都生成一批图片
  path_font_file = os.path.join(font_dir, font_name)
  # 倾斜角度从-1010度,每个角度都生成一批图片
  for k in range(-10, 10, 1): 
      # 每个字符都生成图片
      makeImage(label_dict, path_font_file, rotate = k)

上面纯代码不到30行,相信大家应该能看懂!看不懂不是我的读者。

核心代码就是画文字。

draw.text((x,y), char, (255, 255, 255), font)

翻译一下就是:使用某字体在黑底图片的(x,y)位置写白色的char符号。

核心逻辑就是三层循环。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

如果代码你运行的没有问题,最终会生成如下结果:

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

好了,数据准备好了。总共15个文件夹,每个文件夹下对应的各种字体各种倾斜角的字符图片3900个(字符15类×字体13种×角度20个),图片的大小是24×24像素。

有了数据,我们就可以再进行下一步了,下一步是训练使用数据。

2.2 训练数据

2.2.1 构建模型

你先看代码,外行感觉好深奥,内行偷偷地笑。

# %% 导入必要的包 
import tensorflow as tf
import numpy as np
from tensorflow.keras import layers
from tensorflow.keras.models import Sequential
import pathlib
import cv2

# %% 构建模型
def create_model():
  model = Sequential([
      layers.experimental.preprocessing.Rescaling(1./255, input_shape=(24, 24, 1)),
      layers.Conv2D(24,3,activation='relu'),
      layers.MaxPooling2D((2,2)),
      layers.Conv2D(64,3, activation='relu'),
      layers.MaxPooling2D((2,2)),
      layers.Flatten(),
      layers.Dense(128, activation='relu'),
      layers.Dense(15)]
  )
   
  model.compile(optimizer='adam',
              loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
              metrics=['accuracy'])

  return model

这个模型的序列是下面这样的,作用是输入一个图片数据,经过各个层揉搓,最终预测出这个图片属于哪个分类。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

这么多层都是干什么的,有什么用?和衣服一样,肯定是有用的,内衣、衬衣、毛衣、棉衣各有各的用处。

2.2.2 卷积层 Conv2D

各个职能部门的调查员,搜集和整理某单位区域内的特定数据。我们输入的是一个图像,它是由像素组成的,这就是R e s c a l i n g ( 1. / 255 , i n p u t s h a p e = ( 24 , 24 , 1 ) ) Rescaling(1./255, input_shape=(24, 24, 1))Rescaling(1./255,input shape=(24,24,1))中,input_shape输入形状是24*24像素1个通道(彩色是RGB 3个通道)的图像。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

卷积层代码中的定义是Conv2D(24,3),意思是用3*3像素的卷积核,去提取24个特征。

我把图转到地图上来,你就能理解了。以我大济南的市中区为例子。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

卷积的作用就相当于从地图的某级单位区域中收集多组特定信息。比如以小区为单位去提取住宅数量、车位数量、学校数量、人口数、年收入、学历、年龄等等24个维度的信息。小区相当于卷积核。

提取完成之后是这样的。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

第一次卷积之后,我们从市中区得到N个小区的数据。

卷积是可以进行多次的。

比如在小区卷积之后,我们还可在小区的基础上再来一次卷积,在卷积就是街道了。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

通过再次以街道为单位卷积小区,我们就从市中区得到了N个街道的数据。

这就是卷积的作用。

通过一次次卷积,就把一张大图,通过特定的方法卷起来,最终留下来的是固定几组有目的数据,以此方便后续的评选决策。这是评选一个区的数据,要是评选济南市,甚至山东省,也是这么卷积。这和现实生活中评选文明城市、经济强省也是一个道理。

2.2.3 池化层 MaxPooling2D

说白了就是四舍五入。

计算机的计算能力是强大的,比你我快,但也不是不用考虑成本。我们当然希望它越快越好,如果一个方法能省一半的时间,我们肯定愿意用这种方法。

池化层干的就是这个事情。池化的代码定义是这样的M a x P o o l i n g 2 D ( ( 2 , 2 ) ) MaxPooling2D((2,2))MaxPooling2D((2,2)),这里是最大值池化。其中(2,2)是池化层的大小,其实就是在2*2的区域内,我们认为这一片可以合成一个单位。

再以地图举个例子,比如下面的16个格子里的数据,是16个街道的学校数量。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

为了进一步提高计算效率,少计算一些数据,我们用2*2的池化层进行池化。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

池化的方格是4个街道合成1个,新单位学校数量取成员中学校数量最大(也有取最小,取平均多种池化)的那一个。池化之后,16个格子就变为了4个格子,从而减少了数据。

这就是池化层的作用。

2.2.4 全连接层 Dense

弱水三千,只取一瓢。

在这里,它其实是一个分类器。

我们构建它时,代码是这样的D e n s e ( 15 ) Dense(15)Dense(15)。

它所做的事情,不管你前面是怎么样,有多少维度,到我这里我要强行转化为固定的通道。

比如识别字母a~z,我有500个神经元参与判断,但是最终输出结果就是26个通道(a,b,c,……,y,z)。

我们这里总共有15类字符,所以是15个通道。给定一个输入后,输出为每个分类的概率。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

注意:上面都是二维的输入,比如24×24,但是全连接层是一维的,所以代码中使用了l a y e r s . F l a t t e n ( ) layers.Flatten()layers.Flatten()将二维数据拉平为一维数据([[11,12],[21,22]]->[11,12,21,22])。

对于总体的模型,调用m o d e l . s u m m a r y ( ) model.summary()model.summary()打印序列的网络结构如下:

_________________________________________________________________
Layer (type)                 Output Shape             Param #   
=================================================================
rescaling_2 (Rescaling)     (None, 24, 24, 1)         0         
_________________________________________________________________
conv2d_4 (Conv2D)           (None, 22, 22, 24)       240       
_________________________________________________________________
max_pooling2d_4 (MaxPooling2 (None, 11, 11, 24)       0         
_________________________________________________________________
conv2d_5 (Conv2D)           (None, 9, 9, 64)         13888     
_________________________________________________________________
max_pooling2d_5 (MaxPooling2 (None, 4, 4, 64)         0         
_________________________________________________________________
flatten_2 (Flatten)         (None, 1024)             0         
_________________________________________________________________
dense_4 (Dense)             (None, 128)               131200    
_________________________________________________________________
dense_5 (Dense)             (None, 15)               1935      
=================================================================
Total params: 147,263
Trainable params: 147,263
Non-trainable params: 0
_________________________________________________________________

我们看到conv2d_5 (Conv2D) (None, 9, 9, 64) 经过2*2的池化之后变为max_pooling2d_5 (MaxPooling2 (None, 4, 4, 64)。(None, 4, 4, 64) 再经过F l a t t e n FlattenFlatten拉成一维之后变为(None, 1024),经过全连接变为(None, 128)再一次全连接变为(None, 15),15就是我们的最终分类。这一切都是我们设计的。

m o d e l . c o m p i l e model.compilemodel.compile就是配置模型的几个参数,这个现阶段记住就可以。

2.2.5 训练数据

执行就完了。

# 统计文件夹下的所有图片数量
data_dir = pathlib.Path('dataset')
# 从文件夹下读取图片,生成数据集
train_ds = tf.keras.preprocessing.image_dataset_from_directory(
  data_dir, # 从哪个文件获取数据
  color_mode="grayscale", # 获取数据的颜色为灰度
  image_size=(24, 24), # 图片的大小尺寸
  batch_size=32 # 多少个图片为一个批次
)
# 数据集的分类,对应dataset文件夹下有多少图片分类
class_names = train_ds.class_names
# 保存数据集分类
np.save("class_name.npy", class_names)
# 数据集缓存处理
AUTOTUNE = tf.data.experimental.AUTOTUNE
train_ds = train_ds.cache().shuffle(1000).prefetch(buffer_size=AUTOTUNE)
# 创建模型
model = create_model()
# 训练模型,epochs=10,所有数据集训练10
model.fit(train_ds,epochs=10)
# 保存训练后的权重
model.save_weights('checkpoint/char_checkpoint')

执行之后会输出如下信息:

Found 3900 files belonging to 15 classes. 
Epoch 1/10 122/122 [=========] - 2s 19ms/step - loss: 0.5795 - accuracy: 0.8615 
Epoch 2/10 122/122 [=========] - 2s 18ms/step - loss: 0.0100 - accuracy: 0.9992 
Epoch 3/10 122/122 [=========] - 2s 19ms/step - loss: 0.0027 - accuracy: 1.0000 
Epoch 4/10 122/122 [=========] - 2s 19ms/step - loss: 0.0013 - accuracy: 1.0000 
Epoch 5/10 122/122 [=========] - 2s 20ms/step - loss: 8.4216e-04 - accuracy: 1.0000 
Epoch 6/10 122/122 [=========] - 2s 18ms/step - loss: 5.5273e-04 - accuracy: 1.0000 
Epoch 7/10 122/122 [=========] - 3s 21ms/step - loss: 4.0966e-04 - accuracy: 1.0000 
Epoch 8/10 122/122 [=========] - 2s 20ms/step - loss: 3.0308e-04 - accuracy: 1.0000 
Epoch 9/10 122/122 [=========] - 3s 23ms/step - loss: 2.3446e-04 - accuracy: 1.0000 
Epoch 10/10 122/122 [=========] - 3s 21ms/step - loss: 1.8971e-04 - accuracy: 1.0000

我们看到,第3遍时候,准确率达到100%了。最后结束的时候,我们发现文件夹checkpoint下多了几个文件:

char_checkpoint.data-00000-of-00001
char_checkpoint.index
checkpoint

上面那几个文件是训练结果,训练保存之后就不用动了。后面可以直接用这些数据进行预测。

2.3 预测数据

终于到了享受成果的时候了。

# 设置待识别的图片
img1=cv2.imread('img1.png',0
img2=cv2.imread('img2.png',0
imgs = np.array([img1,img2])
# 构建模型
model = create_model()
# 加载前期训练好的权重
model.load_weights('checkpoint/char_checkpoint')
# 读出图片分类
class_name = np.load('class_name.npy')
# 预测图片,获取预测值
predicts = model.predict(imgs) 
results = [] # 保存结果的数组
for predict in predicts: #遍历每一个预测结果
  index = np.argmax(predict) # 寻找最大值
  result = class_name[index] # 取出字符
  results.append(result)
print(results)

我们找两张图片img1.png,img2.png,一张是数字6,一张是数字8,两张图放到代码同级目录下,验证一下识别效果如何。

图片要通过cv2.imread('img1.png',0) 转化为二维数组结构,0参数是灰度图片。经过处理后,图片转成的数组是如下所示(24,24)的结构:

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

我们要同时验证两张图,所以把两张图再组成imgs放到一起,imgs的结构是(2,24,24)。

下面是构建模型,然后加载权重。通过调用predicts = model.predict(imgs)将imgs传递给模型进行预测得出predicts。

predicts的结构是(2,15),数值如下面所示:

[[ 16.134243 -12.10675 -1.1994154 -27.766754 -43.4324 -9.633694 -12.214878 1.6287893 2.562174 3.2222707 13.834648 28.254173 -6.102874 16.76582 7.2586184] [ 5.022571 -8.762314 -6.7466817 -23.494259 -30.170597 2.4392672 -14.676962 5.8255725 8.855118 -2.0998626 6.820853 7.6578817 1.5132296 24.4664 2.4192357]]

意思是有2个预测结果,每一个图片的预测结果有15种可能。

然后根据 index = np.argmax(predict) 找出最大可能的索引。

根据索引找到字符的数值结果是[‘6’, ‘8’]。

下面是数据在内存中的监控:

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

可见,我们的预测是准确的。

下面,我们将要把图片中数字切割出来,进行识别了。

之前我们准备了数据,训练了数据,并且拿图片进行了识别,识别结果正确。

到目前为止,看来问题不大……没有大问题,有问题也大不了。

下面就是把图片进行切割识别了。

下面这张大图片,怎么把它搞一搞,搞成单个小数字的图片。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1

续:写了个自动批改小孩作业的代码(下)

原文:https://juejin.cn/post/7006732549451939847

0 个评论

要回复文章请先登录注册