代码链接DETR-small-demo(建议看我的,模型框架部分代码注释的很详细,源码可以移步官网)

论文链接:https://ai.meta.com/research/publications/end-to-end-object-detection-with-transformers/

官方链接detr

DETR给我的感觉是总体的模型结构比YOLO简单很多,代码量精简,逻辑清晰,但是训练它比YOLO难的不是一点半点。对了,学习DETR前最好先学一下VIT,这样理解起来会简单一点。

代码学习就行,跑的话真没必要了。官方给的数据8个V100要跑6天才跑完300epoch,就算是缩减版的也要跑3天。我自己没当真,想着跑个差不多的就行了,不追求精度,然后跑了4epoch花了12个小时。早上过来看训练效果,AP的所有指标全是0,分类错误率100%,两眼一黑。还有,官方给的加载数据的方法是强适配COCO的,用自己的数据集的话必须有和COCO一样的annotations。
官方给出的效果图,简直震惊。两只象的腿重叠这么厉害的情况下,注意力还能分清哪只是大象的腿(蓝色),哪只是小象的腿(黄色)。这模型未来可期

在这里插入图片描述

DETR的模型框架

从下面这张图可以清晰的看到一张图片的颠沛流离。首先backbone使用ResNet进行特征提取;Encoder输入的是positional encoding(位置编码)和backbone提取的特征图。Decoder输入固定的query(例如一张特征图只生成100个锚框,即100个query。不同于YOLO,每个像素点生成3个锚框,还分大中小三种特征图,那生成的特征图多的简直…所以DETR追求的就是一个简化简化!),keyvalue的输入是Encoder提供的。

这里还有一点不同,在自然语言中应用tranformer的时候,如GPT是把句子后面的内容遮挡住,确保当前词只能看到它前面的词,模型以串行方式逐步生成后续内容。这种 顺序处理 的需求是因为文本的上下文是 时间相关的,词的顺序决定了语义。而这里的tranformer没有对输入图像的特征进行遮挡,而是直接将整个特征图输入进去。因为图像的像素或特征是 空间相关的,而非时间相关。

最后再接FFN(简单的说就是全连接层)进行预测。预测有两条支线,一条是预测分类,一条是是预测锚框xywh。这里就有人问了,你预测了100个锚框,但是你图片上只有两个目标,你剩余的98个锚框呢?这里用了匈牙利算法。简单的说就是,如果你图片上只有两个目标,那么我就找两个loss最小的锚框作为预测值(loss怎么算的下面细说),剩下的98个都是背景。这也是为什么COCO有80个分类,但是为什么代码里设置的是81,因为还有一个是背景。

在这里插入图片描述

demo

官方提供了一个小demo,可以看到短短50行就把模型框架写完了。

