PyTorch-现代计算机视觉-七-

159 阅读42分钟

PyTorch 现代计算机视觉(七)

十三、用于操作图像的高级 GAN

在前一章中,我们学习了如何利用生成对抗网络 ( GANs )来生成逼真的图像。在这一章中,我们将学习如何利用 GANs 来处理图像。我们将学习使用 GANs 生成图像的两种变体——监督和非监督方法。在监督方法中,我们将提供输入和输出对组合,以基于输入图像生成图像,我们将在 Pix2Pix GAN 中了解这一点。在无监督方法中,我们将指定输入和输出,然而,我们不会提供输入和输出之间的一一对应,而是期望 GAN 学习两个类的结构,并将图像从一个类转换为另一个类,这将在 CycleGAN 中学习。

另一类无监督的图像处理涉及从随机向量的潜在空间生成图像,并查看图像如何随着潜在向量值的变化而变化,我们将在在自定义图像上利用 style gan部分了解这一点。最后,我们将了解如何利用预先训练的 GAN–SRGAN,它有助于将低分辨率图像转换为高分辨率图像。

具体来说,我们将了解以下主题:

  • 利用 Pix2Pix GAN
  • 利用循环根
  • 在自定义图像上利用 StyleGAN
  • 超分辨率氮化镓

利用 Pix2Pix GAN

想象一个场景,我们有彼此相关的图像对(例如,一个对象的边缘图像作为输入,一个对象的实际图像作为输出)。给定的挑战是我们想要在给定物体边缘的输入图像的情况下生成图像。在传统的设置中,这将是一个简单的输入到输出的映射,因此是一个监督学习问题。然而,想象一下,你正和一个创意团队一起工作,他们正试图为产品设计出一个全新的外观。在这种情况下,监督学习没有太大帮助——因为它只从历史中学习。GAN 在这里很方便,因为它将确保生成的图像看起来足够真实,并为实验留下空间(因为我们有兴趣检查生成的图像是否像感兴趣的类之一)。

在本节中,我们将学习从手绘的鞋子轮廓生成鞋子图像的架构。我们将采用以下策略从涂鸦中生成逼真的图像:

  1. 获取大量实际图像,并使用标准 cv2 边缘检测技术创建相应的轮廓。
  2. 从原始图像的补丁中采样颜色,以便生成器知道要生成的颜色。
  3. 构建一个 UNet 架构,将带有样本补丁颜色的轮廓作为输入,并预测相应的图像-这是我们的生成器。
  4. 建立一个鉴别器架构,它可以拍摄图像并预测图像是真是假。
  5. 将发生器和鉴别器一起训练到发生器可以生成欺骗鉴别器的图像的程度。

让我们编码策略:

以下代码在本书的 GitHub 知识库的Chapter13文件夹中以Pix2Pix_GAN.ipynb的形式提供-【tinyurl.com/mcvp-packt 代码包含下载数据的 URL,长度适中。我们强烈建议您在 GitHub 中执行 notebook 以重现结果,同时理解执行的步骤和对文本中各种代码组件的解释。

  1. 导入数据集并安装相关包:
try:
    !wget https://bit.ly/3kiuN93
    !mv 3kiuN93 ShoeV2.zip
    !unzip ShoeV2.zip
    !unzip ShoeV2_F/ShoeV2_photo.zip
except:
    !wget https://www.dropbox.com/s/g6b6gtvmdu0h77x/ShoeV2_photo.zip
!pip install torch_snippets
from torch_snippets import *
device = 'cuda' if torch.cuda.is_available() else 'cpu'

前面的代码下载鞋子的图像。下载图像的示例如下:

对于我们的问题,我们想画出鞋子的轮廓(边缘)和鞋子的样本补丁颜色。在下一步中,我们将获取给定鞋子图像的边缘。这样,我们可以训练一个模型,在给定鞋子的轮廓和样本补丁颜色的情况下,重建鞋子的图像。

  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 方法如何工作的更多细节,请参见第十八章、中的使用 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
  1. 定义数据集类(ShoesData)。这个数据集类返回原始图像和带边缘的图像。我们将传递给网络的另一个细节是随机选择的区域中的颜色块。通过这种方式,我们使用户能够获得手绘轮廓图像,在图像的不同部分喷洒所需的颜色,并生成新的图像。此处显示了输入(第三幅图像)和输出(第一幅图像)的示例(彩色效果最佳):

然而,我们在步骤 1 中得到的输入图像只是鞋子的图像(第一幅图像),我们将用它来提取鞋子的边缘(第二幅图像)。此外,我们将在下一步中使用颜色来获取前面图像的输入(第三个图像)-输出(第一个图像)组合。

在下面的代码中,我们将构建一个类,该类获取轮廓图像,散布颜色,并返回一对散布了颜色的图像和原始的鞋子图像(生成轮廓的图像):

  • 定义ShoesData类、__init__方法和__len__方法:
class ShoesData(Dataset):
    def __init__(self, items):
        self.items = items
    def __len__(self): return len(self.items)
  • 定义__getitem__方法。在这种方法中,我们将处理输入图像以获取具有边缘的图像,然后用原始图像中存在的颜色来点缀图像。这里,我们获取给定图像的边缘:
    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)
  • 一旦我们获取了图像的边缘,调整图像的大小并使其正常化:
        im, edges = resize(im, IMAGE_SIZE), \
                    resize(edges, IMAGE_SIZE)
        im, edges = normalize(im), normalize(edges)
  • edges图像和preprocess原始图像和edges图像上色:
        self._draw_color_circles_on_src_img(edges, im)
        im, edges = preprocess(im), preprocess(edges)
        return edges, im
  • 定义喷洒颜色的功能:
    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))]
  1. 定义训练和验证数据对应的数据集和数据加载器:
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)
  1. 定义生成器和鉴别器架构,它们利用权重初始化(weights_init_normal)、UNetDownUNetUp架构,正如我们在第九章、图像分割和第十章、目标检测和分割应用中所做的那样,来定义GeneratorUNetDiscriminator架构。
  • 初始化权重,使其遵循正态分布:
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)
  • 定义UNetwDownUNetUp类:
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
  • 定义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)
  • 定义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)
  1. 定义generatordiscriminator模型对象并提取摘要:
generator = GeneratorUNet().to(device)
discriminator = Discriminator().to(device)
!pip install torch_summary
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)))

发电机架构总结如下:

鉴别器架构概述如下:

  1. 定义训练鉴别器的函数(discriminator_train_step):
  • 鉴别器函数将源图像(real_src)、真实目标(real_trg)和虚假目标(fake_trg)作为输入:
def discriminator_train_step(real_src, real_trg, fake_trg):
    d_optimizer.zero_grad()
  • 通过比较真实目标(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()
  • 计算假图像(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()
  • 执行优化器步骤,并返回预测的真实和虚假目标的总体错误和损失值:
    d_optimizer.step()
    return error_real + error_fake
  1. 定义训练生成器(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):

  • 定义一个函数来获取预测样本:
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)
  1. 将权重初始化(weights_init_normal)应用于发生器和鉴别器模型对象:
generator.apply(weights_init_normal)
discriminator.apply(weights_init_normal)
  1. 指定损失标准和优化方法(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))
  1. 训练模型超过 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)]
  1. 在样本手绘轮廓上生成:
[sample_prediction() for _ in range(2)]

上述代码生成以下输出:

请注意,在前面的输出中,我们生成了与原始图像颜色相似的图像。

在本节中,我们学习了如何利用图像的轮廓来生成图像。然而,这要求我们成对地提供输入和输出,这有时会是一个繁琐的过程。在下一节中,我们将了解不成对的图像转换,在这种情况下,无需我们指定图像的输入和输出映射,网络就能计算出转换。

利用循环根

想象一个场景,我们要求您执行从一个类到另一个类的图像转换,但是没有给出输入和相应的输出图像来训练模型。然而,我们在两个不同的文件夹中给你两个类的图像。在这种情况下,CycleGAN 就派上了用场。

在本节中,我们将学习如何训练 CycleGAN 将苹果的图像转换为橙子的图像,反之亦然。CycleGAN 中的循环指的是我们把一个图像从一个类翻译(转换)到另一个类,再回到原来的类。

概括地说,在此架构中,我们将有三个独立的损耗值(此处提供了更多详细信息):

  • 鉴别器损失:这确保在训练模型时修改对象类(如前一节所示)。
  • 循环损耗:将一幅图像从生成的图像循环到原始图像,以保证周围像素不被改变的损耗。
  • 身份损失:当一个类别的图像通过一个生成器时的损失,该生成器预期将另一个类别的图像转换成输入图像的类别。

在这里,我们将从较高的层面了解构建 CycleGAN 的步骤:

  1. 导入和预处理数据集
  2. 构建发生器和鉴别器网络 UNet 架构
  3. 定义两个生成器:
  • G_AB :将 A 类图像转换为 B 类图像的生成器
  • G_BA :将 B 类图像转换为 A 类图像的生成器
  1. 定义身份丧失:
  • 如果你要发送一个橙色图像给一个橙色生成器,理想情况下,如果生成器已经理解了关于橙色的一切,它不应该改变图像,应该“生成”完全相同的图像。因此,我们利用这些知识创造了一个身份。
  • 当类别 A (real_A)的图像通过 G_BA 并与 real_A 比较时,身份损失应该是最小的。
  • 当类 B (real_B)的图像通过 G_AB 并与 real_B 比较时,身份损失应该是最小的。
  1. 定义 GAN 损耗:
  • real_A 和 fake_A 的鉴频器和发生器损耗(当 real_B 图像通过 G_BA 时获得 fake_A)
  • real_B 和 fake_B 的鉴频器和发生器损耗(当 real_A 图像通过 G_AB 时获得 fake_B)
  1. 定义 re- 周期 损耗:
  • 考虑一个场景,其中一个苹果的图像将被一个橙子生成器转换成一个假橙子,而假橙子将被苹果生成器转换回一个苹果。
  • fake_B,是 real_A 通过 G_AB 时的输出,fake_B 通过 G_BA 时应该会重新生成 real_A。
  • fake_A,是 real_B 通过 G_BA 时的输出,fake_A 通过 G_AB 时应该会重新生成 real_B。
  1. 针对三种损失的加权损失进行优化。

现在我们已经了解了这些步骤,让我们对它们进行编码,以便将苹果转换成橙子,反之亦然,如下所示:

以下代码在本书的 GitHub 知识库的Chapter13文件夹中以CycleGAN.ipynb的形式提供-【tinyurl.com/mcvp-packt 代码包含下载数据的 URL,长度适中。我们强烈建议您在 GitHub 中执行笔记本以重现结果,同时理解执行的步骤和文本中各种代码组件的解释。

  1. 导入相关数据集和包:
  • 下载并提取数据集:
!wget https://www.dropbox.com/s/2xltmolfbfharri/apples_oranges.zip
!unzip apples_oranges.zip

我们将要处理的图像样本:

请注意,苹果和橙色图像之间没有一对一的对应关系(不像我们在利用 Pix2Pix GAN 部分了解的轮廓到鞋子生成用例)。

  • 导入所需的包:
!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
  1. 定义图像转换管道(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)),
])
  1. 定义 dataset 类(CycleGANDataset),以appleorange文件夹(解压下载的数据集后得到)为输入,提供一批苹果和橙子的图片:
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)
  1. 定义训练和验证数据集以及数据加载器:
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)
  1. 按照前面章节的定义,定义网络的权重初始化方法(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)
  1. 定义剩余块网络(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)
  1. 定义发电机网络(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)
  1. 定义鉴别器网络(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)
  • 定义生成图像样本的函数-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)
  1. 定义训练发电机的功能(generator_train_step):
  • 该函数以两个发电机模型(G_AB 和 G_BA 为Gs)、optimizer和两个类的实像——real_Areal_B——作为输入:
def generator_train_step(Gs, optimizer, real_A, real_B):
  • 指定生成器:
    G_AB, G_BA = Gs
  • 将优化器的梯度设置为零:
    optimizer.zero_grad()
  • 如果你要发送一个橙色图像到一个橙色生成器,理想情况下,如果生成器已经理解了关于橙色的一切,它不应该对图像做任何改变,应该“生成”精确的图像。因此,我们利用这些知识创造了一个身份。对应于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
  • 当图像通过生成器时,计算 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
  • 计算循环损耗。考虑一个场景,其中一个苹果的图像将被一个橙子生成器转换成一个假橙子,并且这样一个假橙子将被苹果生成器转换回苹果。如果发生器是完美的,这个过程应该返回原始图像,这意味着以下循环损耗应该为零:
    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
  • 计算总损耗,并在返回计算值之前执行反向传播:
    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
  1. 定义训练鉴别器的函数(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
  1. 定义生成器、鉴别器对象、优化器和损失函数:
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
  1. 在越来越多的时期训练网络:
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)
  1. 训练完模型后生成图像:
generate_sample()

上述代码生成以下输出:

从前面的例子中,我们可以看到,我们成功地将苹果转换为橙子(前两行),将橙子转换为苹果(后两行)。

到目前为止,我们已经了解了通过 Pix2Pix GAN 的成对图像到图像转换和通过 CycleGAN 的不成对图像到图像转换。在下一节中,我们将了解如何利用 StyleGAN 将一种样式的图像转换成另一种样式的图像。

在自定义图像上利用 StyleGAN

让我们首先了解在 StyleGAN 发明之前的一些历史发展。正如我们所知,从上一章生成假面涉及到了 GANs 的使用。研究面临的最大问题是可以生成的图像很小(通常为 64 x 64)。任何产生较大尺寸图像的努力都会导致生成器或鉴别器陷入局部极小值,从而停止训练并产生乱码。生成高质量图像的重大飞跃之一涉及一篇名为 ProGAN(Progressive GAN 的缩写)的研究论文,其中涉及一个聪明的技巧。

发生器和鉴别器的尺寸都逐渐增大。在第一步中,创建一个生成器和鉴别器,从潜在向量生成 4 x 4 图像。在此之后,附加卷积(和放大)层被添加到经过训练的生成器和鉴别器,其将负责接受 4×4 图像(在步骤 1 中从潜在向量生成)并生成/鉴别 8×8 图像。一旦这个步骤也完成了,新的层在发生器和鉴别器中再次被创建,被训练以生成更大的图像。图像大小以这种方式一步一步(渐进地)增加。其逻辑是,向已经运行良好的网络添加新层比从头开始学习所有层更容易。通过这种方式,图像被放大到 1024×1024 像素的分辨率(图像来源:arxiv.org/pdf/1710.10196v3.pdf):

