前言
验证码技术作为一种反自动化技术,使得很多程序的自动化工作止步。今天作者采用一些数字图像处理和CNN方法来识别较为简单的数字验证码
实验步骤
实验步骤主要围绕以下展开
图像预处理即滤除噪声和字符分割CNN搭建和训练验证码识别
一.图像预处理
接下来,我将一张验证码0250为例,使用python语言和依赖opencv来开展预处理
可以看到在验证码中除了数字字符以外,夹杂着很多斑点噪声以及横线干扰。这些斑点噪声面积很小,在二值化图像里属于很小的连通域,在后续操作中我们可以通过限制连通域大小来滤除这些小斑点。
1.1滤除噪声
(1)读取灰度图像
# 读取图片并保存为灰色img_gray = cv2.imread(r'./images/0250.jpg',flags=cv2.IMREAD_GRAYSCALE)
(2)二值化
因为字符属于低灰度像素,因此采用逆向二值化,并为了照顾到颜色较淡的字符,我们采用较高的判别阈值,这里使用0.9
# 二值化_,img_bin = cv2.threshold(img_gray,int(0.9*255),255,cv2.THRESH_BINARY_INV) #二值化阈值0.9
(3)滤除小连通域
由二值图像可以看到,图像存在着很多斑点(小连通域)干扰,这里我们采用连通域面积限制来滤除这些斑点,首先我们先定义滤除小连通域方法。
# 定义二值图像去除小连通域def RemoveSmallCC(bin,small,connectivity=8):# (1)获取连通域标签ret, labels = cv2.connectedComponents(bin, connectivity=connectivity)# (2)去小连通域for n in range(ret + 1):# 0~maxnum = 0 # 清零for elem in labels.flat:if elem == n:num += 1if num < small: # 去除小连通域bin[np.where(labels == n)] = 0return bin
connectivity表示连通域构成方式为 8邻域 or 4邻域 。在初始阶段,为了缓解斑点和字符粘连的问题,我们希望采用4邻域来分解更多的连通域,我们限制连通域面积上限为200,接下来我们看看效果:
# 去除斑点img_bin = RemoveSmallCC(img_bin, 200,connectivity=4)
相比较原始图像,这里滤出掉了大多数斑点,保留了字符、横线等大连通域。
(4)形态学开运算
在滤除斑点的过程中,依然可能存在少部分斑点粘连在字体上,这里我们采用形态学开运算,即腐蚀、消除、膨胀的方法来断开斑点和字符连通,去除斑点,再恢复初始字符形态。但是开运算的过程中也可能会导致字符结构断开并被消除,因此我们选择较小的结构元素(本文采用3X3),来去除那些较微弱的粘连问题
# 定义结构元素size = 3kernel = np.ones((size, size), dtype=np.uint8)# 形态学腐蚀img_erosion = cv2.erode(img_bin, kernel, iterations=1)# 小连通域滤除img_erosion = RemoveSmallCC(img_erosion,30)# 形态学膨胀img_dila = cv2.dilate(img_erosion, kernel, iterations=1)
(5)去除横线
此时,图像还有令人讨厌的横线干扰,其中横线穿梭在字符之间。为了保全字符的完整,我们不对字符上的横线进行滤除(粘有横线干扰的字符交给CNN吧。),仅滤除字符之间的横线。首先我们将二值图像在x轴上进行投影,为此不妨先定义一个投影方法。
# 二值图像一维投影def BIN_PROJECT(bin):IMG = bin / 255.0 # 浮点型PROJ = np.zeros(shape=(IMG.shape[1]))for col in range(IMG.shape[1]):v = IMG[:, col]# 列向量PROJ[col] = np.sum(v)return PROJ
得到投影的统计图
其中红色圈圈标记了字符之间横线位置,其中该投影分量较小,因此我们可以通过设定阈值的方法去除。
flaw_area = np.where(PROJ<5) # 字符间横线img_dila[:,flaw_area]=0 # 去除横线IMG = RemoveSmallCC(img_dila,200) # 去除小连通区域
这时验证码中的字符差不多显现出来了。
1.2字符分割
同多个字符识别相比,CNN对单个字符识别效率更高,而且多个字符所需的样本数据大,且交杂在一起的验证码字符更不易识别,因此有必要对字符串的几何特征分析来分割出单个字符。如上述验证码产生两个部分,02交杂在一起,50交杂在一起。
对于不同的字符交杂类型可以分为以下几种情况:
4个部分:1+1+1+1型,即字符之间不存在交杂,可直接分割 3个部分:1+1+2型,我们仅对len=2的部分进行二分就可以了 2个部分(1): 2+2型,对len =2的部分均采用二分法 2个部分(2):1+3型,对len=3的部分采用三分法 1个部分: 4型,对len=4的部分采用4分法
这里我们用LOC表示每个部分首尾地址集合,COUNT表示每个部分的大小集合。观察0250验证码投影属于2+2型,因此每个部分均采用二分法即可
注意:对于2+2型和1+3型的区分,可以采用skew = len(max)/len(min)的比值来判定,通常较小的为2+2型,较大的为1+3型
首先提取出投影中各个部分的位置以及大小,定义方法:
# 提取数字部分def Extract_Num(PROJ):num = 0COUNT = []LOC = []for i in range(len(PROJ)):if PROJ[i]:# 如果非零则累加num+=1if i == 0 or PROJ[i-1]==0:# 记录片段起始位置start = iif i == len(PROJ)-1 or PROJ[i+1]==0 :# 定义片段结束标志,并记录片段end = iif num > 10:# 提取有效片段COUNT.append(num)LOC.append((start,end))num = 0 #清0return LOC,COUNT
在对各个部分分析和分割成4个数字
# 分割数字def Segment4_Num(COUNT,LOC):assert len(COUNT)<=4 and len(COUNT)>0# 数字部分分析if len(COUNT) ==4:#(1,1,1,1)return LOCif len(COUNT)==3:#(1,1,2)idx = np.argmax(np.array(COUNT))# 最大片段下标r = LOC[idx]# 最大片段位置start = r[0]end = r[1]m = (r[0]+r[1])//2 # 中间位置# 修改LOC[idx]LOC[idx] = (start,m)LOC.insert(idx+1,(m+1,end))return LOCif len(COUNT) ==2:#(2,2)or(1,3)skew = max(COUNT)/min(COUNT)# 计算偏移程度if skew<1.7:# 认为是(2,2)start1 = LOC[0][0]end1 = LOC[0][1]start2 = LOC[1][0]end2 = LOC[1][1]m1 = (start1+end1)//2m2 = (start2+end2)//2return [(start1,m1),(m1+1,end1),(start2,m2),(m2+1,end2)]else: # 认为是(1,3)idx = np.argmax(np.array(COUNT))# 最大片段下标start = LOC[idx][0]end = LOC[idx][1]m1 = (end-start)//3+startm2 = (end-start)//3*2+start# 修改LOC[idx]LOC[idx] = (start, m1)LOC.insert(idx+1,(m1+1,m2))LOC.insert(idx+2,(m2+1,end))return LOCif len(COUNT) ==1:# (4)start = LOC[0][0]end = LOC[0][1]m1 = (end-start)//4+startm2 = (end - start) // 4*2 + startm3 = (end - start) // 4*3 + startreturn [(start,m1),(m1+1,m2),(m2+1,m3),(m3+1,end)]
调用方法
# PROJ提取数字部分PROJ = BIN_PROJECT(IMG)LOC,COUNT = Extract_Num(PROJ)# 优化COUNTCOUNT = COUNT_OPTIMIZE(IMG, LOC, COUNT)# 分割数字LOC = Segment4_Num(COUNT,LOC)NUM0 = IMG[:,LOC[0][0]:LOC[0][1]]NUM0 = RemoveSmallCC(NUM0,50)NUM1 = IMG[:,LOC[1][0]:LOC[1][1]]NUM1 = RemoveSmallCC(NUM1,50)NUM2 = IMG[:,LOC[2][0]:LOC[2][1]]NUM2 = RemoveSmallCC(NUM2,50)NUM3 = IMG[:,LOC[3][0]:LOC[3][1]]NUM3 = RemoveSmallCC(NUM3,50)
分割结果:
分割结束后我们需要将每张数字图片resize到32x32格式黑白图像保存起来,提供给后面CNN进行训练和识别
二.CNN搭建和训练
2.1训练集和测试集
这里我们从数据集中随机采样70%作为训练集,30%作为测试集
#--------------------------- 随机选取70%作为训练集,%30作为测试集 ---------------------------------------------data_total = len(img_list) # 数据集总数train_total = int(0.7*data_total) #训练集总量test_total = data_total - train_total # 测试集总量#随机采样训练样本import randomtrain_list = random.sample(img_list,train_total) #训练样本列表test_list = [item for item in img_list if item not in train_list] # 测试样本列表
样本的保存和读入采用TFRecord格式,其中依赖TFRtools脚本下载链接为:/download/ephemeroptera/11139598
2.2CNN搭建和训练
CNN采用两个卷积层和两个全连层,输出使用softmax函数,损失函数采用交叉熵,使用ADAM优化器,学习率为0.0001,迭代5000次,加入dropout,架构如下
"""输入:x:[-1,32,32,1] 输入图像y_:[-1,10]标签CNN:CONV1: w(3x3),stride = 1,pad = 'same',fmaps = 32 ([-1,16,16,32])POOL1: p(2x2),stride = 2,pad = 'same'RELUCONV2: w(3x3),stride = 1,pad = 'same,fmaps = 64 ([-1,8,8,64])POOL2: p(2x2),stride = 2,pad = 'same'RELU,Reshape ([-1,8*8*64])DENSE1: fmaps = 1024 ([-1,1024])RELUDropoutDENSE2: fmaps = 10([-1,10])Softmax"""
完整代码如下:
import tensorflow as tfimport numpy as npimport TFRtoolsimport pickle# 生成相关目录保存生成信息def GEN_DIR():import osif not os.path.isdir('ckpt'):print('文件夹ckpt未创建,现在在当前目录下创建..')os.mkdir('ckpt')if not os.path.isdir('trainLog'):print('文件夹ckpt未创建,现在在当前目录下创建..')os.mkdir('trainLog')# 定义输入x = tf.placeholder(tf.float32,[None,32,32,1],'x')y_ = tf.placeholder(name="y_", shape=[None, 10],dtype=tf.float32)# 第一层卷积层 32x32 to 16x16 , fmaps = 32CONV1 = tf.layers.conv2d(x,32,5,padding='same',activation=tf.nn.relu,kernel_initializer=tf.random_normal_initializer(0,0.1),name='CONV1')POOL1 = tf.layers.max_pooling2d(CONV1,2,2,padding='same',name='POOL1')# 第二层卷积层 16x16 to 8x8, fmaps =64CONV2 = tf.layers.conv2d(POOL1,64,5,padding='same',activation=tf.nn.relu,kernel_initializer=tf.random_normal_initializer(0,0.1),name='CONV2')POOL2 = tf.layers.max_pooling2d(CONV2,2,2,padding='same',name='POOL2')# 第三层全连接层 8x8x64 to 1024 , fmaps = 1024flat = tf.reshape(POOL2,[-1,8*8*64]) # 平铺特征图DENSE1 = tf.layers.dense(flat,1024,activation=tf.nn.relu,kernel_initializer=tf.random_normal_initializer(0,0.1),name='DENSE1')# dropoutdrop_rate = tf.placeholder(dtype=tf.float32,name='drop_rate')DP = tf.layers.dropout(DENSE1,rate=drop_rate,name='DROPOUT')# softmax 1024 to 10 , fmaps = 10DENSE2 = tf.layers.dense(DP,10,activation=tf.nn.softmax,kernel_initializer=tf.random_normal_initializer(0,0.1),name='DENSE2')# 定义损失函数cross_entropy = -tf.reduce_sum(y_ * tf.log(DENSE2)) #计算交叉熵# 梯度下降train_step = tf.train.AdamOptimizer(0.0001).minimize(cross_entropy) #使用adam优化器来以0.0001的学习率来进行微调# 准确率测试correct_prediction = tf.equal(tf.argmax(DENSE2,1), tf.argmax(y_,1)) #判断预测标签和实际标签是否匹配accuracy = tf.reduce_mean(tf.cast(correct_prediction,"float"))# 保存模型saver = tf.train.Saver(var_list=[var for var in tf.trainable_variables()])# 数据集读取# 读取TFR,不打乱文件顺序,指定数据类型[data,label,num] = TFRtools.ReadFromTFRecord(sameName= r'.\TFR\CODE-*',isShuffle= False,datatype= tf.float64,labeltype= tf.uint8,)# 批量处理,送入队列数据,指定数据大小,不打乱数据项,设置批次大小32[data_batch,label_batch,num_batch] = TFRtools.DataBatch(data,label,num,dataSize= 32*32,labelSize= 10,isShuffle= False,batchSize= 32)# 修改格式data_batch = tf.cast(tf.reshape(data_batch,[-1,32,32,1]),tf.float32)label_batch = tf.cast(label_batch,tf.float32)ACC = [] #记录准确率# 建立会话with tf.Session() as sess:# 创建相关目录GEN_DIR()# 初始化变量init = (tf.global_variables_initializer(), tf.local_variables_initializer())sess.run(init)# 开启协调器coord = tf.train.Coordinator()# 启动线程threads = tf.train.start_queue_runners(sess=sess, coord=coord)for step in range(10000):# 获取批数据TDB = sess.run([data_batch,label_batch,num_batch])# 训练sess.run(train_step,feed_dict={x:TDB[0],y_:TDB[1],drop_rate:0.5})# 测试if step%5 == 1:train_acc = sess.run(accuracy,feed_dict={x:TDB[0],y_:TDB[1],drop_rate:0.0})ACC.append(train_acc)print('迭代次数:%d..准确率:%.3f'%(step,train_acc))# 保存模型if step % 1000 == 0 and step>0:saver.save(sess, './ckpt/CNN.ckpt', global_step=step)# 关闭线程coord.request_stop()coord.join(threads)# 保存loss记录with open('./trainLog/ACC.acc', 'wb') as a:losses = np.array(ACC)pickle.dump(losses,a)print('保存accuracy信息..')
在2000次迭代后CNN差不多收敛了。
2.3验证测试集
这里我们将训练好的CNN来验证下测试集,因为CNN是对单个字符识别,识别一个验证码需要识别4个字符,这里我们分别给出数字识别率和验证码识别率
"""该脚本用于验证测试集的准确率"""import tensorflow as tfimport numpy as np#*************************** CNN识别 *******************************# 读取测试集import TFRtools[data,label,num] = TFRtools.ReadFromTFRecord(sameName= r'.\TFR\CODE_TEST-*',isShuffle= False,datatype= tf.float64,labeltype= tf.uint8,)# 单个验证不采用批处理# [data_batch,label_batch,num_batch] = TFRtools.DataBatch(data,label,num,dataSize= 32*32,labelSize= 10,isShuffle= False,batchSize= 32)# 数据格式修正data = tf.cast(tf.reshape(data,[-1,32,32,1]),tf.float32)label = tf.cast(label,tf.float32)with tf.Session() as sess:# 初始化变量init = (tf.global_variables_initializer(), tf.local_variables_initializer())sess.run(init)# 开启协调器coord = tf.train.Coordinator()# 启动线程threads = tf.train.start_queue_runners(sess=sess, coord=coord)# 恢复模型meta_graph = tf.train.import_meta_graph('./ckpt/CNN.ckpt-4000.meta') # 加载模型meta_graph.restore(sess, tf.train.latest_checkpoint('./ckpt')) # 加载数据# 获取输入输出graph = tf.get_default_graph()x = graph.get_tensor_by_name("x:0") # 获取输入占位符xdrop_rate = graph.get_tensor_by_name("drop_rate:0") # 获取输入占位符drop_rateDENSE2 = graph.get_tensor_by_name("DENSE2/Softmax:0")# 获取输出DENSE2# 验证码识别test_total = 2583 # 测试验证码数量NUM_SC = [] # 数字识别情况ID_SC = [] # 测试集验证码识别情况for N in range(test_total):score = 0 # 每组验证码的验证成绩,满分4分for idx in range(4):# 获取数据集TD =sess.run([data,label,num])# 识别y = sess.run(DENSE2,feed_dict={x:TD[0],drop_rate:0.0})# 比较comp = np.equal(np.argmax(y),np.argmax(TD[1]))NUM_SC.append(comp)# 计分if comp:score +=1if idx ==3 :# 完成识别一组验证码ID_SC.append(score)score = 0 # 识别后清零# 打印print('第%d张验证码的第%d位识别情况:%s'%(N,idx,comp))# 统计识别率# (1)数字识别率print('数字识别率为:%.3f'%(sum(NUM_SC)/len(NUM_SC)))# (2) 验证码识别率SC = 0 # 测试集识别总分for sc in ID_SC:if sc == 4:SC +=1print('验证码识别率为:%.3f' % (SC/len(ID_SC)))# 关闭线程coord.request_stop()coord.join(threads)
测试结果:
看来对于横线干扰的字符CNN依然能够较好地识别,因为只要一个字符错误,该验证码算作错误识别,所以验证码识别率相对较低
三.在线识别
这里我们使用先前的去噪,字符分割,CNN识别方法来随机识别一张测试集中的图片
"""该脚本用于在线识别一张验证码"""import tensorflow as tffrom ImageProcess import *from PreProcess import Segment4_NUMBERdef CNN_Identify(numbers):with tf.Session() as sess:# 初始化变量init = (tf.global_variables_initializer(), tf.local_variables_initializer())sess.run(init)# 恢复模型meta_graph = tf.train.import_meta_graph('./ckpt/CNN.ckpt-4000.meta') # 加载模型meta_graph.restore(sess, tf.train.latest_checkpoint('./ckpt')) # 加载数据# 获取输入输出graph = tf.get_default_graph()x = graph.get_tensor_by_name("x:0") # 获取输入占位符xdrop_rate = graph.get_tensor_by_name("drop_rate:0") # 获取输入占位符drop_rateDENSE2 = graph.get_tensor_by_name("DENSE2/Softmax:0") # 获取输出DENSE2id = []for number in numbers:# 格式修正number = np.reshape(number,[-1,32,32,1]).astype(np.float32)# 识别y = sess.run(DENSE2, feed_dict={x: number, drop_rate: 0.0})# 记录识别结果id.append(np.argmax(y))return idcode_name = r'./TEST/0243.jpg'##(1)分割数字img_gray = cv2.imread(code_name, flags=cv2.IMREAD_GRAYSCALE) # 读取灰度图像SHOW('code',img_gray) # 显示_, img_bin = cv2.threshold(img_gray, int(0.9 * 255), 255, cv2.THRESH_BINARY_INV) # 二值化(0.9)numbers = Segment4_NUMBER(img_bin)# 分割数字##(2)识别ID = CNN_Identify(numbers)print('验证码%s的识别结果为%s'%(code_name,ID))cv2.waitKey(0)
识别效果:
四.总结
本次实验并没有直接将验证码送入CNN识别,而是先采用去噪,字符分割的预处理,再送入CNN进行训练。这里能否分割一个完整的字符很重要,如果分割不准确造成字符错位,截断等问题势必影响字符的识别,因此设计一个良好的字符分割算法尤为重要。本文中基于字符的几何特征考虑到了字符之间交杂错位的各种情况分别制定了对应的分割策略,总体上分割效果良好,如下图(字符0)
但是验证码字符情况千奇百怪,本算法也有一定的局限性,存在着错分情况(尤其是2+2型结构分割),因此在保持CNN不变的前提下,可以通过优化分割算法来进一步提高识别率.
最后,这里有完整的代码提供给需要学习的同学参考:/download/ephemeroptera/11141212
如果觉得《基于CNN的四位数字验证码识别》对你有帮助,请点赞、收藏,并留下你的观点哦!