Blur в Delphi. Часть III: Альфа-канал

В предыдущих частях (часть 1, часть 2) мы реализовали несколько алгоритмов размытия — от наивной свёртки до быстрого Stack Blur. Но всё это время мы работали только с тремя каналами: R, G, B. Альфа-канал мы либо игнорировали, либо просто копировали из исходника.

Для непрозрачных изображений, таких как фотографии, скриншоты, этого достаточно. Но стоит взять картинку с прозрачностью и применить к ней размытие — результат окажется неправильным: резкие контуры там, где ожидаешь плавный переход, цветные ореолы на границе прозрачных областей, тёмные каёмки вокруг светлых объектов.

Всё это из-за того, что альфа-канал не участвовал в размытии наравне с цветом. В этой части мы разберёмся, почему так происходит, какую роль играет формат хранения пикселей (straight vs premultiplied alpha), и как правильно модифицировать наши алгоритмы, чтобы размытие корректно работало с прозрачностью.

В чём проблема

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

Результат сразу покажет проблему: по контуру объекта появляется тёмная каёмка, а края остаются резкими, несмотря на сильное размытие.

Почему так происходит? Наши алгоритмы из предыдущих частей размывали только три канала — R, G и B. Альфа-канал при этом просто копировался из исходного изображения. В результате цвета на границе объекта смешиваются с чёрными пикселями прозрачной области (прозрачный пиксель в premultiplied режиме — это RGBA(0, 0, 0, 0)), а граница прозрачности остаётся нетронутой — такой же резкой, как в оригинале.

Получается парадокс: содержимое размыто, а контур — нет. Вместо плавного растворения краёв мы видим мутное изображение, обрезанное по жёсткой маске.

Чтобы исправить это, нужно размывать все четыре канала, включая альфу. Но делать это нужно правильно, и здесь ключевую роль играет формат хранения прозрачности пикселей.

Формат хранения цвета с прозрачностью

Существует два способа хранения цвета с прозрачностью: straight alpha и premultiplied alpha.

Straight alpha

В формате straight alpha (также называемом unassociated) каналы R, G, B хранят «чистый» цвет, а альфа-канал — степень непрозрачности, независимо от цвета. Например, полупрозрачный красный пиксель выглядит так:

Цвет и прозрачность здесь существуют отдельно друг от друга. Это интуитивно понятно, но создаёт проблему: полностью прозрачный пиксель (A = 0) всё равно может хранить произвольный цвет. Такой «призрачный» цвет невидим при отображении, но при размытии он начнёт участвовать в вычислениях наравне с остальными и испортит результат.

Premultiplied alpha

В формате premultiplied alpha (associated) каждый цветовой канал заранее умножен на альфу:

Тот же полупрозрачный красный хранится как:

А полностью прозрачный пиксель всегда равен (0, 0, 0, 0), независимо от того, какой цвет был «под» прозрачностью. Призрачных цветов здесь не существует. Умножаем на ноль, получаем ноль.

Именно это свойство делает premultiplied alpha правильным выбором для размытия. Когда прозрачный пиксель представлен как (0, 0, 0, 0), он вносит нулевой вклад во все каналы при усреднении — и в цвет, и в прозрачность. Никакие скрытые цвета не просачиваются в результат.

Более того, в premultiplied формате размытие сводится к простому применению одного и того же фильтра ко всем четырём каналам. Никаких специальных формул, делений на альфу или условных проверок — просто четыре канала вместо трёх. Это математически корректно, потому что размытие — это линейная операция, а premultiplied alpha как раз и создан для того, чтобы линейные операции над пикселями давали правильный результат.

В Delphi формат задаётся свойством AlphaFormat у TBitmap:

В дальнейшем мы будем исходить из того, что входное изображение уже хранится в этом формате.

Неочевидный нюанс VCL