尽管它取得了很大的成功,但控制生成图像的各个方面(如性别和年龄)却相当困难,这主要是因为网络只能获得一个输入(在前面的图像中:潜伏在网络的顶部)。StyleGAN 解决了这个问题。

StyleGAN 使用了一种类似的训练方案,图像是逐步生成的,但每次网络增长时都会增加一组潜在输入。这意味着该网络现在接受多个潜在向量在定期间隔的图像大小生成。在生成阶段给出的每一个潜势决定了在那个网络阶段将要生成的特征(风格)。让我们在这里更详细地讨论 StyleGAN 的工作细节:

在上图中,我们可以对比生成图像的传统方式和基于样式的生成器。在传统的发电机中,只有一个输入。但是,在基于样式的生成器中有一种机制。下面我们来了解一下细节:

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

  2. 将此馈送到一个称为样式网络(或映射网络)的辅助网络,该网络创建一个大小为 18 x 512 的张量 w

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

    • 对应行的w(‘A’)
    • 随机噪声向量(“B”)
    • 前一层的输出

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

前面的三个组合将创建一个接收 1 x 512 矢量的管道,并创建一个 1024 x 1024 的图像。

现在,让我们了解从映射网络生成的 18×512 向量中的 18 个 1×512 向量中的每一个如何对图像的生成做出贡献。在合成网络的前几层添加的 1 x 512 矢量有助于图像中存在的整体姿势和大比例特征,如姿势、脸型等(因为它们负责生成 4 x 4、8 x 8 图像等,这些是将在后面的层中进一步增强的前几个图像)。中间层中添加的矢量对应于小比例特征,例如发型、眼睛睁开/闭上(因为它们负责生成 16 x 16、32 x 32 和 64 x 64 的图像)。最后几层添加的矢量对应于图像的配色方案和其他微结构。当我们到达最后几层时,图像结构被保留,面部特征被保留但只有图像级细节如光照条件被改变。

在本节中,我们将利用预先训练的 StyleGAN2 模型来定制我们感兴趣的图像,使其具有不同的风格。

对于我们的目标,我们将使用 StyleGAN2 模型执行样式转换。概括地说,下面是 faces 上的样式转换是如何工作的(下面的内容在您浏览代码的结果时会更清楚):

  • 假设 w [1] 样式向量用于生成 face-1,w [2] 样式向量用于生成 face-2。两个都是 18 x 512。

  • w [2] 中 18 个矢量的前几个(负责生成 4×4 到 8×8 分辨率的图像)被替换为 w [1] 中的相应矢量。然后,我们转移非常粗糙的特征,例如从面 1 到面 2 的姿态。

  • 如果在 w [2] 中用 w [1] 中的样式向量替换后面的样式向量(比如 18 x 512 的第三个到第十五个——它们负责生成 64 x 64 到 256 x 256 维的一批图像),那么我们就转移眼睛、鼻子和其他面部中级特征。

  • 如果最后几个风格向量(其负责生成 512 x 512 到 1024 x 1024 维的一批图像)被替换,则精细级别的特征如肤色和背景(其不会以显著的方式影响整个面部)被转移。

了解了如何进行样式转换后,现在让我们了解如何使用 StyleGAN2 在自定义图像上执行样式转换:

  1. 拍摄自定义图像。
  2. 对齐自定义图像,以便仅存储图像的面部区域。
  3. 获取可能生成自定义校准图像的潜在向量。
  4. 通过将随机潜在向量(1 x 512)传递到映射网络来生成图像。

到这一步,我们有两个图像——我们定制的对齐图像和由 StyleGAN2 网络生成的图像。我们现在想把自定义图像的一些特性转移到生成的图像上,反之亦然。

让我们编写前面的策略。

请注意,我们正在利用从 GitHub 存储库中获取的预训练网络,因为训练这样的网络需要几天甚至几周的时间:

您需要一个支持 CUDA 的环境来运行下面的代码。以下代码在本书的 GitHub 知识库的Chapter13文件夹中以Customizing_StyleGAN2.ipynb的形式提供-【tinyurl.com/mcvp-packt 代码包含下载数据的 URL,长度适中。我们强烈建议您在 GitHub 中执行 notebook 以重现结果,同时理解执行的步骤和对文本中各种代码组件的解释。

  1. 克隆存储库,安装需求,并获取预先训练好的权重:
import os
if not os.path.exists('pytorch_stylegan_encoder'):
    !git clone https://github.com/jacobhallberg/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 *
  1. 加载预训练的生成器和合成网络,映射网络的权重:
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
  1. 定义从随机向量生成图像的函数:
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
  1. 生成随机向量:
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)

上述代码生成以下输出:

到目前为止,我们已经生成了一个图像。在接下来的几行代码中,您将了解如何在前面生成的图像和您选择的图像之间执行样式转换。

  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
  1. 对齐自定义图像:
