【CV】第 10 章:目标检测和分割的应用

  🔎大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流🔎

📝个人主页-Sonhhxg_柒的博客_CSDN博客 📃

🎁欢迎各位→点赞👍 + 收藏⭐️ + 留言📝​

📣系列专栏 - 机器学习【ML】 自然语言处理【NLP】  深度学习【DL】

 🖍foreword

✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。

如果你对这个系列感兴趣的话,可以关注订阅哟👋

文章目录

多对象实例分割

获取和准备数据

训练模型以进行实例分割

对新图像进行推断

人体姿态检测

人群计数

编码人群计数

图像着色

使用点云进行 3D 对象检测

理论

输入编码

输出编码

训练用于 3D 对象检测的 YOLO 模型

数据格式

数据检查

训练

测试

概括


在前面的章节中,我们了解了各种对象检测技术,例如 R-CNN 系列算法、YOLO、SSD,以及 U-Net 和 Mask R-CNN图像分割算法。在本章中,我们将进一步学习——我们将研究更现实的场景,并学习更优化以解决检测和分割问题的框架/架构。我们将首先利用 Detectron2 框架来训练和检测图像中存在的自定义对象。我们还将使用预训练模型预测图像中存在的人类姿势。此外,我们将学习如何计算图像中人群中的人数,然后学习如何利用分割技术来执行图像着色 . 最后,我们将了解 YOLO 的修改版本,通过使用从 LIDAR 传感器获得的点云来预测对象周围的 3D 边界框。

在本章结束时,您将了解以下内容:

  • 多对象实例分割
  • 人体姿态检测
  • 人群计数
  • 图像着色
  • 使用点云进行 3D 对象检测

多对象实例分割

在前面的章节中,我们了解了各种对象检测算法。在本节中,我们将了解 Detectron2 平台(https://ai.facebook.com/blog/-detectron2-a-pytorch-based-modular-object-detection-library-/),然后再使用 Google 实现它打开图像数据集。Detectron2 是 Facebook 团队打造的平台。Detectron2 包括最先进的对象检测算法的高质量实现,包括 Mask R-CNN 模型系列的 DensePose。最初的 Detectron 框架是用 Caffe2 编写的,而 Detectron2 框架是使用 PyTorch 编写的。

 Detectron2 支持一系列与对象检测相关的任务。与原始的 Detectron 一样,它支持使用框和实例分割掩码进行对象检测,以及人体姿势预测。除此之外,Detectron2 还增加了对语义分割和全景分割(结合语义和实例分割的任务)的支持。通过利用 Detectron2,我们能够在几行代码中构建对象检测、分割和姿态估计。

在本节中,我们将了解以下内容:

  1. open-images从存储库中获取数据
  2. 将数据转换为 Detectron2 接受的 COCO 格式
  3. 训练模型以进行实例分割
  4. 对新图像进行推断

让我们在以下各节中逐一介绍。

获取和准备数据

我们将处理 Google 在https://storage.googleapis.com/openimages/web/index.html提供的 Open Images 数据集(包含数百万张图像及其注释)中可用的图像。

在这部分代码中,我们将学习仅获取所需图像而不是整个数据集。请注意,此步骤是必需的,因为数据集大小会阻止可能没有大量资源的典型用户构建模型:

1.安装所需的软件包:

!pip install -qU openimages torch_snippets

2.下载所需的注释文件:

from torch_snippets import *
!wget -O train-annotations-object-segmentation.csv -q https://storage.googleapis.com/openimages/v5/train-annotations-object-segmentation.csv
!wget -O classes.csv -q \
 https://raw.githubusercontent.com/openimages/dataset/master/dict.csv 

3.指定我们希望模型预测的类(您可以访问 Open Images 网站查看所有类的列表):

required_classes = 'person,dog,bird,car,elephant,football,\
jug,laptop,Mushroom,Pizza,Rocket,Shirt,Traffic sign,\
Watermelon,Zebra'
required_classes = [c.lower() for c in \
                        required_classes.lower().split(',')]

classes = pd.read_csv('classes.csv', header=None)
classes.columns = ['class','class_name']
classes = classes[classes['class_name'].map(lambda x: x \
                                        in required_classes)]

4.获取对应的图像 ID 和掩码required_classes:

from torch_snippets import * 
df = pd.read_csv('train-annotations-object-segmentation.csv') 

data = pd.merge(df, classes, left_on='LabelName', 
                right_on='class') 

subset_data = data.groupby( 'class_name').agg( \ 
                        {'ImageID': lambda x: list(x)[:500]}) 
subset_data = flatten(subset_data.ImageID.tolist()) 
subset_data = data[data['ImageID'].map (lambda x: x \ 
                                       in subset_data)] 
subset_masks = subset_data['MaskPath'].tolist()

鉴于海量数据,我们仅在. 是否为每个类获取更小或更大的文件集以及唯一类列表 ( ) 取决于您。 subset_datarequired_classes

到目前为止,我们只有图像对应的和值。在接下来的步骤中,我们将继续从. ImageId MaskPathopen-images

5.现在我们有了要下载的掩码数据子集,让我们开始下载。Open Images 有 16 个用于训练掩码的 ZIP 文件。每个 ZIP 文件中只有几个掩码subset_masks,因此我们将在将所需掩码移动到单独的文件夹后删除其余的掩码。此下载->移动->删除操作将使内存占用相对较小。我们必须为 16 个文件中的每一个运行一次此步骤:

!mkdir -p masks
for c in Tqdm('0123456789abcdef'):
    !wget -q \
     https://storage.googleapis.com/openimages/v5/train-masks/train-masks-{c}.zip
    !unzip -q train-masks-{c}.zip -d tmp_masks
    !rm train-masks-{c}.zip
    tmp_masks = Glob('tmp_masks', silent=True)
    items = [(m,fname(m)) for m in tmp_masks]
    items = [(i,j) for (i,j) in items if j in subset_masks]
    for i,j in items:
        os.rename(i, f'masks/{j}')
    !rm -rf tmp_masks

6.下载对应的图片ImageId:

mask = Glob('masks') 
mask = [fname(mask) for mask in mask] 

subset_data = subset_data[subset_data['MaskPath'].map(lambda \ 
                                              x: x in mask)] 
subset_imageIds = subset_data['ImageID'] .tolist() 

from openimages.download import _download_images_by_id 
!mkdir images 
_download_images_by_id(subset_imageIds, 'train', './images/')

7.压缩所有图像、掩码和基本事实并保存它们——以防您的会话崩溃,保存和检索文件以供以后训练很有帮助。创建 ZIP 文件后,请确保将文件保存在驱动器中或下载。文件大小最终约为 2.5 GB:

import zipfile
files = Glob('images') + Glob('masks') + \
['train-annotations-object-segmentation.csv', 'classes.csv']
with zipfile.ZipFile('data.zip','w') as zipme:
    for file in Tqdm(files):
        zipme.write(file, compress_type=zipfile.ZIP_DEFLATED)

最后,将数据移动到单个目录中:

!mkdir -p train/ 
!mv images train/myData2020 
!mv mask train/annotations

鉴于目标检测代码中有如此多的移动组件,作为一种标准化方式,Detectron 接受严格的数据格式进行训练。虽然可以编写数据集定义并将其提供给 Detectron,但以 COCO 格式保存整个训练数据更容易(也更有利可图)。通过这种方式,您可以利用其他训练算法,例如检测器转换器DETR ),而无需对数据进行任何更改。首先,我们将从定义类的类别开始。