Устанавливая AlphaFormat, важно понимать один неочевидный нюанс реализации VCL. Когда вы присваиваете битмапу, в котором уже есть данные, любое значение, отличное от afIgnored, будь то afDefined или afPremultiplied, VCL вызывает внутреннюю процедуру PreMultiplyAlpha, которая умножает каждый цветовой канал на альфу. И обратно: при переключении на afIgnored вызывается UnPreMultiplyAlpha, которая делит каналы на альфу. При этом между afDefined и afPremultiplied никакого преобразования не происходит, разница между ними чисто семантическая.

Из этого следует одно очень важное практическое правило. Не надо переключать AlphaFormat туда-сюда на битмапе с данными: каждый цикл afIgnored -> afPremultiplied -> afIgnored — это умножение и деление с потерей точности из-за целочисленной арифметики, и после нескольких таких циклов полупрозрачные пиксели заметно деградируют.

Почему premultiplied решает проблему

Вернёмся к проблеме из первого раздела и разберём её детально. Допустим, у нас есть два соседних пикселя на границе объекта — непрозрачный красный и полностью прозрачный:

При простом усреднении без учёта альфы мы получим:

Красный потемнел вдвое, хотя рядом не было никакого тёмного объекта — только пустота. Именно так и возникает тёмная каёмка.

Теперь сделаем то же самое, но размоем все четыре канала в premultiplied формате. Данные в нём выглядят точно так же — просто для прозрачного пикселя это и так нули, а для непрозрачного premultiply ничего не меняет:

Конвертируем обратно в обычный цвет: R = 128 × 255 / 128 = 255. Цвет остался чистым красным, просто пиксель стал полупрозрачным. Именно этого мы и ожидаем на границе объекта: плавное растворение в прозрачность без изменения оттенка.

Ситуация становится ещё хуже, если прозрачный пиксель хранит «призрачный» цвет. В straight alpha это допустимо — пиксель невидим, какая разница, что в RGB? Но при размытии эти скрытые цвета всплывают:

Откуда взялся жёлтый на границе красного объекта? Из невидимого пикселя, который никогда не отображался на экране. В premultiplied формате такой ситуации не возникает в принципе: при A=0 все цветовые каналы тоже равны нулю, и скрытых цветов просто не существует.

Поэтому давайте перепишем выбранный в первом разделе метод с учётом premultiply и альфа-канала.

Размываем альфа-канал

Итак, берём реализацию скользящей суммы из второй части и пытаемся добавить размытие альфа-канала. На вход подаётся, как мы ранее договорились, уже premultiplied битмап. Осталось продолжить логику алгоритма на четвёртый канал с альфой.

Первая попытка: Формат результата

Как было сказано ранее, надо просто добавить размытие альфа-канала, аналогичное тому, как это делается для RGB-каналов. То есть добавляем переменную для хранения суммы значений альфа-канала и работаем с ней, как с остальными суммами.

При назначении в результат мы не берём значение исходной альфы, а рассчитываем, как остальные:

Итак, мы добавили альфа-канал в размытие — теперь все четыре канала обрабатываются одинаково. Запускаем, смотрим результат…

и видим, что края стали плавными — это хорошо, но они какие-то… тёмные? Что пошло не так?

Причина потемнения совсем другая, чем в первом разделе. Сейчас проблема в том, что мы размыли premultiplied данные, но записали результат в битмап, у которого не выставлен AlphaFormat := afPremultiplied. VCL считает, что пиксели хранятся в обычном формате (straight alpha), и при последующей установке AlphaFormat применяет premultiply повторно — умножает каналы на альфу ещё раз.

Разберём на конкретном пикселе. После размытия мы получили корректное premultiplied значение:

Это полупрозрачный красный. Но если битмап помечен как afIgnored, а затем где-то в коде или при выводе на экран происходит конвертация в premultiplied, VCL умножает каналы на альфу ещё раз:

Именно это мы и наблюдаем на скриншоте: изображение чуть потемнело целиком, а полупрозрачные области пострадали сильнее всего, ведь чем ниже альфа, тем сильнее повторное умножение «съедает» яркость.

Исправление простое — нужно явно и сразу указать результату формат своих данных:

Это скажет VCL при выводе картинки: «данные уже premultiplied, не трогай их».

Вторая попытка: Слишком медленно