!python stylegan-encoder/align_images.py \
stylegan-encoder/raw_img/ \
stylegan-encoder/aligned_img/
!mv stylegan-encoder/aligned_img/* ./MyImage.jpg
  1. 使用校准图像生成能够完美再现校准图像的潜影。这是识别潜在向量组合的过程,该潜在向量组合使对准图像和从潜在向量生成的图像之间的差异最小化:
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')

上述代码生成以下输出:

Python 脚本encode_image.py在较高层次上执行以下操作:

  1. 空间创建一个随机向量。

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

  3. 使用 VGG 感知损失(与神经风格转换中使用的损失相同)将合成图像与原始输入图像进行比较。

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

  5. 优化的矢量现在将合成一幅图像,VGG 为该图像给出与输入图像几乎相同的特征,因此合成的图像将看起来与输入图像相似。

现在我们有了对应于感兴趣图像的潜在向量,让我们在下一步执行图像之间的风格转换。

  1. 执行风格转换:

如前所述,风格迁移背后的核心逻辑实际上是部分风格张量的转移,即 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')

前面的代码生成了以下内容:

下面是用idxs_to_swap分别作为slice(4,15)slice (15,18)的输出。

  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))

下面是生成的图像:

总之,我们已经了解了在使用 GANs 生成高分辨率人脸图像方面的研究进展。诀窍是在增加分辨率的步骤中增加发生器和鉴别器的复杂性,以便在每一步中,两个模型都能很好地完成任务。我们了解了如何通过确保每个分辨率下的特征都由一个称为样式向量的独立输入来决定,从而操纵生成图像的样式。我们还学习了如何通过从一个图像到另一个图像交换样式来操作不同图像的样式。

现在,我们已经了解了如何利用预训练的 StyleGAN2 模型来执行风格转换,在下一节中,我们将利用预训练的超分辨率 GAN 模型来生成高分辨率图像。

超分辨率氮化镓

在上一节中,我们看到了一个场景,其中我们利用预先训练好的 StyleGAN 来生成给定样式的图像。在本节中,我们将更进一步,了解如何利用预先训练的模型来执行图像超分辨率。在将超分辨率 GAN 模型应用于图像之前,我们将对其架构有所了解。

首先,我们将理解 GAN 是超分辨率任务的良好解决方案的原因。想象这样一个场景,给你一张图片,要求你提高它的分辨率。直觉上,你会考虑各种插值技术来执行超分辨率。这里有一个低分辨率图像样本以及各种技术的输出(图像来源:arxiv.org/pdf/1609.04802.pdf):

从前面的图像中,我们可以看到,当从低分辨率(原始图像的 4X 缩小图像)重建图像时,双三次插值等传统插值技术没有多大帮助。

虽然基于 ResNet 的超分辨率 UNet 在这种情况下可以派上用场,但 GANs 可能更有用,因为它们模拟人类的感知。假设鉴别器知道典型的超分辨率图像看起来是什么样的,那么它可以检测生成的图像具有不一定看起来像高分辨率图像的属性的情况。

确定了超分辨率对 GANs 的需求后,让我们了解并利用预训练模型。

架构

虽然从头开始编码和训练超分辨率 GAN 是可能的,但我们将尽可能利用预先训练的模型。因此,在本节中,我们将利用 Christian Ledig 及其团队开发的模型,该模型发表在题为使用生成式对抗网络的照片级单图像超分辨率的论文中。

SRGAN 的架构如下(图片来源:arxiv.org/pdf/1609.04802.pdf):

从前面的图像中,我们看到鉴别器将高分辨率图像作为输入来训练预测图像是高分辨率图像还是低分辨率图像的模型。生成器网络将低分辨率图像作为输入,并得出高分辨率图像。在训练模型时,内容损失和敌对损失都被最小化。要详细了解模型训练的细节,并比较用于生成高分辨率图像的各种技术的结果,我们建议您通读本文。

通过对模型构建方式的高级理解,我们将编码利用预训练的 SRGAN 模型将低分辨率图像转换为高分辨率图像的方法。

编码 SRGAN

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

以下代码可在本书的 GitHub 知识库的Chapter 13文件夹中以Image super resolution using SRGAN.ipynb的名称获得-【tinyurl.com/mcvp-packt 代码包含了下载数据的 URL。我们强烈建议您在 GitHub 中执行 notebook 以重现结果,同时理解执行的步骤和对文本中各种代码组件的解释。

  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'
  1. 加载模型:
model = torch.load('srgan.pth.tar', map_location='cpu')['generator'].to(device)
model.eval()
  1. 获取要转换为高分辨率的图像:
!wget https://www.dropbox.com/s/nmzwu68nrl9j0lf/Hema6.JPG
  1. 定义图像的preprocesspostprocess函数:
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()
            ])
  1. 加载图像并对其进行预处理:
image = readPIL('Hema6.JPG')
image.size
# (260,181)
image = image.resize((130,90))
im = preprocess(image)

请注意,在前面的代码中,我们对原始图像执行了额外的调整大小,以进一步模糊图像,但这只是为了说明,因为当我们缩小图像时,改进会更加明显。

  1. 将预处理后的图像通过加载的modelpostprocess模型输出:
sr = model(im[None])[0]
sr = postprocess(sr)
  1. 绘制原始图像和高分辨率图像:
subplots([image, sr], nc=2, figsize=(10,10), \
         titles=['Original image','High resolution image'])

上述代码会产生以下输出:

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

请注意,如果原始图像模糊,则原始图像和高分辨率图像之间的对比度会很高。但是,如果原始图像没有模糊,对比度就不会那么高。我们鼓励您使用不同分辨率的图像。

摘要

在本章中,我们学习了如何使用 Pix2Pix GAN 从给定的轮廓生成图像。此外,我们还学习了 CycleGAN 中的各种损失函数,用于将一类图像转换为另一类图像。接下来,我们了解了 StyleGAN 如何帮助生成逼真的人脸,以及如何根据生成器的训练方式将样式从一个图像复制到另一个图像。最后,我们了解了如何利用预训练的 SRGAN 模型来生成高分辨率图像。

在下一章中,我们将转而学习基于非常少(通常少于 20 张)的图像来训练图像分类模型。

问题

  1. 为什么我们需要 Pix2Pix GAN,而 UNet 等监督学习算法可以从轮廓生成图像?
  2. 为什么我们需要在 CycleGAN 中针对三种不同的损失函数进行优化?
  3. ProgressiveGAN 中的技巧如何帮助构建 StyleGAN?
  4. 我们如何识别对应于给定自定义图像的潜在向量?

第四部分:使用其它技术的计算机视觉

在这最后一节中,我们将学习如何将计算机视觉技术与其他领域的技术相结合,如 NLP、强化学习和 OpenCV 等工具,以提出解决传统问题的新方法。

本节包括以下章节:

  • 第十四章,用最少的数据点训练
  • 第十五章,结合计算机视觉和自然语言处理技术
  • 第十六章,结合计算机视觉和强化学习
  • 第十七章,将一个模型转移到生产
  • 第十八章,使用 OpenCV 工具进行图像分析

十四、将最小数据点用于训练

到目前为止,在前面的章节中,我们已经学习了如何分类图像,每个类别都有成百上千的示例图像要训练。在这一章中,我们将学习各种有助于图像分类的技术,即使每堂课只有很少的训练样本。我们将从训练一个模型来预测一个类别开始,即使在训练期间对应于该类别的图像不存在。接下来,我们将转到一个场景,在这个场景中,我们试图预测的类在训练期间只出现几个图像。我们将编码暹罗网络,这属于少数镜头学习的范畴,并了解关系网络和原型网络的工作细节。

我们将在本章中了解以下主题:

  • 实现零样本学习
  • 实现少样本学习

实现零样本学习

想象一个场景,我让你预测图像中的对象类别,而你以前没有见过该对象类别的图像。在这种情况下,你如何做出预测?

直觉上,我们求助于图像中对象的属性,然后尝试识别最匹配属性的对象。

在一个这样的场景中,我们必须自动提出属性(属性不是为了训练而给出的),我们利用词向量。词向量包含词之间的语义相似性。例如,所有的动物都有相似的单词向量,而汽车有非常不同的单词向量表示。虽然单词向量的生成超出了本书的范围,但我们将研究预先训练好的单词向量。在非常高的水平上,具有相似周围单词(上下文)的单词将具有相似的向量。以下是单词向量的 t-SNE 表示法示例:

从前面的示例中,我们可以看到单词 vectors of automobiles 位于图表的左侧,而对应于动物的 vectors 位于右侧。此外,相似的动物也有相似的媒介。

这给了我们直觉,文字,就像图像一样,也有帮助获得相似性的矢量嵌入。

在下一节中,当我们编写零触发学习时,我们将利用这一现象来识别模型在训练期间看不到的类。本质上,我们将学习直接将图像特征映射到单词特征。

编码零触发学习

我们在编码零炮学习时采用的高级策略如下:

  1. 导入数据集——数据集由图像及其相应的类组成。
  2. 从预先训练的词向量模型中取出对应于每个类别的词向量。
  3. 通过预先训练的图像模型(如 VGG16)传递图像。
  4. 我们期望网络预测图像中物体对应的词向量。
  5. 一旦我们训练了模型,我们就可以预测新图像上的单词向量。
  6. 最接近预测单词向量的单词向量的类别是图像的类别。

让我们将前面的策略编码如下:

以下代码在本书的 GitHub 资源库的Chapter14文件夹中以Zero_shot_learning.ipynb的形式提供-【tinyurl.com/mcvp-packt 确保从 GitHub 的笔记本中复制 URL,以避免在复制结果时出现任何问题

  1. 克隆包含本练习数据集的 GitHub 存储库,并导入相关的包:
!git clone https://github.com/sizhky/zero-shot-learning/
!pip install -Uq torch_snippets
%cd zero-shot-learning/src
import gzip, _pickle as cPickle
from torch_snippets import *
from sklearn.preprocessing import LabelEncoder, normalize
device = 'cuda' if torch.cuda.is_available() else 'cpu'
  1. 定义特征数据的路径(DATAPATH)以及 word2vec 嵌入(WORD2VECPATH):
WORD2VECPATH = "../data/class_vectors.npy"
DATAPATH = "../data/zeroshot_data.pkl"
  1. 提取可用类的列表:
with open('train_classes.txt', 'r') as infile:
    train_classes = [str.strip(line) for line in infile]
  1. 加载特征向量数据:
with gzip.GzipFile(DATAPATH, 'rb') as infile:
    data = cPickle.load(infile)
  1. 定义训练数据和属于零触发类(训练期间不存在的类)的数据。注意,我们将仅显示属于训练类的类,并隐藏零样本模型类,直到推断时间:
training_data = [instance for instance in data if \
                instance[0] in train_classes]
zero_shot_data = [instance for instance in data if \
                instance[0] not in train_classes]
np.random.shuffle(training_data)
  1. 为每个类提取 300 个训练图像用于训练,并且为验证提取剩余的训练类图像:
train_size = 300 # per class
train_data, valid_data = [], []
for class_label in train_classes:
    ctr = 0
    for instance in training_data:
        if instance[0] == class_label:
            if ctr < train_size:
                train_data.append(instance)
                ctr+=1
            else:
                valid_data.append(instance)
  1. 混洗训练和验证数据,并将对应于类别的向量提取到字典中-vectors:
np.random.shuffle(train_data)
np.random.shuffle(valid_data)
vectors = {i:j for i,j in np.load(WORD2VECPATH, \
                                allow_pickle=True)}
  1. 获取用于训练和验证数据的图像和文字嵌入特征:
train_data=[(feat,vectors[clss]) for clss,feat in train_data]
valid_data=[(feat,vectors[clss]) for clss,feat in valid_data]
  1. 获取培训、验证和零触发类:
train_clss = [clss for clss,feat in train_data]
valid_clss = [clss for clss,feat in valid_data]
zero_shot_clss = [clss for clss,feat in zero_shot_data]
  1. 定义训练数据、验证数据和零炮数据的输入和输出数组:
x_train, y_train = zip(*train_data)
x_train, y_train = np.squeeze(np.asarray(x_train)), \
                    np.squeeze(np.asarray(y_train))
x_train = normalize(x_train, norm='l2')

x_valid, y_valid = zip(*valid_data)
x_valid, y_valid = np.squeeze(np.asarray(x_valid)), \
                    np.squeeze(np.asarray(y_valid))
x_valid = normalize(x_valid, norm='l2')

y_zsl, x_zsl = zip(*zero_shot_data)
x_zsl, y_zsl = np.squeeze(np.asarray(x_zsl)), \
                np.squeeze(np.asarray(y_zsl))
x_zsl = normalize(x_zsl, norm='l2')
  1. 定义训练和验证数据集以及数据加载器:
from torch.utils.data import TensorDataset

trn_ds = TensorDataset(*[torch.Tensor(t).to(device) for t in \
                         [x_train, y_train]])
val_ds = TensorDataset(*[torch.Tensor(t).to(device) for t in \
                         [x_valid, y_valid]])
trn_dl = DataLoader(trn_ds, batch_size=32, shuffle=True)
val_dl = DataLoader(val_ds, batch_size=32, shuffle=False)
  1. 构建一个模型,将 4096 维特征作为输入,预测 300 维向量作为输出:
def build_model(): 
    return nn.Sequential(
        nn.Linear(4096, 1024), nn.ReLU(inplace=True),
        nn.BatchNorm1d(1024), nn.Dropout(0.8),
        nn.Linear(1024, 512), nn.ReLU(inplace=True),
        nn.BatchNorm1d(512), nn.Dropout(0.8),
        nn.Linear(512, 256), nn.ReLU(inplace=True),
        nn.BatchNorm1d(256), nn.Dropout(0.8),
        nn.Linear(256, 300)
    )
  1. 定义对一批数据进行训练和验证的函数:
def train_batch(model, data, optimizer, criterion):
    model.train()
    ims, labels = data
    _preds = model(ims)
    optimizer.zero_grad()
    loss = criterion(_preds, labels)
    loss.backward()
    optimizer.step()
    return loss.item()

@torch.no_grad()
def validate_batch(model, data, criterion):
    model.eval()
    ims, labels = data
    _preds = model(ims)
    loss = criterion(_preds, labels)
    return loss.item()
  1. 在不断增加的时期内训练模型:
model = build_model().to(device)
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
n_epochs = 60

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')

    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')        

    if not (ex+1)%10: log.report_avgs(ex+1)

log.plot_epochs(log=True)

上述代码会产生以下输出:

  1. 对包含零拍摄类(模型未见过的类)的图像(x_zsl)进行预测,并获取对应于所有可用类的实际特征(vectors)和classnames:
pred_zsl = model(torch.Tensor(x_zsl).to(device)).cpu()\
                                    .detach().numpy()
class_vectors = sorted(np.load(WORD2VECPATH, \
                allow_pickle=True), key=lambda x: x[0])
classnames, vectors = zip(*class_vectors)
classnames = list(classnames)

vectors = np.array(vectors)
  1. 计算每个预测向量与对应于可用类的向量之间的距离,并测量出现在前五个预测中的零炮类的数量:
dists = (pred_zsl[None] - vectors[:,None])
dists = (dists**2).sum(-1).T

best_classes = []
for item in dists:
    best_classes.append([classnames[j] for j in \
                         np.argsort(item)[:5]])

np.mean([i in J for i,J in zip(zero_shot_clss, best_classes)])

从前面可以看出,在模型的前 5 个预测中,我们可以正确预测大约 73%的图像,这些图像包含的对象的类别在训练期间不存在。请注意,对于前 1、2 和 3 个预测,正确分类图像的百分比分别为 6%、14%和 40%。

现在,我们已经看到了在通过零镜头分类进行训练时不存在某个类别的图像时处理预测的场景,在下一节中,我们将了解如何在训练集中只有几个某个类别的示例时构建模型来预测图像中的对象类别。

实现少样本学习

想象一下这样一个场景,我们只给你一个人的 10 张照片,并要求你辨别一张新照片是否是同一个人。作为人类,我们可以轻松地对这些任务进行分类。然而,到目前为止,我们学习的基于深度学习的算法需要数百/数千个标记的例子才能准确分类。

元学习范式中的多种算法可以方便地解决这种情况。在这一节中,我们将学习致力于解决少图像问题的连体网络、原型网络和关系匹配网络。

这三种算法都旨在学习比较两幅图像,以得出图像相似程度的分数。

下面是一个在少镜头分类过程中会发生什么的示例:

在前面的代表性数据集中,我们在训练时向网络显示了每个类的一些图像,并要求它根据这些图像预测新图像的类。

到目前为止,我们一直在使用预先训练好的模型来解决这类问题。然而,考虑到可用的数据量很少,这样的模型很可能很快就会过度拟合。

您可以利用多种指标、模型和基于优化的架构来解决这种情况。在这一章中,我们将了解基于度量的架构,这些架构提出了一个最佳度量,或者是欧几里德距离,或者是余弦相似度,将相似的图像分组在一起,然后对新图像进行预测。

N-shot k-class 分类是指 k 个类别各有 N 个图像来训练网络。

在接下来的章节中,我们将了解工作细节和代码连体网络,以及原型和关系网络的工作细节。

建立一个暹罗网络

这里,它是我们的两个图像(一个参考图像和查询图像)通过的网络。让我们来了解暹罗网络的工作细节,以及它们如何帮助识别只有几幅图像的同一个人的图像。首先,让我们大致了解一下暹罗网络的工作原理:

我们经历以下步骤:

  1. 通过卷积网络传递图像。
  2. 将另一幅图像通过与步骤 1 相同的神经网络。
  3. 计算两幅图像的编码(特征)。
  4. 计算两个特征向量之间的差。
  5. 通过 sigmoid 激活传递差向量,表示两幅图像是否相似。

在前面的架构中,单词 Siamese 与通过双网络传递两个图像(其中我们复制网络来处理两个图像)以获取两个图像中每一个的图像编码有关。此外,我们正在比较两幅图像的编码,以获取两幅图像的相似性得分。如果相似性得分(或不相似性得分)超过阈值,我们认为图像是同一个人的。

有了这个策略,让我们对暹罗网络进行编码,以预测与图像对应的类别——其中图像类别在训练数据中只出现了几次。

编码连体网络

在这一节中,我们将学习编码暹罗网络来预测一个人的图像是否与我们数据库中的参考图像相匹配。

我们采用的高级策略如下:

  1. 获取数据集。
  2. 以这样一种方式创建数据,即同一个人的两个图像的不相似性将较低,而当两个图像是不同的人时,不相似性较高。
  3. 构建一个卷积神经网络 ( CNN )。
  4. 我们期望 CNN 对损失值求和,这两个损失值对应于图像是同一个人时的分类损失,以及两幅图像之间的距离。我们在这个练习中使用对比损失。
  5. 在不断增加的时期内训练模型。

让我们对前面的策略进行编码:

The following code is available as Siamese_networks.ipynb in the Chapter14 folder in this book's GitHub repository - tinyurl.com/mcvp-packt Be sure to copy the URL from the notebook in GitHub to avoid any issue while reproducing the results

  1. 导入相关的包和数据集:
!pip install torch_snippets
from torch_snippets import *
!wget https://www.dropbox.com/s/ua1rr8btkmpqjxh/face-detection.zip
!unzip face-detection.zip
device = 'cuda' if torch.cuda.is_available() else 'cpu'

训练数据包括 38 个文件夹(每个文件夹对应于不同的人),并且每个文件夹包含该人的 10 个样本图像。测试数据包括 3 个不同人物的 3 个文件夹,每个文件夹有 10 幅图像。

  1. 定义数据集类-SiameseNetworkDataset:
  • __init__方法将包含图像的folder和要执行的变换(transform)作为输入:
class SiameseNetworkDataset(Dataset):
    def __init__(self, folder, transform=None, \
                 should_invert=True):
        self.folder = folder
        self.items = Glob(f'{self.folder}/*/*') 
        self.transform = transform
  • 定义__getitem__方法:
    def __getitem__(self, ix):
        itemA = self.items[ix]
        person = fname(parent(itemA))
        same_person = randint(2)
        if same_person:
            itemB = choose(Glob(f'{self.folder}/{person}/*', \
                                silent=True))
        else:
            while True:
                itemB = choose(self.items)
                if person != fname(parent(itemB)):
                    break
        imgA = read(itemA)
        imgB = read(itemB)
        if self.transform:
            imgA = self.transform(imgA)
            imgB = self.transform(imgB)
        return imgA, imgB, np.array([1-same_person])

在前面的代码中,我们获取了两幅图像— imgAimgB,如果是同一个人,则返回第三个输出 0,如果不是,则返回 1。

  • 定义__len__方法:
    def __len__(self):
        return len(self.items)
  1. 定义要执行的转换,并为培训和验证数据准备数据集和数据加载器:
from torchvision import transforms

trn_tfms = transforms.Compose([
            transforms.ToPILImage(),
            transforms.RandomHorizontalFlip(),
            transforms.RandomAffine(5, (0.01,0.2), \
                                    scale=(0.9,1.1)),
            transforms.Resize((100,100)),
            transforms.ToTensor(),
            transforms.Normalize((0.5), (0.5))
        ])
val_tfms = transforms.Compose([
            transforms.ToPILImage(),
            transforms.Resize((100,100)),
            transforms.ToTensor(),
            transforms.Normalize((0.5), (0.5))
        ])

trn_ds=SiameseNetworkDataset(folder="./data/faces/training/" \
                             , transform=trn_tfms)
val_ds=SiameseNetworkDataset(folder="./data/faces/testing/", \
                               transform=val_tfms)

trn_dl = DataLoader(trn_ds, shuffle=True, batch_size=64)
val_dl = DataLoader(val_ds, shuffle=False, batch_size=64)
  1. 定义神经网络架构:
  • 定义卷积块(convBlock):
def convBlock(ni, no):
    return nn.Sequential(
        nn.Dropout(0.2),
        nn.Conv2d(ni, no, kernel_size=3, padding=1, \
                  padding_mode='reflect'),
        nn.ReLU(inplace=True),
        nn.BatchNorm2d(no),
    )
  • 定义在给定输入的情况下返回五维编码的SiameseNetwork架构:
class SiameseNetwork(nn.Module):
    def __init__(self):
        super(SiameseNetwork, self).__init__()
        self.features = nn.Sequential(
            convBlock(1,4),
            convBlock(4,8),
            convBlock(8,8),
            nn.Flatten(),
            nn.Linear(8*100*100, 500), nn.ReLU(inplace=True),
            nn.Linear(500, 500), nn.ReLU(inplace=True),
            nn.Linear(500, 5)
        )

    def forward(self, input1, input2):
        output1 = self.features(input1)
        output2 = self.features(input2)
        return output1, output2
  1. 定义ContrastiveLoss功能:
class ContrastiveLoss(torch.nn.Module):
    """
    Contrastive loss function.
Based on: http://yann.lecun.com/exdb/publis/pdf/hadsell-chopra-lecun-06.pdf
    """

    def __init__(self, margin=2.0):
        super(ContrastiveLoss, self).__init__()
        self.margin = margin

