一、简述
使用的yolov5为2021年6月23号的版本v5.0
此篇作为学习笔记,也花了比较大的功夫,尽可能对每一个要点进行了解释
如有一些问题或错误,欢迎大家一起交流。
简述训练target生成的过程
涉及的部分原理,可以翻到文章最后补充内容,之所以解释不放到最前,是因为涉及了代码中的一些内容,直接可能并不明白,阅读了一遍代码,再看解释的时候可能就明白了.
简述代码流程
在train.py中
1.训练前 实例化 损失类
compute_loss = ComputeLoss(model) # init loss class
2.在训练中,调用__call__函数来返回损失
loss, loss_items = compute_loss(pred, targets.to(device))
二、代码部分
ComputeLoss中 init 部分
class ComputeLoss: # Compute losses def __init__(self, model, autobalance=False): super(ComputeLoss, self).__init__() device = next(model.parameters()).device # get model device h = model.hyp # hyperparameters ''' 对于目标obj损失来说无疑是用BCELoss(二分类损失) YOLOV5中使用的分类损失是BCEloss,对每个类别作sigmoid(而不是softmax), 对于每个类别来说可以是一个2分类任务. 其中nn.BCEWithLogitsLoss自带sigmoid ''' # Define criteria BCEcls = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([h['cls_pw']], device=device)) BCEobj = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([h['obj_pw']], device=device)) ''' 对标签做平滑,eps=0就代表不做标签平滑,那么默认cp=1,cn=0 后续对正类别赋值cp,负类别赋值cn ''' # class label smoothing self.cp, self.cn = smooth_BCE(eps=h.get('label_smoothing', 0.0)) # positive, negative BCE targets ''' 如果g的值为0,则代表不使用focal loss ''' # Focal loss g = h['fl_gamma'] # focal loss gamma if g > 0: BCEcls, BCEobj = FocalLoss(BCEcls, g), FocalLoss(BCEobj, g) ''' balance用来设置三个特征图对应输出的损失系数 从左到右对应大特征图(检测小物体)到小特征图(检测大物体),也就是说小物体的损失权重更大. 当然模型不一定要三个特征图,其他情况比如四个五个特征图,同样可以设置相应的损失权重 ''' det = model.module.model[-1] if is_parallel(model) else model.model[-1] # Detect() module self.balance = {3: [4.0, 1.0, 0.4]}.get(det.nl, [4.0, 1.0, 0.25, 0.06, .02]) # P3-P7 self.ssi = list(det.stride).index(16) if autobalance else 0 # stride 16 index self.BCEcls, self.BCEobj, self.gr, self.hyp, self.autobalance = BCEcls, BCEobj, model.gr, h, autobalance for k in 'na', 'nc', 'nl', 'anchors': setattr(self, k, getattr(det, k))
ComputeLoss中build_targets
def build_targets(self, p, targets): # Build targets for compute_loss(), input targets(image,class,x,y,w,h) ''' p: List[torch.tensor * 3], p[i].shape = (b, 3, h, w, nc+5) targets: targets.shape(nt, 6) , 6=icxywh , i表示第i张图片,c为类别,xywh为坐标 ''' na, nt = self.na, targets.shape[0] # number of anchors, targets tcls, tbox, indices, anch = [], [], [], [] ''' gain是为了对后续的target(na,nt,7)中的归一化的xywh转为特征图中的网格坐标 其中7表示: i c x y w h ai ''' gain = torch.ones(7, device=targets.device) # normalized to gridspace gain gain:(7) ''' 需要na个尺度,都进行训练,那么标签就需要复制na个, ai就代表每个尺度的索引,默认里面的值为0,1,2 ''' ai = torch.arange(na, device=targets.device).float().view(na, 1).repeat(1, nt) # same as .repeat_interleave(nt) ai:(na,nt) ''' targets.repeat(na, 1, 1): (na,nt,6) ai[:, :, None]: (na,nt,1) --广播--> (na,nt,1) targets: (na,nt,7) 7: i c x y w h ai ''' targets = torch.cat((targets.repeat(na, 1, 1), ai[:, :, None]), 2) # append anchor indices g = 0.5 # bias off = torch.tensor([[0, 0], [1, 0], [0, 1], [-1, 0], [0, -1], # j,k,l,m # [1, 1], [1, -1], [-1, 1], [-1, -1], # jk,jm,lk,lm ], device=targets.device).float() * g # offsets for i in range(self.nl): ''' self.anchors[i] :(3,3,2) 就是hyp.yaml里面看到的anchors的形状, 3个尺度,3个形状的anchor, 2表示anchor的宽高 anchors: (3,2) 就是当前尺度对应的3个anchor 原来targets中xywh的值都是0~1之间,也就是原图中,归一化的坐标. 对 i c x y w h ai 相对应位置乘上 [1,1,特征图宽,特征图高,特征图宽,特征图高,1] 得到的xywh就是特征图中的位置了,至于为什么是这种线性关系, 为什么这样乘能得到特征图中的位置,最后会给出解释. ''' anchors = self.anchors[i] gain[2:6] = torch.tensor(p[i].shape)[[3, 2, 3, 2]] # xyxy gain # p[i].shape: (b,na,h,w,5+nc) # Match targets to anchors t = targets * gain # 原始归一化坐标转化为当前特征图中的网格坐标 if nt: # Matches ''' t:(na,nt,7) t[:, :, 4:6]: (na,nt,2) anchors: (na,2) anchors[:, None]: (na,1,2) r: (na,nt,2) ''' r = t[:, :, 4:6] / anchors[:, None] # wh ratio ''' torch.max(r, 1. / r)选出宽比,高比最大的 max(2) 将宽比和高比 最大的那个选出来 取索引[0] 是因为torch.max返回两个值,一个是value,第二个是index. 我们只要max的值就行了,不需要它的索引 得到j: (na,nt) ''' # GT与anchor的宽或高的比超过一定阈值,就当作为负样本 j = torch.max(r, 1. / r).max(2)[0] < self.hyp['anchor_t'] # compare 获取宽高比小于4的网格位置 # j = wh_iou(anchors, t[:, 4:6]) > model.hyp['iou_t'] # iou(3,n)=wh_iou(anchors(3,2), gwh(n,2)) ''' 输入 t:(na,nt,7) j:(na,nt) 得到 t:(M,7) M 代表筛选过后的目标数 ''' t = t[j] # filter 初步分离正负样本(宽高比大于4的anchor均视为负样本) ''' 除了target所在的当前格子外,还需要有2个格子对目标进行预测(计算损失), 也就是说一个目标,用3个格子去预测(计算损失). 首先,必定包含目标所在的当前格子,其次是上下左右, 四个中选择两个,作为另外两个格子,对target进行回归. 只有对不是边上的格子才进行用3个格子去回归目标, 因为他们可能缺少上下左右4个格子中其中一个(因为他们在边上了), 所以后续会限制gxy或gxi>1的操作 j:(M) 值类型为Bool 如果一个target相对应的值为True,表示该目标中心点所在的格子 的左边的格子也对该target进行h回归(后续要计算损失) k:(M) 值类型为Bool 表示目标所在格子的 上面的格子 l:(M) 值类型为Bool 表示目标所在格子的 右边的格子 m:(M) 值类型为Bool 表示目标所在格子的 下面的格子 ''' # Offsets gxy = t[:, 2:4] # grid xy 得到中心点坐标xy(相对于左上角) gxy:(M,2) gxi = gain[[2, 3]] - gxy # inverse 得到中心点相对于右下角的坐标 gxi(M,2) j, k = ((gxy % 1. < g) & (gxy > 1.)).T # 筛选中心坐标 左、上方偏移量小于0.5,并中心点大于1的target l, m = ((gxi % 1. < g) & (gxi > 1.)).T # 筛选中心坐标 右、下方偏移量小于0.5,并且中心点大于1的target j = torch.stack((torch.ones_like(j), j, k, l, m)) # j:(5,M) ''' 输入: t:(M,7)----经过repeat--->(5,M,7) j:(5,M) 输出: t:(N,7) 由于1个格子变成了3个格子去回归目标,所以N<=3M(当target所属格子都不在边上的 时候取到等号), (通过j索引去获得是哪些格子需要做回归) ''' t = t.repeat((5, 1, 1))[j] ''' s j:(5,M) torch.zeros_like(gxy)[None]:(1,M,2) off[:, None]:(5,1,2) (1,M,2)+(5,1,2)=(5,M,2)----[j]--->(N,2) offsets:(N,2) ''' offsets = (torch.zeros_like(gxy)[None] + off[:, None])[j] else: t = targets[0] offsets = 0 # Define ''' b:(N) c:(N) gxy:(N,2) gwh:(N,2) gij:(N,2) gi:(N) gj:(N) ''' b, c = t[:, :2].long().T # image, class gxy = t[:, 2:4] # grid xy gwh = t[:, 4:6] # grid wh gij = (gxy - offsets).long() # 获得每个标签所在的格子的索引 ''' 比如有个目标的中心点x坐标为3.4,那么 3.4-0.5并向下取整,就为2了,那么就采用左边的格子了 ''' gi, gj = gij.T # grid xy indices # Append ''' a:(N) 尺度的索引 b:(N) 第几张图片的索引 indices:List(tuple*3) tuple:(b,a,竖直方向第y个格子,竖直方向第x个格子) tbox:List(tensor*3) tensor:(N,4) 4代表grid_offx,grid_offy,gw,gh anch:List(tensor*3) tensor:(N) 为anchor的索引,如0,1,2,0,0,1... 因为不同尺度用不同大小的anchor tcls:List(tensor*3) tensor:(N) 为类别 值为0~nc-1 ''' a = t[:, 6].long() # anchor indices a:(N) 看属于第几个尺度,值为0,1,2 indices.append((b, a, gj.clamp_(0, gain[3] - 1), gi.clamp_(0, gain[2] - 1))) # image, anchor, grid indices tbox.append(torch.cat((gxy - gij, gwh), 1)) # box anch.append(anchors[a]) # anchors tcls.append(c) # class return tcls, tbox, indices, anch
ComputeLoss中 call 部分
def __call__(self, p, targets): # predictions, targets, model """ p: 网络输出,List[torch.tensor * 3], p[i].shape = (b, 3, h, w, nc+5), hw分别为特征图的长宽 ,b为batch-size targets: targets.shape = (nt, 6) , 6=icxywh,i表示第一张图片,c为类别,然后坐标xywh为(在原图中)归一 化后的GT框 model: 模型 """ device = targets.device ''' lcls,lbox,lobj分别记录分类损失,box损失,obj损失 tcls:List(tensor*3) tensor:(N) 为类别 值为0~nc-1 tbox:List(tensor*3) tensor:(N,4) 4代表grid_offx,grid_offy,gw,gh indices:List(tuple*3) tuple:(b,a,竖直方向第y个格子,竖直方向第x个格子) 为特征图中格子的索引 anch:List(tensor*3) tensor:(N) 为anchor的索引,如0,1,2,0,0,1... 因为不同尺度用不同大小的anchor ''' lcls, lbox, lobj = torch.zeros(1, device=device), torch.zeros(1, device=device), torch.zeros(1, device=device) tcls, tbox, indices, anchors = self.build_targets(p, targets) # targets #创建target # Losses for i, pi in enumerate(p): # layer index, layer predictions ''' b:(N) a:(N) gi:(N) gj:(N) 这是之前target中有需要做回归的格子的索引,后续可以利用索引将网络中相应部分索引出来, 从而使得objloss针对所有格子,而boxloss和clsloss只针对有目标的格子. (也就是说无对应obj的格子不进行分类和回归损失计算) ''' b, a, gj, gi = indices[i] # image, anchor, gridy, gridx ''' 先设置全为0,后续根据,b,a,gi,gj这些索引,将tobj中填充正样本,其他均为负样本 pi:(b, 3, h, w, nc+5) 最后一个维度 是按 x y h w obj + nc 这个顺序来的,所以取索引0就是取x ,但是我们只是要它的形状而已 这里取索引4也是同样的结果 tobj:(b, 3, h, w) ''' tobj = torch.zeros_like(pi[..., 0], device=device) # target obj # n的值为N n = b.shape[0] # number of targets if n: ''' pi:(b, 3, h, w, nc+5) pi[b, a, gj, gi] 根据(batch,anchor,y,x) 找到GT(ground truth)的anchors所对应的anchor 通过索引得到ps:(N,nc+5) ''' ps = pi[b, a, gj, gi] # prediction subset corresponding to targets # Regression ''' ps:(N,nc+5) pxy:(N,2) pwh:(N,2) xy的预测范围为-0.5~1.5,因为除了自身格子外,还有可能还有相邻的两个格子 wh的预测范围是0~4 ,是由于shape的过滤规则,wh预测输出也不再是任意范围 ''' pxy = ps[:, :2].sigmoid() * 2. - 0.5 pwh = (ps[:, 2:4].sigmoid() * 2) ** 2 * anchors[i] pbox = torch.cat((pxy, pwh), 1) # predicted box ''' 只有当CIOU=True时,才计算CIOU,否则默认为GIOU ''' iou = bbox_iou(pbox.T, tbox[i], x1y1x2y2=False, CIoU=True) # iou(prediction, target) lbox += (1.0 - iou).mean() # iou loss 获得iou loss ''' tobj:(b,na,h,w) 将target_obj正样本给加入,原本初始化均为0,为负样本 默认model.gr=1,也就是说使用iou(giou,ciou等)作为obj的置信度,否则使用常数,更加hard的标签 , 或者以某种比例. 由于CIOU还是GIOU等 ,都是IOU-... ,结果都是小于等于1,还可能为负,所以在0处截断一下 ''' # Objectness tobj[b, a, gj, gi] = (1.0 - self.gr) + self.gr * iou.detach().clamp(0).type(tobj.dtype) # iou ratio # Classification if self.nc > 1: # cls loss (only if multiple classes) ''' ps:(N,5+nc) ps[:, 5:]:(N,80) t:(N,80) tcls[i]:(N) ''' t = torch.full_like(ps[:, 5:], self.cn, device=device) # targets t[range(n), tcls[i]] = self.cp # 对正样本填充相应的类别 lcls += self.BCEcls(ps[:, 5:], t) # BCE #计算分类损失 # Append targets to text file # with open('targets.txt', 'a') as file: # [file.write('%11.5g ' * 4 % tuple(x) + '\n') for x in torch.cat((txy[i], twh[i]), 1)] ''' pi:(b,na,h,w,5+nc) pi[..., 4]:(b,na,h,w) tobj:(b,na,h,w) self.balance[i] 对应特征图尺度的损失系数 ''' obji = self.BCEobj(pi[..., 4], tobj) lobj += obji * self.balance[i] # obj loss if self.autobalance: self.balance[i] = self.balance[i] * 0.9999 + 0.0001 / obji.detach().item() if self.autobalance: self.balance = [x / self.balance[self.ssi] for x in self.balance] ''' 对损失再进行一些调整 ''' lbox *= self.hyp['box'] lobj *= self.hyp['obj'] lcls *= self.hyp['cls'] bs = tobj.shape[0] # batch size loss = lbox + lobj + lcls return loss * bs, torch.cat((lbox, lobj, lcls, loss)).detach()
三、补充内容:
1.为什么原图上归一化的框的坐标*特征图的大小就是在特征图上的坐标呢?
比如box在原图上归一化的坐标为(0.2,0.3,0.4,0,3),对应的是xywh
特征图的大小为80∗80,那么转化为特征图上的坐标为(16,24,32,24),是否想过为啥是线性关系呢?
从下面这行中,似乎得出了疑惑.
t = targets*gain
这种情况是在整个模型的卷积核大小等于步长满足的情况下才有的.
那么整个模型的卷积核大小怎么计算呢?
- 如果输入N∗N大小的图片得到结果是1∗1,那么卷积核大小是N∗N
那么整个模型的步长怎么计算的呢?
- 看下采样次数,比如yolov5,下采样5次,每次2倍,那么下采样倍率是32,于是模型的步长为32
当有了步长=卷积核大小,就可以用这种线性关系了.
下面来证明一下:
情况1.当卷积核大小不等于步长 时
下图是左边原图经过一次卷积,变成右侧特征图,可以看到红色的区域,对应的就是蓝色的区域
下面例子中卷积核大小K∗K,其中K=2
步长为stride,这边为stride=1
很显然,蓝色框的左上角,对应红色框的左上角,蓝色框的右下角,对应红色框的右下角.
因为卷积的滑动计算的缘故.
可以发现红色框的左上角坐标为(i×stride,j×stride)
红色框的右下角坐标为(i×stride+k,j×stride+k)
其中i j是特征图中格子的索引
情况2.当卷积核大小等于步长时
kstride=k
红色框的左上角坐标为(i×stride,j×stride)不变
红色框的右下角坐标变为((i+1)×stride,(j+1)×stride)
可以发现坐标可以变得更加通用一点,就变成下面这个样子了
也正是YOLOV5中下图的样子了,对于下采样32倍的特征图来说,每一个格子对应着原图上(h/32,w/32)的大小,其中h,w是原图的高和宽
2.YOLOV5中(非边界格子)每个target训练的时候,除了自身的格子,还需要相邻的两个格子作回归
下图是特征图上的一个格子,物体中心点的位置位于格子的左上方
此时(假设该物体是第i个)那么
即用上边、左边、和自身所在三个格子回归target
深蓝色的就是另外两个格子来回归红点的.
蓝灰色的框代表左侧格子的预测范围,可以看到红点是包括在内的
粉色框代表着上侧格子的预测范围
黄色代表着下侧格子的预测范围
如果以格子的左上角作为原点,那么预测范围就是(-0.5,1.5)之间