Нейросетевой помощник для Catan Universe: как я научил ИИ считать карты соперников

от автора

Вступление или как я подсел на Catan

Привет, коллеги-катановцы!

Знакомо чувство, когда в пылу битвы за овец и кирпичи напрочь забываешь, сколько ресурсов только что сбросил соперник? Вот и я вечно путался — пока не загорелся безумной идеей: А что если заставить нейросеть следить за картами вместо меня?

Пару месяцев, несколько килограммов кофе и одна сгоревшая видеокарта спустя — представляю вам Catan Neural Assistant — шпаргалку, которая в реальном времени подсчитывает ресурсы оппонентов!

Но сначала — лирическое отступление для тех, кто вдруг не в теме.

Catan для чайников (и зачем это всё)

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

  • Дерево (wood)

  • Кирпич (brick)

  • Овца (sheep)

  • Пшено (wheat)

  • Камень (ore)

Онлайн версия игры из Steam (Рис.1)

Онлайн версия игры из Steam (Рис.1)

Механика получения ресурсов:

  1. В начале каждого хода происходит бросок двух игральных кубиков.

  2. Если ваши поселения или города расположены на гексах с выпавшим числом, вы автоматически получаете соответствующие ресурсные карты.

  3. В игровом интерфейсе этот процесс сопровождается визуальной анимацией.

Анимация получения карт (Рис.2)

Анимация получения карт (Рис.2)
Анимация сброса карт (Рис.3)

Анимация сброса карт (Рис.3)

Механика «Грабителя» и учет неизвестных ресурсов

Когда при броске кубиков выпадает сумма 7, срабатывает особый игровой механизм:

  1. Игрок получает право переместить фишку Грабителя (Robber) на любой гекс игрового поля.

  2. Можно выбрать одного из соперников, чьи поселения граничат с этим гексом, и взять у него один случайный ресурс.

Ключевая особенность:

  • Процесс передачи ресурса происходит скрыто от других участников.

  • Это создает информационную неопределенность, так как невозможно точно определить, какой именно ресурс был изъят.

Скрытый ресурс (Рис.4)

Скрытый ресурс (Рис.4)

Для корректного учета этой неопределенности в алгоритме был введен дополнительный класс ресурсов — Unknown. 

Именно из этих анимаций мы потом построим систему автоматического подсчета ресурсов, которые находятся на руках у наших соперников!

Техническая часть

Задача распознавания делится на 3 этапа:

  1. Первичная детекция анимации распределения ресурса.

  2. Определение числового значения внутри детектированного бокса — знак и число

  3. И наконец, нам нужно классифицировать, какой ресурс мы имеем в боксе (пшено, овца, камень, лес, кирпич или неизвестный).

Итак по порядку.

Для первичной детекции я сразу решил использовать самую популярую модель, предназначенную для таких задач — YOLOv11. На сайте Ultralytics доступны несколько архитектур с разным количеством параметров:

Модели YOLO с разными параметрами и их характеристики (Рис.5)

Модели YOLO с разными параметрами и их характеристики (Рис.5)

Ключевой момент тут в том, что при росте количества параметров модели сильно падает ее скорость обработки потокового видео (FPS). Скорость для нас тут очень важна, т.к. детектируемый объект появляется на экране буквально на доли секунды и очень быстро исчезает. За это ограниченное время необходимо получить bounding box с очень качественной картинкой и mAP.

Один из спобосов ускорения работы YOLO — ограничение detection zone, в коде представленном ниже я отсекаю от монитора пятую часть сверху:

monitor = get_monitors()[0]  # Index 0 is all monitors, 1 is primary screen_width = monitor.width screen_height = monitor.height  one_fifth_hight = screen_height // 5 selected_region = {"top": 0,                     "left": 0,                     "width": screen_width,                     "height": one_fifth_hight}

В дальнейшем лишь этот кусочек будет передан на вход модели (переменная selected_region):

screen = np.array(sct.grab(selected_region)) frame = cv2.cvtColor(screen, cv2.COLOR_BGRA2BGR) results_track = model_yolo.track(frame, persist=True)

Что касается самой модели — я вручную набрал и разметил дата сет и попробовал обучить все 5 видов YOLO11. Золотой серединой оказалась модель YOLO11m — в сочетани с ограничением selected_region она дает почти идеальный mAP и все еще достаточно высокую скорость обработки:

Детекция анимации получения/скидывания карт ресурсов (Рис.6)

Детекция анимации получения/скидывания карт ресурсов (Рис.6)

На рис.6 видно, что стандартный YOLO трекер не различает id разных детекций — модель все 6 скиданных карт ресурсов приняла за 16-ый объект. В принципе это очень серьезная проблема, я перепробовал разные трекеры с разными настройками — botsort, bytrack, но качество оставляло желать лучшего, поэтому пришлось делать свою логику трекинга объектов, которую я опишу чуть позже.

