Как написать свой Metalagram. Color Lookup Tables или CLUT-ы в Metal

Смоки, тут не Вьетнам. Это — боулинг. Здесь есть правила.
Уолтер Собчак/Большой Лебовски

В самом первом посте этого блога я обещал, что мы напишем свою версию приложения фото-камеры с фильтрами, что-то вроде Instagram-а или встроенной Камеры iOS. В 100500й раз. Приложение будет конечно же круче в тоже самое количество раз. Все дело в количестве припоя кода которого мы нальем в программу, что-бы сделать её работающей. Его практически не будет. Сомневаюсь, что у отцов-основателей Instagram были наши сегодняшние возможности. А еще у нас будут большие файлы формата Cube LUT Specification Version 1.0. В общем пишем свой Instagram!

Приложение будет выглядеть как-то так:

Metalling App

Вот сегодня это и сделаем: возьмем в руки отвертку и паяльник XCode и напаямем накодируем кода: быстренько обернем Metal в свой фреймворк и сосредоточимся, в итоге, исключительно на разработке фильтров не отвлекаясь на детали. А еще проще использовать готовый через cocoapods: DPCore3. Эта штука находится еще на стадии активных доработок, но вполне сгодится для использования в качестве слоя абстракции уменьшающего количества кода на единицу  времени программиста. Но обо всем по порядку.

Инста-фильтры

Если вы в курсе, что такое CLUT, то вводную часть вот про это все, можно проскипать. А можно вообще закрыть пост, если не любите работать отверткой.

Color Look-up Table — это по сути универсальный способ отмапить один набор цветов (картинку) в другой (другую картинку) через какую-то промежуточную схему. Самой простой схемой трансформации является трансляция цвета по индексу из палитры. Подменяя палитру можно полностью изменить изображение. Так в принципе и происходит в реальной жизни и даже более того существует целая индустрия производства и потребления таких палитр для работы над кинофильмами, к примеру. Пожалуй единственный подвох в этой схеме — невозможность создать полную карту цветов для трансформации полноцветных изображений. Но как мы понимаем задача эта решена и достаточно эффективно: карты создаются не полноразмерные, а примерные, а недостающие значения интерполируются в момент конвертации. Вот и все. Это все что, сделали в свое время в Instagram — собрали несколько таких таблиц в приложение и дали возможность людям творчески прикладывать эти таблицы к изображениям и делиться друг с другом результатом. Ну еще оквадратили чтобы не морочиться ориентацией изображения. Все гениальное просто.

Так вот, таблицы такие принято называть CLUT — Color Look-Up Table-s. GPU как ни что иное идеально подходит для работы по прикладыванию CLUT к изображениям и этим доступным способом пользуются как любители, так и профессионалы. И даже есть рынок специальных железок для применения профилей из LUT к цифровому видео-изображению на лету с какими-то фантастическими скоростями. Причем профессиональный рынок CLUT-ов весьма внушает. Вся фишка в том, что хотя технически использовать CLUT не сложно и сегодня я покажу как это можно сделать одной строчкой в шейдере MSL, сложности возникают на этапе производства самой таблицы. И когда инстаграмцы начинали свой бизнес, думаю они провели колоссальную ручную работу с этим связанную.

Сегодня же, вы можете заплатив 300 рублей за Photoshop CC2015 нагенерить бесчисленное количество своих вариантов обработок, вклеить их в свое приложение и запустить свой Instagram в течении дня. Большую часть, которого, потратите на конструирование CLUT в Photoshop.

Типы CLUT

Ну или не Photoshop, тогда скорее всего вы получите какой-то файл, какого-то формата, который наш фреймворк читать пока не умеет. Тогда вам придется написать свой парсер или взять из сети (если найдете или реализуете для DPCore3 — с радостью приму в дар). А еще можете просто создавать CLUT-ы на лету, при старте приложения или при настройках параметров каких-то своих специфических фильтров и вообще использовать не DPCore3 или Metal, а встроенный в iOS CIColorCube.

