目标检测与分割 目标检测(object detection)对图像中每个物体进行定位和分类;分割(segmentation)给每个像素分配一个标签。本文件涵盖 IoU、mAP、锚框、R-CNN 家族、YOLO、SSD、特征金字塔网络、语义/实例/全景分割(U-Net、Mask R-CNN、SAM),以及用来评估它们的指标。 图像分类(文件 02)回答"这张图里有什么?"目标检测则问一个更难的问题:"这张图里有哪些物体,它们在哪里?" 分割更进一步:"哪些像素属于哪个物体或类别?"这些任务构成了一组越来越精细的空间理解层级。 目标检测模型输出一组边界框(bounding box),每个框由四个坐标(左上角 $x, y$、宽度、高度)和一个带置信度分数的类别标签定义。
目标检测(object detection)对图像中每个物体进行定位和分类;分割(segmentation)给每个像素分配一个标签。本文件涵盖 IoU、mAP、锚框、R-CNN 家族、YOLO、SSD、特征金字塔网络、语义/实例/全景分割(U-Net、Mask R-CNN、SAM),以及用来评估它们的指标。
图像分类(文件 02)回答"这张图里有什么?"目标检测则问一个更难的问题:"这张图里有哪些物体,它们在哪里?"
分割更进一步:"哪些像素属于哪个物体或类别?"这些任务构成了一组越来越精细的空间理解层级。
目标检测模型输出一组边界框(bounding box),每个框由四个坐标(左上角 x, y、宽度、高度)和一个带置信度分数的类别标签定义。一张图里可能有零个、一个或数百个物体,分属多个类别。
IoU 为 1 表示完全重叠;为 0 表示完全不重叠。判断一次检测是否"正确"的常用阈值是 IoU \geq 0.5,不过也会用更严格的阈值(0.75、0.9)。
如果一个检测框与真值框的 IoU 超过阈值且类别正确,它就是一个真正例(true positive,TP)。
**假正例(false positive,FP)**是没有匹配上任何真值的预测框。
**假负例(false negative,FN)**是没有任何预测匹配上的真值物体。这些都是第 6 章介绍过的精确率/召回率概念。
**平均精度(Average Precision,AP)**汇总一个类别的检测质量。对每个类别,按置信度分数给所有检测排序,在每个排名处计算精确率和召回率,然后求精确率-召回率曲线下的面积:
实际操作中会对曲线做插值:在每个召回率水平上,把精确率设为任何召回率 \geq r 处的最大精确率。这会平滑曲线,让它单调递减。
**平均精度均值(Mean Average Precision,mAP)**对所有类别的 AP 取平均。"mAP@0.5" 用 0.5 的 IoU 阈值。"mAP@[.5:.95]"(COCO 标准)在 0.5 到 0.95 之间以 0.05 为步长取十个 IoU 阈值的平均,同时奖励检测能力和定位精度。
**非极大值抑制(Non-Maximum Suppression,NMS)**用来去除重复检测。当模型对同一个物体预测了多个重叠框时,NMS 保留置信度最高的框,删除其他与它 IoU 超过阈值的框。它在模型给出原始预测之后按类别分别进行。
**两阶段检测器(two-stage detector)**先提出候选区域,再对每个候选进行分类和精炼。
R-CNN(Girshick 等,2014)是第一个成功的深度学习检测器。它用选择性搜索(一种经典算法)提出约 2000 个候选区域,把每个区域缩放到固定大小,各自独立地过一遍 CNN,再用 SVM(第 6 章)分类。R-CNN 精度不错但极其慢:每张图要跑 2000 次 CNN。
Fast R-CNN(Girshick,2015)通过让 CNN 在整张图上只跑一次、得到一张共享特征图,再从这张共享图上用 **RoI 池化(RoI pooling,Region of Interest pooling)**为每个候选提取特征,解决了这种冗余。
RoI 池化把特征图上一个可变大小的区域划分成网格,在每个格子里做最大池化,从而得到固定大小的输出。这要快得多,因为昂贵的 CNN 计算只做一次。
Faster R-CNN(Ren 等,2015)引入区域提议网络(Region Proposal Network,RPN),去掉了外部的区域提议算法。RPN 是一个跑在共享特征图之上的小 CNN,直接预测候选框。它在特征图上滑动一个小窗口,在每个位置预测 k 个提议(对应每个**锚框(anchor box)**一个)。
锚框是特征图上每个空间位置处预定义的边界框,覆盖不同的尺度和长宽比(例如三个尺度 × 三个比例 = 每个位置 9 个锚框)。RPN 对每个锚框预测两样东西:一个 objectness 分数(物体还是背景)和把锚框精炼成更紧提议的坐标偏移。这种参数化让回归问题更简单:网络不用预测绝对坐标,而是预测对合理起始框的小幅调整。
锚框的偏移按如下参数化:
其中 (x, y, w, h) 是预测框的中心和尺寸,(x_a, y_a, w_a, h_a) 是锚框。宽和高上的对数变换保证预测框始终为正,并使回归具有尺度不变性。
Faster R-CNN 用一个多任务损失训练:用于类别标签的分类损失(第 5 章的交叉熵),加上用于边界框回归的 smooth L1 损失。Smooth L1 对离群值的敏感度比 L2 低:
\text{smooth}_{L1}(x) = \begin{cases} 0.5x^2 & \text{if } |x| < 1 \\ |x| - 0.5 & \text{otherwise} \end{cases}
特征金字塔网络(Feature Pyramid Networks,FPN)(Lin 等,2017)通过构建一条带横向连接的自顶向下路径,把高层语义和低层空间细节融合起来,解决多尺度问题。主干在多个尺度上产生特征图(每次池化把分辨率减半)。FPN 加了一条自顶向下的路径,每层接收上面一层的上采样特征,并通过横向的 1x1 卷积与对应的自底向上层融合。结果是一组特征图金字塔,每层都既有强语义又有好的空间分辨率。
小物体从更高分辨率的金字塔层检测;大物体从更低分辨率的层检测。FPN 现在是大多数现代检测架构的标准组件。
**单阶段检测器(one-stage detector)**完全跳过提议步骤,一次前向就预测类别标签和边界框。这更快,但历史上精度不如两阶段检测器,直到 focal loss 出现弥合了差距。
YOLO(You Only Look Once,Redmon 等,2016)把图像划分为 S \times S 的网格。每个网格单元预测 B 个边界框和 C 个类别概率。如果某个物体的中心落在一个网格单元里,那个单元就负责检测它。YOLO 极其快,因为整个检测是一次前向,没有提议阶段。
YOLOv2 加入了锚框、批归一化和多尺度训练。YOLOv3 用了特征金字塔网络,并在三个尺度上预测。YOLOv4-v8 继续改进,用了更好的主干、路径聚合网络和 mosaic 数据增强(训练时把四张图拼在一起以增加上下文多样性)。
SSD(Single Shot MultiBox Detector,Liu 等,2016)在主干内多个特征图尺度上预测,每个尺度都用锚框。靠前的(高分辨率)特征图检测小物体;靠后的(低分辨率)检测大物体。SSD 比 Faster R-CNN 更快,精度也相当。
RetinaNet(Lin 等,2017)指出了单阶段检测器的核心问题:类别不平衡。绝大多数锚框对应的是背景,这会产生大量容易的负样本,主导损失函数、淹没稀少正样本的梯度。
Focal loss 通过降低容易样本的权重来解决这个问题:
其中 p_t 是对正确类别的预测概率。当模型既自信又正确时(p_t 很大),(1 - p_t)^\gamma 很小,降低了容易负样本对损失的贡献。超参数 \gamma(通常取 2)控制下调强度的。当 \gamma = 0 时,focal loss 就退化为标准交叉熵。借助 focal loss,RetinaNet 达到了和两阶段检测器相当的精度,同时保持单阶段速度。
**无锚框检测(anchor-free detection)**彻底去掉锚框,减少了超参数调优,简化了流程。
FCOS(Fully Convolutional One-Stage,Tian 等,2019)在特征图的每个空间位置上,预测从该位置到最近边界框四条边(左、上、右、下)的距离以及一个类别标签。一个 centerness 分数会下调远离物体中心的预测,提升质量。FCOS 用 FPN 处理多尺度。
CenterNet(Zhou 等,2019)把物体当作点来检测:它预测一张热力图,峰值对应物体中心,然后在每个峰值处回归宽度和高度。检测变成了关键点估计。这种方法优雅且无锚框,但需要仔细的热力图后处理。
CornerNet 把物体检测成一对角点(左上和右下)。它预测两张热力图(每种角点一张),并用一个**关联嵌入(associative embedding)**把对应的角点匹配成边界框。这避免了锚框,还能处理任意形状的物体。
**语义分割(semantic segmentation)**给图像中的每个像素分配一个类别标签。和检测(输出框)不同,分割产出一张密集的像素级图。一个街景可能把每个像素标为道路、人行道、汽车、行人、建筑、天空等。
全卷积网络(Fully Convolutional Networks,FCN)(Long 等,2015)把分类 CNN 改造成用于分割的形式:用卷积层替换全连接层,让网络输出一张空间图而不是单个类别。上采样(通过转置卷积或双线性插值)把输出恢复到输入分辨率。来自浅层的跳跃连接把下采样过程中丢失的空间细节补回来。
转置卷积(transposed convolution)(有时叫"反卷积")是卷积的上采样对应物。带步长的卷积缩小空间维度,转置卷积则放大。它在输入元素之间插入零,再做一次标准卷积,相当于学如何上采样。
U-Net(Ronneberger 等,2015)引入了一种对称的编码器-解码器架构,每一层都有跳跃连接。编码器(收缩路径)降低空间分辨率同时增加通道数,和分类 CNN 完全一样。解码器(扩展路径)上采样回全分辨率。跳跃连接把每层的编码器特征图和解码器特征图拼接起来,给解码器提供精细的空间细节。高层语义和低层细节的这种组合产生了锐利、准确的分割边界。
U-Net 最初是为生物医学图像分割(训练数据稀缺)设计的,它的架构成了后续许多模型的基础,包括潜在扩散模型里的 U-Net(文件 04)。
DeepLab(Chen 等,2014-2018)为分割引入了两项关键创新:
空洞卷积(atrous / dilated convolution):在滤波器元素之间插入间隔的标准卷积,由空洞率 r 控制。一个空洞为 r 的 3x3 滤波器感受野是 (2r + 1) \times (2r + 1),但只用 9 个参数。这样可以在不下采样的情况下捕捉多尺度上下文,保留空间分辨率。
空洞空间金字塔池化(Atrous Spatial Pyramid Pooling,ASPP):并行地用不同空洞率的多个空洞卷积(比如 1、6、12、18),把结果拼起来,再用 1x1 卷积融合。ASPP 同时捕捉多个尺度的上下文,思想上类似于 Inception 模块(文件 02),但用的是空洞而不是不同的核大小。
DeepLab 还把条件随机场(Conditional Random Field,CRF)(第 5 章)用作后处理步骤,通过鼓励空间上相近、颜色相似的像素共享同一个标签来精炼分割边界。
**实例分割(instance segmentation)**结合了检测和分割:它识别每一个独立的物体实例,并为每个实例生成一张像素级掩码。场景里的两辆车会得到两张独立的掩码,而不是两者都标成"车"。
Mask R-CNN(He 等,2017)在 Faster R-CNN 之上加了一个小的分割头,对每个检测到的物体预测一张二值掩码。架构就是 Faster R-CNN + 一个掩码分支:掩码分支接收 RoI 池化后的特征,对每个类别输出一张 m \times m 的二值掩码。它用 RoIAlign 取代 RoI 池化:在精确采样的点上做双线性插值,而不是量化网格单元,避免了量化造成的空间错位。这一小改动显著提升了掩码质量。
Mask R-CNN 用一个多任务损失训练:分类损失 + 边界框回归损失 + 掩码损失(逐像素的二值交叉熵)。掩码分支对每个类别独立预测一张掩码;只有预测类别对应的掩码会被使用,这样把掩码预测和分类解耦,两者都得到提升。
**全景分割(panoptic segmentation)**把语义分割和实例分割统一到一个任务里。每个像素既得到一个类别标签(语义),又得到一个实例 ID(实例,用于像车和人这样的"thing"类别)。"Stuff" 类别(天空、道路、草地)只得到语义标签,因为它们是没有可数实例的无定形区域。
全景质量(Panoptic Quality,PQ)指标通过把它分解成分割质量(匹配片段的平均 IoU)和识别质量(匹配片段的 F1 分数)来评估:
实时分割对自动驾驶和增强现实这类应用至关重要,它们的延迟预算很紧(通常每帧不到 30 毫秒)。
BiSeNet(Bilateral Segmentation Network,双边分割网络,Yu 等,2018)用两条并行路径:一条用宽而浅的层、保留空间细节的空间路径,和一条用深而窄的层、捕捉语义的上下文路径。两条路径的输出融合起来,兼顾速度和精度。
DDRNet(Deep Dual-Resolution Network,深度双分辨率网络,Hong 等,2021)在整个网络中保持两个不同分辨率的分支,并在它们之间反复交换信息。高分辨率分支保留空间细节,低分辨率分支捕捉全局上下文。多个双边融合模块在两个方向上融合信息。
实时分割的总体趋势是避免沉重的编码器-解码器模式,转而在整个网络中保持足够的空间分辨率,用一些精度换取大幅降低的延迟。
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, ['Before NMS', 'After 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"Kept {len(keep)} of {len(boxes)} boxes after NMS")
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:为每个锚框预测 objectness 和边界框偏移。""" 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 map: {feature_h}x{feature_w}, stride={stride}") print(f"Anchors per position: {n_anchors_per_pos}") print(f"Total anchors: {len(anchors)}") print(f"Objectness scores shape: {scores.shape}") print(f"Box offsets shape: {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'Anchors at centre position\n3 scales × 3 ratios = {n_anchors_per_pos}') ax.grid(True, alpha=0.3) plt.tight_layout(); plt.show()
import jax import jax.numpy as jnp import matplotlib.pyplot as plt def conv1d_same(x, kernel): """same 填充的一维卷积。""" 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('Input Signal'); axes[0].set_ylabel('Value') axes[1].fill_between(t, 0, labels, alpha=0.3, color='#27ae60') axes[1].set_title('Ground Truth Labels'); axes[1].set_ylabel('Label') 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 Prediction'); axes[2].set_ylabel('Probability') axes[2].set_xlabel('t') plt.tight_layout(); plt.show() print(f"Final loss: {loss_fn(params, signal, labels):.4f}") print(f"Pixel accuracy: {jnp.mean((pred > 0.5) == labels):.2%}")