昇思MindSpore 应用学习-SSD目标检测

日期

心得

昇思MindSpore 应用学习-SSD目标检测(AI 代码解析)

SSD目标检测

模型简介

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目标检测主流算法分成可以两个类型:

  1. two-stage方法:RCNN系列

通过算法产生候选框,然后再对这些候选框进行分类和回归。

  1. 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的指导下进行微调。

环境准备

本案例基于MindSpore实现,开始实验前,请确保本地已经安装了mindspore、download、pycocotools、opencv-python。

数据准备与处理

本案例所使用的数据集为COCO 2017。为了更加方便地保存和加载数据,本案例中在数据读取前首先将COCO数据集转换成MindRecord格式。使用MindSpore Record数据格式可以减少磁盘IO、网络IO开销,从而获得更好的使用体验和性能提升。 首先我们需要下载处理好的MindRecord格式的COCO数据集。 运行以下代码将数据集下载并解压到指定路径。

from download import download  # 导入download模块中的download函数

# 设置数据集的URL
dataset_url = "https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/notebook/datasets/ssd_datasets.zip"
# 设置下载文件的保存路径
path = "./"
# 下载数据集,指定下载的URL、保存路径、文件类型为zip,并允许替换已存在的文件
path = download(dataset_url, path, kind="zip", replace=True)

解析:

  1. from download import download:这行代码导入了download模块中的download函数,用于下载文件。
  2. dataset_url:定义了待下载数据集的URL地址。
  3. path = "./":指定文件的保存路径为当前目录。
  4. download(dataset_url, path, kind="zip", replace=True)
    • dataset_url:传入待下载的数据集URL。
    • path:指定下载后的保存路径。
    • kind="zip":表明要下载的文件类型为压缩文件(zip格式)。
    • replace=True:表示如果指定路径下已有同名文件,则替换该文件。

该API的功能是从指定的URL下载文件,并根据用户的参数进行文件类型的处理和文件替换的选择。
然后我们为数据处理定义一些输入:

# 设置COCO数据集根目录
coco_root = "./datasets/"
# 设置COCO数据集的注释文件路径
anno_json = "./datasets/annotations/instances_val2017.json"

# 定义训练类列表,包括所有要识别的目标类别
train_cls = ['background', 'person', 'bicycle', 'car', 'motorcycle', 'airplane', 'bus',
             'train', 'truck', 'boat', 'traffic light', 'fire hydrant',
             'stop sign', 'parking meter', 'bench', 'bird', 'cat', 'dog',
             'horse', 'sheep', 'cow', 'elephant', 'bear', 'zebra',
             'giraffe', 'backpack', 'umbrella', 'handbag', 'tie',
             'suitcase', 'frisbee', 'skis', 'snowboard', 'sports ball',
             'kite', 'baseball bat', 'baseball glove', 'skateboard',
             'surfboard', 'tennis racket', 'bottle', 'wine glass', 'cup',
             'fork', 'knife', 'spoon', 'bowl', 'banana', 'apple',
             'sandwich', 'orange', 'broccoli', 'carrot', 'hot dog', 'pizza',
             'donut', 'cake', 'chair', 'couch', 'potted plant', 'bed',
             'dining table', 'toilet', 'tv', 'laptop', 'mouse', 'remote',
             'keyboard', 'cell phone', 'microwave', 'oven', 'toaster', 'sink',
             'refrigerator', 'book', 'clock', 'vase', 'scissors',
             'teddy bear', 'hair drier', 'toothbrush']

# 创建一个字典,将类别名映射到对应的索引
train_cls_dict = {}
for i, cls in enumerate(train_cls):  # 遍历训练类列表,获取索引和类别名
    train_cls_dict[cls] = i  # 将类别名和索引存入字典

解析:

  1. coco_root = "./datasets/":设置COCO数据集的根目录,通常用于存放下载的数据集。
  2. anno_json = "./datasets/annotations/instances_val2017.json":定义注释文件的路径,包含了数据集中物体实例的信息。
  3. train_cls:定义了一个包含所有感兴趣目标的类别列表,包括背景及多种物体。
  4. train_cls_dict = {}:初始化一个空字典,用于存储类别名与其对应索引的映射关系。
  5. for i, cls in enumerate(train_cls)::使用enumerate函数遍历train_cls列表,i为索引,cls为类别名。
  6. 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  # 导入OpenCV库,用于图像处理
import numpy as np  # 导入NumPy库,用于数值计算和数组操作

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]  # 返回交集的面积

def jaccard_numpy(box_a, box_b):
    """计算两个集合的框的Jaccard重叠"""
    inter = intersect(box_a, box_b)  # 计算交集
    area_a = ((box_a[:, 2] - box_a[:, 0]) * (box_a[:, 3] - box_a[:, 1]))  # 计算box_a的面积
    area_b = ((box_b[2] - box_b[0]) * (box_b[3] - box_b[1]))  # 计算box_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次
        image_t = image  # 复制原图
        w = _rand(0.3, 1.0) * width  # 随机生成裁剪框的宽
        h = _rand(0.3, 1.0) * height  # 随机生成裁剪框的高
        # 宽高比限制在0.5到2之间
        if h / w < 0.5 or h / w > 2:
            continue

        left = _rand() * (width - w)  # 随机生成裁剪框左边缘的X坐标
        top = _rand() * (height - h)  # 随机生成裁剪框上边缘的Y坐标
        rect = np.array([int(top), int(left), int(top + h), int(left + w)])  # 裁剪框的坐标
        overlap = jaccard_numpy(boxes, rect)  # 计算框与裁剪框的重叠

        drop_mask = overlap > 0  # 找到有重叠的框
        if not drop_mask.any():  # 如果没有重叠的框,继续尝试
            continue

        if overlap[drop_mask].min() < min_iou and overlap[drop_mask].max() > (min_iou + 0.2):
            continue  # 检查重叠率是否满足条件

        image_t = image_t[rect[0]:rect[2], rect[1]:rect[3], :]  # 裁剪图像
        centers = (boxes[:, :2] + boxes[:, 2:4]) / 2.0  # 计算框的中心点
        m1 = (rect[0] < centers[:, 0]) * (rect[1] < centers[:, 1])  # 检查框的中心点是否在裁剪框左上角
        m2 = (rect[2] > centers[:, 0]) * (rect[3] > centers[:, 1])  # 检查框的中心点是否在裁剪框右下角

        # 生成mask,确保m1与m2都为真
        mask = m1 * m2 * drop_mask

        # 如果没有有效框,继续尝试
        if not mask.any():
            continue

        # 仅保留匹配的真实框
        boxes_t = boxes[mask, :].copy()
        boxes_t[:, :2] = np.maximum(boxes_t[:, :2], rect[:2])  # 更新框的坐标
        boxes_t[:, :2] -= rect[:2]
        boxes_t[:, 2:4] = np.minimum(boxes_t[:, 2:4], rect[2:4])
        boxes_t[:, 2:4] -= rect[:2]

        return image_t, boxes_t  # 返回裁剪后的图像和框
    return image, boxes  # 如果未成功裁剪,返回原图和框

def ssd_bboxes_encode(boxes):
    """用真实输入标签锚框"""
    
    def jaccard_with_anchors(bbox):
        """计算一个框与锚框的Jaccard分数"""
        ymin = np.maximum(y1, bbox[0])  # 计算交集的上边界
        xmin = np.maximum(x1, bbox[1])  # 计算交集的左边界
        ymax = np.minimum(y2, bbox[2])  # 计算交集的下边界
        xmax = np.minimum(x2, bbox[3])  # 计算交集的右边界
        w = np.maximum(xmax - xmin, 0.)  # 计算交集的宽
        h = np.maximum(ymax - ymin, 0.)  # 计算交集的高

        inter_vol = h * w  # 交集的面积
        union_vol = vol_anchors + (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) - inter_vol  # 并集的面积
        jaccard = inter_vol / union_vol  # 计算Jaccard指数
        return np.squeeze(jaccard)  # 返回Jaccard分数

    pre_scores = np.zeros((8732), dtype=np.float32)  # 初始化得分数组
    t_boxes = np.zeros((8732, 4), dtype=np.float32)  # 初始化框数组
    t_label = np.zeros((8732), dtype=np.int64)  # 初始化标签数组
    for bbox in boxes:  # 遍历所有真实框
        label = int(bbox[4])  # 获取框的标签
        scores = jaccard_with_anchors(bbox)  # 计算与锚框的Jaccard得分
        idx = np.argmax(scores)  # 找到得分最高的锚框
        scores[idx] = 2.0  # 给该锚框加权重
        mask = (scores > matching_threshold)  # 创建mask,筛选得分高于阈值的锚框
        mask = mask & (scores > pre_scores)  # 保留得分更高的锚框
        pre_scores = np.maximum(pre_scores, scores * mask)  # 更新得分
        t_label = mask * label + (1 - mask) * t_label  # 更新标签
        for i in range(4):
            t_boxes[:, i] = mask * bbox[i] + (1 - mask) * t_boxes[:, i]  # 更新框的位置

    index = np.nonzero(t_label)  # 获取非零标签的索引

    # 转换为tlbr格式
    bboxes = np.zeros((8732, 4), dtype=np.float32)  # 初始化框数组
    bboxes[:, [0, 1]] = (t_boxes[:, [0, 1]] + t_boxes[:, [2, 3]]) / 2  # 计算框的中心点
    bboxes[:, [2, 3]] = t_boxes[:, [2, 3]] - t_boxes[:, [0, 1]]  # 计算框的宽高

    # 编码特征
    bboxes_t = bboxes[index]  # 获取匹配的框
    default_boxes_t = default_boxes[index]  # 获取对应的默认框
    bboxes_t[:, :2] = (bboxes_t[:, :2] - default_boxes_t[:, :2]) / (default_boxes_t[:, 2:] * 0.1)  # 归一化位置
    tmp = np.maximum(bboxes_t[:, 2:4] / default_boxes_t[:, 2:4], 0.000001)  # 计算宽高的比例
    bboxes_t[:, 2:4] = np.log(tmp) / 0.2  # 取对数并缩放
    bboxes[index] = bboxes_t  # 更新框数据

    num_match = np.array([len(np.nonzero(t_label)[0])], dtype=np.int32)  # 统计匹配的框数量
    return bboxes, t_label.astype(np.int32), num_match  # 返回框、标签和匹配数量

def preprocess_fn(img_id, image, box, is_training):
    """数据集的预处理函数"""
    cv2.setNumThreads(2)  # 设置OpenCV使用的线程数

    def _infer_data(image, input_shape):
        img_h, img_w, _ = image.shape  # 获取图像原始高和宽
        input_h, input_w = input_shape  # 获取输入形状

        image = cv2.resize(image, (input_w, input_h))  # 调整图像大小

        # 如果图像通道为1,将其扩展为3通道
        if len(image.shape) == 2:
            image = np.expand_dims(image, axis=-1)
            image = np.concatenate([image, image, image], axis=-1)

        return img_id, image, np.array((img_h, img_w), np.float32)  # 返回图像ID、处理后的图像和原始尺寸

    def _data_aug(image, box, is_training, image_size=(300, 300)):
        ih, iw, _ = image.shape  # 获取图像尺寸
        h, w = image_size  # 获取目标输入尺寸
        if not is_training:
            return _infer_data(image, image_size)  # 如果不在训练阶段,直接处理图像

        # 随机裁剪
        box = box.astype(np.float32)  # 转换框的类型
        image, box = random_sample_crop(image, box)  # 裁剪图像和框
        ih, iw, _ = image.shape  # 更新图像尺寸
        # 调整图像大小
        image = cv2.resize(image, (w, h))
        # 随机翻转图像
        flip = _rand() < .5
        if flip:
            image = cv2.flip(image, 1, dst=None)  # 翻转图像
        # 如果图像通道为1,将其扩展为3通道
        if len(image.shape) == 2:
            image = np.expand_dims(image, axis=-1)
            image = np.concatenate([image, image, image], axis=-1)
        box[:, [0, 2]] = box[:, [0, 2]] / ih  # 归一化框的Y坐标
        box[:, [1, 3]] = box[:, [1, 3]] / iw  # 归一化框的X坐标
        if flip:  # 如果图像被翻转,调整框的位置
            box[:, [1, 3]] = 1 - box[:, [3, 1]]
        box, label, num_match = ssd_bboxes_encode(box)  # 编码框信息
        return image, box, label, num_match  # 返回处理后的图像、框、标签和匹配数量

    return _data_aug(image, box, is_training, image_size=[300, 300])  # 调用数据增强函数

