Как стать автором
Обновить

Создание арта с помощью DCGAN на Keras

Время на прочтение 13 мин
Количество просмотров 7.5K

Доброго времени суток. Полгода назад я начал изучать машинное обучение, прошел пару курсов и получил некоторый опыт в этом. Затем, видя самые разные новости о том, какие нейронные сети крутые и много могут делать, я решил попробовать изучить их. Начал читать книгу Николенко про глубокое обучение и в ходе чтения у меня появилось несколько идей (которые не новы для мира, но для меня представляли огромный интерес), одна из которых — создать нейросеть, которая генерировала бы для меня арт, который казался бы классным не только мне, "отцу рисующего ребёнка", но и другим людям. В этой статье я постараюсь описать путь, который я прошел для того, чтобы получить первые удовлетворяющие меня результаты.


Сбор датасета


Когда я прочитал главу о состязательных сетях, я понял, что теперь могу что-то написать.
Одной из первых задач было написать парсер веб-страницы для сбора датасета. Для этого отлично подошел сайт wikiart, на нём большое количество картин и все собраны по стилям. Это был мой первый парсер, поэтому я писал его в течение 4-5 дней, первые 3 из которых заняли тыканья по совершенно неправильному пути. Правильный путь был в том, чтобы перейти во вкладку network в исходном коде страницы и отследить, как появляются картинки, при нажатии кнопки "больше". Собственно, для таких же начинающих как я, будет хорошо показать код.


from scipy.misc import imresize, imsave
from matplotlib.image import imread
import requests
import json
from bs4 import BeautifulSoup
from itertools import count
import os
import glob

В первой ячейке жупитера я импортил нужные библиотеки.


  • glob — Удобная штука для получения списка файлов в директории
  • requests, BeautifulSoup — Мастхэв для парсинга
  • json — библиотека, для получения словаря, который возвращается при нажатии кнопки "больше" на сайте
  • resize, save, imread — для чтения изображений и их подготовки.


def get_page(style, pagenum):
    page = requests.get(url1 + style + url2 + str(pagenum) + url3)
    return page

def make_soup(page):
    soup = BeautifulSoup(page.text, 'html5lib')
    return soup

def make_dir(name, s):
    path = os.getcwd() + '/' + s + '/' + name
    os.mkdir(path)

Описываю функции для удобной работы.


Первая — получает страничку в виде текста, вторая делает этот текст удобнее для работы. Ну и третья для создания нужных папок по стилям.


styles = ['kubizm']
url1 = 'https://www.wikiart.org/ru/paintings-by-style/'
url2 = '?select=featured&json=2&layout=new&page='
url3 = '&resultType=masonry'

В массиве styles по задумке должно было быть несколько стилей, но так получилось, что я их загружал совсем неравномерно.


for style in styles:
    make_dir(style, 'images')

for style in styles:
    make_dir(style, 'new256_images')

Создание нужных папок. Второй цикл создает папки, в котором будут сохранены изображение, сплюснутые в квадрат 256х256.


(Сначала я думал о том, чтобы как-то не нормировать размеры картинок, чтобы не было искажений, но понял, что это либо невозможно, либо слишком сложно для меня)


for style in styles:
    path = os.getcwd() + '\\images\\' + style + '\\'
    images = []
    names = []
    titles = []
    for pagenum in count(start=1):
        page = get_page(style, pagenum)
        if page.text[0]!='{': break
        jsons = json.loads(page.text)
        paintings = jsons['Paintings']
        if paintings is None: break
        for item in paintings:
            images_temp = []
            images_dict = item['images']
            if images_dict is None:
                images_temp.append(item['image'])
                names.append(item['artistName'])
                titles.append(item['title'])
            else:
                for inner_item in item['images']:
                    images_temp.append(inner_item['image'])
                names.append(item['artistName'])
                titles.append(item['title'])
            images.append(images_temp)

    for char in ['/','\\','"', '?', ':','*','|','<','>']:
        titles = [title.replace(char, ' ') for title in titles]
    for listimg, name, title in zip(images, names, titles):
        if len(name) > 30: 
            name = name[:25]
        if len(title) > 50:
            title = title[:50]
        if len(listimg) == 1:
            response = requests.get(listimg[0])
            if response.status_code == 200:
                with open(path + name + ' ' + title + '.png', 'wb') as f:
                    f.write(response.content)
            else: print('Error from server')
        else:
            for i, img in enumerate(listimg):
                response = requests.get(img)
                if response.status_code == 200:
                    with open(path + name + ' ' + title + str(i) + '.png', 'wb') as f:
                        f.write(response.content)
                else: print('Error from server')