Чтобы написать такой парсер нужно знать: CLUT-ы бывают 2-х типов 1D и 3D. А вот 2D не бывает. И это не странно. 1D организуются в виде 3-х 1D-массивов описывающих кривые преобразования одного канала в другой. В файле могут быть заданы, как правило, только 3-канала (в уме помним про кино откуда все это счастие свалилось, а альфа канал этим парням наверное не сильно нужен). Очевидно нам никто не мешает думать о преобразовании произвольного канала произвольного цветового пространства по готовому CLUT или работе с альфа-каналом. Одна крутая компания, которая сделала очень много для развития GPU, делится как всего лучше с этим справиться в своей статье: Using Lookup Tables to Accelerate Color Transformations.

Второй тип CLUT-ов 3D появился благодаря все той же крутой компании и возможности аппаратными средствами дернуть больше информации с меньшими затратами чем если просто работать с одномерной текстурой цветов. Однако у  3D-LUT-ов есть еще одно офигенное преимущество перед 1D: более гибкая возможность подготовки цветового образа, произвольная работа с выбранными цветами и возможность создания редакторов более наглядно представляющих отображение цвета в пространство.

3D-LUT в виде текстуры записанной в png-файл легко представить визуально:

lookup_amatorka

Вот так будут выглядеть наши цвета, если мы мысленно разобъём наш 3D-LUT в виде куба на срезы из карт цветов.

И мы, к стати, можем работать с представлением карты в виде изображения просто прочитав специально подготовленную картинку в текстуру. Пока, мне больше нравится идея читать LUT из стандартного файла без всякой конвертации в специальную кратинку. В качестве исходного материала наших фильтров будем использовать формат компании Adobe. И программа для подготовки файлов этого формата у нее есть популярная и LUT-ов в этом формате больше чем надо. Расширение файлы этого формата как правило имеют .cube или .lut

Как подготовить текстуру в Metal для мапинга LUT

В этом будет состоять вся фильтрация по сути — в правильной варке исходной текстуры для MSL-шейдера. Весь код приводить не буду: разбирать как распарсить текстовый файл не являются целью поста, а вот заполнить пробел в описании использования Metal-ических конструкций, пожалуй стоит. Полный текст класса подготовки текстуры можно найти в проекте DPCore3, в реализации DPCubeLUTFileProvider, например.

...
// тут вычисляем действительную размерность LUT из распаршеных значений
NSUInteger width = _lut3DSize;
NSUInteger height = _type==DP_CLUT_TYPE_1D?1:_lut3DSize;
NSUInteger depth = _type==DP_CLUT_TYPE_1D?1:_lut3DSize;

NSUInteger componentBytes = sizeof(uint8_t);

if (useFloatLUT) {
componentBytes = sizeof(Float32);
}

// Текстуре надо, заметьте, все же 4 канала RGBA
NSUInteger bytesPerPixel = 4 * componentBytes;
NSUInteger bytesPerRow = bytesPerPixel * width;
NSUInteger bytesPerImage = bytesPerRow * height;

// создаем описание текстуры
MTLTextureDescriptor* textureDescriptor = [MTLTextureDescriptor new];

// заполняем описание
// если 1D то создаем на самом деле 2D но с высотой 1 пиксел
// если 3D то как и полагается - трехмерную по размеру и количеству исходных семплов
textureDescriptor.textureType = _type==DP_CLUT_TYPE_1D?MTLTextureType2D:MTLTextureType3D;
textureDescriptor.width = _lut3DSize;
textureDescriptor.height = _type==DP_CLUT_TYPE_1D?1:_lut3DSize;
textureDescriptor.depth = _type==DP_CLUT_TYPE_1D?1:_lut3DSize;

if (!useFloatLUT) {
textureDescriptor.pixelFormat = MTLPixelFormatRGBA8Unorm;
}
else{
textureDescriptor.pixelFormat = MTLPixelFormatRGBA32Float;
}

// всякая вспомогательная чепуха которую требует металический слой
textureDescriptor.arrayLength = 1;
textureDescriptor.mipmapLevelCount = 1;

// выделяем под текстуру память в устройстве
self.texture = [self.context.device newTextureWithDescriptor:textureDescriptor];
// задаем размерность
MTLRegion region = _type==DP_CLUT_TYPE_1D?MTLRegionMake2D(0, 0, width, 1):MTLRegionMake3D(0, 0, 0, width, height, depth);
// заполняем текстуру нашим LUT
[self.texture replaceRegion:region mipmapLevel:0 slice:0 withBytes:dataBytes.bytes bytesPerRow:bytesPerRow bytesPerImage:bytesPerImage];
...

Теперь можем уже и с шейдером поработать.

Шейдер