class DETRdemo(nn.Module):
    """
    Demo DETR implementation.

    Demo implementation of DETR in minimal number of lines, with the
    following differences wrt DETR in the paper:
    * learned positional encoding (instead of sine)
    * positional encoding is passed at input (instead of attention)
    * fc bbox predictor (instead of MLP)
    The model achieves ~40 AP on COCO val5k and runs at ~28 FPS on Tesla V100.
    Only batch size 1 supported.
    """

    def __init__(self, num_classes, hidden_dim=256, nheads=8,
                 num_encoder_layers=6, num_decoder_layers=6):
        super().__init__()

        # create ResNet-50 backbone
        self.backbone = resnet50()
        del self.backbone.fc  # 删除了 ResNet 的全连接层 (fc),保留用于提取特征的卷积部分

        # create conversion layer  将 ResNet 输出的 2048 维特征降维到 Transformer 使用的 256 维
        self.conv = nn.Conv2d(2048, hidden_dim, 1)

        # create a default PyTorch transformer
        self.transformer = nn.Transformer(hidden_dim, nheads, num_encoder_layers, num_decoder_layers)

        # prediction heads, one extra class for predicting non-empty slots
        # note that in baseline DETR linear_bbox layer is 3-layer MLP
        self.linear_class = nn.Linear(hidden_dim, num_classes + 1)  # 预测目标的类别(包括一个额外的“背景”类)
        self.linear_bbox = nn.Linear(hidden_dim, 4)  # 预测边界框的xywh(归一化到 [0, 1])

        # output positional encodings (object queries)
        self.query_pos = nn.Parameter(torch.rand(100, hidden_dim))  # 预测100个框

        # spatial positional encodings
        # note that in baseline DETR we use sine positional encodings
        self.row_embed = nn.Parameter(torch.rand(50, hidden_dim // 2))  # 行列位置编码
        self.col_embed = nn.Parameter(torch.rand(50, hidden_dim // 2))

    def forward(self, inputs):
        # propagate inputs through ResNet-50 up to avg-pool layer
        x = self.backbone.conv1(inputs)  # 将输入图像通过 ResNet-50 提取特征,逐层提取多尺度特征
        x = self.backbone.bn1(x)
        x = self.backbone.relu(x)
        x = self.backbone.maxpool(x)

        x = self.backbone.layer1(x)
        x = self.backbone.layer2(x)
        x = self.backbone.layer3(x)
        x = self.backbone.layer4(x)

        # convert from 2048 to 256 feature planes for the transformer
        h = self.conv(x)  # [B, 2048, H, W] -> [B, 256, H, W]

        # construct positional encodings
        H, W = h.shape[-2:]
        pos = torch.cat([  # 位置编码 [H*W, 1, 256]
            self.col_embed[:W].unsqueeze(0).repeat(H, 1, 1),
            self.row_embed[:H].unsqueeze(1).repeat(1, W, 1),
        ], dim=-1).flatten(0, 1).unsqueeze(1)

        # propagate through the transformer
        h = self.transformer(pos + 0.1 * h.flatten(2).permute(2, 0, 1),  # 加了位置编码的特征图
                             self.query_pos.unsqueeze(1)).transpose(0, 1)  # [100, B, 256]

        # finally project transformer outputs to class labels and bounding boxes
        return {'pred_logits': self.linear_class(h),  # 类别预测
                'pred_boxes': self.linear_bbox(h).sigmoid()}  # 边界框预测,通过 Sigmoid 归一化到 [0, 1]

得益于 Transformer 的表示能力,DETR 架构非常简单,比YOLO简单太多了。主要由两个组件:

  • ResNet:使用 ResNet 提取图片特征。
  • Transformer:通过全局关系增强特征。

看看我用这个demo实现的一个效果
在这里插入图片描述

模型解读

好,现在开始深入解读,折磨开始!

backbone

前面什么RestNet部分就不说了,直接调用模型就行

class Joiner(nn.Sequential):
    def __init__(self, backbone, position_embedding):
        super().__init__(backbone, position_embedding)

    def forward(self, tensor_list: NestedTensor):
        xs = self[0](tensor_list)  # 这里就是个ResNet   self[0]: backbone self[1]: position_embedding
        out: List[NestedTensor] = []
        pos = []
        for name, x in xs.items():  # 先通过ResNet提取特征图,再为特征图添加位置编码
            out.append(x)
            # position encoding
            pos.append(self[1](x).to(x.tensors.dtype))  # 位置编码

        return out, pos  # 特征图 与特征图对应的位置编码

bockbone的输出就是先通过ResNet提取特征图,再为特征图添加位置编码

positional encoding (位置编码)

def forward(self, tensor_list: NestedTensor):
    x = tensor_list.tensors  # (bs, n_c, w, h) (批次, 特征通道数, 宽, 高)
    mask = tensor_list.mask  # (bs, w, h) True表示padding的部分
    assert mask is not None
    not_mask = ~mask  # 取反 True表示有效区域
    y_embed = not_mask.cumsum(1, dtype=torch.float32)  # 按行累加 每个特征获得列坐标位置编码
    x_embed = not_mask.cumsum(2, dtype=torch.float32)  # 按列累加 每个特征获得行坐标位置编码
    if self.normalize:  # 归一化
        eps = 1e-6  # eg: not_mask = [[True, True, False],[True, False, False]] -> y_embed = [[1, 2, 0],[1, 0, 0]]
        y_embed = y_embed / (y_embed[:, -1:, :] + eps) * self.scale  # 取最后一个值肯定就是最大值
        x_embed = x_embed / (x_embed[:, :, -1:] + eps) * self.scale  # 2π 方便后面位置编码的正余弦操作

    dim_t = torch.arange(self.num_pos_feats, dtype=torch.float32, device=x.device)
    dim_t = self.temperature ** (2 * (dim_t // 2) / self.num_pos_feats)  # 分别对奇偶位置编码

    pos_x = x_embed[:, :, :, None] / dim_t  # (b, w, h, num_pos_feats)
    pos_y = y_embed[:, :, :, None] / dim_t
    pos_x = torch.stack((pos_x[:, :, :, 0::2].sin(), pos_x[:, :, :, 1::2].cos()), dim=4).flatten(3)
    pos_y = torch.stack((pos_y[:, :, :, 0::2].sin(), pos_y[:, :, :, 1::2].cos()), dim=4).flatten(3)
    pos = torch.cat((pos_y, pos_x), dim=3).permute(0, 3, 1, 2)
    return pos 

mask补充说明一下,就是一张图输入的时候会reszie为统一大小,如果图片不够大的地方会用padding填充,所以mask就是将输入图片中,本来就是图片的地方为Flase,而填充的地方为True,即区分图片的有效部分和无效部分,无效部分不参与计算。

代码逻辑

  1. mask 生成有效区域的行列位置累积值,累积的最大值用于归一化。
  2. 归一化这些位置值,用于位置编码。
  3. 计算特征图每个像素点的正弦和余弦编码。
  4. 将 x 和 y 的编码拼接成最终的二维位置编码。

至于位置编码为什么这样,详细要看论文,这个一言两语解释不清,我只从代码的角度解释

Transformer Encoder(编码器)

def forward_post(self, src, src_mask: Optional[Tensor] = None,
                 src_key_padding_mask: Optional[Tensor] = None,
                 pos: Optional[Tensor] = None):
    q = k = self.with_pos_embed(src, pos)  # q, k = 特征图 + 位置信息 v不需要位置信息
    src2 = self.self_attn(q, k, value=src, attn_mask=src_mask,  # 不需要遮盖后面的数据
                          key_padding_mask=src_key_padding_mask)[0]  # 不计算padding的数据 True值的就是需要mask的数据
    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

q = k = self.with_pos_embed(src, pos):将特征图 src 和位置编码 pos 合并,得到位置编码后的查询(q)和键(k)。在 Transformer 中,查询和键通常是相同的,因为它们是从同一源特征图中计算得到的。

src2 = self.self_attn(q, k, value=src, attn_mask=src_mask, key_padding_mask=src_key_padding_mask)[0]:使用查询(q)、键(k)和值(value)来计算注意力得分并生成输出。这里的查询和键都是来自位置编码过的特征图 srcattn_mask 是告诉Encoder不要遮掩图片。key_padding_mask 是指示哪些位置是 padding 数据的遮蔽。

后面不说了,就是一些残差连接、规范化(与处理图片常用的BN不一样哦)、全连接、激活、Dropout,都是些常规的网络。

Transformer Decoder (解码器)

def forward_post(self, tgt, memory,
                 tgt_mask: Optional[Tensor] = None,
                 memory_mask: Optional[Tensor] = None,
                 tgt_key_padding_mask: Optional[Tensor] = None,
                 memory_key_padding_mask: Optional[Tensor] = None,
                 pos: Optional[Tensor] = None,
                 query_pos: Optional[Tensor] = None):
    q = k = self.with_pos_embed(tgt, query_pos)  # q, k的初始化时仅有100个预测框的位置编码
    tgt2 = self.self_attn(q, k, value=tgt, attn_mask=tgt_mask,
                          key_padding_mask=tgt_key_padding_mask)[0]
    tgt = tgt + self.dropout1(tgt2)
    tgt = self.norm1(tgt)
    tgt2 = self.multihead_attn(query=self.with_pos_embed(tgt, query_pos),  # q是Decoder提供的
                               key=self.with_pos_embed(memory, pos),  # k, v是Encoder提供的
                               value=memory, attn_mask=memory_mask,
                               key_padding_mask=memory_key_padding_mask)[0]
    tgt = tgt + self.dropout2(tgt2)
    tgt = self.norm2(tgt)
    tgt2 = self.linear2(self.dropout(self.activation(self.linear1(tgt))))
    tgt = tgt + self.dropout3(tgt2)
    tgt = self.norm3(tgt)
    return tgt

和编码器中很多相似的地方我就不解释了。这里解码器中主要用了自注意力和交叉注意力,其中自注意力里的qkv是自身的,而交叉注意力里的kv是编码器中的。

  • 自注意力机制:通过目标序列自身的自注意力计算更新目标表示。
  • 交叉注意力机制:通过编码器的输出更新目标表示,使解码器能够利用编码器的上下文信息。

loss计算

这里的loss计算了三类损失,分别是labelsboxescardinality,然后以加权的方式计算总loss。还有,DETR在计算loss时,不仅计算了最后一层的,前面几层的输出也会计算损失。

在计算loss前得找到预测的100锚框里,哪个拿来跟我的真实框进行计算呢?

def forward(self, outputs, targets):
    """
    目标匹配 根据 分类结果 和 框位置 的相似度来匹配预测框和真实框
    :param outputs: This is a dict that contains at least these entries:
                "pred_logits": 预测分类结果  [batch_size, num_queries, num_classes]
                "pred_boxes": 预测框坐标的  [batch_size, num_queries, 4]
    :param targets: This is a list of targets (len(targets) = batch_size), where each target is a dict containing:\
                "labels": 目标类别标签 [num_target_boxes] num_target_boxes  图中真实目标数量
                "boxes": 目标框坐标 [num_target_boxes, 4]
    :return: A list of size batch_size, containing tuples of (index_i, index_j) where:
            - index_i 预测的索引
            - index_j 真实的索引
            len(index_i) = len(index_j) = min(num_queries, num_target_boxes) 不会超过 100个
    """

    bs, num_queries = outputs["pred_logits"].shape[:2]

    # We flatten to compute the cost matrices in a batch
    out_prob = outputs["pred_logits"].flatten(0, 1).softmax(-1)  # [batch_size * num_queries, num_classes]
    out_bbox = outputs["pred_boxes"].flatten(0, 1)  # [batch_size * num_queries, 4]

    # Also concat the target labels and boxes 将每个目标的类别标签和框坐标拼接在一起,形成整体的标签和框
    tgt_ids = torch.cat([v["labels"] for v in targets])  # 标签里的类别
    tgt_bbox = torch.cat([v["boxes"] for v in targets])  # 标签里的锚框

    # Compute the classification cost. Contrary to the loss, we don't use the NLL,
    # but approximate it in 1 - proba[target class].
    # The 1 is a constant that doesn't change the matching, it can be ommitted.
    cost_class = -out_prob[:, tgt_ids]

    # Compute the L1 cost between boxes
    cost_bbox = torch.cdist(out_bbox, tgt_bbox, p=1)  # 计算预测框和真实框之间的 L1距离(曼哈顿距离)

    # Compute the giou cost betwen boxes  计算框的重叠程度 GIOU
    cost_giou = -generalized_box_iou(box_cxcywh_to_xyxy(out_bbox), box_cxcywh_to_xyxy(tgt_bbox))

    # Final cost matrix 分类损失、L1损失、GIoU损失按权重合并
    C = self.cost_bbox * cost_bbox + self.cost_class * cost_class + self.cost_giou * cost_giou
    C = C.view(bs, num_queries, -1).cpu()

    sizes = [len(v["boxes"]) for v in targets]  # 每个图片中的目标框的数量
    indices = [linear_sum_assignment(c[i]) for i, c in enumerate(C.split(sizes, -1))]  # 匈牙利算法
    return [(torch.as_tensor(i, dtype=torch.int64), torch.as_tensor(j, dtype=torch.int64)) for i, j in indices]

最后输出的indices包含了预测框和真实框索引。

假设的场景

  • 真实框(targets)有 4 个目标:[目标A, 目标B, 目标C, 目标D],它们的索引分别为 [0, 1, 2, 3]
  • 模型预测了 100 个框(queries),其中只有一部分是有效预测框,其余是无关框(背景)。
  • 我们希望找到 与真实框匹配的预测框,从而为每个真实目标分配一个最佳预测框。

于是我们计算代价矩阵C

  • 分类代价(预测框的类别分布与真实类别之间的差异)。
  • L1 框位置代价(预测框和真实框的坐标差异)。
  • GIoU 框位置代价(两者的重叠程度)。

然后使用匈牙利算法找到最优匹配

  • 预测框索引[5, 12, 47, 88](代表 100 个预测框中,与真实框最匹配的框索引)。
  • 真实框索引[0, 1, 2, 3](代表 4 个真实目标的索引)。

那么最终indices的值为

indices = [(torch.tensor([5, 12, 47, 88]), torch.tensor([0, 1, 2, 3]))]

分类损失

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)])  # 从 targets 中提取匹配的真实类别
    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}

    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

假设场景

indices = [
    (tensor([46, 97]), tensor([0, 1])),  # 第一张图片的匹配
    (tensor([25, 28, 39, 80]), tensor([1, 0, 3, 2]))  # 第二张图片的匹配
]

那么idx=(tensor([0, 0, 1, 1, 1, 1]), tensor([46, 97, 25, 28, 39, 80]))

  • 批次号 0 的预测框索引是 [46, 97]
  • 批次号 1 的预测框索引是 [25, 28, 39, 80]

然后我们得到真实的图像类别为target_classes_o=tensor([82, 79, 1, 1, 1, 34])

构造一个 target_classes,并将预测框位置填充对应的目标类别。

  • 第一张图片
    • 46 位置设为 8297 位置设为 79
  • 第二张图片
    • 25 位置设为 128 位置设为 139 位置设为 180 位置设为 34
tensor([[91, 91, ..., 82, ..., 79, ..., 91],  # 第一张图片
        [91, 91, ..., 1,  ..., 1,  ..., 1, ..., 34, ..., 91]]) # 第二张图片

计算交叉熵损失

loss_ce = F.cross_entropy(
    src_logits.transpose(1, 2),  # 模型的输出概率分布
    target_classes,  # 目标类别,形状 [batch_size, num_queries]
    weight=self.empty_weight  # 类别权重(可选)
)
  • 第一张图片:
    • 46 的预测概率为 [..., 0.1, 0.8, 0.1, ...],真实类别为 82
    • 97 的预测概率为 [..., 0.2, 0.7, 0.1, ...],真实类别为 79
  • 第二张图片:
    • 25 的预测概率为 [..., 0.05, 0.9, 0.05, ...],真实类别为 1
    • 28 的预测概率为 [..., 0.2, 0.75, 0.05, ...],真实类别为 1
    • 39 的预测概率为 [..., 0.15, 0.7, 0.15, ...],真实类别为 1
    • 80 的预测概率为 [..., 0.1, 0.5, 0.4, ...],真实类别为 34

L o s s = − 1 / 6 ∗ ( l o g ( 0.8 ) + l o g ( 0.7 ) + l o g ( 0.9 ) + l o g ( 0.75 ) + l o g ( 0.7 ) + l o g ( 0.4 ) ) Loss=-1/6*(log(0.8)+log(0.7)+log(0.9)+log(0.75)+log(0.7)+log(0.4)) Loss=1/6(log(0.8)+log(0.7)+log(0.9)+log(0.75)+log(0.7)+log(0.4))

这段代码,交叉熵损失在每张图片的有效预测框位置(idx)上计算模型的预测概率与目标类别之间的差异。填充类别(91)的损失被自动忽略

锚框损失

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]  # 预测的锚框xywh
    target_boxes = torch.cat([t['boxes'][i] for t, (_, i) in zip(targets, indices)], dim=0)  # 真实锚框的xywh

    loss_bbox = F.l1_loss(src_boxes, target_boxes, reduction='none')  # 预测框和目标框在 xywh 坐标上的绝对误差

    losses = {}
    losses['loss_bbox'] = loss_bbox.sum() / num_boxes  # 对误差求和,并除以目标框数量

    loss_giou = 1 - torch.diag(box_ops.generalized_box_iou(  # GIoU损失
        box_ops.box_cxcywh_to_xyxy(src_boxes),  # GIoU 值范围为 [−1,1],值越大表示预测框越接近目标框
        box_ops.box_cxcywh_to_xyxy(target_boxes)))
    losses['loss_giou'] = loss_giou.sum() / num_boxes
    return losses

假设场景

  • 预测框 src_boxes=[[0.5,0.5,0.2,0.2],[0.7,0.7,0.3,0.3]]
  • 目标框 target_boxes=[[0.5,0.5,0.2,0.2],[0.8,0.8,0.2,0.2]]
  • 预测框与目标框的GIoU = [1, 0.7]

L o s s L 1 = ( 0 + 0.1 + 0.1 + 0.1 + 0.1 ) / 2 = 0.2 LossL1=(0+0.1+0.1+0.1+0.1)/2=0.2 LossL1=0+0.1+0.1+0.1+0.1/2=0.2 L o s s G I o U = ( ( 1 − 1 ) + ( 1 − 0.7 ) ) / 2 = 0.5 LossGIoU=((1-1)+(1-0.7))/2=0.5 LossGIoU=((11)+(10.7))/2=0.5

这段代码计算了 L1回归损失GIoU损失,分别从 位置偏差重叠程度 两方面衡量预测框的质量。

预测框数量损失

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-small-demo

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