目标检测与分割¶
目标检测对图像中的每个物体进行定位和分类;分割则为每个像素分配一个标签。本文涵盖IoU、mAP、锚框、R-CNN系列、YOLO、SSD、特征金字塔网络、语义/实例/全景分割(U-Net、Mask R-CNN、SAM)以及用于评估它们的指标。
-
图像分类回答的是“这张图像里有什么?”。目标检测问的是更难的问题:“图像里有什么物体,它们在哪里?”
-
分割更进一步:“哪些像素属于哪个物体或类别?”这些任务构成了一个空间理解精度逐步提升的层次结构。
-
目标检测模型输出一组边界框,每个框由四个坐标(左上角 \(x, y\)、宽度、高度)以及带置信度分数的类别标签定义。一张图像可能包含零个、一个或数百个来自多个类别的物体。
- 交并比(IoU) 衡量预测边界框与真实边界框的匹配程度。它是重叠面积除以并集面积:
-
IoU为1表示完全重叠,IoU为0表示完全没有重叠。判断检测是否“正确”的标准阈值通常是IoU \(\geq 0.5\),但也使用更严格的阈值(0.75,0.9)。
-
如果一个检测框与某个真实框的IoU超过阈值且类别正确,则称为真正例(TP)。
-
假正例(FP) 是一个预测框没有匹配到任何真实物体。
-
假负例(FN) 是一个真实物体没有被任何预测框匹配到。这些概念与第06章中的精确率和召回率相同。
-
平均精度(AP) 汇总单个类别的检测质量。对于每个类别,按置信度分数对所有检测结果降序排序,计算每个排序位置上的精确率和召回率,然后计算精确率-召回率曲线下的面积:
-
实际计算中,曲线会被插值处理:在每个召回率水平,精确率取所有召回率 \(\geq r\) 时的最大精确率。这样使得曲线平滑并单调递减。
-
平均精度均值(mAP) 是所有类别AP的平均值。“mAP@0.5”使用IoU阈值0.5。“mAP@[.5:.95]”(COCO标准)是在0.5到0.95之间、步长0.05的十个IoU阈值上计算mAP的平均值,既奖励检测也奖励精确定位。
-
非极大值抑制(NMS) 用于去除重复检测。当模型为同一物体预测出多个重叠框时,NMS保留置信度最高的框,并移除所有与其重叠IoU高于阈值的其他框。NMS在每个类别上独立应用,在模型产生原始预测之后执行。
-
两阶段检测器首先提出候选区域,然后对每个区域进行分类和微调。
-
R-CNN(Girshick 等,2014)是第一个成功的基于深度学习的目标检测器。它使用选择性搜索(一种经典算法)提出约2000个候选区域,将每个区域调整为固定尺寸,独立通过CNN,并使用SVM(第06章)进行分类。R-CNN准确但极慢:每张图像要运行CNN 2000次。
-
Fast R-CNN(Girshick,2015)通过在整个图像上只运行一次CNN来生成共享特征图,然后对每个候选区域从该共享特征图中提取特征,使用了RoI池化(感兴趣区域池化),解决了计算冗余的问题。
-
RoI池化将大小不一的区域特征图划分为固定网格,在每个网格单元内进行最大池化,产生固定大小的输出。这样昂贵的CNN计算只进行一次,速度大幅提升。
-
Faster R-CNN(Ren 等,2015)通过引入区域提议网络(RPN) 彻底去掉了外部区域提议算法。RPN是一个小型CNN,运行在共享特征图之上,直接预测候选框。RPN在特征图上滑动一个小窗口,在每个位置为每个锚框预测 \(k\) 个提议。
-
锚框是预定义的边界框,位于特征图的每个空间位置,覆盖不同的尺度和宽高比(例如,3个尺度 × 3个比例 = 每个位置9个锚框)。RPN为每个锚框预测两个东西:物体性分数(是物体还是背景)和坐标偏移量,用于将锚框微调为更紧致的提议。这种参数化使得回归问题更容易:网络不是预测绝对坐标,而是预测对合理初始框的微小调整。
-
锚框偏移量的参数化形式为:
-
其中 \((x, y, w, h)\) 是预测框的中心和尺寸,\((x_a, y_a, w_a, h_a)\) 是锚框。宽度和高度的对数变换确保预测框始终为正,并使回归具有尺度不变性。
-
Faster R-CNN使用多任务损失进行训练:分类损失(来自第05章的交叉熵)加上用于边界框回归的平滑L1损失。平滑L1对异常值的敏感性低于L2:
-
特征金字塔网络(FPN)(Lin 等,2017)通过构建一条带有横向连接的自顶向下路径,将高层语义与低层空间细节融合,解决了多尺度问题。骨干网络产生多个尺度的特征图(每个池化层将分辨率减半)。FPN增加了一条自顶向下的路径,其中每一层接收来自上一层的上采样特征,并通过横向1x1卷积与对应的自底向上特征融合。结果得到一个特征金字塔,每个层级同时具有强语义和良好的空间分辨率。
-
小物体从金字塔的高分辨率层级检测;大物体从低分辨率层级检测。FPN如今已成为大多数现代检测架构的标准组件。
-
单阶段检测器完全跳过候选框生成步骤,直接在一次前向传播中预测类别标签和边界框。这更快,但历史上准确率不如两阶段检测器,直到焦点损失缩小了差距。
-
YOLO(You Only Look Once,Redmon 等,2016)将图像划分为 \(S \times S\) 的网格。每个网格单元预测 \(B\) 个边界框和 \(C\) 个类别概率。如果一个物体的中心落在某个网格单元内,该单元负责检测它。YOLO极快,因为整个检测过程是单次前向传播,没有候选区域阶段。
-
YOLOv2 增加了锚框、批归一化和多尺度训练。YOLOv3 使用了特征金字塔网络并在三个尺度上预测。YOLOv4-v8 持续改进,采用了更好的骨干网络、路径聚合网络和马赛克数据增强(训练时将四张图像拼接在一起,增加上下文多样性)。
-
SSD(Single Shot MultiBox Detector,Liu 等,2016)在骨干网络内的多个特征图尺度上进行预测,每个尺度使用锚框。早期(高分辨率)特征图检测小物体;后期(低分辨率)特征图检测大物体。SSD比Faster R-CNN更快,且精度具有竞争力。
-
RetinaNet(Lin 等,2017)指出了单阶段检测器的核心问题:类别不平衡。绝大多数锚框对应背景,这些容易分类的负样本主导了损失,淹没了来自稀有正样本的梯度。
-
焦点损失通过降低容易样本的权重来解决这一问题:
-
其中 \(p_t\) 是模型对正确类别的预测概率。当模型对正确类别非常自信时(\(p_t\) 高),\((1 - p_t)^\gamma\) 很小,从而减少了容易负样本的损失贡献。超参数 \(\gamma\)(通常取2)控制降权的强度。当 \(\gamma = 0\) 时,焦点损失退化为标准交叉熵。有了焦点损失,RetinaNet在单阶段速度下达到了与两阶段检测器相当的准确率。
-
无锚框检测完全抛弃了锚框,减少了超参数调整,简化了流程。
-
FCOS(Fully Convolutional One-Stage,Tian 等,2019)在特征图的每个空间位置预测从该位置到最近边界框四条边的距离(左、上、右、下),以及一个类别标签。中心度分数用于降低远离物体中心的预测的质量,从而提升检测质量。FCOS使用FPN处理多尺度。
-
CenterNet(Zhou 等,2019)将物体检测视为点检测:它预测一个热力图,其中峰值对应物体中心,然后在每个峰值处回归宽度和高度。检测变成了关键点估计。这种方法简洁且无锚框,但需要仔细的热力图后处理。
-
CornerNet 将物体检测为一对角点(左上和右下)。它预测两个热力图(每个角点类型一个),并使用关联嵌入将对应的角点匹配成边界框。这避免了锚框,并能处理任意形状的物体。
-
语义分割为图像中的每个像素分配一个类别标签。与检测(输出框)不同,分割产生密集的像素级映射。一张街景图像可能将每个像素标记为道路、人行道、汽车、行人、建筑、天空等。
-
全卷积网络(FCN)(Long 等,2015)将分类CNN改造用于分割,用卷积层替换全连接层,使网络输出空间映射而非单个类别。上采样(通过转置卷积或双线性插值)将输出恢复到输入分辨率。来自早期层的跳跃连接添加回下采样过程中丢失的空间细节。
-
转置卷积(有时称为“反卷积”)是卷积的上采样对应操作。步长卷积减小空间尺寸,转置卷积则增大空间尺寸。它在输入元素之间插入零,然后进行标准卷积,从而学习如何上采样。
-
U-Net(Ronneberger 等,2015)引入了对称的编码器-解码器架构,并在每一层都有跳跃连接。编码器(收缩路径)降低空间分辨率同时增加通道数,与分类CNN完全相同。解码器(扩展路径)上采样回原始分辨率。跳跃连接将每一层的编码器特征图与解码器特征图拼接,为解码器提供精细的空间细节。这种高层语义与低层细节的结合产生了清晰准确的分割边界。
-
U-Net最初是为生物医学图像分割(训练数据稀缺)设计的,其架构已成为许多后续模型的基础,包括潜在扩散模型中的U-Net(见第04章)。
-
DeepLab(Chen 等,2014-2018)为分割引入了两项关键创新:
-
空洞卷积(扩张卷积):在卷积滤波器元素之间插入间隙的标准卷积,由扩张率 \(r\) 控制。一个3x3的滤波器,扩张率 \(r\),其感受野为 \((2r+1) \times (2r+1)\),但只使用9个参数。这可以在不进行下采样的情况下捕获多尺度上下文,保留空间分辨率。
-
空洞空间金字塔池化(ASPP):并行应用多个不同扩张率的空洞卷积(例如,扩张率1,6,12,18),将结果拼接,并通过1x1卷积融合。ASPP同时捕获多尺度上下文,其思想类似于Inception模块(第02章),但使用扩张代替不同卷积核尺寸。
-
-
DeepLab还使用条件随机场(CRF)(第05章)作为后处理步骤,通过鼓励空间邻近且颜色相似的像素共享相同标签来细化分割边界。
-
实例分割结合了检测和分割:它将每个物体实例单独识别出来,并为每个物体生成像素级掩膜。场景中的两辆汽车得到两个独立的掩膜,而不仅仅是“汽车”一个标签。
-
Mask R-CNN(He 等,2017)通过为每个检测到的物体添加一个预测二进制掩膜的小型分割头来扩展Faster R-CNN。其架构是Faster R-CNN + 掩膜分支:掩膜分支接收RoI池化的特征,并为每个类别输出一个 \(m \times m\) 的二进制掩膜。它使用RoIAlign代替RoI池化:在精确采样的点上进行双线性插值,而不是量化网格单元,这避免了量化造成的空间错位。这一小改动显著提高了掩膜质量。
-
Mask R-CNN使用多任务损失进行训练:分类损失 + 边界框回归损失 + 掩膜损失(逐像素二元交叉熵)。掩膜分支为每个类别独立预测一个掩膜;只使用与预测类别对应的掩膜,这使掩膜预测与分类解耦,两者都得到改善。
-
全景分割将语义分割和实例分割统一为单个任务。每个像素同时获得一个类别标签(语义)和一个实例ID(针对“物体”类别,如汽车和人)。“材质”类别(天空、道路、草地)只获得语义标签,因为它们是无定形区域,没有可计数的实例。
-
全景质量(PQ)指标通过分解为分割质量(匹配片段平均IoU)和识别质量(匹配片段的F1分数)来评估此任务:
-
实时分割对于自动驾驶和增强现实等应用至关重要,这些应用的延迟预算通常很紧张(通常每帧小于30毫秒)。
-
BiSeNet(Bilateral Segmentation Network,Yu 等,2018)使用两条并行路径:空间路径采用宽而浅的层,保留空间细节;上下文路径采用深而窄的层,捕获语义。输出融合后,兼具速度和准确性。
-
DDRNet(Deep Dual-Resolution Network,Hong 等,2021)在整个网络中保持两个不同分辨率的分支,并在它们之间反复进行信息交换。高分辨率分支保留空间细节,低分辨率分支捕获全局上下文。多个双边融合模块在两个方向上进行信息合并。
-
实时分割的总体趋势是避免沉重的编码器-解码器模式,而是在整个网络中保持足够高的空间分辨率,以一定的准确性换取极低的延迟。
编程任务(使用CoLab或notebook)¶
-
从零实现IoU计算和非极大值抑制。将NMS应用于一组重叠的边界框并可视化结果。
import jax.numpy as jnp import matplotlib.pyplot as plt import matplotlib.patches as patches def compute_iou(box1, box2): """计算两个框 [x1, y1, x2, y2] 之间的IoU。""" x1 = jnp.maximum(box1[0], box2[0]) y1 = jnp.maximum(box1[1], box2[1]) x2 = jnp.minimum(box1[2], box2[2]) y2 = jnp.minimum(box1[3], box2[3]) intersection = jnp.maximum(0, x2 - x1) * jnp.maximum(0, y2 - y1) area1 = (box1[2] - box1[0]) * (box1[3] - box1[1]) area2 = (box2[2] - box2[0]) * (box2[3] - box2[1]) union = area1 + area2 - intersection return intersection / (union + 1e-6) def nms(boxes, scores, iou_threshold=0.5): """非极大值抑制。""" order = jnp.argsort(-scores) # 按置信度降序排序 keep = [] remaining = list(range(len(scores))) order_list = order.tolist() while order_list: idx = order_list[0] keep.append(idx) order_list = order_list[1:] new_order = [] for j in order_list: iou = compute_iou(boxes[idx], boxes[j]) if iou < iou_threshold: new_order.append(j) order_list = new_order return keep # 示例:同一物体的重叠检测框 boxes = jnp.array([ [50, 60, 150, 160], # 高置信度 [55, 65, 155, 165], # 重叠重复 [52, 58, 148, 158], # 重叠重复 [200, 100, 300, 200], # 不同物体 [205, 105, 305, 205], # 重叠重复 ]) scores = jnp.array([0.95, 0.80, 0.70, 0.90, 0.60]) keep = nms(boxes, scores, iou_threshold=0.5) fig, axes = plt.subplots(1, 2, figsize=(14, 5)) colors = ['#3498db', '#e74c3c', '#27ae60', '#9b59b6', '#f39c12'] for ax, title, indices in zip(axes, ['NMS之前', 'NMS之后'], [range(len(boxes)), keep]): ax.set_xlim(0, 400); ax.set_ylim(0, 300) ax.set_aspect('equal'); ax.invert_yaxis() ax.set_title(title) for i in indices: b = boxes[i] rect = patches.Rectangle((b[0], b[1]), b[2]-b[0], b[3]-b[1], linewidth=2, edgecolor=colors[i], facecolor='none') ax.add_patch(rect) ax.text(b[0], b[1]-5, f'{scores[i]:.2f}', color=colors[i], fontsize=10) plt.tight_layout(); plt.show() print(f"NMS后保留了 {len(keep)} / {len(boxes)} 个框") -
实现一个简化的区域提议网络(RPN)。给定一个特征图,生成多个尺度和宽高比的锚框,并预测物体性分数和框偏移量。
import jax import jax.numpy as jnp import matplotlib.pyplot as plt import matplotlib.patches as patches def generate_anchors(feature_h, feature_w, stride, scales, ratios): """为特征图上的每个位置生成锚框。""" anchors = [] for y in range(feature_h): for x in range(feature_w): cx = (x + 0.5) * stride cy = (y + 0.5) * stride for s in scales: for r in ratios: w = s * jnp.sqrt(r) h = s / jnp.sqrt(r) anchors.append([cx - w/2, cy - h/2, cx + w/2, cy + h/2]) return jnp.array(anchors) def rpn_forward(feature_map, params): """简化的RPN:为每个锚框预测物体性和框偏移。""" H, W, C = feature_map.shape n_anchors = params['cls_w'].shape[1] # 在特征图上滑动1x1卷积(简化版) cls_scores = feature_map.reshape(-1, C) @ params['cls_w'] # (H*W, n_anchors) box_offsets = feature_map.reshape(-1, C) @ params['reg_w'] # (H*W, n_anchors*4) cls_scores = jax.nn.sigmoid(cls_scores) return cls_scores.ravel(), box_offsets.reshape(-1, 4) # 设置参数 feature_h, feature_w, channels = 4, 4, 16 stride = 16 # 每个特征图单元对应 16x16 像素 scales = [32, 64, 128] ratios = [0.5, 1.0, 2.0] n_anchors_per_pos = len(scales) * len(ratios) key = jax.random.PRNGKey(42) k1, k2, k3 = jax.random.split(key, 3) feature_map = jax.random.normal(k1, (feature_h, feature_w, channels)) params = { 'cls_w': jax.random.normal(k2, (channels, n_anchors_per_pos)) * 0.01, 'reg_w': jax.random.normal(k3, (channels, n_anchors_per_pos * 4)) * 0.01, } anchors = generate_anchors(feature_h, feature_w, stride, scales, ratios) scores, offsets = rpn_forward(feature_map, params) print(f"特征图尺寸: {feature_h}x{feature_w}, 步长={stride}") print(f"每个位置的锚框数: {n_anchors_per_pos}") print(f"锚框总数: {len(anchors)}") print(f"物体性分数形状: {scores.shape}") print(f"框偏移量形状: {offsets.shape}") # 可视化一个位置处的锚框 fig, ax = plt.subplots(figsize=(6, 6)) img_size = feature_h * stride ax.set_xlim(0, img_size); ax.set_ylim(0, img_size) ax.invert_yaxis(); ax.set_aspect('equal') pos_idx = feature_h // 2 * feature_w + feature_w // 2 # 中心位置 colors = ['#3498db', '#e74c3c', '#27ae60'] for i, s in enumerate(scales): for j, r in enumerate(ratios): idx = pos_idx * n_anchors_per_pos + i * len(ratios) + j a = anchors[idx] rect = patches.Rectangle((a[0], a[1]), a[2]-a[0], a[3]-a[1], linewidth=1.5, edgecolor=colors[i], facecolor='none', linestyle=['--', '-', ':'][j]) ax.add_patch(rect) ax.scatter([img_size/2], [img_size/2], c='red', s=50, zorder=5) ax.set_title(f'中心位置的锚框\n3种尺度 × 3种比例 = {n_anchors_per_pos}') ax.grid(True, alpha=0.3) plt.tight_layout(); plt.show() -
实现一个简化的1D U-Net编码器-解码器,带跳跃连接,用于1D分割(一维信号的二分类)。
import jax import jax.numpy as jnp import matplotlib.pyplot as plt def conv1d_same(x, kernel): """带有相同填充的一维卷积。""" k = len(kernel) pad = k // 2 x_pad = jnp.pad(x, pad, mode='edge') n = len(x) out = jnp.zeros(n) for i in range(n): out = out.at[i].set(jnp.sum(x_pad[i:i+k] * kernel)) return out def downsample(x): return x[::2] def upsample(x, target_len): return jnp.interp(jnp.linspace(0, 1, target_len), jnp.linspace(0, 1, len(x)), x) def unet_1d(x, params): """简化的1D U-Net,包含2个编码/解码层级。""" # 编码器 e1 = jnp.maximum(0, conv1d_same(x, params['enc1'])) e1_down = downsample(e1) e2 = jnp.maximum(0, conv1d_same(e1_down, params['enc2'])) e2_down = downsample(e2) # 瓶颈 bottleneck = jnp.maximum(0, conv1d_same(e2_down, params['bottleneck'])) # 解码器,带跳跃连接 d2_up = upsample(bottleneck, len(e2)) d2 = jnp.maximum(0, conv1d_same(d2_up + e2, params['dec2'])) # 跳跃连接 d1_up = upsample(d2, len(e1)) d1 = conv1d_same(d1_up + e1, params['dec1']) # 跳跃连接 return jax.nn.sigmoid(d1) # 创建带有标签区域的信号 n = 128 t = jnp.linspace(0, 4 * jnp.pi, n) signal = jnp.sin(t) + 0.5 * jnp.sin(3 * t) labels = (signal > 0.5).astype(jnp.float32) # 二分割目标 key = jax.random.PRNGKey(42) keys = jax.random.split(key, 5) params = { 'enc1': jax.random.normal(keys[0], (5,)) * 0.3, 'enc2': jax.random.normal(keys[1], (5,)) * 0.3, 'bottleneck': jax.random.normal(keys[2], (3,)) * 0.3, 'dec2': jax.random.normal(keys[3], (5,)) * 0.3, 'dec1': jax.random.normal(keys[4], (5,)) * 0.3, } def loss_fn(params, signal, labels): pred = unet_1d(signal, params) return -jnp.mean(labels * jnp.log(pred + 1e-7) + (1 - labels) * jnp.log(1 - pred + 1e-7)) grad_fn = jax.jit(jax.grad(loss_fn)) lr = 0.05 for step in range(500): grads = grad_fn(params, signal, labels) params = {k: params[k] - lr * grads[k] for k in params} pred = unet_1d(signal, params) fig, axes = plt.subplots(3, 1, figsize=(12, 7), sharex=True) axes[0].plot(t, signal, color='#3498db', linewidth=1.5) axes[0].set_title('输入信号'); axes[0].set_ylabel('幅值') axes[1].fill_between(t, 0, labels, alpha=0.3, color='#27ae60') axes[1].set_title('真实标签'); axes[1].set_ylabel('标签') axes[2].plot(t, pred, color='#e74c3c', linewidth=1.5) axes[2].fill_between(t, 0, (pred > 0.5).astype(float), alpha=0.2, color='#e74c3c') axes[2].set_title('U-Net预测'); axes[2].set_ylabel('概率') axes[2].set_xlabel('t') plt.tight_layout(); plt.show() print(f"最终损失: {loss_fn(params, signal, labels):.4f}") print(f"像素准确率: {jnp.mean((pred > 0.5) == labels):.2%}")