Ищем цель
Идем на тематический сайт miniclip.com и ищем цель. Выбор пал на цветовую головоломку Coloruid 2 раздела Puzzles, в которой нам необходимо заполнить круглое игровое поле одним цветом за заданное количество ходов.
Выбранным внизу экрана цветом заливается произвольная область, при этом соседние области одного цвета сливаются в единую.
Подготовка
Использовать будем 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)
Цветовая сегментация
Дальше интереснее. Усложним задачу и найдем все символы красного цвета на экране выбора уровней.
По умолчанию, все изображения OpenCV хранит в формате BGR. Для цветовой сегментации больше подходит HSV (Hue, Saturation, Value тон, насыщенность, значение). Ее преимущество перед RGB заключается в том, что HSV отделяет цвет от его насыщенности и яркости. Цветовой тон кодируется одним каналом Hue. Возьмем для примера салатовый прямоугольник и будем постепенно уменьшать его яркость.
В отличии от 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()
Запустим его с нашим скриншотом.
Кликаем по красному цвету и смотрим на полученную маску. Если вывод нас не устраивает выбираем оттенкам красного, увеличивая диапазон и площадь маски. Работа скрипта основана на функции 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
Удаление шума
Полученные контуры содержат много шума от фона. Чтобы убрать его воспользуемся свойством наших цифр. Они состоят из прямоугольников, которые параллельны осям координат. Перебираем все контуры и вписываем каждый в минимальный прямоугольник при помощи 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]]]))
Объединение контуров
Уже лучше. Теперь нам нужно объединить найденные прямоугольники в общий контур символов. Нам понадобится промежуточное изображение. Создадим его при помощи 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)
Оставшийся шум отфильтруем по площади контуров при помощи cv2.contourArea. Убираем все, что занимает меньше 500 пикселей.
digit_contours = [cnt for cnt in digit_contours if cv2.contourArea(cnt) > 500]
Вот теперь отлично. Реализуем все вышеописанное в нашем классе 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
Распознание цифр
Добавим возможность распознания цифр. Зачем нам это нужно?
# 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
Анализируем игровое поле
Пропустим тренировочный уровень, он заскриптован по клику на белом курсоре и приступаем к игре.
Представим игровое поле как сеть. Каждая область цвета будет узлом, который связан с граничащими рядом соседями. Создадим класс 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)
Связываем области
Будем считать области соседями, если расстояние между их контурами в пределах 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)
Ищем оптимальный ход
У нас есть вся информация об игровом поле. Приступим к выбору хода. Для этого нам нужен индекс узла и цвета. Количество вариантов хода можно определить формулой:
Варианты ходов = Количество узлов * Количество цветов 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 поможет новичкам разобраться с данной библиотекой на начальном этапе.