yolo系列是第一个单阶段的目标检测网络,与RCNN系列算法(二阶段,即两个网络分别对图片的分类问题,定位问题进行预测)具有本质的区别。同时,由于其单网络的特性,所以训练与预测速度会加快很多,可以实现实时的目标检测任务~
代码链接:https://github.com/hizhangp/yolo_tensorflow
参考:
https://blog.csdn.net/weixin_42278173/article/details/81778217
https://blog.csdn.net/c20081052/article/details/80236015
https://blog.csdn.net/weixin_40092412/article/details/90731258
https://zhuanlan.zhihu.com/p/89143061
YOLO的核心思想:利用整张图作为网络的输入,直接在输出层回归bounding box的位置和bounding box所属的类别。(faster rcnn虽然也是将整张图片进行卷积,但是faster-RCNN整体还是采用了RCNN那种 proposal+classifier的思想,只不过是将提取proposal的步骤放在CNN中实现了)
def image_read(self, imname, flipped=False):
image = cv2.imread(imname)
image = cv2.resize(image, (self.image_size, self.image_size))
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB).astype(np.float32)
image = (image / 255.0) * 2.0 - 1.0
if flipped:
image = image[:, ::-1, :]
return image
参考:https://blog.csdn.net/weixin_40092412/article/details/90731258
下面,就上述讲述,进行计算:
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')
im = cv2.imread(imname)
h_ratio = 1.0 * self.image_size / im.shape[0]
w_ratio = 1.0 * self.image_size / im.shape[1]
# im = cv2.resize(im, [self.image_size, self.image_size])
label = np.zeros((self.cell_size, self.cell_size, 25))
filename = os.path.join(self.data_path, 'Annotations', index + '.xml')
tree = ET.parse(filename)
objs = tree.findall('object')
for obj in objs:
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()]
boxes = [(x2 + x1) / 2.0, (y2 + y1) / 2.0, x2 - x1, y2 - y1]
#判断gt bbox落在哪个grid cell中
x_ind = int(boxes[0] * self.cell_size / self.image_size)
y_ind = int(boxes[1] * self.cell_size / self.image_size)
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)
Yolo采用卷积网络来提取特征,然后使用全连接层来得到预测值。网络结构参考GooLeNet模型。YOLO检测层包含24个卷积层和2个全连接层。
上图是YOLO V1的网络结构,可以看到其实它只使用了最高层的feature map,所以来说,它没有利用底层的feature map,网络很大程度上丢失了信息,从而预测效果低于使用resnet等特征融合网络结构。
def build_network(self,
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),
weights_regularizer=slim.l2_regularizer(0.0005),
weights_initializer=tf.truncated_normal_initializer(0.0, 0.01)
):
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')
net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_3')
net = slim.conv2d(net, 192, 3, scope='conv_4')
net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_5')
net = slim.conv2d(net, 128, 1, scope='conv_6')
net = slim.conv2d(net, 256, 3, scope='conv_7')
net = slim.conv2d(net, 256, 1, scope='conv_8')
net = slim.conv2d(net, 512, 3, scope='conv_9')
net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_10')
net = slim.conv2d(net, 256, 1, scope='conv_11')
net = slim.conv2d(net, 512, 3, scope='conv_12')
net = slim.conv2d(net, 256, 1, scope='conv_13')
net = slim.conv2d(net, 512, 3, scope='conv_14')
net = slim.conv2d(net, 256, 1, scope='conv_15')
net = slim.conv2d(net, 512, 3, scope='conv_16')
net = slim.conv2d(net, 256, 1, scope='conv_17')
net = slim.conv2d(net, 512, 3, scope='conv_18')
net = slim.conv2d(net, 512, 1, scope='conv_19')
net = slim.conv2d(net, 1024, 3, scope='conv_20')
net = slim.max_pool2d(net, 2, padding='SAME', scope='pool_21')
net = slim.conv2d(net, 512, 1, scope='conv_22')
net = slim.conv2d(net, 1024, 3, scope='conv_23')
net = slim.conv2d(net, 512, 1, scope='conv_24')
net = slim.conv2d(net, 1024, 3, scope='conv_25')
net = slim.conv2d(net, 1024, 3, scope='conv_26')
net = tf.pad(
net, np.array([[0, 0], [1, 1], [1, 1], [0, 0]]),
name='pad_27')
net = slim.conv2d(
net, 1024, 3, 2, padding='VALID', scope='conv_28')
net = slim.conv2d(net, 1024, 3, scope='conv_29')
net = slim.conv2d(net, 1024, 3, scope='conv_30')
net = tf.transpose(net, [0, 3, 1, 2], name='trans_31')
net = slim.flatten(net, scope='flat_32')
net = slim.fully_connected(net, 512, scope='fc_33')
net = slim.fully_connected(net, 4096, scope='fc_34')
net = slim.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
tf.pad用法:
import tensorflow as tf
a = tf.constant(value=[[1,2,3],[2,3,4]],
dtype=tf.float32,
shape=None,
name="Const",
verify_shape=False)
sess = tf.InteractiveSession()
re = tf.pad(tensor=a, #需要填充的tensor张量
paddings=[[1,1],[2,2]], #上下左右需要填充的大小,格式为[[上,下],[左,右]]
mode="CONSTANT", #填充方式
name=None, #节点名称
constant_values=0 #常量填充数值)
print(sess.run(re))
结果:
[[0. 0. 0. 0. 0. 0. 0.]
[0. 0. 1. 2. 3. 0. 0.]
[0. 0. 2. 3. 4. 0. 0.]
[0. 0. 0. 0. 0. 0. 0.]]
最后经过Goolge net得到的feature map大小为(1470)
损失函数需要两个输入,一个是predict,就是网络前向的结果(和图片分类的logit是类似的),另一个是label,就是从原图中获取的相关信息。
参考:https://zhuanlan.zhihu.com/p/89143061
我们可以看出,总共有5个loss,每个loss参与计算损失的参数都不一样。我们需要提取predict和labels中对应的参数来计算每一个loss。所以首先应该对predict和labels进行处理,使他们中的求解loss的参数能够对应起来。以下代码就是做了一件这样的事:
输入参数:
(1)predicts:Tensor(“yolo/fc_36/BiasAdd:0”, shape=(?, 1470), dtype=float32)
(2)labels:Tensor(“Placeholder:0”, shape=(?, 7, 7, 25), dtype=float32)
疑问:为什么这里7725 != 1470??(后面会有解释的)
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)7 * 7个grid cell对应的坐标信息(7 * 7 * 2 * 4)= 392,
(2)类别信息(7 * 7 * 20)= 980,
(3)框的置信度信息(7 * 7 * 2)= 98,
相加之后:392 + 980 + 98 = 1470,正好等于最后一层的feature map的维度大小
提取了labels中:
(1) response信息(7 * 7 * 1)
(2) boxes信息(7 * 7 * 4—>7 * 7 * 4 * 2,这里为了和predict中的坐标信息对应,使用了tile函数)
(3) 分类信息(7 * 7 * 20)
注意:
boxes = tf.tile(boxes, [1, 1, 1, self.boxes_per_cell, 1]) / self.image_size
这一行代码是将第4维的数据进行复制一份,这样的话,正好就解决了上述疑问:为什么这里7725 != 1470?这一行代码的目的就是将label中的box格式转换为与predict中box对应的格式。
Gt_box除以图片大小,意思就是将gt_box做了归一化,框的信息限制在了(0,1)中。
我们对predict和labels做了处理后,现在我们思考下,既然我们已经对了labels中的坐标信息做了归一化,那么predict输出框信息应当也需要做归一化,这样在相同的空间中,我们才能求两者之间的IOU了。
offset = tf.reshape(
tf.constant(self.offset, dtype=tf.float32),
[1, self.cell_size, self.cell_size, self.boxes_per_cell]) #(1,7,7,2)
offset = tf.tile(offset, [self.batch_size, 1, 1, 1])
offset_tran = tf.transpose(offset, (0, 2, 1, 3)) #(1,7,7,2)
这一段代码应该是整个项目代码中最复杂的地方了,很绕,但是好在看到了大佬对这部分的讲解,顿时茅厕顿开啊!,此处必须给链接:
https://zhuanlan.zhihu.com/p/87870736
首先,先搞清楚offset是什么?
np.transpose(np.reshape(np.array(
[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是:
#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]]])
它的shape为(7,7,2),那么经过reshape,tile,transpose,最终得到的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.]]]]
#
它的shape为[batch_size, 7, 7, 2]。
predict_boxes_tran = tf.stack(
[(predict_boxes[..., 0] + offset) / self.cell_size,
(predict_boxes[..., 1] + offset_tran) / self.cell_size,
tf.square(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中的前两位,就是中心点坐标(x,y)代表的含义如上图,是predict_boxes中心坐标离所属格子(response)左上角的坐标。而predict_boxes中的后两位,其实并不是predict_boxes的宽度高度,而是predict_boxes的宽度高度相对于图片的大小(归一化后)的开方。
那么我们所说的输入predict中包含的坐标信息,就不是
(中心横坐标,
中心纵坐标,
宽,
高)
而是
(中心横坐标离所属方格左上角坐标的横向距离(假设每个方格宽度为1),
中心纵坐标离所属方格左上角坐标的纵向距离(假设每个方格高度为1),
宽度(归一化)的开方,
高度(归一化)的开方)
这里理解了,后面理解起来就很easy了。
其中,
(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中坐标相同格式的了,难道不是很妙嘛?
最后,得到的predict_boxes_tran 的shape为:
(batch_size,7,7,2,4),其中最后一维的4已经经过了上述的转换之后得到:(相对于最后一层7 * 7的feature map的左上角坐标的中心点位置xy(已经进行归一化操作),相对于最后一层7 * 7的feature map的长宽的预测框的长宽(已经进行归一化操作))。
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开始算
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的情况
这段代码的意思就是找到7*7格子中满足两个以下条件的对象:
(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
这里有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
上面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
第三个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为损失函数前面的系数
最后再加一句,最后loss部分基本上都是参考周威大佬的blog,写的确实好!再次膜拜!!