5.2.3 检测头设计(计算预测框位置和类别)
YOLOv3中对每个预测框计算逻辑如下:
- 预测框是否包含物体。也可理解为objectness=1的概率是多少,可以用网络输出一个实数 x,可以用 Sigmoid(x) 表示objectness为正的概率P_{obj}
- 预测物体位置和形状。物体位置和形状t_x, t_y, t_w, t_h 可以用网络输出4个实数来表示 t_x, t_y, t_w, t_h
- 预测物体类别。预测图像中物体的具体类别是什么,或者说其属于每个类别的概率分别是多少。总的类别数为C,需要预测物体属于每个类别的概率(P_1, P_2, ..., P_C) ,可以用网络输出C个实数(x_1, x_2, ..., x_C) ,对每个实数分别求Sigmoid函数,让P_i = Sigmoid(x_i) ,则可以表示出物体属于每个类别的概率。
对于一个预测框,网络需要输出(5+C)个实数来表征它是否包含物体、位置和形状尺寸以及属于每个类别的概率。
由于我们在每个小方块区域都生成了K个预测框,则所有预测框一共需要网络输出的预测值数目是:
[K(5+C)]×m×n
还有更重要的一点是网络输出必须要能区分出小方块区域的位置来,不能直接将特征图连接一个输出大小为[K(5+C)]×m×n的全连接层。
建立输出特征图与预测框之间的关联
现在观察特征图,经过多次卷积核池化之后,其步幅stride=32,640×480大小的输入图片变成了20×15的特征图;而小方块区域的数目正好是20×15,也就是说可以让特征图上每个像素点分别跟原图上一个小方块区域对应。这也是为什么我们最开始将小方块区域的尺寸设置为32的原因,这样可以巧妙的将小方块区域跟特征图上的像素点对应起来,解决了空间位置的对应关系。
图5:特征图C0与小方块区域形状对比
下面需要将像素点(i,j)与第i行第j列的小方块区域所需要的预测值关联起来,每个小方块区域产生K个预测框,每个预测框需要(5+C)个实数预测值,则每个像素点相对应的要有K(5+C)个实数。
为了解决这一问题,对特征图进行多次卷积,并将最终的输出通道数设置为K(5+C),即可将生成的特征图与每个预测框所需要的预测值巧妙的对应起来。当然,这种对应是为了将骨干网络提取的特征对接输出层来形成Loss。实际中,这几个尺寸可以随着任务数据分布的不同而调整,只要保证特征图输出尺寸(控制卷积核和下采样)和输出层尺寸(控制小方块区域的大小)相同即可。
骨干网络的输出特征图是C0,下面的程序是对C0进行多次卷积以得到跟预测框相关的特征图P0。
class YoloDetectionBlock(paddle.nn.Layer): # define YOLOv3 detection head # 使用多层卷积和BN提取特征 def __init__(self,ch_in,ch_out,is_test=True): super(YoloDetectionBlock, self).__init__() assert ch_out % 2 == 0, \ "channel {} cannot be divided by 2".format(ch_out) self.conv0 = ConvBNLayer( ch_in=ch_in, ch_out=ch_out, kernel_size=1, stride=1, padding=0) self.conv1 = ConvBNLayer( ch_in=ch_out, ch_out=ch_out*2, kernel_size=3, stride=1, padding=1) self.conv2 = ConvBNLayer( ch_in=ch_out*2, ch_out=ch_out, kernel_size=1, stride=1, padding=0) self.conv3 = ConvBNLayer( ch_in=ch_out, ch_out=ch_out*2, kernel_size=3, stride=1, padding=1) self.route = ConvBNLayer( ch_in=ch_out*2, ch_out=ch_out, kernel_size=1, stride=1, padding=0) self.tip = ConvBNLayer( ch_in=ch_out, ch_out=ch_out*2, kernel_size=3, stride=1, padding=1) def forward(self, inputs): out = self.conv0(inputs) out = self.conv1(out) out = self.conv2(out) out = self.conv3(out) route = self.route(out) tip = self.tip(route) return route, tip
NUM_ANCHORS = 3 NUM_CLASSES = 7 num_filters=NUM_ANCHORS * (NUM_CLASSES + 5) backbone = DarkNet53_conv_body() detection = YoloDetectionBlock(ch_in=1024, ch_out=512) conv2d_pred = paddle.nn.Conv2D(in_channels=1024, out_channels=num_filters, kernel_size=1) x = np.random.randn(1, 3, 640, 640).astype('float32') x = paddle.to_tensor(x) C0, C1, C2 = backbone(x) route, tip = detection(C0) P0 = conv2d_pred(tip) print(P0.shape)
[1, 36, 20, 20]
如上面的代码所示,可以由特征图C0生成特征图P0,P0的形状是[1,36,20,20]。每个小方块区域生成的锚框或者预测框的数量是3,物体类别数目是7,每个区域需要的预测值个数是3×(5+7)=36,正好等于P0的输出通道数。
将P0[t,0:12,i,j]与输入的第t张图片上小方块区域(i,j)第1个预测框所需要的12个预测值对应,P0[t,12:24,i,j]与输入的第t张图片上小方块区域(i,j)第2个预测框所需要的12个预测值对应,P0[t,24:36,i,j]与输入的第t张图片上小方块区域(i,j)第3个预测框所需要的12个预测值对应。
P0[t,0:4,i,j]与输入的第t张图片上小方块区域(i,j)第1个预测框的位置对应,P0[t,4,i,j]与输入的第t张图片上小方块区域(i,j)第1个预测框的objectness对应,P0[t,5:12,i,j]与输入的第t张图片上小方块区域(i, j)第1个预测框的类别对应。
如图6所示,通过这种方式可以巧妙的将网络输出特征图,与每个小方块区域生成的预测框对应起来了。
图6:特征图P0与候选区域的关联
计算预测框是否包含物体的概率
根据前面的分析,P0[t,4,i,j]与输入的第t张图片上小方块区域(i,j)第1个预测框的objectness对应,P0[t,4+12,i,j]与第2个预测框的objectness对应,...,则可以使用下面的程序将objectness相关的预测取出,并使用paddle.nn.functional.sigmoid
计算输出概率。
NUM_ANCHORS = 3 NUM_CLASSES = 7 num_filters=NUM_ANCHORS * (NUM_CLASSES + 5) backbone = DarkNet53_conv_body() detection = YoloDetectionBlock(ch_in=1024, ch_out=512) conv2d_pred = paddle.nn.Conv2D(in_channels=1024, out_channels=num_filters, kernel_size=1) x = np.random.randn(1, 3, 640, 640).astype('float32') x = paddle.to_tensor(x) C0, C1, C2 = backbone(x) route, tip = detection(C0) P0 = conv2d_pred(tip) reshaped_p0 = paddle.reshape(P0, [-1, NUM_ANCHORS, NUM_CLASSES + 5, P0.shape[2], P0.shape[3]]) pred_objectness = reshaped_p0[:, :, 4, :, :] pred_objectness_probability = F.sigmoid(pred_objectness) print(pred_objectness.shape, pred_objectness_probability.shape)
[1, 3, 20, 20]
上面的输出程序显示,预测框是否包含物体的概率pred_objectness_probability
,其数据形状是[1, 3, 20, 20],与我们上面提到的预测框个数一致,数据大小在0~1之间,表示预测框为正样本的概率。
计算预测框位置坐标
P0[t,0:4,i,j]与输入的第tt张图片上小方块区域(i,j)第1个预测框的位置对应,P0[t,12:16,i,j]与第2个预测框的位置对应,依此类推,则使用下面的程序可以从P0中取出跟预测框位置相关的预测值。
NUM_ANCHORS = 3 NUM_CLASSES = 7 num_filters=NUM_ANCHORS * (NUM_CLASSES + 5) backbone = DarkNet53_conv_body() detection = YoloDetectionBlock(ch_in=1024, ch_out=512) conv2d_pred = paddle.nn.Conv2D(in_channels=1024, out_channels=num_filters, kernel_size=1) x = np.random.randn(1, 3, 640, 640).astype('float32') x = paddle.to_tensor(x) C0, C1, C2 = backbone(x) route, tip = detection(C0) P0 = conv2d_pred(tip) reshaped_p0 = paddle.reshape(P0, [-1, NUM_ANCHORS, NUM_CLASSES + 5, P0.shape[2], P0.shape[3]]) pred_objectness = reshaped_p0[:, :, 4, :, :] pred_objectness_probability = F.sigmoid(pred_objectness) pred_location = reshaped_p0[:, :, 0:4, :, :] print(pred_location.shape)
[1, 3, 4, 20, 20]
网络输出值是(t_x, t_y, t_w, t_h),还需要将其转化为(x_1, y_1, x_2, y_2)这种形式的坐标表示。使用飞桨paddle.vision.ops.yolo_boxAPI可以直接计算出结果,但为了给读者更清楚的展示算法的实现过程,我们使用Numpy来实现这一过程。
In [ ]
# 定义Sigmoid函数 def sigmoid(x): return 1./(1.0 + np.exp(-x)) # 将网络特征图输出的[tx, ty, th, tw]转化成预测框的坐标[x1, y1, x2, y2] def get_yolo_box_xxyy(pred, anchors, num_classes, downsample): """ pred是网络输出特征图转化成的numpy.ndarray anchors 是一个list。表示锚框的大小, 例如 anchors = [116, 90, 156, 198, 373, 326],表示有三个锚框, 第一个锚框大小[w, h]是[116, 90],第二个锚框大小是[156, 198],第三个锚框大小是[373, 326] """ batchsize = pred.shape[0] num_rows = pred.shape[-2] num_cols = pred.shape[-1] input_h = num_rows * downsample input_w = num_cols * downsample num_anchors = len(anchors) // 2 # pred的形状是[N, C, H, W],其中C = NUM_ANCHORS * (5 + NUM_CLASSES) # 对pred进行reshape pred = pred.reshape([-1, num_anchors, 5+num_classes, num_rows, num_cols]) pred_location = pred[:, :, 0:4, :, :] pred_location = np.transpose(pred_location, (0,3,4,1,2)) anchors_this = [] for ind in range(num_anchors): anchors_this.append([anchors[ind*2], anchors[ind*2+1]]) anchors_this = np.array(anchors_this).astype('float32') # 最终输出数据保存在pred_box中,其形状是[N, H, W, NUM_ANCHORS, 4], # 其中最后一个维度4代表位置的4个坐标 pred_box = np.zeros(pred_location.shape) for n in range(batchsize): for i in range(num_rows): for j in range(num_cols): for k in range(num_anchors): pred_box[n, i, j, k, 0] = j pred_box[n, i, j, k, 1] = i pred_box[n, i, j, k, 2] = anchors_this[k][0] pred_box[n, i, j, k, 3] = anchors_this[k][1] # 这里使用相对坐标,pred_box的输出元素数值在0.~1.0之间 pred_box[:, :, :, :, 0] = (sigmoid(pred_location[:, :, :, :, 0]) + pred_box[:, :, :, :, 0]) / num_cols pred_box[:, :, :, :, 1] = (sigmoid(pred_location[:, :, :, :, 1]) + pred_box[:, :, :, :, 1]) / num_rows pred_box[:, :, :, :, 2] = np.exp(pred_location[:, :, :, :, 2]) * pred_box[:, :, :, :, 2] / input_w pred_box[:, :, :, :, 3] = np.exp(pred_location[:, :, :, :, 3]) * pred_box[:, :, :, :, 3] / input_h # 将坐标从xywh转化成xyxy pred_box[:, :, :, :, 0] = pred_box[:, :, :, :, 0] - pred_box[:, :, :, :, 2] / 2. pred_box[:, :, :, :, 1] = pred_box[:, :, :, :, 1] - pred_box[:, :, :, :, 3] / 2. pred_box[:, :, :, :, 2] = pred_box[:, :, :, :, 0] + pred_box[:, :, :, :, 2] pred_box[:, :, :, :, 3] = pred_box[:, :, :, :, 1] + pred_box[:, :, :, :, 3] pred_box = np.clip(pred_box, 0., 1.0) return pred_box
通过调用上面定义的get_yolo_box_xxyy
函数,可以从P0计算出预测框坐标来,具体程序如下:
NUM_ANCHORS = 3 NUM_CLASSES = 7 num_filters=NUM_ANCHORS * (NUM_CLASSES + 5) backbone = DarkNet53_conv_body() detection = YoloDetectionBlock(ch_in=1024, ch_out=512) conv2d_pred = paddle.nn.Conv2D(in_channels=1024, out_channels=num_filters, kernel_size=1) x = np.random.randn(1, 3, 640, 640).astype('float32') x = paddle.to_tensor(x) C0, C1, C2 = backbone(x) route, tip = detection(C0) P0 = conv2d_pred(tip) reshaped_p0 = paddle.reshape(P0, [-1, NUM_ANCHORS, NUM_CLASSES + 5, P0.shape[2], P0.shape[3]]) pred_objectness = reshaped_p0[:, :, 4, :, :] pred_objectness_probability = F.sigmoid(pred_objectness) pred_location = reshaped_p0[:, :, 0:4, :, :] # anchors包含了预先设定好的锚框尺寸 anchors = [116, 90, 156, 198, 373, 326] # downsample是特征图P0的步幅 pred_boxes = get_yolo_box_xxyy(P0.numpy(), anchors, num_classes=7, downsample=32) # 由输出特征图P0计算预测框位置坐标 print(pred_boxes.shape)
(1, 20, 20, 3, 4)
上面程序计算出来的pred_boxes的形状是[N,H,W,num_anchors,4] ,坐标格式是[x_1, y_1, x_2, y_2],数值在0~1之间,表示相对坐标。
计算物体属于每个类别概率
P0[t,5:12,i,j]与输入的第tt张图片上小方块区域(i,j)第1个预测框包含物体的类别对应,P0[t,17:24,i,j]与第2个预测框的类别对应,依此类推,则使用下面的程序可以从P0P0中取出那些跟预测框类别相关的预测值。
In [ ]
NUM_ANCHORS = 3 NUM_CLASSES = 7 num_filters=NUM_ANCHORS * (NUM_CLASSES + 5) backbone = DarkNet53_conv_body() detection = YoloDetectionBlock(ch_in=1024, ch_out=512) conv2d_pred = paddle.nn.Conv2D(in_channels=1024, out_channels=num_filters, kernel_size=1) x = np.random.randn(1, 3, 640, 640).astype('float32') x = paddle.to_tensor(x) C0, C1, C2 = backbone(x) route, tip = detection(C0) P0 = conv2d_pred(tip) reshaped_p0 = paddle.reshape(P0, [-1, NUM_ANCHORS, NUM_CLASSES + 5, P0.shape[2], P0.shape[3]]) # 取出与objectness相关的预测值 pred_objectness = reshaped_p0[:, :, 4, :, :] pred_objectness_probability = F.sigmoid(pred_objectness) # 取出与位置相关的预测值 pred_location = reshaped_p0[:, :, 0:4, :, :] # 取出与类别相关的预测值 pred_classification = reshaped_p0[:, :, 5:5+NUM_CLASSES, :, :] pred_classification_probability = F.sigmoid(pred_classification) print(pred_classification.shape)
[1, 3, 7, 20, 20]
上面的程序通过P0P0计算出了预测框包含的物体所属类别的概率,pred_classification_probability
的形状是[1,3,7,20,20][1, 3, 7, 20, 20],数值在0~1之间。
了解了检测模型的backbone、neck、head3个部分,接下来定义YOLOv3的模型,同时添加模型预测代码get_pred
,通过网络输出计算出预测框位置和所属类别的得分,推荐大家直接使用paddle.vision.ops.yolo_box获得P0、P1、P2三个层级的特征图对应的预测框和得分,并将他们拼接在一块,即可得到所有的预测框及其属于各个类别的得分,关键参数含义如下:
paddle.vision.ops.yolo_box(x, img_size, anchors, class_num, conf_thresh, downsample_ratio, clip_bbox=True, name=None, scale_x_y=1.0)
- x,网络输出特征图,例如上面提到的P0或者P1、P2。
- img_size,输入图片尺寸。
- anchors,使用到的anchor的尺寸,如[10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326]
- class_num,物体类别数。
- conf_thresh, 置信度阈值,得分低于该阈值的预测框位置数值不用计算直接设置为0.0。
- downsample_ratio, 特征图的下采样比例,例如P0是32,P1是16,P2是8。
- name=None,名字,例如'yolo_box',一般无需设置,默认值为None。
返回值包括两项,boxes和scores,其中boxes是所有预测框的坐标值,scores是所有预测框的得分。
预测框得分的定义是所属类别的概率乘以其预测框是否包含目标物体的objectness概率,即
score=Pobj⋅Pclassificationscore = P_{obj} \cdot P_{classification}
In [ ]
# 定义上采样模块 class Upsample(paddle.nn.Layer): def __init__(self, scale=2): super(Upsample,self).__init__() self.scale = scale def forward(self, inputs): # get dynamic upsample output shape shape_nchw = paddle.shape(inputs) shape_hw = paddle.slice(shape_nchw, axes=[0], starts=[2], ends=[4]) shape_hw.stop_gradient = True in_shape = paddle.cast(shape_hw, dtype='int32') out_shape = in_shape * self.scale out_shape.stop_gradient = True # reisze by actual_shape out = paddle.nn.functional.interpolate( x=inputs, scale_factor=self.scale, mode="NEAREST") return out class YOLOv3(paddle.nn.Layer): def __init__(self, num_classes=7): super(YOLOv3,self).__init__() self.num_classes = num_classes # 提取图像特征的骨干代码 self.block = DarkNet53_conv_body() self.block_outputs = [] self.yolo_blocks = [] self.route_blocks_2 = [] # 生成3个层级的特征图P0, P1, P2 for i in range(3): # 添加从ci生成ri和ti的模块 yolo_block = self.add_sublayer( "yolo_detecton_block_%d" % (i), YoloDetectionBlock( ch_in=512//(2**i)*2 if i==0 else 512//(2**i)*2 + 512//(2**i), ch_out = 512//(2**i))) self.yolo_blocks.append(yolo_block) num_filters = 3 * (self.num_classes + 5) # 添加从ti生成pi的模块,这是一个Conv2D操作,输出通道数为3 * (num_classes + 5) block_out = self.add_sublayer( "block_out_%d" % (i), paddle.nn.Conv2D(in_channels=512//(2**i)*2, out_channels=num_filters, kernel_size=1, stride=1, padding=0, weight_attr=paddle.ParamAttr( initializer=paddle.nn.initializer.Normal(0., 0.02)), bias_attr=paddle.ParamAttr( initializer=paddle.nn.initializer.Constant(0.0), regularizer=paddle.regularizer.L2Decay(0.)))) self.block_outputs.append(block_out) if i < 2: # 对ri进行卷积 route = self.add_sublayer("route2_%d"%i, ConvBNLayer(ch_in=512//(2**i), ch_out=256//(2**i), kernel_size=1, stride=1, padding=0)) self.route_blocks_2.append(route) # 将ri放大以便跟c_{i+1}保持同样的尺寸 self.upsample = Upsample() def forward(self, inputs): outputs = [] blocks = self.block(inputs) for i, block in enumerate(blocks): if i > 0: # 将r_{i-1}经过卷积和上采样之后得到特征图,与这一级的ci进行拼接 block = paddle.concat([route, block], axis=1) # 从ci生成ti和ri route, tip = self.yolo_blocks[i](block) # 从ti生成pi block_out = self.block_outputs[i](tip) # 将pi放入列表 outputs.append(block_out) if i < 2: # 对ri进行卷积调整通道数 route = self.route_blocks_2[i](route) # 对ri进行放大,使其尺寸和c_{i+1}保持一致 route = self.upsample(route) return outputs def get_pred(self, outputs, im_shape=None, anchors = [10, 13, 16, 30, 33, 23, 30, 61, 62, 45, 59, 119, 116, 90, 156, 198, 373, 326], anchor_masks = [[6, 7, 8], [3, 4, 5], [0, 1, 2]], valid_thresh = 0.01): downsample = 32 total_boxes = [] total_scores = [] for i, out in enumerate(outputs): anchor_mask = anchor_masks[i] anchors_this_level = [] for m in anchor_mask: anchors_this_level.append(anchors[2 * m]) anchors_this_level.append(anchors[2 * m + 1]) boxes, scores = paddle.vision.ops.yolo_box( x=out, img_size=im_shape, anchors=anchors_this_level, class_num=self.num_classes, conf_thresh=valid_thresh, downsample_ratio=downsample, name="yolo_box" + str(i)) total_boxes.append(boxes) total_scores.append( paddle.transpose( scores, perm=[0, 2, 1])) downsample = downsample // 2 yolo_boxes = paddle.concat(total_boxes, axis=1) yolo_scores = paddle.concat(total_scores, axis=2) return yolo_boxes, yolo_scores
5.2.4 损失函数
上面从概念上将输出特征图上的像素点与预测框关联起来了,那么要对神经网络进行求解,还必须从数学上将网络输出和预测框关联起来,也就是要建立起损失函数跟网络输出之间的关系。下面讨论如何建立起YOLOv3的损失函数。
对于每个预测框,YOLOv3模型会建立三种类型的损失函数:
- 表征是否包含目标物体的损失函数,通过pred_objectness和label_objectness计算。
loss_obj = paddle.nn.fucntional.binary_cross_entropy_with_logits(pred_objectness, label_objectness)
- 表征物体位置的损失函数,通过pred_location和label_location计算。
pred_location_x = pred_location[:, :, 0, :, :] pred_location_y = pred_location[:, :, 1, :, :] pred_location_w = pred_location[:, :, 2, :, :] pred_location_h = pred_location[:, :, 3, :, :] loss_location_x = paddle.nn.fucntional.binary_cross_entropy_with_logits(pred_location_x, label_location_x) loss_location_y = paddle.nn.fucntional.binary_cross_entropy_with_logits(pred_location_y, label_location_y) loss_location_w = paddle.abs(pred_location_w - label_location_w) loss_location_h = paddle.abs(pred_location_h - label_location_h) loss_location = loss_location_x + loss_location_y + loss_location_w + loss_location_h
- 表征物体类别的损失函数,通过pred_classification和label_classification计算。
loss_obj = paddle.nn.fucntional.binary_cross_entropy_with_logits(pred_classification, label_classification)
本节以上一节介绍的目标检测算法YOLOv3来完成病虫害检测任务为例,介绍一个更加通用的进行计算机视觉任务研发的全流程,主要涵盖如下内容:
- 通用的计算机视觉任务研发全流程:介绍通用的计算机视觉任务研发全流程。
- 林业病虫害数据集:介绍数据集结构及数据预处理方法。
- YOLOv3目标检测模型:介绍算法原理,及如何应用林业病虫害数据集进行模型训练和测试。