Нейронная сеть для распознавания капчи

Досталась мне такая капча. На первый взгляд не сложно: 4-е не пересекающихся символа, мало шумов, характер искажений очевиден. Почитав на хабре статью "Взлом матановой капчи на C# — это просто!" я решил применить нейронную сеть к своей.

Алгоритм нейронной сети очень хорошо демонстрирует следующий рисунок и небольшое описание к нему (так же с хабра):

..каждый нейрон похож на холст художника, где на местах в которых чаще всего встречались черные пиксели наиболее темная краска (значение заряда больше), а там, где реже совсем светлый тон.

Изначально берутся образцы (в данном случае символов), накладываются друг-на-друга и формируется результат из точек(нейронов) каждая из которых имеет свой вес(заряд) - это называется обучением. Затем берётся символ для распознавания, находится сумма зарядов для него и принимается решение о его "похожести" на тот или иной образец. Нейронная сеть состоит из слоёв: то что подаётся на вход - входной слой, на выходе - выходной. Что бы добиться лучшего результата усложняют функцию суммирования делая её не линейной за счёт добавления дополнительных слоев - их называют скрытыми:

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


Исходя из этого необходимо:

  1. Для обучения необходимо подготовить образцы капчи. Чем больше их будет тем лучше
  2. Символы на капче поделить на отдельные изображения (сегментировать)
  3. Привести изображения символов к одному размеру

Для начала в графическом редакторе я нашёл что хорошо помогает поднять контраст. Готовых функций для изменения контраста для C# в интернете полно поэтому я взял первую попавшуюся. Затем в два цвета: чёрный/белый.
Во-вторых видно капча всегда изогнута в трёх местах: по середине и по краям. При этом найти радиус изгиба по горизонтали достаточно просто - снизу или вверху капчи есть "горка" (на рисунке ниже закрашена зелёным). Дальше я полагал что радиус изгиба одинаковый в середине и по краям, исходя из этого я разворачивал горку по вертикали и дорисовывал с двух сторон по краям (на рисунке ниже красным цветом). Потом обрезал лишнее по краям. Оставалось поделить на символы. Я использовал тот же алгоритм что и в первой ссылке на хабр: рекурсивный алгоритм Flood fill для выделения связных областей по 8 направлениям. Походу установил что максимальные размеры символов 25x24 пикселей. Я взял 1001 картинок капчи. Из них только 22 программа не смогла поделить на 4-е символа. Итого у меня было чуть меньше 4000 картинок-символов для обучения нейронной сети.
Что бы определить какие именно символы изображены на этих 4000 картинок (разгребать их руками совсем не хотелось) я воспользовался программой для распознавания текста Tesseract OCR. Точнее это библиотека, в том числе есть обёртка и для C# но заставить её (библиотеку) работать у меня не получилось. Поэтому я запускал её консольный вариант в цикле.

		// bmp_file_name - путь/имя файла с одним символом в BMP формате !!! Save(bmp_file_name, ImageFormat.Bmp);
		// res_file_name - путь/имя файла куда tesseract запишет результат 

		ProcessStartInfo startInfo = new ProcessStartInfo();
		startInfo.CreateNoWindow = false;
		startInfo.UseShellExecute = false;
		startInfo.FileName = @"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe";
		startInfo.WindowStyle = ProcessWindowStyle.Hidden;
		startInfo.Arguments = bmp_file_name + " " + res_file_name + @" -l eng -psm 10"; // "-l eng" язык "-psm 10" это значит один символ

		using (Process exeProcess = Process.Start(startInfo))
		{
			exeProcess.WaitForExit();
		}

		// ... тут читал содержимое res_file_name и если там 1 символ то счиал что ок

По-факту Tesseract OCR сделала примерно 50% того чего должна была. Остальные 50% пришлось сделать руками. Местами Tesseract допускала поразительные ошибки, что я в общем-то предполагал, поэтому остановился не на ней, а на нейронной сети.

В итоге получилось 3935 распознанных картинок-символов для обучения.
Всего различных символов набралось 52:
# + 2 3 4 5 6 7 8 9 = @ A B C D E F G H K M N P Q R S T W X Y Z a b c d e f g h k m n p q r s t w x y z
Количество картинок на символ: от 41 для символа "f" до 147 для символа "7"
Вот пример для "A"

Обучение нейронной сети

Напомню будем использовать Fast Artificial Neural Network Library (FANN). Там всего две dll. Я компилировал под .NET 4.0 x86.

Для этой библиотеки необходимо сформировать файл с данными для обучения в следующем формате:

num_train_data num_input num_output
inputdata seperated by space
outputdata seperated by space
...
inputdata seperated by space
outputdata seperated by space
  • num_train_data - это общее кол-во образцов (выше у меня получилось 3935 штук)
  • num_input - кол-во элементов во-входном массиве - у нас это картинка с символом размером 25px * 24px = 600 элементов.
  • num_output - кол-элементов в выходном массиве. У нас это кол-во символов 52.
  • inputdata - картинка с символом в виде массива где элемент это "0"-белый или "1"-чёрный цвет.
  • outputdata - массива из "0" и одной "1" соответствующей индексу символа

Вот начало моего файла Train.tr в этом формате:

3935 600 52
0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 0 0 0 0 1 1 0 0 1 1 1 0 0 0 0 0 0 1 1 0 0 0 1 1 0 0 0 0 1 1 0 0 1 1 1 0 0 0 0 0 0 1 1 0 0 0 1 1 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 1 1 0 0 1 0 1 1 1 1 1 1 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 0 0 0 0 0 0 0 0 0 0 1 1 1 0 0 1 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 0 0 1 1 1 0 0 1 1 1 1 1 1 1 1 1 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
..
..

Вторая строка это картинка в виде массива, третья строка это выходной массив с "1" в первом номере что соответствует символу "#". Размер файла Train.tr у меня получился около 5МБ

Само обучение выполняется так:

private void btnTrain_Click(object sender, EventArgs e)
{
	NeuralNet net = new NeuralNet();
	//Указываем слои нейронной сети
	uint[] layers = { 600, 200, 52 };
	net.CreateStandardArray(layers);
	//Устанавливаем случайные веса
	net.RandomizeWeights(-0.1, 0.1);
	net.SetLearningRate(0.7f);
	//Загружаем данные для обучения НС
	TrainingData data = new TrainingData();
	data.ReadTrainFromFile(@"c:\tmp\train.tr");
	//Обучаем сеть
	net.TrainOnData(data, 500, 0, 0.001f);
	//Сохраняем готовую НС в файл
	net.Save(@"c:\tmp\train.ann");
}

Этот код я скопировал 1-в-1 из хабра только поменял кол-ва в слоях на свои в строке 5.
600 / 52 - это кол-во нейронов во входном и выходном слое. Равно кол-ву элементов входного / выходного массива.
200 - это кол-во нейронов в промежуточном скрытом слое.

Методики расчёта слоёв и кол-ва нейронов не существует. Только экспериментально. Допустим скрытый слой 1, что бы подобрать кол-во нейронов для него можно воспользоваться программой FANN Tool. У меня она по умолчанию выдала 274 нейрона но результаты оказались плохими. К тому же программа вылетала при попытке сохранить лог проверки поэтому было крайне сложно анализировать результат.
Где-то видел что кло-во нейронов в скрытом слое выбирают как: вход / 3, поэтому я поставил 200. Так же я экспериментировал с maxEpochs / desired_error (параметры функции TrainOnData) / ActivationFunction но походу не вдаваясь в математику процесса делать это бесполезно. В документации к FANN есть раздел про выбор параметров: RandomizeWeights и SetLearningRate оттуда.
Ещё заметил странную особенность: файл train.tr получался разный на разных компьютерах. Процесс обучения с этими параметрами у меня занимает около 2-3 минут на виртуальной машине. На физическом комп-ре завершается за пару секунд, размер ann файла примерно одинаковый но при этом функция Run возвращает нули.

Проверка нейронной сети

После обучения процесс распознавания крайне прост: подаёте на вход массив такой же как inputdata на выходе получаете массив размером num_output из double элементов. Остаётся только найти индекс максимального элемента.

double[] output = net.Run(input);

Результаты

Процент распознавания символа составил около 92%, соответственно 4 символов около 0.924 = 0,7 = 70%. Процент успешного сегментирования 4-х символов около 90%. Итого процент успешного распознавания капчи составил около 63%.

Исходники

comments powered by Disqus
Яндекс.Метрика