Здесь происходит скачивание картинок и их сохранение в нужную папку. Здесь картинки не меняют размера, сохраняются оригиналы.


Интересные вещи происходят в первом вложенном цикле:


Я решил в тупую постоянно просить json'ы (json — словарь, который возвращает сервер при нажатии кнопки "Больше". В словаре вся информация о картинах), а останавливаться тогда, когда сервер будет возвращать что-то невнятное и не похожее на типичные значения. В данном случае, первый символ возвращаемого текста должен был быть открывающейся фигурной скобкой, после которой идёт тело словаря.


Также было замечено, что сервер может возвращать что-то типа альбома картин. То есть по сути массив картин. Поначалу думал, что возвращаются одинарные картины, имя художников к ним, а может быть так, что разом с одним именем художника дается массив картин.


 for style in styles:
    directory = os.getcwd() + '\\images\\' + style + '\\'
    new_dir = os.getcwd() + '\\new256_images\\' + style + '\\'
    filepaths = []
    for dir_, _, files in os.walk(directory):
        for fileName in files:
            #relDir = os.path.relpath(dir_, directory)
            #relFile = os.path.join(relDir, fileName)
            relFile = fileName
            #print(directory)
            #print(relFile)
            filepaths.append(relFile)
            #print(filepaths[-1])

    print(filepaths[0])        

    for i, fp in enumerate(filepaths):
        img = imread(directory + fp, 0) #/ 255.0
        img = imresize(img, (256, 256))
        imsave(new_dir  + str(i) + ".png", img)

Здесь изображения меняют размер и сохраняются в приготовленную для них папку.


Что ж, датасет собран, можно приступать к наиболее интересному !


Начиная с малого



Далее, прочитав исходную статью я начал творить! Но каково же было моё разочарование, когда ничего хорошего не получалось. В эти попытки я обучал сеть на одном стиле картин, но даже так ничего не получалось, поэтому я решил для начала научиться генерировать цифры из мниста. Не буду здесь подробно останавливаться, расскажу только об архитектуре и переломном моменте, благодаря которому начали генерироваться цифры.


def build_generator():

        model = Sequential()
        model.add(Dense(128 * 7 * 7, input_dim = latent_dim))
        model.add(BatchNormalization())
        model.add(LeakyReLU())
        model.add(Reshape((7, 7, 128)))
        model.add(Conv2DTranspose(64, filter_size, strides=(2,2), padding='same'))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU())
        model.add(Conv2DTranspose(32, filter_size, strides=(1, 1), padding='same'))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU())
        model.add(Conv2DTranspose(img_channels, filter_size, strides=(2,2), padding='same'))
        model.add(Activation("tanh"))

        model.summary()

        return model

  • latent_dim — массив из 100 рандомно сгенерированных чисел.


    def build_discriminator():
    
        model = Sequential()
        model.add(Conv2D(64, kernel_size=filter_size, strides = (2,2), input_shape=img_shape, padding="same"))
        model.add(LeakyReLU(alpha=0.2))
        model.add(Dropout(0.25))
        model.add(Conv2D(128,  kernel_size=filter_size, strides = (2,2), padding="same"))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU(alpha=0.2))
        model.add(Dropout(0.25))
        model.add(Conv2D(128, kernel_size=filter_size, strides = (2,2), padding="same"))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU(alpha=0.2))
        model.add(Dropout(0.25))
        model.add(Flatten())
        model.add(Dense(1))
        model.add(Activation('sigmoid'))
    
        model.summary()
    
        return model

    То есть по итогу размеры выходов свёрточных слоёв и количество слоёв вообще меньше, чем в исходной статье. 28х28 ведь генерирую, а не интерьеры !



