24 April 2019

目录:

概述

从春节后,就着手这个项目,从开始研究,到上手代码,到样本生成,到真实样本采集等,经历了整个过程,已经2个月了,项目也基本上完成的差不多了,开一个帖子,把自己的项目中的点滴记录下来。

CTPN

代码库

我最终选择的是eragonruan的版本,fork了一份,放到了我的github上,但是CTPN确实有不少问题,如果让我重新选择的话,我可能会选择EAST或者PSNet。特别是PSNet,有个QQ群【785515057】,群主就是复现PSNet代码的“晓恒”,群里面也非常友爱互助,真后悔没有早点遇到这群人。不过,CTPN是一个保守选择,对我们的场景,是够用的了。我也没时间再去研究PSNet了,也只有这样了,下个项目或者二期的时候,我会去再选择PSNet了。

另外,我fork的版本修改了和增加了狠多内容,包括:

  • 重写了训练数据生成的部分,基本上是从头写的了
  • 重构了样本split的代码
  • 修改了样本加载机制,没有用TFRecord,而是用的shuffle_batch的方式
  • 重构了训练代码,加入了早停、Validate验证等
  • 重构了预测代码
  • 修改了很多细节,模型细节,训练细节等

网络结构没有什么变化,还是vgg做为backbone,然后接一个LSTM,全连接后,做一个anchor的正负例和回归预测。不过,这些代码里面,增加了大量的注释,都是我对代码的理解,特别是核心类anchor_target_layer.py,由于注释太多,我都惨不忍睹了,只好备份一下注释版,然后删除掉注释,整了一个整洁版。

修改细节

样本数据生成

generator.py,里面尽量模拟了各种的文字情况。从网上花钱买了一些白纸背景,然后,搞了一堆的20多种字体,生成的时候,不仅仅生成文字,还要生成数字,字母等,而且考虑到各自的比例,按概率生成。并且,做了各类模糊和处理,加入了仿射(完成倾斜),模糊,锐化,加入了干扰线。

bbox样本的split

我们知道,CTPN的样本,不是你的标注,而是把你的标注的四边形,变成一个一个的小矩形,这个是靠split.py完成的,代码读着有些诡异,要对opencv处理多边形的形态学api比较熟悉才行,倒是不难。有个细节说一下,切的时候,第一个图像必须是要和16整数倍的像素对齐的,所以可能会切出来宽度很小,但是太窄了,我担心当做样本去和anchor比较的时候,基本上没啥用(算IoU算不出高值来,算regression调整有比较剧烈),所以,我把小于3个像素的切出来的bbox都直接扔掉了。最后一个bbox也有这个问题,我本来也是同样的处理方法,后来发现右边总是没有,老缺一块,所以后来我就索性多算一些,直接把最后一个bbox直接设成16像素了,多点就多点吧,后面crnn识别反正也能搞定。

训练

train.py

启动参数

加入了一系列的参数,方便训练,以及验证。如lambda,是按照论文加上的,主要是观察到rpn classes的loss和regression的loss,差差不多3个数量级,所以默认设置lambda=1000;

验证、F1和早停

之前的代码是没有验证集的,我加入了验证集,并且在训练的过程中进行验证。验证什么?验证F1。F1如何算?这个后面再细谈。

加入了F1,Recall,Precision的计算,并且把他们加到了summary中,以便于后续在TensorBoard中观察。

加入了早停,默认是5次,加载10张验证集图片,计算探测出来的大的GT框的F1,如果持续5次都没有改进,就会早停。

图片Resize

之前,看eragonruan君的代码中总是resize图片,我当时就想,resize啥啊,不resize也没事啊,于是,我就给他的resize相关代码,都删掉了。我造的样本没事,但是,当我用真实的样本的时候,训练不超过100 steps就出现OOM,靠。同样的代码,我切回我的训练集,就没事。没办法,只好又把resize代码加了回来。resize的处理还是有些麻烦的,所以我秉承一个原则,在数据进入network前才做resize,从network预测出来的结果立刻就是un-resize回去。我写了单元测试,没事,但是还有一丝担心,这毕竟对训练息息相关。

