5.17. Каталог стандартных ядер¶
За время своего существования классическая обработка изображений накопила довольно обширный каталог шаблонов весов ядра, которые встречаются снова и снова – детекторы границ, повышение резкости, тиснение, сглаживание, размытие в движении – и каждый из них работает через morph(). Каждое ядро короткое, каждое делает одну вещь, и большинство из них легко читаются, как только становится понятна базовая логика весов.
Все приведённые ниже ядра имеют размер 3 на 3, если не указано иное, поэтому все они используют size=1 в вызове. Структура весов каждого ядра описана рядом с ним, поскольку именно чтение весов формирует интуитивное понимание того, почему одно ядро делает тиснение, а другое повышает резкость.
5.17.1. Единичное ядро¶
Простейшее возможное ядро – это единичное: единица в центре и нули во всех остальных позициях:
identity = [0, 0, 0,
0, 1, 0,
0, 0, 0]
img.morph(1, identity)
Каждый выходной пиксель берёт своё значение из центра окрестности, то есть из входного пикселя в той же позиции. Изображение проходит без изменений. Единичное ядро не имеет практического применения как фильтр, но оно является полезной отправной точкой для понимания любого другого ядра: любое неединичное ядро – это единичное ядро плюс некоторая модификация.
Ядро, у которого большой вес в центре и небольшие отрицательные веса вокруг него, вычитает окружение из центра. Ядро с нулевым весом в центре игнорирует сам пиксель и реагирует только на различия между его соседями. Чтение ядра таким образом – что центральный вес делает с пикселем, что окружающие веса добавляют или убирают – это самый быстрый способ предсказать его эффект.
5.17.2. Обнаружение границ¶
Ядра обнаружения границ сильно реагируют на позиции, где яркость быстро меняется в определённом направлении, и дают почти нулевой результат там, где яркость однородна. Это семейство, у которого сумма весов равна нулю: плоский участок (каждый пиксель имеет одинаковое значение) даёт нулевой результат, потому что каждый положительный вес в точности компенсируется отрицательным весом равной величины.
Sobel-x – канонический пример. Он обнаруживает вертикальные границы (переходы яркости влево/вправо):
sobel_x = [-1, 0, 1,
-2, 0, 2,
-1, 0, 1]
img.morph(1, sobel_x, mul=0.25, add=128)
Соответствующий Sobel-y – это тот же шаблон, повёрнутый на 90 градусов; он обнаруживает горизонтальные границы (переходы яркости вверх/вниз):
sobel_y = [-1, -2, -1,
0, 0, 0,
1, 2, 1]
В среднем ряду Sobel-x находятся веса -2 и 2, а не -1 и 1. Дополнительный вес в центральном ряду придаёт ядру небольшое встроенное сглаживание в направлении вдоль границы, что делает его более устойчивым к шуму, чем более простой оператор Prewitt, который отбрасывает эти дополнительные величины:
prewitt_x = [-1, 0, 1,
-1, 0, 1,
-1, 0, 1]
prewitt_y = [-1, -1, -1,
0, 0, 0,
1, 1, 1]
Prewitt взвешивает каждый ряд одинаково, поэтому его отклик чуть резче, чем у Sobel, ценой большей чувствительности к шуму отдельных пикселей (стоимость работы ядра идентична – свёртка выполняет одинаковую работу, какими бы ни были веса). На чистом изображении с сильными границами он является вполне пригодной заменой Sobel.
Scharr идёт в другом направлении. Его веса больше и настроены на точное определение направления границы под более мелкими углами:
scharr_x = [-3, 0, 3,
-10, 0, 10,
-3, 0, 3]
img.morph(1, scharr_x, mul=0.0625, add=128)
Делитель mul=0.0625 (1/16) возвращает результат обратно в диапазон 0 – 255 после большей суммы произведений. Scharr – это правильный выбор, когда приложению нужен наиболее геометрически точный отклик градиента и оно готово заплатить за это чуть большим объёмом арифметики.
5.17.3. Лапласиан¶
Ядро лапласиана реагирует на границы в любом направлении одновременно. Если каждый из операторов Sobel обнаруживает изменения яркости вдоль одной оси, то симметричный шаблон весов лапласиана реагирует одинаково независимо от того, в каком направлении идёт граница:
laplacian_4 = [ 0, -1, 0,
-1, 4, -1,
0, -1, 0]
img.morph(1, laplacian_4, add=128)
Структура: центральный вес 4, четыре горизонтальных/вертикальных соседа с весом -1, четыре диагональных соседа с нулевым весом. Сумма весов ядра равна нулю, поэтому плоские участки дают нулевой результат. Там, где яркость меняется, центральное значение отличается от среднего его четырёх кардинальных соседей, и результат равен величине этой разницы.
8-связный вариант включает диагональных соседей:
laplacian_8 = [-1, -1, -1,
-1, 8, -1,
-1, -1, -1]
Каждое ядро обнаруживает немного разные вещи. 4-связная версия даёт более чистый результат на горизонтальных и вертикальных границах; 8-связная более изотропна – она одинаково хорошо реагирует во всех направлениях – но даёт чуть более шумный результат. 8-связное ядро также известно под названием outline благодаря его использованию для визуализации границ.
5.17.5. Тиснение¶
Ядро тиснения создаёт эффект бокового освещения, встречающийся в классических редакторах изображений. Результат выглядит так, будто изображение было выдавлено в рельеф, а затем освещено из одного угла:
emboss = [-2, -1, 0,
-1, 1, 1,
0, 1, 2]
img.morph(1, emboss, add=128)
Хитрость заключается в асимметрии по диагонали. Левый верхний угол имеет наиболее отрицательный вес, правый нижний – наиболее положительный, а диагональ от угла к углу идёт от отрицательного значения через единицу к положительному. В каждом пикселе ядро по сути вычисляет «яркость справа снизу от меня минус яркость слева сверху от меня», что положительно там, где изображение становится ярче в этом направлении, и отрицательно там, где оно темнеет. Добавление 128 смещает знаковый результат к средне-серому, чтобы эффект был виден.
Поворот асимметрии по другой диагонали делает тиснение с противоположного направления:
emboss_alt = [ 0, 1, 2,
-1, 1, 1,
-2, -1, 0]
img.morph(1, emboss_alt, add=128)
Два направления тиснения полезны в сочетании – вычитая одно из другого или применяя каждое к одному и тому же изображению и сравнивая отклики – когда приложению нужно определить ориентацию.
5.17.6. Сглаживание¶
Ядра сглаживания – это семейство, у которого сумма весов равна единице (и все они неотрицательны). Плоский участок, пропущенный через такое ядро, даёт ту же плоскую яркость, потому что ядро усредняет значения пикселей вместе, а не усиливает их различия.
Простейшее из них – box blur (блочное размытие), которое и вычисляет mean():
box_blur = [1, 1, 1,
1, 1, 1,
1, 1, 1]
img.morph(1, box_blur)
Сумма весов ядра равна 9, поэтому автоматическое деление на сумму весов превращает сумму произведений в истинное среднее по девяти пикселям окрестности. На практике mean() – это лучший способ применить это ядро: он даёт тот же результат быстрее, через путь, оптимизированный для вычисления среднего и ничего больше, тогда как morph запускает общий механизм свёртки. Box blur включён в каталог, потому что он является правильной отправной точкой для понимания любого другого ядра сглаживания.
Аппроксимация Gaussian размером 3 на 3 взвешивает центр и кардинальных соседей больше, чем углы:
gaussian = [1, 2, 1,
2, 4, 2,
1, 2, 1]
img.morph(1, gaussian)
Веса – это строка треугольника Паскаля 1, 2, 1, перемноженная внешним произведением сама на себя. Центральный вес 4 является наибольшим, потому что центральный пиксель вносит наибольший вклад в свой собственный результат; углы имеют вес 1, потому что они дальше всего от центра. Сумма весов ядра равна 16, и автоматическое деление на сумму весов обеспечивает нормализацию – аргумент mul не нужен. Форма 3 на 3 является грубой аппроксимацией истинного гауссиана и неотличима от gaussian() при size=1; форма morph в основном полезна, когда приложению нужно скомбинировать сглаживание с другой операцией за один проход.
5.17.7. Размытие в движении¶
Ядро motion-blur (размытие в движении) усредняет пиксели вдоль одного направления, оставляя перпендикулярное направление без размытия. Простейший случай – горизонтальный:
motion_h = [0, 0, 0,
1, 1, 1,
0, 0, 0]
img.morph(1, motion_h)
Средний ряд усредняет три пикселя вдоль горизонтальной оси; верхний и нижний ряды равны нулю. Сумма весов ядра равна 3, поэтому автоматическое деление на сумму весов даёт истинное среднее по трём пикселям без какого-либо mul. Результат – это горизонтально размазанная копия входа, эффект, который камера фиксирует, когда объект движется вбок во время экспозиции. Вертикальное размытие в движении – это тот же шаблон, повёрнутый:
motion_v = [0, 1, 0,
0, 1, 0,
0, 1, 0]
Диагональное размытие в движении использует главную диагональ:
motion_diag = [1, 0, 0,
0, 1, 0,
0, 0, 1]
img.morph(1, motion_diag)
Ядра размытия в движении полезны как в качестве эффекта (намеренное размытие кадра в визуальных целях), так и в качестве тестового шаблона для алгоритмов, которые должны быть устойчивы к артефактам движения (запустите алгоритм на размытом в движении входе и проверьте, что он по-прежнему даёт правильный ответ).
5.17.8. Чтение ядер с первого взгляда¶
Несколько эмпирических правил облегчают чтение новых ядер с первого взгляда:
Сумма равна единице при неотрицательных весах ⇒ сглаживание (сохраняет среднюю яркость).
Сумма равна нулю при наличии как положительных, так и отрицательных весов ⇒ отклик на границы (ноль на плоских участках).
Сумма равна единице при большом положительном центре и малых отрицательных весах вокруг ⇒ повышение резкости (единичное ядро плюс отклик на границы).
Асимметрия по диагонали при сумме, равной единице ⇒ тиснение (выделяет одну сторону каждого перехода яркости).
Концентрация вдоль одной оси при сумме, равной единице ⇒ направленное размытие.
Первое из этих правил, которому соответствует ядро, обычно является верной догадкой о том, что оно делает. Большинство полезных ядер можно распознать только по расположению их шаблона весов.
Когда ни одно из стандартных ядер не делает того, что нужно приложению, следующий шаг – настроить ядро вручную. Сочетание приведённых выше правил и элементов управления mul / add покрывает почти каждый линейный проход, который когда-либо требовался классическому конвейеру машинного зрения; дальше остаётся лишь подбирать веса, смотреть на результат и итерировать.