Мы сдетектировали первичную анимацию и теперь нам нужно понять, что же находится внутри bounding box:

Результаты работы YOLO11m - bounding box Рис.(7)

Результаты работы YOLO11m — bounding box Рис.(7)
Результаты работы YOLO11m - bounding box Рис.(8)

Результаты работы YOLO11m — bounding box Рис.(8)

А внутри мы имеем по сути 3 объекта — знак, число ресурса и изображение этого самого ресурса.

Начнем с извлечения знака и числа. Много копий я сломал чтобы получить тут высокую точность. Сначала я подумал извлекать цветовой threshhold и распознавать число с помощью Resnet, обученный на датасете MNIST. Потом пытался распознать текст из лога игры, используя технологию OCR (object character recognition). Все эти идеи хоть и работали, но не давали достаточную точность — во время игры то и дело проиходили ошибки и опираться на такой помощник было бесполезно.

Прорыв случился когда я попрбовал использовать YOLO в режиме сегментации (модель yolo11n-seg). Тут я использовал еще одну забавную идею, которая дала очень сильный прирост качества — если посмотреть на приход и расход карт, то видно, что приход всегда стабильно окрашивается в зеленый цвет, а расход в красный (рис.7 и рис.8). А что если инвертировать красный и зеленый слои в RGB тензоре у картинки? Получим следующее:

Замена местами красного и зеленого слоя RGB (Рис.9)

Замена местами красного и зеленого слоя RGB (Рис.9)
blue, green, red = cv2.split(box_img) inverted_img = cv2.merge([blue, red, green]) segmentation_result = model_yolo_segmentation(inverted_img, conf = 0.7, iou=0.45)

На первый взгляд действие кажется бессмысленным, однако не стоит спешить с выводом. Положитетельные и отрицательные числа теперь становятся почти что одного цвета, а значит, нам не надо делить их в разные классы.

Теперь при сегментации картинки классов у нас становится в 2 раза меньше, что очень сильно повысило точность модели — ведь теперь весь дата сет делится не на 22 класса,а на 12!

Сегментация на классы (Рис.10)

Сегментация на классы (Рис.10)

Итого получается, что классов всего 12 — цифры от 0 до 9 и два знака — «плюс» и «минус»

С этим разобрались, теперь к классификации ресурса на картинке. В целом тут ничего экзотического, изображение ресурса всегда находится на правой половине bounding box, поэтому логично его отсечь:

_, width, _ = box_img.shape width_cutoff = int(width / 2) cropped_box_img = box_img[:, width_cutoff:]

Здесь мудрить нечего, я взял предобученную Resnet34 со всеми замороженными слоями, кроме последнего, инициализировал еще один full connected layer на выходе модели:

def create_blank_model(device, freeze_layers = True):   model = models.resnet34(pretrained=True)   if freeze_layers:       for param in model.parameters():               param.requires_grad = False      model.fc = torch.nn.Linear(model.fc.in_features, 6)          return model

Сделал нормализацию исходных картинок согласно рекомендациям разработчиков Resnet:

pred_transforms = transforms.Compose([                 transforms.Resize((224, 224)),                 transforms.ToTensor(),                 transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])                 ])   

И подобрал приемлемые параметры:

learning_rate =1.0e-3 step_size=7 gamma=0.1 model_name = 'Resnet34' model_freezed = 'except last layer' num_of_epochs = 60 num_of_augmentaions = 1 optimizer_name = 'Adam'

Resnet34 полностью сошлась к 15 эпохе, выдав 100% точность на тренировочном и валидационном датасете:

Обучение Resnet34 (Рис.11)

Обучение Resnet34 (Рис.11)

Ну и осталась последняя проблема, которую я поднимал ранее — как разделять одинаковые детекции друг от друга? Как мы уже выяснили, типовые трекеры плохо справляются с этой задачей.

Я реализовал следующую логику:

Для каждого игрока я запоминаю предыдущие значения детекции (знак, число и ресурс и текущее время) в отдельные свойства класса Catan_player, а так же вношу параметр дельты времени:

delay_delta = datetime.timedelta(seconds=0.5)  class Catan_player():      def __init__(self, player_number):       self.previous_detection_time = datetime.datetime.now()       self.previous_resource_count = ''       self.previous_recource_type = ''       self.previous_sign_result = ''

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

Заключение

Сразу хотел бы ответить потенциальным обвинениям в жульничестве.

Во-первых, мы играем в Catan не на корову, никакого коммерческого эффекта я от этого не получаю.

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

Ну а в-третьих, у нас все готово и я предлагаю вам посмотеть мою реальную партию в качестве демонстрации того, что получилось!

Спасибо за внимание!


ссылка на оригинал статьи https://habr.com/ru/articles/935054/