Единый интерфейс изображения: VCL, FMX, LCL

Рано или поздно возникает вопрос — что мешает делать размытие, ресамплинг или любой другой алгоритм обработки изображения сразу для всех экосистем? Решения из коробки в Delphi нет. Существующие сторонние решения слишком громоздки и навсегда привязывают к себе. Давайте сделаем лёгкий интерфейс, который будет одинаково восприниматься и в VCL, и в FMX, и в Lazarus.

Содержание скрыть

Причины

Алгоритмы обработки изображения — это по сути не самая сложная математика, которая что-то делает с пикселями, иногда в зависимости от состояния соседей. Математика — абстракция по природе, и как-то очень не хочется прибивать её гвоздями к одному фреймворку. Отпустить её на простор кроссплатформенности по сути ничего не стоит, она изначально абстракция.

Обработка изображения часто представляет собой некую функцию, принимающую параметром TBitmap. Если работаем в VCL, это будет Vcl.Graphics.TBitmap, если в FMX — это FMX.Graphics.TBitmap. А это совершенно разные типы, имеющие разный набор методов, построенных от разных родителей. Они связаны только семантически — прямоугольный набор пикселей, и всё, больше ничего общего.

В трёх статьях про blur (1, 2, 3) мы методично оптимизировали размытие: от 1593 мс до 15 мс. Алгоритмы получились хорошие, быстрые. Но все они жёстко привязаны к TBitmap из VCL. Хотя сама математика: скользящая сумма, треугольное ядро, свёртки, не имеет никакого отношения к VCL. Ей всё равно, кто предоставит байты пикселей.

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

В этой статье мы спроектируем интерфейс IImageSurface32, тонкую прослойку, которая отделит наши алгоритмы от фреймворка. Один и тот же код обработки пикселей должен одинаково работать поверх Vcl.Graphics.TBitmap, FMX.Graphics.TBitmap и TLazIntfImage.

Формальные требования

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

И в-четвёртых — самое главное. В статьях «Прямой доступ к пикселям Bitmap» и «TBitmap.ScanLine: Полное руководство» подробно показано: для быстрой обработки изображения нужны всего две вещи — указатель на байты массива пикселей и ширина строки в байтах. Не «удобный API», не «правильный класс». Указатель и stride. Это ядро. Метаданные — ширина, высота, альфа-режим — обвязка вокруг ядра, разберём ниже.

А почему не GR32?

Graphics32 — отличная библиотека. Я сам её много раз использовал, упоминал с уважением в статьях. Библиотека десятилетиями полирует свои алгоритмы. Но есть архитектурное различие, из-за которого она не годится в качестве основы для нашей прослойки.

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

Почему не GR32

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

Graphics32 — это десятки модулей, тысячи строк, лицензия LGPL/MPL. IImageSurface32 — это около 50 строк на интерфейс, плюс по сотне строк на каждую реализацию. Минимум кода, минимум зависимостей, нулевой порог вхождения.

Если буду использовать TBitmap32 в качестве параметра для своих алгоритмов, то во-первых, придётся протаскивать и модули GR32, что снова прибивает математику гвоздями к чужому фреймворку — только теперь к Graphics32 вместо VCL. Во-вторых, навечно привяжу себя к сторонней библиотеке, в которой однажды может что-то измениться или сломаться без меня. В-третьих, мне совершенно не нужна вся мощь библиотеки, мне просто нужен универсальный мостик, который находится в одном модуле. Который можно прочитать за чашку кофе — и понять полностью.

Проще говоря, GR32 — это фреймворк. Ниша нашего интерфейса — переходник между фреймворком и экосистемой. Это не замена фреймворка, а коммуникация с ним.

[свернуть]

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

Список требований к интерфейсу

Лёгкость. Прочитать и понять код полностью за один заход. Не неделю.

Минимум модулей. Один интерфейс. Несколько лёгких реализаций. Без иерархий, без обвязки, без «архитектурного жирка».

Никаких внешних зависимостей. Только то, что уже есть в Delphi. Никаких DLL, никаких лицензий, никаких дополнительных скачиваний библиотек и компонент.

Нулевой оверхед. Прослойка должна быть бесплатной по производительности. Виртуальный вызов на старте обработки строки — да, виртуальный вызов на каждый пиксель — нет.

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

Интерфейс IImageSurface32

Предлагается такой интерфейс:

И всё? Ага, лёгкий, самодостаточный, только необходимое. Используемые типы:

Собственно, тоже ничего сверхъестественного. Зафиксируем, что работать собираемся только с 32-битным пикселем. Это и быстрее, и универсальнее. Альфу мы любим, помним, активно используем.

Теперь подробно о том, почему такие решения.

BGRA: Почему такой порядок байт