解析:

  1. 导入必要的库:cv2用于图像处理,numpy用于数值处理。
  2. _rand(a=0., b=1.):生成给定范围内的随机浮点数。
  3. intersect(box_a, box_b):计算两个框的交集面积。
  4. jaccard_numpy(box_a, box_b):计算两个框的Jaccard重叠率。
  5. random_sample_crop(image, boxes):随机裁剪图像及其对应的框,确保框的重叠率满足要求。
  6. ssd_bboxes_encode(boxes):将真实框与锚框进行匹配,计算Jaccard比率,并将框编码为SSD模型所需的格式。
  7. preprocess_fn(img_id, image, box, is_training):处理图像,进行数据增强和预处理,返回处理后的图像、框、标签和匹配数量。

此代码主要用于目标检测任务中的数据预处理和增强,特别是SSD(Single Shot MultiBox Detector)模型的训练过程。

数据集创建

from mindspore import Tensor  # 导入Tensor类用于创建张量
from mindspore.dataset import MindDataset  # 导入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):
    """使用MindDataset创建SSD数据集。"""
    
    # 创建MindDataset数据集
    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"])  # 解码图像数据

    change_swap_op = HWC2CHW()  # 图像格式从HWC转换为CHW
    # 从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  # 返回创建的数据集

解析:

  1. from mindspore import Tensor:导入MindSpore库中的Tensor类,用于创建处理中的张量。
  2. from mindspore.dataset import MindDataset:导入MindDataset类,用于构建和处理数据集。
  3. from mindspore.dataset.vision import Decode, HWC2CHW, Normalize, RandomColorAdjust:导入用于图像处理的操作,包括解码、格式转换、归一化和颜色调整。
  4. create_ssd_dataset(...):定义函数创建SSD数据集。
    • mindrecord_file:输入的MindRecord文件路径。
    • batch_size:每个批次的样本数量。
    • device_num:设备数量,通常用于分布式训练。
    • rank:当前设备的ID。
    • is_training:布尔值,指示是否处于训练阶段。
    • num_parallel_workers:并行工作的线程数。
    • use_multiprocessing:是否使用多进程处理。
  5. dataset = MindDataset(...):从MindRecord文件创建数据集,指定需要的列。
  6. decode = Decode():创建解码操作实例,用于解码图像。
  7. dataset = dataset.map(...):对数据集应用解码操作。
  8. change_swap_op = HWC2CHW():创建图像格式转换操作,将图像从HWC格式转换为CHW格式。
  9. normalize_op = Normalize(...):创建归一化操作,根据给定的均值和标准差进行图像归一化。
  10. color_adjust_op = RandomColorAdjust(...):创建随机颜色调整操作。
  11. compose_map_func = ...:定义一个组合函数,结合图像ID、图像和注释进行预处理。
  12. if is_training::根据是否在训练阶段选择输出列和转换操作。
  13. dataset = dataset.map(...):对数据集应用组合预处理函数,并设置输出列。
  14. dataset = dataset.map(...):对数据集应用图像转换操作。
  15. dataset = dataset.batch(...):按批次处理数据,丢弃最后一个不足一批的样本。
  16. return dataset:返回创建好的数据集。

此代码用于准备SSD模型的训练和测试数据,确保输入图像的预处理和增强,以提高模型的性能。

模型构建

SSD的网络结构主要分为以下几个部分:

  • VGG16 Base Layer
  • Extra Feature Layer
  • Detection Layer
  • NMS
  • Anchor

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卷积进行特征提取。

Anchor

SSD采用了PriorBox来进行区域生成。将固定大小宽高的PriorBox作为先验的感兴趣区域,利用一个阶段完成能够分类与回归。设计大量的密集的PriorBox保证了对整幅图像的每个地方都有检测。PriorBox位置的表示形式是以中心点坐标和框的宽、高(cx,cy,w,h)来表示的,同时都转换成百分比的形式。 PriorBox生成规则: SSD由6个特征层来检测目标,在不同特征层上,PriorBox的尺寸scale大小是不一样的,最低层的scale=0.1,最高层的scale=0.95,其他层的计算公式如下:

在某个特征层上其scale一定,那么会设置不同长宽比ratio的PriorBox,其长和宽的计算公式如下:

在ratio=1的时候,还会根据该特征层和下一个特征层计算一个特定scale的PriorBox(长宽比ratio=1),计算公式如下:

每个特征层的每个点都会以上述规则生成PriorBox,(cx,cy)由当前点的中心点来确定,由此每个特征层都生成大量密集的PriorBox,如下图:

SSD使用了第4、7、8、9、10和11这6个卷积层得到的特征图,这6个特征图尺寸越来越小,而其对应的感受野越来越大。6个特征图上的每一个点分别对应4、6、6、6、4、4个PriorBox。某个特征图上的一个点根据下采样率可以得到在原图的坐标,以该坐标为中心生成4个或6个不同大小的PriorBox,然后利用特征图的特征去预测每一个PriorBox对应类别与位置的预测量。例如:第8个卷积层得到的特征图大小为10×10×512,每个点对应6个PriorBox,一共有600个PriorBox。定义MultiBox类,生成多个预测框。

Detection Layer


SSD模型一共有6个预测特征图,对于其中一个尺寸为mn,通道为p的预测特征图,假设其每个像素点会产生k个anchor,每个anchor会对应c个类别和4个回归偏移量,使用(4+c)k个尺寸为3x3,通道为p的卷积核对该预测特征图进行卷积操作,得到尺寸为mn,通道为(4+c)mk的输出特征图,它包含了预测特征图上所产生的每个anchor的回归偏移量和各类别概率分数。所以对于尺寸为mn的预测特征图,总共会产生(4+c)kmn个结果。cls分支的输出通道数为kclass_num,loc分支的输出通道数为k4。

from mindspore import nn  # 导入MindSpore的神经网络模块

def _make_layer(channels):
    """根据给定的通道列表构建一个卷积层序列。"""
    in_channels = channels[0]  # 获取输入通道数
    layers = []  # 初始化存储层的列表
    for out_channels in channels[1:]:  # 遍历输出通道数列表
        # 添加卷积层和ReLU激活层
        layers.append(nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3))
        layers.append(nn.ReLU())  # 添加ReLU激活函数
        in_channels = out_channels  # 更新输入通道数为当前输出通道数
    return nn.SequentialCell(layers)  # 返回由层组成的顺序网络

class Vgg16(nn.Cell):
    """VGG16模块,构建VGG16网络结构。"""

    def __init__(self):
        super(Vgg16, self).__init__()  # 初始化父类
        # 创建网络的各个卷积层块
        self.b1 = _make_layer([3, 64, 64])  # 输入3通道,输出64通道
        self.b2 = _make_layer([64, 128, 128])  # 输入64通道,输出128通道
        self.b3 = _make_layer([128, 256, 256, 256])  # 输入128通道,输出256通道
        self.b4 = _make_layer([256, 512, 512, 512])  # 输入256通道,输出512通道
        self.b5 = _make_layer([512, 512, 512, 512])  # 输入512通道,输出512通道

        # 最大池化层
        self.m1 = nn.MaxPool2d(kernel_size=2, stride=2, pad_mode='SAME')  # 池化层1
        self.m2 = nn.MaxPool2d(kernel_size=2, stride=2, pad_mode='SAME')  # 池化层2
        self.m3 = nn.MaxPool2d(kernel_size=2, stride=2, pad_mode='SAME')  # 池化层3
        self.m4 = nn.MaxPool2d(kernel_size=2, stride=2, pad_mode='SAME')  # 池化层4
        self.m5 = nn.MaxPool2d(kernel_size=3, stride=1, pad_mode='SAME')  # 池化层5

    def construct(self, x):
        """前向传播函数,定义数据流经网络的顺序。"""
        # block1
        x = self.b1(x)  # 通过第一个卷积块
        x = self.m1(x)  # 通过第一个池化层

        # block2
        x = self.b2(x)  # 通过第二个卷积块
        x = self.m2(x)  # 通过第二个池化层

        # block3
        x = self.b3(x)  # 通过第三个卷积块
        x = self.m3(x)  # 通过第三个池化层

        # block4
        x = self.b4(x)  # 通过第四个卷积块
        block4 = x  # 保存第四个块的输出,用于后续使用
        x = self.m4(x)  # 通过第四个池化层

        # block5
        x = self.b5(x)  # 通过第五个卷积块
        x = self.m5(x)  # 通过第五个池化层

        return block4, x  # 返回第四块的输出和最后的输出

解析:

  1. from mindspore import nn:导入MindSpore的神经网络模块,以便使用其构建神经网络的功能。
  2. _make_layer(channels):定义一个辅助函数,创建一个由多个卷积层和ReLU激活函数组成的序列。
    • channels:包含输入和输出通道数的列表。
    • 使用循环遍历输出通道数,构建卷积层并添加ReLU激活函数,构成一个SequentialCell对象。
  3. class Vgg16(nn.Cell):定义VGG16网络结构的类,继承自nn.Cell
    • __init__():构造函数,初始化VGG16的各个卷积层块和池化层。
      • self.b1self.b5:使用_make_layer定义的五个卷积层块,分别处理不同的通道数。
      • self.m1self.m5:定义五个最大池化操作,逐步缩减特征图的大小。
  4. def construct(self, x):定义前向传播函数,描述输入数据如何通过网络。
    • 将输入x逐层传递通过卷积层和池化层。
    • block4 = x:在第4个块的输出后保存该输出,以便后续使用。
    • 最后返回第4块的输出和最终的输出。

该代码实现了VGG16网络的基本结构,适用于图像分类和其他相关任务。

import mindspore as ms  # 导入MindSpore库
import mindspore.nn as nn  # 导入MindSpore的神经网络模块
import mindspore.ops as ops  # 导入MindSpore的操作模块

def _last_conv2d(in_channel, out_channel, kernel_size=3, stride=1, pad_mod='same', pad=0):
    """创建一个深度可分离卷积层,后接BatchNorm和ReLU6激活。"""
    in_channels = in_channel  # 输入通道数
    out_channels = in_channel  # 输出通道数
    # 深度可分离卷积
    depthwise_conv = nn.Conv2d(in_channels, out_channels, kernel_size, stride, pad_mode='same',
                               padding=pad, group=in_channels)
    # 1x1卷积,用于通道映射
    conv = nn.Conv2d(in_channel, out_channel, kernel_size=1, stride=1, padding=0, pad_mode='same', has_bias=True)
    # BatchNorm层
    bn = nn.BatchNorm2d(in_channel, eps=1e-3, momentum=0.97,
                        gamma_init=1, beta_init=0, moving_mean_init=0, moving_var_init=1)

    return nn.SequentialCell([depthwise_conv, bn, nn.ReLU6(), conv])  # 返回顺序执行的网络层

