以前,对SSD的论文进行了解读,能够回顾以前的博客:http://www.javashuo.com/article/p-rrxantwu-cm.html。html
为了加深对SSD的理解,所以对SSD的源码进行了复现,主要参考的github项目是ssd.pytorch。同时,我本身对该项目增长了大量注释:https://github.com/Dengshunge/mySSD_pytorchgit
搭建SSD的项目,能够分红如下三个部分:github
接下来,本篇博客重点分析损失函数的构建。网络
检测任务的损失函数,与分类任务的损失函数具备很大不一样。在检测的损失函数中,不只须要计类别置信度的差别,坐标的差别,还须要使用到各类tricks,例如hard negative mining等。函数
在train.py中,首先须要对损失函数MultiBoxLoss()进行初始化,须要传入的参数为num_classes类别数,正例的IOU阈值和hard negative mining的正负样本比例。在论文中,VOC的类别总数是21(20个类别加上1个背景);当预测框与GT框的IOU大于0.5时,认为该预测框是正例;hard negative mining的正样本和负样本的比例是1:3。学习
# 损失函数 criterion = MultiBoxLoss(num_classes=voc['num_classes'], overlap_thresh=0.5, neg_pos=3)
在models/multibox_loss中,定义了损失函数MultiBoxLoss()。在函数forward()中,须要传进来两个参数,分别是predictions和targets,其中,predictions是SSD网络获得的结果,分别是预测框坐标,类别置信度和先验锚点框;而targets是则是数据读取中的值,是GT框的坐标和类别label。首先,须要建立坐标loc_t和类别置信度conf_t的tensor,其shape分别是[batch_size,8732,4]和[batch_size,8732]。而后,使用一个for循环,将GT框与先验锚点框的坐标与label进行match,获得每一个锚点框的label和坐标误差,并将结果保存与loc_t和conf_t中。因为制定了某些锚点框用于预测目标,所以,接下来,须要使用这部分锚点框信息来计算损失。取出含目标的锚点框,获得其index,其中,pos的shape为[batch_size,8732],每一个元素是true或者false。再从网络预测的8732个预测框中,取出一样index的预测框的坐标误差loc_p,而loc_t则是一样index的先验锚点框的坐标误差。因为锚点框对应上了,则使用smooth_l1来计算预测框回归的算是loss_l,以下图所示的$L_{loc}$,图片来源。测试
接下来,则是使用hard negative mining和计算置信度损失。首先为模型预测出来的置信度conf_data进行维度变换,由[batch_size,8732,21]变成[batch_size*8732,21]的batch_conf,应该是为了方便下面进行计算。接下来,计算全部预测框的置信度损失loss_c,将含目标的锚点框(正例)的损失置0,并对损失进行排名,从而选出损失最大的前num_neg个损失的index。将正例的pos_index和损失最大的负例neg_idx提取出来成conf_p,用于参与训练中,与相同index的先验锚点框进行计算交叉熵损失计算。最后将置信度损失和位置损失返回。ui
class MultiBoxLoss(nn.Module): ''' SSD损失函数的计算 ''' def __init__(self, num_classes, overlap_thresh, neg_pos): super(MultiBoxLoss, self).__init__() self.num_classes = num_classes # 类别数 self.threshold = overlap_thresh # GT框与先验锚点框的阈值 self.negpos_ratio = neg_pos # 负例的比例 def forward(self, predictions, targets): ''' 对损失函数进行计算: 1.进行GT框与先验锚点框的匹配,获得loc_t和conf_t,分别表示锚点框须要匹配的坐标和锚点框须要匹配的label 2.对包含目标的先验锚点框loc_t(即正例)与预测的loc_data计算位置损失函数 3.对负例(即背景)进行损失计算,选择损失最大的num_neg个负例和正例共同组成训练样本,取出这些训练样本的锚点框targets_weighted 与置信度预测值conf_p,计算置信度损失: a)为Hard Negative Mining计算最大置信度loss_c b)将loss_c中正例对应的值置0,即保留了全部负例 c)对此loss_c进行排序,获得损失最大的idx_rank d)计算用于训练的负例的个数num_neg,约为正例的3倍 e)选择idx_rank中前num_neg个用做训练 f)将正例的index和负例的index共同组成用于计算损失的index,并从预测置信度conf_data和真实置信度conf_t提出这些样本,造成 conf_p和targets_weighted,计算二者的置信度损失. :param predictions: 一个元祖,包含位置预测,置信度预测,先验锚点框 位置预测:(batch_size,num_priors,4),即[batch_size,8732,4] 置信度预测:(batch_size,num_priors,num_classes),即[batch_size, 8732, 21] 先验锚点框:(num_priors,4),即[8732, 4] :param targets: 真实框的坐标与label,[batch_size,num_objs,5] 其中,5表明[xmin,ymin,xmia,ymax,label] ''' loc_data, conf_data, priors = predictions num = loc_data.shape[0] # 即batch_size大小 priors = priors[:loc_data.shape[1], :] # 取出8732个锚点框,与位置预测的锚点框数量相同 num_priors = priors.shape[0] # 8732 loc_t = torch.Tensor(num, num_priors, 4) # [batch_size,8732,4],生成随机tensor,后续用于填充 conf_t = torch.Tensor(num, num_priors) # [batch_size,8732] # 取消梯度更新,貌似默认是False loc_t.requires_grad = False conf_t.requires_grad = False for idx in range(num): truths = targets[idx][:, :-1] # 坐标值,[xmin,ymin,xmia,ymax] labels = targets[idx][:, -1] # label defaults = priors.cuda() match(self.threshold, truths, defaults, labels, loc_t, conf_t, idx) if torch.cuda.is_available(): loc_t = loc_t.cuda() conf_t = conf_t.cuda() # shape:[batch_size,8732],其元素组成是类别标签号和背景 pos = conf_t > 0 # 排除label=0,即排除背景,shape[batch_size,8732],其元素组成是true或者false # Localization Loss (Smooth L1),定位损失函数 # Shape: [batch,num_priors,4] # pos.dim()表示pos有多少维,应该是一个定值(2) # pos由[batch_size,8732]变成[batch_size,8732,1],而后展开成[batch_size,8732,4] pos_idx = pos.unsqueeze(pos.dim()).expand_as(loc_data) loc_p = loc_data[pos_idx].view(-1, 4) # [num_pos,4],取出带目标的这些框 loc_t = loc_t[pos_idx].view(-1, 4) # [num_pos,4] # 位置损失函数 loss_l = F.smooth_l1_loss(loc_p, loc_t, reduction='sum') # 这里对损失值是相加,有公式可知,还没到相除的地步 # 为Hard Negative Mining计算max conf across batch batch_conf = conf_data.view(-1, self.num_classes) # shape[batch_size*8732,21] # gather函数的做用是沿着定轴dim(1),按照Index(conf_t.view(-1, 1))取出元素 # batch_conf.gather(1, conf_t.view(-1, 1))的shape[8732,1],做用是获得每一个锚点框在匹配GT框后的label loss_c = log_sum_exp(batch_conf) - batch_conf.gather(1, conf_t.view(-1, 1).long()) # 这个不是最终的置信度损失函数 # Hard Negative Mining # 因为正例与负例的数据不均衡,所以不是全部负例都用于训练 loss_c[pos.view(-1, 1)] = 0 # pos与loss_c维度不同,因此须要转换一下,选出负例 loss_c = loss_c.view(num, -1) # [batch_size,8732] _, loss_idx = loss_c.sort(1, descending=True) # 获得降序排列的index _, idx_rank = loss_idx.sort(1) num_pos = pos.sum(1, keepdim=True) # pos里面是true或者false,所以sum后的结果应该是包含的目标数量 num_neg = torch.clamp(self.negpos_ratio * num_pos, max=pos.size(1) - 1) # 生成一个随机数用于表示负例的数量,正例和负例的比例约3:1 neg = idx_rank < num_neg.expand_as(idx_rank) # [batch_size,8732] 选择num_neg个负例,其元素组成是true或者false # 置信度损失,包括正例和负例 # [batch_size, 8732, 21],元素组成是true或者false,但true表明着存在目标,其对应的index为label pos_idx = pos.unsqueeze(2).expand_as(conf_data) neg_idx = neg.unsqueeze(2).expand_as(conf_data) # pos_idx由true和false组成,表示选择出来的正例,neg_idx同理 # (pos_idx + neg_idx)表示选择出来用于训练的样例,包含正例和反例 # torch.gt(other)函数的做用是逐个元素与other进行大小比较,大于则为true,不然为false # 所以conf_data[(pos_idx + neg_idx).gt(0)]获得了全部用于训练的样例 conf_p = conf_data[(pos_idx + neg_idx).gt(0)].view(-1, self.num_classes) targets_weighted = conf_t[(pos + neg).gt(0)] loss_c = F.cross_entropy(conf_p, targets_weighted.long(), reduction='sum') # L(x,c,l,g) = (Lconf(x, c) + αLloc(x,l,g)) / N N = num_pos.sum() # 一个batch里面全部正例的数量 loss_l /= N loss_c /= N return loss_l, loss_c
在hard negative mining中,须要先计算loss_c。从代码能够看到 loss_c = log_sum_exp(batch_conf) - batch_conf.gather(1, conf_t.view(-1, 1).long()) ,这句代码就是置信度损失的计算,能够参考公式进行理解。这里能够说起一下,对loss_c的两次排序,参考这篇博客,首先对值进行降序排序,获得排名1,而后对排名又进行降序排序,获得排名2,以下图所示,即能取出idx_rank的前N个,可得到损失最大那些值,即变量neg的做用。编码
在计算损失函数时,说起了函数match(),这个函数位于models/box_utils.py中,是一个很是关键的函数,对应论文的匹配策略那一章节,其做用是为每一个锚点框指定GT框和为每一个GT框指定锚点框。须要传进来几个参数,truths是GT框的坐标,priors是先验锚点框的坐标[中心点x,中心点y,W,H],labels是GT框对应的类别(不包含背景),loc_t和conf_t是用来保存结果的,idx是第i张图片。spa
为了方便表述,num_objects表示一张图中,GT框的数量;num_priors表示先验锚点框的数量,即8732。
第一步,因为先验锚点框priors的坐标形式是[中心点x,中心点y,W,H],须要使用函数point_from()来将其转化成[x_min,y_min,x_max,y_max]。而后计算每一个GT框与全部先验锚点框的jaccard值,即IOU的值,使用了numpy风格的计算方式,返回的变量overlaps的shape为[GT框数量,8732]。
第二步,根据论文,为每一个GT框匹配一个最大IOU的先验锚点框,确保每一个GT框至少有一个锚点框进行预测。
第三步,为每一个锚点框匹配上一个最大IOU的GT框来进行预测。
第四步,变量best_truth_overlap保存着每一个框与GT框的最大IOU值(第三步的结果),使用index_fill()函数,将第二步的结果同步到这个变量中。在index_fill()函数中,使用数值2来进行填充,是为了确保第二步中获得的锚点框确定会被选到。对变量best_truth_idx也进行一样的处理。
第五步,因为传入进来的labels的类别是从0开始的,SSD中认为0应该是背景,因此,须要对labels进行加一。这里须要注意一下,best_truth_idx的shape是[8732],每一个元素的范围为[0,num_objects],因此conf的shape为[num_priors],每一个元素表示先验锚点框的label(0是背景)。同时,须要将变量best_truth_overlap中IOU小于阈值(0.5)的锚点框的label设置为0。并将结果保存与conf_t,返回给外面的函数用于计算。
第六步,一样须要将GT框的坐标进行扩展,造成shape为[num_priors,4]的matches,这样每一个锚点框都有对应的坐标进行预测,但最终并非每一个锚点框都用于训练中。
第七步,使用GT框与锚点框进行编码,对应论文中的公式2,获得shape为[num_priors,4]的值,即误差,将此结果返回出去。
注意,这里使用的是GT框的信息和先验锚点框的信息,并无涉及到网络预测出来的结果。获得每一个锚点框的类别conf_t和坐标loc_t。因为没有用到网络预测的结果,能够认为这部分一直都是定值。
def match(threshold, truths, priors, labels, loc_t, conf_t, idx): ''' 这个函数对应论文中的matching strategy匹配策略.SSD须要为每个先验锚点框都指定一个label, 这个label或者指向背景,或者指向每一个类别. 论文中的匹配策略是: 1.首先,每一个GT框选择与其IOU最大的一个锚点框,并令这个锚点框的label等于这个GT框的label 2.而后,当锚点框与GT框的IOU大于阈值(0.5)时,一样令这个锚点框的label等于这个GT框的label 所以,代码上的逻辑为: 1.计算每一个GT框与每一个锚点框的IOU,获得一个shape为[num_object,num_priors]的矩阵overlaps 2.选择与GT框的IOU最大的锚点框,锚点框的index为best_prior_idx,对应的IOU值为best_prior_overlap 3.为每个锚点框选择一个IOU最大的GT框,可能会出现多个锚点框匹配一个GT框的状况,此时,每一个锚点框对应GT框的index为best_truth_idx, 对应的IOU为best_truth_overlap.注意,此时IOU值可能会存在小于阈值的状况. 4.第3步可能到致使存在GT框没有与锚点框匹配上的状况,因此要和第2步进行结合.在第3步的基础上,对best_truth_overlap进行选择,选择出 best_prior_idx这些锚点框,让其对其的IOU等于一个大于1的定值;而且让best_truth_idx中index为best_prior_idx的锚点框的label 与GT框对应上.最终,best_truth_overlap表示每一个锚点框与GT框的最大IOU值,而best_truth_idx表示每一个锚点框用于与相应的GT框进行 匹配. 5.第4步中,会存在IOU小于阈值的状况,要将这些小于IOU阈值的锚点框的label指向背景,完成第二条匹配策略. labels表示GT框对应的标签号,"conf=labels[best_truth_idx]+1"获得每一个锚点框对应的标签号,其中label=0是背景. "conf[best_truth_overlap < threshold] = 0"则将小于IOU阈值的锚点框的label指向背景 6.获得的conf表示每一个锚点框对应的label,还须要一个矩阵,来表示每一个锚点框须要匹配GT框的坐标. truths表示GT框的坐标,"matches = truths[best_truth_idx]"获得每一个锚点框须要匹配GT框的坐标. :param threshold:IOU的阈值 :param truths:GT框的坐标,shape:[num_obj,4] :param priors:先验锚点框的坐标,shape:[num_priors,4],num_priors=8732 :param labels:这些GT框对应的label,shape:[num_obj],此时label=0还不是背景 :param loc_t:坐标结果会保存在这个tensor :param conf_t:置信度结果会保存在这个tensor :param idx:结果保存的idx ''' # 第1步,计算IOU overlaps = jaccard(truths, point_from(priors)) # shape:[num_object,num_priors] # 第2步,为每一个真实框匹配一个IOU最大的锚点框,GT框->锚点框 # best_prior_overlap为每一个真实框的最大IOU值,shape[num_objects,1] # best_prior_idx为对应的最大IOU的先验锚点框的Index,其元素值的范围为[0,num_priors] best_prior_overlap, best_prior_idx = overlaps.max(1, keepdim=True) # 第3步,若先验锚点框与GT框的IOU>阈值,也将这些锚点框匹配上,锚点框->GT框 # best_truth_overlap为每一个先验锚点框对应其中一个真实框的最大IOU,shape[1,num_priors] # best_truth_idx为每一个先验锚点框对应的真实框的index,其元素值的范围为[0,num_objects] best_truth_overlap, best_truth_idx = overlaps.max(0, keepdim=True) best_prior_idx.squeeze_(1) # [num_objects] best_prior_overlap.squeeze_(1) # [num_objects] best_truth_idx.squeeze_(0) # [num_priors],8732 best_truth_overlap.squeeze_(0) # [num_priors],8732 # 第4步 # index_fill_(self, dim: _int, index: Tensor, value: Number)对第dim行的index使用value进行填充 # best_truth_overlap为第一步匹配的结果,须要使用到,使用best_prior_idx是第二步的结果,也是须要使用上的 # 因此在best_truth_overlap上进行填充,代表选出来的正例 # 使用2进行填充,是由于,IOU值的范围是[0,1],只要使用大于1的值填充,就代表确定能被选出来 best_truth_overlap.index_fill_(0, best_prior_idx, 2) # 肯定最佳先验锚点框 # 确保每一个GT框都能匹配上最大IOU的先验锚点框 # 获得每一个先验锚点框都能有一个匹配上的数字 # best_prior_idx的元素值的范围是[0,num_priors],长度为num_objects for j in range(best_prior_idx.size(0)): best_truth_idx[best_prior_idx[j]] = j # 第5步 conf = labels[best_truth_idx] + 1 # Shape: [num_priors],0为背景,因此其他编号+1 conf[best_truth_overlap < threshold] = 0 # 置信度小于阈值的label设置为0 # 第6步 matches = truths[best_truth_idx] # 取出最佳匹配的GT框,Shape: [num_priors,4] # 进行位置编码 loc = encode(matches, priors,voc['variance']) loc_t[idx] = loc # [num_priors,4],应该学习的编码误差 conf_t[idx] = conf # [num_priors],每一个锚点框的label
在函数match()中,使用到了函数encode()来对位置进行编码。参考博客和R-CNN中的公式,假设先验锚点框的坐标为$(d^{cx},d^{cy},d^w,d^h)$,预测框的坐标为$(b^{cx},b^{cy},b^w,b^h)$,则预测框的转换值l为:
$$l^{cx}=(b^{cx}-d^{cx})/d^w, l^{cy}=(b^{cy}-d^{cy})/d^h$$
$$b^w=d^wexp(l^x), b^h=d^hexp(l^h)$$
而代码中,咱们利用了方差的信息,所以进行了相应的调整,总体上是一致的。
def encode(matched, priors, variances): ''' 对坐标进行编码,对应论文中的公式2 利用GT框和先验锚点框,计算误差,用于回归 :param matched: 每一个先验锚点框对应最佳的GT框,Shape: [num_priors, 4], 其中4表明[xmin,ymin,xmax,ymax] :param priors: 先验锚点框,Shape: [num_priors,4], 其中4表明[中心点x,中心点y,宽,高] :return: shape:[num_priors, 4] ''' g_cxcy = (matched[:, :2] + matched[:, 2:]) / 2 - priors[:, :2] # 计算GT框与锚点框中心点的距离 g_cxcy /= (variances[0] * priors[:, 2:]) g_wh = (matched[:, 2:] - matched[:, :2]) # xmax-xmin,ymax-ymin g_wh /= priors[:, 2:] g_wh = torch.log(g_wh) / variances[1] return torch.cat([g_cxcy, g_wh], 1)
至此,SSD的损失函数构建以介绍完成。相比于分类任务,目标检测的损失函数构建须要更多的代码,包含了各类tricks。