В предыдущих частях (часть 1, часть 2) мы реализовали несколько алгоритмов размытия — от наивной свёртки до быстрого Stack Blur. Но всё это время мы работали только с тремя каналами: R, G, B. Альфа-канал мы либо игнорировали, либо просто копировали из исходника.
Для непрозрачных изображений, таких как фотографии, скриншоты, этого достаточно. Но стоит взять картинку с прозрачностью и применить к ней размытие — результат окажется неправильным: резкие контуры там, где ожидаешь плавный переход, цветные ореолы на границе прозрачных областей, тёмные каёмки вокруг светлых объектов.
Всё это из-за того, что альфа-канал не участвовал в размытии наравне с цветом. В этой части мы разберёмся, почему так происходит, какую роль играет формат хранения пикселей (straight vs premultiplied alpha), и как правильно модифицировать наши алгоритмы, чтобы размытие корректно работало с прозрачностью.
В чём проблема
Возьмём фотографию из предыдущих частей, вырежем фон и применим к получившемуся изображению с прозрачным фоном одно из размытий, реализованных нами ранее. Например, скользящую сумму:

Результат сразу покажет проблему: по контуру объекта появляется тёмная каёмка, а края остаются резкими, несмотря на сильное размытие.
Почему так происходит? Наши алгоритмы из предыдущих частей размывали только три канала — R, G и B. Альфа-канал при этом просто копировался из исходного изображения. В результате цвета на границе объекта смешиваются с чёрными пикселями прозрачной области (прозрачный пиксель в premultiplied режиме — это RGBA(0, 0, 0, 0)), а граница прозрачности остаётся нетронутой — такой же резкой, как в оригинале.
Получается парадокс: содержимое размыто, а контур — нет. Вместо плавного растворения краёв мы видим мутное изображение, обрезанное по жёсткой маске.
Чтобы исправить это, нужно размывать все четыре канала, включая альфу. Но делать это нужно правильно, и здесь ключевую роль играет формат хранения прозрачности пикселей.
Формат хранения цвета с прозрачностью
Существует два способа хранения цвета с прозрачностью: straight alpha и premultiplied alpha.
Straight alpha
В формате straight alpha (также называемом unassociated) каналы R, G, B хранят «чистый» цвет, а альфа-канал — степень непрозрачности, независимо от цвета. Например, полупрозрачный красный пиксель выглядит так:
|
1 |
R = 255, G = 0, B = 0, A = 128 |
Цвет и прозрачность здесь существуют отдельно друг от друга. Это интуитивно понятно, но создаёт проблему: полностью прозрачный пиксель (A = 0) всё равно может хранить произвольный цвет. Такой «призрачный» цвет невидим при отображении, но при размытии он начнёт участвовать в вычислениях наравне с остальными и испортит результат.
Premultiplied alpha
В формате premultiplied alpha (associated) каждый цветовой канал заранее умножен на альфу:
|
1 2 3 |
R_pm = R × A / 255 G_pm = G × A / 255 B_pm = B × A / 255 |
Тот же полупрозрачный красный хранится как:
|
1 |
R = 128, G = 0, B = 0, A = 128 |
А полностью прозрачный пиксель всегда равен (0, 0, 0, 0), независимо от того, какой цвет был «под» прозрачностью. Призрачных цветов здесь не существует. Умножаем на ноль, получаем ноль.
Именно это свойство делает premultiplied alpha правильным выбором для размытия. Когда прозрачный пиксель представлен как (0, 0, 0, 0), он вносит нулевой вклад во все каналы при усреднении — и в цвет, и в прозрачность. Никакие скрытые цвета не просачиваются в результат.
Более того, в premultiplied формате размытие сводится к простому применению одного и того же фильтра ко всем четырём каналам. Никаких специальных формул, делений на альфу или условных проверок — просто четыре канала вместо трёх. Это математически корректно, потому что размытие — это линейная операция, а premultiplied alpha как раз и создан для того, чтобы линейные операции над пикселями давали правильный результат.
В Delphi формат задаётся свойством AlphaFormat у TBitmap:
|
1 |
Bitmap.AlphaFormat := afPremultiplied; |
В дальнейшем мы будем исходить из того, что входное изображение уже хранится в этом формате.
Неочевидный нюанс VCL
Устанавливая AlphaFormat, важно понимать один неочевидный нюанс реализации VCL. Когда вы присваиваете битмапу, в котором уже есть данные, любое значение, отличное от afIgnored, будь то afDefined или afPremultiplied, VCL вызывает внутреннюю процедуру PreMultiplyAlpha, которая умножает каждый цветовой канал на альфу. И обратно: при переключении на afIgnored вызывается UnPreMultiplyAlpha, которая делит каналы на альфу. При этом между afDefined и afPremultiplied никакого преобразования не происходит, разница между ними чисто семантическая.
Из этого следует одно очень важное практическое правило. Не надо переключать AlphaFormat туда-сюда на битмапе с данными: каждый цикл afIgnored -> afPremultiplied -> afIgnored — это умножение и деление с потерей точности из-за целочисленной арифметики, и после нескольких таких циклов полупрозрачные пиксели заметно деградируют.
Почему premultiplied решает проблему
Вернёмся к проблеме из первого раздела и разберём её детально. Допустим, у нас есть два соседних пикселя на границе объекта — непрозрачный красный и полностью прозрачный:
|
1 2 |
Пиксель 1: R=255, G=0, B=0, A=255 (непрозрачный красный) Пиксель 2: R=0, G=0, B=0, A=0 (прозрачный) |
При простом усреднении без учёта альфы мы получим:
|
1 2 3 4 5 6 |
R = (255 + 0) / 2 = 128 G = (0 + 0) / 2 = 0 B = (0 + 0) / 2 = 0 A = 255 (скопирована из исходника) Результат: (128, 0, 0, 255) — тёмно-красный, непрозрачный |
Красный потемнел вдвое, хотя рядом не было никакого тёмного объекта — только пустота. Именно так и возникает тёмная каёмка.
Теперь сделаем то же самое, но размоем все четыре канала в premultiplied формате. Данные в нём выглядят точно так же — просто для прозрачного пикселя это и так нули, а для непрозрачного premultiply ничего не меняет:
|
1 2 3 4 5 6 7 |
Пиксель 1: R=255, G=0, B=0, A=255 Пиксель 2: R=0, G=0, B=0, A=0 R = (255 + 0) / 2 = 128 A = (255 + 0) / 2 = 128 Результат: (128, 0, 0, 128) — premultiplied |
Конвертируем обратно в обычный цвет: R = 128 × 255 / 128 = 255. Цвет остался чистым красным, просто пиксель стал полупрозрачным. Именно этого мы и ожидаем на границе объекта: плавное растворение в прозрачность без изменения оттенка.
Ситуация становится ещё хуже, если прозрачный пиксель хранит «призрачный» цвет. В straight alpha это допустимо — пиксель невидим, какая разница, что в RGB? Но при размытии эти скрытые цвета всплывают:
|
1 2 3 4 |
Пиксель 1: R=255, G=0, B=0, A=255 (красный) Пиксель 2: R=0, G=255, B=0, A=0 (невидимый зелёный!) Среднее RGB: R=128, G=128, B=0 — жёлтый! |
Откуда взялся жёлтый на границе красного объекта? Из невидимого пикселя, который никогда не отображался на экране. В premultiplied формате такой ситуации не возникает в принципе: при A=0 все цветовые каналы тоже равны нулю, и скрытых цветов просто не существует.
Поэтому давайте перепишем выбранный в первом разделе метод с учётом premultiply и альфа-канала.
Размываем альфа-канал
Итак, берём реализацию скользящей суммы из второй части и пытаемся добавить размытие альфа-канала. На вход подаётся, как мы ранее договорились, уже premultiplied битмап. Осталось продолжить логику алгоритма на четвёртый канал с альфой.
Первая попытка: Формат результата
Как было сказано ранее, надо просто добавить размытие альфа-канала, аналогичное тому, как это делается для RGB-каналов. То есть добавляем переменную для хранения суммы значений альфа-канала и работаем с ней, как с остальными суммами.
|
1 2 |
SumA := SumA + AddPx.A; Inc(Count); |
При назначении в результат мы не берём значение исходной альфы, а рассчитываем, как остальные:
|
1 2 3 4 5 6 7 8 |
with TmpCache[Y][X] do begin R := SumR div Count; G := SumG div Count; B := SumB div Count; A := SumA div Count; // Было: A := SrcCache[Y][X].A; end; |
Итак, мы добавили альфа-канал в размытие — теперь все четыре канала обрабатываются одинаково. Запускаем, смотрим результат…