请注意,这里的边距类似于 SVM 的边距,我们希望属于两个不同类的数据点之间的边距尽可能高。

  • 定义forward方法:
    def forward(self, output1, output2, label):
        euclidean_distance = F.pairwise_distance(output1, \
                                output2, keepdim = True)
        loss_contrastive = torch.mean((1-label) * \
                        torch.pow(euclidean_distance, 2) + \
                        (label) * torch.pow(torch.clamp( \
                        self.margin - euclidean_distance, \
                                            min=0.0), 2))
        acc = ((euclidean_distance>0.6)==label).float().mean()
        return loss_contrastive, acc

在前面的代码中,我们获取两个不同图像的编码—output1output2,并计算它们的eucledian_distance

接下来,我们计算对比损失–loss_contrastive,这对于相同标签的图像之间具有高欧几里德距离以及对于不同标签的图像具有低欧几里德距离和self.margin是不利的。

  1. 定义函数以对一批数据进行训练并验证:
def train_batch(model, data, optimizer, criterion):
    imgsA, imgsB, labels = [t.to(device) for t in data]
    optimizer.zero_grad()
    codesA, codesB = model(imgsA, imgsB)
    loss, acc = criterion(codesA, codesB, labels)
    loss.backward()
    optimizer.step()
    return loss.item(), acc.item()

@torch.no_grad()
def validate_batch(model, data, criterion):
    imgsA, imgsB, labels = [t.to(device) for t in data]
    codesA, codesB = model(imgsA, imgsB)
    loss, acc = criterion(codesA, codesB, labels)
    return loss.item(), acc.item()
  1. 定义模型、损失函数和优化器:
model = SiameseNetwork().to(device)
criterion = ContrastiveLoss()
optimizer = optim.Adam(model.parameters(),lr = 0.001)
  1. 在不断增加的时期内训练模型:
n_epochs = 200
log = Report(n_epochs)
for epoch in range(n_epochs):
    N = len(trn_dl)
    for i, data in enumerate(trn_dl):
        loss, acc = train_batch(model, data, optimizer, \
                                criterion)
        log.record(epoch+(1+i)/N,trn_loss=loss,trn_acc=acc, \
                   end='\r')
    N = len(val_dl)
    for i, data in enumerate(val_dl):
        loss, acc = validate_batch(model, data, \
                                   criterion)
        log.record(epoch+(1+i)/N,val_loss=loss,val_acc=acc, \
                   end='\r')
    if (epoch+1)%20==0: log.report_avgs(epoch+1)
  • 绘制在增加的时期内训练和验证损失准确度的变化记录:
log.plot_epochs(['trn_loss','val_loss'])
log.plot_epochs(['trn_acc','val_acc'])

上述代码会产生以下输出:

  1. 在新图像上测试模型。请注意,该模型从未见过这些新图像。测试时,我们将获取一个随机测试图像,并将其与测试数据中的其他图像进行比较:
model.eval()
val_dl = DataLoader(val_ds,num_workers=6,batch_size=1, \
                    shuffle=True)
dataiter = iter(val_dl)
x0, _, _ = next(dataiter)

for i in range(2):
    _, x1, label2 = next(dataiter)
    concatenated = torch.cat((x0*0.5+0.5, x1*0.5+0.5),0)
    output1,output2 = model(x0.cuda(),x1.cuda())
    euclidean_distance = F.pairwise_distance(output1, output2)
    output = 'Same Face' if euclidean_distance.item() < 0.6 \
                        else 'Different'
    show(torchvision.utils.make_grid(concatenated), \
         title='Dissimilarity: {:.2f}\n{}'. \
         format(euclidean_distance.item(), output))
    plt.show()

上述操作会产生以下输出:

从前面的描述中,我们可以看到,即使我们只有一个类的几个图像,我们也可以识别图像中的人。

在现实场景中(您可能会使用暹罗网络进行出勤跟踪),在我们训练模型或在新图像上进行推断之前,从完整图像中裁剪人脸是一个好主意。

现在我们已经了解了暹罗网络的工作原理,在接下来的章节中,我们将学习其他基于度量的技术——原型网络和关系网络。

原型网络的工作细节

原型是某一类的代表。想象一个场景,我们给你每类 10 张图片,有 5 个这样的类。原型网络通过对属于一个类别的每个图像的嵌入进行平均,得出每个类别的代表性嵌入(原型)。

这里,让我们来理解一个实际的场景:

假设您有 5 个不同的图像类别,每个类别的数据集包含 10 个图像。此外,我们在培训中每班给你 5 张图片,并在另外 5 张图片上测试你的网络的准确性。我们将用每个类中的一个图像和随机选择的测试图像作为查询来构建我们的网络。我们的任务是识别与查询图像(测试图像)具有最高相似性的已知图像(训练图像)的类别。

对于面部识别,原型网络的工作细节如下:

  • 随机选择 N 个不同的人进行训练。
  • 选择与每个人相对应的 k 个样本作为可用于训练的数据点-这是我们的支持集(要比较的图像)。
  • 选择与每个人相对应的 q 个样本作为要测试的数据点–这是我们的查询集(要比较的图像):

现在,我们已经选择了 N 个 [c] 类,N 个 [s] 图像在支持集中,N 个 [q] 图像在查询集中:

  • 当通过 CNN 网络时,获取每个数据点在支持集(训练图像)和查询集(测试图像)内的嵌入,其中我们期望 CNN 网络识别与查询图像具有最高相似性的训练图像的索引。
  • 训练完网络后,计算对应于支持集(训练图像)嵌入的原型:
    • 原型是属于同一类的所有图像的平均嵌入:

在前面的示例插图中,有三个类,每个圆圈代表属于该类的图像的嵌入。每个星形(原型)是图像中所有图像(圆形)的平均嵌入:

  • 计算查询嵌入和原型嵌入之间的欧几里德距离:
  • 如果有 5 个查询图像和 10 个类别,我们将有 50 个欧几里德距离。
  • 在之前获得的欧几里德距离的基础上执行 softmax,以识别对应于不同支持类别的概率。
  • 训练模型以最小化将查询图像分配给正确类别的损失值。此外,在数据集上循环时,在下一次迭代中随机选择一组新的人。

在迭代结束时,模型将学会识别查询图像所属的类别——给定一些支持集图像和查询图像。

关系网络的工作细节

关系网络非常类似于暹罗网络,除了我们优化的度量不是嵌入之间的 L1 距离,而是关系分数。让我们使用下图来了解关系网络的工作细节:

在上图中,左边的图片是五个类的支持集,底部的狗图像是查询图像:

  • 通过嵌入模块传递支持和查询图像,该模块为输入图像提供嵌入。
  • 将支持图像的特征图与查询图像的特征图连接起来。
  • 通过 CNN 模块传递连接的要素以预测关系得分。

具有最高关系分数的类别是查询图像的预测类别。

至此,我们已经理解了少样本学习算法的不同工作方式。我们将给定的查询图像与图像的支持集合进行比较,以得出支持集合中存在的与查询图像具有最高相似性的对象类别。

摘要

在这一章中,我们已经学习了如何利用词向量来提出一种方法来解决我们想要预测的类在训练期间不存在的情况。此外,我们学习了暹罗网络,它学习两幅图像之间的距离函数,以识别相似人的图像。最后,我们学习了原型网络和关系网络,以及它们如何学习执行少镜头图像分类。

在下一章中,我们将学习如何将计算机视觉和基于自然语言处理的技术结合起来,提出解决注释图像、检测图像中的对象和手写转录的方法。

问题

  1. 预训练的词向量是如何获得的?
  2. 零拍学习中我们如何从一个图像特征嵌入映射到一个单词嵌入?
  3. 暹罗网为什么这么叫?
  4. 暹罗网是怎么得出两幅图像的相似度的?

十五、组合计算机视觉和 NLP 技术

在前一章中,我们学习了在数据点数量最少的情况下如何利用新颖的架构。在这一章中,我们将切换话题,了解如何将卷积神经网络 ( CNN )与循环神经网络 ( RNNs )的广泛家族中的算法结合使用,这些算法在自然语言处理 ( NLP )中被大量使用,以开发利用计算机视觉和 NLP 的解决方案。

为了理解 CNN 和 RNNs 的结合,我们将首先了解 RNNs 如何工作及其变体——主要是长短期记忆(LSTM)——以理解它们如何应用于预测给定图像作为输入的注释。在此之后,我们将了解另一个重要的损失函数,称为连接主义者时间分类 ( CTC )损失函数,然后将其与 CNN 和 RNN 一起应用来执行手写图像的转录。最后,我们将了解并利用转换器,使用转换器检测 ( DETR )架构来执行目标检测。

本章结束时,您将了解到以下主题:

  • RNNs 简介
  • 介绍 LSTM 建筑
  • 实现图像字幕
  • 抄写手写图像
  • 使用 DETR 的目标检测

RNNs 简介

一个 RNN 可以有多种架构。设计 RNN 的一些可能方法如下:

在上图中,底部的框是输入层,后面是隐藏层(中间的框),然后顶部的框是输出层。一对一架构是典型的神经网络,在输入层和输出层之间有一个隐藏层。不同架构的示例如下:

  • 一对多:输入是图像,输出是图像的标题。
  • 多对一:输入是电影评论(输入多个单词),输出是与评论相关的情感。
  • 多对多:机器翻译一种语言的句子到另一种语言的句子。

需要 RNN 建筑背后的想法

当我们想要预测给定一系列事件的下一个事件时,rnn 是有用的。一个例子就是预测这个单词后面的单词:这是一个 __。

假设在现实中,句子是这是一个例子

传统的文本挖掘技术将通过以下方式解决这个问题:

  1. 对每个单词进行编码,同时为潜在的新单词提供额外的索引:

这个 : {1,0,0,0}

: {0,1,0,0}

一个 : {0,0,1,0}

  1. 编码短语这是一个:

这是一个 : {1,1,1,0}

  1. 创建训练数据集:

输入- > {1,1,1,0}

输出- > {0,0,0,1}

  1. 使用给定的输入和输出组合构建模型:

该模型的一个主要缺点是,无论输入句子是以这是一个安是这个还是这安是的形式,输入表示都不会改变。

但是,直观上,我们知道前面的每一个句子都是不同的,在数学上不能用相同的结构来表示。这需要不同的架构,如下所示:

在前面的架构中,句子中的每个单词在输入框中输入一个单独的框。这确保了我们保留了输入句子的结构;比如这个进入第一个盒子,进入第二个盒子,进入第三个盒子。顶部的输出框将是输出–即示例

了解了对 RNN 架构的需求后,在下一节中,让我们学习如何解释 rnn 的输出。

探索 RNN 的结构

你可以把 RNN 想象成一种保存记忆的机制——隐藏层包含了记忆。RNN 的展开版本如下:

右边的网络是左边网络的展开版本。右侧的网络在每个时间步长中获取一个输入,并在每个时间步长提取输出。

请注意,在预测第三个时间步长的输出时,我们通过隐藏层合并了前两个时间步长的值,隐藏层连接了跨时间步长的值。

让我们来看看前面的图表:

  • u 权重表示连接输入层和隐藏层的权重。
  • w 权重表示隐藏层到隐藏层的连接。
  • v 权重表示隐藏层到输出层的连接。

给定时间步长中的输出取决于当前时间步长中的输入和前一时间步长中的隐藏层值。通过引入前一时间步的隐藏层作为输入,以及当前时间步的输入,我们从前一时间步获得信息。这样,我们就创建了一个支持内存存储的连接管道。

为什么要存储内存?

需要存储记忆,因为在前面的例子中,或者甚至在一般的文本生成中,下一个单词不仅依赖于前面的单词,而且依赖于要预测的单词前面的单词的上下文。

鉴于我们正在看前面的单词,应该有一种方法将它们保存在内存中,这样我们就可以更准确地预测下一个单词。

我们还应该把记忆整理好;通常,在预测下一个单词时,最近的单词比距离要预测的单词更远的单词更有用。

考虑多个时间步长进行预测的传统 RNN 可以如下所示:

请注意,随着时间步长的增加,出现在更早时间步长(时间步长 1)的输入对更晚时间步长(时间步长 7)的输出的影响会更小。这里可以看到一个这样的例子(暂且忽略偏置项,假设在时间步长 1 输入的隐藏层是0,我们预测时间步长 5 的隐藏层的值——h[5]):

可以看到,随着时间步长的增加,隐藏层的值(h[5]??)高度依赖于X[1]ifU>1;但是如果 U < 1,那么对 X [1] 的依赖性就小很多。**

U 矩阵的依赖还会导致隐藏层( h [5] )的值非常小,因此当 U 的值非常小时会导致渐变消失,当 U 的值非常高时会导致渐变爆炸。

当存在对预测下一个单词的长期依赖性时,前面的现象导致了一个问题。为了解决这个问题,我们将使用 LSTM 架构。

介绍 LSTM 建筑

在上一节中,我们了解了传统的 RNN 如何面临消失或爆炸的梯度问题,导致它无法适应长期记忆。在本节中,我们将了解如何利用 LSTM 来解决这个问题。

为了用一个例子进一步理解这个场景,让我们考虑下面的句子:

我来自英国。我讲 __ 。

在上一句中,直观地说,我们知道大多数英国人说英语。要填充的空白值(英文)是从这个人来自英国这一事实中获得的。虽然在这种情况下,我们的信号词(英格兰)更接近空白值,但在现实情况下,我们可能会发现信号词远离空白(我们试图预测的词)。当信号字和空白值之间的距离很大时,通过传统的 RNNs 的预测可能由于消失或爆炸梯度现象而出错。LSTM 解决了这种情况——我们将在下一节学习。

LSTM 的工作细节

标准的 LSTM 架构如下:

在上图中,您可以看到,虽然输入 X 和输出 h 与我们在探索 RNN 部分的结构中看到的相似,但是在 LSTM,输入和输出之间发生的计算是不同的。让我们来理解输入和输出之间发生的各种激活:

在上图中,我们可以观察到以下情况:

  • Xh 代表时间步 t 的输入和输出。

  • C 代表电池状态。这可能有助于储存长期记忆。

  • C [t-1]

  • h[t-1 代表前一时间步的输出。]

  • f[t]代表帮助遗忘某些信息的激活。

  • i [t] 代表输入结合上一时间步的输出所对应的变换( h [t-1] )。

需要被遗忘的内容f[t]如下获得:

注意W[xf]和W[HF]分别代表与输入和前一个隐藏层相关联的权重。**

通过将来自前一时间步的单元状态C[t-1]乘以有助于遗忘的输入内容f[t]来更新单元状态。**