kernel void kernel_adjustCLUT3D(
texture2d<float, access::sample> inTexture [[texture(0)]],
texture2d<float, access::write> outTexture [[texture(1)]],
texture3d<float, access::sample> lut [[texture(2)]],
uint2 gid [[thread_position_in_grid]]){

constexpr sampler s(address::clamp_to_edge, filter::linear, coord::normalized);

float4 inColor = inTexture.read(gid);

outTexture.write(lut.sample(s, inColor.rgb), gid);
}

Надо сказать, именно, строка #4 меня сподвигла написать этот пост — я попросту не нашел готового решения на Metal в сети. А раз нет, нужно создать прецедент и сломать систему.

 texture3d<float, access::sample> lut

— означает получить подготовленную 3D текстуру с LUT в каждой точке координатного пространства, которой, содержится соответствующий маппингу цвет. В таком случаем входной цвет будет являться координатой текстуры, а выходной значением в этой точке. Для того, чтобы получить несуществующий цвет проводится трилинейная интерполяция по координатной сетке. Надо понимать, чтобы такая схема работала, LUT-ы должны быть правильно подготовлены — в каждой точке сетки должны находится линейно близкие цвета. Интерполяция уже встроена в MSL и мы ей воспользуемся: объявим семплер текстуры как constexpr sampler s(address::clamp_to_edge, filter::linear, coord::normalized).

 

Metalagram — фотоаппарат с готовым пресетом фотографических фильтров

Ядро приложения металаграмический фильтр. Создаем не с нуля, а используем готовый набор классов еще более больше скрывающий слой работающий с GPU: https://bitbucket.org/degrader/degradr-core-3

DPCore3 Framework — специализированный модульный конструктор для создания собственных прикладных image-фильтров.

Чтобы добавить DPCore3 в свой проект на XCode нужно воспользоваться инструментом cocoapods. И выполнить небольшие процедуры по подготовке проекта к интеграции DPCore3 с системой сборки XCode.

1. Добавить в проект файл [имя проекта]-Bridging-Header.h
2. Добавить в него строчку: #import «DPCore3.h»
3. Найти в панели «Build settings» проекта свойство «Edit Objective-C Bridging Header»
4. Добавить в свойство путь к файлу [имя проекта]-Bridging-Header.h
5. Добавить в проект пустой файл Metal
6. Добавить в него строчки для загрузки стандартных металических функций DPCore3

#include <metal_stdlib>
#include "DPMetal_main.h"
using namespace metal;

7. Добавить в панели проекта «Build Settings» -> «Metal Compiler — Build Options» -> «Header Search Paths» строку: ${PODS_ROOT}/Headers/Public/DegradrCore3
8. Запустить pod install
9. Проект готов сборке. Не забудьте только переоткрыть проект в пространстве которое сгенерил pod install. Я постоянно забываю.

Все это было нужно для интеграции pod-а в сборщик проекта на Swift. Как вариант, можете скачать и собрать DPCore3 как iOS Framework и просто добавить к проекту, тогда всех этих манипуляций будет не нужно.

В нашем приложении воспользуемся готовыми классами из комплекта: читалкой файла формата .cube, в котором хранят заранее подготовленные Color LookUp Table. Cube LUT — это открытая спецификация формата текстовых файлов Adobe, поддерживается кучей генераторов CLUT.

