Пост, посвященный методам генерации датасетов в задачах image super-resolution, deblur и тому подобных. Рассмотрен взгляд с точки зрения оптики. Полезно может быть также тем, кто знанимается CV, но в оптике есть какие-то пробелы. Я постарался срезать углы, чтобы не зарываться в детали, хотя местами все равно зарылся.
В этом параграфе описано, почему сложно сделать идеальное изображение, и какие проблемы есть с тем, что дают нам объективы из реального мира.
Обычно свет распространяется от видимого предмета сразу во все стороны. Поэтому, если взять белый лист бумаги, на нем не будет ничего отображаться - лучи падают на этот лист со всех сторон, перемешиваясь. Фильмы про хакеров врут: такое, как на картинке, когда на лице отпечатываются полоски текста, невозможно.
Чтобы увидеть на белом листе предметы, нужна штука, которая вычленит лучи от какой-то конкретной точки из этой электромагнитной каши. Простейший способ - небольшая дырка. Из всех лучей, падающих на нас, отсекаем небольшую область. Ставим на пути лучей плоскость. Получаем изображение. Чем меньше дырка, чем четче изображение.
Но возникают трудности - световой энергии проходит мало. Чем меньше дырка, тем тусклее. Поэтому в реальности - это всегда дневной пейзаж.
В компьютерной графике, где об энергии можно не заботиться, такой способ построения изображений стал основным. Это удобно, потому что геометрическая задача несложная: провести прямую через 2 точки и найти пересечение с плоскостью. Для пущей простоты в компьютерах меняют местами плоскость и дырку. Физически это уже неосуществимо, зато изображение не переворачивается.
В реальном же мире используют линзы. Линза собирает лучи с большей области пространства, и направляет их примерно в одну точку. Получается намного ярче, но возникают новые проблемы. С дыркой у нас все было настолько четко, насколько эта дырка маленькая (волновые свойства света в расчет не берем). С линзой все начинает зависеть от того, где расположен лист, но даже если он расположен в наилучшем месте, все равно где-то будет более размыто, где-то менее. У дырки прямые линии остаются прямыми. Линза будет их гнуть.
Таким образом, с одной стороны у нас есть идеальное изображение, которое создают 3D-движки, а также несуществующая камера с бесконечно малой дыркой, которая, несмотря на свое несуществование, является полезной ментальной моделью. С другой стороны, у нас есть реальное изображение, построенное с помощью линз. Чем идеальное отличается от реального?
Проблема нулевая - когда у нас простая дырка, перемещение плоскости меняет в основном размер изображения. Линзе же не все равно, на каком расстоянии предмет. Если он бесконечно далеко, как звезда, а лучи падают почти параллельно - плоскость должна быть в одном месте. Если предмет близко - то в другом. Необходим способ фокусироваться - менять на лету расстояние между линзой и плоскостью, подстраиваясь под расстояние до предмета. Также появляется понятие глубины резкости - некоторые линзы изображают четко довольно большие области, с некоторыми в фокус еще попасть нужно. Так как объективы в наше время делать более-менее умеют, дефокусировка - главная проблема неудачных кадров.
Проблема первая - технологически проще всего делать плоские и сферические поверхности. Берешь шар, трешь об него стекло как угодно - стекло примет форму вогнутого шара. То же самое с выпуклым шаром и плоскостью. С другой поверхностью так не получится, потому что в разных местах будет разная кривизна. Но геометрическая фигура, которая сводит лучи идеально в точку - параболоид.
Параболоид сделать тяжелее. Компромисс - найти такую сферу, чтобы она максимально повторяла параболоид. Идеально сферическую линзу сфокусировать нельзя, в минимальном случае будет небольшое пятно. Это пятно называется сферической аберрацией.
Если в оптической системе используется что-то, кроме сфер, это называется асферической оптикой. Сейчас асферикой никого не удивишь, но по-прежнему точную сферу сделать намного проще, поэтому количество таких поверхностей стараются минимизировать.
Проблема вторая - линза преломляет красные, зеленые и синие лучи слегка по-разному. В итоге, там, где зеленые лучи сведены минимально, красные и синие будут чуть шире. Если передвинуть плоскость, чтобы красные стали четкими - зеленые и синие разъедутся. Это называется хроматической аберрацией. Максимальную четкость зеленого считают основной, потому что так считает глаз.
Проблема третья - если предмет находится не на оси, лучи перекашивает еще больше, а размытое пятно, похожее на гауссоиду, становится ассиметричным, и похожим то ли на запятую, то ли на комету. Это называется комой.
Стекла, как правило, имеют осевую симметривую. Поэтому кома смотрит в центр объектива и одна и та же на данном расстоянии от центра.
В этот же пункт следует добавить астигматизм. Если предмет размывается не в круг, а эллипс - это он. Объединить их следует из-за того, что в размытии здесь возникает какая-то асимметрия. Из-за осевой симметрии этот эллипс устроен также: один и тот же на данном расстоянии от центра.
Почему у гитары - дисторшен, а в оптике - дисторсен? Не знаю.
Описать ее можно так: все может быть четко, но прямые линии не прямые. Это называется дисторсией. Не всегда это является недостатком. Например, загнать широкоугольный объектив в конечные размеры кадра без дисторсии не получится.
Любой, кто устанавливал FOV 170 в шутерах, знает, как выглядит широкоугольное изображение без дисторсии:
Хорошие новости в том, что дисторсия сама по себе не размывает. Плохие - в том, что искажает линии, и если вы деблюрите широкоугольные объективы, возможно, следует вносить подобные искажения в фотографии из интернета.
Стекла здесь не при чем, а при чем то, что фотоприемник фиксирует свет в течение какого-то времени, за которое предмет мог сдвинуться.
Глаз тоже этим страдает, достаточно помахать рукой перед собой, чтобы убедиться.
Для съемки чего-то тусклого фотоприемнику нужно долго фиксировать свет и считать число попавших в него фотонов. Но если одновременно с этим в кадре есть что-то яркое, электрический заряд в пикселе фотоприемника может начать утекать. То есть, яркий предмет будет белым, а по его границе возникнет дымка. Правда, она также будет из-за аберраций, перечисленных выше. Величину растекания можно попробовать поискать в даташите на сенсор.
Задача деблюра - из мутного изображения сделать четкое. Значит нужно научиться из четкого изображения делать мутное, и обучить нейронку делать наоборот.
Четкое изображение получить несложно: скачать картинку 4096х4096, убедиться, что она в фокусе, и ресайзнуть до 512х512 идеальным алгоритмом без алиасинга (tldr: используйте Pillow). Даже если объектив имел какие-то недостатки, при таком ресайзе они пропадут. Важный момент: аугментации типа поворота должны делаться до ресайза, размывать тоже желательно в максимально хорошем качестве картинки, просто на всякий случай.
Рецептов, как убедиться, что картинка четкая, в интернете много. Они сводятся к тому, что в четком изображении много высоких частот, а значит можно сделать фильтр высоких частот, который посчитает их относительное количество и оценит четкость.
Поэтому, качаем большое и качественное изборажение, режем его на кропы, убеждаемся, что в кропах нет размытых частей. Вроде ничего не забыл? Сделать четкое изображение мутным сложнее, чему будет посвящен остаток поста.
В мире с бесконечными вычислительными ресурсами мы могли бы максимально имитировать природу. Поместил виртуально распечатанное изображение на каком-то расстоянии от линзы. Пустил из предмета луч, он летит, пока не столкнется с чем-то, если это что-то - линза, то много раз преломится по формуле преломления, и попадет (или нет) в фотоприемник. Там он пройдет через RGB-сетку и оцифруется вместе с электрическими шумами фотоприемника.
Но ресурсы конечные, надо упростить. Первое, что можно заметить, каждый пиксель можно рассматривать независимо, ведь световые лучи друг с другом не взаимодействуют. Поэтому каждая точка на предмете превратится в какую-то размытую область на изображении, а итоговое изображение будет суммой таких размытых областей. Поэтому, нам достаточно посчитать один раз, как пиксель, светящий из данной области пространства заблюрится в результате прохода через линзу. А затем как-то применить это знание.
Это называется PSF - “функция рассеяния точки”. Такое моделирование чем-то это напоминает свертку, но ядро переменное, зависящие от пикселя.
for x_изображения
for y_изображения
кернел = PSF(x, y, расстояние)
for x_кернела
for y_кернела
результат[y_изображения+y_кернела, x_изображения+x_кернела] += изображение[y_изображения, x_изображения] * кернел[y_кернела, x_кернела]
PSF считается один раз, поэтому можно посчитать и трассировкой лучей, как в случае решения влоб. Киношные трассировщики вряд ли подойдут. Подойдут те, что встроены в оптические пакеты. Есть платные, типа Zemax. Есть бесплатные, типа GNU Optical. Как-то я его форкнул, добавив CMake, а потом его форкнул Dibyendu Majumdar.
Если доступа к документации на оптику нет, можно вообще ничего не вычислять, а PSF’ы нарисовать. Но как их нарисовать, если никогда их не видел? Если есть доступ к RAW файлам с фотоприемника, создаем большой черный файл с сеткой из белых точек. Затем в черной комнате с включенным монитором фотографируем его на расстоянии, на котором обычно фотографируем. Важно, чтобы пиксель монитора был меньше пикселя фотоприемника. Если фокус автоматический, нужно поместить рядом какой-нибудь светящийся предмет, чтобы автоматика сфокусировалась. Дальше анализируем, каким образом лучше всего замоделировать получившуюся картинку.
Это, конечно, колхоз, работающий в редких случаях. На большом расстоянии пиксели монитора могут быть не видны. Да и на малом они могут быть слишком тусклыми и утонуть в шумах фотоприемника. Профессиональный подход - взять лампочку, сфокусировать ее в точку одной линзой (на рисунке в качестве линзы - очередная дырка), затем с помощью второй линзы сэмулировать нужное расстояние до предмета (если вторая линза имеет лампочку в фокусе, ее свет будет как свет далекой звезды, идти параллельными лучами, двигая ее туда-сюда можно менять это виртуальное расстояние).
Зная, как выглядят PSF, можно их параметризовать и генерировать на лету. Ниже, в разделе про кому, я предлагаю моделировать PSF как сумму гауссоид.
Если PSF все время постоянная, модель сводится с свертке.
for x_изображения
for y_изображения
for x_кернела
for y_кернела
результат[y_изображения+y_кернела, x_изображения+x_кернела] += изображение[y_изображения, x_изображения] * кернел[y_кернела, x_кернела]
Встречается ли такое в реальности? Ну, при сильном расфокусе, наверное, если объектив неплохой. При этом PSF при расфокусе похожа на гауссоиду. Вот мы и придумали guassian blur из фотошопа. Идеальный алгоритм для старых медленных компьютеров, но основую суть выхватывает, будучи очень простым.
filtered_image = scipy.ndimage.gaussian_filter(input, sigma)
Внутри там что-то типа
kernel = guassian_kernel(kernel_size, sigma) # описана ниже
filtered_image = np.convolve2d(image, kernel)
Единственный параметр тут - sigma, управляет силой размытия. Можно подогнать sigma под величину PSF вручную. Второй параметр - kernel_size, больше касается скорости обработки. Если его поставить слишком маленьким, пятно станет таким толстым, что вытечет из экрана.
Так как 99,7% энергии попадает в круг +-3 sigma, примерно такого размера kernel_size и нужно делать.
Также можно считать PSF постоянной, если в пределах receptive field выходного пикселя она слабо меняется.
имитируется разными PSF для разных каналов. В случае размытия по Гауссу:
filtered_image = np.zeros_like(input)
filtered_image[:, :, 0] = scipy.ndimage.gaussian_filter(input[:, :, 0], sigma_r)
filtered_image[:, :, 1] = scipy.ndimage.gaussian_filter(input[:, :, 1], sigma_g)
filtered_image[:, :, 2] = scipy.ndimage.gaussian_filter(input[:, :, 2], sigma_b)
sigma_b обычно минимальная, потому что объективы фокусируются по зеленому каналу.
Если мы обучаем на мелких кропах, PSF можно попробовать считать постоянной в пределах кропа. Но использовать только гауссово размытие неправильно, потому что оно соответствует идеально симметричной PSF. Ближе к краю кадра эта симметрия, как правило, нарушается. Как сгенерировать PSF с перекосом? Можно делать это с помощью накладывания множества гауссоид в цикле. Алгоритму требуется начальная, конечная точка и то, как меняется ширина пятна. Диапазоны для рандомизатора можно установить, внимательно изучив PSF реальных объективов. Так можно объективы с комой, астигматизмом, моделировать реальные PSF, меняя только xs и ys:
import matplotlib.pyplot as plt
import numpy as np
import scipy.ndimage
kernel_size = 128
steps = 100
ts = np.linspace(0, 1, steps)
xs = np.interp(ts, [0, 1], [0.35, 0.55]) * kernel_size
ys = np.interp(ts, [0, 1], [0.5, 0.8]) * kernel_size
sigmas = np.interp(ts, [0, 1], [0.05, 0.1]) * kernel_size
def guassian_kernel(kernel_size, x, y, sigma):
x_axis = np.arange(-x, kernel_size - x)
y_axis = np.arange(-y, kernel_size - y)
xx, yy = np.meshgrid(x_axis, y_axis)
kernel = np.exp(-0.5 * (np.square(xx) + np.square(yy)) / np.square(sigma))
return kernel
a = np.zeros((kernel_size, kernel_size))
for x, y, sigma in zip(xs, ys, sigmas):
a = a + guassian_kernel(kernel_size, x, y, sigma)
# normalize in two steps, first energy value
a = a/np.sum(a)
# second: energy maximum should be in the center of kernel
x, y = scipy.ndimage.measurements.center_of_mass(a)
a = scipy.ndimage.interpolation.shift(a, [kernel_size//2 - x, kernel_size//2 - y])
plt.imshow(a)
plt.show()
Ключевые входные данные здесь - xs, ys и sigmas. С их помощью мы рисуем шлейф из гауссоид разной толщины sigmas, идя по траектории xs, ys.
Чтобы добавить мошуен блюр, нужно свернуть PSF с прямой линией. Правда, зачастую нам интересна резкая граница между движущимся предметом и плавным фоном (или наоборот). Как с этим бороться, написано в параграфе “Разные расстояния” ниже.
Линзовые объективы осесимметричны. То есть если в точке (5; 0) мы имеем PSF, то в точке (0; 5) будет такая же картина, но повернутая на 90 градусов. Это позволяет ввести функцию PSF(r), посчитав PSF только вдоль одной линии от центра к краю. Для остальных мест нужно найти r=sqrt(x^2+y^2) и просто повернуть изображение на atan2(y, x). Когда разговор заходит о поворотах и ресайзах, нужно убедиться, что у исходного изображения достаточно разрешения. Поэтому посчитать PSF сеткой по x и y тоже подход неплохой. Тогда в узловых точках мы вычисляем PSF(x, y), между ними - интерполируем между 4 ближайшими PSF. Все зависит от того, насколько дорого считать PSF. Когда я пришел на свою первую работу, как это автоматизируется я не знал, и мне давали какие-то таблички оптики, которые вручную считали в Zemax. Мне это не понравилось, я взял GNU Optical, чтобы считать это автоматически.
Существует готовый слой, способный делать свертку с переменным ядром. Можно загрузить в него нужные веса, и размывать на GPU. Он занимает много памяти, потому что хранит кернел для каждого пикселя. Но зато PSF для каждого пикселя вычисляется всего один раз.
Разбиваем изображение на перекрывающиеся квадраты. Размываем в них по-разному (в зависимости от PSF в данной области кадра). Собираем изображение назад. Чтобы избежать краевых эффектов, блендим квадраты плавно между собой, используя что-то типа hann window. Таким образом получаем дешевую свертку с переменным ядром.
Две оптимизации выше работают, только если в функции PSF(x, y, расстояние) расстояние постоянно. То же касается и LocallyConnectedLayer. То есть, физически это значит, что мы распечатали картинку, поставили ее на каком-то расстоянии от объектива, и что-то получили. Если на этой картинке изображен автобус с картинки выше, все равно это будет обрабатываться, как распечатка картинки.
Это очень сильное предположение, потому что зачастую нам интересен как раз контраст фигур на фоне. Возможно, мы не хотим деблюрить фон вообще, только передний план.
Возможен следующий трюк: разбиваем изображение на серию плоских изображений с прозрачностью, соответствую переднему и заднему планам. К переднему плану применяем PSF, как будто все в фокусе. К заднему плану - другую, более размытую PSF. Затем складываем два изображения.
Разбить изображение на слои можно уже натренированной depth estimation нейросетью.