class FlattenConcat(nn.Cell):
    """FlattenConcat模块,将多个张量展平并连接。"""

    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))  # 变换维度,从(N, C, H, W)到(N, H, W, C)
            output += (ops.reshape(x, (batch_size, -1)),)  # 展平并添加到输出元组
        res = ops.concat(output, axis=1)  # 沿着第一个维度连接所有的展平输出
        return ops.reshape(res, (batch_size, self.num_ssd_boxes, -1))  # 返回最终的展平和连接的结果

class MultiBox(nn.Cell):
    """
    Multibox卷积层。每个multibox层包含类别置信度和定位预测。
    """

    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],
                                        kernel_size=3, stride=1, pad_mod='same', pad=0)]
            # 为类别预测添加卷积层
            cls_layers += [_last_conv2d(out_channel, num_classes * num_default[k],
                                        kernel_size=3, stride=1, pad_mod='same', pad=0)]

        self.multi_loc_layers = nn.CellList(loc_layers)  # 将位置层存入CellList
        self.multi_cls_layers = nn.CellList(cls_layers)  # 将类别层存入CellList
        self.flatten_concat = FlattenConcat()  # 实例化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]),)  # 计算类别输出
        return self.flatten_concat(loc_outputs), self.flatten_concat(cls_outputs)  # 返回展平后的输出

class SSD300Vgg16(nn.Cell):
    """SSD300Vgg16模块,构建SSD300 VGG16网络结构。"""

    def __init__(self):
        super(SSD300Vgg16, self).__init__()

        # VGG16 backbone: block1~5
        self.backbone = Vgg16()  # 实例化VGG16主干网络

        # SSD blocks: block6~7
        self.b6_1 = nn.Conv2d(in_channels=512, out_channels=1024, kernel_size=3, padding=6, dilation=6, pad_mode='pad')
        self.b6_2 = nn.Dropout(p=0.5)  # Dropout层

        self.b7_1 = nn.Conv2d(in_channels=1024, out_channels=1024, kernel_size=1)
        self.b7_2 = nn.Dropout(p=0.5)

        # Extra Feature Layers: block8~11
        self.b8_1 = nn.Conv2d(in_channels=1024, out_channels=256, kernel_size=1, padding=1, pad_mode='pad')
        self.b8_2 = nn.Conv2d(in_channels=256, out_channels=512, kernel_size=3, stride=2, pad_mode='valid')

        self.b9_1 = nn.Conv2d(in_channels=512, out_channels=128, kernel_size=1, padding=1, pad_mode='pad')
        self.b9_2 = nn.Conv2d(in_channels=128, out_channels=256, kernel_size=3, stride=2, pad_mode='valid')

        self.b10_1 = nn.Conv2d(in_channels=256, out_channels=128, kernel_size=1)
        self.b10_2 = nn.Conv2d(in_channels=128, out_channels=256, kernel_size=3, pad_mode='valid')

        self.b11_1 = nn.Conv2d(in_channels=256, out_channels=128, kernel_size=1)
        self.b11_2 = nn.Conv2d(in_channels=128, out_channels=256, kernel_size=3, pad_mode='valid')

        # boxes
        self.multi_box = MultiBox()  # 实例化MultiBox模块

    def construct(self, x):
        # VGG16 backbone: block1~5
        block4, x = self.backbone(x)  # 获取VGG16的块4和后续特征

        # SSD blocks: block6~7
        x = self.b6_1(x)  # 1024
        x = self.b6_2(x)  # Dropout

        x = self.b7_1(x)  # 1024
        x = self.b7_2(x)  # Dropout
        block7 = x  # 保存块7的输出

        # Extra Feature Layers: block8~11
        x = self.b8_1(x)  # 256
        x = self.b8_2(x)  # 512
        block8 = x  # 保存块8的输出

        x = self.b9_1(x)  # 128
        x = self.b9_2(x)  # 256
        block9 = x  # 保存块9的输出

        x = self.b10_1(x)  # 128
        x = self.b10_2(x)  # 256
        block10 = x  # 保存块10的输出

        x = self.b11_1(x)  # 128
        x = self.b11_2(x)  # 256
        block11 = x  # 保存块11的输出

        # boxes
        multi_feature = (block4, block7, block8, block9, block10, block11)  # 累加各个特征块
        pred_loc, pred_label = self.multi_box(multi_feature)  # 通过MultiBox计算位置和类别预测
        if not self.training:  # 如果不是训练模式,应用sigmoid激活
            pred_label = ops.sigmoid(pred_label)
        pred_loc = pred_loc.astype(ms.float32)  # 确保输出类型为float32
        pred_label = pred_label.astype(ms.float32)  # 确保输出类型为float32
        return pred_loc, pred_label  # 返回位置和类别预测

解析:

  1. import mindspore as ms:导入MindSpore库,用于深度学习模型的构建与训练。
  2. import mindspore.nn as nn:导入MindSpore的神经网络模块,提供构建神经网络所需的工具。
  3. import mindspore.ops as ops:导入操作模块,提供常用的数学和张量操作。
  4. _last_conv2d(...):定义一个深度可分离卷积层的构建函数。
    • in_channel:输入通道数。
    • out_channel:输出通道数。
    • kernel_sizestridepad_modpad:卷积参数。
    • 返回一个包括深度卷积、BatchNorm和ReLU激活的顺序模块。
  5. class FlattenConcat(nn.Cell):定义一个展平并连接多个输入的模块。
    • construct(self, inputs):实现输入张量的展平和连接,返回合并后的张量。
  6. class MultiBox(nn.Cell):定义Multibox卷积层,负责处理类别置信度和位置预测。
    • __init__(self):初始化位置层和类别层,构建多个卷积层。
    • construct(self, inputs):计算位置和类别输出。
  7. class SSD300Vgg16(nn.Cell):定义SSD300 VGG16网络结构。
    • __init__(self):构建VGG16主干网络和SSD模块,包括额外的卷积层。
    • construct(self, x):实现前向传播,获取各个块的输出,并通过Multibox进行最终位置和类别预测。

此代码实现了SSD300 VGG16模型,是一种用于目标检测的深度学习架构,能够处理输入图像并预测目标的位置和类别。

损失函数

SSD算法的目标函数分为两部分:计算相应的预选框与目标类别的置信度误差(confidence loss, conf)以及相应的位置误差(locatization loss, loc):

其中:
N 是先验框的正样本数量;
c 为类别置信度预测值;
l 为先验框的所对应边界框的位置预测值;
g 为ground truth的位置参数
α 用以调整confidence loss和location loss之间的比例,默认为1。

对于位置损失函数

针对所有的正样本,采用 Smooth L1 Loss, 位置信息都是 encode 之后的位置信息。

对于置信度损失函数

置信度损失是多类置信度©上的softmax损失。

def class_loss(logits, label):
    """计算类别损失."""
    
    # 将标签转换为one-hot编码形式
    label = ops.one_hot(label, ops.shape(logits)[-1], Tensor(1.0, ms.float32), Tensor(0.0, ms.float32))
    
    # 创建与logits相同形状的权重张量
    weight = ops.ones_like(logits)  # 权重为1
    pos_weight = ops.ones_like(logits)  # 正样本权重也为1
    
    # 计算带logits的二元交叉熵损失
    sigmiod_cross_entropy = ops.binary_cross_entropy_with_logits(logits, label, weight.astype(ms.float32), pos_weight.astype(ms.float32))
    
    # 计算sigmoid激活后的值
    sigmoid = ops.sigmoid(logits)
    
    # 确保标签的类型为float32
    label = label.astype(ms.float32)
    
    # 计算p_t,即正类和负类的预测概率
    p_t = label * sigmoid + (1 - label) * (1 - sigmoid)
    
    # 计算调制因子
    modulating_factor = ops.pow(1 - p_t, 2.0)  # (1 - p_t)^2
    
    # 计算alpha权重因子
    alpha_weight_factor = label * 0.75 + (1 - label) * (1 - 0.75)  # 使用0.75为正类的权重
    
    # 计算Focal Loss
    focal_loss = modulating_factor * alpha_weight_factor * sigmiod_cross_entropy
    
    return focal_loss  # 返回计算得到的Focal Loss

解析:

  1. def class_loss(logits, label):定义一个计算类别损失的函数。
    • logits:模型输出的原始预测值(未经过sigmoid激活)。
    • label:真实标签。
  2. label = ops.one_hot(...):将标签转换为one-hot编码,方便计算损失。
    • ops.shape(logits)[-1]:获取logits的最后一个维度的大小,作为类别的数量。
  3. weight = ops.ones_like(logits):创建一个与logits相同形状的权重张量,所有值为1。
  4. pos_weight = ops.ones_like(logits):创建一个正样本权重张量,所有值为1。
  5. sigmiod_cross_entropy = ops.binary_cross_entropy_with_logits(...):计算二元交叉熵损失。
    • 使用logits和one-hot编码的label进行计算,同时传入权重。
  6. sigmoid = ops.sigmoid(logits):对logits进行sigmoid激活,得到预测的概率分布。
  7. label = label.astype(ms.float32):确保标签的类型为float32,以便后续计算。
  8. p_t = label * sigmoid + (1 - label) * (1 - sigmoid):计算每个样本的预测概率,p_t为正类的预测概率。
  9. modulating_factor = ops.pow(1 - p_t, 2.0):计算调制因子,控制难易样本的损失权重。
  10. alpha_weight_factor = label * 0.75 + (1 - label) * (1 - 0.75):计算类别的权重因子,正类权重为0.75,负类权重为0.25。
  11. focal_loss = modulating_factor * alpha_weight_factor * sigmiod_cross_entropy:计算最终的Focal Loss,通过调制和类别权重进行修改。
  12. return focal_loss:返回计算得到的Focal Loss,用于训练过程中优化模型。

Focal Loss是一种用于处理类别不平衡问题的损失函数,强调难分类样本的损失,减少易分类样本对总损失的影响。

Metrics

在SSD中,训练过程是不需要用到非极大值抑制(NMS),但当进行检测时,例如输入一张图片要求输出框的时候,需要用到NMS过滤掉那些重叠度较大的预测框。
非极大值抑制的流程如下:

  1. 根据置信度得分进行排序

  2. 选择置信度最高的比边界框添加到最终输出列表中,将其从边界框列表中删除

  3. 计算所有边界框的面积

  4. 计算置信度最高的边界框与其它候选框的IoU

  5. 删除IoU大于阈值的边界框

  6. 重复上述过程,直至边界框列表为空

import json
from pycocotools.coco import COCO
from pycocotools.cocoeval import COCOeval

def apply_eval(eval_param_dict):
    """评估模型的函数."""
    net = eval_param_dict["net"]  # 从参数字典中获取网络模型
    net.set_train(False)  # 设置模型为评估模式
    ds = eval_param_dict["dataset"]  # 获取数据集
    anno_json = eval_param_dict["anno_json"]  # 获取标注文件路径
    # 创建COCOMetrics对象,负责计算COCO评估指标
    coco_metrics = COCOMetrics(anno_json=anno_json,
                               classes=train_cls,
                               num_classes=81,
                               max_boxes=100,
                               nms_threshold=0.6,
                               min_score=0.1)
    
    # 遍历数据集中的每个数据项
    for data in ds.create_dict_iterator(output_numpy=True, num_epochs=1):
        img_id = data['img_id']  # 获取图像ID
        img_np = data['image']  # 获取图像数据
        image_shape = data['image_shape']  # 获取图像尺寸
        
        output = net(Tensor(img_np))  # 使用网络模型进行推理

        # 遍历批次中的每个图像
        for batch_idx in range(img_np.shape[0]):
            pred_batch = {
                "boxes": output[0].asnumpy()[batch_idx],  # 获取预测框
                "box_scores": output[1].asnumpy()[batch_idx],  # 获取框得分
                "img_id": int(np.squeeze(img_id[batch_idx])),  # 获取图像ID
                "image_shape": image_shape[batch_idx]  # 获取图像尺寸
            }
            coco_metrics.update(pred_batch)  # 更新COCOMetrics实例的预测结果
    eval_metrics = coco_metrics.get_metrics()  # 计算评估指标
    return eval_metrics  # 返回评估结果

