Генеративный CV. Часть 1
Дисклеймер: Это обзорный пост, а не deep dive. Он рассчитан и на тех, кто вообще не знаком с нейросетями и компьютерным зрением, и на опытных практиков. Я сознательно избегаю сложной математики — не потому, что её нет, а чтобы сначала донести суть идей. За каждым «простым» объяснением здесь стоит серьёзная теория, но начинать лучше с интуиции.
Представь, что ты просишь нейросеть нарисовать «белого кота».
Просто так. Без шаблонов, без чёткого «правильного ответа».
И вот тут возникает главная головоломка генеративного компьютерного зрения:
Как научить модельку создавать то, чего ещё не существует — и при этом делать это «правдоподобно»?
Ведь в той же задачи классификации всё просто: есть картинка, есть метка — сравнил, получил ошибку, поправил веса.
А в генерации? Где взять «эталон» для кота, которого никто никогда не видел?
Именно эта проблема породила целую эволюцию архитектур: от первых состязательных сетей (GAN), которые учились обманывать друг друга, до диффузионных моделей — тех самых, что сегодня рисуют всё: от аниме до рекламных баннеров.
Но даже диффузия — не конец пути. Сегодня лучшие модели уже переходят к ещё более элегантным идеям: flow matching, rectified flows, нейросетевые ОДУ — где генерация отходит от идеи «постепенного убирания шума».
В этой статье мы пробежимся по всему этому пути — c целью разобраться: как это вообще работает?
И главное — почему именно так?
GAN
Первые на разбор - это старые-добрые Ганчики. GAN — это первая успешная архитектура для генерации изображений. Название расшифровывается как Generative Adversarial Networks (генеративно-состязательные сети). Почему они «состязаются» - дальше расскажу.
Обучение нейросеток в традиционных задачах, таких как классификация или детекция, основано на наличии правильной разметки — так называемой ground truth. В таких задачах легко вычислить ошибку (или «штраф») модели, сравнив её предсказание с эталоном.
Однако в задачах генерации такой эталон отсутствует: у нас нет чёткого формализованного правила, по которому можно было бы напрямую сравнить сгенерированное изображение с ground truth.
Например, как оценить точность генерации по такому текстовому запросу:
«Нарисуй белого кота»?
Вообще непонятно, кто «лучше» — кажется, что здесь нет однозначного ответа. Оценить качество генерации сложно. Давайте сначала решим более простую задачу: научимся определять, является ли изображение реальным или сгенерированным.
Представим: нейросеть генерирует картинку, а мы должны решить — настоящая она или нет. Если сгенерированная, то сильно штрафуем генератор, чтобы он учился делать изображения правдоподобнее.
Но ведь мы не можем сидеть и вручную помечать каждое сгенерированное изображение как «реальное» или «фейковое» — это просто невозможно в масштабах реального обучения.
Именно так и появилась идея GAN: доверить эту задачу оценки другой нейросети. Мы обучаем дополнительную модель — дискриминатор, — которая сама учится отличать настоящие изображения от сгенерированных.
GAN — это уникальный подход в машинном обучении, где две нейросетки (генератор и дискриминатор) состязаются друг с другом:
- Генератор пытается создавать всё более правдоподобные изображения,
- Дискриминатор — всё лучше их распознавать.
Именно из-за этого противоборства такие модели и называются генеративно-состязательными.
Недостатки GAN
- Ограниченная обобщенность: на практике GAN плохо справляются с генерацией высококачественных изображений из разных доменов одновременно (например, одновременно гневить и аниме и фотореалистичные портреты).
- Сложность обучения: необходимо балансировать обучение двух моделей. Если дискриминатор станет слишком умненьким — мы будем получать нулевые градиенты, и обучение остановится. Если же генератор «обгонит» дискриминатор — тот перестанет учиться.
Кроме того, loss не отражает качество генерации: он не должна ни сильно убывать, ни сильно расти — оптимальное состояние находится в кое-каком равновесии. - Mode collapse — это такое явление, когда генератор находит лазейку, он старается минимизировать свой лосс, из-за чего может найти такой ограниченный набор картинок, которые дискриминатор не распознает как фейки и генерировать только их.
Преимущества GAN
- Высокое качество в узком домене: GAN отлично работают, если сфокусироваться на одной задаче — например, генерация лиц, повышение качества (super-resolution), изменение возраста или стиля на фото.
- Скорость генерации: GAN работают в разы быстрее диффузионных моделей, потому что генерируют изображение за один проход. Диффузионные модели требуют десятки или сотни итераций, а также обладают значительно большим числом параметров. Благодаря этому GAN можно запускать даже на CPU с приемлемой скоростью.
Самое интересное Примеры работы GAN на практике:
Ретушь фото
Повышение качества изображения
Процесс обучения
Процесс обучения схематично выглядит вот так:
# Генерация: случайный шум → сгенерированное изображение fake_image = generator(noise) # Оценка дискриминатором: насколько изображение "реальное" real_score = discriminator(real_image) # должно быть близко к 1 fake_score = discriminator(fake_image) # должно быть близко к 0 # Loss: дискриминатор учится отличать фейки, генератор — обманывать его loss_D = BCE(real_score, 1) + BCE(fake_score, 0) # лосс для дискриминатора loss_G = BCE(fake_score, 1) # лосс для генератора
BCE -это бинарная кросс-энтропия, классическая лосс функция для обучения классификатора. Все обучение моделей строится на лоссе классификации, того насколько точно дискриминатор распознает между собой фейки и оригиналы
Мы используем отдельную функцию потерь для обучения дискриминатора и обучения генератора, на практике можно использовать различные стратегии обучения, например по очереди учим генератор, дискриминатор замораживаем в это время, потом меняем их местами.
GAN'ы под капотом
Одна из самых простых реализаций GAN моделей - это 2 сверточные нейросети, на картинке генератор от DCGAN (Deep Convolutional GAN, 2015 год), там просто набор сверточных слоев, между которыми происходит апсемплинг. Дискриминатором, может быть любой сверточный классификатор - моделька, которая принимает изображение и выдает скор на него.
Заметьте: нейросетке всегда надо дать что-то на вход. Нейросети — это функции, которые преобразуют входной объект. А в случае генератора нам же надо получить от него картинку — причём «из ничего». Поэтому принято простое решение: давать на вход в генератор случайный шум, который он преобразует в итоговое изображение. Пускай это будет у нас такая заглушка для входа в модель. И за счёт того, что этот входной тензор всегда разный (случайный), итоговая генерация у нас тоже каждый раз будет разной.
Вот эта идея — брать случайный шум — может показаться костыльной… и не зря. В последующем подход, ставший SOTA среди GAN’ов — StyleGAN, — использовал нейросетевую трансформацию шума в другое латентное пространство. Ниже — схема работы преобразования входного шума в StyleGAN. Подробнее об этой модели, возможно, в следующем посте.
Автоэнкодеры
Это даже более ранняя идея построения архитектуры нейросетей для создания изображений, чем GAN’ы. Исторически сначала мы научились кодировать картинку в небольшой латент (вектор или тензор), который очень информативен — по нему, например, можно с лёгкостью классифицировать изображения.
А когда захотели не анализировать картинку, а генерировать, самое простое, с чего можно начать, — это научить модель хотя бы точь-в-точь повторять входное изображение. Пускай моделька пока не умеет генерить что-то новое — пусть сначала научится копировать входную картинку 1 в 1.
Для этого мы используем две нейросетки: одна кодирует входное изображение в латент, а вторая — разжимает его обратно в финальную картинку. Их называют енкодер и декодер.
Какой лосс использовать?
Вот так в коде у нас выглядит процесс обучения автоэнкодера, все достаточно просто
# Кодирование: изображение → скрытое представление (latent code) latent = encoder(image) # Декодирование: скрытое представление → восстановленное изображение reconstructed_image = decoder(latent) # Loss: насколько восстановленное изображение отличается от оригинала loss = reconstruction_loss(image, reconstructed_image) # l1_loss, l2_loss, ssim_loss и т.д.
Пока мы хотим просто повторить декодером входную картинку 1 в 1. Для этого используем так называемый reconstruction loss, им обычно является простейший MAE(L1 loss), MSE(L2 loss), SSIM (метод из классического CV для оценки сходства картинок) или Perceptual Loss (это нейросетевая оценка схожести картинок).
Недостатки MSE и попиксельных метрик
При использовании MSE для обучения автоэнкодера мы просто берём разницу между каждым пикселем исходной картинки и сгенерированной. Очевидно, что если наша генерация чуть-чуть съехала в сторону на пару пикселей, MSE может нас сильно подвести — он не способен оценить соответствие общего контекста и семантики двух изображений.
Ниже — пример шести картинок, у которых MSE примерно одинаковый, хотя для человеческого глаза очевидно, что они очень разные.
На практике с обучением через MSE часто наблюдается, что генерируемая картинка становится размытая как (e)
"Дырявое" латентное пространство
Если мы возьмем датасет MNIST (Картинки рукописных цифр от 0 до 9), обучим на нем классический автоэнкодер с MSE лоссом и затем проанализируем латентное пространство путем того, что прогоним все наши картинки через encoder и визуализируем латентные вектора мы увидим следующую картину:
Вектора разбросаны в сложных формах, есть много пустых пространств, ничем не заполненных. Такое распределение нельзя описать никакой известной нам математической функцией. А значит, во время генерации нам надо быть осторожными: нельзя просто взять рандомный вектор через np.random, подать его в декодер и ожидать нормальную картинку цифры. Если случайный вектор попадёт в такую «пропасть», сгенерируется что угодно — скорее всего, несуразная шумная мешанина.
Проблема решается улучшенным подходом к обучению — VAE (вариационным автоэнкодером). Всё то же самое, но немного видоизменяется лосс-функция и сам механизм получения латента: теперь мы берём случайный вектор не откуда попало, а из нормального распределения N(0, 1).
Если наше латентное пространство теперь соответствует реальному математическому распределению, мы легко можем генерить латент через np.random и подавать его в декодер — ведь теперь мы не промахнёмся и не попадём в «пустоту», из которой получается непонятно что.
Рассказать можно ещё ой как много, но давайте перейдём к практическим применениям автоэнкодеров в современном мире.
Почти везде сейчас используется вариант в виде VAE. Сама по себе идея классического автоэнкодера слабовата для генерации картинок: он учится только повторять входное изображение, и с него тяжело «выбить» топовое качество визуала. Зато он отлично работает как ZIP-архиватор для изображений.
Дело в том, что сегодня для генерации видео и картинок мы используем ООООЧЕНЬ большие модельки — диффузионки (о них поговорим дальше). Чтобы хоть как-то уменьшить нагрузку на видеокарты, решили сжимать входные данные.
Запустить диффузионку напрямую на картинке 1024×1024 — просто самоубийство. Поэтому мы сначала прогоняем изображение через Encoder от VAE, получаем маленький тензор размером примерно 64×64, и уже его подаём в диффузионную модель. На выходе диффузия выдаёт тоже 64×64 — и мы апсемплим его обратно до 1024×1024 с помощью Decoder от VAE, получая финальное изображение.
Именно так сегодня работает подавляющее большинство современных генераторов: FLUX, Hi-Dream, WAN и многие другие.
VAE обучается отдельно от диффузионной модели. Во-первых, он небольшой — его веса весят около 400 МБ, тогда как сама диффузия — 10–35 ГБ. Во-вторых, автоэнкодер удобно и быстро обучить на всех имеющихся картинках: никаких сложных трюков при тренировке придумывать не нужно, разметка тоже не требуется — входная картинка сама себе и есть таргет для подсчёта лосса.
Диффузионки
Заключительный пункт — про подход, который уже стал классикой в генерации изображений и видео.
Нам очень мешает жить и достигать успеха одна старая проблема: как придумать лосс-функцию для оценки сгенерированной картинки? Мы до сих пор не можем найти оптимальный способ «штрафовать» модель — ведь что для одного человека «шедевр», для другого — «мазня».
Давайте немного отвлечёмся и посмотрим, что происходит в соседних областях: в NLP и классическом ML. Какие там самые успешные идеи?
В топе — архитектуры, где предсказание делается не за один раз, а постепенно.
В NLP это, например, GPT-трансформеры: они генерируют текст слово за словом, а не выдают сразу весь абзац.
В классическом ML лучше всего работают бустинги — там мы последовательно используем маленькие решающие деревья, и каждое делает небольшое уточнение к предыдущему предсказанию.
А почему тогда в компьютерном зрении мы всё ещё надеемся решить, пожалуй, самую сложную задачу во всём ML — генерацию изображения или видео — за один проход?
Люди ведь тоже рисуют постепенно: сначала набросок, потом тени, детали, финальные штрихи. Никто не выдаёт готовый шедевр «из головы» за один миг. Так почему бы и модели не дать право на итеративное уточнение?
Можно надеяться, что итеративное предсказание через небольшие фиксы предыдущего предикта — это рабочая идея.
Начинаем думать: как разбить процесс генерации на несколько этапов, а не выдавать финальную картинку за один проход, как это делают GAN или VAE? Да, придётся запускать нейросеть несколько раз — будет медленнее, но выхода нет.
Проблема в том, что у нас нет датасетов, где показано, как картинка создаётся пошагово — от белого листа до финального результата. У нас есть только готовое изображение, уже «доделанное».
А нам нужно для каждой картинки создать последовательность промежуточных версий — чтобы нейросеть училась постепенно «вырисовывать» результат.
Пока единственное, что мы можем сделать, — это сами испортить готовое изображение, тем самым имитируя обратный процесс его создания с нуля.
Будем делать это через зашумление: постепенно, маленькими дозами добавлять шум к чистой картинке, пока не получим белый шум — примерно как экран помех на старом телевизоре.
Forward process
Ну и вот — мы получили много шумных версий исходной картинки: каждая следующая хуже предыдущей, всё менее разборчивая. Этот процесс зашумления называется forward-процессом, и он реализуется по простой формуле.
Мы всего лишь один раз сэмплируем случайный шум в самом начале, а потом на каждой итерации просто усиливаем его — за счёт домножения на скалярные коэффициенты.
Таким образом, в коде из одного и того же шума мы можем получить N версий зашумлённых изображений. И для каждой из них мы точно знаем, какой шум был добавлен — как относительно исходной картинки, так и относительно предыдущего шага.
Я сознательно опускаю то безумное обилие математических формул и терминов, которые встречаются в любой академической статье про диффузионки. Они, честно говоря, не нужны для понимания сути подхода и тем более — для его применения в реальных проектах.
Если чуть копнуть глубже, то на самом деле во время forward-процесса мы решаем такую задачу:
любую картинку — а точнее, её распределение (под «распределением» давайте понимать просто гистограмму: как часто встречаются те или иные значения пикселей) — нужно превратить в стандартное нормальное распределение N(0, 1).
И оказывается, этого можно добиться простой итеративной процедурой: постепенно добавляя гауссовский шум к исходному распределению. Код для визуализации — тут.
Пусть у нас есть такая картинка на вход — она очень тёмная: большинство пикселей сосредоточено около нуля (напомню, диапазон значений — от 0 до 255, где 0 — чёрный, а 255 — белый). Её гистограмма сильно прижата к нулю.
И вот что будет происходить с гистограммой, когда мы добавляем последовательно шум.
По итогу, мы нашли функцию которая позволяет любое распределение(любую картинку) свести к нормальному распределению N(0,1) (к полностью шумной картинке). Именно такую шумную картинку мы на инференсе будем подавать в нейросеть, чтобы она из нее выдала итоговое предсказание, похоже на принцип работы GAN.
Backward process
Во время Backward процесса мы решаем более сложную задачу, теперь надо из нормального распределения получить распределение соответствующей реальной картинке. Это уже не получится сделать простой арифметической функцией, тут мы нейросеть используем в качестве очень-очень сложной математической функции, которая способно выполнить такое преобразование.
И мы будем расшумлять картинку постепенно — много раз подряд убирая с неё немного шума.
Правда, так происходит только во время инференса, когда мы можем себе позволить запустить нейросеть 10–50 раз подряд на одной и той же картинке.
А во время трейна мы делаем чуть иначе: формируем батч из совершенно разных изображений, и каждому случаем образом присваиваем степень зашумленности — от 1 до 1000.
В итоге все картинки в батче имеют разную степень зашумленности, и модель учится обрабатывать любой уровень шума за один проход.
Как устроен лосс?
Диффузионка берёт на вход шумную картинку, а также получает дополнительную информацию — номер итерации (от 1 до 1000), то есть насколько сильно картинка зашумлена. Этот номер подаётся в виде эмбеддинга.
На выходе модель пытается предсказать исходный шум — тот самый ε ~ N(0, 1), который был добавлен в самом начале forward-процесса.
А в качестве лосса используется простой MSE между предсказанным и настоящим шумом.
Одна из самых сложных архитектур в современном deep learning учится с самым простым лоссом, какой только можно представить — обычной разницей значений в квадрате.
Но на самом деле всё немного сложнее: MSE не взялся с потолка. Он естественным образом вытекает из ELBO (вариационной нижней границы правдоподобия) при определённых упрощениях и благодаря свойствам нормального распределения. Вывод этой формулы занимает целую страницу, так что мы его пропустим — знать это для практики совсем не обязательно.
Метод, описывающий такой подход, называется DDPM (Denoising Diffusion Probabilistic Models).
Сегодня оригинальный DDPM почти не используется — сфера шагнула вперёд. Современные топовые генеративки вроде FLUX, Hi-Dream и других уже перешли на flow-matching подход. Суть — всё та же: постепенная генерация. Но лосс и сам процесс обучения теперь устроены иначе.
Плюсы диффузионок
- На практике оказалось, что подход к итеративной генерации легко обучается и обгоняет по качеству прежние методы
- Диффузионки хорошо скейлятся, это значит, что увеличение числа параметров модели ведет к увеличения качества генерации, что не всегда так
- Один набор весов способен покрывать огромное количество доменов для генерации, т.е в отличии от ГАН'ов мы одной моделью можем генерировать что угодно и аниме, и фотореалистичные картинки и тд
- Легко дообучаются такими методами как LoRA, даже на пользовательских видеокартах
Минусы диффузионок
- Медленно работают, требуют N раз запускать модель в цикле, из-за чего время инференса на одну картинку может достигать нескольких минут, а для генерации видео минут 20
- Потребляет много видеопамяти, диффузионки для видео требуют под 80ГБ
Заключение
Ну вот и все, мы закончили наш беглый осмотр сферы генеративного CV. Это, конечно же, только верхушка айсберга — внутри ещё множество огромных и интересных подобластей: генерация 3D, нейросетевая параметризация сцен, синтез видео с пространственно-временной когерентностью, генерация с условием (текст, маска, эскиз, глубина), персонализированная генерация (LoRA, textual inversion, DreamBooth), анимация изображений, инверсия реальных объектов в латентное пространств, синтез данных для автопилотов