分享

【OCR技术系列之三】大批量生成文字训练集 - 风骚的小柴犬 - 博客园

 黄爸爸好 2023-09-21

www.cnblogs.com 2019-05-12 23:15

到周末了,终于可以继续可以静下心写一写OCR方面的东西。上次谈到文字的切割,今天打算总结一下我们怎么得到用于训练的文字数据集。如果是想训练一个手写体识别的模型,用一些前人收集好的手写文字集就好了,比如中科院的这些数据集。但是如果我们只是想要训练一个专门用于识别印刷汉字的模型,那么我们就需要各种印刷字体的训练集,那怎么获取呢?借助强大的图像库,自己生成就行了!

先捋一捋思路,生成文字集需要什么步骤:

第三步的生成字体图像最为重要,如果仅仅是生成很正规的文字,那么用这个正规文字集去训练模型,第一图像数目有点少,第二模型泛化能力比较差,所以我们需要对字体图像做大量的图像处理工作,以增大我们的印刷体文字数据集。

我总结了一下,我们可以做的一些图像增强工作有这些:

做完以上增强后,我们得到的数据集已经非常庞大了。

现在开始一步一步生成我们的3755个汉字的印刷体文字数据集。

这里的汉字、label映射表的生成我使用了pickel模块,借助它生成一个id:汉字的映射文件存储下来。这里举个小例子说明怎么生成这个“汉字:id”映射表。

首先在一个txt文件里写入你想要的汉字,如果对汉字对应的ID没有要求的话,我们不妨使用该汉字的排位作为其ID,比如“一二三四五”中,五的ID就是00005。如此类推,把汉字读入内存,建立一个字典,把这个关系记录下来,再使用pickle.dump存入文件保存。

字体文件上网收集就好了,但是值得注意的是,不是每一种字体都支持汉字,所以我们需要筛选出真正适合汉字生成的字体文件才可以。我一共使用了十三种汉字字体作为我们接下来汉字数据集用到的字体,具体如下图:

当然,如果需要进一步扩大数据集来增强训练得到的模型的泛化能力,可以花更多的时间去收集各类汉字字体,那么模型在面对各种字体时也能从容应对,给出准确的预测。

首先是定义好输入参数,其中包括输出目录、字体目录、测试集大小、图像尺寸、图像旋转幅度等等。

def args_parse(): #解析输入参数 parser = argparse.ArgumentParser( description=description, formatter_class=RawTextHelpFormatter) parser.add_argument('--out_dir', dest='out_dir', default=None, required=True, help='write a caffe dir') parser.add_argument('--font_dir', dest='font_dir', default=None, required=True, help='font dir to to produce images') parser.add_argument('--test_ratio', dest='test_ratio', default=0.2, required=False, help='test dataset size') parser.add_argument('--width', dest='width', default=None, required=True, help='width') parser.add_argument('--height', dest='height', default=None, required=True, help='height') parser.add_argument('--no_crop', dest='no_crop', default=True, required=False, help='', action='store_true') parser.add_argument('--margin', dest='margin', default=0, required=False, help='', ) parser.add_argument('--rotate', dest='rotate', default=0, required=False, help='max rotate degree 0-45') parser.add_argument('--rotate_step', dest='rotate_step', default=0, required=False, help='rotate step for the rotate angle') parser.add_argument('--need_aug', dest='need_aug', default=False, required=False, help='need data augmentation', action='store_true') args = vars(parser.parse_args()) return args

接下来需要将我们第一步得到的对应表读入内存,因为这个表示ID到汉字的映射,我们在做一下转换,改成汉字到ID的映射,用于后面的字体生成。

#将汉字的label读入,得到(ID:汉字)的映射表label_dict label_dict = get_label_dict() char_list=[] # 汉字列表 value_list=[] # label列表 for (value,chars) in label_dict.items(): print (value,chars) char_list.append(chars) value_list.append(value) # 合并成新的映射关系表:(汉字:ID) lang_chars = dict(zip(char_list,value_list)) font_check = FontCheck(lang_chars)

我们对旋转的角度存储到列表中,旋转角度的范围是[-rotate,rotate].

if rotate < 0: roate = - rotate if rotate > 0 and rotate <= 45: all_rotate_angles = [] for i in range(0, rotate+1, rotate_step): all_rotate_angles.append(i) for i in range(-rotate, 0, rotate_step): all_rotate_angles.append(i) #print(all_rotate_angles)