и видим, что края стали плавными — это хорошо, но они какие-то… тёмные? Что пошло не так?
Причина потемнения совсем другая, чем в первом разделе. Сейчас проблема в том, что мы размыли premultiplied данные, но записали результат в битмап, у которого не выставлен AlphaFormat := afPremultiplied. VCL считает, что пиксели хранятся в обычном формате (straight alpha), и при последующей установке AlphaFormat применяет premultiply повторно — умножает каналы на альфу ещё раз.
Разберём на конкретном пикселе. После размытия мы получили корректное premultiplied значение:
|
1 |
R = 128, G = 0, B = 0, A = 128 |
Это полупрозрачный красный. Но если битмап помечен как afIgnored, а затем где-то в коде или при выводе на экран происходит конвертация в premultiplied, VCL умножает каналы на альфу ещё раз:
|
1 2 3 4 5 6 |
R = 128 × 128 / 255 = 64 G = 0 B = 0 A = 128 Результат: (64, 0, 0, 128) — вдвое темнее, чем должен быть |
Именно это мы и наблюдаем на скриншоте: изображение чуть потемнело целиком, а полупрозрачные области пострадали сильнее всего, ведь чем ниже альфа, тем сильнее повторное умножение «съедает» яркость.
Исправление простое — нужно явно и сразу указать результату формат своих данных:
|
1 |
Result.AlphaFormat := afPremultiplied; // до заполнения пикселей |
Это скажет VCL при выводе картинки: «данные уже premultiplied, не трогай их».
Вторая попытка: Слишком медленно
Мы делаем всё правильно, выставляем альфа-формат результату, и промежуточному битмапу тоже.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
Tmp := TBitmap.Create; try Tmp.PixelFormat := pf32bit; Tmp.SetSize(W, H); Tmp.AlphaFormat := afPremultiplied; Result := TBitmap.Create; Result.PixelFormat := pf32bit; Result.SetSize(W, H); Result.AlphaFormat := afPremultiplied; ... finally ... end; |
Запускаем, смотрим…

и видим именно то размытие, которое ожидали. Но время выполнения неприятно удивляет. Даже по сравнению с предыдущей реализацией оно увеличилось почти в 2.5 раза! Хотя мы добавили всего две строки с присвоением формата.
На самом деле время крадётся во время присваивания AlphaFormat:
|
1 2 |
Result.SetSize(W, H); // Установили размер Result.AlphaFormat := afPremultiplied; |
До этого присваивания мы установили размер битмапа. Последующее назначение формата прозрачности вызовет перерасчёт цвета по всему битмапу. В нашем случае (600 на 600) это 360 000 пикселей, по несколько операций на пиксель: 3 умножения + 3 деления (или сдвига) + чтение/запись. Это немало, и более того, совершенно не нужно.
При назначении размера битмапа формат альфы пикселей не сбрасывается. Поэтому формат альфы имеет смысл выставлять до установки размеров.
|
1 2 3 4 5 6 |
Result := TBitmap.Create; Result.PixelFormat := pf32bit; // До установки размера, иначе внутри будет лишнее преобразование Result.AlphaFormat := afPremultiplied; // И только после этого выставляем размер Result.SetSize(W, H); |
В этом случае, когда и высота, и ширина битмапа равны нулю, ничего не происходит — данных нет, не на чем производить операции, время не тратится. Последующий вызов SetSize(W, H) заполнит массив данных нулями, что тоже совершенно корректная операция.
Финальная попытка: Скользящая сумма с альфой
Учитывая все предыдущие ошибки, делаем следующую реализацию алгоритма размытия скользящей суммой, которая учитывает альфу.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 |
procedure BlurBoxRunningSumA(Bitmap: TBitmap; const Value: TValue; out Result: TBitmap); var Radius: Integer; W, H, X, Y: Integer; SumR, SumG, SumB, SumA: Integer; Count: Integer; LeftX, RightX: Integer; TopY, BottomY: Integer; SrcCache, TmpCache, DstCache: TScanLineCache; Tmp: TBitmap; AddPx, SubPx: TPixel32; begin Radius := Round(Value.AsType<Double>); if Radius < 1 then Radius := 1; Bitmap.PixelFormat := pf32bit; W := Bitmap.Width; H := Bitmap.Height; Tmp := TBitmap.Create; try Tmp.PixelFormat := pf32bit; Tmp.AlphaFormat := afPremultiplied; Tmp.SetSize(W, H); Result := TBitmap.Create; Result.PixelFormat := pf32bit; // До установки размера, иначе внутри будет лишнее преобразование Result.AlphaFormat := afPremultiplied; // И только после этого выставляем размер Result.SetSize(W, H); SrcCache := BuildScanLineCache(Bitmap); TmpCache := BuildScanLineCache(Tmp); DstCache := BuildScanLineCache(Result); // Проход 1: горизонтальный for Y := 0 to H - 1 do begin // Инициализация: набираем первую сумму для X=0 SumR := 0; SumG := 0; SumB := 0; SumA := 0; Count := 0; for X := 0 to Min(Radius, W - 1) do begin AddPx := SrcCache[Y][X]; SumR := SumR + AddPx.R; SumG := SumG + AddPx.G; SumB := SumB + AddPx.B; SumA := SumA + AddPx.A; Inc(Count); end; // Записываем первый пиксель with TmpCache[Y][0] do begin A := SumA div Count; R := SumR div Count; G := SumG div Count; B := SumB div Count; end; // Скользим вправо for X := 1 to W - 1 do begin // Добавляем правый край RightX := X + Radius; if RightX < W then begin AddPx := SrcCache[Y][RightX]; SumR := SumR + AddPx.R; SumG := SumG + AddPx.G; SumB := SumB + AddPx.B; SumA := SumA + AddPx.A; Inc(Count); end; // Убираем левый край LeftX := X - Radius - 1; if LeftX >= 0 then begin SubPx := SrcCache[Y][LeftX]; SumR := SumR - SubPx.R; SumG := SumG - SubPx.G; SumB := SumB - SubPx.B; SumA := SumA - SubPx.A; Dec(Count); end; with TmpCache[Y][X] do begin R := SumR div Count; G := SumG div Count; B := SumB div Count; A := SumA div Count; end; end; end; // Проход 2: вертикальный for X := 0 to W - 1 do begin SumR := 0; SumG := 0; SumB := 0; SumA := 0; Count := 0; for Y := 0 to Min(Radius, H - 1) do begin AddPx := TmpCache[Y][X]; SumR := SumR + AddPx.R; SumG := SumG + AddPx.G; SumB := SumB + AddPx.B; SumA := SumA + AddPx.A; Inc(Count); end; with DstCache[0][X] do begin R := SumR div Count; G := SumG div Count; B := SumB div Count; A := SumA div Count; end; for Y := 1 to H - 1 do begin BottomY := Y + Radius; if BottomY < H then begin AddPx := TmpCache[BottomY][X]; SumR := SumR + AddPx.R; SumG := SumG + AddPx.G; SumB := SumB + AddPx.B; SumA := SumA + AddPx.A; Inc(Count); end; TopY := Y - Radius - 1; if TopY >= 0 then begin SubPx := TmpCache[TopY][X]; SumR := SumR - SubPx.R; SumG := SumG - SubPx.G; SumB := SumB - SubPx.B; SumA := SumA - SubPx.A; Dec(Count); end; with DstCache[Y][X] do begin R := SumR div Count; G := SumG div Count; B := SumB div Count; A := SumA div Count; end; end; end; finally Tmp.Free; end; end; procedure BlurBoxRunningSumSigmaA(Bitmap: TBitmap; const Value: TValue; out Result: TBitmap); var Sigma, Radius: Double; begin Sigma := Value.AsType<Double>; if Sigma < 0.5 then Sigma := 0.5; Radius := Max(1, (Sqrt(12.0 * Sigma * Sigma + 1.0) - 1) * 0.5); if Bitmap.AlphaFormat = afIgnored then BlurBoxRunningSum(Bitmap, TValue.From<Double>(Radius), Result) else BlurBoxRunningSumA(Bitmap, TValue.From<Double>(Radius), Result); end; |

И вот этот вариант работает уже и быстро, и хорошо.
Итог: Тройной Box Blur с альфой
Теперь мы можем написать полноценный блюр с альфой на основе ранее написанного тройного Box Blur’а.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
procedure BlurBoxTriplePassA(Bitmap: TBitmap; const Value: TValue; out Result: TBitmap); var Sigma, BoxRadius: Double; Pass: Integer; Tmp, Src: TBitmap; IgnoreAlpha: Boolean; begin Sigma := Value.AsType<Double>; if Sigma < 0.5 then Sigma := 0.5; BoxRadius := Max(1, (Sqrt(4.0 * Sigma * Sigma + 1.0) - 1) * 0.5); IgnoreAlpha := Bitmap.AlphaFormat = afIgnored; if IgnoreAlpha then BlurBoxRunningSum(Bitmap, TValue.From<Double>(BoxRadius), Result) else BlurBoxRunningSumA(Bitmap, TValue.From<Double>(BoxRadius), Result); for Pass := 2 to 3 do begin Src := Result; try if IgnoreAlpha then BlurBoxRunningSum(Src, TValue.From<Double>(BoxRadius), Tmp) else BlurBoxRunningSumA(Src, TValue.From<Double>(BoxRadius), Tmp); Result := Tmp; finally Src.Free; end; end; end; |

