今天是参加昇思学习打卡营的第15天,学习内容是SSD目标检测。
以下是关键点概要:
-
模型简介:
- 介绍SSD算法的基本概念,包括它在目标检测领域的地位和性能。
-
SSD目标检测算法类型:
- 讨论了目标检测算法的两种主流类型:two-stage方法(如RCNN系列)和one-stage方法(如YOLO和SSD)。
-
模型结构:
- 详细描述了SSD的网络结构,包括使用VGG16作为基础模型,并在其上新增卷积层来获得更多的特征图。
-
模型特点:
- 讨论了SSD的主要特点,如多尺度检测、采用卷积进行检测和预设anchor。
-
环境准备:
- 指导如何在本地环境中安装MindSpore和其他必要的库。
-
数据准备与处理:
- 说明如何准备COCO 2017数据集,并将其转换为MindRecord格式以提高性能。
-
数据采样:
- 描述了SSD算法中的数据采样方法,以提高模型对不同对象大小和形状的鲁棒性。
-
数据集创建:
- 提供了创建SSD数据集的代码示例,包括数据增强和预处理。
-
模型构建:
- 详细介绍了SSD模型的构建过程,包括Backbone Layer、Extra Feature Layer、Detection Layer和Anchor。
-
损失函数:
- 讨论了SSD算法的目标函数,包括置信度误差和位置误差的计算。
-
训练过程:
- 描述了模型训练的过程,包括先验框匹配、损失函数和数据增强。
-
评估:
- 提供了评估模型性能的方法,包括使用COCO评估工具计算mAP。
模型简介
SSD,全称Single Shot MultiBox Detector,是Wei Liu在ECCV 2016上提出的一种目标检测算法。使用Nvidia Titan X在VOC 2007测试集上,SSD对于输入尺寸300x300的网络,达到74.3%mAP(mean Average Precision)以及59FPS;对于512x512的网络,达到了76.9%mAP ,超越当时最强的Faster RCNN(73.2%mAP)。具体可参考论文[1]。 SSD目标检测主流算法分成可以两个类型:
-
two-stage方法:RCNN系列
通过算法产生候选框,然后再对这些候选框进行分类和回归。
-
one-stage方法:YOLO和SSD
直接通过主干网络给出类别位置信息,不需要区域生成。
SSD是单阶段的目标检测算法,通过卷积神经网络进行特征提取,取不同的特征层进行检测输出,所以SSD是一种多尺度的检测方法。在需要检测的特征层,直接使用一个3 ×× 3卷积,进行通道的变换。SSD采用了anchor的策略,预设不同长宽比例的anchor,每一个输出特征层基于anchor预测多个检测框(4或者6)。采用了多尺度检测方法,浅层用于检测小目标,深层用于检测大目标。SSD的框架如下图:
模型结构
SSD采用VGG16作为基础模型,然后在VGG16的基础上新增了卷积层来获得更多的特征图以用于检测。SSD的网络结构如图所示。上面是SSD模型,下面是YOLO模型,可以明显看到SSD利用了多尺度的特征图做检测。
两种单阶段目标检测算法的比较:
SSD先通过卷积不断进行特征提取,在需要检测物体的网络,直接通过一个3 ×× 3卷积得到输出,卷积的通道数由anchor数量和类别数量决定,具体为(anchor数量*(类别数量+4))。
SSD对比了YOLO系列目标检测方法,不同的是SSD通过卷积得到最后的边界框,而YOLO对最后的输出采用全连接的形式得到一维向量,对向量进行拆解得到最终的检测框。
模型特点
-
多尺度检测
在SSD的网络结构图中我们可以看到,SSD使用了多个特征层,特征层的尺寸分别是38 ×× 38,19 ×× 19,10 ×× 10,5 ×× 5,3 ×× 3,1 ×× 1,一共6种不同的特征图尺寸。大尺度特征图(较靠前的特征图)可以用来检测小物体,而小尺度特征图(较靠后的特征图)用来检测大物体。多尺度检测的方式,可以使得检测更加充分(SSD属于密集检测),更能检测出小目标。
-
采用卷积进行检测
与YOLO最后采用全连接层不同,SSD直接采用卷积对不同的特征图来进行提取检测结果。对于形状为m ×× n ×× p的特征图,只需要采用3 ×× 3 ×× p这样比较小的卷积核得到检测值。
-
预设anchor
在YOLOv1中,直接由网络预测目标的尺寸,这种方式使得预测框的长宽比和尺寸没有限制,难以训练。在SSD中,采用预设边界框,我们习惯称它为anchor(在SSD论文中叫default bounding boxes),预测框的尺寸在anchor的指导下进行微调。
数据准备与处理
本案例所使用的数据集为COCO 2017。为了更加方便地保存和加载数据,本案例中在数据读取前首先将COCO数据集转换成MindRecord格式。使用MindSpore Record数据格式可以减少磁盘IO、网络IO开销,从而获得更好的使用体验和性能提升。 首先我们需要下载处理好的MindRecord格式的COCO数据集。 运行以下代码将数据集下载并解压到指定路径。
from download import download # 导入download函数,用于下载文件
# 定义数据集的URL地址
dataset_url = "https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/notebook/datasets/ssd_datasets.zip"
# 指定下载后文件存放的目录
path = "./"
# 调用download函数下载数据集
# 参数解释:
# dataset_url: 数据集的URL地址
# path: 下载数据集后存放的目录
# kind="zip": 指定下载文件的类型,这里是zip格式的压缩包
# replace=True: 如果目标文件已存在,是否替换它
path = download(dataset_url, path, kind="zip", replace=True)
# 设置COCO数据集的根目录
coco_root = "./datasets/"
# 设置COCO数据集的注释文件路径,这里使用的是验证集的注释
anno_json = "./datasets/annotations/instances_val2017.json"
# 定义训练中使用的所有类别
train_cls = [
'background', 'person', 'bicycle', 'car', 'motorcycle', 'airplane', 'bus',
# ... 其他类别名称 ...
]
# 创建一个字典来存储类别名称和它们对应的索引
train_cls_dict = {}
# 使用enumerate函数遍历类别列表,并为每个类别分配一个唯一的索引
for i, cls in enumerate(train_cls):
train_cls_dict[cls] = i
数据采样
为了使模型对于各种输入对象大小和形状更加鲁棒,SSD算法每个训练图像通过以下选项之一随机采样:
-
使用整个原始输入图像
-
采样一个区域,使采样区域和原始图片最小的交并比重叠为0.1,0.3,0.5,0.7或0.9
-
随机采样一个区域
每个采样区域的大小为原始图像大小的[0.3,1],长宽比在1/2和2之间。如果真实标签框中心在采样区域内,则保留两者重叠部分作为新图片的真实标注框。在上述采样步骤之后,将每个采样区域大小调整为固定大小,并以0.5的概率水平翻转。
import cv2
import numpy as np
# 生成[a, b)区间内的随机数
def _rand(a=0., b=1.):
return np.random.rand() * (b - a) + a
# 计算两个边界框的交集
def intersect(box_a, box_b):
max_yx = np.minimum(box_a[:, 2:4], box_b[2:4]) # 取两个边界框的上边界和右边界的最小值
min_yx = np.maximum(box_a[:, :2], box_b[:2]) # 取两个边界框的下边界和左边界的最大值
inter = np.clip((max_yx - min_yx), a_min=0, a_max=np.inf) # 计算交集的宽度和高度
return inter[:, 0] * inter[:, 1] # 计算交集面积
# 计算Jaccard重叠度
def jaccard_numpy(box_a, box_b):
inter = intersect(box_a, box_b) # 计算交集
area_a = ((box_a[:, 2] - box_a[:, 0]) * (box_a[:, 3] - box_a[:, 1])) # 计算边界框a的面积
area_b = ((box_b[2] - box_b[0]) * (box_b[3] - box_b[1])) # 计算边界框b的面积
union = area_a + area_b - inter # 计算并集
return inter / union # 计算Jaccard重叠度
# 随机裁剪图像和边界框
def random_sample_crop(image, boxes):
height, width, _ = image.shape
min_iou = np.random.choice([None, 0.1, 0.3, 0.5, 0.7, 0.9])
if min_iou is None:
return image, boxes
for _ in range(50): # 尝试50次随机裁剪
# 随机生成裁剪区域的宽高和起始坐标
# ...
# 省略了部分代码,但基本逻辑是:
# 1. 计算Jaccard重叠度
# 2. 如果重叠度在指定范围内,则保留该区域
# 3. 根据重叠区域调整边界框
# 4. 如果有有效的边界框,则返回裁剪后的图像和边界框
return image, boxes
# 用于边界框编码的函数
def ssd_bboxes_encode(boxes):
# 这里定义了如何将真实边界框与先验框(anchor boxes)进行匹配,并编码边界框
# ...
# 省略了部分代码,但基本逻辑是:
# 1. 计算Jaccard重叠度
# 2. 根据重叠度对先验框进行评分
# 3. 选择最佳匹配的先验框
# 4. 编码边界框的位置
# 数据预处理函数
def preprocess_fn(img_id, image, box, is_training):
# 定义了图像预处理的流程,包括数据增强和格式转换
# ...
# 省略了部分代码,但基本逻辑是:
# 1. 根据是否训练模式选择不同的预处理方式
# 2. 进行随机裁剪和翻转
# 3. 调整边界框的坐标
# 4. 对边界框进行编码
# 5. 返回预处理后的图像和边界框信息
return _data_aug(image, box, is_training, image_size=[300, 300])
数据集创建
from mindspore import Tensor
from mindspore.dataset import MindDataset
from mindspore.dataset.vision import Decode, HWC2CHW, Normalize, RandomColorAdjust
def create_ssd_dataset(mindrecord_file, batch_size=32, device_num=1, rank=0,
is_training=True, num_parallel_workers=1, use_multiprocessing=True):
"""
Create SSD dataset with MindDataset.
mindrecord_file: MindRecord文件路径。
batch_size: 每个批次的样本数量。
device_num: 设备数量。
rank: 当前设备的编号。
is_training: 是否处于训练模式。
num_parallel_workers: 并行工作线程的数量。
use_multiprocessing: 是否使用多进程。
"""
# 创建MindDataset实例,用于加载MindRecord数据集
dataset = MindDataset(mindrecord_file, columns_list=["img_id", "image", "annotation"],
num_shards=device_num, shard_id=rank, num_parallel_workers=num_parallel_workers, shuffle=is_training)
# 定义图像解码操作
decode = Decode()
dataset = dataset.map(operations=decode, input_columns=["image"])
# 定义HWC到CHW的转换操作
change_swap_op = HWC2CHW()
# 定义归一化操作,均值和标准差基于ImageNet训练集的统计数据
normalize_op = Normalize(mean=[0.485 * 255, 0.456 * 255, 0.406 * 255],
std=[0.229 * 255, 0.224 * 255, 0.225 * 255])
# 定义随机颜色调整操作
color_adjust_op = RandomColorAdjust(brightness=0.4, contrast=0.4, saturation=0.4)
# 定义预处理函数,用于数据增强和格式转换
compose_map_func = (lambda img_id, image, annotation: preprocess_fn(img_id, image, annotation, is_training))
# 根据训练或测试模式,设置不同的输出列和转换操作
if is_training:
output_columns = ["image", "box", "label", "num_match"]
trans = [color_adjust_op, normalize_op, change_swap_op]
else:
output_columns = ["img_id", "image", "image_shape"]
trans = [normalize_op, change_swap_op]
# 应用预处理函数和转换操作
dataset = dataset.map(operations=compose_map_func, input_columns=["img_id", "image", "annotation"],
output_columns=output_columns, python_multiprocessing=use_multiprocessing,
num_parallel_workers=num_parallel_workers)
dataset = dataset.map(operations=trans, input_columns=["image"], python_multiprocessing=use_multiprocessing,
num_parallel_workers=num_parallel_workers)
# 将数据集转换为批次,并确保批次大小符合要求
dataset = dataset.batch(batch_size, drop_remainder=True)
return dataset
模型构建
SSD的网络结构主要分为以下几个部分:
Backbone Layer
输入图像经过预处理后大小固定为300×300,首先经过backbone,本案例中使用的是VGG16网络的前13个卷积层,然后分别将VGG16的全连接层fc6和fc7转换成3 ×× 3卷积层block6和1 ×× 1卷积层block7,进一步提取特征。 在block6中,使用了空洞数为6的空洞卷积,其padding也为6,这样做同样也是为了增加感受野的同时保持参数量与特征图尺寸的不变。
Extra Feature Layer
在VGG16的基础上,SSD进一步增加了4个深度卷积层,用于提取更高层的语义信息:
block8-11,用于更高语义信息的提取。block8的通道数为512,而block9、block10与block11的通道数都为256。从block7到block11,这5个卷积后输出特征图的尺寸依次为19×19、10×10、5×5、3×3和1×1。为了降低参数量,使用了1×1卷积先降低通道数为该层输出通道数的一半,再利用3×3卷积进行特征提取。
Detection Layer
SSD模型一共有6个预测特征图,对于其中一个尺寸为m*n,通道为p的预测特征图,假设其每个像素点会产生k个anchor,每个anchor会对应c个类别和4个回归偏移量,使用(4+c)k个尺寸为3x3,通道为p的卷积核对该预测特征图进行卷积操作,得到尺寸为m*n,通道为(4+c)m*k的输出特征图,它包含了预测特征图上所产生的每个anchor的回归偏移量和各类别概率分数。所以对于尺寸为m*n的预测特征图,总共会产生(4+c)k*m*n个结果。cls分支的输出通道数为k*class_num,loc分支的输出通道数为k*4。
from mindspore import nn
# 定义一个函数来创建网络层
def _make_layer(channels):
in_channels = channels[0] # 当前输入通道数
layers = [] # 初始化层列表
# 循环创建卷积层和ReLU激活层
for out_channels in channels[1:]:
layers.append(nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3))
layers.append(nn.ReLU())
in_channels = out_channels # 更新输入通道数
return nn.SequentialCell(layers) # 返回一个顺序容器,按顺序执行层操作
class Vgg16(nn.Cell):
"""VGG16 module."""
def __init__(self):
super(Vgg16, self).__init__() # 调用父类构造函数
# 使用_make_layer函数创建VGG的卷积块
self.b1 = _make_layer([3, 64, 64])
self.b2 = _make_layer([64, 128, 128])
self.b3 = _make_layer([128, 256, 256, 256])
self.b4 = _make_layer([256, 512, 512, 512])
self.b5 = _make_layer([512, 512, 512, 512])
# 创建最大池化层
self.m1 = nn.MaxPool2d(kernel_size=2, stride=2, pad_mode='SAME')
self.m2 = nn.MaxPool2d(kernel_size=2, stride=2, pad_mode='SAME')
self.m3 = nn.MaxPool2d(kernel_size=2, stride=2, pad_mode='SAME')
self.m4 = nn.MaxPool2d(kernel_size=2, stride=2, pad_mode='SAME')
self.m5 = nn.MaxPool2d(kernel_size=3, stride=1, pad_mode='SAME')
def construct(self, x):
# 前向传播过程,依次通过卷积块和池化层
# block1
x = self.b1(x) # 卷积块1
x = self.m1(x) # 池化层1
# block2
x = self.b2(x) # 卷积块2
x = self.m2(x) # 池化层2
# block3
x = self.b3(x) # 卷积块3
x = self.m3(x) # 池化层3
# block4
x = self.b4(x) # 卷积块4
block4 = x # 保存block4的输出
x = self.m4(x) # 池化层4
# block5
x = self.b5(x) # 卷积块5
x = self.m5(x) # 池化层5
# 返回block4和block5的输出,用于后续的多尺度预测
return block4, x
import mindspore as ms
import mindspore.nn as nn
import mindspore.ops as ops
# 定义一个深度可分离卷积函数
def _last_conv2d(in_channel, out_channel, kernel_size=3, stride=1, pad_mod='same', pad=0):
# 深度可分离卷积由深度卷积和逐点卷积组成
depthwise_conv = nn.Conv2d(in_channels, out_channels, kernel_size, stride, pad_mode='same', padding=pad, group=in_channels)
conv = nn.Conv2d(in_channel, out_channel, kernel_size=1, stride=1, padding=0, pad_mode='same', has_bias=True)
bn = nn.BatchNorm2d(in_channel, eps=1e-3, momentum=0.97)
return nn.SequentialCell([depthwise_conv, bn, nn.ReLU6(), conv])
# 定义一个用于拉平特征并连接的类
class FlattenConcat(nn.Cell):
def __init__(self):
super(FlattenConcat, self).__init__()
self.num_ssd_boxes = 8732 # SSD中使用的锚点数量
def construct(self, inputs):
output = ()
batch_size = ops.shape(inputs[0])[0]
for x in inputs:
x = ops.transpose(x, (0, 2, 3, 1)) # HWC到CHW的转换
output += (ops.reshape(x, (batch_size, -1)),)
res = ops.concat(output, axis=1) # 沿着特定轴连接特征
return ops.reshape(res, (batch_size, self.num_ssd_boxes, -1)) # 调整形状以匹配锚点数量
# 定义MultiBox类,用于生成类别置信度和边界框预测
class MultiBox(nn.Cell):
def __init__(self):
super(MultiBox, self).__init__()
num_classes = 81 # 类别数量(包括背景)
out_channels = [512, 1024, 512, 256, 256, 256] # 每个尺度的特征图通道数
num_default = [4, 6, 6, 6, 4, 4] # 每个尺度的锚点数量
# 创建用于预测边界框位置的卷积层列表
loc_layers = []
cls_layers = []
for k, out_channel in enumerate(out_channels):
loc_layers += [_last_conv2d(out_channel, 4 * num_default[k])]
cls_layers += [_last_conv2d(out_channel, num_classes * num_default[k])]
self.multi_loc_layers = nn.CellList(loc_layers)
self.multi_cls_layers = nn.CellList(cls_layers)
self.flatten_concat = FlattenConcat() # 用于拉平特征并连接
def construct(self, inputs):
loc_outputs = ()
cls_outputs = ()
for i in range(len(self.multi_loc_layers)):
loc_outputs += (self.multi_loc_layers[i](inputs[i]),)
cls_outputs += (self.multi_cls_layers[i](inputs[i]),)
# 使用FlattenConcat处理预测结果
return self.flatten_concat(loc_outputs), self.flatten_concat(cls_outputs)
# 定义SSD300Vgg16类,实现SSD300的Vgg16版本
class SSD300Vgg16(nn.Cell):
def __init__(self):
super(SSD300Vgg16, self).__init__()
self.backbone = Vgg16() # 使用Vgg16作为骨干网络
# 定义SSD特有的层,包括block6到block11
# ...
def construct(self, x):
# 通过Vgg16骨干网络提取特征
block4, x = self.backbone(x)
# 通过SSD特有的层进一步提取特征
# ...
# 通过MultiBox层生成预测结果
pred_loc, pred_label = self.multi_box(multi_feature)
# 如果不在训练模式,应用sigmoid函数到类别预测上
if not self.training:
pred_label = ops.sigmoid(pred_label)
# 将预测结果转换为float32
pred_loc = pred_loc.astype(ms.float32)
pred_label = pred_label.astype(ms.float32)
return pred_loc, pred_label
训练过程
(1)先验框匹配
在训练过程中,首先要确定训练图片中的ground truth(真实目标)与哪个先验框来进行匹配,与之匹配的先验框所对应的边界框将负责预测它。
SSD的先验框与ground truth的匹配原则主要有两点:
-
对于图片中每个ground truth,找到与其IOU最大的先验框,该先验框与其匹配,这样可以保证每个ground truth一定与某个先验框匹配。通常称与ground truth匹配的先验框为正样本,反之,若一个先验框没有与任何ground truth进行匹配,那么该先验框只能与背景匹配,就是负样本。
-
对于剩余的未匹配先验框,若某个ground truth的IOU大于某个阈值(一般是0.5),那么该先验框也与这个ground truth进行匹配。尽管一个ground truth可以与多个先验框匹配,但是ground truth相对先验框还是太少了,所以负样本相对正样本会很多。为了保证正负样本尽量平衡,SSD采用了hard negative mining,就是对负样本进行抽样,抽样时按照置信度误差(预测背景的置信度越小,误差越大)进行降序排列,选取误差的较大的top-k作为训练的负样本,以保证正负样本比例接近1:3。
(2)损失函数
损失函数使用的是上文提到的位置损失函数和置信度损失函数的加权和。
(3)数据增强
使用之前定义好的数据增强方式,对创建好的数据增强方式进行数据增强。
模型训练时,设置模型训练的epoch次数为60,然后通过create_ssd_dataset类创建了训练集和验证集。batch_size大小为5,图像尺寸统一调整为300×300。损失函数使用位置损失函数和置信度损失函数的加权和,优化器使用Momentum,并设置初始学习率为0.001。回调函数方面使用了LossMonitor和TimeMonitor来监控训练过程中每个epoch结束后,损失值Loss的变化情况以及每个epoch、每个step的运行时间。设置每训练10个epoch保存一次模型。
import math import itertools as it import numpy as np # 设置随机种子以保证结果的可复现性 set_seed(1) class GeneratDefaultBoxes(): """ 生成用于SSD的默认边界框,顺序为(宽度, 高度, 特征图尺寸)。 self.default_boxes的形状为[特征图尺寸, 高度, 宽度, 4],最后一维是[y, x, h, w]。 self.default_boxes_tlbr的形状与self.default_boxes相同,最后一维是[y1, x1, y2, x2]。 """ def __init__(self): # 计算不同特征图上的默认边界框尺度 fk = 300 / np.array([8, 16, 32, 64, 100, 300]) scale_rate = (0.95 - 0.1) / (len([4, 6, 6, 6, 4, 4]) - 1) scales = [0.1 + scale_rate * i for i in range(len([4, 6, 6, 4, 4]))] + [1.0] self.default_boxes = [] for idex, feature_size in enumerate([38, 19, 10, 5, 3, 1]): # 计算当前特征图上的尺度和纵横比 sk1 = scales[idex] sk2 = scales[idex + 1] sk3 = math.sqrt(sk1 * sk2) all_sizes = [(sk1, sk1)] # 存储当前特征图上所有边界框的尺寸 # 根据纵横比生成不同的边界框尺寸 for aspect_ratio in [[2], [2, 3], [2, 3], [2, 3], [2], [2]][idex]: w, h = sk1 * math.sqrt(aspect_ratio), sk1 / math.sqrt(aspect_ratio) all_sizes.append((w, h)) all_sizes.append((h, w)) if idex == 0: # 第一个特征图上添加特殊尺寸 all_sizes.append((sk3, sk3)) # 确保所有特征图上的边界框数量与预期一致 assert len(all_sizes) == [4, 6, 6, 6, 4, 4][idex] # 计算当前特征图上每个单元格的边界框中心坐标 for i, j in it.product(range(feature_size), repeat=2): for w, h in all_sizes: cx, cy = (j + 0.5) / fk[idex], (i + 0.5) / fk[idex] self.default_boxes.append([cy, cx, h, w]) # 将边界框的中心点坐标及宽高转换为左上角和右下角坐标 def to_tlbr(cy, cx, h, w): return cy - h / 2, cx - w / 2, cy + h / 2, cx + w / 2 # 计算IoU时使用,将默认边界框转换为左上角和右下角坐标形式 self.default_boxes_tlbr = np.array(tuple(to_tlbr(*i) for i in self.default_boxes), dtype='float32') self.default_boxes = np.array(self.default_boxes, dtype='float32') # 实例化GeneratDefaultBoxes类并获取默认边界框 default_boxes_tlbr = GeneratDefaultBoxes().default_boxes_tlbr default_boxes = GeneratDefaultBoxes().default_boxes # 将边界框的左上角和右下角坐标分离 y1, x1, y2, x2 = np.split(default_boxes_tlbr[:, :4], 4, axis=-1) # 计算边界框的面积,用于匹配过程中的面积约束 vol_anchors = (x2 - x1) * (y2 - y1) # 设置匹配先验框和真实边界框时的IoU阈值 matching_threshold = 0.5
from mindspore.common.initializer import initializer, TruncatedNormal import math import numpy as np # 定义初始化网络参数的函数 def init_net_param(network, initialize_mode='TruncatedNormal'): """ 初始化网络中的参数。 network: 要初始化参数的网络。 initialize_mode: 初始化模式,默认为'TruncatedNormal'。 """ params = network.trainable_params() # 获取网络中可训练的参数 for p in params: # 忽略beta、gamma和bias参数 if 'beta' not in p.name and 'gamma' not in p.name and 'bias' not in p.name: if initialize_mode == 'TruncatedNormal': # 使用TruncatedNormal分布初始化参数 p.set_data(initializer(TruncatedNormal(0.02), p.data.shape, p.data.dtype)) else: # 使用其他指定的初始化模式初始化参数 p.set_data(initializer(initialize_mode, p.data.shape, p.data.dtype)) # 定义学习率调整的函数 def get_lr(global_step, lr_init, lr_end, lr_max, warmup_epochs, total_epochs, steps_per_epoch): """ 动态生成学习率数组。 global_step: 当前全局步数。 lr_init: 初始学习率。 lr_end: 最终学习率。 lr_max: 最大学习率。 warmup_epochs: 预热周期数。 total_epochs: 总周期数。 steps_per_epoch: 每个周期的步数。 """ lr_each_step = [] total_steps = steps_per_epoch * total_epochs # 总步数 warmup_steps = steps_per_epoch * warmup_epochs # 预热步数 for i in range(total_steps): if i < warmup_steps: # 预热期间的学习率线性增加 lr = lr_init + (lr_max - lr_init) * i / warmup_steps else: # 预热后的学习率按照余弦退火调整 lr = lr_end + (lr_max - lr_end) * (1. + math.cos(math.pi * (i - warmup_steps) / (total_steps - warmup_steps))) / 2. if lr < 0.0: lr = 0.0 lr_each_step.append(lr) # 将学习率数组转换为numpy数组,并取当前步数之后的学习率 current_step = global_step lr_each_step = np.array(lr_each_step).astype(np.float32) learning_rate = lr_each_step[current_step:] return learning_rate
import mindspore.dataset as ds import mindspore as ms from mindspore.amp import DynamicLossScaler from mindspore import Tensor, nn, ops import time # 设置MindSpore数据集共享内存选项 ds.config.set_enable_shared_mem(False) # 设置随机种子以保证结果的可复现性 set_seed(1) # 定义数据集路径 mindrecord_dir = "./datasets/MindRecord_COCO" mindrecord_file = "./datasets/MindRecord_COCO/ssd.mindrecord0" # 创建SSD数据集实例 dataset = create_ssd_dataset(mindrecord_file, batch_size=5, rank=0, use_multiprocessing=True) dataset_size = dataset.get_dataset_size() # 获取数据集大小 # 初始化网络参数 network = SSD300Vgg16() init_net_param(network) # 定义学习率 lr = Tensor(get_lr(global_step=0 * dataset_size, lr_init=0.001, lr_end=0.001 * 0.05, lr_max=0.05, warmup_epochs=2, total_epochs=60, steps_per_epoch=dataset_size)) # 定义优化器 opt = nn.Momentum(filter(lambda x: x.requires_grad, network.get_parameters()), lr, 0.9, 0.00015, float(1024)) # 定义前向传播函数 def forward_fn(x, gt_loc, gt_label, num_matched_boxes): # 模型预测 pred_loc, pred_label = network(x) # 真实标签转换为掩码 mask = ops.less(0, gt_label).astype(ms.float32) # 计算匹配的边界框数量 num_matched_boxes = ops.sum(num_matched_boxes.astype(ms.float32)) # 计算位置损失 mask_loc = ops.tile(ops.expand_dims(mask, -1), (1, 1, 4)) smooth_l1 = nn.SmoothL1Loss()(pred_loc, gt_loc) * mask_loc loss_loc = ops.sum(ops.sum(smooth_l1, -1), -1) # 计算类别损失 loss_cls = class_loss(pred_label, gt_label) loss_cls = ops.sum(loss_cls, (1, 2)) # 总损失 return ops.sum((loss_cls + loss_loc) / num_matched_boxes) # 定义梯度更新函数 grad_fn = ms.value_and_grad(forward_fn, None, opt.parameters, has_aux=False) loss_scaler = DynamicLossScaler(1024, 2, 1000) def train_step(x, gt_loc, gt_label, num_matched_boxes): loss, grads = grad_fn(x, gt_loc, gt_label, num_matched_boxes) opt(grads) return loss # 训练循环 print("=================== Starting Training =====================") for epoch in range(60): network.set_train(True) begin_time = time.time() for step, (image, get_loc, gt_label, num_matched_boxes) in enumerate(dataset.create_tuple_iterator()): loss = train_step(image, get_loc, gt_label, num_matched_boxes) end_time = time.time() times = end_time - begin_time print(f"Epoch:[{int(epoch + 1)}/{int(60)}], " f"loss:{loss} , " f"time:{times}s ") # 保存训练好的模型 ms.save_checkpoint(network, "ssd-60_9.ckpt") print("=================== Training Success =====================")
评估
自定义eval_net()类对训练好的模型进行评估,调用了上述定义的SsdInferWithDecoder类返回预测的坐标及标签,然后分别计算了在不同的IoU阈值、area和maxDets设置下的Average Precision(AP)和Average Recall(AR)。使用COCOMetrics类计算mAP。模型在测试集上的评估指标如下。
精确率(AP)和召回率(AR)的解释
-
TP:IoU>设定的阈值的检测框数量(同一Ground Truth只计算一次)。
-
FP:IoU<=设定的阈值的检测框,或者是检测到同一个GT的多余检测框的数量。
-
精确率(AP)和召回率(AR)的公式
-
精确率(Average Precision,AP):
精确率是将正样本预测正确的结果与正样本预测的结果和预测错误的结果的和的比值,主要反映出预测结果错误率。
-
召回率(Average Recall,AR):
召回率是正样本预测正确的结果与正样本预测正确的结果和正样本预测错误的和的比值,主要反映出来的是预测结果中的漏检率。
8.FN:没有检测到的GT的数量。
import mindspore as ms from mindspore import Tensor # 假设SSD300Vgg16, SsdInferWithDecoder, create_ssd_dataset, apply_eval等函数和类已经被定义 # 定义评估函数 def ssd_eval(dataset_path, ckpt_path, anno_json): """SSD评估函数.""" batch_size = 1 # 设置批量大小为1 # 创建数据集实例,设置为非训练模式,不使用多进程 ds = create_ssd_dataset(dataset_path, batch_size=batch_size, is_training=False, use_multiprocessing=False) # 实例化SSD300Vgg16模型 network = SSD300Vgg16() print("Load Checkpoint!") # 打印加载检查点信息 # 使用SsdInferWithDecoder类加载预训练模型和解码器 net = SsdInferWithDecoder(network, Tensor(default_boxes), ckpt_path) # 设置网络为评估模式 net.set_train(False) # 计算总图像数量 total = ds.get_dataset_size() * batch_size print("\n========================================\n") print("total images num: ", total) # 准备评估参数字典 eval_param_dict = {"net": net, "dataset": ds, "anno_json": anno_json} # 调用apply_eval函数计算mAP mAP = apply_eval(eval_param_dict) print("\n========================================\n") print(f"mAP: {mAP}") # 打印mAP结果 # 定义评估网络的函数 def eval_net(): print("Start Eval!") # 打印开始评估信息 # 调用ssd_eval函数进行评估 ssd_eval(mindrecord_file, "./ssd-60_9.ckpt", anno_json) # 调用eval_net函数执行评估 eval_net()
学习心得:
通过学习SSD,我对目标检测任务有了更深入的理解,特别是如何在图像中识别和定位多个对象。
我了解到SSD作为一种单阶段目标检测算法,它通过单次前向传播即可同时预测边界框和类别概率,这使得它在速度和准确性上都有出色的表现。
SSD利用多尺度的特征图进行检测,这使得它能够检测不同大小的目标,我学习了如何通过特征金字塔网络来实现这一点。
我认识到数据增强在目标检测中的重要性,它通过随机裁剪、翻转等操作提高了模型的泛化能力。
学习了锚框(anchor boxes)的概念,以及如何使用它们来预测目标的位置,这有助于我理解模型是如何在特征图上定位目标的。
我掌握了SSD中使用的损失函数,包括位置损失和置信度损失,以及如何通过这些损失函数来训练模型。
学习了如何使用mAP(mean Average Precision)等指标来评估目标检测模型的性能。
通过实践MindSpore框架,我获得了使用该框架进行模型构建、训练、评估和推理的经验。
我学习了模型优化的技巧,如使用深度可分离卷积来减少计算量,以及使用梯度累积和学习率预热策略来提高训练稳定性。
今天是学习打卡的第15天 ,加油!