Почему BGRA, а не RGBA? Это наследие Windows: и в DIB, и в Vcl.Graphics.TBitmap байты в памяти лежат именно в порядке B, G, R, A. FMX в Windows-сборке использует тот же порядок.

Для других платформ (macOS, iOS, Android) порядок может быть иной, часто RGBA. В этом случае реализация анализирует исходный порядок и при необходимости формирует BGRA-представление. Но это дело реализации, не алгоритма.

Поэтому BGRA — это выбор, покрывающий большинство задач.

Stride: Почему его нет

Явного Stride в интерфейсе нет. Он всегда равен Width * SizeOf(TPixel32) — то есть строки лежат вплотную, без выравнивания. Это архитектурное обязательство интерфейса: каждая реализация обязана держать пиксели в непрерывном блоке без зазоров. Если приходит битмап, в котором это не так, то реализация должна скопировать пиксели в свой буфер. Обязательство непрерывности буфера упрощает алгоритмам жизнь: можно пробежать всю поверхность одним линейным циклом по Data, без построчных переходов.

Data: Инструмент скоростной обработки

В комментарии читаем, что порядок строк у нас нигде не хранится. То есть мы не знаем, строки идут снизу вверх или сверху вниз. Зачем тогда нам Data?

Data — это специализированный инструмент для попиксельных операций, в которых порядок обхода не важен — вроде premultiply, gamma correction, color matrix, threshold. Для алгоритмов, где нужны соседи (свёртки, blur, edge detection), надо использовать ScanLine[Y]. Реализация не обязана располагать строки сверху вниз: VCL держит их снизу вверх, FMX — сверху вниз, и Data указывает на начало этого блока в его естественном порядке.

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

AlphaMode: Два режима, не три

Почему два режима, а не три? В классической графической литературе обычно различают три состояния альфы: ignored, straight (она же unassociated), premultiplied. Я свёл их к двум, и вот почему.

В VCL есть три значения AlphaFormat: afIgnored, afDefined, afPremultiplied. На первый взгляд — три режима. Но если посмотреть в VCL изнутри: при присвоении afDefined или afPremultiplied битмапу, в котором уже есть данные, вызывается внутренняя PreMultiplyAlpha, которая физически домножает каналы на альфу. При обратном переходе на afIgnored внутренняя UnPreMultiplyAlpha делит обратно. А вот между afDefined и afPremultiplied никакого преобразования нет — разница между ними чисто семантическая, «пометка для GDI».

То есть в VCL фактически два физических состояния пикселей: либо они premultiplied, либо нет. Третьего не существует. Подробнее я это описал в статье про особенности блюра с альфа-каналом.

В FMX битмап всегда premultiplied — режима нет, выбора нет.

В LCL пиксели всегда в straight-форме — режима тоже нет, premultiply делает приложение.

Получается: на трёх платформах три разных дефолта, но физических состояний только два: умножено или нет. Поэтому в IImageSurface32 я закрепляю именно физический смысл: amIgnored означает «альфа не используется алгоритмами, последний байт можно считать мусором», amPremultiplied — «RGB уже умножены на A, алгоритмы это учитывают». Третьего режима нет, потому что в железе его и не было.

Width и Height: Простые свойства

Простые информационные свойства — ширина и высота поверхности в пикселях. Без них не обходится ни один алгоритм обработки изображений. Доступны только по чтению. Чтобы их изменить, следует вызвать SetSize.

CreateSurface: Фабрика

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

SetSize: Установка размеров битмапа

Устанавливает новые размеры битмапу, обёрнутому интерфейсом. На практике метод почти всегда идет в паре с CreateSurface: создал экземпляр того же типа, и тут же задал размер:

Сразу же реализуем интерфейс, который хранит данные пикселей в собственном внутреннем буфере.

Реализация интерфейса: TMemoryImageSurface32

Это первая реализация интерфейса IImageSurface32. Она не зависит от платформы и владеет собственным буфером пикселей в памяти. Полезна в сценариях, где нужен временный рабочий буфер для промежуточных данных алгоритма, когда возвращается новая поверхность в качестве результата, а также как место для конвертации из чужих форматов с другим порядком байт.

И реализация:

TMemoryImageSurface32

[свернуть]

Несколько комментариев по реализации.

Паттерн использования

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

Сначала создаём пустой объект, затем выставляем альфа-режим, и только потом — размер. Почему именно так?

Дело в том, что в реализациях поверх реальных битмапов, например, Vcl.Graphics.TBitmap, присваивание AlphaFormat после того, как размер уже задан, физически перебирает все пиксели и применяет к ним premultiply либо unpremultiply. Если это сделать на пустом, но большом буфере — получим лишний длительный проход, совершенно бессмысленный, потому что происходит на нулях. Подробнее чуть ниже, в VCL-реализации.

