Вступление или как я подсел на 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 не на корову, никакого коммерческого эффекта я от этого не получаю.
Во-вторых — все вышеперечисленное можно реализовать простым листком бумаги и ручкой, записывая все приходы и уходы карт своих соперников прямо с монитора. В первую очередь я преследовал тут инженерный интерес и азарт от решения непростой задачки по компьютерному зрению.
Ну а в-третьих, у нас все готово и я предлагаю вам посмотеть мою реальную партию в качестве демонстрации того, что получилось!
Спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/articles/935054/
Добавить комментарий