《TubeR: Tubelet Transformer for Video Action Detection》论文+代码分析

这是2022年的一篇CVPR,整个的模型的结构是在目标检测中的DETR的基础上更改而来,但观察代码发现编码器和解码器的方式都一样,说白了就是把2D的方法用到了3D中,后边又用了《Action Tubelet Detector for Spatio-Temporal Action Localization》这篇文章中Tubes的聚合方法,将每一帧预测的box聚合成Tubes,从时间线上来看就如同管道一样,不过这与之前的一些Tubes的论文不同,这里的Tubes的大小是可变的,也就是每一帧的box大小是可变的,这样就能更好的检测动作,尤其是一些距离镜头远处的人的动作也能检测到。能达到这样的效果,很大程度上都得益于DETR。后面在FFN网络中,作者又做了一定的改进,加入了前面的信息,最后才输出的结果。整篇文章以及代码的思路在我看来是:先利用DETR对每一帧进行目标检测,然后通过Tubes_Link聚合起来。下面就来讲讲整个流程。

一.论文提出的创新点:

1.提出了一种用于人体动作检测的Tubes Transformer的框架

2.基于query和attention能够生成任意位置和规模的Tubes

3.Classification Head能够聚合短期和长期的上下文信息

二.论文和代码讲解:

1.数据集准备:

文章用了3个数据集,AVA,JHMDB,UCF101。那么这里我就拿jhmdb举一个例子。注意,这里取clips的时候是以每一帧为中心帧,也就是一个视频不是只取一个clips,而是每一帧都取一个cilps。那这时可能有疑问了第一帧当作中心帧那么剩余帧如何填充?这里举一个例子:一个视频有40帧,clips的长度为16帧,取的当前帧为第0帧,那么结束帧为第7帧,也就是我取的这个片段为[0,7]帧共8帧,我要将其填充为16帧,那么前边填充4个0,后边填充4个第8帧,最后取出的cilps可以表示成[0,0,0,0,0,1,2,3,4,5,6,7,8,8,8,8],我当时看到看到这里时,就产生了疑惑从时间线来看第0帧并不是中心帧。但当从第8帧往后取时,中心帧就是当前所取的帧数。所以这并不是严格的中心帧,我认为要么把这一部分丢弃掉,要么就把第0帧制作成中心帧的clips。

    # load the video based on keyframe
    def loadvideo(self, mid_point, sample_id, target, p_t):
        from PIL import Image
        import numpy as np

        buffer = []
        # if len(glob(self.video_path + "/" + sample_id + "/*.jpg")) < 66:
        #     print(111)
        start = max(mid_point - p_t, 0)
        end = min(mid_point + self.clip_len - p_t, self.dataset["nframes"][sample_id] - 1)
        frame_ids_ = [s for s in range(start, end)]
        if len(frame_ids_) < self.clip_len:
            front_size = (self.clip_len - len(frame_ids_)) // 2
            front = [0 for _ in range(front_size)]
            back = [end for _ in range(self.clip_len - len(frame_ids_) - front_size)]
            frame_ids_ = front + frame_ids_ + back
        assert len(frame_ids_) == self.clip_len
        for frame_idx in frame_ids_:
            tmp = Image.open(os.path.join(self.video_path, sample_id, "{:0>5}.png".format(frame_idx + 1)))
            try:
                tmp = tmp.resize((target['orig_size'][1], target['orig_size'][0]))
            except:
                print(target)
                raise "error"
            buffer.append(np.array(tmp))
        buffer = np.stack(buffer, axis=0)

        imgs = []
        for i in range(buffer.shape[0]):
            imgs.append(Image.fromarray(buffer[i, :, :, :].astype(np.uint8)))
        return imgs

后面就是对这个clips进行标注的整理,代码如下。将x1,y1,x2,y2的标注信息变成x,y,h,w,并且进行归一化。假如取16帧,这里的box只标注中心帧的(也就是只取第8帧的box作为整个clips的box标注),这里的还是上面说的那个问题,小于8帧,并不是严格意义上的中心帧,但想到有几帧没有变化,所以用一个box表示clips的box也说的过去。其他就是一些很常规的label,原始尺寸的记录,还有image_id的记录这些的。

