Post

Object Detection

整理目标检测的基本组成:边界框格式、IoU、非极大值抑制、锚框、检测头输出的解码、损失函数、mAP 的计算,以及预训练检测器的使用和坐标映射

Object Detection
深度学习系列:第 17 篇,共 22 篇

用 PyTorch 从搭建模型、训练、调试到部署的一组笔记。按下面的顺序阅读,后面的文章会直接引用前面讲过的内容

模型结构
  1. PyTorch CNN
  2. PyTorch Transformer
  3. Vision Transformer
训练
  1. PyTorch Data Loading
  2. PyTorch Training
  3. Optimizers and Learning Rate Schedules
  4. PyTorch Checkpoints and Reproducibility
  5. Normalization and Regularization
  6. PyTorch Debugging
  7. PyTorch Common Mistakes
  8. PyTorch Memory and Speed
  9. Evaluation Metrics
  10. TensorBoard
  11. Experiment Tracking
  12. PyTorch Distributed Training
任务
  1. PyTorch Fine-Tuning
  2. Object Detection
  3. Image Segmentation
  4. Keypoint Detection
  5. Generative Models
部署
  1. PyTorch Deployment
  2. Model Compression

目标检测要同时回答图像中有什么和在哪里:对每个目标输出一个边界框、一个类别和一个置信度分数。不同的检测模型结构差别很大,但边界框的表示、IoU、非极大值抑制、解码、损失函数和评价指标是通用的,torchvision.ops中有对应的实现

边界框的表示

边界框是与图像坐标轴平行的矩形,用 4 个数表示。图像坐标系的原点在左上角,$x$向右,$y$向下,与 OpenCV 一致

格式4 个数的含义常见的使用场合
xyxy左上角和右下角的坐标torchvision的模型和函数、Pascal VOC
xywh左上角的坐标、宽度、高度COCO 标注文件、cv2.boundingRect的返回值
cxcywh中心点的坐标、宽度、高度YOLO 系列的标注和网络输出、DETR

同一个边界框在 xyxy、xywh、cxcywh 三种格式下的表示

坐标还分为绝对坐标(以像素为单位)和归一化坐标(除以图像的宽度和高度,取值在 0 到 1 之间)。YOLO 的标注文件使用归一化的cxcywh,好处是图像缩放后标注不需要修改

torchvision.ops.box_convert在三种格式之间转换,输入的形状为 (N, 4)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import torch
from torchvision.ops import box_convert

# 640x480 图像上的一个框,左上角 (100, 120),右下角 (300, 360)
xyxy = torch.tensor([[100., 120., 300., 360.]])

xywh = box_convert(xyxy, in_fmt='xyxy', out_fmt='xywh')
cxcywh = box_convert(xyxy, in_fmt='xyxy', out_fmt='cxcywh')
print(xywh)    # tensor([[100., 120., 200., 240.]])
print(cxcywh)  # tensor([[200., 240., 200., 240.]])

# 归一化:x 除以图像宽度,y 除以图像高度
width, height = 640, 480
scale = torch.tensor([width, height, width, height])
print(cxcywh / scale)  # tensor([[0.3125, 0.5000, 0.3125, 0.5000]])

# 转换回来
print(box_convert(cxcywh, in_fmt='cxcywh', out_fmt='xyxy'))  # tensor([[100., 120., 300., 360.]])

torchvision.ops中计算 IoU、做非极大值抑制的函数都要求xyxy格式,并且不检查输入。 传入其他格式不会报错,只会得到错误的结果,从标注文件或其他框架拿到边界框后要先确认格式

IoU

计算方法

IoU(Intersection over Union,交并比)衡量两个框的重合程度,是两个框交集的面积与并集的面积之比,取值在 0 到 1 之间

\[IoU = \frac{\operatorname{area}(A \cap B)}{\operatorname{area}(A \cup B)}\]

交集仍然是一个矩形:左上角取两个框左上角中较大的坐标,右下角取两个框右下角中较小的坐标。并集的面积等于两个框的面积之和减去交集的面积

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import torch
from torchvision.ops import box_iou


def iou(boxes1, boxes2):
    """boxes1: (N, 4),boxes2: (M, 4),均为 xyxy 格式,返回 (N, M)"""
    area1 = (boxes1[:, 2] - boxes1[:, 0]) * (boxes1[:, 3] - boxes1[:, 1])
    area2 = (boxes2[:, 2] - boxes2[:, 0]) * (boxes2[:, 3] - boxes2[:, 1])

    # 交集的左上角取两个框左上角的较大值,右下角取较小值
    left_top = torch.max(boxes1[:, None, :2], boxes2[None, :, :2])      # (N, M, 2)
    right_bottom = torch.min(boxes1[:, None, 2:], boxes2[None, :, 2:])  # (N, M, 2)
    wh = (right_bottom - left_top).clamp(min=0)  # 不相交时宽或高为负,截断为 0
    inter = wh[..., 0] * wh[..., 1]

    return inter / (area1[:, None] + area2[None, :] - inter)


gt = torch.tensor([[100., 100., 300., 300.]])
pred = torch.tensor([
    [100., 100., 300., 300.],  # 完全重合
    [120., 120., 320., 320.],  # 偏移 20 像素
    [150., 150., 350., 350.],  # 偏移 50 像素
    [200., 200., 400., 400.],  # 偏移 100 像素
    [150., 150., 250., 250.],  # 在真实框内部
    [320., 100., 420., 300.],  # 不相交
])

print(iou(gt, pred))      # tensor([[1.0000, 0.6807, 0.3913, 0.1429, 0.2500, 0.0000]])
print(box_iou(gt, pred))  # tensor([[1.0000, 0.6807, 0.3913, 0.1429, 0.2500, 0.0000]])

# 格式传错不会报错:把 xywh 格式的框当作 xyxy 传入,框与自身的 IoU 都不是 1
xywh = torch.tensor([[100., 100., 50., 50.]])
print(box_iou(xywh, xywh))  # tensor([[0.]])

box_iou接收 (N, 4) 和 (M, 4) 两组框,返回 (N, M) 的矩阵,即每一对框的 IoU

真实框与预测框在六种相对位置下的 IoU

IoU 对位置偏差很敏感:边长 200 的框偏移 20 像素(边长的 10%),IoU 就降到 0.68;偏移 50 像素降到 0.39。评价时常用的阈值 0.5 并不宽松