Чуть дольше, чем такой же алгоритм без альфы, но это ожидаемо. Сравним методы далее, в бенчмарке.
Что ж, как говорил Михаил Жванецкий — «Общим видом овладели, теперь подробности не надо пропускать». Переделка двух значимых для нас алгоритмов — Fast Gaussian Blur и Stack Blur — труда не составит.
Gaussian и Stack с альфой
Применим все принципы, изложенные выше, к двум алгоритмам, которые представляют для нас наибольший практический интерес: Gaussian blur математически точно воспроизводит купол нормального распределения, а Stack Blur — самый быстрый, с лучшим результатом сглаживания, чем скользящая сумма.
Быстрый Gaussian с альфой. Этот блюр мы не будем включать в бенчмарк. Он будет ожидаемо сильно медленней остальных. Применять его в UI мы не будем никогда. Вряд ли он понадобится и в обработках, про которые собираюсь говорить в следующих статьях. Но для полноты картины, согласитесь, эталон должен быть.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 |
// Транспонирование битмапа через ScanLine-кэш procedure TransposeBitmapA(Src: TBitmap; out Dst: TBitmap); var X, Y, W, H: Integer; SrcCache, DstCache: TScanLineCache; begin W := Src.Width; H := Src.Height; Dst := TBitmap.Create; Dst.PixelFormat := pf32bit; Dst.AlphaFormat := afPremultiplied; Dst.SetSize(H, W); // ширина и высота меняются местами SrcCache := BuildScanLineCache(Src); DstCache := BuildScanLineCache(Dst); for Y := 0 to H - 1 do for X := 0 to W - 1 do DstCache[X][Y] := SrcCache[Y][X]; end; // Горизонтальный проход с разделением краёв и середины procedure HorzPassFastA(SrcCache, DstCache: TScanLineCache; W, H: Integer; const K: TFastKernel); var X, Y, I, R: Integer; SumR, SumG, SumB, SumA: Integer; Weight: Integer; SrcRow: PPixel32Array; DstRow: PPixel32Array; LeftEnd, MidStart, MidEnd, RightStart: Integer; begin R := K.Radius; // Предвычисляем границы зон LeftEnd := Min(R - 1, W - 1); // защита от R >= W MidStart := R; MidEnd := W - R - 1; RightStart := Max(R, W - R); for Y := 0 to H - 1 do begin SrcRow := PPixel32Array(SrcCache[Y]); DstRow := PPixel32Array(DstCache[Y]); // Левый край: X = 0..LeftEnd (нужен Clamp) for X := 0 to LeftEnd do begin // Центральный вес Weight := K.Weights[0]; SumR := SrcRow[X].R * Weight; SumG := SrcRow[X].G * Weight; SumB := SrcRow[X].B * Weight; SumA := SrcRow[X].A * Weight; for I := 1 to R do begin Weight := K.Weights[I]; // Левый сосед (может выйти за границу) if X - I >= 0 then begin SumR := SumR + SrcRow[X - I].R * Weight; SumG := SumG + SrcRow[X - I].G * Weight; SumB := SumB + SrcRow[X - I].B * Weight; SumA := SumA + SrcRow[X - I].A * Weight; end else begin SumR := SumR + SrcRow[0].R * Weight; SumG := SumG + SrcRow[0].G * Weight; SumB := SumB + SrcRow[0].B * Weight; SumA := SumA + SrcRow[0].A * Weight; end; // Правый сосед (всегда в диапазоне при X < R и R < W) if X + I < W then begin SumR := SumR + SrcRow[X + I].R * Weight; SumG := SumG + SrcRow[X + I].G * Weight; SumB := SumB + SrcRow[X + I].B * Weight; SumA := SumA + SrcRow[X + I].A * Weight; end else begin SumR := SumR + SrcRow[W - 1].R * Weight; SumG := SumG + SrcRow[W - 1].G * Weight; SumB := SumB + SrcRow[W - 1].B * Weight; SumA := SumA + SrcRow[W - 1].A * Weight; end; end; DstRow[X].B := Clamp(SumB shr FIXED_SHIFT, 0, 255); DstRow[X].G := Clamp(SumG shr FIXED_SHIFT, 0, 255); DstRow[X].R := Clamp(SumR shr FIXED_SHIFT, 0, 255); DstRow[X].A := Clamp(SumA shr FIXED_SHIFT, 0, 255); end; // Середина: X = MidStart..MidEnd (без проверок границ) for X := MidStart to MidEnd do begin Weight := K.Weights[0]; SumR := SrcRow[X].R * Weight; SumG := SrcRow[X].G * Weight; SumB := SrcRow[X].B * Weight; SumA := SrcRow[X].A * Weight; for I := 1 to R do begin Weight := K.Weights[I]; // Гарантированно в диапазоне - никаких проверок! SumR := SumR + (SrcRow[X - I].R + SrcRow[X + I].R) * Weight; SumG := SumG + (SrcRow[X - I].G + SrcRow[X + I].G) * Weight; SumB := SumB + (SrcRow[X - I].B + SrcRow[X + I].B) * Weight; SumA := SumA + (SrcRow[X - I].A + SrcRow[X + I].A) * Weight; end; DstRow[X].B := SumB shr FIXED_SHIFT; DstRow[X].G := SumG shr FIXED_SHIFT; DstRow[X].R := SumR shr FIXED_SHIFT; DstRow[X].A := SumA shr FIXED_SHIFT; end; // Правый край: X = RightStart..W-1 (нужен Clamp) for X := RightStart to W - 1 do begin Weight := K.Weights[0]; SumR := SrcRow[X].R * Weight; SumG := SrcRow[X].G * Weight; SumB := SrcRow[X].B * Weight; SumA := SrcRow[X].A * Weight; for I := 1 to R do begin Weight := K.Weights[I]; if X - I >= 0 then begin SumR := SumR + SrcRow[X - I].R * Weight; SumG := SumG + SrcRow[X - I].G * Weight; SumB := SumB + SrcRow[X - I].B * Weight; SumA := SumA + SrcRow[X - I].A * Weight; end else begin SumR := SumR + SrcRow[0].R * Weight; SumG := SumG + SrcRow[0].G * Weight; SumB := SumB + SrcRow[0].B * Weight; SumA := SumA + SrcRow[0].A * Weight; end; if X + I < W then begin SumR := SumR + SrcRow[X + I].R * Weight; SumG := SumG + SrcRow[X + I].G * Weight; SumB := SumB + SrcRow[X + I].B * Weight; SumA := SumA + SrcRow[X + I].A * Weight; end else begin SumR := SumR + SrcRow[W - 1].R * Weight; SumG := SumG + SrcRow[W - 1].G * Weight; SumB := SumB + SrcRow[W - 1].B * Weight; SumA := SumA + SrcRow[W - 1].A * Weight; end; end; DstRow[X].B := Clamp(SumB shr FIXED_SHIFT, 0, 255); DstRow[X].G := Clamp(SumG shr FIXED_SHIFT, 0, 255); DstRow[X].R := Clamp(SumR shr FIXED_SHIFT, 0, 255); DstRow[X].A := Clamp(SumA shr FIXED_SHIFT, 0, 255); end; end; end; procedure BlurGaussianSeparableFastA(Bitmap: TBitmap; const Value: TValue; out Result: TBitmap); var Sigma: Double; K: TFastKernel; W, H: Integer; Tmp, Transposed, BlurredT: TBitmap; SrcCache, TmpCache, TransCache, BlurTCache: TScanLineCache; begin Sigma := Value.AsType<Double>; if Sigma < 0.5 then Sigma := 0.5; BuildFastKernel(K, Sigma); Bitmap.PixelFormat := pf32bit; W := Bitmap.Width; H := Bitmap.Height; // --- Шаг 1: горизонтальный проход (Src -> Tmp) --- Tmp := TBitmap.Create; try Tmp.PixelFormat := pf32bit; Tmp.AlphaFormat := afPremultiplied; Tmp.SetSize(W, H); SrcCache := BuildScanLineCache(Bitmap); TmpCache := BuildScanLineCache(Tmp); HorzPassFastA(SrcCache, TmpCache, W, H, K); // --- Шаг 2: транспонировать --- TransposeBitmapA(Tmp, Transposed); finally Tmp.Free; end; // --- Шаг 3: горизонтальный проход по транспонированному (= вертикальный) --- try BlurredT := TBitmap.Create; try BlurredT.PixelFormat := pf32bit; BlurredT.AlphaFormat := afPremultiplied; BlurredT.SetSize(H, W); // размеры транспонированы TransCache := BuildScanLineCache(Transposed); BlurTCache := BuildScanLineCache(BlurredT); HorzPassFastA(TransCache, BlurTCache, H, W, K); // --- Шаг 4: транспонировать обратно --- TransposeBitmapA(BlurredT, Result); finally BlurredT.Free; end; finally Transposed.Free; end; end; |
Stack Blur с альфой. Особых отличий от Box Blur нет. К трём суммам RGB добавилась четвёртая — для альфы. Остальное без изменений.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 |
procedure StackBlurHorzA(SrcCache, DstCache: TScanLineCache; W, H, Radius: Integer); var X, Y, I, SrcIdx, StackIdx: Integer; Divisor, StackSize: Integer; SumR, SumG, SumB, SumA: Integer; InR, InG, InB, InA: Integer; OutR, OutG, OutB, OutA: Integer; Px: TPixel32; Stack: array of TPixel32; Ri1: Integer; // Radius + 1 begin StackSize := Radius * 2 + 1; SetLength(Stack, StackSize); Ri1 := Radius + 1; Divisor := Ri1 * Ri1; for Y := 0 to H - 1 do begin SumR := 0; SumG := 0; SumB := 0; SumA := 0; OutR := 0; OutG := 0; OutB := 0; OutA := 0; InR := 0; InG := 0; InB := 0; InA := 0; // Инициализация стека для X=0 // Левая часть стека (позиции 0..Radius): clamp к левому краю for I := 0 to Radius do begin SrcIdx := Clamp(I - Radius, 0, W - 1); // при R=5: -5,-4,...,0 Px := SrcCache[Y][SrcIdx]; Stack[I] := Px; // Вес = I + 1 (для позиции 0 вес=1, для Radius вес=Ri1) SumR := SumR + Px.R * (I + 1); SumG := SumG + Px.G * (I + 1); SumB := SumB + Px.B * (I + 1); SumA := SumA + Px.A * (I + 1); OutR := OutR + Px.R; OutG := OutG + Px.G; OutB := OutB + Px.B; OutA := OutA + Px.A; end; // Правая часть стека (позиции Radius+1..StackSize-1) for I := 1 to Radius do begin SrcIdx := Clamp(I, 0, W - 1); Px := SrcCache[Y][SrcIdx]; Stack[Radius + I] := Px; // Вес = Ri1 - I SumR := SumR + Px.R * (Ri1 - I); SumG := SumG + Px.G * (Ri1 - I); SumB := SumB + Px.B * (Ri1 - I); SumA := SumA + Px.A * (Ri1 - I); InR := InR + Px.R; InG := InG + Px.G; InB := InB + Px.B; InA := InA + Px.A; end; StackIdx := 0; // указатель на самый старый элемент // Скольжение по строке for X := 0 to W - 1 do begin // 1. Записываем результат with DstCache[Y][X] do begin R := Clamp(SumR div Divisor, 0, 255); G := Clamp(SumG div Divisor, 0, 255); B := Clamp(SumB div Divisor, 0, 255); A := Clamp(SumA div Divisor, 0, 255); end; // 2. Вычитаем OutSum из общей суммы SumR := SumR - OutR; SumG := SumG - OutG; SumB := SumB - OutB; SumA := SumA - OutA; // 3. Убираем самый старый элемент из OutSum Px := Stack[StackIdx]; OutR := OutR - Px.R; OutG := OutG - Px.G; OutB := OutB - Px.B; OutA := OutA - Px.A; // 4. Новый пиксель занимает место старого в стеке SrcIdx := Clamp(X + Ri1, 0, W - 1); Px := SrcCache[Y][SrcIdx]; Stack[StackIdx] := Px; // 5. Добавляем новый пиксель в InSum InR := InR + Px.R; InG := InG + Px.G; InB := InB + Px.B; InA := InA + Px.A; // 6. Прибавляем InSum к общей сумме SumR := SumR + InR; SumG := SumG + InG; SumB := SumB + InB; SumA := SumA + InA; // 7. Средний элемент переходит из In -> Out // Это элемент на позиции (StackIdx + Ri1) mod StackSize I := StackIdx + Ri1; if I >= StackSize then I := I - StackSize; Px := Stack[I]; InR := InR - Px.R; InG := InG - Px.G; InB := InB - Px.B; InA := InA - Px.A; OutR := OutR + Px.R; OutG := OutG + Px.G; OutB := OutB + Px.B; OutA := OutA + Px.A; // 8. Сдвигаем указатель стека Inc(StackIdx); if StackIdx >= StackSize then StackIdx := 0; end; end; end; procedure StackBlurVertA(SrcCache, DstCache: TScanLineCache; W, H, Radius: Integer); var X, Y, I, SrcIdx, StackIdx: Integer; Divisor, StackSize: Integer; SumR, SumG, SumB, SumA: Integer; OutR, OutG, OutB, OutA: Integer; InR, InG, InB, InA: Integer; Px: TPixel32; Stack: array of TPixel32; Ri1: Integer; begin StackSize := Radius * 2 + 1; SetLength(Stack, StackSize); Ri1 := Radius + 1; Divisor := Ri1 * Ri1; for X := 0 to W - 1 do begin SumR := 0; SumG := 0; SumB := 0; SumA := 0; OutR := 0; OutG := 0; OutB := 0; OutA := 0; InR := 0; InG := 0; InB := 0; InA := 0; for I := 0 to Radius do begin SrcIdx := Clamp(I - Radius, 0, H - 1); Px := SrcCache[SrcIdx][X]; Stack[I] := Px; SumR := SumR + Px.R * (I + 1); SumG := SumG + Px.G * (I + 1); SumB := SumB + Px.B * (I + 1); SumA := SumA + Px.A * (I + 1); OutR := OutR + Px.R; OutG := OutG + Px.G; OutB := OutB + Px.B; OutA := OutA + Px.A; end; for I := 1 to Radius do begin SrcIdx := Clamp(I, 0, H - 1); Px := SrcCache[SrcIdx][X]; Stack[Radius + I] := Px; SumR := SumR + Px.R * (Ri1 - I); SumG := SumG + Px.G * (Ri1 - I); SumB := SumB + Px.B * (Ri1 - I); SumA := SumA + Px.A * (Ri1 - I); InR := InR + Px.R; InG := InG + Px.G; InB := InB + Px.B; InA := InA + Px.A; end; StackIdx := 0; for Y := 0 to H - 1 do begin with DstCache[Y][X] do begin R := Clamp(SumR div Divisor, 0, 255); G := Clamp(SumG div Divisor, 0, 255); B := Clamp(SumB div Divisor, 0, 255); A := Clamp(SumA div Divisor, 0, 255); end; SumR := SumR - OutR; SumG := SumG - OutG; SumB := SumB - OutB; SumA := SumA - OutA; Px := Stack[StackIdx]; OutR := OutR - Px.R; OutG := OutG - Px.G; OutB := OutB - Px.B; OutA := OutA - Px.A; SrcIdx := Clamp(Y + Ri1, 0, H - 1); Px := SrcCache[SrcIdx][X]; Stack[StackIdx] := Px; InR := InR + Px.R; InG := InG + Px.G; InB := InB + Px.B; InA := InA + Px.A; SumR := SumR + InR; SumG := SumG + InG; SumB := SumB + InB; SumA := SumA + InA; I := StackIdx + Ri1; if I >= StackSize then I := I - StackSize; Px := Stack[I]; InR := InR - Px.R; InG := InG - Px.G; InB := InB - Px.B; InA := InA - Px.A; OutR := OutR + Px.R; OutG := OutG + Px.G; OutB := OutB + Px.B; OutA := OutA + Px.A; Inc(StackIdx); if StackIdx >= StackSize then StackIdx := 0; end; end; end; procedure BlurStackBlurA(Bitmap: TBitmap; const Value: TValue; out Result: TBitmap); var Radius, W, H: Integer; Tmp: TBitmap; SrcCache, TmpCache, DstCache: TScanLineCache; begin // Если передаём радиус, а не сигму Radius := Round(Value.AsType<Double>); Bitmap.PixelFormat := pf32bit; W := Bitmap.Width; H := Bitmap.Height; Tmp := TBitmap.Create; try Tmp.PixelFormat := pf32bit; Tmp.AlphaFormat := afPremultiplied; Tmp.SetSize(W, H); Result := TBitmap.Create; Result.PixelFormat := pf32bit; Result.AlphaFormat := afPremultiplied; Result.SetSize(W, H); SrcCache := BuildScanLineCache(Bitmap); TmpCache := BuildScanLineCache(Tmp); DstCache := BuildScanLineCache(Result); StackBlurHorzA(SrcCache, TmpCache, W, H, Radius); StackBlurVertA(TmpCache, DstCache, W, H, Radius); finally Tmp.Free; end; end; procedure BlurStackBlurSigmaA(Bitmap: TBitmap; const Value: TValue; out Result: TBitmap); var Sigma, Radius: Double; begin Sigma := Value.AsType<Double>; if Sigma < 0.5 then Sigma := 0.5; Radius := Max(1, Sqrt(6.0 * Sigma * Sigma + 1) - 1.0); BlurStackBlurA(Bitmap, TValue.From<Double>(Radius), Result); end; |
Скрины не привожу по той причине, что они визуально не отличаются от приведённого выше. Всегда можно скачать демо-приложение в конце статьи и поэкспериментировать самостоятельно.
С математическими блюрами разобрались — изменения минимальны. Но Downscale+Upscale использует масштабирование через GDI, и здесь нас ждёт сюрприз.
Downscale+Upscale Blur с альфой
Проблема в том, что наш хитрый метод напрочь отказывается быть прозрачным.

