计算机视觉学习记录

oh_computer_vision。


#

见我所见。续深度学习基础学习记录,更注重图像处理任务。

# 图像增广 \(\textit{Image Augmentation}\)

为使应用在不同场景下都具良好的泛化性,需要在已有数据上进行数据增强,以使其有更强的多样性。具体可以在语言里加入各种不同的背景噪音,改变图像的颜色和形状等。在计算机视觉方面,这也被称为图像增广。

数据增强一般在线进行,即读取之后再进行随机变换,不存储到本地空间。

图像增广的一般操作

以下是一些常用方法。其余方法,如锐化、高斯模糊等,可以参考此处

翻转

左右翻转、上下翻转等。

颜色

改变色调、饱和度和明亮度。[latex]multiplier[/latex]设置在[latex][0.5,1.5][/latex]。

切割

从原始图片中切割出小块,然后变换到固定形状。随机性体现在:随机高宽比、随机大小、随机位置。

注:变换时要考虑应用场景的真实情况。如果不符合真实情况,变换图像后可能起负面效果。

基于PyTorch实现图像增广

当使用 PyTorch 处理图像任务时,需涉及 torchvision 

trans_train = transforms.Compose(
                [
                    # 缩放到224*224
                    transforms.Resize(224),
                    # 随机水平翻转
                    transforms.RandomHorizontalFlip(p=0.5),
                    # 随机上下翻转
                    transforms.RandomVerticalFlip(p=0.5),
                    # 最后Resize到200*200,涵盖10%-100%的原始信息,高宽比在1:2和2:1之间
                    transforms.RandomResizedCrop((200, 200), scale=(0.1, 1), ratio=(0.5, 2)),
                    # 亮度增减0到50%,对比度增减50%,饱和度增减50%,颜色增减50%
                    transforms.ColorJitter(brightness=0.5, contrast=0.5, saturation=0.5, hue=0.5),
                    # 转Tensor方便训练
                    transforms.ToTensor()
                 ])
trans_valid_test = transforms.Compose([transforms.Resize(224), transforms.ToTensor()])

如果图像增广做得特别狠,那么验证精度可能比训练精度高。

QA

理论上是不是原始样本足够多就不用数据增强了?

数据多,不代表多样性强。可能效果没那么好。

多张图片叠加是一种图像增广方式吗?

是的,叫 mix-up 

# 微调 \(\textit{Fine-Tuning}\)

微调是[latex]\textbf{迁移学习}[/latex]中的常见技巧,即将从源数据集学到的知识迁移到目标数据集。

该方法的前提是,所有处理分类任务的神经网络架构都可以被分为两块:[latex]1.[/latex]特征抽取将原始像素变成容易线性分割的特征;[latex]2.[/latex]线性分类器来做分类。即,被看作由[latex]L[/latex]层进行特征抽取的[latex]\text{Layer}[/latex]以及[latex]1[/latex]层进行[latex]\text{Softmax}[/latex]的输出层构成。或者说,由做[latex]\textbf{特征抽取}[/latex]和做[latex]\textbf{线性分类}[/latex]的两部分构成。

步骤

一个在源数据上[latex]\textbf{预训练}[/latex]好的模型被称为[latex]\textbf{源模型}[/latex]。在另一个新神经网络上,即[latex]\textbf{目标模型}[/latex]上,使用[latex]\textbf{微调}[/latex]时,由于具体识别的物体及其[latex]label[/latex]不同,不能直接挪用输出层,所以一般复制除输出层外的所有层的参数,并新设输出层并对其进行随机初始化。如下图所示:

训练时的注意事项

[latex]\\ \begin{align}& \centerdot \text{是一个目标数据集上的正常训练任务,但使用更强的正则化}\\ & \quad \centerdot \text{使用更小的学习率,因为已经离最优解比较近}\\ &\quad \centerdot \text{使用更少的数据迭代}\textit{epoch}\\& \centerdot \text{源数据集远复杂于目标数据,如十倍百倍,这样效果更好}\end{align}[/latex]

由于模型参数已[latex]\textit{pre-trained}[/latex],但使用新的数据进行训练时还会细微调动模型参数,因此该方法叫做微调。

更强的正则化:固定底部的一些层。这些层学习的是一些底层的细节,如边边角角;而高层学习的是语义信息,如猫猫狗狗。越底层越通用,越顶层越和标号相关。因此,如果训练全体模型时发生过拟合,则可以固定底层的权重,不再更新它们,以减小模型复杂度避免过拟合。

尽管前文提到不会挪用输出层,但源数据集和目标数据集标号重合时,也可以使用预训练的模型分类器中对应标号所对应的向量来做新模型输出层的初始化。

阶段总结

微调通过使用在大数据上得到的预训练好的模型来初始化模型权重来提高精度;预训练模型的质量很重要;微调通常速度快、精度高。

微调可以用在之后任何一个需要抽取特征的任务上,如目标检测等。

基于PyTorch实现微调

基于 PyTorch 使用预训练模型很容易,仅需设定 weights 参数。然而使用预训练模型有一些需要注意的事项,在此处列出:

\(\text{ImageNet}\)所需的标准化

trans_train = transforms.Compose(
    [
        transforms.ToTensor(),
        # 使用RGB通道的均值和标准差,以标准化每个通道
        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
    ]
)
注:如果网络中已包括\(\text{批量归一化层}\),就不需要在数据集上做归一化了。

使用预训练模型

from torchvision.models import ResNeXt101_32X8D_Weights
from torchvision import models

net = models.resnext101_32x8d(weights=ResNeXt101_32X8D_Weights.IMAGENET1K_V1)
net.fc = nn.Sequential(nn.Linear(net.fc.in_features, 176))

随机初始化输出层

nn.init.xavier_uniform_(net.fc[0].weight)

QA

假设有个很大的医学图片数据集,用\(\text{ImageNet}\)的预训练模型效果大吗?

可能不大,效果可能不如在医学图片上自己完整训练一次。建议找一些在医学图像预训练得到的模型参数。

怎样选用于预训练的数据集?

要与自己需要的目标类似,并且要更大的数据集。

微调怎么选学习率?

选一个小的学习率就行了,微调对学习率不敏感。

# 目标检测 \(\textit{Object Detection}\)

图片分类在实际场景中应用较少,而目标检测不仅能处理分类任务,还可以反映目标的位置。本节旨在介绍目标检测任务的一些基础, 下一节再介绍一些目标检测算法。

注:实践中最好采取别人使用\(\text{CUDA}\)写好的算法并直接调用。使用\(\text{Python}\)手写的锚框等会运行得很慢。

# 边界框 \(\textit{Bounding Boxes}\)

用于表示物体位置的框。边界框的定位方式有:\(\text{(左上X, 左上Y, 右下X, 右下Y)}\)或者\(\text{(左上X, 左上Y, 宽, 高)}\)。

数据集存放方式

由于一张图片可能有多个物体,因此以子文件夹区分图片类别的方式不再可行。可行的一种方案是,以文本文件存储,每行注明图片的位置、物体类别以及边界框。值得一提的训练集为\(\text{COCO}\),内含80个常见物体,33万张图片,所有类别的物体共出现150万次 (COCO)。

边界框实现

需要使用 matplotlib  PyTorch  PIL 等库。猫狗图下载地址在此

from matplotlib import pyplot
import torch
from PIL import Image
from matplotlib import patches

# 返回一个patches.Rectangle
def bbox_to_rect(bbox, color):
    """Convert bounding box to matplotlib format.
    将将(左上X, 左上Y, 右下X, 右下Y)转为(X, Y, 宽, 高)"""
    return patches.Rectangle(
        xy=(bbox[0], bbox[1]), width=bbox[2]-bbox[0], height=bbox[3]-bbox[1],
        fill=False, edgecolor=color, linewidth=2)

