Фильтр реалистичной, «пленочной» коррекции теней

Сегодня я не буду подробно описывать как с помощью Metal Framework и Metal Shading Language запрограммировать фильтр. Т.е. не буду останавливаться на деталях реализации. В целом, предыдущего поста должно было быть достаточно для уверенного понимания как варится программа с использованием этих средств.

Сегодня мы сосредоточимся на идее нового фильтра с точки зрения разработчика этого фильтра (инженера или математика) и покажем как от идеи до воплощения в коде заставить железку работать на благо, например, эстетики. Подумаем как сделать изображение более «привлекательным» в некоторых, специальных случаях.

В общем начнем простыми и местами научными методами учить наш iPhone или iPad быстро и качественно корректировать тени несколько недоэкспонированных или высококонтрастных фотографий и, возможно, потерявших непростительно много деталей в тенях. Предлагаемый способ можно назвать универсальным алгоритмом коррекции теней произвольных изображений, в отличие, скажем, от версии Adobe реализованной в инструменте Shadows/Highlights алгоритм, который я опишу сегодня, нельзя воспроизвести средствами фоторедакторов, по крайней мере пока.

Этот алгоритм используется как часть одного большого фильтра изображений в новой версии фото-приложения Degradr. И надо сказать существенно влияет на конечный результат всей работы.

Если по чесноку, и вот без этого кривляния, основная цель сегодняшнего поста показать как легко можно сосредоточиться на идее и не тратить время на прикладную часть: сбивании пальцев рук в ацкие мозоли при набивании текста программы фильтра. Metal Framework и Metal Shading Language — это все, что вам нужно для реализации вашей идеи. Сосредоточьтесь на главном: на реализации.

Идея

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

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

{f(rgb) = S(L(rgb)_{1},L(rgb)_{2})} — функция смешивания слоев яркостных каналов изображения,
где {S = (1.0 - ((1.0 - L_{1})(1.0 - L_{2})))} — функция смешивание в режиме Screen

Выглядеть такая штука (смешивание изображения с самим собой) будет так:
ScreenBlendingCurve

На графике хорошо видно, что это частный случай функции гамма-коррекции. Или как если бы мы применили инструмент «Кривые» к изображению в Photoshop-е аккуратно притянув сплайны к нужным точкам. Но кривые, и сплайны — это слишком грубая работа для пытливого ума. Не наш, в общем метод…

Нетрудно догадаться, что функцию можно применить дважды и получить еще более высветляющую кривую:
ScreenDubleBlendingCurve

Вот эту функцию, которая более, и возьмем за основу «высветляющей» части фильтра — это будет гамма коррекция негатива изображения с гаммой равной 4 и последующим «отпечатком» в исходный позитив (поэтому я назвал коррекцию «пленочной» — работаем с «негативом»):

{L_{o} = 1 - (1-L_{i})^{4}}

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

Свертку можно придумать любую, вы можете свою, даже может быть еще лучше. Но смысл должен быть тут ясен: нам нужна плавная монотонно-убывающая функция. Хотя подойдет и просто линейная, но работать она будет хуже. При желании, вы сможете это проверить после сборки проекта, заменив мою версию на свою.

Напомню как смешиваются каналы в нормальном режиме с учетом альфа-композиции двух исходных изображений:

{C^{rgb}_{o} = \frac{\alpha_{i}}{\alpha_{s}}C^{rgb}_{i}+(1-\frac{\alpha_{i}}{\alpha_{s}})C^{rgb}_{s}}

Или результирующее выражение свертки для нашего случая:

{(L_{i}*L_{s})(C^{rgb}_{o}) = \frac{\alpha_{i}}{\alpha_{s}}C^{rgb}_{i}+(1-\frac{\alpha_{i}}{\alpha_{s}})C^{rgb}_{s}}W_{c_{o}}

Вес осветленного изображения в зависимости от значения яркостного канала исходного изображения подобрать несложно:

{W_{c_{o}}(L_{i}) = \frac{W}{\exp^{\frac{6K_{s}L_{i}}{w_{t}}}}w_{t}\ \begin{cases}0\leq{W}\leq1\\0<{w_{t}}\leq1\\1\leq{K_{s}}\leq5\end{cases}} ,

    где {C^{rgb}_{z}} — представление изображения в цветовом пространстве RGB для {o\ } utput,{i\ } nput, {s\ } creen соответственно
    {L_{i}} — яркостный канал исходного изображения
    {L_{s}} — яркостный канал осветленного изображения
    {W} — вес осветленного изображения
    {K_{s}} — коэффициент наклона свертки
    {w_{t}} — тональная ширина теней, т.е. на сколько захватываем в яркостном канале тени

Небольшой update по следам дискуссии

Результирующая функция-свертка от функции гамма-коррекции инвертированного изображения и веса альфа канала получается такой (синяя кривая — поправили, исправляю: «магическая некривая линия коррекции теней»):

ShadowsCorrection-W-Convolve

Куём математику в железо: проверяем гипотезу

//
//  IMPFilter.metal
//  ImageMetalling-00
//
//  Created by denis svinarchuk on 27.10.15.
//  Copyright © 2015 ImageMetalling. All rights reserved.
//

#include <metal_stdlib>
#include <simd/simd.h>
using namespace metal;

inline float4 blendNormal(float4 c2, float4 c1)
{
    //
    // from: https://github.com/BradLarson/GPUImage
    //
    
    float4 outputColor;
    
    float a = c1.a + c2.a * (1.0 - c1.a);
    float alphaDivisor = a + step(a, 0.0);
    
    outputColor.r = (c1.r * c1.a + c2.r * c2.a * (1.0 - c1.a))/alphaDivisor;
    outputColor.g = (c1.g * c1.a + c2.g * c2.a * (1.0 - c1.a))/alphaDivisor;
    outputColor.b = (c1.b * c1.a + c2.b * c2.a * (1.0 - c1.a))/alphaDivisor;
    outputColor.a = a;
    
    return clamp(outputColor, float4(0.0), float4(1.0));
}

typedef struct{
    packed_float4 shadows;       // [level, weight, tonal width, slop]
} IMPShadows;


//
// Прямое переложение функции расчета веса светов в якростном канале сигнала
//
inline float luminance_weight(float Li, float W, float Wt, float Ks){
    return W / exp( 6 * Ks * Li / Wt) * (Wt * 0.5);
}

//
// РЕзультирующая функция коррекции теней
//
inline float4 adjustShadows(float4 source, constant IMPShadows &adjustment)
{
    float3 rgb = source.rgb;

    //
    // выучите эту строчку наизусть, используется почти везде
    // можно запомнить как 3/6/1
    //
    // почитать можно тут: https://en.wikipedia.org/wiki/Relative_luminance
    // исходная формула относительной яркости в колорометрии:
    // Y = 0.2126 R + 0.7152 G + 0.0722 B
    // но мы работаем не с колорметрически измеренным значением RGB, а с представлением
    // rgb в виде sRGB цветового пространства. Так случилось, что быстрое преобразование:
    // L(rgb)= (r,g,b)(0.299, 0.587, 0.114)', для наших целей подходит лучше
    // и подтверждается рядом экспериментов с большим набором изображений.
    //
    float luminance = dot(rgb, float3(0.299, 0.587, 0.114));

    //
    // Распаковываем выходной буфер, прилетевший из памяти приложения в память GPU
    // подразумеваем:
    // 1. x - уровень воздействия фильтра
    // 2. y - коэффициент нормализации фильтра (по умолчанию = 1 и мы его не трогаем)
    // 3. z - тональная ширина охвата фильтра, т.е. насколько далеко мы восстанавливаем тени от черной точки
    // 4. w - коэффициент наклона (slope) кривой фильтра, т.е. скорость сниения воздействия в зависимости от
    //        яркости
    //
    float4 shadows(adjustment.shadows);
    
    float weight = luminance_weight(luminance,
                                    shadows.y,
                                    shadows.z,
                                    shadows.w);
    
    //
    // Альфа канал - функция уровня воздействия фильтра и вес от яркости
    //
    float  a(shadows.x * weight);
    
    //
    // Функция смешивания в режиме screen 2 раза или
    // гаммакорекция негатива с гаммой == 4
    //
    float3 c(1.0 - pow((1.0 - rgb),4));
    
    //
    // Результат смешиваем в нормальном режиме с учетом композиции в альфа канале
    //
    return blendNormal (source, float4 (c , a));
}


kernel void kernel_adjustSHL(
                             texture2d<float, access::sample> inTexture [[texture(0)]],
                             texture2d<float, access::write> outTexture [[texture(1)]],
                             constant IMPShadows &adjustment             [[buffer(0)]],
                             uint2 gid [[thread_position_in_grid]]
                             )
{
    float4 inColor = inTexture.read(gid);
    outTexture.write(adjustShadows(inColor, adjustment), gid);
}

Весь остальной код, с некоторыми изменениями, копируем из предыдущего проекта. В итоге получаем: аппликуху магически «улучшающую» любые фотографии. Скачать исходный код можно поссылке. Cобирать проект в папке: ImageMetalling-01.

Добавлю небольшие пояснения по коду MSL. В нем можно увидеть вот такой кусок:

typedef struct{
    packed_float4 shadows;       // [level, weight, tonal width, slop]
} IMPShadows;
...
    constant IMPShadows &adjustment             [[buffer(0)]],
....

Он означает, что мы передаем в память GPU не готовую структуру данных, а упакованную, т.е. без побайтного выравнивания. Разработчики Metal позаботились о возможности упаковать произвольную структуру данных в основном коде приложения и передать её в программу исполняемую на GPU. Поскольку мы не знаем о природе выравнивания данных в памяти GPU, мы должны использовать такую нотацию для многомерных структур. Очевидно, что в нашем случаем можно сконструировать свою структуру для параметризации функции фильтра, но помня о главном принципе программиста: не плодить лишних сущностей, использовали готовый вектор класса float4 из комплекта Metal Framework.

В проект можно подгрузить любую свою фотографию, можно даже переделать код для чтения изображений из Camera Roll и запись обратно. Я украл взял фотоку Павла Косенко с каким-то экстремальным случаем теней, и поэкспериментировал с ней:

Пример работы фильтра
Бинго! Работает. Можно нас поздравить: наша теория работает в железе, и скорее всего она верна.

Что еще можно сказать?..
Совсем не трудно догадаться, что операция затемнения светов строго симметрична осветлению теней. Единственное отличие, которое можно себе вообразить: нужно инвертировать свертку и смешивать изображения в режиме Multiply.

Notice (отчего-то спрашивают): По тексту, когда мы говорим об изображении, всегда имеется в виду один пиксел. Мы продолжаем помнить о параллелизме вычислений: функция фильтра вычисляется для каждого пиксела отдельно и сразу. Я думаю, вы привыкните к этому скоро, если еще не имели дело с вычислениями на графических сопроцессорах.


Если заметили ашипку, если написал глупость, если что-то не понятно: комментируйте или пишите на: imagemetalling [*] gmail.com .

