Бинаризация методом Оцу в dlib

В данной статье я расскажу как получить из полноцветного изображения полностью переработанное изображение, составленное только из двух цветов: черного и белого, т.е я покажу как с помощью 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.

Добавить комментарий