Русский
Русский
English
Статистика
Реклама

Из песочницы Пишем бот для пазл игры на Python

Давно хотел попробовать свои силы в компьютерном зрении и вот этот момент настал. Интереснее обучаться на играх, поэтому тренироваться будем на боте. В статье я попытаюсь подробно расписать процесс автоматизации игры при помощи связки Python + OpenCV.

image


Ищем цель


Идем на тематический сайт miniclip.com и ищем цель. Выбор пал на цветовую головоломку Coloruid 2 раздела Puzzles, в которой нам необходимо заполнить круглое игровое поле одним цветом за заданное количество ходов.

Выбранным внизу экрана цветом заливается произвольная область, при этом соседние области одного цвета сливаются в единую.

image

Подготовка


Использовать будем Python. Бот создан исключительно в образовательных целях. Статья рассчитана на новичков в компьютером зрении, каким я сам и являюсь.

Игра находится тут
GitHub бота тут

Для работы бота нам понадобятся следующие модули:

  • opencv-python
  • Pillow
  • selenium

Бот написан и протестирован для версии Python 3.8 на Ubuntu 20.04.1. Устанавливаем необходимые модули в ваше виртуальное окружение или через pip install. Дополнительно для работы Selenium нам понадобится geckodriver для FireFox, скачать можно тут github.com/mozilla/geckodriver/releases

Управление браузером


Мы имеем дело с онлайн-игрой, поэтому для начала организуем взаимодействие с браузером. Для этой цели будем использовать Selenium, который предоставит нам API для управления FireFox. Изучаем код страницы игры. Пазл представляет из себя canvas, которая в свою очередь располагается в iframe.

Ожидаем загрузки фрейма с id = iframe-game и переключаем контекст драйвера на него. Затем ждем canvas. Она единственная во фрейме и доступна по XPath /html/body/canvas.

wait(self.__driver, 20).until(EC.frame_to_be_available_and_switch_to_it((By.ID, "iframe-game")))self.__canvas = wait(self.__driver, 20).until(EC.visibility_of_element_located((By.XPATH, "/html/body/canvas")))

Далее наша канва будет доступна через свойство self.__canvas. Вся логика работы с браузером сводится к получению скриншота canvas и клику по ней в заданной координате.

Полный код Browser.py:

from selenium import webdriverfrom selenium.webdriver.support import expected_conditions as ECfrom selenium.webdriver.support.ui import WebDriverWait as waitfrom selenium.webdriver.common.by import Byclass Browser:    def __init__(self, game_url):        self.__driver = webdriver.Firefox()        self.__driver.get(game_url)        wait(self.__driver, 20).until(EC.frame_to_be_available_and_switch_to_it((By.ID, "iframe-game")))        self.__canvas = wait(self.__driver, 20).until(EC.visibility_of_element_located((By.XPATH, "/html/body/canvas")))    def screenshot(self):        return self.__canvas.screenshot_as_png    def quit(self):        self.__driver.quit()    def click(self, click_point):        action = webdriver.common.action_chains.ActionChains(self.__driver)        action.move_to_element_with_offset(self.__canvas, click_point[0], click_point[1]).click().perform()

Состояния игры


Приступим к самой игре. Вся логика бота будет реализована в классе Robot. Разделим игровой процесс на 7 состояний и назначим им методы для их обработки. Выделим отдельно обучающий уровень. Он содержит большой белый курсор, указывающий куда нажимать, который не позволит корректно распознавать элементы игры.

  • Приветственный экран
  • Экран выбора уровня
  • Выбор цвета на обучающем уровне
  • Выбор области на обучающем уровне
  • Выбор цвета
  • Выбор области
  • Результат хода

