Недавний пост Фильтр реалистичной, «пленочной» коррекции теней, вызывал неподдельный интерес и отнял у автора пару полезных для сна часов. Приходилось сумбурно отвечать и получать возражения. Поскольку я блоггер неопытный, и в сетях до сих пор присутствовал исключительно как потребитель, то по началу даже растерялся. Но поразмыслив, понял, что в целом вопросы были правильные, а ответы не совсем прозрачные.
Поэтому сегодня, я уделю больше внимания разбору классического алгоритма с некоторыми примерами уже пройденного, в том числе и для закрепления материала представленного в предыдущем посте: шаблонизация функций.
И да, не воспринимайте слишком серьезно слишком амбициозные заголовки этого блога. Хотя я и не буду в этот раз углубляться в философию мышления, но все-же хочется немного убедить читателя: Image Processing в фотографии, скорее развлекательная история, чем серьезный научный раздел. Так и надо к нему подходить, я счетаю. Если принято использовать LUT или кривые для решения задачи, можно попытаться представить себе процесс по другому, поиграть с палитрой решений и в целом получить немного отличающийся результат. Сегодня я покажу как, в столь тривиальном случае, можно немного улучшить эстетику результата одного из самых распространенных фильтров на планете (на самом деле нет, если вы зануда…). Оригинальный фильтр по понятным причинам я разбирать не буду — его описание было уже сделано. Займемся возней вокруг кривых.
Фильтр коррекции светов и теней Shadows/Highlights
Как вы помните цель поста была просто написать какой-нибудь простой фильтр, который не просто прост, а предельно прост и делает, что-то сверх полезное. А потом показать как все это реализуется в железе на iOS совместимом устройстве типа iPhone/iPad средствами Metal Framework и Metal Shading Language.
Так случилось, что был выбран фильтр коррекции теней фотографий. Операция повышения деталей в тенях достаточно частая операция выполняемая редакторами изображений и в целом функция востребованная. В смысле Image Processing-а фотографий предполагается, что такая операция является частью какого-то большего набора операций. Однако кажущаяся простота реализации скрывает за собой некоторые нюансы.
Поскольку операция над светами симметрична теням, то сделаем сегодня уже полноценный фильтр Shadows/Highlights.
Собственно описывать нечего: берем исходный сигнал и с помощью каких-то кривых в одно действие повышаем тени и понижаем еще за одно света. Кривые придумываем. Придумывание кривых процесс не сложный. Можно тупо нарисовать сплайны. Можно снять какие-то данные и восстановить полином. Можно аналитически подобрать функцию, главное понимать что мы делаем. Я подобрал вот ту самую из прошлой статьи (точнее несколько, но понятно, что все вырождается в одну функцию, но я не зря заговорил об философии мышления — декомпозиция задачи одна из важных составляющих этого подхода…).
Результат той самой функции будет кривая, которую можно показать вроде вот такого графика:
Да, эта та кривая, результирующая функция вот тех самых операций, которые были приняты за кривые, без учета их работы как свертки альфа-канала композиционного смешивания. В общем, если не заниматься фигней со свертками и не кидаться словами, а просто вычислить кривую — то вот такая штука и получится. Я эти рассуждения из поста про «пленочную» коррекцию теней опустил, для краткости.
Что мы можем сделать с этой кривой?
- Перевести исходное RGB-пространство в одной из пространств с яркостным каналом: HSV, HSL, HCL, Lab и тп. И приложить кривую к каналу яркости. Выберем для простоты HSV
- Ничего никуда не переводить просто провести преобразования по типу вытягивания кривых в композитной кривой RGB ala-фотошоп. Тоже самое можно представить как маскирование теней/светов в режиме смешивания screen/multiply
- Провести операцию над относительной яркостью RGB как мы делали это в исходной статьей и смешать кривую относительной яркости в режиме Luminosity с исходной картинкой
- Кучу еще всего, но остановимся на этих 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
Теперь сравним результаты испытаний различных подходов.
Сравнение различных реализаций одного алгоритма
Оригинальный «пленочный» вариант
Кривая V-канала в пространстве HSV
Композитная кривая RGB
Композитная кривая RGB
Смешивание кривой относительной яркости с исходным изображением в режиме Luminosity похоже на композитную кривую, хоть и немного отличается в тенях, приводить не буду: все тоже самое — гораздо интереснее посмотреть рядом.
Сравнение «пленочной» версии, rgb-композиции и версии кривой по относительной яркости со смешиванием в режиме Luminosity
Сравнение «пленочной» версии и кривой V-канала HSV
Как бы выводы
Как бы выводы
Как не трудно видеть — оригинальный вариант, при всей кажущейся простоте идеи, работает более аккуратно. Почему — я попытался объяснить еще в предыдущем посте. Работа с гамма-коррекцией и смешиванием в альфа-канале по свертке описанной там же, симулирует работу с пленкой. Получение двух негативных отпечатков, сложение и печать через них на бумагу — это смешивание в режиме screen. Свертка функции смешивания — по сути медленный проявитель быстрее вымывающий толстую эмульсию, чем уже проявленную и т.п. Математически обосновать этот процесс тоже можно, но не является целью этого блога. Думаю приведенные примеры наглядно показывают — не всегда конструирование фильтров в строгом математическом и общепринятом виде имеет эстетически приемлемое решение.
Полностью примеры исходного кода валяются тут: ImageMetalling. ImageMetalling-02 — наш сегодняшний пример.
Еще раз напомню: я не преследую задачи быть предельно корректным, но
если заметили явную ашипку, если написал глупость, если что-то не понятно: комментируйте или пишите на: imagemetalling [*] gmail.com .
Готов подискутировать).
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. Синтаксис MSL == C++11 (с очень небольшим набором ограничений, ну разве, что не доступны STL, Boost и т.п.). Я последний раз использовал C++ лет 10 назад, поэтому если покажете как сделать проще и меньше кода — будет просто замечательно.
2. Вполне допускаю. Попытаюсь объяснить почему не хочется решать задачу «чисто». Т.е. через поканальные кривые — это слишком математическая модель. Поэтому мне нравится думать, по крайней мере пока, что декомпозиция на условно-приближенные к реальным процессам, операции над исходным сигналом дадут в итоге «условно-лучший» результат с пецептуальной точки зрения.
Ваш подход абсолютно рабочий, просто потому, что — это классика:) Но найти приемлемый вариант F1 становится задачей немного более сложной, на мой взгляд: что должна объяснить эта функция какую «модель»? Как кривые должны «жить» в своих срезах? Как-то так.
В приведенном, мной примере, наверное повторюсь, рассматривается не способ нахождения конечной F1, а способ её композиции из известных элементарных операций, каждая из которых пытается ~ описывать «физический» процесс. Т.е. понятно, что мы получим всю ту-же F1, и сути это не меняет, но процесс поиска может пойти несколько быстрее, а найти мы можем функцию немного другую.
Вектор представления яркости пиксела от RGB просто подобран из известных, выбран вот этот 3/6/1 по чисто экспериментальным причинам. Тут есть одно существенное допущение — мы не учитываем нелинейные характеристики сигнала от rgb, поскольку нам нужны не точные приближения — глаз все таки не слишком чувствительный прибор, да еще и сам сигнал потом обрабатывается странно. Поэтому пока так.
НравитсяНравится
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 конвертер имеет параметрическое управление кривой (воздействие на тени, света…), наверняка там тоже заложена какая-то модель. Я не буду сейчас спорить соответствии подобных моделей процессам при проявке пленок. Но это далеко не ново. Речь тут скорее не о мышлении, а об элементарно удобстве построения/воздействия на кривую. Ну и вы считаете, что модель еще и соответствует аналоговым процессам.
НравитсяНравится
1. Ок попробую, спасибо.
2. Не новый, ни разу, потому я несколько был удивлен, вопросами, по казалось бы полностью раскрытой теме. Модель не соответствует аналоговым процессам, для соответствия надо строить полностью непрерывные модели, я просто вскользь показал какие операции из композиции функции можно сопоставить с пленочным процессом, как раз таки, что бы не отвлекаться на детали. А это вызвало больше вопросов.
НравитсяНравится 1 человек
Поправил, действительно был лишний код.
НравитсяНравится
Ну, Паша написал, что вот никто до такого додуматься пока не смог . Хм. Заинтересовало. Смотрю. MSL, смешивание, свертка… ужас. Аааа. Семен Семеныч!
На одном из этих этапов пошли вопросы :).
НравитсяНравится
Не, Паша пишет совсем о другом. Скорее как раз о простоте, от которой ушли гиганты индустрии, но порожденная сложность не позволяет решать фотографу задачи ему интересные просто, приходится сосредотачиваться на массе технических деталей в обработке. Вот про это мысль. И поэтому все так грустят за пленку:)
НравитсяНравится
Действительно, ближайшим аналогом вашей пленочной кривой будет способ с использованием промежуточного пространства HSV и канала V. В данном случае с точки зрения «RGB на входе и RGB на выходе» мы тоже будем иметь дело с 1D кривыми, лежащими в срезах RGB куба, но вроде как не получим поканального разбаланса для серого градиента после возвращения в RGB пространство.
НравитсяНравится
Ну и да, все что нам позволяет фотошоп сегодня, это только построить 1D вариант вашей функции (если ее перезаписать сплайном), зависящей только от яркости одного канала. На сколько это приемлемый результат — это другой вопрос.
НравитсяНравится
Кривыми можно построить вообще почти все, что угодно, но это больше из области рисования:) И процесс сильно не автоматизируешь. В отличие, скажем от проявки пленки, или в нашем случае исходного изображения.
НравитсяНравится
Ну и хотел бы заметить, что тремя 1D кривыми можно построить совсем не что угодно, в отличие от трех 3D кривых.
НравитсяНравится
Поэтому я не уточняю размерность.
НравитсяНравится
С HSV все равно получается «не то». В средних становится слишком много «чистого» цвета. Это происходит из-за смещения контраста (чистой кривой без разбаланса), чего мы хотим избежать на этом этапе.
НравитсяНравится
Я верно понимаю, что для серого градиента после применения вашего метода shadows/highlights появится цветовой разбаланс?
НравитсяНравится
Для серого не появится.
НравитсяНравится
Туплю. Исходному значению всех каналов добавляется одинаковая величина.
НравитсяНравится