TPngImage: глюк масштабирования и как это исправить

Обнаружил удивительную вещь — при масштабировании TPngImage картинка смещается относительно такой же картинки в TBitmap. Захотелось разобраться в причинах столь загадочного поведения.

Как это выглядит

У нас есть некий PNG с альфой. Сделаем из него 32-разрядный Bitmap. Далее, кинем два TImage на форму, назначим в один PNG, в другой — BMP, и по переключателю будем менять видимость. Чтобы не моргало, выставим форме:

Для TImage выставим параметры масштабирования по умолчанию: Center, Proportional, Stretch := True.

Битмап делаем сами из PNG:

В этом случае перенесётся и альфа, и правильно выставится AlphaFormat.

Переключаем просмотр с PNG на BMP и видим, что картинки смещены относительно друг друга. Кто-то из них глючит, и вряд ли это TBitmap.

Предварительный анализ

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

Сделаем ещё один PNG, с рамкой по краям, шириной в 3 пикселя, и посмотрим, как себя поведут PNG и BMP при масштабировании:

Замечаем, что при отрисовке масштабированного PNG рамка справа и снизу «съедается», а при отрисовке BMP такого не наблюдается.

Вывод: Баг в отрисовке PNG действительно есть.

Причина казуса

Идём в исходники PNG и видим, что при масштабировании TPngImage не использует WinApI, а самостоятельно пересчитывает координаты пикселей:

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

На конкретном примере

У нас картинка 800 x 625. Прямоугольник, в котором происходит отрисовка: 436 x 341. Коэффициенты масштаба получаются такими:

Рамка справа находится в пикселях с индексами по X = 797, 798, 799:

Рамка снизу находится в пикселях с индексами по Y = 622, 623, 624:

Ну это же масштабирование, скажете вы. Было три пикселя, стал один — это нормально. Согласен, но посмотрим как себя ведёт округление на старте скан-линии:

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

Давайте представим себе, как должен вычисляться индекс, чтобы не терять пиксели:

Откуда это следует: при правильном маппинге первый целевой пиксель (i=0) должен соответствовать первому исходному (i2=0), а последний целевой (i=W-1) — последнему исходному (i2=Header.Width-1). Из этого граничного условия и следует формула.

Как вычисляются коэффициенты в TPngImage указано выше. Если индекс по X вычисляется так:

то, подставив формулу для коэффициента, получаем

Таким образом видим, что налицо проблема с неправильной индексацией.

А для TBitmap.Draw этой проблемы нет, масштабирование выполняет WinApI (StretchBlt или AlphaBlend). Поэтому и существует разница в отображении TPngImage и TBitmap.

Решение

Проблему и её причины мы определили. Давайте попробуем её решить.

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

Возможное решение — небольшая модификация исходника Vcl.Imaging.pngimage. Чтобы наши модификации возымели действие, копируем модуль в каталог проекта и меняем уже его.

Лютый хак. Так делать не надо

Чтобы видеть, как себя ведёт штатный TPngImage и видоизменённый, я переобозвал модуль Vcl.Imaging.pngimage, который лежит рядом с проектом в Vcl.Imaging.pngimage.Test. В предложении uses расположил модули в последовательности: Vcl.Imaging.pngimage.Test, Vcl.Imaging.pngimage. Родной Vcl.Imaging.pngimage идёт последним, чтобы IDE и компилятору было понятно, что TPngImage — это тот, что в Vcl.Imaging.pngimage.

Далее, в секции implementation задал новый тип для видоизменённого TPngImage:

Это сделано, чтобы можно было работать с разными классами TPngImage в одном приложении. Если модуль не переименовывать, то будет подхватываться именно тот, что рядом с проектом. То есть тот, в котором живёт модифицированный TPngImage.

Создаю экземпляр этого нового класса TPngImageTest (который на самом деле слегка изменённый TPngImage) следующим образом:

Сразу скажу, я против таких хаков без веской причины!

[свернуть]

Делаем правильные коэффициенты

В процедуре

находим строки, вычисляющие коэффициенты масштаба:

и меняем их на:

Так распределение пикселов станет более ровным.

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

Билинейный фильтр

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

Билинейный фильтр для TPngImage

[свернуть]

Суть переделки в том, что вещественные расчёты заменены целочисленной арифметикой. Также учли тот факт, что PNG хранит цвет и альфу в отдельных массивах. Причём цвет 24-битный, TRGBTriple.

Теперь разберёмся, как рисует прозрачность TPngImage. Когда его просят отрисовать себя на указанном холсте DC в заданном прямоугольнике Rect, он создаёт внутри себя временный битмап и рисует в него содержимое холста из этого прямоугольника:

Делается это затем, чтобы потом смешивать пиксель изображения с пикселем фона, согласно формуле:

Далее происходит цикл по строкам и внутренний цикл по пикселам в строке, в котором происходит расчёт цвета пикселя с учётом прозрачности. Для того, чтобы найти исходный пиксель, используются коэффициенты масштаба FactorX, FactorY, которые мы теперь считаем более правильно. На основании этих коэффициентов находятся индексы пикселя источника [i2, j2]. Конечный цвет пикселя формируется так:

За приёмник отвечает ImageData, за исходные данные ImageSource и AlphaSource. Далее, полученный битмап рисуется на холсте и уничтожается:

Что нам не нравится. Нам не нравится, что целочисленные индексы дают какую-то непонятную деформацию. И вот бы здорово как-то задействовать вещественные индексы. Ну так давайте задействуем:

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

И установим цвет уже с учётом рассчитанного цвета:

Результат безусловно лучше, но всё равно видна небольшая деформация внутри изображения.

Билинейная интерполяция безусловно помогла, сдвига больше нет. Но, сделанная руками, без использования SSE, она… медленная. Может быть попробуем AlphaBlend? Она ж для этого и создавалась в своё время.

AlphaBlend

AlphaBlend — WinApi функция, которая отображает растровые изображения, содержащие прозрачные или полупрозрачные пиксели.

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

Очевидно, что надо писать дополнительный метод отрисовки. Мы не собираемся формировать прозрачность каждого пикселя. Вместо этого, мы должны подготовить правильный битовый образ для AlphaBlend.

Для того, чтобы функция учитывала альфу каждого пикселя, мы должны использовать формат AC_SRC_ALPHA:

Функция AlphaBlend с флагом AC_SRC_ALPHA требует, чтобы исходные данные были в формате premultiplied alpha — то есть каждый цветовой компонент должен быть предварительно умножен на альфу:

Напишем пару перегруженных inline-функций для этого:

Для экономии тактов, GetPremultiplyQuad(const V: TRGBQuad) не вызывает GetPremultiplyQuad(const V: TRGBTriple; A: Byte), а выполняет все вычисления внутри себя.

Цикл для формирования битового образа для AlphaBlend уменьшается до простого перебора и сильно напоминает код из предыдущей статьи TBitmap.ScanLine:

Делаем метод класса TPngImage, который на основании 24-битных данных заголовка и массива альфы формирует 32-битный Bitmap и отрисовывает его в заданном прямоугольнике, используя возможности масштабирования функции AlphaBlend. Делаем всё на голом WinApi, как это принято в этом модуле:

Метод отрисовки PNG с использованием AlphaBlend

[свернуть]

Данный метод предполагается использовать только для 24-битных PNG с альфа-каналом, для других форматов используется штатный DrawPartialTrans.

Вызывать метод будем из TPngImage.Draw:

Получилось идеально. Быстро. Качественно. Но возникает ощущение, что всё то же самое делает битмап. Если посмотреть в исходники TBitmap, то убедимся, что мы, по сути, просто продублировали его функционал при PixelFormat = pf32Bit и AlphaFormat > afIgnored.

Окончательное решение: TBitmap

Я не сторонник менять штатные исходники. Стараюсь использовать хаки по минимуму, только в случае крайней необходимости. Поэтому предлагаю простое решение, заключающееся в том, что давайте просто не будем использовать TPngImage напрямую. Давайте конвертировать его в TBitmap и отображать уже его.

При TBitmap.Assign вызывается внутренний TPngImage.AssignTo, который корректно создаёт 32-битный TBitmap с AlphaFormat := afDefined. При этом альфа-канал сохраняется, а масштабирование берёт на себя WinApI.

Аргумент раз: Bitmap внутри PNG

Вся работа по отрисовке PNG происходит через создание битмапов. WinApi не умеет рисовать PNG, WinApi заточен под HBitmap. Никакого особого PNG-шного чуда тут нет. Более того, формирование битмапа у TPngImage сделано немного косячно, как мы выше убедились. Раз уж TPngImage всё равно делает битмап и руками его собирает перед каждой отрисовкой, не быстрее ли будет создать битмап один раз и у себя?

Аргумент два: Bitmap из PNG сделать просто

Битмап из PNG делается в две строки:

Аргумент три: Кесарю — кесарево

В этом случае не надо вносить никаких изменений в штатный TPngImage. Нет трудностей с коллективным проектом и недоумением новичков. Плюс к тому, билинейный фильтр, сделанный руками, медленный. Его можно разогнать, но зачем, если всё может сделать AlphaBlend, которая вызывается в недрах TBitmap в случае 32-разрядной альфы.

Напоследок

В последней версии Delphi 13.0 Florence, на момент написания статьи, баг не исправлен.

TPngImage из TBitmap

Чтобы создать TPngImage из TBitmap с корректным переносом альфа-канала, можно воспользоваться следующей функцией:

Сохранить TBitmap как PNG

Если понадобится сохранить TBitmap как PNG, то можем воспользоваться очень простой процедурой (публикация в телеге):


Скачать

Друзья, спасибо за внимание!

Исходник (zip) 1.19 Мб. Delphi XE 7

Исполняемый файл (zip) 1.87 Мб.


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

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