PyTorch 现代计算机视觉第二版(七)

原文:zh.annas-archive.org/md5/355d709877e6e04dc1540c8ccd0b447d

译者:飞龙

协议:CC BY-NC-SA 4.0

第十三章:高级 GAN 用于图像操作

在前一章中,我们学习了如何利用生成对抗网络GANs)生成逼真图像。在本章中,我们将学习如何利用 GANs 来操作图像。我们将学习使用 GANs 生成图像的两种变体方法——配对方法和非配对方法。在配对方法中,我们将提供输入和输出的组合对,以生成基于输入图像的新图像,我们将在Pix2Pix GAN中学习到。在非配对方法中,我们将指定输入和输出;然而,我们不会提供输入和输出之间的一对一对应关系,而是期望 GAN 学习两类的结构,并将一个类别的图像转换为另一个类别的图像,这是我们讨论CycleGAN时将要学习的内容。

另一种非配对图像操作的类别涉及从随机向量的潜在空间生成图像,并查看随着潜在向量值的变化图像如何改变,这是我们在利用 StyleGAN 在自定义图像上部分将要学习的内容。最后,我们将学习如何利用预训练的 GAN——超分辨率生成对抗网络SRGAN),将低分辨率图像转换为高分辨率图像。

具体来说,我们将学习以下主题:

  • 利用 Pix2Pix GAN 将草图/边缘图片转换为图片

  • 利用 CycleGAN 将苹果转换为橙子,反之亦然

  • 利用 StyleGAN 在自定义图像上改变图像表情

  • 使用 SRGAN 对图像进行超分辨率处理

    本章中的所有代码片段都可以在 GitHub 仓库的Chapter13文件夹中找到,网址是bit.ly/mcvp-2e

利用 Pix2Pix GAN

想象一种情景,我们有一对彼此相关的图像(例如,一个对象边缘的图像作为输入,实际对象图像作为输出)。面临的挑战是,我们希望根据对象边缘的输入图像生成一张图像。在传统设置中,这将是一个简单的输入到输出的映射,因此是一个监督学习问题。然而,假设你正在与一个试图为产品设计新外观的创意团队合作。在这种情况下,监督学习并没有太多帮助,因为它只从历史中学习。这里 GAN 会很有用,因为它确保生成的图像看起来逼真,并为实验留下空间(因为我们希望检查生成的图像是否类似于我们想要生成的图像)。具体来说,Pix2Pix GAN 在需要从另一个仅包含边缘(或轮廓)图像生成图像的情景中非常有用。

在本节中,我们将了解用于从鞋类图像的手绘边缘生成鞋类图像的架构。我们将采用以下策略来从手绘的草图生成逼真图像:

  1. 从大量实际图像中获取,并使用标准的cv2边缘检测技术创建相应的边缘。

  2. 从原始图像的补丁中采样颜色,以使生成器知道生成哪些颜色。

  3. 建立一个 UNet 架构,该架构以带有样本补丁颜色的边缘作为输入,并预测相应的图像 - 这是我们的生成器。

  4. 构建一个鉴别器架构,它接受图像并预测其真实性。

  5. 训练生成器和鉴别器,使之达到生成可以欺骗鉴别器的图像的能力。

让我们编写策略:

以下代码可作为本书 GitHub 存储库中Chapter13文件夹中的Pix2Pix_GAN.ipynb获取:bit.ly/mcvp-2e 该代码包含从中下载数据的 URL,长度适中。我们强烈建议您在 GitHub 上执行笔记本以在按照步骤执行并阅读文本中各种代码部分的解释时重现结果。

  1. 导入数据集(在此处可用:sketchx.eecs.qmul.ac.uk/downloads/),并安装相关的包:

    !wget https://www.dropbox.com/s/g6b6gtvmdu0h77x/ShoeV2_photo.zip
    !pip install torch_snippets
    !pip install torch_summary
    from torch_snippets import *
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    

上述代码下载了鞋类图像。下载的图像样本如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.1:样本图像

对于我们的问题,我们希望根据鞋子的边缘和一些样本补丁颜色绘制鞋子。在下一步中,我们将从鞋类图像中获取边缘。这样,我们可以训练一个模型,从鞋子的边缘和样本补丁颜色中重建出鞋子的图像。

  1. 定义一个函数,从下载的图像中获取边缘:

    def detect_edges(img):
        img_gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
        img_gray = cv2.bilateralFilter(img_gray, 5, 50, 50)
        img_gray_edges = cv2.Canny(img_gray, 45, 100)
        # invert black/white
        img_gray_edges = cv2.bitwise_not(img_gray_edges)         
        img_edges=cv2.cvtColor(img_gray_edges, cv2.COLOR_GRAY2RGB)
        return img_edges 
    

在上述代码中,我们利用 OpenCV 包中提供的各种方法从图像中获取边缘(有关 OpenCV 方法的详细信息,请参见 GitHub 存储库中“第一版额外章节”文件夹中的 OpenCV 章节)。

  1. 定义图像转换流水线,对数据集进行预处理和标准化(preprocessnormalize):

    IMAGE_SIZE = 256
    preprocess = T.Compose([
                 T.Lambda(lambda x: torch.Tensor(x.copy())\
                           .permute(2, 0, 1).to(device))])
    normalize = lambda x: (x - 127.5)/127.5 
    
  2. 定义数据集类(ShoesData)。此数据集类返回原始图像和带有边缘的图像。我们将向网络传递的一个额外细节是一些颜色补丁,这些补丁出现在随机选择的区域中。这样,我们使用户能够从手绘的轮廓图像中取出所需颜色并在图像的不同部分中添加,从而生成新的图像。

    此处显示了一个示例输入(第一幅图像)和输出(第三幅图像)(您可以在本书的数字版中以彩色查看):

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 13.2:(左)原始图像;(中)原始图像的轮廓;(右)带有颜色信息的轮廓图像

    然而,我们拥有的输入图像只是鞋子的图像(最左边的图像),我们将用它来提取鞋子的边缘(中间的图像)。接下来,我们将在下一步中撒上颜色,以获取原始图像的颜色信息(最右边的图像)。当右边的图像通过我们的网络时,应该生成左边的图像。在下面的代码中,我们将构建一个类,该类接受轮廓图像,撒上颜色,并返回撒上颜色的图像对和原始鞋子图像(生成包含轮廓图像的图像):

  3. 定义 ShoesData 类、__init__ 方法和 __len__ 方法:

  4. class ShoesData(Dataset):
        def __init__(self, items):
            self.items = items
        def __len__(self): return len(self.items) 
    
  5. 定义 __getitem__ 方法。在这个方法中,我们将处理输入图像以获取带有边缘的图像,然后在原始图像中撒上颜色。在这里,我们获取给定图像的边缘:

  6.  def __getitem__(self, ix):
            f = self.items[ix]
            try: im = read(f, 1)
            except:
                blank = preprocess(Blank(IMAGE_SIZE, IMAGE_SIZE, 3))
                return blank, blank
            edges = detect_edges(im) 
    
  7. 一旦我们获取了图像中的边缘,我们会调整大小并对图像进行归一化处理:

  8.  im, edges = resize(im, IMAGE_SIZE), resize(edges, IMAGE_SIZE)
            im, edges = normalize(im), normalize(edges) 
    
  9. edges 图像上撒上颜色,并预处理原始图像和 edges 图像:

  10.  self._draw_color_circles_on_src_img(edges, im)
            im, edges = preprocess(im), preprocess(edges)
            return edges, im 
    
  11. 定义撒上颜色的函数:

  12.  def _draw_color_circles_on_src_img(self, img_src,
                                           img_target):
            non_white_coords = self._get_non_white_coordinates(img_target)
            for center_y, center_x in non_white_coords:
                self._draw_color_circle_on_src_img(img_src,
                            img_target, center_y, center_x)
        def _get_non_white_coordinates(self, img):
            non_white_mask = np.sum(img, axis=-1) < 2.75
            non_white_y, non_white_x = np.nonzero(non_white_mask)
            # randomly sample non-white coordinates
            n_non_white = len(non_white_y)
            n_color_points = min(n_non_white, 300)
            idxs = np.random.choice(n_non_white, n_color_points, 
                                                  replace=False)
            non_white_coords = list(zip(non_white_y[idxs],
                                        non_white_x[idxs]))
            return non_white_coords
        def _draw_color_circle_on_src_img(self, img_src,
                                img_target, center_y, center_x):
            assert img_src.shape == img_target.shape
            y0, y1, x0, x1= self._get_color_point_bbox_coords(center_y,                                                          center_x)
            color= np.mean(img_target[y0:y1, x0:x1],axis=(0, 1))
            img_src[y0:y1, x0:x1] = color
        def _get_color_point_bbox_coords(self, center_y,center_x):
            radius = 2
            y0 = max(0, center_y-radius+1)
            y1 = min(IMAGE_SIZE, center_y+radius)
            x0 = max(0, center_x-radius+1)
            x1 = min(IMAGE_SIZE, center_x+radius)
            return y0, y1, x0, x1
        def choose(self): return self[randint(len(self))] 
    
  13. 定义训练和验证数据的对应数据集和数据加载器:

    from sklearn.model_selection import train_test_split
    train_items, val_items = train_test_split(Glob('ShoeV2_photo/*.png'),
                             test_size=0.2, random_state=2)
    trn_ds, val_ds = ShoesData(train_items), ShoesData(val_items)
    trn_dl = DataLoader(trn_ds, batch_size=32, shuffle=True)
    val_dl = DataLoader(val_ds, batch_size=32, shuffle=True) 
    
  14. 定义生成器和鉴别器架构,这些架构使用权重初始化 (weights_init_normal)、UNetDownUNetUp 架构,正如我们在 第九章、“图像分割” 和 第十章、“目标检测和分割的应用” 中定义 GeneratorUNetDiscriminator 架构:

    1. 初始化权重,使其遵循正态分布:
    def weights_init_normal(m):
        classname = m.__class__.__name__
        if classname.find("Conv") != -1:
            torch.nn.init.normal_(m.weight.data, 0.0, 0.02)
        elif classname.find("BatchNorm2d") != -1:
            torch.nn.init.normal_(m.weight.data, 1.0, 0.02)
            torch.nn.init.constant_(m.bias.data, 0.0) 
    
    1. 定义 UnetDownUNetUp 类:
    class UNetDown(nn.Module):
        def __init__(self, in_size, out_size, normalize=True, dropout=0.0):
            super(UNetDown, self).__init__()
            layers = [nn.Conv2d(in_size, out_size, 4, 2, 1, bias=False)]
            if normalize:
                layers.append(nn.InstanceNorm2d(out_size))
            layers.append(nn.LeakyReLU(0.2))
            if dropout:
                layers.append(nn.Dropout(dropout))
            self.model = nn.Sequential(*layers)
        def forward(self, x):
            return self.model(x)
    class UNetUp(nn.Module):
        def __init__(self, in_size, out_size, dropout=0.0):
            super(UNetUp, self).__init__()
            layers = [
                nn.ConvTranspose2d(in_size, out_size, 4, 2, 1, bias=False),
                nn.InstanceNorm2d(out_size),
                nn.ReLU(inplace=True),
            ]
            if dropout:
                layers.append(nn.Dropout(dropout))
            self.model = nn.Sequential(*layers)
        def forward(self, x, skip_input):
            x = self.model(x)
            x = torch.cat((x, skip_input), 1)
            return x 
    
    1. 定义 GeneratorUNet 类:
    class GeneratorUNet(nn.Module):
        def __init__(self, in_channels=3, out_channels=3):
            super(GeneratorUNet, self).__init__()
            self.down1 = UNetDown(in_channels, 64,normalize=False)
            self.down2 = UNetDown(64, 128)
            self.down3 = UNetDown(128, 256)
            self.down4 = UNetDown(256, 512, dropout=0.5)
            self.down5 = UNetDown(512, 512, dropout=0.5)
            self.down6 = UNetDown(512, 512, dropout=0.5)
            self.down7 = UNetDown(512, 512, dropout=0.5)
            self.down8 = UNetDown(512, 512, normalize=False, dropout=0.5)
            self.up1 = UNetUp(512, 512, dropout=0.5)
            self.up2 = UNetUp(1024, 512, dropout=0.5)
            self.up3 = UNetUp(1024, 512, dropout=0.5)
            self.up4 = UNetUp(1024, 512, dropout=0.5)
            self.up5 = UNetUp(1024, 256)
            self.up6 = UNetUp(512, 128)
            self.up7 = UNetUp(256, 64)
            self.final = nn.Sequential(
                nn.Upsample(scale_factor=2),
                nn.ZeroPad2d((1, 0, 1, 0)),
                nn.Conv2d(128, out_channels, 4, padding=1),
                nn.Tanh(),
            )
        def forward(self, x):
            d1 = self.down1(x)
            d2 = self.down2(d1)
            d3 = self.down3(d2)
            d4 = self.down4(d3)
            d5 = self.down5(d4)
            d6 = self.down6(d5)
            d7 = self.down7(d6)
            d8 = self.down8(d7)
            u1 = self.up1(d8, d7)
            u2 = self.up2(u1, d6)
            u3 = self.up3(u2, d5)
            u4 = self.up4(u3, d4)
            u5 = self.up5(u4, d3)
            u6 = self.up6(u5, d2)
            u7 = self.up7(u6, d1)
            return self.final(u7) 
    
    1. 定义 Discriminator 类:
    class Discriminator(nn.Module):
        def __init__(self, in_channels=3):
            super(Discriminator, self).__init__()
            def discriminator_block(in_filters, out_filters, 
                                          normalization=True):
                """Returns downsampling layers of each
                discriminator block"""
                layers = [nn.Conv2d(in_filters, out_filters, 4, 
                                          stride=2, padding=1)]
                if normalization:
                   layers.append(nn.InstanceNorm2d(out_filters))
                layers.append(nn.LeakyReLU(0.2, inplace=True))
                return layers
            self.model = nn.Sequential(
                *discriminator_block(in_channels * 2, 64,
                                     normalization=False),
                *discriminator_block(64, 128),
                *discriminator_block(128, 256),
                *discriminator_block(256, 512),
                nn.ZeroPad2d((1, 0, 1, 0)),
                nn.Conv2d(512, 1, 4, padding=1, bias=False)
                                      )
        def forward(self, img_A, img_B):
            img_input = torch.cat((img_A, img_B), 1)
            return self.model(img_input) 
    
  15. 定义生成器和鉴别器模型对象并获取概要:

    generator = GeneratorUNet().to(device)
    discriminator = Discriminator().to(device)
    from torchsummary import summary
    print(summary(generator, torch.zeros(3, 3, IMAGE_SIZE,
                                IMAGE_SIZE).to(device)))
    print(summary(discriminator, torch.zeros(3, 3, IMAGE_SIZE, IMAGE_SIZE).\to(device), torch.zeros(3, 3, IMAGE_SIZE, IMAGE_SIZE).to(device))) 
    

生成器架构概述如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.3:生成器架构概述