可视化调试

我在session.run的feed_dict加入了一个张量,“input_image_name”,把文件名传入了计算图,为何呢?为了可视化。我想在训练过程中,把整个训练过程可视化。可视化什么?可视化anchor的产生过程。anchor的生成,和GT别的比较,选择,这个过程都是在代码中,只能通过读代码理解代码,才能理解,一旦有问题,你根本无法验证。我很郁闷这点。所以,我就写了一个调试代码,把图片传入后,把anchor生成、筛选过程等,都可视化出来,把anchor、gt和一些调试信息,都画到了图上,输出到data/pred目录下。

评价

实现了对CTPN预测出来的框的检测,实现是参考这篇这篇:

ICDAR2013则使用了新evaluation方法:DetEval,也就是十几年前Wolf提出的方法。“新方法”同时考虑了一对一,一对多,多对一的情况, 但不能处理多对多的情况。 (作者说,实验结果表示在文本检测里这种情况出现的不多。) 这里的框无论是标定框还是检测框都认为是水平的矩形框

其实,思路很简单,就是分为3种情况,1对1,1对M,M对1。每个框都属于这3种情况中的一个,不会重复。细节可以参考那两篇博文

这个算法支持对小框,也就是bbox,和大框(GT)的评价,不过,后来bbox的评价基本上被我废弃了,因为,没必要,直接看大框的就可以了。

Anchor的筛选

anchor_target_layer.py

我认为,这个类才是整个项目的核心,代码复杂,也不好理解,也是我花时间比较多的内容。看这个内容,还是要对论文熟悉,对算法熟悉,否则,你看着肯定是馒头雾水。你可以参阅白裳的这篇讲解,我这里只说说我趟的坑:

一个诡异问题

曾经遇到过一个问题,

gt_argmax_overlaps = overlaps.argmax(axis=0)  #G#找到每个位置上9个anchor中与gtbox,overlap最大的那个
gt_max_overlaps = overlaps[gt_argmax_overlaps,np.arange(overlaps.shape[1])]
gt_argmax_overlaps = np.where(overlaps == gt_max_overlaps)[0]
labels[gt_argmax_overlaps] = 1   # 每个位置上的9个anchor中overlap最大的认为是前景

这段代码我理解不了,觉得没用,就给注释了,原因是,我把gt_argmax_overlaps = np.where(overlaps == gt_max_overlaps)[0]得到的这个gt_argmax_overlaps对应的anchor画出来(紫色的),全屏幕都是了:

其实我是没想清楚的,当时,后来凭借实证,就觉着这个代码是祸害,就给注释了。后来和同事讨论了一上午,终于搞清楚了,这个是有用的,这个是有用的。

# 这句话是找到每个gt对应的最大的IoU的那个anchor的行号,一共有多少个个呢,gt的个数一样,也就是270多个
gt_argmax_overlaps = overlaps.argmax(axis=0) 
# 然后,从overlaps中,把每个行,对应的gt的那列,的值,拿出来,是一个一维数组(这块有点绕,看下面的例子吧)
gt_max_overlaps = overlaps[gt_argmax_overlaps,np.arange(overlaps.shape[1])]
# gt_max_overlaps是长度为\|GT\|,值为overlaps中每个gt对应最大的ioU的值的数组,
# 那这个where后,就是把这个GT列对应的其他的和这个max最大IoU值一样的anchor行号,也找出来
gt_argmax_overlaps = np.where(overlaps == gt_max_overlaps)[0]
labels[gt_argmax_overlaps] = 1   #设置为前景

所以,这个就是说,把某个GT对应的最大的IoU的anchor挑出来,但是,对某个GT来说,还有别的anchor和他相交的IoU的值和这个max值一样的话,你也不应该漏掉呀,恩,合理,所以给挑出来。可是,为何挑出几乎所有的anchor了呢?满篇的紫框?!原因是,某个GT的最大的IoU值为0,这样的话,不得了了,因为每个anchor都有等于0的overlap中的值,所以每个anchor都成为备选了。那为何为0呢,按理说,GT最大的那个anchor,总是存在的,至少有一个anchor应该和GT相交吧,那IoU怎么也不是0呀?!我们怎么办呢?我们只好把这个问题anchor,也就是IoU=0的这个anchor画出来,也把他相交最大IoU的那个GT也画出来。