class Robot:    STATE_START = 0x01    STATE_SELECT_LEVEL = 0x02    STATE_TRAINING_SELECT_COLOR = 0x03    STATE_TRAINING_SELECT_AREA = 0x04    STATE_GAME_SELECT_COLOR = 0x05    STATE_GAME_SELECT_AREA = 0x06    STATE_GAME_RESULT = 0x07    def __init__(self):        self.states = {            self.STATE_START: self.state_start,            self.STATE_SELECT_LEVEL: self.state_select_level,            self.STATE_TRAINING_SELECT_COLOR: self.state_training_select_color,            self.STATE_TRAINING_SELECT_AREA: self.state_training_select_area,            self.STATE_GAME_RESULT: self.state_game_result,            self.STATE_GAME_SELECT_COLOR: self.state_game_select_color,            self.STATE_GAME_SELECT_AREA: self.state_game_select_area,        }

Для большей стабильности бота будем проверять, успешно ли произошла смена игрового состояния. Если self.state_next_success_condition не вернет True за время self.state_timeout продолжаем обрабатывать текущее состояние, иначе переключаемся на self.state_next. Также переведем скриншот, полученный от Selenium, в понятный для OpenCV формат.

import timeimport cv2import numpyfrom PIL import Imagefrom io import BytesIOclass Robot:    def __init__(self):# self.screenshot = []        self.state_next_success_condition = None          self.state_start_time = 0          self.state_timeout = 0         self.state_current = 0         self.state_next = 0      def run(self, screenshot):        self.screenshot = cv2.cvtColor(numpy.array(Image.open(BytesIO(screenshot))), cv2.COLOR_BGR2RGB)        if self.state_current != self.state_next:            if self.state_next_success_condition():                self.set_state_current()            elif time.time() - self.state_start_time >= self.state_timeout                    self.state_next = self.state_current            return False        else:            try:                return self.states[self.state_current]()            except KeyError:                self.__del__()    def set_state_current(self):        self.state_current = self.state_next    def set_state_next(self, state_next, state_next_success_condition, state_timeout):        self.state_next_success_condition = state_next_success_condition        self.state_start_time = time.time()        self.state_timeout = state_timeout        self.state_next = state_next

Реализуем проверку в методах обработки состояний. Ждем кнопку Play на стартовом экране и кликаем по ней. Если в течении 10 секунд мы не получили экран выбора уровней, возвращаемся к предыдущему этапу self.STATE_START, иначе переходим к обработке self.STATE_SELECT_LEVEL.

# class Robot:   DEFAULT_STATE_TIMEOUT = 10      #     def state_start(self):        # пытаемся получить координату кнопки Play        #         if button_play is False:            return False        self.set_state_next(self.STATE_SELECT_LEVEL, self.state_select_level_condition, self.DEFAULT_STATE_TIMEOUT)        return button_play    def state_select_level_condition(self):        # содержит ли скриншот выбор уровней# 

Зрение бота


Пороговая обработка изображения


Определим цвета, которые используются в игре. Это 5 игровых цветов и цвет курсора на учебном уровне. COLOR_ALL будем использовать, если нужно найти все объекты, независимо от цвета. Для начала этот случай мы и рассмотрим.

    COLOR_BLUE = 0x01      COLOR_ORANGE = 0x02    COLOR_RED = 0x03    COLOR_GREEN = 0x04    COLOR_YELLOW = 0x05    COLOR_WHITE = 0x06    COLOR_ALL = 0x07

Для поиска объекта в первую очередь необходимо упростить изображение. Для примера возьмем символ 0 и применим к нему пороговую обработку, то есть отделим объект от фона. На этом этапе нам не важно, какого цвета символ. Для начала переведем изображение в черно-белое, сделав его 1-канальным. В этом нам поможет функция cv2.cvtColor со вторым аргументом cv2.COLOR_BGR2GRAY, который отвечает за перевод в градации серого. Далее производим пороговую обработку при помощи cv2.threshold. Все пиксели изображения ниже определенного порога устанавливаются в 0, все, что выше, в 255. За значение порога отвечает второй аргумент функции cv2.threshold. В нашем случае там может стоят любое число, так как мы используем cv2.THRESH_OTSU и функция сама определит оптимальный порог по методу Оцу на основе гистограммы изображения.

image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)_, thresh = cv2.threshold(image, 0, 255, cv2.THRESH_OTSU)

