ssd网络结构_目标检测--SSD(附pytorch代码详解)

SSD详解:

结合代码https://github.com/amdegroot/ssd.pytorch进行讲解

1.网络结构:

b73de56b11d32656cc7735e78bedc8ac.png

整个网络可以分成三个部分:

2.1 Base网络:

主要用来提取特征,这里使用VGG16, 但是这里做了一些修改:

  1. conv4-1前面一层的maxpooling的ceil_mode=True,使得输出为 38x38;
  2. 将第5个pooling层和后面的fc层丢弃, 其中池化层pool5由原来的stride=2的2x2 变成stride=1 的3x3(猜想是不想reduce特征图大小)
  3. 3个fc层丢弃, 加上的conv6采用扩展卷积或带孔卷积(Dilation Conv),其在不增加参数与模型复杂度的条件下指数级扩大卷积的视野,采用 3x3 大小但dilation rate=6的扩展卷积。conv7采用1x1的卷积,

网络层次图:

beb2988a6f7c2632ea487bfff4069931.png

base的部分(VGG16)实现:

# cfg =  [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'C', 512, 512, 512, 'M' ,512, 512, 512]
def vgg(cfg, i, batch_norm=False):
    layers = []
    in_channels = i
    for v in cfg:
        if v == 'M':
            layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
        elif v == 'C':
            layers += [nn.MaxPool2d(kernel_size=2, stride=2, ceil_mode=True)]  # maxpool3中使用了ceil_model=True,在pooling时向上取整,特征图由75->38
        else:
            conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1)
            if batch_norm:
                layers += [conv2d, nn.BatchNorm2d(v), nn.ReLU(inplace=True)]
            else:
                layers += [conv2d, nn.ReLU(inplace=True)]
            in_channels = v
    # max_pooling (3,3,1,1)        
    pool5 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)
    # 新添加的网络层 1024x3x3
    conv6 = nn.Conv2d(512, 1024, kernel_size=3, padding=6, dilation=6)
​
    # 新添加的网络层 1024x1x1
    conv7 = nn.Conv2d(1024, 1024, kernel_size=1)
​
    # 结合到整体网络中
    layers += [pool5, conv6,
               nn.ReLU(inplace=True), conv7, nn.ReLU(inplace=True)]
    return layers
​
# 代码测试
if __name__ == "__main__":
    base = {
    '300': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'C', 512, 512, 512, 'M',
            512, 512, 512],
    '512': [],
    }
    vgg = nn.Sequential(*vgg(base['300'], 3))
    x = torch.randn(1,3,300,300)
    print(vgg(x).shape)  #(1, 1024, 19, 19)

conv4_3层的特征图大小是38x38,该但是该层比较靠前,其norm较大,所以在其后面增加了一个L2 Normalization层(参见ParseNet),以保证和后面的检测层差异不是很大,这个和Batch Normalization层不太一样,其仅仅是对每个像素点在channle维度做归一化,而Batch Normalization层是在[batch_size, width, height]三个维度上做归一化。

class L2Norm(nn.Module):
    def __init__(self,n_channels, scale):
        super(L2Norm,self).__init__()
        self.n_channels = n_channels
        self.gamma = scale or None
        self.eps = 1e-10
        self.weight = nn.Parameter(torch.Tensor(self.n_channels))
        self.reset_parameters()
​
    def reset_parameters(self):
        init.constant_(self.weight,self.gamma)
​
    def forward(self, x):
        norm = x.pow(2).sum(dim=1, keepdim=True).sqrt()+self.eps
        #x /= norm
        x = torch.div(x,norm)
        out = self.weight.unsqueeze(0).unsqueeze(2).unsqueeze(3).expand_as(x) * x
        return out

2.2 Extra Layers部分:

接连在base网络后面, 用来增加一些卷积层,以便增加一些尺度的特征图输入给,新增的卷积层有:Conv8_2,Conv9_2,Conv10_2,Conv11_2,他们的输出尺度分别为(10,10),(5,5),(3,3),(1,1)

b6b1e7e2096f8d97b7edf3f4bee91b60.png

网络层次:

26a67ebc607b0c54e8e6bdc650f9d99f.png

实现:

# [256, 'S', 512, 128, 'S', 256, 128, 256, 128, 256]
def add_extras(cfg, i, batch_norm=False):
    # Extra layers added to VGG for feature scaling
    layers = []
    in_channels = i
    flag = False  # False means 0, True means 1
    for k, v in enumerate(cfg):
        if in_channels != 'S':
            if v == 'S':
                layers += [nn.Conv2d(in_channels, cfg[k + 1],
                           kernel_size=(1, 3)[flag], stride=2, padding=1)]
            else:
                layers += [nn.Conv2d(in_channels, v, kernel_size=(1, 3)[flag])]
            flag = not flag
        in_channels = v
    return layers

3.3 Multi-box Layers 部分:

由base部分和add_extras部分的介绍, 这个网络的输入分别是base部分的conv4_3 和conv_7,以及extras_layers 部分的Conv8_2,Conv9_2,Conv10_2,Conv11_2的输出的特征图,尺度分别为(38,38), (19,19),(10,10),(5,5),(3,3),(1,1), 每个特征图分别交给两个卷积层来处理,一个loc_layer, 输出位置信息,另一个是conf_layer, 来输出分类信息. 每个特征图上的每个像素都对应了不同个数的prio_box,这里取个数分别是[4, 6, 6, 6, 4, 4] . 这里取conv_4的特征图来举例说明:

特征38x38个像素,每个像素上生成4个prio_box. loc_layer和conf_layer都采用3x3, stride=1, padding=1的卷积方式,输出尺寸不变,但是channel发生了改变: 因为每个像素有4个prio_box, 每个prio_box有4个位置坐标值,所以loc_layer的输出channel=4x4. 同理每个prio_box有21种类别可能, conf_layer的输出channel=4 x 21.

对于其他输入给检测的特征图都用相同的处理方式, 所以loc_layers和conf_layers分别存储了处理各个特征图的loc_layer和conf_layer

网络层次:

ee9daab628e8506d0ef0035df6285e5b.png

实现过程:

def multibox(vgg, extra_layers, cfg, num_classes):
    loc_layers = []
    conf_layers = []
    vgg_source = [21, -2]
    for k, v in enumerate(vgg_source):  #处理vgg部分特征图的layers
        loc_layers += [nn.Conv2d(vgg[v].out_channels,
                                 cfg[k] * 4, kernel_size=3, padding=1)]
        conf_layers += [nn.Conv2d(vgg[v].out_channels,
                        cfg[k] * num_classes, kernel_size=3, padding=1)]
    for k, v in enumerate(extra_layers[1::2], 2):  # 处理extra_layers输出特征图的layers
        loc_layers += [nn.Conv2d(v.out_channels, cfg[k]
                                 * 4, kernel_size=3, padding=1)]  
        conf_layers += [nn.Conv2d(v.out_channels, cfg[k]
                                  * num_classes, kernel_size=3, padding=1)]
    return vgg, extra_layers, (loc_layers, conf_layers) #这里的输出包含了vgg和extra_layers网络

以上三个部分都是简单的网络层组合成的list,在ssd类中组合成具有forward功能的moudle类:,整个ssd的网络结构:

6df73e1d9fdb84b859869138fe6011de.png

2. 先验框(prio_box)的生成

这里参照了https://zhuanlan.zhihu.com/p/33544892的描述:

每个特征图的每个单元为中心都设置一定数量的prio_box, 但是不同特征图设置的先验框数目不同(同一个特征图上每个单元设置的先验框是相同的,这里的数目指的是一个单元的先验框数目)。

先验框的设置,包括尺度(或者说大小)和长宽比两个方面。对于先验框的尺度,其遵守一个线性递增规则:随着特征图大小降低,先验框尺度线性增加:

e8f0c5fca5fc39c71189819a47ab3677.png