Тут всё просто. Для качественного масштабирования мы использовали режим HALFTONE в функции SetStretchBltMode. К сожалению, при таком режиме мы теряем прозрачность. Для сохранения альфы будем использовать режим COLORONCOLOR. Качество уменьшения при этом снижается, ближайший сосед (nearest neighbor) вместо усреднения, но последующее размытие скрадывает разницу.
|
1 2 3 4 |
if Bitmap.AlphaFormat = afIgnored then SetStretchBltMode(SmallBmp.Canvas.Handle, HALFTONE) else SetStretchBltMode(SmallBmp.Canvas.Handle, COLORONCOLOR); |
Таким образом, наш хитрый метод размытия претерпевает минимальные, но важные изменения:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
procedure BlurDownscaleUpscaleA(Bitmap: TBitmap; const Value: TValue; out Result: TBitmap); var Sigma, SmallSigma, Scale, FinalRadius: Double; SmallW, SmallH: Integer; SmallBmp, BlurredSmall, Tmp: TBitmap; IgnoreAlpha: Boolean; begin Sigma := Value.AsType<Double>; if Sigma < 0.5 then Sigma := 0.5; Scale := Max(1.0, Sigma / 4.0); SmallW := Max(2, Round(Bitmap.Width / Scale)); SmallH := Max(2, Round(Bitmap.Height / Scale)); SmallSigma := Sigma / Scale; FinalRadius := Max(1, Scale * 0.5); Bitmap.PixelFormat := pf32bit; IgnoreAlpha := Bitmap.AlphaFormat = afIgnored; // 1. Уменьшаем SmallBmp := TBitmap.Create; try SmallBmp.PixelFormat := pf32bit; SmallBmp.AlphaFormat := Bitmap.AlphaFormat; SmallBmp.SetSize(SmallW, SmallH); if IgnoreAlpha then SetStretchBltMode(SmallBmp.Canvas.Handle, HALFTONE) else SetStretchBltMode(SmallBmp.Canvas.Handle, COLORONCOLOR); SetBrushOrgEx(SmallBmp.Canvas.Handle, 0, 0, nil); StretchBlt( SmallBmp.Canvas.Handle, 0, 0, SmallW, SmallH, Bitmap.Canvas.Handle, 0, 0, Bitmap.Width, Bitmap.Height, SRCCOPY ); // 2. Размываем маленькое (Stack Blur) if IgnoreAlpha then BlurStackBlurSigma(SmallBmp, TValue.From<Double>(SmallSigma), BlurredSmall) else BlurStackBlurSigmaA(SmallBmp, TValue.From<Double>(SmallSigma), BlurredSmall); // 3. Увеличиваем обратно try Tmp := TBitmap.Create; try Tmp.PixelFormat := pf32bit; Tmp.AlphaFormat := Bitmap.AlphaFormat; Tmp.SetSize(Bitmap.Width, Bitmap.Height); if IgnoreAlpha then SetStretchBltMode(Tmp.Canvas.Handle, HALFTONE) else SetStretchBltMode(Tmp.Canvas.Handle, COLORONCOLOR); SetBrushOrgEx(Tmp.Canvas.Handle, 0, 0, nil); StretchBlt( Tmp.Canvas.Handle, 0, 0, Bitmap.Width, Bitmap.Height, BlurredSmall.Canvas.Handle, 0, 0, SmallW, SmallH, SRCCOPY ); // 4. Лёгкий проход по полноразмерному результату if IgnoreAlpha then BlurBoxRunningSum(Tmp, TValue.From<Double>(FinalRadius), Result) else BlurBoxRunningSumA(Tmp, TValue.From<Double>(FinalRadius), Result); finally Tmp.Free; end; finally BlurredSmall.Free; end; finally SmallBmp.Free; end; end; |

В итоге видим на скрине очень симпатичное размытие, практически не отличимое от Гаусса.
Хочу отметить, что COLORONCOLOR — не лучший выбор. Лучшей альтернативой было бы применение API-функции AlphaBlend, которая корректно обрабатывает premultiplied данные. Но очень не хотелось раздувать код и терять общую наглядность алгоритма. В продакш-версии это надо учитывать, исходя из обстоятельств применения этого способа размытия.
Бенчмарк
Методика измерений та же, что в предыдущих частях: случайный порядок запусков, 30 замеров на метод, изображение 600×600, платформа x64, два режима — σ = 5 и σ = 50. Для сравнения на диаграмме оставлены версии без альфы из второй части и Direct2D в качестве ориентира.

Добавление четвёртого канала обошлось удивительно дёшево. Сравним попарно:
| Метод | Без альфы | С альфой | Разница |
|---|---|---|---|
| Box Blur ×3, σ=5 | 36.3 мс | 38.9 мс | +2.6 мс |
| Box Blur ×3, σ=50 | 36.0 мс | 38.7 мс | +2.7 мс |
| Stack Blur, σ=5 | 15.0 мс | 18.7 мс | +3.7 мс |
| Stack Blur, σ=50 | 15.7 мс | 19.8 мс | +4.1 мс |
| DownUp, σ=5 | 31.7 мс | 27.0 мс | −4.7 мс (!) |
| DownUp, σ=50 | 14.7 мс | 14.4 мс | −0.3 мс |
Box Blur и Stack Blur ведут себя предсказуемо: добавился четвёртый канал — добавилось 3–4 миллисекунды. Это примерно 7–25% сверху, что даже меньше ожидаемых 33% (четыре канала вместо трёх). Основное время в этих алгоритмах тратится не на арифметику, а на доступ к памяти, и один дополнительный байт на пиксель не сильно меняет картину.
Downscale-Upscale при σ=5 стал даже быстрее, но это не заслуга альфы, а скорее следствие того, что альфа-версия использует COLORONCOLOR вместо HALFTONE при масштабировании, что быстрее. При σ=50 разница в пределах погрешности.
Главный вывод: поддержка альфа-канала — не роскошь. За корректную обработку прозрачности мы платим считанные миллисекунды, получая взамен правильный результат без артефактов на границах. В продакшн-версии, скорее всего, надо отдать предпочтение варианту, сразу учитывающему альфа-канал, потому что замедление ничтожно мало, зато не надо поддерживать две версии алгоритмов — с альфой и без.
Direct2D по-прежнему недосягаем на GPU (7 мс).
Потенциальные ошибки при размытии с альфа-каналом
Частично, мы уже разбирали эти случаи выше, здесь приводятся для полноты чеклиста. Прошу простить меня за занудство, но ничего не могу с собой поделать.
Размытие в неправильном цветовом пространстве
Самая частая ошибка. Размытие straight alpha данных как есть:
|
1 2 3 4 5 6 7 8 9 10 11 |
Пиксель 1: R=255, G=0, B=0, A=255 (непрозрачный красный) Пиксель 2: R=0, G=255, B=0, A=0 (прозрачный, но хранит зелёный!) Среднее straight: R=127, G=127, B=0, A=127 Видимый цвет: тёмно-жёлтый — откуда?! Среднее premultiplied: PM1: (255, 0, 0, 255) PM2: (0, 0, 0, 0) - зелёный обнулился при premultiply Среднее: (127, 0, 0, 127) Видимый цвет: красный, полупрозрачный |
Результат ошибки: цветные ореолы (color fringing) вокруг полупрозрачных краёв «белая каёмка», «тёмные края».
Забыли размыть альфа-канал
|
1 2 3 4 5 |
// Ошибка: копируем альфу из исходника вместо размытия DstRow[X].A := SrcRow[X].A; // Правильно: размываем альфу наравне с RGB DstRow[X].A := Clamp(SumA shr FIXED_SHIFT, 0, 255); |
Результат ошибки: резкие границы по прозрачности при размытых цветах. Объект выглядит «вырезанным ножницами», но с мутным содержимым.
Нарушение инварианта R ≤ A
После размытия из-за ошибок округления возможно:
|
1 2 3 4 5 |
// Теоретически невозможно при корректных весах, // но при раздельном округлении каналов: R_result = 128 (округлилось вверх) A_result = 127 (округлилось вниз) // R > A — невалидный premultiplied пиксель! |
Защита:
|
1 2 3 4 |
// После вычисления всех каналов: if DstRow[X].R > DstRow[X].A then DstRow[X].R := DstRow[X].A; if DstRow[X].G > DstRow[X].A then DstRow[X].G := DstRow[X].A; if DstRow[X].B > DstRow[X].A then DstRow[X].B := DstRow[X].A; |
Для наших алгоритмов это не представляет реальной проблемы. В Box Blur и Stack Blur используется целочисленное деление с одним и тем же делителем для всех каналов, поэтому если исходные данные валидны (R ≤ A), результат тоже будет валиден. В Gaussian свёртке все веса неотрицательны и делитель одинаков для всех каналов. Если на входе R ≤ A для каждого пикселя, то взвешенная сумма R не может превысить взвешенную сумму A, а одинаковый shr порядок не нарушает.
Нарушение возможно только если:
- Исходные данные уже невалидны;
- Веса могут быть отрицательными (например, sharpen-фильтр);
- Каналы делятся с разными делителями (что было бы багом).
Двойное premultiply/unpremultiply
|
1 2 3 4 |
// Ошибка: данные уже premultiplied, но мы ещё раз конвертируем Bitmap.AlphaFormat := afIgnored; // VCL делает unpremultiply Bitmap.AlphaFormat := afPremultiplied; // VCL делает premultiply снова // Каждая конвертация теряет точность из-за целочисленного деления! |
Результат: постепенная деградация цвета, особенно в полупрозрачных областях. После нескольких циклов полупрозрачные пиксели темнеют.
AlphaFormat при создании промежуточных битмапов
|
1 2 3 4 5 6 7 8 |
// Если забыть выставить AlphaFormat на промежуточном битмапе, // а потом передать его в VCL-функцию, которая читает AlphaFormat: Tmp := TBitmap.Create; Tmp.PixelFormat := pf32bit; // Tmp.AlphaFormat := afPremultiplied; --- забыли! Tmp.SetSize(W, H); // ... заполняем premultiplied данными ... Canvas.Draw(0, 0, Tmp); // VCL думает, что данные straight -> неправильный вывод |
Скачать
Друзья, спасибо за внимание!
Исходник (zip) 709 Кб. Delphi XE 7, 13.0
Исполняемый файл x32 (zip) 1.69 Мб. Built in Delphi XE 7
Исполняемый файл x64 (zip) 2.22 Мб. Built in Delphi 13.0