image

Цветовая сегментация


Дальше интереснее. Усложним задачу и найдем все символы красного цвета на экране выбора уровней.

image

По умолчанию, все изображения OpenCV хранит в формате BGR. Для цветовой сегментации больше подходит HSV (Hue, Saturation, Value тон, насыщенность, значение). Ее преимущество перед RGB заключается в том, что HSV отделяет цвет от его насыщенности и яркости. Цветовой тон кодируется одним каналом Hue. Возьмем для примера салатовый прямоугольник и будем постепенно уменьшать его яркость.

image

В отличии от RGB, в HSV данное преобразование выглядит интуитивно мы просто уменьшаем значение канала Value или Brightness. Тут стоит обратить внимание на то, что в эталонной модели шкала оттенков Hue варьируется в диапазоне 0-360. Наш салатовый цвет соответствует 90. Для того, чтобы уместить это значение в 8 битный канал, его следует разделить на 2.
Сегментация цветов работает с диапазонами, а не с одним цветом. Определить диапазон можно опытным путем, но проще написать небольшой скрипт.

import cv2import numpy as numpyimage_path = "tests_data/SELECT_LEVEL.png"hsv_max_upper = 0, 0, 0hsv_min_lower = 255, 255, 255def bite_range(value):    value = 255 if value > 255 else value    return 0 if value < 0 else valuedef pick_color(event, x, y, flags, param):    if event == cv2.EVENT_LBUTTONDOWN:        global hsv_max_upper        global hsv_min_lower        global image_hsv        hsv_pixel = image_hsv[y, x]        hsv_max_upper = bite_range(max(hsv_max_upper[0], hsv_pixel[0]) + 1), \                        bite_range(max(hsv_max_upper[1], hsv_pixel[1]) + 1), \                        bite_range(max(hsv_max_upper[2], hsv_pixel[2]) + 1)        hsv_min_lower = bite_range(min(hsv_min_lower[0], hsv_pixel[0]) - 1), \                        bite_range(min(hsv_min_lower[1], hsv_pixel[1]) - 1), \                        bite_range(min(hsv_min_lower[2], hsv_pixel[2]) - 1)        print('HSV range: ', (hsv_min_lower, hsv_max_upper))        hsv_mask = cv2.inRange(image_hsv, numpy.array(hsv_min_lower), numpy.array(hsv_max_upper))        cv2.imshow("HSV Mask", hsv_mask)image = cv2.imread(image_path)cv2.namedWindow('Original')cv2.setMouseCallback('Original', pick_color)image_hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)cv2.imshow("Original", image)cv2.waitKey(0)cv2.destroyAllWindows()

Запустим его с нашим скриншотом.

image

Кликаем по красному цвету и смотрим на полученную маску. Если вывод нас не устраивает выбираем оттенкам красного, увеличивая диапазон и площадь маски. Работа скрипта основана на функции cv2.inRange, которая работает как цветовой фильтр и возвращает пороговое изображение для заданного цветового диапазона.
Остановимся на следующих диапазонах:

    COLOR_HSV_RANGE = {   COLOR_BLUE: ((112, 151, 216), (128, 167, 255)),   COLOR_ORANGE: ((8, 251, 93), (14, 255, 255)),   COLOR_RED: ((167, 252, 223), (171, 255, 255)),   COLOR_GREEN: ((71, 251, 98), (77, 255, 211)),   COLOR_YELLOW: ((27, 252, 51), (33, 255, 211)),   COLOR_WHITE: ((0, 0, 159), (7, 7, 255)),}

Поиск контуров


Вернемся к нашему экрану выбора уровней. Применим цветовой фильтр красного диапазона, который мы только что определили, и передадим найденный порог в cv2.findContours. Функция найдет нам контуры красных элементов. Укажем вторым аргументом cv2.RETR_EXTERNAL нам нужны только внешние контуры, и третьим cv2.CHAIN_APPROX_SIMPLE нас интересуют прямые контуры, экономим память и храним только их вершины.