0e84cb38b553d81f81b74d1c603305c0.png
class PriorBox(object):
    """Compute priorbox coordinates in center-offset form for each source
    feature map.
    cfg = {
    'num_classes': 21,
    'lr_steps': (80000, 100000, 120000),
    'max_iter': 120000,
    'feature_maps': [38, 19, 10, 5, 3, 1],
    'min_dim': 300,
    'steps': [8, 16, 32, 64, 100, 300],  #下采样的倍数
    'min_sizes': [30, 60, 111, 162, 213, 264],
    'max_sizes': [60, 111, 162, 213, 264, 315],
    'aspect_ratios': [[2], [2, 3], [2, 3], [2, 3], [2], [2]],
    'variance': [0.1, 0.2],
    'clip': True,
    'name': 'VOC',
    }
    """
    def __init__(self, cfg):
        super(PriorBox, self).__init__()
        self.image_size = cfg['min_dim']
        # number of priors for feature map location (either 4 or 6)
        self.num_priors = len(cfg['aspect_ratios'])
        self.variance = cfg['variance'] or [0.1]
        self.feature_maps = cfg['feature_maps']
        self.min_sizes = cfg['min_sizes']
        self.max_sizes = cfg['max_sizes']
        self.steps = cfg['steps']
        self.aspect_ratios = cfg['aspect_ratios']
        self.clip = cfg['clip']
        self.version = cfg['name']
        for v in self.variance:
            if v <= 0:
                raise ValueError('Variances must be greater than 0')
​
    def forward(self):
        mean = []
        for k, f in enumerate(self.feature_maps):
            for i, j in product(range(f), repeat=2):
                f_k = self.image_size / self.steps[k]
                # unit center x,y
                cx = (j + 0.5) / f_k
                cy = (i + 0.5) / f_k
​
                # aspect_ratio: 1
                # rel size: min_size
                s_k = self.min_sizes[k]/self.image_size
                mean += [cx, cy, s_k, s_k]
​
                # aspect_ratio: 1
                # rel size: sqrt(s_k * s_(k+1))
                s_k_prime = sqrt(s_k * (self.max_sizes[k]/self.image_size))
                mean += [cx, cy, s_k_prime, s_k_prime]
​
                # rest of aspect ratios
                for ar in self.aspect_ratios[k]:
                    mean += [cx, cy, s_k*sqrt(ar), s_k/sqrt(ar)]
                    mean += [cx, cy, s_k/sqrt(ar), s_k*sqrt(ar)]
        # back to torch land
        output = torch.Tensor(mean).view(-1, 4)
        if self.clip:
            output.clamp_(max=1, min=0)
        return output

priorbox网络层中variance变量的作用:

在作者的回答参见:The meanings of parameter "variance" in PriorBox layer · Issue #75 · weiliu89/caffe

个人理解:除以variance是对预测box和真实box的误差进行放大,从而增加loss,增加梯度,加快收敛速度。

实现细节:在encodeBBox中除以相应的variance,在decodeBBox中就要乘以相应的variance.

3.前向推断过程:

1.1 读取图片:

这里最主要值得一提的操作是, cv2.imread读取的图片是BGR格式的,需要通过

rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)

转换到RGB格式,方便后面做通道减均值处理

然后经过如下处理:

x = cv2.resize(image, (300, 300)).astype(np.float32)  # 缩放
x -= (104.0, 117.0, 123.0)  # 减去通道均值,通过减去数据对应维度的统计平均值,来消除公共的部分,以凸显个体之间的特征和差异。
x = x.astype(np.float32)
x = x[:, :, ::-1].copy()#将三个通道的顺序又变换过来,成为最原始的顺序
x = torch.from_numpy(x).permute(2, 0, 1) #(300,300,3) -> (3,300,300)
​
xx = Variable(x.unsqueeze(0))     # wrap tensor in Variable

1.2 网络模型的推导:

读取的图片经过处理,导入网络模型(模型结构在后面会有详细的讲解,可以先看模型结构,才能理解模型的输出是什么),经过模型的forward过程,得到网络的输出值,主要包括两个部分: 位置信息loc 和 分类信息conf, 还有生成的priox_box送给检测层做解码用, 结合loc一起生成最终的预测框位置信息

ssd的forward过程如下,可以分为两个部分来看,一是网络结构的推断过程, 二是检测层对网络输出的处理

def forward(self, x):
​
    sources = list()
    loc = list()
    conf = list()
​
    # apply vgg up to conv4_3 relu
    for k in range(23):
        x = self.vgg[k](x)
​
    s = self.L2Norm(x)
    sources.append(s)
​
    # apply vgg up to fc7
    for k in range(23, len(self.vgg)):
        x = self.vgg[k](x)
    sources.append(x)