def load_annotation(self, sample_id, start, index, p_t):

        # print('sample_id',sample_id)

        boxes, classes = [], []
        target = {}
        vis = [0]

        oh = self.dataset['resolution'][sample_id][0]
        ow = self.dataset['resolution'][sample_id][1]

        if oh <= ow:
            nh = self.resize_size
            nw = self.resize_size * (ow / oh)
        else:
            nw = self.resize_size
            nh = self.resize_size * (oh / ow)

        key_pos = p_t

        for ilabel, tubes in self.dataset['gttubes'][sample_id].items():
            # self.max_person = len(tubes) if self.max_person < len(tubes) else self.max_person
            # self.person_size = len(tubes)
            for t in tubes:
                box_ = t[(t[:, 0] == start), 0:5]
                key_point = key_pos // 8

                if len(box_) > 0:
                    box = box_[0]
                    p_x1 = np.int(box[1] / ow * nw)
                    p_y1 = np.int(box[2] / oh * nh)
                    p_x2 = np.int(box[3] / ow * nw)
                    p_y2 = np.int(box[4] / oh * nh)
                    boxes.append([key_pos, p_x1, p_y1, p_x2, p_y2])
                    classes.append(np.clip(ilabel, 0, 24))

                    vis[0] = 1

        if self.mode == 'test' and False:
            classes = torch.as_tensor(classes, dtype=torch.int64)
            # print('classes', classes.shape)

            target["image_id"] = [str(sample_id) + '-' + str(start)]
            target["labels"] = classes
            target["orig_size"] = torch.as_tensor([int(nh), int(nw)])
            target["size"] = torch.as_tensor([int(nh), int(nw)])
            self.index_cnt = self.index_cnt + 1

        else:

            boxes = torch.as_tensor(boxes, dtype=torch.float32).reshape(-1, 5)
            boxes[:, 1::3].clamp_(min=0, max=nw)
            boxes[:, 2::3].clamp_(min=0, max=nh)

            if boxes.shape[0]:
                raw_boxes = F.pad(boxes, (1, 0, 0, 0), value=self.index_cnt)
            else:
                raw_boxes = boxes

            classes = torch.as_tensor(classes, dtype=torch.int64)
            # print('classes', classes.shape)

            target["image_id"] = [str(sample_id).replace("/", "_") + '-' + str(start), key_pos]
            target["key_pos"] = torch.as_tensor(key_pos)
            target['boxes'] = boxes
            target['raw_boxes'] = raw_boxes
            target["labels"] = classes
            target["orig_size"] = torch.as_tensor([int(nh), int(nw)])
            target["size"] = torch.as_tensor([int(nh), int(nw)])
            target["vis"] = torch.as_tensor(vis)
            self.index_cnt = self.index_cnt + 1
        return target

 Dataset之后会经过一个Transform的数据增强,随机翻转,随即裁剪,颜色抖动等,这里clips裁剪了,那么标注的box也要进行对应的裁剪。把数据喂给DataLoader后,会经过collate_fn的一个函数,对数据进行再一次的处理,将clips填充到一样的大小,后边方便数据传递给模型,这里有一个作者自定义的数据类型NestedTensor,里面包括了(tensor(clips的数据), mask(原图部分为0,填充部分为1))。最后Dataloader返回的是NestedTensor+标注文件

2.网络结构:

网络结构就是DETR,它的Backbone是I3D,先经过I3D提取特征后,再对特征进行编码和解码。

I3D这一部分不仅对clips进行特征的提取,相应的mask也要进行下采样,这样才能让feature map和mask的大小相互对应,得到的feature map通过正弦函数,对每一部分的特征进行位置的编码,这样做是为了标记每个位置。这里值得注意的是,标注文件中采用的是编码的方式。根据代码可以

知道,对I3D提取的特征要先进行一次编码,下图中的xs就是I3D提取的特征(feature map),而query_embed是可学习参数的权重。

pool_decoder部分的代码如下图所示,其中tgt是query_embed,memory是xs,整个的思想是:先进行self-attention,计算query自己和自己每个位置的关联程度,cross-attention:query和clips的feature计算每个部分的相似程度,然后当作权值加在query(可学习参数)的每个位置,最后输出这个query。

送入Transformer进行编码的有,src: [4, 256, 1, 16, 16] ,mask:[4, 1, 16, 16] ,query_embed: (15,256)---参数可学习的词表(根据数据集的不同,其也会不同:ava:(num_query ,256),其他数据集:(tmp_len*num_query , 256)), pos:[4, 256, 1, 16, 16]。这里的src不是真正的clips的feature,而是上面携带有位置信息clips的特征信息的query。后面的编码也都是针对query来的。解码时的过程与上面的编码过程很像,要经过self-attention和cross-attention,(这里值得说明一下的是:cross-attention的本质也是self-attention,query和value来自同一组编码的Feature时,就是对自身的各个feature查询,如果query和value来自不同的编码feature,那就是cross-attention)。

3.编解码具体的过程:

3.1编码器部分:

整个Transformer的过程就如下图所示,

