Здесь будут собираны рецепты по имитации эффектов различных пунктов меню в фотошопе. Полезно, например, для создания пайплайна предобработки в классическом компьютерном зрении или для аугментации данных в глубоком обучении.
Часто бывает нужно обработать только часть изображения. Например, заблюрить фон, оставив передний план четким.
Нужна маска - изображение того же размера, в котором для каждого пикселя описано, какой части он принадлежит - обрабатываемой, или игнорируемой. Так изображение с наложенной маской может создать краевые эффекты, обычно имеет смысл накладывать ее уже после обработки. Но можно и до. Например, если мы хотим обработать только самые темные области изображения.
#конвертируем в gray scale
gray = np.mean(image, axis = 2, keepdims = True)
# маска будет содержать 0 для темных пикселей и 1 для светлых
# конвертируем boolean'ы во флоаты
mask = (gray > 0.1).astype(float)
processed_image = process(image)
result = processed_image*mask + image*(1-mask)
Если маска состоит из 0 и 1, можно ее увеличивать и уменьшать, как в пунктах меню Shrink и Grow. Из комбинации можно найти границу:
# уменьшение
erode = scipy.ndimage.binary_erosion(input, iterations = ...)
# увеличение
dilate = scipy.ndimage.binary_erosion(input, iterations = ...)
# граница
border = dilate - erode
Резкие края у маски часто нежелательны, и делают ее заметной на результате. Чтобы их не было, маску можно размыть:
mask = (gray > 0.1).astype(float)
smoothed_mask = scipy.ndimage.gaussian_filter(mask, sigma)
При этом код result = processed_image*mask + image*(1-mask)
отработает, как надо.
Если нужен какой-то сложный критерий, то имеет смысл векторными операциями превратить картинку в такую, что более белому цвету соответствует большая вероятность соответствия критерую. Затем применить то же, что и выше: gray > ...
. Для выделения по цвету, можно взять L2-норму как критерий: sqrt( (r1-r2)^2 + (g1-g2)^2 + (b1-b2)^2 )
. Далее см. комментарии. в коде:
# [r, g, b] - цвет, близкие величины к которому находим
# мы хотим вычесть одну и ту же константу из каждого пикселя
# для этого воспользуемся broadcasting'ом, поставив 1 там, где
# у картинки длина и ширина
color_for_broadcasting = np.reshape([r, g, b], [1, 1, 3])
# Теперь операция `image - color_for_broadcasting` не будет падать
# с несоответствием размеростей
# Первый этап вычисления критерия
squared_diff = (image - color_for_broadcasting)**2
# Второй этап
# в каждом пикселе color_distance находится степень близости цвета
color_distance = np.sqrt(np.sum(squared_diff, axis=2, keepdims=True))
# теперь можно найти маску
mask = color_distance > THRESHOLD
Чтобы найти THRESHOLD, имеет смысл посмотреть на содержимое color_distance с помощщью matplotib.pyplot.imshow
import matplotlib.pyplot as plt
plt.imshow(color_distance)
plt.show()
Таким же критерием может быть результат работы нейронки, например, предсказанная дистанция.
Все эти обработки берут значение пикселя и применяют к нему какую-то функцию, и записывают результат назад. Каждый пиксель обрабатывается независимо. Таким образом, все, что отличает эти операции, это вид применяемой к пикселю функции. “Кривые” дают самую наглядную визуализацию и являются самым мощным и наглядным вариантом. Остальные обработки создают какую-то свою кривую под капотом, но ее не демонстрируют. Поэтому для окончательных тестов того, а что такого закодить, рекомендую переходить сразу к кривым.
Простейший случай - применить линейную функцию k*x + b
к яркости каждого пикселя. C numpy это просто:
new_img = k * img + b
Число b отвечает за яркость, k - за kontrast. img - матрица с картинкой.
В результате возможен выход самых ярких пикселей за пределы. Если это не допустимо, все, что ниже 0 и выше 1 превращаем в 0 и 1:
new_img = np.clip(k * img + b, 0, 1)
Параметр b точно соответствует яркости в редакторе - 0 означает отсутствие изменений, идем в плюс - увеличиваем яркость, в минус - все тусклее.
С параметром k сложнее, потому что в том виде, как он тут написан, он управляет наклоном функции относительно точки пересечения прямой и оси Y (0; b). Это не совсем интуитивный контраст. Для более интуитивного подхода требуется вращать вокруг точки (0.5, 0.5):
new_img = k*(img - 0.5) + b + 0.5
Сам параметр k может быть также нелинейно привязан к крутилке в UI, и как оно сделано в конкретном редакторе - вопрос.
Иногда требуется максимизировать контраст, то есть, чтобы самый тусклый пиксель имел значение точно 0, самый яркий точно 1:
min_pixel = np.minimum(img)
max_pixel = np.minimum(img)
max_contrast_img = (img - min_pixel) / (max_pixel - min_pixel)
Уровни применяют такую же k*x+b
, но через UI задаются значения этой функции для самого темного и яркого пикселей. Есть более продвинутая версия, где помимо черного и белого ползунка есть еще серый, midpoint. Здесь уже не прямая, а изогнутая кривая.
С помощью пункта меню “кривые” можно нарисовать функцию непосредственно в UI. Если функция кусочно-линейная, ее можно описать с помощью np.interp. Например, вот так можно сделать что-то вроде изменения контраста:
new_img = numpy.interp(img, [0, 1], [0.3, 0.8])
Аппроксимация гамма-коррекции с помощью двух отрезков:
new_img = numpy.interp(img, [0, 0.5, 1], [0, 0.8, 1])
Для более гладкой кривой интерполировать сплайнами:
tck = interpolate.splrep([0, 0.5, 1], [0, 0.8, 1], s=0)
xnew = np.linspace(0, 1, 100)
ynew = interpolate.splev(xnew, tck, der=0)
Если накрутили что-то в редакторе и нужно перенести это в скрипт, то для реверса нужно создать png со всеми 255 оттенками серого, обработать его настройкой, и сохранить результат, и путем сравнения оригинального и обработанного файлов реверснуть обработку.
from PIL import Image
import numpy as np
# строка
img = np.array(range(255), dtype=np.uint8)
# превращаем shape в [1, 255, 1]
img = img[np.newaxis, :, np.newaxis]
# превращаем картинку в цветную, дублируя ось channel
img = np.tile(img, (1, 1, 3))
Image.fromarray(img).save("gradient_sample.png")
Обрабатываем gradient_sample.png.
После обработки можно вычленить нужную информацию и, для удобства работы с дробными значениями, превратить ее из массива в функцию.
import scipy.interpolate
import matplotlib.pyplot as plt
with Image.open('gradient_sample_out.png') as img:
img_arr = np.asarray(img)
# функция конвертации красного
img_arr_r = np.squeeze(img_arr[:, :, 0])
xs = np.arange(255)
ys = img_arr_r
fun = scipy.interpolate.InterpolatedUnivariateSpline(xs, ys)
plt.plot(fun(xs))
plt.show()
Выбор по умолчанию - размытие по Гауссу. Оно довольно неплохо имитирует то, что происходит в расфокусированной оптике.
Параметр sigma управляет величиной размытия. Для совсем ленивых есть уже готовая функция в scipy:
filtered_image = scipy.ndimage.gaussian_filter(input, sigma)
Этот прием уже был использован для размытия маски.
Другие виды размытия описаны в https://galchinsky.github.io/2021/11/25/KnowYourOptics.html
Первым делом нужно определить, одинаковое ли размытие везде в картинке, или в разных областях размывается по-разному. Для этого делаем белый файл с черными точками в виде сетки и обрабатываем фильтром.
from PIL import Image
import numpy as np
img = np.zeros((255, 255, 3), dtype=np.uint8)
img[img.shape[0]//2, img.shape[0]//2, :] = 255
Image.fromarray(img).save("delta.png")
Обрабатываем ее в редакторе.
Получаенная картинка - это и есть то ядро, которое нужно скормить в convolve2d. Видно, что motion blur - имеет ядро в виде линии.
import scipy.interpolate
import matplotlib.pyplot as plt
with Image.open('delta_out.png') as img:
img_arr = np.asarray(img)
img_arr = img_arr.astype(float)/255.0
# так как в случае motion blur обработка идентичная для всех каналов
# берем только красный канал
# помимо этого, выделяем только центральную область для более
# быстрой свертки
h, w, c = img_arr.shape
img_arr = img_arr[h//2-16:h//2+32, w//2-16:w//2+32, :]
img_arr = img_arr[:, :, 0]
plt.imshow(img_arr)
plt.show()
GIMP почему-то создал неотцентрированное ядро, поэтому картинка в итоге съедет. Чтобы она не съезжала, можно применить следующий спосб:
x, y = scipy.ndimage.measurements.center_of_mass(a)
a = scipy.ndimage.interpolation.shift(a, [kernel_size//2 - x, kernel_size//2 - y])
Делать это мы, конечно же, не будем. Вот так его можно применить:
import scipy.signal
import scipy.misc
f = scipy.misc.face().copy()
f[:, :, 0] = scipy.signal.convolve2d(f[:, :, 0], img_arr, 'same')
f[:, :, 1] = scipy.signal.convolve2d(f[:, :, 1], img_arr, 'same')
f[:, :, 2] = scipy.signal.convolve2d(f[:, :, 2], img_arr, 'same')
plt.imshow(f)
plt.show()
Картинка съехала и образовалась черная полоса. Чтобы это замаскировать, если центрировать кернел не вариант, можно указать boundary=’symm’.
Если результирующие точки разные - все сложнее, но подход тот же. Нужно скормить гимпу сетку точек, выделить из размытого файла ядра, и дальше интерполировать между разными ядрами при применении свертки.
Размытие убирает высокие частоты, оставляя низкие. А значит, разность между оригинальным изображением и размытием даст, наоборот, только высокие частоты. Если сложить их с оригиналом, получим эффект увеличения резкости. Называется это unsharp mask и применялось еще с пленочной фотографией. Он есть в scikit-image:
sharpened_image = skimage.filters.unsharp_mask(input, radius=3, amount=0.5)
Внутри он устроен, как описано выше: размываем, находим разность, складываем с оригиналом:
filtered_image = scipy.ndimage.gaussian_filter(input, radius)
sharpened_image = input + amount*(input - filtered_image)
В фоторедакторах есть еще один этап: там, где изображение малоконтрастно, оставляем оригинал, комбинируя с помощью маски по рецепту из раздела Select. Это позволяет не вытягивать шумы, ведь шум - это что-то с небольшой энергией, создающее высокочастотную рябь.
mask = np.absolute(input - sharpened_image) > threshold
sharpened_with_threshold = input * (1 - mask) + sharpened_image * mask
input
и sharpened_image
:
mask
и sharpened_with_threshold
:
Примеры сгенерированы этим скриптом:
import scipy.signal
import scipy.misc
import imageio
import numpy as np
import matplotlib.pyplot as plt
input = scipy.misc.face().astype(float)/255
input = input[300:600, 300:600, :]
radius = 3
amount = 0.5
threshold = 0.1
filtered_image = scipy.ndimage.gaussian_filter(input, radius)
sharpened_image = input + amount*(input - filtered_image)
mask = np.absolute(input - sharpened_image) > threshold
sharpened_with_threshold = input * (1 - mask) + sharpened_image * mask
combined_image = np.clip(np.concatenate([input,
sharpened_image,
mask,
sharpened_with_threshold], axis=0), 0, 1)
imageio.imwrite('input.png', input)
imageio.imwrite('sharpened_image.png', np.clip(sharpened_image, 0, 1))
imageio.imwrite('mask.png', mask.astype(float))
imageio.imwrite('sharpened_with_threshold.png', np.clip(sharpened_with_threshold, 0, 1))