Установив сначала режим альфы (на пустом нулевом объекте), потом размер, мы избегаем этой работы: преобразовывать нечего, а после SetSize буфер уже считается находящимся в нужном состоянии.

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

CreateSurface создаёт пустой экземпляр

Никаких размеров, никаких унаследованных свойств. Можно было бы сделать так, чтобы новая поверхность приходила сразу того же размера и с той же альфой, что и текущая. Но это означало бы додумывать действия программиста: а вдруг ему нужна поверхность другого размера? А вдруг с другой альфой? Поэтому лучше всегда следовать паттерну Create -> AlphaMode -> SetSize явно, по своим задачам, а не заниматься предсказаниями.

SetSize не сбрасывает AlphaMode

AlphaMode — это семантическая пометка, описывающая, как алгоритмы должны интерпретировать пиксели. Она не привязана к содержимому буфера: установили amPremultiplied, потом перевыделили буфер через SetSize — пометка осталась прежней. Новый буфер обнулён, нули формально удовлетворяют premultiplied-семантике (0 умножен на любую альфу = 0), так что сохранение режима корректно.

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

Реальную прослойку, не хранящую пиксели, удалось сделать только для VCL. Но давайте по порядку, для каждой экосистемы. И начнём с LCL.

Реализация для LCL

LCL — экосистема, где прямой доступ к буферу битмапа невозможен. TBitmap в LCL общается с GDI/GTK/Qt/Cocoa через handle, а пиксели достаёт через посредника — TLazIntfImage. Поэтому делаем реализацию, копирующую данные в свой внутренний буфер. Это значит, что пишем наследника от TMemoryImageSurface32:

Класс наследуется от TMemoryImageSurface32 и ничего в нём не переопределяет, кроме CreateSurface. Не нужен переопределённый GetScanLine, не нужен свой GetData, не нужен какой-то особый порядок строк. Всё, что у него своё — это способ обмена с LCL-битмапом.

Добавляем пару конструкторов и пару методов обмена с битмапом. Вся специфика платформы локализована в CopyFromBitmap и CopyToBitmap.

Один нюанс: TGraphic вместо TBitmap

В LCL TBitmap — это конкретно растровый битмап, а PNG, JPEG и прочие живут как отдельные классы-потомки TGraphic. И когда нужно загрузить, скажем, PNG, приходится сначала превратить его в TBitmap, а уже потом разбираться с пикселями.

Логичная попытка Bitmap.Assign(PngImage) работает не всегда: для некоторых форматов и виджет-сетов handle остаётся не материализованным до первой реальной отрисовки, и LoadFromBitmap потом возвращает пустоту. Поэтому в библиотеке есть отдельная свободная функция-помощник:

Canvas.Draw гарантированно растрирует любой TGraphic в наш 32-битный битмап с гарантированно валидным handle. Это «на всякий случай надёжный» путь, специально для LCL.

Применение:

CopyFromBitmap

Здесь работает важный приём — явное задание формата через Init_BPP32_B8G8R8A8_BIO_TTB. Эта строчка говорит LCL: «я хочу 32-битные пиксели, в порядке BGRA, с расположением строк сверху вниз». Дальше LoadFromBitmap сам выполнит любую необходимую конверсию из внутреннего формата платформенного битмапа в наш желаемый формат.

Здесь мы не выясняем, как у нас на самом деле лежат байты в TBitmap, мы просто говорим LCL, как мы хотим их получить, и платформа сама приводит к этому виду. В итоге один Move на всю строку — и всё.

Цена этой простоты — мы платим временем LoadFromBitmap внутри LCL, который и делает реальную конверсию, если она нужна. Но это всё равно один проход по данным, и он уже оптимизирован в недрах LCL.

FAlphaMode := amIgnored — сознательное решение. LCL не предоставляет ни одного достоверного способа выяснить, premultiplied данные в битмапе или straight. Виджет-сет может прислать любое — в зависимости от платформы, от того, откуда взялся битмап, и от фазы луны. Поэтому консервативная позиция: считаем, что альфы нет, обрабатываем поверхность как непрозрачную. Если пользователь точно знает, что у него premultiplied PNG — он может выставить режим вручную после копирования.

CopyToBitmap

Зеркальный метод, но с одной важной особенностью, специфичной для LCL: выгрузка в битмап делается через подмену handle. Мы сначала складываем пиксели в TLazIntfImage, потом просим у него свежий HBITMAP через CreateBitmaps, и подставляем его в TBitmap.

Это значит: handle целевого битмапа после CopyToBitmap — другой, не тот, что был до вызова. Если у пользователя где-то сохранён старый handle (например, он передал его в нативный API или закэшировал) — этот handle становится невалидным.

В практике обычной обработки изображений это никого не задевает: сохранил битмап, потом его нарисовал, потом забыл. Но если кто-то строит более сложные сценарии с передачей handle наружу — стоит помнить. Это вынужденная особенность LCL: записать пиксели в существующий handle средствами TLazIntfImage нельзя, можно только пересоздать.