Ну и тот самый трюк, благодаря которому всё получилось — на четной итерации обучения дискриминатор смотрел на сгенерированные картинки, а на нечетной — на реальные.


На этом в общем-то всё. Подобный DCGAN обучился весьма быстро, например, картинка в начале данной подтемы была получена на 19 эпохе,



Эти уже уверенные, но тем не менее временами не реальные цифры получились на 99 эпохе обучения.


Удовлетворившись предварительным результатом я остановил обучение и начал думать над тем, как же решить основную задачу.


Creative adversarial network


Следующим шагом было чтение о GAN с лейблами: дискриминатору и генератору подается класс текущей картинки. А после гана с лейблами я узнал о CAN — расшифровка в общем-то в названии подтемы.


В CAN дискриминатор пытается угадать класс картины, если картина из реального сета. И, соответственно, в случае обучения на реальной картине в качестве ошибки дискриминатору кроме дефолтной подается ошибка от угадывания класса.


При обучении на сгенерированной картинке дискриминатору нужно только предсказать, реальная эта картинка или нет.


Генератору же, кроме того, чтобы просто обмануть дискриминатор, нужно сделать так, чтобы дискриминатор был в растерянности при угадывании класса картинки, то есть генератор будет интересовать то, чтобы выходы на классы дискриминаторы были как можно дальше от 1, полной уверенности.


Переходя к CAN я опять же испытывал трудности, дизмораль из-за того, что ничего не работает и не обучается. Спустя несколько неприятных провалов, решил начать всё с начала и сохранять все изменения (Да, раньше этого не делал), веса и архитектуру (для прерывания обучения).


Сначала я захотел сделать сеть, которая генерировала бы мне одну-единственную картинку размера 256х256 (Все следующие картинки такого размера) без всяких лейблов. Переломный момент здесь был в том, чтобы наоборот в каждой итерации обучения дискриминатору давать смотреть и на сгенерированные картинки, и на реальные.



Это результат, на котором я остановился и перешел к следующему этапу. Да, цвета отличаются от реальной картины, но меня больше интересовало умение сети выделять контуры и объекты. С этим она справилась.


Дальше можно было приступать к основной задаче — генерированию арта. Сразу представлю код, попутно комментируя его.


Сначала как всегда нужно импортировать все библиотеки.


import glob
from PIL import Image
from keras.preprocessing.image import array_to_img, img_to_array, load_img
from datetime import date
from datetime import datetime
import tensorflow as tf
import numpy as np
import argparse
import math
import os
from matplotlib.image import imread
from scipy.misc.pilutil import imresize, imsave
import matplotlib.pyplot as plt
import cv2
import keras
from keras.models import Sequential, Model
from keras.layers import Dense, Activation, Reshape, Flatten, Dropout, Input
from keras.layers.convolutional import Conv2D, Conv2DTranspose, MaxPooling2D
from keras.layers.normalization import BatchNormalization
from keras.layers.advanced_activations import LeakyReLU
from keras.optimizers import Adam, SGD
from keras.datasets import mnist
from keras import initializers
import numpy as np
import random

Создание генератора.


Выходы слоёв опять же отличаются от статьи. Где-то в целях экономии памяти (Условия: домашний компьютер с gtx970), а где-то из-за успеха с конфигурацией


