链接
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中:
def load_pascal_annotation(self, index): # Load image and bounding boxes info from XML file in the PASCAL VOC format
imname = os.path.join(self.data_path, 'JPEGImages', index + '.jpg') #data/pascal_voc/VOCdevkit/VOC2007/JPEGImages/index.jpg
im = cv2.imread(imname)
h_ratio = 1.0 * self.image_size / im.shape[0] # 448所占图片高度的比例
w_ratio = 1.0 * self.image_size / im.shape[1] # 448占图片宽度的比例
# im = cv2.resize(im, [self.image_size, self.image_size])
label = np.zeros((self.cell_size, self.cell_size, 5+len(self.classes))) #label数组维度 7*7*25, 一个cell只负责预测一个类别
filename = os.path.join(self.data_path, 'Annotations', index + '.xml') #data/pascal_voc/VOCdevkit/VOC2007/Annotations/index.xml
tree = ET.parse(filename) #解析xml文件
objs = tree.findall('object') #找到index指向的该xml文件中的所有object
for obj in objs: #记录出xml文件中object框的位置
bbox = obj.find('bndbox')
# Make pixel indexes 0-based
x1 = max(min((float(bbox.find('xmin').text) - 1) * w_ratio, self.image_size - 1), 0)
y1 = max(min((float(bbox.find('ymin').text) - 1) * h_ratio, self.image_size - 1), 0)
x2 = max(min((float(bbox.find('xmax').text) - 1) * w_ratio, self.image_size - 1), 0)
y2 = max(min((float(bbox.find('ymax').text) - 1) * h_ratio, self.image_size - 1), 0)
cls_ind = self.class_to_ind[obj.find('name').text.lower().strip()] # class to index, matters
boxes = [(x2 + x1) / 2.0, (y2 + y1) / 2.0, x2 - x1, y2 - y1] # 将坐标(x1,y1,x2,y2)转变成(x_center,y_center,width,height)
x_ind = int(boxes[0] * self.cell_size / self.image_size) # 查看object的x_center落在哪个cell, 整张图片cell的数量为7*7
y_ind = int(boxes[1] * self.cell_size / self.image_size) # 查看object的y_center落在哪个cell, 整张图片cell的数量为7*7
if label[y_ind, x_ind, 0] == 1: #设置一个标记,看其是否被访问过,同时也表明这是个object
continue
label[y_ind, x_ind, 0] = 1 #设置标记,1是已经被访问过了,同时也表明这是个object
label[y_ind, x_ind, 1:5] = boxes #设置标记的框,框的形式为 ( x_center, y_center, width, height)
label[y_ind, x_ind, 5 + cls_ind] = 1 #标记类别,pascal_voc数据集一共有20个类,哪个类是哪个,则在响应的位置上的index是1
return label, len(objs) #返回label,以及该index文件中object的数量
输出的 label 是一个(7,7,25)数组(这里假设类别数为 20)。(我们 YOLO v1 网络的最后输出的 shape 是(7,7,30),这里好像尺寸不匹配耶,先不管,后面说明)
这里 label 是宽 7,高 7,深度为 25 的数组,沿着深度进行解析,根据代码
x_ind = int(boxes[0] * self.cell_size / self.image_size) # 查看object的x_center落在哪个cell, 整张图片cell的数量为7*7
y_ind = int(boxes[1] * self.cell_size / self.image_size) # 查看object的y_center落在哪个cell, 整张图片cell的数量为7*7
if label[y_ind, x_ind, 0] == 1: #设置一个标记,看其是否被访问过,同时也表明这是个object
continue
label[y_ind, x_ind, 0] = 1 #设置标记,1是已经被访问过了,同时也表明这是个object
可以看出,深度(axis=2) 索引为 0(第一个)处的值是根据 gt_boxes 的中心是否落在这个方格内部的标识(方格是图片 77 后 的每一格),也就是这个方格负不负责对这个物体(框)进行检测(response),如果落在当中,该处的值就是 1,否则为 0。这样做的意图就是:*防止出现多个格子争抢对同一个物体的检测权。
那么这么一处理,共 7*7 的格子,深度索引为 0 处的值就确定了。
下面举个例子(图是上面链接中的图):
图中有三个物体:这三个物体的中心分别落在 77 格子中的(1,4),(2,3),(5,1),*索引从 0 开始。
那么这张图片通过 pascal_voc.py 后生成 label 的第一层就是一个(7,7,1)的数组,其中(1,4),(2,3),(5,1)处的值为 1,其余的都是 0。
从代码中的
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。
代码中
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 的网络了,代码如下
def build_network(self, #用slim构建网络,简单高效
images,
num_outputs,
alpha,
keep_prob=0.5,
is_training=True,
scope='yolo'):
with tf.variable_scope(scope):
with slim.arg_scope(
[slim.conv2d, slim.fully_connected], #卷积层加上全连接层
activation_fn=leaky_relu(alpha), #用的是leaky_relu激活函数
weights_regularizer=slim.l2_regularizer(0.0005), #L2正则化,防止过拟合
weights_initializer=tf.truncated_normal_initializer(0.0, 0.01) #权重初始化
):
#这里先执行填充操作
# t = [[2, 3, 4], [5, 6, 7]], paddings = [[1, 1], [2, 2]],mode = "CONSTANT"
#
# 那么sess.run(tf.pad(t, paddings, "CONSTANT"))
# 的输出结果为:
#
# array([[0, 0, 0, 0, 0, 0, 0],
# [0, 0, 2, 3, 4, 0, 0],
# [0, 0, 5, 6, 7, 0, 0],
# [0, 0, 0, 0, 0, 0, 0]], dtype=int32)
#
# 可以看到,上,下,左,右分别填充了1, 1, 2, 2
# 行刚好和paddings = [[1, 1], [2, 2]]
# 相等,零填充
#因为这里有4维,batch和channel维没有填充,只填充了image_height,image_width这两个维度,0填充
net = tf.pad(
images, np.array([[0, 0], [3, 3], [3, 3], [0, 0]]),
name='pad_1')
net = slim.conv2d(
net, 64, 7, 2, padding='VALID', scope='conv_2') #这里的64是指卷积核个数,7是指卷积核的高度和宽度,2是指步长,valid表示没有填充
net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_3') #max_pool, 大小2*2, stride:2
net = slim.conv2d(net, 192, 3, scope='conv_4') #这里的192是指卷积核的个数,3是指卷积核的高度和宽度,默认的步长为1
net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_5') #max_pool,大小为2*2,strides:2
net = slim.conv2d(net, 128, 1, scope='conv_6') #128个卷积核,大小为1*1,默认步长为1
net = slim.conv2d(net, 256, 3, scope='conv_7') #256个卷积核,大小为3*3,默认步长为1
net = slim.conv2d(net, 256, 1, scope='conv_8') #256个卷积核,大小为1*1,默认步长为1
net = slim.conv2d(net, 512, 3, scope='conv_9') #512个卷积核,大小为3*3,默认步长为3
net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_10') #max_pool, 大小为2*2,stride:2
net = slim.conv2d(net, 256, 1, scope='conv_11') #256个卷积核,大小为1*1, 默认步长为1
net = slim.conv2d(net, 512, 3, scope='conv_12') #512个卷积核,大小为3*3,默认步长为1
net = slim.conv2d(net, 256, 1, scope='conv_13') #256个卷积核,大小为1*1, 默认步长为1
net = slim.conv2d(net, 512, 3, scope='conv_14') #512个卷积核,大小为3*3, 默认步长为1
net = slim.conv2d(net, 256, 1, scope='conv_15') #256个卷积核,大小为1*1, 默认步长为1
net = slim.conv2d(net, 512, 3, scope='conv_16') #512个卷积核,大小为3*3, 默认步长为1
net = slim.conv2d(net, 256, 1, scope='conv_17') #256个卷积核,大小为1*1, 默认步长为1
net = slim.conv2d(net, 512, 3, scope='conv_18') #512个卷积核,大小为3*3, 默认步长为1
net = slim.conv2d(net, 512, 1, scope='conv_19') #256个卷积核,大小为1*1, 默认步长为1
net = slim.conv2d(net, 1024, 3, scope='conv_20') #1024个卷积核,大小为3*3,默认步长为1
net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_21') # max_pool, 大小为2*2,strides: 2
net = slim.conv2d(net, 512, 1, scope='conv_22') #512卷积核,大小为1*1,默认步长为1
net = slim.conv2d(net, 1024, 3, scope='conv_23') #1024卷积核,大小为3*3,默认步长1
net = slim.conv2d(net, 512, 1, scope='conv_24') #512卷积核,大小为1*1,默认步长1
net = slim.conv2d(net, 1024, 3, scope='conv_25') #1024卷积核,大小为3*3, 默认步长为1
net = slim.conv2d(net, 1024, 3, scope='conv_26') #1024卷积核,大小为3*3,默认步长为1
net = tf.pad(
net, np.array([[0, 0], [1, 1], [1, 1], [0, 0]]),
name='pad_27') #padding, 第一个维度batch和第四个维度channels不用管,只padding卷积核的高度和宽度
net = slim.conv2d(
net, 1024, 3, 2, padding='VALID', scope='conv_28') #1024卷积核,大小3*3,步长为2
net = slim.conv2d(net, 1024, 3, scope='conv_29') #1024卷积核,大小为3*3,默认步长为1
net = slim.conv2d(net, 1024, 3, scope='conv_30') #1024卷积核,大小为3*3,默认步长为1
net = tf.transpose(net, [0, 3, 1, 2], name='trans_31') #转置,由[batch, image_height,image_width,channels]变成[bacth, channels, image_height,image_width]
net = slim.flatten(net, scope='flat_32') #将输入扁平化,但保留batch_size, 假设第一位是batch,实际上第一维也是batch
net = slim.fully_connected(net, 512, scope='fc_33') #全连接层,神经元个数
net = slim.fully_connected(net, 4096, scope='fc_34') #全连接层,神经元个数
net = slim.dropout( #dropout,防止过拟合
net, keep_prob=keep_prob, is_training=is_training,
scope='dropout_35')
net = slim.fully_connected( #全连接层
net, num_outputs, activation_fn=None, scope='fc_36')
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 来的,别介意)
我们可以看出,总共有5 个 loss,每个 loss 参与计算损失的参数都不一样。我们需要提取 predict 和 labels 中对应的参数来计算每一个 loss。
所以首先应该对 predict 和 labels 进行处理,使他们中的求解 loss 的参数能够对应起来。以下代码就是做了一件这样的事
with tf.variable_scope(scope):
predict_classes = tf.reshape( #reshape一下,每个cell一个框,变成[batch_size, 7, 7, 20]
predicts[:, :self.boundary1],
[self.batch_size, self.cell_size, self.cell_size, self.num_class])
predict_scales = tf.reshape( #reshape一下,7*7*20 ~ 7*7*22, 就是分别找到每个cell的两个框的置信度,这里是两个框,可自定义,变成[batch_size, 7, 7, 2]
predicts[:, self.boundary1:self.boundary2],
[self.batch_size, self.cell_size, self.cell_size, self.boxes_per_cell])
predict_boxes = tf.reshape( #reshape,就是分别找到每个cell中两个框的坐标(x_center, y_center, w, h),这里是两个框,可自定义, 变成[batch_size, 7, 7, 2, 4]
predicts[:, self.boundary2:], #7 * 7 * 22 ~ 7 * 7 * 30,
[self.batch_size, self.cell_size, self.cell_size, self.boxes_per_cell, 4])
#下面是对label部分进行reshape
response = tf.reshape(
labels[..., 0],
[self.batch_size, self.cell_size, self.cell_size, 1]) #reshape, 就是查看哪个cell负责标记object,是的话就为1 ,否则是0 ,维度形式:[batch_size, 7, 7, 1]
boxes = tf.reshape(
labels[..., 1:5],
[self.batch_size, self.cell_size, self.cell_size, 1, 4]) #找到这个cell负责的框的位置,其形式为:(x_center,y_center,width,height), 其维度为:[batch_size, 7, 7, 1, 4]
boxes = tf.tile(
boxes, [1, 1, 1, self.boxes_per_cell, 1]) / self.image_size # tile() 平铺之意,用于在同一维度上的复制, 变成[batch_size, 7, 7, 2, 4], 除以image_size就是得到相对于整张图片的比例
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)
这里我们注意一点:
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 的设置。
offset = tf.reshape(
tf.constant(self.offset, dtype=tf.float32),
[1, self.cell_size, self.cell_size, self.boxes_per_cell]) #由7*7*2 reshape成 1*7*7*2
offset = tf.tile(offset, [self.batch_size, 1, 1, 1]) #在第一个维度上进行复制,变成 [batch_size, 7, 7,2]
offset_tran = tf.transpose(offset, (0, 2, 1, 3)) #维度为[batch_size, 7, 7, 2]
这里出现了个 self.offset, 我们看看这是一个什么。
self.offset = np.transpose(np.reshape(np.array( #reshape之后再转置,变成7*7*2的三维数组
[np.arange(self.cell_size)] * self.cell_size * self.boxes_per_cell),
(self.boxes_per_cell, self.cell_size, self.cell_size)), (1, 2, 0))
嗯,这一段代码看似简短,实际上还是有点绕的,我写了另一篇文章,大家可以看一下里面的解释。
其实这段代码的输出,就是
#offset
# array([[[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
# [5, 5],
# [6, 6]],
#
# [[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
# [5, 5],
# [6, 6]],
#
# [[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
# [5, 5],
# [6, 6]],
#
# [[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
# [5, 5],
# [6, 6]],
#
# [[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
# [5, 5],
# [6, 6]],
#
# [[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
# [5, 5],
# [6, 6]],
#
# [[0, 0],
# [1, 1],
# [2, 2],
# [3, 3],
# [4, 4],
# [5, 5],
# [6, 6]]])
以上就是 self.offset 的值
那么前一段代码,
offset = tf.reshape(
tf.constant(self.offset, dtype=tf.float32),
[1, self.cell_size, self.cell_size, self.boxes_per_cell]) #由7*7*2 reshape成 1*7*7*2
offset = tf.tile(offset, [self.batch_size, 1, 1, 1]) #在第一个维度上进行复制,变成 [batch_size, 7, 7,2]
offset_tran = tf.transpose(offset, (0, 2, 1, 3)) #维度为[batch_size, 7, 7, 2]
offset_tran 的输出就是
#offset_tran如下,只不过batch_size=1
# [[[[0. 0.]
# [0. 0.]
# [0. 0.]
# [0. 0.]
# [0. 0.]
# [0. 0.]
# [0. 0.]]
#
# [[1. 1.]
# [1. 1.]
# [1. 1.]
# [1. 1.]
# [1. 1.]
# [1. 1.]
# [1. 1.]]
#
# [[2. 2.]
# [2. 2.]
# [2. 2.]
# [2. 2.]
# [2. 2.]
# [2. 2.]
# [2. 2.]]
#
# [[3. 3.]
# [3. 3.]
# [3. 3.]
# [3. 3.]
# [3. 3.]
# [3. 3.]
# [3. 3.]]
#
# [[4. 4.]
# [4. 4.]
# [4. 4.]
# [4. 4.]
# [4. 4.]
# [4. 4.]
# [4. 4.]]
#
# [[5. 5.]
# [5. 5.]
# [5. 5.]
# [5. 5.]
# [5. 5.]
# [5. 5.]
# [5. 5.]]
#
# [[6. 6.]
# [6. 6.]
# [6. 6.]
# [6. 6.]
# [6. 6.]
# [6. 6.]
# [6. 6.]]]]
#
获取了 offset 和 offset_trans 后,我们看下一段代码,
predict_boxes_tran = tf.stack( #相对于整张特征图来说,找到相对于特征图大小的中心点,和宽度以及高度的开方, 其格式为[batch_size, 7, 7, 2, 4]
[(predict_boxes[..., 0] + offset) / self.cell_size, #self.cell=7,predict_boxes[..., 0]的shape为(1,7,7,2,1),是x离他的左上角的偏移量
(predict_boxes[..., 1] + offset_tran) / self.cell_size, #predict_boxes[..., 1]的shape为(1,7,7,2,1),是y离他的左上角的偏移量
tf.square(predict_boxes[..., 2]), #predict_boxes[..., 2]是预测的宽度的均方,这里求平方,就是获取宽度原值,相对于整个图片的大小
tf.square(predict_boxes[..., 3])], axis=-1) #获得高度的原值
解说这段代码之前,必须要补充点知识,就是关于predict_boxes 的输出,我们知道 predict_boxes 的输出是网络前向传播后预测的候选框。固定思维让我们认为,predict_boxes 的值就是类似 gt_box 坐标那样的(x,y,d,h)坐标。错!保持这个固有的思维,这段代码就无法看懂了,我也是不断推测的,才知道实际上道 predict_boxes 各个坐标的含义。
predict_boxes 中心坐标真实含义
其实 predict_boxes 中的前两位,就是中心点坐标(x,y)代表的含义如上图,是 predict_boxes中心坐标离所属格子(response)左上角的坐标。而 predict_boxes 中的后两位,其实并不是 predict_boxes 的宽度高度,而是 predict_boxes 的宽度高度相对于图片的大小(归一化后)的开方。
那么我们所说的输入 predict 中包含的坐标信息,就不是
(中心横坐标,
中心纵坐标,
宽,
高)
而是
(中心横坐标离所属方格左上角坐标的横向距离(假设每个方格宽度为1),
中心纵坐标离所属方格左上角坐标的纵向距离(假设每个方格高度为1),
宽度(归一化)的开方,
高度(归一化)的开方)
这里理解了,后面理解起来就很 easy 了。
代码
predict_boxes_tran = tf.stack( #相对于整张特征图来说,找到相对于特征图大小的中心点,和宽度以及高度的开方, 其格式为[batch_size, 7, 7, 2, 4]
[(predict_boxes[..., 0] + offset) / self.cell_size, #self.cell=7,predict_boxes[..., 0]的shape为(1,7,7,2,1),是x离他的左上角的偏移量
(predict_boxes[..., 1] + offset_tran) / self.cell_size, #predict_boxes[..., 1]的shape为(1,7,7,2,1),是y离他的左上角的偏移量
tf.square(predict_boxes[..., 2]), #predict_boxes[..., 2]是预测的宽度的均方,这里求平方,就是获取宽度原值,相对于整个图片的大小
tf.square(predict_boxes[..., 3])], axis=-1) #获得高度的原值
中的
(predict_boxes[..., 0] + offset) / self.cell_size,
(predict_boxes[..., 1] + offset_tran) / self.cell_size,
就是将 predict_boxes 的中心坐标转换为相对于整张图来说的(x,y)中心坐标。
还是用上面的图来说明
我们标出了 response 格子对应的offset x和offset 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, 相当于对中心坐标进行了归一化,
tf.square(predict_boxes[..., 2]),
tf.square(predict_boxes[..., 3])],
就是将原来的宽度(归一化)的开方和高度(归一化)的开方恢复成:(宽度(归一化),高度(归一化)),那么 predict_bbox 中的坐标信息,全部通过这段代码,恢复成了和labels中坐标相同格式的了,难道不是很妙嘛?
说到这里了,接下来我们就准备分析下 loss 的代码了,我们仔细分析下,
根据下面的 loss 式子,看看我们还缺少什么没有进行说明。
上面公式中,置信度损失的
和
我们还没有进行说明。
接下来的一段代码如下:
iou_predict_truth = self.calc_iou(predict_boxes_tran, boxes) #计算IOU, 其格式为: [batch_size, 7, 7, 2]
# calculate I tensor [BATCH_SIZE, CELL_SIZE, CELL_SIZE, BOXES_PER_CELL],获取obj(ij),第i个格子第j个bbox有obj
object_mask = tf.reduce_max(iou_predict_truth, 3, keep_dims=True) # Computes the maximum of elements across dimensions of a tensor, 在第四个维度上,维度从0开始算
object_mask = tf.cast(
(iou_predict_truth >= object_mask), tf.float32) * response #其维度为[batch_size, 7, 7, 2] , 如果cell中真实有目标,那么该cell内iou最大的那个框的相应位置为1(就是负责预测该框),
# 其余为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 最大的那一个,注意这里是去取值,而不是取索引。
后面的代码
object_mask = tf.cast((iou_predict_truth >= object_mask), tf.float32) * response #其维度为[batch_size, 7, 7, 2] , 如果cell中真实有目标,那么该cell内iou最大的那个框的相应位置为1(就是负责预测该框),
# 其余为0,使用response是因为可能会遇到object_mask中最大值为0的情况
这段代码的意思就是找到 77 格子中满足*两个以下条件的对象
(1) 该对象属于的框是 response 框,负责检测物体
(2) 该对象是所属框中的,与实际物体 IOU 比例较大的那个
这样我们获得了 object_mask,他的 shape 为[batch_size, 7, 7, 2],满足以上两个条件的框的位置为 1,其余为 0,说了这么多,这个就是我们公式中的
。
那么自然地,公式中的
是不满足上述两个条件的框的集合,定义如下:
# calculate no_I tensor [CELL_SIZE, CELL_SIZE, BOXES_PER_CELL]
noobject_mask = tf.ones_like(object_mask, dtype=tf.float32) - object_mask #其维度为[batch_size, 7 , 7, 2], 真实没有目标的区域都为1,真实有目标的区域为0
接下来就来我们最关键 LOSS 定义了。
参照着 LOSS 图(为了方便,这个图已经出来三次了)
这里有5 个 LOSS,我们将第一个 LOSS(边框中心误差)和第二个 LOSS(边框的宽度和高度误差)整合为一个 LOSS,程序如下:
# 框坐标的损失,只计算有目标的cell中iou最大的那个框的损失,即用这个iou最大的框来负责预测这个框,其它不管,乘以0
coord_mask = tf.expand_dims(object_mask, 4) # object_mask其维度为:[batch_size, 7, 7, 2], 扩展维度之后变成[batch_size, 7, 7, 2, 1]
boxes_delta = coord_mask * (predict_boxes - boxes_tran) #predict_boxes维度为: [batch_size, 7, 7, 2, 4],这些框的坐标都是偏移值
coord_loss = tf.reduce_mean( #平方差损失函数
tf.reduce_sum(tf.square(boxes_delta), axis=[1, 2, 3, 4]),
name='coord_loss') * self.coord_scale
这里的 predict_boxes 和 boxes_tran 是最后一个维度的信息是归一化的(x,y,
,
),那么正好这两三行代码,直接解决了上面看似比较复杂的表达式。
上面 LOSS 图的第三个 LOSS,是置信度损失(框内有对象),代码是:
object_delta = object_mask * (predict_scales - iou_predict_truth) #用iou_predict_truth替代真实的置信度,真的妙,佩服的5体投递,
#仔细分析一下他的精妙之处,他的精妙之处就在于让他教网络去学习如何计算predict score
object_loss = tf.reduce_mean( #平方差损失函数
tf.reduce_sum(tf.square(object_delta), axis=[1, 2, 3]),
name='object_loss') * self.object_scale
这里有个很妙的应用,就是用iou_predict_truth 替代真实的置信度。上面我们提到的 iou_predict_truth 是真实框和预测框(归一化)的 IOU。那么我们使用这个 IOU 去当作训练的目标,原因在哪儿呢?
我们这样做的目的很明显,我们就是尽可能使得置信度(框内有对象)接近这个 IOU,当然了,就是教 yolo 如何去学习计算置信度信息,结果就是yolo 学会了使用使用 IOU 当作置信度啦!你说妙不妙(当然这是我的想法,不知是否正确,说错了,记得评论告诉我)
置信度的第一个 LOSS 解决了,现在我们来看置信度的第二个 LOSS,就是图上的第三个 LOSS:置信度损失(框内无对象),代码是:
# 没有目标的时候,置信度的损失函数,这里的predict_scales是(predict_scales-0)
noobject_delta = noobject_mask * predict_scales
noobject_loss = tf.reduce_mean( #平方差损失函数
tf.reduce_sum(tf.square(noobject_delta), axis=[1, 2, 3]),
name='noobject_loss') * self.noobject_scale
这个定义很简单,就不多说。
最后一个损失是分类损失,代码如下
# class_loss, 计算类别的损失
class_delta = response * (predict_classes - classes)
class_loss = tf.reduce_mean( #平方差损失函数
tf.reduce_sum(tf.square(class_delta), axis=[1, 2, 3]),
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