注意这里的query_embeded,如果使用的是ava数据集,最后预测的就只有针对中心帧的query_num个情况,而使用其他的数据集,如jhmdb,针对每一帧都预测出query_num个结果,然后在从这些中挑出query_num个结果作为中心帧的预测结果。具体如下图中词向量的定义:num_query*temporal_length就是使用其他数据集的情况。

送入编码器的有编码后的query和mask以及位置编码,编码器由多个编码层构成,这里面有6个编码层,每个编码层的结构如下图所示:

编码层用了使用了self-attention,计算src中每个部分的相似程度或者说关联程度,然后和src相加,这里面相加不是直接相加,而是经过了一个dropout层,是因为要防止过拟合。然后再进行层归一化。layernorm也能防止过拟合。再经过线性层和非线性的激活函数,加深网络的表达能力。

feature_map中不是每一个特征都对最后的分类,以及box的位置预测都有帮助的,想要起决定性作用的特征对最后的结果影响大一些,无关紧要的特征对结果影响小一些,由于这些层的参数都是通过每次学习可以调整的,因此整个编码的思想:调整每部分feature_map的权值,让网络学习哪些位置的信息对结果起正面影响,就加大这一部分的权值,反之。这应该就是我理解的编码器部分所要表达的重要理念。

3.2解码器部分:

这里输入解码器的部分有,tgt全0的矩阵,充当解码时的q和k,memory:经过编码后的输出,充当解码时的v,还有memeory的mask以及位置编码,和这个可学习参数的位置编码,用来学习哪些位置重要。

下图展示的是解码器的部分: 

可以看到,tgt先加上新的可学习的位置编码,先进行self-attention的计算,是为了得到特征与特征之间的关系,然后用这种建模好的关系作为查询,去学习编码好的对应的位置的特征关系,并将学习到的特征融合到先前建模好的这种查询关系中,后面就是再增强这种查询关系的表达能力。整个过程,个人的理解是全新的q和k, 让pos去学习哪些位置最重要(可以区分类别的),并记录这些位置,最后通过多层解码,按照我们建模好的query中不仅含有物体与物体之间的相关性的信息,而且还有物体的特征信息,由于还多了时间维度,所以还能捕捉到物体的运动信息。

4.Classfication Head:

后面对Transformer解码器出来的query又做了一系列的变换,首先对于Backbone提取的clips的feature_map进行卷积的处理,这里的feature_map不是经过编码(self-attention+cross-attention)后的,就是原来的特征。

然后经过一个编码器:编码器的输入只有原来的feature_map

这个编码器的部分的代码如下图所示:

 分别从时间上和空间上做了一次self-attention,让feature_map含有时间上的相关信息和空间位置的相关信息,返回的是src也就是src_flatten。然后用Transformer解码器出来的query去查询src_flatten中的信息,这里做的是一个cross-attention。相当于融合了位置,时间等信息的query去查询原始的feature,query融合原始的feature的信息。再经过不同的线性层和激活函数得到分类信息,坐标的信息。最后输出的信息有pred_logits,pred_boxes。其中针对每个clips会预测15个中心帧的box和对应的分类信息(当然预测的数量是可更改的)。

4.1小思考:

1.为什么I3D提取后的特征不直接传入编码器,而是用一个可学习的参数query经过self-attention和cross-attention,融合了feature_map信息后再传入Transformer进行编解码?

我认为可能有两方面的原因,第一个原因:I3D提取后的特征是相互独立的,而经过两种attention后的feature_map包含了周围的信息,那么在编码时对这种包含了周围信息的feature_map进行编码要比独立信息的feature_map容易第二个原因:可能跟过拟合有关,从代码能看到,是把cross_attention的信息先经过dropout后再融入query中。这样可能比直接使用feature_map更能减少数据的相互依赖程度。

2.为什么对于Transformer输出的query不直接经过线性层和激活函数得到结果,而是用这个输出的query经过cross-attention去查询原始的Feature_map后再得到结果?

我认为是因为:经过多层的编码和解码,最开始的有些信息(如纹理)可能被丢弃,那么用解码后的query去查询原图,就能弥补丢失的这一部分的信息,将信息融合到query中,在进行分类和box的预测。

5.损失函数:

损失函数包括了三个部分:label_loss,boxes_loss,loss_masks。

对于jhmdb数据集只计算label_loss,boxes_loss两种loss。