# 给定猫狗的边界框
dog_bbox, cat_bbox = [60.0, 45.0, 378.0, 516.0], [400.0, 112.0, 655.0, 493.0]
# 实例化一个fig和ax
fig, ax = pyplot.subplots(figsize=(10, 8))
# 读取猫狗图
img = Image.open('./objdtct/catdog.jpg')
# ax加载猫狗图
ax.imshow(img)
# ax加载边界框
ax.add_patch(bbox_to_rect(dog_bbox, 'blue'))
ax.add_patch(bbox_to_rect(cat_bbox, 'red'))
# 展示!
pyplot.show()

目标检测数据集

由于原视频及教材提供的方法将读取全体图片数据,此处做出细微改进,使其能够一次仅读取批量指定的图片。

构造图像数据迭代器

训练数据解压得到两个文件夹,分别装有训练数据信息与验证数据信息。每个文件夹内,还要有一个直接装图片的\(\textbf{images}\)文件夹与\(\textbf{label.csv}\)。因此需要构造一个读取这些数据的类及其方法。由于方法内涉及打开图像,因此需要\(\text{from PIL import Image}\)。具体代码如下:

class custom_dateset(data.Dataset):
    def __init__(self, df, image_dir, transform=None):
        self.data_frame = df
        self.image_dir = image_dir
        self.transform = transform

    def __len__(self):
        return len(self.data_frame)

    def __getitem__(self, idx):
        if torch.is_tensor(idx):
            idx = idx.to_list()
        img_name = os.path.join(self.image_dir, str(self.data_frame.iloc[idx, 0]))
        image = Image.open(img_name)
        # unsqueeze(0)的原因是在最前面插入一个维度,用来表明有几个物体需要检测。该方法常用于统一Tensor的大小。同时需注意,该方法返回的是每次获取的样本,因此还不涉及批量大小。
        label = torch.tensor(self.data_frame.iloc[idx, 1:6]).unsqueeze(0)
        if self.transform:
            image = self.transform(image)
        return image, label

读取并展示图片及其边界框

# 读取训练集
trans = transforms.Compose([transforms.ToTensor()])  # 用于转为Tensor
banana_train_csv = pandas.read_csv('/banana-detection/bananas_train/label.csv', header=0)
train_iter = data.DataLoader(custom_dateset(banana_train_csv, '/banana-detection/bananas_train/images', transform=trans), shuffle=True, batch_size=32)
# 随便来一笔数据
images, labels = next(iter(train_iter))
# 初始化画幅,2行5列,共10个坐标轴
fig, ax = pyplot.subplots(2, 5, figsize=(10, 4))
# 重新排列各个维度
images = images[0: 10].permute(0, 2, 3, 1)  # 原本为torch.Size([32, 3, 256, 256]),需改为[32, 256, 256, 3],其中[2256, 256, 3]是.imshow()要求的顺序
# 在每一个坐标轴上画图
for i in range(2):
    for j in range(5):
        ax[i, j].imshow(images[5 * i + j])
        ax[i, j].add_patch(patches.Rectangle(xy=(labels[5 * i + j][0, 1], labels[5 * i + j][0, 2]),
                                             width=(labels[5 * i + j][0, 3] - labels[5 * i + j][0, 1]),
                                             height=(labels[5 * i + j][0, 4] - labels[5 * i + j][0, 2]),
                                             fc='None', ec='white'))  # label[1]的size为(1, 5),分别是(类别数, 左上X, 左上Y, 右下X, 右下Y)
pyplot.show()  # 展示

QA

数据标注?

可以手标二十到一百张图片,结合迁移学习\(\text{ (Fine-Tuning) }\)学习自己标注的图片。再用学习到的模型做推理,预测剩下的图片。再重新标注预测置信度低的图片。

数据集小怎么办?

若是基于类似数据集的迁移学习,几百张图片也够了。也可以采用数据增强,但需要注意的是边界框\(\text{ (Bounding Box) }\)会随着\(\text{Crop}\)跟着变化大小,这做起来较麻烦。

# 锚框 \(\textit{Anchor Boxes}\)

计算机视觉中生成的用于预测边界框\(\text{ (Bounding Boxes) }\)的框叫做锚框,一些目标检测算法基于锚框实现。

训练时锚框在做什么

[latex]\begin{align}& \centerdot \text{第一步: 按照不同比例和大小生成多个框框,这些框框即锚框}\\& \centerdot \text{第二步: 计算锚框与边界框的交并比}\\& \centerdot \text{第三步: 将真实边界框分配给锚框,并标记锚框的类别和偏移量}\end{align}[/latex]

IoU – 交并比

处理锚框时,需比较生成的框与真实边界框二者之间的相似度,用\(\text{IoU}\)反映。其中\(0\)表示无重叠,\(1\)表示完全重叠。来源于\(\text{Jacquard指数}\)。代码见此处

$$\huge J(A,B)=\frac{\left|A\cap B\right|}{\left|A\cup B\right|}$$

标号锚框

每个锚框可以被看作一个训练样本,其标号要么是\(\textbf{背景}\),要么是\(\textbf{某类目标物体}\)及其关联上的一个\(\textbf{边界框偏移}\)。每一次读图片,算法会生成大量的锚框,然而真实边界框不多。因此绝大部分锚框的标号都为背景,进而导致\(\text{大量负类样本}\)。此外,由于锚框量巨大,因此读取图片批量大小通常很小。

标号锚框的其中一种方法及其具体步骤

存在多种有效方法标号锚框,这里介绍非常常用的一种方法。

设有\(4\)个边界框,\(9\)个锚框。 1. 计算\(4\times 9=36\)个锚框与边界框对应的\(\textbf{IoU}\),并填在对应的蓝色框内; 2. 找到\(36\)个\(\textbf{IoU}\)中的最大值,假设找到\(X_{23}\)。那么边界框\(3\)被\(\text{assign}\)给了锚框\(2\),并删除第\(2\)行第\(3\)列; 3. 在其他行列中,继续找最大值,设这里找到了\(x_{71}\)。那么边界框\(1\)被\(\text{assign}\)给了锚框\(7\),并删除第\(7\)行第\(1\)列; 4. 以此类推,直到所有边界框都被\(\text{assign}\)至对应的锚框。

至于剩下的锚框,也可以将其与\(\textbf{IoU}\)较大的边界框关联。相比于将所有剩下的锚框设为背景,这种方法会避免大量负类样本。

训练时锚框代码实现

以每个像素为中心,生成不同形状的锚框时需要考虑其宽度\(ws\sqrt{r}\)和高度\(hs\sqrt{r}\)。以上\(4\)个变量可以生成的组合极多,为提高效率,因此仅考虑生成以下组合:

$$\large \begin{align} & (s_1,r_1),(s_1,r_2),(s_1,r_3),\dots,(s_2,r_1),(s_3,r_1),(s_4,r_1),\dots,(s_n,r_1)\\& \text{式中:}s\text{意为size,指锚框大小对图像大小的比例;}w,h\text{分别为图像的宽和高;}r\text{为生成锚框的宽与高之比。}\end{align}$$

即固定\(s_1\)遍历一次\(r\),然后固定\(r\)遍历剩下的\(s\)。

构造生成一系列锚框的函数

