这是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