CycleGan(循环一致生成网络学习)(一)

   博客分类: 学习笔记 文章类型: 原创

CycleGan(循环一致生成网络学习)(一)

本文字数为 9169 字, 预计读完大约需要 27 分钟

这个笔记用来记录对github上面的CycleGan-循环一致生成网络的代码的学习。 github地址:CycleGan

@[toc]

第一天的学习,先只看它的util模块。 在开始 学习Util的模块之前,需要有一点关于CycleGAN的预备知识。

一、预备的知识

  1. CycleGAN网络可以用来进行图片风格转换,即将一种类型图片映射到另一种类型的图片,它和最简单的GAN网络不同的一点是,它有两个转换器模型以及两个鉴别器模型\(G\)和\(F\)。两个转换器模型目的分别是把图像由类型A转换到类型B和将类型B转换到类型A。即: \(G(A)=B, F(B)=A\) 由于两个转换器正好在两个数之间形成了一个闭环,因此循环一致生成网络名称就由此而来。同时,它也有两个鉴别器\(D_A\)和\(D_B\),他们分别用来鉴别A和B的图片是真实的图片还是由生成器生成的图片。

  2. 对于CycleGAN网络来说它的训练数据不是一张一张图片进行训练,而是两张图片作为一组进行训练的。这和它的应用有关系,它用来进行图片风格的转换,即从图片类型A转换到图片类型B,因此对于模型的训练的单个训练元素就需要一个类型A的图片和一个类型B的图片,即给模型的一个最小训练单位应该为[A,B]或者是将A和B进行结合后给模型
  3. 你以为有第三点?想多了,小生才疏学浅,书本上的公式学不来,等什么时候琢磨透了第三点再补上。

有了上面的基础介绍后,对于理解util模块会有很大的助益。直接放代码有点耍流氓,那先看看util模块的简要作用。

二 、util模块简介

纵观util模块,它的主要作用有如下几点:

  1. 从文件系统加载图像,包括:
    • 数据增强
    • 图像裁剪
    • 图像大小调整
    • 数据标准化(或者叫归一化)
  2. 保存图像到文件系统
    • 保存单个图像
    • 合并图像进行保存
  3. 为之后的模型训练准备模型

三、模块代码解析

1. 加载模块

import copy
import math
import pprint
import numpy as np
import scipy.misc

try:
    _imread = scipy.misc.imread
except AttributeError:
    from imageio import imread as _imread

pp = pprint.PrettyPrinter()

get_stddev = lambda x, k_h, k_w: 1 / math.sqrt(k_w * k_h * x.get_shape()[-1])

开始的导入要用到的代码库什么的就不用说了。这里初始化了一个 _imread对象,是用来后面加载图片和保存图片。另外这里看到作者用了try块去捕获异常,我的猜测是,scipy 的图像读取在代码库里面已经被标注过时了,所以作者为了保证代码之后的兼容性,如果某一天scipy的图片加载对象不能使用了,就换用imageio中的图像加载模块。

另外 get_stddev 其作用是为了之后构建训练模型的时候初始化模型权重的时候要用到的,初始化模型权重的时候我们希望使用随机初始化,其中一个参数就是要设置随机函数的标准差。

2. 加载本地图片

加载本地图片包括几个函数,代码如下:

def get_image(image_path, image_size, is_crop=True, resize_w=64, is_grayscale=False):
	# 加载图像数据对象
    image = imread(image_path, is_grayscale)
    # 对图像进行标准化
    return transform(image, image_size, is_crop, resize_w)


def imread(path, is_grayscale=False):
    """
    读取图片
    :param path: 图片路径
    :param is_grayscale: 是否加载为灰度图
    :return: 加载后的图片数据对象
    """
    if is_grayscale:
    	# 加载为灰度图像
        return _imread(path, flatten=True).astype(np.float)
    else:
    	# 加载为RGB模式图像
        return _imread(path, mode="RGB").astype(np.float)