thresh = cv2.inRange(image, self.COLOR_HSV_RANGE[self.COLOR_RED][0], self.COLOR_HSV_RANGE[self.COLOR_RED][1])contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE

image

Удаление шума


Полученные контуры содержат много шума от фона. Чтобы убрать его воспользуемся свойством наших цифр. Они состоят из прямоугольников, которые параллельны осям координат. Перебираем все контуры и вписываем каждый в минимальный прямоугольник при помощи cv2.minAreaRect. Прямоугольник определяется 4 точками. Если наш прямоугольник параллелен осям, то одна из координат для каждой пары точек должны совпадать. Значит у нас будет максимум 4 уникальных значения, если представить координаты прямоугольника как одномерный массив. Дополнительно отфильтруем слишком длинные прямоугольники, где соотношение сторон больше, чем 3 к 1. Для этого найдем их ширину и длину при помощи cv2.boundingRect.

squares = []        for cnt in contours:            rect = cv2.minAreaRect(cnt)            square = cv2.boxPoints(rect)            square = numpy.int0(square)            (_, _, w, h) = cv2.boundingRect(square)            a = max(w, h)            b = min(w, h)            if numpy.unique(square).shape[0] <= 4 and a <= b * 3:                squares.append(numpy.array([[square[0]], [square[1]], [square[2]], [square[3]]]))

image

Объединение контуров


Уже лучше. Теперь нам нужно объединить найденные прямоугольники в общий контур символов. Нам понадобится промежуточное изображение. Создадим его при помощи numpy.zeros_like. Функция создает копию матрицы image с сохранением ее формы и размера, затем заполняет ее нулями. Другими словами, мы получили копию нашего оригинального изображения, залитую черным фоном. Переводим его в 1-канальное и наносим найденные контуры при помощи cv2.drawContours, заполнив их белым цветом. Получаем бинарный порог, к которому можно применить cv2.dilate. Функция расширяет белую область, соединяя отдельные прямоугольники, расстояние между которыми в пределах 5 пикселей. Еще раз вызываем cv2.findContours и получаем контуры красных цифр.

        image_zero = numpy.zeros_like(image)        image_zero = cv2.cvtColor(image_zero, cv2.COLOR_BGR2RGB)        cv2.drawContours(image_zero, contours_of_squares, -1, (255, 255, 255), -1)  _, thresh = cv2.threshold(image_zero, 0, 255, cv2.THRESH_OTSU)  kernel = numpy.ones((5, 5), numpy.uint8)        thresh = cv2.dilate(thresh, kernel, iterations=1)        dilate_contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

image

Оставшийся шум отфильтруем по площади контуров при помощи cv2.contourArea. Убираем все, что занимает меньше 500 пикселей.

digit_contours = [cnt for cnt in digit_contours if cv2.contourArea(cnt) > 500]

image

Вот теперь отлично. Реализуем все вышеописанное в нашем классе Robot.

# ...class Robot:         # ...        def get_dilate_contours(self, image, color_inx, distance):        thresh = self.get_color_thresh(image, color_inx)        if thresh is False:            return []        kernel = numpy.ones((distance, distance), numpy.uint8)        thresh = cv2.dilate(thresh, kernel, iterations=1)        contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)        return contours    def get_color_thresh(self, image, color_inx):        if color_inx == self.COLOR_ALL:            image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)            _, thresh = cv2.threshold(image, 0, 255, cv2.THRESH_OTSU)        else:            image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)            thresh = cv2.inRange(image, self.COLOR_HSV_RANGE[color_inx][0], self.COLOR_HSV_RANGE[color_inx][1])        return threshdef filter_contours_of_rectangles(self, contours):        squares = []        for cnt in contours:            rect = cv2.minAreaRect(cnt)            square = cv2.boxPoints(rect)            square = numpy.int0(square)            (_, _, w, h) = cv2.boundingRect(square)            a = max(w, h)            b = min(w, h)            if numpy.unique(square).shape[0] <= 4 and a <= b * 3:                squares.append(numpy.array([[square[0]], [square[1]], [square[2]], [square[3]]]))        return squares    def get_contours_of_squares(self, image, color_inx, square_inx):        thresh = self.get_color_thresh(image, color_inx)        if thresh is False:            return False        contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)        contours_of_squares = self.filter_contours_of_rectangles(contours)        if len(contours_of_squares) < 1:            return False        image_zero = numpy.zeros_like(image)        image_zero = cv2.cvtColor(image_zero, cv2.COLOR_BGR2RGB)        cv2.drawContours(image_zero, contours_of_squares, -1, (255, 255, 255), -1)        dilate_contours = self.get_dilate_contours(image_zero, self.COLOR_ALL, 5)        dilate_contours = [cnt for cnt in dilate_contours if cv2.contourArea(cnt) > 500]        if len(dilate_contours) < 1:            return False        else:            return dilate_contours

