目标检测第三篇:基于SSD的目标检测算法

news2024/11/15 17:36:15

文章目录

  • SSD简介
  • 网络搭建
    • 卷积块
    • 下采样块
    • 主干网
    • 多层特征提起层
    • 输出头
  • 数据处理
    • 形成训练TXT
    • Dataset
    • DataLoader
    • Anchors
      • 生成先验框
      • 匹配先验框
      • 位置 offset
  • 损失函数
  • 训练
  • 代码及参考

SSD简介

SSD,全称Single Shot MultiBox Detector,是Wei Liu在ECCV 2016上提出的一种目标检测算法,截至目前是主要的检测框架之一,相比Faster RCNN有明显的速度优势,相比YOLO又有明显的mAP优势,是在 RCNN 系列和 YOLO 系列之外属于单阶段的另外一系列的奠基之作。
论文链接:https://arxiv.org/pdf/1512.02325.pdf
官方代码:https://github.com/weiliu89/caffe/tree/ssd

SSD的主要设计理念:根据不同的特征层设置不同大小的先验框,在不同的特征层上建立检测头和分类头,以满足大、小目标检测的需求。关于SSD的具体的技术细节,网上大神给出的解释很多,这里不再赘述。
网络架构如下:
在这里插入图片描述
本文主要是使用 pytorch 对 SSD 进行简单的实现。包括各个模块的讲解、数据的前、后处理、训练参数解释等。

网络搭建

论文中以 VGG16 为主干网络,替换了 VGG16 5_3 层和后面的部分,换成了 3*3 的卷积,再加上多尺度特征层,来实现多个检测和分类头。

卷积块

论文中以 卷积 + 激活函数为一个卷积标准模块,这里实现的时候加上了 BatchNorm,具体实现如下:

def conv_blk(in_channels, out_channels, stride=1, padding=1):
    """卷积块"""
    return nn.Sequential(nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride,padding=padding),
                         nn.BatchNorm2d(out_channels),
                         nn.ReLU())

下采样块

论文中以 池化层作为下采样层,来实现特征层的大小减半,这里实现的时候,将 卷积 + 池化层作为一个标准下采样模块,具体实现如下:

def down_sample_blk(in_channels, out_channels, ceil_mode = False):
    """下采样块"""
    return nn.Sequential(conv_blk(in_channels, out_channels),
                         nn.MaxPool2d(2, ceil_mode=ceil_mode))

主干网

使用上面两个模块,按照 VGG16 网络的基本架构实现 主干网:具体实现代码如下:

def backbone(input_shape):
    """搭建vgg16主干网络"""
    return nn.Sequential(
        conv_blk(input_shape[0], 64),
        down_sample_blk(64, 64),
        conv_blk(64, 128),
        down_sample_blk(128, 128),
        conv_blk(128, 256),
        conv_blk(256, 256),
        down_sample_blk(256, 512, ceil_mode=True),
        conv_blk(512, 512),
        conv_blk(512, 512),
        conv_blk(512, 512) # vgg16 4-3层
    )

多层特征提起层

主干网仅仅到 vgg16 4-3层, 后面的我们需要加上特征提取层,来实现输出不同特征层的需求,具体实现代码如下:

class extra_feature(nn.Module):
    """根据backbone输出的特征图,生成额外的特征图"""
    def __init__(self, input_shape) -> None:
        super().__init__()
        # (3, 300, 300) -> (512, 38, 38)
        self.out_layer1 = backbone(input_shape)

        # (512, 38, 38) -> (512, 19, 19)
        self.out_layer2 = nn.Sequential(
            nn.MaxPool2d(2, stride=1, padding=1),
            conv_blk(512, 512),
            conv_blk(512, 512),
            down_sample_blk(512, 521),
            conv_blk(521, 1024),
            conv_blk(1024, 1024)
        )

        # (512, 19, 19) -> (256, 10, 10)
        self.out_layer3 = nn.Sequential(
            conv_blk(1024, 256),
            conv_blk(256, 512, stride=2)
        )

        # (256, 10, 10) -> (256, 5, 5)
        self.out_layer4 = nn.Sequential(
            conv_blk(512, 128),
            conv_blk(128, 256, stride=2)
        )

        # (256, 5, 5) -> (256, 3, 3)
        self.out_layer5 = nn.Sequential(
            conv_blk(256, 128),
            conv_blk(128, 256, stride=2)
        )

        # (256, 3, 3) -> (256, 1, 1)
        self.out_layer6 = nn.Sequential(
            conv_blk(256, 128),
            nn.Conv2d(128, 256, kernel_size=3, stride=1, padding=0)
        )
    def forward(self, x):
        out1 = self.out_layer1(x)
        out2 = self.out_layer2(out1)
        out3 = self.out_layer3(out2)
        out4 = self.out_layer4(out3)
        out5 = self.out_layer5(out4)
        out6 = self.out_layer6(out5)
        return out1, out2, out3, out4, out5, out6

经过 extra_feature 层,我们将会得到六层特征层的输出,对应上面图片的六层输出层,特征层的大小依次为:(38, 38)、(19, 19)、(10, 10)、(5, 5)、(3, 3)、(1, 1)。

输出头

得到六个输出层之后,我们需要对六层输出的通道数目进行调整,使其为我们需要分类的数目和需要输出的检测框的数目的大小。具体代码如下:

def mutiboxhead(num_classes):
    """搭建SSD多尺度检测头"""
    num_anchors = [4, 6, 6, 6, 4, 4] # 每个尺度的锚框数量
    num_channels = [512, 1024, 512, 256, 256, 256] # 每个尺度的通道数量
    cls_predictors = [] # 类别预测器
    bbox_predictors = [] # 边界框预测器
    for i in range(6):
        cls_predictors.append(nn.Conv2d(num_channels[i], num_anchors[i] * (num_classes + 1), kernel_size=3, padding=1))
        bbox_predictors.append(nn.Conv2d(num_channels[i], num_anchors[i] * 4, kernel_size=3, padding=1))
    cls_predictors = nn.ModuleList(cls_predictors)
    bbox_predictors = nn.ModuleList(bbox_predictors)
    return cls_predictors, bbox_predictors

