Кадр из итогового видео
Кадр из итогового видео

Привет, Хабр. Мне не удалось найти русскоязычные статьи, посвященные генерации артов с помощью архитектуры CPPN, поэтому я сам расскажу о том, что можно с ней сделать. Это позволит скрасить пару вечеров и сгенерировать себе, например, обои на рабочий стол. А может и придумать что-нибудь серьезное. 

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

Что это?

Compositional pattern-producing network относится к семейству нейросетей, идеально подходящих для обучения с помощью генетических алгоритмов. Генетические алгоритмы прежде всего нужны для оптимизации чего угодно. Но так как я не об этом собираюсь рассказывать, то уточню лишь, что CPPN способна генерировать паттерны, форма которых зависит от использованных функций активаций. Мы даже можем научить группу таких нейросетей генерировать нужные нам паттерны с помощью отбора.

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

Можно описать CPPN как функцию представления пространственной позиции каждого пикселя в его интенсивность. Проще говоря, мы имеем набор слоев и активаций, применяемых ко входному изображению, а на выходе полчаем арт.

Проба кисти

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

... перемножить их, применить какую-нибудь функцию (сигмоида, тангенс и др.) и готово.

Покажу на примере. Сначала сделаем пространственную сетку. Цветное изображение - это 3D-массив, в каждой точке каждого измерения которого закодирована интенсивность красного, зеленого и голубого цветов соответственно. Для того, чтобы результат генерации получился хоть сколько-нибудь интересным, нам нужно изначальную сетку заполнить какими-либо неслучайными значениями. Обычно используют градиент цветов. Но ваша фантазия не ограничена.

import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import minmax_scale

nrows = 512
ncols = 512

rowmat = (np.tile(np.linspace(0, nrows-1, nrows, dtype=np.float32), ncols).reshape(ncols, nrows).T - nrows / 2.0) / (nrows / 2.0)
colmat = (np.tile(np.linspace(0, ncols-1, ncols, dtype=np.float32), nrows).reshape(nrows, ncols) - ncols / 2.0) / (ncols / 2.0)
inputs = np.stack([rowmat, colmat, np.sqrt(np.power(rowmat, 2) + np.power(colmat, 2))]).transpose(1, 2, 0)
# преобразуем 3-d вектор в 2-d вектор
grid = inputs.reshape(-1, 3).astype(np.float32)

# Сетка готова, далее сделаем обратное преобразование для визуализации
img = minmax_scale(grid)
img *= 255.
img = img.reshape(nrows, ncols, 3).astype(np.int32)
plt.figure(figsize=(10, 10))
plt.imshow(img)
Исходная сетка
Исходная сетка

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

def build_cpnn(nlayers, hsize):

    cppn = []
    for i in range(0, nlayers):
        if i == 0:
            mutator = np.random.randn(3, hsize)
        elif i == nlayers - 1:
            mutator = np.random.randn(hsize, 3)
        else:
            mutator = np.random.randn(hsize, hsize)
        mutator = mutator.astype(np.float32)
        cppn.append(mutator)
        
    return cppn
  
nlayers = 8
hsize = 32

cppn = build_cppn(nlayers, hsize)
gen_img = grid.copy()

for layer in cppn:
    gen_img = np.tanh(np.matmul(gen_img, layer))
    
gen_img = minmax_scale(gen_img)
gen_img *= 255.
gen_img = gen_img.reshape(nrows, ncols, 3).astype(np.int32)
plt.figure(figsize=(10, 10))
plt.imshow(gen_img)
Готовый арт
Готовый арт

Вот и все, первый арт готов. Для того, чтобы оценить, как влияет количество слоев и размер скрытого пространства на итоговое изображение, я покажу такую картинку:

Слева напрово увеличиваю размер скрытого пространства, сверху вниз - количество слоев.
Слева напрово увеличиваю размер скрытого пространства, сверху вниз - количество слоев.

Очевидно, что можно экспериментировать и далее, меняя функции активации и размеры скрытого пространства хоть на каждом слое. Для этого, конечно, удобнее уже соорудить какой-нибудь класс, например, так:

import torch
import torch.nn as nn

class CPPN(nn.Module):
    

    def __init__(self, inp_dim=3, hid_dim=64, n_layers=8, activation='tanh'):
        super(CPPN, self).__init__()

        self.activations = {
            'sigmoid': nn.Sigmoid(),
            'tanh': nn.Tanh(), 
            'relu': nn.ReLU(),
            'shrink': nn.Hardshrink()
        }
        
        self.n_layers = n_layers
        self.input_layer = nn.Linear(inp_dim, hid_dim)
        self.layer = nn.Linear(hid_dim, hid_dim)
        self.out_layer = nn.Linear(hid_dim, 3)
        self.activation = self.activations[activation]
        

    def forward(self, inputs):
        x = self.input_layer(inputs)
        x = self.activation(x)
        for i in range(self.n_layers-2):
            x = self.layer(x)
            x = self.activation(x)
        x = self.out_layer(x)
        x = self.activation(x)
        return x
      
# функция для случайной инициализации весов
def init_weights(m):
    if isinstance(m, nn.Linear):
        torch.nn.init.normal_(m.weight)
        m.bias.data.fill_(0.01)
        

model = CPPN(activation='softmax')
model.apply(init_weights)
tensor_grid = torch.tensor(grid)
output = model(tensor_grid).detach().numpy()

Ниже результат работы сетей с разными активациями.

Пример работы различных активаций: sigmoid, tanh, relu, shrink
Пример работы различных активаций: sigmoid, tanh, relu, shrink

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

Добавляем движение.

Как мы помним, CPPN - по сути функция от пространственных координат, поэтому чтобы добавить движение, мы добавим пространство. А именно дополнительное измерение, в которое будем вносить периодический сигнал. А лучше два измерения (так получается интереснее. Если оставить только одну составляющую, то картинка будет лишь скучно пульсировать, сжимаясь и растягиваясь относительно центра).

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

import os
from tqdm.auto import tqdm
import cv2


model = CPPN(inp_dim=5, activation='shrink')
model.apply(init_weights)

def generate_frame(model, cost, sint):
    
    inputs = np.stack([
        rowmat, 
        colmat, 
        np.sqrt(np.power(rowmat, 2) + np.power(colmat, 2)),
        cost * np.ones(rowmat.shape),
        sint * np.ones(rowmat.shape)
    ]).transpose(1, 2, 0)
    grid = inputs.reshape(-1, inputs.shape[2]).astype(np.float32)
    tensor_grid = torch.tensor(grid)
    
    output = model(tensor_grid).detach().numpy()
    
    img = minmax_scale(output)
    img *= 255.
    
    return img.reshape(nrows, ncols, 3).astype(np.int32)
  
  
os.system('mkdir -p frames/')
n = 360
freq = 1.0/float(n)

for t in tqdm(range(0, n)):

    cost = np.cos(2 * np.pi * freq * t)
    sint = np.sin(2 * np.pi * freq * t)
    frame = generate_frame(model, cost, sint)
    cv2.imwrite('frames/%06d.png' % t, frame)

# делаем видео из изображений
os.system('ffmpeg -r 30 -f image2 -i frames/%06d.png -crf 20 -vcodec libx264 -pix_fmt yuv420p out.mp4')
Чтобы пример вместился в статью, пришлось его ускорить. Осторожно, контент может вызвать головокружение и так далее.

Извлекаем частотные признаки из аудио

Я не буду вдаваться в теорию звука. Главное, что нужно знать: любой аудиофайл сэмплируется с определенной частотой дискретизации. Сэмплирование - это способ представить непрерывный сигнал в виде дискретных значений. Стандарт частоты дискретизации, который подтверждается теоремой составляет 2 * B, где В - предел слышимой частоты человеческого уха (22000 Hz). Любая современная песня имеет частоту дискретизации 44100 Hz. Проще говоря, каждая секунда аудио трека раскладывается на 44100 значений.

