Post

Object Detection Inference

整理 torchvision 预训练检测器的使用与输入要求,以及缩放、填充后把检测框映射回原图并与相机参数配合的方法

Object Detection Inference
深度学习系列:第 18 篇,共 23 篇

用 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. Object Detection Inference
  4. Image Segmentation
  5. Keypoint Detection
  6. Generative Models
部署
  1. PyTorch Deployment
  2. Model Compression

Object Detection整理了检测模型内部的各个组成部分。这里整理把检测器用起来时要处理的事情:怎样调用预训练模型、输入要满足什么要求,以及网络输入上的检测框怎样换算回原始图像的坐标

使用预训练检测器

推理

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.