根据上面构建的所有东西来实现我们的SSD网络,实现代码如下:

class SSD(nn.Module):
    def __init__(self, input_shape=(3, 300, 300), num_classes=20, **kwargs):
        super(SSD, self).__init__(**kwargs)
        self.num_classes = num_classes
        self.input_shape = input_shape
        self.extra_feature = extra_feature(input_shape)
        self.cls_predictors, self.bbox_predictors = mutiboxhead(num_classes)
    
    def forward(self, x):
        x = self.extra_feature(x)
        cls_preds = []
        bbox_preds = []
        for i, feature in enumerate(x):
            cls_preds.append(self.cls_predictors[i](feature).permute(0, 2, 3, 1)) # (batch_size, num_classes, h, w) -> (batch_size, h, w, num_classes)
            bbox_preds.append(self.bbox_predictors[i](feature).permute(0, 2, 3, 1)) # (batch_size, 4, h, w) -> (batch_size, h, w, 4)
        
        bbox_preds = torch.cat([pred.reshape(pred.shape[0], -1, 4) for pred in bbox_preds], dim=1) # (batch_size, num_anchors, 4)
        cls_preds = torch.cat([pred.reshape(pred.shape[0], -1, self.num_classes + 1) for pred in cls_preds], dim=1) # (batch_size, num_anchors, num_classes + 1)
        return bbox_preds, cls_preds # (batch_size, num_anchors, 4), (batch_size, num_anchors, num_classes + 1)

如果对搭建的网络参数或者输出的形状不是很放心的话,可以输出和跟踪一下看看具体的网络的架构。

if __name__ == "__main__":
    x = torch.randn(1, 3, 300, 300)
    ssd = SSD((3, 300, 300), 20)
    cls_preds, bbox_preds = ssd(x)
    print(cls_preds[0].shape, bbox_preds[0].shape)

数据处理

一般情况下,我们使用 COCO 或者 VOC 数据集进行训练,这里以 VOC 数据进行讲解,如何加载数据。

形成训练TXT

VOC 的标签为 XML 文件,在网络加载数据的时候,不是很方便,这里将其处理为 TXT 文件,方便我们读取。处理的结果如下:
在这里插入图片描述
每一行分别为:图片的路径,x_min y_min x_max y_max label … x_min y_min x_max y_max label
在后面加载数据的时候,直接读取即可,稍微方便一点。
处理的代码如下;

# 从 voc 数据集中读取数据,并生成可用于训练、验证和测试txt文件
import os
try:
    import xml.etree.cElementTree as ET
except ImportError:
    import xml.etree.ElementTree as ET

def get_class_names(class_names_path):
    """
    获取数据集的类别名字
    Args:
        class_names_path (str): 数据集路径
    Returns:
        class_names (list): 类别名字列表
    """
    with open(class_names_path, "r") as f:
        class_names = f.readlines()
    class_names = {c.strip(): i  for i, c in enumerate(class_names)}

    return class_names, len(class_names)

def write_txt(txt_path, JPEGImages_path, annotation_path, txt_f, name_dict):
    """
    将txt文件写入到txt_path
    Args:
        txt_path (str): txt文件保存路径
        txt_f (list): txt文件内容
    """
    with open(txt_path, "r") as f:
        img_name_lists = f.readlines()
        train_name_lists = [img_name.strip() for img_name in img_name_lists]
        for train_name in train_name_lists:
            txt_f.write(os.path.join(JPEGImages_path, train_name + ".jpg").replace('\\', '/'))
            xml_name = train_name + ".xml"
            xml_path = os.path.join(annotation_path, xml_name)
            tree = ET.parse(xml_path)
            root = tree.getroot()
            for obj in root.iter("object"):
                cls = name_dict[obj.find("name").text]
                xmlbox = obj.find("bndbox")
                xmin = xmlbox.find("xmin").text
                ymin = xmlbox.find("ymin").text
                xmax = xmlbox.find("xmax").text
                ymax = xmlbox.find("ymax").text
                txt_f.write(f",{xmin} {ymin} {xmax} {ymax} {cls}")
            txt_f.write("\n")

def convert_annotation_2_txt(dataset_path, txt_path):
    """
    将voc数据集的xml标注转换为txt格式
    Args:
        dataset_path (str): 数据集路径
        txt_path (str): txt文件保存路径
    """
    annotation_path = os.path.join(dataset_path, "Annotations")
    ImageSets_path = os.path.join(dataset_path, "ImageSets/Main")
    JPEGImages_path = os.path.join(dataset_path, "JPEGImages")

    train_txt_f = open(os.path.join(txt_path, "train.txt"), "w")
    val_txt_f = open(os.path.join(txt_path, "val.txt"), "w")
    test_txt_f = open(os.path.join(txt_path, "test.txt"), "w")

    name_dict, _ = get_class_names(os.path.join(txt_path, "voc_classes.txt"))

    write_txt(os.path.join(ImageSets_path, "train.txt"), JPEGImages_path, annotation_path, train_txt_f, name_dict)
    train_txt_f.close()

    write_txt(os.path.join(ImageSets_path, "val.txt"), JPEGImages_path, annotation_path, val_txt_f, name_dict)
    val_txt_f.close()

    write_txt(os.path.join(ImageSets_path, "test.txt"), JPEGImages_path, annotation_path, test_txt_f, name_dict)
    test_txt_f.close()
    
if __name__ == "__main__":
    convert_annotation_2_txt("你的\VOC07+12", "输出txt想要保存的地点")

运行完以上程序,会形成 train.txt, test.txt val.txt,分别对应训练集、验证集和测试集。训练的时候读取对应的txt即可。

Dataset

