Наверное, некоторые из вас, дорогие читатели, видели то, как ранее отображались картинки на экранах старых мониторов — при довольно скромной цветовой палитре, с помощью одного простого алгоритма, удавалось добиться глубины цвета, и при этом не задействуя значительные ресурсы процессора.
Сегодня, мы хотим вам показать реализацию одного из таких алгоритмов, который позволяет используя дизеринг и небольшую палитру получать интересные изображения.
Для начала объясним в двух словах, что такое дизеринг.
Дизеринг— это такая схема смешивания цветов из ограниченной палитры, которая позволяет смешивая цвета из палитры определенным образом: к примеру отсутствующие цвета, могут получаться путем расстановки существующих цветов в шахматном (или ином) порядке, а также эффект может достигаться подмешиванием шума со специально подобранными характеристиками. Для достижения эффекта глубины цвета (скажем сразу, глубина, а также отсутствующие цвета, которые при этом не исчезают с обработанного изображения являются чистой воды иллюзией) есть несколько очень известных алгоритмов. Мы будем делать реализацию одного из таких алгоритмов, который называется алгоритмом Флойда-Стейнберга.
Данный алгоритм основан на распространении ошибки, что означает, что ошибки от квантования переносятся на соседние пиксели, с которыми далее продолжается работа. По сути для того, чтобы алгоритм работал надо фактически вычислить насколько пиксель, который заменит текущий отличается от существующего в изображении (это и есть та самая ошибка) и применить эту разницу с определенными коэффициентами к соседним пикселям. Фактически, нам необходимо выполнить перемножение полученной ошибки на следующую матрицу значений:

Здесь звездочкой обозначен текущий пиксель, а коэффициенты показаны для его ближайших соседей (в контексте данного алгоритма), и на схеме показан только один шаг алгоритма, а его надо повторить для всех пикселей в изображении. Повтор этого шага приводит к постоянному накоплению и продвижению разницы между исходным и пикселем результатом трансформации, что в итоге приводит к изменению всех пикселей в изображении.
Кроме этого, есть важный момент — алгоритм указывает только как привести пиксели к новым значениям, на основании уже известного значения для нового пикселя, но алгоритм не говорит как получить новое значение для текущего пикселя.
Как вы понимаете, решение о том, как это сделать переходит на самого программиста. И вот тут мы кое что придумали…
Однажды мы уже выкладывали набор палитр в удобной для загрузки форме, и мы как раз предлагаем использовать либо загрузку палитры из какого-либо файла этой коллекции, либо ее ручное создание (путь для истинных ценителей, так сказать). Но просто загрузки недостаточно, поскольку сама палитра — это просто набор цветов, а в данном случае необходимо для каждого пикселя из изображения найти наиболее «подходящий» цвет из палитры.
Для достижения подобных целей, а также для реализации всего алгоритма будем использовать библиотеку dlib, с помощью которой задача загрузки и поиска ближайшего цвета из палитры решаются так:
import std.algorithm; import std.conv; import std.math; import std.range; import std.stdio; import std.string; import std.file; import dlib.image; Color4f[] getPalette(string filename) { Color4f[] palette; auto extractField(string triplet) { auto content = triplet.split(";"); auto r = parse!float(content[0]); auto g = parse!float(content[1]); auto b = parse!float(content[2]); return Color4f(r / 255.0f, g / 255.0f, b / 255.0f); } palette = (cast(string)(read(filename))) .splitLines .map!(a => extractField(a)) .array; return palette; } auto calculateColorDistance(Color4f a, Color4f b) { auto dx = a.r - b.r; auto dy = a.g - b.g; auto dz = a.b - b.b; return sqrt((dx ^^ 2) + (dy ^^ 2) + (dz ^^ 2)); } auto nearestColorFromPalette(Color4f a, ref Color4f[] palette) { import std.math : abs; float distance = float.max; Color4f r = palette[0]; foreach (e; palette.sort!`a.luminance < b.luminance`) { auto d = calculateColorDistance(e, a); if (d <= distance) { r = e; distance = d; } } return r; }
загрузку палитр мы уже рассматривали ранее, а вот про подход к поиску ближайшего цвета мы упоминали в записи про выборочное обесцвечивание. Но тут алгоритм поиска ближайшего цвета несколько отличается от того, что было при обесцвечивании: здесь также используется идея с минимальным расстоянием между цветами, но замена идет на цвет из палитры и только из присутствует только одна ветвь условия в if, поскольку в побочной ветви нет необходимости.
Сам алгоритм Флойда-Стейнберга можно выразить следующим псевдокодом:
for each y from top to bottom do for each x from left to right do oldpixel := pixels[x][y] newpixel := nearestColorFromPalette(oldpixel) pixels[x][y] := newpixel quant_error := oldpixel - newpixel pixels[x + 1][y ] := pixels[x + 1][y ] + quant_error × 7 / 16 pixels[x - 1][y + 1] := pixels[x - 1][y + 1] + quant_error × 3 / 16 pixels[x ][y + 1] := pixels[x ][y + 1] + quant_error × 5 / 16 pixels[x + 1][y + 1] := pixels[x + 1][y + 1] + quant_error × 1 / 16
который можно буквально перевести на D следующим образом:
auto dithering(SuperImage simg, ref Color4f[] palette) { foreach (x; 0..simg.width) { foreach (y; 0..simg.height) { auto oldpixel = simg[x, y]; auto newpixel = nearestColorFromPalette(oldpixel, palette); simg[x, y] = newpixel; auto quant_error = oldpixel - newpixel; simg[x + 1, y ] = simg[x + 1, y ] + quant_error * 7.0 / 16.0; simg[x - 1, y + 1] = simg[x - 1, y + 1] + quant_error * 3.0 / 16.0; simg[x , y + 1] = simg[x , y + 1] + quant_error * 5.0 / 16.0; simg[x + 1, y + 1] = simg[x + 1, y + 1] + quant_error * 1.0 / 16.0; } } return simg; }
И для испытания которого мы создадим палитру в стиле ZX-Spectrum и попробуем в качестве исходной картинки, взять изображение, созданное одним из авторов этого блога. Само изображение выглядит следующим образом:

