链接

1. 前言

本来的打算是不打算对 YOLO v1 进行解读的,因为怎么说,虽然 YOLO v1 是 YOLO 的开山之作,但是说实话,现在 YOLO v3 都烂大街了,谁还用 YOLO v1 呀?简单尝试了下看了下代码,发现代码中还是存在一些比较精妙之处的,可以让我们更好了解 YOLO 系列模型的思想。话不多说,开始进行的我们代码的解读。再次我推荐YOLOv1 解读

当然代码看这个就好了

https://github.com/TowardsNorth/yolo_v1_tensorflow_guiyu

2. 代码解读

YOLO V1 的代码倒是比 FasterRCNN 代码看起来简单多了呀,仔细观察,一些逻辑顺序和 FasterRCNN 倒是挺像的呀。

(1) pascal voc.py: 对图片数据和 XML数据进行解析预处理

(2) yolo_net.py:搭建 yolo v1 网络,设置 yolo v1 的损失函数

(3) train.py 和 test.py :一个用来训练模型,一个用来测试模型

多么简单明了呀,这么一看 one-stage 比 two-stage 模型看起来真的简单很多呀!

话不多说,进入pascal voc.py脚本中,我们主要看函数load_pascal_annotation中:

  1. def load_pascal_annotation(self, index): # Load image and bounding boxes info from XML file in the PASCAL VOC format
  2. imname = os.path.join(self.data_path, 'JPEGImages', index + '.jpg') #data/pascal_voc/VOCdevkit/VOC2007/JPEGImages/index.jpg
  3. im = cv2.imread(imname)
  4. h_ratio = 1.0 * self.image_size / im.shape[0] # 448所占图片高度的比例
  5. w_ratio = 1.0 * self.image_size / im.shape[1] # 448占图片宽度的比例
  6. # im = cv2.resize(im, [self.image_size, self.image_size])
  7. label = np.zeros((self.cell_size, self.cell_size, 5+len(self.classes))) #label数组维度 7*7*25, 一个cell只负责预测一个类别
  8. filename = os.path.join(self.data_path, 'Annotations', index + '.xml') #data/pascal_voc/VOCdevkit/VOC2007/Annotations/index.xml
  9. tree = ET.parse(filename) #解析xml文件
  10. objs = tree.findall('object') #找到index指向的该xml文件中的所有object
  11. for obj in objs: #记录出xml文件中object框的位置
  12. bbox = obj.find('bndbox')
  13. # Make pixel indexes 0-based
  14. x1 = max(min((float(bbox.find('xmin').text) - 1) * w_ratio, self.image_size - 1), 0)
  15. y1 = max(min((float(bbox.find('ymin').text) - 1) * h_ratio, self.image_size - 1), 0)
  16. x2 = max(min((float(bbox.find('xmax').text) - 1) * w_ratio, self.image_size - 1), 0)
  17. y2 = max(min((float(bbox.find('ymax').text) - 1) * h_ratio, self.image_size - 1), 0)
  18. cls_ind = self.class_to_ind[obj.find('name').text.lower().strip()] # class to index, matters
  19. boxes = [(x2 + x1) / 2.0, (y2 + y1) / 2.0, x2 - x1, y2 - y1] # 将坐标(x1,y1,x2,y2)转变成(x_center,y_center,width,height)
  20. x_ind = int(boxes[0] * self.cell_size / self.image_size) # 查看object的x_center落在哪个cell, 整张图片cell的数量为7*7
  21. y_ind = int(boxes[1] * self.cell_size / self.image_size) # 查看object的y_center落在哪个cell, 整张图片cell的数量为7*7
  22. if label[y_ind, x_ind, 0] == 1: #设置一个标记,看其是否被访问过,同时也表明这是个object
  23. continue
  24. label[y_ind, x_ind, 0] = 1 #设置标记,1是已经被访问过了,同时也表明这是个object
  25. label[y_ind, x_ind, 1:5] = boxes #设置标记的框,框的形式为 ( x_center, y_center, width, height)
  26. label[y_ind, x_ind, 5 + cls_ind] = 1 #标记类别,pascal_voc数据集一共有20个类,哪个类是哪个,则在响应的位置上的index是1
  27. return label, len(objs) #返回label,以及该index文件中object的数量

输出的 label 是一个(7,7,25)数组(这里假设类别数为 20)。(我们 YOLO v1 网络的最后输出的 shape 是(7,7,30),这里好像尺寸不匹配耶,先不管,后面说明)