Распознание цифр


Добавим возможность распознания цифр. Зачем нам это нужно? Потому что мы можем . Данная возможность не является обязательной для работы бота и при желании ее можно смело вырезать. Но так как мы обучаемся, добавим ее для подсчета набранных очков и для понимания бота, на каком он шаге на уровне. Зная завершающий ход уровня, бот будет искать кнопку перехода на следующий или повтор текущего. Иначе пришлось бы осуществлять их поиск после каждого хода. Откажемся от использования Tesseract и реализуем все средствами OpenCV. Распознание цифр будет построено на сравнении hu моментов, что позволит нам сканировать символы в разном масштабе. Это важно, так как в интерфейсе игры есть разные размеры шрифта. Текущий, где мы выбираем уровень, определим SQUARE_BIG_SYMBOL: 9, где 9 средняя сторона квадрата в пикселях, из которых состоит цифра. Кадрируем изображения цифр и сохраним их в папке data. В словаре self.dilate_contours_bi_data у нас содержатся эталоны контуров, с которым будет происходить сравнение. Индексом будет название файла без расширения (например digit_0).

# class Robot:    # ...    SQUARE_BIG_SYMBOL = 0x01    SQUARE_SIZES = {        SQUARE_BIG_SYMBOL: 9,      }    IMAGE_DATA_PATH = "data/"     def __init__(self):        # ...        self.dilate_contours_bi_data = {}         for image_file in os.listdir(self.IMAGE_DATA_PATH):            image = cv2.imread(self.IMAGE_DATA_PATH + image_file)            contour_inx = os.path.splitext(image_file)[0]            color_inx = self.COLOR_RED            dilate_contours = self.get_dilate_contours_by_square_inx(image, color_inx, self.SQUARE_BIG_SYMBOL)            self.dilate_contours_bi_data[contour_inx] = dilate_contours[0]    def get_dilate_contours_by_square_inx(self, image, color_inx, square_inx):        distance = math.ceil(self.SQUARE_SIZES[square_inx] / 2)        return self.get_dilate_contours(image, color_inx, distance)

В OpenCV для сравнения контуров на основе Hu моментов используется функция cv2.matchShapes. Она скрывает от нас детали реализации, принимая на вход два контура и возвращает результат сравнения в виде числа. Чем оно меньше, тем более схожими являются контуры.

cv2.matchShapes(dilate_contour, self.dilate_contours_bi_data['digit_' + str(digit)], cv2.CONTOURS_MATCH_I1, 0)

Сравниваем текущий контур digit_contour со всеми эталонами и находим минимальное значение cv2.matchShapes. Если минимальное значение меньше 0.15, цифра считается распознанной. Порог минимального значения найден опытным путем. Также объединим близко расположенные символы в одно число.