获得完上面的 txt, 我们就可以编写自己的 Dataset 函数,形成自己在网络训练时候的数据加载方式。具体代码如下:

# 加载数据集的函数
class SSDDataset(Dataset):
    """
    定义自己的数据集加载方式
    """
    def __init__(self, data_lines, transform=None):
        """
        初始化文件路径和数据增强所需的信息
        Args:
            data (list): 文件路径列表
            transform: torchvision.transforms
        """
        self.data_lines = data_lines
        self.transform = transform

    def __len__(self):
        """
        返回数据集的总长度
        Returns:
            length (int): 数据集的总长度
        """
        return len(self.data_lines)
    
    def __getitem__(self, index):
        """
        通过索引返回一个数据
        Args:
            index (int): 索引
        Returns:
            img (Tensor): 图像数据
            label (Tensor): 标签数据
        """
        data_line = self.data_lines[index].split(",")
        image       = np.array((Image.open(data_line[0]).convert("RGB")))
        bboxes      = np.array([list(map(int, box.split(' '))) for box in data_line[1:]])

        if self.transform is not None:
            augmentations = self.transform(image=image, bboxes=bboxes)
            image = augmentations["image"]
            bboxes = augmentations["bboxes"]

        return image, bboxes

也就是通过输入的 index,来获取 图片 和对应的 标签信息, transform 是是否采用数据增强,数据增强的具体代码如下:

train_transforms = A.Compose(
    [
        # 保持图片的比例进行图片的放大
        A.LongestMaxSize(max_size=300),
        # 不保证图片比例进行放大,需要的时候会对图片添加 0 
        A.PadIfNeeded(
            min_height=300, min_width=300, border_mode=cv2.BORDER_CONSTANT
        ),
        # 进行正则化
        A.Normalize(mean=[0.4914, 0.4822, 0.4465], std=[0.2023, 0.1994, 0.2010], max_pixel_value=255,),
        # 转变为 tensor
        ToTensorV2(),
    ],
    # 对检测框进行调整
    bbox_params=A.BboxParams(format="pascal_voc", label_fields=[],check_each_transform=False),
) 

val_transforms = A.Compose(
    [
        # 保持图片的比例进行图片的放大
        A.LongestMaxSize(max_size=300),
        # 不保证图片比例进行放大,需要的时候会对图片添加 0 
        A.PadIfNeeded(
            min_height=300, min_width=300, border_mode=cv2.BORDER_CONSTANT
        ),
        # 进行正则化
        A.Normalize(mean=[0.4914, 0.4822, 0.4465], std=[0.2023, 0.1994, 0.2010], max_pixel_value=255,),
        # 转变为 tensor
        ToTensorV2(),
    ],
    # 对检测框进行调整
    bbox_params=A.BboxParams(format="pascal_voc", label_fields=[], check_each_transform=False),
)

因为我们使用的格式为 pascal_voc 的格式,所以这里的我们选择的增强方式为 pascal_voc,如果使用的为 coco 的话,换成 coco 即可。
一般情况下,在训练的时候和在验证的时候采用的数据增强手段是不一样的,训练的时候,我们强调数据的多样性,会使用较多的数据增强的手段,为反转、随机剪裁、色域变换等,但是在验证的时候,我们强调当前网络的性能,一般采取较少的数据增强手段。

注意:我们在对图片进行数据增强的时候,要保证检测的一致性。

DataLoader

刚刚只是定义了如何获取图片,但是如何设置为可以供 pytorch 使用,需要放到 DataLoader 中, 形成迭代器,来实现不间断的拿取 batch_size 的图片。
具体代码如下:

def load_data(root_path, batch_size=8, num_workers=0):
    # 第一步:加载数据集
    with open(os.path.join(root_path, "train.txt"), "r") as f:
        train_lines = f.readlines()
    with open(os.path.join(root_path, "train.txt"), "r") as f:
        val_lines = f.readlines()

    train_dataset = SSDDataset(train_lines, train_transforms)
    val_dataset = SSDDataset(val_lines, val_transforms)
    
    train_iter = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=num_workers, collate_fn=collate_fn)
    test_iter = DataLoader(val_dataset, batch_size=batch_size, shuffle=True, num_workers=num_workers, collate_fn=collate_fn)
    return train_iter, test_iter

def collate_fn(batch):
    """
    自定义一个函数,将一个batch的数据拼接起来
    Args:
        batch (list): batch数据
    Returns:
        images (Tensor): 图像数据
        bboxes (list): 边界框数据
    """
    images = []
    bboxes = []
    for image, bbox in batch:
        images.append(image)
        bboxes.append(torch.tensor(bbox))
    return torch.stack(images, dim=0), bboxes

Anchors

生成先验框

SSD 网络需要生成在不同的特征层生成先验框,以满足对不同大、小物体检测的需求;具体实现代码如下:

# SSD 生成 anchors 的方式, 中心宽高的方式
def generate_anchors(feature_maps_size=[38, 19, 10, 5, 3, 1], image_size=300, steps=[8, 16, 32, 64, 100, 300], 
                    min_sizes=[30, 60, 111, 162, 213, 264], max_sizes=[60, 111, 162, 213, 264, 315], 
                    aspect_ratios=[[2], [2, 3], [2, 3], [2, 3], [2], [2]], clip=True):
    mean = []
    for k, f in enumerate(feature_maps_size): #[38, 19, 10, 5, 3, 1],
        for i, j in product(range(f), repeat=2):
            f_k = image_size / steps[k]
            # unit center x,y
            cx = (j + 0.5) / f_k
            cy = (i + 0.5) / f_k

            # aspect_ratio: 1
            # rel size: min_size
            s_k = min_sizes[k] / image_size
            mean += [cx, cy, s_k, s_k]

            # aspect_ratio: 1
            # rel size: sqrt(s_k * s_(k+1))
            s_k_prime = sqrt(s_k * (max_sizes[k]/image_size))
            mean += [cx, cy, s_k_prime, s_k_prime]

            # rest of aspect ratios
            for ar in aspect_ratios[k]:
                mean += [cx, cy, s_k*sqrt(ar), s_k/sqrt(ar)]
                mean += [cx, cy, s_k/sqrt(ar), s_k*sqrt(ar)]
    # back to torch land
    prior_anchors = torch.Tensor(mean).view(-1, 4)  # * image_size ##[8732,4]

    # 对于超出边界的先验框进行裁剪到边界内
    if clip:
        prior_anchors.clamp_(max=1, min=0)
	# 返回的时候转化为 corner 的数据格式,并且乘上 图片 对应的尺度获取真实大小
    return box_center_to_corner(prior_anchors) * image_size