​
    # apply extra layers and cache source layer outputs
    for k, v in enumerate(self.extras):
        x = F.relu(v(x), inplace=True)
        if k % 2 == 1:
            sources.append(x)
​
    # apply multibox head to source layers
    for (x, l, c) in zip(sources, self.loc, self.conf):
        loc.append(l(x).permute(0, 2, 3, 1).contiguous())
        conf.append(c(x).permute(0, 2, 3, 1).contiguous())
​
    loc = torch.cat([o.view(o.size(0), -1) for o in loc], 1)   # 
    conf = torch.cat([o.view(o.size(0), -1) for o in conf], 1) 
    
    --------  以上是网络结构推导过程,以下是网络的输出送给检测层的处理过程-------------
    
    if self.phase == "test":
        output = self.detect(
            loc.view(loc.size(0), -1, 4),                   # loc preds
            self.softmax(conf.view(conf.size(0), -1,
                         self.num_classes)),               # conf preds
            self.priors.type(type(x.data))                  # default boxes
        )
    else:
        output = (
            loc.view(loc.size(0), -1, 4),
            conf.view(conf.size(0), -1, self.num_classes),
            self.priors
        )
    return output

1.3 检测处理部分:

主要思路是这样: 现在拥有8732个预测框的位置信息(和原图的缩放比例值)(8732,4), 和分类信息(21,8732), 当然还有8732个先验框的坐标(8732,4).

每个类别都对应了8732个预测框, 先取一个很小的阈值( self.conf_thresh), 剔除掉此类别对应的框中置信度小于此阈值的框, 然后对剩下的框采用nms(非极大值抑制)算法,整个detection部分的实现可用下面的流程图来表示:

ec893f0f468c564747314746dae4d32f.png

代码:

class Detect(Function):
​
    def __init__(self, num_classes, top_k, conf_thresh, nms_thresh):
        self.num_classes = num_classes
        self.top_k = top_k
        self.conf_thresh = conf_thresh
        self.nms_thresh = nms_thresh
        self.variance = cfg['variance']
​
    def forward(self, loc_data, conf_data, prior_data):
        '''
        Args:
            loc_data: 预测出的loc张量,shape[b,M,4], eg:[b, 8732, 4]
            conf_data: 预测出的置信度,shape[b,M,num_classes], eg:[b, 8732, 21]
            prior_data: 先验框,shape[M,4], eg:[8732, 4]
        ''' 
        batch = loc_data.size(0)    # batch size
        output = torch.zeros(batch, self.num_classes, self.top_k, 5) # 初始化输出
        conf_preds = conf_data.transpose(2,1)
​
        # 解码loc的信息,变为正常的bboxes
        for i in range(batch):
            # 解码loc
            decode_boxes = decode(loc_data[i], prior_data, self.variance)
            # 拷贝每个batch内的conf,用于nms
            conf_scores = conf_preds[i].clone()
​
            # 遍历每一个类别
            for num in range(1, self.num_classes):
                # 筛选掉 conf < conf_thresh 的conf
                c_mask = conf_scores[num].gt(self.conf_thresh)
                scores = conf_scores[num][c_mask]
                # 如果都被筛掉了,则跳入下一类
                if scores.size(0) == 0:
                    continue
                # 筛选掉 conf < conf_thresh 的框
                l_mask = c_mask.unsqueeze(1).expand_as(decode_boxes)
                boxes = decode_boxes[l_mask].view(-1, 4)
​
                # nms
                ids, count = nms(boxes, scores, self.nms_thresh, self.top_k)
                # nms 后得到的输出拼接
                output[i, num, :count] = torch.cat((
                                            scores[ids[:count]].unsqueeze(1),
                                            boxes[ids[:count]]), 1)
​
        return output
​
# 代码测试
if __name__ == "__main__":
    detect = Detect(21, 200, 0.01, 0.5)
    loc_data = torch.randn(1,8732,4)
    conf_data = torch.randn(1,8732,21)
    prior_data = torch.randn(8732, 4)
​
    out = detect(loc_data, conf_data, prior_data)
    print('Detect output shape:', out.shape)

输出:

Detect output shape: torch.Size([1, 21, 200, 5])

1.3.1 预测框解码

