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) возвращает результат обратно в диапазон 0255 после большей суммы произведений. 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.4. Повышение резкости

Ядро повышения резкости – это единичное ядро плюс ядро отклика на границы. Результат – это исходное изображение плюс копия границ, поэтому высокочастотные признаки усиливаются относительно гладких внутренних областей.

Стандартное 4-связное ядро повышения резкости добавляет 4-связный лапласиан к единичному ядру:

sharpen = [ 0, -1,  0,
           -1,  5, -1,
            0, -1,  0]

img.morph(1, sharpen)

Чтение ядра: центральный вес равен identity (1) + Laplacian centre (4) = 5, а окружение совпадает с лапласианом. Плоские участки дают значение 5 * 1 - 4 * 1 = 1, умноженное на центральное значение – то есть единичное ядро. Границы дают исходное значение плюс отклик лапласиана. Сумма весов равна 1, поэтому mul и add остаются со значениями по умолчанию.

Для более сильного повышения резкости 8-связный вариант идёт дальше:

sharpen_strong = [-1, -1, -1,
                  -1,  9, -1,
                  -1, -1, -1]

img.morph(1, sharpen_strong)

Центральный вес 9 равен identity (1) + Laplacian-8 centre (8). Та же логика, больше усиления, больше риска также усилить шум датчика.

Ядра сильного повышения резкости по сути являются gaussian() с unsharp=True, только выраженными напрямую в виде ядра, а не через флаг нерезкой маски. Поведение на уровне пикселей то же самое; выбор стоит между удобством именованного метода и тонким контролем вручную настроенного ядра.

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 покрывает почти каждый линейный проход, который когда-либо требовался классическому конвейеру машинного зрения; дальше остаётся лишь подбирать веса, смотреть на результат и итерировать.