Итог по LCL

Вся специфика LCL уместилась в:

  • одну вспомогательную функцию растеризации (GraphicToBitmap32);
  • два метода обмена с битмапом (CopyFromBitmap / CopyToBitmap);
  • два конструктора-загрузчика для удобства.

Есть одна особенность, о которой стоит помнить: CopyToBitmap пересоздаёт handle. На стандартных сценариях обработки изображений это незаметно, но в нестандартных может стать сюрпризом.

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

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

Реализация для FMX

В FMX, аналогично LCL, мы не имеем прямого доступа к буферу битмапа, пиксели можно получить только через короткоживущий Map, и удерживать его на время работы алгоритма нельзя. Плюс на iOS/macOS/Android внутренний порядок каналов может быть RGBA, а наш интерфейс требует BGRA. Поэтому здесь также только одна реализация, аналог TLclMemorySurface32.

Вся специфика FMX полностью локализована в двух методах — CopyFromBitmap и CopyToBitmap. Остальное унаследовано.

Конструкторы и фабрика

Конструкторов три — для трёх типичных источников: готовый TBitmap, файл, поток. Все они тонкие: вызывают базовый Create и затем соответствующий CopyFrom…:

CreateSurface — обязательный метод фабрики из интерфейса. Создаёт пустой экземпляр того же типа:

Главный нюанс: порядок каналов

Вся специфика FMX сводится к одному решению: как лежат байты в памяти. На Windows FMX использует BGRA — наш родной формат, копировать можно Move. На iOS, macOS и Android может использоваться RGBA, то есть нужен своп каналов R и B при каждом копировании.

Решение делается в одной маленькой функции:

И применяется через внутреннюю процедуру копирования строки:

На Windows, где формат совпадает, мы делаем один Move на всю строку — быстро и без лишней работы. На платформах с RGBA приходится идти попиксельно со свопом, медленнее, но другого пути нет. Решение принимается один раз на строку, а не на каждый пиксель — if not SwapRB стоит вне цикла.

Сама операция симметрична: что для чтения из битмапа, что для записи в битмап — алгоритм один. R и B меняются местами, G и A остаются на местах. Поэтому одна и та же функция используется и в CopyFromBitmap, и в CopyToBitmap.

CopyFromBitmap

Логика прямолинейная: устанавливаем размер, фиксируем amPremultiplied (FMX-битмапы всегда premultiplied — это свойство платформы, не наше предположение), проверяем нулевой размер, и далее построчно копируем через Map/Unmap.

Парное применение Map/Unmap обрамляет только то время, пока мы реально копируем строки. Никаких алгоритмов внутри Map не происходит. Это принципиально — Map короткоживущий ресурс, и мы его держим строго на время копирования. Алгоритм будет работать дальше с уже нашим буфером FData, который никем не залочен.

SetSize(0, 0) корректно отрабатывается базовым классом — буфер FData получит длину 0, и мы выходим, не пытаясь делать Map пустого битмапа.

CopyToBitmap

Зеркальный метод. Параметр var ABitmap означает, что вызывающий может передать nil, и тогда мы создадим битмап сами. Это удобно для разовых случаев, когда результат не нужно складывать в заранее существующий битмап. Если битмап передан, но не того размера — приводим к нужному через SetSize.

SwapRB проверяется именно у целевого битмапа, а не у нашего буфера. Потому что наш буфер всегда BGRA, а вот битмап может оказаться на платформе с RGBA — тогда при записи нужно свопить. Это та же асимметрия, что в CopyFromBitmap, только с обратным знаком.

Что про AlphaMode

Заметьте, что CopyFromBitmap принудительно ставит amPremultiplied. А CopyToBitmap никак не трогает альфа-режим целевого битмапа — потому что в FMX просто нет свойства, аналогичного VCL-овскому AlphaFormat. FMX-битмап всегда premultiplied, выбора нет.

Если пользователь хочет работать в amIgnored (то есть игнорировать альфу при обработке), он может поменять режим у поверхности после CopyFromBitmap. Это будет означать «я знаю, что физически данные premultiplied, но обрабатывать их хочу как непрозрачные».

Итог по FMX

Вся специфика FMX уместилась в:

  • одну функцию определения формата (BitmapNeedsRBSwap);
  • одну функцию построчного копирования со свопом (CopyRowSwapIfNeeded);
  • два метода обмена с битмапом (CopyFromBitmap CopyToBitmap);
  • три конструктора-загрузчика для удобства.