def apply_nms(all_boxes, all_scores, thres, max_boxes):
    """对边界框应用非极大值抑制 (NMS)."""
    y1 = all_boxes[:, 0]  # 获取左上角y坐标
    x1 = all_boxes[:, 1]  # 获取左上角x坐标
    y2 = all_boxes[:, 2]  # 获取右下角y坐标
    x2 = all_boxes[:, 3]  # 获取右下角x坐标
    areas = (x2 - x1 + 1) * (y2 - y1 + 1)  # 计算每个框的面积

    order = all_scores.argsort()[::-1]  # 按得分降序排序
    keep = []  # 保存保留的索引

    while order.size > 0:  # 当还有框未处理
        i = order[0]  # 选择得分最高的框
        keep.append(i)  # 将其加入保留列表

        if len(keep) >= max_boxes:  # 达到最大框数时停止
            break

        # 计算当前框与剩余框的交集
        xx1 = np.maximum(x1[i], x1[order[1:]])
        yy1 = np.maximum(y1[i], y1[order[1:]])
        xx2 = np.minimum(x2[i], x2[order[1:]])
        yy2 = np.minimum(y2[i], y2[order[1:]])

        w = np.maximum(0.0, xx2 - xx1 + 1)  # 计算宽度
        h = np.maximum(0.0, yy2 - yy1 + 1)  # 计算高度
        inter = w * h  # 计算交集面积

        ovr = inter / (areas[i] + areas[order[1:]] - inter)  # 计算IOU

        inds = np.where(ovr <= thres)[0]  # 找到IOU小于阈值的框

        order = order[inds + 1]  # 更新待处理框
    return keep  # 返回保留框的索引

class COCOMetrics:
    """计算预测边界框的mAP指标."""
    
    def __init__(self, anno_json, classes, num_classes, min_score, nms_threshold, max_boxes):
        self.num_classes = num_classes  # 类别数量
        self.classes = classes  # 类别列表
        self.min_score = min_score  # 最小得分阈值
        self.nms_threshold = nms_threshold  # NMS阈值
        self.max_boxes = max_boxes  # 最大框数量

        self.val_cls_dict = {i: cls for i, cls in enumerate(classes)}  # 类别字典
        self.coco_gt = COCO(anno_json)  # 加载COCO数据集的标注
        cat_ids = self.coco_gt.loadCats(self.coco_gt.getCatIds())  # 获取类别信息
        self.class_dict = {cat['name']: cat['id'] for cat in cat_ids}  # 类别名称到ID的映射

        self.predictions = []  # 存储预测结果
        self.img_ids = []  # 存储图像ID

    def update(self, batch):
        """更新预测结果."""
        pred_boxes = batch['boxes']  # 获取预测框
        box_scores = batch['box_scores']  # 获取框得分
        img_id = batch['img_id']  # 获取图像ID
        h, w = batch['image_shape']  # 获取图像高度和宽度

        final_boxes = []
        final_label = []
        final_score = []
        self.img_ids.append(img_id)  # 记录图像ID

        # 遍历每个类别
        for c in range(1, self.num_classes):
            class_box_scores = box_scores[:, c]  # 获取当前类别的得分
            score_mask = class_box_scores > self.min_score  # 筛选出得分高于阈值的框
            class_box_scores = class_box_scores[score_mask]  # 过滤得分
            class_boxes = pred_boxes[score_mask] * [h, w, h, w]  # 缩放框坐标

            if score_mask.any():  # 如果有框
                nms_index = apply_nms(class_boxes, class_box_scores, self.nms_threshold, self.max_boxes)  # 应用NMS
                class_boxes = class_boxes[nms_index]  # 获取NMS后的框
                class_box_scores = class_box_scores[nms_index]  # 获取NMS后的得分

                final_boxes += class_boxes.tolist()  # 收集最终框
                final_score += class_box_scores.tolist()  # 收集最终得分
                final_label += [self.class_dict[self.val_cls_dict[c]]] * len(class_box_scores)  # 收集最终标签

        # 生成预测结果
        for loc, label, score in zip(final_boxes, final_label, final_score):
            res = {}
            res['image_id'] = img_id  # 图像ID
            res['bbox'] = [loc[1], loc[0], loc[3] - loc[1], loc[2] - loc[0]]  # 存储边框
            res['score'] = score  # 存储得分
            res['category_id'] = label  # 存储类别ID
            self.predictions.append(res)  # 添加到预测结果列表

    def get_metrics(self):
        """计算评估指标并返回mAP值."""
        with open('predictions.json', 'w') as f:
            json.dump(self.predictions, f)  # 保存预测结果到文件

        coco_dt = self.coco_gt.loadRes('predictions.json')  # 加载预测结果
        E = COCOeval(self.coco_gt, coco_dt, iouType='bbox')  # 创建COCO评估对象
        E.params.imgIds = self.img_ids  # 设置评估图像ID
        E.evaluate()  # 评估
        E.accumulate()  # 累计结果
        E.summarize()  # 输出总结
        return E.stats[0]  # 返回mAP值

class SsdInferWithDecoder(nn.Cell):
    """SSD推理包装器,用于解码边框位置."""
    
    def __init__(self, network, default_boxes, ckpt_path):
        super(SsdInferWithDecoder, self).__init__()  # 初始化父类
        param_dict = ms.load_checkpoint(ckpt_path)  # 加载模型参数
        ms.load_param_into_net(network, param_dict)  # 将参数加载到网络中
        self.network = network  # 保存网络
        self.default_boxes = default_boxes  # 保存默认框
        self.prior_scaling_xy = 0.1  # x, y坐标缩放因子
        self.prior_scaling_wh = 0.2  # 宽高缩放因子

    def construct(self, x):
        """执行推理并解码边框位置."""
        pred_loc, pred_label = self.network(x)  # 获取预测的框位置和标签

        default_bbox_xy = self.default_boxes[..., :2]  # 获取默认框的xy坐标
        default_bbox_wh = self.default_boxes[..., 2:]  # 获取默认框的宽高
        pred_xy = pred_loc[..., :2] * self.prior_scaling_xy * default_bbox_wh + default_bbox_xy  # 解码x, y坐标
        pred_wh = ops.exp(pred_loc[..., 2:] * self.prior_scaling_wh) * default_bbox_wh  # 解码宽高

        pred_xy_0 = pred_xy - pred_wh / 2.0  # 获取左上角坐标
        pred_xy_1 = pred_xy + pred_wh / 2.0  # 获取右下角坐标
        pred_xy = ops.concat((pred_xy_0, pred_xy_1), -1)  # 连接坐标
        pred_xy = ops.maximum(pred_xy, 0)  # 确保坐标不小于0
        pred_xy = ops.minimum(pred_xy, 1)  # 确保坐标不大于1
        return pred_xy, pred_label  # 返回解码后的框坐标和标签

解析:

  1. apply_eval(eval_param_dict): 用于评估模型,接受一个字典参数,包含网络模型、数据集和标注文件路径。设置模型为评估模式,创建 COCOMetrics 实例来计算评估指标,遍历数据集进行推理,并更新评估结果。
  2. apply_nms(all_boxes, all_scores, thres, max_boxes): 应用非极大值抑制(NMS)来过滤重叠的边界框。计算每个框的面积,按得分排序并逐个检查重叠程度,保留得分高且重叠小的框。
  3. class COCOMetrics: 计算平均精度(mAP)的类,初始化时加载标注文件和类别信息,提供 update 方法来更新预测结果和 get_metrics 方法来计算最终评估指标。
  4. class SsdInferWithDecoder: SSD推理的包装器,负责加载模型和解码预测的边界框位置。通过缩放和转换默认框的方式来计算最终预测框的位置,确保框坐标在合理范围内。

这些代码片段展示了如何进行目标检测中的评估和推理过程,使用COCO数据集的评估工具来计算mAP指标,同时使用NMS来处理预测框。

训练过程

(1)先验框匹配

在训练过程中,首先要确定训练图片中的ground truth(真实目标)与哪个先验框来进行匹配,与之匹配的先验框所对应的边界框将负责预测它。
SSD的先验框与ground truth的匹配原则主要有两点:

  1. 对于图片中每个ground truth,找到与其IOU最大的先验框,该先验框与其匹配,这样可以保证每个ground truth一定与某个先验框匹配。通常称与ground truth匹配的先验框为正样本,反之,若一个先验框没有与任何ground truth进行匹配,那么该先验框只能与背景匹配,就是负样本。
  2. 对于剩余的未匹配先验框,若某个ground truth的IOU大于某个阈值(一般是0.5),那么该先验框也与这个ground truth进行匹配。尽管一个ground truth可以与多个先验框匹配,但是ground truth相对先验框还是太少了,所以负样本相对正样本会很多。为了保证正负样本尽量平衡,SSD采用了hard negative mining,就是对负样本进行抽样,抽样时按照置信度误差(预测背景的置信度越小,误差越大)进行降序排列,选取误差的较大的top-k作为训练的负样本,以保证正负样本比例接近1:3。

注意点:

  1. 通常称与gt匹配的prior为正样本,反之,若某一个prior没有与任何一个gt匹配,则为负样本。
  2. 某个gt可以和多个prior匹配,而每个prior只能和一个gt进行匹配。
  3. 如果多个gt和某一个prior的IOU均大于阈值,那么prior只与IOU最大的那个进行匹配。


如上图所示,训练过程中的 prior boxes 和 ground truth boxes 的匹配,基本思路是:让每一个 prior box 回归并且到 ground truth box,这个过程的调控我们需要损失层的帮助,他会计算真实值和预测值之间的误差,从而指导学习的走向。

(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  # 确保导入numpy库

from mindspore import set_seed

class GeneratDefaultBoxes():
    """
    生成SSD的默认框,按照(W, H, anchor_sizes)的顺序。
    `self.default_boxes` 的形状为 [anchor_sizes, H, W, 4],最后一维为 [y, x, h, w]。
    `self.default_boxes_tlbr` 的形状与 `self.default_boxes` 相同,最后一维为 [y1, x1, y2, x2]。
    """

    def __init__(self):
        # 计算缩放因子fk
        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, 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)  # 平均尺度
            
            # 根据特定条件生成默认框的大小
            if idex == 0 and not [[2], [2, 3], [2, 3], [2, 3], [2], [2]][idex]:
                w, h = sk1 * math.sqrt(2), sk1 / math.sqrt(2)  # 宽高比例为1:1的框
                all_sizes = [(0.1, 0.1), (w, h), (h, w)]  # 包含多种宽高比例的框
            else:
                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))  # 添加反转框
                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')

# 生成默认框
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)
matching_threshold = 0.5  # 设置匹配阈值

解析:

  1. **类 **GeneratDefaultBoxes: 此类用于生成SSD(Single Shot MultiBox Detector)模型的默认框。它使用一系列预定义的尺度和宽高比来创建候选框,以便在目标检测任务中使用。
  2. __init__** 方法**:
    • 计算缩放因子 fk,用于转换特征图尺寸。
    • 计算缩放比例并生成尺度列表 scales
    • 对于每个特征图尺寸,生成不同尺寸和比例的默认框,并将其存储在 self.default_boxes 中。
    • 通过内部函数 to_tlbr 将框的中心坐标和宽高转换为左上角和右下角坐标,便于后续的IoU计算。
  3. 生成默认框:
    • 在类实例化后,default_boxes_tlbrdefault_boxes 分别存储了转换后的框坐标和宽高格式的默认框。
  4. vol_anchors** 计算**: 通过解包 default_boxes_tlbr 的坐标,计算锚框的面积,用于后续的目标检测中的IoU计算。
  5. matching_threshold: 该变量定义了在进行目标框匹配时所需的IoU阈值,用于判断预测框和真实框之间的重叠程度。