上图中所有预测框输出位置信息结合先验框的信息进行解码(对应过程是编码,这个过程在训练过程中实现,详细的编解码过程可参考训练过程的讲解),得到解码后的所有预测框在原图上的位置信息.解码过程如下:

08c415db02724ef8b19b188d53ebb214.png
def decode(loc, priors, variances):
    """Decode locations from predictions using priors to undo
    the encoding we did for offset regression at train time.
    Args:
        loc (tensor): location predictions for loc layers,
            Shape: [num_priors,4]
        priors (tensor): Prior boxes in center-offset form.
            Shape: [num_priors,4].
        variances: (list[float]) Variances of priorboxes
    Return:
        decoded bounding box predictions
    """
​
    boxes = torch.cat((
        priors[:, :2] + loc[:, :2] * variances[0] * priors[:, 2:],
        priors[:, 2:] * torch.exp(loc[:, 2:] * variances[1])), 1)
    boxes[:, :2] -= boxes[:, 2:] / 2
    boxes[:, 2:] += boxes[:, :2]
    return boxes

1.3.2 非极大值抑制算法(NMS)

对每个类别采用nms算法,NMS算法一般是为了去掉模型预测后的多余框,其一般设有一个nms_threshold=0.5,具体的实现思路如下:

  • 选取这类box中scores最大的哪一个,它的index记为 i,并保留它;
  • 计算 boxes[i] 与其余的 boxesIOU 值;
  • 如果其 IOU>0.5 了,那么就舍弃这个box(由于可能这两个box表示同一目标,所以保留分数高的哪一个)
  • 从最后剩余的boxes中,再找出最大scores的哪一个,如此循环往复
def nms(boxes, scores, threshold=0.5, top_k=200):
    '''
    Args:
        boxes: 预测出的box, shape[M,4]
        scores: 预测出的置信度,shape[M]
        threshold: 阈值
        top_k: 要考虑的box的最大个数
    Return:
        keep: nms筛选后的box的新的index数组
        count: 保留下来box的个数
    '''
    keep = scores.new(scores.size(0)).zero_().long()
    x1 = boxes[:, 0]
    y1 = boxes[:, 1]
    x2 = boxes[:, 2]
    y2 = boxes[:, 3]
​
    area = (x2-x1)*(y2-y1)  # 面积,shape[M]
    _, idx = scores.sort(0, descending=True) # 降序排列scores的值大小
    # 取前top_k个进行nms
    idx = idx[:top_k]
​
    count = 0
​
    while idx.numel():
        # 记录最大score值的index
        i = idx[0]
        # 保存到keep中
        keep[count] = i
        # keep 的序号
        count += 1
​
        if idx.size(0) == 1: # 保留框只剩一个
            break
​
        idx = idx[1:] # 移除已经保存的index
​
        # 计算boxes[i]和其他boxes之间的iou
        xx1 = x1[idx].clamp(min=x1[i])
        yy1 = y1[idx].clamp(min=y1[i])
        xx2 = x2[idx].clamp(max=x2[i])
        yy2 = y2[idx].clamp(max=y2[i])
​
        w = (xx2 - xx1).clamp(min=0)
        h = (yy2 - yy1).clamp(min=0)
​
        # 交集的面积
        inter = w * h  # shape[M-1]
        iou = inter / (area[i] + area[idx] - inter)
​
        # iou满足条件的idx
        idx = idx[iou.le(threshold)] # Shape[M-1]
​
    return keep, count

4 .训练过程:

总体思路, 从网络结构图中可以看出,整个网络的训练过程,在训练过程中的前向推断过程不包含检测处理部分,网络的输出就包含三个内容, loc_data([b, 8732, 4]), conf_data(32,8732,21), prio_box(8732,4), 然后根据这三个数据, 结合标签值来计算loss, 这里涉及到两个方面的内容,一是如何构造检测网络需要的标签, 而是如何设计损失函数.

4.1 .构造标签:

一个batch的图片和相应的annotation, 从数据集里经过读取,转换, 最后拿到的一个batch的target,其中每个target表示每张输入图片的目标信息, 一张图片可能包含多个目标的ground_truth_box和label. 现在网络需要用到prio_box,每个prio_box会与之匹配一个gt_box和label,也就是先验框的匹配. 最后的网络需要的标签包括两个方面, 一是每个proi_box和与之对应的gt_box的offset(这里用到了位置信息的编码,下面会讲解),还有就是每个proi_box根据和gt的匹配情况会给它分配一个label.