def center_crop(x, crop_h, crop_w, resize_h=64, resize_w=64):
    """
    从图片中间裁剪图片,并对图片大小重新调整
    :param x: 图片对象
    :param crop_h: 要裁剪的高度
    :param crop_w: 要裁剪的宽度
    :param resize_h: 最终返回的图片高度
    :param resize_w: 最终返回的图片宽度
    :return: 返回裁剪且重新调整大小的图片数据对象
    """
    if crop_w is None:
        crop_w = crop_h
    # 获取图像本身宽高
    h, w = x.shape[:2]
    # 找到切割图像的起始点
    j = int(round(h - crop_h) / 2.)
    i = int(round(w - crop_w) / 2.)
    # 裁剪图像后并对图像进行重置大小,默认大小为[64,64]
    return scipy.misc.imresize(x[j:j + crop_w, i:i + crop_h], [resize_h, resize_w])


def transform(image, npx=64, is_crop=True, resize_w=64):
    """
    对图片数据进行标准化,即最终的数据大小都在[-1,1]之间
    :param image: 图片数据对象
    :param npx: 如果图片要被裁剪,被裁减的像素大小
    :param is_crop: 是否裁剪图片
    :param resize_w: 裁剪图片后重新调整的大小
    :return: 标准化后的图片数据
    """
    # npx : # of pixels width/height of image
    if is_crop:
        cropped_image = center_crop(image, npx, crop_w=npx, resize_h=resize_w, resize_w=resize_w)
    else:
        cropped_image = image
    # 标准化
    return np.array(cropped_image) / 127.5 - 1.

这里的加载图片函数为 get_image,它首先调用了 imread 将本地图片路径的图片加载成图片数据对象,之后调用 transform 对图像进行标准化,将图像数据区间由数据加载时的 0-255 标准化到 [-1,1] 之间。方便之后的模型训练。 另外在 transform 中,有参数标志着是否对图像进行裁剪,裁剪的操作是从图像的中间进行裁剪,裁剪之后,再通过scipy的 resize 方法对图像的大小进行重新调整,这个目的是为了是适配之后模型数据,因为对于模型来说,输入的数据维度都是一定的。

3. 保存图片数据到本地

def save_images(images, size, image_path):
    return imsave(inverse_transform(images), size, image_path)


def imsave(images, size, path):
    return scipy.misc.imsave(path, merge(images, size))


def inverse_transform(images):
    """
    对标准化的图像数据进行反变换,使其转换到[0,1]之间
    方便图像的本地保存
    :param images: 要转换的图像列表
    :return: 转换后的图像数据对象
    """
    return (images + 1.) / 2.


def merge(images, size):
    """
    将多张图片合并成一张图像
    :param images: 要合并的图像列表
    :param size: 合成图像的长宽,长宽是指横向有几张图片,纵向有几张图片
    :return: 合并后的图像数据
    """
    h, w = images.shape[1], images.shape[2]
    # 根据size获取一个总的图片对象,这个图像总计可以包含size[0]*size[1]张图片
    img = np.zeros((h * size[0], w * size[1], 3))
    for idx, image in enumerate(images):
        # 图片列的位置
        i = idx % size[1]
        # 图片行的位置
        j = idx // size[1]
        # 根据行和列将图片复制到对应位置
        img[j * h:j * h + h, i * w:i * w + w, :] = image
    return img

从上面代码可以看出,保存图片到本地首先调用的 save_images 方法,它接收的是一个图片列表,它先调用 inverse_transform 方法对图片数据进行反标准化,那么就可以知道,它接收的图片数据应该是被标准化后的图片数据。因为标准化后的图片在-1到1之间,是没有办法保存到本地的(数据范围不对),因此要把它转换到0-1之间。 之后调用 imsave 方法,对图像进行保存。 imsave 方法接收一个 size 参数以及一个 path 参数,既然是一个图片数组,只有一个path是怎么保存的呢,因为它调用了 merge 方法,将图像列表合并成了一张图像。这样,保存的就是图片列表合并的图片。