鉴别器架构概述如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.4:鉴别器架构概述

  1. 定义训练鉴别器的函数 (discriminator_train_step):

    1. 鉴别器函数接受源图像 (real_src)、真实目标 (real_trg) 和伪造目标 (fake_trg) 作为输入:
    def discriminator_train_step(real_src, real_trg, fake_trg):
        d_optimizer.zero_grad() 
    
    1. 通过比较真实目标(real_trg)和目标的预测值(real_src)来计算损失(error_real),期望鉴别器将图像预测为真实的(由 torch.ones 表示),然后进行反向传播:
     prediction_real = discriminator(real_trg, real_src)
        error_real = criterion_GAN(prediction_real,
                                   torch.ones(len(real_src), 
                                          1, 16, 16).to(device))
        error_real.backward() 
    
    1. 计算与假图像(fake_trg)对应的鉴别器损失(error_fake);预期鉴别器将假目标分类为假图像(由torch.zeros指示),然后进行反向传播:
     prediction_fake = discriminator( real_src, fake_trg.detach())
        error_fake = criterion_GAN(prediction_fake,
                                   torch.zeros(len(real_src), 1, 
                                        16, 16).to(device))
        error_fake.backward() 
    
    1. 执行优化器步骤,并返回预测的真实和假目标的总体错误和损失值:
     d_optimizer.step()
        return error_real + error_fake 
    
  2. 定义训练生成器的函数(generator_train_step),该函数接受一个假的目标(fake_trg)并训练它,使其在通过鉴别器时被识别为假的概率很低:

    def generator_train_step(real_src, fake_trg):
        g_optimizer.zero_grad()
        prediction = discriminator(fake_trg, real_src)
        loss_GAN = criterion_GAN(prediction, 
                torch.ones(len(real_src), 1, 16, 16).to(device))
        loss_pixel = criterion_pixelwise(fake_trg, real_trg)
        loss_G = loss_GAN + lambda_pixel * loss_pixel
        loss_G.backward()
        g_optimizer.step()
        return loss_G 
    

请注意,在前述代码中,除了生成器损失外,我们还获取了对应于给定轮廓的生成图像和真实图像之间差异的像素损失(loss_pixel)。

  1. 定义一个函数来获取预测的样本:

    denorm = T.Normalize((-1, -1, -1), (2, 2, 2))
    def sample_prediction():
        """Saves a generated sample from the validation set"""
        data = next(iter(val_dl))
        real_src, real_trg = data
        fake_trg = generator(real_src)
        img_sample = torch.cat([denorm(real_src[0]),
                                denorm(fake_trg[0]),
                                denorm(real_trg[0])], -1)
        img_sample = img_sample.detach().cpu().permute(1,2,0).numpy()
        show(img_sample, title='Source::Generated::GroundTruth',sz=12) 
    
  2. 对生成器和鉴别器模型对象应用权重初始化(weights_init_normal):

    generator.apply(weights_init_normal)
    discriminator.apply(weights_init_normal) 
    
  3. 指定损失标准和优化方法(criterion_GANcriterion_pixelwise):

    criterion_GAN = torch.nn.MSELoss()
    criterion_pixelwise = torch.nn.L1Loss()
    lambda_pixel = 100
    g_optimizer = torch.optim.Adam(generator.parameters(),
                 	             lr=0.0002, betas=(0.5, 0.999))
    d_optimizer= torch.optim.Adam(discriminator.parameters(),
                                    lr=0.0002, betas=(0.5, 0.999)) 
    
  4. 训练模型超过 100 个时期:

    epochs = 100
    log = Report(epochs)
    for epoch in range(epochs):
        N = len(trn_dl)
        for bx, batch in enumerate(trn_dl):
            real_src, real_trg = batch
            fake_trg = generator(real_src)
            errD = discriminator_train_step(real_src,  real_trg, fake_trg)
            errG = generator_train_step(real_src, fake_trg)
            log.record(pos=epoch+(1+bx)/N, errD=errD.item(),
                       errG=errG.item(), end='\r')
        [sample_prediction() for _ in range(2)] 
    
  5. 在样本手绘轮廓上生成输出(图像):

    [sample_prediction() for _ in range(2)] 
    

上述代码生成以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.5:(左)输入图像;(中)生成的图像;(右)原始图像

请注意,在前述输出中,我们生成了具有与原始图像相似颜色的图像。作为练习,我们鼓励您训练更多时期的模型,并查看生成图像的改进。

在本节中,我们学习了如何使用图像的轮廓来生成图像。然而,这要求我们提供输入和输出作为一对,这可能是一个繁琐的过程。在下一节中,我们将学习无配对图像转换的方法。这是通过网络自动学习图像转换而无需指定输入和输出映射的过程。

利用 CycleGAN

想象一个场景,我们要求您执行从一个类别到另一个类别的图像转换,但是不使用输入和相应的输出图像来训练模型。例如,将电影当前场景中的一个演员更换为另一个演员。然而,我们会给您两个不同文件夹中的两个类别/演员的图像。在这种情况下,CycleGAN 非常有用。

在本节中,我们将学习如何训练 CycleGAN(arxiv.org/abs/1703.10593)将苹果的图像转换为橙子的图像,反之亦然。但首先,让我们了解 CycleGAN 的工作原理。

CycleGAN 的工作原理

CycleGAN 中的“Cycle”指的是我们将图像从一个类别转换为另一个类别,然后再转换回原始类别的过程。在高层次上,此架构中将有三个单独的损失值。关于损失计算的更多细节请参阅下一页:

  • 对抗损失:确保领域生成器使用另一个领域的图像作为输入准确地创建各自领域中的对象。与标准 GAN 的唯一区别在于,生成器接受图像而不是噪声。

  • 循环损失:再循环生成图像到原始图像的损失,以确保周围像素不被改变。

  • 身份损失:当一个输入图像属于一个类别时,通过预期将另一个类别的图像转换为输入图像类别的生成器时的损失。

在这里,我们将讲解构建 CycleGAN 的高级步骤:

  1. 导入并预处理数据集。

  2. 构建生成器和鉴别器网络 UNet 架构。

  3. 定义两个生成器:

    • G_AB:将类 A 图像转换为类 B 图像的生成器

    • G_BA:将类 B 图像转换为类 A 图像的生成器

  4. 定义身份损失:

    • 如果你将一个橙子图像发送给一个橙子生成器,理想情况下,如果生成器已经完全理解了橙子的所有内容,它不应该改变图像,而是应该“生成”完全相同的图像。因此,我们利用这个知识创建一个身份。

    • 当通过 G_BA 传递类 A 的图像(real_A)并与 real_A 进行比较时,身份损失应该是最小的。

    • 当通过 G_AB 传递类 B 的图像(real_B)并与 real_B 进行比较时,身份损失应该是最小的。

  5. 定义GAN 损失:

    • 对于 real_A 和 fake_A(当 real_B 图像通过 G_BA 时获得的 fake_A),鉴别器和生成器损失。

    • 对于 real_B 和 fake_B(当 real_A 图像通过 G_AB 时获得的 fake_B),鉴别器和生成器损失。

  6. 定义再循环损失:

    • 考虑这样一个场景:将一个苹果图像通过橙子生成器转换为生成假橙子,然后通过苹果生成器将假橙子转换回苹果。

    • 当 real_A 通过 G_AB 传递时获得的 fake_B 应该在 fake_B 通过 G_BA 传递时再生出 real_A。

    • 当 real_B 通过 G_BA 传递时获得的 fake_A 应该在 fake_A 通过 G_AB 传递时再生出 real_B。

  7. 优化三个损失的加权损失。

现在我们理解了步骤,让我们编写代码来将苹果转换成橙子,反之亦然。

实施 CycleGAN

要实现我们刚刚讨论的步骤,可以使用以下代码:

该代码作为本书 GitHub 存储库中Chapter13文件夹下的CycleGAN.ipynb可用:bit.ly/mcvp-2e。代码包含从中下载数据的 URL,并且长度适中。我们强烈建议您在 GitHub 上执行笔记本以在阅读代码组件的步骤和解释时再现结果。

  1. 下载并提取包含苹果和橙子图像的数据集文件夹:

    !wget https://www.dropbox.com/s/2xltmolfbfharri/apples_oranges.zip
    !unzip apples_oranges.zip 
    

这是我们将要处理的图像的一个示例:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.6:样本图像

注意,苹果和橙子图像之间没有一对一的对应关系(不像我们在Leveraging the Pix2Pix GAN一节中学到的轮廓到鞋子生成的用例)。

  1. 导入所需的包:

    !pip install torch_snippets torch_summary
    import itertools
    from PIL import Image
    from torch_snippets import *
    from torchvision import transforms
    from torchvision.utils import make_grid
    from torchsummary import summary 
    
  2. 定义图像转换流水线(transform):

    IMAGE_SIZE = 256
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    transform = transforms.Compose([
        transforms.Resize(int(IMAGE_SIZE*1.33)),
        transforms.RandomCrop((IMAGE_SIZE,IMAGE_SIZE)),
        transforms.RandomHorizontalFlip(),
        transforms.ToTensor(),
        transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))]) 
    
  3. 定义数据集类(CycleGANDataset),接受苹果和橙子文件夹(在解压下载的数据集后获取)作为输入,并提供一批苹果和橙子图像:

    class CycleGANDataset(Dataset):
        def __init__(self, apples, oranges):
            self.apples = Glob(apples)
            self.oranges = Glob(oranges)
        def __getitem__(self, ix):
            apple = self.apples[ix % len(self.apples)]
            orange = choose(self.oranges)
            apple = Image.open(apple).convert('RGB')
            orange = Image.open(orange).convert('RGB')
            return apple, orange
        def __len__(self): return max(len(self.apples), len(self.oranges))
        def choose(self): return self[randint(len(self))]
        def collate_fn(self, batch):
            srcs, trgs = list(zip(*batch))
            srcs=torch.cat([transform(img)[None] for img in \
                             srcs], 0).to(device).float()
            trgs=torch.cat([transform(img)[None] for img in \ 
                             trgs], 0).to(device).float()
            return srcs.to(device), trgs.to(device) 
    
  4. 定义训练和验证数据集以及数据加载器:

    trn_ds = CycleGANDataset('apples_train', 'oranges_train')
    val_ds = CycleGANDataset('apples_test', 'oranges_test')
    trn_dl = DataLoader(trn_ds, batch_size=1, shuffle=True,
                        collate_fn=trn_ds.collate_fn)
    val_dl = DataLoader(val_ds, batch_size=5, shuffle=True,
                        collate_fn=val_ds.collate_fn) 
    
  5. 定义网络的权重初始化方法(weights_init_normal),如前几节所定义:

    def weights_init_normal(m):
        classname = m.__class__.__name__
        if classname.find("Conv") != -1:
            torch.nn.init.normal_(m.weight.data, 0.0, 0.02)
            if hasattr(m, "bias") and m.bias is not None:
                torch.nn.init.constant_(m.bias.data, 0.0)
        elif classname.find("BatchNorm2d") != -1:
            torch.nn.init.normal_(m.weight.data, 1.0, 0.02)
            torch.nn.init.constant_(m.bias.data, 0.0) 
    
  6. 定义残差块网络(ResidualBlock),因为在这个例子中,我们将使用 ResNet:

    class ResidualBlock(nn.Module):
        def __init__(self, in_features):
            super(ResidualBlock, self).__init__()
            self.block = nn.Sequential(
                nn.ReflectionPad2d(1),
                nn.Conv2d(in_features, in_features, 3),
                nn.InstanceNorm2d(in_features),
                nn.ReLU(inplace=True),
                nn.ReflectionPad2d(1),
                nn.Conv2d(in_features, in_features, 3),
                nn.InstanceNorm2d(in_features),
            )
        def forward(self, x):
            return x + self.block(x) 
    
  7. 定义生成器网络(GeneratorResNet):

    class GeneratorResNet(nn.Module):
        def __init__(self, num_residual_blocks=9):
            super(GeneratorResNet, self).__init__()
            out_features = 64
            channels = 3
            model = [
                nn.ReflectionPad2d(3),
                nn.Conv2d(channels, out_features, 7),
                nn.InstanceNorm2d(out_features),
                nn.ReLU(inplace=True),
            ]
            in_features = out_features
            # Downsampling
            for _ in range(2):
                out_features *= 2
                model += [
                    nn.Conv2d(in_features, out_features, 3,
                              stride=2, padding=1),
                    nn.InstanceNorm2d(out_features),
                    nn.ReLU(inplace=True),
                         ]
                in_features = out_features
            # Residual blocks
            for _ in range(num_residual_blocks):
                model += [ResidualBlock(out_features)]
            # Upsampling
            for _ in range(2):
                out_features //= 2
                model += [
                    nn.Upsample(scale_factor=2),
                    nn.Conv2d(in_features, out_features, 3,
                              stride=1, padding=1),
                    nn.InstanceNorm2d(out_features),
                    nn.ReLU(inplace=True),
                         ]
                in_features = out_features
            # Output layer
            model += [nn.ReflectionPad2d(channels),
                      nn.Conv2d(out_features, channels, 7),
                      nn.Tanh()]
            self.model = nn.Sequential(*model)
            self.apply(weights_init_normal)
        def forward(self, x):
            return self.model(x) 
    
  8. 定义鉴别器网络(Discriminator):

    class Discriminator(nn.Module):
        def __init__(self):
            super(Discriminator, self).__init__()
            channels, height, width = 3, IMAGE_SIZE, IMAGE_SIZE
            def discriminator_block(in_filters, out_filters,  
                                            normalize=True):
                """Returns downsampling layers of each
                discriminator block"""
                layers = [nn.Conv2d(in_filters, out_filters, 4, 
                                    stride=2, padding=1)]
                if normalize:
                   layers.append(nn.InstanceNorm2d(out_filters))
                layers.append(nn.LeakyReLU(0.2, inplace=True))
                return layers
            self.model = nn.Sequential(
              *discriminator_block(channels,64,normalize=False),
              *discriminator_block(64, 128),
              *discriminator_block(128, 256),
              *discriminator_block(256, 512),
              nn.ZeroPad2d((1, 0, 1, 0)),
              nn.Conv2d(512, 1, 4, padding=1)
            )
            self.apply(weights_init_normal)
        def forward(self, img):
            return self.model(img) 
    
  9. 定义生成图像样本的函数 – generate_sample

    @torch.no_grad()
    def generate_sample():
        data = next(iter(val_dl))
        G_AB.eval()
        G_BA.eval()   
        real_A, real_B = data
        fake_B = G_AB(real_A)
        fake_A = G_BA(real_B)
        # Arange images along x-axis
        real_A = make_grid(real_A, nrow=5, normalize=True)
        real_B = make_grid(real_B, nrow=5, normalize=True)
        fake_A = make_grid(fake_A, nrow=5, normalize=True)
        fake_B = make_grid(fake_B, nrow=5, normalize=True)
        # Arange images along y-axis
        image_grid = torch.cat((real_A,fake_B,real_B,fake_A), 1)
    show(image_grid.detach().cpu().permute(1,2,0).numpy(),sz=12) 
    
  10. 定义训练生成器的函数(generator_train_step):

    1. 函数将两个生成器模型(G_AB 和 G_BA)作为Gsoptimizer,以及两类真实图像real_Areal_B作为输入:
    def generator_train_step(Gs, optimizer, real_A, real_B): 
    
    1. 指定生成器:
     G_AB, G_BA = Gs 
    
    1. 将优化器的梯度设置为零:
     optimizer.zero_grad() 
    
    1. 如果将橙色图像发送到橙色生成器,理想情况下,如果生成器完全理解了橙子的所有内容,它不应对图像做任何更改,并且应该“生成”出完全相同的图像。因此,我们利用这一知识创建了一个身份。对应于criterion_identity的损失函数将在模型训练之前定义。计算类型为 A(苹果)和 B(橙子)的身份损失(loss_identity):
     loss_id_A = criterion_identity(G_BA(real_A), real_A)
        loss_id_B = criterion_identity(G_AB(real_B), real_B)
        loss_identity = (loss_id_A + loss_id_B) / 2 
    
    1. 当图像通过生成器传递,并且生成的图像预期尽可能接近另一类时,计算 GAN 损失(在这种情况下,当训练生成器时,我们对同一类别的伪造图像传递给鉴别器时,使用np.ones):
     fake_B = G_AB(real_A)
        loss_GAN_AB = criterion_GAN(D_B(fake_B),
                        torch.Tensor(np.ones((len(real_A), 1,
                                          16, 16))).to(device))
        fake_A = G_BA(real_B)
        loss_GAN_BA = criterion_GAN(D_A(fake_A),
                        torch.Tensor(np.ones((len(real_A), 1,
                                          16, 16))).to(device))
        loss_GAN = (loss_GAN_AB + loss_GAN_BA) / 2 
    
    1. 计算循环损失。考虑这样一个场景:一个苹果图像经由橙色生成器转换成一个伪橙色图像,然后这个伪橙色图像再经由苹果生成器转换回苹果。如果生成器是完美的,这个过程应该返回原始图像,这意味着以下循环损失应该为零:
     recov_A = G_BA(fake_B)
        loss_cycle_A = criterion_cycle(recov_A, real_A)
        recov_B = G_AB(fake_A)
        loss_cycle_B = criterion_cycle(recov_B, real_B)
        loss_cycle = (loss_cycle_A + loss_cycle_B) / 2 
    
    1. 计算总加权损失(其中lambda_cyclambda_id分别是循环损失和身份损失的权重),在返回计算值之前进行反向传播:
     loss_G = loss_GAN + lambda_cyc * loss_cycle + \
                lambda_id * loss_identity
        loss_G.backward()
        optimizer.step()
        return loss_G, loss_identity, loss_GAN, loss_cycle,
                loss_G, fake_A, fake_B 
    
  11. 定义训练鉴别器的函数(discriminator_train_step):

    def discriminator_train_step(D, real_data, fake_data, optimizer):
        optimizer.zero_grad()
        loss_real = criterion_GAN(D(real_data),
                      torch.Tensor(np.ones((len(real_data), 1,
                                       16, 16))).to(device))
        loss_fake = criterion_GAN(D(fake_data.detach()),
                      torch.Tensor(np.zeros((len(real_data), 1,
                                       16, 16))).to(device))
        loss_D = (loss_real + loss_fake) / 2
        loss_D.backward()
        optimizer.step()
        return loss_D 
    
  12. 定义生成器、鉴别器对象、优化器和损失函数:

    G_AB = GeneratorResNet().to(device)
    G_BA = GeneratorResNet().to(device)
    D_A = Discriminator().to(device)
    D_B = Discriminator().to(device)
    criterion_GAN = torch.nn.MSELoss()
    criterion_cycle = torch.nn.L1Loss()
    criterion_identity = torch.nn.L1Loss()
    optimizer_G = torch.optim.Adam(
        itertools.chain(G_AB.parameters(), G_BA.parameters()),
        lr=0.0002, betas=(0.5, 0.999))
    optimizer_D_A = torch.optim.Adam(D_A.parameters(),
                            lr=0.0002, betas=(0.5, 0.999))
    optimizer_D_B = torch.optim.Adam(D_B.parameters(),
                            lr=0.0002, betas=(0.5, 0.999))
    lambda_cyc, lambda_id = 10.0, 5.0 
    
  13. 在增加的周期内训练网络:

    n_epochs = 10
    log = Report(n_epochs)
    for epoch in range(n_epochs):
        N = len(trn_dl)
        for bx, batch in enumerate(trn_dl):
            real_A, real_B = batch
            loss_G, loss_identity, loss_GAN, loss_cycle, \
            loss_G, fake_A, fake_B = generator_train_step(\
                                      (G_AB,G_BA), optimizer_G, 
                                             real_A, real_B)
            loss_D_A = discriminator_train_step(D_A, real_A, 
                                      fake_A, optimizer_D_A)
            loss_D_B = discriminator_train_step(D_B, real_B, 
                                      fake_B, optimizer_D_B)
            loss_D = (loss_D_A + loss_D_B) / 2
    
            log.record(epoch+(1+bx)/N, loss_D=loss_D.item(),
              loss_G=loss_G.item(), loss_GAN=loss_GAN.item(),
                loss_cycle=loss_cycle.item(),
               loss_identity=loss_identity.item(), end='\r')
            if bx%100==0: generate_sample()
        log.report_avgs(epoch+1) 
    
  14. 在我们训练了模型之后生成图像:

    generate_sample() 
    