GIoU、DIoU 与 CIoU

IoU 用作损失函数时有两个问题:两个框不相交时 IoU 恒为 0,无法反映相距多远;一个框包含另一个框时,无论小框在什么位置 IoU 都相同。下面三种改进在 IoU 的基础上减去一个惩罚项

名称在 IoU 基础上减去的惩罚项torchvision.ops中的函数
GIoU最小外接框中没有被两个框覆盖的面积所占的比例generalized_box_iou
DIoU两个框中心点距离的平方除以最小外接框对角线的平方distance_box_iou
CIoUDIoU 的惩罚项,再加上宽高比不一致的惩罚complete_box_iou

最小外接框是能同时包住两个框的最小矩形。三者的取值范围都从 IoU 的 0 到 1 扩展到了负数,两个框完全重合时都等于 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import torch
from torchvision.ops import box_iou, generalized_box_iou, distance_box_iou, complete_box_iou

gt = torch.tensor([[100., 100., 300., 300.]])
pred = torch.tensor([
    [150., 150., 350., 350.],  # 部分重叠
    [320., 100., 420., 300.],  # 不相交,相距 20 像素
    [500., 100., 600., 300.],  # 不相交,相距 200 像素
    [150., 150., 250., 250.],  # 在真实框内部,居中
    [100., 100., 200., 200.],  # 在真实框内部,靠左上角
    [150., 175., 250., 225.],  # 在真实框内部,居中,宽高比不同
])

for name, fn in [('IoU', box_iou), ('GIoU', generalized_box_iou), ('DIoU', distance_box_iou), ('CIoU', complete_box_iou)]:
    print(f'{name:5s}', ' '.join(f'{v:8.4f}' for v in fn(gt, pred)[0].tolist()))
1
2
3
4
IoU     0.3913   0.0000   0.0000   0.2500   0.2500   0.1250
GIoU    0.3113  -0.0625  -0.4000   0.2500   0.2500   0.1250
DIoU    0.3513  -0.2029  -0.4224   0.2500   0.1875   0.1250
CIoU    0.3513  -0.2046  -0.4241   0.2500   0.1875   0.1231
  • 第 2、3 列:两个不相交的框 IoU 都是 0,GIoU、DIoU、CIoU 则是相距越远数值越小
  • 第 4、5 列:预测框在真实框内部时,居中和靠角落的 IoU 与 GIoU 相同,DIoU 能区分
  • 第 6 列:中心重合但宽高比不同时,只有 CIoU 多了一项惩罚

非极大值抑制

计算方法

检测网络会在同一个目标附近输出多个位置相近的框。非极大值抑制(Non-Maximum Suppression,NMS)只保留其中分数最高的一个:

  1. 把所有框按分数从高到低排列
  2. 取出分数最高的框,保留
  3. 删除剩余的框中与它的 IoU 超过阈值的框
  4. 对剩下的框重复第 2、3 步,直到没有框为止
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import torch
from torchvision.ops import box_iou, nms


def simple_nms(boxes, scores, iou_threshold):
    order = scores.argsort(descending=True)  # 按分数从高到低排列
    keep = []
    while order.numel() > 0:
        best = order[0]
        keep.append(best.item())
        if order.numel() == 1:
            break
        # 与当前分数最高的框重叠超过阈值的框被抑制
        overlap = box_iou(boxes[best][None], boxes[order[1:]])[0]
        order = order[1:][overlap <= iou_threshold]
    return torch.tensor(keep)


boxes = torch.tensor([
    [100., 100., 300., 300.],  # 目标 1 附近的三个框
    [110., 105., 310., 295.],
    [ 90., 110., 290., 310.],
    [400., 150., 560., 330.],  # 目标 2 附近的三个框
    [410., 160., 570., 340.],
    [380., 140., 540., 310.],
    [250., 120., 450., 320.],  # 夹在两个目标之间的框
])
scores = torch.tensor([0.90, 0.75, 0.60, 0.85, 0.70, 0.55, 0.30])

print(simple_nms(boxes, scores, iou_threshold=0.5))  # tensor([0, 3, 6])
print(nms(boxes, scores, iou_threshold=0.5))         # tensor([0, 3, 6])

# 不同阈值下保留的框
for threshold in [0.1, 0.3, 0.5, 0.7, 0.9]:
    print(threshold, nms(boxes, scores, threshold).tolist())

nms返回的是保留下来的框的下标,按分数从高到低排列,不是框本身

非极大值抑制前有 7 个候选框,抑制后每个目标只保留分数最高的一个

IoU 阈值的影响

上面的代码最后在不同阈值下运行了nms,7 个框中,0、1、2 属于第一个目标,3、4、5 属于第二个目标,6 夹在两个目标之间,与框 0 的 IoU 为 0.13

1
2
3
4
5
0.1 [0, 3]
0.3 [0, 3, 6]
0.5 [0, 3, 6]
0.7 [0, 3, 5, 6]
0.9 [0, 3, 1, 4, 2, 5, 6]
阈值效果
过低相邻或互相遮挡的不同目标被当作重复框删除,如 0.1 时的框 6
过高同一个目标保留多个框,如 0.7 时的框 5、0.9 时的全部

常用的取值在 0.45 到 0.7 之间。目标密集、互相遮挡多的场景(人群、货架)适当调高,目标稀疏的场景可以调低

多个类别

nms不区分类别。两个类别不同的目标重叠在一起时(骑自行车的人和自行车),分数低的会被误删。batched_nms多接收一个类别编号,只在同一类别内部做抑制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import torch
from torchvision.ops import batched_nms, nms

# 两个几乎重合的框,类别不同(例如人和他骑的自行车)
boxes = torch.tensor([
    [100., 100., 300., 300.],
    [105., 105., 305., 305.],
    [102.,  98., 298., 302.],
])
scores = torch.tensor([0.90, 0.80, 0.70])
labels = torch.tensor([0, 1, 0])

print(nms(boxes, scores, iou_threshold=0.5))                  # tensor([0]),不同类别的框被误删
print(batched_nms(boxes, scores, labels, iou_threshold=0.5))  # tensor([0, 1])

# batched_nms 的做法:给每个类别的框加上一个足够大的偏移,使不同类别的框互不重叠
offset = labels[:, None] * (boxes.max() + 1)
print(nms(boxes + offset, scores, iou_threshold=0.5))         # tensor([0, 1])