Все остальные требования интерфейса — GetScanLine, GetData, GetWidth, GetHeight, SetSize, SetAlphaMode, размещение пикселей в памяти — обеспечены базовым классом TMemoryImageSurface32. Алгоритм, написанный для IImageSurface32, не имеет ни малейшего понятия, что под ним FMX, что внутри был Map, и что на маке свопались каналы. Он видит ровно тот же интерфейс, что в VCL и LCL.

Если завтра понадобится поддержка, скажем, Skia — мы напишем TSkiaMemorySurface32 ровно по той же схеме: что-то про чтение пикселей, что-то про запись, остальное унаследовано.

Переходим к VCL-реализации. Из-за возможности прямого доступа к буферу пикселей в этой реализации можно развернуться во всю ширь.

Реализация для VCL

VCL-битмап (Vcl.Graphics.TBitmap) — мой главный попутчик по жизни. Большинство приложений на VCL уже работают с TBitmap: загружают, рисуют, показывают на канве. Логично, что наши алгоритмы должны прозрачно работать с этим объектом.

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

Поэтому реализаций для VCL две:

  • TVclMemorySurface32 — наследник TMemoryImageSurface32 с собственным буфером, специализированный под VCL. Есть методы CopyFromBitmap / CopyToBitmap для обмена данными с битмапом, но между этими копированиями поверхность живёт независимо.
  • TVclBitmapSurface — обёртка вокруг существующего TBitmap без копирования. Все обращения через IImageSurface32 адресуют тот же самый буфер пикселей в DIB битмапа. Изменения через интерфейс мгновенно видны через свойства битмапа, и наоборот.

TVclMemorySurface32: собственный буфер с VCL-раскладкой

Наследуется от TMemoryImageSurface32 и переопределяет, помимо CreateSurface, ещё и GetScanLine:

В TMemoryImageSurface32 строка Y — это байты Y*Stride .. (Y+1)*Stride-1 от начала буфера. В TVclMemorySurface32 логическая строка Y хранится физически на месте Height-1-Y. То есть в памяти расклад тот же, что у VCL: первая строка в памяти — нижняя строка картинки.

Зачем это? Чтобы CopyFromBitmap и CopyToBitmap могли копировать одним Move весь блок пикселей, без построчных циклов:

Bitmap.ScanLine[Height-1] — указатель на начало DIB (нижняя строка). FData[0] — начало нашего буфера, тоже соответствует нижней строке. Раскладка совпадает, можно копировать целиком.

Если бы мы не инвертировали GetScanLine, пришлось бы либо копировать построчно (медленнее), либо иметь рассогласование между «логическим» порядком в ScanLine[Y] и физическим порядком в памяти, что усложнило бы и Data, и понимание класса.

Data для TVclMemorySurface32 остаётся унаследованным:

И возвращает он начало физического буфера, что в нашей раскладке соответствует нижней строке картинки.

CopyFromBitmap: копирование с конвертацией формата

Логика двухступенчатая. Если формат не pf32bit, то делаем временный битмап, копируем туда исходник через Assign (получаем независимую копию), переводим временный в pf32bit, и рекурсивно вызываем себя на нём. Рекурсия завершается за один шаг, потому что временный битмап уже в нужном формате.

Если формат уже pf32bit, выставляем размер, копируем AlphaMode из AlphaFormat, и одним Move переносим все пиксели. Здесь работает то самое знание про bottom-up: ScanLine[Height-1] — это начало DIB, FData[0] — начало нашего буфера, обе раскладки совпадают.

CopyToBitmap: обратное копирование

Здесь две ветви для удобства пользователя. Если он передал ABitmap = nil, процедура создаст битмап сама с нужным форматом, размером и альфой, а пользователь получит результат через var-параметр. Если передал битмап неподходящего формата или размера — мы не трогаем его свойства напрямую (PixelFormat := …; SetSize(…)), потому что это сбросило бы атрибуты целевого битмапа. Вместо этого делаем подходящий временный битмап и копируем в него, а потом через Assign приводим целевой к нужному виду. Assign корректно перенесёт пиксельные данные, не разрушая остального.

Если же пришёл битмап правильного формата и размера, копируем напрямую одним Move.

TVclBitmapSurface: обёртка без копирования

Класс короткий по содержанию, но в нём несколько тонкостей, связанных со спецификой VCL.

Новый конструктор

Конструктор требует уже готовый битмап в формате pf32bit. Это сознательное ограничение: обёртка не должна молча конвертировать формат — это была бы неявная и потенциально дорогая операция. Если у пользователя битмап другого формата, пусть он явно решит, что с этим делать: либо конвертировать самому, либо использовать TVclMemorySurface32.CreateFromBitmap, который для того и сделан.

Параметр AOwnsBitmap — это управление временем жизни. По умолчанию обёртка не владеет битмапом: пользователь передал свой, пользователь и освободит. Но иногда удобно создать битмап специально для оборачивания и забыть про него, тогда AOwnsBitmap = True, и битмап освободится в деструкторе вместе с обёрткой.