8.以 COCO 格式定义所需的类别:

!pip install \
 git+git://github.com/waspinator/pycococreator.git@0.2.0
import datetime

INFO = {
    "description": "MyData2020",
    "url": "None",
    "version": "1.0",
    "year": 2020,
    "contributor": "sizhky",
    "date_created": datetime.datetime.utcnow().isoformat(' ')
}

LICENSES = [
    {
        "id": 1,
        "name": "MIT"
    }
]

CATEGORIES = [{'id': id+1, 'name': name.replace('/',''), \
               'supercategory': 'none'} \
              for id,(_,(name, clss_name)) in \
              enumerate(classes.iterrows())]

在前面的代码中,在 的定义中CATEGORIES,我们创建了一个名为 的新键supercategory。为了理解supercategory,我们来看一个例子:Man和Woman类是属于超类别的类别Person。在我们的例子中,鉴于我们对超类别不感兴趣,我们将其指定为none.

  • 导入相关包并使用保存 COCO JSON 文件所需的键创建一个空字典:
!pip install pycocotools
from pycococreatortools import pycococreatortools
from os import listdir
from os.path import isfile, join
from PIL import Image

coco_output = {
    "info": INFO,
    "licenses": LICENSES,
    "categories": CATEGORIES,
    "images": [],
    "annotations": []
}
  • 设置一些包含图像位置和注释文件位置信息的变量:
ROOT_DIR = "train" 
IMAGE_DIR, ANNOTATION_DIR = 'train/myData2020/', \ 
                            'train/annotations/' 
image_files = [f for f in listdir(IMAGE_DIR) if \ 
               isfile(join(IMAGE_DIR, f))] 
annotation_files = [f for f in listdir(ANNOTATION_DIR) if \ 
                    isfile(join(ANNOTATION_DIR, f))]
  • 循环遍历每个图像文件名并填充字典images中的键:coco_output
image_id = 1
# go through each image
for image_filename in Tqdm(image_files):
    image = Image.open(IMAGE_DIR + '/' + image_filename)
    image_info = pycococreatortools\
                    .create_image_info(image_id, \
                os.path.basename(image_filename), image.size)
    coco_output["images"].append(image_info)
    image_id = image_id + 1

9.循环遍历每个分段注释并填充字典annotations中的键:coco_output

segmentation_id = 1
for annotation_filename in Tqdm(annotation_files):
    image_id = [f for f in coco_output['images'] if \
                stem(f['file_name']) == \
                annotation_filename.split('_')[0]][0]['id']
    class_id = [x['id'] for x in CATEGORIES \
                if x['name'] in annotation_filename][0]
    category_info = {'id': class_id, \
                    'is_crowd': 'crowd' in image_filename}
    binary_mask = np.asarray(Image.open(f'{ANNOTATION_DIR}/\
{annotation_filename}').convert('1')).astype(np.uint8)
 
    annotation_info = pycococreatortools\
                    .create_annotation_info( \
                    segmentation_id, image_id, category_info, 
                    binary_mask, image.size, tolerance=2)

    if annotation_info is not None:
        coco_output["annotations"].append(annotation_info)
        segmentation_id = segmentation_id + 1

10.保存coco_output在 JSON 文件中:

coco_output['categories'] = [{'id': id+1, 'name':clss_name, \
                              'supercategory': 'none'} for \
                             id,(_,(name, clss_name)) in \
                             enumerate(classes.iterrows())]

import json
with open('images.json', 'w') as output_json_file:
    json.dump(coco_output, output_json_file)

有了这个,我们就有了 COCO 格式的文件,可以很容易地使用 Detectron2 框架来训练我们的模型。

训练模型以进行实例分割

使用 Detectron2 进行训练可以通过几个步骤完成:

1.安装所需的 Detectron2 软件包。在安装正确的包之前,您应该检查您的 CUDA 和 PyTorch 版本。Colab 包含 PyTorch 1.7 和 CUDA 10.1,截至撰写本书时,我们将使用相应的文件:

!pip install detectron2 -f https://dl.fbaipublicfiles.com/detectron2/wheels/cu101/torch1.7/index.html 
!pip install pyyaml==5.1 pycocotools>=2.0.1

在继续下一步之前重新启动 Colab。

2.导入相关detectron2包:

from detectron2 import model_zoo
from detectron2.engine import DefaultPredictor
from detectron2.config import get_cfg
from detectron2.utils.visualizer import Visualizer
from detectron2.data import MetadataCatalog, DatasetCatalog
from detectron2.engine import DefaultTrainer
  • 鉴于我们已经重新启动 Colab,让我们重新获取所需的类:
from torch_snippets import *
required_classes= 'person,dog,bird,car,elephant,football,jug,\
laptop,Mushroom,Pizza,Rocket,Shirt,Traffic sign,\
Watermelon,Zebra'
required_classes = [c.lower() for c in \
                    required_classes.lower().split(',')]