这里 label 是宽 7,高 7,深度为 25 的数组,沿着深度进行解析,根据代码

  1. x_ind = int(boxes[0] * self.cell_size / self.image_size) # 查看object的x_center落在哪个cell, 整张图片cell的数量为7*7
  2. y_ind = int(boxes[1] * self.cell_size / self.image_size) # 查看object的y_center落在哪个cell, 整张图片cell的数量为7*7
  3. if label[y_ind, x_ind, 0] == 1: #设置一个标记,看其是否被访问过,同时也表明这是个object
  4. continue
  5. label[y_ind, x_ind, 0] = 1 #设置标记,1是已经被访问过了,同时也表明这是个object

可以看出,深度(axis=2) 索引为 0(第一个)处的值是根据 gt_boxes 的中心是否落在这个方格内部的标识(方格是图片 77 后 的每一格),也就是这个方格负不负责对这个物体(框)进行检测(response),如果落在当中,该处的值就是 1,否则为 0。这样做的意图就是:*防止出现多个格子争抢对同一个物体的检测权。

那么这么一处理,共 7*7 的格子,深度索引为 0 处的值就确定了。

下面举个例子(图是上面链接中的图):

[转载]YOLO V1 深层解读与代码解析 - 图1

图中有三个物体:这三个物体的中心分别落在 77 格子中的(1,4),(2,3),(5,1),*索引从 0 开始。

那么这张图片通过 pascal_voc.py 后生成 label 的第一层就是一个(7,7,1)的数组,其中(1,4),(2,3),(5,1)处的值为 1,其余的都是 0。

从代码中的

  1. label[y_ind, x_ind, 1:5] = boxes #设置标记的框,框的形式为 ( x_center, y_center, width, height)

我们可以看出,(7,7,25)数组中深度 (aixs=2) 为(1,5)的部分存储着gt_boxes 的坐标。当然啦,只在 responsible 的格子才有坐标,不负责检测该框的格子中的都是值 0。

还是上面的图,只有 label[1,4,1:5]、label[2,3,1:5]、label[5,1,1:5]存放着坐标,其余的 label[:,:,1:5]全部为 0。

代码中

  1. label[y_ind, x_ind, 5 + cls_ind] = 1 #标记类别,pascal_voc数据集一共有20个类,哪个类是哪个,则在响应的位置上的index是1

可以看出,(7,7,25)数组中深度 (axis=2) 为(5,25)的部分存储着gt_boxes 的 20 分类的类别信息。

那么了解完 label 的形式了,我们就来进入 yolo v1 的核心代码,他在 yolo_net.py 脚本中,我们将 yolo_net.py 脚本中的函数主要分为一下几个:

(1) build_network

(2) calc_iou

(3) loss_layer