现在说一下字体图像是怎么生成的,首先我们使用的工具是PIL。PIL里面有很好用的汉字生成函数,我们用这个函数再结合我们提供的字体文件,就可以生成我们想要的数字化的汉字了。我们先设定好我们生成的字体颜色为黑底白色,字体尺寸由输入参数来动态设定。

# 生成字体图像 class Font2Image(object): def __init__(self, width, height, need_crop, margin): self.width = width self.height = height self.need_crop = need_crop self.margin = margin def do(self, font_path, char, rotate=0): find_image_bbox = FindImageBBox() # 黑色背景 img = Image.new("RGB", (self.width, self.height), "black") draw = ImageDraw.Draw(img) font = ImageFont.truetype(font_path, int(self.width * 0.7),) # 白色字体 draw.text((0, 0), char, (255, 255, 255), font=font) if rotate != 0: img = img.rotate(rotate) data = list(img.getdata()) sum_val = 0 for i_data in data: sum_val += sum(i_data) if sum_val > 2: np_img = np.asarray(data, dtype='uint8') np_img = np_img[:, 0] np_img = np_img.reshape((self.height, self.width)) cropped_box = find_image_bbox.do(np_img) left, upper, right, lower = cropped_box np_img = np_img[upper: lower + 1, left: right + 1] if not self.need_crop: preprocess_resize_keep_ratio_fill_bg = \ PreprocessResizeKeepRatioFillBG(self.width, self.height, fill_bg=False, margin=self.margin) np_img = preprocess_resize_keep_ratio_fill_bg.do( np_img) # cv2.imwrite(path_img, np_img) return np_img else: print("img doesn't exist.")

我们写两个循环,外层循环是汉字列表,内层循环是字体列表,对于每个汉字会得到一个image_list列表,里面存储着这个汉字的所有图像。

for (char, value) in lang_chars.items(): # 外层循环是字 image_list = [] print (char,value) #char_dir = os.path.join(images_dir, "%0.5d" % value) for j, verified_font_path in enumerate(verified_font_paths): # 内层循环是字体 if rotate == 0: image = font2image.do(verified_font_path, char) image_list.append(image) else: for k in all_rotate_angles: image = font2image.do(verified_font_path, char, rotate=k) image_list.append(image)

我们将image_list中图像按照比例分为训练集和测试集存储。

test_num = len(image_list) * test_ratio random.shuffle(image_list) # 图像列表打乱 count = 0 for i in range(len(image_list)): img = image_list[i] #print(img.shape) if count < test_num : char_dir = os.path.join(test_images_dir, "%0.5d" % value) else: char_dir = os.path.join(train_images_dir, "%0.5d" % value) if not os.path.isdir(char_dir): os.makedirs(char_dir) path_image = os.path.join(char_dir,"%d.png" % count) cv2.imwrite(path_image,img) count += 1

写好代码后,我们执行如下指令,开始生成印刷体文字汉字集。

解析一下上述指令的附属参数:

生成这么一个3755个汉字的数据集的所需的时间还是很久的,估计接近一个小时。其实这个生成过程可以用多线程、多进程并行加速,但是考虑到这种文字数据集只需生成一次就好,所以就没做这方面的优化了。数据集生成完我们可以发现,在dataset文件夹下得到train和test两个文件夹,train和test文件夹下都有3755个子文件夹,分别存储着生成的3755个汉字对应的图像,每个子文件的名字就是该汉字对应的id。随便选择一个train文件夹下的一个子文件夹打开,可以看到所获得的汉字图像,一共634个。

dataset下自动生成测试集和训练集

测试集和训练集下都有3755个子文件夹,用于存储每个汉字的图像。

生成出来的汉字图像

第三步生成的汉字图像是最基本的数据集,它所做的图像处理仅有旋转这么一项,如果我们想在数据增强上再做多点东西,想必我们最终训练出来的OCR模型的性能会更加优秀。我们使用opencv来完成我们定制的汉字图像增强任务。

因为生成的图像比较小,仅仅是30*30,如果对这么小的图像加噪声或者形态学处理,得到的字体图像会很糟糕,所以我们在做数据增强时,把图片尺寸适当增加,比如设置为100×100,再进行相应的数据增强,效果会更好。

def add_noise(cls,img): for i in range(20): #添加点噪声 temp_x = np.random.randint(0,img.shape[0]) temp_y = np.random.randint(0,img.shape[1]) img[temp_x][temp_y] = 255 return img

def add_erode(cls,img): kernel = cv2.getStructuringElement(cv2.MORPH_RECT,(3, 3)) img = cv2.erode(img,kernel) return img

def add_dilate(cls,img): kernel = cv2.getStructuringElement(cv2.MORPH_RECT,(3, 3)) img = cv2.dilate(img,kernel) return img

然后做随机扰动

def do(self,img_list=[]): aug_list= copy.deepcopy(img_list) for i in range(len(img_list)): im = img_list[i] if self.noise and random.random()<0.5: im = self.add_noise(im) if self.dilate and random.random()<0.25: im = self.add_dilate(im) if self.erode and random.random()<0.25: im = self.add_erode(im) aug_list.append(im) return aug_list

输入指令

使用这种生成的图像如下图所示,第一数据集扩大了两倍,第二图像的丰富性进一步提高,效果还是明显的。当然,如果要获得最好的效果,还需要调一下里面的参数,这里就不再详细说明了。

至此,我们所需的印刷体汉字数据集已经成功生成完毕,下一步要做的就是利用这些数据集设计一个卷积神经网络做文字识别了!完整的代码可以在我的github获取。

【OCR技术系列之二】文字定位于切割

要做文字识别,第一步要考虑的就是怎么将每一个字符从图片中切割下来,然后才可以送入我们设计好的模型进行字符识别。现在就以下面这张图片为例,说一说最一般的字符切割的步骤是哪些。

当然,我们实际上要识别的图片很可能没上面那张图片如此整洁,很可能是倾斜的,或者是带噪声的,又或者这张图片是用手机拍下来下来的,变得歪歪扭扭,所以需要进行图片预处理,把文本位置矫正,把噪声去除,然后才可以进行进一步的字符分割和文字识别。这些预处理的方法在我的接下来的几篇博客都会提到,大家可以参考参考:

透视矫正水平矫正

在预处理工作做好之后,我们就可以开始切割字符了。最普通的切割算法可以总结为以下几个步骤:

一看只有两个步骤,好像不太难,马上编程实现看看效果。

首先是行切割。这里提到了水平投影的概念,估计有的读者没听过这个名词,我来解释一下吧。水平投影,就是对一张图片的每一行元素进行统计(就是往水平方向统计),然后我们根据这个统计结果画出统计结果图,进而确定每一行的起始点和结束点。下面提到的垂直投影也是类似的,只是它的投影方向是往下的,即统计每一列的元素个数。

根据上面的解释,我们可以写出一个用于水平投影和垂直投影的函数。

#define V_PROJECT 1 //垂直投影(vertical) #define H_PROJECT 2 //水平投影(horizational) typedef struct { int begin; int end; }char_range_t; //获取文本的投影以用于分割字符(垂直,水平),默认图片是白底黑色 int GetTextProjection(Mat &src, vector<int>& pos, int mode) { if (mode == V_PROJECT) { for (int i = 0; i < src.rows; i++) { uchar* p = src.ptr<uchar>(i); for (int j = 0; j < src.cols; j++) { if (p[j] == 0) //是黑色像素 { pos[j]++; } } } } else if (mode == H_PROJECT) { for (int i = 0; i < src.cols; i++) { for (int j = 0; j < src.rows; j++) { if (src.at<uchar>(j, i) == 0) { pos[j]++; } } } } return 0; }

上面代码提到的vector pos就是用于存储垂直投影和水平投影的位置的,我们可以根据它来确定行的位置。我们先把水平投影画出来。

下面是画出水平(垂直)投影图的代码实现。