Фильтр реалистичной, «пленочной» коррекции теней: 27 комментариев

    1. Назвать можно как угодно, в том числе и выбрать слово для привлечения внимания.

      Почему «пленочной» отчасти объясняется в самом посте:

      1. работаем с «негативом», т.е. с инверсией изображения

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

      Нравится 1 человек

    1. Павел перенаправил сюда, поэтому спрошу и здесь. Чем отличается (принципиально) такой подход от попытки смешать два слоя (осветленный и оригинальный) по маске теней в фотошопе (кроме того, что я не могу там маску экспонентой задать)?

      Нравится

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

        В этой модели композиция используется только для того, что бы показать воздействие алгоритма.

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

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

        В предлагаемом решение происходит эмуляция процессов экспонирования и проявки негативной пленки.

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

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

        Нравится 1 человек

  1. Насколько я понимаю, яркость результирующей точки зависит исключительно от яркости исходной. Раз так, то это значит, что то, что вы изобрели, — это банальная кривая, несколько приподнятая в тенях и переходящая в прямую у=х ближе к светам. Уж по каким формулам она приподнята — другой вопрос. В вычислительном смысле такое преобразование делается в ОДНО ДЕЙСТВИЕ по таблице, расчет которой делается заранее при ничтожных затратах.

    Нравится

    1. Сергей, вы абсолютно правы: результирующая функция может быть преобразованием почти в одной действие. Более, того я прямым текстом отсылаю к кривым, которыми эта функция может быть реализована, точнее осветляющая, её часть.

      Но целью этого поста было не показать изобретение, которого здесь нет, а как использовать новое инструментальное средство Apple под названием Metal Framework и Metal Shading Language для реализации конкретной функции.

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

      Но я, пожалуй, категорически не соглашусь с утверждением «…какими формулами она будет поднята — другой вопрос…».

      Очевидно, что предметом изучения и практическим результатом исследований в конкретной прикладной области — обработке цвета (света) фотографий, является аналитический, эмпирический или доказательный способы поиска функций отображения одного цветового (светового) пространства изображения. В данном примере используется один из миллиона случаев, который удовлетворяет конкретно моей цели. Вы можете предложить свой вариант, и он, несомненно будет точно таким же решением ровно той же задачи.

      Кстати, будет весьма интересно увидеть предложенное решение и подискутировать, на эту тему.

      И спасибо за проявленный интерес!

      Нравится 1 человек

      1. Кривые они и есть кривые. Это по поводу пожалуй, «категорически не соглашусь с утверждением Никаких находок по части обработки цвета тут не предвидится. Так, в
        В Вашем случае поднятие теней приведет к потере контрастности в средних тонах.

        Еще мне кажется, что то, что Вы называете сверткой, таковой не является:

        https://ru.wikipedia.org/wiki/%D0%A1%D0%B2%D1%91%D1%80%D1%82%D0%BA%D0%B0_(%D0%BC%D0%B0%D1%82%D0%B5%D0%BC%D0%B0%D1%82%D0%B8%D1%87%D0%B5%D1%81%D0%BA%D0%B8%D0%B9_%D0%B0%D0%BD%D0%B0%D0%BB%D0%B8%D0%B7)

        А то, что предлагаете выучить наизусть:

        // выучите эту строчку наизусть, используется почти везде
        //
        float luminance = dot(rgb, float3(0.299, 0.587, 0.114));

        это неправильный способ вычисления светлоты.

        Упражнения с Metal любопытны.

        Нравится 1 человек

      2. Сорри, что обозвал гауссиану… Но все таки:
        blendNormal (source, float4 (c , a)) — это и есть «композиция двух сигналов с учетом альфа канала…», это возвращаясь к цитате про фотошоп. Отдельная задача в фотошопе получить оствеляющий сигнал (достаточно просто) и маску(немного сожнее).

        Немного туплю (точнее не хватает бекграунда в фунциональном анализе), но причем тут свертка двух функций? Ну, в смысле, что я знаю что такое свертка функций в контексте конструирования 1D/2D фильтров, но тут что-то совсем не понял.

        Нравится

  2. Никаких противоречий в своем утверждении не вижу.

    Форма кривых определяет результат, поиск формы кривой: цель подобных задач. На мой взгляд — это самоочевидно, вопрос именно в форме. В конкретном примере, форма вот такая. Доказательством этого простого утверждения, является бесчисленное количество методик обработки фотографий с помощью кривых, бесчисленное количество LUT-для постпродакшена фильмов, бесчисленное количество публикаций обработки медицинских снимков и т.п. — это все база поиска условно «хорошей» кривой для каких-то целей. В развлекательных целях можно найти еще с пару 10ков пока не надоест. То, что это будут какие-то кривые — не предмет дискуссии. Очевидно, это будут кривые.

    Не думал, что часть про свертку может вызвать недопонимание, поскольку является, достаточно стереотипным подходом: в данном случае функция f=Lo, g=W(Li), сверткой будет результирующая функция от этих двух (если так удобнее воспринимать), которую вот в Metal и выливаем. Эта свертка удовлетворяет всем основным свойствам: коммутативности, линейности и т.п.

    Степень воздействия на контраст в средних можно уменьшить сужая ядро W(Li): ws. Задачу «не снижать» контраст в средних этот вариант фильтра не решает. И я бы вообще, с точки зрения «гармонизации изображения», разделял эти фильтры.

    Нравится 1 человек

      1. Не совсем так — это частный случай для интегрируемых функций. Свертка — это любая функция от двух других, удовлетворяющая свойствам коммутативности, ассоциативности, линейности и непрерывности на отрезках непрерывных для исходных функций. В приведенном примере из поста — это простая свертка последовательностей.

        Нравится 1 человек

      2. Если следовать последнему вашему определению, тогда ясно. Но такого определения я нигде не нашел. Во всех источниках — интеграл/сумма. А у нас по факту просто произведение двух функций. Что тоже, как я понимаю, можно называть сверткой?

        Нравится

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

        Вот всю это чепуху я опустил и сразу приступил к выводам. Пока вижу, что вызвало больше вопросов.

        Нравится

      4. Круть. На пальцах, не вдаваясь в теоретические подробности, казалось бы, все просто: перемножили две картинки и добавили к исходной картике… Но по сути, вся эта теория применима и к смешиванию слоев в фотошопе для normal blending.

        Нравится

      5. Абсолютно. Тут главное не стесняться насиловать и математику и железо и иногда здравый смысл;)

        Рад, что вам понравилось — это мотивирует продолжать бложить на эту тему.

        Нравится

    1. Нет, на самом деле, если есть возможность, то здорово было использовать «глубокую» математизацию там, где без этого иначе нельзя. В данном конкретном выражении вполне можно было обойтись и достаточно простой формой: Y(x,y) = A(x,y) + B(x,y) * M(x,y). Во-первых, интерпретация такого выражения для «фотографа» достаточно проста: A — исходное изображение, B — осветленная его версия, M — маска воздействия. Опять таки, в данном случае становится совершенно ясной аналогия с использованием слоев в фотошопе, но с какими-то ограничениями или недостатками. Во-вторых, это никак не противоречит цели статьи — пояснить принципы построения фильтров для выполнения на железе. Это мое субъективное мнение.

      Еще хотел бы добавить про аналогии с «пленочностью» при обработке цифровых изображений. Это весьма тонкая материя. Часто субъективная. Неплохо было бы как-то прийти к общему знаменателю с аналоговыми процессами при проявке/печати. Таким знаменателем, например, может быть общая система координат. Если проводить аналогию с пленкой, то неплохо было использовать логарифмическую систему координат — привычную систему для ХК пленок.

      Спасибо за ваши ответы и полезную, по крайней мере для мнея, дискуссию.

      Нравится

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

        По поводу реализации, конечно можно и так решить. Это решение очевидное. Однако хотелось показать как можно использовать смешивание как функцию другой. Раз.
        А два, если немного заглянуть вперед, мы можем написать некую библиотеку фильтров одним из общих параметров, которых, будет выбор режима смешивания, тогда уже не важно какой режим мы выбираем: нормальный, света, перекрытия и т.п. — мы мыслим в рамках функции альфа канала, а режим выбираем как параметр фильтра: так проще организовать цепочки их применения, без создания дополнительных специальных смешивающих фильтров, как, к примеру это организовано в Core Image или GPUimage (это просто лишние текстуры и накладные расходы на обмен в памяти GPU/CPU. В этом есть некоторое преимущество перед мышлением в терминах маски.

        Но это уже не относится к теме совсем. Тут можно и так и так поступать — не принципиально.

        Нравится

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

      Нравится

      1. Тогда не называйте это «кривой», поскольку в обсуждаемом контексте «кривую» понимают, скорее, в «фотошоповском» смысле. И не показывайте на графике, где по оси абсцисс input, а по оси ординат output.

        Нравится

      2. Сергей, как вам угодно. Давайте называть кривую некривой. Мне нравится: «магическая некривая линия осветления теней». Подходит? Правда, я немного теряюсь мы сейчас про какую из функций?

        Нравится 1 человек

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

      И да, плевать в чужом блоге, все же несколько не воспитанно. За дискуссию в целом спасибо.

      Почему яркостный канал в этом примере и других считается не по колорометрическому вектору: 0.2126, 0.7152, 0.0722, а по линейному, объясню чуть позже (критиковали чуть выше), хотя это не сильно принципиально. В первом ~ так: 3/6/1 запомнить проще и лежит все в пределах допустимого: 1. перцептуальной погрешности, 2. разброса калибровок мониторов.

      Нравится

Оставьте комментарий