代码展示了如何生成SSD模型所需的默认锚框,这些框在目标检测过程中用于评估预测结果与真实结果之间的匹配度。

from mindspore.common.initializer import initializer, TruncatedNormal
import numpy as np  # 确保导入numpy库
import math  # 导入math库以使用数学函数

def init_net_param(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':
                # 使用截断正态分布初始化
                p.set_data(initializer(TruncatedNormal(0.02), p.data.shape, p.data.dtype))
            else:
                # 使用指定的初始化方式
                p.set_data(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):
    """生成学习率数组."""
    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:  # 确保学习率不小于0
            lr = 0.0
        lr_each_step.append(lr)  # 添加当前学习率到列表

    current_step = global_step  # 当前步数
    lr_each_step = np.array(lr_each_step).astype(np.float32)  # 转换为numpy数组
    learning_rate = lr_each_step[current_step:]  # 获取当前步数及后续的学习率

    return learning_rate  # 返回学习率数组

解析:

  1. init_net_param(network, initialize_mode='TruncatedNormal'):
    • 此函数用于初始化给定网络的参数。
    • 使用 network.trainable_params() 获取所有可训练的参数。
    • 遍历每个参数,排除名字中包含betagammabias的参数,通常这些是用于归一化层。
    • 根据指定的初始化模式(默认为 TruncatedNormal),使用截断的正态分布初始化参数。通过 initializer 方法和 p.set_data() 设置参数的初始值。
  2. get_lr(global_step, lr_init, lr_end, lr_max, warmup_epochs, total_epochs, steps_per_epoch):
    • 此函数生成一个学习率数组,适用于训练过程中的学习率调整。
    • 根据输入的总步数和预热步数计算每一步的学习率。
    • 在预热阶段,学习率从 lr_init 线性增加到 lr_max
    • 在预热结束后,使用余弦退火策略调整学习率,逐渐降低到 lr_end
    • 确保学习率不小于0,并将每一步的学习率存储在列表中。
    • 返回从当前学习步 (global_step) 开始的学习率数组,用于后续训练。

这些代码段展示了如何在深度学习模型训练的过程中有效地初始化网络参数和动态调整学习率,以提高训练效果和收敛速度。

import time
from mindspore import Tensor
from mindspore.amp import DynamicLossScaler
from mindspore import nn, ops
from mindspore.common import set_seed

set_seed(1)

# 加载数据集
mindrecord_dir = "./datasets/MindRecord_COCO"  # MindRecord数据集目录
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()  # 获取数据集大小

# 从数据集中提取一个批次的数据
image, get_loc, gt_label, num_matched_boxes = next(dataset.create_tuple_iterator())

# 网络定义与初始化
network = SSD300Vgg16()  # 实例化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  # 计算平滑L1损失
    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("=================== 开始训练 =====================")
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 ")  # 输出每个epoch的损失和时间
ms.save_checkpoint(network, "ssd-60_9.ckpt")  # 保存训练好的模型
print("=================== 训练成功 =====================")

解析:

  1. 数据加载:
    • 使用 create_ssd_dataset 函数创建SSD数据集,并设置批大小、进程数等参数。
    • 从数据集中提取一批数据,包括图像、真实框位置(get_loc)、真实标签(gt_label)和匹配框数量(num_matched_boxes)。
  2. 网络定义与参数初始化:
    • 实例化SSD300Vgg16网络。
    • 使用 init_net_param 函数初始化网络参数。
  3. 学习率定义:
    • 使用 get_lr 函数生成学习率,设置学习率初始化值、结束值、最大值、预热和总训练周期。
  4. 优化器定义:
    • 定义一个Momentum优化器,用于更新网络参数。
  5. 前向传播过程:
    • 定义 forward_fn 函数,执行前向传播并计算位置损失和分类损失。
    • 使用平滑L1损失计算位置损失,分类损失通过 class_loss 函数计算。
  6. 梯度计算:
    • 使用 ms.value_and_grad 获取损失和梯度,利用动态损失缩放器 DynamicLossScaler 来稳定训练过程。
  7. 训练步骤:
    • 定义 train_step 函数,执行单次训练步骤,包括梯度计算和优化器更新。
  8. 训练循环:
    • 通过循环训练60个epoch,每个epoch中遍历整个数据集,记录训练损失和消耗时间。
    • 每个epoch结束后打印损失和时间,并在训练完成后保存模型。

代码实现了目标检测模型的训练过程,通过适当的网络结构、损失计算和优化步骤,确保模型能够有效学习。

评估

自定义eval_net()类对训练好的模型进行评估,调用了上述定义的SsdInferWithDecoder类返回预测的坐标及标签,然后分别计算了在不同的IoU阈值、area和maxDets设置下的Average Precision(AP)和Average Recall(AR)。使用COCOMetrics类计算mAP。模型在测试集上的评估指标如下。

精确率(AP)和召回率(AR)的解释

  • TP:IoU>设定的阈值的检测框数量(同一Ground Truth只计算一次)。
  • FP:IoU<=设定的阈值的检测框,或者是检测到同一个GT的多余检测框的数量。
  • FN:没有检测到的GT的数量。

精确率(AP)和召回率(AR)的公式

  • 精确率(Average Precision,AP):


精确率是将正样本预测正确的结果与正样本预测的结果和预测错误的结果的和的比值,主要反映出预测结果错误率。

  • 召回率(Average Recall,AR):


召回率是正样本预测正确的结果与正样本预测正确的结果和正样本预测错误的和的比值,主要反映出来的是预测结果中的漏检率。

关于以下代码运行结果的输出指标

  • 第一个值即为mAP(mean Average Precision), 即各类别AP的平均值。
  • 第二个值是iou取0.5的mAP值,是voc的评判标准。
  • 第三个值是评判较为严格的mAP值,可以反应算法框的位置精准程度;中间几个数为物体大小的mAP值。

对于AR看一下maxDets=10/100的mAR值,反应检出率,如果两者接近,说明对于这个数据集来说,不用检测出100个框,可以提高性能。

mindrecord_file = "./datasets/MindRecord_COCO/ssd_eval.mindrecord0"

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)

    # 实例化网络
    network = SSD300Vgg16()
    print("加载检查点!")
    net = SsdInferWithDecoder(network, Tensor(default_boxes), ckpt_path)  # 加载模型并解码

    net.set_train(False)  # 设置网络为评估模式
    total = ds.get_dataset_size() * batch_size  # 计算总图像数量
    print("\n========================================\n")
    print("总图像数量: ", total)  # 打印总图像数量

    # 设置评估参数
    eval_param_dict = {"net": net, "dataset": ds, "anno_json": anno_json}
    mAP = apply_eval(eval_param_dict)  # 计算mAP (平均精度均值)
    
    print("\n========================================\n")
    print(f"mAP: {mAP}")  # 打印mAP

def eval_net():
    print("开始评估!")
    ssd_eval(mindrecord_file, "./ssd-60_9.ckpt", anno_json)  # 调用评估函数

eval_net()  # 执行评估

解析:

  1. **评估函数 **ssd_eval:
    • 输入参数包括数据集路径 dataset_path、检查点路径 ckpt_path 和标注的JSON文件 anno_json
    • 设置批量大小为1,通常用于评估时处理单个样本。
    • 使用 create_ssd_dataset 函数创建评估数据集,设置 is_training=False 表示处于评估模式。
    • 实例化SSD300Vgg16网络,并加载预先训练好的模型检查点 ckpt_path。通过 SsdInferWithDecoder 来解码默认框。
    • 设置网络为评估模式,通过 net.set_train(False)
    • 计算并打印总图像数量。
  2. 评估参数设置:
    • 将网络、数据集和标注JSON文件打包到一个字典 eval_param_dict,用于后续评估计算。
    • 调用 apply_eval 函数计算平均精度均值(mAP),这是目标检测任务中评估模型性能的重要指标。
  3. **评估主函数 **eval_net:
    • 打印开始评估的消息。
    • 调用 ssd_eval 执行评估过程,传入必要的参数。
  4. 执行评估:
    • eval_net() 函数的调用启动整个评估流程,最终输出模型在给定数据集上的mAP。

这段代码展示了如何加载训练好的SSD模型进行评估,通过计算mAP来衡量模型的性能,适用于目标检测任务的模型评估阶段。

整体代码

#!/usr/bin/env python
# coding: utf-8

# 
# # SSD目标检测
# 
# [![下载Notebook](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/resource/_static/logo_notebook.svg)](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/notebook/r2.3/tutorials/application/zh_cn/cv/mindspore_ssd.ipynb)&emsp;[![下载样例代码](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/resource/_static/logo_download_code.svg)](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/notebook/r2.3/tutorials/application/zh_cn/cv/mindspore_ssd.py)&emsp;[![查看源文件](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/resource/_static/logo_source.svg)](https://gitee.com/mindspore/docs/blob/r2.3/tutorials/application/source_zh_cn/cv/ssd.ipynb)

# ## 模型简介
# 
# 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)。具体可参考论文<sup>[1]</sup>。
# SSD目标检测主流算法分成可以两个类型:
# 
# 1. two-stage方法:RCNN系列

# 
#     通过算法产生候选框,然后再对这些候选框进行分类和回归。

# 
# 2. one-stage方法:YOLO和SSD

# 
#     直接通过主干网络给出类别位置信息,不需要区域生成。

# 
# SSD是单阶段的目标检测算法,通过卷积神经网络进行特征提取,取不同的特征层进行检测输出,所以SSD是一种多尺度的检测方法。在需要检测的特征层,直接使用一个3 $\times$ 3卷积,进行通道的变换。SSD采用了anchor的策略,预设不同长宽比例的anchor,每一个输出特征层基于anchor预测多个检测框(4或者6)。采用了多尺度检测方法,浅层用于检测小目标,深层用于检测大目标。SSD的框架如下图:
# 
# ![SSD-1](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_1.png)
# 

# ### 模型结构
# 
# SSD采用VGG16作为基础模型,然后在VGG16的基础上新增了卷积层来获得更多的特征图以用于检测。SSD的网络结构如图所示。上面是SSD模型,下面是YOLO模型,可以明显看到SSD利用了多尺度的特征图做检测。
# 
# ![SSD-2](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_2.jpg)
# 

# 
# 两种单阶段目标检测算法的比较:

# SSD先通过卷积不断进行特征提取,在需要检测物体的网络,直接通过一个3 $\times$ 3卷积得到输出,卷积的通道数由anchor数量和类别数量决定,具体为(anchor数量*(类别数量+4))。  
# SSD对比了YOLO系列目标检测方法,不同的是SSD通过卷积得到最后的边界框,而YOLO对最后的输出采用全连接的形式得到一维向量,对向量进行拆解得到最终的检测框。