匹配先验框

在训练的时候,我们需要将真实的标签框和先验框进行匹配,来获取训练的正样本。也就是先确保每个标签框有一个先验框负责预测,完成 一对一 的匹配。如果其他的先验框没有被分配,且和其中某个标签框的 iou 也很大,也将对列为正样本,完成 先验框 -> 标签框 的 多对一 的匹配。具体技术细节,请参考:沐神的动手学深度学习的 13.4. 锚框 的章节。
具体实现代码如下:

def assign_anchor_to_bbox(ground_truth, anchors, device, iou_threshold=0.5):
    """ 为锚框分配边界框

    Args:
        ground_truth (Tensor): 边界框,形状为 (n, 4)
        anchors (Tensor): 锚框,形状为 (k, 4)
        device (torch.device): 用于分配张量的设备
        iou_threshold (float): 阈值
    Returns:
        Tensor: 所分配的边界框,形状为 (k, 4)
    """
    num_anchors, num_gt_boxes = anchors.shape[0], ground_truth.shape[0]

    # 计算锚框和边界框的交并比
    anchors_boxes_iou = box_iou(anchors, ground_truth)

    # 为每个锚框分配边界框的索引, 形状为 (k, ), 默认为 -1
    anchor_to_bbox_map = torch.full((num_anchors,), -1, dtype=torch.long, device=device)

    # 找到每个 anchors 对应的最大 iou 值和索引
    max_ious, indices = torch.max(anchors_boxes_iou, dim=1)
    
    # 将交并比大于阈值的锚框分配给边界框
    anchor_to_bbox_map[max_ious >= iou_threshold] = indices[max_ious >= iou_threshold]

    # 找到应该丢弃的行和列,也就是找到每一个 gt 真实值匹配的 anchor 所以对应的行和列
    col_discard = torch.full((num_anchors, ), -1)
    row_discard = torch.full((num_gt_boxes,), -1)
    # 每个 iou 较大的 anchor只保留一个gt对应
    for _ in range(num_gt_boxes):
        # 找到目前 iou 最大的位置,因为没有指定维度的话,默认将所有维度参与
        # anchors_boxes_iou 的维度为 : [num_anchors, num_gt_boxes]
        max_idx = torch.argmax(anchors_boxes_iou)
        # 找到对应的列
        box_idx = (max_idx % num_gt_boxes).long()
        # 找到对应的行
        anc_idx = (max_idx / num_gt_boxes).long()
        # 将对应的 gt 作为 anchor 的真值
        anchor_to_bbox_map[anc_idx] = box_idx
        
        # 取出对应的行和列
        anchors_boxes_iou[:, box_idx] = col_discard
        anchors_boxes_iou[anc_idx, :] = row_discard
    
    return anchor_to_bbox_map

位置 offset

网络并不是直接预测标签的中心点和宽高,那样会增加网络预测的难度,我们预测的时候,先验框相对于标签框的偏移量,包括中心点的偏移量和宽高的偏移量。参考 沐神动手学深度的讲解如下;
在这里插入图片描述
具体代码如下:

def offset_boxes(c_anc, assigned_bbox, eps=1e-6):
    """根据所分配的边界框来调整锚框

    Args:
        c_anc (Tensor): 锚框,形状为 (n, 4) center 形状
        assigned_bbox (Tensor): 所分配的边界框,形状为 (n, 4) corner 形状
        eps (float): 一个极小值,防止被零整除
    Returns:
        Tensor: 调整后的锚框,形状为 (n, 4)
    """
    c_anc = box_corner_to_center(c_anc)
    c_assigned_bbox = box_corner_to_center(assigned_bbox)
    offset_xy = 10.0 * (c_assigned_bbox[:, :2] - c_anc[:, :2]) / (c_anc[:, 2:] + eps)
    offset_wh = 5.0 * torch.log((c_assigned_bbox[:, 2:] + eps) / (c_anc[:, 2:] + eps))
    offset = torch.cat([offset_xy, offset_wh], axis=1)
    return offset

这些偏移量将作为训练的时候,真正的标签。

损失函数

有了网络的输出和真正的偏移量的标签,我们就可以定义我们的损失函数了。
关于 SSD 损失讲解,可以参考大神的解释:链接
简单来说,分为两个部分:
在这里插入图片描述
前面是置信度损失函数,后面是位置损失函数,中间为权重因子。再详细划分如下:
在这里插入图片描述
在这里插入图片描述
根据上面公式,定义损失函数如下:

