论文题目:End-to-End Object Detection with Transformers
论文链接:[2005.12872] End-to-End Object Detection with Transformers (arxiv.org)
源码链接:GitHub - facebookresearch/detr: End-to-End Object Detection with Transformers
前言:
DETR是目标检测领域除了yolo之外的另一种体系和框架(也就是开启了新的一种目标检测范式),自2020年发表以后,目前已经有12000+的引用量了。最开始的DETR是一个简单并且高效的框架,训练轮次较少的情况下就可以取得不错的效果,在大目标上的效果比较好,小目标的效果稍差。
DETR方法的优势:
文章翻译为端到端的检测,它将目标检测问题转化为一个集合预测问题,也就是意味着该文章提出的方法和以前双阶段的模型不一样,以前双阶段的模型第一阶段可能就是生成大量的预设的检测框,本文是单阶段,也就是去除了目标框生成的阶段。可以总结为:
- 没有非极大值抑制(NMS)
- 没有anchor的生成
DETR的核心内容总结为下面两点:
- transformer结构的使用
- 二分图匹配算法
对于这两点的解释:(如果不明白可以先跳过,看完论文再看)
transformer是对特征图的提取,encoder阶段是对全局关系进行建模,decoder阶段是预测目标框也就是位置信息和类别。
二分图匹配算法是最后模型对预测的100目标框与真实框(gt,groundtruth)进行匹配用于计算损失的算法。
可以预见的是,100个框中可能对一个物体进行了多个位置的预测,如何找到最佳的那个目标框就是二分图匹配算法要做的事情。
DETR模型:
DETR模型分为四个阶段
1.backbone的特征提取阶段
2.transformer的encoder
3.transformer的decoder
4.预测层FFN阶段
1. CNN Backbone
backbone是一个使用在 Imagenet 预训练好的 Resnet50的CNN网络模型,该部分目前可以替换成Swin-L等其他模型。
作用:CNN 的部分对原始输入的图像进行特征提取,这样可以缩减输入到transformer的数据量。
输入:原始图像
输出:特征图(通道数为 2048,图像高和宽都变为了原来的 1/32)
位置编码张量形状是(batch_size, num_pos_feats * 2, height, width)
tensors属性通常是一个形状为(batch_size, channels, height, width)
CNN的特征图进入encoder之前,还需要一个经过一个1*1的卷积降低维度(就是降低C),2048维度到256维度)
2.transformer的encoder
通过文章encoder的消融实验的结果来看,encoder阶段已经可以对图像中大概的目标物体进行区分,因此encoder阶段可以理解为,对不同的物体之间进行关系建模,自注意机制捕捉图像中不同区域之间的相互关系。
图像的不同部分进行注意力操作,建模全局关系。自注意力机制在特征图上进行全局分析,因为最后一个特征图对于大物体比较友好,那么在上面进行 Self-Attention 会便于网络更好的提取不同位置不同大物体之间的相互关系的联系,比如有桌子的地方可能有杯子,有草坪的地方有树,有一个鸟的地方可能还有一个鸟等等。
memory = self.encoder(src, src_key_padding_mask=mask, pos=pos_embed)
输入:一共有6层layer,每一层的layer的输入是上层输出
第一层的输入是backbone阶段的输出(原始的DETR只使用了最后一个阶段的特征图)
输出:transformer的输出输出大小形状是相同的
+号不是cat的意思,它是直接把tensor相加在了一起
def with_pos_embed(self, tensor, pos: Optional[Tensor]):
return tensor if pos is None else tensor + pos
# 位置编码在模块后处理
def forward_post(self,
src,
src_mask: Optional[Tensor] = None,
src_key_padding_mask: Optional[Tensor] = None,
pos: Optional[Tensor] = None):
# 位置编码信息与src直接相加
q = k = self.with_pos_embed(src, pos)
# self-attetion部分
src2 = self.self_attn(q, k, value=src, attn_mask=src_mask,
key_padding_mask=src_key_padding_mask)[0]
#
src = src + self.dropout1(src2)
src = self.norm1(src)
src2 = self.linear2(self.dropout(self.activation(self.linear1(src))))
src = src + self.dropout2(src2)
src = self.norm2(src)
return src
3.transformer的decoder
hs = self.decoder(tgt, memory, memory_key_padding_mask=mask, pos=pos_embed, query_pos=query_embed)
作用:Decoder的目标是预测一组对象的类别和边界框(bounding boxes)。Decoder通过解码器的自注意力和交叉注意力机制(cross-attention)来逐步构建预测结果。
交叉注意力允许解码器在生成边界框时考虑编码器提供的全局图像特征。
输入:一共有6个layer,每个layer的输入是上一层的输出
每个layer的第一组件输入是object query
object query包含两个部分:上一层的输入+ query_embed
query_embed是可学习的参数,num_queries=100
self.query_embed = nn.Embedding(num_queries, hidden_dim)
由于第一个layer的第一个组件没有上一层的输入,上一层的输入就初始化全是0
tgt = torch.zeros_like(query_embed)
后面的组件就是再经过多头自注意机制,输入的Q,K,V,还包括encoder的输出和位置编码
输出:100个(参数定义的个数)预测值(100*256*HW) 输入输出都是一样的
4.FFN预测阶段
这一层实际上就是全连接层,对decoder的输出进行全连接层投影输出为100个标签(类别是数据集类别个数决定的)和对应的目标框的坐标值。
预测类别的是线性层
预测框体的是3层的感知器
# FFN网络
self.class_embed = nn.Linear(hidden_dim, num_classes + 1) #用于分类属于哪个类91+1
self.bbox_embed = MLP(hidden_dim, hidden_dim, 4, 3) #用于预测框的位置
hs = self.transformer(self.input_proj(src), mask, self.query_embed.weight, pos[-1])[0]
#调用的是forward函数
# pos[-1] 位置编码信息的最后一层
# FFN阶段
outputs_class = self.class_embed(hs)
outputs_coord = self.bbox_embed(hs).sigmoid()
很明显,一张图片100个预测目标框的数量对于很多图片是远远多的,因此,经过匈牙利匹配算法之后,就是计算模型损失,然后反向传播,更新参数的过程。
这四个部分的作用可以看下这个图,来自深度学习之目标检测(十一)--DETR详解-CSDN博客
不同的组件可能是提供了更大的参数来使得模型有更大的参数量来适应该任务
5.损失函数
损失函数的部分,注意与后面二分图匹配的代价函数的不同,损失函数需要是正值,所以使用了 log-probability,而代价函数是直接通过对预测出来的概率值*权重(以label的计算为例)进行计算
不过实际上模型的损失函数也是包含了class的label损失和bbox损失两个部分(bbox包括有L1和Giou损失),这三个部分分别赋予了不同的权重
def get_loss(self, loss, outputs, targets, indices, num_boxes, **kwargs):
loss_map = {
'labels': self.loss_labels,
'cardinality': self.loss_cardinality,
'boxes': self.loss_boxes,
'masks': self.loss_masks
}
#
assert loss in loss_map, f'do you really want to compute {loss} loss?'
# 具体的计算过程在各个函数里面
return loss_map[loss](outputs, targets, indices, num_boxes, **kwargs)
首先是label的损失,采用的是常用的多分类的交叉熵计算
def loss_labels(self, outputs, targets, indices, num_boxes, log=True):
"""Classification loss (NLL)
targets dicts must contain the key "labels" containing a tensor of dim [nb_target_boxes]
"""
assert 'pred_logits' in outputs
src_logits = outputs['pred_logits']
idx = self._get_src_permutation_idx(indices)
target_classes_o = torch.cat([t["labels"][J] for t, (_, J) in zip(targets, indices)])
target_classes = torch.full(src_logits.shape[:2], self.num_classes,
dtype=torch.int64, device=src_logits.device)
target_classes[idx] = target_classes_o
loss_ce = F.cross_entropy(src_logits.transpose(1, 2), target_classes, self.empty_weight) # 计算交叉熵损失
losses = {'loss_ce': loss_ce}
# self.empty_weight作用是什么?
# self.empty_weight参数在这里用来为每个类别指定一个权重,以此来调整损失函数对于不同类别的重视程度。
# 权重越高的类别在损失函数中占的比重越大,这样可以帮助模型在训练过程中更多地关注那些样本数量较少的类别。
# empty_weight = torch.ones(self.num_classes + 1)
# empty_weight[-1] = self.eos_coef
# 把最后一个类别,也就是空背景的类别设置为0.1
losses = {'loss_ce': loss_ce}
if log:
# TODO this should probably be a separate loss, not hacked in this one here
losses['class_error'] = 100 - accuracy(src_logits[idx], target_classes_o)[0]
return losses
其次,bbox的loss计算包含L1和Giou,没什么好说的
def loss_boxes(self, outputs, targets, indices, num_boxes):
"""Compute the losses related to the bounding boxes, the L1 regression loss and the GIoU loss
targets dicts must contain the key "boxes" containing a tensor of dim [nb_target_boxes, 4]
The target boxes are expected in format (center_x, center_y, w, h), normalized by the image size.
"""
assert 'pred_boxes' in outputs
idx = self._get_src_permutation_idx(indices)
src_boxes = outputs['pred_boxes'][idx]
target_boxes = torch.cat([t['boxes'][i] for t, (_, i) in zip(targets, indices)], dim=0)
loss_bbox = F.l1_loss(src_boxes, target_boxes, reduction='none')
losses = {}
losses['loss_bbox'] = loss_bbox.sum() / num_boxes
loss_giou = 1 - torch.diag(box_ops.generalized_box_iou(
box_ops.box_cxcywh_to_xyxy(src_boxes),
box_ops.box_cxcywh_to_xyxy(target_boxes)))
losses['loss_giou'] = loss_giou.sum() / num_boxes
return losses
接着,'cardinality': self.loss_cardinality,该函数用于计算预测非空框的数量与目标非空框的数量之间的绝对误差,即预测框的数量与真实框的数量之间的差异。这个函数主要用于日志记录目的,不用于反向传播梯度。
@torch.no_grad()
def loss_cardinality(self, outputs, targets, indices, num_boxes):
""" Compute the cardinality error, ie the absolute error in the number of predicted non-empty boxes
This is not really a loss, it is intended for logging purposes only. It doesn't propagate gradients
"""
pred_logits = outputs['pred_logits']
device = pred_logits.device
tgt_lengths = torch.as_tensor([len(v["labels"]) for v in targets], device=device)
# Count the number of predictions that are NOT "no-object" (which is the last class)
card_pred = (pred_logits.argmax(-1) != pred_logits.shape[-1] - 1).sum(1)
card_err = F.l1_loss(card_pred.float(), tgt_lengths.float())
losses = {'cardinality_error': card_err}
return losses
需要补充的是,损失函数不只有模型的损失函数部分,DETR模型还利用了辅助损失
最后,关于损失函数部分的执行过程代码如下:
# SetCriterion类的
def forward(self, outputs, targets):
""" This performs the loss computation.
Parameters:
outputs: dict of tensors, see the output specification of the model for the format
targets: list of dicts, such that len(targets) == batch_size.
The expected keys in each dict depends on the losses applied, see each loss' doc
"""
outputs_without_aux = {k: v for k, v in outputs.items() if k != 'aux_outputs'}
# Retrieve the matching between the outputs of the last layer and the targets
# 得到100pre和gt的对应关系。
indices = self.matcher(outputs_without_aux, targets) # 对于每一个批次(batch)中的图像,根据算法找到模型输出和真实标签之间的最佳匹配关系。
# Compute the average number of target boxes accross all nodes, for normalization purposes
# 这些是对目标框的处理
num_boxes = sum(len(t["labels"]) for t in targets) #
num_boxes = torch.as_tensor([num_boxes], dtype=torch.float, device=next(iter(outputs.values())).device)
if is_dist_avail_and_initialized():
torch.distributed.all_reduce(num_boxes)
num_boxes = torch.clamp(num_boxes / get_world_size(), min=1).item()
# Compute all the requested losses
losses = {}
# 字典类型,结果是每种loss对应的值
for loss in self.losses:
# 进行loss的计算
losses.update(self.get_loss(loss, outputs, targets, indices, num_boxes))
# In case of auxiliary losses, we repeat this process with the output of each intermediate layer.
if 'aux_outputs' in outputs: # 有辅助损失的部分,就对每一层的输出进行前面类似的计算
for i, aux_outputs in enumerate(outputs['aux_outputs']):
indices = self.matcher(aux_outputs, targets) # 对于每一个批次(batch)中的图像,根据算法找到模型输出和真实标签之间的最佳匹配关系。
for loss in self.losses: # 前面已经处理好了目标框
if loss == 'masks': # 忽略masks
# Intermediate masks losses are too costly to compute, we ignore them.
continue
kwargs = {}
if loss == 'labels': # 如果损失函数是'labels',则设置日志记录为False
# Logging is enabled only for the last layer
kwargs = {'log': False}
l_dict = self.get_loss(loss, aux_outputs, targets, indices, num_boxes, **kwargs) # 类似前面的计算loss
l_dict = {k + f'_{i}': v for k, v in l_dict.items()}
losses.update(l_dict)
return losses
DETR的不足:
1.backbone阶段只利用了特征的最后一层,最后一层中大目标的信息比较多(C*HW,C=2048,HW是原来的1/32,通道数多,特征图高宽比较小,这不利于多跟小目标的检测),对大目标的检测结果较好是合理的。因为transformer的结构,无法输入更大的特征图,因此如何输入更多,更大的特征图也是后续的研究重点,deformeable detr就是针对这个问题提出的。
2.可以了解到,decoder的输入query embed含义是不明确的,直接0初始化其中一部分是比较简单粗暴的,后续的研究对query embed的含义进行了解释,同时也提出了很多初始化输入的方法。
3.此外,DETR还存在收敛慢的问题,这个问题根据后续的研究工作来看是解码器中的交叉注意力和匈牙利损失的不稳定性这两个方面,匈牙利损失主要是由于二义性导致的不稳定,而交叉注意力DAB-DETR发现是cross-attention中learned query位置编码的问题,而不是query难以学习的原因。
参考资料:
其他知识补充:
1.NMS
非极大值抑制(Non-Maximum Suppression,NMS)是一种在目标检测中非常重要的技术,它的作用是去除冗余的检测框,只保留最有可能包含目标物体的框,从而提高检测的精度和效率 。NMS的基本原理是保留每个目标的最高置信度的边界框,同时去除其他与之高度重叠的边界框,这里的重叠通常用交并比IOU(Intersection over Union)来量化 。
NMS的具体实现步骤如下:
- 对于每个类别,按照预测框的置信度进行排序。
- 选择置信度最高的预测框作为基准,然后遍历剩余的预测框,如果与基准框的IOU大于某个阈值,则将其删除。
- 重复上述过程,直到所有预测框都被处理完毕 。
2.anchor生成(解释为什么没有anchor生成是一个优势)
以前的目标检测过程是先预设一些潜在的anchor的位置,也就是会生成一些anchor(例如Fast-RNN第一阶段就是通过预设的框体的个数以及高宽比,生成一定数量和大小的目标框。第二阶段再进行微调),然后在模型训练的时候进行微调。
3.RPN
RPN是"Region Proposal Network"的缩写,中文可以称为“区域提议网络”。RPN是一种深度学习架构,用于目标检测任务中,主要负责生成候选的区域(即建议框,proposals),这些建议框是潜在的目标物体的位置和大小的估计。
RPN的核心思想是通过滑动窗口在特征图上快速生成候选区域,然后使用卷积神经网络(CNN)来预测每个候选区域是否包含目标物体,以及物体的类别和边界框的偏移量。RPN通常与后续的检测网络(如Fast R-CNN或Faster R-CNN)结合使用,以实现端到端的目标检测。
4.二分图匹配算法
注意:二分图匹配的损失函数只是叫损失函数,因为它和用于模型训练的损失函数作用是一样的,但是这个用于二分图匹配的损失函数不是模型训练的损失函数,只是用于gt的匹配
二分图匹配算法是一种解决二分图中的匹配问题的方法,其目的是在两个顶点集合之间找到一条边的集合,使得这些边之间互不相交,并且尽可能多的顶点被匹配。二分图匹配在许多领域都有应用,比如在网络流、调度、最优分配等问题中。
(回到本文的问题,就是在一张图预测的100个框和这张图片真实的几个框之间进行1对1匹配,通过定义的损失函数,找到损失最小的匹配情况就认为是给gt找到最佳的匹配框,就可以计算损失了)
gt是一个y=(c,b)的表示形式,前面c代表类别,后面b是一个四维的位置框体坐标
简单写为上面的形式,详细说明是如何计算的就是下面的公式
而关于bbox计算又包含下面两个部分(bbox+giou)
cost_class = -out_prob[:, tgt_ids]
# Compute the L1 cost between boxes
cost_bbox = torch.cdist(out_bbox, tgt_bbox, p=1)
# Compute the giou cost betwen boxes
cost_giou = -generalized_box_iou(box_cxcywh_to_xyxy(out_bbox), box_cxcywh_to_xyxy(tgt_bbox))
# Final cost matrix
C = self.cost_bbox * cost_bbox + self.cost_class * cost_class + self.cost_giou * cost_giou
以下是一些常见的二分图匹配算法:
-
匈牙利算法(Hungarian Algorithm):该算法也是DETR使用的
- 这是一种在多项式时间内解决二分图匹配问题的高效算法。
- 算法的基本思想是通过构造一个增广路径来找到一个完美匹配或者证明不存在完美匹配。
- 它使用了一个称为“覆盖”的技术,通过交替的匹配和非匹配边来增加匹配的数目。
-
Hopcroft-Karp算法:
- 这个算法是对匈牙利算法的改进,它可以更快地找到二分图的最大匹配。
- Hopcroft-Karp算法通过同时寻找多条增广路径来减少算法的迭代次数。
-
Edmonds' Blossom Algorithm:
- 这是一种用于寻找二分图最大匹配的算法,它可以处理包含特殊结构(如“花”)的图。
- 该算法通过识别并缩减图中的“花”结构来逐步增加匹配的数目。
-
Kuhn-Munkres算法:
- 这是另一种用于解决二分图匹配问题的算法,它通过线性规划或者矩阵运算来找到最优匹配。
- Kuhn-Munkres算法可以看作是匈牙利算法的一个变种,它使用了不同的数学工具来找到匹配。
-
Ford-Fulkerson算法:
- 虽然这个算法主要用于寻找网络流问题中的最大流,但它也可以用于二分图匹配问题。
- Ford-Fulkerson算法通过寻找增广路径来逐步增加流,直到达到最大流。
-
Dinic算法:
- 这个算法也是一种网络流算法,它可以用于求解二分图匹配问题。
- Dinic算法通过分层网络来加速寻找增广路径的过程。
在目标检测的上下文中,二分图匹配算法常用于解决类别不均衡问题,特别是在训练阶段。例如,在DETR(Detection Transformer)模型中,二分图匹配算法可以用于将预测的边界框与真实边界框进行最优匹配,以便计算损失函数并进行模型训练。匈牙利算法因其高效性和稳定性,在这类应用中尤为常见。
最近忙里偷闲,把上半年看的论文梳理记录一下,算是给对几个月的学习内容的总结,希望再接再厉,不积跬步无以至千里!
如有理解不到位的地方,欢迎交流指正!