# ### 模型特点
# 
# - 多尺度检测
# 
#     在SSD的网络结构图中我们可以看到,SSD使用了多个特征层,特征层的尺寸分别是38 $\times$ 38,19 $\times$ 19,10 $\times$ 10,5 $\times$ 5,3 $\times$ 3,1 $\times$ 1,一共6种不同的特征图尺寸。大尺度特征图(较靠前的特征图)可以用来检测小物体,而小尺度特征图(较靠后的特征图)用来检测大物体。多尺度检测的方式,可以使得检测更加充分(SSD属于密集检测),更能检测出小目标。
# 
# - 采用卷积进行检测
# 
#     与YOLO最后采用全连接层不同,SSD直接采用卷积对不同的特征图来进行提取检测结果。对于形状为m $\times$ n $\times$ p的特征图,只需要采用3 $\times$ 3 $\times$ p这样比较小的卷积核得到检测值。
# 
# - 预设anchor
# 
#     在YOLOv1中,直接由网络预测目标的尺寸,这种方式使得预测框的长宽比和尺寸没有限制,难以训练。在SSD中,采用预设边界框,我们习惯称它为anchor(在SSD论文中叫default bounding boxes),预测框的尺寸在anchor的指导下进行微调。

# ## 环境准备
# 
# 本案例基于MindSpore实现,开始实验前,请确保本地已经安装了mindspore、download、pycocotools、opencv-python。

# ## 数据准备与处理
# 
# 本案例所使用的数据集为COCO 2017。为了更加方便地保存和加载数据,本案例中在数据读取前首先将COCO数据集转换成MindRecord格式。使用MindSpore Record数据格式可以减少磁盘IO、网络IO开销,从而获得更好的使用体验和性能提升。
# 首先我们需要下载处理好的MindRecord格式的COCO数据集。
# 运行以下代码将数据集下载并解压到指定路径。

# In[2]:


from download import download

dataset_url = "https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/notebook/datasets/ssd_datasets.zip"
path = "./"
path = download(dataset_url, path, kind="zip", replace=True)


# 然后我们为数据处理定义一些输入:

# In[3]:


coco_root = "./datasets/"
anno_json = "./datasets/annotations/instances_val2017.json"

train_cls = ['background', 'person', 'bicycle', 'car', 'motorcycle', 'airplane', 'bus',
             'train', 'truck', 'boat', 'traffic light', 'fire hydrant',
             'stop sign', 'parking meter', 'bench', 'bird', 'cat', 'dog',
             'horse', 'sheep', 'cow', 'elephant', 'bear', 'zebra',
             'giraffe', 'backpack', 'umbrella', 'handbag', 'tie',
             'suitcase', 'frisbee', 'skis', 'snowboard', 'sports ball',
             'kite', 'baseball bat', 'baseball glove', 'skateboard',
             'surfboard', 'tennis racket', 'bottle', 'wine glass', 'cup',
             'fork', 'knife', 'spoon', 'bowl', 'banana', 'apple',
             'sandwich', 'orange', 'broccoli', 'carrot', 'hot dog', 'pizza',
             'donut', 'cake', 'chair', 'couch', 'potted plant', 'bed',
             'dining table', 'toilet', 'tv', 'laptop', 'mouse', 'remote',
             'keyboard', 'cell phone', 'microwave', 'oven', 'toaster', 'sink',
             'refrigerator', 'book', 'clock', 'vase', 'scissors',
             'teddy bear', 'hair drier', 'toothbrush']

train_cls_dict = {}
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的概率水平翻转。

# In[4]:


import cv2
import numpy as np

def _rand(a=0., b=1.):
    return np.random.rand() * (b - a) + a

def intersect(box_a, box_b):
    """Compute the intersect of two sets of boxes."""
    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]

def jaccard_numpy(box_a, box_b):
    """Compute the jaccard overlap of two sets of boxes."""
    inter = intersect(box_a, box_b)
    area_a = ((box_a[:, 2] - box_a[:, 0]) *
              (box_a[:, 3] - box_a[:, 1]))
    area_b = ((box_b[2] - box_b[0]) *
              (box_b[3] - box_b[1]))
    union = area_a + area_b - inter
    return inter / union

def random_sample_crop(image, boxes):
    """Crop images and boxes randomly."""
    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):
        image_t = image
        w = _rand(0.3, 1.0) * width
        h = _rand(0.3, 1.0) * height
        # aspect ratio constraint b/t .5 & 2
        if h / w < 0.5 or h / w > 2:
            continue

        left = _rand() * (width - w)
        top = _rand() * (height - h)
        rect = np.array([int(top), int(left), int(top + h), int(left + w)])
        overlap = jaccard_numpy(boxes, rect)

        # dropout some boxes
        drop_mask = overlap > 0
        if not drop_mask.any():
            continue

        if overlap[drop_mask].min() < min_iou and overlap[drop_mask].max() > (min_iou + 0.2):
            continue

        image_t = image_t[rect[0]:rect[2], rect[1]:rect[3], :]
        centers = (boxes[:, :2] + boxes[:, 2:4]) / 2.0
        m1 = (rect[0] < centers[:, 0]) * (rect[1] < centers[:, 1])
        m2 = (rect[2] > centers[:, 0]) * (rect[3] > centers[:, 1])

        # mask in that both m1 and m2 are true
        mask = m1 * m2 * drop_mask

        # have any valid boxes? try again if not
        if not mask.any():
            continue

        # take only matching gt boxes
        boxes_t = boxes[mask, :].copy()
        boxes_t[:, :2] = np.maximum(boxes_t[:, :2], rect[:2])
        boxes_t[:, :2] -= rect[:2]
        boxes_t[:, 2:4] = np.minimum(boxes_t[:, 2:4], rect[2:4])
        boxes_t[:, 2:4] -= rect[:2]

        return image_t, boxes_t
    return image, boxes

def ssd_bboxes_encode(boxes):
    """Labels anchors with ground truth inputs."""

    def jaccard_with_anchors(bbox):
        """Compute jaccard score a box and the anchors."""
        # Intersection bbox and volume.
        ymin = np.maximum(y1, bbox[0])
        xmin = np.maximum(x1, bbox[1])
        ymax = np.minimum(y2, bbox[2])
        xmax = np.minimum(x2, bbox[3])
        w = np.maximum(xmax - xmin, 0.)
        h = np.maximum(ymax - ymin, 0.)

        # Volumes.
        inter_vol = h * w
        union_vol = vol_anchors + (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]) - inter_vol
        jaccard = inter_vol / union_vol
        return np.squeeze(jaccard)

    pre_scores = np.zeros((8732), dtype=np.float32)
    t_boxes = np.zeros((8732, 4), dtype=np.float32)
    t_label = np.zeros((8732), dtype=np.int64)
    for bbox in boxes:
        label = int(bbox[4])
        scores = jaccard_with_anchors(bbox)
        idx = np.argmax(scores)
        scores[idx] = 2.0
        mask = (scores > matching_threshold)
        mask = mask & (scores > pre_scores)
        pre_scores = np.maximum(pre_scores, scores * mask)
        t_label = mask * label + (1 - mask) * t_label
        for i in range(4):
            t_boxes[:, i] = mask * bbox[i] + (1 - mask) * t_boxes[:, i]

    index = np.nonzero(t_label)

    # Transform to tlbr.
    bboxes = np.zeros((8732, 4), dtype=np.float32)
    bboxes[:, [0, 1]] = (t_boxes[:, [0, 1]] + t_boxes[:, [2, 3]]) / 2
    bboxes[:, [2, 3]] = t_boxes[:, [2, 3]] - t_boxes[:, [0, 1]]

    # Encode features.
    bboxes_t = bboxes[index]
    default_boxes_t = default_boxes[index]
    bboxes_t[:, :2] = (bboxes_t[:, :2] - default_boxes_t[:, :2]) / (default_boxes_t[:, 2:] * 0.1)
    tmp = np.maximum(bboxes_t[:, 2:4] / default_boxes_t[:, 2:4], 0.000001)
    bboxes_t[:, 2:4] = np.log(tmp) / 0.2
    bboxes[index] = bboxes_t

    num_match = np.array([len(np.nonzero(t_label)[0])], dtype=np.int32)
    return bboxes, t_label.astype(np.int32), num_match

def preprocess_fn(img_id, image, box, is_training):
    """Preprocess function for dataset."""
    cv2.setNumThreads(2)

    def _infer_data(image, input_shape):
        img_h, img_w, _ = image.shape
        input_h, input_w = input_shape

        image = cv2.resize(image, (input_w, input_h))

        # When the channels of image is 1
        if len(image.shape) == 2:
            image = np.expand_dims(image, axis=-1)
            image = np.concatenate([image, image, image], axis=-1)

        return img_id, image, np.array((img_h, img_w), np.float32)

    def _data_aug(image, box, is_training, image_size=(300, 300)):
        ih, iw, _ = image.shape
        h, w = image_size
        if not is_training:
            return _infer_data(image, image_size)
        # Random crop
        box = box.astype(np.float32)
        image, box = random_sample_crop(image, box)
        ih, iw, _ = image.shape
        # Resize image
        image = cv2.resize(image, (w, h))
        # Flip image or not
        flip = _rand() < .5
        if flip:
            image = cv2.flip(image, 1, dst=None)
        # When the channels of image is 1
        if len(image.shape) == 2:
            image = np.expand_dims(image, axis=-1)
            image = np.concatenate([image, image, image], axis=-1)
        box[:, [0, 2]] = box[:, [0, 2]] / ih
        box[:, [1, 3]] = box[:, [1, 3]] / iw
        if flip:
            box[:, [1, 3]] = 1 - box[:, [3, 1]]
        box, label, num_match = ssd_bboxes_encode(box)
        return image, box, label, num_match

    return _data_aug(image, box, is_training, image_size=[300, 300])


# ### 数据集创建

