В процессе работы над небольшим учебным проектом родилась идея системы, которой захотелось поделиться. Верхнеуровнево она до безобразия проста: на вход подаём спутниковый снимок, на выходе имеем изображение местности, в середине — обученная этому действу нейронная сеть. Забегая вперёд, работает подход (в самом минимальном приближении) примерно вот так:
Таким образом задача искомого решения такая: интерпретировать спутниковый снимок (и только его) и на основе выявленных закономерностей сгенерировать фотореалистичное изображение местности (виртуальный наблюдатель стоит на земле в центре координат спутникового изображения и делает снимок того, что видит).
Общая картина
Чтобы «съесть» этого слона, делим его на две части: первая (энкодер) представляет спутниковый снимок в виде набора признаков, вторая (генератор) создает картинку, поглядев на признаки, полученные энкодером. Набор всевозможных состояний извлеченных признаков формирует так называемое скрытое пространство.
Для обучения подобной системы, какой бы она не была по своей структуре, нужен набор данных, включающий в себя связанные географическими координатами пары спутниковых снимков и фотографий местности. Примерно таких:
Процесс обучения итоговой пары моделей энкодера и генератора можно разбить на два этапа:
Обучение генеративно-состязательной сети для создания фотографий из состояний скрытого пространства
Обучение сети энкодера, преобразующего признаки спутникового снимка в состояние скрытого пространства
Первый этап не зависит от второго, его итогом является модель-генератор, создающая изображения, схожие представленным в датасете. На втором этапе, имея уже работающий генератор, энкодер обучается заставлять его генерировать пейзажи схожие исходным, в точках, где вторые были сняты.
Формирование датасета
Подходящего датасета фотографий ландшафта (без людей, машин и прочих посторонних предметов) с геопривязками найти не удалось. Поэтому в моем случае источниками изображений явились API VK (для изображений местности) и Яндекс Карт (для спутниковых снимков).
Изображения местности
Отбор подходящих для обучения генератора пользовательских фотографий из VK — та ещё задача. Потребовался многостадийный процесс их фильтрации, чтобы оставить ограниченный набор сцен для обучения генератора. Получить сырой набор публичных фотографий просто, вот пример такой функции:
def get_user_photos(lat, long, radius, time_range, offset=0):
params = {
'lat': lat,
'long': long,
'count': '1000',
'offset': offset,
'radius': radius,
'start_time': time_range[0],
'end_time': time_range[1],
'access_token': VK_ACCESS_TOKEN,
'v': VK_VERSION,
'sort': 0
}
return requests.get("https://api.vk.com/method/photos.search",
params=params, verify=True).json()
Широта, долгота, радиус и временной промежуток поиска. Но пользовательский контент — это быт за редким счастливым исключением. В общем виде последовательный процесс получения итогового набора изображений вышел следующим:
Получение списка публичных фотографий, загруженных пользователями
Отсев изображений, не имеющих привязки к географическим координатам
Отбор изображений с помощью сторонней сети-классификатора согласно списку подходящих классов (в моем случае всяческая природа)
Отсев изображений, содержащих фигуры и лица людей (также используя предобученную стороннюю нейросеть)
Ручной отбор неподходящих снимков, пропущенных предыдущими фильтрами
В итоге из всего загруженного объема фотографий остается всего около 0.5%, что делает создание подходящего датасета настоящим испытанием.
Спутниковые изображения
Здесь задача оказывается значительно проще. Для географических координат каждой фотографии из пункта выше получаем спутниковый снимок с помощью Static API Яндекс Карт.
def get_satellite_image(lat, long):
params = {
'll': lat + ',' + long,
'size': '450,450',
'z': 15,
'l': 'sat'
}
return requests.get("https://static-maps.yandex.ru/1.x/",
params=params, verify=True)
В итоге были получены два набора фотографий следующего вида:
Про объем. Во-первых, чем больше, тем лучше (думаю потолка качества таким путем достигнуть сложно). Во-вторых, чем шире набор классов (горная местность, населенные пункты, дорожная инфраструктура и т.п.), тем больше примеров нужно генератору, чтобы научиться воспроизводить все эти типы ландшафта. В своем случае я оставил скромный набор из фотографий природы средней полосы в теплое время года.
Обучение модели
Архитектуры сетей GAN и энкодера привожу в довольно примитивном символическом варианте, который использовался в эксперименте. При должном усердии в формировании набора обучающих данных и свободе в вычислительных ресурсах, можно, конечно, пойти дальше и опробовать гораздо более интересные модели.
Обучение генератора
Так как набор сцен и состояний сильно ограничен, оказалось, что двузначная, а тем боле трехзначная, размерность скрытого пространства избыточна (сеть долго обучается и склонна к переобучению), поэтому пришлось остановиться на 4-мерном векторе. Далее последовательное применение 5 слоёв, использующих двумерные транспонированные свёртки размером 4 на 4 с шагом 2.
На выходе получаем цветное трёхканальное квадратное изображение размером 128 на 128 пикселей. В силу того, что вариативность обучающих сцен достаточно небольшая, сеть обучалась с нуля, а не использовался fine tuning предобученного генератора.
nz = 4 # размерность скрытого пространства
ngf = 64 # размерность карт признаков генератора
nc = 3 # число каналов изображения на выходе
class Generator(nn.Module):
def __init__(self):
super(Generator, self).__init__()
self.main = nn.Sequential(
nn.ConvTranspose2d(nz, ngf * 16, 4, 1, 0, bias=False),
nn.BatchNorm2d(ngf * 16),
nn.ReLU(True),
nn.ConvTranspose2d(ngf * 16, ngf * 8, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf * 8),
nn.ReLU(True),
nn.ConvTranspose2d( ngf * 8, ngf * 4, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf * 4),
nn.ReLU(True),
nn.ConvTranspose2d( ngf * 4, ngf * 2, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf * 2),
nn.ReLU(True),
nn.ConvTranspose2d( ngf * 2, ngf, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf),
nn.ReLU(True),
nn.ConvTranspose2d(ngf, nc, 4, 2, 1, bias=False),
nn.Tanh()
)
def forward(self, input):
return self.main(input)
Дискриминатор в свою очередь является практически зеркальным отражением генератора. Поступающее на вход изображение сквозь последовательные свёрточные слои приводится к скалярной величине от 0 до 1, соответствующей вероятности того, что это изображение является сгенерированным нейросетью.
Соответствующий дискриминатору код также почти зеркален, двумерные свёрточные слои занимают место транспонированных свёрток.
ndf = 64 # размерность карт признаков дискриминатора
class Discriminator(nn.Module):
def __init__(self):
super(Discriminator, self).__init__()
self.main = nn.Sequential(
nn.Conv2d(nc, ndf, 4, 2, 1, bias=False),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(ndf, ndf * 2, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 2),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 4),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 8),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(ndf * 8, ndf * 16, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 16),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(ndf * 16, 1, 4, 1, 0, bias=False),
nn.Sigmoid()
)
def forward(self, input):
return self.main(input)
В отличие от дискриминатора, который оставался достаточно точным при любом достигнутом числе итераций, генератор, по визуальной оценке, достигал максимального качества и начинал трансформировать уже, кажется, хорошо сформировавшиеся карты признаков. Поэтому шаг обучения генератора дополнительно корректировался шедулером StepLR, чтобы не промахиваться мимо локальных минимумов. При использовавшихся шагах обучения, и наборе данных, своего потолка сеть достигает примерно на 800 эпохе.
Обучение энкодера
Как уже было сказано ранее, задача энкодера сводится к выделению на спутниковых снимках признаков, отображающихся в такие состояния скрытого пространства, из которых генератор создаст изображения, адекватные этой местности. Для спутникового снимка места, покрытого лесом, сгенерируется лесной пейзаж, для травянистой равнины — изображение луга и так далее.
Выше уже была приведена схема обучения энкодера. Поэтапно она выглядит так:
Энкодер получает на вход спутниковые снимки в координатах c1, c2 ... cn
Последовательными сверками выделяются признаки и приводятся к вектору в скрытом пространстве
Генератор (уже обученный ранее) создает на основе этого вектора новые изображения
Эти изображения сравниваются с реальными, полученными в координатах c1, c2 ... cn
Веса модели корректируются, переходим к пункту 1
ndf = 16 # размерность карт признаков энкодера
class Encoder(nn.Module):
def __init__(self):
super(Encoder, self).__init__()
self.main = nn.Sequential(
nn.Conv2d(nc, ndf, 4, 2, 1, bias=False),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(ndf, ndf * 2, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 2),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 4),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 8),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(ndf * 8, ndf * 16, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 16),
nn.LeakyReLU(0.2, inplace=True),
nn.Flatten(),
nn.Linear(ndf*16*4*4, nz)
)
def forward(self, input):
return self.main(input)
Архитектура энкодера достаточно схожа с дискриминатором, имеет столько же сверточных слоев с одинаковыми параметрами. Однако для энкодера выбраны вчетверо меньшие размеры карт признаков, так как вариативность спутниковых снимков, использованных в рамках задачи достаточно небольшая.
Здесь основной сложностью является выбор корректной функции потерь, позволяющей понять насколько сгенерированное изображение отвечает тому, что мы видим на реальной фотографии этой местности. Об этом далее.
Функция потерь
Нагляднее — на примере. Имеем три пары схожих по классу ландшафта изображений (условно: поле, водоем, лес), которые должны получить меньший loss при сравнении внутри пары, нежели между соседями.
Идеальная для данной задачи функция должна вернуть минимальные значения для схожих пар. Эти значения расположились на главной диагонали матрицы на рисунке ниже. Однако популярные в таких делах MSE и SSIM не отвечают этому в достаточной степени:
Проблему решила специальная функция потерь, основанная на предобученной модели классификатора, использовавшегося при формировании датасета (ResNet, обученная на датасете Places365). Схема проста:
Получаем поклассовые вероятности для первого изображения
Получаем поклассовые вероятности для второго изображения
Считаем MSE loss для этих двух тензоров.
Примеры классов для реальных и сгенерированных изображений изображений выглядят следующим образом:
Код примененной функции потерь выглядит примерно так (где MSE_CLASS — часть, отвечающая за «правильность» содержания сгенерированного изображения, а MSE_NORM — за «нормальность» полученного распределения):
def class_norm_criterion(img_tensor1, img_tensor2, mean, variance):
logit1 = classifier.forward(img_tensor1)
logit2 = classifier.forward(img_tensor2)
MSE_CLASS = mseloss(logit1, logit2)
MSE_NORM = mean.pow(2) + (1 - variance).pow(2)
return MSE_CLASS + MSE_NORM * 0.5
Отдельной сложностью здесь явилось то, что на самых первых эпохах связка энкодер-генератор имела свойство сваливаться к краям скрытого пространства, откуда впоследствии выбраться не удавалось. Поэтому была введена поправка на степень близости полученной выборки состояний в пределах батча к нормальному распределению (по аналогии с расстоянием Кульбака — Лейблера). Эта мера адекватна при достаточно больших размеров батчей, так как при обучении генератора использовался шум с нормальным распределением.
Итоговые результаты превзошли по точности MSE и SSIM. Энкодер с такой комплексной функцией потерь обучается медленнее, но качественно лучше.
Результат работы генератора
По результатам работы связка энкодер-генератор таки научилась создавать изображения, минимально адекватно описывающие то, что открывается взору наблюдателя в местности, запечатленной спутником.
При большом желании, с кодом, использовавшимся для создания всего этого безобразия, можно ознакомиться здесь.
Где применять
В голову приходит ряд примеров, когда требуется визуальное подкрепление, а единственной актуальной и достоверной информацией о местности служит спутниковый снимок.
Валидация загружаемых пользователями фотографий
Зачастую, в картографических сервисах (живой пример - Яндекс Карты) попадаются объекты, например фотографии или панорамы, с ошибочной геопривязкой. Проверить корректность загружаемой фотографии заявленной точке на карте - подходящая задача для такой модели.
Визуальная фильтрация по типу местности
Пример - фильтр для выбора максимально подходящей местности на карте для сервиса подбора недвижимости.
Генерация контента
Для природных (а в общем смысле подхода и не только) объектов на карте, не снабженных пользовательскими фотографиями.
Вместо заключения
Думаю, можно с уверенностью утверждать, что при кратно большем (как по объёму так и разнообразию) наборе обучающих данных и доступных вычислительных мощностях результаты генерации должны значительно превзойти полученные в работе. Как в плане размера изображений и их качества, так и в плане количества типов ландшафтов (населенные пункты, горная местность, дорожная инфраструктура и т.п.). Совсем замечательно было бы физически собрать свой датасет, с учетом ориентации поля зрения камеры. Но это уже совсем другая история.