# class Robot:    #     def scan_digits(self, image, color_inx, square_inx):        result = []        contours_of_squares = self.get_contours_of_squares(image, color_inx, square_inx)        before_digit_x, before_digit_y = (-100, -100)        if contours_of_squares is False:            return result        for contour_of_square in reversed(contours_of_squares):            crop_image = self.crop_image_by_contour(image, contour_of_square)            dilate_contours = self.get_dilate_contours_by_square_inx(crop_image, self.COLOR_ALL, square_inx)            if (len(dilate_contours) < 1):                continue            dilate_contour = dilate_contours[0]            match_shapes = {}            for digit in range(0, 10):                match_shapes[digit] = cv2.matchShapes(dilate_contour, self.dilate_contours_bi_data['digit_' + str(digit)], cv2.CONTOURS_MATCH_I1, 0)            min_match_shape = min(match_shapes.items(), key=lambda x: x[1])            if len(min_match_shape) > 0 and (min_match_shape[1] < self.MAX_MATCH_SHAPES_DIGITS):                digit = min_match_shape[0]                rect = cv2.minAreaRect(contour_of_square)                box = cv2.boxPoints(rect)                box = numpy.int0(box)                (digit_x, digit_y, digit_w, digit_h) = cv2.boundingRect(box)                if abs(digit_y - before_digit_y) < digit_y * 0.3 and abs(                        digit_x - before_digit_x) < digit_w + digit_w * 0.5:                    result[len(result) - 1][0] = int(str(result[len(result) - 1][0]) + str(digit))                else:                    result.append([digit, self.get_contour_centroid(contour_of_square)])                before_digit_x, before_digit_y = digit_x + (digit_w / 2), digit_y        return result


На выходе метод self.scan_digits выдаст массив, содержащий распознанную цифру и координату клика по ней. Точкой клика будет центроид ее контура.

# class Robot:    # def get_contour_centroid(self, contour):        moments = cv2.moments(contour)        return int(moments["m10"] / moments["m00"]), int(moments["m01"] / moments["m00"])

Радуемся полученной распознавалке цифр, но не долго. Hu моменты помимо масштаба инвариантны также к повороту и зеркальности. Следовательно бот будет путать цифры 6 и 9 / 2 и 5. Добавим дополнительную проверку этих символов по вершинам. 6 и 9 будем отличать по правой верхней точке. Если она ниже горизонтального центра, значит это 6 и 9 для обратного. Для пары 2 и 5 проверяем, лежит ли верхняя правая точка на правой границе символа.

if digit == 6 or digit == 9:    extreme_bottom_point = digit_contour[digit_contour[:, :, 1].argmax()].flatten()    x_points = digit_contour[:, :, 0].flatten()    extreme_right_points_args = numpy.argwhere(x_points == numpy.amax(x_points))    extreme_right_points = digit_contour[extreme_right_points_args]    extreme_top_right_point = extreme_right_points[extreme_right_points[:, :, :, 1].argmin()].flatten()    if extreme_top_right_point[1] > round(extreme_bottom_point[1] / 2):        digit = 6    else:        digit = 9if digit == 2 or digit == 5:    extreme_right_point = digit_contour[digit_contour[:, :, 0].argmax()].flatten()    y_points = digit_contour[:, :, 1].flatten()    extreme_top_points_args = numpy.argwhere(y_points == numpy.amin(y_points))    extreme_top_points = digit_contour[extreme_top_points_args]    extreme_top_right_point = extreme_top_points[extreme_top_points[:, :, :, 0].argmax()].flatten()    if abs(extreme_right_point[0] - extreme_top_right_point[0]) > 0.05 * extreme_right_point[0]:        digit = 2    else:        digit = 5

image

image

Анализируем игровое поле


Пропустим тренировочный уровень, он заскриптован по клику на белом курсоре и приступаем к игре.

Представим игровое поле как сеть. Каждая область цвета будет узлом, который связан с граничащими рядом соседями. Создадим класс self.ColorArea, который будет описывать область цвета/узел.

class ColorArea:         def __init__(self, color_inx, click_point, contour):            self.color_inx = color_inx  # индекс цвета            self.click_point = click_point  # клик поинт области            self.contour = contour  # контур области            self.neighbors = []  # индексы соседей

Определим список узлов self.color_areas и список того, как часто встречается цвет на игровом поле self.color_areas_color_count. Кадрируем игровое поле из скриншота канвы.

image[pt1[1]:pt2[1], pt1[0]:pt2[0]]