def multibox_prior(data, sizes, ratios):
    """以每个像素为中心,生成不同形状的锚框"""
    # 获取图像高、宽
    in_height, in_width = data.shape[-2:]
    # 获取图像所在设备、设了多少个锚框大小对图像大小的比例、设了多少个锚框宽高比
    device, num_sizes, num_ratios = data.device, len(sizes), len(ratios)
    # 那么每个像素要生成(num_sizes + num_ratios - 1)个锚框
    boxes_per_pixel = (num_sizes + num_ratios - 1)
    # 将sizes和ratios都转到与图像一致的设备上
    size_tensor = torch.tensor(sizes, device=device)
    ratio_tensor = torch.tensor(ratios, device=device)
    # 需要确保锚框的中心与每个像素的中心一致。因为像素的宽高均为1,因此有一个0.5的offset
    offset_h, offset_w = 0.5, 0.5
    # 为计算归一化center_h和center_w,这里计算了steps_h和steps_w,用于归一化
    steps_h = 1.0 / in_height
    steps_w = 1.0 / in_width
    # 生成所有锚框的中心点,即每个像素点的中心点。结果均使用*steps_w/h归一化,省内存。
    center_h = (torch.arange(in_height, device=device) + offset_h) * steps_h
    center_w = (torch.arange(in_width, device=device) + offset_w) * steps_w
    # 生成网格。分'ij'和'xy'两种方式
    shift_y, shift_x = torch.meshgrid(center_h, center_w, indexing='ij')
    # 拉平刚生成的网络为一行
    shift_y, shift_x = shift_y.reshape(-1), shift_x.reshape(-1)
    ## 生成“boxes_per_pixel”个高和宽,之后用于创建锚框的四角坐标(xmin,xmax,ymin,ymax)
    # 此处仅计算锚框的宽和高,w乘了一个h/w,旨在将其转为正方形输入
    w = torch.cat((size_tensor * torch.sqrt(ratio_tensor[0]), sizes[0] * torch.sqrt(ratio_tensor[1:]))) * in_height / in_width  # Handle rectangular inputs
    h = torch.cat((size_tensor / torch.sqrt(ratio_tensor[0]), sizes[0] / torch.sqrt(ratio_tensor[1:])))
    # 因为w和h为全宽和高,除以2可以刚好通过-w/2和w/2得到整个w的长度。其中.repeat()为在各dim的出现次数,如.repeat(1,1)即为不变,.repeat(3,1)即为dim=0出现3次但dim=1不变,这不会增加新的维度
    anchor_manipulations = torch.stack((-w, -h, w, h)).T.repeat(in_height * in_width, 1) / 2
    # 每个中心点都将有“boxes_per_pixel”个锚框,所以生成含所有锚框中心的网格,重复了“boxes_per_pixel”次
    out_grid = torch.stack([shift_x, shift_y, shift_x, shift_y], dim=1).repeat_interleave(boxes_per_pixel, dim=0)
    output = out_grid + anchor_manipulations
    # 输出,给它在最前面新增一个维度,可能表示批量大小。(这不一定对
    return output.unsqueeze(0)

直观来讲,上述函数返回的结果的\(\text{shape}\)为\((1, 2042040, 4)\)。得到所有框后,将其 .reshape() 为所需形态:

h, w = img.shape[1:]
X = torch.rand(size=(1, 3, h, w))  # X为(批量为1,通道为3,高,宽))
Y = multibox_prior(X, sizes=[0.75, 0.5, 0.25], ratios=[1, 2, 0.5])
boxes = Y.reshape(h, w, 5, 4)  # reshape为(高,宽,sizes+ratios-1=5个框,四个坐标)
print(boxes[122, 232, 0, :])  # 输出(123,233)处的第1个锚框的四个坐标信息

其中, .meshgrid() 的\(\text{indexing}\)参数以及 .repeat() 的简介如下:


>>> x = torch.tensor([1, 2, 3])
>>> y = torch.tensor([4, 5, 6])

Observe the element-wise pairings across the grid, (1, 4),
(1, 5), ..., (3, 6). This is the same thing as the
cartesian product.
>>> grid_x, grid_y = torch.meshgrid(x, y, indexing='ij')
>>> grid_x
tensor([[1, 1, 1],
        [2, 2, 2],
        [3, 3, 3]])
>>> grid_y
tensor([[4, 5, 6],
        [4, 5, 6],
        [4, 5, 6]])
>>> x = torch.tensor([1, 2, 3])
>>> y = torch.tensor([4, 5, 6])

Observe the element-wise pairings across the grid, (1, 4),
(1, 5), ..., (3, 6). This is the same thing as the
cartesian product.
>>> grid_x, grid_y = torch.meshgrid(x, y, indexing='ij')
>>> grid_x
tensor([[1, 1, 1],
        [2, 2, 2],
        [3, 3, 3]])
>>> grid_y
tensor([[4, 5, 6],
        [4, 5, 6],
        [4, 5, 6]])


# 因为w和h为全宽和高,除以2可以刚好通过-w/2和w/2得到整个w的长度。其中.repeat()为在各dim的出现次数,如.repeat(1,1)即为不变,.repeat(3,1)即为dim=0出现3次但dim=1不变,这不会增加新的维度
anchor_manipulations = torch.stack((-w, -h, w, h)).T.repeat(in_height * in_width, 1) / 2

利用 matplotlib 并结合上述代码,将图片及锚框绘画出来

def bbox_to_rect(bbox, color):
    """Convert bounding box to matplotlib format.
    将将(左上X, 左上Y, 右下X, 右下Y)转为(X, Y, 宽, 高)"""
    return patches.Rectangle(
        xy=(bbox[0], bbox[1]), width=bbox[2]-bbox[0], height=bbox[3]-bbox[1],
        fill=False, edgecolor=color, linewidth=2)

def show_bboxes(axes, bboxes, labels=None, colors=None):
    """Show bounding boxes."""

    def make_list(obj, default_values=None):
        if obj is None:
            obj = default_values
        elif not isinstance(obj, (list, tuple)):
            obj = [obj]
        return obj

    labels = make_list(labels)
    colors = make_list(colors, ['b', 'g', 'r', 'm', 'c'])
    for i, bbox in enumerate(bboxes):
        color = colors[i % len(colors)]
        rect = bbox_to_rect(bbox.detach().numpy(), color)
        axes.add_patch(rect)
        if labels and len(labels) > i:
            text_color = 'k' if color == 'w' else 'w'
            axes.text(rect.xy[0], rect.xy[1], labels[i],
                      va='center', ha='center', fontsize=9, color=text_color,
                      bbox=dict(facecolor=color, lw=0))

img = Image.open('./objdtct/catdog.jpg')
trans = transforms.Compose([transforms.ToTensor()])
img = trans(img)
fig, ax = pyplot.subplots()
h, w = img.shape[1:]
X = torch.rand(size=(1, 3, h, w))  # X为(批量为1,通道为3,高,宽))
Y = multibox_prior(X, sizes=[0.75, 0.5, 0.25], ratios=[1, 2, 0.5])
boxes = Y.reshape(h, w, 5, 4)  # reshape为(高,宽,sizes+ratios-1=5个框,四个坐标)

bbox_scale = torch.tensor((w, h, w, h))  # 因为之前做了归一化,因此这里将大小复原
show_bboxes(ax, boxes[250, 250, :, :] * bbox_scale,
            ['s=0.75, r=1', 's=0.5, r=1', 's=0.25, r=1', 's=0.75, r=2',
             's=0.75, r=0.5'])
ax.imshow(img.permute(1,2,0))
pyplot.show()

\(\textbf{IoU}\)计算函数

def box_iou(boxes1, boxes2):
    """Compute pairwise IoU across two lists of anchor or bounding boxes."""
    box_area = lambda boxes: ((boxes[:, 2] - boxes[:, 0]) *
                              (boxes[:, 3] - boxes[:, 1]))
    # Shape of `boxes1`, `boxes2`, `areas1`, `areas2`: (no. of boxes1, 4), (no. of boxes2, 4), (no. of boxes1,), (no. of boxes2,)
    areas1 = box_area(boxes1)
    areas2 = box_area(boxes2)
    # Shape of `inter_upperlefts`, `inter_lowerrights`, `inters`: (no. of boxes1, no. of boxes2, 2)
    inter_upperlefts = torch.max(boxes1[:, None, :2], boxes2[:, :2])
    inter_lowerrights = torch.min(boxes1[:, None, 2:], boxes2[:, 2:])
    inters = (inter_lowerrights - inter_upperlefts).clamp(min=0)
    # Shape of `inter_areas` and `union_areas`: (no. of boxes1, no. of boxes2)
    inter_areas = inters[:, :, 0] * inters[:, :, 1]
    union_areas = areas1[:, None] + areas2 - inter_areas
    return inter_areas / union_areas