更新后的单元状态如下:

注意,在前面的步骤中,我们正在执行C[t-1]和 f [t] 之间的元素到元素乘法,以获得修改后的单元格状态, C [t]

为了理解前面的操作有什么帮助,我们来看一下输入句子:我来自英国。我讲 __

一旦我们用英语填补空白,我们就不再需要这个人来自英国的信息,因此应该从记忆中抹去。细胞状态和遗忘门的结合有助于实现这一点。

在下一步中,我们将包括从当前时间步长到单元状态以及输出的附加信息。通过输入激活(基于当前时间步长的输入和先前时间步长的输出)和调制门gt 更新修改后的单元状态(在忘记要忘记的内容之后)。

输入激活的计算如下:

注意 W [xi]W [hi] 分别代表与输入和前一个隐藏层相关联的权重。

修改门的激活计算如下:

注意 W [xg]W [hg] 分别代表与输入和前一个隐藏层相关联的权重。

经修改的门可帮助隔离待更新的单元状态值而非其余的单元状态值,以及识别待完成的更新的量值。

修改后的单元状态C[t]将传递到下一个时间步,现在如下:

最后,我们将激活的更新单元状态( tanh(C [t] ) )乘以激活的输出值 O [t] ,以获得最终输出 h [t] ,在时间步长 t :

这样,我们可以利用 LSTM 中存在的各种门来选择性地记忆过长的时间步长。

在 PyTorch 实现 LSTM

在一个典型的文本相关练习中,每个单词都是 LSTM 的一个输入——每个时间步长一个单词。为了让 LSTM 工作,我们执行以下两个步骤:

  1. 将每个单词转换成嵌入向量。
  2. 将时间步长中对应于相关单词的嵌入向量作为输入传递给 LSTM。

我们先来理解一下为什么要把一个输入词转换成嵌入向量的原因。如果我们的词汇表中有 100K 个唯一的单词,我们必须在将它们传递到网络之前对它们进行一次性编码。然而,为每个单词创建一个 one-hot-encoded 向量会失去该单词的语义含义——例如,单词 likeenjoy 是相似的,应该具有相似的向量。为了解决这种情况,我们利用单词嵌入,这有助于自动学习单词向量表示(因为它们是网络的一部分)。单词嵌入按如下方式提取:

embed = nn.Embedding(vocab_size, embed_size)

在前面的代码中,nn.Embedding方法将vocab_size个维度作为输入,并返回输出的embed_size个维度。这样,如果词汇大小是 100K,嵌入大小是 128,则 100K 个单词中的每一个都被表示为 128 维向量。进行这种练习的一个好处是,一般来说,相似的单词将具有相似的嵌入。

接下来,我们通过 LSTM 传递单词 embeddings。使用nn.LSTM方法在 PyTorch 中实现 LSTM,如下所示:

hidden_state, cell_state = nn.LSTM(embed_size, \
                                   hidden_size, num_layers)

在前面的代码中,embed_size表示每个时间步长对应的嵌入大小,hidden_size对应隐藏层输出的维度,num_layers表示 LSTM 叠加的次数。

此外,nn.LSTM方法返回隐藏状态值和单元格状态值。

现在我们已经了解了 LSTM 和 RNNs 的工作细节,让我们了解在下一节中预测给定图像的字幕时,如何结合 CNN 利用它们。

实现图像字幕

图像字幕是指在给定图像的情况下生成字幕。在这一节中,我们将首先学习构建一个 LSTM 时要做的预处理,该库可以生成给定图像的文本字幕,然后我们将学习如何结合 CNN 和 LSTM 来执行图像字幕。在我们了解如何构建一个生成标题的系统之前,让我们先来了解一个输入和输出的例子:

在前面的例子中,图像是输入,预期的输出是图像的标题–在这个图像中,我可以看到一些蜡烛。背景为黑色

我们将采取以下策略来解决这个问题:

  1. 预处理输出(基本事实注释/标题),以便每个唯一的单词由一个唯一的 ID 表示。
  2. 假设输出句子可以是任意长度,让我们指定一个开始和结束标记,以便模型知道何时停止生成预测。此外,确保所有输入的句子都被填充,以便所有输入都具有相同的长度。
  3. 将输入影像传递给预先训练好的模型,如 VGG16、ResNet-18 等,以在拼合图层之前提取要素。
  4. 使用图像的特征图和上一步中获得的文本(如果它是我们要预测的第一个单词,则是开始标记)来预测一个单词。
  5. 重复前面的步骤,直到我们获得结束令牌。

既然我们已经在较高层次上理解了要做什么,那么让我们在下一节中用代码实现前面的步骤。

代码中的图像字幕

让我们用代码执行上一节中设计的策略:

The following code is available as Image_captioning.ipynb in the Chapter15 folder of this book's GitHub repository - tinyurl.com/mcvp-packt The code contains URLs to download data from and is moderately lengthy. We strongly recommend you to execute the notebook in GitHub to reproduce results while you understand the steps to perform and explanation of various code components from text.

  1. 从打开的图像数据集中获取数据集,该数据集包括训练图像、其注释和验证数据集:
  • 导入相关的包,定义设备,并获取包含要下载的图像信息的 JSON 文件:
!pip install -qU openimages torch_snippets urllib3
!wget -O open_images_train_captions.jsonl -q https://storage.googleapis.com/localized-narratives/annotations/open_images_train_v6_captions.jsonl
from torch_snippets import *
import json
device = 'cuda' if torch.cuda.is_available() else 'cpu'
  • 遍历 JSON 文件的内容,获取前 100,000 张图像的信息:
with open('open_images_train_captions.jsonl', 'r') as \
                                            json_file:
    json_list = json_file.read().split('\n')
np.random.shuffle(json_list)
data = []
N = 100000
for ix, json_str in Tqdm(enumerate(json_list), N):
    if ix == N: break
    try:
        result = json.loads(json_str)
        x = pd.DataFrame.from_dict(result, orient='index').T
        data.append(x)
    except:
        pass

从 JSON 文件获得的信息示例如下:

从前面的示例中,我们可以看到captionimage_id是我们将在后续步骤中使用的关键信息。image_id将用于获取相应的图像,而caption将用于关联与从给定图像 ID 获得的图像相对应的输出。

  • 将数据帧(data)分成训练和验证数据集:
np.random.seed(10)
data = pd.concat(data)
data['train'] = np.random.choice([True,False], \
                                 size=len(data),p=[0.95,0.05])
data.to_csv('data.csv', index=False)
  • 下载与从 JSON 文件中获取的图像 id 相对应的图像:
from openimages.download import _download_images_by_id
!mkdir -p train-images val-images
subset_imageIds = data[data['train']].image_id.tolist()
_download_images_by_id(subset_imageIds, 'train', \
                       './train-img/')

subset_imageIds = data[~data['train']].image_id.tolist()
_download_images_by_id(subset_imageIds, 'train', \
                       './val-img/')
  1. 为数据帧中所有标题中出现的所有独特单词创建一个词汇表:
  • 词汇对象可以将所有标题中的每个单词映射到一个唯一的整数,反之亦然。我们将利用torchtext库的Field.build_vocab功能,该功能贯穿所有单词(注释/标题)并将它们累积到两个计数器stoiitos中,这两个计数器分别是“string to int”(一个字典)和“int to string”(一个列表):
from torchtext.data import Field
from pycocotools.coco import COCO
from collections import defaultdict

captions = Field(sequential=False, init_token='<start>', \
                 eos_token='<end>')
all_captions = data[data['train']]['caption'].tolist()
all_tokens = [[w.lower() for w in c.split()] \
              for c in all_captions]
all_tokens = [w for sublist in all_tokens \
              for w in sublist]
captions.build_vocab(all_tokens)

在前面的代码中,captionsField是用于在 PyTorch 中构建更复杂的 NLP 数据集的专用对象。我们不能像处理图像一样直接处理文本,因为字符串与张量是不兼容的。因此,我们需要跟踪所有唯一出现的单词(也称为标记),这将有助于每个单词与唯一相关整数的一对一映射。例如,如果输入标题是坐在垫子上的猫,基于单词到整数的映射,该序列将被转换成,比如说,[5 23 24 4 29],其中唯一地与整数 5 相关联。这种映射通常被称为词汇表,可能看起来像{'<pad>': 0, '<unk'>: 1, '<start>': 2, '<end>': 3, 'the': 4, 'cat': 5, ...., 'on': 24, 'sat': 23, ... }。前几个标记是为特殊功能保留的,比如填充、未知、句子的开始和句子的结束。

  • 我们只需要captions词汇组件,所以在下面的代码中,我们创建了一个虚拟的vocab对象,它是轻量级的,将有一个额外的<pad>令牌,这是在captions.vocab中所缺少的:
class Vocab: pass
vocab = Vocab()
captions.vocab.itos.insert(0, '<pad>')
vocab.itos = captions.vocab.itos

vocab.stoi = defaultdict(lambda: \
                         captions.vocab.itos.index('<unk>'))
vocab.stoi['<pad>'] = 0
for s,i in captions.vocab.stoi.items():
    vocab.stoi[s] = i+1

注意vocab.stoi被定义为具有默认功能的defaultdict。当一个键不存在时,Python 使用这个特殊的字典返回一个默认值。在我们的例子中,当我们试图调用vocab.stoi[<new-key/word>]时,我们将返回一个'<unk>'令牌。这在验证阶段非常方便,因为在验证阶段可能会有一些标记不在训练数据中。

  1. 定义数据集类-CaptioningDataset:
  • 定义__init__方法,其中我们提供之前获得的数据帧(df)、包含图像的文件夹(root)、vocab,以及图像转换管道(self.transform):
from torchvision import transforms
class CaptioningData(Dataset):
    def __init__(self, root, df, vocab):
        self.df = df.reset_index(drop=True)
        self.root = root
        self.vocab = vocab
        self.transform = transforms.Compose([ 
            transforms.Resize(224),
            transforms.RandomCrop(224),
            transforms.RandomHorizontalFlip(), 
            transforms.ToTensor(), 
            transforms.Normalize((0.485, 0.456, 0.406), 
                                 (0.229, 0.224, 0.225))]
        )
  • 定义__getitem__方法,获取图像及其相应的标题。此外,使用在上一步中构建的vocab将目标转换成相应的单词 id 列表:
    def __getitem__(self, index):
        """Returns one data pair (image and caption)."""
        row = self.df.iloc[index].squeeze()
        id = row.image_id
        image_path = f'{self.root}/{id}.jpg'
        image = Image.open(os.path.join(image_path))\
                                  .convert('RGB')

        caption = row.caption
        tokens = str(caption).lower().split()
        target = []
        target.append(vocab.stoi['<start>'])
        target.extend([vocab.stoi[token] for token in tokens])
        target.append(vocab.stoi['<end>'])
        target = torch.Tensor(target).long()
        return image, target, caption
  • 定义__choose__方法:
    def choose(self):
        return self[np.random.randint(len(self))]
  • 定义__len__方法:
    def __len__(self):
        return len(self.df)
  • 定义collate_fn方法来处理一批数据:
    def collate_fn(self, data):
        data.sort(key=lambda x: len(x[1]), reverse=True)
        images, targets, captions = zip(*data)
        images = torch.stack([self.transform(image) \
                              for image in images], 0)
        lengths = [len(tar) for tar in targets]
        _targets = torch.zeros(len(captions), \
                               max(lengths)).long()
        for i, tar in enumerate(targets):
            end = lengths[i]
            _targets[i, :end] = tar[:end] 
        return images.to(device), _targets.to(device), \
    torch.tensor(lengths).long().to(device)

collate_fn方法中,我们计算一批标题的最大长度(具有最大字数的标题),并填充该批中的其余标题,使其具有相同的长度。

  1. 定义培训和验证数据集以及数据加载器:
trn_ds = CaptioningData('train-images', data[data['train']], \
                        vocab)
val_ds = CaptioningData('val-images', data[~data['train']], \
                        vocab)

image, target, caption = trn_ds.choose()
show(image, title=caption, sz=5); print(target)

样本图像和相应的标题和标记的单词索引如下:

  1. 为数据集创建数据加载器:
trn_dl = DataLoader(trn_ds, 32, collate_fn=trn_ds.collate_fn)
val_dl = DataLoader(val_ds, 32, collate_fn=val_ds.collate_fn)
inspect(*next(iter(trn_dl)), names='images,targets,lengths')

样本批次将包含以下实体:

  1. 定义网络类别:
  • 定义编码器架构-EncoderCNN:
from torch.nn.utils.rnn import pack_padded_sequence
from torchvision import models
class EncoderCNN(nn.Module):
    def __init__(self, embed_size):
        """Load the pretrained ResNet-152 and replace 
        top fc layer."""
        super(EncoderCNN, self).__init__()
        resnet = models.resnet152(pretrained=True)
        # delete the last fc layer.
        modules = list(resnet.children())[:-1] 
        self.resnet = nn.Sequential(*modules)
        self.linear = nn.Linear(resnet.fc.in_features, \
                                embed_size)
        self.bn = nn.BatchNorm1d(embed_size, \
                                 momentum=0.01)

    def forward(self, images):
        """Extract feature vectors from input images."""
        with torch.no_grad():
            features = self.resnet(images)
        features = features.reshape(features.size(0), -1)
        features = self.bn(self.linear(features))
        return features

在前面的代码中,我们获取预训练的 ResNet-152 模型,删除最后的fc层,将其连接到大小为embed_sizeLinear层,然后通过批处理规范化(bn)传递它。

  • 获取encoder类的摘要:
encoder = EncoderCNN(256).to(device)
!pip install torch_summary
from torchsummary import summary
print(summary(encoder,torch.zeros(32,3,224,224).to(device)))

上述代码给出了以下输出:

  • 定义解码器架构-DecoderRNN:
class DecoderRNN(nn.Module):
    def __init__(self, embed_size, hidden_size, vocab_size, \
                 num_layers, max_seq_length=80):
        """Set the hyper-parameters and build the layers."""
        super(DecoderRNN, self).__init__()
        self.embed = nn.Embedding(vocab_size, embed_size)
        self.lstm = nn.LSTM(embed_size, hidden_size, \
                            num_layers, batch_first=True)
        self.linear = nn.Linear(hidden_size, vocab_size)
        self.max_seq_length = max_seq_length

    def forward(self, features, captions, lengths):
        """Decode image feature vectors and 
        generates captions."""
        embeddings = self.embed(captions)
        embeddings = torch.cat((features.unsqueeze(1), \
                                embeddings), 1)
        packed = pack_padded_sequence(embeddings, \
                            lengths.cpu(), batch_first=True) 
        outputs, _ = self.lstm(packed)
        outputs = self.linear(outputs[0])
        return outputs