Код для испытания:
void main() { //auto palette = getPalette(`bone.csv`); /* auto palette = [ Color4f(0.0f, 0.0f, 0.0f), Color4f(1.0f, 1.0f, 1.0f) ]; */ auto ZX_SPECTRUM_PALETTE = [ Color4f(0.0f, 0.0f, 0.0f), Color4f(0.0f, 0.0f, 238.0f), Color4f(238.0 / 255.0f, 0.0f, 0.0f), Color4f(238.0 / 255.0f, 0.0f, 238.0 / 255.0f), Color4f(0.0f, 238.0 / 255.0f, 0.0f), Color4f(0.0f, 238.0 / 255.0f, 238.0 / 255.0f), Color4f(238.0 / 255.0f, 238.0 / 255.0f, 0.0f), Color4f(238.0 / 255.0f, 238.0 / 255.0f, 238.0 / 255.0f), Color4f(0.0f, 0.0f, 0.0f), Color4f(0.0f, 0.0f , 255.0 / 255.0f), Color4f(255.0 / 255.0f, 0.0, 0.0), Color4f(255.0 / 255.0f, 0.0f, 255.0 / 255.0f), Color4f(0.0f, 255.0 / 255.0f, 0.0f), Color4f(0.0f, 255.0 / 255.0f, 255.0 / 255.0f), Color4f(255.0 / 255.0f, 255.0 / 255.0f, 0.0f), Color4f(255.0 / 255.0f, 255.0 / 255.0f, 255.0 / 255.0f) ]; alias palette = ZX_SPECTRUM_PALETTE; auto img = loadImage(`dlang.jpg`); img.dithering(palette); img.saveImage(`dlang_dither.png`); }
Результат тестирования с палитрой от ZX Spectrum 48/128 (16 цветов):

Код очень простой и может быть использован не только с той палитрой, которую мы задали в примере для тестирования, но и с любой другой, которая представлена в форме массива (не обязательно упорядоченного) цветов типа Color4f. Также не обязательно в алгоритме использовать именно наш подход с поиском ближайшего цвета на основе кратчайшего расстояния между цветом из палитры и текущим, можно использовать и иные варианты — здесь выбор остается за вами.
Напоследок, мы оставляем ряд ссылок, которые использовались при подготовке данной статьи:
Полный код dub-скрипта из статьи:
#!/usr/bin/env dub /+ dub.sdl: dependency "dlib" version="~>1.0.0" +/ import std.algorithm; import std.conv; import std.math; import std.range; import std.stdio; import std.string; import std.file; import dlib.image; Color4f[] getPalette(string filename) { Color4f[] palette; auto extractField(string triplet) { auto content = triplet.split(";"); auto r = parse!float(content[0]); auto g = parse!float(content[1]); auto b = parse!float(content[2]); return Color4f(r / 255.0f, g / 255.0f, b / 255.0f); } palette = (cast(string)(read(filename))) .splitLines .map!(a => extractField(a)) .array; return palette; } auto calculateColorDistance(Color4f a, Color4f b) { auto dx = a.r - b.r; auto dy = a.g - b.g; auto dz = a.b - b.b; return sqrt((dx ^^ 2) + (dy ^^ 2) + (dz ^^ 2)); } auto nearestColorFromPalette(Color4f a, ref Color4f[] palette) { import std.math : abs; float distance = float.max; Color4f r = palette[0]; foreach (e; palette.sort!`a.luminance < b.luminance`) { auto d = calculateColorDistance(e, a); if (d <= distance) { r = e; distance = d; } } return r; } auto dithering(SuperImage simg, ref Color4f[] palette) { foreach (x; 0..simg.width) { foreach (y; 0..simg.height) { auto oldpixel = simg[x, y]; auto newpixel = nearestColorFromPalette(oldpixel, palette); simg[x, y] = newpixel; auto quant_error = oldpixel - newpixel; simg[x + 1, y ] = simg[x + 1, y ] + quant_error * 7.0 / 16.0; simg[x - 1, y + 1] = simg[x - 1, y + 1] + quant_error * 3.0 / 16.0; simg[x , y + 1] = simg[x , y + 1] + quant_error * 5.0 / 16.0; simg[x + 1, y + 1] = simg[x + 1, y + 1] + quant_error * 1.0 / 16.0; } } return simg; } void main() { //auto palette = getPalette(`bone.csv`); /* auto palette = [ Color4f(0.0f, 0.0f, 0.0f), Color4f(1.0f, 1.0f, 1.0f) ]; */ auto ZX_SPECTRUM_PALETTE = [ Color4f(0.0f, 0.0f, 0.0f), Color4f(0.0f, 0.0f, 238.0f), Color4f(238.0 / 255.0f, 0.0f, 0.0f), Color4f(238.0 / 255.0f, 0.0f, 238.0 / 255.0f), Color4f(0.0f, 238.0 / 255.0f, 0.0f), Color4f(0.0f, 238.0 / 255.0f, 238.0 / 255.0f), Color4f(238.0 / 255.0f, 238.0 / 255.0f, 0.0f), Color4f(238.0 / 255.0f, 238.0 / 255.0f, 238.0 / 255.0f), Color4f(0.0f, 0.0f, 0.0f), Color4f(0.0f, 0.0f , 255.0 / 255.0f), Color4f(255.0 / 255.0f, 0.0, 0.0), Color4f(255.0 / 255.0f, 0.0f, 255.0 / 255.0f), Color4f(0.0f, 255.0 / 255.0f, 0.0f), Color4f(0.0f, 255.0 / 255.0f, 255.0 / 255.0f), Color4f(255.0 / 255.0f, 255.0 / 255.0f, 0.0f), Color4f(255.0 / 255.0f, 255.0 / 255.0f, 255.0 / 255.0f) ]; alias palette = ZX_SPECTRUM_PALETTE; auto img = loadImage(`dlang.jpg`); img.dithering(palette); img.saveImage(`dlang_dither.png`); }
P.S: На самом деле какое-то подобие дизеринга мы уже делали вот в этой статье, если интересно, можете с ней ознакомится.