前面的代码生成了以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.7:原始和重建的苹果和橙子以及反之亦然

在前面的图像中,我们可以看到我们成功地将苹果转换成橙子(前两行),以及将橙子转换成苹果(最后两行)。

到目前为止,我们已经学习了通过 Pix2Pix GAN 进行成对图像转换和通过 CycleGAN 进行非成对图像转换。在接下来的章节中,我们将学习如何利用 StyleGAN 将一种风格的图像转换为另一种风格的图像。

利用 StyleGAN 处理自定义图像

在第十一章中,我们学习了神经风格转移。我们通过将一幅图像的风格与另一幅图像的内容混合来生成一幅图像。然而,如果我们想要在图片中创建一个人的年轻版本或者给图像增加眼镜等特定属性,StyleGAN 可以做到这一点。让我们在接下来的几节中学习如何做到这一点。

StyleGAN 的演变

让我们首先看一些在 StyleGAN 发明之前的发展。众所周知,生成假面孔(正如我们在上一章中看到的)涉及到使用 GAN。研究面临的最大问题是,可以生成的图像很小(通常为 64 x 64)。任何尝试生成更大图像的努力都会导致生成器或鉴别器陷入局部最小值,停止训练并生成胡言乱语。在一篇提出 Progressive GAN(ProGAN)的研究论文中出现了生成高质量图像的重大进展,它使用了一个巧妙的技巧。逐步增加生成器和鉴别器的大小:

  1. 首先,您创建一个生成器和鉴别器,以从潜在向量生成 4 x 4 的图像。

  2. 接下来,额外的卷积(和放大)层被添加到训练好的生成器和鉴别器中,这些层负责接受 4 x 4 像素的图像(这些图像是从步骤 1 中的潜在向量生成的),并生成/鉴别 8 x 8 像素的图像。

  3. 然后,在生成器和鉴别器中再次创建新的层,以便它们可以被训练生成更大的图像。逐步增加图像大小的逻辑是,向已经正常运行的网络添加新层比尝试从头学习所有层更容易。

以这种方式,图像被放大到 1,024 x 1,024 像素的分辨率:

图 13.8:图像放大过程(来源:))

尽管它取得了成功,但要控制生成图像的各个方面(如性别和年龄)却相当困难,主要因为网络只接收一个输入(在前述图像中,这是网络顶部的潜在)。StyleGAN 解决了这个问题。它使用类似的训练方案逐步生成图像,但每次网络增长时都添加了一组额外的潜在输入。这意味着网络现在在固定间隔接受多个潜在向量(如图 13.9 中 (a) 区块所示)。在生成的阶段,每个给定的潜在向量决定了在网络的该阶段生成的特征。让我们在这里更详细地讨论 StyleGAN 的工作细节:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.9:StyleGAN 工作细节

