对于这种按顺序书写的文字,我们可以使用循环神经网络来识别序列。下面我们来了解一下如何使用循环神经网络来识别这类验证码。
captcha 部分的代码和之前卷积神经网络识别的一样,只是将 n_class
改为了 len(characters)+1
,因为我们需要添加一个空白类用于 CTC Loss。
from captcha.image import ImageCaptcha
import matplotlib.pyplot as plt
import numpy as np
import random
%matplotlib inline
%config InlineBackend.figure_format = 'retina'
import string
characters = string.digits + string.ascii_uppercase
print(characters)
width, height, n_len, n_class = 170, 80, 4, len(characters)+1
generator = ImageCaptcha(width=width, height=height)
random_str = ''.join([random.choice(characters) for j in range(4)])
img = generator.generate_image(random_str)
plt.imshow(img)
plt.title(random_str)
这个 loss 是一个特别神奇的 loss,它可以在只知道序列的顺序,不知道具体位置的情况下,让模型收敛。(warp-ctc)
那么在 Keras 里面,CTC Loss 已经内置了,我们直接定义这样一个函数即可,由于我们使用的是循环神经网络,所以默认丢掉前面两个输出,因为它们通常无意义,且会影响模型的输出。
from keras import backend as K
def ctc_lambda_func(args):
y_pred, labels, input_length, label_length = args
y_pred = y_pred[:, 2:, :]
return K.ctc_batch_cost(labels, y_pred, input_length, label_length)
我们的模型结构是这样设计的,首先通过卷积神经网络去识别特征,然后经过一个全连接降维,再按水平顺序输入到一种特殊的循环神经网络,叫 GRU,全程是 Gated Recurrent Unit,可以理解为是 LSTM 的简化版。LSTM 早在1997年就已经被发明出来了,但是 GRU 直到2014年才出现。经过实验,GRU 效果比 LSTM 要好。
参考链接:https://zhuanlan.zhihu.com/p/28297161
from keras.models import *
from keras.layers import *
from keras.optimizers import *
rnn_size = 128
input_tensor = Input((width, height, 3))
x = input_tensor
x = Lambda(lambda x:(x-127.5)/127.5)(x)
for i in range(3):
for j in range(2):
x = Convolution2D(32*2**i, 3, kernel_initializer='he_uniform')(x)
x = BatchNormalization()(x)
x = Activation('relu')(x)
x = MaxPooling2D((2, 2))(x)
conv_shape = x.get_shape().as_list()
rnn_length = conv_shape[1]
rnn_dimen = conv_shape[2]*conv_shape[3]
print(conv_shape, rnn_length, rnn_dimen)
x = Reshape(target_shape=(rnn_length, rnn_dimen))(x)
rnn_length -= 2
x = Dense(rnn_size, kernel_initializer='he_uniform')(x)
x = BatchNormalization()(x)
x = Activation('relu')(x)
x = Dropout(0.2)(x)
gru_1 = GRU(rnn_size, return_sequences=True, kernel_initializer='he_uniform', name='gru1')(x)
gru_1b = GRU(rnn_size, return_sequences=True, kernel_initializer='he_uniform',
go_backwards=True, name='gru1_b')(x)
x = add([gru_1, gru_1b])
gru_2 = GRU(rnn_size, return_sequences=True, kernel_initializer='he_uniform', name='gru2')(x)
gru_2b = GRU(rnn_size, return_sequences=True, kernel_initializer='he_uniform',
go_backwards=True, name='gru2_b')(x)
x = concatenate([gru_2, gru_2b])
x = Dropout(0.2)(x)
x = Dense(n_class, activation='softmax')(x)
base_model = Model(inputs=input_tensor, outputs=x)
labels = Input(name='the_labels', shape=[n_len], dtype='float32')
input_length = Input(name='input_length', shape=[1], dtype='int64')
label_length = Input(name='label_length', shape=[1], dtype='int64')
loss_out = Lambda(ctc_lambda_func, output_shape=(1,),
name='ctc')([x, labels, input_length, label_length])
model = Model(inputs=[input_tensor, labels, input_length, label_length], outputs=[loss_out])
model.compile(loss={'ctc': lambda y_true, y_pred: y_pred}, optimizer='adam')
从 Input 到 最后一个 MaxPooling2D,是一个很深的卷积神经网络,它负责学习字符的各个特征,尽可能区分不同的字符。它输出 shape 是 [None, 17, 6, 128]
,这个形状相当于把一张宽为 170,高为 80 的彩色图像 (170, 80, 3),压缩为宽为 17,高为 6 的 128维特征的特征图 (17, 6, 128)。
然后我们把图像 reshape 成 (17, 768),也就是把高和特征放在一个维度,然后降维成 (17, 128),也就是从左到右有17条特征,每个特征128个维度。
这128个维度就是这一条图像的非常高维,非常抽象的概括,然后我们将17个特征向量依次输入到 GRU 中,GRU 有能力学会不同特征向量的组合会代表什么字符,即使是字符之间有粘连也不会怕。这里使用了双向 GRU,
最后 Dropout 接一个全连接层,作为分类器输出每个字符的概率。
这个是 base_model 的结构,也是我们模型的结构。那么后面的 labels, input_length, label_length 和 loss_out 都是为了输入必要的数据来计算 CTC Loss 的。
可视化的代码同上,这里只贴图。
可以看到模型比上一个模型复杂了许多,但实际上只是因为输入比较多,所以它显得很大。还有一个值得注意的地方,我们的图片在输入的时候是经过了旋转的,这是因为我们希望以水平方向输入循环神经网络,而图片在 numpy 里默认是这样的形状:(height, width, 3),因此我们使用了 transpose
函数将图片转为了(width, height, 3)的格式,这样就能把 X 轴转到第一个维度,方便输入到循环神经网络。
根据模型的输入,我们需要输入四个数据:
最后还有一个输入是 np.ones(batch_size)
,这是因为 Keras 在训练模型的时候必须输入一个 X 和一个 y,我们这里把上面四个都合并为一个 X 了,因此实际上 y 没有参与 loss 的计算,所以随便编一个 batch_size
长度的数据输入进去就好了。
def gen(batch_size=128):
X = np.zeros((batch_size, width, height, 3), dtype=np.uint8)
y = np.zeros((batch_size, n_len), dtype=np.uint8)
generator = ImageCaptcha(width=width, height=height)
while True:
for i in range(batch_size):
random_str = ''.join([random.choice(characters) for j in range(n_len)])
X[i] = np.array(generator.generate_image(random_str)).transpose(1, 0, 2)
y[i] = [characters.find(x) for x in random_str]
yield [X, y, np.ones(batch_size)*rnn_length, np.ones(batch_size)*n_len], np.ones(batch_size)
我们可以举个例子,使用一次生成器,看看输出的是什么内容:
(X_vis, y_vis, input_length_vis, label_length_vis), _ = next(gen(1))
print(X_vis.shape, y_vis, input_length_vis, label_length_vis)
plt.imshow(X_vis[0].transpose(1, 0, 2))
plt.title(''.join([characters[i] for i in y_vis[0]]))
我们可以看到输出了下面的内容:
(1, 170, 80, 3) [[29 4 21 21]] [ 15.] [ 4.]
这里:
(1, 170, 80, 3)
,如果有 n 张图,shape 就是 (n, 170, 80, 3)
[29 4 21 21]
,外面还有一个框是因为这里面可以有 n 个 label我们会通过这个函数来评估我们的模型,和上面的评估标准一样,只有全部正确,我们才算预测正确。这里有个坑,就是模型最开始训练的时候,并不一定会输出四个字符,所以我们如果遇到所有的字符都不到四个的时候,就不用计算了,一定是全错。遇到多于四个字符的时候,只取前四个。
def evaluate(batch_size=128, steps=10):
batch_acc = 0
generator = gen(batch_size)
for i in range(steps):
[X_test, y_test, _, _], _ = next(generator)
y_pred = base_model.predict(X_test)
shape = y_pred[:,2:,:].shape
ctc_decode = K.ctc_decode(y_pred[:,2:,:], input_length=np.ones(shape[0])*shape[1])[0][0]
out = K.get_value(ctc_decode)[:, :n_len]
if out.shape[1] == n_len:
batch_acc += (y_test == out).all(axis=1).mean()
return batch_acc / steps
因为 Keras 没有针对 CTC 模型计算准确率的选项,因此我们需要自定义一个回调函数,它会在每一代训练完成的时候计算模型的准确率。
from keras.callbacks import *
class Evaluator(Callback):
def __init__(self):
self.accs = []
def on_epoch_end(self, epoch, logs=None):
acc = evaluate(steps=20)*100
self.accs.append(acc)
print('')
print('acc: %f%%' % acc)
evaluator = Evaluator()
我们先按 Adam(1e-3)
的学习率训练20代,让模型快速收敛,然后以 Adam(1e-4)
的学习率再训练20代。这里设置每代训练 400 个 step,也就是每代 400*128=51200
个样本,验证集设置的是 20*128=2048
个样本。
h = model.fit_generator(gen(128), steps_per_epoch=400, epochs=20,
callbacks=[evaluator],
validation_data=gen(128), validation_steps=20)
model.compile(loss={'ctc': lambda y_true, y_pred: y_pred}, optimizer=Adam(1e-4))
h2 = model.fit_generator(gen(128), steps_per_epoch=400, epochs=20,
callbacks=[evaluator],
validation_data=gen(128), validation_steps=20)
然后我们将 loss 和 acc 的曲线图画出来:
plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
plt.plot(h.history['loss'] + h2.history['loss'])
plt.plot(h.history['val_loss'] + h2.history['val_loss'])
plt.legend(['loss', 'val_loss'])
plt.ylabel('loss')
plt.xlabel('epoch')
plt.ylim(0, 1)
plt.subplot(1, 2, 2)
plt.plot(evaluator.accs)
plt.ylabel('acc')
plt.xlabel('epoch')
训练到20代的时候,模型是这样的表现:
Epoch 20/20
399/400 [============================>.] - ETA: 0s - loss: 0.1593
acc: 97.929688%
400/400 [==============================] - 122s - loss: 0.1589 - val_loss: 0.1671
训练到40代的时候,模型是这样的表现:
Epoch 20/20
399/400 [============================>.] - ETA: 0s - loss: 0.1317
acc: 99.570312%
400/400 [==============================] - 123s - loss: 0.1315 - val_loss: 0.1130
(X_vis, y_vis, input_length_vis, label_length_vis), _ = next(gen(12))
y_pred = base_model.predict(X_vis)
shape = y_pred[:,2:,:].shape
ctc_decode = K.ctc_decode(y_pred[:,2:,:], input_length=np.ones(shape[0])*shape[1])[0][0]
out = K.get_value(ctc_decode)[:, :4]
plt.figure(figsize=(16, 8))
for i in range(12):
plt.subplot(3, 4, i+1)
plt.imshow(X_vis[i].transpose(1, 0, 2))
plt.title('pred:%s\nreal :%s' % (''.join([characters[x] for x in out[i]]),
''.join([characters[x] for x in y_vis[i]])))
我们可以尝试计算模型的总体准确率,以及看看模型到底错在哪。首先生成1024个样本,然后用 base_model
进行预测,然后裁剪并进行 ctc 解码,最后裁剪到4个 label 并与真实值进行对比。
(X_vis, y_vis, input_length_vis, label_length_vis), _ = next(gen(10000))
y_pred = base_model.predict(X_vis, verbose=1)
shape = y_pred[:,2:,:].shape
ctc_decode = K.ctc_decode(y_pred[:,2:,:], input_length=np.ones(shape[0])*shape[1])[0][0]
out = K.get_value(ctc_decode)[:, :4]
(y_vis == out).all(axis=1).mean()
# 0.99460000000000004
输出结果是99.46%的准确率,已经比上一个模型强很多了。
我们可以对预测错的样本进行统计:
from collections import Counter
Counter(''.join([characters[i] for i in y_vis[y_vis != out]]))
Counter({'0': 37, 'O': 14, 'Q': 1, 'T': 1, 'W': 1})
我们可以发现模型在 0 和 O 的准确率稍微低一点,其他的错误都只是个例。0与 O 确实是很难分辨的,我们可以尝试用代码生成一个 '0O0O' 的图像,然后用模型预测:
characters2 = characters + ' '
generator = ImageCaptcha(width=width, height=height)
random_str = '0O0O'
X_test = np.array(generator.generate_image(random_str))
X_test = X_test.transpose(1, 0, 2)
X_test = np.expand_dims(X_test, 0)
y_pred = base_model.predict(X_test)
shape = y_pred[:,2:,:].shape
ctc_decode = K.ctc_decode(y_pred[:,2:,:], input_length=np.ones(shape[0])*shape[1])[0][0]
out = K.get_value(ctc_decode)[:, :4]
out = ''.join([characters[x] for x in out[0]])
plt.imshow(X_test[0].transpose(1, 0, 2))
plt.title('pred:' + str(out))
argmax = np.argmax(y_pred, axis=2)[0]
list(zip(argmax, ''.join([characters2[x] for x in argmax])))
可以看到模型预测得还是很准的。
模型的大小是3.3MB,在显卡上跑10000张验证码需要用9秒,平均一秒识别一千张以上,完全可以拼过网速。即使是在笔记本上跑,也可以跑到一秒几十张的速度,因此此类验证码可以说已经被破解了。
本节会详细介绍我在进行四则混合运算识别竞赛初赛时的所有思路。
核心思想在前面,所以此处会省略部分重复内容。
本次竞赛目的是为了解决一个 OCR 问题,通俗地讲就是实现图像到文字的转换过程。
初赛数据集一共包含10万张180*60的图片和一个labels.txt的文本文件。每张图片包含一个数学运算式,运算式包含:
3个运算数:3个0到9的整型数字;2个运算符:可以是+、-、*,分别代表加法、减法、乘法0或1对括号:括号可能是0对或者1对
图片的名称从0.png到99999.png,下面是一些样例图片(这里只取了一张):
文本文件 labels.txt 包含10w行文本,每行文本包含每张图片对应的公式以及公式的计算结果,公式和计算结果之间空格分开,例如图片中的示例图片对应的文本如下所示:
(3-7)+5 1
5-6+2 1
(6+7)*2 26
(4+2)+7 13
(6*4)*4 96
官方的评价指标是准确率,初赛只有整数的加减乘运算,所得的结果一定是整数,所以要求序列与运算结果都正确才会判定为正确。
我们本地除了会使用官方的准确率作为评估标准以外,还会使用 CTC loss 来评估模型。
官方提供了10万张图片,我们可以直接使用官方数据进行训练,也可以通过Captcha,参照官方训练集,随机生成更多数据,进而提高准确性。根据题目要求,label 必定是三个数字,两个运算符,一对或没有括号,根据括号规则,只有可能是没括号,左括号和右括号,因此很容易就可以写出数据生成器的代码。
生成器的生成规则很简单:
import string
import random
digits = string.digits
operators = '+-*'
characters = digits + operators + '() '
def generate():
seq = ''
k = random.randint(0, 2)
if k == 1:
seq += '('
seq += random.choice(digits)
seq += random.choice(operators)
if k == 2:
seq += '('
seq += random.choice(digits)
if k == 1:
seq += ')'
seq += random.choice(operators)
seq += random.choice(digits)
if k == 2:
seq += ')'
return seq
相信大家都能看懂。当然,我写文章的时候又想到一种更好的写法:
import random
def generate():
ts = [u'{}{}{}{}{}', '({}{}{}){}{}', '{}{}({}{}{})']
ds = u'0123456789'
os = u'+-*'
cs = [random.choice(ds) if x%2 == 0 else random.choice(os) for x in range(5)]
return random.choice(ts).format(*cs)
除了生成算式以外,还有一个值得注意的地方就是初赛所有的减号(也就是“-”)都是细的,但是我们直接用 captcha 库生成图像会得到粗的减号,所以我们修改了 image.py 中的代码,在 _draw_character
函数中我们增加了一句判断,如果是减号,我们就不进行 resize 操作,这样就能防止减号变粗:
# line 191-194
if c != '-':
im = im.resize((w2, h2))
im = im.transform((w, h), Image.QUAD, data)
我们继而使用生成器生成四则运算验证码:
import string
import os
digits = string.digits
operators = '+-*'
characters = digits + operators + '() '
width, height, n_len, n_class = 180, 60, 7, len(characters) + 1
from captcha.image import ImageCaptcha
generator = ImageCaptcha(width=width, height=height,
font_sizes=range(35, 56),
fonts=['fonts/%s'%x for x in os.listdir('fonts') if '.tt' in x]
)
generator.generate_image('(1-2)-3')
上图就是原版生成器生成的图,我们可以看到减号是很粗的。
上图是修改过的生成器,可以看到减号已经不粗了。
from keras.layers import *
from keras.models import *
from make_parallel import make_parallel
rnn_size = 128
input_tensor = Input((width, height, 3))
x = input_tensor
for i in range(3):
x = Conv2D(32*2**i, (3, 3), kernel_initializer='he_normal')(x)
x = BatchNormalization()(x)
x = Activation('relu')(x)
x = Conv2D(32*2**i, (3, 3), kernel_initializer='he_normal')(x)
x = BatchNormalization()(x)
x = Activation('relu')(x)
x = MaxPooling2D(pool_size=(2, 2))(x)
conv_shape = x.get_shape()
x = Reshape(target_shape=(int(conv_shape[1]), int(conv_shape[2]*conv_shape[3])))(x)
x = Dense(128, kernel_initializer='he_normal')(x)
x = BatchNormalization()(x)
x = Activation('relu')(x)
gru_1 = GRU(rnn_size, return_sequences=True, kernel_initializer='he_normal', name='gru1')(x)
gru_1b = GRU(rnn_size, return_sequences=True, go_backwards=True, kernel_initializer='he_normal',
name='gru1_b')(x)
gru1_merged = add([gru_1, gru_1b])
gru_2 = GRU(rnn_size, return_sequences=True, kernel_initializer='he_normal', name='gru2')(gru1_merged)
gru_2b = GRU(rnn_size, return_sequences=True, go_backwards=True, kernel_initializer='he_normal',
name='gru2_b')(gru1_merged)
x = concatenate([gru_2, gru_2b])
x = Dropout(0.25)(x)
x = Dense(n_class, kernel_initializer='he_normal', activation='softmax')(x)
base_model = Model(input=input_tensor, output=x)
base_model2 = make_parallel(base_model, 4)
labels = Input(name='the_labels', shape=[n_len], dtype='float32')
input_length = Input(name='input_length', shape=(1,), dtype='int64')
label_length = Input(name='label_length', shape=(1,), dtype='int64')
loss_out = Lambda(ctc_lambda_func, name='ctc')([base_model2.output, labels, input_length, label_length])
model = Model(inputs=(input_tensor, labels, input_length, label_length), outputs=loss_out)
model.compile(loss={'ctc': lambda y_true, y_pred: y_pred}, optimizer='adam')
模型结构像之前写的文章一样,只是把卷积核的个数改多了一点,加了一些 BN 层,并且在四卡上做了一点小改动以支持多GPU训练。如果你是单卡,可以直接去掉 base_model2 = make_parallel(base_model, 4)
的代码。
BN 层主要是为了训练加速,实验结果非常好,模型收敛快了很多。
base_model 的可视化:
model 的可视化:
在经过几次测试以后,我已经抛弃了 evaluate 函数,因为在验证集上已经能做到 100% 识别率了,所以只需要看 val_loss 就可以了。在经过之前的几次尝试以后,我发现在有生成器的情况下,训练代数越多越好,因此直接用 adam 跑了50代,每代10万样本,可以看到模型在10代以后基本已经收敛。
我们可以看到模型先分为四份,在四个显卡上并行计算,然后合并结果,计算最后的 ctc loss,进而训练模型。
这里我们对生成的数据进行了可视化,可以看到模型基本已经做到万无一失,百发百中。
打包成 docker 以后提交到比赛系统中,经过十几分钟的运行,我们得到了完美的1分。
初赛是非常简单的,因此我们才能得到这么准的分数,之后官方进一步提升了难度,将初赛测试集提高到了20万张,在这个集上我们的模型只能拿到0.999925的成绩,可行的改进方法是将准确率进一步降低,充分训练模型,将多个模型结果融合等。
在扩充数据集上,我们发现有一些图片预测出来无法计算,比如 [629,2271,6579,17416,71857,77631,95303,102187,117422,142660,183693]
等,这里我们取 117422.png 为例。
我们可以看到肉眼基本无法认出这个图,但是经过一定的图像处理,我们可以显现出来它的真实面貌:
IMAGE_DIR = 'image_contest_level_1_validate'
index = 117422
img = cv2.imread('%s/%d.png' % (IMAGE_DIR, index))
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
h = cv2.equalizeHist(gray)
然后我们可以看到这样的结果:
当然,还有一张图是无法通过预处理得到结果的,142660,这有可能是程序的 bug 造成的小概率事件,所以初赛除了我们跑了一个 docker 得到满分以外,没有第二个人达到满分。
本节会详细介绍我在进行四则混合运算识别竞赛决赛时的所有思路。
本次竞赛目的是为了解决一个 OCR 问题,通俗地讲就是实现图像到文字的转换过程。
决赛数据集一共包含10万张图片和一个labels.txt的文本文件。每张图片包含一个数学运算式,运算式中包含:
决赛样例:
初赛的题不难,只需要识别文本序列即可,决赛的算式比较复杂,需要先经过图像处理,然后才能输入到神经网络中进行端到端的文本序列识别。
官方的评价指标是准确率,初赛只有整数的加减乘运算,所得的结果一定是整数,所以要求序列与运算结果都正确才会判定为正确。
但决赛的数字通常都是五位数,并且会有很多乘法和加法,以及一定会存在的一个分数,所以结果很容易超出64位浮点数所能表示的范围,因此官方在经过讨论后决定只考虑文本序列的识别,不评价运算结果。
而我们本地除了会使用官方的准确率作为评估标准以外,还会使用 CTC loss 来评估模型。
决赛的数据集探索就复杂得多,我们先明确两个概念:
流=42072;圳=86;(圳-(97510*45921))*流/35864
在这个式子中,流=42072;圳=86;
被称为赋值式,(圳-(97510*45921))*流/35864
被称为表达式,赋值式和表达式统称为公式,+-*/
被称为运算符。
首先我们对样本的每个字出现的次数进行了统计:
可以看到数字的分布很有意思,0出现的次数比其他数字都低,其他的数字出现次数基本一样,所以立即推这是直接按随机数生成的,0不能出现在首位,所以概率变低。
分号和等号出现的次数一样,这是因为每个赋值式都有一个等号和一个分号。它出现的概率是 1.65807,因此可以猜出一个赋值式和两个赋值式的比例是 1:2。
运算符出现的概率都是一样的,所以可以推断它们是直接随机取的。
括号出现的概率是 1.36505,我们统计了一下括号出现的所有可能:
1+1+1+1
(1+1)+1+1
1+(1+1)+1
1+1+(1+1)
(1+1+1)+1
1+(1+1+1)
((1+1)+1)+1
(1+(1+1))+1
1+((1+1)+1)
1+(1+(1+1))
(1+1)+(1+1)
一共有11种可能,按括号的数量统计括号出现的频率可以得出 2*5/11.0+5/11.0 = 1.3636,因此括号也是从上面几种模板随机取的。
中文除了“不”字出现了两次,概率翻倍,其他字概率基本相等。中文字取自于下面两句诗:“君不见,黄河之水天上来,奔流到海不复回 烟锁池塘柳,深圳铁板烧”,所以也可以推断出是按字直接随机取的。
由于原始的图像十分巨大,直接输入到 CNN 中会有90%以上的区域是没有用的,所以我们需要对图像做预处理,裁剪出有用的部分。然后因为图像有两到三个式子,因此我们采取的方案是从左至右拼接在一起,这样的好处是图像比较小。(900*80=72000 vs 600*270=162000)
我主要使用了以下几种技术:
首先先进行初步的关键区域提取:
def plot(index):
img = cv2.imread('%s/%d.png'%(IMAGE_DIR, index))
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
eq = cv2.equalizeHist(gray)
b = cv2.medianBlur(eq, 9)
m, n = img.shape[:2]
b2 = cv2.resize(b, (n//4, m//4))
m1 = cv2.morphologyEx(b2, cv2.MORPH_OPEN, np.ones((7, 40)))
m2 = cv2.morphologyEx(m1, cv2.MORPH_CLOSE, np.ones((4, 4)))
_, bw = cv2.threshold(m2, 127, 255, cv2.THRESH_BINARY_INV)
bw = cv2.resize(bw, (n, m))
r = img.copy()
img2, ctrs, hier = cv2.findContours(bw, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
for ctr in ctrs:
x, y, w, h = cv2.boundingRect(ctr)
cv2.rectangle(r, (x, y), (x+w, y+h), (0, 255, 0), 10)
首先要将图像转灰度图,然后用初赛使用的直方图均衡提高图像的对比度,这里噪点还在,所以需要进行滤波,我们这里使用了中值滤波,它能很好地滤掉噪点和干扰线。(上图的 blur)
现在我们只关心公式的提取,而不在意字符的提取(因为无法保证准确提取),所以我们需要将这些字符连接起来。这里首先对图像进行了4倍的缩放,然后使用了一种叫做开闭运算的算法来连接字符。因为我们要的是横向连接,纵向不需要连接,所以我们选择了 (7, 40) 大小的开运算,然后为了滤掉不必要的噪声,我们使用了 (4, 4) 的闭运算。(位于上图中间的 m2)
在拼接好公式以后,我们就可以对图像使用轮廓查找的算法了,很容易我们就可以抓到图像的三个边缘点集,然后我们使用边界矩形函数得到矩形的 (x, y, w, h),完成关键区域提取。提取之后我们将绿色的矩形画在了原图上。(位于上图右下角的 rect)
由于之前使用了很大的 kernel 进行滤波,所以这里需要进行一个微调的操作:
# 微调三个公式
d = 20
d2 = 5
imgs = []
sizes = []
for i, ctr in enumerate(ctrs):
x, y, w, h = cv2.boundingRect(ctr)
roi = img[max(0, y-d):min(m, y+h+d),max(0, x-d):min(n, x+w+d)]
p, q, _ = roi.shape
x = b[max(0, y-d):min(m, y+h+d),max(0, x-d):min(n, x+w+d)]
x = cv2.morphologyEx(x, cv2.MORPH_CLOSE, np.ones((3, 3)))
_, x = cv2.threshold(x, 127, 255, cv2.THRESH_BINARY_INV)
_, x, _ = cv2.findContours(x, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
x, y, w, h = cv2.boundingRect(np.vstack(x))
roi2 = roi[max(0, y-d2):min(p, y+h+d2),max(0, x-d2):min(q, x+w+d2)]
imgs.append(roi2)
sizes.append(roi2.shape)
首先通过之前的矩形,扩充20像素,然后裁剪出关键区域,这里是直接对滤波的图裁剪,所以分辨率很高。然后经过简单的闭运算滤波,二值化,提取边框,这里即使有噪点也不用担心,裁多了不要紧,裁少了才麻烦,然后裁出来的图可能会比较小,因为滤波过了,所以再扩充5个像素,达到不错的效果。
以下是几个例子:
裁出来准确的公式以后,我们就可以直接进行横向连接了:
# 连接三个公式
sizes = np.array(sizes)
img2 = np.zeros((sizes[:,0].max(), sizes[:,1].sum()+2*(len(sizes)-1), 3),
dtype=np.uint8)
x = 0
for a in imgs[::-1]:
w = a.shape[1]
img2[:a.shape[0], x:x+w] = a
x += w + 2
下图是拼接好的图像:
如果直接使用 python 的 for 循环去跑,只能占用一个核的 CPU 利用率,为了充分利用 CPU,我们使用了多进行并行预处理的方法让每个 CPU 都能满载运行。为了能够实时查看进度,我使用了 tqdm 这个进度条的库。
p = Pool(12)
n = 100000
if __name__ == '__main__':
rs = []
for r in tqdm(p.imap_unordered(f, range(n)), total=n):
rs.append(r)
这里我们把各个量之间的关系都画出来了,很有意思。
pd.plotting.scatter_matrix(df, alpha=0.1, figsize=(14,8), diagonal='kde');
其中的 x, y 表示公式的起始坐标,w, h 表示公式的宽和高,n, m 表示原图的宽和高,r 表示有几个公式。我们可以从图中看到,x, y 没有明显的规律,稍微有一点规律就是越宽的图能得到的 x 越大(废话,宽1000的图不可能有公式出现在1200)。
w 也没有明显的规律,是典型的正态分布,而 h 则有两个峰,这是因为公式有两个和三个的差别。
m, n 很有规律,它们是按某几个固定的数随机取的,m 的取值是从 [400, 500, 600, 700, 800, 900, 1000] 中随机选取的,n 是从 [800, 1600, 2400, 3200, 4000] 中随机取的。
Counter(df['m'])
Counter({400: 14233,
500: 14414,
600: 14332,
700: 14304,
800: 14293,
900: 14299,
1000: 14125})
Counter(df['n'])
Counter({800: 19872, 1600: 19937, 2400: 20128, 3200: 19975, 4000: 20088})
由于我们只对 base_model
进行了修改,ctc 部分直接照搬之前的代码即可,因此这里我们只讨论 base_model
,下面是代码:
def ctc_lambda_func(args):
y_pred, labels, input_length, label_length = args
y_pred = y_pred[:, 2:, :]
return K.ctc_batch_cost(labels, y_pred, input_length, label_length)
rnn_size = 128
l2_rate = 1e-5
input_tensor = Input((width, height, 3))
x = input_tensor
for i, n_cnn in enumerate([3, 4, 6]):
for j in range(n_cnn):
x = Conv2D(32*2**i, (3, 3), padding='same', kernel_initializer='he_uniform',
kernel_regularizer=l2(l2_rate))(x)
x = BatchNormalization(gamma_regularizer=l2(l2_rate), beta_regularizer=l2(l2_rate))(x)
x = Activation('relu')(x)
x = MaxPooling2D((2, 2))(x)
# x = AveragePooling2D((1, 2))(x)
cnn_model = Model(input_tensor, x, name='cnn')
input_tensor = Input((width, height, 3))
x = cnn_model(input_tensor)
conv_shape = x.get_shape().as_list()
rnn_length = conv_shape[1]
rnn_dimen = conv_shape[3]*conv_shape[2]
print conv_shape, rnn_length, rnn_dimen
x = Reshape(target_shape=(rnn_length, rnn_dimen))(x)
rnn_length -= 2
rnn_imp = 0
x = Dense(rnn_size, kernel_initializer='he_uniform', kernel_regularizer=l2(l2_rate), bias_regularizer=l2(l2_rate))(x)
x = BatchNormalization(gamma_regularizer=l2(l2_rate), beta_regularizer=l2(l2_rate))(x)
x = Activation('relu')(x)
# x = Dropout(0.2)(x)
gru_1 = GRU(rnn_size, implementation=rnn_imp, return_sequences=True, name='gru1')(x)
gru_1b = GRU(rnn_size, implementation=rnn_imp, return_sequences=True, go_backwards=True, name='gru1_b')(x)
gru1_merged = add([gru_1, gru_1b])
gru_2 = GRU(rnn_size, implementation=rnn_imp, return_sequences=True, name='gru2')(gru1_merged)
gru_2b = GRU(rnn_size, implementation=rnn_imp, return_sequences=True, go_backwards=True, name='gru2_b')(gru1_merged)
x = concatenate([gru_2, gru_2b])
# x = Dropout(0.2)(x)
x = Dense(n_class, activation='softmax', kernel_regularizer=l2(l2_rate), bias_regularizer=l2(l2_rate))(x)
rnn_out = x
base_model = Model(input_tensor, x)
在经过多次的代码迭代以后,我将 cnn 打包为了一个 model,这样模型会简洁很多:
模型思路是这样的:首先输入一张图,然后通过 cnn 导出 (112, 10, 128) 的特征图,其中112就是输入到 rnn 的序列长度,10 指的是每一条特征的高度是10像素,将后面 (10, 128) 的特征合并成1280,然后经过一个全连接降维到128维,就得到了 (112, 128) 的特征,输入到 RNN 中,然后经过两层双向 GRU 输出112个字的概率,然后用 CTC loss 去优化模型,得到能够准确识别字符序列的模型。
CNN 的结构如下图:
理论最大序列长度为46个字符(数字可能为100000,所以是 2*9+3*6+4+4+2=46
,对于 CTC 来说,我们最好要输入大于最大长度2倍的序列,才能收敛得比较好。之前我直接卷积到50左右了,然后对于连续字符来说,没有空白能将它们分隔开来,所以收敛效果会差很多。这里的最大序列长度我之前总是算错,因为我用的是 Python2,没有 decode 成 utf-8 的话,一个中文占三个字节。
CNN 的结构由原来的两层卷积一层池化,改为了多层卷积,一层池化的结构,由于卷积层分别是3,4和6层,我称之为 346 结构。
为什么使用 RNN 呢,这里我举一个很经典的例子:研表究明,汉字的序顺并不定一能影阅响读,比如当你看完这句话后,才发这现里的字全是都乱的。
人眼去阅读一段话的时候,是会顾及到上下文的,不是依次单个字符的识别,因此引入 RNN 去识别上下文能够极大提升模型的准确率。在决赛中,序列有几个地方都是有上下文关系的:
相比之前初赛的模型,这里进行了一些修改:
l2 正则化的参数直接参考了 Xception 论文的 4.3 节给的参数:
Weight decay: The Inception V3 model uses a weight decay (L2 regularization) rate of 4e-5, which has been carefully tuned for performance on ImageNet. We found this rate to be quite suboptimal for Xception and instead settled for 1e-5.
为了得到更多的数据,提高模型的泛化能力,我使用了一种很简单的数据扩充办法,那就是根据表达式中的中文随机挑选赋值式,组成新的样本。这里我们取了前 350*256=89600 个样本来生成,用之后的 10240 个样本来做验证集,还有一点零头因为太少就没有用了。
导入数据的时候,先读取运算式的图像,然后按中文导入赋值式的图像到字典中。因为字典中的 key 是无序的,所以我们在字典中存的是 list,列表是有序的。
from collections import defaultdict
cn_imgs = defaultdict(list)
cn_labels = defaultdict(list)
ss_imgs = []
ss_labels = []
for i in tqdm(range(n1)):
ss = df[0][i].decode('utf-8').split(';')
m = len(ss)-1
ss_labels.append(ss[-1])
ss_imgs.append(cv2.imread('crop_split2/%d_%d.png'%(i, 0)).transpose(1, 0, 2))
for j in range(m):
cn_labels[ss[j][0]].append(ss[j])
cn_imgs[ss[j][0]].append(cv2.imread('crop_split2/%d_%d.png'%(i, m-j)).transpose(1, 0, 2))
然后实现生成器,这里继承了 keras 里的 Sequence 类:
from keras.utils import Sequence
class SGen(Sequence):
def __init__(self, batch_size):
self.batch_size = batch_size
self.X_gen = np.zeros((batch_size, width, height, 3), dtype=np.uint8)
self.y_gen = np.zeros((batch_size, n_len), dtype=np.uint8)
self.input_length = np.ones(batch_size)*rnn_length
self.label_length = np.ones(batch_size)*38
def __len__(self):
return 350*256 // self.batch_size
def __getitem__(self, idx):
self.X_gen[:] = 0
for i in range(self.batch_size):
try:
random_index = random.randint(0, n1-1)
cls = []
ss = ss_labels[random_index]
cs = re.findall(ur'[\u4e00-\u9fff]', df[0][random_index].decode('utf-8').split(';')[-1])
random.shuffle(cs)
x = 0
for c in cs:
random_index2 = random.randint(0, len(cn_labels[c])-1)
cls.append(cn_labels[c][random_index2])
img = cn_imgs[c][random_index2]
w, h, _ = img.shape
self.X_gen[i, x:x+w, :h] = img
x += w+2
img = ss_imgs[random_index]
w, h, _ = img.shape
self.X_gen[i, x:x+w, :h] = img
cls.append(ss)
random_str = u';'.join(cls)
self.y_gen[i,:len(random_str)] = [characters.find(x) for x in random_str]
self.y_gen[i,len(random_str):] = n_class-1
self.label_length[i] = len(random_str)
except:
pass
return [self.X_gen, self.y_gen, self.input_length, self.label_length], np.ones(self.batch_size)
首先随机取一个表达式,然后用正则表达式找里面的中文,再从{中文:图像数组}的字典中随机取图像,经过之前预处理的方式拼接成一个新的序列。
比如随机取了一个 85882*(河/76020-37023)-铁
,然后我们从铁的赋值式中随机取一个,再从河的赋值式中随便取一个,拼起来就能得到下图:
可以看到背景颜色是不同的,但是并不影响模型去识别。
我们训练的策略是先用 Adam() 默认的学习率 1e-3 快速收敛50代,然后用 Adam(1e-4) 跑50代,达到一个不错的 loss,最后用 Adam(1e-5)微调50代,每一代都保存权值,并且把验证集的准确率跑出来。图中的绿色的线 0.9977 就是按上面的方法训练的模型,
当然我们还尝试过先按 1e-3 的学习率训练20代,然后 1e-4 和 1e-5 交替训练2次,每次训练取验证集 loss 最低的结果继续训练,也就是图中红色的线,虽然速度快,但是准确率不够好。
之后我们将全部训练集都用于训练,得到了蓝色的线,效果和绿色差不多。
读取测试集的样本,然后用 base_model
进行预测,这个过程很简单,就不讲了。
X = np.zeros((n, width, height, channels), dtype=np.uint8)
for i in tqdm(range(n)):
img = cv2.imread('crop_split2_test/%d.png'%i).transpose(1, 0, 2)
a, b, _ = img.shape
X[i, :a, :b] = img
base_model = load_model('model_346_split2_3_%s.h5' % z)
base_model2 = make_parallel(base_model, 4)
y_pred = base_model2.predict(X, batch_size=500, verbose=1)
out = K.get_value(K.ctc_decode(y_pred[:,2:], input_length=np.ones(y_pred.shape[0])*rnn_length)[0][0])[:, :n_len]
输出到文件的部分有一点值得一提,就是如何计算出真实值:
ss = map(decode, out)
vals = []
errs = []
errsid = []
for i in tqdm(range(100000)):
val = ''
try:
a = ss[i].split(';')
s = a[-1]
for x in a[:-1]:
x, c = x.split('=')
s = s.replace(x, c+'.0')
val = '%.2f' % eval(s)
except:
# disp3(i)
errs.append(ss[i])
errsid.append(i)
ss[i] = ''
vals.append(val)
with open('result_%s.txt' % z, 'w') as f:
f.write('\n'.join(map(' '.join, list(zip(ss, vals)))).encode('utf-8'))
print len(errs)
print 1-len(errs)/100000.
# output
22
0.99978
其中的思路说起来也很简单,就是将表达式中的赋值式中文替换为赋值式的数字,然后直接用 python eval 得到结果,算不出来的直接留空即可。这个0.9977模型的可算率达到了0.99978,也就是说十万个样本里面只有22个样本不可算,当然,实际上还是有一些样本即使可算,也会因为各种原因识别错,比如5和6就是错误的重灾区,某些数字被干扰线切过,导致肉眼都辨认不清等。
模型结果融合的规则很简单,对所有的结果进行次数统计,先去掉空的结果,然后取最高次数的结果即可,其实就是简单的投票。
import glob
import numpy as np
from collections import Counter
def fun(x):
c = Counter(x)
c[' '] = 0
return c.most_common()[0][0]
ss = [open(fname, 'r').read().split('\n') for fname in glob.glob('result_model*.txt')]
s = np.array(ss).T
with open('result.txt', 'w') as f:
f.write('\n'.join(map(fun, s)))
将上面 loss 图中的三个模型结果融合以后,最后得到了0.99868的测试集准确率。
在比赛刚开始的时候,尝试过将图像的宽度设置为 None,也就是不定长的宽度,但是由于无法解决 reshape 的问题,这个方案被否了。
之前尝试过图像切成几块,分别识别,赋值式和表达式的模型分开,考虑到由于无法得到上下文的信息,可能会丢失一定的准确率,做到一半否掉了这个方案。
我们尝试过写一个生成器,但是由于和官方给的图像差太远,并且实际测试的时候要么是生成的准确率高,官方的准确率低,要么反过来,所以没有投入使用。
上图第一个是官方的图像,后面五个是我们的生成器生成的,可以看到我们的字没有官方的紧凑,等号也不太一样,分式我们的字又太紧凑了。
除了自己搭模型,我还尝试过用 ResNet,DenseNet 替换 CNN,然后去训练,但是由于本身这些模型就很大,训练起来速度很慢,然后主要问题又不在模型不够复杂,因为从绘制出来的 loss 曲线来看,虽然前面的 val_loss 一直在抖,但是在第50代学习率下降以后就非常平缓了,这模型是没有过拟合的:
在比赛最后尝试过将 GRU 替换为 LSTM,得到的结果是十分类似的,但是提交上去以后准确率有轻微下降(多错了几个样本,可能是运气问题),之前做验证码识别的时候也是替换过,效果差不多,因此没有继续尝试。理论上这个序列长度并没有很长,GRU 和 LSTM 影响不大。
本项目中,需要注意以下几个重要的点:
在测试集里有一个 95170.png 样本很难分割:
因为它的字太浅了,很难被切割出来,肉眼也基本无法分辨。
它的表达式也很难切,稍有不慎就切掉中文了:
在我们分割的验证集中,发现了被干扰线成功干扰的样本:
我们可以看到第一个 7 倾斜以后加上一条干扰线,很容易就被模型认成4了,但是人类却不会犯这样的错,这也是 CNN 和 人类之间的区别,目测卷积层自动把图像转灰度图了。
主要内容 课程列表 专项课程学习 辅助课程 论文专区 课程列表 课程 机构 参考书 Notes等其他资料 卷积神经网络视觉识别 Stanford 暂无 链接 神经网络 Tweet 暂无 链接 深度学习用于自然语言处理 Stanford 暂无 链接 自然语言处理 Speech and Language Processing 链接 专项课程学习 下述的课程都是公认的最好的在线学习资料,侧重点不同,但推
Google Cloud Platform 推出了一个 Learn TensorFlow and deep learning, without a Ph.D. 的教程,介绍了如何基于 Tensorflow 实现 CNN 和 RNN,链接在 这里。 Youtube Slide1 Slide2 Sample Code
我太菜了,C++需要恶补才行,面试完基本上就知道自己寄,面试官特别好给我说了很多,也让我充分认识到自己的不足 如果是项目的话,会问你项目背景以及项目最终的实现结果等等 如果是自己学习的项目的话,会问你对这个项目的学习心得 最后问对C++对掌握程度 实现vector
词向量 自然语言需要数学化才能够被计算机认识和计算。数学化的方法有很多,最简单的方法是为每个词分配一个编号,这种方法已经有多种应用,但是依然存在一个缺点:不能表示词与词的关系。 词向量是这样的一种向量[0.1, -3.31, 83.37, 93.0, -18.37, ……],每一个词对应一个向量,词义相近的词,他们的词向量距离也会越近(欧氏距离、夹角余弦) 词向量有一个优点,就是维度一般较低,一般
现在开始学深度学习。在这部分讲义中,我们要简单介绍神经网络,讨论一下向量化以及利用反向传播(backpropagation)来训练神经网络。 1 神经网络(Neural Networks) 我们将慢慢的从一个小问题开始一步一步的构建一个神经网络。回忆一下本课程最开始的时就见到的那个房价预测问题:给定房屋的面积,我们要预测其价格。 在之前的章节中,我们学到的方法是在数据图像中拟合一条直线。现在咱们不
深度学习的总体来讲分三层,输入层,隐藏层和输出层。如下图: 但是中间的隐藏层可以是多层,所以叫深度神经网络,中间的隐藏层可以有多种形式,就构成了各种不同的神经网络模型。这部分主要介绍各种常见的神经网络层。在熟悉这些常见的层后,一个神经网络其实就是各种不同层的组合。后边介绍主要基于keras的文档进行组织介绍。
Python 是一种通用的高级编程语言,广泛用于数据科学和生成深度学习算法。这个简短的教程介绍了 Python 及其库,如 Numpy,Scipy,Pandas,Matplotlib,像 Theano,TensorFlow,Keras 这样的框架。
你拿起这本书的时候,可能已经知道深度学习近年来在人工智能领域所取得的非凡进展。在图像识别和语音转录的任务上,五年前的模型还几乎无法使用,如今的模型的表现已经超越了人类。