def loc_loss(self, loc_preds, bbox_labels, bbox_masks):
        """计算位置损失

        Args:
            loc_preds (Tensor): 预测的位置结果,形状为 (batch_size, num_anchors, 4)
            bbox_labels (Tensor): 真实的位置标签,形状为 (batch_size, num_anchors, 4)
            bbox_masks (Tensor): 真实的位置标签的掩码,形状为 (batch_size, num_anchors, 4)
        Returns:
            Tensor: 位置损失
        """
        # 第一步:计算损失
        return  F.l1_loss(loc_preds * bbox_masks, bbox_labels * bbox_masks, reduction='sum')
    
    def conf_loss(self, conf_preds, cls_labels):
        """计算分类损失

        Args:
            conf_preds (Tensor): 预测的分类结果,形状为 (batch_size, num_anchors, num_classes)
            cls_labels (Tensor): 真实的分类标签,形状为 (batch_size, num_anchors, 1)
        Returns:
            Tensor: 分类损失
        """
        # 第一步:计算正样本的损失
        # 获取正样本的索引
        pos_mask = cls_labels > 1
        # 获取正样本的数量
        num_pos = pos_mask.long().sum(dim=1, keepdim=True) # (batch_size, 1)
        # 计算正样本的损失, 交叉熵损失函数的输入为 (N, C) 和 (N, ),其中 N 为样本数量,C 为类别数量,会自动找到(N, C)中的对应的值,计算损失
        pos_loss = F.cross_entropy( conf_preds.reshape(-1, conf_preds.shape[-1])[pos_mask.reshape(-1)], cls_labels[pos_mask])

        # 第二步:计算负样本的损失
        # 获取负样本的索引
        neg_mask = cls_labels == 0
        # 计算负样本的损失
        neg_loss = F.cross_entropy(conf_preds.reshape(-1, conf_preds.shape[-1])[neg_mask.reshape(-1)], cls_labels[neg_mask])
        # 第三步:计算损失
        loss = pos_loss +  neg_loss
        # 返回损失
        return loss

根据上面的损失函数,可以定义我们训练的总的损失函数类:具体代码如下:

class MultiBoxLoss():
    """根据预测的结果计算损失函数"""
    def __init__(self, num_classes, device, alpha=1) -> None:
        super().__init__()
        self.num_classes   = num_classes
        self.image_size    = 300
        self.prior_anchors = generate_anchors()
        self.device        = device
        self.alpha         = alpha

    def __call__(self, net_output, labels):
        """计算损失函数

        Args:
            net_output (tuple): 网络输出,包含回归预测和分类预测,
            形状为 loc_preds:(batch_size, num_anchors, 4), conf_preds: (batch_size, num_anchors, 21)
            
            labels (Tensor): 标签,形状为 (n, 5),其中 n 是所有边界框的数量, 5 表示 (类别, x, y, w, h)
        Returns:
            Tensor: 损失值
        """
        # 第一步:获取网络输出 (batch_size, num_anchors, 4), (batch_size, num_anchors, 21)
        loc_preds, conf_preds = net_output
        batch_size, device = len(labels), loc_preds.device
        
        num_anchors = self.prior_anchors.shape[0] # self.prior_anchors (num_anchors, 4)

        # 第二步:为不同大小的特征层生成对应的 anchors 找到对应 target 标号, 并且将其转换为相对于 anchor 的偏移量
        bbox_labels, bbox_masks, cls_labels = multibox_target(self.prior_anchors, labels)
        cls_labels = cls_labels.to(device)
        bbox_labels = bbox_labels.to(device)
        bbox_masks = bbox_masks.to(device)

        num_prior_anchors = bbox_masks[:,:, 0].sum() # (batch_size, 1)

        # 第三步:计算损失
        # 将预测的结果转换为 (batch_size, num_anchors, num_classes + 1), + 1 为背景类
        conf_preds = conf_preds.view(batch_size, num_anchors, self.num_classes + 1)
        
        # 计算分类损失
        cls_loss = self.conf_loss(conf_preds, cls_labels)
        # 计算位置损失
        loc_loss = self.loc_loss(loc_preds, bbox_labels, bbox_masks)

        all_loss = (cls_loss + self.alpha * loc_loss) / num_prior_anchors

        return all_loss
    
    def loc_loss(self, loc_preds, bbox_labels, bbox_masks):
        """计算位置损失

        Args:
            loc_preds (Tensor): 预测的位置结果,形状为 (batch_size, num_anchors, 4)
            bbox_labels (Tensor): 真实的位置标签,形状为 (batch_size, num_anchors, 4)
            bbox_masks (Tensor): 真实的位置标签的掩码,形状为 (batch_size, num_anchors, 4)
        Returns:
            Tensor: 位置损失
        """
        # 第一步:计算损失
        return  F.l1_loss(loc_preds * bbox_masks, bbox_labels * bbox_masks, reduction='sum')
    
    def conf_loss(self, conf_preds, cls_labels):
        """计算分类损失

        Args:
            conf_preds (Tensor): 预测的分类结果,形状为 (batch_size, num_anchors, num_classes)
            cls_labels (Tensor): 真实的分类标签,形状为 (batch_size, num_anchors, 1)
        Returns:
            Tensor: 分类损失
        """
        # 第一步:计算正样本的损失
        # 获取正样本的索引
        pos_mask = cls_labels > 1
        # 获取正样本的数量
        num_pos = pos_mask.long().sum(dim=1, keepdim=True) # (batch_size, 1)
        # 计算正样本的损失, 交叉熵损失函数的输入为 (N, C) 和 (N, ),其中 N 为样本数量,C 为类别数量,会自动找到(N, C)中的对应的值,计算损失
        pos_loss = F.cross_entropy( conf_preds.reshape(-1, conf_preds.shape[-1])[pos_mask.reshape(-1)], cls_labels[pos_mask])

        # 第二步:计算负样本的损失
        # 获取负样本的索引
        neg_mask = cls_labels == 0
        # 计算负样本的损失
        neg_loss = F.cross_entropy(conf_preds.reshape(-1, conf_preds.shape[-1])[neg_mask.reshape(-1)], cls_labels[neg_mask])
        # 第三步:计算损失
        loss = pos_loss +  neg_loss
        # 返回损失
        return loss

    #-------------------------------#
    #   计算预测结果
    #-------------------------------#
    def eval(self, net_output, labels, show_img_flag=False):
        """计算准确率

        Args:
            net_output (tuple): 网络输出,包含回归预测和分类预测,形状为 (loc_preds (1, num_anchors, 4), conf_preds (1, num_anchors, 21))
            labels (Tensor): 标签,形状为 (n, 5),其中 n 是所有边界框的数量, 5 表示 (类别, x, y, w, h)
        Returns:
            Tensor: 损失值
        """
        # 第一步:获取网络输出 (1, num_anchors, 4), (1, num_anchors, 21)
        loc_preds, conf_preds = net_output

        batch_size, device = len(labels), loc_preds.device
        # self.prior_anchors (num_anchors, 4)
        num_anchors = self.prior_anchors.shape[0]

        # 第二步:为不同大小的特征层生成对应的 anchors 找到对应 target 标号, 并且将其转换为相对于 anchor 的偏移量
        bbox_labels, bbox_masks, cls_labels = multibox_target(self.prior_anchors, labels)
        cls_labels = cls_labels.to(device)
        bbox_labels = bbox_labels.to(device)
        bbox_masks = bbox_masks.to(device)
		
		num_prior_anchors = bbox_masks[:,:, 0].sum() # (1,)

        # 第三步:计算准确度
        # 计算分类准确度
        cls_acc = self.cls_eval(conf_preds, cls_labels) / (batch_size * num_anchors)
        #计算分类损失
        cls_loss = self.conf_loss(conf_preds, cls_labels) # / (batch_size * num_anchors)
        # 计算位置损失
        bbox_loss = self.bbox_eval(loc_preds, bbox_labels, bbox_masks) #  / (batch_size * num_anchors)
        # 返回准确度
        return cls_acc, cls_loss / num_prior_anchors , bbox_loss / num_prior_anchors 
        
    def cls_eval(self, cls_preds, cls_labels):
        # 由于类别预测结果放在最后一维,argmax需要指定最后一维。
        return float((cls_preds.argmax(dim=-1).type(cls_labels.dtype) == cls_labels).sum())

    def bbox_eval(self, bbox_preds, bbox_labels, bbox_masks):
        return float((torch.abs((bbox_labels - bbox_preds) * bbox_masks)).sum())

其中 multibox_target 函数的作用为 :为不同大小的特征层生成对应的 anchors 找到对应 target 标号, 并且将其转换为相对于 anchor 的偏移量

def multibox_target(anchors, labels):
    """ 使用真实边界框标记锚框

    Args:
        anchors (Tensor): 先验框,对应的大小为,(num_anchors, 4)
        labels (Tensor): 多个图片的真实标签,(batch_size, num_labels, 4)  都是 corner 的形式 
    Returns:
        bbox_offset  (batch_size, num_boxes, 4)
        bbox_mask    (batch_size, num_boxes, 4)
        class_labels (batch_size, num_boxes, 1)
    """
    # labels 为多个图片的box的标签合集,维度为 [batch_size, num_gt, 4 + 1], 其中 1 为类别
    batch_size, anchors = len(labels), anchors.squeeze(0)
    batch_offset, batch_mask, batch_class_labels = [], [], []
    device, num_anchors = anchors.device, anchors.shape[0]
    
    for i in range(batch_size):
        # 取出第 i 张图片对应的 box 的 gt
        label = labels[i]
        # 找到和标签 box 最接近的真实标签框,作为训练的正样本
        anchor_bbox_map = assign_anchor_to_bbox(label[:, :4], anchors, device)
        # 构造 mask 矩阵,大于零的我们认为找打了相应的gt,扩展到4维的目的是为了后面bbox的四个点的mask
        bbox_mask = ((anchor_bbox_map >= 0).float().unsqueeze(-1)).repeat(1, 4)
        # 将类标签和分配的边界框坐标初始化为0
        class_labels = torch.zeros(num_anchors, dtype=torch.long, device=device)
        assigned_bbox = torch.zeros((num_anchors, 4), dtype=torch.float32, device=device)
        
        # 使用真实边界框来标记锚框的类别
        # 如果一个锚框没有被分配,标记为背景,值为零
        indices_true = torch.nonzero(anchor_bbox_map >= 0)
        # 拿出已经分配好的锚框对应的 gt bbox
        bbox_idx = anchor_bbox_map[indices_true]
        # 将类别和对应的标签进行赋值。label + 1 的原因是为了和 0 进行区别。
        class_labels[indices_true] = label[bbox_idx, 4].long() + 1
        assigned_bbox[indices_true] = label[bbox_idx, :4].float()

        # 偏移量的转换,得到真实框和对应anchor之间的位置偏移
        offset = offset_boxes(anchors, assigned_bbox) * bbox_mask
        # 记录一下对应的关系
        batch_offset.append(offset)
        batch_mask.append(bbox_mask)
        batch_class_labels.append(class_labels)
        
    bbox_offset = torch.stack(batch_offset)
    bbox_mask = torch.stack(batch_mask)
    class_labels = torch.stack(batch_class_labels)
    
    return bbox_offset, bbox_mask, class_labels

训练

定义相关的优化器、学习率等,我们就可以开始我们的训练了;具体训练代码如下;

if __name__ == "__main__":

    # -------------------超参数-------------------#
    batch_size = 32
    num_workers = 4
    input_size = (3, 300, 300)
    num_classes = 20 
    learning_rate = 1e-2
    epochs = 100
    weight_decay = 5e-4
    root_path = "./data"
    # -------------------超参数-------------------#

    # 设置使用的设备
    # device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    device = torch.device("cuda:1" if torch.cuda.is_available() else "cpu")
    
    # 第一步:加载数据集
    train_iter, test_iter = load_data(root_path, batch_size, num_workers)

    # 第二步:定义模型
    net = SSD(input_size, num_classes)
    net.to(device)
    # summary(net,input_size=(3, 300, 300))

    # 第三步:定义损失函数
    Loss = MultiBoxLoss(num_classes, device)

    # 第四步:定义优化器
    optimizer = torch.optim.SGD(net.parameters(), lr=learning_rate, momentum=0.9, weight_decay=weight_decay)

    # 第五步:训练模型
    train_day = datetime.now().strftime("%Y_%m_%d_%H_%M_%S")

    print("#", "-"*30, "训练参数", "-"*30, "#")
    print("训练日期:{0:>65}".format(train_day))
    print("训练设备:{0:>65}".format(str(device)))
    print("训练数据集大小:{0:>59}".format(len(train_iter) * batch_size))
    print("测试数据集大小:{0:>59}".format(len(test_iter) * batch_size))
    print("训练批次大小:{0:>61}".format(batch_size))
    print("训练轮次:{0:>65}".format(epochs))
    print("学习率:{0:>67}".format(learning_rate))
    print("权重衰减:{0:>65}".format(weight_decay))
    print("#", "-"*30, "训练开始", "-"*30, "#")

    train_file = "./log/train_log_" + train_day
    os.makedirs(train_file, exist_ok=True)

    min_loss = 1e+10
    for epoch in range(epochs):
        net.train()
        phgr = tqdm(train_iter, total=len(train_iter), desc="Train epoch " + str(epoch + 1) + "/" + str(epochs))
        epoch_train_losses = []
        for i, (images, labels) in enumerate(phgr):
            # 将数据转换为 cuda 的 tensor
            images = images.to(device)

            # 前向传播
            net_output = net(images)

            # 计算损失
            net_loss = Loss(net_output, labels)
            loss = net_loss.item()
            epoch_train_losses.append(loss)
            # 反向传播
            optimizer.zero_grad()
            net_loss.backward()
            optimizer.step()

            phgr.set_postfix({"loss": loss})
        
        # 保存最优模型
        epoch_train_loss = (sum(epoch_train_losses) / len(epoch_train_losses))
        print("epoch_train_loss:", epoch_train_loss, " min_loss:", min_loss)
        if epoch_train_loss < min_loss:
            min_loss = epoch_train_loss
            save_weight_path = train_file + f"/ssd_best_loss.pth"
            torch.save(net.state_dict(), save_weight_path) 

        if epoch % 5 == 0 or epoch == epochs - 1:
            net.eval()
            epoch_test_losses = []
            show_img_flag = True
            with torch.no_grad():
                phgr_test = tqdm(test_iter, total=len(test_iter), desc="Test epoch " + str(epoch + 1) + "/" + str(epochs))
                for images, labels in phgr_test:
                    # 将数据转换为 cuda 的 tensor
                    tensor_images = images.to(device)

                    # 前向传播
                    net_output = net(tensor_images)

                    if show_img_flag:
                        # 显示第一张图片的预测结果作为显示
                        # decoder_out = decoder(net_output, Loss.prior_anchors)
                        # out_frame = draw_infer_box(images[0], decoder_out[0], decoder_out[1], decoder_out[2], class_names, colors)
                        # cv2.imwrite("train_eval_result.png", out_frame)
                        show_img_flag = False

                    # 计算准确度
                    cls_acc, cls_loss, bbox_loss = Loss.eval(net_output, labels)
                    epoch_test_losses.append(cls_loss + bbox_loss)
                    phgr_test.set_postfix({"cls_acc": cls_acc," cls_loss": cls_loss.item(), " bbox_loss": bbox_loss})

            epoch_test_loss = (sum(epoch_test_losses) / len(epoch_test_losses))
            save_weight_path = train_file + f"/ssd_epoch_{epoch + 1}_testloss_{0:.3f}_trainloss_{0:.3f}.pth".format(epoch_test_loss, epoch_train_loss)

代码及参考

完整包含预测的代码地址为:
参考
1、沐神的动手学深度学习

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/605606.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

软件测试新人害怕不过试用期,教你几招使你安稳度过!!!

对于很多刚步入职场的新人来说&#xff0c;害怕自己试用期过不了&#xff0c;被辞退。别的行业我可能不知道该如何帮大家应对&#xff0c;但在测试行业我希望还是能给大家带俩一点帮助&#xff0c;希望大家能安稳度过试用期&#xff0c;并且成功入行。 保持初心&#xff0c;安稳…

零基础开发小程序第四课-查看功能开发

目录 1 创建页面2 搭建页面3 创建数据4 数据绑定5 页面传参6 预览发布总结 本篇是我们零基础入门课的第四篇&#xff0c;前三篇我们介绍了创建项目、列表功能、新增功能&#xff0c;本篇我们介绍一下查看详情功能的开发。 1 创建页面 打开Zion开发工具&#xff0c;点击已经创建…

Python Struct 库之 pack 和 unpack 详解

1. 官网解析 首先是官网对于 pack 、 unpack 、calcsize以及Format Strings的描述 1.1 pack、unpack、calcsize struct.pack返回一个bytes对象&#xff0c;其中包含根据格式字符串format打包的值v1, v2&#xff0c;…。参数必须与格式所要求的值完全匹配。 struct.unpack根据…

云原生 HTAP -- Cloud-Native Transactions and Analytics in SingleStore

文章目录 背景1 存算分离2. 统一的表存储 &#xff08;行列混存&#xff09;2.1 二级索引2.2 行锁 3. 自适应查询引擎3.1 Segment skipping 实现3.2 Filtering 选择 4 性能总结 背景 上篇看了 PolarDB-IMCI 在HTAP的实践&#xff0c;其中提到了其也有借鉴 SingleStore 的实现思…

openresty离线rpm升级至openresty-1.19.9.1版本

注意&#xff1a;此方法步骤仅本人验证通过&#xff0c;要升级的话&#xff0c;需要做备份 1。系统版本是centos7(Linux version 3.10.0-693.el7.x86_64) 2。默认openresty版本是1.15.8.1 3。本次升级到openresty-1.19.9.1 目前系统是没有连接外网&#xff0c;只能进行rpm离…

网络重置后无法上网,以太网和无线网全部丢失,网络适配器出现“56”错误码

文章目录 一、问题描述电脑系统&#xff1a;电脑问题&#xff1a;解决方案 二、问题过程1. IP问题2.网络重置问题3.电脑无法启动问题 三、解决方案1.卸载2.安全模式检查修复3.软件下载1.CCleaner2.驱动精灵万能网卡版 四、参考链接 一、问题描述 电脑系统&#xff1a; Window…

【MySQL高级篇笔记-索引的创建与设计原则 (中) 】

此笔记为尚硅谷MySQL高级篇部分内容 目录 一、索引的声明与使用 1、索引的分类 2、创建索引 1. 创建表的时候创建索引 2. 在已经存在的表上创建索引 3、删除索引 二、MySQL8.0索引新特性 1、支持降序索引 2、 隐藏索引 三、索引的设计原则 1、哪些情况适合创建索引…

Android 应用快捷ShortcutManager与ShortcutManagerCompat详解与实战(二)

一、介绍 之前我已通过一篇文章介绍了应用快捷的接入与Demo。如果还未看过上一篇的文章可以先了解入门。 传送门&#xff1a;Android 应用快捷(shortcut)功能的详解(一)_蜗牛、Z的博客-CSDN博客 有创建自然就会有管理&#xff0c;否则一个完美的方案不应该这么被推荐出来。如何…

红黑树的 概念性质 和 详解实现(插入旋转等)

文章目录 概念满足的条件性质实现红黑树的定义红黑树节点插入操作情况一情况二情况三Insert()总代码 其余操作左右单旋RotateL 左单旋RotateR 右单旋prevCheck 红黑树性质检测isBalance 红黑树平衡判断InOrder 中序遍历 完整代码 概念 红黑树&#xff0c;是一种二叉搜索树&…

HNU-操作系统OS-ucoreLab系列-感悟

谨以此片篇,献给熬夜的8个晚上,以及逝去的时光。 感悟: 今天结束了所有的Lab实验(2023.6.3),感慨万千。 喜是这个实验终于结束了,悲是其实有好多地方我都没有理解。 应该指出,由于验收的助教学长学姐们的宽容,HNU实际上在验收这一块的要求还是比较低的。 但是这个…

第三章 Electron 使用Koa以及Koa-Router

一、Koa是什么 &#x1f447; &#x1f447; &#x1f447; 据网上的资料显示&#xff0c;Koa 是下一代的 Node.js 的 Web 框架。是express原班人马打造,同样用于构建服务端web application的。旨在提供一个更小型、更富有表现力、更可靠的 Web 应用和 API 的开发基础。扯这些…

在输入URL后,前端浏览器的工作流程

这是一个经久不衰的面试题&#xff0c;整理一下。 浏览器 浏览器是一个多进程的架构。 主进程&#xff1a;主要适用于界面的显示&#xff0c;用户的交互&#xff0c;子进程管理。 渲染进程&#xff1a;将HTML&#xff0c;css&#xff0c;JS转换成页面&#xff0c;V8引擎以及…

redisson 随笔 0-入门

0. 虽说时运不佳&#xff0c;仍欲提桶跑路 分布式锁的常见实现方案 常用锁的用例 runoob Lua教程 对于分布式锁的实现方案&#xff0c;本文如标题所言&#xff0c;简单梳理了redisson的实现方案 redisson 也是基于redis的多个命令组合来实现的&#xff0c;为保证执行多个命…

项目实战:基于Linux的Flappy bird游戏开发

一、项目介绍 项目总结 1.按下空格键小鸟上升&#xff0c;不按小鸟下落 2.搭建小鸟需要穿过的管道 3.管道自动左移和创建 4.小鸟撞到管道游戏结束 知识储备 1.C语言 2.数据结构-链表 3.Ncurses库 4.信号机制 二、Ncurses库介绍 Ncurses是最早的System V Release 4.0 (…

F. Editorial for Two(二分答案+反悔贪心)

F. Editorial for Two&#xff08;二分答案反悔贪心&#xff09; F. Editorial for Two 1、问题 给定一个 n n n和 k k k&#xff0c;以及一个长度为 n n n数组。现在从 n n n个数中&#xff0c;挑出 k k k个数&#xff0c;称作个子序列。然后将这个子序列分成两部分&#x…

干翻Mybatis源码系列之第八篇:Mybatis二级缓存的创建和存储

给自己的每日一句 不从恶人的计谋&#xff0c;不站罪人的道路&#xff0c;不坐亵慢人的座位&#xff0c;惟喜爱耶和华的律法&#xff0c;昼夜思想&#xff0c;这人便为有福&#xff01;他要像一棵树栽在溪水旁&#xff0c;按时候结果子&#xff0c;叶子也不枯干。凡他所做的尽…

C++之---树/数据结构

一、树 什么是树&#xff1f; 1.1 树&#xff08;Tree&#xff09;是n&#xff08;n>0&#xff09;个结点的有限集。n0时称为空树。在任意一棵非空树中&#xff1a; &#xff08;1&#xff09; 有且仅有一个特定的称为根&#xff08;Root&#xff09;的结点&#xff1b; &am…

CodeForces..最新行动.[中等].[遍历].[判断]

题目描述&#xff1a; 题目解读&#xff1a; "最近操作"字段会显示最近操作的n个文件。 最初有编号文件1&#xff0c;2&#xff0c;... n在"最近操作"字段&#xff0c;还有其他无限多个文件不在。 当某个文件pi发生操作时&#xff1a; 如果它位于“最近…

小红书账号矩阵优化软件

小红书账号矩阵优化软件 大家有关注过品牌在⼩红书上的打法有哪些吗&#xff1f; #品牌营销#小红书运营#爆文拆解#品牌投放#爆品打造 我们如果确定了我们要去做小红书&#xff0c;那我到底该怎么去做&#xff1f;现在小红书对我们目前这些品牌来说&#xff0c;你们是作为把它…

Allegro16.6详细教程(二)

R.3-D Viewer 3-D Viewer,可以直接在allegro中看到board file的3-D顯示效果。3-D Viewer對於PCB Editor Products,只有環境變數中的OpenGL顯示功能開啟後才有效,而對於APD/SiP是無效的。 2.3-D viewer是在一個獨立的視窗中打開的。3-D environment環境支援多種顯示內容的過…