Этот же приём используется в TVclBitmapSurface.CreateSurface, где создаём временный битмап и сразу передаём владение обёртке.

Bottom-up в VCL и тонкость GetData

Повторюсь, что особенность VCL-битмапа заключается в том, что строки в DIB лежат снизу вверх. То есть ScanLine[0] указывает на верхнюю строку картинки, но физически это последняя строка в памяти. А ScanLine[Height-1] наоборот, нижняя строка картинки, но первая в памяти.

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

Тут работают сразу два знания о VCL: что строки идут снизу вверх (отсюда Height-1), и что для pf32bit они лежат вплотную без выравнивающих байт (Width × 4 всегда кратно четырём, никакой padding не нужен). Если бы битмап был, например, pf24bit, между строками мог бы быть padding, и Move на весь объём дал бы мусор. Но pf32bit мы обеспечили в конструкторе.

Получается семантическое согласие с TVclMemorySurface32.GetData: оба класса возвращают указатель на «начало непрерывного блока в bottom-up порядке». Алгоритм, использующий Data, видит одну и ту же картину независимо от того, какая из двух VCL-реализаций под ним.

Проверка на Height <= 0 нужна, чтобы для пустого битмапа возвращать nil, а не падать с исключением — как мы и договорились в семантике IImageSurface32.Data.

Тонкость SetAlphaMode на заполненном битмапе

Внешне просто, но за FBitmap.AlphaFormat := afPremultiplied стоит физический проход по всем пикселям с умножением RGB на A. На пустом битмапе (Width × Height = 0) это ничего не делает; на маленьком — незаметно; на 4К-картинке — заметная пауза.

Поэтому имеет смысл следовать паттерну Create -> SetAlphaMode -> SetSize: установить режим альфы до того, как буфер заполнился чем-то непустым. Тогда SetAlphaMode никаких пикселей не трогает (битмап ещё пустой), а SetSize после неё аллоцирует буфер уже в нужном режиме.

Если же пользователь сначала наполнил битмап, а потом меняет режим — это легально, но он должен понимать, что VCL переберёт все пиксели.

Сохранение AlphaFormat в SetSize

Здесь приятный нюанс VCL. Vcl.Graphics.TBitmap.SetSize сохраняет AlphaFormat при изменении размера, не сбрасывая его в afIgnored. Это сделано как раз для того, чтобы избежать длительных конвертаций при последовательных операциях. Поэтому наш SetSize остаётся одной строкой:

И семантика AlphaMode как «свойства поверхности, переживающего изменение размера» соблюдается бесплатно, за счёт самого VCL.

CreateSurface создаёт чистую обёртку

Создаётся пустой битмап в pf32bit, оборачивается обёрткой с владением. Размер — нулевой, режим альфы — amIgnored (значение по умолчанию для нового TBitmap). Программист дальше выставит то и другое сам по своему сценарию.

Передача AOwnsBitmap = True принципиальна: новая поверхность сама отвечает за созданный для неё битмап. Когда последняя ссылка на интерфейс уйдёт, и поверхность будет уничтожена, битмап освободится автоматически.

Итог по VCL: что выбрать пользователю

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

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

Обе реализации идентичны для алгоритма, он работает через IImageSurface32 и не отличает одну от другой. Разница только в политике владения данными.

Боевое крещение: Один алгоритм на три экосистемы

У нас всё готово, чтобы наконец убедиться, что мероприятие было затеяно не зря. Возьмём типичную задачу обработки изображений — альфа-блендинг одного битмапа на другой со смещением (X, Y) и общей непрозрачностью AOpacity и реализуем её один раз, через IImageSurface32. А потом из VCL-, FMX- и LCL-проектов вызовем эту функцию, передав ей родные битмапы каждой экосистемы.

Сам алгоритм

Альфа-блендинг

[свернуть]

Весь код — это чистая работа с пикселями. В нём нет ни одного упоминания Vcl.Graphics, FMX.Graphics, TLazIntfImage или хотя бы Windows. Алгоритм видит ровно то, что обещает интерфейс: ширину, высоту, scanline-ы и режим альфы.

Несколько ключевых мест.

NormalizeForBlend. Для того, чтобы наложение отработало корректно, надо привести альфу в нормальное состояние. А именно, если поверхность помечена как не предумноженная, необходимо установить альфа-канал в 255, иначе там будет мусор. Если поверхность имеет признак amPremultiplied, верим, что пиксели находятся в предумноженном состоянии и ничего не трогаем.

Result := ADst.CreateSurface — та самая фабрика из интерфейса. Алгоритм не знает, что под ним: если вход был VCL-обёрткой, результат тоже будет VCL-обёрткой; если LCL — будет LCL. Алгоритму это безразлично, он получает «такую же поверхность».