classes = pd.read_csv('classes.csv', header=None)
classes.columns = ['class','class_name']
classes = classes[classes['class_name'].map(lambda \
                                x: x in required_classes)]

3.使用以下方法注册创建的数据集register_coco_instances:

from detectron2.data.datasets import register_coco_instances
register_coco_instances("dataset_train", {}, \
                        "images.json", "train/myData2020")

4.cfg在配置文件中定义所有参数。

Configuration ( cfg) 是一个特殊的 Detectron 对象,它包含用于训练模型的所有相关信息:

cfg = get_cfg()
cfg.merge_from_file(model_zoo.get_config_file("COCO-\ InstanceSegmentation/mask_rcnn_R_50_FPN_3x.yaml"))
cfg.DATASETS.TRAIN = ("dataset_train",)
cfg.DATASETS.TEST = ()
cfg.DATALOADER.NUM_WORKERS = 2
cfg.MODEL.WEIGHTS = model_zoo.get_checkpoint_url("COCO-\ InstanceSegmentation/mask_rcnn_R_50_FPN_3x.yaml") # pretrained 
# weights
cfg.SOLVER.IMS_PER_BATCH = 2
cfg.SOLVER.BASE_LR = 0.00025 # pick a good LR
cfg.SOLVER.MAX_ITER = 5000 # instead of epochs, we train on 
# 5000 batches
cfg.MODEL.ROI_HEADS.BATCH_SIZE_PER_IMAGE = 512
cfg.MODEL.ROI_HEADS.NUM_CLASSES = len(classes) 

正如您在前面的代码中看到的,您可以设置训练模型所需的所有主要超参数。正在从一个预先存在的配置文件中导入所有核心参数,该文件用于作为主干merge_from_file进行预训练。这还将包含有关预训练实验的其他信息,例如优化器和损失函数。为了我们的目的,已经设置的超参数是不言自明的。mask_rccnnFPNcfg

5.训练模型:

os.makedirs(cfg.OUTPUT_DIR, exists_ok=True) 
trainer = DefaultTrainer(cfg) 
trainer.resume_or_load(resume=False) 
trainer.train()

使用前面的代码行,我们可以训练一个模型来预测类、边界框以及属于我们自定义数据集中已定义类的对象的分割。

  • 将模型保存在文件夹中:
!cp output/model_final.pth output/trained_model.pth

至此,我们已经训练了我们的模型。在下一节中,我们将对新图像进行推断。

对新图像进行推断

为了对新图像进行推理,我们加载路径,设置概率阈值,并将其传递给DefaultPredictor方法,如下所示:

1.使用经过训练的模型加载权重。使用相同的方法cfg并加载模型权重,如以下代码所示:

cfg.MODEL.WEIGHTS = os.path.join(cfg.OUTPUT_DIR, \ 
                                 "trained_model.pth")

2.设置对象属于某一类的概率阈值:

cfg.MODEL.ROI_HEADS.SCORE_THRESH_TEST = 0.25

3.定义predictor方法:

predictor = DefaultPredictor(cfg)

4.对感兴趣的图像进行分割并将其可视化:

在下面的代码中,我们随机绘制了 30 张训练图像(请注意,我们尚未创建验证数据;我们将此作为练习留给您),但您也可以加载自己的图像路径来代替choose(files):

from detectron2.utils.visualizer import ColorMode
files = Glob('train/myData2020')
for _ in range(30):
    im = cv2.imread(choose(files))
    outputs = predictor(im)
    v = Visualizer(im[:, :, ::-1], scale=0.5, \
                    metadata=MetadataCatalog.get(\
                              "dataset_train"), \
                    instance_mode=ColorMode.IMAGE_BW 
# remove the colors of unsegmented pixels. 
# This option is only available for segmentation models
    )

    out = v.draw_instance_predictions(\
                         outputs["instances"].to("cpu"))
    show(out.get_image())

Visualizer是 Detectron2 绘制对象实例的方式。假设预测(存在于变量中)仅仅是张量的字典,将它们转换为像素信息并将它们绘制在图像上。outputs Visualizer

让我们看看每个输入的含义:

  • im:我们想要可视化的图像。
  • scale:绘制时图像的大小。在这里,我们要求它将图像缩小到 50%。
  • metadata:我们需要数据集的类级信息,主要是索引到类的映射,这样当我们发送原始张量作为要绘制的输入时,类会将它们解码为实际的人类可读类。
  • instance_mode:我们要求模型仅突出显示分割的像素。

最后,一旦创建了类(在我们的示例中,它是v),我们可以要求它绘制来自模型的实例预测并显示图像。

前面的代码给出以下输出:

从前面的输出可以看出,我们能够相当准确地识别出大象对应的像素。

既然我们已经了解了如何利用 Detectron2 来识别与图像中的类别对应的像素,那么在下一节中,我们将了解如何利用 Detectron2 来对图像中存在的人类进行姿势检测。

人体姿态检测

在上一节中,我们学习了检测多个对象并对其进行分割。在本节中,我们将学习如何检测图像中的多个人,以及使用 Detectron2 检测图像中存在的人的各个身体部位的关键点。检测关键点在多个用例中派上用场。例如在体育分析和安全方面。

对于本练习,我们将利用配置文件中提供的预训练关键点模型:

1.安装上一节所示的所有要求:

!pip install detectron2 -f \
  https://dl.fbaipublicfiles.com/detectron2/wheels/cu101/torch1.7/index.html
!pip install torch_snippets
!pip install pyyaml==5.1 pycocotools>=2.0.1

from torch_snippets import *
import detectron2
from detectron2.utils.logger import setup_logger
setup_logger()

from detectron2 import model_zoo
from detectron2.engine import DefaultPredictor
from detectron2.config import get_cfg
from detectron2.utils.visualizer import Visualizer
from detectron2.data import MetadataCatalog, DatasetCatalog

2.获取配置文件并加载 Detectron2 中存在的预训练关键点检测模型:

cfg = get_cfg() # get a fresh new config
cfg.merge_from_file(model_zoo.get_config_file("COCO-\ Keypoints/keypoint_rcnn_R_50_FPN_3x.yaml"))

3.指定配置参数:

cfg.MODEL.ROI_HEADS.SCORE_THRESH_TEST = 0.5 # set threshold 
# for this model
cfg.MODEL.WEIGHTS = model_zoo.get_checkpoint_url("COCO-\ Keypoints/keypoint_rcnn_R_50_FPN_3x.yaml")
predictor = DefaultPredictor(cfg)

4.加载我们要预测的图像:

from torch_snippets import read, resize
!wget -q https://i.imgur.com/ldzGSHk.jpg -O image.png
im = read('image.png',1)
im = resize(im, 0.5) # resize image to half its dimensions

5.预测图像并绘制关键点:

output = predictor(im) 
v = Visualizer(im[:,:,::-1], \ 
               MetadataCatalog.get(cfg.DATASETS.TRAIN[0]), \ 
               scale=1.2) 
out = v.draw_instance_predictions(\ 
                outputs ["instances"].to("cpu")) 
import matplotlib.pyplot as plt 
%matplotlib inline 
plt.imshow(out.get_image())

前面的代码给出如下输出:

从前面的输出可以看出,该模型能够准确地识别出图像中人物对应的各个关键点。

在本节中,我们学习了如何使用 Detectron2 平台执行关键点检测。在下一节中,我们将学习如何从头开始实施修改后的 VGG 架构来估计图像中存在的人数。

人群计数

想象一个场景,给你一张人群的照片,并要求你估计图像中出现的人数。在这种情况下,人群计数模型会派上用场。在我们继续构建模型来执行人群计数之前,让我们先了解可用的数据和模型架构。

为了训练一个预测图像中人数的模型,我们必须首先加载图像。图像应构成图像中所有人员的头部中心位置。输入图像的样本和图像中各个人的头部中心位置如下(来源:上海科技数据集(GitHub - desenzhou/ShanghaiTechDataset: Dataset appeared in Single Image Crowd Counting via Multi Column Convolutional Neural Network(MCNN))):

在前面的示例中,表示基本事实的图像(右侧的图像 - 图像中出现的人的头部中心)非常稀疏。正好有N个白色像素,其中N是图像中的人数。让我们放大图像的左上角并再次查看相同的地图:

在下一步中,我们将地面实况稀疏图像转换为表示图像该区域中的人数的密度图:

同一作物的最终输入-输出对如下所示:

整个图像看起来像这样:

请注意,在上图中,当两个人彼此靠近时,像素强度很高。但是,当一个人远离其他人时,该人对应的像素密度分布更均匀,导致离其他人较远的人对应的像素强度较低。本质上,热图的生成方式是像素值的总和等于图像中存在的人数。

现在我们已经能够接受输入图像和图像中人物头部中心的位置(经过处理以获取地面实况输出热图),我们将利用标题为CSRNet:用于理解高度拥挤场景的扩张卷积神经网络以预测图像中存在的人数。

模型架构(https://arxiv.org/pdf/1802.10062.pdf)如下:

在模型架构的上述结构中,我们先将图像通过标准 VGG-16 主干网络,然后再通过四个额外的卷积层。此输出通过四种配置之一,最后通过 1 x 1 x 1 卷积层。我们将使用A配置,因为它是最小的。

接下来,我们对输出图像执行均方误差MSE ) 损失最小化,以达到最佳权重值,同时使用 MAE 跟踪实际人群计数。

该架构的另一个细节是作者使用扩张卷积而不是普通卷积。