标号锚框,将真实边界框分配给锚框

def assign_anchor_to_bbox(ground_truth, anchors, device, iou_threshold=0.5):
    """Assign closest ground-truth bounding boxes to anchor boxes."""
    num_anchors, num_gt_boxes = anchors.shape[0], ground_truth.shape[0]
    # Element x_ij in the i-th row and j-th column is the IoU of the anchor
    # box i and the ground-truth bounding box j
    jaccard = box_iou(anchors, ground_truth)
    # Initialize the tensor to hold the assigned ground-truth bounding box for
    # each anchor
    anchors_bbox_map = torch.full((num_anchors,), -1, dtype=torch.long,
                                  device=device)
    # Assign ground-truth bounding boxes according to the threshold
    max_ious, indices = torch.max(jaccard, dim=1)
    anc_i = torch.nonzero(max_ious >= iou_threshold).reshape(-1)
    box_j = indices[max_ious >= iou_threshold]
    anchors_bbox_map[anc_i] = box_j
    col_discard = torch.full((num_anchors,), -1)
    row_discard = torch.full((num_gt_boxes,), -1)
    for _ in range(num_gt_boxes):
        max_idx = torch.argmax(jaccard)  # Find the largest IoU
        box_idx = (max_idx % num_gt_boxes).long()
        anc_idx = (max_idx / num_gt_boxes).long()
        anchors_bbox_map[anc_idx] = box_idx
        jaccard[:, box_idx] = col_discard
        jaccard[anc_idx, :] = row_discard
    return anchors_bbox_map

注意式中的\(\text{iou_threshold}\),它指若生成的锚框与任何一个边界框的\(\textbf{IoU}\)都小于\(0.5\)的话,则将其标记为背景

构造一个函数,计算锚框距其分配真实边界框的偏移量

为方便机器学习模型训练,需先将四个坐标转为\(\textbf{(中心点,高宽)}\)的形式。代码如下:

def box_corner_to_center(boxes):
    """Convert from (upper-left, lower-right) to (center, width, height).
    将(左上X, 左上Y, 右下X, 右下Y)转为(中点X, 重点Y, 宽, 高)"""
    x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
    cx = (x1 + x2) / 2
    cy = (y1 + y2) / 2
    w = x2 - x1
    h = y2 - y1
    boxes = torch.stack((cx, cy, w, h), axis=-1)
    return boxes

进而计算偏移量。代码如下:

def offset_boxes(anchors, assigned_bb, eps=1e-6):
    """Transform for anchor box offsets."""
    c_anc = box_corner_to_center(anchors)
    c_assigned_bb = box_corner_to_center(assigned_bb)
    offset_xy = 10 * (c_assigned_bb[:, :2] - c_anc[:, :2]) / c_anc[:, 2:]
    offset_wh = 5 * torch.log(eps + c_assigned_bb[:, 2:] / c_anc[:, 2:])
    offset = torch.cat([offset_xy, offset_wh], axis=1)
    return offset
可见,代码中又对 偏移量 又是乘\(10\)又是取\(\log\),其目的是更好地让机器学习代码去预测。比如,使它缩放至均值为\(0\)并有个较好的分散程度,而不是特别大的值且呈现聚集。

运用上述函数标记锚框类别并得到偏移量

若锚框未匹配边界框,则将锚框标记为\(\textbf{背景}\)。标号为背景的锚框被称作负类锚框,其余的称作正类锚框。构造 multibox_target() 以使用边界框\(\text{(对应labels参数)}\)标记锚框的类别和偏移量\(\text{(对应anchors参数)}\)。此函数将背景标号为\(0\),然后将新类别的整数索引递增\(1\)。代码如下:

def multibox_target(anchors, labels):
    """Label anchor boxes using ground-truth bounding boxes."""
    batch_size, anchors = labels.shape[0], anchors.squeeze(0)
    batch_offset, batch_mask, batch_class_labels = [], [], []
    device, num_anchors = anchors.device, anchors.shape[0]
    for i in range(batch_size):
        label = labels[i, :, :]
        anchors_bbox_map = assign_anchor_to_bbox(
            label[:, 1:], anchors, device)
        bbox_mask = ((anchors_bbox_map >= 0).float().unsqueeze(-1)).repeat(
            1, 4)
        # Initialize class labels and assigned bounding box coordinates with
        # zeros
        class_labels = torch.zeros(num_anchors, dtype=torch.long,
                                   device=device)
        assigned_bb = torch.zeros((num_anchors, 4), dtype=torch.float32,
                                  device=device)
        # Label classes of anchor boxes using their assigned ground-truth
        # bounding boxes. If an anchor box is not assigned any, we label its
        # class as background (the value remains zero)
        indices_true = torch.nonzero(anchors_bbox_map >= 0)
        bb_idx = anchors_bbox_map[indices_true]
        class_labels[indices_true] = label[bb_idx, 0].long() + 1
        assigned_bb[indices_true] = label[bb_idx, 1:]
        # Offset transformation
        offset = offset_boxes(anchors, assigned_bb) * bbox_mask
        batch_offset.append(offset.reshape(-1))
        batch_mask.append(bbox_mask.reshape(-1))
        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)

其中,\(\textbf{bbox_offset}\)指每个锚框到边界框的偏移;\(\textbf{bbox_mask}\)判断锚框是否为负类锚框,为\(0\)是负类锚框即背景,为\(1\)时为正类锚框;\(\textbf{class_labels}\)为锚框对应类别的标号,如\(0\)为背景,\(1\)为狗,\(2\)为猫。

当\(\text{bbox_mask}\)为\(0\)时,\(\text{bbox_offset}\)与\(\text{class_labels}\)均为\(0\),即不用计算偏移量。\(\text{bbox_mask}\)的 shape 与\(\text{bbox_offset}\)一致。

测试上述代码,生成锚框并标号,计算偏移

手动绘制锚框、边界框并基于\(\textbf{IoU}\)进行标号。代码如下:

img = Image.open('./objdtct/catdog.jpg')
trans = transforms.Compose([transforms.ToTensor()])
img = trans(img)
fig, ax = pyplot.subplots()
h, w = img.shape[1:]
bbox_scale = torch.tensor((w, h, w, h))  # 因为之前做了归一化,因此这里将大小复原
ax.imshow(img.permute(1,2,0))
# 手动给边界框
ground_truth = torch.tensor([[0, 0.1, 0.08, 0.52, 0.92],
                         [1, 0.55, 0.2, 0.9, 0.88]])
# 手动画锚框
anchors = torch.tensor([[0, 0.1, 0.2, 0.3], [0.15, 0.2, 0.4, 0.4],
                    [0.63, 0.05, 0.88, 0.98], [0.66, 0.45, 0.8, 0.8],
                    [0.57, 0.3, 0.92, 0.9]])
show_bboxes(ax, ground_truth[:, 1:] * bbox_scale, ['dog', 'cat'], 'k')
show_bboxes(ax, anchors * bbox_scale, ['0', '1', '2', '3', '4'])
pyplot.show()

计算偏移量。代码如下:

# unsqueeze(0)为增加批量的维度
labels = multibox_target(anchors.unsqueeze(dim=0), ground_truth.unsqueeze(dim=0))

# 非极大值抑制 \(\textit{Non-Maximum Suppression}\)

预测时使用。预测时生成大量预测边界框的锚框,然而实际上并不是所有预测锚框都具有良好的效果。因此需要采用\(\textbf{非极大值抑制}\)法合并或减少相似的预测。

具体步骤

 1. 对于每一类目标,在非目标类的诸多预测锚框中选中具有最大预测值的预测锚框,如下图中狗狗的蓝框\(\text{(dog=0.9)}\)所示; 2. 删除所有其他狗狗的预测锚框,以及包括非目标类锚框在内的和具有最大预测值锚框\(\textbf{IoU}\)大于\(\theta\)的其它预测锚框; 3. 重复上述步骤,直到所有的预测锚框要么被选中,要么被丢掉。