Где pt1, pt2 крайние точки кадра. Перебираем все цвета игры и применяем к каждому метод self.get_dilate_contours. Нахождение контура узла аналогично тому, как мы искали общий контур символов, с тем отличием, что на игровом поле отсутствуют шумы. Форма узлов может быть вогнутой или иметь отверстие, поэтому центроид будет выпадать за пределы фигуры и не подходит в качестве координата для клика. Для этого найдем экстремальную верхнюю точку и опустимся на 20 пикселей. Способ не универсальный, но в нашем случае рабочий.

        self.color_areas = []        self.color_areas_color_count = [0] * self.SELECT_COLOR_COUNT        image = self.crop_image_by_rectangle(self.screenshot, numpy.array(self.GAME_MAIN_AREA))        for color_inx in range(1, self.SELECT_COLOR_COUNT + 1):            dilate_contours = self.get_dilate_contours(image, color_inx, 10)            for dilate_contour in dilate_contours:                click_point = tuple(                    dilate_contour[dilate_contour[:, :, 1].argmin()].flatten() + [0, int(self.CLICK_AREA)])                self.color_areas_color_count[color_inx - 1] += 1                color_area = self.ColorArea(color_inx, click_point, dilate_contour)                self.color_areas.append(color_area)

image

Связываем области


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

        blank_image = numpy.zeros_like(image)        blank_image = cv2.cvtColor(blank_image, cv2.COLOR_BGR2GRAY)        for color_area_inx_1 in range(0, len(self.color_areas)):            for color_area_inx_2 in range(color_area_inx_1 + 1, len(self.color_areas)):                color_area_1 = self.color_areas[color_area_inx_1]                color_area_2 = self.color_areas[color_area_inx_2]                if color_area_1.color_inx == color_area_2.color_inx:                    continue                common_image = cv2.drawContours(blank_image.copy(), [color_area_1.contour, color_area_2.contour], -1, (255, 255, 255), cv2.FILLED)                kernel = numpy.ones((15, 15), numpy.uint8)                common_image = cv2.dilate(common_image, kernel, iterations=1)                common_contour, _ = cv2.findContours(common_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)                if len(common_contour) == 1:self.color_areas[color_area_inx_1].neighbors.append(color_area_inx_2)self.color_areas[color_area_inx_2].neighbors.append(color_area_inx_1)

image

Ищем оптимальный ход


У нас есть вся информация об игровом поле. Приступим к выбору хода. Для этого нам нужен индекс узла и цвета. Количество вариантов хода можно определить формулой:

Варианты ходов = Количество узлов * Количество цветов 1

Для предыдущего игрового поля у нас есть 7*(5-1) = 28 вариантов. Их немного, поэтому мы можем перебрать все ходы и выбрать оптимальный. Определим варианты как матрицу
select_color_weights, в которой строкой будет индекс узла, столбцом индекс цвета и ячейкой вес хода. Нам нужно уменьшить количество узлов до одного, поэтому отдадим приоритет областям, цвет которых уникален на игровом поле и которые исчезнут после хода на них. Дадим +10 к весу ко все строке узла с уникальным цветом. Как часто встречается цвет на игровом поле, мы ранее собрали в self.color_areas_color_count

if self.color_areas_color_count[color_area.color_inx - 1] == 1:   select_color_weight = [x + 10 for x in select_color_weight]

Далее рассмотрим цвета соседних областей. Если у узла есть соседи цвета color_inx, и их количество равно общему количеству данного цвета на игровом поле, назначим +10 к весу ячейки. Это также уберет цвет color_inx с поля.

for color_inx in range(0, len(select_color_weight)):   color_count = select_color_weight[color_inx]   if color_count != 0 and self.color_areas_color_count[color_inx] == color_count:      select_color_weight[color_inx] += 10

Дадим +1 к весу ячейки за каждого соседа одного цвета. То есть если у нас есть 3 красных соседа, красная ячейка получит +3 к весу.