Чтобы извлечь частотные признаки из столь длинного списка, необходимо дробить массив значений по пересекающимся чанкам с определенным шагом. Шаг выберем, отталкиваясь от частоты кадров итогового видео, пусть будет 30 fps, а шаг тогда будет 44100 / 30 = 1470. Ширину чанка можно выбрать эмпирически, пусть будет 2048, что соответствует 46 мс.

Для каждого слайса аудиосигнала мы применяем быстрое преобразование Фурье, которое отдает нам информацию об интенсивности частот на заданном чанке. Результат возвращается в виде комплексных чисел, представляющих спектр частот от 0 до 22050 Hz. Мы можем простым слайсом вбрать из них интересующие нас (бас, середину, верха) и работать далее с ними.

Колебания в самом начале трека на разных частотах
Колебания в самом начале трека на разных частотах

Я же выбрал восемь групп частот и решил добавить их все на видео в разных частях картинки. Одна из проблем - частоты в разном масштабе, поэтому их надо шкалировать. Иначе интенсивность колебаний в одной части картинки будет непропорционально выше, чем в другой. А чтобы в принципе колебания были не столь сильными, их лучше всего сгладить.

В результате, весь код, отвечающий за обработку аудио сигнала, оборачиваем в класс:

Audio processing
import numpy as np
from scipy.io import wavfile


class AudioProcessor:

    
    def __init__(self, track_path, fps=30, wsize=2048):
        
        fs, sound = wavfile.read(track_path)
        self.sound = sound[:, 0]

        self.wsize = wsize
        self.stride = int(fs / fps)


    def condense_spectrum(self, ampspectrum):

        bands = np.zeros(8, dtype=np.float32)

        bands[0] = np.sum(ampspectrum[0:4])
        bands[1] = np.sum(ampspectrum[4:12])
        bands[2] = np.sum(ampspectrum[12:28])
        bands[3] = np.sum(ampspectrum[28:60])
        bands[4] = np.sum(ampspectrum[60:124])
        bands[5] = np.sum(ampspectrum[124:252])
        bands[6] = np.sum(ampspectrum[252:508])
        bands[7] = np.sum(ampspectrum[508:])

        return bands

    
    def get_amplitudes(self, scale=True, scale_rate=0.1, alpha=0.8):
        
        amplitudes = []
        n_samples = len(self.sound)

        for i in range(int(np.ceil(n_samples / self.stride))):
            
            chunk = self.sound[i*self.stride: i*self.stride+self.wsize]
            
            if len(chunk) < self.wsize:
                padsize = self.wsize - len(chunk)
                chunk = np.pad(chunk, (0, padsize), constant_values=0)
            
            # сигнал симметричный, поэтому оставляем половину
            freq = np.fft.fft(chunk)[:self.wsize//2]
            amplitudes.append(self.condense_spectrum(np.abs(freq)))
        
        amplitudes = np.stack(amplitudes)

        if scale:
            amplitudes = scale_rate * amplitudes / np.median(amplitudes, axis=0)
        
        if alpha != 1.:
            result = amplitudes.copy()
            for i in range(1, amplitudes.shape[0]):
                for j in range(amplitudes.shape[1]):
                    result[i, j] = alpha * result[i-1, j] + (1 - alpha) * amplitudes[i, j]
            amplitudes = result

        return amplitudes

Возвращаясь назад. Аппроксимация

Я хотел какой-то определенности в качестве отправной точки, а не случайный арт, поэтому решил обучить нейросеть предсказывать конкретное изображение по конкретной исходной сетке. Звучит так же просто, как и делается. Добавляем MSELoss, какой-нибудь оптимизатор (Адам хорошо показывает себя) и пару тысяч эпох скармливаем одну картинку нашей нейросети.

Слева референс, справа - аппроксимация
Слева референс, справа - аппроксимация

Итог

Итак, мы получили 8 амплитуд на каждый фрейм. Добавляем их все в 4-е измерение нашего грида, а в 5-м оставляем коснусный сигнал, который будет что-нибудь плавно менять. Повторяем все шаги по генерации изображений и объединяем их вместе с аудио треком в видео.

Пример результата
Пример результата

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

Комментарии (0)