第一个 build_network 函数显然就是搭建 yolo v1 的网络了,代码如下

  1. def build_network(self, #用slim构建网络,简单高效
  2. images,
  3. num_outputs,
  4. alpha,
  5. keep_prob=0.5,
  6. is_training=True,
  7. scope='yolo'):
  8. with tf.variable_scope(scope):
  9. with slim.arg_scope(
  10. [slim.conv2d, slim.fully_connected], #卷积层加上全连接层
  11. activation_fn=leaky_relu(alpha), #用的是leaky_relu激活函数
  12. weights_regularizer=slim.l2_regularizer(0.0005), #L2正则化,防止过拟合
  13. weights_initializer=tf.truncated_normal_initializer(0.0, 0.01) #权重初始化
  14. ):
  15. #这里先执行填充操作
  16. # t = [[2, 3, 4], [5, 6, 7]], paddings = [[1, 1], [2, 2]],mode = "CONSTANT"
  17. #
  18. # 那么sess.run(tf.pad(t, paddings, "CONSTANT"))
  19. # 的输出结果为:
  20. #
  21. # array([[0, 0, 0, 0, 0, 0, 0],
  22. # [0, 0, 2, 3, 4, 0, 0],
  23. # [0, 0, 5, 6, 7, 0, 0],
  24. # [0, 0, 0, 0, 0, 0, 0]], dtype=int32)
  25. #
  26. # 可以看到,上,下,左,右分别填充了1, 1, 2, 2
  27. # 行刚好和paddings = [[1, 1], [2, 2]]
  28. # 相等,零填充
  29. #因为这里有4维,batch和channel维没有填充,只填充了image_height,image_width这两个维度,0填充
  30. net = tf.pad(
  31. images, np.array([[0, 0], [3, 3], [3, 3], [0, 0]]),
  32. name='pad_1')
  33. net = slim.conv2d(
  34. net, 64, 7, 2, padding='VALID', scope='conv_2') #这里的64是指卷积核个数,7是指卷积核的高度和宽度,2是指步长,valid表示没有填充
  35. net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_3') #max_pool, 大小2*2, stride:2
  36. net = slim.conv2d(net, 192, 3, scope='conv_4') #这里的192是指卷积核的个数,3是指卷积核的高度和宽度,默认的步长为1
  37. net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_5') #max_pool,大小为2*2,strides:2
  38. net = slim.conv2d(net, 128, 1, scope='conv_6') #128个卷积核,大小为1*1,默认步长为1
  39. net = slim.conv2d(net, 256, 3, scope='conv_7') #256个卷积核,大小为3*3,默认步长为1
  40. net = slim.conv2d(net, 256, 1, scope='conv_8') #256个卷积核,大小为1*1,默认步长为1
  41. net = slim.conv2d(net, 512, 3, scope='conv_9') #512个卷积核,大小为3*3,默认步长为3
  42. net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_10') #max_pool, 大小为2*2,stride:2
  43. net = slim.conv2d(net, 256, 1, scope='conv_11') #256个卷积核,大小为1*1, 默认步长为1
  44. net = slim.conv2d(net, 512, 3, scope='conv_12') #512个卷积核,大小为3*3,默认步长为1
  45. net = slim.conv2d(net, 256, 1, scope='conv_13') #256个卷积核,大小为1*1, 默认步长为1
  46. net = slim.conv2d(net, 512, 3, scope='conv_14') #512个卷积核,大小为3*3, 默认步长为1
  47. net = slim.conv2d(net, 256, 1, scope='conv_15') #256个卷积核,大小为1*1, 默认步长为1
  48. net = slim.conv2d(net, 512, 3, scope='conv_16') #512个卷积核,大小为3*3, 默认步长为1
  49. net = slim.conv2d(net, 256, 1, scope='conv_17') #256个卷积核,大小为1*1, 默认步长为1
  50. net = slim.conv2d(net, 512, 3, scope='conv_18') #512个卷积核,大小为3*3, 默认步长为1
  51. net = slim.conv2d(net, 512, 1, scope='conv_19') #256个卷积核,大小为1*1, 默认步长为1
  52. net = slim.conv2d(net, 1024, 3, scope='conv_20') #1024个卷积核,大小为3*3,默认步长为1
  53. net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_21') # max_pool, 大小为2*2,strides: 2
  54. net = slim.conv2d(net, 512, 1, scope='conv_22') #512卷积核,大小为1*1,默认步长为1
  55. net = slim.conv2d(net, 1024, 3, scope='conv_23') #1024卷积核,大小为3*3,默认步长1
  56. net = slim.conv2d(net, 512, 1, scope='conv_24') #512卷积核,大小为1*1,默认步长1
  57. net = slim.conv2d(net, 1024, 3, scope='conv_25') #1024卷积核,大小为3*3, 默认步长为1
  58. net = slim.conv2d(net, 1024, 3, scope='conv_26') #1024卷积核,大小为3*3,默认步长为1
  59. net = tf.pad(
  60. net, np.array([[0, 0], [1, 1], [1, 1], [0, 0]]),
  61. name='pad_27') #padding, 第一个维度batch和第四个维度channels不用管,只padding卷积核的高度和宽度
  62. net = slim.conv2d(
  63. net, 1024, 3, 2, padding='VALID', scope='conv_28') #1024卷积核,大小3*3,步长为2
  64. net = slim.conv2d(net, 1024, 3, scope='conv_29') #1024卷积核,大小为3*3,默认步长为1
  65. net = slim.conv2d(net, 1024, 3, scope='conv_30') #1024卷积核,大小为3*3,默认步长为1
  66. net = tf.transpose(net, [0, 3, 1, 2], name='trans_31') #转置,由[batch, image_height,image_width,channels]变成[bacth, channels, image_height,image_width]
  67. net = slim.flatten(net, scope='flat_32') #将输入扁平化,但保留batch_size, 假设第一位是batch,实际上第一维也是batch
  68. net = slim.fully_connected(net, 512, scope='fc_33') #全连接层,神经元个数
  69. net = slim.fully_connected(net, 4096, scope='fc_34') #全连接层,神经元个数
  70. net = slim.dropout( #dropout,防止过拟合
  71. net, keep_prob=keep_prob, is_training=is_training,
  72. scope='dropout_35')
  73. net = slim.fully_connected( #全连接层
  74. net, num_outputs, activation_fn=None, scope='fc_36')
  75. return net #net shape[7*7*30]

看完网络搭建的模型后,我们是否有种感觉,yolo v1 的网络和 vgg16 有种类似感,至少从网络形状来说,比较像,先是一堆卷积和池化,后面加入全连接层。最大的差异是最后输出层用线性函数做激活函数,因为需要预测 bounding box 的位置(数值型),而不仅仅是对象的概率。其实可以看出,模型的输出的 shape 为(7,7,30)。

函数calc_iou是一个两个 bbox 之间的IOU大小的函数。

我们进入 loss_layer,我个人认为其实只要把网络搭建损失函数完成,那么这个程序就完成了一大半了。

损失函数仍然需要两个输入,一个是predict,就是网络前向的结果(和图片分类的 logit 是类似的),另一个是label,就是从原图中获取的相关信息。

我们根据损失函数的公式,如图 (图是 copy 来的,别介意)

[转载]YOLO V1 深层解读与代码解析 - 图2

我们可以看出,总共有5 个 loss,每个 loss 参与计算损失的参数都不一样。我们需要提取 predict 和 labels 中对应的参数来计算每一个 loss。

所以首先应该对 predict 和 labels 进行处理,使他们中的求解 loss 的参数能够对应起来。以下代码就是做了一件这样的事

  1. with tf.variable_scope(scope):
  2. predict_classes = tf.reshape( #reshape一下,每个cell一个框,变成[batch_size, 7, 7, 20]
  3. predicts[:, :self.boundary1],
  4. [self.batch_size, self.cell_size, self.cell_size, self.num_class])
  5. predict_scales = tf.reshape( #reshape一下,7*7*20 ~ 7*7*22, 就是分别找到每个cell的两个框的置信度,这里是两个框,可自定义,变成[batch_size, 7, 7, 2]
  6. predicts[:, self.boundary1:self.boundary2],
  7. [self.batch_size, self.cell_size, self.cell_size, self.boxes_per_cell])
  8. predict_boxes = tf.reshape( #reshape,就是分别找到每个cell中两个框的坐标(x_center, y_center, w, h),这里是两个框,可自定义, 变成[batch_size, 7, 7, 2, 4]
  9. predicts[:, self.boundary2:], #7 * 7 * 22 ~ 7 * 7 * 30,
  10. [self.batch_size, self.cell_size, self.cell_size, self.boxes_per_cell, 4])
  11. #下面是对label部分进行reshape
  12. response = tf.reshape(
  13. labels[..., 0],
  14. [self.batch_size, self.cell_size, self.cell_size, 1]) #reshape, 就是查看哪个cell负责标记object,是的话就为1 ,否则是0 ,维度形式:[batch_size, 7, 7, 1]
  15. boxes = tf.reshape(
  16. labels[..., 1:5],
  17. [self.batch_size, self.cell_size, self.cell_size, 1, 4]) #找到这个cell负责的框的位置,其形式为:(x_center,y_center,width,height), 其维度为:[batch_size, 7, 7, 1, 4]
  18. boxes = tf.tile(
  19. boxes, [1, 1, 1, self.boxes_per_cell, 1]) / self.image_size # tile() 平铺之意,用于在同一维度上的复制, 变成[batch_size, 7, 7, 2, 4], 除以image_size就是得到相对于整张图片的比例
  20. classes = labels[..., 5:] #找到这个cell负责的框所框出的类别,有20个类别, 变成[batch_size, 7, 7, 20],正确的类别对应的位置为1,其它为0

这段代码提取了predict中的

(1)77 个格子对应的坐标信息(7724),

(2)类别信息(7720),

(3)框的置信度信息(772),

也提取了 labels 中的

(1) response 信息(771)

(2) boxes 信息(774—->774*2, 这里为了和 predict 中的坐标信息对应,使用了 tile 函数)

(3) 分类信息(7720)

