计算机视觉,图像增广,微调,R-CNN,SSD,YOLO

一、图像增广

**图像增广(Image Augmentation,也常被称为数据增强)**是计算机视觉中最常用且最有效的提升模型泛化能力的方法。

1.1 什么是图像增广?为什么需要它?

在深度学习中,模型很容易"死记硬背"训练数据(即过拟合)。比如,如果训练集里所有的猫都在图片的左边,模型可能会认为"左边有一团毛茸茸的东西"才是猫。
图像增广就是在把图片喂给神经网络之前,随机地对它进行一些变换(如:翻转、裁剪、改变颜色、加噪等)。

它的好处有两点:

  1. 变相扩大数据集:一张猫的图片经过10种不同的变换,就变成了10张"相似但不同"的训练样本。
  2. 提高泛化能力(鲁棒性):打破模型对特定属性(如位置、大小、颜色)的依赖。比如裁剪能让模型适应物体在不同位置,改颜色能降低模型对光照的敏感度。

辅助函数

python 复制代码
%matplotlib inline
from d2l import torch as d2l
import torch
import torchvision
from torch import nn

torchvision包含了计算机视觉常用的数据集、模型和图像变换工具(即我们将使用的transforms` 模块)。

为了直观看到增广的效果,定义一个辅助函数 apply

python 复制代码
def apply(img, aug, num_rows=2, num_cols=4, scale=1.5):
    # 列表推导式:对同一张输入图片 img,重复执行 aug(img) 操作 num_rows * num_cols 次(这里是 2*4=8 次)
    # 因为增广方法通常带有随机性,所以即使是同一个 aug 操作,每次输出的图片也会不一样。
    Y = [aug(img) for _ in range(num_rows * num_cols)]
    # 使用 d2l 库提供的绘图函数,将这 8 张生成的图片以网格的形式展示出来
    d2l.show_images(Y, num_rows, num_cols, scale=scale)

1.2 常用的图像增广方法

翻转 (Flipping) 和 裁剪 (Cropping)

左右翻转 (Horizontal Flip)

最常用的增广方法,因为现实世界中绝大多数物体的左右对称性不改变其类别(猫向左看和向右看都是猫)。

python 复制代码
# RandomHorizontalFlip(): 以 50% 的默认概率将图片左右翻转
apply(img, torchvision.transforms.RandomHorizontalFlip())

上下翻转 (Vertical Flip)

用的相对较少(毕竟现实中倒立的猫不常见),但在医学图像(细胞切片)、卫星遥感图像中非常常用。

python 复制代码
# RandomVerticalFlip(): 以 50% 的默认概率将图片上下翻转
apply(img, torchvision.transforms.RandomVerticalFlip())

随机缩放裁剪 (Random Resized Crop)

非常有用的一种增广方法,它能解决物体在图像中大小不一、位置不一的问题。

python 复制代码
shape_aug = torchvision.transforms.RandomResizedCrop(
    (200, 200),        # 无论原本裁下来多大,最后统统强行缩放(Resize)到 200x200 像素
    scale=(0.1, 1),    # 随机裁剪面积:裁剪出的面积是原图面积的 10% 到 100% 之间
    ratio=(0.5, 2)     # 随机长宽比:裁剪框的高宽比例在 0.5 (1:2) 到 2 (2:1) 之间随机
)
apply(img, shape_aug)

改变颜色 (Color Jittering)

光照条件在现实中千变万化。我们可以通过改变亮度 (brightness)、对比度 (contrast)、饱和度 (saturation) 和色调 (hue) 来模拟各种光照。
ColorJitter 接收的参数如果是单个数 x,一般表示在 [max(0, 1 - x), 1 + x] 范围内随机抖动。

python 复制代码
# 只改变亮度:亮度将在原图的 50% (1-0.5) 到 150% (1+0.5) 之间随机浮动
apply(img, torchvision.transforms.ColorJitter(
    brightness=0.5, contrast=0, saturation=0, hue=0))

# 只改变色调:hue 参数的取值范围限制在 [-0.5, 0.5] 之间
apply(img, torchvision.transforms.ColorJitter(
    brightness=0, contrast=0, saturation=0, hue=0.5))

# 同时随机改变四个属性
color_aug = torchvision.transforms.ColorJitter(
    brightness=0.5, contrast=0.5, saturation=0.5, hue=0.5)
apply(img, color_aug)

组合多种增广方法 (Compose)

在实际训练中,我们当然希望把上述方法结合起来使用。Compose 就相当于一条流水线。

python 复制代码
# Compose 接收一个列表,图片会依次通过列表中的每个变换
augs = torchvision.transforms.Compose([
    torchvision.transforms.RandomHorizontalFlip(), # 第一步:一半概率左右翻转
    color_aug,                                     # 第二步:随机改变颜色
    shape_aug                                      # 第三步:随机裁剪并缩放
])
apply(img, augs)

1.3 使用图像增广进行模型训练

定义训练集和测试集的增广流水线

重要原则 :在训练 时,我们使用随机增广来增加数据多样性;但在测试/预测 时,我们绝不能使用随机增广,否则会对同一个样本得出不同的预测结果,导致评估不准确。

python 复制代码
# 训练期的流水线
train_augs = torchvision.transforms.Compose([
     torchvision.transforms.RandomHorizontalFlip(), # 加上随机左右翻转
     torchvision.transforms.ToTensor()              # 必须的一步:将 PIL 格式转为 PyTorch 张量
     # ToTensor() 会做两件事:
     # 1. 把形状从 (高度, 宽度, 通道数) 变成 PyTorch 需要的 (通道数, 高度, 宽度)
     # 2. 把像素值从 0~255 的整数,归一化到 0.0~1.0 的浮点数
])

# 测试期的流水线
test_augs = torchvision.transforms.Compose([
     torchvision.transforms.ToTensor()              
])

加载数据的辅助函数:

python 复制代码
def load_cifar10(is_train, augs, batch_size):
    # 下载并加载 CIFAR10 数据集,根据 is_train 决定是加载训练集还是测试集
    # transform=augs 将我们上面定义的流水线应用到每一张图片上
    dataset = torchvision.datasets.CIFAR10(root="../data", train=is_train,
                                           transform=augs, download=True)
    # 包装成 DataLoader,方便分批次 (batch) 送入 GPU 训练
    dataloader = torch.utils.data.DataLoader(dataset, batch_size=batch_size,
                    shuffle=is_train, num_workers=d2l.get_dataloader_workers())
    return dataloader

1.4 多GPU训练

这里定义了如何在一个 batch 上进行训练 (train_batch_ch13) 以及整个训练流程 (train_ch13)。这部分代码是为了兼容单GPU和多GPU。

python 复制代码
# 单个 batch 的训练函数
def train_batch_ch13(net, X, y, loss, trainer, devices):
    # 将数据 X 和标签 y 移动到对应的设备(如 GPU:0)上
    if isinstance(X, list):
        X = [x.to(devices[0]) for x in X]
    else:
        X = X.to(devices[0])
    y = y.to(devices[0])
    
    net.train()           # 将网络设置为训练模式(启用 Dropout 和 BatchNorm 等特性)
    trainer.zero_grad()   # 优化器梯度清零(PyTorch中梯度默认是累加的,每一步必须清零)
    pred = net(X)         # 前向传播:计算预测结果
    l = loss(pred, y)     # 计算预测结果与真实标签的损失
    l.sum().backward()    # 反向传播:计算所有参数的梯度
    trainer.step()        # 优化器更新:根据梯度更新网络参数
    
    # 记录当前 batch 的总损失和预测正确的数量,用于后续计算平均指标
    train_loss_sum = l.sum()
    train_acc_sum = d2l.accuracy(pred, y)
    return train_loss_sum, train_acc_sum

def train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs,
               devices=d2l.try_all_gpus()):
    """用多GPU进行模型训练"""
    timer, num_batches = d2l.Timer(), len(train_iter)
    animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs], ylim=[0, 1],
                            legend=['train loss', 'train acc', 'test acc'])
    net = nn.DataParallel(net, device_ids=devices).to(devices[0])
    for epoch in range(num_epochs):
        # 4个维度:储存训练损失,训练准确度,实例数,特点数
        metric = d2l.Accumulator(4)
        for i, (features, labels) in enumerate(train_iter):
            timer.start()
            l, acc = train_batch_ch13(
                net, features, labels, loss, trainer, devices)
            metric.add(l, acc, labels.shape[0], labels.numel())
            timer.stop()
            if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1:
                animator.add(epoch + (i + 1) / num_batches,
                             (metric[0] / metric[2], metric[1] / metric[3],
                              None))
        test_acc = d2l.evaluate_accuracy_gpu(net, test_iter)
        animator.add(epoch + 1, (None, None, test_acc))
    print(f'loss {metric[0] / metric[2]:.3f}, train acc '
          f'{metric[1] / metric[3]:.3f}, test acc {test_acc:.3f}')
    print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec on '
          f'{str(devices)}')

完整的训练循环函数 (train_ch13 较长,主要做了以下事情):

  1. 设置计时器和绘图动画 (Animator)。
  2. 使用 nn.DataParallel(net, device_ids=devices) 把模型包装起来,实现多GPU并行计算。
  3. 循环 num_epochs 次。在每个 epoch 内,遍历训练集,调用 train_batch_ch13 进行训练,并累加损失和准确率。
  4. 每个 epoch 结束后,用测试集评估当前模型的准确率 (evaluate_accuracy_gpu)。
  5. 实时画出 loss 曲线和 accuracy 曲线。

组合执行训练

最后,我们将上述所有组件拼合起来,启动训练。

python 复制代码
batch_size, devices, net = 256, d2l.try_all_gpus(), d2l.resnet18(10, 3) # 初始化 ResNet18 模型,10个类别,3个颜色通道

# 定义权重初始化函数
def init_weights(m):
    # 如果是全连接层 (Linear) 或 卷积层 (Conv2d),则使用 Xavier 均匀分布初始化权重
    if type(m) in [nn.Linear, nn.Conv2d]:
        nn.init.xavier_uniform_(m.weight)

net.apply(init_weights) # 将初始化函数应用到网络的所有层

# 主训练函数
def train_with_data_aug(train_augs, test_augs, net, lr=0.001):
    # 使用我们定义的流水线加载数据
    train_iter = load_cifar10(True, train_augs, batch_size)
    test_iter = load_cifar10(False, test_augs, batch_size)
    
    # 定义损失函数:交叉熵损失(用于分类任务)
    loss = nn.CrossEntropyLoss(reduction="none")
    # 定义优化器:Adam(自适应学习率优化器)
    trainer = torch.optim.Adam(net.parameters(), lr=lr)
    
    # 调用训练引擎,训练 10 个 epoch
    train_ch13(net, train_iter, test_iter, loss, trainer, 10, devices)

# 这里使用了仅有随机左右翻转的 train_augs
train_with_data_aug(train_augs, test_augs, net)

输出:

复制代码
loss 0.222, train acc 0.923, test acc 0.817
1131.9 examples/sec on [device(type='cuda', index=0)]

一些练习题

  1. 不使用图像增广训练模型 :如果你把 train_augs 也设成只做 ToTensor(),你会发现训练准确率 会非常快地逼近 100%,但是测试准确率会停滞在一个较低的水平甚至下降。这证明了模型过拟合了(死记硬背了训练集),也反向证明了图像增广能有效减轻过拟合。
  2. 结合多种方法 :如果使用 Compose 将翻转、裁剪、颜色变化结合起来训练 CIFAR-10,测试准确性通常会进一步提升(但需要训练更多的 epoch,因为任务变难了,模型需要更多时间收敛)。
  3. 查阅文档torchvision.transforms 里还有很多好用的方法,比如:
    • RandomRotation: 随机旋转图片。
    • RandomErasing: 随机擦除图片上的一块矩形区域(模拟物体被遮挡)。
    • GaussianBlur: 高斯模糊(让模型不要过度依赖高频清晰的细节)。

二、微调

当目标数据集比较小、但任务和 ImageNet 这类大规模自然图像数据集有相似性时,不要从零开始训练整个模型,而应该把预训练模型学到的"通用视觉知识"迁移过来,再针对新任务做微调。

2.1 为什么需要微调

以前学过两种典型情形:

  • 小数据集:比如 Fashion-MNIST,只有 6 万张图;
  • 超大数据集:比如 ImageNet,千万级图像、1000 类别。

现实里很多任务都介于两者之间。比如:

  • 识别不同椅子款式;
  • 区分某种零食是否出现;
  • 某工业零件是否缺陷。

这类任务往往有两个特点:

  1. 数据不算特别少,但远不够大
  2. 如果从头训练大模型,很容易过拟合

所以就有了迁移学习(transfer learning)。

  1. 迁移学习与微调的关系
  • 迁移学习:总称,指把一个任务上学到的知识迁移到另一个任务。
  • 微调(fine-tuning):迁移学习里最常见的一种做法。

你可以把它理解成:

  • 在 ImageNet 上训练好的模型,已经学会了很多视觉基础能力:
    • 边缘
    • 纹理
    • 颜色组合
    • 局部形状
    • 物体部件组合
  • 这些能力对"热狗识别""椅子识别"仍然有价值。

也就是说,虽然 ImageNet 里的大多数图片不是热狗,也不是椅子,但它学到的底层和中层特征依然通用。

2.2 微调的四个步骤

1、在源数据集上预训练源模型

比如在 ImageNet 上训练一个 ResNet-18。

2、复制源模型结构与参数,但去掉原输出层(因为原输出层是专门为 ImageNet 的 1000 类服务的,它和"热狗 / 非热狗"这个新任务不匹配。)

3、添加新的输出层

例如新任务只有 2 类,就新建一个 2 分类层。

4、在目标数据集上训练

  • 新输出层:从头学习;
  • 其余层:在预训练参数基础上继续微调。

2.3 为什么微调有效

核心原因有两个:

(1)更好的初始化

如果从头训练,参数是随机的;

如果微调,参数来自 ImageNet 训练结果,已经处在一个很好的区域。

(2)更强的泛化能力

小数据集很容易把模型"带偏";

预训练参数相当于给模型加了一个强先验:

"别乱学,先沿着已有的通用视觉知识微调。"

所以微调通常能:

  • 收敛更快;
  • 精度更高;
  • 对小数据集更稳。

我们可以把 ResNet-18 拆成两部分:

2.4 输出层要用更大的学习率

原因

  • backbone 的参数来自预训练,已经比较好;
  • 新的 fc 层是随机初始化的,几乎什么都没学到。

如果所有层都用一样的小学习率:

  • backbone 更新得合理;
  • 但新 fc 层学得太慢。

所以我们后面实践部分的做法是:

  • backbone:学习率 = (η\etaη)
  • 新fc层:学习率 = (10η10\eta10η)

这叫做 差分学习率(discriminative learning rates)

2.5 输入要按 ImageNet 的均值和方差做标准化

这一点也非常重要。

预训练的 ResNet-18 是在 ImageNet 上训练的,而训练时输入通常都做过下面的标准化:

  • mean = [0.485, 0.456, 0.406]
  • std = [0.229, 0.224, 0.225]

如果你现在拿新数据直接喂进去,而不做同样分布的归一化,那么模型看到的数据分布和它预训练时看到的分布不一致,会影响效果。

所以这里不是"随便归一化",而是为了和预训练模型的输入分布保持一致

2.6 代码实现

导入库

python 复制代码
%matplotlib inline
from d2l import torch as d2l
from torch import nn
import torch
import torchvision
import os

下载并解压数据集

python 复制代码
d2l.DATA_HUB['hotdog'] = (d2l.DATA_URL + 'hotdog.zip', 
                         'fba480ffa8aa7e0febbb511d181409f899b9baa5')

data_dir = d2l.download_extract('hotdog')

读取训练集和测试集

python 复制代码
train_imgs = torchvision.datasets.ImageFolder(os.path.join(data_dir, 'train'))
test_imgs = torchvision.datasets.ImageFolder(os.path.join(data_dir, 'test'))

可视化部分样本

python 复制代码
hotdogs = [train_imgs[i][0] for i in range(8)]
not_hotdogs = [train_imgs[-i - 1][0] for i in range(8)]
d2l.show_images(hotdogs + not_hotdogs, 2, 8, scale=1.4);

定义数据预处理与数据增广

python 复制代码
normalize = torchvision.transforms.Normalize(
    [0.485, 0.456, 0.406], [0.229, 0.224, 0.225])

train_augs = torchvision.transforms.Compose([
    torchvision.transforms.RandomResizedCrop(224),
    torchvision.transforms.RandomHorizontalFlip(),
    torchvision.transforms.ToTensor(),
    normalize])

test_augs = torchvision.transforms.Compose([
    torchvision.transforms.Resize([256, 256]),
    torchvision.transforms.CenterCrop(224),
    torchvision.transforms.ToTensor(),
    normalize])
  • 对 RGB 三个通道分别做标准化,均值和标准差来自 ImageNet,这是为了和预训练模型的输入分布保持一致。

  • 训练集:

    • 把多个变换串起来,按顺序执行。

      • 随机裁剪,随机水平翻转,
    • 最后再做标准化。

  • 验证集无需做随机增强,因为测试阶段要的是稳定、可重复的评估结果。

加载预训练模型

python 复制代码
pretrained_net = torchvision.models.resnet18(pretrained=True)
  • 加载一个在 ImageNet 上预训练好的 ResNet-18;
  • pretrained=True 表示自动加载训练好的参数。

查看最后一层

python 复制代码
pretrained_net.fc

输出:

复制代码
Linear(in_features=512, out_features=1000, bias=True)

ResNet-18 最后分类层:

  • 输入特征维度:512
  • 输出类别数:1000(对应 ImageNet 1000 类)

构建微调模型

python 复制代码
finetune_net = torchvision.models.resnet18(pretrained=True)
finetune_net.fc = nn.Linear(finetune_net.fc.in_features, 2)
nn.init.xavier_uniform_(finetune_net.fc.weight);
  • 再加载一个预训练好的 ResNet-18,这个模型将作为真正参与微调的模型。
  • 把原来的 1000 分类层替换成 2 分类层,然后更改下维度
  • backbone 保留预训练参数,最后一层重新定义,适配热狗任务。
  • 用 Xavier 均匀分布初始化新 fc 层的权重;

定义训练函数

python 复制代码
def train_fine_tuning(net, learning_rate, batch_size=128, num_epochs=5,
                      param_group=True):
    train_iter = torch.utils.data.DataLoader(torchvision.datasets.ImageFolder(
        os.path.join(data_dir, 'train'), transform=train_augs),
        batch_size=batch_size, shuffle=True)
    test_iter = torch.utils.data.DataLoader(torchvision.datasets.ImageFolder(
        os.path.join(data_dir, 'test'), transform=test_augs),
        batch_size=batch_size)
    devices = d2l.try_all_gpus()
    loss = nn.CrossEntropyLoss(reduction="none")
    if param_group:
        params_1x = [param for name, param in net.named_parameters()
             if name not in ["fc.weight", "fc.bias"]]
        trainer = torch.optim.SGD([{'params': params_1x},
                                   {'params': net.fc.parameters(),
                                    'lr': learning_rate * 10}],
                                lr=learning_rate, weight_decay=0.001)
    else:
        trainer = torch.optim.SGD(net.parameters(), lr=learning_rate,
                                  weight_decay=0.001)    
    d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs,
                   devices)
  • param_group=True:是否使用参数分组(即输出层 10 倍学习率)。
    • 否则所有参数都用同一个学习率,这通常用于"从零训练"的模型。

微调训练

python 复制代码
train_fine_tuning(finetune_net, 5e-5)

输出:

复制代码
loss 0.549, train acc 0.858, test acc 0.919
44.5 examples/sec on [device(type='cuda', index=0)]

为什么学习率这么小?

因为 backbone 参数已经很不错了,不能大幅破坏。

从零开始训练作对比

python 复制代码
scratch_net = torchvision.models.resnet18()
scratch_net.fc = nn.Linear(scratch_net.fc.in_features, 2)
train_fine_tuning(scratch_net, 5e-4, param_group=False)
  • 新建一个 ResNet-18,没有加载预训练权重,参数是随机初始化的。
  • 学习率设成 5e-4,比微调大;

输出:

复制代码
loss 0.370, train acc 0.833, test acc 0.824
45.8 examples/sec on [device(type='cuda', index=0)]

2.7 为什么微调通常比从零训练好

这里你要从三个角度理解。

1. 优化角度

从头训练:

  • 起点差;
  • 需要自己摸索出有意义的视觉特征;
  • 在小数据集上很难。

微调:

  • 起点好;
  • 模型已经能看懂很多自然图像结构;
  • 只需要适配任务。

2. 泛化角度

从头训练容易记住训练集细节,泛化差。

微调相当于说:

  • "先别乱改,你已经知道什么是边缘、纹理、食物形状了;
  • 现在只是在这些知识上学会区分热狗和非热狗。"

这比从零学更稳。

3. 数据效率角度

微调最大的优点之一是:同样的数据量下,效果更好;同样的效果下,需要的数据更少。

一些课后题

题 1:继续提高 finetune_net 的学习率,模型的准确性如何变化?

通常会先变好一点,再变差。

也就是说,准确率对学习率的关系常常是:

  • 太小:学得慢,5 个 epoch 内没充分收敛;
  • 合适:效果最好;
  • 太大:破坏预训练参数,甚至训练不稳定。
  • 微调时,backbone 里的参数已经是"好参数"了。
    如果学习率太大,每一步更新都会太猛烈,导致:
    • 原本有用的通用视觉特征被迅速破坏;
    • 出现 catastrophic forgetting(灾难性遗忘)
    • 在小数据集上更容易过拟合或震荡。

题 2:进一步调整 finetune_netscratch_net 的超参数,它们的准确性还有不同吗?

通常仍然有差异,但差距可能缩小。

如果你给 scratch_net 更多训练资源,比如:

  • 更长训练轮数
  • 更好的学习率调度
  • 更强的数据增强
  • 更合适的 weight decay
  • 更仔细的超参数搜索

那么从零训练的模型是有机会追上来的,因为 ResNet-18 本身有足够的表达能力。

题 3:把 finetune_net 输出层之前的参数冻结,只训练输出层,准确性如何变化?

教材给的 PyTorch 提示是:

python 复制代码
for param in finetune_net.parameters():
    param.requires_grad = False

冻结特征提取层、只训练最后一层,这种做法叫:

  • 固定特征提取器
  • feature extractor 模式

它通常会出现这样的关系:
全量微调≥冻结 backbone 只训 fc≥从零训练 \text{全量微调} \ge \text{冻结 backbone 只训 fc} \ge \text{从零训练} 全量微调≥冻结 backbone 只训 fc≥从零训练

但不是绝对,在非常小的数据集上,冻结 backbone 有时反而更稳,因为它减少了过拟合风险。

题 4:ImageNet 中本来就有 hotdog 类,如何利用这一类对应的输出层权重?

python 复制代码
weight = pretrained_net.fc.weight
hotdog_w = torch.split(weight.data, 1, dim=0)[934]
hotdog_w.shape

把它作为新二分类头中"hotdog 类"的初始化权重

2.8 微调的本质

  • 复用预训练模型学到的通用表示;
  • 丢掉和源标签绑定过深的输出层;
  • 给新任务建新输出层;
  • 用较小学习率调整 backbone,用较大学习率训练新头。

三、目标检测和边缘框

我们不仅关注目标是什么,还关注目标在哪。

3.1 什么是目标检测?

目标检测:

输入一张图片,输出多个目标,每个目标包含:(类别, 位置)

因为要描述物体在图中的位置,就得有一种统一的方式来"框住"它。

最常见的方法就是画一个矩形,把目标尽量完整地包起来,这个矩形就叫:

  • 边界框
  • bounding box
  • 简写 bbox

例如:

  • 狗在左边,就用一个矩形框住狗;
  • 猫在右边,就用另一个矩形框住猫。

3.2 边界框的两种表示方式

方式一:左上角 + 右下角

记为:
(x1,y1,x2,y2) (x_1, y_1, x_2, y_2) (x1,y1,x2,y2)

  • ((x_1, y_1)):左上角坐标
  • ((x_2, y_2)):右下角坐标

这也是最直观的表示方式。

方式二:中心点 + 宽度 + 高度

记为:
(cx,cy,w,h) (c_x, c_y, w, h) (cx,cy,w,h)

  • ((c_x, c_y)):边界框中心点
  • (w):宽度
  • (h):高度

因为不同任务里,适合的表示不一样。

左上右下格式适合:

  • 直接画框;
  • 判断两个框是否重叠;
  • 和图像坐标一一对应。

中心宽高格式适合:

  • 神经网络预测;
  • 锚框(anchor box)相关计算;
  • 对宽高和中心偏移进行回归。

图像坐标系是什么样的?

  • 原点在左上角
  • 向右是 (x) 轴正方向
  • 向下是 (y) 轴正方向

3.3 代码实现

python 复制代码
%matplotlib inline
from d2l import torch as d2l
import torch
python 复制代码
d2l.set_figsize()
img = d2l.plt.imread('../img/catdog.jpg')
d2l.plt.imshow(img);
python 复制代码
def box_corner_to_center(boxes):
    """从(左上,右下)转换到(中间,宽度,高度)"""
    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 = d2l.stack((cx, cy, w, h), axis=-1)
    return boxes
python 复制代码
def box_center_to_corner(boxes):
    """从(中间,宽度,高度)转换到(左上,右下)"""
    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 = d2l.stack((x1, y1, x2, y2), axis=-1)
    return boxes

验证下转换函数对不对:

python 复制代码
dog_bbox, cat_bbox = [60.0, 45.0, 378.0, 516.0], [400.0, 112.0, 655.0, 493.0]
boxes = d2l.tensor((dog_bbox, cat_bbox))
box_center_to_corner(box_corner_to_center(boxes)) == boxes

输出:

复制代码
tensor([[True, True, True, True],
        [True, True, True, True]])

把边界框画成矩形

python 复制代码
def bbox_to_rect(bbox, color):
    return d2l.plt.Rectangle(
        xy=(bbox[0], bbox[1]), width=bbox[2]-bbox[0], height=bbox[3]-bbox[1],
        fill=False, edgecolor=color, linewidth=2)
python 复制代码
fig = d2l.plt.imshow(img)
fig.axes.add_patch(bbox_to_rect(dog_bbox, 'blue'))
fig.axes.add_patch(bbox_to_rect(cat_bbox, 'red'));

一些练习题

3.4 目标检测数据集

和图像分类相比,目标检测数据集最大的区别是:

  • 图像分类标签只有一个类别,比如 catdog
  • 目标检测标签除了类别,还要有边界框坐标

也就是说,一张图的标签不再只是一个数字,而是类似:

text 复制代码
[类别, x1, y1, x2, y2]
  • 类别:目标属于哪一类
  • x1, y1:边界框左上角
  • x2, y2:边界框右下角

这节用的是一个很小的人工数据集:香蕉检测数据集。 每张图里只有一根香蕉,所以它特别适合入门。

代码实现:

python 复制代码
%matplotlib inline
from d2l import torch as d2l
import torch
import torchvision
import os
import pandas as pd

注册并下载数据集

python 复制代码
d2l.DATA_HUB['banana-detection'] = (
    d2l.DATA_URL + 'banana-detection.zip',
    '5de26c8fce5ccdea9f91267273464dc968d20d72')

读取香蕉数据集

python 复制代码
def read_data_bananas(is_train=True):
    """读取香蕉检测数据集中的图像和标签"""
    data_dir = d2l.download_extract('banana-detection')
    csv_fname = os.path.join(data_dir, 'bananas_train' if is_train
                             else 'bananas_val', 'label.csv')
    csv_data = pd.read_csv(csv_fname)
    csv_data = csv_data.set_index('img_name')
    images, targets = [], []
    for img_name, target in csv_data.iterrows():
        images.append(torchvision.io.read_image(
            os.path.join(data_dir, 'bananas_train' if is_train else
                         'bananas_val', 'images', f'{img_name}')))
        # 这里的target包含(类别,左上角x,左上角y,右下角x,右下角y),
        # 其中所有图像都具有相同的香蕉类(索引为0)
        targets.append(list(target))
    return images, torch.tensor(targets).unsqueeze(1) / 256

返回的这个处理:

python 复制代码
return images, torch.tensor(targets).unsqueeze(1) / 256

unsqueeze(1)

在第 1 维插入一个长度为 1 的维度。

于是形状从:

python 复制代码
[n, 5]

变成:

python 复制代码
[n, 1, 5]

为什么要这么做?

因为目标检测里,一张图可能有多个边界框。

统一格式通常设计成:

python 复制代码
[样本数, 每张图的边界框数, 每个边界框的5个值]

也就是:

python 复制代码
[n, m, 5]

其中:

  • n:样本数
  • m:每张图中最大边界框数量
  • 5[class, x1, y1, x2, y2]

而这个香蕉数据集每张图只有 1 个 边界框,所以这里:

python 复制代码
m = 1

于是标签形状就是:

python 复制代码
[n, 1, 5]

这是一种非常典型的检测任务标签设计。

然耨把标签里的边界框坐标除以 256,进行归一化。

因为这批图片大小是 256 × 256,所以除以 256 后,坐标范围大致落在:

python 复制代码
0 ~ 1

这样做的好处是:

  • 不依赖具体像素尺寸
  • 数值尺度更稳定
  • 后面模型训练更方便

Dataset

python 复制代码
class BananasDataset(torch.utils.data.Dataset):
    """一个用于加载香蕉检测数据集的自定义数据集"""
    def __init__(self, is_train):
        self.features, self.labels = read_data_bananas(is_train)
        print('read ' + str(len(self.features)) + (f' training examples' if
              is_train else f' validation examples'))

    def __getitem__(self, idx):
        return (self.features[idx].float(), self.labels[idx])

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

构造 DataLoader

python 复制代码
def load_data_bananas(batch_size):
    """加载香蕉检测数据集"""
    train_iter = torch.utils.data.DataLoader(BananasDataset(is_train=True),
                                             batch_size, shuffle=True)
    val_iter = torch.utils.data.DataLoader(BananasDataset(is_train=False),
                                           batch_size)
    return train_iter, val_iter

读取一个 batch 并看形状

python 复制代码
batch_size, edge_size = 32, 256
train_iter, _ = load_data_bananas(batch_size)
batch = next(iter(train_iter))
batch[0].shape, batch[1].shape

输出:

复制代码
read 1000 training examples
read 100 validation examples
(torch.Size([32, 3, 256, 256]), torch.Size([32, 1, 5]))

演示代码

python 复制代码
imgs = (batch[0][0:10].permute(0, 2, 3, 1)) / 255
axes = d2l.show_images(imgs, 2, 5, scale=2)
for ax, label in zip(axes, batch[1][0:10]):
    d2l.show_bboxes(ax, [label[0][1:5] * edge_size], colors=['w'])

四、锚框

4.1 什么是锚框

目标检测不像图像分类只输出一个类别,它要同时回答两件事:

  1. 图里有什么
  2. 它在哪

模型一开始并不知道物体在哪,所以通常不会"凭空"直接输出框,而是先准备很多个候选框,再判断哪些候选框里有目标,并把候选框修正成更准确的位置。

这些候选框里最经典的一类就是:锚框(anchor boxes)

  • 先在图像上每个位置放一些"模板框"
  • 这些模板框有不同大小、不同长宽比
  • 模型只需要判断:
    • 这个模板框里有没有物体
    • 如果有,要往哪偏一点、缩放多少,才能更贴合真实物体

所以锚框的核心思想是:把"直接找框"变成"从已有模板框出发做分类 + 回归修正"。

为什么锚框要"每个像素/位置生成多个"?

因为真实物体:

  • 大小不同
  • 形状不同
  • 出现位置不同

如果一个位置只放一个固定大小的框,那它只能覆盖很有限的一类目标。

所以在同一个位置,我们要放多个框,比如:

  • 大框 / 中框 / 小框
  • 扁一点的框 / 高一点的框 / 正方形框

这样无论目标偏大、偏小、偏胖、偏瘦,都更容易有某个锚框和它比较接近。

4.2 锚框的两个超参数:scale 和 ratio

这节里一个锚框由两个量决定:

  • 缩放比 s(scale)
  • 宽高比 r(aspect ratio)

宽高比 r定义为:
r=widthheight r=\frac{\text{width}}{\text{height}} r=heightwidth

缩放比 s

size 表示这个框整体有多大。

教材里把框的尺寸和图像尺寸关联起来,所以 s 一般取 (0,1] 之间的数。

例如:

  • s = 0.75:比较大的框
  • s = 0.25:比较小的框

4.3 IoU

IoU(Intersection over Union),中文通常叫:

  • 交并比
  • 杰卡德系数在框上的应用

loU 用来计算两个框之间的相似度

定义:
IoU=交集面积并集面积 IoU = \frac{\text{交集面积}}{\text{并集面积}} IoU=并集面积交集面积

取值范围:

  • 0:完全不重叠
  • 1:完全重合

IoU 越大,说明锚框越像真实框。

4.4 给每个锚框打标签

每个锚框是一个训练样本。

训练时模型要学两件事:

  1. 这个锚框是背景,还是某个物体
  2. 如果它对应某个物体,应该怎么挪、怎么缩放,才能更接近真实框

因此每个锚框都要有两类标签:

  • 类别标签
  • 偏移量标签

4.5 锚框与真实框怎么匹配?

  • 每个锚框都找和自己 IoU 最大的真实框
  • 但不能只这么做,因为可能有某个真实框根本没被分到任何锚框
  • 所以还要保证:每个真实框至少分配给一个锚框

我们先重复做如下流程:

  • 对每个锚框,找 IoU 最大的真实框
  • 如果这个最大 IoU 超过阈值(默认 0.5),就把该真实框分给它

再保证每个真实框至少有一个锚框:

  • 贪心地从整个 IoU 矩阵里反复找当前全局最大 IoU
  • 把这对 anchor / gt 强行匹配
  • 然后丢掉该 anchor 对应的行、该 gt 对应的列
  • 继续找下一个

这样就保证:每个真实框至少会有一个 anchor 负责它,否则某个目标可能因为整体 IoU 都偏低,训练时完全没有正样本去学它。

4.6 使用非极大值抑制(NMS)输出

锚框非常多,同一个目标附近通常会有许多相似锚框都预测成正类。

如果不处理,你会得到一堆重叠很高的框,比如同一只狗被框 20 次。

所以要做:非极大值抑制(NMS)

思想很简单:

  1. 先按置信度从高到低排序
  2. 取最高分框作为保留框
  3. 把与它 IoU 太大的其他框删掉(和它IoU越大,越说明预测的是同一物体)
  4. 继续处理剩余框

4.7 总体流程

  • 一类目标检测算法基于锚框来预测
  • 首先生成大量锚框,并赋予标号,每个锚框作为一个样本进行训练
  • 在预测时,使用NMS来去掉冗余的预测

4.8 代码练习

环境准备 + 基础辅助函数

python 复制代码
%matplotlib inline

# d2l 里封装了画图、显示图片等常用教学工具
from d2l import torch as d2l

# PyTorch 主库
import torch

# 让张量打印更简洁:只保留 2 位小数
torch.set_printoptions(precision=2, sci_mode=False)


# =========================
# 边界框格式转换辅助函数
# =========================

def box_corner_to_center(boxes):
    """
    将边界框从 (xmin, ymin, xmax, ymax)
    转换成 (cx, cy, w, h)

    参数:
        boxes: shape = (n, 4)

    返回:
        shape = (n, 4)
    """
    # 左上角与右下角坐标
    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

    # 按最后一维拼回去
    return torch.stack((cx, cy, w, h), dim=-1)


def box_center_to_corner(boxes):
    """
    将边界框从 (cx, cy, w, h)
    转换成 (xmin, ymin, xmax, ymax)

    参数:
        boxes: shape = (n, 4)

    返回:
        shape = (n, 4)
    """
    # 中心点和宽高
    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

    return torch.stack((x1, y1, x2, y2), dim=-1)


def bbox_to_rect(bbox, color):
    """
    把 (xmin, ymin, xmax, ymax) 格式的边界框
    转换成 matplotlib 可以直接画的 Rectangle 对象

    参数:
        bbox: 长度为 4 的数组或张量
        color: 边框颜色

    返回:
        matplotlib.patches.Rectangle
    """
    return d2l.plt.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):
    """
    在图像坐标轴上画多个边界框

    参数:
        axes: matplotlib 的坐标轴对象
        bboxes: 一个边界框列表 / 张量,元素格式为 (xmin, ymin, xmax, ymax)
        labels: 每个框对应的标签(可选)
        colors: 每个框对应的颜色(可选)
    """

    # 内部辅助函数:
    # 如果输入不是 list/tuple,就包成 list,便于统一处理
    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 / colors 处理成列表
    labels = _make_list(labels)
    colors = _make_list(colors, ['b', 'g', 'r', 'm', 'c'])

    # 逐个边界框画出来
    for i, bbox in enumerate(bboxes):
        color = colors[i % len(colors)]

        # 注意 matplotlib 更喜欢 numpy,所以这里转一下
        rect = bbox_to_rect(d2l.numpy(bbox), color)

        # 把矩形 patch 加到坐标轴上
        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)
            )

生成锚框:multibox_prior

python 复制代码
def multibox_prior(data, sizes, ratios):
    """
    生成以每个像素为中心、具有不同尺度和宽高比的锚框

    参数:
        data: 输入图像或特征图,形状通常为 (batch, channel, height, width)
              这里我们只关心它的 height 和 width
        sizes: 缩放比列表,例如 [0.75, 0.5, 0.25]
        ratios: 宽高比列表,例如 [1, 2, 0.5]

    返回:
        output: shape = (1, num_anchors, 4)
                其中最后一维是 (xmin, ymin, xmax, ymax)
                坐标是归一化后的坐标(范围通常在 0~1 左右)
    """

    # 取输入的高和宽
    in_height, in_width = data.shape[-2:]

    # 设备:CPU 或 GPU
    device = data.device

    # sizes 和 ratios 的数量
    num_sizes = len(sizes)
    num_ratios = len(ratios)

    # 每个像素位置生成多少个锚框
    # 不是 num_sizes * num_ratios,而是 num_sizes + num_ratios - 1
    # 因为教材只保留:
    # (s1, r1), (s2, r1), ..., (sn, r1), (s1, r2), ..., (s1, rm)
    boxes_per_pixel = num_sizes + num_ratios - 1

    # 把 Python 列表转成张量,放到对应 device
    size_tensor = torch.tensor(sizes, device=device)
    ratio_tensor = torch.tensor(ratios, device=device)

    # -------------------------
    # 1. 生成每个像素位置的中心点
    # -------------------------

    # 一个像素的中心,不在整数格点上,而在 "格子中间"
    # 所以偏移量取 0.5
    offset_h, offset_w = 0.5, 0.5

    # 在归一化坐标系下:
    # y 轴每移动一个像素,相当于移动 1 / in_height
    # x 轴每移动一个像素,相当于移动 1 / in_width
    steps_h = 1.0 / in_height
    steps_w = 1.0 / in_width

    # center_h: 所有像素中心的 y 坐标,形状 (in_height,)
    # 第 i 行像素中心的归一化坐标 = (i + 0.5) / in_height
    center_h = (torch.arange(in_height, device=device) + offset_h) * steps_h

    # center_w: 所有像素中心的 x 坐标,形状 (in_width,)
    center_w = (torch.arange(in_width, device=device) + offset_w) * steps_w

    # meshgrid 生成二维网格:
    # shift_y.shape = (in_height, in_width)
    # shift_x.shape = (in_height, in_width)
    # 每个位置对应图像中的一个中心点
    shift_y, shift_x = torch.meshgrid(center_h, center_w, indexing='ij')

    # 拉平成一维,方便后面批量操作
    # 现在每个位置都对应一个中心点,共 in_height * in_width 个
    shift_y = shift_y.reshape(-1)
    shift_x = shift_x.reshape(-1)

    # -------------------------
    # 2. 为每个中心点生成不同形状的锚框
    # -------------------------

    # 宽度 w 和高度 h 的构造逻辑:
    #
    # 对于基准 ratio_tensor[0](通常是 1):
    #   使用所有 sizes -> (s1,r1), (s2,r1), ..., (sn,r1)
    #
    # 对于基准 size sizes[0]:
    #   使用其余 ratios -> (s1,r2), (s1,r3), ..., (s1,rm)
    #
    # 因此总共 boxes_per_pixel = num_sizes + num_ratios - 1 个框

    # 先构造宽度
    # 注意:因为坐标是归一化坐标,x 和 y 的单位长度可能不同
    # 所以这里乘 in_height / in_width,用于适配非正方形输入
    w = torch.cat((
        size_tensor * torch.sqrt(ratio_tensor[0]),   # (s1,r1),(s2,r1),...
        sizes[0] * torch.sqrt(ratio_tensor[1:])      # (s1,r2),(s1,r3),...
    )) * in_height / in_width

    # 再构造高度
    h = torch.cat((
        size_tensor / torch.sqrt(ratio_tensor[0]),   # (s1,r1),(s2,r1),...
        sizes[0] / torch.sqrt(ratio_tensor[1:])      # (s1,r2),(s1,r3),...
    ))

    # 现在 w, h 的形状都是 (boxes_per_pixel,)

    # 对于每一个锚框,我们最终要的是:
    # (xmin, ymin, xmax, ymax)
    #
    # 如果中心在 (cx, cy),宽高为 (w, h),那么:
    # xmin = cx - w/2
    # ymin = cy - h/2
    # xmax = cx + w/2
    # ymax = cy + h/2
    #
    # 所以这里先构造相对中心点的偏移量:
    # (-w/2, -h/2, w/2, h/2)
    anchor_manipulations = torch.stack(
        (-w, -h, w, h), dim=1
    ).repeat(in_height * in_width, 1) / 2

    # anchor_manipulations 的形状:
    # (in_height * in_width * boxes_per_pixel, 4)

    # -------------------------
    # 3. 把所有中心点复制成对应数量的锚框中心
    # -------------------------

    # 每个中心点需要重复 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)

    # out_grid 的形状也为:
    # (in_height * in_width * boxes_per_pixel, 4)

    # 将中心点坐标与相对偏移相加,得到最终锚框坐标
    output = out_grid + anchor_manipulations

    # 在最前面加一个 batch 维度,保持教材接口一致
    # 输出形状:(1, num_anchors, 4)
    return output.unsqueeze(0)

生成锚框示例

python 复制代码
# 读取示例图片
img = d2l.plt.imread('../img/catdog.jpg')

# 图像高和宽
h, w = img.shape[:2]
print(h, w)

# 构造一个假的输入张量:
# shape = (batch_size=1, channel=3, height=h, width=w)
# 这里像素值是什么并不重要,我们只需要它的高和宽
X = torch.rand(size=(1, 3, h, w))

# 为每个像素生成锚框
# sizes 有 3 个,ratios 有 3 个
# 所以每个像素生成 3 + 3 - 1 = 5 个锚框
Y = multibox_prior(X, sizes=[0.75, 0.5, 0.25], ratios=[1, 2, 0.5])

# 输出形状:
# (1, h * w * 5, 4)
print(Y.shape)

查看某个像素中心对应的锚框

python 复制代码
# 因为每个像素位置有 5 个锚框,所以把 Y reshape 成更直观的形状
# 这里直接把 batch 维省略掉
# boxes.shape = (h, w, 5, 4)
boxes = Y.reshape(h, w, 5, 4)

# 查看以 (250, 250) 这个像素位置为中心的第 0 个锚框
# 返回的是归一化坐标 (xmin, ymin, xmax, ymax)
print(boxes[250, 250, 0, :])

把这些锚框画出来

python 复制代码
# 画图尺寸
d2l.set_figsize()

# 由于 boxes 中的坐标是归一化坐标:
# x 轴坐标要乘 w,y 轴坐标要乘 h
bbox_scale = torch.tensor((w, h, w, h))

# 显示图片
fig = d2l.plt.imshow(img)

# 画出以 (250, 250) 为中心的 5 个锚框
show_bboxes(
    fig.axes,
    boxes[250, 250, :, :] * bbox_scale,
    labels=[
        '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'
    ]
)

计算 IoU:box_iou

python 复制代码
def box_iou(boxes1, boxes2):
    """
    计算两组边界框之间两两配对的 IoU(交并比)

    参数:
        boxes1: shape = (n1, 4)
        boxes2: shape = (n2, 4)

    返回:
        iou: shape = (n1, n2)
        其中 iou[i, j] = boxes1[i] 和 boxes2[j] 的 IoU
    """

    # 定义一个小函数:计算一组边界框各自的面积
    def box_area(boxes):
        return (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1])

    # 两组框的面积
    # areas1.shape = (n1,)
    # areas2.shape = (n2,)
    areas1 = box_area(boxes1)
    areas2 = box_area(boxes2)

    # -------------------------
    # 计算交集矩形的左上角和右下角
    # -------------------------

    # 对于两框交集矩形的左上角:
    # x 要取两个 xmin 的较大值
    # y 要取两个 ymin 的较大值
    #
    # boxes1[:, None, :2] 的形状是 (n1, 1, 2)
    # boxes2[:, :2]       的形状是 (n2, 2)
    # 利用广播后,结果是 (n1, n2, 2)
    inter_upperlefts = torch.max(boxes1[:, None, :2], boxes2[:, :2])

    # 对于交集矩形的右下角:
    # x 要取两个 xmax 的较小值
    # y 要取两个 ymax 的较小值
    inter_lowerrights = torch.min(boxes1[:, None, 2:], boxes2[:, 2:])

    # 交集矩形的宽高 = 右下角 - 左上角
    # 如果两个框不相交,宽/高会变成负数,所以 clamp 到 0
    inters = (inter_lowerrights - inter_upperlefts).clamp(min=0)

    # inters.shape = (n1, n2, 2)
    # inters[..., 0] 是交集宽
    # inters[..., 1] 是交集高

    # 交集面积
    inter_areas = inters[:, :, 0] * inters[:, :, 1]

    # 并集面积 = area1 + area2 - intersection
    union_areas = areas1[:, None] + areas2 - inter_areas

    # IoU = 交集面积 / 并集面积
    return inter_areas / union_areas

将真实边界框分配给锚框:assign_anchor_to_bbox

python 复制代码
def assign_anchor_to_bbox(ground_truth, anchors, device, iou_threshold=0.5):
    """
    将真实边界框分配给锚框

    参数:
        ground_truth: shape = (num_gt_boxes, 4)
                      真实边界框坐标,不包含类别列
        anchors: shape = (num_anchors, 4)
                 所有锚框
        device: CPU / GPU
        iou_threshold: IoU 阈值

    返回:
        anchors_bbox_map: shape = (num_anchors,)
                          第 i 个元素表示第 i 个锚框分配给了哪个真实框
                          -1 表示该锚框未分配到任何真实框(背景)
    """

    # 锚框数、真实框数
    num_anchors = anchors.shape[0]
    num_gt_boxes = ground_truth.shape[0]

    # jaccard[i, j] = 第 i 个锚框与第 j 个真实框的 IoU
    jaccard = box_iou(anchors, ground_truth)

    # anchors_bbox_map[i] = j
    # 表示第 i 个锚框被分配给了第 j 个真实框
    # 初始值全设为 -1,表示还没分配
    anchors_bbox_map = torch.full(
        (num_anchors,), -1, dtype=torch.long, device=device
    )

    # -------------------------------------
    # 第一步:先按阈值给锚框分配"最像"的真实框
    # -------------------------------------

    # 对每个锚框,找它和哪个真实框的 IoU 最大
    # max_ious.shape = (num_anchors,)
    # indices.shape  = (num_anchors,)
    max_ious, indices = torch.max(jaccard, dim=1)

    # 找出 IoU 达到阈值的锚框索引
    anc_i = torch.nonzero(max_ious >= iou_threshold, as_tuple=False).reshape(-1)

    # 这些锚框对应的最佳真实框索引
    box_j = indices[max_ious >= iou_threshold]

    # 将这些锚框先分配出去
    anchors_bbox_map[anc_i] = box_j

    # -------------------------------------
    # 第二步:确保每个真实框至少分到一个锚框
    # -------------------------------------
    #
    # 这是一个贪心过程:
    # 每次在整个 IoU 矩阵里找当前最大值,把对应的 anchor 和 gt 绑定
    # 然后把该 anchor 所在行、该 gt 所在列作废
    # 重复 num_gt_boxes 次

    # 用 -1 作为"作废"标记,因为 IoU 原本范围在 [0, 1]
    col_discard = torch.full((num_anchors,), -1.0, device=device)
    row_discard = torch.full((num_gt_boxes,), -1.0, device=device)

    for _ in range(num_gt_boxes):
        # 找当前 IoU 矩阵里最大的元素的位置(展平成一维后的索引)
        max_idx = torch.argmax(jaccard)

        # 将一维索引还原成二维下标
        # 列号 = max_idx % num_gt_boxes
        box_idx = (max_idx % num_gt_boxes).long()

        # 行号 = max_idx // num_gt_boxes
        anc_idx = torch.div(max_idx, num_gt_boxes, rounding_mode='floor')

        # 把这个锚框强行分配给这个真实框
        anchors_bbox_map[anc_idx] = box_idx

        # 将这个真实框所在列作废:表示它已经确保有锚框匹配了
        jaccard[:, box_idx] = col_discard

        # 将这个锚框所在行作废:表示这个锚框已经被用掉了
        jaccard[anc_idx, :] = row_discard

    return anchors_bbox_map

计算偏移量标签:offset_boxes

python 复制代码
def offset_boxes(anchors, assigned_bb, eps=1e-6):
    """
    计算真实边界框相对于锚框的偏移量标签

    参数:
        anchors: shape = (num_anchors, 4)
        assigned_bb: shape = (num_anchors, 4)
                     每个锚框分配到的真实框坐标
                     如果某个锚框是背景,对应位置通常是全 0

    返回:
        offset: shape = (num_anchors, 4)
                每行是 (dx, dy, dw, dh)
    """

    # 把锚框和真实框都转换成 (cx, cy, w, h)
    c_anc = box_corner_to_center(anchors)
    c_assigned_bb = box_corner_to_center(assigned_bb)

    # 中心点偏移:
    # (x_gt - x_anchor) / w_anchor
    # (y_gt - y_anchor) / h_anchor
    # 再乘 10,相当于除以 sigma_x = sigma_y = 0.1
    offset_xy = 10 * (c_assigned_bb[:, :2] - c_anc[:, :2]) / c_anc[:, 2:]

    # 宽高偏移:
    # log(w_gt / w_anchor), log(h_gt / h_anchor)
    # 再乘 5,相当于除以 sigma_w = sigma_h = 0.2
    offset_wh = 5 * torch.log(eps + c_assigned_bb[:, 2:] / c_anc[:, 2:])

    # 拼回 4 维偏移量
    offset = torch.cat([offset_xy, offset_wh], dim=1)

    return offset

生成训练标签:multibox_target

python 复制代码
def multibox_target(anchors, labels):
    """
    根据真实边界框给锚框打标签(类别 + 偏移量)

    参数:
        anchors: shape = (1, num_anchors, 4)
        labels:  shape = (batch_size, num_gt_boxes, 5)
                 每个真实框格式为:
                 [class, xmin, ymin, xmax, ymax]

    返回:
        bbox_offset:  shape = (batch_size, num_anchors * 4)
                      每个锚框的偏移量标签(拉平成一维)
        bbox_mask:    shape = (batch_size, num_anchors * 4)
                      回归损失掩码:正样本为 1,背景为 0
        class_labels: shape = (batch_size, num_anchors)
                      每个锚框的类别标签
                      其中 0 表示背景,前景类别从 1 开始编号
    """

    # batch 大小
    batch_size = labels.shape[0]

    # anchors 原本形状是 (1, num_anchors, 4),这里把最前面的 batch 维去掉
    anchors = anchors.squeeze(0)

    # 用于收集每张图的输出
    batch_offset = []
    batch_mask = []
    batch_class_labels = []

    device = anchors.device
    num_anchors = anchors.shape[0]

    # 逐张图片处理
    for i in range(batch_size):
        # 当前图片的真实框标签
        # shape = (num_gt_boxes, 5)
        label = labels[i, :, :]

        # 先给每个锚框分配一个真实框(或者 -1 表示背景)
        # 注意这里只传入真实框坐标,不传类别列
        anchors_bbox_map = assign_anchor_to_bbox(
            label[:, 1:], anchors, device
        )

        # bbox_mask:哪些锚框是正样本
        # anchors_bbox_map >= 0 表示这个锚框分到了真实框
        #
        # 先得到 shape = (num_anchors,)
        # 再变成 (num_anchors, 1)
        # 再 repeat 成 (num_anchors, 4)
        # 因为每个锚框有 4 个 offset 分量
        bbox_mask = ((anchors_bbox_map >= 0).float().unsqueeze(-1)).repeat(1, 4)

        # 类别标签初始化为 0 ------ 0 表示背景
        class_labels = torch.zeros(
            num_anchors, dtype=torch.long, device=device
        )

        # 分配的真实框坐标初始化为全 0
        assigned_bb = torch.zeros(
            (num_anchors, 4), dtype=torch.float32, device=device
        )

        # 找出所有正样本锚框的索引
        indices_true = torch.nonzero(
            anchors_bbox_map >= 0, as_tuple=False
        ).reshape(-1)

        # 这些正样本锚框分别对应哪个真实框
        bb_idx = anchors_bbox_map[indices_true]

        # 给正样本锚框赋类别标签
        # 注意:真实类别通常从 0 开始,但这里 0 要留给背景
        # 所以前景类别统一 +1
        class_labels[indices_true] = label[bb_idx, 0].long() + 1

        # 给正样本锚框填入对应的真实框坐标
        assigned_bb[indices_true] = label[bb_idx, 1:]

        # 计算偏移量标签
        # 背景锚框虽然也会算出 offset,但乘 mask 后会变成 0
        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)

    # 堆叠成 batch 形式
    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

用狗猫真实框 + 5 个锚框做标签示例

python 复制代码
# ground_truth 的每一行格式:
# [类别, xmin, ymin, xmax, ymax]
# 这里 0 表示 dog,1 表示 cat
ground_truth = torch.tensor([
    [0, 0.10, 0.08, 0.52, 0.92],   # 狗
    [1, 0.55, 0.20, 0.90, 0.88]    # 猫
])

# 手工构造 5 个锚框
anchors = torch.tensor([
    [0.00, 0.10, 0.20, 0.30],
    [0.15, 0.20, 0.40, 0.40],
    [0.63, 0.05, 0.88, 0.98],
    [0.66, 0.45, 0.80, 0.80],
    [0.57, 0.30, 0.92, 0.90]
])

# 先把真实框和锚框画出来看看
fig = d2l.plt.imshow(img)

# 真值框用黑色画
show_bboxes(fig.axes, ground_truth[:, 1:] * bbox_scale, ['dog', 'cat'], 'k')

# 锚框编号 0~4
show_bboxes(fig.axes, anchors * bbox_scale, ['0', '1', '2', '3', '4'])
python 复制代码
# multibox_target 的输入要有 batch 维
labels = multibox_target(
    anchors.unsqueeze(0),       # (1, 5, 4)
    ground_truth.unsqueeze(0)   # (1, 2, 5)
)

# labels 是一个三元组:
# labels[0] -> bbox_offset
# labels[1] -> bbox_mask
# labels[2] -> class_labels

print('class_labels =')
print(labels[2])

print('bbox_mask =')
print(labels[1])

print('bbox_offset =')
print(labels[0])

预测时:根据 offset 反推出边界框 offset_inverse

python 复制代码
def offset_inverse(anchors, offset_preds):
    """
    根据锚框和模型预测的偏移量,恢复出预测边界框

    参数:
        anchors: shape = (num_anchors, 4)
        offset_preds: shape = (num_anchors, 4)

    返回:
        predicted_bbox: shape = (num_anchors, 4)
                        格式为 (xmin, ymin, xmax, ymax)
    """

    # 先把锚框转成中心坐标 + 宽高形式
    anc = box_corner_to_center(anchors)

    # 还原中心点:
    # pred_cx = dx * w_anchor / 10 + cx_anchor
    # pred_cy = dy * h_anchor / 10 + cy_anchor
    pred_bbox_xy = (offset_preds[:, :2] * anc[:, 2:] / 10) + anc[:, :2]

    # 还原宽高:
    # pred_w = exp(dw / 5) * w_anchor
    # pred_h = exp(dh / 5) * h_anchor
    pred_bbox_wh = torch.exp(offset_preds[:, 2:] / 5) * anc[:, 2:]

    # 拼成 (cx, cy, w, h)
    pred_bbox = torch.cat((pred_bbox_xy, pred_bbox_wh), dim=1)

    # 再转回 (xmin, ymin, xmax, ymax)
    predicted_bbox = box_center_to_corner(pred_bbox)

    return predicted_bbox

非极大值抑制:nms

python 复制代码
def nms(boxes, scores, iou_threshold):
    """
    非极大值抑制(NMS)

    参数:
        boxes: shape = (num_boxes, 4)
        scores: shape = (num_boxes,)
        iou_threshold: IoU 阈值

    返回:
        keep: 保留下来的边界框索引
    """

    # 按置信度从高到低排序,返回索引
    B = torch.argsort(scores, dim=-1, descending=True)

    # 保存最终保留的索引
    keep = []

    # 只要还有候选框,就继续
    while B.numel() > 0:
        # 当前置信度最高的框,一定保留
        i = B[0].item()
        keep.append(i)

        # 如果只剩这一个框,直接结束
        if B.numel() == 1:
            break

        # 计算当前最高分框与剩余框之间的 IoU
        iou = box_iou(
            boxes[i, :].reshape(-1, 4),      # 当前基准框,shape=(1,4)
            boxes[B[1:], :].reshape(-1, 4)   # 剩余所有框
        ).reshape(-1)

        # 只保留那些 IoU 不超过阈值的框
        inds = torch.nonzero(iou <= iou_threshold, as_tuple=False).reshape(-1)

        # B[0] 已经被保留了,所以从 B[1:] 里继续筛
        B = B[inds + 1]

    return torch.tensor(keep, device=boxes.device)

完整预测后处理:multibox_detection

python 复制代码
def multibox_detection(cls_probs, offset_preds, anchors,
                       nms_threshold=0.5,
                       pos_threshold=0.009999999):
    """
    根据类别预测 + 偏移量预测 + 锚框,得到最终检测结果

    参数:
        cls_probs: shape = (batch_size, num_classes, num_anchors)
                   每个锚框对每个类别的预测概率
                   注意:第 0 类通常是背景类
        offset_preds: shape = (batch_size, num_anchors * 4)
                      每个锚框预测的偏移量
        anchors: shape = (1, num_anchors, 4)
        nms_threshold: NMS 的 IoU 阈值
        pos_threshold: 置信度阈值,太低的前景预测会被当作背景

    返回:
        out: shape = (batch_size, num_anchors, 6)
             每个预测框的输出格式为:
             [class_id, confidence, xmin, ymin, xmax, ymax]

             其中:
             - class_id = -1 表示背景或在 NMS 中被抑制
             - 其余为前景类别编号(从 0 开始)
    """

    device = cls_probs.device
    batch_size = cls_probs.shape[0]

    # anchors 的 batch 维只有 1,去掉即可
    anchors = anchors.squeeze(0)

    # 类别数、锚框数
    num_classes = cls_probs.shape[1]
    num_anchors = cls_probs.shape[2]

    # 保存整个 batch 的输出
    out = []

    # 逐张图片处理
    for i in range(batch_size):
        # cls_prob.shape = (num_classes, num_anchors)
        cls_prob = cls_probs[i]

        # offset_pred.shape = (num_anchors, 4)
        offset_pred = offset_preds[i].reshape(-1, 4)

        # 对每个锚框,只看前景类别(跳过背景类 cls_prob[0])
        # conf: 每个锚框的最大前景置信度
        # class_id: 对应哪个前景类别(从 0 开始编号)
        conf, class_id = torch.max(cls_prob[1:], dim=0)

        # 根据锚框 + offset 恢复预测边界框
        predicted_bb = offset_inverse(anchors, offset_pred)

        # 做 NMS,得到保留下来的索引
        keep = nms(predicted_bb, conf, nms_threshold)

        # -------------------------
        # 找出哪些框没被保留
        # -------------------------
        # all_idx: 所有锚框索引
        all_idx = torch.arange(num_anchors, dtype=torch.long, device=device)

        # 把 keep 和 all_idx 拼起来
        combined = torch.cat((keep, all_idx))

        # 统计每个索引出现次数
        uniques, counts = combined.unique(return_counts=True)

        # 只出现一次的,说明它只在 all_idx 里出现,
        # 没有在 keep 里出现,所以是 non_keep
        non_keep = uniques[counts == 1]

        # 最终输出顺序:先 keep,再 non_keep
        # 这样真正保留的预测框会排在前面
        all_id_sorted = torch.cat((keep, non_keep))

        # 把没保留的框类别记为 -1
        class_id[non_keep] = -1

        # 按新顺序重排
        class_id = class_id[all_id_sorted]
        conf = conf[all_id_sorted]
        predicted_bb = predicted_bb[all_id_sorted]

        # -------------------------
        # 置信度过低的,也视为背景
        # -------------------------
        below_min_idx = (conf < pos_threshold)

        # 这些框的类别改成 -1(背景 / 无效)
        class_id[below_min_idx] = -1

        # 书中这里把这些低置信度前景框的值改成 1-conf,
        # 可以理解成一种"背景置信度"的表达
        conf[below_min_idx] = 1 - conf[below_min_idx]

        # 每个框最终输出 6 个值:
        # [class_id, confidence, xmin, ymin, xmax, ymax]
        pred_info = torch.cat((
            class_id.unsqueeze(1).float(),
            conf.unsqueeze(1),
            predicted_bb
        ), dim=1)

        out.append(pred_info)

    return torch.stack(out)

预测阶段示例:4 个锚框 + NMS

python 复制代码
# 这里构造 4 个锚框
anchors = torch.tensor([
    [0.10, 0.08, 0.52, 0.92],
    [0.08, 0.20, 0.56, 0.95],
    [0.15, 0.30, 0.62, 0.91],
    [0.55, 0.20, 0.90, 0.88]
])

# 偏移量全设为 0
# 这意味着预测框 = 锚框本身
offset_preds = torch.zeros(anchors.numel())

# cls_probs 的形状是 (num_classes, num_anchors)
# 第 0 行:背景类概率
# 第 1 行:狗类概率
# 第 2 行:猫类概率
cls_probs = torch.tensor([
    [0.0, 0.0, 0.0, 0.0],   # 背景
    [0.9, 0.8, 0.7, 0.1],   # 狗
    [0.1, 0.2, 0.3, 0.9]    # 猫
])

# 先把原始预测框画出来
fig = d2l.plt.imshow(img)
show_bboxes(
    fig.axes,
    anchors * bbox_scale,
    ['dog=0.9', 'dog=0.8', 'dog=0.7', 'cat=0.9']
)
python 复制代码
# multibox_detection 需要 batch 维,所以都加一个 unsqueeze(0)
output = multibox_detection(
    cls_probs.unsqueeze(0),       # (1, 3, 4)
    offset_preds.unsqueeze(0),    # (1, 16)
    anchors.unsqueeze(0),         # (1, 4, 4)
    nms_threshold=0.5
)

# 输出形状: (1, 4, 6)
# 每一行是:
# [class_id, confidence, xmin, ymin, xmax, ymax]
print(output)

输出:

复制代码
tensor([[[ 0.00,  0.90,  0.10,  0.08,  0.52,  0.92],
         [ 1.00,  0.90,  0.55,  0.20,  0.90,  0.88],
         [-1.00,  0.80,  0.08,  0.20,  0.56,  0.95],
         [-1.00,  0.70,  0.15,  0.30,  0.62,  0.91]]])
python 复制代码
# 只把真正保留下来的框画出来(class_id != -1)
fig = d2l.plt.imshow(img)

for row in d2l.numpy(output[0]):
    # row[0] 是类别编号
    if row[0] == -1:
        continue

    # row[1] 是置信度
    # row[2:] 是边界框坐标
    label = ('dog=', 'cat=')[int(row[0])] + str(row[1])

    show_bboxes(
        fig.axes,
        [torch.tensor(row[2:]) * bbox_scale],
        label
    )

五、区域卷积神经网络(R-CNN)系列

R-CNN 家族的核心思想:先找"哪里可能有物体",再对这些候选区域做更精细的分类和边界框回归。

R-CNN 系列属于 two-stage(双阶段)检测: 先产生候选区域(proposal),再细致识别。

待会要介绍的4个R-CNN 模型:

  1. R-CNN:先裁图,再分别识别每一块
  2. Fast R-CNN:不再一块一块卷积,而是整图卷积一次,再从特征图里取每块
  3. Faster R-CNN:候选区域也不要手工方法生成了,改成神经网络自己提
  4. Mask R-CNN:不仅要框出物体,还要把物体的像素轮廓分出来

5.1 R-CNN:最原始的"两阶段检测"思想

1. R-CNN 想解决什么问题?

我们能不能先提出一批"看起来可能是物体"的区域,然后再对这些区域逐个判断?------这就是 R-CNN 的出发点。

2. R-CNN 的流程

讲义里说 R-CNN 有 4 步,我们逐步拆开。

第一步:用选择性搜索(Selective Search)找候选框

输入一张图像后,R-CNN 不会像 SSD 一样在每个位置直接用锚框密集预测,

它会先用一个传统视觉算法 Selective Search 找出大约 2000 个候选区域(proposal regions)。

这些候选区域的特点:

  • 大小不同
  • 长宽比不同
  • 位置不同
  • 希望"尽量覆盖可能的物体"

即 先粗略找出很多"可能有东西"的框,再慢慢筛。

Selective Search 是什么?

它不是神经网络,而是传统图像处理方法。

大致思路是:根据颜色、纹理、区域相似性等,把图像分割成很多小块,再不断合并,得到一批看起来像"物体"的区域。

所以:

  • 优点:不需要训练
  • 缺点:慢,而且和后面的深度网络是割裂的

第二步:把每个候选框裁出来,送进 CNN 提特征

R-CNN 的做法很"朴素":

  • 对于每个 proposal
  • 从原图中裁出这块区域
  • resize 到固定大小(比如 224×224)
  • 单独送进 CNN
  • 取 CNN 的高层特征作为这个 proposal 的表示

也就是说,如果一张图有 2000 个 proposal:

  • 你就要做 2000 次 CNN 前向传播

这就是 R-CNN 最大的问题。

第三步:用 SVM 分类

R-CNN 不是直接在 CNN 后面接 softmax 做分类,

它是先把 CNN 当特征提取器,再对每个类别训练一个 SVM。

比如:

  • 一个 SVM 判断是不是狗
  • 一个 SVM 判断是不是猫
  • 一个 SVM 判断是不是车

这体现了当时深度学习和传统机器学习混合使用的风格。

第四步:用线性回归修正边界框

分类只告诉你"这块是不是狗",

但 proposal 本身未必很准,所以还要再做边界框回归:

  • 输入:proposal 的 CNN 特征
  • 输出:边界框的修正量

和前面的思想类似:

  • 中心点偏移多少
  • 宽高缩放多少

R-CNN 的本质

R-CNN 本质上就是:候选区域生成 + 每个候选区域独立提特征 + 分类 + 边框回归

可以看成目标检测最经典的"两阶段"模板:

  • 阶段 1:先给出候选框
  • 阶段 2:再识别这些候选框

R-CNN 为什么慢?

因为它对每个 proposal 都单独跑一次 CNN。 而这些 proposal 大量重叠:

  • 这个框里有狗头
  • 那个框里有半只狗
  • 另一个框里有整只狗

它们覆盖的是图像中相似区域,

但 R-CNN 却反复对这些重叠区域做卷积,造成大量重复计算。

所以:R-CNN 的慢,不是因为分类器慢,而是因为卷积特征提取没有共享。

5.2 Fast R-CNN:"共享卷积计算"

Fast R-CNN 到底快在哪里?

Fast R-CNN 的核心思想:整张图只做一次卷积,然后从共享特征图中提取各个候选区域的特征。

这一下就把 R-CNN 的最大瓶颈解决了。

Fast R-CNN 的整体流程

设输入图像经过 backbone CNN 后得到特征图:
1×c×h1×w1 1 \times c \times h_1 \times w_1 1×c×h1×w1

这里:

  • 1:batch size
  • c:通道数
  • h1, w1:特征图高宽

假设有 n 个 proposal。

那么流程是:

第 1 步:整图卷积一次

整张图输入 CNN,只做一次前向传播,得到共享特征图。

第 2 步:把 proposal 映射到特征图上

proposal 原来在输入图像坐标系里,

现在要映射到特征图坐标系里,得到对应的 RoI(Region of Interest)

注意:

  • proposal:原图上的候选框
  • RoI:候选框在特征图上的对应区域

第 3 步:RoI Pooling,把不同大小的区域变成同样大小

不同 proposal 的尺寸不同,

但全连接层要求输入维度固定。

所以 Fast R-CNN 引入了:

RoI Pooling(兴趣区域汇聚)

它把每个大小不同的 RoI,统一变成固定大小,比如:

h2×w2=7×7 h_2 \times w_2 = 7 \times 7 h2×w2=7×7

于是输出形状就变成:

n×c×h2×w2 n \times c \times h_2 \times w_2 n×c×h2×w2

也就是:

  • n 个 proposal
  • 每个 proposal 都变成同样大小的特征块

第 4 步:送入全连接层

把每个 RoI 的固定大小特征展平,经过全连接层,得到:

n×d n \times d n×d

这里 d 是特征维度。

第 5 步:分别预测类别和边界框

然后分成两个头:

  • 分类头:输出 n × q
  • 回归头:输出 n × 4

其中:

  • n:proposal 数量
  • q:类别数

严格一点说,在原论文和很多实现里,bbox 回归常常是 每类一个回归器 ,所以会写成 n × 4q

教材这里写成 n × 4 是为了突出主线,不影响理解。

Fast R-CNN 为什么比 R-CNN 快很多?

因为 R-CNN 是:2000 个 proposal → 2000 次 CNN

Fast R-CNN 是:1 张图 → 1 次 CNN → 2000 个 proposal 在特征图上共享这次卷积结果

所以卷积计算被共享了。这就是速度提升的根本原因。

RoI Pooling:这是 Fast R-CNN 的关键

1. 普通池化 vs RoI Pooling

之前学的普通池化层:

  • 给定固定的窗口大小、步幅、填充
  • 在整张特征图上滑动
  • 输出大小由参数间接决定

RoI Pooling 不一样:

  • 输入是"一个任意大小的区域"
  • 直接指定输出大小,比如 2×27×7
  • 不管输入区域多大,输出都固定

所以它的任务是:把大小不一的候选区域,统一压成固定大小的特征表示。

RoI Pooling 不是为了提取"更强特征",而是为了把"不同大小的候选区域"统一成"固定大小的特征块"。

这样后面的全连接层才能接上。

Fast R-CNN 仍然有一个大问题

虽然它解决了"重复卷积"的问题, 但 proposal 还是来自 Selective Search

也就是说:

  • 分类和回归已经交给神经网络了
  • 但"候选框怎么来"仍然靠传统算法

所以它依然有两个问题:

  1. proposal 生成本身仍然慢
  2. proposal 生成不是网络的一部分,不能端到端学习

于是就有了 Faster R-CNN

5.3 Faster R-CNN:把 proposal 生成也变成神经网络

1. Faster R-CNN 的最大思想不要再用 Selective Search 了,直接让网络自己在特征图上生成 proposal。

这个负责生成 proposal 的网络,叫:RPN:Region Proposal Network(区域提议网络)

2.Faster R-CNN 的整体结构

可以把 Faster R-CNN 看成两段:

第一段:RPN 负责"提建议"

  • 哪些位置可能有物体?
  • 给出一批高质量 proposal

第二段:Fast R-CNN head 负责"精修"

  • 每个 proposal 到底是什么类别?
  • 边界框再精修一次

所以它仍然是 two-stage detector

RPN 是怎么工作的?

设 backbone 输出特征图形状:
B×C×H×W B \times C \times H \times W B×C×H×W

RPN 做的事情是:

第一步:在特征图上再接一个 3×3 卷积:使用填充为 1 的 3×3 卷积,把每个位置变成一个长度为 c 的新特征。

这一步的作用是:

  • 让每个空间位置的感受野更适合预测 proposal
  • 相当于给每个位置准备一份"局部上下文特征"

第二步:以每个像素为中心生成多个锚框

对于特征图上每个位置:

  • 生成 k 个 anchors
  • 大小不同
  • 宽高比不同

H \\times W \\times k

第三步:对每个 anchor 做两件事

(1) 预测它是不是"有物体"

这是一个 二分类

  • object
  • background

所以如果每个位置有 k 个 anchor,

分类头输出通道数通常是:2k2k2k

总形状:B×2k×H×WB \times 2k \times H \times WB×2k×H×W

这叫 objectness,即"目标性分数"。

注意这里还不是具体类别(狗、猫、车), 只是判断:这个 anchor 里有没有某个物体

(2) 预测这个 anchor 的边界框偏移量

和你之前学的偏移编码一样,每个 anchor 输出 4 个量:

  • dx
  • dy
  • dw
  • dh

所以回归头输出形状通常是:

B \\times 4k \\times H \\times W

第四步:把高分 anchor 变成 proposal,再做 NMS

RPN 得到:

  • objectness score
  • bbox delta

然后:

  1. 用 delta 修正 anchor,得到 proposal
  2. 去掉太小、越界的框
  3. 按分数排序
  4. 做 NMS 去重
  5. 保留 top-N proposal

这些 proposal 就送给第二阶段(RoI Pooling / RoI Align + 分类回归头)。

可以说:Faster R-CNN 的 RPN,本质上就是"在特征图上做一遍锚框分类 + 锚框回归"。

你前面那节不是白学的,它在这里直接变成了真正的模型组件。

5. Faster R-CNN 为什么更快?

因为 Selective Search 太慢。

RPN 虽然也是网络,但它:

  • 和 backbone 共享特征图
  • 计算量小
  • proposal 质量高
  • 能跟整个检测器联合训练

所以整体效率远高于传统 proposal 方法。

Faster R-CNN 的损失函数怎么理解?

可以粗略理解为 4 部分:

  1. RPN 分类损失:anchor 是否含物体
  2. RPN 回归损失:proposal 框的位置修正
  3. 第二阶段分类损失:proposal 是猫/狗/车/背景
  4. 第二阶段回归损失:进一步精修框的位置

所以 Faster R-CNN 其实做了 两次框回归

  • 第一次:anchor → proposal(RPN)
  • 第二次:proposal → final box(RoI head)

这是它精度高的重要原因之一。

5.4 Mask R-CNN:从"框"升级到"像素级实例分割"

1. Mask R-CNN 比 Faster R-CNN 多了什么? ------除了分类和框回归,再加一个分支预测目标的像素级 mask。

也就是说,每个 proposal 不仅要回答:

  • 这是什么类别?
  • 框在哪里?

还要回答:

  • 这个目标具体覆盖哪些像素?

这就不再只是目标检测,而是 实例分割(instance segmentation)

2. 为什么要把 RoI Pooling 换成 RoI Align?

RoI Pooling 有一个问题:

  • proposal 映射到特征图时,会做坐标量化/取整
  • 每个 bin 划分时,也会取整
  • 这样会造成空间位置偏移

对于分类来说,这点偏差还能忍。

但对于 mask 预测,这种偏移会让边缘不准。

所以 Mask R-CNN 引入:

RoI Align

它的关键思想是:

  • 不做粗暴取整
  • 直接在浮点坐标上采样
  • 双线性插值(bilinear interpolation) 取特征值

这样能更精确保留空间位置信息。

什么是双线性插值?

如果你想在特征图的一个"非整数坐标"处取值,比如 (2.3, 5.7)

这个点不正好落在网格点上。

双线性插值会看它附近 4 个整数网格点,按距离做加权平均,得到一个连续位置上的特征值。

所以 RoI Align 的本质是:

让 RoI 特征提取更"对齐"真实空间位置。

这对像素级任务特别重要。

Mask R-CNN 的输出是什么?

对每个 RoI,通常会有 3 个分支:

  1. 分类分支:类别
  2. 框回归分支:bbox
  3. mask 分支 :一个 m×m 的二值/概率掩码

常见地可以写成:

  • 分类:n × q
  • bbox:n × 4q
  • mask:n × q × m × m

其中:

  • n:RoI 数量
  • q:类别数
  • m×m:mask 分辨率,比如 28×28

训练时通常只对 RoI 的真实类别对应那一张 mask 计算损失。

Mask R-CNN 比 Faster R-CNN 强在哪?

因为它利用了更细粒度的监督:

  • Faster R-CNN 只知道框
  • Mask R-CNN 还知道每个目标具体占哪些像素

这会让模型学到更细致的物体边界和形状信息,往往也能反过来提升检测质量。

下面我按 PyTorch 版本 ,把 SSD(Single Shot MultiBox Detection,单发多框检测) 这一节从
整体思想 → 模型结构 → 每段代码 → 张量形状 → 训练 → 预测 → 练习中的改进点

完整讲一遍。

六、SSD

SSD 的核心思想是:不先生成 proposal(候选区域),而是直接在多个尺度的特征图上,对大量锚框同时做"分类 + 边界框回归"。

所以它属于:

  • one-stage detector:单阶段检测
  • dense prediction:密集预测
  • multi-scale detection:多尺度检测

和你刚才学的 R-CNN 系列相比:

  • R-CNN / Faster R-CNN:先 proposal,再分类回归
  • SSD:直接在锚框上预测,不走 proposal 这一步

所以 SSD 的优点是:

  • 结构更直接
  • 推理更快
  • 工程实现比较自然

但挑战也很明显:

  • 锚框非常多
  • 正负样本极不平衡
  • 小目标比较难

1. Single Shot一趟前向传播里,直接把检测结果做出来

不像 Faster R-CNN 那样先做 RPN,再做第二阶段分类回归。

  1. MultiBox:每个位置不只预测一个框,而是预测多个不同形状/大小的锚框

为什么要多尺度?------因为不同大小的目标,适合在不同分辨率的特征图上检测。

大特征图

  • 空间分辨率高
  • 更适合小目标
  • 因为小目标在这里不会"缩没了"

小特征图

  • 分辨率低,但感受野大
  • 更适合大目标
  • 因为一个位置能"看到"更大区域

所以 SSD 的关键设计就是:在多个尺度的特征图上都做检测。

七、YOLO(你只看一次)

  • SSD中锚框大量重叠,因此浪费了很多计算
  • YOLO 将图片均匀分成SxS个锚框
  • 每个锚框预测 B个边缘框
  • 后续版本(V2,3,V4...)有持续改进
相关推荐
xcbeyond2 小时前
AI 时代生存指南:入门路径 × 工具选择 × 应用场景 × 高频 Prompt 模板
人工智能·prompt
夫唯不争,故无尤也2 小时前
PostgreSQL + SQLAlchemy 快速搭一个能跑的 Agent 后端数据层
数据库·人工智能·postgresql·agent
Monly212 小时前
大模型:LangChain调用大语言模型
人工智能·语言模型·langchain
reesn2 小时前
嵌入模型分类问答
人工智能·分类·数据挖掘
对号东2 小时前
2026现象级开源AI智能体|OpenClaw:让AI从“聊天”变“干活”,本地部署零门槛
人工智能·开源
初窺門徑2 小时前
label studio安装和使用
人工智能·分类·数据挖掘
ZFSS2 小时前
Producer Tasks API 的对接和使用
人工智能·ai·ai作画·ai编程
飞升不如收破烂~2 小时前
AI会替代一部分人,但不会“替代人类”。更准确的说法是——重构工作结构。
人工智能