分类 源码阅读 下的文章

pix2seq implement by Pytorch

pix2seq - framework

source code : moein-shariatnia/Pix2Seq

paper : http://arxiv.org/abs/2109.10852

这次解析的源码是非官方实现的Pix2Seq,项目地址如上。教程直接看作者的Readme或者Object Detection w/ Transformers Pix2Seq in Pytorch | Towards AI,总体还是比较详细的。

模型即训练源码基本没什么问题,不过推荐先看完原始论文,不然可能在一些细节方面卡住。代码问题主要出现在测试文件中,问题如下。

Issue 1

2023.8.30

Tokenizer类的max_len参数用于限制单张图片的Obejct个数

labels = labels.astype('int')[:self.max_len]

bboxes = self.quantize(bboxes)[:self.max_len]

get_loaders中的collate_fn,把max_len作为输入序列的最大长度,这两处地方出现了矛盾(因为一个Object对应5个token,[x1, y1, x2, y2, class] )

if max_len: # [B,max_seq_len,dim] -> [B,max_len,dim]
        pad = torch.ones(seq_batch.size(0), max_len -
                         seq_batch.size(1)).fill_(pad_idx).long()
        seq_batch = torch.cat([seq_batch, pad], dim=1)

Issue 2

2023.8.31

test.py中的postprocess函数存在问题,没有考虑model未检出object的情况。导致会将一个空序列(即\<EOS\>\<BOS\>)输入tokenizer的decoder方法,从而引发错误

 for i, EOS_idx in enumerate(EOS_idxs.tolist()):
        if EOS_idx == 0:
            all_bboxes.append(None)
            all_labels.append(None)
            all_confs.append(None)
            continue

修正如下,考虑空序列的情况

 for i, EOS_idx in enumerate(EOS_idxs.tolist()):
        if EOS_idx == 0 or EOS_idx ==1: # invalid idx which EOS_idx = 0 or the model detect nothing when EOS_idx = 1 
            all_bboxes.append(None)
            all_labels.append(None)
            all_confs.append(None)
            continue

Issue 3

2023.8.31

test.py 中的第125行,发生类型判断错误。这里是剔除没有检测出物体的图片,但是此时lambda表达式中的x是array类型,所以isinstance函数参数不应该是list。否则过滤后preds_df会是空表

# preds_df = preds_df[preds_df['bbox'].map(lambda x: isinstance(x, list))].reset_index(drop=True)
# fix : list -> np.ndarray
preds_df = preds_df[preds_df['bbox'].map(lambda x: isinstance(x, np.ndarray))].reset_index(drop=True)

Issue 4

2023.9.1 按道理这个很影响结果啊,毕竟图片之间的映射都错了。加上Issue 3,感觉作者写完教程,整理源码后,并没有再测试test.py这个文件了。

test.py第117行开始,保存预测结果。这里的原意应该是把预测结果对应回每张图片的id,但是源码直接对valid_df做了截断,这里很明显导致了部分图片的id被trunc了(这是因为valid_df将同一图片中的不同物体分成了若干行,如果直接按照总的图片数目进行截断,后面的一些图片id就会被截掉)。使用valid_df['id'].unique()进行修改,正好与133行代码对应。能这么做也是因为data loader的shuffle=False

# preds_df = pd.DataFrame()
# valid_df = valid_df.iloc[:len(all_bboxes)]
# preds_df['id'] = valid_df['id'].copy()
# preds_df['bbox'] = all_bboxes
# preds_df['label'] = all_labels
# preds_df['conf'] = all_confs

# I think there is some bug above, because the code \
# do not consider the corresponding id of image, \
# it just trunc the valid_df with len(all_bboxes)!!!

preds_df = pd.DataFrame()
preds_df['id'] = valid_df['id'].unique().copy()
preds_df['bbox'] = all_bboxes
preds_df['label'] = all_labels
preds_df['conf'] = all_confs

# line_133 : valid_df = df[df['id'].isin(preds_df['id'].unique())].reset_index(drop=True)

使用作者提供的权重文件,跑出来的结果确实比原作者给出的结果(mAP=0.264399)高出7%左右,而且由于上述逻辑错误,作者的结果其实只在基本一半数量的box做了mAP的计算(因为测试图片就少了快一半),如果使用全量数据,他的效果应该会更差。

image-20230901001735855

Issue 5

2023.9.1

TODO : 之后把这些问题跟作者反馈一下,下一步考虑使用DDP,方便以后多卡训练。

Issues and PR

https://github.com/moein-shariatnia/Pix2Seq/issues/6

https://github.com/moein-shariatnia/Pix2Seq/issues/7

