Вступление или как я подсел на Catan
Привет, коллеги-катановцы!
Знакомо чувство, когда в пылу битвы за овец и кирпичи напрочь забываешь, сколько ресурсов только что сбросил соперник? Вот и я вечно путался — пока не загорелся безумной идеей: А что если заставить нейросеть следить за картами вместо меня?
Пару месяцев, несколько килограммов кофе и одна сгоревшая видеокарта спустя — представляю вам Catan Neural Assistant — шпаргалку, которая в реальном времени подсчитывает ресурсы оппонентов!
Но сначала — лирическое отступление для тех, кто вдруг не в теме.
Catan для чайников (и зачем это всё)
В верхней части игрового экрана расположены аватары участников: ваш персонаж и три оппонента. В нижней секции отображается текущее количество ресурсов, которые разделены на пять категорий:
Дерево (wood)
Кирпич (brick)
Овца (sheep)
Пшено (wheat)
Камень (ore)

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


Механика "Грабителя" и учет неизвестных ресурсов
Когда при броске кубиков выпадает сумма 7, срабатывает особый игровой механизм:
Игрок получает право переместить фишку Грабителя (Robber) на любой гекс игрового поля.
Можно выбрать одного из соперников, чьи поселения граничат с этим гексом, и взять у него один случайный ресурс.
Ключевая особенность:
Процесс передачи ресурса происходит скрыто от других участников.
Это создает информационную неопределенность, так как невозможно точно определить, какой именно ресурс был изъят.

Для корректного учета этой неопределенности в алгоритме был введен дополнительный класс ресурсов - Unknown.
Именно из этих анимаций мы потом построим систему автоматического подсчета ресурсов, которые находятся на руках у наших соперников!
Техническая часть
Задача распознавания делится на 3 этапа:
Первичная детекция анимации распределения ресурса.
Определение числового значения внутри детектированного бокса - знак и число
И наконец, нам нужно классифицировать, какой ресурс мы имеем в боксе (пшено, овца, камень, лес, кирпич или неизвестный).
Итак по порядку.
Для первичной детекции я сразу решил использовать самую популярую модель, предназначенную для таких задач - YOLOv11. На сайте Ultralytics доступны несколько архитектур с разным количеством параметров:

Ключевой момент тут в том, что при росте количества параметров модели сильно падает ее скорость обработки потокового видео (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 видно, что стандартный YOLO трекер не различает id разных детекций - модель все 6 скиданных карт ресурсов приняла за 16-ый объект. В принципе это очень серьезная проблема, я перепробовал разные трекеры с разными настройками - botsort, bytrack, но качество оставляло желать лучшего, поэтому пришлось делать свою логику трекинга объектов, которую я опишу чуть позже.
Мы сдетектировали первичную анимацию и теперь нам нужно понять, что же находится внутри bounding box:


А внутри мы имеем по сути 3 объекта - знак, число ресурса и изображение этого самого ресурса.
Начнем с извлечения знака и числа. Много копий я сломал чтобы получить тут высокую точность. Сначала я подумал извлекать цветовой threshhold и распознавать число с помощью Resnet, обученный на датасете MNIST. Потом пытался распознать текст из лога игры, используя технологию OCR (object character recognition). Все эти идеи хоть и работали, но не давали достаточную точность - во время игры то и дело проиходили ошибки и опираться на такой помощник было бесполезно.
Прорыв случился когда я попрбовал использовать YOLO в режиме сегментации (модель yolo11n-seg). Тут я использовал еще одну забавную идею, которая дала очень сильный прирост качества - если посмотреть на приход и расход карт, то видно, что приход всегда стабильно окрашивается в зеленый цвет, а расход в красный (рис.7 и рис.8). А что если инвертировать красный и зеленый слои в RGB тензоре у картинки? Получим следующее:

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!

Итого получается, что классов всего 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% точность на тренировочном и валидационном датасете:

Ну и осталась последняя проблема, которую я поднимал ранее - как разделять одинаковые детекции друг от друга? Как мы уже выяснили, типовые трекеры плохо справляются с этой задачей.
Я реализовал следующую логику:
Для каждого игрока я запоминаю предыдущие значения детекции (знак, число и ресурс и текущее время) в отдельные свойства класса 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 не на корову, никакого коммерческого эффекта я от этого не получаю.
Во-вторых - все вышеперечисленное можно реализовать простым листком бумаги и ручкой, записывая все приходы и уходы карт своих соперников прямо с монитора. В первую очередь я преследовал тут инженерный интерес и азарт от решения непростой задачки по компьютерному зрению.
Ну а в-третьих, у нас все готово и я предлагаю вам посмотеть мою реальную партию в качестве демонстрации того, что получилось!
Спасибо за внимание!