这里我们注意一点:

  1. boxes = tf.tile(boxes, [1, 1, 1, self.boxes_per_cell, 1]) / self.image_size # tile() 平铺之意,用于在同一维度上的复制, 变成[batch_size, 7, 7, 2, 4], 除以image_size就是得到相对于整张图片的比例

这里使用tile 函数将原本一个 gt_bbox 的坐标复制了两份,然后做了一件事,除以图片的大小(448),这里很重要,如果漏看了,后面根本无法解释清楚!

Gt_box 除以图片大小,意思就是将 gt_box 做了归一化,框的信息限制在了(0,1)中。

我们对 predict 和 labels 做了处理后,现在我们思考下,既然我们已经对了 labels 中的坐标信息做了归一化,那么predict输出框信息应当也需要做归一化,这样在相同的空间中,我们才能求两者之间的 IOU 了。

接下来的一段代码需要读者们好好进行领悟的。

就是关于偏移 offset 的设置。

  1. offset = tf.reshape(
  2. tf.constant(self.offset, dtype=tf.float32),
  3. [1, self.cell_size, self.cell_size, self.boxes_per_cell]) #由7*7*2 reshape成 1*7*7*2
  4. offset = tf.tile(offset, [self.batch_size, 1, 1, 1]) #在第一个维度上进行复制,变成 [batch_size, 7, 7,2]
  5. offset_tran = tf.transpose(offset, (0, 2, 1, 3)) #维度为[batch_size, 7, 7, 2]

这里出现了个 self.offset, 我们看看这是一个什么。

  1. self.offset = np.transpose(np.reshape(np.array( #reshape之后再转置,变成7*7*2的三维数组
  2. [np.arange(self.cell_size)] * self.cell_size * self.boxes_per_cell),
  3. (self.boxes_per_cell, self.cell_size, self.cell_size)), (1, 2, 0))

嗯,这一段代码看似简短,实际上还是有点绕的,我写了另一篇文章,大家可以看一下里面的解释。

其实这段代码的输出,就是

  1. #offset
  2. # array([[[0, 0],
  3. # [1, 1],
  4. # [2, 2],
  5. # [3, 3],
  6. # [4, 4],
  7. # [5, 5],
  8. # [6, 6]],
  9. #
  10. # [[0, 0],
  11. # [1, 1],
  12. # [2, 2],
  13. # [3, 3],
  14. # [4, 4],
  15. # [5, 5],
  16. # [6, 6]],
  17. #
  18. # [[0, 0],
  19. # [1, 1],
  20. # [2, 2],
  21. # [3, 3],
  22. # [4, 4],
  23. # [5, 5],
  24. # [6, 6]],
  25. #
  26. # [[0, 0],
  27. # [1, 1],
  28. # [2, 2],
  29. # [3, 3],
  30. # [4, 4],
  31. # [5, 5],
  32. # [6, 6]],
  33. #
  34. # [[0, 0],
  35. # [1, 1],
  36. # [2, 2],
  37. # [3, 3],
  38. # [4, 4],
  39. # [5, 5],
  40. # [6, 6]],
  41. #
  42. # [[0, 0],
  43. # [1, 1],
  44. # [2, 2],
  45. # [3, 3],
  46. # [4, 4],
  47. # [5, 5],
  48. # [6, 6]],
  49. #
  50. # [[0, 0],
  51. # [1, 1],
  52. # [2, 2],
  53. # [3, 3],
  54. # [4, 4],
  55. # [5, 5],
  56. # [6, 6]]])

以上就是 self.offset 的值

那么前一段代码,

  1. offset = tf.reshape(
  2. tf.constant(self.offset, dtype=tf.float32),
  3. [1, self.cell_size, self.cell_size, self.boxes_per_cell]) #由7*7*2 reshape成 1*7*7*2
  4. offset = tf.tile(offset, [self.batch_size, 1, 1, 1]) #在第一个维度上进行复制,变成 [batch_size, 7, 7,2]
  5. offset_tran = tf.transpose(offset, (0, 2, 1, 3)) #维度为[batch_size, 7, 7, 2]