for select_color_weight_inx in color_area.neighbors:   neighbor_color_area = self.color_areas[select_color_weight_inx]   select_color_weight[neighbor_color_area.color_inx - 1] += 1

После сбора всех весов, найдем ход с максимальным весом. Определим к какому узлу и к какому цвету он относится.

max_index = select_color_weights.argmax()self.color_area_inx_next = max_index // self.SELECT_COLOR_COUNTselect_color_next = (max_index % self.SELECT_COLOR_COUNT) + 1self.set_select_color_next(select_color_next)

Полный код для определения оптимального хода.

# class Robot:    # def scan_color_areas(self):        self.color_areas = []        self.color_areas_color_count = [0] * self.SELECT_COLOR_COUNT        image = self.crop_image_by_rectangle(self.screenshot, numpy.array(self.GAME_MAIN_AREA))        for color_inx in range(1, self.SELECT_COLOR_COUNT + 1):            dilate_contours = self.get_dilate_contours(image, color_inx, 10)            for dilate_contour in dilate_contours:                click_point = tuple(                    dilate_contour[dilate_contour[:, :, 1].argmin()].flatten() + [0, int(self.CLICK_AREA)])                self.color_areas_color_count[color_inx - 1] += 1                color_area = self.ColorArea(color_inx, click_point, dilate_contour, [0] * self.SELECT_COLOR_COUNT)                self.color_areas.append(color_area)        blank_image = numpy.zeros_like(image)        blank_image = cv2.cvtColor(blank_image, cv2.COLOR_BGR2GRAY)        for color_area_inx_1 in range(0, len(self.color_areas)):            for color_area_inx_2 in range(color_area_inx_1 + 1, len(self.color_areas)):                color_area_1 = self.color_areas[color_area_inx_1]                color_area_2 = self.color_areas[color_area_inx_2]                if color_area_1.color_inx == color_area_2.color_inx:                    continue                common_image = cv2.drawContours(blank_image.copy(), [color_area_1.contour, color_area_2.contour],                                                -1, (255, 255, 255), cv2.FILLED)                kernel = numpy.ones((15, 15), numpy.uint8)                common_image = cv2.dilate(common_image, kernel, iterations=1)                common_contour, _ = cv2.findContours(common_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)                if len(common_contour) == 1:                    self.color_areas[color_area_inx_1].neighbors.append(color_area_inx_2)                    self.color_areas[color_area_inx_2].neighbors.append(color_area_inx_1)    def analysis_color_areas(self):        select_color_weights = []        for color_area_inx in range(0, len(self.color_areas)):            color_area = self.color_areas[color_area_inx]            select_color_weight = numpy.array([0] * self.SELECT_COLOR_COUNT)            for select_color_weight_inx in color_area.neighbors:                neighbor_color_area = self.color_areas[select_color_weight_inx]                select_color_weight[neighbor_color_area.color_inx - 1] += 1            for color_inx in range(0, len(select_color_weight)):                color_count = select_color_weight[color_inx]                if color_count != 0 and self.color_areas_color_count[color_inx] == color_count:                    select_color_weight[color_inx] += 10            if self.color_areas_color_count[color_area.color_inx - 1] == 1:                select_color_weight = [x + 10 for x in select_color_weight]            color_area.set_select_color_weights(select_color_weight)            select_color_weights.append(select_color_weight)        select_color_weights = numpy.array(select_color_weights)        max_index = select_color_weights.argmax()        self.color_area_inx_next = max_index // self.SELECT_COLOR_COUNT        select_color_next = (max_index % self.SELECT_COLOR_COUNT) + 1        self.set_select_color_next(select_color_next)

Добавим возможность перехода между уровнями и радуемся результату. Бот работает стабильно и проходит игру за одну сессию.


Вывод


Созданный бот не несет никакой практической пользы. Но автор статьи искренне надеется, что подробное описание базовых принципов OpenCV поможет новичкам разобраться с данной библиотекой на начальном этапе.
Источник: habr.com
К списку статей
Опубликовано: 28.10.2020 12:04:04
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Python

Opencv

Программирование

Компьютерное зрение

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru