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