offset_tran 的输出就是

  1. #offset_tran如下,只不过batch_size=1
  2. # [[[[0. 0.]
  3. # [0. 0.]
  4. # [0. 0.]
  5. # [0. 0.]
  6. # [0. 0.]
  7. # [0. 0.]
  8. # [0. 0.]]
  9. #
  10. # [[1. 1.]
  11. # [1. 1.]
  12. # [1. 1.]
  13. # [1. 1.]
  14. # [1. 1.]
  15. # [1. 1.]
  16. # [1. 1.]]
  17. #
  18. # [[2. 2.]
  19. # [2. 2.]
  20. # [2. 2.]
  21. # [2. 2.]
  22. # [2. 2.]
  23. # [2. 2.]
  24. # [2. 2.]]
  25. #
  26. # [[3. 3.]
  27. # [3. 3.]
  28. # [3. 3.]
  29. # [3. 3.]
  30. # [3. 3.]
  31. # [3. 3.]
  32. # [3. 3.]]
  33. #
  34. # [[4. 4.]
  35. # [4. 4.]
  36. # [4. 4.]
  37. # [4. 4.]
  38. # [4. 4.]
  39. # [4. 4.]
  40. # [4. 4.]]
  41. #
  42. # [[5. 5.]
  43. # [5. 5.]
  44. # [5. 5.]
  45. # [5. 5.]
  46. # [5. 5.]
  47. # [5. 5.]
  48. # [5. 5.]]
  49. #
  50. # [[6. 6.]
  51. # [6. 6.]
  52. # [6. 6.]
  53. # [6. 6.]
  54. # [6. 6.]
  55. # [6. 6.]
  56. # [6. 6.]]]]
  57. #

获取了 offset 和 offset_trans 后,我们看下一段代码,

  1. predict_boxes_tran = tf.stack( #相对于整张特征图来说,找到相对于特征图大小的中心点,和宽度以及高度的开方, 其格式为[batch_size, 7, 7, 2, 4]
  2. [(predict_boxes[..., 0] + offset) / self.cell_size, #self.cell=7,predict_boxes[..., 0]的shape为(1,7,7,2,1),是x离他的左上角的偏移量
  3. (predict_boxes[..., 1] + offset_tran) / self.cell_size, #predict_boxes[..., 1]的shape为(1,7,7,2,1),是y离他的左上角的偏移量
  4. tf.square(predict_boxes[..., 2]), #predict_boxes[..., 2]是预测的宽度的均方,这里求平方,就是获取宽度原值,相对于整个图片的大小
  5. tf.square(predict_boxes[..., 3])], axis=-1) #获得高度的原值

解说这段代码之前,必须要补充点知识,就是关于predict_boxes 的输出,我们知道 predict_boxes 的输出是网络前向传播后预测的候选框。固定思维让我们认为,predict_boxes 的值就是类似 gt_box 坐标那样的(x,y,d,h)坐标。错!保持这个固有的思维,这段代码就无法看懂了,我也是不断推测的,才知道实际上道 predict_boxes 各个坐标的含义。

[转载]YOLO V1 深层解读与代码解析 - 图3

predict_boxes 中心坐标真实含义

其实 predict_boxes 中的前两位,就是中心点坐标(x,y)代表的含义如上图,是 predict_boxes中心坐标所属格子(response)左上角坐标。而 predict_boxes 中的后两位,其实并不是 predict_boxes 的宽度高度,而是 predict_boxes 的宽度高度相对于图片的大小(归一化后)的开方

那么我们所说的输入 predict 中包含的坐标信息,就不是

(中心横坐标,

中心纵坐标,

宽,

高)

而是

(中心横坐标离所属方格左上角坐标的横向距离(假设每个方格宽度为1),

中心纵坐标离所属方格左上角坐标的纵向距离(假设每个方格高度为1),

宽度(归一化)的开方

高度(归一化)的开方

这里理解了,后面理解起来就很 easy 了。

代码

  1. predict_boxes_tran = tf.stack( #相对于整张特征图来说,找到相对于特征图大小的中心点,和宽度以及高度的开方, 其格式为[batch_size, 7, 7, 2, 4]
  2. [(predict_boxes[..., 0] + offset) / self.cell_size, #self.cell=7,predict_boxes[..., 0]的shape为(1,7,7,2,1),是x离他的左上角的偏移量
  3. (predict_boxes[..., 1] + offset_tran) / self.cell_size, #predict_boxes[..., 1]的shape为(1,7,7,2,1),是y离他的左上角的偏移量
  4. tf.square(predict_boxes[..., 2]), #predict_boxes[..., 2]是预测的宽度的均方,这里求平方,就是获取宽度原值,相对于整个图片的大小
  5. tf.square(predict_boxes[..., 3])], axis=-1) #获得高度的原值

中的

  1. (predict_boxes[..., 0] + offset) / self.cell_size,
  2. (predict_boxes[..., 1] + offset_tran) / self.cell_size,

就是将 predict_boxes 的中心坐标转换为相对于整张图来说的(x,y)中心坐标

还是用上面的图来说明

[转载]YOLO V1 深层解读与代码解析 - 图4

我们标出了 response 格子对应的offset xoffset y,那么结合上面所说,

(predict_boxes[…, 0] + offset) / self.cell_size,
(predict_boxes[…, 1] + offset_tran) / self.cell_size,

就是先将上面图中的 x 和 y 变成(x+offset x,y+offset y),然后除以 cell_size=7, 相当于对中心坐标进行了归一化

  1. tf.square(predict_boxes[..., 2]),
  2. tf.square(predict_boxes[..., 3])],

