Как стать автором
Поиск
Написать публикацию
Обновить

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

Уровень сложностиСредний
Время на прочтение6 мин
Количество просмотров687

Вступление или как я подсел на 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 не на корову, никакого коммерческого эффекта я от этого не получаю.

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

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

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

Теги:
Хабы:
+2
Комментарии2

Публикации

Ближайшие события