看到了吧,就是最右侧那个半拉子绿框。后来我俩一想,终于想明白了:

那就是你的anchor是按照feature map生成的,是16像素的整数倍数,可是你用feature map还原到原图的时候,那最右侧的,大于最后一个16整数倍像素的,但是又不到下一个16像素宽的边,这个之间的像素,是无法被anchor们覆盖掉的。可是,偏偏有个gt就在这里。那么这个gt无论和那个anchor算IoU都是0啊!这个就是问题所在了。

这个问题,未来的影响是,这种地方的文字可能检测不到了,恩,这是问题。 但是对其他的应该没啥影响,所以,我决定还是忽略掉。 这个问题,很隐蔽,我们的体会就是,一定要可视化,这样,很多问题就明显的显现出来了。

关于预测的交叉熵

有一段时间,死活预测不出来,我只好又回去读论文: 1.读论文,某个GT框,跟他对着的最大IoU的那个anchor,自动就是样本了,我之前给忽略了,这样的样本,不知道是不是跟这个有关系,但是论文里明确说这样的得要:

啥意思?就是说,算回归的时候,除了那些是正例的框外,跟GT的IoU大于0.5的也可以参与回归计算。貌似我fork的这个text-detect-ctpn代码没有实现。如果实现的话,在算overlaps矩阵的时候,应该单独再存一下IoU>0.5的anchor们,他们也许达不到前景的目标$(0.7)$,但是,他们可以参与均方误差回归的。 恩,你看,他的前后景判断,和,他的回归用的anchor可能不是一样的、一批的,但是,我们的这个项目里面,是把这两者绑定到一起的,不过,在我看来,这样倒也无所谓。

一直有个疑问?为何要算背景的概率?你既然算了前景的概率,用1减去前景的概率,不就是背景概率了么?这不是个二分类么?!我看代码里面:

cls_pred_shape = tf.shape(cls_pred)
cls_pred_reshape = tf.reshape(cls_pred, [cls_pred_shape[0], cls_pred_shape[1], -1, 2]) #2是指两个概率值,(1, H, WxA, 2)
rpn_cls_score = tf.reshape(cls_pred_reshape, [-1, 2]) #(HxWxA, d)
rpn_keep = tf.where(tf.not_equal(rpn_label, -1)) # rpn只剩下是非-1的那些元素的下标,注意!是位置下标!
rpn_cls_score = tf.gather(rpn_cls_score, rpn_keep) # 把对应的前景的概率取出来,rpn_cls_score是从cls_pred来的(具体自己读代码)
rpn_cross_entropy_n = tf.nn.sparse_softmax_cross_entropy_with_logits(labels=rpn_label, logits=rpn_cls_score)

然后,我打印了一番: 做交叉熵:predict[[[0.000618884573 -0.00124687876]][[-0.002699879 -0.00737032201]] 卧槽!你看,不是0/1分布的(0,1分布应该是相加为1,概率分布嘛),这!这!这!

tf.nn.sparse_softmax_cross_entropy_with_logits:函数输入的logits是非softmax的。
cls_prob = tf.reshape(tf.nn.softmax(tf.reshape(cls_pred_reshape, [-1, cls_pred_reshape_shape[3]])),
                      [-1,
                       cls_pred_reshape_shape[1],
                       cls_pred_reshape_shape[2],
                       cls_pred_reshape_shape[3]],
                      name="cls_prob")
rpn_cross_entropy_n = tf.nn.sparse_softmax_cross_entropy_with_logits(labels=rpn_label, logits=rpn_cls_score)

后来搞清楚,tf.nn.sparse_softmax_cross_entropy_with_logits,要求你出入的logits,一定是非归一化的值。

训练的trick

这一次的训练到达了84%的F1,效果如何呢?

CRNN

集成