4.1.1 先验框的匹配

假设一张图片里有3个目标,也就有3个ground_truth, 会生成8732个先验框, SSD的先验框和ground truth匹配原则主要两点: 1. 对于图片中的每个gt,找到与其IOU最大的先验框,该先验框与其匹配,这样可以保证每个gt一定与某个prior匹配。 2. 对于剩余未匹配的priors,若某个gt的IOU大于某个阈值(一般0.5),那么该prior与这个gt匹配。

注意点:

1.通常称与gt匹配的prior为正样本,反之,若某一个prior没有与任何一个gt匹配,则为负样本。

2.某个gt可以和多个prior匹配,而每个prior只能和一个gt进行匹配。

3.如果多个gt和某一个prior的IOU均大于阈值,那么prior只与IOU最大的那个进行匹配

从代码中看匹配是如何被实现的:

  1. 先给每个gt找到IOU最大的那个prio_box, 一共有3个, 输出3个 best_prior_overlap,和best_prior_idx
  2. 然后给每个prio_box找到IOU最大的那个gt, 以及8732个best_truth_overlap和best_truth_idx
  3. 然后将那三个prio_box对应的最大的overlap的值设定为2,这是因为这三个prio_box对应的最大overlap的值可能小于所设定的阈值, 这样可以确保这三个prio_box不被设置成负样本,也就保证了每个gt一定与某个prior匹配
  4. 然后将这三个prixo_box匹配的gt的索引值调整为1中匹配的顺序, 因为那三个prio_box在2中匹配的gt不一定和1中匹配的顺序一样, 这时候应该以1中的匹配为准
  5. 然后提取出所有prio_box最后对应的gt_box的坐标(matches)
  6. 提取所有prio_box对应的gt的类别作为输出的类别
  7. 将所有prio_box与之对应gt的overlap小于阈值的prio_box的定位成负样本,也就是为0
  8. 所有prio_box对应了一个gt, 然后取两者之间的offset所为真实的位置标签
​
​
def match(threshold, truths, priors, variances, labels, loc_t, conf_t, idx):
    '''
    Target:
        把和每个prior box 有最大的IOU的ground truth box进行匹配,
        同时,编码包围框,返回匹配的索引,对应的置信度和位置
    Args:
        threshold: IOU阈值,小于阈值设为bg
        truths: ground truth boxes, shape[N,4]
        priors: 先验框, shape[M,4]
        variances: prior的方差, list(float)
        labels: 图片的所有类别,shape[num_obj]
        loc_t: 用于填充encoded loc 目标张量
        conf_t: 用于填充encoded conf 目标张量
        idx: 现在的batch index        
    '''    
    overlaps = iou(truths, point_form(priors))
​
    # [1,num_objects] 和每个ground truth box 交集最大的 prior box  
    best_prior_overlap, best_prior_idx = overlaps.max(1, keepdim=True)
​
    # [1,num_priors] 和每个prior box 交集最大的 ground truth box
    best_truth_overlap, best_truth_idx = overlaps.max(0, keepdim=True)
​
    # squeeze shape
    best_prior_idx.squeeze_(1)       #(N)
    best_prior_overlap.squeeze_(1)   #(N)
    best_truth_idx.squeeze_(0)       #(M)
    best_truth_overlap.squeeze_(0)   #(M)
​
    # 保证每个ground truth box 与某一个prior box 匹配,固定值为 2 > threshold,这样在
    # 后面conf[best_truth_overlap < threshold] = 0的时候,能将每个gt对应最大的那三个先验框保留下来,即使这3个先验框对应最大的IOU小于阈值.因为每个ground truth必须要有一个先验框.
    best_truth_overlap.index_fill_(0, best_prior_idx, 2)  # ensure best prior
​
    # 保证每一个ground truth 匹配它的都是具有最大IOU的prior
    # 根据 best_prior_dix 锁定 best_truth_idx里面的最大IOU prior
    for j in range(best_prior_idx.size(0)):
        best_truth_idx[best_prior_idx[j]] = j 