class IMPMetalaGramFilter: DPFilter {

//
// Кэшируем CLUTs в справочник.
// По ключу можно получить готовую текстуру объекта класса типа DPImageProvider.
//
// Все фильтры наследуемые от DPFilter из DPCore3 framework имеют два свойства:
// - .source - исходное изображение
// - .destination - изображение после преобразования
// Оба свойства являются ссылками на объекты класса DPImageProvider.
//
// Image provider-ы - абстрактное представление произвольного изображения и его сырых данных представленных
// в текстуре записанной в область памяти GPU.
// Фреймворк содержит начальный набор провайдеров к jpeg-файлам, UIImage/CGImage,
// NSData, frame-буферу видео-изображения.
// Нам для работы c lookup таблицами нужен CLUT-файл-провайдер, который, по сути,
// явлеется таким же изображением (текстурой), и позиция цвета в виде координаты является отображением
// входного цвета в выходной
//
// DPCubeLUTFileProvider - поддерживает обе формы представления CLUT: 1D и 2D
//
// В качестве упражнения можно также написать провайдер CLUT из png-файлов.
//
private var lutTables = [String:DPCubeLUTFileProvider]()

//
// Фильтр мапинга картинки в новое цветовое пространства представленное CLUT-провайдере.
//
private var lutFilter:DPCLUTFilter!

//
// Просто сервисная функция для получения CLUT по имени файла. Файл добавляется в проект приложения.
//
private func getLUT(name:String) -> DPCubeLUTFileProvider? {

do{
if let lut = lutTables[name]{
return lut
}
else {
let lut = try DPCubeLUTFileProvider.newLUTNamed(name, context: context)
lutTables[name] = lut
return lut
}
}
catch let error as NSError{
//
// Перед падением программы напишем что пошло не так,
// скорее всего в проект не добавили файла или формат файла левый
//
NSLog("%@", error)
}

return nil
}

//
// Управление выбором CLUT по имени.
//
var name:String!{
didSet(oldValue){
if oldValue != name {
lutFilter.lutSource=getLUT(name)

//
// Скажем фильтру, что данные протухли.
// Когда пишется свой собственный кастомный фильтр с помощью
// DPCore3 необходимо выставлять флажок протухания (dirty)
// при изменении параметров фильтра.
//
self.dirty = true
}
}
}

//
// Управления степенью воздействия
//
var opacity:Float{
get{
return lutFilter.adjustment.blending.opacity
}
set(value){
lutFilter.adjustment.blending.opacity=value
}
}

init(context aContext: DPContext!, initialLUTName:String) {
//
// поскольку мы хотим изображение оквадратить инициализируем фильтр
// поддержкой операций трансформаций: обрезание вращение и тп, т.е. инициализируем графические шейдеры
// в обычных фильтрах фото-процессинга они обычно не нужны, или нужны только один раз и задаются в корневом фильтре.
//
super.init(vertex: DP_VERTEX_DEF_FUNCTION, withFragment: DP_FRAGMENT_DEF_FUNCTION, context: aContext)

//
// если CLUT-файл добавлен в проект, формат файла соответствует спекам:
//
name = initialLUTName

if let lut = getLUT(name){
lutFilter = DPCLUTFilter(context: self.context, lut: lut, type: lut.type)
//
// добавляем в цепочку новый фильтр,
// если нам нужна обработка нескольких фильтров можно подцепить несколько
// например анализатор гистограммы:
// DPHistogramAnalizer, к которой в свою очередь DPHistogramZonesSolver для решения
// задачи коррекции экспозиции, к примеру.
//
// Пока просто добавляем фильтра мапинга цветовых пространств через CLUT
//
self.addFilter(lutFilter)
}
}

required init!(context aContext: DPContext!) {
//
// Не даем создать фильтр без инициализации таблицей
//
fatalError("init(context:) does not create initial filter without LUT")
}

}

Осталось оформить визуальную часть, поднять камеру, поймать из нее фреймы и связать фильтр с обработкой фреймов:

...
//
// Для работы с потоком видео создадим неблокирующий контекст.
//
private let contextLive = DPContext.newLazyContext()

...
//
// Создаем менеджер камеры, связываем с контейнером для отображения видео
//
camera = DPCameraManager(outputContainerPreview: self.liveView)

//
// Инициализируем наш фильтр
//
filterLive = IMPMetalaGramFilter(context: contextLive, initialLUTName: currentLutName)

//
// Делаем картинку квадратной отрезая слева и справа
// - помним, что нормальная ориентация камеры на левом боку
//
let factor:Float = (1-3/4)/2
let transform = DPTransform()
transform.cropRegion = DPCropRegion(top: 0, right: factor, left: factor, bottom: 0)

filterLive.transform = transform

//
// Связываем его с live-vew фильтром камеры
//
camera.liveViewFilter = filterLive

//
// Чтобы побыстрее жать жипег используем хардварную компрессию встроенную в iOS
//
camera.hardwareCompression = true;

//
// Чтобы еще чуть ускориться
//
camera.compressionQuality = 0.9;

//
// Теперь настраиваем контекст захвата картинки
// Он по идее может быть и контекстом live-view камеры, но нам ее не хочется тормозить
// на момент работы фильтра по полному разрешению прилетевшего файла
//
let capturingFilter = IMPMetalaGramFilter(context: DPContext.newContext(), initialLUTName: currentLutName)