def build_generator():

        model = Sequential()
        model.add(Dense(128 * 16 * 8, input_dim = latent_dim))
        model.add(BatchNormalization())
        model.add(LeakyReLU())
        model.add(Reshape((8, 8, 256)))

        model.add(Conv2DTranspose(512, filter_size_g, strides=(1,1), padding='same'))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU())

        model.add(Conv2DTranspose(512, filter_size_g, strides=(1,1), padding='same'))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU())

        model.add(Conv2DTranspose(256, filter_size_g, strides=(1,1), padding='same'))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU())

        model.add(Conv2DTranspose(128, filter_size_g, strides=(2,2), padding='same'))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU())

        model.add(Conv2DTranspose(64, filter_size_g, strides=(2,2), padding='same'))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU())

        model.add(Conv2DTranspose(32, filter_size_g, strides=(2,2), padding='same'))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU())

        model.add(Conv2DTranspose(16, filter_size_g, strides=(2,2), padding='same'))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU())

        model.add(Conv2DTranspose(8, filter_size_g, strides=(2,2), padding='same'))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU())

        model.add(Conv2DTranspose(img_channels, filter_size_g, strides=(1,1), padding='same'))
        model.add(Activation("tanh"))

        model.summary()

        return model

Функция создания дискриминатора возвращает две модели, одна из которых пытается узнать, реальная ли картинка, а другая пытается узнать класс картинки.


def build_discriminator(num_classes):

        model = Sequential()

        model.add(Conv2D(64, kernel_size=filter_size_d, strides = (2,2), input_shape=img_shape, padding="same"))
        model.add(LeakyReLU(alpha=0.2))
        model.add(Dropout(0.25))

        model.add(Conv2D(128, kernel_size=filter_size_d, strides = (2,2), padding="same"))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU(alpha=0.2))
        model.add(Dropout(0.25))

        model.add(Conv2D(256,  kernel_size=filter_size_d, strides = (2,2), padding="same"))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU(alpha=0.2))
        model.add(Dropout(0.25))

        model.add(Conv2D(512, kernel_size=filter_size_d, strides = (2,2), padding="same"))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU(alpha=0.2))
        model.add(Dropout(0.25))

        model.add(Conv2D(512, kernel_size=filter_size_d, strides = (2,2), padding="same"))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU(alpha=0.2))
        model.add(Dropout(0.25))

        model.add(Conv2D(512, kernel_size=filter_size_d, strides = (2,2), padding="same"))
        model.add(BatchNormalization(momentum=0.8))
        model.add(LeakyReLU(alpha=0.2))
        model.add(Dropout(0.25))

        model.add(Flatten())

        model.summary()

        img = Input(shape=img_shape)

        features = model(img)

        validity = Dense(1)(features)
        valid = Activation('sigmoid')(validity)

        label1 = Dense(1024)(features)
        lrelu1 = LeakyReLU(alpha=0.2)(label1)
        label2 = Dense(512)(label1)
        lrelu2 = LeakyReLU(alpha=0.2)(label2)
        label3 = Dense(num_classes)(label2)
        label = Activation('softmax')(label3)

        return Model(img, valid), Model(img, label)

Функция для создания состязательной модели. В состязательной модели дискриминатор не обучается.


def generator_containing_discriminator(g, d, d_label):
    noise = Input(shape=(latent_dim,))
    img = g(noise)
    d.trainable = False
    d_label.trainable = False
    valid, target_label = d(img), d_label(img)

    return Model(noise, [valid, target_label])

Функция для загрузки батча с реальными картинками и лейблами. data — массив адресов, который будет определён позже. В этой же функции производится нормализация картинки.


def get_images_classes(batch_size, data):
    X_train = np.zeros((batch_size, img_rows, img_cols, img_channels))
    y_labels = np.zeros(batch_size)
    choice_arr = np.random.randint(0, len(data), batch_size)
    for i in range(batch_size):
        rand_number = np.random.randint(0, len(data[choice_arr[i]]))
        temp_img = cv2.imread(data[choice_arr[i]][rand_number])

        X_train[i] = temp_img
        y_labels[i] = choice_arr[i]

    X_train = (X_train - 127.5)/127.5
    return X_train, y_labels