Мы делаем всё правильно, выставляем альфа-формат результату, и промежуточному битмапу тоже.

Запускаем, смотрим…

и видим именно то размытие, которое ожидали. Но время выполнения неприятно удивляет. Даже по сравнению с предыдущей реализацией оно увеличилось почти в 2.5 раза! Хотя мы добавили всего две строки с присвоением формата.

На самом деле время крадётся во время присваивания AlphaFormat:

До этого присваивания мы установили размер битмапа. Последующее назначение формата прозрачности вызовет перерасчёт цвета по всему битмапу. В нашем случае (600 на 600) это 360 000 пикселей, по несколько операций на пиксель: 3 умножения + 3 деления (или сдвига) + чтение/запись. Это немало, и более того, совершенно не нужно.

При назначении размера битмапа формат альфы пикселей не сбрасывается. Поэтому формат альфы имеет смысл выставлять до установки размеров.

В этом случае, когда и высота, и ширина битмапа равны нулю, ничего не происходит — данных нет, не на чем производить операции, время не тратится. Последующий вызов SetSize(W, H) заполнит массив данных нулями, что тоже совершенно корректная операция.

Финальная попытка: Скользящая сумма с альфой

Учитывая все предыдущие ошибки, делаем следующую реализацию алгоритма размытия скользящей суммой, которая учитывает альфу.

Скользящая сумма с альфой

[свернуть]

И вот этот вариант работает уже и быстро, и хорошо.

Итог: Тройной Box Blur с альфой

Теперь мы можем написать полноценный блюр с альфой на основе ранее написанного тройного Box Blur’а.

Чуть дольше, чем такой же алгоритм без альфы, но это ожидаемо. Сравним методы далее, в бенчмарке.

Что ж, как говорил Михаил Жванецкий — «Общим видом овладели, теперь подробности не надо пропускать». Переделка двух значимых для нас алгоритмов — Fast Gaussian Blur и Stack Blur — труда не составит.

Gaussian и Stack с альфой

Применим все принципы, изложенные выше, к двум алгоритмам, которые представляют для нас наибольший практический интерес: Gaussian blur математически точно воспроизводит купол нормального распределения, а Stack Blur — самый быстрый, с лучшим результатом сглаживания, чем скользящая сумма.

Быстрый Gaussian с альфой. Этот блюр мы не будем включать в бенчмарк. Он будет ожидаемо сильно медленней остальных. Применять его в UI мы не будем никогда. Вряд ли он понадобится и в обработках, про которые собираюсь говорить в следующих статьях. Но для полноты картины, согласитесь, эталон должен быть.

Листинг: BlurGaussianSeparableFastA

[свернуть]

Stack Blur с альфой. Особых отличий от Box Blur нет. К трём суммам RGB добавилась четвёртая — для альфы. Остальное без изменений.

Листинг: BlurStackBlurSigmaA

[свернуть]

Скрины не привожу по той причине, что они визуально не отличаются от приведённого выше. Всегда можно скачать демо-приложение в конце статьи и поэкспериментировать самостоятельно.

С математическими блюрами разобрались — изменения минимальны. Но Downscale+Upscale использует масштабирование через GDI, и здесь нас ждёт сюрприз.

Downscale+Upscale Blur с альфой

Проблема в том, что наш хитрый метод напрочь отказывается быть прозрачным.

Тут всё просто. Для качественного масштабирования мы использовали режим HALFTONE в функции SetStretchBltMode. К сожалению, при таком режиме мы теряем прозрачность. Для сохранения альфы будем использовать режим COLORONCOLOR. Качество уменьшения при этом снижается, ближайший сосед (nearest neighbor) вместо усреднения, но последующее размытие скрадывает разницу.

Таким образом, наш хитрый метод размытия претерпевает минимальные, но важные изменения:

Листинг: BlurDownscaleUpscaleA

[свернуть]

В итоге видим на скрине очень симпатичное размытие, практически не отличимое от Гаусса.

