Введение
Этот проект начинался как обучающий: я хотел углубить свои знания в машинном обучении, и в частности в TensorFlow. В конечном итоге мне хотелось получить работающую в браузере модель машинного обучения, которая смогла бы надёжным образом (с точностью не менее 80%, а предпочтительно >90%) решала капчу 4Chan. Я достиг этих целей и расскажу в статье, каким образом мне это удалось!
Код я опубликовал на GitHub.
Терминология
-
CAPTCHA (капча): тест «запрос-ответ», определяющий, является пользователь компьютера или веб-сайта человеком. Аббревиатура расшифровывается как Completely Automated Public Turing test to tell Computers and Humans Apart («полностью автоматизированный публичный тест Тьюринга для различения компьютеров и людей»).
-
4Chan: публичный анонимный сайт-имиджборд для обсуждения различных тем («досок»). Эти доски используются для публикации изображений и текстовых дискуссий. Перед отправкой каждого поста или ответа требуется пройти капчу.
-
Обычная капча: простейший вид капчи на 4Chan, состоящий из изображения с 5-6 цифробуквенными символами. Чтобы создать пост на 4Chan, пользователь должен прочитать и правильно ввести в поле все символы.
-
Капча со слайдером: более сложный вид капчи на 4Chan, состоящий из фонового изображения со случайно выглядящими фрагментами символов и изображения переднего плана с прозрачными «отверстиями» или «окнами» в нём. Необходимо перетянуть ползунок в браузерной форме капчи, чтобы выровнять два изображения и увидеть текст капчи.
Получаем данные
Я часто слышал, что самое сложное в любой задаче машинного обучения — это получить данные для обучения модели. В данном случае это утверждение тоже было правдивым. Проблема состоит из двух частей: получение капч и получение решений для этих капч.
Скрейпинг CAPTCHA с 4Chan
Изучив HTTP-запросы в консоли браузера при запрашивании новой капчи, я обнаружил, что выполняется запрос к https://sys.4chan.org/captcha?framed=1&board={board}
, где {board}
— это название доски, на которой мы собираемся оставить пост. Ответом становится HTML-документ, содержащий тэг скрипта с вызовом window.parent.postMessage()
с каким-то JSON. Я интуитивно попробовал удалить параметр framed=1
и обнаружил, что это заставляет сайт просто выдавать сырой JSON. С ним будет работать проще. JSON выглядит так:
{
"challenge": "[здесь какая-то длинная случайная строка]",
"ttl": 120,
"cd": 5,
"img": "[строка в base64]",
"img_width": 300,
"img_height": 80,
"bg": "[строка в base64]",
"bg_width": 349
}
Часть этих ключей достаточно очевидна. Самыми неочевидными для меня были ttl
и cd
. По опыту я знаю, что капча на 4Chan отображается всего две минуты, после чего срок её действия истекает и пользователь должен запросить новую; наверно, ttl
к этому и относится. Но что такое cd
? Давайте сделаем ещё один запрос вскоре после первого:
{
"error": "You have to wait a while before doing this again",
"cd": 23
}
Если продолжать делать тот же запрос, параметр cd
стабильно уменьшается с частотой примерно 1 в секунду. Отлично, значит, это время ожидания перед запросом новой капчи. Вероятно, cd
расшифровывается, как cooldown.
Если подождать 23 секунды, а потом сделать ещё один запрос, то ответ будет успешным, но на этот раз cd
увеличится до 32. Каждый раз нам приходится ждать дольше. После экспериментов со скриптом я пришёл к выводу, что несколько первых запросов можно делать каждые 5 секунд, затем промежуток увеличивается до 8, а затем продолжает приблизительно удваиваться, пока не достигнет 280 секунд, после чего не увеличивается.
Кроме того, при достижении таймера на 280 секунд CAPTCHA становится сложнее. Это выглядит так:
а предыдущая была такой:
То есть присутствует некое регулирование. Кроме того, если делать слишком много запросов, качество данных снижается (но они всё равно остаются читаемыми).
Вкратце коснусь того, что для получения возможности запросить капчи пользователь сначала должен пройти Cloudflare Turnstile. Поэтому невозможно использовать просто множество прокси с наивным скриптом без первоначального прохождения Cloudflare Turnstile и сохранения соответствующих куки. Когда я выполнял скрейпинг капч при помощи написанного для этого скрипта, я просто скопировал куки Cloudflare из своего браузера и вручную заменял их после истечения их срока действия.
Таким способом я выполнил скрейпинг нескольких сотен капч; этого недостаточно для обучения модели, но будет хоть какое-то начало. Однако у нас всё равно остаётся нерешённая проблема: капчи есть, но нет их решений. Можно заполнить их вручную, но давайте попробуем что-нибудь другое.
Получаем решения
Или люди плохо справляются с решением капч на 4Chan.
При реализации я часто сталкивался с проблемой «это легко сделать компьютеру, но сложно человеку». Многих пользователей невероятно раздражает капча со слайдером, однако я добился 100-процентного показателя успеха при выравнивании картинок с помощью своего эвристического скрипта (trainer/captcha_aligner.py
). Общепризнанно, что капчи на 4Chan очень сложны в решении. Но, разумеется, для людей, которые зарабатывают на решении капч, это не должно быть непреодолимой трудностью, ведь так?
Я написал короткий скрипт (trainer/labeler.py
), отправляющий капчи коммерческому сервису решения капч, где за скромную плату работают живые люди. Написать скрипт было просто, но использование его очень утомляло. Я отправил сервису пару десятков капч, но почти все они вернулись с неправильно определёнными одним или несколькими символами.
У сервиса есть функция «стопроцентное распознавание», позволяющее указать, чтобы все мои запросы сначала отправлялись n
работникам, а если x
этих работников не давали одинаковое решение, то запросы бы передавались ещё y
работникам. Сервис возвращает ошибку, только если отправит капчи n + y
работникам и не получит как минимум x
одинаковых решений. Я настроил в своём аккаунте значения n = 2
, x = 2
, y = 3
, то есть капчи изначально должны были отправляться двум работникам, а если они оба не пришли к одинаковому ответу, то капчи передавались ещё трём дополнительным работникам, пока двое из них не придут к одинаковому ответу или все разойдутся во мнениях.
Это немного улучшило ситуацию. Теперь распознавалось примерно 80% капч, а после проверки результатов выяснилось, что 90% были верными, но около 10% содержали ошибки; это говорило о том, что несколько работников делают одинаковые ошибки. Далеко не идеальная ситуация.
Небольшое примечание: что, если бы я просто попросил надёжного человека сделать это за меня или даже сделал бы сам? Я изучил оба этих способа. Написал короткий пользовательский скрипт, который сохранял изображение капчи и текст решения, и просто сидел, запрашивая и решая капчу в свободное время. Ещё я попросил делать то же самое своего хорошего друга. Так мы получили несколько тысяч изображений, которые я добавил в обучающий датасет, но в конечном итоге мы отказались от такого подхода, потому что продолжали сталкиваться с проблемой увеличения интервалов между запросами и с проблемой усложнения капч (в конце решить их становилось почти невозможно) при слишком большом количестве запросов.
Я начал раздумывать, а нет ли совершенно иного способа решения. Вероятно, можно избавиться от необходимости скрейпить капчи и распознавать их живыми людьми?
Генерируем синтетические данные
Что, если мы сможем генерировать собственные капчи 4Chan? 4Chan и используемые им капчи не опенсорсные, поэтому я не могу просто запустить тот же код локально. Но я определённо могу его аппроксимировать.
Капчу 4Chan можно разбить на две основные части: фон, который выглядит так:
и символы, которые выглядят так:
Нам не нужно генерировать собственные фоны с нуля. Это относительно простая задача computer vision: берём изображение, например капчу с 4Chan, и находим все крупные контуры в изображении, обозначающие символы, после чего удаляем их. Так у нас остаётся только шумный фон, показанный на изображении выше, который был сгенерирован при помощи этого алгоритма.
Затем нам нужно выделить достаточное количество символов и разметить их нашими значениями. Если бы это было легко сделать при помощи алгоритмического скрипта, я бы не писал эту статью, потому что распознавание капчи тоже бы стало тривиальной задачей. Однако это достаточно легко сделать вручную, и именно такой подход я выбрал. Это было утомительно. Я размечал символы при помощи VoTT, а затем извлекал их при помощи наспех написанного скрипта, который также выполнял их постобработку, чтобы на изображениях были только символы. В результате у меня получилось по 50-150 изолированных изображений для каждого символа. Именно на этом этапе проекта я осознал, что в капче 4Chan присутствуют только символы 0, 2, 4, A, D, G, H, K, M, N, P, S, X, Y. Вероятно, это сделано, чтобы избежать ошибочных трактовок.
Теперь достаточно объединить всё это. При извлечении цифр я наблюдал несколько паттернов группирования или распределения символов, поэтому написал скрипт для сборки изображений с фонами согласно этим формулам. И, разумеется, поскольку изображения символов на входе размечены, можно запросто сгенерировать синтетические капчи с их решениями.
Создаём модель
Теперь у нас есть данные, и настала пора обучать модель. Я собрал архитектуру модели на основе исследований, прочитав множество разных статей о распознавании капчи при помощи нейросетей. Я остановился на архитектуре свёрточной нейронной сети с долгой кратковременной памятью (LSTM CNN) с тремя свёрточными слоями/слоями max pooling и двумя слоями LSTM. Я протестировал и четвёртый свёрточный слой, но он не повысил точность. Использовалось CTC-кодирование текстов капч, потому что выходные данные имели переменную длину (или 5, или 6 символов). Я построил модель при помощи Keras поверх TensorFlow.
Обрабатываем данные
Входными данными модели стала смесь заранее выровненных капч со слайдером, обычных и синтетических капч. Скрипт обучения проверял, что они имеют размер ровно 300x80 пикселей, и преобразовывал изображения в чёрно-белые.
Всегда читайте документацию
Аргументы могут находиться не в том порядке, в каком вы ожидаете.
Один из важных шагов моего конвейера обработки данных заключался в проверке того, что изображения капч имеют размер ровно 300x80 пикселей. Некоторые изображения из датасета, а именно старые выровненные капчи со слайдером, не соответствуют этому разрешению/соотношению сторон. Можно просто исправить данные обучения, но в конечном итоге лучше, если скрипт обучения научится сам справляться с любыми переданными ему данными.
Я использовал для этого функцию tf.image.resize()
. Документация по ней была довольно простой, в моём случае достаточно было всего лишь передать тензор входного изображения и размер. Казалось бы, это должен быть кортеж (width, height)
? Я предположил так, и код работал нормально, поэтому я даже не стал больше об этом задумываться.
Но потом... Точность моей модели оказалась совершенно ужасной! Даже после обучения в течение 32 с лишним эпох модель едва справлялась со всеми изображениями, которые видела раньше, и вообще ничего не могла сделать с новыми для неё изображениями капч, выдавая практически случайные прогнозы. Что же происходит?
Я решил визуализировать изображения, которые передавал в модель, чтобы увидеть, как они выглядят. Возможно, неправильно настроены пороговые значения чёрного/белого? Я взял произвольное изображение из входных данных перед обработкой и, визуализировав его, получил... это:
Да, вероятно, вы уже ожидали этого, когда я сказал «это должен быть кортеж (width, height)
». Оказалось, что это не так. На самом деле, кортеж имеет вид (height, width)
! Если бы я потратил немного времени на прочтение всей страницы документации, то нашёл бы почти в самом низу подробности об ожидаемых аргументах. Я усвоил урок: нужно внимательно читать документацию при работе с незнакомыми библиотеками, даже если думаешь, что знаешь, как они работают, и в особенности когда что-то работает не так, как ожидалось.
После устранения бага точность обучения стала гораздо более многообещающей.
Обучаем модель
Готовый датасет состоял из примерно 500 распознанных вручную изображений и 50 тысяч синтетически сгенерированных изображений. Синтетические изображения генерировались на основании случайных сэмплов из 2,5 тысяч фоновых изображений и 50-150 изображений на каждый символ. Этот датасет случайным образом перемешивался, а затем сегментировался в соотношении 90/10 на датасеты обучения и проверки. На моём ноутбучном GPU NVIDIA RTX A4000 обучение занимало примерно по 45 секунд на эпоху.
В конце первой эпохи функция потерь выглядела не очень хорошо, её значение достигало 19. Во время фазы обратного вызова проверки прогнозы были далеки от корректности, обеспечивая лишь 1-2 спрогнозированных символов, не совпадающих с символами на изображении. Это вполне ожидаемо для ранних этапов обучения.
Более поздние эпохи существенно улучшили точность. К концу четвёртой эпохи функция потерь снизилась до 0,55, а прогнозы уже выглядели хорошо: на этом этапе пять из пяти прогнозов случайных тестов давали корректные результаты. На протяжении последующих эпох обучения потери стабильно снижались.
После экспериментов с разным количеством эпох оказалось, что 8-16 эпох — это хороший компромисс между временем и точностью готовой модели. Функция потерь стабилизировалась к восьмой эпохе, а увеличение количества эпох после 16 практически не давало никаких улучшений.
Я написал короткий тестовый скрипт (trainer/infer.py
) для создания инференсов решений капч на Python. Результаты для изображений, которые модель ещё не видела, были многообещающими, в ограниченном количестве проверенных тестовых случаев решения оказывались корректными.
Работаем с моделью в TensorFlow.js
Написать код TensorFlow.js для пользовательского скрипта было довольно просто. Для этой задачи я выбрал TypeScript. Я заново реализовал алгоритм выравнивания капчи из кода на Python, а также код предварительной обработки изображений. Весь этот код находится в папке user-scripts/
репозитория.
Форматы моделей Python TensorFlow/Keras несовместимы с форматом моделей, ожидаемым TensorFlow.js. Существует официальный скрипт для преобразования с инструкциями по использованию. По идее, всё должно быть просто...
Конвертер не работает в Python 3.12
Это была довольно простая проблема, но чтобы разобраться, мне понадобилось много времени. Официальный конвертер моделей TensorFlow-to-TFJS не работает в Python 3.12. Похоже, это не задокументировано, а сообщения об ошибках, выдаваемые, когда пытаешься запустить его в Python 3.12, неочевидны. Я наудачу попробовал более старую версию Python (3.10) при помощи PyEnv, и всё заработало идеально.
TensorFlow.js не поддерживает Keras 3
Новая проблема: скрипт преобразования поддерживает преобразование моделей Keras 3 в формат TensorFlow.js. А в чём тогда проблема? TensorFlow.js не поддерживает само чтение этих преобразованных моделей. Я узнал это из поста на форуме, сначала немного помучавшись с тем, что TFJS не читал модель, созданную официальным скриптом преобразования.
К счастью, решение оказалось простым: использовать Keras 2. Для этого достаточно обучить модель с заданной переменной окружения TF_USE_LEGACY_KERAS=1
после установки легаси-пакета tf_keras
. Это может потребовать изменений в коде. В моём случае достаточно было просто изменить одну строку. Кроме того, потребуется экспортировать модель в старом формате моделей .h5
и указать его в качестве входного формата при запуске скрипта преобразвания.
Точность модели в реальных условиях
Мы уже видели точность модели на датасете обучения, который в основном состоит из синтетических изображений. Но нас не волнует способность решения синтетических капч, самое главное — распознавание реальных.
Хорошие новости: модель отлично работает с реальной капчей 4Chan. Процесс решения происходит быстро, первоначальная загрузка модели занимает около секунды, а скорость последующего выполнения неразличима глазом. По моему опыту решения в браузере более сотни реальных капч, модель демонстрирует уровень успешности в 90%. Она редко распознаёт символы ошибочно: если она неточна, то обычно просто полностью пропускает символ. Вероятно, это можно улучшить благодаря увеличению обучающего датасета или, возможно, настройкой схем капч в генераторе синтетических датасетов.
Забавный факт: точность этой модели намного лучше, чем у описанного выше сервиса распознавания капч, где используется труд живых людей.
4-символьные капчи
Пока я писал и редактировал эту статью после завершения проекта, заметил, что 4Chan начал иногда показывать капчи всего из 4 символов вместо обычных 5 и 6. Несмотря на то, что модель обучалась только на 5- и 6-символьных капчах, её точность для 4-символьных капч такая же.
Заключение
Меня очень порадовал этот проект. Мне пришлось преодолеть многие сложности, и я много узнал о машинном обучении и computer vision. Разумеется, проект ещё можно улучшить, но пока я доволен результатами, потому что я достиг того, к чему стремился изначально.
Автор: PatientZero