Функция для красивого вывода батча картинок. Собственно, все картинки из этой статьи были собраны этой функцией.


def combine_images(generated_images):
    num = generated_images.shape[0]
    width = int(math.sqrt(num))
    height = int(math.ceil(float(num)/width))
    shape = generated_images.shape[1:3]
    image = np.zeros((height*shape[0], width*shape[1], img_channels),
                     dtype=generated_images.dtype)
    for index, img in enumerate(generated_images):
        i = int(index/width)
        j = index % width
        image[i*shape[0]:(i+1)*shape[0], j*shape[1]:(j+1)*shape[1]] = \
            img[:, :, :,]
    return image

А здесь эта самая data. Она в более-менее удобном виде возвращает набор адресов картинок, которые мы, выше, разложили по папкам


def get_data():
    styles_folder = os.listdir(path=os.getcwd() + "\\new256_images\\")
    num_styles = len(styles_folder)
    data = []
    for i in range(num_styles):
        data.append(glob.glob(os.getcwd() + '\\new256_images\\' + styles_folder[i] + '\\*'))
    return data, num_styles

Для прохождении эпохи выставлено рандомное большое число, потому что посчитать количество всех картинок было лень. В этой же функции предусмотрена загрузка весов, если нужно продолжить обучение. Каждые 5 эпох веса и архитектура сохраняются.


Также стоит написать, что я пробовал добавлять шум на входные картинки, но в последнее обучение решил этого не делать.
Использованы сглаженные метки классов, они весьма заметно помогают обучению.


def train_another(epochs = 100, BATCH_SIZE = 4, weights = False, month_day = '', epoch = ''):

    data, num_styles = get_data()

    generator = build_generator()
    discriminator, d_label = build_discriminator(num_styles)

    discriminator.compile(loss=losses[0], optimizer=d_optim)
    d_label.compile(loss=losses[1], optimizer=d_optim)
    generator.compile(loss='binary_crossentropy', optimizer=g_optim)

    if month_day != '':
        generator.load_weights(os.getcwd() + '/' + month_day + epoch +  ' gen_weights.h5')
        discriminator.load_weights(os.getcwd() + '/' + month_day + epoch + ' dis_weights.h5')
        d_label.load_weights(os.getcwd() + '/' + month_day + epoch + ' dis_label_weights.h5')

    dcgan = generator_containing_discriminator(generator, discriminator, d_label)

    dcgan.compile(loss=losses[0], optimizer=g_optim)

    discriminator.trainable = True
    d_label.trainable = True

    for epoch in range(epochs):
        for index in range(int(15000/BATCH_SIZE)):
            noise = np.random.normal(0, 1, (BATCH_SIZE, latent_dim))

            real_images, real_labels = get_images_classes(BATCH_SIZE, data)

            #real_images += np.random.normal(size = img_shape, scale= 0.1)

            generated_images = generator.predict(noise)

            X = real_images
            real_labels = real_labels - 0.1 + np.random.rand(BATCH_SIZE)*0.2
            y_classif = keras.utils.to_categorical(np.zeros(BATCH_SIZE) + real_labels, num_styles)
            y = 0.8 + np.random.rand(BATCH_SIZE)*0.2

            d_loss = []
            d_loss.append(discriminator.train_on_batch(X, y))
            discriminator.trainable = False
            d_loss.append(d_label.train_on_batch(X, y_classif))
            print("epoch %d batch %d d_loss : %f, label_loss: %f" % (epoch, index, d_loss[0], d_loss[1]))

            X = generated_images
            y = np.random.rand(BATCH_SIZE) * 0.2
            d_loss = discriminator.train_on_batch(X, y)

            print("epoch %d batch %d d_loss : %f" % (epoch, index, d_loss))

            noise = np.random.normal(0, 1, (BATCH_SIZE, latent_dim))

            discriminator.trainable = False
            d_label.trainable = False

            y_classif = keras.utils.to_categorical(np.zeros(BATCH_SIZE) + 1/num_styles, num_styles)
            y = np.random.rand(BATCH_SIZE) * 0.3

            g_loss = dcgan.train_on_batch(noise, [y, y_classif])

            d_label.trainable = True
            discriminator.trainable = True

            print("epoch %d batch %d g_loss : %f, label_loss: %f" % (epoch, index, g_loss[0], g_loss[1]))

            if index % 50 == 0:
                        image = combine_images(generated_images)
                        image = image*127.5+127.5
                        cv2.imwrite(
                            os.getcwd() + '\\generated\\epoch%d_%d.png' % (epoch, index), image)
                        image = combine_images(real_images)
                        image = image*127.5+127.5
                        cv2.imwrite(
                            os.getcwd() + '\\generated\\epoch%d_%d_data.png' % (epoch, index), image)

        if epoch % 5 == 0:

            date_today = date.today()

            month, day = date_today.month, date_today.day

            # Генерируем описание модели в формате json
            d_json = discriminator.to_json()
            # Записываем модель в файл
            json_file = open(os.getcwd() + "/%d.%d dis_model.json" % (day, month), "w")
            json_file.write(d_json)
            json_file.close()

            # Генерируем описание модели в формате json
            d_l_json = d_label.to_json()
            # Записываем модель в файл
            json_file = open(os.getcwd() + "/%d.%d dis_label_model.json" % (day, month), "w")
            json_file.write(d_l_json)
            json_file.close()

            # Генерируем описание модели в формате json
            gen_json = generator.to_json()
            # Записываем модель в файл
            json_file = open(os.getcwd() + "/%d.%d gen_model.json" % (day, month), "w")
            json_file.write(gen_json)
            json_file.close()

            discriminator.save_weights(os.getcwd() + '/%d.%d %d_epoch dis_weights.h5' % (day, month, epoch))
            d_label.save_weights(os.getcwd() + '/%d.%d %d_epoch dis_label_weights.h5' % (day, month, epoch))
            generator.save_weights(os.getcwd() + '/%d.%d %d_epoch gen_weights.h5' % (day, month, epoch))

