Возможно, вы встречали изображения, в которых смешаны два образа. Вблизи виден один, а издалека - другой. Например, Эйнштейн и Мэрилин Монро.

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

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

Преобразование Фурье - прошлый век, новые проблемы требуют инновационных решений.

Код и исходные картинки лежат на гитхабе.

Для запуска понадобится tensorflow, numpy и Pillow. Для последней версии tensorflow понадобится python 3.7 или новее.

Чтобы результат получался за пару минут при вычислении на процессоре, я уменьшил картинки до размера 512х512 пикселей. Взял их из википедии: шахматы и девушка Лена.

Шахматы
Девушка Лена

Загрузим их и переведём во float в интервале от 0 до 1:

lena = Image.open("imgs/Lenna512.png")
chess = Image.open("imgs/ChessSet512.png")
imgs = [np.array(i) / 255.0 for i in [lena, chess]]

Гамма-коррекция

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

Для перевода в физическое значение яркости существует гамма-коррекция: возведение яркости в степень 2.2. Получится величина, пропорциональная количеству фотонов с экрана. Обратное преобразование тоже делается просто - возведением в степень 1/2.2.

Зачем всё это нужно? Если я хочу узнать видимую разницу цветов, можно просто взять разницу значений в обычном RGB пространстве.

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

Размытие по Гауссу

При размытии картинки каждый пиксель превращается в пятнышко. На математическом языке пятнышко называется ядром, а сам процесс - свёрткой. Ядро выглядит как-то так:

Ce^{- \frac{dx^2+dy^2}{2 \sigma^2}} = C e^{-\frac{dx^2}{2 \sigma^2}} e^{-\frac{dy^2}{2 \sigma^2}}

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

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

def make_gauss_blur_kernel(size: int, sigma: float) -> np.ndarray:  
    result = np.zeros(shape=[size], dtype=float)  
    center = (size - 1) // 2  
    div = 2 * (sigma ** 2)  
    for i in range(size):  
        x2 = (center - i)**2 
        result[i] = math.exp( -x2 / div)  
    return result / np.sum(result)  
  
make_gauss_blur_kernel(size=11, sigma=2)

Математически ядро бесконечное, но мы ограничим его размеры. Например, для sigma =2 и размера ядра в 11 получится так:

array([0.00881223, 0.02714358, 0.06511406, 0.12164907, 0.17699836, 0.20056541, 0.17699836, 0.12164907, 0.06511406, 0.02714358, 0.00881223])

Tensorflow 2.0

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

Магия выглядит вот так:

with tf.GradientTape() as tape:
    # computations
    loss = ...
    
gradient = tape.gradient(loss, trainable_variables)    

Это как раз то, что нам нужно.

Создаём модель

class MyModel:
    def __init__(self, img_h: int, img_w: int, gauss_kernel_size: int, gauss_sigma: float, image_source: Optional[np.ndarray] = None):
        if image_source is None:
            image_source = np.zeros(shape=(1, img_h, img_w, 3), dtype=float)
        self.trainable_image = tf.Variable(initial_value=image_source, trainable=True)
        gauss_blur_kernel = make_gauss_blur_kernel(gauss_kernel_size, gauss_sigma)
        self.gauss_kernel_x = tf.constant(gauss_blur_kernel[np.newaxis, :, np.newaxis, np.newaxis] * np.ones(shape=(1, 1, 3, 1)))
        self.gauss_kernel_y = tf.constant(gauss_blur_kernel[:, np.newaxis, np.newaxis, np.newaxis] * np.ones(shape=(1, 1, 3, 1)))
        
    ...

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

Для свёрток используется аж четырёхмерное ядро:

  1. ось У изображения

  2. ось Х изображения

  3. количество входных каналов (3)

  4. количество выходных каналов на каждый входной канал (1, каждый цвет переходит сам в себя)

Кроме того, и в numpy и в tensorflow есть идея броадкастинга. Например, массив с размерностями (1, 256, 512, 1) можно интерпретировать как (N, 256, 512, C). Как будто по первой и последней оси размер произвольный, а числа одни одни и те же и не зависят от координаты вдоль той оси. Броадкастинг в этих библиотеках иногда работает по-разному, функция свёртки хочет увидеть ядро размерностью именно (size_x, size_y, 3, 1) и почему-то недовольно массивом (size_x, size_y, 1, 1), так что пришлось в numpy умножить на массив единичек размерностью (1, 1, 3, 1). Если бы мы сделали разные ядра свёрток для разных цветов, то нам бы пригодилась эта размерность, но у нас всё одинаково.

Я использую поканальные свёртки (чтобы канал с цветом при размытии влиял только на себя и превращался в новый канал с тем же цветом). Такие свёртки считаются быстрее обычных. А ещё внутри нет никакого преобразования Фурье, и сложность вычисления свёртки зависит линейно от размера ядра. По этой же причине свёртка с квадратным ядром размера 15x15 будет считаться в несколько раз дольше, чем две свёртки с ядрами 1х15 и 15х1.

class MyModel:
    ...

    def run(self, img_precise: np.ndarray, img_blurred: np.ndarray, m_precise = 1, m_blurred = 1) -> Report:
        with tf.GradientTape() as tape:
            # next code will be here

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

Обучаемые переменные могут быть любыми, хоть -1, хоть 9000. Но в качестве цветов картинки хочется получить значения в интервале от 0 до 1. Для этого применим сигмоиду: около нуля она более-менее линейно растёт, но на больших входных значениях рост замедляется и результат никогда не превысит единицу.

trainable_image01 = tf.math.sigmoid(self.trainable_image)

Для первой картинки (которая должна быть резкой) разница - просто сумма квадратов разностей яркости для каждого канала каждого пикселя. По-идее, каждый пиксель учится независимо, и шаг обучения в 0.1 будет вполне нормальным.

Вместо суммы можно было бы использовать reduce_mean(), но тогда градиенты были бы меньше на площадь картинки (512x512) и пришлось бы градиенты умножать на что-то типа 10^5.

loss_precise = tf.reduce_sum(tf.square(trainable_image01 - img_precise[np.newaxis, :, :, :]))

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

blurred = self.gauss_blur(trainable_image01 ** 2.2) ** (1.0 / 2.2)
blurred_label = self.gauss_blur(img_blurred[np.newaxis, :, :, :] ** 2.2) ** (1.0 / 2.2)
loss_gauss = tf.reduce_sum(tf.square(blurred - blurred_label))

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

loss = loss_precise * m_precise + loss_gauss * m_blurred

Вжух и получим градиенты:

gradient = tape.gradient(loss, self.trainable_image)

Для обучения есть разные оптимизаторы, но я прямо руками сделал простой градиентный спуск:

def apply_gradient(self, grad: np.ndarray, lr: float):
    self.trainable_image.assign(self.trainable_image.numpy() - lr * grad)

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

class MyModel:
    ...
    def train(self, steps_count: int, print_loss_steps: int, lr: float, **run_kwargs) -> Report:  
        for i in range(steps_count):  
            r = self.run(**run_kwargs)  
            model.apply_gradient(r.gradient, lr)  
            if i % print_loss_steps == print_loss_steps - 1:  
                print(f"{i}: loss = {r.loss}, precise = {r.loss_precise}, gauss = {r.loss_gauss}")  
        return r

Теперь всё готово для обучения модели:

model = MyModel(512, 512, gauss_kernel_size=15, gauss_sigma=3)
r = model.train(steps_count=200, print_loss_steps=50, lr=0.3, img_precise=imgs[0], img_blurred=imgs[1], m_precise=0.1, m_blurred=1.0)  
Image.fromarray(np.uint8(r.image * 255.0))

Я попробовал разные коэффициенты (0.1, 0.3, 1.0).

Картинки тут

А зачем всё это?

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

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

При помощи преобразования Фурье и вырезания высоких частот с одной картинки и низких с другой можно получить похожий эффект. Но с некоторым оговорками: тоже могут получаться значения яркости меньше 0 и больше 1. И я не знаю, как сочетать логарифмическое восприятие яркости человеческим глазом и необходимость делать размытие по Гауссу в линейном цветовом пространстве. Я попробовал, результат мне не понравился. Пруфов не будет.

Вариант с обучением на порядки медленнее Фурье и на моём ноутбуке занимает несколько минут. На мой взгляд это не страшно, так как намного больше времени я потратил на написание кода. У меня нет задачи генерировать тысячи картинок, одной-двух вполне достаточно.