https://github.com/moein-shariatnia/Pix2Seq/pull/8

项目地址

https://github.com/JJJYmmm/CircleDetection

Readme

本项目使用 Canny + Hough 对图像中的圆形进行检测。参考https://github.com/CV-xueba/A01_cvclass_basic_exercise,不过修复了该项目中canny算法计算亚像素点的bug

文件列表如下:

  • main.py : run detection program
  • my_canny.py : canny算法实现,得到图像的梯度图/梯度方向图
  • my_hough.py : hough算法实现,实现通过参数空间的投票算法进行圆形的数学建模

使用方法:

  • 检测图像放在picture_source文件夹下,命名为picture.jpg(或修改main.py中的Path路径)
  • Canny/Hough检测结果放在picture_result文件夹下

测试结果:

左侧为canny算法结果,右侧为hough检测出的圆(原图上画出)

image-20230706191741408

image-20230706192044307

RPNHead

RPNHead的主要功能是

  • 给定图片的各个特征预测层,通过conv3x3滑动窗口,预测每个锚框的前景概率和回归参数

__init__

通过滑动窗口预测目标概率和bbox regression参数

image-20230322030526544

forward

​ 在多个特征层预测,把3x3conv作为滑动窗口生成预测结果.

image-20230322030613270

AnchorsGenerator

AnchorsGenerator类的主要功能是

  • 给定图片的各个特征层,在每个特征层上都生成特定大小和尺寸的锚框

__init__

​ 处理anchor的sizes和ratios

image-20230322030836230

forward

​ 首先生成anchor模板,再获取每个特征图上的anchor在原图上的坐标信息.随后遍历每张图像,将所有anchor存入anchors中,anchors是list of list,第一个维度是不同图片,第二个维度是不同特征层.最后通过cat操作消除第二个维度,即把一个图片的不同特征图的anchor全部汇总起来.至此AnchorGenerator生成anchor的工作结束.

image-20230322031733821

generate_anchors

​ 生成anchors模板,首先拓展ratios和scales的维度并相乘(广播机制),获得不同尺度不同比例的anchor对应的宽和高,再将坐标摞起来,得到anchor模板的左上角(右下角)坐标.

image-20230322031308543

cache_grid_anchors

​ 对于anchor在原图上的位置信息做了一个cache缓存操作

image-20230322031941314

grid_anchors

​ 得到anchor模板在原图上的坐标信息,这里主要用到了meshgrid函数生成网格坐标,先生成特征图的每个像素中心点在原图上的坐标信息,再和anchor模板坐标相加即可(因为anchor模板默认中心点是(0,0))

image-20230322032022340

BoxCoder

BoxCoder类主要工作是

  • 已知两个Box计算regression回归参数
  • 已知一个Box和regression计算回归后的Box

encode

​ 根据anchor和对应的GTBox计算regression参数。这里先把所有图片的reference_boxes和proposals拼接在一起,最后返回的时候再根据每张图片的anchor个数分离,加快运算效率。

image-20230322074008057

encode_single/encode_boxes

​ 根据proposal坐标得到其宽高(ex_widths/ex_heights)和中心点坐标(ex_ctr_x/ex_ctr_y).同理计算GTBox的宽高和中心点.

image-20230323112554925

​ 最后计算四个回归参数dx/dy/dw/dh.参照以下公式.

image-20230307102128616

decode

​ 思路和encode相反,参数为proposal和回归参数regresssion,计算最终的proposal坐标.

image-20230323112840773

decode_single

​ 思路与encode_single相反.

image-20230323113010860

​ 计算公式如下.

image-20230307101918729

Matcher

Matcher的主要功能是

  • 将真实框分配给锚框,并划分正负样本

_call_

​ 根据IoU矩阵为每个proposal分配真实框,并根据IoU大小划分正负样本.

image-20230323115113955

BalancedPositiveNegativeSample

BalancedPositiveNegativeSample的主要功能:

  • 随机采样正负样本,挑选指定比例的样本参与损失计算

__call__

​ call函数的参数是matched_idxs,数据类型是List of Tensor,List代表不同图像,Tensor代表每幅图像中每个Proposals的正负样本类型,为-1时代表样本被忽略;为0时代表负样本;>0时代表正样本

image-20230324144345447

​ 首先通过条件过滤出正负样本的下标,再根据正样本的比例(positive_fraction)选取正负样本的数量.注意如果正/负样本不足时,需要采样所有正/负样本.

​ 正负样本的随机采样使用randperm函数,

image-20230324144715234

​ 最后根据选出的正负样本下标制作掩码并返回.

image-20230324145020855

RegionProposalNetwork