​
    # 提取出所有匹配的ground truth box, Shape: [M,4]    
    matches = truths[best_truth_idx]     
​
    # 提取出所有GT框的类别, Shape:[M]     
    conf = labels[best_truth_idx] + 1         
    # 把 iou < threshold 的框类别设置为 bg,即为0
    conf[best_truth_overlap < threshold] = 0
    # 编码包围框
    loc = encode(matches, priors, variances)
​
    # 保存匹配好的loc和conf到loc_t和conf_t中
    loc_t[idx] = loc    # [M,4] encoded offsets to learn
    conf_t[idx] = conf  # [M] top class label for each prior

4.1.2 位置的编码

0703d5fbb8558020f3722b8d17c95b17.png

1c4fe093517be1595508c414099b220c.png

*代码:

​
def encode(matched, priors, variances):
    '''
    将来至于priorbox的差异编码到ground truth box中
    Args:
        matched: 每个prior box 所匹配的ground truth, 
                 Shape[M,4],坐标(xmin,ymin,xmax,ymax)
        priors: 先验框box, shape[M,4],坐标(cx, cy, w, h)
        variances: 方差,list(float)
    '''
    # 编码中心坐标cx, cy
    g_cxcy = (matched[:, :2] + matched[:, 2:])/2 -priors[:, :2]
    g_cxcy /= (priors[:, 2:] * variances[0]) #shape[M,2]
​
    # 防止出现log出现负数,从而使loss为 nan
    eps = 1e-5
​
    # 编码宽高w, h
    g_wh = (matched[:, 2:] - matched[:, :2]) / priors[:, 2:]
    g_wh = torch.log(g_wh + eps) / variances[1]   #shape[M,2]
​
    return torch.cat([g_cxcy, g_wh], 1)    #shape[M,4]

解码过程在前向推断过程中已经阐述了

4.2.设计损失函数:

1a83be5064a7ba92b0768cf1c67da087.png

1. 对于位置损失函数:

针对所有的正样本,采用 Smooth L1 Loss, 位置信息都是 encode 之后的位置信息。

f47e072a92886a355e0927663a89e7b6.png

SooothL1Loss其实是L2Loss和L1Loss的结合,它同时拥有L2 Loss和L1 Loss的部分优点。

1. 当预测值和ground truth差别较小的时候(绝对值差小于1),梯度不至于太大。(损失函数相较L1 Loss比较圆滑) 2. 当差别大的时候,梯度值足够小(较稳定,不容易梯度爆炸)。

2. 对于置信度损失函数:

首先需要使用 hard negative mining 将正负样本按照 1:3 的比例把负样本抽样出来,抽样的方法是:思想: 针对所有batch的confidence,按照置信度误差进行降序排列,取出前top_k个负样本。

e36ce52effccfbf7b329819fe7db4fd3.png

本来就是从负样本中进行抽样,对于负样本,我们应该认为他输出的背景置信度要很高才对,现在一个负样本,预测的背景置信度却很低,那就不对了,就应该要想方设法纠正过来!且这种负样本产生的交叉损失也是最大的!所以就需要专门挑出这种样本来训练!

完整loss代码

作者:JimmyHua
链接:https://zhuanlan.zhihu.com/p/79854543
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
​
import torch
import torch.nn as nn
import torch.nn.functional as F
from vgg_backbone import voc
from box_utils import match, log_sum_exp
​
class MultiBoxLoss(nn.Module):
    def __init__(self, num_classes, overlap_thresh, neg_pos, use_gpu=False):
        super(MultiBoxLoss, self).__init__()
        self.use_gpu =  use_gpu
        self.num_classes = num_classes
        self.threshold = overlap_thresh
        self.negpos_ratio = neg_pos
        self.variance = voc['variance']
​
    def forward(self, pred, targets):
        '''
        Args:
            pred: A tuple, 包含 loc(编码钱的位置信息), conf(类别), priors(先验框);
                  loc_data: shape[b,M,4];
                  conf_data: shape[b,M,num_classes];
                  priors: shape[M,4];
​
            targets: 真实的boxes和labels,shape[b,num_objs,5];
        '''
        loc_data, conf_data, priors = pred
        batch = loc_data.size(0)  #batch
        num_priors = priors[:loc_data.size(1), :].size(0) # 先验框个数