锚框与无锚框

特征图上的格子

检测网络的骨干部分把图像逐步下采样,输入图像的边长与特征图的边长之比称为步幅。特征图上的一个格子对应输入图像上边长为步幅的一块区域,检测头在每个格子上各做一次预测。特征图大小的计算见PyTorch CNN

多数检测器同时使用几层不同步幅的特征图:步幅小的特征图格子密,负责小目标;步幅大的特征图感受野大,负责大目标。以 320x320 的输入为例

步幅特征图大小格子数负责的目标
840x401600小
1620x20400中
3210x10100大

锚框

锚框(anchor)是在每个格子中心预先放置的一组大小和宽高比固定的框。网络不直接预测边界框,而是预测每个锚框相对于真实框的偏移量,以及这个锚框属于各个类别的分数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import torch
from torchvision.ops import box_iou


def make_anchors(feature_size, stride, sizes, ratios):
    """在特征图的每个格子中心放置 len(sizes) * len(ratios) 个锚框,返回 (H * W * A, 4),xyxy 格式"""
    height, width = feature_size
    # 每个格子的中心在输入图像上的坐标
    cy = (torch.arange(height) + 0.5) * stride
    cx = (torch.arange(width) + 0.5) * stride
    cy, cx = torch.meshgrid(cy, cx, indexing='ij')
    centers = torch.stack([cx, cy], dim=-1).reshape(-1, 1, 2)  # (H * W, 1, 2)

    # 面积保持为 size 的平方,宽高比为 ratio(高 / 宽)
    sizes = torch.tensor(sizes, dtype=torch.float32)
    ratios = torch.tensor(ratios, dtype=torch.float32)
    w = (sizes[:, None] / ratios.sqrt()[None, :]).reshape(-1)
    h = (sizes[:, None] * ratios.sqrt()[None, :]).reshape(-1)
    half = torch.stack([w, h], dim=-1)[None] / 2               # (1, A, 2)

    return torch.cat([centers - half, centers + half], dim=-1).reshape(-1, 4)


# 320x320 的输入,步幅 16 的特征图大小为 20x20
anchors = make_anchors((20, 20), stride=16, sizes=[64, 128], ratios=[0.5, 1.0, 2.0])
print(anchors.shape)  # torch.Size([2400, 4])
print(anchors[:3])    # 第一个格子上边长为 64 的 3 个锚框

# 与真实框的 IoU 不低于 0.5 的锚框作为正样本
gt = torch.tensor([[100., 80., 220., 200.]])
overlap = box_iou(anchors, gt)[:, 0]
print((overlap >= 0.5).sum().item(), '/', len(anchors))  # 32 / 2400
1
2
3
4
5
torch.Size([2400, 4])
tensor([[-37.2548, -14.6274,  53.2548,  30.6274],
        [-24.0000, -24.0000,  40.0000,  40.0000],
        [-14.6274, -37.2548,  30.6274,  53.2548]])
32 / 2400

靠近图像边缘的锚框会超出图像,坐标为负是正常的。训练时用 IoU 把锚框分配给真实框,2400 个锚框中只有 32 个是正样本,其余都是背景,这种正负样本的极度不平衡正是后面焦点损失要解决的问题

无锚框

无锚框(anchor-free)的检测器不预设框,每个格子只对应一个点,直接预测这个点到边界框四条边的距离,或者预测中心点的位置和宽高

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import torch


def make_points(feature_size, stride):
    """特征图每个格子的中心在输入图像上的坐标,返回 (H * W, 2)"""
    height, width = feature_size
    cy = (torch.arange(height) + 0.5) * stride
    cx = (torch.arange(width) + 0.5) * stride
    cy, cx = torch.meshgrid(cy, cx, indexing='ij')
    return torch.stack([cx, cy], dim=-1).reshape(-1, 2)


points = make_points((20, 20), stride=16)
print(points.shape)  # torch.Size([400, 2])
print(points[:3].tolist())  # [[8.0, 8.0], [24.0, 8.0], [40.0, 8.0]]

# 落在真实框内部的点作为正样本,回归目标是该点到框四条边的距离
gt = torch.tensor([100., 80., 220., 200.])
left, top = points[:, 0] - gt[0], points[:, 1] - gt[1]
right, bottom = gt[2] - points[:, 0], gt[3] - points[:, 1]
distances = torch.stack([left, top, right, bottom], dim=-1)  # (H * W, 4)
inside = distances.min(dim=-1).values > 0
print(inside.sum().item(), '/', len(points))    # 56 / 400
print(points[inside][0], distances[inside][0])  # tensor([104.,  88.]) tensor([  4.,   8., 116., 112.])

锚框方式在每个格子放置多个预设框,无锚框方式每个格子一个点并回归到四条边的距离

对比项锚框无锚框
每个格子的预测多个,每个锚框一组一个
回归的内容相对于锚框的偏移和缩放到四条边的距离,或中心点和宽高
正样本的确定锚框与真实框的 IoU点是否落在真实框内或中心附近
需要设定锚框的大小和宽高比,与数据集有关不需要
代表模型Faster R-CNN、SSD、RetinaNet、YOLOv3 至 v5FCOS、CenterNet、YOLOX、YOLOv8 及之后

锚框的大小需要与数据集中目标的大小相符,否则没有锚框能与真实框达到足够的 IoU,这些目标就学不到。换数据集时需要重新统计,YOLOv5 的做法是对标注框的宽高做聚类。无锚框的方式省去了这一步,也减少了输出的数量,较新的检测器大多采用

检测头的输出与解码

输出的形状

单阶段检测器的检测头通常就是若干个卷积层,输出仍然是一张特征图,每个格子的通道中依次存放边界框和类别的预测值。把它变成边界框的过程称为解码

类型每个格子的通道数含义
无锚框4 + C4 个边界框参数,C 个类别分数
锚框,A 个A x (4 + C)每个锚框各一组
带目标置信度的锚框A x (5 + C)多一个表示是否有目标的分数,如 YOLOv5

下面以无锚框、三层特征图的检测头为例,用随机张量代替骨干网络的输出,完成从原始输出到最终结果的全过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import torch
from torch import nn
from torchvision.ops import batched_nms

torch.manual_seed(0)
num_classes = 3
strides = [8, 16, 32]