上面说了对于jhmdb的每一个数据,会总共预测(tmp_len*num_query)个结果,然后先进行一次选择。

 key_frames = torch.from_numpy(np.array([[self.num_queries * (targets[i]["key_pos"].cpu()) + j for j in range(self.num_queries)] for i in range(len(targets))]))
 key_frames = key_frames.view(len(key_frames), self.num_queries, 1).to(targets[0]["key_pos"].device)
 outputs_without_aux = {k: v.gather(1, key_frames.repeat(1, 1, v.shape[-1])) if k in ['pred_boxes', 'pred_logits'] else v for k, v in outputs.items() if k != 'aux_outputs'}

 假如,输入的clips的长度为tmp_len = 16, num_query为10,那么一共生成160个结果,从第80帧开始选,往后挑10帧。即选择[80,90]作为中心帧的候选帧。

5.1小疑问:

看到这里我就有疑问:1.为什么前面预测的输出为(tmp_len*num_query)只从里面取nums_query个作为中心帧的候选?2.就是这种取的方式合不合理?相当于从中间位置往后取,为什么不能从第一帧取?为什么不能跳着取?虽然可以通过输出来改变解码器的映射方式,以达到我们想要的效果,但是这样不是造成了浪费?

这里非常欢迎大家也帮我解决这些疑问。

那我们就暂时按照论文提供的代码来取,取出nums_query个作为中心帧的候选结果,然后通过匈牙利算法进行匹配,一个target匹配一个result。

5.2匈牙利算法:

匹配target集合和result集合(预测的集合),最终的匹配方案选取loss总和最小的分配方式,这里的loss和我们计算的常规的不太一样,这里作为一种度量(cost/metrix),初始化中,下图的三部分

cost分别表示了要计算loss的权重。

下图展示了匈牙利匹配算法的前向过程:

 这里的分类loss采取了更加简单而直接的方式,取分类的概率的负值,假如概率特别高,那么其对loss的贡献值越小,box计算loss时,采用的是torch.cdist中的L1误差,对每对预测框与GT都进行误差的计算。这里还进行了预测框和GT的GIoU的计算。

然后就是要通过算出每部分的loss进行预测框和GT的匹配,这里用到了linear_sum_assignment函数,其输入是一个二分图的度量矩阵,计算二分图度量矩阵的最小权重的分配方式,返回的是行索引和列索引,(即result集合(预测的集合)中元素的索引)。

5.3 Loss_label的计算:

计算中心帧标签的损失+switch的损失,这个switch相当于开关,控制着中心帧是不是有box,0表示没有box,1表示有box。这里采用常规的交叉熵损失。

5.4 Loss_boxes的计算:

 这里计算通过匈牙利算法匹配出来的box(和target数量相对应,相当于一对一)和target的L1回归损失GIoU损失。

本来还有mask的loss,但作者说 开销太大,就忽略了。

6.补充:

        在训练时,我们预测的是中心帧的情况,根据中心帧损失的情况,对中心帧进行约束。由于制作数据集时我们把每一帧都当作中心帧,生成clips,送入模型训练,在测试时,要看模型的效果,不仅要看每一帧iou锁定的准不准,还要把这些帧聚合起来,形成固定长度的clips,看这一个动作片段的变化是不是预测的准。

        所以这里涉及到两个指标一个是F-mAP另一个是V-mAP,根据作者自己说,F-mAP会很大程度上影响V-mAP,但是代码部分只给了F-mAP的,我复现出来的f-mAP@0.5只有0.72远远低于说的0.823,如下图所示,后来我看了gihub上有人也有这样的疑问,很多人和我复现的结果都一样。作者说0.82是v-mAP的结果,由frame聚合成clips参考的方法是《Action Tubelet Detector for Spatio-Temporal Action Localization》中的聚合方法,让我们有兴趣自己弄一下。

我就把这一部分的代码自己根据意思整理出来了,算了一下v-mAP,结果如下图:

v-mAP@0.5只有0.74,没有达到作者说的结果,v-mAP@0.2是达到了,所以我就好奇,为什么差距这么大,索性又设置了阈值,发现大多数的clips的iou聚集在0.4以下,0.4-0.5部分分布就下降了很多。并且作者在文章中也没有提到v-mAP中设置的筛选的阈值是多少?

7.总结:

虽然没有完全复现出这篇文章的结果,但有学习到新的知识,这篇文章还是给动作检测提供很多新的思路的,总体来说还是有一定的参考价值,第一个就是编码的方式,不是直接编码,还有是frame聚合成clips所用的方法,等等。

欢迎大家指正和探讨,以及解答我还存有疑惑的地方。

我的前面的文章讲过了v-mAP是怎么计算的:https://blog.csdn.net/qq_58484580/article/details/131784103?spm=1001.2014.3001.5501

论文代码地址:https://github.com/amazon-science/tubelet-transformer

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值