1. 前言
最近想要实现一个验证码识别的功能,在神经网络和计算机图形学之间摇摆了一下,但是卷积神经网络能实现“端到端”,亦即输入图片,输出验证码的验证码识别,就抛弃了CV选择了CNN。
其实原本我比较熟悉pytorch
的,甚至现成的pytorch + CUDA + cudnn
环境都已经有了,但无奈Tensorflow
有Tensorflow.js
可以与原本选择的跨平台解决方案Electron
完美结合,就背弃了pytorch
,转投Tensorflow
怀抱。
经过一段时间综合研(mo)究(gai)了官方文档+github+stackoverflow+知乎+csdn的各方代码,终于可以用我的GTX1660炼丹了!
2. 实现
2.1 图片预处理
首先第一步要做的就是图片预处理,起码一个灰度模式要有吧?
来看看要处理的验证码图片:
可以看见里面有两条线一条红的,一条绿的,贯穿了图片。理论上来说,可以通过(原图-红-绿)的图像处理手段去掉这两条线。
然而既然都用上卷积神经网络了,就不搞这么多花里胡哨的了,直接莽上去。
几行代码实现把图片灰度化然后去掉周围的黑线:
img = cv2.imread(img_file, cv2.IMREAD_GRAYSCALE)
np_img = np.array(img)
flat_img = np_img.flatten()
ind = np.argmax(np.bincount(flat_img))
if(ind < 255):
np_img[0] = ind
np_img[-1] = ind
np_img[:, 0] = ind
np_img[:, -1] = ind
img = np_img / 255.0
复制代码
输出图片:
当然这里的图片处理只是因为我的搭的网络模型对输入数据的shape没有要求,事实上有不少神经网络对输入数据的形状有要求的,比如InceptionV4(299,299)
,ResNet_18(224,224)
等,因此也可以基于opencv-python
扩展图片,之前做了一种实现是先用cv2.copyMakeBorder
把短边弄到和长边一样长,然后再cv2.resize
。
很好,接下来就是采集足够多的图片验证码以供训练了。
后来原本还做了二值化处理,但后来发现只灰度表现也不错,就去掉了二值化。
2.2 卷积神经网络搭建与训练
2.2.1 训练过程
经过几天的采集,手工标注了几百张图(后来扩展到了18000+张)作为训练集,可以开始尝试炼丹了。
不得不说[email protected]
时代太辉煌了,现在找到的大多数是1.x
版本的源码。
于是看了看github上面一些仓库后就参考官方文档的tutorial开始手撸(魔改)。
思路就是把训练集中标注好的验证码图片按照对应的字符集用One-Hot
喂进模型。从前面的验证码图可以看出,它一个图有六个字符,每个字符共有10个数字+26个大写字母=36种可能性。因此它是一个多分类(multi-class
)模型,总的类别是6*36=216
,损失函数categorical_crossentropy
,激活函数softmax
。
以下是搭建模型的代码部分(当然主体是在一个类里面的,略去了)
def generate_model(self):
self.model = model = models.Sequential()
input_shape=(self.image_height, self.image_width, 1)
# conventional
model.add(layers.Conv2D(96, (3, 3), activation='relu',padding="SAME"))
model.add(layers.Conv2D(96, (3, 3), activation='relu',padding="SAME"))
model.add(layers.MaxPooling2D((2, 2)))
# conv2
model.add(layers.Conv2D(128, (3, 3), activation='relu',padding="SAME"))
model.add(layers.Conv2D(128, (3, 3), activation='relu',padding="SAME"))
model.add(layers.Conv2D(128, (3, 3), activation='relu',padding="SAME"))
model.add(layers.MaxPooling2D((2, 2)))
# conv3
model.add(layers.Conv2D(128, (3, 3), activation='relu'))
model.add(layers.Conv2D(128, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
# conv4
model.add(layers.Conv2D(128, (3, 3), activation='relu'))
model.add(layers.Conv2D(128, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
# flatten
model.add(layers.Flatten())
# fc1
model.add(layers.Dense(384, activation='relu'))
model.add(layers.AlphaDropout(rate=0.2))
# fc2
model.add(layers.Dense(512, activation='relu'))
model.add(layers.AlphaDropout(rate=0.2))
# output
model.add(layers.Dense(self.max_captcha*self.char_set_len, activation='softmax'))
model.add(layers.Reshape((self.max_captcha,self.char_set_len)))
model.summary()
model.compile(optimizer=tf.keras.optimizers.Nadam(learning_rate=1e-5, clipnorm=1),
loss='categorical_crossentropy',
metrics=['accuracy'])
return model
复制代码
compile出来的网络模型长这样:
Model: "sequential"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
conv2d (Conv2D) (None, 80, 300, 96) 960
conv2d_1 (Conv2D) (None, 80, 300, 96) 83040
conv2d_2 (Conv2D) (None, 80, 300, 96) 83040
max_pooling2d (MaxPooling2D (None, 40, 150, 96) 0
)
conv2d_3 (Conv2D) (None, 40, 150, 128) 110720
conv2d_4 (Conv2D) (None, 40, 150, 128) 147584
conv2d_5 (Conv2D) (None, 40, 150, 128) 147584
max_pooling2d_1 (MaxPooling (None, 20, 75, 128) 0
2D)
conv2d_6 (Conv2D) (None, 18, 73, 128) 147584
conv2d_7 (Conv2D) (None, 16, 71, 128) 147584
max_pooling2d_2 (MaxPooling (None, 8, 35, 128) 0
2D)
conv2d_8 (Conv2D) (None, 6, 33, 128) 147584
conv2d_9 (Conv2D) (None, 4, 31, 128) 147584
max_pooling2d_3 (MaxPooling (None, 2, 15, 128) 0
2D)
flatten (Flatten) (None, 3840) 0
dense (Dense) (None, 384) 1474944
alpha_dropout (AlphaDropout (None, 384) 0
)
dense_1 (Dense) (None, 512) 197120
alpha_dropout_1 (AlphaDropo (None, 512) 0
ut)
dense_2 (Dense) (None, 216) 110808
reshape (Reshape) (None, 6, 36) 0
=================================================================
Total params: 2,946,136
Trainable params: 2,946,136
Non-trainable params: 0
复制代码
众所周知,对网络的优化可以通过增加网络层数(depth,比如从 ResNet (He et al.)从resnet18到resnet200 ), 也可以通过增加宽度,比如WideResNet (Zagoruyko & Komodakis, 2016)和Mo-bileNets (Howard et al., 2017) 可以扩大网络的width (#channels), 还有就是更大的输入图像尺寸(resolution)也可以帮助提高精度。——知乎老哥
原本魔改出来的代码应用的模型是复用了Alexnet的,但是计算复杂,层数多,参数太多,模型文件也大,练了一会发现收敛也超级慢。后来结合应用场景进行了一定的trade-off得出了目前这个模型。
不得不说tf2比tf1好上手多了!应用model.fit
方法可以开始炼丹了!
我在model.fit
里面加了两个callback,一个是保存断点,一个是保存历史数据。
def train_cnn(self):
x_train, y_train = self.get_train_data()
cp_path = './cp.h5'
print(np.any(np.isnan(y_train)))
print(y_train[0])
save_chec_points = tf.keras.callbacks.ModelCheckpoint(
filepath=cp_path, save_weights_only=False, save_best_only=True)
try:
model = tf.keras.models.load_model(self.model_save_dir)
except Exception as e:
model = self.generate_model()
try:
model.load_weights(cp_path)
except Exception as e:
pass
filename = 'log.csv'
history_logger = tf.keras.callbacks.CSVLogger(
filename, separator=",", append=True)
history = model.fit(x_train, y_train, batch_size=24,
epochs=200, validation_split=0.1, callbacks=[save_chec_points, history_logger],
shuffle=True)
model.save(self.model_save_dir)
plt.plot(history.history['accuracy'], label='accuracy')
plt.plot(history.history['val_accuracy'], label='val_accuracy')
plt.xlabel('Epoch')
plt.ylabel('Accuracy')
plt.ylim([0, 1])
plt.legend(loc='lower right')
plt.show()
复制代码
在callback里面加了两个函数,主要是要保存checkpoint和每个epoch的数据。
找了个一天长时间开着电脑炼丹,练了200个epoch,画出来的图像如下(忘了画loss
, nevermind):
可以看到训练完两百个epoch,模型的accuracy
已经有0.9左右。然后我又再来了50个epoch,稳定在0.94。
一百张图片这个人工智障它能大概看懂94张,这个性能可以了!作为没有调参的产物还要什么自行车。
2.2.2 踩坑记录
写代码过程中,总是难免要踩坑。
首先是踩了个天坑,每次一训练,在第一个epoch里面loss
就会极速增大直到变成NaN
...这是啥子情况呢?明明损失函数和激活函数好像都没错呀!
找了好久才发现!
原来是因为我的model
代码是改官方文档tutorial
的,里面的最后一步到激活函数softmax
就结束了,而training
的部分又是从别人的tf1
代码魔改的...但是找出来categorical_crossentropy
的文档,它是需要reshape
的,亦即输出的不应该是(1,216)
而是形如(6,36)
。
在complie
前加一层model.add(layers.Reshape((self.max_captcha,self.char_set_len)))
,把它改成(6,36)
形状的输出,问题可解。
然后,又发现总是炼到一半随机在某一个epoch里面loss
会变成NaN
,把历史记录拿出来看毫无头绪;按照一些网站查了输入和label是不是NaN
,发现也不关事。
结果发现优化算法是Adam
,损失函数是categorical_crossentropy
的时候,才会有这种无端变NaN
的现象发生,把优化算法改成sgd
或者其他都不会复现...
后来又训练了别的验证码识别模型,发现加上BatchNormalization
层收敛更快,每个epoch的lost和accuracy变化更稳定。
如果要predict也很简单,直接model.predit
就可,输入(N,80,300,1)
的张量,输出(N,6,36)
,再把输出的tensor按照onehot的顺序匹配即可。
3. 总结
虽然只是一个Toy Project
,但我还是倾注了很多心血的,从配环境到写代码到炼丹,第一次成功炮通Tensorflow2
的项目。这一路走来,很感激我的GTX1660的默默付出!
这应该是我2021年最后一篇技术类文章惹,完结撒花!