(来源:arxiv.org/pdf/1812.04948

在前述图中,我们可以对比传统生成图像的方式和基于风格的生成器。在传统生成器中,只有一个输入。然而,在基于风格的生成器内部有一种机制。让我们看看细节:

  1. 创建一个大小为 1 x 512 的随机噪声向量 z

  2. 将其提供给称为风格网络(或映射网络)的辅助网络,该网络创建了一个大小为 18 x 512 的张量 w

  3. 生成器(合成)网络包含 18 个卷积层。每一层将接受以下输入:

    • w 的相应行(A)

    • 一个随机噪声向量(B)

    • 前一层的输出

注意,噪声(B)仅用于正则化目的。

前述三者的结合将创建一个管道,接受一个大小为 1 x 512 的向量,并生成一个 1,024 x 1,024 的图像。

现在让我们详细讨论从映射网络生成的大小为 18 x 512 向量中的每个 1 x 512 向量如何对图像的生成贡献:

在合成网络的前几层添加的 1 x 512 向量有助于生成图像的大尺度特征,例如姿势和面部形状(因为它们负责生成 4 x 4、8 x 8 图像等,这些是稍后层次中将进一步增强的第一批图像)。

在中间层添加的向量对应于小尺度特征,如发型和眼睛是否睁开或闭合(因为它们负责生成 16 x 16、32 x 32 和 64 x 64 图像)。

在最后几层添加的向量对应于图像的颜色方案和其他微观结构。当我们达到最后几层时,图像结构被保留,面部特征也被保留,但仅改变像素级别的细节,例如光照条件。

在接下来的部分,我们将利用预训练的 StyleGAN2 模型来自定义我们感兴趣的图像,使其具有不同的风格。对于我们的目标,我们将使用 StyleGAN2 模型执行样式迁移。在高层次上,面部样式迁移的工作方式如下(随着您通过代码结果进行查看,以下内容将更加清晰):

  • 假设 w1 样式向量用于生成 face-1w2 样式向量用于生成 face-2。两个向量的形状均为 18 x 512。

  • w2 的前几个 18 个向量(负责从 4 x 4 到 8 x 8 分辨率生成图像)中,将其与 w1 中对应的向量替换。然后,我们将非常粗略的特征,如从 face-1face-2 的姿势,进行转移。

  • 如果后续样式向量(例如 18 x 512 向量中的第三到第十五个,负责生成 64 x 64 到 256 x 256 分辨率的图像)在 w2 中被来自 w1 的向量替换,则我们转移眼睛、鼻子和其他中级面部特征。

  • 如果替换了最后几个样式向量(负责生成 512 x 512 到 1,024 x 1,024 分辨率图像),则会转移细节特征,如肤色和背景(这些特征对整体面部影响不大)。

理解了样式迁移的实现方式,现在让我们看看如何使用 StyleGAN2 进行样式迁移。

实现 StyleGAN

要使用 StyleGAN2 在自定义图像上实现样式迁移,我们遵循以下广泛步骤:

  1. 采用自定义图像。

  2. 将自定义图像对齐,以便仅存储图像的面部区域。

  3. 获取通过 StyleGAN2 传递时可能生成自定义对齐图像的潜在向量。

  4. 通过将随机噪声/潜在向量(1 x 512)传递给映射网络生成图像。

到这一步,我们有两幅图像 —— 我们的自定义对齐图像和通过 StyleGAN2 网络生成的图像。现在,我们希望将自定义图像的一些特征转移到生成的图像中,反之亦然。让我们编写前述策略的代码(请注意,我们正在利用从 GitHub 存储库获取的预训练网络,因为训练这样的网络需要数天甚至数周时间)。

您需要一个支持 CUDA 的环境来运行以下代码。以下代码可以在本书的 GitHub 仓库的 Chapter13 文件夹中的 Customizing_StyleGAN2.ipynb 中找到 bit.ly/mcvp-2e。该代码包含了从中下载数据的 URL,并且代码长度适中。我们强烈建议您在 GitHub 上执行此笔记本,以便在阅读步骤和文本中的各种代码组件的说明时重现结果。

  1. 克隆存储库,安装要求并获取预训练权重:

    import os
    if not os.path.exists('pytorch_stylegan_encoder'):
        !git clone https://github.com/sizhky/pytorch_stylegan_encoder.git
        %cd pytorch_stylegan_encoder
        !git submodule update --init --recursive
        !wget -q https://github.com/jacobhallberg/pytorch_stylegan_encoder/releases/download/v1.0/trained_models.zip
        !unzip -q trained_models.zip
        !rm trained_models.zip
        !pip install -qU torch_snippets
        !mv trained_models/stylegan_ffhq.pth InterFaceGAN/models/pretrain
    else:
        %cd pytorch_stylegan_encoder
    
    from torch_snippets import * 
    
  2. 加载预训练生成器和合成网络,映射网络的权重:

    from InterFaceGAN.models.stylegan_generator import StyleGANGenerator
    from models.latent_optimizer import PostSynthesisProcessing
    synthesizer=StyleGANGenerator("stylegan_ffhq").model.synthesis
    mapper = StyleGANGenerator("stylegan_ffhq").model.mapping
    trunc = StyleGANGenerator("stylegan_ffhq").model.truncation 
    
  3. 定义一个从随机向量生成图像的函数:

    post_processing = PostSynthesisProcessing()
    post_process = lambda image: post_processing(image)\
                    .detach().cpu().numpy().astype(np.uint8)[0]
    def latent2image(latent):
        img = post_process(synthesizer(latent))
        img = img.transpose(1,2,0)
        return img 
    
  4. 生成一个随机向量:

    rand_latents = torch.randn(1,512).cuda() 
    

在上述代码中,我们将随机的 1 x 512 维向量通过映射和截断网络生成一个 1 x 18 x 512 的向量。这些 18 x 512 向量决定了生成图像的风格。

  1. 从随机向量生成一个图像:

    show(latent2image(trunc(mapper(rand_latents))), sz=5) 
    

前述代码生成以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.10:来自随机潜在的图像

到目前为止,我们已经生成了一个图像。在接下来的几行代码中,您将学习如何在前面生成的图像与您选择的图像之间执行风格转移。

  1. 获取自定义图像(MyImage.jpg)并对齐它。对齐是生成正确潜在向量的重要步骤,因为 StyleGAN 中生成的所有图像都有面部居中和特征明显可见:

    !wget https://www.dropbox.com/s/lpw10qawsc5ipbn/MyImage.JPG\
     -O MyImage.jpg
    !git clone https://github.com/Puzer/stylegan-encoder.git
    !mkdir -p stylegan-encoder/raw_images
    !mkdir -p stylegan-encoder/aligned_images
    !mv MyImage.jpg stylegan-encoder/raw_images 
    
  2. 对齐自定义图像:

    !python stylegan-encoder/align_images.py \
            stylegan-encoder/raw_images/ \
            stylegan-encoder/aligned_images/
    !mv stylegan-encoder/aligned_images/* ./MyImage.jpg 
    
  3. 使用对齐图像生成完美重现对齐图像的潜在变量。这是识别最小化对齐图像与从潜在向量生成的图像之间差异的潜在向量组合的过程:

    from PIL import Image
    img = Image.open('MyImage.jpg')
    show(np.array(img), sz=4, title='original')
    !python encode_image.py ./MyImage.jpg\
     pred_dlatents_myImage.npy\
     --use_latent_finder true\
     --image_to_latent_path ./trained_models/image_to_latent.pt
    pred_dlatents = np.load('pred_dlatents_myImage.npy')
    pred_dlatent = torch.from_numpy(pred_dlatents).float().cuda()
    pred_image = latent2image(pred_dlatent)
    show(pred_image, sz=4, title='synthesized') 
    

前述代码生成以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.11:原始图像和相应潜在图像的合成图像

Python 脚本encode_image.py在高层次上执行以下操作(为了彻底了解每个步骤,请查看 GitHub 存储库中的脚本):

  1. 在潜在空间中创建一个随机向量。或者,我们可以通过将原始图像通过网络传递初始化为权重image_to_latent.pt和文件models/image_to_latent.py中的架构,以获得需要较少优化的初始潜在(向量)集合。

  2. 用这个向量合成一个图像。

  3. 使用 VGG 的感知损失比较合成图像与原始输入图像。

  4. w随机向量进行反向传播,以在固定迭代次数内减少此损失。

  5. 优化的潜在向量现在将合成一个图像,该图像在 VGG 给出的特征几乎与输入图像相同,因此合成图像将类似于输入图像。我们现在有了对应于感兴趣图像的潜在向量。

  6. 在图像之间执行风格转移。如讨论的那样,风格转移背后的核心逻辑实际上是风格张量的部分转移,即 18 x 512 风格张量的 18 个子集之一。在这里,我们将在一个案例中传输前两行(18 x 512 张量的两行),在一个案例中传输第 3-15 行,以及在一个案例中传输第 15-18 行。由于每组向量负责生成图像的不同方面,每组交换的向量集合交换图像中的不同特征:

    idxs_to_swap = slice(0,3)
    my_latents=torch.Tensor(np.load('pred_dlatents_myImage.npy',
                                             allow_pickle=True))
    A, B = latent2image(my_latents.cuda()),
           latent2image(trunc(mapper(rand_latents)))
    generated_image_latents = trunc(mapper(rand_latents))
    x = my_latents.clone()
    x[:,idxs_to_swap] = generated_image_latents[:,idxs_to_swap]
    a = latent2image(x.float().cuda())
    x = generated_image_latents.clone()
    x[:,idxs_to_swap] = my_latents[:,idxs_to_swap]
    b = latent2image(x.float().cuda())
    subplots([A,a,B,b], figsize=(7,8), nc=2,
             suptitle='Transfer high level features') 
    

前述代码生成以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.12:原始图像(左侧)和对应的风格转移图像(右侧)

图 13.12中,由于我们在流水线的早期阶段进行了交换,因此最高级别的特征,如年龄,被交换了。在交换下一个层级的特征(4 到 15),我们将看到下一个层级的特征,如颜色调色板和背景,被交换了。最后,层次(15,18)似乎根本没有改变图像,因为这些特征非常微妙,影响图片中非常细微的细节,如光照。这里的输出带有idxs_to_swap分别为slice(4,15)slice (15,18)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.13:不同层级上的层交换风格转移

  1. 接下来,我们推断出一个风格向量,使得新向量只会改变我们自定义图像的表情。为此,您需要计算将潜在向量移动的正确方向。我们可以通过首先创建大量的假图像来实现这一点。然后使用 SVM 分类器来识别图像中的人是否微笑。因此,这个 SVM 创建了一个分隔笑脸和非笑脸的超平面。移动的所需方向将是垂直于这个超平面,这被呈现为stylegan_ffhq_smile_w_boundary.npy。实现细节可以在InterfaceGAN/edit.py代码中找到:

    !python InterFaceGAN/edit.py\
     -m stylegan_ffhq\
     -o results_new_smile\
     -b InterFaceGAN/boundaries/stylegan_ffhq_smile_w_boundary.npy\
     -i pred_dlatents_myImage.npy\
     -s WP\
     --steps 20
    generated_faces = glob.glob('results_new_smile/*.jpg')
    subplots([read(im,1) for im in sorted(generated_faces)], 
                                            figsize=(10,10)) 
    

这里展示了生成的图像的外观:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.14:从皱眉到微笑的情感变化进展

总结一下,我们学习了如何利用 GAN 生成非常高分辨率的人脸图像的研究进展。关键是通过逐步增加生成器和鉴别器的复杂性,以增加分辨率的步骤,使得在每一步中,这两个模型在其任务上都表现得不错。我们学习了如何通过确保每个分辨率上的特征由独立输入(称为风格向量)来操纵生成图像的风格。我们还学习了如何通过从一幅图像中交换风格来操纵不同图像的风格。

VToonify 可用于从输入视频生成高质量的艺术变化。有关论文和相关代码,请访问这里:github.com/williamyang1991/VToonify

现在我们已经学会如何利用预训练的 StyleGAN2 模型执行风格转移,在接下来的部分,我们将利用预训练的 SRGAN 模型生成高分辨率的图像。

引入 SRGAN

在前一节中,我们看到了一个场景,我们在其中使用预训练的 StyleGAN 来生成给定风格的图像。在本节中,我们将进一步学习如何使用预训练模型来执行图像超分辨率。在实施之前,我们将了解 SRGAN 模型的架构。

首先,我们将解释为什么 GAN 是超分辨率任务的一个好解决方案。想象一种情况,你拿到一张图像,并被要求提高其分辨率。直觉上,你会考虑各种插值技术来进行超分辨率。以下是一个低分辨率图像示例,以及各种技术的输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.15:不同图像超分辨率技术的性能

(来源:arxiv.org/pdf/1609.04802.pdf

在前述图像中,我们可以看到传统的插值技术,如双三次插值,在从低分辨率重建图像(在本例中为原始图像的 4 倍降低缩放图像)方面并没有太大帮助。

尽管基于超分辨率 ResNet 的 UNet 在这种情况下可能很有用,但是 GAN 可以更有用,因为它们模拟了人类的感知。鉴别器知道典型的超分辨率图像是什么样子,可以检测到生成的图像具有不像高分辨率图像的特性的情况。

通过验证 GAN 在超分辨率中的实用性,让我们来了解并利用预训练模型。

架构

尽管可以从头开始编码和训练 SRGAN,但我们将在可能的情况下使用预训练模型。因此,在本节中,我们将利用 Christian Ledig 及其团队开发并发表在标题为《使用生成对抗网络的逼真单图像超分辨率》的论文中的模型。

SRGAN 的架构如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.16:SRGAN 架构

(来源:arxiv.org/pdf/1609.04802.pdf

在前述图像中,我们可以看到鉴别器将高分辨率图像作为输入来训练一个模型,预测一个图像是高分辨率还是低分辨率图像。生成器网络以低分辨率图像作为输入,并生成高分辨率图像。在训练模型时,同时最小化内容损失和对抗损失。如果你希望详细了解模型训练的细节以及各种技术用于生成高分辨率图像的比较结果,我们建议你阅读 Ledig 的论文。

在理解了模型构建的高层次之后,我们现在将编写代码来利用预训练的 SRGAN 模型将低分辨率图像转换为高分辨率图像。

编码 SRGAN

以下是加载预训练的 SRGAN 并进行预测的步骤:

以下代码在本书 GitHub 仓库的 Chapter13 文件夹中可作为 Image super resolution using SRGAN.ipynb 使用:bit.ly/mcvp-2e 代码包含了从中下载数据的 URL。我们强烈建议您在 GitHub 上执行笔记本,以便在您进行步骤执行和文本中各种代码组件的解释时重现结果。

  1. 导入相关包和预训练模型:

    import os
    if not os.path.exists('srgan.pth.tar'):
        !pip install -q torch_snippets
        !wget -q https://raw.githubusercontent.com/sizhky/a-PyTorch-Tutorial-to-Super-Resolution/master/models.py -O models.py
        from pydrive.auth import GoogleAuth
        from pydrive.drive import GoogleDrive
        from google.colab import auth
        from oauth2client.client import GoogleCredentials
        auth.authenticate_user()
        gauth = GoogleAuth()
        gauth.credentials = GoogleCredentials.get_application_default()
        drive = GoogleDrive(gauth)
        downloaded = drive.CreateFile({'id': \
                        '1_PJ1Uimbr0xrPjE8U3Q_bG7XycGgsbVo'})
        downloaded.GetContentFile('srgan.pth.tar')
        from torch_snippets import *
        device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  2. 加载模型:

    model = torch.load('srgan.pth.tar', map_location='cpu')['generator'].to(device)
    model.eval() 
    
  3. 获取要转换为更高分辨率的图像:

    !wget https://www.dropbox.com/s/nmzwu68nrl9j0lf/Hema6.JPG 
    
  4. 定义预处理和后处理图像的函数:

    preprocess = T.Compose([
                    T.ToTensor(),
                    T.Normalize([0.485, 0.456, 0.406],
                                [0.229, 0.224, 0.225]),
                    T.Lambda(lambda x: x.to(device))
                ])
    postprocess = T.Compose([
                    T.Lambda(lambda x: (x.cpu().detach()+1)/2),
                    T.ToPILImage()
                ]) 
    
  5. 加载并预处理图像:

    image = readPIL('Hema6.JPG')
    image.size
    # (260,181)
    image = image.resize((130,90))
    im = preprocess(image) 
    

请注意,在上述代码中,我们对原始图像执行了额外的调整大小,以进一步模糊图像,但这仅用于说明,因为当我们使用缩小的图像时,改进效果更为明显。

  1. 通过加载的 model 对预处理过的图像进行处理,并对模型输出进行后处理:

    sr = model(im[None])[0]
    sr = postprocess(sr) 
    
  2. 绘制原始图像和高分辨率图像:

    subplots([image, sr], nc=2, figsize=(10,10),
             titles=['Original image','High resolution image']) 
    

上述代码的输出如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 13.17:原始图像及其对应的 SRGAN 输出

在上述图像中,我们可以看到高分辨率图像捕捉到了原始图像中模糊的细节。

请注意,如果原始图像模糊或低分辨率,原始图像和高分辨率图像之间的对比度将会很高。然而,如果原始图像不模糊,则对比度不会那么高。我们鼓励您使用不同分辨率的图像进行工作。

总结

在本章中,我们学习了如何使用 Pix2Pix GAN 从图像的轮廓生成图像。此外,我们还学习了 CycleGAN 中的各种损失函数,将一类图像转换为另一类图像。接下来,我们学习了如何使用 StyleGAN 生成逼真的人脸,并根据生成器的训练方式复制图像之间的风格。最后,我们学习了如何使用预训练的 SRGAN 模型生成高分辨率图像。所有这些技术为我们进一步学习在 第 16第 17 章中更现代的图像属性转移方法打下了坚实基础。

在下一章中,我们将改变方向,学习如何将计算机视觉技术与强化学习中的其他突出技术结合使用。

问题

  1. 为什么我们需要 Pix2Pix GAN,如果像 UNet 这样的监督学习算法可以从轮廓生成图像?

  2. 为什么我们需要针对 CycleGAN 中的三种不同损失函数进行优化?

  3. ProgressiveGAN 使用的技巧如何帮助构建 StyleGAN 模型?

  4. 如何识别与给定自定义图像相对应的潜在向量?

在 Discord 上了解更多

加入我们社区的 Discord 空间,与作者和其他读者讨论:

packt.link/modcv

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

第四部分:结合计算机视觉与其他技术

本书最后一节中,我们将学习如何将计算机视觉技术与其他领域的技术(如自然语言处理NLP)、强化学习和基础模型)相结合,开发出解决传统问题的新方法,即使只有有限或没有训练数据。接下来,我们将学习像 Stable Diffusion 这样的新颖图像生成技术,并实现多个应用。最后,我们将学习将模型移至生产环境的最佳实践。

建议您查阅有关用最少数据点进行训练的补充章节,以熟悉词嵌入,这些章节可以在 GitHub 上的第一版额外章节文件夹中找到。

本节包括以下章节:

  • 第十四章结合计算机视觉和强化学习

  • 第十五章结合计算机视觉和 NLP 技术

  • 第十六章计算机视觉中的基础模型

  • 第十七章Stable Diffusion 的应用

  • 第十八章将模型移至生产环境

第十四章:结合计算机视觉和强化学习

在上一章中,我们学习了如何生成感兴趣的图像。在本章中,我们将学习如何将基于强化学习的技术(主要是深度 Q-learning)与基于计算机视觉的技术相结合。这在学习环境复杂且无法收集所有案例数据的场景中特别有用。在这种情况下,我们希望模型在模拟环境中尽可能接近现实地自学习。这种模型在用于自动驾驶汽车、机器人、游戏中的机器人(真实和数字)、以及自监督学习领域时非常有用。

我们将从了解强化学习的基本知识开始,然后了解与确定如何计算与在给定状态下采取行动相关的价值(Q 值)相关的术语。然后,我们将学习填充Q 表,这有助于确定在给定状态下与各种行动相关联的值。我们还将学习在由于可能状态数过高而无法创建 Q 表的情况下,如何利用深度 Q 网络DQN)来确定各种行动的 Q 值。这是我们将了解如何将神经网络与强化学习相结合。接下来,我们将学习 DQN 模型本身无法工作的情况,通过使用固定目标模型来解决这个问题。在这里,我们将通过利用 CNN 与强化学习来玩一个名为 Pong 的视频游戏。最后,我们将利用我们所学到的知识来构建一个可以在模拟环境中自主驾驶汽车的代理人 - CARLA。

总之,在本章中,我们将涵盖以下主题:

  • 学习强化学习的基础知识

  • 实现 Q-learning

  • 实现深度 Q-learning

  • 实现具有固定目标的深度 Q-learning

  • 实现一个执行自主驾驶的代理

本章中的所有代码片段都在 GitHub 存储库的Chapter14文件夹中可用,网址是bit.ly/mcvp-2e

随着领域的发展,我们将定期向 GitHub 存储库添加有价值的补充内容。请检查每个章节目录下的supplementary_sections文件夹获取新的有用内容。

学习强化学习的基础知识

强化学习RL)是机器学习的一个领域,关注软件代理人如何在给定环境状态下采取行动,最大化累积奖励的概念。

要了解 RL 如何帮助,让我们考虑一个简单的场景。想象一下你正在与计算机下国际象棋。让我们确定涉及的不同组件:

  • 计算机是一个已经学会/正在学习如何下国际象棋的代理人

  • 游戏设置(规则)构成了环境

  • 当我们进行一步棋(采取行动)时,棋盘的状态(棋盘上各个棋子的位置)会发生变化。

  • 游戏结束时,根据结果,代理获得奖励。代理的目标是最大化奖励。

如果机器(agent1)与人类对弈,它能够进行的游戏数量是有限的(取决于人类可以进行的游戏数量)。这可能对代理学习游戏造成瓶颈。但是,如果agent1(正在学习游戏的代理)能够与agent2对弈(agent2可以是另一个正在学习国际象棋的代理,或者是一个已经预先编程以玩游戏的国际象棋软件)呢?从理论上讲,这些代理可以无限对弈,这最大化了学习玩游戏的机会。通过进行多场游戏,学习代理很可能学会如何处理游戏的不同场景/状态。

让我们理解学习代理将要遵循的学习过程:

  1. 最初,代理在给定状态下采取随机动作。

  2. 代理将其在游戏中各个状态下采取的动作存储在内存中。

  3. 然后,代理将在各个状态下动作的结果与奖励关联起来。

  4. 在进行多场游戏之后,代理可以通过重播其经历来关联状态中的动作和潜在奖励。

接下来是量化在给定状态下采取行动所对应的价值的问题。我们将在下一节学习如何计算这个价值。

计算状态价值

要理解如何量化一个状态的价值,让我们使用一个简单的场景来定义环境和目标,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.1:环境

环境是一个具有两行三列的网格。代理从起始单元格开始,并且如果到达右下角的网格单元格,则实现其目标(奖励得分+1)。如果它去到任何其他单元格,代理将不会获得奖励。代理可以通过向右、向左、向下或向上移动来采取行动,具体取决于行动的可行性(例如,代理可以从起始网格单元格向右或向下移动)。到达除右下角单元格以外的任何其他单元格的奖励为 0。

利用这些信息,让我们计算一个单元格的价值(代理在给定快照中所处的状态)。鉴于从一个单元格移动到另一个单元格会消耗一些能量,我们通过一个折扣因子 γ 来打折到达单元格的价值,其中 γ 考虑到从一个单元格移动到另一个单元格所花费的能量。此外,引入 γ 会导致代理更快地学会玩得好。因此,让我们形式化广泛使用的贝尔曼方程,帮助计算单元格的价值:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

有了上述方程式,让我们计算所有单元格的值(一旦确定了状态中的最优动作),其中γ的值为 0.9(γ的典型值在 0.9 到 0.99 之间):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从前述计算中,我们可以理解如何计算给定状态(单元格)中的值,当给出该状态中的最优动作时。对于我们达到终端状态的简化情景,这些值如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.2:每个单元格的值

有了这些值,我们期望代理会遵循增值路径。

现在我们了解了如何计算状态值,接下来的部分中,我们将了解如何计算与状态-动作组合相关联的值。

计算状态-动作值

在上一节中,我们提供了一个情景,其中我们已经知道代理正在采取最优动作(这并不现实)。在本节中,我们将看看一个情景,我们可以识别与状态-动作组合对应的值。

在下图中,单元格内的每个子单元格代表在该单元格中采取动作的值。最初,给定状态中各种动作的单元格值如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.3:给定状态中不同动作的初始值

请注意,在前面的图像中,单元格b1(第 1 行和第 2 列)将具有值 1,如果代理从该单元格向右移动(因为它对应终端单元格);其他动作结果为 0。X 表示该动作不可能,因此与之相关联的值为零。

在四次迭代(步骤)后,给定状态中各动作的更新单元格值如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.4:四次迭代后更新的单元格值

随后将通过多次迭代提供最大化每个单元格价值的最优动作。

让我们了解如何获取第二个表中的单元格值(在前述图像中称为迭代 2)。我们将其缩小到 0.3,这是通过在第二个表的第 1 行和第 2 列中存在时采取向下动作获得的。当代理采取向下动作时,有 1/3 的机会采取下一个状态的最优动作。因此,采取向下动作的值如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

类似地,我们可以获取不同单元格中采取不同可能动作的值。

现在我们知道了如何计算给定状态中各种动作的值,在接下来的部分中,我们将学习 Q-learning 以及如何利用它与 Gym 环境,使其能够玩各种游戏。

实施 Q-learning

从技术上讲,现在我们已经计算出了所需的各种状态-动作值,我们可以确定在每个状态下将采取的动作。然而,在更复杂的情景中——例如玩视频游戏时——获取状态信息就变得棘手。OpenAI 的Gym环境在这种情况下非常有用。它包含了我们正在玩的游戏的预定义环境。在这里,它获取下一个状态的信息,给定在当前状态下已经采取的动作。到目前为止,我们考虑了选择最优路径的情况。然而,可能会出现我们陷入局部最小值的情况。

在本节中,我们将学习 Q-learning,它帮助计算状态中与动作相关联的值,以及利用 Gym 环境,使我们能够玩各种游戏。目前,我们将看看一个称为 Frozen Lake 的简单游戏,它在 Gym 环境中可用。我们还将学习探索-利用,这有助于避免陷入局部最小值。然而,在此之前,我们将学习 Q 值。

定义 Q 值

Q-learning 或者 Q 值中的 Q 代表动作的质量(值)。让我们回顾一下如何计算它:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

我们已经知道,我们必须持续更新给定状态的状态-动作值,直到饱和为止。因此,我们将修改前述公式如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在上述方程中,我们用学习率替换 1,以便我们可以更渐进地更新在状态中采取的动作的值:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这个 Q 值的正式定义中,接下来的部分中,我们将学习关于 Gym 环境以及它如何帮助我们获取 Q 表(其中存储了在各种状态下执行的各种动作的价值信息),从而在状态中提出最优动作。

理解 Gym 环境

在本节中,我们将探索 Gym 环境以及其中的各种功能,同时玩冰湖游戏:

以下代码作为Understanding_the_Gym_environment.ipynb在本书 GitHub 存储库的Chapter14文件夹中提供,网址为bit.ly/mcvp-2e

  1. 安装并导入相关软件包:

    %pip install -U "gym==0.26.2"
    import numpy as np
    import gym
    import random 
    
  2. 打印 Gym 环境中的各种环境:

    from gym import envs
    print('\n'.join([str(env) for env in envs.registry])) 
    

前述代码打印了一个包含在 Gym 中所有可用游戏的字典。

  1. 为所选游戏创建一个环境:

    env = gym.make('FrozenLake-v1', is_slippery=False, render_mode='rgb_array') 
    
  2. 检查已创建的环境:

    env.render() 
    

前述代码的输出如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.5:环境状态

在上述图像中,代理从 左上角 开始。在冰冻湖中间有四个洞。如果代理掉入洞中,将获得 0 的奖励并终止游戏。游戏的目标是使代理通过采取特定的动作(在步骤 6 中提到)达到 目标(右下角)

  1. 打印游戏中观测空间的大小(即状态数):

    env.observation_space.n 
    

上述代码给出了 16 的输出。这代表游戏的 16 个单元格。

  1. 打印可能动作的数量:

    env.action_space.n 
    

上述代码的结果是 4,表示可以执行的四种可能动作。

  1. 在给定状态下随机抽取一个动作:

    env.action_space.sample() 
    

在这里,.sample() 指定我们在给定状态下随机选择四种可能的动作之一。每个动作对应的标量可以与动作的名称相关联。我们可以通过查看 GitHub 上的源代码来做到这一点:github.com/openai/gym/blob/master/gym/envs/toy_text/frozen_lake.py

  1. 将环境重置为其原始状态:

    env.reset() 
    
  2. 执行 (step) 一个动作:

    env.step(env.action_space.sample()) 
    

上述代码获取下一个状态、奖励、游戏是否完成的标志以及其他信息。我们可以使用 .step 执行游戏,因为环境在给定动作的情况下会提供下一个状态。

这些步骤为我们建立一个指导在每个状态下采取最优动作的 Q 表奠定了基础。我们将在下一节中完成这一操作。

构建 Q 表

在前一节中,我们学习了如何手动计算各种状态-动作对的 Q 值。在本节中,我们将利用 Gym 环境及其相关模块填充 Q 表,其中行表示代理的可能状态,列表示代理可以执行的动作。Q 表的值表示在给定状态下执行动作的 Q 值。

我们可以使用以下策略填充 Q 表的数值:

  1. 初始化游戏环境和 Q 表,以零填充。

  2. 执行一个随机动作,并获取下一个状态、奖励、游戏是否完成的标志以及其他信息。

  3. 使用我们之前定义的贝尔曼方程更新 Q 值。

  4. 重复 步骤 23,使每个回合最多有 50 步。

  5. 重复 步骤 234 多个回合。

让我们编写上述策略的代码:

以下代码可在本书的 GitHub 代码库的 Chapter14 文件夹中的 Building_Q_table.ipynb 中找到:bit.ly/mcvp-2e

  1. 安装和初始化游戏环境:

    %pip install torch-snippets "gym==0.26.2"
    import numpy as np
    import gym
    import random
    env = gym.make('FrozenLake-v0', is_slippery=False, 
                                    render_mode='rgb_array') 
    
  2. 使用零初始化 Q 表:

    action_size=env.action_space.n
    state_size=env.observation_space.n
    qtable=np.zeros((state_size,action_size)) 
    

上述代码检查可以用来构建 Q 表的可能动作和状态。Q 表的维度应该是状态数乘以动作数。

  1. 进行多个回合,同时随机选择一个动作:

    1. 在这里,我们首先在每个 episode 结束时重置环境:
    episode_rewards = []
    for i in range(10000):
        state, *_ =env.reset() 
    
    1. 每个 episode 最多执行 50 步:
     total_rewards = 0
        for step in range(50): 
    
    1. 我们考虑每个 episode 最多 50 步,因为代理可能会在两个状态之间永远循环(考虑连续执行左右动作)。因此,我们需要指定代理可以采取的最大步数。

    2. 随机采样一个动作并执行(step)该动作:

     action=env.action_space.sample()
            new_state,reward,done,*_=env.step(action) 
    
    1. 更新对应于状态和动作的 Q 值:
     qtable[state,action]+=0.1*(reward+0.9*np.max(qtable[new_state,:]) \
                                                       -qtable[state,action]) 
    
    1. 在上述代码中,我们指定学习速率为0.1,并更新状态-动作组合的 Q 值,考虑到下一个状态的最大 Q 值(np.max(qtable[new_state,:]))。

    2. 更新state值为之前获得的new_state,并将reward累积到total_rewards中:

     state=new_state
            total_rewards+=reward 
    
    1. 将奖励放入列表(episode_rewards),并打印 Q 表(qtable):
     episode_rewards.append(total_rewards)
    print(qtable) 
    

上述代码获取了各种动作在不同状态下的 Q 值:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.6:各种动作在状态间的 Q 值

我们将学习如何在下一节中利用获得的 Q 表。

到目前为止,我们一直在每次随机采取一个动作。然而,在现实场景中,一旦我们学到某些动作不能在某些状态下执行,反之亦然,我们就不需要再随机采取动作了。在这种情况下,探索-利用的概念非常有用。

利用探索-利用

探索-利用的概念可以描述如下:

  • 探索 是一种策略,我们学习在给定状态下需要做什么(采取什么动作)。

  • 利用 是一种策略,利用已经学到的知识 - 即在给定状态下采取哪个动作。

在初始阶段,高量的探索是理想的,因为代理一开始不会知道哪些动作是最优的。随着 episode 的进行,随着代理逐渐学习各种状态-动作组合的 Q 值,我们必须利用利用,执行能够带来高奖励的动作。

有了这个直觉之后,让我们修改在前一节中构建的 Q 值计算方法,以便包含探索和利用:

episode_rewards = []
**epsilon=****1**
**max_epsilon=****1**
**min_epsilon=****0.01**
**decay_rate=****0.005**
for episode in range(1000):
    state, *_=env.reset()
    total_rewards = 0
    for step in range(50):
        **exp_exp_tradeoff=random.uniform(****0****,****1****)**
        **## Exploitation:**
        **if** **exp_exp_tradeoff>epsilon:**
            **action=np.argmax(qtable[state,:])**
        **else****:**
            **## Exploration**
            **action=env.action_space.sample()**
        new_state,reward,done,*_=env.step(action)
        qtable[state,action]+=0.9*(reward+0.9*np.max(\
                                  qtable[new_state,:])\
                                   -qtable[state,action])
        state=new_state
        total_rewards+=reward
    episode_rewards.append(total_rewards)
    epsilon=min_epsilon+(max_epsilon-min_epsilon)\
                            ***np.exp(decay_rate*episode)**
print(qtable) 

在上述代码中的粗体行是在之前显示的代码基础上新增的部分。在这段代码中,我们指定随着更多的 episode 进行,我们执行更多的利用而非探索。

一旦我们获得了 Q 表,我们可以利用它来确定代理需要执行的步骤以达到目的地:

env.reset()
for episode in range(1):
    state, *_=env.reset()
    step=0
    done=False
    print("-----------------------")
    print("Episode",episode)
    for step in range(50):
        env.render()
        action=np.argmax(qtable[state,:])
        print(action)
        new_state,reward,done,*_­­­=env.step(action)
        if done:
            print("Number of Steps",step+1)
            break
        state=new_state
env.close() 

在前面的代码中,我们获取代理所处的当前state,确定在给定状态-动作组合中产生最大值的action,执行该动作(step)以获取代理将会处于的new_state对象,并重复这些步骤直到游戏完成(终止)。

前面的代码的输出结果如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.7:代理执行的最优动作

正如您从前面的图中看到的那样,代理能够采取最优动作来达到其目标。请注意,这只是一个简化的例子,因为状态空间是离散的,导致我们构建了一个 Q 表。

但是,如果状态空间是连续的(例如,状态空间是游戏当前状态的快照图像)会怎么样?构建 Q 表变得非常困难(因为可能的状态数量非常大)。在这种情况下,深度 Q 学习非常有用。我们将在下一节学习关于这个的内容。

实施深度 Q 学习

到目前为止,我们已经学习了如何构建 Q 表,通过多次回放游戏(在本例中是 Frozen Lake 游戏)来为给定的状态-动作组合提供对应的值。然而,当状态空间是连续的(比如 Pong 游戏的快照 - 也就是图像),可能的状态空间数量就变得非常庞大。我们将在本节以及接下来的节中解决这个问题,使用深度 Q 学习。在本节中,我们将学习如何通过神经网络估计状态-动作组合的 Q 值,因此称为深度 Q 学习。与 Q 表相比,深度 Q 学习利用神经网络来映射任何给定的状态-动作(其中状态可以是连续的或离散的)组合到 Q 值。

在这个练习中,我们将在 Gym 的 CartPole 环境中进行操作。让我们首先了解这是什么。

理解 CartPole 环境

我们的任务是尽可能长时间地平衡车杆。下面的图像展示了 CartPole 环境的样子:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.8:CartPole 环境中的可能动作

注意,在购物车向右移动时,杆向左移动,反之亦然。此环境中的每个状态都使用四个观测定义,它们的名称及其最小和最大值如下:

观察最小值最大值
购物车位置-2.42.4
购物车速度-infinf
杆角度-41.8°41.8°
杆顶端的速度-infinf

表 14.1:CartPole 环境中的观察(状态)

请注意,表示状态的所有观察都具有连续的值。

在高层次上,CartPole 平衡游戏的深度 Q 学习工作如下:

  1. 获取输入值(游戏图像/游戏的元数据)。

  2. 将输入值通过一个具有与可能动作数相同输出数量的网络。

  3. 输出层预测与在给定状态下采取行动相对应的动作值。

网络架构的高级概述如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.9:在给定状态时识别正确动作值的网络架构

在上述图像中,网络架构使用状态(四个观察)作为输入,当前状态下采取左右动作的 Q 值作为输出。我们按以下方式训练神经网络:

  1. 在探索阶段,我们执行具有输出层中最高值的随机动作。

  2. 然后,我们将动作、下一个状态、奖励以及标志游戏是否完成存储在内存中。

  3. 在给定状态下,如果游戏尚未完成,则在该状态下采取行动的 Q 值将如下计算:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 当前状态-动作组合的 Q 值保持不变,除了在 步骤 2 中执行的动作。

  2. 多次执行步骤 1 到 4 并存储经验。

  3. 适合一个以状态作为输入和以行动值作为预期输出的模型(从记忆和重播经验中),并最小化均方误差MSE)损失,该损失是最佳动作在下一个状态的目标 Q 值与在给定状态下动作的预测 Q 值之间的差值。

  4. 在多个情节上重复上述步骤,同时减少探索率。

有了上述策略,让我们编写深度 Q 学习,以便进行 CartPole 平衡。

执行 CartPole 平衡

要执行 CartPole 平衡,您可以使用以下代码:

此代码作为 Deep_Q_Learning_Cart_Pole_balancing.ipynb 存在于此书 GitHub 存储库的 Chapter14 文件夹中,网址为 bit.ly/mcvp-2e。该代码包含用于下载数据的 URL,并且长度适中。我们强烈建议您在 GitHub 上执行笔记本以重现结果,以便理解所需执行的步骤和各种代码组件。

  1. 安装并导入相关包:

    %pip install "gym==0.26.2"
    import gym
    import numpy as np
    import cv2import torch
    import torch.nn as nn
    import torch.nn.functional as F
    import random
    from collections import namedtuple, deque
    import torch.optim as optim
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  2. 定义环境:

    env = gym.make('CartPole-v1') 
    
  3. 定义网络架构:

    class DQNetwork(nn.Module):
        def __init__(self, state_size, action_size):
            super(DQNetwork, self).__init__()
    
            self.fc1 = nn.Linear(state_size, 24)
            self.fc2 = nn.Linear(24, 24)
            self.fc3 = nn.Linear(24, action_size)
    
        def forward(self, state):     
            x = F.relu(self.fc1(state))
            x = F.relu(self.fc2(x))
            x = self.fc3(x)
            return x 
    

注意,由于仅在 2 个隐藏层中包含 24 个单元,因此体系结构相当简单。输出层包含与可能动作数相同数量的单元。

  1. 定义 Agent 类,如下所示:

    1. 使用各种参数、网络和经验定义 __init__ 方法:
    class Agent():
        def __init__(self, state_size, action_size):
    
            self.state_size = state_size
            self.action_size = action_size
            self.seed = random.seed(0)
            ## hyperparameters
            self.buffer_size = 2000
            self.batch_size = 64
            self.gamma = 0.99
            self.lr = 0.0025
            self.update_every = 4
            # Q-Network
            self.local = DQNetwork(state_size, action_size).to(device)
            self.optimizer=optim.Adam(self.local.parameters(), lr=self.lr)
            # Replay memory
            self.memory = deque(maxlen=self.buffer_size)
            self.experience = namedtuple("Experience", \
                                field_names=["state", "action",
                                "reward", "next_state", "done"])
            self.t_step = 0 
    
    1. 定义 step 函数,从内存中获取数据,并通过调用 learn 函数将其适应模型:
     def step(self, state, action, reward, next_state, done):
            # Save experience in replay memory
            self.memory.append(self.experience(state, action,
                                               reward, next_state, done))
            # Learn once every 'update_every' number of time steps.
            self.t_step = (self.t_step + 1) % self.update_every
            if self.t_step == 0:
            # If enough samples are available in memory,
            # get random subset and learn
                if len(self.memory) > self.batch_size:
                    experiences = self.sample_experiences()
                    self.learn(experiences, self.gamma) 
    
    1. 请注意,我们正在学习随机样本的经验(使用 self.memory),而不是连续经验序列,确保模型仅基于当前输入学习要做什么。如果我们按顺序提供经验,模型可能会学习连续输入中的相关性。

    2. 定义 act 函数,它在给定状态时预测动作:

     def act(self, state, eps=0.):
            # Epsilon-greedy action selection
            if random.random() > eps:
                state = torch.from_numpy(state).float()\
                                               .unsqueeze(0).to(device)
                self.local.eval()
                with torch.no_grad():
                    action_values = self.local(state)
                self.local.train()
                return np.argmax(action_values.cpu().data.numpy())
            else:
                return random.choice(np.arange(self.action_size)) 
    
    1. 注意,在上述代码中,我们在确定要采取的动作时进行探索利用。

    2. 定义 learn 函数,它适配模型以便在给定状态时预测动作值:

     def learn(self, experiences, gamma):
            states,actions,rewards,next_states,dones= experiences
            # Get expected Q values from local model
            Q_expected = self.local(states).gather(1, actions)
            # Get max predicted Q values (for next states)
            # from local model
            Q_targets_next = self.local(next_states).detach()\
                                                    .max(1)[0].unsqueeze(1)
            # Compute Q targets for current states
            Q_targets = rewards+(gamma*Q_targets_next*(1-dones))
    
            # Compute loss
            loss = F.mse_loss(Q_expected, Q_targets)
            # Minimize the loss
            self.optimizer.zero_grad()
            loss.backward()
            self.optimizer.step() 
    
    1. 在上述代码中,我们获取了抽样的经验并预测了我们执行的动作的 Q 值。此外,考虑到我们已经知道下一个状态,我们可以预测下一个状态中动作的最佳 Q 值。这样,我们现在知道了与在给定状态下采取的动作对应的目标值。最后,我们计算了预期值 (Q_targets) 和预测值 (Q_expected) 之间的损失,即当前状态中采取的动作的 Q 值的损失。

    2. 定义 sample_experiences 函数以从记忆中抽取经验:

     def sample_experiences(self):
            experiences = random.sample(self.memory,
                                        k=self.batch_size)
            states = torch.from_numpy(np.vstack([e.state \
                        for e in experiences if e is not \
                                    None])).float().to(device)
            actions = torch.from_numpy(np.vstack([e.action \
                        for e in experiences if e is not \
                                    None])).long().to(device)
            rewards = torch.from_numpy(np.vstack([e.reward \
                        for e in experiences if e is not \
                                    None])).float().to(device)
            next_states=torch.from_numpy(np.vstack([e.next_state \
                        for e in experiences if e is not \
                                      None])).float().to(device)
            dones = torch.from_numpy(np.vstack([e.done \
                        for e in experiences if e is not None])\
                           .astype(np.uint8)).float().to(device)
            return (states, actions, rewards, next_states,dones) 
    
  2. 定义 agent 对象:

    agent = Agent(env.observation_space.shape[0], env.action_space.n) 
    
  3. 执行深度 Q 学习,如下所示:

    1. 初始化将存储得分信息和超参数的列表:
    scores = [] # list containing scores from each episode
    scores_window = deque(maxlen=100) # last 100 scores
    n_episodes=5000
    max_t=5000
    eps_start=1.0
    eps_end=0.001
    eps_decay=0.9995
    eps = eps_start 
    
    1. 在每一回合中重置环境并获取状态的形状(观察数量)。此外,重塑它以便我们可以将其传递给网络:
    for i_episode in range(1, n_episodes+1):
        state, *_ = env.reset()
        state_size = env.observation_space.shape[0]
        state = np.reshape(state, [1, state_size])
        score = 0 
    
    1. max_t 时间步中循环,确定要执行的动作并执行 (step)。然后,重塑状态,以便将重塑后的状态传递给神经网络:
     for i in range(max_t):
            action = agent.act(state, eps)
            next_state, reward, done, *_ = env.step(action)
            next_state = np.reshape(next_state, [1, state_size]) 
    
    1. 通过指定 agent.step 在当前状态的基础上适配模型,并重置状态到下一个状态,以便在下一次迭代中有用:
     reward = reward if not done or score == 499 else -10
            agent.step(state, action, reward, next_state, done)
            state = next_state
            score += reward
            if done:
                break 
    
    1. 存储得分值,定期打印,并在前 10 步得分的均值大于 450 时停止训练(这通常是一个不错的分数,因此被选择):
     scores_window.append(score) # save most recent score
        scores.append(score) # save most recent score
        eps = max(eps_end, eps_decay*eps) # decrease epsilon
        print('\rEpisode {:.2f}\tReward {:.2f} \tAverage Score: {:.2f} \tEpsilon: {:.2f}'.format(i_episode, score, np.mean(scores_window), eps), end="")
        if i_episode % 100 == 0:
            print('\rEpisode {:.2f}\tAverage Score: {:.2f} \tEpsilon: {:.2f}'.format(i_episode, np.mean(scores_window), eps))
        if i_episode>10 and np.mean(scores[-10:])>450:
            break 
    
  4. 绘制随着回合增加得分变化的图表:

    import matplotlib.pyplot as plt
    %matplotlib inline
    plt.plot(scores)
    plt.title('Scores over increasing episodes') 
    

以下是显示得分随回合变化的图表:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.10:随着回合增加的得分

从上图可以看出,在第 2,000 个回合后,模型在平衡 CartPole 时取得了高分。

现在我们已经学会了如何实现深度 Q 学习,在接下来的部分,我们将学习如何处理不同的状态空间 - Pong 中的视频帧,而不是定义 CartPole 环境中的四个状态空间。我们还将学习如何使用固定目标模型实现深度 Q 学习。

使用固定目标模型实现深度 Q 学习

在前一节中,我们学习了如何利用深度 Q 学习解决 Gym 中的 CartPole 环境问题。在本节中,我们将解决一个更复杂的乒乓球游戏,并了解如何利用深度 Q 学习以及固定目标模型来解决游戏。在处理此用例时,您还将学习如何利用基于 CNN 的模型(代替我们在前一节中使用的普通神经网络)来解决问题。前一节的理论基本保持不变,但关键变化是“固定目标模型”。基本上,我们创建了本地模型的副本,并在每 1,000 步与本地模型一起使用本地模型的奖励。这使得本地模型更加稳定,并更平滑地更新其权重。在 1,000 步之后,我们使用本地模型更新目标模型,以更新整体学习。

两个模型有效的原因是,我们减轻了本地模型同时选择动作和生成训练网络目标的负担 - 这种相互依赖会导致训练过程中的显著振荡。

理解用例

该用例的目标是构建一个能够与计算机(预训练的非学习代理)对战并在乒乓球游戏中击败它的代理,期望代理能够获得 21 分。

我们将采用的策略来解决创建成功的乒乓球游戏代理的问题如下:

  1. 剪裁图像的不相关部分以获取当前帧(状态):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.11:乒乓球游戏中的原始图像和处理后图像(帧)

请注意,在前述图像中,我们已经对原始图像进行了处理,剪裁了处理后图像的顶部和底部像素。

  1. 堆叠四个连续帧 - 代理需要状态序列来判断球是否接近它。

  2. 让代理通过最初采取随机动作来进行游戏,并在内存中收集当前状态、未来状态、采取的动作和奖励。只保留最近 10,000 个动作的信息,并在超过 10,000 个动作时清除历史信息。

  3. 构建一个网络(本地网络),从内存中取样状态并预测可能动作的值。

  4. 定义另一个网络(目标网络),它是本地网络的复制品。

  5. 每当更新本地网络 1,000 次时,更新目标网络。每 1,000 个周期结束时,目标网络的权重与本地网络的权重相同。

  6. 利用目标网络计算下一状态中最佳动作的 Q 值。

  7. 对于本地网络建议的动作,我们期望它预测即时奖励和下一状态中最佳动作的 Q 值之和。

  8. 最小化本地网络的均方误差损失。

  9. 让代理程序继续播放,直到最大化其奖励。

有了上述策略,我们现在可以编写代理程序,使其在玩乒乓球时最大化奖励。

编写代理程序玩乒乓球

编写代理程序以使其自学习如何玩乒乓球的步骤如下:

以下代码在本书的 GitHub 存储库的Chapter14文件夹中的Pong_Deep_Q_Learning_with_Fixed_targets.ipynb文件中提供。代码包含从中下载数据的 URL,并且代码长度适中。我们强烈建议您在 GitHub 上执行笔记本以重现结果,以理解执行步骤和各种代码组件。

  1. 导入相关包并设置游戏环境:

    %pip install -qqU "gym[atari, accept-rom-license]==0.26.2"
    import gym
    import numpy as np
    import cv2
    from collections import deque
    import matplotlib.pyplot as plt
    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    import random
    from collections import namedtuple, deque
    import torch.optim as optim
    import matplotlib.pyplot as plt
    %matplotlib inline
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    env = gym.make('PongDeterministic-v0') 
    
  2. 定义状态大小和动作大小:

    state_size = env.observation_space.shape[0]
    action_size = env.action_space.n 
    
  3. 定义一个函数,该函数将预处理帧以去除不相关的底部和顶部像素:

    def preprocess_frame(frame):
        bkg_color = np.array([144, 72, 17])
        img = np.mean(frame[34:-16:2,::2]-bkg_color,axis=-1)/255.
        resized_image = img
        return resized_image 
    
  4. 定义一个函数,该函数将堆叠四个连续的帧,如下所示:

    1. 该函数接受stacked_frames、当前stateis_new_episode标志作为输入:
    def stack_frames(stacked_frames, state, is_new_episode):
        # Preprocess frame
        frame = preprocess_frame(state)
        stack_size = 4 
    
    1. 如果这是新的场次(游戏重新开始),我们将以初始帧的堆栈开始:
     if is_new_episode:
            # Clear our stacked_frames
            stacked_frames = deque([np.zeros((80,80), dtype=np.uint8) \
                             for i in range(stack_size)], maxlen=4)
            # Because we're in a new episode,
            # copy the same frame 4x
            for i in range(stack_size):
                stacked_frames.append(frame)
            # Stack the frames
            stacked_state = np.stack(stacked_frames, \
                                     axis=2).transpose(2, 0, 1) 
    
    1. 如果该场次不是新的,我们将从stacked_frames中移除最旧的帧并附加最新的帧:
     else:
            # Append frame to deque,
            # automatically removes the #oldest frame
            stacked_frames.append(frame)
            # Build the stacked state
            # (first dimension specifies #different frames)
            stacked_state = np.stack(stacked_frames, \
                                     axis=2).transpose(2, 0, 1)
        return stacked_state, stacked_frames 
    
  5. 定义网络架构——即DQNetwork

    class DQNetwork(nn.Module):
        def __init__(self, states, action_size):
            super(DQNetwork, self).__init__()
    
            self.conv1 = nn.Conv2d(4, 32, (8, 8), stride=4)
            self.conv2 = nn.Conv2d(32, 64, (4, 4), stride=2)
            self.conv3 = nn.Conv2d(64, 64, (3, 3), stride=1)
            self.flatten = nn.Flatten()
            self.fc1 = nn.Linear(2304, 512)
            self.fc2 = nn.Linear(512, action_size)
    
        def forward(self, state):
            x = F.relu(self.conv1(state))
            x = F.relu(self.conv2(x))
            x = F.relu(self.conv3(x))
            x = self.flatten(x)
            x = F.relu(self.fc1(x))
            x = self.fc2(x)
            return x 
    
  6. 如前一节中所做的那样,定义Agent类:

    1. 定义__init__方法:
    class Agent():
        **def****__init__****(self, state_size, action_size):**      
            self.state_size = state_size
            self.action_size = action_size
            self.seed = random.seed(0)
            ## hyperparameters
            self.buffer_size = 1000
            self.batch_size = 32
            self.gamma = 0.99
            self.lr = 0.0001
            self.update_every = 4
            self.update_every_target = 1000
            self.learn_every_target_counter = 0
            # Q-Network
            self.local = DQNetwork(state_size, action_size).to(device)
            self.target = DQNetwork(state_size, action_size).to(device)
            self.optimizer=optim.Adam(self.local.parameters(), lr=self.lr)
            # Replay memory
            self.memory = deque(maxlen=self.buffer_size)
            self.experience = namedtuple("Experience", \
                               field_names=["state", "action", \
                                "reward", "next_state", "done"])
            # Initialize time step (for updating every few steps)
            self.t_step = 0 
    
    1. 请注意,在前述代码的__init__方法中,与前一节提供的代码相比,我们唯一增加的部分是target网络及其更新频率(这些行在前述代码中以粗体显示)。

    2. 定义将更新权重的方法(step),就像我们在前一节中所做的那样:

     def step(self, state, action, reward, next_state, done):
            # Save experience in replay memory
            self.memory.append(self.experience(state[None], \
                                        action, reward, \
                                        next_state[None], done))
    
            # Learn every update_every time steps.
            self.t_step = (self.t_step + 1) % self.update_every
            if self.t_step == 0:
        # If enough samples are available in memory, get  
        # random subset and learn
                if len(self.memory) > self.batch_size:
                    experiences = self.sample_experiences()
                    self.learn(experiences, self.gamma) 
    
    1. 定义act方法,该方法将获取给定状态下要执行的动作:
     def act(self, state, eps=0.):
            # Epsilon-greedy action selection
            if random.random() > eps:
                state = torch.from_numpy(state).float()\
                             .unsqueeze(0).to(device)
                self.local.eval()
                with torch.no_grad():
                    action_values = self.local(state)
                self.local.train()
                return np.argmax(action_values.cpu().data.numpy())
            else:
                return random.choice(np.arange(self.action_size)) 
    
    1. 定义learn函数,该函数将训练本地模型:
     def learn(self, experiences, gamma):
            self.learn_every_target_counter+=1
            states,actions,rewards,next_states,dones = experiences
            # Get expected Q values from local model
            Q_expected = self.local(states).gather(1, actions)
            # Get max predicted Q values (for next states)
            # from target model
            **Q_targets_next =** **self****.target(next_states).detach()\**
                                 **.****max****(****1****)[****0****].unsqueeze(****1****)**
            # Compute Q targets for current state
            Q_targets = rewards+(gamma*Q_targets_next*(1-dones))
    
            # Compute loss
            loss = F.mse_loss(Q_expected, Q_targets)
            # Minimize the loss
            self.optimizer.zero_grad()
            loss.backward()
            self.optimizer.step()
            # ------------ update target network ------------- #
            if self.learn_every_target_counter%1000 ==0:
                self.target_update() 
    
    1. 请注意,在前述代码中,Q_targets_next是使用目标模型而不是在前一节中使用的本地模型预测的(我们在代码中突出显示了这一行)。我们还会在每 1,000 步之后更新目标网络,其中learn_every_target_counter是帮助我们确定是否应更新目标模型的计数器。

    2. 定义一个函数(target_update),用于更新目标模型:

     def target_update(self):
            print('target updating')
            self.target.load_state_dict(self.local.state_dict()) 
    
    1. 定义一个函数,该函数将从内存中采样经验:
     def sample_experiences(self):
            experiences = random.sample(self.memory, k=self.batch_size)
            states = torch.from_numpy(np.vstack([e.state \
                        for e in experiences if e is not \
                               None])).float().to(device)
            actions = torch.from_numpy(np.vstack([e.action \
                        for e in experiences if e is not \
                               None])).long().to(device)
            rewards = torch.from_numpy(np.vstack([e.reward \
                        for e in experiences if e is not \
                               None])).float().to(device)
            next_states=torch.from_numpy(np.vstack([e.next_state \
                         for e in experiences if e is not \
                                      None])).float().to(device)
            dones = torch.from_numpy(np.vstack([e.done \
                        for e in experiences if e is not None])\
                           .astype(np.uint8)).float().to(device)
            return (states, actions, rewards, next_states,dones) 
    
  7. 定义Agent对象:

    agent = Agent(state_size, action_size) 
    
  8. 定义将用于训练代理程序的参数:

    n_episodes=5000
    max_t=5000
    eps_start=1.0
    eps_end=0.02
    eps_decay=0.995
    scores = [] # list containing scores from each episode
    scores_window = deque(maxlen=100) # last 100 scores
    eps = eps_start
    stack_size = 4
    stacked_frames = deque([np.zeros((80,80), dtype=np.int) \
                            for i in range(stack_size)], maxlen=stack_size) 
    
  9. 训练代理程序随着场次的增加,就像我们在前一节中所做的那样:

    for i_episode in range(1, n_episodes+1):
        state, *_ = env.reset()
        state, frames = stack_frames(stacked_frames, state, True)
        score = 0
        for i in range(max_t):
            action = agent.act(state, eps)
            next_state, reward, done, *_ = env.step(action)
            next_state, frames = stack_frames(frames, next_state, False)
            agent.step(state, action, reward, next_state, done)
            state = next_state
            score += reward
            if done:
                break
        scores_window.append(score) # save most recent score
        scores.append(score) # save most recent score
        eps = max(eps_end, eps_decay*eps) # decrease epsilon
        print('\rEpisode {}\tReward {} \tAverage Score: {:.2f} \
        \tEpsilon: {}'.format(i_episode,score,\
                              np.mean(scores_window),eps),end="")
        if i_episode % 100 == 0:
            print('\rEpisode {}\tAverage Score: {:.2f} \
            \tEpsilon: {}'.format(i_episode, \
                                  np.mean(scores_window), eps)) 
    

以下图表显示了随着场次增加得分的变化:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.12:随着时代增长的得分

从前面的图像中,我们可以看到代理逐渐学会了玩 Pong 游戏,并且在 800 个周期结束时,它学会了在接受高奖励的同时玩这个游戏。

现在我们已经训练了一个能够很好地玩 Pong 游戏的代理,下一节我们将训练一个代理,使其能够在模拟环境中自动驾驶汽车。

实现一个代理程序执行自动驾驶

现在您已经看到 RL 在逐步挑战的环境中运作,我们将通过演示相同的概念可以应用于自动驾驶汽车来结束本章。由于在实际汽车上看到这种工作是不切实际的,我们将转而使用模拟环境。这种情景包含以下组成部分:

  • 环境是一个充满交通的完整城市,道路上有汽车和其他细节。演员(代理)是一辆汽车。

  • 汽车的输入包括各种感官输入,例如仪表板摄像头、光学雷达LIDAR)传感器和 GPS 坐标。

  • 输出将是汽车移动的快慢程度,以及转向的级别。

此仿真将尝试准确地反映现实世界的物理情况。因此,请注意,无论是汽车仿真还是真实汽车,基本原理都将保持不变。

注意,我们要安装的环境需要图形用户界面GUI)来显示仿真内容。此外,训练将至少需要一天时间,如果不是更长。由于 Google Colab 缺乏可视化设置并且时间使用限制,我们将不再使用 Google Colab 笔记本,正如我们迄今所做的那样。这是本书唯一一个需要活跃的 Linux 操作系统,并且最好有 GPU 以在几天的训练中获得可接受结果的部分。

设置 CARLA 环境

正如我们之前提到的,我们需要一个可以模拟复杂交互的环境,使我们相信我们实际上正在处理一个真实场景。CARLA 就是这样的一个环境。环境作者对 CARLA 表示如下:

CARLA 是从头开始开发的,旨在支持自动驾驶系统的开发、训练和验证。除了开放源代码和协议外,CARLA 还提供了专为此目的创建并可以自由使用的开放数字资产(城市布局、建筑物和车辆)。仿真平台支持传感器套件的灵活规格、环境条件、对所有静态和动态参与者的全面控制、地图生成等等。

我们需要遵循两个步骤来设置环境:

  1. 安装 CARLA 仿真环境的二进制文件。

  2. 安装 Gym 版本,该版本提供了 Python 连接以进行仿真环境。

此部分的步骤已作为视频教程呈现在此处:tinyurl.com/mcvp-self-driving-agent

让我们开始吧!

安装 CARLA 二进制文件

在本节中,我们将学习如何安装必要的 CARLA 二进制文件:

  1. 访问github.com/carla-simulator/carla/releases/tag/0.9.6,并下载编译版本文件CARLA_0.9.6.tar.gz

  2. 将其移动到您希望 CARLA 在系统中居住的位置并解压缩它。在这里,我们将通过下载并解压缩 CARLA 到Documents文件夹来演示这一点:

    $ mv CARLA_0.9.6.tar.gz ~/Documents/
    $ cd ~/Documents/
    $ tar -xf CARLA_0.9.6.tar.gz
    $ cd CARLA_0.9.6/ 
    
  3. 将 CARLA 添加到PYTHONPATH中,以便您的机器上的任何模块都可以导入它:

    $ echo "export PYTHONPATH=$PYTHONPATH:/home/$(whoami)/Documents/CARLA_0.9.6/PythonAPI/carla/dist/carla-0.9.6-py3.5-linux-x86_64.egg" >> ~/.bashrc 
    

在前面的代码中,我们将包含 CARLA 的目录添加到名为PYTHONPATH的全局变量中,这是一个用于访问所有 Python 模块的环境变量。将其添加到~/.bashrc将确保每次打开终端时都可以访问此新文件夹。运行上述代码后,重新启动终端并运行ipython -c "import carla; carla.__spec__"。您应该会得到以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.13:CARLA 在您的机器上的位置

  1. 最后,按以下方式提供必要的权限并执行 CARLA:

    $ chmod +x /home/$(whoami)/Documents/CARLA_0.9.6/CarlaUE4.sh
    $ ./home/$(whoami)/Documents/CARLA_0.9.6/CarlaUE4.sh 
    

一两分钟后,您应该会看到一个类似于以下显示 CARLA 作为仿真运行的窗口,准备接受输入:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.14:显示 CARLA 运行的窗口

在本节中,我们已验证CARLA是一个仿真环境,其二进制文件按预期工作。让我们继续为其安装 Gym 环境。保持终端运行状态不变,因为我们需要二进制文件在整个练习过程中后台运行。

安装 CARLA 的 Gym 环境

由于没有官方的 Gym 环境,我们将利用用户实施的 GitHub 存储库,并从那里为 CARLA 安装 Gym 环境。按照以下步骤安装 CARLA 的 Gym 环境:

  1. 克隆 Gym 存储库到您选择的位置并安装该库:

    $ cd /location/to/clone/repo/to
    $ git clone https://github.com/cjy1992/gym-carla
    $ cd gym-carla
    $ pip install -r requirements.txt
    $ pip install -e . 
    
  2. 通过运行以下命令来测试您的设置:

    $ python test.py 
    

应该会打开一个类似以下的窗口,显示我们已将一个虚拟汽车添加到环境中。从这里,我们可以监视俯视图,激光雷达传感器点云和我们的车载摄像头:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 14.15:当前情节的概述

在这里,我们可以观察到以下内容:

  • 第一个视图包含一个与车辆 GPS 系统显示非常相似的视图 - 即我们的车辆,各种航点和道路车道。然而,我们不会使用此输入进行训练,因为它还显示了视图中的其他车辆,这是不现实的。

  • 第二视图更为有趣。有些人认为它就像是自动驾驶汽车的眼睛。激光雷达每秒多次向周围环境发射脉冲光(在所有方向),捕获反射光以确定该方向上最近的障碍物距离。车载计算机汇总所有最近的障碍物信息,以重建一个三维点云,使其能够三维理解其环境。

  • 在第一和第二视图中,我们可以看到汽车前方有一条带状物。这是一个航向指示,指示汽车应该去的方向。

  • 第三视图是一个简单的仪表盘摄像头。

除了这三个外,CARLA 还提供其他传感器数据,例如以下内容:

  • lateral-distance(车道偏离距离)

  • delta-yaw(相对于前方道路的角度)

  • 速度

  • 车辆前方是否有危险障碍物

我们将使用前面提到的前四个传感器以及 LIDAR 和我们的仪表盘摄像头来训练模型。

现在我们准备理解 CARLA 的组件并为自动驾驶汽车创建 DQN 模型。

训练自动驾驶代理

在笔记本中开始训练过程之前,我们将创建两个文件 —— model.pyactor.py。它们分别包含模型架构和 Agent 类。Agent 类包含我们将用于训练代理的各种方法。

此部分的代码说明位于本书 GitHub 存储库的 Chapter14 文件夹中的 Carla.md 文件中,网址为 bit.ly/mcvp-2e

创建 model.py

这将是一个 PyTorch 模型,它将接受提供给它的图像以及其他传感器输入。预计它将返回最可能的行动:

from torch_snippets import *
class DQNetworkImageSensor(nn.Module):
    def __init__(self):
        super().__init__()
        self.n_outputs = 9
        self.image_branch = nn.Sequential(
                            nn.Conv2d(3, 32, (8, 8), stride=4),
                            nn.ReLU(inplace=True),
                            nn.Conv2d(32, 64, (4, 4), stride=2),
                            nn.ReLU(inplace=True),
                            nn.Conv2d(64,128,(3, 3),stride=1),
                            nn.ReLU(inplace=True),
                            nn.AvgPool2d(8),
                            nn.ReLU(inplace=True),
                            nn.Flatten(),
                            nn.Linear(1152, 512),
                            nn.ReLU(inplace=True),
                            nn.Linear(512, self.n_outputs)
                        )
        self.lidar_branch = nn.Sequential(
                            nn.Conv2d(3, 32, (8, 8), stride=4),
                            nn.ReLU(inplace=True),
                            nn.Conv2d(32,64,(4, 4),stride=2),
                            nn.ReLU(inplace=True),
                            nn.Conv2d(64,128,(3, 3),stride=1),
                            nn.ReLU(inplace=True),
                            nn.AvgPool2d(8),
                            nn.ReLU(inplace=True),
                            nn.Flatten(),
                            nn.Linear(1152, 512),
                            nn.ReLU(inplace=True),
                            nn.Linear(512, self.n_outputs)
                        )
        self.sensor_branch = nn.Sequential(
                                nn.Linear(4, 64),
                                nn.ReLU(inplace=True),
                                nn.Linear(64, self.n_outputs)
                            )
    def forward(self, image, lidar=None, sensor=None):
        x = self.image_branch(image)
        if lidar is None:
            y = 0
        else:
            y = self.lidar_branch(lidar)
        z = self.sensor_branch(sensor)
        return x + y + z 

让我们来解析这段代码。正如您所看到的,与之前只接受图像输入的部分不同,这里的 forward 方法中输入的数据类型更多:

  • self.image_branch 期望来自汽车仪表盘摄像头的图像。

  • self.lidar_branch 接受由 LIDAR 传感器生成的图像。

  • self.sensor_branch 接受四个传感器输入,形式为 NumPy 数组。这四个项目是:

    • 横向距离(车道偏离距离)

    • delta-yaw(相对于前方道路的角度)

    • 速度

    • 车辆前方是否存在任何危险障碍物

gym_carla/envs/carla_env.py 中看到第 544 行(已经进行了 Git 克隆的存储库)得到相同的输出。在神经网络的不同分支上,该模块将提供不同级别的传感器重要性,并将输出汇总为最终输出。注意,共有九个输出;我们稍后将对其进行查看。

创建 actor.py

与前几节类似,我们将使用一些代码来存储回放信息,并在需要时进行播放:

  1. 让我们把导入和超参数放在正确的位置:

    import numpy as np
    import random
    from collections import namedtuple, deque
    import torch
    import torch.nn.functional as F
    import torch.optim as optim
    from model1 import DQNetworkImageSensor
    BUFFER_SIZE = int(1e3) # replay buffer size
    BATCH_SIZE = 256 # minibatch size
    GAMMA = 0.99 # discount factor
    TAU = 1e-2 # for soft update of target parameters
    LR = 5e-4 # learning rate
    UPDATE_EVERY = 50 # how often to update the network
    ACTION_SIZE = 2
    device = 'cuda' if torch.cuda.is_available() else 'cpu' 
    
  2. 接下来,我们将初始化目标网络和本地网络。在这里与上一节相比,代码没有做任何更改,只是导入的模块不同:

    class Actor():
        def __init__(self):      
            # Q-Network
            self.qnetwork_local=DQNetworkImageSensor().to(device)
            self.qnetwork_target=DQNetworkImageSensor().to(device)
            self.optimizer = optim.Adam(self.qnetwork_local.parameters(),
                                                                  lr=LR)
            # Replay memory
            self.memory= ReplayBuffer(ACTION_SIZE,BUFFER_SIZE, \
                                       BATCH_SIZE, 10)
            # Initialize time step
            # (for updating every UPDATE_EVERY steps)
            self.t_step = 0
    
        def step(self, state, action, reward, next_state, done):
            # Save experience in replay memory
            self.memory.add(state, action, reward, next_state, done)
    
            # Learn every UPDATE_EVERY time steps.
            self.t_step = (self.t_step + 1) % UPDATE_EVERY
            if self.t_step == 0:
      # If enough samples are available in memory,
      # get random subset and learn
                if len(self.memory) > BATCH_SIZE:
                    experiences = self.memory.sample()
                    self.learn(experiences, GAMMA) 
    
  3. 由于有更多传感器需要处理,我们将它们作为状态字典传输。状态包含了在前一节介绍的'image''lidar''sensor'键。在将它们发送给神经网络之前,我们进行预处理,如下面的代码所示:

     def act(self, state, eps=0.):
            images,lidars sensors=state['image'], \
                                  state['lidar'],state['sensor']
            images = torch.from_numpy(images).float()\
                          .unsqueeze(0).to(device)
            lidars = torch.from_numpy(lidars).float()\
                          .unsqueeze(0).to(device)
            sensors = torch.from_numpy(sensors).float()\
                           .unsqueeze(0).to(device)
            self.qnetwork_local.eval()
            with torch.no_grad():
                action_values = self.qnetwork_local(images, \
                                  lidar=lidars, sensor=sensors)
            self.qnetwork_local.train()
            # Epsilon-greedy action selection
            if random.random() > eps:
                return np.argmax(action_values.cpu().data.numpy())
            else:
                return random.choice(np.arange(self.qnetwork_local.n_outputs)) 
    
  4. 现在,我们需要从重放内存中提取项目。执行以下指令:

    1. 获得一批当前状态和下一个状态。

    2. 计算网络在当前状态下执行操作时的预期奖励,Q_expected

    3. 将其与目标奖励Q_targets进行比较,当下一个状态被送入网络时将获得该奖励。

    4. 定期使用本地网络更新目标网络。

下面是用于实现此目的的代码:

  1.  def learn(self, experiences, gamma):
            states,actions,rewards,next_states,dones= experiences
            images, lidars, sensors = states
            next_images, next_lidars, next_sensors = next_states
            # Get max predicted Q values (for next states)
            # from target model
            Q_targets_next = self.qnetwork_target(next_images,
                                            lidar=next_lidars,
                                         sensor=next_sensors)\
                                .detach().max(1)[0].unsqueeze(1)
            # Compute Q targets for current states
            Q_targets= rewards +(gamma*Q_targets_next*(1-dones))
            # Get expected Q values from local model
            # import pdb; pdb.set_trace()
            Q_expected=self.qnetwork_local(images,lidar=lidars,
                                     sensor=sensors).gather(1,actions.long())
            # Compute loss
            loss = F.mse_loss(Q_expected, Q_targets)
            # Minimize the loss
            self.optimizer.zero_grad()
            loss.backward()
            self.optimizer.step()
            # ------------ update target network ------------- #
            self.soft_update(self.qnetwork_local,
                             self.qnetwork_target, TAU)
        def soft_update(self, local_model, target_model, tau):
            for target_param, local_param in \
                zip(target_model.parameters(), \
                local_model.parameters()):
                target_param.data.copy_(tau*local_param.data + \
                                    (1.0-tau)*target_param.data) 
    
  2. ReplayBuffer类中唯一的主要更改是数据存储方式。由于我们有多个传感器,每个内存状态(当前状态和下一个状态)都存储为数据元组 - 即states = [images, lidars, sensors]

    class ReplayBuffer:
        """Fixed-size buffer to store experience tuples."""
        def __init__(self, action_size, buffer_size, batch_size, seed):
            self.action_size = action_size
            self.memory = deque(maxlen=buffer_size)
            self.batch_size = batch_size
            self.experience = namedtuple("Experience", \
                              field_names=["state", "action", \
                              "reward","next_state", "done"])
            self.seed = random.seed(seed)
    
        def add(self, state, action, reward, next_state, done):
            """Add a new experience to memory."""
            e = self.experience(state, action, reward,
                                next_state, done)
            self.memory.append(e)
    
        def sample(self):
            experiences = random.sample(self.memory,
                                        k=self.batch_size)
            images = torch.from_numpy(np.vstack([e.state['image'][None] \
                     for e in experiences if e is not None]))\
                        .float().to(device)
            lidars = torch.from_numpy(np.vstack([e.state['lidar'][None] \
                     for e in experiences if e is not None]))\
                        .float().to(device)
            sensors = torch.from_numpy(np.vstack([e.state['sensor'] \
                     for e in experiences if e is not None]))\
                        .float().to(device)
            **states = [images, lidars, sensors]**
            actions = torch.from_numpy(np.vstack(\
                        [e.action for e in experiences \
                         if e is not None])).long().to(device)
            rewards = torch.from_numpy(np.vstack(\
                        [e.reward for e in experiences \
                         if e is not None])).float().to(device)
            next_images = torch.from_numpy(np.vstack(\
                        [e.next_state['image'][None] \
                         for e in experiences if e is not
                         None])).float().to(device)
            next_lidars = torch.from_numpy(np.vstack(\
                        [e.next_state['lidar'][None] \
                         for e in experiences if e is not \
                         None])).float().to(device)
            next_sensors = torch.from_numpy(np.vstack(\
                        [e.next_state['sensor'] \
                         for e in experiences if e is not \
                         None])).float().to(device)
            next_states = [next_images, next_lidars, next_sensors]
            dones = torch.from_numpy(np.vstack([e.done \
                         for e in experiences if e is not \
                     None]).astype(np.uint8)).float().to(device)     
            return (states,actions, rewards, next_states, dones)
        def __len__(self):
            """Return the current size of internal memory."""
            return len(self.memory) 
    

注意,粗体代码行获取当前状态、动作、奖励和下一个状态的信息。

现在关键的组件都已就位,让我们将 Gym 环境加载到 Python 笔记本中并开始训练。

使用固定目标训练 DQN

在这里我们没有额外的理论需要学习。基础知识保持不变;我们只会对 Gym 环境、神经网络的架构以及我们的代理需要采取的行动进行更改:

  1. 首先,加载与环境相关的超参数。在以下代码中,每个键值对旁边的每个注释都很重要。因为我们将模拟一个复杂的环境,我们需要选择环境的参数,如城市中的汽车数量、行人数量、模拟哪个城镇、仪表摄像头图像的分辨率和 LIDAR 传感器:

    %pip install -U "gym==0.26.2"
    import gym
    import gym_carla
    import carla
    from model import DQNetworkState
    from actor import Actor
    from torch_snippets import *
    params = {
        'number_of_vehicles': 10,
        'number_of_walkers': 0,
        'display_size': 256, # screen size of bird-eye render
        'max_past_step': 1, # the number of past steps to draw
        'dt': 0.1, # time interval between two frames
        'discrete': True, # whether to use discrete control space
        # discrete value of accelerations
        'discrete_acc': [-1, 0, 1],
        # discrete value of steering angles
        'discrete_steer': [-0.3, 0.0, 0.3],
        # define the vehicle
        'ego_vehicle_filter': 'vehicle.lincoln*',
        'port': 2000, # connection port
        'town': 'Town03', # which town to simulate
        'task_mode': 'random', # mode of the task
        'max_time_episode': 1000, # maximum timesteps per episode
        'max_waypt': 12, # maximum number of waypoints
        'obs_range': 32, # observation range (meter)
        'lidar_bin': 0.125, # bin size of lidar sensor (meter)
        'd_behind': 12, # distance behind the ego vehicle (meter)
        'out_lane_thres': 2.0, # threshold for out of lane
        'desired_speed': 8, # desired speed (m/s)
        'max_ego_spawn_times': 200, # max times to spawn vehicle
        'display_route': True, # whether to render desired route
        'pixor_size': 64, # size of the pixor labels
        'pixor': False, # whether to output PIXOR observation
    }
    # Set gym-carla environment
    env = gym.make('carla-v0', params=params) 
    

在上述params字典中,对于我们模拟中的动作空间,以下内容很重要:

  • 'discrete': True:我们的动作位于一个离散空间中。

  • 'discrete_acc':[-1,0,1]:自驾车在模拟过程中可以进行的所有可能的加速度。

  • 'discrete_steer':[-0.3,0,0.3]:自驾车在模拟过程中可以进行的所有可能的转向幅度。

正如您所看到的,discrete_accdiscrete_steer列表各包含三个项目。这意味着汽车可以采取 9 种唯一的离散动作。因此,model.py文件中的网络有九个离散状态。

一旦您浏览了官方文档,可以随意更改参数。

  1. 有了这些,我们就有了训练模型所需的所有组件。如果存在预训练模型,请加载它。如果我们从头开始,将其保持为None

    load_path = None # 'car-v1.pth'
    # continue training from an existing model
    save_path = 'car-v2.pth'
    actor = Actor()
    if load_path is not None:
        actor.qnetwork_local.load_state_dict(torch.load(load_path))
        actor.qnetwork_target.load_state_dict(torch.load(load_path))
    else:
        pass 
    
  2. 固定 episode 数量,并定义dqn函数来训练代理,如下所示:

    1. 重置状态:
    n_episodes = 100000
    def dqn(n_episodes=n_episodes, max_t=1000, eps_start=1, \
            eps_end=0.01, eps_decay=0.995):
        scores = [] # list containing scores from each episode
        scores_window = deque(maxlen=100) # last 100 scores
        eps = eps_start # Initialize epsilon
        for i_episode in range(1, n_episodes+1):
            state, *_ = env.reset() 
    
    1. 将状态封装到字典中(如在actor.py:Actor类中讨论的),并对其进行操作:
     image, lidar, sensor = state['camera'], \
                                   state['lidar'], \
                                   state['state']
            image, lidar = preprocess(image), preprocess(lidar)
            state_dict = {'image': image, 'lidar': lidar, \
                          'sensor': sensor}
            score = 0
            for t in range(max_t):
                action = actor.act(state_dict, eps) 
    
    1. 存储从环境中获得的下一个状态,然后存储state, next_state对(以及奖励和其他状态信息),以使用 DQN 来训练 actor:
     next_state, reward, done, *_ = env.step(action)
                image, lidar, sensor = next_state['camera'], \
                                       next_state['lidar'], \
                                       next_state['state']
                image,lidar = preprocess(image), preprocess(lidar)
                next_state_dict= {'image':image,'lidar':lidar, \
                                  'sensor': sensor}
                actor.step(state_dict, action, reward, \
                           next_state_dict, done)
                state_dict = next_state_dict
                score += reward
                if done:
                    break
            scores_window.append(score) # save most recent score
            scores.append(score) # save most recent score
            eps = max(eps_end, eps_decay*eps) # decrease epsilon
            if i_episode % 100 == 0:
                log.record(i_episode, mean_score=np.mean(scores_window))
                torch.save(actor.qnetwork_local.state_dict(), save_path) 
    

我们必须重复这个循环,直到收到完成信号,然后重置环境并重新开始存储行动。每隔 100 个 episode,存储模型。

  1. 调用dqn函数来训练模型:

    dqn() 
    

由于这是一个更复杂的环境,训练可能需要几天时间,因此请耐心,并继续使用load_pathsave_path参数每次训练几个小时。通过足够的训练,车辆可以学会如何自主驾驶。这是我们在两天训练后能够达到的训练结果视频:tinyurl.com/mcvp-self-driving-agent-result

概要

在本章中,我们学习了如何计算给定状态下各种行动的价值。然后,我们学习了代理如何更新 Q 表,使用折扣值来执行给定状态下的行动。在此过程中,我们了解了在状态数量较多的情况下,Q 表是不可行的。我们还学习了如何利用深度 Q 网络来解决可能状态数量较高的情况。接着,我们利用基于固定目标的 DQN,结合 CNN-based 神经网络来建立一个学习如何玩乒乓球的代理。最后,我们学习了如何利用具有固定目标的 DQN 来进行自动驾驶,使用 CARLA 模拟器。

正如我们在本章中多次看到的那样,您可以使用深度 Q 学习来学习非常不同的任务,比如 CartPole 平衡、玩乒乓球和自动驾驶导航,几乎使用相同的代码。虽然这不是我们探索 RL 旅程的终点,但在这一点上,我们应该能够理解如何将基于 CNN 和基于强化学习的算法结合起来解决复杂问题并构建学习代理。

到目前为止,我们学习了如何将基于计算机视觉的技术与其他重要研究领域的技术结合起来,包括元学习、自然语言处理和强化学习。除此之外,我们还学习了如何使用 GAN 进行对象分类、检测、分割和图像生成。在下一章中,我们将转向学习如何将深度学习模型投入到生产中。

问题

  1. 代理如何计算给定状态的价值?

  2. 如何填充 Q 表?

  3. 为什么在状态-行动价值计算中需要折扣因子?

  4. 为什么需要探索-利用策略?

  5. 为什么需要使用深度 Q 学习?

  6. 如何使用深度 Q 学习计算给定状态-动作组合的值?

  7. 一旦代理在 CartPole 环境中最大化了奖励,是否有可能后来学习到次优策略?

在 Discord 上了解更多

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/modcv

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值