Функциональное мышление и мышление кривыми при проектировании фильтров изображений на примере фильтра Shadows/Highlights

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

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

И да, не воспринимайте слишком серьезно слишком амбициозные заголовки этого блога. Хотя я и не буду в этот раз углубляться в философию мышления, но все-же хочется немного убедить читателя: Image Processing в фотографии, скорее развлекательная история, чем серьезный научный раздел. Так и надо к нему подходить, я счетаю. Если принято использовать LUT или кривые для решения задачи, можно попытаться представить себе процесс по другому, поиграть с палитрой решений и в целом получить немного отличающийся результат. Сегодня я покажу как, в столь тривиальном случае, можно немного улучшить эстетику результата одного из самых распространенных фильтров на планете (на самом деле нет, если вы зануда…). Оригинальный фильтр по понятным причинам я разбирать не буду — его описание было уже сделано. Займемся возней вокруг кривых.

Фильтр коррекции светов и теней Shadows/Highlights

Как вы помните цель поста была просто написать какой-нибудь простой фильтр, который не просто прост, а предельно прост и делает, что-то сверх полезное. А потом показать как все это реализуется в железе на iOS совместимом устройстве типа iPhone/iPad средствами Metal Framework и Metal Shading Language.

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

Поскольку операция над светами симметрична теням, то сделаем сегодня уже полноценный фильтр Shadows/Highlights.

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

Результат той самой функции будет кривая, которую можно показать вроде вот такого графика:
Кривая коррекции теней и светов: Shadows/Highlights

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

Что мы можем сделать с этой кривой?

  1. Перевести исходное RGB-пространство в одной из пространств с яркостным каналом: HSV, HSL, HCL, Lab и тп. И приложить кривую к каналу яркости. Выберем для простоты HSV
  2. Ничего никуда не переводить просто провести преобразования по типу вытягивания кривых в композитной кривой RGB ala-фотошоп. Тоже самое можно представить как маскирование теней/светов в режиме смешивания screen/multiply
  3. Провести операцию над относительной яркостью RGB как мы делали это в исходной статьей и смешать кривую относительной яркости в режиме Luminosity с исходной картинкой
  4. Кучу еще всего, но остановимся на этих 3-х случаях

 

Вот и займемся кодированием фильтра, прямо в MSL:

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

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

//
// Маска теней для вектора RGB/канала яркости в произвольном числовом формате формате
//
template<typename T> inline T LsMask(T Li, float W, float Wt, float Ks){
    T c(1 - pow((1 - Li),4));
    return c * W / exp( 6 * Ks * Li / Wt) * Wt;
}

//
// Маска светов для вектора RGB/канала яркости в произвольном формате
//
template<typename T> inline T LhMask(T Li, float W, float Wt, float Ka){
    return 1 - LsMask(1-Li,W,Wt,Ka);
}

//
// Результирующая кривая коррекции канала яркости
//
template<typename T> inline T shlCurve(T Li, constant IMPShadowsHighLights &adjustment){
    
    T lh = LhMask(Li,
                  adjustment.highlights.x,
                  adjustment.highlights.y,
                  adjustment.highlights.z
                  );
    
    T ls = LsMask(Li,
                  adjustment.shadows.x,
                  adjustment.shadows.y,
                  adjustment.shadows.z
                  );
    
    return T ((Li + ls) * lh);
}

//
// Результирующая кривая коррекции свето-тени в RGB пространстве
//
inline float4 adjustRgbShadowsHighlights(float4 source, constant IMPShadowsHighLights &adjustment)
{
    float3 curve ( shlCurve(source.rgb, adjustment) );
    //
    // Результат смешивание с учетом композиции в альфа канале.
    //
    return blendNormal (source, float4 (curve , adjustment.level));
}

//
// Результирующая кривая коррекции свето-тени в L канале и смешивании в светах
//
inline float4 adjustLumaShadowsHighlights(float4 source, constant IMPShadowsHighLights &adjustment)
{
    float luminance = dot(source.rgb, luma_factor);

    float3 curve = shlCurve(luminance, adjustment);
    //
    // Результат ссмешивае с учетом композиции в альфа канале.
    //
    return blendLuminosity (source, float4 (curve , adjustment.level));
}


//
// Результирующая кривая коррекции свето-тени в HSV пространстве
//
inline float4 adjustHSVShadowsHighlights(float4 source, constant IMPShadowsHighLights &adjustment)
{
    float3 hsv = rgb_2_HSV (source.rgb);
    
    hsv.z = shlCurve(hsv.z, adjustment);
    
    //
    // Результат ссмешивае с учетом композиции в альфа канале.
    //
    return blendNormal (source, float4 (HSV_2_rgb(hsv) , adjustment.level));
}

//
// Дальше просто конкретные kernel-функции
//
kernel void kernel_adjustRgbCurvedSHL(
                             texture2d<float, access::sample> inTexture [[texture(0)]],
                             texture2d<float, access::write> outTexture [[texture(1)]],
                             constant IMPShadowsHighLights &adjustment  [[buffer(0)]],
                             uint2 gid [[thread_position_in_grid]]
                             )
{
    float4 inColor = inTexture.read(gid);
    outTexture.write(adjustRgbShadowsHighlights(inColor, adjustment), gid);
}


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

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

В заголовке:

 #include "IMPCommonMetal.h"

, можно найти реализации функций смешивания. Это еще один приятный бонус MSL — привычная организация кода. В отличии от OpenGL ES не нужно беспокоиться над тем как мы управляемся библиотекой шейдеров.

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

UPD: Убрал лишние шаблоны под чутким руководством: ruslansorokin

Теперь сравним результаты испытаний различных подходов.

Сравнение различных реализаций одного алгоритма

Оригинальный «пленочный» вариант
Shadows Highlights Alpha

Кривая V-канала в пространстве HSV
Shadows Highlights HSV Curve

Композитная кривая RGB
Shadows Highlights RGB Curve

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

Сравнение «пленочной» версии, rgb-композиции и версии кривой по относительной яркости со смешиванием в режиме Luminosity
Shadows Highlights Comparsion

Сравнение «пленочной» версии и кривой V-канала HSV
Shadows Highlights ALPHA HSV Comparsion

Как бы выводы

Как не трудно видеть — оригинальный вариант, при всей кажущейся простоте идеи, работает более аккуратно. Почему — я попытался объяснить еще в предыдущем посте. Работа с гамма-коррекцией и смешиванием в альфа-канале по свертке описанной там же, симулирует работу с пленкой. Получение двух негативных отпечатков, сложение и печать через них на бумагу — это смешивание в режиме screen. Свертка функции смешивания — по сути медленный проявитель быстрее вымывающий толстую эмульсию, чем уже проявленную и т.п. Математически обосновать этот процесс тоже можно, но не является целью этого блога. Думаю приведенные примеры наглядно показывают — не всегда конструирование фильтров в строгом математическом и общепринятом виде имеет эстетически приемлемое решение.

Полностью примеры исходного кода валяются тут: ImageMetalling. ImageMetalling-02 — наш сегодняшний пример.


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

Функциональное мышление и мышление кривыми при проектировании фильтров изображений на примере фильтра Shadows/Highlights: 16 комментариев

  1. Готов подискутировать).

    1. Я понятия не имею про синтаксис MSL, но с точки зрения с++ в приведенном куске кода все шаблонные функции с параметром vec можно выкинуть. Иначе нет смысла использовать шаблоны, т.к. в параметр T везде будет инстанциирован как float.

    2. Мы с вами немного с разной стороны смотрим на по сути очень простые вещи. Из-за этого мне кажется, что вы немного лукавите.

    Дело ведь совершенно не в свертке и не в других магических заклинаниях. Вы совершенно прозрачно начали с кривой. Но давайте запишем классические 1D кривые для каналов R,G,B и композитного канала в общем виде:

    Rout=F4(F1(Rin)),
    Gout=F4(F2(Gin)),
    Bout=F4(F3(Bin))

    F1,F2,F3 — это 1D поканальные кривые, зависящие только от Rin, Gin и Bin соответственно,
    F4 — это 1D композитная кривая, которая в каждом из трех выражений зависит также только от Rin, Gin и Bin.

    А вот так выглядят выражения для ваших “пленочных” кривых:

    Rout=F1(Rin, Gin, Bin)
    Gout=F1(Rin, Gin, Bin)
    Bout=F1(Rin, Gin, Bin)

    По сути эти кривые — это не 1D кривые, т.к. F1 зависит именно от трех каналов, поскольку в расчете участвует их взвешенная сумма. Эти выражения можно представить как семейство кривых, каждая из которых «живет» в своем срезе RGB куба. Я вот сходу даже не могу сказать даст ли она или нет поканальный разбаланс при применении к серому градиенту. Колориметрический вектор хоть в сумме и дает единицу, но потом идут всякие операции возведения в степень… Но это достаточно просто проверить.

    Нравится

    1. 1. Синтаксис MSL == C++11 (с очень небольшим набором ограничений, ну разве, что не доступны STL, Boost и т.п.). Я последний раз использовал C++ лет 10 назад, поэтому если покажете как сделать проще и меньше кода — будет просто замечательно.

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

      Ваш подход абсолютно рабочий, просто потому, что — это классика:) Но найти приемлемый вариант F1 становится задачей немного более сложной, на мой взгляд: что должна объяснить эта функция какую «модель»? Как кривые должны «жить» в своих срезах? Как-то так.

      В приведенном, мной примере, наверное повторюсь, рассматривается не способ нахождения конечной F1, а способ её композиции из известных элементарных операций, каждая из которых пытается ~ описывать «физический» процесс. Т.е. понятно, что мы получим всю ту-же F1, и сути это не меняет, но процесс поиска может пойти несколько быстрее, а найти мы можем функцию немного другую.

      Вектор представления яркости пиксела от RGB просто подобран из известных, выбран вот этот 3/6/1 по чисто экспериментальным причинам. Тут есть одно существенное допущение — мы не учитываем нелинейные характеристики сигнала от rgb, поскольку нам нужны не точные приближения — глаз все таки не слишком чувствительный прибор, да еще и сам сигнал потом обрабатывается странно. Поэтому пока так.

      Нравится

      1. 1. Я вроде как написал решение — удалить из кода все шаблонные функции с параметром vec3

        template inline vec LsMask(vec Li, float W, float Wt, float Ks){
        vec c(1 — pow((1 — Li),4));
        return c * W / exp( 6 * Ks * Li / Wt) * Wt;
        }

        template inline T LsMask(T Li, float W, float Wt, float Ks){
        T c(1 — pow((1 — Li),4));
        return c * W / exp( 6 * Ks * Li / Wt) * Wt;
        }

        Среди этих двух — первая лишняя.

        2. Я согласен, что иметь некую модель формирования кривой вместо, сдвигания точек на кривой — это правильно и удобно. Более того, я в свое время пришел ровно к такому же выводу.

        Но это же далеко не новый подход. Вы можете найти десятки патентов Kodak,Fuji… по формированию тон-передающих кривых по какой-то модели (наклон, размер стопы и плеча и т.д.)… Фактически каждый raw конвертер имеет параметрическое управление кривой (воздействие на тени, света…), наверняка там тоже заложена какая-то модель. Я не буду сейчас спорить соответствии подобных моделей процессам при проявке пленок. Но это далеко не ново. Речь тут скорее не о мышлении, а об элементарно удобстве построения/воздействия на кривую. Ну и вы считаете, что модель еще и соответствует аналоговым процессам.

        Нравится

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

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

      3. Ну, Паша написал, что вот никто до такого додуматься пока не смог . Хм. Заинтересовало. Смотрю. MSL, смешивание, свертка… ужас. Аааа. Семен Семеныч!
        На одном из этих этапов пошли вопросы :).

        Нравится

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

        Нравится

  2. Действительно, ближайшим аналогом вашей пленочной кривой будет способ с использованием промежуточного пространства HSV и канала V. В данном случае с точки зрения «RGB на входе и RGB на выходе» мы тоже будем иметь дело с 1D кривыми, лежащими в срезах RGB куба, но вроде как не получим поканального разбаланса для серого градиента после возвращения в RGB пространство.

    Нравится

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

      Нравится

      1. Кривыми можно построить вообще почти все, что угодно, но это больше из области рисования:) И процесс сильно не автоматизируешь. В отличие, скажем от проявки пленки, или в нашем случае исходного изображения.

        Нравится

    2. С HSV все равно получается «не то». В средних становится слишком много «чистого» цвета. Это происходит из-за смещения контраста (чистой кривой без разбаланса), чего мы хотим избежать на этом этапе.

      Нравится

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