Изначальное состояние обучаемой модели - серая картинка. Если важна производительность, можно в качестве первого приближения взять одну из картинок или даже результаты экспериментов с частотами и Фурье. Но в случае с одной картинкой написание и отладка этого кода займёт больше времени, чем обучение с нуля, а результат будет примерно тот же.

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


  1. napa3um
    25.11.2021 03:42
    +3

    Оправдывается ТОЛЬКО нежеланием (невозможностью) задать алгоритм аналитически, но пониманием критериев результата. Получилось что-то типа вычисления результата выражения 2+3 брутфорсом или монтекарлой :). А так да, тензорфлоу - это не про нейросетки, а про массово-параллельные вычисления в бОльшей степени (нейросетки лишь популярная аппликация этих вычислений).


  1. nulovkin
    25.11.2021 04:15

    Извините, что я туплю, но насколько бы отличалась картинка, где каждый второй пиксель просто заменен на соответствующий ему с другой картинки?


    1. lgorSL Автор
      25.11.2021 04:42

      Тогда две картинки будут просто смешаны в одной. А в моём варианте с большого расстояния видно только одну картинку, вторая исчезает (особенно сильный эффект получился с девушкой и листьями)


      1. napa3um
        25.11.2021 05:07
        +1

        Если была такая цель, то, кажется, достаточно одну фотку фильтрануть высокочастотным фильтром (шарпировать), другую низкочастотным (заблюрить), а потом тупо смешать (каждый второй пиксель или альфа-каналом). Низкочастотная картинка будет видна издалека, высокочастотная - вблизи. Я сварщик не очень настоящий, чего я тут не учёл?

        «Энтропийную» фильтрацию можно свалить на человеческий глаз по заветам джпега - просто одну картинку обесцветить (вместо шарпирования, но можно и с ним для усиления :)). Цветовая составляющая зрения у человеков имеет ниже разрешающую способность и теряет высокочастотную инфу при отдалении картинки быстрее, чем чёрно-белая (яркостная) составляющая. (На самом деле разрешающая способность различается даже между красной, синей и зелёной составляющей, хотя и не так сильно, но, наверное, игра с отдельными каналами RGB исходных картинок тоже добавит эффекта).

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


        1. lgorSL Автор
          25.11.2021 05:34

          Я сварщик не очень настоящий, чего я тут не учёл?

          При смешении картинок будет блёклый результат. Если одну из картинок обесцветить - тем более.

          P.s. В репозитории есть картинки, сделайте за минуту в Фотошопе и выкладывайте сюда, чтобы можно было сравнить.


          1. napa3um
            25.11.2021 05:58
            +1

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

            XCF-файл со слоями - https://github.com/napa3um/trash
            XCF-файл со слоями - https://github.com/napa3um/trash

            (Да, не совсем с тупой прозрачностью решение, писал навскидку прост. Этот режим совмещения превращает отклонение от серого в альфу или типа того, там тоже нет матана, обработка попиксельная. Я только что установил Гимп, но рисовал картинку буквально 50 секунд, клянусь :). Если надо заскриптовать, то все эти три операции можно превратить в три строчки кода на OpenCV или каком-нибудь ImageMagic.)


            1. lgorSL Автор
              25.11.2021 06:24

              Да, довольно похоже, но в деталях по-другому выглядит. Интересно сравнить.

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

              Мне кажется, вариант с белёсыми границами сильнее влияет на оттенки: даже после размытия красный цвет с тонкой беловатой линией уже не будет чисто красным. Если делать через Фурье, то вылазит примерно та же проблема (ну либо отрицательные цвета, что не лучше)


              1. napa3um
                25.11.2021 06:29

                Можете взять XFC-файл и покрутить значения фильтров, чтобы добиться попиксельной идентичности, я их тыкнул почти случайные, прост показать принцип :). А можете и не идентичности добиваться, а ещё более качественного на ваш взгляд результата - вы сразу видите изменения, не надо гадать с параметрами, ожидая обучения (пусть даже и всего пару минут).


  1. MegaMANGO
    25.11.2021 11:15

    Интересно. Я сам по себе ещё нифига не понимаю в машинном обучении, а подобные посты помогают понять лучше. Может кто то из профи посчитает, что тут всё сделано "не так", но как по мне лучше сделать плохо, чем никак. Но учитывая, что ТАКОЕ собирает минус карму, на Хабр я, наверное, писать начну не скоро (с моим уровнем ниже Джуна меня закидают говном за нубство. И я их даже понимаю). А так, вроде неплохая платформа, этот ваш Хабр. Прочитал статьи вроде этой, понравились. Очень много экспериментального кода, который хотелось бы самому написать и доработать.


    1. lgorSL Автор
      25.11.2021 16:28

      Если хочется написать пост - пишите! Ради себя и ради возможности донести свою мысль до других. Я пишу когда захочется, получаются одна-две статьи в год. Какие-то удачные, какие-то не очень. Ну будет первый блин комом, самое "страшное" что грозит - снижение абстрактной цифры или негативный комментарий от незнакомого человека.


  1. major-general_Kusanagi
    25.11.2021 12:29

    Возможно, вы встречали изображения, в которых смешаны два образа. Вблизи виден один, а издалека — другой. Например, Эйнштейн и Мэрилин Монро.

    Результат статьи и близко не такое похож. :(
    Всё что в статье можно получить за полминуты в фотошопе играя с прозрачностями верхнего слоя картинки.
    Суть этих картинок в том, что натуральная человеческая нейросеть распознаёт и Энштейна и Мерлин одновременно. А ваша нейросеть тупо мешает две картинки. Но на самом деле, вам нужно было создать нейросеть распознающую картинку, а другая нейросеть должна была учиться генерировать такую картинку, чтобы первая нейросеть её определяла как «50% шахматы; 50% девушка».


    1. Quiensabe
      26.11.2021 02:00
      +1

      Автор говорит о вот таком типе изображений. Результат вполне сопоставим.


  1. lozy_rc
    27.11.2021 13:13

    Интересно попробовать в аугментации при обучении полновесных моделей, всяких yolo или unet, как это повлияет на переобучение?