代码实现预测时的坐标转换与非极大值抑制

预测时涉及坐标转换以及非极大值抑制两个部分。

坐标转换

在预测阶段,此时模型已训练完成,首先生成大量\(\textbf{锚框}\),再通过输入数据预测锚框的\(\textbf{偏移量}\)。然后通过这\(2\)笔数据得到预测的边界框,即最终输出结果。代码如下:

def box_center_to_corner(boxes):
    """Convert from (center, width, height) to (upper-left, lower-right).
    将(中点X, 重点Y, 宽, 高)转为(左上X, 左上Y, 右下X, 右下Y)"""
    cx, cy, w, h = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
    x1 = cx - 0.5 * w
    y1 = cy - 0.5 * h
    x2 = cx + 0.5 * w
    y2 = cy + 0.5 * h
    boxes = torch.stack((x1, y1, x2, y2), axis=-1)
    return boxes

def offset_inverse(anchors, offset_preds):
    """Predict bounding boxes based on anchor boxes with predicted offsets."""
    anc = box_corner_to_center(anchors)
    pred_bbox_xy = (offset_preds[:, :2] * anc[:, 2:] / 10) + anc[:, :2]
    pred_bbox_wh = torch.exp(offset_preds[:, 2:] / 5) * anc[:, 2:]
    pred_bbox = torch.cat((pred_bbox_xy, pred_bbox_wh), axis=1)
    predicted_bbox = box_center_to_corner(pred_bbox)
    return predicted_bbox

非极大值抑制

def nms(boxes, scores, iou_threshold):
    """Sort confidence scores of predicted bounding boxes."""
    B = torch.argsort(scores, dim=-1, descending=True)
    keep = []  # Indices of predicted bounding boxes that will be kept
    while B.numel() > 0:
        i = B[0]
        keep.append(i)
        if B.numel() == 1: break
        iou = box_iou(boxes[i, :].reshape(-1, 4),
                      boxes[B[1:], :].reshape(-1, 4)).reshape(-1)
        inds = torch.nonzero(iou <= iou_threshold).reshape(-1)
        B = B[inds + 1]
    return torch.tensor(keep, device=boxes.device)

def multibox_detection(cls_probs, offset_preds, anchors, nms_threshold=0.5,
                       pos_threshold=0.009999999):
    """Predict bounding boxes using non-maximum suppression."""
    device, batch_size = cls_probs.device, cls_probs.shape[0]
    anchors = anchors.squeeze(0)
    num_classes, num_anchors = cls_probs.shape[1], cls_probs.shape[2]
    out = []
    for i in range(batch_size):
        cls_prob, offset_pred = cls_probs[i], offset_preds[i].reshape(-1, 4)
        conf, class_id = torch.max(cls_prob[1:], 0)
        predicted_bb = offset_inverse(anchors, offset_pred)
        keep = nms(predicted_bb, conf, nms_threshold)
        # Find all non-`keep` indices and set the class to background
        all_idx = torch.arange(num_anchors, dtype=torch.long, device=device)
        combined = torch.cat((keep, all_idx))
        uniques, counts = combined.unique(return_counts=True)
        non_keep = uniques[counts == 1]
        all_id_sorted = torch.cat((keep, non_keep))
        class_id[non_keep] = -1
        class_id = class_id[all_id_sorted]
        conf, predicted_bb = conf[all_id_sorted], predicted_bb[all_id_sorted]
        # Here `pos_threshold` is a threshold for positive (non-background)
        # predictions
        below_min_idx = (conf < pos_threshold)
        class_id[below_min_idx] = -1
        conf[below_min_idx] = 1 - conf[below_min_idx]
        pred_info = torch.cat((class_id.unsqueeze(1),
                               conf.unsqueeze(1),
                               predicted_bb), dim=1)
        out.append(pred_info)
    return torch.stack(out)

手绘一些锚框,设已经得到了模型预测的偏移量,并给出预测概率。然后使用上述函数输出:

img = Image.open('./objdtct/catdog.jpg')
trans = transforms.Compose([transforms.ToTensor()])
img = trans(img)
fig, ax = pyplot.subplots()
h, w = img.shape[1:]
bbox_scale = torch.tensor((w, h, w, h))  # 因为之前做了归一化,因此这里将大小复原
ax.imshow(img.permute(1,2,0))
anchors = torch.tensor([[0.1, 0.08, 0.52, 0.92], [0.08, 0.2, 0.56, 0.95],
                      [0.15, 0.3, 0.62, 0.91], [0.55, 0.2, 0.9, 0.88]])
offset_preds = torch.tensor([0] * anchors.numel())
cls_probs = torch.tensor([[0] * 4,  # Predicted background likelihood
                      [0.9, 0.8, 0.7, 0.1],  # Predicted dog likelihood
                      [0.1, 0.2, 0.3, 0.9]])  # Predicted cat likelihood
output = multibox_detection(cls_probs.unsqueeze(dim=0),
                            offset_preds.unsqueeze(dim=0),
                            anchors.unsqueeze(dim=0),
                            nms_threshold=0.5)
for i in output[0].detach().numpy():
    if i[0] == -1:
        continue
    label = ('dog=', 'cat=')[int(i[0])] + str(i[1])
    show_bboxes(ax, [torch.tensor(i[2:]) * bbox_scale], label)
pyplot.show()

 cls_probs 概率方面,可见每个锚框有三种类别概率,三者和为\(1\)。

阶段总结

部分目标检测算法基于锚框实现。它首先生成大量锚框并赋予标号,每个锚框作为训练样本进行训练;在预测时,使用非极大值抑制来去掉冗余的预测。注:本文写到这里还未涉及神经网络训练部分。实际情况与此处相反,通常在预测之前已经训练好模型!

QA

一定要锚框吗?

不一定。但锚框算法更多,效果也不错。

最大预测值,指的是什么?

锚框预测没有置信度,只有分类有置信度。这取决于算法如何进行分类任务,一般每个类是分类问题,\(\text{Softmax}\)有置信度。锚框偏移的预测是一个回归问题。

每次做\(\textbf{NMS}\),是只针对相同类别(只对狗)做循环过滤去除,还是对不同类别(猫和狗)都做过滤去除?

两种都可以。

能根据特征点筛选像素再添加锚框吗?

可以。像 YOLO 做了聚类,根据先验知识来选锚框。

# 目标检测常用算法

目标检测有诸多系列算法,如\(\textbf{R-CNN系列}\)、\(\textbf{YOLO系列}\)等。

# 区域域卷积神经网络 \(\textit{Region Based Convolutional Neural Networks}\)

 R-CNN 既是最早,也是最有名的一类基于锚框和\(\text{CNN}\)的目标检测算法。按时间排列,该系列包括\(\text{R-CNN}\)、\(\text{Fast R-CNN}\)、\(\text{Faster R-CNN}\)以及\(\text{Mask R-CNN}\)。其中,\(\text{Mask R-CNN}\)要求像素级别标号,在无人车领域运用较多。并且,得益于算法的高精度结果,\(\text{Faster R-CNN}\)和\(\text{Mask R-CNN}\)适合刷榜、水论文等精度要求高的场景。接下来简单介绍各算法。

R-CNN

该算法纳入传统目标检测算法中的\(\text{选择性搜索}\),检测流程如下图所示。具体而言:

1.通过\(\textbf{选择性搜索}\)来选取多个高质量的提议区域

在多个尺度下选取若干具有不同形状和大小的\(\textbf{提议区域}\),每个\(\text{提议区域}\)都将被标注类别并分配给对应边界框;上一节提到的在每个像素上生成\(\text{锚框}\)也可以被认为是一种选取提议区域的方法。