4. 准备训练图片和测试图片

有了上面的基本图片的加载和保存,就可以为之后的模型数据做准备了。

def load_test_data(image_path, fine_size=256):
    """
    加载测试的图片
    :param image_path: 图片路径
    :param fine_size: 加载后的图片大小
    :return: 加载后重新调整大小的图片数据对象
    """
    img = imread(image_path)
    img = scipy.misc.imresize(img, [fine_size, fine_size])
    img = img / 127.5 - 1
    return img


def load_train_data(image_path, load_size=286, fine_size=256, is_testing=False):
    """
    加载训练的图片,这里使用了数据增强,从而保证更多的数据
    :param image_path: 图片路径列表,这里面应该有两个元素,即保证有两张图片
    :param load_size: 加载的图片大小
    :param fine_size: 返回的重新调整的图片的大小
    :param is_testing: 是否是测试的图片,如果是测试的图片的话就不再使用 数据增强了,保证测试数据的真实
    :return:
    """
    img_A = imread(image_path[0])
    img_B = imread(image_path[1])

    if not is_testing:
        # 如果不是测试,说明是训练,那么这里使用了图像增强的原理
        img_A = scipy.misc.imresize(img_A, [load_size, load_size])
        img_B = scipy.misc.imresize(img_B, [load_size, load_size])

        # 从图片的随机位置进行裁剪,Cell:向上取整
        h1 = int(np.ceil(np.random.uniform(1e-2, load_size - fine_size)))
        w1 = int(np.ceil(np.random.uniform(1e-2, load_size - fine_size)))
        img_A = img_A[h1:h1 + fine_size, w1:w1 + fine_size]
        img_B = img_B[h1:h1 + fine_size, w1:w1 + fine_size]

        # 左右 翻转
        if np.random.random() > 0.5:
            img_A = np.fliplr(img_A)
            img_B = np.fliplr(img_B)
    else:
        img_A = scipy.misc.imresize(img_A, [fine_size, fine_size])
        img_B = scipy.misc.imresize(img_B, [fine_size, fine_size])

    # 标准化
    img_A = img_A / 127.5 - 1.
    img_B = img_B / 127.5 - 1.

    # 最终合并的AB图像shape为[fine_size, fine_size, 6]
    img_AB = np.concatenate((img_A, img_B), axis=2)
    return img_AB

上面有两个方法。从名字就可以知道他们的作用,一个是加载测试数据,一个是加载训练数据。加载测试数据 load_test_data 这个很简单,不用解释,就是加载图像,调整大小,标准化。 加载训练数据就有点意思了,我们看到里面是有 img_A 还有 img_B 需要加载两张图片。还记得CycleGAN的准备知识里提到的吗? 这里就是这个意思。 在 load_train_data 中,传入的 image_path 其实是一个图片数组,里面包含有两个图片的路径。分别就是类型A的图片和类型B的图片。 这里首先把他们加载出来。之后有两个区别: 如果是训练的数据的话: 那么就对他们进行调增大小和随机裁剪。之所以在裁剪之前有一个调整大小的操作是为了对于本地的图片来说 大小不一,如果直接裁剪的话图片可能过大,也可能过小,过大裁剪出的图片可能没有意义,太小程序会直接报错,所以调整大小有着很重要的作用。 而之后的裁剪也很有意思,它其实是随机进行裁剪,即从图片的随机位置进行裁剪 fine_size 大小的图片,然后后面在随机的对图像进行翻转。为什么?这里有一点数据增强的意味在里面,随机裁剪和翻转会使每次的图片尽可能不同,但又不会使图片失去原来的意思,也就是说对同一张图片进行随机裁剪和翻转可以的到不同于原图片的很多图片。就是我们可以获取到多余原始数据的数据,这也就是数据增强的意思。当然我说有点这个意思是因为图像增强不仅仅有上面的两种处理,同时还有上下翻转,调整亮度,对比度等各种方式。 如果是测试的话 就简单了,只是简单地 resize 返回,这个没什么可说的。

最终,将处理后的图片A和图片B作为一个整体进行返回。这里使用了 numpyconcatenate 方法,这个方法后面会详细解释。

5. 最后:图片缓存池

在util模块中,作者写了一个类,这个类是一个图片的缓存池。

class ImagePool(object):
    """
    用于 管理图片的的图片池
    它的原理如下:
    图片管理池有一个最大缓存的图片大小
    如果设置的图片大小小于零向池中添加图片的时候,会获取原本传入的图片,因为设定小于零不会有缓存
    如果设置的图片数量大于零,并且当前缓存池中的缓存数量还没有满,同样返回原图片
    如果设置的图片数量大于零,而且缓存池中的数量已满的话
        :会从缓存池中随机位置取出图片组的第一张,并将传入的图片组第一张进行替换
        :从另一个随机位置获取图片组的第二张,并将传入图片组的第二张进行替换
        :将取出的第一张和第二张组合成一个图片组进行返回

    """

    def __init__(self, maxsize=50):
        self.maxsize = maxsize
        self.num_img = 0
        self.images = []

    def __call__(self, image):
        if self.maxsize <= 0:
            return image
        if self.num_img < self.maxsize:
            self.images.append(image)
            self.num_img += 1
            return image
        if np.random.rand() > 0.5:
            idx = int(np.random.rand() * self.maxsize)
            tmp1 = copy.copy(self.images[idx])[0]
            self.images[idx][0] = image[0]
            idx = int(np.random.rand() * self.maxsize)
            tmp2 = copy.copy(self.images[idx])[1]
            self.images[idx][1] = image[1]
            return [tmp1, tmp2]

它的作用是这样的,当你向缓存池中放入一个“图片组”后,缓存池会给返回给你一个“图片组”,它的目的是为了随机化图片组。用下面的一个例子来说明缓存池的作用。 假如我们有一些数据 \(A[A_1,A_2,…A_n]\) ,和数据 \(B[B_1,B_2,…B_n]\),它们是我们要给模型训练的数据集。按照我们的目的,我们应该把A数据和B数据一个一个分好组:即最终的分组可能是这样的(当然也可能有其他分组方式): \(AB[(A_1,B_1), (A_2,B_2),…,(A_n,B_n)]\) , 在训练模型的时候,我们往往需要对同一组数据进行多次训练。(因为我们没有更多的数据),那么如果还是使用之前分配好的组就会使模型太依赖当前分组,尤其可能出现过拟合,因此每次训练完成后就需要打乱顺序,重新分组。 作者写的类完成了这个功能,他不需要对全部重新分组,当有图像组传给缓存池,我们记传入的图片组为 \((in_A,in_B)\),缓存池判断当前缓存池有没有满,如果没有满,就把传进来的图片组原样返回\((in_A,in_B)\) 。如果已满,那么从缓存池中随机取出一个图片组,取出这个 图片组的第一张图片,即A类图片,记为\(out_A\) ,将 \(in_A\) 替换掉 \(out_A\) , 然后在在随机取出一个图片组,取出这个图片组的第二张图片,即B类图片,记为 \(out_B\) , 将 \(in_B\) 替换掉 \(out_B\), 最后返回 \((out_A, out_B)\) 。这样就能保证图片组的随机性了。这样做的好处是不需要将全部图片加载到内存,节省了空间。 另外,对于图片组,由于还没有看到之后的代码,我认为传给图片池的其实是图片的路径,因为它返回的值正好可以给第4点中对的方法 load_train_data 使用。 因为缓存池主要用于数据源的分配,传入图片对象是没意义的,白白浪费空间。

6. 结束

至此,util模块的代码就学习完了。这里面的代码主要为了给模型训练提供数据方面的支持,真正的模型代码没有,训练模型的代码也没有,话虽如此,从这里还是学到很多有意思的东西的。

四、后记

1. numpy axis

在文章的第三节的第四小节的 load_train_data 中 有一个 np.concatenate 函数,它的作用很简单就是把数据给拼接起来,比如\([1,2,3],[4,5,6]\) ,拼接起来后就是 \([1,2,3,4,5,6]\) ,这个好理解,可当数据是多维度的时候就有点难理解了。尤其是它的 axis 参数。对于这个参数,如果我们简单看 np.concatenate 的输出维度的话,可以看到其规律:例如我们的测试数据: 从维度角度来看: \(a = [[[1, 2, 3], [3, 4, 5]]] \\ b = [[[1, 2, 3], [3, 4, 5]]]\) 这里的数据维度都是 \((1,2,3)\), 我们模型训练的图片数据维度就是这样,比如 \((1920, 1080, 3)\), 那不同的axis作用于方法,最终输出结果是什么? | axis | 0 | 1| 2| |–|–|–|–| | \(out_shape\) | \((2, 2, 3)\) | \((1, 4, 3)\) | \((1, 2, 6)\) | 观察到规律了吗,axis=n就把n+1位置的维度相加就是结果,因此,上面的 load_train_data 的输出维度是 \((fine_size, fine_size, 6)\)

从数据角度来看 axis 代表的其实就是在哪个维度进行数据的操作。同样以上面的a和b作为参照数据。 下面是对于数据a的axis的维度数据的参照,b同理:

axis 0 1 2
子维度 \([[1,2,3],[3,4,5]]\) \([1,2,3],\\ [3,4,5]\) \(1, 2, 3 \\ 3,4,5\)

也就是说axis对应着数据维度的深度。如果axis=0,说明在子维度深度为0的数据上面进行操作,如果axis=1,说明在子维度深度为1的数据上面进行操作。 下面就是处理后的结果

axis 0 1 2
concatenate((a,b)) \([ \\ [[1,2,3],[3,4,5]], \\ [[1,2,3],[3,4,5]] \\]\) \([[ \\ [1,2,3],[3,4,5], \\ [1,2,3],[3,4,5] \\ ]]\) \([[[1,2,3,1,2,3],\\ [3,4,5,3,4,5]]]\)

2. scipy.misc.imresize

对于 imresize 本来以为和numpy的resize有点类似,后来发现完全不是,它居然可以把一个大的size转换为一个小的size,一开始没有理解他的原理是什么。我的想法可能是两种方式:

  1. imresize 是把原来的数据给分割了,或者是填充了
  2. 通过图片处理算法把图片给压缩了

经过试验发现是第二种方式,其实也对,分割或者是填充不符合图片处理库的运行机理,也不适合在深度学习中处理图片。 下面是对图片运用上面的util模块中的代码裁剪图片并resize图片后的结果。可以看到设置 size 比较小的时候其实就是图片的分辨率降低了。既没有分割图片也没有填充图片。 代码

import imageio

from util import center_crop
from util import imread


def test_resize(image_path):
    image = imread(image_path, False)
    min_pix = min(image.shape[0], image.shape[1])
    crop_pix = int(min_pix * 0.8)
    h_size = int(crop_pix)
    l_size = int(crop_pix * 0.4)
    h_image = center_crop(image, crop_pix, crop_pix, resize_h=h_size, resize_w=h_size)
    imageio.imwrite("%dx%d.jpg" % (h_size, h_size), h_image)
    l_image = center_crop(image, crop_pix, crop_pix, resize_h=l_size, resize_w=l_size)
    imageio.imwrite("%dx%d.jpg" % (l_size, l_size), l_image)


if __name__ == '__main__':
    image_path = os.path.abspath("1.jpg")
    test_resize(image_path)

运行结果( 文章最后涂山红红镇楼 ): 原图 涂山之王镇楼专用

裁剪2024x2024 裁剪2024x2024

裁剪404x404 裁剪404x404

可以看到,图片内容是一样的,就是分辨率不同了而已