版面分析
版面分析指的是对图片形式的文档进行区域划分,定位其中的关键区域,如文字、标题、表格、图片等。
在上图中,最上面有图片区域,中间是标题和表格区域,下面是文字区域。
命令行使用
代码语言:javascript复制paddleocr --image_dir=ppstructure/docs/table/1.png --type=structure --table=false --ocr=false
Python 代码使用
代码语言:javascript复制import os
import cv2
from paddleocr import PPStructure,save_structure_res
if __name__ == '__main__':
table_engine = PPStructure(table=False, ocr=False, show_log=True)
save_folder = './output'
img_path = 'ppstructure/docs/table/1.png'
img = cv2.imread(img_path)
result = table_engine(img)
save_structure_res(result, save_folder, os.path.basename(img_path).split('.')[0])
for line in result:
img = line.pop('img')
print(line)
while True:
cv2.imshow('img', img)
key = cv2.waitKey()
if key & 0xFF == ord('q'):
break
cv2.destroyAllWindows()
运行结果
代码语言:javascript复制{'type': 'text', 'bbox': [11, 729, 407, 847], 'res': '', 'img_idx': 0}
{'type': 'text', 'bbox': [442, 754, 837, 847], 'res': '', 'img_idx': 0}
{'type': 'title', 'bbox': [443, 705, 559, 719], 'res': '', 'img_idx': 0}
{'type': 'figure', 'bbox': [10, 1, 841, 294], 'res': '', 'img_idx': 0}
{'type': 'figure_caption', 'bbox': [70, 317, 707, 357], 'res': '', 'img_idx': 0}
{'type': 'figure_caption', 'bbox': [160, 317, 797, 335], 'res': '', 'img_idx': 0}
{'type': 'table', 'bbox': [453, 359, 822, 664], 'res': '', 'img_idx': 0}
{'type': 'table', 'bbox': [12, 360, 410, 716], 'res': '', 'img_idx': 0}
{'type': 'table_caption', 'bbox': [494, 343, 785, 356], 'res': '', 'img_idx': 0}
{'type': 'table_caption', 'bbox': [69, 318, 706, 357], 'res': '', 'img_idx': 0}
'text', 'bbox': 11, 729, 407, 847
'text', 'bbox': 442, 754, 837, 847
'title', 'bbox': 443, 705, 559, 719
'figure', 'bbox': 10, 1, 841, 294
'figure_caption', 'bbox': 70, 317, 707, 357
'figure_caption', 'bbox': 160, 317, 797, 335
'table', 'bbox': 453, 359, 822, 664
'table', 'bbox': 12, 360, 410, 716
'table_caption', 'bbox': 494, 343, 785, 356
'table_caption', 'bbox': 69, 318, 706, 357
从运行的结果来看,它是将原始图像拆成了图像、图像标题、表格、表格标题、文字和文字标题六个分类。
模型训练
下载 PaddleDection 框架代码
PaddleDetection: PaddleDetection 的目的是为工业界和学术界提供丰富、易用的目标检测模型 (gitee.com)
下载,解压,进入 PaddleDection 主目录,安装需要的 Python 库
代码语言:javascript复制pip install -r .requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
cocotools 安装错误的话可以使用如下命令安装
代码语言:javascript复制git clone https://github.com/pdollar/coco.git
cd coco/PythonAPI
python setup.py build_ext --inplace
python setup.py build_ext install
数据集:这是一个英文数据集,包含 5 个类 {0: "Text", 1: "Title", 2: "List", 3:"Table", 4:"Figure"}
代码语言:javascript复制wget https://dax-cdn.cdn.appdomain.cloud/dax-publaynet/1.0.0/publaynet.tar.gz
tar -xzvf publaynet.tar.gz
这是一个 COCO 数据集,随便打开一张图像大概是这个样子的
它的标签文件是 json 文件,里面的内容如下
代码语言:javascript复制{"file_name": "PMC1087888_00001.jpg", "width": 612, "id": 410520, "height": 792}
{"segmentation": [[55.14, 456.69, 296.1, 456.69, 296.1, 467.82, 296.1, 467.82, 296.1, 480.15, 296.1, 480.15, 296.1, 491.28, 144.06, 491.28, 144.06, 503.04, 55.14, 503.04, 55.14, 491.92, 55.14, 480.15, 55.14, 468.46, 55.14, 456.69]],
"area": 9380.344594193506,
"iscrowd": 0,
"image_id": 410520,
"bbox": [55.14, 456.69, 240.96, 46.35],
"category_id": 1,
"id": 4010177}
第一行表示标注文件中图像信息列表,每个元素是一张图像的信息。第二行到最后一个行表示标注文件中目标物体的标注信息列表,每个元素是一个目标物体的标注信息。这里只是其中一个区域的标注,其他还有几个区域标注,这里没有列出。
代码语言:javascript复制{
'segmentation': # 物体的分割标注
'area': # 物体的区域面积
'iscrowd': # 是否多区域
'image_id': # image id
'bbox': # bbox [x1,y1,w,h]
'category_id': # 图片类别
'id': # 区域 id
}
这里我们可以看到 category_id 为 1,表示这个区域是一个 Title。
修改配置文件 configs/picodet/legacy_model/application/layout_analysis/picodet_lcnet_x1_0_layout.yml ,内容如下
PP-PicoDet 模型原理
PP-PicoDet 是一个目标检测模型,对比于 YOLO 系列在轻量级检测中 (移动端) 表现更好
PicoDet-S 以 0.99M 参数以及 1.08G-FLOPs 实现 30.06% mAP。它在移动端 ARM-CPU 上实现了 150FPS,输入尺寸为 320。PicoDet-M 在仅 2.15M 参数和 2.5G-FLOPs 的情况下实现 34.3% mAP。PicoDet-L 在仅 3.3M 参数和 8.74G-FLOPs 情况下实现 40.9% mAP。本文提供了小、中、大三种模型来支持不同的部署场景。
- 整体网络结构
我们先来看它的主干网 (Backbone),是百度自研的轻量级网络 ESNet。它是根据 ShuffleNet V2 进行的改进,有关 ShuffleNet V2 的内容可以参考深度学习网络模型的改进与调整 中的 ShuffleNet V2。
第一个改进是引入了 SE block,主要作用是对通道加权,增强特征的提取能力。有关 SE block 的内容可以参考深度学习网络模型的改进与调整 的 MobileNet V3。第二个改进是使用了一组深度可分离卷积在 stride=2 的时候,替换掉了 channel shuffle。channel shuffle 可以增强不同通道中的信息交换,但是这个信息交换是不容于 1*1 卷积的,1*1 卷积的计算速度通常比较慢,这里在每次进行下采样的时候就会替换掉 channel shuffle。第三个改进是引入了 Ghost block,主要目的是降低网络的冗余性,有关 Ghost block 的内容可以参考深度学习网络模型的改进与调整 中的 GhostNet 的 Ghost bottleneck。
Backbone 的权重占整个网络的 60% 以上,并且 Backbone 的特征提取作用也是至关重要的。优化 Backbone 对检测的性能提升还是非常有帮助的。
在 Neck 部分,使用的是 CSP-PAN。PAN 是一种双向特征融合的网络,先上采样 (深层到浅层) 再下采样 (浅层到深层)。CSP-PAN 中在每层输入的地方插入了 1*1 的卷积,用来统一通道数,这么做的好处可以减少计算量,因为不统一通道数,在 concat 融合的过程中,通道数会成倍的增加,越来越多,对于移动端是非常不友好的。在上图中所示,分层特征图通过 Backbone 输出的各层中,C3 的通道数最小为 96,那么整个 CSP-PAN 都会通过 1*1 卷积统一到 96 通道数的维度上,整个参数量减少了 73%。
CSP-PAN 每一层输入的网络结构是 CSP 的结构,有关 CSP 结构的内容可以参考 YOLO 系列介绍 (二) YOLOV4 中的内容。一般的 PAN 网络都是三层输出,但是在 CSP-PAN 中增加了一层 64 倍下采样的分支,就是上图中右上角橙色框的 P6 部分,目的是为了增大大物体的召回率。这里的下采样都使用的是深度可分离卷积 (DP)。这种操作 mAP 提升了 1 个点,速度只损失了 0.25%。
- Sim-OTA 动态样本匹配
只有符合的标签匹配策略的样本才会定义为正样本,只有正样本所对应的特征图的像素才能够参与 loss 计算及反传。所以标签匹配策略是非常重要的,选择合适的正样本对精度提升至关重要。
Sim-OTA 是 YOLOX 作者在 OT 策略上提出的简化算法,其作用是为不同目标选择不同数量的正样本。样本采集是动态的。
SimOTA 跟其他采样方式相比,对于一些被遮挡的物体,它仍然可以采样到,比如上图中中间的头发背影。
- 上图是一张我们要检测的原始图像,上面有绿色框和黄色框,绿色框为 ground truth box,即人工标注出来的区域。黄色框为当前 ground truth box 的中心点为中心点作为一个特征点,向上下左右四个方向分别延伸 2.5 倍的步长 (stride,特征图对应原图的比例),不同的特征图上的特征点的黄色框是不同的。灰色网格代表 FPN 的其中一个步长 (stride) 为长度给图像打的网格,一个网格代表 feature map 中的像素点所能看到的感受野。如果 feature map 中的一个像素点对应原图的中心点在绿色框或者黄色框的区域内,那么这个像素点就属于正样本的候选区域。那么在上图中黄绿框相交的四个角的灰色网格不属于正样本区域。
- 计算正样本候选区域所产生的每个预测框与当前 ground truth box 的 IoU。在 YOLO 中一个灰色网格会产生三个预测框 Anchor。
- 将计算的 IoU 按从大到小排序,将排名靠前 10 的 IoU 求和,由于 IoU 本身值不会超过 1,所以这个和的指区间为 0~10,记该值为 dynamic_k。
- 计算正样本候选区域产生的预测框与当前 ground truth box 的 cost 值,得到 Cost 代价矩阵。该矩阵计算公式为,其中 λ 是平衡系数,和分别是 ground truth box 和预测框 Anchor 的分类损失和回归损失。该矩阵代表当前 gruond truth box 和预测框之间的代价关系,预测框的 cost 值越小越好。通过 Cost 矩阵,使网络能够自适应的找到每个 ground truth box 的正样本。Cost 代价矩阵由三个部分组成:1、每个 ground truth box 和灰色网格的预测框的重合程度越高,代表这个灰色网格已经尝试去拟合 ground truth box 了,因此它的 cost 代价就会越小。2、每个 ground truth box 和灰色网格的预测框的分类精度越高,代表这个灰色网格已经尝试去拟合 ground truth box 了。3、每个 ground truth box 的中心是否落在灰色网格的一定半径内,如果在一定半径内,代表这个灰色网格已经尝试去拟合 ground truth box 了。
- 将 Cost 矩阵的的值按从小到大的顺序排列。取前 dynamic_k 个 cost 最小的预测框作为当前 ground truth box 最终的正样本,将其余剩下的预测框作为负样本。对于不同的 ground truth box,dynamic_k 的值是不一样的。
- 使用求出的最终正负样本来计算分类和回归损失。
在 PP-PicoDet 中,修改了 Sim-OTA 的原始 loss,变为,该方法只在训练阶段使用,预测阶段是无损的。mAP 提升了 1%。
- 其他优化
代码分析
ESNet 的完整代码位于 ppdet/modeling/backbones/esnet.py
代码语言:javascript复制class SEModule(nn.Layer):
def __init__(self, channel, reduction=4):
'''
SE模块
channel:输入通道数
reducion:通道缩放率
'''
super(SEModule, self).__init__()
# 通过全局池化将空间尺寸变成1*1,通道数不变
self.avg_pool = AdaptiveAvgPool2D(1)
# 1*1卷积进行通道降维
self.conv1 = Conv2D(
in_channels=channel,
out_channels=channel // reduction,
kernel_size=1,
stride=1,
padding=0,
weight_attr=ParamAttr(),
bias_attr=ParamAttr())
# 1*1卷积进行通道升维
self.conv2 = Conv2D(
in_channels=channel // reduction,
out_channels=channel,
kernel_size=1,
stride=1,
padding=0,
weight_attr=ParamAttr(),
bias_attr=ParamAttr())
def forward(self, inputs):
outputs = self.avg_pool(inputs)
outputs = self.conv1(outputs)
outputs = F.relu(outputs)
outputs = self.conv2(outputs)
# 获取每个通道的权重
outputs = F.hardsigmoid(outputs)
# 利用每个通道的权重对输入的特征图进行特征值的调整
return paddle.multiply(x=inputs, y=outputs)
代码语言:javascript复制def channel_shuffle(x, groups):
'''
通道洗牌
'''
batch_size, num_channels, height, width = x.shape[0:4]
assert num_channels % groups == 0, 'num_channels should be divisible by groups'
# 每通道的向量数n
channels_per_group = num_channels // groups
# reshape成(g,n)的矩阵
x = paddle.reshape(
x=x, shape=[batch_size, groups, channels_per_group, height, width])
# 转置成(n,g)
x = paddle.transpose(x=x, perm=[0, 2, 1, 3, 4])
# flatten打散
x = paddle.reshape(x=x, shape=[batch_size, num_channels, height, width])
return x
代码语言:javascript复制class ConvBNLayer(nn.Layer):
def __init__(self,
in_channels,
out_channels,
kernel_size,
stride,
padding,
groups=1,
act=None):
'''
普通卷积
'''
super(ConvBNLayer, self).__init__()
self._conv = Conv2D(
in_channels=in_channels,
out_channels=out_channels,
kernel_size=kernel_size,
stride=stride,
padding=padding,
groups=groups,
weight_attr=ParamAttr(initializer=KaimingNormal()),
bias_attr=False)
self._batch_norm = BatchNorm2D(
out_channels,
weight_attr=ParamAttr(regularizer=L2Decay(0.0)),
bias_attr=ParamAttr(regularizer=L2Decay(0.0)))
if act == "hard_swish":
act = 'hardswish'
self.act = act
def forward(self, inputs):
y = self._conv(inputs)
y = self._batch_norm(y)
if self.act:
y = getattr(F, self.act)(y)
return y
代码语言:javascript复制class InvertedResidual(nn.Layer):
def __init__(self,
in_channels,
mid_channels,
out_channels,
stride,
act="relu"):
'''
stride=1构建块
'''
super(InvertedResidual, self).__init__()
# 1*1卷积
self._conv_pw = ConvBNLayer(
in_channels=in_channels // 2,
out_channels=mid_channels // 2,
kernel_size=1,
stride=1,
padding=0,
groups=1,
act=act)
# 3*3深度可分离卷积
self._conv_dw = ConvBNLayer(
in_channels=mid_channels // 2,
out_channels=mid_channels // 2,
kernel_size=3,
stride=stride,
padding=1,
groups=mid_channels // 2,
act=None)
# SE block
self._se = SEModule(mid_channels)
# 1*1深度可分离卷积
self._conv_linear = ConvBNLayer(
in_channels=mid_channels,
out_channels=out_channels // 2,
kernel_size=1,
stride=1,
padding=0,
groups=1,
act=act)
def forward(self, inputs):
# 对特征图进行通道拆分
x1, x2 = paddle.split(
inputs,
num_or_sections=[inputs.shape[1] // 2, inputs.shape[1] // 2],
axis=1)
# 左分支
x2 = self._conv_pw(x2)
x3 = self._conv_dw(x2)
# 将1*1和3*3的结果进行合并
x3 = paddle.concat([x2, x3], axis=1)
# 合并后SE
x3 = self._se(x3)
x3 = self._conv_linear(x3)
# 合并左右分支
out = paddle.concat([x1, x3], axis=1)
return channel_shuffle(out, 2)
代码语言:javascript复制class InvertedResidualDS(nn.Layer):
def __init__(self,
in_channels,
mid_channels,
out_channels,
stride,
act="relu"):
'''
stride=2构建块
'''
super(InvertedResidualDS, self).__init__()
# 右分支
# 3*3深度可分离卷积
self._conv_dw_1 = ConvBNLayer(
in_channels=in_channels,
out_channels=in_channels,
kernel_size=3,
stride=stride,
padding=1,
groups=in_channels,
act=None)
# 1*1深度可分离卷积
self._conv_linear_1 = ConvBNLayer(
in_channels=in_channels,
out_channels=out_channels // 2,
kernel_size=1,
stride=1,
padding=0,
groups=1,
act=act)
# 左分支
# 1*1卷积
self._conv_pw_2 = ConvBNLayer(
in_channels=in_channels,
out_channels=mid_channels // 2,
kernel_size=1,
stride=1,
padding=0,
groups=1,
act=act)
# 3*3深度可分离卷积
self._conv_dw_2 = ConvBNLayer(
in_channels=mid_channels // 2,
out_channels=mid_channels // 2,
kernel_size=3,
stride=stride,
padding=1,
groups=mid_channels // 2,
act=None)
# se block
self._se = SEModule(mid_channels // 2)
# 1*1深度可分离卷积
self._conv_linear_2 = ConvBNLayer(
in_channels=mid_channels // 2,
out_channels=out_channels // 2,
kernel_size=1,
stride=1,
padding=0,
groups=1,
act=act)
# 3*3深度可分离卷积
self._conv_dw_mv1 = ConvBNLayer(
in_channels=out_channels,
out_channels=out_channels,
kernel_size=3,
stride=1,
padding=1,
groups=out_channels,
act="hard_swish")
# 1*1卷积
self._conv_pw_mv1 = ConvBNLayer(
in_channels=out_channels,
out_channels=out_channels,
kernel_size=1,
stride=1,
padding=0,
groups=1,
act="hard_swish")
def forward(self, inputs):
# 右分支
x1 = self._conv_dw_1(inputs)
x1 = self._conv_linear_1(x1)
# 左分支
x2 = self._conv_pw_2(inputs)
x2 = self._conv_dw_2(x2)
x2 = self._se(x2)
x2 = self._conv_linear_2(x2)
# 合并左右分支
out = paddle.concat([x1, x2], axis=1)
out = self._conv_dw_mv1(out)
out = self._conv_pw_mv1(out)
return out
代码语言:javascript复制@register
@serializable
class ESNet(nn.Layer):
def __init__(self,
scale=1.0,
act="hard_swish",
feature_maps=[4, 11, 14],
channel_ratio=[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]):
'''
ESNet Backbone
'''
super(ESNet, self).__init__()
self.scale = scale
if isinstance(feature_maps, Integral):
feature_maps = [feature_maps]
self.feature_maps = feature_maps
# C3、C4、C5层的ES Block的数量
stage_repeats = [3, 7, 3]
# 每一步的输出通道数
stage_out_channels = [
-1, 24, make_divisible(128 * scale), make_divisible(256 * scale),
make_divisible(512 * scale), 1024
]
self._out_channels = []
self._feature_idx = 0
# 第一个普通卷积
self._conv1 = ConvBNLayer(
in_channels=3,
out_channels=stage_out_channels[1],
kernel_size=3,
stride=2,
padding=1,
act=act)
# 3*3最大池化层
self._max_pool = MaxPool2D(kernel_size=3, stride=2, padding=1)
self._feature_idx = 1
# 2. bottleneck sequences
self._block_list = []
arch_idx = 0
# 遍历每一个特征输出层
for stage_id, num_repeat in enumerate(stage_repeats):
# 遍历每一层中的重复次数
for i in range(num_repeat):
channels_scales = channel_ratio[arch_idx]
mid_c = make_divisible(
int(stage_out_channels[stage_id 2] * channels_scales),
divisor=8)
if i == 0:
# 第一次进行降采样,即stride=2
block = self.add_sublayer(
name=str(stage_id 2) '_' str(i 1),
sublayer=InvertedResidualDS(
in_channels=stage_out_channels[stage_id 1],
mid_channels=mid_c,
out_channels=stage_out_channels[stage_id 2],
stride=2,
act=act))
else:
# 之后不进行降采样,即stride=1
block = self.add_sublayer(
name=str(stage_id 2) '_' str(i 1),
sublayer=InvertedResidual(
in_channels=stage_out_channels[stage_id 2],
mid_channels=mid_c,
out_channels=stage_out_channels[stage_id 2],
stride=1,
act=act))
# ES block列表
self._block_list.append(block)
arch_idx = 1
self._feature_idx = 1
self._update_out_channels(stage_out_channels[stage_id 2],
self._feature_idx, self.feature_maps)
def _update_out_channels(self, channel, feature_idx, feature_maps):
if feature_idx in feature_maps:
self._out_channels.append(channel)
def forward(self, inputs):
y = self._conv1(inputs['image'])
y = self._max_pool(y)
outs = []
for i, inv in enumerate(self._block_list):
# 通过每一层的ES block
y = inv(y)
if i 2 in self.feature_maps:
outs.append(y)
return outs
@property
def out_shape(self):
return [ShapeSpec(channels=c) for c in self._out_channels]
CSP-PAN 的完整代码位于 ppdet/modeling/necks/csp_pan.py
代码语言:javascript复制class ConvBNLayer(nn.Layer):
def __init__(self,
in_channel=96,
out_channel=96,
kernel_size=3,
stride=1,
groups=1,
act='leaky_relu'):
'''
普通卷积
'''
super(ConvBNLayer, self).__init__()
initializer = nn.initializer.KaimingUniform()
self.conv = nn.Conv2D(
in_channels=in_channel,
out_channels=out_channel,
kernel_size=kernel_size,
groups=groups,
padding=(kernel_size - 1) // 2,
stride=stride,
weight_attr=ParamAttr(initializer=initializer),
bias_attr=False)
self.bn = nn.BatchNorm2D(out_channel)
if act == "hard_swish":
act = 'hardswish'
self.act = act
def forward(self, x):
x = self.bn(self.conv(x))
if self.act:
x = getattr(F, self.act)(x)
return x
代码语言:javascript复制class DPModule(nn.Layer):
def __init__(self,
in_channel=96,
out_channel=96,
kernel_size=3,
stride=1,
act='leaky_relu',
use_act_in_out=True):
'''
深度可分离卷积DP
'''
super(DPModule, self).__init__()
initializer = nn.initializer.KaimingUniform()
self.use_act_in_out = use_act_in_out
self.dwconv = nn.Conv2D(
in_channels=in_channel,
out_channels=out_channel,
kernel_size=kernel_size,
groups=out_channel,
padding=(kernel_size - 1) // 2,
stride=stride,
weight_attr=ParamAttr(initializer=initializer),
bias_attr=False)
self.bn1 = nn.BatchNorm2D(out_channel)
self.pwconv = nn.Conv2D(
in_channels=out_channel,
out_channels=out_channel,
kernel_size=1,
groups=1,
padding=0,
weight_attr=ParamAttr(initializer=initializer),
bias_attr=False)
self.bn2 = nn.BatchNorm2D(out_channel)
if act == "hard_swish":
act = 'hardswish'
self.act = act
def forward(self, x):
x = self.bn1(self.dwconv(x))
if self.act:
x = getattr(F, self.act)(x)
x = self.bn2(self.pwconv(x))
if self.use_act_in_out and self.act:
x = getattr(F, self.act)(x)
return x
代码语言:javascript复制class DarknetBottleneck(nn.Layer):
def __init__(self,
in_channels,
out_channels,
kernel_size=3,
expansion=0.5,
add_identity=True,
use_depthwise=False,
act="leaky_relu"):
'''
DarkNet block,包含两个卷积和一个残差连接
'''
super(DarknetBottleneck, self).__init__()
hidden_channels = int(out_channels * expansion)
conv_func = DPModule if use_depthwise else ConvBNLayer
self.conv1 = ConvBNLayer(
in_channel=in_channels,
out_channel=hidden_channels,
kernel_size=1,
act=act)
self.conv2 = conv_func(
in_channel=hidden_channels,
out_channel=out_channels,
kernel_size=kernel_size,
stride=1,
act=act)
self.add_identity =
add_identity and in_channels == out_channels
def forward(self, x):
identity = x
out = self.conv1(x)
out = self.conv2(out)
if self.add_identity:
return out identity
else:
return out
代码语言:javascript复制class CSPLayer(nn.Layer):
def __init__(self,
in_channels,
out_channels,
kernel_size=3,
expand_ratio=0.5,
num_blocks=1,
add_identity=True,
use_depthwise=False,
act="leaky_relu"):
'''
CSPDarkNet
'''
super().__init__()
mid_channels = int(out_channels * expand_ratio)
# 右分支
self.main_conv = ConvBNLayer(in_channels, mid_channels, 1, act=act)
# 左分支
self.short_conv = ConvBNLayer(in_channels, mid_channels, 1, act=act)
# 最后输出的1*1卷积
self.final_conv = ConvBNLayer(
2 * mid_channels, out_channels, 1, act=act)
# 堆叠DarkNet block
self.blocks = nn.Sequential(* [
DarknetBottleneck(
mid_channels,
mid_channels,
kernel_size,
1.0,
add_identity,
use_depthwise,
act=act) for _ in range(num_blocks)
])
def forward(self, x):
# 左分支,通过1*1的卷积,获取原特征图一半的通道数
x_short = self.short_conv(x)
# 右分支,通过1*1的卷积,获取原特征图一半的通道数
x_main = self.main_conv(x)
# 右分支通过一系列DarkNet block的堆叠
x_main = self.blocks(x_main)
# 拼接左右分支
x_final = paddle.concat((x_main, x_short), axis=1)
# 使用1*1卷积进行输出
return self.final_conv(x_final)
代码语言:javascript复制class Channel_T(nn.Layer):
def __init__(self,
in_channels=[116, 232, 464],
out_channels=96,
act="leaky_relu"):
'''
输入部分统一通道数
'''
super(Channel_T, self).__init__()
self.convs = nn.LayerList()
for i in range(len(in_channels)):
self.convs.append(
ConvBNLayer(
in_channels[i], out_channels, 1, act=act))
def forward(self, x):
outs = [self.convs[i](x[i]) for i in range(len(x))]
return outs
代码语言:javascript复制@register
@serializable
class CSPPAN(nn.Layer):
def __init__(self,
in_channels,
out_channels,
kernel_size=5,
num_features=3,
num_csp_blocks=1,
use_depthwise=True,
act='hard_swish',
spatial_scales=[0.125, 0.0625, 0.03125]):
'''
CSP-PAN Neck
'''
super(CSPPAN, self).__init__()
self.conv_t = Channel_T(in_channels, out_channels, act=act)
in_channels = [out_channels] * len(spatial_scales)
# 输入的三层通道数
self.in_channels = in_channels
# 输出的三层通道数
self.out_channels = out_channels
# 空间尺度
self.spatial_scales = spatial_scales
# 特征数
self.num_features = num_features
# 卷积层,深度可分离卷积或普通卷积
conv_func = DPModule if use_depthwise else ConvBNLayer
if self.num_features == 4:
# P6层的第一个深度可分离卷积
self.first_top_conv = conv_func(
in_channels[0], in_channels[0], kernel_size, stride=2, act=act)
# P6层的第二个深度可分离卷积
self.second_top_conv = conv_func(
in_channels[0], in_channels[0], kernel_size, stride=2, act=act)
self.spatial_scales.append(self.spatial_scales[-1] / 2)
# 上采样
self.upsample = nn.Upsample(scale_factor=2, mode='nearest')
# 上采样模块列表
self.top_down_blocks = nn.LayerList()
for idx in range(len(in_channels) - 1, 0, -1):
# 使用CSPDarkNet进行上采样
self.top_down_blocks.append(
CSPLayer(
in_channels[idx - 1] * 2,
in_channels[idx - 1],
kernel_size=kernel_size,
num_blocks=num_csp_blocks,
add_identity=False,
use_depthwise=use_depthwise,
act=act))
# 下采样列表
self.downsamples = nn.LayerList()
# 下采样模块列表
self.bottom_up_blocks = nn.LayerList()
# 下采样中一个深度可分离卷积接一个CSPDarkNet
for idx in range(len(in_channels) - 1):
self.downsamples.append(
conv_func(
in_channels[idx],
in_channels[idx],
kernel_size=kernel_size,
stride=2,
act=act))
self.bottom_up_blocks.append(
CSPLayer(
in_channels[idx] * 2,
in_channels[idx 1],
kernel_size=kernel_size,
num_blocks=num_csp_blocks,
add_identity=False,
use_depthwise=use_depthwise,
act=act))
def forward(self, inputs):
assert len(inputs) == len(self.in_channels)
# 统一输入通道数
inputs = self.conv_t(inputs)
# 上采样过程
inner_outs = [inputs[-1]]
for idx in range(len(self.in_channels) - 1, 0, -1):
# 获取深层特征
feat_heigh = inner_outs[0]
# 获取浅层特征(比feat_heigh低一层)
feat_low = inputs[idx - 1]
# 对深层特征进行上采样
upsample_feat = self.upsample(feat_heigh)
# 合并上采样之后的高层特征和浅层特征,再送入CSPDarkNet网络
inner_out = self.top_down_blocks[len(self.in_channels) - 1 - idx](
paddle.concat([upsample_feat, feat_low], 1))
inner_outs.insert(0, inner_out)
# 下采样过程
outs = [inner_outs[0]]
for idx in range(len(self.in_channels) - 1):
# 获取浅层特征
feat_low = outs[-1]
# 获取深层特征(比feat_low高一层)
feat_height = inner_outs[idx 1]
# 对浅层特征使用深度可分离卷积进行下采样
downsample_feat = self.downsamples[idx](feat_low)
# 合并下采样之后的浅层特征和深层特征,再送入CSPDarkNet网络
out = self.bottom_up_blocks[idx](paddle.concat(
[downsample_feat, feat_height], 1))
outs.append(out)
top_features = None
# 获取P6层的特征
if self.num_features == 4:
top_features = self.first_top_conv(inputs[-1])
top_features = top_features self.second_top_conv(outs[-1])
outs.append(top_features)
return tuple(outs)
@property
def out_shape(self):
return [
ShapeSpec(
channels=self.out_channels, stride=1. / s)
for s in self.spatial_scales
]
@classmethod
def from_config(cls, cfg, input_shape):
return {'in_channels': [i.channels for i in input_shape], }
表格识别
表格识别指的是对图片上的表格进行识别,不仅要识别表格中的文字,而且需要识别表格中单元格的坐标信息。
整个过程分为两步,第一调用文本检测算法,对单行文本进行检测,得到一个一个的检测框,再对这些检测框送入到文本识别算法获得文字。第二调用表格结构预测算法获得单元格的坐标,并与文本检测的检测框坐标进行聚合,再与文本识别结果进行聚合,最终得到图像表格的 Excel 结果。
命令行使用
进入 PaddleOCR 的 ppstructure 目录
代码语言:javascript复制mkdir inference
cd inference
wget https://paddleocr.bj.bcebos.com/PP-OCRv3/chinese/ch_PP-OCRv3_det_infer.tar
tar -xzvf ch_PP-OCRv3_det_infer.tar
wget https://paddleocr.bj.bcebos.com/PP-OCRv3/chinese/ch_PP-OCRv3_rec_infer.tar
tar -xzvf ch_PP-OCRv3_rec_infer.tar
wget https://paddleocr.bj.bcebos.com/ppstructure/models/slanet/ch_ppstructure_mobile_v2.0_SLANet_infer.tar
tar -xzvf ch_ppstructure_mobile_v2.0_SLANet_infer.tar
在 ppstructure/table/predict_table.py 中添加执行参数
代码语言:javascript复制--det_model_dir=inference/ch_PP-OCRv3_det_infer --rec_model_dir=inference/ch_PP-OCRv3_rec_infer --table_model_dir=inference/ch_ppstructure_mobile_v2.0_SLANet_infer --rec_char_dict_path=../ppocr/utils/ppocr_keys_v1.txt --table_char_dict_path=../ppocr/utils/dict/table_structure_dict_ch.txt --image_dir=docs/table/table.jpg --output=../output/table
并将工作目录设置为 ppstructure。这里的表格图片如下
执行结果,生成的 Excel 文件如下
模型训练
文字检测模型略过,文字识别模型的训练可以参考 PaddleOCR 使用指南 中的模型训练
这里主要看一下表格结构预测模型的训练
数据集:https://dax-cdn.cdn.appdomain.cloud/dax-pubtabnet/2.0.0/pubtabnet.tar.gz
下载后解压,大概样子如下所示
数据集生成
下载数据集生成工具:GitHub - WenmuZhou/TableGeneration: 通过浏览器渲染生成表格图像