Цикл по строкам через ScanLine[Y] — здесь нам важна согласованность: индекс Y отсчитывается от верха картинки, и обе поверхности (источник и приёмник) интерпретируют его одинаково, независимо от того, как физически лежат строки в памяти. В VCL они идут снизу вверх, в FMX и LCL — сверху вниз; алгоритм об этом ничего не знает и знать не хочет.

Внутренний цикл по пикселям — работа с PPixel32 напрямую через Inc, без обращений к интерфейсу. Виртуальный вызов был один раз на строку (ScanLine[Y]), внутри строки — голый указатель. Это и есть «нулевой оверхед», который мы заявляли в начале.

Вызов из VCL

В VCL мы используем обёртку без копирования — TVclBitmapSurface. Битмапы остаются на своих местах, обёртка просто даёт алгоритму прямой доступ к их пикселям. После работы BlendSurface возвращает новую поверхность (созданную через CreateSurface приёмника, тоже TVclBitmapSurface, со своим внутренним битмапом), и нам остаётся только достать этот битмап через Assign, чтобы вызывающая сторона получила независимый экземпляр.

Никаких копирований пикселей до и после работы алгоритма не происходит — мы платим только за сам блендинг.

Вызов из FMX

В FMX прямого доступа к пикселям нет, Map явление недолговечное. Поэтому работаем через копию: CreateFromBitmap забирает пиксели через Map/Unmap в свой буфер, при необходимости свопая каналы R и B. AlphaMode выставляется автоматически в amPremultiplied, потому что FMX-битмапы всегда такие.

После работы CopyToBitmap возвращает результат в новый FMX.Graphics.TBitmap, снова через Map. Сам алгоритм между этими двумя точками работает с обычной памятью на скорости, не отличающейся от VCL-варианта. Дополнительное время уходит на предварительное копирование данных и обратное копирование.

Так как могу проверить на Андроиде, добавил текущий формат пикселя и операционную систему:

Как видим, время увеличилось — формат пикселя RGBA и нам надо два раза полностью пробегать по битмапу, с целью изменить порядок следования R и B. Но даже с учётом этого, подобный пробег по таким немаленьким битмапам 1315 x 877 и 480 x 457 уложился в 23 миллисекунды — это очень хороший результат.

Вызов из LCL

В LCL та же схема, что и в FMX — копирование через TLazIntfImage с гарантированно заданным форматом Init_BPP32_B8G8R8A8_BIO_TTB. Разница в одной детали: CreateFromBitmap в LCL ставит amIgnored, потому что у LCL нет надёжного способа узнать, premultiplied ли данные в битмапе. Но мы точно знаем, что наши PNG уже в premultiplied — поэтому явно выставляем amPremultiplied после загрузки. Это сценарий «пользователь знает лучше системы», о котором говорилось в разделе про LCL.

Краткие итоги

Три проекта, три экосистемы и одна и та же функция BlendSurface, одна на всех. Различия трёх ActionBlend сводятся к одному: какую реализацию IImageSurface32 создать на входе и как достать результат на выходе. Это всё.

Если завтра я оптимизирую BlendSurface через SIMD, или добавлю режим overlay вместо normal, или поменяю порядок обхода для лучшей локальности кэша, изменения тут же отразятся во всех трёх экосистемах. Без копипасты и без портирования.

Это и есть то, ради чего затевался интерфейс.

Цепочки обработки

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

Равномерное осветление поверхности

Напишем простейшую операцию — равномерное осветление поверхности:

Равномерное осветление

[свернуть]

Здесь, кстати, наконец-то использован прямой доступ через Data — тот самый Pattern 1, обещанный в разделе про интерфейс. Когда порядок обхода нам безразличен (а в попиксельных операциях вроде осветления — именно так), нет смысла дёргать ScanLine[Y] в цикле. Берём указатель на начало всей пиксельной памяти и идём по ней одним сплошным проходом. Ни одного виртуального вызова на всю функцию.

Теперь самое интересное — цепочка:

Что тут интересного. BlendSurface не отличает, что ей передали — оригинальный битмап, обёрнутый в TVclBitmapSurface, или результат Lighten. Для неё это одинаковые IImageSurface32. Каждый шаг возвращает поверхность, готовую быть аргументом следующего. Промежуточные результаты не превращаются в TBitmap и обратно — пиксели просто текут через цепочку, оставаясь всё это время в одном и том же BGRA32. А так как работаем с интерфейсами, они корректно уничтожаться при покидании зоны видимости.

Второй момент: код выше написан для VCL, но Lighten от этого фреймворконезависим ровно так же, как BlendSurface. В исходниках в конце статьи аналогичный вызов сделан и для FMX / LCL.

Градиентное осветление

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

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

А вот как это выглядит на экране — демка из VCL-проекта:

Сверху — два исходных изображения: фотореалистичная зебра-акварель (1315 x 877) и логотип «Animal Park» с прозрачным фоном (480 x 457). Снизу слева — результат: логотип наложен на фон, который перед этим был осветлён градиентом «Bottom-Left» (то есть сильнее всего в левом нижнем углу). 17 миллисекунд на всю цепочку для картинки 1315 x 877.

Нижним CombBox’ом можно выбрать режим смешивания. Можно поменять направление, поиграть с opacity. Под капотом каждый раз — одна и та же цепочка BlendSurface(LighterGradient(…), …) или BlendSurface(Lighten(…), …), и эта цепочка вызывает строго фреймворконезависимые функции.

Для сравнения, как бы это выглядело без градиентного осветления:

Скриншот сделан на VCL, но точно такая же демка собирается на FMX и LCL с тем же визуальным результатом. Что, собственно, и требовалось доказать.

Заключение

Мы спроектировали и реализовали тонкую прослойку между алгоритмами обработки изображений и графическими фреймворками. Получился один интерфейс IImageSurface32 на полусотни строк, базовая реализация TMemoryImageSurface32 и четыре фреймворковые реализации — две для VCL (с владением и без), по одной для FMX и LCL. Разделение по модулям не косметика, а необходимость. Каждый фреймворковый модуль тянет за собой свой набор unit-ов (Vcl.Graphics, FMX.Graphics, IntfGraphics) и в один файл их сводить ну вот совсем не нужно: один проект может физически не иметь доступа к юнитам другого фреймворка. Зато базовый модуль не зависит вообще ни от чего, кроме SysUtils и Math. Алгоритмы, которые на нём будут написаны, наследуют эту чистоту.

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

В качестве доказательства жизнеспособности конструкции мы написали полноценный альфа-блендинг с клиппингом, premultiplied-композитингом и общей непрозрачностью, плюс пару операций для построения цепочек. Одна функция — три рабочих экосистемы. Прикладной код, который её вызывает, занимает 5–10 строк и сводится к двум действиям: «оберни битмап» и «достань результат». Всё остальное внутри интерфейса.

Цена этой универсальности оказалась символической. Виртуальный вызов один раз на строку при использовании ScanLine[Y], ноль вызовов при попиксельной работе через Data. Для алгоритмов, которые крутят миллионы пикселей во внутренних циклах, такой оверхед лежит ниже погрешности измерения. Зато выигрыш ощутимый: математика отвязана от фреймворка навсегда. Можно писать алгоритм один раз и переиспользовать его в любом проекте на Delphi или Lazarus, какой бы графический стек он ни использовал.

И главное — интерфейс получился действительно минимальным. Не «архитектурно красивым», не «расширяемым на все случаи жизни», а ровно таким, какой нужен алгоритмам: указатель на пиксели, ширина строки в байтах, размеры, режим альфы. Всё. Если завтра понадобится поддержка Skia, GDI+, или какой-нибудь нативной поверхности iOS — добавится ещё один класс по тому же шаблону: пара методов обмена с битмапом, остальное унаследовано. Без переписывания алгоритмов.

Планы

Этот интерфейс — фундамент, на котором имеет смысл строить дальше. Планирую изучить, сделать и, если получится, описать:

  • Ресамплинг. Масштабирование изображений: bilinear, bicubic, Lanczos. Два последних пока знаю только в теории, руками ещё не делал. Но очень интересно было бы сделать.
  • Блюр. Возвращение к серии о размытии, но уже без привязки к Vcl.Graphics.TBitmap. Те же алгоритмы — от гауссовой свёртки до box blur со скользящей суммой и треугольного ядра.
  • Пиксельные эффекты и градиенты. Осветление, затемнение, инверсия, цветовые матрицы, градиентные маски. Простые по математике, но дающие огромный визуальный выхлоп в комбинации с блендингом и блюром.

Интерфейс, который мы построили сегодня, даёт возможность сосредоточиться исключительно на математике, не отвлекаясь на то, чей TBitmap сейчас используется.

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


Листинги

Тут представлены полные исходники модулей. Можно копировать как есть, зависимостей нет — только стандартные unit-ы. Также, всё можно скачать одним архивом в конце статьи.

Базовый модуль: IP76.Imaging.Surface

IP76.Imaging.Surface

[свернуть]

Реализация VCL: IP76.Imaging.Surface.Vcl

IP76.Imaging.Surface.Vcl

[свернуть]

Реализация FMX: IP76.Imaging.Surface.Fmx

IP76.Imaging.Surface.Fmx

[свернуть]

Реализация LCL: IP76.Imaging.Surface.Lcl

IP76.Imaging.Surface.Lcl

[свернуть]

Скачать

Демо-проекты:

Только IP76.Surface:

Исполняемые файлы:


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

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