本篇文章并没有涉及YOLOv3的训练内容,实际是根据原作者使用 PyTorch 实现基于YOLOv3的目标检测器的源代码,来进一步理解YOLOv3网络模型的输入,结构及输出。
代码地址
YOLOv3的pytorch实现:https://github.com/ayooshkathuria/YOLO_v3_tutorial_from_scratch
模型的输入
YOLOv3采用darknet-53模型,其中并没有传统的池化层,而是采用下采样来进行深层特征的提取,每下采样一次,其宽高则为原来的一半。由于下采样次数为5次,设初始图像大小为[w,h],最后下采样5次的feature map为[w/32,h/32]。所以原始图片的宽、高应为32的倍数,例如320x320,416x416,480x480等。在源码中作者取得图片大小为416x416,所以对于所有输入图片都会进行预处理。

图片的预处理(源码)
在源码中对应与util.py的prep_image和letterbox_image方法,处理图片大小的主要是letterbox_image方法。
def letterbox_image(img, inp_dim):
"""
将图片按照纵横比进行缩放,将空白部分用(128,128,128)填充,调整图像尺寸
将缩放后的数据拷贝到画布中心,返回完成缩放
"""
img_w, img_h = img.shape[1], img.shape[0]
w, h = inp_dim#inp_diw=416
new_w = int(img_w * min(w / img_w, h / img_h))
new_h = int(img_h * min(w / img_w, h / img_h))
resized_image = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_CUBIC)
canvas = np.full((inp_dim[1], inp_dim[0], 3), 128)
canvas[(h - new_h) // 2:(h - new_h) // 2 + new_h, (w - new_w) // 2:(w - new_w) // 2 + new_w, :] = resized_image
return canvas
代码按照长宽比将较长的一边缩放到416,那么较短的一边肯定小于416,将缩放后的图片摆放到画布中心,空白部分用(128,128,128)填充。这样就得到了符合模型输入的416x416大小的图片。

模型结构
1、多尺度特征融合
YOLOv3为了能够实现对不同大小的物体进行准确的预测,在不同的特征图上进行目标检测。模型对于一张图片,一共在3张feature map进行预测。32倍下采样大小为13x13的feature map更适合于预测大型目标,16倍下采样大小为26x26的feature map更适合于预测中型目标,8倍下采样大小为52x52的feature map更适合于预测小型目标。而一般认为下采样次数少,提取的是图像的浅层特征,所以网络并不是直接将下采样的结果作为最后的feature map。
例如为了得到26x26大小的feature map:
1、网络先是将图片进行4次下采样得到26x26的feature map(下图网络61层的输出),可以看成浅层特征。
2、在对4次下采样的数据进行一次下采样和一次上采样,下采样一次[h,w]变成[h/2,w/2],而上采样一次[h/2,w/2]变成[h,w],所以也能得到26x26的feature map(下图红框中的第二个粉框的输出),可以看成深层特征。
3、最后对浅层特征和深层特征进行拼接(对应于下图的红框),得到最终的26x26的feature map,再在该feature map上进行目标检测。
同理,对于52x52的feature map,也是将浅层特征与深层特征进行拼接(对应于下图的橘色框),得到最终的52x52的feature map。

在代码中是通过route层来实现特征融合,摘录了Darknet的forward方法中的一段。例如拼接52x52的feature map。在yolov3.cfg文件中有[route]layers = -1, 36,其中36层的输出是图片下采样3次的浅层特征。-1为当前层的前一层输出,是橘色框中的深层特征。
elif module_type == "route":
layers = module["layers"]
layers = [int(a) for a in layers]#[-1,36]
if (layers[0]) > 0:
layers[0] = layers[0] - i
if len(layers) == 1:
x = outputs[i + (layers[0])]
else:
if (layers[1]) > 0:
layers[1] = layers[1] - i
map1 = outputs[i + layers[0]]#outputs[i-1]
map2 = outputs[i + layers[1]]#outputs[36]
# 第二个参数设为 1,这是因为我们希望将特征图沿anchor数量的维度级联起来。
#将浅层特征和深层特征进行拼接
x = torch.cat((map1, map2), 1)
可能有小伙伴还看到[route]layers = -4的情况,该情况为每次feature map检测以后,接下来的网络卷积从当前feature map的前4张开始。也就是对应上图中小狗图片上方有4个框,从下往上数分别为-1,-2,-3,-4。
2、跳跃连接(short connect)
网络模型借鉴了残差网络的跳跃连接,可以保证网络在深层的情况下也能收敛。使用1x1卷积可以大量减少每次卷积的channel,不仅可以减少模型的参数量,而且可以减少计算量,从而加速检测速度。

源码实现:在yolov3.cfg文件中有[shortcut] from=-3 activation=linear。摘录了Darknet的forward方法中的一段。其中i表示当前层数。由于outputs[i-1]和outputs[i-3]维度相同,所以可以直接相加。
elif module_type == "shortcut":
from_ = int(module["from"])#-3
#i表示当前层数,跳跃连接将前一层和前三层的输出直接相加
x = outputs[i - 1] + outputs[i + from_]
3、目标检测
anchor box大小的确定
上述内容提到网络模型一共在3张feature map上进行目标检测,但是每张feature map用的anchor box的大小并不一样。在先前的Faster-Rcnn和SSD模型中,anchor box的大小是人为设定的,而YOLOv3中,anchor box大小是采用k-means聚类得到的,在原始图片大小为416x416的情况下,32倍下采样大小为13x13的feature map采用的anchor box大小为(116,90),(156,198),(373,326)。16倍下采样大小为26x26的feature map采用的anchor box大小为(30,61),(62,45),(59,119)。8倍下采样大小为52x52的feature map采用的anchor box大小为(10,13),(16,30),(33,23)。anchor的大小是在yolov3.cfg的[yolo]中设置。
可以看到对于每张feature map都有3个不同比例大小的anchor box。那么一张图片总共能够产生(13x13+26x26+52x52)x3=10647个anchor box。下图中的红框表示feature map上的一个单元格,蓝框表示一个单元格预测的3种不同比例大小的anchor box,黄框为真实框(groundtruth)。可以看到不同feature map适用于不同大小的目标检测。

在darknet.py的create_modules方法中用于读取yolov3.cfg中anchor box大小,而yolo检测层的具体实现, 即在特征图上使用锚点预测目标区域和类别, 功能函数在predict_transform中实现,该方法在下文将会讲到。
[yolo]
mask = 6,7,8 #通过mask来选择不同大小的anchors
anchors = 10,13, 16,30, 33,23, 30,61, 62,45, 59,119, 116,90, 156,198, 373,326
classes=80
num=9
jitter=.3
ignore_thresh = .5
truth_thresh = 1
random=1
# Yolo is the detection layer,对不同尺度的feature map进行检测
elif x["type"] == "yolo":
mask = x["mask"].split(",")
mask = [int(x) for x in mask]
anchors = x["anchors"].split(",")
anchors = [int(a) for a in anchors]
anchors = [(anchors[i], anchors[i + 1]) for i in range(0, len(anchors), 2)]
anchors = [anchors[i] for i in mask]
# print('index',anchors)
detection = DetectionLayer(anchors) # 锚点,检测,位置回归,分类,这个类见predict_transform中
module.add_module("Detection_{}".format(index), detection)
在feature map上进行目标检测
选取在32倍下采样大小为13x13的feature map上进行检测为例,其余两个大小的feature map其过程与该feature map是相同的。
将经过32倍下采样的feature map输出定义为prediction,其维度为[batch_size,C,H,W],具体数值为[1,255,13,13]。
由于最终预测输出的维度为85,具体内容为[x,y,h,w,s,cls_1,cls_2,...cls_80]。分别表示相对于原图的x,y,宽,高,预测框中含有目标的置信度,及80个类别分数。因为coco数据集有80种类别。所以输出的维度为5+cls_num=5+80=85。
因此prediction的维度变化为
1、[1,255,13,13]-->[1,85x3,13x13]=[1,255,169]。因为32倍下采样feature map上的一共有13x13=169个单元格
2、[1,255,169]-->[1,169,255],交换1,2维度
3、[1,169,255]-->[1,169x3,85]=[1,507,85],因为每个单元格预测3个anchor box,所以feature map上一共有13x13x3=507个anchor box,每个anchor box输出的维度为85。这85维分别是[tx,ty,tw,th,score,cls_1,cls_2,...,cls_80]。

4、根据上方公式,将85维中的前4维[tx,ty,tw,th]变成[bx,by,bw,bh],其中Cx,Cy为当前单元格左上方的坐标。同时将第5维的score(含有目标的置信度),经过sigmoid函数激活,得到其含有目标的概率。cls_1,cls_2,..,cls_80也要经过sigmoid函数激活得到相应的概率。
最终在13x13的feature map上得到的预测输出为[1,507,85],同理在26x26的feature map上的预测输出为[1,2028,85],在52x52的feature map上的预测输出为[1,8112,85]。最后将这些预测进行拼接,对于一张图其最终的预测输出为[1,10647,85]。预测实现的具体细节再util.py的predict_transform方法中实现。
def predict_transform(prediction, inp_dim, anchors, num_classes, CUDA=True):
"""
prediction[batch_size,C,H,W]-->[1,255,13,13]
inp_dim=416
anchors=[(116,90),(156,198),(373,326)]
num_classses=80
"""
batch_size = prediction.size(0)
# stride表示整个网络的步长,等于原始图像大小除以feature map大小
stride = inp_dim // prediction.size(2)#416/13=32
# feature map每条边格子的个数
grid_size = inp_dim // stride#416/32=13
# 每个方框属性个数,x,y,h,w,置信度+类别个数
bbox_attrs = 5 + num_classes
num_anchors = len(anchors)
#[1,255,13,13]-->[1,85x3,13x13]=[1,255,169]
prediction = prediction.view(batch_size, bbox_attrs * num_anchors, grid_size * grid_size)
#[1,255,169]-->[1,169,255]
prediction = prediction.transpose(1, 2).contiguous()
# [batch_size,单元格数量*每个单元格锚框个数,5+类别个数]
#[1,169,255]-->[1,169x3,85]=[1,507,85]
prediction = prediction.view(batch_size, grid_size * grid_size * num_anchors, bbox_attrs)
anchors = [(a[0] / stride, a[1] / stride) for a in anchors] # anchors在feature map上的大小
# Sigmoid the tx, ty. and object confidenccet。tx与ty为预测的坐标偏移值
prediction[:, :, 0] = torch.sigmoid(prediction[:, :, 0])
prediction[:, :, 1] = torch.sigmoid(prediction[:, :, 1])
prediction[:, :, 4] = torch.sigmoid(prediction[:, :, 4])
# 生成了每个格子的左上角坐标,生成的坐标为grid x grid的二维数组
# a,b分别对应这个二维矩阵的x,y坐标的数组,a,b的维度与grid维度一样。
# 每个grid cell的尺寸均为1,故grid范围是[0,12](假如当前的特征图13*13)
grid = np.arange(grid_size)
a, b = np.meshgrid(grid, grid)
# x_offset即cx,y_offset即cy,表示当前cell左上角坐标
x_offset = torch.FloatTensor(a).view(-1, 1)
y_offset = torch.FloatTensor(b).view(-1, 1)
if CUDA:
x_offset = x_offset.cuda()
y_offset = y_offset.cuda()
# 这里的x_y_offset对应的是最终的feature map中每个格子的左上角坐标,
# 比如有13个格子,刚x_y_offset的坐标就对应为(0,0),(0,1)…(12,12)
# .view(-1, 2)将tensor变成两列,unsqueeze(0)在0维上添加了一维。
x_y_offset = torch.cat((x_offset, y_offset), 1).repeat(1, num_anchors).view(-1, 2).unsqueeze(0)
prediction[:, :, :2] += x_y_offset # bx=sigmoid(tx)+cx,by=sigmoid(ty)+cy
# log space transform height and the width
anchors = torch.FloatTensor(anchors)
if CUDA:
anchors = anchors.cuda()
# 这里的anchors本来是一个长度为3的list(三个anchors),然后在0维上(行)进行了grid_size*grid_size个复制,在1维(列)上一次复制(没有变化),
# 即对每个格子都得到三个anchor。Unsqueeze(0)的作用是在数组上添加一维,这里是在第0维上添加的。添加grid_size是为了之后的公式bw=pw×e^tw的tw。
anchors = anchors.repeat(grid_size * grid_size, 1).unsqueeze(0)
# 对网络预测得到的矩形框的宽高的偏差值进行指数计算,然后乘以anchors里面对应的宽高(这里的anchors里面的宽高是对应最终的feature map尺寸grid_size),
# 得到目标的方框的宽高,这里得到的宽高是相对于在feature map的尺寸
# bw=pw*e^tw,bh=ph*e^th。anchors提供(ph,pw)。prediction为th,tw
prediction[:, :, 2:4] = torch.exp(prediction[:, :, 2:4]) * anchors
# 这里得到每个anchor中每个类别的得分。将网络预测的每个得分用sigmoid()函数计算得到
prediction[:, :, 5: 5 + num_classes] = torch.sigmoid((prediction[:, :, 5: 5 + num_classes]))
# 将相对于最终feature map的方框坐标和尺寸映射回原始输入图片(416x416),即将方框的坐标乘以网络的stride即可
prediction[:, :, :4] *= stride
return prediction#[1,507,85]

对预测的bounding box进行挑选
prediction对于一张图片预测了这么多bounding box,需要从中挑选最合适的几个。
1、预测框的置信度必须大于 objectness 分数阈值,将低于阈值的方框去掉。
2、通过非极大值抑制(NMS)去除重复度较大的框。
3、在80个类别中提取得分最高的那个类的得分max_conf,同时返回这个类对应的序号max_conf_score,
经过一系列操作,得到最终的输出为(ind,x1,y1,x2,y2,s,s_cls,index_cls),ind 是这个预测框所属图片在这个一组图片中的序号,x1,y1为预测框的左上角坐标,x2,y2为预测框的右下角角坐标,s是这个方框含有目标的置信度,s_cls是这个方框中最有可能的类别的概率得分,index_cls是s_cls对应的这个类别所对应的序号.
上述过程通过util.py的write_results方法实现。
def write_results(prediction, confidence, num_classes, nms_conf=0.4):
'''
prediction: 输入的预测shape维度(1,10647, 85)
confidence:0.5
num_classes:80
'''
#得到预测种含有目标置信度大于confidence的数据
conf_mask = (prediction[:, :, 4] > confidence).float().unsqueeze(2)
# conf_mask中置信度大于confidence的方框所对应的含有目标的得分为1
# 根据numpy的广播原理,它会扩展成与prediction维度一样的tensor,所以含有目标的得分小于confidence的方框所有的属性都会变为0,故如果没有检测任何有效目标,则返回值为0
prediction = prediction * conf_mask
'''
保留预测结果中置信度大于阈值的bbox
下面开始为nms准备
'''
# prediction的前五个数据分别表示 (Cx, Cy, w, h, score),这里创建一个新的数组,大小与predicton的大小相同
box_corner = prediction.new(prediction.shape)
box_corner[:, :, 0] = (prediction[:, :, 0] - prediction[:, :, 2] / 2) # x1 = Cx - w/2
box_corner[:, :, 1] = (prediction[:, :, 1] - prediction[:, :, 3] / 2) # y1 = Cy - h/2
box_corner[:, :, 2] = (prediction[:, :, 0] + prediction[:, :, 2] / 2) # x2 = Cx + w/2
box_corner[:, :, 3] = (prediction[:, :, 1] + prediction[:, :, 3] / 2) # y2 = Cy + h/2
prediction[:, :, :4] = box_corner[:, :, :4]
batch_size = prediction.size(0)
write = False
# 对每一张图片得分的预测值进行NMS操作,因为每张图片的目标数量不一样,所以有效得分的方框的数量不一样,
# 没法将几张图片同时处理,因此一次只能完成一张图的置信度阈值的设置和NMS,不能将所涉及的操作向量化.
# 所以必须在预测的第一个维度上(batch数量)上遍历每张图片,将得分低于一定分数的去掉,对剩下的方框进行进行NMS
for ind in range(batch_size):
# 选择此batch中第ind个图像的预测结果,image_pred对应一张图片中所有方框的坐标(x1,y1,x2,y2)以及得分,
image_pred = prediction[ind] #一个二维tensor 维度为10647x85
# 只关心有最大值的类别分数,prediction[:, 5:]表示每一分类的分数,
# 返回每一行中所有类别的得分最高的那个类的得分max_conf,同时返回这个类对应的序号max_conf_score
max_conf, max_conf_score = torch.max(image_pred[:, 5:5 + num_classes], 1)
# 维度扩展max_conf: shape=(10647,) => (10647,1)添加一个列的维度,max_conf变成二维tensor,尺寸为10647x1
max_conf = max_conf.float().unsqueeze(1)
max_conf_score = max_conf_score.float().unsqueeze(1)
# 我们移除了每一行的这 80 个类别分数,只保留bbox4个坐标以及objectnness分数,转而增加了有最大值的类别分数及索引。
seq = (image_pred[:, :5], max_conf, max_conf_score)
# 将每个方框的(x1,y1,x2,y2,s)与得分最高的这个类的分数s_cls(max_conf)和对应类的序号index_cls(max_conf_score)在列维度上连接起来,
# 即将10647x5,10647x1,10647x1三个tensor 在列维度进行concatenate操作,得到一个10647x7的tensor,(x1,y1,x2,y2,s,s_cls,index_cls)。
image_pred = torch.cat(seq, 1) # shape=(10647, 5+1+1=7)
# image_pred[:,4]是长度为10647的一维tensor,维度为4的列是置信度分数。假设有15个框含有目标的得分非0,返回15x1的tensor
non_zero_ind = (torch.nonzero(image_pred[:, 4])) # torch.nonzero返回的是索引
try:
image_pred_ = image_pred[non_zero_ind.squeeze(), :].view(-1, 7)
except:
continue
if image_pred_.shape[0] == 0: # 当没有检测到时目标时,我们使用 continue 来跳过对本图像的循环,即进行下一次循环。
continue
# 获取当前图像检测结果中出现的所有类别
img_classes = unique(image_pred_[:, -1]) # -1 index holds the class index
for cls in img_classes:
# 获取含有该类别的预测数据
cls_mask = image_pred_ * (image_pred_[:, -1] == cls).float().unsqueeze(1)
class_mask_ind = torch.nonzero(cls_mask[:, -2]).squeeze()
image_pred_class = image_pred_[class_mask_ind].view(-1, 7)
# 开始NMS
# 按置信度对含有该类别的预测框进行从大到小排序,并获取其下标
# confidence is at the top
conf_sort_index = torch.sort(image_pred_class[:, 4], descending=True)[1]
image_pred_class = image_pred_class[conf_sort_index]
idx = image_pred_class.size(0) # Number of detections
for i in range(idx):
# Get the IOUs of all boxes that come after the one we are looking at
# in the loop
try:
ious = bbox_iou(image_pred_class[i].unsqueeze(0), image_pred_class[i + 1:])
except ValueError:
'''
在for i in range(idx):这个循环中,因为有一些框(在image_pred_class对应一行)会被去掉,image_pred_class行数会减少,
这样在后面的循环中,idx序号会超出image_pred_class的行数的范围,出现ValueError错误。
所以当抛出这个错误时,则跳出这个循环,因为此时已经没有更多可以去掉的方框了。
'''
break
except IndexError:
break
# 选取IOU<阈值的框
iou_mask = (ious < nms_conf).float().unsqueeze(1)
image_pred_class[i + 1:] *= iou_mask
# 保留那些非零的行数据
non_zero_ind = torch.nonzero(image_pred_class[:, 4]).squeeze()
image_pred_class = image_pred_class[non_zero_ind].view(-1, 7)
# new()创建了一个和image_pred_class相同的tensor,tensor行数等于cls这个类别所有的方框经过NMS剩下的方框的个数,
# 即image_pred_class的行数,列数为1.
# 再将生成的这个tensor所有元素赋值为这些方框所属图片对应于batch中的序号ind(一张图片有多个类别),用fill_(ind)实现
batch_ind = image_pred_class.new(image_pred_class.size(0), 1).fill_(
ind) # Repeat the batch_id for as many detections of the class cls in the image
seq = batch_ind, image_pred_class
if not write:
# 将batch_ind, image_pred_class在列维度上进行连接,image_pred_class每一行存储的是(x1,y1,x2,y2,s,s_cls,index_cls),
# 现在在第一列增加了一个代表这个行对应方框所属图片在一个batch中的序号ind
output = torch.cat(seq, 1)
write = True
else:
out = torch.cat(seq, 1)
output = torch.cat((output, out))
try:
return output
except:
return 0
在原图中绘制预测结果
在detect.py的write方法中实现。
# x为映射到原始图片中一个方框的属性(ind,x1,y1,x2,y2,s,s_cls,index_cls),
# results列表保存了所有测试图片,一个元素对应一张图片
def write(x, results):
c1 = tuple(x[1:3].int()) # c1为方框左上角坐标x1,y1
c2 = tuple(x[3:5].int()) # c2为方框右下角坐标x2,y2
img = results[int(x[0])] # 在results中找到x方框所对应的图片,x[0]为方框所在图片在所有测试图片中的序号
cls = int(x[-1])
color = random.choice(colors)
label = "{0}".format(classes[cls])
cv2.rectangle(img, c1, c2, color, 1) # 在图片上画出(x1,y1,x2,y2)矩形,即我们检测到的目标方框
t_size = cv2.getTextSize(label, cv2.FONT_HERSHEY_PLAIN, 1, 1)[0] # 得到一个包含目标名字字符的方框的宽高
c2 = c1[0] + t_size[0] + 3, c1[1] + t_size[1] + 4
cv2.rectangle(img, c1, c2, color, -1) # 在图片上画一个实心方框,我们将在方框内放置目标类别名字
cv2.putText(img, label, (c1[0], c1[1] + t_size[1] + 4), cv2.FONT_HERSHEY_PLAIN, 1, [225, 255, 255], 1);
return img
程序运行中的问题
我测试代码时发现运行完,检测的结果并没有保存到我的图片目录下,后来发现是因为我在windows下运行的原因,需要修改detect.py的该处代码,因为windows和Linux的分隔符不同,所以图片未能保存。

参考文章:
https://blog.csdn.net/qq_34199326/article/details/84072505
https://zhuanlan.zhihu.com/p/49556105
网友评论