​
        # 获取匹配每个prior box的 ground truth
        # 创建 loc_t 和 conf_t 保存真实box的位置和类别
        loc_t = torch.Tensor(batch, num_priors, 4)
        conf_t = torch.LongTensor(batch, num_priors)
​
        for idx in range(batch):
            truths = targets[idx][:, :-1].detach() # ground truth box信息
            labels = targets[idx][:, -1].detach()  # ground truth conf信息
            defaults = priors.detach()     # priors的 box 信息
​
            # 匹配 ground truth
            match(self.threshold, truths, defaults, 
                  self.variance, labels, loc_t, conf_t, idx)
​
        # use gpu
        if self.use_gpu:
            loc_t = loc_t.cuda()
            conf_t = conf_t.cuda()
​
        pos = conf_t > 0 # 匹配中所有的正样本mask,shape[b,M]
​
        # Localization Loss,使用 Smooth L1
        # shape[b,M]-->shape[b,M,4]
        pos_idx = pos.unsqueeze(2).expand_as(loc_data) 
        loc_p = loc_data[pos_idx].view(-1,4)  # 预测的正样本box信息
        loc_t = loc_t[pos_idx].view(-1,4)     # 真实的正样本box信息
        loss_l = F.smooth_l1_loss(loc_p, loc_t) # Smooth L1 损失
​
        '''
        Target;
            下面进行hard negative mining
        过程:
            1、 针对所有batch的conf,按照置信度误差(预测背景的置信度越小,误差越大)进行降序排列;
            2、 负样本的label全是背景,那么利用log softmax 计算出logP,
               logP越大,则背景概率越低,误差越大;
            3、 选取误差交大的top_k作为负样本,保证正负样本比例接近1:3;
        '''
        # shape[b*M,num_classes]
        batch_conf = conf_data.view(-1, self.num_classes) 
        # 使用logsoftmax,计算置信度,shape[b*M, 1]
        conf_logP = log_sum_exp(batch_conf) - batch_conf.gather(1, conf_t.view(-1, 1)) 
​
        # hard Negative Mining
        conf_logP = conf_logP.view(batch, -1) # shape[b, M]
        conf_logP[pos] = 0 # 把正样本排除,剩下的就全是负样本,可以进行抽样
​
        # 两次sort排序,能够得到每个元素在降序排列中的位置idx_rank
        _, index = conf_logP.sort(1, descending=True)
        _, idx_rank = index.sort(1)
​
        # 抽取负样本
        # 每个batch中正样本的数目,shape[b,1]
        num_pos = pos.long().sum(1, keepdim=True) 
        num_neg = torch.clamp(self.negpos_ratio*num_pos, max= pos.size(1)-1)
        neg = idx_rank < num_neg # 抽取前top_k个负样本,shape[b, M]
​
        # shape[b,M] --> shape[b,M,num_classes]
        pos_idx = pos.unsqueeze(2).expand_as(conf_data)
        neg_idx = neg.unsqueeze(2).expand_as(conf_data)
​
        # 提取出所有筛选好的正负样本(预测的和真实的)
        conf_p = conf_data[(pos_idx+neg_idx).gt(0)].view(-1, self.num_classes)
        conf_target = conf_t[(pos+neg).gt(0)]
​
        # 计算conf交叉熵
        loss_c = F.cross_entropy(conf_p, conf_target)
​
        # 正样本个数
        N = num_pos.detach().sum().float()
​
        loss_l /= N
        loss_c /= N
​
        return loss_l, loss_c        
​
# 调试代码使用       
if __name__ == "__main__":
    loss = MultiBoxLoss(21, 0.5, 3)
    p = (torch.randn(1,100,4), torch.randn(1,100,21), torch.randn(100,4))
    t = torch.randn(1, 10, 4)
    tt = torch.randint(20, (1,10,1))
    t = torch.cat((t,tt.float()), dim=2)    
    l, c = loss(p, t)
    # 随机randn,会导致g_wh出现负数,此时结果会变成 nan
    print('loc loss:', l)
    print('conf loss:', c)

输出:

loc loss: tensor(11.9424) 
conf loss: tensor(2.0487)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值