前言
论文:End-to-end Temporal Action Detection with Transformer
代码:TadTR
从论文题目可以看出 TadTR 是基于 Transformer 的端到端的方法,TAD 在视频动作分类任务上更进一步,不仅对动作分类,还要检测动作发生的时间段。本文使用的代码为 OpenTAD,其中包含了多种 TAD 方法,在正式解析网络细节之前,看一下总框架。
图像序列会经过 CNN 提取特征,并用 Transformer Encoder 编码,Encoder 的输出经过 Decoder 和 FFN 得到 Segment 分割时间段和 Class Prob 动作分类得分。时间段和 Encoder 特征再经过 Actionness Regression 计算时间段是否存在动作。
整体的思路与目标检测类似,Segment 对应检测框 box,Class Prob 对应目标分类 class,Actionness 对应目标框是否存在目标 conf。
Backbone
TadTR 使用 SlowFast 作为 Backbone。SlowFast 本身做视频动作分类,相关工作请看 视频理解调研笔记 | 2021年前视频动作分类发展脉络(类似使用图像分类的网络作为目标检测的 Backbone 来抽特征)。下面对输入数据、模型结构和参数的细节进行说明。
输入
Input fast
是从原始视频中每隔 3 帧取 1 帧共 256 帧图像,通过间隔较短的大量帧作为运动动态信息;Input slow
对Input fast
每隔 8 帧取 1 帧共 32 帧,通过间隔较长的少量帧作为静态图像信息;- 4 - BatchSize,3 - 图像 RGB 三通道,32/256 - 图像帧数, 96 × 96 96\times96 96×96 - 图像分辨率(保比缩放+中心裁剪)。
- 对于一个较长的视频来说会取出多段的 256 帧最终基本覆盖整个视频,具体的取帧逻辑就略过了,直接以某个视频的划分结果来感受一下,这里一个视频划分出了 3 个 Batch 的数据,对应原始视频帧的索引分别为 0~765、192~957、246~1011。
结构和参数
- SlowFast 包含 2 个 3D-ResNet 对应了快慢分支;
- 慢分支的图像特征会多次融合快分支的运动特征,具体方式是用 3D 卷积对运动特征在时序上做下采样,然后和图像特征做 Concat;
- 空间维度上,模型下采样 32 倍后通过一个平均池化变为 1 × 1 1\times1 1×1;
- 时间维度上全程保持不变,末尾处以线性插值的方式将两个分支的维度对齐为 128;
- 最后两个分支的特征 Concat 到一起作为 Backbone 的输出。
- 以
Input fast
后的第一个CBR
为例对结构图进行说明,CBR
代表Conv + BatchNorm + RuLU
的模块,上方的两组参数分别为时序和空间上的size + stride + pad
,如果只有一组参数代表在时序维度的参数为1,1,0
。 res_layer
上方参数代表Bottleneck
的数量,Bottleneck
的结构以slow_res_layer1
中的第一个为例进行说明,具体如下图。每个res_layer
的第一个Bottleneck
的残差结构都需要一个 downsample 卷积对特征的维度或空间分辨率做调整,空间参数为1,1,0
或1,2,0
,时间参数为1,1,0
;后续的Bottleneck
因为输入输出形状相同,残差结构去掉 downsample 卷积直接和输入相加。
Bottleneck
每个卷积核的参数可以看下表,空间上都保持一致,3 个卷积核的参数分别为1,1,0
、3,1,1
、1,1,0
;时间上,conv1
中除了慢分支的layer1
和layer2
的为1,1,0
其余都是3,1,1
,conv2
和conv3
全是1,1,0
。
Projection
Projection 的输入为 Backbone 的输出,用一维卷积做特征降维,torch.nn.GroupNorm 按特征维度 32 一组划分为 8 组做 Normalization。这里的 Mask 是在对原始视频取帧时得到的,大致意味着是否是真实的帧(例如视频帧不够了需要 padding 到 256 帧那么对应 Mask 的值为 False),本文默认 Mask 全为 True 进行说明。
Transformer
1. Encoder
Encoder 包含 TDA 和 FFN,先看 FFN 的计算流程。
难点在于 TDA,出自 Deformable-DETR,原本用于做目标检测,受可变形卷积 DCN 启发对 Self-Attention 做改进,核心在于不再计算所有特征之间的注意力权重,而是选取几个位置的特征,且位置由网络学习得到。
输入
(1)query & value
对 Projection
的输出做维度调整,即 query = value = Projection_output.permute(0, 2, 1)
(2)enPos
图中位置编码 enPos
按下方代码由两个部分组成。其中 position
和原版 Transformer 类似,由三角函数生成;level
是可学习参数,对应特征的层级(此处只有 1 个 level),在原版 Deformable-DETR 会取出不同层级的特征图,position
体现的是空间上的位置差异,level
则体现层级的差异。
self.level_embeds = nn.Parameter(torch.Tensor(self.encoder.num_feature_levels, self.encoder.embed_dim))
pos_embed = self.position_embedding(masks) + self.level_embeds[0].view(1, 1, -1)
(3)enPoints
其数值为 0.5 ~ 127.5 除以 128 做归一化,对应时序的位置坐标。
计算流程
B = 1 = BatchSize
N = 128
H = 8 = Head
L = 1 = Level
P = 4 = Point
D = 32 = Dimension
(1)attention
与原始 Transformer 不同,注意力权重直接通过一个全连接层加 Softmax 得到。
(2)offset
用一个全连接层得到坐标偏移量,与原坐标 enPoints
相加得到坐标 Locations
(范围大致0~1),
×
2
−
1
\times2-1
×2−1 得到 Grids
(范围大致-1~1),Stack 的数值全为 -1,具体意义在 value 分支一同说明。
(3)value
sampling_value_l_ = F.grid_sample(
value_l_.unsqueeze(-1),
sampling_grid_l_,
mode="bilinear",
padding_mode="zeros",
align_corners=False,
)
直接看 grid_sample
操作,原图为
32
×
32
×
128
×
1
32\times32\times128\times1
32×32×128×1,第一个
32
=
B
×
H
32=B\times H
32=B×H,第二个
32
32
32 为特征维度,
128
128
128 对应了时序的长度也可以看作词向量的个数,在这里可以把
128
×
1
128\times1
128×1 看作
h
×
w
h\times w
h×w 的图像分辨率,grid
中最后的维度
2
=
(
x
,
y
)
2=(x,y)
2=(x,y) 坐标,其中
x
=
−
1
x=-1
x=−1,
y
y
y 在 offset 分支计算得到。输出可以看作 128 个时间点,每个时间点关注 4 个特定时间点的特征,特征的维度是 32。
grid_sample
后的 Stack 操作是用于合并每个 level
,后续就是常规的加权求和。这里返回看 Softmax 的维度是
L
×
P
L\times P
L×P,与原版 Transformer 对比,原版输入是每个单词的特征,经过 self-attention 输出每个单词新的特征,而新的特征是对所有单词特征加权求和所得;在 TDA 中输入是 128 个时间点的特征,输出的每个特征由 4 个时间点的特征加权求和所得。
2. Decoder
输入 query
先经过经典多头注意力 nn.MultiheadAttention,其输出和 Encoder 的输出分别作为 TDA 的输入 query
和 value
,得到输出 Output
。
Output
通过 bbox embed
与 dePoints
一同更新 dePoints
,bbox embed
具体细节如下图;最后一个 Decoder 的输出 dePoints
代表检测动作时间段 box,Class
代表动作分类。
最后,box 与 Encoder 输出特征做 RoIAlign 与 FFN 得到每个片段是否存在动作的置信度,对应总框架图右上角部分。
box 中的 2 个数值分别代表时间的中心点和时间长度,RoI 的 3 个数值分别代表 batch 索引、时间的起始和结束点(尺度为128)。
输入
Decoder 的输入由 nn.Embedding 生成,query
对应起始的输入,dePos
和 dePoints
会代替 Encoder 中 TDA 的 enPos
和 enPoints
(注 enPoints
会不断更新,而 enPos
保持不变)。40 代表 proposals 个数,意味着这段视频内最多检测出 40 个动作。
self.query_embedding = nn.Embedding(two_stage_num_proposals, self.encoder.embed_dim * 2)
Postprocess
后处理根据分类得分和动作置信度综合排序得到结果,最终输出时间段(秒)、动作类别、置信度。每个 Batch 输出 200 个结果,此处 3 个 Batch 属于同一个视频,那么一个视频就得到 600 个结果。
prob = sigmoid(Class) * actionness # [4,40,20]
score, index = topk(prob.view(bs, -1), 200, dim=1) # [4,200]
Train
1. 输入
return self.losses(output, masks, gt_segments, gt_labels)
Loss 计算使用的 output 如下所示,前三项和推理阶段相同,最后一项 aux_outputs
是 Decoder 阶段的中间输出。Decoder 包含 4 个相同的模块,前三个输出的 dePoints 和 Class 构成了 aux_outputs
。
2. 正样本选取
indices = self.matcher(outputs_without_aux, gt_segments, gt_labels)
此处针对 Outputs 的前三项,即最终输出进行计算,4 个元组对应 4 个 batch,每个 batch 中第一个张量对应 proposal 的索引,第二个张量对应 gt 索引。
3. Loss 计算
这里简单介绍每个部分 Loss 的核心计算函数,具体计算细节涉及不少代码和各种参数意义不大,如下图所示,前四项为最终输出对应的 Loss,后续为 aux
部分的 Loss,计算方式是一样的。
(1)class
F.binary_cross_entropy_with_logits 计算交叉熵,正样本 → \to → 1,负样本 → \to → 0
(2)bbox
F.l1_loss 计算 box 数值的 L1Loss
(3)iou
1 − G I o U 1-\mathrm{GIoU} 1−GIoU
(4)actionness
F.l1_loss 计算 actionness 和 IoU 的 L1Loss
代码中的细枝末节
(1)dePoints 迭代与 Locations 计算
在 Decoder 中 TDA 的 dePoints 初始形状为 4 × 40 × 1 4\times 40\times 1 4×40×1 而后续为 4 × 40 × 2 4\times 40\times 2 4×40×2,结合源码看 Locations 的计算方式。
"当 points 为 [4,40,1] 时直接与 offsets 相加"
"normalizer=128=时序长度, 对 offsets 缩放"
sampling_locations = (
reference_points[:, :, None, :, None]
+ sampling_offsets / offset_normalizer[None, None, None, :, None]
)
"当 points 为 [4,40,2] 时第二个值用于对 offsets 缩放, num_points=4"
sampling_locations = (
reference_points[:, :, None, :, None, 0]
+ sampling_offsets / self.num_points * reference_points[:, :, None, :, None, 1] * 0.5
)
(2)Mask
在 Projection 部分提到了 Mask,实际上 Mask 在许多地方都会使用,但本文默认 Mask 全为 True 而将其忽略了。Mask 随 DataLoader 获取,记录序列帧中每一帧是否是 padding 得到的,对比图像就是记录图像的像素是否是 padding 的。代码中会根据真实帧所占的比例 ratio = sum(Mask) / len(Mask)
作为一个系数在一些地方使用。
(3)正样本选取的代码实现
这里的代码实现比较有意思,直接看最后一行,c[i]
代表每个 Batch 的 proposals 和对应 gt 的 cost 矩阵大小为 [40,n]
,n 为 gt 的数量。利用匈牙利算法 scipy.optimize.linear_sum_assignment 直接得到正样本的分配,在此任务中就是为每个 gt 分配一个不重复的 proposal,最终 cost 总和最低。这里的 cost 矩阵由分类得分、检测框的绝对值与 IoU 综合构成,cost 越低代表与 gt 越匹配。
# alpha=0.25, gamma=2.0
neg_cost_class = (1 - alpha) * (out_prob**gamma) * (-(1 - out_prob + 1e-8).log()) # [160,20]
pos_cost_class = alpha * ((1 - out_prob) ** gamma) * (-(out_prob + 1e-8).log()) # [160,20]
cost_class = pos_cost_class[:, tgt_ids] - neg_cost_class[:, tgt_ids] # [160,6]
cost_bbox = torch.cdist(out_bbox, tgt_segment, p=1) # [160,6]
cost_giou = -compute_iou_torch(proposal_cw_to_se(tgt_segment), proposal_cw_to_se(out_bbox)) # [160,6]
# self.cost_bbox=5.0, self.cost_class=6.0, self.cost_giou=2.0
C = self.cost_bbox * cost_bbox + self.cost_class * cost_class + self.cost_giou * cost_giou # [160,6]
C = C.view(bs, num_queries, -1).cpu() # [4,40,6]
indices = [linear_sum_assignment(c[i]) for i, c in enumerate(C.split(sizes, -1))]