TorchVision Object Detection Finetuning Tutorial
在本教程中,我们将在Penn-Fudan数据库上微调一个预先训练的Mask R-CNN模型,用于行人检测和分割。它包含170张带有345个行人实例的图像,我们将用它来说明如何使用torchvision中的新功能,以便在自定义数据集中训练对象检测和实例分割模型
1. 如何定义数据集
用于训练对象检测、实例分割和人员关键点检测的参考脚本可以轻松支持添加新的自定义数据集。数据集应该继承标准torch.utils.data.Dataset类,并实现__len__和__getitem__函数,特别是__getitem__函数,需要我们返回一个特殊的元组。
- Image: 形状为[3, H, W]的
torchvision.tv_tensors.Image的Tensor张量类型,或者是形状为(H, W) 的PIL图类型 - target:一个包含下列内容的字典:
boxes(边界框)类型为
torchvision.tv_tensors.BoundingBoxes,形状为[N, 4],表示N个边界框的坐标,格式为[x0, y0, x1, y1],范围在0到W(宽度)和0到H(高度)之间。labels(标签):整数类型的
torch.Tensor,形状为[N],表示每个边界框的类别标签,其中0始终代表背景类别。image_id(图像ID):
int类型,表示图像的唯一标识符,在数据集中所有图像之间应保持唯一,用于评估阶段。area(面积):
float类型的torch.Tensor,形状为[N],表示每个边界框的面积。该字段用于 COCO 评估指标,以区分小、中、大三种尺寸的边界框得分。iscrowd(是否拥挤):
uint8类型的torch.Tensor,形状为[N]。若iscrowd=True,则在评估阶段会忽略该实例。(可选)masks(掩码):
torchvision.tv_tensors.Mask,形状为[N, H, W],表示每个对象的语义分割掩码
如果您的数据集符合上述要求,那么它将适用于参考脚本中的培训和评估代码。评估代码将使用pycocotools的脚本,在window上可以使用pip安装pycocotools安装。
1 | pip install git+https://github.com/gautamchitnis/cocoapi.git@cocodataset-master#subdirectory=PythonAPI |
模型的类别标签中,0默认代表背景类。假设只有cat和dog两类,我们可以用1代表cat,2代表dog,如果一个图片中包含cat和dog,那么labels应该是[1,2]而不是[0,1]
在训练时,让每个batch仅包含宽高比相似的图片,减少padding计算,提高效率。推荐使用get_height_and_width的方法返回图片的height和width,避免在分组时反复调用__getitem(否则会加载图像到内存,降低速度)
2. 创建自定义数据集
你可以在这里下载数据集,或者直接使用下列命令
1 | wget https://www.cis.upenn.edu/~jshi/ped_html/PennFudanPed.zip -P data |
我们现在利用matplotlib来查看一对数据:
1 | import matplotlib.pyplot as plt |
每个图像都有一个对应的分割掩码(segmentation mask),其中不同颜色代表不同的实例(instance)。接下来,我们为这个数据集编写一个 torch.utils.data.Dataset 类。
在下面的代码中,我们将 图像(images)、边界框(bounding boxes) 和 掩码(masks) 封装到 torchvision.tv_tensors.TVTensor 类中,以便能够针对目标检测和分割任务应用 TorchVision 内置的变换(新版的 Transforms API)。具体来说:
- 图像张量 会被封装为
torchvision.tv_tensors.Image - 边界框 会被封装为
torchvision.tv_tensors.BoundingBoxes - 掩码 会被封装为
torchvision.tv_tensors.Mask
由于torchvision.tv_tensors.TVTensor是torch.Tensor的子类,封装后的对象仍然是张量,并继承了普通torch.Tensor的 API。有关torchvision.tv_tensors的更多信息,请参阅 官方文档。
1 | import os |
关键代码解释
1 | obj_ids = torch.unique(mask) |
详解
好的,我们用一个具体的例子来一步步解释这段代码。
想象一下,我们有一张 4x5 像素的图像,里面有一个苹果和一个香蕉。在我们的标注数据中,我们用一个叫做 mask 的二维数组(张量)来表示它们的位置。
- 背景像素的值是
0。 - 苹果的所有像素值都是
10。 - 香蕉的所有像素值都是
20。
初始状态:我们的 mask
我们的输入 mask 张量看起来是这样的:
1 | mask = tensor([[ 0, 0, 10, 10, 0], |
这是一个 4x5 的张量。现在我们来逐行执行代码。
第1步: obj_ids = torch.unique(mask)
- 功能: 找到
mask中所有不重复的像素值。 - 解释:
torch.unique会扫描整个mask张量,并返回一个包含所有出现过的唯一值的新张量(通常是排序后的)。 - 执行结果:
mask中有0,10,20这三种值。- 所以,
obj_ids会变成tensor([ 0, 10, 20])。
第2步: obj_ids = obj_ids[1:]
- 功能: 去掉第一个元素。
- 解释: 这行代码假设第一个唯一值(通常是
0)代表背景。我们只关心真正的物体,所以我们通过切片[1:](从索引1开始取到最后) 来移除背景ID。 - 执行结果:
- 原始
obj_ids是tensor([ 0, 10, 20])。 - 去掉索引为0的元素
0后,obj_ids现在是tensor([10, 20])。这代表了我们图像中所有物体的ID列表。
- 原始
第3步: num_objs = len(obj_ids)
- 功能: 计算物体的数量。
- 解释:
len()函数返回张量中元素的数量。 - 执行结果:
obj_ids是tensor([10, 20]),它包含2个元素。- 所以,
num_objs的值是2。
第4步: masks = (mask == obj_ids[:, None, None]).to(dtype=torch.uint8)
这是最关键的一步,我们把它分解来看。
obj_ids[:, None, None]:- 这是一个广播(Broadcasting)技巧。它改变了
obj_ids的形状,以便和二维的mask进行比较。 obj_ids的原始形状是(2,)。obj_ids[:, None, None]将其形状变为(2, 1, 1)。- 你可以想象它从
[10, 20]变成了[[[10]], [[20]]]。
- 这是一个广播(Broadcasting)技巧。它改变了
mask == obj_ids[:, None, None]:- 这里进行的是一个元素级的比较。由于广播机制,PyTorch 会将
mask(形状(4, 5))与obj_ids(形状(2, 1, 1))进行比较。 - 实际上,这会执行两次比较,每次使用
obj_ids中的一个ID:- 第一次比较:
mask == 10(将mask的每个像素和10比较) - 第二次比较:
mask == 20(将mask的每个像素和20比较)
- 第一次比较:
- 这个操作的结果是一个布尔类型(
True/False)的张量,形状为(2, 4, 5)。
我们来看看结果的第一部分(对应ID 10,苹果的掩码):
1
2
3
4tensor([[False, False, True, True, False],
[False, True, True, True, False],
[False, False, False, False, False],
[False, False, False, False, False]])结果的第二部分(对应ID 20,香蕉的掩码):
1
2
3
4tensor([[False, False, False, False, False],
[False, False, False, False, False],
[False, True, True, False, False],
[ True, True, True, False, False]])这两部分被堆叠在一起,形成一个
(2, 4, 5)的张量。- 这里进行的是一个元素级的比较。由于广播机制,PyTorch 会将
.to(dtype=torch.uint8):这个操作将布尔值
True和False转换为整数1和0。这是大多数深度学习框架中表示二进制掩码的标准方式。最终结果
masks:
这是一个形状为(2, 4, 5)的张量,其中包含了两个独立的二进制掩码。第一个掩码 (苹果):
1
2
3
4tensor([[0, 0, 1, 1, 0],
[0, 1, 1, 1, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0]], dtype=torch.uint8)第二个掩码 (香蕉):
1
2
3
4tensor([[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 1, 1, 0, 0],
[1, 1, 1, 0, 0]], dtype=torch.uint8)
1 | masks = (mask == obj_ids[:, None, None]).to(dtype=torch.uint8) |
- 功能: 这行代码的作用是将一个多类别、颜色编码的掩码图像(
mask)转换成一组二进制掩码 - 解释:
mask:通常是一个二维数组(代表图像),其中每个像素的值代表一个物体的ID或者类别。背景通常为0。obj_ids:这是一个包含图像中所有物体唯一ID的一维张量。obj_ids[:, None, None]:这是一种广播(broadcasting)技巧。它将一维的obj_ids扩展成三维,以便能和二维的mask进行逐像素比较。(mask == obj_ids[:, None, None]):这个比较操作会为obj_ids中的每一个物体ID生成一个布尔类型的二进制掩码。如果mask中的像素值等于某个物体ID,那么在对应的二进制掩码中,该像素位置为True,否则为False。最终生成的masks是一个三维张量,形状为[N, H, W],其中N是物体的数量,H和W是图像的高和宽。.to(dtype=torch.uint8):将布尔类型的掩码转换为无符号8位整型(0和1),这是PyTorch中常用的掩码格式。
1 | # 获取每个掩码的边界框坐标 |
- 功能: 这行代码调用
masks_to_boxes函数,为上一部生成的每个二进制掩码计算出其最小外接边界框。 - 解释:
masks_to_boxes是torchvision.ops模块中的一个函数。- 它接收一个形状为
[N, H, W]的掩码张量,并返回一个形状为[N, 4]的张量,其中每一行代表一个边界框,格式为(x1, y1, x2, y2),即左上角和右下角的坐标。
1 | # 只有一个类别 |
- 功能: 为每个检测到的物体分配一个标签。
- 解释:
num_objs:图像中物体的数量。torch.ones((num_objs,), dtype=torch.int64):创建一个长度为num_objs的张量,所有元素都为1。这行代码假设数据集中只有一个物体类别(不包括背景)。在实际应用中,如果存在多个类别,你需要根据具体情况为每个物体分配正确的类别标签。
1 | image_id = idx |
- 功能: 计算一些与COCO数据集格式兼容的元数据。
- 解释:
image_id: 为当前图像分配一个唯一的ID,这里直接使用了数据加载时的索引idx。area: 计算每个边界框的面积。这在一些评估指标中会用到。iscrowd: 一个标志,用于指示某个物体是否为“拥挤”的(即由多个物体组成的区域)。这里假设所有物体都不是拥挤的,所以都设置为0。
1 | # 将样本和目标包装成 torchvision tv_tensors: |
- 功能: 将图像和所有标注信息打包成
torchvision的tv_tensors格式,并存入一个字典target中。 - 解释:
tv_tensors是torchvision.transforms.v2引入的新特性,它们是torch.Tensor的子类,能够携带元数据(如边界框的格式、图像尺寸等),并在进行数据增强时自动更新这些元数据。tv_tensors.Image(img): 将普通的图像张量包装成Image类型的tv_tensor。tv_tensors.BoundingBoxes(...): 将边界框张量包装成BoundingBoxes类型的tv_tensor。 这需要提供边界框的格式(这里是XYXY)和原始图像的尺寸(canvas_size),以便在图像变换时能正确地调整边界框。tv_tensors.Mask(masks): 将掩码张量包装成Mask类型的tv_tensor。- 最后,将所有这些
tv_tensors和其他元数据存入名为target的字典中。这个字典是许多torchvision模型的标准输入格式。
1 | if self.transforms is not None: |
- 功能: 如果定义了数据增强操作(
self.transforms),则将其应用于图像和相应的target。 - 解释:
self.transforms通常是一个包含一系列数据增强操作(如随机裁剪、翻转、缩放等)的组合。- 由于图像和标注(边界框、掩码)都被包装成了
tv_tensors,当对img进行几何变换时,target中的boxes和masks也会被自动地、正确地进行相应的变换。 - 最后,返回经过(可能有的)数据增强处理后的图像和标注。
3. 如何创建自定义模型
在本教程中,我们将使用基于Faster R-CNN的Mask R-CNN。Faster R-CNN是一个预测图像中潜在对象的边界框和类分数的模型。Mask R-CNN在Faster R-CNN中增加了一个额外的分支,该分支还预测了每个实例的分割掩码。这里有两个方法,第一个更基础,第二个更加进阶:
3.1 微调预训练模型
1 | # 导入torchvision库 |
这段代码的核心思想是迁移学习(Transfer Learning)。它加载一个在大型通用数据集(COCO)上预训练好的强大模型,然后对其进行微调,以适应我们自己的、通常更小、更具体的任务。
详解
第1步:加载预训练模型
1 | import torchvision |
import ...: 导入所需的库和模块。torchvision是PyTorch官方的计算机视觉库,FastRCNNPredictor是一个辅助类,用于方便地替换Faster R-CNN模型的预测头。torchvision.models.detection.fasterrcnn_resnet50_fpn(...): 这行代码加载了一个非常经典和强大的目标检测模型——Faster R-CNN。resnet50: 表示这个模型使用ResNet-50作为其“骨干网络(backbone)”。骨干网络负责从输入图像中提取有用的特征。ResNet-50是一个非常强大的图像特征提取器。fpn: 表示模型使用了特征金字塔网络(Feature Pyramid Network)。FPN能够整合骨干网络在不同层次提取的特征,从而有效地检测不同尺寸的物体(大的和小的都能很好地识别)。
weights="DEFAULT": 这是最关键的部分。它告诉torchvision加载在COCO数据集上已经训练好的权重。COCO是一个包含80个物体类别的大型、多样化的数据集。这意味着我们得到的model已经是一个“专家”了,它已经学会了如何识别各种通用的视觉特征,比如边缘、纹理、形状,甚至是一些常见的物体部件。
第2步:为新任务定义类别数
1 | # 将分类器替换为一个新的、具有用户自定义类别数的分类器 |
num_classes = 2: 这里我们定义了我们自己任务的类别数量。- 为什么是2,而不是1?: 在目标检测任务中,模型不仅需要识别出你感兴趣的物体,还需要能识别出哪些区域是“背景”。因此,总的类别数总是
你感兴趣的物体类别数 + 1 (背景)。如果你要检测猫和狗,那么num_classes就应该是2 + 1 = 3。
第3步:获取并替换模型的“头部”
1 | # 获取分类器的输入特征数 |
model.roi_heads.box_predictor.cls_score: 这是深入到模型内部结构,找到负责最终类别预测的那个线性层(Linear Layer)。roi_heads:是Faster R-CNN中负责处理候选区域(Region of Interest)的“头”部网络。box_predictor:是头部网络中具体执行分类和边界框回归预测的模块。cls_score:是box_predictor中最终输出类别分数的那个全连接层。
.in_features: 这个属性可以获取到cls_score这个线性层所期望的输入向量的维度(大小)。我们必须知道这个数字,才能创建一个可以无缝衔接上去的新层。model.roi_heads.box_predictor = FastRCNNPredictor(...): 这是整个替换操作的核心。- 我们创建了一个全新的
FastRCNNPredictor实例。 FastRCNNPredictor(in_features, num_classes):这个新的预测器被初始化为:- 接收与原始模型完全相同大小的输入特征(
in_features)。 - 但它的输出维度是我们新任务所需的类别数(
num_classes,也就是2)。
- 接收与原始模型完全相同大小的输入特征(
- 通过这个赋值操作,我们把模型原来那个为COCO数据集(比如91个类别)设计的“旧头”给扔掉了,换上了我们自己为新任务(2个类别)设计的“新头”。
- 我们创建了一个全新的
这样做的好处是,我们不需要从零开始训练整个庞大的网络,只需要在我们自己的(通常较小的)数据集上,主要训练这个新的、小得多的“头部”,以及微调一下模型的其他部分。这极大地加快了训练速度,降低了对数据量的要求,并且通常能达到比从零开始训练好得多的效果。
3.2 修改模型的backbone
1 | import torchvision |
关键代码解析
前一个部分是拿一个预制好的完整模型然后只换掉“头”,而这个部分则是我们自己挑选“骨干”、“锚点生成器”和“感兴趣区域池化层”这些核心组件,然后把它们拼装起来。
这个方法提供了极大的灵活性,你可以任意替换模型的某个部分(比如用一个更轻量级的骨干网络),而不需要重写整个模型。
第1步:选择并准备骨干网络 (Backbone)
1 | # 加载一个预训练的分类模型,并且只返回其特征提取部分 |
- 功能: 定义模型的“眼睛”,即特征提取器。
- 解释:
torchvision.models.mobilenet_v2(weights="DEFAULT"): 这里我们选择MobileNetV2作为骨干网络,而不是像之前例子里的ResNet-50。MobileNetV2 是一个非常轻量级且高效的网络,非常适合在移动设备或资源有限的环境下运行。我们同样加载了在 ImageNet 上预训练好的权重。.features: 这是关键。完整的 MobileNetV2 模型最后有一个用于分类的“头”(一个全连接层)。通过.features,我们把这个头给“砍掉”了,只保留了前面的卷积层部分。因为在目标检测中,我们只需要骨干网络来提取特征图(Feature Map),而不需要它来进行最终的图像分类。backbone.out_channels = 1280: 这是一个非常重要的手动设置。FasterRCNN的构造函数需要知道从骨干网络输出的特征图的“深度”(即通道数)是多少,以便正确地连接后续的RPN网络。对于标准的MobileNetV2,其.features部分的输出通道数是1280。因为我们得到的是一个通用的nn.Sequential模块,它本身没有.out_channels这个属性,所以我们必须手动为它指定。
第2步:自定义锚点生成器 (Anchor Generator)
1 | # 让RPN网络在特征图的每个空间位置上生成 5x3=15 个锚点 |
- 功能: 定义区域提议网络(RPN)用来产生候选框的“模板”。
- 解释:
- RPN 不会盲目地在图像上搜索物体。它会在特征图的每一个位置上,放置一堆预设好的、不同尺寸和形状的“锚点框”(Anchors)。然后,网络会学习判断这些锚点框里是否包含物体,并对框的位置进行微调。
sizes=((32, 64, 128, 256, 512),): 定义了锚点框的5种基本尺寸(以像素为单位)。注意这里的双括号((...)),这是因为模型可以处理来自特征金字塔(FPN)的多个特征图,每个特征图可以有自己的一套尺寸。由于我们的MobileNetV2backbone只输出一个特征图,所以我们只提供了一组尺寸。aspect_ratios=((0.5, 1.0, 2.0),): 定义了3种宽高比。1.0是正方形,0.5是矮胖的矩形,2.0是瘦高的矩形。- 最终,在特征图的每个点,都会生成
5 (尺寸) * 3 (宽高比) = 15个锚点框。通过自定义这些值,你可以让模型更好地适应你数据集中物体的特定形状和大小。
第3步:定义感兴趣区域池化层 (ROI Pooler)
1 | # 定义我们将从哪些特征图上进行感兴趣区域(RoI)裁剪,以及裁剪后缩放的尺寸。 |
- 功能: 从特征图中精确地提取每个候选区域的特征,并将其缩放到一个固定的尺寸。
- 解释:
- 在RPN提出了很多大小不一的候选区域(Region of Interest, RoI)后,我们需要从这些区域中提取特征,送入最终的分类和边界框回归“头”。但这个“头”要求输入尺寸是固定的。
RoIAlign就是来做这个“裁剪并缩放”的工作的。 featmap_names=['0']: 这个参数至关重要,它告诉RoIAlign该从哪一张特征图上提取特征。因为我们的backbone只输出一个张量(Tensor),而不是一个带有名字的有序字典(OrderedDict),torchvision会默认将其命名为'0'。如果我们用的是FPN,这里可能会是['0', '1', '2', '3']这样的列表。output_size=7: 无论原始的候选区域有多大,从其中提取的特征图都将被缩放到7x7的大小。sampling_ratio=2: 这是RoIAlign算法的一个参数,用于更精确地对齐特征,避免量化误差,从而提升检测精度。
- 在RPN提出了很多大小不一的候选区域(Region of Interest, RoI)后,我们需要从这些区域中提取特征,送入最终的分类和边界框回归“头”。但这个“头”要求输入尺寸是固定的。
第4步:组装模型
1 | # 将所有部件组装进一个FasterRCNN模型里 |
- 功能: 调用
FasterRCNN的构造函数,将我们上面精心准备的所有自定义组件传入,完成模型的创建。 - 解释:
backbone: 我们选择的MobileNetV2特征提取器。num_classes=2: 和上一个例子一样,代表1个物体类别 + 1个背景类别。rpn_anchor_generator=anchor_generator: 传入我们自定义的锚点生成器。box_roi_pool=roi_pooler: 传入我们自定义的ROI池化层。
4. 目标检测和实力分割模型
在我们的教程中,鉴于我们的数据集非常小,我们希望从预训练的模型中进行微调,因此我们将遵使用第一种方法。在这里,我们还想计算实例分割掩码,因此我们将使用Mask R-CNN:
1 | import torchvision |
关键代码解析
这个是针对**实例分割(Instance Segmentation)**任务,使用的是 Mask R-CNN 模型。
实例分割比目标检测更进了一步,它不仅要找出物体的位置(边界框),还要为每个物体实例生成一个像素级的掩码(mask)。这里的逻辑和微调 Faster R-CNN 非常相似,但由于 Mask R-CNN 多了一个“生成掩码”的分支,所以我们需要替换两个“头”:一个是负责分类的头,另一个是负责生成掩码的头。
第1步:加载预训练的 Mask R-CNN 模型
1 | def get_model_instance_segmentation(num_classes): |
- 功能: 加载一个强大的、预训练好的实例分割模型。
- 解释:
torchvision.models.detection.maskrcnn_resnet50_fpn: 这次我们加载的是Mask R-CNN模型。它的结构可以看作是在Faster R-CNN的基础上增加了一个并行的分支,这个分支专门用来为每个检测到的物体(RoI)生成二进制掩码。resnet50_fpn: 同样,它使用 ResNet-50 作为骨干网络,并带有 FPN,这对于在不同尺度上同时做好目标检测和实例分割至关重要。weights="DEFAULT": 加载在 COCO 数据集上预训练的权重。这个预训练模型不仅知道如何检测80个类别的物体,还知道如何为它们生成精确的像素级掩码。
第2步:替换边界框分类头 (Box Predictor)
1 | # 获取分类器的输入特征数 |
- 功能: 调整模型以适应我们新任务的物体类别。
- 解释:
- 这部分和微调 Faster R-CNN 的代码完全一样。
model.roi_heads是处理候选区域(RoI)的总模块。- 它内部的
box_predictor负责两件事:1) 判定候选区域属于哪个类别;2) 微调候选区域的边界框位置。 - 我们通过
cls_score找到负责分类的那个全连接层,获取其输入维度in_features。 - 然后,我们创建一个新的
FastRCNNPredictor,它的输出维度是我们自定义的num_classes(例如,你的物体数 + 1个背景)。 - 这个操作将原来为COCO数据集(91个类别)设计的分类器,换成了我们自己任务专用的分类器。
第3步:替换掩码预测头 (Mask Predictor)
1 | # 现在获取掩码分类器的输入特征数 |
- 功能: 调整模型以生成与我们新任务类别相对应的掩码。
- 解释: 这是 Mask R-CNN 特有的步骤。
model.roi_heads.mask_predictor: 这是roi_heads中与box_predictor并行存在的另一个模块,它专门负责生成掩码。conv5_mask: 我们深入到mask_predictor内部,找到它的一个关键卷积层(在这里是conv5_mask)来获取其输入通道数in_channels。这个值决定了输入到掩码头的特征图的深度。hidden_layer = 256: Mask R-CNN的掩码头通常由一系列卷积层组成。这里我们定义了这些中间卷积层的通道数(或称特征维度)为256。这是一个常见的默认值。MaskRCNNPredictor(...): 我们创建了一个新的掩码预测器。in_features_mask: 它的输入通道数必须和原始模型匹配,确保可以无缝衔接。hidden_layer: 定义了其内部卷积层的维度。num_classes: 这是关键。新的掩码预测器需要为每一个类别都生成一个对应的掩码。因此,它的输出通道数也需要是num_classes。模型会先判断物体是什么类别,然后选择该类别对应的通道输出作为最终的掩码。
- 通过这个赋值操作,我们把原来为COCO数据集(91个类别)生成掩码的“旧头”,换成了为我们自己任务(
num_classes个类别)生成掩码的“新头”。
第4步:返回模型
1 | return model |
- 功能: 返回这个经过完全定制和修改后的新模型。
- 解释: 这个返回的
model保留了强大的预训练骨干网络,但拥有了两个为我们特定任务量身定制的“新头”(一个用于分类和边界框回归,一个用于掩码生成)。现在,你可以用这个模型在你自己的实例分割数据集上进行训练了。
总结
这个函数完美地展示了如何通过迁移学习来创建一个自定义的实例分割模型。整个过程可以概括为:
- 加载一个通用的、预训练好的Mask R-CNN模型。
- 更换“分类头”:使其能够识别你自定义的物体类别。
- 更换“掩码头”:使其能够为你的每一个自定义类别生成对应的像素级掩码。
这样,你就能利用COCO数据集上学到的强大特征提取能力,高效地解决自己的实例分割问题,而无需从零开始训练整个庞大的网络。
5. 一些辅助函数
除此之外,我们还需要一些辅助函数来帮助我们训练和评估模型,下载方式如下:
1 | os.system("wget https://raw.githubusercontent.com/pytorch/vision/main/references/detection/engine.py") |
下面是一个辅助函数来帮我们对图像进行变换:
1 | from torchvision.transforms import v2 as T |
6. forward()前向传播测试
在迭代数据集之前,我们看看模型在对样本数据的训练和推理时间的预期。
1 | import utils |
关键代码解析
这段代码展示了 torchvision 目标检测模型在**训练(Training)和推断(Inference)**两种模式下截然不同的行为。
我们来分两部分详细解析。
第一部分:训练模式 (For Training)
逐行解释:
images, targets = next(iter(data_loader)):iter(data_loader)创建一个迭代器。next(...)从迭代器中取出一项,也就是一个批次的数据。images是一个包含多张图像的张量(如果图像大小相同并通过默认collate_fn堆叠)或一个元组(如果图像大小不同,由自定义的collate_fn处理)。targets是一个包含了对应标注信息的元组或列表。每个元素通常是一个字典,包含boxes,labels等键。
images = list(image for image in images):- 关键点:
torchvision的目标检测模型在训练和推断时,都希望接收一个Python列表作为图像输入,其中列表的每个元素都是一个[C, H, W]形状的张量。 - 这个操作确保了
images是一个列表,即使DataLoader因为某些原因将它们打包成了单个大张量。
- 关键点:
targets = [{k: v for k, v in t.items()} for t in targets]:- 这行代码确保
targets是一个字典的列表。每个字典代表一张图像的真实标注(ground-truth),包含'boxes','labels','masks'等键值对。这通常是DataLoader和collate_fn已经处理好的标准格式,这里是做一个确认。
- 这行代码确保
output = model(images, targets):- 这是最核心的区别:当模型同时接收到
images和targets(真实标注)时,它会自动进入训练模式。 - 在这种模式下,模型会执行以下操作:
- 对输入图像进行正向传播,得到预测结果。
- 将预测结果与你提供的
targets(真实标注) 进行比较。 - 计算各种损失函数的值。
- 因此,返回的
output不是预测结果,而是一个包含所有损失项的字典。
- 这是最核心的区别:当模型同时接收到
print(output):- 打印出的结果会是类似这样的一个字典:
1
2
3
4{'loss_classifier': tensor(0.1234, grad_fn=<...>),
'loss_box_reg': tensor(0.2345, grad_fn=<...>),
'loss_objectness': tensor(0.3456, grad_fn=<...>),
'loss_rpn_box_reg': tensor(0.4567, grad_fn=<...>)} - 这些损失值随后会在训练循环中用于反向传播(
loss.backward())和更新模型权重。
- 打印出的结果会是类似这样的一个字典:
第二部分:推断模式 (For Inference)
逐行解释:
model.eval():- 至关重要的一步! 这行代码将模型切换到评估模式。
- 这会关闭 Dropout 和 BatchNorm 等在训练和推断时行为不同的层。如果不做这一步,每次预测的结果可能会因为这些层的随机性而不同,并且会使用批次数据的统计信息,导致结果不准确。
x = [torch.rand(3, 300, 400), torch.rand(3, 500, 400)]:- 这里我们创建了一个虚拟的输入
x。它是一个包含两个张量的列表,模拟一个批次包含两张不同尺寸的图像。这再次强调了模型输入的格式是一个列表。
- 这里我们创建了一个虚拟的输入
predictions = model(x):- 核心区别:当模型只接收到
images(这里是x),而没有targets时,它会自动进入推断模式。 - 在这种模式下,模型只执行正向传播,并返回最终处理好的预测结果。
- 核心区别:当模型只接收到
print(predictions[0]):- 返回的
predictions是一个列表,其长度等于输入图像的数量(这里是2)。列表中的每个元素都是一个字典,包含了对相应输入图像的预测结果。 - 打印
predictions[0]会输出对第一张图像的预测,其格式会是类似这样的一个字典:1
2
3{'boxes': tensor([[x1, y1, x2, y2], ...]),
'labels': tensor([label1, ...]),
'scores': tensor([score1, ...])} boxes: 检测到的边界框坐标[N, 4]。labels: 每个边界框对应的类别标签[N]。scores: 每个检测结果的置信度得分(0到1之间)[N]。通常我们会根据这个得分来过滤掉置信度低的预测结果。
- 返回的
总结
| 特性 | 训练模式 (Training) | 推断模式 (Inference) |
|---|---|---|
| 模型状态 | model.train() (默认) | 必须调用 model.eval() |
| 输入 | model(images, targets) | model(images) |
| 输出 | 一个包含各个损失值的字典 | 一个预测结果的列表,列表中每个元素是对应图像的预测字典 |
| 目的 | 计算损失,用于梯度下降和模型优化 | 对新数据生成边界框、类别和置信度得分 |
7. 训练模型
接下来就是常规的训练模型代码啊:
1 | from engine import train_one_epoch, evaluate |
数据集划分代码解释
1 | indices = torch.randperm(len(dataset)).tolist() |
好的,我们来详细解释这段代码。
这段代码的目的是执行机器学习中一个非常基础且重要的步骤:将一个完整的数据集随机分割成一个训练集(training set)和一个测试集(test set)。
可以把它想象成洗牌。你有一整副牌(dataset),你想留出一小部分(比如50张)来做最后的测试,剩下的牌用来练习。为了保证公平,你不能只把顶上或底下的50张拿出来,而是要先彻底洗牌,然后从洗好的牌堆里分出两部分。
让我们用一个具体的例子来解释,假设我们原始的 dataset 里有 1000 个样本(比如1000张图片和它们的标注)。
第1步:生成随机索引
1 | # 将数据集分割成训练集和测试集 |
len(dataset): 首先,获取数据集中样本的总数。在我们的例子中,len(dataset)会返回1000。torch.randperm(1000): 这是核心的“洗牌”操作。randperm是 “random permutation”(随机置换)的缩写。这个函数会生成一个从 0 到 999 的整数序列,但是顺序是完全随机打乱的。- 例如,它可能会生成一个像
tensor([15, 980, 2, 541, ... , 33])这样的张量,其中 0 到 999 之间的每个数字都只出现一次。
- 例如,它可能会生成一个像
.tolist(): 这个方法将上面生成的 PyTorch 张量转换成一个普通的 Python 列表。- 所以
indices现在是一个 Python 列表,如[15, 980, 2, 541, ... , 33]。我们现在有了一个随机的索引顺序。
- 所以
第2步:创建训练子集
1 | dataset = torch.utils.data.Subset(dataset, indices[:-50]) |
indices[:-50]: 这是一个 Python 的列表切片操作。它的意思是“取indices列表中的所有元素,除了最后50个”。- 在我们的例子中,这会得到一个包含
1000 - 50 = 950个随机索引的列表。
- 在我们的例子中,这会得到一个包含
torch.utils.data.Subset(...): 这是一个非常有用的工具类。它接收一个原始的数据集和一个索引列表,然后创建一个“子集”。- 这个子集并不会在内存中复制数据,它只是一个“视图”或者说一个“包装器”,它知道只从原始
dataset中提取indices[:-50]指定的那些样本。这样做非常高效。
- 这个子集并不会在内存中复制数据,它只是一个“视图”或者说一个“包装器”,它知道只从原始
dataset = ...: 这里用新创建的训练子集覆盖了原来的dataset变量。现在,dataset变量指向的这个对象只包含了950个用于训练的样本。
第3步:创建测试子集
1 | dataset_test = torch.utils.data.Subset(dataset_test, indices[-50:]) |
重要提示: 这里的代码 torch.utils.data.Subset(dataset_test, ...) 很可能是一个笔误。它应该从原始的、完整的 dataset 中创建子集,而不是从一个已经存在的(可能是空的或不同的)dataset_test 中创建。
正确的、符合逻辑的代码应该是:
1 | # 修正后的代码 |
让我们基于这个修正后的代码来解释:
indices[-50:]: 这同样是列表切片操作,意思是“只取indices列表中的最后50个元素”。torch.utils.data.Subset(dataset, ...): 我们再次使用Subset类,但这次我们传入的是原始dataset和最后50个随机索引。dataset_test = ...: 这样就创建了一个名为dataset_test的新对象,它包含了50个专门用于测试的样本。
总结
整个过程的结果是:
- 我们从原始的1000个样本中,随机、不重复地挑选了950个样本组成了新的
dataset(训练集)。 - 我们用剩下的50个样本组成了
dataset_test(测试集)。
由于索引是随机打乱后分割的,这就保证了训练集和测试集是随机分配的,并且没有任何交集(即一个样本不会同时出现在训练集和测试集中)。这是进行可靠模型评估的基础,可以防止模型“泄题”(即在训练中见过了测试题),从而更公平地衡量模型的泛化能力。
8. 图片测试
现在让我们用训练好的模型来测试一张图片:
1 | import matplotlib.pyplot as plt |
代码详解
代码展示了如何将一个训练好的实例分割(或目标检测)模型的推断结果(inference result)进行可视化。
整个过程可以分为三个主要步骤:
- 获取模型预测:将一张图片喂给模型,得到预测的边界框、标签和掩码。
- 准备可视化数据:处理原始图片和预测结果,使其符合可视化函数要求的格式。
- 绘制并显示:使用
torchvision.utils和matplotlib将边界框和掩码画在图片上并最终显示出来。
第1步:准备工作和模型推断
1 | import matplotlib.pyplot as plt |
import ...: 导入绘图库matplotlib和torchvision提供的两个非常方便的可视化工具:draw_bounding_boxes和draw_segmentation_masks。read_image(...): 加载一张图片。torchvision.io.read_image会将其读取为一个 PyTorch 张量,形状为[Channels, Height, Width],数值范围通常是 0-255。model.eval(): 非常重要。将模型切换到评估(推断)模式,这会关闭 Dropout 等层,确保每次预测结果是确定的。with torch.no_grad(): 告诉 PyTorch 在这个代码块里不要计算梯度。这可以显著减少内存消耗并加速计算,因为在推断时我们不需要反向传播。x = x[:3, ...]: 这一步是在处理图像通道。输入的PNG图片可能是4通道的RGBA(红、绿、蓝、透明度)。大多数预训练模型只接受3通道的RGB图像。这个切片操作[:3, ...]就是取前三个通道(R, G, B),丢弃Alpha通道。predictions = model([x, ]): 这是执行推断的核心。注意,x被包装在一个列表中[x]。这是因为torchvision的模型设计为可以接收一个批次(batch)的图像进行处理。即使只预测一张图,也要把它当作一个大小为1的批次传入。pred = predictions[0]: 模型返回的是一个列表,列表中每个元素对应输入批次中每张图的预测结果。因为我们只输入了一张图,所以我们取出列表的第一个元素,这就是我们需要的预测字典。pred现在是一个包含'boxes','labels','scores','masks'等键的字典。
第2步:准备用于绘图的数据
1 | # 将原始图像的像素值缩放到0-255范围,并转换为uint8整数类型 |
image = (255.0 * ...): 这是一个归一化操作,用于将image张量的像素值安全地转换到[0, 255]的范围内,并转换为uint8类型。这是绘图函数所期望的标准图像格式。pred_labels = [...]: 这是一个列表推导式,用于创建更友好的标签。它遍历预测出的每个标签和对应的置信度分数,然后格式化成一个字符串,例如"pedestrian: 0.998"。pred_boxes.long(): 绘图函数要求边界框的坐标是整数像素值,所以我们用.long()或.int()将浮点数坐标转换成整数。
第3步:绘制边界框和掩码
1 | # 在原始图像上绘制边界框和标签,颜色为红色 |
draw_bounding_boxes(...): 第一个绘图步骤。它接收原始图像、处理好的边界框和标签,然后返回一个新的图像张量,上面已经画好了红色的边界框和文字。masks = (pred["masks"] > 0.7).squeeze(1): 这是一个关键的掩码预处理步骤。pred["masks"]: 模型输出的通常是“软掩码”,每个像素值在0到1之间,表示该像素属于物体的概率。> 0.7: 我们设置一个阈值(这里是0.7)。所有概率大于0.7的像素被认为是物体的一部分(变为True),其他的则不是(变为False)。这样我们就从软掩码得到了二进制(黑白)掩码。.squeeze(1): 模型输出的掩码张量形状通常是[N, 1, H, W](N个物体,1个通道,高,宽)。但绘图函数期望的输入是[N, H, W]。.squeeze(1)会移除那个大小为1的通道维度。
draw_segmentation_masks(...): 第二个绘图步骤。它在output_image(已经有边界框的图)的基础上,再把蓝色的掩码叠加上去。alpha=0.5参数设置了掩码的透明度为50%,这样我们还能看到掩码下方的原始图像。
第4步:显示最终结果
1 | # 创建一个12x12英寸的画布 |
plt.figure(...): 设置matplotlib画布的大小,让最终显示的图片更大更清晰。plt.imshow(...): 使用imshow来显示图像。output_image.permute(1, 2, 0): 至关重要的一步。PyTorch 和torchvision处理图像时,默认的维度顺序是[Channels, Height, Width](C, H, W)。而matplotlib和大多数图像处理库期望的顺序是[Height, Width, Channels](H, W, C)。.permute(1, 2, 0)就是在重新排列张量的维度,将顺序从 (0, 1, 2) 调整为 (1, 2, 0),以满足imshow的要求。没有这一步,imshow会报错或者显示出颜色奇怪的错误图像。
9. 模型结构
如何展现一个模型的结构?
添加一行代码:
1
print(model)
这样就可以简单打印出模型的所有层级结构。
Netron 是一个专门用于可视化神经网络、深度学习和机器学习模型的网站,可以生成“方框和箭头”的交互式图形。你需要先将你的模型转化为ONNX,再用Netron打开这个模型。
第一步:安装必要的库
1 | pip install onnx |
第二部:编写代码,导出模型
1 | import torch |
第三步:导入Netron,可视化查看
你可以在网页中搜索打开Netron,将模型导入,这样你就可以可视化模型的结构。