//
// Не забываем отрезать
//
capturingFilter.transform = transform

//
// Ловим снепшот и тут же фильтруем с записью в Camera Roll так будет дольше,
// но зато сразу и без всяких внутренних галерей. (пример в общем, то)
//
camera.capturingCompleteBlock = { (finished, file, meta) in

if finished {
//
// если камера успела захватить изображение
//

//
// устанавливаем текущий lut
//
capturingFilter.name = self.currentLutName

//
// и прозрачность которую запомнили в live-view фильтре
//
capturingFilter.opacity = self.filterLive.opacity

//
// получаем из меты ориентацию картинки
//
let orientation:UIImageOrientation! = UIImageOrientation(rawValue: (meta[kDP_imageOrientationKey] as! NSNumber).integerValue)

//
// Читаем из источника jpeg
//
capturingFilter.source = DPImageFileProvider.newWithImageFile(file, context: capturingFilter.context, maxSize: 0, orientation: orientation)

//
// Записываем результат в Camera Roll
//
UIImageWriteToSavedPhotosAlbum(UIImage(imageProvider: capturingFilter.destination), nil, nil, nil)
}

}
}

...

override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
//
// При старте стартуем камеру
//
camera.start()
}

override func viewWillDisappear(animated: Bool) {
super.viewWillDisappear(animated)

//
// При стопе стопаем камеру
//
camera.stop()
}

Если убрать длинную, и для многих не нужную цепочку рассуждений вот про это все, отрезать комментарии из текста программы, то весь процесс займет минут 30 с запуском и отладкой на телефоне. А листинг программы сожмется до одной страницы. Ну еще останется сделать собственную галерею, сервис публикации в соцсети, выбор фильтров больше чем из 3х, оформление покупок новых через AppStore и… финансовое благополучие настанет через пару месяцев. А если не повезет просто инжой.

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

Metalagram

Вы можете загрузить свои люты в приложение. Я один LUT (V50) утащил из коллекции Дмитрия Новака просто погуглив в сети «LUT для rpp». Один взят из комплекта самого Photoshop: Kodak5218. Один LUT собран с помощью Adobe Photoshop CC2015. Делается все просто: берете любую фотку, обрабатываете. Выбираете File->Export->Color Lookup Tables. Сохраняете файл .CUBE. А можете взять Ухудшайзер 3.0 Павла Косенко прогнать произвольную картинку через него, сохранить LUT и у вас будет собственный вариант Degradr.

И никакой магии! (на самом деле нет).


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

Реклама

Как написать свой Metalagram. Color Lookup Tables или CLUT-ы в Metal: 8 комментариев

  1. Ну, прогонять через Ухудшайзер большого смысла нет, т.к. он там пару операций делает автоматически в зависимости от содержание снимка (особенно авто-баланс). Хотя считаю, «правильные» луты — это относительно доступный инструмент достижения какой-никакой эстетики в цифровой фотографии, если другие инструменты непосильны. Но у нас же куча инструментов, чтобы обойтись без лутов :). Очень интересно было бы услышать про «перцептуальную насыщенность», про хантовский коэффициент эксцентричности и как с ним работать, и др. штуки :).

    Нравится

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

      Перцептуальная насыщенность не тема этого блога, скорее всего, когда созреем ее описать, разместим пост в блоге Degradr. Работать с самим коэффициентом не нужно, поскольку он описывает всего лишь вклад цвета в ощущение насыщенности изолированного стимула в зависимости от яркости. Мы же рассматриваем изображение как целостную сущность. Перцептуальная насыщенность — следствие этого факта. Вычисляется как интегральный показатель таких стимулов с учетом коэффициента по всему полю изображения. Вычисление полностью эмпирическое. И строится на анализе большого массива изображений. Задача — повысить тональную вариативность цветов изображения.

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

      К стати, изначально была идея, что я пишу сюда не один, если есть желание поделиться какими-то интересными находками — you are wellcome.

      Нравится

  2. Спасибо. Написать, думаю, есть что. Но, скорее всего, это не попадает в контекст «imagemetalling». Пару черновиков уже приготовил, осталось только собраться с силами и опубликовать.

    Нравится

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход / Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход / Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход / Изменить )

Google+ photo

Для комментария используется ваша учётная запись Google+. Выход / Изменить )

Connecting to %s