Инициализация переменных и запуск обучение. Из-за низкой "мощности" моего компьютера обучение возможно максимум на батче размером в 16 картинок.


img_rows = 256
img_cols = 256
img_channels = 3
img_shape = (img_rows, img_cols, img_channels)
latent_dim = 100
filter_size_g = (5,5)
filter_size_d = (5,5)
d_strides = (2,2)

color_mode = 'rgb'

losses = ['binary_crossentropy', 'categorical_crossentropy']

g_optim = Adam(0.0002, beta_2 = 0.5)
d_optim = Adam(0.0002, beta_2 = 0.5)

train_another(1000, 16)

Вообще я довольно давно хочу написать пост на хабре про эту свою идею, сейчас не самое лучшее для этого время, потому что эта нейронка обучается в течение трёх дней и сейчас находится на 113 эпохе, но сегодня я обнаружил интересные картинки, поэтому решил, что пора бы уже писать пост !



Вот такие картинки получились на сегодняшний день. Возможно, назвав их, я смогу передать читателю моё личное восприятие этих картинок. Вполне заметно, что сеть не обучена достаточно (или может вообще такими методами не обучится), особенно учитывая то, что картинки взяты черрипикингом, но сегодня я получил результат, который мне понравился.


В дальнейших планах дообучить эту конфигурацию до момента, когда станет ясно, на что она способна. Также в планах создание сети, которая бы увеличивала эти картинки до вменяемых размеров. Такое уже придумали и реализации есть.


Буду чрезвычайно рад конструктивной критике, хорошим советам и вопросам.

Теги:
Хабы:
+12
Комментарии 5
Комментарии Комментарии 5

Публикации

Истории

Работа

Data Scientist
66 вакансий
Python разработчик
130 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн