基于CNN的四位数字验证码识别

 前言

    验证码技术作为一种反自动化技术,使得很多程序的自动化工作止步。今天作者采用一些数字图像处理和CNN方法来识别较为简单的数字验证码


实验步骤

    实验步骤主要围绕以下展开

  1. 图像预处理即滤除噪声和字符分割
  2. CNN搭建和训练
  3. 验证码识别

一. 图像预处理

接下来,我将一张验证码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~max
        num = 0 # 清零
        for elem in labels.flat:
            if elem == n:
                num += 1
        if num < small:  # 去除小连通域
            bin[np.where(labels == n)] = 0
    return bin

connectivity 表示连通域构成方式为 8邻域 or 4邻域 。在初始阶段,为了缓解斑点和字符粘连的问题,我们希望采用4邻域来分解更多的连通域,我们限制连通域面积上限为200,接下来我们看看效果:

# 去除斑点
    img_bin = RemoveSmallCC(img_bin, 200,connectivity=4)

                                                                         

相比较原始图像,这里滤出掉了大多数斑点,保留了字符、横线等大连通域。

(4) 形态学开运算

        在滤除斑点的过程中,依然可能存在少部分斑点粘连在字体上,这里我们采用形态学开运算,即腐蚀、消除、膨胀的方法来断开斑点和字符连通,去除斑点,再恢复初始字符形态。但是开运算的过程中也可能会导致字符结构断开并被消除,因此我们选择较小的结构元素(本文采用3X3),来去除那些较微弱的粘连问题

    
    # 定义结构元素
    size = 3
    kernel = 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 = 0
    COUNT = []
    LOC = []
    for i in range(len(PROJ)):
        if PROJ[i]:# 如果非零则累加
            num+=1
            if i == 0 or PROJ[i-1]==0:# 记录片段起始位置
                start = i
            if i == len(PROJ)-1 or PROJ[i+1]==0 :# 定义片段结束标志,并记录片段
                end = i
                if num > 10:# 提取有效片段
                    COUNT.append(num)
                    LOC.append((start,end))
                num = 0 #清0
    return 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 LOC

    if 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 LOC

    if 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)//2
           m2 = (start2+end2)//2
           return [(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+start
            m2 = (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 LOC
    if len(COUNT) ==1:# (4)
        start = LOC[0][0]
        end = LOC[0][1]
        m1 = (end-start)//4+start
        m2 = (end - start) // 4*2 + start
        m3 = (end - start) // 4*3 + start
        return [(start,m1),(m1+1,m2),(m2+1,m3),(m3+1,end)]

   调用方法

   # PROJ提取数字部分
    PROJ = BIN_PROJECT(IMG)
    LOC,COUNT = Extract_Num(PROJ)

    # 优化COUNT
    COUNT = 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 random
train_list = random.sample(img_list,train_total) #训练样本列表
test_list = [item for item in img_list if item not in train_list] # 测试样本列表

样本的保存和读入采用TFRecord格式,其中依赖TFRtools脚本下载链接为:https://download.csdn.net/download/ephemeroptera/11139598

2.2 CNN搭建和训练

 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'
    RELU
    
    CONV2: 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])
    RELU
    Dropout
    
    DENSE2: fmaps = 10                                   ([-1,10])
    Softmax
"""

完整代码如下:

import tensorflow as tf
import  numpy as np
import TFRtools
import pickle

# 生成相关目录保存生成信息
def GEN_DIR():
    import os
    if 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 = 32
CONV1 = 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 =64
CONV2 = 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 = 1024
flat = 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')
# dropout
drop_rate = tf.placeholder(dtype=tf.float32,name='drop_rate')
DP = tf.layers.dropout(DENSE1,rate=drop_rate,name='DROPOUT')

# softmax 1024 to 10 , fmaps = 10
DENSE2 = 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 tf
import 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")  # 获取输入占位符x
    drop_rate = graph.get_tensor_by_name("drop_rate:0") # 获取输入占位符drop_rate
    DENSE2 = 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 +=1
            if 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 +=1
    print('验证码识别率为:%.3f' % (SC/len(ID_SC)))


    # 关闭线程
    coord.request_stop()
    coord.join(threads)





测试结果:

                                                                                     

看来对于横线干扰的字符CNN依然能够较好地识别,因为只要一个字符错误,该验证码算作错误识别,所以验证码识别率相对较低


三.在线识别

这里我们使用先前的去噪,字符分割,CNN 识别方法来随机识别一张测试集中的图片

"""
该脚本用于在线识别一张验证码
"""
import tensorflow as tf
from ImageProcess import *
from PreProcess import Segment4_NUMBER

def 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")  # 获取输入占位符x
        drop_rate = graph.get_tensor_by_name("drop_rate:0")  # 获取输入占位符drop_rate
        DENSE2 = graph.get_tensor_by_name("DENSE2/Softmax:0")  # 获取输出DENSE2

        id = []
        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 id



code_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不变的前提下,可以通过优化分割算法来进一步提高识别率.

 最后,这里有完整的代码提供给需要学习的同学参考:https://download.csdn.net/download/ephemeroptera/11141212

 

Guess you like

Origin blog.csdn.net/Ephemeroptera/article/details/89478727