# 320x320 的输入经过骨干网络后得到的三层特征图,这里用随机张量代替
features = [torch.randn(2, 64, 320 // s, 320 // s) for s in strides]

# 检测头:每个位置输出 4 个距离和 num_classes 个类别分数
head = nn.Conv2d(64, 4 + num_classes, kernel_size=1)
outputs = [head(f) for f in features]
for out in outputs:
    print(out.shape)
# torch.Size([2, 7, 40, 40])
# torch.Size([2, 7, 20, 20])
# torch.Size([2, 7, 10, 10])


def decode(outputs, strides):
    boxes, logits = [], []
    for out, stride in zip(outputs, strides):
        n, c, h, w = out.shape
        out = out.permute(0, 2, 3, 1).reshape(n, h * w, c)  # (N, H * W, 4 + C)

        cy = (torch.arange(h) + 0.5) * stride
        cx = (torch.arange(w) + 0.5) * stride
        cy, cx = torch.meshgrid(cy, cx, indexing='ij')
        points = torch.stack([cx, cy], dim=-1).reshape(-1, 2)  # (H * W, 2)

        # 前 4 个通道是到左、上、右、下四条边的距离,以步幅为单位,取指数保证为正
        distances = out[..., :4].exp() * stride
        x1y1 = points - distances[..., :2]
        x2y2 = points + distances[..., 2:]
        boxes.append(torch.cat([x1y1, x2y2], dim=-1))
        logits.append(out[..., 4:])
    return torch.cat(boxes, dim=1), torch.cat(logits, dim=1)


boxes, logits = decode(outputs, strides)
print(boxes.shape, logits.shape)  # torch.Size([2, 2100, 4]) torch.Size([2, 2100, 3])


def postprocess(boxes, logits, score_threshold=0.5, iou_threshold=0.5, image_size=(320, 320)):
    """处理一张图像:boxes 为 (M, 4),logits 为 (M, C)"""
    scores, labels = logits.sigmoid().max(dim=-1)  # 每个位置取分数最高的类别
    keep = scores > score_threshold
    boxes, scores, labels = boxes[keep], scores[keep], labels[keep]

    # 把超出图像的部分裁掉
    boxes[:, 0::2] = boxes[:, 0::2].clamp(0, image_size[1])
    boxes[:, 1::2] = boxes[:, 1::2].clamp(0, image_size[0])

    keep = batched_nms(boxes, scores, labels, iou_threshold)
    return boxes[keep], scores[keep], labels[keep]


with torch.no_grad():
    for i in range(2):  # 每张图像保留的框数不同,只能逐张处理
        b, s, l = postprocess(boxes[i], logits[i])
        print(b.shape, s.shape, l.shape)
# torch.Size([1626, 4]) torch.Size([1626]) torch.Size([1626])
# torch.Size([1618, 4]) torch.Size([1618]) torch.Size([1618])

解码分为三步:

  1. 把每一层的输出从 (N, 4 + C, H, W) 变为 (N, H x W, 4 + C),每一行是一个格子的预测
  2. 生成每个格子的中心点坐标,与预测的距离组合成xyxy格式的框,乘以步幅还原到输入图像的尺度
  3. 把各层的结果拼接在一起。320x320 的输入共有 1600 + 400 + 100 = 2100 个预测

之后的后处理是所有检测器通用的:类别分数经过 sigmoid,按分数阈值过滤,再做非极大值抑制。这里的权重是随机的,分数没有意义,所以剩下的框很多;训练好的模型绝大多数位置的分数接近 0,过滤后只剩几个到几十个

  • permute不能省略。直接对 (N, 4 + C, H, W) 做reshape会把通道和空间位置混在一起,形状正确但内容是错的
  • 生成网格时torch.meshgrid要写indexing='ij',返回的顺序是先$y$后$x$,而框的坐标是先$x$后$y$,两者容易弄反。长宽不相等的输入最容易暴露这个问题
  • 每张图像保留下来的框数量不同,后处理只能逐张图像进行,结果是列表而不是一个张量

不同模型的解码公式不同(锚框的偏移、距离的分布等),导出为 ONNX 的模型有些已经包含解码,有些没有,使用前要看清输出的形状。例如 YOLOv8 导出后的输出为 (N, 4 + C, 8400),其中 8400 是 640x640 的输入在步幅 8、16、32 下的格子总数 6400 + 1600 + 400,边界框已经解码为cxcywh,但没有做非极大值抑制

损失函数

检测的损失由两部分相加:分类损失针对所有位置,边界框回归损失只针对正样本

分类:焦点损失

单阶段检测器的每个位置都要分类,其中绝大多数是容易区分的背景。每个背景的损失虽然小,但数量太多,加起来会淹没少数正样本的损失。焦点损失(Focal Loss)在交叉熵前乘以一个系数,把已经分对的样本的损失压低

\[FL(p_t) = -\alpha_t (1 - p_t)^{\gamma} \log(p_t)\]

其中$p_t$是模型对正确答案给出的概率,分得越对越接近 1,系数越接近 0

参数含义常用值
gamma压低容易样本的力度,0 时退化为交叉熵2
alpha正样本的权重,负样本的权重为1 - alpha,设为 -1 表示不使用0.25
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import torch
import torch.nn.functional as F
from torchvision.ops import sigmoid_focal_loss

torch.set_printoptions(precision=4, sci_mode=False)

# 一个正样本和四个负样本,后面的负样本预测得越来越差
probability = torch.tensor([0.9, 0.1, 0.3, 0.5, 0.9])
target = torch.tensor([1.0, 0.0, 0.0, 0.0, 0.0])
logits = torch.logit(probability)  # 损失函数接收的是 sigmoid 之前的值

bce = F.binary_cross_entropy_with_logits(logits, target, reduction='none')
focal = sigmoid_focal_loss(logits, target, alpha=0.25, gamma=2.0, reduction='none')
print(bce)    # tensor([0.1054, 0.1054, 0.3567, 0.6931, 2.3026])
print(focal)  # tensor([0.0003, 0.0008, 0.0241, 0.1300, 1.3988])

# 手动计算
p_t = torch.where(target == 1, probability, 1 - probability)  # 预测正确的概率
alpha_t = torch.where(target == 1, 0.25, 0.75)
print(alpha_t * (1 - p_t) ** 2 * bce)  # tensor([0.0003, 0.0008, 0.0241, 0.1300, 1.3988])

# 1 个没预测好的正样本和 2399 个容易的负样本
logits = torch.cat([torch.tensor([-1.0]), torch.full((2399,), -3.0)])
target = torch.zeros(2400)
target[0] = 1.0
bce = F.binary_cross_entropy_with_logits(logits, target, reduction='none')
focal = sigmoid_focal_loss(logits, target, alpha=0.25, gamma=2.0, reduction='none')
print(f'BCE    正样本 {bce[0]:.4f}  负样本合计 {bce[1:].sum():.4f}')
print(f'Focal  正样本 {focal[0]:.4f}  负样本合计 {focal[1:].sum():.4f}')
1
2
3
4
5
tensor([0.1054, 0.1054, 0.3567, 0.6931, 2.3026])
tensor([0.0003, 0.0008, 0.0241, 0.1300, 1.3988])
tensor([0.0003, 0.0008, 0.0241, 0.1300, 1.3988])
BCE    正样本 1.3133  负样本合计 116.5610
Focal  正样本 0.1755  负样本合计 0.1966

预测概率为 0.1 的容易负样本,损失从 0.1054 降到 0.0008,缩小了一百多倍;预测错的负样本(概率 0.9)只从 2.3026 降到 1.3988。在 1 个正样本对 2399 个负样本的例子中,交叉熵下负样本的损失之和是正样本的近 90 倍,焦点损失下两者相当

  • sigmoid_focal_loss接收的是 sigmoid 之前的值,不要先做 sigmoid
  • targets是与输入形状相同的浮点张量(每个类别一个 0 或 1),不是类别编号
  • reduction默认为'none',返回每个元素的损失。通常求和后除以正样本的个数,而不是取平均,否则损失会被大量负样本稀释

边界框回归

损失计算方式特点
L1坐标差的绝对值与框的大小有关,大目标的损失大
Smooth L1差值小时为平方,差值大时为绝对值对异常值不敏感,Faster R-CNN、SSD 使用
IoU 类(GIoU、DIoU、CIoU)1 减去对应的 IoU与框的大小无关,直接优化评价指标
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import torch
import torch.nn.functional as F
from torchvision.ops import box_iou, complete_box_iou_loss, distance_box_iou_loss, generalized_box_iou_loss

# 一大一小两个目标,预测框都向右下偏移了边长的 10%
target = torch.tensor([[100., 100., 300., 300.], [100., 100., 120., 120.]])
pred = torch.tensor([[120., 120., 320., 320.], [102., 102., 122., 122.]])

print(F.l1_loss(pred, target, reduction='none').sum(dim=1))         # tensor([80.,  8.])
print(F.smooth_l1_loss(pred, target, reduction='none').sum(dim=1))  # tensor([78.,  6.])
print(generalized_box_iou_loss(pred, target))                       # tensor([0.3359, 0.3359])
print(distance_box_iou_loss(pred, target))                          # tensor([0.3276, 0.3276])
print(complete_box_iou_loss(pred, target))                          # tensor([0.3276, 0.3276])

# 预测框与真实框不相交时,1 - IoU 的梯度为 0,GIoU 损失仍然有梯度
target = torch.tensor([[100., 100., 300., 300.]])
pred = torch.tensor([[320., 120., 420., 280.]], requires_grad=True)

iou_loss = 1 - box_iou(pred, target)[0, 0]
print(iou_loss.item(), torch.autograd.grad(iou_loss, pred)[0])

giou_loss = generalized_box_iou_loss(pred, target, reduction='sum')
print(giou_loss.item(), torch.autograd.grad(giou_loss, pred)[0])
1
2
3
4
5
6
7
tensor([80.,  8.])
tensor([78.,  6.])
tensor([0.3359, 0.3359])
tensor([0.3276, 0.3276])
tensor([0.3276, 0.3276])
1.0 tensor([[0., 0., 0., 0.]])
1.125 tensor([[ 0.0025,  0.0016,  0.0002, -0.0016]])

两个预测框偏移的比例相同,L1 损失相差 10 倍,IoU 类的损失则完全相同。使用 L1 类损失的检测器通常不直接回归坐标,而是回归相对于锚框大小归一化后的偏移量,以消除尺度的影响

预测框与真实框不相交时,1 - IoU 的梯度全为 0,网络得不到任何更新方向;GIoU 损失仍然有梯度,其中左边界的梯度为正,梯度下降会让左边界向左移动,靠近真实框

generalized_box_iou_loss等三个函数接收的是一一对应的两组框,形状都是 (N, 4),返回 (N,);generalized_box_iou等函数则返回 (N, M) 的矩阵。两组函数名字相近,用途不同

评价指标 mAP

TP、FP 与 FN

分类任务中精确率和召回率的含义见Evaluation Metrics。检测任务没有现成的对错,需要先用 IoU 把预测框与真实框配对

名称含义
TP预测框与一个尚未被匹配的同类别真实框的 IoU 不低于阈值
FP预测框没有匹配到真实框,或者匹配的真实框已经被分数更高的框占用
FN没有被任何预测框匹配到的真实框

精确率是 TP 占所有预测框的比例,召回率是 TP 占所有真实框的比例。检测任务中没有 TN,因为不存在可以计数的背景框

AP 的计算

精确率和召回率都依赖分数阈值:阈值高,留下的框少而准;阈值低,找到的目标多但误检也多。AP(Average Precision)把所有阈值都考虑进来:

  1. 把一个类别在所有图像上的预测框放在一起,按分数从高到低排列
  2. 从前往后逐个加入预测框,每加入一个就计算一次精确率和召回率,得到精确率-召回率曲线
  3. 把曲线上每个点的精确率替换为它右侧的最大值,得到单调下降的包络
  4. AP 是包络下的面积
\[AP = \sum_{k} (r_k - r_{k-1}) \cdot \max_{j \ge k} p_j\]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import torch
from torchvision.ops import box_iou

# 3 张图像、1 个类别、共 5 个真实框
targets = [
    torch.tensor([[50., 50., 150., 150.], [200., 80., 300., 200.]]),
    torch.tensor([[30., 40., 130., 160.], [180., 60., 280., 180.]]),
    torch.tensor([[100., 100., 220., 220.]]),
]
# 每张图像的预测框和分数
predictions = [
    (torch.tensor([[52., 48., 152., 148.], [210., 100., 310., 215.], [400., 300., 460., 380.]]),
     torch.tensor([0.95, 0.60, 0.80])),
    (torch.tensor([[35., 45., 128., 150.], [60., 70., 160., 190.], [185., 70., 290., 185.]]),
     torch.tensor([0.90, 0.70, 0.50])),
    (torch.tensor([[120., 125., 240., 245.], [300., 50., 360., 110.]]),
     torch.tensor([0.85, 0.40])),
]


def match(predictions, targets, iou_threshold):
    """判断每个预测框是 TP 还是 FP,返回所有图像的分数和 TP 标记"""
    all_scores, all_tp = [], []
    for (boxes, scores), gt in zip(predictions, targets):
        overlap = box_iou(boxes, gt)  # (预测框个数, 真实框个数)
        used = torch.zeros(len(gt), dtype=torch.bool)
        tp = torch.zeros(len(boxes), dtype=torch.bool)
        for i in scores.argsort(descending=True):  # 分数高的预测框优先匹配
            candidate = overlap[i].clone()
            candidate[used] = 0                    # 每个真实框只能被匹配一次
            best = candidate.argmax()
            if candidate[best] >= iou_threshold:
                tp[i] = True
                used[best] = True
        all_scores.append(scores)
        all_tp.append(tp)
    return torch.cat(all_scores), torch.cat(all_tp)


def average_precision(scores, tp, num_targets):
    order = scores.argsort(descending=True)
    tp = tp[order].float()
    tp_count = tp.cumsum(0)
    fp_count = (1 - tp).cumsum(0)
    recall = tp_count / num_targets
    precision = tp_count / (tp_count + fp_count)

    # 包络:每个位置的精确率取它右侧的最大值,消除曲线的锯齿
    envelope = precision.flip(0).cummax(0).values.flip(0)
    # 包络下的面积
    previous = torch.cat([torch.zeros(1), recall[:-1]])
    ap = ((recall - previous) * envelope).sum()
    return ap.item(), recall, precision, envelope


num_targets = sum(len(gt) for gt in targets)
scores, tp = match(predictions, targets, iou_threshold=0.5)
ap, recall, precision, envelope = average_precision(scores, tp, num_targets)

order = scores.argsort(descending=True)
print('分数  ', [round(v, 2) for v in scores[order].tolist()])
print('TP    ', tp[order].int().tolist())
print('精确率', [round(v, 2) for v in precision.tolist()])
print('召回率', [round(v, 2) for v in recall.tolist()])
print(f'AP50 = {ap:.4f}')

# 在多个 IoU 阈值下分别计算再取平均
thresholds = [0.5 + 0.05 * i for i in range(10)]
aps = [average_precision(*match(predictions, targets, t), num_targets)[0] for t in thresholds]
print('各阈值的 AP', [round(v, 3) for v in aps])
print(f'AP75 = {aps[5]:.4f}')
print(f'AP@[0.5:0.95] = {sum(aps) / len(aps):.4f}')
1
2
3
4
5
6
7
8
分数   [0.95, 0.9, 0.85, 0.8, 0.7, 0.6, 0.5, 0.4]
TP     [1, 1, 0, 0, 0, 1, 1, 0]
精确率 [1.0, 1.0, 0.67, 0.5, 0.4, 0.5, 0.57, 0.5]
召回率 [0.2, 0.4, 0.4, 0.4, 0.4, 0.6, 0.8, 0.8]
AP50 = 0.6286
各阈值的 AP [0.629, 0.629, 0.629, 0.486, 0.486, 0.486, 0.4, 0.2, 0.2, 0.0]
AP75 = 0.4857
AP@[0.5:0.95] = 0.4143

精确率-召回率曲线及其包络,包络下的面积即为 AP

  • 分数 0.85 的预测框与真实框的 IoU 为 0.49,看上去框住了目标,在 0.5 的阈值下仍然是 FP
  • 分数 0.70 的预测框与一个真实框的 IoU 只有 0.36,而且该真实框已经被分数 0.90 的框匹配,是 FP。没有做好非极大值抑制时,重复的框都会像这样被计为 FP
  • 5 个真实框中有 1 个始终没有被检测到,召回率最高只到 0.8,曲线在 0.8 之后的面积为 0
  • 排在前面的 FP 比排在后面的 FP 对 AP 的影响大得多,所以分数的排序质量与框的位置同样重要

常用的指标

指标含义
AP50IoU 阈值为 0.5 时的 AP,Pascal VOC 使用
AP75IoU 阈值为 0.75 时的 AP,对定位精度要求更高
AP@[0.5:0.95]阈值从 0.5 到 0.95 每隔 0.05 取一个,10 个 AP 的平均,COCO 的主要指标
mAP对每个类别分别计算 AP,再对所有类别取平均
AP small / medium / large按目标面积分组统计,分界为 32x32 和 96x96 像素

COCO 中通常把 mAP 直接写作 AP,指的是对类别和 10 个 IoU 阈值都取平均后的结果

使用 torchmetrics

torchmetrics.detection.MeanAveragePrecision实现了 COCO 的评价方式,需要另外安装pycocotools。下面用它计算与手动计算时相同的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import torch
from torchmetrics.detection import MeanAveragePrecision

# 与上面手动计算时相同的数据
targets = [
    torch.tensor([[50., 50., 150., 150.], [200., 80., 300., 200.]]),
    torch.tensor([[30., 40., 130., 160.], [180., 60., 280., 180.]]),
    torch.tensor([[100., 100., 220., 220.]]),
]
predictions = [
    (torch.tensor([[52., 48., 152., 148.], [210., 100., 310., 215.], [400., 300., 460., 380.]]),
     torch.tensor([0.95, 0.60, 0.80])),
    (torch.tensor([[35., 45., 128., 150.], [60., 70., 160., 190.], [185., 70., 290., 185.]]),
     torch.tensor([0.90, 0.70, 0.50])),
    (torch.tensor([[120., 125., 240., 245.], [300., 50., 360., 110.]]),
     torch.tensor([0.85, 0.40])),
]

# 预测和标注都是字典的列表,每张图像一个字典,这里只有一个类别,编号为 0
preds = [{ 'boxes': boxes, 'scores': scores, 'labels': torch.zeros(len(boxes), dtype=torch.long) } for boxes, scores in predictions]
target = [{ 'boxes': boxes, 'labels': torch.zeros(len(boxes), dtype=torch.long) } for boxes in targets]

metric = MeanAveragePrecision(box_format='xyxy')
metric.update(preds, target)
result = metric.compute()
for key in ['map_50', 'map_75', 'map', 'mar_100']:
    print(f'{key:8s}{result[key].item():.4f}')
1
2
3
4
map_50  0.6266
map_75  0.4866
map     0.4161
mar_100 0.5000
指标手动计算torchmetrics
AP500.62860.6266
AP750.48570.4866
AP@[0.5:0.95]0.41430.4161

两者在小数点后第三位有差别,原因是面积的算法不同:COCO 的官方实现不直接计算包络下的面积,而是在 0 到 1 之间取 101 个等间隔的召回率,对包络采样后取平均。比较不同模型时要使用同一套评价代码

mar_100是每张图像最多取 100 个预测框时的平均召回率,同样对 10 个 IoU 阈值取平均。这个例子中各阈值下的召回率依次为 0.8、0.8、0.8、0.6、0.6、0.6、0.4、0.2、0.2、0,平均为 0.5

使用预训练检测器

推理

torchvision.models.detection提供了在 COCO 上预训练的检测模型,后处理已经包含在模型内部,输出直接是最终的检测结果

模型类型特点
fasterrcnn_resnet50_fpn_v2两阶段,锚框精度高,速度慢
fasterrcnn_mobilenet_v3_large_320_fpn两阶段,锚框轻量,适合 CPU
retinanet_resnet50_fpn_v2单阶段,锚框使用焦点损失
fcos_resnet50_fpn单阶段,无锚框回归到四条边的距离
ssdlite320_mobilenet_v3_large单阶段,锚框最轻量

为了让结果可以重复,下面用代码画一张合成的街景作为测试图像,左边是红绿灯,右边是停车标志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import math

from PIL import Image, ImageDraw


def make_scene(width=512, height=600):
    """画一张合成的街景:左边是红绿灯,右边是停车标志"""
    image = Image.new('RGB', (width, height), (150, 190, 225))  # 天空
    draw = ImageDraw.Draw(image)
    draw.rectangle([0, 430, width, height], fill=(120, 125, 130))  # 路面

    # 红绿灯:灯杆、灯箱和三盏灯,只有红灯亮
    draw.rectangle([100, 330, 120, 490], fill=(60, 60, 60))
    draw.rounded_rectangle([60, 60, 160, 330], radius=20, fill=(35, 35, 35))
    for center_y, color in zip([105, 195, 285], [(230, 40, 40), (90, 80, 20), (20, 80, 30)]):
        draw.ellipse([78, center_y - 32, 142, center_y + 32], fill=color)

    # 停车标志:立柱、白边和红色的八边形
    def octagon(radius):
        return [(340 + radius * math.cos(math.radians(22.5 + 45 * k)), 230 + radius * math.sin(math.radians(22.5 + 45 * k))) for k in range(8)]

    draw.rectangle([334, 230, 346, 520], fill=(170, 170, 170))
    draw.polygon(octagon(140), fill=(255, 255, 255))
    draw.polygon(octagon(129), fill=(200, 25, 30))
    return image


if __name__ == '__main__':
    make_scene().save('scene.png')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import torch
from torchvision.models.detection import FasterRCNN_MobileNet_V3_Large_320_FPN_Weights, fasterrcnn_mobilenet_v3_large_320_fpn
from torchvision.transforms.functional import pil_to_tensor

from scene import make_scene

weights = FasterRCNN_MobileNet_V3_Large_320_FPN_Weights.DEFAULT
model = fasterrcnn_mobilenet_v3_large_320_fpn(weights=weights)
model.eval()

image = make_scene()  # 合成的街景,宽 512、高 600
x = pil_to_tensor(image).float() / 255  # (3, H, W),取值范围 [0, 1]
print(x.shape)  # torch.Size([3, 600, 512])

with torch.no_grad():
    outputs = model([x])  # 输入是图像的列表,每张图像的大小可以不同

output = outputs[0]       # 每张图像对应一个字典
for key, value in output.items():
    print(key, value.shape, value.dtype)

categories = weights.meta['categories']
keep = output['scores'] > 0.5
for box, label, score in zip(output['boxes'][keep], output['labels'][keep], output['scores'][keep]):
    print(f'{categories[label]:15s}{score:.3f}  {[round(v) for v in box.tolist()]}')
1
2
3
4
5
6
torch.Size([3, 600, 512])
boxes torch.Size([2, 4]) torch.float32
labels torch.Size([2]) torch.int64
scores torch.Size([2]) torch.float32
stop sign      0.997  [215, 96, 471, 359]
traffic light  0.965  [54, 52, 165, 342]
键形状含义
boxes(K, 4)xyxy格式的绝对坐标,属于输入图像的坐标系
labels(K,)类别编号,用weights.meta['categories']查名称
scores(K,)置信度分数,已经从高到低排列

模型默认返回分数高于 0.05 的所有结果。这张合成图像很干净,只返回了 2 个框;真实图像上会有很多低分的误检,使用前需要按分数再过滤一次。阈值根据应用决定:漏检代价高时调低,误检代价高时调高

输入的要求

输入结果
取值在 0 到 1 之间的浮点张量正确,检测到 stop sign 和 traffic light
取值在 0 到 255 之间的浮点张量不报错,没有返回任何检测结果
自己先做了 ImageNet 的标准化不报错,stop sign 的分数降到 0.979,traffic light 降到 0.276,按 0.5 过滤后漏检
uint8张量抛出TypeError

模型内部已经包含标准化和缩放,输入只需要除以 255。 重复做标准化或者忘记除以 255 都不会报错,只是结果不对,这是使用这些模型时最常见的错误

  • 输入是三维张量的列表而不是一个四维张量,列表中的图像大小可以不同,模型内部会缩放并填充成一个批次
  • 类别表共 91 项,编号 0 是__background__,中间有若干N/A占位,实际只有 80 个类别。在自己的数据集上微调时,类别编号同样要从 1 开始,0 留给背景
  • 必须调用model.eval()。训练模式下模型的调用方式是model(images, targets),返回的是各项损失的字典而不是检测结果
  • 训练时targets中的边界框为xyxy格式的绝对坐标,宽或高为 0 的框会直接抛出AssertionError,数据增强后要把这样的框去掉

创建模型时可以修改后处理的参数

参数默认值含义
box_score_thresh0.05低于该分数的结果不返回
box_nms_thresh0.5非极大值抑制的 IoU 阈值
box_detections_per_img100每张图像最多返回的结果个数

坐标映射

缩放与填充

检测网络的输入大小是固定的,相机图像需要先缩放。torchvision的检测模型在内部完成缩放,并把结果映射回原图;而自己做预处理的模型(导出为 ONNX 的模型、YOLO 系列)输出的坐标属于缩放后的图像,需要自己映射回去

方式做法映射回原图
直接拉伸宽和高分别缩放到目标大小$x$和$y$分别除以各自的缩放比例
等比例缩放加填充按较小的比例缩放,不足的部分用灰色填充先减去填充量,再除以同一个缩放比例

直接拉伸会改变目标的宽高比,1920x1080 的图像拉伸成正方形后,目标在水平方向被压扁近一半。等比例缩放加填充(letterbox)保持宽高比不变,是 YOLO 系列的默认做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import torch
import torch.nn.functional as F


def letterbox(image, size, fill=0.5):
    """把 (3, H, W) 的图像等比例缩放后填充到 size x size,返回新图像、缩放比例和左上角的填充量"""
    height, width = image.shape[1:]
    scale = min(size / height, size / width)
    new_height, new_width = round(height * scale), round(width * scale)
    resized = F.interpolate(image[None], size=(new_height, new_width), mode='bilinear', align_corners=False)[0]

    pad_left = (size - new_width) // 2
    pad_top = (size - new_height) // 2
    padding = (pad_left, size - new_width - pad_left, pad_top, size - new_height - pad_top)  # 左、右、上、下
    return F.pad(resized, padding, value=fill), scale, (pad_left, pad_top)


def to_letterbox(boxes, scale, pad):
    """原图上的框 -> 网络输入上的框:先缩放再平移"""
    offset = torch.tensor([pad[0], pad[1], pad[0], pad[1]])
    return boxes * scale + offset


def to_original(boxes, scale, pad, original_size):
    """网络输入上的框 -> 原图上的框:先减去填充再除以缩放比例,最后裁剪到图像范围内"""
    offset = torch.tensor([pad[0], pad[1], pad[0], pad[1]])
    boxes = (boxes - offset) / scale
    height, width = original_size
    boxes[:, 0::2] = boxes[:, 0::2].clamp(0, width)
    boxes[:, 1::2] = boxes[:, 1::2].clamp(0, height)
    return boxes


if __name__ == '__main__':
    image = torch.rand(3, 1080, 1920)  # 1920x1080 的相机图像
    resized, scale, pad = letterbox(image, 640)
    print(resized.shape, scale, pad)  # torch.Size([3, 640, 640]) 0.3333333333333333 (0, 140)

    boxes = torch.tensor([[600., 300., 1200., 900.]])
    mapped = to_letterbox(boxes, scale, pad)
    print(mapped)                                         # tensor([[200., 240., 400., 440.]])
    print(to_original(mapped, scale, pad, (1080, 1920)))  # tensor([[ 600.,  300., 1200.,  900.]])

映射回原图时的顺序与预处理相反:预处理是先缩放再平移,还原时先减去填充量再除以缩放比例。顺序弄反或者忘记减去填充,得到的框会整体偏移,而且偏移量随图像的宽高比变化,在正方形的测试图像上发现不了

在检测器上验证

把同一张图像直接送入检测器,和先填充到 320x320 再检测并映射回原图,两种方式得到的框应当基本一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import torch
from torchvision.models.detection import FasterRCNN_MobileNet_V3_Large_320_FPN_Weights, fasterrcnn_mobilenet_v3_large_320_fpn
from torchvision.transforms.functional import pil_to_tensor

from letterbox import letterbox, to_original
from scene import make_scene

weights = FasterRCNN_MobileNet_V3_Large_320_FPN_Weights.DEFAULT
model = fasterrcnn_mobilenet_v3_large_320_fpn(weights=weights, box_score_thresh=0.5)
model.eval()

image = make_scene()
x = pil_to_tensor(image).float() / 255      # (3, 600, 512)
resized, scale, pad = letterbox(x, 320)
print(resized.shape, round(scale, 4), pad)  # torch.Size([3, 320, 320]) 0.5333 (23, 0)

with torch.no_grad():
    direct = model([x])[0]['boxes']        # 直接在原图上检测
    boxes = model([resized])[0]['boxes']   # 在填充后的图像上检测,坐标属于 320x320 的图像

print(boxes.round())
print(to_original(boxes, scale, pad, x.shape[1:]).round())
print(direct.round())
1
2
3
4
5
6
7
torch.Size([3, 320, 320]) 0.5333 (23, 0)
tensor([[138.,  52., 272., 194.],
        [ 52.,  34., 109., 184.]])
tensor([[215.,  98., 466., 364.],
        [ 55.,  63., 162., 344.]])
tensor([[215.,  96., 471., 359.],
        [ 54.,  52., 165., 342.]])

三组结果依次为 320x320 输入上的框、映射回原图后的框、直接在原图上检测的框。后两组最多相差 11 个像素,是因为两次送入网络的图像本身不同

原图经过等比例缩放和填充后送入检测器,再把检测框映射回原图

与相机参数配合

相机的内参是在原始分辨率下标定的,用检测框计算目标的方位角或进行测距之前,必须先把框映射回原图的坐标系(或者把内参按同样的缩放和平移进行换算)。视场角与焦距的关系见Field of View

1
2
3
4
5
6
7
8
9
10
11
import math

# 1920x1080 的相机,水平视场角 90 度,主点在图像中心
width, fov = 1920, 90
fx = width / 2 / math.tan(math.radians(fov) / 2)  # 以像素为单位的焦距
cx = width / 2

box = [600, 300, 1200, 900]  # 原图坐标系下的检测框
u = (box[0] + box[2]) / 2    # 框中心的横坐标
angle = math.degrees(math.atan((u - cx) / fx))
print(round(fx, 1), round(angle, 2))  # 960.0 -3.58,目标在光轴左侧 3.58 度

如果直接使用 640x640 输入上的坐标,框中心的横坐标是 300,按原图的内参会算出 -34.51 度,结果完全错误

This post is licensed under CC BY 4.0 by the author.