在前面的解码器中,让我们了解我们正在初始化什么:

  • self.embed:一个vocab x embed_size矩阵,为每个单词创建并学习一个唯一的嵌入。
  • self.lstmCNNEncoder的输出和前一时间步的单词输出嵌入作为输入,并返回每个时间步的隐藏状态。
  • self.linear将每个隐藏状态转换成一个V维向量,我们将使用 softmax 来获取时间步长的可能单词。

forward方法中,我们看到以下内容:

  1. 使用self.embed将标题(作为整数发送)转换成嵌入内容。
  2. 来自EncoderCNNfeatures被连接到embeddings。如果每个字幕的时间步长数(在下面的例子中为 L )是 80,那么在拼接之后,时间步长数将是 81。请参见以下示例,了解每个时间步中的供给和预测内容:

  1. 使用pack_padded_sequences,级联的嵌入被打包到一个数据结构中,该数据结构通过不在填充出现的时间步长展开而使 RNN 计算更有效。直观的解释见下图:
  • 在下图中,我们有三个句子,它们用相应的单词索引进行编码。字索引0表示填充索引。打包后,批次大小为最后一个索引中的1,因为只有一个句子中的最后一个索引不是填充索引:

  • 打包的填充现在被传递给 LSTM,如下所示:

代码中上一个示例的对应行是outputs, _ = self.lstm(packed)。最后,LSTM 的输出通过线性层发送,因此维数从 512 变为 vocab 大小。

我们还将向 RNN 添加一个predict方法,该方法接受来自EncoderCNN的特性并返回每个特性的预期令牌。我们将在训练后使用它来获取图像的标题:

    def predict(self, features, states=None):
        """Generate captions for given image 
        features using greedy search."""
        sampled_ids = []
        inputs = features.unsqueeze(1)
        for i in range(self.max_seq_length):
            hiddens, states = self.lstm(inputs, states) 
            # hiddens: (batch_size, 1, hidden_size)
            outputs = self.linear(hiddens.squeeze(1)) 
            # outputs: (batch_size, vocab_size)
            _, predicted = outputs.max(1) 
            # predicted: (batch_size)
            sampled_ids.append(predicted)
            inputs = self.embed(predicted) 
            # inputs: (batch_size, embed_size)
            inputs = inputs.unsqueeze(1) 
            # inputs: (batch_size, 1, embed_size)

        sampled_ids = torch.stack(sampled_ids, 1) 
        # sampled_ids: (batch_size, max_seq_length)
        # convert predicted tokens to strings
        sentences = []
        for sampled_id in sampled_ids:
            sampled_id = sampled_id.cpu().numpy()
            sampled_caption = []
            for word_id in sampled_id:
                word = vocab.itos[word_id]
                sampled_caption.append(word)
                if word == '<end>':
                    break
            sentence = ' '.join(sampled_caption)
            sentences.append(sentence)
        return sentences
  1. 定义对一批数据进行训练的函数:
def train_batch(data, encoder, decoder, optimizer, criterion):
    encoder.train()
    decoder.train()
    images, captions, lengths = data
    images = images.to(device)
    captions = captions.to(device)
    targets = pack_padded_sequence(captions, lengths.cpu(), \
                                   batch_first=True)[0]
    features = encoder(images)
    outputs = decoder(features, captions, lengths)
    loss = criterion(outputs, targets)
    decoder.zero_grad()
    encoder.zero_grad()
    loss.backward()
    optimizer.step()
    return loss

请注意,我们由此创建了一个名为targets的张量,它将项目打包到一个向量中。正如您在前面的图表中所知道的,pack_padded_sequence有助于以这样一种方式打包预测,即更容易在输出中调用带有打包的target值的nn.CrossEntropyLoss

  1. 定义要对一批数据进行验证的函数:
@torch.no_grad()
def validate_batch(data, encoder, decoder, criterion):
    encoder.eval()
    decoder.eval()
    images, captions, lengths = data
    images = images.to(device)
    captions = captions.to(device)
    targets = pack_padded_sequence(captions, lengths.cpu(), \
                                   batch_first=True)[0]
    features = encoder(images)
    outputs = decoder(features, captions, lengths)
    loss = criterion(outputs, targets)
    return loss
  1. 定义模型对象和损失函数,以及优化器:
encoder = EncoderCNN(256).to(device)
decoder = DecoderRNN(256, 512, len(vocab.itos), 1).to(device)
criterion = nn.CrossEntropyLoss()
params = list(decoder.parameters()) + \
         list(encoder.linear.parameters()) + \
         list(encoder.bn.parameters())
optimizer = torch.optim.AdamW(params, lr=1e-3)
n_epochs = 10
log = Report(n_epochs)
  1. 在不断增加的时期内训练模型:
for epoch in range(n_epochs):
    if epoch == 5: optimizer = torch.optim.AdamW(params, \
                                                 lr=1e-4)
    N = len(trn_dl)
    for i, data in enumerate(trn_dl):
        trn_loss = train_batch(data, encoder, decoder, \
                               optimizer, criterion)
        pos = epoch + (1+i)/N
        log.record(pos=pos, trn_loss=trn_loss, end='\r')

    N = len(val_dl)
    for i, data in enumerate(val_dl):
        val_loss = validate_batch(data, encoder, decoder, \
                                  criterion)
        pos = epoch + (1+i)/N
        log.record(pos=pos, val_loss=val_loss, end='\r')
    log.report_avgs(epoch+1)

log.plot_epochs(log=True)

前面的代码生成了在增加的时期内训练和验证损失的变化的输出:

  1. 定义一个在给定图像的情况下生成预测的函数:
def load_image(image_path, transform=None):
    image = Image.open(image_path).convert('RGB')
    image = image.resize([224, 224], Image.LANCZOS)
    if transform is not None:
        tfm_image = transform(image)[None]
    return image, tfm_image

def load_image_and_predict(image_path):
    transform = transforms.Compose([
                    transforms.ToTensor(), 
                    transforms.Normalize(\
                        (0.485, 0.456, 0.406), 
                        (0.229, 0.224, 0.225))
                    ])
    org_image, tfm_image = load_image(image_path, transform)
    image_tensor = tfm_image.to(device)
    encoder.eval()
    decoder.eval()
    feature = encoder(image_tensor)
    sentence = decoder.predict(feature)[0]
    show(org_image, title=sentence)
    return sentence

files = Glob('val-images')
load_image_and_predict(choose(files))

前述生成给定图像的预测:

从前面的例子中,我们可以看到,给定一幅图像(在前面的例子中显示为标题),我们可以生成合理的标题。

在本节中,我们学习了如何利用 CNN 和 RNN 一起生成字幕。在下一节中,我们将了解如何使用 CNN、RNNs 和 CTC 损失函数来转录包含手写单词的图像。

抄写手写图像

在上一节中,我们学习了从输入图像中生成单词序列。在这一节中,我们将学习用图像作为输入来生成字符序列。此外,我们将了解 CTC 损失功能,这有助于抄录手写图像。

在我们了解 CTC 损失函数之前,让我们了解一下为什么我们在图像字幕部分看到的架构可能不适用于手写转录。在图像字幕中,图像中的内容和输出单词之间没有直接的关联,而在手写图像中,图像中出现的字符序列和输出序列之间有直接的关联。因此,我们将遵循与上一节中设计的不同的架构。

此外,假设一幅图像被分成 20 个部分(假设一幅图像中每个单词最多 20 个字符),其中每个部分对应一个字符。一个人的笔迹可能确保每个字符完全适合一个框,而另一个人的笔迹可能被混淆,使得每个框包含两个字符,而另一个人的笔迹中两个字符之间的间隔太大,以至于不可能将一个单词适合 20 个时间步长(部分)。这需要一种不同的方法来解决这个问题,即利用 CTC 丢失功能,我们将在下一节了解这一点。

CTC 丢失的工作细节

想象一个场景,我们正在转录一个包含单词 ab 的图像。无论我们选择以下三个图像中的哪一个,图像看起来都像下面的任何一个,并且输出总是 ab :

在下一步中,我们将前面的三个示例分成六个时间步长,如下所示(其中每个方框代表一个时间步长):

现在,我们将预测每个时间步中的输出字符——其中输出是词汇中出现单词的概率的软最大值。假设我们正在执行 softmax,假设通过我们的模型(我们将在后续部分中定义)运行图像后,每个时间步的输出字符如下所示(图像上方提供了每个单元的输出):

请注意,*-*表示在相应的时间步长中不存在任何东西。此外,注意字符 b 在两个不同的时间步长中重复出现。

在最后一步中,我们将压缩输出(一个字符序列),这是通过将我们的图像传递到模型中获得的,其方式是将连续的重复字符压缩成一个字符。

如果存在连续的相同字符预测,则压缩重复字符输出的前一步骤会产生如下最终输出:

-a-b-

在另一种情况下,当输出为 abb 时,预计最终输出压缩后在两个 b 字符之间有一个分隔符,示例如下:

-a-b-b-

现在我们已经了解了输入和输出值的概念,在下一节中,让我们了解如何计算 CTC 损失值。

计算 CTC 损失值

对于我们在上一节中解决的问题,让我们考虑以下场景——在下图的圆圈中提供了角色在给定时间步长中的概率(注意,在从 ???? 的每个时间步长中,概率加起来为 1):

然而,为了使计算简单,为了让我们理解 CTC 损失值是如何计算的,让我们假设图像只包含字符 a 而不包含单词 ab 的场景。此外,为了简化计算,我们假设只有三个时间步长:

如果每个时间步中的 softmax 是以下七种情况中的任何一种,我们可以获得 a 的地面真值:

| 每个时间步的输出 | Prob。t 中的人物[0] | Prob。中的人物 t[1] | Prob。中的人物 t[2] | 组合概率 | 最终概率 | | [构成动植物的古名或拉丁化的现代名] | Zero point eight | Zero point one | Zero point one | 0.8 x 0.1 x 0.1 | Zero point zero zero eight | | -aa | Zero point eight | Zero point nine | Zero point one | 0.8 x 0.9 x 0.1 | Zero point zero seven two | | 美国汽车协会 | Zero point two | Zero point nine | Zero point one | 0.2 x 0.9 x 0.1 | Zero point zero one eight | | -一个- | Zero point eight | Zero point nine | Zero point eight | 0.8 x 0.9 x 0.8 | Zero point five seven six | | -aa | Zero point eight | Zero point nine | Zero point one | 0.8 x 0.9 x 0.1 | Zero point zero seven two | | 表示“不” | Zero point two | Zero point one | Zero point eight | 0.2 x 0.1 x 0.8 | Zero point zero one six | | aa- | Zero point two | Zero point nine | Zero point eight | 0.2 x 0.9 x 0.8 | Zero point one four four | | | | | | 总概率 | Zero point nine zero six |

从前面的结果可以看出,获得地面真值的总体概率为 0.906。

0.094 的其余部分对应于结果未获得地面真相的概率。

让我们来计算所有可能的地真理之和对应的二元交叉熵损失。

CTC 损失是导致地面实况= -log(0.906) = 0.1 的组合的总体概率总和的负对数。

既然我们已经了解了 CTC 损失是如何计算的,那么让我们在下一节中实现这一知识,同时构建一个从图像进行手写转录的模型。

代码中的手写转录

我们将采用以下策略来编码一个可以转录手写单词图像内容的网络:

  1. 导入图像数据集及其相应的转录。
  2. 给每个字符一个索引。
  3. 通过卷积网络传递图像以获取对应于图像的特征图。
  4. 通过 RNN 传递特征地图。
  5. 获取每个时间步中的概率。
  6. 利用 CTC 损失功能压缩输出,并提供转录和相应的损失。
  7. 通过最小化 CTC 损失函数来优化网络的权重。

让我们用代码执行前面的策略:

The following code is available as Handwriting_transcription.ipynb in the Chapter15 folder of this book's GitHub repository - tinyurl.com/mcvp-packt.

  1. 下载并导入图像数据集:
!wget https://www.dropbox.com/s/l2ul3upj7dkv4ou/synthetic-data.zip
!unzip -qq synthetic-data.zip

在前面的代码中,我们已经下载了提供图像的数据集,并且图像的文件名包含对应于该图像的转录的基本事实。

下载的图像示例如下:

  1. 安装所需的软件包并导入它们:
!pip install torch_snippets torch_summary editdistance
  • 导入包:
from torch_snippets import *
from torchsummary import summary
import editdistance
  1. 指定图像的位置和从图像中提取地面实况的功能:
device = 'cuda' if torch.cuda.is_available() else 'cpu'
fname2label = lambda fname: stem(fname).split('@')[0]
images = Glob('synthetic-data')

请注意,我们正在创建fname2label函数,因为在文件名中的@符号之后可以获得图像的基本事实。文件名示例如下:

  1. 定义字符的词汇量(vocab)、批量大小(B)、RNN 的时间步长(T)、词汇量的长度(V)、图像的高度(H)和宽度(W):
vocab='QWERTYUIOPASDFGHJKLZXCVBNMqwertyuiopasdfghjklzxcvbnm'
B,T,V = 64, 32, len(vocab) 
H,W = 32, 128 
  1. 定义OCRDataset数据集类:
  • 定义__init__方法,通过循环vocab指定字符到字符 ID 的映射(charList)和反过来的映射(invCharList),以及时间步数(timesteps)和要提取的图像的文件路径(items)。我们在这里使用charListinvCharList而不是torchtextbuild vocab,因为词汇表更容易处理(包含更少数量的不同字符):
