5.16. Пользовательские ядра свёртки

Каждый из рассмотренных до сих пор оконных фильтров применял к окну в каждой позиции свою встроенную статистику – среднее, гауссово взвешенное среднее, медиану. morph() – единственный фильтр, позволяющий приложению самому задать статистику в виде ядра: небольшой матрицы весов, которая описывает, как фильтр должен объединять пиксели окрестности в одно выходное значение.

В основе механизма лежит классическая операция свёртки. В каждой выходной позиции каждый пиксель окрестности умножается на соответствующий вес ядра, произведения суммируются, результат при необходимости масштабируется и смещается, а полученное значение записывается в выходной пиксель. Разные ядра дают из одного и того же входа разные результаты. Ядро с одинаковыми положительными весами воспроизводит фильтр mean(); колоколообразное ядро воспроизводит gaussian(). Иные структуры дают отклики на границы, рельеф, градиенты, повышение резкости, размытие в движении и длинный список других эффектов – всё, что классическая обработка изображений когда-либо хотела сделать за один линейный проход.

5.16.1. Метод morph

Сигнатура выглядит как у других оконных фильтров, но с одним дополнительным аргументом:

img.morph(size, kernel, mul=1.0, add=0.0)

size задаёт радиус так же, как и везде, поэтому ядро должно иметь ровно (2 * size + 1) строк на (2 * size + 1) столбцов. Само ядро – это плоский список Python из такого количества чисел, в порядке по строкам: первые (2 * size + 1) элементов – верхняя строка, следующие (2 * size + 1) – вторая строка, и так далее, вплоть до нижней строки. mul масштабирует сумму произведений перед записью в выходной пиксель, а add прибавляет константу. Значения по умолчанию mul=1.0 и add=0.0 оставляют результат свёртки без изменений.

Стоит явно отметить одну деталь: метод автоматически делит сумму произведений на сумму элементов ядра перед записью результата. Благодаря этому автоделению усредняющее ядро, элементы которого в сумме дают девять – например, ядро размытия 3 на 3 – выходит в масштабе одной девятой без каких-либо дополнительных усилий, а ядро гауссова приближения, сумма которого равна шестнадцати, выходит в масштабе одной шестнадцатой, и оба без необходимости вычислять деление в приложении. Приложение задаёт mul только тогда, когда хочет применить дополнительное масштабирование поверх автонормировки – или, чаще, когда ядро в сумме даёт ноль (ядро отклика на границы) и автоделение оказалось бы делением ни на что. В этом случае фреймворк считает сумму равной единице, и mul становится единственной ручкой для удержания немасштабированной суммы произведений в диапазоне.

Пара threshold=True / offset=N из раздела об адаптивном пороге также работает с morph(), поэтому тот же механизм пользовательских ядер может создать бинарный порог, граница которого вычисляется пользовательской статистикой.

5.16.2. Раскладка ядра

Ядро 3 на 3 (size=1) – это плоский список из девяти чисел, расположенных слева направо, сверху вниз. Эта конвенция читается естественно, если разбить список на три строки Python:

sobel_x = [-1,  0,  1,
           -2,  0,  2,
           -1,  0,  1]

Это градиентный оператор Собеля по x – первое стандартное ядро, которое захочет любое приложение, и его полезно разобрать от начала до конца. Структура проста: отрицательные веса в левом столбце, положительные веса в правом столбце, центральный столбец нулевой. Веса строк -1, -2, -1 (или 1, 2, 1 справа) в середине выше, чем по углам, что даёт центральной строке больше влияния на результат, чем угловым строкам.

Когда ядро проходит через вертикальную границу – столбец пикселей, идущий от тёмного слева к яркому справа – отрицательные веса захватывают тёмную сторону, а положительные веса захватывают яркую сторону. Сумма произведений оказывается большим положительным числом, которое фильтр записывает как яркий выходной пиксель. Горизонтальный участок равномерной яркости даёт ноль, потому что каждому положительному весу соответствует отрицательный вес той же величины на пикселе с тем же значением.