void draw_projection(vector<int>& pos, int mode) { vector<int>::iterator max = std::max_element(std::begin(pos), std::end(pos)); //求最大值 if (mode == H_PROJECT) { int height = pos.size(); int width = *max; Mat project = Mat::zeros(height, width, CV_8UC1); for (int i = 0; i < project.rows; i++) { for (int j = 0; j < pos[i]; j++) { project.at<uchar>(i, j) = 255; } } imshow("horizational projection", project); } else if (mode == V_PROJECT) { int height = *max; int width = pos.size(); Mat project = Mat::zeros(height, width, CV_8UC1); for (int i = 0; i < project.cols; i++) { for (int j = project.rows - 1; j >= project.rows - pos[i]; j--) { //std::cout << "j:" << j << "i:" << i << std::endl; project.at<uchar>(j, i) = 255; } } imshow("vertical projection", project); } waitKey(); }

水平投影图:

通过上面的水平投影图,我们很容易就能确定每一行文字的位置,确定的思路如下:我们可以以每个小山峰的起始结束点作为我们文本行的起始结束点,当然我们要对这些山峰做些约束,比如这些山峰的跨度不能太小。这样子我们就得到每一个文本行的位置,接着我们就根据这些位置将每个文本行切割下来用于接下来的单个字符的切割。

//获取每个分割字符的范围,min_thresh:波峰的最小幅度,min_range:两个波峰的最小间隔 int GetPeekRange(vector<int> &vertical_pos, vector<char_range_t> &peek_range, int min_thresh = 2, int min_range = 10) { int begin = 0; int end = 0; for (int i = 0; i < vertical_pos.size(); i++) { if (vertical_pos[i] > min_thresh && begin == 0) { begin = i; } else if (vertical_pos[i] > min_thresh && begin != 0) { continue; } else if (vertical_pos[i] < min_thresh && begin != 0) { end = i; if (end - begin >= min_range) { char_range_t tmp; tmp.begin = begin; tmp.end = end; peek_range.push_back(tmp); begin = 0; end = 0; } } else if (vertical_pos[i] < min_thresh || begin == 0) { continue; } else { //printf("raise error!\n"); } } return 0; }

切割每一行,然后我们得到了一行文本,我们继续对这行文本进行垂直投影。

紧接着我们根据垂直投影求出来每个字符的边界值进行单个字符切割。方法与垂直投影的方法一样,只不过,因为字符排列得比较紧密,仅通过投影确定字符得到的结果往往不够准确的。不过先不管了,先切下来看看。

从上图看出,切割效果不太好,那多切割几行再看看。

效果确实不咋滴,那换成英文文档来测试这个切割算法。

比如切割这个英语文本图片

切割效果还是很不错的:

那为什么英语的切割效果很好,但中文效果一般呢?

分析其原因,这其实跟中文的字体复杂度有关的,中文的字符的笔画和形态都比英文的多,更重要的是英文字母都是绝大部分都是联通体,切割起来很简单,但是汉字多存在左右结构和上下结构,很容易造成过度切割,即把一个左右偏旁的汉字切成了两份,比如上面的“则”字。

针对行字符分割,左右偏旁的字难以分割的情况,我觉得可以做以下处理:

因为以上的思路可能只适应于纯汉字文本,所以就不贴代码了。

最后贴几张分割字符的图吧,感觉分割效果不太让人满意,主要是汉字的分割确实很有难度,左右偏旁的字经常分割错误。

英文的切割还是比较简单的,毕竟英文字母基本都是联通体,而且没有像汉字那样的左右结构。

对于字体间隔比较宽的汉字文档,总的看来分割任务基本完成,但是左右结构的汉字依然难以正确分割。

最后看一下一些字体较小,字体间隔较窄的情况。这类情况确实分割效果大打折扣,因为每个字体粘连过于接近,字体的波谷很难确定下来,进而造成切割字符失败。

总结

汉字字符切割,看似简单,做起来其实很难做得很好,我也对此查阅了很多论文,发现其实很多论文也谈到了,汉字确实很那做到一个高正确率的分割,直至现在还没有一统江湖的解决方案。汉字切割的失败,就会直接导致了后面OCR识别的失败,这也是当前很多一些很厉害的OCR公司都没法把汉字做到100%识别的一个原因吧。所以这个问题就必须得到很好的解决。现在解决汉字切割失败(过切割,一个字被拆成两个)的较好方法是,在OCR识别中再把它修正。比如“刺”字被分为两部分了,那么我们就直接将这两个“字”送去识别,结果当然是得到一个置信度很低的一个反馈,那么我们就将这两个部分往他们身边最近的、而且没被成功识别的部分进行合并,再将这个合并后的字送进OCR识别,这样子我们就可以通过识别反馈来完成汉字的正确分割和识别了。既然一些基于图像处理的方法基本很难把汉字分割的效果做得很好,那深度学习呢?我先去试试,效果好的话再分享给大家。

代码在我的Github上

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多