# In[5]:


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."""
    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"])

    change_swap_op = HWC2CHW()
    # Computed from random subset of ImageNet training
    # images
    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的网络结构主要分为以下几个部分:
# 
# ![SSD-3](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_3.jpg)
# 
# - VGG16 Base Layer
# 
# - Extra Feature Layer
# 
# - Detection Layer
# 
# - NMS
# 
# - Anchor
# 
# ### Backbone Layer
# 
# ![SSD-4](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_4.png)
# 
# 输入图像经过预处理后大小固定为300×300,首先经过backbone,本案例中使用的是VGG16网络的前13个卷积层,然后分别将VGG16的全连接层fc6和fc7转换成3 $\times$ 3卷积层block6和1 $\times$ 1卷积层block7,进一步提取特征。 在block6中,使用了空洞数为6的空洞卷积,其padding也为6,这样做同样也是为了增加感受野的同时保持参数量与特征图尺寸的不变。
# 
# ### Extra Feature Layer
# 
# 在VGG16的基础上,SSD进一步增加了4个深度卷积层,用于提取更高层的语义信息:
# 
# ![SSD-5](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_5.png)
# 
# 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卷积进行特征提取。
# 
# ### Anchor
# 
# SSD采用了PriorBox来进行区域生成。将固定大小宽高的PriorBox作为先验的感兴趣区域,利用一个阶段完成能够分类与回归。设计大量的密集的PriorBox保证了对整幅图像的每个地方都有检测。PriorBox位置的表示形式是以中心点坐标和框的宽、高(cx,cy,w,h)来表示的,同时都转换成百分比的形式。
# PriorBox生成规则:
# SSD由6个特征层来检测目标,在不同特征层上,PriorBox的尺寸scale大小是不一样的,最低层的scale=0.1,最高层的scale=0.95,其他层的计算公式如下:
# 
# ![SSD-6](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_6.jpg)
# 
# 在某个特征层上其scale一定,那么会设置不同长宽比ratio的PriorBox,其长和宽的计算公式如下:
# 
# ![SSD-7](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_7.jpg)
# 
# 在ratio=1的时候,还会根据该特征层和下一个特征层计算一个特定scale的PriorBox(长宽比ratio=1),计算公式如下:
# 
# ![SSD-8](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_8.jpg)
# 
# 每个特征层的每个点都会以上述规则生成PriorBox,(cx,cy)由当前点的中心点来确定,由此每个特征层都生成大量密集的PriorBox,如下图:
# 
# ![SSD-9](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_9.png)
# 
# SSD使用了第4、7、8、9、10和11这6个卷积层得到的特征图,这6个特征图尺寸越来越小,而其对应的感受野越来越大。6个特征图上的每一个点分别对应4、6、6、6、4、4个PriorBox。某个特征图上的一个点根据下采样率可以得到在原图的坐标,以该坐标为中心生成4个或6个不同大小的PriorBox,然后利用特征图的特征去预测每一个PriorBox对应类别与位置的预测量。例如:第8个卷积层得到的特征图大小为10×10×512,每个点对应6个PriorBox,一共有600个PriorBox。定义MultiBox类,生成多个预测框。
# 
# ### Detection Layer
# 
# ![SSD-10](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_10.jpg)
# 
# 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。

# In[6]:


from mindspore import nn

def _make_layer(channels):
    in_channels = channels[0]
    layers = []
    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__()
        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)
        x = self.m1(x)

        # block2
        x = self.b2(x)
        x = self.m2(x)

        # block3
        x = self.b3(x)
        x = self.m3(x)

        # block4
        x = self.b4(x)
        block4 = x
        x = self.m4(x)

        # block5
        x = self.b5(x)
        x = self.m5(x)

        return block4, x


# In[7]:


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):
    in_channels = in_channel
    out_channels = in_channel
    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,
                        gamma_init=1, beta_init=0, moving_mean_init=0, moving_var_init=1)

    return nn.SequentialCell([depthwise_conv, bn, nn.ReLU6(), conv])

class FlattenConcat(nn.Cell):
    """FlattenConcat module."""

    def __init__(self):
        super(FlattenConcat, self).__init__()
        self.num_ssd_boxes = 8732

    def construct(self, inputs):
        output = ()
        batch_size = ops.shape(inputs[0])[0]
        for x in inputs:
            x = ops.transpose(x, (0, 2, 3, 1))
            output += (ops.reshape(x, (batch_size, -1)),)
        res = ops.concat(output, axis=1)
        return ops.reshape(res, (batch_size, self.num_ssd_boxes, -1))

class MultiBox(nn.Cell):
    """
    Multibox conv layers. Each multibox layer contains class conf scores and localization predictions.
    """

    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],
                                        kernel_size=3, stride=1, pad_mod='same', pad=0)]
            cls_layers += [_last_conv2d(out_channel, num_classes * num_default[k],
                                        kernel_size=3, stride=1, pad_mod='same', pad=0)]

        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]),)
        return self.flatten_concat(loc_outputs), self.flatten_concat(cls_outputs)

class SSD300Vgg16(nn.Cell):
    """SSD300Vgg16 module."""

    def __init__(self):
        super(SSD300Vgg16, self).__init__()

        # VGG16 backbone: block1~5
        self.backbone = Vgg16()

        # SSD blocks: block6~7
        self.b6_1 = nn.Conv2d(in_channels=512, out_channels=1024, kernel_size=3, padding=6, dilation=6, pad_mode='pad')
        self.b6_2 = nn.Dropout(p=0.5)

        self.b7_1 = nn.Conv2d(in_channels=1024, out_channels=1024, kernel_size=1)
        self.b7_2 = nn.Dropout(p=0.5)

        # Extra Feature Layers: block8~11
        self.b8_1 = nn.Conv2d(in_channels=1024, out_channels=256, kernel_size=1, padding=1, pad_mode='pad')
        self.b8_2 = nn.Conv2d(in_channels=256, out_channels=512, kernel_size=3, stride=2, pad_mode='valid')

        self.b9_1 = nn.Conv2d(in_channels=512, out_channels=128, kernel_size=1, padding=1, pad_mode='pad')
        self.b9_2 = nn.Conv2d(in_channels=128, out_channels=256, kernel_size=3, stride=2, pad_mode='valid')

        self.b10_1 = nn.Conv2d(in_channels=256, out_channels=128, kernel_size=1)
        self.b10_2 = nn.Conv2d(in_channels=128, out_channels=256, kernel_size=3, pad_mode='valid')

        self.b11_1 = nn.Conv2d(in_channels=256, out_channels=128, kernel_size=1)
        self.b11_2 = nn.Conv2d(in_channels=128, out_channels=256, kernel_size=3, pad_mode='valid')

        # boxes
        self.multi_box = MultiBox()

    def construct(self, x):
        # VGG16 backbone: block1~5
        block4, x = self.backbone(x)

        # SSD blocks: block6~7
        x = self.b6_1(x)  # 1024
        x = self.b6_2(x)

        x = self.b7_1(x)  # 1024
        x = self.b7_2(x)
        block7 = x

        # Extra Feature Layers: block8~11
        x = self.b8_1(x)  # 256
        x = self.b8_2(x)  # 512
        block8 = x

        x = self.b9_1(x)  # 128
        x = self.b9_2(x)  # 256
        block9 = x

        x = self.b10_1(x)  # 128
        x = self.b10_2(x)  # 256
        block10 = x

        x = self.b11_1(x)  # 128
        x = self.b11_2(x)  # 256
        block11 = x

        # boxes
        multi_feature = (block4, block7, block8, block9, block10, block11)
        pred_loc, pred_label = self.multi_box(multi_feature)
        if not self.training:
            pred_label = ops.sigmoid(pred_label)
        pred_loc = pred_loc.astype(ms.float32)
        pred_label = pred_label.astype(ms.float32)
        return pred_loc, pred_label


# ## 损失函数
# 
# SSD算法的目标函数分为两部分:计算相应的预选框与目标类别的置信度误差(confidence loss, conf)以及相应的位置误差(locatization loss, loc):
# 
# ![SSD-11](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_11.jpg)
# 
# 其中:

# N 是先验框的正样本数量;

# c 为类别置信度预测值; 

# l 为先验框的所对应边界框的位置预测值; 

# g 为ground truth的位置参数  

# α 用以调整confidence loss和location loss之间的比例,默认为1。
# 
# ### 对于位置损失函数
# 
# 针对所有的正样本,采用 Smooth L1 Loss, 位置信息都是 encode 之后的位置信息。
# 
# ![SSD-12](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_12.jpg)
# 
# ### 对于置信度损失函数
# 
# 置信度损失是多类置信度(c)上的softmax损失。
# 
# ![SSD-13](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_13.jpg)

# In[8]:


def class_loss(logits, label):
    """Calculate category losses."""
    label = ops.one_hot(label, ops.shape(logits)[-1], Tensor(1.0, ms.float32), Tensor(0.0, ms.float32))
    weight = ops.ones_like(logits)
    pos_weight = ops.ones_like(logits)
    sigmiod_cross_entropy = ops.binary_cross_entropy_with_logits(logits, label, weight.astype(ms.float32), pos_weight.astype(ms.float32))
    sigmoid = ops.sigmoid(logits)
    label = label.astype(ms
    float32)
    p_t = label * sigmoid + (1 - label) * (1 - sigmoid)
    modulating_factor = ops.pow(1 - p_t, 2.0)
    alpha_weight_factor = label * 0.75 + (1 - label) * (1 - 0.75)
    focal_loss = modulating_factor * alpha_weight_factor * sigmiod_cross_entropy
    return focal_loss


# ## Metrics
# 
# 在SSD中,训练过程是不需要用到非极大值抑制(NMS),但当进行检测时,例如输入一张图片要求输出框的时候,需要用到NMS过滤掉那些重叠度较大的预测框。

# 非极大值抑制的流程如下:
# 
# 1. 根据置信度得分进行排序
# 
# 2. 选择置信度最高的边界框添加到最终输出列表中,将其从边界框列表中删除

# 
# 3. 计算所有边界框的面积

# 
# 4. 计算置信度最高的边界框与其它候选框的IoU

# 
# 5. 删除IoU大于阈值的边界框

# 
# 6. 重复上述过程,直至边界框列表为空


# In[9]:


import json
from pycocotools.coco import COCO
from pycocotools.cocoeval import COCOeval


def apply_eval(eval_param_dict):
    net = eval_param_dict["net"]
    net.set_train(False)
    ds = eval_param_dict["dataset"]
    anno_json = eval_param_dict["anno_json"]
    coco_metrics = COCOMetrics(anno_json=anno_json,
                               classes=train_cls,
                               num_classes=81,
                               max_boxes=100,
                               nms_threshold=0.6,
                               min_score=0.1)
    for data in ds.create_dict_iterator(output_numpy=True, num_epochs=1):
        img_id = data['img_id']
        img_np = data['image']
        image_shape = data['image_shape']

        output = net(Tensor(img_np))

        for batch_idx in range(img_np.shape[0]):
            pred_batch = {
                "boxes": output[0].asnumpy()[batch_idx],
                "box_scores": output[1].asnumpy()[batch_idx],
                "img_id": int(np.squeeze(img_id[batch_idx])),
                "image_shape": image_shape[batch_idx]
            }
            coco_metrics.update(pred_batch)
    eval_metrics = coco_metrics.get_metrics()
    return eval_metrics


def apply_nms(all_boxes, all_scores, thres, max_boxes):
    """Apply NMS to bboxes."""
    y1 = all_boxes[:, 0]
    x1 = all_boxes[:, 1]
    y2 = all_boxes[:, 2]
    x2 = all_boxes[:, 3]
    areas = (x2 - x1 + 1) * (y2 - y1 + 1)

    order = all_scores.argsort()[::-1]
    keep = []

    while order.size > 0:
        i = order[0]
        keep.append(i)

        if len(keep) >= max_boxes:
            break

        xx1 = np.maximum(x1[i], x1[order[1:]])
        yy1 = np.maximum(y1[i], y1[order[1:]])
        xx2 = np.minimum(x2[i], x2[order[1:]])
        yy2 = np.minimum(y2[i], y2[order[1:]])

        w = np.maximum(0.0, xx2 - xx1 + 1)
        h = np.maximum(0.0, yy2 - yy1 + 1)
        inter = w * h

        ovr = inter / (areas[i] + areas[order[1:]] - inter)

        inds = np.where(ovr <= thres)[0]

        order = order[inds + 1]
    return keep


class COCOMetrics:
    """Calculate mAP of predicted bboxes."""

    def __init__(self, anno_json, classes, num_classes, min_score, nms_threshold, max_boxes):
        self.num_classes = num_classes
        self.classes = classes
        self.min_score = min_score
        self.nms_threshold = nms_threshold
        self.max_boxes = max_boxes

        self.val_cls_dict = {i: cls for i, cls in enumerate(classes)}
        self.coco_gt = COCO(anno_json)
        cat_ids = self.coco_gt.loadCats(self.coco_gt.getCatIds())
        self.class_dict = {cat['name']: cat['id'] for cat in cat_ids}

        self.predictions = []
        self.img_ids = []

    def update(self, batch):
        pred_boxes = batch['boxes']
        box_scores = batch['box_scores']
        img_id = batch['img_id']
        h, w = batch['image_shape']

        final_boxes = []
        final_label = []
        final_score = []
        self.img_ids.append(img_id)

        for c in range(1, self.num_classes):
            class_box_scores = box_scores[:, c]
            score_mask = class_box_scores > self.min_score
            class_box_scores = class_box_scores[score_mask]
            class_boxes = pred_boxes[score_mask] * [h, w, h, w]

            if score_mask.any():
                nms_index = apply_nms(class_boxes, class_box_scores, self.nms_threshold, self.max_boxes)
                class_boxes = class_boxes[nms_index]
                class_box_scores = class_box_scores[nms_index]

                final_boxes += class_boxes.tolist()
                final_score += class_box_scores.tolist()
                final_label += [self.class_dict[self.val_cls_dict[c]]] * len(class_box_scores)

        for loc, label, score in zip(final_boxes, final_label, final_score):
            res = {}
            res['image_id'] = img_id
            res['bbox'] = [loc[1], loc[0], loc[3] - loc[1], loc[2] - loc[0]]
            res['score'] = score
            res['category_id'] = label
            self.predictions.append(res)

    def get_metrics(self):
        with open('predictions.json', 'w') as f:
            json.dump(self.predictions, f)

        coco_dt = self.coco_gt.loadRes('predictions.json')
        E = COCOeval(self.coco_gt, coco_dt, iouType='bbox')
        E.params.imgIds = self.img_ids
        E.evaluate()
        E.accumulate()
        E.summarize()
        return E.stats[0]


class SsdInferWithDecoder(nn.Cell):
    """
    SSD Infer wrapper to decode the bbox locations."""
    def __init__(self, network, default_boxes, ckpt_path):
        super(SsdInferWithDecoder, self).__init__()
        param_dict = ms.load_checkpoint(ckpt_path)
        ms.load_param_into_net(network, param_dict)
        self.network = network
        self.default_boxes = default_boxes
        self.prior_scaling_xy = 0.1
        self.prior_scaling_wh = 0.2

    def construct(self, x):
        pred_loc, pred_label = self.network(x)

        default_bbox_xy = self.default_boxes[..., :2]
        default_bbox_wh = self.default_boxes[..., 2:]
        pred_xy = pred_loc[..., :2] * self.prior_scaling_xy * default_bbox_wh + default_bbox_xy
        pred_wh = ops.exp(pred_loc[..., 2:] * self.prior_scaling_wh) * default_bbox_wh

        pred_xy_0 = pred_xy - pred_wh / 2.0
        pred_xy_1 = pred_xy + pred_wh / 2.0
        pred_xy = ops.concat((pred_xy_0, pred_xy_1), -1)
        pred_xy = ops.maximum(pred_xy, 0)
        pred_xy = ops.minimum(pred_xy, 1)
        return pred_xy, pred_label


# ## 训练过程
# 
# ### (1)先验框匹配
# 
# 在训练过程中,首先要确定训练图片中的ground truth(真实目标)与哪个先验框来进行匹配,与之匹配的先验框所对应的边界框将负责预测它。
# 
# SSD的先验框与ground truth的匹配原则主要有两点:
# 
# 1. 对于图片中每个ground truth,找到与其IOU最大的先验框,该先验框与其匹配,这样可以保证每个ground truth一定与某个先验框匹配。通常称与ground truth匹配的先验框为正样本,反之,若一个先验框没有与任何ground truth进行匹配,那么该先验框只能与背景匹配,就是负样本。
# 
# 2. 对于剩余的未匹配先验框,若某个ground truth的IOU大于某个阈值(一般是0.5),那么该先验框也与这个ground truth进行匹配。尽管一个ground truth可以与多个先验框匹配,但是ground truth相对先验框还是太少了,所以负样本相对正样本会很多。为了保证正负样本尽量平衡,SSD采用了hard negative mining,就是对负样本进行抽样,抽样时按照置信度误差(预测背景的置信度越小,误差越大)进行降序排列,选取误差的较大的top-k作为训练的负样本,以保证正负样本比例接近1:3。
# 
# 注意点:
# 
# 1. 通常称与gt匹配的prior为正样本,反之,若某一个prior没有与任何一个gt匹配,则为负样本。
# 
# 2. 某个gt可以和多个prior匹配,而每个prior只能和一个gt进行匹配。
# 
# 3. 如果多个gt和某一个prior的IOU均大于阈值,那么prior只与IOU最大的那个进行匹配。
# 
# ![SSD-14](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/website-images/r2.3/tutorials/application/source_zh_cn/cv/images/SSD_14.jpg)
# 
# 如上图所示,训练过程中的 prior boxes 和 ground truth boxes 的匹配,基本思路是:让每一个 prior box 回归并且到 ground truth box,这个过程的调控我们需要损失层的帮助,他会计算真实值和预测值之间的误差,从而指导学习的走向。
# 
# ### (2)损失函数
# 
# 损失函数使用的是上文提到的位置损失函数和置信度损失函数的加权和。
# 
# ### (3)数据增强
# 
# 使用之前定义好的数据增强方式,对创建好的数据增强方式进行数据增强。
# 
# 模型训练时,设置模型训练的epoch次数为60,然后通过create_ssd_dataset类创建了训练集和验证集。batch_size大小为5,图像尺寸统一调整为300×300。损失函数使用位置损失函数和置信度损失函数的加权和,优化器使用Momentum,并设置初始学习率为0.001。回调函数方面使用了LossMonitor和TimeMonitor来监控训练过程中每个epoch结束后,损失值Loss的变化情况以及每个epoch、每个step的运行时间。设置每训练10个epoch保存一次模型。

# In[10]:


import math
import itertools as it

from mindspore import set_seed

class GeneratDefaultBoxes():
    """
    Generate Default boxes for SSD, follows the order of (W, H, archor_sizes).
    `self.default_boxes` has a shape of [archor_sizes, H, W, 4], the last dimension is [y, x, h, w].
    `self.default_boxes_tlbr` has a shape as `self.default_boxes`, the last dimension is [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, 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)
            if idex == 0 and not [[2], [2, 3], [2, 3], [2, 3], [2], [2]][idex]:
                w, h = sk1 * math.sqrt(2), sk1 / math.sqrt(2)
                all_sizes = [(0.1, 0.1), (w, h), (h, w)]
            else:
                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))
                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

        # For IoU calculation
        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')

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)
matching_threshold = 0.5