就是将原来的宽度(归一化)的开方和高度(归一化)的开方恢复成:(宽度(归一化),高度(归一化)),那么 predict_bbox 中的坐标信息,全部通过这段代码,恢复成了和labels中坐标相同格式的了,难道不是很妙嘛?

说到这里了,接下来我们就准备分析下 loss 的代码了,我们仔细分析下,

根据下面的 loss 式子,看看我们还缺少什么没有进行说明。

[转载]YOLO V1 深层解读与代码解析 - 图5

上面公式中,置信度损失的

[转载]YOLO V1 深层解读与代码解析 - 图6

[转载]YOLO V1 深层解读与代码解析 - 图7

我们还没有进行说明。

接下来的一段代码如下:

  1. iou_predict_truth = self.calc_iou(predict_boxes_tran, boxes) #计算IOU, 其格式为: [batch_size, 7, 7, 2]
  2. # calculate I tensor [BATCH_SIZE, CELL_SIZE, CELL_SIZE, BOXES_PER_CELL],获取obj(ij),第i个格子第j个bbox有obj
  3. object_mask = tf.reduce_max(iou_predict_truth, 3, keep_dims=True) # Computes the maximum of elements across dimensions of a tensor, 在第四个维度上,维度从0开始算
  4. object_mask = tf.cast(
  5. (iou_predict_truth >= object_mask), tf.float32) * response #其维度为[batch_size, 7, 7, 2] , 如果cell中真实有目标,那么该cell内iou最大的那个框的相应位置为1(就是负责预测该框),
  6. # 其余为0,使用response是因为可能会遇到object_mask中最大值为0的情况

iou_predict_truth 的定义是比较简单的,就是求预测框和实际框的 IOU

输出的结果 shape 为[batch_size, 7, 7, 2],就是求每个对应位置格子中的对应框的 IOU。

接着 object_mask 对上述的[batch_size, 7, 7, 2]个 IOU 按照 axis=3 取最大值,就是在7*7 个格子中的2 个对象中,找到与实际框IOU 最大的那一个,注意这里是去取值,而不是取索引。

后面的代码

  1. object_mask = tf.cast((iou_predict_truth >= object_mask), tf.float32) * response #其维度为[batch_size, 7, 7, 2] , 如果cell中真实有目标,那么该cell内iou最大的那个框的相应位置为1(就是负责预测该框),
  2. # 其余为0,使用response是因为可能会遇到object_mask中最大值为0的情况

这段代码的意思就是找到 77 格子中满足*两个以下条件的对象

(1) 该对象属于的框是 response 框,负责检测物体

(2) 该对象是所属框中的,与实际物体 IOU 比例较大的那个

这样我们获得了 object_mask,他的 shape 为[batch_size, 7, 7, 2],满足以上两个条件的框的位置为 1,其余为 0,说了这么多,这个就是我们公式中的

[转载]YOLO V1 深层解读与代码解析 - 图8

那么自然地,公式中的

[转载]YOLO V1 深层解读与代码解析 - 图9

是不满足上述两个条件的框的集合,定义如下:

  1. # calculate no_I tensor [CELL_SIZE, CELL_SIZE, BOXES_PER_CELL]
  2. noobject_mask = tf.ones_like(object_mask, dtype=tf.float32) - object_mask #其维度为[batch_size, 7 , 7, 2], 真实没有目标的区域都为1,真实有目标的区域为0

接下来就来我们最关键 LOSS 定义了。