class OCRDataset(Dataset):
    def __init__(self, items, vocab=vocab, \
                 preprocess_shape=(H,W), timesteps=T):
        super().__init__()
        self.items = items
        self.charList = {ix+1:ch for ix,ch \
                         in enumerate(vocab)}
        self.charList.update({0: '`'})
        self.invCharList = {v:k for k,v in \
                            self.charList.items()}
        self.ts = timesteps
  • 定义__len____getitem__ 的方法:
    def __len__(self):
        return len(self.items)
    def sample(self):
        return self[randint(len(self))]
    def __getitem__(self, ix):
        item = self.items[ix]
        image = cv2.imread(item, 0)
        label = fname2label(item)
        return image, label

注意,在 __getitem__ 方法中,我们使用前面定义的fname2label读取图像并创建标签。 此外,我们正在定义一个sample方法,帮助我们从数据集中随机抽取图像。

  • 定义collate_fn方法,该方法获取一批图像,并将它们和它们的标签添加到不同的列表中。此外,它将对应于图像的地面实况的字符转换为其矢量格式(将每个字符转换为其对应的 ID ),最后,存储每个图像的标签长度和输入长度(总是时间步长的数量)。CTC 损耗函数在计算损耗值时利用标签长度和输入长度:
    def collate_fn(self, batch):
        images, labels, label_lengths = [], [], []
        label_vectors, input_lengths = [], []
        for image, label in batch:
            images.append(torch.Tensor(self.\
                                preprocess(image))[None,None])
            label_lengths.append(len(label))
            labels.append(label)
            label_vectors.append(self.str2vec(label))
            input_lengths.append(self.ts)
  • 将前面的每个列表转换成一个 Torch 张量对象,并返回imageslabelslabel_lengthslabel_vectorsinput_lengths:
        images = torch.cat(images).float().to(device)
        label_lengths = torch.Tensor(label_lengths)\
                             .long().to(device)
        label_vectors = torch.Tensor(label_vectors)\
                             .long().to(device)
        input_lengths = torch.Tensor(input_lengths)\
                             .long().to(device)
        return images, label_vectors, label_lengths, \
                input_lengths, labels
  • 定义str2vec函数,它将字符 id 的输入转换成一个字符串:
    def str2vec(self, string, pad=True):
        string = ''.join([s for s in string if \
                          s in self.invCharList])
        val = list(map(lambda x: self.invCharList[x], \
                       string)) 
        if pad:
            while len(val) < self.ts:
                val.append(0)
        return val