# In[11]:


from mindspore.common.initializer import initializer, TruncatedNormal


def init_net_param(network, initialize_mode='TruncatedNormal'):
    """Init the parameters in net."""
    params = network.trainable_params()
    for p in params:
        if 'beta' not in p.name and 'gamma' not in p.name and 'bias' not in p.name:
            if initialize_mode == 'TruncatedNormal':
                p.set_data(initializer(TruncatedNormal(0.02), p.data.shape, p.data.dtype))
            else:
                p.set_data(initializer, p.data.shape, p.data.dtype)


def get_lr(global_step, lr_init, lr_end, lr_max, warmup_epochs, total_epochs, steps_per_epoch):
    """ generate learning rate array"""
    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)

    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


# In[12]:


import time

from mindspore.amp import DynamicLossScaler

set_seed(1)

# load data
mindrecord_dir = "./datasets/MindRecord_COCO"
mindrecord_file = "./datasets/MindRecord_COCO/ssd.mindrecord0"

dataset = create_ssd_dataset(mindrecord_file, batch_size=5, rank=0, use_multiprocessing=True)
dataset_size = dataset.get_dataset_size()

image, get_loc, gt_label, num_matched_boxes = next(dataset.create_tuple_iterator())

# Network definition and initialization
network = SSD300Vgg16()
init_net_param(network)

# Define the learning rate
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))

# Define the optimizer
opt = nn.Momentum(filter(lambda x: x.requires_grad, network.get_parameters()), lr,
                  0.9, 0.00015, float(1024))

# Define the forward procedure
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))

    # Positioning loss
    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)

    # Category loss
    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)

# Gradient updates
def train_step(x, gt
_loc, gt_label, num_matched_boxes):
    """Perform a training step."""
    loss_scaler.reinit()
    loss_scaler.set_train()

    # Forward pass and compute loss
    (loss, ) = loss_scaler.unscale(grad_fn)(x, gt_loc, gt_label, num_matched_boxes)
    
    # Backward pass and updates
    loss_scaler.backward(loss)
    opt.step()
    opt.clear_grad()
    
    return loss


# Initialize training parameters
num_epochs = 60
loss_list = []

# Training loop
for epoch in range(num_epochs):
    epoch_loss = 0.0
    start_time = time.time()

    for data in dataset.create_dict_iterator(num_epochs=1):
        image, gt_loc, gt_label, num_matched_boxes = data['image'], data['box'], data['label'], data['num_match']
        loss = train_step(image, gt_loc, gt_label, num_matched_boxes)
        epoch_loss += loss.asnumpy()

    avg_loss = epoch_loss / dataset_size
    loss_list.append(avg_loss)

    # Print epoch metrics
    print(f"Epoch [{epoch + 1}/{num_epochs}], Loss: {avg_loss:.4f}, Time: {time.time() - start_time:.2f}s")

# Save the model after training
ms.save_checkpoint(network, "ssd_model.ckpt")

# ## 模型评估与推理
# 
# 一旦模型训练完成,下一步就是对模型进行评估和推理,在给定的验证集上执行预测,并使用COCO评估指标来评估模型性能。

# In[13]:


# Load model for evaluation
def load_model(ckpt_path):
    """Load the model checkpoint."""
    network = SSD300Vgg16()
    param_dict = ms.load_checkpoint(ckpt_path)
    ms.load_param_into_net(network, param_dict)
    return network


# Evaluate the model
def evaluate_model(network, dataset, anno_json):
    """Evaluate the model using COCO metrics."""
    coco_metrics = COCOMetrics(anno_json=anno_json,
                               classes=train_cls,
                               num_classes=81,
                               max_boxes=100,
                               nms_threshold=0.6,
                               min_score=0.01)

    for data in dataset.create_dict_iterator(output_numpy=True, num_epochs=1):
        img_id = data['img_id']
        img_np = data['image']
        image_shape = data['image_shape']

        output = network(Tensor(img_np))

        for batch_idx in range(img_np.shape[0]):
            pred_batch = {
                "boxes": output[0].asnumpy()[batch_idx],
                "box_scores": output[1].asnumpy()[batch_idx],
                "img_id": int(np.squeeze(img_id[batch_idx])),
                "image_shape": image_shape[batch_idx]
            }
            coco_metrics.update(pred_batch)

    eval_metrics = coco_metrics.get_metrics()
    return eval_metrics


# Load the trained model
ssd_network = load_model("ssd_model.ckpt")

# Create the evaluation dataset
eval_mindrecord_file = "./datasets/MindRecord_COCO/ssd_eval.mindrecord"
eval_dataset = create_ssd_dataset(eval_mindrecord_file, batch_size=5, rank=0)

# Evaluate the model
eval_results = evaluate_model(ssd_network, eval_dataset, anno_json)
print("Evaluation Results:", eval_results)

# ## 结束
# 
# 本文档展示了如何使用MindSpore实现SSD目标检测模型的完整流程,包括数据准备、模型构建、训练及评估等步骤。希望对您学习和实现目标检测有所帮助。

代码解析:

  1. 导入必要的库和模块
    • 导入了numpymindspore等库用于数据处理、模型构建和训练。
  2. 定义生成默认框的类
    • GeneratDefaultBoxes类用于生成SSD模型所需的默认框(anchor),这些框用于匹配实际目标。
  3. 网络参数初始化
    • init_net_param函数对网络中的参数进行初始化。
  4. 学习率调度
    • get_lr函数根据当前训练进度动态生成学习率。
  5. 训练步骤定义
    • train_step函数执行一次训练步骤,包括前向传播、损失计算、反向传播和参数更新。
  6. 训练循环
    • 通过循环迭代多个epoch进行模型训练,并计算每个epoch的平均损失。
  7. 模型评估
    • 加载训练好的模型并在验证集上进行评估,使用COCO评估指标计算模型性能。
  8. 结果输出
    • 打印并输出训练和评估的结果。

API解析:

  • Tensor: MindSpore中的张量类型,用于存储数据。
  • nn.Cell: MindSpore中神经网络模型的基本类。
  • Dataset: MindSpore中用于数据处理的类。
  • COCO: 用于处理COCO数据集的API,包括加载和评估。
  • Momentum: 优化器类型,适用于梯度下降的加速。
  • 15
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值