参照着 LOSS 图(为了方便,这个图已经出来三次了)

[转载]YOLO V1 深层解读与代码解析 - 图10

这里有5 个 LOSS,我们将第一个 LOSS(边框中心误差)和第二个 LOSS(边框的宽度和高度误差)整合为一个 LOSS,程序如下:

  1. # 框坐标的损失,只计算有目标的cell中iou最大的那个框的损失,即用这个iou最大的框来负责预测这个框,其它不管,乘以0
  2. coord_mask = tf.expand_dims(object_mask, 4) # object_mask其维度为:[batch_size, 7, 7, 2], 扩展维度之后变成[batch_size, 7, 7, 2, 1]
  3. boxes_delta = coord_mask * (predict_boxes - boxes_tran) #predict_boxes维度为: [batch_size, 7, 7, 2, 4],这些框的坐标都是偏移值
  4. coord_loss = tf.reduce_mean( #平方差损失函数
  5. tf.reduce_sum(tf.square(boxes_delta), axis=[1, 2, 3, 4]),
  6. name='coord_loss') * self.coord_scale

这里的 predict_boxes 和 boxes_tran 是最后一个维度的信息是归一化的(x,y, [转载]YOLO V1 深层解读与代码解析 - 图11
[转载]YOLO V1 深层解读与代码解析 - 图12
),那么正好这两三行代码,直接解决了上面看似比较复杂的表达式。

上面 LOSS 图的第三个 LOSS,是置信度损失(框内有对象),代码是:

  1. object_delta = object_mask * (predict_scales - iou_predict_truth) #用iou_predict_truth替代真实的置信度,真的妙,佩服的5体投递,
  2. #仔细分析一下他的精妙之处,他的精妙之处就在于让他教网络去学习如何计算predict score
  3. object_loss = tf.reduce_mean( #平方差损失函数
  4. tf.reduce_sum(tf.square(object_delta), axis=[1, 2, 3]),
  5. name='object_loss') * self.object_scale

这里有个很妙的应用,就是用iou_predict_truth 替代真实的置信度。上面我们提到的 iou_predict_truth 是真实框和预测框(归一化)的 IOU。那么我们使用这个 IOU 去当作训练的目标,原因在哪儿呢?

我们这样做的目的很明显,我们就是尽可能使得置信度(框内有对象)接近这个 IOU,当然了,就是教 yolo 如何去学习计算置信度信息,结果就是yolo 学会了使用使用 IOU 当作置信度啦!你说妙不妙(当然这是我的想法,不知是否正确,说错了,记得评论告诉我)

置信度的第一个 LOSS 解决了,现在我们来看置信度的第二个 LOSS,就是图上的第三个 LOSS:置信度损失(框内无对象),代码是:

  1. # 没有目标的时候,置信度的损失函数,这里的predict_scales是(predict_scales-0)
  2. noobject_delta = noobject_mask * predict_scales
  3. noobject_loss = tf.reduce_mean( #平方差损失函数
  4. tf.reduce_sum(tf.square(noobject_delta), axis=[1, 2, 3]),
  5. name='noobject_loss') * self.noobject_scale

这个定义很简单,就不多说。

最后一个损失是分类损失,代码如下

  1. # class_loss, 计算类别的损失
  2. class_delta = response * (predict_classes - classes)
  3. class_loss = tf.reduce_mean( #平方差损失函数
  4. tf.reduce_sum(tf.square(class_delta), axis=[1, 2, 3]),
  5. name='class_loss') * self.class_scale # self.class_scale为损失函数前面的系数

这里使用平方差损失函数就好了,不用交叉熵啦!

说完损失函数,差不多这个 yolo 的核心就掌握了。关于 YOLO v1 的模型,其实看起来是比较普通的,各种博客帖子都有很好的说明,我们就不多说了。

写了 fasterRCNN 和 yolo v1 的总结,发现网络模型真的仅仅是一部分,如果能把损失函数 LOSS 设置合理可行,加上模型上的一些优势,真的效果就出来了。

大家下载代码后,可以进行 YOLO V1 的实战,你们会在实战中发现,还有很多问题(不使用 YOLO_small.ckpt),后面如果有空,我会出一个 yolo v1 的训练,但是这意义不大,能吃牛肉(yolo v3),谁还吃清道夫呢(yolo v1)?
https://zhuanlan.zhihu.com/p/89143061?from_voters_page=true