RegionProposalNetwork类是RPN网络的主体,调用了Matcher/BoxCoder等类来完成RPN的功能

__init__

  • 传入上文提到的两个类(anchor_generator和rpn_head).
  • fg_iou_thresh和bg_iou_thresh是划分正负样本的阈值.
  • positive_fraction是计算损失时正负样本比例
  • pre_nms_top_n是每个特征层最后NMS留下的候选框个数,post_nms_top_n是所有特征层汇总后留下的候选框个数
  • BoxCoder /Matcher/BalancedPositiveNegativeSample见前文

image-20230322033422504

forward

​ 首先将特征图丢入head中获得预测类别和边界框偏移的结果,再通过anchor_generator生成anchors.

​ 接下来计算每个特征层上的anchors的数量,用于之后的过滤操作.

​ 利用Box_coder的decode操作将pred_bbox_deltas应用在生成的anchors上,获得propoals的坐标信息,在这里做NMS操作.

image-20230322050416286

​ 最后为预测的proposal分配GTBox,并计算损失.

image-20230322051331331

concat_box_prediction_layers

​ 对box_cls和box_regression两个list中的每个预测特征层的预测信息的tensor排列顺序以及shape进行调整 -> [N, -1, C]

C是num_classes or 4(bbox regression)

image-20230322051509943

permute_and_flatten

​ 主要工作是交换维度,把C维度换到最后.利于之后计算损失.

image-20230322051918714

filter_proposals

​ 此方法调用topk方法过滤每个特征层的proposals.

​ 首先用level变量存储不同特征层上的anchors索引信息(因为这个时候不同特征层的anchor在之前的处理中存储在一起了,这里需要用level来区分不同的特征层anchor)

​ 随后调用_get_top_n_idx获取每层特征图中预测概率排前pre_nms_top_n的anchor的索引值,存入top_n_idx.利用双重索引获得这些anchor的objectness和proposal.

最后过滤得到的proposal对应的objectness记得做一次sigmod~

image-20230322063704375

​ 最后对于过滤的proposals进行边缘的裁剪,并去除小概率的proposal.最后再按照不同次执行一次batch_nms.

batch_nms是按照某个类别/预测层分别执行nms,当然底层实现更加高效.具体见下文

image-20230322064428092

clip_boxes_to_image

​ 利用clamp函数将边界框限制在图片范围内

image-20230322065759214

remove_small_boxes

​ 过滤宽高不满足条件的proposal

image-20230322065232352

batch_nms

​ 为了高效实现不同类别分别做NMS,首先为不同的类别生成一个足够大的offset,保证不同类别之间的boxes不会重叠,从而保证了在各自类别内做NMS的效果.

image-20230322065049862

assign_targets_to_anchors

将真实框分配给锚框,并划分正负样本.主要使用proposal_matcher来实现匹配.具体方法见Matcher类实现.

image-20230322070220705

compute_loss

​ 首先通过BalancedPositiveNegativeSampler类选择正负样本,并将正负样本的索引拼接在一起,用来计算目标预测损失.

image-20230322070543937

​ 随后计算正样本的边界框回归损失(smooth_L1_Loss)和所有样本的物体预测损失(BCE_Loss)

image-20230322070803228

整体思路

​ RoI-Header共由三部分组成:

  • box_roi_pool:Multi-scale RoIAlign pooling
  • box_head:TwoMLPHead
  • box_predictor:FastRCNNPredictor

MultiScaleRoIAlign

​ 该类与之前所述的RoIPooling不同,RoIAlign的定位能力更强。RoIPooling在计算过程中存在取整操作,从而引入了更多的定位误差,而Align不会进行取整操作。具体以后再展开~

image-20230325223450415

TwoMLPHead

​ TwoMLPHead其实就是RoIPooling之后跟着的两个全连接层(还有一个Flatten层).

image-20230325223606050

FastRCNNPredictor

​ FastRCNNPredictor也就是两个全连接层,分别预测每个proposal的类别和bbox的回归参数。

image-20230325223734155

注意输入的num_classes应该是实际类型+1,因为第0类是background

image-20230325223834839

ROIHeads

init

​ 保存一些需要用到的工具.

  • box_similarity负责计算box_iou
  • proposal_matcher负责正负样本的分配
  • fg_bg_sampler负责正负样本的采样
  • 其他参数就是刚刚提到的类以及一些阈值参数了

image-20230325223924768

forward

​ 训练模式下,首先会对proposal进一步采样,得到proposal样本和对应的label.

​ 其次将proposal和features特征层送入roi_pool得到每个proposal的box_features.box_features的形状应该是[num_proposals,channel,7,7]

​ 随后将box_features送入box_header提取出特征向量

