В данной статье я расскажу как получить из полноцветного изображения полностью переработанное изображение, составленное только из двух цветов: черного и белого, т.е я покажу как с помощью D и небольшой библиотеки dlib выполнить своими руками так называемую бинаризацию изображения. Стоит отметить, что бинаризация изображения является важным компонентом некоторых алгоритмов обработки изображений и распознавания образов.
Бинаризация изображения, как я уже упоминал в предисловии, это преобразование, суть которого заключается в том, что наиболее яркие и существенные для какой-либо последующей обработки становятся максимально яркими, превращаясь в точки белого цвета (цвет с максимальной интенсивностью или яркостью), а все остальные точки, которые принято считать фоновыми, становятся минимально яркими, т.е преобразуются в точки черного цвета (абсолютное отсутствие цвета, минимальная яркость или интенсивность). Таким образом, вся операция бинаризации сводиться к обычной попиксельной трансформации каждой точки изображения либо в белый, либо в черный цвет в зависимости от некоего яркостного признака, т.е от некоторого минимально приемлимого значения яркости при превышении которого точка приобретает белый цвет. Данный признак мы будем называть порогом бинаризации, и это первое, что необходимо определить при реализации бинаризирования изображения.
В настоящий момент существует огромное множество алгоритмов и способов бинаризации, начиная от простой ручной (порог задается вручную и в зависимости от самого изображения) до сложных адаптивных и мульти-методов (в том числе, и многослойной бинаризации), но мы рассмотрим интересный и эффективный метод, который называется методом Оцу.
Метод Оцу – это алгоритм, позволяющий разделить пиксели изображения на два класса (“полезные” и “фоновые”), за счет несложного статистического анализа изображения, который при разделении пикселей на классы, делает так чтобы дисперсия внутри одного класса была минимальной. Звучит запутанно и непонятно, особенно, если познания в статистике и математике очень слабы, однако, можно без труда разобраться в коде (который будет приведен через пару строк) и прочесть какую-нибудь замечательную статью о методе, например, здесь.
Давайте же приступим к реализации !
Создадим новый проект в dub и добавим туда в качестве зависимости dlib:
dub init otsu_experiment
Наш проект будет состоять только из одного файла под названием app.d, в котором мы разместим три необходимых нам функции: вычисление гистограммы изображения, вычисления порога бинаризации по Оцу и сама процедура бинаризации.
Для построения гистограммы нам необходимо будет знать яркость каждого пикселя изображения и перевести эту яркость из диапазона [0,1] в диапазон [0,255], для упрощения расчетов и для получения массива, который будет содержать количество пикселей каждого значения яркости из нового диапазона. Нетрудно догадаться, что массив под гистограмму содержит ровно 256 элементов:
// создать гистограмму
auto createHistogram(SuperImage superImage)
{
int[256] histogram;
foreach (x; 0..superImage.width)
{
foreach (y; 0..superImage.height)
{
int instensity = cast(int) (superImage[x,y].luminance * 255);
histogram[instensity] += 1;
}
}
return histogram;
}
Алгоритм прост и понятен, хоть и отличается немного от того, что мы приводили в статье про гистограммы: проходим по каждой точке изображения, вычисляем ее яркость и используем ее как индекс массива гистограммы, увеличивая на 1, число в заданной ячейке массива.
Теперь рассчитаем порог бинаризации. Для этого сначала получим гистограмму изображения с помощью уже подготовленной нами функции createHistogram, после этого вычислим сумму яркостей всех пикселей изображения и далее производим статистическую оценку двух классов пикселей с помощью несложных статистических подсчетов:
// посчитать порог по Оцу
auto calculateOtsuThreshold(SuperImage superImage)
{
// вычисляем гистограмму
auto histogram = createHistogram(superImage);
// аккумулятор суммы яркостей
int sumOfLuminances;
// вычисляем сумму яркостей
foreach (x; 0..superImage.width)
{
foreach (y; 0..superImage.height)
{
sumOfLuminances += cast(int) (superImage[x,y].luminance * 255);
}
}
// общее количество пикселей
auto allPixelCount = cast(double) (superImage.width * superImage.height);
// оптимальный порог
int bestThreshold = 0;
// количество полезных пикселей
int firstClassPixelCount = 0;
// суммарная яркость полезных пикселей
int firstClassLuminanceSum = 0;
// оптимальный разброс яркостей
double bestSigma = 0.0;
for (int threshold = 0; threshold < 255; threshold++) { firstClassPixelCount += histogram[threshold]; firstClassLuminanceSum += threshold * histogram[threshold]; // доля полезных пикселей double firstClassProbability = firstClassPixelCount / allPixelCount; // доля фоновых пикселей double secondClassProbability = 1.0 - firstClassProbability; // средняя доля полезных пикселей double firstClassMean = (firstClassPixelCount == 0) ? 0 : firstClassLuminanceSum / firstClassPixelCount; // средняя доля фоновых пикселей double secondClassMean = (sumOfLuminances - firstClassLuminanceSum) / (allPixelCount - firstClassPixelCount); // величина разброса double meanDelta = firstClassMean - secondClassMean; // общий разброс double sigma = firstClassProbability * secondClassProbability * meanDelta * meanDelta; // находим оптимальный разброс if (sigma > bestSigma)
{
bestSigma = sigma;
bestThreshold = threshold;
}
}
return bestThreshold;
}
Бинаризация по Оцу делается очень просто: вычисляем порог бинаризации с помощью упомянутой выше функции, затем проходим по каждому пикселю изображения, на ходу подгоняя его яркость к нужному нам диапазону [0,255], и если яркость пикселя больше порога бинаризации, то в новом изображении на месте этого пикселя ставим белый пиксель, иначе – черный:
// бинаризация по Оцу
auto otsuBinarization(SuperImage superImage)
{
SuperImage newImage = image(superImage.width, superImage.height);
auto threshold = calculateOtsuThreshold(superImage);
foreach (x; 0..superImage.width)
{
foreach (y; 0..superImage.height)
{
auto luminance = cast(int) (superImage[x,y].luminance * 255);
if (luminance > threshold)
{
newImage[x, y] = Color4f(1.0f, 1.0f, 1.0f);
}
else
{
newImage[x, y] = Color4f(0.0f, 0.0f, 0.0f);
}
}
}
return newImage;
}
Теперь можно испытать процедуру бинаризации, для чего мы возьмем уже многим известное стандартное изображение Lenna.png и применим на нем функцию бинаризации:
void main()
{
auto img = load("Lenna.png");
img.otsuBinarization.savePNG("Lenna_binarizated.png");
}
Результат:

Метод Оцу довольно интересная и забавная процедура, которая может быть использована и как компонент алгоритма компьютерного зрения или распознавания образов, так и просто как креативный инструмент, используемй на стадии предобработки изображения. Я сам реализовывал данную процедуру в рамках проекта библиотеки цифровой обработки изображений под названием rip, и просто портировал ее под dlib. Но, как и обычно, сама процедура результат портирования из другого языка, и опять, в сторонней реализации были допущены досадные ошибки, особенно в том, что не были учтены границы для средних значений в алгоритме Оцу, что было успешно исправлено в нашей реализации (хотя возможно не все ошибки были учтены мной, но ряд ошибок был определенно исправлен).
Заканчивая статью, хотелось бы сказать следущее: будьте осторожны в применении сторонних алгоритмов, особенно, если вы их портируете из другого языка.
И полный код проекта на GitHub.