Хочу отметить, что COLORONCOLOR — не лучший выбор. Лучшей альтернативой было бы применение API-функции AlphaBlend, которая корректно обрабатывает premultiplied данные. Но очень не хотелось раздувать код и терять общую наглядность алгоритма. В продакш-версии это надо учитывать, исходя из обстоятельств применения этого способа размытия.

Бенчмарк

Методика измерений та же, что в предыдущих частях: случайный порядок запусков, 30 замеров на метод, изображение 600×600, платформа x64, два режима — σ = 5 и σ = 50. Для сравнения на диаграмме оставлены версии без альфы из второй части и Direct2D в качестве ориентира.

Добавление четвёртого канала обошлось удивительно дёшево. Сравним попарно:

МетодБез альфыС альфойРазница
Box Blur ×3, σ=536.3 мс38.9 мс+2.6 мс
Box Blur ×3, σ=5036.0 мс38.7 мс+2.7 мс
Stack Blur, σ=515.0 мс18.7 мс+3.7 мс
Stack Blur, σ=5015.7 мс19.8 мс+4.1 мс
DownUp, σ=531.7 мс27.0 мс−4.7 мс (!)
DownUp, σ=5014.7 мс14.4 мс−0.3 мс

Box Blur и Stack Blur ведут себя предсказуемо: добавился четвёртый канал — добавилось 3–4 миллисекунды. Это примерно 7–25% сверху, что даже меньше ожидаемых 33% (четыре канала вместо трёх). Основное время в этих алгоритмах тратится не на арифметику, а на доступ к памяти, и один дополнительный байт на пиксель не сильно меняет картину.

Downscale-Upscale при σ=5 стал даже быстрее, но это не заслуга альфы, а скорее следствие того, что альфа-версия использует COLORONCOLOR вместо HALFTONE при масштабировании, что быстрее. При σ=50 разница в пределах погрешности.

Главный вывод: поддержка альфа-канала — не роскошь. За корректную обработку прозрачности мы платим считанные миллисекунды, получая взамен правильный результат без артефактов на границах. В продакшн-версии, скорее всего, надо отдать предпочтение варианту, сразу учитывающему альфа-канал, потому что замедление ничтожно мало, зато не надо поддерживать две версии алгоритмов — с альфой и без.

Direct2D по-прежнему недосягаем на GPU (7 мс).

Потенциальные ошибки при размытии с альфа-каналом

Частично, мы уже разбирали эти случаи выше, здесь приводятся для полноты чеклиста. Прошу простить меня за занудство, но ничего не могу с собой поделать.

Размытие в неправильном цветовом пространстве

Самая частая ошибка. Размытие straight alpha данных как есть:

Результат ошибки: цветные ореолы (color fringing) вокруг полупрозрачных краёв «белая каёмка», «тёмные края».

Забыли размыть альфа-канал

Результат ошибки: резкие границы по прозрачности при размытых цветах. Объект выглядит «вырезанным ножницами», но с мутным содержимым.

Нарушение инварианта R ≤ A

После размытия из-за ошибок округления возможно:

Защита:

Для наших алгоритмов это не представляет реальной проблемы. В Box Blur и Stack Blur используется целочисленное деление с одним и тем же делителем для всех каналов, поэтому если исходные данные валидны (R ≤ A), результат тоже будет валиден. В Gaussian свёртке все веса неотрицательны и делитель одинаков для всех каналов. Если на входе R ≤ A для каждого пикселя, то взвешенная сумма R не может превысить взвешенную сумму A, а одинаковый shr порядок не нарушает.

Нарушение возможно только если:

  • Исходные данные уже невалидны;
  • Веса могут быть отрицательными (например, sharpen-фильтр);
  • Каналы делятся с разными делителями (что было бы багом).

Двойное premultiply/unpremultiply

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

AlphaFormat при создании промежуточных битмапов


Скачать

Друзья, спасибо за внимание!

Исходник (zip) 709 Кб. Delphi XE 7, 13.0

Исполняемый файл x32 (zip) 1.69 Мб. Built in Delphi XE 7

Исполняемый файл x64 (zip) 2.22 Мб. Built in Delphi 13.0


5 1 голос
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии