一、FOTS方法的概述
FOTS,Fast Oriented Text Spotting With a Unified Network—使用统一的网络进行快速文本定位是商汤在2018CVPR提出的快速的end2end的文本检测与识别方法,这是面向文本检测和识别的第一个端到端可训练框架。通过共享训练特征,互补监督的方法减少了特征提取所需的时间,从而加快了整体的速度。与以前的两阶段文本识别方法相比,如CTPN,FOTS引入新颖的ROIRotate操作,将文本检测和识别统一end2end pipeline,并且可以解决更复杂和困难的情况,不仅适用于水平文本;FOTS在速度和性能方面完全碾压了CTPN,可以进行文本识别的实时运行。
二、FOTS整体网络架构—如何利用网络进行预测
FOTS的整体网络结构如下图,将图片进行数据增强,然后作为网络输入;首先使用共享的卷积网络提取特征图;特征图的去向有两个分支,一个是向上的文本检测分支,用于bounding box的预测;还有一个分支就是利用ROIRotate从特征图中提取与上面检测结果对应的文本建议框特征,然后将文本建议框特征输入到文本识别网络,即由递归神经网络RNN的encoder和连接时序分类器CTC的decoder组成,得到建议框中文本的识别内容。由于整个全卷积网络中所有的模块都是可微的,因此可以进行整体的端到端的训练。FOTS主要由四个组件组成:共享卷积Shared Convolutions,文本检测分支Text Detection Branch,ROIRotate以及分本识别分支Text Recognition。
1、主干特征提取网络—Shared Convolutions
FOTS使用了ResNet50作为主干特征提取网络,将输入的图片不断进行高度和宽度的压缩,即压缩为原图的1/4,1/8,1/16,1/32,提取高语义的特征图,将上述压缩得到的4个特征图构建特征金字塔FPN,即利用上采样,即线性插值,恢复高度和宽度,将高语义特征与低语义特征进行特征层融合,得到更加细致的特征图。
ResNet残差网络有两个最主要的基本块,我们称之为Conv Block和Identity Block。Conv Block和Identity Block的结构如下:
图片转自https://blog.youkuaiyun.com/weixin_44791964/article/details/104629135。
可以看到在Conv Block中,输入input在左右两边进行的操作的是不一样的,左边先进行了两次卷积块,一次卷积块即Conv+BN+Relu,然后又进行了Conv+BN的操作;而右边只进行了一次的Conv+BN;最后是将两边的输出进行相加Add和Relu激活函数操作;在Conv Block中输入和输出的shape可以是不一样的,因此Conv Block是不可以连续的串联,它是用来改变网络的维度;而Identity Block中的左边的操作和Conv Block时一样的,而右边输入input不进行任何的操作,因此在Identity Block中输入和输出的shape是一样的,可以进行连续多次的串联,用于加深网络的深度。ResNet网络就是由Conv Block和Identity Block不断重复,ResNet网络不仅能达到很深,而且每次Block提取到的特征总是不比原来差!!!
以代码中将图片随机裁剪为512x512为例,展示输入图片在ResNet50残差网络中shape的变化:
我们将输入图片crop成512x512作为input输入,首先将input进行Zeropadding操作,然后再进行经过一个步长stride=2的卷积块(Conv+BN+Relu),此时的shape为256x256x64,此特征层对应上面论文图中的Conv1;再经过一次步长stride=2的Maxpooling最大池化,此时的shape为128x128x64,对应的特征层为Pool1;接着进入第一个残差块,先进行了一次Conv Block,这里的Conv Block中卷积的步长stride=1,因此高度和宽度是不变的,输出的shape为128x128x256,然后进行了两次的Identity Block,在Identity Block中高度和宽度以及输入和输出的channels不变,因此输出仍然为128x128x256,对应的特征层为Res2;进入第二个残差块,先进行了一次Conv Block,这里的Conv Block中卷积的步长stride=2,因此高度和宽度继续压缩1/2,输出的shape为64x64x512,然后进行了三次的Identity Block,在Identity Block中高度和宽度以及输入和输出的channels不变,因此输出仍然为64x64x512,对应的特征层为Res3;进入第三个残差块,先进行了一次Conv Block,这里的Conv Block中卷积的步长stride=2,因此高度和宽度继续压缩1/2,输出的shape为32x32x1024,然后进行了五次的Identity Block,在Identity Block中高度和宽度以及输入和输出的channels不变,因此输出仍然为32x32x1024,对应的特征层为Res4;进入最后一个残差块,先进行了一次Conv Block,这里的Conv Block中卷积的步长stride=2,因此高度和宽度继续压缩1/2,输出的shape为16x16x2048,然后进行了两次的Identity Block,在Identity Block中高度和宽度以及输入和输出的channels不变,因此输出仍然为16x16x2048,对应的特征层为Re5;
2、通过特征融合获取高分辨率的共享特征图—Shared Features
利用主干网络ResNet50我们分别获取了长宽压缩1/4的Res2特征层,压缩1/8的Res3特征层,压缩1/16的Res4特征层以及压缩1/32的Res5特征层,我们就利用这四个特征层构建特征金字塔。首先将Res5特征层16x16x2048进行Deconv上采样,代码中上采样为线性插值Interpolate,上采样后shape为32x32x2048,将此输出再与Res4的32x32x1024进行通道数的Concatenate操作,进行特征融合,此时shape为32x32x(1024+2048),然后再进行一次kernel_size为1x1的卷积块Conv+BN+Relu,进行通道数的调整,shape为32x32x128,然后是有进行kernel_size为3x3的卷积块Conv+BN+Relu,进行特征的提取;该输出结果32x32x128继续进行上采样,shape变为64x64x128,再与Res3的64x64x512进行通道数的Concatenate操作,进行特征融合,此时shape为64x64x(128+512),然后又是进行一次kernel_size为1x1的卷积块Conv+BN+Relu,进行通道数的调整,shape为64x64x64,然后是有进行kernel_size为3x3的卷积块Conv+BN+Relu,进行特征的提取;该输出结果64x64x64继续进行上采样,shape变为128x128x64,再与Res2的128x128x256进行通道数的Concatenate操作,进行特征融合,此时shape为128x128x(64+256),然后又是进行一次kernel_size为1x1的卷积块Conv+BN+Relu,进行通道数的调整,shape为128x128x32,然后是有进行kernel_size为3x3的卷积块Conv+BN+Relu,进行特征提取,此时得到的高分辨率特征图128x128x32,我们称之为共享特征层,长宽为输入图片的1/4。我们后续就会利用该共享特征图获取文本检测分支和文本识别分支的预测结果。
这一部分的代码具体实现如下:
# 主干网络部分
class FOTSModel:
def __init__(self, config):
self.mode = config['model']['mode']
# backbone resnet50
bbNet = pm.__dict__['resnet50'](pretrained='imagenet') # resnet50 in paper
# backbone得到多个特征层,利用FPN;
# 将高层特征上采样与低层特征进行特征融合,最终得到H/4,W/4的高分辨率共享特征图
self.sharedConv = shared_conv.SharedConv(bbNet, config)
# 特征融合部分
class SharedConv(BaseModel):
'''
sharded convolutional layers
'''
def __init__(self, bbNet: nn.Module, config):
super(SharedConv, self).__init__(config)
self.backbone = bbNet
# 网络预测,只进行前向传播
self.backbone.eval()
# backbone as feature extractor
""" for param in self.backbone.parameters():
param.requires_grad = False """
self.mergeLayers0 = DummyLayer()
# Res5上采样与Res4进行特征融合,得到p4
self.mergeLayers1 = HLayer(2048 + 1024, 128)
# 将p4再与Res3进行特征融合,得到P3
self.mergeLayers2 = HLayer(128 + 512, 64)
#将p3在与Res2进行特征融合,得到共享特征层p
self.mergeLayers3 = HLayer(64 + 256, 32)
# 最后将p再进行一次3x3卷积用于特征提取,得到最后的共享特征层p
self.mergeLayers4 = nn.Conv2d(32, 32, kernel_size = 3, padding = 1)
self.bn5 = nn.BatchNorm2d(32, momentum=0.003)
# Output Layer
self.textScale = 512
def forward(self, input):
input = self.__mean_image_subtraction(input)
# bottom up
f = self.__foward_backbone(input)
g = [None] * 4
h = [None] * 4
# i = 1
h[0] = self.mergeLayers0(f[0])
g[0] = self.__unpool(h[0])
# i = 2
h[1] = self.mergeLayers1(g[0], f[1])
g[1] = self.__unpool(h[1])
# i = 3
h[2] = self.mergeLayers2(g[1], f[2])
g[2] = self.__unpool(h[2])
# i = 4
h[3] = self.mergeLayers3(g[2], f[3])
#g[3] = self.__unpool(h[3])
# final stage
final = self.mergeLayers4(h[3])
final = self.bn5(final)
final = F.relu(final)
return final
def __foward_backbone(self, input):
conv2 = None
conv3 = None
conv4 = None
output = None # n * 7 * 7 * 2048
for name, layer in self.backbone.named_children():
input = layer(input)
if name == 'layer1':
conv2 = input
elif name == 'layer2':
conv3 = input
elif name == 'layer3':
conv4 = input
elif name == 'layer4':
output = input
break
return output, conv4, conv3, conv2
def __unpool(self, input):
_, _, H, W = input.shape
# 线性插值进行上采样
return F.interpolate(input, mode = 'bilinear', scale_factor = 2, align_corners = True)
# 输入图片的标准化操作
def __mean_image_subtraction(self, images, means = [123.68, 116.78, 103.94]):
'''
image normalization
:param images: bs * w * h * channel
:param means:
:return:
'''
num_channels = images.data.shape[1]
if len(means) != num_channels:
raise ValueError('len(means) must match the number of channels')
for i in range(num_channels):
images.data[:, i, :, :] -= means[i]
return images
class DummyLayer(nn.Module):
def forward(self, input_f):
return input_f
class HLayer(nn.Module):
def __init__(self, inputChannels, outputChannels):
"""
:param inputChannels: channels of g+f
:param outputChannels:
"""
super(HLayer, self).__init__()
self.conv2dOne = nn.Conv2d(inputChannels, outputChannels, kernel_size = 1)
self.bnOne = nn.BatchNorm2d(outputChannels, momentum=0.003)
self.conv2dTwo = nn.Conv2d(outputChannels, outputChannels, kernel_size = 3, padding = 1)
self.bnTwo = nn.BatchNorm2d(outputChannels, momentum=0.003)
def forward(self, inputPrevG, inputF):
input = torch.cat([inputPrevG, inputF], dim = 1)
output = self.conv2dOne(input)
output = self.bnOne(output)
output = F.relu(output)
output = self.conv2dTwo(output)
output = self.bnTwo(output)
output = F.relu(output)
return output
3、Text Detection Branch文本检测分支
上面我们通过构建了特征金字塔FPN得到高分辨率共享特征图为128x128x32,该特征图去向有两个分支,分别进行文本检测和文本识别。这里我们介绍何如利用特征层得到文本检测的预测结果。
共享特征图的高度和宽度为128x128,就相当于将我们的图片划分成了128x128的区域,每一个区域存在一个特征点。
在Text Detection Branch中,我们利用该特征图分别进行了三次1x1的卷积操作:
第一次卷积操作:shape为128x128x1,1表示上述每个区域中每一个特征点属于正样本的概率值,即自信度得分scoreMap的预测;
第二次的卷积操作:shape为128x128x4,对于正例,4表示每个区域中特征点到bounding box的顶部、底部、左侧和右侧的距离;
第三次的卷积操作:shape为128x128x1,1表示bounding box 的方向,即angle角度的预测。
文本检测分支的具体代码实现如下:
class FOTSModel:
def __init__(self, config):
self.mode = config['model']['mode']
# backbone resnet50
bbNet = pm.__dict__['resnet50'](pretrained='imagenet') # resnet50 in paper
# backbone得到多个特征层,利用FPN
# 将高层特征上采样与低层特征进行特征融合,最终得到H/4,W/4的高分辨率共享特征图
self.sharedConv = shared_conv.SharedConv(bbNet, config)
self.detector = Detector(config)
# 文本检测分支
class Detector(BaseModel):
def __init__(self, config):
super().__init__(config)
# 自信度得分的预测
self.scoreMap = nn.Conv2d(32, 1, kernel_size = 1)
# 正例中,特征点到top,bottom,left,right的距离
self.geoMap = nn.Conv2d(32, 4, kernel_size = 1)
# bounding box角度的预测
self.angleMap = nn.Conv2d(32, 1, kernel_size = 1)
def forward(self, *input):
final, = input
score = self.scoreMap(final)
score = torch.sigmoid(score)
geoMap = self.geoMap(final)
# 由于将top,bottom,left,right的距离归一化到了0-1之间,且输入图片的shape为512x512,因此将预测出来的距离还原至相对原始图片的距离。
geoMap = torch.sigmoid(geoMap) * 512
angleMap = self.angleMap(final)
# 角度为(-4/pi,4/pi)
angleMap = (torch.sigmoid(angleMap) - 0.5) * math.pi / 2
geometry = torch.cat([geoMap, angleMap], dim=1)
return score, geometry
4、ROIRotate
我们知道,共享特征图的去向有两个分支,一个用于文本检测分支;另一个分支就是先经过ROIRotate得到文本建议框特征,然后再通过文本识别分支得到最终的文本预测结果。
ROIRotate操作就是对特征区域进行变换以获得轴对称的特征图,如下图所示。
其中,ROIRotate操作固定输出的高度(论文中h=8)并且保持宽高比不变,以处理文本长度的变化。ROIRotate操作可以分为两个步骤,首先通过Text Detection Branch预测的文本建议框或者ground truth的坐标来计算出变换参数;然后分别对每个区域进行仿射变换到共享特征图,并且获得文本区域的标准水平特征图。
第一个步骤论文中使用了如下几个公式来进行表示:
其中,M是仿射变换矩阵;ht,wt分别代表经过放射变换后特征图的高度(论文中设置为h=8)和宽度;x,y表示共享特征图中特征点的坐标值;t,b,l,r和theta代表了特征点到文本建议框的top,bottom,left和right的距离,以及文本建议框的旋转角度。t,b,l,r和theta可以由ground truth 或者文本检测分支给出。在进行模型训练过程中,我们使用的是ground truth文本区域来替代预测的文本区域,原因是文本识别对检测噪声非常敏感,预测的文本区域中的一个小错误可能会切断多个字符,这对训练网络有害。
第一个步骤的具体代码如下:
class ROIRotate(nn.Module):
# 经过放射变换后的高度,固定为8
def __init__(self, height=8):
super().__init__()
self.height = height
def forward(self, feature_map, boxes, mapping):
'''
feature_map: 共享特征图(Batch,128,128,