一个典型的空洞卷积如下所示(图片来源:https ://arxiv.org/pdf/1802.10062.pdf ):

在前面,左边的图表代表了一个我们迄今为止一直在研究的典型内核。第二张和第三张图表示膨胀的内核,它们在各个像素之间有间隙。这样,内核就有了更大的感受野。一个大的感受野可以派上用场,因为我们需要了解给定人附近的人数,以便估计与该人相对应的像素密度。我们使用扩张核(有九个参数)而不是普通核(它将有 49 个参数,相当于三个核的扩张率),以用更少的参数捕获更多信息。

了解了如何就地构建模型后,让我们继续编写模型以在下一节中执行人群计数。(对于那些希望了解工作细节的人,我们建议您在此处阅读本文:https ://arxiv.org/pdf/1802.10062.pdf 。我们将在下一节中训练的模型受本文启发.)

编码人群计数

我们将采用的人群计数策略如下:

1.导入相关包和数据集。

2.我们将要处理的数据集——上海科技数据集——已经将人脸中心转换为基于高斯滤波器密度的分布,因此我们无需再次执行。使用网络映射输入图像和输出高斯密度图。

3.定义一个函数来执行空洞卷积。

4.定义网络模型并训练批量数据以最小化 MSE。

让我们继续编写我们的策略,如下所示:

1.导入包并下载数据集:

%%time
import os
if not os.path.exists('CSRNet-pytorch/'):
    !pip install -U scipy torch_snippets torch_summary
    !git clone https://github.com/sizhky/CSRNet-pytorch.git
    from google.colab import files
    files.upload() # upload kaggle.json
    !mkdir -p ~/.kaggle
    !mv kaggle.json ~/.kaggle/
    !ls ~/.kaggle
    !chmod 600 /root/.kaggle/kaggle.json
    print('downloading data...')
    !kaggle datasets download -d \
        tthien/shanghaitech-with-people-density-map/
    print('unzipping data...')
    !unzip -qq shanghaitech-with-people-density-map.zip

%cd CSRNet-pytorch
!ln -s ../shanghaitech_with_people_density_map
from torch_snippets import *
import h5py
from scipy import io
  • 提供图像 ( image_folder)、ground truth ( gt_folder) 和热图文件夹 ( heatmap_folder) 的位置:
part_A = Glob('shanghaitech_with_people_density_map/\ 
ShanghaiTech/part_A/train_data/'); 

image_folder = 'shanghaitech_with_people_density_map/\ 
ShanghaiTech/part_A/train_data/images/' 
heatmap_folder = 'shanghaitech_with_people_density_map/\ 
ShanghaiTech/part_A/train_data/ground-truth-h5/' 
gt_folder = 'shanghaitech_with_people_density_map/\ 
ShanghaiTech/part_A/train_data/ground-truth/ '

2.定义训练和验证数据集和数据加载器:

device = 'cuda' if torch.cuda.is_available() else 'cpu'
tfm = T.Compose([
    T.ToTensor()
])

class Crowds(Dataset):
    def __init__(self, stems):
        self.stems = stems

    def __len__(self):
        return len(self.stems)

    def __getitem__(self, ix):
        _stem = self.stems[ix]
        image_path = f'{image_folder}/{_stem}.jpg'
        heatmap_path = f'{heatmap_folder}/{_stem}.h5'
        gt_path = f'{gt_folder}/GT_{_stem}.mat'

        pts = io.loadmat(gt_path)
        pts = len(pts['image_info'][0,0][0,0][0])

        image = read(image_path, 1)
        with h5py.File(heatmap_path, 'r') as hf:
            gt = hf['density'][:]
        gt = resize(gt, 1/8)*64
        return image.copy(), gt.copy(), pts

    def collate_fn(self, batch):
        ims, gts, pts = list(zip(*batch))
        ims = torch.cat([tfm(im)[None] for im in \
                            ims]).to(device)
        gts = torch.cat([tfm(gt)[None] for gt in \
                            gts]).to(device)
        return ims, gts, torch.tensor(pts).to(device)

    def choose(self):
        return self[randint(len(self))]

from sklearn.model_selection import train_test_split
trn_stems, val_stems = train_test_split(\
            stems(Glob(image_folder)), random_state=10)

trn_ds = Crowds(trn_stems)
val_ds = Crowds(val_stems)

trn_dl = DataLoader(trn_ds, batch_size=1, shuffle=True, \
                    collate_fn=trn_ds.collate_fn)
val_dl = DataLoader(val_ds, batch_size=1, shuffle=True, \
                    collate_fn=val_ds.collate_fn)

请注意,到目前为止我们编写的典型数据集类的唯一补充是前面代码中的粗体代码行。我们正在调整 ground truth 的大小,因为我们的网络的输出将缩小到原始大小的 1/8 ,因此我们将地图乘以 64,以便图像像素的总和将按比例缩小到原始人群数数。

3.定义网络架构:

  • 定义启用扩张卷积的函数 ( make_layers):
import torch.nn as nn
import torch
from torchvision import models
from utils import save_net,load_net

def make_layers(cfg, in_channels = 3, batch_norm=False, 
                dilation = False):
    if dilation:
        d_rate = 2
    else:
        d_rate = 1
    layers = []
    for v in cfg:
        if v == 'M':
            layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
        else:
            conv2d = nn.Conv2d(in_channels,v,kernel_size=3,\
                               padding=d_rate, dilation=d_rate)
            if batch_norm:
                layers += [conv2d, nn.BatchNorm2d(v), \
                           nn.ReLU(inplace=True)]
            else:
                layers += [conv2d, nn.ReLU(inplace=True)]
            in_channels = v
    return nn.Sequential(*layers)
  • 定义网络架构 – CSRNet:
class CSRNet(nn.Module):
    def __init__(self, load_weights=False):
        super(CSRNet, self).__init__()
        self.seen = 0
        self.frontend_feat = [64, 64, 'M', 128, 128, 'M',256,
                                256, 256, 'M', 512, 512, 512]
        self.backend_feat = [512, 512, 512, 256, 128, 64]
        self.frontend = make_layers(self.frontend_feat)
        self.backend = make_layers(self.backend_feat,
                          in_channels = 512,dilation = True)
        self.output_layer = nn.Conv2d(64, 1, kernel_size=1)
        if not load_weights:
            mod = models.vgg16(pretrained = True)
            self._initialize_weights()
            items = list(self.frontend.state_dict().items())
            _items = list(mod.state_dict().items())
            for i in range(len(self.frontend.state_dict()\
                               .items())):
                items[i][1].data[:] = _items[i][1].data[:]
    def forward(self,x):
        x = self.frontend(x)
        x = self.backend(x)
        x = self.output_layer(x)
        return x
    def _initialize_weights(self):
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.normal_(m.weight, std=0.01)
                if m.bias is not None:
                    nn.init.constant_(m.bias, 0)
            elif isinstance(m, nn.BatchNorm2d):
                nn.init.constant_(m.weight, 1)
                nn.init.constant_(m.bias, 0)

4.定义对一批数据进行训练和验证的函数:

def train_batch(model, data, optimizer, criterion):
    model.train()
    optimizer.zero_grad()
    ims, gts, pts = data
    _gts = model(ims)
    loss = criterion(_gts, gts)
    loss.backward()
    optimizer.step()
    pts_loss = nn.L1Loss()(_gts.sum(), gts.sum())
    return loss.item(), pts_loss.item()

@torch.no_grad()
def validate_batch(model, data, criterion):
    model.eval()
    ims, gts, pts = data
    _gts = model(ims)
    loss = criterion(_gts, gts)
    pts_loss = nn.L1Loss()(_gts.sum(), gts.sum())
    return loss.item(), pts_loss.item()

5.在越来越多的时期训练模型:

model = CSRNet().to(device)
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=1e-6)
n_epochs = 20

log = Report(n_epochs)
for ex in range(n_epochs):
    N = len(trn_dl)
    for bx, data in enumerate(trn_dl):
        loss,pts_loss=train_batch(model, data, optimizer, \
                                        criterion)
        log.record(ex+(bx+1)/N, trn_loss=loss, 
                           trn_pts_loss=pts_loss, end='\r')

    N = len(val_dl)
    for bx, data in enumerate(val_dl):
        loss, pts_loss = validate_batch(model, data, \
                                        criterion)
        log.record(ex+(bx+1)/N, val_loss=loss, 
                    val_pts_loss=pts_loss, end='\r')

    log.report_avgs(ex+1)
    if ex == 10: optimizer = optim.Adam(model.parameters(), \
                                        lr=1e-7)

上述代码导致训练和验证损失的变化(这里的损失是人群计数的 MAE),如下所示:

从前面的图中,我们可以看到我们的预测偏离了大约 150 人。我们可以通过以下两种方式改进模型:

  • 通过对原始图像的裁剪使用数据增强和训练
  • 通过使用更大的网络(我们使用了A配置,而B、C和D更大)。

6.对新图像进行推断:

  • 获取测试图像并对其进行标准化:
from matplotlib import cm as c
from torchvision import datasets, transforms
from PIL import Image
transform=transforms.Compose([
                 transforms.ToTensor(),transforms.Normalize(\
                          mean=[0.485, 0.456, 0.406],\
                          std=[0.229, 0.224, 0.225]),\
                  ])

test_folder = 'shanghaitech_with_people_density_map/\
ShanghaiTech/part_A/test_data/'
imgs = Glob(f'{test_folder}/images')
f = choose(imgs)
print(f)
img = transform(Image.open(f).convert('RGB')).to(device)
  • 通过训练好的模型传递图像:
output = model(img[None]) 
print("Predicted Count : ", int(output.detach().cpu()\ 
                                      .sum().numpy())) 
temp = np.asarray(output.detach() .cpu()\ 
                    .reshape(output.detach().cpu()\ 
                    .shape[2],output.detach()\ 
                    .cpu().shape[3])) 
plt.imshow(temp,cmap = c .jet) 
plt.show()

上述代码生成输入图像(左图)的热图(右图):

从前面的输出可以看出,模型对热图的预测合理准确,预测人数接近实际值。

在下一节中,我们将利用 U-Net 架构为图像着色。

图像着色

想象一个场景,你得到一堆黑白图像,并被要求将它们转换成彩色图像。你将如何解决这个问题?解决此问题的一种方法是使用伪监督管道,我们在其中获取原始图像,将其转换为黑白图像,并将它们视为输入-输出对。我们将通过利用 CIFAR-10 数据集对图像执行着色来证明这一点。

我们在编码图像着色网络时将采用的策略如下:

  1. 取训练数据集中的原始彩色图像,将其转换为灰度,以获取输入(灰度)和输出(原始彩色图像)组合。
  2. 规范化输入和输出。
  3. 构建 U-Net 架构。
  4. 在越来越多的时期训练模型。

有了前面的策略,让我们继续编写模型,如下所示:

1.安装所需的包并导入它们:

!pip install torch_snippets 
from torch_snippets import * 
device = 'cuda' if torch.cuda.is_available() else 'cpu'

2.下载数据集并定义训练和验证数据集和数据加载器:

  • 下载数据集:
from torchvision import datasets
import torch
data_folder = '~/cifar10/cifar/' 
datasets.CIFAR10(data_folder, download=True)
  • 定义训练和验证数据集和数据加载器:
class Colorize(torchvision.datasets.CIFAR10):
    def __init__(self, root, train):
        super().__init__(root, train)
        
    def __getitem__(self, ix):
        im, _ = super().__getitem__(ix)
        bw = im.convert('L').convert('RGB')
        bw, im = np.array(bw)/255., np.array(im)/255.
        bw, im = [torch.tensor(i).permute(2,0,1)\
                  .to(device).float() for i in [bw,im]]
        return bw, im

trn_ds = Colorize('~/cifar10/cifar/', train=True)
val_ds = Colorize('~/cifar10/cifar/', train=False)

trn_dl = DataLoader(trn_ds, batch_size=256, shuffle=True)
val_dl = DataLoader(val_ds, batch_size=256, shuffle=False)

输入和输出图像的示例如下:

a,b = trn_ds[0]
subplots([a,b], nc=2)

前面的代码产生以下输出:

请注意,CIFAR-10 的图像尺寸为 32 x 32。

3.定义网络架构:

class Identity(nn.Module):
    def __init__(self):
        super().__init__()
    def forward(self, x):
        return x

class DownConv(nn.Module):
    def __init__(self, ni, no, maxpool=True):
        super().__init__()
        self.model = nn.Sequential(
            nn.MaxPool2d(2) if maxpool else Identity(),
            nn.Conv2d(ni, no, 3, padding=1),
            nn.BatchNorm2d(no),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(no, no, 3, padding=1),
            nn.BatchNorm2d(no),
            nn.LeakyReLU(0.2, inplace=True),
        )
    def forward(self, x):
        return self.model(x)

class UpConv(nn.Module):
    def __init__(self, ni, no, maxpool=True):
        super().__init__()
        self.convtranspose = nn.ConvTranspose2d(ni, no, \
                                                2, stride=2)
        self.convlayers = nn.Sequential(
            nn.Conv2d(no+no, no, 3, padding=1),
            nn.BatchNorm2d(no),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(no, no, 3, padding=1),
            nn.BatchNorm2d(no),
            nn.LeakyReLU(0.2, inplace=True),
        )
        
    def forward(self, x, y):
        x = self.convtranspose(x)
        x = torch.cat([x,y], axis=1)
        x = self.convlayers(x)
        return x

class UNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.d1 = DownConv( 3, 64, maxpool=False)
        self.d2 = DownConv( 64, 128)
        self.d3 = DownConv( 128, 256)
        self.d4 = DownConv( 256, 512)
        self.d5 = DownConv( 512, 1024)
        self.u5 = UpConv (1024, 512)
        self.u4 = UpConv ( 512, 256)
        self.u3 = UpConv ( 256, 128)
        self.u2 = UpConv ( 128, 64)
        self.u1 = nn.Conv2d(64, 3, kernel_size=1, stride=1)

    def forward(self, x):
        x0 = self.d1( x) # 32
        x1 = self.d2(x0) # 16
        x2 = self.d3(x1) # 8
        x3 = self.d4(x2) # 4
        x4 = self.d5(x3) # 2
        X4 = self.u5(x4, x3)# 4
        X3 = self.u4(X4, x2)# 8
        X2 = self.u3(X3, x1)# 16
        X1 = self.u2(X2, x0)# 32
        X0 = self.u1(X1) # 3
        return X0

4.定义模型、优化器和损失函数:

def get_model():
    model = UNet().to(device)
    optimizer = optim.Adam(model.parameters(), lr=1e-3)
    loss_fn = nn.MSELoss()
    return model, optimizer, loss_fn

5.定义对一批数据进行训练和验证的函数:

def train_batch(model, data, optimizer, criterion):
    model.train()
    x, y = data
    _y = model(x)
    optimizer.zero_grad()
    loss = criterion(_y, y)
    loss.backward()
    optimizer.step()
    return loss.item()

@torch.no_grad()
def validate_batch(model, data, criterion):
    model.eval()
    x, y = data
    _y = model(x)
    loss = criterion(_y, y)
    return loss.item()

6.在越来越多的时期训练模型:

model, optimizer, criterion = get_model()
exp_lr_scheduler = optim.lr_scheduler.StepLR(optimizer, \
                                    step_size=10, gamma=0.1)

_val_dl = DataLoader(val_ds, batch_size=1, shuffle=True)

n_epochs = 100
log = Report(n_epochs)
for ex in range(n_epochs):
    N = len(trn_dl)
    for bx, data in enumerate(trn_dl):
        loss = train_batch(model, data, optimizer, criterion)
        log.record(ex+(bx+1)/N, trn_loss=loss, end='\r')
        if (bx+1)%50 == 0:
            for _ in range(5):
                a,b = next(iter(_val_dl))
                _b = model(a)
                subplots([a[0], b[0], _b[0]], nc=3, \
                          figsize=(5,5))

    N = len(val_dl)
    for bx, data in enumerate(val_dl):
        loss = validate_batch(model, data, criterion)
        log.record(ex+(bx+1)/N, val_loss=loss, end='\r')
        
    exp_lr_scheduler.step()
    if (ex+1) % 5 == 0: log.report_avgs(ex+1)

    for _ in range(5):
        a,b = next(iter(_val_dl))
        _b = model(a)
        subplots([a[0], b[0], _b[0]], nc=3, figsize=(5,5))

log.plot_epochs()

上述代码生成如下输出:

从前面的输出中,我们可以看到模型能够很好地为灰度图像着色。

到目前为止,我们已经了解了利用 Detectron2 进行分割和关键点检测、人群计数中的扩张卷积以及图像着色中的 U-Net。在下一节中,我们将了解如何利用 YOLO 进行 3D 对象检测。

使用点云进行 3D 对象检测

到目前为止,我们已经学习了如何使用具有锚框核心底层概念的算法来预测 2D 图像上的边界矩形。我们现在将学习如何扩展相同的概念来预测对象周围的 3D 边界框。

在自动驾驶汽车中,行人/障碍物检测和路线规划等任务在不了解环境的情况下是不可能发生的。预测 3D 对象位置及其方向成为一项重要任务。不仅障碍物周围的 2D 边界框很重要,而且了解与物体的距离、障碍物的高度、宽度和方向对于在 3D 世界中安全导航至关重要。

在本节中,我们将学习如何使用 YOLO 在真实数据集上预测汽车和行人的 3D 方向和位置。

下载数据、训练和测试集的说明都在这个 GitHub 存储库中给出:https ://github.com/sizhky/Complex-YOLOv4-Pytorch/blob/master/README.md#training-instructions 。鉴于公开可用的 3D 数据集非常少,我们选择了本练习中最常用的数据集,您仍然需要注册才能下载。我们也在前面的链接中提供了注册说明。

理论

用于收集实时 3D 数据的知名传感器之一是LIDAR光检测和测距)。它是一种安装在旋转装置上的激光器,每秒发射数百次激光束。另一个传感器接收来自周围物体的激光反射,并计算激光在遇到障碍物之前已经行进了多远。在汽车的各个方向上执行此操作将产生一个 3D 距离点云,该点云可以反映环境本身。在我们将要了解的数据集中,我们从称为. 让我们了解如何对输入和输出进行编码以进行 3D 对象检测。 velodyne

输入编码

我们的原始输入将以文件的形式呈现给我们的 3D 点云.bin。每个都可以作为一个 NumPy 数组加载,np.fromfile(<filepath>)下面是数据如何查找示例文件(根据 GitHub 存储库说明下载和移动原始文件后,可以在dataset/.../training/velodyne 目录中找到这些文件):

files = Glob('training/velodyne') 
F = choose(files) 
pts = np.fromfile(F, dtype=np.float32).reshape(-1, 4) 
pts

前面的代码给出以下输出:

这可以形象化如下:

# take the points and remove faraway points
x,y,z = np.clip(pts[:,0], 0, 50), 
        np.clip(pts[:,1], -25, 25), 
        np.clip(pts[:,2],-3, 1.27)

fig = go.Figure(data=[go.Scatter3d(\
        x=x, y=y, z=z, mode='markers',
        marker=dict(
            size=2,
            color=z, # set color to a list of desired values
            colorscale='Viridis', # choose a colorscale
            opacity=0.8
        )
    )])

fig.update_layout(margin=dict(l=0, r=0, b=0, t=0))
fig.show()

前面的代码产生以下输出:

我们可以通过执行以下步骤将此信息转换为鸟瞰图的图像。

1.将 3D 点云投影到XY平面(地面)上,并将其分割成一个网格,每个网格单元的分辨率为 8 cm 2 。

2.对于每个单元格,计算以下内容并将它们与指定的通道相关联:

  • 红色通道:网格中最高点的高度
  • 绿色通道:网格中最高点的强度
  • 蓝色通道:网格中的点数除以 64(这是一个归一化因子)

例如,重建的云顶视图可能如下所示:

您可以清楚地看到图像中的“阴影”,表明有障碍物。

这就是我们从 LIDAR 点云数据创建图像的方式。

我们将 3D 点云作为原始输入,得到鸟瞰图作为输出。这是创建将成为 YOLO 模型输入的图像所必需的预处理步骤。

输出编码

现在我们将鸟瞰图像(3D 点云)作为模型的输入,模型需要预测以下真实世界的特征:

  • 图像中存在的对象()是什么
  • 物体在东西轴 ( x ) 上与汽车的距离(以米为单位)
  • 物体在南北轴 ( y ) 上与汽车的距离(以米为单位)
  • 物体的方向(偏航 是什么
  • 物体有多大(物体的长度宽度,以米为单位)

可以在(鸟瞰图像的)像素坐标系中预测边界框。但它没有任何现实意义,因为预测仍然在像素空间中(在鸟瞰图中)。在这种情况下,我们需要将这些像素坐标(鸟瞰图)边界框预测转换为以米为单位的真实世界坐标。为了避免后处理过程中的额外步骤,我们直接预测真实世界的值。

此外,在现实场景中,对象可以朝向任何方向。如果我们只计算长度和宽度,将不足以描述紧密的边界框。这种场景的一个例子如下:

为了获得物体的紧密边界框,我们还需要障碍物所面向的方向的信息,因此我们还需要额外的偏航参数。形式上,它是对象与南北轴的方向。

首先,YOLO 模型使用 32 x 64 单元格(宽度大于高度)的锚点网格,考虑到汽车的行车记录仪(以及激光雷达)视图的宽度大于高度。该模型对任务使用了两个损失。第一个是我们在第 8 章高级目标检测”中了解的正常 YOLO 损失(负责预测xylw类) ,以及另一个称为 EULER 损失的损失,它专门预测偏航。形式上,从模型的输出预测最终边界框的方程组如下:

b x = σ( t x ) + c x
b y = σ( t y ) + c y
b w = p w e w
b l = p l e l
b φ = arctan2( t Im , t Re )

这里,b xb yb wb lb φ分别是障碍物的xz坐标值、宽度、长度和偏航角。
t xt yt wt lt ImRe 是从 YOLO 预测的六个回归值。
c xc y是 32 x 64 矩阵中网格单元中心的位置,p wp l是通过取汽车和行人的平均宽度和长度选择的预定义先验。此外,在实现中有五个先验(锚框)。

同一类的每个对象的高度假定为一个固定数字。

请参阅此处给出的插图,该插图以图形方式显示(图片来源:https ://arxiv.org/pdf/1803.06199.pdf ):

总损失计算如下:

您已经从上一章了解了损失YOLO (使用t xywl 作为目标)。另外,请注意以下事项:

现在我们已经了解了 3D 对象检测的基本原理如何与 2D 对象检测(但要预测的参数数量更多)以及该任务的输入输出对保持相同,让我们利用现有的 GitHub 存储库来训练我们的模型。

有关 3D 对象检测的更多详细信息,请参阅https://arxiv.org/pdf/1803.06199.pdf上的论文Complex-YOLO

训练用于 3D 对象检测的 YOLO 模型

由于标准化代码,编码工作在很大程度上远离了用户。就像 Detectron2 一样,我们可以通过确保数据在正确的位置以正确的格式来训练和测试算法。一旦确保了这一点,我们就可以用最少的行数来训练和测试代码。

我们需要先克隆Complex-YOLOv4-Pytorch存储库:

$ git clone https://github.com/sizhky/Complex-YOLOv4-Pytorch

按照README.md文件中的说明下载数据集并将其移动到正确的位置。

下载数据、训练和测试集的说明都在这个 GitHub 存储库中给出:https ://github.com/sizhky/Complex-YOLOv4-Pytorch/blob/master/README.md#training-instructions 。

鉴于公开可用的 3D 数据集非常少,我们选择了本练习中最常用的数据集,您仍然需要注册才能下载。我们还在前面的链接中提供了注册说明。

数据格式

我们可以在本练习中使用任何具有基本事实的 3D 点云数据。有关如何下载和移动数据的更多说明,请参阅READMEGitHub 存储库中的文件。数据需要以如下格式存放在根目录下:

对我们来说新的三个文件夹是velodyne、calib和label_2:

  • velodyne包含对文件夹.bin中存在的相应图像的 3D 点云信息进行编码的文件列表image_2。
  • calib包含与每个点云对应的校准文件。通过使用文件夹中每个文件中存在的 3 x 4 投影矩阵,可以将来自 LIDAR 点云坐标系的 3D 坐标投影到相机坐标系(即图像)上calib。从本质上讲,激光雷达传感器捕获的点与相机正在捕获的点略有偏移。这种偏移是由于两个传感器安装的距离彼此相距几英寸。知道正确的偏移量将帮助我们正确地将边界框和 3D 点投影到来自相机的图像上。
  • label_2以 15 个值的形式包含每个图像的基本事实(每行一个基本事实),下表解释了这些值:

请注意,我们的目标列是此处看到的类型(类)、w  l  x  zry 偏航)。我们将忽略此任务的其余值。

数据检查

我们可以通过运行以下命令来验证数据是否正确下载:

$ cd Complex-YOLOv4-Pytorch/src/data_process 
$ python kitti_dataloader.py --output-width 600

前面的代码显示了多张图片,一次一张。以下是一个这样的例子(图片来源:https ://arxiv.org/pdf/1803.06199.pdf ):

现在我们可以下载和查看一些图像,在下一节中,我们将学习如何训练模型来预测 3D 边界框。

训练

训练代码封装在一个 Python 文件中,可以按如下方式调用:

$ cd Complex-YOLOv4-Pytorch/src 
$ python train.py --gpu_idx 0 --batch_size 2 --num_workers 4 \ 
                  --num_epochs 5

默认的 epoch 数是 300,但从第五个 epoch 本身开始,结果是相当合理的。在 GTX 1070 GPU 上,每个 epoch 需要 30 到 45 分钟。如果无法一次性完成训练,您可以使用它--resume_path来恢复训练。该代码每五个时期保存一个新的检查点。

测试

就像在数据检查部分中一样,可以使用以下代码测试经过训练的模型:

$ cd Complex-YOLOv4-Pytorch/src 
$ python test.py --gpu_idx 0 --pretrained_pa​​th ../checkpoints/complexer_yolo/Model_complexer_yolo_epoch_5.pth --cfgfile ./config/cfg/complex_yolov4.cfg --show_image

代码的主要输入是检查点路径和模型配置路径。给出它们并运行代码后,会弹出以下输出(图片来源:https ://arxiv.org/pdf/1803.06199.pdf ):

由于模型的简单性,我们可以在具有普通 GPU 的实时场景中使用它,每秒获得大约 15-20 个预测。

概括

在本章中,我们了解了处理对象定位和分割的各个实际方面。具体来说,我们了解了如何利用 Detectron2 平台执行图像分割和检测以及关键点检测。此外,当我们从 Open Images 数据集中获取图像时,我们还了解了处理大型数据集所涉及的一些复杂性。接下来,我们分别利用 VGG 和 U-Net 架构进行人群计数和图像着色。最后,我们了解了使用点云图像进行 3D 对象检测背后的理论和实现步骤。正如您从所有这些示例中所看到的,基础知识 与前几章中描述的相同,只是在网络的输入/输出中进行了修改以适应手头的任务。

  • 6
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Sonhhxg_柒

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值