Запуск ядра:

img.morph(1, sobel_x, mul=0.25)

Ядро Собеля в сумме даёт ноль – каждому отрицательному весу слева соответствует равный положительный вес справа – поэтому автоделение ни на что не делит, и mul остаётся единственным масштабом для суммы произведений. mul=0.25 удерживает отклик в диапазоне: наибольшая абсолютная сумма, которую Собель по x может дать из участка 3 на 3, составляет примерно 4 * 255 = 1020 (восемь ярких пикселей с весами до 2), и деление этого значения на четыре приводит крайние случаи к 255, где формат аккуратно их обрезает.

Соответствующее ядро Собеля по y обнаруживает горизонтальные границы, поворачивая ту же структуру весов на 90 градусов:

sobel_y = [-1, -2, -1,
            0,  0,  0,
            1,  2,  1]

Приложения, которым нужно обнаруживать любые границы независимо от направления, обычно запускают оба ядра Собеля и объединяют отклики.

5.16.3. Смещение выхода

add – вторая половина истории о масштабировании. Отклик ядра с нулевой суммой знаковый – положительный с одной стороны границы, отрицательный с другой – и отрицательная половина обрезается до нуля при записи в беззнаковый пиксель. add=128 сдвигает отклик так, чтобы он центрировался на средне-сером уровне, так что отрицательные отклики сохраняются как значения ниже 128, а положительные оказываются выше: отклик на границу или рельеф становится виден в обоих направлениях ценой половины диапазона в каждом.

Какое сочетание mul и add ожидает ядро – часть устройства самого ядра; каталог стандартных ядер перечисляет правильные настройки для каждого распространённого ядра.

5.16.4. Ядра большего размера

Всё на этой странице описывалось на примере ядер 3 на 3 (size=1), поскольку именно этот размер использует стандартный каталог и поскольку раскладку по строкам легко записать вручную при таком размере. Однако ничто в механизме не ограничивает ядро размером 3 на 3. size=2 запускает ядро 5 на 5 с двадцатью пятью элементами в плоском списке; size=3 запускает ядро 7 на 7 с сорока девятью; и так далее, вплоть до любого радиуса, который приложение готово оплатить. Фреймворк обрабатывает как плоские списки, так и вложенные строки при любом нечётном размере.

Причина обратиться к ядру большего размера та же, что и причина обратиться к большей окрестности у любого из встроенных фильтров: больше усреднения, более широкое обнаружение признаков, меньшая чувствительность к одиночному пиксельному шуму. Затраты растут как квадрат радиуса – ядро 5 на 5 выполняет примерно в 2,8 раза больше работы на пиксель, чем 3 на 3, а 7 на 7 – примерно в 5,4 раза – и этот множитель напрямую вычитается из частоты кадров.

На практике стоит оставаться на size=1 для стандартного каталога и обращаться к большим размерам только тогда, когда алгоритму нужна большая окрестность. Детекторы границ редко выигрывают от размера больше 3 на 3; сглаживающие фильтры иногда выигрывают; правильный размер зависит от масштаба признаков, которые приложение пытается подчеркнуть или подавить.

5.16.5. Когда стоит обращаться к morph

Для повседневного сглаживания mean(), gaussian() и bilateral() быстрее и чище. Для обнаружения границ laplacian() и find_edges() созданы специально для этого. Обращаться напрямую к morph() имеет смысл тогда, когда приложению нужна конкретная свёртка, которую встроенные фильтры не предоставляют – направленный Собель, пользовательский шаблон границы, ядро, настроенное под определённую текстуру, которую будет искать остальная часть конвейера, или любое из стандартного каталога полезных ядер, накопленного классической обработкой изображений за десятилетия. Доступна вся гибкость произвольных ядер; платой за это является то, что приложение само отвечает за выбор значений ядра, дающих нужный ему результат.