str2vec函数中,如果标签的长度(len(val))小于时间步长的数量(self.ts),我们从一串字符 id 中提取字符,并向向量添加填充索引0

  • 定义preprocess函数,该函数将一幅图像(imgshape作为输入,将其处理成一致的 32 x 128 的形状。注意,除了调整图像大小之外,还要进行额外的预处理,因为要在保持纵横比的同时调整图像大小。

定义preprocess函数和图像的目标形状,该图像目前被初始化为空白图像(白色图像–target):

    def preprocess(self, img, shape=(32,128)):
        target = np.ones(shape)*255

获取图像的形状和预期形状:

        try:
            H, W = shape
            h, w = img.shape

计算如何调整图像大小以保持纵横比:

            fx = H/h
            fy = W/w
            f = min(fx, fy)
            _h = int(h*f)
            _w = int(w*f)

调整图像大小,并将其存储在前面定义的目标变量中:

            _img = cv2.resize(img, (_w,_h))
            target[:_h,:_w] = _img

返回标准化的图像(我们首先将图像转换为黑色背景,然后将像素缩放到 0 到 1 之间的值):

        except:
            ...
        return (255-target)/255
  • 定义decoder_chars函数将预测解码成单词:
    def decoder_chars(self, pred):
        decoded = ""
        last = ""
        pred = pred.cpu().detach().numpy()
        for i in range(len(pred)):
            k = np.argmax(pred[i])
            if k > 0 and self.charList[k] != last:
                last = self.charList[k]
                decoded = decoded + last
            elif k > 0 and self.charList[k] == last:
                continue
            else:
                last = ""
        return decoded.replace(" "," ")

在前面的代码中,我们一次循环一个时间步长的预测(pred),获取具有最高置信度的字符(k),将其与前一个时间步长中具有最高置信度的字符(last)进行比较,如果前一个时间步长中具有最高置信度的字符与当前时间步长中具有最高置信度的字符不同(相当于挤压,我们在“CTC 损失函数”一节中对此进行了讨论),则将该字符追加到目前为止的decoded字符。

  • 定义计算字符和单词准确性的方法:
    def wer(self, preds, labels):
        c = 0
        for p, l in zip(preds, labels):
            c += p.lower().strip() != l.lower().strip()
        return round(c/len(preds), 4)
    def cer(self, preds, labels):
        c, d = [], []
        for p, l in zip(preds, labels):
            c.append(editdistance.eval(p, l) / len(l))
        return round(np.mean(c), 4)
  • 定义一种在一组图像上评估模型并返回单词和字符错误率的方法:
    def evaluate(self, model, ims, labels, lower=False):
        model.eval()
        preds = model(ims).permute(1,0,2) # B, T, V+1
        preds = [self.decoder_chars(pred) for pred in preds]
        return {'char-error-rate': self.cer(preds, labels), \
                'word-error-rate': self.wer(preds, labels), \
                'char-accuracy': 1-self.cer(preds, labels), \
                'word-accuracy' : 1-self.wer(preds, labels)}

在前面的代码中,我们对输入图像的通道进行了置换,以便按照模型的预期对数据进行预处理,使用decoder_chars函数对预测进行解码,然后返回字符错误率、单词错误率及其相应的准确性。

  1. 指定训练和验证数据集以及数据加载器:
from sklearn.model_selection import train_test_split
trn_items,val_items=train_test_split(Glob('synthetic-data'), \
                              test_size=0.2, random_state=22)
trn_ds = OCRDataset(trn_items)
val_ds = OCRDataset(val_items)

trn_dl = DataLoader(trn_ds, batch_size=B, \
                    collate_fn=trn_ds.collate_fn, \
                    drop_last=True, shuffle=True)
val_dl = DataLoader(val_ds, batch_size=B, \
                collate_fn=val_ds.collate_fn, drop_last=True)
  1. 构建网络架构:
  • 构建 CNN 的基本模块:
from torch_snippets import Reshape, Permute
class BasicBlock(nn.Module):
    def __init__(self, ni, no, ks=3, st=1, \
                 padding=1, pool=2, drop=0.2):
        super().__init__()
        self.ks = ks
        self.block = nn.Sequential(
            nn.Conv2d(ni, no, kernel_size=ks, \
                      stride=st, padding=padding),
            nn.BatchNorm2d(no, momentum=0.3),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(pool),
            nn.Dropout2d(drop)
        )
    def forward(self, x):
        return self.block(x)
  • 构建神经网络类 OCR,其具有分别在self.modelself.rnn中的__init__方法中定义的 CNN 块和 RNN 块。接下来,我们定义self.classification层,它获取 RNN 的输出,并在通过密集层处理 RNN 输出后,将其传递给 softmax 激活:
class Ocr(nn.Module):
    def __init__(self, vocab):
        super().__init__()
        self.model = nn.Sequential(
                    BasicBlock( 1, 128),
                    BasicBlock(128, 128),
                    BasicBlock(128, 256, pool=(4,2)),
                    Reshape(-1, 256, 32),
                    Permute(2, 0, 1) # T, B, D
                )
        self.rnn = nn.Sequential(
            nn.LSTM(256, 256, num_layers=2, \
                    dropout=0.2, bidirectional=True),
        )
        self.classification = nn.Sequential(
            nn.Linear(512, vocab+1),
            nn.LogSoftmax(-1),
        )
  • 定义forward方法:
    def forward(self, x):
        x = self.model(x)
        x, lstm_states = self.rnn(x)
        y = self.classification(x)
        return y

在前面的代码中,我们在第一步中获取 CNN 输出,然后通过 RNN 获取lstm_states和 RNN 输出x,最后通过分类层(self.classification)传递输出并返回。

  • 定义 CTC 损失函数:
def ctc(log_probs, target, input_lengths, \
        target_lengths, blank=0):
    loss = nn.CTCLoss(blank=blank, zero_infinity=True)
    ctc_loss = loss(log_probs, target, \
                    input_lengths, target_lengths)
    return ctc_loss

在前面的代码中,我们利用nn.CTCLoss方法来最小化ctc_loss,它将置信度矩阵、log_probs(每个时间步中的预测)、target(基本事实)、input_lengthstarget_lengths作为输入来返回ctc_loss值。

  • 获取已定义模型的概要:
model = Ocr(len(vocab)).to(device)
summary(model, torch.zeros((1,1,32,128)).to(device))

上述代码会产生以下输出:

注意,输出有 53 个概率与批中的每个图像相关联,因为有 53 个字符的词汇表(26 x 2 = 52 个字母和分隔符)。

  1. 定义对一批数据进行训练的函数:
def train_batch(data, model, optimizer, criterion):
    model.train()
    imgs, targets, label_lens, input_lens, labels = data
    optimizer.zero_grad()
    preds = model(imgs)
    loss = criterion(preds, targets, input_lens, label_lens)
    loss.backward()
    optimizer.step()
    results = trn_ds.evaluate(model, imgs.to(device),labels)
    return loss, results
  1. 定义要对一批数据进行验证的函数:
@torch.no_grad()
def validate_batch(data, model):
    model.eval()
    imgs, targets, label_lens, input_lens, labels = data
    preds = model(imgs)
    loss = criterion(preds, targets, input_lens, label_lens)
    return loss, val_ds.evaluate(model, imgs.to(device), \
                                 labels)
  1. 定义模型对象、优化器、损失函数和时期数:
model = Ocr(len(vocab)).to(device)
criterion = ctc

optimizer = optim.AdamW(model.parameters(), lr=3e-3)

n_epochs = 50
log = Report(n_epochs)
  1. 在不断增加的时期内运行模型:
for ep in range( n_epochs):
    N = len(trn_dl)
    for ix, data in enumerate(trn_dl):
        pos = ep + (ix+1)/N
        loss, results = train_batch(data, model, optimizer, \
                                    criterion)
        ca, wa = results['char-accuracy'], \
                 results['word-accuracy']
        log.record(pos=pos, trn_loss=loss, trn_char_acc=ca, \
                   trn_word_acc=wa, end='\r')
    val_results = []
    N = len(val_dl)
    for ix, data in enumerate(val_dl):
        pos = ep + (ix+1)/N
        loss, results = validate_batch(data, model)
        ca, wa = results['char-accuracy'], \
                 results['word-accuracy']
        log.record(pos=pos, val_loss=loss, val_char_acc=ca, \
                   val_word_acc=wa, end='\r')

    log.report_avgs(ep+1)
    print()
    for jx in range(5):
        img, label = val_ds.sample()
        _img=torch.Tensor(val_ds.preprocess(img)[None,None])\
                                  .to(device)
        pred = model(_img)[:,0,:]
        pred = trn_ds.decoder_chars(pred)
        print(f'Pred: `{pred}` :: Truth: `{label}`')
    print()

上述代码会产生以下输出:

从图表中,我们可以看到,该模型在验证数据集上的单词准确率约为 80%。

此外,训练结束时的预测如下:

到目前为止,我们已经学习了如何结合使用 CNN 和 rnn。在下一节中,我们将了解如何利用 transformer 架构对我们在前面章节中处理过的卡车和公交车数据集执行目标检测。

使用 DETR 的目标检测

在前面关于目标检测的章节中,我们学习了利用锚盒/区域提议来执行对象分类和检测。然而,它涉及一系列步骤来实现目标检测。DETR 是一种利用转换器来提供端到端管道的技术,可以大大简化目标检测网络架构。转换器是在 NLP 中执行各种任务的最流行和最新的技术之一。在这一节中,我们将学习转换器 DETR 的工作细节,并编写代码来执行我们检测卡车和公共汽车的任务。

转换器的工作细节

转换器已被证明是解决序列间问题的出色架构。截至撰写本书时,几乎所有的 NLP 任务都有来自 transformers 的最先进的实现。这类网络仅使用线性层和 softmax 来创建自我关注(这将在下一小节中详细解释)。自我注意有助于识别输入文本中单词之间的相互依赖性。输入序列通常不超过 2,048 项,因为这对文本应用程序来说已经足够大了。然而,如果图像要与 transformers 一起使用,它们必须被展平,这将产生数千/数百万像素量级的序列(因为 300 x 300 x 3 图像将包含 270K 像素),这是不可行的。脸书研究公司想出了一种绕过这种限制的新方法,将特征图(比输入图像小)作为输入输入到转换器。这一节先了解转换器的基础知识,后面再了解相关的代码块。

转换器基础

转换器的核心是自我关注模块。它以三个二维矩阵(称为查询 ( Q )、 ( K )、 ( V )矩阵)作为输入。矩阵可以具有非常大的嵌入大小(因为它们将包含文本大小 x 嵌入大小的值的数量),所以在遍历 scaled-dot-product-attention(下图中的步骤 2)之前,首先将它们拆分成较小的组件(下图中的步骤 1)。

让我们来理解自我关注是如何工作的。在序列长度为 3 的假设场景中,我们有三个字嵌入( W [1]W [2]W [3] )作为输入。假设每个嵌入的大小为 512。这些嵌入中的每一个都被单独转换成三个附加向量,它们是对应于每个输入的查询、键和值向量:

因为每个向量的大小是 512,所以在它们之间进行矩阵乘法在计算上是昂贵的。因此,我们将这些向量中的每一个分成八个部分,对于键、查询和值张量中的每一个有八组(64×3)向量,其中 64 是从 512(嵌入大小)/ 8(多头)获得的,3 是序列长度:

注意会有八组等张量,因为有八个多头。

在每一部分中,我们首先执行键矩阵和查询矩阵之间的矩阵乘法。这样,我们最终得到一个 3 x 3 的矩阵。让它通过 softmax 激活。现在,我们有一个矩阵来显示每个单词相对于其他单词的重要性:

最后,我们执行前面的张量输出与值张量的矩阵乘法,以获得我们的自我注意操作的输出:

然后,我们组合这一步的八个输出,使用 concat layer 返回(下图中的步骤 3),最终得到一个大小为 512 x 3 的张量。由于 Q、K、V 矩阵的分裂,该层也被称为多头自关注(来源:【arxiv.org/pdf/1706.03…??):

这样一个看起来复杂的网络背后的想法如下:

  • ( )是在键和查询矩阵的上下文中,对于给定的输入需要学习的处理过的嵌入。
  • 查询 ( Qs )和 ( Ks )的作用方式是,它们的组合将创建正确的掩码,以便仅将值矩阵的重要部分提供给下一层。

对于我们在计算机视觉中的例子,当搜索一个对象(比如一匹马)时,查询应该包含搜索一个大尺寸并且通常是棕色、黑色或白色的对象的信息。比例点积注意力的 softmax 输出将反映图像中包含该颜色(棕色、黑色、白色等)的按键矩阵部分。因此,从自我关注层输出的值将具有图像中大致具有期望颜色的那些部分,并且存在于值矩阵中。

我们在网络中多次使用自我关注模块,如下图所示。转换器网络包含一个编码网络(图的左边部分),其输入是源序列。编码部分的输出用作解码部分的密钥和查询输入,而值输入将由神经网络独立于编码部分进行学习(来源:arxiv.org/pdf/1706.03762.pdf):

最后,尽管这是一个输入序列,但没有迹象表明哪个标记(单词)是第一个,哪个是下一个(因为线性图层没有位置指示)。位置编码是可学习的嵌入(有时是硬编码的向量),我们根据它在序列中的位置将其添加到每个输入中。这样做是为了使网络了解哪个单词嵌入是序列中的第一个,哪个是第二个,等等。

在 PyTorch 中创建转换器网络的方法非常简单。您可以创建一个内置的转换器块,如下所示:

from torch import nn
transformer = nn.Transformer(hidden_dim, nheads, \
                        num_encoder_layers, num_decoder_layers)

这里,hidden_dim是嵌入的大小,nheads是多头自关注中的头数,num_encoder_layersnum_decoder_layers分别是网络中的编码和解码块数。

DETR 的工作细节

普通的转换器网络和 DETR 没有什么关键区别。首先,我们的输入是图像,而不是序列。因此,DETR 通过 ResNet 主干传递图像,以获得大小为 256 的向量,然后可以将该向量视为一个序列。在我们的例子中,解码器的输入是对象查询嵌入,它是在训练过程中自动学习的。这些充当所有解码器层的查询矩阵。类似地,对于每一层,键和查询矩阵将成为编码器块的最终输出矩阵,复制两次。转换器的最终输出将是一个Batch_Size x 100 x Embedding_Size张量,其中模型已经用100作为序列长度进行了训练;也就是说,它学习了 100 个对象查询嵌入,并为每个图像返回 100 个向量,指示是否存在对象。这些 100 x Embedding_Size矩阵被分别馈送到对象分类模块和对象回归模块,它们分别独立地预测是否有对象(以及它是什么)和边界框坐标是什么。这两个模块都是简单的nn.Linear层。

在高层次上,DETR 的架构如下(来源:arxiv.org/pdf/2005.12872.pdf):

DETR 的一个较小变体的定义如下:

  • 创建 DETR 模型类:
from collections import OrderedDict
class DETR(nn.Module):
    def __init__(self,num_classes,hidden_dim=256,nheads=8, \
                 num_encoder_layers=6, num_decoder_layers=6):
        super().__init__()
        self.backbone = resnet50()
  • 我们将只从 ResNet 中提取几层,并丢弃其余的层。这几层包含以下列表中给出的名称:
        layers = OrderedDict()
        for name,module in self.backbone.named_modules():
            if name in ['conv1','bn1','relu','maxpool', \
                    'layer1','layer2','layer3','layer4']:
                layers[name] = module
        self.backbone = nn.Sequential(layers)
        self.conv = nn.Conv2d(2048, hidden_dim, 1)
        self.transformer = nn.Transformer(\
                            hidden_dim, nheads, \
                            num_encoder_layers, \
                            num_decoder_layers)
        self.linear_class = nn.Linear(hidden_dim, \
                                      num_classes + 1)
        self.linear_bbox = nn.Linear(hidden_dim, 4)

在前面的代码中,我们指定了以下内容:

  • 感兴趣的层按顺序排列(self.backbone)

  • 卷积运算(self.conv)

  • 转换器座(self.transformer)

  • 最终连接获得的类数(self.linear_class)

  • 边界框(self.linear_box)

  • 定义编码器和解码器层的位置嵌入:

        self.query_pos = nn.Parameter(torch.rand(100, \
                                            hidden_dim))
        self.row_embed = nn.Parameter(torch.rand(50, \
                                            hidden_dim // 2))
        self.col_embed = nn.Parameter(torch.rand(50, \
                                            hidden_dim // 2))

self.query_pos是解码器层的位置嵌入输入,而self.row_embedself.col_embed形成编码器层的二维位置嵌入。

  • 定义forward方法:
    def forward(self, inputs):
        x = self.backbone(inputs)
        h = self.conv(x)
        H, W = h.shape[-2:]
 '''Below operation is rearranging the positional 
 embedding vectors for encoding layer'''
        pos = torch.cat([\
            self.col_embed[:W].unsqueeze(0).repeat(H, 1, 1),\
            self.row_embed[:H].unsqueeze(1).repeat(1, W, 1),\
            ], dim=-1).flatten(0, 1).unsqueeze(1)
 '''Finally, predict on the feature map obtained 
 from resnet using the transformer network'''
        h = self.transformer(pos+0.1*h.flatten(2)\
                             .permute(2, 0, 1), \
                      self.query_pos.unsqueeze(1))\
                             .transpose(0, 1)
 '''post process the output `h` to obtain class 
 probability and bounding boxes'''
        return {'pred_logits': self.linear_class(h), \
                'pred_boxes': self.linear_bbox(h).sigmoid()}

您可以加载在 COCO 数据集上训练的预训练模型,并将其用于预测一般类。预测逻辑将在下一节中解释,您也可以在这个模型上使用相同的函数(当然,对于 COCO 类):

detr = DETR(num_classes=91)
state_dict = torch.hub.load_state_dict_from_url(url=\ 'https://dl.fbaipublicfiles.com/detr/detr_demo-da2a99e9.pth'\
,map_location='cpu', check_hash=True)
detr.load_state_dict(state_dict)
detr.eval();

请注意,与我们在第七章、物体探测基础知识和第八章、高级物体探测中学习的其他物体探测技术相比,DETR 可以在单次拍摄中获取预测。

更详细的 DETR 建筑版本如下(来源:arxiv.org/pdf/2005.12872.pdf):

主干段中,我们获取图像特征,然后通过编码器传递,编码器将图像特征与位置嵌入连接起来。

本质上,在__init__方法中,位置嵌入(表示为self.row_embed, self.col_embed)有助于对图像中各种对象的位置信息进行编码。编码器采用位置嵌入和图像特征的连接来获得隐藏状态向量h(在正向方法中),该向量作为输入被传递给解码器。该变换器的输出被进一步馈送到两个线性网络,一个用于对象识别,一个用于边界框回归。转换器的所有复杂性都隐藏在网络的self.transformer模块中。

训练使用一种新颖的匈牙利损失,它负责将对象识别为一个集合,并惩罚冗余预测。这完全消除了对非最大抑制的需要。匈牙利损失的细节超出了本书的范围,我们鼓励你仔细阅读原始论文中的工作细节。

解码器采用编码器隐藏状态向量和对象查询的组合。对象查询的工作方式类似于位置嵌入/锚定框的工作方式,产生五个预测-一个针对对象的类别,另外四个针对与对象对应的边界框。

凭着对 DETR 工作细节的直觉和高度理解,让我们在下面的部分中对它进行编码。

使用代码中的转换器进行检测

在下面的代码中,我们将对 DETR 进行编码,以预测我们感兴趣的对象——公共汽车与卡车:

The following code is available as Object_detection_with_DETR.ipynb in the Chapter15 folder of this book's GitHub repository - tinyurl.com/mcvp-packt The code contains URLs to download data from and is moderately lengthy. We strongly recommend you to execute the notebook in GitHub to reproduce results while you understand the steps to perform and explanation of various code components from text.

  1. 导入数据集并创建一个名为detr的文件夹:
import os
if not os.path.exists('open-images-bus-trucks'):
    !pip install -q torch_snippets torchsummary
    !wget --quiet https://www.dropbox.com/s/agmzwk95v96ihic/open-images-bus-trucks.tar.xz
    !tar -xf open-images-bus-trucks.tar.xz
    !rm open-images-bus-trucks.tar.xz
    !git clone https://github.com/sizhky/detr/
%cd detr
  • 将注释图像移动到detr文件夹:
%cd ../open-images-bus-trucks/annotations
!cp mini_open_images_train_coco_format.json\
 instances_train2017.json
!cp mini_open_images_val_coco_format.json\
 instances_val2017.json
%cd ..
!ln -s img/ train2017
!ln -s img/ val2017
%cd ../detr
  • 定义感兴趣的类别:
CLASSES = ['', 'BUS','TRUCK']
  1. 导入预训练的 DETR 模型:
from torch_snippets import *
if not os.path.exists('detr-r50-e632da11.pth'):
    !wget https://dl.fbaipublicfiles.com/detr/detr-r50-e632da11.pth
    checkpoint = torch.load("detr-r50-e632da11.pth", \
                            map_location='cpu')
    del checkpoint["model"]["class_embed.weight"]
    del checkpoint["model"]["class_embed.bias"]
    torch.save(checkpoint,"detr-r50_no-class-head.pth")
  1. open-images-bus-trucks文件夹中的图像和注释训练模型:
!python main.py --coco_path ../open-images-bus-trucks/\
  --epochs 10 --lr=1e-4 --batch_size=2 --num_workers=4\
  --output_dir="outputs" --resume="detr-r50_no-class-head.pth"
  1. 一旦我们定型了模型,就从文件夹中加载它:
from main import get_args_parser, argparse, build_model
parser=argparse.ArgumentParser('DETR training and \
            evaluation script', parents=[get_args_parser()])
args, _ = parser.parse_known_args()

model, _, _ = build_model(args)
model.load_state_dict(torch.load("outputs/checkpoint.pth")\
                      ['model']);
  1. 后处理预测以获取图像和对象周围的边界框:
from PIL import Image, ImageDraw, ImageFont

# standard PyTorch mean-std input image normalization
# colors for visualization
COLORS = [[0.000, 0.447, 0.741], [0.850, 0.325, 0.098], 
          [0.929, 0.694, 0.125], [0.494, 0.184, 0.556], 
          [0.466, 0.674, 0.188], [0.301, 0.745, 0.933]]

transform = T.Compose([
    T.Resize(800),
    T.ToTensor(),
    T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

# for output bounding box post-processing
def box_cxcywh_to_xyxy(x):
    x_c, y_c, w, h = x.unbind(1)
    b = [(x_c - 0.5 * w), (y_c - 0.5 * h), \
         (x_c + 0.5 * w), (y_c + 0.5 * h)]
    return torch.stack(b, dim=1)

def rescale_bboxes(out_bbox, size):
    img_w, img_h = size
    b = box_cxcywh_to_xyxy(out_bbox)
    b = b * torch.tensor([img_w, img_h, img_w, img_h], \
                         dtype=torch.float32)
    return b

def detect(im, model, transform):
    img = transform(im).unsqueeze(0)
    '''demo model only supports images up to 1600 pixels 
     on each side'''
    assert img.shape[-2] <= 1600 and \
    img.shape[-1] <= 1600
    outputs = model(img)
    # keep only predictions with 0.7+ confidence
    probas=outputs['pred_logits'].softmax(-1)[0,:,:-1]
    keep = probas.max(-1).values > 0.7
    # convert boxes from [0; 1] to image scales
    bboxes_scaled = rescale_bboxes(outputs['pred_boxes']\
                                   [0, keep], im.size)
    return probas[keep], bboxes_scaled

def plot_results(pil_img, prob, boxes):
    plt.figure(figsize=(16,10))
    plt.imshow(pil_img)
    ax = plt.gca()
    for p, (xmin, ymin, xmax, ymax), c in zip(prob, \
                            boxes.tolist(), COLORS * 100):
        ax.add_patch(plt.Rectangle((xmin, ymin), \
                        xmax - xmin, ymax - ymin,\
                        fill=False, color=c, linewidth=3))
        cl = p.argmax()
        text = f'{CLASSES[cl]}: {p[cl]:0.2f}'
        ax.text(xmin, ymin, text, fontsize=15,\
                bbox=dict(facecolor='yellow', alpha=0.5))
    plt.axis('off')
    plt.show()
  1. 预测新图像:
for _ in range(2):
    image = Image.open(choose(Glob(\
                '../open-images-bus-trucks/img/*')))\
                .resize((800,800)).convert('RGB')
    scores, boxes = detect(image, model, transform)
    plot_results(image, scores, boxes)

上述代码生成以下输出:

从前面,我们可以看到,我们现在可以训练模型,能够预测图像中的对象。

请注意,我们已经在小数据集上训练了模型,因此在这种特定情况下,检测的准确性可能不是很高。然而,同样的方法可以扩展到大型数据集。作为一个练习,我们建议你应用与我们在第十章、目标检测应用和分割中所做的相同的技术来检测多个物体。

摘要

在这一章中,我们学习了 RNNs 是如何工作的,特别是 LSTM 的变体。此外,在我们的图像字幕用例中,当我们将图像通过预先训练的模型来提取特征,并将特征作为时间步长传递给 RNN 来一次提取一个单词时,我们学习了如何一起利用 CNN 和 RNNs。然后,我们将 CNN 和 RNNs 的结合更进一步,我们利用 CTC 损失函数来转录手写图像。CTC 损失函数有助于确保我们将来自后续时间步骤的相同字符压缩成单个字符,并确保考虑所有可能的输出组合,然后我们基于产生地面真实的组合来评估损失。最后,我们学习了如何利用转换器来执行使用 DETR 的目标检测,在此期间,我们还了解了转换器如何工作,以及如何在目标检测的环境中利用它们。

在下一章中,我们将了解如何结合 CNN 和强化学习技术来开发自动驾驶汽车原型,这是一个能够在学习贝尔曼方程后在没有监督的情况下玩 Atari Space Invaders 游戏的代理,它能够为给定的状态赋值。

问题

  1. 为什么 CNN 和 RNNs 在图像字幕中组合使用?
  2. 为什么图像字幕中提供了开始和结束标记,而手写转录中没有?
  3. 为什么在手写转录中利用 CTC 丢失功能?
  4. 转换器如何帮助目标检测?