​ 最后将这些向量送入box_predictor得到类别和回归参数预测结果

image-20230325224128474

​ 最后一部分代码如下.如果是训练模式下将通过fastrcnn_loss计算损失;如果是预测模式则会对proposals进行预处理postprocess_detections.最后返回相应的结果

image-20230325231206433

select_training_samples

该函数的功能是将RPN网络提供的Proposal进行采样,并计算这些Proposal的标签和regression参数(分配gtbox并计算,跟之前RPN网络内的操作类似)

​ 如下图所示,源码将gt_boxes也拼到了proposal后面,这里可能考虑到了PRN训练初始无法提供有效的proposal,所以加入gt_boxes来训练FastRCNN网络部分.

image-20230325224923241

​ 接下来将调用assign_targets_to_proposals函数将proposals分配给gt_boxes.这个函数在之前的RPN网络提到过,这里不再赘述.

​ 之后调用了subsample进行采样.得到一定比例的正负样本.

image-20230325225121300

​ 最后一步是遍历每张图片,首先找到正负样本(因为回归参数正负样本都参与计算)对应proposal的类别和proposal分配到的gt_box,再计算gt_box和proposal之间的回归参数(通过box_coder的encode方法,之前在RPN网络中有提到).

注意这里负样本对应的gt_box是第0个gt_box,按道理来说负样本不参与边界回归参数损失的计算.但是为了防止matched_idxs下标越界,所以在计算match_idxs时将-1都置为了0,导致现在"负样本有对应的gt_box,且计算了回归参数",不过这个问题不大,因为label记录了负样本的位置,在计算损失时忽略这部分即可~

image-20230325225957610

subsample

​ 该函数其实只是调用了fg_bg_sampler这个类对象,得到了每张图片里的正负样本索引,随后将每张图片的正负样本索引丢到sampled_inds列表里.

image-20230325225519409

fastrcnn_loss

​ 刚开始将label和regression cat起来是把不同图片的labels和回归参数都摞起来,一起处理.

​ 正负样本都会计算类别损失.

​ 而回归参数损失只计算正样本的,所以这里需要用sampled_pos_inds_subset记录正样本的位置.同时还需要对box_regression进行reshape处理,因为regression参数针对每个类别都会有四个参数. 最后使用smoothL1Loss进行正样本的回归参数损失计算.

image-20230325231409283

postprocess_detections

 在预测模式下,将通过此函数得到最后的预测结果。具体流程见下图;具体操作见源码(带注释)

image-20230326095609394

整体思路

​ 创建DataSet首先需要继承torch.utils.data.Dataset这个类,然后再init函数中完成数据的一些预处理,比如xml文件的解析/类与序号的映射/图片路径的存储等。

​ 接下来需要重载__len____getitem__两个方法,分别返回数据长度和某个序号对应的图片(包括图片本身和标注)

如果用到多GPU训练,按照Pytorch官方的建议,最好再实现get_height_and_wight这个方法,节约内存.(因为这样可以避免pytorch将所有图片读入计算宽高)

源码细节

1. xml解析

​ 在init方法中调用了parse_xml_to_dict方法解析xml文件,获取其中的object信息.(物体的类别/位置/边界框)

image-20230321230712153

​ 而parse_xml_to_dict具体使用递归的方法遍历标签信息,返回字典类型的数据

image-20230321230939717

2.__getitem__方法

​ 首先通过上述的给出的xml解析方法解析图片对应的xml文件,将结果存入data变量.图片也通过Image.open打开

image-20230321231804715

​ 接下来将data中的边界框和类别数据进行读取,丢到boxes和labels列表中.

image-20230321231948677

之后注意将这些数据转换成Tensor类型

​ 最后将信息都整理到target中,作为整体的标签返回.

image-20230321232106426

最后还需要判断是否对图片进行data augmentation

3.Transform

​ transform有很多类型,这里简单介绍一下水平翻转的实现.需要注意的是图片翻转之后,边界框的标注位置也需要翻转.

​ 对于水平翻转: y坐标不需要改变,xmax变为width-xmin,xmin变为width-xmax

image-20230321232427257

4.collate_fn

​ 为了之后实现dataloaer,这里需要实现collate_fn函数.

​ 不同于分类网络中dataset只返回一张图片和一个label(形式比较固定),目标识别网络中需要返回图片加标注,而标注是不等长的,使用默认的stack有可能出现问题.所以需要手动用collate_fn方法进行堆叠.

image-20230321232235799

下图是dataloader的实现,这里传入了collate_fn.不传入这个参数默认使用torch.stack()对__getitem__的每个返回值进行堆叠

image-20230321234927729