2.使用预训练模型抽取每个提议区域的特征(在这里使用\(\text{CNN}\)

选择一个预训练好的卷积神经网络,并将其在输出层之前截断。将每个提议区域变形为网络需要的输入尺寸,并通过前向传播输出抽取的提议区域特征。

3.训练多个支持向量机对目标分类

将每个提议区域的特征连同其标注的\(\textbf{类别}\)作为一个样本。训练多个支持向量机对目标分类,其中每个支持向量机用来判断样本是否属于某一个类别。

4.训练回归模型预测边界框偏移量

将每个提议区域的特征连同其标注的\(\textbf{边界框}\)作为一个样本,训练线性回归模型来预测真实边界框。

对每个提议区域,卷积神经网络的前向传播是独立的,而没有共享计算(如图中两个\(\text{CNN}\)所示)。 由于这些区域通常有重叠,独立的特征抽取会导致重复的计算。所以尽管\(\text{R-CNN}\)通过预训练好的卷积神经网络有效地抽取了图像特征,但碍于庞大的计算量,它的速度很慢。这使得该算法在实践中难以被广泛应用。

Fast R-CNN

\(\text{Fast R-CNN}\)的主要改进之一,是仅在整张图象上执行卷积神经网络的前向传播(如图中一个\(\text{CNN}\)所示)。此外,由于在不同形状大小的提议区域上抽取的特征大小不同,不利于做批量\(\text{ (Batching)}\)。因此,为统一这些特征的形状和大小以便于连结后输出,\(\text{Fast R-CNN}\)还引入了兴趣区域池化层\(\text{ (RoI Pooling)}\)。算法流程如下图所示,具体而言:

1.使用卷积神经网络提取整幅影像的特征(在这里使用\(\text{CNN}\)

与\(\text{R-CNN}\)相比,\(\text{Fast R-CNN}\)用来提取特征的卷积神经网络的输入是整个图像,而不是各个提议区域。此外,这个网络通常会参与训练。设输入为一张图像,将卷积神经网络的输出的形状记为\((1\times c\times h_1\times w_1)\)。

2.通过\(\textbf{选择性搜索}\)来选取多个高质量的提议区域

在多个尺度下选取若干具有不同形状和大小的\(\textbf{提议区域}\),每个\(\text{提议区域}\)都将被标注类别并分配给对应边界框。

3.利用\(\textbf{兴趣区域池化层}\)抽取出形状相同的特征

设在上一步选择了\(n\)个提议区域,这些形状各异的提议区域被映射到\(\text{CNN}\)上,并在\(\text{CNN}\)的\(\text{特征图}\)输出上分别标出了形状各异的兴趣区域。

为进一步从感兴趣区域取出形状相同的特征(比如指定高度\(h_2\)和宽度\(w_2\)),以便连结后以批量的形式输出。需采用兴趣区域池化层\(\text{(RoI Pooling)}\)将卷积神经网络的输出和提议区域作为输入,输出连结后的\(n\)个提议区域抽取的特征,形状为\((n\times c\times h_2\times w_2)\)。

4.通过全连接层改变输出形状

通过全连接层将输出形状变换为\((n\times d)\),其中超参数\(d\)取决于模型设计。

5.预测每个提议区域的目标以及边界框偏移量

在预测类别和边界框时,将全连接层的输出分别转换为形状为\((n\times 类别数量)\)的输出和形状为\((n\times 4)\)的输出。其中预测类别时使用\(\text{Softmax}\)回归。

兴趣区域池化层

这里的\(\textbf{兴趣区域池化层}\)与在基础文章中介绍的池化层不同。之前通过\(\text{Stride}\)、\(\text{Padding}\)和窗口大小来控制输出形状,然而\(\textbf{兴趣区域池化层}\)可以直接指定每个区域的输出形状。

例如,指定每个区域输出的高和宽分别为\(h_2\)和\(w_2\)。对于任何形状为\(h\times w\)的兴趣区域,都会被划分为\(h_2\times w_2\)个子区域,其中每个子区域的大小约为\(\frac{h}{h_2}\times \frac{w}{w_2}\)。 在实践中,任何子区域的高度和宽度都应向上取整,其中的最大元素作为该子窗口的输出。 因此,\(\textbf{兴趣区域池化层}\)可从形状各异的兴趣区域中均抽取出形状相同的特征。

举例说明:从\(4\times 4\)的输入中选取左上角\(3\times 3\)的兴趣区域。再通过\(2\times 2\)的\(\textbf{兴趣区域池化层}\)得到一个\(2\times 2\)的输出。

Faster R-CNN

相较\(\text{Fast R-CNN}\),\(\text{Faster R-CNN}\)使用名为\(\textbf{区域提议网络}\)的神经网络代替了之前的\(\text{启发式搜索}\),从而减少提议区域的生成数量,并保证目标检测的精度。其余部分没有区别。算法流程如下图所示,具体而言:

1.更改通道数

使用\(padding=1\)的\(3\times 3\)卷积层变换\(\text{CNN}\)的输出,并将输出通道数记为\(c\)。鉴此,\(\text{CNN}\)为图像抽取的特征图中的每个单元均得到一个长度为\(c\)的新特征。

2.生成锚框

以特征图的每个像素为中心,生成多个不同大小和宽高比的锚框并标注它们的类别和偏移量。

3.学习预测

使用每个锚框中心的长度为\(c\)的特征向量,预测该锚框的二元类别(背景或物体)以及边界框。

4.输出预测的边界框

使用\(\text{NMS}\),从预测类别为目标的预测边界框中移除相似的结果。最终输出的预测边界框即是\(\textbf{兴趣区域池化层}\)所需的提议区域。

值得一提的是,区域提议网络\(\textbf{RPN}\)作为\(\textbf{Faster R-CNN}\)模型的一部分,是和整个模型一起训练得到的。 换句话说,\(\text{Faster R-CNN}\)目标函数不仅包括目标检测中的类别和边界框预测,还包括\(\text{RPN}\)中锚框的二元类别和边界框预测。 作为端到端训练的结果,\(\text{RPN}\)能够学习到如何生成高质量的提议区域,从而在减少了从数据中学习的提议区域的数量的情况下,仍保持目标检测的精度。

Mask R-CNN

相较\(\text{Faster R-CNN}\),\(\textbf{Mask R-CNN}\)多了一个全卷积网络\(\text{(Fully Convolutional Network)}\),来预测每个像素。前提是数据提供像素级的标号。

此外,兴趣区域池化层\(\text{(RoI Pooling)}\)被替换为了兴趣区域对齐层\(\text{(RoI align)}\),使用双线性插值来保留特征图上的空间信息,从而更适于像素级预测。

\(\text{(RoI align)}\)的输出包含了所有与兴趣区域的形状相同的特征图。 它们不仅被用于预测每个兴趣区域的类别和边界框,还通过额外的全卷积网络预测目标的像素级位置。

替换的原因是,在\(\text{(RoI Pooling)}\)中输入特征图被分为固定数量的网格,每个网格的值通过赋最大值或平均值。这种划分过程需将浮点数边界框坐标量化为整数,导致精度损失和边界框定位不准确;而在\(\text{(RoI align)}\)中,直接使用浮点数边界框坐标来进行特征图采样,在每个采样点使用双线性插值从输入特征图中获取精确的值。

# 单发多框检测 \(\textit{Single Shot Multibox Detection}\)

\(\text{SSD}\)是非常经典的\(\text{one-stage}\)目标检测算法,主要由基础网络组成,其后是几个多尺度特征块。基本网络用于从输入图像中提取特征,因此它可以使用深度卷积神经网络。 论文中,该算法选用了在分类层之前截断的\(\text{VGG}\),现在也常用\(\text{ResNet}\)替代。

可以设计基础网络,使它输出的高和宽较大。 进而使得基于该特征图生成的锚框数量较多,可以用来检测尺寸较小的目标。 接下来的每个多尺度特征块将上一层提供的特征图的高和宽缩小(如减半),并使特征图中每个单元在输入图像上的感受野变得更广阔。

通过多尺度特征块,\(\text{SSD}\)可以生成不同大小的锚框,并通过预测边界框的类别和偏移量来检测大小不同的目标,因此这是一个多尺度目标检测模型。具体流程如下:

1.基础网络\(\textbf{Base Network}\)抽取特征

抽取特征后,在每个像素上生成锚框并标号类别和边界框偏移量,然后预测类别和边界框。

2.进入卷积层,高宽减半,重复该步骤

高宽减半后,继续在每个像素上生成锚框并标号类别和边界框偏移量,然后预测类别和边界框。

这样的好处是,越到顶层的特征图越小,即使保持锚框大小不变,感受野也会越来越大。可以说是底部拟合小物体,顶部拟合大物体。

代码思路

整个模型分五个模块,第一个为\(\text{基础网络}\),第二、三、四个为\(\text{下采样}\),第五个为\(\text{全局自适应池化}\)。

def get_blk(i):
    if i == 0:
        blk = base_net()
    elif i == 1:
        blk = down_sample_blk(64, 128)
    elif i == 4:
        blk = nn.AdaptiveMaxPool2d((1,1))
    else:
        blk = down_sample_blk(128, 128)
    return blk

基础网络

抽取输入数据的特征,并输出特征图。

def base_net():
    blk = []
    num_filters = [3, 16, 32, 64]
    for i in range(len(num_filters) - 1):
        blk.append(down_sample_blk(num_filters[i], num_filters[i+1]))
    return nn.Sequential(*blk)

forward(torch.zeros((2, 3, 256, 256)), base_net()).shape

下采样

\(\text{重采样}\)分为\(\text{上采样}\)和\(\text{下采样}\)。其中下采样又称抽取或欠采样,在神经网络中即变换通道然后将图片高宽减半(小)。这其实在神经网络基础中的经典网络里很常见。

def down_sample_blk(in_channels, out_channels):
    blk = []
    for _ in range(2):
        blk.append(nn.Conv2d(in_channels, out_channels,
                             kernel_size=3, padding=1))
        blk.append(nn.BatchNorm2d(out_channels))
        blk.append(nn.ReLU())
        in_channels = out_channels
    blk.append(nn.MaxPool2d(2))
    return nn.Sequential(*blk)

定义每个块的前向计算

与之前的简单神经网络仅输出一个对象不同。基于锚框的目标检测任务下,在前向计算时还输出生成的锚框,以及预测类别和边界框。

def blk_forward(X, blk, size, ratio, cls_predictor, bbox_predictor):
    Y = blk(X)
    anchors = d2l.multibox_prior(Y, sizes=size, ratios=ratio)
    cls_preds = cls_predictor(Y)
    bbox_preds = bbox_predictor(Y)
    return (Y, anchors, cls_preds, bbox_preds)

超参数

# 0.272可以看作是0.2*0.37开根号,以此类推
sizes = [[0.2, 0.272], [0.37, 0.447], [0.54, 0.619], [0.71, 0.79],
         [0.88, 0.961]]
# 一般ratio就是这样
ratios = [[1, 2, 0.5]] * 5
num_anchors = len(sizes[0]) + len(ratios[0]) - 1

读取数据

接下来是正常训练那一套。首先把训练数据读进来。

定义优化算法并初始化其超参数

选择合适的优化算法,如\(\text{SGD}\)等,然后初始化其超参数。

定义损失函数和评价函数

由于是对每张图片计算损失,因此\(\text{reduction}\)没有取平均。边缘框损失取\(\text{L1Loss}\),即\(|\text{预测值}-\text{真实值}|\),若是\(\text{L2Loss}\)求距离平方的话,较大的罚会给很多较差的预测。

cls_loss = nn.CrossEntropyLoss(reduction='none')
bbox_loss = nn.L1Loss(reduction='none')

def calc_loss(cls_preds, cls_labels, bbox_preds, bbox_labels, bbox_masks):
    batch_size, num_classes = cls_preds.shape[0], cls_preds.shape[2]
    cls = cls_loss(cls_preds.reshape(-1, num_classes),
                   cls_labels.reshape(-1)).reshape(batch_size, -1).mean(dim=1)
    bbox = bbox_loss(bbox_preds * bbox_masks,
                     bbox_labels * bbox_masks).mean(dim=1)
    return cls + bbox

def cls_eval(cls_preds, cls_labels):
    # Because the class prediction results are on the final dimension,
    # `argmax` needs to specify this dimension
    return float((cls_preds.argmax(dim=-1).type(
        cls_labels.dtype) == cls_labels).sum())

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

QA

每个通道上的锚框数固定吗?

在做预测时,通道用来存每个像素的预测值。就像\(\text{NiN}\)用最后一个卷积把通道数设置为类别数做预测。也像是一个全连接层的那种输出。

图像尺寸如果很大,比如\(1024\times 1024\),那么生成的锚框太多,爆内存怎么办?

\(\text{SSD}\)不适合大图片,建议换\(\text{two-stage}\)的\(\text{Faster R-CNN}\)等。或者换\(\text{YOLO}\),不管图片有多大,生成的锚框也不会很多。

如何处理特殊形状的物体?如电线杆?

统计一下每类物体的边缘框真实情况具体什么样,它们对整张图片的占比,以及高宽比,再设计锚框的超参数。比如\(\text{YOLO}\)会真的去看是否有很小的物体,通常目标在什么地方出现等。

为什么用\(\text{L1}\)?

预测好的锚框就那么几个,其余预测不好的锚框真的不需要关心。所以不给这些不值的关心的锚框加\(\text{L2}\)的罚。

多个\(\text{Loss}\)相加,若其中一个很大,会不会导致其他的\(\text{Loss}\)没什么用?

是的。所以通常会给不同的损失以权重。如在\(\text{two-stage}\)算法中,可以通过看两个阶段的模型的学习曲线是否在一个\(\text{scale}\)里,以此作为目标来调整权重。

目标检测怎么做\(\text{Fine-Tune}\)?

目标检测一般要做\(\text{Fine-Tune}\),但\(\text{class_predictor}\)和\(\text{bbox-predictor}\)需要重新训练。但别的东西,如\(\text{backbone}\),一般是\(\text{pretrained}\)出来的。

目标检测有什么推荐的库吗?

\(\text{MMDetection}\)用得比较多。

严重数据不平衡怎么办?

把具有更多数据的类别权重降低;或再多标一点数据少的类别。

# YOLO \(\textit{You Only Look Once}\)

\(\text{YOLO}\)也是\(\text{one-stage}\)目标检测算法,第一版从\(\text{SSD}\)衍生而来,不同之处在于它将图片均匀分成\(S\times S\)个锚框(网格),每个锚框预测\(B\)个边缘框,避免了由于大量重叠锚框导致的多余计算。其中,\(B\)可以设为5左右;\(S\)可以设置为几百到一千。

预测\(B\)个边缘框是为了避免一个锚框圈中了多个目标,而导致丢掉其中一个。

后续版本有持续改进。

\(\text{YOLO}\)很快,在某个同一精度下,比\(\text{Faster R-CNN}\)快\(5\)倍。

QA

测试数据增强做平均,是结果做平均还是概率做平均?

在概率上做平均,即\(\text{Softmax}\)上做平均。

多模型融合对小样本数据有用吗?

有。

对于密集小目标检测,如文本框、车牌等,怎么设置网络?

把图片放大。因为目标检测不需要像图像分类一样把特征图压成向量,目标检测只是抽一些锚框,图片大一点,特征图也会变大。如果文本确实很小,那么输入图片可以做成高分辨率图片。在文本识别上,一般用\(\text{R-CNN}\)系列,因为精度高,并通常把特别大的图片输入进去。

高精度图片分类小目标?

多用于卫星图处理上,存在一套成熟的方法使算法不学习背景噪音。

# 语义分割 \(\textit{Semantic Segmentation}\)

不同于目标检测仅识别目标的主体部分,语义分割还能识别每个像素的标签。由于处理不同任务,它们之间利用的数据集及其读取方式亦有差别。此外,实例分割被认为是目标检测的改进,不仅检测了物体,还描绘了物体边缘。

# 语义分割数据集

在语义分割中,\(\text{VOC}\)数据集格式被广泛采用。实际上它不仅含有语义分割数据集,还包含目标检测数据集,这里采用它语义分割的部分作为示例。特征\(\textbf{features}\)与标签\(\textbf{labels}\)都使用栅格图像存储,其中标签需要使用\(\textbf{.png}\)格式存储,因为\(\textbf{.jpg}\)格式压缩信息。图片中每个像素使用不同的颜色来表示不同的标签。因此还需要一个\(\textbf{colormap}\)来将颜色映射至标签类别。这是大部分语义分割数据集的逻辑。

VOC语义分割数据集画像

语义分割任务涉及的文件如下表所示:

序号文件夹示例描述
1ImageSets/segmentation/train.txt文件夹内文本文件中每一行为训练样本的文件名称,不含后缀
2JPEGImages/2007_000032.jpg文件夹内每张图像对应一个训练样本的features
3SegmentationClass/2007_000032.png文件夹内每张图像对应一个训练样本的targets
Table 1. 用于语义分割任务的部分

涉及的库

为读取存放每个训练样本文件名的文本文件、栅格训练样本(包括特征和标签),以及构造数据迭代器,需要引入以下库。这里使用\(\text{torchvision.io}\)提供的方法替代 PIL 读取图片数据。

import torch
import pandas
from torch.utils import data
import os
import torchvision
from torchvision import tv_tensors
from torchvision.transforms import v2

定义映射RGB至目标类别的函数

一般而言,在语义分割标签数据中使用不同的颜色代表不同物体种类,使用黑色代表背景。因此需要定义映射这些颜色至对应物体类别的函数。

VOC_COLORMAP = [[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
                [0, 0, 128], [128, 0, 128], [0, 128, 128], [128, 128, 128],
                [64, 0, 0], [192, 0, 0], [64, 128, 0], [192, 128, 0],
                [64, 0, 128], [192, 0, 128], [64, 128, 128], [192, 128, 128],
                [0, 64, 0], [128, 64, 0], [0, 192, 0], [128, 192, 0],
                [0, 64, 128]]

VOC_CLASSES = ['background', 'aeroplane', 'bicycle', 'bird', 'boat',
               'bottle', 'bus', 'car', 'cat', 'chair', 'cow',
               'diningtable', 'dog', 'horse', 'motorbike', 'person',
               'potted plant', 'sheep', 'sofa', 'train', 'tv/monitor']

def voc_colormap2label():
    """Build the mapping from RGB to class indices for VOC labels."""
    colormap2label = torch.zeros(256 ** 3, dtype=torch.long)
    for i, colormap in enumerate(VOC_COLORMAP):
        colormap2label[
            (colormap[0] * 256 + colormap[1]) * 256 + colormap[2]] = i
    return colormap2label

def voc_label_indices(colormap, colormap2label):
    """Map any RGB values in VOC labels to their class indices."""
    colormap = colormap.permute(1, 2, 0).numpy().astype('int32')
    idx = ((colormap[:, :, 0] * 256 + colormap[:, :, 1]) * 256
           + colormap[:, :, 2])
    return colormap2label[idx]

定义读取自定义\(\text{VOC}\)样式语义分割数据集的类

用于训练的训练样本的输入特征和标签均为栅格。因此数据增广时需对二者均进行同样的变换,需要使用\(\text{transforms.v2}\)和\(\text{torchvision.tv_tensors}\)。

class custom_VOCSegDataset(data.Dataset):
    def __init__(self, df, image_dir, transform=None):
        self.data_frame = df
        self.image_dir = image_dir
        self.transform = transform
        self.colormap2label = voc_colormap2label()

    def __len__(self):
        return len(self.data_frame)

    def __getitem__(self, idx):
        if torch.is_tensor(idx):
            idx = idx.to_list()
        feature_img_name = os.path.join(self.image_dir, 'JPEGImages', f'{self.data_frame.iloc[idx, 0]}.jpg')
        feature_image = torchvision.io.read_image(feature_img_name, mode=torchvision.io.image.ImageReadMode.RGB)  # image = Image.open(img_name)
        label_img_name = os.path.join(self.image_dir, 'SegmentationClass', f'{self.data_frame.iloc[idx, 0]}.png')
        label_image = torchvision.io.read_image(label_img_name, mode=torchvision.io.image.ImageReadMode.RGB)
        if self.transform:
            feature_image, label_image = self.transform(feature_image, tv_tensors.Mask(label_image))
        return  feature_image.to('cuda:0'), voc_label_indices(label_image, self.colormap2label)

\(\text{tv_tensors.Mask}\)用于语义分割。此外,\(\text{v2}\)同样适用于目标检测的边缘框数据增广,具体而言将边缘框坐标存放在\(\text{tv_tensors.BoundingBoxes}\)中。此处

根据上述函数实例化数据迭代器

结合上述所有函数读取自定义\(\text{VOC}\)样式语义分割数据集。

trans = v2.Compose([
    v2.RandomResizedCrop(size=(320, 480)),
    v2.ToTensor()
])
voc_dir = f'/VOCdevkit/VOC2012/'
train_df = pandas.read_csv('/VOCdevkit/VOC2012/ImageSets/Segmentation/train.txt', skip_blank_lines=True, header=None, names=['img_name'])
train_dataset = custom_VOCSegDataset(df=train_df, image_dir=voc_dir, transform=trans)
train_iter = data.DataLoader(train_dataset, batch_size=32, shuffle=True)

# 转置卷积

目前为止,帖子中的卷积层均减少下采样输入图像的空间维度\(\text (高和宽)\)。然而语义分割聚焦于每个像素的标签,使用卷积层抽取特征并减少图像空间维度后,需要\(\textbf{转置卷积}\)将图像的空间维度还原,以便进行像素级的输出。

转置卷积本质上是一种卷积。它是一种变化了输入和核的卷积。具体而言,它给输入添加了很多的\(0\),来放大输出的高宽以达到上采样的目的;同时给核做了翻转,再像普通卷积一样去计算。这并不等同于数学上的\(\text{反卷积}\)。

工作流程

卷积核为\(2\times 2\)的转置卷积

$$Y[i:i+h,j:j+w]+=X[i,j]\cdot K$$

\(\text{Padding}\)

与常规卷积不同,在转置卷积中,填充被应用于的输出(常规卷积将填充应用于输入)。 例如,当将高和宽两侧的填充数指定为1时,转置卷积的输出中将删除第一和最后的行与列。

\(\text{Stride}\)

在转置卷积中,步幅被指定为中间结果(输出),而不是输入。使用上图中相同输入和卷积核张量,将步幅从\(1\)更改为\(2\)会增加中间张量的高和权重,因此输出张量在下图中。

\(\text{多通道}\)

对于多个输入和输出通道,转置卷积与常规卷积以相同方式运作。 假设输入有\(c_i\)个通道,且转置卷积为每个输入通道分配了一个\(k_h\times k_w\)的卷积核张量。当指定多个输出通道时,每个输出通道将有一个\(c_i\times k_h\times k_w\)的卷积核。

QA

上采样过程中还可以采用线性插值,哪种方式更好?

转置卷积不仅可以实现上插值的效果,语义分割任务不仅仅是插值,还有语义转换。可以用线性插值初始化转置卷积的核,使得放大图片的效果更好。

转置卷积会将特征图还原为之前的值吗?

不会。转置卷积只是将特征图的形状还原为之前的输入图像大小,值并不一定还原,目的也不在还原它的值。转置卷积旨在利于语义分割任务对每一个像素判断标号,而不是判断它的\(\text{RGB}\)。

转置卷积就是上采样吗?

不是。转置卷积可用于上采样,但它本身不是上采样。

超分辨率是用转置卷积做的吗?

有用转置卷积做超分辨率的。

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments